// 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 <- collision-resistant successor to WB's content key // 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; namespace AcDream.App.Rendering.Wb; public sealed partial class EnvCellRenderer : IDisposable, IEnvCellLandblockPublisher { private readonly object _publicationOwner = new(); 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(); // 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 // 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 data, parallel to _gpuInstanceTransforms. // One uint per instance selecting its CellClip slot, indexed by the same // BaseInstance + gl_InstanceID the shader uses for binding=0. ALL ZEROS ⇒ // slot 0 ⇒ no-clip. private uint[] _clipSlotData = Array.Empty(); // A7 Fix D (D-2): this renderer owns its lighting (self-contained state, // like uViewProjection) instead of reading whatever WbDrawDispatcher last // bound. Global point-light snapshot (same data/indices as the dispatcher, // via GlobalLightPacker) plus 8 int indices per instance. private float[] _globalLightData = new float[AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * 16]; 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; // Per-GPU-fenced-frame-slot draw bookkeeping. private int _dynamicFrameSlot; private bool _dynamicFrameStarted; /// /// The dynamic per-frame-slot SSBO pool this used to report was raw-GL-only /// bookkeeping, deleted with that arm at Campaign V slice V11. The RHI arm /// allocates its storage from the GPU frame's own upload ring instead, so /// there is no separate pool to count. /// internal int DynamicBufferSetCount => 0; // 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(); public bool NeedsPrepare { get; private set; } = true; // --- Prepare gate (2026-07-24) ------------------------------------------- // PrepareRenderBatches rebuilds the visibility snapshot only when one of its // inputs changed: landblock commits/removals (NeedsPrepare), the visible-cell // filter, the trim window, mesh render-data availability (the snapshot bakes // per-cell transparency from TryGetRenderData), or the view-projection. // NeedsPrepare existed since A8 but was never read — this wires it. The VP // tolerance must swallow the ~36 µm eye rest jitter (RetailPViewRenderer // R-A2 note) while any real camera motion crosses it in the same frame. private Matrix4x4 _preparedViewProjection; private Vector3 _preparedCameraPosition; private readonly HashSet _preparedFilter = new(); private bool _preparedFilterWasNull; private (int? X, int? Y, int? Radius) _preparedTrim; private long _preparedMeshVersion = -1; private bool _hasPreparedSnapshot; /// Bumps once per visibility-snapshot rebuild (tests + diagnostics). internal int SnapshotGeneration { get; private set; } 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.TextureSlot.IsAssigned) 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 // Campaign V slice V11: the raw-GL constructor + Initialize(Shader) two-step // are deleted. EnvCellRenderer.Rhi.cs's constructor is now the class's sole // constructor — it builds the three shell pipelines itself, so there is no // second initialization step. // --------------------------------------------------------------------------- /// Resets the per-frame submission cursor for the GPU-fenced slot. public void BeginFrame(int frameSlot) { ArgumentOutOfRangeException.ThrowIfNegative(frameSlot); _dynamicFrameSlot = frameSlot; _dynamicFrameStarted = true; if (++_lightFrameGeneration == 0) { _cellLightSetCache.Clear(); _lightFrameGeneration = 1; } } // 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 // Shared collision-resistant content identity. Core retains WB's original // polynomial for conformance evidence and the installed-DAT collision test. // --------------------------------------------------------------------------- /// /// 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). /// See . /// 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) { EnvCellLandblockPublication publication = PreparePublication(build); while (!AdvancePreparationOne(publication)) { } CommitPublication(publication); } EnvCellLandblockPublication IEnvCellLandblockPublisher.PreparePublication( EnvCellLandblockBuild build) => PreparePublication(build); bool IEnvCellLandblockPublisher.AdvancePreparationOne( EnvCellLandblockPublication publication) => AdvancePreparationOne(publication); void IEnvCellLandblockPublisher.CommitPublication( EnvCellLandblockPublication publication) => CommitPublication(publication); internal EnvCellLandblockPublication PreparePublication( EnvCellLandblockBuild build) { ArgumentNullException.ThrowIfNull(build); return new EnvCellLandblockPublication(_publicationOwner, build); } internal bool AdvancePreparationOne( EnvCellLandblockPublication publication) { ValidatePublication(publication); if (publication.PreparationCommitted) return true; if (publication.ShellCursor < publication.Build.Shells.Length) { AddShell( publication.Replacement, publication.Build.Shells[publication.ShellCursor]); WbBoundingBox bounds = publication.Build.Shells[publication.ShellCursor].WorldBounds; publication.TotalBounds = publication.ShellCursor == 0 ? bounds : WbBoundingBox.Union( publication.TotalBounds, bounds); publication.ShellCursor++; return false; } publication.Replacement.TotalEnvCellBounds = publication.Replacement.EnvCellBounds.Count == 0 ? new WbBoundingBox(Vector3.Zero, Vector3.Zero) : publication.TotalBounds; publication.Replacement.InstancesReady = true; publication.Replacement.MeshDataReady = true; publication.Replacement.GpuReady = true; publication.PreparationCommitted = true; return true; } internal void CommitPublication( EnvCellLandblockPublication publication) { ValidatePublication(publication); if (!publication.PreparationCommitted) throw new InvalidOperationException( "EnvCell landblock publication cannot commit before preparation."); if (publication.PublicationCommitted) return; _landblocks[publication.Build.LandblockId] = publication.Replacement; NeedsPrepare = true; publication.PublicationCommitted = 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) AddShell(replacement, shell); 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; } private static void AddShell( EnvCellLandblock replacement, EnvCellShellPlacement shell) { 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, }); } private void ValidatePublication( EnvCellLandblockPublication publication) { ArgumentNullException.ThrowIfNull(publication); if (!ReferenceEquals(publication.Owner, _publicationOwner)) { throw new ArgumentException( "The EnvCell publication receipt belongs to another renderer.", nameof(publication)); } } /// /// 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. // Stashed even when the gate below skips the rebuild — Render must always // project with the CURRENT frame's matrix (the U.4 stale-matrix root cause). _lastViewProjection = viewProjection; // WB EnvCellRenderManager.cs:249-250: if (!_initialized || cameraPosition.Z > 4000) return; long meshVersion = _meshManager is null ? 0L : _meshManager.RenderDataAvailabilityVersion; if (filter is { Count: 0 }) { // Idempotent: an empty filter always produces the empty snapshot, so // skip once it is already active. Landblock/content changes cannot // affect an empty result; NeedsPrepare stays observable for the next // non-empty prepare. if (_hasPreparedSnapshot && !_preparedFilterWasNull && _preparedFilter.Count == 0) return; lock (_renderLock) { _poolIndex = 0; _activeSnapshot = new EnvCellVisibilitySnapshot(); _transparentCellIds.Clear(); NeedsPrepare = false; } RecordPreparedInputs(viewProjection, cameraPosition, filter, centerLbX, centerLbY, renderRadius, meshVersion); return; } // Prepare gate: every snapshot input unchanged → keep the active snapshot. // (Same-thread discipline makes the version sample exact: publish, release // tickets, and this method all run on the render thread.) if (_hasPreparedSnapshot && !NeedsPrepare && meshVersion == _preparedMeshVersion && _preparedTrim == (centerLbX, centerLbY, renderRadius) && FilterUnchanged(filter) && CameraApproximatelyEqual( viewProjection, cameraPosition, _preparedViewProjection, _preparedCameraPosition)) { 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; } RecordPreparedInputs(viewProjection, cameraPosition, filter, centerLbX, centerLbY, renderRadius, meshVersion); } private void RecordPreparedInputs( in Matrix4x4 viewProjection, Vector3 cameraPosition, HashSet? filter, int? centerLbX, int? centerLbY, int? renderRadius, long meshVersion) { _preparedViewProjection = viewProjection; _preparedCameraPosition = cameraPosition; _preparedFilterWasNull = filter is null; _preparedFilter.Clear(); if (filter is not null) _preparedFilter.UnionWith(filter); _preparedTrim = (centerLbX, centerLbY, renderRadius); _preparedMeshVersion = meshVersion; _hasPreparedSnapshot = true; SnapshotGeneration++; } private bool FilterUnchanged(HashSet? filter) { if (filter is null) return _preparedFilterWasNull; if (_preparedFilterWasNull) return false; // The caller reuses one scratch HashSet across frames, so compare by // content against our recorded copy, never by reference. return filter.Count == _preparedFilter.Count && _preparedFilter.SetEquals(filter); } /// /// Pure half of the prepare gate's camera test (regression-tested without a /// GL context, same pattern as ). /// Eye position uses a 1 mm ABSOLUTE epsilon: it swallows the ~36 µm rest /// jitter but dirties on any real movement (a slow walk moves 20+ mm/frame). /// Position must not be tested through the matrix — the view-projection's /// translation row scales with world coordinates (~5e4 in AC), where a /// relative tolerance would mask sub-meter motion. Rows 1–3 of /// view × projection are position-independent (rotation × projection), so a /// relative 1e-5 there dirties at ≈0.001° of rotation and on any /// projection (FOV/aspect/near/far) change. /// internal static bool CameraApproximatelyEqual( in Matrix4x4 vpA, Vector3 eyeA, in Matrix4x4 vpB, Vector3 eyeB) { const float EyeEpsilonSq = 1e-3f * 1e-3f; if (Vector3.DistanceSquared(eyeA, eyeB) > EyeEpsilonSq) return false; return Close(vpA.M11, vpB.M11) && Close(vpA.M12, vpB.M12) && Close(vpA.M13, vpB.M13) && Close(vpA.M14, vpB.M14) && Close(vpA.M21, vpB.M21) && Close(vpA.M22, vpB.M22) && Close(vpA.M23, vpB.M23) && Close(vpA.M24, vpB.M24) && Close(vpA.M31, vpB.M31) && Close(vpA.M32, vpB.M32) && Close(vpA.M33, vpB.M33) && Close(vpA.M34, vpB.M34); static bool Close(float x, float y) { const float Rel = 1e-5f; return MathF.Abs(x - y) <= Rel * MathF.Max(1f, MathF.Max(MathF.Abs(x), MathF.Abs(y))); } } 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: the RHI arm's three pipelines are built // at construction (see EnvCellRenderer.Rhi.cs), so _initialized alone // answers whether this renderer is ready to draw. if (!_initialized) return; lock (_renderLock) { var snapshot = _activeSnapshot; // 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; 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( drawCalls, allInstances, _drawCallRanges, renderPass); } // WB EnvCellRenderManager.cs:486-510: selection/hover highlights — DROPPED (no editor state). // 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.TextureSlot.IsAssigned) 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 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 RenderModernMDIInternal( 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; // Campaign V slice V6j: the RHI arm has no vertex array — the pipeline // owns one shaped by GpuVertexLayout.WorldMesh — so its readiness test is // the backend-neutral HasStores flag the mesh arena publishes (V6i-3). if (_meshManager.GlobalBuffer is not { HasStores: true }) return; // 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; int uniqueInstanceCount = allInstances.Count; // Campaign V slice V6j: the encoder arm owns no buffer pool and no // imperative state bracket. Every per-frame section is a ring slice // allocated fresh in SubmitRhi, and blend plus depth-write are baked // into the three shell pipelines rather than set here. The frame- // started invariant this used to enforce via ActivateNextDynamicBufferSet // still matters (it gates the light-frame-generation cache), so it is // checked directly. if (!_dynamicFrameStarted) throw new InvalidOperationException("BeginFrame must be called before drawing EnvCells."); // 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 { // Campaign V slice V2: table slot, not the raw handle. // Slice V4t: the slot is the device's, assigned by the // mesh manager at upload rather than interned here. TextureTableIndex = item.batch.TextureSlot.Index, 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); } } SubmitRhi(allInstances, renderPass, totalDraws, uniqueInstanceCount); } 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}"); } // --------------------------------------------------------------------------- // 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), }; // Campaign V slice V11: the raw-GL arm's dynamic buffer-set pool is // deleted along with it. The RHI arm's pipelines route their // physical free through the device's own retirement queue, so the // ledger above holds only the scratch. DisposeRhiResources(); _disposeResources = new RetryableResourceReleaseLedger(releases); } ResourceReleaseAttempt attempt = _disposeResources.Advance(); if (!_disposeResources.IsComplete) { throw attempt.ToException( "One or more EnvCell renderer resources could not be released."); } _dynamicFrameStarted = false; _disposeResources = null; IsDisposed = true; if (attempt.HasFailures) { throw attempt.ToException( "EnvCell renderer resources released with exceptional committed outcomes."); } } finally { _disposing = false; } } }