Keep both atmospheric vertex receivers on retail's exact authored, unnormalized uLights direction in every shadow-gate state while preserving the selected celestial direction for fragment shadow projection and volumetrics. Regenerate the affected SPIR-V and amend IA-24. Proof adds committed-SPIR-V dataflow checks, parent plain/pipeline source pins, and a real Vulkan mesh+terrain pixel witness owned by Lane=Vulkan. Mutation first failures (all restored): 1. Restoring the mesh shadow/celestial branch failed CommittedProductionAtmosphericReceivers_KeepAuthoredLightAcrossShadowGate at line 27: expected Pixel 128/128/128/255, actual 0/0/0/255. 2. Restoring the terrain shadow/celestial branch failed the same witness at line 28: expected Pixel 128/128/128/255, actual 0/0/0/255. 3. Normalizing the authored mesh direction failed AssertAuthoredHalfIntensity at line 277: expected 126..129, actual 255. 4. Removing atmospheric_volumetric.frag's celestial xyz use failed ProductionShadowAndVolumetricModules_StillNormalizeTheCelestialProjectionDirection at line 61: Assert.Single found no matching member-5 access. 5. Restoring IA-24's old celestial-base-light claim failed BuiltInAndDeclaredShadowGraphsUseTheSameTypedPriorVisibilitySelector at line 1482: the authored unnormalized uLights sole-base-light assertion was absent.
360 lines
18 KiB
GLSL
360 lines
18 KiB
GLSL
#version 430 core
|
||
#extension GL_ARB_shader_draw_parameters : require
|
||
|
||
#include "directional_shadow_common.glsl"
|
||
#include "atmospheric_common.glsl"
|
||
#include "foliage_wind.glsl"
|
||
|
||
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 global texture table (set 2,
|
||
// injected by tools/ShaderCompiler/VulkanGlslPreamble.cs — see
|
||
// ACDREAM_TEXTURE_HANDLE/ACDREAM_SAMPLE_ARRAY) which main() below forwards to
|
||
// the fragment stage. 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 global texture table
|
||
float surfaceOpacity; // authored material alpha (not base texture alpha)
|
||
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[];
|
||
};
|
||
|
||
// S3 review fix round 1 (F5): the per-cell screen-space gl_ClipDistance gate
|
||
// (Phase U.3's binding=2 CellClip region SSBO) is deleted — the CPU-side
|
||
// routing that could ever select a non-zero slot for an instance
|
||
// (WbDrawDispatcher's per-frame clip-routing arming call) had ZERO production callers, so every
|
||
// instance has always mapped to slot 0 (no-clip) in every shipped build; a
|
||
// shader-side clip test against a table that only ever holds the reserved
|
||
// no-clip slot clips nothing. binding=3 — LOCAL per-submission slot index,
|
||
// zero-based independently of the shared binding=0 world-transform arena —
|
||
// stays declared (the CPU side still writes it, always 0) but is no longer
|
||
// read here; the instance-buffer layout it occupies is S5's to revisit.
|
||
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 — LOCAL per-submission light SET: MaxLightsPerObject(8) int
|
||
// indices per instance INTO gLights[] (-1 = unused slot), addressed from
|
||
// local instance zero even when binding=0 begins with a shared shadow prefix.
|
||
// 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: LOCAL per-submission "indoor" flag, 1 per instance. 1 = object
|
||
// parented to an EnvCell (skip the sun — retail's useSunlight==0 interior
|
||
// stage); 0 = outdoor object (gets the sun). It is indexed from local zero,
|
||
// independently of binding=0's shared world-transform prefix.
|
||
// 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: LOCAL per-submission opacity multiplier, 1 per instance, indexed from
|
||
// local zero independently of binding=0's shared transform arena. 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 LOCAL vec2 per OBJECT instance:
|
||
// 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[];
|
||
};
|
||
|
||
layout(std430, binding = 9) readonly buffer InstanceDetailCategoryBuf {
|
||
uint instanceDetailCategory[];
|
||
};
|
||
|
||
uniform mat4 uViewProjection;
|
||
// Absolute transform prefix in the shared shadow/world pose arena. Every
|
||
// parallel per-instance array remains local to this submission, so only the
|
||
// transform lookup keeps the absolute index.
|
||
uniform uint uTextureIndexB;
|
||
|
||
// 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 accumulateAmbientLocalLights(
|
||
vec3 N,
|
||
vec3 worldPos,
|
||
int instanceIndex,
|
||
out vec3 directionalLit)
|
||
{
|
||
vec3 lit = uCellAmbient.xyz;
|
||
directionalLit = vec3(0.0);
|
||
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
|
||
// Campaign OVERHAUL S5 #469: retail owns one authored outdoor
|
||
// directional-light channel (SkyDesc::GetLighting ->
|
||
// LScape::set_landscape_lighting -> Render::world_lights.sunlight).
|
||
// Keep the EXACT plain mesh_modern.vert direction in every shadow
|
||
// gate state: negate uLights[].dirAndRange.xyz and preserve its
|
||
// authored, unnormalized magnitude. The selected celestial source
|
||
// remains pack-only projection input in the fragment receiver and
|
||
// volumetric shaders; it never replaces base vertex lighting.
|
||
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));
|
||
directionalLit += 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 vAmbientLocalLit; // authored ambient + capped local/point lights
|
||
out vec3 vDirectionalLit; // authored outdoor directional sun, shadowable
|
||
// 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;
|
||
out flat uint vReceivesDirectionalShadow;
|
||
out flat float vSurfaceOpacity;
|
||
out flat uint vBatchFlags;
|
||
out flat uint vDetailCategory;
|
||
|
||
void main() {
|
||
int transformIndex = gl_BaseInstanceARB + gl_InstanceID;
|
||
int instanceIndex = transformIndex - int(uTextureIndexB);
|
||
mat4 model = Instances[transformIndex].transform;
|
||
vOpacityMultiplier = instanceAlpha[instanceIndex]; // #188
|
||
vSelectionLighting = (uLightingMode == 0)
|
||
? instanceSelectionLighting[instanceIndex]
|
||
: vec2(0.0, 1.0);
|
||
|
||
BatchData b = Batches[uDrawIDOffset + gl_DrawIDARB];
|
||
|
||
vec4 worldPos = model * vec4(aPosition, 1.0);
|
||
// Campaign VM VM6: weather-driven foliage sway. acdreamFoliageDisplace is
|
||
// a no-op unless b.flags carries the cutout (0x2) or trunk (0x4)
|
||
// classification bit — see foliage_wind.glsl. Applied before gl_Position
|
||
// so lighting and the fragment stage both see the displaced position; the
|
||
// four directional-shadow caster vertex shaders call the identical
|
||
// include so the shadow moves with the same vertex.
|
||
worldPos.xyz = acdreamFoliageDisplace(
|
||
worldPos.xyz,
|
||
model[3].xyz,
|
||
b.flags,
|
||
uAtmosphereClockWind,
|
||
uAtmosphereWindAmplitude);
|
||
gl_Position = uViewProjection * worldPos;
|
||
|
||
vWorldPos = worldPos.xyz;
|
||
vNormal = normalize(mat3(model) * aNormal);
|
||
vAmbientLocalLit = accumulateAmbientLocalLights(
|
||
vNormal,
|
||
vWorldPos,
|
||
instanceIndex,
|
||
vDirectionalLit);
|
||
// EnvCell-parented objects keep the authored indoor result. The separate
|
||
// EnvCell shell renderer never selects this receiver variant at all.
|
||
vReceivesDirectionalShadow = (uLightingMode == 0
|
||
&& instanceIndoor[instanceIndex] == 0u)
|
||
? 1u
|
||
: 0u;
|
||
vTexCoord = aTexCoord;
|
||
|
||
// 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.
|
||
// (b was fetched earlier, before worldPos, so acdreamFoliageDisplace
|
||
// could read its flags — Campaign VM VM6.)
|
||
vTextureIndex = b.textureIndex;
|
||
vTextureLayer = b.textureLayer;
|
||
vSurfaceOpacity = b.surfaceOpacity;
|
||
vBatchFlags = b.flags;
|
||
vDetailCategory = instanceDetailCategory[instanceIndex];
|
||
}
|