diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs index 26ffa74e..3e2ef8db 100644 --- a/src/AcDream.App/Composition/LivePresentationComposition.cs +++ b/src/AcDream.App/Composition/LivePresentationComposition.cs @@ -680,7 +680,8 @@ internal sealed class LivePresentationCompositionPhase "WB draw dispatcher", () => new WbDrawDispatcher( d.Gl, - foundation.MeshShader, + host.GpuDevice, + host.GpuFrameLifetime, foundation.TextureCache, foundation.MeshAdapter, entitySpawnAdapter, @@ -855,10 +856,12 @@ 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(foundation.MeshShader); + envCellLease.Resource.Initialize(); 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 dd329df8..22076739 100644 --- a/src/AcDream.App/Rendering/Gpu/Gl/GlGpuDevice.cs +++ b/src/AcDream.App/Rendering/Gpu/Gl/GlGpuDevice.cs @@ -190,6 +190,30 @@ 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); } @@ -297,14 +321,31 @@ internal sealed class GlGpuDevice : IGpuDevice "only ever renders single-sampled targets."); } - uint framebuffer = 0; + // 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. 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."); - framebuffer = glTarget.GlFramebufferName; + _gl.BindFramebuffer(GLEnum.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 10e76490..83ad72fd 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, meshShader, textureCache, meshAdapter, entitySpawnAdapter, + gl, gpuDevice, gpuFrameLifetime, 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 b74960ae..1be4b000 100644 --- a/src/AcDream.App/Rendering/Shader.cs +++ b/src/AcDream.App/Rendering/Shader.cs @@ -52,7 +52,16 @@ public sealed class Shader : IDisposable /// preamble cannot simply be prepended — it has to land after that block, /// before the first real declaration. /// - private static string InjectPreamble(string source, string preamble) + /// + /// 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) { 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 4c4054a1..674b01c4 100644 --- a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs +++ b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs @@ -24,6 +24,7 @@ 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; @@ -48,13 +49,16 @@ public sealed unsafe class EnvCellRenderer : private readonly object _renderLock = new(); private EnvCellVisibilitySnapshot _activeSnapshot = new(); - // Shader (set by caller via Initialize). - // Uses acdream's legacy Shader type (not WB's GLSLShader) to match the - // existing wire-in pattern in GameWindow.cs where _meshShader is loaded - // for mesh_modern.{vert,frag} and shared across multiple consumers. - // API mapping: Bind() -> Use(), SetUniform(s, int) -> SetInt(s, int), - // SetUniform(s, Vector4) -> SetVec4(s, Vector4). - private AcDream.App.Rendering.Shader? _shader; + // 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; // 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 @@ -81,12 +85,10 @@ 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. - private uint _mdiCommandBuffer; - private int _mdiCommandCapacity; - private uint _modernInstanceBuffer; - private int _modernInstanceCapacity; - private uint _modernBatchBuffer; - private int _modernBatchCapacity; + // 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. // 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. @@ -96,19 +98,13 @@ 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 @@ -121,31 +117,22 @@ 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; - internal int DynamicBufferSetCount => - _dynamicBufferSetsByFrame.Sum(frameSets => frameSets.Count); + /// + /// 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; // Phase U.3: SHARED per-cell clip-region SSBO (binding=2) handed in via // SetClipRegionSsbo (the GameWindow-level ClipFrame buffer). When 0, we bind @@ -162,9 +149,12 @@ 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 uint _textureTableSsbo; - private int _textureTableSsboCapacityBytes; + private IGpuBuffer? _textureTableBuffer; + private int _textureTableBufferBytes; // Reusable scratch arrays — avoid per-frame allocation. // WB BaseObjectRenderManager.cs:58-59: private DrawElementsIndirectCommand[] _commands = Array.Empty<...>() @@ -193,8 +183,9 @@ 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. - private static uint _currentVao; + // 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. private static CullMode? _currentCullMode; public bool NeedsPrepare { get; private set; } = true; @@ -300,28 +291,85 @@ public sealed unsafe class EnvCellRenderer : // Constructor + Initialize // --------------------------------------------------------------------------- - public EnvCellRenderer(GL gl, ObjectMeshManager meshManager, WbFrustum frustum) + internal EnvCellRenderer( + GL gl, + IGpuDevice device, + ICurrentGpuFrameSource frameSource, + ObjectMeshManager meshManager, + WbFrustum frustum) { _gl = gl; + _device = device; + _frameSource = frameSource; _meshManager = meshManager; _frustum = frustum; } - public void Initialize(AcDream.App.Rendering.Shader shader) + /// + /// 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() { - _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); _initialized = true; } /// Resets the per-frame submission cursor for the GPU-fenced slot. public void BeginFrame(int frameSlot) { - if ((uint)frameSlot >= (uint)_dynamicBufferSetsByFrame.Length) + if ((uint)frameSlot >= (uint)FrameSlotCount) throw new ArgumentOutOfRangeException(nameof(frameSlot)); - _dynamicFrameSlot = frameSlot; - _dynamicBufferSetCursor = 0; + // 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. _dynamicFrameStarted = true; - _activeDynamicBufferSet = null; if (++_lightFrameGeneration == 0) { _cellLightSetCache.Clear(); @@ -971,13 +1019,13 @@ public sealed unsafe class EnvCellRenderer : IReadOnlyList? orderedCellIds) { // WB EnvCellRenderManager.cs:400: - if (!_initialized || _shader is null || _shader.Program == 0) return; + if (!_initialized || _opaquePipeline is null) return; lock (_renderLock) { var snapshot = _activeSnapshot; - // WB EnvCellRenderManager.cs:403-404: - _shader.Use(); + // WB EnvCellRenderManager.cs:403-404: the program bind moved into + // the pass (BindPipeline), so nothing happens here any more. // 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 @@ -1003,27 +1051,24 @@ 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/0 at entry guarantees each Render call - // re-establishes the GL state it expects. - _currentVao = 0; + // 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.) _currentCullMode = null; - // WB EnvCellRenderManager.cs:406-409: uniform state setup. - _shader.SetInt("uRenderPass", (int)renderPass); - _shader.SetInt("uFilterByCell", 0); - _shader.SetInt("uLightingMode", 1); // A7 Fix D D-3/D-4: EnvCell bake (wrap points, no sun) - // #176 stripe-hunt isolation (ACDREAM_LIGHT_DEBUG) — throwaway diagnostic. - _shader.SetInt("uLightDebug", AcDream.Core.Rendering.RenderingDiagnostics.LightDebugMode); - - // Phase U.4 ROOT-CAUSE FIX (cell-shell flicker / "transparent walls when - // moving"): upload uViewProjection HERE rather than inheriting it from - // WbDrawDispatcher. The opaque shell pass runs BEFORE the dispatcher's - // Draw (GameWindow ~7411 vs ~7418, the only other setter), so without - // this the opaque shells used the PREVIOUS frame's matrix — a stale - // gl_Position against this frame's clip planes → pose-dependent clipping, - // worst while moving. Same self-contained-GL-state precedent as the - // 2026-05-28 cull-state cache fix above. - _shader.SetMatrix4("uViewProjection", _lastViewProjection); + // 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. List allInstances = _renderInstances; List<(ObjectRenderData renderData, ulong gfxObjId, int count, int offset)> drawCalls = @@ -1137,7 +1182,6 @@ public sealed unsafe class EnvCellRenderer : if (_drawCallRanges.Count == 0 && drawCalls.Count > 0) _drawCallRanges.Add(new DrawCallRange(0, drawCalls.Count)); RenderModernMDIInternal( - _shader, drawCalls, allInstances, _drawCallRanges, @@ -1146,11 +1190,13 @@ public sealed unsafe class EnvCellRenderer : // WB EnvCellRenderManager.cs:486-510: selection/hover highlights — DROPPED (no editor state). - // WB EnvCellRenderManager.cs:506-509: cleanup. - _shader.SetVec4("uHighlightColor", new System.Numerics.Vector4(0, 0, 0, 0)); - _shader.SetInt("uRenderPass", (int)renderPass); + // 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. _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 @@ -1276,58 +1322,6 @@ 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) @@ -1350,59 +1344,7 @@ 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, @@ -1421,9 +1363,9 @@ public sealed unsafe class EnvCellRenderer : var globalVao = _meshManager.GlobalBuffer?.VAO ?? 0u; if (globalVao == 0) return; - // WB BaseObjectRenderManager.cs:715-716: - shader.Use(); - shader.SetInt("uFilterByCell", 0); + // 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:718-740: count the pass-filtered batches. // A normal render has one range. The ordered transparent-shell path has @@ -1459,114 +1401,33 @@ 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"): 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. + // 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. // - // §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; - } + // §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!; + // 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) @@ -1658,35 +1519,17 @@ public sealed unsafe class EnvCellRenderer : } } - // 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); + // 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. if (_gpuInstanceTransforms.Length < uniqueInstanceCount) Array.Resize(ref _gpuInstanceTransforms, Math.Max(_gpuInstanceTransforms.Length * 2, uniqueInstanceCount)); for (int i = 0; i < uniqueInstanceCount; i++) _gpuInstanceTransforms[i] = allInstances[i].Transform; - fixed (Matrix4x4* ptr = _gpuInstanceTransforms) - { - _gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0, - (nuint)(uniqueInstanceCount * sizeof(Matrix4x4)), ptr); - } - _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _modernBatchBuffer); - fixed (ModernBatchData* ptr = _modernBatches) - { - _gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0, - (nuint)(totalDraws * sizeof(ModernBatchData)), ptr); - } - - // Phase U.4: upload the per-instance clip-slot buffer (binding=3). When + // Phase U.4: fill the per-instance clip-slot data (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 @@ -1708,13 +1551,6 @@ 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; @@ -1733,108 +1569,133 @@ public sealed unsafe class EnvCellRenderer : && AcDream.Core.Rendering.RenderingDiagnostics.ProbeSeamDrawEnabled) EmitSeamDrawProbe(drawCalls, allInstances, _seamProbeFilter); - // A7 Fix D (D-2): upload binding=4 (global lights) + binding=5 (per-instance set). + // A7 Fix D (D-2): binding=4 (global lights) + binding=5 (per-instance set). int lightCount = AcDream.Core.Lighting.GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData); int glUploadCount = lightCount > 0 ? lightCount : 1; - _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _globalLightsSsbo); - if (glUploadCount > _globalLightsCapacity) - { - int grownGlobalLightCapacity = Math.Max(_globalLightsCapacity * 2, glUploadCount); - TrackedGlResource.AllocateBufferStorage( - _gl, - GLEnum.ShaderStorageBuffer, - _globalLightsSsbo, - (long)_globalLightsCapacity - * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight - * sizeof(float), - (long)grownGlobalLightCapacity - * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight - * sizeof(float), - GLEnum.DynamicDraw, - $"growing EnvCell global-light SSBO to {grownGlobalLightCapacity} lights"); - _globalLightsCapacity = grownGlobalLightCapacity; - } - fixed (float* gp = _globalLightData) - _gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0, - (nuint)(glUploadCount * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * sizeof(float)), gp); - _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _instLightSetSsbo); - fixed (int* lp = _lightSetData) - _gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0, - (nuint)(uniqueInstanceCount * lightStride * sizeof(int)), lp); - - PersistActiveDynamicBufferCapacities(); - - // WB BaseObjectRenderManager.cs:807-818: bind VAO + SSBOs + barrier. - // (globalVao validated at the top of the method — a return here would leak the - // pass state established above.) - if (_currentVao != globalVao) - { - _gl.BindVertexArray(globalVao); - _currentVao = globalVao; - } - - _gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 0, _modernInstanceBuffer); - _gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 1, _modernBatchBuffer); - // Phase U.3: per-instance clip slots (binding=3) + shared clip regions - // (binding=2, via the GameWindow ClipFrame or our no-clip fallback). - _gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 3, _clipSlotBuffer); + // 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(); - _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++) + IGpuFrame frame = RequireFrame(); + using (IGpuPassEncoder encoder = frame.BeginPass(ShellPass)) { - 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) + var pushConstants = new GpuPushConstants { - SetCullMode(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); - 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); - } + // 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); - shader.SetInt("uDrawIDOffset", drawRange.FirstCommand); - _gl.MultiDrawElementsIndirect( - PrimitiveType.Triangles, - DrawElementsType.UnsignedShort, - (void*)(drawRange.FirstCommand * sizeof(DrawElementsIndirectCommand)), - (uint)drawRange.CommandCount, - (uint)sizeof(DrawElementsIndirectCommand)); + 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)); + } } // 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. + // 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. _gl.Disable(EnableCap.Blend); _gl.DepthMask(true); - // WB BaseObjectRenderManager.cs:845-847: - shader.SetInt("uDrawIDOffset", 0); - _gl.BindBuffer(GLEnum.DrawIndirectBuffer, 0); + // 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. } internal static void AppendMdiDrawRange( @@ -1967,22 +1828,20 @@ public sealed unsafe class EnvCellRenderer : // Verbatim copy of WB BaseObjectRenderManager.cs:850-866. // --------------------------------------------------------------------------- - private void SetCullMode(CullMode mode) + private static void SetCullMode(IGpuPassEncoder encoder, CullMode mode) { _currentCullMode = mode; switch (mode) { case CullMode.None: - _gl.Disable(EnableCap.CullFace); + encoder.SetCullMode(GpuCullMode.None); break; case CullMode.Clockwise: - _gl.Enable(EnableCap.CullFace); - _gl.CullFace(TriangleFace.Front); + encoder.SetCullMode(GpuCullMode.Front); break; case CullMode.CounterClockwise: case CullMode.Landblock: - _gl.Enable(EnableCap.CullFace); - _gl.CullFace(TriangleFace.Back); + encoder.SetCullMode(GpuCullMode.Back); break; } } @@ -1992,48 +1851,136 @@ public sealed unsafe class EnvCellRenderer : // --------------------------------------------------------------------------- /// - /// 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. + /// 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. /// - private void FlushAndBindTextureTable() + private void BindTextureTable(IGpuPassEncoder encoder) { - if (_textureTableSsbo == 0) - _textureTableSsbo = TrackedGlResource.CreateBuffer(_gl, "creating EnvCell texture-table SSBO"); + ReadOnlySpan handles = _textureTable.Handles; + int byteCount = Math.Max(handles.Length * sizeof(ulong), sizeof(ulong)); - if (_textureTable.Dirty) + bool reallocated = false; + if (_textureTableBuffer is null || _textureTableBufferBytes < byteCount) { - 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); - } + _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)); _textureTable.MarkFlushed(); } - _gl.BindBufferBase( - GLEnum.ShaderStorageBuffer, - AcDream.App.Rendering.Gpu.GpuBindingModel.StorageTextureTable, - _textureTableSsbo); + + encoder.BindStorageBuffer( + GpuBindingModel.StorageTextureTable, + _textureTableBuffer, + 0, + (uint)byteCount); } + /// + /// 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) // --------------------------------------------------------------------------- @@ -2147,67 +2094,23 @@ public sealed unsafe class EnvCellRenderer : ("prepare-scratch", _prepareScratch.Dispose), }; - for (int frame = 0; frame < _dynamicBufferSetsByFrame.Length; frame++) - { - List frameSets = _dynamicBufferSetsByFrame[frame]; - for (int index = 0; index < frameSets.Count; index++) - { - DynamicBufferSet set = frameSets[index]; - AddTrackedBufferRelease( - releases, - set.MdiCommandBuffer, - (long)set.MdiCommandCapacity * sizeof(DrawElementsIndirectCommand), - $"dynamic-{frame}-{index}-mdi", - "deleting EnvCell MDI buffer"); - AddTrackedBufferRelease( - releases, - set.ModernInstanceBuffer, - (long)set.ModernInstanceCapacity * sizeof(Matrix4x4), - $"dynamic-{frame}-{index}-instances", - "deleting EnvCell instance SSBO"); - AddTrackedBufferRelease( - releases, - set.ModernBatchBuffer, - (long)set.ModernBatchCapacity * sizeof(ModernBatchData), - $"dynamic-{frame}-{index}-batches", - "deleting EnvCell batch SSBO"); - AddTrackedBufferRelease( - releases, - set.ClipSlotBuffer, - (long)set.ClipSlotCapacity * sizeof(uint), - $"dynamic-{frame}-{index}-clip-slots", - "deleting EnvCell clip-slot SSBO"); - AddTrackedBufferRelease( - releases, - set.GlobalLightsSsbo, - (long)set.GlobalLightsCapacity - * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight - * sizeof(float), - $"dynamic-{frame}-{index}-global-lights", - "deleting EnvCell global-light SSBO"); - AddTrackedBufferRelease( - releases, - set.InstanceLightSetSsbo, - (long)set.InstanceLightSetCapacity - * AcDream.Core.Lighting.LightManager.MaxLightsPerObject - * sizeof(int), - $"dynamic-{frame}-{index}-light-sets", - "deleting EnvCell light-set SSBO"); - } - } - + // 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. AddTrackedBufferRelease( releases, _fallbackClipRegionSsbo, AcDream.App.Rendering.ClipFrame.CellClipStrideBytes, "fallback-clip-region", "deleting EnvCell fallback clip SSBO"); - AddTrackedBufferRelease( - releases, - _textureTableSsbo, - _textureTableSsboCapacityBytes, - "texture-table", - "deleting EnvCell texture-table 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); _disposeResources = new RetryableResourceReleaseLedger(releases); } @@ -2218,19 +2121,13 @@ 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; - _textureTableSsbo = 0; - _textureTableSsboCapacityBytes = 0; + _opaquePipeline = null; + _alphaBlendPipeline = null; + _alphaAdditivePipeline = null; + _textureTableBuffer = null; + _textureTableBufferBytes = 0; _disposeResources = null; IsDisposed = true; @@ -2246,6 +2143,16 @@ 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 4827e12a..e39b1bc6 100644 --- a/src/AcDream.App/Rendering/Wb/GlobalMeshBuffer.cs +++ b/src/AcDream.App/Rendering/Wb/GlobalMeshBuffer.cs @@ -171,6 +171,23 @@ 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 bb82c7e1..a9d71316 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -3,6 +3,7 @@ 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; @@ -86,7 +87,28 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable } private readonly GL _gl; - private readonly Shader _shader; + + // 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 TextureCache _textures; private readonly WbMeshAdapter _meshAdapter; private readonly EntitySpawnAdapter _entitySpawnAdapter; @@ -443,20 +465,19 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable /// public bool AlphaToCoverage { get; set; } = true; - // SSBO buffer ids - private uint _instanceSsbo; - private uint _batchSsbo; - private uint _indirectBuffer; - private int _instanceSsboCapacityBytes; - private int _batchSsboCapacityBytes; - private int _indirectBufferCapacityBytes; + // 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. // 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 @@ -465,10 +486,6 @@ 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 @@ -476,8 +493,6 @@ 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 @@ -485,56 +500,37 @@ 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 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. + // 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. private readonly GlBindlessHandleTable _textureTable = new(); - private uint _textureTableSsbo; - private int _textureTableSsboCapacityBytes; + private IGpuBuffer? _textureTableBuffer; + private int _textureTableBufferBytes; - 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; - internal int DynamicBufferSetCount => - _dynamicBufferSetsByFrame.Sum(frameSets => frameSets.Count); + /// + /// 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; 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). @@ -682,6 +678,19 @@ 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; @@ -788,32 +797,14 @@ 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 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; + // 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. 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 @@ -821,7 +812,8 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable // exclusively from GameWindow (same assembly), so internal is fine. internal WbDrawDispatcher( GL gl, - Shader shader, + IGpuDevice device, + ICurrentGpuFrameSource frameSource, TextureCache textures, WbMeshAdapter meshAdapter, EntitySpawnAdapter entitySpawnAdapter, @@ -833,7 +825,8 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable long? alphaScratchBudgetBytes = null) { ArgumentNullException.ThrowIfNull(gl); - ArgumentNullException.ThrowIfNull(shader); + ArgumentNullException.ThrowIfNull(device); + ArgumentNullException.ThrowIfNull(frameSource); ArgumentNullException.ThrowIfNull(textures); ArgumentNullException.ThrowIfNull(meshAdapter); ArgumentNullException.ThrowIfNull(entitySpawnAdapter); @@ -841,7 +834,18 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable ArgumentNullException.ThrowIfNull(translucencyFades); _gl = gl; - _shader = shader; + _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); _textures = textures; _meshAdapter = meshAdapter; _entitySpawnAdapter = entitySpawnAdapter; @@ -859,6 +863,116 @@ 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 @@ -867,7 +981,7 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable /// public void BeginFrame(int frameSlot) { - if ((uint)frameSlot >= (uint)_dynamicBufferSetsByFrame.Length) + if ((uint)frameSlot >= (uint)FrameSlotCount) throw new ArgumentOutOfRangeException(nameof(frameSlot)); if (_groupFrame == long.MaxValue) throw new InvalidOperationException("Instance-group frame identity was exhausted."); @@ -879,13 +993,24 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable _groups, _retiredGroupKeys, _groupFrame - 1); - _dynamicFrameSlot = frameSlot; - _dynamicBufferSetCursor = 0; + // 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. _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; @@ -2059,30 +2184,22 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable out Matrix4x4 viewProjection, out Vector3 cameraWorldPosition) { - _shader.Use(); + // 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. _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; @@ -2260,196 +2377,175 @@ 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); - // 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) + // ── 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)) { - // #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]) + var pushConstants = new GpuPushConstants { - _gl.GetQueryObject(_gpuQueryOpaque[gpuQuerySlot], QueryObjectParameterName.ResultAvailable, out int availO); - if (availO != 0) + 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.Result, out opaqueNs); - anyRead = true; + DrawIndirectRange( + encoder, ref pushConstants, commandBuffer, commandBase, + 0, _opaqueDrawCount); } - else allAvailable = false; } - if (_gpuQueryTransparentBegun[gpuQuerySlot]) + + // ── Phase 8: transparent pass ──────────────────────────────────── + if (_transparentDrawCount > 0) { - _gl.GetQueryObject(_gpuQueryTransparent[gpuQuerySlot], QueryObjectParameterName.ResultAvailable, out int availT); - if (availT != 0) + // 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.Result, out transNs); - anyRead = true; + DrawIndirectRange( + encoder, ref pushConstants, commandBuffer, commandBase, + _opaqueDrawCount, _transparentDrawCount); } - 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; } } - // ── 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); - } - + // 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); _gl.Disable(EnableCap.CullFace); _gl.BindVertexArray(0); + SampleGpuTimers(diag); + _cpuStopwatch.Stop(); if (diag) @@ -2458,11 +2554,6 @@ 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(); @@ -2999,12 +3090,49 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable _deferredAlphaKinds[i] = key.Translucency; } - // 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(); + // 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); } private void DrawPreparedAlphaBatch(int firstPreparedDraw, int drawCount) @@ -3019,53 +3147,83 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable if (global is null || global.VAO == 0) return; - _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); + // Binding=2 stays a raw global bind, outside the pass — see the note + // at the immediate path's call site. 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); - int runStart = firstPreparedDraw; - int preparedEnd = firstPreparedDraw + drawCount; - while (runStart < preparedEnd) + IGpuFrame frame = RequireFrame(); + using (IGpuPassEncoder encoder = frame.BeginPass(WorldPass)) { - TranslucencyKind blend = _deferredAlphaKinds[runStart]; - int runEnd = runStart + 1; - while (runEnd < preparedEnd && _deferredAlphaKinds[runEnd] == blend) - runEnd++; + 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, + }; - ApplyRetailBlend(blend); - DrawIndirectRange(runStart, runEnd - runStart); - runStart = 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; + } } + // 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); @@ -3149,58 +3307,23 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable _deferredAlpha.Capacity = targetCapacity; } - private void UploadDeferredAlphaBuffers(int count) + /// + /// 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 { - 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, - }); - } + TranslucencyKind.Additive => _alphaAdditivePipeline, + TranslucencyKind.InvAlpha => _alphaInversePipeline, + _ => _alphaBlendPipeline, + }; private static int CompareOpaqueSubmissionOrder(InstanceGroup a, InstanceGroup b) { @@ -3231,267 +3354,253 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable return runs; } - private unsafe void DrawIndirectRange(int startCommand, int commandCount) + private void DrawIndirectRange( + IGpuPassEncoder encoder, + ref GpuPushConstants pushConstants, + IGpuBuffer commandBuffer, + uint commandBaseOffsetBytes, + int startCommand, + int commandCount) { int end = startCommand + commandCount; int command = startCommand; while (command < end) { var cullMode = _drawCullModes[command]; - ApplyCullMode(cullMode); + ApplyCullMode(encoder, cullMode); int runCount = 1; while (command + runCount < end && _drawCullModes[command + runCount] == cullMode) runCount++; - // 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 + // 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 // index for this run or it will read BatchData[0] again and bind - // the wrong texture for later runs. - _shader.SetInt("uDrawIDOffset", command); - _gl.MultiDrawElementsIndirect( - PrimitiveType.Triangles, - DrawElementsType.UnsignedShort, - (void*)(command * DrawCommandStride), + // 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), (uint)runCount, - (uint)DrawCommandStride); + DrawCommandStride); command += runCount; } } - private void ApplyCullMode(CullMode mode) + /// + /// 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) { // 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. - _gl.FrontFace(FrontFaceDirection.CW); + // Both are dynamic state in core Vulkan 1.3, so they stay per-run + // calls rather than pipeline variants. + encoder.SetFrontFace(GpuFrontFace.Clockwise); switch (mode) { case CullMode.None: - _gl.Disable(EnableCap.CullFace); + encoder.SetCullMode(GpuCullMode.None); break; case CullMode.Clockwise: - _gl.Enable(EnableCap.CullFace); - _gl.CullFace(TriangleFace.Front); + encoder.SetCullMode(GpuCullMode.Front); break; case CullMode.CounterClockwise: case CullMode.Landblock: - _gl.Enable(EnableCap.CullFace); - _gl.CullFace(TriangleFace.Back); + encoder.SetCullMode(GpuCullMode.Back); break; } } - private void ActivateNextDynamicBufferSet() + /// + /// 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) { - 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; + encoder.BindPipeline(pipeline); + BindWorldMesh(encoder); } - private DynamicBufferSet CreateDynamicBufferSet() + private void BindWorldMesh(IGpuPassEncoder encoder) { - 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); + 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); } /// /// Fix B: pack into the binding=4 global light - /// 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). + /// 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). /// - private unsafe void UploadGlobalLights() + private void BindGlobalLights(IGpuPassEncoder encoder, IGpuFrame frame) { - int n = GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData); - int count = n > 0 ? n : 1; // never zero-size + int packed = GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData); + int count = packed > 0 ? packed : 1; // never zero-size // Pack guarantees _globalLightData holds at least max(n,1) * FloatsPerLight floats. - fixed (float* gp = _globalLightData) - UploadSsbo(_globalLightsSsbo, 4, ref _globalLightsSsboCapacityBytes, gp, - count * GlobalLightPacker.FloatsPerLight * sizeof(float)); + BindRingSection( + encoder, + frame, + GpuBindingModel.StorageGlobalLights, + _globalLightData.AsSpan(0, count * GlobalLightPacker.FloatsPerLight)); } /// - /// 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. + /// 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. /// - private unsafe void FlushAndBindTextureTable() + private void BindTextureTable(IGpuPassEncoder encoder) { - if (_textureTableSsbo == 0) - _textureTableSsbo = TrackedGlResource.CreateBuffer(_gl, "creating WB texture-table SSBO"); + ReadOnlySpan handles = _textureTable.Handles; + int byteCount = Math.Max(handles.Length * sizeof(ulong), sizeof(ulong)); - if (_textureTable.Dirty) + bool reallocated = false; + if (_textureTableBuffer is null || _textureTableBufferBytes < byteCount) { - ReadOnlySpan handles = _textureTable.Handles; - int byteCount = handles.Length * sizeof(ulong); - fixed (ulong* p = handles) - { - UploadDynamicBuffer( - BufferTargetARB.ShaderStorageBuffer, - _textureTableSsbo, - ref _textureTableSsboCapacityBytes, - p, - byteCount); - } + _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)); _textureTable.MarkFlushed(); } - _gl.BindBufferBase( - BufferTargetARB.ShaderStorageBuffer, - AcDream.App.Rendering.Gpu.GpuBindingModel.StorageTextureTable, - _textureTableSsbo); + + encoder.BindStorageBuffer( + GpuBindingModel.StorageTextureTable, + _textureTableBuffer, + 0, + (uint)byteCount); } + /// + /// 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 ); @@ -4142,13 +4251,10 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable private void BuildDisposeReleases(List<(string Name, Action Release)> releases) { - 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); - } - + // 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. AddRawGlRelease( releases, _fallbackClipRegionSsbo, @@ -4156,60 +4262,26 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable "deleting entity fallback clip SSBO", _gl.DeleteBuffer); - 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); - } + // 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); } - private void AddDynamicBufferSetReleases( + private static void AddResourceRelease( List<(string Name, Action Release)> releases, - DynamicBufferSet set, - int frame, - int index) + string name, + IDisposable? resource) { - 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"); + if (resource is null) + return; + releases.Add((name, resource.Dispose)); } private void AddTrackedBufferRelease( @@ -4251,25 +4323,19 @@ 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; - _textureTableSsbo = 0; - _textureTableSsboCapacityBytes = 0; - Array.Clear(_gpuQueryOpaque); - Array.Clear(_gpuQueryTransparent); - _gpuQueriesInitialized = false; + _textureTableBuffer = null; + _textureTableBufferBytes = 0; + _deferredAlphaInstances = default; + _deferredAlphaBatches = default; + _deferredAlphaClipSlots = default; + _deferredAlphaGlobalLights = default; + _deferredAlphaLightSets = default; + _deferredAlphaIndoor = default; + _deferredAlphaOpacity = default; + _deferredAlphaSelectionLighting = default; + _deferredAlphaCommands = default; } // ── 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 007cb531..a940cfa6 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!, meshManager: null!, frustum: new WbFrustum()); + var r = new EnvCellRenderer(gl: null!, device: null!, frameSource: null!, meshManager: null!, frustum: new WbFrustum()); Assert.True(r.NeedsPrepare); } [Fact] public void NewRenderer_NotDisposed() { - var r = new EnvCellRenderer(gl: null!, meshManager: null!, frustum: new WbFrustum()); + var r = new EnvCellRenderer(gl: null!, device: null!, frameSource: 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!, meshManager: null!, frustum: new WbFrustum()); + var r = new EnvCellRenderer(gl: null!, device: null!, frameSource: 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!, meshManager: null!, frustum: new WbFrustum()); + var r = new EnvCellRenderer(gl: null!, device: null!, frameSource: 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!, meshManager: null!, frustum: new WbFrustum()); + var r = new EnvCellRenderer(gl: null!, device: null!, frameSource: 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!, meshManager: null!, frustum: new WbFrustum()); + var r = new EnvCellRenderer(gl: null!, device: null!, frameSource: null!, meshManager: null!, frustum: new WbFrustum()); Assert.Equal(0, r.SnapshotGeneration); } }