feat(render): V6e — move the world mesh's texture lookup to where Vulkan can express it

Campaign V slice V6e, first of three. mesh_modern is the shader every world
static, every piece of scenery and every EnvCell surface draws through, and it
was one of the four production pairs the SPIR-V toolchain still refused.

The blocker was a varying. Since V2 the vertex stage looked a batch's table slot
up in the binding=9 handle table and forwarded the resulting 64-bit
GL_ARB_bindless_texture handle to the fragment stage as a `flat uvec2`. That
works on GL because a bindless handle is just a number a shader may carry
anywhere. It cannot work on Vulkan at all: the equivalent object is a descriptor
in set 2, and a descriptor is not a value a stage can hand to another stage. So
what travels between the stages is now the SLOT — a `flat uint` — and the
fragment stage does the lookup at the point of sampling.

That relocation needs one shared idea, because the two backends disagree about
what the lookup IS. `ACDREAM_SAMPLE_ARRAY(slot, uvw)` asks the dialect-neutral
question — "sample table slot N" — and expands to
`texture(sampler2DArray(gTextureTable[slot]), uvw)` under GL and to
`texture(uTextures[nonuniformEXT(slot)], uvw)` under Vulkan. It is deliberately
a SAMPLING macro rather than a sampler-returning one: `nonuniformEXT` belongs on
the indexing expression itself, and binding the result to a local
`sampler2DArray` first is exactly where an implementation is free to drop it.
That is the same shape V6d already used for the retained UI's 2-D reads, and it
now covers the array reads the world path needs.

`ACDREAM_TEXTURE_NONE` lands alongside it, unused here and used by the next
commit. GL can ask "does this slot hold a texture" of the payload, because an
unregistered slot holds the null handle; Vulkan cannot, because set 2 is opaque
and reading an unwritten element of a partially-bound array is undefined rather
than zero. The sentinel moves that answer into the index, where both dialects
test it identically.

On GL nothing about the sampled result changes — the same slot resolves to the
same handle to the same texel. The SSBO read simply happens one stage later,
and `flat` keeps it one scalar load per primitive rather than per fragment.

Also: RenderBootstrap has been loading mesh_modern without common.glsl since V2,
which cannot have linked — `ACDREAM_UBO_SET` sits inside a layout qualifier
there. The UI Studio path is the only caller. One argument, same pair, same way
WorldRenderComposition has always loaded it.

Gates: Release build clean; App tests 4,057 passed / 3 skipped (baseline);
offline pixel gate against 95f8c25f differing fraction 3.37e-05 (~19 px of
563,200), inside the documented 15–23 px same-commit noise band and ~30x under
the 0.001 threshold. mesh_modern is the shader that gate covers most heavily,
so this is the strongest automated evidence any V6e commit gets.

Manifest: 4/9 pairs compile (debug_line, mesh_modern, ui_text, vk_probe).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-28 09:21:22 +02:00
parent 95f8c25f31
commit 935f4dc3d9
8 changed files with 80 additions and 28 deletions

View file

@ -174,9 +174,16 @@ public static class RenderBootstrap
var lightingUbo = new SceneLightingUboBinding(gl); var lightingUbo = new SceneLightingUboBinding(gl);
// --- Mesh shader (GameWindow ~1769-1771) --- // --- Mesh shader (GameWindow ~1769-1771) ---
// Campaign V slice V6e: mesh_modern has needed common.glsl since V2 —
// ACDREAM_UBO_SET appears in a layout qualifier and ACDREAM_SAMPLE_ARRAY
// at the sample site, and without the preamble both are undeclared
// identifiers, so this program has failed to link on the Studio path
// since that slice. The world composition (WorldRenderComposition) has
// always passed true; this is the same pair loaded the same way.
var meshShader = new Shader(gl, var meshShader = new Shader(gl,
Path.Combine(shaderDir, "mesh_modern.vert"), Path.Combine(shaderDir, "mesh_modern.vert"),
Path.Combine(shaderDir, "mesh_modern.frag")); Path.Combine(shaderDir, "mesh_modern.frag"),
includeCommonPreamble: true);
// --- TextureCache (GameWindow ~1774) --- // --- TextureCache (GameWindow ~1774) ---
var frameFlights = new GpuFrameFlightController(gl); var frameFlights = new GpuFrameFlightController(gl);

