From f353fb53f8a6bbd35cf3d5af1c9610aea23b0802 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 27 Jul 2026 20:55:25 +0200 Subject: [PATCH] feat(render): Campaign V slice V4c - move the world draw path onto the RHI The two renderers that draw everything in the world - WbDrawDispatcher for entities and EnvCellRenderer for dungeon shells - now record through IGpuPassEncoder instead of calling GL directly. They share mesh_modern and its binding layout, which is why they had to move together. What moved. Every per-frame upload became an IGpuFrame.AllocateRing slice: instance transforms, batch metadata, clip slots, global lights, per-instance light sets, indoor flags, opacity, selection lighting, and the indirect command array. That retires both renderers' DynamicBufferSet pools outright. Those pools existed so a second Draw within one frame could not overwrite an earlier draw's still-pending data; the frame ring gives that structurally, because every allocation within a frame is distinct memory that lives until the frame retires. DynamicBufferSetCount now reports 0 for both, which is the truth rather than a silent change - they own no such pool any more. The imperative Enable/Disable/BlendFunc/DepthMask brackets around the two multi-draw passes became pipeline variants: five for the dispatcher (opaque, opaque+alpha-to-coverage, and the three retail blends) and three for the cell shells. Cull mode and front face stay dynamic per MDI run, exactly where ApplyCullMode and SetCullMode set them, because core Vulkan 1.3 makes those dynamic and blend and alpha-to-coverage not. ApplyRetailBlend is gone: its three cases are now three pipelines, including the inverse-alpha one that GpuBlendMode.InverseAlpha was added for. uViewProjection, uDrawIDOffset, uLightingMode, uRenderPass and uLightDebug became fields of the shared GpuPushConstants block. Issue #52's per-pass batch offset is unchanged - the draw index still resets per indirect call, and Vulkan's gl_DrawID resets identically. Depth compare is baked as GL_LESS, not the contract's LessOrEqual default. The world frame runs under GL_LESS (RenderFrameGlStateController.RestoreFrameDefaults) and neither renderer ever called glDepthFunc, so both inherited it; baking LessOrEqual would have changed which of two coplanar retail surfaces wins. Two uniform writes were dropped rather than ported, and both are no-ops today: uFilterByCell and uHighlightColor are declared in neither mesh_modern stage, so they resolved to location -1. Saying so here rather than letting them vanish. GPU timing moved to IGpuPassEncoder.BeginTimerScope. The [WB-DIAG] median/p95 window is still fed and still measures opaque + transparent time for the dispatch, but the sample now comes from IGpuTimerPool.TryResolve - the most recent retired result - instead of a hand-rolled 3-deep query ring read at N-3. A sample can therefore repeat when the GPU has not finished a newer query, where the old code dropped it. The pool also owns the #125 "never read a query that was never begun" guard now. Diagnostic-only, and flagged rather than left to be discovered. Three things deliberately did NOT move, per the campaign doc's section 5.3. The interim GlBindlessHandleTable stays; both renderers still intern raw bindless handles and now bind that table through the encoder as an ordinary IGpuBuffer at binding 9. Retiring it is slice V4t, because the handles are produced by the texture caches and carried through GroupKey and CachedBatch. ClipFrame's region buffer (binding 2) and the SceneLighting UBO stay globally bound by raw GL, because terrain and the viewport/portal renderers read the same bindings and are raw GL until V4d/V4g. EnvCellRenderer's glMemoryBarrier stays a raw call: it has no RHI verb, and it guards incoherent shader writes that acdream does not make, so it was already a no-op against client-side uploads. RetailAlphaQueue, the GroupKey bucketing, the front-to-back and translucent sort orders, and every other piece of CPU fidelity logic are untouched. The deferred alpha payload is still prepared exactly once per sorted alpha scope: a ring allocation cannot outlive its frame as a ref struct, but its buffer, offset and size can be stored, so DrawPreparedAlphaBatch binds the same bytes many times without recopying them. Two supporting changes outside the two renderers, both flagged. GlGpuDevice.BeginPass no longer binds framebuffer 0 for a null colour target; it leaves the binding alone and only binds an explicitly named target. A null target means "whatever the spine bound", which is what GpuPassDescription's own remarks describe when they say clears and framebuffer management stay with the spine until V4h. Forcing 0 would have been fatal here and invisible to this gate: PrivateEntityViewportRenderer binds its offscreen FBO and then calls WbDrawDispatcher.Draw, as does PortalTunnelPresentation, so the paperdoll and creature-appraisal viewports would have rendered to the backbuffer and left their textures empty - and the offline gate does not cover those viewports. This is the same class of fix as the ambient-capability save/restore in GlGpuPassEncoder. GlGpuDevice.CreatePipeline now splices the slice-V2 shared preamble (Shaders/common.glsl) into every pipeline, reusing Shader.InjectPreamble - widened from private to internal - 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 table and defines ACDREAM_TEXTURE_HANDLE, without which the world shaders do not compile. Shaders that reference none of it gain an unused SSBO declaration and two macros; every shader in the tree is #version 430 core, so that is always legal. Both renderers keep their trailing raw-GL disable block after the pass closes. The encoder's Dispose restores the capability state that was ambient on ENTRY, which is not the state these renderers used to leave behind - terrain, sky and particles are still raw GL and still inherit what the previous renderer left, so the exit state is reasserted explicitly. It goes at V4h with the last raw-GL renderer. A defect caught in review and fixed before the gate: each IGpuPipeline owns its own vertex array, and vertex attribute pointers plus the index binding are vertex-array state, so switching blend variants mid-pass silently dropped the mesh source while the storage bindings survived. Every pipeline switch now goes through one helper that re-binds the arena. Gates. Release build green with TreatWarningsAsErrors. App tests 3,844 passed / 3 skipped, stable over four consecutive runs, against a 3,843 baseline plus the InverseAlpha contract test. Offline pixel gate against 111e7236: 20 differing pixels of 563,200 compared (fraction 3.55e-05), against a same-commit control captured immediately afterwards of 17 - indistinguishable from capture noise and 28x under the 0.001 threshold. The gate run's client log has zero exceptions and an empty stderr. Coverage gap, stated rather than assumed: the offline gate's scene is a fixed outdoor view, so it exercises WbDrawDispatcher heavily and EnvCellRenderer not at all. Dungeon interiors, the paperdoll and appraisal viewports, and portal transit need a user visual check before this slice is considered proven. No divergence-register row: this slice changes no retail-facing behaviour. Co-Authored-By: Claude Fable 5 --- .../LivePresentationComposition.cs | 7 +- .../Rendering/Gpu/Gl/GlGpuDevice.cs | 47 +- src/AcDream.App/Rendering/RenderBootstrap.cs | 2 +- src/AcDream.App/Rendering/Shader.cs | 11 +- .../Rendering/Wb/EnvCellRenderer.cs | 933 +++++------- .../Rendering/Wb/GlobalMeshBuffer.cs | 17 + .../Rendering/Wb/WbDrawDispatcher.cs | 1350 +++++++++-------- .../Rendering/Wb/EnvCellRendererTests.cs | 12 +- 8 files changed, 1211 insertions(+), 1168 deletions(-) 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); } }