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()