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:
parent
1f1f6c088b
commit
a85743f70d
4 changed files with 130 additions and 32 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue