acdream/docs/superpowers/specs/2026-05-29-a8f-camera-collision-design.md
Erik fcea05f808 fix(render): Phase A8.F — camera sweep uses retail moverFlags 0x5c (PathClipped hard-stop)
Code review found the probe passed ObjectInfoState.None; retail's
SmartBox::update_viewer calls init_object(player, 0x5c) =
IsViewer|PathClipped|FreeRotate|PerfectClip (pseudo-C :92864). PathClipped makes
the sweep hard-stop at first contact (TransitionTypes.cs:811) instead of
edge-sliding around corners (which would re-trigger the A8.F camera-cell
instability); IsViewer lets the eye pass through creatures, colliding only with
world geometry. Resolves the spec's slide-vs-stop open question. Also reset
CollideCamera in the Defaults_AreRetailValues baseline test (review: maintenance
trap). Spec §5.1/§11.1 synced.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 19:11:53 +02:00

17 KiB

Phase A8.F — Swept-sphere camera collision (design)

Date: 2026-05-29 Phase: A8.F (indoor rendering) — camera-collision sub-step Milestone: M1.5 — Indoor world feels right Status: Design — approved, pending spec review → implementation plan Predecessor handoff: docs/research/2026-05-29-a8f-camera-collision-handoff.md


1. Problem

With ACDREAM_A8_INDOOR_BRANCH=1, walking +Acdream through a Holtburg cottage produces a flap (walls/ground blink in and out) and intermittent missing/transparent walls. The handoff established the root cause: the 3rd-person camera eye clips through walls, and the A8.F renderer derives all three of its indoor-visibility decisions from that eye position (camPos, extracted from the inverse View matrix at GameWindow.cs:7270-7271):

  1. Camera-cell + portal BFS — PointInCell(camPos) picks the camera cell.
  2. Strict inside-building gate — cameraInsideBuilding (GameWindow.cs:7343-7346).
  3. Per-portal interior-side cull — CameraOnInteriorSide(cell, i, cameraPos) (PortalVisibilityBuilder.cs:196-203).

When the eye damps to a position outside the room (through a wall), PointInCell flips and CameraOnInteriorSide inverts frame-to-frame → the camera-cell ping-pongs, the inside/outside render branch switches, and the exit portal is culled-then-uncovered → walls/ground blink. The existing 3-frame grace (CellVisibility.cs:167) only masks single-frame blips.

Fix: stop the eye from clipping walls. This stabilizes the camera-cell and side-tests (the eye stays in valid space) and fixes the render (the eye is never behind a wall). It is a retail-faithful change, not a divergence.

2. Retail behavior (the thing we are porting)

Retail's chase camera is a spring arm: think of the eye as a ball on a stick behind the player's head. Retail does not let the ball pass through walls — it rolls a small collision ball outward from the head toward the desired eye and stops it where it first hits geometry (a swept sphere). Three stages, decomp in docs/research/named-retail/acclient_2013_pseudo_c.txt:

  • Stage 1 — desired eye (no collision). CameraManager::UpdateCamera (0x00456660, :95505-95953) computes eye = pivot + viewer_offset, damps it, stores it as SmartBox::viewer_sought_position.
  • Stage 2 — collide the desired eye (the pull-in). SmartBox::update_viewer (0x00453ce0, :92761-92892) sweeps a swept-sphere CTransition from the head-pivot to viewer_sought_position: makeTransitioninit_object(player, 0x5c)init_sphere(1, &viewer_sphere, 1f)init_path(cell, pivot, sought)find_valid_position → on success set_viewer(sphere_path.curr_pos) (the stopped position). Fallbacks: CPhysicsObj::AdjustPosition, then snap to the player's position. viewer_sphere is a global CSphere, radius 0.3 m, center (0,0,0) (:93308-93314, :1144645).
  • Stage 3 — fade the player when the collided eye is very close. CameraSet::UpdateCamera (0x00458ae0) calls SetTranslucencyHierarchical (:97679/97698/97725/97737) — opaque ≥0.45 m, transparent ≤0.20 m. Already ported as RetailChaseCamera.ComputeTranslucency (:367-376).

