import { Color, ShaderMaterial, UniformsLib, UniformsUtils, Vector3 } from "three";

// Lit variant of ImpostorShaderV0 — runs three.js's STANDARD (physical) shading
// model so the impostor responds to scene lights and shadow maps.
//
// What this shader does differently from V0:
//   - The atlas baked-view-space normal is reconstructed back to object
//     space per-corner before blending. We need the actual surface normal
//     in view space for the lighting equations; V0 didn't care.
//   - We integrate three.js's lighting chunks (lights_fragment_begin /
//     lights_physical_fragment / shadowmap_* etc.) instead of just writing
//     out the sampled colour.
//   - We don't write gl_FragDepth — the card sits at the bounding sphere
//     centre, so depth from gl_Position is close enough for the lit
//     impostor's own occlusion. (Receiving shadows needs vDirectionalShadowCoord
//     etc., which we compute in the vertex shader from worldPosition.)
//
// Implementation choice: ShaderMaterial (not Raw) so three.js auto-injects
// position/normal/uv attributes, modelMatrix/viewMatrix uniforms, and the
// light/shadow uniform block, and so the chunk system Just Works. GLSL1-style
// (varying/attribute/texture2D) so the included three.js chunks compile
// without per-include rewrites; on WebGL2 three.js #defines these to their
// GLSL3 equivalents automatically.
const shader_vx = `
    #define STANDARD

    varying vec3 vViewPosition;
    varying vec2 vImpUv;

    // Card position and normal in OBJECT space — the fragment shader uses
    // these (plus the atlas depth at this fragment's parallax-shifted UV)
    // to reconstruct the actual surface position in WORLD space. Once we
    // have that, the shadow coord we hand to getShadow() reflects where
    // the pixel REALLY is rather than where its card-plane proxy is, so
    // the lit impostor's shadow-receive agrees with the caster's
    // gl_FragDepth and self-shadow goes away.
    varying vec3 vCardPos_OS;
    flat varying vec3 vCardNormal_OS;

    flat varying vec2 vGridFloor;
    flat varying vec4 vWeights;
    flat varying vec3 vImpTangent;
    flat varying vec3 vImpBinormal;
    flat varying vec3 vImpNormal;
    flat varying vec3 vRay00;
    flat varying vec3 vRay10;
    flat varying vec3 vRay01;
    flat varying vec3 vRay11;
    flat varying vec4 vFrameXform00;
    flat varying vec4 vFrameXform10;
    flat varying vec4 vFrameXform01;
    flat varying vec4 vFrameXform11;

    uniform vec3 uOffset;
    uniform float uRadius;
    uniform float uFrames;
    uniform bool uIsFullSphere;

    #include <common>
    #include <fog_pars_vertex>
    #include <shadowmap_pars_vertex>
    #include <logdepthbuf_pars_vertex>
    #include <clipping_planes_pars_vertex>

    vec2 VecToSphereOct(vec3 pivotToCamera)
    {
        vec3 octant = sign(pivotToCamera);
        float sum = dot(pivotToCamera, octant);
        vec3 octahedron = pivotToCamera / sum;
        if (octahedron.y < 0.0) {
            vec3 absolute = abs(octahedron);
            octahedron.xz = octant.xz * vec2(1.0 - absolute.z, 1.0 - absolute.x);
        }
        return octahedron.xz;
    }

    vec2 VecToHemiSphereOct(vec3 v)
    {
        v.y = max(v.y, 0.001);
        v = normalize(v);
        vec3 octant = sign(v);
        float sum = dot(v, octant);
        vec3 octahedron = v / sum;
        return vec2(
            octahedron.x + octahedron.z,
            octahedron.z - octahedron.x
        );
    }

    vec2 VectorToGrid(vec3 v)
    {
        return uIsFullSphere ? VecToSphereOct(v) : VecToHemiSphereOct(v);
    }

    vec3 OctaSphereDec(vec2 coord)
    {
        coord = (coord - 0.5) * 2.0;
        vec3 p = vec3(coord.x, 0.0, coord.y);
        vec2 a = abs(p.xz);
        p.y = 1.0 - a.x - a.y;
        if (p.y < 0.0) {
            p.xz = sign(p.xz) * vec2(1.0 - a.y, 1.0 - a.x);
        }
        return p;
    }

    vec3 OctaHemiSphereDec(vec2 coord)
    {
        vec3 p = vec3(coord.x - coord.y, 0.0, -1.0 + coord.x + coord.y);
        vec2 a = abs(p.xz);
        p.y = 1.0 - a.x - a.y;
        return p;
    }

    vec3 GridToVector(vec2 coord)
    {
        return uIsFullSphere ? OctaSphereDec(coord) : OctaHemiSphereDec(coord);
    }

    vec3 FrameToRay(vec2 frame, vec2 framesMinusOne)
    {
        vec2 f = clamp(frame / framesMinusOne, 0.0, 1.0);
        return normalize(GridToVector(f));
    }

    vec4 ComputeFrameXform(vec3 D_frame, vec3 tangent_OS, vec3 binormal_OS)
    {
        vec3 D = abs(D_frame.y) > 0.99999
                 ? normalize(D_frame + vec3(0.0, 0.0, 0.0001))
                 : D_frame;
        vec3 bake_right = normalize(cross(vec3(0.0, 1.0, 0.0), D));
        vec3 bake_up    = cross(D, bake_right);
        return vec4(
            dot(tangent_OS,  bake_right),
            dot(binormal_OS, bake_right),
            dot(tangent_OS,  bake_up),
            dot(binormal_OS, bake_up)
        );
    }

    vec4 BilinearWeights(vec2 frac_uv)
    {
        vec2 omuv = vec2(1.0) - frac_uv;
        return vec4(
            omuv.x * omuv.y,
            frac_uv.x * omuv.y,
            omuv.x * frac_uv.y,
            frac_uv.x * frac_uv.y
        );
    }

    void main() {
        vImpUv = uv;

        vec3 cameraPos_OS = (inverse(modelViewMatrix) * vec4(0.0, 0.0, 0.0, 1.0)).xyz;
        // Atlas lookup must be relative to the bounding-sphere centre
        // (uOffset), since the bake's D_frame is measured from the centre,
        // not from the object origin.
        vec3 pivotToCameraRay = normalize(cameraPos_OS - uOffset);

        vec2 framesMinusOne = vec2(uFrames - 1.0);
        vec2 octahedral_uv = clamp(VectorToGrid(pivotToCameraRay) * 0.5 + 0.5, 0.0, 1.0);
        vec2 grid = octahedral_uv * framesMinusOne;
        vec2 gridFloor = min(floor(grid), framesMinusOne - 1.0);
        vec4 weights = BilinearWeights(grid - gridFloor);

        vGridFloor = gridFloor;
        vWeights = weights;

        vec3 ray00 = FrameToRay(gridFloor + vec2(0.0, 0.0), framesMinusOne);
        vec3 ray10 = FrameToRay(gridFloor + vec2(1.0, 0.0), framesMinusOne);
        vec3 ray01 = FrameToRay(gridFloor + vec2(0.0, 1.0), framesMinusOne);
        vec3 ray11 = FrameToRay(gridFloor + vec2(1.0, 1.0), framesMinusOne);

        // Object-space corner ray directions — needed by the fragment shader
        // to reconstruct each frame's bake_right / bake_up basis and decode
        // the atlas normal back to object space.
        vRay00 = ray00;
        vRay10 = ray10;
        vRay01 = ray01;
        vRay11 = ray11;

        vec3 projectedRay = normalize(
            ray00 * weights.x +
            ray10 * weights.y +
            ray01 * weights.z +
            ray11 * weights.w
        );

        vec3 normal_OS = projectedRay;
        vec3 up_OS = abs(normal_OS.y) > 0.999
                     ? vec3(0.0, 0.0, -1.0)
                     : vec3(0.0, 1.0,  0.0);
        vec3 tangent_OS  = normalize(cross(up_OS, normal_OS));
        vec3 binormal_OS = cross(normal_OS, tangent_OS);

        float card_diameter = uRadius * 2.0;
        vec3 pos_OS = uOffset
                    + position.x * card_diameter * tangent_OS
                    + position.y * card_diameter * binormal_OS;

        vCardPos_OS    = pos_OS;
        vCardNormal_OS = normal_OS;

        vec4 mvPosition = modelViewMatrix * vec4(pos_OS, 1.0);
        gl_Position = projectionMatrix * mvPosition;
        vViewPosition = -mvPosition.xyz;

        mat3 m3 = mat3(modelViewMatrix);
        vImpTangent  = normalize(m3 * tangent_OS);
        vImpBinormal = normalize(m3 * binormal_OS);
        vImpNormal   = normalize(m3 * normal_OS);

        vFrameXform00 = ComputeFrameXform(ray00, tangent_OS, binormal_OS);
        vFrameXform10 = ComputeFrameXform(ray10, tangent_OS, binormal_OS);
        vFrameXform01 = ComputeFrameXform(ray01, tangent_OS, binormal_OS);
        vFrameXform11 = ComputeFrameXform(ray11, tangent_OS, binormal_OS);

        // Quantities the three.js chunks expect to be in scope.
        // - 'worldPosition' is consumed by <shadowmap_vertex> to build the
        //   per-light shadow projection coords.
        // - 'transformedNormal' is consumed by the same chunk for the
        //   shadow-acne normal-bias offset; it should be the view-space
        //   surface normal.
        vec4 worldPosition = modelMatrix * vec4(pos_OS, 1.0);
        vec3 transformedNormal = vImpNormal;

        #include <logdepthbuf_vertex>
        #include <clipping_planes_vertex>
        #include <shadowmap_vertex>
        #include <fog_vertex>
    }
`;
const shader_fg = `
    #define STANDARD

    uniform vec3 diffuse;
    uniform vec3 emissive;
    uniform float roughness;
    uniform float metalness;
    uniform float opacity;

    varying vec3 vViewPosition;
    varying vec2 vImpUv;

    varying vec3 vCardPos_OS;
    flat varying vec3 vCardNormal_OS;

    flat varying vec2 vGridFloor;
    flat varying vec4 vWeights;
    flat varying vec3 vImpTangent;
    flat varying vec3 vImpBinormal;
    flat varying vec3 vImpNormal;
    flat varying vec3 vRay00;
    flat varying vec3 vRay10;
    flat varying vec3 vRay01;
    flat varying vec3 vRay11;
    flat varying vec4 vFrameXform00;
    flat varying vec4 vFrameXform10;
    flat varying vec4 vFrameXform01;
    flat varying vec4 vFrameXform11;

    uniform sampler2D tBase;
    uniform sampler2D tGeometry;
    uniform sampler2D tMaterial;
    uniform float uFrames;
    uniform float uRadius;
    uniform float uDepthScale;

    // Needed in the fragment by the light-direction parallax block below
    // (it has to compute its own octahedral grid lookup for the caster's
    // sampling direction, not the camera's).
    uniform vec3 uOffset;
    uniform bool uIsFullSphere;

    // three.js auto-injects normalMatrix in the VERTEX prefix only; in the
    // fragment we have to declare it ourselves. WebGLRenderer.js still
    // binds it from object.normalMatrix when the program exposes the
    // location, so this is enough to transform our atlas-reconstructed
    // object-space normal into view space.
    uniform mat3 normalMatrix;

    // Same story for modelMatrix. We need it to put the parallax surface
    // into world space for the shadow-coord recomputation.
    uniform mat4 modelMatrix;

    #include <common>
    #include <packing>
    #include <dithering_pars_fragment>
    #include <color_pars_fragment>
    #include <bsdfs>
    #include <cube_uv_reflection_fragment>
    #include <envmap_common_pars_fragment>
    #include <envmap_physical_pars_fragment>
    #include <fog_pars_fragment>
    #include <lights_pars_begin>
    #include <lights_physical_pars_fragment>
    #include <shadowmap_pars_fragment>
    #include <logdepthbuf_pars_fragment>
    #include <clipping_planes_pars_fragment>

    // directionalShadowMatrix[] is declared in <shadowmap_pars_VERTEX> but
    // not in the fragment-side pars chunk; declaring it here lets
    // three.js's WebGLLights bind it (it's part of UniformsLib.lights
    // which got merged into our material's uniforms via lights: true).
    #if defined( USE_SHADOWMAP ) && NUM_DIR_LIGHT_SHADOWS > 0
    uniform mat4 directionalShadowMatrix[ NUM_DIR_LIGHT_SHADOWS ];
    #endif

    // Redirect the chunk-side reference to vDirectionalShadowCoord[i]
    // into our per-fragment customShadowCoord array. The varying with the
    // original name still exists (declared above by shadowmap_pars_fragment)
    // and the vertex shader still writes to it — we just don't read it.
    // We have to put the #define AFTER the pars include so the chunk's
    // own "varying vec4 vDirectionalShadowCoord[...]" declaration isn't
    // affected by the macro.
    #if defined( USE_SHADOWMAP ) && NUM_DIR_LIGHT_SHADOWS > 0
    #define vDirectionalShadowCoord customShadowCoord
    #endif

    // ---- octahedral helpers duplicated from the vertex shader ----
    // We need them in the fragment too because the shadow-coord block
    // below recomputes the caster's parallax surface (with light-direction
    // octahedral lookup) per-fragment. The vertex flat-varyings are for
    // the CAMERA direction; here we need the same math evaluated for the
    // LIGHT direction.

    vec2 VecToSphereOct(vec3 pivotToCamera)
    {
        vec3 octant = sign(pivotToCamera);
        float sum = dot(pivotToCamera, octant);
        vec3 octahedron = pivotToCamera / sum;
        if (octahedron.y < 0.0) {
            vec3 absolute = abs(octahedron);
            octahedron.xz = octant.xz * vec2(1.0 - absolute.z, 1.0 - absolute.x);
        }
        return octahedron.xz;
    }

    vec2 VecToHemiSphereOct(vec3 v)
    {
        v.y = max(v.y, 0.001);
        v = normalize(v);
        vec3 octant = sign(v);
        float sum = dot(v, octant);
        vec3 octahedron = v / sum;
        return vec2(
            octahedron.x + octahedron.z,
            octahedron.z - octahedron.x
        );
    }

    vec2 VectorToGrid(vec3 v)
    {
        return uIsFullSphere ? VecToSphereOct(v) : VecToHemiSphereOct(v);
    }

    vec3 OctaSphereDec(vec2 coord)
    {
        coord = (coord - 0.5) * 2.0;
        vec3 p = vec3(coord.x, 0.0, coord.y);
        vec2 a = abs(p.xz);
        p.y = 1.0 - a.x - a.y;
        if (p.y < 0.0) {
            p.xz = sign(p.xz) * vec2(1.0 - a.y, 1.0 - a.x);
        }
        return p;
    }

    vec3 OctaHemiSphereDec(vec2 coord)
    {
        vec3 p = vec3(coord.x - coord.y, 0.0, -1.0 + coord.x + coord.y);
        vec2 a = abs(p.xz);
        p.y = 1.0 - a.x - a.y;
        return p;
    }

    vec3 GridToVector(vec2 coord)
    {
        return uIsFullSphere ? OctaSphereDec(coord) : OctaHemiSphereDec(coord);
    }

    vec3 FrameToRay(vec2 frame, vec2 framesMinusOne)
    {
        vec2 f = clamp(frame / framesMinusOne, 0.0, 1.0);
        return normalize(GridToVector(f));
    }

    vec4 ComputeFrameXform(vec3 D_frame, vec3 tangent_OS, vec3 binormal_OS)
    {
        vec3 D = abs(D_frame.y) > 0.99999
                 ? normalize(D_frame + vec3(0.0, 0.0, 0.0001))
                 : D_frame;
        vec3 bake_right = normalize(cross(vec3(0.0, 1.0, 0.0), D));
        vec3 bake_up    = cross(D, bake_right);
        return vec4(
            dot(tangent_OS,  bake_right),
            dot(binormal_OS, bake_right),
            dot(tangent_OS,  bake_up),
            dot(binormal_OS, bake_up)
        );
    }

    vec4 BilinearWeights(vec2 frac_uv)
    {
        vec2 omuv = vec2(1.0) - frac_uv;
        return vec4(
            omuv.x * omuv.y,
            frac_uv.x * omuv.y,
            omuv.x * frac_uv.y,
            frac_uv.x * frac_uv.y
        );
    }

    vec2 apply_frame_xform(vec2 card_uv, vec4 xform)
    {
        vec2 c = card_uv - 0.5;
        return vec2(
            xform.x * c.x + xform.y * c.y,
            xform.z * c.x + xform.w * c.y
        ) + 0.5;
    }

    // Sample one corner's atlas tile after rotating the card UV into that
    // frame's bake-camera basis. Returns the diffuse and geometry+ORM
    // texels along with the per-frame atlas UV (so the caller can do
    // parallax in two passes — first depth, then a shifted re-sample).
    void corner_atlas_uv(
        vec2 card_uv, vec2 gridFloor, vec2 corner_offset,
        vec4 xform, out vec2 atlas_uv
    ) {
        vec2 frame_size = vec2(1.0 / uFrames);
        vec2 uv = clamp(apply_frame_xform(card_uv, xform), 0.0, 1.0);
        atlas_uv = (gridFloor + corner_offset + uv) * frame_size;
    }

    // Decode the atlas-encoded normal (stored in bake-camera view space)
    // back to OBJECT-LOCAL space. Inverse of the (right, up, D_frame)
    // basis the baker used. See BakeShaderStandard.js for the encode side.
    vec3 decode_normal_OS(vec3 encoded, vec3 D_frame_OS)
    {
        vec3 n_bv = encoded * 2.0 - 1.0;
        vec3 D = abs(D_frame_OS.y) > 0.99999
                 ? normalize(D_frame_OS + vec3(0.0, 0.0, 0.0001))
                 : D_frame_OS;
        vec3 bake_right = normalize(cross(vec3(0.0, 1.0, 0.0), D));
        vec3 bake_up    = cross(D, bake_right);
        return bake_right * n_bv.x + bake_up * n_bv.y + D * n_bv.z;
    }

    void main() {

        #include <clipping_planes_fragment>

        // ---- pass 1: blended depth (for parallax shift) ----
        vec2 base_uv = vImpUv;

        vec2 atlas_uv00, atlas_uv10, atlas_uv01, atlas_uv11;
        corner_atlas_uv(base_uv, vGridFloor, vec2(0.0, 0.0), vFrameXform00, atlas_uv00);
        corner_atlas_uv(base_uv, vGridFloor, vec2(1.0, 0.0), vFrameXform10, atlas_uv10);
        corner_atlas_uv(base_uv, vGridFloor, vec2(0.0, 1.0), vFrameXform01, atlas_uv01);
        corner_atlas_uv(base_uv, vGridFloor, vec2(1.0, 1.0), vFrameXform11, atlas_uv11);

        float d00 = texture2D(tGeometry, atlas_uv00).a;
        float d10 = texture2D(tGeometry, atlas_uv10).a;
        float d01 = texture2D(tGeometry, atlas_uv01).a;
        float d11 = texture2D(tGeometry, atlas_uv11).a;
        float blended_depth =
            d00 * vWeights.x + d10 * vWeights.y +
            d01 * vWeights.z + d11 * vWeights.w;

        // Card-tangent-space view direction; same trick as V0 — the card
        // is perpendicular to the projected ray (= vImpNormal), so projecting
        // -vViewPosition onto (vImpTangent, vImpBinormal, vImpNormal) gives the
        // view direction in the card's frame.
        vec3 view_dir_view = normalize(vViewPosition);
        vec3 view_dir = vec3(
            dot(vImpTangent,  view_dir_view),
            dot(vImpBinormal, view_dir_view),
            dot(vImpNormal,   view_dir_view)
        );

        base_uv += (view_dir.xy / view_dir.z) * (blended_depth - 0.5) * uDepthScale;
        base_uv = clamp(base_uv, 0.0, 1.0);

        // ---- pass 2: full atlas sample at parallax-shifted UV ----
        corner_atlas_uv(base_uv, vGridFloor, vec2(0.0, 0.0), vFrameXform00, atlas_uv00);
        corner_atlas_uv(base_uv, vGridFloor, vec2(1.0, 0.0), vFrameXform10, atlas_uv10);
        corner_atlas_uv(base_uv, vGridFloor, vec2(0.0, 1.0), vFrameXform01, atlas_uv01);
        corner_atlas_uv(base_uv, vGridFloor, vec2(1.0, 1.0), vFrameXform11, atlas_uv11);

        vec4 b00 = texture2D(tBase, atlas_uv00);
        vec4 b10 = texture2D(tBase, atlas_uv10);
        vec4 b01 = texture2D(tBase, atlas_uv01);
        vec4 b11 = texture2D(tBase, atlas_uv11);
        vec4 albedo =
            b00 * vWeights.x + b10 * vWeights.y +
            b01 * vWeights.z + b11 * vWeights.w;

        if (albedo.a <= 0.5) {
            discard;
        }

        // sRGB -> linear. The GLTFLoader marks color textures as
        // sRGBEncoding, but BakeShaderStandard is a RawShaderMaterial and
        // sampled them with a plain texture() call (no auto-conversion
        // through <map_fragment>), so the bake atlas holds the raw sRGB
        // bytes labelled LinearEncoding. The truth mesh's
        // MeshStandardMaterial decodes its map in the shader before
        // running PBR; we have to do the same here or the lighting math
        // operates on gamma-compressed values and the result looks
        // washed-out / too bright in the mid-tones relative to the
        // truth. (sRGBToLinear is provided by three.js's fragment prefix
        // via <encodings_pars_fragment>, which is auto-included.)
        albedo.rgb = sRGBToLinear(vec4(albedo.rgb, 1.0)).rgb;

        vec4 g00 = texture2D(tGeometry, atlas_uv00);
        vec4 g10 = texture2D(tGeometry, atlas_uv10);
        vec4 g01 = texture2D(tGeometry, atlas_uv01);
        vec4 g11 = texture2D(tGeometry, atlas_uv11);

        // Decode each corner's normal in its own bake basis, then blend.
        // Blending the encoded RGB directly would mix incompatible bases
        // (a normal pointing "right" in frame 00 is unrelated to the
        // same RGB in frame 11).
        vec3 n_OS_00 = decode_normal_OS(g00.rgb, vRay00);
        vec3 n_OS_10 = decode_normal_OS(g10.rgb, vRay10);
        vec3 n_OS_01 = decode_normal_OS(g01.rgb, vRay01);
        vec3 n_OS_11 = decode_normal_OS(g11.rgb, vRay11);
        vec3 normal_OS = normalize(
            n_OS_00 * vWeights.x + n_OS_10 * vWeights.y +
            n_OS_01 * vWeights.z + n_OS_11 * vWeights.w
        );

        vec4 m00 = texture2D(tMaterial, atlas_uv00);
        vec4 m10 = texture2D(tMaterial, atlas_uv10);
        vec4 m01 = texture2D(tMaterial, atlas_uv01);
        vec4 m11 = texture2D(tMaterial, atlas_uv11);
        vec4 orm =
            m00 * vWeights.x + m10 * vWeights.y +
            m01 * vWeights.z + m11 * vWeights.w;

        // ---- parallax surface in world space ----
        // Use the post-parallax depth (we just sampled g00..g11 at the
        // shifted UV — their alpha is the depth at that surface point).
        // Same convention as in the bake/depth shader: depth=1 -> +radius
        // along the card normal, 0.5 -> on the card plane, 0 -> -radius.
        float surface_depth_blended =
            g00.a * vWeights.x + g10.a * vWeights.y +
            g01.a * vWeights.z + g11.a * vWeights.w;
        float height_OS = (surface_depth_blended - 0.5) * 2.0 * uRadius;
        vec3 surface_OS_pos = vCardPos_OS + height_OS * vCardNormal_OS;
        vec4 worldPosition_surface = modelMatrix * vec4(surface_OS_pos, 1.0);

        // ---- standard three.js lit pipeline ----
        // The chunks below expect these names to already be defined in
        // scope: diffuseColor (vec4), reflectedLight, totalEmissiveRadiance,
        // roughnessFactor, metalnessFactor, normal, geometryNormal.
        vec4 diffuseColor = vec4(albedo.rgb * diffuse, 1.0);
        ReflectedLight reflectedLight = ReflectedLight(vec3(0.0), vec3(0.0), vec3(0.0), vec3(0.0));

        float occlusionFactor  = orm.r;
        float roughnessFactor  = roughness * orm.g;
        float metalnessFactor  = metalness * orm.b;

        vec3 totalEmissiveRadiance = emissive;

        vec3 normal = normalize(normalMatrix * normal_OS);
        vec3 geometryNormal = normal;

        // Recompute the directional-light shadow coord per-fragment.
        //
        // The naive approach — feed worldPosition_surface (which uses
        // CAMERA-direction parallax) straight to directionalShadowMatrix —
        // doesn't work because the CASTER's gl_FragDepth uses LIGHT-
        // direction parallax. Single-step parallax along two different
        // directions converges to two different surface points on a
        // sloped height-field, so the receiver and caster disagree on
        // "where this pixel actually is in world space" and the
        // comparison creates self-shadow along the boundary.
        //
        // The fix is to do a SECOND parallax inside the lit shader that
        // mirrors the caster: light-perpendicular caster card geometry,
        // light-direction octahedral grid, and a sample at the caster's
        // UV. The world-space surface that comes out matches what
        // ImpostorShaderDepthV0 wrote at the same light_xy, so the
        // receiver/caster depths line up.
        //
        // (We keep the camera-direction parallax surface for the rest of
        // the lit pipeline — it's what the eye actually sees through this
        // card pixel.)
        //
        // The vDirectionalShadowCoord macro defined above redirects the
        // chunk's reference into this array.
        #if defined( USE_SHADOWMAP ) && NUM_DIR_LIGHT_SHADOWS > 0
        vec4 customShadowCoord[ NUM_DIR_LIGHT_SHADOWS ];
        {
            vec3 shadowWorldNormal_lit = normalize( mat3( modelMatrix ) * normal_OS );

            for ( int i = 0; i < NUM_DIR_LIGHT_SHADOWS; i ++ ) {

                // ---- step 1: light direction in OBJECT space ----
                // directionalLights[i].direction is "to-light" in VIEW
                // space; bring it back to world then into OS. modelMatrix
                // is uniform-scale + rotation for our impostor entities,
                // so a plain inverseTransformDirection works.
                vec3 light_dir_view  = normalize( directionalLights[ i ].direction );
                vec3 light_dir_world = inverseTransformDirection( light_dir_view, viewMatrix );
                vec3 light_dir_OS    = inverseTransformDirection( light_dir_world, modelMatrix );

                // ---- step 2: caster card basis (perpendicular to light) ----
                vec3 up_OS_L = abs( light_dir_OS.y ) > 0.999
                               ? vec3( 0.0, 0.0, -1.0 )
                               : vec3( 0.0, 1.0,  0.0 );
                vec3 tan_OS_L = normalize( cross( up_OS_L, light_dir_OS ) );
                vec3 bin_OS_L = cross( light_dir_OS, tan_OS_L );

                // ---- step 3: project the visible's parallax surface
                //              onto the caster card plane to find the
                //              caster-UV at this fragment ----
                vec3 surf_OS = surface_OS_pos;
                vec3 from_center = surf_OS - uOffset;
                float u_caster = dot( from_center, tan_OS_L );
                float v_caster = dot( from_center, bin_OS_L );
                vec2 caster_card_uv = vec2( u_caster, v_caster ) / ( 2.0 * uRadius ) + 0.5;
                caster_card_uv = clamp( caster_card_uv, 0.0, 1.0 );

                // ---- step 4: atlas lookup for the light direction ----
                vec2 fMinusOne_L = vec2( uFrames - 1.0 );
                vec2 oct_uv_L = clamp(
                    VectorToGrid( light_dir_OS ) * 0.5 + 0.5,
                    0.0, 1.0
                );
                vec2 grid_L = oct_uv_L * fMinusOne_L;
                vec2 gridFloor_L = min( floor( grid_L ), fMinusOne_L - 1.0 );
                vec4 weights_L = BilinearWeights( grid_L - gridFloor_L );

                vec3 ray00_L = FrameToRay( gridFloor_L + vec2( 0.0, 0.0 ), fMinusOne_L );
                vec3 ray10_L = FrameToRay( gridFloor_L + vec2( 1.0, 0.0 ), fMinusOne_L );
                vec3 ray01_L = FrameToRay( gridFloor_L + vec2( 0.0, 1.0 ), fMinusOne_L );
                vec3 ray11_L = FrameToRay( gridFloor_L + vec2( 1.0, 1.0 ), fMinusOne_L );

                vec4 xform00_L = ComputeFrameXform( ray00_L, tan_OS_L, bin_OS_L );
                vec4 xform10_L = ComputeFrameXform( ray10_L, tan_OS_L, bin_OS_L );
                vec4 xform01_L = ComputeFrameXform( ray01_L, tan_OS_L, bin_OS_L );
                vec4 xform11_L = ComputeFrameXform( ray11_L, tan_OS_L, bin_OS_L );

                // ---- step 5: sample the depth atlas at the caster UV.
                //              Caster card is perpendicular to light, so
                //              its view_dir.z == 1 and parallax shift is
                //              zero — same as in ImpostorShaderDepthV0. ----
                vec2 fs_L = vec2( 1.0 / uFrames );
                vec2 uv00_L = clamp( apply_frame_xform( caster_card_uv, xform00_L ), 0.0, 1.0 );
                vec2 uv10_L = clamp( apply_frame_xform( caster_card_uv, xform10_L ), 0.0, 1.0 );
                vec2 uv01_L = clamp( apply_frame_xform( caster_card_uv, xform01_L ), 0.0, 1.0 );
                vec2 uv11_L = clamp( apply_frame_xform( caster_card_uv, xform11_L ), 0.0, 1.0 );

                float d00_L = texture2D( tGeometry, ( gridFloor_L + vec2( 0.0, 0.0 ) + uv00_L ) * fs_L ).a;
                float d10_L = texture2D( tGeometry, ( gridFloor_L + vec2( 1.0, 0.0 ) + uv10_L ) * fs_L ).a;
                float d01_L = texture2D( tGeometry, ( gridFloor_L + vec2( 0.0, 1.0 ) + uv01_L ) * fs_L ).a;
                float d11_L = texture2D( tGeometry, ( gridFloor_L + vec2( 1.0, 1.0 ) + uv11_L ) * fs_L ).a;
                float depth_L =
                    d00_L * weights_L.x + d10_L * weights_L.y +
                    d01_L * weights_L.z + d11_L * weights_L.w;

                // ---- step 6: build the surface position the CASTER
                //              would have written at this light_xy, then
                //              project through directionalShadowMatrix. ----
                float height_OS_L = ( depth_L - 0.5 ) * 2.0 * uRadius;
                vec3 caster_card_pos_OS =
                    uOffset + u_caster * tan_OS_L + v_caster * bin_OS_L;
                vec3 surface_OS_L = caster_card_pos_OS + height_OS_L * light_dir_OS;
                vec4 worldPosition_surface_L =
                    modelMatrix * vec4( surface_OS_L, 1.0 );

                vec4 shadowWorldPosition = worldPosition_surface_L
                    + vec4( shadowWorldNormal_lit * directionalLightShadows[ i ].shadowNormalBias, 0.0 );
                customShadowCoord[ i ] = directionalShadowMatrix[ i ] * shadowWorldPosition;
            }
        }
        #endif

        #include <logdepthbuf_fragment>

        #include <lights_physical_fragment>
        #include <lights_fragment_begin>
        #include <lights_fragment_maps>
        #include <lights_fragment_end>

        // Apply the atlas's baked AO directly to direct + indirect diffuse;
        // standard <aomap_fragment> only touches indirect, but the impostor
        // bake doesn't separate them.
        reflectedLight.directDiffuse    *= occlusionFactor;
        reflectedLight.indirectDiffuse  *= occlusionFactor;
        reflectedLight.indirectSpecular *= occlusionFactor;

        vec3 totalDiffuse  = reflectedLight.directDiffuse + reflectedLight.indirectDiffuse;
        vec3 totalSpecular = reflectedLight.directSpecular + reflectedLight.indirectSpecular;
        vec3 outgoingLight = totalDiffuse + totalSpecular + totalEmissiveRadiance;

        gl_FragColor = vec4(outgoingLight, 1.0);

        #include <tonemapping_fragment>
        #include <encodings_fragment>
        #include <fog_fragment>
        #include <premultiplied_alpha_fragment>
        #include <dithering_fragment>
    }
`;