View file

@ -38,8 +38,36 @@ layout(std430, binding = 9) readonly buffer TextureTableBuf {
// explicit rather than folded into one sampler-returning macro) because every // explicit rather than folded into one sampler-returning macro) because every
// existing call site already follows that exact pattern and a function cannot // existing call site already follows that exact pattern and a function cannot
// return an opaque sampler type built from a runtime value in GLSL. // return an opaque sampler type built from a runtime value in GLSL.
//
// Campaign V slice V6e: this macro is GL-shaped — under Vulkan it degenerates
// to the index itself, because there is no handle to look up. Every NEW call
// site should use ACDREAM_SAMPLE_ARRAY / ACDREAM_SAMPLE_2D below, which ask the
// dialect-neutral question ("sample table slot N") instead of the GL-only one
// ("what handle does slot N hold"). The remaining direct users are the shaders
// whose port slice has not run yet.
#define ACDREAM_TEXTURE_HANDLE(idx) gTextureTable[idx] #define ACDREAM_TEXTURE_HANDLE(idx) gTextureTable[idx]
// Campaign V slice V6e: samples a table slot that holds a 2-D ARRAY texture —
// the world/particle/terrain case, as opposed to the retained UI's plain 2-D
// entries that ACDREAM_SAMPLE_2D below covers.
//
// Expressed as a SAMPLING macro rather than a sampler-returning one on purpose.
// Under Vulkan the expansion carries a `nonuniformEXT` qualifier, and that
// qualifier belongs on the indexing expression at the point of use; binding the
// result to a local `sampler2DArray` variable first is where a driver is free to
// lose it. Both dialects therefore read the texture in one expression.
#define ACDREAM_SAMPLE_ARRAY(idx, uvw) texture(sampler2DArray(gTextureTable[idx]), uvw)
// Campaign V slice V6e: the reserved "this draw has no texture" slot index.
//
// GL could ask the question directly — an unregistered slot holds the handle 0,
// so `gTextureTable[idx] == uvec2(0)` answered it. Vulkan cannot: set 2 is an
// opaque descriptor array with nothing to compare, and reading an unwritten
// element of a partially-bound array is undefined rather than zero. So the
// answer moves to the index itself, which both dialects can test identically,
// and the CPU writes this value instead of registering a null handle.
#define ACDREAM_TEXTURE_NONE 0xFFFFFFFFu
// Campaign V slice V6d: samples a table slot that holds a plain 2-D texture. // Campaign V slice V6d: samples a table slot that holds a plain 2-D texture.
// //
// The two backends disagree about what a 2-D table entry IS, and this macro is // The two backends disagree about what a 2-D table entry IS, and this macro is

View file

@ -5,7 +5,10 @@ in vec3 vNormal;
in vec2 vTexCoord; in vec2 vTexCoord;
in vec3 vWorldPos; in vec3 vWorldPos;
in vec3 vLit; // A7: per-vertex Gouraud lighting (ambient + capped lights), from mesh_modern.vert in vec3 vLit; // A7: per-vertex Gouraud lighting (ambient + capped lights), from mesh_modern.vert
in flat uvec2 vTextureHandle; // Campaign V slice V6e: the table slot, not the bindless handle — see
// mesh_modern.vert. The lookup moved here because a Vulkan varying cannot
// carry a descriptor.
in flat uint vTextureIndex;
in flat uint vTextureLayer; in flat uint vTextureLayer;
in flat float vOpacityMultiplier; // #188 in flat float vOpacityMultiplier; // #188
in flat vec2 vSelectionLighting; // x=luminosity, y=diffuse in flat vec2 vSelectionLighting; // x=luminosity, y=diffuse
@ -55,8 +58,7 @@ vec3 applyFog(vec3 lit, vec3 worldPos) {
out vec4 FragColor; out vec4 FragColor;
void main() { void main() {
sampler2DArray tex = sampler2DArray(vTextureHandle); vec4 color = ACDREAM_SAMPLE_ARRAY(vTextureIndex, vec3(vTexCoord, float(vTextureLayer)));
vec4 color = texture(tex, vec3(vTexCoord, float(vTextureLayer)));
// Two-pass alpha-test (N.5 Decision 2). // Two-pass alpha-test (N.5 Decision 2).
// A.5 T20: opaque pass writes alpha as-sampled so GL_SAMPLE_ALPHA_TO_COVERAGE // A.5 T20: opaque pass writes alpha as-sampled so GL_SAMPLE_ALPHA_TO_COVERAGE

View file

@ -12,9 +12,8 @@ struct InstanceData {
// Campaign V slice V2 (2026-07-27): textureHandle (uvec2, a 64-bit // Campaign V slice V2 (2026-07-27): textureHandle (uvec2, a 64-bit
// GL_ARB_bindless_texture handle) became textureIndex (uint) plus an explicit // GL_ARB_bindless_texture handle) became textureIndex (uint) plus an explicit
// pad word. textureIndex is a slot into the binding=9 handle table // pad word. textureIndex is a slot into the binding=9 handle table
// (ACDREAM_TEXTURE_HANDLE, common.glsl) that main() below looks up once per // (common.glsl) which main() below forwards to the fragment stage, where slice
// vertex to reconstruct the exact same uvec2 handle main() used to receive // V6e moved the lookup so the same source compiles for Vulkan. The pad word keeps
// directly — one indirection, identical value. The pad word keeps
// textureLayer/flags at their original std430 offsets (8/12), so the struct // textureLayer/flags at their original std430 offsets (8/12), so the struct
// is still 16 bytes and every existing CPU writer's layout is unchanged // is still 16 bytes and every existing CPU writer's layout is unchanged
// (GpuBindingModel.GpuBatchDataStrideBytes). // (GpuBindingModel.GpuBatchDataStrideBytes).
@ -289,7 +288,14 @@ out vec3 vNormal;
out vec2 vTexCoord; out vec2 vTexCoord;
out vec3 vWorldPos; out vec3 vWorldPos;
out vec3 vLit; // A7: per-vertex Gouraud lighting (ambient + capped lights) out vec3 vLit; // A7: per-vertex Gouraud lighting (ambient + capped lights)
out flat uvec2 vTextureHandle; // Campaign V slice V6e: was `flat uvec2 vTextureHandle` — a raw 64-bit
// GL_ARB_bindless_texture handle handed across the stage boundary. A varying
// cannot carry a Vulkan descriptor, so what travels is the table SLOT and the
// fragment stage does the lookup (see mesh_modern.frag). Under GL the value
// sampled is bit-for-bit the one the vertex stage used to forward; the SSBO
// read simply happens one stage later, and `flat` keeps it one scalar load per
// primitive rather than per fragment.
out flat uint vTextureIndex;
out flat uint vTextureLayer; out flat uint vTextureLayer;
out flat float vOpacityMultiplier; // #188 out flat float vOpacityMultiplier; // #188
out flat vec2 vSelectionLighting; out flat vec2 vSelectionLighting;
@ -323,9 +329,9 @@ void main() {
vTexCoord = aTexCoord; vTexCoord = aTexCoord;
BatchData b = Batches[uDrawIDOffset + gl_DrawIDARB]; BatchData b = Batches[uDrawIDOffset + gl_DrawIDARB];
// Campaign V slice V2: reconstruct the SAME uvec2 handle the shader used // Campaign V slice V6e: forward the table SLOT untouched. V2 looked the
// to receive directly from BatchData, now via one binding=9 table lookup. // handle up here and passed the handle; the lookup now lives at the sample
// vTextureHandle's type and every downstream frag-shader use are unchanged. // site in mesh_modern.frag, which is the only form Vulkan can express.
vTextureHandle = ACDREAM_TEXTURE_HANDLE(b.textureIndex); vTextureIndex = b.textureIndex;
vTextureLayer = b.textureLayer; vTextureLayer = b.textureLayer;
} }

View file

@ -25,31 +25,29 @@
"stage": "vert", "stage": "vert",
"sourceSha256": "c35f767ab07fa9df805f9e77f4851f517c153dd2ef2efa6d49d0c24b688e4f56", "sourceSha256": "c35f767ab07fa9df805f9e77f4851f517c153dd2ef2efa6d49d0c24b688e4f56",
"compiled": false, "compiled": false,
"message": "mesh.vert:71: error: \u0027uModel\u0027 : undeclared identifier" "message": "mesh.vert:73: error: \u0027uModel\u0027 : undeclared identifier"
}, },
{ {
"stage": "frag", "stage": "frag",
"sourceSha256": "4d6478543a9a903a3453581fa847e096aaecf01f38ebb2921572663bad8e24ea", "sourceSha256": "4d6478543a9a903a3453581fa847e096aaecf01f38ebb2921572663bad8e24ea",
"compiled": false, "compiled": false,
"message": "mesh.frag:158: error: \u0027uDiffuse\u0027 : undeclared identifier" "message": "mesh.frag:160: error: \u0027uDiffuse\u0027 : undeclared identifier"
} }
] ]
}, },
{ {
"name": "mesh_modern", "name": "mesh_modern",
"vulkanReady": false, "vulkanReady": true,
"stages": [ "stages": [
{ {
"stage": "vert", "stage": "vert",
"sourceSha256": "1ec2f4af83e73102d87997244a35b69ad5e9ece4b1ad78e2b5ece4d58fab5530", "sourceSha256": "770e6300e023bd2600e8fed52768c624d8fff2ff8d6f997f076980502d3f675b",
"compiled": false, "compiled": true
"message": "mesh_modern.vert:380: error: \u0027assign\u0027 : cannot convert from \u0027 global highp uint\u0027 to \u0027layout( location=4) flat out highp 2-component vector of uint\u0027"
}, },
{ {
"stage": "frag", "stage": "frag",
"sourceSha256": "3aea96cba6c2afc49caae7545f9e42603b6b17afa50b3254beca60f95af5d2f3", "sourceSha256": "12f2ceec9a8420bd273650e95251ca56c3bf3c3691fce1a539e4c5aea7abaabf",
"compiled": false, "compiled": true
"message": "mesh_modern.frag:106: error: \u0027sampler2DArray\u0027 : sampler-constructor requires the extension GL_ARB_bindless_texture enabled"
} }
] ]
}, },
@ -61,13 +59,13 @@
"stage": "vert", "stage": "vert",
"sourceSha256": "6a6ebeaacba95e5e4e8a308ed7c4cd805b80f305650c1e9e03e2bdfc6c18f5e7", "sourceSha256": "6a6ebeaacba95e5e4e8a308ed7c4cd805b80f305650c1e9e03e2bdfc6c18f5e7",
"compiled": false, "compiled": false,
"message": "particle.vert:83: 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" "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"
}, },
{ {
"stage": "frag", "stage": "frag",
"sourceSha256": "3924ecbabf051349bc6a13e6cff370725a0832f8baf515decdb7e0394304006d", "sourceSha256": "3924ecbabf051349bc6a13e6cff370725a0832f8baf515decdb7e0394304006d",
"compiled": false, "compiled": false,
"message": "particle.frag:60: error: \u0027sampler2DArray\u0027 : sampler-constructor requires the extension GL_ARB_bindless_texture enabled" "message": "particle.frag:62: error: \u0027sampler2DArray\u0027 : sampler-constructor requires the extension GL_ARB_bindless_texture enabled"
} }
] ]
}, },
@ -84,7 +82,7 @@
"stage": "frag", "stage": "frag",
"sourceSha256": "0da368243e967388990f4f4b90e2304044af6187de45f70499a3e4ece8dfd5a8", "sourceSha256": "0da368243e967388990f4f4b90e2304044af6187de45f70499a3e4ece8dfd5a8",
"compiled": false, "compiled": false,
"message": "particle_mesh.frag:62: error: \u0027uTextureIndex\u0027 : undeclared identifier" "message": "particle_mesh.frag:64: error: \u0027uTextureIndex\u0027 : undeclared identifier"
} }
] ]
}, },
@ -96,13 +94,13 @@
"stage": "vert", "stage": "vert",
"sourceSha256": "d338e9b03686b7baf79d5121c5c8d0f24037979cc58f203957d7bd97b02b1cc2", "sourceSha256": "d338e9b03686b7baf79d5121c5c8d0f24037979cc58f203957d7bd97b02b1cc2",
"compiled": false, "compiled": false,
"message": "sky.vert:151: error: \u0027uUvScroll\u0027 : undeclared identifier" "message": "sky.vert:153: error: \u0027uUvScroll\u0027 : undeclared identifier"
}, },
{ {
"stage": "frag", "stage": "frag",
"sourceSha256": "8084af39f65ae399c73e3ca864376ef20ba8a1c495ee4774be6a82af3872c51c", "sourceSha256": "8084af39f65ae399c73e3ca864376ef20ba8a1c495ee4774be6a82af3872c51c",
"compiled": false, "compiled": false,
"message": "sky.frag:76: error: \u0027uDiffuse\u0027 : undeclared identifier" "message": "sky.frag:78: error: \u0027uDiffuse\u0027 : undeclared identifier"
} }
] ]
}, },
@ -114,13 +112,13 @@
"stage": "vert", "stage": "vert",
"sourceSha256": "4de580ce11b8d755d3558dc49bf7ebccec54d307595d91c38b5c5d552d645c7e", "sourceSha256": "4de580ce11b8d755d3558dc49bf7ebccec54d307595d91c38b5c5d552d645c7e",
"compiled": false, "compiled": false,
"message": "terrain_modern.vert:219: error: \u0027uProjection\u0027 : undeclared identifier" "message": "terrain_modern.vert:221: error: \u0027uProjection\u0027 : undeclared identifier"
}, },
{ {
"stage": "frag", "stage": "frag",
"sourceSha256": "6003b81df6da6cbea7f00310bd956348bc7b2525345dd490b0b6a3b6428340d9", "sourceSha256": "6003b81df6da6cbea7f00310bd956348bc7b2525345dd490b0b6a3b6428340d9",
"compiled": false, "compiled": false,
"message": "terrain_modern.frag:108: error: \u0027uTexTiling\u0027 : undeclared identifier" "message": "terrain_modern.frag:110: error: \u0027uTexTiling\u0027 : undeclared identifier"
} }
] ]
}, },

