/*---------------------------------------------------------------------
    Standalone Voxel Flood-Fill Global Illumination (CUSTOM_GI_VOXEL)
    ------------------------------------------------------------------
    Built from scratch, independent of the existing ACT colored-light
    flood-fill (voxel_img / floodfill_img). This system has its own:
      - gi_voxel_img          (rgba8, albedo.rgb + opacity.a)
      - gi_radiance_a_img     (rgba16f, ping-pong radiance buffer A)
      - gi_radiance_b_img     (rgba16f, ping-pong radiance buffer B)

    Pipeline (see shaders.properties + program/*.glsl):
      1. begin.csh          - clears all 3 images every frame
      2. shadow.fsh         - voxelizes albedo+opacity per fragment (OVERWORLD)
      3. prepare1.csh       - direct sun/moon light injection -> gi_radiance_a
      4. prepare2..N.csh    - bounce propagation, ping-ponging A<->B
      5. deferred1.fsh      - trilinear sample + composite into scene color
---------------------------------------------------------------------*/
#ifndef INCLUDE_GI_FLOODFILL
    #define INCLUDE_GI_FLOODFILL

    const ivec3 giVolumeSize = ivec3(GI_VOXEL_RESOLUTION);

    // World-space (scene/player-space, already camera-relative in this engine)
    // position -> voxel-space coordinate, centered on the camera and snapped
    // via cameraPositionFract to avoid "swimming" as the camera moves.
    // Offset Y down by 2 voxels so the grid covers more of the scene below
    // eye level (floors, walls, ground) where bounce light matters most.
    const float GI_Y_OFFSET = 2.0;
    vec3 GiSceneToVoxel(vec3 scenePos) {
        return (scenePos + cameraPositionFract) / GI_VOXEL_SIZE + (0.5 * vec3(giVolumeSize)) + vec3(0.0, GI_Y_OFFSET, 0.0);
    }

    // Same transform, but parameterized by an arbitrary camera fractional
    // offset instead of always using this frame's cameraPositionFract. Used
    // for temporal reprojection: last frame's grid was built using LAST
    // frame's fractional camera offset, so re-deriving "where did this
    // world position land in last frame's grid" needs previousCameraPositionFract
    // here, not the current one, or every voxel index would read a
    // slightly wrong world location every single frame the camera moves -
    // which reads as constant flicker/"gerak-gerak" even while standing still
    // and just turning the camera, since previousCameraPositionFract still
    // shifts a little between frames from player motion.
    vec3 GiSceneToVoxelWithFract(vec3 scenePos, vec3 fractOffset) {
        return (scenePos + fractOffset) / GI_VOXEL_SIZE + (0.5 * vec3(giVolumeSize)) + vec3(0.0, GI_Y_OFFSET, 0.0);
    }

    bool GiCheckInsideVolume(vec3 voxelPos) {
        vec3 t = voxelPos / vec3(giVolumeSize);
        return clamp01(t) == t;
    }

    // Everything below reads from gi_voxel_sampler / a radiance sampler, so
    // it's only compiled in stages that actually declare those uniforms
    // (prepare1.glsl, giPropagateBody.glsl, deferred1.glsl). shadow.fsh only
    // writes to the grid (writeonly image) and never reads it, so without
    // this guard it would fail to compile there with "gi_voxel_sampler:
    // undeclared identifier" even though these functions are never called.
    #ifdef GI_NEED_READ_SAMPLERS

    // Read-only sampler-bound accessors (used in compute/composite passes;
    // the writeonly image bindings used during voxelization/injection are
    // declared directly at each call site since Iris requires distinct
    // read vs write image qualifiers per program stage).
    vec4 GiLoadAlbedoOpacity(ivec3 pos) {
        return texelFetch(gi_voxel_sampler, pos, 0);
    }

    vec4 GiLoadRadiance(sampler3D radSampler, ivec3 pos) {
        return texelFetch(radSampler, pos, 0);
    }

    // Cheap occupancy-gradient normal estimate for a solid voxel: points
    // from solid into the nearest open/air neighbor. Used only for the
    // direct light injection pass (prepare1.csh), since the albedo/opacity
    // grid doesn't store real geometric normals.
    //
    // `isThin` (out param) flags the degenerate case: a voxel whose
    // occupancy is symmetric on every axis (air on both sides in one axis,
    // solid on both sides in another) has a zero gradient - this happens
    // for any solid feature that is only 1 voxel thick perpendicular to its
    // own faces (e.g. a 1-block pillar/column when GI_VOXEL_SIZE makes a
    // whole block fit in a single voxel row). In that case there is
    // physically only ONE grid cell doing double duty for BOTH opposite
    // faces of the feature - whatever light gets written there is read
    // back identically from either side, which is why a pillar lit on one
    // side can appear equally lit on the shadowed side too. No normal
    // estimate fixes that storage limit (it needs a finer GI_VOXEL_SIZE to
    // actually resolve), but callers use `isThin` to at least avoid
    // compounding it with an arbitrary, wrong hemisphere restriction based
    // on the meaningless default normal below.
    vec3 GiEstimateVoxelNormalEx(ivec3 pos, out bool isThin) {
        float xn = GiLoadAlbedoOpacity(pos - ivec3(1, 0, 0)).a;
        float xp = GiLoadAlbedoOpacity(pos + ivec3(1, 0, 0)).a;
        float yn = GiLoadAlbedoOpacity(pos - ivec3(0, 1, 0)).a;
        float yp = GiLoadAlbedoOpacity(pos + ivec3(0, 1, 0)).a;
        float zn = GiLoadAlbedoOpacity(pos - ivec3(0, 0, 1)).a;
        float zp = GiLoadAlbedoOpacity(pos + ivec3(0, 0, 1)).a;

        vec3 n = vec3(xn - xp, yn - yp, zn - zp);
        float len = length(n);
        isThin = len <= 0.0001;
        return isThin ? vec3(0.0, 1.0, 0.0) : n / len;
    }

    vec3 GiEstimateVoxelNormal(ivec3 pos) {
        bool unusedIsThin;
        return GiEstimateVoxelNormalEx(pos, unusedIsThin);
    }

    // Manual trilinear fetch of the radiance volume. Hardware filtering of a
    // 3D image-backed sampler is not guaranteed on every driver (Iris often
    // binds these as NEAREST), so we interpolate the 8 surrounding texels
    // ourselves. THIS is what gives the bounce its soft, PCF-shadow-like
    // falloff instead of hard voxel blocks ("perkotak"). Every higher-level
    // sample below funnels through here.
    vec3 GiSampleRadianceTrilinear(sampler3D radSampler, vec3 voxelCoord) {
        // Outside the voxel volume there is no data - return black instead of
        // clamping to the edge texel (which smeared a bright/dark ring at the
        // grid boundary and read as a moving "artifact" as the grid swept).
        if (any(lessThan(voxelCoord, vec3(0.0))) || any(greaterThanEqual(voxelCoord, vec3(giVolumeSize)))) return vec3(0.0);

        // Soft edge fade: within GI_EDGE_FADE voxels of the volume boundary
        // the radiance ramps smoothly to zero instead of cutting off at the
        // edge. Without this, the voxel volume's outer faces show up as a
        // dark "border shadow" that follows the player as the grid moves.
        vec3 edgeDist = min(voxelCoord, vec3(giVolumeSize) - voxelCoord);
        float edgeFade = clamp01(min(edgeDist.x, min(edgeDist.y, edgeDist.z)) / float(GI_EDGE_FADE));

        vec3 texPos = voxelCoord - 0.5;
        ivec3 base = ivec3(floor(texPos));
        vec3 f = clamp01(texPos - vec3(base));
        vec3 result = vec3(0.0);
        for (int z = 0; z <= 1; z++) {
            for (int y = 0; y <= 1; y++) {
                for (int x = 0; x <= 1; x++) {
                    ivec3 p = clamp(base + ivec3(x, y, z), ivec3(0), giVolumeSize - 1);
                    float w = mix(1.0 - f.x, f.x, float(x))
                            * mix(1.0 - f.y, f.y, float(y))
                            * mix(1.0 - f.z, f.z, float(z));
                    result += w * GiLoadRadiance(radSampler, p).rgb;
                }
            }
        }
        return result * edgeFade;
    }

    // Smoothing filter on top of the trilinear fetch: averages a small
    // cross-pattern of extra samples spread out by GI_BLUR_STRENGTH voxels.
    // GI_BLUR_STRENGTH previously existed only as a settings-menu slider
    // with no code behind it, so raising bounce energy (GI_BOUNCE_PASSES /
    // GI_DECAY) had nothing to soften the per-frame stochastic ray noise -
    // it just showed up directly as bright white speckles. This gives that
    // slider an actual smoothing effect on the noisy bounce result.
    //
    // IMPORTANT: the extra taps are offset along the TANGENT PLANE of the
    // hit surface (perpendicular to `normal`), not along raw world X/Y/Z.
    // The old version offset along X/Y/Z directly, which for any
    // axis-aligned wall (the overwhelming majority of Minecraft geometry)
    // meant one of those three offset directions pointed STRAIGHT THROUGH
    // the wall's thickness - on thin (1-2 voxel) walls this pulled in
    // radiance from the room on the other side, reading as bounce light
    // "leaking"/tembus through solid blocks. Blurring only sideways along
    // the surface keeps every extra tap on the SAME side of the wall as
    // the original hit, so it can still smooth noise across a surface but
    // can no longer cross through it.
    vec3 GiSampleRadianceBlurred(sampler3D radSampler, vec3 voxelCoord, vec3 normal) {
        float r = float(GI_BLUR_STRENGTH) * 0.5;

        #if GI_LEAK_REDUCTION == 1
            // GI_LEAK_REDUCTION existed only as a settings-menu slider with
            // no code behind it (same situation GI_BLUR_STRENGTH was in
            // before it got wired up). Cap how far the tangential blur can
            // reach: at a convex corner (e.g. the top edge of a block) the
            // tangent plane of one face runs close to a second, differently
            // lit face just past the edge, so a large blur radius can still
            // pick up a sliver of that neighbouring face even though it's
            // now confined to the tangent plane. Capping the reach trades a
            // little extra smoothness on very high GI_BLUR_STRENGTH values
            // for keeping the sample away from that neighbouring face.
            // Cap raised from 1.0 to 2.0, then to 3.0 ("bounce masih
            // blocky/kotak di sudut ruangan" fix - see GI_BLUR_STRENGTH in
            // common.glsl) so this fallback sample (now only a minority of
            // the final GI via GI_RAY_BLEND) smooths the coarse voxel grid
            // into a soft wash instead of painting per-voxel "bulet-bulet"
            // blobs or a visible step at corners. The leak-proof ray trace
            // still dominates the final result.
            r = min(r, 3.0);
        #endif

        vec3 up = abs(normal.y) < 0.99 ? vec3(0.0, 1.0, 0.0) : vec3(1.0, 0.0, 0.0);
        vec3 tangent = normalize(cross(up, normal));
        vec3 bitangent = cross(normal, tangent);

        // FIX (pasal "blur kurang smooth / cat air"): the old 4-tap cross
        // (N/S/E/W on the tangent plane only) still left visible per-voxel
        // grain between taps - a true cross has gaps at the diagonals, so
        // noise there passed through almost untouched. Adding the 4
        // diagonal taps (at 0.7x radius, matching a proper 3x3 blur
        // footprint) closes those gaps for a softer, more even falloff -
        // closer to a watercolor wash than a thin plus-sign.
        vec3 result = GiSampleRadianceTrilinear(radSampler, voxelCoord) * 0.28;
        result += GiSampleRadianceTrilinear(radSampler, voxelCoord + tangent   *  r) * 0.11;
        result += GiSampleRadianceTrilinear(radSampler, voxelCoord - tangent   *  r) * 0.11;
        result += GiSampleRadianceTrilinear(radSampler, voxelCoord + bitangent *  r) * 0.11;
        result += GiSampleRadianceTrilinear(radSampler, voxelCoord - bitangent *  r) * 0.11;
        vec3 diagA = (tangent + bitangent) * (r * 0.7071);
        vec3 diagB = (tangent - bitangent) * (r * 0.7071);
        result += GiSampleRadianceTrilinear(radSampler, voxelCoord + diagA) * 0.07;
        result += GiSampleRadianceTrilinear(radSampler, voxelCoord - diagA) * 0.07;
        result += GiSampleRadianceTrilinear(radSampler, voxelCoord + diagB) * 0.07;
        result += GiSampleRadianceTrilinear(radSampler, voxelCoord - diagB) * 0.07;
        return result;
    }

    // ------------------------------------------------------------------
    //  PTGI-STYLE STOCHASTIC DIFFUSE GI  (SEUS PTGI / DDGI approach)
    // ------------------------------------------------------------------
    //  Instead of a fixed cone, we cast a few COSINE-WEIGHTED (Lambertian)
    //  STOCHASTIC rays per pixel into the voxel radiance volume. Every ray
    //  is a real DDA (Amanatides-Woo) visibility trace, so it STOPS at the
    //  first solid voxel -> light physically cannot leak through walls or
    //  ceilings (fixes "kebocoran"). The hit voxel's stored radiance (its
    //  own direct sun + already-propagated bounce, i.e. the colour of the
    //  block the sunlight struck) IS the incoming indirect light from that
    //  direction. Averaging the rays gives this frame's diffuse GI.
    //
    //  Because the rays are seeded by frameCounter, every frame is a
    //  slightly different noisy estimate. Iris's TEMPORAL ANTIALIASING
    //  (TAA) then accumulates those frames into the smooth, soft, coloured
    //  indirect light - exactly how SEUS PTGI converges its stochastic path
    //  tracing. KEEP TAA ON in Iris for this to look right. The multi-bounce
    //  floodfill (GI_BOUNCE_PASSES) is the "path tracing" part: energy
    //  bounces several times through the grid before a ray samples it.
    // ------------------------------------------------------------------

    // 3D DDA (Amanatides-Woo) voxel ray march through the GI albedo/opacity
    // grid. Steps voxel-by-voxel along the ray, testing real occupancy
    // each step, and returns the first solid voxel hit (or false if the
    // ray escapes the volume/runs out of steps) - an actual visibility
    // trace, not an assumption about where light "should" be. Defined here
    // (before the stochastic sampler) so GLSL ES 1.00 sees it on first use.
    //
    // "cahaya tembus kaca berwarna / air" fix: voxels written with a partial
    // opacity (colored glass/panes, water, ice, glass - see giIsTransmissive
    // in shadow.fsh) are NOT solid, so the ray passes through them instead of
    // stopping, and their tinted albedo is accumulated into `transTint`.
    // The caller multiplies the bounced radiance by that accumulated color,
    // so light that travelled through a colored glass window comes back
    // tinted by the glass color, and light entering water comes back tinted
    // by the water color - instead of both reading as transparent "holes".
    bool GiTraceRay(vec3 startVoxelPos, vec3 dir, int maxSteps, out ivec3 hitVoxel, out float hitDist, out vec3 transTint) {
        hitDist = 0.0;
        transTint = vec3(1.0);
        vec3 safeDir = dir + vec3(equal(dir, vec3(0.0))) * 1e-6;
        ivec3 voxel = ivec3(floor(startVoxelPos));

        // Self-intersection guard: if the ray origin still lands inside a
        // solid voxel (can happen on thin geometry/steep angles where the
        // caller's outward push isn't enough), stepping straight into the
        // occupancy test below would immediately "hit" at t=0 and return
        // that voxel's OWN radiance instead of a real neighbour's - this is
        // what made the bounce read as weak/flat and sometimes look like it
        // was coming from the wrong side. Nudge forward along the ray
        // direction (not the surface normal, since that's what actually
        // gets the origin out of the solid) until it clears, or bail after
        // a few tries so a genuinely buried origin still just returns "no
        // hit" instead of a false self-reading.
        for (int esc = 0; esc < 4 && GiCheckInsideVolume(vec3(voxel)) && GiLoadAlbedoOpacity(voxel).a > 0.5; esc++) {
            startVoxelPos += safeDir * 1.0;
            voxel = ivec3(floor(startVoxelPos));
        }

        vec3 stepDir = sign(safeDir);
        vec3 tDelta = abs(1.0 / safeDir);

        vec3 voxelBound = vec3(voxel) + max(stepDir, vec3(0.0));
        vec3 tMax = (voxelBound - startVoxelPos) / safeDir;
        float t = 0.0;

        for (int i = 0; i < maxSteps; i++) {
            if (!GiCheckInsideVolume(vec3(voxel))) return false;

            vec4 occ = GiLoadAlbedoOpacity(voxel);
            if (occ.a > 0.5) {
                hitVoxel = voxel;
                hitDist = t;
                return true;
            }

            // Transmissive tint accumulation: the voxel is not solid (a <= 0.5)
            // but still has content (colored glass / water, written with a
            // partial opacity in shadow.fsh). Mix its albedo into the ray's
            // running tint so light passing through carries the material color.
            // oc.a is pushed 3x so a 0.3-opacity glass reads at 0.9 strength
            // (its color dominates the result instead of washing out to grey),
            // and the albedo is gamma-ramped (pow 1.8) so a low channel (e.g.
            // green/blue through red glass) is suppressed instead of reading as
            // a washed-out white - that is what made the tint look pale before.
            if (occ.a > 0.01) {
                vec3 alb = clamp(occ.rgb, 0.0, 1.0);
                float strength = clamp(occ.a * 3.0, 0.0, 0.95);
                vec3 tint = mix(vec3(1.0), pow(alb, vec3(1.8)), strength);
                transTint *= clamp(tint, 0.02, 1.0);
            }

            if (tMax.x < tMax.y) {
                if (tMax.x < tMax.z) { voxel.x += int(stepDir.x); t = tMax.x; tMax.x += tDelta.x; }
                else { voxel.z += int(stepDir.z); t = tMax.z; tMax.z += tDelta.z; }
            } else {
                if (tMax.y < tMax.z) { voxel.y += int(stepDir.y); t = tMax.y; tMax.y += tDelta.y; }
                else { voxel.z += int(stepDir.z); t = tMax.z; tMax.z += tDelta.z; }
            }
        }
        return false;
    }

    // Cheap 2D hash -> vec2 in [0,1). Per-pixel + per-frame seed.
    vec2 GiHash22(vec2 p) {
        vec3 p3 = fract(vec3(p.xyx) * vec3(0.1031, 0.1030, 0.0973));
        p3 += dot(p3, p3.yzx + 33.33);
        return fract((p3.xx + p3.yz) * p3.zy);
    }

    // Cosine-weighted hemisphere sample around normal n (Lambertian
    // importance sampling - the same math real path tracers use, so the
    // estimate needs no extra weighting term).
    // FIX (pasal "proyeksi bounce ke sudut, bukan ke atas"): same issue as
    // GetScreenSpaceGI in deferred1.glsl - the old hard switch
    // (abs(n.y) < 0.99 ? ...) picked its reference axis based on world Y,
    // which is unstable right where n itself approaches world-up/down -
    // exactly a floor or ceiling voxel's normal. Near that threshold the
    // tangent frame direction is dominated by rounding noise, so rays meant
    // to spread evenly across the hemisphere above a floor instead biased
    // toward one corner. Side-facing voxel normals (walls) never get near
    // that axis, so they were never affected. Replaced with Duff et al.'s
    // branchless orthonormal basis - continuous for every normal direction,
    // no threshold, no unstable zone.
    vec3 GiCosineSampleHemisphere(vec3 n, vec2 u) {
        float r = sqrt(u.x);
        float phi = 6.28318530718 * u.y;
        float dsign = n.z >= 0.0 ? 1.0 : -1.0;
        float da = -1.0 / (dsign + n.z);
        float db = n.x * n.y * da;
        vec3 tangent   = vec3(1.0 + dsign * n.x * n.x * da, dsign * db, -dsign * n.x);
        vec3 bitangent = vec3(db, dsign + n.y * n.y * da, -n.y);
        return normalize(tangent * (r * cos(phi)) + bitangent * (r * sin(phi)) + n * sqrt(max(1.0 - u.x, 0.0)));
    }

    // Smooth radiance fetch: average the hit SOLID voxel's radiance with its
    // neighbouring SOLID voxels only. Air neighbours are skipped (their
    // radiance is ~0), so there is no darkening / "half-coloured squares", and
    // adjacent differently-coloured blocks blend across the voxel boundary
    // instead of showing a hard block edge. This is what removes the blocky
    // bounce artefact while keeping the per-voxel hit stable (no sparkle).
    vec3 GiSampleRadianceSmoothed(sampler3D rad, ivec3 hit) {
        vec3 sum = GiLoadRadiance(rad, hit).rgb;
        float w = 1.0;
        ivec3 n;
        n = hit + ivec3( 1, 0, 0); if (GiCheckInsideVolume(vec3(n)) && GiLoadAlbedoOpacity(n).a > 0.5) { sum += GiLoadRadiance(rad, n).rgb; w += 1.0; }
        n = hit + ivec3(-1, 0, 0); if (GiCheckInsideVolume(vec3(n)) && GiLoadAlbedoOpacity(n).a > 0.5) { sum += GiLoadRadiance(rad, n).rgb; w += 1.0; }
        n = hit + ivec3( 0, 1, 0); if (GiCheckInsideVolume(vec3(n)) && GiLoadAlbedoOpacity(n).a > 0.5) { sum += GiLoadRadiance(rad, n).rgb; w += 1.0; }
        n = hit + ivec3( 0,-1, 0); if (GiCheckInsideVolume(vec3(n)) && GiLoadAlbedoOpacity(n).a > 0.5) { sum += GiLoadRadiance(rad, n).rgb; w += 1.0; }
        n = hit + ivec3( 0, 0, 1); if (GiCheckInsideVolume(vec3(n)) && GiLoadAlbedoOpacity(n).a > 0.5) { sum += GiLoadRadiance(rad, n).rgb; w += 1.0; }
        n = hit + ivec3( 0, 0,-1); if (GiCheckInsideVolume(vec3(n)) && GiLoadAlbedoOpacity(n).a > 0.5) { sum += GiLoadRadiance(rad, n).rgb; w += 1.0; }
        return sum / w;
    }

    // ------------------------------------------------------------------
    //  ROOM-AWARE RAY-TRACED BOUNCE  (all-direction hemisphere sampling)
    // ------------------------------------------------------------------
    //  Replaces the old single-point sample (one blurred voxel fetch at the
    //  surface point, which showed up as visible "bulet-bulet" blobs near &
    //  far and had no idea what was around the receiver) with a real
    //  hemispherical ray trace: GI_RAYS_PER_PIXEL cosine-weighted (Lambertian)
    //  rays are cast from the surface in ALL directions of the hemisphere
    //  above it. Every ray is a DDA visibility trace (GiTraceRay) that STOPS
    //  at the first solid voxel, so:
    //    * Inside a room the rays fan out and hit the floor, walls and
    //      ceiling on every side, pulling their radiance back to the
    //      receiver - bounce now actually travels around the room in all
    //      directions ("nyebar semua arah") instead of a local blob.
    //    * Light physically cannot leak through walls/ceilings (rays stop
    //      there), and each surface is lit by what its hemisphere actually
    //      sees, so bounce lands on the correct side of the room.
    //    * The directionally-averaged footprint is wide and smooth, so the
    //      per-voxel blob pattern disappears entirely (near and far).
    //  Rays that escape the volume (open sky) add a small flat ambient term
    //  (GI_SKY_AMBIENT) so open fields keep a broad, non-blobby bounce.
    //  The seed is derived from the pixel position only (not frameCounter),
    //  so standing still produces an IDENTICAL result every frame - no
    //  per-frame re-roll = no "gerak-gerak / flicker".
    //
    //  ADAPTIVE ROOM MEASUREMENT ("otomatis nyesuaiin ruangan, bukan
    //  slider"): the same hemisphere of rays already tells us how big/
    //  enclosed the space around the receiver is - we don't need a
    //  separate pass or a manual number for that. Two things are measured
    //  directly off the DDA hits themselves and handed back to the caller:
    //    - avgHitDist:   average distance (in voxels) rays travelled
    //                     before hitting something (escaped rays count as
    //                     the full trace length, i.e. "far"). Small in a
    //                     closet, large in a cave/hall, maxed out outdoors.
    //    - enclosure:    fraction of rays that hit a surface at all vs
    //                     escaped to open sky. ~1.0 = fully enclosed room,
    //                     ~0.0 = standing in the open.
    //  The caller uses these two numbers to scale intensity/spread instead
    //  of a fixed GI_INTENSITY slider - see the "Adaptive room response"
    //  block in deferred1.glsl.
    vec3 GiSampleRadianceRayTraced(sampler3D radSampler, vec3 voxelCoord, vec3 normal, vec2 seed,
                                    float daylightFactor, out float avgHitDist, out float enclosure) {
        vec3 acc = vec3(0.0);
        float weight = 0.0;
        float distSum = 0.0;
        float hitCount = 0.0;

        for (int i = 0; i < GI_RAYS_PER_PIXEL; i++) {
            vec2 u = GiHash22(seed + vec2(float(i) * 13.17, float(i) * 41.71));
            vec3 dir = GiCosineSampleHemisphere(normal, u);

            ivec3 hit;
            float hd;
            vec3 tt;
            if (!GiTraceRay(voxelCoord, dir, GI_TRACE_STEPS, hit, hd, tt)) {
                // ("malam/cave masih cerah" fix): this ambient term used to be
                // a flat constant added for every ray that escaped to open
                // sky, with no day/night gating - so any room with a sky
                // opening (window, gap, doorway) kept a sunlit-looking ambient
                // bounce even well into the night. Scaling it by daylightFactor
                // (0 at night, 1 in daytime) makes it fade out correctly.
                acc += vec3(GI_SKY_AMBIENT) * daylightFactor * tt;
                weight += 1.0;
                distSum += float(GI_TRACE_STEPS); // escaped = counts as "far"
                continue;
            }

            // Radiance at the hit surface: blend the hit voxel's own stored
            // radiance (averaged over its solid neighbours so a single voxel
            // can't read as a hard block) with the light that propagated into
            // the AIR cell just in front of the hit face. Pulling the air
            // sample back from the face along the outward normal reads the
            // wall's OUTGOING bounce, never its raw self-lighting.
            vec3 solidRad = GiSampleRadianceSmoothed(radSampler, hit);
            vec3 hitN = GiEstimateVoxelNormal(hit);
            if (dot(hitN, dir) > 0.0) hitN = -hitN;
            vec3 hitPos = voxelCoord + dir * max(hd - 0.75, 0.0);
            vec3 airPos = clamp(hitPos - hitN * 0.25, vec3(0.0), vec3(giVolumeSize) - vec3(0.001));
            vec3 airRad = GiSampleRadianceTrilinear(radSampler, airPos);

            acc += mix(airRad, solidRad, 0.35) * tt;
            weight += 1.0;
            distSum += hd;
            hitCount += 1.0;
        }

        avgHitDist = distSum / max(weight, 1.0);
        enclosure  = hitCount / max(weight, 1.0);
        return acc / max(weight, 1.0);
    }

    // Include the Irradiance Cache system AFTER all helper functions
    // (GiTraceRay, GiHash22, GiCosineSampleHemisphere, GiEstimateVoxelNormal)
    // so irradianceCache.glsl can reference them.
    #include "/lib/voxelization/irradianceCache.glsl"

    #endif // GI_NEED_READ_SAMPLERS
#endif
