/*---------------------------------------------------------------------
    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.
    // ("posisi light bounce ketinggian kurang pas dibanding ForkReimagined"
    // fix): GI_Y_OFFSET dulunya 2.0, menggeser SELURUH grid 2 blok ke bawah
    // dari posisi kamera secara sengaja (supaya lebih banyak lantai
    // tercakup). Tapi ACT (dipakai untuk colored lighting biasa DAN Solar
    // GI) selalu simetris tepat di tengah kamera, TANPA offset apapun -
    // sama seperti ForkReimagined, yang cuma pakai ACT (tidak punya grid
    // kedua yang asimetris ini). Akibatnya CUSTOM_GI_VOXEL "mengira" posisi
    // kamu 2 blok lebih rendah dari yang sebenarnya, jadi bounce dari
    // sumber di atas kepala (jendela lantai atas, dst) meleset
    // ketinggiannya dibanding ACT/Solar GI yang selalu presisi. Dikembalikan
    // ke 0.0 supaya grid ini simetris juga, konsisten dengan ACT dan dengan
    // ForkReimagined. Trade-off: cakupan ke arah lantai/bawah jadi 2 voxel
    // lebih pendek dari sebelumnya (tapi cakupan ke atas/langit-langit
    // sekarang benar, sebelumnya malah lebih sempit dari yang dikira).
    const float GI_Y_OFFSET = 0.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.
    //
    // `flatness` (out param) - FIX ("kotak putih aneh di sudut/pojok"):
    // a voxel sitting exactly on the SEAM between two shell faces (e.g. the
    // elbow voxel where a wall meets a ceiling) has air on TWO perpendicular
    // sides at once, not one - its gradient length comes out around 1.41
    // instead of 1.0, and the resulting normalized normal points diagonally
    // INTO THE ROOM, roughly halfway between the wall's real normal and the
    // ceiling's real normal. That single ambiguous diagonal normal was then
    // treated exactly like a normal flat-face normal everywhere downstream
    // (emission direction weighting in giPropagateBody STEP 1, gather
    // weighting in STEP 3) - so that one elbow voxel ends up emitting
    // strongly along a direction that doesn't correspond to any real
    // surface, AND gathering light from both the wall-side air and the
    // ceiling-side air simultaneously (each of which a flat voxel only
    // gets from ONE side). The seam voxel effectively double-dips, which is
    // exactly the bright rim/"kotak putih" line traced along every interior
    // wall-ceiling and wall-wall corner in the screenshots. `flatness` is
    // 1.0 for an unambiguous single-face voxel (gradient length ~1.0), and
    // drops toward 0 as more sides open up (edge ~1.41, true 3-way corner
    // ~1.73) - callers multiply their emission/gather contribution by it so
    // ambiguous seam voxels fall back toward neutral instead of glowing.
    vec3 GiEstimateVoxelNormalEx(ivec3 pos, out bool isThin, out float flatness) {
        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;
        // len == 1.0 -> single open face (flat, confident). len > 1.0 -> a
        // seam/corner voxel (2 or 3 open faces at once, ambiguous normal).
        // Map [1.0 .. 1.732] down to [1.0 .. 0.35] so a true corner still
        // keeps a little emission/gather (it's not wrong for a genuine
        // convex corner to catch a bit more light) but a 2-face seam - by
        // far the most common case, every ordinary room corner - is pulled
        // most of the way back toward neutral.
        flatness = isThin ? 1.0 : clamp(1.0 - (len - 1.0) * 0.9, 0.35, 1.0);
        return isThin ? vec3(0.0, 1.0, 0.0) : n / len;
    }

    // Backward-compatible overloads for call sites that don't need flatness.
    vec3 GiEstimateVoxelNormalEx(ivec3 pos, out bool isThin) {
        float unusedFlatness;
        return GiEstimateVoxelNormalEx(pos, isThin, unusedFlatness);
    }

    // Variant that also returns flatness for callers that want to damp
    // their contribution at seams (see giPropagateBody.glsl STEP 1 & 3).
    vec3 GiEstimateVoxelNormalFlat(ivec3 pos, out float flatness) {
        bool unusedIsThin;
        return GiEstimateVoxelNormalEx(pos, unusedIsThin, flatness);
    }

    // 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);
        // ("border/cincin mengelilingi player" fix): plain clamp01(x/GI_EDGE_FADE)
        // is a LINEAR ramp that is flat (1.0) right up until it suddenly
        // starts sloping down - that kink in the derivative is a classic
        // Mach band, and it's exactly what reads as a visible ring/border
        // around the player even though the underlying value never jumps.
        // Feeding the same ratio through a smoothstep curve (0 slope at
        // BOTH ends, t=0 and t=1) removes that kink entirely, so the
        // radiance fades in/out with no perceptible edge - same fade
        // distance, just no more visible seam.
        float edgeT = clamp01(min(edgeDist.x, min(edgeDist.y, edgeDist.z)) / float(GI_EDGE_FADE));
        float edgeFade = edgeT * edgeT * (3.0 - 2.0 * edgeT);

        // FIX ("merah di celah slab" / color bleed through thin walls):
        // The 8-texel trilinear kernel previously had zero awareness of
        // solid boundaries - it freely interpolated radiance from solid
        // voxels on the OTHER side of a thin wall/slab. This caused color
        // from blocks on one side (e.g. red wool) to bleed through the
        // voxel grid into the opposite side's air space. Now each tap
        // checks occupancy: if a tap lands inside a solid voxel, it is
        // replaced with the center sample to prevent cross-boundary bleed.
        vec3 centerSample = GiLoadRadiance(radSampler, clamp(ivec3(floor(voxelCoord)), ivec3(0), giVolumeSize - 1)).rgb;
        vec3 texPos = voxelCoord - 0.5;
        ivec3 base = ivec3(floor(texPos));
        vec3 f = clamp01(texPos - vec3(base));
        vec3 result = vec3(0.0);
        float totalWeight = 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));
                    // Occupancy check: if this tap is inside a solid voxel,
                    // smoothly blend toward center sample to prevent
                    // cross-boundary bleed without creating hard box edges
                    float occupancy = GiLoadAlbedoOpacity(p).a;
                    float guard = smoothstep(0.3, 0.7, occupancy);
                    vec3 tapSample = mix(GiLoadRadiance(radSampler, p).rgb, centerSample, guard);
                    result += w * tapSample;
                    totalWeight += w;
                }
            }
        }
        return (result / max(totalWeight, 1.0)) * 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.
            //
            // ("light bounce kelihatan berbintik/noise" request): this cap
            // was silently eating all of GI_BLUR_STRENGTH's effect - every
            // profile sets GI_BLUR_STRENGTH to 2.5-4.0, so r = strength*0.5
            // (1.25-2.0) was ALWAYS being clamped straight back down to
            // 1.2 regardless of the slider, i.e. every profile got
            // identical, minimal smoothing no matter what GI_BLUR_STRENGTH
            // said. Raised to 1.8 so the slider (and the higher values the
            // ULTRA/CINEMATIC profiles below now use) actually reaches
            // further and smooths out more of the per-frame ray noise -
            // same fixed tap count as before, just spread wider, so this
            // is still free (no extra samples = no extra GPU cost).
            r = min(r, 1.8);
        #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".
    //
    // ("hybrid ray tracing" DDA/normal fix, referensi ApolloRT
    // shaders/lib/raytracing.glsl): DDA loop ini SUDAH TAHU secara eksak
    // sumbu (x/y/z) dan arah mana yang baru saja dilangkahi persis saat
    // sebuah voxel solid ditemukan - itu ADALAH face normal permukaan yang
    // ditabrak, gratis tanpa sampling tambahan (persis teknik hitNormal di
    // ApolloRT RaytraceVoxel2). Sebelumnya caller (GiSampleRadianceRayTraced)
    // malah membuang info ini dan menghitung ulang normal lewat 6x
    // texelFetch tetangga (GiEstimateVoxelNormal) yang cuma perkiraan
    // gradien occupancy - buram dan gagal total di geometri setebal 1 voxel
    // (lihat catatan isThin di GiEstimateVoxelNormalEx). Sekarang exact
    // hit-face normal dikembalikan langsung dari DDA sebagai `hitNormal`,
    // jadi arah pantulan cahaya (light bounce) jadi presisi sesuai bidang
    // yang benar-benar ditabrak, bukan estimasi.
    bool GiTraceRay(vec3 startVoxelPos, vec3 dir, int maxSteps, out ivec3 hitVoxel, out float hitDist, out vec3 transTint, out vec3 hitNormal) {
        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;

        // Fallback normal for the rare case a hit is found before any DDA
        // step has run (degenerate/unresolved self-intersection that
        // survived the escape guard above): point back toward the ray
        // origin along the ray itself rather than leaving it undefined.
        hitNormal = -normalize(safeDir);

        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; hitNormal = vec3(-stepDir.x, 0.0, 0.0); }
                else { voxel.z += int(stepDir.z); t = tMax.z; tMax.z += tDelta.z; hitNormal = vec3(0.0, 0.0, -stepDir.z); }
            } else {
                if (tMax.y < tMax.z) { voxel.y += int(stepDir.y); t = tMax.y; tMax.y += tDelta.y; hitNormal = vec3(0.0, -stepDir.y, 0.0); }
                else { voxel.z += int(stepDir.z); t = tMax.z; tMax.z += tDelta.z; hitNormal = vec3(0.0, 0.0, -stepDir.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 sampling diganti dengan low-discrepancy
    // R2 sequence" - improvement #2): sebelumnya tiap ray i di dalam
    // GiSampleRadianceRayTraced dapat titik hemisphere dari GiHash22(seed +
    // offset tetap per-i) - itu white noise MURNI, independen antar ray.
    // Untuk GI_RAYS_PER_PIXEL yang relatif sedikit (beberapa sampel per
    // pixel per frame), white noise gampang "menggerombol" - beberapa titik
    // berdekatan sementara sebagian hemisphere kosong sama sekali, yang
    // langsung kelihatan sebagai grain tidak merata sebelum TAA sempat
    // meratakannya lewat banyak frame.
    //
    // R2 (additive-recurrence 2D quasi-random, konstanta dari "plastic
    // number") adalah LOW-DISCREPANCY: titik ke-i SELALU mengisi celah
    // terlebar dari titik-titik sebelumnya, jadi N sampel yang sama
    // menghasilkan cakupan hemisphere yang jauh lebih merata daripada N
    // sampel white-noise - noise per-frame lebih halus & konvergen lebih
    // cepat di bawah TAA, tanpa nambah satu ray pun (biaya performa sama
    // persis).
    //
    // `rotation` adalah offset per-pixel (Cranley-Patterson rotation) -
    // TETAP diturunkan dari `seed` yang caller berikan (dunia-terkunci +
    // di-cycle 8 fase lewat frameCounter, persis seperti sebelumnya - lihat
    // giTraceSeed di deferred1.glsl), supaya sifat "menempel diam di
    // permukaan, bukan swimming saat kamera gerak" yang sudah diperbaiki
    // sebelumnya TETAP terjaga. Tanpa rotation per-pixel ini, seluruh layar
    // akan memakai pola R2 identik dan menunjukkan garis-garis
    // low-discrepancy yang terlihat (bukan noise acak) - rotation-lah yang
    // mendekorelasi pola itu antar pixel.
    const vec2 giR2Alpha = vec2(0.7548776662466927, 0.5698402909980532);
    vec2 GiR2Sample(int i, vec2 rotation) {
        return fract(giR2Alpha * float(i) + rotation);
    }

    // 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).
    //
    // FIX ("bocor posisi / tembus" - wall-thickness leak): the un-restricted
    // 6-neighbour average used to include the neighbour DIRECTLY BEHIND the
    // hit face (i.e. one step further INTO the solid, away from the ray
    // origin). On any wall/floor that is more than 1 voxel thick (most
    // built structures), that neighbour voxel is physically on the OTHER
    // side of the surface and carries whatever light propagated into it
    // from the OTHER room/space - so a receiver in room A that hit the
    // near face of a shared wall was quietly also reading a sample that
    // had been lit from room B, i.e. bounce colour leaking/duplicating
    // through solid geometry that should have fully blocked it. Since
    // GiTraceRay already hands back the exact face normal it hit
    // (hitFaceNormal), we can exclude precisely that one "through the
    // wall" neighbour and keep the other 5 (which stay on the near/side
    // faces, exactly like the tangent-plane restriction already applied
    // to GiSampleRadianceBlurred above). Passing vec3(0.0) preserves the
    // old unrestricted behaviour for any other call site that doesn't have
    // a hit normal available.
    vec3 GiSampleRadianceSmoothed(sampler3D rad, ivec3 hit, vec3 hitNormalForLeakGuard) {
        vec3 sum = GiLoadRadiance(rad, hit).rgb;
        float w = 1.0;
        // Axis-aligned "into the solid" direction to skip (rounds the exact
        // face normal, which is always axis-aligned coming out of the DDA,
        // to the matching integer offset). Zero normal (no guard requested)
        // rounds to (0,0,0), which never matches any of the 6 offsets below.
        ivec3 skipDir = -ivec3(round(hitNormalForLeakGuard));
        ivec3 n;
        n = hit + ivec3( 1, 0, 0); if (n - hit != skipDir && GiCheckInsideVolume(vec3(n)) && GiLoadAlbedoOpacity(n).a > 0.5) { sum += GiLoadRadiance(rad, n).rgb; w += 1.0; }
        n = hit + ivec3(-1, 0, 0); if (n - hit != skipDir && GiCheckInsideVolume(vec3(n)) && GiLoadAlbedoOpacity(n).a > 0.5) { sum += GiLoadRadiance(rad, n).rgb; w += 1.0; }
        n = hit + ivec3( 0, 1, 0); if (n - hit != skipDir && GiCheckInsideVolume(vec3(n)) && GiLoadAlbedoOpacity(n).a > 0.5) { sum += GiLoadRadiance(rad, n).rgb; w += 1.0; }
        n = hit + ivec3( 0,-1, 0); if (n - hit != skipDir && GiCheckInsideVolume(vec3(n)) && GiLoadAlbedoOpacity(n).a > 0.5) { sum += GiLoadRadiance(rad, n).rgb; w += 1.0; }
        n = hit + ivec3( 0, 0, 1); if (n - hit != skipDir && GiCheckInsideVolume(vec3(n)) && GiLoadAlbedoOpacity(n).a > 0.5) { sum += GiLoadRadiance(rad, n).rgb; w += 1.0; }
        n = hit + ivec3( 0, 0,-1); if (n - hit != skipDir && 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.
    // ("gradasi jarak per-ray" fix, teknik dipelajari dari DC Team's
    // Derivative shaderpack - VXGI_TraceRay's `falloff = 1.0 / (1.0 +
    // travelled^2)`, kredit: DureXXX, M1zore, Skeeder461, Frs0n, Sone4ka):
    // sebelumnya SETIAP ray yang kena voxel solid dikasih bobot penuh yang
    // SAMA, entah itu kena tembok 2 voxel di depan atau 25 voxel jauhnya di
    // ujung ruangan. Dalam satu hemisphere (GI_RAYS_PER_PIXEL rays), kalau
    // sebagian ray kena permukaan dekat (misal lantai) dan sebagian lagi
    // kena permukaan jauh (dinding di ujung ruangan besar), keduanya
    // nyumbang porsi radiance yang sama besar ke hasil akhir - ini yang
    // bikin transisi warna antar permukaan kelihatan nggak mulus/"patah"
    // (gejala yang dilaporkan sebagai "warna kadang kayak glitch"), karena
    // nggak ada gradasi berdasarkan jarak tempuh ray-nya sendiri. Beda
    // dengan GI_DECAY/GI_BOUNCE_PASSES yang cuma ngatur peluruhan energi
    // ANTAR PASS flood-fill (dari sumber cahaya ke voxel penyimpan), bukan
    // dari voxel penyimpan itu ke MATA/permukaan yang lagi di-render -
    // falloff per-ray ini melengkapi bagian yang belum ada itu.
    // Formula inverse-square Derivative dipakai apa adanya (secara fisik
    // itu memang bagaimana intensitas cahaya titik meluruh terhadap
    // jarak), tapi konstanta pembagi jaraknya (GI_RAY_FALLOFF_SCALE)
    // ditambahkan di sini karena jarak `hd` di Unbound dalam satuan voxel
    // (bisa sampai puluhan pada GI_TRACE_STEPS besar), sedangkan di
    // Derivative jaraknya dibatasi VXGI_MAX_DISTANCE yang jauh lebih
    // pendek (default 24) - tanpa penyesuaian skala, falloff versi mentah
    // akan meredam HAMPIR SEMUA ray sampai nyaris hitam duluan sebelum
    // sempat kena apapun yang agak jauh.
    #ifndef GI_RAY_FALLOFF_ENABLED
        #define GI_RAY_FALLOFF_ENABLED 1 //[0 1]
    #endif
    #ifndef GI_RAY_FALLOFF_SCALE
        // ("bounce sesuai referensi Cornell-box - warna kuat sampai ujung
        // koridor/ruangan, bukan cuma dekat sumbernya" fix): 8.0 meredam
        // kontribusi ray yang kena permukaan lumayan jauh (koridor/ruangan
        // sedang-besar seperti di foto referensi) lebih cepat daripada yang
        // dimaksud komentar di atas ("ray yang kena permukaan di seberang
        // ruangan kecil/sedang masih nyumbang porsi wajar"). Dinaikkan ke
        // 12.0 supaya jarak segitu memang masih nyumbang wajar, dan warna
        // pantulan tetap kuat/kelihatan sampai agak jauh dari sumbernya -
        // cuma ruangan yang BENAR-BENAR besar (aula/gua raksasa) yang masih
        // meluruh signifikan.
        #define GI_RAY_FALLOFF_SCALE 10.0 //[4.0 6.0 8.0 9.0 10.0 12.0 16.0 20.0]
    #endif

    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 = GiR2Sample(i, seed);
            vec3 dir = GiCosineSampleHemisphere(normal, u);

            #if GI_VERTICAL_SPREAD == 0
            // LEGACY PATH ("light column" safety fallback - see
            // GI_VERTICAL_SPREAD in common.glsl): flattens every ray onto
            // the X/Z plane, so a floor can never gather from its ceiling
            // (or vice versa) through this estimator. Kept only as an
            // instant revert switch in case GI_VERTICAL_SPREAD == 1 ever
            // reproduces the old upward light-column artifact on someone's
            // world; the propagation flood-fill (giPropagateBody.glsl)
            // stays horizontal/downward-only either way, since THAT is the
            // multi-pass, multi-frame-accumulating system that actually
            // caused that artifact originally.
            vec2 dirXZ = dir.xz;
            float dirXZLen = length(dirXZ);
            if (dirXZLen < 0.0001) {
                dirXZ = vec2(1.0, 0.0);
                dirXZLen = 1.0;
            }
            dirXZ /= dirXZLen;
            vec2 normalXZ = normal.xz;
            float normalXZLen = length(normalXZ);
            if (normalXZLen > 0.0001) {
                normalXZ /= normalXZLen;
                dirXZ = normalize(mix(dirXZ, normalXZ, 0.22));
            }
            dir = vec3(dirXZ.x, 0.0, dirXZ.y);
            #endif
            // GI_VERTICAL_SPREAD == 1 (default): `dir` is left exactly as
            // GiCosineSampleHemisphere returned it - a real, full-hemisphere
            // cosine-weighted Monte Carlo direction. This is the textbook
            // Monte Carlo GI estimator: a floor's hemisphere legitimately
            // includes "straight up" (the ceiling) and a ceiling's includes
            // "straight down" (the floor), which is exactly what makes a
            // closed room feel filled/"lega" instead of only lit sideways.
            // This single-ray, per-pixel, visibility-tested estimator (DDA
            // stops at the first solid voxel) does not itself accumulate
            // across passes the way giPropagateBody's flood-fill does, so
            // restoring true vertical sampling here is a much lower-risk
            // change than touching that separate multi-pass system.

            ivec3 hit;
            float hd;
            vec3 tt;
            vec3 hitFaceNormal;
            if (!GiTraceRay(voxelCoord, dir, GI_TRACE_STEPS, hit, hd, tt, hitFaceNormal)) {
                // ("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.
            //
            // ("hybrid ray tracing" DDA/normal fix): hitN now comes straight
            // from GiTraceRay's own DDA step (exact face crossed by the ray -
            // see hitNormal in GiTraceRay above), instead of a separate
            // GiEstimateVoxelNormal(hit) occupancy-gradient guess. This is
            // both cheaper (no extra 6x texelFetch per ray-hit) AND correct
            // on thin 1-voxel geometry where the gradient estimate used to
            // return a degenerate/zero normal (see isThin in
            // GiEstimateVoxelNormalEx) - light now bounces off the actual
            // surface the ray hit, not a blurred guess of it.
            vec3 hitN = hitFaceNormal;
            if (dot(hitN, dir) > 0.0) hitN = -hitN;
            // Pass the resolved outward-facing normal so the neighbour
            // average never reaches through this surface to the far side
            // (see the leak-guard note on GiSampleRadianceSmoothed above).
            vec3 solidRad = GiSampleRadianceSmoothed(radSampler, hit, hitN);
            // Sample the AIR SIDE of the actual hit face. Using the solid voxel center as the
            // dominant sample made each of the six block faces project a discrete square of
            // bounce. The continuous air-side trilinear sample follows the real hit position
            // instead, so adjacent faces blend into one smooth field instead of six voxel tiles.
            vec3 hitPos = voxelCoord + dir * max(hd - 0.20, 0.0);
            vec3 airPos = clamp(hitPos + hitN * 0.18, vec3(0.0), vec3(giVolumeSize) - vec3(0.001));
            vec3 airRad = GiSampleRadianceTrilinear(radSampler, airPos);

            vec3 hitRadiance = mix(airRad, solidRad, 0.20) * tt;

            #if GI_RAY_FALLOFF_ENABLED == 1
                // hd dalam voxel; dibagi GI_RAY_FALLOFF_SCALE dulu supaya
                // "1 unit jarak" di rumus ini kira-kira setara beberapa
                // blok, bukan 1 voxel - biar ray yang kena permukaan di
                // seberang ruangan kecil/sedang (~5-10 blok) masih nyumbang
                // porsi wajar, dan cuma yang bener-bener jauh (lorong/aula
                // besar) yang meluruh signifikan.
                float giFalloffDist = hd / float(GI_RAY_FALLOFF_SCALE);
                float giRayFalloff = 1.0 / (1.0 + giFalloffDist * giFalloffDist);
                hitRadiance *= giRayFalloff;
            #endif

            acc += hitRadiance;
            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);
    }

    // ------------------------------------------------------------------
    //  MIS-STYLE DIRECT LIGHT SAMPLING for small/bright emitters
    //  (GI improvement #1 - technique studied from VTXS Screen Space
    //  Demo's SSLL system: see geometryWeight()/ssll_GetLight() in
    //  src/ray_tracing/ssll.glsl, which pairs a light-source-sampling
    //  strategy alongside its BSDF/hemisphere rays for exactly the same
    //  reason this is being added here)
    // ------------------------------------------------------------------
    //  Pure cosine-weighted hemisphere sampling above is a BSDF-sampling
    //  strategy: excellent for broad, low-frequency bounce (floors, walls,
    //  open sky), but it statistically starves small, bright, spatially
    //  isolated emitters. With only GI_RAYS_PER_PIXEL rays spread across
    //  the WHOLE hemisphere, the odds that any one of them lands exactly
    //  on a single glowing voxel are low - that emitter's contribution to
    //  the ray-traced GI term is noisier/less stable per frame than a
    //  broad wall right next to it, even after the R2 change above.
    //
    //  This adds the complementary technique real path tracers pair with
    //  BSDF sampling for exactly this reason: explicit light-source
    //  sampling ("next event estimation") - stochastically search nearby
    //  for an actual emissive voxel and fire a ray STRAIGHT at it, so a
    //  small light gets a dedicated, reliably-aimed sample every frame
    //  instead of only relying on a hemisphere ray to randomly wander onto
    //  it. Reuses the exact same GiTraceRay DDA visibility trace as the
    //  hemisphere path, so it obeys the identical occlusion/transmissive-
    //  tint rules - this is not a second, disagreeing light model, just a
    //  second SAMPLING STRATEGY for the same voxel data.
    //
    //  Honesty note (this is NOT textbook offline-path-tracer MIS): a
    //  rigorous balance-heuristic weight needs the analytic PDF of "a
    //  cosine-hemisphere ray happens to land on this specific emissive
    //  voxel", which has no closed form for arbitrary voxelized geometry.
    //  Instead this uses the same practical, bounded-heuristic style this
    //  pack already uses elsewhere (see the per-source falloff sum in
    //  blockLightPointShadows.glsl) - each found emitter is visibility-
    //  tested and weighted by real cosine + inverse-square falloff, capped
    //  to a small number of candidates, then blended in at a modest
    //  intensity. It measurably stabilizes small emitters without a
    //  closed-form PDF, at the cost of a small amount of possible
    //  double-counting against whatever the hemisphere rays already pick
    //  up from the same voxel (mild by design - GI_MIS_LIGHT_I below
    //  keeps that overlap small rather than trying to eliminate it
    //  perfectly).
    //
    //  Gated behind GI_EMISSIVE_BLOCKS: this samples gi_emission_sampler,
    //  the SAME per-voxel emission data prepare.glsl already injects into
    //  the radiance grid when that setting is on - if GI_EMISSIVE_BLOCKS
    //  is off (this pack's current default: torch/lantern light does NOT
    //  feed the sun-driven GI bounce, by earlier deliberate request), the
    //  emission grid is all zeros and this function is simply never
    //  called, so it changes nothing for anyone who hasn't opted into
    //  emissive-block bounce in the first place.
    #if GI_EMISSIVE_BLOCKS == 1
        #ifndef GI_MIS_LIGHT_SAMPLING
            #define GI_MIS_LIGHT_SAMPLING 1 //[0 1]
        #endif
        #ifndef GI_MIS_LIGHT_RADIUS
            #define GI_MIS_LIGHT_RADIUS 12 //[6 8 10 12 16 20 24]
        #endif
        #ifndef GI_MIS_LIGHT_PROBES
            #define GI_MIS_LIGHT_PROBES 5 //[3 4 5 6 8 10]
        #endif
        #ifndef GI_MIS_LIGHT_MAX
            #define GI_MIS_LIGHT_MAX 2 //[1 2 3 4]
        #endif
        #ifndef GI_MIS_LIGHT_I
            #define GI_MIS_LIGHT_I 0.6 //[0.0 0.2 0.4 0.6 0.8 1.0 1.25 1.5]
        #endif

    #if GI_MIS_LIGHT_SAMPLING == 1
        // Local hash, kept independent of GiHash22's other call sites so
        // this search's seeding doesn't perturb unrelated existing noise
        // patterns elsewhere in the file.
        vec2 GiMisHash21(float seed) {
            vec3 p3 = fract(vec3(seed) * vec3(0.1031, 0.1030, 0.0973));
            p3 += dot(p3, p3.yzx + 33.33);
            return fract((p3.xx + p3.yz) * p3.zy);
        }

        vec3 GiSampleEmissiveDirect(sampler3D emissionSampler, vec3 voxelCoord, vec3 normal, vec2 seed) {
            vec3 result = vec3(0.0);
            ivec3 originVoxel = ivec3(floor(voxelCoord));
            float radius = float(GI_MIS_LIGHT_RADIUS);
            int found = 0;

            // Stochastic search: same style as ACT_TraceShadowRay's search
            // in blockLightPointShadows.glsl, adapted to this grid's
            // coordinate space and its emission buffer instead of the ACT
            // material-ID grid. Probe directions are drawn from the SAME
            // cosine-weighted hemisphere as the BSDF rays above (not a
            // full sphere) since a light behind the surface can never be
            // sampled by either strategy anyway.
            for (int i = 0; i < GI_MIS_LIGHT_PROBES && found < GI_MIS_LIGHT_MAX; i++) {
                vec2 h = GiMisHash21(seed.x * 12.9898 + seed.y * 78.233 + float(i) * 37.719);
                vec3 dir = GiCosineSampleHemisphere(normal, h);

                for (int step = 1; step <= 4; step++) {
                    float dist = radius * (float(step) * 0.25);
                    ivec3 p = ivec3(floor(voxelCoord + dir * dist));
                    if (p == originVoxel) continue;
                    if (!GiCheckInsideVolume(vec3(p))) break;

                    vec3 emission = GiLoadRadiance(emissionSampler, p).rgb;
                    if (GetLuminance(emission) > 0.001) {
                        vec3 lightCenter = vec3(p) + 0.5;
                        float lDist = distance(voxelCoord, lightCenter);
                        if (lDist > radius) break;

                        vec3 lightDir = (lightCenter - voxelCoord) / max(lDist, 0.001);
                        float NdotL = max(0.0, dot(normal, lightDir));
                        if (NdotL <= 0.0) break; // behind this surface either way

                        ivec3 hitVoxel; float hitDist; vec3 tint; vec3 hitNormal;
                        bool hit = GiTraceRay(voxelCoord, lightDir, int(lDist) + 2, hitVoxel, hitDist, tint, hitNormal);
                        // Unoccluded if the trace found nothing solid before
                        // reaching (or landing on) the light voxel itself -
                        // the emissive voxel is solid in gi_voxel_img, so a
                        // "hit" that lands exactly on it is expected, not an
                        // occluder.
                        bool blocked = hit && hitVoxel != p;

                        if (!blocked) {
                            // Same falloff shape as BLOCKLIGHT_POINT_SHADOWS,
                            // kept consistent on purpose so torch-adjacent
                            // brightness reads similarly between the direct
                            // blocklight system and this GI contribution.
                            float falloff = 1.0 / (1.0 + lDist * lDist * 0.35);
                            result += emission * tint * NdotL * falloff;
                            found++;
                        }
                        break; // one candidate per probe direction
                    } else if (GiLoadAlbedoOpacity(p).a > 0.5) {
                        break; // solid, non-emissive occluder in the way
                    }
                }
            }

            return result * float(GI_MIS_LIGHT_I);
        }
    #endif // GI_MIS_LIGHT_SAMPLING
    #endif // GI_EMISSIVE_BLOCKS

    // 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
