feat(render): Campaign V slice V2a - mesh path texture-index migration

Moves the mesh/EnvCell draw path's per-batch texture representation from a
64-bit ARB_bindless_texture handle to a small integer table index, entirely
on the still-shipping GL backend, with zero pixel change. This is the CPU-side
half of the eventual Vulkan descriptor-array indexing model: a table index is
the backend-neutral form (Vulkan indexes a descriptor array with it directly),
while a raw bindless handle is GL-only. Landing the data-model change now, on
GL, under a strict self-differential pixel gate, keeps it separate from V4c's
much larger RHI-plumbing change (see docs/plans/2026-07-27-vulkan-campaign.md
section 5.2 for why the table cannot be device-owned yet).

Mechanism: mesh_modern.vert's BatchData struct carries `textureIndex` (a slot)
instead of `textureHandle` (uvec2); the vertex shader looks the slot up in a
new binding=9 storage buffer (GpuBindingModel.StorageTextureTable) and passes
the reconstructed uvec2 handle to the fragment shader exactly as before, so
mesh_modern.frag needed no change at all beyond the UBO-set macro below. The
16-byte std430 stride is unchanged (GpuBindingModel.GpuBatchDataStrideBytes);
textureLayer/flags keep their offsets, so every existing CPU writer's layout
is untouched.

The handle->slot table (GlBindlessHandleTable, new, pure C#) is owned
separately by WbDrawDispatcher and EnvCellRenderer rather than shared through
a single TextureCache-owned instance: EnvCellRenderer never had a TextureCache
dependency, and nothing requires index agreement between renderers since each
rebinds its own binding=9 buffer immediately before its own draw call. This
avoided threading a new constructor parameter through EnvCellRenderer (and its
six test call sites) for no behavioral benefit. TextureCache and
CompositeTextureArrayCache turned out to need no changes at all: they only
ever produce raw ulong handles, and that production path is unaffected -
the new indirection is entirely a WbDrawDispatcher/EnvCellRenderer-side
concern, added exactly where each already assembles its per-batch GPU struct
(ToInput, the copy-back loop, PrepareDeferredAlphaDraws for the
RetailAlphaQueue path, and EnvCellRenderer's ModernBatchData construction).
The table itself is a single non-ring buffer (unlike the per-frame
triple-buffered SSBOs) because a genuinely new handle is rare - new dat
surfaces/composite overrides, not every frame - so it flushes only when
GlBindlessHandleTable.Dirty is set, mirroring how the existing texture caches
already upload infrequently.

Shader-side, introduced Rendering/Shaders/common.glsl as the shared preamble
GL has no #include for: Shader.cs gained an `includeCommonPreamble` overload
that splices the file's text in after the leading #version/#extension block
(GLSL requires #version first). It declares the binding=9 table plus the
ACDREAM_TEXTURE_HANDLE(idx) lookup macro, and a scaffolding ACDREAM_UBO_SET
macro (a no-op under GL today, redefined to `set = 1,` when the Vulkan
toolchain compiles this same source at V6+, per the campaign doc's set-1 UBO
note) applied to both SceneLighting UBO declarations now so no later slice
needs to touch them again.

Tests: WbDrawDispatcherIndirectBuilderTests updated for the renamed
IndirectGroupInput/BatchDataPublic fields; new ModernBatchDataLayoutTests
(mirrors ClipFrameLayoutTests' role, but for EnvCellRenderer's GPU struct) and
GlBindlessHandleTableTests (pure-CPU allocator behavior, including the
zero-handle case, which is registered like any other handle rather than
special-cased, since that's what reproduces the pre-V2 sampling result
bit-for-bit).

Gate: dotnet build -c Release green, dotnet test
tests/AcDream.App.Tests -c Release green (3843 passed / 3 skipped, +9 over
the 3834/3 baseline), and tools/run-offline-pixel-gate.ps1 passed with a
2.84e-05 differing-pixel fraction against the parent commit - within the
documented ~33x same-commit noise margin. No divergence-register row: this
introduces no retail behavior deviation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-27 15:51:26 +02:00
parent 8b57ba7167
commit d365476ebb
12 changed files with 550 additions and 49 deletions

View file

@ -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));
}
/// <summary>
/// Campaign V slice V2: uploads <see cref="_textureTable"/>'s handles to
/// <see cref="_textureTableSsbo"/> when a new one was registered since the
/// last flush (by <see cref="ToInput"/> or <see cref="PrepareDeferredAlphaDraws"/>),
/// then (re)binds it at <see cref="AcDream.App.Rendering.Gpu.GpuBindingModel.StorageTextureTable"/>.
/// 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 cref="DynamicBufferSet"/>; see
/// <see cref="GlBindlessHandleTable"/>'s doc comment.
/// </summary>
private unsafe void FlushAndBindTextureTable()
{
if (_textureTableSsbo == 0)
_textureTableSsbo = TrackedGlResource.CreateBuffer(_gl, "creating WB texture-table SSBO");
if (_textureTable.Dirty)
{
ReadOnlySpan<ulong> 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);
}
/// <summary>
/// Phase U.3: bind the per-cell clip-region SSBO to binding=2. Prefers the
/// shared <see cref="ClipFrame"/> buffer (set via <see cref="SetClipRegionSsbo"/>);
@ -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
/// <summary>
/// Public view of the per-group inputs to <see cref="BuildIndirectArrays"/> — used in tests.
/// Campaign V slice V2: <c>TextureIndex</c> is a slot into the binding=9
/// handle table (was a raw 64-bit bindless <c>TextureHandle</c>).
/// </summary>
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 <see cref="BatchData"/> uploaded to the SSBO.
/// Tests verify the layout. Same field shape as the private BatchData.
/// </summary>
[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;
}
/// <summary>Result of <see cref="BuildIndirectArrays"/>.</summary>
@ -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))