phase(N.5): retirement amendment — InstancedMeshRenderer + StaticMeshRenderer + WbFoundationFlag deleted

Final cross-cutting review of N.5 found that Task 15's deletion of
mesh_instanced.vert/.frag left InstancedMeshRenderer orphaned —
ACDREAM_USE_WB_FOUNDATION=0 silently rendered terrain+sky only with
no entities. The SHIP commit's "[x] ACDREAM_USE_WB_FOUNDATION=0 still
works" claim was inaccurate.

Resolution: formal retirement of the legacy renderer path within N.5
instead of deferring to N.6.

Deleted:
- src/AcDream.App/Rendering/InstancedMeshRenderer.cs
- src/AcDream.App/Rendering/StaticMeshRenderer.cs
- src/AcDream.App/Rendering/Wb/WbFoundationFlag.cs

GameWindow simplified — capability detection is unconditional, missing
bindless throws NotSupportedException with a clear message at startup.
WbDrawDispatcher + mesh_modern shader load are mandatory after init.
No escape hatch.

GpuWorldState simplified — WbFoundationFlag.IsEnabled guards on
AddLandblock/RemoveLandblock removed; adapter calls are unconditional
when the adapter is non-null.

PendingSpawnIntegrationTests updated — WbFoundationFlag.ForTestsOnly_ForceEnable
static ctor removed (flag is gone; adapter calls are unconditional).

The ApplyLoadedTerrain physics-data loop was also simplified: the
EnsureUploaded sub-loop that fed InstancedMeshRenderer is gone;
_pendingCellMeshes is now explicitly cleared to prevent unbounded
accumulation (the worker thread still populates it, but WB handles
EnvCell geometry through its own pipeline).

Spec §2 Decision 5 + §10 Out-of-Scope updated. Plan ship-amendment
section added. Roadmap updated (N.5 ships with retirement; N.6 scope
narrowed to perf-only). CLAUDE.md "WB integration cribs" updated.
Perf baseline doc updated. WbDrawDispatcher class summary docstring
corrected to describe the as-shipped SSBO + multi-draw-indirect path.
ISSUES.md #51 updated (terrain not in N.5 scope; deferred to N.7).

Bindless support is now a hard requirement. Modern desktop GPUs
universally expose GL_ARB_bindless_texture + GL_ARB_shader_draw_parameters;
if a user hits the NotSupportedException, that's a real bug report
worth investigating, not a silent fallback.

Build: 0 errors, 0 warnings. Tests: 71/71 (Wb+MatrixComposition+TextureCacheBindless filter).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-08 22:01:36 +02:00
parent 55ecec683f
commit dcae2b6b94
13 changed files with 211 additions and 1140 deletions

View file

@ -55,9 +55,11 @@ ourselves".
`EntitySpawnAdapter.cs` — bridge spawn lifecycle to WB ref-counts. `EntitySpawnAdapter.cs` — bridge spawn lifecycle to WB ref-counts.
Atlas tier (procedural) goes via Landblock; per-instance tier Atlas tier (procedural) goes via Landblock; per-instance tier
(server-spawned, palette/texture overrides) goes via Entity. (server-spawned, palette/texture overrides) goes via Entity.
- `WbFoundationFlag` is default-on. `ACDREAM_USE_WB_FOUNDATION=0` - **Modern path is mandatory as of N.5 ship amendment (2026-05-08).**
falls back to legacy `InstancedMeshRenderer` (kept as escape hatch `WbFoundationFlag`, `InstancedMeshRenderer`, and `StaticMeshRenderer`
until N.6 fully retires it). are deleted. Missing `GL_ARB_bindless_texture` or
`GL_ARB_shader_draw_parameters` throws `NotSupportedException` at
startup. There is no legacy fallback.
- **WB's modern rendering path** (GL 4.3 + bindless) packs every mesh - **WB's modern rendering path** (GL 4.3 + bindless) packs every mesh
into a single global VAO/VBO/IBO. Each batch references its slice into a single global VAO/VBO/IBO. Each batch references its slice
via `FirstIndex` (offset into IBO) + `BaseVertex` (offset into VBO). via `FirstIndex` (offset into IBO) + `BaseVertex` (offset into VBO).
@ -500,21 +502,25 @@ acdream's plan lives in two files committed to the repo:
acceptance criteria. Do not drift from the spec without explicit user acceptance criteria. Do not drift from the spec without explicit user
approval. approval.
**Currently in flight: Phase N.6 — Retire legacy renderers + perf polish.** **Currently in flight: Phase N.6 — Perf polish.**
Roadmap entry at [`docs/plans/2026-04-11-roadmap.md`](docs/plans/2026-04-11-roadmap.md). Roadmap entry at [`docs/plans/2026-04-11-roadmap.md`](docs/plans/2026-04-11-roadmap.md).
Builds on N.5. Retires `InstancedMeshRenderer` + `StaticMeshRenderer` entirely. Builds on N.5. Legacy renderers (`InstancedMeshRenderer`, `StaticMeshRenderer`,
Optional candidates: WB atlas adoption, persistent-mapped buffers, GPU-side `WbFoundationFlag`) were retired in the N.5 ship amendment — N.6 scope is
culling via compute pre-pass, GL_TIME_ELAPSED query double-buffering, direct perf-only: WB atlas adoption, persistent-mapped buffers, GPU-side culling,
N.4 vs N.5 perf measurement. Plan + spec written when work begins. GL_TIME_ELAPSED query double-buffering, direct N.4 vs N.5 perf measurement,
legacy `Texture2D`/`sampler2D` TextureCache path retirement (Sky/Terrain/Debug).
Plan + spec written when work begins.
**Phase N.5 (Modern Rendering Path) shipped 2026-05-08.** `WbDrawDispatcher` **Phase N.5 (Modern Rendering Path) shipped + amended 2026-05-08.** `WbDrawDispatcher`
on bindless textures + `glMultiDrawElementsIndirect`. CPU dispatcher 1.23ms/frame on bindless textures + `glMultiDrawElementsIndirect`. CPU dispatcher 1.23ms/frame
at Holtburg (~810 fps). Plan archived at at Holtburg (~810 fps). **Ship amendment:** `InstancedMeshRenderer`,
`StaticMeshRenderer`, `WbFoundationFlag` deleted in same phase — modern path is
mandatory; missing bindless throws at startup. Plan archived at
[`docs/superpowers/plans/2026-05-08-phase-n5-modern-rendering.md`](docs/superpowers/plans/2026-05-08-phase-n5-modern-rendering.md). [`docs/superpowers/plans/2026-05-08-phase-n5-modern-rendering.md`](docs/superpowers/plans/2026-05-08-phase-n5-modern-rendering.md).
**Phase N.4 (Rendering Pipeline Foundation) shipped 2026-05-08.** WB's **Phase N.4 (Rendering Pipeline Foundation) shipped 2026-05-08.** WB's
`ObjectMeshManager` is integrated and is the default rendering path `ObjectMeshManager` is integrated and is the production rendering path
behind `ACDREAM_USE_WB_FOUNDATION` (default-on). Plan archived at (mandatory as of N.5 ship amendment). Plan archived at
[`docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md`](docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md). [`docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md`](docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md).
**Rules:** **Rules:**

View file

@ -82,11 +82,12 @@ ground. This is the bug class fixed in
**Sequencing implication:** Phase N.2 (terrain math helpers **Sequencing implication:** Phase N.2 (terrain math helpers
substitution) cannot be shipped in isolation — it must land alongside substitution) cannot be shipped in isolation — it must land alongside
N.5 (visual terrain renderer migration), at which point both physics visual terrain renderer migration (originally N.5, now moved to N.7
and visual mesh switch to WB's formula together. Roadmap N.2 entry scope), at which point both physics and visual mesh switch to WB's
flags this dependency. formula together. N.5 shipped entity rendering only; terrain remains
on acdream's own pipeline through N.7.
**Research needed (when N.5 picks this up):** **Research needed (when N.7 picks this up):**
1. Quantify divergence: run WB's `CalculateSplitDirection` and our 1. Quantify divergence: run WB's `CalculateSplitDirection` and our
`IsSplitSWtoNE` across all (lbX, lbY, cellX, cellY) tuples for a `IsSplitSWtoNE` across all (lbX, lbY, cellX, cellY) tuples for a
representative landblock set; record disagreement rate. representative landblock set; record disagreement rate.
@ -97,8 +98,8 @@ flags this dependency.
server-authoritative Z within tolerance) is invalidated by the server-authoritative Z within tolerance) is invalidated by the
formula change. formula change.
**Acceptance:** Resolved when N.5 lands and both physics + visual **Acceptance:** Resolved when N.7 lands and both physics + visual
mesh use WB's split formula, OR when we decide to keep the AC2D terrain use WB's split formula, OR when we decide to keep the AC2D
formula and patch WB's renderer in our fork. formula and patch WB's renderer in our fork.
--- ---
@ -998,8 +999,8 @@ If the coat texture's UVs at the upper region map to texel-bytes whose palette i
**Files (diagnostic env vars committed for next-session reuse):** **Files (diagnostic env vars committed for next-session reuse):**
- `src/AcDream.App/Rendering/InstancedMeshRenderer.cs:210-275` - ~~`src/AcDream.App/Rendering/InstancedMeshRenderer.cs:210-275`
`ACDREAM_NO_CULL` env var `ACDREAM_NO_CULL` env var~~ (file deleted in N.5 ship amendment)
- `src/AcDream.App/Rendering/GameWindow.cs``ACDREAM_HIDE_PART=N` - `src/AcDream.App/Rendering/GameWindow.cs``ACDREAM_HIDE_PART=N`
hides specific humanoid part; `ACDREAM_DUMP_CLOTHING=1` dumps hides specific humanoid part; `ACDREAM_DUMP_CLOTHING=1` dumps
AnimPartChanges + TextureChanges + per-part Surface chain coverage. AnimPartChanges + TextureChanges + per-part Surface chain coverage.

View file

