docs(render): Phase A8.F — swept-sphere camera collision design spec
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>
This commit is contained in:
parent
9757818e95
commit
9bdd50287b
1 changed files with 302 additions and 0 deletions
302
docs/superpowers/specs/2026-05-29-a8f-camera-collision-design.md
Normal file
302
docs/superpowers/specs/2026-05-29-a8f-camera-collision-design.md
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
# 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`](../../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`](../../../src/AcDream.App/Rendering/GameWindow.cs)):
|
||||
|
||||
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`](../../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`: `makeTransition` → `init_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`](../../../src/AcDream.App/Rendering/RetailChaseCamera.cs)).
|
||||
|
||||
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`](2026-05-18-retail-chase-camera-design.md)
|
||||
> 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:
|
||||
|
||||
- indoor cell walls — `FindEnvCollisions` ([`TransitionTypes.cs:870`](../../../src/AcDream.Core/Physics/TransitionTypes.cs)),
|
||||
- outdoor / landblock-baked **GfxObj building shells** — `FindObjCollisions`
|
||||
([`TransitionTypes.cs:894`](../../../src/AcDream.Core/Physics/TransitionTypes.cs))
|
||||
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`:
|
||||
|
||||
```csharp
|
||||
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`):
|
||||
|
||||
```csharp
|
||||
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`](../../../src/AcDream.Core/Physics/PhysicsEngine.cs)),
|
||||
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`](../../../src/AcDream.App/Rendering/RetailChaseCamera.cs))
|
||||
and publish ([`:136`](../../../src/AcDream.App/Rendering/RetailChaseCamera.cs)):
|
||||
|
||||
```csharp
|
||||
if (CameraDiagnostics.CollideCamera && _probe is not null)
|
||||
_dampedEye = _probe.SweepEye(pivotWorld, _dampedEye, cellId, selfEntityId);
|
||||
```
|
||||
|
||||
The fade ([`:140-141`](../../../src/AcDream.App/Rendering/RetailChaseCamera.cs))
|
||||
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`](../../../src/AcDream.App/Rendering/GameWindow.cs)),
|
||||
pass `cellId: _playerController.CellId`
|
||||
([`PlayerMovementController.cs:133`](../../../src/AcDream.App/Input/PlayerMovementController.cs))
|
||||
and `selfEntityId: _playerController.LocalEntityId`
|
||||
([`:144`](../../../src/AcDream.App/Input/PlayerMovementController.cs)).
|
||||
|
||||
### 5.4 `CameraDiagnostics.CollideCamera` (new flag, Core)
|
||||
|
||||
In [`CameraDiagnostics.cs`](../../../src/AcDream.Core/Rendering/CameraDiagnostics.cs),
|
||||
matching the `UseRetailChaseCamera` pattern (default-on; `"0"` disables):
|
||||
|
||||
```csharp
|
||||
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`](../../../src/AcDream.App/Input/PlayerMovementController.cs),
|
||||
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`](../../../src/AcDream.Core/Physics/PhysicsEngine.cs)),
|
||||
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` against 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);
|
||||
- `selfEntityId` set → 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=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.** Reusing `ResolveWithTransition` gives the player
|
||||
path's edge-slide (the eye glides along a wall rather than jittering). Read
|
||||
retail's `find_valid_position` during 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.
|
||||
2. **`sphereHeight: 0f`.** Confirm `SpherePath.InitPath` with height 0 yields a
|
||||
single sphere (no degenerate coincident head sphere). If not, pass a tiny
|
||||
height or a single-sphere init.
|
||||
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:**
|
||||
- [`RetailChaseCamera.cs`](../../../src/AcDream.App/Rendering/RetailChaseCamera.cs) — eye `:113-117`, damp `:131`, publish `:136-137`, fade `:367-376`.
|
||||
- [`CameraController.cs`](../../../src/AcDream.App/Rendering/CameraController.cs), [`CameraDiagnostics.cs`](../../../src/AcDream.Core/Rendering/CameraDiagnostics.cs).
|
||||
- [`GameWindow.cs`](../../../src/AcDream.App/Rendering/GameWindow.cs) — camera Update `:6862`, eye extract `:7270-7271`, visibility `:7323`, `cameraInsideBuilding` `:7343-7346`, RetailChaseCamera ctor `:10693`/`:10826`.
|
||||
- [`PlayerMovementController.cs`](../../../src/AcDream.App/Input/PlayerMovementController.cs) — `CellId` `:133`, `LocalEntityId` `:144`, self-skip sweep `:1105-1129`.
|
||||
- [`PhysicsEngine.cs`](../../../src/AcDream.Core/Physics/PhysicsEngine.cs) — `ResolveWithTransition` `:589`, returns `sp.CheckPos` `:846`/`:865`.
|
||||
- [`TransitionTypes.cs`](../../../src/AcDream.Core/Physics/TransitionTypes.cs) — `FindTransitionalPosition` `:653`, `FindEnvCollisions` `:870`, `FindObjCollisions` `:894`.
|
||||
- [`BSPQuery.cs`](../../../src/AcDream.Core/Physics/BSPQuery.cs) — `FindCollisions` `:1637`.
|
||||
|
||||
**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`).
|
||||
Loading…
Add table
Add a link
Reference in a new issue