

float ComputeOcclusion(vec3 N, vec3 P)
{
    float fovScale = gbufferProjection[1][1] * 0.36397;
    float radiusPix = fovScale * 0.1;
    float maxDist2 = P.z * P.z * fovScale * 0.22592;
    float invMaxDist2 = 1.0 / maxDist2;
    float falloffFactor = -2.88539 * invMaxDist2;

    float noise = blueNoise(gl_FragCoord.xy);
    float baseAngle = noise * 6.4;
    float alpha = noise;

    vec2 viewResF = vec2(viewResolution);
    vec2 kernelScale = alpha * radiusPix * vec2(viewResolution.x, viewResolution.y * aspectRatio);
    vec2 invRes = 1.0 / viewResF;

    vec2 accum = vec2(0.0);

    // only compute sin/cos ONCE
    vec2 dir = vec2(cos(baseAngle), sin(baseAngle));
    ivec2 resMinusOne = ivec2(viewResolution) - ivec2(1);

    for (int i = 0; i < 4; ++i)
    {
        vec2 sampleCoord = gl_FragCoord.xy + dir * kernelScale;
        ivec2 tc = ivec2(sampleCoord);
        tc = clamp(tc, ivec2(0), resMinusOne);

        float dSample = texelFetch(depthtex2, tc, 0).r;
        vec3 posS = toScreenSpace(vec3((vec2(tc) + 0.5) * invRes, dSample));

        vec3 d = posS - P;
        float d2 = dot(d, d);

        float mask = step(1e-5, d2) * step(d2, maxDist2);
        float nd = dot(d, N) * inversesqrt(max(d2, 1e-5));
        float atten = exp2(d2 * falloffFactor);

        accum += vec2(mask * nd * atten, mask);

        // rotate dir by 90 degrees CCW
        dir = vec2(dir.y, -dir.x);
    }

    return smoothstep(0.5, 1.0, 1.0 - (accum.x / max(accum.y, 1e-5)));
}

/* ─────────────────────────  compile-time switch  ──────────────────────── */
// #define STOCHASTIC_AO // <-- uncomment to use 1-tap tracing
/* ───────────────────────────────────────────────────────────────────────── */

float rtao(vec3 N, vec3 P)
{
    /* constants ------------------------------------------------------ */
    const float PHI = 1.61803398875;
    const float TWO_PI = 6.28318530718;

#ifndef STOCHASTIC_AO
    const int MAX_STEPS = 16; // march budget (ignored in stochastic)
#endif
    const int RAYS = 8;

    const float DEPTH_EPS = 0.035;
    const float SQRT3 = 1.73205080757;
    const float RAY_MIN = 0.0;
    const float RAY_MAX = PI;

    /* tangent frame -------------------------------------------------- */
    vec3 nV = worldToView(N);
    vec3 up = (abs(nV.z) < 0.999) ? vec3(0, 0, 1) : vec3(1, 0, 0);
    vec3 T = normalize(cross(up, nV));
    vec3 B = cross(nV, T);

    float dith = blueNoise(gl_FragCoord.xy); // 0‥1 blue-noise
    vec3 clipSurf = toClipSpace3(P);

    float selfBias = mix(0.001, 0.01, clamp(P.z / far, 0.0, 1.0));

    float occ = 0.0; // accumulated occlusion

    /* ray loop ------------------------------------------------------- */
    for (int r = 0; r < RAYS; ++r)
    {
        /* Vogel hemisphere sample ------------------------------------ */
        float idx = float(r) + dith;
        float u = idx * (1.0 / float(RAYS));
        float phi = TWO_PI * fract(idx * PHI);
        float cosA = u;
        float sinA = sqrt(max(0.0, 1.0 - cosA * cosA));

        vec3 dir = normalize(T * (sinA * cos(phi)) + B * (sinA * sin(phi)) + nV * cosA);

        /* frustum-bound length --------------------------------------- */
        float maxLen = (dir.z > 0.0) ? (SQRT3 * far - P.z) / dir.z : (-SQRT3 * near - P.z) / dir.z;
        maxLen = abs(maxLen);
        if (maxLen <= RAY_MIN)
            continue;

        float lenBeg = RAY_MIN;
        float lenEnd = min(RAY_MAX, maxLen);
        float rayLen = lenEnd - lenBeg;
        if (rayLen <= 0.0)
            continue;

#ifdef STOCHASTIC_AO
        /* ─────── stochastic 1-tap trace ───────────────────────────── */

        /* pick ONE random point along the segment -------------------- */
        float tRay = (fract(idx * PI + dith) /* extra hash */);
        vec3 posW = P + dir * (lenBeg + tRay * rayLen);
        vec3 clip = toClipSpace3(posW);

        float zs = ld(texelFetch(depthtex2,
                                 ivec2(clip.xy / texelSize), 0)
                          .r);
        float zr = ld(clip.z);
        float dz = zr - zs;

        float tol = DEPTH_EPS;
        float hitWeight = RAY_MAX / rayLen; // ≥1 for short rays

        if (dz > selfBias && dz <= tol)
            occ += min(hitWeight, 3.0);

#else
        /* ─────── marching trace (16 steps) ────────────────────────── */

        float stepW = rayLen / float(MAX_STEPS);
        vec3 posW = P + dir * (lenBeg + stepW * dith); // jitter first step

        for (int s = 0; s < MAX_STEPS; ++s, posW += dir * stepW)
        {
            vec3 clip = toClipSpace3(posW);

            float zs = ld(texelFetch(depthtex2,
                                     ivec2(clip.xy / texelSize), 0)
                              .r);
            float zr = ld(clip.z);
            float dz = zr - zs;

            if (dz > selfBias && dz <= DEPTH_EPS * zr)
            {
                occ += distance(vec3(clip.xy, zs), clipSurf); // weighted hit
                break;
            }
        }
#endif
    }

#ifdef STOCHASTIC_AO
    /* binary accumulation → divide by RAYS */
    float ao = clamp(1.0 - occ / float(RAYS), 0.0, 1.0);
#else
    /* distance-weighted + dithering (as before) */
    float ditherAmp = 1.0 / float(RAYS);
    float ao = clamp(1.0 - occ / float(RAYS) + (dith - 0.5) * ditherAmp, 0.0, 1.0);
#endif
    return ao;
}
