#version 410 core
// Forward lighting pass, daylight. Everything below works in LINEAR light:
// authored colors are decoded from sRGB on the way in, lighting accumulates in
// HDR, and the frame is tonemapped and re-encoded at the very end. That is the
// single biggest reason this reads as daylight rather than as a flat repaint —
// bright surfaces roll off instead of clipping, and shadowed ground still has
// color in it.
//
// Materials (uMaterial):
//   0 flat color        (enemies, viewmodel, misc)
//   1 concrete slab     (poured panels + joints — platforms)
//   2 brick             (running bond + mortar, per-brick tint)
//   3 metal             (brushed streaks + rust)
//   4 wood              (plank stripes — crates)
//   5 panel             (riveted panels)
//   6 terrain           (grass/dirt/rock blended by slope, macro + detail noise)
//   7 sky               (unlit gradient dome with a sun disc)
// Patterns are generated from world position projected on the dominant axis of
// the normal (poor-man's triplanar), so no UVs and no texture files.

in vec3 vWorldPos;
in vec3 vNormal;
in float vSun;   // terrain only: baked sun visibility, see basic.vert

out vec4 FragColor;

uniform vec3 uColor;
uniform vec3 uCamPos;
uniform vec3 uSunDir;
uniform vec3 uSunColor;      // HDR: goes above 1
uniform vec3 uSkyColor;      // hemisphere ambient from above
uniform vec3 uGroundColor;   // bounce from below
uniform vec3 uFogColor;      // horizon color; aerial perspective fades into it
uniform float uExposure;
uniform int  uMaterial;
uniform vec2 uResolution;

#define MAX_POINT_LIGHTS 8
uniform int  uNumPointLights;
uniform vec3 uPointLightPos[MAX_POINT_LIGHTS];
uniform vec3 uPointLightColor[MAX_POINT_LIGHTS];

uniform int uUnlit;   // emissive path: billboards, fireballs, viewmodel glow

// Graphics preset: 0 low (flat colors, no specular), 1 medium (3-octave
// patterns), 2 high (4 octaves + ray-traced sun shadows), 3 ultra
// (5 octaves + ray-traced reflections + point-light shadows).
uniform int uDetail;

// Ground detail, baked once on the CPU rather than evaluated per pixel:
// RG = micro-relief gradient, B = grain. Tiling and mipmapped, so it is one
// filtered fetch instead of a dozen hashes, and it stops shimmering at range.
uniform sampler2D uDetailTex;

// ---- ray-traced scene ------------------------------------------------------
// Structures, crates, barrels and live enemies are uploaded as AABBs each
// frame and traced per pixel: real shadow and reflection rays, slab tests, no
// shadow maps. Terrain is not in this set any more — it is a heightfield, and
// its shape is instead resolved by the mesh itself.
//
// 48 slots, not more: this loop runs per pixel per ray, and outdoors the
// ground fills the screen. The CPU sends the 48 nearest.
#define MAX_BOXES 48
uniform int uNumBoxes;
uniform vec3 uBoxMin[MAX_BOXES];
uniform vec3 uBoxMax[MAX_BOXES];
uniform vec3 uBoxColor[MAX_BOXES];

float boxHit(vec3 ro, vec3 rd, vec3 bmin, vec3 bmax) {
    vec3 inv = 1.0 / rd;
    vec3 t0 = (bmin - ro) * inv;
    vec3 t1 = (bmax - ro) * inv;
    vec3 tn3 = min(t0, t1);
    vec3 tf3 = max(t0, t1);
    float tn = max(max(tn3.x, tn3.y), tn3.z);
    float tf = min(min(tf3.x, tf3.y), tf3.z);
    return (tf >= tn && tf > 0.0) ? max(tn, 0.0) : -1.0;
}

bool occluded(vec3 ro, vec3 rd, float maxDist) {
    for (int i = 0; i < uNumBoxes; ++i) {
        float t = boxHit(ro, rd, uBoxMin[i], uBoxMax[i]);
        if (t > 0.0 && t < maxDist) return true;
    }
    return false;
}

