// 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; // 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 = 1024; private uint _modernInstanceBuffer; private int _modernInstanceCapacity = 1024; private uint _modernBatchBuffer; private int _modernBatchCapacity = 1024; // 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 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 float[] _globalLightData = new float[AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * 16]; private uint _instLightSetSsbo; // binding=5 private int[] _lightSetData = new int[1024 * AcDream.Core.Lighting.LightManager.MaxLightsPerObject]; private System.Collections.Generic.IReadOnlyList? _pointSnapshot; private readonly System.Collections.Generic.Dictionary _cellLightSetCache = new(); // 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(); // 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; 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; AllocateMdiBuffers(); _initialized = true; } // --------------------------------------------------------------------------- // AllocateMdiBuffers // Mirrors WB BaseObjectRenderManager.cs:62-127 (slot-0 only). // --------------------------------------------------------------------------- private void AllocateMdiBuffers() { // MDI command buffer (DrawIndirectBuffer) _gl.GenBuffers(1, out _mdiCommandBuffer); _gl.BindBuffer(GLEnum.DrawIndirectBuffer, _mdiCommandBuffer); _gl.BufferData(GLEnum.DrawIndirectBuffer, (nuint)(_mdiCommandCapacity * sizeof(DrawElementsIndirectCommand)), null, GLEnum.DynamicDraw); // Per-frame scratch instance SSBO (binding=0) _gl.GenBuffers(1, out _modernInstanceBuffer); _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _modernInstanceBuffer); _gl.BufferData(GLEnum.ShaderStorageBuffer, (nuint)(_modernInstanceCapacity * sizeof(Matrix4x4)), null, GLEnum.DynamicDraw); // Per-batch data SSBO (binding=1) _gl.GenBuffers(1, out _modernBatchBuffer); _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _modernBatchBuffer); _gl.BufferData(GLEnum.ShaderStorageBuffer, (nuint)(_modernBatchCapacity * sizeof(ModernBatchData)), null, GLEnum.DynamicDraw); // Phase U.3: per-instance clip-slot SSBO (binding=3), sized to the // instance capacity. Uploaded all-zeros each frame in RenderModernMDIInternal. _gl.GenBuffers(1, out _clipSlotBuffer); _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _clipSlotBuffer); _gl.BufferData(GLEnum.ShaderStorageBuffer, (nuint)(_modernInstanceCapacity * sizeof(uint)), null, GLEnum.DynamicDraw); // A7 Fix D (D-2): binding=4 global lights + binding=5 per-instance light set. _gl.GenBuffers(1, out _globalLightsSsbo); _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _globalLightsSsbo); _gl.BufferData(GLEnum.ShaderStorageBuffer, (nuint)(_globalLightData.Length * sizeof(float)), null, GLEnum.DynamicDraw); _gl.GenBuffers(1, out _instLightSetSsbo); _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _instLightSetSsbo); _gl.BufferData(GLEnum.ShaderStorageBuffer, (nuint)(_modernInstanceCapacity * AcDream.Core.Lighting.LightManager.MaxLightsPerObject * sizeof(int)), null, GLEnum.DynamicDraw); _gl.BindBuffer(GLEnum.ShaderStorageBuffer, 0); _gl.BindBuffer(GLEnum.DrawIndirectBuffer, 0); } /// /// 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 _); 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(); _activeSnapshotGlobalGroups = new Dictionary>(); _activeSnapshotGlobalGfxObjIds = new List(); 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. var landblocks = new List(); 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: // Use ThreadLocal to avoid contention on ConcurrentDictionaries during parallel grouping. using var threadLocalBatchedByCell = new ThreadLocal>>>( () => new(), trackAllValues: true); using var threadLocalGlobalGroups = new ThreadLocal>>( () => new(), trackAllValues: true); // 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; var lbBatchedByCell = threadLocalBatchedByCell.Value!; var lbGlobalGroups = threadLocalGlobalGroups.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(lbBatchedByCell, lbGlobalGroups, 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(lbBatchedByCell, lbGlobalGroups, instanceData.CellId, gfxObjId, instanceData); } return; } // WB EnvCellRenderManager.cs:298-324: slow path — per-cell frustum test. var visibleCells = new HashSet(); 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(lbBatchedByCell, lbGlobalGroups, instanceData.CellId, gfxObjId, instanceData); } foreach (var (gfxObjId, instances) in lb.StaticPartGroups) foreach (var instanceData in instances) { if (visibleCells.Contains(instanceData.CellId)) AddToGroups(lbBatchedByCell, lbGlobalGroups, instanceData.CellId, gfxObjId, instanceData); } } } }); // WB EnvCellRenderManager.cs:327-373: merge thread-locals + atomic swap. var newBatchedByCell = new Dictionary>>(); var newVisibleGroups = new Dictionary>(); var newVisibleGfxObjIds = new List(); // WB EnvCellRenderManager.cs:333-347: merge per-cell batches. foreach (var localBatchedByCell in threadLocalBatchedByCell.Values) { foreach (var cellKvp in localBatchedByCell) { 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:349-358: merge global groups. foreach (var localGlobalGroups in threadLocalGlobalGroups.Values) { foreach (var kvp in localGlobalGroups) { if (!newVisibleGroups.TryGetValue(kvp.Key, out var list)) { list = GetPooledList(); newVisibleGroups[kvp.Key] = list; newVisibleGfxObjIds.Add(kvp.Key); } list.AddRange(kvp.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, // VisibleGroups / VisibleGfxObjIds are stored as extra fields below. }; // Stash the global groups on the snapshot for use in the unfiltered render path. _activeSnapshotGlobalGroups = newVisibleGroups; _activeSnapshotGlobalGfxObjIds = newVisibleGfxObjIds; _poolIndex = 0; NeedsPrepare = false; } } // Extra fields to carry global groups out of PrepareRenderBatches into Render(). // WB stores these in VisibilitySnapshot; we keep them as sibling fields since our // EnvCellVisibilitySnapshot only exposes BatchedByCell (per spec Task 4). private Dictionary> _activeSnapshotGlobalGroups = new(); private List _activeSnapshotGlobalGfxObjIds = new(); // --------------------------------------------------------------------------- // AddToGroups (static helper) // Verbatim port of WB EnvCellRenderManager.cs:375-393. // --------------------------------------------------------------------------- private static void AddToGroups( Dictionary>> batchedByCell, Dictionary> globalGroups, uint cellId, ulong gfxObjId, InstanceData data) { // WB EnvCellRenderManager.cs:377-381: add to global grouping. if (!globalGroups.TryGetValue(gfxObjId, out var globalList)) { globalList = new List(); globalGroups[gfxObjId] = globalList; } globalList.Add(data); // WB EnvCellRenderManager.cs:383-392: add to per-cell grouping. if (!batchedByCell.TryGetValue(cellId, out var gfxDict)) { gfxDict = new Dictionary>(); batchedByCell[cellId] = gfxDict; } if (!gfxDict.TryGetValue(gfxObjId, out var list)) { list = new List(); batchedByCell[cellId][gfxObjId] = list; } list.Add(data); } // --------------------------------------------------------------------------- // 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: Render(renderPass, 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) { // 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); var allInstances = new List(); var drawCalls = new List<(ObjectRenderData renderData, ulong gfxObjId, int count, int offset)>(); if (filter == null) { // 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. var filteredGroups = new Dictionary>(); var ownedLists = new HashSet>(); 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). RenderModernMDIInternal(_shader, drawCalls, allInstances, 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 = 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) { var snapshot = _activeSnapshot; if (snapshot is null || !snapshot.BatchedByCell.TryGetValue(cellId, out var gfxDict)) return false; foreach (var (gfxObjId, transforms) in gfxDict) { if (transforms.Count == 0) continue; var rd = _meshManager.TryGetRenderData(gfxObjId); if (rd is null) continue; foreach (var b in rd.Batches) if (b.IsTransparent) return true; } return false; } // --------------------------------------------------------------------------- // 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 var cached)) return cached; var set = new int[AcDream.Core.Lighting.LightManager.MaxLightsPerObject]; 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); } _cellLightSetCache[cellId] = set; 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 RenderModernMDIInternal( AcDream.App.Rendering.Shader shader, List<(ObjectRenderData renderData, ulong gfxObjId, int count, int offset)> drawCalls, List allInstances, 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; // A7 Fix D (D-2): per-frame per-cell light-set cache (built lazily in // GetCellLightSet below). Clear once here so each cell gets a fresh lookup // using this frame's _pointSnapshot. Called for EVERY pass (opaque AND // transparent); the cache entries are stable within a frame since PointSnapshot // doesn't change between Render calls, so clearing once (at the opaque pass) // and leaving stale entries for the transparent pass would also be correct, but // clearing both is safe and matches WbDrawDispatcher's per-call ComputeEntityLightSet. _cellLightSetCache.Clear(); // §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: group batches by CullMode + additive flag. var batchesByCullMode = new Dictionary>(); int totalDraws = 0; foreach (var call in drawCalls) { 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; } } // WB BaseObjectRenderManager.cs:732-740: var cullMode = batch.CullMode; var groupIdx = (int)cullMode + (batch.IsAdditive ? 4 : 0); if (!batchesByCullMode.TryGetValue(groupIdx, out var list)) { list = new List<(ObjectRenderBatch, int, int)>(); batchesByCullMode[groupIdx] = list; } list.Add((batch, call.count, call.offset)); totalDraws++; } } // WB BaseObjectRenderManager.cs:743: if (totalDraws == 0) return; // 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) { _mdiCommandCapacity = Math.Max(_mdiCommandCapacity * 2, totalDraws); _gl.BindBuffer(GLEnum.DrawIndirectBuffer, _mdiCommandBuffer); _gl.BufferData(GLEnum.DrawIndirectBuffer, (nuint)(_mdiCommandCapacity * sizeof(DrawElementsIndirectCommand)), null, GLEnum.DynamicDraw); _modernBatchCapacity = _mdiCommandCapacity; _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _modernBatchBuffer); _gl.BufferData(GLEnum.ShaderStorageBuffer, (nuint)(_modernBatchCapacity * sizeof(ModernBatchData)), null, GLEnum.DynamicDraw); } int uniqueInstanceCount = allInstances.Count; if (uniqueInstanceCount > _modernInstanceCapacity) { _modernInstanceCapacity = Math.Max(_modernInstanceCapacity * 2, uniqueInstanceCount); _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _modernInstanceBuffer); _gl.BufferData(GLEnum.ShaderStorageBuffer, (nuint)(_modernInstanceCapacity * sizeof(Matrix4x4)), null, GLEnum.DynamicDraw); // Phase U.3: keep the clip-slot buffer (binding=3) sized to the // instance buffer so instanceClipSlot[BaseInstance + gl_InstanceID] // is always in range. _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _clipSlotBuffer); _gl.BufferData(GLEnum.ShaderStorageBuffer, (nuint)(_modernInstanceCapacity * sizeof(uint)), null, GLEnum.DynamicDraw); } // 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:764-781: build commands array. int cmdIndex = 0; foreach (var group in batchesByCullMode) { foreach (var item in group.Value) { _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++; } } // WB BaseObjectRenderManager.cs:784-805: upload (with orphaning). _gl.BindBuffer(GLEnum.DrawIndirectBuffer, _mdiCommandBuffer); _gl.BufferData(GLEnum.DrawIndirectBuffer, (nuint)(totalDraws * sizeof(DrawElementsIndirectCommand)), null, GLEnum.DynamicDraw); fixed (DrawElementsIndirectCommand* ptr = _commands) { _gl.BufferSubData(GLEnum.DrawIndirectBuffer, 0, (nuint)(totalDraws * sizeof(DrawElementsIndirectCommand)), ptr); } _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _modernInstanceBuffer); _gl.BufferData(GLEnum.ShaderStorageBuffer, (nuint)(uniqueInstanceCount * sizeof(Matrix4x4)), null, GLEnum.DynamicDraw); 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); _gl.BufferData(GLEnum.ShaderStorageBuffer, (nuint)(totalDraws * sizeof(ModernBatchData)), null, GLEnum.DynamicDraw); 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); _gl.BufferData(GLEnum.ShaderStorageBuffer, (nuint)(uniqueInstanceCount * sizeof(uint)), null, GLEnum.DynamicDraw); 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); _gl.BufferData(GLEnum.ShaderStorageBuffer, (nuint)(glUploadCount * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * sizeof(float)), null, GLEnum.DynamicDraw); fixed (float* gp = _globalLightData) _gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0, (nuint)(glUploadCount * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * sizeof(float)), gp); _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _instLightSetSsbo); _gl.BufferData(GLEnum.ShaderStorageBuffer, (nuint)(uniqueInstanceCount * lightStride * sizeof(int)), null, GLEnum.DynamicDraw); fixed (int* lp = _lightSetData) _gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0, (nuint)(uniqueInstanceCount * lightStride * sizeof(int)), lp); // 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. int currentDrawOffset = 0; foreach (var group in batchesByCullMode) { var cullMode = (CullMode)(group.Key % 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 = group.Key >= 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", currentDrawOffset); int numDraws = group.Value.Count; _gl.MultiDrawElementsIndirect( PrimitiveType.Triangles, DrawElementsType.UnsignedShort, (void*)(currentDrawOffset * sizeof(DrawElementsIndirectCommand)), (uint)numDraws, (uint)sizeof(DrawElementsIndirectCommand)); currentDrawOffset += numDraws; } // 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); } // --------------------------------------------------------------------------- // #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) { _gl.GenBuffers(1, out _fallbackClipRegionSsbo); // One CellClip slot, all zeros: count 0 ⇒ shader passes every plane. var zero = new byte[AcDream.App.Rendering.ClipFrame.CellClipStrideBytes]; _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _fallbackClipRegionSsbo); fixed (byte* p = zero) { _gl.BufferData(GLEnum.ShaderStorageBuffer, (nuint)AcDream.App.Rendering.ClipFrame.CellClipStrideBytes, p, GLEnum.DynamicDraw); } } _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) return; IsDisposed = true; if (_mdiCommandBuffer != 0) { _gl.DeleteBuffer(_mdiCommandBuffer); _mdiCommandBuffer = 0; } if (_modernInstanceBuffer != 0){ _gl.DeleteBuffer(_modernInstanceBuffer); _modernInstanceBuffer = 0; } if (_modernBatchBuffer != 0) { _gl.DeleteBuffer(_modernBatchBuffer); _modernBatchBuffer = 0; } if (_clipSlotBuffer != 0) { _gl.DeleteBuffer(_clipSlotBuffer); _clipSlotBuffer = 0; } // Phase U.3 if (_fallbackClipRegionSsbo != 0) { _gl.DeleteBuffer(_fallbackClipRegionSsbo); _fallbackClipRegionSsbo = 0; } // Phase U.3 if (_globalLightsSsbo != 0) { _gl.DeleteBuffer(_globalLightsSsbo); _globalLightsSsbo = 0; } // A7 Fix D (D-2) if (_instLightSetSsbo != 0) { _gl.DeleteBuffer(_instLightSetSsbo); _instLightSetSsbo = 0; } // A7 Fix D (D-2) } }