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,41 @@
// Campaign V slice V2 shared preamble (docs/plans/2026-07-27-vulkan-campaign.md
// §3.4, §5.2). GL has no #include, so AcDream.App.Rendering.Shader
// concatenates this file's text into every shader source that opts in
// (Shader(gl, vertPath, fragPath, includeCommonPreamble: true)), inserted
// right after the leading #version / #extension block so it can declare new
// layout bindings and macros before the rest of the shader body runs.
//
// --- set 1 (uniform buffers) ------------------------------------------------
// GL keeps the SSBO and UBO binding-number namespaces separate, so today's
// SceneLighting UBO (binding=1) never collides with BatchBuffer's SSBO
// (also binding=1). Vulkan has ONE binding namespace per set, so the Vulkan
// backend (from V6 on) moves every uniform buffer to its own set (1) to keep
// both binding numbers. ACDREAM_UBO_SET is a no-op under GL today and is
// redefined to `set = 1,` when the same source is compiled for Vulkan, so
// applying it to every UBO layout now costs nothing and needs no source edit
// at the call sites later.
#define ACDREAM_UBO_SET
// --- set 0 binding 9 / set 2 (the global texture table) --------------------
// GL emulates Vulkan's set 2 variable-count sampled-texture descriptor array
// (GpuBindingModel.TextureTableSet) with a plain storage buffer of packed
// GL_ARB_bindless_texture handles at set 0 binding 9
// (GpuBindingModel.StorageTextureTable). A batch/pass no longer carries a
// 64-bit bindless handle directly into its GPU-visible struct; it carries a
// small integer slot index into this table instead, which is what makes the
// CPU-side data model backend-neutral (Campaign V slice V2). The table itself
// — and every per-renderer GL-side handle-slot allocator that fills it
// (WbDrawDispatcher, EnvCellRenderer, TerrainModernRenderer, ParticleRenderer)
// — is deleted once each renderer moves onto IGpuDevice's own retirement-gated
// table at V4c/V4d/V4e; see the campaign doc's §5.2 for why V2 cannot reach
// that table yet.
layout(std430, binding = 9) readonly buffer TextureTableBuf {
uvec2 gTextureTable[];
};
// Looks up the packed bindless handle for table slot `idx`. Callers still
// wrap the result in `sampler2DArray(...)` themselves at the use site (kept
// explicit rather than folded into one sampler-returning macro) because every
// existing call site already follows that exact pattern and a function cannot
// return an opaque sampler type built from a runtime value in GLSL.
#define ACDREAM_TEXTURE_HANDLE(idx) gTextureTable[idx]

View file

@ -27,7 +27,7 @@ struct Light {
vec4 colorAndIntensity;
vec4 coneAngleEtc;
};
layout(std140, binding = 1) uniform SceneLighting {
layout(std140, ACDREAM_UBO_SET binding = 1) uniform SceneLighting {
Light uLights[8];
vec4 uCellAmbient;
vec4 uFogParams;

View file

@ -9,8 +9,18 @@ struct InstanceData {
mat4 transform;
};
// Campaign V slice V2 (2026-07-27): textureHandle (uvec2, a 64-bit
// GL_ARB_bindless_texture handle) became textureIndex (uint) plus an explicit
// pad word. textureIndex is a slot into the binding=9 handle table
// (ACDREAM_TEXTURE_HANDLE, common.glsl) that main() below looks up once per
// vertex to reconstruct the exact same uvec2 handle main() used to receive
// directly — one indirection, identical value. The pad word keeps
// textureLayer/flags at their original std430 offsets (8/12), so the struct
// is still 16 bytes and every existing CPU writer's layout is unchanged
// (GpuBindingModel.GpuBatchDataStrideBytes).
struct BatchData {
uvec2 textureHandle; // bindless handle for sampler2DArray
uint textureIndex; // slot into the binding=9 handle table
uint _pad; // keeps textureLayer/flags at offsets 8/12
uint textureLayer; // layer in the shared WB or pooled composite array
uint flags; // reserved — N.5 dispatcher owns all blend state
// (glBlendFunc per pass). If a future phase wants
@ -168,7 +178,7 @@ struct Light {
vec4 colorAndIntensity;
vec4 coneAngleEtc;
};
layout(std140, binding = 1) uniform SceneLighting {
layout(std140, ACDREAM_UBO_SET binding = 1) uniform SceneLighting {
Light uLights[8];
vec4 uCellAmbient;
vec4 uFogParams;
@ -313,6 +323,9 @@ void main() {
vTexCoord = aTexCoord;
BatchData b = Batches[uDrawIDOffset + gl_DrawIDARB];
vTextureHandle = b.textureHandle;
// Campaign V slice V2: reconstruct the SAME uvec2 handle the shader used
// to receive directly from BatchData, now via one binding=9 table lookup.
// vTextureHandle's type and every downstream frag-shader use are unchanged.
vTextureHandle = ACDREAM_TEXTURE_HANDLE(b.textureIndex);
vTextureLayer = b.textureLayer;
}