docs(render): V1 implementation plan — single-viewpoint un-split (TDD)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-03 12:29:27 +02:00
parent b3fe54a5f4
commit b7375c6563

View file

@ -0,0 +1,336 @@
# Single-Viewpoint Render — V1 (un-split) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans (inline) or
> superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox
> (`- [ ]`) syntax for tracking.
**Goal:** Make the render key its inside/outside decision, indoor root, and portal side-test on the
**collided-camera (viewer) cell + eye** — one viewpoint, retail-faithful — instead of splitting them
across the player cell (visibility/side-test) and the eye (projection only).
**Architecture:** The camera-collision sweep (`PhysicsCameraCollisionProbe.SweepEye`
`PhysicsEngine.ResolveWithTransition`) already produces the collided eye AND its swept cell
(`ResolveResult.CellId == sp.CurCellId`, retail `viewer_cell = sphere_path.curr_cell`). V1 surfaces
that cell (`RetailChaseCamera.ViewerCellId`) and routes the render through it + the eye, while
**lighting / `seen_outside` / `CurrCell` stay on the player** (retail `CellManager::ChangePosition`).
No AABB, no grace frames → the U.4c flap source is gone. The blue-hole fix (`CurrCell` player-only)
is untouched.
**Tech Stack:** C# / .NET 10, Silk.NET. Spec: `docs/superpowers/specs/2026-06-03-single-viewpoint-render-design.md`.
---
## File structure
- `src/AcDream.App/Rendering/ICameraCollisionProbe.cs` — add `CameraSweepResult`; change `SweepEye` return.
- `src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs` — return `(eye, r.CellId)`.
- `src/AcDream.App/Rendering/RetailChaseCamera.cs` — new `ViewerCellId`; set it in `Update`.
- `src/AcDream.App/Rendering/GameWindow.cs` (~7150-7332) — render keys on viewer; lighting on player.
- `tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs` — update fakes; add `ViewerCellId` test.
---
## Task 1: `CameraSweepResult` + `SweepEye` returns (Eye, ViewerCellId)
**Files:**
- Modify: `src/AcDream.App/Rendering/ICameraCollisionProbe.cs`
- Modify: `src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs:24-84`
- Modify: `src/AcDream.App/Rendering/RetailChaseCamera.cs:154-156` (caller — keep behavior)
- Modify: `tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs:451-460,537-546` (fakes)
This is a pure plumbing/type change — no new behavior. Verified by a green build + the (updated) existing tests; the new behavior is tested in Task 2.
- [ ] **Step 1: Add the result type + change the interface**
In `ICameraCollisionProbe.cs`, replace the `SweepEye` signature and add the struct:
```csharp
using System.Numerics;
namespace AcDream.App.Rendering;
/// <summary>Result of a camera spring-arm sweep: the collided eye position and the
/// cell the swept viewer-sphere ended in (retail <c>viewer_cell = sphere_path.curr_cell</c>,
/// update_viewer pc:92871). The cell is graph-tracked by the transition — no AABB, no grace frames.</summary>
public readonly record struct CameraSweepResult(Vector3 Eye, uint ViewerCellId);
public interface ICameraCollisionProbe
{
/// <summary>
/// Roll a collision sphere from <paramref name="pivot"/> to <paramref name="desiredEye"/>;
/// return the stopped (non-penetrating) eye AND the cell it ended in. Returns
/// <paramref name="desiredEye"/> + <paramref name="cellId"/> unchanged when nothing blocks the
/// path or when <paramref name="cellId"/> is 0.
/// </summary>
CameraSweepResult SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId);
}
```
- [ ] **Step 2: Update `PhysicsCameraCollisionProbe`**
In `PhysicsCameraCollisionProbe.cs`, change the method return type to `CameraSweepResult`. Early return becomes `return new CameraSweepResult(desiredEye, cellId);` (line ~27). The final return becomes:
```csharp
Vector3 eye = FromSpherePath(r.Position, ViewerSphereRadius);
// ... existing [flap-sweep] probe block unchanged ...
return new CameraSweepResult(eye, r.CellId);
```
- [ ] **Step 3: Update the `RetailChaseCamera` call site (behavior-preserving)**
In `RetailChaseCamera.cs:154-156`, extract the eye from the result (ViewerCellId is wired in Task 2):
```csharp
Vector3 publishedEye = _dampedEye;
if (CameraDiagnostics.CollideCamera && CollisionProbe is not null)
publishedEye = CollisionProbe.SweepEye(pivotWorld, _dampedEye, cellId, selfEntityId).Eye;
```
- [ ] **Step 4: Update the test fakes so the project compiles**
In `RetailChaseCameraTests.cs`, both fakes return `CameraSweepResult`. `FakeProbe` (451-460):
```csharp
private sealed class FakeProbe : ICameraCollisionProbe
{
public int Calls;
public Vector3 ReturnEye;
public uint ReturnCell;
public CameraSweepResult SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId)
{
Calls++;
return new CameraSweepResult(ReturnEye, ReturnCell);
}
}
```
`ClampThenReleaseProbe` (537-546):
```csharp
private sealed class ClampThenReleaseProbe : ICameraCollisionProbe
{
public int Calls;
public Vector3 ClampEye;
public CameraSweepResult SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId)
{
Calls++;
return new CameraSweepResult(Calls == 1 ? ClampEye : desiredEye, cellId);
}
}
```
- [ ] **Step 5: Build + run the camera tests**
Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` then
`dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~RetailChaseCameraTests"`
Expected: build green; all existing RetailChaseCameraTests PASS (eye behavior unchanged).
- [ ] **Step 6: Commit**
```bash
git add src/AcDream.App/Rendering/ICameraCollisionProbe.cs src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs src/AcDream.App/Rendering/RetailChaseCamera.cs tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs
git commit -m "refactor(render): SweepEye returns (Eye, ViewerCellId) — surface the swept viewer cell"
```
---
## Task 2: `RetailChaseCamera.ViewerCellId` (TDD)
**Files:**
- Modify: `src/AcDream.App/Rendering/RetailChaseCamera.cs` (new property + set in `Update`)
- Test: `tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs`
- [ ] **Step 1: Write the failing tests**
Add to `RetailChaseCameraTests.cs` (in the camera-collision region):
```csharp
[Fact]
public void Update_WithProbeAndFlagOn_ExposesSweptViewerCell()
{
CameraDiagnostics.CollideCamera = true;
var probe = new FakeProbe { ReturnEye = new Vector3(1f, 2f, 3f), ReturnCell = 0xA9B40170u };
var cam = new RetailChaseCamera { CollisionProbe = probe };
cam.Update(
playerPosition: Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero,
isOnGround: true, contactPlaneNormal: Vector3.UnitZ, dt: 1f / 60f,
cellId: 0xA9B40171u, selfEntityId: 0x5);
Assert.Equal(0xA9B40170u, cam.ViewerCellId); // the swept cell, not the player cell
}
[Fact]
public void Update_FlagOff_ViewerCellFallsBackToPlayerCell()
{
CameraDiagnostics.CollideCamera = false;
try
{
var cam = new RetailChaseCamera { CollisionProbe = new FakeProbe { ReturnCell = 0xDEADu } };
cam.Update(
playerPosition: Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero,
isOnGround: true, contactPlaneNormal: Vector3.UnitZ, dt: 1f / 60f,
cellId: 0xA9B40171u, selfEntityId: 0x5);
Assert.Equal(0xA9B40171u, cam.ViewerCellId); // collision off → the passed player cell
}
finally { CameraDiagnostics.CollideCamera = true; }
}
```
- [ ] **Step 2: Run to verify they fail**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ViewerCell"`
Expected: FAIL — `RetailChaseCamera` has no `ViewerCellId` (compile error).
- [ ] **Step 3: Implement**
In `RetailChaseCamera.cs`, add the property (near `Position`):
```csharp
/// <summary>The cell the collided viewer-sphere ended in (retail <c>viewer_cell</c>). Roots the
/// render mode + indoor visibility + the portal side-test. Equals the passed player cell when
/// camera collision is off. Read by <c>GameWindow.OnRender</c>.</summary>
public uint ViewerCellId { get; private set; }
```
In `Update`, replace the collision block (step 5b) so it captures both fields and always sets `ViewerCellId`:
```csharp
Vector3 publishedEye = _dampedEye;
ViewerCellId = cellId; // default: the player cell (collision off / null probe)
if (CameraDiagnostics.CollideCamera && CollisionProbe is not null)
{
var swept = CollisionProbe.SweepEye(pivotWorld, _dampedEye, cellId, selfEntityId);
publishedEye = swept.Eye;
ViewerCellId = swept.ViewerCellId;
}
```
- [ ] **Step 4: Run the tests**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~RetailChaseCameraTests"`
Expected: PASS (both new tests + all existing).
- [ ] **Step 5: Commit**
```bash
git add src/AcDream.App/Rendering/RetailChaseCamera.cs tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs
git commit -m "feat(render): RetailChaseCamera.ViewerCellId — the swept viewer cell (retail viewer_cell)"
```
---
## Task 3: Route the render through the viewer; keep lighting on the player (integration + visual gate)
**Files:**
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (~7150-7202 lighting/root split; ~7315-7332 builder call; the `[flap-cam]` probe block ~7368)
Not unit-testable (render integration). Verified by build + the `[flap-sweep]`/`[flap-cam]` probes + the user's eyes.
- [ ] **Step 1: Split the lighting root (player) from the render root (viewer)**
Replace the `visRootPos` / `physicsRoot` / `visibility` / `cameraInsideCell` / `rootSeenOutside` block
(~7162-7186, 7202) with:
```csharp
// ── Lighting / seen_outside key on the PLAYER cell (retail CellManager::ChangePosition
// @ 0x4559b0): a sealed interior kills the sun; a seen_outside interior keeps it. ──
LoadedCell? playerRoot = null;
if (_physicsEngine.DataCache?.CellGraph.CurrCell is AcDream.Core.World.Cells.EnvCell pCell
&& _cellVisibility.TryGetCell(pCell.Id, out var pReg))
playerRoot = pReg;
bool playerSeenOutside = playerRoot?.SeenOutside ?? true;
// ── Render keys on the VIEWER (collided camera) cell + eye — ONE viewpoint (retail
// RenderNormalMode → DrawInside(viewer_cell) @92675; InitCell side-test vs
// viewer.viewpoint @432991). The viewer cell is the camera-collision sweep's swept
// cell (RetailChaseCamera.ViewerCellId): graph-tracked, no AABB/grace → no U.4c flap.
// Falls back to the player cell for the non-default legacy/debug camera paths. ──
uint viewerCellId =
(_playerMode && _retailChaseCamera is not null
&& AcDream.Core.Rendering.CameraDiagnostics.UseRetailChaseCamera)
? _retailChaseCamera.ViewerCellId
: (_physicsEngine.DataCache?.CellGraph.CurrCell?.Id ?? 0u);
Vector3 viewerEyePos = camPos; // the collided eye now drives the side-test AND the projection
LoadedCell? viewerRoot = null;
if ((viewerCellId & 0xFFFFu) >= 0x0100u && _cellVisibility.TryGetCell(viewerCellId, out var vReg))
viewerRoot = vReg;
var visibility = _cellVisibility.ComputeVisibilityFromRoot(viewerRoot, viewerEyePos);
bool cameraInsideCell = visibility?.CameraCell is not null;
bool rootSeenOutside = viewerRoot?.SeenOutside ?? true; // render's seen_outside (terrain-thru-door)
```
Then change the lighting predicate (was `cameraInsideCell && !rootSeenOutside`) to key on the **player**:
```csharp
// Lighting: kill the sun only when the PLAYER is in a sealed interior (dungeon), not the
// camera. (Render seen_outside above is separate — it gates terrain-through-the-door.)
bool playerInsideCell = playerRoot is not null && !playerSeenOutside;
```
- [ ] **Step 2: Point the portal builder at the viewer eye**
At the `PortalVisibilityBuilder.Build` call (~7326-7330), the side-test position becomes the eye:
```csharp
pvFrame = PortalVisibilityBuilder.Build(
clipRoot,
viewerEyePos, // U.4c split removed: side-test from the collided eye, not the player
id => _cellVisibility.TryGetCell(id, out var c) ? c : null,
envCellViewProj);
```
(`clipRoot = visibility?.CameraCell` at ~7315 now resolves to the viewer cell — no edit needed there.)
- [ ] **Step 3: Extend the `[flap-cam]` probe to log viewer vs player**
In the `ProbeFlapEnabled` block (~7368), add `viewerCell=0x{viewerCellId:X8}` and
`playerCell=0x{(_physicsEngine.DataCache?.CellGraph.CurrCell?.Id ?? 0):X8}` to the emitted line so the
viewer-cell-vs-player-cell relationship (and any oscillation) is visible in the capture.
- [ ] **Step 4: Build**
Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`
Expected: green.
- [ ] **Step 5: Full test suite (no regressions)**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj` and
`dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj`
Expected: App green; Core = the documented baseline (1295 pass / 5 pre-existing fails — 2 BSPStepUp + 3 door-collision), no NEW failures.
- [ ] **Step 6: Commit**
```bash
git add src/AcDream.App/Rendering/GameWindow.cs
git commit -m "feat(render): V1 — render keys on the viewer cell+eye; lighting stays on the player (un-split)"
```
- [ ] **Step 7: VISUAL GATE (user)**
Launch (PowerShell, `ACDREAM_PROBE_FLAP=1 ACDREAM_PROBE_CELL=1`). Capture `[flap-sweep]`/`[flap-cam]`
(viewerCell vs playerCell). User verifies, walking from outside → doorway → inside a Holtburg cottage:
- The doorway-straddle **void is gone** — when the camera is outside, the outdoor world renders (cottage = solid exterior); inside, the interior renders; the transition follows the **camera**.
- **No flap** standing or walking inside (viewerCell stable in the probe — the key risk).
- Inside-looking-out still correct.
(Outside the cottage = solid exterior at this gate; interior-through-door is V2. Blue floor is V3.)
---
## After V1
V2 (DrawPortal / outside-looking-in) and V3 (floor seal) get their own plans once the V1 gate passes
(spec §5, §6). Do not start them before the V1 visual gate.
## Self-review
- **Spec coverage:** §3 keystone → Task 1+2 (SweepEye returns the cell; ViewerCellId). §4 un-split →
Task 3 (render on viewer, lighting on player, side-test from the eye, probe). §2 "lighting on player"
→ Task 3 Step 1. V2/V3 explicitly deferred. ✓
- **Placeholder scan:** Step 3 of Task 3 (probe format) is a diagnostic line described, not exact code —
acceptable (non-load-bearing; the executor reads the existing block). All load-bearing steps have code. ✓
- **Type consistency:** `CameraSweepResult(Vector3 Eye, uint ViewerCellId)` used identically in Task 1
(def + probe + fakes) and Task 2 (`swept.Eye`, `swept.ViewerCellId`); `ViewerCellId` property name
consistent across Task 2 + Task 3. `ResolveResult.CellId` confirmed = `sp.CurCellId`. ✓