acdream/src/AcDream.App/Rendering/Shaders/mesh_modern.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

97 lines
4 KiB
GLSL
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#version 430 core
#extension GL_ARB_bindless_texture : require
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;
// 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;
};
// 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);
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;
}
// 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);
// 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);
}