/*
    VertaLux - lighting engine.
    PCSS shadows (variable penumbra), colored shadows (stained glass), moving
    cloud shadows on terrain, sky ambient, blue cave minimum light, held light.
    Everything is computed in linear (HDR) space.
    Requires lib/common.glsl and lib/distort.glsl to be included first.
*/

uniform sampler2D shadowtex0;
uniform sampler2D shadowtex1;
uniform sampler2D shadowcolor0;
uniform mat4 shadowModelView;
uniform mat4 shadowProjection;
uniform mat4 gbufferModelViewInverse;
uniform vec3 shadowLightPosition;
uniform vec3 sunPosition;
uniform vec3 upPosition;
uniform vec3 cameraPosition;
uniform float rainStrength;
uniform float wetness;
uniform int moonPhase;
// worldTime / worldDay are declared in lib/common.glsl (always included BEFORE
// this file) for the cloudWorldTime() helper; they also drive the star dome
// rotation (sky.glsl) and sunrise/sunset selection below.
uniform float frameTimeCounter;
uniform int heldBlockLightValue;
uniform int heldBlockLightValue2; // offhand light source
uniform vec3 fogColor;

float sunHeight() {
    return dot(normalize(sunPosition), normalize(upPosition));
}

// Height of the ACTIVE light (sun by day, moon by night - Iris swaps
// shadowLightPosition automatically). Used by godrays and caustics.
float lightHeight() {
    return dot(normalize(shadowLightPosition), normalize(upPosition));
}

// Direct light color (sun by day, moon by night)
vec3 getLightColor() {
    float sh = sunHeight();
    float dayF  = smoothstep(-0.03, 0.2, sh);
    // Dusk band is ASYMMETRIC around the horizon: below it the orange is cut
    // (end of civil twilight) so nights stay lunar rather than golden.
    float duskF = clamp(1.0 - abs(sh) * 5.0, 0.0, 1.0) * smoothstep(-0.14, -0.04, sh);
    // Moonrise: once Iris swaps the shadow light to the MOON, the dusk tint must
    // stop coloring the active light, otherwise the moon briefly wears sun color.
    duskF *= smoothstep(0.0, 0.25, dot(normalize(sunPosition), normalize(shadowLightPosition)));
    // Continuity at the Iris sun/moon swap (exactly at sh=0): without this gate
    // duskF is ~1 at the swap and the binary gate above snaps the color in one
    // frame. Here duskF reaches 0 AT the swap; the dusk peak shifts to sh~0.06
    // and the below-horizon afterglow is carried by the AMBIENT term instead
    // (getAmbientColor has no such gate).
    duskF *= smoothstep(0.0, 0.05, sh);

    // SUN_TINT_TEMP MULTIPLIES here: dividing would invert the slider direction.
    vec3 day   = vec3(1.0, 0.97 * SUN_TINT_TEMP, 0.90 * SUN_TINT_TEMP * SUN_TINT_TEMP) * 4.6 * SUN_INTENSITY;
    // Sunrise = warm golden pink, sunset = deep fire red; separated by worldTime.
    bool amG = (worldTime > 21000 || worldTime < 3000);
    // Deep hues at moderate gain: ACES pushes high-intensity dusk toward white,
    // so the gold/fire look comes from the tint, not from the multiplier.
    vec3 dusk  = (amG ? vec3(1.0, 0.46, 0.15) * 2.2 : vec3(1.0, 0.30, 0.08) * 2.3) * SUN_INTENSITY;
    // Moonlight: silvery blue
    vec3 night = vec3(0.15, 0.21, 0.38) * 0.30 * MOON_INTENSITY;

    vec3 c = mix(night, day, dayF);
    c = mix(c, dusk, duskF);
    return c * (1.0 - 0.85 * rainStrength);
}

// Ambient color coming from the sky
vec3 getAmbientColor() {
    float sh = sunHeight();
    float dayF  = smoothstep(-0.05, 0.2, sh);
    // Same asymmetric dusk band as getLightColor: no golden ambient at night.
    float duskF = clamp(1.0 - abs(sh) * 4.0, 0.0, 1.0) * smoothstep(-0.14, -0.04, sh);

    // AMBIENT_COOL (CONFIG_LIGHT): day ambient temperature - >1 cooler (R down,
    // B up), <1 warmer. Exact legacy color at 1.0; dusk/night stay untouched.
    vec3 day   = vec3(0.55 * (2.0 - AMBIENT_COOL), 0.72, 1.00 * AMBIENT_COOL) * 0.72;
    vec3 dusk  = vec3(0.85, 0.55, 0.40) * 0.52; // warm golden-hour ambient
    vec3 night = vec3(0.09, 0.13, 0.25) * 0.42; // readable lunar blue

    vec3 c = mix(night, day, dayF);
    c = mix(c, dusk, duskF * 0.7);
    return c * (1.0 - 0.45 * rainStrength) * AMBIENT_INTENSITY;
}

vec3 getTorchColor() {
    // CONFIG_LIGHT: per-channel creator dials (1.0 = exact vanilla torch tint)
    return vec3(1.0 * TORCH_R, 0.52 * TORCH_G, 0.18 * TORCH_B);
}

