acdream/docs/research/2026-05-06-issue-38-render-interp-pseudocode.md
Erik 71b1622293 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>
2026-05-06 17:53:34 +02:00

3.4 KiB

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:

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:

body.Position = newPosition
prevPhysicsPos = newPosition
currPhysicsPos = newPosition
physicsAccum = 0

Each Update(dt):

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.