int traceNearest(vec3 ro, vec3 rd, float maxDist, out float tOut) {
    float best = maxDist;
    int idx = -1;
    for (int i = 0; i < uNumBoxes; ++i) {
        float t = boxHit(ro, rd, uBoxMin[i], uBoxMax[i]);
        if (t > 0.0 && t < best) { best = t; idx = i; }
    }
    tOut = best;
    return idx;
}

// ---- helpers ---------------------------------------------------------------

vec3 toLinear(vec3 c) { return pow(max(c, 0.0), vec3(2.2)); }

// ACES filmic curve (Narkowicz fit) — the highlight rolloff that keeps a lit
// daylight scene from turning into white paste.
vec3 tonemapACES(vec3 x) {
    const float a = 2.51, b = 0.03, c = 2.43, d = 0.59, e = 0.14;
    return clamp((x * (a * x + b)) / (x * (c * x + d) + e), 0.0, 1.0);
}

// Hash without sin(). The usual fract(sin(dot(p, big))) trick dies out here:
// world coordinates run to +-2500 m, so the argument to sin reaches millions,
// float32 precision collapses, and every procedural pattern in the frame
// degenerates into a flat tint. This one stays in a small numeric range.
float hash(vec2 p) {
    vec3 p3 = fract(vec3(p.xyx) * 0.1031);
    p3 += dot(p3, p3.yzx + 33.33);
    return fract((p3.x + p3.y) * p3.z);
}

float vnoise(vec2 p) {
    // Wrap the lattice as well, so the hash inputs stay small no matter how
    // far out in the world this is. The repeat is thousands of meters away at
    // every scale actually used, and lands on a lattice line, so it is seamless.
    vec2 i = mod(floor(p), 4096.0);
    vec2 f = fract(p);
    vec2 u = f * f * (3.0 - 2.0 * f);
    float a = hash(i);
    float b = hash(i + vec2(1.0, 0.0));
    float c = hash(i + vec2(0.0, 1.0));
    float d = hash(i + vec2(1.0, 1.0));
    return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}

float fbm(vec2 p) {
    int octaves = uDetail >= 3 ? 5 : (uDetail == 2 ? 4 : 3);
    float v = 0.0;
    float amp = 0.5;
    for (int i = 0; i < 5; ++i) {
        if (i >= octaves) break;
        v += amp * vnoise(p);
        p *= 2.03;
        amp *= 0.5;
    }
    return v;
}

// Per-material surface response. x = specular strength, y = shininess.
vec2 surfaceSpec() {
    if (uMaterial == 3) return vec2(0.55, 96.0);   // metal: tight and bright
    if (uMaterial == 1) return vec2(0.16, 48.0);   // concrete: broad sheen
    if (uMaterial == 6) return vec2(0.05, 20.0);   // ground: almost none
    if (uMaterial == 2) return vec2(0.06, 24.0);   // brick
    if (uMaterial == 4) return vec2(0.09, 32.0);   // wood
    return vec2(0.10, 32.0);
}

// ---- albedo (returns LINEAR color) -----------------------------------------

