手势操控的粒子土星 (Three.js + MediaPipe)

参考:https://www.yjln.com/643.html

1. 基础追踪模式 (基础操控)

当单手处于摄像头视野内且被成功识别时,系统进入追踪模式。

A.旋转控制 (掌心水平移动/垂直移动)

a1.动作: 将手掌面向摄像头,然后在空中左右或上下平移。

a2.模型: 土星模型会随着掌心的移动而进行相应的 Y 轴(左右)或 X 轴(上下)旋转。手随心动,可以360度欣赏粒子星系的细节。

B.缩放控制 (拇指与食指捏合距离)

b1.动作: 做出"捏合"的手势,改变拇指指尖与食指指尖之间的距离。

b2.模型: 系统会实时计算这两个指尖的距离。捏合距离越小,土星模型缩小(远离镜头);距离越大,模型放大(靠近镜头)。

2. 状态控制手势 (高级指令)

为了增加交互的流畅性和趣味性,设计了特定的手势来切换系统的运行状态。

C.一键归位 (比耶手势 ✌️)

c1.动作: 对着摄像头比出标准的"V字"或"比耶"手势(食指和中指伸直,其余手指弯曲)。

c2.模型: 一旦识别到此手势,系统会立即从当前的追踪模式切换至"IDLE(待机)"状态。土星模型将通过平滑的动画过渡,自动回到屏幕中心,并恢复初始的、缓慢的自动游荡旋转。无论之前模型处于什么角度或大小,都能优雅归位。

D.断点锁定 (手部移出画面)

d1.动作: 直接将手从摄像头的视野中移开。