We are porting stage 2; stages 1 and 3 already exist in acdream.

Correction to the prior spec. The retail-chase-camera spec 2026-05-18-retail-chase-camera-design.md:454-457 scoped collision out with "retail's per-frame update doesn't raycast world geometry … we don't attempt 'camera collides with wall' — same as retail." That is falsified: the earlier investigation traced only the producer (CameraManager::UpdateCamera) and missed the consumer (update_viewer), where the collision lives. This spec supersedes that note.

3. Scope (decided)

Full faithful — reuse the engine sweep. The camera sphere is swept through acdream's existing Transition swept-sphere engine, which already collides against both geometry types in one path:

This is required, not just nice-to-have: per issue #98/#101 the cottage cellar floors/walls live in GfxObj 0x01000A2B, not in a cell BSP. A cell-BSP-only camera sweep would miss exactly the cellar walls the A8.F flap is about. Reusing the engine also matches retail's "full CTransition" call and reuses tested code rather than adding a parallel cast.

4. Architecture

A narrow collision-probe interface is injected into RetailChaseCamera; the sweep runs after damping and before publish, exactly the handoff's slot-in.

GameWindow (has player state)
  └─ injects PhysicsCameraCollisionProbe(physicsEngine) into RetailChaseCamera
  └─ per frame: _retailChaseCamera.Update(..., cellId, selfEntityId)   [:6862]

RetailChaseCamera.Update            (App, GL-free, unit-testable)
  ... compute pivotWorld [:113], damp _dampedEye [:131]
  if CameraDiagnostics.CollideCamera && _probe != null:
      _dampedEye = _probe.SweepEye(pivotWorld, _dampedEye, cellId, selfEntityId)
  publish Position + View [:136-137]; fade from collided eye [:140-141]

ICameraCollisionProbe.SweepEye(pivot, desiredEye, cellId, selfEntityId) -> Vector3   (App)
  └─ PhysicsCameraCollisionProbe wraps PhysicsEngine.ResolveWithTransition (Core)

Dependency direction is App → Core throughout (allowed; CLAUDE.md rule #2). No Core change is required beyond the new CameraDiagnostics flag. Camera logic stays out of GameWindow (rule #1).

5. Components

5.1 ICameraCollisionProbe + PhysicsCameraCollisionProbe (new, App)

src/AcDream.App/Rendering/ICameraCollisionProbe.cs:

public interface ICameraCollisionProbe
{
    /// Roll a small sphere from pivot to desiredEye; return the stopped
    /// (non-penetrating) eye. Returns desiredEye unchanged when nothing is hit.
    Vector3 SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId);
}

PhysicsCameraCollisionProbe (wraps PhysicsEngine):

public Vector3 SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId)
{
    if (cellId == 0) return desiredEye;                 // no starting cell → can't sweep
    // InitPath offsets sphere0's center up by radius (foot-capsule convention),
    // but retail's viewer_sphere center is (0,0,0). Shift the path down by radius
    // so the SPHERE CENTER travels pivot→eye, then add it back to the result.
    var zoff = new Vector3(0f, 0f, 0.3f);
    var r = _physics.ResolveWithTransition(
        currentPos:     pivot - zoff,
        targetPos:      desiredEye - zoff,
        cellId:         cellId,
        sphereRadius:   0.3f,        // retail viewer_sphere radius
        sphereHeight:   0f,          // single sphere (no head sphere)
        stepUpHeight:   0f,          // no step-up for a camera
        stepDownHeight: 0f,          // no step-down / ground snap
        isOnGround:     false,       // no contact-plane / walkable semantics
        body:           null,        // no cross-frame contact-plane persistence
        // Retail init_object(player, 0x5c) = IsViewer|PathClipped|FreeRotate|
        // PerfectClip (pseudo-C :92864). PathClipped = hard-stop at first contact
        // (the spring arm, not edge-slide); IsViewer = eye passes through creatures,
        // colliding only with world geometry. Not IsPlayer → stays out of the #98
        // capture filter.
        moverFlags:     ObjectInfoState.IsViewer | ObjectInfoState.PathClipped
                      | ObjectInfoState.FreeRotate | ObjectInfoState.PerfectClip,
        movingEntityId: selfEntityId);         // skip the player's own ShadowEntry
    return r.Position + zoff;        // r.Position = sp.CheckPos (path pt); + zoff = eye
}

