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>
/// 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.
/// </summary>
[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<uint> _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);
}
/// <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)
{
_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();

View file

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

View file

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

View file

@ -8,15 +8,16 @@ public sealed class ParticleBindlessInstanceTests
[Fact]
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(
new IntPtr(64),
Marshal.OffsetOf<ParticleRenderer.BillboardGpuInstance>(
nameof(ParticleRenderer.BillboardGpuInstance.TextureHandleLow)));
Assert.Equal(
new IntPtr(68),
Marshal.OffsetOf<ParticleRenderer.BillboardGpuInstance>(
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);