# 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<int partIndex, ulong gfxObjId>`.
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<batchKey, AcSurfaceMetadata>` 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)
