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.2 KiB
GLSL
129 lines
5.2 KiB
GLSL
#version 430 core
|
|
in vec2 vTex;
|
|
in vec3 vWorldNormal;
|
|
in vec3 vWorldPos;
|
|
out vec4 fragColor;
|
|
|
|
uniform sampler2D uDiffuse;
|
|
|
|
// Phase 9.1: translucency kind — matches TranslucencyKind C# enum.
|
|
// 0 = Opaque — depth write+test, no blend; shader never discards
|
|
// 1 = ClipMap — alpha-key discard (doors, windows, vegetation)
|
|
// 2 = AlphaBlend — GL blending handles compositing; do NOT discard
|
|
// 3 = Additive — GL additive blending; do NOT discard
|
|
// 4 = InvAlpha — GL inverted-alpha blending; do NOT discard
|
|
uniform int uTranslucencyKind;
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
// Phase G.1+G.2: shared scene-lighting UBO (binding = 1).
|
|
//
|
|
// Layout mirrors SceneLightingUbo in C#:
|
|
// struct Light {
|
|
// vec4 posAndKind; xyz = world pos, w = kind (0=dir,1=point,2=spot)
|
|
// vec4 dirAndRange; xyz = forward, w = range (metres, hard cutoff)
|
|
// vec4 colorAndIntensity; xyz = RGB linear, w = intensity
|
|
// vec4 coneAngleEtc; x = cone (rad), yzw = reserved
|
|
// };
|
|
// layout(std140, binding = 1) uniform SceneLighting {
|
|
// Light uLights[8];
|
|
// vec4 uCellAmbient; xyz = ambient RGB, w = active count
|
|
// vec4 uFogParams; x = start, y = end, z = flash, w = mode
|
|
// vec4 uFogColor; xyz = color
|
|
// vec4 uCameraAndTime; xyz = camera pos, w = day fraction
|
|
// };
|
|
// ─────────────────────────────────────────────────────────────
|
|
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;
|
|
};
|
|
|
|
// Retail per-vertex point-light ramp (calc_point_light 0x0059c8b0): the
|
|
// contribution scales by (1 - dist/falloff_eff) — a LINEAR fade to exactly
|
|
// 0 at the edge, NOT a hard-cutoff bubble. (The prior "no attenuation inside
|
|
// Range / crisp boundaries" note was a misread; it is the literal cause of
|
|
// the #133 "spotlight" look. falloff_eff = Falloff * static_light_factor 1.3
|
|
// is folded into Range by LightInfoLoader.) Spots add a binary cos-cone test.
|
|
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) {
|
|
// Directional: "forward" is the light's direction vector
|
|
// pointing INTO the scene. N·(-forward) = light-facing.
|
|
vec3 Ldir = -uLights[i].dirAndRange.xyz;
|
|
float ndl = max(0.0, dot(N, Ldir));
|
|
lit += Lcol * ndl;
|
|
} else {
|
|
// Point / spot: falloff is a HARD bubble at Range.
|
|
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));
|
|
// calc_point_light (1 - dist/falloff_eff) linear ramp; Range already
|
|
// carries falloff_eff (Falloff * 1.3), so it fades to 0 at the cutoff.
|
|
float atten = clamp(1.0 - d / max(range, 1e-3), 0.0, 1.0);
|
|
if (kind == 2) {
|
|
// Spotlight: hard-edged cos-cone test.
|
|
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;
|
|
}
|
|
|
|
// Linear fog (r12 §5.1): mode 1 = LINEAR, 0 = off, others reserved.
|
|
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);
|
|
}
|
|
|
|
void main() {
|
|
vec4 sampled = texture(uDiffuse, vTex);
|
|
|
|
// Alpha cutout only for clip-map surfaces (doors, windows, vegetation).
|
|
if (uTranslucencyKind == 1 && sampled.a < 0.5) discard;
|
|
|
|
vec3 N = normalize(vWorldNormal);
|
|
vec3 lit = accumulateLights(N, vWorldPos);
|
|
|
|
// Lightning flash (r12 §9) — additive cold-white pulse layered on top
|
|
// of diffuse lighting.
|
|
float flash = uFogParams.z;
|
|
lit += flash * vec3(0.6, 0.6, 0.75);
|
|
|
|
// Clamp per-channel to 1.0 — matches retail (r13 §13.1).
|
|
lit = min(lit, vec3(1.0));
|
|
|
|
vec3 rgb = sampled.rgb * lit;
|
|
|
|
// Atmospheric fog — applied after lighting.
|
|
rgb = applyFog(rgb, vWorldPos);
|
|
|
|
fragColor = vec4(rgb, sampled.a);
|
|
}
|