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:
parent
b3fe54a5f4
commit
b7375c6563
1 changed files with 336 additions and 0 deletions
336
docs/superpowers/plans/2026-06-03-single-viewpoint-render-v1.md
Normal file
336
docs/superpowers/plans/2026-06-03-single-viewpoint-render-v1.md
Normal 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`. ✓
|
||||
Loading…
Add table
Add a link
Reference in a new issue