From 05ce090346192bf50636d8f854eac47e8e3bb707 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 26 Apr 2026 18:00:58 +0200 Subject: [PATCH] fix(camera): smooth chase-camera Z follow so the jump arc is visible on screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diagnostic from K-fix10 confirmed our local jump physics is mathematically perfect — every full-charge jump produces formulaPeak = actualPeakDz = vz²/19.6 to four-digit precision (3.11 m for Jump skill 208). Yet the user observed retail clients seeing the SAME character jump much higher than ACdream sees of itself. Root cause: ChaseCamera tracked player.Z 1:1. When the player rises 3 m the camera rises 3 m too — the player's screen position never changes during the arc, so the jump is visually invisible. Retail's chase camera lags the Z follow, so an observer sees the player visibly rise on screen. Fix: low-pass filter the camera's Z target. ChaseCamera.Update gains a dt parameter and an exponential smoother: alpha = 1 - exp(-dt / ZFollowTimeConstant) smoothedZ += (player.Z - smoothedZ) * alpha ZFollowTimeConstant defaults to 0.15 s — slow enough that a ~1 s jump arc shows up clearly on screen, fast enough that slope walking still feels glued. The look-at point still uses the raw player Z so the camera tilts up to keep the airborne character in frame. Drive-by: stripped K-fix10 jump diagnostic logging now that the math has been confirmed correct. Tests stay 1222 green. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Input/PlayerMovementController.cs | 28 ------------- src/AcDream.App/Rendering/ChaseCamera.cs | 41 +++++++++++++++++-- src/AcDream.App/Rendering/GameWindow.cs | 7 +++- 3 files changed, 43 insertions(+), 33 deletions(-) diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs index 4326599..d3c25c4 100644 --- a/src/AcDream.App/Input/PlayerMovementController.cs +++ b/src/AcDream.App/Input/PlayerMovementController.cs @@ -130,12 +130,6 @@ public sealed class PlayerMovementController // Jump charge state. private bool _jumpCharging; private float _jumpExtent; - // K-fix10 diag (2026-04-26): track airborne arc height for the - // jump-too-low investigation. Strip after fix. - private bool _jumpDiagSampling; - private float _jumpDiagStartZ; - private float _jumpDiagPeakZ; - private float _jumpDiagSentVz; // K-fix6 (2026-04-26): retail's PowerBar charge constant for jump is // not legible in the named decomp (the divisor was clobbered in // GetPowerBarLevel's FPU stack reordering at FUN_0056ade0). 2.0/s @@ -397,16 +391,6 @@ public sealed class PlayerMovementController _motion.LeaveGround(); outJumpExtent = _jumpExtent; outJumpVelocity = _body.Velocity; // capture after LeaveGround applies it - - _jumpDiagSampling = true; - _jumpDiagStartZ = _body.Position.Z; - _jumpDiagPeakZ = _body.Position.Z; - _jumpDiagSentVz = _body.Velocity.Z; - Console.WriteLine( - $"[jump.send] extent={_jumpExtent:F3} sentVz={_body.Velocity.Z:F3} " + - $"sentVel=({_body.Velocity.X:F2},{_body.Velocity.Y:F2},{_body.Velocity.Z:F2}) " + - $"formulaPeak={_body.Velocity.Z * _body.Velocity.Z / 19.6f:F2}m " + - $"startZ={_body.Position.Z:F2}"); } _jumpCharging = false; _jumpExtent = 0f; @@ -451,14 +435,6 @@ public sealed class PlayerMovementController { _motion.HitGround(); justLanded = true; - if (_jumpDiagSampling) - { - Console.WriteLine( - $"[jump.peak] sentVz={_jumpDiagSentVz:F3} formulaPeak={_jumpDiagSentVz * _jumpDiagSentVz / 19.6f:F2}m " + - $"actualPeakDz={(_jumpDiagPeakZ - _jumpDiagStartZ):F2}m " + - $"startZ={_jumpDiagStartZ:F2} peakZ={_jumpDiagPeakZ:F2} landZ={_body.Position.Z:F2}"); - _jumpDiagSampling = false; - } } } else @@ -475,10 +451,6 @@ public sealed class PlayerMovementController _body.calc_acceleration(); } - // K-fix10 diag: peak Z tracking — placed AFTER the resolve branch - // so it doesn't disrupt control flow. - if (_jumpDiagSampling && _body.Position.Z > _jumpDiagPeakZ) - _jumpDiagPeakZ = _body.Position.Z; _wasAirborneLastFrame = !_body.OnWalkable; CellId = resolveResult.CellId; diff --git a/src/AcDream.App/Rendering/ChaseCamera.cs b/src/AcDream.App/Rendering/ChaseCamera.cs index c778ff6..174df3a 100644 --- a/src/AcDream.App/Rendering/ChaseCamera.cs +++ b/src/AcDream.App/Rendering/ChaseCamera.cs @@ -46,6 +46,22 @@ public sealed class ChaseCamera : ICamera private float _playerYaw; private Vector3 _lookAt; + // K-fix11 (2026-04-26): smoothed Z follow. Without this the camera + // tracks player.Z 1:1 — during a 3 m jump the camera also rises 3 m + // and the player's screen position never changes, making the jump + // visually invisible. A retail observer sees ACdream's jump arc + // clearly because retail's chase camera lags the Z follow. Mirror + // that: the camera Z target uses an exponential low-pass filter + // toward player.Z with a ~150 ms time constant. Initialised on + // first Update so spawn doesn't snap. + private float _smoothedPlayerZ; + private bool _smoothedZInitialised; + /// Time constant for the camera-Z smoothing filter (seconds). + /// Smaller = camera catches up faster (less perceptible jump arc on + /// screen). Larger = jumps look bigger but camera lags on slope + /// transitions. 0.15 = retail-feel feedback for a ~0.5–1.0 s jump. + public float ZFollowTimeConstant { get; set; } = 0.15f; + public Matrix4x4 View => Matrix4x4.CreateLookAt(Position, _lookAt, Vector3.UnitZ); @@ -53,11 +69,30 @@ public sealed class ChaseCamera : ICamera Matrix4x4.CreatePerspectiveFieldOfView(FovY, Aspect, 1f, 5000f); /// - /// Update the camera position to follow the player. + /// Update the camera position to follow the player. + /// drives the Z-smoothing filter. /// - public void Update(Vector3 playerPosition, float playerYaw) + public void Update(Vector3 playerPosition, float playerYaw, float dt = 1f / 60f) { _playerYaw = playerYaw; + + // K-fix11: smooth the camera's Z target using an exponential + // low-pass filter. Camera Z lags player Z so airborne motion + // visibly shows up on screen. + if (!_smoothedZInitialised) + { + _smoothedPlayerZ = playerPosition.Z; + _smoothedZInitialised = true; + } + else + { + float alpha = 1f - MathF.Exp(-MathF.Max(dt, 1e-4f) / MathF.Max(ZFollowTimeConstant, 1e-4f)); + _smoothedPlayerZ += (playerPosition.Z - _smoothedPlayerZ) * alpha; + } + + // Look-at uses the actual player Z so the camera always points + // at the character — when player rises above the lagged camera + // the camera tilts up slightly to keep them in frame. _lookAt = playerPosition + new Vector3(0f, 0f, EyeHeight); // Camera offset: behind the player (-forward direction) plus any @@ -72,7 +107,7 @@ public sealed class ChaseCamera : ICamera Position = new Vector3( playerPosition.X - forwardX * horizontalDist, playerPosition.Y - forwardY * horizontalDist, - playerPosition.Z + EyeHeight + verticalDist); + _smoothedPlayerZ + EyeHeight + verticalDist); // ← uses smoothed Z, not raw } /// diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 1f6e726..a456fd9 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -3987,8 +3987,11 @@ public sealed class GameWindow : IDisposable _worldState.RelocateEntity(pe, currentLb); } - // Update chase camera. - _chaseCamera.Update(result.Position, _playerController.Yaw); + // Update chase camera. K-fix11 (2026-04-26): pass dt so the + // Z-smoothing low-pass filter tracks correctly on variable + // frame rates — without this the camera tracks player.Z 1:1 + // and jumps look invisible (camera rises with player). + _chaseCamera.Update(result.Position, _playerController.Yaw, (float)dt); // Send outbound movement messages to the live server. if (_liveSession is not null)