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>
154 lines
7.4 KiB
C#
154 lines
7.4 KiB
C#
using System.Numerics;
|
|
using AcDream.App.Rendering.Wb;
|
|
using AcDream.Core.Meshing;
|
|
using DatReaderWriter.Enums;
|
|
using Xunit;
|
|
|
|
namespace AcDream.Core.Tests.Rendering.Wb;
|
|
|
|
/// <summary>
|
|
/// Pure CPU test of <see cref="WbDrawDispatcher.BuildIndirectArrays"/>.
|
|
/// Verifies that a synthetic group set lays out into the indirect buffer
|
|
/// + parallel batch data with opaque section first, transparent second,
|
|
/// per-group fields propagated correctly.
|
|
/// </summary>
|
|
public sealed class WbDrawDispatcherIndirectBuilderTests
|
|
{
|
|
[Fact]
|
|
public void TwoOpaqueGroupsAndOneTransparent_LaysOutContiguouslyOpaqueFirst()
|
|
{
|
|
// 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, 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];
|
|
var batch = new WbDrawDispatcher.BatchDataPublic[16];
|
|
var cull = new CullMode[16];
|
|
|
|
// Act
|
|
var result = WbDrawDispatcher.BuildIndirectArrays(groups, indirect, batch, cull);
|
|
|
|
// Assert layout
|
|
Assert.Equal(2, result.OpaqueCount);
|
|
Assert.Equal(1, result.TransparentCount);
|
|
Assert.Equal(2 * 20, result.TransparentByteOffset); // sizeof(DEIC) = 20
|
|
|
|
// Opaque section, in input order (Task 10 callers sort)
|
|
Assert.Equal(100u, indirect[0].Count);
|
|
Assert.Equal(0u, indirect[0].FirstIndex);
|
|
Assert.Equal(0, indirect[0].BaseVertex);
|
|
Assert.Equal(12u, indirect[0].InstanceCount);
|
|
Assert.Equal(0u, indirect[0].BaseInstance);
|
|
|
|
Assert.Equal(50u, indirect[1].Count);
|
|
Assert.Equal(300u, indirect[1].FirstIndex);
|
|
Assert.Equal(100, indirect[1].BaseVertex);
|
|
Assert.Equal(1u, indirect[1].InstanceCount);
|
|
Assert.Equal(24u, indirect[1].BaseInstance);
|
|
|
|
// Transparent section
|
|
Assert.Equal(200u, indirect[2].Count);
|
|
Assert.Equal(100u, indirect[2].FirstIndex);
|
|
Assert.Equal(12u, indirect[2].InstanceCount);
|
|
Assert.Equal(12u, indirect[2].BaseInstance);
|
|
|
|
// BatchData parallel — same indices as indirect
|
|
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]);
|
|
}
|
|
|
|
[Fact]
|
|
public void CullModes_FollowOpaqueTransparentLayout()
|
|
{
|
|
var groups = new List<WbDrawDispatcher.IndirectGroupInput>
|
|
{
|
|
new(IndexCount: 10, FirstIndex: 0, BaseVertex: 0, InstanceCount: 1, FirstInstance: 0,
|
|
TextureIndex: 0x1, TextureLayer: 0, Translucency: TranslucencyKind.Opaque,
|
|
CullMode: CullMode.Clockwise),
|
|
new(IndexCount: 20, FirstIndex: 10, BaseVertex: 0, InstanceCount: 1, FirstInstance: 1,
|
|
TextureIndex: 0x2, TextureLayer: 0, Translucency: TranslucencyKind.AlphaBlend,
|
|
CullMode: CullMode.None),
|
|
new(IndexCount: 30, FirstIndex: 30, BaseVertex: 0, InstanceCount: 1, FirstInstance: 2,
|
|
TextureIndex: 0x3, TextureLayer: 0, Translucency: TranslucencyKind.ClipMap,
|
|
CullMode: CullMode.Landblock),
|
|
};
|
|
var indirect = new DrawElementsIndirectCommand[4];
|
|
var batch = new WbDrawDispatcher.BatchDataPublic[4];
|
|
var cull = new CullMode[4];
|
|
|
|
var result = WbDrawDispatcher.BuildIndirectArrays(groups, indirect, batch, cull);
|
|
|
|
Assert.Equal(2, result.OpaqueCount);
|
|
Assert.Equal(CullMode.Clockwise, cull[0]);
|
|
Assert.Equal(CullMode.Landblock, cull[1]);
|
|
Assert.Equal(CullMode.None, cull[2]);
|
|
}
|
|
|
|
[Fact]
|
|
public void EmptyGroupList_ProducesZeroCounts()
|
|
{
|
|
var groups = new List<WbDrawDispatcher.IndirectGroupInput>();
|
|
var indirect = new DrawElementsIndirectCommand[0];
|
|
var batch = new WbDrawDispatcher.BatchDataPublic[0];
|
|
|
|
var result = WbDrawDispatcher.BuildIndirectArrays(groups, indirect, batch);
|
|
|
|
Assert.Equal(0, result.OpaqueCount);
|
|
Assert.Equal(0, result.TransparentCount);
|
|
Assert.Equal(0, result.TransparentByteOffset);
|
|
}
|
|
|
|
[Fact]
|
|
public void ClipMapTreatedAsOpaque()
|
|
{
|
|
// ClipMap surfaces (alpha-cutout) belong with the opaque pass
|
|
// because the discard handles transparency, not blending.
|
|
var groups = new List<WbDrawDispatcher.IndirectGroupInput>
|
|
{
|
|
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];
|
|
|
|
var result = WbDrawDispatcher.BuildIndirectArrays(groups, indirect, batch);
|
|
|
|
Assert.Equal(1, result.OpaqueCount);
|
|
Assert.Equal(0, result.TransparentCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void BatchDataPublic_LayoutMatchesPrivateBatchData()
|
|
{
|
|
// 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).
|
|
//
|
|
// 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.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)));
|
|
}
|
|
|
|
[Fact]
|
|
public void DrawCommandStride_MatchesStructSize()
|
|
{
|
|
Assert.Equal(WbDrawDispatcher.DrawCommandStride, System.Runtime.CompilerServices.Unsafe.SizeOf<DrawElementsIndirectCommand>());
|
|
}
|
|
}
|