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);
}
}