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>
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):
- Camera-cell + portal BFS —
PointInCell(camPos)picks the camera cell. - Strict inside-building gate —
cameraInsideBuilding(GameWindow.cs:7343-7346). - 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) computeseye = pivot + viewer_offset, damps it, stores it asSmartBox::viewer_sought_position. - Stage 2 — collide the desired eye (the pull-in).
SmartBox::update_viewer(0x00453ce0,:92761-92892) sweeps a swept-sphereCTransitionfrom the head-pivot toviewer_sought_position:makeTransition→init_object(player, 0x5c)→init_sphere(1, &viewer_sphere, 1f)→init_path(cell, pivot, sought)→find_valid_position→ on successset_viewer(sphere_path.curr_pos)(the stopped position). Fallbacks:CPhysicsObj::AdjustPosition, then snap to the player's position.viewer_sphereis a globalCSphere, 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) callsSetTranslucencyHierarchical(:97679/97698/97725/97737) — opaque ≥0.45 m, transparent ≤0.20 m. Already ported asRetailChaseCamera.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-457scoped 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:
- indoor cell walls —
FindEnvCollisions(TransitionTypes.cs:870), - outdoor / landblock-baked GfxObj building shells —
FindObjCollisions(TransitionTypes.cs:894) via the ShadowObjectRegistry.
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? _probefield, set via constructor/init (nullable so existing unit tests and the flag-off path keep today's behavior — anullprobe = no collision). - Extend
Update(...)withuint cellId, uint selfEntityId(Update isRetailChaseCamera-specific, not onICamera; only the call site atGameWindow.cs:6862and 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
PhysicsEngineand pass it to the twoRetailChaseCameraconstructions (GameWindow.cs:10693,:10826). - At the
Updatecall (:6862), passcellId: _playerController.CellId(PlayerMovementController.cs:133) andselfEntityId: _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/):
RetailChaseCamerawith a fakeICameraCollisionProbe:- probe returns
desiredEyeunchanged → publishedPosition/Viewidentical to today (guards the no-regression path; keeps existing camera-math tests valid). - probe returns a pulled-in eye → published
Position/Viewuse the collided eye; fade increases. CollideCamera = false→ probe never consulted.
- probe returns
PhysicsCameraCollisionProbedeterministic units (no heavy fixture setup):ToSpherePath/FromSpherePathz-offset round-trip;cellId == 0guard → returnsdesiredEyeunchanged. Collision correctness itself (eye stops at a wall/shell; self-skip) is already covered by the exhaustiveResolveWithTransition/BSPQuerysuites 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=0vs 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
AdjustPositionfallback (see §7) — added only if visual verification demands it. - Deleting the legacy
ChaseCamera.
11. Open implementation questions (decide during the plan, not now)
- Slide vs hard-stop — RESOLVED (hard-stop). Retail's
update_viewercallsinit_object(player, 0x5c), and0x5cincludesPathClipped, which makes the transition HARD-STOP at first contact (TransitionTypes.cs:811) rather than edge-slide. The probe therefore passesIsViewer | 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. sphereHeight: 0f— RESOLVED.SpherePath.InitPathwith height 0 yields a single sphere (NumSphere = 1,TransitionTypes.cs:534-537). It also offsets sphere0's center topathPos + (0,0,radius)(foot-capsule convention) whereas retail'sviewer_spherecenter is (0,0,0); the probe compensates with theToSpherePath/FromSpherePathz-shift (§5.1) so the sphere center travels pivot→eye.- Probe construction order. The
PhysicsEnginemust exist before theRetailChaseCameraconstructions atGameWindow.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=0disables; DebugPanel toggle). dotnet buildgreen;dotnet testgreen; new unit tests pass.- Visual verification at Holtburg cottage + cellar: flap gone, walls solid.
- Roadmap +
2026-05-18-retail-chase-camera-design.mdcollision note updated.
13. References
acdream code:
RetailChaseCamera.cs— eye:113-117, damp:131, publish:136-137, fade:367-376.CameraController.cs,CameraDiagnostics.cs.GameWindow.cs— camera Update:6862, eye extract:7270-7271, visibility:7323,cameraInsideBuilding:7343-7346, RetailChaseCamera ctor:10693/:10826.PlayerMovementController.cs—CellId:133,LocalEntityId:144, self-skip sweep:1105-1129.PhysicsEngine.cs—ResolveWithTransition:589, returnssp.CheckPos:846/:865.TransitionTypes.cs—FindTransitionalPosition:653,FindEnvCollisions:870,FindObjCollisions:894.BSPQuery.cs—FindCollisions:1637.
Retail decomp (docs/research/named-retail/acclient_2013_pseudo_c.txt):
SmartBox::update_viewer0x00453ce0(:92761-92892) — the camera collision.CameraManager::UpdateCamera0x00456660(:95505-95953) — desired eye.CameraSet::UpdateCamera0x00458ae0(:97643-97742) — player fade.viewer_sphereradius 0.3 m (:93308-93314,:1144645).CObjCell::find_obj_collisionsself-skip (:308931).