/**
 * PBR-lit impostor material — applies three.js's standard (physical)
 * lighting model to a baked impostor atlas. Casts/receives shadows like
 * any MeshStandardMaterial-backed mesh.
 *
 * Same uniform names as ImpostorShaderV0 (tBase / tGeometry / tMaterial /
 * uFrames / uRadius / uOffset / uIsFullSphere / uDepthScale) plus the
 * standard PBR knobs (diffuse / emissive / roughness / metalness / opacity).
 */
export class ImpostorShaderLitV0 extends ShaderMaterial {
    constructor() {
        const uniforms = UniformsUtils.merge([
            UniformsLib.common,
            UniformsLib.fog,
            UniformsLib.lights,
            {
                roughness: { value: 1.0 },
                metalness: { value: 0.0 },
                emissive: { value: new Color(0x000000) }
            },
            {
                tBase: { value: null },
                tGeometry: { value: null },
                tMaterial: { value: null },
                uFrames: { value: 0 },
                uRadius: { value: 0 },
                uOffset: { value: new Vector3(0, 0, 0) },
                uIsFullSphere: { value: false },
                uDepthScale: { value: 0.5 }
            }
        ]);

        super({
            uniforms,
            vertexShader: shader_vx,
            fragmentShader: shader_fg,
            lights: true,
            fog: true
        });
    }
}
