feat(render): Campaign V slice V2c - particle texture-index migration

Completes Campaign V slice V2 by moving both particle render paths -
billboard (particle.vert/.frag) and mesh-emitter (particle_mesh.vert/.frag) -
from raw 64-bit ARB_bindless_texture handles to binding=9 handle-table
indices, matching V2a's mesh path and V2b's terrain path.

Particles differ from both prior sub-slices in HOW the handle reaches the
shader:

- Billboard particles carry it as a per-INSTANCE vertex attribute (not an
  SSBO batch or a per-draw uniform), because each particle can use a
  different texture within one instanced draw. aTextureHandle (location 6,
  uvec2) became aTextureIndex (uint); particle.vert looks it up via
  ACDREAM_TEXTURE_HANDLE and reconstructs the SAME uvec2 into vTextureHandle
  exactly as before, so particle.frag - including its zero-handle check for
  the procedural circle fallback - needed no change at all. The per-instance
  ABI struct BillboardGpuInstance shrank by 4 bytes (one uint slot instead of
  two uint handle halves); ParticleBindlessInstanceTests updated for the new
  68-byte layout and the vertex attribute declaration text.

- Mesh-emitter particles carry it as a per-draw uniform (uTextureHandle,
  uvec2) exactly like terrain's pattern from V2b: one texture per draw call,
  set right before it. Became uTextureIndex (uint) + the same
  ACDREAM_TEXTURE_HANDLE lookup.

ParticleRenderer owns its own GlBindlessHandleTable and binding=9 SSBO,
independent of the other three renderers' tables, created eagerly in the
constructor alongside the other GL resources it already creates there. Unlike
the other three renderers, flushing/binding the table happens immediately
before EVERY individual draw call (four call sites: immediate billboard,
immediate mesh, and both halves of the deferred/prepared RetailAlphaQueue
path) rather than once per pipeline-state switch - a run of consecutive
mesh-particle sub-batches can register a new handle partway through (each
sub-batch has its own texture), and the table must be current for each one,
not just the first.

TextureCache's particle-texture cache (AcquireParticleTexture,
StandaloneBindlessTextureCache) needed no change: it only ever hands back a
raw ulong handle, and both ParticleGfxInfo.TextureHandle and
ParticleInstance.TextureHandle keep carrying that raw value - the table
lookup is added exactly where each path already converts its handle into
GPU-visible state (WriteBillboardGpuInstance and the two ProgramUniform
call sites).

Coverage caveat (flagged per the campaign doc's slice table): the offline
pixel gate's fixed outdoor view has no particles in frame, so it does not
exercise this slice - it only confirms nothing else regressed. This change
is correspondingly kept strictly mechanical (indirection only, no logic
change), but it still needs a user visual check with live particle emitters
before being trusted as pixel-identical.

Gate: dotnet build -c Release green, dotnet test tests/AcDream.App.Tests
-c Release green (3843 passed / 3 skipped on a clean run - one unrelated
pre-existing flaky allocation test, UiDatFontTests, failed once and passed
on immediate re-run in isolation and in the full suite, confirmed unrelated
to this change), and tools/run-offline-pixel-gate.ps1 passed against the
V2b commit's build with a 4.62e-05 differing-pixel fraction (a tripwire
only, per the coverage caveat above). 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 16:02:59 +02:00
parent 1f1f6c088b
commit a85743f70d
4 changed files with 130 additions and 32 deletions

View file

