feat(render): Campaign V slice V6f-3 - terrain's atlas reads cross the dialect

The last thing keeping terrain_modern out of SPIR-V was how it named its two
atlases:

    #define uTerrain sampler2DArray(ACDREAM_TEXTURE_HANDLE(uTextureIndexA))
    #define uAlpha   sampler2DArray(ACDREAM_TEXTURE_HANDLE(uTextureIndexB))

`sampler2DArray(handle)` is a GL_ARB_bindless_texture construction with no
Vulkan equivalent. Vulkan's table is an opaque descriptor array in set 2; there
is no handle, so there is nothing to construct a sampler from. The ten sample
sites now go through ACDREAM_SAMPLE_ARRAY, the dialect-neutral read V6e
introduced for mesh_modern, wrapped in two shader-local macros that keep the
call sites reading as "sample the terrain atlas" rather than "index the table".

They are SAMPLING macros, not sampler-returning ones, and that is not
cosmetic. Under Vulkan the expansion carries `nonuniformEXT` on the indexing
expression, and binding the result to a local sampler2DArray first is exactly
where an implementation may drop it. The old `#define uTerrain
sampler2DArray(...)` was textually that shape, so preserving it would have
reintroduced the hazard at every use site.

On GL nothing about the sampled result changes: the same slot resolves through
the same binding=9 table to the same handle to the same texel, and the macro
expands to the identical expression the shader wrote by hand.

With this, terrain_modern compiles for Vulkan and the manifest reads 8/9. The
remaining pair is `mesh`, which the campaign doc records as having no consumer
at all - so every production shader acdream actually draws with is now
Vulkan-expressible. That closes the obligation §5 recorded against V6e ("the one
production pair still not Vulkan-expressible after V6e is terrain_modern") and
it closes the shader half of V4d's parked content.

What this does NOT do is give the Vulkan backend a world to draw. That is
reported separately with the rest of slice V6f; the shaders were the part that
could be finished, gated and landed on GL today.

Gates. Release build clean. App tests 4,073 passed / 3 skipped over four
consecutive runs. A fifth run failed only
CurrentRenderSceneOracleTests.SurfaceOverrideFingerprint_DictionaryHotPathAllocatesNothing,
an allocation-counting test over a CPU dictionary path that touches nothing in
this diff; it passes in isolation and passed in every other full run. That is
the known #250 flake class on an otherwise unchanged tree. Offline pixel gate
against fac09407: 12 differing pixels of 563,200 compared (fraction 2.13e-05) -
below even the floor of the documented 15-23 pixel band. Cumulatively, across
all three V6f commits against 7faaaa34: 22 pixels (3.91e-05, maximumChannelDelta
52), which is the same number the first commit measured on its own. Three
changes to the shader terrain draws with, and the drift has not accumulated.

No divergence-register row: the sampled result is unchanged on GL and no
retail-facing behaviour moves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-28 10:14:37 +02:00
parent fac0940711
commit 30e94da607
4 changed files with 39 additions and 24 deletions

View file

@ -101,7 +101,7 @@
},
{
"name": "terrain_modern",
"vulkanReady": false,
"vulkanReady": true,
"stages": [
{
"stage": "vert",
@ -110,9 +110,8 @@
},
{
"stage": "frag",
"sourceSha256": "dc4d38b5eb356629c83681fbc6a1d5d867792e99a8c4e659031f390333edb7b8",
"compiled": false,
"message": "terrain_modern.frag:150: error: \u0027sampler2DArray\u0027 : sampler-constructor requires the extension GL_ARB_bindless_texture enabled"
"sourceSha256": "21f41dcca4d4973ad9ee148c5433e0dae015489414c164c51bde958963f03598",
"compiled": true
}
]
},

View file

