feat(render): Phase A8 Wave 3 — wire EnvCellRenderer into landblock streaming

Six surgical edits to GameWindow.cs (+1 MeshManager accessor on WbMeshAdapter):

1. Field declarations (line 166-167): _envCellRenderer + _envCellFrustum.
2. Ctor init (line 1775-1778): construct WbFrustum + EnvCellRenderer,
   Initialize with the existing _meshShader (loaded from mesh_modern.vert/frag).
3. BuildInteriorEntitiesForStreaming (line 5444): _envCellRenderer.RegisterCell(...)
   replaces the cell-as-WorldEntity creation block. staticObjects is empty —
   cell stabs continue as WorldEntity records via the dispatcher's IndoorPass.
4. ApplyLoadedTerrainLocked (line 5885): _envCellRenderer.FinalizeLandblock(...)
   immediately after _buildingRegistries[lb.LandblockId] = ... — atomically
   commits the landblock's per-cell instance store.
5. RemoveLandblock callbacks (lines 1861 + 8955): mirror the existing
   _buildingRegistries.Remove(id) sites so EnvCellRenderer's storage clears
   in lockstep.
6. Dispose (line 10595): _envCellRenderer?.Dispose() after _wbDrawDispatcher.

Plan revision (vs original plan.md Task 6): we keep the static-object stab
WorldEntity hydration (lines 5440-5489) instead of deleting it — stabs need
WorldEntity records for interaction (clicking) and physics. EnvCellRenderer
receives empty staticObjects so it only renders cell geometry; stab rendering
continues unchanged through the dispatcher.

Build green. 23/23 EnvCellRenderer + WbFrustum + EnvCellSceneryInstance
tests pass. App.Tests baseline holds (82/82). Pre-existing Core.Tests
static-leak flakiness (8-19 failures, documented baseline) unrelated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-27 15:03:17 +02:00
parent aad9ed4cdb
commit 4b4f687070
2 changed files with 52 additions and 14 deletions

View file