/*
    COLORED LIGHTS - forward bridge to the LPV field (compute chain composite.csh + flood a..d).
    v120 gbuffers cannot imageLoad, so lightImg is read through its Iris SAMPLER (lightSampler,
    declared in shaders.properties). ADDRESSING must be an EXACT copy of the world-aligned snap in
    lib/lpv.glsl (sampleLPV): cell = f(p, cameraPosition) IDENTICAL to the injection write - any
    deviation offsets the field and the tint drifts. Manual trilinear, 8 texture3D taps (Iris custom
    image samplers are NEAREST), EXCLUDING SOLID cells (flag .a in [0.5,1.5] -> weight 0, then
    renormalize) so no tint is sucked out of a wall.
    lightImg encoding: .rgb = radiance, .a = flag (0 empty, 2 source); both are readable here.
*/
#if EMISSIVE_LPV == 1
uniform sampler3D lightSampler;   // sampler for the custom image lightImg (128^3, rgba16f)

vec3 sampleLPVForward(vec3 worldRelCam, vec3 normalW) {
    float s = VOXEL_SCALE * 2.0;               // = LPV_SCALE (lib/lpv.glsl), 128^3 grid
    // Step out half an LPV cell along the normal so the sample point leaves the solid shell of
    // its own surface; otherwise all taps land inside the wall.
    vec3 p = worldRelCam + normalW * (0.5 * s);
    vec3 camBlock = floor(cameraPosition / s);  // SAME snap as lpv.glsl (ivec3 <-> floor equivalent)
    vec3 g = (p + (cameraPosition - camBlock * s)) / s + 64.0 - 0.5;   // LPV_HALF = 64
    vec3 b = floor(g);
    vec3 f = g - b;
    vec3 acc = vec3(0.0);
    float ws = 0.0;
    for (int ix = 0; ix < 2; ix++)
    for (int iy = 0; iy < 2; iy++)
    for (int iz = 0; iz < 2; iz++) {
        vec3 c = b + vec3(float(ix), float(iy), float(iz));
        if (c.x < 0.0 || c.y < 0.0 || c.z < 0.0 || c.x > 127.5 || c.y > 127.5 || c.z > 127.5) continue;
        vec4 L = texture3D(lightSampler, (c + 0.5) / 128.0);  // exact texel center (nearest-safe)
        if (L.a > 0.5 && L.a < 1.5) continue;  // SOLID cell: radiance ignored (anti-leak)
        float w = ((ix == 1) ? f.x : 1.0 - f.x)
                * ((iy == 1) ? f.y : 1.0 - f.y)
                * ((iz == 1) ? f.z : 1.0 - f.z);
        acc += L.rgb * w;
        ws  += w;
    }
    return (ws > 1e-4) ? (acc / ws) * LPV_STRENGTH : vec3(0.0);
}
#endif

/*
    SHARED CUMULUS COVERAGE (VC_TEXTURED=1) - SINGLE SOURCE OF TRUTH.
    Lives HERE, not in clouds.glsl, because lighting.glsl is ALWAYS included
    first (deferred.fsh / composite1.fsh / every gbuffers program). cloudShadow
    below AND the raymarch in lib/clouds.glsl therefore call the SAME function:
    the ground shadows cannot desync from the visible clouds any more (the old
    proxy re-typed ~15 constants by hand, which was the standing sync contract).
    Kept outside the VOLUMETRIC_CLOUDS / VC_GROUND_SHADOWS gates on purpose:
    clouds.glsl needs it even when ground shadows are off.
    The sampler declaration is guarded so clouds.glsl may repeat it harmlessly.
*/
#if VC_TEXTURED == 1
#ifndef VL_CUMULUS_BASE_DECL
#define VL_CUMULUS_BASE_DECL
uniform sampler2D vlCumulusBase;   // customTexture: 1024^2 R8, blur=true clamp=false
#endif

// World blocks spanned by ONE 1024^2 tile: 2667 x 1.5 = 4000 blocks at the
// default VC_CUMULUS_SCALE. MEASURED on cumulus_base.bin (mask at
// VC_COVERAGE=0.40): median cell chord ~51 blocks, area-weighted median ~172,
// plus a few 350-600 block systems. The map is NOT band-limited (85% of its
// variance is already gone at a 125-block lag), so nothing below may assume
// smoothness. VC_CUMULUS_SCALE keeps its meaning: larger = larger, more
// spaced clouds.
#define VL_CUM_TILE (2667.0 * VC_CUMULUS_SCALE)
// Morphological radius in blocks (140 at defaults), proportional to the tile
// so the width/height guarantees below hold at every VC_CUMULUS_SCALE.
#define VL_CUM_RAD (VL_CUM_TILE * 0.035)
// Ceiling on the column-height fraction: filled height never exceeds
// VL_CUM_RAD blocks (the raymarch re-clamps colH with this).
#define VL_CUM_COLCAP clamp(VL_CUM_RAD / VC_THICK, 0.08, 1.0)

// Calibrated base-field value in [0,1] at one position. COST: 1 texture2D tap.
// Calibration measured on the .bin (min 0.03, max 0.91, mean 0.35, bell
// shaped): stretching 0.14..0.58 to 0..1 makes it nearly uniform, which turns
// VC_COVERAGE into ~the fraction of sky actually covered.
float vlCumulusCn(vec2 xz, vec2 wind) {
    float raw = texture2D(vlCumulusBase, (xz + wind) * (1.0 / VL_CUM_TILE)).r;
    return clamp((raw - 0.14) * (1.0 / 0.44), 0.0, 1.0);
}

// Shared wind advection (same vector as every other cloud layer). mod() folds
// it back into ONE tile: exact for a repeating texture (no jump), and it keeps
// the uv small so float32 still resolves it on worlds thousands of days old.
vec2 vlCumulusWind(float t) {
    return mod(t * vec2(7.0, 2.4) * VC_WIND, VL_CUM_TILE);
}

