diff --git a/src/AcDream.App/Rendering/ChaseCamera.cs b/src/AcDream.App/Rendering/ChaseCamera.cs
index 174df3a..96712cb 100644
--- a/src/AcDream.App/Rendering/ChaseCamera.cs
+++ b/src/AcDream.App/Rendering/ChaseCamera.cs
@@ -46,21 +46,15 @@ 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;
+ // K-fix12 (2026-04-26): retail-feel jump camera. The camera Z is
+ // pinned to the LAST GROUNDED Z while the player is airborne — the
+ // character rises above the camera on screen, visually matching
+ // retail's "you can see yourself jump" feedback. Walking on the
+ // ground tracks Z directly (no lag on hill transitions); falling
+ // catches up immediately so we don't end up below ground when
+ // landing in a pit. Only the upward-while-airborne case is pinned.
+ private float _trackedZ;
+ private bool _trackedZInitialised;
public Matrix4x4 View =>
Matrix4x4.CreateLookAt(Position, _lookAt, Vector3.UnitZ);
@@ -69,30 +63,37 @@ public sealed class ChaseCamera : ICamera
Matrix4x4.CreatePerspectiveFieldOfView(FovY, Aspect, 1f, 5000f);
///
- /// Update the camera position to follow the player.
- /// drives the Z-smoothing filter.
+ /// Update the camera position to follow the player.
+ /// drives the airborne-pin behavior: while airborne and rising, the
+ /// camera stays at last-grounded Z so the jump is visible on screen.
///
- public void Update(Vector3 playerPosition, float playerYaw, float dt = 1f / 60f)
+ public void Update(Vector3 playerPosition, float playerYaw, bool isOnGround = true, 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)
+ // K-fix12: track the camera's reference Z.
+ // - On ground: snap directly to player.Z (smooth slope walking).
+ // - Airborne + rising: stay pinned (player visibly rises above camera).
+ // - Airborne + falling below tracked Z: catch up so we don't lag below
+ // ground when landing somewhere lower (a cliff / hole).
+ if (!_trackedZInitialised)
{
- _smoothedPlayerZ = playerPosition.Z;
- _smoothedZInitialised = true;
+ _trackedZ = playerPosition.Z;
+ _trackedZInitialised = true;
}
- else
+ else if (isOnGround)
{
- float alpha = 1f - MathF.Exp(-MathF.Max(dt, 1e-4f) / MathF.Max(ZFollowTimeConstant, 1e-4f));
- _smoothedPlayerZ += (playerPosition.Z - _smoothedPlayerZ) * alpha;
+ _trackedZ = playerPosition.Z;
}
+ else if (playerPosition.Z < _trackedZ)
+ {
+ _trackedZ = playerPosition.Z; // catch up to falls / drops
+ }
+ // else: airborne and rising — keep _trackedZ pinned.
// 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.
+ // at the character — when the player rises above the pinned
+ // camera the look-at tilts up to keep them centered in frame.
_lookAt = playerPosition + new Vector3(0f, 0f, EyeHeight);
// Camera offset: behind the player (-forward direction) plus any
@@ -107,7 +108,7 @@ public sealed class ChaseCamera : ICamera
Position = new Vector3(
playerPosition.X - forwardX * horizontalDist,
playerPosition.Y - forwardY * horizontalDist,
- _smoothedPlayerZ + EyeHeight + verticalDist); // ← uses smoothed Z, not raw
+ _trackedZ + EyeHeight + verticalDist); // ← uses tracked Z (pinned to ground while airborne)
}
///
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index a456fd9..92a7913 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -3987,11 +3987,16 @@ public sealed class GameWindow : IDisposable
_worldState.RelocateEntity(pe, currentLb);
}
- // 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);
+ // Update chase camera. K-fix12 (2026-04-26): pass isOnGround
+ // so the camera pins its Z to last-grounded while the
+ // player is airborne — without this the camera follows
+ // player.Z 1:1 during a jump and the player's screen
+ // position never changes. With the pin: player visibly
+ // rises above the camera, matching retail "you can see
+ // yourself jump" feedback.
+ _chaseCamera.Update(result.Position, _playerController.Yaw,
+ isOnGround: result.IsOnGround,
+ dt: (float)dt);
// Send outbound movement messages to the live server.
if (_liveSession is not null)