The dungeon/house/outdoor lights read as hard-edged blown discs ("spotlights")
because our point/spot shader used `atten = 1.0` flat inside a hard `d < range`
cutoff. The mesh.frag comment claimed this was retail-faithful ("no attenuation
inside Range... the bubble-of-light look relies on crisp boundaries", citing
r13 10.2) — that was a misread and the literal cause of the symptom.
Verified against the decomp (not guessed): calc_point_light (0x0059c8b0, the
PER-VERTEX point-light path that lights static walls) scales each light's
contribution by (1 - dist/falloff_eff) — a LINEAR ramp that fades to exactly 0
at the edge, eliminating the hard disc. falloff_eff = Falloff * static_light_factor,
and static_light_factor = 1.3 (0x00820e24), NOT the 1.5 config_hardware_light
rangeAdjust (that 1.5 is the D3D-dynamic path for moving objects, a different
path). The Ghidra port (acclient.c:808639) is more garbled — BN pseudo-C is the
oracle here; the exact normalization factor + a half-Lambert wrap (0.5*dist+N*L)
are x87-obscured (same artifact class as GetPowerBarLevel) and left unported.
Changes:
- mesh_modern.frag + mesh.frag: replace flat atten with clamp(1 - d/range, 0, 1);
Range now carries falloff_eff so the ramp fades to 0 at the cutoff. Fix the
false "no attenuation / crisp bubble" comment in mesh.frag.
- LightInfoLoader: Range = Falloff * 1.3 (static_light_factor), was * 1.5.
- LightManager: correct the stale class doc comment (Tick is now nearest-8
allocation-free partial-select with NO viewer-range slack filter).
- divergence register: AP-16 updated (slack filter removed), AP-35 added
(per-pixel vs per-vertex Gouraud; dropped half-Lambert wrap + normalization).
- test: LightingHookSinkTests Range 8*1.3 = 10.4.
Build + 20 lighting tests green. Visual gate pending (game-wide lighting change:
dungeon torches, house candles, outdoor braziers).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
129 lines
5.3 KiB
GLSL
129 lines
5.3 KiB
GLSL
#version 430 core
|
||
#extension GL_ARB_bindless_texture : require
|
||
|
||
in vec3 vNormal;
|
||
in vec2 vTexCoord;
|
||
in vec3 vWorldPos;
|
||
in flat uvec2 vTextureHandle;
|
||
in flat uint vTextureLayer;
|
||
|
||
// 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;
|
||
|
||
// SceneLighting UBO — IDENTICAL layout to mesh_instanced.frag binding=1.
|
||
struct Light {
|
||
vec4 posAndKind;
|
||
vec4 dirAndRange;
|
||
vec4 colorAndIntensity;
|
||
vec4 coneAngleEtc;
|
||
};
|
||
layout(std140, binding = 1) uniform SceneLighting {
|
||
Light uLights[8];
|
||
vec4 uCellAmbient;
|
||
vec4 uFogParams;
|
||
vec4 uFogColor;
|
||
vec4 uCameraAndTime;
|
||
};
|
||
|
||
vec3 accumulateLights(vec3 N, vec3 worldPos) {
|
||
vec3 lit = uCellAmbient.xyz;
|
||
int activeLights = int(uCellAmbient.w);
|
||
for (int i = 0; i < 8; ++i) {
|
||
if (i >= activeLights) break;
|
||
int kind = int(uLights[i].posAndKind.w);
|
||
vec3 Lcol = uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w;
|
||
if (kind == 0) {
|
||
vec3 Ldir = -uLights[i].dirAndRange.xyz;
|
||
float ndl = max(0.0, dot(N, Ldir));
|
||
lit += Lcol * ndl;
|
||
} else {
|
||
vec3 toL = uLights[i].posAndKind.xyz - worldPos;
|
||
float d = length(toL);
|
||
float range = uLights[i].dirAndRange.w;
|
||
if (d < range && range > 1e-3) {
|
||
vec3 Ldir = toL / max(d, 1e-4);
|
||
float ndl = max(0.0, dot(N, Ldir));
|
||
// Retail per-vertex point-light ramp (calc_point_light 0x0059c8b0,
|
||
// line 0x0059c9a2): contribution scales by (1 - dist/falloff_eff), a
|
||
// LINEAR fade to exactly 0 at the edge. That is what makes a torch a
|
||
// smooth glow that blends into the ambient instead of a flat disc with
|
||
// a hard edge — the dungeon/house/outdoor "spotlight" look (#133 A7).
|
||
// falloff_eff = Falloff * static_light_factor (1.3, 0x00820e24) is folded
|
||
// into the shader Range (dirAndRange.w) by LightInfoLoader, so the ramp
|
||
// denominator is just Range and fades to 0 exactly at the cutoff.
|
||
float atten = clamp(1.0 - d / max(range, 1e-3), 0.0, 1.0);
|
||
if (kind == 2) {
|
||
float cos_edge = cos(uLights[i].coneAngleEtc.x * 0.5);
|
||
float cos_l = dot(-Ldir, uLights[i].dirAndRange.xyz);
|
||
atten *= (cos_l > cos_edge) ? 1.0 : 0.0;
|
||
}
|
||
lit += Lcol * ndl * atten;
|
||
}
|
||
}
|
||
}
|
||
return lit;
|
||
}
|
||
|
||
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() {
|
||
sampler2DArray tex = sampler2DArray(vTextureHandle);
|
||
vec4 color = texture(tex, 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;
|
||
}
|
||
|
||
vec3 N = normalize(vNormal);
|
||
vec3 lit = accumulateLights(N, vWorldPos);
|
||
|
||
// 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);
|
||
FragColor = vec4(rgb, color.a);
|
||
}
|