acdream/src/AcDream.App/Rendering/Shaders/mesh_modern.vert
Erik 935f4dc3d9 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>
2026-07-28 09:21:22 +02:00

337 lines
17 KiB
GLSL
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#version 430 core
#extension GL_ARB_shader_draw_parameters : require
layout(location = 0) in vec3 aPosition;
layout(location = 1) in vec3 aNormal;
layout(location = 2) in vec2 aTexCoord;
struct InstanceData {
mat4 transform;
};
// Campaign V slice V2 (2026-07-27): textureHandle (uvec2, a 64-bit
// GL_ARB_bindless_texture handle) became textureIndex (uint) plus an explicit
// pad word. textureIndex is a slot into the binding=9 handle table
// (common.glsl) which main() below forwards to the fragment stage, where slice
// V6e moved the lookup so the same source compiles for Vulkan. The pad word keeps
// 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
// (GpuBindingModel.GpuBatchDataStrideBytes).
struct BatchData {
uint textureIndex; // slot into the binding=9 handle table
uint _pad; // keeps textureLayer/flags at offsets 8/12
uint textureLayer; // layer in the shared WB or pooled composite array
uint flags; // reserved — N.5 dispatcher owns all blend state
// (glBlendFunc per pass). If a future phase wants
// shader-side per-batch additive flag (Decision 2
// fallback), encode it here as bit 0.
};
layout(std430, binding = 0) readonly buffer InstanceBuffer {
InstanceData Instances[];
};
// binding=1 here is the SSBO namespace — distinct from the UBO namespace.
// SceneLighting UBO also uses binding=1 in the fragment shader; GL keeps
// GL_SHADER_STORAGE_BUFFER and GL_UNIFORM_BUFFER binding tables separate.
// Task 10 dispatcher binds:
// glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, instanceSsbo)
// glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, batchSsbo)
// Existing SceneLightingUboBinding handles the UBO side.
layout(std430, binding = 1) readonly buffer BatchBuffer {
BatchData Batches[];
};
// === Phase U.3: per-cell screen-space clip gate (gl_ClipDistance) =============
// Two SSBOs add the clip mechanism without disturbing binding=0/1 above.
//
// binding=2 — SHARED per-frame clip regions, one CellClip per "slot". Uploaded
// ONCE per frame by ClipFrame.UploadShared (shared across WbDrawDispatcher +
// EnvCellRenderer). Slot 0 is RESERVED = no-clip (count 0 ⇒ every plane passes).
//
// binding=3 — PER-RENDERER per-instance slot index, parallel to the binding=0
// instance buffer and indexed by the IDENTICAL per-instance index
// (gl_BaseInstanceARB + gl_InstanceID). instanceClipSlot[i] selects which
// CellClip region instance i is clipped against. Default all-zeros in U.3 ⇒
// every instance maps to slot 0 ⇒ no clipping ⇒ identical render to pre-U.3.
//
// CellClip std430 layout (144 bytes/slot): a uint count + 3 pad uints (16 bytes)
// then vec4 planes[8] (8 × 16 = 128 bytes). vec4 array stride is 16 under std430.
// ClipFrame on the CPU side lays out the bytes to match exactly (verified by
// ClipFrameLayoutTests). A clip-space vertex is INSIDE iff dot(plane, gl_Position)
// >= 0 for every active plane (see ClipPlaneSet for the plane convention).
struct CellClip {
uint count;
uint _p0;
uint _p1;
uint _p2;
vec4 planes[8];
};
layout(std430, binding = 2) readonly buffer ClipRegionBuf {
CellClip clipRegions[];
};
layout(std430, binding = 3) readonly buffer ClipSlotBuf {
uint instanceClipSlot[];
};
// === Fix B (A7 #3): per-OBJECT light selection — minimize_object_lighting =====
// retail picks up-to-8 point/spot lights PER OBJECT by the object's own position
// (minimize_object_lighting 0x0054d480), so a torch always lights the wall it
// sits on, camera-INDEPENDENTLY. The previous single global nearest-8-to-CAMERA
// UBO set (LightManager.Tick) made a wall brighten as the camera approached
// (its torches swapping into the global top-8). Two SSBOs replace that for
// point/spot lights (the SUN + ambient still come from the SceneLighting UBO):
//
// binding=4 — GLOBAL point/spot light array, uploaded once per frame from
// LightManager.PointSnapshot. The index of a light here is stable for the frame.
// binding=5 — per-instance light SET: MaxLightsPerObject(8) int indices per
// instance INTO gLights[] (-1 = unused slot), parallel to the binding=0
// instance buffer and indexed by the SAME instanceIndex. WbDrawDispatcher fills
// it once per entity (the set is constant across the entity's parts/tuples).
struct GlobalLight {
vec4 posAndKind;
vec4 dirAndRange;
vec4 colorAndIntensity;
vec4 coneAngleEtc;
};
layout(std430, binding = 4) readonly buffer GlobalLightBuf {
GlobalLight gLights[];
};
layout(std430, binding = 5) readonly buffer InstanceLightSetBuf {
int instanceLightIdx[]; // 8 per instance; -1 = unused
};
// #142: per-instance "indoor" flag, 1 per instance, parallel to the binding=0
// instance buffer (same instanceIndex). 1 = object parented to an EnvCell (skip the
// sun — retail's useSunlight==0 interior stage); 0 = outdoor object (gets the sun).
// Read ONLY inside the uniform `uLightingMode == 0` branch below, so the mode-1
// (EnvCell shell) path provably never touches it — EnvCellRenderer need not bind it.
layout(std430, binding = 6) readonly buffer InstanceIndoorBuf {
uint instanceIndoor[];
};
// #188: per-instance opacity multiplier, 1 per instance, parallel to the
// binding=0 instance buffer (same instanceIndex). 1.0 = unmodified; <1.0
// while a TransparentPartHook translucency fade is in flight for the
// entity/part this instance belongs to (e.g. the "fading wall" secret-
// passage doors). Multiplied against the sampled texture alpha in
// mesh_modern.frag.
layout(std430, binding = 7) readonly buffer InstanceAlphaBuf {
float instanceAlpha[];
};
// Retail SmartBox click confirmation. One vec2 per OBJECT instance, parallel
// to binding=0: x = CMaterial luminosity, y = CMaterial diffuse. Normal
// rendering is (0,1); SmartBox alternates LOW=(0,.35) and HIGH=(.99,1).
// EnvCellRenderer uses uLightingMode=1 and deliberately never reads this
// object-only binding.
layout(std430, binding = 8) readonly buffer InstanceSelectionLightingBuf {
vec2 instanceSelectionLighting[];
};
// Core profile: redeclare gl_PerVertex so writing gl_ClipDistance[] is legal
// alongside gl_Position. The array is sized 8 to match the CellClip plane budget
// and the GL guarantee (GL_MAX_CLIP_DISTANCES >= 8). The host enables
// GL_CLIP_DISTANCE0..7 once at startup; unused planes are set to +1.0 below so
// they pass everything (no clipping) when the slot's count < 8.
out gl_PerVertex {
vec4 gl_Position;
float gl_ClipDistance[8];
};
uniform mat4 uViewProjection;
// Phase Post-A.5 (ISSUE #52, 2026-05-10): per-pass offset into Batches[].
// gl_DrawIDARB resets to 0 at the start of each glMultiDrawElementsIndirect
// call, so the transparent pass — which begins later in the indirect buffer
// — was fetching Batches[0..transparentCount) instead of its actual section
// at Batches[opaqueCount..end). The lifestone crystal (a transparent draw)
// ended up reading the FIRST OPAQUE batch's TextureHandle every frame. As
// the camera moved and the opaque front-to-back sort reordered which group
// landed at BatchData[0], the lifestone's apparent texture flickered to
// whatever was first — frequently the player character's body parts.
//
// WbDrawDispatcher.Draw sets this to 0 before the opaque MDI call and to
// _opaqueDrawCount before the transparent MDI call, matching WorldBuilder's
// uDrawIDOffset pattern in BaseObjectRenderManager.cs line 845.
uniform int uDrawIDOffset;
uniform int uLightingMode; // A7 Fix D: 0 = OBJECT (plain Lambert + sun), 1 = ENVCELL (half-Lambert wrap, no sun)
// #176 stripe-hunt isolation modes (ACDREAM_LIGHT_DEBUG, throwaway diagnostic):
// 0 = off; 1 = ambient-only vLit (all point/sun contributions killed);
// 2 = DYNAMIC point lights killed (purples + viewer fill off, statics stay);
// 3 = handled in the frag (raw vLit visualization, texture ignored).
uniform int uLightDebug;
// SceneLighting UBO — binding=1 in the UBO namespace (GL keeps the SSBO and UBO
// binding tables separate, so this coexists with the binding=1 BatchBuffer SSBO
// above). IDENTICAL std140 layout to mesh_modern.frag.
//
// A7 (2026-06-15): lighting moved from the FRAGMENT shader to HERE (per-VERTEX) so
// torch/point lights Gouraud-interpolate across each triangle the way retail's
// fixed-function T&L does (D3D DrawEnvCell vertex bake + minimize_object_lighting for
// objects). A per-PIXEL evaluation made a tight bright "spotlight" pool on flat walls;
// per-vertex spreads it into a soft, broad gradient with no hard edge.
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;
};
// Faithful calc_point_light (0x0059c8b0) contribution from ONE point/spot light —
// the wrap + norm shape, factored out so the per-object SSBO loop shares it. D =
// light vertex, used UN-normalised (length = dist); N is the unit vertex normal.
// Returns the RGB to ADD, already per-channel capped to the light's own colour.
vec3 pointContribution(vec3 N, vec3 worldPos, GlobalLight L) {
int kind = int(L.posAndKind.w);
vec3 toL = L.posAndKind.xyz - worldPos; // D (un-normalised)
float distsq = dot(toL, toL);
float d = sqrt(distsq);
float range = L.dirAndRange.w; // falloff_eff = Falloff × 1.3 (static) / × 1.5 (dynamic)
if (d >= range || range <= 1e-4) return vec3(0.0);
float intensity = L.colorAndIntensity.w;
vec3 baseCol = L.colorAndIntensity.xyz;
// #143: DYNAMIC lights (viewer fill, portal, server-object lights — flagged by
// coneAngleEtc.y==1 from GlobalLightPacker) use retail's D3D hardware attenuation
// (config_hardware_light 0x0059ad30): a POINT light is given Attenuation1=1 ⇒
// att = 1/d (inverse-LINEAR), plain Lambert N·L, hard range cutoff. That spreads
// softly across the room (the portal tint, the viewer fill) instead of the static
// bake's 1/d³ distance-cube, which makes a tight concentrated pool. No per-light
// cap — D3D accumulates then saturates, which accumulateLights does via min(pointAcc,1).
if (L.coneAngleEtc.y > 0.5) {
if (uLightDebug == 2) return vec3(0.0); // #176 stripe hunt: dynamics killed
vec3 Ldir = toL / max(d, 1e-4);
float ndl = max(0.0, dot(N, Ldir));
if (ndl <= 0.0) return vec3(0.0);
if (kind == 2) { // dynamic spot: hard cos-cone gate
if (dot(-Ldir, L.dirAndRange.xyz) <= cos(L.coneAngleEtc.x * 0.5)) return vec3(0.0);
}
return (intensity * ndl / max(d, 1e-3)) * baseCol; // att = 1/d
}
// ── STATIC dat-baked lights: retail's per-vertex bake (calc_point_light 0x0059c8b0) ──
// A7 Fix D D-3: angular term by lighting path. ENVCELL bake (mode 1) keeps the
// half-Lambert wrap (lights surfaces angled away, retail calc_point_light); OBJECT
// mode (0) uses plain Lambert max(0,N·L) so a torch BEHIND a character contributes
// nothing (retail's hardware path). toL is un-normalised (length d).
float angular = (uLightingMode == 1)
? (1.0 / 1.5) * (dot(N, toL) + 0.5 * d) // half-Lambert wrap (EnvCell bake)
: max(0.0, dot(N, toL)); // plain Lambert (object/hardware)
if (angular <= 0.0) return vec3(0.0);
// NORM branch (distance-cube): >1 m → distsq·d ≈ inverse-square soft far halo;
// <1 m → just d (dodge the near singularity). "Punchy near, soft far."
float norm = (distsq > 1.0) ? (distsq * d) : d;
float scale = (1.0 - d / range) * intensity * (angular / norm);
if (kind == 2) {
// Spotlight: hard-edged cos-cone gate layered on the point ramp.
vec3 Ldir = toL / max(d, 1e-4);
float cos_edge = cos(L.coneAngleEtc.x * 0.5);
float cos_l = dot(-Ldir, L.dirAndRange.xyz);
if (cos_l <= cos_edge) scale = 0.0;
}
// Per-channel no-blowout cap to the light's OWN colour (un-intensity-scaled):
// a single light can't push a channel past its colour. Summed lit clamped in frag.
return min(scale * baseCol, baseCol);
}
vec3 accumulateLights(vec3 N, vec3 worldPos, int instanceIndex) {
vec3 lit = uCellAmbient.xyz;
if (uLightDebug == 1) return lit; // #176 stripe hunt: ambient only
// SUN / directional — OBJECT path only (mode 0). retail's EnvCell path
// (minimize_envcell_lighting) enables only dynamic lights, NEVER the sun, so
// EnvCell walls (mode 1) get no directional sun wash (A7 Fix D D-4).
// #142: within mode 0, also skip the sun for indoor objects (ParentCellId is an
// EnvCell). This mirrors retail's per-draw-stage useSunlight toggle: the interior
// stage runs useSunlightSet(0) (PView::DrawCells 0x005a49f3), so indoor objects
// get no sun even in windowed buildings where the player's frame is not sun-killed.
if (uLightingMode == 0) {
if (instanceIndoor[instanceIndex] == 0u) { // #142: outdoor objects only get the sun
int activeLights = int(uCellAmbient.w);
for (int i = 0; i < 8; ++i) {
if (i >= activeLights) break;
if (int(uLights[i].posAndKind.w) != 0) continue; // directional only
vec3 Ldir = -uLights[i].dirAndRange.xyz;
float ndl = max(0.0, dot(N, Ldir));
lit += uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w * ndl;
}
}
}
// POINT / SPOT torches: their OWN accumulator (A7 Fix D, D-1). Retail's
// SetStaticLightingVertexColors sums the static point lights from BLACK and
// clamps the SUM to [0,1] before anything else (a baked emissive term), so a
// few warm intensity-100 torches can't push the whole pixel to white the way
// folding them into ambient+sun did. Mirrors LightBake.ComputeVertexColor
// (LightBakeConformanceTests). Per-light cap inside pointContribution is unchanged.
vec3 pointAcc = vec3(0.0);
int base = instanceIndex * 8;
for (int k = 0; k < 8; ++k) {
int gi = instanceLightIdx[base + k];
if (gi < 0) continue;
pointAcc += pointContribution(N, worldPos, gLights[gi]);
}
lit += min(pointAcc, vec3(1.0)); // clamp the torch sum on its own (retail baked emissive)
return lit; // frag still does the final min(lit, 1.0)
}
out vec3 vNormal;
out vec2 vTexCoord;
out vec3 vWorldPos;
out vec3 vLit; // A7: per-vertex Gouraud lighting (ambient + capped lights)
// 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 float vOpacityMultiplier; // #188
out flat vec2 vSelectionLighting;
void main() {
int instanceIndex = gl_BaseInstanceARB + gl_InstanceID;
mat4 model = Instances[instanceIndex].transform;
vOpacityMultiplier = instanceAlpha[instanceIndex]; // #188
vSelectionLighting = (uLightingMode == 0)
? instanceSelectionLighting[instanceIndex]
: vec2(0.0, 1.0);
vec4 worldPos = model * vec4(aPosition, 1.0);
gl_Position = uViewProjection * worldPos;
// Phase U.3: per-instance clip gate. instanceClipSlot is indexed by the
// SAME instanceIndex used for the binding=0 transform above, so the slot
// travels with the instance through the MDI BaseInstance offsets. Slot 0
// (the U.3 default) has count 0 ⇒ the second loop sets all 8 distances to
// +1.0 ⇒ nothing is clipped.
uint _slot = instanceClipSlot[instanceIndex];
CellClip _c = clipRegions[_slot];
for (uint i = 0u; i < _c.count; ++i)
gl_ClipDistance[i] = dot(_c.planes[i], gl_Position);
for (uint i = _c.count; i < 8u; ++i)
gl_ClipDistance[i] = 1.0;
vWorldPos = worldPos.xyz;
vNormal = normalize(mat3(model) * aNormal);
vLit = accumulateLights(vNormal, vWorldPos, instanceIndex); // A7: per-vertex Gouraud (per-object lights)
vTexCoord = aTexCoord;
BatchData b = Batches[uDrawIDOffset + gl_DrawIDARB];
// Campaign V slice V6e: forward the table SLOT untouched. V2 looked the
// handle up here and passed the handle; the lookup now lives at the sample
// site in mesh_modern.frag, which is the only form Vulkan can express.
vTextureIndex = b.textureIndex;
vTextureLayer = b.textureLayer;
}