From c62da825fef07ab0feefcc8a56431c169c586559 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 18 Jun 2026 17:35:33 +0200 Subject: [PATCH] =?UTF-8?q?fix(render):=20A7=20Fix=20D=20D-2=20=E2=80=94?= =?UTF-8?q?=20EnvCell=20shell=20binds=20its=20own=20per-cell=20light=20set?= =?UTF-8?q?=20(#140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cell shell read whatever light set (SSBO 4/5) WbDrawDispatcher last left bound, lighting walls with a leaked set. EnvCellRenderer now uploads its own binding=4 global lights (frame PointSnapshot via GlobalLightPacker) + a binding=5 per-instance set, computed per cell by LightManager.SelectForObject over the cell's world bounds (mirrors _cellIdToSlot + WbDrawDispatcher.ComputeEntityLightSet). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 1 + .../Rendering/Wb/EnvCellRenderer.cs | 105 ++++++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 3735979e..8fd2afda 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -7775,6 +7775,7 @@ public sealed class GameWindow : IDisposable // SceneLighting UBO built below (binding=1) — terrain/sky read those. Lighting.BuildPointLightSnapshot(camPos); _wbDrawDispatcher?.SetSceneLights(Lighting.PointSnapshot); + _envCellRenderer?.SetPointSnapshot(Lighting.PointSnapshot); // A7 Fix D (D-2) var ubo = AcDream.Core.Lighting.SceneLightingUbo.Build( Lighting, in atmo, camPos, (float)WorldTime.DayFraction); diff --git a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs index 2fe1a37a..421890e2 100644 --- a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs +++ b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs @@ -88,6 +88,17 @@ public sealed unsafe class EnvCellRenderer : IDisposable 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. @@ -231,6 +242,18 @@ public sealed unsafe class EnvCellRenderer : IDisposable _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); } @@ -262,6 +285,17 @@ public sealed unsafe class EnvCellRenderer : IDisposable 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. @@ -997,6 +1031,35 @@ public sealed unsafe class EnvCellRenderer : IDisposable } } + // --------------------------------------------------------------------------- + // 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; + if (snap is { Count: > 0 } && + _landblocks.TryGetValue(cellId & 0xFFFF0000u, 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; + AcDream.Core.Lighting.LightManager.SelectForObject(snap, center, radius, set); + } + _cellLightSetCache[cellId] = set; + return set; + } + // --------------------------------------------------------------------------- // RenderModernMDIInternal // Extracted from WB BaseObjectRenderManager.cs:709-848 (single-slot variant). @@ -1016,6 +1079,15 @@ public sealed unsafe class EnvCellRenderer : IDisposable 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 — @@ -1213,6 +1285,35 @@ public sealed unsafe class EnvCellRenderer : IDisposable (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); + } + + // 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.) @@ -1228,6 +1329,8 @@ public sealed unsafe class EnvCellRenderer : IDisposable // (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); @@ -1443,5 +1546,7 @@ public sealed unsafe class EnvCellRenderer : IDisposable 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) } }