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