From 543bc79f8adbadf9e23bafdf184a0a92bcb5e07d Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 27 Jul 2026 22:38:02 +0200 Subject: [PATCH] Revert "feat(render): Campaign V slice V4c - move the world draw path onto the RHI" This reverts commit f353fb53f8a6bbd35cf3d5af1c9610aea23b0802. --- .../LivePresentationComposition.cs | 7 +- .../Rendering/Gpu/Gl/GlGpuDevice.cs | 47 +- src/AcDream.App/Rendering/RenderBootstrap.cs | 2 +- src/AcDream.App/Rendering/Shader.cs | 11 +- .../Rendering/Wb/EnvCellRenderer.cs | 941 ++++++------ .../Rendering/Wb/GlobalMeshBuffer.cs | 17 - .../Rendering/Wb/WbDrawDispatcher.cs | 1350 ++++++++--------- .../Rendering/Wb/EnvCellRendererTests.cs | 12 +- 8 files changed, 1172 insertions(+), 1215 deletions(-) diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs index 3e2ef8db..26ffa74e 100644 --- a/src/AcDream.App/Composition/LivePresentationComposition.cs +++ b/src/AcDream.App/Composition/LivePresentationComposition.cs @@ -680,8 +680,7 @@ internal sealed class LivePresentationCompositionPhase "WB draw dispatcher", () => new WbDrawDispatcher( d.Gl, - host.GpuDevice, - host.GpuFrameLifetime, + foundation.MeshShader, foundation.TextureCache, foundation.MeshAdapter, entitySpawnAdapter, @@ -856,12 +855,10 @@ internal sealed class LivePresentationCompositionPhase "environment-cell renderer", () => new EnvCellRenderer( d.Gl, - host.GpuDevice, - host.GpuFrameLifetime, foundation.MeshAdapter.MeshManager!, envCellFrustum), static value => value.Dispose()); - envCellLease.Resource.Initialize(); + envCellLease.Resource.Initialize(foundation.MeshShader); Fault(LivePresentationCompositionPoint.EnvironmentCellsCreated); var landblockRenderPublisher = new LandblockRenderPublisher( diff --git a/src/AcDream.App/Rendering/Gpu/Gl/GlGpuDevice.cs b/src/AcDream.App/Rendering/Gpu/Gl/GlGpuDevice.cs index 22076739..dd329df8 100644 --- a/src/AcDream.App/Rendering/Gpu/Gl/GlGpuDevice.cs +++ b/src/AcDream.App/Rendering/Gpu/Gl/GlGpuDevice.cs @@ -190,30 +190,6 @@ internal sealed class GlGpuDevice : IGpuDevice string fragmentPath = Path.Combine(_shadersDirectory, $"{description.Shaders.Name}.frag"); string vertexSource = File.ReadAllText(vertexPath); string fragmentSource = File.ReadAllText(fragmentPath); - - // Campaign V slice V4c: splice the slice-V2 shared preamble - // (Shaders/common.glsl) into every pipeline, using Shader's own - // InjectPreamble so a pipeline-compiled program and a - // Shader-compiled one are built from byte-identical sources. - // - // mesh_modern REQUIRES it — the preamble declares the binding-9 - // texture table and defines ACDREAM_TEXTURE_HANDLE / ACDREAM_UBO_SET, - // so without it the world shaders do not compile at all. Applying it - // unconditionally rather than per-pipeline keeps one rule: every - // shader this backend compiles sees the same preamble, which is also - // what the Vulkan backend gets for free once the sources are compiled - // to .spv. For shaders that reference none of it (ui_text, - // debug_line) the added text is an unused SSBO declaration and two - // macro definitions — every shader in the tree is #version 430 core, - // so the std430 declaration is always legal. - string commonPath = Path.Combine(_shadersDirectory, "common.glsl"); - if (File.Exists(commonPath)) - { - string commonSource = File.ReadAllText(commonPath); - vertexSource = Shader.InjectPreamble(vertexSource, commonSource); - fragmentSource = Shader.InjectPreamble(fragmentSource, commonSource); - } - return new GlGpuPipeline(_gl, Retirement, description, vertexSource, fragmentSource); } @@ -321,31 +297,14 @@ internal sealed class GlGpuDevice : IGpuDevice "only ever renders single-sampled targets."); } - // Campaign V slice V4c: a null Color.Target means "whatever the frame - // spine has bound", NOT "framebuffer 0" — so this deliberately does - // not rebind. GpuPassDescription's own remarks say the transitional - // path keeps "clears and framebuffer management" with the spine, and - // forcing 0 here breaks that: PrivateEntityViewportRenderer binds its - // offscreen FBO and then calls WbDrawDispatcher.Draw (see that file's - // RenderToTexture), as does PortalTunnelPresentation. Once the - // dispatcher records through an encoder, binding 0 on BeginPass would - // redirect the paperdoll and creature-appraisal viewports to the - // backbuffer and leave their textures empty — and the offline pixel - // gate does not cover those viewports, so it would have shipped - // silently. This is the same class of fix as the ambient-capability - // save/restore in GlGpuPassEncoder (plan §7.1 rule 1): while raw-GL - // renderers still own framebuffers, the backend preserves what they - // bound rather than asserting its own. Removed at V4h, when the spine - // declares real passes and a target is always explicit. - // - // An explicit Target still binds, because then the caller HAS named - // the attachment. + uint framebuffer = 0; if (description.Color.Target is { } target) { if (target is not GlGpuRenderTarget glTarget) throw new ArgumentException("The GL backend can only render into a GL render target."); - _gl.BindFramebuffer(GLEnum.Framebuffer, glTarget.GlFramebufferName); + framebuffer = glTarget.GlFramebufferName; } + _gl.BindFramebuffer(GLEnum.Framebuffer, framebuffer); bool clearsColor = description.Color.Load == GpuLoadOp.Clear; bool clearsDepth = description.Depth is { Load: GpuLoadOp.Clear }; diff --git a/src/AcDream.App/Rendering/RenderBootstrap.cs b/src/AcDream.App/Rendering/RenderBootstrap.cs index 83ad72fd..10e76490 100644 --- a/src/AcDream.App/Rendering/RenderBootstrap.cs +++ b/src/AcDream.App/Rendering/RenderBootstrap.cs @@ -249,7 +249,7 @@ public static class RenderBootstrap // --- WbDrawDispatcher (GameWindow ~2377-2381) --- var drawDispatcher = new Wb.WbDrawDispatcher( - gl, gpuDevice, gpuFrameLifetime, textureCache, meshAdapter, entitySpawnAdapter, + gl, meshShader, textureCache, meshAdapter, entitySpawnAdapter, bindless, classificationCache, translucencyFades); drawDispatcher.AlphaToCoverage = opts.Quality.AlphaToCoverage; diff --git a/src/AcDream.App/Rendering/Shader.cs b/src/AcDream.App/Rendering/Shader.cs index 1be4b000..b74960ae 100644 --- a/src/AcDream.App/Rendering/Shader.cs +++ b/src/AcDream.App/Rendering/Shader.cs @@ -52,16 +52,7 @@ public sealed class Shader : IDisposable /// preamble cannot simply be prepended — it has to land after that block, /// before the first real declaration. /// - /// - /// Campaign V slice V4c widened this from private to - /// internal so GlGpuDevice.CreatePipeline splices the - /// preamble with the SAME code rather than a second copy of the rule. - /// mesh_modern needs the preamble (it calls ACDREAM_TEXTURE_HANDLE and - /// ACDREAM_UBO_SET), and a pipeline-compiled copy that differed from the - /// -compiled one by even a line would be a silent - /// divergence between two programs that must stay identical. - /// - internal static string InjectPreamble(string source, string preamble) + private static string InjectPreamble(string source, string preamble) { int insertAt = 0; int lineStart = 0; diff --git a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs index 674b01c4..4c4054a1 100644 --- a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs +++ b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs @@ -24,7 +24,6 @@ using System.Numerics; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; -using AcDream.App.Rendering.Gpu; using DatReaderWriter.Enums; using Silk.NET.OpenGL; @@ -49,16 +48,13 @@ public sealed unsafe class EnvCellRenderer : private readonly object _renderLock = new(); private EnvCellVisibilitySnapshot _activeSnapshot = new(); - // Campaign V slice V4c: the shared legacy Shader this renderer used to be - // handed by Initialize is replaced by three mesh_modern pipeline variants - // built by Initialize itself. Everything it used to set imperatively — - // program bind, uViewProjection, uLightingMode, uRenderPass, uLightDebug — - // is now a pipeline bind plus the shared push-constant block. - private readonly IGpuDevice _device; - private readonly ICurrentGpuFrameSource _frameSource; - private IGpuPipeline? _opaquePipeline; - private IGpuPipeline? _alphaBlendPipeline; - private IGpuPipeline? _alphaAdditivePipeline; + // 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 @@ -85,10 +81,12 @@ public sealed unsafe class EnvCellRenderer : // 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. - // Campaign V slice V4c: the MDI command array, the instance transforms and - // the batch metadata are per-frame ring allocations rather than renderer- - // owned GL buffers, so their capacity bookkeeping is gone with them — the - // ring sizes each allocation to the exact demand of the pass that takes it. + private uint _mdiCommandBuffer; + private int _mdiCommandCapacity; + private uint _modernInstanceBuffer; + private int _modernInstanceCapacity; + private uint _modernBatchBuffer; + private int _modernBatchCapacity; // mesh_modern.vert's SSBO InstanceData is only mat4 transform. The CPU // InstanceData below also carries CellId/Flags for filtering, so upload a // packed transform array instead of the 80-byte CPU struct. @@ -98,13 +96,19 @@ public sealed unsafe class EnvCellRenderer : // _modernInstanceBuffer. One uint per instance selecting its CellClip slot, // indexed by the same BaseInstance + gl_InstanceID the shader uses for // binding=0. ALL ZEROS in U.3 ⇒ slot 0 ⇒ no-clip. U.4 populates real slots. + private uint _clipSlotBuffer; + private int _clipSlotCapacity; private uint[] _clipSlotData = Array.Empty(); // A7 Fix D (D-2): this renderer owns its lighting (self-contained GL state, // like uViewProjection) instead of reading the SSBO 4/5 WbDrawDispatcher last // left bound. binding=4 = global point-light snapshot (same data/indices as the // dispatcher, via GlobalLightPacker); binding=5 = 8 int indices per instance. + private uint _globalLightsSsbo; // binding=4 + private int _globalLightsCapacity; private float[] _globalLightData = new float[AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * 16]; + private uint _instLightSetSsbo; // binding=5 + private int _instLightSetCapacity; private int[] _lightSetData = new int[1024 * AcDream.Core.Lighting.LightManager.MaxLightsPerObject]; private System.Collections.Generic.IReadOnlyList? _pointSnapshot; private sealed class CachedCellLightSet @@ -117,22 +121,31 @@ public sealed unsafe class EnvCellRenderer : private readonly List _cellLightRemovalScratch = new(); private int _lightFrameGeneration; + private sealed class DynamicBufferSet + { + public uint MdiCommandBuffer; + public uint ModernInstanceBuffer; + public uint ModernBatchBuffer; + public uint ClipSlotBuffer; + public uint GlobalLightsSsbo; + public uint InstanceLightSetSsbo; + public int MdiCommandCapacity; + public int ModernInstanceCapacity; + public int ModernBatchCapacity; + public int ClipSlotCapacity; + public int GlobalLightsCapacity; + public int InstanceLightSetCapacity; + } + + private readonly List[] _dynamicBufferSetsByFrame = + [[], [], []]; + private int _dynamicFrameSlot; + private int _dynamicBufferSetCursor; private bool _dynamicFrameStarted; + private DynamicBufferSet? _activeDynamicBufferSet; - /// - /// Frames-in-flight slots the spine rotates through. Unchanged from the - /// retired buffer-set pool's fixed three, so - /// rejects exactly the arguments it rejected before. - /// - private const int FrameSlotCount = 3; - - /// - /// Always 0 since Campaign V slice V4c: this renderer owns no per-frame GL - /// buffer pool. Its instance, batch, clip-slot, light and indirect data are - /// slices. Kept so - /// RenderFrameDiagnosticSources' resource snapshot keeps its shape. - /// - internal int DynamicBufferSetCount => 0; + internal int DynamicBufferSetCount => + _dynamicBufferSetsByFrame.Sum(frameSets => frameSets.Count); // Phase U.3: SHARED per-cell clip-region SSBO (binding=2) handed in via // SetClipRegionSsbo (the GameWindow-level ClipFrame buffer). When 0, we bind @@ -149,12 +162,9 @@ public sealed unsafe class EnvCellRenderer : // call). See GlBindlessHandleTable's doc comment and the campaign doc's // §5.2. Lazily created; grown/uploaded only when a genuinely new handle // appears (rare — see FlushAndBindTextureTable). - // Campaign V slice V4c moved the table's storage from a raw GL name onto - // IGpuBuffer and binds it through the pass encoder. Retiring the table - // itself in favour of IGpuDevice's own is slice V4t (campaign doc §5.3). private readonly GlBindlessHandleTable _textureTable = new(); - private IGpuBuffer? _textureTableBuffer; - private int _textureTableBufferBytes; + private uint _textureTableSsbo; + private int _textureTableSsboCapacityBytes; // Reusable scratch arrays — avoid per-frame allocation. // WB BaseObjectRenderManager.cs:58-59: private DrawElementsIndirectCommand[] _commands = Array.Empty<...>() @@ -183,9 +193,8 @@ public sealed unsafe class EnvCellRenderer : private readonly List _activeSnapshotGlobalGfxObjIds = new(); // Static render-state tracking — matches WB BaseObjectRenderManager.cs:24-28. - // Shared across all manager instances on the same GL context. The VAO half - // retired at Campaign V slice V4c: the pipeline owns the vertex array, so - // there is no renderer-side VAO to skip rebinding. + // 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; @@ -291,85 +300,28 @@ public sealed unsafe class EnvCellRenderer : // Constructor + Initialize // --------------------------------------------------------------------------- - internal EnvCellRenderer( - GL gl, - IGpuDevice device, - ICurrentGpuFrameSource frameSource, - ObjectMeshManager meshManager, - WbFrustum frustum) + public EnvCellRenderer(GL gl, ObjectMeshManager meshManager, WbFrustum frustum) { _gl = gl; - _device = device; - _frameSource = frameSource; _meshManager = meshManager; _frustum = frustum; } - /// - /// Campaign V slice V4c: the cell shells share mesh_modern with - /// and therefore share its pipeline shape, - /// but they are their own pass with their own state, so this renderer - /// builds its own three variants. Blend and alpha-to-coverage are the only - /// dimensions core Vulkan does not make dynamic; cull mode and front face - /// stay per-draw calls exactly where SetCullMode made them. - /// - /// Depth compare is to match the frame - /// default this pass inherited (RenderFrameGlStateController), and - /// alpha-to-coverage is off on all three because the shell pass never - /// enabled it. - /// - private static IGpuPipeline CreateShellPipeline( - IGpuDevice device, - string name, - GpuBlendMode blend, - bool depthWrite) => - device.CreatePipeline(new GpuPipelineDescription - { - Name = name, - Shaders = new GpuShaderSet("mesh_modern"), - VertexLayout = GpuVertexLayout.WorldMesh, - Topology = GpuPrimitiveTopology.TriangleList, - Blend = blend, - Depth = new GpuDepthState(Test: true, Write: depthWrite, GpuCompareOp.Less), - Cull = GpuCullMode.Back, - FrontFace = GpuFrontFace.Clockwise, - AlphaToCoverage = false, - ColorWrite = true, - SampleCount = 1, - }); - - /// - /// Latches the renderer as ready to draw and builds its pipelines. - /// - /// Campaign V slice V4c dropped the Shader argument this used to - /// take: the shared mesh_modern program is now compiled per - /// pipeline variant through , so - /// there is nothing left for a caller to hand over. Building here rather - /// than in the constructor keeps pipeline creation off the unit tests that - /// construct this type with null GL/device purely to exercise its pure - /// grouping and range-merging logic. - /// - public void Initialize() + public void Initialize(AcDream.App.Rendering.Shader shader) { - _opaquePipeline ??= CreateShellPipeline( - _device, "envcell-opaque", GpuBlendMode.None, depthWrite: true); - _alphaBlendPipeline ??= CreateShellPipeline( - _device, "envcell-alpha", GpuBlendMode.StraightAlpha, depthWrite: false); - _alphaAdditivePipeline ??= CreateShellPipeline( - _device, "envcell-additive", GpuBlendMode.Additive, depthWrite: false); + _shader = shader; _initialized = true; } /// Resets the per-frame submission cursor for the GPU-fenced slot. public void BeginFrame(int frameSlot) { - if ((uint)frameSlot >= (uint)FrameSlotCount) + if ((uint)frameSlot >= (uint)_dynamicBufferSetsByFrame.Length) throw new ArgumentOutOfRangeException(nameof(frameSlot)); - // Campaign V slice V4c: the slot no longer selects a buffer set — - // IGpuDevice.BeginFrame already rotated the fence-gated ring slot. The - // argument and its range check remain this renderer's published - // contract with the frame spine. + _dynamicFrameSlot = frameSlot; + _dynamicBufferSetCursor = 0; _dynamicFrameStarted = true; + _activeDynamicBufferSet = null; if (++_lightFrameGeneration == 0) { _cellLightSetCache.Clear(); @@ -1019,13 +971,13 @@ public sealed unsafe class EnvCellRenderer : IReadOnlyList? orderedCellIds) { // WB EnvCellRenderManager.cs:400: - if (!_initialized || _opaquePipeline is null) return; + if (!_initialized || _shader is null || _shader.Program == 0) return; lock (_renderLock) { var snapshot = _activeSnapshot; - // WB EnvCellRenderManager.cs:403-404: the program bind moved into - // the pass (BindPipeline), so nothing happens here any more. + // 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 @@ -1051,24 +1003,27 @@ public sealed unsafe class EnvCellRenderer : // 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 at entry guarantees each Render call - // re-establishes the GL state it expects. (The VAO half of this - // cache retired with slice V4c — the pipeline owns the vertex - // array now — but the cull-mode half still matters for exactly the - // reason above.) + // 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. All four - // uniforms are now fields of the shared push-constant block written - // inside RenderModernMDIInternal's pass, so they are set there - // instead — including uViewProjection, whose self-contained upload - // is the Phase U.4 root-cause fix for cell-shell flicker - // ("transparent walls when moving"): the opaque shell pass runs - // BEFORE WbDrawDispatcher's Draw, so inheriting the matrix meant - // drawing shells with the PREVIOUS frame's gl_Position against this - // frame's clip planes. uFilterByCell is dropped outright: it is - // declared in neither mesh_modern stage, so the SetInt resolved to - // location -1 and was already a no-op. + // WB EnvCellRenderManager.cs:406-409: uniform state setup. + _shader.SetInt("uRenderPass", (int)renderPass); + _shader.SetInt("uFilterByCell", 0); + _shader.SetInt("uLightingMode", 1); // A7 Fix D D-3/D-4: EnvCell bake (wrap points, no sun) + // #176 stripe-hunt isolation (ACDREAM_LIGHT_DEBUG) — throwaway diagnostic. + _shader.SetInt("uLightDebug", AcDream.Core.Rendering.RenderingDiagnostics.LightDebugMode); + + // Phase U.4 ROOT-CAUSE FIX (cell-shell flicker / "transparent walls when + // moving"): upload uViewProjection HERE rather than inheriting it from + // WbDrawDispatcher. The opaque shell pass runs BEFORE the dispatcher's + // Draw (GameWindow ~7411 vs ~7418, the only other setter), so without + // this the opaque shells used the PREVIOUS frame's matrix — a stale + // gl_Position against this frame's clip planes → pose-dependent clipping, + // worst while moving. Same self-contained-GL-state precedent as the + // 2026-05-28 cull-state cache fix above. + _shader.SetMatrix4("uViewProjection", _lastViewProjection); List allInstances = _renderInstances; List<(ObjectRenderData renderData, ulong gfxObjId, int count, int offset)> drawCalls = @@ -1182,6 +1137,7 @@ public sealed unsafe class EnvCellRenderer : if (_drawCallRanges.Count == 0 && drawCalls.Count > 0) _drawCallRanges.Add(new DrawCallRange(0, drawCalls.Count)); RenderModernMDIInternal( + _shader, drawCalls, allInstances, _drawCallRanges, @@ -1190,13 +1146,11 @@ public sealed unsafe class EnvCellRenderer : // WB EnvCellRenderManager.cs:486-510: selection/hover highlights — DROPPED (no editor state). - // WB EnvCellRenderManager.cs:506-509: cleanup. The two uniform - // writes that used to sit here are gone: uHighlightColor is - // declared in neither mesh_modern stage (a no-op at location -1, - // left over from WB's editor highlight path), and uRenderPass was - // being reset to the value it already held. The VAO unbind remains - // because raw-GL renderers still run after this one. + // 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 @@ -1322,6 +1276,58 @@ public sealed unsafe class EnvCellRenderer : // issues glMultiDrawElementsIndirect. // --------------------------------------------------------------------------- + private void ActivateNextDynamicBufferSet() + { + if (!_dynamicFrameStarted) + throw new InvalidOperationException("BeginFrame must be called before drawing EnvCells."); + + List slotSets = _dynamicBufferSetsByFrame[_dynamicFrameSlot]; + if (_dynamicBufferSetCursor == slotSets.Count) + slotSets.Add(CreateDynamicBufferSet()); + + DynamicBufferSet set = slotSets[_dynamicBufferSetCursor++]; + _activeDynamicBufferSet = set; + _mdiCommandBuffer = set.MdiCommandBuffer; + _modernInstanceBuffer = set.ModernInstanceBuffer; + _modernBatchBuffer = set.ModernBatchBuffer; + _clipSlotBuffer = set.ClipSlotBuffer; + _globalLightsSsbo = set.GlobalLightsSsbo; + _instLightSetSsbo = set.InstanceLightSetSsbo; + _mdiCommandCapacity = set.MdiCommandCapacity; + _modernInstanceCapacity = set.ModernInstanceCapacity; + _modernBatchCapacity = set.ModernBatchCapacity; + _clipSlotCapacity = set.ClipSlotCapacity; + _globalLightsCapacity = set.GlobalLightsCapacity; + _instLightSetCapacity = set.InstanceLightSetCapacity; + } + + private DynamicBufferSet CreateDynamicBufferSet() + { + var set = new DynamicBufferSet(); + try + { + set.MdiCommandBuffer = TrackedGlResource.CreateBuffer(_gl, "creating EnvCell MDI buffer"); + set.ModernInstanceBuffer = TrackedGlResource.CreateBuffer(_gl, "creating EnvCell instance SSBO"); + set.ModernBatchBuffer = TrackedGlResource.CreateBuffer(_gl, "creating EnvCell batch SSBO"); + set.ClipSlotBuffer = TrackedGlResource.CreateBuffer(_gl, "creating EnvCell clip-slot SSBO"); + set.GlobalLightsSsbo = TrackedGlResource.CreateBuffer(_gl, "creating EnvCell global-light SSBO"); + set.InstanceLightSetSsbo = TrackedGlResource.CreateBuffer(_gl, "creating EnvCell light-set SSBO"); + return set; + } + catch (Exception creationFailure) + { + try { DeleteDynamicBufferSet(set); } + catch (Exception cleanupFailure) + { + throw new AggregateException( + "EnvCell dynamic-buffer creation and rollback failed.", + creationFailure, + cleanupFailure); + } + throw; + } + } + private void RebuildUnfilteredGroups(EnvCellVisibilitySnapshot snapshot) { foreach (List instances in _activeSnapshotGlobalGroups.Values) @@ -1344,7 +1350,59 @@ public sealed unsafe class EnvCellRenderer : } } + private void DeleteDynamicBufferSet(DynamicBufferSet set) + { + List? failures = null; + void Attempt(uint buffer, long bytes, string name) + { + try { TrackedGlResource.DeleteBuffer(_gl, buffer, bytes, $"deleting {name}"); } + catch (Exception ex) { (failures ??= []).Add(ex); } + } + + Attempt( + set.MdiCommandBuffer, + (long)set.MdiCommandCapacity * sizeof(DrawElementsIndirectCommand), + "EnvCell MDI buffer"); + Attempt( + set.ModernInstanceBuffer, + (long)set.ModernInstanceCapacity * sizeof(Matrix4x4), + "EnvCell instance SSBO"); + Attempt( + set.ModernBatchBuffer, + (long)set.ModernBatchCapacity * sizeof(ModernBatchData), + "EnvCell batch SSBO"); + Attempt(set.ClipSlotBuffer, (long)set.ClipSlotCapacity * sizeof(uint), "EnvCell clip-slot SSBO"); + Attempt( + set.GlobalLightsSsbo, + (long)set.GlobalLightsCapacity + * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight + * sizeof(float), + "EnvCell global-light SSBO"); + Attempt( + set.InstanceLightSetSsbo, + (long)set.InstanceLightSetCapacity + * AcDream.Core.Lighting.LightManager.MaxLightsPerObject + * sizeof(int), + "EnvCell light-set SSBO"); + + if (failures is not null) + throw new AggregateException("One or more EnvCell dynamic buffers failed to delete.", failures); + } + + private void PersistActiveDynamicBufferCapacities() + { + DynamicBufferSet set = _activeDynamicBufferSet + ?? throw new InvalidOperationException("No dynamic EnvCell buffer set is active."); + set.MdiCommandCapacity = _mdiCommandCapacity; + set.ModernInstanceCapacity = _modernInstanceCapacity; + set.ModernBatchCapacity = _modernBatchCapacity; + set.ClipSlotCapacity = _clipSlotCapacity; + set.GlobalLightsCapacity = _globalLightsCapacity; + set.InstanceLightSetCapacity = _instLightSetCapacity; + } + private void RenderModernMDIInternal( + AcDream.App.Rendering.Shader shader, List<(ObjectRenderData renderData, ulong gfxObjId, int count, int offset)> drawCalls, List allInstances, IReadOnlyList drawCallRanges, @@ -1363,9 +1421,9 @@ public sealed unsafe class EnvCellRenderer : var globalVao = _meshManager.GlobalBuffer?.VAO ?? 0u; if (globalVao == 0) return; - // WB BaseObjectRenderManager.cs:715-716: the program bind moved into - // the pass below, and uFilterByCell was already a no-op (declared in - // neither mesh_modern stage, so it resolved to location -1). + // WB BaseObjectRenderManager.cs:715-716: + shader.Use(); + shader.SetInt("uFilterByCell", 0); // WB BaseObjectRenderManager.cs:718-740: count the pass-filtered batches. // A normal render has one range. The ordered transparent-shell path has @@ -1401,33 +1459,114 @@ public sealed unsafe class EnvCellRenderer : // WB BaseObjectRenderManager.cs:743: if (totalDraws == 0) return; + ActivateNextDynamicBufferSet(); // Phase U.4 ROOT-CAUSE FIX (cell-shell "transparent walls / only bluish - // background, flickering when moving"): this pass's BLEND + DepthMask state is - // established BY THIS RENDERER rather than inherited. Campaign V slice V4c bakes - // it into the pipeline variant chosen below — Blend-off/DepthMask-on for the - // opaque and single passes, Blend-on/DepthMask-off for the transparent one — - // which is the same state the imperative block here used to set, now - // unforgeable rather than a pair of calls that had to be paired with a restore. + // 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): the state used to be established - // 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. Binding the state with the pipeline INSIDE the pass — which is - // only reached past the totalDraws==0 early-out above — makes that failure shape - // unreachable rather than merely avoided. - IGpuPipeline passPipeline = renderPass == WbRenderPass.Transparent - ? _alphaBlendPipeline! - : _opaquePipeline!; + // §4 outdoor full-world flap fix (2026-06-10): this block MOVED below the + // totalDraws==0 early-out above. It used to run before the batch grouping, so a + // Transparent pass over a cell whose batches are ALL opaque (a plain cottage + // interior) set Blend-on/DepthMask-off and then returned at the count check + // WITHOUT reaching the restore. The frame ended with dmask=0; the NEXT frame's + // glClear(DEPTH) silently no-oped (depth clears honor glDepthMask), every world + // fragment failed GL_LESS against its own previous-frame depth ghost, and the + // whole screen dropped to the fog-tinted clear color — onset-locked to the + // building-flood merge (the first frame a flooded building shell draws), holding + // until camera rotation dropped the cell from the flood. From here down every + // path reaches the end-of-pass restore. + if (renderPass == WbRenderPass.Transparent) + { + _gl.Enable(EnableCap.Blend); + _gl.DepthMask(false); + } + else + { + _gl.Disable(EnableCap.Blend); + _gl.DepthMask(true); + } + + // WB BaseObjectRenderManager.cs:745-759: resize buffers if needed. + if (totalDraws > _mdiCommandCapacity) + { + int grownMdiCapacity = Math.Max(_mdiCommandCapacity * 2, totalDraws); + TrackedGlResource.AllocateBufferStorage( + _gl, + GLEnum.DrawIndirectBuffer, + _mdiCommandBuffer, + (long)_mdiCommandCapacity * sizeof(DrawElementsIndirectCommand), + (long)grownMdiCapacity * sizeof(DrawElementsIndirectCommand), + GLEnum.DynamicDraw, + $"growing EnvCell MDI buffer to {grownMdiCapacity} commands"); + _mdiCommandCapacity = grownMdiCapacity; + + int grownBatchCapacity = grownMdiCapacity; + TrackedGlResource.AllocateBufferStorage( + _gl, + GLEnum.ShaderStorageBuffer, + _modernBatchBuffer, + (long)_modernBatchCapacity * sizeof(ModernBatchData), + (long)grownBatchCapacity * sizeof(ModernBatchData), + GLEnum.DynamicDraw, + $"growing EnvCell batch SSBO to {grownBatchCapacity} batches"); + _modernBatchCapacity = grownBatchCapacity; + } - // WB BaseObjectRenderManager.cs:745-759 used to resize six renderer-owned - // buffers here. Ring allocations size themselves to each pass's exact demand, - // so only the CPU scratch arrays below still grow. int uniqueInstanceCount = allInstances.Count; + if (uniqueInstanceCount > _modernInstanceCapacity) + { + int grownInstanceCapacity = Math.Max(_modernInstanceCapacity * 2, uniqueInstanceCount); + TrackedGlResource.AllocateBufferStorage( + _gl, + GLEnum.ShaderStorageBuffer, + _modernInstanceBuffer, + (long)_modernInstanceCapacity * sizeof(Matrix4x4), + (long)grownInstanceCapacity * sizeof(Matrix4x4), + GLEnum.DynamicDraw, + $"growing EnvCell instance SSBO to {grownInstanceCapacity} instances"); + _modernInstanceCapacity = grownInstanceCapacity; + } + + // Phase U.3: keep the clip-slot buffer (binding=3) sized to the + // instance prefix so instanceClipSlot[BaseInstance + gl_InstanceID] + // is always in range. It owns an independent committed capacity so a + // failed allocation can never publish the instance buffer's growth as + // if both resources had succeeded. + if (uniqueInstanceCount > _clipSlotCapacity) + { + int grownClipCapacity = Math.Max(_clipSlotCapacity * 2, uniqueInstanceCount); + TrackedGlResource.AllocateBufferStorage( + _gl, + GLEnum.ShaderStorageBuffer, + _clipSlotBuffer, + (long)_clipSlotCapacity * sizeof(uint), + (long)grownClipCapacity * sizeof(uint), + GLEnum.DynamicDraw, + $"growing EnvCell clip-slot SSBO to {grownClipCapacity} instances"); + _clipSlotCapacity = grownClipCapacity; + } + + if (uniqueInstanceCount > _instLightSetCapacity) + { + int grownLightSetCapacity = Math.Max(_instLightSetCapacity * 2, uniqueInstanceCount); + TrackedGlResource.AllocateBufferStorage( + _gl, + GLEnum.ShaderStorageBuffer, + _instLightSetSsbo, + (long)_instLightSetCapacity + * AcDream.Core.Lighting.LightManager.MaxLightsPerObject + * sizeof(int), + (long)grownLightSetCapacity + * AcDream.Core.Lighting.LightManager.MaxLightsPerObject + * sizeof(int), + GLEnum.DynamicDraw, + $"growing EnvCell light-set SSBO to {grownLightSetCapacity} instances"); + _instLightSetCapacity = grownLightSetCapacity; + } // WB BaseObjectRenderManager.cs:761-762: grow scratch arrays. if (_commands.Length < totalDraws) @@ -1519,17 +1658,35 @@ public sealed unsafe class EnvCellRenderer : } } - // WB BaseObjectRenderManager.cs:784-805 upload. Every section below is - // now a slice of this frame's ring rather than a renderer-owned buffer, - // so the "retain capacity, update the active prefix" bookkeeping that - // kept portal frames from enqueuing an unbounded chain of retired - // driver allocations is no longer needed — the ring never allocates. + // WB BaseObjectRenderManager.cs:784-805 upload. Retain capacity and + // update the active prefix so portal frames cannot enqueue an unbounded + // chain of retired driver allocations. + _gl.BindBuffer(GLEnum.DrawIndirectBuffer, _mdiCommandBuffer); + fixed (DrawElementsIndirectCommand* ptr = _commands) + { + _gl.BufferSubData(GLEnum.DrawIndirectBuffer, 0, + (nuint)(totalDraws * sizeof(DrawElementsIndirectCommand)), ptr); + } + + _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _modernInstanceBuffer); if (_gpuInstanceTransforms.Length < uniqueInstanceCount) Array.Resize(ref _gpuInstanceTransforms, Math.Max(_gpuInstanceTransforms.Length * 2, uniqueInstanceCount)); for (int i = 0; i < uniqueInstanceCount; i++) _gpuInstanceTransforms[i] = allInstances[i].Transform; + fixed (Matrix4x4* ptr = _gpuInstanceTransforms) + { + _gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0, + (nuint)(uniqueInstanceCount * sizeof(Matrix4x4)), ptr); + } - // Phase U.4: fill the per-instance clip-slot data (binding=3). When + _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _modernBatchBuffer); + fixed (ModernBatchData* ptr = _modernBatches) + { + _gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0, + (nuint)(totalDraws * sizeof(ModernBatchData)), ptr); + } + + // Phase U.4: upload the per-instance clip-slot buffer (binding=3). When // _cellIdToSlot is set (indoor routing), each cell shell instance is gated // to its cell's CellClip slot via allInstances[i].CellId; cells absent from // the map (shouldn't happen — the Render filter is the map's keys) and the @@ -1551,6 +1708,13 @@ public sealed unsafe class EnvCellRenderer : _clipSlotData[i] = _cellIdToSlot.TryGetValue(allInstances[i].CellId, out int slot) ? (uint)slot : 0u; } + _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _clipSlotBuffer); + fixed (uint* ptr = _clipSlotData) + { + _gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0, + (nuint)(uniqueInstanceCount * sizeof(uint)), ptr); + } + // A7 Fix D (D-2): per-instance 8-int light set, parallel to the transforms, // keyed on the cell each shell instance belongs to (mirrors _clipSlotData). int lightStride = AcDream.Core.Lighting.LightManager.MaxLightsPerObject; @@ -1569,133 +1733,108 @@ public sealed unsafe class EnvCellRenderer : && AcDream.Core.Rendering.RenderingDiagnostics.ProbeSeamDrawEnabled) EmitSeamDrawProbe(drawCalls, allInstances, _seamProbeFilter); - // A7 Fix D (D-2): binding=4 (global lights) + binding=5 (per-instance set). + // 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; - - // Phase U.3: the shared clip regions (binding=2) stay a raw global bind - // outside the pass — terrain reads the same globally bound ClipFrame - // buffer and is still raw GL until V4d (campaign doc §5.3). - BindClipRegionBinding2(); - - IGpuFrame frame = RequireFrame(); - using (IGpuPassEncoder encoder = frame.BeginPass(ShellPass)) + _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _globalLightsSsbo); + if (glUploadCount > _globalLightsCapacity) { - var pushConstants = new GpuPushConstants + int grownGlobalLightCapacity = Math.Max(_globalLightsCapacity * 2, glUploadCount); + TrackedGlResource.AllocateBufferStorage( + _gl, + GLEnum.ShaderStorageBuffer, + _globalLightsSsbo, + (long)_globalLightsCapacity + * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight + * sizeof(float), + (long)grownGlobalLightCapacity + * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight + * sizeof(float), + GLEnum.DynamicDraw, + $"growing EnvCell global-light SSBO to {grownGlobalLightCapacity} lights"); + _globalLightsCapacity = grownGlobalLightCapacity; + } + fixed (float* gp = _globalLightData) + _gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0, + (nuint)(glUploadCount * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * sizeof(float)), gp); + + _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _instLightSetSsbo); + fixed (int* lp = _lightSetData) + _gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0, + (nuint)(uniqueInstanceCount * lightStride * sizeof(int)), lp); + + PersistActiveDynamicBufferCapacities(); + + // WB BaseObjectRenderManager.cs:807-818: bind VAO + SSBOs + barrier. + // (globalVao validated at the top of the method — a return here would leak the + // pass state established above.) + if (_currentVao != globalVao) + { + _gl.BindVertexArray(globalVao); + _currentVao = globalVao; + } + + _gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 0, _modernInstanceBuffer); + _gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 1, _modernBatchBuffer); + // Phase U.3: per-instance clip slots (binding=3) + shared clip regions + // (binding=2, via the GameWindow ClipFrame or our no-clip fallback). + _gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 3, _clipSlotBuffer); + BindClipRegionBinding2(); + _gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 4, _globalLightsSsbo); // A7 Fix D (D-2) + _gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 5, _instLightSetSsbo); // A7 Fix D (D-2) + FlushAndBindTextureTable(); // Campaign V slice V2 (binding=9) + _gl.BindBuffer(GLEnum.DrawIndirectBuffer, _mdiCommandBuffer); + + _gl.MemoryBarrier(MemoryBarrierMask.ShaderStorageBarrierBit | MemoryBarrierMask.CommandBarrierBit); + + // WB BaseObjectRenderManager.cs:821-847: issue per-group multi-draw calls. + // The ranges retain ordered-cell boundaries, so transparent geometry + // stays far-to-near even though all command data was uploaded once. + for (int drawRangeIndex = 0; drawRangeIndex < _mdiDrawRanges.Count; drawRangeIndex++) + { + MdiDrawRange drawRange = _mdiDrawRanges[drawRangeIndex]; + int groupIndex = drawRange.GroupIndex; + var cullMode = (CullMode)(groupIndex % 4); + // Phase A8 visual-gate evidence: cell meshes use CullMode.Landblock + // uniformly, but the room surfaces need to be visible from inside + // under acdream's current global winding state. Render cell polys + // double-sided while the architectural cause is isolated. + if (cullMode == CullMode.Landblock) cullMode = CullMode.None; + if (_currentCullMode != cullMode) { - // Phase U.4 root-cause fix: this pass supplies its own - // view-projection rather than inheriting WbDrawDispatcher's, - // because the opaque shell pass runs BEFORE the dispatcher's - // draw and would otherwise use the previous frame's matrix. - ViewProjection = _lastViewProjection, - DrawIdOffset = 0, - // A7 Fix D D-3/D-4: EnvCell bake (wrap points, no sun). - LightingMode = 1, - RenderPass = (int)renderPass, - // #176 stripe-hunt isolation (ACDREAM_LIGHT_DEBUG). - LightDebug = AcDream.Core.Rendering.RenderingDiagnostics.LightDebugMode, - TextureIndexA = 0, - TextureIndexB = 0, - ParamA = 0f, - ParamB = 0f, - }; - encoder.BindPipeline(passPipeline); - encoder.SetPushConstants(in pushConstants); - - // WB BaseObjectRenderManager.cs:807-818: bind the mesh source and - // every storage section this pass reads. - BindRingSection( - encoder, frame, GpuBindingModel.StorageInstances, - _gpuInstanceTransforms.AsSpan(0, uniqueInstanceCount)); - BindRingSection( - encoder, frame, GpuBindingModel.StorageBatches, - _modernBatches.AsSpan(0, totalDraws)); - BindRingSection( - encoder, frame, GpuBindingModel.StorageClipSlots, - _clipSlotData.AsSpan(0, uniqueInstanceCount)); - BindRingSection( - encoder, frame, GpuBindingModel.StorageGlobalLights, - _globalLightData.AsSpan( - 0, glUploadCount * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight)); - BindRingSection( - encoder, frame, GpuBindingModel.StorageInstanceLightSets, - _lightSetData.AsSpan(0, uniqueInstanceCount * lightStride)); - BindTextureTable(encoder); // Campaign V slice V2 (binding=9) - BindWorldMesh(encoder); - - GpuRingAllocation commands = frame.AllocateRing( - totalDraws * sizeof(DrawElementsIndirectCommand), GpuRingUsage.Indirect); - System.Runtime.InteropServices.MemoryMarshal - .AsBytes(_commands.AsSpan(0, totalDraws)) - .CopyTo(commands.Data); - IGpuBuffer commandBuffer = commands.Buffer; - uint commandBase = commands.OffsetBytes; - - // The barrier stays a raw call: it has no RHI verb, because it - // guards incoherent SHADER writes and acdream has none — nothing - // writes these buffers from a shader, so it was already a no-op - // against client-side uploads, which GL orders implicitly. Kept - // rather than quietly dropped; the Vulkan backend expresses real - // ordering with pipeline barriers it inserts itself. - _gl.MemoryBarrier(MemoryBarrierMask.ShaderStorageBarrierBit | MemoryBarrierMask.CommandBarrierBit); - - // WB BaseObjectRenderManager.cs:821-847: issue per-group multi-draw calls. - // The ranges retain ordered-cell boundaries, so transparent geometry - // stays far-to-near even though all command data was written once. - for (int drawRangeIndex = 0; drawRangeIndex < _mdiDrawRanges.Count; drawRangeIndex++) - { - MdiDrawRange drawRange = _mdiDrawRanges[drawRangeIndex]; - int groupIndex = drawRange.GroupIndex; - var cullMode = (CullMode)(groupIndex % 4); - // Phase A8 visual-gate evidence: cell meshes use CullMode.Landblock - // uniformly, but the room surfaces need to be visible from inside - // under acdream's current global winding state. Render cell polys - // double-sided while the architectural cause is isolated. - if (cullMode == CullMode.Landblock) cullMode = CullMode.None; - if (_currentCullMode != cullMode) - { - SetCullMode(encoder, cullMode); - } - - // An additive group's blend function was set imperatively here. - // It is now the additive pipeline variant — but ONLY while this - // pass blends at all: in the opaque and single passes blending - // is disabled, so the old glBlendFunc call could not affect a - // pixel, and switching pipelines here would wrongly turn it on. - // The uRenderPass 0x100 flag the shader reads is unconditional, - // exactly as before. - bool isAdditive = groupIndex >= 4; - if (renderPass == WbRenderPass.Transparent) - { - BindPipelineWithMesh( - encoder, - isAdditive ? _alphaAdditivePipeline! : _alphaBlendPipeline!); - } - pushConstants.RenderPass = isAdditive - ? (int)renderPass | 0x100 - : (int)renderPass; - pushConstants.DrawIdOffset = drawRange.FirstCommand; - encoder.SetPushConstants(in pushConstants); - - encoder.MultiDrawIndexedIndirect( - commandBuffer, - commandBase + (uint)(drawRange.FirstCommand * sizeof(DrawElementsIndirectCommand)), - (uint)drawRange.CommandCount, - (uint)sizeof(DrawElementsIndirectCommand)); + SetCullMode(cullMode); } + + bool isAdditive = groupIndex >= 4; + if (isAdditive) + { + _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.One); + shader.SetInt("uRenderPass", (int)renderPass | 0x100); + } + else + { + _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha); + shader.SetInt("uRenderPass", (int)renderPass); + } + + shader.SetInt("uDrawIDOffset", drawRange.FirstCommand); + _gl.MultiDrawElementsIndirect( + PrimitiveType.Triangles, + DrawElementsType.UnsignedShort, + (void*)(drawRange.FirstCommand * sizeof(DrawElementsIndirectCommand)), + (uint)drawRange.CommandCount, + (uint)sizeof(DrawElementsIndirectCommand)); } // Phase U.4: leave a clean opaque-default render state (mirrors WbDrawDispatcher's // post-transparent restore) so a Transparent pass's Blend-on / DepthMask-off does - // not leak into particles or the next frame's draws. The pass encoder restores - // the state that was ambient on ENTRY, which is not necessarily this — so the - // explicit restore stays until the last raw-GL renderer goes at V4h. + // not leak into particles or the next frame's draws. _gl.Disable(EnableCap.Blend); _gl.DepthMask(true); - // WB BaseObjectRenderManager.cs:845-847: the trailing uDrawIDOffset reset - // and indirect-buffer unbind are gone — push constants are per-pass state - // that no later pass inherits, and the encoder owns the indirect binding. + // WB BaseObjectRenderManager.cs:845-847: + shader.SetInt("uDrawIDOffset", 0); + _gl.BindBuffer(GLEnum.DrawIndirectBuffer, 0); } internal static void AppendMdiDrawRange( @@ -1828,20 +1967,22 @@ public sealed unsafe class EnvCellRenderer : // Verbatim copy of WB BaseObjectRenderManager.cs:850-866. // --------------------------------------------------------------------------- - private static void SetCullMode(IGpuPassEncoder encoder, CullMode mode) + private void SetCullMode(CullMode mode) { _currentCullMode = mode; switch (mode) { case CullMode.None: - encoder.SetCullMode(GpuCullMode.None); + _gl.Disable(EnableCap.CullFace); break; case CullMode.Clockwise: - encoder.SetCullMode(GpuCullMode.Front); + _gl.Enable(EnableCap.CullFace); + _gl.CullFace(TriangleFace.Front); break; case CullMode.CounterClockwise: case CullMode.Landblock: - encoder.SetCullMode(GpuCullMode.Back); + _gl.Enable(EnableCap.CullFace); + _gl.CullFace(TriangleFace.Back); break; } } @@ -1851,136 +1992,48 @@ public sealed unsafe class EnvCellRenderer : // --------------------------------------------------------------------------- /// - /// Campaign V slice V2's handle table (binding=9), held as an - /// since slice V4c and bound through the encoder. - /// Not a ring allocation: a genuinely new handle is rare (new dat surfaces - /// / atlases, not every frame), so the buffer is long-lived and re-uploaded - /// only when is set or growth - /// forced a fresh allocation whose contents would otherwise be undefined. - /// Growth is create-and-retire, so a frame still reading the old buffer - /// never has it freed underneath. + /// Uploads 's handles to + /// when a new one was registered since the last flush, then (re)binds it at + /// . + /// A genuinely new handle is rare — new dat surfaces/atlases, not every + /// frame — so this is not part of the ring-buffered per-frame SSBO set; + /// see GlBindlessHandleTable's doc comment. /// - private void BindTextureTable(IGpuPassEncoder encoder) + private void FlushAndBindTextureTable() { - ReadOnlySpan handles = _textureTable.Handles; - int byteCount = Math.Max(handles.Length * sizeof(ulong), sizeof(ulong)); + if (_textureTableSsbo == 0) + _textureTableSsbo = TrackedGlResource.CreateBuffer(_gl, "creating EnvCell texture-table SSBO"); - bool reallocated = false; - if (_textureTableBuffer is null || _textureTableBufferBytes < byteCount) + if (_textureTable.Dirty) { - _textureTableBuffer?.Dispose(); - _textureTableBufferBytes = DynamicBufferCapacity.Grow(_textureTableBufferBytes, byteCount); - _textureTableBuffer = _device.CreateBuffer(new GpuBufferDescription( - "envcell-texture-table", - _textureTableBufferBytes, - GpuBufferUsage.Storage, - GpuMemoryResidency.DeviceLocal)); - reallocated = true; - } - - if ((reallocated || _textureTable.Dirty) && !handles.IsEmpty) - { - _textureTableBuffer.Upload(0, System.Runtime.InteropServices.MemoryMarshal.AsBytes(handles)); + ReadOnlySpan handles = _textureTable.Handles; + int byteCount = handles.Length * sizeof(ulong); + fixed (ulong* p = handles) + { + if (_textureTableSsboCapacityBytes < byteCount) + { + int grown = DynamicBufferCapacity.Grow(_textureTableSsboCapacityBytes, byteCount); + TrackedGlResource.AllocateBufferStorage( + _gl, + GLEnum.ShaderStorageBuffer, + _textureTableSsbo, + _textureTableSsboCapacityBytes, + grown, + GLEnum.DynamicDraw, + "growing EnvCell texture-table SSBO"); + _textureTableSsboCapacityBytes = grown; + } + _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _textureTableSsbo); + _gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0, (nuint)byteCount, p); + } _textureTable.MarkFlushed(); } - - encoder.BindStorageBuffer( - GpuBindingModel.StorageTextureTable, - _textureTableBuffer, - 0, - (uint)byteCount); + _gl.BindBufferBase( + GLEnum.ShaderStorageBuffer, + AcDream.App.Rendering.Gpu.GpuBindingModel.StorageTextureTable, + _textureTableSsbo); } - /// - /// Reserves this pass's worth of the frame ring, copies into it, and binds - /// the slice. A logically empty section still reserves one element so the - /// bound range is never zero-length. - /// - private static void BindRingSection( - IGpuPassEncoder encoder, - IGpuFrame frame, - uint binding, - ReadOnlySpan data) - where T : unmanaged - { - int elementBytes = System.Runtime.CompilerServices.Unsafe.SizeOf(); - int byteCount = Math.Max(data.Length * elementBytes, elementBytes); - GpuRingAllocation allocation = frame.AllocateRing(byteCount, GpuRingUsage.Storage); - if (!data.IsEmpty) - data.CopyTo(allocation.AsSpan()); - encoder.BindStorageBuffer(binding, allocation.Buffer, allocation.OffsetBytes, (uint)byteCount); - } - - /// - /// Binds a pipeline and immediately re-establishes the mesh source. - /// - /// Every owns its own vertex array, and vertex - /// attribute pointers plus the index binding are vertex-array state — so a - /// pipeline switch inside a pass silently drops them, while the - /// storage-buffer bindings (context state) survive. The per-range switch - /// between the alpha and additive variants therefore has to re-bind the - /// arena. - /// - private void BindPipelineWithMesh(IGpuPassEncoder encoder, IGpuPipeline pipeline) - { - encoder.BindPipeline(pipeline); - BindWorldMesh(encoder); - } - - /// - /// Binds the shared mesh arena as this pass's vertex and index source, - /// replacing glBindVertexArray(globalVao). The pipeline owns a VAO - /// shaped by , which is the layout - /// GlobalMeshBuffer packs. - /// - private void BindWorldMesh(IGpuPassEncoder encoder) - { - GlobalMeshBuffer global = _meshManager.GlobalBuffer - ?? throw new InvalidOperationException( - "The world mesh arena is not available; RenderModernMDIInternal " - + "should have returned at its globalVao guard."); - IGpuBuffer vertices = global.VertexStore - ?? throw new InvalidOperationException("The world mesh arena has no vertex store."); - IGpuBuffer indices = global.IndexStore - ?? throw new InvalidOperationException("The world mesh arena has no index store."); - encoder.BindVertexBuffer(vertices, 0); - encoder.BindIndexBuffer(indices, 0, GpuIndexType.UInt16); - } - - private IGpuFrame RequireFrame() - { - // Same precondition ActivateNextDynamicBufferSet enforced before the - // buffer-set pool was retired. - if (!_dynamicFrameStarted) - throw new InvalidOperationException("BeginFrame must be called before drawing EnvCells."); - - return _frameSource.CurrentFrame - ?? throw new InvalidOperationException( - "EnvCellRenderer requires an open IGpuFrame (see GpuDeviceFrameLifetime) — " - + "the host must drive IGpuDevice.BeginFrame() before drawing cell shells."); - } - - /// - /// The cell-shell pass. Load/Store against a null colour target, which on - /// GL means "the framebuffer the spine already bound" — clears and - /// framebuffer management stay with the spine until slice V4h. - /// - private static readonly GpuPassDescription ShellPass = new() - { - Name = "envcell-shells", - Color = new GpuColorAttachment( - Target: null, - Load: GpuLoadOp.Load, - Store: GpuStoreOp.Store, - ClearColor: default), - Depth = new GpuDepthAttachment( - Load: GpuLoadOp.Load, - Store: GpuStoreOp.Store, - ClearDepth: 1f, - ClearStencil: 0), - SampleCount = 1, - }; - // --------------------------------------------------------------------------- // BindClipRegionBinding2 (Phase U.3) // --------------------------------------------------------------------------- @@ -2094,23 +2147,67 @@ public sealed unsafe class EnvCellRenderer : ("prepare-scratch", _prepareScratch.Dispose), }; - // Campaign V slice V4c: the per-frame buffer-set pool is gone — - // its data lives in the device's frame ring now — so only this - // renderer's own long-lived resources are released here. + for (int frame = 0; frame < _dynamicBufferSetsByFrame.Length; frame++) + { + List frameSets = _dynamicBufferSetsByFrame[frame]; + for (int index = 0; index < frameSets.Count; index++) + { + DynamicBufferSet set = frameSets[index]; + AddTrackedBufferRelease( + releases, + set.MdiCommandBuffer, + (long)set.MdiCommandCapacity * sizeof(DrawElementsIndirectCommand), + $"dynamic-{frame}-{index}-mdi", + "deleting EnvCell MDI buffer"); + AddTrackedBufferRelease( + releases, + set.ModernInstanceBuffer, + (long)set.ModernInstanceCapacity * sizeof(Matrix4x4), + $"dynamic-{frame}-{index}-instances", + "deleting EnvCell instance SSBO"); + AddTrackedBufferRelease( + releases, + set.ModernBatchBuffer, + (long)set.ModernBatchCapacity * sizeof(ModernBatchData), + $"dynamic-{frame}-{index}-batches", + "deleting EnvCell batch SSBO"); + AddTrackedBufferRelease( + releases, + set.ClipSlotBuffer, + (long)set.ClipSlotCapacity * sizeof(uint), + $"dynamic-{frame}-{index}-clip-slots", + "deleting EnvCell clip-slot SSBO"); + AddTrackedBufferRelease( + releases, + set.GlobalLightsSsbo, + (long)set.GlobalLightsCapacity + * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight + * sizeof(float), + $"dynamic-{frame}-{index}-global-lights", + "deleting EnvCell global-light SSBO"); + AddTrackedBufferRelease( + releases, + set.InstanceLightSetSsbo, + (long)set.InstanceLightSetCapacity + * AcDream.Core.Lighting.LightManager.MaxLightsPerObject + * sizeof(int), + $"dynamic-{frame}-{index}-light-sets", + "deleting EnvCell light-set SSBO"); + } + } + AddTrackedBufferRelease( releases, _fallbackClipRegionSsbo, AcDream.App.Rendering.ClipFrame.CellClipStrideBytes, "fallback-clip-region", "deleting EnvCell fallback clip SSBO"); - - // RHI resources: disposal enqueues the real release on the - // device's retirement queue, so nothing is freed under a frame - // still in flight. - AddResourceRelease(releases, "pipeline-opaque", _opaquePipeline); - AddResourceRelease(releases, "pipeline-alpha", _alphaBlendPipeline); - AddResourceRelease(releases, "pipeline-additive", _alphaAdditivePipeline); - AddResourceRelease(releases, "texture-table", _textureTableBuffer); + AddTrackedBufferRelease( + releases, + _textureTableSsbo, + _textureTableSsboCapacityBytes, + "texture-table", + "deleting EnvCell texture-table SSBO"); _disposeResources = new RetryableResourceReleaseLedger(releases); } @@ -2121,13 +2218,19 @@ public sealed unsafe class EnvCellRenderer : "One or more EnvCell renderer resources could not be released."); } + foreach (List frameSets in _dynamicBufferSetsByFrame) + frameSets.Clear(); + _activeDynamicBufferSet = null; _dynamicFrameStarted = false; + _mdiCommandBuffer = 0; + _modernInstanceBuffer = 0; + _modernBatchBuffer = 0; + _clipSlotBuffer = 0; + _globalLightsSsbo = 0; + _instLightSetSsbo = 0; _fallbackClipRegionSsbo = 0; - _opaquePipeline = null; - _alphaBlendPipeline = null; - _alphaAdditivePipeline = null; - _textureTableBuffer = null; - _textureTableBufferBytes = 0; + _textureTableSsbo = 0; + _textureTableSsboCapacityBytes = 0; _disposeResources = null; IsDisposed = true; @@ -2143,16 +2246,6 @@ public sealed unsafe class EnvCellRenderer : } } - private static void AddResourceRelease( - List<(string Name, Action Release)> releases, - string name, - IDisposable? resource) - { - if (resource is null) - return; - releases.Add((name, resource.Dispose)); - } - private void AddTrackedBufferRelease( List<(string Name, Action Release)> releases, uint buffer, diff --git a/src/AcDream.App/Rendering/Wb/GlobalMeshBuffer.cs b/src/AcDream.App/Rendering/Wb/GlobalMeshBuffer.cs index e39b1bc6..4827e12a 100644 --- a/src/AcDream.App/Rendering/Wb/GlobalMeshBuffer.cs +++ b/src/AcDream.App/Rendering/Wb/GlobalMeshBuffer.cs @@ -171,23 +171,6 @@ public sealed class GlobalMeshBuffer : IDisposable /// The index store's raw GL name. See . public uint IBO => _indexBuffer is null ? 0u : RequireGlBuffer(_indexBuffer).GlName; - - /// - /// The vertex store as the arena actually owns it. Campaign V slice V4c's - /// draw paths bind this through IGpuPassEncoder.BindVertexBuffer; - /// its layout is already byte-identical to - /// GpuVertexLayout.WorldMesh (32-byte stride, locations 0/1/2 at - /// offsets 0/12/24 — see ), so the - /// pipeline's own VAO describes the same vertices this arena packs. - /// - /// / survive alongside these because - /// ParticleRenderer and ObjectMeshManager still bind the raw - /// names; that bridge retires with them, not with this slice. - /// - internal IGpuBuffer? VertexStore => _vertexBuffer; - - /// The index store as the arena owns it. See . - internal IGpuBuffer? IndexStore => _indexBuffer; internal long UploadCount { get; private set; } internal long UploadedBytes { get; private set; } internal long CapacityBytes => diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index a9d71316..bb82c7e1 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Numerics; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; -using AcDream.App.Rendering.Gpu; using AcDream.App.Rendering.Residency; using AcDream.App.Rendering.Scene; using AcDream.Core.Lighting; @@ -87,28 +86,7 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable } private readonly GL _gl; - - // Campaign V slice V4c. The mesh_modern program, all of its fixed state, - // and every per-frame upload now travel through the RHI. _gl survives for - // exactly three things, each documented at its call site: the trailing - // exit-state block that leaves the GL state later raw-GL renderers still - // expect, the ACDREAM_NO_CULL probe, and the binding=2 clip-region bind - // that stays raw because terrain reads the same globally bound buffer - // (campaign doc §5.3). - private readonly IGpuDevice _device; - private readonly ICurrentGpuFrameSource _frameSource; - - // Five pipelines rather than one program plus imperative state. Blend and - // alpha-to-coverage are the two dimensions core Vulkan does NOT make - // dynamic, so each combination the retail passes use becomes a variant; - // cull mode, front face and depth write stay dynamic and are still set - // per MDI run exactly where ApplyCullMode set them before. - private readonly IGpuPipeline _opaquePipeline; - private readonly IGpuPipeline _opaqueAlphaToCoveragePipeline; - private readonly IGpuPipeline _alphaBlendPipeline; - private readonly IGpuPipeline _alphaAdditivePipeline; - private readonly IGpuPipeline _alphaInversePipeline; - + private readonly Shader _shader; private readonly TextureCache _textures; private readonly WbMeshAdapter _meshAdapter; private readonly EntitySpawnAdapter _entitySpawnAdapter; @@ -465,19 +443,20 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable /// public bool AlphaToCoverage { get; set; } = true; - // Campaign V slice V4c: instance transforms (binding=0), per-draw batch - // metadata (binding=1) and the indirect command array no longer live in - // renderer-owned GL buffers. Each is a per-frame IGpuFrame.AllocateRing - // slice written once and bound through IGpuPassEncoder, so the - // ring-buffered DynamicBufferSet pool that used to hand a fresh buffer - // set to every Draw within a frame is gone — the frame's ring provides - // exactly that non-aliasing guarantee, and resets once per - // IGpuDevice.BeginFrame. + // SSBO buffer ids + private uint _instanceSsbo; + private uint _batchSsbo; + private uint _indirectBuffer; + private int _instanceSsboCapacityBytes; + private int _batchSsboCapacityBytes; + private int _indirectBufferCapacityBytes; // Phase U.3: per-instance clip-slot SSBO (binding=3), parallel to // _instanceSsbo. One uint per instance selecting its CellClip slot. In U.3 // this is ALL ZEROS (every instance → slot 0 → no-clip), so the render is // identical to pre-U.3. U.4 populates real slot indices. + private uint _clipSlotSsbo; + private int _clipSlotSsboCapacityBytes; private uint[] _clipSlotData = new uint[256]; // Fix B (A7 #3): per-OBJECT light selection (minimize_object_lighting). Two @@ -486,6 +465,10 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable // holds the per-frame point-light snapshot (LightManager.PointSnapshot); // _instLightSetSsbo (binding=5) holds MaxLightsPerObject int indices per // instance INTO it (-1 = unused), laid out parallel to _instanceSsbo. + private uint _globalLightsSsbo; + private uint _instLightSetSsbo; + private int _globalLightsSsboCapacityBytes; + private int _instLightSetSsboCapacityBytes; private int[] _lightSetData = new int[256 * LightManager.MaxLightsPerObject]; private float[] _globalLightData = new float[GlobalLightPacker.FloatsPerLight * 16]; // 16 floats (4 vec4) per GlobalLight @@ -493,6 +476,8 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable // to _instanceSsbo. 1 = object parented to an EnvCell (skip the sun in the // shader's uLightingMode==0 branch); 0 = outdoor object (gets the sun). // Mechanically a clone of _clipSlotData / _clipSlotSsbo. + private uint _instIndoorSsbo; + private int _instIndoorSsboCapacityBytes; private uint[] _indoorData = new uint[256]; // #188: per-instance opacity multiplier (binding=7), one float per @@ -500,37 +485,56 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable // material/texture alpha, untouched); < 1.0 multiplies the shader's // sampled alpha for an entity mid-TransparentPartHook fade. Mechanically // a clone of _indoorData / _instIndoorSsbo, one binding higher. + private uint _instAlphaSsbo; + private int _instAlphaSsboCapacityBytes; private float[] _alphaData = new float[256]; // Retail SmartBox click confirmation: per-instance CMaterial luminosity / // diffuse replacement (binding=8), parallel to the transform buffer. + private uint _instSelectionLightingSsbo; + private int _instSelectionLightingSsboCapacityBytes; // Campaign V slice V2 (2026-07-27): GL-only emulation of the eventual // Vulkan global texture descriptor array (binding=9, // GpuBindingModel.StorageTextureTable). A genuinely new handle is rare — - // new dat surfaces/atlases, not every frame — so unlike the per-frame - // data above this is NOT a ring allocation; it is one long-lived buffer - // re-uploaded only when a new handle registers. Campaign V slice V4c - // moved it from a raw GL name onto IGpuBuffer and binds it through the - // pass encoder, but the table itself stays: retiring it in favour of - // IGpuDevice's own table is slice V4t (campaign doc §5.3), because the - // ulong handles are produced by the texture caches and carried through - // GroupKey and CachedBatch, none of which this slice may touch. + // new dat surfaces/atlases, not every frame — so this single buffer is + // NOT part of the ring-buffered DynamicBufferSet below; see + // GlBindlessHandleTable's doc comment and the campaign doc's §5.2 for why + // this table is owned here rather than by GlGpuDevice. Lazily created. private readonly GlBindlessHandleTable _textureTable = new(); - private IGpuBuffer? _textureTableBuffer; - private int _textureTableBufferBytes; + private uint _textureTableSsbo; + private int _textureTableSsboCapacityBytes; + private sealed class DynamicBufferSet + { + public uint InstanceSsbo; + public uint BatchSsbo; + public uint IndirectBuffer; + public uint ClipSlotSsbo; + public uint GlobalLightsSsbo; + public uint InstanceLightSetSsbo; + public uint InstanceIndoorSsbo; + public uint InstanceAlphaSsbo; + public uint InstanceSelectionLightingSsbo; + public int InstanceCapacityBytes; + public int BatchCapacityBytes; + public int IndirectCapacityBytes; + public int ClipSlotCapacityBytes; + public int GlobalLightsCapacityBytes; + public int InstanceLightSetCapacityBytes; + public int InstanceIndoorCapacityBytes; + public int InstanceAlphaCapacityBytes; + public int InstanceSelectionLightingCapacityBytes; + } + + private readonly List[] _dynamicBufferSetsByFrame = + [[], [], []]; + private int _dynamicFrameSlot; + private int _dynamicBufferSetCursor; private bool _dynamicFrameStarted; + private DynamicBufferSet? _activeDynamicBufferSet; - /// - /// Always 0 since Campaign V slice V4c: the dispatcher owns no per-frame - /// GL buffer pool any more. Instance/batch/clip/light/indoor/alpha/ - /// selection data and the indirect commands are all - /// slices of the frame's own upload - /// ring, which is what the retired DynamicBufferSet pool existed to - /// approximate. Kept so RenderFrameDiagnosticSources' resource - /// snapshot keeps its shape. - /// - internal int DynamicBufferSetCount => 0; + internal int DynamicBufferSetCount => + _dynamicBufferSetsByFrame.Sum(frameSets => frameSets.Count); private Vector2[] _selectionLightingData = new Vector2[256]; // This frame's point-light snapshot, handed in by GameWindow before Draw via // SetSceneLights. Null/empty ⇒ only ambient + sun render (all instance sets -1). @@ -678,19 +682,6 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable private readonly List _deferredAlpha = new(128); private TranslucencyKind[] _deferredAlphaKinds = new TranslucencyKind[128]; private Matrix4x4 _deferredAlphaViewProjection; - - // Campaign V slice V4c: this alpha scope's prepared payload, reserved once - // in PrepareDeferredAlphaDraws and bound by every DrawPreparedAlphaBatch - // call the queue makes against it. Valid for the frame that produced them. - private RingSection _deferredAlphaInstances; - private RingSection _deferredAlphaBatches; - private RingSection _deferredAlphaClipSlots; - private RingSection _deferredAlphaGlobalLights; - private RingSection _deferredAlphaLightSets; - private RingSection _deferredAlphaIndoor; - private RingSection _deferredAlphaOpacity; - private RingSection _deferredAlphaSelectionLighting; - private RingSection _deferredAlphaCommands; private int _nextInstanceSubmissionOrder; internal long AlphaScratchBudgetBytes => _alphaScratchPolicy.BudgetBytes; @@ -797,14 +788,32 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable private readonly System.Diagnostics.Stopwatch _cpuStopwatch = new(); private readonly long[] _cpuSamples = new long[256]; // microseconds private int _cpuSampleCursor; - // GPU timing moved onto IGpuPassEncoder.BeginTimerScope at Campaign V - // slice V4c. The device's timer pool owns the query objects, their - // double-buffering, and the "never read a query that was never begun" - // guard that issue #125 needed here — this class keeps only the rolling - // sample window [WB-DIAG] reports over. See SampleGpuTimers for what - // changed about when a sample arrives. + // GPU timing uses a ring of 3 query-pair slots so the read of frame N-3's + // result lands when the GPU has finished (~50ms after issue on a typical + // 60fps frame). Ring of 3 is the vendor-neutral choice: NVIDIA drivers with + // triple-buffering+vsync can queue ~3 frames ahead, AMD typically 1-2, + // Intel iGPUs vary. ResultAvailable is the safety guard if the GPU is + // still working when we try to read. + private const int GpuQueryRingDepth = 3; + private readonly uint[] _gpuQueryOpaque = new uint[GpuQueryRingDepth]; + private readonly uint[] _gpuQueryTransparent = new uint[GpuQueryRingDepth]; + // #125: a glGenQueries name does not become a QUERY OBJECT until its first + // glBeginQuery — GetQueryObject on a never-begun name is GL_INVALID_OPERATION. + // The N.6 ring assumed ONE Draw per frame with both passes always non-empty; + // the pview pipeline issues MANY small Draws per frame (landscape slices, + // per-cell buckets, dynamics), where zero-draw passes routinely skip + // BeginQuery. Under ACDREAM_WB_DIAG=1 the slot read then queued an + // InvalidOperation EVERY frame — silently, until WB's diligent texture-path + // glGetError checks ate the stale errors and treated their own successful + // uploads as failures ([wb-error] + sticky drop) and ProcessDirtyUpdates' + // check threw (process death; tower-wbdiag3.log). Track which slots were + // actually begun and only read those. + private readonly bool[] _gpuQueryOpaqueBegun = new bool[GpuQueryRingDepth]; + private readonly bool[] _gpuQueryTransparentBegun = new bool[GpuQueryRingDepth]; + private int _gpuQueryFrameIndex; private readonly long[] _gpuSamples = new long[256]; // microseconds private int _gpuSampleCursor; + private bool _gpuQueriesInitialized; // Constructor accessibility is internal because EntityClassificationCache // is internal — a public ctor with an internal-typed parameter would be @@ -812,8 +821,7 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable // exclusively from GameWindow (same assembly), so internal is fine. internal WbDrawDispatcher( GL gl, - IGpuDevice device, - ICurrentGpuFrameSource frameSource, + Shader shader, TextureCache textures, WbMeshAdapter meshAdapter, EntitySpawnAdapter entitySpawnAdapter, @@ -825,8 +833,7 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable long? alphaScratchBudgetBytes = null) { ArgumentNullException.ThrowIfNull(gl); - ArgumentNullException.ThrowIfNull(device); - ArgumentNullException.ThrowIfNull(frameSource); + ArgumentNullException.ThrowIfNull(shader); ArgumentNullException.ThrowIfNull(textures); ArgumentNullException.ThrowIfNull(meshAdapter); ArgumentNullException.ThrowIfNull(entitySpawnAdapter); @@ -834,18 +841,7 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable ArgumentNullException.ThrowIfNull(translucencyFades); _gl = gl; - _device = device; - _frameSource = frameSource; - _opaquePipeline = CreateMeshPipeline( - device, "wb-mesh-opaque", GpuBlendMode.None, depthWrite: true, alphaToCoverage: false); - _opaqueAlphaToCoveragePipeline = CreateMeshPipeline( - device, "wb-mesh-opaque-a2c", GpuBlendMode.None, depthWrite: true, alphaToCoverage: true); - _alphaBlendPipeline = CreateMeshPipeline( - device, "wb-mesh-alpha", GpuBlendMode.StraightAlpha, depthWrite: false, alphaToCoverage: false); - _alphaAdditivePipeline = CreateMeshPipeline( - device, "wb-mesh-additive", GpuBlendMode.Additive, depthWrite: false, alphaToCoverage: false); - _alphaInversePipeline = CreateMeshPipeline( - device, "wb-mesh-inverse-alpha", GpuBlendMode.InverseAlpha, depthWrite: false, alphaToCoverage: false); + _shader = shader; _textures = textures; _meshAdapter = meshAdapter; _entitySpawnAdapter = entitySpawnAdapter; @@ -863,116 +859,6 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable _bindless = bindless ?? throw new ArgumentNullException(nameof(bindless)); } - /// - /// One mesh_modern pipeline variant. Everything here was previously - /// imperative GL around the two multi-draw brackets, and each value is the - /// state those brackets actually established: - /// - /// - /// Depth compare is , not the - /// contract's LessOrEqual default — the world frame runs under - /// GL_LESS (RenderFrameGlStateController.RestoreFrameDefaults), - /// and the dispatcher never set glDepthFunc itself, so it inherited - /// exactly that. Baking LessOrEqual instead would change which of two - /// coplanar retail surfaces wins. - /// and - /// are only defaults; every MDI run still - /// re-establishes both through ApplyCullMode, matching WB's - /// BaseObjectRenderManager convention. - /// Sample count is 1 because the GL backend ignores it — the pass - /// renders into whatever framebuffer the spine bound, multisampled or not. - /// Alpha-to-coverage is still applied verbatim, which is what keeps - /// ClipMap foliage silhouettes identical under MSAA. - /// - /// - private static IGpuPipeline CreateMeshPipeline( - IGpuDevice device, - string name, - GpuBlendMode blend, - bool depthWrite, - bool alphaToCoverage) => - device.CreatePipeline(new GpuPipelineDescription - { - Name = name, - Shaders = new GpuShaderSet("mesh_modern"), - VertexLayout = GpuVertexLayout.WorldMesh, - Topology = GpuPrimitiveTopology.TriangleList, - Blend = blend, - Depth = new GpuDepthState(Test: true, Write: depthWrite, GpuCompareOp.Less), - Cull = GpuCullMode.Back, - FrontFace = GpuFrontFace.Clockwise, - AlphaToCoverage = alphaToCoverage, - ColorWrite = true, - SampleCount = 1, - }); - - /// - /// Reserves 's worth of this frame's upload ring, - /// copies into it, and binds the slice at . - /// Replaces one UploadSsbo call: the write lands directly in the - /// memory the draw will read instead of going through a renderer-owned - /// buffer plus glBufferSubData. - /// - /// A logically empty section still reserves one element so the bound range - /// is never zero-length — the same "bind at least one element so the - /// shader never reads an unbound SSBO" rule the light buffers already - /// stated, now applied uniformly. - /// - private static void BindRingSection( - IGpuPassEncoder encoder, - IGpuFrame frame, - uint binding, - ReadOnlySpan data) - where T : unmanaged => - BindSection(encoder, binding, WriteRingSection(frame, data)); - - /// - /// A ring slice that has already been written, reduced to the three values - /// a later bind needs. is a ref - /// struct — deliberately, so nothing can outlive the frame's memory — - /// but the buffer reference plus offset and size are ordinary values and - /// stay valid for as long as that memory does. That is what lets - /// PrepareDeferredAlphaDraws write a payload once and - /// DrawPreparedAlphaBatch bind it many times without recopying. - /// - private readonly record struct RingSection( - IGpuBuffer Buffer, - uint OffsetBytes, - uint SizeBytes); - - private static RingSection WriteRingSection( - IGpuFrame frame, - ReadOnlySpan data, - GpuRingUsage usage = GpuRingUsage.Storage) - where T : unmanaged - { - int elementBytes = Unsafe.SizeOf(); - int byteCount = Math.Max(data.Length * elementBytes, elementBytes); - GpuRingAllocation allocation = frame.AllocateRing(byteCount, usage); - if (!data.IsEmpty) - data.CopyTo(allocation.AsSpan()); - return new RingSection(allocation.Buffer, allocation.OffsetBytes, (uint)byteCount); - } - - /// - /// Requires the frame the host opened for this render frame. Every draw - /// path below records into it, so a missing frame is a composition error - /// rather than something to render around. - /// - private IGpuFrame RequireFrame() - { - // Same precondition ActivateNextDynamicBufferSet enforced before the - // buffer-set pool was retired: a draw that has not been bracketed by - // BeginFrame has no slot to write into. - if (!_dynamicFrameStarted) - throw new InvalidOperationException("BeginFrame must be called before drawing world entities."); - - return _frameSource.CurrentFrame - ?? throw new InvalidOperationException( - "WbDrawDispatcher requires an open IGpuFrame (see GpuDeviceFrameLifetime) — " - + "the host must drive IGpuDevice.BeginFrame() before drawing world entities."); - } - /// /// Selects the fence-protected frame slot and resets its draw-call cursor. /// Every Draw/alpha preparation in one frame receives a distinct buffer @@ -981,7 +867,7 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable /// public void BeginFrame(int frameSlot) { - if ((uint)frameSlot >= (uint)FrameSlotCount) + if ((uint)frameSlot >= (uint)_dynamicBufferSetsByFrame.Length) throw new ArgumentOutOfRangeException(nameof(frameSlot)); if (_groupFrame == long.MaxValue) throw new InvalidOperationException("Instance-group frame identity was exhausted."); @@ -993,24 +879,13 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable _groups, _retiredGroupKeys, _groupFrame - 1); - // Campaign V slice V4c: the slot index no longer selects a buffer set — - // IGpuDevice.BeginFrame already rotated (and fence-waited on) the - // matching ring slot. The argument and its range check survive because - // they are this class's published contract with the frame spine, and - // because "BeginFrame ran" is still the precondition every draw path - // asserts. + _dynamicFrameSlot = frameSlot; + _dynamicBufferSetCursor = 0; _dynamicFrameStarted = true; + _activeDynamicBufferSet = null; _currentRenderSceneObserver?.BeginDispatcherFrame(); } - /// - /// Frames-in-flight slots the spine rotates through. Unchanged from the - /// retired DynamicBufferSet pool's fixed three, so - /// rejects exactly the same arguments it did - /// before. - /// - private const int FrameSlotCount = 3; - internal void SetCurrentRenderSceneObserver( ICurrentRenderDispatcherObserver? observer) => _currentRenderSceneObserver = observer; @@ -2184,22 +2059,30 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable out Matrix4x4 viewProjection, out Vector3 cameraWorldPosition) { - // Campaign V slice V4c: the program bind and the three loose uniforms - // that used to be set here now travel with the pass — BindPipeline - // binds the program, and uViewProjection / uLightingMode / uLightDebug - // are fields of the shared GpuPushConstants block written inside - // ExecuteClassifiedGroups. Nothing else about this method's ordering - // changes: the selection tick and probe counter still advance once per - // dispatch, before any classification runs. + _shader.Use(); _selectionLighting?.TickLighting(); _indoorProbeFrameCounter++; viewProjection = camera.View * camera.Projection; + _shader.SetMatrix4("uViewProjection", viewProjection); + _shader.SetInt("uLightingMode", 0); + _shader.SetInt( + "uLightDebug", + RenderingDiagnostics.LightDebugMode); _missRequested.Clear(); bool diagnosticsEnabled = string.Equals( Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG"), "1", StringComparison.Ordinal); + if (diagnosticsEnabled && !_gpuQueriesInitialized) + { + for (int index = 0; index < GpuQueryRingDepth; index++) + { + _gpuQueryOpaque[index] = _gl.GenQuery(); + _gpuQueryTransparent[index] = _gl.GenQuery(); + } + _gpuQueriesInitialized = true; + } _cpuStopwatch.Restart(); cameraWorldPosition = Vector3.Zero; @@ -2377,175 +2260,196 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable deferTransparent, camPos); + // ── Phase 5: upload four buffers ──────────────────────────────────── + ActivateNextDynamicBufferSet(); + fixed (float* ip = _instanceData) + UploadSsbo(_instanceSsbo, 0, ref _instanceSsboCapacityBytes, + ip, immediateInstances * 16 * sizeof(float)); + + fixed (BatchData* bp = _batchData) + UploadSsbo(_batchSsbo, 1, ref _batchSsboCapacityBytes, + bp, totalDraws * sizeof(BatchData)); + + // Phase U.4: per-instance clip-slot buffer (binding=3), one uint per + // instance, laid out parallel to _instanceData in Phase 3's group loop so + // instanceClipSlot[instanceIndex] tracks Instances[instanceIndex]. On the + // U.3 / outdoor path every entry is 0 ⇒ slot 0 ⇒ no-clip (identical to + // U.3); under indoor routing it holds the per-instance slot from + // ResolveEntitySlot. No clear here — Phase 3 wrote exactly immediateInstances + // entries; only [0..immediateInstances) is uploaded, so any stale tail is + // never read by the shader. + fixed (uint* sp = _clipSlotData) + UploadSsbo(_clipSlotSsbo, 3, ref _clipSlotSsboCapacityBytes, + sp, immediateInstances * sizeof(uint)); + + // #142: per-instance indoor flag buffer (binding=6), one uint per instance, + // laid out parallel to _instanceData in Phase 3. Only [0..immediateInstances) + // is uploaded — stale tail never read (same guarantee as clip-slot above). + fixed (uint* dp = _indoorData) + UploadSsbo(_instIndoorSsbo, 6, ref _instIndoorSsboCapacityBytes, + dp, immediateInstances * sizeof(uint)); + + // #188: per-instance opacity buffer (binding=7), one float per instance, + // laid out parallel to _instanceData in Phase 3. Only [0..immediateInstances) + // is uploaded — stale tail never read (same guarantee as clip-slot above). + fixed (float* ap = _alphaData) + UploadSsbo(_instAlphaSsbo, 7, ref _instAlphaSsboCapacityBytes, + ap, immediateInstances * sizeof(float)); + + // SmartBox click lighting: x=luminosity, y=diffuse. mesh_modern.vert + // reads this only for the object path (uLightingMode=0), so EnvCell's + // independent mode-1 renderer does not consume this binding. + fixed (Vector2* hp = _selectionLightingData) + UploadSsbo(_instSelectionLightingSsbo, 8, ref _instSelectionLightingSsboCapacityBytes, + hp, immediateInstances * sizeof(float) * 2); + + // Fix B: global point-light buffer (binding=4) + per-instance light-set + // buffer (binding=5). The global buffer is this frame's PointSnapshot; the + // per-instance buffer holds 8 int indices into it per instance, laid out + // parallel to _instanceData in Phase 3. Both bound with ≥1 element so the + // shader never reads an unbound SSBO on a no-lights frame. + UploadGlobalLights(); + fixed (int* lp = _lightSetData) + UploadSsbo(_instLightSetSsbo, 5, ref _instLightSetSsboCapacityBytes, + lp, immediateInstances * LightManager.MaxLightsPerObject * sizeof(int)); + + // Campaign V slice V2 (binding=9): uploads only when ToInput registered + // a genuinely new handle this frame; otherwise just rebinds. + FlushAndBindTextureTable(); + + fixed (DrawElementsIndirectCommand* cp = _indirectCommands) + { + UploadDynamicBuffer( + BufferTargetARB.DrawIndirectBuffer, + _indirectBuffer, + ref _indirectBufferCapacityBytes, + cp, + totalDraws * sizeof(DrawElementsIndirectCommand)); + } + + PersistActiveDynamicBufferCapacities(); + // Phase U.3: bind the SHARED per-cell clip-region SSBO (binding=2). The // GameWindow-level ClipFrame already uploaded + bound it this frame; we // re-bind defensively in case another consumer touched binding=2 since. // When no shared id is set (0), bind our own no-clip fallback so the // shader never reads an unbound SSBO at binding=2. - // - // Campaign V slice V4c leaves this one raw and OUTSIDE the pass: - // ClipFrame's buffer is read by terrain too, which is still raw GL - // until V4d, and GL binding points are global — so the buffer the - // encoder would bind and the buffer terrain inherits must stay the - // same object. Converted with the frame spine at V4h (campaign §5.3). BindClipRegionBinding2(); + // ── Phase 6: bind global VAO once ─────────────────────────────────── + _gl.BindVertexArray(anyVao); + if (string.Equals(Environment.GetEnvironmentVariable("ACDREAM_NO_CULL"), "1", StringComparison.Ordinal)) _gl.Disable(EnableCap.CullFace); - // ── Phase 5-8: record the world pass ──────────────────────────────── - // Load/Store against whatever the spine bound: the world pass draws - // into the frame's own target, and offscreen consumers - // (PrivateEntityViewportRenderer, PortalTunnelPresentation) call this - // dispatcher with their FBO already bound. GlGpuDevice.BeginPass - // deliberately does not rebind for a null target — see its comment. - IGpuFrame frame = RequireFrame(); - using (IGpuPassEncoder encoder = frame.BeginPass(WorldPass)) + // GPU timing: compute this frame's ring slot. We read frame N-3's + // result (the oldest data in the ring) before overwriting it with + // frame N's queries. Hoisted to function scope so both the opaque + // and transparent passes below can reference gpuQuerySlot. See spec + // §3 Q1/Q2 + §4 in + // docs/superpowers/specs/2026-05-11-phase-n6-slice1-design.md. + int gpuQuerySlot = _gpuQueryFrameIndex % GpuQueryRingDepth; + // diag is part of the gate so the read/issue/increment trio stays + // symmetric — without it, toggling ACDREAM_WB_DIAG mid-session would + // freeze the frame counter (gated by diag below) while the read kept + // re-reading the same slot, producing duplicate stale samples. + if (diag && _gpuQueriesInitialized && _gpuQueryFrameIndex >= GpuQueryRingDepth) { - var pushConstants = new GpuPushConstants + // #125: only read slots whose query objects were actually BEGUN (a + // zero-draw pass skips BeginQuery; reading a never-begun name is + // GL_INVALID_OPERATION). A pass that never ran contributes 0 ns. + ulong opaqueNs = 0, transNs = 0; + bool anyRead = false, allAvailable = true; + if (_gpuQueryOpaqueBegun[gpuQuerySlot]) { - ViewProjection = vp, - DrawIdOffset = 0, - LightingMode = 0, - RenderPass = 0, - LightDebug = RenderingDiagnostics.LightDebugMode, - TextureIndexA = 0, - TextureIndexB = 0, - ParamA = 0f, - ParamB = 0f, - }; - - // Bind the opaque variant first so the ring binds below land on a - // live program; the transparent bracket rebinds its own variant, - // and push constants survive that switch per the encoder contract. - encoder.BindPipeline( - AlphaToCoverage ? _opaqueAlphaToCoveragePipeline : _opaquePipeline); - encoder.SetPushConstants(in pushConstants); - - BindRingSection( - encoder, frame, GpuBindingModel.StorageInstances, - _instanceData.AsSpan(0, immediateInstances * 16)); - BindRingSection( - encoder, frame, GpuBindingModel.StorageBatches, - _batchData.AsSpan(0, totalDraws)); - - // Phase U.4: per-instance clip-slot section (binding=3), one uint per - // instance, laid out parallel to _instanceData in Phase 3's group loop so - // instanceClipSlot[instanceIndex] tracks Instances[instanceIndex]. On the - // U.3 / outdoor path every entry is 0 ⇒ slot 0 ⇒ no-clip (identical to - // U.3); under indoor routing it holds the per-instance slot from - // ResolveEntitySlot. Only [0..immediateInstances) is written, so any - // stale tail is never read by the shader. - BindRingSection( - encoder, frame, GpuBindingModel.StorageClipSlots, - _clipSlotData.AsSpan(0, immediateInstances)); - - // Fix B: global point-light section (binding=4) + per-instance light-set - // section (binding=5). The global section is this frame's PointSnapshot; - // the per-instance one holds 8 int indices into it per instance, laid out - // parallel to _instanceData in Phase 3. Both bound with ≥1 element so the - // shader never reads an unbound SSBO on a no-lights frame. - BindGlobalLights(encoder, frame); - BindRingSection( - encoder, frame, GpuBindingModel.StorageInstanceLightSets, - _lightSetData.AsSpan(0, immediateInstances * LightManager.MaxLightsPerObject)); - - // #142: per-instance indoor flag (binding=6), one uint per instance, - // laid out parallel to _instanceData in Phase 3. - BindRingSection( - encoder, frame, GpuBindingModel.StorageInstanceIndoor, - _indoorData.AsSpan(0, immediateInstances)); - - // #188: per-instance opacity (binding=7), one float per instance, - // laid out parallel to _instanceData in Phase 3. - BindRingSection( - encoder, frame, GpuBindingModel.StorageInstanceAlpha, - _alphaData.AsSpan(0, immediateInstances)); - - // SmartBox click lighting: x=luminosity, y=diffuse. mesh_modern.vert - // reads this only for the object path (uLightingMode=0), so EnvCell's - // independent mode-1 renderer does not consume this binding. - BindRingSection( - encoder, frame, GpuBindingModel.StorageInstanceSelectionLighting, - _selectionLightingData.AsSpan(0, immediateInstances)); - - // Campaign V slice V2 (binding=9): uploads only when ToInput registered - // a genuinely new handle this frame; otherwise just rebinds. - BindTextureTable(encoder); - - BindWorldMesh(encoder); - - GpuRingAllocation commands = frame.AllocateRing( - totalDraws * DrawCommandStride, GpuRingUsage.Indirect); - MemoryMarshal.AsBytes(_indirectCommands.AsSpan(0, totalDraws)) - .CopyTo(commands.Data); - IGpuBuffer commandBuffer = commands.Buffer; - uint commandBase = commands.OffsetBytes; - - // ── Phase 7: opaque pass ───────────────────────────────────────── - if (_opaqueDrawCount > 0) - { - // A.5 T20: A2C for ClipMap foliage — the GPU derives the sample - // mask from the alpha mesh_modern.frag writes, so foliage edges - // stay smooth under MSAA 4x. A no-op for fully-opaque batches. - // A.5 T22.5: gated by the AlphaToCoverage property so Low/Medium - // presets (no MSAA) never enable it. Blend-off and depth-write-on - // come from the pipeline rather than an imperative bracket. - // Phase Post-A.5 (ISSUE #52, 2026-05-10): the opaque section of - // Batches[] starts at index 0. See the uDrawIDOffset comment in - // mesh_modern.vert for why this is needed. - pushConstants.RenderPass = 0; - pushConstants.DrawIdOffset = 0; - encoder.SetPushConstants(in pushConstants); - using (BeginPassTimer(encoder, diag, OpaqueTimerScope)) + _gl.GetQueryObject(_gpuQueryOpaque[gpuQuerySlot], QueryObjectParameterName.ResultAvailable, out int availO); + if (availO != 0) { - DrawIndirectRange( - encoder, ref pushConstants, commandBuffer, commandBase, - 0, _opaqueDrawCount); + _gl.GetQueryObject(_gpuQueryOpaque[gpuQuerySlot], QueryObjectParameterName.Result, out opaqueNs); + anyRead = true; } + else allAvailable = false; } - - // ── Phase 8: transparent pass ──────────────────────────────────── - if (_transparentDrawCount > 0) + if (_gpuQueryTransparentBegun[gpuQuerySlot]) { - // Blend (SrcAlpha, OneMinusSrcAlpha) and depth-write-off are - // baked into the alpha pipeline; the depth TEST stays on. - BindPipelineWithMesh(encoder, _alphaBlendPipeline); - // Phase Post-A.5 (ISSUE #52, 2026-05-10): transparent section of - // Batches[] starts at index _opaqueDrawCount. Without this offset, - // each transparent draw reads BatchData[0..transparentCount) — the - // OPAQUE section — and the lifestone crystal's apparent texture - // flickers to whatever opaque batch sorted first that frame. See - // uDrawIDOffset comment in mesh_modern.vert. - pushConstants.RenderPass = 1; - pushConstants.DrawIdOffset = _opaqueDrawCount; - encoder.SetPushConstants(in pushConstants); - // Closed-shell translucent meshes still need culling, but the - // cull side must come from each dat batch just like the opaque - // section. BuildIndirectArrays preserves CullMode in _drawCullModes. - encoder.SetFrontFace(GpuFrontFace.Clockwise); - using (BeginPassTimer(encoder, diag, TransparentTimerScope)) + _gl.GetQueryObject(_gpuQueryTransparent[gpuQuerySlot], QueryObjectParameterName.ResultAvailable, out int availT); + if (availT != 0) { - DrawIndirectRange( - encoder, ref pushConstants, commandBuffer, commandBase, - _opaqueDrawCount, _transparentDrawCount); + _gl.GetQueryObject(_gpuQueryTransparent[gpuQuerySlot], QueryObjectParameterName.Result, out transNs); + anyRead = true; } + else allAvailable = false; + } + // If a begun query isn't available yet the sample is dropped + // silently. MedianMicros computes over the non-zero subset, so + // dropped samples don't poison the median. + if (anyRead && allAvailable) + { + long gpuUs = (long)((opaqueNs + transNs) / 1000UL); + _gpuSamples[_gpuSampleCursor] = gpuUs; + _gpuSampleCursor = (_gpuSampleCursor + 1) % _gpuSamples.Length; } } - // The encoder's Dispose restored the capability state that was ambient - // when the pass opened — which is NOT the state this dispatcher used to - // leave behind. Terrain, sky and particles are still raw GL and still - // inherit whatever the previous renderer left, so reassert the exact - // exit state the pre-RHI code ended on. Retired at V4h with the last - // raw-GL renderer. - _gl.DepthMask(true); - _gl.Disable(EnableCap.Blend); + // ── Phase 7: opaque pass ───────────────────────────────────────────── + if (_opaqueDrawCount > 0) + { + _gl.Disable(EnableCap.Blend); + _gl.DepthMask(true); + // A.5 T20: enable A2C for ClipMap foliage — GPU derives sample mask + // from the alpha written by mesh_modern.frag so foliage edges are + // smooth under MSAA 4x. A no-op for fully-opaque (α=1) batches. + // A.5 T22.5: gated by AlphaToCoverage property so Low/Medium presets + // (no MSAA) skip the unnecessary GL state change. + if (AlphaToCoverage) _gl.Enable(EnableCap.SampleAlphaToCoverage); + _shader.SetInt("uRenderPass", 0); + // Phase Post-A.5 (ISSUE #52, 2026-05-10): opaque section of + // Batches[] starts at index 0. See uDrawIDOffset comment in + // mesh_modern.vert for why this is needed. + _shader.SetInt("uDrawIDOffset", 0); + _gl.BindBuffer(BufferTargetARB.DrawIndirectBuffer, _indirectBuffer); + if (diag && _gpuQueriesInitialized) + { + _gl.BeginQuery(QueryTarget.TimeElapsed, _gpuQueryOpaque[gpuQuerySlot]); + _gpuQueryOpaqueBegun[gpuQuerySlot] = true; // #125 + } + DrawIndirectRange(0, _opaqueDrawCount); + if (diag && _gpuQueriesInitialized) _gl.EndQuery(QueryTarget.TimeElapsed); + if (AlphaToCoverage) _gl.Disable(EnableCap.SampleAlphaToCoverage); + } + + // ── Phase 8: transparent pass ──────────────────────────────────────── + if (_transparentDrawCount > 0) + { + _gl.Enable(EnableCap.Blend); + _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha); + _gl.DepthMask(false); + // Phase Post-A.5 (ISSUE #52, 2026-05-10): transparent section of + // Batches[] starts at index _opaqueDrawCount. Without this offset, + // each transparent draw reads BatchData[0..transparentCount) — the + // OPAQUE section — and the lifestone crystal's apparent texture + // flickers to whatever opaque batch sorted first that frame. See + // uDrawIDOffset comment in mesh_modern.vert. + _shader.SetInt("uDrawIDOffset", _opaqueDrawCount); + // Closed-shell translucent meshes still need culling, but the + // cull side must come from each dat batch just like the opaque + // section. BuildIndirectArrays preserves CullMode in _drawCullModes. + _gl.FrontFace(FrontFaceDirection.CW); + _shader.SetInt("uRenderPass", 1); + if (diag && _gpuQueriesInitialized) + { + _gl.BeginQuery(QueryTarget.TimeElapsed, _gpuQueryTransparent[gpuQuerySlot]); + _gpuQueryTransparentBegun[gpuQuerySlot] = true; // #125 + } + DrawIndirectRange(_opaqueDrawCount, _transparentDrawCount); + if (diag && _gpuQueriesInitialized) _gl.EndQuery(QueryTarget.TimeElapsed); + _gl.DepthMask(true); + _gl.Disable(EnableCap.Blend); + } + _gl.Disable(EnableCap.CullFace); _gl.BindVertexArray(0); - SampleGpuTimers(diag); - _cpuStopwatch.Stop(); if (diag) @@ -2554,6 +2458,11 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable _cpuSamples[_cpuSampleCursor] = cpuUs; _cpuSampleCursor = (_cpuSampleCursor + 1) % _cpuSamples.Length; + // GPU sample read happens BEFORE issuing the next frame's queries + // (see step 1.3 above). Increment the frame counter here so the + // next call computes a fresh slot. + if (_gpuQueriesInitialized) _gpuQueryFrameIndex++; + _drawsIssued += _opaqueDrawCount + _transparentDrawCount; _instancesIssued += totalInstances; MaybeFlushDiag(); @@ -3090,49 +2999,12 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable _deferredAlphaKinds[i] = key.Translucency; } - // One reservation per source per sorted alpha scope. RetailAlphaQueue - // later draws contiguous ranges from this immutable prepared payload, - // so it must never be rewritten for every short mesh/particle run. - // The retired DynamicBufferSet pool bought that by handing each - // Prepare a fresh set of GL buffers; the frame ring gives it - // structurally, because every allocation within a frame is distinct - // memory that lives until the frame retires. The GpuRingAllocation - // itself is a ref struct and cannot be stored, but its (buffer, - // offset, size) triple can — that is what DrawPreparedAlphaBatch - // binds, without recopying a byte. - WriteDeferredAlphaSections(count); - } - - /// - /// Reserves and fills this alpha scope's ring sections. Mirrors the - /// binding set the immediate path writes in - /// ExecuteClassifiedGroups, minus binding=2 (the shared clip - /// regions, still bound globally by raw GL — campaign doc §5.3). - /// - private void WriteDeferredAlphaSections(int count) - { - IGpuFrame frame = RequireFrame(); - int packedLights = GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData); - int lightCount = packedLights > 0 ? packedLights : 1; - - _deferredAlphaInstances = WriteRingSection( - frame, _instanceData.AsSpan(0, count * 16)); - _deferredAlphaBatches = WriteRingSection( - frame, _batchData.AsSpan(0, count)); - _deferredAlphaClipSlots = WriteRingSection( - frame, _clipSlotData.AsSpan(0, count)); - _deferredAlphaGlobalLights = WriteRingSection( - frame, _globalLightData.AsSpan(0, lightCount * GlobalLightPacker.FloatsPerLight)); - _deferredAlphaLightSets = WriteRingSection( - frame, _lightSetData.AsSpan(0, count * LightManager.MaxLightsPerObject)); - _deferredAlphaIndoor = WriteRingSection( - frame, _indoorData.AsSpan(0, count)); - _deferredAlphaOpacity = WriteRingSection( - frame, _alphaData.AsSpan(0, count)); - _deferredAlphaSelectionLighting = WriteRingSection( - frame, _selectionLightingData.AsSpan(0, count)); - _deferredAlphaCommands = WriteRingSection( - frame, _indirectCommands.AsSpan(0, count), GpuRingUsage.Indirect); + // One upload per source per sorted alpha scope. RetailAlphaQueue later + // draws contiguous ranges from this immutable prepared payload; it must + // never overwrite these buffers for every short mesh/particle run. + ActivateNextDynamicBufferSet(); + UploadDeferredAlphaBuffers(count); + PersistActiveDynamicBufferCapacities(); } private void DrawPreparedAlphaBatch(int firstPreparedDraw, int drawCount) @@ -3147,83 +3019,53 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable if (global is null || global.VAO == 0) return; - // Binding=2 stays a raw global bind, outside the pass — see the note - // at the immediate path's call site. + _shader.Use(); + _shader.SetMatrix4("uViewProjection", _deferredAlphaViewProjection); + _shader.SetInt("uLightingMode", 0); + _shader.SetInt("uLightDebug", AcDream.Core.Rendering.RenderingDiagnostics.LightDebugMode); + _shader.SetInt("uRenderPass", 1); + _gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer, 0, _instanceSsbo); + _gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer, 1, _batchSsbo); + _gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer, 3, _clipSlotSsbo); + _gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer, 4, _globalLightsSsbo); + _gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer, 5, _instLightSetSsbo); + _gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer, 6, _instIndoorSsbo); + _gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer, 7, _instAlphaSsbo); + _gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer, 8, _instSelectionLightingSsbo); + // Campaign V slice V2: already flushed/uploaded in UploadDeferredAlphaBuffers + // (this is the same non-ring buffer as the main draw path); just rebind. + _gl.BindBufferBase( + BufferTargetARB.ShaderStorageBuffer, + AcDream.App.Rendering.Gpu.GpuBindingModel.StorageTextureTable, + _textureTableSsbo); BindClipRegionBinding2(); + _gl.BindVertexArray(global.VAO); + _gl.BindBuffer(BufferTargetARB.DrawIndirectBuffer, _indirectBuffer); + _gl.Enable(EnableCap.DepthTest); + _gl.Enable(EnableCap.Blend); + _gl.DepthMask(false); + _gl.FrontFace(FrontFaceDirection.CW); - IGpuFrame frame = RequireFrame(); - using (IGpuPassEncoder encoder = frame.BeginPass(WorldPass)) + int runStart = firstPreparedDraw; + int preparedEnd = firstPreparedDraw + drawCount; + while (runStart < preparedEnd) { - var pushConstants = new GpuPushConstants - { - ViewProjection = _deferredAlphaViewProjection, - DrawIdOffset = 0, - LightingMode = 0, - RenderPass = 1, - LightDebug = AcDream.Core.Rendering.RenderingDiagnostics.LightDebugMode, - TextureIndexA = 0, - TextureIndexB = 0, - ParamA = 0f, - ParamB = 0f, - }; + TranslucencyKind blend = _deferredAlphaKinds[runStart]; + int runEnd = runStart + 1; + while (runEnd < preparedEnd && _deferredAlphaKinds[runEnd] == blend) + runEnd++; - // Bind a pipeline before anything else so the bindings below land - // on a live program; each blend run rebinds its own variant, and - // push constants survive those switches per the encoder contract. - encoder.BindPipeline(_alphaBlendPipeline); - encoder.SetPushConstants(in pushConstants); - - BindSection(encoder, GpuBindingModel.StorageInstances, _deferredAlphaInstances); - BindSection(encoder, GpuBindingModel.StorageBatches, _deferredAlphaBatches); - BindSection(encoder, GpuBindingModel.StorageClipSlots, _deferredAlphaClipSlots); - BindSection(encoder, GpuBindingModel.StorageGlobalLights, _deferredAlphaGlobalLights); - BindSection(encoder, GpuBindingModel.StorageInstanceLightSets, _deferredAlphaLightSets); - BindSection(encoder, GpuBindingModel.StorageInstanceIndoor, _deferredAlphaIndoor); - BindSection(encoder, GpuBindingModel.StorageInstanceAlpha, _deferredAlphaOpacity); - BindSection( - encoder, - GpuBindingModel.StorageInstanceSelectionLighting, - _deferredAlphaSelectionLighting); - BindTextureTable(encoder); - BindWorldMesh(encoder); - - int runStart = firstPreparedDraw; - int preparedEnd = firstPreparedDraw + drawCount; - while (runStart < preparedEnd) - { - TranslucencyKind blend = _deferredAlphaKinds[runStart]; - int runEnd = runStart + 1; - while (runEnd < preparedEnd && _deferredAlphaKinds[runEnd] == blend) - runEnd++; - - // The blend function retail selects per surface type is baked - // into the pipeline rather than issued as glBlendFunc: blend is - // not dynamic state in core Vulkan. Depth-test-on and - // depth-write-off come from the same descriptions. - BindPipelineWithMesh(encoder, PipelineForBlend(blend)); - DrawIndirectRange( - encoder, - ref pushConstants, - _deferredAlphaCommands.Buffer, - _deferredAlphaCommands.OffsetBytes, - runStart, - runEnd - runStart); - runStart = runEnd; - } + ApplyRetailBlend(blend); + DrawIndirectRange(runStart, runEnd - runStart); + runStart = runEnd; } - // Reassert the exit state raw-GL consumers still inherit — the alpha - // queue interleaves these batches with the (still raw GL) particle - // renderer, so the pass's ambient restore is not enough on its own. _gl.DepthMask(true); _gl.Disable(EnableCap.Blend); _gl.Disable(EnableCap.CullFace); _gl.BindVertexArray(0); } - private static void BindSection(IGpuPassEncoder encoder, uint binding, in RingSection section) => - encoder.BindStorageBuffer(binding, section.Buffer, section.OffsetBytes, section.SizeBytes); - private void EnsureDeferredAlphaCapacity(int count) { TrackScratchDemand(count); @@ -3307,23 +3149,58 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable _deferredAlpha.Capacity = targetCapacity; } - /// - /// The pipeline whose baked blend reproduces retail's per-surface-type - /// blend function — the exact mapping the imperative glBlendFunc - /// call this replaces performed: - /// Additive → (SrcAlpha, One), - /// InvAlpha → (OneMinusSrcAlpha, SrcAlpha), - /// everything else → (SrcAlpha, OneMinusSrcAlpha). - /// exists in the contract for this - /// third case; folding it onto straight alpha would silently change how - /// every inverse-alpha DAT surface composites. - /// - private IGpuPipeline PipelineForBlend(TranslucencyKind blend) => blend switch + private void UploadDeferredAlphaBuffers(int count) { - TranslucencyKind.Additive => _alphaAdditivePipeline, - TranslucencyKind.InvAlpha => _alphaInversePipeline, - _ => _alphaBlendPipeline, - }; + fixed (float* p = _instanceData) + UploadSsbo(_instanceSsbo, 0, ref _instanceSsboCapacityBytes, + p, count * 16 * sizeof(float)); + fixed (BatchData* p = _batchData) + UploadSsbo(_batchSsbo, 1, ref _batchSsboCapacityBytes, + p, count * sizeof(BatchData)); + fixed (uint* p = _clipSlotData) + UploadSsbo(_clipSlotSsbo, 3, ref _clipSlotSsboCapacityBytes, + p, count * sizeof(uint)); + fixed (int* p = _lightSetData) + UploadSsbo(_instLightSetSsbo, 5, ref _instLightSetSsboCapacityBytes, + p, count * LightManager.MaxLightsPerObject * sizeof(int)); + fixed (uint* p = _indoorData) + UploadSsbo(_instIndoorSsbo, 6, ref _instIndoorSsboCapacityBytes, + p, count * sizeof(uint)); + fixed (float* p = _alphaData) + UploadSsbo(_instAlphaSsbo, 7, ref _instAlphaSsboCapacityBytes, + p, count * sizeof(float)); + fixed (Vector2* p = _selectionLightingData) + UploadSsbo(_instSelectionLightingSsbo, 8, ref _instSelectionLightingSsboCapacityBytes, + p, count * sizeof(float) * 2); + UploadGlobalLights(); + // Campaign V slice V2 (binding=9): PrepareDeferredAlphaDraws registers + // handles into _textureTable above; flush/rebind before DrawPreparedAlphaBatch. + FlushAndBindTextureTable(); + + fixed (DrawElementsIndirectCommand* p = _indirectCommands) + { + UploadDynamicBuffer( + BufferTargetARB.DrawIndirectBuffer, + _indirectBuffer, + ref _indirectBufferCapacityBytes, + p, + count * sizeof(DrawElementsIndirectCommand)); + } + } + + private void ApplyRetailBlend(TranslucencyKind blend) + { + _gl.BlendFunc( + blend == TranslucencyKind.InvAlpha + ? BlendingFactor.OneMinusSrcAlpha + : BlendingFactor.SrcAlpha, + blend switch + { + TranslucencyKind.Additive => BlendingFactor.One, + TranslucencyKind.InvAlpha => BlendingFactor.SrcAlpha, + _ => BlendingFactor.OneMinusSrcAlpha, + }); + } private static int CompareOpaqueSubmissionOrder(InstanceGroup a, InstanceGroup b) { @@ -3354,253 +3231,267 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable return runs; } - private void DrawIndirectRange( - IGpuPassEncoder encoder, - ref GpuPushConstants pushConstants, - IGpuBuffer commandBuffer, - uint commandBaseOffsetBytes, - int startCommand, - int commandCount) + private unsafe void DrawIndirectRange(int startCommand, int commandCount) { int end = startCommand + commandCount; int command = startCommand; while (command < end) { var cullMode = _drawCullModes[command]; - ApplyCullMode(encoder, cullMode); + ApplyCullMode(cullMode); int runCount = 1; while (command + runCount < end && _drawCullModes[command + runCount] == cullMode) runCount++; - // Each multi-draw-indirect call restarts gl_DrawID at 0. Because - // this method splits one logical opaque/transparent pass into - // CullMode runs, the shader must receive the absolute command + // Each glMultiDrawElementsIndirect call restarts gl_DrawID at 0. + // Because this method splits one logical opaque/transparent pass + // into CullMode runs, the shader must receive the absolute command // index for this run or it will read BatchData[0] again and bind - // the wrong texture for later runs. Vulkan's gl_DrawID resets per - // vkCmdDrawIndexedIndirect identically, so this survives the - // backend swap unchanged (issue #52). - pushConstants.DrawIdOffset = command; - encoder.SetPushConstants(in pushConstants); - encoder.MultiDrawIndexedIndirect( - commandBuffer, - commandBaseOffsetBytes + (uint)(command * DrawCommandStride), + // the wrong texture for later runs. + _shader.SetInt("uDrawIDOffset", command); + _gl.MultiDrawElementsIndirect( + PrimitiveType.Triangles, + DrawElementsType.UnsignedShort, + (void*)(command * DrawCommandStride), (uint)runCount, - DrawCommandStride); + (uint)DrawCommandStride); command += runCount; } } - /// - /// The world pass. Load/Store against a null colour target, which on GL - /// means "the framebuffer the spine (or an offscreen caller) already - /// bound" — the frame spine still owns clears and framebuffer management - /// until slice V4h. - /// - private static readonly GpuPassDescription WorldPass = new() - { - Name = "wb-world-entities", - Color = new GpuColorAttachment( - Target: null, - Load: GpuLoadOp.Load, - Store: GpuStoreOp.Store, - ClearColor: default), - Depth = new GpuDepthAttachment( - Load: GpuLoadOp.Load, - Store: GpuStoreOp.Store, - ClearDepth: 1f, - ClearStencil: 0), - SampleCount = 1, - }; - - private const string OpaqueTimerScope = "wb-entities-opaque"; - private const string TransparentTimerScope = "wb-entities-transparent"; - - private static readonly IDisposable InactiveTimerScope = new NullTimerScope(); - - private sealed class NullTimerScope : IDisposable - { - public void Dispose() - { - } - } - - private static void ApplyCullMode(IGpuPassEncoder encoder, CullMode mode) + private void ApplyCullMode(CullMode mode) { // WB BaseObjectRenderManager.cs:850-866 applies CullMode per MDI group. // WB GameScene.cs:843 sets FrontFace(CW) globally; SetCullMode then // only chooses front/back culling. Keep the same convention here so // splitting MDI commands by CullMode cannot resurrect stale CCW state. - // Both are dynamic state in core Vulkan 1.3, so they stay per-run - // calls rather than pipeline variants. - encoder.SetFrontFace(GpuFrontFace.Clockwise); + _gl.FrontFace(FrontFaceDirection.CW); switch (mode) { case CullMode.None: - encoder.SetCullMode(GpuCullMode.None); + _gl.Disable(EnableCap.CullFace); break; case CullMode.Clockwise: - encoder.SetCullMode(GpuCullMode.Front); + _gl.Enable(EnableCap.CullFace); + _gl.CullFace(TriangleFace.Front); break; case CullMode.CounterClockwise: case CullMode.Landblock: - encoder.SetCullMode(GpuCullMode.Back); + _gl.Enable(EnableCap.CullFace); + _gl.CullFace(TriangleFace.Back); break; } } - /// - /// Binds the shared mesh arena as this pass's vertex and index source. - /// Replaces glBindVertexArray(global.VAO): the pipeline owns a VAO - /// shaped by , which is the same - /// 32-byte position/normal/texcoord layout GlobalMeshBuffer packs, - /// so binding the arena's stores through the encoder reproduces exactly - /// the vertex state the arena's own VAO carried. - /// - /// - /// Binds a pipeline and immediately re-establishes the mesh source. - /// - /// Every owns its own vertex array, and the - /// vertex attribute pointers plus the index binding are vertex-array state - /// — so a pipeline switch inside a pass silently drops them, while the - /// storage-buffer bindings (context state) survive. Binding a blend - /// variant mid-pass therefore has to re-bind the arena, and routing every - /// switch through one helper is what stops that from being re-derived at - /// each call site. - /// - private void BindPipelineWithMesh(IGpuPassEncoder encoder, IGpuPipeline pipeline) + private void ActivateNextDynamicBufferSet() { - encoder.BindPipeline(pipeline); - BindWorldMesh(encoder); + if (!_dynamicFrameStarted) + throw new InvalidOperationException("BeginFrame must be called before drawing world entities."); + + List slotSets = _dynamicBufferSetsByFrame[_dynamicFrameSlot]; + if (_dynamicBufferSetCursor == slotSets.Count) + slotSets.Add(CreateDynamicBufferSet()); + + DynamicBufferSet set = slotSets[_dynamicBufferSetCursor++]; + _activeDynamicBufferSet = set; + _instanceSsbo = set.InstanceSsbo; + _batchSsbo = set.BatchSsbo; + _indirectBuffer = set.IndirectBuffer; + _clipSlotSsbo = set.ClipSlotSsbo; + _globalLightsSsbo = set.GlobalLightsSsbo; + _instLightSetSsbo = set.InstanceLightSetSsbo; + _instIndoorSsbo = set.InstanceIndoorSsbo; + _instAlphaSsbo = set.InstanceAlphaSsbo; + _instSelectionLightingSsbo = set.InstanceSelectionLightingSsbo; + _instanceSsboCapacityBytes = set.InstanceCapacityBytes; + _batchSsboCapacityBytes = set.BatchCapacityBytes; + _indirectBufferCapacityBytes = set.IndirectCapacityBytes; + _clipSlotSsboCapacityBytes = set.ClipSlotCapacityBytes; + _globalLightsSsboCapacityBytes = set.GlobalLightsCapacityBytes; + _instLightSetSsboCapacityBytes = set.InstanceLightSetCapacityBytes; + _instIndoorSsboCapacityBytes = set.InstanceIndoorCapacityBytes; + _instAlphaSsboCapacityBytes = set.InstanceAlphaCapacityBytes; + _instSelectionLightingSsboCapacityBytes = set.InstanceSelectionLightingCapacityBytes; } - private void BindWorldMesh(IGpuPassEncoder encoder) + private DynamicBufferSet CreateDynamicBufferSet() { - GlobalMeshBuffer global = _meshAdapter.MeshManager?.GlobalBuffer - ?? throw new InvalidOperationException( - "The world mesh arena is not available; ExecuteClassifiedGroups " - + "should have returned at its anyVao guard."); - IGpuBuffer vertices = global.VertexStore - ?? throw new InvalidOperationException("The world mesh arena has no vertex store."); - IGpuBuffer indices = global.IndexStore - ?? throw new InvalidOperationException("The world mesh arena has no index store."); - encoder.BindVertexBuffer(vertices, 0); - encoder.BindIndexBuffer(indices, 0, GpuIndexType.UInt16); + var set = new DynamicBufferSet(); + try + { + set.InstanceSsbo = TrackedGlResource.CreateBuffer(_gl, "creating entity instance SSBO"); + set.BatchSsbo = TrackedGlResource.CreateBuffer(_gl, "creating entity batch SSBO"); + set.IndirectBuffer = TrackedGlResource.CreateBuffer(_gl, "creating entity indirect buffer"); + set.ClipSlotSsbo = TrackedGlResource.CreateBuffer(_gl, "creating entity clip-slot SSBO"); + set.GlobalLightsSsbo = TrackedGlResource.CreateBuffer(_gl, "creating entity global-light SSBO"); + set.InstanceLightSetSsbo = TrackedGlResource.CreateBuffer(_gl, "creating entity light-set SSBO"); + set.InstanceIndoorSsbo = TrackedGlResource.CreateBuffer(_gl, "creating entity indoor SSBO"); + set.InstanceAlphaSsbo = TrackedGlResource.CreateBuffer(_gl, "creating entity alpha SSBO"); + set.InstanceSelectionLightingSsbo = TrackedGlResource.CreateBuffer( + _gl, + "creating entity selection-lighting SSBO"); + return set; + } + catch (Exception creationFailure) + { + try { DeleteDynamicBufferSet(set); } + catch (Exception cleanupFailure) + { + throw new AggregateException( + "Entity dynamic-buffer creation and rollback failed.", + creationFailure, + cleanupFailure); + } + throw; + } + } + + private void DeleteDynamicBufferSet(DynamicBufferSet set) + { + List? failures = null; + void Attempt(uint buffer, int bytes, string name) + { + try { TrackedGlResource.DeleteBuffer(_gl, buffer, bytes, $"deleting {name}"); } + catch (Exception ex) { (failures ??= []).Add(ex); } + } + + Attempt(set.InstanceSsbo, set.InstanceCapacityBytes, "entity instance SSBO"); + Attempt(set.BatchSsbo, set.BatchCapacityBytes, "entity batch SSBO"); + Attempt(set.IndirectBuffer, set.IndirectCapacityBytes, "entity indirect buffer"); + Attempt(set.ClipSlotSsbo, set.ClipSlotCapacityBytes, "entity clip-slot SSBO"); + Attempt(set.GlobalLightsSsbo, set.GlobalLightsCapacityBytes, "entity global-light SSBO"); + Attempt(set.InstanceLightSetSsbo, set.InstanceLightSetCapacityBytes, "entity light-set SSBO"); + Attempt(set.InstanceIndoorSsbo, set.InstanceIndoorCapacityBytes, "entity indoor SSBO"); + Attempt(set.InstanceAlphaSsbo, set.InstanceAlphaCapacityBytes, "entity alpha SSBO"); + Attempt( + set.InstanceSelectionLightingSsbo, + set.InstanceSelectionLightingCapacityBytes, + "entity selection-lighting SSBO"); + + if (failures is not null) + throw new AggregateException("One or more entity dynamic buffers failed to delete.", failures); + } + + private void PersistActiveDynamicBufferCapacities() + { + DynamicBufferSet set = _activeDynamicBufferSet + ?? throw new InvalidOperationException("No dynamic entity buffer set is active."); + set.InstanceCapacityBytes = _instanceSsboCapacityBytes; + set.BatchCapacityBytes = _batchSsboCapacityBytes; + set.IndirectCapacityBytes = _indirectBufferCapacityBytes; + set.ClipSlotCapacityBytes = _clipSlotSsboCapacityBytes; + set.GlobalLightsCapacityBytes = _globalLightsSsboCapacityBytes; + set.InstanceLightSetCapacityBytes = _instLightSetSsboCapacityBytes; + set.InstanceIndoorCapacityBytes = _instIndoorSsboCapacityBytes; + set.InstanceAlphaCapacityBytes = _instAlphaSsboCapacityBytes; + set.InstanceSelectionLightingCapacityBytes = _instSelectionLightingSsboCapacityBytes; + } + + private unsafe void UploadSsbo( + uint ssbo, + uint binding, + ref int capacityBytes, + void* data, + int byteCount) + { + UploadDynamicBuffer( + BufferTargetARB.ShaderStorageBuffer, + ssbo, + ref capacityBytes, + data, + byteCount); + _gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer, binding, ssbo); + } + + private unsafe void UploadDynamicBuffer( + BufferTargetARB target, + uint buffer, + ref int capacityBytes, + void* data, + int byteCount) + { + if (byteCount < 0) + throw new ArgumentOutOfRangeException(nameof(byteCount)); + + _gl.BindBuffer(target, buffer); + // A render bucket can legitimately contain zero batches (for example the outdoor dynamic + // bucket immediately after auto-entry). Keep the buffer bound for the corresponding SSBO + // binding, but there is no active prefix to allocate or upload and no draw can read it. + if (byteCount == 0) + return; + + if (capacityBytes < byteCount) + { + int grownCapacity = DynamicBufferCapacity.Grow(capacityBytes, byteCount); + TrackedGlResource.AllocateBufferStorage( + _gl, + (GLEnum)target, + buffer, + capacityBytes, + grownCapacity, + GLEnum.DynamicDraw, + $"growing entity dynamic buffer {buffer} to {grownCapacity} bytes"); + capacityBytes = grownCapacity; + } + + _gl.BufferSubData(target, 0, (nuint)byteCount, data); } /// /// Fix B: pack into the binding=4 global light - /// section (one GlobalLight = 4 vec4 = 16 floats, std430 stride 64 bytes, - /// matching mesh_modern.vert's GlobalLight). Always binds ≥1 element - /// so the shader never reads an unbound SSBO — on a no-lights frame index 0 - /// is a zeroed dummy that no instance set references (all sets are -1). + /// buffer (one GlobalLight = 4 vec4 = 16 floats, std430 stride 64 bytes, + /// matching mesh_modern.vert's GlobalLight). Always uploads ≥1 element + /// so the shader never reads an unbound SSBO — on a no-lights frame index 0 is + /// a zeroed dummy that no instance set references (all sets are -1). /// - private void BindGlobalLights(IGpuPassEncoder encoder, IGpuFrame frame) + private unsafe void UploadGlobalLights() { - int packed = GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData); - int count = packed > 0 ? packed : 1; // never zero-size + int n = GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData); + int count = n > 0 ? n : 1; // never zero-size // Pack guarantees _globalLightData holds at least max(n,1) * FloatsPerLight floats. - BindRingSection( - encoder, - frame, - GpuBindingModel.StorageGlobalLights, - _globalLightData.AsSpan(0, count * GlobalLightPacker.FloatsPerLight)); + fixed (float* gp = _globalLightData) + UploadSsbo(_globalLightsSsbo, 4, ref _globalLightsSsboCapacityBytes, gp, + count * GlobalLightPacker.FloatsPerLight * sizeof(float)); } /// - /// Campaign V slice V2's handle table (binding=9), now held as an - /// and bound through the encoder. Unlike every - /// other binding here it is NOT a ring allocation: a genuinely new handle - /// is rare — new dat surfaces / composite overrides, not every frame — so - /// the buffer is long-lived and re-uploaded only when - /// is set, or when growth forced - /// a fresh allocation whose contents would otherwise be undefined. - /// - /// Growth is create-and-retire rather than resize, matching the contract: - /// the old buffer's release routes through the device's retirement queue, - /// so a submitted frame still reading it is never freed underneath. + /// Campaign V slice V2: uploads 's handles to + /// when a new one was registered since the + /// last flush (by or ), + /// then (re)binds it at . + /// A genuinely new handle is rare — new dat surfaces/composite overrides, + /// not every frame — so unlike the SSBOs above this is not part of the + /// ring-buffered ; see + /// 's doc comment. /// - private void BindTextureTable(IGpuPassEncoder encoder) + private unsafe void FlushAndBindTextureTable() { - ReadOnlySpan handles = _textureTable.Handles; - int byteCount = Math.Max(handles.Length * sizeof(ulong), sizeof(ulong)); + if (_textureTableSsbo == 0) + _textureTableSsbo = TrackedGlResource.CreateBuffer(_gl, "creating WB texture-table SSBO"); - bool reallocated = false; - if (_textureTableBuffer is null || _textureTableBufferBytes < byteCount) + if (_textureTable.Dirty) { - _textureTableBuffer?.Dispose(); - _textureTableBufferBytes = DynamicBufferCapacity.Grow(_textureTableBufferBytes, byteCount); - _textureTableBuffer = _device.CreateBuffer(new GpuBufferDescription( - "wb-texture-table", - _textureTableBufferBytes, - GpuBufferUsage.Storage, - GpuMemoryResidency.DeviceLocal)); - reallocated = true; - } - - if ((reallocated || _textureTable.Dirty) && !handles.IsEmpty) - { - _textureTableBuffer.Upload(0, MemoryMarshal.AsBytes(handles)); + ReadOnlySpan handles = _textureTable.Handles; + int byteCount = handles.Length * sizeof(ulong); + fixed (ulong* p = handles) + { + UploadDynamicBuffer( + BufferTargetARB.ShaderStorageBuffer, + _textureTableSsbo, + ref _textureTableSsboCapacityBytes, + p, + byteCount); + } _textureTable.MarkFlushed(); } - - encoder.BindStorageBuffer( - GpuBindingModel.StorageTextureTable, - _textureTableBuffer, - 0, - (uint)byteCount); + _gl.BindBufferBase( + BufferTargetARB.ShaderStorageBuffer, + AcDream.App.Rendering.Gpu.GpuBindingModel.StorageTextureTable, + _textureTableSsbo); } - /// - /// Opens a GPU timing scope only while ACDREAM_WB_DIAG=1, matching - /// the old behaviour of creating query objects lazily on first diagnostic - /// frame and issuing none otherwise. - /// - private static IDisposable BeginPassTimer(IGpuPassEncoder encoder, bool diag, string scopeName) => - diag ? encoder.BeginTimerScope(scopeName) : InactiveTimerScope; - - /// - /// Feeds the [WB-DIAG] 256-sample median/p95 window from the timer - /// pool. - /// - /// Behaviour note (Campaign V slice V4c): the sample still measures - /// this dispatch's opaque + transparent GPU time and still flows into the - /// same rolling window, but it is now read from - /// — the most recent RETIRED result - /// for each scope name — rather than from a hand-rolled 3-deep query ring - /// read at N-3. Two consequences, both confined to the diagnostic: a - /// sample can repeat when the GPU has not finished a newer query yet - /// (TryResolve keeps the last resolved value instead of dropping), and the - /// #125 "never read a query that was never begun" guard is now the timer - /// pool's problem rather than this class's. Median and p95 over 256 - /// samples are unchanged. - /// - private void SampleGpuTimers(bool diag) - { - if (!diag) - return; - - double milliseconds = 0; - bool resolvedAny = false; - if (_device.Timers.TryResolve(OpaqueTimerScope, out double opaqueMs)) - { - milliseconds += opaqueMs; - resolvedAny = true; - } - if (_device.Timers.TryResolve(TransparentTimerScope, out double transparentMs)) - { - milliseconds += transparentMs; - resolvedAny = true; - } - if (!resolvedAny) - return; - - _gpuSamples[_gpuSampleCursor] = (long)(milliseconds * 1000d); - _gpuSampleCursor = (_gpuSampleCursor + 1) % _gpuSamples.Length; - } - - /// /// Phase U.3: bind the per-cell clip-region SSBO to binding=2. Prefers the /// shared buffer (set via ); @@ -4251,10 +4142,13 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable private void BuildDisposeReleases(List<(string Name, Action Release)> releases) { - // Campaign V slice V4c: the per-frame buffer-set pool and the timing - // query ring are gone, so neither appears here any more. The frame - // ring belongs to the device, and the timer pool owns its own queries; - // both outlive this renderer and are released by their owners. + for (int frame = 0; frame < _dynamicBufferSetsByFrame.Length; frame++) + { + List frameSets = _dynamicBufferSetsByFrame[frame]; + for (int index = 0; index < frameSets.Count; index++) + AddDynamicBufferSetReleases(releases, frameSets[index], frame, index); + } + AddRawGlRelease( releases, _fallbackClipRegionSsbo, @@ -4262,26 +4156,60 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable "deleting entity fallback clip SSBO", _gl.DeleteBuffer); - // The five mesh_modern pipeline variants and the texture-table buffer - // are RHI resources: disposing them enqueues the real release on the - // device's retirement queue, so nothing is freed under a frame still - // in flight. Named individually so a failure names the resource. - AddResourceRelease(releases, "pipeline-opaque", _opaquePipeline); - AddResourceRelease(releases, "pipeline-opaque-a2c", _opaqueAlphaToCoveragePipeline); - AddResourceRelease(releases, "pipeline-alpha", _alphaBlendPipeline); - AddResourceRelease(releases, "pipeline-additive", _alphaAdditivePipeline); - AddResourceRelease(releases, "pipeline-inverse-alpha", _alphaInversePipeline); - AddResourceRelease(releases, "texture-table", _textureTableBuffer); + AddTrackedBufferRelease( + releases, + _textureTableSsbo, + _textureTableSsboCapacityBytes, + "texture-table", + "deleting entity texture-table SSBO"); + + if (!_gpuQueriesInitialized) + return; + for (int i = 0; i < GpuQueryRingDepth; i++) + { + AddRawGlRelease( + releases, + _gpuQueryOpaque[i], + $"opaque-query-{i}", + "deleting entity opaque timing query", + _gl.DeleteQuery); + AddRawGlRelease( + releases, + _gpuQueryTransparent[i], + $"transparent-query-{i}", + "deleting entity transparent timing query", + _gl.DeleteQuery); + } } - private static void AddResourceRelease( + private void AddDynamicBufferSetReleases( List<(string Name, Action Release)> releases, - string name, - IDisposable? resource) + DynamicBufferSet set, + int frame, + int index) { - if (resource is null) - return; - releases.Add((name, resource.Dispose)); + AddTrackedBufferRelease(releases, set.InstanceSsbo, set.InstanceCapacityBytes, + $"dynamic-{frame}-{index}-instances", "deleting entity instance SSBO"); + AddTrackedBufferRelease(releases, set.BatchSsbo, set.BatchCapacityBytes, + $"dynamic-{frame}-{index}-batches", "deleting entity batch SSBO"); + AddTrackedBufferRelease(releases, set.IndirectBuffer, set.IndirectCapacityBytes, + $"dynamic-{frame}-{index}-indirect", "deleting entity indirect buffer"); + AddTrackedBufferRelease(releases, set.ClipSlotSsbo, set.ClipSlotCapacityBytes, + $"dynamic-{frame}-{index}-clip-slots", "deleting entity clip-slot SSBO"); + AddTrackedBufferRelease(releases, set.GlobalLightsSsbo, set.GlobalLightsCapacityBytes, + $"dynamic-{frame}-{index}-global-lights", "deleting entity global-light SSBO"); + AddTrackedBufferRelease(releases, set.InstanceLightSetSsbo, set.InstanceLightSetCapacityBytes, + $"dynamic-{frame}-{index}-light-sets", "deleting entity light-set SSBO"); + AddTrackedBufferRelease(releases, set.InstanceIndoorSsbo, set.InstanceIndoorCapacityBytes, + $"dynamic-{frame}-{index}-indoor", "deleting entity indoor SSBO"); + AddTrackedBufferRelease(releases, set.InstanceAlphaSsbo, set.InstanceAlphaCapacityBytes, + $"dynamic-{frame}-{index}-alpha", "deleting entity alpha SSBO"); + AddTrackedBufferRelease( + releases, + set.InstanceSelectionLightingSsbo, + set.InstanceSelectionLightingCapacityBytes, + $"dynamic-{frame}-{index}-selection-lighting", + "deleting entity selection-lighting SSBO"); } private void AddTrackedBufferRelease( @@ -4323,19 +4251,25 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable private void CompleteDispose() { + foreach (List frameSets in _dynamicBufferSetsByFrame) + frameSets.Clear(); + _activeDynamicBufferSet = null; _dynamicFrameStarted = false; + _instanceSsbo = 0; + _batchSsbo = 0; + _indirectBuffer = 0; + _clipSlotSsbo = 0; + _globalLightsSsbo = 0; + _instLightSetSsbo = 0; + _instIndoorSsbo = 0; + _instAlphaSsbo = 0; + _instSelectionLightingSsbo = 0; _fallbackClipRegionSsbo = 0; - _textureTableBuffer = null; - _textureTableBufferBytes = 0; - _deferredAlphaInstances = default; - _deferredAlphaBatches = default; - _deferredAlphaClipSlots = default; - _deferredAlphaGlobalLights = default; - _deferredAlphaLightSets = default; - _deferredAlphaIndoor = default; - _deferredAlphaOpacity = default; - _deferredAlphaSelectionLighting = default; - _deferredAlphaCommands = default; + _textureTableSsbo = 0; + _textureTableSsboCapacityBytes = 0; + Array.Clear(_gpuQueryOpaque); + Array.Clear(_gpuQueryTransparent); + _gpuQueriesInitialized = false; } // ── Public types + helpers for BuildIndirectArrays (Task 9) ───────────── diff --git a/tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs index a940cfa6..007cb531 100644 --- a/tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs @@ -110,14 +110,14 @@ public class EnvCellRendererTests { // GL and meshManager are null — only valid for pure-data tests (no // Initialize() is called, so no GL calls are made). - var r = new EnvCellRenderer(gl: null!, device: null!, frameSource: null!, meshManager: null!, frustum: new WbFrustum()); + var r = new EnvCellRenderer(gl: null!, meshManager: null!, frustum: new WbFrustum()); Assert.True(r.NeedsPrepare); } [Fact] public void NewRenderer_NotDisposed() { - var r = new EnvCellRenderer(gl: null!, device: null!, frameSource: null!, meshManager: null!, frustum: new WbFrustum()); + var r = new EnvCellRenderer(gl: null!, meshManager: null!, frustum: new WbFrustum()); Assert.False(r.IsDisposed); } @@ -128,7 +128,7 @@ public class EnvCellRendererTests [Fact] public void RemoveLandblock_NonExistent_DoesNotThrow() { - var r = new EnvCellRenderer(gl: null!, device: null!, frameSource: null!, meshManager: null!, frustum: new WbFrustum()); + var r = new EnvCellRenderer(gl: null!, meshManager: null!, frustum: new WbFrustum()); // Should silently no-op. r.RemoveLandblock(0xA9B40000u); Assert.True(r.NeedsPrepare); @@ -218,7 +218,7 @@ public class EnvCellRendererTests // Reflection-based test that drives the private GetPooledList + // _poolIndex/_listPool fields. If a future refactor removes the // Clear() call, this test fails. - var r = new EnvCellRenderer(gl: null!, device: null!, frameSource: null!, meshManager: null!, frustum: new WbFrustum()); + var r = new EnvCellRenderer(gl: null!, meshManager: null!, frustum: new WbFrustum()); var type = typeof(EnvCellRenderer); var getPooledListMethod = type.GetMethod("GetPooledList", @@ -249,7 +249,7 @@ public class EnvCellRendererTests { // Sanity check for the fresh-list branch. _poolIndex past _listPool.Count // should produce a brand-new empty list and grow the pool. - var r = new EnvCellRenderer(gl: null!, device: null!, frameSource: null!, meshManager: null!, frustum: new WbFrustum()); + var r = new EnvCellRenderer(gl: null!, meshManager: null!, frustum: new WbFrustum()); var type = typeof(EnvCellRenderer); var getPooledListMethod = type.GetMethod("GetPooledList", @@ -338,7 +338,7 @@ public class EnvCellRendererTests [Fact] public void NewRenderer_SnapshotGenerationStartsAtZero() { - var r = new EnvCellRenderer(gl: null!, device: null!, frameSource: null!, meshManager: null!, frustum: new WbFrustum()); + var r = new EnvCellRenderer(gl: null!, meshManager: null!, frustum: new WbFrustum()); Assert.Equal(0, r.SnapshotGeneration); } }