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>
116 lines
5.2 KiB
GLSL
116 lines
5.2 KiB
GLSL
#version 430 core
|
||
#extension GL_ARB_bindless_texture : require
|
||
|
||
in vec3 vNormal;
|
||
in vec2 vTexCoord;
|
||
in vec3 vWorldPos;
|
||
in vec3 vLit; // A7: per-vertex Gouraud lighting (ambient + capped lights), from mesh_modern.vert
|
||
// 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 float vOpacityMultiplier; // #188
|
||
in flat vec2 vSelectionLighting; // x=luminosity, y=diffuse
|
||
|
||
// uRenderPass values (Phase N.5 Decision 2 — two-pass alpha-test):
|
||
// 0 = opaque pass — discard fragments with alpha < 0.95
|
||
// (lets the depth write succeed for solid pixels)
|
||
// 1 = translucent pass — covers AlphaBlend / Additive / InvAlpha;
|
||
// discard alpha >= 0.95 (already drawn opaque) and
|
||
// alpha < 0.05 (skip empty fragments — large
|
||
// transparent overdraw cost otherwise)
|
||
uniform int uRenderPass;
|
||
uniform int uLightDebug; // #176 stripe hunt (see mesh_modern.vert) — mode 3 handled here
|
||
|
||
// SceneLighting UBO — IDENTICAL layout to mesh_instanced.frag binding=1.
|
||
struct Light {
|
||
vec4 posAndKind;
|
||
vec4 dirAndRange;
|
||
vec4 colorAndIntensity;
|
||
vec4 coneAngleEtc;
|
||
};
|
||
layout(std140, ACDREAM_UBO_SET binding = 1) uniform SceneLighting {
|
||
Light uLights[8];
|
||
vec4 uCellAmbient;
|
||
vec4 uFogParams;
|
||
vec4 uFogColor;
|
||
vec4 uCameraAndTime;
|
||
};
|
||
|
||
// A7 (2026-06-15): per-vertex lighting moved to mesh_modern.vert (Gouraud) to match
|
||
// retail's fixed-function per-vertex T&L — a per-pixel evaluation made a hard "spotlight"
|
||
// pool. The SceneLighting UBO above is still declared here for fog (uFogParams/uFogColor/
|
||
// uCameraAndTime) + the lightning-flash bump; its uLights[]/uCellAmbient are now consumed
|
||
// in the vertex shader. The std140 layout must stay identical to the vert + the CPU upload.
|
||
|
||
vec3 applyFog(vec3 lit, vec3 worldPos) {
|
||
int mode = int(uFogParams.w);
|
||
if (mode == 0) return lit;
|
||
float d = length(worldPos - uCameraAndTime.xyz);
|
||
float fogStart = uFogParams.x;
|
||
float fogEnd = uFogParams.y;
|
||
float span = max(1e-3, fogEnd - fogStart);
|
||
float fog = clamp((d - fogStart) / span, 0.0, 1.0);
|
||
return mix(lit, uFogColor.xyz, fog);
|
||
}
|
||
|
||
out vec4 FragColor;
|
||
|
||
void main() {
|
||
vec4 color = ACDREAM_SAMPLE_ARRAY(vTextureIndex, vec3(vTexCoord, float(vTextureLayer)));
|
||
|
||
// Two-pass alpha-test (N.5 Decision 2).
|
||
// A.5 T20: opaque pass writes alpha as-sampled so GL_SAMPLE_ALPHA_TO_COVERAGE
|
||
// derives the MSAA sample mask from it — ClipMap foliage edges become smooth.
|
||
// Discard only fully-transparent (α < 0.05); the GPU handles coverage masking.
|
||
if (uRenderPass == 0) {
|
||
if (color.a < 0.05) discard; // opaque pass — kill truly empty only (A2C)
|
||
} else {
|
||
// Transparent pass.
|
||
//
|
||
// Phase Post-A.5 (ISSUE #52, 2026-05-10): do NOT discard α≥0.95 here.
|
||
// Native AC transparent-flagged surfaces routinely include
|
||
// effectively-opaque pixels — e.g. the Holtburg lifestone crystal core
|
||
// (surface 0x080011DE) which the spawn manifest classifies as
|
||
// transparent (batch.IsTransparent=True) but whose decoded texture
|
||
// alpha lands ≥0.95 across the visible surface. Those pixels still
|
||
// compose correctly under (SrcAlpha, 1-SrcAlpha) alpha-blending, so
|
||
// discarding them here threw away the whole crystal. The original
|
||
// N.5 §2 rationale (high-α fragments belong in the opaque pass) does
|
||
// not apply when the SURFACE is dat-flagged transparent — those
|
||
// pixels can't reach the opaque pass at all.
|
||
//
|
||
// Keep the α<0.05 short-circuit as a fragment-cost optimization
|
||
// (skip fully-empty pixels — saves blend bandwidth on alpha-keyed
|
||
// sprites with large transparent margins).
|
||
if (color.a < 0.05) discard;
|
||
}
|
||
|
||
// Per-vertex Gouraud lighting from the vertex shader (ambient + capped
|
||
// lights), passed through retail CMaterial's two click-pulse replacements:
|
||
// emissive/luminosity + diffuse * scene lighting. Normal is (0,1).
|
||
vec3 lit = vec3(vSelectionLighting.x) + vSelectionLighting.y * vLit;
|
||
|
||
// #176 stripe-hunt mode 3: show the raw per-vertex light field (texture
|
||
// ignored). Stripes visible HERE = a vertex-lighting artifact; absent =
|
||
// the pattern comes from texture/per-pixel machinery. Throwaway diagnostic.
|
||
if (uLightDebug == 3) {
|
||
FragColor = vec4(min(lit, vec3(1.0)), 1.0);
|
||
return;
|
||
}
|
||
|
||
// Lightning flash — additive scene bump (matches mesh_instanced.frag).
|
||
lit += uFogParams.z * vec3(0.6, 0.6, 0.75);
|
||
|
||
// Retail clamp per-channel to 1.0 (r13 §13.1).
|
||
lit = min(lit, vec3(1.0));
|
||
|
||
vec3 rgb = color.rgb * lit;
|
||
rgb = applyFog(rgb, vWorldPos);
|
||
// #188: multiply the FINAL alpha only — the discard thresholds above stay
|
||
// keyed on the raw sampled color.a, so the last few frames of a fade
|
||
// (multiplier crossing under 0.05) still ramp smoothly toward zero rather
|
||
// than popping invisible early against the discard cutoff.
|
||
FragColor = vec4(rgb, color.a * vOpacityMultiplier);
|
||
}
|