diff --git a/src/AcDream.App/Rendering/ParticleRenderer.cs b/src/AcDream.App/Rendering/ParticleRenderer.cs index 303f1f7a..81657d9a 100644 --- a/src/AcDream.App/Rendering/ParticleRenderer.cs +++ b/src/AcDream.App/Rendering/ParticleRenderer.cs @@ -67,9 +67,11 @@ public sealed unsafe class ParticleRenderer : IDisposable } /// - /// Vertex-instance ABI shared with particle.vert. The resident texture - /// handle is carried per particle so ordered particles using different - /// textures remain one instanced draw when their blend mode matches. + /// Vertex-instance ABI shared with particle.vert. Campaign V slice V2c + /// (2026-07-27): TextureHandleLow/High (the split halves of a raw 64-bit + /// ARB_bindless_texture handle) became one TextureIndex — a slot into the + /// binding=9 handle table — so ordered particles using different textures + /// still remain one instanced draw when their blend mode matches. /// [StructLayout(LayoutKind.Sequential)] internal struct BillboardGpuInstance @@ -78,8 +80,7 @@ public sealed unsafe class ParticleRenderer : IDisposable public Vector4 AxisX; public Vector4 AxisY; public Vector4 Color; - public uint TextureHandleLow; - public uint TextureHandleHigh; + public uint TextureIndex; } private readonly struct MeshParticleInstance @@ -115,9 +116,21 @@ public sealed unsafe class ParticleRenderer : IDisposable private bool _disposing; private bool _disposed; private readonly HashSet _meshLoadRequestedThisFrame = new(); - private readonly int _meshTextureHandleLoc = -1; + private readonly int _meshTextureIndexLoc = -1; private readonly int _meshTextureLayerLoc = -1; + // Campaign V slice V2c (2026-07-27): GL-only emulation of the eventual + // Vulkan global texture descriptor array (binding=9, + // GpuBindingModel.StorageTextureTable). Owns its own table — see + // GlBindlessHandleTable's doc comment and the campaign doc's §5.2 for why + // particles don't share WbDrawDispatcher's/EnvCellRenderer's/ + // TerrainModernRenderer's tables. There is no automated pixel-gate + // coverage for particles (the offline gate's fixed outdoor view has none + // in frame), so this indirection is kept strictly mechanical. + private readonly GlBindlessHandleTable _textureTable = new(); + private uint _textureTableSsbo; + private int _textureTableSsboCapacityBytes; + private uint _quadVao; private readonly uint _quadVbo; private readonly uint _quadEbo; @@ -241,20 +254,37 @@ public sealed unsafe class ParticleRenderer : IDisposable { _shader = new Shader(_gl, System.IO.Path.Combine(shadersDir, "particle.vert"), - System.IO.Path.Combine(shadersDir, "particle.frag")); + System.IO.Path.Combine(shadersDir, "particle.frag"), + includeCommonPreamble: true); constructionResources.Add("particle shader", _shader.Dispose); if (_meshAdapter?.MeshManager?.GlobalBuffer is not null) { _meshShader = new Shader(_gl, System.IO.Path.Combine(shadersDir, "particle_mesh.vert"), - System.IO.Path.Combine(shadersDir, "particle_mesh.frag")); + System.IO.Path.Combine(shadersDir, "particle_mesh.frag"), + includeCommonPreamble: true); constructionResources.Add( "particle mesh shader", _meshShader.Dispose); - _meshTextureHandleLoc = _gl.GetUniformLocation(_meshShader.Program, "uTextureHandle"); + _meshTextureIndexLoc = _gl.GetUniformLocation(_meshShader.Program, "uTextureIndex"); _meshTextureLayerLoc = _gl.GetUniformLocation(_meshShader.Program, "uTextureLayer"); } + // Campaign V slice V2c: binding=9 texture-table SSBO (GL-only + // emulation of the eventual Vulkan descriptor array). + _textureTableSsbo = TrackedGlResource.CreateBuffer( + _gl, + "creating particle texture-table SSBO"); + RetryableGpuResourceRelease textureTableRelease = + TrackedGlResource.CreateRetryableBufferDeletion( + _gl, + _textureTableSsbo, + () => _textureTableSsboCapacityBytes, + "rolling back particle texture-table SSBO"); + constructionResources.Add( + "particle texture-table SSBO", + textureTableRelease.Run); + float[] quadVerts = { -0.5f, -0.5f, 0f, 0f, @@ -530,15 +560,15 @@ public sealed unsafe class ParticleRenderer : IDisposable _ => BlendingFactor.OneMinusSrcAlpha, }); - _gl.ProgramUniform2( + _gl.ProgramUniform1( _meshShader.Program, - _meshTextureHandleLoc, - (uint)(batch.BindlessTextureHandle & 0xFFFFFFFFu), - (uint)(batch.BindlessTextureHandle >> 32)); + _meshTextureIndexLoc, + _textureTable.GetOrAdd(batch.BindlessTextureHandle)); _gl.ProgramUniform1(_meshShader.Program, _meshTextureLayerLoc, batch.TextureIndex); UploadMeshInstances(_meshRunScratch); PrepareMeshPipeline(viewProjection, global); + FlushAndBindTextureTable(); // Campaign V slice V2c (binding=9) _gl.DrawElementsInstancedBaseVertex( PrimitiveType.Triangles, (uint)batch.IndexCount, @@ -668,6 +698,7 @@ public sealed unsafe class ParticleRenderer : IDisposable BlendingFactor.SrcAlpha, key.Additive ? BlendingFactor.One : BlendingFactor.OneMinusSrcAlpha); _gl.BindVertexArray(_quadVao); + FlushAndBindTextureTable(); // Campaign V slice V2c (binding=9) _gl.DrawElementsInstancedBaseInstance( PrimitiveType.Triangles, 6, @@ -721,14 +752,14 @@ public sealed unsafe class ParticleRenderer : IDisposable _ => BlendingFactor.OneMinusSrcAlpha, }); - _gl.ProgramUniform2( + _gl.ProgramUniform1( _meshShader.Program, - _meshTextureHandleLoc, - (uint)(batch.BindlessTextureHandle & 0xFFFFFFFFu), - (uint)(batch.BindlessTextureHandle >> 32)); + _meshTextureIndexLoc, + _textureTable.GetOrAdd(batch.BindlessTextureHandle)); _gl.ProgramUniform1(_meshShader.Program, _meshTextureLayerLoc, batch.TextureIndex); _gl.BindVertexArray(_meshVao); + FlushAndBindTextureTable(); // Campaign V slice V2c (binding=9) _gl.DrawElementsInstancedBaseVertexBaseInstance( PrimitiveType.Triangles, (uint)batch.IndexCount, @@ -1003,6 +1034,7 @@ public sealed unsafe class ParticleRenderer : IDisposable PersistActiveDynamicBufferCapacities(); PrepareBillboardPipeline(viewProjection); + FlushAndBindTextureTable(); // Campaign V slice V2c (binding=9) _gl.DrawElementsInstanced(PrimitiveType.Triangles, 6, DrawElementsType.UnsignedInt, (void*)0, (uint)instances.Count); } @@ -1025,7 +1057,9 @@ public sealed unsafe class ParticleRenderer : IDisposable PersistActiveDynamicBufferCapacities(); } - private static void WriteBillboardGpuInstance( + // Campaign V slice V2c: instance method (not static) because it converts + // the particle's raw bindless handle to a _textureTable slot. + private void WriteBillboardGpuInstance( ref BillboardGpuInstance destination, ParticleInstance particle) { @@ -1039,8 +1073,7 @@ public sealed unsafe class ParticleRenderer : IDisposable ((particle.ColorArgb >> 8) & 0xFF) / 255f, (particle.ColorArgb & 0xFF) / 255f, ((particle.ColorArgb >> 24) & 0xFF) / 255f), - TextureHandleLow = (uint)(particle.TextureHandle & 0xFFFFFFFFu), - TextureHandleHigh = (uint)(particle.TextureHandle >> 32), + TextureIndex = _textureTable.GetOrAdd(particle.TextureHandle), }; } @@ -1123,10 +1156,12 @@ public sealed unsafe class ParticleRenderer : IDisposable _gl.EnableVertexAttribArray(5); _gl.VertexAttribPointer(5, 4, VertexAttribPointerType.Float, false, instanceStride, (void*)(12 * sizeof(float))); _gl.VertexAttribDivisor(5, 1); + // Campaign V slice V2c: one uint table slot (was uvec2 low/high + // handle halves) — BillboardGpuInstance shrank by 4 bytes. _gl.EnableVertexAttribArray(6); _gl.VertexAttribIPointer( 6, - 2, + 1, VertexAttribIType.UnsignedInt, instanceStride, (void*)(16 * sizeof(float))); @@ -1217,6 +1252,48 @@ public sealed unsafe class ParticleRenderer : IDisposable _gl.BufferSubData(BufferTargetARB.ArrayBuffer, 0, (nuint)byteCount, data); } + /// + /// Campaign V slice V2c: uploads 's handles to + /// when a new one was registered since the + /// last flush (by or either mesh + /// draw site's _textureTable.GetOrAdd), then (re)binds it at + /// . + /// Called immediately before every draw call rather than once per pipeline + /// switch: a run of consecutive mesh-particle sub-batches can register a + /// new handle partway through, and the table must be current for each one. + /// + private void FlushAndBindTextureTable() + { + if (_textureTable.Dirty) + { + ReadOnlySpan handles = _textureTable.Handles; + int byteCount = handles.Length * sizeof(ulong); + fixed (ulong* p = handles) + { + _gl.BindBuffer(BufferTargetARB.ShaderStorageBuffer, _textureTableSsbo); + if (_textureTableSsboCapacityBytes < byteCount) + { + int grown = DynamicBufferCapacity.Grow(_textureTableSsboCapacityBytes, byteCount); + TrackedGlResource.AllocateBufferStorage( + _gl, + GLEnum.ShaderStorageBuffer, + _textureTableSsbo, + _textureTableSsboCapacityBytes, + grown, + GLEnum.DynamicDraw, + "growing particle texture-table SSBO"); + _textureTableSsboCapacityBytes = grown; + } + _gl.BufferSubData(BufferTargetARB.ShaderStorageBuffer, 0, (nuint)byteCount, p); + } + _textureTable.MarkFlushed(); + } + _gl.BindBufferBase( + BufferTargetARB.ShaderStorageBuffer, + AcDream.App.Rendering.Gpu.GpuBindingModel.StorageTextureTable, + _textureTableSsbo); + } + private void ApplyMeshCullMode(CullMode mode) { _gl.FrontFace(FrontFaceDirection.CW); @@ -1557,6 +1634,12 @@ public sealed unsafe class ParticleRenderer : IDisposable 6L * sizeof(uint), "quad-ebo", "deleting particle quad EBO"); + AddTrackedBufferRelease( + releases, + _textureTableSsbo, + _textureTableSsboCapacityBytes, + "texture-table", + "deleting particle texture-table SSBO"); for (int frame = 0; frame < _dynamicBufferSetsByFrame.Length; frame++) { @@ -1654,6 +1737,8 @@ public sealed unsafe class ParticleRenderer : IDisposable _meshInstanceVbo = 0; _instanceVboCapacityBytes = 0; _meshInstanceVboCapacityBytes = 0; + _textureTableSsbo = 0; + _textureTableSsboCapacityBytes = 0; _particleGfxInfoByEmitter.Clear(); _particleGfxInfoByGfxObj.Clear(); _geometryKindByGfxObj.Clear(); diff --git a/src/AcDream.App/Rendering/Shaders/particle.vert b/src/AcDream.App/Rendering/Shaders/particle.vert index 3b948e78..11162a38 100644 --- a/src/AcDream.App/Rendering/Shaders/particle.vert +++ b/src/AcDream.App/Rendering/Shaders/particle.vert @@ -9,7 +9,10 @@ layout(location = 2) in vec4 aCenter; layout(location = 3) in vec4 aAxisX; layout(location = 4) in vec4 aAxisY; layout(location = 5) in vec4 aColor; -layout(location = 6) in uvec2 aTextureHandle; +// Campaign V slice V2c (2026-07-27): was uvec2 aTextureHandle (a raw +// ARB_bindless_texture handle); now a slot into the binding=9 handle table +// (ACDREAM_TEXTURE_HANDLE, common.glsl). +layout(location = 6) in uint aTextureIndex; uniform mat4 uViewProjection; @@ -24,6 +27,8 @@ void main() { vTex = aTex; vColor = aColor; - vTextureHandle = aTextureHandle; + // Reconstruct the SAME uvec2 handle particle.frag used to receive + // directly — one binding=9 lookup, identical value downstream. + vTextureHandle = ACDREAM_TEXTURE_HANDLE(aTextureIndex); gl_Position = uViewProjection * vec4(world, 1.0); } diff --git a/src/AcDream.App/Rendering/Shaders/particle_mesh.frag b/src/AcDream.App/Rendering/Shaders/particle_mesh.frag index 7ed4fb24..d880c95d 100644 --- a/src/AcDream.App/Rendering/Shaders/particle_mesh.frag +++ b/src/AcDream.App/Rendering/Shaders/particle_mesh.frag @@ -5,11 +5,13 @@ in vec2 vTexCoord; in vec4 vColor; out vec4 fragColor; -uniform uvec2 uTextureHandle; +// Campaign V slice V2c (2026-07-27): was uvec2 uTextureHandle (a raw +// ARB_bindless_texture handle); now a slot into the binding=9 handle table. +uniform uint uTextureIndex; uniform int uTextureLayer; void main() { - sampler2DArray tex = sampler2DArray(uTextureHandle); + sampler2DArray tex = sampler2DArray(ACDREAM_TEXTURE_HANDLE(uTextureIndex)); vec4 color = texture(tex, vec3(vTexCoord, float(uTextureLayer))) * vColor; if (color.a < 0.02) discard; diff --git a/tests/AcDream.App.Tests/Rendering/ParticleBindlessInstanceTests.cs b/tests/AcDream.App.Tests/Rendering/ParticleBindlessInstanceTests.cs index d3b1d089..0f18be54 100644 --- a/tests/AcDream.App.Tests/Rendering/ParticleBindlessInstanceTests.cs +++ b/tests/AcDream.App.Tests/Rendering/ParticleBindlessInstanceTests.cs @@ -8,15 +8,16 @@ public sealed class ParticleBindlessInstanceTests [Fact] public void BillboardGpuInstance_MatchesVertexAttributeAbi() { - Assert.Equal(72, Marshal.SizeOf()); + // Campaign V slice V2c (2026-07-27): TextureHandleLow/High (the split + // halves of a raw 64-bit ARB_bindless_texture handle, 8 bytes) became + // one TextureIndex (a binding=9 handle-table slot, 4 bytes), so the + // struct shrank by 4 bytes; TextureIndex keeps TextureHandleLow's + // former offset (64 — right after the four vec4 fields). + Assert.Equal(68, Marshal.SizeOf()); Assert.Equal( new IntPtr(64), Marshal.OffsetOf( - nameof(ParticleRenderer.BillboardGpuInstance.TextureHandleLow))); - Assert.Equal( - new IntPtr(68), - Marshal.OffsetOf( - nameof(ParticleRenderer.BillboardGpuInstance.TextureHandleHigh))); + nameof(ParticleRenderer.BillboardGpuInstance.TextureIndex))); } [Fact] @@ -29,7 +30,12 @@ public sealed class ParticleBindlessInstanceTests string vertex = File.ReadAllText(Path.Combine(shadersDirectory, "particle.vert")); string fragment = File.ReadAllText(Path.Combine(shadersDirectory, "particle.frag")); - Assert.Contains("layout(location = 6) in uvec2 aTextureHandle;", vertex); + // Campaign V slice V2c: the per-instance attribute now carries a + // binding=9 table slot, not the raw handle; particle.vert + // reconstructs the SAME uvec2 handle via ACDREAM_TEXTURE_HANDLE + // before handing it to particle.frag, so the fragment shader's half + // (extension, reconstruction, varying type/name) is untouched. + Assert.Contains("layout(location = 6) in uint aTextureIndex;", vertex); Assert.Contains("flat out uvec2 vTextureHandle;", vertex); Assert.Contains("#extension GL_ARB_bindless_texture : require", fragment); Assert.Contains("sampler2DArray(vTextureHandle)", fragment);