feat(render): V6e — both particle shaders cross the dialect

Campaign V slice V6e, second of three. Billboard particles and mesh particles
are the last two pairs blocked on the texture-table shape; sky follows.

particle takes the same treatment mesh_modern took: the `flat uvec2` handle
varying becomes a `flat uint` slot and the fragment stage samples through
ACDREAM_SAMPLE_ARRAY. What is different here is the untextured particle. The
shader used to ask "is the handle I was given zero", which GL can answer because
its emulated table stores handles; Vulkan cannot, because set 2 is an opaque
descriptor array and reading an element nobody wrote is undefined rather than
zero. So the question moves to the index: the CPU writes ACDREAM_TEXTURE_NONE
for a particle with no texture instead of interning the null handle as a table
slot, and both dialects test the same value. GL renders identically — the same
particles take the same branch to the same procedural blob — and the handle
table simply stops carrying an entry that never named a texture. A test pins the
sentinel across all three declarations of it, because a silent disagreement here
would sample slot 0xFFFFFFFF instead of drawing the blob.

particle_mesh needed no restructuring, only names. Vulkan GLSL has no default
uniform block, so `uniform uint uTextureIndex;` is not unsupported but
unspellable, and the two values are per-pass — one texture and one layer for a
whole sub-batch — which is exactly what the shared push-constant block is for.
uTextureIndex becomes uTextureIndexA; uTextureLayer becomes uParamA, which was
the spare scalar and is a natural fit because the shader converted the layer to
float anyway. The widening moved from the shader to the CPU; layers are small
integers, so the sampled value is bit-identical.

Gates: Release build clean; App tests 4,058 passed / 3 skipped (baseline 4,057
plus the sentinel drift guard). Offline pixel gate against 95f8c25f: two
captures, 29 px and 21 px of 563,200 compared (3.73e-05 and 5.15e-05), with a
same-commit control between them of 13 px and this commit measuring 14 px
against its own parent. The scene draws no particles, so this gate is a tripwire
that the world path is undisturbed, not evidence about particles.

Particles remain user-gate debt — the same debt V2c and V4e already carry, to be
paid by casting a spell in a connected session.

Manifest: 6/9 pairs compile. Remaining: mesh (legacy, no consumer), sky (next
commit), terrain_modern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-28 09:26:13 +02:00
parent 935f4dc3d9
commit 602bc9dddb
11 changed files with 126 additions and 36 deletions

View file

@ -116,6 +116,11 @@ public sealed unsafe class ParticleRenderer : IDisposable
private bool _disposing;
private bool _disposed;
private readonly HashSet<uint> _meshLoadRequestedThisFrame = new();
// Campaign V slice V6e: particle_mesh.frag's two loose uniforms were renamed
// onto members of the shared push-constant block (uTextureIndex →
// uTextureIndexA, uTextureLayer → uParamA), because Vulkan GLSL has no
// default uniform block to declare them in. Under GL they are still plain
// program uniforms set exactly as before; only the names moved.
private readonly int _meshTextureIndexLoc = -1;
private readonly int _meshTextureLayerLoc = -1;
@ -266,8 +271,8 @@ public sealed unsafe class ParticleRenderer : IDisposable
constructionResources.Add(
"particle mesh shader",
_meshShader.Dispose);
_meshTextureIndexLoc = _gl.GetUniformLocation(_meshShader.Program, "uTextureIndex");
_meshTextureLayerLoc = _gl.GetUniformLocation(_meshShader.Program, "uTextureLayer");
_meshTextureIndexLoc = _gl.GetUniformLocation(_meshShader.Program, "uTextureIndexA");
_meshTextureLayerLoc = _gl.GetUniformLocation(_meshShader.Program, "uParamA");
}
// Campaign V slice V2c: binding=9 texture-table SSBO (GL-only
@ -564,7 +569,10 @@ public sealed unsafe class ParticleRenderer : IDisposable
_meshShader.Program,
_meshTextureIndexLoc,
_textureTable.GetOrAdd(batch.BindlessTextureHandle));
_gl.ProgramUniform1(_meshShader.Program, _meshTextureLayerLoc, batch.TextureIndex);
// Slice V6e: uParamA is a float, so the layer is widened here rather
// than in the shader's float(uTextureLayer). Layers are small
// integers; the sampled value is bit-identical.
_gl.ProgramUniform1(_meshShader.Program, _meshTextureLayerLoc, (float)batch.TextureIndex);
UploadMeshInstances(_meshRunScratch);
PrepareMeshPipeline(viewProjection, global);
@ -756,7 +764,10 @@ public sealed unsafe class ParticleRenderer : IDisposable
_meshShader.Program,
_meshTextureIndexLoc,
_textureTable.GetOrAdd(batch.BindlessTextureHandle));
_gl.ProgramUniform1(_meshShader.Program, _meshTextureLayerLoc, batch.TextureIndex);
// Slice V6e: uParamA is a float, so the layer is widened here rather
// than in the shader's float(uTextureLayer). Layers are small
// integers; the sampled value is bit-identical.
_gl.ProgramUniform1(_meshShader.Program, _meshTextureLayerLoc, (float)batch.TextureIndex);
_gl.BindVertexArray(_meshVao);
FlushAndBindTextureTable(); // Campaign V slice V2c (binding=9)
@ -1057,6 +1068,22 @@ public sealed unsafe class ParticleRenderer : IDisposable
PersistActiveDynamicBufferCapacities();
}
/// <summary>
/// Campaign V slice V6e: the shader-side spelling of "this particle has no
/// texture, draw the procedural blob". It must agree with
/// <c>ACDREAM_TEXTURE_NONE</c> in <c>Shaders/common.glsl</c> and in the
/// Vulkan preamble.
///
/// <para>V2c encoded the same fact as "a table slot whose handle is zero",
/// which particle.frag could test because GL's emulated table stores the
/// handles themselves. Vulkan's table is an opaque descriptor array with
/// nothing to compare — reading an unwritten element of a partially-bound
/// array is undefined, not zero — so the fact moves into the index, where
/// both dialects test it the same way. GL renders identically: the same
/// particles take the same branch.</para>
/// </summary>
private const uint NoTextureSlot = 0xFFFFFFFFu;
// Campaign V slice V2c: instance method (not static) because it converts
// the particle's raw bindless handle to a _textureTable slot.
private void WriteBillboardGpuInstance(
@ -1073,7 +1100,9 @@ public sealed unsafe class ParticleRenderer : IDisposable
((particle.ColorArgb >> 8) & 0xFF) / 255f,
(particle.ColorArgb & 0xFF) / 255f,
((particle.ColorArgb >> 24) & 0xFF) / 255f),
TextureIndex = _textureTable.GetOrAdd(particle.TextureHandle),
TextureIndex = particle.TextureHandle == 0UL
? NoTextureSlot
: _textureTable.GetOrAdd(particle.TextureHandle),
};
}