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>
This commit is contained in:
parent
d2b8a51426
commit
aa94cedc38
3 changed files with 93 additions and 41 deletions
|
|
@ -84,7 +84,10 @@ vec3 accumulateLights(vec3 N, vec3 worldPos) {
|
||||||
float cos_l = dot(-Ldir, uLights[i].dirAndRange.xyz);
|
float cos_l = dot(-Ldir, uLights[i].dirAndRange.xyz);
|
||||||
atten *= (cos_l > cos_edge) ? 1.0 : 0.0;
|
atten *= (cos_l > cos_edge) ? 1.0 : 0.0;
|
||||||
}
|
}
|
||||||
lit += Lcol * ndl * atten;
|
// 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
in vec3 vNormal;
|
in vec3 vNormal;
|
||||||
in vec2 vTexCoord;
|
in vec2 vTexCoord;
|
||||||
in vec3 vWorldPos;
|
in vec3 vWorldPos;
|
||||||
|
in vec3 vLit; // A7: per-vertex Gouraud lighting (ambient + capped lights), from mesh_modern.vert
|
||||||
in flat uvec2 vTextureHandle;
|
in flat uvec2 vTextureHandle;
|
||||||
in flat uint vTextureLayer;
|
in flat uint vTextureLayer;
|
||||||
|
|
||||||
|
|
@ -31,44 +32,11 @@ layout(std140, binding = 1) uniform SceneLighting {
|
||||||
vec4 uCameraAndTime;
|
vec4 uCameraAndTime;
|
||||||
};
|
};
|
||||||
|
|
||||||
vec3 accumulateLights(vec3 N, vec3 worldPos) {
|
// A7 (2026-06-15): per-vertex lighting moved to mesh_modern.vert (Gouraud) to match
|
||||||
vec3 lit = uCellAmbient.xyz;
|
// retail's fixed-function per-vertex T&L — a per-pixel evaluation made a hard "spotlight"
|
||||||
int activeLights = int(uCellAmbient.w);
|
// pool. The SceneLighting UBO above is still declared here for fog (uFogParams/uFogColor/
|
||||||
for (int i = 0; i < 8; ++i) {
|
// uCameraAndTime) + the lightning-flash bump; its uLights[]/uCellAmbient are now consumed
|
||||||
if (i >= activeLights) break;
|
// in the vertex shader. The std140 layout must stay identical to the vert + the CPU upload.
|
||||||
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) {
|
vec3 applyFog(vec3 lit, vec3 worldPos) {
|
||||||
int mode = int(uFogParams.w);
|
int mode = int(uFogParams.w);
|
||||||
|
|
@ -114,8 +82,8 @@ void main() {
|
||||||
if (color.a < 0.05) discard;
|
if (color.a < 0.05) discard;
|
||||||
}
|
}
|
||||||
|
|
||||||
vec3 N = normalize(vNormal);
|
// Per-vertex Gouraud lighting from the vertex shader (ambient + capped lights).
|
||||||
vec3 lit = accumulateLights(N, vWorldPos);
|
vec3 lit = vLit;
|
||||||
|
|
||||||
// Lightning flash — additive scene bump (matches mesh_instanced.frag).
|
// Lightning flash — additive scene bump (matches mesh_instanced.frag).
|
||||||
lit += uFogParams.z * vec3(0.6, 0.6, 0.75);
|
lit += uFogParams.z * vec3(0.6, 0.6, 0.75);
|
||||||
|
|
|
||||||
|
|
@ -96,9 +96,89 @@ uniform mat4 uViewProjection;
|
||||||
// uDrawIDOffset pattern in BaseObjectRenderManager.cs line 845.
|
// uDrawIDOffset pattern in BaseObjectRenderManager.cs line 845.
|
||||||
uniform int uDrawIDOffset;
|
uniform int uDrawIDOffset;
|
||||||
|
|
||||||
|
// 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, 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) {
|
||||||
|
// Directional (sun): forward points 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 — FAITHFUL port of calc_point_light (0x0059c8b0) via our
|
||||||
|
// verified LightBake.PointContribution (LightBake.cs:46-77). D = light −
|
||||||
|
// vertex, used UN-normalised (length = dist); N is the unit vertex normal.
|
||||||
|
// (A7 2026-06-15 #2: the prior model was a simplification — plain
|
||||||
|
// max(0,N·L) × linear(1−d/range) — which gave a harsher terminator and a
|
||||||
|
// flatter falloff than retail. The two terms below are the fix.)
|
||||||
|
vec3 toL = uLights[i].posAndKind.xyz - worldPos; // D (un-normalised)
|
||||||
|
float distsq = dot(toL, toL);
|
||||||
|
float d = sqrt(distsq);
|
||||||
|
float range = uLights[i].dirAndRange.w; // falloff_eff = Falloff × 1.3
|
||||||
|
if (d < range && range > 1e-4) {
|
||||||
|
// Half-Lambert WRAP: (1/1.5)·(N·D + 0.5·d). D is un-normalised so
|
||||||
|
// N·D = d·cosθ; the +0.5·d bias lets a face angled AWAY from the torch
|
||||||
|
// still catch light — retail's soft terminator. wrap≤0 = fully shadowed
|
||||||
|
// (retail early-out at 0x0059c8b0). TwoLpr=1.5, WrapBias=0.5.
|
||||||
|
float wrap = (1.0 / 1.5) * (dot(N, toL) + 0.5 * d);
|
||||||
|
if (wrap > 0.0) {
|
||||||
|
// NORM branch (the distance-cube term): beyond 1 m, divide by
|
||||||
|
// distsq·d ≈ inverse-square (soft far halo); within 1 m, divide by
|
||||||
|
// d only, to dodge a near singularity. This is the "punchy near,
|
||||||
|
// soft far" shape the flat linear ramp was flattening.
|
||||||
|
float norm = (distsq > 1.0) ? (distsq * d) : d;
|
||||||
|
float intensity = uLights[i].colorAndIntensity.w;
|
||||||
|
float scale = (1.0 - d / range) * intensity * (wrap / 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(uLights[i].coneAngleEtc.x * 0.5);
|
||||||
|
float cos_l = dot(-Ldir, uLights[i].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
|
||||||
|
// (dat torch intensity ~100 would saturate). Summed lit clamped in frag.
|
||||||
|
vec3 baseCol = uLights[i].colorAndIntensity.xyz;
|
||||||
|
lit += min(scale * baseCol, baseCol);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lit;
|
||||||
|
}
|
||||||
|
|
||||||
out vec3 vNormal;
|
out vec3 vNormal;
|
||||||
out vec2 vTexCoord;
|
out vec2 vTexCoord;
|
||||||
out vec3 vWorldPos;
|
out vec3 vWorldPos;
|
||||||
|
out vec3 vLit; // A7: per-vertex Gouraud lighting (ambient + capped lights)
|
||||||
out flat uvec2 vTextureHandle;
|
out flat uvec2 vTextureHandle;
|
||||||
out flat uint vTextureLayer;
|
out flat uint vTextureLayer;
|
||||||
|
|
||||||
|
|
@ -123,6 +203,7 @@ void main() {
|
||||||
|
|
||||||
vWorldPos = worldPos.xyz;
|
vWorldPos = worldPos.xyz;
|
||||||
vNormal = normalize(mat3(model) * aNormal);
|
vNormal = normalize(mat3(model) * aNormal);
|
||||||
|
vLit = accumulateLights(vNormal, vWorldPos); // A7: per-vertex Gouraud lighting
|
||||||
vTexCoord = aTexCoord;
|
vTexCoord = aTexCoord;
|
||||||
|
|
||||||
BatchData b = Batches[uDrawIDOffset + gl_DrawIDARB];
|
BatchData b = Batches[uDrawIDOffset + gl_DrawIDARB];
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue