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 <codex@openai.com>
This commit is contained in:
Erik 2026-05-06 17:53:34 +02:00
parent e3d8a44c48
commit 71b1622293
5 changed files with 218 additions and 16 deletions

View file

@ -643,11 +643,13 @@ primary motion + collision resolution.
- Verification: side-by-side vs legacy default in 2-client setup, - Verification: side-by-side vs legacy default in 2-client setup,
identical visible behavior. 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) **Severity:** MEDIUM (gameplay-feel regression; not a correctness bug)
**Filed:** 2026-05-01 **Filed:** 2026-05-01
**Closed:** 2026-05-06
**Commit:** `(this commit)`
**Component:** rendering / physics / camera **Component:** rendering / physics / camera
**Description:** User reports that running around in third-person / **Description:** User reports that running around in third-person /
@ -704,7 +706,8 @@ collision fixes.)
**Acceptance:** **Acceptance:**
- Chase-camera run-around at 60+ FPS feels as smooth as render rate - 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) - Network outbound (MoveToState / AutonomousPosition cadence + values)
unchanged from current behavior unchanged from current behavior
- Collision behavior unchanged (the L.5 wedge / steep-roof scenarios - 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 **Closed:** 2026-05-06
**Commit:** `0bd9b96`
**Severity:** MEDIUM (cosmetic — characters readable but visibly different from retail) **Severity:** MEDIUM (cosmetic — characters readable but visibly different from retail)
**Filed:** 2026-05-06 **Filed:** 2026-05-06
**Component:** rendering / mesh / character animation **Component:** rendering / mesh / character animation
@ -1382,10 +1386,10 @@ Concrete swaps the resolver now performs:
- Heritage variants: `0x010004BF → 0x010017A8`, `0x010004BD → 0x010017A7`, - Heritage variants: `0x010004BF → 0x010017A8`, `0x010004BD → 0x010017A7`,
`0x010004B7 → 0x0100179A`, etc. `0x010004B7 → 0x0100179A`, etc.
Fix landed as `GfxObjDegradeResolver`, gated behind Fix landed as `GfxObjDegradeResolver`, default-on and scoped to humanoid
`ACDREAM_RETAIL_CLOSE_DEGRADES=1` and scoped to humanoid setups setups (34-part with ≥8 null-sentinel attachment slots). Set
(34-part with ≥8 null-sentinel attachment slots). User confirmed `ACDREAM_RETAIL_CLOSE_DEGRADES=0` only for diagnostic before/after
visually 2026-05-06. comparisons. User confirmed visually 2026-05-06.
Files: `src/AcDream.Core/Meshing/GfxObjDegradeResolver.cs`, Files: `src/AcDream.Core/Meshing/GfxObjDegradeResolver.cs`,
`src/AcDream.App/Rendering/GameWindow.cs` (wiring), 5 unit tests in `src/AcDream.App/Rendering/GameWindow.cs` (wiring), 5 unit tests in

View file

@ -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.

View file

@ -41,6 +41,7 @@ public readonly record struct MovementInput(
/// </summary> /// </summary>
public readonly record struct MovementResult( public readonly record struct MovementResult(
Vector3 Position, Vector3 Position,
Vector3 RenderPosition,
uint CellId, uint CellId,
bool IsOnGround, bool IsOnGround,
bool MotionStateChanged, bool MotionStateChanged,
@ -128,6 +129,7 @@ public sealed class PlayerMovementController
public float Yaw { get; set; } public float Yaw { get; set; }
public Vector3 Position => _body.Position; public Vector3 Position => _body.Position;
public Vector3 RenderPosition => ComputeRenderPosition();
public uint CellId { get; private set; } public uint CellId { get; private set; }
/// <summary> /// <summary>
@ -213,6 +215,8 @@ public sealed class PlayerMovementController
// ACE: PhysicsObj.UpdateObject (Physics.cs). // ACE: PhysicsObj.UpdateObject (Physics.cs).
// Named-retail: CPhysicsObj::update_object (acclient_2013_pseudo_c.txt:283950). // Named-retail: CPhysicsObj::update_object (acclient_2013_pseudo_c.txt:283950).
private float _physicsAccum; private float _physicsAccum;
private Vector3 _prevPhysicsPos;
private Vector3 _currPhysicsPos;
public PlayerMovementController(PhysicsEngine physics) public PlayerMovementController(PhysicsEngine physics)
{ {
@ -287,6 +291,8 @@ public sealed class PlayerMovementController
public void SetPosition(Vector3 pos, uint cellId) public void SetPosition(Vector3 pos, uint cellId)
{ {
_body.Position = pos; _body.Position = pos;
_prevPhysicsPos = pos;
_currPhysicsPos = pos;
CellId = cellId; CellId = cellId;
// Treat as grounded after a server-side position snap. // 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. // Reset physics clock so any subsequent update_object calls start fresh.
_body.LastUpdateTime = 0.0; _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) public MovementResult Update(float dt, MovementInput input)
@ -306,6 +319,7 @@ public sealed class PlayerMovementController
{ {
return new MovementResult( return new MovementResult(
Position: Position, Position: Position,
RenderPosition: RenderPosition,
CellId: CellId, CellId: CellId,
IsOnGround: _body.OnWalkable, IsOnGround: _body.OnWalkable,
MotionStateChanged: false, MotionStateChanged: false,
@ -524,12 +538,16 @@ public sealed class PlayerMovementController
// accumulated dt) when the threshold is reached. See _physicsAccum // accumulated dt) when the threshold is reached. See _physicsAccum
// declaration for the full retail trace evidence. // declaration for the full retail trace evidence.
var preIntegratePos = _body.Position; var preIntegratePos = _body.Position;
bool physicsTickRan = false;
Vector3 oldTickEndPos = _currPhysicsPos;
_physicsAccum += dt; _physicsAccum += dt;
if (_physicsAccum > PhysicsBody.HugeQuantum) if (_physicsAccum > PhysicsBody.HugeQuantum)
{ {
// Stale frame (debugger break, GC pause). Discard accumulated dt. // Stale frame (debugger break, GC pause). Discard accumulated dt.
_physicsAccum = 0f; _physicsAccum = 0f;
_prevPhysicsPos = _body.Position;
_currPhysicsPos = _body.Position;
} }
else if (_physicsAccum >= PhysicsBody.MinQuantum) else if (_physicsAccum >= PhysicsBody.MinQuantum)
{ {
@ -539,6 +557,7 @@ public sealed class PlayerMovementController
_body.calc_acceleration(); _body.calc_acceleration();
_body.UpdatePhysicsInternal(tickDt); _body.UpdatePhysicsInternal(tickDt);
_physicsAccum -= tickDt; _physicsAccum -= tickDt;
physicsTickRan = true;
} }
// Else: dt below MinQuantum threshold — skip integration. Position // Else: dt below MinQuantum threshold — skip integration. Position
// and velocity remain unchanged; Resolve below runs as a zero-distance // and velocity remain unchanged; Resolve below runs as a zero-distance
@ -591,6 +610,11 @@ public sealed class PlayerMovementController
// Apply resolved position. // Apply resolved position.
_body.Position = resolveResult.Position; _body.Position = resolveResult.Position;
if (physicsTickRan)
{
_prevPhysicsPos = oldTickEndPos;
_currPhysicsPos = _body.Position;
}
// L.3a (2026-04-30): retail wall-bounce / velocity reflection. // L.3a (2026-04-30): retail wall-bounce / velocity reflection.
// //
@ -874,6 +898,7 @@ public sealed class PlayerMovementController
return new MovementResult( return new MovementResult(
Position: Position, Position: Position,
RenderPosition: RenderPosition,
CellId: CellId, CellId: CellId,
IsOnGround: _body.OnWalkable, IsOnGround: _body.OnWalkable,
MotionStateChanged: changed, MotionStateChanged: changed,

View file

@ -173,15 +173,15 @@ public sealed class GameWindow : IDisposable
private static readonly int s_hidePartIndex = private static readonly int s_hidePartIndex =
int.TryParse(Environment.GetEnvironmentVariable("ACDREAM_HIDE_PART"), out var hp) ? hp : -1; 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 // humanoid setups. When enabled, every per-part GfxObj id (after
// server AnimPartChanges are applied) is replaced with Degrades[0] // server AnimPartChanges are applied) is replaced with Degrades[0]
// from its DIDDegrade table when present. See GfxObjDegradeResolver // from its DIDDegrade table when present. See GfxObjDegradeResolver
// for the full retail-decomp citation. Off by default while the fix // for the full retail-decomp citation. Default-on after visual
// bakes; flip to default-on once we've confirmed no scenery/setup // confirmation; set ACDREAM_RETAIL_CLOSE_DEGRADES=0 only for
// regressions. // diagnostic before/after comparisons.
private static readonly bool s_retailCloseDegrades = 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);
/// <summary> /// <summary>
/// Issue #47 humanoid-setup detector. Matches Aluvian Male /// Issue #47 humanoid-setup detector. Matches Aluvian Male
@ -5728,7 +5728,7 @@ public sealed class GameWindow : IDisposable
// the physics-resolved location each frame. // the physics-resolved location each frame.
if (_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe)) if (_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe))
{ {
pe.Position = result.Position; pe.Position = result.RenderPosition;
pe.Rotation = System.Numerics.Quaternion.CreateFromAxisAngle( pe.Rotation = System.Numerics.Quaternion.CreateFromAxisAngle(
System.Numerics.Vector3.UnitZ, _playerController.Yaw - MathF.PI / 2f); 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 // position never changes. With the pin: player visibly
// rises above the camera, matching retail "you can see // rises above the camera, matching retail "you can see
// yourself jump" feedback. // yourself jump" feedback.
_chaseCamera.Update(result.Position, _playerController.Yaw, _chaseCamera.Update(result.RenderPosition, _playerController.Yaw,
isOnGround: result.IsOnGround, isOnGround: result.IsOnGround,
dt: (float)dt); dt: (float)dt);

View file

@ -49,6 +49,81 @@ public class PlayerMovementControllerTests
Assert.True(result.Position.X > 96f + 2f, $"X={result.Position.X} should have moved forward"); 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] [Fact]
public void Update_RunForward_MoveFasterThanWalk() public void Update_RunForward_MoveFasterThanWalk()
{ {