用摄像头捕捉手势实现酷炫的粒子效果!

12 minute read

Published:

今天干了个很有意思的东西, 通过摄像头追踪手势的握拳和张开, 来渲染图案的粒子效果, 建议在 PC 网页上浏览.

📢 网页需要读取你的摄像头信息, 通过摄像头识别你的手握拳 (伸展) 来收缩 (散开) 粒子, 所以需要通过一次权限!

🔮 体验 3D 粒子交互演示

顺带附上源码, 欢迎体验:

---
layout: null
permalink: /particles/
---
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Three.js 手势交互粒子系统 v4.7 (Smaller Heart)</title>
    <!-- Tailwind CSS -->
    <script src="https://cdn.tailwindcss.com"></script>
    <!-- MediaPipe -->
    <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>
    <!-- Three.js -->
    <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
    
    <style>
        body { margin: 0; overflow: hidden; background-color: #020205; color: white; font-family: 'Segoe UI', sans-serif; }
        #canvas-container { position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 1; }
        #video-input { position: absolute; top: 0; left: 0; visibility: hidden; width: 320px; height: 240px; transform: scaleX(-1); }
        
        .ui-panel {
            position: absolute;
            top: 20px;
            left: 20px;
            z-index: 10;
            background: rgba(10, 10, 15, 0.6);
            backdrop-filter: blur(12px);
            border: 1px solid rgba(255, 255, 255, 0.08);
            padding: 24px;
            border-radius: 20px;
            width: 300px;
            box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
            transition: opacity 0.3s;
        }

        .btn-shape {
            background: rgba(255, 255, 255, 0.05);
            border: 1px solid rgba(255, 255, 255, 0.1);
            color: #aaa;
            padding: 8px 14px;
            margin: 4px;
            border-radius: 8px;
            cursor: pointer;
            transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
            font-size: 13px;
            font-weight: 500;
            letter-spacing: 0.5px;
        }
        .btn-shape:hover, .btn-shape.active {
            background: rgba(255, 255, 255, 0.15);
            border-color: rgba(255, 255, 255, 0.4);
            color: white;
            box-shadow: 0 0 15px rgba(255, 255, 255, 0.1);
            text-shadow: 0 0 8px rgba(255,255,255,0.5);
        }
        .btn-shape.active {
            background: rgba(100, 200, 255, 0.2);
            border-color: rgba(100, 200, 255, 0.6);
            color: #e0f2fe;
            box-shadow: 0 0 20px rgba(100, 200, 255, 0.2);
        }

        #loading {
            position: absolute;
            top: 50%; left: 50%;
            transform: translate(-50%, -50%);
            z-index: 20;
            text-align: center;
            pointer-events: none;
        }
        .spinner {
            width: 40px; height: 40px;
            border: 3px solid rgba(255,255,255,0.1);
            border-top: 3px solid #00d2ff;
            border-radius: 50%;
            animation: spin 1s cubic-bezier(0.4, 0, 0.2, 1) infinite;
            margin: 0 auto 15px;
        }
        @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }

        /* 摄像头预览窗口样式 */
        #cam-preview {
            position: absolute;
            bottom: 24px;
            right: 24px;
            width: 180px;
            height: 135px;
            z-index: 5;
            border-radius: 16px;
            overflow: hidden;
            border: 1px solid rgba(255,255,255,0.1);
            background: #000;
            opacity: 0.9;
            box-shadow: 0 4px 20px rgba(0,0,0,0.5);
            transition: opacity 0.3s;
            display: none; /* 默认隐藏 */
        }
    </style>