vec3 materialColor() {
    vec3 base = toLinear(uColor);
    if (uDetail == 0) return base;   // low preset: flat colors, no patterns
    vec3 an = abs(normalize(vNormal));
    vec2 uv = an.y > 0.7 ? vWorldPos.xz
            : (an.x > an.z ? vWorldPos.zy : vWorldPos.xy);

    if (uMaterial == 6) {
        // Ground: grass where it is flat, dirt on the breaks, rock where the
        // slope steepens.
        //
        // Written for cost, not just for looks. This branch runs on most of
        // the pixels on screen, so it takes exactly two noise lookups: one
        // fbm for the landform-scale variation, whose value is then reused
        // for the dirt blend, the rock threshold and the per-patch tint, and
        // one cheap vnoise for close-up grain that is skipped entirely past
        // 40 m. The earlier version called fbm five separate times and cost
        // roughly four times as much for a difference you cannot see.
        vec3 N = normalize(vNormal);
        float slope = 1.0 - N.y;

        vec3 grass = toLinear(vec3(0.27, 0.34, 0.17));
        vec3 dry   = toLinear(vec3(0.46, 0.43, 0.26));
        vec3 dirt  = toLinear(vec3(0.33, 0.26, 0.18));
        vec3 rock  = toLinear(vec3(0.45, 0.44, 0.42));

        float macro = fbm(uv * 0.011);
        vec3 col = mix(grass, dry, smoothstep(0.32, 0.72, macro));
        // reuse the same field at a different threshold for worn dirt
        col = mix(col, dirt, 0.7 * smoothstep(0.52, 0.30, macro));
        // and again, tilted by slope, for the rock line
        float rockMix = smoothstep(0.09, 0.40, slope + 0.13 * (macro - 0.5));
        col = mix(col, rock, rockMix);
        col *= 0.88 + 0.24 * macro;

        float d = length(uCamPos - vWorldPos);
        if (d < 65.0) {
            // two scales of the same tile so its 8 m repeat never reads as one
            float fade = 1.0 - smoothstep(28.0, 65.0, d);
            float g1 = texture(uDetailTex, uv * 0.125).b;
            float g2 = texture(uDetailTex, uv * 0.031).b;
            col *= 1.0 + ((g1 * 0.62 + g2 * 0.38) - 0.5) * 0.44 * fade;
        }
        return col;
    }
    if (uMaterial == 1) {           // concrete: poured panels with joints
        vec2 cell = mod(floor(uv / 2.0), 4096.0);
        vec3 col = base * (0.90 + 0.16 * hash(cell));
        vec2 f = fract(uv / 2.0);
        float edge = min(min(f.x, 1.0 - f.x), min(f.y, 1.0 - f.y));
        col *= 0.72 + 0.28 * smoothstep(0.0, 0.03, edge);    // form joints
        col *= 0.92 + 0.16 * fbm(uv * 6.0);                  // aggregate
        col *= 1.0 - 0.22 * smoothstep(0.62, 0.88, fbm(uv * 0.45 + 7.3));  // staining
        return col;
    }
    if (uMaterial == 2) {           // brick: 1.0 x 0.4 running bond
        float row = floor(uv.y / 0.4);
        vec2 b = vec2(uv.x + mod(row, 2.0) * 0.5, uv.y / 0.4);
        vec2 f = vec2(fract(b.x), fract(b.y));
        float wob = 0.02 * (vnoise(uv * 18.0) - 0.5);
        float mortar = 1.0 - step(0.05 + wob, f.x) * step(0.10 + wob, f.y);
        vec3 col = base * (0.85 + 0.28 * hash(mod(vec2(floor(b.x), row), 4096.0)));
        col *= 0.90 + 0.20 * fbm(uv * 22.0);
        col = mix(col, base * 0.62,
                  0.45 * smoothstep(0.66, 0.9, fbm(uv * 0.8 + 3.1)));   // grime
        col = mix(col, base * 0.55 * (0.85 + 0.3 * vnoise(uv * 30.0)), mortar);
        return col;
    }
    if (uMaterial == 3) {           // metal: brushed streaks + rust
        float brush = vnoise(vec2(uv.x * 55.0, uv.y * 2.5));
        float speck = hash(mod(floor(uv * 24.0), 4096.0));
        vec3 col = base * (0.86 + 0.12 * brush + 0.08 * speck);
        float rust = fbm(uv * 3.5 + 11.0);
        col = mix(col, toLinear(vec3(0.42, 0.22, 0.10)),
                  0.55 * smoothstep(0.68, 0.88, rust));
        return col;
    }
    if (uMaterial == 5) {           // riveted panels
        vec2 cell = mod(floor(uv / 1.5), 4096.0);
        vec2 f = fract(uv / 1.5);
        float edge = min(min(f.x, 1.0 - f.x), min(f.y, 1.0 - f.y));
        vec3 col = base * (0.88 + 0.20 * hash(cell));
        col *= 0.66 + 0.34 * smoothstep(0.0, 0.05, edge);
        col *= 0.92 + 0.16 * fbm(uv * 9.0);
        if (length(f - vec2(0.08)) < 0.035) col *= 1.5;
        return col;
    }
    if (uMaterial == 4) {           // wood: planks with warped ring grain
        float plank = floor(uv.x * 2.5);
        float ring = sin(uv.y * 40.0 + fbm(uv * 5.0 + plank * 3.7) * 9.0);
        float grain = 0.88 + 0.12 * ring;
        vec3 col = base * (0.82 + 0.32 * hash(mod(vec2(plank, 0.0), 4096.0))) * grain;
        col *= 0.92 + 0.16 * fbm(uv * 28.0);
        col *= 1.0 - 0.32 * smoothstep(0.78, 0.95, fbm(uv * 2.2 + plank));
        return col;
    }
    return base;
}

