Design for porting retail's stage-2 camera collision (SmartBox::update_viewer): sweep a 0.3 m sphere from the head-pivot to the damped eye via the existing ResolveWithTransition engine (collides both indoor cell walls and GfxObj building shells, e.g. the cottage cellar per #98/#101), publish the stopped position as the eye. Fixes the A8.F flap by keeping the eye out of walls so the camera-cell + portal side-tests stay stable. Self-skip via LocalEntityId; gated by CameraDiagnostics.CollideCamera (default ON). Corrects the prior retail-chase-camera spec's "no camera collision" note. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
16 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
var r = _physics.ResolveWithTransition(
currentPos: pivot,
targetPos: desiredEye,
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
moverFlags: ObjectInfoState.None, // all targets collide; also keeps
// camera sweeps out of the #98
// IsPlayer capture filter
movingEntityId: selfEntityId); // skip the player's own ShadowEntry
return r.Position; // = sp.CheckPos, the swept stop position
}
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
PhysicsCameraCollisionProbeagainst fixtures (reuse the issue-#98 cell + GfxObj fixtures already in the test tree):- clear path → eye unchanged;
- wall/shell between pivot and desiredEye → eye stops short (does not penetrate);
selfEntityIdset → sweep does not collide with the player's own ShadowEntry.
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. Reusing
ResolveWithTransitiongives the player path's edge-slide (the eye glides along a wall rather than jittering). Read retail'sfind_valid_positionduring implementation and confirm whether it slides or hard-stops; match it. Both keep the eye out of walls, so this does not change the architecture. sphereHeight: 0f. ConfirmSpherePath.InitPathwith height 0 yields a single sphere (no degenerate coincident head sphere). If not, pass a tiny height or a single-sphere init.- 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).