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 3db004829..bb724a97d 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -11684,6 +11684,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 = @@ -11691,7 +11696,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(); @@ -11774,6 +11779,7 @@ impl Game<'_> { pitch, fov_degrees, bob, + spin, sky, distance, detail_distance: (self.streaming.radius * 16) as f32, @@ -14688,6 +14694,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 ddfd0a335..6807d7718 100644 --- a/crates/horizon-client/src/game/render_tick.rs +++ b/crates/horizon-client/src/game/render_tick.rs @@ -214,8 +214,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 = @@ -252,6 +252,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 e5a168d15..beb8b9060 100644 --- a/crates/horizon-client/src/gfx.rs +++ b/crates/horizon-client/src/gfx.rs @@ -219,6 +219,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, @@ -3417,7 +3420,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]);