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

@ -0,0 +1,105 @@
using AcDream.App.Rendering;
using Xunit;
namespace AcDream.App.Tests.Rendering;
/// <summary>
/// Campaign V slice V2 (2026-07-27): pure-CPU proof of
/// <see cref="GlBindlessHandleTable"/>'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.
/// </summary>
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]);
}
}

View file

@ -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;
/// <summary>
/// Campaign V slice V2 (2026-07-27): CPU-side proof that
/// <see cref="ModernBatchData"/> — 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
/// </summary>
public class ModernBatchDataLayoutTests
{
[Fact]
public void Size_Is16Bytes_MatchingGpuBatchDataStride()
{
Assert.Equal(16, Unsafe.SizeOf<ModernBatchData>());
}
[Fact]
public void FieldOffsets_MatchStd430Layout()
{
Assert.Equal(0, (int)Marshal.OffsetOf<ModernBatchData>(nameof(ModernBatchData.TextureTableIndex)));
Assert.Equal(4, (int)Marshal.OffsetOf<ModernBatchData>(nameof(ModernBatchData.Reserved)));
Assert.Equal(8, (int)Marshal.OffsetOf<ModernBatchData>(nameof(ModernBatchData.TextureIndex)));
Assert.Equal(12, (int)Marshal.OffsetOf<ModernBatchData>(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);
}
}

View file

@ -20,9 +20,9 @@ public sealed class WbDrawDispatcherIndirectBuilderTests
// Arrange — three groups: 2 opaque (12+1 instances) + 1 transparent (12 instances)
var groups = new List<WbDrawDispatcher.IndirectGroupInput>
{
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<WbDrawDispatcher.IndirectGroupInput>
{
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<WbDrawDispatcher.IndirectGroupInput>
{
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<BatchData, BatchDataPublic> 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<WbDrawDispatcher.BatchDataPublic>());
Assert.Equal(0, (int)System.Runtime.InteropServices.Marshal.OffsetOf<WbDrawDispatcher.BatchDataPublic>(nameof(WbDrawDispatcher.BatchDataPublic.TextureHandle)));
Assert.Equal(0, (int)System.Runtime.InteropServices.Marshal.OffsetOf<WbDrawDispatcher.BatchDataPublic>(nameof(WbDrawDispatcher.BatchDataPublic.TextureIndex)));
Assert.Equal(4, (int)System.Runtime.InteropServices.Marshal.OffsetOf<WbDrawDispatcher.BatchDataPublic>(nameof(WbDrawDispatcher.BatchDataPublic.Reserved)));
Assert.Equal(8, (int)System.Runtime.InteropServices.Marshal.OffsetOf<WbDrawDispatcher.BatchDataPublic>(nameof(WbDrawDispatcher.BatchDataPublic.TextureLayer)));
Assert.Equal(12, (int)System.Runtime.InteropServices.Marshal.OffsetOf<WbDrawDispatcher.BatchDataPublic>(nameof(WbDrawDispatcher.BatchDataPublic.Flags)));
}