@ -59,8 +59,8 @@
| C.1 | PES particle system + sky-pass refinements — retail-faithful `ParticleEmitterInfo` unpack with all 13 motion integrators (`Particle::Init`/`Update` ports of `0x0051c290`/`0x0051c930`), `PhysicsScriptRunner` with `CallPES` self-loop semantics, `ParticleHookSink` with `EmitterDied` cleanup, instanced billboard `ParticleRenderer` with material-derived blend (DAT emitters never default additive — pulled from particle GfxObj surface), global back-to-front sort, BC clipmap alpha-keying, AttachLocal `is_parent_local=1` live-parent follow via `UpdateEmitterAnchor`. Sky pass: `Translucent+ClipMap` → alpha-blend cloud sheet (matches `D3DPolyRender::SetSurface` `0x0059c4d0`), raw-`Additive` fog-skip (matches `0x0059c882`), per-keyframe `SkyObjectReplace` Translucency/Luminosity/MaxBright divide-by-100, bit `0x01` pre/post-scene split (matches `GameSky::CreateDeletePhysicsObjects` `0x005073c0`), Setup-backed (`0x020xxxxx`) sky objects via `SetupMesh.Flatten`, persistent GL sampler objects (Wrap + ClampToEdge) replace per-frame wrap-mode mutation (ported from WorldBuilder's `OpenGLGraphicsDevice`), post-scene Z-offset gated on `(Properties & 4) != 0 && (Properties & 8) == 0` per `GameSky::UpdatePosition` `0x00506dd0`. Sky-PES playback disabled by default (named-retail proves `GameSky` drops `pes_id`); `ACDREAM_ENABLE_SKY_PES=1` opens the experimental path. 1325 → 1331 tests. | Live ✓ | | C.1 | PES particle system + sky-pass refinements — retail-faithful `ParticleEmitterInfo` unpack with all 13 motion integrators (`Particle::Init`/`Update` ports of `0x0051c290`/`0x0051c930`), `PhysicsScriptRunner` with `CallPES` self-loop semantics, `ParticleHookSink` with `EmitterDied` cleanup, instanced billboard `ParticleRenderer` with material-derived blend (DAT emitters never default additive — pulled from particle GfxObj surface), global back-to-front sort, BC clipmap alpha-keying, AttachLocal `is_parent_local=1` live-parent follow via `UpdateEmitterAnchor`. Sky pass: `Translucent+ClipMap` → alpha-blend cloud sheet (matches `D3DPolyRender::SetSurface` `0x0059c4d0`), raw-`Additive` fog-skip (matches `0x0059c882`), per-keyframe `SkyObjectReplace` Translucency/Luminosity/MaxBright divide-by-100, bit `0x01` pre/post-scene split (matches `GameSky::CreateDeletePhysicsObjects` `0x005073c0`), Setup-backed (`0x020xxxxx`) sky objects via `SetupMesh.Flatten`, persistent GL sampler objects (Wrap + ClampToEdge) replace per-frame wrap-mode mutation (ported from WorldBuilder's `OpenGLGraphicsDevice`), post-scene Z-offset gated on `(Properties & 4) != 0 && (Properties & 8) == 0` per `GameSky::UpdatePosition` `0x00506dd0`. Sky-PES playback disabled by default (named-retail proves `GameSky` drops `pes_id`); `ACDREAM_ENABLE_SKY_PES=1` opens the experimental path. 1325 → 1331 tests. | Live ✓ |
| N.1 | WorldBuilder-backed scenery (Chorizite/WorldBuilder fork as submodule, SceneryHelpers + TerrainUtils replace our inline ports) | Live ✓ | | N.1 | WorldBuilder-backed scenery (Chorizite/WorldBuilder fork as submodule, SceneryHelpers + TerrainUtils replace our inline ports) | Live ✓ |
| N.3 | WorldBuilder-backed texture decode — `SurfaceDecoder` delegates INDEX16 / P8 / A8R8G8B8 / R8G8B8 / A8(+Additive) to `TextureHelpers.Fill*`; `isAdditive` threaded through (terrain alpha → `FillA8Additive`, non-additive entity surfaces → `FillA8`). R5G6B5 + A4R4G4B4 newly handled (previously magenta). X8R8G8B8, DXT1/3/5, SolidColor remain ours (no WB equivalent). 9 conformance tests prove byte-identical equivalence per format. | Live ✓ | | N.3 | WorldBuilder-backed texture decode — `SurfaceDecoder` delegates INDEX16 / P8 / A8R8G8B8 / R8G8B8 / A8(+Additive) to `TextureHelpers.Fill*`; `isAdditive` threaded through (terrain alpha → `FillA8Additive`, non-additive entity surfaces → `FillA8`). R5G6B5 + A4R4G4B4 newly handled (previously magenta). X8R8G8B8, DXT1/3/5, SolidColor remain ours (no WB equivalent). 9 conformance tests prove byte-identical equivalence per format. | Live ✓ |
| N.4 | Rendering pipeline foundation — adopted WB's `ObjectMeshManager` as the production mesh pipeline behind `ACDREAM_USE_WB_FOUNDATION` (default-on). `WbMeshAdapter` is the single seam (owns `ObjectMeshManager`, drains the staged-upload queue per frame, populates `AcSurfaceMetadataTable` with per-batch translucency / luminosity / fog metadata). `WbDrawDispatcher` is the production draw path: groups all visible (entity, batch) pairs, single-uploads the matrix buffer, fires one `glDrawElementsInstancedBaseVertexBaseInstance` per group with `BaseInstance` slicing into the shared instance VBO. `LandblockSpawnAdapter` + `EntitySpawnAdapter` bridge spawn lifecycle to WB ref-counts (atlas tier vs per-instance). Perf wins shipped as part of N.4: per-entity frustum cull, opaque front-to-back sort, palette-hash memoization (compute once per entity, reuse across batches). Visual verification at Holtburg passed: scenery + connected characters with full close-detail geometry (Issue #47 regression resolved). Legacy `InstancedMeshRenderer` retained as `ACDREAM_USE_WB_FOUNDATION=0` escape hatch until N.6. | Live ✓ | | N.4 | Rendering pipeline foundation — adopted WB's `ObjectMeshManager` as the production mesh pipeline behind `ACDREAM_USE_WB_FOUNDATION` (default-on). `WbMeshAdapter` is the single seam (owns `ObjectMeshManager`, drains the staged-upload queue per frame, populates `AcSurfaceMetadataTable` with per-batch translucency / luminosity / fog metadata). `WbDrawDispatcher` is the production draw path: groups all visible (entity, batch) pairs, single-uploads the matrix buffer, fires one `glDrawElementsInstancedBaseVertexBaseInstance` per group with `BaseInstance` slicing into the shared instance VBO. `LandblockSpawnAdapter` + `EntitySpawnAdapter` bridge spawn lifecycle to WB ref-counts (atlas tier vs per-instance). Perf wins shipped as part of N.4: per-entity frustum cull, opaque front-to-back sort, palette-hash memoization (compute once per entity, reuse across batches). Visual verification at Holtburg passed: scenery + connected characters with full close-detail geometry (Issue #47 regression resolved). Legacy `InstancedMeshRenderer` retained as `ACDREAM_USE_WB_FOUNDATION=0` escape hatch until N.6 (retired early in N.5 ship amendment). | Live ✓ |
| N.5 | Modern rendering path — lifted `WbDrawDispatcher` onto bindless textures (`GL_ARB_bindless_texture`) + `glMultiDrawElementsIndirect`. Per-frame entity rendering: 3 SSBO uploads (instance matrices @ binding=0, batch data @ binding=1, indirect commands) + 2 indirect draw calls (opaque + transparent). ~12-15 GL calls per frame regardless of group count, down from hundreds-of-per-group in N.4. CPU dispatcher: 1.23 ms/frame median at Holtburg courtyard (1662 groups, ~810 fps sustained). All textures on the WB modern path use 1-layer `Texture2DArray` + `sampler2DArray`. Legacy callers keep `Texture2D` / `sampler2D` via the parallel `TextureCache` path until N.6 retires them. Three gotchas captured in memory: texture target lock-in, bindless Dispose order (two-phase non-resident before delete), GL_TIME_ELAPSED double-buffering. Plan archived at `docs/superpowers/plans/2026-05-08-phase-n5-modern-rendering.md`. | Live ✓ | | N.5 | Modern rendering path — lifted `WbDrawDispatcher` onto bindless textures (`GL_ARB_bindless_texture`) + `glMultiDrawElementsIndirect`. Per-frame entity rendering: 3 SSBO uploads (instance matrices @ binding=0, batch data @ binding=1, indirect commands) + 2 indirect draw calls (opaque + transparent). ~12-15 GL calls per frame regardless of group count, down from hundreds-of-per-group in N.4. CPU dispatcher: 1.23 ms/frame median at Holtburg courtyard (1662 groups, ~810 fps sustained). All textures on the WB modern path use 1-layer `Texture2DArray` + `sampler2DArray`. Legacy callers keep `Texture2D` / `sampler2D` via the parallel `TextureCache` path until N.6 retires them. Three gotchas captured in memory: texture target lock-in, bindless Dispose order (two-phase non-resident before delete), GL_TIME_ELAPSED double-buffering. **Ship amendment 2026-05-08:** legacy renderers (`InstancedMeshRenderer`, `StaticMeshRenderer`, `WbFoundationFlag`) retired within N.5 — modern path is mandatory; missing bindless throws `NotSupportedException` at startup. N.6 scope narrowed accordingly. Plan archived at `docs/superpowers/plans/2026-05-08-phase-n5-modern-rendering.md`. | Live ✓ |
Plus polish that doesn't get its own phase number: Plus polish that doesn't get its own phase number:
- FlyCamera default speed lowered + Shift-to-boost - FlyCamera default speed lowered + Shift-to-boost
@ -647,16 +647,17 @@ for our deletions/additions; merge upstream `master` periodically.
`CalculateSplitDirection` + `GetHeight` + `GetNormal` in lockstep, `CalculateSplitDirection` + `GetHeight` + `GetNormal` in lockstep,
resolving ISSUE #51. **Estimate: 1-2 weeks** (was 2-3 — modern path resolving ISSUE #51. **Estimate: 1-2 weeks** (was 2-3 — modern path
primitives already in place from N.5). primitives already in place from N.5).
- **N.6 — Retire legacy renderers + perf polish.** **Currently in flight.** - **N.6 — Perf polish.** **Currently in flight.**
Builds on N.5. Retires `InstancedMeshRenderer` + `StaticMeshRenderer` Builds on N.5. Legacy renderer retirement was pulled forward into N.5
entirely — they remain as `ACDREAM_USE_WB_FOUNDATION=0` escape hatches ship amendment — `InstancedMeshRenderer`, `StaticMeshRenderer`, and
through N.5 but are deleted when N.6 ships. Optional N.6 candidates: WB `WbFoundationFlag` are already gone. N.6 scope: WB atlas adoption for
atlas adoption for memory savings on shared content, persistent-mapped memory savings on shared content, persistent-mapped buffers if
buffers if `glBufferData` shows up in profiling, GPU-side culling via `glBufferData` shows up in profiling, GPU-side culling via compute
compute pre-pass, GL_TIME_ELAPSED query double-buffering (deferred from pre-pass, GL_TIME_ELAPSED query double-buffering (deferred from N.5 —
N.5 — diagnostic shows `gpu_us=0/0` under `ACDREAM_WB_DIAG=1`), direct diagnostic shows `gpu_us=0/0` under `ACDREAM_WB_DIAG=1`), direct N.4
N.4 vs N.5 perf measurement. Plan + spec written when work begins. vs N.5 perf measurement, retire the legacy `Texture2D`/`sampler2D` path
**Estimate: 1-2 weeks** (was 2-3). in `TextureCache` (currently kept for Sky + Terrain + Debug).
Plan + spec written when work begins. **Estimate: 1-2 weeks.**
- **N.7 — EnvCells / dungeons.** Replace EnvCell rendering with WB's - **N.7 — EnvCells / dungeons.** Replace EnvCell rendering with WB's
`EnvCellRenderManager` + `PortalRenderManager` on top of N.4's `EnvCellRenderManager` + `PortalRenderManager` on top of N.4's
foundation. **Estimate: 1-2 weeks** (was 2-3 — naturally smaller now foundation. **Estimate: 1-2 weeks** (was 2-3 — naturally smaller now

View file

@ -44,8 +44,11 @@ half of the lower bound estimate.
8 pre-existing failures in `MotionInterpreter` / `BSPStepUp` / 8 pre-existing failures in `MotionInterpreter` / `BSPStepUp` /
`PositionManager` / `PlayerMovementController` / `Dispatcher` are `PositionManager` / `PlayerMovementController` / `Dispatcher` are
carry-forward from before N.5 and unrelated to rendering. carry-forward from before N.5 and unrelated to rendering.
- [ ] **`ACDREAM_USE_WB_FOUNDATION=0` still works** — to be verified at - [N/A] **`ACDREAM_USE_WB_FOUNDATION=0` still works** — escape hatch
Task 14 (legacy escape hatch check). formally retired in N.5 ship amendment. `InstancedMeshRenderer`,
`StaticMeshRenderer`, and `WbFoundationFlag` deleted. Missing
bindless throws `NotSupportedException` at startup with a clear
error message. No fallback path.
## Visual verification (Task 14) ## Visual verification (Task 14)

View file

@ -2561,10 +2561,10 @@ SHIP commit at Task 19.
`FullyQualifiedName~Wb|FullyQualifiedName~MatrixComposition`. `FullyQualifiedName~Wb|FullyQualifiedName~MatrixComposition`.
Pre-existing 8 failures in physics/input/movement tests carry Pre-existing 8 failures in physics/input/movement tests carry
forward unchanged from before N.5. forward unchanged from before N.5.
- [x] **`ACDREAM_USE_WB_FOUNDATION=0` still works** — Task 15 confirmed - [N/A] **`ACDREAM_USE_WB_FOUNDATION=0` still works** — escape hatch
InstancedMeshRenderer remains intact as the escape hatch; if formally retired in N.5 ship amendment (see section below).
bindless is missing, `_meshShader` stays null + `_wbDrawDispatcher` `InstancedMeshRenderer`, `StaticMeshRenderer`, and `WbFoundationFlag`
stays null, falling through to InstancedMeshRenderer naturally. deleted. Missing bindless throws `NotSupportedException` at startup.
### Plan amendments captured during execution ### Plan amendments captured during execution
@ -2613,7 +2613,7 @@ adjustments captured beyond the plan:
- **Persistent-mapped buffers** (Decision 7 deferral). Layer on top of - **Persistent-mapped buffers** (Decision 7 deferral). Layer on top of
the modern path if `glBufferData` shows up as a residual hot spot in the modern path if `glBufferData` shows up as a residual hot spot in
profiling. profiling.
- **Retire `InstancedMeshRenderer`** entirely — N.6 primary scope. - ~~**Retire `InstancedMeshRenderer`** entirely — N.6 primary scope.~~ **Done in N.5 ship amendment.**
- **WB atlas adoption** for memory savings on shared content (trees, - **WB atlas adoption** for memory savings on shared content (trees,
walls, etc). walls, etc).
- **GPU-side culling** via compute pre-pass. - **GPU-side culling** via compute pre-pass.
@ -2655,3 +2655,52 @@ CLAUDE.md "WB integration cribs" updated with N.5 patterns (Task 16).
**Deleted:** **Deleted:**
- `src/AcDream.App/Rendering/Shaders/mesh_instanced.vert` - `src/AcDream.App/Rendering/Shaders/mesh_instanced.vert`
- `src/AcDream.App/Rendering/Shaders/mesh_instanced.frag` - `src/AcDream.App/Rendering/Shaders/mesh_instanced.frag`
---
## Ship amendment — 2026-05-08
### Problem discovered in cross-cutting review
Task 15's deletion of `mesh_instanced.vert/.frag` left `InstancedMeshRenderer`
orphaned. The `_staticMesh` construction was gated on `_meshShader is not null`,
and `_meshShader` was only assigned when bindless was present. So with
`ACDREAM_USE_WB_FOUNDATION=0`, the flag path produced `_meshShader=null`
`_staticMesh=null` → terrain+sky only with no entity rendering. The SHIP
commit's `[x] ACDREAM_USE_WB_FOUNDATION=0 still works` claim was inaccurate.
### Resolution
User authorized **Option B**: formal retirement of the legacy path in N.5
instead of restoring it. Reasons: bindless + WB foundation has been default-on
since N.4, escape hatch was never exercised in practice, N.6 was already
planning to retire it — we did it now instead.
**Files deleted:**
- `src/AcDream.App/Rendering/InstancedMeshRenderer.cs`
- `src/AcDream.App/Rendering/StaticMeshRenderer.cs`
- `src/AcDream.App/Rendering/Wb/WbFoundationFlag.cs`
**GameWindow simplified:**
- `_staticMesh` field removed
- Capability detection block is unconditional (no `WbFoundationFlag.IsEnabled` guard)
- Missing bindless throws `NotSupportedException` at startup with a clear message
- `_wbMeshAdapter`, `_wbEntitySpawnAdapter`, `_wbDrawDispatcher` all construct
unconditionally after the capability check
- Draw path: `_wbDrawDispatcher!.Draw(...)` — no null-conditional, no else branch
**GpuWorldState simplified:**
- `WbFoundationFlag.IsEnabled` guards removed from `AddLandblock` /
`RemoveLandblock`; adapter calls are unconditional when adapter is non-null
**Test file updated:**
- `PendingSpawnIntegrationTests.cs`: removed `static WbFoundationFlag.ForTestsOnly_ForceEnable()` ctor
(no longer needed — `GpuWorldState` adapter calls are unconditional)
**Spec §2 Decision 5 updated:** two-way flag → mandatory modern path.
**Spec §10 Out-of-scope updated:** `InstancedMeshRenderer` deletion crossed off (done).
**Roadmap updated:** N.5 entry notes retirement; N.6 scope narrowed.
**Perf baseline doc updated:** acceptance gate row corrected to N/A.
**CLAUDE.md updated:** WB integration cribs no longer reference WbFoundationFlag.
Build: green (0 errors, 0 warnings). Tests: 71/71 in Wb+MatrixComposition+TextureCacheBindless filter.

View file

@ -40,7 +40,7 @@ This section records the brainstorm outcomes that the rest of the doc relies on.
| 2 | Translucent rendering | **WB's two-pass alpha-test** (opaque pass discards `α<0.95`, transparent pass discards `α≥0.95`) | Single blend mode per pass enables one indirect call per pass. Loses native `Additive` blend on GfxObj surfaces; sky + particles have own renderers and aren't affected. Falsifiable at visual verification — if we see a regression, add an additive sub-pass (~30-min fix). | | 2 | Translucent rendering | **WB's two-pass alpha-test** (opaque pass discards `α<0.95`, transparent pass discards `α≥0.95`) | Single blend mode per pass enables one indirect call per pass. Loses native `Additive` blend on GfxObj surfaces; sky + particles have own renderers and aren't affected. Falsifiable at visual verification — if we see a regression, add an additive sub-pass (~30-min fix). |
| 3 | Per-instance + per-draw data delivery | **All-SSBO**: `Instances[]` at binding=0 (mat4 per instance), `Batches[]` at binding=1 (texture handle + layer + flags per group) | Matches WB's modern shader. SSBOs avoid the 16-attrib stride limit, scale to large instance counts, give clean per-draw indexing via `gl_DrawIDARB`. | | 3 | Per-instance + per-draw data delivery | **All-SSBO**: `Instances[]` at binding=0 (mat4 per instance), `Batches[]` at binding=1 (texture handle + layer + flags per group) | Matches WB's modern shader. SSBOs avoid the 16-attrib stride limit, scale to large instance counts, give clean per-draw indexing via `gl_DrawIDARB`. |
| 4 | Bindless handle residency | **Resident on upload, never release** | acdream's content set is bounded (~1-5K unique textures per session). Handles persist for process lifetime; no eviction code in N.5. Diagnostic logging of handle count under `ACDREAM_WB_DIAG=1` to spot growth. | | 4 | Bindless handle residency | **Resident on upload, never release** | acdream's content set is bounded (~1-5K unique textures per session). Handles persist for process lifetime; no eviction code in N.5. Diagnostic logging of handle count under `ACDREAM_WB_DIAG=1` to spot growth. |
| 5 | Escape hatch | **Two-way flag (no change)**. `ACDREAM_USE_WB_FOUNDATION=0/1` controls `WbFoundationFlag`; flag-on is the N.5 modern path; flag-off falls back to legacy `InstancedMeshRenderer`. N.4's draw method is replaced in place. | N.4's grouped-instanced draw is not preserved as an A/B fallback; legacy `InstancedMeshRenderer` is the existing safety net for "modern rendering broken on this GPU." | | 5 | Escape hatch | **Modern path mandatory (N.5 ship amendment)**. `WbFoundationFlag` and `ACDREAM_USE_WB_FOUNDATION` env var have been deleted. Missing `GL_ARB_bindless_texture` or `GL_ARB_shader_draw_parameters` throws `NotSupportedException` at startup with a clear error message. No fallback. | Escape hatch was never exercised after N.4 ship. Legacy `InstancedMeshRenderer` + `StaticMeshRenderer` deleted in the N.5 retirement commit. N.6 scope narrowed accordingly. |
| 6 | Perf measurement | **CPU stopwatch + GL timer queries** logged via `[WB-DIAG]` | Captures both CPU dispatcher time and GPU rendering time. Acceptance gate compares before/after numbers in fixed Holtburg/Foundry scenes. | | 6 | Perf measurement | **CPU stopwatch + GL timer queries** logged via `[WB-DIAG]` | Captures both CPU dispatcher time and GPU rendering time. Acceptance gate compares before/after numbers in fixed Holtburg/Foundry scenes. |
| 7 | Persistent-mapped buffers | **Defer to N.6** | Bindless+indirect win is 70-80% of achievable savings. Persistent-mapped + ring + sync is the last 5-10% with non-trivial sync-fence complexity; not worth the risk in N.5's 2-3 week budget. Add post-N.5 if profiling shows residual `glBufferData` cost. | | 7 | Persistent-mapped buffers | **Defer to N.6** | Bindless+indirect win is 70-80% of achievable savings. Persistent-mapped + ring + sync is the last 5-10% with non-trivial sync-fence complexity; not worth the risk in N.5's 2-3 week budget. Add post-N.5 if profiling shows residual `glBufferData` cost. |
| 8 | Per-instance highlight (selection blink) | **Defer to a Phase B.4 follow-up** | Retail pulses click targets as visual confirmation; the right mechanism is per-instance highlight color (NOT WB's global `uHighlightColor` which would tint everything in our single-indirect-call design). Field is reserved in design (extend `InstanceData` to include `vec4 highlightColor`); N.5 ships without the field, future phase plumbs it without shader rewrite. | | 8 | Per-instance highlight (selection blink) | **Defer to a Phase B.4 follow-up** | Retail pulses click targets as visual confirmation; the right mechanism is per-instance highlight color (NOT WB's global `uHighlightColor` which would tint everything in our single-indirect-call design). Field is reserved in design (extend `InstanceData` to include `vec4 highlightColor`); N.5 ships without the field, future phase plumbs it without shader rewrite. |
@ -540,7 +540,7 @@ The following are NOT N.5 work. They become possible follow-ons.
- **GPU-side culling (compute pre-pass).** Future phase. - **GPU-side culling (compute pre-pass).** Future phase.
- **Texture array repacking for multi-layer per-instance composites.** Future, if many palette-overrides actually share dimensions and could be packed. - **Texture array repacking for multi-layer per-instance composites.** Future, if many palette-overrides actually share dimensions and could be packed.
- **Selection-blink highlight color.** Decision 8. Phase B.4 follow-up. Field reserved in `InstanceData` design (extend stride to 80 bytes when implementing). - **Selection-blink highlight color.** Decision 8. Phase B.4 follow-up. Field reserved in `InstanceData` design (extend stride to 80 bytes when implementing).
- **Deletion of legacy `InstancedMeshRenderer`.** N.6. - ~~**Deletion of legacy `InstancedMeshRenderer`.** N.6.~~ **Done in N.5 ship amendment**`InstancedMeshRenderer`, `StaticMeshRenderer`, and `WbFoundationFlag` were deleted in the retirement commit.
- **Terrain wiring through WB.** Future. - **Terrain wiring through WB.** Future.
--- ---

View file

@ -25,17 +25,16 @@ public sealed class GameWindow : IDisposable
private DatCollection? _dats; private DatCollection? _dats;
private float _lastMouseX; private float _lastMouseX;
private float _lastMouseY; private float _lastMouseY;
private InstancedMeshRenderer? _staticMesh;
private Shader? _meshShader; private Shader? _meshShader;
private TextureCache? _textureCache; private TextureCache? _textureCache;
/// <summary>Phase N.4: WB-backed rendering pipeline adapter. Non-null only /// <summary>Phase N.4+: WB-backed rendering pipeline adapter. Always non-null
/// when <c>ACDREAM_USE_WB_FOUNDATION=1</c> is set; null otherwise.</summary> /// after <c>OnLoad</c> completes (modern path is mandatory as of N.5).</summary>
private AcDream.App.Rendering.Wb.WbMeshAdapter? _wbMeshAdapter; private AcDream.App.Rendering.Wb.WbMeshAdapter? _wbMeshAdapter;
private AcDream.App.Rendering.Wb.EntitySpawnAdapter? _wbEntitySpawnAdapter; private AcDream.App.Rendering.Wb.EntitySpawnAdapter? _wbEntitySpawnAdapter;
private AcDream.App.Rendering.Wb.WbDrawDispatcher? _wbDrawDispatcher; private AcDream.App.Rendering.Wb.WbDrawDispatcher? _wbDrawDispatcher;
/// <summary>Phase N.5: ARB_bindless_texture + ARB_shader_draw_parameters /// <summary>Phase N.5: ARB_bindless_texture + ARB_shader_draw_parameters
/// support. Non-null only when both extensions are present and WbFoundation /// support. Required at startup — missing bindless throws
/// is enabled. Passed to TextureCache and (later) WbDrawDispatcher.</summary> /// <see cref="NotSupportedException"/> in <c>OnLoad</c>.</summary>
private AcDream.App.Rendering.Wb.BindlessSupport? _bindlessSupport; private AcDream.App.Rendering.Wb.BindlessSupport? _bindlessSupport;
private SamplerCache? _samplerCache; private SamplerCache? _samplerCache;
private DebugLineRenderer? _debugLines; private DebugLineRenderer? _debugLines;
@ -970,10 +969,6 @@ public sealed class GameWindow : IDisposable
Path.Combine(shadersDir, "terrain.vert"), Path.Combine(shadersDir, "terrain.vert"),
Path.Combine(shadersDir, "terrain.frag")); Path.Combine(shadersDir, "terrain.frag"));
// mesh_instanced is the default; Task 10 (N.5) moves the final shader
// selection to after capability detection so mesh_modern can be chosen
// when bindless + ARB_shader_draw_parameters are available. See below.
// Phase G.1/G.2: shared scene-lighting UBO. Stays bound at // Phase G.1/G.2: shared scene-lighting UBO. Stays bound at
// binding=1 for the lifetime of the process — every shader that // binding=1 for the lifetime of the process — every shader that
// declares `layout(std140, binding = 1) uniform SceneLighting` // declares `layout(std140, binding = 1) uniform SceneLighting`
@ -1423,12 +1418,11 @@ public sealed class GameWindow : IDisposable
_heightTable = heightTable; _heightTable = heightTable;
_surfaceCache = new Dictionary<uint, AcDream.Core.Terrain.SurfaceInfo>(); _surfaceCache = new Dictionary<uint, AcDream.Core.Terrain.SurfaceInfo>();
// N.5: detect ARB_bindless_texture + ARB_shader_draw_parameters when WB // N.5: detect ARB_bindless_texture + ARB_shader_draw_parameters.
// foundation is on. Store the BindlessSupport for TextureCache + future // The modern path (SSBO + glMultiDrawElementsIndirect + bindless textures)
// WbDrawDispatcher. Mesh shader load stays as mesh_instanced for now — // is mandatory as of Phase N.5 — missing extensions throw at startup with
// Task 10 swaps to mesh_modern after the dispatcher is rewired. // a clear error so users can file a real bug report rather than silently
if (AcDream.App.Rendering.Wb.WbFoundationFlag.IsEnabled) // falling back to a half-working renderer.
{
if (AcDream.App.Rendering.Wb.BindlessSupport.TryCreate(_gl, out var bindless)) if (AcDream.App.Rendering.Wb.BindlessSupport.TryCreate(_gl, out var bindless))
{ {
if (bindless!.HasShaderDrawParameters(_gl)) if (bindless!.HasShaderDrawParameters(_gl))
@ -1438,28 +1432,27 @@ public sealed class GameWindow : IDisposable
} }
else else
{ {
Console.WriteLine("[N.5] GL_ARB_shader_draw_parameters not present — modern dispatch path will not activate"); Console.WriteLine("[N.5] GL_ARB_shader_draw_parameters not present — modern path not available");
} }
} }
else else
{ {
Console.WriteLine("[N.5] GL_ARB_bindless_texture not present — modern dispatch path will not activate"); Console.WriteLine("[N.5] GL_ARB_bindless_texture not present — modern path not available");
}
} }
// N.5 Task 10/15: load mesh_modern when both extensions are present. if (_bindlessSupport is null)
// If bindless is missing _meshShader stays null, _wbDrawDispatcher won't
// be constructed (its guard requires _bindlessSupport non-null), and
// rendering falls back to InstancedMeshRenderer — but only when
// _meshShader is non-null (see _staticMesh construction below).
if (_bindlessSupport is not null)
{ {
throw new NotSupportedException(
"acdream requires GL_ARB_bindless_texture + GL_ARB_shader_draw_parameters " +
"(GL 4.3+ with bindless support). Your GPU/driver does not expose these extensions. " +
"If this is unexpected, please file a bug report with your GPU vendor + driver version.");
}
// Mesh shader always loads (modern path is the only path).
_meshShader = new Shader(_gl, _meshShader = new Shader(_gl,
Path.Combine(shadersDir, "mesh_modern.vert"), Path.Combine(shadersDir, "mesh_modern.vert"),
Path.Combine(shadersDir, "mesh_modern.frag")); Path.Combine(shadersDir, "mesh_modern.frag"));
Console.WriteLine("[N.5] mesh_modern shader loaded"); Console.WriteLine("[N.5] mesh_modern shader loaded");
}
// else: bindless missing — _meshShader stays null.
_textureCache = new TextureCache(_gl, _dats, _bindlessSupport); _textureCache = new TextureCache(_gl, _dats, _bindlessSupport);
// Two persistent GL sampler objects (Repeat + ClampToEdge) so // Two persistent GL sampler objects (Repeat + ClampToEdge) so
@ -1469,17 +1462,14 @@ public sealed class GameWindow : IDisposable
// references/WorldBuilder/Chorizite.OpenGLSDLBackend/OpenGLGraphicsDevice.cs:115-132. // references/WorldBuilder/Chorizite.OpenGLSDLBackend/OpenGLGraphicsDevice.cs:115-132.
_samplerCache = new SamplerCache(_gl); _samplerCache = new SamplerCache(_gl);
// Phase N.4 — WB rendering pipeline foundation. Constructed only when // Phase N.4+N.5 — WB rendering pipeline foundation. The modern path is
// ACDREAM_USE_WB_FOUNDATION=1 is set; otherwise the legacy renderer // mandatory as of N.5 ship amendment: WbMeshAdapter + WbDrawDispatcher
// path stays in charge. The full ObjectMeshManager bring-up lives in // always construct. WbMeshAdapter owns ObjectMeshManager and opens its
// WbMeshAdapter (Task 9): OpenGLGraphicsDevice + DefaultDatReaderWriter // own file handles for the dat files (independent of our DatCollection).
// + ObjectMeshManager. WbMeshAdapter opens its own file handles for
// the dat files (independent of our DatCollection).
if (AcDream.App.Rendering.Wb.WbFoundationFlag.IsEnabled)
{ {
var wbLogger = Microsoft.Extensions.Logging.Abstractions.NullLogger<AcDream.App.Rendering.Wb.WbMeshAdapter>.Instance; var wbLogger = Microsoft.Extensions.Logging.Abstractions.NullLogger<AcDream.App.Rendering.Wb.WbMeshAdapter>.Instance;
_wbMeshAdapter = new AcDream.App.Rendering.Wb.WbMeshAdapter(_gl, _datDir, _dats, wbLogger); _wbMeshAdapter = new AcDream.App.Rendering.Wb.WbMeshAdapter(_gl, _datDir, _dats, wbLogger);
Console.WriteLine("[N.4] WbFoundation flag is ENABLED — routing static content through ObjectMeshManager."); Console.WriteLine("[N.4+N.5] WB foundation + modern path active — routing all content through ObjectMeshManager.");
} }
// Phase N.4 Task 12: construct LandblockSpawnAdapter under the feature flag // Phase N.4 Task 12: construct LandblockSpawnAdapter under the feature flag
@ -1488,17 +1478,15 @@ public sealed class GameWindow : IDisposable
// one that carries the adapter so AddLandblock/RemoveLandblock notify WB. // one that carries the adapter so AddLandblock/RemoveLandblock notify WB.
// Phase N.4 Task 17: also construct EntitySpawnAdapter for server-spawned // Phase N.4 Task 17: also construct EntitySpawnAdapter for server-spawned
// per-instance content under the same flag. // per-instance content under the same flag.
// N.5 mandatory path: spawn adapters + dispatcher always construct.
// _wbMeshAdapter, _meshShader, _textureCache, and _bindlessSupport are
// all guaranteed non-null here (startup throws above if any are missing).
{ {
AcDream.App.Rendering.Wb.LandblockSpawnAdapter? wbSpawnAdapter = null; var wbSpawnAdapter = new AcDream.App.Rendering.Wb.LandblockSpawnAdapter(_wbMeshAdapter!);
AcDream.App.Rendering.Wb.EntitySpawnAdapter? wbEntitySpawnAdapter = null;
if (AcDream.App.Rendering.Wb.WbFoundationFlag.IsEnabled && _wbMeshAdapter is not null)
{
wbSpawnAdapter = new AcDream.App.Rendering.Wb.LandblockSpawnAdapter(_wbMeshAdapter);
// Sequencer factory: look up Setup + MotionTable from dats and build // Sequencer factory: look up Setup + MotionTable from dats and build
// an AnimationSequencer. Falls back to a no-op sequencer when the // an AnimationSequencer. Falls back to a no-op sequencer when the
// entity has no motion table (static props, etc.). Uses _animLoader // entity has no motion table (static props, etc.). Uses _animLoader
// which is initialised at line 1004; it is non-null here because // which is initialised earlier in OnLoad; it is non-null here.
// OnLoad wires _dats + _animLoader before this block runs.
var capturedDats = _dats; var capturedDats = _dats;
var capturedAnimLoader = _animLoader; var capturedAnimLoader = _animLoader;
AcDream.Core.Physics.AnimationSequencer SequencerFactory(AcDream.Core.World.WorldEntity e) AcDream.Core.Physics.AnimationSequencer SequencerFactory(AcDream.Core.World.WorldEntity e)
@ -1528,28 +1516,13 @@ public sealed class GameWindow : IDisposable
new DatReaderWriter.DBObjs.MotionTable(), new DatReaderWriter.DBObjs.MotionTable(),
new NullAnimLoader()); new NullAnimLoader());
} }
wbEntitySpawnAdapter = new AcDream.App.Rendering.Wb.EntitySpawnAdapter( var wbEntitySpawnAdapter = new AcDream.App.Rendering.Wb.EntitySpawnAdapter(
_textureCache, SequencerFactory, _wbMeshAdapter); _textureCache!, SequencerFactory, _wbMeshAdapter!);
_wbEntitySpawnAdapter = wbEntitySpawnAdapter; _wbEntitySpawnAdapter = wbEntitySpawnAdapter;
}
_worldState = new AcDream.App.Streaming.GpuWorldState(wbSpawnAdapter, wbEntitySpawnAdapter); _worldState = new AcDream.App.Streaming.GpuWorldState(wbSpawnAdapter, wbEntitySpawnAdapter);
}
// Task 15: _meshShader is null when bindless is missing; skip constructing
// _staticMesh in that case. All downstream _staticMesh usages are already
// null-safe (null-conditional operators or explicit null guards).
if (_meshShader is not null && _textureCache is not null)
_staticMesh = new InstancedMeshRenderer(_gl, _meshShader, _textureCache, _wbMeshAdapter);
if (AcDream.App.Rendering.Wb.WbFoundationFlag.IsEnabled
&& _wbMeshAdapter is not null && _wbEntitySpawnAdapter is not null
&& _bindlessSupport is not null)
{
// _meshShader is non-null here: the _bindlessSupport guard implies
// the if(_bindlessSupport is not null) block above ran and assigned it.
// _textureCache is always non-null (assigned unconditionally above).
_wbDrawDispatcher = new AcDream.App.Rendering.Wb.WbDrawDispatcher( _wbDrawDispatcher = new AcDream.App.Rendering.Wb.WbDrawDispatcher(
_gl, _meshShader!, _textureCache!, _wbMeshAdapter, _wbEntitySpawnAdapter, _bindlessSupport); _gl, _meshShader!, _textureCache!, _wbMeshAdapter!, _wbEntitySpawnAdapter, _bindlessSupport!);
} }
// Phase G.1 sky renderer — its own shader (sky.vert / sky.frag) // Phase G.1 sky renderer — its own shader (sky.vert / sky.frag)
@ -2075,7 +2048,7 @@ public sealed class GameWindow : IDisposable
} }
} }
if (_dats is null || _staticMesh is null) return; if (_dats is null) return;
if (spawn.Position is null || spawn.SetupTableId is null) if (spawn.Position is null || spawn.SetupTableId is null)
{ {
// Can't place a mesh without both. Most of these are inventory // Can't place a mesh without both. Most of these are inventory
@ -2410,10 +2383,9 @@ public sealed class GameWindow : IDisposable
continue; continue;
} }
_physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx); _physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx);
var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
_staticMesh.EnsureUploaded(mr.GfxObjId, subMeshes);
if (dumpClothing) if (dumpClothing)
{ {
var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
int tris = 0; int subs = 0; int tris = 0; int subs = 0;
foreach (var sm in subMeshes) { tris += sm.Indices.Length / 3; subs++; } foreach (var sm in subMeshes) { tris += sm.Indices.Length / 3; subs++; }
dumpClothingTotalTris += tris; dumpClothingTotalTris += tris;
@ -5244,44 +5216,25 @@ public sealed class GameWindow : IDisposable
portalPlanes, origin.X, origin.Y); portalPlanes, origin.X, origin.Y);
} }
// Upload every GfxObj referenced by this landblock's entities. // N.5: WbMeshAdapter.Tick() handles GPU upload for all GfxObj meshes via
// EnsureUploaded is idempotent so duplicates across landblocks are free. // ObjectMeshManager.PrepareMeshDataAsync. The legacy EnsureUploaded loop
if (_staticMesh is not null) // (and _pendingCellMeshes drain) are retired with InstancedMeshRenderer.
{ // Cache GfxObj physics data (BSP trees) for the physics engine — this
// Task 8: drain any pending EnvCell room-mesh sub-meshes first. // loop is physics-only, not renderer-side.
// The worker thread pre-built these CPU-side and stored them in
// _pendingCellMeshes. We must upload them here (render thread) before
// the per-MeshRef loop below tries to look them up via GfxObjMesh.Build,
// which would fail because EnvCell ids (0xAAAA01xx) aren't real GfxObj
// dat ids. EnsureUploaded is idempotent so calling it here then seeing
// the same id again in the loop below is safe.
foreach (var entity in lb.Entities) foreach (var entity in lb.Entities)
{ {
foreach (var meshRef in entity.MeshRefs) foreach (var meshRef in entity.MeshRefs)
{ {
if (_pendingCellMeshes.TryRemove(meshRef.GfxObjId, out var cellSubMeshes))
_staticMesh.EnsureUploaded(meshRef.GfxObjId, cellSubMeshes);
}
}
// Now upload regular GfxObj sub-meshes (stabs, scenery, interior stabs).
// Skip any ids already uploaded (includes the cell meshes just drained).
foreach (var entity in lb.Entities)
{
foreach (var meshRef in entity.MeshRefs)
{
// Skip EnvCell synthetic ids — already handled above (or already
// uploaded on a prior tick). GfxObj ids are 0x01xxxxxx; Setup ids
// are 0x02xxxxxx; anything else is not a GfxObj dat record.
if ((meshRef.GfxObjId & 0xFF000000u) != 0x01000000u) continue; if ((meshRef.GfxObjId & 0xFF000000u) != 0x01000000u) continue;
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(meshRef.GfxObjId); var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(meshRef.GfxObjId);
if (gfx is null) continue; if (gfx is null) continue;
_physicsDataCache.CacheGfxObj(meshRef.GfxObjId, gfx); _physicsDataCache.CacheGfxObj(meshRef.GfxObjId, gfx);
var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
_staticMesh.EnsureUploaded(meshRef.GfxObjId, subMeshes);
}
} }
} }
// Drain _pendingCellMeshes to prevent unbounded accumulation.
// The data is no longer consumed (WB handles EnvCell geometry through
// its own pipeline), but the worker thread still populates this dict.
_pendingCellMeshes.Clear();
// Task 7: register static entities into the ShadowObjectRegistry so the // Task 7: register static entities into the ShadowObjectRegistry so the
// Transition system can find and collide against them during movement. // Transition system can find and collide against them during movement.
@ -6386,20 +6339,11 @@ public sealed class GameWindow : IDisposable
animatedIds.Add(k); animatedIds.Add(k);
} }
if (_wbDrawDispatcher is not null) // N.5: WbDrawDispatcher is always non-null (modern path mandatory).
{ _wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum,
_wbDrawDispatcher.Draw(camera, _worldState.LandblockEntries, frustum,
neverCullLandblockId: playerLb, neverCullLandblockId: playerLb,
visibleCellIds: visibility?.VisibleCellIds, visibleCellIds: visibility?.VisibleCellIds,
animatedEntityIds: animatedIds); animatedEntityIds: animatedIds);
}
else
{
_staticMesh?.Draw(camera, _worldState.LandblockEntries, frustum,
neverCullLandblockId: playerLb,
visibleCellIds: visibility?.VisibleCellIds,
animatedEntityIds: animatedIds);
}
// Phase G.1 / E.3: draw all live particles after opaque // Phase G.1 / E.3: draw all live particles after opaque
// scene geometry so alpha blending composites correctly. // scene geometry so alpha blending composites correctly.
@ -8781,11 +8725,10 @@ public sealed class GameWindow : IDisposable
_liveSession?.Dispose(); _liveSession?.Dispose();
_audioEngine?.Dispose(); // Phase E.2: stop all voices, close AL context _audioEngine?.Dispose(); // Phase E.2: stop all voices, close AL context
_wbDrawDispatcher?.Dispose(); _wbDrawDispatcher?.Dispose();
_staticMesh?.Dispose();
_skyRenderer?.Dispose(); // depends on sampler cache; dispose first _skyRenderer?.Dispose(); // depends on sampler cache; dispose first
_samplerCache?.Dispose(); _samplerCache?.Dispose();
_textureCache?.Dispose(); _textureCache?.Dispose();
_wbMeshAdapter?.Dispose(); // Phase N.4 WB foundation — null when flag off _wbMeshAdapter?.Dispose(); // Phase N.4+N.5 WB foundation (mandatory modern path)
_meshShader?.Dispose(); _meshShader?.Dispose();
_terrain?.Dispose(); _terrain?.Dispose();

