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);