// Coverage remap - the ONLY place the threshold lives. Soft remap instead of a
// hard cut: 0.0 = clear sky, 0.40 (default) = separated masses with real blue
// between them (measured 30.6% footprint), 1.0 = overcast. Monotone in cn AND
// in cvS: raising VC_COVERAGE can only raise cov at every point, which is what
// makes the height driver below grow WITH coverage instead of against it.
float vlCumulusCovRemap(float cn, float cvS) {
    return clamp((cn - (1.0 - cvS)) / cvS, 0.0, 1.0) * smoothstep(0.0, 0.05, cvS);
}

// Coverage only, 1 tap. t = cloudWorldTime() (same clock as the render, so
// /time set and sleeping move shadows and clouds together). Also used alone by
// the light-march coverage refresh in lib/clouds.glsl.
float vlCumulusCoverage(vec2 xz, float t) {
    float cvS = clamp(mix(VC_COVERAGE, 1.0, rainStrength * 0.95), 0.0, 1.0); // full rain = near-total overcast
    if (cvS <= 0.0) return 0.0;
    return vlCumulusCovRemap(vlCumulusCn(xz, vlCumulusWind(t)), cvS);
}

/*
    COLUMN-HEIGHT DRIVER - the anti-"towers" contract, GEOMETRIC this time.
    Height is driven by MORPHOLOGICAL EROSION of the coverage itself: a column
    may only rise where coverage survives a min() over a sampled cross around
    the point, i.e. where the point is surrounded by VISIBLE cloud:
      . tier A (mid domes):   cover on the +-VL_CUM_RAD/2 cross -> local width
        >= VL_CUM_RAD, height budget VL_CUM_RAD/2 (tops at 70 blocks default);
      . tier B (grand cores): cover on the full +-VL_CUM_RAD cross too (its 4
        outer taps AND tier A's inner ones, which also plug ring-jumping over
        gaps) -> width >= 2*VL_CUM_RAD, total height budget VL_CUM_RAD.
    Each tier caps its own contribution, so filled height <= half the
    guaranteed width BY CONSTRUCTION, at every VC_COVERAGE / VC_CUMULUS_SCALE /
    VC_THICK. The remap being monotone, min(cov) == remap(min(cn)): the erosion
    runs on cn and is remapped once per ring.
    MEASURED on the PREVIOUS hard-min saturation (.bin replay, VC_THICK=160,
    scale 1.5) - the soft ramps below keep the same caps and footprint but
    reach full height deeper inside the masses (mid slopes sit lower):
    at VC_COVERAGE=0.40 the covered sky is 26-block pancakes (median), ~5.1%
    of it is 48+ blocks tall, grand cores reach ~107 blocks (soft-ramp replay;
    pre-rework hard-min saturation measured 118-140) at width/height
    p10 = 2.2; "towers" (width < 2*height among the 48+ tall) are 0.26% of the
    covered surface - the pointwise driver this replaces measured 84%. Heights
    GROW with coverage (48+ tall fraction, pre-rework reference: 5.4% / 10.6% / 32.3% at coverage
    0.20 / 0.40 / 0.70): scattered fair-weather cumulus stay flat pancakes,
    stratocumulus decks get thick - the old driver had it inverted.
*/
float vlCumulusTiers(float cvA, float cvB) {
    // Per-tier height budgets in units of the 0.72 colH swing. The clamps
    // keep the guarantee at extreme VC_THICK/scale combos (budgets hit 0).
    float cA = clamp((0.5 * VL_CUM_RAD / VC_THICK - 0.16) * (1.0 / 0.72), 0.0, 0.385);
    float cB = max(clamp((VL_CUM_RAD / VC_THICK - 0.16) * (1.0 / 0.72), 0.0, 0.993) - cA, 0.0);
    // budget * WIDE smoothstep, not min(smoothstep, budget): the old hard min
    // saturated tier A at cvA ~0.14 and tier B at cvB ~0.25, so each tier shot
    // from 0 to its full budget across a razor-thin spatial band - read as
    // straight cylinder flanks topped by a rectangular tower. The wide bands
    // spread each rise over most of the eroded-coverage ramp and are C1 at
    // BOTH ends (no kink where the slope meets the plateau): domes, not mesas.
    // Anti-tower guarantee UNCHANGED: smoothstep <= 1 keeps every contribution
    // <= its budget, so filled height stays <= half the guaranteed width.
    return cA * smoothstep(0.05, 0.65, cvA) + cB * smoothstep(0.08, 0.80, cvB);
}

// Full cumulus field: x = coverage [0,1], y = height driver [0,0.993].
// COST: 1 tap on clear sky, 9 taps inside coverage - all on the same 1 MB R8
// 2D texture (bilinear, high spatial coherence), well below one 128^3 3D tap.
vec2 vlCumulusField(vec2 xz, float t) {
    float cvS = clamp(mix(VC_COVERAGE, 1.0, rainStrength * 0.95), 0.0, 1.0); // full rain = near-total overcast
    if (cvS <= 0.0) return vec2(0.0);
    vec2 wind = vlCumulusWind(t);
    float cn0 = vlCumulusCn(xz, wind);
    float cov = vlCumulusCovRemap(cn0, cvS);
    if (cov < 0.015) return vec2(cov, 0.0); // same early-out as the density
    float rA = VL_CUM_RAD * 0.5;
    float mnA = min(cn0, min(min(vlCumulusCn(xz + vec2(rA, 0.0), wind),
                                 vlCumulusCn(xz - vec2(rA, 0.0), wind)),
                             min(vlCumulusCn(xz + vec2(0.0, rA), wind),
                                 vlCumulusCn(xz - vec2(0.0, rA), wind))));
    // Outer ring rotated 45 degrees (verifier fix): axial alignment with the
    // inner cross let narrow DIAGONAL mask gaps slip between both rings. Same
    // distance (|(d,d)| = VL_CUM_RAD), same cost, no desync (single source).
    float dB = VL_CUM_RAD * 0.7071;
    float mnB = min(mnA, min(min(vlCumulusCn(xz + vec2(dB, dB), wind),
                                 vlCumulusCn(xz - vec2(dB, dB), wind)),
                             min(vlCumulusCn(xz + vec2(dB, -dB), wind),
                                 vlCumulusCn(xz - vec2(dB, -dB), wind))));
    // Rain flattens the deck (nimbostratus are flat, not towering); single
    // source, so the raymarch AND cloudShadow inherit the same flattening.
    return vec2(cov, vlCumulusTiers(vlCumulusCovRemap(mnA, cvS), vlCumulusCovRemap(mnB, cvS)) * (1.0 - rainStrength * 0.65));
}
#endif