View file

@ -1,596 +0,0 @@
// src/AcDream.App/Rendering/InstancedMeshRenderer.cs
//
// True instanced rendering for static-object meshes.
// Groups entities by GfxObjId. All instance model matrices are written into
// a single shared instance VBO once per frame. Each sub-mesh is drawn with
// DrawElementsInstanced — one GL draw call per (GfxObj × sub-mesh) instead
// of one per entity. For a scene with N unique GfxObjs and M total entities
// this reduces draw calls from M*subMeshes to N*subMeshes.
//
// Matrix layout:
// System.Numerics.Matrix4x4 is row-major. Written to the float[] buffer in
// natural memory order (M11..M44). The GLSL shader reads 4 vec4 attributes
// (aInstanceRow0-3) and constructs mat4(row0, row1, row2, row3). Because
// GLSL mat4() takes column vectors, the rows of the C# matrix become the
// columns of the GLSL mat4 — which is the same transpose that UniformMatrix4
// with transpose=false produces. Visual result is identical to the old
// SetMatrix4("uModel", ...) path.
//
// Architecture note: public API matches StaticMeshRenderer so GameWindow only
// needs to update the shader and uniform setup at the call sites.
using System.Numerics;
using System.Runtime.InteropServices;
using AcDream.App.Rendering.Wb;
using AcDream.Core.Meshing;
using AcDream.Core.Terrain;
using AcDream.Core.World;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
public sealed unsafe class InstancedMeshRenderer : IDisposable
{
private readonly GL _gl;
private readonly Shader _shader;
private readonly TextureCache _textures;
/// <summary>
/// Optional WB adapter. Held but currently unused — Phase N.4 Adjustment 2
/// (2026-05-08) reverted Task 9's renderer-level routing. Tier-routing decisions
/// (atlas vs per-instance) belong at the spawn-callback layer (Task 11
/// LandblockSpawnAdapter for atlas-tier; Task 17 EntitySpawnAdapter for
/// per-instance), not in the renderer which is intentionally tier-blind. The
/// constructor parameter is preserved so GameWindow's wire-up doesn't shift
/// when later tasks need adapter access.
/// </summary>
private readonly WbMeshAdapter? _wbMeshAdapter;
// One GPU bundle per unique GfxObj id. Each GfxObj can have multiple sub-meshes.
private readonly Dictionary<uint, List<SubMeshGpu>> _gpuByGfxObj = new();
// Shared instance VBO — filled every frame with all instance model matrices.
private readonly uint _instanceVbo;
// Per-frame scratch: reused float buffer for instance matrix data.
// 16 floats per mat4. Grown on demand; never shrunk.
private float[] _instanceBuffer = new float[256 * 16]; // start at 256 instances
// ── Instance grouping scratch ─────────────────────────────────────────────
//
// Reused every frame to avoid per-frame allocation.
//
// **Group key = (GfxObjId, PaletteOverrideHash, SurfaceOverridesHash).**
//
// An earlier implementation grouped on <c>GfxObjId</c> alone and resolved
// the per-sub-mesh texture from the first instance in the group — which
// is fine for scenery where every tree shares the same palette, but
// utterly broken for NPCs: every humanoid uses the same base body
// GfxObjs and they all piled into one group, so the first NPC's palette
// was used for every NPC in the frame. Frustum culling + iteration
// order meant that "first NPC" changed as the camera turned — producing
// the "NPC clothing changes when I turn" symptom.
//
// Now we also key by the entity's PaletteOverride + per-MeshRef
// SurfaceOverrides signature so only entities that decode to the
// SAME texture for every sub-mesh can share a batch. Entities with
// unique appearance fall to single-instance groups (still correct,
// marginally slower than true instancing).
private readonly Dictionary<GroupKey, InstanceGroup> _groups = new();
private readonly record struct GroupKey(uint GfxObjId, ulong TextureSignature);
public InstancedMeshRenderer(GL gl, Shader shader, TextureCache textures,
WbMeshAdapter? wbMeshAdapter = null)
{
_gl = gl;
_shader = shader;
_textures = textures;
_wbMeshAdapter = wbMeshAdapter;
_instanceVbo = _gl.GenBuffer();
}
// ── Upload ────────────────────────────────────────────────────────────────
public void EnsureUploaded(uint gfxObjId, IReadOnlyList<GfxObjSubMesh> subMeshes)
{
if (_gpuByGfxObj.ContainsKey(gfxObjId))
return;
// Phase N.4 Adjustment 2 (2026-05-08): renderer is tier-blind. Tier-routing
// (atlas vs per-instance) lives at the spawn-callback layer (Tasks 11 + 17),
// not here. Smoke-test of the original Task 9 routing showed it caught
// characters / NPCs (server-spawned, per-instance tier) along with static
// scenery, because EnsureUploaded is called from both spawn paths.
var list = new List<SubMeshGpu>(subMeshes.Count);
foreach (var sm in subMeshes)
list.Add(UploadSubMesh(sm));
_gpuByGfxObj[gfxObjId] = list;
}
private SubMeshGpu UploadSubMesh(GfxObjSubMesh sm)
{
uint vao = _gl.GenVertexArray();
_gl.BindVertexArray(vao);
// ── Vertex buffer (positions, normals, UVs) ───────────────────────────
uint vbo = _gl.GenBuffer();
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, vbo);
fixed (void* p = sm.Vertices)
_gl.BufferData(BufferTargetARB.ArrayBuffer,
(nuint)(sm.Vertices.Length * sizeof(Vertex)), p, BufferUsageARB.StaticDraw);
uint stride = (uint)sizeof(Vertex);
_gl.EnableVertexAttribArray(0);
_gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, stride, (void*)0);
_gl.EnableVertexAttribArray(1);
_gl.VertexAttribPointer(1, 3, VertexAttribPointerType.Float, false, stride, (void*)(3 * sizeof(float)));
_gl.EnableVertexAttribArray(2);
_gl.VertexAttribPointer(2, 2, VertexAttribPointerType.Float, false, stride, (void*)(6 * sizeof(float)));
// Note: location 3 (uint TerrainLayer) is NOT used by mesh_instanced.vert;
// that slot is reserved for per-instance mat4 row 0 from the instance VBO.
// ── Index buffer ──────────────────────────────────────────────────────
uint ebo = _gl.GenBuffer();
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, ebo);
fixed (void* p = sm.Indices)
_gl.BufferData(BufferTargetARB.ElementArrayBuffer,
(nuint)(sm.Indices.Length * sizeof(uint)), p, BufferUsageARB.StaticDraw);
// ── Per-instance model matrix (locations 3-6) ─────────────────────────
// Bind the shared instance VBO. The VAO captures this binding at each
// attribute location. At draw time we re-call VertexAttribPointer with
// the per-group byte offset (to address different groups in the VBO
// without DrawElementsInstancedBaseInstance).
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _instanceVbo);
// mat4 = 4 × vec4, stride = 64 bytes, divisor = 1 (advance once per instance)
for (uint row = 0; row < 4; row++)
{
uint loc = 3 + row;
_gl.EnableVertexAttribArray(loc);
_gl.VertexAttribPointer(loc, 4, VertexAttribPointerType.Float, false, 64, (void*)(row * 16));
_gl.VertexAttribDivisor(loc, 1);
}
_gl.BindVertexArray(0);
return new SubMeshGpu
{
Vao = vao,
Vbo = vbo,
Ebo = ebo,
IndexCount = sm.Indices.Length,
SurfaceId = sm.SurfaceId,
Translucency = sm.Translucency,
};
}
// ── Draw ──────────────────────────────────────────────────────────────────
public void Draw(ICamera camera,
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList<WorldEntity> Entities)> landblockEntries,
FrustumPlanes? frustum = null,
uint? neverCullLandblockId = null,
HashSet<uint>? visibleCellIds = null,
// L-fix1 (2026-04-28): set of entity ids that should bypass the
// landblock-level frustum cull. Animated entities (other
// players, NPCs, monsters) are always rendered if their
// landblock is loaded — without this they vanish whenever the
// camera rotates away from their landblock, even though
// they're within visible distance of the player. Pass null /
// empty to keep the previous "cull everything by landblock"
// behavior.
HashSet<uint>? animatedEntityIds = null)
{
_shader.Use();
var vp = camera.View * camera.Projection;
_shader.SetMatrix4("uViewProjection", vp);
// Phase G: lighting + ambient + fog are owned by the
// SceneLighting UBO (binding=1) uploaded once per frame by
// GameWindow. The instanced mesh fragment shader reads it
// directly — no per-draw uniform uploads needed.
// ── Collect and group instances ───────────────────────────────────────
CollectGroups(landblockEntries, frustum, neverCullLandblockId, visibleCellIds, animatedEntityIds);
// ── Build and upload the instance buffer ──────────────────────────────
// Count total instances.
int totalInstances = 0;
foreach (var grp in _groups.Values)
totalInstances += grp.Count;
// Grow the scratch buffer if needed.
int needed = totalInstances * 16;
if (_instanceBuffer.Length < needed)
_instanceBuffer = new float[needed + 256 * 16]; // extra headroom
// Write all groups contiguously. Record each group's starting offset
// (in units of instances, not bytes) so we can address them at draw time.
int instanceOffset = 0;
foreach (var grp in _groups.Values)
{
grp.BufferOffset = instanceOffset;
foreach (ref readonly var inst in CollectionsMarshal.AsSpan(grp.Entries))
WriteMatrix(_instanceBuffer, instanceOffset++ * 16, inst.Model);
}
// Upload all instance data in a single DynamicDraw call.
if (totalInstances > 0)
{
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _instanceVbo);
fixed (void* p = _instanceBuffer)
_gl.BufferData(BufferTargetARB.ArrayBuffer,
(nuint)(totalInstances * 16 * sizeof(float)), p, BufferUsageARB.DynamicDraw);
}
// ── Pass 1: Opaque + ClipMap ──────────────────────────────────────────
// Diagnostic: ACDREAM_NO_CULL=1 disables backface culling entirely.
if (string.Equals(Environment.GetEnvironmentVariable("ACDREAM_NO_CULL"), "1", StringComparison.Ordinal))
{
_gl.Disable(EnableCap.CullFace);
}
foreach (var (key, grp) in _groups)
{
if (!_gpuByGfxObj.TryGetValue(key.GfxObjId, out var subMeshes))
continue;
bool hasOpaqueSubMesh = false;
foreach (var sub in subMeshes)
{
if (sub.Translucency == TranslucencyKind.Opaque ||
sub.Translucency == TranslucencyKind.ClipMap)
{
hasOpaqueSubMesh = true;
break;
}
}
if (!hasOpaqueSubMesh) continue;
// For this group, instance data starts at grp.BufferOffset in the VBO.
// We need to tell the VAO to read from that offset.
uint byteOffset = (uint)(grp.BufferOffset * 64); // 64 bytes per mat4
foreach (var sub in subMeshes)
{
if (sub.Translucency != TranslucencyKind.Opaque &&
sub.Translucency != TranslucencyKind.ClipMap)
continue;
_shader.SetInt("uTranslucencyKind", (int)sub.Translucency);
// Bind VAO + re-point instance attributes to the group's slice
// in the shared VBO. This updates the VAO's stored offset for
// locations 3-6 without touching the vertex or index bindings.
_gl.BindVertexArray(sub.Vao);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _instanceVbo);
for (uint row = 0; row < 4; row++)
{
_gl.VertexAttribPointer(3 + row, 4, VertexAttribPointerType.Float,
false, 64, (void*)(byteOffset + row * 16));
}
// Resolve texture from the first instance (all instances in this
// group share the same GfxObj so they have compatible overrides
// only in the degenerate case of mixed-palette entities using the
// same GfxObj — rare enough to accept the approximation here).
if (grp.Count == 0) continue;
var firstEntry = grp.Entries[0];
uint tex = ResolveTex(firstEntry.Entity, firstEntry.MeshRef, sub);
_gl.ActiveTexture(TextureUnit.Texture0);
_gl.BindTexture(TextureTarget.Texture2D, tex);
_gl.DrawElementsInstanced(PrimitiveType.Triangles,
(uint)sub.IndexCount,
DrawElementsType.UnsignedInt,
(void*)0,
(uint)grp.Count);
}
}
// ── Pass 2: Translucent (AlphaBlend, Additive, InvAlpha) ─────────────
_gl.Enable(EnableCap.Blend);
_gl.DepthMask(false);
// Diagnostic: ACDREAM_NO_CULL=1 disables backface culling (used 2026-05-01
// to test if our mesh winding (0,i,i+1) vs ACME's (i+1,i,0) is causing
// visible polygons to be culled, especially around the neck/coat seam).
if (string.Equals(Environment.GetEnvironmentVariable("ACDREAM_NO_CULL"), "1", StringComparison.Ordinal))
{
_gl.Disable(EnableCap.CullFace);
}
else
{
_gl.Enable(EnableCap.CullFace);
_gl.CullFace(TriangleFace.Back);
_gl.FrontFace(FrontFaceDirection.Ccw);
}
foreach (var (key, grp) in _groups)
{
if (!_gpuByGfxObj.TryGetValue(key.GfxObjId, out var subMeshes))
continue;
bool hasTranslucentSubMesh = false;
foreach (var sub in subMeshes)
{
if (sub.Translucency != TranslucencyKind.Opaque &&
sub.Translucency != TranslucencyKind.ClipMap)
{
hasTranslucentSubMesh = true;
break;
}
}
if (!hasTranslucentSubMesh) continue;
uint byteOffset = (uint)(grp.BufferOffset * 64);
foreach (var sub in subMeshes)
{
if (sub.Translucency == TranslucencyKind.Opaque ||
sub.Translucency == TranslucencyKind.ClipMap)
continue;
switch (sub.Translucency)
{
case TranslucencyKind.Additive:
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.One);
break;
case TranslucencyKind.InvAlpha:
_gl.BlendFunc(BlendingFactor.OneMinusSrcAlpha, BlendingFactor.SrcAlpha);
break;
default: // AlphaBlend
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
break;
}
_shader.SetInt("uTranslucencyKind", (int)sub.Translucency);
_gl.BindVertexArray(sub.Vao);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _instanceVbo);
for (uint row = 0; row < 4; row++)
{
_gl.VertexAttribPointer(3 + row, 4, VertexAttribPointerType.Float,
false, 64, (void*)(byteOffset + row * 16));
}
if (grp.Count == 0) continue;
var firstEntry = grp.Entries[0];
uint tex = ResolveTex(firstEntry.Entity, firstEntry.MeshRef, sub);
_gl.ActiveTexture(TextureUnit.Texture0);
_gl.BindTexture(TextureTarget.Texture2D, tex);
_gl.DrawElementsInstanced(PrimitiveType.Triangles,
(uint)sub.IndexCount,
DrawElementsType.UnsignedInt,
(void*)0,
(uint)grp.Count);
}
}
// Restore default GL state.
_gl.DepthMask(true);
_gl.Disable(EnableCap.Blend);
_gl.Disable(EnableCap.CullFace);
_gl.BindVertexArray(0);
}
// ── Grouping ──────────────────────────────────────────────────────────────
/// <summary>
/// Iterates all visible landblock entries and groups every (entity, meshRef)
/// pair by GfxObjId. Clears previous frame's groups before filling.
/// </summary>
private void CollectGroups(
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList<WorldEntity> Entities)> landblockEntries,
FrustumPlanes? frustum,
uint? neverCullLandblockId,
HashSet<uint>? visibleCellIds,
HashSet<uint>? animatedEntityIds)
{
foreach (var grp in _groups.Values)
grp.Entries.Clear();
foreach (var entry in landblockEntries)
{
// L-fix1 (2026-04-28): the landblock cull decision is now
// PER-LANDBLOCK boolean, not a continue. We still need to
// walk the entity list because animated entities (in
// animatedEntityIds) bypass the cull and render anyway.
bool landblockVisible = frustum is null
|| entry.LandblockId == neverCullLandblockId
|| FrustumCuller.IsAabbVisible(frustum.Value, entry.AabbMin, entry.AabbMax);
// Fast path: no animated entities globally → if landblock is
// culled, skip the whole entity list (preserves the original
// O(visible-landblocks) cost when the caller doesn't care
// about animated bypass).
if (!landblockVisible && (animatedEntityIds is null || animatedEntityIds.Count == 0))
continue;
foreach (var entity in entry.Entities)
{
if (entity.MeshRefs.Count == 0)
continue;
// L-fix1: when the landblock is frustum-culled, only
// render entities flagged as animated. This keeps
// remote players / NPCs / monsters visible even when
// their landblock rotates out of the view frustum.
bool isAnimated = animatedEntityIds?.Contains(entity.Id) == true;
if (!landblockVisible && !isAnimated)
continue;
// Step 4: portal visibility filter. If we have a visible cell set,
// skip interior entities whose parent cell isn't visible.
// visibleCellIds == null means camera is outdoors → show all interiors.
if (entity.ParentCellId.HasValue && visibleCellIds is not null
&& !visibleCellIds.Contains(entity.ParentCellId.Value))
continue;
var entityRoot =
Matrix4x4.CreateFromQuaternion(entity.Rotation) *
Matrix4x4.CreateTranslation(entity.Position);
// Hash the entity's PaletteOverride once — shared by every
// MeshRef on this entity, so we compute it outside the loop.
ulong palHash = HashPaletteOverride(entity.PaletteOverride);
foreach (var meshRef in entity.MeshRefs)
{
if (!_gpuByGfxObj.TryGetValue(meshRef.GfxObjId, out var cachedMeshes))
continue;
var model = meshRef.PartTransform * entityRoot;
// Texture signature = palette hash ^ surface-overrides hash.
// Two instances can share a batch only when their ResolveTex
// would return identical handles for every sub-mesh — that
// means identical palette AND identical surface overrides.
ulong surfHash = HashSurfaceOverrides(meshRef.SurfaceOverrides);
ulong texSig = palHash ^ surfHash;
var key = new GroupKey(meshRef.GfxObjId, texSig);
if (!_groups.TryGetValue(key, out var group))
{
group = new InstanceGroup();
_groups[key] = group;
}
group.Entries.Add(new InstanceEntry(model, entity, meshRef));
}
}
}
}
private static ulong HashPaletteOverride(AcDream.Core.World.PaletteOverride? p)
{
if (p is null) return 0UL;
ulong h = 0xCBF29CE484222325UL;
const ulong prime = 0x100000001B3UL;
h = (h ^ p.BasePaletteId) * prime;
foreach (var sp in p.SubPalettes)
{
h = (h ^ sp.SubPaletteId) * prime;
h = (h ^ sp.Offset) * prime;
h = (h ^ sp.Length) * prime;
}
return h;
}
/// <summary>
/// Order-independent hash of a SurfaceOverrides dictionary. XOR of each
/// (key, value) pair keeps the result stable regardless of Dictionary
/// iteration order, so two instances whose override maps contain the
/// same pairs will hash identically.
/// </summary>
private static ulong HashSurfaceOverrides(IReadOnlyDictionary<uint, uint>? overrides)
{
if (overrides is null || overrides.Count == 0) return 0UL;
ulong acc = 0UL;
foreach (var kvp in overrides)
{
ulong pair = ((ulong)kvp.Key << 32) | kvp.Value;
acc ^= pair;
}
// Fold with a prime so the zero case doesn't collide with "empty".
return (acc ^ 0xCBF29CE484222325UL) * 0x100000001B3UL;
}
// ── Matrix write ──────────────────────────────────────────────────────────
/// <summary>
/// Writes a System.Numerics Matrix4x4 into <paramref name="buf"/> starting
/// at <paramref name="offset"/> as 16 consecutive floats in row-major order
/// (the C# natural memory layout). The GLSL shader reads each 4-float row
/// as a column of the mat4 — identical to what UniformMatrix4(transpose=false)
/// produces for the uniform path.
/// </summary>
private static void WriteMatrix(float[] buf, int offset, in Matrix4x4 m)
{
buf[offset + 0] = m.M11; buf[offset + 1] = m.M12; buf[offset + 2] = m.M13; buf[offset + 3] = m.M14;
buf[offset + 4] = m.M21; buf[offset + 5] = m.M22; buf[offset + 6] = m.M23; buf[offset + 7] = m.M24;
buf[offset + 8] = m.M31; buf[offset + 9] = m.M32; buf[offset + 10] = m.M33; buf[offset + 11] = m.M34;
buf[offset + 12] = m.M41; buf[offset + 13] = m.M42; buf[offset + 14] = m.M43; buf[offset + 15] = m.M44;
}
// ── Texture resolution ────────────────────────────────────────────────────
private uint ResolveTex(WorldEntity entity, MeshRef meshRef, SubMeshGpu sub)
{
uint overrideOrigTex = 0;
bool hasOrigTexOverride = meshRef.SurfaceOverrides is not null
&& meshRef.SurfaceOverrides.TryGetValue(sub.SurfaceId, out overrideOrigTex);
uint? origTexOverride = hasOrigTexOverride ? overrideOrigTex : (uint?)null;
if (entity.PaletteOverride is not null)
{
return _textures.GetOrUploadWithPaletteOverride(
sub.SurfaceId, origTexOverride, entity.PaletteOverride);
}
else if (hasOrigTexOverride)
{
return _textures.GetOrUploadWithOrigTextureOverride(sub.SurfaceId, overrideOrigTex);
}
else
{
return _textures.GetOrUpload(sub.SurfaceId);
}
}
// ── Disposal ──────────────────────────────────────────────────────────────
public void Dispose()
{
foreach (var subs in _gpuByGfxObj.Values)
{
foreach (var sub in subs)
{
_gl.DeleteBuffer(sub.Vbo);
_gl.DeleteBuffer(sub.Ebo);
_gl.DeleteVertexArray(sub.Vao);
}
}
_gl.DeleteBuffer(_instanceVbo);
_gpuByGfxObj.Clear();
_groups.Clear();
}
// ── Private types ─────────────────────────────────────────────────────────
private sealed class SubMeshGpu
{
public uint Vao;
public uint Vbo;
public uint Ebo;
public int IndexCount;
public uint SurfaceId;
public TranslucencyKind Translucency;
}
/// <summary>
/// All instances of one GfxObj for this frame, plus their starting offset
/// in the shared instance VBO (in units of instances, not bytes).
/// </summary>
private sealed class InstanceGroup
{
public readonly List<InstanceEntry> Entries = new();
public int BufferOffset;
public int Count => Entries.Count;
}
private readonly struct InstanceEntry
{
public readonly Matrix4x4 Model;
public readonly WorldEntity Entity;
public readonly MeshRef MeshRef;
public InstanceEntry(Matrix4x4 model, WorldEntity entity, MeshRef meshRef)
{
Model = model;
Entity = entity;
MeshRef = meshRef;
}
}
}

