diff --git a/CLAUDE.md b/CLAUDE.md
index e6d0b27..60bcbae 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -55,9 +55,11 @@ ourselves".
`EntitySpawnAdapter.cs` — bridge spawn lifecycle to WB ref-counts.
Atlas tier (procedural) goes via Landblock; per-instance tier
(server-spawned, palette/texture overrides) goes via Entity.
-- `WbFoundationFlag` is default-on. `ACDREAM_USE_WB_FOUNDATION=0`
- falls back to legacy `InstancedMeshRenderer` (kept as escape hatch
- until N.6 fully retires it).
+- **Modern path is mandatory as of N.5 ship amendment (2026-05-08).**
+ `WbFoundationFlag`, `InstancedMeshRenderer`, and `StaticMeshRenderer`
+ 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
into a single global VAO/VBO/IBO. Each batch references its slice
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
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).
-Builds on N.5. Retires `InstancedMeshRenderer` + `StaticMeshRenderer` entirely.
-Optional candidates: WB atlas adoption, persistent-mapped buffers, GPU-side
-culling via compute pre-pass, GL_TIME_ELAPSED query double-buffering, direct
-N.4 vs N.5 perf measurement. Plan + spec written when work begins.
+Builds on N.5. Legacy renderers (`InstancedMeshRenderer`, `StaticMeshRenderer`,
+`WbFoundationFlag`) were retired in the N.5 ship amendment — N.6 scope is
+perf-only: WB atlas adoption, persistent-mapped buffers, GPU-side culling,
+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
-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).
**Phase N.4 (Rendering Pipeline Foundation) shipped 2026-05-08.** WB's
-`ObjectMeshManager` is integrated and is the default rendering path
-behind `ACDREAM_USE_WB_FOUNDATION` (default-on). Plan archived at
+`ObjectMeshManager` is integrated and is the production rendering path
+(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).
**Rules:**
diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index d3fd991..95dcbc6 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -82,11 +82,12 @@ ground. This is the bug class fixed in
**Sequencing implication:** Phase N.2 (terrain math helpers
substitution) cannot be shipped in isolation — it must land alongside
-N.5 (visual terrain renderer migration), at which point both physics
-and visual mesh switch to WB's formula together. Roadmap N.2 entry
-flags this dependency.
+visual terrain renderer migration (originally N.5, now moved to N.7
+scope), at which point both physics and visual mesh switch to WB's
+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
`IsSplitSWtoNE` across all (lbX, lbY, cellX, cellY) tuples for a
representative landblock set; record disagreement rate.
@@ -97,8 +98,8 @@ flags this dependency.
server-authoritative Z within tolerance) is invalidated by the
formula change.
-**Acceptance:** Resolved when N.5 lands and both physics + visual
-mesh use WB's split formula, OR when we decide to keep the AC2D
+**Acceptance:** Resolved when N.7 lands and both physics + visual
+terrain use WB's split formula, OR when we decide to keep the AC2D
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):**
-- `src/AcDream.App/Rendering/InstancedMeshRenderer.cs:210-275`
- — `ACDREAM_NO_CULL` env var
+- ~~`src/AcDream.App/Rendering/InstancedMeshRenderer.cs:210-275`
+ — `ACDREAM_NO_CULL` env var~~ (file deleted in N.5 ship amendment)
- `src/AcDream.App/Rendering/GameWindow.cs` — `ACDREAM_HIDE_PART=N`
hides specific humanoid part; `ACDREAM_DUMP_CLOTHING=1` dumps
AnimPartChanges + TextureChanges + per-part Surface chain coverage.
diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md
index 43623cf..3c915ec 100644
--- a/docs/plans/2026-04-11-roadmap.md
+++ b/docs/plans/2026-04-11-roadmap.md
@@ -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 ✓ |
| 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.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.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.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. **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:
- FlyCamera default speed lowered + Shift-to-boost
@@ -647,16 +647,17 @@ for our deletions/additions; merge upstream `master` periodically.
`CalculateSplitDirection` + `GetHeight` + `GetNormal` in lockstep,
resolving ISSUE #51. **Estimate: 1-2 weeks** (was 2-3 — modern path
primitives already in place from N.5).
-- **N.6 — Retire legacy renderers + perf polish.** **Currently in flight.**
- Builds on N.5. Retires `InstancedMeshRenderer` + `StaticMeshRenderer`
- entirely — they remain as `ACDREAM_USE_WB_FOUNDATION=0` escape hatches
- through N.5 but are deleted when N.6 ships. Optional N.6 candidates: WB
- atlas adoption for memory savings on shared content, persistent-mapped
- buffers if `glBufferData` shows up in profiling, GPU-side culling via
- compute pre-pass, GL_TIME_ELAPSED query double-buffering (deferred from
- N.5 — diagnostic shows `gpu_us=0/0` under `ACDREAM_WB_DIAG=1`), direct
- N.4 vs N.5 perf measurement. Plan + spec written when work begins.
- **Estimate: 1-2 weeks** (was 2-3).
+- **N.6 — Perf polish.** **Currently in flight.**
+ Builds on N.5. Legacy renderer retirement was pulled forward into N.5
+ ship amendment — `InstancedMeshRenderer`, `StaticMeshRenderer`, and
+ `WbFoundationFlag` are already gone. N.6 scope: WB atlas adoption for
+ memory savings on shared content, persistent-mapped buffers if
+ `glBufferData` shows up in profiling, GPU-side culling via compute
+ pre-pass, GL_TIME_ELAPSED query double-buffering (deferred from N.5 —
+ diagnostic shows `gpu_us=0/0` under `ACDREAM_WB_DIAG=1`), direct N.4
+ vs N.5 perf measurement, retire the legacy `Texture2D`/`sampler2D` path
+ 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
`EnvCellRenderManager` + `PortalRenderManager` on top of N.4's
foundation. **Estimate: 1-2 weeks** (was 2-3 — naturally smaller now
diff --git a/docs/plans/2026-05-08-phase-n5-perf-baseline.md b/docs/plans/2026-05-08-phase-n5-perf-baseline.md
index 33b7f1e..6d14bb8 100644
--- a/docs/plans/2026-05-08-phase-n5-perf-baseline.md
+++ b/docs/plans/2026-05-08-phase-n5-perf-baseline.md
@@ -44,8 +44,11 @@ half of the lower bound estimate.
8 pre-existing failures in `MotionInterpreter` / `BSPStepUp` /
`PositionManager` / `PlayerMovementController` / `Dispatcher` are
carry-forward from before N.5 and unrelated to rendering.
-- [ ] **`ACDREAM_USE_WB_FOUNDATION=0` still works** — to be verified at
- Task 14 (legacy escape hatch check).
+- [N/A] **`ACDREAM_USE_WB_FOUNDATION=0` still works** — escape hatch
+ 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)
diff --git a/docs/superpowers/plans/2026-05-08-phase-n5-modern-rendering.md b/docs/superpowers/plans/2026-05-08-phase-n5-modern-rendering.md
index fe428d5..43abd7c 100644
--- a/docs/superpowers/plans/2026-05-08-phase-n5-modern-rendering.md
+++ b/docs/superpowers/plans/2026-05-08-phase-n5-modern-rendering.md
@@ -2561,10 +2561,10 @@ SHIP commit at Task 19.
`FullyQualifiedName~Wb|FullyQualifiedName~MatrixComposition`.
Pre-existing 8 failures in physics/input/movement tests carry
forward unchanged from before N.5.
-- [x] **`ACDREAM_USE_WB_FOUNDATION=0` still works** — Task 15 confirmed
- InstancedMeshRenderer remains intact as the escape hatch; if
- bindless is missing, `_meshShader` stays null + `_wbDrawDispatcher`
- stays null, falling through to InstancedMeshRenderer naturally.
+- [N/A] **`ACDREAM_USE_WB_FOUNDATION=0` still works** — escape hatch
+ formally retired in N.5 ship amendment (see section below).
+ `InstancedMeshRenderer`, `StaticMeshRenderer`, and `WbFoundationFlag`
+ deleted. Missing bindless throws `NotSupportedException` at startup.
### Plan amendments captured during execution
@@ -2613,7 +2613,7 @@ adjustments captured beyond the plan:
- **Persistent-mapped buffers** (Decision 7 deferral). Layer on top of
the modern path if `glBufferData` shows up as a residual hot spot in
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,
walls, etc).
- **GPU-side culling** via compute pre-pass.
@@ -2655,3 +2655,52 @@ CLAUDE.md "WB integration cribs" updated with N.5 patterns (Task 16).
**Deleted:**
- `src/AcDream.App/Rendering/Shaders/mesh_instanced.vert`
- `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.
diff --git a/docs/superpowers/specs/2026-05-08-phase-n5-modern-rendering-design.md b/docs/superpowers/specs/2026-05-08-phase-n5-modern-rendering-design.md
index 738bedd..3e7aeed 100644
--- a/docs/superpowers/specs/2026-05-08-phase-n5-modern-rendering-design.md
+++ b/docs/superpowers/specs/2026-05-08-phase-n5-modern-rendering-design.md
@@ -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). |
| 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. |
-| 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. |
| 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. |
@@ -540,7 +540,7 @@ The following are NOT N.5 work. They become possible follow-ons.
- **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.
- **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.
---
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index a6e2c1a..273f4d4 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -25,17 +25,16 @@ public sealed class GameWindow : IDisposable
private DatCollection? _dats;
private float _lastMouseX;
private float _lastMouseY;
- private InstancedMeshRenderer? _staticMesh;
private Shader? _meshShader;
private TextureCache? _textureCache;
- /// Phase N.4: WB-backed rendering pipeline adapter. Non-null only
- /// when ACDREAM_USE_WB_FOUNDATION=1 is set; null otherwise.
+ /// Phase N.4+: WB-backed rendering pipeline adapter. Always non-null
+ /// after OnLoad completes (modern path is mandatory as of N.5).
private AcDream.App.Rendering.Wb.WbMeshAdapter? _wbMeshAdapter;
private AcDream.App.Rendering.Wb.EntitySpawnAdapter? _wbEntitySpawnAdapter;
private AcDream.App.Rendering.Wb.WbDrawDispatcher? _wbDrawDispatcher;
/// Phase N.5: ARB_bindless_texture + ARB_shader_draw_parameters
- /// support. Non-null only when both extensions are present and WbFoundation
- /// is enabled. Passed to TextureCache and (later) WbDrawDispatcher.
+ /// support. Required at startup — missing bindless throws
+ /// in OnLoad.
private AcDream.App.Rendering.Wb.BindlessSupport? _bindlessSupport;
private SamplerCache? _samplerCache;
private DebugLineRenderer? _debugLines;
@@ -970,10 +969,6 @@ public sealed class GameWindow : IDisposable
Path.Combine(shadersDir, "terrain.vert"),
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
// binding=1 for the lifetime of the process — every shader that
// declares `layout(std140, binding = 1) uniform SceneLighting`
@@ -1423,43 +1418,41 @@ public sealed class GameWindow : IDisposable
_heightTable = heightTable;
_surfaceCache = new Dictionary();
- // N.5: detect ARB_bindless_texture + ARB_shader_draw_parameters when WB
- // foundation is on. Store the BindlessSupport for TextureCache + future
- // WbDrawDispatcher. Mesh shader load stays as mesh_instanced for now —
- // Task 10 swaps to mesh_modern after the dispatcher is rewired.
- if (AcDream.App.Rendering.Wb.WbFoundationFlag.IsEnabled)
+ // N.5: detect ARB_bindless_texture + ARB_shader_draw_parameters.
+ // The modern path (SSBO + glMultiDrawElementsIndirect + bindless textures)
+ // is mandatory as of Phase N.5 — missing extensions throw at startup with
+ // a clear error so users can file a real bug report rather than silently
+ // 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))
- {
- _bindlessSupport = bindless;
- Console.WriteLine("[N.5] modern path capabilities present (bindless + ARB_shader_draw_parameters)");
- }
- else
- {
- Console.WriteLine("[N.5] GL_ARB_shader_draw_parameters not present — modern dispatch path will not activate");
- }
+ _bindlessSupport = bindless;
+ Console.WriteLine("[N.5] modern path capabilities present (bindless + ARB_shader_draw_parameters)");
}
else
{
- Console.WriteLine("[N.5] GL_ARB_bindless_texture not present — modern dispatch path will not activate");
+ Console.WriteLine("[N.5] GL_ARB_shader_draw_parameters not present — modern path not available");
}
}
-
- // N.5 Task 10/15: load mesh_modern when both extensions are present.
- // 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)
+ else
{
- _meshShader = new Shader(_gl,
- Path.Combine(shadersDir, "mesh_modern.vert"),
- Path.Combine(shadersDir, "mesh_modern.frag"));
- Console.WriteLine("[N.5] mesh_modern shader loaded");
+ Console.WriteLine("[N.5] GL_ARB_bindless_texture not present — modern path not available");
}
- // else: bindless missing — _meshShader stays null.
+
+ if (_bindlessSupport is 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,
+ Path.Combine(shadersDir, "mesh_modern.vert"),
+ Path.Combine(shadersDir, "mesh_modern.frag"));
+ Console.WriteLine("[N.5] mesh_modern shader loaded");
_textureCache = new TextureCache(_gl, _dats, _bindlessSupport);
// 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.
_samplerCache = new SamplerCache(_gl);
- // Phase N.4 — WB rendering pipeline foundation. Constructed only when
- // ACDREAM_USE_WB_FOUNDATION=1 is set; otherwise the legacy renderer
- // path stays in charge. The full ObjectMeshManager bring-up lives in
- // WbMeshAdapter (Task 9): OpenGLGraphicsDevice + DefaultDatReaderWriter
- // + ObjectMeshManager. WbMeshAdapter opens its own file handles for
- // the dat files (independent of our DatCollection).
- if (AcDream.App.Rendering.Wb.WbFoundationFlag.IsEnabled)
+ // Phase N.4+N.5 — WB rendering pipeline foundation. The modern path is
+ // mandatory as of N.5 ship amendment: WbMeshAdapter + WbDrawDispatcher
+ // always construct. WbMeshAdapter owns ObjectMeshManager and opens its
+ // own file handles for the dat files (independent of our DatCollection).
{
var wbLogger = Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance;
_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
@@ -1488,68 +1478,51 @@ public sealed class GameWindow : IDisposable
// one that carries the adapter so AddLandblock/RemoveLandblock notify WB.
// Phase N.4 Task 17: also construct EntitySpawnAdapter for server-spawned
// 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;
- AcDream.App.Rendering.Wb.EntitySpawnAdapter? wbEntitySpawnAdapter = null;
- if (AcDream.App.Rendering.Wb.WbFoundationFlag.IsEnabled && _wbMeshAdapter is not null)
+ var wbSpawnAdapter = new AcDream.App.Rendering.Wb.LandblockSpawnAdapter(_wbMeshAdapter!);
+ // Sequencer factory: look up Setup + MotionTable from dats and build
+ // an AnimationSequencer. Falls back to a no-op sequencer when the
+ // entity has no motion table (static props, etc.). Uses _animLoader
+ // which is initialised earlier in OnLoad; it is non-null here.
+ var capturedDats = _dats;
+ var capturedAnimLoader = _animLoader;
+ AcDream.Core.Physics.AnimationSequencer SequencerFactory(AcDream.Core.World.WorldEntity e)
{
- wbSpawnAdapter = new AcDream.App.Rendering.Wb.LandblockSpawnAdapter(_wbMeshAdapter);
- // Sequencer factory: look up Setup + MotionTable from dats and build
- // an AnimationSequencer. Falls back to a no-op sequencer when the
- // entity has no motion table (static props, etc.). Uses _animLoader
- // which is initialised at line 1004; it is non-null here because
- // OnLoad wires _dats + _animLoader before this block runs.
- var capturedDats = _dats;
- var capturedAnimLoader = _animLoader;
- AcDream.Core.Physics.AnimationSequencer SequencerFactory(AcDream.Core.World.WorldEntity e)
+ if (capturedDats is not null && capturedAnimLoader is not null)
{
- if (capturedDats is not null && capturedAnimLoader is not null)
+ var setup = capturedDats.Get(e.SourceGfxObjOrSetupId);
+ if (setup is not null)
{
- var setup = capturedDats.Get(e.SourceGfxObjOrSetupId);
- if (setup is not null)
+ uint mtableId = (uint)setup.DefaultMotionTable;
+ if (mtableId != 0)
{
- uint mtableId = (uint)setup.DefaultMotionTable;
- if (mtableId != 0)
- {
- var mtable = capturedDats.Get(mtableId);
- if (mtable is not null)
- return new AcDream.Core.Physics.AnimationSequencer(setup, mtable, capturedAnimLoader);
- }
- // Setup exists but no motion table — no-op sequencer.
- return new AcDream.Core.Physics.AnimationSequencer(
- setup,
- new DatReaderWriter.DBObjs.MotionTable(),
- capturedAnimLoader);
+ var mtable = capturedDats.Get(mtableId);
+ if (mtable is not null)
+ return new AcDream.Core.Physics.AnimationSequencer(setup, mtable, capturedAnimLoader);
}
+ // Setup exists but no motion table — no-op sequencer.
+ return new AcDream.Core.Physics.AnimationSequencer(
+ setup,
+ new DatReaderWriter.DBObjs.MotionTable(),
+ capturedAnimLoader);
}
- // Complete fallback: empty setup + empty motion table + null loader.
- return new AcDream.Core.Physics.AnimationSequencer(
- new DatReaderWriter.DBObjs.Setup(),
- new DatReaderWriter.DBObjs.MotionTable(),
- new NullAnimLoader());
}
- wbEntitySpawnAdapter = new AcDream.App.Rendering.Wb.EntitySpawnAdapter(
- _textureCache, SequencerFactory, _wbMeshAdapter);
- _wbEntitySpawnAdapter = wbEntitySpawnAdapter;
+ // Complete fallback: empty setup + empty motion table + null loader.
+ return new AcDream.Core.Physics.AnimationSequencer(
+ new DatReaderWriter.DBObjs.Setup(),
+ new DatReaderWriter.DBObjs.MotionTable(),
+ new NullAnimLoader());
}
+ var wbEntitySpawnAdapter = new AcDream.App.Rendering.Wb.EntitySpawnAdapter(
+ _textureCache!, SequencerFactory, _wbMeshAdapter!);
+ _wbEntitySpawnAdapter = 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(
- _gl, _meshShader!, _textureCache!, _wbMeshAdapter, _wbEntitySpawnAdapter, _bindlessSupport);
+ _gl, _meshShader!, _textureCache!, _wbMeshAdapter!, _wbEntitySpawnAdapter, _bindlessSupport!);
}
// 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)
{
// Can't place a mesh without both. Most of these are inventory
@@ -2410,10 +2383,9 @@ public sealed class GameWindow : IDisposable
continue;
}
_physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx);
- var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
- _staticMesh.EnsureUploaded(mr.GfxObjId, subMeshes);
if (dumpClothing)
{
+ var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
int tris = 0; int subs = 0;
foreach (var sm in subMeshes) { tris += sm.Indices.Length / 3; subs++; }
dumpClothingTotalTris += tris;
@@ -5244,44 +5216,25 @@ public sealed class GameWindow : IDisposable
portalPlanes, origin.X, origin.Y);
}
- // Upload every GfxObj referenced by this landblock's entities.
- // EnsureUploaded is idempotent so duplicates across landblocks are free.
- if (_staticMesh is not null)
+ // N.5: WbMeshAdapter.Tick() handles GPU upload for all GfxObj meshes via
+ // ObjectMeshManager.PrepareMeshDataAsync. The legacy EnsureUploaded loop
+ // (and _pendingCellMeshes drain) are retired with InstancedMeshRenderer.
+ // Cache GfxObj physics data (BSP trees) for the physics engine — this
+ // loop is physics-only, not renderer-side.
+ foreach (var entity in lb.Entities)
{
- // Task 8: drain any pending EnvCell room-mesh sub-meshes first.
- // 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 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;
- var gfx = _dats.Get(meshRef.GfxObjId);
- if (gfx is null) continue;
- _physicsDataCache.CacheGfxObj(meshRef.GfxObjId, gfx);
- var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
- _staticMesh.EnsureUploaded(meshRef.GfxObjId, subMeshes);
- }
+ if ((meshRef.GfxObjId & 0xFF000000u) != 0x01000000u) continue;
+ var gfx = _dats.Get(meshRef.GfxObjId);
+ if (gfx is null) continue;
+ _physicsDataCache.CacheGfxObj(meshRef.GfxObjId, gfx);
}
}
+ // 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
// Transition system can find and collide against them during movement.
@@ -6386,20 +6339,11 @@ public sealed class GameWindow : IDisposable
animatedIds.Add(k);
}
- if (_wbDrawDispatcher is not null)
- {
- _wbDrawDispatcher.Draw(camera, _worldState.LandblockEntries, frustum,
- neverCullLandblockId: playerLb,
- visibleCellIds: visibility?.VisibleCellIds,
- animatedEntityIds: animatedIds);
- }
- else
- {
- _staticMesh?.Draw(camera, _worldState.LandblockEntries, frustum,
- neverCullLandblockId: playerLb,
- visibleCellIds: visibility?.VisibleCellIds,
- animatedEntityIds: animatedIds);
- }
+ // N.5: WbDrawDispatcher is always non-null (modern path mandatory).
+ _wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum,
+ neverCullLandblockId: playerLb,
+ visibleCellIds: visibility?.VisibleCellIds,
+ animatedEntityIds: animatedIds);
// Phase G.1 / E.3: draw all live particles after opaque
// scene geometry so alpha blending composites correctly.
@@ -8781,11 +8725,10 @@ public sealed class GameWindow : IDisposable
_liveSession?.Dispose();
_audioEngine?.Dispose(); // Phase E.2: stop all voices, close AL context
_wbDrawDispatcher?.Dispose();
- _staticMesh?.Dispose();
_skyRenderer?.Dispose(); // depends on sampler cache; dispose first
_samplerCache?.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();
_terrain?.Dispose();
diff --git a/src/AcDream.App/Rendering/InstancedMeshRenderer.cs b/src/AcDream.App/Rendering/InstancedMeshRenderer.cs
deleted file mode 100644
index 5b0c9eb..0000000
--- a/src/AcDream.App/Rendering/InstancedMeshRenderer.cs
+++ /dev/null
@@ -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;
-
- ///
- /// 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.
- ///
- private readonly WbMeshAdapter? _wbMeshAdapter;
-
- // One GPU bundle per unique GfxObj id. Each GfxObj can have multiple sub-meshes.
- private readonly Dictionary> _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 GfxObjId 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 _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 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(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 Entities)> landblockEntries,
- FrustumPlanes? frustum = null,
- uint? neverCullLandblockId = null,
- HashSet? 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? 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 ──────────────────────────────────────────────────────────────
-
- ///
- /// Iterates all visible landblock entries and groups every (entity, meshRef)
- /// pair by GfxObjId. Clears previous frame's groups before filling.
- ///
- private void CollectGroups(
- IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList Entities)> landblockEntries,
- FrustumPlanes? frustum,
- uint? neverCullLandblockId,
- HashSet? visibleCellIds,
- HashSet? 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;
- }
-
- ///
- /// 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.
- ///
- private static ulong HashSurfaceOverrides(IReadOnlyDictionary? 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 ──────────────────────────────────────────────────────────
-
- ///
- /// Writes a System.Numerics Matrix4x4 into starting
- /// at 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.
- ///
- 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;
- }
-
- ///
- /// All instances of one GfxObj for this frame, plus their starting offset
- /// in the shared instance VBO (in units of instances, not bytes).
- ///
- private sealed class InstanceGroup
- {
- public readonly List 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;
- }
- }
-}
diff --git a/src/AcDream.App/Rendering/StaticMeshRenderer.cs b/src/AcDream.App/Rendering/StaticMeshRenderer.cs
deleted file mode 100644
index f201338..0000000
--- a/src/AcDream.App/Rendering/StaticMeshRenderer.cs
+++ /dev/null
@@ -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> _gpuByGfxObj = new();
-
- public StaticMeshRenderer(GL gl, Shader shader, TextureCache textures)
- {
- _gl = gl;
- _shader = shader;
- _textures = textures;
- }
-
- public void EnsureUploaded(uint gfxObjId, IReadOnlyList subMeshes)
- {
- if (_gpuByGfxObj.ContainsKey(gfxObjId))
- return;
-
- var list = new List(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 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);
- }
-
- ///
- /// Resolves the GL texture id for a sub-mesh, honouring palette and
- /// texture overrides carried on the entity and the mesh-ref.
- ///
- 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;
- ///
- /// Cached from GfxObjSubMesh.Translucency at upload time.
- /// Avoids any per-draw lookup into external state.
- ///
- public TranslucencyKind Translucency;
- }
-}
diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
index 4dca392..eecc1a6 100644
--- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
+++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
@@ -13,26 +13,29 @@ namespace AcDream.App.Rendering.Wb;
///
/// Draws entities using WB's (a single global
/// VAO/VBO/IBO under modern rendering) with acdream's
-/// for texture resolution and for
+/// for bindless texture resolution and for
/// translucency classification.
///
///
/// Atlas-tier entities (ServerGuid == 0): mesh data comes from WB's
/// via .
-/// Textures resolve through using the batch's
-/// SurfaceId.
+/// Textures resolve through the bindless-suffixed
+/// variants, returning 64-bit
+/// resident handles stored in the per-group SSBO.
///
///
///
/// Per-instance-tier entities (ServerGuid != 0): mesh data also from
-/// WB, but textures resolve through with palette and
-/// surface overrides applied. is currently
+/// WB, but textures resolve through
+/// with palette
+/// and surface overrides applied. is currently
/// unused at draw time — GameWindow's spawn path already bakes AnimPartChanges +
/// GfxObjDegradeResolver (Issue #47 close-detail mesh) into MeshRefs.
///
///
///
-/// GL strategy (N.5): glMultiDrawElementsIndirect with SSBOs.
+/// GL strategy (N.5 — mandatory): glMultiDrawElementsIndirect with SSBOs
+/// and GL_ARB_bindless_texture + GL_ARB_shader_draw_parameters.
/// All visible (entity, batch) pairs are bucketed by ;
/// each group becomes one DrawElementsIndirectCommand. Three GPU buffers
/// are uploaded per frame: instance matrices (SSBO binding 0), per-group batch
@@ -42,17 +45,17 @@ namespace AcDream.App.Rendering.Wb;
///
///
///
-/// Shader: mesh_modern when bindless + ARB_shader_draw_parameters
-/// are available (N.5 path). Falls back to mesh_instanced when the GPU
-/// lacks those extensions.
+/// Shader: mesh_modern (bindless + gl_DrawIDARB /
+/// gl_BaseInstanceARB). Missing bindless/draw-parameters throws
+/// at startup — there is no legacy fallback.
///
///
///
/// Modern rendering assumption: WB's _useModernRendering path (GL
/// 4.3 + bindless) puts every mesh in a single shared VAO/VBO/IBO and uses
/// FirstIndex + BaseVertex per batch. The dispatcher honors those
-/// offsets via DrawElementsInstancedBaseVertex(BaseInstance). The legacy
-/// per-mesh-VAO path also works since FirstIndex/BaseVertex are zero there.
+/// offsets inside each DrawElementsIndirectCommand via
+/// glMultiDrawElementsIndirect.
///
///
public sealed unsafe class WbDrawDispatcher : IDisposable
diff --git a/src/AcDream.App/Rendering/Wb/WbFoundationFlag.cs b/src/AcDream.App/Rendering/Wb/WbFoundationFlag.cs
deleted file mode 100644
index c3fd006..0000000
--- a/src/AcDream.App/Rendering/Wb/WbFoundationFlag.cs
+++ /dev/null
@@ -1,39 +0,0 @@
-namespace AcDream.App.Rendering.Wb;
-
-///
-/// Process-lifetime cache of ACDREAM_USE_WB_FOUNDATION 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).
-///
-///
-/// Default-on as of Phase N.4 ship (2026-05-08). The WB foundation
-/// (WbMeshAdapter + WbDrawDispatcher) is the production
-/// rendering path. Set ACDREAM_USE_WB_FOUNDATION=0 to fall back
-/// to the legacy InstancedMeshRenderer path — kept as an escape
-/// hatch until N.6 fully replaces it.
-///
-///
-///
-/// Per-instance customized content (server CreateObject entities
-/// with palette / texture overrides) routes through
-/// regardless
-/// of the flag — the flag controls which DRAW path consumes those
-/// textures.
-///
-///
-public static class WbFoundationFlag
-{
- private static bool _isEnabled =
- System.Environment.GetEnvironmentVariable("ACDREAM_USE_WB_FOUNDATION") != "0";
-
- public static bool IsEnabled => _isEnabled;
-
- ///
- /// FOR TESTS ONLY. Forces to true so
- /// integration tests can exercise the WB adapter path without having to
- /// set the env var before static initialisation. Never call from
- /// production code.
- ///
- internal static void ForTestsOnly_ForceEnable() => _isEnabled = true;
-}
diff --git a/src/AcDream.App/Streaming/GpuWorldState.cs b/src/AcDream.App/Streaming/GpuWorldState.cs
index 7f6d228..a256d26 100644
--- a/src/AcDream.App/Streaming/GpuWorldState.cs
+++ b/src/AcDream.App/Streaming/GpuWorldState.cs
@@ -144,7 +144,7 @@ public sealed class GpuWorldState
}
_loaded[landblock.LandblockId] = landblock;
- if (WbFoundationFlag.IsEnabled && _wbSpawnAdapter is not null)
+ if (_wbSpawnAdapter is not null)
_wbSpawnAdapter.OnLandblockLoaded(_loaded[landblock.LandblockId]);
RebuildFlatView();
}
@@ -195,7 +195,7 @@ public sealed class GpuWorldState
public void RemoveLandblock(uint landblockId)
{
- if (WbFoundationFlag.IsEnabled && _wbSpawnAdapter is not null)
+ if (_wbSpawnAdapter is not null)
_wbSpawnAdapter.OnLandblockUnloaded(landblockId);
// Rescue persistent entities before removal. These get appended
diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/PendingSpawnIntegrationTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/PendingSpawnIntegrationTests.cs
index a02f080..c5d47f7 100644
--- a/tests/AcDream.Core.Tests/Rendering/Wb/PendingSpawnIntegrationTests.cs
+++ b/tests/AcDream.Core.Tests/Rendering/Wb/PendingSpawnIntegrationTests.cs
@@ -19,16 +19,9 @@ namespace AcDream.Core.Tests.Rendering.Wb;
///
public sealed class PendingSpawnIntegrationTests
{
- ///
- /// Force-enable WbFoundationFlag for this test class.
- /// GpuWorldState gates its adapter calls on this static-cached flag;
- /// calling the internal test hook lets us exercise the full integration
- /// path without needing the env var set before process startup.
- ///
- static PendingSpawnIntegrationTests()
- {
- WbFoundationFlag.ForTestsOnly_ForceEnable();
- }
+ // N.5 ship amendment: WbFoundationFlag was deleted — GpuWorldState
+ // no longer gates adapter calls on the flag; they are unconditional
+ // when the adapter is non-null. No static ctor hook needed.
[Fact]
public void LiveEntity_ParkedBeforeLandblock_DrainsButIsNotRegisteredWithAdapter()