View file

@ -101,6 +101,17 @@ internal static class VulkanGlslPreamble
// textures stay plain GL_TEXTURE_2D objects. // textures stay plain GL_TEXTURE_2D objects.
text.AppendLine( text.AppendLine(
"#define ACDREAM_SAMPLE_2D(idx, uv) texture(ACDREAM_TEXTURE(idx), vec3((uv), 0.0))"); "#define ACDREAM_SAMPLE_2D(idx, uv) texture(ACDREAM_TEXTURE(idx), vec3((uv), 0.0))");
// Slice V6e: the 2-D ARRAY read (world meshes, particles, terrain). GL
// reconstructs a sampler2DArray from the slot's bindless handle; Vulkan
// indexes set 2 directly. Sampling in one expression is what keeps the
// nonuniformEXT qualifier on the indexing operation itself.
text.AppendLine(
"#define ACDREAM_SAMPLE_ARRAY(idx, uvw) texture(ACDREAM_TEXTURE(idx), uvw)");
// Slice V6e: the reserved "no texture" slot. Under GL an empty slot can
// be recognised by the null handle it holds; a Vulkan descriptor array
// has nothing to compare, so the sentinel lives in the index and both
// dialects test it the same way. See common.glsl for the GL half.
text.AppendLine("#define ACDREAM_TEXTURE_NONE 0xFFFFFFFFu");
text.AppendLine(); text.AppendLine();
text.AppendLine("// §3.4 push constants: one shared 96-byte block, so switching pipelines"); text.AppendLine("// §3.4 push constants: one shared 96-byte block, so switching pipelines");
text.AppendLine("// mid-pass invalidates neither descriptors nor constants."); text.AppendLine("// mid-pass invalidates neither descriptors nor constants.");