View file

@ -1,293 +0,0 @@
// src/AcDream.App/Rendering/StaticMeshRenderer.cs
using System.Numerics;
using AcDream.Core.Meshing;
using AcDream.Core.Terrain;
using AcDream.Core.World;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
public sealed unsafe class StaticMeshRenderer : IDisposable
{
private readonly GL _gl;
private readonly Shader _shader;
private readonly TextureCache _textures;
// One GPU bundle per unique GfxObj id. Each GfxObj can have multiple sub-meshes.
private readonly Dictionary<uint, List<SubMeshGpu>> _gpuByGfxObj = new();
public StaticMeshRenderer(GL gl, Shader shader, TextureCache textures)
{
_gl = gl;
_shader = shader;
_textures = textures;
}
public void EnsureUploaded(uint gfxObjId, IReadOnlyList<GfxObjSubMesh> subMeshes)
{
if (_gpuByGfxObj.ContainsKey(gfxObjId))
return;
var list = new List<SubMeshGpu>(subMeshes.Count);
foreach (var sm in subMeshes)
list.Add(UploadSubMesh(sm));
_gpuByGfxObj[gfxObjId] = list;
}
private SubMeshGpu UploadSubMesh(GfxObjSubMesh sm)
{
uint vao = _gl.GenVertexArray();
_gl.BindVertexArray(vao);
uint vbo = _gl.GenBuffer();
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, vbo);
fixed (void* p = sm.Vertices)
_gl.BufferData(BufferTargetARB.ArrayBuffer,
(nuint)(sm.Vertices.Length * sizeof(Vertex)), p, BufferUsageARB.StaticDraw);
uint ebo = _gl.GenBuffer();
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, ebo);
fixed (void* p = sm.Indices)
_gl.BufferData(BufferTargetARB.ElementArrayBuffer,
(nuint)(sm.Indices.Length * sizeof(uint)), p, BufferUsageARB.StaticDraw);
uint stride = (uint)sizeof(Vertex);
_gl.EnableVertexAttribArray(0);
_gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, stride, (void*)0);
_gl.EnableVertexAttribArray(1);
_gl.VertexAttribPointer(1, 3, VertexAttribPointerType.Float, false, stride, (void*)(3 * sizeof(float)));
_gl.EnableVertexAttribArray(2);
_gl.VertexAttribPointer(2, 2, VertexAttribPointerType.Float, false, stride, (void*)(6 * sizeof(float)));
_gl.EnableVertexAttribArray(3);
_gl.VertexAttribIPointer(3, 1, VertexAttribIType.UnsignedInt, stride, (void*)(8 * sizeof(float)));
_gl.BindVertexArray(0);
return new SubMeshGpu
{
Vao = vao,
Vbo = vbo,
Ebo = ebo,
IndexCount = sm.Indices.Length,
SurfaceId = sm.SurfaceId,
// Capture translucency at upload time so the draw loop never
// has to look it up from external state.
Translucency = sm.Translucency,
};
}
public void Draw(ICamera camera,
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList<WorldEntity> Entities)> landblockEntries,
FrustumPlanes? frustum = null,
uint? neverCullLandblockId = null)
{
_shader.Use();
_shader.SetMatrix4("uView", camera.View);
_shader.SetMatrix4("uProjection", camera.Projection);
// ── Pass 1: Opaque + ClipMap ──────────────────────────────────────────
// Depth write on (default). No blending. ClipMap surfaces use the
// alpha-discard path in the fragment shader (uTranslucencyKind == 1).
foreach (var entry in landblockEntries)
{
// Per-landblock frustum cull. Never cull the player's landblock.
if (frustum is not null &&
entry.LandblockId != neverCullLandblockId &&
!FrustumCuller.IsAabbVisible(frustum.Value, entry.AabbMin, entry.AabbMax))
continue;
foreach (var entity in entry.Entities)
{
if (entity.MeshRefs.Count == 0)
continue;
foreach (var meshRef in entity.MeshRefs)
{
if (!_gpuByGfxObj.TryGetValue(meshRef.GfxObjId, out var subMeshes))
continue;
var entityRoot =
Matrix4x4.CreateFromQuaternion(entity.Rotation) *
Matrix4x4.CreateTranslation(entity.Position);
var model = meshRef.PartTransform * entityRoot;
_shader.SetMatrix4("uModel", model);
foreach (var sub in subMeshes)
{
// Skip translucent sub-meshes in the first pass.
if (sub.Translucency != TranslucencyKind.Opaque &&
sub.Translucency != TranslucencyKind.ClipMap)
continue;
_shader.SetInt("uTranslucencyKind", (int)sub.Translucency);
uint tex = ResolveTex(entity, meshRef, sub);
_gl.ActiveTexture(TextureUnit.Texture0);
_gl.BindTexture(TextureTarget.Texture2D, tex);
_gl.BindVertexArray(sub.Vao);
_gl.DrawElements(PrimitiveType.Triangles, (uint)sub.IndexCount, DrawElementsType.UnsignedInt, (void*)0);
}
}
}
}
// ── Pass 2: Translucent (AlphaBlend, Additive, InvAlpha) ─────────────
// Depth test on so translucents composite correctly behind opaque geometry.
// Depth write OFF so translucents don't occlude each other or downstream
// opaque draws. Blend function is set per-draw based on TranslucencyKind.
//
// NOTE: translucent draws are NOT sorted by depth — overlapping translucent
// surfaces can composite in the wrong order. Portal-sized billboards don't
// overlap in practice so this is acceptable and avoids a larger refactor.
_gl.Enable(EnableCap.Blend);
_gl.DepthMask(false);
// Phase 9.2: enable back-face culling for the translucent pass so
// closed-shell translucents (lifestone crystal, glow gems, any
// convex blended mesh) don't draw their back faces over their
// front faces in arbitrary iteration order. Without this, the
// 58 triangles of the lifestone crystal composited with an
// "inside-out" look where the user saw through one face into
// the hollow interior. With back-face culling on, back faces are
// dropped at rasterization time, front faces composite as-is,
// and depth ordering within the front-facing subset is a
// non-issue for closed convex-ish shells. Matches WorldBuilder's
// per-batch CullMode handling in
// references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/
// BaseObjectRenderManager.cs:361-365.
//
// Our fan triangulation emits pos-side polygons as
// (0, i, i+1) which is CCW in standard OpenGL conventions, so
// GL_BACK + CCW front is the correct state. Neg-side polygons
// (if any) use reversed winding and get culled here — that's a
// known limitation and matches the opaque-pass behavior since
// neg-side polys are virtually never translucent in AC content.
_gl.Enable(EnableCap.CullFace);
_gl.CullFace(TriangleFace.Back);
_gl.FrontFace(FrontFaceDirection.Ccw);
foreach (var entry in landblockEntries)
{
// Same per-landblock frustum cull for pass 2.
if (frustum is not null &&
entry.LandblockId != neverCullLandblockId &&
!FrustumCuller.IsAabbVisible(frustum.Value, entry.AabbMin, entry.AabbMax))
continue;
foreach (var entity in entry.Entities)
{
if (entity.MeshRefs.Count == 0)
continue;
foreach (var meshRef in entity.MeshRefs)
{
if (!_gpuByGfxObj.TryGetValue(meshRef.GfxObjId, out var subMeshes))
continue;
var entityRoot =
Matrix4x4.CreateFromQuaternion(entity.Rotation) *
Matrix4x4.CreateTranslation(entity.Position);
var model = meshRef.PartTransform * entityRoot;
_shader.SetMatrix4("uModel", model);
foreach (var sub in subMeshes)
{
if (sub.Translucency == TranslucencyKind.Opaque ||
sub.Translucency == TranslucencyKind.ClipMap)
continue;
// Set per-draw blend function.
switch (sub.Translucency)
{
case TranslucencyKind.Additive:
// src*a + dst — portal swirls, glows
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.One);
break;
case TranslucencyKind.InvAlpha:
// src*(1-a) + dst*a
_gl.BlendFunc(BlendingFactor.OneMinusSrcAlpha, BlendingFactor.SrcAlpha);
break;
default: // AlphaBlend
// src*a + dst*(1-a)
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
break;
}
_shader.SetInt("uTranslucencyKind", (int)sub.Translucency);
uint tex = ResolveTex(entity, meshRef, sub);
_gl.ActiveTexture(TextureUnit.Texture0);
_gl.BindTexture(TextureTarget.Texture2D, tex);
_gl.BindVertexArray(sub.Vao);
_gl.DrawElements(PrimitiveType.Triangles, (uint)sub.IndexCount, DrawElementsType.UnsignedInt, (void*)0);
}
}
}
}
// Restore default GL state for subsequent renderers (terrain etc.).
_gl.DepthMask(true);
_gl.Disable(EnableCap.Blend);
_gl.Disable(EnableCap.CullFace);
_gl.BindVertexArray(0);
}
/// <summary>
/// Resolves the GL texture id for a sub-mesh, honouring palette and
/// texture overrides carried on the entity and the mesh-ref.
/// </summary>
private uint ResolveTex(WorldEntity entity, MeshRef meshRef, SubMeshGpu sub)
{
uint overrideOrigTex = 0;
bool hasOrigTexOverride = meshRef.SurfaceOverrides is not null
&& meshRef.SurfaceOverrides.TryGetValue(sub.SurfaceId, out overrideOrigTex);
uint? origTexOverride = hasOrigTexOverride ? overrideOrigTex : (uint?)null;
if (entity.PaletteOverride is not null)
{
return _textures.GetOrUploadWithPaletteOverride(
sub.SurfaceId, origTexOverride, entity.PaletteOverride);
}
else if (hasOrigTexOverride)
{
return _textures.GetOrUploadWithOrigTextureOverride(sub.SurfaceId, overrideOrigTex);
}
else
{
return _textures.GetOrUpload(sub.SurfaceId);
}
}
public void Dispose()
{
foreach (var subs in _gpuByGfxObj.Values)
{
foreach (var sub in subs)
{
_gl.DeleteBuffer(sub.Vbo);
_gl.DeleteBuffer(sub.Ebo);
_gl.DeleteVertexArray(sub.Vao);
}
}
_gpuByGfxObj.Clear();
}
private sealed class SubMeshGpu
{
public uint Vao;
public uint Vbo;
public uint Ebo;
public int IndexCount;
public uint SurfaceId;
/// <summary>
/// Cached from GfxObjSubMesh.Translucency at upload time.
/// Avoids any per-draw lookup into external state.
/// </summary>
public TranslucencyKind Translucency;
}
}