d2.模型: 当系统在设定的缓冲时间内(约0.5秒)未检测到手势时,为了防止由于识别丢失导致的画面突变,系统将自动进入"FROZEN(锁定)"状态。土星模型将完全停留在手离开时的那一刻的角度、大小和位置。可以随时再次将手伸入画面来接管控制。

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Saturn Resonance - 极致防抖稳定版 (手势悬停+比耶归位)</title>
    
    <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/@mediapipe/camera_utils/camera_utils.js" crossorigin="anonymous"></script>
    <script src="https://cdn.jsdelivr.net/npm/@mediapipe/hands/hands.js" crossorigin="anonymous"></script>

    <style>
        :root { --primary-gold: #c5a059; --bg-dark: #050505; }
        body { margin: 0; overflow: hidden; background-color: var(--bg-dark); font-family: 'Segoe UI', 'Microsoft YaHei', sans-serif; color: white; user-select: none; }
        #canvas-container { width: 100vw; height: 100vh; position: absolute; top: 0; left: 0; z-index: 1; background: radial-gradient(circle at center, #0a0a0f 0%, #020202 100%); }
        #ui-layer { position: absolute; top: 40px; left: 40px; z-index: 10; pointer-events: none; }
        
        .glass-panel {
            background: rgba(5, 5, 5, 0.4); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
            padding: 30px; border-radius: 8px; border: 1px solid rgba(197, 160, 89, 0.15);
            border-left: 3px solid var(--primary-gold); box-shadow: 0 15px 35px rgba(0, 0, 0, 0.5);
        }
        
        h1 { font-weight: 300; font-size: 2rem; margin: 0 0 15px 0; color: #f0e6d2; letter-spacing: 6px; text-transform: uppercase; }
        .status-text { font-size: 0.85rem; color: #888; line-height: 1.8; font-family: monospace; }
        .highlight { font-weight: bold; text-shadow: 0 0 8px rgba(197,160,89,0.4); }
        .active-pulse { animation: pulse 1.5s infinite; color: #00ff88 !important; text-shadow: 0 0 10px rgba(0,255,136,0.6); }
        .frozen-state { color: #ffaa00 !important; text-shadow: 0 0 10px rgba(255,170,0,0.6); }
        .idle-state { color: var(--primary-gold) !important; }
        @keyframes pulse { 0% { opacity: 0.7; } 50% { opacity: 1; } 100% { opacity: 0.7; } }

        #controls { position: absolute; bottom: 40px; right: 40px; z-index: 10; pointer-events: auto; }
        button, #author-btn {
            background: rgba(0, 0, 0, 0.4); border: 1px solid rgba(197, 160, 89, 0.3); color: var(--primary-gold);
            padding: 12px 24px; font-size: 0.8rem; border-radius: 4px; cursor: pointer; transition: all 0.2s ease;
            text-transform: uppercase; letter-spacing: 2px; backdrop-filter: blur(8px); text-decoration: none; display: inline-block;
        }
        #author-btn { position: absolute; top: 40px; right: 40px; border-radius: 20px; }
        button:hover, #author-btn:hover { background: rgba(197, 160, 89, 0.25); border-color: var(--primary-gold); box-shadow: 0 0 20px rgba(197, 160, 89, 0.3); transform: scale(1.05); }
        
        #loading { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 20; color: #888; font-size: 0.9rem; letter-spacing: 4px; font-family: monospace; }
        .input_video { display: none; }
    </style>
</head>
<body>

    <video class="input_video"></video>
    <a id="author-btn" href="https://www.yjln.com" target="_blank">By Mr.lun</a>
    <div id="loading">NEURAL LINK INITIATING...</div>

    <div id="ui-layer">
        <div class="glass-panel">
            <h1>Saturn</h1>
            <div class="status-text">
                TRACKING STATE: <span id="status-indicator" class="highlight idle-state">STANDBY / IDLE</span><br><br>
                > ORBITAL MECHANICS: ONLINE<br>
                > MOTION LOCK: <span style="color:#ffaa00;">ON (LIFT HAND TO FREEZE)</span><br>
                > RESET TRIGGER: <span style="color:#00ff88;">V-SIGN (✌️)</span>
            </div>
        </div>
    </div>

    <div id="controls"><button onclick="toggleFullScreen()">FULLSCREEN INIT</button></div>
    <div id="canvas-container"></div>

    <script type="x-shader/x-vertex" id="vertexshader">
        attribute vec4 aParams; attribute vec3 customColor; attribute float aRandomId;
        varying vec3 vColor; varying float vDist; varying float vOpacity; varying float vScaleFactor; varying float vIsRing;
        uniform float uTime; uniform float uScale; uniform float uRotationX; uniform float uRotationY; 
        
        mat2 rotate2d(float _angle){ return mat2(cos(_angle),-sin(_angle), sin(_angle),cos(_angle)); }
        float hash(float n) { return fract(sin(n) * 43758.5453123); }

        void main() {
            float normScaleLOD = clamp((uScale - 0.15) / 2.35, 0.0, 1.0);
            float visibilityThreshold = 0.9 + pow(normScaleLOD, 1.2) * 0.1; 
            if (aRandomId > visibilityThreshold) { gl_Position = vec4(2.0, 2.0, 2.0, 0.0); return; }

            vec3 pos = position; float isRing = aParams.w; float speed = aParams.z;
            if (isRing > 0.5) { pos.xz = rotate2d(uTime * speed * 0.2) * pos.xz; } else { pos.xz = rotate2d(uTime * 0.03) * pos.xz; }

            float cx = cos(uRotationX), sx = sin(uRotationX);
            float ry = pos.y * cx - pos.z * sx; float rz = pos.y * sx + pos.z * cx; pos.y = ry; pos.z = rz;

            float cy = cos(uRotationY), sy = sin(uRotationY);
            float rx = pos.x * cy + pos.z * sy; rz = -pos.x * sy + pos.z * cy; pos.x = rx; pos.z = rz;

            vec4 mvPosition = modelViewMatrix * vec4(pos * uScale, 1.0);
            float dist = -mvPosition.z;
            
            if (dist < 25.0 && dist > 0.1) {
                float chaos = pow(1.0 - (dist / 25.0), 3.0) * 3.0; float ht = uTime * 40.0;
                vec3 noiseVec = vec3(sin(ht + pos.x * 10.0) * hash(pos.y), cos(ht + pos.y * 10.0) * hash(pos.x), sin(ht * 0.5) * hash(pos.z));
                mvPosition.xyz += noiseVec * chaos;
            }
            
            gl_Position = projectionMatrix * mvPosition;
            float pointSize = aParams.x * (190.0 / dist); if (isRing < 0.5 && dist < 50.0) pointSize *= 0.8; 
            gl_PointSize = clamp(pointSize, 0.0, 150.0);

            vColor = customColor; vOpacity = aParams.y; vScaleFactor = uScale; vIsRing = isRing; vDist = dist;
        }
    </script>

    <script type="x-shader/x-fragment" id="fragmentshader">
        varying vec3 vColor; varying float vDist; varying float vOpacity; varying float vScaleFactor; varying float vIsRing;
        void main() {
            vec2 cxy = 2.0 * gl_PointCoord - 1.0; float r2 = dot(cxy, cxy); if (r2 > 1.0) discard;
            float glow = 1.0 - r2; float t = clamp((vScaleFactor - 0.15) / 2.35, 0.0, 1.0);
            vec3 baseColor = mix(vec3(0.35, 0.22, 0.05), vColor, smoothstep(0.1, 0.9, t)); vec3 finalColor = baseColor * (0.2 + t); 
            if (vDist < 40.0) {
                float closeMix = 1.0 - (vDist / 40.0);
                if (vIsRing < 0.5) finalColor = mix(finalColor, vColor * vColor * 1.5, closeMix * 0.8); else finalColor += vec3(0.15, 0.12, 0.1) * closeMix;
            }
            float alpha = glow * vOpacity * (0.25 + 0.45 * t); if (vDist < 10.0) alpha *= vDist * 0.1;
            gl_FragColor = vec4(finalColor, alpha);
        }
    </script>

    <script type="x-shader/x-vertex" id="starVS">
        attribute float size; attribute vec3 customColor; varying vec3 vColor;
        void main() { vColor = customColor; vec4 mvPos = modelViewMatrix * vec4(position, 1.0); gl_PointSize = clamp(size * (1000.0 / -mvPos.z), 1.0, 8.0); gl_Position = projectionMatrix * mvPos; }
    </script>
    <script type="x-shader/x-fragment" id="starFS">
        varying vec3 vColor; uniform float uTime; float hash(vec2 st) { return fract(sin(dot(st, vec2(12.9898,78.233))) * 43758.545); }
        void main() { vec2 cxy = 2.0 * gl_PointCoord - 1.0; float r2 = dot(cxy, cxy); if(r2 > 1.0) discard; gl_FragColor = vec4(vColor * (0.7 + 0.3 * sin(uTime * 2.0 + hash(gl_FragCoord.xy) * 10.0)), (1.0 - r2) * 0.8); }
    </script>

    <script>
        const CONFIG = {
            particles: 1000000, planetRadius: 18,
            lerpSpeed: 0.2, cameraZ: 100, pixelRatioLimit: 1.5
        };

        let scene, camera, renderer, particles, stars;
        let uniforms, starUniforms;
        
        let targetScale = 1.0, targetRotX = 0.4, targetRotY = 0.0;
        let currentScale = 1.0, currentRotX = 0.4, currentRotY = 0.0;
        
        // --- 核心状态机 ---
        // 'IDLE': 初始/比耶后的自动缓游荡归位状态
        // 'TRACKING': 手势控制状态
        // 'FROZEN': 手离开画面,保持当前形态
        let systemState = 'IDLE'; 
        
        let lostHandFrames = 0; 
        const LOST_TOLERANCE = 15; 
        
        let smoothedPinchDist = 0.1; 
        let smoothedPalmX = 0.5;    
        let smoothedPalmY = 0.5;    

        const videoElement = document.querySelector('.input_video');
        const statusElement = document.getElementById('status-indicator');
        const loadingElement = document.getElementById('loading');

        function initThree() {
            scene = new THREE.Scene(); scene.fog = new THREE.FogExp2(0x020202, 0.00015);
            camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 1, 10000); camera.position.z = CONFIG.cameraZ;
            initSaturn(); initStarfield();

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

            window.addEventListener('resize', () => { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); });
            animate();
        }

        function initSaturn() {
            const geometry = new THREE.BufferGeometry();
            const pos = new Float32Array(CONFIG.particles * 3), cols = new Float32Array(CONFIG.particles * 3), params = new Float32Array(CONFIG.particles * 4), randomIds = new Float32Array(CONFIG.particles);
            const R = CONFIG.planetRadius; const bodyCols = ['#E3DAC5', '#C9A070', '#E3DAC5', '#B08D55'].map(c => new THREE.Color(c));

            for(let i = 0; i < CONFIG.particles; i++) {
                let x, y, z, r, g, b, size, opacity, speed, isRingVal; randomIds[i] = Math.random();

                if (i < CONFIG.particles * 0.25) {
                    isRingVal = 0.0; speed = 0.0;
                    const phi = Math.acos(2 * Math.random() - 1), theta = 2 * Math.PI * Math.random();
                    x = R * Math.sin(phi) * Math.cos(theta); y = R * Math.cos(phi) * 0.9; z = R * Math.sin(phi) * Math.sin(theta);
                    let lat = (y / R + 1.0) * 0.5; let cIdx = Math.max(0, Math.floor(lat * 4 + Math.cos(lat * 40.0)*0.8) % 4);
                    r = bodyCols[cIdx].r; g = bodyCols[cIdx].g; b = bodyCols[cIdx].b; size = 1.0 + Math.random() * 0.8; opacity = 0.8; 
                } else {
                    isRingVal = 1.0; let zR = Math.random(), ringR;
                    if (zR < 0.15) { ringR = R * (1.235 + Math.random()*0.29); r=0.16; g=0.14; b=0.12; size=0.5; opacity=0.3; }
                    else if (zR < 0.65) { ringR = R * (1.525 + Math.random()*0.425); r=0.8; g=0.75; b=0.62; size=0.8+Math.random()*0.6; opacity=0.85; }
                    else if (zR < 0.69) { ringR = R * (1.95 + Math.random()*0.075); r=0.02; g=0.02; b=0.02; size=0.3; opacity=0.1; }
                    else if (zR < 0.99) { ringR = R * (2.025 + Math.random()*0.245); r=0.59; g=0.56; b=0.52; size=0.7; opacity=0.6; }
                    else { ringR = R * (2.32 + Math.random()*0.02); r=0.68; g=0.68; b=0.62; size=1.0; opacity=0.7; }
                    const theta = Math.random() * Math.PI * 2;
                    x = ringR * Math.cos(theta); z = ringR * Math.sin(theta); y = (Math.random() - 0.5) * (ringR > R*2.3 ? 0.4 : 0.15);
                    speed = 8.0 / Math.sqrt(ringR);
                }
                pos[i*3]=x; pos[i*3+1]=y; pos[i*3+2]=z; cols[i*3]=r; cols[i*3+1]=g; cols[i*3+2]=b;
                let pIdx = i * 4; params[pIdx] = size; params[pIdx+1] = opacity; params[pIdx+2] = speed; params[pIdx+3] = isRingVal;
            }
            geometry.setAttribute('position', new THREE.BufferAttribute(pos, 3)); geometry.setAttribute('customColor', new THREE.BufferAttribute(cols, 3));
            geometry.setAttribute('aParams', new THREE.BufferAttribute(params, 4)); geometry.setAttribute('aRandomId', new THREE.BufferAttribute(randomIds, 1));

            uniforms = { uTime: { value: 0 }, uScale: { value: 1.0 }, uRotationX: { value: 0.4 }, uRotationY: { value: 0.0 } };
            particles = new THREE.Points(geometry, new THREE.ShaderMaterial({ depthWrite: false, blending: THREE.AdditiveBlending, transparent: true, uniforms: uniforms, vertexShader: document.getElementById('vertexshader').textContent, fragmentShader: document.getElementById('fragmentshader').textContent }));
            particles.rotation.z = 26.73 * (Math.PI / 180); scene.add(particles);
        }

        function initStarfield() {
            const geo = new THREE.BufferGeometry();
            const pos = new Float32Array(30000 * 3), cols = new Float32Array(30000 * 3), sizes = new Float32Array(30000);
            for(let i=0; i<30000; i++) {
                const r = 400 + Math.random() * 3000, t = Math.random()*Math.PI*2, p = Math.acos(2*Math.random()-1);
                pos[i*3]=r*Math.sin(p)*Math.cos(t); pos[i*3+1]=r*Math.cos(p); pos[i*3+2]=r*Math.sin(p)*Math.sin(t);
                cols[i*3]=0.8+Math.random()*0.2; cols[i*3+1]=0.8+Math.random()*0.2; cols[i*3+2]=0.9+Math.random()*0.1; sizes[i]=1.0+Math.random()*2.0;
            }
            geo.setAttribute('position', new THREE.BufferAttribute(pos, 3)); geo.setAttribute('customColor', new THREE.BufferAttribute(cols, 3)); geo.setAttribute('size', new THREE.BufferAttribute(sizes, 1));
            starUniforms = { uTime: { value: 0 } };
            stars = new THREE.Points(geo, new THREE.ShaderMaterial({ uniforms: starUniforms, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, vertexShader: document.getElementById('starVS').textContent, fragmentShader: document.getElementById('starFS').textContent }));
            scene.add(stars);
        }

        const clock = new THREE.Clock(); let autoIdleTime = 0;

        function animate() {
            requestAnimationFrame(animate);
            const time = clock.getElapsedTime();
            uniforms.uTime.value = time; if(starUniforms) starUniforms.uTime.value = time;
            if(stars) stars.rotation.y = time * 0.002;

            // 依据不同状态执行不同逻辑
            if (systemState === 'IDLE') {
                autoIdleTime += 0.01;
                targetScale = 1.0 + Math.sin(autoIdleTime) * 0.1;
                targetRotX = 0.4 + Math.sin(autoIdleTime * 0.3) * 0.2;
                targetRotY = Math.sin(autoIdleTime * 0.2) * 0.4; 
                statusElement.innerText = "STANDBY / IDLE (AUTO ORBIT)"; 
                statusElement.className = "highlight idle-state";
            } else if (systemState === 'FROZEN') {
                // FROZEN 状态下不修改 target,维持原状
                statusElement.innerText = "SIGNAL LOST / POSITION LOCKED"; 
                statusElement.className = "highlight frozen-state";
            } else if (systemState === 'TRACKING') {
                statusElement.innerText = "LINK ESTABLISHED / FULL CONTROL"; 
                statusElement.className = "highlight active-pulse";
            }

            // 平滑过渡
            currentScale += (targetScale - currentScale) * CONFIG.lerpSpeed;
            currentRotX += (targetRotX - currentRotX) * CONFIG.lerpSpeed;
            currentRotY += (targetRotY - currentRotY) * CONFIG.lerpSpeed;
            
            uniforms.uScale.value = currentScale; uniforms.uRotationX.value = currentRotX; uniforms.uRotationY.value = currentRotY;
            renderer.render(scene, camera);
        }

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

        hands.onResults((results) => {
            loadingElement.style.display = 'none';
            if (results.multiHandLandmarks?.length > 0) {
                lostHandFrames = 0; 
                const hand = results.multiHandLandmarks[0];
                
                // 1. 识别是否为"比耶 (V字)"手势
                // 逻辑:食指和中指伸直(指尖在关节上方),无名指和小指弯曲(指尖在关节下方)
                const indexUp = hand[8].y < hand[6].y;
                const middleUp = hand[12].y < hand[10].y;
                const ringDown = hand[16].y > hand[14].y;
                const pinkyDown = hand[20].y > hand[18].y;
                
                const isVSign = indexUp && middleUp && ringDown && pinkyDown;

                if (isVSign) {
                    // 如果识别到比耶,立刻进入 IDLE 归位模式
                    systemState = 'IDLE';
                    
                    // 让平滑滤波器的数据也同步往默认状态靠拢,防止放下手切回 TRACKING 时镜头突变抽搐
                    smoothedPinchDist += (0.1 - smoothedPinchDist) * 0.1; 
                    smoothedPalmX += (0.5 - smoothedPalmX) * 0.1;
                    smoothedPalmY += (0.5 - smoothedPalmY) * 0.1;
                } else {
                    // 正常追踪手势
                    systemState = 'TRACKING';
                    
                    const palm = hand[9]; 
                    const rawPinchDist = Math.hypot(hand[4].x - hand[8].x, hand[4].y - hand[8].y);
                    const rawPalmX = palm.x;
                    const rawPalmY = palm.y;

                    const filterFactor = 0.15; 
                    smoothedPinchDist += (rawPinchDist - smoothedPinchDist) * filterFactor;
                    smoothedPalmX += (rawPalmX - smoothedPalmX) * filterFactor;
                    smoothedPalmY += (rawPalmY - smoothedPalmY) * filterFactor;
                    
                    targetScale = 0.15 + Math.max(0, Math.min(1, (smoothedPinchDist - 0.02) / 0.18)) * 2.6;
                    targetRotY = -(smoothedPalmX - 0.5) * Math.PI * 2.5; 
                    targetRotX = (smoothedPalmY - 0.5) * Math.PI * 1.5; 
                }
            } else {
                lostHandFrames++;
                if (lostHandFrames > LOST_TOLERANCE) {
                    // 如果手势丢失,且之前是在 TRACKING 模式,则冻结在当前位置
                    if (systemState === 'TRACKING') {
                        systemState = 'FROZEN';
                    }
                    // 如果之前是因为比耶进入了 IDLE 模式,手离开后也会继续保持漂亮的缓游荡状态,不会突变。
                }
            }
        });

        new Camera(videoElement, { onFrame: async () => { await hands.send({image: videoElement}); }, width: 640, height: 480 }).start();
        function toggleFullScreen() { if (!document.fullscreenElement) document.documentElement.requestFullscreen(); else if (document.exitFullscreen) document.exitFullscreen(); }
        initThree();
    </script>
</body>
</html>
相关推荐
LXS_3572 小时前
案例 —— 机房预约系统
开发语言·c++·学习方法
代码探秘者2 小时前
【Java】final、finally、finalize 区别
java·开发语言
yeflx2 小时前
C++纯虚接口
开发语言·c++
代码探秘者2 小时前
【Java】浅拷贝 VS 深拷贝:核心差异 + 实现方式 + 避坑指南
java·开发语言
坚持学习前端日记2 小时前
AI 产品开发经验
前端·javascript·人工智能·visual studio
雾削木2 小时前
STM32输入捕获测量PWM频率占空比
前端·javascript·stm32
JamesYoung79712 小时前
第八部分 — UI 表面 动作(工具栏)、徽标、弹出窗口
前端·javascript
Joker Zxc2 小时前
【前端基础(Javascript部分)】5、JavaScript的循环语句
开发语言·前端·javascript
不会写DN2 小时前
Golang中实时推送的功臣 - WebSocket
开发语言·后端·golang