Merge branch 'claude/tender-mcclintock-a16839' — N.4 SHIP + N.5 handoff

Phase N.4 (Rendering Pipeline Foundation) shipped. WB's ObjectMeshManager
is now acdream's production mesh pipeline; WbDrawDispatcher is the
production draw path; ACDREAM_USE_WB_FOUNDATION default-on.

9 commits including: Adjustment 6 (PartOverrides + HiddenPartsMask),
Tasks 22+23 (WbDrawDispatcher + side-table), three Task 26 fixups
(load triggers, batch.Key.SurfaceId, FirstIndex/BaseVertex + #47),
perf pass 1-4 (sort, cull, hash memo), final SHIP commit (flag
default-on + roadmap + memory + CLAUDE.md), and N.5 cold-start
handoff for next session.
This commit is contained in:
Erik 2026-05-08 18:14:16 +02:00
commit c1e31148bb
14 changed files with 1488 additions and 91 deletions

1
.gitignore vendored
View file

@ -29,6 +29,7 @@ references/*
launch.log
launch-*.log
launch.utf8.log
n4-verify*.log
# ImGui auto-saved window/docking state (per-user, not source)
imgui.ini

View file

@ -25,19 +25,54 @@ single source of truth for how the client is structured. All work must
align with this document. When the architecture doc and reality diverge,
update one or the other — never leave them out of sync.
**WorldBuilder is acdream's rendering + dat-handling base** as of
2026-05-08. Before re-implementing any AC-specific rendering or
dat-handling algorithm, **read `docs/architecture/worldbuilder-inventory.md`
FIRST**. If WorldBuilder has it, port from WorldBuilder (or call into
our fork once wired up), not from retail decomp. WorldBuilder is
MIT-licensed, verified to render the world correctly, and uses the same
Silk.NET stack we target. Re-porting from retail decomp when WB already
has a tested port is how subtle bugs (the scenery edge-vertex bug, the
**WorldBuilder is acdream's rendering + dat-handling base, integrated
as of Phase N.4 ship (2026-05-08).** WB's `ObjectMeshManager` is the
production mesh pipeline; `WbMeshAdapter` is the seam; `WbDrawDispatcher`
is the production draw path (default-on, see `WbFoundationFlag`). Before
re-implementing any AC-specific rendering or dat-handling algorithm,
**read `docs/architecture/worldbuilder-inventory.md` FIRST**. If
WorldBuilder has it, port from WorldBuilder (or call into our fork via
the adapter), not from retail decomp. WorldBuilder is MIT-licensed,
verified to render the world correctly, and uses the same Silk.NET
stack we target. Re-porting from retail decomp when WB already has a
tested port is how subtle bugs (the scenery edge-vertex bug, the
triangle-Z bug) keep slipping in. Retail decomp remains the oracle for
network, physics, animation, movement, UI, plugin, audio, chat — see
the inventory doc's 🔴 list for the full scope of "we still write this
ourselves".
**WB integration cribs:**
- `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs` — single seam over WB's
`ObjectMeshManager`. Owns the WB pipeline, drains its staged-upload
queue per frame via `Tick()`, populates `AcSurfaceMetadataTable` with
per-batch translucency / luminosity / fog metadata.
- `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` — production draw
path. Groups all visible (entity, batch) pairs, single-uploads the
matrix buffer, fires one `glDrawElementsInstancedBaseVertexBaseInstance`
per group with `BaseInstance` pointing at the slice. Per-entity
frustum cull, opaque front-to-back sort, palette-hash memoization.
- `src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs` /
`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).
- **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).
Honor those offsets when issuing draws — `DrawElementsInstanced`
with `indices=0` will draw every entity's first triangle from the
global mesh, not the per-batch range. (This is exactly the
exploded-character bug we hit during Task 26.)
- **WB's `ObjectRenderBatch.SurfaceId` is unset** — the actual surface
id lives in `batch.Key.SurfaceId` (the `TextureKey` struct).
- **`ObjectMeshManager.IncrementRefCount` only bumps a counter** — it
does NOT trigger mesh loading. You must explicitly call
`PrepareMeshDataAsync(id, isSetup)` to fire the background decode.
Result auto-enqueues to `_stagedMeshData` which `Tick()` drains.
`WbMeshAdapter` does this for you on first registration.
**Execution phases:** R1→R8 in the architecture doc. Each phase has clear
goals, test criteria, and builds on the previous. Don't skip phases.
@ -437,15 +472,19 @@ 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.4 — Rendering Pipeline Foundation.** Plan
at [`docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md`](docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md).
Spec at [`docs/superpowers/specs/2026-05-08-phase-n4-rendering-foundation-design.md`](docs/superpowers/specs/2026-05-08-phase-n4-rendering-foundation-design.md).
This is a 3-4 week phase adopting WB's `ObjectMeshManager` + `TextureAtlasManager`
as our shared rendering infrastructure. The plan is a **living document**
task checkboxes get marked as commits land, adjustments are appended in-place,
weeks 2-4 may be revised based on week 1 discoveries. Read the plan's "Plan
Living-Document Convention" section before contributing. After N.4 ships
this pointer is removed and the plan's status flips to "Final."
**Currently in flight: Phase N.5 — Modern Rendering Path.** Roadmap entry
at [`docs/plans/2026-04-11-roadmap.md`](docs/plans/2026-04-11-roadmap.md).
Builds on N.4's `WbDrawDispatcher` to adopt WB's modern rendering primitives:
bindless textures (eliminate `glBindTexture` calls) and
`glMultiDrawElementsIndirect` (one GL call per pass instead of one per
group). Together these target a 2-5× CPU win on draw-heavy scenes by
eliminating the remaining per-group state changes. Plan + spec to be
written when work begins.
**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
[`docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md`](docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md).
**Rules:**

View file

@ -1,6 +1,6 @@
# acdream — strategic roadmap
**Status:** Living document. Updated 2026-05-08 for Phase N.3 shipping + N.4-N.9 strategy revision (rendering rebuild on shared WB infrastructure rather than independent substitutions).
**Status:** Living document. Updated 2026-05-08 for Phase N.4 shipping (`WbMeshAdapter` + `WbDrawDispatcher` + `ACDREAM_USE_WB_FOUNDATION` default-on) + N.5 rebranded to "Modern rendering path" (bindless + multi-draw indirect on top of N.4's foundation).
**Purpose:** One source of truth for where the project is and where it's going. Every observed defect or missing feature has a named phase that owns it; when something looks wrong in-game, look here to find the phase that'll address it. Implementation details live in per-phase specs under `docs/superpowers/specs/`, not in this file.
---
@ -59,6 +59,7 @@
| 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 ✓ |
Plus polish that doesn't get its own phase number:
- FlyCamera default speed lowered + Shift-to-boost
@ -604,36 +605,54 @@ for our deletions/additions; merge upstream `master` periodically.
byte-identical equivalence per format** before substitution; updated
`SurfaceDecoderTests` to match the new A8 split semantics. Visual
verification at Holtburg passed 2026-05-08 — no texture regressions.
- **N.4 — Rendering pipeline foundation.** **Rebranded from "object
meshing" 2026-05-08 after brainstorm.** WB's `ObjectMeshManager` is
not a static helper — it's a 2070-line stateful asset pipeline that
owns GPU resources (VAO/VBO/IBO), an LRU cache + memory budget,
background staging, a shared texture atlas, and a bindless rendering
path. Adopting it wholesale is the foundation that N.5 + N.6 + N.7
build on. Concretely: (1) integrate `ObjectMeshManager` +
`TextureAtlasManager` as the shared infrastructure; (2) build a
per-instance customization layer that threads `CreaturePalette` /
`GfxObjRemapping` / `HiddenParts` / `TextureChanges` / `SubPalettes` /
`AnimPartChange` through WB's atlas keys; (3) extend WB's
`MeshBatchData` to carry our surface metadata (`Translucency` /
`Luminosity` / `Diffuse` / `SurfOpacity` / `NeedsUvRepeat` /
`DisableFog`) — likely a fork patch on the `acdream` branch; (4)
decide animation cache strategy (per-frame transform via uniform/SSBO
vs. cache invalidation); (5) adapter from our streaming loader's
Setup/Static spawn events to WB's `IncrementRefCount` lifecycle.
**Estimate: 3-4 weeks.** No visible change yet — visual verification =
"world looks identical to before." Foundation enables the next phases.
- **N.5 — Terrain rendering.** Wire WB's `TerrainRenderManager` +
`LandSurfaceManager` + `TerrainGeometryGenerator` onto the foundation
N.4 builds. Closes N.2's deferred terrain math substitution: visual
mesh and physics both switch to WB's `CalculateSplitDirection` +
`GetHeight` + `GetNormal` in lockstep, resolving ISSUE #51. **Estimate:
2-3 weeks** (was 3-4 — atlas + GPU pipeline already in place from N.4).
- **✓ SHIPPED — N.4 — Rendering pipeline foundation.** Shipped 2026-05-08.
WB's `ObjectMeshManager` is integrated as the production mesh pipeline
behind `ACDREAM_USE_WB_FOUNDATION=1` (default-on). The integration is
three pieces: `WbMeshAdapter` (single seam owning the WB pipeline,
drains the staged-upload queue per frame, populates
`AcSurfaceMetadataTable` for translucency / luminosity / fog),
`WbDrawDispatcher` (production draw path — groups all visible
(entity, batch) pairs, uploads matrices in a single `glBufferData`,
fires one `glDrawElementsInstancedBaseVertexBaseInstance` per group
with `BaseInstance` slicing the shared instance VBO), and the
`LandblockSpawnAdapter` + `EntitySpawnAdapter` bridge that wires our
streaming loader to WB's `IncrementRefCount` / `PrepareMeshDataAsync`
lifecycle (atlas tier vs per-instance customized).
Issue #47 (close-detail mesh) preserved; sky pass structurally
independent of the WB foundation. Perf wins shipped as part of N.4:
per-entity AABB frustum cull, opaque front-to-back sort, palette-hash
memoization. Legacy `InstancedMeshRenderer` retained as flag-off
fallback until N.6 fully retires it. Plan archived at
`docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md`.
- **N.5 — Modern rendering path.** **Rebranded from "Terrain rendering"
2026-05-08 after N.4 perf review.** N.4 left two big remaining wins
on the table that pair naturally: (1) bindless textures via
`GL_ARB_bindless_texture` (WB already populates
`ObjectRenderBatch.BindlessTextureHandle`; switch our shader to
consume per-instance handles, eliminate 100% of `glBindTexture`
calls), and (2) `glMultiDrawElementsIndirect` (one GL call per pass
instead of one per group; build a `DrawElementsIndirectCommand`
buffer, fire one indirect draw, the driver pulls everything). Both
require shader changes (same shader, in fact — bindless + indirect
are the same modern path WB uses internally). Together they target a
2-5× CPU win on draw-heavy scenes (Holtburg courtyard, Foundry,
dense dungeons). Also folds in: persistent-mapped instance VBO
(`glBufferStorage` + `MAP_PERSISTENT_BIT | MAP_COHERENT_BIT` + ring
buffer + sync) and texture pre-warm at landblock load (smooths
streaming-boundary hitches). **Estimate: 2-3 weeks.**
- **N.5b — Terrain rendering on N.5 path.** Wire WB's
`TerrainRenderManager` + `LandSurfaceManager` + `TerrainGeometryGenerator`
onto the modern rendering path. Closes N.2's deferred terrain math
substitution: visual mesh and physics both switch to WB's
`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 — Static objects rendering.** Wire WB's
`StaticObjectRenderManager` onto N.4's foundation; replace our
`StaticMeshRenderer` + `InstancedMeshRenderer`. Mostly draw
orchestration at this point — most of the substance landed in N.4.
**Estimate: 1-2 weeks** (was 2-3).
`StaticObjectRenderManager` onto the modern rendering path; **fully
delete** legacy `StaticMeshRenderer` + `InstancedMeshRenderer` (they
remain as `ACDREAM_USE_WB_FOUNDATION=0` escape hatches through N.5).
Mostly draw orchestration at this point — most of the substance
landed in N.4 + N.5. **Estimate: 1-2 weeks** (was 2-3).
- **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

View file

@ -0,0 +1,495 @@
# Phase N.5 — Modern Rendering Path — Cold-Start Handoff
**Created:** 2026-05-08, immediately after N.4 ship.
**Audience:** the next agent picking up rendering perf work.
**Purpose:** give you everything you need to start N.5 cold, without
spelunking through five months of session history.
---
## TL;DR
N.4 just shipped: WB's `ObjectMeshManager` is now acdream's production
mesh pipeline, and `WbDrawDispatcher` is the production draw path. It
works (Holtburg renders correctly, FPS substantially improved over the
naïve dual-pipeline state we hit during week 4 verification) but it's
still doing per-group state changes (`glBindTexture`, `glBindBuffer`
for the IBO, `glDrawElementsInstancedBaseVertexBaseInstance` per group)
and a fresh `glBufferData` upload per frame.
**N.5's job: lift the dispatcher onto WB's modern rendering primitives
that we're already paying GPU-feature-detection cost for.** Two big
wins, paired:
1. **Bindless textures** (`GL_ARB_bindless_texture`) — WB already
populates `ObjectRenderBatch.BindlessTextureHandle`. Switch our
shader to read texture handles from a per-instance attribute
(`uvec2``sampler2D` via the bindless extension). Eliminates
100% of `glBindTexture` calls.
2. **Multi-draw indirect** (`glMultiDrawElementsIndirect`) — build a
buffer of `DrawElementsIndirectCommand` structs (one per group),
upload once, fire ONE `glMultiDrawElementsIndirect` call per pass.
The driver pulls everything from the indirect buffer.
Together they target a 2-5× CPU win on draw-heavy scenes (Holtburg
courtyard, Foundry, dense dungeons). They're packaged together because
both are "modern path" extensions we already gate on, both require
the same shader rewrite, and they pair naturally — multi-draw indirect
is a no-op CPU-win without bindless because per-group `glBindTexture`
calls would still serialize.
**Estimated scope: 2-3 weeks.** Plan + spec to be written by the
brainstorm + spec steps below.
---
## Where N.4 left things
### Branch state
If this handoff is being read on `main` after merging the N.4 worktree:
N.4 commits land at the head of main. The relevant final commits:
- `c445364` — N.4 SHIP (flag default-on, plan final, roadmap, memory)
- `573526d` — perf pass 1-4 (drop dead lookup, sort, cull, hash memo)
- `7b41efc` — FirstIndex/BaseVertex + Issue #47 + grouped instanced
- `943652d` — load triggers + `batch.Key.SurfaceId` source
- `01cff41` — Tasks 22+23 (`WbDrawDispatcher` + side-table)
If the worktree branch (`claude/tender-mcclintock-a16839`) hasn't been
merged yet, that's where the work is. Verify with `git log --oneline`.
### What works in N.4
- `ACDREAM_USE_WB_FOUNDATION=1` is default-on. WB's `ObjectMeshManager`
loads, decodes, and uploads every entity mesh. Our existing
`TextureCache` decodes textures (palette-aware, per-instance overrides
via `GetOrUploadWithPaletteOverride`).
- `WbDrawDispatcher.Draw`:
- Walks visible entities (per-landblock AABB cull + per-entity AABB
cull + portal visibility)
- Buckets every (entity × meshRef × batch) tuple by
`GroupKey(Ibo, FirstIndex, BaseVertex, IndexCount, TextureHandle, Translucency)`
- Single `glBufferData` upload of all matrices for the frame
- Per group: `glActiveTexture(0) + glBindTexture(2D, handle) + glBindBuffer(EBO, ibo) + glDrawElementsInstancedBaseVertexBaseInstance(..., FirstInstance)`
- Two passes: opaque (front-to-back sorted) + translucent
- 940/948 tests pass (8 pre-existing failures unrelated to rendering).
- Visual verification at Holtburg passed: scenery + characters render
correctly with full close-detail geometry (Issue #47 preserved).
### What N.5 inherits
These are levers N.5 will pull on:
- **WB's modern rendering is already active.** `OpenGLGraphicsDevice`
detected GL 4.3 + bindless on first run; WB's `_useModernRendering`
is true; every mesh lives in WB's single `GlobalMeshBuffer` (one VAO,
one VBO, one IBO).
- **Bindless handles are already populated.** `ObjectRenderBatch.BindlessTextureHandle`
is non-zero for batches WB owns the texture for. (See gotcha #2
below for entities with palette overrides — those use acdream's
`TextureCache` which doesn't expose bindless handles yet.)
- **The instance VBO is acdream-owned** (`WbDrawDispatcher._instanceVbo`)
with locations 3-6 patched onto WB's global VAO. Stride 64 bytes
(one mat4). N.5 expands this to (mat4 + uvec2 handle) = 80 bytes.
### Three load-bearing WB API gotchas N.4 surfaced
These bit us hard during Task 26 visual verification. Documented in
CLAUDE.md "WB integration cribs" + plan adjustments 7-9 +
`memory/project_phase_n4_state.md`. Re-stating here because they
reshape the design space:
1. **`ObjectMeshManager.IncrementRefCount(id)` is NOT lifecycle-aware.**
It only bumps a usage counter. Mesh loading is fired separately
via `PrepareMeshDataAsync(id, isSetup)`. The result auto-enqueues
to `_stagedMeshData` (line 510 of `ObjectMeshManager.cs`); our
existing `WbMeshAdapter.Tick()` drains it. `WbMeshAdapter.IncrementRefCount`
already calls `PrepareMeshDataAsync`. **N.5 doesn't need to change
this — just don't break it.**
2. **`ObjectRenderBatch.SurfaceId` is unset.** WB constructs batches
with `Key = batch.Key` (a `TextureAtlasManager.TextureKey` struct
that has a `SurfaceId` field) but never populates the top-level
`SurfaceId` property. Read `batch.Key.SurfaceId`. **N.5 keeps this
pattern.**
3. **WB's modern rendering packs every mesh into ONE global
VAO/VBO/IBO.** Each batch's `IBO` field points to the global IBO;
the batch's actual slice is identified by `FirstIndex` (offset into
IBO, in *indices*) and `BaseVertex` (offset into VBO, in *vertices*).
N.4's draw uses `glDrawElementsInstancedBaseVertexBaseInstance`
with those offsets. **N.5's `DrawElementsIndirectCommand` per-group
record will carry `firstIndex` + `baseVertex` for the same reason.**
---
## What N.5 is — technical detail
### The two-feature pairing
**Bindless textures** (`GL_ARB_bindless_texture`):
- Each texture handle is a 64-bit integer (`uvec2` in GLSL).
- Shader declares `layout(bindless_sampler) uniform sampler2D ...` or
receives the handle as a per-vertex-attribute `uvec2`.
- No `glBindTexture` needed at draw time — the handle IS the binding.
- Handle generation: `glGetTextureHandleARB(textureId)` followed by
`glMakeTextureHandleResidentARB(handle)` (the texture must be
resident on the GPU; non-resident handles produce GPU faults).
**Multi-draw indirect** (`glMultiDrawElementsIndirect`):
- Indirect command struct layout (must match `DrawElementsIndirectCommand`):
```c
struct {
uint count; // index count for this draw
uint instanceCount; // number of instances
uint firstIndex; // offset into IBO, in indices
int baseVertex; // vertex offset into VBO
uint baseInstance; // first instance ID (offsets per-instance attribs)
};
```
- Build a buffer of N of these structs (one per group), upload once,
fire one GL call: `glMultiDrawElementsIndirect(mode, indexType, ptr, drawcount, stride)`.
- The driver issues all N draws in one shot. Effectively zero CPU
overhead per draw beyond uploading the indirect buffer.
**Why pair them.** Multi-draw indirect doesn't let you change uniform
state between draws. So if textures are bound via `glBindTexture` per
group, you'd still need N CPU-side setup steps before each indirect
call — defeating the purpose. Bindless removes that constraint by
encoding the texture handle as per-instance data the shader reads
directly. With both, the modern render loop becomes:
```
1. Upload instance buffer (mat4 + uvec2 handle, per-instance) — once per frame
2. Upload indirect command buffer (one DEIC per group) — once per frame
3. glBindVertexArray(globalVAO) — once
4. glMultiDrawElementsIndirect(...) — ONCE per pass
```
That's it. No per-group state changes.
### Instance attribute layout
Currently (N.4): location 3-6 = mat4 model matrix (16 floats = 64 bytes).
N.5 (proposed): location 3-6 = mat4 + location 7 = uvec2 bindless
handle = 16 floats + 2 uints = 72 bytes (16-aligned to 80 bytes per
WB's `InstanceData` precedent).
Or use std140-aligned struct:
```c
struct InstanceData {
mat4 transform; // locations 3-6
uvec2 textureHandle; // location 7
uvec2 _pad; // padding to 80
};
```
Brainstorm should decide if we copy WB's `InstanceData` struct (Pack=16,
80 bytes including CellId/Flags fields we don't use) or define our own
minimal version. The 80-byte stride matches WB's so global VAO state
configured by WB stays compatible if the legacy WB draw path ever runs.
### Per-instance entity texture handles
Here's the wrinkle. N.4 uses `WbDrawDispatcher.ResolveTexture` to map
each (entity, batch) to a GL texture handle:
- Tree (no overrides): `_textures.GetOrUpload(surfaceId)` → 2D texture handle
- NPC with palette override: `_textures.GetOrUploadWithPaletteOverride(...)` → composite-cached 2D texture handle
- Anything with surface override: `_textures.GetOrUploadWithOrigTextureOverride(...)` → composite-cached 2D texture handle
Those are all `GLuint` 32-bit GL texture *names*, not bindless handles.
**N.5 needs `TextureCache` to publish bindless handles for everything
it owns, not just WB-owned textures.**
Implementation sketch:
- `TextureCache` adds a parallel cache keyed identically but storing
64-bit bindless handles. On first request, generate via
`glGetTextureHandleARB(textureId)` + make resident.
- New API: `GetBindlessHandle(uint surfaceId, ...)` returns the handle.
- Or: change every `GetOrUpload*` method to return both the GL name
and the bindless handle (or just the handle; let GL name fall out
if anyone needs it later).
WB's `ObjectRenderBatch.BindlessTextureHandle` covers the atlas-tier
case. For per-instance entities, we use `TextureCache`'s handle.
### The new shader
Reuse WB's `StaticObjectModern.vert` / `StaticObjectModern.frag` as a
template. Read those files cold. They already do bindless + the
instance-data layout. Adapt to acdream's `mesh_instanced.vert/frag`
conventions:
- Keep the `uViewProjection` uniform, lighting UBO at binding=1, fog
uniforms.
- Add `#version 430 core` + `#extension GL_ARB_bindless_texture : require`.
- Replace `uniform sampler2D uDiffuse` with a `uvec2` per-vertex
attribute (location 7) → reconstruct sampler in vertex shader OR
pass through to fragment via flat varying.
- Drop `uTranslucencyKind` uniform, OR keep it (still set per-pass —
multi-draw indirect doesn't break uniforms; only state that varies
per-draw is the constraint).
### Translucency
Multi-draw indirect can't change blend state mid-draw. Solution:
**still use two passes** (opaque + translucent), but within translucent
keep the per-blendfunc sub-passes (additive, alpha-blend, inv-alpha).
Three sub-passes within translucent. Each sub-pass = one
`glMultiDrawElementsIndirect` over its filtered groups.
Or: if perf allows, fold all four blend modes into the shader via
per-instance blendmode int, sort all translucent groups by blendmode
in the indirect buffer, switch blend state at sub-pass boundaries.
Brainstorm decides the cleanest pattern.
---
## Files to read before brainstorming
In rough order:
1. **N.4 plan + spec**`docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md`
(status: Final). Adjustments 7-10 capture the gotchas. Spec at
`docs/superpowers/specs/2026-05-08-phase-n4-rendering-foundation-design.md`.
2. **N.4 dispatcher source**`src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`.
This is what you're modifying. Read end-to-end.
3. **WB's modern rendering shaders**`references/WorldBuilder/Chorizite.OpenGLSDLBackend/Shaders/StaticObjectModern.vert`
+ `StaticObjectModern.frag`. The template you're adapting from.
4. **WB's `ObjectMeshManager.UploadGfxObjMeshData`** — lines ~1654-1780
of `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectMeshManager.cs`.
Shows how WB sets up the modern path's VBO/IBO/VAO. Especially note
how it patches in instance attribute slots (locations 3-6) on the
global VAO and configures location 7+ for bindless handles.
5. **WB's `ObjectRenderBatch`** — same file, lines ~166-184. Note the
`BindlessTextureHandle` field — already populated when `_useModernRendering`
is on.
6. **Our `TextureCache`**`src/AcDream.App/Rendering/TextureCache.cs`.
Three composite caches: by surface id, by surface+origTex, by
surface+origTex+palette. N.5 adds parallel bindless-handle caches.
7. **CLAUDE.md "WB integration cribs"** section. Lines ~28-80. The
three gotchas + the integration architecture in plain language.
8. **Memory: `project_phase_n4_state.md`** — same content from a
different angle. Reading both helps lock in the gotchas.
---
## Brainstorm questions
These are the questions to resolve in the brainstorm step. Don't
prejudge them — bring them to the user with options + recommendation:
1. **Instance attribute layout.** Match WB's `InstanceData` struct
(80 bytes including CellId/Flags fields we don't use) for global
VAO compatibility, or define a minimal acdream-specific version
(mat4 + handle = ~72 bytes padded to 80)?
2. **Bindless handle generation strategy.**
- At texture upload time? (Eager — every texture that lands in
`TextureCache` gets a handle. Memory cost ~per-texture state.)
- On first draw lookup? (Lazy — cache fills as scene exercises
content. Possible first-use stall.)
- At spawn time via the spawn adapter? (Tied to lifecycle. Cleanest
but requires touching the spawn path.)
3. **Translucent pass structure.** Three sub-indirect-draws (one per
blend mode) or a single sorted indirect buffer with per-instance
blend mode + state-flip at sub-pass boundaries? Or: just iterate
per-group like N.4 for translucent only (translucent groups are a
small fraction of total)?
4. **Persistent-mapped indirect + instance buffers.** Use
`GL_ARB_buffer_storage` + `MAP_PERSISTENT_BIT | MAP_COHERENT_BIT`?
Triple-buffered ring + sync object? Or stick with `glBufferData`
(still one upload per frame, just larger)? Persistent mapping is
~2-5% per-frame win in our context but adds buffer-management
complexity.
5. **Shader unification.** Keep `mesh_instanced` for legacy + add
`mesh_indirect` for modern, or replace `mesh_instanced` entirely?
Replacement requires the legacy `InstancedMeshRenderer` (escape
hatch under `ACDREAM_USE_WB_FOUNDATION=0`) to also use the new
shader, which... probably doesn't matter if we delete legacy in
N.6 anyway. Brainstorm.
6. **Conformance test strategy.** N.4 used visual verification at
Holtburg as the gate. N.5's gate is "no visual regression vs N.4
AND measurable CPU win." How do we measure CPU? `[WB-DIAG]`
counters give draw count + group count; we need frame-time
counters too. Add to the dispatcher? Use a profiler?
7. **Per-instance entity bindless.** `TextureCache.GetOrUpload*`
returns a GL name. The dispatcher (or `TextureCache` itself) needs
to convert that to a bindless handle. Design questions:
- Where does the conversion happen?
- When is the texture made resident? (Residency is global state;
too many resident textures hits driver limits.)
- What about palette/surface overrides — same caching key as the
name, just a parallel handle dictionary?
8. **Escape hatch.** N.4 keeps `ACDREAM_USE_WB_FOUNDATION=0` as a
fallback. N.5 needs to decide: does the new shader REPLACE the
N.4 dispatcher's draw path (so flag-on means N.5 modern path,
flag-off means legacy `InstancedMeshRenderer`)? Or do we add a
separate flag (`ACDREAM_USE_MODERN_DRAW`) so users can toggle
N.4 vs N.5 vs legacy independently? Three-way flag is more
complex but useful for A/B during rollout.
---
## Spec structure
After the brainstorm, the spec doc covers:
1. **Architecture diagram** — how `WbDrawDispatcher` changes shape.
Where the indirect buffer lives. Where bindless handles flow from.
2. **Instance data layout** — exact struct, byte offsets, GL attribute
pointer setup.
3. **TextureCache changes** — new methods, new cache, residency
policy.
4. **Shader files** — name(s), version, extensions, in/out variables.
5. **Conformance tests** — what to write, what coverage to claim.
6. **Acceptance criteria** — visual identity to N.4 + measured CPU
delta.
7. **Risks** — driver bugs in bindless / indirect, residency limits,
shader compile issues on weird GPUs, the legacy escape hatch
breaking.
Spec lives at: `docs/superpowers/specs/2026-05-XX-phase-n5-modern-rendering-design.md`.
## Plan structure
After the spec, the plan doc lays out the week-by-week task list.
Match N.4's plan structure (living document, task checkboxes, commit
SHAs appended, adjustments documented inline). Plan lives at:
`docs/superpowers/plans/2026-05-XX-phase-n5-modern-rendering.md`.
Suggested initial breakdown (brainstorm + spec will refine):
- **Week 1** — Plumbing: bindless handle generation in `TextureCache`,
shader rewrite (compile + bind), instance-attrib layout updated to
mat4+handle. Dispatcher still uses per-group draws but reads
textures bindless. Validate: visual identical to N.4.
- **Week 2** — Indirect: build `DrawElementsIndirectCommand` buffer
per frame, switch to `glMultiDrawElementsIndirect`. Three-pass
translucent (or whatever brainstorm decides). Validate: visual
identical, draw-call count drops to 2-4 per frame.
- **Week 3** — Polish + ship: persistent-mapped buffers if brainstorm
voted yes, profiler/counters, visual verification, flag flip, plan
finalization.
---
## Acceptance criteria for the whole phase
- Visual output identical to N.4 (no character regressions, no
scenery missing, no z-fighting introduced)
- `[WB-DIAG]` shows `drawsIssued` ≤ ~5 per frame (down from N.4's
few hundred)
- Frame time measurably lower in dense scenes (specify what scenes
to test in the spec — probably Holtburg courtyard + Foundry
interior)
- All tests still green (940/948 + any new conformance tests)
- `ACDREAM_USE_WB_FOUNDATION=0` escape hatch still works
- Plan doc finalized, roadmap updated, memory captured if N.5
surfaces durable lessons (it almost certainly will — bindless
+ indirect both have well-known driver gotchas)
---
## What you'll be doing in the first 30 minutes
1. Read this handoff in full.
2. Read CLAUDE.md "WB integration cribs" section.
3. Read `WbDrawDispatcher.cs` end-to-end.
4. Skim WB's `StaticObjectModern.vert/frag` + `ObjectMeshManager.UploadGfxObjMeshData`
to ground the reference.
5. Verify build is green: `dotnet build`.
6. Verify N.4 ship is intact: `dotnet test --filter "FullyQualifiedName~Wb|MatrixComposition"`
should produce 60 passing tests, 0 failures.
7. Invoke the `superpowers:brainstorming` skill with the user. Walk
through the 8 brainstorm questions above. Capture decisions in a
spec.
8. Write the spec at the path above.
9. Write the plan at the path above.
10. Begin Week 1 implementation per the plan.
Don't skip the brainstorm. Multi-draw indirect + bindless have several
real driver-compatibility / API-shape decisions that need user input,
not "the agent makes a call and goes." This phase is structurally the
same shape as N.4 — brainstorm → spec → plan → tasks-with-checkboxes →
commits-update-checkboxes → final SHIP commit.
---
## Things to NOT do
- **Don't delete the legacy `InstancedMeshRenderer`.** It's the N.4
escape hatch. N.6 retires it after N.5 is proven default-on.
- **Don't fork WB.** N.4 deliberately avoided fork patches by using
the side-table pattern (`AcSurfaceMetadataTable`). Stay on that
path. If you need data WB doesn't expose, add a side-table or
decode it yourself from dats.
- **Don't try to make per-instance entities use WB's `TextureAtlasManager`.**
That's N.6+ territory. acdream's `TextureCache` owns palette/surface
overrides because WB's atlas is keyed by `(surfaceId, paletteId,
stippling, isSolid)` and our overrides don't fit cleanly. Bindless
handles let us escape that mismatch — handles for both atlas-tier
AND per-instance-tier textures, no atlas adoption needed.
- **Don't skip visual verification.** N.4 surfaced three bugs at
visual verification that no test caught. Don't trust "build green +
tests pass" — exercise the rendering path with the local ACE server.
- **Don't extend the phase scope.** N.5 is bindless + indirect on
the existing rendering pipeline. Texture array atlas, GPU-side
culling, terrain wiring — all of those are subsequent phases. If
the brainstorm tries to expand, push back.
---
## Reference: the N.4 dispatcher flow you're modifying
```
Draw(camera, landblockEntries, frustum, ...) {
// Phase 1: walk entities, build groups
foreach (entity, meshRef, batch) {
cull, classify into _groups[GroupKey]
}
// Phase 2: lay matrices contiguously
// Phase 3: glBufferData(_instanceVbo, allMatrices)
// Phase 4: bind global VAO once
// Phase 5: opaque pass (sorted)
foreach (group in _opaqueDraws) {
glBindTexture(group.handle)
glBindBuffer(EBO, group.ibo)
glDrawElementsInstancedBaseVertexBaseInstance(...)
}
// Phase 6: translucent pass
}
```
After N.5, Phases 5 and 6 collapse to:
```
glBindBuffer(DRAW_INDIRECT_BUFFER, _opaqueIndirect)
glMultiDrawElementsIndirect(GL_TRIANGLES, GL_UNSIGNED_SHORT, 0, opaqueGroups.Count, sizeof(DEIC))
glBindBuffer(DRAW_INDIRECT_BUFFER, _translucentIndirect)
// 3 sub-calls for translucent or 1 if shader-folded
glMultiDrawElementsIndirect(...)
```
That's the destination. Get there cleanly.
Good luck. Holler at the user if any of the brainstorm questions feel
genuinely ambiguous after reading the references — they care about
this phase landing right and will engage on design questions.

View file

@ -64,7 +64,12 @@ This plan is the **execution source of truth** for N.4. It is updated as tasks l
- If a downstream task changes shape because of an earlier task's outcome, append the changes to the downstream task in-place rather than scattering deltas.
- Final commit for the phase updates this header note from "Living document — work in progress" to "Final state at <date> — phase shipped (merge `<sha>`)."
Status: **Living document — work in progress, started 2026-05-08.**
Status: **Final state at 2026-05-08 — phase shipped.** All tasks
complete; `ACDREAM_USE_WB_FOUNDATION` flipped default-on. Visual
verification at Holtburg passed. Three bugs surfaced + resolved during
Task 26 are documented as Adjustments 7-9 below and as gotchas in
CLAUDE.md. Followup work moves to N.5 (modern rendering path: bindless
+ multi-draw indirect).
**Progress (2026-05-08):** Weeks 1 + 2 + 3 ✅ COMPLETE. WB pipeline running flag-on (constructed + ref-counted + per-frame Tick draining its queues). Per-instance tier wired (`EntitySpawnAdapter` routes server-spawned entities through existing `TextureCache.GetOrUploadWithPaletteOverride` path; per-entity `AnimatedEntityState` accumulates AnimPartChange + HiddenParts data, ready for the dispatcher). Five architectural adjustments documented: 1 (DefaultDatReaderWriter discovery), 2 (renderer is tier-blind), 3 (FPS regression = dual-pipeline cost; resolves at Task 22), 4 (WorldEntity missing HiddenPartsMask + AnimPartChanges fields, plumbing deferred), 5 (Task 20 is structural — same function called both paths). Build green, 947 tests pass, 8 pre-existing failures only.
@ -92,13 +97,15 @@ Status: **Living document — work in progress, started 2026-05-08.**
| 17 — EntitySpawnAdapter | ✅ + Adj. 4 | `c02c307` |
| 20 — Per-instance decode conformance | ✅ structural (Adj. 5) | (no test file) |
| 21 — Week 3 wrap-up | ✅ | (this commit) |
| 22 — WbDrawDispatcher full draw loop | pending | — |
| 23 — Surface metadata side-table population | pending | — |
| 24 — Sky-pass preservation check | pending | — |
| 25 — Component micro-tests round-out | pending | — |
| 26 — Visual verification + flag default-on | pending | — |
| 27 — Delete legacy code paths | pending | — |
| 28 — Update memory + ISSUES + finalize plan | pending | — |
| 22+23 — WbDrawDispatcher + side-table population | ✅ | `01cff41` |
| 22+23 fixup — load triggers + SurfaceId source | ✅ | `943652d` |
| 22+23 perf — FirstIndex/BaseVertex + #47 + grouped instanced | ✅ | `7b41efc` |
| 22+23 perf 1-4 — drop dead lookup, sort, cull, hash memo | ✅ | `573526d` |
| 24 — Sky-pass preservation check | ✅ structural (independent) | `5df9135` |
| 25 — Component micro-tests round-out | ✅ all spec tests covered | — |
| 26 — Visual verification + flag default-on | ✅ | (this commit) |
| 27 — Delete legacy code paths | ⚠️ deferred to N.6 (legacy retained as flag-off escape hatch) | — |
| 28 — Update memory + ISSUES + finalize plan | ✅ | (this commit) |
---
@ -992,6 +999,77 @@ behavior (does the adapter call the cache with the right args?). The
decode-byte conformance is structural: same function = same output.
Mark Task 20 ✅ structurally; no separate test file.
### Adjustment 6 (2026-05-08): Resolved Adjustment 4 — Option A (fields on WorldEntity)
**Context.** Adjustment 4 deferred the `HiddenPartsMask` + `AnimPartChanges`
plumbing decision to Task 22. Two options:
- **A**: add fields to `WorldEntity`, populate at spawn time
- **B**: thread as separate args into `EntitySpawnAdapter.OnCreate`
**Decision: Option A.** Reasoning:
1. The data is already computed at spawn time in GameWindow's CreateObject
handler — adding two fields is a 4-line change.
2. Option B would spread network-layer types across the streaming subsystem,
violating the same separation-of-concerns principle as Adjustment 2.
3. The 0xF625 ObjDescEvent (appearance update) replays through the same
spawn path, so WorldEntity fields work automatically for hot-swap updates.
**Implementation:**
- `WorldEntity` gains `PartOverrides: IReadOnlyList<PartOverride>` (default
empty) and `HiddenPartsMask: ulong` (default 0).
- `PartOverride(byte PartIndex, uint GfxObjId)` is a lightweight record struct
in Core.World that decouples from the network-layer `CreateObject.AnimPartChange`.
- `EntitySpawnAdapter.OnCreate` now calls `state.HideParts(entity.HiddenPartsMask)`
and `state.SetPartOverride(...)` for each override.
- GameWindow's CreateObject handler builds the `PartOverride[]` from the
server-sent `AnimPartChanges` list.
### Adjustment 7 (2026-05-08, Task 26 visual verification): IncrementRefCount doesn't trigger mesh load
**Discovered when** Task 26's first launch showed only terrain — zero entities visible. Diagnostic counters (added the same launch via `ACDREAM_WB_DIAG=1`) showed `entitiesSeen=14M, entitiesDrawn=14M, drawsIssued=0` — every entity was visited but no draws were issued because `TryGetRenderData` returned null for everything.
**Root cause.** WB's `ObjectMeshManager.IncrementRefCount(id)` only bumps a usage counter — it does NOT trigger mesh loading. Loading is fired separately by `PrepareMeshDataAsync(id, isSetup)`, which dispatches to a background worker pool; the result auto-enqueues to `_stagedMeshData` (line 510 of `ObjectMeshManager.cs`) which our existing `WbMeshAdapter.Tick()` already drains.
The N.4 plan assumed `IncrementRefCount` was lifecycle-aware (it isn't). `LandblockSpawnAdapter` and the original `EntitySpawnAdapter` both called `IncrementRefCount` and stopped — meshes never loaded.
**Fix** (commit `943652d`):
- `WbMeshAdapter.IncrementRefCount` now calls `_meshManager.PrepareMeshDataAsync(id, isSetup: false)` on first registration. `isSetup: false` is correct because acdream's MeshRefs already carry expanded per-part GfxObj ids (0x01XXXXXX) — WB's Setup-expansion path is unused.
- `EntitySpawnAdapter` gained an optional `IWbMeshAdapter` constructor parameter. Per-instance entities (server-spawned characters / NPCs) had been entirely skipped by `LandblockSpawnAdapter` (which filters `ServerGuid != 0`); their GfxObjs now get registered + loaded at `OnCreate` and decremented at `OnRemove`. Includes both `MeshRefs.GfxObjId` AND `PartOverrides.GfxObjId` so weapon/clothing/helmet swaps load too.
**Lesson preserved.** Future cross-session work touching WB: **`IncrementRefCount` is not lifecycle-aware. Call `PrepareMeshDataAsync` to trigger loads.** Documented in CLAUDE.md "WB integration cribs" section.
### Adjustment 8 (2026-05-08, Task 26 visual verification): SurfaceId lives in batch.Key.SurfaceId
**Discovered when** the second Task 26 launch showed `drawsIssued=4.8M/5s` (draws ARE happening) but ZERO entities visible. Inspection of `ResolveTexture` showed it was returning early because `batch.SurfaceId == 0` for every batch.
**Root cause.** WB's `ObjectMeshManager.UploadGfxObjMeshData` (line 1746 of `ObjectMeshManager.cs`) constructs `ObjectRenderBatch` and sets `Key = batch.Key` (a `TextureAtlasManager.TextureKey` struct that contains a `SurfaceId` field) but does NOT populate the top-level `ObjectRenderBatch.SurfaceId` property. That property exists on the type but stays at its default 0.
**Fix** (commit `943652d`): `WbDrawDispatcher.ResolveTexture` reads `batch.Key.SurfaceId` instead of `batch.SurfaceId`. Also handles the dummy `0xFFFFFFFF` case used by WB's environment edge wireframes.
**Lesson preserved.** **`ObjectRenderBatch.SurfaceId` is not populated by WB. Read `batch.Key.SurfaceId`.** Documented in CLAUDE.md.
### Adjustment 9 (2026-05-08, Task 26 visual verification): Modern rendering uses one global VAO/VBO/IBO
**Discovered when** the third Task 26 launch finally showed real draws — but as "exploded" character body parts scattered around the world with no scenery. Visual was completely broken even though the GL pipeline was clearly issuing draws and binding textures correctly.
**Root cause.** WB's `ObjectMeshManager` has two rendering paths controlled by `_useModernRendering = HasOpenGL43 && HasBindless`. On any modern GPU (which is everything we target), modern is true and ALL meshes share a single `GlobalMeshBuffer` — one VAO, one VBO, one IBO. Each batch's `IBO` field points to that ONE global IBO; the batch's actual slice is identified by `FirstIndex` (offset into IBO, in indices) and `BaseVertex` (offset into VBO, in vertices). The dispatcher was issuing `glDrawElementsInstanced` with `indices=0` and no base vertex — so every entity drew the same first triangle of the global mesh starting at offset 0. That produced exactly the "exploded parts at scattered positions" symptom.
**Fix** (commit `7b41efc`): switch to `glDrawElementsInstancedBaseVertexBaseInstance`, pass `(void*)(batch.FirstIndex * sizeof(ushort))` as the indices argument, pass `(int)batch.BaseVertex` as base vertex. The grouped-instanced refactor in the same commit additionally uses `BaseInstance` to slice into the shared instance VBO per group.
**Bonus discovery:** because all meshes share one VAO under modern rendering, the dispatcher only needs to bind the VAO ONCE per frame (not per draw). Every draw goes to the same VAO. Significant CPU savings.
**Lesson preserved.** **WB's modern rendering path packs everything into one global VAO/VBO/IBO. Honor `FirstIndex` and `BaseVertex`.** Documented in CLAUDE.md.
### Adjustment 10 (2026-05-08, Task 26 visual verification): AnimatedEntityState overrides clobber Issue #47 close-detail mesh
**Discovered when** Task 26's fourth launch showed scenery + connected characters — but characters were "bulky and missing detail" compared to the legacy renderer. Recognized as a re-occurrence of Issue #47 (resolved 2026-05-06 via `GfxObjDegradeResolver`).
**Root cause.** Adjustment 6 stored AnimPartChanges on `WorldEntity.PartOverrides` using the raw `NewModelId` from the network packet — without applying `GfxObjDegradeResolver`. GameWindow's spawn path correctly resolves base GfxObjs (e.g., upper arm `0x01000055`, 14 verts/17 polys) to their close-detail equivalents (`0x01001795`, 32 verts/60 polys) and bakes the result into `MeshRefs`. But `WbDrawDispatcher` then called `animState.ResolvePartGfxObj(partIdx, meshRefGfxObjId)` which returned the raw (low-detail) override from `PartOverrides`, undoing the degrade.
**Fix** (commit `7b41efc`): the dispatcher trusts `MeshRefs` as the source of truth and does NOT re-apply `animState.ResolvePartGfxObj` at draw time. `AnimatedEntityState` overrides become relevant only for hot-swap appearance updates (0xF625 `ObjDescEvent`) which today rebuild MeshRefs anyway. `IsPartHidden` similarly skipped — `HiddenPartsMask` is never populated by spawn code (legacy renderer also doesn't check it).
**Lesson preserved.** **`MeshRefs` is the source of truth at draw time** — GameWindow's spawn path bakes overrides + degrades into it. Don't re-apply overrides downstream.
### Task 6 (original — kept for history)
**Files:**

View file

@ -31,6 +31,8 @@ public sealed class GameWindow : IDisposable
/// <summary>Phase N.4: WB-backed rendering pipeline adapter. Non-null only
/// when <c>ACDREAM_USE_WB_FOUNDATION=1</c> is set; null otherwise.</summary>
private AcDream.App.Rendering.Wb.WbMeshAdapter? _wbMeshAdapter;
private AcDream.App.Rendering.Wb.EntitySpawnAdapter? _wbEntitySpawnAdapter;
private AcDream.App.Rendering.Wb.WbDrawDispatcher? _wbDrawDispatcher;
private SamplerCache? _samplerCache;
private DebugLineRenderer? _debugLines;
// K-fix4 (2026-04-26): default OFF. The orange BSP / green cylinder
@ -1434,7 +1436,7 @@ public sealed class GameWindow : IDisposable
if (AcDream.App.Rendering.Wb.WbFoundationFlag.IsEnabled)
{
var wbLogger = Microsoft.Extensions.Logging.Abstractions.NullLogger<AcDream.App.Rendering.Wb.WbMeshAdapter>.Instance;
_wbMeshAdapter = new AcDream.App.Rendering.Wb.WbMeshAdapter(_gl, _datDir, wbLogger);
_wbMeshAdapter = new AcDream.App.Rendering.Wb.WbMeshAdapter(_gl, _datDir, _dats, wbLogger);
Console.WriteLine("[N.4] WbFoundation flag is ENABLED — routing static content through ObjectMeshManager.");
}
@ -1485,13 +1487,21 @@ public sealed class GameWindow : IDisposable
new NullAnimLoader());
}
wbEntitySpawnAdapter = new AcDream.App.Rendering.Wb.EntitySpawnAdapter(
_textureCache, SequencerFactory);
_textureCache, SequencerFactory, _wbMeshAdapter);
_wbEntitySpawnAdapter = wbEntitySpawnAdapter;
}
_worldState = new AcDream.App.Streaming.GpuWorldState(wbSpawnAdapter, wbEntitySpawnAdapter);
}
_staticMesh = new InstancedMeshRenderer(_gl, _meshShader, _textureCache, _wbMeshAdapter);
if (AcDream.App.Rendering.Wb.WbFoundationFlag.IsEnabled
&& _wbMeshAdapter is not null && _wbEntitySpawnAdapter is not null)
{
_wbDrawDispatcher = new AcDream.App.Rendering.Wb.WbDrawDispatcher(
_gl, _meshShader, _textureCache, _wbMeshAdapter, _wbEntitySpawnAdapter);
}
// Phase G.1 sky renderer — its own shader (sky.vert / sky.frag)
// with depth writes off + far plane 1e6 so celestial meshes
// never clip. Shares the TextureCache with the static pipeline.
@ -2407,6 +2417,19 @@ public sealed class GameWindow : IDisposable
SubPalettes: ranges);
}
AcDream.Core.World.PartOverride[] entityPartOverrides;
if (animPartChanges.Count == 0)
{
entityPartOverrides = Array.Empty<AcDream.Core.World.PartOverride>();
}
else
{
entityPartOverrides = new AcDream.Core.World.PartOverride[animPartChanges.Count];
for (int i = 0; i < animPartChanges.Count; i++)
entityPartOverrides[i] = new AcDream.Core.World.PartOverride(
animPartChanges[i].PartIndex, animPartChanges[i].NewModelId);
}
var entity = new AcDream.Core.World.WorldEntity
{
Id = _liveEntityIdCounter++,
@ -2416,6 +2439,7 @@ public sealed class GameWindow : IDisposable
Rotation = rot,
MeshRefs = meshRefs,
PaletteOverride = paletteOverride,
PartOverrides = entityPartOverrides,
};
var snapshot = new AcDream.Plugin.Abstractions.WorldEntitySnapshot(
@ -6312,10 +6336,20 @@ public sealed class GameWindow : IDisposable
animatedIds.Add(k);
}
_staticMesh?.Draw(camera, _worldState.LandblockEntries, frustum,
neverCullLandblockId: playerLb,
visibleCellIds: visibility?.VisibleCellIds,
animatedEntityIds: animatedIds);
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);
}
// Phase G.1 / E.3: draw all live particles after opaque
// scene geometry so alpha blending composites correctly.
@ -8696,6 +8730,7 @@ public sealed class GameWindow : IDisposable
_combatChatTranslator?.Dispose();
_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();

View file

@ -123,10 +123,23 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
uint surfaceId,
uint? overrideOrigTextureId,
PaletteOverride paletteOverride)
=> GetOrUploadWithPaletteOverride(surfaceId, overrideOrigTextureId, paletteOverride,
HashPaletteOverride(paletteOverride));
/// <summary>
/// Overload that accepts a precomputed palette hash. Lets callers (e.g.
/// the WB draw dispatcher) compute the hash ONCE per entity and reuse
/// it across every (part, batch) lookup, avoiding the per-batch
/// FNV-1a fold over <see cref="PaletteOverride.SubPalettes"/>.
/// </summary>
public uint GetOrUploadWithPaletteOverride(
uint surfaceId,
uint? overrideOrigTextureId,
PaletteOverride paletteOverride,
ulong precomputedPaletteHash)
{
ulong hash = HashPaletteOverride(paletteOverride);
uint origTexKey = overrideOrigTextureId ?? 0;
var key = (surfaceId, origTexKey, hash);
var key = (surfaceId, origTexKey, precomputedPaletteHash);
if (_handlesByPalette.TryGetValue(key, out var h))
return h;
@ -138,9 +151,10 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab
/// <summary>
/// Cheap 64-bit hash over a palette override's identity so two
/// entities with the same palette setup share a decode.
/// entities with the same palette setup share a decode. Internal so
/// the WB dispatcher can compute it once per entity.
/// </summary>
private static ulong HashPaletteOverride(PaletteOverride p)
internal static ulong HashPaletteOverride(PaletteOverride p)
{
// Not cryptographic — just needs to distinguish override setups
// for caching. Start with base palette id, fold in each entry.

View file

@ -39,26 +39,28 @@ namespace AcDream.App.Rendering.Wb;
/// </para>
///
/// <para>
/// <b>Adjustment 4</b>: <see cref="WorldEntity"/> does not currently expose
/// <c>HiddenPartsMask</c> or <c>AnimPartChanges</c> as direct fields (those
/// live on the network-layer spawn record and are consumed upstream before
/// the <see cref="WorldEntity"/> is built). When those fields are promoted to
/// <see cref="WorldEntity"/>, <see cref="OnCreate"/> should call
/// <see cref="AnimatedEntityState.HideParts"/> and
/// <see cref="AnimatedEntityState.SetPartOverride"/> here. For now the mask
/// stays at 0 (no parts hidden) and no part overrides are set — the draw
/// dispatcher falls through to Setup defaults for every part.
/// <b>Adjustment 6</b> (resolved Adjustment 4): <see cref="WorldEntity"/> now
/// carries <see cref="WorldEntity.PartOverrides"/> and
/// <see cref="WorldEntity.HiddenPartsMask"/>. <see cref="OnCreate"/> applies
/// both to the created <see cref="AnimatedEntityState"/>.
/// </para>
/// </summary>
public sealed class EntitySpawnAdapter
{
private readonly ITextureCachePerInstance _textureCache;
private readonly Func<WorldEntity, AnimationSequencer> _sequencerFactory;
private readonly IWbMeshAdapter? _meshAdapter;
// Per-server-guid state. Written on OnCreate, released on OnRemove.
// Single-threaded: called only from the render thread (same as GpuWorldState).
private readonly Dictionary<uint, AnimatedEntityState> _stateByGuid = new();
// Per-server-guid set of GfxObj ids registered with the mesh adapter,
// so OnRemove can decrement each. Per-instance entities don't go through
// LandblockSpawnAdapter, so without this their meshes would never load
// (WB doesn't know they exist).
private readonly Dictionary<uint, HashSet<ulong>> _meshIdsByGuid = new();
/// <param name="textureCache">
/// Per-instance texture decode path. In production this is the
/// <see cref="TextureCache"/> instance (which implements
@ -71,14 +73,23 @@ public sealed class EntitySpawnAdapter
/// and server-supplied motion table override. Tests pass a lambda that
/// returns a stub sequencer.
/// </param>
/// <param name="meshAdapter">
/// Optional WB mesh adapter. When non-null, <see cref="OnCreate"/>
/// registers each unique <c>MeshRef.GfxObjId</c> with the adapter so WB
/// background-loads the mesh data; <see cref="OnRemove"/> decrements the
/// matching ref counts. When null, the adapter only tracks per-instance
/// state without driving WB lifecycle (test mode + flag-off mode).
/// </param>
public EntitySpawnAdapter(
ITextureCachePerInstance textureCache,
Func<WorldEntity, AnimationSequencer> sequencerFactory)
Func<WorldEntity, AnimationSequencer> sequencerFactory,
IWbMeshAdapter? meshAdapter = null)
{
ArgumentNullException.ThrowIfNull(textureCache);
ArgumentNullException.ThrowIfNull(sequencerFactory);
_textureCache = textureCache;
_sequencerFactory = sequencerFactory;
_meshAdapter = meshAdapter;
}
/// <summary>
@ -125,13 +136,29 @@ public sealed class EntitySpawnAdapter
var sequencer = _sequencerFactory(entity);
var state = new AnimatedEntityState(sequencer);
// Adjustment 4 placeholder: when WorldEntity gains HiddenPartsMask +
// AnimPartChanges fields, apply them here:
// state.HideParts(entity.HiddenPartsMask);
// foreach (var apc in entity.AnimPartChanges)
// state.SetPartOverride(apc.PartIndex, apc.NewModelId);
// Adjustment 6: WorldEntity now carries PartOverrides + HiddenPartsMask.
state.HideParts(entity.HiddenPartsMask);
foreach (var po in entity.PartOverrides)
state.SetPartOverride(po.PartIndex, po.GfxObjId);
_stateByGuid[entity.ServerGuid] = state;
// Register each unique GfxObj id with WB so the meshes background-load.
// Includes both the entity's natural MeshRefs AND any server-sent
// PartOverride GfxObjs (weapons, clothing, helmets) — those replace the
// Setup default and need their own mesh data uploaded.
if (_meshAdapter is not null)
{
var unique = new HashSet<ulong>();
foreach (var meshRef in entity.MeshRefs)
unique.Add((ulong)meshRef.GfxObjId);
foreach (var po in entity.PartOverrides)
unique.Add((ulong)po.GfxObjId);
_meshIdsByGuid[entity.ServerGuid] = unique;
foreach (var id in unique) _meshAdapter.IncrementRefCount(id);
}
return state;
}
@ -140,7 +167,16 @@ public sealed class EntitySpawnAdapter
/// on <c>RemoveObject</c>. Unknown guids (never spawned, or already
/// removed) are silently ignored.
/// </summary>
public void OnRemove(uint serverGuid) => _stateByGuid.Remove(serverGuid);
public void OnRemove(uint serverGuid)
{
_stateByGuid.Remove(serverGuid);
if (_meshAdapter is not null && _meshIdsByGuid.TryGetValue(serverGuid, out var ids))
{
foreach (var id in ids) _meshAdapter.DecrementRefCount(id);
_meshIdsByGuid.Remove(serverGuid);
}
}
/// <summary>
/// Look up the <see cref="AnimatedEntityState"/> for a server guid.

View file

@ -0,0 +1,521 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Meshing;
using AcDream.Core.Terrain;
using AcDream.Core.World;
using Chorizite.OpenGLSDLBackend.Lib;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Draws entities using WB's <see cref="ObjectRenderData"/> (a single global
/// VAO/VBO/IBO under modern rendering) with acdream's <see cref="TextureCache"/>
/// for texture resolution and <see cref="AcSurfaceMetadataTable"/> for
/// translucency classification.
///
/// <para>
/// <b>Atlas-tier</b> entities (<c>ServerGuid == 0</c>): mesh data comes from WB's
/// <see cref="ObjectMeshManager"/> via <see cref="WbMeshAdapter.TryGetRenderData"/>.
/// Textures resolve through <see cref="TextureCache.GetOrUpload"/> using the batch's
/// <c>SurfaceId</c>.
/// </para>
///
/// <para>
/// <b>Per-instance-tier</b> entities (<c>ServerGuid != 0</c>): mesh data also from
/// WB, but textures resolve through <see cref="TextureCache"/> with palette and
/// surface overrides applied. <see cref="AnimatedEntityState"/> is currently
/// unused at draw time — GameWindow's spawn path already bakes AnimPartChanges +
/// GfxObjDegradeResolver (Issue #47 close-detail mesh) into <c>MeshRefs</c>.
/// </para>
///
/// <para>
/// <b>GL strategy:</b> GROUPED instanced drawing. All visible (entity, batch)
/// pairs are bucketed by <see cref="GroupKey"/>; within a group a single
/// <c>glDrawElementsInstancedBaseVertexBaseInstance</c> renders all instances.
/// All matrices for the frame land in one shared instance VBO via a single
/// <c>BufferData</c> upload. This drops draw calls from O(entities×batches)
/// to O(unique GfxObj×batch×texture) — typically two orders of magnitude fewer.
/// </para>
///
/// <para>
/// <b>Shader:</b> reuses <c>mesh_instanced</c> (vert locations 0-2 = Position/
/// Normal/UV from WB's <c>VertexPositionNormalTexture</c>; locations 3-6 = instance
/// matrix from our VBO). WB's 32-byte vertex stride is compatible.
/// </para>
///
/// <para>
/// <b>Modern rendering assumption:</b> WB's <c>_useModernRendering</c> path (GL
/// 4.3 + bindless) puts every mesh in a single shared VAO/VBO/IBO and uses
/// <c>FirstIndex</c> + <c>BaseVertex</c> per batch. The dispatcher honors those
/// offsets via <c>DrawElementsInstancedBaseVertex(BaseInstance)</c>. The legacy
/// per-mesh-VAO path also works since FirstIndex/BaseVertex are zero there.
/// </para>
/// </summary>
public sealed unsafe class WbDrawDispatcher : IDisposable
{
private readonly GL _gl;
private readonly Shader _shader;
private readonly TextureCache _textures;
private readonly WbMeshAdapter _meshAdapter;
private readonly EntitySpawnAdapter _entitySpawnAdapter;
private readonly uint _instanceVbo;
private readonly HashSet<uint> _patchedVaos = new();
// Per-frame scratch — reused across frames to avoid per-frame allocation.
private readonly Dictionary<GroupKey, InstanceGroup> _groups = new();
private readonly List<InstanceGroup> _opaqueDraws = new();
private readonly List<InstanceGroup> _translucentDraws = new();
private float[] _instanceBuffer = new float[256 * 16]; // grow on demand, never shrink
// Per-entity-cull AABB radius. Conservative — covers most entities; large
// outliers (long banners, tall columns) are still landblock-culled.
private const float PerEntityCullRadius = 5.0f;
private bool _disposed;
// Diagnostic counters logged once per ~5s under ACDREAM_WB_DIAG=1.
private int _entitiesSeen;
private int _entitiesDrawn;
private int _meshesMissing;
private int _drawsIssued;
private int _instancesIssued;
private long _lastLogTick;
public WbDrawDispatcher(
GL gl,
Shader shader,
TextureCache textures,
WbMeshAdapter meshAdapter,
EntitySpawnAdapter entitySpawnAdapter)
{
ArgumentNullException.ThrowIfNull(gl);
ArgumentNullException.ThrowIfNull(shader);
ArgumentNullException.ThrowIfNull(textures);
ArgumentNullException.ThrowIfNull(meshAdapter);
ArgumentNullException.ThrowIfNull(entitySpawnAdapter);
_gl = gl;
_shader = shader;
_textures = textures;
_meshAdapter = meshAdapter;
_entitySpawnAdapter = entitySpawnAdapter;
_instanceVbo = _gl.GenBuffer();
}
public static Matrix4x4 ComposePartWorldMatrix(
Matrix4x4 entityWorld,
Matrix4x4 animOverride,
Matrix4x4 restPose)
=> restPose * animOverride * entityWorld;
public void Draw(
ICamera camera,
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList<WorldEntity> Entities)> landblockEntries,
FrustumPlanes? frustum = null,
uint? neverCullLandblockId = null,
HashSet<uint>? visibleCellIds = null,
HashSet<uint>? animatedEntityIds = null)
{
_shader.Use();
var vp = camera.View * camera.Projection;
_shader.SetMatrix4("uViewProjection", vp);
bool diag = string.Equals(Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG"), "1", StringComparison.Ordinal);
// Camera world-space position for front-to-back sort (perf #2). The view
// matrix is the inverse of the camera's world transform, so the world
// translation lives in the inverse's translation row.
Vector3 camPos = Vector3.Zero;
if (Matrix4x4.Invert(camera.View, out var invView))
camPos = invView.Translation;
// ── Phase 1: clear groups, walk entities, build groups ──────────────
foreach (var grp in _groups.Values) grp.Matrices.Clear();
var metaTable = _meshAdapter.MetadataTable;
uint anyVao = 0;
foreach (var entry in landblockEntries)
{
bool landblockVisible = frustum is null
|| entry.LandblockId == neverCullLandblockId
|| FrustumCuller.IsAabbVisible(frustum.Value, entry.AabbMin, entry.AabbMax);
if (!landblockVisible && (animatedEntityIds is null || animatedEntityIds.Count == 0))
continue;
foreach (var entity in entry.Entities)
{
if (entity.MeshRefs.Count == 0) continue;
bool isAnimated = animatedEntityIds?.Contains(entity.Id) == true;
if (!landblockVisible && !isAnimated) continue;
if (entity.ParentCellId.HasValue && visibleCellIds is not null
&& !visibleCellIds.Contains(entity.ParentCellId.Value))
continue;
// Per-entity AABB frustum cull (perf #3). Skips work for distant
// entities even when their landblock is visible. Animated
// entities bypass — they're tracked at landblock level + need
// per-frame work for animation regardless. Conservative 5m
// radius covers typical entity bounds.
if (frustum is not null && !isAnimated && entry.LandblockId != neverCullLandblockId)
{
var p = entity.Position;
var aMin = new Vector3(p.X - PerEntityCullRadius, p.Y - PerEntityCullRadius, p.Z - PerEntityCullRadius);
var aMax = new Vector3(p.X + PerEntityCullRadius, p.Y + PerEntityCullRadius, p.Z + PerEntityCullRadius);
if (!FrustumCuller.IsAabbVisible(frustum.Value, aMin, aMax))
continue;
}
if (diag) _entitiesSeen++;
var entityWorld =
Matrix4x4.CreateFromQuaternion(entity.Rotation) *
Matrix4x4.CreateTranslation(entity.Position);
// Compute palette-override hash ONCE per entity (perf #4).
// Reused across every (part, batch) lookup so the FNV-1a fold
// over SubPalettes runs once instead of N times. Zero when the
// entity has no palette override (trees, scenery).
ulong palHash = 0;
if (entity.PaletteOverride is not null)
palHash = TextureCache.HashPaletteOverride(entity.PaletteOverride);
bool drewAny = false;
for (int partIdx = 0; partIdx < entity.MeshRefs.Count; partIdx++)
{
// Note: GameWindow's spawn path already applies
// AnimPartChanges + GfxObjDegradeResolver (Issue #47 fix —
// close-detail mesh swap for humanoids) to MeshRefs. We
// trust MeshRefs as the source of truth here. AnimatedEntityState's
// overrides become relevant only for hot-swap (0xF625
// ObjDescEvent) which today rebuilds MeshRefs anyway.
var meshRef = entity.MeshRefs[partIdx];
ulong gfxObjId = meshRef.GfxObjId;
var renderData = _meshAdapter.TryGetRenderData(gfxObjId);
if (renderData is null)
{
if (diag) _meshesMissing++;
continue;
}
drewAny = true;
if (anyVao == 0) anyVao = renderData.VAO;
if (renderData.IsSetup && renderData.SetupParts.Count > 0)
{
foreach (var (partGfxObjId, partTransform) in renderData.SetupParts)
{
var partData = _meshAdapter.TryGetRenderData(partGfxObjId);
if (partData is null) continue;
var model = ComposePartWorldMatrix(
entityWorld, meshRef.PartTransform, partTransform);
ClassifyBatches(partData, partGfxObjId, model, entity, meshRef, palHash, metaTable);
}
}
else
{
var model = meshRef.PartTransform * entityWorld;
ClassifyBatches(renderData, gfxObjId, model, entity, meshRef, palHash, metaTable);
}
}
if (diag && drewAny) _entitiesDrawn++;
}
}
// Nothing visible — skip the GL pass entirely.
if (anyVao == 0)
{
if (diag) MaybeFlushDiag();
return;
}
// ── Phase 2: lay matrices out contiguously, assign per-group offsets,
// split into opaque/translucent + compute sort keys ─────────
int totalInstances = 0;
foreach (var grp in _groups.Values) totalInstances += grp.Matrices.Count;
if (totalInstances == 0)
{
if (diag) MaybeFlushDiag();
return;
}
int needed = totalInstances * 16;
if (_instanceBuffer.Length < needed)
_instanceBuffer = new float[needed + 256 * 16]; // headroom
_opaqueDraws.Clear();
_translucentDraws.Clear();
int cursor = 0;
foreach (var grp in _groups.Values)
{
if (grp.Matrices.Count == 0) continue;
grp.FirstInstance = cursor;
grp.InstanceCount = grp.Matrices.Count;
// Use the first instance's translation as the group's representative
// position for front-to-back sort (perf #2). Cheap heuristic; works
// well when instances of one group are spatially coherent
// (typical for trees in one landblock area, NPCs at one spawn).
var firstM = grp.Matrices[0];
var grpPos = new Vector3(firstM.M41, firstM.M42, firstM.M43);
grp.SortDistance = Vector3.DistanceSquared(camPos, grpPos);
for (int i = 0; i < grp.Matrices.Count; i++)
{
WriteMatrix(_instanceBuffer, cursor * 16, grp.Matrices[i]);
cursor++;
}
if (grp.Translucency == TranslucencyKind.Opaque || grp.Translucency == TranslucencyKind.ClipMap)
_opaqueDraws.Add(grp);
else
_translucentDraws.Add(grp);
}
// Front-to-back sort for opaque pass: nearer groups draw first so the
// depth test rejects fragments hidden behind them, reducing fragment
// shader cost from overdraw on dense scenes (Holtburg courtyard,
// Foundry interior).
_opaqueDraws.Sort(static (a, b) => a.SortDistance.CompareTo(b.SortDistance));
// ── Phase 3: one upload of all matrices ─────────────────────────────
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _instanceVbo);
fixed (float* p = _instanceBuffer)
_gl.BufferData(BufferTargetARB.ArrayBuffer,
(nuint)(totalInstances * 16 * sizeof(float)), p, BufferUsageARB.DynamicDraw);
// ── Phase 4: bind VAO once (modern rendering shares one global VAO) ──
EnsureInstanceAttribs(anyVao);
_gl.BindVertexArray(anyVao);
// ── Phase 5: opaque + ClipMap pass (front-to-back sorted) ───────────
if (string.Equals(Environment.GetEnvironmentVariable("ACDREAM_NO_CULL"), "1", StringComparison.Ordinal))
_gl.Disable(EnableCap.CullFace);
foreach (var grp in _opaqueDraws)
{
_shader.SetInt("uTranslucencyKind", (int)grp.Translucency);
DrawGroup(grp);
}
// ── Phase 6: translucent pass ───────────────────────────────────────
_gl.Enable(EnableCap.Blend);
_gl.DepthMask(false);
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 grp in _translucentDraws)
{
switch (grp.Translucency)
{
case TranslucencyKind.Additive:
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.One);
break;
case TranslucencyKind.InvAlpha:
_gl.BlendFunc(BlendingFactor.OneMinusSrcAlpha, BlendingFactor.SrcAlpha);
break;
default:
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
break;
}
_shader.SetInt("uTranslucencyKind", (int)grp.Translucency);
DrawGroup(grp);
}
_gl.DepthMask(true);
_gl.Disable(EnableCap.Blend);
_gl.Disable(EnableCap.CullFace);
_gl.BindVertexArray(0);
if (diag)
{
_drawsIssued += _opaqueDraws.Count + _translucentDraws.Count;
_instancesIssued += totalInstances;
MaybeFlushDiag();
}
}
private void DrawGroup(InstanceGroup grp)
{
_gl.ActiveTexture(TextureUnit.Texture0);
_gl.BindTexture(TextureTarget.Texture2D, grp.TextureHandle);
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, grp.Ibo);
// BaseInstance offsets the per-instance attribute fetches into our
// shared instance VBO so each group reads its own slice. Requires
// GL_ARB_base_instance (GL 4.2+); WB requires 4.3 so this is available.
_gl.DrawElementsInstancedBaseVertexBaseInstance(
PrimitiveType.Triangles,
(uint)grp.IndexCount,
DrawElementsType.UnsignedShort,
(void*)(grp.FirstIndex * sizeof(ushort)),
(uint)grp.InstanceCount,
grp.BaseVertex,
(uint)grp.FirstInstance);
}
private void MaybeFlushDiag()
{
long now = Environment.TickCount64;
if (now - _lastLogTick > 5000)
{
Console.WriteLine(
$"[WB-DIAG] entSeen={_entitiesSeen} entDrawn={_entitiesDrawn} meshMissing={_meshesMissing} drawsIssued={_drawsIssued} instances={_instancesIssued} groups={_groups.Count}");
_entitiesSeen = _entitiesDrawn = _meshesMissing = _drawsIssued = _instancesIssued = 0;
_lastLogTick = now;
}
}
private void ClassifyBatches(
ObjectRenderData renderData,
ulong gfxObjId,
Matrix4x4 model,
WorldEntity entity,
MeshRef meshRef,
ulong palHash,
AcSurfaceMetadataTable metaTable)
{
for (int batchIdx = 0; batchIdx < renderData.Batches.Count; batchIdx++)
{
var batch = renderData.Batches[batchIdx];
TranslucencyKind translucency;
if (metaTable.TryLookup(gfxObjId, batchIdx, out var meta))
{
translucency = meta.Translucency;
}
else
{
translucency = batch.IsAdditive ? TranslucencyKind.Additive
: batch.IsTransparent ? TranslucencyKind.AlphaBlend
: TranslucencyKind.Opaque;
}
uint texHandle = ResolveTexture(entity, meshRef, batch, palHash);
if (texHandle == 0) continue;
var key = new GroupKey(
batch.IBO, batch.FirstIndex, (int)batch.BaseVertex,
batch.IndexCount, texHandle, translucency);
if (!_groups.TryGetValue(key, out var grp))
{
grp = new InstanceGroup
{
Ibo = batch.IBO,
FirstIndex = batch.FirstIndex,
BaseVertex = (int)batch.BaseVertex,
IndexCount = batch.IndexCount,
TextureHandle = texHandle,
Translucency = translucency,
};
_groups[key] = grp;
}
grp.Matrices.Add(model);
}
}
private uint ResolveTexture(WorldEntity entity, MeshRef meshRef, ObjectRenderBatch batch, ulong palHash)
{
// WB stores the surface id on batch.Key.SurfaceId (TextureKey struct);
// batch.SurfaceId is unset (zero) for batches built by ObjectMeshManager.
uint surfaceId = batch.Key.SurfaceId;
if (surfaceId == 0 || surfaceId == 0xFFFFFFFF) return 0;
uint overrideOrigTex = 0;
bool hasOrigTexOverride = meshRef.SurfaceOverrides is not null
&& meshRef.SurfaceOverrides.TryGetValue(surfaceId, out overrideOrigTex);
uint? origTexOverride = hasOrigTexOverride ? overrideOrigTex : (uint?)null;
if (entity.PaletteOverride is not null)
{
// perf #4: pass the entity-precomputed palette hash so TextureCache
// can skip its internal HashPaletteOverride for repeat lookups
// within the same character.
return _textures.GetOrUploadWithPaletteOverride(
surfaceId, origTexOverride, entity.PaletteOverride, palHash);
}
else if (hasOrigTexOverride)
{
return _textures.GetOrUploadWithOrigTextureOverride(surfaceId, overrideOrigTex);
}
else
{
return _textures.GetOrUpload(surfaceId);
}
}
private void EnsureInstanceAttribs(uint vao)
{
if (!_patchedVaos.Add(vao)) return;
_gl.BindVertexArray(vao);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _instanceVbo);
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);
}
}
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;
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_gl.DeleteBuffer(_instanceVbo);
}
private readonly record struct GroupKey(
uint Ibo,
uint FirstIndex,
int BaseVertex,
int IndexCount,
uint TextureHandle,
TranslucencyKind Translucency);
private sealed class InstanceGroup
{
public uint Ibo;
public uint FirstIndex;
public int BaseVertex;
public int IndexCount;
public uint TextureHandle;
public TranslucencyKind Translucency;
public int FirstInstance; // offset into the shared instance VBO (in instances, not bytes)
public int InstanceCount;
public float SortDistance; // squared distance from camera to first instance, for opaque sort
public readonly List<Matrix4x4> Matrices = new();
}
}

View file

@ -7,18 +7,25 @@ namespace AcDream.App.Rendering.Wb;
/// free at hot-path cadence).
///
/// <para>
/// Set <c>ACDREAM_USE_WB_FOUNDATION=1</c> to route static-scenery + atlas
/// content through WB's <c>ObjectMeshManager</c>; per-instance customized
/// content (server <c>CreateObject</c> entities) takes the existing
/// <see cref="TextureCache.GetOrUploadWithPaletteOverride"/> path either
/// way. Flag becomes default-on at end of Phase N.4 after visual
/// verification.
/// <b>Default-on as of Phase N.4 ship (2026-05-08).</b> The WB foundation
/// (<c>WbMeshAdapter</c> + <c>WbDrawDispatcher</c>) is the production
/// rendering path. Set <c>ACDREAM_USE_WB_FOUNDATION=0</c> to fall back
/// to the legacy <c>InstancedMeshRenderer</c> path — kept as an escape
/// hatch until N.6 fully replaces it.
/// </para>
///
/// <para>
/// Per-instance customized content (server <c>CreateObject</c> entities
/// with palette / texture overrides) routes through
/// <see cref="TextureCache.GetOrUploadWithPaletteOverride"/> regardless
/// of the flag — the flag controls which DRAW path consumes those
/// textures.
/// </para>
/// </summary>
public static class WbFoundationFlag
{
private static bool _isEnabled =
System.Environment.GetEnvironmentVariable("ACDREAM_USE_WB_FOUNDATION") == "1";
System.Environment.GetEnvironmentVariable("ACDREAM_USE_WB_FOUNDATION") != "0";
public static bool IsEnabled => _isEnabled;

View file

@ -1,6 +1,10 @@
using System;
using System.Collections.Generic;
using AcDream.Core.Meshing;
using Chorizite.OpenGLSDLBackend;
using Chorizite.OpenGLSDLBackend.Lib;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Silk.NET.OpenGL;
@ -26,6 +30,9 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
private readonly OpenGLGraphicsDevice? _graphicsDevice;
private readonly DefaultDatReaderWriter? _wbDats;
private readonly ObjectMeshManager? _meshManager;
private readonly DatCollection? _dats;
private readonly AcSurfaceMetadataTable _metadataTable = new();
private readonly HashSet<ulong> _metadataPopulated = new();
/// <summary>
/// True when this instance was created via <see cref="CreateUninitialized"/>;
@ -43,14 +50,19 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
/// thread (construction runs GL queries; call from OnLoad).</param>
/// <param name="datDir">Path to the dat directory (same as the one supplied
/// to our DatCollection). DefaultDatReaderWriter opens its own file handles.</param>
/// <param name="dats">acdream's DatCollection, used to populate the surface
/// metadata side-table via <c>GfxObjMesh.Build</c>. Shares file handles with
/// the rest of the client; read-only access from the render thread.</param>
/// <param name="logger">Logger for the adapter; ObjectMeshManager uses
/// NullLogger internally.</param>
public WbMeshAdapter(GL gl, string datDir, ILogger<WbMeshAdapter> logger)
public WbMeshAdapter(GL gl, string datDir, DatCollection dats, ILogger<WbMeshAdapter> logger)
{
ArgumentNullException.ThrowIfNull(gl);
ArgumentNullException.ThrowIfNull(datDir);
ArgumentNullException.ThrowIfNull(dats);
ArgumentNullException.ThrowIfNull(logger);
_dats = dats;
_graphicsDevice = new OpenGLGraphicsDevice(gl, logger, new DebugRenderSettings());
_wbDats = new DefaultDatReaderWriter(datDir);
_meshManager = new ObjectMeshManager(
@ -70,9 +82,18 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
/// underlying mesh manager. Public methods are all no-ops.</summary>
public static WbMeshAdapter CreateUninitialized() => new();
/// <summary>
/// The surface metadata side-table populated on each first
/// <see cref="IncrementRefCount"/>. Queried by the draw dispatcher
/// to determine translucency, luminosity, and fog behavior per batch.
/// </summary>
public AcSurfaceMetadataTable MetadataTable => _metadataTable;
/// <summary>
/// Returns the WB render data for <paramref name="id"/>, or null if not
/// yet uploaded or if this adapter is uninitialized.
/// yet uploaded or if this adapter is uninitialized. Increments WB's
/// internal usage counter — use <see cref="TryGetRenderData"/> for
/// render-loop lookups that should not affect lifecycle.
/// </summary>
public ObjectRenderData? GetRenderData(ulong id)
{
@ -80,11 +101,40 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
return _meshManager.GetRenderData(id);
}
/// <summary>
/// Returns the WB render data for <paramref name="id"/> without
/// modifying reference counts. Returns null if the mesh is not yet
/// uploaded. Safe for render-loop lookups.
/// </summary>
public ObjectRenderData? TryGetRenderData(ulong id)
{
if (_isUninitialized || _meshManager is null) return null;
return _meshManager.TryGetRenderData(id);
}
/// <inheritdoc/>
public void IncrementRefCount(ulong id)
{
if (_isUninitialized || _meshManager is null) return;
_meshManager.IncrementRefCount(id);
if (_metadataPopulated.Add(id))
{
PopulateMetadata(id);
// WB's IncrementRefCount alone only bumps a usage counter; it does
// NOT trigger mesh loading. We must explicitly call PrepareMeshDataAsync
// so the background workers actually decode the GfxObj. The result
// auto-enqueues into _stagedMeshData (ObjectMeshManager line 510),
// which Tick() drains onto the GPU. Until that completes,
// TryGetRenderData(id) returns null and the dispatcher silently
// skips the entity — standard streaming flicker.
//
// isSetup: false — acdream's MeshRefs already carry expanded
// per-part GfxObj ids (0x01XXXXXX). WB's Setup-expansion path is
// unused.
_ = _meshManager.PrepareMeshDataAsync(id, isSetup: false);
}
}
/// <inheritdoc/>
@ -126,6 +176,21 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
}
}
private void PopulateMetadata(ulong id)
{
if (_dats is null) return;
if (!_dats.Portal.TryGet<GfxObj>((uint)id, out var gfxObj)) return;
var subMeshes = GfxObjMesh.Build(gfxObj, _dats);
for (int i = 0; i < subMeshes.Count; i++)
{
var sm = subMeshes[i];
_metadataTable.Add(id, i, new AcSurfaceMetadata(
sm.Translucency, sm.Luminosity, sm.Diffuse,
sm.SurfOpacity, sm.NeedsUvRepeat, sm.DisableFog));
}
}
/// <inheritdoc/>
public void Dispose()
{

View file

@ -55,4 +55,27 @@ public sealed class WorldEntity
/// visible trunk, producing "partial passthrough" bugs.
/// </summary>
public float Scale { get; init; } = 1.0f;
/// <summary>
/// Server-sent part-swap overrides from <c>AnimPartChange</c>. Each entry
/// replaces a Setup part's GfxObj with an alternate model (clothing, weapons,
/// helmets). Carried on the entity so <c>EntitySpawnAdapter</c> can populate
/// <c>AnimatedEntityState</c>'s override map at spawn time. Empty for atlas-
/// tier entities.
/// </summary>
public IReadOnlyList<PartOverride> PartOverrides { get; init; } = Array.Empty<PartOverride>();
/// <summary>
/// Bitmask of hidden Setup parts. Bit <c>i</c> set hides part <c>i</c> at
/// draw time. Sourced from the server's <c>CreateObject</c> record when
/// present. Zero (no parts hidden) is the default.
/// </summary>
public ulong HiddenPartsMask { get; init; }
}
/// <summary>
/// Lightweight value type for a server-sent <c>AnimPartChange</c> (part index
/// → replacement GfxObj id). Decouples <c>WorldEntity</c> (Core) from the
/// network-layer <c>CreateObject.AnimPartChange</c> type.
/// </summary>
public readonly record struct PartOverride(byte PartIndex, uint GfxObjId);

View file

@ -0,0 +1,64 @@
using System.Numerics;
using AcDream.App.Rendering.Wb;
namespace AcDream.Core.Tests.Rendering.Wb;
public sealed class MatrixCompositionTests
{
[Fact]
public void Compose_EntityAnimRest_ProducesExpectedWorldMatrix()
{
var entityWorld = Matrix4x4.CreateTranslation(100, 200, 300);
var animOverride = Matrix4x4.CreateRotationZ(MathF.PI / 4);
var restPose = Matrix4x4.CreateTranslation(1, 0, 0);
var result = WbDrawDispatcher.ComposePartWorldMatrix(entityWorld, animOverride, restPose);
var expected = restPose * animOverride * entityWorld;
AssertMatrixEqual(expected, result);
}
[Fact]
public void Compose_IdentityAnim_EqualsRestTimesEntity()
{
var entityWorld = Matrix4x4.CreateFromQuaternion(
Quaternion.CreateFromYawPitchRoll(0.5f, 0, 0)) *
Matrix4x4.CreateTranslation(10, 20, 30);
var restPose = Matrix4x4.CreateTranslation(0.5f, -0.3f, 0.1f);
var result = WbDrawDispatcher.ComposePartWorldMatrix(
entityWorld, Matrix4x4.Identity, restPose);
var expected = restPose * entityWorld;
AssertMatrixEqual(expected, result);
}
[Fact]
public void Compose_AllIdentity_ReturnsIdentity()
{
var result = WbDrawDispatcher.ComposePartWorldMatrix(
Matrix4x4.Identity, Matrix4x4.Identity, Matrix4x4.Identity);
AssertMatrixEqual(Matrix4x4.Identity, result);
}
private static void AssertMatrixEqual(Matrix4x4 expected, Matrix4x4 actual, float eps = 1e-5f)
{
Assert.Equal(expected.M11, actual.M11, eps);
Assert.Equal(expected.M12, actual.M12, eps);
Assert.Equal(expected.M13, actual.M13, eps);
Assert.Equal(expected.M14, actual.M14, eps);
Assert.Equal(expected.M21, actual.M21, eps);
Assert.Equal(expected.M22, actual.M22, eps);
Assert.Equal(expected.M23, actual.M23, eps);
Assert.Equal(expected.M24, actual.M24, eps);
Assert.Equal(expected.M31, actual.M31, eps);
Assert.Equal(expected.M32, actual.M32, eps);
Assert.Equal(expected.M33, actual.M33, eps);
Assert.Equal(expected.M34, actual.M34, eps);
Assert.Equal(expected.M41, actual.M41, eps);
Assert.Equal(expected.M42, actual.M42, eps);
Assert.Equal(expected.M43, actual.M43, eps);
Assert.Equal(expected.M44, actual.M44, eps);
}
}

View file

@ -14,7 +14,7 @@ public sealed class WbMeshAdapterTests
// We can't pass a real GL (no context in tests), so we verify only the
// null-GL guard. The real pipeline is tested via integration.
Assert.Throws<ArgumentNullException>(() =>
new WbMeshAdapter(gl: null!, datDir: "some/path", logger: NullLogger<WbMeshAdapter>.Instance));
new WbMeshAdapter(gl: null!, datDir: "some/path", dats: null!, logger: NullLogger<WbMeshAdapter>.Instance));
}
[Fact]