diff --git a/docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md b/docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md index ba2337d..f31e820 100644 --- a/docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md +++ b/docs/superpowers/plans/2026-05-08-phase-n4-rendering-foundation.md @@ -837,6 +837,47 @@ writing a real bridge that shares index caches with our `DatCollection`. **No work for this task — skip and proceed to Task 7.** +--- + +### Adjustment 2 (2026-05-08): Task 9 routing reverted — tier decision belongs at spawn-callback layer + +**Discovered during Week 1 visual smoke test**: with flag on, characters / +NPCs disappeared along with static scenery. Root cause: Task 9 routed +**all** `InstancedMeshRenderer.EnsureUploaded` calls through +`WbMeshAdapter.IncrementRefCount` and marked their cache entries with +`WbManagedSentinel`. But `InstancedMeshRenderer` is used for both tiers +in production: + +- **Atlas-tier** call sites: `_pendingCellMeshes` drain + ([GameWindow.cs:5137](../../../src/AcDream.App/Rendering/GameWindow.cs:5137)), + per-MeshRef GfxObj loop on `lb.Entities` + ([:5155](../../../src/AcDream.App/Rendering/GameWindow.cs:5155)). +- **Per-instance-tier** call sites: per-part loop in spawn handling + ([:2302](../../../src/AcDream.App/Rendering/GameWindow.cs:2302)) — this is + character / creature rendering driven by server `CreateObject`. + +The renderer is **tier-blind by design**: it doesn't know spawn source. +Putting routing logic there violates separation of concerns. The spec's +Data-Flow section already specifies the right placement — routing happens +at the **spawn-callback layer**: + +- `LandblockSpawnAdapter.OnLandblockLoaded(...)` (Task 11) calls + `IncrementRefCount` per unique GfxObj — atlas-tier only. +- `EntitySpawnAdapter.OnCreate(entity)` (Task 17) routes through + per-instance path (`TextureCache.GetOrUploadWithPaletteOverride`) — + never calls `IncrementRefCount` for atlas. + +**Resolution:** reverted Task 9's renderer-level routing. Removed the +sentinel logic and the 4 sentinel-skip checks in +`InstancedMeshRenderer`. **Kept** the `_wbMeshAdapter` constructor +parameter (unused for now) so `GameWindow.cs` doesn't shift when +later tasks need adapter access. Kept all the real WB pipeline +construction in `WbMeshAdapter` (verified working under flag-off). + +**Week 1 endpoint shifts:** "WB infrastructure constructed; flag-on and +flag-off visually identical." Routing arrives in Week 2 (Task 11) at +the correct layer. Smoke verification is now: flag-on === flag-off. + ### Task 6 (original — kept for history) **Files:** diff --git a/src/AcDream.App/Rendering/InstancedMeshRenderer.cs b/src/AcDream.App/Rendering/InstancedMeshRenderer.cs index 2ba5093..5b0c9eb 100644 --- a/src/AcDream.App/Rendering/InstancedMeshRenderer.cs +++ b/src/AcDream.App/Rendering/InstancedMeshRenderer.cs @@ -35,19 +35,16 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable private readonly TextureCache _textures; /// - /// Optional WB adapter. When non-null and , - /// hands the GfxObj ref to the WB pipeline instead of - /// uploading into our own VAO pool. The draw loop skips sentinel entries — Task 22's - /// WbDrawDispatcher will eventually draw them. + /// Optional WB adapter. Held but currently unused — Phase N.4 Adjustment 2 + /// (2026-05-08) reverted Task 9's renderer-level routing. Tier-routing decisions + /// (atlas vs per-instance) belong at the spawn-callback layer (Task 11 + /// LandblockSpawnAdapter for atlas-tier; Task 17 EntitySpawnAdapter for + /// per-instance), not in the renderer which is intentionally tier-blind. The + /// constructor parameter is preserved so GameWindow's wire-up doesn't shift + /// when later tasks need adapter access. /// private readonly WbMeshAdapter? _wbMeshAdapter; - // Sentinel: a GfxObj that has been handed to the WB pipeline gets this list - // stored in _gpuByGfxObj. The Draw loop recognises it by reference identity - // (object.ReferenceEquals) and skips it — no legacy VAO draw for WB-managed - // objects until Task 22 wires up WbDrawDispatcher. - private static readonly List WbManagedSentinel = new(0); - // One GPU bundle per unique GfxObj id. Each GfxObj can have multiple sub-meshes. private readonly Dictionary> _gpuByGfxObj = new(); @@ -100,17 +97,11 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable if (_gpuByGfxObj.ContainsKey(gfxObjId)) return; - // Phase N.4 Task 9: when the WB foundation flag is on and we have an - // adapter, hand this GfxObj to the WB pipeline instead of uploading our - // own VAO. The sentinel entry marks "this GfxObj lives in WB now" so the - // draw loop knows to skip it. Task 22's WbDrawDispatcher will draw them. - if (WbFoundationFlag.IsEnabled && _wbMeshAdapter is not null) - { - _wbMeshAdapter.IncrementRefCount(gfxObjId); - _gpuByGfxObj[gfxObjId] = WbManagedSentinel; - return; - } - + // Phase N.4 Adjustment 2 (2026-05-08): renderer is tier-blind. Tier-routing + // (atlas vs per-instance) lives at the spawn-callback layer (Tasks 11 + 17), + // not here. Smoke-test of the original Task 9 routing showed it caught + // characters / NPCs (server-spawned, per-instance tier) along with static + // scenery, because EnsureUploaded is called from both spawn paths. var list = new List(subMeshes.Count); foreach (var sm in subMeshes) list.Add(UploadSubMesh(sm)); @@ -245,11 +236,6 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable if (!_gpuByGfxObj.TryGetValue(key.GfxObjId, out var subMeshes)) continue; - // WB-managed GfxObjs have a sentinel entry; Task 22 (WbDrawDispatcher) - // will draw them. Skip here to avoid drawing with stale/null VAO data. - if (object.ReferenceEquals(subMeshes, WbManagedSentinel)) - continue; - bool hasOpaqueSubMesh = false; foreach (var sub in subMeshes) { @@ -325,10 +311,6 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable if (!_gpuByGfxObj.TryGetValue(key.GfxObjId, out var subMeshes)) continue; - // WB-managed GfxObjs — skip; Task 22 will draw them. - if (object.ReferenceEquals(subMeshes, WbManagedSentinel)) - continue; - bool hasTranslucentSubMesh = false; foreach (var sub in subMeshes) { @@ -458,9 +440,6 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable { if (!_gpuByGfxObj.TryGetValue(meshRef.GfxObjId, out var cachedMeshes)) continue; - // WB-managed GfxObjs don't go through our instance pipeline. - if (object.ReferenceEquals(cachedMeshes, WbManagedSentinel)) - continue; var model = meshRef.PartTransform * entityRoot; @@ -565,11 +544,6 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable { foreach (var subs in _gpuByGfxObj.Values) { - // WB-managed entries use the sentinel — no GL resources to free here; - // ObjectMeshManager owns those resources. - if (object.ReferenceEquals(subs, WbManagedSentinel)) - continue; - foreach (var sub in subs) { _gl.DeleteBuffer(sub.Vbo);