/*
    2D cloud shadow on the ground: cheap proxy of the volumetric cloud field
    (same coordinates, same leading octaves) so shadows travel with the clouds.
*/
#ifdef VOLUMETRIC_CLOUDS
#ifdef VC_GROUND_SHADOWS
float cloudShadow(vec3 wp, vec3 lightDirW) {
    // Grazing sun: the /y projection blows up (stretched pattern that reveals
    // the projection direction) and used to cut off abruptly. The offset is
    // clamped and the shadow fades to 1.0 below ~0.22 sun height, where light
    // crosses too much atmosphere to cast crisp cloud shadows anyway.
    float sunUpF = smoothstep(0.06, 0.22, lightDirW.y);
    if (sunUpF <= 0.0) return 1.0;
    // Projection plane at VC_THICK*0.33: the vertical profile piles the matter
    // low (flat base at 6% of the slab, plateau to 0.74*colH, colH <= 0.88),
    // so the density barycentre sits near h~0.33, not mid-slab. Projecting
    // from mid-slab offset grazing shadows by ~28/tan(elev) blocks.
    vec2 cp = wp.xz + lightDirW.xz / max(lightDirW.y, 0.25) * (VC_BASE + VC_THICK * 0.33 - wp.y);
#if VC_CUMULUS_SHAPES == 0 || VC_TEXTURED == 0
    // World-time SYNC: same clock as the cloud render (clouds.glsl receives
    // cloudWorldTime() at its call sites). Using another clock would make the
    // ground shadows drift from the visible clouds after /time set or sleeping.
    // Only the procedural branches read this; the textured branch advects
    // inside vlCumulusField and must not pay an unused per-pixel compute.
    vec2 wind = cloudWorldTime() * vec2(7.0, 2.4) * VC_WIND;
#endif
#if VC_CUMULUS_SHAPES == 1
#if VC_TEXTURED == 1
    // TEXTURED path: calls the EXACT function the raymarch uses
    // (vlCumulusField above) - zero duplicated constants, so ground shadows
    // follow the visible clouds by construction. Cost: 1 tap on clear sky,
    // 9 taps under cloud. The composite1 volumetric-light probes (csA/csB) go
    // through here too and therefore inherit the same field for free.
    vec2 fT = vlCumulusField(cp, cloudWorldTime());
    // Shadow follows column thickness (same intent as the procedural proxy):
    // grand cores (y up to ~0.99) cast full shadow, flat pancakes (y=0) a
    // light penumbra. CLOUD_SHADOW_SOFTNESS keeps its usual meaning.
    float dT = clamp(fT.x * (0.70 + fT.y * 0.90)
                   * (0.80 + CLOUD_SHADOW_SOFTNESS * 0.50), 0.0, 1.0);
    dT = smoothstep(0.0, 1.0, dT);
    return 1.0 - dT * CLOUD_SHADOW_DARKNESS * sunUpF;
#else
    // Cumulus 2D proxy = the SAME field as cloudFieldCumulus in clouds.glsl
    // (2-octave smooth mass + silhouette octave (+ detail), threshold-lifting
    // banks, scattered small cumulus - same frequencies, thresholds and wind).
    // DUPLICATED here because lighting.glsl is included BEFORE clouds.glsl:
    // any edit there MUST be mirrored here or ground shadows desync from clouds.
    vec2 pC = cp + wind;
    vec2 qmC = vec2(pC.x * 0.0028, pC.y * 0.0038) / VC_CUMULUS_SCALE;
    float MC = vnoise(qmC) * 0.62 + vnoise(qmC * 2.31 + 19.7) * 0.26;
    float mkC = MC + vnoise(qmC * 7.3 + 2.9) * 0.12;
    #if VC_DETAIL == 1
    mkC = mkC * 0.92 + vnoise(qmC * 16.9 + 5.1) * 0.08;
    #endif
    float bankC = smoothstep(0.28, 0.62, fbm3(pC * 0.00027 + 17.3));
    float covC = clamp(mix(VC_COVERAGE, 1.0, rainStrength * 0.95), 0.0, 1.0); // full rain = near-total overcast
    float thBC = mix(0.60, 0.34, covC) + (1.0 - bankC) * 0.12;
    float bigC = smoothstep(thBC, thBC + 0.13, mkC);
    vec2 qsC = vec2(pC.x * 0.010, pC.y * 0.013) / VC_CUMULUS_SCALE;
    float scC = vnoise(qsC) * 0.70 + vnoise(qsC * 2.63 + 7.7) * 0.30;
    float thSC = mix(0.74, 0.58, covC);
    float smC = smoothstep(thSC, thSC + 0.10, scC) * (0.30 + 0.70 * bankC);
    float covF = max(bigC, smC * 0.85) * smoothstep(0.0, 0.05, covC);
    covF = max(covF, rainStrength * rainStrength * 0.85); // rain overcast floor (MIRROR of cloudFieldCumulus)
    float massC = smoothstep(thBC, thBC + 0.30, MC) * (1.0 - rainStrength * 0.65); // rain flattening, mirrored from cloudFieldCumulus
    // Shadow follows column thickness: cores of large systems (massC->1) cast
    // full shadow, flat edges cast light penumbra, so ground shadows track
    // cloud relief. CLOUD_SHADOW_SOFTNESS keeps its usual meaning.
    float d = clamp(covF * (0.70 + massC * 0.90) * (0.80 + CLOUD_SHADOW_SOFTNESS * 0.50), 0.0, 1.0);
    d = smoothstep(0.0, 1.0, d);
    return 1.0 - d * CLOUD_SHADOW_DARKNESS * sunUpF;
#endif
#else
    float cov = clamp(mix(VC_COVERAGE, 1.0, rainStrength * 0.95), 0.0, 1.0); // full rain = near-total overcast
    float covLo = max(0.20 - (cov - 0.45) * 0.44, 0.0);
    float covL = cov * smoothstep(covLo, 0.70, vnoise((cp + wind * 0.6) * 0.0010)) * 1.5;
    float base = fbm((cp + wind) * 0.0060);
    float d = clamp((base - (1.0 - covL) * 0.52 - 0.08) * (2.0 + CLOUD_SHADOW_SOFTNESS * 2.5), 0.0, 1.0);
    d = smoothstep(0.0, 1.0, d);
    return 1.0 - d * CLOUD_SHADOW_DARKNESS * sunUpF;
#endif
}
#endif
#endif

