3D 粒子手势交互系统

点击测试

这个3D粒子系统是通过gemini3实现的,只需要输入下面的提示词即可生成。具体细节可参考上图进行微调。最后我把生成好的代码也放在了下面。


提示词
用Three.js创建一个实时交互的3D粒子系统。要求:

1.通过摄像头检测双手张合控制粒子群的缩放与扩散

2.提供U面板可选择爱心/花朵土星/佛像/烟花等模型

3.支持颜色选择器调整粒子颜色

4.粒子需实时响应手势变化

5.界面简洁现代,包含全屏控制按钮

<!DOCTYPE html>
<html lang="zh-CN">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>3D 粒子手势交互系统 v2.1 - 双指旋钮操控版</title>
    <script src="https://cdn.tailwindcss.com"></script>
    <script async src="https://unpkg.com/es-module-shims@1.6.3/dist/es-module-shims.js"></script>
    <script type="importmap">
        {
            "imports": {
                "three": "https://cdn.jsdelivr.net/npm/three@0.154.0/build/three.module.js",
                "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.154.0/examples/jsm/"
            }
        }
    </script>
    <!-- MediaPipe Hands -->
    <script src="https://cdn.jsdelivr.net/npm/@mediapipe/camera_utils/camera_utils.js" crossorigin="anonymous"></script>
    <script src="https://cdn.jsdelivr.net/npm/@mediapipe/control_utils/control_utils.js"
        crossorigin="anonymous"></script>
    <script src="https://cdn.jsdelivr.net/npm/@mediapipe/drawing_utils/drawing_utils.js"
        crossorigin="anonymous"></script>
    <script src="https://cdn.jsdelivr.net/npm/@mediapipe/hands/hands.js" crossorigin="anonymous"></script>

    <style>
        body {
            margin: 0;
            overflow: hidden;
            background-color: #020205;
            font-family: 'Segoe UI', sans-serif;
        }

        #canvas-container {
            position: absolute;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            z-index: 1;
        }

        #video-element {
            display: none;
        }

        .glass-panel {
            background: rgba(10, 10, 20, 0.7);
            backdrop-filter: blur(16px);
            -webkit-backdrop-filter: blur(16px);
            border: 1px solid rgba(255, 255, 255, 0.08);
            border-radius: 20px;
            box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
        }

        .control-btn {
            transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
            position: relative;
            overflow: hidden;
        }

        .control-btn:hover {
            background: rgba(255, 255, 255, 0.15);
            transform: translateY(-2px);
            box-shadow: 0 0 20px rgba(255, 255, 255, 0.1);
        }

        .control-btn.active {
            background: linear-gradient(135deg, rgba(100, 200, 255, 0.4), rgba(100, 200, 255, 0.1));
            border: 1px solid rgba(100, 200, 255, 0.5);
            box-shadow: 0 0 25px rgba(79, 209, 197, 0.3);
            text-shadow: 0 0 8px rgba(255, 255, 255, 0.8);
        }

        .loader {
            border: 3px solid rgba(255, 255, 255, 0.1);
            border-left-color: #00ffff;
            border-radius: 50%;
            width: 50px;
            height: 50px;
            animation: spin 0.8s cubic-bezier(0.5, 0.1, 0.5, 0.9) infinite;
        }

        @keyframes spin {
            0% {
                transform: rotate(0deg);
            }

            100% {
                transform: rotate(360deg);
            }
        }

        .custom-scroll::-webkit-scrollbar {
            height: 4px;
        }

        .custom-scroll::-webkit-scrollbar-track {
            background: rgba(0, 0, 0, 0.2);
            border-radius: 4px;
        }

        .custom-scroll::-webkit-scrollbar-thumb {
            background: rgba(255, 255, 255, 0.15);
            border-radius: 4px;
        }

        .custom-scroll::-webkit-scrollbar-thumb:hover {
            background: rgba(255, 255, 255, 0.3);
        }

        .mode-item {
            transition: all 0.2s ease;
            opacity: 0.4;
            transform: scale(0.95);
            font-weight: normal;
        }

        .mode-item.active {
            opacity: 1;
            transform: scale(1.0);
            color: #22d3ee;
            font-weight: bold;
            text-shadow: 0 0 10px rgba(34, 211, 238, 0.4);
        }
    </style>
</head>

