diff --git a/src/AcDream.App/Rendering/Shaders/mesh.frag b/src/AcDream.App/Rendering/Shaders/mesh.frag index 45fe4e7f..f2e879ae 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh.frag +++ b/src/AcDream.App/Rendering/Shaders/mesh.frag @@ -84,7 +84,10 @@ vec3 accumulateLights(vec3 N, vec3 worldPos) { float cos_l = dot(-Ldir, uLights[i].dirAndRange.xyz); 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); } } } diff --git a/src/AcDream.App/Rendering/Shaders/mesh_modern.frag b/src/AcDream.App/Rendering/Shaders/mesh_modern.frag index 040e15b2..4f344369 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh_modern.frag +++ b/src/AcDream.App/Rendering/Shaders/mesh_modern.frag @@ -4,6 +4,7 @@ in vec3 vNormal; in vec2 vTexCoord; in vec3 vWorldPos; +in vec3 vLit; // A7: per-vertex Gouraud lighting (ambient + capped lights), from mesh_modern.vert in flat uvec2 vTextureHandle; in flat uint vTextureLayer; @@ -31,44 +32,11 @@ layout(std140, binding = 1) uniform SceneLighting { 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; -} +// A7 (2026-06-15): per-vertex lighting moved to mesh_modern.vert (Gouraud) to match +// retail's fixed-function per-vertex T&L — a per-pixel evaluation made a hard "spotlight" +// pool. The SceneLighting UBO above is still declared here for fog (uFogParams/uFogColor/ +// uCameraAndTime) + the lightning-flash bump; its uLights[]/uCellAmbient are now consumed +// in the vertex shader. The std140 layout must stay identical to the vert + the CPU upload. vec3 applyFog(vec3 lit, vec3 worldPos) { int mode = int(uFogParams.w); @@ -114,8 +82,8 @@ void main() { if (color.a < 0.05) discard; } - vec3 N = normalize(vNormal); - vec3 lit = accumulateLights(N, vWorldPos); + // Per-vertex Gouraud lighting from the vertex shader (ambient + capped lights). + vec3 lit = vLit; // Lightning flash — additive scene bump (matches mesh_instanced.frag). lit += uFogParams.z * vec3(0.6, 0.6, 0.75); diff --git a/src/AcDream.App/Rendering/Shaders/mesh_modern.vert b/src/AcDream.App/Rendering/Shaders/mesh_modern.vert index ce4378ac..fa150cbc 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh_modern.vert +++ b/src/AcDream.App/Rendering/Shaders/mesh_modern.vert @@ -96,9 +96,89 @@ uniform mat4 uViewProjection; // uDrawIDOffset pattern in BaseObjectRenderManager.cs line 845. 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 vec2 vTexCoord; out vec3 vWorldPos; +out vec3 vLit; // A7: per-vertex Gouraud lighting (ambient + capped lights) out flat uvec2 vTextureHandle; out flat uint vTextureLayer; @@ -123,6 +203,7 @@ void main() { vWorldPos = worldPos.xyz; vNormal = normalize(mat3(model) * aNormal); + vLit = accumulateLights(vNormal, vWorldPos); // A7: per-vertex Gouraud lighting vTexCoord = aTexCoord; BatchData b = Batches[uDrawIDOffset + gl_DrawIDARB];