View file

@ -13,26 +13,29 @@ namespace AcDream.App.Rendering.Wb;
/// <summary> /// <summary>
/// Draws entities using WB's <see cref="ObjectRenderData"/> (a single global /// Draws entities using WB's <see cref="ObjectRenderData"/> (a single global
/// VAO/VBO/IBO under modern rendering) with acdream's <see cref="TextureCache"/> /// VAO/VBO/IBO under modern rendering) with acdream's <see cref="TextureCache"/>
/// for texture resolution and <see cref="AcSurfaceMetadataTable"/> for /// for bindless texture resolution and <see cref="AcSurfaceMetadataTable"/> for
/// translucency classification. /// translucency classification.
/// ///
/// <para> /// <para>
/// <b>Atlas-tier</b> entities (<c>ServerGuid == 0</c>): mesh data comes from WB's /// <b>Atlas-tier</b> entities (<c>ServerGuid == 0</c>): mesh data comes from WB's
/// <see cref="ObjectMeshManager"/> via <see cref="WbMeshAdapter.TryGetRenderData"/>. /// <see cref="ObjectMeshManager"/> via <see cref="WbMeshAdapter.TryGetRenderData"/>.
/// Textures resolve through <see cref="TextureCache.GetOrUpload"/> using the batch's /// Textures resolve through the bindless-suffixed
/// <c>SurfaceId</c>. /// <see cref="TextureCache.GetOrUploadBindless"/> variants, returning 64-bit
/// resident handles stored in the per-group SSBO.
/// </para> /// </para>
/// ///
/// <para> /// <para>
/// <b>Per-instance-tier</b> entities (<c>ServerGuid != 0</c>): mesh data also from /// <b>Per-instance-tier</b> entities (<c>ServerGuid != 0</c>): mesh data also from
/// WB, but textures resolve through <see cref="TextureCache"/> with palette and /// WB, but textures resolve through
/// surface overrides applied. <see cref="AnimatedEntityState"/> is currently /// <see cref="TextureCache.GetOrUploadWithPaletteOverrideBindless"/> with palette
/// and surface overrides applied. <see cref="AnimatedEntityState"/> is currently
/// unused at draw time — GameWindow's spawn path already bakes AnimPartChanges + /// unused at draw time — GameWindow's spawn path already bakes AnimPartChanges +
/// GfxObjDegradeResolver (Issue #47 close-detail mesh) into <c>MeshRefs</c>. /// GfxObjDegradeResolver (Issue #47 close-detail mesh) into <c>MeshRefs</c>.
/// </para> /// </para>
/// ///
/// <para> /// <para>
/// <b>GL strategy (N.5):</b> <c>glMultiDrawElementsIndirect</c> with SSBOs. /// <b>GL strategy (N.5 — mandatory):</b> <c>glMultiDrawElementsIndirect</c> with SSBOs
/// and <c>GL_ARB_bindless_texture</c> + <c>GL_ARB_shader_draw_parameters</c>.
/// All visible (entity, batch) pairs are bucketed by <see cref="GroupKey"/>; /// All visible (entity, batch) pairs are bucketed by <see cref="GroupKey"/>;
/// each group becomes one <c>DrawElementsIndirectCommand</c>. Three GPU buffers /// each group becomes one <c>DrawElementsIndirectCommand</c>. Three GPU buffers
/// are uploaded per frame: instance matrices (SSBO binding 0), per-group batch /// are uploaded per frame: instance matrices (SSBO binding 0), per-group batch
@ -42,17 +45,17 @@ namespace AcDream.App.Rendering.Wb;
/// </para> /// </para>
/// ///
/// <para> /// <para>
/// <b>Shader:</b> <c>mesh_modern</c> when bindless + ARB_shader_draw_parameters /// <b>Shader:</b> <c>mesh_modern</c> (bindless + <c>gl_DrawIDARB</c> /
/// are available (N.5 path). Falls back to <c>mesh_instanced</c> when the GPU /// <c>gl_BaseInstanceARB</c>). Missing bindless/draw-parameters throws
/// lacks those extensions. /// <see cref="NotSupportedException"/> at startup — there is no legacy fallback.
/// </para> /// </para>
/// ///
/// <para> /// <para>
/// <b>Modern rendering assumption:</b> WB's <c>_useModernRendering</c> path (GL /// <b>Modern rendering assumption:</b> WB's <c>_useModernRendering</c> path (GL
/// 4.3 + bindless) puts every mesh in a single shared VAO/VBO/IBO and uses /// 4.3 + bindless) puts every mesh in a single shared VAO/VBO/IBO and uses
/// <c>FirstIndex</c> + <c>BaseVertex</c> per batch. The dispatcher honors those /// <c>FirstIndex</c> + <c>BaseVertex</c> per batch. The dispatcher honors those
/// offsets via <c>DrawElementsInstancedBaseVertex(BaseInstance)</c>. The legacy /// offsets inside each <c>DrawElementsIndirectCommand</c> via
/// per-mesh-VAO path also works since FirstIndex/BaseVertex are zero there. /// <c>glMultiDrawElementsIndirect</c>.
/// </para> /// </para>
/// </summary> /// </summary>
public sealed unsafe class WbDrawDispatcher : IDisposable public sealed unsafe class WbDrawDispatcher : IDisposable

