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>
105 lines
3.3 KiB
C#
105 lines
3.3 KiB
C#
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]);
|
|
}
|
|
}
|