← Mike Higgins Home
Custom Code Experiment •

ThreeJS: Locust

Source Code View HTML / CSS / JavaScript
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>ThreeJS Locust</title>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body { background: #000; overflow: hidden; height: 100vh; }
        #bgCanvas { position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 1; }
        #animationCanvas { position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 2; cursor: grab; }
        #animationCanvas.dragging { cursor: grabbing; }
        #hint { position: absolute; bottom: 18px; left: 50%; transform: translateX(-50%); z-index: 10;
            color: rgba(180, 210, 80, 0.55); font-family: monospace; font-size: 12px; letter-spacing: 0.12em;
            pointer-events: none; transition: opacity 2s; user-select: none; }
    </style>
</head>
<body>
    <canvas id="bgCanvas"></canvas>
    <canvas id="animationCanvas"></canvas>
    <script>
        const colors = {
            body:    0x8a7a30,
            dark:    0x2e2800,
            light:   0xc8b84a,
            tegmen:  0xa09030,
            hindwing:0x70c040,
            vein:    0xd8e878,
            eye:     0xd0e000,
            spine:   0xe8d060,
            pronotum:0x6a5e20,
            stripe:  0xf0e870,
            tarsus:  0x3a3010,
        };

        const bgCanvas = document.getElementById('bgCanvas');
        const bgCtx    = bgCanvas.getContext('2d');
        const px = 7;
        let cols, rows, pixels = [];

        function initBackground() {
            bgCanvas.width  = window.innerWidth;
            bgCanvas.height = window.innerHeight;
            cols = Math.ceil(bgCanvas.width / px);
            rows = Math.ceil(bgCanvas.height / px);
            pixels = [];
            for (let i = 0; i < cols * rows; i++)
                pixels.push({ b: Math.random() * 0.3, speed: 0.001 + Math.random() * 0.003 });
        }

        function animateBackground() {
            for (let y = 0; y < rows; y++) {
                for (let x = 0; x < cols; x++) {
                    const p = pixels[y * cols + x];
                    p.b += p.speed;
                    if (p.b > 0.4) p.b = 0;
                    bgCtx.fillStyle = `rgb(${Math.floor(p.b*44)},${Math.floor(p.b*38)},${Math.floor(p.b*8)})`;
                    bgCtx.fillRect(x * px, y * px, px, px);
                }
            }
            requestAnimationFrame(animateBackground);
        }

        const renderer = new THREE.WebGLRenderer({
            canvas: document.getElementById('animationCanvas'),
            antialias: true, alpha: true
        });

        let scene, camera, locust;
        let tegmenL, tegmenR, hindwingL, hindwingR;
        let hindLegL, hindLegR;
        let legGroups = [];

        const orbit = {
            theta: 0.5, phi: 1.15, radius: 9,
            isDragging: false, lastX: 0, lastY: 0,
            autoRotate: true, autoRotateSpeed: 0.003,
            velTheta: 0, velPhi: 0,
        };
        const PHI_MIN = 0.05, PHI_MAX = Math.PI * 0.95;
        const RADIUS_MIN = 3,  RADIUS_MAX = 22;

        function setCameraFromOrbit() {
            const x = orbit.radius * Math.sin(orbit.phi) * Math.sin(orbit.theta);
            const y = orbit.radius * Math.cos(orbit.phi);
            const z = orbit.radius * Math.sin(orbit.phi) * Math.cos(orbit.theta);
            camera.position.set(x, y, z);
            camera.lookAt(0, 0, 0);
            camera.updateProjectionMatrix();
        }

        function updateSize() {
            renderer.setSize(window.innerWidth, window.innerHeight);
            renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
        }

        function updateCamera() {
            const w = window.innerWidth;
            orbit.radius = w < 480 ? 13 : w < 768 ? 11 : 9;
            camera.fov   = 52;
            camera.aspect = window.innerWidth / window.innerHeight;
            camera.updateProjectionMatrix();
            setCameraFromOrbit();
        }

        function mat(color) {
            return new THREE.MeshBasicMaterial({ color, wireframe: true });
        }

        function makeTegmen(side) {
            const s = side, group = new THREE.Group();
            const W = 0.38;
            const L = 2.20;

            const shape = new THREE.Shape();
            shape.moveTo(0, 0);
            shape.bezierCurveTo(s*W*0.55, 0.05,  s*W*0.90, 0.10,  s*W, L*0.35);
            shape.bezierCurveTo(s*W*1.02, L*0.55, s*W*0.98, L*0.80, s*W*0.60, L);
            shape.bezierCurveTo(s*W*0.40, L*1.02, s*W*0.10, L*0.98, 0, L*0.95);
            shape.bezierCurveTo(s*(-W*0.08), L*0.70, s*(-W*0.06), L*0.30, 0, 0);

            const geo = new THREE.ShapeGeometry(shape, 16);
            group.add(new THREE.Mesh(geo, mat(colors.tegmen)));
            group.add(new THREE.LineSegments(
                new THREE.EdgesGeometry(geo),
                new THREE.LineBasicMaterial({ color: colors.vein })
            ));

            const veinDefs = [
                { xFrac: 0.95, label: 'costa',    r: 0.010 },
                { xFrac: 0.78, label: 'radius',   r: 0.008 },
                { xFrac: 0.55, label: 'medial',   r: 0.007 },
                { xFrac: 0.30, label: 'cubitus',  r: 0.006 },
                { xFrac: 0.05, label: 'anal',     r: 0.005 },
            ];
            veinDefs.forEach(v => {
                const xBase = s * W * v.xFrac;
                const pts = [
                    new THREE.Vector3(xBase * 0.1, 0.005, 0.05),
                    new THREE.Vector3(xBase * 0.7, 0.005, L * 0.4),
                    new THREE.Vector3(xBase * 0.9, 0.005, L * 0.75),
                    new THREE.Vector3(xBase * 0.75, 0.005, L * 0.96),
                ];
                group.add(new THREE.Mesh(
                    new THREE.TubeGeometry(new THREE.CatmullRomCurve3(pts), 8, v.r, 4),
                    mat(colors.vein)
                ));
            });

            const cvPositions = [0.30, 0.52, 0.68, 0.80, 0.89, 0.95];
            cvPositions.forEach(zf => {
                const zp = zf * L;
                const pts = [
                    new THREE.Vector3(s*W*0.90, 0.005, zp),
                    new THREE.Vector3(s*W*0.55, 0.005, zp + 0.06),
                    new THREE.Vector3(s*W*0.28, 0.005, zp + 0.04),
                ];
                group.add(new THREE.Mesh(
                    new THREE.TubeGeometry(new THREE.CatmullRomCurve3(pts), 4, 0.005, 4),
                    mat(colors.vein)
                ));
            });

            return group;
        }

        function makeHindwing(side) {
            const s = side, group = new THREE.Group();
            const W = 0.18;
            const L = 2.00;

            const shape = new THREE.Shape();
            shape.moveTo(0, 0);
            shape.bezierCurveTo(s*W*0.6, 0.02, s*W, L*0.3, s*W*0.95, L*0.7);
            shape.bezierCurveTo(s*W*0.85, L*0.9, s*W*0.50, L, s*W*0.15, L*0.96);
            shape.bezierCurveTo(s*(-W*0.05), L*0.65, s*(-W*0.05), L*0.25, 0, 0);
            const geo = new THREE.ShapeGeometry(shape, 12);
            group.add(new THREE.Mesh(geo, mat(colors.hindwing)));
            group.add(new THREE.LineSegments(
                new THREE.EdgesGeometry(geo),
                new THREE.LineBasicMaterial({ color: colors.vein })
            ));
            for (let fi = 0; fi < 4; fi++) {
                const xf = s * W * (0.2 + fi * 0.18);
                const pts = [
                    new THREE.Vector3(xf * 0.2, 0.004, L * 0.05),
                    new THREE.Vector3(xf,        0.004, L * 0.55),
                    new THREE.Vector3(xf * 0.9,  0.004, L * 0.92),
                ];
                group.add(new THREE.Mesh(
                    new THREE.TubeGeometry(new THREE.CatmullRomCurve3(pts), 6, 0.005, 4),
                    mat(colors.vein)
                ));
            }
            return group;
        }

        function makeHindLeg(side) {
            const s = side, group = new THREE.Group();
            group.add(new THREE.Mesh(
                new THREE.TubeGeometry(new THREE.CatmullRomCurve3([
                    new THREE.Vector3(0, 0, 0),
                    new THREE.Vector3(s*0.14, -0.06, 0.04),
                ]), 3, 0.025, 5),
                mat(colors.dark)
            ));
            const femPts = [
                new THREE.Vector3(s*0.14, -0.06, 0.04),
                new THREE.Vector3(s*0.44, -0.10, 0.30),
                new THREE.Vector3(s*0.70, -0.18, 0.60),
                new THREE.Vector3(s*0.82, -0.30, 0.80),
            ];
            group.add(new THREE.Mesh(
                new THREE.TubeGeometry(new THREE.CatmullRomCurve3(femPts), 10, 0.060, 8),
                mat(colors.body)
            ));
            const keelPts = femPts.map((p, i) =>
                new THREE.Vector3(p.x + s*0.01, p.y + 0.07, p.z)
            );
            group.add(new THREE.Mesh(
                new THREE.TubeGeometry(new THREE.CatmullRomCurve3(keelPts), 8, 0.018, 4),
                mat(colors.light)
            ));
            const tibPts = [
                new THREE.Vector3(s*0.82, -0.30, 0.80),
                new THREE.Vector3(s*0.90, -0.65, 1.20),
                new THREE.Vector3(s*0.88, -1.05, 1.55),
                new THREE.Vector3(s*0.82, -1.40, 1.80),
            ];
            group.add(new THREE.Mesh(
                new THREE.TubeGeometry(new THREE.CatmullRomCurve3(tibPts), 10, 0.022, 6),
                mat(colors.light)
            ));
            for (let si = 0; si < 7; si++) {
                const t = si / 6;
                const base = new THREE.Vector3(
                    s*0.82 + (s*0.82-s*0.82)*t,
                    -0.30 + (-1.40+0.30)*t,
                    0.80 + (1.80-0.80)*t
                );
                [0.06, -0.06].forEach(offset => {
                    const spPts = [
                        base.clone(),
                        new THREE.Vector3(base.x + s*0.04, base.y + offset, base.z + 0.08),
                    ];
                    group.add(new THREE.Mesh(
                        new THREE.TubeGeometry(new THREE.CatmullRomCurve3(spPts), 3, 0.008, 3),
                        mat(colors.spine)
                    ));
                });
            }
            let tPos = new THREE.Vector3(s*0.82, -1.40, 1.80);
            for (let ti = 0; ti < 3; ti++) {
                const tEnd = new THREE.Vector3(tPos.x + s*0.08, tPos.y - 0.14 + ti*0.03, tPos.z + 0.14);
                group.add(new THREE.Mesh(
                    new THREE.TubeGeometry(new THREE.CatmullRomCurve3([tPos, tEnd]), 4, 0.018 - ti*0.004, 5),
                    mat(ti % 2 === 0 ? colors.dark : colors.tarsus)
                ));
                const padGeo = new THREE.SphereGeometry(0.020, 6, 5);
                const pad = new THREE.Mesh(padGeo, mat(colors.spine));
                pad.position.copy(tEnd);
                group.add(pad);
                tPos = tEnd;
            }
            const clPts = [tPos.clone(), new THREE.Vector3(tPos.x + s*0.06, tPos.y - 0.06, tPos.z + 0.06)];
            group.add(new THREE.Mesh(
                new THREE.TubeGeometry(new THREE.CatmullRomCurve3(clPts), 3, 0.010, 4),
                mat(colors.dark)
            ));
            return group;
        }

        function makeWalkingLeg(side, index) {
            const s = side, group = new THREE.Group();
            const zOff = index * 0.40;
            group.add(new THREE.Mesh(
                new THREE.TubeGeometry(new THREE.CatmullRomCurve3([
                    new THREE.Vector3(0,0,0),
                    new THREE.Vector3(s*0.15,-0.08,0.02),
                ]), 3, 0.022, 5),
                mat(colors.dark)
            ));
            const fPts = [
                new THREE.Vector3(s*0.15,-0.08,0.02),
                new THREE.Vector3(s*0.38,-0.32,0.04),
                new THREE.Vector3(s*0.52,-0.52,0.02),
            ];
            group.add(new THREE.Mesh(
                new THREE.TubeGeometry(new THREE.CatmullRomCurve3(fPts), 6, 0.026, 6),
                mat(colors.body)
            ));
            const tPts = [
                new THREE.Vector3(s*0.52,-0.52,0.02),
                new THREE.Vector3(s*0.72,-0.82,-0.02),
                new THREE.Vector3(s*0.80,-1.10,-0.06),
            ];
            group.add(new THREE.Mesh(
                new THREE.TubeGeometry(new THREE.CatmullRomCurve3(tPts), 6, 0.018, 6),
                mat(colors.light)
            ));
            for (let si = 0; si < 3; si++) {
                const t = 0.3 + si * 0.25;
                const bx = s*0.52 + (s*0.80 - s*0.52)*t;
                const by = -0.52 + (-1.10+0.52)*t;
                const bz = 0.02 + (-0.06-0.02)*t;
                group.add(new THREE.Mesh(
                    new THREE.TubeGeometry(new THREE.CatmullRomCurve3([
                        new THREE.Vector3(bx, by, bz),
                        new THREE.Vector3(bx + s*0.04, by + 0.06, bz + 0.04),
                    ]), 3, 0.006, 3),
                    mat(colors.spine)
                ));
            }
            let tp = new THREE.Vector3(s*0.80,-1.10,-0.06);
            for (let ti = 0; ti < 3; ti++) {
                const te = new THREE.Vector3(tp.x + s*0.07, tp.y - 0.10 + ti*0.02, tp.z - 0.03);
                group.add(new THREE.Mesh(
                    new THREE.TubeGeometry(new THREE.CatmullRomCurve3([tp,te]), 3, 0.013-ti*0.003, 4),
                    mat(ti%2===0?colors.dark:colors.tarsus)
                ));
                tp = te;
            }
            return group;
        }

        function createLocust() {
            const group = new THREE.Group();
            legGroups = [];

            const abdData = [
                { r:0.22, z:0.05 }, { r:0.24, z:0.35 }, { r:0.23, z:0.64 },
                { r:0.21, z:0.92 }, { r:0.18, z:1.18 }, { r:0.15, z:1.42 },
                { r:0.11, z:1.62 }, { r:0.07, z:1.78 },
            ];
            abdData.forEach((s, i) => {
                const next = abdData[i+1];
                const rTop = next ? next.r : s.r * 0.5;
                const isAlt = i % 2 === 0;
                const geo = new THREE.CylinderGeometry(rTop*0.9, s.r, 0.30, 14, 1);
                const seg = new THREE.Mesh(geo, mat(isAlt ? colors.body : colors.dark));
                seg.rotation.x = Math.PI * 0.5;
                seg.position.set(0, 0, s.z);
                group.add(seg);
                [-1,1].forEach(side => {
                    const spGeo = new THREE.TorusGeometry(0.020, 0.006, 5, 10);
                    const sp = new THREE.Mesh(spGeo, mat(colors.light));
                    sp.position.set(side * s.r * 0.92, 0.04, s.z);
                    group.add(sp);
                });
                if (isAlt) {
                    const ridgeGeo = new THREE.BoxGeometry(0.04, 0.025, 0.28);
                    const ridge = new THREE.Mesh(ridgeGeo, mat(colors.stripe));
                    ridge.position.set(0, s.r + 0.01, s.z);
                    group.add(ridge);
                }
            });
            [-1,1].forEach(side => {
                const cPts = [
                    new THREE.Vector3(side*0.06, 0, 1.90),
                    new THREE.Vector3(side*0.10, 0.04, 2.04),
                    new THREE.Vector3(side*0.12, 0.06, 2.14),
                ];
                group.add(new THREE.Mesh(
                    new THREE.TubeGeometry(new THREE.CatmullRomCurve3(cPts), 5, 0.012, 4),
                    mat(colors.dark)
                ));
            });

            const mGeo = new THREE.CylinderGeometry(0.24, 0.28, 0.72, 14, 2);
            const meso = new THREE.Mesh(mGeo, mat(colors.body));
            meso.rotation.x = Math.PI * 0.5;
            meso.position.set(0, 0.04, -0.42);
            group.add(meso);

            const proGeo = new THREE.CylinderGeometry(0.22, 0.30, 0.55, 14, 3);
            const pro = new THREE.Mesh(proGeo, mat(colors.pronotum));
            pro.rotation.x = Math.PI * 0.5;
            pro.position.set(0, 0.06, -0.86);
            group.add(pro);
            const keelPts = [
                new THREE.Vector3(0, 0.30, -0.62),
                new THREE.Vector3(0, 0.32, -0.86),
                new THREE.Vector3(0, 0.30, -1.10),
            ];
            group.add(new THREE.Mesh(
                new THREE.TubeGeometry(new THREE.CatmullRomCurve3(keelPts), 6, 0.022, 5),
                mat(colors.stripe)
            ));
            [-1,1].forEach(side => {
                const cPts = [
                    new THREE.Vector3(side*0.20, 0.14, -0.64),
                    new THREE.Vector3(side*0.24, 0.10, -0.86),
                    new THREE.Vector3(side*0.20, 0.14, -1.08),
                ];
                group.add(new THREE.Mesh(
                    new THREE.TubeGeometry(new THREE.CatmullRomCurve3(cPts), 5, 0.012, 4),
                    mat(colors.light)
                ));
            });

            const headGeo = new THREE.SphereGeometry(0.24, 16, 14);
            const head = new THREE.Mesh(headGeo, mat(colors.body));
            head.scale.set(0.85, 1.10, 0.95);
            head.position.set(0, 0.04, -1.28);
            group.add(head);

            const fastGeo = new THREE.BoxGeometry(0.28, 0.08, 0.16);
            const fast = new THREE.Mesh(fastGeo, mat(colors.pronotum));
            fast.position.set(0, 0.24, -1.28);
            group.add(fast);

            [-1,1].forEach(side => {
                const eyeGeo = new THREE.SphereGeometry(0.13, 14, 12);
                const eye = new THREE.Mesh(eyeGeo, mat(colors.eye));
                eye.scale.set(0.65, 1.0, 0.80);
                eye.position.set(side*0.20, 0.08, -1.28);
                group.add(eye);
                for (let fi = 0; fi < 8; fi++) {
                    const ang = (fi/8) * Math.PI * 2;
                    const fGeo = new THREE.CircleGeometry(0.028, 6);
                    const facet = new THREE.Mesh(fGeo, mat(colors.stripe));
                    facet.position.set(
                        side*(0.22 + Math.cos(ang)*0.04),
                        0.08 + Math.sin(ang)*0.06,
                        -1.26
                    );
                    group.add(facet);
                }
            });

            const fronsGeo = new THREE.BoxGeometry(0.26, 0.28, 0.08);
            const frons = new THREE.Mesh(fronsGeo, mat(colors.light));
            frons.position.set(0, 0.00, -1.48);
            group.add(frons);

            const clyGeo = new THREE.BoxGeometry(0.22, 0.12, 0.06);
            const cly = new THREE.Mesh(clyGeo, mat(colors.pronotum));
            cly.position.set(0, -0.14, -1.50);
            group.add(cly);

            [-1,1].forEach(side => {
                const mPts = [
                    new THREE.Vector3(side*0.08, -0.18, -1.50),
                    new THREE.Vector3(side*0.16, -0.24, -1.60),
                    new THREE.Vector3(side*0.18, -0.28, -1.68),
                ];
                group.add(new THREE.Mesh(
                    new THREE.TubeGeometry(new THREE.CatmullRomCurve3(mPts), 5, 0.020, 5),
                    mat(colors.dark)
                ));
            });

            [-1,1].forEach(side => {
                const antSegs = 20;
                let aPos = new THREE.Vector3(side*0.12, 0.26, -1.38);
                for (let ai = 0; ai < antSegs; ai++) {
                    const t = ai / antSegs;
                    const spread = side * (0.04 + t * 0.06);
                    const aEnd = new THREE.Vector3(
                        aPos.x + spread,
                        aPos.y + 0.05 - t*0.02,
                        aPos.z - 0.12 - t*0.02
                    );
                    const r = 0.010 - t*0.007;
                    if (r > 0.002) {
                        group.add(new THREE.Mesh(
                            new THREE.TubeGeometry(new THREE.CatmullRomCurve3([aPos.clone(), aEnd.clone()]), 3, r, 4),
                            mat(ai%2===0 ? colors.dark : colors.light)
                        ));
                    }
                    if (ai % 3 === 0) {
                        const nodeGeo = new THREE.SphereGeometry(0.014 - t*0.008, 5, 4);
                        const node = new THREE.Mesh(nodeGeo, mat(colors.body));
                        node.position.copy(aEnd);
                        group.add(node);
                    }
                    aPos = aEnd;
                }
            });

            tegmenL = new THREE.Group();
            tegmenL.position.set(-0.10, 0.24, -0.72);
            tegmenL.rotation.z =  0.08;
            tegmenL.rotation.x =  0.04;
            tegmenL.add(makeTegmen(-1));
            group.add(tegmenL);

            tegmenR = new THREE.Group();
            tegmenR.position.set( 0.10, 0.24, -0.72);
            tegmenR.rotation.z = -0.08;
            tegmenR.rotation.x =  0.04;
            tegmenR.add(makeTegmen(1));
            group.add(tegmenR);

            hindwingL = new THREE.Group();
            hindwingL.position.set(-0.08, 0.21, -0.70);
            hindwingL.rotation.z =  0.06;
            hindwingL.rotation.x =  0.05;
            hindwingL.add(makeHindwing(-1));
            group.add(hindwingL);

            hindwingR = new THREE.Group();
            hindwingR.position.set( 0.08, 0.21, -0.70);
            hindwingR.rotation.z = -0.06;
            hindwingR.rotation.x =  0.05;
            hindwingR.add(makeHindwing(1));
            group.add(hindwingR);

            [-1,1].forEach(side => {
                const fl = makeWalkingLeg(side, 0);
                fl.position.set(side*0.28, -0.06, -0.92);
                group.add(fl);
                legGroups.push({ g: fl, side, li: 0, isHind: false });
                const ml = makeWalkingLeg(side, 1);
                ml.position.set(side*0.28, -0.06, -0.50);
                group.add(ml);
                legGroups.push({ g: ml, side, li: 1, isHind: false });
            });

            hindLegL = makeHindLeg(-1);
            hindLegL.position.set(-0.26, 0.00, -0.08);
            group.add(hindLegL);
            hindLegR = makeHindLeg(1);
            hindLegR.position.set( 0.26, 0.00, -0.08);
            group.add(hindLegR);

            group.position.set(0, 0.4, 0.6);
            return group;
        }

        function initOrbitControls() {
            const canvas = document.getElementById('animationCanvas');
            const hint   = document.getElementById('hint');

            function onDragStart(x, y) {
                orbit.isDragging = true; orbit.autoRotate = false;
                orbit.lastX = x; orbit.lastY = y;
                orbit.velTheta = 0; orbit.velPhi = 0;
                canvas.classList.add('dragging');
                hint.style.opacity = '0';
            }
            function onDragMove(x, y) {
                if (!orbit.isDragging) return;
                const dx = x - orbit.lastX, dy = y - orbit.lastY;
                orbit.velTheta = -dx * 0.005; orbit.velPhi = -dy * 0.005;
                orbit.theta += orbit.velTheta;
                orbit.phi = Math.max(PHI_MIN, Math.min(PHI_MAX, orbit.phi + orbit.velPhi));
                orbit.lastX = x; orbit.lastY = y;
                setCameraFromOrbit();
            }
            function onDragEnd() {
                orbit.isDragging = false;
                canvas.classList.remove('dragging');
                setTimeout(() => { orbit.autoRotate = true; }, 2000);
            }

            canvas.addEventListener('mousedown', e => onDragStart(e.clientX, e.clientY));
            window.addEventListener('mousemove', e => onDragMove(e.clientX, e.clientY));
            window.addEventListener('mouseup',   () => onDragEnd());
            canvas.addEventListener('touchstart', e => { e.preventDefault(); onDragStart(e.touches[0].clientX, e.touches[0].clientY); }, { passive: false });
            window.addEventListener('touchmove',  e => { e.preventDefault(); onDragMove(e.touches[0].clientX, e.touches[0].clientY); }, { passive: false });
            window.addEventListener('touchend',   () => onDragEnd());
            canvas.addEventListener('wheel', e => {
                e.preventDefault();
                orbit.radius = Math.max(RADIUS_MIN, Math.min(RADIUS_MAX, orbit.radius + e.deltaY * 0.01));
                setCameraFromOrbit();
            }, { passive: false });

            setTimeout(() => { hint.style.opacity = '0'; }, 3000);
        }

        function init() {
            initBackground();
            animateBackground();
            updateSize();

            scene = new THREE.Scene();
            scene.background = new THREE.Color(0x000000);

            camera = new THREE.PerspectiveCamera(52, window.innerWidth / window.innerHeight, 0.1, 1000);
            updateCamera();

            locust = createLocust();
            scene.add(locust);

            scene.add(new THREE.AmbientLight(0xd0c870, 0.5));
            const pl1 = new THREE.PointLight(0xffe060, 1.8, 100);
            pl1.position.set(5, 6, 3);  scene.add(pl1);
            const pl2 = new THREE.PointLight(0x80d040, 0.7, 100);
            pl2.position.set(-4, -2, 2); scene.add(pl2);
            const pl3 = new THREE.PointLight(0xfff0a0, 0.5, 100);
            pl3.position.set(0, 3, -10); scene.add(pl3);

            animate();
            initOrbitControls();
        }

        function animate() {
            requestAnimationFrame(animate);
            const t = Date.now() * 0.001;

            if (!orbit.isDragging) {
                if (orbit.autoRotate) {
                    orbit.theta += orbit.autoRotateSpeed;
                } else {
                    orbit.velTheta *= 0.92; orbit.velPhi *= 0.92;
                    orbit.theta += orbit.velTheta;
                    orbit.phi = Math.max(PHI_MIN, Math.min(PHI_MAX, orbit.phi + orbit.velPhi));
                }
                setCameraFromOrbit();
            }

            if (locust) {
                legGroups.forEach(({ g, side, li }) => {
                    const phase = li * 1.2 + (side > 0 ? 0.8 : 0);
                    g.rotation.x = Math.sin(t * 0.8 + phase) * 0.012;
                    g.rotation.z = side * (0.04 + Math.sin(t * 0.6 + phase) * 0.010);
                });
                if (hindLegL) hindLegL.rotation.x = Math.sin(t * 0.7)        * 0.010;
                if (hindLegR) hindLegR.rotation.x = Math.sin(t * 0.7 + 0.4)  * 0.010;
            }

            renderer.render(scene, camera);
        }

        window.addEventListener('resize', () => {
            updateSize(); initBackground();
            if (camera) updateCamera();
        });

        window.addEventListener('load', init);
    </script>
</body>
</html>