</head>
<body>

    <div id="loading">
        <div class="spinner"></div>
        <div class="text-gray-400 text-xs tracking-[0.2em] uppercase">System Initializing</div>
    </div>

    <video id="video-input"></video>
    <div id="canvas-container"></div>
    <canvas id="cam-preview"></canvas>

    <div class="ui-panel text-white">
        <div class="flex items-center justify-between mb-4">
            <h2 class="text-lg font-bold bg-clip-text text-transparent bg-gradient-to-r from-blue-300 via-purple-300 to-pink-300 tracking-wide">ASTRO · DUST</h2>
            <div class="h-2 w-2 rounded-full bg-green-500 shadow-[0_0_10px_#22c55e]" id="status-dot"></div>
        </div>
        
        <p class="text-[10px] text-gray-400 mb-6 uppercase tracking-wider leading-relaxed">
            Gestural Field Control<br>
            <span class="text-gray-500">Pinch to Gather · Release to Scatter</span>
        </p>

        <div class="mb-6">
            <label class="block text-[10px] uppercase text-gray-500 mb-3 font-bold tracking-widest">Topology</label>
            <div class="flex flex-wrap -m-1">
                <button class="btn-shape active" onclick="switchShape('text')">SJTU</button>
                <button class="btn-shape" onclick="switchShape('heart')">Heart</button>
                <button class="btn-shape" onclick="switchShape('saturn')">Saturn</button>
            </div>
        </div>

        <div class="mb-4">
            <label class="block text-[10px] uppercase text-gray-500 mb-3 font-bold tracking-widest">Visual Effects</label>
            
            <!-- 颜色选择器 -->
            <div class="flex items-center justify-between mb-3 bg-white/5 p-2 rounded-lg border border-white/5">
                <span class="text-xs text-gray-400 font-medium">Particle Color</span>
                <div class="flex items-center space-x-2">
                    <span class="text-[10px] text-gray-500 font-mono" id="colorCode">#40c9ff</span>
                    <input type="color" id="colorPicker" value="#40c9ff" class="w-5 h-5 rounded cursor-pointer border-none bg-transparent">
                </div>
            </div>

            <!-- 摄像头开关 -->
            <div class="flex items-center justify-between mb-3 bg-white/5 p-2 rounded-lg border border-white/5">
                <span class="text-xs text-gray-400 font-medium">Camera Feed</span>
                <label class="relative inline-flex items-center cursor-pointer">
                    <input type="checkbox" id="camToggle" class="sr-only peer">
                    <div class="w-9 h-5 bg-gray-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-blue-500"></div>
                </label>
            </div>
        </div>
        
        <div class="mt-4 text-[10px] text-gray-600 font-mono text-center">
            Render Scale: <span id="pixel-ratio">2.0x</span>
        </div>
    </div>

<!-- ================= 顶点着色器 (Vertex Shader) ================= -->
<script type="x-shader/x-vertex" id="vertexshader">
    attribute float size;
    attribute float random;
    
    uniform float time;
    uniform float pixelRatio;
    uniform float handOpenness;
    
    varying float vAlpha;
    varying vec3 vColor;

    void main() {
        vec3 pos = position;
        
        // [可调参数] 波动幅度
        float wave = sin(time * 2.0 + pos.x * 0.1) * 0.1 * handOpenness;
        pos.y += wave;

        vec4 mvPosition = modelViewMatrix * vec4(pos, 1.0);
        float dist = -mvPosition.z;

        // [可调参数] 粒子透视大小
        gl_PointSize = size * (0.8 + 0.4 * random) * pixelRatio * (300.0 / dist);

        gl_Position = projectionMatrix * mvPosition;

        // [可调参数] 呼吸/闪烁速度 (1.5 = 慢速)
        float twinkle = sin(time * 1.5 + random * 10.0);
        
        // [可调参数] 基础透明度
        vAlpha = 0.5 + 0.5 * twinkle;
    }
</script>

<!-- ================= 片元着色器 (Fragment Shader) ================= -->
<script type="x-shader/x-fragment" id="fragmentshader">
    uniform vec3 color;
    varying float vAlpha;

    void main() {
        vec2 xy = gl_PointCoord.xy - vec2(0.5);
        float r = length(xy);

        // 硬切割:实心圆
        if (r > 0.5) discard;

        // 极微小的抗锯齿
        float edge = 1.0 - smoothstep(0.05, 0.08, r);
        
        gl_FragColor = vec4(color, vAlpha * edge);
    }
</script>