View file

@ -1,39 +0,0 @@
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Process-lifetime cache of <c>ACDREAM_USE_WB_FOUNDATION</c> env var.
/// Read once at static-init time; all consumers import this rather than
/// re-reading the env var per call (env-var lookups on Windows are not
/// free at hot-path cadence).
///
/// <para>
/// <b>Default-on as of Phase N.4 ship (2026-05-08).</b> The WB foundation
/// (<c>WbMeshAdapter</c> + <c>WbDrawDispatcher</c>) is the production
/// rendering path. Set <c>ACDREAM_USE_WB_FOUNDATION=0</c> to fall back
/// to the legacy <c>InstancedMeshRenderer</c> path — kept as an escape
/// hatch until N.6 fully replaces it.
/// </para>
///
/// <para>
/// Per-instance customized content (server <c>CreateObject</c> entities
/// with palette / texture overrides) routes through
/// <see cref="TextureCache.GetOrUploadWithPaletteOverride"/> regardless
/// of the flag — the flag controls which DRAW path consumes those
/// textures.
/// </para>
/// </summary>
public static class WbFoundationFlag
{
private static bool _isEnabled =
System.Environment.GetEnvironmentVariable("ACDREAM_USE_WB_FOUNDATION") != "0";
public static bool IsEnabled => _isEnabled;
/// <summary>
/// FOR TESTS ONLY. Forces <see cref="IsEnabled"/> to <c>true</c> so
/// integration tests can exercise the WB adapter path without having to
/// set the env var before static initialisation. Never call from
/// production code.
/// </summary>
internal static void ForTestsOnly_ForceEnable() => _isEnabled = true;
}