@ -158,6 +158,14 @@ public sealed class GameWindow : IDisposable
private readonly System.Collections.Generic.Dictionary<uint, AcDream.App.Rendering.Wb.BuildingRegistry>
_buildingRegistries = new();
// Phase A8 (2026-05-28): WB EnvCellRenderManager port. Cells render
// through this dedicated pipeline now, NOT through WbDrawDispatcher.
// The dispatcher's IndoorPass still walks cell-static stabs (WorldEntity
// records with real GfxObj MeshRefs); only the cell GEOMETRY (walls /
// floors / ceilings) flows through here.
private AcDream.App.Rendering.Wb.EnvCellRenderer? _envCellRenderer;
private AcDream.App.Rendering.Wb.WbFrustum? _envCellFrustum;
/// <summary>
/// Phase 6.4: per-entity animation playback state for entities whose
/// MotionTable resolved to a real cycle. The render loop ticks each
@ -1760,6 +1768,14 @@ public sealed class GameWindow : IDisposable
_classificationCache);
// A.5 T22.5: apply A2C gate from quality preset.
_wbDrawDispatcher.AlphaToCoverage = _resolvedQuality.AlphaToCoverage;
// Phase A8: EnvCellRenderer init. Shares _meshShader (mesh_modern.{vert,frag})
// with the dispatcher — both consume the same global mesh buffer (VAO/IBO)
// from ObjectMeshManager.GlobalBuffer.
_envCellFrustum = new AcDream.App.Rendering.Wb.WbFrustum();
_envCellRenderer = new AcDream.App.Rendering.Wb.EnvCellRenderer(
_gl, _wbMeshAdapter!.MeshManager!, _envCellFrustum);
_envCellRenderer.Initialize(_meshShader!);
}
// Phase G.1 sky renderer — its own shader (sky.vert / sky.frag)
@ -1842,6 +1858,7 @@ public sealed class GameWindow : IDisposable
_physicsEngine.RemoveLandblock(id);
_cellVisibility.RemoveLandblock((id >> 16) & 0xFFFFu);
_buildingRegistries.Remove(id); // Phase A8
_envCellRenderer?.RemoveLandblock(id); // Phase A8
});
// A.5 T22.5: apply max-completions from resolved quality.
_streamingController.MaxCompletionsPerFrame = _resolvedQuality.MaxCompletionsPerFrame;
@ -5398,6 +5415,13 @@ public sealed class GameWindow : IDisposable
if (environment is not null
&& environment.Cells.TryGetValue(envCell.CellStructure, out cellStruct))
{
// Phase A8 (2026-05-28): cells render through EnvCellRenderer, NOT as
// WorldEntities with fake MeshRefs. The CellMesh.Build call stays for
// _pendingCellMeshes (still populated for any consumer) and the physics
// data cache (CacheCellStruct uses cellStruct directly, not the sub-meshes).
// Static objects inside the cell continue to flow through the dispatcher
// as WorldEntity records below — they have real GfxObj MeshRefs that work
// fine; EnvCellRenderer.RegisterCell receives an empty staticObjects list.
var cellSubMeshes = AcDream.Core.Meshing.CellMesh.Build(envCell, cellStruct, _dats);
if (cellSubMeshes.Count > 0)
{
@ -5414,23 +5438,23 @@ public sealed class GameWindow : IDisposable
System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
System.Numerics.Matrix4x4.CreateTranslation(physicsCellOrigin);
var cellMeshRef = new AcDream.Core.World.MeshRef(envCellId, cellTransform);
// Phase A8: register the cell with EnvCellRenderer for rendering.
// staticObjects is empty — cell stabs continue as separate WorldEntity
// records via the dispatcher (see lines below for the unchanged stab path).
_envCellRenderer?.RegisterCell(
landblockId: landblockId,
envCellId: envCellId,
envCell: envCell,
cellStruct: cellStruct,
cellTransform: cellTransform,
cellWorldPosition: cellOrigin,
cellRotation: envCell.Position.Orientation,
staticObjects: System.Array.Empty<(uint, System.Numerics.Vector3, System.Numerics.Quaternion, bool, System.Numerics.Matrix4x4)>());
var cellEntity = new AcDream.Core.World.WorldEntity
{
Id = interiorIdBase + localCounter++,
SourceGfxObjOrSetupId = envCellId,
Position = System.Numerics.Vector3.Zero,
Rotation = System.Numerics.Quaternion.Identity,
MeshRefs = new[] { cellMeshRef },
ParentCellId = envCellId,
};
result.Add(cellEntity);
// Step 4: build LoadedCell for portal visibility.
// Step 4: build LoadedCell for portal visibility (UNCHANGED from pre-A8).
BuildLoadedCell(envCellId, envCell, cellStruct, cellOrigin, cellTransform);
// Cache CellStruct physics BSP for indoor collision.
// Cache CellStruct physics BSP for indoor collision (UNCHANGED).
_physicsDataCache.CacheCellStruct(envCellId, envCell, cellStruct, physicsCellTransform);
}
}
@ -5854,6 +5878,11 @@ public sealed class GameWindow : IDisposable
AcDream.App.Rendering.Wb.BuildingLoader.Build(
lbInfo, lb.LandblockId, drainedCells);
}
// Phase A8: finalize EnvCellRenderer's per-landblock instance store.
// Atomically swaps PendingInstances -> committed, computes TotalEnvCellBounds,
// populates StaticPartGroups/BuildingPartGroups via PopulateRecursive.
_envCellRenderer?.FinalizeLandblock(lb.LandblockId);
}
// N.5: WbMeshAdapter.Tick() handles GPU upload for all GfxObj meshes via
@ -8923,6 +8952,7 @@ public sealed class GameWindow : IDisposable
_physicsEngine.RemoveLandblock(id);
_cellVisibility.RemoveLandblock((id >> 16) & 0xFFFFu);
_buildingRegistries.Remove(id); // Phase A8
_envCellRenderer?.RemoveLandblock(id); // Phase A8
});
_streamingController.MaxCompletionsPerFrame = newResolved.MaxCompletionsPerFrame;
@ -10562,6 +10592,7 @@ public sealed class GameWindow : IDisposable
_liveSession = null;
_audioEngine?.Dispose(); // Phase E.2: stop all voices, close AL context
_wbDrawDispatcher?.Dispose();
_envCellRenderer?.Dispose(); // Phase A8
_skyRenderer?.Dispose(); // depends on sampler cache; dispose first
_samplerCache?.Dispose();
_textureCache?.Dispose();

View file

@ -120,6 +120,13 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
/// </summary>
public AcSurfaceMetadataTable MetadataTable => _metadataTable;
/// <summary>
/// Phase A8 (2026-05-28): exposes the underlying <see cref="ObjectMeshManager"/>
/// so <c>EnvCellRenderer</c> can share the same global mesh buffer (VAO/VBO/IBO).
/// Returns null when the adapter is uninitialized.
/// </summary>
public ObjectMeshManager? MeshManager => _meshManager;
/// <summary>
/// Returns the WB render data for <paramref name="id"/>, or null if not
/// yet uploaded or if this adapter is uninitialized. Increments WB's