<script>
    // ================= 全局配置 (CONFIGURATION) =================
    const CONFIG = {
        particleCount: 30000, 
        baseSize: 1.2,        
        defaultColor: new THREE.Color(0x40c9ff), 
        cameraZ: 25,          
        scatterRadius: 35     
    };

    const STATE = {
        currentShape: 'text',
        targetPositions: new Float32Array(CONFIG.particleCount * 3), 
        randomOffsets: new Float32Array(CONFIG.particleCount * 3),   
        handOpenness: 1.0,  
        handPosition: { x: 0.5, y: 0.5 }, 
        handDetected: false,
        time: 0
    };

    // ================= THREE.JS 核心设置 =================
    const scene = new THREE.Scene();
    scene.background = new THREE.Color(0x020205); 
    scene.fog = new THREE.FogExp2(0x020205, 0.015); 

    const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 200);
    camera.position.z = CONFIG.cameraZ;

    const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: "high-performance" });
    renderer.setSize(window.innerWidth, window.innerHeight);
    
    const pixelRatio = Math.max(window.devicePixelRatio, 2.0);
    renderer.setPixelRatio(pixelRatio);
    document.getElementById('pixel-ratio').innerText = pixelRatio.toFixed(1) + 'x';
    document.getElementById('canvas-container').appendChild(renderer.domElement);

    // ================= 材质设置 =================
    
    const shaderMaterial = new THREE.ShaderMaterial({
        uniforms: {
            time: { value: 0 },
            color: { value: CONFIG.defaultColor },
            pixelRatio: { value: pixelRatio },
            handOpenness: { value: 1.0 }
        },
        vertexShader: document.getElementById('vertexshader').textContent,
        fragmentShader: document.getElementById('fragmentshader').textContent,
        transparent: true,
        depthWrite: false, 
        blending: THREE.AdditiveBlending 
    });

    // ================= 粒子几何体生成 =================

    const geometry = new THREE.BufferGeometry();
    const positions = new Float32Array(CONFIG.particleCount * 3);
    const randoms = new Float32Array(CONFIG.particleCount); 
    const sizes = new Float32Array(CONFIG.particleCount);   
    
    for (let i = 0; i < CONFIG.particleCount; i++) {
        const r = CONFIG.scatterRadius * (0.5 + Math.random());
        const theta = Math.random() * Math.PI * 2;
        const phi = Math.acos(2 * Math.random() - 1);
        
        positions[i*3] = r * Math.sin(phi) * Math.cos(theta);
        positions[i*3+1] = r * Math.sin(phi) * Math.sin(theta);
        positions[i*3+2] = r * Math.cos(phi);

        STATE.randomOffsets[i*3] = positions[i*3];
        STATE.randomOffsets[i*3+1] = positions[i*3+1];
        STATE.randomOffsets[i*3+2] = positions[i*3+2];

        randoms[i] = Math.random();
        sizes[i] = CONFIG.baseSize * (0.5 + Math.random());
    }

    geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
    geometry.setAttribute('random', new THREE.BufferAttribute(randoms, 1));
    geometry.setAttribute('size', new THREE.BufferAttribute(sizes, 1));

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

    // ================= 形状生成逻辑 =================

    function getPointsFromText(textString) {
        const canvas = document.createElement('canvas');
        const ctx = canvas.getContext('2d');
        canvas.width = 800; 
        canvas.height = 200;
        
        ctx.fillStyle = '#000000';
        ctx.fillRect(0, 0, canvas.width, canvas.height);
        
        ctx.fillStyle = '#FFFFFF';
        ctx.font = '900 100px "Segoe UI", Arial, sans-serif'; 
        ctx.textAlign = 'center';
        ctx.textBaseline = 'middle';
        ctx.fillText(textString, canvas.width / 2, canvas.height / 2);
        
        const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
        const data = imageData.data;
        const validPoints = [];
        
        for (let y = 0; y < canvas.height; y += 3) {
            for (let x = 0; x < canvas.width; x += 3) {
                if (data[(y * canvas.width + x) * 4] > 50) {
                    const worldX = (x - canvas.width / 2) * 0.06; 
                    const worldY = -(y - canvas.height / 2) * 0.06; 
                    validPoints.push({x: worldX, y: worldY});
                }
            }
        }
        return validPoints;
    }

    function generateTargetPositions(shapeType) {
        const targets = new Float32Array(CONFIG.particleCount * 3);
        const count = CONFIG.particleCount;
        let textPoints = [];
        
        if (shapeType === 'text') {
            textPoints = getPointsFromText("I ❤️ SJTU");
        } else if (shapeType === 'heart') {
            textPoints = getPointsFromText("❤️");
        }

        for (let i = 0; i < count; i++) {
            let x, y, z;
            const idx = i * 3;

            if (shapeType === 'heart') {
                if (textPoints.length > 0) {
                    const p = textPoints[i % textPoints.length];
                    
                    // [修改] 缩放比例从 3.5 调整为 2.5 (缩小约 30%)
                    const scale = 2.5;
                    
                    x = p.x * scale;
                    // [修改] Y轴坐标减去 2.5,使图案整体稍微下移
                    y = p.y * scale - 2.5;
                    
                    z = (Math.random() - 0.5) * 3.0 * scale; 
                } else {
                    x = 0; y = 0; z = 0;
                }

            } else if (shapeType === 'saturn') {
                const ratio = i / count;
                if (ratio < 0.6) { 
                    const phi = Math.acos(-1 + (2 * i / (count * 0.6)));
                    const theta = Math.sqrt((count * 0.6) * Math.PI) * phi;
                    const r = 5;
                    x = r * Math.cos(theta) * Math.sin(phi);
                    y = r * Math.sin(theta) * Math.sin(phi);
                    z = r * Math.cos(phi);
                } else { 
                    const angle = Math.random() * Math.PI * 2;
                    const r = 8 + Math.random() * 6; 
                    x = r * Math.cos(angle);
                    z = r * Math.sin(angle); 
                    y = (Math.random() - 0.5) * 0.3; 
                    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 (shapeType === 'text') {
                if (textPoints.length > 0) {
                    const p = textPoints[i % textPoints.length];
                    x = p.x;
                    y = p.y;
                    z = (Math.random() - 0.5) * 2.0;
                } else {
                    x = (Math.random() - 0.5) * 20;
                    y = (Math.random() - 0.5) * 20;
                    z = (Math.random() - 0.5) * 20;
                }
            }

            targets[idx] = x;
            targets[idx + 1] = y;
            targets[idx + 2] = z;
        }
        return targets;
    }

    STATE.targetPositions = generateTargetPositions(STATE.currentShape);

    // ================= MEDIA PIPE 手势识别 =================
    
    const videoElement = document.getElementById('video-input');
    const previewCanvas = document.getElementById('cam-preview');
    const previewCtx = previewCanvas.getContext('2d');
    
    previewCtx.translate(previewCanvas.width, 0);
    previewCtx.scale(-1, 1);

    function onResults(results) {
        // 即使隐藏,Canvas 也必须绘制,否则逻辑可能会断
        previewCtx.drawImage(results.image, 0, 0, previewCanvas.width, previewCanvas.height);
        
        if (results.multiHandLandmarks && results.multiHandLandmarks.length > 0) {
            STATE.handDetected = true;
            document.getElementById('status-dot').className = "h-2 w-2 rounded-full bg-green-500 shadow-[0_0_10px_#22c55e]";
            document.getElementById('loading').style.display = 'none';

            const landmarks = results.multiHandLandmarks[0];
            drawConnectors(previewCtx, landmarks, HAND_CONNECTIONS, {color: '#40c9ff', lineWidth: 1});

            const wrist = landmarks[0];
            const tips = [4, 8, 12, 16, 20].map(i => landmarks[i]);
            const dists = tips.map(tip => Math.hypot(tip.x - wrist.x, tip.y - wrist.y));
            const avgDist = dists.reduce((a, b) => a + b) / 5;
            
            let rawOpenness = (avgDist - 0.2) * 3.3; 
            rawOpenness = Math.max(0, Math.min(1, rawOpenness));
            
            STATE.handOpenness += (rawOpenness - STATE.handOpenness) * 0.1;

            const mid = landmarks[9];
            const handX = (wrist.x + mid.x) / 2;
            const handY = (wrist.y + mid.y) / 2;
            
            STATE.handPosition.x += (handX - STATE.handPosition.x) * 0.1;
            STATE.handPosition.y += (handY - STATE.handPosition.y) * 0.1;

        } else {
            STATE.handDetected = false;
            document.getElementById('status-dot').className = "h-2 w-2 rounded-full bg-yellow-500";
            STATE.handOpenness += (0.0 - STATE.handOpenness) * 0.05;
        }
    }

    const hands = new Hands({locateFile: (file) => `https://cdn.jsdelivr.net/npm/@mediapipe/hands/${file}`});
    hands.setOptions({ maxNumHands: 1, modelComplexity: 1, minDetectionConfidence: 0.6, minTrackingConfidence: 0.6 });
    hands.onResults(onResults);

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

    // ================= 动画循环 (ANIMATION LOOP) =================
    
    function animate() {
        requestAnimationFrame(animate);

        const dt = 0.03;
        STATE.time += dt;
        
        shaderMaterial.uniforms.time.value = STATE.time;
        shaderMaterial.uniforms.handOpenness.value = STATE.handOpenness;

        const positionsArray = particles.geometry.attributes.position.array;
        
        // 旋转逻辑
        if (STATE.handDetected) {
            // [可调参数] 手势旋转灵敏度 (已增大)
            // handPosition.x 范围 0~1,中心为 0.5。
            // 当手在画面正中心 (0.5) 时,targetRotY = -(0.5 - 0.5) * 8.0 = 0
            // 此时粒子群旋转角度归零,正面对着屏幕。
            const targetRotY = -(STATE.handPosition.x - 0.4) * 8.0; 
            const targetRotX = (STATE.handPosition.y - 0.6) * 6.0; 
            
            particles.rotation.y += (targetRotY - particles.rotation.y) * 0.1;
            particles.rotation.x += (targetRotX - particles.rotation.x) * 0.1;
        } else {
            particles.rotation.y += 0.001; 
            particles.rotation.x += (0 - particles.rotation.x) * 0.05;
        }

        const openness = STATE.handOpenness; 
        const lerpFactor = 0.1; 

        for (let i = 0; i < CONFIG.particleCount; i++) {
            const i3 = i * 3;
            
            const tx = STATE.targetPositions[i3];
            const ty = STATE.targetPositions[i3+1];
            const tz = STATE.targetPositions[i3+2];

            const rx = STATE.randomOffsets[i3] + Math.sin(STATE.time * 0.5 + i) * 2.0;
            const ry = STATE.randomOffsets[i3+1] + Math.cos(STATE.time * 0.3 + i) * 2.0;
            const rz = STATE.randomOffsets[i3+2];

            const mixRatio = openness * openness; 

            const destX = tx * (1.0 - mixRatio) + rx * mixRatio;
            const destY = ty * (1.0 - mixRatio) + ry * mixRatio;
            const destZ = tz * (1.0 - mixRatio) + rz * mixRatio;

            positionsArray[i3]   += (destX - positionsArray[i3]) * lerpFactor;
            positionsArray[i3+1] += (destY - positionsArray[i3+1]) * lerpFactor;
            positionsArray[i3+2] += (destZ - positionsArray[i3+2]) * lerpFactor;
        }

        particles.geometry.attributes.position.needsUpdate = true;
        renderer.render(scene, camera);
    }

    animate();

    // ================= 交互事件 =================

    window.switchShape = (shape) => {
        STATE.currentShape = shape;
        STATE.targetPositions = generateTargetPositions(shape);
        
        document.querySelectorAll('.btn-shape').forEach(btn => {
            btn.classList.remove('active');
            if(btn.innerText.toLowerCase().includes(shape === 'text' ? 'sjtu' : shape)) {
                btn.classList.add('active');
            }
        });
    };

    document.getElementById('colorPicker').addEventListener('input', (e) => {
        const hex = e.target.value;
        document.getElementById('colorCode').innerText = hex;
        shaderMaterial.uniforms.color.value = new THREE.Color(hex);
    });

    // [新增] 摄像头开关监听
    document.getElementById('camToggle').addEventListener('change', (e) => {
        const preview = document.getElementById('cam-preview');
        // 如果选中,显示 Block;否则 None
        preview.style.display = e.target.checked ? 'block' : 'none';
    });

    window.addEventListener('resize', () => {
        camera.aspect = window.innerWidth / window.innerHeight;
        camera.updateProjectionMatrix();
        renderer.setSize(window.innerWidth, window.innerHeight);
        const newPixel = Math.max(window.devicePixelRatio, 2.0);
        renderer.setPixelRatio(newPixel);
        shaderMaterial.uniforms.pixelRatio.value = newPixel;
        document.getElementById('pixel-ratio').innerText = newPixel.toFixed(1) + 'x';
    });
    
</script>
</body>
</html>