// Phase A8 (2026-05-28): port of WB's EnvCellRenderManager. This is the // production cell-rendering pipeline for indoor visibility, replacing the // broken "cell as WorldEntity with MeshRef(envCellId)" approach that the // four reverted RR7 variants couldn't fix. // // Sources ported byte-for-byte: // GetEnvCellGeomId <- WB EnvCellRenderManager.cs:94-103 // PrepareRenderBatches <- WB EnvCellRenderManager.cs:247-373 // Render(filter:) <- WB EnvCellRenderManager.cs:395-511 // RenderModernMDIInternal <- WB BaseObjectRenderManager.cs:709-848 (single-slot variant) // AddToGroups / AddToCellGroup <- WB EnvCellRenderManager.cs:375-393 // // Note: we do NOT inherit from WB's ObjectRenderManagerBase. That base // class owns the landblock-streaming loop (Update, _pendingGeneration, // _uploadQueue). acdream's StreamingController already does that work — // running a parallel loop would compete for dat I/O. Instead, streaming builds // a private EnvCellLandblockBuild and CommitLandblock publishes the completed // snapshot on the render thread. using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Numerics; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using DatReaderWriter.Enums; using Silk.NET.OpenGL; namespace AcDream.App.Rendering.Wb; public sealed unsafe class EnvCellRenderer : IDisposable { private readonly GL _gl; private readonly ObjectMeshManager _meshManager; private readonly WbFrustum _frustum; // Per-landblock storage. Key = full 32-bit landblock dat id (e.g. 0xA9B4FFFF). // WB EnvCellRenderManager.cs:75 uses ConcurrentDictionary _landblocks — // we use uint (full LB id) because acdream uses 32-bit landblock keys throughout. private readonly ConcurrentDictionary _landblocks = new(); // Active snapshot (atomic swap under _renderLock). // WB EnvCellRenderManager.cs:71: private VisibilitySnapshot _activeSnapshot = new(); private readonly object _renderLock = new(); private EnvCellVisibilitySnapshot _activeSnapshot = new(); // Shader (set by caller via Initialize). // Uses acdream's legacy Shader type (not WB's GLSLShader) to match the // existing wire-in pattern in GameWindow.cs where _meshShader is loaded // for mesh_modern.{vert,frag} and shared across multiple consumers. // API mapping: Bind() -> Use(), SetUniform(s, int) -> SetInt(s, int), // SetUniform(s, Vector4) -> SetVec4(s, Vector4). private AcDream.App.Rendering.Shader? _shader; // Phase U.4 root-cause fix: the view-projection captured in PrepareRenderBatches, // re-uploaded by Render() so the cell-shell pass is self-contained and does NOT // inherit WbDrawDispatcher's uViewProjection (which the opaque pass would read one // frame stale, since it draws BEFORE the dispatcher's upload). Same matrix the // portal clip planes are computed with (envCellViewProj). private Matrix4x4 _lastViewProjection = Matrix4x4.Identity; private bool _initialized; // List pool — copied from WB ObjectRenderManagerBase. // WB ObjectRenderManagerBase.cs:83-86: protected readonly List> _listPool = new(); protected int _poolIndex = 0; private readonly List> _listPool = new(); private int _poolIndex = 0; // PrepareRenderBatches used to construct and dispose two ThreadLocal dictionary trees every // frame. At outdoor view distances those trees grow thousands of InstanceData entries, so the // temporary List backing arrays alone accounted for ~11 MB of allocations in a five-second // Caul trace. Keep one scratch arena per participating worker thread: Reset clears counts and // dictionaries while retaining their capacity. The published visibility snapshot still owns // separate pooled output lists, preserving the existing atomic-swap lifetime. private readonly ThreadLocal _prepareScratch = new(() => new PrepareScratch(), trackAllValues: true); // Modern-MDI scratch buffers (single slot — we re-upload every frame). // WB BaseObjectRenderManager.cs:43-48: _scratchMdiCommandBuffers, _scratchModernBatchBuffers, _modernInstanceBuffers // We collapse the ring-of-3 to a single slot since we have no persistent/consolidated draws. private uint _mdiCommandBuffer; private int _mdiCommandCapacity; private uint _modernInstanceBuffer; private int _modernInstanceCapacity; private uint _modernBatchBuffer; private int _modernBatchCapacity; // mesh_modern.vert's SSBO InstanceData is only mat4 transform. The CPU // InstanceData below also carries CellId/Flags for filtering, so upload a // packed transform array instead of the 80-byte CPU struct. private Matrix4x4[] _gpuInstanceTransforms = Array.Empty(); // Phase U.3: per-instance clip-slot SSBO (binding=3), parallel to // _modernInstanceBuffer. One uint per instance selecting its CellClip slot, // indexed by the same BaseInstance + gl_InstanceID the shader uses for // binding=0. ALL ZEROS in U.3 ⇒ slot 0 ⇒ no-clip. U.4 populates real slots. private uint _clipSlotBuffer; private int _clipSlotCapacity; private uint[] _clipSlotData = Array.Empty(); // A7 Fix D (D-2): this renderer owns its lighting (self-contained GL state, // like uViewProjection) instead of reading the SSBO 4/5 WbDrawDispatcher last // left bound. binding=4 = global point-light snapshot (same data/indices as the // dispatcher, via GlobalLightPacker); binding=5 = 8 int indices per instance. private uint _globalLightsSsbo; // binding=4 private int _globalLightsCapacity; private float[] _globalLightData = new float[AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * 16]; private uint _instLightSetSsbo; // binding=5 private int _instLightSetCapacity; private int[] _lightSetData = new int[1024 * AcDream.Core.Lighting.LightManager.MaxLightsPerObject]; private System.Collections.Generic.IReadOnlyList? _pointSnapshot; private sealed class CachedCellLightSet { public int FrameGeneration; public readonly int[] Indices = new int[AcDream.Core.Lighting.LightManager.MaxLightsPerObject]; } private readonly System.Collections.Generic.Dictionary _cellLightSetCache = new(); private readonly List _cellLightRemovalScratch = new(); private int _lightFrameGeneration; private sealed class DynamicBufferSet { public uint MdiCommandBuffer; public uint ModernInstanceBuffer; public uint ModernBatchBuffer; public uint ClipSlotBuffer; public uint GlobalLightsSsbo; public uint InstanceLightSetSsbo; public int MdiCommandCapacity; public int ModernInstanceCapacity; public int ModernBatchCapacity; public int ClipSlotCapacity; public int GlobalLightsCapacity; public int InstanceLightSetCapacity; } private readonly List[] _dynamicBufferSetsByFrame = [[], [], []]; private int _dynamicFrameSlot; private int _dynamicBufferSetCursor; private bool _dynamicFrameStarted; private DynamicBufferSet? _activeDynamicBufferSet; internal int DynamicBufferSetCount => _dynamicBufferSetsByFrame.Sum(frameSets => frameSets.Count); // Phase U.3: SHARED per-cell clip-region SSBO (binding=2) handed in via // SetClipRegionSsbo (the GameWindow-level ClipFrame buffer). When 0, we bind // our own one-slot no-clip fallback so the shader never reads an unbound SSBO. private uint _sharedClipRegionSsbo; private uint _fallbackClipRegionSsbo; // Reusable scratch arrays — avoid per-frame allocation. // WB BaseObjectRenderManager.cs:58-59: private DrawElementsIndirectCommand[] _commands = Array.Empty<...>() private DrawElementsIndirectCommand[] _commands = Array.Empty(); private ModernBatchData[] _modernBatches = Array.Empty(); private readonly List _prepareLandblocks = new(); private readonly List _renderInstances = new(); private readonly List<(ObjectRenderData renderData, ulong gfxObjId, int count, int offset)> _renderDrawCalls = new(); private readonly Dictionary> _filteredGroups = new(); private readonly HashSet> _filteredOwnedLists = new(); private readonly List<(ObjectRenderBatch batch, int instanceCount, int instanceOffset)>[] _batchesByCullGroup = Enumerable.Range(0, 8) .Select(_ => new List<(ObjectRenderBatch, int, int)>()) .ToArray(); private readonly List _activeCullGroups = new(8); private readonly HashSet _transparentCellIds = new(); private readonly List _drawCallRanges = new(); private readonly List _mdiDrawRanges = new(); private readonly record struct DrawCallRange(int First, int Count); internal readonly record struct MdiDrawRange(int GroupIndex, int FirstCommand, int CommandCount); // Unfiltered rendering is retained for diagnostic callers only. Build its // global grouping lazily instead of duplicating every prepared gameplay // instance in both a per-cell and global tree each frame. private readonly Dictionary> _activeSnapshotGlobalGroups = new(); private readonly List _activeSnapshotGlobalGfxObjIds = new(); // Static render-state tracking — matches WB BaseObjectRenderManager.cs:24-28. // Shared across all manager instances on the same GL context. private static uint _currentVao; private static CullMode? _currentCullMode; public bool NeedsPrepare { get; private set; } = true; private RetryableResourceReleaseLedger? _disposeResources; private bool _disposing; public bool IsDisposed { get; private set; } public LastFrameStats Stats => _lastFrameStats; public struct LastFrameStats { public int CellsRendered; public int TrianglesDrawn; } private LastFrameStats _lastFrameStats; /// /// Diagnostic accessor for the [envcells] probe (Phase A8 apparatus 2026-05-28). /// Returns (pool-list count total, snapshot's PostPreparePoolIndex high-water). /// A divergence between expected and actual values would indicate a pool- /// management regression — exactly the bug class the 2026-05-28 audit caught. /// public (int PoolTotal, int SnapshotPoolHwm) GetPoolDiagnostics() { int poolTotal; lock (_listPool) poolTotal = _listPool.Count; int hwm; lock (_renderLock) hwm = _activeSnapshot.PostPreparePoolIndex; return (poolTotal, hwm); } /// /// Phase A8 audit probe (2026-05-28 visual-gate-#1 follow-up). /// One-shot per (cellId, gfxObjId) pair: dumps batch counts + CullModes + /// transparency flags + bindless-handle-non-zero status, so the operator /// can read offline and identify why specific polys (e.g., floors) aren't /// rendering. Set ACDREAM_A8_AUDIT=1 to enable. /// Returns a deduplicated audit-line list per Render snapshot /// (one entry per (cellId, gfxObjId) seen in BatchedByCell). The caller /// (GameWindow EmitEnvCellProbe) prints these and tracks which pairs /// have already been logged. /// public IReadOnlyList CollectCellAuditLines(HashSet<(uint cellId, ulong gfxObjId)> alreadyLogged) { var lines = new List(); lock (_renderLock) { var snap = _activeSnapshot; foreach (var (cellId, gfxDict) in snap.BatchedByCell) { foreach (var (gfxObjId, transforms) in gfxDict) { var key = (cellId, gfxObjId); if (alreadyLogged.Contains(key)) continue; alreadyLogged.Add(key); var rd = _meshManager.TryGetRenderData(gfxObjId); if (rd is null) { lines.Add($"[a8-audit] cell=0x{cellId:X8} gfx=0x{gfxObjId:X10} instances={transforms.Count} renderData=null"); continue; } int totalIdx = 0; var cullModes = new HashSet(); int translucent = 0; int additive = 0; int zeroHandle = 0; foreach (var b in rd.Batches) { totalIdx += b.IndexCount; cullModes.Add(b.CullMode); if (b.IsTransparent) translucent++; if (b.IsAdditive) additive++; if (b.BindlessTextureHandle == 0) zeroHandle++; } var cullList = string.Join(",", cullModes); lines.Add( $"[a8-audit] cell=0x{cellId:X8} gfx=0x{gfxObjId:X10} instances={transforms.Count} " + $"isSetup={rd.IsSetup} batches={rd.Batches.Count} totalIdx={totalIdx} " + $"cull=[{cullList}] translucent={translucent} additive={additive} zeroHandle={zeroHandle}"); } } } return lines; } // --------------------------------------------------------------------------- // Constructor + Initialize // --------------------------------------------------------------------------- public EnvCellRenderer(GL gl, ObjectMeshManager meshManager, WbFrustum frustum) { _gl = gl; _meshManager = meshManager; _frustum = frustum; } public void Initialize(AcDream.App.Rendering.Shader shader) { _shader = shader; _initialized = true; } /// Resets the per-frame submission cursor for the GPU-fenced slot. public void BeginFrame(int frameSlot) { if ((uint)frameSlot >= (uint)_dynamicBufferSetsByFrame.Length) throw new ArgumentOutOfRangeException(nameof(frameSlot)); _dynamicFrameSlot = frameSlot; _dynamicBufferSetCursor = 0; _dynamicFrameStarted = true; _activeDynamicBufferSet = null; if (++_lightFrameGeneration == 0) { _cellLightSetCache.Clear(); _lightFrameGeneration = 1; } } /// /// Phase U.3: hand the renderer the SHARED per-cell clip-region SSBO /// (binding=2) created by . The renderer /// re-binds it to binding=2 immediately before its MDI. Pass 0 to fall back to /// the internal one-slot no-clip region buffer. /// public void SetClipRegionSsbo(uint sharedClipRegionSsbo) => _sharedClipRegionSsbo = sharedClipRegionSsbo; // Phase U.4: per-frame cellId→CellClip-slot map for the cell shells. When // non-null, RenderModernMDIInternal writes instanceClipSlot[i] = // _cellIdToSlot[allInstances[i].CellId] so each cell's shell instances are // gated to that cell's portal-clip region. When null (U.3 path), every // instance maps to slot 0 (no-clip). A cell absent from the map writes slot 0 // (no-clip) — but the caller's Render filter already restricts the draw to the // map's keys, so that fallback should not fire in practice. private IReadOnlyDictionary? _cellIdToSlot; /// /// Phase U.4: install the per-frame cellId→slot map used to gate cell shells /// to their portal-clip regions. Call once per frame BEFORE /// . Pass null to revert to /// the U.3 no-clip behavior (every shell instance → slot 0). /// public void SetClipRouting(IReadOnlyDictionary? cellIdToSlot) => _cellIdToSlot = cellIdToSlot; /// /// A7 Fix D (D-2): hand the renderer this frame's point-light snapshot /// (LightManager.PointSnapshot). Call once per frame BEFORE Render, alongside /// the WbDrawDispatcher snapshot wire-in. Indices in the per-cell light sets /// reference this snapshot, which is also uploaded to binding=4 here, so the /// pass is self-contained. Null/empty -> shells receive no point lights. /// public void SetPointSnapshot( System.Collections.Generic.IReadOnlyList? snapshot) => _pointSnapshot = snapshot; // --------------------------------------------------------------------------- // GetEnvCellGeomId // Verbatim copy of WB EnvCellRenderManager.cs:94-103. // --------------------------------------------------------------------------- /// /// Returns a deduplicated geometry ID for an EnvCell based on its environment, /// cell structure index, and surface IDs. Bit 33 is set to distinguish from /// per-cell IDs (which use bit 32). /// Source: WB EnvCellRenderManager.cs:94-103 (verbatim). /// public static ulong GetEnvCellGeomId(uint environmentId, ushort cellStructure, List surfaces) => EnvCellLandblockBuildBuilder.ComputeGeometryId( environmentId, cellStructure, surfaces); // --------------------------------------------------------------------------- // CommitLandblock — render-thread transaction boundary // --------------------------------------------------------------------------- /// /// Commits one complete worker-built landblock snapshot. A replacement is /// constructed off to the side, then published with one dictionary write; /// render preparation can observe either the previous complete snapshot or /// this complete snapshot, never an in-progress cell list. /// public void CommitLandblock(EnvCellLandblockBuild build) { _landblocks[build.LandblockId] = CreateCommittedSnapshot(build); NeedsPrepare = true; } /// /// Pure half of . Kept separate so transaction /// replacement semantics are regression-tested without an OpenGL context. /// internal static EnvCellLandblock CreateCommittedSnapshot(EnvCellLandblockBuild build) { var replacement = new EnvCellLandblock { GridX = (int)((build.LandblockId >> 24) & 0xFFu), GridY = (int)((build.LandblockId >> 16) & 0xFFu), }; foreach (var shell in build.Shells) { replacement.Instances.Add(new EnvCellSceneryInstance { ObjectId = shell.GeometryId, InstanceId = shell.CellId, IsBuilding = true, IsEntryCell = false, WorldPosition = shell.WorldPosition, LocalPosition = Vector3.Zero, Rotation = shell.Rotation, Scale = Vector3.One, Transform = shell.Transform, LocalBoundingBox = shell.LocalBounds, BoundingBox = shell.WorldBounds, }); replacement.EnvCellBounds[shell.CellId] = shell.WorldBounds; if (!replacement.BuildingPartGroups.TryGetValue(shell.GeometryId, out var instances)) { instances = new List(); replacement.BuildingPartGroups[shell.GeometryId] = instances; } instances.Add(new InstanceData { Transform = shell.Transform, CellId = shell.CellId, Flags = 0, }); } var total = new WbBoundingBox(new Vector3(float.MaxValue), new Vector3(float.MinValue)); foreach (var bounds in replacement.EnvCellBounds.Values) total = WbBoundingBox.Union(total, bounds); replacement.TotalEnvCellBounds = replacement.EnvCellBounds.Count == 0 ? new WbBoundingBox(Vector3.Zero, Vector3.Zero) : total; replacement.InstancesReady = true; replacement.MeshDataReady = true; replacement.GpuReady = true; return replacement; } /// /// Removes a landblock from the renderer. Future PrepareRenderBatches will exclude it. /// public void RemoveLandblock(uint landblockId) { _landblocks.TryRemove(landblockId, out _); uint cellPrefix = landblockId & 0xFFFF0000u; _cellLightRemovalScratch.Clear(); foreach (uint cellId in _cellLightSetCache.Keys) { if ((cellId & 0xFFFF0000u) == cellPrefix) _cellLightRemovalScratch.Add(cellId); } foreach (uint cellId in _cellLightRemovalScratch) _cellLightSetCache.Remove(cellId); NeedsPrepare = true; } // --------------------------------------------------------------------------- // PrepareRenderBatches // Verbatim port of WB EnvCellRenderManager.cs:247-373. // --------------------------------------------------------------------------- /// /// Frustum-culls all registered landblocks and builds a new /// that the render thread consumes. /// Call once per frame, before . /// Source: WB EnvCellRenderManager.cs:247-373 (verbatim). /// public void PrepareRenderBatches( Matrix4x4 viewProjection, Vector3 cameraPosition, HashSet? filter = null, int? centerLbX = null, int? centerLbY = null, int? renderRadius = null) { // Phase U.4 fix: stash the view-projection so Render() can upload it itself. _lastViewProjection = viewProjection; // WB EnvCellRenderManager.cs:249-250: if (!_initialized || cameraPosition.Z > 4000) return; if (filter is { Count: 0 }) { lock (_renderLock) { _poolIndex = 0; _activeSnapshot = new EnvCellVisibilitySnapshot(); _transparentCellIds.Clear(); NeedsPrepare = false; } return; } // WB EnvCellRenderManager.cs:251-253: lock (_renderLock) { _poolIndex = 0; } // WB skips _cameraLbX/Y update (from LandscapeDoc.Region) here in our variant // because we don't need camera-LB tracking for the snapshot — just frustum tests. // WB EnvCellRenderManager.cs:262: // Filter loaded landblocks by GpuReady + Instances non-empty. List landblocks = _prepareLandblocks; landblocks.Clear(); foreach (var lb in _landblocks.Values) { if (centerLbX.HasValue && centerLbY.HasValue && renderRadius.HasValue) { if (Math.Abs(lb.GridX - centerLbX.Value) > renderRadius.Value || Math.Abs(lb.GridY - centerLbY.Value) > renderRadius.Value) { continue; } } if (lb.GpuReady && lb.Instances.Count > 0) landblocks.Add(lb); } if (landblocks.Count == 0) return; // WB EnvCellRenderManager.cs:265-267: worker-local grouping avoids contention. The scratch // arenas persist so their backing arrays can be reused; Parallel.ForEach has completed before // the merge below reads them, and Reset runs before the next workers start. foreach (PrepareScratch scratch in _prepareScratch.Values) scratch.Reset(); // WB EnvCellRenderManager.cs:269: var parallelOptions = new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount }; // WB EnvCellRenderManager.cs:270-325: Parallel.ForEach(landblocks, parallelOptions, lb => { lock (lb.Lock) { var testResult = _frustum.TestBox(lb.TotalEnvCellBounds); if (testResult == FrustumTestResult.Outside) return; PrepareScratch scratch = _prepareScratch.Value!; // WB EnvCellRenderManager.cs:279-295: fast path — LB fully inside. if (testResult == FrustumTestResult.Inside) { foreach (var (gfxObjId, instances) in lb.BuildingPartGroups) foreach (var instanceData in instances) { if (filter != null && !filter.Contains(instanceData.CellId)) continue; AddToGroups(scratch, instanceData.CellId, gfxObjId, instanceData); } foreach (var (gfxObjId, instances) in lb.StaticPartGroups) foreach (var instanceData in instances) { if (filter != null && !filter.Contains(instanceData.CellId)) continue; AddToGroups(scratch, instanceData.CellId, gfxObjId, instanceData); } return; } // WB EnvCellRenderManager.cs:298-324: slow path — per-cell frustum test. HashSet visibleCells = scratch.VisibleCells; visibleCells.Clear(); foreach (var kvp in lb.EnvCellBounds) { var cellId = kvp.Key; if (filter != null && !filter.Contains(cellId)) continue; if (_frustum.Intersects(kvp.Value)) visibleCells.Add(cellId); } if (visibleCells.Count > 0) { foreach (var (gfxObjId, instances) in lb.BuildingPartGroups) foreach (var instanceData in instances) { if (visibleCells.Contains(instanceData.CellId)) AddToGroups(scratch, instanceData.CellId, gfxObjId, instanceData); } foreach (var (gfxObjId, instances) in lb.StaticPartGroups) foreach (var instanceData in instances) { if (visibleCells.Contains(instanceData.CellId)) AddToGroups(scratch, instanceData.CellId, gfxObjId, instanceData); } } } }); // WB EnvCellRenderManager.cs:327-373: merge thread-locals + atomic swap. var newBatchedByCell = new Dictionary>>(); // WB EnvCellRenderManager.cs:333-347: merge per-cell batches. foreach (PrepareScratch scratch in _prepareScratch.Values) { foreach (var cellKvp in scratch.BatchedByCell) { if (!newBatchedByCell.TryGetValue(cellKvp.Key, out var gfxDict)) { gfxDict = new Dictionary>(); newBatchedByCell[cellKvp.Key] = gfxDict; } foreach (var gfxKvp in cellKvp.Value) { if (!gfxDict.TryGetValue(gfxKvp.Key, out var list)) { list = GetPooledList(); gfxDict[gfxKvp.Key] = list; } list.AddRange(gfxKvp.Value); } } } // WB EnvCellRenderManager.cs:361-372: atomic swap under _renderLock. // // FIX 2026-05-28 (pool aliasing root cause): capture _poolIndex's // high-water mark from the merge phase into the snapshot's // PostPreparePoolIndex BEFORE the reset to 0. Render reads it back // to set its pool cursor past the snapshot's owned lists. Without // this capture, Render's filter-path GetPooledList returns lists // the snapshot is still referencing, corrupting per-frame instance // data. See docs/research/2026-05-28-a8-env-cell-renderer-audit-findings.md. lock (_renderLock) { _activeSnapshot = new EnvCellVisibilitySnapshot { BatchedByCell = newBatchedByCell, VisibleLandblocks = landblocks, PostPreparePoolIndex = _poolIndex, }; RebuildTransparentCellIndex(newBatchedByCell); _poolIndex = 0; NeedsPrepare = false; } } private void RebuildTransparentCellIndex( Dictionary>> batchedByCell) { _transparentCellIds.Clear(); foreach ((uint cellId, Dictionary> groups) in batchedByCell) { foreach ((ulong gfxObjId, List transforms) in groups) { if (transforms.Count == 0) continue; ObjectRenderData? renderData = _meshManager.TryGetRenderData(gfxObjId); if (renderData is null) continue; for (int batchIndex = 0; batchIndex < renderData.Batches.Count; batchIndex++) { if (!renderData.Batches[batchIndex].IsTransparent) continue; _transparentCellIds.Add(cellId); goto NextCell; } } NextCell:; } } // --------------------------------------------------------------------------- // AddToGroups (static helper) // Verbatim port of WB EnvCellRenderManager.cs:375-393. // --------------------------------------------------------------------------- private static void AddToGroups( PrepareScratch scratch, uint cellId, ulong gfxObjId, InstanceData data) { Dictionary>> batchedByCell = scratch.BatchedByCell; // The gameplay renderer always consumes cell-filtered PView batches. // Keeping a second global copy doubled every prepared instance and was // only useful to WorldBuilder's editor-wide unfiltered path. if (!batchedByCell.TryGetValue(cellId, out var gfxDict)) { gfxDict = scratch.RentGfxDictionary(); batchedByCell[cellId] = gfxDict; } if (!gfxDict.TryGetValue(gfxObjId, out var list)) { list = scratch.RentList(); batchedByCell[cellId][gfxObjId] = list; } list.Add(data); } private sealed class PrepareScratch { public readonly Dictionary>> BatchedByCell = new(); public readonly HashSet VisibleCells = new(); private readonly List>> _gfxDictionaryPool = new(); private readonly List> _listPool = new(); private int _gfxDictionaryIndex; private int _listIndex; public void Reset() { BatchedByCell.Clear(); VisibleCells.Clear(); _gfxDictionaryIndex = 0; _listIndex = 0; } public Dictionary> RentGfxDictionary() { if (_gfxDictionaryIndex == _gfxDictionaryPool.Count) _gfxDictionaryPool.Add(new Dictionary>()); Dictionary> value = _gfxDictionaryPool[_gfxDictionaryIndex++]; value.Clear(); return value; } public List RentList() { if (_listIndex == _listPool.Count) _listPool.Add(new List()); List value = _listPool[_listIndex++]; value.Clear(); return value; } } // --------------------------------------------------------------------------- // Render // Verbatim port of WB EnvCellRenderManager.cs:395-511. // Deviations from WB (all documented): // - Drop the _useModernRendering branch (our codebase asserts modern at startup per Phase N.5). // - Drop SelectedInstance/HoveredInstance highlight block (lines 486-510) — no editor state. // - Replace RenderModernMDI(base) with private RenderModernMDIInternal. // - shader.Bind() / SetUniform API: mapped to acdream's legacy Shader // class (Use() + SetInt/SetVec4/SetMatrix4) to match the existing // wire-in pattern in GameWindow.cs where _meshShader is loaded once // for mesh_modern.{vert,frag} and shared with WbDrawDispatcher. // --------------------------------------------------------------------------- public void Render(WbRenderPass renderPass) { // WB EnvCellRenderManager.cs:396: RenderCore(renderPass, null, null); } /// /// Draws all visible EnvCells (and their static objects) for the given pass. /// When is non-null, only cells whose CellId is in /// the set are drawn. As of Phase U.4 this is the portal-visibility SHELL /// filter (the drawable visible cells from the PView traversal; each cell's /// shell instances are clip-gated to its CellClip slot by the caller's /// binding=3 map). NOTE: this is NOT the old two-pipe RenderInsideOut approach /// — that flat camera-inside-building stencil pass was deleted in Phase U.1. /// Source: WB EnvCellRenderManager.cs:399-511 (verbatim minus selection highlights). /// public void Render(WbRenderPass renderPass, HashSet? filter) => RenderCore(renderPass, filter, null); /// /// Draws transparent cell shells in the supplied far-to-near PView order. /// All instance/command buffers are uploaded once, while command ranges /// retain the same per-cell cull/blend ordering as the former one-Render- /// call-per-cell path. /// public void RenderTransparentOrdered(IReadOnlyList orderedCellIds) { ArgumentNullException.ThrowIfNull(orderedCellIds); RenderCore(WbRenderPass.Transparent, null, orderedCellIds); } private void RenderCore( WbRenderPass renderPass, HashSet? filter, IReadOnlyList? orderedCellIds) { // WB EnvCellRenderManager.cs:400: if (!_initialized || _shader is null || _shader.Program == 0) return; lock (_renderLock) { var snapshot = _activeSnapshot; // WB EnvCellRenderManager.cs:403-404: _shader.Use(); // FIX 2026-05-28 (pool aliasing root cause): mirror WB // EnvCellRenderManager.cs:405 — restore the pool cursor to the // high-water mark Prepare's merge phase reached, so any // GetPooledList calls below return lists past the snapshot's // owned region. Original code used `snapshot.BatchedByCell.Count` // (number of cells, e.g. 18) which has no relation to the pool // index and pointed back into snapshot data, corrupting it // mid-Render. See docs/research/2026-05-28-a8-env-cell-renderer-audit-findings.md. _poolIndex = snapshot.PostPreparePoolIndex; // FIX 2026-05-28: invalidate static GL-state caches at start of Render. // Mirrors WB EnvCellRenderManager.cs:404-410: // CurrentVAO = 0; CurrentIBO = 0; CurrentAtlas = 0; // CurrentInstanceBuffer = 0; CurrentCullMode = null; // // These caches let SetCullMode / BindVertexArray skip redundant GL // calls when the state is already correct. BUT: between two Render() // invocations, OTHER consumers (WbDrawDispatcher, terrain, the // RenderInsideOutAcdream stencil pipeline) change the actual GL // state without updating these caches. The cache then lies, and // the per-batch SetCullMode in RenderModernMDIInternal skips its // glCullFace call — leaving stale cull state from the prior // consumer. For a cottage with mixed CullMode batches, half the // walls end up culled and the user sees "missing walls". // // Forcing the cache to null/0 at entry guarantees each Render call // re-establishes the GL state it expects. _currentVao = 0; _currentCullMode = null; // WB EnvCellRenderManager.cs:406-409: uniform state setup. _shader.SetInt("uRenderPass", (int)renderPass); _shader.SetInt("uFilterByCell", 0); _shader.SetInt("uLightingMode", 1); // A7 Fix D D-3/D-4: EnvCell bake (wrap points, no sun) // #176 stripe-hunt isolation (ACDREAM_LIGHT_DEBUG) — throwaway diagnostic. _shader.SetInt("uLightDebug", AcDream.Core.Rendering.RenderingDiagnostics.LightDebugMode); // Phase U.4 ROOT-CAUSE FIX (cell-shell flicker / "transparent walls when // moving"): upload uViewProjection HERE rather than inheriting it from // WbDrawDispatcher. The opaque shell pass runs BEFORE the dispatcher's // Draw (GameWindow ~7411 vs ~7418, the only other setter), so without // this the opaque shells used the PREVIOUS frame's matrix — a stale // gl_Position against this frame's clip planes → pose-dependent clipping, // worst while moving. Same self-contained-GL-state precedent as the // 2026-05-28 cull-state cache fix above. _shader.SetMatrix4("uViewProjection", _lastViewProjection); List allInstances = _renderInstances; List<(ObjectRenderData renderData, ulong gfxObjId, int count, int offset)> drawCalls = _renderDrawCalls; allInstances.Clear(); drawCalls.Clear(); _drawCallRanges.Clear(); if (orderedCellIds is not null) { for (int cellIndex = 0; cellIndex < orderedCellIds.Count; cellIndex++) { uint cellId = orderedCellIds[cellIndex]; if (!snapshot.BatchedByCell.TryGetValue(cellId, out var cellGroups)) continue; int firstDrawCall = drawCalls.Count; foreach ((ulong gfxObjId, List transforms) in cellGroups) { if (transforms.Count == 0) continue; ObjectRenderData? renderData = _meshManager.TryGetRenderData(gfxObjId); if (renderData is null || renderData.IsSetup) continue; drawCalls.Add((renderData, gfxObjId, transforms.Count, allInstances.Count)); allInstances.AddRange(transforms); } int drawCallCount = drawCalls.Count - firstDrawCall; if (drawCallCount > 0) _drawCallRanges.Add(new DrawCallRange(firstDrawCall, drawCallCount)); } } else if (filter is null) { RebuildUnfilteredGroups(snapshot); // WB EnvCellRenderManager.cs:418-429: optimized path — global groups. foreach (var gfxObjId in _activeSnapshotGlobalGfxObjIds) { if (_activeSnapshotGlobalGroups.TryGetValue(gfxObjId, out var transforms)) { var renderData = _meshManager.TryGetRenderData(gfxObjId); if (renderData != null && !renderData.IsSetup) { drawCalls.Add((renderData, gfxObjId, transforms.Count, allInstances.Count)); allInstances.AddRange(transforms); } } } } else { // WB EnvCellRenderManager.cs:431-468: filtered path. // Group by gfxObjId within the filtered cells to minimize draw calls. Dictionary> filteredGroups = _filteredGroups; HashSet> ownedLists = _filteredOwnedLists; filteredGroups.Clear(); ownedLists.Clear(); foreach (var cellId in filter) { if (!snapshot.BatchedByCell.TryGetValue(cellId, out var gfxDict)) continue; foreach (var (gfxObjId, transforms) in gfxDict) { if (transforms.Count == 0) continue; if (!filteredGroups.TryGetValue(gfxObjId, out var list)) { list = transforms; // Optimization: just use the first list filteredGroups[gfxObjId] = list; } else { if (list == transforms) continue; // If we don't own this list yet, we must clone it before adding to it if (!ownedLists.Contains(list)) { var newList = GetPooledList(); newList.AddRange(list); list = newList; filteredGroups[gfxObjId] = list; ownedLists.Add(list); } list.AddRange(transforms); } } } // WB EnvCellRenderManager.cs:461-468: foreach (var (gfxObjId, transforms) in filteredGroups) { var renderData = _meshManager.TryGetRenderData(gfxObjId); if (renderData != null && !renderData.IsSetup) { drawCalls.Add((renderData, gfxObjId, transforms.Count, allInstances.Count)); allInstances.AddRange(transforms); } } } // #176 seam-draw probe: stash this call's filter so the opaque-pass // emitter inside RenderModernMDIInternal can report flood membership // per target cell (null on the unfiltered/outdoor path). if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeSeamDrawEnabled) _seamProbeFilter = filter; // WB EnvCellRenderManager.cs:470-483: if (allInstances.Count > 0) { // WB uses: if (_useModernRendering) { RenderModernMDI(...) } else { legacy } // We always use modern (Phase N.5 mandatory). if (_drawCallRanges.Count == 0 && drawCalls.Count > 0) _drawCallRanges.Add(new DrawCallRange(0, drawCalls.Count)); RenderModernMDIInternal( _shader, drawCalls, allInstances, _drawCallRanges, renderPass); } // WB EnvCellRenderManager.cs:486-510: selection/hover highlights — DROPPED (no editor state). // WB EnvCellRenderManager.cs:506-509: cleanup. _shader.SetVec4("uHighlightColor", new System.Numerics.Vector4(0, 0, 0, 0)); _shader.SetInt("uRenderPass", (int)renderPass); _gl.BindVertexArray(0); _currentVao = 0; // No cull restore at exit, matching WB's manager pattern: the // last SetCullMode call reflects actual GL state, and the next // Render call invalidates `_currentCullMode` before issuing its // own per-batch state. The Landblock->None override below can // intentionally leave cull disabled for the following IndoorPass, // preserving the shipped Gate #5 baseline while deeper evidence is // gathered. // Update frame stats for probe emission at the call site. _lastFrameStats.CellsRendered = orderedCellIds?.Count ?? filter?.Count ?? snapshot.BatchedByCell.Count; _lastFrameStats.TrianglesDrawn = 0; foreach (var dc in drawCalls) _lastFrameStats.TrianglesDrawn += (dc.renderData.Batches.Count > 0 ? dc.renderData.Batches[0].IndexCount / 3 : 0) * dc.count; // Issue #78 (2026-05-31) [shell] probe (ACDREAM_PROBE_SHELL) — THROWAWAY. // Per opaque-pass call: totals + per visible (filtered) cell whether it is // present in the prepared snapshot, and its geometry/flags. Answers why the // interior walls/ceiling don't appear: NOSNAP / gfx=0 ⇒ no shell geometry // prepared for the cell; idx>0 + zh>0 ⇒ prepared but missing bindless texture // (invisible); idx>0 + zh=0 + tr=0 ⇒ opaque geometry drawn (fault is depth/ // occlusion or the geometry isn't the wall). Opaque pass only (halves noise). if (renderPass == WbRenderPass.Opaque && AcDream.Core.Rendering.RenderingDiagnostics.ProbeShellEnabled) { var sb = new System.Text.StringBuilder(256); sb.Append("[shell] filter=").Append(filter?.Count ?? -1) .Append(" drawCalls=").Append(drawCalls.Count) .Append(" inst=").Append(allInstances.Count) .Append(" tris=").Append(_lastFrameStats.TrianglesDrawn); if (filter != null) { foreach (var cellId in filter) { if (!snapshot.BatchedByCell.TryGetValue(cellId, out var gfxDict)) { sb.Append(" [0x").Append(cellId.ToString("X8")).Append(":NOSNAP]"); continue; } int gfxN = 0, tf = 0, batch = 0, idx = 0, tr = 0, zh = 0; foreach (var (gfxObjId, transforms) in gfxDict) { gfxN++; tf += transforms.Count; var rd = _meshManager.TryGetRenderData(gfxObjId); if (rd != null) foreach (var b in rd.Batches) { batch++; idx += b.IndexCount; if (b.IsTransparent) tr++; if (b.BindlessTextureHandle == 0) zh++; } } sb.Append(" [0x").Append(cellId.ToString("X8")) .Append(":gfx=").Append(gfxN).Append(" tf=").Append(tf) .Append(" batch=").Append(batch).Append(" idx=").Append(idx) .Append(" tr=").Append(tr).Append(" zh=").Append(zh).Append(']'); } } System.Console.WriteLine(sb.ToString()); } } } /// /// True if the cell's prepared snapshot has any transparent render batch. /// The pview shell pass uses this to skip the (heavy per-frame) transparent /// call for opaque-only cells — most cell geometry is /// opaque walls/floors/ceilings, so this removes the bulk of the per-cell /// transparent draws. Read-only; mirrors the [shell] probe's batch scan. /// public bool CellHasTransparent(uint cellId) => _transparentCellIds.Contains(cellId); // --------------------------------------------------------------------------- // GetCellLightSet (A7 Fix D D-2 helper) // Per-cell up-to-8 point lights, cached per frame. Camera-independent, like // WbDrawDispatcher.ComputeEntityLightSet — keyed on the cell's world bounds. // --------------------------------------------------------------------------- // A7 Fix D (D-2): the up-to-8 point lights reaching a cell, by the cell's world // bounding sphere (camera-independent, like WbDrawDispatcher.ComputeEntityLightSet). // Cached per frame; unused slots are -1 (shader adds no point light there). private int[] GetCellLightSet(uint cellId) { if (!_cellLightSetCache.TryGetValue(cellId, out CachedCellLightSet? cached)) { cached = new CachedCellLightSet(); _cellLightSetCache.Add(cellId, cached); } if (cached.FrameGeneration == _lightFrameGeneration) return cached.Indices; int[] set = cached.Indices; System.Array.Fill(set, -1); var snap = _pointSnapshot; // Landblocks are keyed by the streaming landblock id 0xXXYYFFFF // (GameWindow: (x<<24)|(y<<16)|0xFFFF), NOT 0xXXYY0000 — so the landblock // key is (cellId & 0xFFFF0000) | 0xFFFF. The old `cellId & 0xFFFF0000` key // (0xXXYY0000) NEVER matched a registered landblock, so this lookup always // missed: SelectForObject never ran and every EnvCell wall received ZERO // point lights (the entire "indoor torches/lanterns don't light the room" // bug — confirmed by the [cell-light] probe: inBounds=False for every cell). if (snap is { Count: > 0 } && _landblocks.TryGetValue((cellId & 0xFFFF0000u) | 0xFFFFu, out var lb) && lb.EnvCellBounds.TryGetValue(cellId, out var b)) { Vector3 center = (b.Min + b.Max) * 0.5f; float radius = (b.Max - b.Min).Length() * 0.5f; // #176 flap fix: cells use SelectForCell (retail minimize_envcell_lighting) — ALL // dynamic lights on every cell (stable), not the per-object sphere-overlap cull that // let the portal set flip as the flood shifted → floor-lighting flap. AcDream.Core.Lighting.LightManager.SelectForCell(snap, center, radius, set); } cached.FrameGeneration = _lightFrameGeneration; return set; } // --------------------------------------------------------------------------- // RenderModernMDIInternal // Extracted from WB BaseObjectRenderManager.cs:709-848 (single-slot variant). // Groups draw calls by CullMode (+ additive flag), uploads per-frame SSBOs, // issues glMultiDrawElementsIndirect. // --------------------------------------------------------------------------- private void ActivateNextDynamicBufferSet() { if (!_dynamicFrameStarted) throw new InvalidOperationException("BeginFrame must be called before drawing EnvCells."); List slotSets = _dynamicBufferSetsByFrame[_dynamicFrameSlot]; if (_dynamicBufferSetCursor == slotSets.Count) slotSets.Add(CreateDynamicBufferSet()); DynamicBufferSet set = slotSets[_dynamicBufferSetCursor++]; _activeDynamicBufferSet = set; _mdiCommandBuffer = set.MdiCommandBuffer; _modernInstanceBuffer = set.ModernInstanceBuffer; _modernBatchBuffer = set.ModernBatchBuffer; _clipSlotBuffer = set.ClipSlotBuffer; _globalLightsSsbo = set.GlobalLightsSsbo; _instLightSetSsbo = set.InstanceLightSetSsbo; _mdiCommandCapacity = set.MdiCommandCapacity; _modernInstanceCapacity = set.ModernInstanceCapacity; _modernBatchCapacity = set.ModernBatchCapacity; _clipSlotCapacity = set.ClipSlotCapacity; _globalLightsCapacity = set.GlobalLightsCapacity; _instLightSetCapacity = set.InstanceLightSetCapacity; } private DynamicBufferSet CreateDynamicBufferSet() { var set = new DynamicBufferSet(); try { set.MdiCommandBuffer = TrackedGlResource.CreateBuffer(_gl, "creating EnvCell MDI buffer"); set.ModernInstanceBuffer = TrackedGlResource.CreateBuffer(_gl, "creating EnvCell instance SSBO"); set.ModernBatchBuffer = TrackedGlResource.CreateBuffer(_gl, "creating EnvCell batch SSBO"); set.ClipSlotBuffer = TrackedGlResource.CreateBuffer(_gl, "creating EnvCell clip-slot SSBO"); set.GlobalLightsSsbo = TrackedGlResource.CreateBuffer(_gl, "creating EnvCell global-light SSBO"); set.InstanceLightSetSsbo = TrackedGlResource.CreateBuffer(_gl, "creating EnvCell light-set SSBO"); return set; } catch (Exception creationFailure) { try { DeleteDynamicBufferSet(set); } catch (Exception cleanupFailure) { throw new AggregateException( "EnvCell dynamic-buffer creation and rollback failed.", creationFailure, cleanupFailure); } throw; } } private void RebuildUnfilteredGroups(EnvCellVisibilitySnapshot snapshot) { foreach (List instances in _activeSnapshotGlobalGroups.Values) instances.Clear(); _activeSnapshotGlobalGfxObjIds.Clear(); foreach (Dictionary> cellGroups in snapshot.BatchedByCell.Values) { foreach ((ulong gfxObjId, List transforms) in cellGroups) { if (!_activeSnapshotGlobalGroups.TryGetValue(gfxObjId, out List? combined)) { combined = new List(transforms.Count); _activeSnapshotGlobalGroups.Add(gfxObjId, combined); } if (combined.Count == 0) _activeSnapshotGlobalGfxObjIds.Add(gfxObjId); combined.AddRange(transforms); } } } private void DeleteDynamicBufferSet(DynamicBufferSet set) { List? failures = null; void Attempt(uint buffer, long bytes, string name) { try { TrackedGlResource.DeleteBuffer(_gl, buffer, bytes, $"deleting {name}"); } catch (Exception ex) { (failures ??= []).Add(ex); } } Attempt( set.MdiCommandBuffer, (long)set.MdiCommandCapacity * sizeof(DrawElementsIndirectCommand), "EnvCell MDI buffer"); Attempt( set.ModernInstanceBuffer, (long)set.ModernInstanceCapacity * sizeof(Matrix4x4), "EnvCell instance SSBO"); Attempt( set.ModernBatchBuffer, (long)set.ModernBatchCapacity * sizeof(ModernBatchData), "EnvCell batch SSBO"); Attempt(set.ClipSlotBuffer, (long)set.ClipSlotCapacity * sizeof(uint), "EnvCell clip-slot SSBO"); Attempt( set.GlobalLightsSsbo, (long)set.GlobalLightsCapacity * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * sizeof(float), "EnvCell global-light SSBO"); Attempt( set.InstanceLightSetSsbo, (long)set.InstanceLightSetCapacity * AcDream.Core.Lighting.LightManager.MaxLightsPerObject * sizeof(int), "EnvCell light-set SSBO"); if (failures is not null) throw new AggregateException("One or more EnvCell dynamic buffers failed to delete.", failures); } private void PersistActiveDynamicBufferCapacities() { DynamicBufferSet set = _activeDynamicBufferSet ?? throw new InvalidOperationException("No dynamic EnvCell buffer set is active."); set.MdiCommandCapacity = _mdiCommandCapacity; set.ModernInstanceCapacity = _modernInstanceCapacity; set.ModernBatchCapacity = _modernBatchCapacity; set.ClipSlotCapacity = _clipSlotCapacity; set.GlobalLightsCapacity = _globalLightsCapacity; set.InstanceLightSetCapacity = _instLightSetCapacity; } private void RenderModernMDIInternal( AcDream.App.Rendering.Shader shader, List<(ObjectRenderData renderData, ulong gfxObjId, int count, int offset)> drawCalls, List allInstances, IReadOnlyList drawCallRanges, WbRenderPass renderPass) { // WB BaseObjectRenderManager.cs:710-713: if (drawCalls.Count == 0 || allInstances.Count == 0) return; int passIdx = (int)renderPass; if (passIdx < 0 || passIdx > 2) return; // §4 outdoor full-world flap (2026-06-10): hoisted from below the SSBO uploads. // Without the global VAO nothing can draw, and returning AFTER the pass state // was established leaked it (same early-out shape as the totalDraws==0 leak — // see the comment on the state-establish block below). var globalVao = _meshManager.GlobalBuffer?.VAO ?? 0u; if (globalVao == 0) return; // WB BaseObjectRenderManager.cs:715-716: shader.Use(); shader.SetInt("uFilterByCell", 0); // WB BaseObjectRenderManager.cs:718-740: count the pass-filtered batches. // A normal render has one range. The ordered transparent-shell path has // one range per cell, retaining retail's far-to-near cell order while // sharing a single set of buffer uploads for the entire shell pass. int totalDraws = 0; for (int rangeIndex = 0; rangeIndex < drawCallRanges.Count; rangeIndex++) { DrawCallRange range = drawCallRanges[rangeIndex]; int rangeEnd = Math.Min(range.First + range.Count, drawCalls.Count); for (int callIndex = range.First; callIndex < rangeEnd; callIndex++) { var call = drawCalls[callIndex]; foreach (var batch in call.renderData.Batches) { // WB BaseObjectRenderManager.cs:723-731: pass-filter. if (renderPass != WbRenderPass.SinglePass) { if (batch.IsAdditive) { if (renderPass == WbRenderPass.Opaque) continue; } else if (!batch.IsTransparent) { if (renderPass == WbRenderPass.Transparent) continue; } } totalDraws++; } } } // WB BaseObjectRenderManager.cs:743: if (totalDraws == 0) return; ActivateNextDynamicBufferSet(); // Phase U.4 ROOT-CAUSE FIX (cell-shell "transparent walls / only bluish // background, flickering when moving"): establish this pass's BLEND + DepthMask // state OURSELVES rather than inheriting it. Mirror the working WbDrawDispatcher // passes (Disable(Blend)+DepthMask(true) opaque; Enable(Blend)+DepthMask(false) // transparent). Restored to opaque defaults at the end of the draw loop so a // Transparent pass can't leak into later draws. // // §4 outdoor full-world flap fix (2026-06-10): this block MOVED below the // totalDraws==0 early-out above. It used to run before the batch grouping, so a // Transparent pass over a cell whose batches are ALL opaque (a plain cottage // interior) set Blend-on/DepthMask-off and then returned at the count check // WITHOUT reaching the restore. The frame ended with dmask=0; the NEXT frame's // glClear(DEPTH) silently no-oped (depth clears honor glDepthMask), every world // fragment failed GL_LESS against its own previous-frame depth ghost, and the // whole screen dropped to the fog-tinted clear color — onset-locked to the // building-flood merge (the first frame a flooded building shell draws), holding // until camera rotation dropped the cell from the flood. From here down every // path reaches the end-of-pass restore. if (renderPass == WbRenderPass.Transparent) { _gl.Enable(EnableCap.Blend); _gl.DepthMask(false); } else { _gl.Disable(EnableCap.Blend); _gl.DepthMask(true); } // WB BaseObjectRenderManager.cs:745-759: resize buffers if needed. if (totalDraws > _mdiCommandCapacity) { int grownMdiCapacity = Math.Max(_mdiCommandCapacity * 2, totalDraws); TrackedGlResource.AllocateBufferStorage( _gl, GLEnum.DrawIndirectBuffer, _mdiCommandBuffer, (long)_mdiCommandCapacity * sizeof(DrawElementsIndirectCommand), (long)grownMdiCapacity * sizeof(DrawElementsIndirectCommand), GLEnum.DynamicDraw, $"growing EnvCell MDI buffer to {grownMdiCapacity} commands"); _mdiCommandCapacity = grownMdiCapacity; int grownBatchCapacity = grownMdiCapacity; TrackedGlResource.AllocateBufferStorage( _gl, GLEnum.ShaderStorageBuffer, _modernBatchBuffer, (long)_modernBatchCapacity * sizeof(ModernBatchData), (long)grownBatchCapacity * sizeof(ModernBatchData), GLEnum.DynamicDraw, $"growing EnvCell batch SSBO to {grownBatchCapacity} batches"); _modernBatchCapacity = grownBatchCapacity; } int uniqueInstanceCount = allInstances.Count; if (uniqueInstanceCount > _modernInstanceCapacity) { int grownInstanceCapacity = Math.Max(_modernInstanceCapacity * 2, uniqueInstanceCount); TrackedGlResource.AllocateBufferStorage( _gl, GLEnum.ShaderStorageBuffer, _modernInstanceBuffer, (long)_modernInstanceCapacity * sizeof(Matrix4x4), (long)grownInstanceCapacity * sizeof(Matrix4x4), GLEnum.DynamicDraw, $"growing EnvCell instance SSBO to {grownInstanceCapacity} instances"); _modernInstanceCapacity = grownInstanceCapacity; } // Phase U.3: keep the clip-slot buffer (binding=3) sized to the // instance prefix so instanceClipSlot[BaseInstance + gl_InstanceID] // is always in range. It owns an independent committed capacity so a // failed allocation can never publish the instance buffer's growth as // if both resources had succeeded. if (uniqueInstanceCount > _clipSlotCapacity) { int grownClipCapacity = Math.Max(_clipSlotCapacity * 2, uniqueInstanceCount); TrackedGlResource.AllocateBufferStorage( _gl, GLEnum.ShaderStorageBuffer, _clipSlotBuffer, (long)_clipSlotCapacity * sizeof(uint), (long)grownClipCapacity * sizeof(uint), GLEnum.DynamicDraw, $"growing EnvCell clip-slot SSBO to {grownClipCapacity} instances"); _clipSlotCapacity = grownClipCapacity; } if (uniqueInstanceCount > _instLightSetCapacity) { int grownLightSetCapacity = Math.Max(_instLightSetCapacity * 2, uniqueInstanceCount); TrackedGlResource.AllocateBufferStorage( _gl, GLEnum.ShaderStorageBuffer, _instLightSetSsbo, (long)_instLightSetCapacity * AcDream.Core.Lighting.LightManager.MaxLightsPerObject * sizeof(int), (long)grownLightSetCapacity * AcDream.Core.Lighting.LightManager.MaxLightsPerObject * sizeof(int), GLEnum.DynamicDraw, $"growing EnvCell light-set SSBO to {grownLightSetCapacity} instances"); _instLightSetCapacity = grownLightSetCapacity; } // WB BaseObjectRenderManager.cs:761-762: grow scratch arrays. if (_commands.Length < totalDraws) Array.Resize(ref _commands, Math.Max(_commands.Length * 2, totalDraws)); if (_modernBatches.Length < totalDraws) Array.Resize(ref _modernBatches, Math.Max(_modernBatches.Length * 2, totalDraws)); // WB BaseObjectRenderManager.cs:718-781: group and build commands. // Group independently inside each ordered cell range. This preserves // the old per-cell draw ordering exactly; only the repeated CPU-side // buffer uploads have been coalesced. _mdiDrawRanges.Clear(); int cmdIndex = 0; for (int rangeIndex = 0; rangeIndex < drawCallRanges.Count; rangeIndex++) { _activeCullGroups.Clear(); for (int groupIndex = 0; groupIndex < _batchesByCullGroup.Length; groupIndex++) _batchesByCullGroup[groupIndex].Clear(); DrawCallRange range = drawCallRanges[rangeIndex]; int rangeEnd = Math.Min(range.First + range.Count, drawCalls.Count); for (int callIndex = range.First; callIndex < rangeEnd; callIndex++) { var call = drawCalls[callIndex]; foreach (var batch in call.renderData.Batches) { if (renderPass != WbRenderPass.SinglePass) { if (batch.IsAdditive) { if (renderPass == WbRenderPass.Opaque) continue; } else if (!batch.IsTransparent) { if (renderPass == WbRenderPass.Transparent) continue; } } int groupIndex = (int)batch.CullMode + (batch.IsAdditive ? 4 : 0); List<(ObjectRenderBatch batch, int instanceCount, int instanceOffset)> group = _batchesByCullGroup[groupIndex]; if (group.Count == 0) _activeCullGroups.Add(groupIndex); group.Add((batch, call.count, call.offset)); } } for (int activeIndex = 0; activeIndex < _activeCullGroups.Count; activeIndex++) { int groupIndex = _activeCullGroups[activeIndex]; List<(ObjectRenderBatch batch, int instanceCount, int instanceOffset)> group = _batchesByCullGroup[groupIndex]; int firstCommand = cmdIndex; foreach (var item in group) { _modernBatches[cmdIndex] = new ModernBatchData { TextureHandle = item.batch.BindlessTextureHandle, TextureIndex = (uint)item.batch.TextureIndex, }; _commands[cmdIndex] = new DrawElementsIndirectCommand { Count = (uint)item.batch.IndexCount, InstanceCount = (uint)item.instanceCount, FirstIndex = item.batch.FirstIndex, BaseVertex = (int)item.batch.BaseVertex, BaseInstance = (uint)item.instanceOffset, }; cmdIndex++; } int commandCount = cmdIndex - firstCommand; if (commandCount == 0) continue; // Adjacent cells frequently resolve to the same cull/blend // state. Their commands are already contiguous and remain in // strict cell order, so one MDI call can cover the complete run // without changing alpha compositing or gl_DrawID indexing. AppendMdiDrawRange( _mdiDrawRanges, groupIndex, firstCommand, commandCount); } } // WB BaseObjectRenderManager.cs:784-805 upload. Retain capacity and // update the active prefix so portal frames cannot enqueue an unbounded // chain of retired driver allocations. _gl.BindBuffer(GLEnum.DrawIndirectBuffer, _mdiCommandBuffer); fixed (DrawElementsIndirectCommand* ptr = _commands) { _gl.BufferSubData(GLEnum.DrawIndirectBuffer, 0, (nuint)(totalDraws * sizeof(DrawElementsIndirectCommand)), ptr); } _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _modernInstanceBuffer); if (_gpuInstanceTransforms.Length < uniqueInstanceCount) Array.Resize(ref _gpuInstanceTransforms, Math.Max(_gpuInstanceTransforms.Length * 2, uniqueInstanceCount)); for (int i = 0; i < uniqueInstanceCount; i++) _gpuInstanceTransforms[i] = allInstances[i].Transform; fixed (Matrix4x4* ptr = _gpuInstanceTransforms) { _gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0, (nuint)(uniqueInstanceCount * sizeof(Matrix4x4)), ptr); } _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _modernBatchBuffer); fixed (ModernBatchData* ptr = _modernBatches) { _gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0, (nuint)(totalDraws * sizeof(ModernBatchData)), ptr); } // Phase U.4: upload the per-instance clip-slot buffer (binding=3). When // _cellIdToSlot is set (indoor routing), each cell shell instance is gated // to its cell's CellClip slot via allInstances[i].CellId; cells absent from // the map (shouldn't happen — the Render filter is the map's keys) and the // U.3 path both map to slot 0 (no-clip). allInstances is laid out in the // SAME order as the binding=0 transforms (_gpuInstanceTransforms below), so // instanceClipSlot[i] tracks Instances[i] through the MDI BaseInstance. if (_clipSlotData.Length < uniqueInstanceCount) _clipSlotData = new uint[Math.Max(_clipSlotData.Length * 2, uniqueInstanceCount)]; // #176 stripe-hunt isolation (ACDREAM_CLIP_DEBUG=1): force every shell // instance to slot 0 (no-clip) — retail draws cell shells WHOLE. if (_cellIdToSlot is null || AcDream.Core.Rendering.RenderingDiagnostics.ClipDebugNoShellTrim) { Array.Clear(_clipSlotData, 0, uniqueInstanceCount); } else { for (int i = 0; i < uniqueInstanceCount; i++) _clipSlotData[i] = _cellIdToSlot.TryGetValue(allInstances[i].CellId, out int slot) ? (uint)slot : 0u; } _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _clipSlotBuffer); fixed (uint* ptr = _clipSlotData) { _gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0, (nuint)(uniqueInstanceCount * sizeof(uint)), ptr); } // A7 Fix D (D-2): per-instance 8-int light set, parallel to the transforms, // keyed on the cell each shell instance belongs to (mirrors _clipSlotData). int lightStride = AcDream.Core.Lighting.LightManager.MaxLightsPerObject; if (_lightSetData.Length < uniqueInstanceCount * lightStride) _lightSetData = new int[System.Math.Max(_lightSetData.Length * 2, uniqueInstanceCount * lightStride)]; for (int i = 0; i < uniqueInstanceCount; i++) { int[] cellSet = GetCellLightSet(allInstances[i].CellId); System.Array.Copy(cellSet, 0, _lightSetData, i * lightStride, lightStride); } // #176 seam-draw probe: emitted HERE (not in Render) so the per-cell light // sets read through the just-cleared cache against THIS frame's // _pointSnapshot — the exact data the SSBO upload below carries. if (renderPass == WbRenderPass.Opaque && AcDream.Core.Rendering.RenderingDiagnostics.ProbeSeamDrawEnabled) EmitSeamDrawProbe(drawCalls, allInstances, _seamProbeFilter); // A7 Fix D (D-2): upload binding=4 (global lights) + binding=5 (per-instance set). int lightCount = AcDream.Core.Lighting.GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData); int glUploadCount = lightCount > 0 ? lightCount : 1; _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _globalLightsSsbo); if (glUploadCount > _globalLightsCapacity) { int grownGlobalLightCapacity = Math.Max(_globalLightsCapacity * 2, glUploadCount); TrackedGlResource.AllocateBufferStorage( _gl, GLEnum.ShaderStorageBuffer, _globalLightsSsbo, (long)_globalLightsCapacity * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * sizeof(float), (long)grownGlobalLightCapacity * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * sizeof(float), GLEnum.DynamicDraw, $"growing EnvCell global-light SSBO to {grownGlobalLightCapacity} lights"); _globalLightsCapacity = grownGlobalLightCapacity; } fixed (float* gp = _globalLightData) _gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0, (nuint)(glUploadCount * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * sizeof(float)), gp); _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _instLightSetSsbo); fixed (int* lp = _lightSetData) _gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0, (nuint)(uniqueInstanceCount * lightStride * sizeof(int)), lp); PersistActiveDynamicBufferCapacities(); // WB BaseObjectRenderManager.cs:807-818: bind VAO + SSBOs + barrier. // (globalVao validated at the top of the method — a return here would leak the // pass state established above.) if (_currentVao != globalVao) { _gl.BindVertexArray(globalVao); _currentVao = globalVao; } _gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 0, _modernInstanceBuffer); _gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 1, _modernBatchBuffer); // Phase U.3: per-instance clip slots (binding=3) + shared clip regions // (binding=2, via the GameWindow ClipFrame or our no-clip fallback). _gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 3, _clipSlotBuffer); BindClipRegionBinding2(); _gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 4, _globalLightsSsbo); // A7 Fix D (D-2) _gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 5, _instLightSetSsbo); // A7 Fix D (D-2) _gl.BindBuffer(GLEnum.DrawIndirectBuffer, _mdiCommandBuffer); _gl.MemoryBarrier(MemoryBarrierMask.ShaderStorageBarrierBit | MemoryBarrierMask.CommandBarrierBit); // WB BaseObjectRenderManager.cs:821-847: issue per-group multi-draw calls. // The ranges retain ordered-cell boundaries, so transparent geometry // stays far-to-near even though all command data was uploaded once. for (int drawRangeIndex = 0; drawRangeIndex < _mdiDrawRanges.Count; drawRangeIndex++) { MdiDrawRange drawRange = _mdiDrawRanges[drawRangeIndex]; int groupIndex = drawRange.GroupIndex; var cullMode = (CullMode)(groupIndex % 4); // Phase A8 visual-gate evidence: cell meshes use CullMode.Landblock // uniformly, but the room surfaces need to be visible from inside // under acdream's current global winding state. Render cell polys // double-sided while the architectural cause is isolated. if (cullMode == CullMode.Landblock) cullMode = CullMode.None; if (_currentCullMode != cullMode) { SetCullMode(cullMode); } bool isAdditive = groupIndex >= 4; if (isAdditive) { _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.One); shader.SetInt("uRenderPass", (int)renderPass | 0x100); } else { _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha); shader.SetInt("uRenderPass", (int)renderPass); } shader.SetInt("uDrawIDOffset", drawRange.FirstCommand); _gl.MultiDrawElementsIndirect( PrimitiveType.Triangles, DrawElementsType.UnsignedShort, (void*)(drawRange.FirstCommand * sizeof(DrawElementsIndirectCommand)), (uint)drawRange.CommandCount, (uint)sizeof(DrawElementsIndirectCommand)); } // Phase U.4: leave a clean opaque-default render state (mirrors WbDrawDispatcher's // post-transparent restore) so a Transparent pass's Blend-on / DepthMask-off does // not leak into particles or the next frame's draws. _gl.Disable(EnableCap.Blend); _gl.DepthMask(true); // WB BaseObjectRenderManager.cs:845-847: shader.SetInt("uDrawIDOffset", 0); _gl.BindBuffer(GLEnum.DrawIndirectBuffer, 0); } internal static void AppendMdiDrawRange( List ranges, int groupIndex, int firstCommand, int commandCount) { ArgumentNullException.ThrowIfNull(ranges); if (commandCount <= 0) return; if (ranges.Count > 0) { MdiDrawRange previous = ranges[^1]; if (previous.GroupIndex == groupIndex && previous.FirstCommand + previous.CommandCount == firstCommand) { ranges[^1] = previous with { CommandCount = checked(previous.CommandCount + commandCount), }; return; } } ranges.Add(new MdiDrawRange(groupIndex, firstCommand, commandCount)); } // --------------------------------------------------------------------------- // #176 seam-draw probe (ACDREAM_PROBE_SEAMDRAW) — throwaway apparatus. // The in-engine replacement for the RenderDoc pixel-history the pipeline // can't have (RenderDoc hides GL_ARB_bindless_texture → our mandatory-modern // startup gate throws). Per opaque pass: for each target cell — flood // membership, every shell instance (count + translation, F3 z shows the // +0.02 lift; n≥2 for one (cell,gfx) = the runtime double-draw), and the // cell's 8-light set resolved to stable IDENTITIES (owner-cell low16 + // intensity; raw indices shuffle when the pool rebuilds). Plus the // snapshot's HOT lights (intensity ≥ 50 — the portal purples; fixtures are // ~1–2). Change-deduped block with a 2 s heartbeat: a purple identity // flipping with flood membership = the snapshot-scope mechanism; two // coincident instances = the z-fight. See RenderingDiagnostics. // --------------------------------------------------------------------------- private HashSet? _seamProbeFilter; private string? _seamSig; private long _seamLastEmitMs; private void EmitSeamDrawProbe( List<(ObjectRenderData renderData, ulong gfxObjId, int count, int offset)> drawCalls, List allInstances, HashSet? filter) { var ci = System.Globalization.CultureInfo.InvariantCulture; var snap = _pointSnapshot; var sb = new System.Text.StringBuilder(640); var sorted = new List(AcDream.Core.Rendering.RenderingDiagnostics.SeamDrawTargetCells); sorted.Sort(); foreach (uint cell in sorted) { sb.Append("\n[seam-cell] cell=0x").Append(cell.ToString("X8")); sb.Append(" flood=").Append(filter is null ? '?' : (filter.Contains(cell) ? 'Y' : 'N')); int totalInst = 0; foreach (var dc in drawCalls) { int n = 0; InstanceData first = default; for (int i = dc.offset; i < dc.offset + dc.count; i++) { if (allInstances[i].CellId != cell) continue; if (n == 0) first = allInstances[i]; n++; } if (n == 0) continue; totalInst += n; var t = first.Transform.Translation; sb.AppendFormat(ci, " g=0x{0:X8}:n={1}@({2:F2},{3:F2},{4:F3})", dc.gfxObjId, n, t.X, t.Y, t.Z); } if (totalInst == 0) sb.Append(" inst=0"); // The 8-light set this cell's instances carry (fresh: the per-pass // cache was cleared at the top of RenderModernMDIInternal). int[] set = GetCellLightSet(cell); sb.Append(" L=["); bool any = false; for (int k = 0; k < set.Length; k++) { int idx = set[k]; if (idx < 0) continue; if (any) sb.Append(','); if (snap is not null && idx < snap.Count) sb.AppendFormat(ci, "{0:X4}:I{1:F0}", snap[idx].CellId & 0xFFFFu, snap[idx].Intensity); else sb.Append('?').Append(idx); any = true; } sb.Append(']'); } sb.Append("\n[seam-snap] pool=").Append(snap?.Count ?? 0).Append(" hot=["); if (snap is not null) { bool anyHot = false; for (int i = 0; i < snap.Count; i++) { var ls = snap[i]; if (ls.Intensity < 50f) continue; if (anyHot) sb.Append(','); sb.AppendFormat(ci, "0x{0:X8}:I{1:F0}rgb({2:F2},{3:F2},{4:F2})", ls.CellId, ls.Intensity, ls.ColorLinear.X, ls.ColorLinear.Y, ls.ColorLinear.Z); anyHot = true; } } sb.Append(']'); string sig = sb.ToString(); long now = System.Environment.TickCount64; bool changed = sig != _seamSig; if (!changed && (now - _seamLastEmitMs) < 2000) return; _seamSig = sig; _seamLastEmitMs = now; System.Console.WriteLine($"[seam-blk] t={now} changed={(changed ? 1 : 0)}{sig}"); } // --------------------------------------------------------------------------- // SetCullMode // Verbatim copy of WB BaseObjectRenderManager.cs:850-866. // --------------------------------------------------------------------------- private void SetCullMode(CullMode mode) { _currentCullMode = mode; switch (mode) { case CullMode.None: _gl.Disable(EnableCap.CullFace); break; case CullMode.Clockwise: _gl.Enable(EnableCap.CullFace); _gl.CullFace(TriangleFace.Front); break; case CullMode.CounterClockwise: case CullMode.Landblock: _gl.Enable(EnableCap.CullFace); _gl.CullFace(TriangleFace.Back); break; } } // --------------------------------------------------------------------------- // BindClipRegionBinding2 (Phase U.3) // --------------------------------------------------------------------------- /// /// Bind the per-cell clip-region SSBO to binding=2. Prefers the shared /// buffer (); otherwise /// lazily creates + binds a one-slot no-clip fallback (count 0 = pass-all) so /// the shader never reads an unbound SSBO. /// private void BindClipRegionBinding2() { if (_sharedClipRegionSsbo != 0) { _gl.BindBufferBase(GLEnum.ShaderStorageBuffer, AcDream.App.Rendering.ClipFrame.MeshClipSsboBinding, _sharedClipRegionSsbo); return; } if (_fallbackClipRegionSsbo == 0) { uint fallback = TrackedGlResource.CreateBuffer(_gl, "creating EnvCell fallback clip SSBO"); bool allocated = false; try { TrackedGlResource.AllocateBufferStorage( _gl, GLEnum.ShaderStorageBuffer, fallback, 0, AcDream.App.Rendering.ClipFrame.CellClipStrideBytes, GLEnum.DynamicDraw, "allocating EnvCell fallback clip SSBO"); allocated = true; // One CellClip slot, all zeros: count 0 ⇒ shader passes every plane. Span zero = stackalloc byte[AcDream.App.Rendering.ClipFrame.CellClipStrideBytes]; zero.Clear(); fixed (byte* p = zero) { _gl.BufferSubData( GLEnum.ShaderStorageBuffer, 0, (nuint)zero.Length, p); } GLHelpers.ThrowOnResourceError(_gl, "initializing EnvCell fallback clip SSBO"); _fallbackClipRegionSsbo = fallback; } catch { TrackedGlResource.DeleteBuffer( _gl, fallback, allocated ? AcDream.App.Rendering.ClipFrame.CellClipStrideBytes : 0, "rolling back EnvCell fallback clip SSBO"); throw; } } _gl.BindBufferBase(GLEnum.ShaderStorageBuffer, AcDream.App.Rendering.ClipFrame.MeshClipSsboBinding, _fallbackClipRegionSsbo); } // --------------------------------------------------------------------------- // List pool (GetPooledList) // Copied from WB ObjectRenderManagerBase (pattern). // --------------------------------------------------------------------------- private List GetPooledList() { // Mirrors WB ObjectRenderManagerBase.cs:1221-1233 — the reuse // branch MUST clear the list before returning. PrepareRenderBatches' // merge phase pattern is `gfxDict[k] = list; list.AddRange(...)`, // which assumes the list is empty. Without the clear, lists grow // unbounded across frames and each frame's draw includes all prior // frames' stale data. Original port omitted the Clear() call — root // cause of post-Wave-5 visual chaos (FIX 2026-05-28). See // docs/research/2026-05-28-a8-env-cell-renderer-audit-findings.md. lock (_listPool) { if (_poolIndex < _listPool.Count) { var list = _listPool[_poolIndex++]; list.Clear(); return list; } var fresh = new List(); _listPool.Add(fresh); _poolIndex++; return fresh; } } // --------------------------------------------------------------------------- // Helpers: bounds computation // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- // IDisposable // --------------------------------------------------------------------------- public void Dispose() { if (IsDisposed || _disposing) return; _disposing = true; try { if (_disposeResources is null) { var releases = new List<(string Name, Action Release)> { ("prepare-scratch", _prepareScratch.Dispose), }; for (int frame = 0; frame < _dynamicBufferSetsByFrame.Length; frame++) { List frameSets = _dynamicBufferSetsByFrame[frame]; for (int index = 0; index < frameSets.Count; index++) { DynamicBufferSet set = frameSets[index]; AddTrackedBufferRelease( releases, set.MdiCommandBuffer, (long)set.MdiCommandCapacity * sizeof(DrawElementsIndirectCommand), $"dynamic-{frame}-{index}-mdi", "deleting EnvCell MDI buffer"); AddTrackedBufferRelease( releases, set.ModernInstanceBuffer, (long)set.ModernInstanceCapacity * sizeof(Matrix4x4), $"dynamic-{frame}-{index}-instances", "deleting EnvCell instance SSBO"); AddTrackedBufferRelease( releases, set.ModernBatchBuffer, (long)set.ModernBatchCapacity * sizeof(ModernBatchData), $"dynamic-{frame}-{index}-batches", "deleting EnvCell batch SSBO"); AddTrackedBufferRelease( releases, set.ClipSlotBuffer, (long)set.ClipSlotCapacity * sizeof(uint), $"dynamic-{frame}-{index}-clip-slots", "deleting EnvCell clip-slot SSBO"); AddTrackedBufferRelease( releases, set.GlobalLightsSsbo, (long)set.GlobalLightsCapacity * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * sizeof(float), $"dynamic-{frame}-{index}-global-lights", "deleting EnvCell global-light SSBO"); AddTrackedBufferRelease( releases, set.InstanceLightSetSsbo, (long)set.InstanceLightSetCapacity * AcDream.Core.Lighting.LightManager.MaxLightsPerObject * sizeof(int), $"dynamic-{frame}-{index}-light-sets", "deleting EnvCell light-set SSBO"); } } AddTrackedBufferRelease( releases, _fallbackClipRegionSsbo, AcDream.App.Rendering.ClipFrame.CellClipStrideBytes, "fallback-clip-region", "deleting EnvCell fallback clip SSBO"); _disposeResources = new RetryableResourceReleaseLedger(releases); } ResourceReleaseAttempt attempt = _disposeResources.Advance(); if (!_disposeResources.IsComplete) { throw attempt.ToException( "One or more EnvCell renderer resources could not be released."); } foreach (List frameSets in _dynamicBufferSetsByFrame) frameSets.Clear(); _activeDynamicBufferSet = null; _dynamicFrameStarted = false; _mdiCommandBuffer = 0; _modernInstanceBuffer = 0; _modernBatchBuffer = 0; _clipSlotBuffer = 0; _globalLightsSsbo = 0; _instLightSetSsbo = 0; _fallbackClipRegionSsbo = 0; _disposeResources = null; IsDisposed = true; if (attempt.HasFailures) { throw attempt.ToException( "EnvCell renderer resources released with exceptional committed outcomes."); } } finally { _disposing = false; } } private void AddTrackedBufferRelease( List<(string Name, Action Release)> releases, uint buffer, long capacityBytes, string name, string context) { if (buffer == 0) return; RetryableGpuResourceRelease release = TrackedGlResource.CreateRetryableBufferDeletion( _gl, buffer, capacityBytes, context); releases.Add((name, release.Run)); } }