diff --git a/src/AcDream.App/Composition/WorldRenderComposition.cs b/src/AcDream.App/Composition/WorldRenderComposition.cs index 21ad200d..d034c1af 100644 --- a/src/AcDream.App/Composition/WorldRenderComposition.cs +++ b/src/AcDream.App/Composition/WorldRenderComposition.cs @@ -274,7 +274,8 @@ internal sealed class RetailWorldRenderCompositionFactory new( gl, Path.Combine(shadersDirectory, "mesh_modern.vert"), - Path.Combine(shadersDirectory, "mesh_modern.frag")); + Path.Combine(shadersDirectory, "mesh_modern.frag"), + includeCommonPreamble: true); public WbMeshAdapter CreateMeshAdapter( GL gl, diff --git a/src/AcDream.App/Rendering/GlBindlessHandleTable.cs b/src/AcDream.App/Rendering/GlBindlessHandleTable.cs new file mode 100644 index 00000000..330541c8 --- /dev/null +++ b/src/AcDream.App/Rendering/GlBindlessHandleTable.cs @@ -0,0 +1,62 @@ +namespace AcDream.App.Rendering; + +/// +/// Campaign V slice V2 (docs/plans/2026-07-27-vulkan-campaign.md §3.4, §5.2): +/// pure bookkeeping that assigns a stable small-integer "slot" to each distinct +/// GL_ARB_bindless_texture handle a renderer submits. A batch/pass no longer +/// carries the 64-bit handle directly into its GPU-visible struct; it carries +/// this slot index instead, and the shader looks the handle back up from a +/// binding=9 storage buffer (GpuBindingModel.StorageTextureTable) — the +/// GL-only emulation of Vulkan's global sampled-texture descriptor array +/// (GpuBindingModel.TextureTableSet). That indirection is what makes +/// the CPU-side batch data backend-neutral. +/// +/// Owns no GL resource. The owning renderer uploads to +/// its own storage buffer whenever is set — mirroring how +/// it already uploads its other per-frame/per-registration SSBOs — and clears +/// the flag with . Never releases a slot: entries +/// accumulate for the renderer's lifetime, exactly like the world atlas and +/// composite-texture caches it draws handles from. +/// +/// This class, plus the per-renderer SSBO it backs, is deleted at +/// V4c/V4d/V4e when each renderer moves onto IGpuDevice's own +/// retirement-gated table — see the campaign doc's §5.2 for why V2 cannot +/// reach that table yet. +/// +/// Each renderer that needs the indirection (WbDrawDispatcher, +/// EnvCellRenderer, TerrainModernRenderer, ParticleRenderer) owns its own +/// instance. Nothing requires index agreement between renderers: each rebinds +/// its own buffer to binding=9 immediately before its own draw call, so two +/// renderers may legitimately assign different slots to the same handle. +/// +internal sealed class GlBindlessHandleTable +{ + private readonly Dictionary _slotByHandle = new(); + private ulong[] _handles = new ulong[64]; + private int _count; + + /// True after a call registers a handle not seen before. + public bool Dirty { get; private set; } + + /// Live prefix of assigned handles, in slot order (array index == slot). + public ReadOnlySpan Handles => _handles.AsSpan(0, _count); + + /// Returns the stable slot for a handle, registering it on first use. + public uint GetOrAdd(ulong bindlessHandle) + { + if (_slotByHandle.TryGetValue(bindlessHandle, out uint slot)) + return slot; + + if (_count == _handles.Length) + Array.Resize(ref _handles, _handles.Length * 2); + slot = (uint)_count; + _handles[_count] = bindlessHandle; + _count++; + _slotByHandle.Add(bindlessHandle, slot); + Dirty = true; + return slot; + } + + /// Call once the owning renderer has uploaded to its SSBO. + public void MarkFlushed() => Dirty = false; +} diff --git a/src/AcDream.App/Rendering/Shader.cs b/src/AcDream.App/Rendering/Shader.cs index 278a768b..b74960ae 100644 --- a/src/AcDream.App/Rendering/Shader.cs +++ b/src/AcDream.App/Rendering/Shader.cs @@ -10,16 +10,72 @@ public sealed class Shader : IDisposable public uint Program { get; private set; } public Shader(GL gl, string vertexPath, string fragmentPath) + : this(gl, vertexPath, fragmentPath, includeCommonPreamble: false) + { + } + + /// + /// Campaign V slice V2 (docs/plans/2026-07-27-vulkan-campaign.md §3.4): when + /// is true, the text of + /// Shaders/common.glsl — sitting alongside + /// — is spliced into both sources right after their leading + /// #version/#extension block. GL has no #include, so this + /// is plain string concatenation at load time rather than a GLSL-level + /// mechanism. Every existing two-argument-path caller is unaffected: the + /// convenience constructor above always passes false. + /// + public Shader(GL gl, string vertexPath, string fragmentPath, bool includeCommonPreamble) { _gl = gl; string vertexSource = File.ReadAllText(vertexPath); string fragmentSource = File.ReadAllText(fragmentPath); + if (includeCommonPreamble) + { + string? directory = Path.GetDirectoryName(vertexPath); + string commonPath = directory is null + ? "common.glsl" + : Path.Combine(directory, "common.glsl"); + string commonSource = File.ReadAllText(commonPath); + vertexSource = InjectPreamble(vertexSource, commonSource); + fragmentSource = InjectPreamble(fragmentSource, commonSource); + } Program = ShaderProgramConstruction.Build( new GlShaderProgramBuildApi(gl), vertexSource, fragmentSource); } + /// + /// Inserts right after the shader's leading + /// #version/#extension/blank-line block. GLSL requires + /// #version to be the very first statement in the source, so the + /// preamble cannot simply be prepended — it has to land after that block, + /// before the first real declaration. + /// + private static string InjectPreamble(string source, string preamble) + { + int insertAt = 0; + int lineStart = 0; + while (lineStart < source.Length) + { + int lineEnd = source.IndexOf('\n', lineStart); + if (lineEnd < 0) + lineEnd = source.Length; + string trimmed = source[lineStart..lineEnd].TrimStart(); + bool isLeadingLine = + trimmed.Length == 0 + || trimmed.StartsWith("#version", StringComparison.Ordinal) + || trimmed.StartsWith("#extension", StringComparison.Ordinal); + if (!isLeadingLine) + break; + + insertAt = lineEnd < source.Length ? lineEnd + 1 : lineEnd; + lineStart = lineEnd + 1; + } + + return string.Concat(source.AsSpan(0, insertAt), preamble, "\n", source.AsSpan(insertAt)); + } + public void Use() => _gl.UseProgram(Program); public unsafe void SetMatrix4(string name, Matrix4x4 m) diff --git a/src/AcDream.App/Rendering/Shaders/common.glsl b/src/AcDream.App/Rendering/Shaders/common.glsl new file mode 100644 index 00000000..f0234359 --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/common.glsl @@ -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] diff --git a/src/AcDream.App/Rendering/Shaders/mesh_modern.frag b/src/AcDream.App/Rendering/Shaders/mesh_modern.frag index eb737155..bd930397 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh_modern.frag +++ b/src/AcDream.App/Rendering/Shaders/mesh_modern.frag @@ -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; diff --git a/src/AcDream.App/Rendering/Shaders/mesh_modern.vert b/src/AcDream.App/Rendering/Shaders/mesh_modern.vert index 3281e19c..58d81021 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh_modern.vert +++ b/src/AcDream.App/Rendering/Shaders/mesh_modern.vert @@ -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; } diff --git a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs index d7b8acc4..4c4054a1 100644 --- a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs +++ b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs @@ -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(); @@ -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) + // --------------------------------------------------------------------------- + + /// + /// Uploads 's handles to + /// when a new one was registered since the last flush, then (re)binds it at + /// . + /// 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. + /// + private void FlushAndBindTextureTable() + { + if (_textureTableSsbo == 0) + _textureTableSsbo = TrackedGlResource.CreateBuffer(_gl, "creating EnvCell texture-table SSBO"); + + if (_textureTable.Dirty) + { + ReadOnlySpan 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; diff --git a/src/AcDream.App/Rendering/Wb/ModernRenderData.cs b/src/AcDream.App/Rendering/Wb/ModernRenderData.cs index 927631df..a3b497eb 100644 --- a/src/AcDream.App/Rendering/Wb/ModernRenderData.cs +++ b/src/AcDream.App/Rendering/Wb/ModernRenderData.cs @@ -5,14 +5,18 @@ using Chorizite.Core.Render; namespace AcDream.App.Rendering.Wb { /// /// Per-batch (draw call) data for modern rendering. - /// Consists of a bindless texture handle to a texture array and the layer index. - /// Indexed by gl_DrawIDARB in the vertex shader. + /// Consists of a texture-table slot (Campaign V slice V2; was a raw 64-bit + /// bindless handle) and the layer index within the shared/pooled array. + /// Indexed by gl_DrawIDARB in the vertex shader. Same 16-byte std430 shape + /// as mesh_modern.vert's BatchData: TextureTableIndex/Reserved/TextureIndex/ + /// Flags at offsets 0/4/8/12 — see ModernBatchDataLayoutTests. /// - [StructLayout(LayoutKind.Sequential, Pack = 8)] + [StructLayout(LayoutKind.Sequential, Pack = 4)] public struct ModernBatchData { - public ulong TextureHandle; // 8 bytes - public uint TextureIndex; // 4 bytes - public uint Padding; // 4 bytes + public uint TextureTableIndex; // 4 bytes — slot into the binding=9 handle table + public uint Reserved; // 4 bytes — pad, keeps TextureIndex/Flags at offsets 8/12 + public uint TextureIndex; // 4 bytes — layer within the texture array + public uint Flags; // 4 bytes — reserved, matches mesh_modern.vert's BatchData.flags } public struct LandblockMdiCommand { diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index b6d50e74..bb82c7e1 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -493,6 +493,17 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable private uint _instSelectionLightingSsbo; private int _instSelectionLightingSsboCapacityBytes; + // Campaign V slice V2 (2026-07-27): GL-only emulation of the eventual + // Vulkan global texture descriptor array (binding=9, + // GpuBindingModel.StorageTextureTable). A genuinely new handle is rare — + // new dat surfaces/atlases, not every frame — so this single buffer is + // NOT part of the ring-buffered DynamicBufferSet below; see + // GlBindlessHandleTable's doc comment and the campaign doc's §5.2 for why + // this table is owned here rather than by GlGpuDevice. Lazily created. + private readonly GlBindlessHandleTable _textureTable = new(); + private uint _textureTableSsbo; + private int _textureTableSsboCapacityBytes; + private sealed class DynamicBufferSet { public uint InstanceSsbo; @@ -587,18 +598,22 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable private int _transparentDrawCount; private int _transparentByteOffset; - // std430 layout: ulong TextureHandle (uvec2) at offset 0, uint TextureLayer - // at offset 8, uint Flags at offset 12. Total 16 bytes. - // Pack=8 (not 4) because std430's uvec2 requires 8-byte alignment — Pack=4 - // works today by accident (TextureHandle is the first field, so offset 0 is - // always 8-byte aligned), but adding a 4-byte field before TextureHandle - // without bumping Pack would silently misalign the GPU struct. - [StructLayout(LayoutKind.Sequential, Pack = 8)] + // Campaign V slice V2 (2026-07-27): std430 layout: uint TextureIndex at + // offset 0, uint Reserved (pad) at offset 4, uint TextureLayer at offset 8, + // uint Flags at offset 12. Total 16 bytes — unchanged from before V2, so + // every existing CPU writer's offsets are unchanged (see + // GpuBindingModel.GpuBatchDataStrideBytes). TextureIndex used to be a + // 64-bit ulong TextureHandle (an ARB_bindless_texture handle, uvec2 in + // GLSL); it is now a slot into the binding=9 handle table + // (mesh_modern.vert's BatchData.textureIndex / ACDREAM_TEXTURE_HANDLE), + // which is why the struct only needs 4-byte (not 8-byte) packing now. + [StructLayout(LayoutKind.Sequential, Pack = 4)] private struct BatchData { - public ulong TextureHandle; // bindless handle (uvec2 in GLSL) - public uint TextureLayer; - public uint Flags; + public uint TextureIndex; // slot into the binding=9 handle table + public uint Reserved; // pad — keeps TextureLayer/Flags at offsets 8/12 + public uint TextureLayer; + public uint Flags; } private readonly record struct DeferredAlphaInstance( @@ -2221,9 +2236,9 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable { _batchData[i] = new BatchData { - TextureHandle = _batchPublicScratch[i].TextureHandle, - TextureLayer = _batchPublicScratch[i].TextureLayer, - Flags = _batchPublicScratch[i].Flags, + TextureIndex = _batchPublicScratch[i].TextureIndex, + TextureLayer = _batchPublicScratch[i].TextureLayer, + Flags = _batchPublicScratch[i].Flags, }; } _opaqueDrawCount = layout.OpaqueCount; @@ -2298,6 +2313,10 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable UploadSsbo(_instLightSetSsbo, 5, ref _instLightSetSsboCapacityBytes, lp, immediateInstances * LightManager.MaxLightsPerObject * sizeof(int)); + // Campaign V slice V2 (binding=9): uploads only when ToInput registered + // a genuinely new handle this frame; otherwise just rebinds. + FlushAndBindTextureTable(); + fixed (DrawElementsIndirectCommand* cp = _indirectCommands) { UploadDynamicBuffer( @@ -2502,13 +2521,15 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable } } - private static IndirectGroupInput ToInput(InstanceGroup g) => new( + // Campaign V slice V2: instance method (not static) because it converts + // the group's raw bindless handle to a _textureTable slot. + private IndirectGroupInput ToInput(InstanceGroup g) => new( IndexCount: g.IndexCount, FirstIndex: g.FirstIndex, BaseVertex: g.BaseVertex, InstanceCount: g.InstanceCount, FirstInstance: g.FirstInstance, - TextureHandle: g.BindlessTextureHandle, + TextureIndex: _textureTable.GetOrAdd(g.BindlessTextureHandle), TextureLayer: g.TextureLayer, Translucency: g.Translucency, CullMode: g.CullMode); @@ -2961,7 +2982,8 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable GroupKey key = entry.Key; _batchData[i] = new BatchData { - TextureHandle = key.BindlessTextureHandle, + // Campaign V slice V2: table slot, not the raw handle. + TextureIndex = _textureTable.GetOrAdd(key.BindlessTextureHandle), TextureLayer = key.TextureLayer, Flags = 0, }; @@ -3010,6 +3032,12 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable _gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer, 6, _instIndoorSsbo); _gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer, 7, _instAlphaSsbo); _gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer, 8, _instSelectionLightingSsbo); + // Campaign V slice V2: already flushed/uploaded in UploadDeferredAlphaBuffers + // (this is the same non-ring buffer as the main draw path); just rebind. + _gl.BindBufferBase( + BufferTargetARB.ShaderStorageBuffer, + AcDream.App.Rendering.Gpu.GpuBindingModel.StorageTextureTable, + _textureTableSsbo); BindClipRegionBinding2(); _gl.BindVertexArray(global.VAO); _gl.BindBuffer(BufferTargetARB.DrawIndirectBuffer, _indirectBuffer); @@ -3145,6 +3173,9 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable UploadSsbo(_instSelectionLightingSsbo, 8, ref _instSelectionLightingSsboCapacityBytes, p, count * sizeof(float) * 2); UploadGlobalLights(); + // Campaign V slice V2 (binding=9): PrepareDeferredAlphaDraws registers + // handles into _textureTable above; flush/rebind before DrawPreparedAlphaBatch. + FlushAndBindTextureTable(); fixed (DrawElementsIndirectCommand* p = _indirectCommands) { @@ -3425,6 +3456,42 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable count * GlobalLightPacker.FloatsPerLight * sizeof(float)); } + /// + /// Campaign V slice V2: uploads 's handles to + /// when a new one was registered since the + /// last flush (by or ), + /// then (re)binds it at . + /// A genuinely new handle is rare — new dat surfaces/composite overrides, + /// not every frame — so unlike the SSBOs above this is not part of the + /// ring-buffered ; see + /// 's doc comment. + /// + private unsafe void FlushAndBindTextureTable() + { + if (_textureTableSsbo == 0) + _textureTableSsbo = TrackedGlResource.CreateBuffer(_gl, "creating WB texture-table SSBO"); + + if (_textureTable.Dirty) + { + ReadOnlySpan handles = _textureTable.Handles; + int byteCount = handles.Length * sizeof(ulong); + fixed (ulong* p = handles) + { + UploadDynamicBuffer( + BufferTargetARB.ShaderStorageBuffer, + _textureTableSsbo, + ref _textureTableSsboCapacityBytes, + p, + byteCount); + } + _textureTable.MarkFlushed(); + } + _gl.BindBufferBase( + BufferTargetARB.ShaderStorageBuffer, + AcDream.App.Rendering.Gpu.GpuBindingModel.StorageTextureTable, + _textureTableSsbo); + } + /// /// Phase U.3: bind the per-cell clip-region SSBO to binding=2. Prefers the /// shared buffer (set via ); @@ -4089,6 +4156,13 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable "deleting entity fallback clip SSBO", _gl.DeleteBuffer); + AddTrackedBufferRelease( + releases, + _textureTableSsbo, + _textureTableSsboCapacityBytes, + "texture-table", + "deleting entity texture-table SSBO"); + if (!_gpuQueriesInitialized) return; for (int i = 0; i < GpuQueryRingDepth; i++) @@ -4191,6 +4265,8 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable _instAlphaSsbo = 0; _instSelectionLightingSsbo = 0; _fallbackClipRegionSsbo = 0; + _textureTableSsbo = 0; + _textureTableSsboCapacityBytes = 0; Array.Clear(_gpuQueryOpaque); Array.Clear(_gpuQueryTransparent); _gpuQueriesInitialized = false; @@ -4210,6 +4286,8 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable /// /// Public view of the per-group inputs to — used in tests. + /// Campaign V slice V2: TextureIndex is a slot into the binding=9 + /// handle table (was a raw 64-bit bindless TextureHandle). /// public readonly record struct IndirectGroupInput( int IndexCount, @@ -4217,7 +4295,7 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable int BaseVertex, int InstanceCount, int FirstInstance, - ulong TextureHandle, + uint TextureIndex, uint TextureLayer, TranslucencyKind Translucency, CullMode CullMode = CullMode.CounterClockwise); @@ -4226,12 +4304,13 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable /// Public mirror of the per-group uploaded to the SSBO. /// Tests verify the layout. Same field shape as the private BatchData. /// - [StructLayout(LayoutKind.Sequential, Pack = 8)] + [StructLayout(LayoutKind.Sequential, Pack = 4)] public struct BatchDataPublic { - public ulong TextureHandle; - public uint TextureLayer; - public uint Flags; + public uint TextureIndex; + public uint Reserved; + public uint TextureLayer; + public uint Flags; } /// Result of . @@ -4279,9 +4358,10 @@ public sealed unsafe partial class WbDrawDispatcher : IDisposable }; var bd = new BatchDataPublic { - TextureHandle = g.TextureHandle, - TextureLayer = g.TextureLayer, - Flags = 0, + TextureIndex = g.TextureIndex, + Reserved = 0, + TextureLayer = g.TextureLayer, + Flags = 0, }; if (IsOpaque(g.Translucency)) diff --git a/tests/AcDream.App.Tests/Rendering/GlBindlessHandleTableTests.cs b/tests/AcDream.App.Tests/Rendering/GlBindlessHandleTableTests.cs new file mode 100644 index 00000000..e5b5170f --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/GlBindlessHandleTableTests.cs @@ -0,0 +1,105 @@ +using AcDream.App.Rendering; +using Xunit; + +namespace AcDream.App.Tests.Rendering; + +/// +/// Campaign V slice V2 (2026-07-27): pure-CPU proof of +/// '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. +/// +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]); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Wb/ModernBatchDataLayoutTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/ModernBatchDataLayoutTests.cs new file mode 100644 index 00000000..b8e78b3b --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Wb/ModernBatchDataLayoutTests.cs @@ -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; + +/// +/// Campaign V slice V2 (2026-07-27): CPU-side proof that +/// — 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 +/// +public class ModernBatchDataLayoutTests +{ + [Fact] + public void Size_Is16Bytes_MatchingGpuBatchDataStride() + { + Assert.Equal(16, Unsafe.SizeOf()); + } + + [Fact] + public void FieldOffsets_MatchStd430Layout() + { + Assert.Equal(0, (int)Marshal.OffsetOf(nameof(ModernBatchData.TextureTableIndex))); + Assert.Equal(4, (int)Marshal.OffsetOf(nameof(ModernBatchData.Reserved))); + Assert.Equal(8, (int)Marshal.OffsetOf(nameof(ModernBatchData.TextureIndex))); + Assert.Equal(12, (int)Marshal.OffsetOf(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); + } +} diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherIndirectBuilderTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherIndirectBuilderTests.cs index 0d6f977f..61d4b366 100644 --- a/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherIndirectBuilderTests.cs +++ b/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherIndirectBuilderTests.cs @@ -20,9 +20,9 @@ public sealed class WbDrawDispatcherIndirectBuilderTests // Arrange — three groups: 2 opaque (12+1 instances) + 1 transparent (12 instances) var groups = new List { - 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 { 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 { - 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 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()); - Assert.Equal(0, (int)System.Runtime.InteropServices.Marshal.OffsetOf(nameof(WbDrawDispatcher.BatchDataPublic.TextureHandle))); + Assert.Equal(0, (int)System.Runtime.InteropServices.Marshal.OffsetOf(nameof(WbDrawDispatcher.BatchDataPublic.TextureIndex))); + Assert.Equal(4, (int)System.Runtime.InteropServices.Marshal.OffsetOf(nameof(WbDrawDispatcher.BatchDataPublic.Reserved))); Assert.Equal(8, (int)System.Runtime.InteropServices.Marshal.OffsetOf(nameof(WbDrawDispatcher.BatchDataPublic.TextureLayer))); Assert.Equal(12, (int)System.Runtime.InteropServices.Marshal.OffsetOf(nameof(WbDrawDispatcher.BatchDataPublic.Flags))); }