diff --git a/src/AcDream.App/Composition/WorldRenderComposition.cs b/src/AcDream.App/Composition/WorldRenderComposition.cs
index 21ad200d..d034c1af 100644
--- a/src/AcDream.App/Composition/WorldRenderComposition.cs
+++ b/src/AcDream.App/Composition/WorldRenderComposition.cs
@@ -274,7 +274,8 @@ internal sealed class RetailWorldRenderCompositionFactory
new(
gl,
Path.Combine(shadersDirectory, "mesh_modern.vert"),
- Path.Combine(shadersDirectory, "mesh_modern.frag"));
+ Path.Combine(shadersDirectory, "mesh_modern.frag"),
+ includeCommonPreamble: true);
public WbMeshAdapter CreateMeshAdapter(
GL gl,
diff --git a/src/AcDream.App/Rendering/GlBindlessHandleTable.cs b/src/AcDream.App/Rendering/GlBindlessHandleTable.cs
new file mode 100644
index 00000000..330541c8
--- /dev/null
+++ b/src/AcDream.App/Rendering/GlBindlessHandleTable.cs
@@ -0,0 +1,62 @@
+namespace AcDream.App.Rendering;
+
+///
+/// Campaign V slice V2 (docs/plans/2026-07-27-vulkan-campaign.md §3.4, §5.2):
+/// pure bookkeeping that assigns a stable small-integer "slot" to each distinct
+/// GL_ARB_bindless_texture handle a renderer submits. A batch/pass no longer
+/// carries the 64-bit handle directly into its GPU-visible struct; it carries
+/// this slot index instead, and the shader looks the handle back up from a
+/// binding=9 storage buffer (GpuBindingModel.StorageTextureTable) — the
+/// GL-only emulation of Vulkan's global sampled-texture descriptor array
+/// (GpuBindingModel.TextureTableSet). That indirection is what makes
+/// the CPU-side batch data backend-neutral.
+///
+/// Owns no GL resource. The owning renderer uploads to
+/// its own storage buffer whenever is set — mirroring how
+/// it already uploads its other per-frame/per-registration SSBOs — and clears
+/// the flag with . Never releases a slot: entries
+/// accumulate for the renderer's lifetime, exactly like the world atlas and
+/// composite-texture caches it draws handles from.
+///
+/// This class, plus the per-renderer SSBO it backs, is deleted at
+/// V4c/V4d/V4e when each renderer moves onto IGpuDevice's own
+/// retirement-gated table — see the campaign doc's §5.2 for why V2 cannot
+/// reach that table yet.
+///
+/// Each renderer that needs the indirection (WbDrawDispatcher,
+/// EnvCellRenderer, TerrainModernRenderer, ParticleRenderer) owns its own
+/// instance. Nothing requires index agreement between renderers: each rebinds
+/// its own buffer to binding=9 immediately before its own draw call, so two
+/// renderers may legitimately assign different slots to the same handle.
+///
+internal sealed class GlBindlessHandleTable
+{
+ private readonly Dictionary _slotByHandle = new();
+ private ulong[] _handles = new ulong[64];
+ private int _count;
+
+ /// True after a call registers a handle not seen before.
+ public bool Dirty { get; private set; }
+
+ /// Live prefix of assigned handles, in slot order (array index == slot).
+ public ReadOnlySpan Handles => _handles.AsSpan(0, _count);
+
+ /// Returns the stable slot for a handle, registering it on first use.
+ public uint GetOrAdd(ulong bindlessHandle)
+ {
+ if (_slotByHandle.TryGetValue(bindlessHandle, out uint slot))
+ return slot;
+
+ if (_count == _handles.Length)
+ Array.Resize(ref _handles, _handles.Length * 2);
+ slot = (uint)_count;
+ _handles[_count] = bindlessHandle;
+ _count++;
+ _slotByHandle.Add(bindlessHandle, slot);
+ Dirty = true;
+ return slot;
+ }
+
+ /// Call once the owning renderer has uploaded to its SSBO.
+ public void MarkFlushed() => Dirty = false;
+}
diff --git a/src/AcDream.App/Rendering/Shader.cs b/src/AcDream.App/Rendering/Shader.cs
index 278a768b..b74960ae 100644
--- a/src/AcDream.App/Rendering/Shader.cs
+++ b/src/AcDream.App/Rendering/Shader.cs
@@ -10,16 +10,72 @@ public sealed class Shader : IDisposable
public uint Program { get; private set; }
public Shader(GL gl, string vertexPath, string fragmentPath)
+ : this(gl, vertexPath, fragmentPath, includeCommonPreamble: false)
+ {
+ }
+
+ ///
+ /// Campaign V slice V2 (docs/plans/2026-07-27-vulkan-campaign.md §3.4): when
+ /// is true, the text of
+ /// Shaders/common.glsl — sitting alongside
+ /// — is spliced into both sources right after their leading
+ /// #version/#extension block. GL has no #include, so this
+ /// is plain string concatenation at load time rather than a GLSL-level
+ /// mechanism. Every existing two-argument-path caller is unaffected: the
+ /// convenience constructor above always passes false.
+ ///
+ public Shader(GL gl, string vertexPath, string fragmentPath, bool includeCommonPreamble)
{
_gl = gl;
string vertexSource = File.ReadAllText(vertexPath);
string fragmentSource = File.ReadAllText(fragmentPath);
+ if (includeCommonPreamble)
+ {
+ string? directory = Path.GetDirectoryName(vertexPath);
+ string commonPath = directory is null
+ ? "common.glsl"
+ : Path.Combine(directory, "common.glsl");
+ string commonSource = File.ReadAllText(commonPath);
+ vertexSource = InjectPreamble(vertexSource, commonSource);
+ fragmentSource = InjectPreamble(fragmentSource, commonSource);
+ }
Program = ShaderProgramConstruction.Build(
new GlShaderProgramBuildApi(gl),
vertexSource,
fragmentSource);
}
+ ///
+ /// Inserts right after the shader's leading
+ /// #version/#extension/blank-line block. GLSL requires
+ /// #version to be the very first statement in the source, so the
+ /// preamble cannot simply be prepended — it has to land after that block,
+ /// before the first real declaration.
+ ///
+ private static string InjectPreamble(string source, string preamble)
+ {
+ int insertAt = 0;
+ int lineStart = 0;
+ while (lineStart < source.Length)
+ {
+ int lineEnd = source.IndexOf('\n', lineStart);
+ if (lineEnd < 0)
+ lineEnd = source.Length;
+ string trimmed = source[lineStart..lineEnd].TrimStart();
+ bool isLeadingLine =
+ trimmed.Length == 0
+ || trimmed.StartsWith("#version", StringComparison.Ordinal)
+ || trimmed.StartsWith("#extension", StringComparison.Ordinal);
+ if (!isLeadingLine)
+ break;
+
+ insertAt = lineEnd < source.Length ? lineEnd + 1 : lineEnd;
+ lineStart = lineEnd + 1;
+ }
+
+ return string.Concat(source.AsSpan(0, insertAt), preamble, "\n", source.AsSpan(insertAt));
+ }
+
public void Use() => _gl.UseProgram(Program);
public unsafe void SetMatrix4(string name, Matrix4x4 m)
diff --git a/src/AcDream.App/Rendering/Shaders/common.glsl b/src/AcDream.App/Rendering/Shaders/common.glsl
new file mode 100644
index 00000000..f0234359
--- /dev/null
+++ b/src/AcDream.App/Rendering/Shaders/common.glsl
@@ -0,0 +1,41 @@
+// Campaign V slice V2 shared preamble (docs/plans/2026-07-27-vulkan-campaign.md
+// §3.4, §5.2). GL has no #include, so AcDream.App.Rendering.Shader
+// concatenates this file's text into every shader source that opts in
+// (Shader(gl, vertPath, fragPath, includeCommonPreamble: true)), inserted
+// right after the leading #version / #extension block so it can declare new
+// layout bindings and macros before the rest of the shader body runs.
+//
+// --- set 1 (uniform buffers) ------------------------------------------------
+// GL keeps the SSBO and UBO binding-number namespaces separate, so today's
+// SceneLighting UBO (binding=1) never collides with BatchBuffer's SSBO
+// (also binding=1). Vulkan has ONE binding namespace per set, so the Vulkan
+// backend (from V6 on) moves every uniform buffer to its own set (1) to keep
+// both binding numbers. ACDREAM_UBO_SET is a no-op under GL today and is
+// redefined to `set = 1,` when the same source is compiled for Vulkan, so
+// applying it to every UBO layout now costs nothing and needs no source edit
+// at the call sites later.
+#define ACDREAM_UBO_SET
+
+// --- set 0 binding 9 / set 2 (the global texture table) --------------------
+// GL emulates Vulkan's set 2 variable-count sampled-texture descriptor array
+// (GpuBindingModel.TextureTableSet) with a plain storage buffer of packed
+// GL_ARB_bindless_texture handles at set 0 binding 9
+// (GpuBindingModel.StorageTextureTable). A batch/pass no longer carries a
+// 64-bit bindless handle directly into its GPU-visible struct; it carries a
+// small integer slot index into this table instead, which is what makes the
+// CPU-side data model backend-neutral (Campaign V slice V2). The table itself
+// — and every per-renderer GL-side handle-slot allocator that fills it
+// (WbDrawDispatcher, EnvCellRenderer, TerrainModernRenderer, ParticleRenderer)
+// — is deleted once each renderer moves onto IGpuDevice's own retirement-gated
+// table at V4c/V4d/V4e; see the campaign doc's §5.2 for why V2 cannot reach
+// that table yet.
+layout(std430, binding = 9) readonly buffer TextureTableBuf {
+ uvec2 gTextureTable[];
+};
+
+// Looks up the packed bindless handle for table slot `idx`. Callers still
+// wrap the result in `sampler2DArray(...)` themselves at the use site (kept
+// explicit rather than folded into one sampler-returning macro) because every
+// existing call site already follows that exact pattern and a function cannot
+// return an opaque sampler type built from a runtime value in GLSL.
+#define ACDREAM_TEXTURE_HANDLE(idx) gTextureTable[idx]
diff --git a/src/AcDream.App/Rendering/Shaders/mesh_modern.frag b/src/AcDream.App/Rendering/Shaders/mesh_modern.frag
index eb737155..bd930397 100644
--- a/src/AcDream.App/Rendering/Shaders/mesh_modern.frag
+++ b/src/AcDream.App/Rendering/Shaders/mesh_modern.frag
@@ -27,7 +27,7 @@ struct Light {
vec4 colorAndIntensity;
vec4 coneAngleEtc;
};
-layout(std140, binding = 1) uniform SceneLighting {
+layout(std140, ACDREAM_UBO_SET binding = 1) uniform SceneLighting {
Light uLights[8];
vec4 uCellAmbient;
vec4 uFogParams;
diff --git a/src/AcDream.App/Rendering/Shaders/mesh_modern.vert b/src/AcDream.App/Rendering/Shaders/mesh_modern.vert
index 3281e19c..58d81021 100644
--- a/src/AcDream.App/Rendering/Shaders/mesh_modern.vert
+++ b/src/AcDream.App/Rendering/Shaders/mesh_modern.vert
@@ -9,8 +9,18 @@ struct InstanceData {
mat4 transform;
};
+// Campaign V slice V2 (2026-07-27): textureHandle (uvec2, a 64-bit
+// GL_ARB_bindless_texture handle) became textureIndex (uint) plus an explicit
+// pad word. textureIndex is a slot into the binding=9 handle table
+// (ACDREAM_TEXTURE_HANDLE, common.glsl) that main() below looks up once per
+// vertex to reconstruct the exact same uvec2 handle main() used to receive
+// directly — one indirection, identical value. The pad word keeps
+// textureLayer/flags at their original std430 offsets (8/12), so the struct
+// is still 16 bytes and every existing CPU writer's layout is unchanged
+// (GpuBindingModel.GpuBatchDataStrideBytes).
struct BatchData {
- uvec2 textureHandle; // bindless handle for sampler2DArray
+ uint textureIndex; // slot into the binding=9 handle table
+ uint _pad; // keeps textureLayer/flags at offsets 8/12
uint textureLayer; // layer in the shared WB or pooled composite array
uint flags; // reserved — N.5 dispatcher owns all blend state
// (glBlendFunc per pass). If a future phase wants
@@ -168,7 +178,7 @@ struct Light {
vec4 colorAndIntensity;
vec4 coneAngleEtc;
};
-layout(std140, binding = 1) uniform SceneLighting {
+layout(std140, ACDREAM_UBO_SET binding = 1) uniform SceneLighting {
Light uLights[8];
vec4 uCellAmbient;
vec4 uFogParams;
@@ -313,6 +323,9 @@ void main() {
vTexCoord = aTexCoord;
BatchData b = Batches[uDrawIDOffset + gl_DrawIDARB];
- vTextureHandle = b.textureHandle;
+ // Campaign V slice V2: reconstruct the SAME uvec2 handle the shader used
+ // to receive directly from BatchData, now via one binding=9 table lookup.
+ // vTextureHandle's type and every downstream frag-shader use are unchanged.
+ vTextureHandle = ACDREAM_TEXTURE_HANDLE(b.textureIndex);
vTextureLayer = b.textureLayer;
}
diff --git a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs
index d7b8acc4..4c4054a1 100644
--- a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs
+++ b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs
@@ -153,6 +153,19 @@ public sealed unsafe class EnvCellRenderer :
private uint _sharedClipRegionSsbo;
private uint _fallbackClipRegionSsbo;
+ // Campaign V slice V2 (2026-07-27): GL-only emulation of the eventual
+ // Vulkan global texture descriptor array (binding=9,
+ // GpuBindingModel.StorageTextureTable). Owns its own table rather than
+ // sharing WbDrawDispatcher's — EnvCellRenderer never had a TextureCache
+ // dependency and nothing requires index agreement between renderers (each
+ // rebinds its own buffer to binding=9 immediately before its own draw
+ // 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).
+ private readonly GlBindlessHandleTable _textureTable = new();
+ private uint _textureTableSsbo;
+ private int _textureTableSsboCapacityBytes;
+
// Reusable scratch arrays — avoid per-frame allocation.
// WB BaseObjectRenderManager.cs:58-59: private DrawElementsIndirectCommand[] _commands = Array.Empty<...>()
private DrawElementsIndirectCommand[] _commands = Array.Empty();
@@ -1611,8 +1624,11 @@ public sealed unsafe class EnvCellRenderer :
{
_modernBatches[cmdIndex] = new ModernBatchData
{
- TextureHandle = item.batch.BindlessTextureHandle,
- TextureIndex = (uint)item.batch.TextureIndex,
+ // Campaign V slice V2: table slot, not the raw handle.
+ // See _textureTable's doc comment for why EnvCellRenderer
+ // owns its own table rather than sharing WbDrawDispatcher's.
+ TextureTableIndex = _textureTable.GetOrAdd(item.batch.BindlessTextureHandle),
+ TextureIndex = (uint)item.batch.TextureIndex,
};
_commands[cmdIndex] = new DrawElementsIndirectCommand
@@ -1766,6 +1782,7 @@ public sealed unsafe class EnvCellRenderer :
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);
@@ -1970,6 +1987,53 @@ public sealed unsafe class EnvCellRenderer :
}
}
+ // ---------------------------------------------------------------------------
+ // FlushAndBindTextureTable (Campaign V slice V2)
+ // ---------------------------------------------------------------------------
+
+ ///
+ /// 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 FlushAndBindTextureTable()
+ {
+ if (_textureTableSsbo == 0)
+ _textureTableSsbo = TrackedGlResource.CreateBuffer(_gl, "creating EnvCell texture-table SSBO");
+
+ if (_textureTable.Dirty)
+ {
+ 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();
+ }
+ _gl.BindBufferBase(
+ GLEnum.ShaderStorageBuffer,
+ AcDream.App.Rendering.Gpu.GpuBindingModel.StorageTextureTable,
+ _textureTableSsbo);
+ }
+
// ---------------------------------------------------------------------------
// BindClipRegionBinding2 (Phase U.3)
// ---------------------------------------------------------------------------
@@ -2138,6 +2202,12 @@ public sealed unsafe class EnvCellRenderer :
AcDream.App.Rendering.ClipFrame.CellClipStrideBytes,
"fallback-clip-region",
"deleting EnvCell fallback clip SSBO");
+ AddTrackedBufferRelease(
+ releases,
+ _textureTableSsbo,
+ _textureTableSsboCapacityBytes,
+ "texture-table",
+ "deleting EnvCell texture-table SSBO");
_disposeResources = new RetryableResourceReleaseLedger(releases);
}
@@ -2159,6 +2229,8 @@ public sealed unsafe class EnvCellRenderer :
_globalLightsSsbo = 0;
_instLightSetSsbo = 0;
_fallbackClipRegionSsbo = 0;
+ _textureTableSsbo = 0;
+ _textureTableSsboCapacityBytes = 0;
_disposeResources = null;
IsDisposed = true;
diff --git a/src/AcDream.App/Rendering/Wb/ModernRenderData.cs b/src/AcDream.App/Rendering/Wb/ModernRenderData.cs
index 927631df..a3b497eb 100644
--- a/src/AcDream.App/Rendering/Wb/ModernRenderData.cs
+++ b/src/AcDream.App/Rendering/Wb/ModernRenderData.cs
@@ -5,14 +5,18 @@ using Chorizite.Core.Render;
namespace AcDream.App.Rendering.Wb {
///
/// Per-batch (draw call) data for modern rendering.
- /// Consists of a bindless texture handle to a texture array and the layer index.
- /// Indexed by gl_DrawIDARB in the vertex shader.
+ /// Consists of a texture-table slot (Campaign V slice V2; was a raw 64-bit
+ /// bindless handle) and the layer index within the shared/pooled array.
+ /// Indexed by gl_DrawIDARB in the vertex shader. Same 16-byte std430 shape
+ /// as mesh_modern.vert's BatchData: TextureTableIndex/Reserved/TextureIndex/
+ /// Flags at offsets 0/4/8/12 — see ModernBatchDataLayoutTests.
///
- [StructLayout(LayoutKind.Sequential, Pack = 8)]
+ [StructLayout(LayoutKind.Sequential, Pack = 4)]
public struct ModernBatchData {
- public ulong TextureHandle; // 8 bytes
- public uint TextureIndex; // 4 bytes
- public uint Padding; // 4 bytes
+ public uint TextureTableIndex; // 4 bytes — slot into the binding=9 handle table
+ public uint Reserved; // 4 bytes — pad, keeps TextureIndex/Flags at offsets 8/12
+ public uint TextureIndex; // 4 bytes — layer within the texture array
+ public uint Flags; // 4 bytes — reserved, matches mesh_modern.vert's BatchData.flags
}
public struct LandblockMdiCommand {
diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
index b6d50e74..bb82c7e1 100644
--- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
+++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
@@ -493,6 +493,17 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable
private uint _instSelectionLightingSsbo;
private int _instSelectionLightingSsboCapacityBytes;
+ // Campaign V slice V2 (2026-07-27): GL-only emulation of the eventual
+ // Vulkan global texture descriptor array (binding=9,
+ // GpuBindingModel.StorageTextureTable). A genuinely new handle is rare —
+ // new dat surfaces/atlases, not every frame — so this single buffer is
+ // NOT part of the ring-buffered DynamicBufferSet below; see
+ // GlBindlessHandleTable's doc comment and the campaign doc's §5.2 for why
+ // this table is owned here rather than by GlGpuDevice. Lazily created.
+ private readonly GlBindlessHandleTable _textureTable = new();
+ private uint _textureTableSsbo;
+ private int _textureTableSsboCapacityBytes;
+
private sealed class DynamicBufferSet
{
public uint InstanceSsbo;
@@ -587,18 +598,22 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable
private int _transparentDrawCount;
private int _transparentByteOffset;
- // std430 layout: ulong TextureHandle (uvec2) at offset 0, uint TextureLayer
- // at offset 8, uint Flags at offset 12. Total 16 bytes.
- // Pack=8 (not 4) because std430's uvec2 requires 8-byte alignment — Pack=4
- // works today by accident (TextureHandle is the first field, so offset 0 is
- // always 8-byte aligned), but adding a 4-byte field before TextureHandle
- // without bumping Pack would silently misalign the GPU struct.
- [StructLayout(LayoutKind.Sequential, Pack = 8)]
+ // Campaign V slice V2 (2026-07-27): std430 layout: uint TextureIndex at
+ // offset 0, uint Reserved (pad) at offset 4, uint TextureLayer at offset 8,
+ // uint Flags at offset 12. Total 16 bytes — unchanged from before V2, so
+ // every existing CPU writer's offsets are unchanged (see
+ // GpuBindingModel.GpuBatchDataStrideBytes). TextureIndex used to be a
+ // 64-bit ulong TextureHandle (an ARB_bindless_texture handle, uvec2 in
+ // GLSL); it is now a slot into the binding=9 handle table
+ // (mesh_modern.vert's BatchData.textureIndex / ACDREAM_TEXTURE_HANDLE),
+ // which is why the struct only needs 4-byte (not 8-byte) packing now.
+ [StructLayout(LayoutKind.Sequential, Pack = 4)]
private struct BatchData
{
- public ulong TextureHandle; // bindless handle (uvec2 in GLSL)
- public uint TextureLayer;
- public uint Flags;
+ public uint TextureIndex; // slot into the binding=9 handle table
+ public uint Reserved; // pad — keeps TextureLayer/Flags at offsets 8/12
+ public uint TextureLayer;
+ public uint Flags;
}
private readonly record struct DeferredAlphaInstance(
@@ -2221,9 +2236,9 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable
{
_batchData[i] = new BatchData
{
- TextureHandle = _batchPublicScratch[i].TextureHandle,
- TextureLayer = _batchPublicScratch[i].TextureLayer,
- Flags = _batchPublicScratch[i].Flags,
+ TextureIndex = _batchPublicScratch[i].TextureIndex,
+ TextureLayer = _batchPublicScratch[i].TextureLayer,
+ Flags = _batchPublicScratch[i].Flags,
};
}
_opaqueDrawCount = layout.OpaqueCount;
@@ -2298,6 +2313,10 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable
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(
@@ -2502,13 +2521,15 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable
}
}
- private static IndirectGroupInput ToInput(InstanceGroup g) => new(
+ // Campaign V slice V2: instance method (not static) because it converts
+ // the group's raw bindless handle to a _textureTable slot.
+ private IndirectGroupInput ToInput(InstanceGroup g) => new(
IndexCount: g.IndexCount,
FirstIndex: g.FirstIndex,
BaseVertex: g.BaseVertex,
InstanceCount: g.InstanceCount,
FirstInstance: g.FirstInstance,
- TextureHandle: g.BindlessTextureHandle,
+ TextureIndex: _textureTable.GetOrAdd(g.BindlessTextureHandle),
TextureLayer: g.TextureLayer,
Translucency: g.Translucency,
CullMode: g.CullMode);
@@ -2961,7 +2982,8 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable
GroupKey key = entry.Key;
_batchData[i] = new BatchData
{
- TextureHandle = key.BindlessTextureHandle,
+ // Campaign V slice V2: table slot, not the raw handle.
+ TextureIndex = _textureTable.GetOrAdd(key.BindlessTextureHandle),
TextureLayer = key.TextureLayer,
Flags = 0,
};
@@ -3010,6 +3032,12 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable
_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);
@@ -3145,6 +3173,9 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable
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)
{
@@ -3425,6 +3456,42 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable
count * GlobalLightPacker.FloatsPerLight * sizeof(float));
}
+ ///
+ /// 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 unsafe void FlushAndBindTextureTable()
+ {
+ if (_textureTableSsbo == 0)
+ _textureTableSsbo = TrackedGlResource.CreateBuffer(_gl, "creating WB texture-table SSBO");
+
+ if (_textureTable.Dirty)
+ {
+ ReadOnlySpan handles = _textureTable.Handles;
+ int byteCount = handles.Length * sizeof(ulong);
+ fixed (ulong* p = handles)
+ {
+ UploadDynamicBuffer(
+ BufferTargetARB.ShaderStorageBuffer,
+ _textureTableSsbo,
+ ref _textureTableSsboCapacityBytes,
+ p,
+ byteCount);
+ }
+ _textureTable.MarkFlushed();
+ }
+ _gl.BindBufferBase(
+ BufferTargetARB.ShaderStorageBuffer,
+ AcDream.App.Rendering.Gpu.GpuBindingModel.StorageTextureTable,
+ _textureTableSsbo);
+ }
+
///
/// Phase U.3: bind the per-cell clip-region SSBO to binding=2. Prefers the
/// shared buffer (set via );
@@ -4089,6 +4156,13 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable
"deleting entity fallback clip SSBO",
_gl.DeleteBuffer);
+ AddTrackedBufferRelease(
+ releases,
+ _textureTableSsbo,
+ _textureTableSsboCapacityBytes,
+ "texture-table",
+ "deleting entity texture-table SSBO");
+
if (!_gpuQueriesInitialized)
return;
for (int i = 0; i < GpuQueryRingDepth; i++)
@@ -4191,6 +4265,8 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable
_instAlphaSsbo = 0;
_instSelectionLightingSsbo = 0;
_fallbackClipRegionSsbo = 0;
+ _textureTableSsbo = 0;
+ _textureTableSsboCapacityBytes = 0;
Array.Clear(_gpuQueryOpaque);
Array.Clear(_gpuQueryTransparent);
_gpuQueriesInitialized = false;
@@ -4210,6 +4286,8 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable
///
/// Public view of the per-group inputs to — used in tests.
+ /// Campaign V slice V2: TextureIndex is a slot into the binding=9
+ /// handle table (was a raw 64-bit bindless TextureHandle).
///
public readonly record struct IndirectGroupInput(
int IndexCount,
@@ -4217,7 +4295,7 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable
int BaseVertex,
int InstanceCount,
int FirstInstance,
- ulong TextureHandle,
+ uint TextureIndex,
uint TextureLayer,
TranslucencyKind Translucency,
CullMode CullMode = CullMode.CounterClockwise);
@@ -4226,12 +4304,13 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable
/// Public mirror of the per-group uploaded to the SSBO.
/// Tests verify the layout. Same field shape as the private BatchData.
///
- [StructLayout(LayoutKind.Sequential, Pack = 8)]
+ [StructLayout(LayoutKind.Sequential, Pack = 4)]
public struct BatchDataPublic
{
- public ulong TextureHandle;
- public uint TextureLayer;
- public uint Flags;
+ public uint TextureIndex;
+ public uint Reserved;
+ public uint TextureLayer;
+ public uint Flags;
}
/// Result of .
@@ -4279,9 +4358,10 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable
};
var bd = new BatchDataPublic
{
- TextureHandle = g.TextureHandle,
- TextureLayer = g.TextureLayer,
- Flags = 0,
+ TextureIndex = g.TextureIndex,
+ Reserved = 0,
+ TextureLayer = g.TextureLayer,
+ Flags = 0,
};
if (IsOpaque(g.Translucency))
diff --git a/tests/AcDream.App.Tests/Rendering/GlBindlessHandleTableTests.cs b/tests/AcDream.App.Tests/Rendering/GlBindlessHandleTableTests.cs
new file mode 100644
index 00000000..e5b5170f
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/GlBindlessHandleTableTests.cs
@@ -0,0 +1,105 @@
+using AcDream.App.Rendering;
+using Xunit;
+
+namespace AcDream.App.Tests.Rendering;
+
+///
+/// Campaign V slice V2 (2026-07-27): pure-CPU proof of
+/// 's bookkeeping — the handle→slot
+/// allocator each Campaign V-touched renderer (WbDrawDispatcher,
+/// EnvCellRenderer, TerrainModernRenderer, ParticleRenderer) owns to back its
+/// own binding=9 GL texture table.
+///
+public class GlBindlessHandleTableTests
+{
+ [Fact]
+ public void GetOrAdd_FirstHandle_AssignsSlotZero_AndMarksDirty()
+ {
+ var table = new GlBindlessHandleTable();
+
+ uint slot = table.GetOrAdd(0xDEADBEEFu);
+
+ Assert.Equal(0u, slot);
+ Assert.True(table.Dirty);
+ Assert.Equal(new ulong[] { 0xDEADBEEFu }, table.Handles.ToArray());
+ }
+
+ [Fact]
+ public void GetOrAdd_SameHandleTwice_ReturnsSameSlot_AndDoesNotDuplicate()
+ {
+ var table = new GlBindlessHandleTable();
+
+ uint first = table.GetOrAdd(111ul);
+ table.MarkFlushed();
+ uint second = table.GetOrAdd(111ul);
+
+ Assert.Equal(first, second);
+ Assert.False(table.Dirty); // no NEW handle was registered
+ Assert.Single(table.Handles.ToArray());
+ }
+
+ [Fact]
+ public void GetOrAdd_DistinctHandles_AssignStableIncreasingSlots()
+ {
+ var table = new GlBindlessHandleTable();
+
+ uint a = table.GetOrAdd(1ul);
+ uint b = table.GetOrAdd(2ul);
+ uint c = table.GetOrAdd(3ul);
+ // Re-querying an already-registered handle must not shift anyone else's slot.
+ uint aAgain = table.GetOrAdd(1ul);
+
+ Assert.Equal(0u, a);
+ Assert.Equal(1u, b);
+ Assert.Equal(2u, c);
+ Assert.Equal(a, aAgain);
+ Assert.Equal(new ulong[] { 1ul, 2ul, 3ul }, table.Handles.ToArray());
+ }
+
+ [Fact]
+ public void ZeroHandle_IsRegisteredLikeAnyOther_NotSpecialCased()
+ {
+ // The pre-V2 behaviour let a batch/pass carry a literal zero bindless
+ // handle through to the shader unchanged (an existing "no texture"
+ // edge case some batches hit). V2 must reproduce that bit-for-bit: a
+ // zero handle gets a real slot whose table entry is uvec2(0,0) — the
+ // same value the shader would have received directly before V2.
+ var table = new GlBindlessHandleTable();
+
+ uint slot = table.GetOrAdd(0ul);
+
+ Assert.Equal(0u, table.Handles[(int)slot]);
+ }
+
+ [Fact]
+ public void MarkFlushed_ClearsDirty_UntilNextNewHandle()
+ {
+ var table = new GlBindlessHandleTable();
+ table.GetOrAdd(42ul);
+ Assert.True(table.Dirty);
+
+ table.MarkFlushed();
+ Assert.False(table.Dirty);
+
+ table.GetOrAdd(42ul); // already known — must NOT re-dirty
+ Assert.False(table.Dirty);
+
+ table.GetOrAdd(43ul); // genuinely new — must re-dirty
+ Assert.True(table.Dirty);
+ }
+
+ [Fact]
+ public void GetOrAdd_GrowsPastInitialCapacity_WithoutLosingEarlierSlots()
+ {
+ var table = new GlBindlessHandleTable();
+ const int count = 200; // exceeds the 64-entry initial backing array
+ for (int i = 0; i < count; i++)
+ {
+ uint slot = table.GetOrAdd((ulong)i + 1000ul);
+ Assert.Equal((uint)i, slot);
+ }
+
+ for (int i = 0; i < count; i++)
+ Assert.Equal((ulong)i + 1000ul, table.Handles[i]);
+ }
+}
diff --git a/tests/AcDream.App.Tests/Rendering/Wb/ModernBatchDataLayoutTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/ModernBatchDataLayoutTests.cs
new file mode 100644
index 00000000..b8e78b3b
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/Wb/ModernBatchDataLayoutTests.cs
@@ -0,0 +1,58 @@
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+using AcDream.App.Rendering.Wb;
+using Xunit;
+
+namespace AcDream.App.Tests.Rendering.Wb;
+
+///
+/// Campaign V slice V2 (2026-07-27): CPU-side proof that
+/// — EnvCellRenderer's per-batch GPU struct,
+/// sharing mesh_modern.vert's BatchData shader-side layout with
+/// WbDrawDispatcher — packs at the exact std430 offsets the shader expects
+/// after TextureHandle (a raw 64-bit ARB_bindless_texture handle) became
+/// TextureTableIndex (a slot into the binding=9 handle table). Mirrors
+/// ClipFrameLayoutTests' role for ClipFrame and
+/// WbDrawDispatcherIndirectBuilderTests.BatchDataPublic_LayoutMatchesPrivateBatchData
+/// for WbDrawDispatcher's own BatchData — a silent layout drift here would
+/// desync EnvCellRenderer's uploaded bytes from what mesh_modern.vert reads,
+/// with no build error.
+///
+/// Layout under test (std430, 16 bytes total):
+/// offset 0 : uint TextureTableIndex
+/// offset 4 : uint Reserved (pad)
+/// offset 8 : uint TextureIndex (layer within the texture array)
+/// offset 12 : uint Flags
+///
+public class ModernBatchDataLayoutTests
+{
+ [Fact]
+ public void Size_Is16Bytes_MatchingGpuBatchDataStride()
+ {
+ Assert.Equal(16, Unsafe.SizeOf());
+ }
+
+ [Fact]
+ public void FieldOffsets_MatchStd430Layout()
+ {
+ Assert.Equal(0, (int)Marshal.OffsetOf(nameof(ModernBatchData.TextureTableIndex)));
+ Assert.Equal(4, (int)Marshal.OffsetOf(nameof(ModernBatchData.Reserved)));
+ Assert.Equal(8, (int)Marshal.OffsetOf(nameof(ModernBatchData.TextureIndex)));
+ Assert.Equal(12, (int)Marshal.OffsetOf(nameof(ModernBatchData.Flags)));
+ }
+
+ [Fact]
+ public void FieldValues_RoundTripThroughTheStruct()
+ {
+ var data = new ModernBatchData
+ {
+ TextureTableIndex = 7u,
+ TextureIndex = 3u,
+ };
+
+ Assert.Equal(7u, data.TextureTableIndex);
+ Assert.Equal(0u, data.Reserved);
+ Assert.Equal(3u, data.TextureIndex);
+ Assert.Equal(0u, data.Flags);
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherIndirectBuilderTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherIndirectBuilderTests.cs
index 0d6f977f..61d4b366 100644
--- a/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherIndirectBuilderTests.cs
+++ b/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherIndirectBuilderTests.cs
@@ -20,9 +20,9 @@ public sealed class WbDrawDispatcherIndirectBuilderTests
// Arrange — three groups: 2 opaque (12+1 instances) + 1 transparent (12 instances)
var groups = new List
{
- new(IndexCount: 100, FirstIndex: 0, BaseVertex: 0, InstanceCount: 12, FirstInstance: 0, TextureHandle: 0xAA, TextureLayer: 0, Translucency: TranslucencyKind.Opaque),
- new(IndexCount: 200, FirstIndex: 100, BaseVertex: 0, InstanceCount: 12, FirstInstance: 12, TextureHandle: 0xBB, TextureLayer: 0, Translucency: TranslucencyKind.AlphaBlend),
- new(IndexCount: 50, FirstIndex: 300, BaseVertex: 100, InstanceCount: 1, FirstInstance: 24, TextureHandle: 0xCC, TextureLayer: 0, Translucency: TranslucencyKind.Opaque),
+ new(IndexCount: 100, FirstIndex: 0, BaseVertex: 0, InstanceCount: 12, FirstInstance: 0, TextureIndex: 0xAA, TextureLayer: 0, Translucency: TranslucencyKind.Opaque),
+ new(IndexCount: 200, FirstIndex: 100, BaseVertex: 0, InstanceCount: 12, FirstInstance: 12, TextureIndex: 0xBB, TextureLayer: 0, Translucency: TranslucencyKind.AlphaBlend),
+ new(IndexCount: 50, FirstIndex: 300, BaseVertex: 100, InstanceCount: 1, FirstInstance: 24, TextureIndex: 0xCC, TextureLayer: 0, Translucency: TranslucencyKind.Opaque),
};
var indirect = new DrawElementsIndirectCommand[16];
@@ -57,9 +57,9 @@ public sealed class WbDrawDispatcherIndirectBuilderTests
Assert.Equal(12u, indirect[2].BaseInstance);
// BatchData parallel — same indices as indirect
- Assert.Equal(0xAAul, batch[0].TextureHandle);
- Assert.Equal(0xCCul, batch[1].TextureHandle);
- Assert.Equal(0xBBul, batch[2].TextureHandle);
+ Assert.Equal(0xAAu, batch[0].TextureIndex);
+ Assert.Equal(0xCCu, batch[1].TextureIndex);
+ Assert.Equal(0xBBu, batch[2].TextureIndex);
Assert.Equal(CullMode.CounterClockwise, cull[0]);
Assert.Equal(CullMode.CounterClockwise, cull[1]);
Assert.Equal(CullMode.CounterClockwise, cull[2]);
@@ -71,13 +71,13 @@ public sealed class WbDrawDispatcherIndirectBuilderTests
var groups = new List
{
new(IndexCount: 10, FirstIndex: 0, BaseVertex: 0, InstanceCount: 1, FirstInstance: 0,
- TextureHandle: 0x1, TextureLayer: 0, Translucency: TranslucencyKind.Opaque,
+ TextureIndex: 0x1, TextureLayer: 0, Translucency: TranslucencyKind.Opaque,
CullMode: CullMode.Clockwise),
new(IndexCount: 20, FirstIndex: 10, BaseVertex: 0, InstanceCount: 1, FirstInstance: 1,
- TextureHandle: 0x2, TextureLayer: 0, Translucency: TranslucencyKind.AlphaBlend,
+ TextureIndex: 0x2, TextureLayer: 0, Translucency: TranslucencyKind.AlphaBlend,
CullMode: CullMode.None),
new(IndexCount: 30, FirstIndex: 30, BaseVertex: 0, InstanceCount: 1, FirstInstance: 2,
- TextureHandle: 0x3, TextureLayer: 0, Translucency: TranslucencyKind.ClipMap,
+ TextureIndex: 0x3, TextureLayer: 0, Translucency: TranslucencyKind.ClipMap,
CullMode: CullMode.Landblock),
};
var indirect = new DrawElementsIndirectCommand[4];
@@ -113,7 +113,7 @@ public sealed class WbDrawDispatcherIndirectBuilderTests
// because the discard handles transparency, not blending.
var groups = new List
{
- new(IndexCount: 10, FirstIndex: 0, BaseVertex: 0, InstanceCount: 1, FirstInstance: 0, TextureHandle: 0x1, TextureLayer: 0, Translucency: TranslucencyKind.ClipMap),
+ new(IndexCount: 10, FirstIndex: 0, BaseVertex: 0, InstanceCount: 1, FirstInstance: 0, TextureIndex: 0x1, TextureLayer: 0, Translucency: TranslucencyKind.ClipMap),
};
var indirect = new DrawElementsIndirectCommand[4];
var batch = new WbDrawDispatcher.BatchDataPublic[4];
@@ -130,9 +130,18 @@ public sealed class WbDrawDispatcherIndirectBuilderTests
// Task 10 will use MemoryMarshal.Cast to
// expose the dispatcher's per-frame BatchData[] scratch to BuildIndirectArrays
// without copying. The cast is only safe if the structs have identical
- // layout (size, field offsets). Both use [StructLayout(Sequential, Pack=8)].
+ // layout (size, field offsets).
+ //
+ // Campaign V slice V2 (2026-07-27): TextureHandle (ulong, an
+ // ARB_bindless_texture handle) became TextureIndex (uint) plus an
+ // explicit Reserved pad word — a slot into the binding=9 handle table
+ // (GpuBindingModel.StorageTextureTable) instead of the raw handle.
+ // The struct stays 16 bytes and TextureLayer/Flags keep their offsets
+ // (8/12), matching GpuBindingModel.GpuBatchDataStrideBytes and every
+ // existing CPU writer, so both structs only need 4-byte packing now.
Assert.Equal(16, System.Runtime.CompilerServices.Unsafe.SizeOf());
- Assert.Equal(0, (int)System.Runtime.InteropServices.Marshal.OffsetOf(nameof(WbDrawDispatcher.BatchDataPublic.TextureHandle)));
+ Assert.Equal(0, (int)System.Runtime.InteropServices.Marshal.OffsetOf(nameof(WbDrawDispatcher.BatchDataPublic.TextureIndex)));
+ Assert.Equal(4, (int)System.Runtime.InteropServices.Marshal.OffsetOf(nameof(WbDrawDispatcher.BatchDataPublic.Reserved)));
Assert.Equal(8, (int)System.Runtime.InteropServices.Marshal.OffsetOf(nameof(WbDrawDispatcher.BatchDataPublic.TextureLayer)));
Assert.Equal(12, (int)System.Runtime.InteropServices.Marshal.OffsetOf(nameof(WbDrawDispatcher.BatchDataPublic.Flags)));
}