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

@ -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<DrawElementsIndirectCommand>();
@ -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)
// ---------------------------------------------------------------------------
/// <summary>
/// Uploads <see cref="_textureTable"/>'s handles to <see cref="_textureTableSsbo"/>
/// when a new one was registered since the last flush, then (re)binds it at
/// <see cref="AcDream.App.Rendering.Gpu.GpuBindingModel.StorageTextureTable"/>.
/// 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.
/// </summary>
private void FlushAndBindTextureTable()
{
if (_textureTableSsbo == 0)
_textureTableSsbo = TrackedGlResource.CreateBuffer(_gl, "creating EnvCell texture-table SSBO");
if (_textureTable.Dirty)
{
ReadOnlySpan<ulong> 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;