docs(spec): Phase A8 — full WB RenderInsideOut + RenderOutsideIn port (design)
Re-brainstormed after RR0 falsification showed R3+R3.5 introduced
Issues A+C (rendering all 16 BFS-reachable cells at full screen extent
caused co-planar Z-fight + grace-state leak from outside view). The
prior design's "WB-faithful restructure" was insufficient — it kept
the BFS-wide cell rendering. Retail and WB both solve indoor visibility
with per-portal recursive culling.
This design ports WB's full pipeline:
- RenderInsideOut Steps 1-5 (including 3-stencil-bit cross-building)
- RenderOutsideIn (cottage interiors visible through windows from outside)
- Per-building cell association (Building + BuildingRegistry, plus
LoadedCell.BuildingId for O(1) cell→building lookups)
- Single strict cameraInsideBuilding gate (no grace for render path)
- Stencil-gated sky inside indoor branch (acdream enhancement)
12 tasks (RR1-RR12), 8-10 sessions estimated. M1.5 indoor scope ships fully.
Supersedes:
docs/superpowers/specs/2026-05-26-phase-a8-restructure-design.md
docs/superpowers/plans/2026-05-26-phase-a8-restructure.md
(both will be footer-marked in RR1 cleanup)
Reverts in RR1: R3 (60f07bc), R3.5 v1 (38d5374), R3.5 v2 (2bfeafd).
R1+R2 (data layer + dispatcher partition) stay — orthogonal infrastructure.
RR2 spike resolves the BuildingInfo data shape + interior-portal walk
algorithm against WB PortalRenderManager:518-551 before RR3 implements.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f9bab501df
commit
ea60d1fb7d
1 changed files with 451 additions and 0 deletions
|
|
@ -0,0 +1,451 @@
|
|||
# Phase A8 — Full WorldBuilder RenderInsideOut + RenderOutsideIn port (design)
|
||||
|
||||
**Date:** 2026-05-26 (PM, post-RR0 re-brainstorm)
|
||||
**Phase:** A8 — Indoor-cell visibility culling — **SCOPE EXPANSION** after RR0 falsified the prior design's "stencil-mask only" assumption.
|
||||
**Status:** Design approved 2026-05-26. Ready for `superpowers:writing-plans`.
|
||||
**Branch:** `claude/strange-albattani-3fc83c` (worktree)
|
||||
**Supersedes:** [docs/superpowers/specs/2026-05-26-phase-a8-restructure-design.md](2026-05-26-phase-a8-restructure-design.md) (footer-marked) and [docs/superpowers/plans/2026-05-26-phase-a8-restructure.md](../plans/2026-05-26-phase-a8-restructure.md) (footer-marked).
|
||||
|
||||
**Required predecessor reading:**
|
||||
- [docs/research/2026-05-26-a8-rr0-falsification-findings.md](../../research/2026-05-26-a8-rr0-falsification-findings.md) — the falsification spike that triggered this re-brainstorm
|
||||
- [docs/research/2026-05-26-a8-r3.5-restructure-handoff.md](../../research/2026-05-26-a8-r3.5-restructure-handoff.md) — full R3.5 saga
|
||||
- [docs/research/2026-05-26-a8-entity-taxonomy.md](../../research/2026-05-26-a8-entity-taxonomy.md) — entity taxonomy (R1+R2 ship-shape; still valid)
|
||||
- [references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilityManager.cs:73-330](../../../references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilityManager.cs) — the proven reference; both `RenderInsideOut` (lines 73-239) and `RenderOutsideIn` (lines 241+) must be ported
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
The prior design (`2026-05-26-phase-a8-restructure-design.md`) assumed Issues A and C from R4 visual verification were either pre-existing or fixable by removing a depth-clear workaround. The **RR0 falsification spike disproved both assumptions**: A and C are caused by R3's stencil pipeline wire-in — specifically by rendering **16 cells of a Holtburg cottage cluster simultaneously at full screen extent**. R3 implements WB's Step 4 (stencil-gate outdoor) but skips WB's Step 3 architecture (render only the camera-building's cells, not BFS-extended).
|
||||
|
||||
The fix is to **port WB's full `RenderInsideOut` and `RenderOutsideIn`** with per-building cell scoping, including Step 5 (3-stencil-bit cross-building visibility). This requires:
|
||||
|
||||
1. New data model: `Building` data class + `BuildingRegistry` per landblock + `LoadedCell.BuildingId` field (Option C of the data-model brainstorm — both directions indexed for O(1) lookups).
|
||||
2. New code path: `BuildingLoader` walks `LandBlockInfo.Buildings` at landblock load time, computes per-building cell sets via the portal graph.
|
||||
3. Render-frame restructure: single strict `cameraInsideBuilding` gate (replaces both `cameraInsideCell` lenient and `cameraReallyInside` strict); `IndoorPass` walks the camera-building's cells (not full BFS); stencil-gated sky for windows; Step 5 for cross-building visibility; `RenderOutsideIn` for outdoor camera looking into cottage windows.
|
||||
4. Revert R3, R3.5 v1, R3.5 v2 (the half-port). Keep R1 (`IsBuildingShell` flag) and R2 (`EntitySet` partition) — they remain useful infrastructure.
|
||||
|
||||
Twelve tasks (RR1–RR12), ~8-10 sessions (1.5-2 weeks calendar). M1.5 indoor-world acceptance scope ships fully.
|
||||
|
||||
---
|
||||
|
||||
## Why the scope expanded
|
||||
|
||||
Per RR0 evidence:
|
||||
|
||||
| Branch | Issue C | Issue A | #78 |
|
||||
|---|---|---|---|
|
||||
| HEAD (R3.5 v2) | YES | YES | (fixed by R3) |
|
||||
| R3 baseline (60f07bc) | YES | YES | (fixed by R3) |
|
||||
| main (7034be9, no A8) | NO | NO flicker | YES, constant |
|
||||
|
||||
R3's stencil-mask approach **fixes #78 by stencil-gating outdoor visibility** — but the underlying "render all 16 BFS-reachable cells at full screen" is structurally wrong. Co-planar cell meshes (cottage floor + cellar ceiling, both with +0.02m EnvCell render lift) Z-fight. During exit-grace, the same 16-cell pool leaks from outside view. R3 is half a WB port. Retail and WB both solve this with **per-portal recursive culling** (retail via polygon-clip scissor, WB via per-building stencil scoping).
|
||||
|
||||
The "minimum viable indoor visibility" turns out to be larger than the prior design assumed. We are committing to the full port now rather than iterating through more half-fixes.
|
||||
|
||||
---
|
||||
|
||||
## Brainstorm outcomes
|
||||
|
||||
| # | Question | Decision |
|
||||
|---|---|---|
|
||||
| Q1 | Scope: include WB Step 5 (cross-building visibility)? | **Yes — full RenderInsideOut including Step 5** |
|
||||
| Q2 | Data model for per-building cell association? | **Option C — Building registry + cell-side BuildingId field (both directions O(1))** |
|
||||
| Q3 | Outdoor→indoor visibility (RenderOutsideIn)? | **Yes — include in this phase** |
|
||||
| (implicit) | What about R3 + R3.5 v1 + R3.5 v2? | Revert (3 commits); R1+R2 stay |
|
||||
| (implicit) | Grace mechanism for render path? | Drop in render frame (use strict `cameraInsideBuilding`); grace stays alive in `CellVisibility` for non-render consumers |
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### One new gate flag (replaces the two-flag asymmetry)
|
||||
|
||||
```csharp
|
||||
bool cameraInsideBuilding = visibility?.CameraCell is not null
|
||||
&& CellVisibility.PointInCell(camPos, visibility.CameraCell)
|
||||
&& visibility.CameraCell.BuildingId is not null;
|
||||
```
|
||||
|
||||
Strict (PointInCell), no grace, AND requires the camera's cell to actually be a building cell. A cell with `BuildingId == null` is an outdoor cell or dungeon cell not tagged by `LandBlockInfo.Buildings`; those flow through the outdoor render path.
|
||||
|
||||
Drives: sky pre-scene, initial terrain, stencil-pipeline branch entry, weather post-scene, sky-PES debug. `playerInsideCell` (used for lighting in third-person chase mode) stays separate — different semantics (player position, not camera).
|
||||
|
||||
### Two render paths
|
||||
|
||||
**`cameraInsideBuilding == false` — outdoor path (matches WB `RenderOutsideIn`):**
|
||||
|
||||
1. Sky drawn normally
|
||||
2. Terrain drawn normally
|
||||
3. **`RenderOutsideIn`**: for each visible building in the frustum (per `BuildingRegistry.All()` filtered by frustum):
|
||||
- Stencil-mark its exit portals (single-bit stencil 1)
|
||||
- Render its `EnvCellIds` through stencil (visible only at portal silhouettes)
|
||||
- Use occlusion query (prev-frame result) to skip buildings whose portals weren't visible last frame
|
||||
4. `Draw(set: All)` outdoor entities (existing pre-A8 behavior)
|
||||
5. Weather
|
||||
|
||||
**`cameraInsideBuilding == true` — indoor path (matches WB `RenderInsideOut`):**
|
||||
|
||||
1. **Skip** initial sky (gated on `!cameraInsideBuilding`)
|
||||
2. **Skip** initial terrain (gated on `!cameraInsideBuilding`)
|
||||
3. **No depth-clear** (the old depth-clear-if-inside block is deleted)
|
||||
4. **MarkAndPunch** — stencil bit 1 + far-depth at the camera-buildings' exit portals (lookup via `registry.GetBuildingsContainingCell(visibility.CameraCell.CellId)`)
|
||||
5. **IndoorPass** — render the camera-buildings' cells via `WbDrawDispatcher.Draw(cellIds: camBuildings.SelectMany(b => b.EnvCellIds))`. Stencil off, DepthFunc.Less. **This is the structural fix.**
|
||||
6. **EnableOutdoorPass** — stencil read `Equal(1, 0x01)`
|
||||
7. **Stencil-gated sky** — `_skyRenderer.RenderSky` with DepthMask off (acdream enhancement; closes the "fog haze through windows" complaint)
|
||||
8. **Stencil-gated terrain re-draw**
|
||||
9. **Stencil-gated `OutdoorScenery`**
|
||||
10. **Step 5 — for each "other building"** (visible per frustum but NOT containing camera):
|
||||
- Read prev-frame occlusion query result
|
||||
- Begin new occlusion query
|
||||
- Mark stencil bit 2 at this other-building's portals (`StencilFunc.Equal(3, 0x01)`, `StencilMask(0x02)`) — bit 2 set only where bit 1 already set
|
||||
- End occlusion query
|
||||
- Clear depth where bits 1+2 set (re-draw portal mesh with `DepthFunc.Always`, `StencilFunc.Equal(3, 0x03)`)
|
||||
- Render this other-building's `EnvCellIds` through stencil == 3
|
||||
- Reset bit 2 for the next iteration
|
||||
11. **DisableStencil**
|
||||
12. **LiveDynamic** — player + NPCs + dropped items, depth-test only
|
||||
|
||||
---
|
||||
|
||||
## Data model
|
||||
|
||||
### `Building` (new class)
|
||||
|
||||
`src/AcDream.App/Rendering/Wb/Building.cs`
|
||||
|
||||
```csharp
|
||||
namespace AcDream.App.Rendering.Wb;
|
||||
|
||||
public sealed class Building
|
||||
{
|
||||
public required uint BuildingId { get; init; }
|
||||
public required HashSet<uint> EnvCellIds { get; init; }
|
||||
public required IReadOnlyList<Vector3[]> ExitPortalPolygons { get; init; }
|
||||
|
||||
// Step 5 occlusion-query state (mutable, per-frame):
|
||||
public uint QueryId; // GL query id, lazily created
|
||||
public bool QueryStarted; // true after first BeginQuery
|
||||
public bool WasVisible; // result from prev-frame query; drives whether to render this frame
|
||||
}
|
||||
```
|
||||
|
||||
### `BuildingRegistry` (new class, per-landblock)
|
||||
|
||||
`src/AcDream.App/Rendering/Wb/BuildingRegistry.cs`
|
||||
|
||||
```csharp
|
||||
public sealed class BuildingRegistry
|
||||
{
|
||||
// Index 1: cell-id → list of buildings containing that cell.
|
||||
// Returns a list because a cell may be shared between buildings (rare; matches WB's API shape).
|
||||
private readonly Dictionary<uint, List<Building>> _byCellId = new();
|
||||
|
||||
// Index 2: building-id → Building.
|
||||
private readonly Dictionary<uint, Building> _byBuildingId = new();
|
||||
|
||||
public void Add(Building b) { /* keep both indexes in sync */ }
|
||||
|
||||
public IReadOnlyList<Building> GetBuildingsContainingCell(uint cellId) =>
|
||||
_byCellId.TryGetValue(cellId, out var list) ? list : Array.Empty<Building>();
|
||||
|
||||
public Building? GetById(uint buildingId) =>
|
||||
_byBuildingId.TryGetValue(buildingId, out var b) ? b : null;
|
||||
|
||||
public IEnumerable<Building> All() => _byBuildingId.Values;
|
||||
|
||||
public int Count => _byBuildingId.Count;
|
||||
}
|
||||
```
|
||||
|
||||
### `LoadedCell.BuildingId` (extension)
|
||||
|
||||
`src/AcDream.App/Rendering/CellVisibility.cs`
|
||||
|
||||
```csharp
|
||||
public sealed class LoadedCell
|
||||
{
|
||||
// ... existing fields ...
|
||||
|
||||
/// <summary>
|
||||
/// 2026-05-26 (Phase A8): the building this cell belongs to, if any.
|
||||
/// Set at landblock load time by <see cref="BuildingLoader"/> from
|
||||
/// <c>LandBlockInfo.Buildings</c>. Null when the cell isn't part of any
|
||||
/// building (outdoor surface cells, or dungeon cells not enumerated
|
||||
/// in Buildings).
|
||||
/// </summary>
|
||||
public uint? BuildingId { get; init; }
|
||||
}
|
||||
```
|
||||
|
||||
### `BuildingLoader` (new static)
|
||||
|
||||
`src/AcDream.App/Rendering/Wb/BuildingLoader.cs`
|
||||
|
||||
Pure factory. Inputs: `LandBlockInfo` + an existing dictionary `IReadOnlyDictionary<uint, LoadedCell> cellsByCellId`. Outputs: a `BuildingRegistry`.
|
||||
|
||||
Algorithm:
|
||||
1. For each `BuildingInfo` in `info.Buildings`:
|
||||
a. Allocate a sequential `BuildingId`.
|
||||
b. Walk its `Portals` (list of `CBldPortal`-equivalent items in DatReaderWriter). Each portal points to one indoor cell (`OtherCellId`).
|
||||
c. Build the building's `EnvCellIds` set by adding each portal's `OtherCellId` AND any cells reachable from those via **interior portals only** (no exit portals — those connect to outdoor). Walk the portal graph BFS-style; mark visited; stop when no new cells found OR hitting an exit portal.
|
||||
d. Build the building's `ExitPortalPolygons` by collecting portal polygons from each cell whose `OtherCellId == 0xFFFF` (existing `PortalMeshBuilder.BuildTriangles` logic, but per-building scoped).
|
||||
2. Return the registry.
|
||||
|
||||
**Open question RR2 resolves:** does WB's per-building cell set include ALL cells reachable from the building's entry portals (via any interior portal), or only the cells DIRECTLY in `BuildingInfo.Portals`? This needs verifying against WB's code (`references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/PortalRenderManager.cs:518-551`). The algorithm above assumes "all interior-reachable cells" which is the conservative/correct interpretation; RR2's spike confirms before RR3 implements.
|
||||
|
||||
### Cell-side mutation
|
||||
|
||||
`LoadedCell` is a `sealed class` with `init` properties. To set `BuildingId` after `BuildingLoader` runs, we either:
|
||||
- Add an internal setter (and document the lifecycle: "set once during landblock load, never mutated after")
|
||||
- Or recreate the `LoadedCell` instances (expensive — many references)
|
||||
- Or move `BuildingId` to a side-table on `BuildingRegistry` (gives up cell-side O(1) lookup → Option A, not chosen)
|
||||
|
||||
**Decision: internal setter** (`{ get; internal set; }`). Documented as "set exactly once by `BuildingLoader.Apply(registry, cells)` immediately after `LandblockLoader` produces the cells; never mutated thereafter." The narrowness keeps it safe.
|
||||
|
||||
---
|
||||
|
||||
## Render-frame data flow (frame-by-frame)
|
||||
|
||||
### Outdoor frame (`cameraInsideBuilding == false`)
|
||||
|
||||
Pre-frame: `glClear(Color | Depth)` at frame start (line 6900). Stencil **also cleared** in this restructure (was not before; the missing stencil clear is potentially load-bearing — see Risk Register).
|
||||
|
||||
```
|
||||
1. Sky pre-scene (existing _skyRenderer.RenderSky)
|
||||
- Writes color + depth at far plane
|
||||
2. Terrain
|
||||
- Writes color + depth at terrain Z
|
||||
3. RenderOutsideIn — for each visible building:
|
||||
- Stencil bit 1 at building's exit portals
|
||||
- Stencil-gated render of building's EnvCells (occlusion-query-driven)
|
||||
- Reset stencil between iterations
|
||||
4. Draw(set: All) — outdoor entities (trees, stabs, dynamic)
|
||||
5. Weather post-scene
|
||||
```
|
||||
|
||||
### Indoor frame (`cameraInsideBuilding == true`)
|
||||
|
||||
```
|
||||
Pre-frame: glClear(Color | Depth | Stencil)
|
||||
|
||||
1. Skip sky pre-scene (gated on !cameraInsideBuilding)
|
||||
2. Skip terrain (gated on !cameraInsideBuilding)
|
||||
3. (No depth-clear — block deleted)
|
||||
|
||||
4. MarkAndPunch
|
||||
- Stencil bit 1 + depth=1.0 at camera-buildings' exit portal silhouettes
|
||||
- GL state on exit: stencil off, depth normal, masks all
|
||||
|
||||
5. IndoorPass — WbDrawDispatcher.Draw(set: IndoorPass, cellIds: camBuildings.SelectMany(b => b.EnvCellIds))
|
||||
- Walks ONLY the camera-buildings' cells (typically 1-3 cells for a Holtburg cottage cluster)
|
||||
- + building shells (IsBuildingShell from R1)
|
||||
- Stencil off, DepthFunc.Less
|
||||
- Writes cottage wall/floor/ceiling depth that protects against outdoor leak
|
||||
|
||||
6. EnableOutdoorPass
|
||||
- StencilFunc.Equal(1, 0x01), StencilMask 0x00
|
||||
|
||||
7. Stencil-gated sky
|
||||
- DepthMask off; _skyRenderer.RenderSky; DepthMask on
|
||||
- Sky color writes through punched depth=1.0 only where stencil bit 1 is set (portal silhouettes)
|
||||
- DepthMask off so the punched depth survives for step 8
|
||||
|
||||
8. Stencil-gated terrain re-draw
|
||||
- Terrain Z (with f48c74a -0.01 nudge) overwrites the punched 1.0 at portal pixels
|
||||
- Color: terrain near-field through window; beyond terrain horizon, sky color from step 7 survives
|
||||
|
||||
9. Stencil-gated OutdoorScenery
|
||||
- WbDrawDispatcher.Draw(set: OutdoorScenery)
|
||||
- Stabs/procedural scenery visible at portal silhouettes
|
||||
|
||||
10. Step 5 — for each other building in otherBuildings:
|
||||
a. Read prev-frame occlusion query result → b.WasVisible
|
||||
b. Begin new occlusion query
|
||||
c. Mark stencil bit 2 at this building's portals
|
||||
- StencilFunc.Equal(3, 0x01), StencilMask 0x02
|
||||
- Replace where bit 1 already set
|
||||
d. End occlusion query
|
||||
e. Clear depth where stencil == 3 (re-draw portals with DepthFunc.Always)
|
||||
f. Render this building's EnvCells where stencil == 3
|
||||
- WbDrawDispatcher.Draw(set: IndoorPass, cellIds: building.EnvCellIds)
|
||||
g. Reset bit 2 for next iteration
|
||||
- Replace where bit 2 set with bit 2 cleared
|
||||
|
||||
11. DisableStencil
|
||||
|
||||
12. LiveDynamic — player + NPCs + dropped items
|
||||
- Stencil off, depth-test only
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Components & file map
|
||||
|
||||
| File | Status | Purpose |
|
||||
|---|---|---|
|
||||
| `src/AcDream.App/Rendering/Wb/Building.cs` | **NEW** | `Building` data class (cell ids + portal polygons + occlusion-query state) |
|
||||
| `src/AcDream.App/Rendering/Wb/BuildingRegistry.cs` | **NEW** | Per-landblock registry; two indexes (`byCellId`, `byBuildingId`) |
|
||||
| `src/AcDream.App/Rendering/Wb/BuildingLoader.cs` | **NEW** | Static factory: reads `LandBlockInfo.Buildings` + interior-portal walk; produces registry; sets `LoadedCell.BuildingId` |
|
||||
| `src/AcDream.App/Rendering/CellVisibility.cs` (`LoadedCell`) | MODIFY | Add `BuildingId: uint?` (init + internal setter) |
|
||||
| `src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs` | **EXTEND** | Add 3-bit mode for Step 5; add occlusion-query helpers; building-portal upload helper |
|
||||
| `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` | MINOR | New overload `Draw(... cellIds: IEnumerable<uint>, ...)` to walk an explicit cell list (used by IndoorPass + Step 5) |
|
||||
| `src/AcDream.App/Rendering/GameWindow.cs` | RESTRUCTURE | Render frame: single `cameraInsideBuilding` gate; new indoor branch with Steps 1-5; new outdoor branch with RenderOutsideIn pass; landblock-load wires `BuildingLoader` |
|
||||
| `src/AcDream.App/Rendering/Shaders/portal_stencil.{vert,frag}` | LIKELY OK | Re-used as-is for Step 5 (uniform/state changes are in the C# pipeline class, not the shader) |
|
||||
| `tests/AcDream.App.Tests/Rendering/Wb/BuildingLoaderTests.cs` | **NEW** | Mock `LandBlockInfo`; assert cell mapping + interior-portal walk + exit-portal extraction |
|
||||
| `tests/AcDream.App.Tests/Rendering/Wb/BuildingRegistryTests.cs` | **NEW** | Two-way indexing invariants |
|
||||
| `tests/AcDream.App.Tests/Rendering/IndoorCellStencilPipelineTests.cs` | EXTEND | 3-bit mode + occlusion-query state machine |
|
||||
| `tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherEntitySetTests.cs` | EXTEND | New `Draw(cellIds:)` overload tests |
|
||||
| `docs/superpowers/specs/2026-05-26-phase-a8-restructure-design.md` | FOOTER-MARK | Mark as SUPERSEDED by this doc; brief note pointing here |
|
||||
| `docs/superpowers/plans/2026-05-26-phase-a8-restructure.md` | FOOTER-MARK | Same — SUPERSEDED |
|
||||
| `docs/ISSUES.md` | UPDATE (in RR12) | Move #78 to closed; close #102 (subsumed by Step 5); file new follow-ups if any |
|
||||
| `CLAUDE.md` | UPDATE (in RR12) | Update A8 paragraph to SHIPPED with full description |
|
||||
|
||||
**Commits to revert at the start of execution (RR1):**
|
||||
- `2bfeafd` R3.5 v2 (depth-clear gate)
|
||||
- `38d5374` R3.5 v1 (stencil-branch gate)
|
||||
- `60f07bc` R3 (stencil-pipeline wire-in)
|
||||
|
||||
**Commits kept:**
|
||||
- `ed72704` R1 — IsBuildingShell tag (still useful — drives EntitySet partition + Step 5 building-shell rendering)
|
||||
- `55f26f2` R2 — EntitySet partition (still useful — IndoorPass/OutdoorScenery/LiveDynamic semantics unchanged)
|
||||
- Tasks 1-6 infrastructure (PortalPolygons field, IndoorCellStencilPipeline, portal_stencil shaders, ProbeVisibilityEnabled)
|
||||
|
||||
**Uncommitted local change to commit before reverts:** the `[vis]` probe wired in GameWindow.cs (RenderingDiagnostics gate). Useful long-term diagnostic; commit as a standalone preserving improvement.
|
||||
|
||||
---
|
||||
|
||||
## Testing strategy
|
||||
|
||||
### Unit tests (TDD where feasible)
|
||||
|
||||
- **`BuildingLoaderTests`** — fixture `LandBlockInfo` with synthetic `Buildings` array; assert:
|
||||
- Single-portal building maps to one cell
|
||||
- Multi-portal building maps to multiple cells
|
||||
- Interior-portal walk reaches all connected cells
|
||||
- Exit portal extraction collects right polygons
|
||||
- Empty Buildings → empty registry
|
||||
- **`BuildingRegistryTests`** — add/lookup two-way invariants; cell shared between buildings handled; Count accurate.
|
||||
- **`IndoorCellStencilPipelineTests`** — 3-bit mode portal-triangle math (existing pattern); occlusion-query state machine if mockable (BeginQuery / EndQuery / read-back lifecycle).
|
||||
- **`WbDrawDispatcherEntitySetTests`** — new `Draw(cellIds:)` overload: explicit cell list filters indoor entities correctly; combined with EntitySet partition works as expected.
|
||||
|
||||
### Visual verification matrix (per RR8, RR10, RR12)
|
||||
|
||||
| Scenario | Acceptance |
|
||||
|---|---|
|
||||
| Cottage interior (ground floor) | Walls solid; no see-through to outside; **sky visible through windows** (Issue B closure) |
|
||||
| Cottage cellar | Walls solid; **cottage floor SOLID, no transparent floor showing cellar** (Issue C closure); no grass overlay through stairs from inside |
|
||||
| Holtburg Inn (multi-room) | All walls solid; **no see-through to adjacent rooms** (no cross-room portal leak); sky through windows |
|
||||
| Dungeon (no buildings) | Corridor walls solid; indoor lighting; cells without BuildingId render via fallback path |
|
||||
| Exit transition (indoor → outdoor) | Clean — **no through-ground flicker** (Issue A closure); no missing walls on adjacent cottages |
|
||||
| Entry transition (outdoor → indoor) | Clean — no transparent floor; no texture flicker |
|
||||
| Cross-building (Step 5) | Stand inside Holtburg Inn, look through window at a cottage across the street with the cottage's window facing you; **cottage interior visible through its own window through the inn's window** |
|
||||
| Looking-into-windows from outside (RenderOutsideIn) | Stand outside a cottage, look at its window; **cottage interior visible** (cottage walls, furniture, NPCs inside) |
|
||||
| No regression on #100 | No transparent rectangles around cottages |
|
||||
|
||||
### Risk register
|
||||
|
||||
| Risk | Likelihood | Mitigation |
|
||||
|---|---|---|
|
||||
| `LandBlockInfo.Buildings` portal data shape differs from WB's expectations | Medium | **RR2 spike** dumps Holtburg landblock's Buildings array structure before RR3 designs/implements |
|
||||
| Interior-portal walk algorithm has subtle rules I don't capture in BuildingLoader | Medium | RR2 reads WB `PortalRenderManager:518-551` verbatim; algorithm matches WB |
|
||||
| 3-bit stencil + occlusion-query has GPU-specific quirks | Medium | RR6 mirrors WB's exact GL state sequence; tests on dev machine; comments document WB line refs |
|
||||
| Occlusion queries cause CPU stall if read same-frame | Low | WB uses prev-frame results (asynchronous); we mirror this |
|
||||
| Step 5 logic complex, multiple iterations needed | High | **RR8 gate** isolates Steps 1-4 first; if A+C+#78 closed at RR8, Step 5 can ship as a follow-on without blocking M1.5 indoor acceptance |
|
||||
| Some Holtburg cottages don't have `BuildingInfo` entries (older content) | Medium | BuildingLoader handles missing data: cells without a Building get `BuildingId == null`, flow through the outdoor render path |
|
||||
| Stencil-buffer clear missing at frame start (line 6900 currently only clears Color+Depth) | High | Restructure adds `ClearBufferMask.StencilBufferBit` to the per-frame clear. Validate no other code relied on stencil persisting between frames |
|
||||
| `LoadedCell` immutability broken by `internal set` on BuildingId | Low | Documented narrowly — BuildingLoader.Apply is the sole writer, runs once after LandblockLoader |
|
||||
| RR2 spike reveals BuildingInfo structure incompatible with the design | Low-Medium | Re-brainstorm gate at RR2 outcome; design doc updated; subsequent tasks adapted |
|
||||
|
||||
### Falsifiability
|
||||
|
||||
The visual matrix is the acceptance test. If any scenario fails at RR8 or RR12, the failure mode determines next step:
|
||||
- **Issue C or A reproduces** → IndoorPass partition wrong; debug with `[vis]` probe; check `camBuildings.EnvCellIds` actually narrows to the camera's cottage cluster
|
||||
- **Sky-through-window fails** → step 7 GL state issue; check DepthMask + stencil interaction
|
||||
- **Cross-building (Step 5) fails** → likely 3-bit stencil math wrong; compare per-iteration GL state against WB line-by-line
|
||||
- **RenderOutsideIn fails** → check building-frustum culling + per-building portal mesh upload
|
||||
|
||||
Each failure has a deterministic next investigation step. We avoid the "speculative fix → another iteration" anti-pattern.
|
||||
|
||||
---
|
||||
|
||||
## Task breakdown
|
||||
|
||||
| # | Task | Sub-skill | Est. session | Outputs |
|
||||
|---|---|---|---|---|
|
||||
| **RR1** | Cleanup: commit `[vis]` probe (uncommitted); revert R3+R3.5 v1+R3.5 v2; footer-mark old design + plan as SUPERSEDED | (mechanical) | 0.5 | 1 probe commit, 3 revert commits, 2 footer-marks |
|
||||
| **RR2** | **Spike**: dump `LandBlockInfo.Buildings` for Holtburg cottages (e.g. landblock 0xA9B40000); read WB `PortalRenderManager:518-551`; write findings doc `2026-05-2X-a8-buildings-data-shape.md` locking the data shape + interior-portal walk algorithm | (research) | 0.5-1 | Findings doc |
|
||||
| **RR3** | TDD-implement `Building`, `BuildingRegistry`, `BuildingLoader` (+ unit tests) | impl | 1 | 3 new files + 2 test files |
|
||||
| **RR4** | Wire `BuildingRegistry` into landblock load + `LoadedCell.BuildingId` propagation | impl | 0.5 | GameWindow.cs ctor wiring + LoadedCell change |
|
||||
| **RR5** | Extend `WbDrawDispatcher` with `Draw(cellIds:)` overload (TDD) | impl | 0.5 | Dispatcher + test extension |
|
||||
| **RR6** | Extend `IndoorCellStencilPipeline` for 3-bit mode + occlusion-query helpers | impl | 1 | Pipeline class extension + tests |
|
||||
| **RR7** | Restructure render frame: WB-faithful Steps 1-4 + stencil-gated sky + outdoor branch (no Step 5 yet) + initial sky/terrain gates | impl | 1 | GameWindow.cs render-frame block rewrite |
|
||||
| **RR8** | **Visual verification gate**: Steps 1-4 working (cottage / cellar / inn / dungeon / sky-through-windows / clean transitions). Closes #78 + R4 Issues A+C. | (visual) | 0.5 | RR8 findings doc; gate decision |
|
||||
| **RR9** | Implement Step 5 (cross-building visibility) | impl | 1-2 | Pipeline + GameWindow.cs additions |
|
||||
| **RR10** | **Visual verification gate**: Step 5 working (inn→window→cottage across street) | (visual) | 0.5 | RR10 findings doc |
|
||||
| **RR11** | Implement `RenderOutsideIn` (outdoor camera looking into cottage windows) | impl | 1 | Outdoor-branch addition |
|
||||
| **RR12** | Final visual matrix + ship docs (close #78; update CLAUDE.md A8 paragraph; supersede old docs in their footers) | (visual + docs) | 0.5 | ISSUES.md + CLAUDE.md commits |
|
||||
|
||||
**Total estimate: 8-10 sessions** (~1.5-2 weeks calendar).
|
||||
|
||||
**Gate decisions:**
|
||||
- After **RR2** outcome: if `LandBlockInfo.Buildings` structure incompatible → re-brainstorm.
|
||||
- After **RR8** outcome: if any of #78, Issue A, Issue C still reproduces → debug into RR7 implementation BEFORE proceeding to RR9 (Step 5 amplifies bugs).
|
||||
- After **RR10** outcome: if Step 5 visibly broken → file as a known issue and proceed to RR11+RR12. Step 5 is a polish-tier feature; Steps 1-4 carry M1.5.
|
||||
|
||||
---
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
### Alt 1 — `cameraInsideCell` (lenient) gate everywhere
|
||||
|
||||
Re-use the existing lenient flag. Means grace frames stay treated as "inside" for the render frame. Reintroduces every grace-state bug seen in R3.5. Rejected: the entire RR0 analysis points to dropping grace for the render path.
|
||||
|
||||
### Alt 2 — `LoadedCell.BuildingId` as the SOLE data source (Option B from brainstorm)
|
||||
|
||||
Cells carry building-id; no registry; rebuilds the per-building cell set per frame by scanning all cells. Performance fine at our scale, but the API becomes awkward (no `building.EnvCellIds` to iterate). Rejected per the data-model brainstorm.
|
||||
|
||||
### Alt 3 — Per-cell Z-offset to break co-planar Z-fight
|
||||
|
||||
Apply `glPolygonOffset` differently per cell to resolve the cottage-floor / cellar-ceiling tie. Doesn't address the structural "render 16 cells at full screen" issue — just patches one of its symptoms. Rejected as another half-fix in the R3 lineage.
|
||||
|
||||
### Alt 4 — Retail polygon-clip scissor port
|
||||
|
||||
Most faithful to retail (`PView::DrawCells` at `acclient_2013_pseudo_c.txt:432709`). Implements per-portal screen-space clipping recursively. Multi-week scope. WB's stencil approach is a known-good equivalent observable behavior; preferring WB matches "rendering: WB is acdream's base" per CLAUDE.md.
|
||||
|
||||
### Alt 5 — Defer all A8 work; live with #78 on main
|
||||
|
||||
Revert R1+R2+R3+R3.5; ship M1.5 without indoor visibility fix; come back later. Rejected per user direction ("Try to expand A8 scope to a full WB port now").
|
||||
|
||||
---
|
||||
|
||||
## Open follow-ups (post-RR12)
|
||||
|
||||
- **#78** — closed by this phase
|
||||
- **#102** (cross-cell-portal far-side visibility, WB Step 5 deferral) — closed by Step 5 in this phase
|
||||
- **#103** (cellar terrain Z-fight from outside, out-to-in artifact) — pre-existing; may be addressed by `RenderOutsideIn` if the cellar entrance is treated as a building portal; verify in RR12
|
||||
- **Grace-mechanism cleanup** — non-render consumers may still benefit from grace in `CellVisibility.FindCameraCell`; auditing them is out of scope. Future cleanup phase.
|
||||
|
||||
---
|
||||
|
||||
## Self-review notes
|
||||
|
||||
- **Placeholder scan:** no TBDs/TODOs/incomplete sections. RR2 explicitly resolves the "open question" about WB's interior-portal walk algorithm via the spike + findings doc; that's a CONCRETE next step, not a placeholder.
|
||||
- **Internal consistency:** `cameraInsideBuilding` semantics stated once and used consistently across all sections. `BuildingId` lifecycle stated once (set at load by `BuildingLoader.Apply`, never mutated thereafter). Step numbering (1-12) consistent between architecture, data-flow, and task breakdown sections.
|
||||
- **Scope check:** twelve tasks, ~8-10 sessions, decomposed by surface (data model → dispatcher → pipeline → render frame → Step 5 → RenderOutsideIn → ship). Each task is single-PR-sized. No scope sprawl.
|
||||
- **Ambiguity check:**
|
||||
- "render only the camera-buildings' cells" — explicit about USING the registry, not BFS
|
||||
- "stencil-gated sky with DepthMask off + on" — explicit about depth-buffer effect
|
||||
- "RR2 spike resolves the interior-portal walk algorithm" — explicit about what's settled vs deferred
|
||||
- "RR8 gate decision" — explicit about what failure→action mapping looks like
|
||||
|
||||
---
|
||||
|
||||
## Notes for the writing-plans skill
|
||||
|
||||
When this design enters `superpowers:writing-plans`:
|
||||
- Each RR# task above maps to one plan task.
|
||||
- TDD where feasible (RR3, RR5, RR6 have clear unit-test surfaces).
|
||||
- GL integration tasks (RR7, RR9, RR11) are visual-verification-only.
|
||||
- Spike (RR2) is research-only; output is a findings doc, no production code.
|
||||
- Add to the spec reviewer prompt for RR7, RR9, RR11: *"Does the implementation match WB's `RenderInsideOut`/`RenderOutsideIn` at `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilityManager.cs:73-330` line-by-line? Reviewer must cross-reference."*
|
||||
Loading…
Add table
Add a link
Reference in a new issue