// Maps a view-space point into shadow map clip space, offset along the normal
// to avoid shadow acne.
vec3 toShadowClip(vec3 viewPos, vec3 viewNormal) {
    vec4 wpos = gbufferModelViewInverse * vec4(viewPos, 1.0);
    vec3 wnorm = mat3(gbufferModelViewInverse) * viewNormal;
    wpos.xyz += wnorm * (0.012 + length(viewPos) * 0.0018); // small normal-offset keeps contact shadows of foliage/entities attached; raise it if flat terrain shows acne
    vec4 spos = shadowProjection * (shadowModelView * wpos);
    return spos.xyz;
}

/*
    PCSS shadows: blocker search estimates penumbra size (the farther the
    occluder, the softer the shadow), then a Vogel-disk PCF.
    Returns RGB visibility - stained glass tints the light.
*/
vec3 sampleShadowPCF(vec3 shadowClip, float dither) {
    float f = getDistortFactor(shadowClip.xy);
    vec3 sp = distortShadow(shadowClip, f) * 0.5 + 0.5;

    if (sp.x < 0.0 || sp.x > 1.0 || sp.y < 0.0 || sp.y > 1.0) return vec3(1.0);

    // Bias is expressed in tex-z units calibrated for a 0.5 packing; the
    // (VLX_SHADOW_ZSCALE * 2.0) factor keeps the same WORLD-space bias for any
    // SHADOW_Z_SCALE (exact no-op at 0.5).
    float bias = (0.00012 + 0.00010 * f) * (VLX_SHADOW_ZSCALE * 2.0); // ~0.14 block of Peter-Pan; if flat terrain shows acne under a grazing sun, raise the normal-offset in toShadowClip BEFORE touching this bias.
    float z = sp.z - bias;
    // Receiver BEYOND the shadow frustum Z range: nothing testable, so fail
    // OPEN (lit) and let shadeSurface's sfade/lightmap take over. A step() here
    // would return a false BLACK in the distance.
    if (z >= 1.0) return vec3(1.0);
    float texel = 1.0 / float(shadowMapResolution) / f;

    float radius;
    #ifdef PCSS
        // Blocker search: 5 samples
        float searchR = 12.0 * texel;
        float blockerSum = 0.0;
        float blockerCnt = 0.0;
        float blocker0Cnt = 0.0;
        for (int i = 0; i < 5; i++) {
            float a = float(i) * 2.4 + dither * 6.2831853;
            float r = sqrt((float(i) + 0.5) / 5.0);
            vec2 o = vec2(cos(a), sin(a)) * r * searchR;
            float zb = texture2D(shadowtex1, sp.xy + o).r;
            if (zb < z) { blockerSum += zb; blockerCnt += 1.0; }
            // tex0 too (translucents: water/glass) for the early-out below
            if (texture2D(shadowtex0, sp.xy + o).r < z) blocker0Cnt += 1.0;
        }
        // Early-out: NO blocker at all (opaque or translucent) inside the search
        // radius (12 texels >> the blocker-free PCF radius of ~0.4 texel x
        // SOFTNESS) means full sun, so the SHADOW_SAMPLES PCF taps are skipped.
        // This is the majority case outdoors during the day.
        if (blockerCnt < 0.5 && blocker0Cnt < 0.5) return vec3(1.0);
        // Penumbra tuning: the real sun (0.53 deg) gives ~0.01 block of penumbra
        // per block of occluder distance. Floor 0.30 keeps contact shadows crisp
        // (a higher floor dissolves small casters such as entities), slope 35
        // and cap 4 control distant softness. Both remain user-drivable through
        // the SHADOW_SOFTNESS / LIGHT_SIZE sliders.
        float penumbra = 0.30;
        if (blockerCnt > 0.5) {
            float zb = blockerSum / blockerCnt;
            // (z - zb) is in PACKED tex-z units (proportional to
            // VLX_SHADOW_ZSCALE) and the 35.0 slope is calibrated for packing
            // 0.5, hence the (0.5 / VLX_SHADOW_ZSCALE) compensation: without it
            // the SHADOW_Z_SCALE slider silently changes shadow softness.
            penumbra += clamp((z - zb) * 35.0 * (0.5 / VLX_SHADOW_ZSCALE) * LIGHT_SIZE * float(shadowMapResolution) * 0.001, 0.0, 4.0);
        }
        radius = penumbra * texel * SHADOW_SOFTNESS * 1.2;
        radius = max(radius, 0.6 * texel); // anti-alias floor: never below ~0.6 texel (clean hard edges, no staircasing)
    #else
        radius = SHADOW_SOFTNESS * 2.5 * texel;
    #endif

    vec3 shadow = vec3(0.0);
    for (int i = 0; i < SHADOW_SAMPLES; i++) {
        float a = float(i) * 2.4 + dither * 6.2831853;
        float r = sqrt((float(i) + 0.5) / float(SHADOW_SAMPLES));
        vec2 o = vec2(cos(a), sin(a)) * r * radius;

        float s0 = step(z, texture2D(shadowtex0, sp.xy + o).r); // everything
        float s1 = step(z, texture2D(shadowtex1, sp.xy + o).r); // opaque only

        vec3 col = vec3(1.0);
#if SHADOW_TEX1_GATE_FIX == 1
        // RGB-band receiver tag (blend-robust; encoded in shadow.fsh). The tag lives in
        // RGB BANDS and alpha is forced to 1.0 on write so it survives the Iris shadow
        // pass alpha blend (an alpha-encoded tag gets destroyed and light tints vanish).
        // max(rgb) < 0.325 = WATER (tint decoded), < 0.675 = GLASS/PORTAL (tint decoded),
        // otherwise SOLID tex0-only OR a texel never written (white clear) -> FULL
        // OCCLUSION in both cases. Fail-safe: small casters shadow as soon as their depth
        // is in shadowtex0, even if their color write does not land.
        if (s0 < s1) {
            vec4 sc0 = texture2D(shadowcolor0, sp.xy + o);
            float tagMx = max(max(sc0.r, sc0.g), sc0.b);
            if (tagMx < 0.325)      col = clamp(sc0.rgb / 0.30, 0.0, 1.0);
            else if (tagMx < 0.675) col = clamp((sc0.rgb - 0.35) / 0.30, 0.0, 1.0);
            else s1 = 0.0;
        }
#else
        if (s0 < s1) col = texture2D(shadowcolor0, sp.xy + o).rgb;
#endif
        // boost the tint of colored shadows (blue water, stained glass)
        col = max(mix(vec3(luma(col)), col, 1.4), vec3(0.0)); // clamp >=0: the 1.4 extrapolation can push channels negative
        shadow += s1 * mix(col, vec3(1.0), s0);
    }
    return shadow / float(SHADOW_SAMPLES);
}