@ -5,14 +5,15 @@
// Math identical to terrain.frag (Phase 3c per-cell maskBlend3 +
// Phase G fog + lightning flash).
//
// Bindless texture handles are passed as uvec2 (low/high 32 bits) and
// reconstructed into sampler2DArray at use sites via the GLSL
// sampler-from-handle constructor. The alternative pattern —
// `uniform sampler2DArray` set via glProgramUniformHandleARB — produces
// GL_INVALID_OPERATION on at least one driver in practice (NVIDIA on
// Windows). The uvec2 + constructor pattern is what N.5's mesh_modern
// shader uses and is the documented "always works" form per the
// ARB_bindless_texture spec.
// Texture reads go through ACDREAM_SAMPLE_ARRAY (common.glsl) since slice
// V6f-3, so this source compiles for both backends. Under GL that still expands
// to the uvec2-handle + sampler2DArray-constructor pattern this shader has
// always used — the documented "always works" form per the ARB_bindless_texture
// spec, and the one that avoids the GL_INVALID_OPERATION the alternative
// (`uniform sampler2DArray` set via glProgramUniformHandleARB) produces on at
// least one driver in practice. Under Vulkan it indexes the set-2 descriptor
// array instead. The extension requirement above is dropped for Vulkan by the
// compiler's preamble, where it would be an error rather than a no-op.
in vec2 vBaseUV;
in vec3 vWorldNormal;
@ -35,8 +36,23 @@ out vec4 fragColor;
// push-constant plumbing yet, so these stay plain uniforms for now.
uniform uint uTextureIndexA;
uniform uint uTextureIndexB;
#define uTerrain sampler2DArray(ACDREAM_TEXTURE_HANDLE(uTextureIndexA))
#define uAlpha sampler2DArray(ACDREAM_TEXTURE_HANDLE(uTextureIndexB))
// Campaign V slice V6f-3: the two atlases are sampled through the
// dialect-neutral table read instead of a GL sampler-from-handle constructor.
// `sampler2DArray(handle)` is a GL_ARB_bindless_texture form with no Vulkan
// equivalent — Vulkan's table is an opaque descriptor array in set 2, and there
// is no handle to construct a sampler from. ACDREAM_SAMPLE_ARRAY asks the
// question both dialects can answer ("sample table slot N at these
// coordinates") and expands to the right thing on each.
//
// A SAMPLING macro, not a sampler-returning one, for the reason common.glsl
// records: under Vulkan the expansion carries `nonuniformEXT` on the indexing
// expression, and binding the result to a local sampler2DArray first is exactly
// where an implementation may drop that qualifier. The old `#define uTerrain
// sampler2DArray(...)` was that shape textually, so keeping it would have
// reintroduced the hazard at every use site.
#define sampleTerrain(uvw) ACDREAM_SAMPLE_ARRAY(uTextureIndexA, uvw)
#define sampleAlpha(uvw) ACDREAM_SAMPLE_ARRAY(uTextureIndexB, uvw)
// Campaign V slice V6f-2: the 36 per-layer tiling factors moved out of a loose
// `uniform float uTexTiling[36]` and into the uniform buffer GpuBindingModel
@ -97,23 +113,23 @@ vec4 combineOverlays(vec2 baseUV, vec4 pOverlay0, vec4 pOverlay1, vec4 pOverlay2
vec4 t0 = vec4(0.0), t1 = vec4(0.0), t2 = vec4(0.0);
if (h0 > 0.0) {
t0 = texture(uTerrain, vec3(baseUV * terrainTiling(pOverlay0.z), pOverlay0.z));
t0 = sampleTerrain(vec3(baseUV * terrainTiling(pOverlay0.z), pOverlay0.z));
if (pOverlay0.w >= 0.0) {
vec4 a = texture(uAlpha, vec3(pOverlay0.xy, pOverlay0.w));
vec4 a = sampleAlpha(vec3(pOverlay0.xy, pOverlay0.w));
t0.a = a.a;
}
}
if (h1 > 0.0) {
t1 = texture(uTerrain, vec3(baseUV * terrainTiling(pOverlay1.z), pOverlay1.z));
t1 = sampleTerrain(vec3(baseUV * terrainTiling(pOverlay1.z), pOverlay1.z));
if (pOverlay1.w >= 0.0) {
vec4 a = texture(uAlpha, vec3(pOverlay1.xy, pOverlay1.w));
vec4 a = sampleAlpha(vec3(pOverlay1.xy, pOverlay1.w));
t1.a = a.a;
}
}
if (h2 > 0.0) {
t2 = texture(uTerrain, vec3(baseUV * terrainTiling(pOverlay2.z), pOverlay2.z));
t2 = sampleTerrain(vec3(baseUV * terrainTiling(pOverlay2.z), pOverlay2.z));
if (pOverlay2.w >= 0.0) {
vec4 a = texture(uAlpha, vec3(pOverlay2.xy, pOverlay2.w));
vec4 a = sampleAlpha(vec3(pOverlay2.xy, pOverlay2.w));
t2.a = a.a;
}
}
@ -125,12 +141,12 @@ vec4 combineRoad(vec2 baseUV, vec4 pRoad0, vec4 pRoad1) {
float h1 = pRoad1.z < 0.0 ? 0.0 : 1.0;
vec4 result = vec4(0.0);
if (h0 > 0.0) {
result = texture(uTerrain, vec3(baseUV * terrainTiling(pRoad0.z), pRoad0.z));
result = sampleTerrain(vec3(baseUV * terrainTiling(pRoad0.z), pRoad0.z));
if (pRoad0.w >= 0.0) {
vec4 a0 = texture(uAlpha, vec3(pRoad0.xy, pRoad0.w));
vec4 a0 = sampleAlpha(vec3(pRoad0.xy, pRoad0.w));
result.a = 1.0 - a0.a;
if (h1 > 0.0 && pRoad1.w >= 0.0) {
vec4 a1 = texture(uAlpha, vec3(pRoad1.xy, pRoad1.w));
vec4 a1 = sampleAlpha(vec3(pRoad1.xy, pRoad1.w));
result.a = 1.0 - (a0.a * a1.a);
}
}
@ -152,7 +168,7 @@ vec3 applyFog(vec3 lit, vec3 worldPos) {
void main() {
vec4 baseColor = vec4(0.0);
if (vBaseTexIdx >= 0.0) {
baseColor = texture(uTerrain, vec3(vBaseUV * terrainTiling(vBaseTexIdx), vBaseTexIdx));
baseColor = sampleTerrain(vec3(vBaseUV * terrainTiling(vBaseTexIdx), vBaseTexIdx));
}
vec4 overlays = vec4(0.0);