acdream/docs/superpowers/plans/2026-06-03-single-viewpoint-render-v1.md
Erik b7375c6563 docs(render): V1 implementation plan — single-viewpoint un-split (TDD)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 12:29:27 +02:00

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.SweepEyePhysicsEngine.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:

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 RetailChaseCamera call 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 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):

    [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); ViewerCellId property name consistent across Task 2 + Task 3. ResolveResult.CellId confirmed = sp.CurCellId. ✓