vec3 getShadow(vec3 viewPos, vec3 viewNormal, float dither) {
    return sampleShadowPCF(toShadowClip(viewPos, viewNormal), dither);
}

#if DEBUG_VIEW == 22
// Shadow debug view: classifies the shadow texel at the RECEIVER pixel (center tap,
// SAME math as sampleShadowPCF: distortion, z packing, bias). Answers in-world
// "does the receiver see the occluder?".
//   WHITE   = lit (s0=s1=1)
//   BLACK   = opaque shadow (s1=0, occluder present in shadowtex1)
//   RED     = SOLID tex0-only band -> full occlusion
//   BLUE    = WATER band, CYAN = GLASS/PORTAL band (tinted)
//   GREEN   = solid band with alpha<0.9 (partial write; still occludes)
//   YELLOW  = anomaly s0>s1, MAGENTA = outside the shadow map
// WHITE under an entity means its depth is MISSING from shadowtex0 (caster-side
// issue, not the tag). Compiled only in this debug view.
vec3 debugShadowClassify(vec3 viewPos, vec3 viewNormal) {
    vec3 shadowClip = toShadowClip(viewPos, viewNormal);
    float f = getDistortFactor(shadowClip.xy);
    vec3 sp = distortShadow(shadowClip, f) * 0.5 + 0.5;
    if (sp.x < 0.0 || sp.x > 1.0 || sp.y < 0.0 || sp.y > 1.0) return vec3(0.5, 0.0, 0.5);
    float bias = (0.00012 + 0.00010 * f) * (VLX_SHADOW_ZSCALE * 2.0); // same calibration as sampleShadowPCF
    float z = sp.z - bias;
    float s0 = step(z, texture2D(shadowtex0, sp.xy).r);
    float s1 = step(z, texture2D(shadowtex1, sp.xy).r);
    if (s0 > 0.5 && s1 > 0.5) return vec3(1.0);
    if (s0 < 0.5 && s1 < 0.5) return vec3(0.0);
    if (s0 < 0.5 && s1 > 0.5) {
#if SHADOW_TEX1_GATE_FIX == 1
        vec4 scD = texture2D(shadowcolor0, sp.xy);
        float mxD = max(max(scD.r, scD.g), scD.b);
        if (mxD < 0.325) return vec3(0.1, 0.3, 1.0);   // BLUE  = WATER band (tint expected)
        if (mxD < 0.675) return vec3(0.1, 1.0, 0.9);   // CYAN  = GLASS/PORTAL band (tinted)
        if (scD.a < 0.9) return vec3(0.1, 1.0, 0.1);   // GREEN = solid band, alpha<0.9 (partial write)
        return vec3(1.0, 0.0, 0.0);                    // RED   = SOLID band -> full occlusion
#else
        return (texture2D(shadowcolor0, sp.xy).a > 0.2) ? vec3(0.1, 0.3, 1.0) : vec3(1.0, 0.0, 0.0);
#endif
    }
    return vec3(1.0, 1.0, 0.0);
}
#endif