void main() {
    if (uUnlit == 1) {
        // emissive: already an HDR color, still needs the same tonemap so it
        // sits in the same image as everything else
        vec3 c = tonemapACES(toLinear(uColor) * 2.2 * uExposure);
        FragColor = vec4(pow(c, vec3(1.0 / 2.2)), 1.0);
        return;
    }

    vec3 L = normalize(uSunDir);

    // ---- sky dome ----------------------------------------------------------
    if (uMaterial == 7) {
        vec3 d = normalize(vWorldPos - uCamPos);
        float up = max(d.y, 0.0);
        // Rayleigh-ish: deep overhead, pale and hazy toward the horizon
        vec3 zenith = toLinear(vec3(0.19, 0.38, 0.72));
        vec3 horizon = toLinear(vec3(0.72, 0.80, 0.88));
        vec3 col = mix(horizon, zenith, pow(up, 0.42));
        // sun disc plus its glow, brighter than anything else in the frame
        float sun = max(dot(d, L), 0.0);
        col += uSunColor * 0.55 * pow(sun, 1400.0) * 40.0;    // disc
        col += uSunColor * 0.14 * pow(sun, 12.0);             // glow
        col += horizon * 0.35 * pow(sun, 3.0);                // haze around it
        // thin high cloud, stretched toward the horizon
        float cl = vnoise(d.xz * 2.2 / max(abs(d.y), 0.10) + 4.0);
        col = mix(col, col * 1.22 + vec3(0.05), 0.30 * smoothstep(0.55, 0.95, cl) * up);
        // below the horizon line, fade to fog so distant terrain meets the sky
        col = mix(uFogColor, col, smoothstep(-0.06, 0.02, d.y));

        col = tonemapACES(col * uExposure);
        FragColor = vec4(pow(col, vec3(1.0 / 2.2)), 1.0);
        return;
    }

    vec3 albedo = materialColor();
    vec3 N = normalize(vNormal);
    vec3 V = normalize(uCamPos - vWorldPos);
    float camDist = length(uCamPos - vWorldPos);

    // Ground micro-relief. The mesh is smooth by design; the lumps and tufts
    // that make real ground read as a surface come from perturbing the normal
    // with the gradient of a noise field. Two scales, faded out with distance
    // so the horizon stays calm and nothing aliases.
    // Ground micro-relief: perturb the normal by the gradient of a noise
    // field so the lighting picks up lumps the mesh does not have. Three
    // single-octave taps instead of three fbm stacks, and only within 30 m —
    // beyond that it was invisible and pure cost.
    if (uMaterial == 6 && uDetail > 0 && camDist < 50.0) {
        float bumpFade = 1.0 - smoothstep(22.0, 50.0, camDist);
        if (bumpFade > 0.01) {
            // the gradient is already in the texture — no taps, no differences
            vec2 g = texture(uDetailTex, vWorldPos.xz * 0.125).rg * 2.0 - 1.0;
            N = normalize(N + vec3(-g.x, 0.0, -g.y) * 1.7 * bumpFade);
        }
    }
    // Traced rays only for what is close enough to read. Past this the aerial
    // perspective has eaten the contrast, and the box set the CPU sent is all
    // near the camera anyway, so a distant pixel would trace against nothing
    // useful at full cost.
    bool traceHere = camDist < 60.0;
    vec2 spec = surfaceSpec();

    // ---- ambient: sky above, bounce below ----------------------------------
    // A hemisphere term instead of one flat number. Upward faces catch sky,
    // downward faces catch warm ground bounce, so shadowed sides stay readable
    // and everything gains a sense of being outdoors.
    float hemi = N.y * 0.5 + 0.5;
    vec3 ambient = mix(uGroundColor, uSkyColor, hemi) * albedo;

    vec3 color = ambient;

    {   // ---- sun -----------------------------------------------------------
        float diff = max(dot(N, L), 0.0);
        float shadow = 1.0;
        if (uDetail >= 2 && traceHere && diff > 0.0 &&
            occluded(vWorldPos + N * 0.02, L, 60.0))
            shadow = 0.0;   // ambient alone carries the shadow now
        // Terrain carries its own shadow, baked per vertex when the tile was
        // meshed: hills shading valleys, at the cost of one multiply. It is
        // gated on the material because the attribute is disabled elsewhere.
        if (uMaterial == 6) shadow *= mix(0.12, 1.0, clamp(vSun, 0.0, 1.0));

        float s = 0.0;
        if (uDetail > 0 && diff > 0.0) {
            vec3 H = normalize(L + V);
            s = pow(max(dot(N, H), 0.0), spec.y) * spec.x;
            // Schlick fresnel: grazing angles get brighter, which is most of
            // what sells wet-looking concrete and metal
            float f = pow(1.0 - max(dot(H, V), 0.0), 5.0);
            s *= mix(1.0, 3.0, f);
        }
        color += uSunColor * shadow * (diff * albedo + s);
    }

    for (int i = 0; i < uNumPointLights; ++i) {
        vec3 toL = uPointLightPos[i] - vWorldPos;
        float d = length(toL);
        vec3 PL = toL / max(d, 0.0001);
        if (uDetail >= 3 && i == 0 && traceHere && d < 30.0 &&
            occluded(vWorldPos + N * 0.02, PL, d - 0.1)) continue;
        float atten = 1.0 / (1.0 + 0.30 * d + 0.16 * d * d);
        float diff = max(dot(N, PL), 0.0);
        float s = 0.0;
        if (uDetail > 0) {
            vec3 H = normalize(PL + V);
            s = pow(max(dot(N, H), 0.0), spec.y) * spec.x;
        }
        color += uPointLightColor[i] * atten * (diff * albedo + s);
    }

    // ---- traced reflections (ultra, concrete only) -------------------------
    if (uDetail >= 3 && traceHere && uMaterial == 1) {
        vec3 rdir = reflect(-V, N);
        float rt;
        int hit = traceNearest(vWorldPos + N * 0.02, rdir, 50.0, rt);
        float fres = pow(1.0 - max(dot(N, V), 0.0), 4.0);
        vec3 rcol = hit >= 0 ? toLinear(uBoxColor[hit]) * exp(-rt * 0.09)
                             : uSkyColor * 1.6;   // nothing there: it sees sky
        color = mix(color, rcol, 0.06 + 0.30 * fres);
    }

    // ---- aerial perspective ------------------------------------------------
    // Distance haze tinted toward the sun, so looking into it washes out and
    // looking away stays crisp. This is the depth cue that makes a 500 m view
    // read as landscape instead of as a flat backdrop.
    float fog = 1.0 - exp(-pow(camDist * 0.0055, 2.0));
    float towardSun = max(dot(normalize(vWorldPos - uCamPos), L), 0.0);
    vec3 fogCol = mix(uFogColor, uFogColor * 1.5 + uSunColor * 0.10,
                      pow(towardSun, 5.0));
    color = mix(color, fogCol, fog);

    // ---- output ------------------------------------------------------------
    color = tonemapACES(color * uExposure);
    color = pow(color, vec3(1.0 / 2.2));   // back to sRGB for the framebuffer

    vec2 q = gl_FragCoord.xy / uResolution - 0.5;
    color *= 1.0 - 0.28 * dot(q, q) * 1.8;   // light vignette, not a tunnel

    FragColor = vec4(color, 1.0);
}
