From 71b1622293ccae7d30a6fc46f67221fea9f81ef8 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 6 May 2026 17:53:34 +0200 Subject: [PATCH] fix(camera): #38 render-interpolate player motion Keep local physics authoritative at the retail 30 Hz MinQuantum, but expose a render-only position that lerps between completed physics ticks for the player mesh and chase-camera target. Network outbound continues to use the discrete physics position. Also make the visually confirmed #47 humanoid close-detail DIDDegrade path default-on, with ACDREAM_RETAIL_CLOSE_DEGRADES=0 left as a diagnostic opt-out. Verification: dotnet build AcDream.slnx -c Debug; focused #38 interpolation tests passed; visual confirmed smooth 2026-05-06. Full dotnet test AcDream.slnx -c Debug --no-build still has the known 8 AcDream.Core.Tests baseline failures. Co-authored-by: Codex --- docs/ISSUES.md | 22 +++-- ...05-06-issue-38-render-interp-pseudocode.md | 98 +++++++++++++++++++ .../Input/PlayerMovementController.cs | 25 +++++ src/AcDream.App/Rendering/GameWindow.cs | 14 +-- .../Input/PlayerMovementControllerTests.cs | 75 ++++++++++++++ 5 files changed, 218 insertions(+), 16 deletions(-) create mode 100644 docs/research/2026-05-06-issue-38-render-interp-pseudocode.md diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 7210cba..7c18f3a 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -643,11 +643,13 @@ primary motion + collision resolution. - Verification: side-by-side vs legacy default in 2-client setup, identical visible behavior. -## #38 — Chase camera + player feel "30 fps" since L.5 physics-tick gate +## #38 — [DONE 2026-05-06 · (this commit)] Chase camera + player feel "30 fps" since L.5 physics-tick gate -**Status:** OPEN +**Status:** DONE **Severity:** MEDIUM (gameplay-feel regression; not a correctness bug) **Filed:** 2026-05-01 +**Closed:** 2026-05-06 +**Commit:** `(this commit)` **Component:** rendering / physics / camera **Description:** User reports that running around in third-person / @@ -704,7 +706,8 @@ collision fixes.) **Acceptance:** - Chase-camera run-around at 60+ FPS feels as smooth as render rate - suggests (no perceptual stepping) + suggests (no perceptual stepping) — user visually confirmed + 2026-05-06. - Network outbound (MoveToState / AutonomousPosition cadence + values) unchanged from current behavior - Collision behavior unchanged (the L.5 wedge / steep-roof scenarios @@ -1360,10 +1363,11 @@ If hypothesis (a) is correct, this issue effectively rolls into **#28** — the --- -## #47 — [DONE 2026-05-06] Humanoid Setup 0x02000001 renders bulky / lacks shape detail vs retail +## #47 — [DONE 2026-05-06 · 0bd9b96] Humanoid Setup 0x02000001 renders bulky / lacks shape detail vs retail -**Status:** DONE (commit pending) +**Status:** DONE **Closed:** 2026-05-06 +**Commit:** `0bd9b96` **Severity:** MEDIUM (cosmetic — characters readable but visibly different from retail) **Filed:** 2026-05-06 **Component:** rendering / mesh / character animation @@ -1382,10 +1386,10 @@ Concrete swaps the resolver now performs: - Heritage variants: `0x010004BF → 0x010017A8`, `0x010004BD → 0x010017A7`, `0x010004B7 → 0x0100179A`, etc. -Fix landed as `GfxObjDegradeResolver`, gated behind -`ACDREAM_RETAIL_CLOSE_DEGRADES=1` and scoped to humanoid setups -(34-part with ≥8 null-sentinel attachment slots). User confirmed -visually 2026-05-06. +Fix landed as `GfxObjDegradeResolver`, default-on and scoped to humanoid +setups (34-part with ≥8 null-sentinel attachment slots). Set +`ACDREAM_RETAIL_CLOSE_DEGRADES=0` only for diagnostic before/after +comparisons. User confirmed visually 2026-05-06. Files: `src/AcDream.Core/Meshing/GfxObjDegradeResolver.cs`, `src/AcDream.App/Rendering/GameWindow.cs` (wiring), 5 unit tests in diff --git a/docs/research/2026-05-06-issue-38-render-interp-pseudocode.md b/docs/research/2026-05-06-issue-38-render-interp-pseudocode.md new file mode 100644 index 0000000..11c9b97 --- /dev/null +++ b/docs/research/2026-05-06-issue-38-render-interp-pseudocode.md @@ -0,0 +1,98 @@ +# Issue #38 render interpolation pseudocode + +## Problem + +Phase L.5 correctly restored retail's `CPhysicsObj::update_object` +physics gate: the body only integrates when accumulated frame time reaches +`PhysicsBody.MinQuantum` (1/30 second). That keeps collision behavior aligned +with retail, but the renderer currently reads `_body.Position` directly. + +At 60+ FPS that means several rendered frames can show the same physics +position, then jump to the next 30 Hz position. Chase camera makes the stepping +obvious because both the player mesh and camera target follow that discrete +physics sample. + +## Evidence + +- Named retail: `CPhysicsObj::update_object` at `0x00515d10` + (`docs/research/named-retail/acclient_2013_pseudo_c.txt:283950`) skips or + consumes time outside the valid quantum window and calls + `CPhysicsObj::UpdateObjectInternal` only for accepted physics quanta. +- ACE mirrors the same constants in `PhysicsGlobals`: `MinQuantum = 1/30`, + `MaxQuantum = 0.1`, `HugeQuantum = 2.0`. +- ACE's `InterpolationManager` / `PositionManager` prove client-style + smoothing is a known AC-family concept, but their queue chases network target + positions. Issue #38 is narrower: local render-only interpolation between the + last two authoritative local physics ticks. +- Glenn Fiedler's "Fix Your Timestep!" describes the canonical fixed-timestep + accumulator plus render interpolation pattern: + https://gafferongames.com/post/fix_your_timestep/ + +## Intentional divergence + +Retail did not need a separate high-FPS render interpolation layer because the +2013 client effectively presented near the same cadence as its physics gate. +acdream renders at modern display rates, so we preserve retail's 30 Hz physics +truth but draw a blended visual position between the last two physics truths. + +## Pseudocode + +State stored by `PlayerMovementController`: + +```text +physicsAccum: leftover render time not yet consumed by physics +prevPhysicsPos: body position at the start of the most recently completed tick +currPhysicsPos: body position at the end of the most recently completed tick +``` + +On spawn, teleport, or any authoritative SetPosition: + +```text +body.Position = newPosition +prevPhysicsPos = newPosition +currPhysicsPos = newPosition +physicsAccum = 0 +``` + +Each Update(dt): + +```text +physicsAccum += dt + +if physicsAccum > HugeQuantum: + physicsAccum = 0 + prevPhysicsPos = body.Position + currPhysicsPos = body.Position + return/render body.Position + +if physicsAccum >= MinQuantum: + oldTickEnd = currPhysicsPos + preIntegratePos = body.Position + + tickDt = min(physicsAccum, MaxQuantum) + calc_acceleration() + UpdatePhysicsInternal(tickDt) + + postIntegratePos = body.Position + resolve collision from preIntegratePos to postIntegratePos + body.Position = resolved physics position + update contact/cell/velocity exactly as before + + prevPhysicsPos = oldTickEnd + currPhysicsPos = body.Position + physicsAccum -= tickDt + +alpha = clamp(physicsAccum / MinQuantum, 0, 1) +renderPosition = lerp(prevPhysicsPos, currPhysicsPos, alpha) +``` + +Important constraints: + +- Never write `renderPosition` back to `_body.Position`. +- `MovementResult.Position` remains the authoritative physics/collision/network + position. +- Add `MovementResult.RenderPosition` for mesh and camera drawing only. +- Outbound `MoveToState` / `AutonomousPosition` keep using + `MovementResult.Position`. +- If no physics tick has completed yet, `prevPhysicsPos == currPhysicsPos`, so + interpolation is a no-op. diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs index a57ccd9..1bc88b8 100644 --- a/src/AcDream.App/Input/PlayerMovementController.cs +++ b/src/AcDream.App/Input/PlayerMovementController.cs @@ -41,6 +41,7 @@ public readonly record struct MovementInput( /// public readonly record struct MovementResult( Vector3 Position, + Vector3 RenderPosition, uint CellId, bool IsOnGround, bool MotionStateChanged, @@ -128,6 +129,7 @@ public sealed class PlayerMovementController public float Yaw { get; set; } public Vector3 Position => _body.Position; + public Vector3 RenderPosition => ComputeRenderPosition(); public uint CellId { get; private set; } /// @@ -213,6 +215,8 @@ public sealed class PlayerMovementController // ACE: PhysicsObj.UpdateObject (Physics.cs). // Named-retail: CPhysicsObj::update_object (acclient_2013_pseudo_c.txt:283950). private float _physicsAccum; + private Vector3 _prevPhysicsPos; + private Vector3 _currPhysicsPos; public PlayerMovementController(PhysicsEngine physics) { @@ -287,6 +291,8 @@ public sealed class PlayerMovementController public void SetPosition(Vector3 pos, uint cellId) { _body.Position = pos; + _prevPhysicsPos = pos; + _currPhysicsPos = pos; CellId = cellId; // Treat as grounded after a server-side position snap. @@ -295,6 +301,13 @@ public sealed class PlayerMovementController // Reset physics clock so any subsequent update_object calls start fresh. _body.LastUpdateTime = 0.0; + _physicsAccum = 0f; + } + + private Vector3 ComputeRenderPosition() + { + float alpha = Math.Clamp(_physicsAccum / PhysicsBody.MinQuantum, 0f, 1f); + return Vector3.Lerp(_prevPhysicsPos, _currPhysicsPos, alpha); } public MovementResult Update(float dt, MovementInput input) @@ -306,6 +319,7 @@ public sealed class PlayerMovementController { return new MovementResult( Position: Position, + RenderPosition: RenderPosition, CellId: CellId, IsOnGround: _body.OnWalkable, MotionStateChanged: false, @@ -524,12 +538,16 @@ public sealed class PlayerMovementController // accumulated dt) when the threshold is reached. See _physicsAccum // declaration for the full retail trace evidence. var preIntegratePos = _body.Position; + bool physicsTickRan = false; + Vector3 oldTickEndPos = _currPhysicsPos; _physicsAccum += dt; if (_physicsAccum > PhysicsBody.HugeQuantum) { // Stale frame (debugger break, GC pause). Discard accumulated dt. _physicsAccum = 0f; + _prevPhysicsPos = _body.Position; + _currPhysicsPos = _body.Position; } else if (_physicsAccum >= PhysicsBody.MinQuantum) { @@ -539,6 +557,7 @@ public sealed class PlayerMovementController _body.calc_acceleration(); _body.UpdatePhysicsInternal(tickDt); _physicsAccum -= tickDt; + physicsTickRan = true; } // Else: dt below MinQuantum threshold — skip integration. Position // and velocity remain unchanged; Resolve below runs as a zero-distance @@ -591,6 +610,11 @@ public sealed class PlayerMovementController // Apply resolved position. _body.Position = resolveResult.Position; + if (physicsTickRan) + { + _prevPhysicsPos = oldTickEndPos; + _currPhysicsPos = _body.Position; + } // L.3a (2026-04-30): retail wall-bounce / velocity reflection. // @@ -874,6 +898,7 @@ public sealed class PlayerMovementController return new MovementResult( Position: Position, + RenderPosition: RenderPosition, CellId: CellId, IsOnGround: _body.OnWalkable, MotionStateChanged: changed, diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 21e72ea..6e66647 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -173,15 +173,15 @@ public sealed class GameWindow : IDisposable private static readonly int s_hidePartIndex = int.TryParse(Environment.GetEnvironmentVariable("ACDREAM_HIDE_PART"), out var hp) ? hp : -1; - // Issue #47 — opt in to retail's close-detail GfxObj selection on + // Issue #47 — use retail's close-detail GfxObj selection on // humanoid setups. When enabled, every per-part GfxObj id (after // server AnimPartChanges are applied) is replaced with Degrades[0] // from its DIDDegrade table when present. See GfxObjDegradeResolver - // for the full retail-decomp citation. Off by default while the fix - // bakes; flip to default-on once we've confirmed no scenery/setup - // regressions. + // for the full retail-decomp citation. Default-on after visual + // confirmation; set ACDREAM_RETAIL_CLOSE_DEGRADES=0 only for + // diagnostic before/after comparisons. private static readonly bool s_retailCloseDegrades = - string.Equals(Environment.GetEnvironmentVariable("ACDREAM_RETAIL_CLOSE_DEGRADES"), "1", StringComparison.Ordinal); + !string.Equals(Environment.GetEnvironmentVariable("ACDREAM_RETAIL_CLOSE_DEGRADES"), "0", StringComparison.Ordinal); /// /// Issue #47 humanoid-setup detector. Matches Aluvian Male @@ -5728,7 +5728,7 @@ public sealed class GameWindow : IDisposable // the physics-resolved location each frame. if (_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe)) { - pe.Position = result.Position; + pe.Position = result.RenderPosition; pe.Rotation = System.Numerics.Quaternion.CreateFromAxisAngle( System.Numerics.Vector3.UnitZ, _playerController.Yaw - MathF.PI / 2f); @@ -5750,7 +5750,7 @@ public sealed class GameWindow : IDisposable // 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, + _chaseCamera.Update(result.RenderPosition, _playerController.Yaw, isOnGround: result.IsOnGround, dt: (float)dt); diff --git a/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs b/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs index fe1e859..8a09b79 100644 --- a/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs +++ b/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs @@ -49,6 +49,81 @@ public class PlayerMovementControllerTests Assert.True(result.Position.X > 96f + 2f, $"X={result.Position.X} should have moved forward"); } + [Fact] + public void Update_SubQuantumFrame_InterpolatesRenderPositionWithoutAdvancingPhysicsPosition() + { + var engine = MakeFlatEngine(); + var controller = new PlayerMovementController(engine); + var start = new Vector3(96f, 96f, 50f); + controller.SetPosition(start, 0x0001); + controller.Yaw = 0f; + + var firstTick = controller.Update(PhysicsBody.MinQuantum, new MovementInput(Forward: true)); + Assert.True(firstTick.Position.X > start.X, "Physics tick should advance the authoritative body position"); + Assert.Equal(start.X, firstTick.RenderPosition.X, precision: 4); + + var halfFrame = controller.Update(PhysicsBody.MinQuantum * 0.5f, new MovementInput(Forward: true)); + + Assert.Equal(firstTick.Position.X, halfFrame.Position.X, precision: 4); + Assert.True(halfFrame.RenderPosition.X > start.X, "Render position should move between physics ticks"); + Assert.True(halfFrame.RenderPosition.X < firstTick.Position.X, + $"Render X={halfFrame.RenderPosition.X} should stay between {start.X} and {firstTick.Position.X}"); + + float expectedMidpoint = start.X + ((firstTick.Position.X - start.X) * 0.5f); + Assert.Equal(expectedMidpoint, halfFrame.RenderPosition.X, precision: 3); + } + + [Fact] + public void SetPosition_ResnapsRenderInterpolationEndpoints() + { + var engine = MakeFlatEngine(); + var controller = new PlayerMovementController(engine); + controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001); + controller.Yaw = 0f; + + controller.Update(PhysicsBody.MinQuantum, new MovementInput(Forward: true)); + controller.Update(PhysicsBody.MinQuantum * 0.5f, new MovementInput(Forward: true)); + + var snapped = new Vector3(120f, 80f, 50f); + controller.SetPosition(snapped, 0x0001); + var result = controller.Update(PhysicsBody.MinQuantum * 0.5f, new MovementInput()); + + Assert.Equal(snapped, result.Position); + Assert.Equal(snapped, result.RenderPosition); + } + + [Fact] + public void Update_HugeQuantumDiscard_ResnapsRenderInterpolationEndpoints() + { + var engine = MakeFlatEngine(); + var controller = new PlayerMovementController(engine); + controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001); + controller.Yaw = 0f; + + var moved = controller.Update(PhysicsBody.MinQuantum, new MovementInput(Forward: true)); + var stale = controller.Update(PhysicsBody.HugeQuantum + 0.1f, new MovementInput(Forward: true)); + + Assert.Equal(moved.Position.X, stale.Position.X, precision: 4); + Assert.Equal(stale.Position, stale.RenderPosition); + } + + [Fact] + public void Update_LeftoverAboveMinQuantum_ClampsRenderAlphaToCurrentPhysicsPosition() + { + var engine = MakeFlatEngine(); + var controller = new PlayerMovementController(engine); + controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001); + controller.Yaw = 0f; + + var result = controller.Update( + PhysicsBody.MaxQuantum + PhysicsBody.MinQuantum, + new MovementInput(Forward: true)); + + Assert.Equal(result.Position.X, result.RenderPosition.X, precision: 4); + Assert.Equal(result.Position.Y, result.RenderPosition.Y, precision: 4); + Assert.Equal(result.Position.Z, result.RenderPosition.Z, precision: 4); + } + [Fact] public void Update_RunForward_MoveFasterThanWalk() {