/*
    Full surface shading.
    subsurface > 0: approximate subsurface scattering (foliage).
*/
// ===== FULL_PATH_TRACE variant of shadeSurface, exporting the pure DIRECT term =====
// shadeSurface itself is left alone: its signature is shared with entities/water and changing it
// would break their compilation even at FULL_PATH_TRACE=0. This sibling exists only under the gate
// and is called only by gbuffers_terrain. It matches shadeSurface exactly EXCEPT:
//   (1) shadow = 1 - sun visibility is delegated to the composite5 voxel trace;
//   (2) it exports directOut = albedo * getLightColor() * diff, the SAME quantity (without
//       visibility) that is baked into colortex0, so composite5 can subtract it exactly for any
//       albedo. cloudShadow is included so the lit region matches.
#if FULL_PATH_TRACE == 1 && VOXEL_GI == 1
vec3 shadeSurfaceFPT(vec3 albedo, vec3 normal, vec2 lm, vec3 viewPos, float dither, float subsurface, out vec3 directOut) {
    directOut = vec3(0.0);
    vec3 L = normalize(shadowLightPosition);
    float NdotL = clamp(dot(normal, L), 0.0, 1.0);
    float diff = mix(NdotL, 0.6 + 0.4 * NdotL, subsurface);

    vec3 shadow = vec3(1.0);   // sun visibility DELEGATED to the voxel trace (composite5)
    // Foliage shadow floor (no-op when subsurface=0): same as shadeSurface.
    shadow = mix(shadow, max(shadow, vec3(FOLIAGE_SHADOW_FLOOR)), subsurface);

    // Pure direct term (cloudShadow included for parity with the lit region; baked + exported).
    vec3 lightCol = getLightColor();
    float cloudS = 1.0;
    #ifdef VOLUMETRIC_CLOUDS
    #ifdef VC_GROUND_SHADOWS
        vec3 wpFPT = (gbufferModelViewInverse * vec4(viewPos, 1.0)).xyz + cameraPosition;
        vec3 lightDirWFPT = normalize(mat3(gbufferModelViewInverse) * shadowLightPosition);
        cloudS = cloudShadow(wpFPT, lightDirWFPT);
    #endif
    #endif
    vec3 direct = lightCol * diff * shadow * cloudS;
    // directOut = pure sun contribution (shadow already 1) as baked into colortex0.
    directOut = albedo * lightCol * diff * cloudS;

    if (subsurface > 0.0) {
        float backlit = pow(clamp(dot(normalize(viewPos), L), 0.0, 1.0), 6.0);
        direct += lightCol * shadow * cloudS * backlit * subsurface * 0.85 * SUBSURFACE_STRENGTH;
    }

    float skyl = lm.y * lm.y;
    vec3 ambient = getAmbientColor() * skyl;
    ambient *= VOXEL_GI_AMBIENT;
    #ifdef RTGI
        ambient *= mix(1.0, RTGI_BOUNCE_AMBIENT, skyl);
    #endif
    ambient += toLinear(fogColor) * 0.35 * (1.0 - skyl) + getTorchColor() * 0.006 * (1.0 - skyl);

    float torch = lm.x * lm.x * lm.x * 2.0 + lm.x * 0.08;
    #ifdef VANILLA_BLOCKLIGHT
        torch *= VANILLA_TORCH;
    #else
        torch = 0.0;
    #endif
    float held = float(max(heldBlockLightValue, heldBlockLightValue2)) / 15.0; // offhand counts too
    torch += held * held * 2.5 * HELD_LIGHT_STRENGTH / (1.0 + dot(viewPos, viewPos) * 0.05);

    vec3 caveFloor = vec3(0.8, 0.95, 1.3) * MIN_LIGHT * max(skyl * skyl, 0.005);

    // directOut is already linear and already multiplied by albedo: exactly the sun share below.
    return albedo * (direct + ambient + getTorchColor() * torch + caveFloor);
}
#endif

