acdream/src/AcDream.App/Rendering/Shaders/mesh.frag
Erik aa94cedc38 fix(render): A7 point-light shape — per-vertex Gouraud + faithful calc_point_light (wrap + norm)
The torch/point-light look was wrong two ways, both now fixed against the
named retail decomp (calc_point_light 0x0059c8b0) via our verified
LightBake.PointContribution port:

1. Per-PIXEL → per-VERTEX. accumulateLights moved from mesh_modern.frag to
   mesh_modern.vert so point lights Gouraud-interpolate across each triangle
   the way retail's fixed-function T&L does. The per-pixel eval made a tight,
   hard-edged "spotlight" pool on flat walls; per-vertex is a soft, broad
   gradient. frag now just consumes the interpolated vLit (+ fog + flash).

2. Simplified ramp → faithful calc_point_light shape. The live point/spot
   branch was max(0,N·L) × linear(1−d/range) × cap — missing two terms our
   LightBake.cs port already has:
     • half-Lambert WRAP (1/1.5)·(N·D + 0.5·d), D un-normalised — a face
       angled away from a torch still catches light (retail's soft terminator)
       instead of snapping to black.
     • distance-cube NORM branch norm = distsq>1 ? distsq·d : d — inverse-
       square-ish soft far halo + punchy near field, vs the flat linear ramp.
   Per-channel no-blowout cap (min(scale·color, color)) retained.

The per-channel cap was also added to the legacy mesh.frag for consistency.

A read-only retail-vs-acdream lighting audit (11-agent workflow) confirmed
these two as the cause of the "better but a bit off" look and cleared the
ambient/sun/terrain/color-space chain as already faithful. Remaining
confirmed divergences (per-object light selection; dungeon static vertex
bake) are filed as the next fixes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 22:27:27 +02:00

132 lines
5.5 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;
}
// Retail per-channel "no-blowout" cap (calc_point_light 0x0059c8b0): a single
// point/spot light can't push a channel past its own colour, regardless of
// intensity (~100) — kills the close-torch overblow (#93). See mesh_modern.frag.
lit += min(Lcol * ndl * atten, uLights[i].colorAndIntensity.xyz);
}
}
}
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);
}