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

@ -3,14 +3,17 @@
in vec2 vTex;
in vec4 vColor;
flat in uvec2 vTextureHandle;
// Campaign V slice V6e: the texture-table SLOT, not the bindless handle — see
// particle.vert. ACDREAM_TEXTURE_NONE is the untextured particle, which used to
// be spelled "the slot whose handle is zero"; a Vulkan descriptor array cannot
// be asked that question, so the answer lives in the index (common.glsl).
flat in uint vTextureIndex;
out vec4 fragColor;
void main() {
vec4 texel;
if (any(notEqual(vTextureHandle, uvec2(0)))) {
sampler2DArray particleTexture = sampler2DArray(vTextureHandle);
texel = texture(particleTexture, vec3(vTex, 0.0));
if (vTextureIndex != ACDREAM_TEXTURE_NONE) {
texel = ACDREAM_SAMPLE_ARRAY(vTextureIndex, vec3(vTex, 0.0));
} else {
vec2 d = vTex - vec2(0.5, 0.5);
float r = length(d) * 2.0;

View file

@ -18,7 +18,10 @@ uniform mat4 uViewProjection;
out vec2 vTex;
out vec4 vColor;
flat out uvec2 vTextureHandle;
// Campaign V slice V6e: was `flat uvec2 vTextureHandle`. A varying cannot carry
// a Vulkan descriptor, so the table SLOT crosses the stage boundary and
// particle.frag does the lookup at the sample site.
flat out uint vTextureIndex;
void main() {
vec3 world = aCenter.xyz
@ -27,8 +30,8 @@ void main() {
vTex = aTex;
vColor = aColor;
// Reconstruct the SAME uvec2 handle particle.frag used to receive
// directly — one binding=9 lookup, identical value downstream.
vTextureHandle = ACDREAM_TEXTURE_HANDLE(aTextureIndex);
// Slice V6e: forward the slot untouched; the lookup moved to the fragment
// stage, which is the only form Vulkan can express.
vTextureIndex = aTextureIndex;
gl_Position = uViewProjection * vec4(world, 1.0);
}

View file

@ -7,12 +7,19 @@ out vec4 fragColor;
// 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;
//
// Slice V6e renamed both onto members of the shared 96-byte push-constant block
// (GpuPushConstants), which is the only home Vulkan has for a loose uniform:
// there is no default uniform block, so `uniform uint uTextureIndex;` is not
// merely unsupported, it is unspellable. Both values are per-pass — one texture,
// one layer, for a whole mesh-particle sub-batch — which is exactly what the
// block is for. uParamA carries the layer because it is consumed as a float
// anyway; the CPU writes the same integral value it always did.
uniform uint uTextureIndexA;
uniform float uParamA;
void main() {
sampler2DArray tex = sampler2DArray(ACDREAM_TEXTURE_HANDLE(uTextureIndex));
vec4 color = texture(tex, vec3(vTexCoord, float(uTextureLayer))) * vColor;
vec4 color = ACDREAM_SAMPLE_ARRAY(uTextureIndexA, vec3(vTexCoord, uParamA)) * vColor;
if (color.a < 0.02)
discard;
fragColor = color;

View file

@ -53,25 +53,23 @@
},
{
"name": "particle",
"vulkanReady": false,
"vulkanReady": true,
"stages": [
{
"stage": "vert",
"sourceSha256": "6a6ebeaacba95e5e4e8a308ed7c4cd805b80f305650c1e9e03e2bdfc6c18f5e7",
"compiled": false,
"message": "particle.vert:85: error: \u0027assign\u0027 : cannot convert from \u0027layout( location=6) in highp uint\u0027 to \u0027layout( location=2) flat out highp 2-component vector of uint\u0027"
"sourceSha256": "9629271f8997853a3c78a3cb1ec7af02a13518d68bdaa5ae1378997da7e5ab62",
"compiled": true
},
{
"stage": "frag",
"sourceSha256": "3924ecbabf051349bc6a13e6cff370725a0832f8baf515decdb7e0394304006d",
"compiled": false,
"message": "particle.frag:62: error: \u0027sampler2DArray\u0027 : sampler-constructor requires the extension GL_ARB_bindless_texture enabled"
"sourceSha256": "bc08e4fb6f57da94d6c41d52aa81d73ab287c968214303a446c40a04d0495b40",
"compiled": true
}
]
},
{
"name": "particle_mesh",
"vulkanReady": false,
"vulkanReady": true,
"stages": [
{
"stage": "vert",
@ -80,9 +78,8 @@
},
{
"stage": "frag",
"sourceSha256": "0da368243e967388990f4f4b90e2304044af6187de45f70499a3e4ece8dfd5a8",
"compiled": false,
"message": "particle_mesh.frag:64: error: \u0027uTextureIndex\u0027 : undeclared identifier"
"sourceSha256": "73c8db72aeb44e88054dd79fd722c7f683b27a8bc03368b5201f05adffd92728",
"compiled": true
}
]
},