ResolveWithTransition returns sp.CheckPos as .Position in both the success and partial branches (PhysicsEngine.cs:846/:865), so the returned position is the swept stop point — exactly retail's set_viewer(sphere_path.curr_pos).

5.2 RetailChaseCamera integration (edit)

  • Add a nullable ICameraCollisionProbe? _probe field, set via constructor/init (nullable so existing unit tests and the flag-off path keep today's behavior — a null probe = no collision).
  • Extend Update(...) with uint cellId, uint selfEntityId (Update is RetailChaseCamera-specific, not on ICamera; only the call site at GameWindow.cs:6862 and tests change).
  • Between damp (:131) and publish (:136):
if (CameraDiagnostics.CollideCamera && _probe is not null)
    _dampedEye = _probe.SweepEye(pivotWorld, _dampedEye, cellId, selfEntityId);

The fade (:140-141) then reads eye→pivot distance from the collided eye automatically.

5.3 GameWindow wiring (edit)

  • Construct the probe once with the live PhysicsEngine and pass it to the two RetailChaseCamera constructions (GameWindow.cs:10693, :10826).
  • At the Update call (:6862), pass cellId: _playerController.CellId (PlayerMovementController.cs:133) and selfEntityId: _playerController.LocalEntityId (:144).

5.4 CameraDiagnostics.CollideCamera (new flag, Core)

In CameraDiagnostics.cs, matching the UseRetailChaseCamera pattern (default-on; "0" disables):

public static bool CollideCamera { get; set; } =
    Environment.GetEnvironmentVariable("ACDREAM_CAMERA_COLLIDE") != "0";

Plus a DebugPanel checkbox (Camera section) for live A/B. Default ON — the behavior is retail-faithful and fixes a real bug; the flag exists for instant revert and A/B during visual verification.

6. Self-skip correctness (the one subtle hazard)

The sweep starts at pivotWorld (the player's head), which is inside the player's own 0.48 m collision sphere / registered ShadowEntry. Without skipping self, FindObjCollisions would report an immediate collision and snap the eye onto the head every frame. Passing movingEntityId: LocalEntityId is the same self-skip the player's own sweep uses (PlayerMovementController.cs:1129, retail CObjCell::find_obj_collisions at :308931, our #42 fix). If LocalEntityId is 0 (player not yet spawned), the sweep degrades to "no self-skip" — acceptable, since chase mode is not active pre-spawn.

7. Fallbacks (mirror retail)

ResolveWithTransition already returns the partial (stopped) position when the sweep can't fully resolve (:864-869), so the eye lands in front of whatever blocked it. Retail's deeper fallbacks are AdjustPosition then snap-to-player; the engine's partial-position return covers the common case, and a fully-degenerate result (eye stays at pivot) is the snap-to-player worst case. We do not add a separate AdjustPosition path unless visual verification shows the eye hugging/penetrating in a tight room.

8. Player-fade interaction

No code change. After collision the eye is closer to the pivot when pulled in, so ComputeTranslucency fades the player more in tight spots — retail's stage 3. Verify during visual check that the player fades (rather than the camera sitting inside the player mesh) when backed into a corner.

9. Testing

Unit (tests/AcDream.App.Tests/):

  • RetailChaseCamera with a fake ICameraCollisionProbe:
    • probe returns desiredEye unchanged → published Position/View identical to today (guards the no-regression path; keeps existing camera-math tests valid).
    • probe returns a pulled-in eye → published Position/View use the collided eye; fade increases.
    • CollideCamera = false → probe never consulted.
  • PhysicsCameraCollisionProbe deterministic units (no heavy fixture setup):
    • ToSpherePath/FromSpherePath z-offset round-trip;
    • cellId == 0 guard → returns desiredEye unchanged. Collision correctness itself (eye stops at a wall/shell; self-skip) is already covered by the exhaustive ResolveWithTransition / BSPQuery suites and is confirmed end-to-end by the visual acceptance below — re-proving it at the probe layer would duplicate that coverage with brittle fixture wiring.

Visual (acceptance): with ACDREAM_A8_INDOOR_BRANCH=1, walk into a Holtburg cottage and its cellar:

  • the flap is gone — walls/ground stay solid while panning the camera through a wall and while crossing the doorway inside↔outside;
  • back walls no longer go missing when looking through a window from outside;
  • the player fades (not the wall) when backed into a corner. Compare ACDREAM_CAMERA_COLLIDE=0 vs default to confirm the flag isolates the fix.

10. Out of scope

  • First-person / look-down / map modes (none exist; a spring arm must no-op at distance 0 if 1st-person is added later).
  • A separate AdjustPosition fallback (see §7) — added only if visual verification demands it.
  • Deleting the legacy ChaseCamera.

11. Open implementation questions (decide during the plan, not now)

  1. Slide vs hard-stop — RESOLVED (hard-stop). Retail's update_viewer calls init_object(player, 0x5c), and 0x5c includes PathClipped, which makes the transition HARD-STOP at first contact (TransitionTypes.cs:811) rather than edge-slide. The probe therefore passes IsViewer | PathClipped | FreeRotate | PerfectClip (see §5.1). Hard-stop also avoids the eye sliding around a corner into the next room, which would re-trigger the A8.F camera-cell instability this fix targets.
  2. sphereHeight: 0f — RESOLVED. SpherePath.InitPath with height 0 yields a single sphere (NumSphere = 1, TransitionTypes.cs:534-537). It also offsets sphere0's center to pathPos + (0,0,radius) (foot-capsule convention) whereas retail's viewer_sphere center is (0,0,0); the probe compensates with the ToSpherePath/FromSpherePath z-shift (§5.1) so the sphere center travels pivot→eye.
  3. Probe construction order. The PhysicsEngine must exist before the RetailChaseCamera constructions at GameWindow.cs:10693/:10826; confirm lifetime ordering or make the probe field settable post-construction.

12. Acceptance criteria

  • Camera sphere (0.3 m) swept pivot→damped-eye via ResolveWithTransition; published eye is the stopped position.
  • Collides against indoor cell walls and GfxObj shells (verified at the cottage cellar).
  • Self-skip via LocalEntityId (no self-collision snap).
  • Gated by CameraDiagnostics.CollideCamera (default ON; ACDREAM_CAMERA_COLLIDE=0 disables; DebugPanel toggle).
  • dotnet build green; dotnet test green; new unit tests pass.
  • Visual verification at Holtburg cottage + cellar: flap gone, walls solid.
  • Roadmap + 2026-05-18-retail-chase-camera-design.md collision note updated.

13. References

acdream code:

Retail decomp (docs/research/named-retail/acclient_2013_pseudo_c.txt):

  • SmartBox::update_viewer 0x00453ce0 (:92761-92892) — the camera collision.
  • CameraManager::UpdateCamera 0x00456660 (:95505-95953) — desired eye.
  • CameraSet::UpdateCamera 0x00458ae0 (:97643-97742) — player fade.
  • viewer_sphere radius 0.3 m (:93308-93314, :1144645).
  • CObjCell::find_obj_collisions self-skip (:308931).