From 7f6e8b634e2699e665e88c3f373477af979a7d8d Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Tue, 27 Oct 2026 20:28:15 +0100 Subject: [PATCH] Spin the level view and draw the Nausea swirl for the player Nausea never reached the camera or the HUD: the swirl input was hard-coded to 0 and the level projection had no portal or Nausea spin. Add vanilla's spin (max of portal and Nausea intensity, scaled by Distortion Effects squared) and the Nausea blend fade (150 in, 20 out). Vanilla: GameRenderer.renderLevel, LocalPlayer.tickSpinningEffect, MobEffects NAUSEA blend. --- crates/horizon-client/src/camera.rs | 21 ++++++++++++ crates/horizon-client/src/effects.rs | 4 +++ crates/horizon-client/src/game.rs | 9 +++++- crates/horizon-client/src/game/fov.rs | 26 ++++++++++++++- crates/horizon-client/src/game/render_tick.rs | 10 ++++-- crates/horizon-client/src/gfx.rs | 5 ++- crates/horizon-client/src/gfx/native_post.rs | 5 +-- .../horizon-client/src/gfx/pack_uniforms.rs | 3 +- crates/horizon-client/src/screen_effects.rs | 32 +++++++++++++++++++ 9 files changed, 107 insertions(+), 8 deletions(-) diff --git a/crates/horizon-client/src/camera.rs b/crates/horizon-client/src/camera.rs index 7da08f2c8..75cbc025e 100644 --- a/crates/horizon-client/src/camera.rs +++ b/crates/horizon-client/src/camera.rs @@ -772,6 +772,27 @@ pub fn bob_matrix(state: &BobState, damage_tilt_strength: f64, bob_view_option: pose } +/// The spinning effect `GameRenderer.renderLevel` multiplies into the level's +/// projection after the bob: a skew about a tilted axis, from a portal's or +/// Nausea's intensity (already scaled by Distortion Effects) and the angle in +/// degrees. The first-person hand keeps its own projection without it. +pub fn spinning_effect_matrix(intensity: f32, angle_degrees: f32) -> glam::Mat4 { + if intensity <= 0.0 { + return glam::Mat4::IDENTITY; + } + let mut skew = 5.0 / (intensity * intensity + 5.0) - intensity * 0.04; + skew *= skew; + let axis = glam::Vec3::new( + 0.0, + std::f32::consts::SQRT_2 / 2.0, + std::f32::consts::SQRT_2 / 2.0, + ); + let angle = angle_degrees * DEG_TO_RAD; + glam::Mat4::from_axis_angle(axis, angle) + * glam::Mat4::from_scale(glam::Vec3::new(1.0 / skew, 1.0, 1.0)) + * glam::Mat4::from_axis_angle(axis, -angle) +} + #[cfg(test)] mod tests { #[test] diff --git a/crates/horizon-client/src/effects.rs b/crates/horizon-client/src/effects.rs index 5365a9d5c..7378de7d8 100644 --- a/crates/horizon-client/src/effects.rs +++ b/crates/horizon-client/src/effects.rs @@ -29,6 +29,10 @@ impl EffectInstance { fn infinite(&self) -> bool { self.duration == -1 } + /// `MobEffectInstance.endsWithin`. + pub fn ends_within(&self, ticks: i32) -> bool { + !self.infinite() && self.duration <= ticks + } fn shorter_than(&self, other: &Self) -> bool { !self.infinite() && (self.duration < other.duration || other.infinite()) } diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 8e591cf3b..052fb4fa0 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -11659,6 +11659,11 @@ impl Game<'_> { } else { self.bob_matrix(camera_alpha as f32) }; + let spin = if title { + glam::Mat4::IDENTITY + } else { + self.spinning_matrix(camera_alpha as f32) + }; let terrain_distance = self.terrain_distance(); let distance = (terrain_distance * 16) as f32; let depth_far = @@ -11666,7 +11671,7 @@ impl Game<'_> { self.view_frustum = self .renderer .as_ref() - .map(|r| r.camera_frustum(fov_degrees, yaw, pitch, bob, depth_far)); + .map(|r| r.camera_frustum(fov_degrees, yaw, pitch, bob * spin, depth_far)); // Keep the buffer local while builders need mutable access to the game. let mut entities = std::mem::take(&mut self.entity_vertices); entities.clear(); @@ -11749,6 +11754,7 @@ impl Game<'_> { pitch, fov_degrees, bob, + spin, sky, distance, detail_distance: (self.streaming.radius * 16) as f32, @@ -14648,6 +14654,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> { pitch: 10.0, fov_degrees: 85.0, bob: glam::Mat4::IDENTITY, + spin: glam::Mat4::IDENTITY, sky: horizon_client::environment::Sky::default(), distance: 12.0, detail_distance: 12.0, diff --git a/crates/horizon-client/src/game/fov.rs b/crates/horizon-client/src/game/fov.rs index 364f89189..bda915aac 100644 --- a/crates/horizon-client/src/game/fov.rs +++ b/crates/horizon-client/src/game/fov.rs @@ -1,11 +1,13 @@ //! Field of view (`Camera.tickFov`, `calculateFov`, //! `AbstractClientPlayer.getFieldOfViewModifier`), view and hurt bobbing -//! (`GameRenderer.bobHurt`, `bobView`) and the spyglass (`SpyglassItem`, +//! (`GameRenderer.bobHurt`, `bobView`), the portal and Nausea spin +//! (`GameRenderer.renderLevel`) and the spyglass (`SpyglassItem`, //! `Hud.extractSpyglassOverlay`). The arithmetic is `horizon_client::camera`. use super::Game; use horizon_client::{ camera::{ BobState, CameraFluid, FovPlayer, HurtBob, UseItem, bob_matrix, field_of_view_modifier, + spinning_effect_matrix, }, fog::{FogType, fog_type}, hud::HudBatch, @@ -26,6 +28,28 @@ impl Game<'_> { pub(super) fn fov_effect_scale(&self) -> f32 { self.settings.file.double("fovEffectScale") as f32 } + /// `options.screenEffectScale` (Distortion Effects). + pub(super) fn screen_effect_scale(&self) -> f32 { + self.settings.file.double("screenEffectScale") as f32 + } + /// `LocalPlayer.getEffectBlendFactor(NAUSEA, partial)`: 0 without the effect. + pub(super) fn nausea_blend(&self, partial: f32) -> f32 { + if self.potions.effects.get("nausea").is_none() { + return 0.0; + } + let fx = &self.screen_fx; + fx.o_nausea + partial * (fx.nausea - fx.o_nausea) + } + /// The spin `GameRenderer.renderLevel` multiplies into the level's + /// projection: the larger of the portal and Nausea intensities, scaled by + /// Distortion Effects squared, turning by `LocalPlayer.getSpinningEffectAngle`. + pub(super) fn spinning_matrix(&self, partial: f32) -> glam::Mat4 { + let fx = &self.screen_fx; + let portal = fx.o_portal + partial * (fx.portal - fx.o_portal); + let scale = self.screen_effect_scale(); + let intensity = portal.max(self.nausea_blend(partial)) * (scale * scale); + spinning_effect_matrix(intensity, fx.spinning_time + partial * fx.spinning_speed) + } /// What `AbstractClientPlayer.getFieldOfViewModifier` reads. fn fov_player(&self) -> Option { let p = self.player.as_ref()?; diff --git a/crates/horizon-client/src/game/render_tick.rs b/crates/horizon-client/src/game/render_tick.rs index 26a5a81cd..69104531d 100644 --- a/crates/horizon-client/src/game/render_tick.rs +++ b/crates/horizon-client/src/game/render_tick.rs @@ -209,8 +209,8 @@ impl Game<'_> { percent_frozen: p.percent_frozen(), partial, portal: [fx.o_portal, fx.portal], - nausea: 0.0, - screen_effect_scale: 1.0, + nausea: self.nausea_blend(partial), + screen_effect_scale: self.screen_effect_scale(), }; let mut scope = self.scope_scale; let gui_overlays = @@ -247,6 +247,12 @@ impl Game<'_> { let brightness = self.eye_brightness(e); let inside_portal = self.client_portal_effect(); self.screen_fx.tick(brightness, inside_portal); + let nausea = self + .potions + .effects + .get("nausea") + .map(|e| !e.ends_within(60)); + self.screen_fx.tick_spinning(nausea); // LocalPlayer water vision and AtmosphericFogEnvironment rain fog. let in_water = fog_type(&self.world, self.res.facts(), e, self.forward()) == FogType::Water; diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs index ad1fd0561..580ee9ec5 100644 --- a/crates/horizon-client/src/gfx.rs +++ b/crates/horizon-client/src/gfx.rs @@ -222,6 +222,9 @@ pub struct Frame<'a> { /// The `GameRenderer.bobHurt`/`bobView` pose multiplied into the /// projection (`crate::camera::bob_matrix`). pub bob: Mat4, + /// The spinning effect multiplied into the level's projection only + /// (`crate::camera::spinning_effect_matrix`), not the hand's. + pub spin: Mat4, pub sky: Sky, /// Render distance in blocks (fog end). pub distance: f32, @@ -3418,7 +3421,7 @@ impl Renderer { frame.fov_degrees, frame.yaw, frame.pitch, - frame.bob, + frame.bob * frame.spin, frame.depth_far, ) } diff --git a/crates/horizon-client/src/gfx/native_post.rs b/crates/horizon-client/src/gfx/native_post.rs index 89515f5db..bf4293157 100644 --- a/crates/horizon-client/src/gfx/native_post.rs +++ b/crates/horizon-client/src/gfx/native_post.rs @@ -361,8 +361,9 @@ impl NativePost { pub fn frame_inputs(frame: &Frame, size: [u32; 2], seconds: f32, index: u32) -> FrameInputs { let aspect = size[0] as f32 / size[1].max(1) as f32; let view = crate::camera::view_matrix(frame.yaw, frame.pitch); - let projection = - super::world_projection(frame.fov_degrees, aspect, frame.depth_far) * frame.bob; + let projection = super::world_projection(frame.fov_degrees, aspect, frame.depth_far) + * frame.bob + * frame.spin; // A celestial body's direction at `angle` degrees (SkyRenderer: Y -90°, then X by the // angle, of straight up): overhead at 0, rising in the east (+x). let body = |angle: f32| { diff --git a/crates/horizon-client/src/gfx/pack_uniforms.rs b/crates/horizon-client/src/gfx/pack_uniforms.rs index 8f5f1a2de..4da492b00 100644 --- a/crates/horizon-client/src/gfx/pack_uniforms.rs +++ b/crates/horizon-client/src/gfx/pack_uniforms.rs @@ -287,7 +287,8 @@ impl FrameUniforms { aspect, 0.05, frame.depth_far, - ) * frame.bob; + ) * frame.bob + * frame.spin; let camera = frame.camera.as_vec3(); let (previous_camera, previous_view, previous_projection) = self.previous diff --git a/crates/horizon-client/src/screen_effects.rs b/crates/horizon-client/src/screen_effects.rs index 269a534e7..996f3f9c2 100644 --- a/crates/horizon-client/src/screen_effects.rs +++ b/crates/horizon-client/src/screen_effects.rs @@ -69,6 +69,12 @@ pub struct ScreenEffects { pub vignette_brightness: f32, pub portal: f32, pub o_portal: f32, + /// Nausea's `MobEffectInstance.BlendState` factor and the previous tick's. + pub nausea: f32, + pub o_nausea: f32, + /// `LocalPlayer.spinningEffectTime` and `spinningEffectSpeed` (degrees). + pub spinning_time: f32, + pub spinning_speed: f32, } impl Default for ScreenEffects { fn default() -> Self { @@ -76,6 +82,10 @@ impl Default for ScreenEffects { vignette_brightness: 1.0, portal: 0.0, o_portal: 0.0, + nausea: 0.0, + o_nausea: 0.0, + spinning_time: 0.0, + spinning_speed: 0.0, } } } @@ -105,6 +115,28 @@ impl ScreenEffects { }; self.portal = (self.portal + step).clamp(0.0, 1.0); } + /// `LocalPlayer.tickSpinningEffect` and Nausea's blend. `nausea` is `None` + /// without the effect, else whether it is past its blend-out point + /// (`!endsWithin(60)`, `MobEffects.NAUSEA`). + pub fn tick_spinning(&mut self, nausea: Option) { + self.o_nausea = self.nausea; + match nausea { + // BlendState.tick: fades in over 150 ticks and out over 20. + Some(active) => { + let (target, duration) = if active { (1.0, 150.0) } else { (0.0, 20.0) }; + self.nausea += (target - self.nausea).clamp(-1.0 / duration, 1.0 / duration); + } + // The blend state belongs to the instance, so a new one starts at 0. + None => self.nausea = 0.0, + } + if self.portal > 0.0 || self.nausea > 0.0 { + self.spinning_speed = + (self.portal * 20.0 + self.nausea * 7.0) / (self.portal + self.nausea); + self.spinning_time += self.spinning_speed; + } else { + self.spinning_speed = 0.0; + } + } pub fn build(&self, input: &ScreenInputs) -> Vec { let mut out = Vec::new(); let sprite = |name: &str| input.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);