<body>

    <div id="canvas-container"></div>
    <video id="video-element" playsinline></video>

    <!-- Loading -->
    <div id="loader-overlay"
        class="fixed inset-0 z-50 flex flex-col items-center justify-center bg-black bg-opacity-95 transition-opacity duration-700">
        <div class="loader mb-6 shadow-[0_0_30px_rgba(0,255,255,0.3)]"></div>
        <div
            class="text-transparent bg-clip-text bg-gradient-to-r from-cyan-300 to-blue-500 text-2xl font-bold tracking-widest uppercase">
            Visual Core</div>
        <div class="text-gray-500 text-xs mt-3 tracking-widest">INITIALIZING PARTICLE ENGINE v2.1</div>
    </div>

    <!-- UI -->
    <div class="absolute top-0 left-0 w-full h-full pointer-events-none z-10 p-4 md:p-8 flex flex-col justify-between">
        <div class="flex justify-between items-start pointer-events-auto">
            <div class="glass-panel p-5 animate-fade-in-down transform transition-all hover:scale-105 duration-500">
                <h1
                    class="text-white text-2xl font-bold tracking-tight mb-2 drop-shadow-[0_0_10px_rgba(255,255,255,0.3)]">
                    粒子 · 幻境 <span class="text-xs text-cyan-400 align-top">v2.1</span></h1>
                <div class="flex items-center gap-3 bg-black/30 rounded-full px-3 py-1 w-fit border border-white/5">
                    <div id="status-dot"
                        class="w-2 h-2 rounded-full bg-red-500 transition-all duration-500 shadow-[0_0_10px_red]"></div>
                    <span id="status-text" class="text-[10px] text-gray-300 font-mono uppercase tracking-wider">System
                        Offline</span>
                </div>
                <div class="text-[10px] text-gray-400 mt-3 font-mono border-t border-white/10 pt-2 flex flex-col gap-1">
                    <div id="mode-scale" class="mode-item">👋 五指张合: 能量爆发 (缩放)</div>
                    <div id="mode-rotate" class="mode-item">☝️ 食指滑动: 视角旋转 (锁定缩放)</div>
                    <div id="mode-roll" class="mode-item">✌️ 双指旋转: 平面翻转 (旋钮操控)</div>
                </div>
            </div>

            <button onclick="toggleFullScreen()"
                class="glass-panel p-4 text-white hover:text-cyan-300 control-btn rounded-full group">
                <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 group-hover:scale-110 transition-transform"
                    fill="none" viewBox="0 0 24 24" stroke="currentColor">
                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
                        d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
                </svg>
            </button>
        </div>

        <div
            class="pointer-events-auto flex flex-col md:flex-row gap-6 items-end md:items-center justify-between w-full">
            <div class="glass-panel p-2 flex gap-2 overflow-x-auto max-w-full custom-scroll">
                <button onclick="changeShape('sphere')"
                    class="control-btn active px-6 py-3 rounded-xl text-sm text-white font-semibold whitespace-nowrap tracking-wide"
                    data-shape="sphere">星云球</button>
                <button onclick="changeShape('heart')"
                    class="control-btn px-6 py-3 rounded-xl text-sm text-white font-semibold whitespace-nowrap tracking-wide"
                    data-shape="heart">机械心</button>
                <button onclick="changeShape('saturn')"
                    class="control-btn px-6 py-3 rounded-xl text-sm text-white font-semibold whitespace-nowrap tracking-wide"
                    data-shape="saturn">土星环</button>
                <button onclick="changeShape('lotus')"
                    class="control-btn px-6 py-3 rounded-xl text-sm text-white font-semibold whitespace-nowrap tracking-wide"
                    data-shape="lotus">能量莲</button>
                <button onclick="changeShape('galaxy')"
                    class="control-btn px-6 py-3 rounded-xl text-sm text-white font-semibold whitespace-nowrap tracking-wide"
                    data-shape="galaxy">黑洞</button>
            </div>

            <div class="glass-panel p-4 flex items-center gap-4 hover:bg-white/10 transition-colors">
                <span class="text-white text-xs font-mono tracking-wider uppercase opacity-70">Emission Color</span>
                <div
                    class="relative w-8 h-8 rounded-full overflow-hidden ring-2 ring-white/20 hover:ring-cyan-400 transition-all">
                    <input type="color" id="color-picker" value="#00ffff"
                        class="absolute -top-1/2 -left-1/2 w-[200%] h-[200%] cursor-pointer p-0 m-0 border-0"
                        onchange="updateColor(this.value)">
                </div>
            </div>
        </div>
    </div>

    <script type="module">
        import * as THREE from 'three';
        import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
        import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
        import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
        import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';

        // --- Configuration ---
        const PARTICLE_COUNT = 45000;
        const PARTICLE_SIZE = 0.18;
        const SATURN_BODY_RATIO = 0.3;

        // State
        let scene, camera, renderer, composer, particles, controls;
        let targetPositions = [];
        let targetColors = [];
        let currentShape = 'sphere';
        let handInfluence = 0;
        let isHandDetected = false;
        let clock = new THREE.Clock();

        // Color State
        let userBaseColor = new THREE.Color(0x00ffff);

        // 旋转交互变量
        let previousFingerPos = { x: 0, y: 0 };
        let smoothedFingerPos = { x: 0, y: 0 };
        let rotationVelocity = { x: 0, y: 0, z: 0 };
        let isTrackingRotation = false;

        // 【新增】双指旋钮交互变量
        let isTrackingRoll = false;
        let smoothedIndexTip = { x: 0, y: 0 };
        let smoothedMiddleTip = { x: 0, y: 0 };
        let previousRollAngle = 0;

        // 缩放交互变量
        let currentGestureMode = 'none';

        // 模式锁定与信号防抖
        let currentStableMode = 'scale';
        let modeFrameCounter = 0;
        let lastTimeHandDetected = 0;

        async function init() {
            // 1. Scene & Camera
            scene = new THREE.Scene();
            scene.fog = new THREE.FogExp2(0x020205, 0.02);

            camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
            camera.position.z = 30;
            camera.position.y = 0;

            // 2. Renderer
            renderer = new THREE.WebGLRenderer({ antialias: false, alpha: true, powerPreference: "high-performance" });
            renderer.setSize(window.innerWidth, window.innerHeight);
            renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
            renderer.toneMapping = THREE.ReinhardToneMapping;
            document.getElementById('canvas-container').appendChild(renderer.domElement);

            // 3. Post Processing (BLOOM)
            const renderScene = new RenderPass(scene, camera);
            const bloomPass = new UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), 1.5, 0.4, 0.85);
            bloomPass.threshold = 0.05;
            bloomPass.strength = 1.4;
            bloomPass.radius = 0.6;

            composer = new EffectComposer(renderer);
            composer.addPass(renderScene);
            composer.addPass(bloomPass);

            // 4. Controls
            controls = new OrbitControls(camera, renderer.domElement);
            controls.enableDamping = true;
            controls.dampingFactor = 0.05;
            controls.autoRotate = true;
            controls.autoRotateSpeed = 0.5;
            controls.enableZoom = true;

            // 5. Build World
            createParticles();
            await setupMediaPipe();

            window.addEventListener('resize', onWindowResize);
            animate();
        }

        function createParticles() {
            const geometry = new THREE.BufferGeometry();
            const positions = new Float32Array(PARTICLE_COUNT * 3);
            const colors = new Float32Array(PARTICLE_COUNT * 3);

            const sphere = getShapePositions('sphere');
            const initialColors = getShapeColors('sphere');

            for (let i = 0; i < PARTICLE_COUNT; i++) {
                positions[i * 3] = sphere[i * 3];
                positions[i * 3 + 1] = sphere[i * 3 + 1];
                positions[i * 3 + 2] = sphere[i * 3 + 2];

                colors[i * 3] = initialColors[i * 3];
                colors[i * 3 + 1] = initialColors[i * 3 + 1];
                colors[i * 3 + 2] = initialColors[i * 3 + 2];
            }

            geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
            geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));

            const sprite = createParticleTexture();

            const material = new THREE.PointsMaterial({
                size: PARTICLE_SIZE,
                map: sprite,
                color: userBaseColor,
                transparent: true,
                opacity: 0.8,
                blending: THREE.AdditiveBlending,
                depthWrite: false,
                sizeAttenuation: true,
                vertexColors: true
            });

            particles = new THREE.Points(geometry, material);
            scene.add(particles);

            targetPositions = Float32Array.from(sphere);
            targetColors = Float32Array.from(initialColors);
        }

        function createParticleTexture() {
            const canvas = document.createElement('canvas');
            canvas.width = 32; canvas.height = 32;
            const context = canvas.getContext('2d');
            const gradient = context.createRadialGradient(16, 16, 0, 16, 16, 16);
            gradient.addColorStop(0, 'rgba(255,255,255,1)');
            gradient.addColorStop(0.4, 'rgba(255,255,255,0.5)');
            gradient.addColorStop(1, 'rgba(0,0,0,0)');
            context.fillStyle = gradient;
            context.fillRect(0, 0, 32, 32);
            return new THREE.CanvasTexture(canvas);
        }

        function getShapeColors(type) {
            const cols = new Float32Array(PARTICLE_COUNT * 3);

            for (let i = 0; i < PARTICLE_COUNT; i++) {
                let brightness = 0.2 + Math.random() * 0.8;
                let r, g, b;

                if (type === 'saturn') {
                    if (i < PARTICLE_COUNT * SATURN_BODY_RATIO) {
                        r = 1.0; g = 0.7; b = 0.3;
                    } else {
                        r = 0.6; g = 0.8; b = 1.0;
                    }
                    r *= brightness; g *= brightness; b *= brightness;
                } else {
                    r = brightness; g = brightness; b = brightness;
                }

                cols[i * 3] = r;
                cols[i * 3 + 1] = g;
                cols[i * 3 + 2] = b;
            }
            return cols;
        }

        function getShapePositions(type) {
            const pos = new Float32Array(PARTICLE_COUNT * 3);

            for (let i = 0; i < PARTICLE_COUNT; i++) {
                let x, y, z;

                if (type === 'sphere') {
                    const r = 10 + Math.random() * 2;
                    const theta = Math.random() * Math.PI * 2;
                    const phi = Math.acos(2 * Math.random() - 1);
                    x = r * Math.sin(phi) * Math.cos(theta);
                    y = r * Math.sin(phi) * Math.sin(theta);
                    z = r * Math.cos(phi);
                    if (i < PARTICLE_COUNT * 0.2) { x *= 0.3; y *= 0.3; z *= 0.3; }
                }
                else if (type === 'heart') {
                    const t = Math.PI - 2 * Math.PI * Math.random();
                    const u = 2 * Math.PI * Math.random();
                    x = 16 * Math.pow(Math.sin(t), 3);
                    y = 13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t);
                    z = 6 * Math.cos(t) * Math.sin(u) * Math.sin(t);
                    const scale = 0.6;
                    x *= scale; y *= scale; z *= scale;
                    if (Math.random() > 0.8) { x *= 1.1; y *= 1.1; z *= 1.1; }
                }
                else if (type === 'saturn') {
                    if (i < PARTICLE_COUNT * SATURN_BODY_RATIO) {
                        const r = 5.5;
                        const theta = Math.random() * Math.PI * 2;
                        const phi = Math.acos(2 * Math.random() - 1);
                        x = r * Math.sin(phi) * Math.cos(theta);
                        y = r * 0.9 * Math.sin(phi) * Math.sin(theta);
                        z = r * Math.cos(phi);
                    } else {
                        const angle = Math.random() * Math.PI * 2;
                        const ringSelector = Math.random();
                        let r, thickness;
                        if (ringSelector < 0.45) {
                            r = 7 + Math.random() * 3.5; thickness = 0.2;
                        } else if (ringSelector < 0.5) {
                            r = 10.5 + Math.random() * 1.5; thickness = 0.1;
                            if (Math.random() > 0.2) { x = 0; y = 0; z = 0; pos[i * 3] = x; pos[i * 3 + 1] = y; pos[i * 3 + 2] = z; continue; }
                        } else {
                            r = 12 + Math.random() * 5; thickness = 0.4;
                        }
                        r += (Math.random() - 0.5) * 0.3;
                        x = r * Math.cos(angle);
                        y = (Math.random() - 0.5) * thickness;
                        z = r * Math.sin(angle);
                        const tilt = 0.4;
                        const y_new = y * Math.cos(tilt) - x * Math.sin(tilt);
                        const x_new = y * Math.sin(tilt) + x * Math.cos(tilt);
                        x = x_new; y = y_new;
                    }
                }
                else if (type === 'lotus') {
                    const u = Math.random() * Math.PI * 2;
                    const v = Math.random();
                    const petals = 7;
                    const rBase = 8 * (0.5 + 0.5 * Math.pow(Math.sin(petals * u * 0.5), 2)) * v;
                    x = rBase * Math.cos(u);
                    z = rBase * Math.sin(u);
                    y = 4 * Math.pow(v, 2) - 2;
                    if (i < PARTICLE_COUNT * 0.15) { x = (Math.random() - 0.5); z = (Math.random() - 0.5); y = (Math.random() - 0.5) * 10; }
                }
                else if (type === 'galaxy') {
                    const arms = 3;
                    const spin = i % arms;
                    const angleOffset = (spin / arms) * Math.PI * 2;
                    const dist = Math.pow(Math.random(), 0.5);
                    const r = dist * 20;
                    const angle = dist * 10 + angleOffset;
                    x = r * Math.cos(angle);
                    z = r * Math.sin(angle);
                    y = (Math.random() - 0.5) * (15 - r) * 0.2;
                    if (r < 2) y *= 0.2;
                }

                pos[i * 3] = x;
                pos[i * 3 + 1] = y;
                pos[i * 3 + 2] = z;
            }
            return pos;
        }

        function animate() {
            requestAnimationFrame(animate);

            const time = clock.getElapsedTime();

            const positions = particles.geometry.attributes.position.array;
            const colors = particles.geometry.attributes.color.array;

            let targetScaleBase = 1.0;
            let targetParticleSize = PARTICLE_SIZE;
            let turbulence = 0.05;

            // 【模式应用】
            if (isHandDetected) {
                targetScaleBase = 0.2 + (handInfluence * 2.3);
                turbulence = 0.02 + (handInfluence * 0.3);
                targetParticleSize = PARTICLE_SIZE * (0.8 + handInfluence * 2.5);
            } else {
                targetScaleBase = 1.0 + Math.sin(time * 1.5) * 0.05;
                targetParticleSize = PARTICLE_SIZE * (1.0 + Math.sin(time * 1.5) * 0.15);
            }

            if (particles && particles.material) {
                particles.material.size = THREE.MathUtils.lerp(particles.material.size, targetParticleSize, 0.1);

                const camRight = new THREE.Vector3(1, 0, 0).applyQuaternion(camera.quaternion).normalize();
                const camUp = new THREE.Vector3(0, 1, 0).applyQuaternion(camera.quaternion).normalize();
                const camForward = new THREE.Vector3(0, 0, -1).applyQuaternion(camera.quaternion).normalize();

                particles.rotateOnWorldAxis(camUp, rotationVelocity.y);
                particles.rotateOnWorldAxis(camRight, rotationVelocity.x);
                particles.rotateOnWorldAxis(camForward, rotationVelocity.z);

                const MAX_SPEED = 0.06;
                const MAX_ROLL_SPEED = 0.035;

                rotationVelocity.x = THREE.MathUtils.clamp(rotationVelocity.x, -MAX_SPEED, MAX_SPEED);
                rotationVelocity.y = THREE.MathUtils.clamp(rotationVelocity.y, -MAX_SPEED, MAX_SPEED);
                rotationVelocity.z = THREE.MathUtils.clamp(rotationVelocity.z, -MAX_ROLL_SPEED, MAX_ROLL_SPEED);

                rotationVelocity.x *= 0.92;
                rotationVelocity.y *= 0.92;
                rotationVelocity.z *= 0.96;
            }

            const lerpSpeed = 0.06;
            const colorLerpSpeed = 0.03;

            for (let i = 0; i < PARTICLE_COUNT; i++) {
                const idx = i * 3;

                let tx = targetPositions[idx];
                let ty = targetPositions[idx + 1];
                let tz = targetPositions[idx + 2];

                const dist = Math.sqrt(tx * tx + ty * ty + tz * tz);
                const normalizedDist = Math.min(dist / 20.0, 1.0);

                let distanceExpansionBoost = 1.0;
                if (isHandDetected) {
                    distanceExpansionBoost = 1.0 + (handInfluence * Math.pow(normalizedDist, 1.5) * 2.0);
                }

                const finalScale = targetScaleBase * distanceExpansionBoost;
                tx *= finalScale; ty *= finalScale; tz *= finalScale;

                if (currentShape === 'galaxy') {
                    const angle = time * (0.1 + (1.0 - (Math.sqrt(tx * tx + tz * tz) / 20)) * 0.5);
                    const cos = Math.cos(angle); const sin = Math.sin(angle);
                    const rx = tx * cos - tz * sin;
                    const rz = tx * sin + tz * cos;
                    tx = rx; tz = rz;
                } else if (currentShape === 'lotus') {
                    ty += Math.sin(time + tx) * 0.5;
                } else {
                    tx += Math.sin(time * 2 + i) * turbulence;
                    ty += Math.cos(time * 3 + i) * turbulence;
                    tz += Math.sin(time * 4 + i) * turbulence;
                }

                positions[idx] += (tx - positions[idx]) * lerpSpeed;
                positions[idx + 1] += (ty - positions[idx + 1]) * lerpSpeed;
                positions[idx + 2] += (tz - positions[idx + 2]) * lerpSpeed;

                if (targetColors.length > 0) {
                    colors[idx] += (targetColors[idx] - colors[idx]) * colorLerpSpeed;
                    colors[idx + 1] += (targetColors[idx + 1] - colors[idx + 1]) * colorLerpSpeed;
                    colors[idx + 2] += (targetColors[idx + 2] - colors[idx + 2]) * colorLerpSpeed;
                }

                if (Math.random() > 0.9995) {
                    colors[idx] = 2.0; colors[idx + 1] = 2.0; colors[idx + 2] = 2.0;
                }
                if (colors[idx] > 1.5) {
                    colors[idx] *= 0.9; colors[idx + 1] *= 0.9; colors[idx + 2] *= 0.9;
                }
            }

            particles.geometry.attributes.position.needsUpdate = true;
            particles.geometry.attributes.color.needsUpdate = true;

            if (!isHandDetected) {
                controls.autoRotate = true;
                controls.autoRotateSpeed = 1.0;
            } else {
                controls.autoRotate = false;
            }

            controls.update();
            composer.render();
        }

        async function setupMediaPipe() {
            const videoElement = document.getElementById('video-element');
            const statusDot = document.getElementById('status-dot');
            const statusText = document.getElementById('status-text');
            const modeScaleText = document.getElementById('mode-scale');
            const modeRotateText = document.getElementById('mode-rotate');
            const modeRollText = document.getElementById('mode-roll');

            const hands = new Hands({
                locateFile: (file) => {
                    return `https://cdn.jsdelivr.net/npm/@mediapipe/hands/${file}`;
                }
            });

            hands.setOptions({
                maxNumHands: 1,
                modelComplexity: 1,
                minDetectionConfidence: 0.5,
                minTrackingConfidence: 0.5
            });

            hands.onResults((results) => {
                const now = Date.now();

                // 【信号防抖核心逻辑】
                if (results.multiHandLandmarks && results.multiHandLandmarks.length > 0) {
                    // --- 检测到手 ---
                    isHandDetected = true;
                    lastTimeHandDetected = now;

                    statusDot.className = "w-2 h-2 rounded-full bg-cyan-400 shadow-[0_0_15px_cyan]";
                    statusText.innerText = "LINK ESTABLISHED";
                    statusText.className = "text-[10px] text-cyan-300 font-mono uppercase tracking-wider font-bold";

                    const landmarks = results.multiHandLandmarks[0];

                    // ... [手势识别逻辑] ...
                    const getDist = (i, j) => {
                        return Math.sqrt(Math.pow(landmarks[i].x - landmarks[j].x, 2) + Math.pow(landmarks[i].y - landmarks[j].y, 2));
                    };

                    // 【核心优化:手势判定宽松化】
                    // 只要指尖离手腕足够远,就认为是伸直的。
                    // 系数 1.1 比较宽松,确保稍微弯一点也算伸直
                    const isIndexOpen = getDist(8, 0) > getDist(6, 0) * 1.1;
                    const isMiddleOpen = getDist(12, 0) > getDist(10, 0) * 1.1;

                    // 无名指和小指:用于判定是否为 Scale 模式
                    // 只要它们有一个是明显伸直的,就认为不是双指模式
                    const isRingOpen = getDist(16, 0) > getDist(14, 0) * 1.1;
                    const isPinkyOpen = getDist(20, 0) > getDist(18, 0) * 1.1;

                    let detectedMode = 'scale';

                    // 【优先级逻辑优化】
                    // 1. 如果无名指或小指是伸直的 -> Scale (最优先,防止五指张开时误判)
                    // 2. 如果只有食指和中指伸直 -> Roll (双指画圈)
                    // 3. 如果只有食指伸直 -> Rotate (单指旋转)
                    // 4. 其他情况 (握拳等) -> Scale

                    if (isRingOpen || isPinkyOpen) {
                        detectedMode = 'scale';
                    } else if (isIndexOpen && isMiddleOpen) {
                        detectedMode = 'roll';
                    } else if (isIndexOpen && !isMiddleOpen) {
                        detectedMode = 'rotate';
                    } else {
                        detectedMode = 'scale'; // 握拳也是 Scale (收缩)
                    }

                    if (detectedMode === currentStableMode) {
                        modeFrameCounter = 0;
                    } else {
                        modeFrameCounter++;
                        // 减少切换阈值,让反应更快 (4帧约 60ms)
                        let switchThreshold = 4;

                        if (modeFrameCounter > switchThreshold) {
                            currentStableMode = detectedMode;
                            modeFrameCounter = 0;
                            isTrackingRotation = false;
                            isTrackingRoll = false;
                            previousFingerPos = { x: 0, y: 0 };
                        }
                    }

                    modeScaleText.classList.remove('active');
                    modeRotateText.classList.remove('active');
                    modeRollText.classList.remove('active');
                    if (currentStableMode === 'roll') modeRollText.classList.add('active');
                    else if (currentStableMode === 'rotate') modeRotateText.classList.add('active');
                    else modeScaleText.classList.add('active');

                    if (currentStableMode === 'roll') {
                        // 【平面旋转 - 绝对角度跟手版】
                        const rawIndex = landmarks[8];
                        const rawMiddle = landmarks[12];

                        // 1. 低通滤波 (平滑度 0.2, 兼顾响应和稳定)
                        if (!isTrackingRoll) {
                            smoothedIndexTip = { x: rawIndex.x, y: rawIndex.y };
                            smoothedMiddleTip = { x: rawMiddle.x, y: rawMiddle.y };
                            // 初始化角度
                            const dx = rawIndex.x - rawMiddle.x;
                            const dy = rawIndex.y - rawMiddle.y;
                            previousRollAngle = Math.atan2(dy, dx);
                            isTrackingRoll = true;
                        } else {
                            const lerpFactor = 0.2;
                            smoothedIndexTip.x = THREE.MathUtils.lerp(smoothedIndexTip.x, rawIndex.x, lerpFactor);
                            smoothedIndexTip.y = THREE.MathUtils.lerp(smoothedIndexTip.y, rawIndex.y, lerpFactor);
                            smoothedMiddleTip.x = THREE.MathUtils.lerp(smoothedMiddleTip.x, rawMiddle.x, lerpFactor);
                            smoothedMiddleTip.y = THREE.MathUtils.lerp(smoothedMiddleTip.y, rawMiddle.y, lerpFactor);
                        }

                        // 2. 计算当前角度 (Between fingers)
                        const dx = smoothedIndexTip.x - smoothedMiddleTip.x;
                        const dy = smoothedIndexTip.y - smoothedMiddleTip.y;
                        const currentAngle = Math.atan2(dy, dx);

                        let deltaAngle = currentAngle - previousRollAngle;

                        // 处理 -PI 到 PI 的跳变
                        if (deltaAngle > Math.PI) deltaAngle -= 2 * Math.PI;
                        if (deltaAngle < -Math.PI) deltaAngle += 2 * Math.PI;

                        // 修正系数:-1.5 (负号修正镜像,1.5放大手感)
                        const inputVelocity = -deltaAngle * 1.5;

                        // 混合输入速度和当前惯性,让它既跟手又有重量
                        rotationVelocity.z = THREE.MathUtils.lerp(rotationVelocity.z, inputVelocity, 0.3);

                        previousRollAngle = currentAngle;
                        isTrackingRotation = false;

                    } else if (currentStableMode === 'rotate') {
                        const rawFingerX = landmarks[8].x;
                        const rawFingerY = landmarks[8].y;

                        if (!isTrackingRotation) {
                            smoothedFingerPos = { x: rawFingerX, y: rawFingerY };
                            previousFingerPos = { x: rawFingerX, y: rawFingerY };
                            isTrackingRotation = true;
                        }

                        const smoothingFactor = 0.2;
                        smoothedFingerPos.x = THREE.MathUtils.lerp(smoothedFingerPos.x, rawFingerX, smoothingFactor);
                        smoothedFingerPos.y = THREE.MathUtils.lerp(smoothedFingerPos.y, rawFingerY, smoothingFactor);

                        const deltaX = smoothedFingerPos.x - previousFingerPos.x;
                        const deltaY = smoothedFingerPos.y - previousFingerPos.y;

                        const MOVE_THRESHOLD = 0.005;

                        if (Math.abs(deltaX) > MOVE_THRESHOLD || Math.abs(deltaY) > MOVE_THRESHOLD) {
                            const sensitivity = 3.5;
                            if (Math.abs(deltaX) > Math.abs(deltaY)) {
                                const effectiveX = deltaX > 0 ? deltaX - MOVE_THRESHOLD : deltaX + MOVE_THRESHOLD;
                                rotationVelocity.y -= effectiveX * sensitivity;
                            } else {
                                const effectiveY = deltaY > 0 ? deltaY - MOVE_THRESHOLD : deltaY + MOVE_THRESHOLD;
                                rotationVelocity.x += effectiveY * sensitivity;
                            }
                        }
                        previousFingerPos = { x: smoothedFingerPos.x, y: smoothedFingerPos.y };
                        isTrackingRoll = false;

                    } else {
                        // 【缩放模式】
                        if (isTrackingRoll) { isTrackingRoll = false; }
                        isTrackingRotation = false;

                        const tips = [4, 8, 12, 16, 20];
                        let totalDist = 0;
                        tips.forEach(idx => {
                            totalDist += getDist(idx, 0);
                        });

                        const avgDist = totalDist / 5;
                        let rawOpenness = (avgDist - 0.08) / (0.4 - 0.08);
                        rawOpenness = Math.max(0, Math.min(1, rawOpenness));

                        handInfluence = THREE.MathUtils.lerp(handInfluence, rawOpenness, 0.2);
                    }

                } else {
                    // --- 未检测到手 ---
                    if (now - lastTimeHandDetected < 800) {
                        return;
                    }

                    isHandDetected = false;
                    isTrackingRotation = false;
                    isTrackingRoll = false;
                    modeFrameCounter = 0;
                    currentStableMode = 'scale';

                    statusDot.className = "w-2 h-2 rounded-full bg-red-500 shadow-[0_0_10px_red]";
                    statusText.innerText = "SEARCHING SIGNAL...";
                    statusText.className = "text-[10px] text-red-400 font-mono uppercase tracking-wider animate-pulse";

                    handInfluence = THREE.MathUtils.lerp(handInfluence, 0.5, 0.02);

                    modeScaleText.classList.remove('active');
                    modeRotateText.classList.remove('active');
                    modeRollText.classList.remove('active');
                }
            });

            const cameraUtils = new Camera(videoElement, {
                onFrame: async () => { await hands.send({ image: videoElement }); },
                width: 640, height: 480
            });

            try {
                await cameraUtils.start();
                const loader = document.getElementById('loader-overlay');
                loader.style.opacity = '0';
                setTimeout(() => loader.remove(), 800);
            } catch (err) {
                console.error(err);
                statusText.innerText = "CAMERA ERROR";
            }
        }

        window.onWindowResize = () => {
            camera.aspect = window.innerWidth / window.innerHeight;
            camera.updateProjectionMatrix();
            renderer.setSize(window.innerWidth, window.innerHeight);
            composer.setSize(window.innerWidth, window.innerHeight);
        };

        window.changeShape = (shape) => {
            currentShape = shape;
            targetPositions = getShapePositions(shape);
            targetColors = getShapeColors(shape);

            if (shape === 'saturn') {
                const white = new THREE.Color(0xffffff);
                new TWEEN.Tween(particles.material.color)
                    .to({ r: 1, g: 1, b: 1 }, 500)
                    .start();
            } else {
                new TWEEN.Tween(particles.material.color)
                    .to({ r: userBaseColor.r, g: userBaseColor.g, b: userBaseColor.b }, 500)
                    .start();
            }

            document.querySelectorAll('[data-shape]').forEach(btn => {
                btn.classList.remove('active');
                if (btn.dataset.shape === shape) btn.classList.add('active');
            });
        };

        window.updateColor = (hex) => {
            const c = new THREE.Color(hex);
            userBaseColor = c;

            if (particles) {
                if (currentShape !== 'saturn') {
                    const initial = particles.material.color.clone();
                    let alpha = 0;
                    const anim = () => {
                        alpha += 0.05;
                        particles.material.color.lerpColors(initial, c, alpha);
                        if (alpha < 1) requestAnimationFrame(anim);
                    };
                    anim();
                }
            }
        };

        window.toggleFullScreen = () => {
            if (!document.fullscreenElement) document.documentElement.requestFullscreen();
            else if (document.exitFullscreen) document.exitFullscreen();
        };

        const TWEEN = {
            Tween: function (obj) {
                this.obj = obj;
                this.target = {};
                this.duration = 1000;
                this.startTime = 0;
                this.to = function (target, duration) { this.target = target; this.duration = duration; return this; };
                this.start = function () { this.startTime = performance.now(); this.initial = { r: this.obj.r, g: this.obj.g, b: this.obj.b }; requestAnimationFrame(this.update.bind(this)); return this; };
                this.update = function (time) {
                    const elapsed = time - this.startTime;
                    const progress = Math.min(elapsed / this.duration, 1);
                    const ease = 1 - Math.pow(1 - progress, 3);
                    this.obj.r = this.initial.r + (this.target.r - this.initial.r) * ease;
                    this.obj.g = this.initial.g + (this.target.g - this.initial.g) * ease;
                    this.obj.b = this.initial.b + (this.target.b - this.initial.b) * ease;
                    if (progress < 1) { requestAnimationFrame(this.update.bind(this)); }
                };
            }
        };

        init();
    </script>
</body>

</html>
觉得有帮助可以投喂下博主哦~感谢!
作者:Jianbo
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0协议
转载请注明文章地址及作者
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