# Phase N.4 — Rendering Pipeline Foundation: Design **Date:** 2026-05-08 **Status:** Design complete, awaiting plan generation. **Parent design:** [2026-05-08-phase-n-worldbuilder-migration-design.md](2026-05-08-phase-n-worldbuilder-migration-design.md) **Roadmap entry:** [docs/plans/2026-04-11-roadmap.md](../../plans/2026-04-11-roadmap.md) — Phase N.4 **Inventory reference:** [docs/architecture/worldbuilder-inventory.md](../../architecture/worldbuilder-inventory.md) **Related:** [ISSUE #51](../../ISSUES.md) — terrain split formula divergence (handled in N.5). ## Goal Adopt WB's `Chorizite.OpenGLSDLBackend.Lib.ObjectMeshManager` and `TextureAtlasManager` as acdream's rendering pipeline foundation. This is the integration that unblocks Phases N.5 (terrain), N.6 (static objects), N.7 (env cells), N.8 (sky/particles), and absorbs N.10 (GL infrastructure consolidation). N.4 ships no visible change — the world should look identical to today; what changes is the infrastructure behind the scenes. ## Why **The roadmap's original "drop-in helper" framing was wrong for N.4.** Discovery during brainstorm 2026-05-08: WB's `ObjectMeshManager` is not a stateless helper class like `SceneryHelpers` (N.1) or `TextureHelpers` (N.3). It is a 2070-line stateful asset pipeline that owns: - GPU resources per object (VAO/VBO/IBO via `ObjectRenderData`) - Reference counting (`IncrementRefCount`/`DecrementRefCount`) - LRU cache + memory budget (default 1 GB) - Background-thread CPU mesh preparation, main-thread GPU upload - Shared texture atlases keyed by `(Width, Height, Format)` - Particle emitter staging - Modern bindless rendering path on capable hardware **There is no clean "just the mesh extraction" entry point.** WB's `BuildPolygonIndices` (the algorithm we already faithfully ported into [GfxObjMesh.cs](../../../src/AcDream.Core/Meshing/GfxObjMesh.cs)) is a private method tightly coupled to atlas batching. To use WB's tested infrastructure at all means adopting the whole pipeline. **N.5 + N.6 + N.7 build on this foundation.** WB's `TerrainRenderManager`, `StaticObjectRenderManager`, and `EnvCellRenderManager` all consume `ObjectMeshManager` (or its atlas) as substrate. Without N.4, each later phase would need to either fork those render managers or duplicate the infrastructure. Doing N.4 now means N.5/N.6/N.7 become integration phases on top of shared plumbing, not parallel infrastructure builds. **Real benefits beyond infrastructure consolidation:** 1. **Memory budget with LRU eviction** (we don't have this; bigger stream radii currently risk OOM). 2. **Texture atlasing → ~4-8× fewer draw calls** for static scenery (~1100 entities at Holtburg today). 3. **Background-thread mesh preparation** — addresses the render-thread-stall problem from [feedback_phase_a1_hotfix_saga.md](../../../memory/feedback_phase_a1_hotfix_saga.md) that forced us to revert async streaming. 4. **Bindless textures** on capable hardware (free perf when GL 4.3 + `GL_ARB_bindless_texture` are available). ## Architecture ### Two-tier rendering split acdream's content cleanly partitions into two categories that map onto two rendering paths: | Tier | Content | Why this category | Path | |---|---|---|---| | **Atlas (shared)** | Terrain props, scenery (procedural — trees / rocks / bushes / fences from ~50 templates), buildings, slabs, dungeon static geometry | Client-side procedural; no per-instance variation; many instances of few unique meshes | WB's `ObjectMeshManager` + `TextureAtlasManager`. Big sharing wins (1100 entities ↦ ~50 atlas slots). | | **Per-instance (customized)** | Server-spawned entities (`CreateObject`): characters, creatures, equipped items. Anything carrying `SubPalettes` / `TextureChanges` / `AnimPartChange` / `HiddenParts` / `GfxObjRemapping` | Always uniquely customized; few visible at a time (~10-50) | Existing [TextureCache.GetOrUploadWithPaletteOverride](../../../src/AcDream.App/Rendering/TextureCache.cs:122). Already hash-keys overrides for caching; already tested. | **Routing rule**: - Objects spawned by `LandblockStreamLoader` (procedural, no customization) → atlas tier. - Objects spawned by `CreateObject` (network, always customized) → per-instance tier. The boundary mirrors a distinction that already exists in our networking model. We are not inventing a new conceptual line; we are matching one that's already there. ### Animation handling **Core insight:** in AC, animation is per-part TRANSFORM changes, not mesh changes. A creature's Setup is a list of rigid GfxObj parts (head, body, hands, etc.). Each part is its own static mesh; vertices inside each part never change. Animation moves the parts as rigid bodies. This means **mesh data is static even for animated entities** — the cache works fine. Only the per-part transforms change per frame, and those don't live in the mesh cache. **Composition at draw time:** ``` final_part_world_matrix = entity_world_transform × animation_override (from AnimationSequencer, this frame) × rest_pose_transform (cached in ObjectMeshData.SetupParts) ``` - WB's `ObjectMeshData.SetupParts: List<(ulong GfxObjId, Matrix4x4 Transform)>` stores the rest-pose transforms (cached, shared). - Our existing [AnimationSequencer](../../../src/AcDream.Core/Animation/AnimationSequencer.cs) is **untouched**. It continues to produce per-part override matrices per frame, driven by motion table + current motion command + tick. - The renderer composes the three matrices per part per draw and pushes the result as a uniform/instance attribute. **`AnimPartChange`** (server swaps a part's GfxObj — e.g., wielding a sword): per-entity override map `Dictionary`. At draw time, look up override; fall back to cached Setup part. WB's mesh manager caches the override GfxObj's mesh data the same way as any other part — first time seen, then shared. **`HiddenParts`** (bitmask hiding parts): per-entity `ulong` bitmask. Draw loop: `if (hiddenMask & (1 << partIndex)) continue;`. **Per-frame CPU cost:** ~50 visible animated entities × ~20 parts = ~1000 matrix multiplies per frame. Sub-millisecond on any CPU. **GPU-side per-draw transform push:** start with uniform-per-draw (simple, ~1000 draws/frame for animated entities — fine). Promote to per-instance vertex attribute (instanced draw, ~50 draws/frame) only if measured perf demands it. ### Streaming loader integration Adapter shim, ~200 LOC, sits between `LandblockStreamLoader` / `WorldSession` and `ObjectMeshManager`: | Source event | Adapter call | What `ObjectMeshManager` does | |---|---|---| | Landblock loaded by streaming | `IncrementRefCount(id)` per unique GfxObj/Setup id in `Setups[]` + `Statics[]` | Begins CPU prep on background worker if not cached; queues GPU upload on main thread | | Landblock unloaded by streaming (radius hysteresis) | `DecrementRefCount(id)` per object | Drops to LRU when count reaches 0; LRU + 1 GB memory budget handles eviction | | Network `CreateObject` | Per-instance path: build `PaletteOverride` from `SubPalettes`, decode through `TextureCache.GetOrUploadWithPaletteOverride`, register entity-local mesh data | Bypasses WB atlas; stays in our existing per-instance path | | Network `RemoveObject` | Release per-instance state for entity | (no WB call) | **Pending-spawn list preservation:** the streaming loader's existing [pending-spawn list](../../../memory/feedback_phase_a1_hotfix_saga.md) mechanism stays in place. `CreateObject` arriving before its landblock streams in still parks until the landblock arrives, then drains. The adapter is invoked when the spawn drains, not when it parks. **Thread safety:** WB's `ObjectMeshManager` uses `ConcurrentDictionary` for its internal state and is designed to take `IncrementRefCount` calls from any thread. Our streaming worker can call it directly without marshaling onto the render thread. (This is part of why WB's design addresses the render-thread-stall problem.) ### Surface metadata strategy **Side-table, not fork patch.** WB's `MeshBatchData` carries `IsTransparent` + `IsAdditive`. We need to preserve these acdream-specific surface properties already present in our [GfxObjMesh.cs](../../../src/AcDream.Core/Meshing/GfxObjMesh.cs): - `Translucency` (`TranslucencyKind` enum: Opaque / AlphaBlend / Additive) - `Luminosity` (float, self-illumination coefficient — sky pass critical) - `Diffuse` (float) - `SurfOpacity` (float, derived from `Surface.Translucency`) - `NeedsUvRepeat` (bool, derived from authored UV range — sky-pass wrap-mode selection) - `DisableFog` (bool, derived from emissive surface flags — sky-pass fog skip) Our renderer integration maintains a side-table: `Dictionary<(ulong gfxObjId, int surfaceIdx), AcSurfaceMetadata>`. The key matches the shape of today's `GfxObjSubMesh` — a (GfxObj, surface index) pair uniquely identifies a per-surface render batch. Stable across `IncrementRefCount` cycles. The metadata is computed once at mesh-extraction time (matching today's `GfxObjMesh.Build`) and looked up at draw time. **Why side-table not fork patch:** - Keeps WB's types pristine; upstream merges stay clean. - Lookup cost is negligible (one hash lookup per batch per frame). - Easy to roll back if WB's design evolves to incorporate similar fields. - Preserves the careful sky-pass work done in C.1 with no risk to sky rendering during this migration. ### Fork hygiene **Target: zero fork patches for N.4.** WB's `acdream` branch stays at upstream `master` plus the editor-only file deletions inherited from N.0/N.1. If a fork patch becomes genuinely necessary mid-implementation (e.g., a public hook is missing for our customization layer), it lands as a single named patch with a comment explaining the rationale. Each patch is candidate to upstream back to Chorizite/WorldBuilder. ## Components ### New code (acdream-side) | File | Responsibility | |---|---| | `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs` | Bridges acdream's lifecycle events to `ObjectMeshManager`. Holds the `ObjectMeshManager` instance, exposes `IncrementRefCount` / `DecrementRefCount` / `GetRenderData` to the rest of the renderer. | | `src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs` | Streaming-loader hook. Walks `LandblockEntry.Setups[]` + `Statics[]`, calls `WbMeshAdapter` with unique ids. Companion `LandblockUnloadAdapter` for unload events. | | `src/AcDream.App/Rendering/Wb/EntitySpawnAdapter.cs` | Network-spawn hook. Routes `CreateObject` to per-instance path, `RemoveObject` to release. | | `src/AcDream.App/Rendering/Wb/AcSurfaceMetadata.cs` | Side-table type holding `Translucency` / `Luminosity` / `Diffuse` / `SurfOpacity` / `NeedsUvRepeat` / `DisableFog`. | | `src/AcDream.App/Rendering/Wb/AcSurfaceMetadataTable.cs` | The `Dictionary` side-table, populated at mesh-extraction time, queried at draw time. | | `src/AcDream.App/Rendering/Wb/AnimatedEntityState.cs` | Per-entity render state for animated entities: `partGfxObjOverrides` map (AnimPartChange), `hiddenMask` (HiddenParts), reference to `AnimationSequencer` for per-frame override matrices. | | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` | Per-frame draw loop. For each visible entity, looks up `ObjectRenderData`, composes per-part matrices (entity × animation × rest-pose), reads side-table metadata, issues GL draw. | ### Modified code (acdream-side) | File | Change | |---|---| | `src/AcDream.App/Rendering/StaticMeshRenderer.cs` | Replace internal mesh-data + GL-resource handling with calls into `WbMeshAdapter`. Public surface preserved for the rest of the renderer's call sites. **N.6 will fully replace this file**; N.4 leaves it in place as a thin adapter. | | `src/AcDream.App/Rendering/InstancedMeshRenderer.cs` | Same pattern — internal swap, public surface preserved. **N.6 fully replaces this file.** | | `src/AcDream.App/Rendering/TextureCache.cs` | Per-instance path stays. Atlas-tier callers (anything using `GetOrUpload(surfaceId)` for static content) route through `WbMeshAdapter` instead. The override paths (`GetOrUploadWithOrigTextureOverride`, `GetOrUploadWithPaletteOverride`) keep their current behavior. | | `src/AcDream.App/Rendering/GpuWorldState.cs` | Spawn/despawn callbacks route through `WbMeshAdapter`. Pending-spawn list mechanism preserved verbatim. | | `src/AcDream.App/Rendering/GameWindow.cs` | Construct `WbMeshAdapter` on init; dispose on shutdown. | | `src/AcDream.Core/Meshing/SetupMesh.cs` | Kept for tests + as the conformance-test reference implementation. Production callers route through `WbMeshAdapter`. | | `src/AcDream.Core/Meshing/GfxObjMesh.cs` | Kept for tests + conformance reference. Production callers route through `WbMeshAdapter`. | ## Data flow ### Spawn — landblock-streamed (atlas tier) ``` LandblockStreamLoader.Load(landblockId) → LandblockEntry { Setups, Statics, ... } → LandblockSpawnAdapter.OnLoaded(entry) for each unique gfxObjId in (entry.Setups ∪ entry.Statics): WbMeshAdapter.IncrementRefCount(gfxObjId) → ObjectMeshManager.IncrementRefCount(gfxObjId) → if not cached: queue background prep → on prep complete: queue main-thread upload → on upload: GL VAO/VBO/IBO ready ``` ### Spawn — network-customized (per-instance tier) ``` WorldSession.OnCreateObject(msg) → EntitySpawnAdapter.OnCreate(entity) → build PaletteOverride from msg.SubPalettes → for each surface needing per-instance decode: TextureCache.GetOrUploadWithPaletteOverride(...) → register AnimatedEntityState (override map, hidden mask, animation sequencer reference) ``` ### Per-frame draw (atlas tier) ``` WbDrawDispatcher.Draw() for each visible atlas-tier entity: var renderData = WbMeshAdapter.GetRenderData(entity.GfxObjId) foreach (batch in renderData.Batches): bind atlas, bind shader, push uniforms foreach (part in renderData.SetupParts): push final_part_world_matrix uniform glDrawElements(part.indices) ``` ### Per-frame draw (per-instance tier, animated) ``` WbDrawDispatcher.DrawAnimated() for each visible animated entity: var state = entity.AnimatedEntityState var sequencer = entity.AnimationSequencer sequencer.AdvanceTo(currentTime) // existing var animOverrides = sequencer.GetCurrentPartTransforms() // existing foreach (partIdx in 0..parts.Count): if (state.hiddenMask & (1 << partIdx)) continue; var gfxObjId = state.partGfxObjOverrides.GetValueOrDefault(partIdx) ?? defaultParts[partIdx] var renderData = WbMeshAdapter.GetRenderData(gfxObjId) var meta = AcSurfaceMetadataTable.Lookup(renderData.BatchKey) var worldMatrix = entityWorld × animOverrides[partIdx] × renderData.RestPose bind per-instance texture (TextureCache lookup) push uniforms (worldMatrix, meta.Luminosity, meta.Diffuse, ...) glDrawElements(...) ``` ## Testing ### Algorithmic conformance (before substitution) Per the N.1 / N.3 pattern, conformance tests run BEFORE the substitution to prove equivalence: | Test | Compares | |---|---| | `MeshExtraction_OurBuildVsWbBuildPolygonIndices` | Battery of fixture GfxObjs (varying polygon counts, stippling flags, NegUVIndices, double-sided polys). For each: our `GfxObjMesh.Build` output vs WB's `ObjectMeshManager` output (extracted via test harness). Assert: identical vertex arrays, identical index arrays, identical per-bucket surface mapping. | | `SetupFlattening_OurFlattenVsWbSetupParts` | Battery of representative Setups (flat / hierarchical / Resting-frame / Default-frame / no-frame). For each: our `SetupMesh.Flatten` output vs WB's Setup-parts walk. Assert: identical (GfxObjId, Matrix4x4) sequences. | | `PerInstanceDecode_OldVsNewPath` | Synthetic palette + texture overrides (mirroring real `CreateObject` data). Decoded through new integrated path vs current `TextureCache.GetOrUploadWithPaletteOverride`. Assert: identical RGBA8. | If any test fails it's a real divergence — investigate, do not "fix" the test (per N.3 watchout). ### Component micro-tests | Test | Covers | |---|---| | `LandblockSpawnAdapter_RegistersAndUnregisters` | Mock `ObjectMeshManager`; verify ref-count increments/decrements pair correctly across landblock load/unload events. | | `LandblockSpawnAdapter_DedupesSharedIds` | Same GfxObj id appearing in multiple landblocks: verify single ref-count per landblock, not per occurrence. | | `EntitySpawnAdapter_RoutesToPerInstance` | `CreateObject` with `SubPalettes` set: verify per-instance path taken, atlas tier not invoked. | | `AnimPartChange_OverridesAtDraw` | Per-instance override map: verify draw loop resolves correct part GfxObj id when override present, falls back to Setup default when absent. | | `HiddenParts_SuppressesDraw` | Bitmask: verify draw loop skips hidden parts. | | `MatrixComposition_EntityAnimRest` | Known entity transform + animation matrix + rest pose: verify final world matrix matches expected composition order (column-major: rest applied first, then animation, then entity world). | | `SurfaceMetadata_SideTableLookup` | Populate side-table during mesh extraction; query at draw time; verify Luminosity / Diffuse / DisableFog round-trip correctly. | ### Visual verification (per phase, before flipping `Live ✓`) Walk the following with the user, comparing against pre-N.4 screenshots or video: 1. **Holtburg outdoor** — terrain props, scenery, buildings, NPCs, characters. Verify: no missing entities, no magenta squares, no alpha bleeding, no shading regressions, no animation hitches. 2. **Drudge Hideout** (or comparable starter dungeon) — EnvCell geometry, interior lighting, animated creatures. 3. **Foundry** — heavy NPC traffic, customized appearances (the server's first-time test bed for per-instance customization correctness). 4. **A character with extreme palette overrides** — char-creation variant if available, otherwise a known-customized server-side test character. 5. **Long roam** — walk for ~5 minutes across multiple landblocks, monitor GPU memory in title bar (memory budget enforcement working means it stabilizes; memory growing unboundedly means LRU eviction isn't firing). ## Phasing Single shippable phase — no internal sub-phases. Within the phase, work ordered to minimize the duration of "broken in middle" state: | Week | Focus | "Done when" | |---|---|---| | 1 | WB integration plumbing + atlas bring-up for static scenery only (smallest tier, highest sharing factor) + algorithmic conformance tests pass | Conformance tests green; static scenery renders through `ObjectMeshManager` while everything else uses old path | | 2 | Streaming-loader adapter; LRU + memory budget verified under streaming pressure (long roam + radius 7×7) | Long roam holds steady GPU memory; landblock unload reclaims memory | | 3 | Per-instance customization path; animated creatures with palette overrides; AnimPartChange + HiddenParts | Drudge / chicken / banderling render with correct customizations; animation matches today | | 4 | Surface metadata side-table integration; sky-pass preservation; visual verification at named locations; polish | Visual verification at all 5 locations passes; sky pass renders identically; ready for `Live ✓` | ## Risks 1. **Per-instance customization scope creep.** If we discover a customization path we don't already handle in `TextureCache` (e.g., a rare `GfxObjRemapping` case), the per-instance path may need extension. Mitigation: enumerate all customization paths during week 3, add tests for each before integrating. 2. **WB threading model interaction with our streaming worker.** `ObjectMeshManager` uses `ConcurrentDictionary` and is designed for concurrent `IncrementRefCount` calls, but its `_pendingRequests` queue is guarded by a `lock`. Heavy concurrent landblock loads could serialize on this lock. Mitigation: profile during week 2; if contention is visible, batch landblock loads to amortize the lock. 3. **Sky pass regression.** The sky pass's `NeedsUvRepeat` / `DisableFog` / `Luminosity` flow is fragile and load-bearing. The side-table preserves the data, but the integration point with `SkyRenderer` needs careful review. Mitigation: sky-pass-specific visual verification before flipping `Live ✓`. 4. **Bindless rendering path mismatch.** WB enables bindless when `GL 4.3 + GL_ARB_bindless_texture` are present. If we ship through the bindless path and a player has older hardware, fallback path must work. Mitigation: dev/test with `_useModernRendering = false` forced during week 1 to ensure the non-bindless path is also exercised. 5. **Performance regression** during integration of week 1's "atlas for static scenery, old path for everything else" mixed state. Mitigation: keep the feature gate `ACDREAM_USE_WB_FOUNDATION=1` during weeks 1-3; default-off until week 4 visual verification. ## Out of scope - Replacing `StaticMeshRenderer` / `InstancedMeshRenderer` — those become thin adapters in N.4 and are fully replaced in **N.6**. - Replacing `TerrainAtlas` / `TerrainBlending` — that's **N.5**. - Replacing EnvCell rendering — that's **N.7**. - Replacing sky / particle rendering — that's **N.8**. - Replacing visibility / culling — that's **N.9**. - Per-instance customization beyond what's in today's `TextureCache` (e.g., novel customization opcodes from future Phase F work) — out of scope; future opcodes route through the same per-instance path. ## Documentation impact - [x] [Roadmap](../../plans/2026-04-11-roadmap.md) — N.4 entry rebranded and N.5/N.6/N.7/N.8/N.9/N.10 estimates revised (committed `6d42744` and merged to main). - [ ] This spec — written 2026-05-08, committing alongside. - [ ] [worldbuilder-inventory.md](../../architecture/worldbuilder-inventory.md) — minor update at end of N.4 to mark `ObjectMeshManager` / `TextureAtlasManager` as "now wired up" rather than just "should use." Not blocking N.4 start. - [ ] [acdream-architecture.md](../../architecture/acdream-architecture.md) — needs an acknowledging note after N.4 lands that the rendering pipeline is WB-backed. Can follow in a later commit. ## Reference materials - WB `ObjectMeshManager`: `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectMeshManager.cs` - WB `TextureAtlasManager`: `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/TextureAtlasManager.cs` - WB `BaseObjectRenderManager`: `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/BaseObjectRenderManager.cs` - ACME secondary oracle for character appearance (CreaturePalette / GfxObjRemapping / HiddenParts behavior): `references/WorldBuilder-ACME-Edition/WorldBuilder/Editors/Landscape/StaticObjectManager.cs` - Existing acdream code: - [SetupMesh.cs](../../../src/AcDream.Core/Meshing/SetupMesh.cs) - [GfxObjMesh.cs](../../../src/AcDream.Core/Meshing/GfxObjMesh.cs) - [TextureCache.cs](../../../src/AcDream.App/Rendering/TextureCache.cs) - [PaletteOverride.cs](../../../src/AcDream.Core/World/PaletteOverride.cs) - [AnimationSequencer.cs](../../../src/AcDream.Core/Animation/AnimationSequencer.cs)