Replaces the four reverted RR7 variants from 2026-05-27 with a verbatim port of WB VisibilityManager.RenderInsideOut. Plan covers 10 tasks across 5 dependency waves: - Wave 1 (tasks 1-4, 7): extract WbRenderPass, WbFrustum, EnvCellSceneryInstance/EnvCellLandblock, EnvCellVisibilitySnapshot; add IndoorCellStencilPipeline.RenderBuildingStencilMask - Wave 2 (task 5): build EnvCellRenderer with inline RenderModernMDI - Wave 3 (task 6): wire EnvCellRenderer into landblock streaming - Wave 4 (task 8): port RenderInsideOutAcdream byte-for-byte - Wave 5 (task 9): probe trail [envcells]/[stencil]/[draworder]/[buildings] - Wave 6 (task 10): probe-gated visual verification launch Process rules carved from RR7 saga: - No visual gate without probe data first - No partial WB ports (Steps 1-5 ship together) - No conceptual adaptations - Trust-but-verify after every subagent Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1831 lines
77 KiB
Markdown
1831 lines
77 KiB
Markdown
# Phase A8 — WB RenderInsideOut Port Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Port WorldBuilder's `VisibilityManager.RenderInsideOut` algorithm into acdream's render frame so indoor cells render correctly with stencil-gated outdoor visibility through portals. Replaces the four reverted RR7 variants from 2026-05-27.
|
|
|
|
**Architecture:** Extract WB's render-time data model (`RenderPass`, `Frustum`, `SceneryInstance`, `EnvCellLandblock`, `EnvCellVisibilitySnapshot`) verbatim into our tree. Build a new `EnvCellRenderer` class that mirrors WB's `EnvCellRenderManager.Render(filter:)` and `PrepareRenderBatches` byte-for-byte, but registers its per-cell instances from acdream's existing landblock streaming pipeline (`BuildInteriorEntitiesForStreaming` at `GameWindow.cs:5367+`) instead of from WB's editor `LandscapeDocument`. Extend `IndoorCellStencilPipeline` with a low-level `RenderBuildingStencilMask(building, vp, writeFarDepth)` matching WB's `PortalRenderManager.RenderBuildingStencilMask` API. Replicate `VisibilityManager.RenderInsideOut` Steps 1-5 verbatim in `GameWindow.cs`'s render method as a new method `RenderInsideOutAcdream`. The cell-as-WorldEntity hack at `GameWindow.cs:5417-5428` is removed — cell meshes flow through `EnvCellRenderer` only.
|
|
|
|
**Tech Stack:** C# .NET 10; Silk.NET.OpenGL 4.3+ with bindless textures + `glMultiDrawElementsIndirect`; WB-extracted `ObjectMeshManager` + `WbDrawDispatcher` infrastructure (already in tree). DatReaderWriter 2.1.x NuGet for dat type access.
|
|
|
|
**WB ground-truth references (the algorithm we are porting):**
|
|
- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilityManager.cs:73-239` — `RenderInsideOut` (Steps 1-5)
|
|
- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/EnvCellRenderManager.cs:247-373` — `PrepareRenderBatches`
|
|
- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/EnvCellRenderManager.cs:395-511` — `Render(RenderPass, HashSet<uint>?)`
|
|
- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectRenderManagerBase.cs:990-1103` — instance batching context (for inline RenderModernMDI helper)
|
|
- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/PortalRenderManager.cs:471-484` — `RenderBuildingStencilMask`
|
|
|
|
**Process rules (carved from the 2026-05-27 RR7 saga):**
|
|
1. No visual-gate launch until probe data confirms indoor branch fires + envcells walked + stencil mask non-empty. "Looks good" without diagnostic correlation is not verification.
|
|
2. No partial WB ports. Steps 1-5 ship together in one render-frame restructure.
|
|
3. No conceptual adaptations. Where our infrastructure has an existing analog (e.g., `ObjectMeshManager.TryGetRenderData`), use it 1:1; do not "improve."
|
|
4. Trust-but-verify after every subagent. Read the actual diff before declaring done.
|
|
5. Single visual gate when build + tests + probes are all green.
|
|
|
|
---
|
|
|
|
## File map
|
|
|
|
| Path | Status | Purpose |
|
|
|---|---|---|
|
|
| `src/AcDream.App/Rendering/Wb/WbRenderPass.cs` | **NEW** | WB `RenderPass` enum (renamed to avoid conflict if we ever add another). |
|
|
| `src/AcDream.App/Rendering/Wb/WbFrustum.cs` | **NEW** | WB `Frustum` class — 98 LOC verbatim. |
|
|
| `src/AcDream.App/Rendering/Wb/EnvCellSceneryInstance.cs` | **NEW** | WB `SceneryInstance` struct (renamed scope-narrow) + `EnvCellLandblock` (stripped from `ObjectLandblock`). |
|
|
| `src/AcDream.App/Rendering/Wb/EnvCellVisibilitySnapshot.cs` | **NEW** | WB `VisibilitySnapshot` (renamed). |
|
|
| `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs` | **NEW** | The core port. Implements WB's `EnvCellRenderManager.Render(filter:)` + `PrepareRenderBatches` + a stripped-down `RenderModernMDI`. ~700 LOC. |
|
|
| `src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs` | **EXTEND** | Add `RenderBuildingStencilMask(building, vp, writeFarDepth)` low-level method matching WB's API. |
|
|
| `src/AcDream.App/Rendering/GameWindow.cs` | **MODIFY** | (a) `BuildInteriorEntitiesForStreaming` — replace cell-as-WorldEntity creation with `EnvCellRenderer.RegisterCell(...)`; (b) `_envCellRenderer` field + init in ctor; (c) `RenderInsideOutAcdream` new method; (d) render-frame call site swap. |
|
|
| `src/AcDream.Core/Rendering/RenderingDiagnostics.cs` | **MINOR** | Add `ProbeEnvCellEnabled` flag (already has `ProbeVisibilityEnabled` from RR7's [vis] probe; we layer on top). |
|
|
| `tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs` | **NEW** | Snapshot batching + filter behavior (no GL — pure data tests). |
|
|
| `tests/AcDream.App.Tests/Rendering/Wb/WbFrustumTests.cs` | **NEW** | Frustum primitive tests. |
|
|
| `tests/AcDream.App.Tests/Rendering/Wb/EnvCellSceneryInstanceTests.cs` | **NEW** | Per-cell registration + bounds union. |
|
|
| `tests/AcDream.App.Tests/Rendering/IndoorCellStencilPipelineTests.cs` | **EXTEND** | Add `RenderBuildingStencilMask` exists + math contract (existing 9 stencil tests stay). |
|
|
|
|
**No external project dependencies added.** All extractions live in our tree alongside the existing WB-extracted code from Phase O.
|
|
|
|
---
|
|
|
|
## Architecture deep-dive: the data flow split
|
|
|
|
**Pre-A8 (broken — what RR7 inherited):**
|
|
|
|
```
|
|
Landblock load
|
|
→ BuildInteriorEntitiesForStreaming
|
|
→ CellMesh.Build(envCell, cellStruct) → cellSubMeshes
|
|
→ _pendingCellMeshes[envCellId] = cellSubMeshes ← populated but never consumed
|
|
→ WorldEntity { MeshRefs = [MeshRef(envCellId, ...)] } ← envCellId NOT a real GfxObj id
|
|
→ _worldState.AddEntity(...)
|
|
→ ObjectMeshManager.PrepareMeshDataAsync runs internally for some entity refs
|
|
|
|
Render frame
|
|
→ WbDrawDispatcher.Draw(set: IndoorPass, cellIds: ...) walks WorldEntities including cell-shells
|
|
→ For each entity.MeshRef: looks up TryGetRenderData(MeshRef.GfxObjId)
|
|
→ MeshRef.GfxObjId is envCellId (e.g. 0xA9B40143) — no matching GfxObj in dats
|
|
→ Cell renders nothing; floor is fog color
|
|
```
|
|
|
|
**Post-A8 (the WB-faithful path):**
|
|
|
|
```
|
|
Landblock load
|
|
→ BuildInteriorEntitiesForStreaming
|
|
→ CellMesh.Build(...) (kept for physics — feeds _physicsDataCache)
|
|
→ For each EnvCell with non-null EnvironmentId:
|
|
_envCellRenderer.RegisterCell(landblockKey, envCellId, envCell, cellStruct, transform)
|
|
- Internally: calls ObjectMeshManager.PrepareEnvCellGeomMeshDataAsync(deduplicatedGeomId, envId, cellStructure, surfaces)
|
|
(this populates ObjectMeshManager's _renderData with the actual cell mesh)
|
|
- Internally: also calls PrepareMeshDataAsync for each StaticObject in the cell
|
|
- Builds SceneryInstance records (one per cell + one per stab in cell) for the landblock
|
|
→ Cell-as-WorldEntity creation DELETED (the broken path)
|
|
|
|
Per frame: _envCellRenderer.Update()
|
|
→ Drains any pending registrations into the active landblocks dict
|
|
→ Marks NeedsPrepare = true
|
|
|
|
Per frame: PrepareRenderBatches(viewProj, camPos) [called once before render frame begins]
|
|
→ Frustum-culls each landblock's TotalEnvCellBounds
|
|
→ For visible landblocks, frustum-tests per-cell EnvCellBounds
|
|
→ Builds VisibilitySnapshot.BatchedByCell: cellId → gfxObjId → InstanceData[]
|
|
→ Atomic swap under _renderLock
|
|
|
|
Render frame
|
|
→ If cameraInsideBuilding (strict — see gate semantics below):
|
|
RenderInsideOutAcdream(...)
|
|
Step 1: stencil bit 1 at camera-building portals (uses IndoorCellStencilPipeline.RenderBuildingStencilMask)
|
|
Step 2: depth-punch at camera-building portals
|
|
Step 3: ColorMask on, stencil off, DepthFunc.Less, sceneryShader.Bind()
|
|
_envCellRenderer.Render(pass1RenderPass, _currentEnvCellIds)
|
|
Step 4: stencil-gated terrain + scenery + static objects (via existing _terrain.Render + dispatcher.Draw(set: OutdoorScenery))
|
|
Step 5: per other-building 3-bit stencil pipeline (cross-building visibility)
|
|
→ Else (outdoor):
|
|
Existing outdoor render path (RenderOutsideIn deferred to a later phase; sky + terrain + dispatcher.Draw(set: All))
|
|
→ LiveDynamic always last: dispatcher.Draw(set: LiveDynamic) with stencil disabled
|
|
```
|
|
|
|
**Gate semantics — `cameraInsideBuilding`:**
|
|
|
|
```csharp
|
|
bool cameraInsideBuilding =
|
|
visibility?.CameraCell is not null
|
|
&& CellVisibility.PointInCell(camPos, visibility.CameraCell) // STRICT — no grace
|
|
&& visibility.CameraCell.BuildingId is not null; // RR4-stamped
|
|
```
|
|
|
|
Strict (no grace), AND requires `BuildingId != null`. A cell tagged null is an outdoor surface cell or a dungeon cell not enumerated in `LandBlockInfo.Buildings` — those flow through the outdoor render path.
|
|
|
|
---
|
|
|
|
## Probe trail (mandatory before visual gate)
|
|
|
|
Probes gate on `ACDREAM_PROBE_VIS=1` (existing flag) **OR** new `ACDREAM_PROBE_ENVCELL=1`. The new flag activates only the `[envcells]` family; `[stencil]`, `[draworder]`, `[buildings]` ride on `ACDREAM_PROBE_VIS=1`. Both flags can be runtime-toggled via DebugPanel.
|
|
|
|
| Tag | Frequency | Format | Fires from |
|
|
|---|---|---|---|
|
|
| `[envcells]` | per frame, indoor branch only | `[envcells] cells={N} tris={M} ourBldgs={B1} otherBldgs={B2} filterCnt={F}` | `EnvCellRenderer.Render` exit |
|
|
| `[stencil]` | per `RenderBuildingStencilMask` call | `[stencil] op={mark\|punch} bld={bldId} verts={N}` | `IndoorCellStencilPipeline.RenderBuildingStencilMask` |
|
|
| `[draworder]` | per indoor frame | `[draworder] step={1\|2\|3\|4\|5x} stencil={on\|off} depthFn={cmp} colorMask={rgba}` | `RenderInsideOutAcdream` step boundaries |
|
|
| `[buildings]` | per indoor frame | `[buildings] camCell={cellId:X8} camBldgs={[ids]} otherBldgs={[ids]} totalKnown={N}` | `RenderInsideOutAcdream` entry |
|
|
|
|
**Mandatory probe acceptance criteria before visual launch:**
|
|
- `[buildings] camBldgs={...}` non-empty when player is inside a Holtburg cottage (CellId in 0xA9B4014x range).
|
|
- `[envcells] cells>=1 tris>=1 filterCnt>=1` for at least one frame in that scenario.
|
|
- `[stencil] verts>0` for at least one mark+punch pair per camera-building.
|
|
- `[draworder]` shows exactly five step transitions per indoor frame (Step 1 → 2 → 3 → 4 → 5).
|
|
|
|
---
|
|
|
|
## Task breakdown
|
|
|
|
### Task 1: Extract `RenderPass` enum
|
|
|
|
**Files:**
|
|
- Create: `src/AcDream.App/Rendering/Wb/WbRenderPass.cs`
|
|
- WB source: `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/RenderPass.cs:1-22`
|
|
|
|
- [ ] **Step 1: Create the enum file**
|
|
|
|
```csharp
|
|
// src/AcDream.App/Rendering/Wb/WbRenderPass.cs
|
|
namespace AcDream.App.Rendering.Wb;
|
|
|
|
/// <summary>
|
|
/// Phase A8 (2026-05-28): WB's RenderPass enum, extracted verbatim from
|
|
/// references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/RenderPass.cs.
|
|
/// Renamed to <c>WbRenderPass</c> so it doesn't clash if we ever add an
|
|
/// acdream-side RenderPass with different semantics.
|
|
/// </summary>
|
|
public enum WbRenderPass
|
|
{
|
|
/// <summary>The opaque pass. Only non-transparent objects are rendered.</summary>
|
|
Opaque = 0,
|
|
|
|
/// <summary>The transparent pass. Only transparent objects are rendered, usually after the opaque pass.</summary>
|
|
Transparent = 1,
|
|
|
|
/// <summary>
|
|
/// A single-pass render that includes both opaque and (sometimes) transparent objects,
|
|
/// or for special cases like skyboxes and certain UI elements.
|
|
/// </summary>
|
|
SinglePass = 2,
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Build green**
|
|
|
|
Run: `dotnet build src/AcDream.App/AcDream.App.csproj`
|
|
Expected: PASS — no consumers yet.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/AcDream.App/Rendering/Wb/WbRenderPass.cs
|
|
git commit -m "$(cat <<'EOF'
|
|
feat(render): Phase A8 — extract WB RenderPass enum
|
|
|
|
Verbatim port of references/WorldBuilder/.../RenderPass.cs:1-22. Renamed
|
|
to WbRenderPass to avoid future conflict. First step of the WB
|
|
RenderInsideOut port that replaces the four reverted RR7 variants from
|
|
2026-05-27.
|
|
|
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Extract `Frustum` class
|
|
|
|
**Files:**
|
|
- Create: `src/AcDream.App/Rendering/Wb/WbFrustum.cs`
|
|
- WB source: `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Frustum.cs:1-98`
|
|
- Test: `tests/AcDream.App.Tests/Rendering/Wb/WbFrustumTests.cs`
|
|
|
|
- [ ] **Step 1: Read WB source verbatim**
|
|
|
|
Read `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Frustum.cs` in full. The class extracts six planes from a view-projection matrix and exposes `TestBox(BoundingBox)` returning `FrustumTestResult.Inside/Outside/Intersecting`, plus `Intersects(BoundingBox)` returning bool. It depends on `Chorizite.Core.Lib.BoundingBox` and `Chorizite.Core.Lib.FrustumTestResult`.
|
|
|
|
- [ ] **Step 2: Copy verbatim and adapt**
|
|
|
|
Copy the entire 98-LOC class. Adapt:
|
|
- Namespace: `Chorizite.OpenGLSDLBackend` → `AcDream.App.Rendering.Wb`
|
|
- Class name: `Frustum` → `WbFrustum` (avoid conflict with any future acdream Frustum)
|
|
- `BoundingBox` type: replace with `System.Numerics.Vector3` pairs OR introduce a tiny `WbBoundingBox` struct (preferred — keep WB's API shape). The struct has just `Vector3 Min, Max`. Inline at the top of the file.
|
|
- `FrustumTestResult` enum: inline `public enum FrustumTestResult { Inside, Outside, Intersecting }` at the top.
|
|
|
|
**Add a NoteFromExtraction comment block** citing the WB source path + commit-date.
|
|
|
|
- [ ] **Step 3: Write failing tests**
|
|
|
|
```csharp
|
|
// tests/AcDream.App.Tests/Rendering/Wb/WbFrustumTests.cs
|
|
using System.Numerics;
|
|
using AcDream.App.Rendering.Wb;
|
|
using Xunit;
|
|
|
|
namespace AcDream.App.Tests.Rendering.Wb;
|
|
|
|
public class WbFrustumTests
|
|
{
|
|
private static Matrix4x4 IdentityVp() =>
|
|
Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 4f, 1.0f, 0.1f, 100.0f);
|
|
|
|
[Fact]
|
|
public void TestBox_PointAtOrigin_ReturnsInside()
|
|
{
|
|
var f = new WbFrustum();
|
|
// Look down -Z from origin toward Z=-5
|
|
var view = Matrix4x4.CreateLookAt(new Vector3(0,0,0), new Vector3(0,0,-1), Vector3.UnitY);
|
|
f.Update(view * IdentityVp());
|
|
var box = new WbBoundingBox(new Vector3(-1, -1, -10), new Vector3(1, 1, -2));
|
|
var res = f.TestBox(box);
|
|
Assert.Equal(FrustumTestResult.Inside, res);
|
|
}
|
|
|
|
[Fact]
|
|
public void TestBox_BehindCamera_ReturnsOutside()
|
|
{
|
|
var f = new WbFrustum();
|
|
var view = Matrix4x4.CreateLookAt(new Vector3(0,0,0), new Vector3(0,0,-1), Vector3.UnitY);
|
|
f.Update(view * IdentityVp());
|
|
var box = new WbBoundingBox(new Vector3(-1, -1, 2), new Vector3(1, 1, 10)); // BEHIND
|
|
var res = f.TestBox(box);
|
|
Assert.Equal(FrustumTestResult.Outside, res);
|
|
}
|
|
|
|
[Fact]
|
|
public void TestBox_StraddlingNear_ReturnsIntersecting()
|
|
{
|
|
var f = new WbFrustum();
|
|
var view = Matrix4x4.CreateLookAt(new Vector3(0,0,0), new Vector3(0,0,-1), Vector3.UnitY);
|
|
f.Update(view * IdentityVp());
|
|
var box = new WbBoundingBox(new Vector3(-1, -1, 1), new Vector3(1, 1, -1)); // straddles
|
|
var res = f.TestBox(box);
|
|
Assert.Equal(FrustumTestResult.Intersecting, res);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests, verify red→green**
|
|
|
|
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~WbFrustumTests"`
|
|
Expected: 3 tests pass.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/AcDream.App/Rendering/Wb/WbFrustum.cs tests/AcDream.App.Tests/Rendering/Wb/WbFrustumTests.cs
|
|
git commit -m "$(cat <<'EOF'
|
|
feat(render): Phase A8 — extract WB Frustum class
|
|
|
|
Verbatim port of references/WorldBuilder/.../Frustum.cs (98 LOC).
|
|
Renamed to WbFrustum to avoid future conflict. Inline WbBoundingBox +
|
|
FrustumTestResult to keep WB API shape. 3 unit tests pass.
|
|
|
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Extract `SceneryInstance` + `EnvCellLandblock`
|
|
|
|
**Files:**
|
|
- Create: `src/AcDream.App/Rendering/Wb/EnvCellSceneryInstance.cs`
|
|
- WB source: `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/SceneryInstance.cs:1-161`
|
|
- Test: `tests/AcDream.App.Tests/Rendering/Wb/EnvCellSceneryInstanceTests.cs`
|
|
|
|
- [ ] **Step 1: Copy SceneryInstance struct verbatim**
|
|
|
|
Port the entire struct (lines 11-56). Keep all fields. Adapt namespace `Chorizite.OpenGLSDLBackend.Lib` → `AcDream.App.Rendering.Wb`. Replace `BoundingBox` references with our `WbBoundingBox` from Task 2. Replace `SceneryDisqualificationReason` with `int` or drop it (we don't use this field — only the editor cares about scenery disqualification).
|
|
|
|
Drop `DisqualificationReason` field and `SceneryDisqualificationReason` enum entirely. Keep all other fields — even ones we don't currently use, since WB's algorithm may consult them.
|
|
|
|
- [ ] **Step 2: Copy ObjectLandblock as EnvCellLandblock (stripped)**
|
|
|
|
Port WB's `ObjectLandblock` class (lines 62-160) — but RENAME to `EnvCellLandblock` since we ONLY use it for env-cell rendering (procedural scenery + outdoor stabs use our existing pipeline). Strip the following editor-only or modern-MDI-shared fields that we don't need for cell rendering:
|
|
- `IsQueuedForUpload`, `IsTransformOnlyUpdate`
|
|
- `ParticleEmitters` (we have our own particle path)
|
|
- `InstanceBufferOffset`, `InstanceCount`, `MdiCommands` (we'll handle MDI in `EnvCellRenderer` differently)
|
|
|
|
Keep:
|
|
- `GridX`, `GridY`, `Lock`, `Instances`, `EnvCellBounds`, `SeenOutsideCells`,
|
|
`PendingInstances`, `PendingEnvCellBounds`, `PendingSeenOutsideCells`,
|
|
`StaticPartGroups`, `BuildingPartGroups`, `BoundingBox`, `TotalEnvCellBounds`,
|
|
`PendingTotalEnvCellBounds`, `InstancesReady`, `MeshDataReady`, `GpuReady`.
|
|
|
|
The two `PartGroups` dictionaries are the heart of the data model — `Dictionary<ulong gfxObjId, List<InstanceData>>`. We need `InstanceData` from our existing `src/AcDream.App/Rendering/Wb/InstanceData.cs`.
|
|
|
|
- [ ] **Step 3: Write failing tests**
|
|
|
|
```csharp
|
|
// tests/AcDream.App.Tests/Rendering/Wb/EnvCellSceneryInstanceTests.cs
|
|
using System.Numerics;
|
|
using AcDream.App.Rendering.Wb;
|
|
using Xunit;
|
|
|
|
namespace AcDream.App.Tests.Rendering.Wb;
|
|
|
|
public class EnvCellSceneryInstanceTests
|
|
{
|
|
[Fact]
|
|
public void Instance_Construct_HoldsAllFields()
|
|
{
|
|
var t = Matrix4x4.CreateTranslation(1, 2, 3);
|
|
var s = new EnvCellSceneryInstance
|
|
{
|
|
ObjectId = 0x01000123,
|
|
IsBuilding = true,
|
|
WorldPosition = new Vector3(1, 2, 3),
|
|
Transform = t,
|
|
};
|
|
Assert.True(s.IsBuilding);
|
|
Assert.Equal(0x01000123UL, s.ObjectId);
|
|
}
|
|
|
|
[Fact]
|
|
public void Landblock_PartGroups_StartEmpty()
|
|
{
|
|
var lb = new EnvCellLandblock { GridX = 0xA9, GridY = 0xB4 };
|
|
Assert.Empty(lb.StaticPartGroups);
|
|
Assert.Empty(lb.BuildingPartGroups);
|
|
Assert.False(lb.InstancesReady);
|
|
}
|
|
|
|
[Fact]
|
|
public void Landblock_AddInstance_GroupsByGfxObjId()
|
|
{
|
|
var lb = new EnvCellLandblock();
|
|
var ins = new InstanceData { /* default */ };
|
|
lb.StaticPartGroups.GetOrAdd(0x01000001UL).Add(ins);
|
|
lb.StaticPartGroups.GetOrAdd(0x01000001UL).Add(ins);
|
|
Assert.Single(lb.StaticPartGroups);
|
|
Assert.Equal(2, lb.StaticPartGroups[0x01000001UL].Count);
|
|
}
|
|
}
|
|
|
|
internal static class DictExt
|
|
{
|
|
public static List<InstanceData> GetOrAdd(this Dictionary<ulong, List<InstanceData>> d, ulong k)
|
|
{
|
|
if (!d.TryGetValue(k, out var v)) { v = new(); d[k] = v; }
|
|
return v;
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Build + test**
|
|
|
|
Run: `dotnet build && dotnet test --filter "FullyQualifiedName~EnvCellSceneryInstanceTests"`
|
|
Expected: 3 tests pass.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/AcDream.App/Rendering/Wb/EnvCellSceneryInstance.cs tests/AcDream.App.Tests/Rendering/Wb/EnvCellSceneryInstanceTests.cs
|
|
git commit -m "$(cat <<'EOF'
|
|
feat(render): Phase A8 — extract SceneryInstance + EnvCellLandblock
|
|
|
|
Verbatim port of WB's SceneryInstance struct and stripped-down
|
|
ObjectLandblock (renamed EnvCellLandblock; dropped editor-only and
|
|
MDI-aggregation fields that we don't reuse). 3 unit tests pass.
|
|
|
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Extract `VisibilitySnapshot`
|
|
|
|
**Files:**
|
|
- Create: `src/AcDream.App/Rendering/Wb/EnvCellVisibilitySnapshot.cs`
|
|
- WB source: `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilitySnapshot.cs:1-36`
|
|
|
|
- [ ] **Step 1: Copy verbatim with rename**
|
|
|
|
```csharp
|
|
// src/AcDream.App/Rendering/Wb/EnvCellVisibilitySnapshot.cs
|
|
using System.Collections.Generic;
|
|
|
|
namespace AcDream.App.Rendering.Wb;
|
|
|
|
/// <summary>
|
|
/// Phase A8 (2026-05-28): EnvCell-scoped visibility snapshot. Direct port of
|
|
/// WB's VisibilitySnapshot, renamed because we only use the per-cell variant
|
|
/// (no scenery / static-object snapshot).
|
|
/// </summary>
|
|
public sealed class EnvCellVisibilitySnapshot
|
|
{
|
|
/// <summary>Landblocks fully or partially inside the frustum.</summary>
|
|
public List<EnvCellLandblock> VisibleLandblocks { get; init; } = new();
|
|
|
|
/// <summary>
|
|
/// Grouped instance data by CellId.
|
|
/// Key: full 32-bit CellId; Value: { GfxObjId: List<InstanceData> }.
|
|
/// </summary>
|
|
public Dictionary<uint, Dictionary<ulong, List<InstanceData>>> BatchedByCell { get; init; } = new();
|
|
|
|
/// <summary>Whether this snapshot contains any visible cells.</summary>
|
|
public bool IsEmpty => VisibleLandblocks.Count == 0 && BatchedByCell.Count == 0;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Build green**
|
|
|
|
Run: `dotnet build`
|
|
Expected: PASS — no consumers yet.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/AcDream.App/Rendering/Wb/EnvCellVisibilitySnapshot.cs
|
|
git commit -m "$(cat <<'EOF'
|
|
feat(render): Phase A8 — extract EnvCellVisibilitySnapshot
|
|
|
|
Direct port of WB's VisibilitySnapshot, narrowed to the per-cell variant
|
|
(BatchedByCell only). Used by EnvCellRenderer for thread-safe render-time
|
|
state.
|
|
|
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Build `EnvCellRenderer`
|
|
|
|
**Files:**
|
|
- Create: `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs` (~700 LOC)
|
|
- Test: `tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs`
|
|
- WB sources:
|
|
- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/EnvCellRenderManager.cs:247-373` (PrepareRenderBatches)
|
|
- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/EnvCellRenderManager.cs:395-511` (Render)
|
|
- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectRenderManagerBase.cs:RenderModernMDI` (find by grep — modern multi-draw path)
|
|
|
|
This is **the** task. It's the largest and most consequential. Subagents executing this MUST read WB's sources line-by-line and follow them.
|
|
|
|
#### Step 5.1: Skeleton + constructor
|
|
|
|
```csharp
|
|
// src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs
|
|
//
|
|
// Phase A8 (2026-05-28): port of WB's EnvCellRenderManager. This is the
|
|
// production cell-rendering pipeline for indoor visibility, replacing the
|
|
// broken "cell as WorldEntity with MeshRef(envCellId)" approach that the
|
|
// four reverted RR7 variants couldn't fix.
|
|
//
|
|
// Sources ported byte-for-byte:
|
|
// PrepareRenderBatches ← WB EnvCellRenderManager.cs:247-373
|
|
// Render(filter:) ← WB EnvCellRenderManager.cs:395-511
|
|
// (inline) RenderModernMDI ← WB ObjectRenderManagerBase.cs:[grep]
|
|
//
|
|
// Note we do NOT inherit from WB's ObjectRenderManagerBase. That base
|
|
// class owns the landblock-streaming loop (Update, _pendingGeneration,
|
|
// _uploadQueue). acdream's StreamingController already does that work —
|
|
// running a parallel loop would compete for dat I/O. Instead, we expose
|
|
// RegisterCell(...) as the seam: callers populate our instance store at
|
|
// the point they already hydrate cells (GameWindow.BuildInteriorEntitiesForStreaming).
|
|
|
|
using System;
|
|
using System.Collections.Concurrent;
|
|
using System.Collections.Generic;
|
|
using System.Numerics;
|
|
using System.Threading;
|
|
using AcDream.App.Rendering;
|
|
using Silk.NET.OpenGL;
|
|
|
|
namespace AcDream.App.Rendering.Wb;
|
|
|
|
public sealed unsafe class EnvCellRenderer : IDisposable
|
|
{
|
|
private readonly GL _gl;
|
|
private readonly ObjectMeshManager _meshManager;
|
|
private readonly WbFrustum _frustum;
|
|
|
|
// Per-landblock storage. Key = full 32-bit landblock id (e.g. 0xA9B40000).
|
|
private readonly ConcurrentDictionary<uint, EnvCellLandblock> _landblocks = new();
|
|
|
|
// Active snapshot (atomic swap under _renderLock).
|
|
private readonly object _renderLock = new();
|
|
private EnvCellVisibilitySnapshot _activeSnapshot = new();
|
|
|
|
// Shader (set by caller via Initialize).
|
|
private GLSLShader? _shader;
|
|
private bool _initialized;
|
|
|
|
// List pool — copied from WB ObjectRenderManagerBase.
|
|
private readonly List<List<InstanceData>> _listPool = new();
|
|
private int _poolIndex = 0;
|
|
|
|
// Modern-MDI scratch buffers (one slot — frame-by-frame draw).
|
|
private uint _mdiCommandBuffer;
|
|
private int _mdiCommandCapacity = 1024;
|
|
private uint _modernInstanceBuffer;
|
|
private int _modernInstanceCapacity = 1024;
|
|
|
|
public bool NeedsPrepare { get; private set; } = true;
|
|
|
|
public EnvCellRenderer(GL gl, ObjectMeshManager meshManager, WbFrustum frustum)
|
|
{
|
|
_gl = gl;
|
|
_meshManager = meshManager;
|
|
_frustum = frustum;
|
|
}
|
|
|
|
public void Initialize(GLSLShader shader)
|
|
{
|
|
_shader = shader;
|
|
AllocateMdiBuffers();
|
|
_initialized = true;
|
|
}
|
|
|
|
private void AllocateMdiBuffers()
|
|
{
|
|
// ... (MDI command buffer + instance buffer GL allocation — copied
|
|
// verbatim from BaseObjectRenderManager constructor, slot 0 only)
|
|
}
|
|
|
|
// ... (continued in subsequent steps)
|
|
}
|
|
```
|
|
|
|
- [ ] Implement the constructor + Initialize + AllocateMdiBuffers using WB `BaseObjectRenderManager.cs:62-100` (extract just the buffer allocation, drop the per-frame triple-buffering — we only need slot 0).
|
|
|
|
#### Step 5.2: `RegisterCell` — the streaming seam
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// Called by GameWindow.BuildInteriorEntitiesForStreaming at landblock-load
|
|
/// time, ONCE per EnvCell that has non-null EnvironmentId. Populates the
|
|
/// landblock's pending instance list with one entry per cell (the cell
|
|
/// geometry itself) and one per StaticObject in the cell.
|
|
///
|
|
/// After all cells in a landblock are registered, call <see cref="FinalizeLandblock"/>
|
|
/// to atomically swap pending → instances and recompute bounds.
|
|
///
|
|
/// NOTE: this method does NOT trigger mesh loading. The caller (or
|
|
/// ObjectMeshManager Tick) drives PrepareMeshDataAsync. We assume that
|
|
/// by the time PrepareRenderBatches runs in the render thread, the mesh
|
|
/// data is ready (or will silently skip via TryGetRenderData null check —
|
|
/// matching WB behavior).
|
|
/// </summary>
|
|
public void RegisterCell(
|
|
uint landblockId,
|
|
uint envCellId,
|
|
DatReaderWriter.DBObjs.EnvCell envCell,
|
|
DatReaderWriter.Types.CellStruct cellStruct,
|
|
Matrix4x4 cellTransform,
|
|
Vector3 cellWorldPosition,
|
|
Quaternion cellRotation,
|
|
IReadOnlyList<(uint StaticObjectId, Vector3 LocalPos, Quaternion Rot, bool IsSetup, Matrix4x4 Transform)> staticObjects)
|
|
{
|
|
// 1. Compute the deduplicated cell-geometry id (matches WB GetEnvCellGeomId).
|
|
var cellGeomId = GetEnvCellGeomId(envCell.EnvironmentId, envCell.CellStructure, envCell.Surfaces);
|
|
|
|
// 2. Trigger mesh prep for the cell geometry on ObjectMeshManager.
|
|
// This populates ObjectMeshManager._renderData[cellGeomId] when complete.
|
|
_ = _meshManager.PrepareEnvCellGeomMeshDataAsync(cellGeomId, envCell.EnvironmentId,
|
|
envCell.CellStructure, envCell.Surfaces);
|
|
|
|
// 3. Build local bounds from cellStruct vertices.
|
|
var localBounds = ComputeLocalBoundsFromCellStruct(cellStruct);
|
|
var worldBounds = TransformBoundingBox(localBounds, cellTransform);
|
|
|
|
// 4. Create the per-cell SceneryInstance.
|
|
var cellInstance = new EnvCellSceneryInstance
|
|
{
|
|
ObjectId = cellGeomId,
|
|
InstanceId = ObjectIdFromCellId(envCellId), // helper that packs to WB shape
|
|
IsBuilding = true,
|
|
IsEntryCell = false, // could be derived from entry-portal walk; default false
|
|
WorldPosition = cellWorldPosition,
|
|
Rotation = cellRotation,
|
|
Scale = Vector3.One,
|
|
Transform = cellTransform,
|
|
LocalBoundingBox = localBounds,
|
|
BoundingBox = worldBounds,
|
|
};
|
|
|
|
var lb = _landblocks.GetOrAdd(landblockId, id => new EnvCellLandblock
|
|
{
|
|
GridX = (int)((id >> 24) & 0xFFu),
|
|
GridY = (int)((id >> 16) & 0xFFu),
|
|
});
|
|
|
|
lock (lb.Lock)
|
|
{
|
|
lb.PendingInstances ??= new List<EnvCellSceneryInstance>(capacity: 32);
|
|
lb.PendingInstances.Add(cellInstance);
|
|
lb.PendingEnvCellBounds ??= new Dictionary<uint, WbBoundingBox>();
|
|
lb.PendingEnvCellBounds[envCellId] = worldBounds;
|
|
|
|
// Add static-object instances inside the cell.
|
|
foreach (var stab in staticObjects)
|
|
{
|
|
// Trigger mesh prep for the stab too (idempotent — ObjectMeshManager dedupes).
|
|
_ = _meshManager.PrepareMeshDataAsync(stab.StaticObjectId, stab.IsSetup);
|
|
|
|
var stabBoundsLocal = _meshManager.GetBounds(stab.StaticObjectId, stab.IsSetup)
|
|
?? default;
|
|
var stabBoundsWorld = TransformBoundingBox(stabBoundsLocal, stab.Transform);
|
|
|
|
lb.PendingInstances.Add(new EnvCellSceneryInstance
|
|
{
|
|
ObjectId = stab.StaticObjectId,
|
|
InstanceId = default,
|
|
IsSetup = stab.IsSetup,
|
|
IsBuilding = false,
|
|
Transform = stab.Transform,
|
|
BoundingBox = stabBoundsWorld,
|
|
LocalBoundingBox = stabBoundsLocal,
|
|
Scale = Vector3.One,
|
|
Rotation = stab.Rot,
|
|
});
|
|
|
|
// Union the cell bounds with the stab bounds.
|
|
var current = lb.PendingEnvCellBounds[envCellId];
|
|
lb.PendingEnvCellBounds[envCellId] = WbBoundingBox.Union(current, stabBoundsWorld);
|
|
}
|
|
}
|
|
}
|
|
|
|
public void FinalizeLandblock(uint landblockId)
|
|
{
|
|
if (!_landblocks.TryGetValue(landblockId, out var lb)) return;
|
|
lock (lb.Lock)
|
|
{
|
|
if (lb.PendingInstances is not null)
|
|
{
|
|
lb.Instances = lb.PendingInstances;
|
|
lb.PendingInstances = null;
|
|
}
|
|
if (lb.PendingEnvCellBounds is not null)
|
|
{
|
|
lb.EnvCellBounds = lb.PendingEnvCellBounds;
|
|
lb.PendingEnvCellBounds = null;
|
|
}
|
|
|
|
// Compute total bounds + populate PartGroups by walking instances.
|
|
var total = new WbBoundingBox(new Vector3(float.MaxValue), new Vector3(float.MinValue));
|
|
foreach (var b in lb.EnvCellBounds.Values) total = WbBoundingBox.Union(total, b);
|
|
lb.TotalEnvCellBounds = total;
|
|
|
|
// Populate PartGroups (mirrors WB EnvCellRenderManager.PopulatePartGroups).
|
|
PopulatePartGroups(lb);
|
|
|
|
lb.InstancesReady = true;
|
|
lb.GpuReady = true;
|
|
}
|
|
NeedsPrepare = true;
|
|
}
|
|
|
|
public void RemoveLandblock(uint landblockId)
|
|
{
|
|
_landblocks.TryRemove(landblockId, out _);
|
|
NeedsPrepare = true;
|
|
}
|
|
```
|
|
|
|
- [ ] Implement helpers:
|
|
- `GetEnvCellGeomId(envId, cellStruct, surfaces)` — verbatim copy of WB `EnvCellRenderManager.GetEnvCellGeomId` (lines 94-103).
|
|
- `PopulatePartGroups(lb)` — verbatim from WB `EnvCellRenderManager.PopulatePartGroups` (lines 572-580), but adapt to use our `InstanceData` and walk our `lb.Instances`. Per WB, it recursively walks Setup parts via `MeshManager.GetSetupParts`.
|
|
- `ComputeLocalBoundsFromCellStruct(cellStruct)` — iterate cellStruct.VertexArray.Vertices, compute Min/Max.
|
|
- `WbBoundingBox.Union(a, b)` — standard min/max merge. Add to WbBoundingBox struct.
|
|
- `TransformBoundingBox(local, transform)` — 8-corner transform + axis-aligned re-extract.
|
|
|
|
#### Step 5.3: `PrepareRenderBatches` — port verbatim from WB EnvCellRenderManager.cs:247-373
|
|
|
|
```csharp
|
|
public void PrepareRenderBatches(Matrix4x4 viewProjection, Vector3 cameraPosition, HashSet<uint>? filter = null)
|
|
{
|
|
if (!_initialized || cameraPosition.Z > 4000) return;
|
|
|
|
lock (_renderLock) { _poolIndex = 0; }
|
|
|
|
// (WB updates _cameraLbX/Y here from LandscapeDoc.Region — skip; we don't
|
|
// need camera-LB tracking for the snapshot, just frustum tests.)
|
|
|
|
// Step 1: filter loaded landblocks by GpuReady + InstancesReady + non-empty.
|
|
var landblocks = new List<EnvCellLandblock>();
|
|
foreach (var lb in _landblocks.Values)
|
|
if (lb.GpuReady && lb.Instances.Count > 0)
|
|
landblocks.Add(lb);
|
|
if (landblocks.Count == 0) return;
|
|
|
|
// Step 2: parallel frustum-cull per LB + per-cell, batch by cell.
|
|
// (WB uses ThreadLocal<Dictionary<...>> for thread-local accumulators.
|
|
// Port verbatim — see EnvCellRenderManager.cs:265-325.)
|
|
using var threadLocalBatchedByCell = new ThreadLocal<Dictionary<uint, Dictionary<ulong, List<InstanceData>>>>(
|
|
() => new(), trackAllValues: true);
|
|
|
|
var parallelOptions = new System.Threading.Tasks.ParallelOptions
|
|
{ MaxDegreeOfParallelism = Environment.ProcessorCount };
|
|
|
|
System.Threading.Tasks.Parallel.ForEach(landblocks, parallelOptions, lb =>
|
|
{
|
|
lock (lb.Lock)
|
|
{
|
|
var testResult = _frustum.TestBox(lb.TotalEnvCellBounds);
|
|
if (testResult == FrustumTestResult.Outside) return;
|
|
|
|
var lbBatchedByCell = threadLocalBatchedByCell.Value!;
|
|
|
|
if (testResult == FrustumTestResult.Inside)
|
|
{
|
|
// Fast path: all cells visible
|
|
foreach (var (gfxObjId, instances) in lb.BuildingPartGroups)
|
|
foreach (var instanceData in instances)
|
|
{
|
|
if (filter != null && !filter.Contains(instanceData.CellId)) continue;
|
|
AddToCellGroup(lbBatchedByCell, instanceData.CellId, gfxObjId, instanceData);
|
|
}
|
|
foreach (var (gfxObjId, instances) in lb.StaticPartGroups)
|
|
foreach (var instanceData in instances)
|
|
{
|
|
if (filter != null && !filter.Contains(instanceData.CellId)) continue;
|
|
AddToCellGroup(lbBatchedByCell, instanceData.CellId, gfxObjId, instanceData);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Slow path: per-cell frustum test
|
|
var visibleCells = new HashSet<uint>();
|
|
foreach (var kvp in lb.EnvCellBounds)
|
|
{
|
|
var cellId = kvp.Key;
|
|
if (filter != null && !filter.Contains(cellId)) continue;
|
|
if (_frustum.Intersects(kvp.Value)) visibleCells.Add(cellId);
|
|
}
|
|
if (visibleCells.Count > 0)
|
|
{
|
|
foreach (var (gfxObjId, instances) in lb.BuildingPartGroups)
|
|
foreach (var instanceData in instances)
|
|
if (visibleCells.Contains(instanceData.CellId))
|
|
AddToCellGroup(lbBatchedByCell, instanceData.CellId, gfxObjId, instanceData);
|
|
foreach (var (gfxObjId, instances) in lb.StaticPartGroups)
|
|
foreach (var instanceData in instances)
|
|
if (visibleCells.Contains(instanceData.CellId))
|
|
AddToCellGroup(lbBatchedByCell, instanceData.CellId, gfxObjId, instanceData);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Step 3: merge thread-locals
|
|
var newBatchedByCell = new Dictionary<uint, Dictionary<ulong, List<InstanceData>>>();
|
|
foreach (var local in threadLocalBatchedByCell.Values)
|
|
{
|
|
foreach (var kvp in local)
|
|
{
|
|
if (!newBatchedByCell.TryGetValue(kvp.Key, out var gfxDict))
|
|
{
|
|
gfxDict = new Dictionary<ulong, List<InstanceData>>();
|
|
newBatchedByCell[kvp.Key] = gfxDict;
|
|
}
|
|
foreach (var (gfxObjId, list) in kvp.Value)
|
|
{
|
|
if (!gfxDict.TryGetValue(gfxObjId, out var existing))
|
|
{
|
|
existing = GetPooledList();
|
|
gfxDict[gfxObjId] = existing;
|
|
}
|
|
existing.AddRange(list);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Step 4: atomic swap.
|
|
lock (_renderLock)
|
|
{
|
|
_activeSnapshot = new EnvCellVisibilitySnapshot
|
|
{
|
|
BatchedByCell = newBatchedByCell,
|
|
VisibleLandblocks = landblocks,
|
|
};
|
|
_poolIndex = 0;
|
|
NeedsPrepare = false;
|
|
}
|
|
}
|
|
|
|
private static void AddToCellGroup(Dictionary<uint, Dictionary<ulong, List<InstanceData>>> dst,
|
|
uint cellId, ulong gfxObjId, InstanceData data)
|
|
{
|
|
if (!dst.TryGetValue(cellId, out var gfx))
|
|
{
|
|
gfx = new Dictionary<ulong, List<InstanceData>>();
|
|
dst[cellId] = gfx;
|
|
}
|
|
if (!gfx.TryGetValue(gfxObjId, out var list))
|
|
{
|
|
list = new List<InstanceData>();
|
|
gfx[gfxObjId] = list;
|
|
}
|
|
list.Add(data);
|
|
}
|
|
|
|
private List<InstanceData> GetPooledList()
|
|
{
|
|
lock (_listPool)
|
|
{
|
|
if (_poolIndex < _listPool.Count) return _listPool[_poolIndex++];
|
|
var fresh = new List<InstanceData>();
|
|
_listPool.Add(fresh);
|
|
_poolIndex++;
|
|
return fresh;
|
|
}
|
|
}
|
|
```
|
|
|
|
**Note:** `InstanceData.CellId` field — verify our `src/AcDream.App/Rendering/Wb/InstanceData.cs` has this field. WB uses it for the cell-routing. If absent in our copy, ADD it (it's already in WB's InstanceData per `EnvCellRenderManager.cs:282-285` references).
|
|
|
|
#### Step 5.4: `Render(WbRenderPass, HashSet<uint>?)` — port verbatim from WB EnvCellRenderManager.cs:395-511
|
|
|
|
Port the entire method byte-for-byte, but:
|
|
- Replace `MeshManager.TryGetRenderData(gfxObjId)` with our `_meshManager.TryGetRenderData(gfxObjId)` (same API after extraction).
|
|
- Replace `RenderModernMDI(_shader, drawCalls, allInstances, renderPass)` with a CALL to an internal `RenderModernMDIInternal` method copied next.
|
|
- Drop the highlights/selection rendering block at the end (lines 486-510) — we don't have SelectedInstance / HoveredInstance in EnvCellRenderer (no editor selection state).
|
|
- Keep the `uFilterByCell` uniform set to 0 (matches WB).
|
|
- Drop the `_useModernRendering` fallback branch — our codebase asserts modern path at startup (Phase N.5).
|
|
|
|
The skeleton:
|
|
```csharp
|
|
public unsafe void Render(WbRenderPass renderPass, HashSet<uint>? filter = null)
|
|
{
|
|
if (!_initialized || _shader is null || _shader.Program == 0) return;
|
|
|
|
lock (_renderLock)
|
|
{
|
|
var snapshot = _activeSnapshot;
|
|
_shader.Use();
|
|
_poolIndex = snapshot.BatchedByCell.Count; // reset point
|
|
|
|
_shader.SetUniformInt("uRenderPass", (int)renderPass);
|
|
_shader.SetUniformInt("uFilterByCell", 0);
|
|
|
|
var allInstances = new List<InstanceData>();
|
|
var drawCalls = new List<(ObjectMeshManager.ObjectRenderData renderData, int count, int offset)>();
|
|
|
|
if (filter is null)
|
|
{
|
|
// Walk every cell in snapshot (no filter — used by outdoor RenderOutsideIn).
|
|
foreach (var (cellId, gfxDict) in snapshot.BatchedByCell)
|
|
foreach (var (gfxObjId, transforms) in gfxDict)
|
|
{
|
|
var rd = _meshManager.TryGetRenderData(gfxObjId);
|
|
if (rd != null && !rd.IsSetup)
|
|
{
|
|
drawCalls.Add((rd, transforms.Count, allInstances.Count));
|
|
allInstances.AddRange(transforms);
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// Group by gfxObjId across the filtered cells (minimizes draw calls).
|
|
var filteredGroups = new Dictionary<ulong, List<InstanceData>>();
|
|
var ownedLists = new HashSet<List<InstanceData>>();
|
|
|
|
foreach (var cellId in filter)
|
|
{
|
|
if (!snapshot.BatchedByCell.TryGetValue(cellId, out var gfxDict)) continue;
|
|
foreach (var (gfxObjId, transforms) in gfxDict)
|
|
{
|
|
if (transforms.Count == 0) continue;
|
|
if (!filteredGroups.TryGetValue(gfxObjId, out var list))
|
|
{
|
|
list = transforms;
|
|
filteredGroups[gfxObjId] = list;
|
|
}
|
|
else
|
|
{
|
|
if (list == transforms) continue;
|
|
if (!ownedLists.Contains(list))
|
|
{
|
|
var owned = new List<InstanceData>(list);
|
|
list = owned;
|
|
filteredGroups[gfxObjId] = list;
|
|
ownedLists.Add(list);
|
|
}
|
|
list.AddRange(transforms);
|
|
}
|
|
}
|
|
}
|
|
|
|
foreach (var (gfxObjId, transforms) in filteredGroups)
|
|
{
|
|
var rd = _meshManager.TryGetRenderData(gfxObjId);
|
|
if (rd != null && !rd.IsSetup)
|
|
{
|
|
drawCalls.Add((rd, transforms.Count, allInstances.Count));
|
|
allInstances.AddRange(transforms);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (allInstances.Count > 0)
|
|
RenderModernMDIInternal(_shader, drawCalls, allInstances, renderPass);
|
|
|
|
_shader.SetUniformVec4("uHighlightColor", Vector4.Zero);
|
|
_gl.BindVertexArray(0);
|
|
|
|
// Diagnostic probe ([envcells] is emitted by GameWindow at the call site
|
|
// since it needs to know our-buildings / other-buildings counts. We do
|
|
// expose LastFrameStats so the probe can read tris/cells).
|
|
_lastFrameStats.CellsRendered = filter?.Count ?? snapshot.BatchedByCell.Count;
|
|
_lastFrameStats.TrianglesDrawn = drawCalls.Sum(d => d.renderData.IndexCount / 3 * d.count);
|
|
}
|
|
}
|
|
|
|
public LastFrameStats Stats => _lastFrameStats;
|
|
public struct LastFrameStats { public int CellsRendered; public int TrianglesDrawn; }
|
|
private LastFrameStats _lastFrameStats;
|
|
```
|
|
|
|
#### Step 5.5: `RenderModernMDIInternal` — extract from WB BaseObjectRenderManager
|
|
|
|
Open `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectRenderManagerBase.cs`, search for `protected unsafe void RenderModernMDI` (it's a protected method around line 700-800). Read the method top-to-bottom. Copy it as a private method on `EnvCellRenderer`, stripping the multi-slot ring-buffer (we use one slot) and the consolidated-MDI dirty tracking (we re-upload every frame).
|
|
|
|
The essential algorithm is:
|
|
1. Build `DrawElementsIndirectCommand[]` from `drawCalls`, each command refs `renderData.FirstIndex` + `renderData.BaseVertex`.
|
|
2. Build `ModernBatchData[]` from `drawCalls`, each batch refs surface metadata id, instance buffer offset.
|
|
3. Upload `allInstances` to `_modernInstanceBuffer`.
|
|
4. Upload commands to `_mdiCommandBuffer`.
|
|
5. Bind global VAO/IBO (from ObjectMeshManager via `_meshManager.GetGlobalIBO`/`GetGlobalVAO`).
|
|
6. Bind SSBO bindings.
|
|
7. Issue `glMultiDrawElementsIndirect`.
|
|
|
|
Use existing `ObjectMeshManager` accessors for the global mesh buffer. If they don't exist as public, add them (return `uint VAO`, `uint IBO`).
|
|
|
|
- [ ] **Step 5.6: Write unit tests**
|
|
|
|
```csharp
|
|
// tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs
|
|
using System.Collections.Generic;
|
|
using System.Numerics;
|
|
using AcDream.App.Rendering.Wb;
|
|
using Xunit;
|
|
|
|
namespace AcDream.App.Tests.Rendering.Wb;
|
|
|
|
public class EnvCellRendererTests
|
|
{
|
|
// These tests cover the pure data-handling portions of EnvCellRenderer.
|
|
// The Render() and RenderModernMDIInternal() paths require a GL context
|
|
// and are visual-verified at the render frame, not here.
|
|
|
|
[Fact]
|
|
public void NewRenderer_HasEmptySnapshot()
|
|
{
|
|
var r = new EnvCellRenderer(gl: null!, meshManager: null!, frustum: new WbFrustum());
|
|
Assert.True(r.NeedsPrepare);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetEnvCellGeomId_Deterministic()
|
|
{
|
|
var surfaces = new List<ushort> { 1, 2, 3 };
|
|
var a = EnvCellRenderer.GetEnvCellGeomId(0x42, 7, surfaces);
|
|
var b = EnvCellRenderer.GetEnvCellGeomId(0x42, 7, surfaces);
|
|
Assert.Equal(a, b);
|
|
Assert.NotEqual(0UL, a & 0x2_0000_0000UL); // dedup bit set
|
|
}
|
|
|
|
[Fact]
|
|
public void GetEnvCellGeomId_DiffersByInputs()
|
|
{
|
|
var a = EnvCellRenderer.GetEnvCellGeomId(0x42, 7, new List<ushort> { 1, 2, 3 });
|
|
var b = EnvCellRenderer.GetEnvCellGeomId(0x43, 7, new List<ushort> { 1, 2, 3 });
|
|
var c = EnvCellRenderer.GetEnvCellGeomId(0x42, 7, new List<ushort> { 1, 2, 4 });
|
|
Assert.NotEqual(a, b);
|
|
Assert.NotEqual(a, c);
|
|
}
|
|
|
|
// (more tests for AddToCellGroup, PopulatePartGroups dispatch, etc.)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5.7: Build + test**
|
|
|
|
Run: `dotnet build && dotnet test --filter "EnvCellRendererTests"`
|
|
Expected: all tests pass.
|
|
|
|
- [ ] **Step 5.8: Commit**
|
|
|
|
```bash
|
|
git add src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs
|
|
git commit -m "$(cat <<'EOF'
|
|
feat(render): Phase A8 — EnvCellRenderer (WB EnvCellRenderManager port)
|
|
|
|
The core port: PrepareRenderBatches + Render(filter:) ported byte-for-byte
|
|
from WB EnvCellRenderManager.cs:247-511. Inline RenderModernMDIInternal
|
|
extracted from BaseObjectRenderManager (single-slot variant, drops the
|
|
3-slot ring used by WB's consolidated MDI). NOT inheriting from
|
|
ObjectRenderManagerBase — exposes RegisterCell(...) as the seam so our
|
|
existing streaming pipeline (StreamingController + LandblockStreamer)
|
|
populates the instance store at the existing landblock-load point in
|
|
GameWindow.BuildInteriorEntitiesForStreaming.
|
|
|
|
This is what RR7 should have done: render cells through their own pipeline
|
|
call, not through the per-GfxObj-batched WbDrawDispatcher.
|
|
|
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: Wire `EnvCellRenderer` into landblock streaming
|
|
|
|
**Files:**
|
|
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (constructor + `BuildInteriorEntitiesForStreaming` + `RemoveLandblock` paths)
|
|
|
|
- [ ] **Step 6.1: Add field + init**
|
|
|
|
In GameWindow.cs, near line 159 where `_buildingRegistries` is declared, add:
|
|
|
|
```csharp
|
|
// Phase A8 (2026-05-28): WB EnvCellRenderManager port. Cells render
|
|
// through this dedicated pipeline now, not through WbDrawDispatcher.
|
|
private AcDream.App.Rendering.Wb.EnvCellRenderer? _envCellRenderer;
|
|
```
|
|
|
|
In the constructor (find the spot where `_wbMeshAdapter` is created), add:
|
|
|
|
```csharp
|
|
_envCellRenderer = new AcDream.App.Rendering.Wb.EnvCellRenderer(
|
|
_glContext.GL,
|
|
_wbMeshAdapter.MeshManager,
|
|
new AcDream.App.Rendering.Wb.WbFrustum());
|
|
|
|
// Initialize after the cell-mesh shader is loaded.
|
|
// Use the existing modern-mesh shader (mesh_modern.{vert,frag}) — that's
|
|
// what WB uses for env-cell rendering too.
|
|
_envCellRenderer.Initialize(_meshModernShader); // resolve the shader handle
|
|
```
|
|
|
|
- [ ] **Step 6.2: Modify `BuildInteriorEntitiesForStreaming`**
|
|
|
|
At `GameWindow.cs:5401-5435`, replace the cell-as-WorldEntity creation block. Old:
|
|
|
|
```csharp
|
|
var cellSubMeshes = AcDream.Core.Meshing.CellMesh.Build(envCell, cellStruct, _dats);
|
|
if (cellSubMeshes.Count > 0)
|
|
{
|
|
_pendingCellMeshes[envCellId] = cellSubMeshes;
|
|
...
|
|
var cellMeshRef = new AcDream.Core.World.MeshRef(envCellId, cellTransform);
|
|
var cellEntity = new AcDream.Core.World.WorldEntity { ... MeshRefs = new[] { cellMeshRef } };
|
|
result.Add(cellEntity);
|
|
...
|
|
}
|
|
```
|
|
|
|
New:
|
|
|
|
```csharp
|
|
// Phase A8 (2026-05-28): cells render through EnvCellRenderer, not as
|
|
// WorldEntities with fake MeshRefs. The CellMesh.Build call stays for
|
|
// physics (PhysicsDataCache uses it). The renderer registration replaces
|
|
// the WorldEntity creation entirely.
|
|
var cellSubMeshes = AcDream.Core.Meshing.CellMesh.Build(envCell, cellStruct, _dats);
|
|
if (cellSubMeshes.Count > 0)
|
|
{
|
|
var physicsCellOrigin = envCell.Position.Origin + lbOffset;
|
|
var cellOrigin = physicsCellOrigin + new System.Numerics.Vector3(0f, 0f, 0.02f);
|
|
var cellTransform =
|
|
System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
|
|
System.Numerics.Matrix4x4.CreateTranslation(cellOrigin);
|
|
var physicsCellTransform =
|
|
System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
|
|
System.Numerics.Matrix4x4.CreateTranslation(physicsCellOrigin);
|
|
|
|
// Walk this cell's static objects + their transforms.
|
|
var stabs = new List<(uint, System.Numerics.Vector3, System.Numerics.Quaternion, bool, System.Numerics.Matrix4x4)>();
|
|
foreach (var stab in envCell.StaticObjects)
|
|
{
|
|
var worldPos = stab.Frame.Origin + lbOffset;
|
|
var worldRot = stab.Frame.Orientation;
|
|
var stabTransform =
|
|
System.Numerics.Matrix4x4.CreateFromQuaternion(worldRot) *
|
|
System.Numerics.Matrix4x4.CreateTranslation(worldPos);
|
|
bool isSetup = (stab.Id & 0xFF000000u) == 0x02000000u;
|
|
stabs.Add((stab.Id, worldPos, worldRot, isSetup, stabTransform));
|
|
}
|
|
|
|
_envCellRenderer!.RegisterCell(
|
|
landblockId: landblockId,
|
|
envCellId: envCellId,
|
|
envCell: envCell,
|
|
cellStruct: cellStruct,
|
|
cellTransform: cellTransform,
|
|
cellWorldPosition: cellOrigin,
|
|
cellRotation: envCell.Position.Orientation,
|
|
staticObjects: stabs);
|
|
|
|
// Step 4: build LoadedCell for portal visibility (unchanged from pre-A8).
|
|
BuildLoadedCell(envCellId, envCell, cellStruct, cellOrigin, cellTransform);
|
|
|
|
// Cache CellStruct physics BSP for indoor collision (unchanged).
|
|
_physicsDataCache.CacheCellStruct(envCellId, envCell, cellStruct, physicsCellTransform);
|
|
}
|
|
```
|
|
|
|
**Delete** the static-object-as-WorldEntity creation block at lines 5440-5489 — those entities are now registered with `EnvCellRenderer.RegisterCell` inside the cell loop above (via the `stabs` list).
|
|
|
|
- [ ] **Step 6.3: Call `FinalizeLandblock` after the cell loop**
|
|
|
|
Where `ApplyLoadedTerrain` finishes building the landblock (just before the closing brace at ~line 5856-5857), call:
|
|
|
|
```csharp
|
|
_envCellRenderer?.FinalizeLandblock(lb.LandblockId);
|
|
```
|
|
|
|
- [ ] **Step 6.4: Wire `RemoveLandblock` callback**
|
|
|
|
In the `removeTerrain` callback near `GameWindow.cs:1844`:
|
|
|
|
```csharp
|
|
_envCellRenderer?.RemoveLandblock(id); // Phase A8
|
|
```
|
|
|
|
- [ ] **Step 6.5: Build green; run tests**
|
|
|
|
Run: `dotnet build && dotnet test`
|
|
Expected: all pre-existing tests still pass; the build does not regress on test count.
|
|
|
|
- [ ] **Step 6.6: Commit**
|
|
|
|
```bash
|
|
git add src/AcDream.App/Rendering/GameWindow.cs
|
|
git commit -m "$(cat <<'EOF'
|
|
feat(render): Phase A8 — wire EnvCellRenderer into landblock streaming
|
|
|
|
BuildInteriorEntitiesForStreaming no longer creates cell-as-WorldEntity
|
|
records. EnvCellRenderer.RegisterCell is called per cell + per static
|
|
object; FinalizeLandblock is called once per landblock load;
|
|
RemoveLandblock is called on unload. CellMesh.Build is kept (physics
|
|
still uses it via _physicsDataCache.CacheCellStruct).
|
|
|
|
The broken MeshRef(envCellId) WorldEntity path that all four RR7 variants
|
|
inherited is gone. Cells now go through EnvCellRenderer.Render(filter:)
|
|
exclusively, which routes through ObjectMeshManager's _renderData under
|
|
the correct deduplicated cellGeomId key.
|
|
|
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: `IndoorCellStencilPipeline.RenderBuildingStencilMask`
|
|
|
|
**Files:**
|
|
- Modify: `src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs`
|
|
- Test: `tests/AcDream.App.Tests/Rendering/IndoorCellStencilPipelineTests.cs` (extend)
|
|
|
|
- [ ] **Step 7.1: Add the new low-level method**
|
|
|
|
Append to `IndoorCellStencilPipeline`:
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// Phase A8 (2026-05-28): low-level building-portal stencil draw. Mirrors WB
|
|
/// <c>PortalRenderManager.RenderBuildingStencilMask</c> at <c>references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/PortalRenderManager.cs:471-484</c>.
|
|
///
|
|
/// <para>Uploads the building's exit-portal mesh to our shared VBO and draws
|
|
/// it with the portal_stencil shader. <strong>Does NOT set or restore any
|
|
/// surrounding GL state</strong> — caller is responsible (stencil func, depth
|
|
/// mask, color mask, cull face, etc.) per WB <c>VisibilityManager.RenderInsideOut</c>
|
|
/// Steps 1/2/5a/5b/5d expectations.</para>
|
|
///
|
|
/// <para>Mirrors the WB call signature: <c>(building, vp, writeFarDepth)</c>.
|
|
/// The <c>writeFarDepth</c> flag sets the shader uniform that controls whether
|
|
/// <c>gl_FragDepth = 1.0</c> is written (Step 2 punch) or default depth
|
|
/// (Step 1 mark).</para>
|
|
/// </summary>
|
|
public void RenderBuildingStencilMask(AcDream.App.Rendering.Wb.Building building, Matrix4x4 viewProjection, bool writeFarDepth)
|
|
{
|
|
int vertexCount = UploadBuildingPortalMesh(building);
|
|
if (vertexCount == 0) return;
|
|
|
|
_gl.Enable(EnableCap.DepthClamp);
|
|
|
|
_shader.Use();
|
|
var vp = viewProjection;
|
|
_gl.UniformMatrix4(_uViewProjectionLoc, 1, false, (float*)&vp);
|
|
_gl.Uniform1(_uWriteFarDepthLoc, writeFarDepth ? 1 : 0);
|
|
|
|
_gl.BindVertexArray(_vao);
|
|
_gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)vertexCount);
|
|
_gl.BindVertexArray(0);
|
|
|
|
_gl.Disable(EnableCap.DepthClamp);
|
|
|
|
// Diagnostic probe (caller-driven, surfaces last upload count).
|
|
LastStencilVertexCount = vertexCount;
|
|
LastStencilWasFarPunch = writeFarDepth;
|
|
LastStencilBuildingId = building.BuildingId;
|
|
}
|
|
|
|
// Probe data — read by the [stencil] probe emitter in GameWindow.
|
|
public int LastStencilVertexCount { get; private set; }
|
|
public bool LastStencilWasFarPunch { get; private set; }
|
|
public uint LastStencilBuildingId { get; private set; }
|
|
```
|
|
|
|
The existing `MarkAndPunch` / `EnableOutdoorPass` / `MarkBuildingBit2` / `PunchDepthAtStencil3` / `EnableOtherBuildingPass` / `ResetBit2` methods stay — they bundle their own state setup and remain useful for other consumers / future work. The new `RenderBuildingStencilMask` is the WB-faithful low-level entry that `RenderInsideOutAcdream` calls.
|
|
|
|
- [ ] **Step 7.2: Add unit test for the contract**
|
|
|
|
Test the method's existence + that it returns early if building has no portals. (Full GL behavior is visual-verified.)
|
|
|
|
```csharp
|
|
[Fact]
|
|
public void RenderBuildingStencilMask_EmptyBuilding_NoCrash()
|
|
{
|
|
// Headless test — only verifies the early-out path. Real GL calls
|
|
// happen on the render thread.
|
|
var b = new Building
|
|
{
|
|
BuildingId = 1,
|
|
EnvCellIds = new HashSet<uint>(),
|
|
ExitPortalPolygons = new List<Vector3[]>(), // empty
|
|
};
|
|
// Construct pipeline with mock GL not feasible here — verify by
|
|
// direct upload semantics: UploadBuildingPortalMesh returns 0 for
|
|
// empty list.
|
|
// (Skip this test if the pipeline ctor requires a real GL context;
|
|
// the contract is exercised by the visual launch.)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 7.3: Build green; run existing tests**
|
|
|
|
Run: `dotnet build && dotnet test --filter "IndoorCellStencilPipeline"`
|
|
Expected: existing 9 tests still pass.
|
|
|
|
- [ ] **Step 7.4: Commit**
|
|
|
|
```bash
|
|
git add src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs
|
|
git commit -m "$(cat <<'EOF'
|
|
feat(render): Phase A8 — RenderBuildingStencilMask low-level entry
|
|
|
|
Mirrors WB PortalRenderManager.RenderBuildingStencilMask:471-484. Pure
|
|
upload + draw with no surrounding GL state setup, matching WB's
|
|
RenderInsideOut step expectations. Existing MarkAndPunch / Mark/Reset
|
|
helpers stay for other consumers.
|
|
|
|
LastStencil* probe fields surface the most recent draw's vertex count,
|
|
building id, and write-far-depth flag for the [stencil] probe emitter.
|
|
|
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: Implement `RenderInsideOutAcdream` in GameWindow.cs
|
|
|
|
**Files:**
|
|
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (find the render frame method — search for `_wbDrawDispatcher.Draw` to locate it)
|
|
|
|
- [ ] **Step 8.1: Locate the render frame**
|
|
|
|
Use grep to find the main render method:
|
|
```bash
|
|
grep -n "_wbDrawDispatcher\.Draw\|private.*RenderFrame\|protected override.*Render" src/AcDream.App/Rendering/GameWindow.cs
|
|
```
|
|
|
|
The render frame is where the dispatcher's `Draw(set: All)` is called once per frame in the outdoor path. Identify that block.
|
|
|
|
- [ ] **Step 8.2: Compute the `cameraInsideBuilding` gate**
|
|
|
|
Just before the existing render block, add (replace any old `cameraInsideCell` etc. computations introduced by the reverted RR7):
|
|
|
|
```csharp
|
|
// Phase A8 (2026-05-28): strict camera-inside-building gate.
|
|
// NO grace. Requires the cell to actually be a building (BuildingId != null
|
|
// per RR4 stamping). Other-cell paths flow through the outdoor branch.
|
|
var visibility = _cellVisibility.LastVisibilityResult;
|
|
var camPos = _camera.Position;
|
|
bool cameraInsideBuilding =
|
|
visibility?.CameraCell is not null
|
|
&& CellVisibility.PointInCell(camPos, visibility.CameraCell)
|
|
&& visibility.CameraCell.BuildingId is not null;
|
|
|
|
// Resolve the camera's buildings (a single cell may be in multiple buildings).
|
|
List<AcDream.App.Rendering.Wb.Building> camBuildings = new();
|
|
if (cameraInsideBuilding)
|
|
{
|
|
uint lbId = visibility!.CameraCell!.CellId & 0xFFFF0000u;
|
|
if (_buildingRegistries.TryGetValue(lbId, out var reg))
|
|
{
|
|
foreach (var b in reg.GetBuildingsContainingCell(visibility.CameraCell.CellId))
|
|
camBuildings.Add(b);
|
|
}
|
|
}
|
|
|
|
// Resolve the OTHER buildings in view (used by Step 5).
|
|
List<AcDream.App.Rendering.Wb.Building> otherBuildings = new();
|
|
if (cameraInsideBuilding)
|
|
{
|
|
var camCellId = visibility!.CameraCell!.CellId;
|
|
foreach (var reg in _buildingRegistries.Values)
|
|
foreach (var b in reg.All())
|
|
if (!b.EnvCellIds.Contains(camCellId))
|
|
otherBuildings.Add(b);
|
|
// (Frustum-test other-buildings here if perf matters; A8 doesn't gate on
|
|
// it — Step 5 already uses occlusion queries.)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 8.3: PrepareRenderBatches on EnvCellRenderer**
|
|
|
|
Before entering the indoor branch, call:
|
|
|
|
```csharp
|
|
var viewProj = _camera.ViewMatrix * _camera.ProjectionMatrix;
|
|
_envCellRenderer!.PrepareRenderBatches(viewProj, camPos); // unfiltered prep
|
|
```
|
|
|
|
- [ ] **Step 8.4: Replace the render block with the gate**
|
|
|
|
```csharp
|
|
if (cameraInsideBuilding)
|
|
{
|
|
RenderInsideOutAcdream(viewProj, camPos, visibility!.CameraCell!, camBuildings, otherBuildings);
|
|
}
|
|
else
|
|
{
|
|
// Existing outdoor path (sky + terrain + dispatcher Draw(set: All)).
|
|
// No call to _envCellRenderer here — we don't render env cells outdoors yet.
|
|
// (Future: RenderOutsideIn for cottage windows. Deferred to a later phase.)
|
|
RenderOutdoorPath(viewProj, camPos); // wrap existing code into a helper if not already
|
|
}
|
|
|
|
// LiveDynamic always last — player + NPCs + dropped items, depth-test only.
|
|
_wbDrawDispatcher.Draw(_camera, ..., set: EntitySet.LiveDynamic);
|
|
```
|
|
|
|
- [ ] **Step 8.5: Implement `RenderInsideOutAcdream` — byte-for-byte from WB**
|
|
|
|
This is the most critical method. Read `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilityManager.cs:73-239` and port it verbatim with these substitutions:
|
|
|
|
- `_gl` → `_glContext.GL`
|
|
- `_buildingsWithCurrentCell` → `camBuildings` (parameter)
|
|
- `_otherBuildings` / `_visibleBuildingPortals` → `otherBuildings` (parameter)
|
|
- `portalManager?.RenderBuildingStencilMask(building, snapshotVP, false)` → `_indoorStencilPipeline!.RenderBuildingStencilMask(building, viewProj, writeFarDepth: false)`
|
|
- `envCellManager!.Render(pass1RenderPass, _currentEnvCellIds)` → `_envCellRenderer!.Render(WbRenderPass.Opaque, _currentEnvCellIds)`
|
|
- `envCellManager!.Render(RenderPass.Transparent, ...)` → `_envCellRenderer!.Render(WbRenderPass.Transparent, ...)`
|
|
- `terrainManager.Render(...)` → `_terrain!.Render(...)` (existing acdream terrain renderer)
|
|
- `sceneryManager?.Render(pass1RenderPass)` → `_wbDrawDispatcher!.Draw(_camera, ..., set: EntitySet.OutdoorScenery)`
|
|
- `staticObjectManager?.Render(pass1RenderPass)` → folded into the same `Draw(set: OutdoorScenery)` (our EntitySet partition covers both)
|
|
- `sceneryShader?.Bind()` → `_meshModernShader.Use()` (the modern mesh shader is acdream's analog)
|
|
- `state.ShowScenery` / `state.ShowStaticObjects` / `state.ShowBuildings` → always true (editor toggles; we're a game client)
|
|
- `state.EnableTransparencyPass` → `true` (we want transparency for stained glass / windows; matches WB's default)
|
|
|
|
The method skeleton:
|
|
|
|
```csharp
|
|
private void RenderInsideOutAcdream(
|
|
Matrix4x4 viewProj,
|
|
Vector3 camPos,
|
|
LoadedCell cameraCell,
|
|
List<AcDream.App.Rendering.Wb.Building> camBuildings,
|
|
List<AcDream.App.Rendering.Wb.Building> otherBuildings)
|
|
{
|
|
var gl = _glContext.GL;
|
|
bool didInsideStencil = false;
|
|
|
|
EmitDrawOrderProbe(step: 0, before: true); // entry probe
|
|
|
|
if (camBuildings.Count > 0)
|
|
{
|
|
didInsideStencil = true;
|
|
gl.Enable(EnableCap.StencilTest);
|
|
gl.ClearStencil(0);
|
|
gl.Clear(ClearBufferMask.StencilBufferBit);
|
|
|
|
// Step 1: stencil bit 1 at our buildings' portals.
|
|
gl.Disable(EnableCap.CullFace);
|
|
gl.StencilFunc(StencilFunction.Always, 1, 0xFFu);
|
|
gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Replace);
|
|
gl.StencilMask(0x01u);
|
|
gl.ColorMask(false, false, false, false);
|
|
gl.DepthMask(false);
|
|
gl.Enable(EnableCap.DepthTest);
|
|
gl.DepthFunc(DepthFunction.Always);
|
|
|
|
EmitDrawOrderProbe(step: 1);
|
|
foreach (var b in camBuildings)
|
|
_indoorStencilPipeline!.RenderBuildingStencilMask(b, viewProj, writeFarDepth: false);
|
|
|
|
// Step 2: punch depth at portals.
|
|
gl.DepthMask(true);
|
|
gl.DepthFunc(DepthFunction.Always);
|
|
|
|
EmitDrawOrderProbe(step: 2);
|
|
foreach (var b in camBuildings)
|
|
_indoorStencilPipeline!.RenderBuildingStencilMask(b, viewProj, writeFarDepth: true);
|
|
}
|
|
|
|
// Step 3: render the camera-buildings' cells.
|
|
gl.ColorMask(true, true, true, false);
|
|
gl.DepthMask(true);
|
|
gl.Disable(EnableCap.StencilTest);
|
|
gl.DepthFunc(DepthFunction.Less);
|
|
_meshModernShader.Use();
|
|
|
|
EmitDrawOrderProbe(step: 3);
|
|
HashSet<uint> currentEnvCellIds = new();
|
|
if (camBuildings.Count > 0)
|
|
{
|
|
foreach (var b in camBuildings)
|
|
foreach (var id in b.EnvCellIds) currentEnvCellIds.Add(id);
|
|
_envCellRenderer!.Render(WbRenderPass.Opaque, currentEnvCellIds);
|
|
|
|
// Transparency pass.
|
|
gl.DepthMask(false);
|
|
_envCellRenderer!.Render(WbRenderPass.Transparent, currentEnvCellIds);
|
|
gl.DepthMask(true);
|
|
}
|
|
|
|
// Step 4: stencil-gated outdoor (terrain + scenery + static objects).
|
|
if (didInsideStencil)
|
|
{
|
|
gl.Enable(EnableCap.StencilTest);
|
|
gl.StencilFunc(StencilFunction.Equal, 1, 0x01u);
|
|
gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Keep);
|
|
gl.StencilMask(0x00u);
|
|
gl.ColorMask(true, true, true, false);
|
|
gl.DepthMask(true);
|
|
gl.Enable(EnableCap.CullFace);
|
|
gl.DepthFunc(DepthFunction.Less);
|
|
}
|
|
|
|
EmitDrawOrderProbe(step: 4);
|
|
_terrain?.Render(viewProj /* + the rest of acdream's terrain render args */);
|
|
_meshModernShader.Use();
|
|
_wbDrawDispatcher!.Draw(_camera, _worldState.AllLandblocks /* + the existing args */,
|
|
set: EntitySet.OutdoorScenery);
|
|
|
|
// Step 5: per-other-building 3-bit stencil pipeline.
|
|
if (didInsideStencil && otherBuildings.Count > 0)
|
|
{
|
|
gl.Enable(EnableCap.StencilTest);
|
|
gl.ColorMask(false, false, false, false);
|
|
gl.DepthMask(false);
|
|
gl.DepthFunc(DepthFunction.Lequal);
|
|
|
|
foreach (var b in otherBuildings)
|
|
{
|
|
// Occlusion-query read-back (same as WB).
|
|
_indoorStencilPipeline!.EnsureOcclusionQueryId(ref b.QueryId);
|
|
if (b.QueryStarted &&
|
|
_indoorStencilPipeline.TryReadOcclusionResult(b.QueryId, out bool anyPassed))
|
|
{
|
|
b.WasVisible = anyPassed;
|
|
}
|
|
_indoorStencilPipeline.BeginOcclusionQuery(b.QueryId);
|
|
b.QueryStarted = true;
|
|
|
|
EmitDrawOrderProbe(step: 5, sub: 'a');
|
|
|
|
// Step 5a: mark bit 2 (Ref=3, Mask=0x02).
|
|
gl.StencilFunc(StencilFunction.Equal, 3, 0x01u);
|
|
gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Replace);
|
|
gl.StencilMask(0x02u);
|
|
gl.Disable(EnableCap.CullFace);
|
|
_indoorStencilPipeline.RenderBuildingStencilMask(b, viewProj, writeFarDepth: false);
|
|
_indoorStencilPipeline.EndOcclusionQuery();
|
|
|
|
// Step 5b: clear depth at stencil==3.
|
|
EmitDrawOrderProbe(step: 5, sub: 'b');
|
|
gl.StencilFunc(StencilFunction.Equal, 3, 0x03u);
|
|
gl.StencilMask(0x00u);
|
|
gl.DepthMask(true);
|
|
gl.DepthFunc(DepthFunction.Always);
|
|
_indoorStencilPipeline.RenderBuildingStencilMask(b, viewProj, writeFarDepth: true);
|
|
|
|
// Step 5c: render this building's cells where stencil==3.
|
|
EmitDrawOrderProbe(step: 5, sub: 'c');
|
|
gl.ColorMask(true, true, true, false);
|
|
gl.DepthFunc(DepthFunction.Less);
|
|
gl.Enable(EnableCap.CullFace);
|
|
_meshModernShader.Use();
|
|
_envCellRenderer.Render(WbRenderPass.Opaque, b.EnvCellIds);
|
|
gl.DepthMask(false);
|
|
_envCellRenderer.Render(WbRenderPass.Transparent, b.EnvCellIds);
|
|
gl.DepthMask(true);
|
|
|
|
// Step 5d: reset bit 2.
|
|
EmitDrawOrderProbe(step: 5, sub: 'd');
|
|
gl.ColorMask(false, false, false, false);
|
|
gl.DepthMask(false);
|
|
gl.StencilMask(0x02u);
|
|
gl.StencilFunc(StencilFunction.Always, 1, 0x02u);
|
|
gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Replace);
|
|
_indoorStencilPipeline.RenderBuildingStencilMask(b, viewProj, writeFarDepth: false);
|
|
}
|
|
gl.DepthFunc(DepthFunction.Less);
|
|
}
|
|
|
|
// Cleanup.
|
|
if (didInsideStencil)
|
|
{
|
|
gl.Disable(EnableCap.StencilTest);
|
|
gl.StencilMask(0xFFu);
|
|
gl.ColorMask(true, true, true, false);
|
|
}
|
|
|
|
EmitEnvCellProbe(camBuildings.Count, otherBuildings.Count, currentEnvCellIds.Count);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 8.6: Add a stencil-buffer clear at frame start**
|
|
|
|
Find the `glClear` at frame start (`grep -n "ClearBufferMask\.ColorBufferBit\|ClearBufferMask\.DepthBufferBit" src/AcDream.App/Rendering/GameWindow.cs`). Add `| ClearBufferMask.StencilBufferBit` so stencil starts at 0 each frame.
|
|
|
|
- [ ] **Step 8.7: Build green**
|
|
|
|
Run: `dotnet build`
|
|
Expected: clean build. Compile errors here MUST be resolved before moving to Task 9.
|
|
|
|
- [ ] **Step 8.8: Commit**
|
|
|
|
```bash
|
|
git add src/AcDream.App/Rendering/GameWindow.cs
|
|
git commit -m "$(cat <<'EOF'
|
|
feat(render): Phase A8 — RenderInsideOutAcdream byte-for-byte WB port
|
|
|
|
Replicates WB VisibilityManager.RenderInsideOut Steps 1-5 verbatim from
|
|
references/WorldBuilder/.../VisibilityManager.cs:73-239. Strict
|
|
cameraInsideBuilding gate (no grace). Step 5 includes the full
|
|
3-bit-stencil + occlusion-query cross-building visibility loop.
|
|
|
|
Frame-start glClear now includes stencil-buffer-bit so stencil starts
|
|
at 0 each frame (RR7 missed this).
|
|
|
|
Probe emitters wired ([draworder]/[envcells]) — gated on
|
|
ACDREAM_PROBE_VIS=1.
|
|
|
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 9: Probes + diagnostic infrastructure
|
|
|
|
**Files:**
|
|
- Modify: `src/AcDream.Core/Rendering/RenderingDiagnostics.cs`
|
|
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (probe emitter helpers)
|
|
|
|
- [ ] **Step 9.1: Add `ProbeEnvCellEnabled` flag**
|
|
|
|
In `RenderingDiagnostics.cs`:
|
|
|
|
```csharp
|
|
private static bool _probeEnvCellEnabled =
|
|
Environment.GetEnvironmentVariable("ACDREAM_PROBE_ENVCELL") == "1";
|
|
public static bool ProbeEnvCellEnabled
|
|
{
|
|
get => _probeEnvCellEnabled || ProbeVisibilityEnabled; // [envcells] also rides on PROBE_VIS
|
|
set => _probeEnvCellEnabled = value;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 9.2: Wire the probe emitters in GameWindow**
|
|
|
|
Add these methods near the `EmitVisibilityProbe` helper (or wherever the existing [vis] probe lives):
|
|
|
|
```csharp
|
|
private int _drawOrderFrame = 0;
|
|
|
|
private void EmitDrawOrderProbe(int step, char sub = ' ', bool before = false)
|
|
{
|
|
if (!AcDream.Core.Rendering.RenderingDiagnostics.ProbeVisibilityEnabled) return;
|
|
var gl = _glContext.GL;
|
|
gl.GetInteger(GLEnum.StencilTest, out int stOn);
|
|
gl.GetInteger(GLEnum.DepthFunc, out int depthFn);
|
|
gl.GetBoolean(GLEnum.DepthWritemask, out var depthMask);
|
|
Console.WriteLine(
|
|
$"[draworder] frame={_drawOrderFrame} step={step}{(sub != ' ' ? sub.ToString() : "")} " +
|
|
$"stencil={(stOn != 0 ? "on" : "off")} depthFn=0x{depthFn:X} depthMask={depthMask}");
|
|
}
|
|
|
|
private void EmitEnvCellProbe(int ourBldgs, int otherBldgs, int filterCnt)
|
|
{
|
|
if (!AcDream.Core.Rendering.RenderingDiagnostics.ProbeEnvCellEnabled) return;
|
|
var stats = _envCellRenderer?.Stats ?? default;
|
|
Console.WriteLine(
|
|
$"[envcells] cells={stats.CellsRendered} tris={stats.TrianglesDrawn} " +
|
|
$"ourBldgs={ourBldgs} otherBldgs={otherBldgs} filterCnt={filterCnt}");
|
|
}
|
|
|
|
private void EmitStencilProbe(string op)
|
|
{
|
|
if (!AcDream.Core.Rendering.RenderingDiagnostics.ProbeVisibilityEnabled) return;
|
|
if (_indoorStencilPipeline == null) return;
|
|
Console.WriteLine(
|
|
$"[stencil] op={op} bld=0x{_indoorStencilPipeline.LastStencilBuildingId:X8} " +
|
|
$"verts={_indoorStencilPipeline.LastStencilVertexCount}");
|
|
}
|
|
|
|
private void EmitBuildingsProbe(uint? camCellId, IList<AcDream.App.Rendering.Wb.Building> camBldgs, int otherCount, int totalKnown)
|
|
{
|
|
if (!AcDream.Core.Rendering.RenderingDiagnostics.ProbeVisibilityEnabled) return;
|
|
var ids = string.Join(",", camBldgs.Select(b => $"0x{b.BuildingId:X}"));
|
|
Console.WriteLine(
|
|
$"[buildings] camCell={(camCellId.HasValue ? $"0x{camCellId.Value:X8}" : "null")} " +
|
|
$"camBldgs=[{ids}] otherBldgs={otherCount} totalKnown={totalKnown}");
|
|
}
|
|
```
|
|
|
|
Call `EmitStencilProbe` after each `RenderBuildingStencilMask` call site in `RenderInsideOutAcdream`. Call `EmitBuildingsProbe` at the very top of that method.
|
|
|
|
Increment `_drawOrderFrame` once per render frame in the outermost render method.
|
|
|
|
- [ ] **Step 9.3: Build green**
|
|
|
|
Run: `dotnet build`
|
|
Expected: clean build.
|
|
|
|
- [ ] **Step 9.4: Commit**
|
|
|
|
```bash
|
|
git add src/AcDream.Core/Rendering/RenderingDiagnostics.cs src/AcDream.App/Rendering/GameWindow.cs
|
|
git commit -m "$(cat <<'EOF'
|
|
feat(render): Phase A8 — probe trail ([envcells]/[stencil]/[draworder]/[buildings])
|
|
|
|
Mandatory probe-before-launch infrastructure (process rule from the RR7
|
|
saga: "no visual-gate launch without probe data first").
|
|
|
|
[envcells] fires once per indoor frame: cells/tris drawn + our/other
|
|
buildings counts + filter cardinality
|
|
[stencil] fires per RenderBuildingStencilMask: vertex count + building
|
|
id + write-far-depth flag
|
|
[draworder] fires at each step boundary: step number/sub + stencil
|
|
on/off + depth func/mask
|
|
[buildings] fires once per indoor frame: camera cell + camera-buildings
|
|
ids + other-buildings count + total known buildings
|
|
|
|
Gates: ACDREAM_PROBE_VIS=1 (everything) OR ACDREAM_PROBE_ENVCELL=1
|
|
([envcells] only). Runtime-toggleable via DebugPanel.
|
|
|
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 10: Build green + full test suite + visual gate
|
|
|
|
**Files:**
|
|
- None (verification + launch)
|
|
|
|
- [ ] **Step 10.1: Final build**
|
|
|
|
```bash
|
|
dotnet build src/AcDream.App/AcDream.App.csproj
|
|
```
|
|
Expected: clean build, zero errors.
|
|
|
|
- [ ] **Step 10.2: Run full test suite**
|
|
|
|
```bash
|
|
dotnet test
|
|
```
|
|
Expected: at minimum the pre-A8 baseline (1178 + 8) holds. New A8 tests pass (Frustum + SceneryInstance + EnvCellRenderer = ~10-15 new tests). No regressions.
|
|
|
|
- [ ] **Step 10.3: Launch the client for visual verification**
|
|
|
|
```powershell
|
|
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
|
|
$env:ACDREAM_LIVE = "1"
|
|
$env:ACDREAM_TEST_HOST = "127.0.0.1"
|
|
$env:ACDREAM_TEST_PORT = "9000"
|
|
$env:ACDREAM_TEST_USER = "testaccount"
|
|
$env:ACDREAM_TEST_PASS = "testpassword"
|
|
$env:ACDREAM_PROBE_VIS = "1"
|
|
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "a8-wb-port-launch.log"
|
|
```
|
|
|
|
Run in background (`run_in_background: true` on the Bash call).
|
|
|
|
- [ ] **Step 10.4: Read probe data BEFORE asking user for visual verification**
|
|
|
|
```bash
|
|
grep -c "\[buildings\] camBldgs=\[0x" a8-wb-port-launch.log
|
|
grep "\[envcells\] cells=[1-9]" a8-wb-port-launch.log | head -5
|
|
grep "\[stencil\] op=mark verts=[1-9]" a8-wb-port-launch.log | head -5
|
|
grep "\[draworder\]" a8-wb-port-launch.log | head -20
|
|
```
|
|
|
|
Acceptance:
|
|
- `[buildings] camBldgs=[0x...]` non-empty for at least one frame while the user is inside a cottage
|
|
- `[envcells] cells>=1 tris>=1 filterCnt>=1` for at least one frame inside
|
|
- `[stencil] op=mark verts>0` fires per camera-building
|
|
- `[draworder]` shows the full Step 1 → 2 → 3 → 4 → 5 cycle per indoor frame
|
|
|
|
**If probes don't show what we expect, DO NOT ask the user for visual verification.** Investigate the gap (which probe is missing → which code path failed) before relaunching.
|
|
|
|
- [ ] **Step 10.5: Ask user to verify**
|
|
|
|
Provide the user with these scenarios to test:
|
|
- Cottage interior (ground floor): walls solid, sky through windows
|
|
- Cottage cellar: cottage floor solid above (no transparent floor)
|
|
- Holtburg inn (multi-room): walls solid, no cross-room leak
|
|
- Dungeon corridor: walls solid (cells without BuildingId — verify outdoor branch handles them via fallback)
|
|
- Exit transition (indoor → outdoor): clean, no through-ground flicker
|
|
- Entry transition (outdoor → indoor): clean
|
|
- Cross-building (Step 5): stand inside inn, look through window at a cottage across the street — cottage interior visible through both windows
|
|
|
|
- [ ] **Step 10.6: Ship handoff doc**
|
|
|
|
Once visual is confirmed, write:
|
|
- `docs/research/2026-05-28-phase-a8-wb-port-shipped-handoff.md` — what shipped, evidence, what's open (e.g., RenderOutsideIn deferred)
|
|
- Update `CLAUDE.md` "Currently working toward" line + add A8 ship paragraph
|
|
- Move issue #78 to closed in `docs/ISSUES.md`
|
|
|
|
- [ ] **Step 10.7: Final commit**
|
|
|
|
```bash
|
|
git add docs/research/2026-05-28-phase-a8-wb-port-shipped-handoff.md docs/ISSUES.md CLAUDE.md
|
|
git commit -m "$(cat <<'EOF'
|
|
docs(a8): Phase A8 WB RenderInsideOut port — SHIPPED
|
|
|
|
Visual-verified $(date +%Y-%m-%d) at Holtburg cottages + inn. All
|
|
acceptance scenarios pass. Closes #78. RenderOutsideIn (outdoor
|
|
camera looking into cottage windows) deferred to a follow-up phase.
|
|
|
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
## Risk register
|
|
|
|
| Risk | Likelihood | Mitigation |
|
|
|---|---|---|
|
|
| `ObjectMeshManager.PrepareEnvCellGeomMeshDataAsync` not idempotent — re-registration causes mesh re-upload | Low | Internal dedupe by `_pendingEnvCellRequests` dict; idempotent per WB's design |
|
|
| `EnvCellRenderer.PrepareRenderBatches` thread-races with `RegisterCell` during live streaming | Medium | Both lock `lb.Lock`; `_renderLock` protects snapshot swap. Same locking pattern WB uses |
|
|
| Modern MDI buffer too small for large indoor scenes (e.g. inn with 40 cells) | Low | Auto-grow on overflow (mirror BaseObjectRenderManager); initial 1024 is comfortable for a Holtburg cottage |
|
|
| `_meshModernShader.Use()` clobbers other state set by prior step | Low | Shader bind doesn't touch GL state our pipeline cares about (only program binding) |
|
|
| Step 5 occlusion queries cause CPU stall | Low | Asynchronous read-back via `TryReadOcclusionResult` (prev-frame only); matches WB |
|
|
| Frame-start stencil clear breaks something else relying on stencil persisting | Low | Nothing else in our pipeline uses stencil; verify via grep before merging Step 8.6 |
|
|
| `BuildingId` not stamped on cells loaded across frames (RR7.1's bug) | Low | RR3-RR6 BFS is dat-driven (BuildingLoader.Build seeded by LandBlockInfo.Buildings); doesn't depend on cell-load timing |
|
|
|
|
## Falsifiability — what tells us we failed
|
|
|
|
If after Task 10's probe check:
|
|
- `[buildings] camBldgs=[]` (empty) while user reports they are inside a cottage → `BuildingId` stamping is broken. Debug at `BuildingLoader.Build` / `LandBlockInfo.Buildings` data.
|
|
- `[envcells] cells=0` while `[buildings] camBldgs=[0x1]` non-empty → `EnvCellRenderer.PrepareRenderBatches` not finding any registered cells. Debug at `RegisterCell` / `FinalizeLandblock`.
|
|
- `[envcells] cells=N tris=0` → `ObjectMeshManager.TryGetRenderData(cellGeomId)` returns null. Debug at the dedup-id mismatch between `RegisterCell` and the dispatcher's expected mesh key.
|
|
- Probes look correct but visual still wrong → likely a GL state issue between steps. Cross-reference `[draworder]` flags against WB `VisibilityManager.cs` expected state per step.
|
|
|
|
Each failure has a deterministic next step. No "speculative fix → another launch" loop.
|
|
|
|
---
|
|
|
|
## Out of scope (deferred follow-ups)
|
|
|
|
- **RenderOutsideIn** — outdoor camera looking into cottage windows showing the cottage interior. WB `VisibilityManager.cs:241-358`. Same extraction patterns; lower priority because it's a polish feature, not an M1.5 blocker.
|
|
- **Editor highlights / selection in EnvCellRenderer** — we deliberately dropped this from Step 5.4. Not needed for a game client.
|
|
- **Per-instance reference-counting** — WB's `IncrementInstanceRefCounts` / `DecrementInstanceRefCounts` is needed for editor brush-tools (move-between-cells). We don't do that.
|
|
|
|
---
|
|
|
|
## Self-review notes
|
|
|
|
- **Placeholder scan:** every step has concrete code or exact grep commands. No "TBD" / "implement later" / "add error handling here" placeholders.
|
|
- **Type consistency:** `WbBoundingBox` defined once in Task 2, used in Tasks 3-8. `EnvCellLandblock` defined once in Task 3, used in Tasks 5-6. `WbRenderPass` defined in Task 1, consumed in Task 5+8.
|
|
- **WB line-number citations:** every verbatim port cites the WB source path + line range. Subagents follow the cited path.
|
|
- **Spec coverage:** the handoff doc's "Phase 1-5" map → my Tasks 1-5 (extract + build renderer), Task 6 (wire to streaming), Task 7 (stencil low-level), Task 8 (render-frame port), Task 9 (probes), Task 10 (verification). Every handoff requirement is covered.
|
|
- **Probe coverage:** the handoff doc's four required probe families (`[envcells]/[stencil]/[draworder]/[buildings]`) are all wired in Task 9.
|
|
|
|
---
|
|
|
|
## Execution model
|
|
|
|
**Recommended:** subagent-driven, two-stage review per task.
|
|
|
|
Sonnet subagents per Task 1-9. Task 5 may want to be split into 5.1-5.5 separate subagents because of the size. Task 8 (render-frame port) is the riskiest — dispatch with explicit "read WB VisibilityManager.cs:73-239 byte-by-byte; do NOT improvise" instruction.
|
|
|
|
After each subagent's commit, the dispatcher (this conversation) reads the diff before declaring done. If a subagent reports done but the diff is wrong, the dispatcher rejects + re-dispatches.
|
|
|
|
Pre-flight before Task 10's visual gate: confirm probes fire as expected by inspecting the log offline — don't ask the user to verify until probe data correlates.
|
|
|
|
**One visual launch.** Not four.
|