View file

@ -144,7 +144,7 @@ public sealed class GpuWorldState
} }
_loaded[landblock.LandblockId] = landblock; _loaded[landblock.LandblockId] = landblock;
if (WbFoundationFlag.IsEnabled && _wbSpawnAdapter is not null) if (_wbSpawnAdapter is not null)
_wbSpawnAdapter.OnLandblockLoaded(_loaded[landblock.LandblockId]); _wbSpawnAdapter.OnLandblockLoaded(_loaded[landblock.LandblockId]);
RebuildFlatView(); RebuildFlatView();
} }
@ -195,7 +195,7 @@ public sealed class GpuWorldState
public void RemoveLandblock(uint landblockId) public void RemoveLandblock(uint landblockId)
{ {
if (WbFoundationFlag.IsEnabled && _wbSpawnAdapter is not null) if (_wbSpawnAdapter is not null)
_wbSpawnAdapter.OnLandblockUnloaded(landblockId); _wbSpawnAdapter.OnLandblockUnloaded(landblockId);
// Rescue persistent entities before removal. These get appended // Rescue persistent entities before removal. These get appended

View file

@ -19,16 +19,9 @@ namespace AcDream.Core.Tests.Rendering.Wb;
/// </summary> /// </summary>
public sealed class PendingSpawnIntegrationTests public sealed class PendingSpawnIntegrationTests
{ {
/// <summary> // N.5 ship amendment: WbFoundationFlag was deleted — GpuWorldState
/// Force-enable WbFoundationFlag for this test class. // no longer gates adapter calls on the flag; they are unconditional
/// GpuWorldState gates its adapter calls on this static-cached flag; // when the adapter is non-null. No static ctor hook needed.
/// calling the internal test hook lets us exercise the full integration
/// path without needing the env var set before process startup.
/// </summary>
static PendingSpawnIntegrationTests()
{
WbFoundationFlag.ForTestsOnly_ForceEnable();
}
[Fact] [Fact]
public void LiveEntity_ParkedBeforeLandblock_DrainsButIsNotRegisteredWithAdapter() public void LiveEntity_ParkedBeforeLandblock_DrainsButIsNotRegisteredWithAdapter()