vec3 shadeSurface(vec3 albedo, vec3 normal, vec2 lm, vec3 viewPos, float dither, float subsurface) {
    vec3 L = normalize(shadowLightPosition);
    float NdotL = clamp(dot(normal, L), 0.0, 1.0);
    float diff = mix(NdotL, 0.6 + 0.4 * NdotL, subsurface);

    vec3 shadow = vec3(0.0);
    if (diff > 0.001) {
        // Past the shadow map: fall back to the sky lightmap
        float sfade = smoothstep(0.85, 1.0, length(viewPos) / shadowDistance);
        if (sfade >= 1.0) {
            // Early-out: at sfade 1 the PCSS result would be fully overwritten
            // by the mix below - a large win on DH / distant terrain fragments.
            shadow = vec3(lm.y * lm.y);
        } else {
            shadow = getShadow(viewPos, normal, dither);
            shadow = mix(shadow, vec3(lm.y * lm.y), sfade);
        }
    }
    // Shadow RECEIVED under foliage (subsurface>0): soft floor, adjustable strength, and a GREEN
    // tint standing in for light filtered through leaves. Exact no-op on any other surface
    // (subsurface=0) because mix(..., subsurface) cancels it.
    // Note: this is the RECEIVER side only; foliage/entity shadow CASTING lives in
    // shadow.vsh/shadow.fsh + shaders.properties.
    {
        vec3 folFloored = max(shadow, vec3(FOLIAGE_SHADOW_FLOOR));
        vec3 folStrong  = mix(folFloored, shadow, FOLIAGE_SHADOW_STRENGTH);
        float folL      = luma(folStrong);
        vec3 folTinted  = mix(folStrong, folStrong * vec3(0.55, 1.0, 0.45), FOLIAGE_SHADOW_TINT * (1.0 - folL));
        shadow = mix(shadow, folTinted, subsurface);
    }
    vec3 direct = getLightColor() * diff * shadow;

    // Foliage backlight: light transmitted through the leaves
    if (subsurface > 0.0) {
        float backlit = pow(clamp(dot(normalize(viewPos), L), 0.0, 1.0), 6.0);
        direct += getLightColor() * shadow * backlit * subsurface * 0.85 * SUBSURFACE_STRENGTH;
    }

    // Clouds crossing the sun darken the ground
    #ifdef VOLUMETRIC_CLOUDS
    #ifdef VC_GROUND_SHADOWS
    // cloudShadow costs ~17 noise evaluations and is pointless when direct is
    // already zero (full shadow, cave, moonless night) - same result, no cost.
    if (luma(direct) > 1e-4) {
        vec3 wp = (gbufferModelViewInverse * vec4(viewPos, 1.0)).xyz + cameraPosition;
        vec3 lightDirW = normalize(mat3(gbufferModelViewInverse) * shadowLightPosition);
        direct *= cloudShadow(wp, lightDirW);
    }
    #endif
    #endif

    float skyl = lm.y * lm.y;
    // Sky ambient: strictly proportional to sky exposure - 0 in caves
    vec3 ambient = getAmbientColor() * skyl;
    // SHADOW_TINT (CONFIG_LIGHT) < 1.0: pull the sky ambient toward its own
    // luma in SHADOWED areas only (weighted by sun visibility; lit areas keep
    // the full tint, direct light swamps them anyway). Delta form: exact no-op
    // at the 1.0 default. Applied BEFORE the dimensional fogColor term below.
    ambient += (vec3(luma(ambient)) - ambient)
             * ((1.0 - SHADOW_TINT) * (1.0 - clamp(luma(shadow), 0.0, 1.0)));
    #if VOXEL_GI == 1
        // Voxel GI supplies the indirect term, so the flat sky ambient is scaled down to avoid
        // double counting. Does NOT touch the dimensional fogColor*0.35 term below.
        ambient *= VOXEL_GI_AMBIENT;
    #endif
    #ifdef RTGI
        ambient *= mix(1.0, RTGI_BOUNCE_AMBIENT, skyl);
    #endif
    // Dimensional ambient: Nether/End keep their atmosphere while overworld caves get almost
    // nothing (fogColor is near black there). The 0.35 factor darkens caves and keeps the
    // Nether warm.
    ambient += toLinear(fogColor) * 0.35 * (1.0 - skyl) + getTorchColor() * 0.006 * (1.0 - skyl);

    float torch = lm.x * lm.x * lm.x * 2.0 + lm.x * 0.08;
    #ifdef VANILLA_BLOCKLIGHT
        torch *= VANILLA_TORCH;
    #else
        torch = 0.0;   // vanilla block light fully cut (GI/emissive only)
    #endif
    float held = float(max(heldBlockLightValue, heldBlockLightValue2)) / 15.0; // offhand counts too
    // HELD block light: kept separate from the lightmap torch term. Under EMISSIVE_LPV it keeps
    // the vanilla torch tint, because the source moves with the player and the LPV never sees it.
    float heldT = held * held * 2.5 * HELD_LIGHT_STRENGTH / (1.0 + dot(viewPos, viewPos) * 0.05);

    // Minimum floor: 0.5% deep underground, so a torch stays mandatory
    vec3 caveFloor = vec3(0.8, 0.95, 1.3) * MIN_LIGHT * max(skyl * skyl, 0.005);

#if EMISSIVE_LPV == 1
    // COLORED LIGHTS: the TINT comes from the LPV field (red redstone torch, bluish white sea
    // lantern, orange glowstone/lava - each source's own texture sets its hue), while the
    // INTENSITY stays driven by the vanilla lightmap (reliable spherical falloff, no leaking
    // through walls). With no LPV energy (out of range, Nether/End without a shadow pass, empty
    // grid) it falls back exactly to getTorchColor().
    vec3 wrelCL  = (gbufferModelViewInverse * vec4(viewPos, 1.0)).xyz;
    vec3 wnrmCL  = mat3(gbufferModelViewInverse) * normal;
    vec3 lpvCL   = sampleLPVForward(wrelCL, wnrmCL);
    float lpvLum = luma(lpvCL);
    vec3 torchCol = getTorchColor();
    if (lpvLum > 1e-4) {
        // Tint normalized to the SAME luma as the vanilla torch, so the swap changes COLOR, not
        // energy. Faded in by field presence (epsilon threshold rejects background noise).
        vec3 lpvTint = lpvCL * (luma(getTorchColor()) / lpvLum);
        torchCol = mix(torchCol, lpvTint, CL_TINT_MIX * smoothstep(0.0005, 0.004, lpvLum));
    }
    // Additive pop: raw LPV radiance (falloff comes from the flood = smooth spherical light),
    // guarded by smoothstep(lm.x) so a wall cuts the lightmap and therefore the pop (anti-leak).
    vec3 blockLight = torchCol * torch + getTorchColor() * heldT
                    + lpvCL * CL_STRENGTH * smoothstep(0.035, 0.10, lm.x);
#else
    // Fallback path: plain vanilla torch color for both lightmap and held light.
    vec3 blockLight = getTorchColor() * (torch + heldT);
#endif

    return albedo * (direct + ambient + blockLight + caveFloor);
}