@ -67,9 +67,11 @@ public sealed unsafe class ParticleRenderer : IDisposable
} }
/// <summary> /// <summary>
/// Vertex-instance ABI shared with particle.vert. The resident texture /// Vertex-instance ABI shared with particle.vert. Campaign V slice V2c
/// handle is carried per particle so ordered particles using different /// (2026-07-27): TextureHandleLow/High (the split halves of a raw 64-bit
/// textures remain one instanced draw when their blend mode matches. /// 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.
/// </summary> /// </summary>
[StructLayout(LayoutKind.Sequential)] [StructLayout(LayoutKind.Sequential)]
internal struct BillboardGpuInstance internal struct BillboardGpuInstance
@ -78,8 +80,7 @@ public sealed unsafe class ParticleRenderer : IDisposable
public Vector4 AxisX; public Vector4 AxisX;
public Vector4 AxisY; public Vector4 AxisY;
public Vector4 Color; public Vector4 Color;
public uint TextureHandleLow; public uint TextureIndex;
public uint TextureHandleHigh;
} }
private readonly struct MeshParticleInstance private readonly struct MeshParticleInstance
@ -115,9 +116,21 @@ public sealed unsafe class ParticleRenderer : IDisposable
private bool _disposing; private bool _disposing;
private bool _disposed; private bool _disposed;
private readonly HashSet<uint> _meshLoadRequestedThisFrame = new(); private readonly HashSet<uint> _meshLoadRequestedThisFrame = new();
private readonly int _meshTextureHandleLoc = -1; private readonly int _meshTextureIndexLoc = -1;
private readonly int _meshTextureLayerLoc = -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 uint _quadVao;
private readonly uint _quadVbo; private readonly uint _quadVbo;
private readonly uint _quadEbo; private readonly uint _quadEbo;
@ -241,20 +254,37 @@ public sealed unsafe class ParticleRenderer : IDisposable
{ {
_shader = new Shader(_gl, _shader = new Shader(_gl,
System.IO.Path.Combine(shadersDir, "particle.vert"), 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); constructionResources.Add("particle shader", _shader.Dispose);
if (_meshAdapter?.MeshManager?.GlobalBuffer is not null) if (_meshAdapter?.MeshManager?.GlobalBuffer is not null)
{ {
_meshShader = new Shader(_gl, _meshShader = new Shader(_gl,
System.IO.Path.Combine(shadersDir, "particle_mesh.vert"), 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( constructionResources.Add(
"particle mesh shader", "particle mesh shader",
_meshShader.Dispose); _meshShader.Dispose);
_meshTextureHandleLoc = _gl.GetUniformLocation(_meshShader.Program, "uTextureHandle"); _meshTextureIndexLoc = _gl.GetUniformLocation(_meshShader.Program, "uTextureIndex");
_meshTextureLayerLoc = _gl.GetUniformLocation(_meshShader.Program, "uTextureLayer"); _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 = float[] quadVerts =
{ {
-0.5f, -0.5f, 0f, 0f, -0.5f, -0.5f, 0f, 0f,
@ -530,15 +560,15 @@ public sealed unsafe class ParticleRenderer : IDisposable
_ => BlendingFactor.OneMinusSrcAlpha, _ => BlendingFactor.OneMinusSrcAlpha,
}); });
_gl.ProgramUniform2( _gl.ProgramUniform1(
_meshShader.Program, _meshShader.Program,
_meshTextureHandleLoc, _meshTextureIndexLoc,
(uint)(batch.BindlessTextureHandle & 0xFFFFFFFFu), _textureTable.GetOrAdd(batch.BindlessTextureHandle));
(uint)(batch.BindlessTextureHandle >> 32));
_gl.ProgramUniform1(_meshShader.Program, _meshTextureLayerLoc, batch.TextureIndex); _gl.ProgramUniform1(_meshShader.Program, _meshTextureLayerLoc, batch.TextureIndex);
UploadMeshInstances(_meshRunScratch); UploadMeshInstances(_meshRunScratch);
PrepareMeshPipeline(viewProjection, global); PrepareMeshPipeline(viewProjection, global);
FlushAndBindTextureTable(); // Campaign V slice V2c (binding=9)
_gl.DrawElementsInstancedBaseVertex( _gl.DrawElementsInstancedBaseVertex(
PrimitiveType.Triangles, PrimitiveType.Triangles,
(uint)batch.IndexCount, (uint)batch.IndexCount,
@ -668,6 +698,7 @@ public sealed unsafe class ParticleRenderer : IDisposable
BlendingFactor.SrcAlpha, BlendingFactor.SrcAlpha,
key.Additive ? BlendingFactor.One : BlendingFactor.OneMinusSrcAlpha); key.Additive ? BlendingFactor.One : BlendingFactor.OneMinusSrcAlpha);
_gl.BindVertexArray(_quadVao); _gl.BindVertexArray(_quadVao);
FlushAndBindTextureTable(); // Campaign V slice V2c (binding=9)
_gl.DrawElementsInstancedBaseInstance( _gl.DrawElementsInstancedBaseInstance(
PrimitiveType.Triangles, PrimitiveType.Triangles,
6, 6,
@ -721,14 +752,14 @@ public sealed unsafe class ParticleRenderer : IDisposable
_ => BlendingFactor.OneMinusSrcAlpha, _ => BlendingFactor.OneMinusSrcAlpha,
}); });
_gl.ProgramUniform2( _gl.ProgramUniform1(
_meshShader.Program, _meshShader.Program,
_meshTextureHandleLoc, _meshTextureIndexLoc,
(uint)(batch.BindlessTextureHandle & 0xFFFFFFFFu), _textureTable.GetOrAdd(batch.BindlessTextureHandle));
(uint)(batch.BindlessTextureHandle >> 32));
_gl.ProgramUniform1(_meshShader.Program, _meshTextureLayerLoc, batch.TextureIndex); _gl.ProgramUniform1(_meshShader.Program, _meshTextureLayerLoc, batch.TextureIndex);
_gl.BindVertexArray(_meshVao); _gl.BindVertexArray(_meshVao);
FlushAndBindTextureTable(); // Campaign V slice V2c (binding=9)
_gl.DrawElementsInstancedBaseVertexBaseInstance( _gl.DrawElementsInstancedBaseVertexBaseInstance(
PrimitiveType.Triangles, PrimitiveType.Triangles,
(uint)batch.IndexCount, (uint)batch.IndexCount,
@ -1003,6 +1034,7 @@ public sealed unsafe class ParticleRenderer : IDisposable
PersistActiveDynamicBufferCapacities(); PersistActiveDynamicBufferCapacities();
PrepareBillboardPipeline(viewProjection); PrepareBillboardPipeline(viewProjection);
FlushAndBindTextureTable(); // Campaign V slice V2c (binding=9)
_gl.DrawElementsInstanced(PrimitiveType.Triangles, 6, DrawElementsType.UnsignedInt, (void*)0, (uint)instances.Count); _gl.DrawElementsInstanced(PrimitiveType.Triangles, 6, DrawElementsType.UnsignedInt, (void*)0, (uint)instances.Count);
} }
@ -1025,7 +1057,9 @@ public sealed unsafe class ParticleRenderer : IDisposable
PersistActiveDynamicBufferCapacities(); 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, ref BillboardGpuInstance destination,
ParticleInstance particle) ParticleInstance particle)
{ {
@ -1039,8 +1073,7 @@ public sealed unsafe class ParticleRenderer : IDisposable
((particle.ColorArgb >> 8) & 0xFF) / 255f, ((particle.ColorArgb >> 8) & 0xFF) / 255f,
(particle.ColorArgb & 0xFF) / 255f, (particle.ColorArgb & 0xFF) / 255f,
((particle.ColorArgb >> 24) & 0xFF) / 255f), ((particle.ColorArgb >> 24) & 0xFF) / 255f),
TextureHandleLow = (uint)(particle.TextureHandle & 0xFFFFFFFFu), TextureIndex = _textureTable.GetOrAdd(particle.TextureHandle),
TextureHandleHigh = (uint)(particle.TextureHandle >> 32),
}; };
} }
@ -1123,10 +1156,12 @@ public sealed unsafe class ParticleRenderer : IDisposable
_gl.EnableVertexAttribArray(5); _gl.EnableVertexAttribArray(5);
_gl.VertexAttribPointer(5, 4, VertexAttribPointerType.Float, false, instanceStride, (void*)(12 * sizeof(float))); _gl.VertexAttribPointer(5, 4, VertexAttribPointerType.Float, false, instanceStride, (void*)(12 * sizeof(float)));
_gl.VertexAttribDivisor(5, 1); _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.EnableVertexAttribArray(6);
_gl.VertexAttribIPointer( _gl.VertexAttribIPointer(
6, 6,
2, 1,
VertexAttribIType.UnsignedInt, VertexAttribIType.UnsignedInt,
instanceStride, instanceStride,
(void*)(16 * sizeof(float))); (void*)(16 * sizeof(float)));
@ -1217,6 +1252,48 @@ public sealed unsafe class ParticleRenderer : IDisposable
_gl.BufferSubData(BufferTargetARB.ArrayBuffer, 0, (nuint)byteCount, data); _gl.BufferSubData(BufferTargetARB.ArrayBuffer, 0, (nuint)byteCount, data);
} }
/// <summary>
/// Campaign V slice V2c: uploads <see cref="_textureTable"/>'s handles to
/// <see cref="_textureTableSsbo"/> when a new one was registered since the
/// last flush (by <see cref="WriteBillboardGpuInstance"/> or either mesh
/// draw site's <c>_textureTable.GetOrAdd</c>), then (re)binds it at
/// <see cref="AcDream.App.Rendering.Gpu.GpuBindingModel.StorageTextureTable"/>.
/// 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.
/// </summary>
private void FlushAndBindTextureTable()
{
if (_textureTable.Dirty)
{
ReadOnlySpan<ulong> 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) private void ApplyMeshCullMode(CullMode mode)
{ {
_gl.FrontFace(FrontFaceDirection.CW); _gl.FrontFace(FrontFaceDirection.CW);
@ -1557,6 +1634,12 @@ public sealed unsafe class ParticleRenderer : IDisposable
6L * sizeof(uint), 6L * sizeof(uint),
"quad-ebo", "quad-ebo",
"deleting particle 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++) for (int frame = 0; frame < _dynamicBufferSetsByFrame.Length; frame++)
{ {
@ -1654,6 +1737,8 @@ public sealed unsafe class ParticleRenderer : IDisposable
_meshInstanceVbo = 0; _meshInstanceVbo = 0;
_instanceVboCapacityBytes = 0; _instanceVboCapacityBytes = 0;
_meshInstanceVboCapacityBytes = 0; _meshInstanceVboCapacityBytes = 0;
_textureTableSsbo = 0;
_textureTableSsboCapacityBytes = 0;
_particleGfxInfoByEmitter.Clear(); _particleGfxInfoByEmitter.Clear();
_particleGfxInfoByGfxObj.Clear(); _particleGfxInfoByGfxObj.Clear();
_geometryKindByGfxObj.Clear(); _geometryKindByGfxObj.Clear();

View file

@ -9,7 +9,10 @@ layout(location = 2) in vec4 aCenter;
layout(location = 3) in vec4 aAxisX; layout(location = 3) in vec4 aAxisX;
layout(location = 4) in vec4 aAxisY; layout(location = 4) in vec4 aAxisY;
layout(location = 5) in vec4 aColor; 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; uniform mat4 uViewProjection;
@ -24,6 +27,8 @@ void main() {
vTex = aTex; vTex = aTex;
vColor = aColor; 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); gl_Position = uViewProjection * vec4(world, 1.0);
} }

View file

@ -5,11 +5,13 @@ in vec2 vTexCoord;
in vec4 vColor; in vec4 vColor;
out vec4 fragColor; 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; uniform int uTextureLayer;
void main() { void main() {
sampler2DArray tex = sampler2DArray(uTextureHandle); sampler2DArray tex = sampler2DArray(ACDREAM_TEXTURE_HANDLE(uTextureIndex));
vec4 color = texture(tex, vec3(vTexCoord, float(uTextureLayer))) * vColor; vec4 color = texture(tex, vec3(vTexCoord, float(uTextureLayer))) * vColor;
if (color.a < 0.02) if (color.a < 0.02)
discard; discard;

View file

@ -8,15 +8,16 @@ public sealed class ParticleBindlessInstanceTests
[Fact] [Fact]
public void BillboardGpuInstance_MatchesVertexAttributeAbi() public void BillboardGpuInstance_MatchesVertexAttributeAbi()
{ {
Assert.Equal(72, Marshal.SizeOf<ParticleRenderer.BillboardGpuInstance>()); // 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<ParticleRenderer.BillboardGpuInstance>());
Assert.Equal( Assert.Equal(
new IntPtr(64), new IntPtr(64),
Marshal.OffsetOf<ParticleRenderer.BillboardGpuInstance>( Marshal.OffsetOf<ParticleRenderer.BillboardGpuInstance>(
nameof(ParticleRenderer.BillboardGpuInstance.TextureHandleLow))); nameof(ParticleRenderer.BillboardGpuInstance.TextureIndex)));
Assert.Equal(
new IntPtr(68),
Marshal.OffsetOf<ParticleRenderer.BillboardGpuInstance>(
nameof(ParticleRenderer.BillboardGpuInstance.TextureHandleHigh)));
} }
[Fact] [Fact]
@ -29,7 +30,12 @@ public sealed class ParticleBindlessInstanceTests
string vertex = File.ReadAllText(Path.Combine(shadersDirectory, "particle.vert")); string vertex = File.ReadAllText(Path.Combine(shadersDirectory, "particle.vert"));
string fragment = File.ReadAllText(Path.Combine(shadersDirectory, "particle.frag")); 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("flat out uvec2 vTextureHandle;", vertex);
Assert.Contains("#extension GL_ARB_bindless_texture : require", fragment); Assert.Contains("#extension GL_ARB_bindless_texture : require", fragment);
Assert.Contains("sampler2DArray(vTextureHandle)", fragment); Assert.Contains("sampler2DArray(vTextureHandle)", fragment);