15 KiB
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— addCameraSweepResult; changeSweepEyereturn.src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs— return(eye, r.CellId).src/AcDream.App/Rendering/RetailChaseCamera.cs— newViewerCellId; set it inUpdate.src/AcDream.App/Rendering/GameWindow.cs(~7150-7332) — render keys on viewer; lighting on player.tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs— update fakes; addViewerCellIdtest.
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:
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:
Vector3 eye = FromSpherePath(r.Position, ViewerSphereRadius);
// ... existing [flap-sweep] probe block unchanged ...
return new CameraSweepResult(eye, r.CellId);
- Step 3: Update the
RetailChaseCameracall site (behavior-preserving)
In RetailChaseCamera.cs:154-156, extract the eye from the result (ViewerCellId is wired in Task 2):
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):
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):
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
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 inUpdate) -
Test:
tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs -
Step 1: Write the failing tests
Add to RetailChaseCameraTests.cs (in the camera-collision region):
[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):
/// <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:
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
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:
// ── 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:
// 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:
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
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);ViewerCellIdproperty name consistent across Task 2 + Task 3.ResolveResult.CellIdconfirmed =sp.CurCellId. ✓