找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

1163

积分

0

好友

163

主题
发表于 3 天前 | 查看: 7| 回复: 0

你是否梦想过用手势操控动态的粒子星云,就像《奇异博士》中的魔法场景?本教程将展示如何利用Three.js的3D图形能力和MediaPipe的AI视觉技术,在网页端实现一个实时交互的3D手势粒子系统。

效果预览

系统构建了一个实时交互的3D粒子环境:

  • 视觉表现:由20,000个粒子组成的动态模型,支持爱心、土星、佛像等多种形状。
  • 交互方式:通过摄像头捕捉手势,张开手掌使粒子扩散,捏合手指让粒子凝聚。
  • 技术栈:Three.js + Google MediaPipe + Lil-GUI。

效果演示如下:

手指捏合:粒子凝聚
粒子凝聚效果

张开手掌:粒子扩散
粒子扩散效果

沉浸模式:隐藏参数界面
沉浸模式效果

核心技术拆解

1. 粒子系统的构建:数学驱动的高效渲染

直接创建大量Mesh对象会导致性能崩溃。高效方案是使用THREE.PointsBufferGeometry,通过数学公式动态生成粒子坐标。

所有形状均基于参数方程生成,例如3D爱心的坐标计算:

const Shapes = {
  heart: (i, count) => {
    const t = (i / count) * Math.PI * 2 * 10;
    const x = 16 * Math.pow(Math.sin(t), 3);
    const y = 13 * Math.cos(t) - 5 * Math.cos(2*t) - 2 * Math.cos(3*t) - Math.cos(4*t);
    const z = (Math.random() - 0.5) * 10; // 增加厚度
    return new THREE.Vector3(x * 0.1, y * 0.1, z * 0.1);
  },
  // 其他形状...
};

关键技巧:使用Canvas动态生成的径向渐变贴图作为粒子纹理,并启用AdditiveBlending混合模式,使粒子重叠处产生发光效果。

2. AI视觉处理:MediaPipe手势关键点识别

系统通过Google MediaPipe精准识别手部的21个关键点骨架,取代传统的色彩追踪方法。

初始化配置需注意GPU加速:

const handLandmarker = await HandLandmarker.createFromOptions(vision, {
  baseOptions: {
    modelAssetPath: `.../hand_landmarker.task`,
    delegate: "GPU" // 启用GPU加速
  },
  runningMode: "VIDEO",
  numHands: 2
});

避坑指南:务必确保视频流已初始化(video.videoWidth > 0),否则MediaPipe可能因内部错误而崩溃。

3. 交互逻辑:从手势到粒子运动的映射

核心是计算手势的交互强度因子,将食指与拇指指尖的距离映射到粒子扩散系数。

计算示例:

// 获取指尖坐标(索引8为食指,4为拇指)
const distance = Math.sqrt(
  Math.pow(thumb.x - index.x, 2) + Math.pow(thumb.y - index.y, 2)
);

// 归一化处理:距离越大(张开),因子越接近1;距离越小(捏合),因子越接近0
let factor = Math.min(Math.max((distance - 0.02) / 0.15, 0), 1);

// 使用线性插值平滑过渡,避免突变
CONFIG.interactionStrength = THREE.MathUtils.lerp(CONFIG.interactionStrength, factor, 0.1);

4. 渲染循环:基于目标位置的粒子动画

requestAnimationFrame循环中,采用“目标位置混合”策略高效模拟粒子运动:

  • Target Position:粒子的原始形状坐标。
  • Current Position:粒子的当前位置。
  • Spread:由手势控制的扩散系数。

更新逻辑:

let targetSpread = 0.5; // 默认松散度

if (isHandDetected) {
  targetSpread = 0.2 + (interactionStrength * 2.5); // 手势控制扩散
}

// 逐个更新粒子位置,0.05为阻尼系数
pos[ix] += ((tx * targetSpread + noise) - pos[ix]) * 0.05;

这种方法以极小计算量模拟出带有弹性和阻尼的物理手感。

性能优化关键点

  1. 几何体复用:切换模型时仅更新position属性数组,避免频繁垃圾回收。
  2. GPU加速:MediaPipe运行在GPU上,不阻塞JavaScript主线程。
  3. 动态纹理:使用Canvas生成粒子贴图,无需外部图片加载。

完整实现代码

以下为完整的HTML代码,可直接运行体验:


<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>3D 手势粒子交互系统</title>
    <style>
        body { margin: 0; overflow: hidden; background: #000; font-family: 'Segoe UI', sans-serif; }
        #loader {
            position: absolute; top: 0; left: 0; width: 100%; height: 100%;
            background: #000; display: flex; flex-direction: column;
            justify-content: center; align-items: center; z-index: 999;
            color: #00ffcc; transition: opacity 0.5s;
        }
        .spinner {
            width: 50px; height: 50px; border: 3px solid rgba(0,255,204,0.3);
            border-radius: 50%; border-top-color: #00ffcc;
            animation: spin 1s ease-in-out infinite; margin-bottom: 20px;
        }
        @keyframes spin { to { transform: rotate(360deg); } }
        #webcam-container {
            position: absolute; bottom: 20px; left: 20px; width: 200px; height: 150px;
            border-radius: 12px; overflow: hidden; border: 2px solid rgba(255,255,255,0.2);
            box-shadow: 0 0 20px rgba(0,0,0,0.5); z-index: 10; transform: scaleX(-1);
        }
        video { width: 100%; height: 100%; object-fit: cover; }
        #ui-layer {
            position: absolute; top: 20px; left: 20px; color: white; z-index: 5;
            pointer-events: none; transition: opacity 0.3s;
        }
        #toggle-ui-btn {
            position: absolute; bottom: 20px; right: 20px;
            background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.3);
            color: white; padding: 10px 20px; border-radius: 30px; cursor: pointer;
            backdrop-filter: blur(5px); transition: all 0.3s; z-index: 10;
        }
        #toggle-ui-btn:hover { background: rgba(0,255,204,0.3); border-color: #00ffcc; }
        .ui-hidden #ui-layer, .ui-hidden #webcam-container, .ui-hidden .lil-gui {
            opacity: 0; pointer-events: none;
        }
    </style>
</head>
<body>
    <div id="loader">
        <div class="spinner"></div>
        <div id="loading-text">正在初始化 AI 视觉引擎...</div>
    </div>
    <div id="webcam-container">
        <video id="webcam" autoplay playsinline muted></video>
    </div>
    <div id="ui-layer">
        <h1>NEBULA PARTICLES</h1>
        <p>交互指南: <br>
        1. <span class="highlight">张开手掌</span>:粒子扩散/变大<br>
        2. <span class="highlight">捏合手指</span>:粒子聚拢/凝聚<br>
        3. <span class="highlight">移动双手</span>:控制旋转</p>
    </div>
    <button id="toggle-ui-btn" onclick="toggleUI()">👁️ 沉浸模式</button>
    <script type="importmap">
        {
            "imports": {
                "three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
                "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/",
                "@mediapipe/tasks-vision": "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.9/+esm",
                "lil-gui": "https://unpkg.com/lil-gui@0.19.1/dist/lil-gui.esm.min.js"
            }
        }
    </script>
    <script type="module">
        import * as THREE from 'three';
        import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
        import { FilesetResolver, HandLandmarker } from '@mediapipe/tasks-vision';
        import GUI from 'lil-gui';

        const CONFIG = {
            particleCount: 20000,
            particleSize: 0.05,
            color: '#00ffff',
            model: 'heart',
            interactionStrength: 0.0,
            handDetected: false,
            rotationSpeed: 0.001
        };

        let scene, camera, renderer, particles, geometry, material;
        let handLandmarker, webcam;
        let positionsOriginal = [];
        let currentPositions = [];
        const clock = new THREE.Clock();

        const Shapes = {
            heart: (i, count) => {
                const t = (i / count) * Math.PI * 2 * 10;
                const x = 16 * Math.pow(Math.sin(t), 3);
                const y = 13 * Math.cos(t) - 5 * Math.cos(2*t) - 2 * Math.cos(3*t) - Math.cos(4*t);
                const z = (Math.random() - 0.5) * 10;
                return new THREE.Vector3(x * 0.1, y * 0.1, z * 0.1);
            },
            sphere: (i, count) => {
                const phi = Math.acos(-1 + (2 * i) / count);
                const theta = Math.sqrt(count * Math.PI) * phi;
                const r = 1.5 + Math.random() * 0.2;
                return new THREE.Vector3(
                    r * Math.cos(theta) * Math.sin(phi),
                    r * Math.sin(theta) * Math.sin(phi),
                    r * Math.cos(phi)
                );
            },
            flower: (i, count) => {
                const theta = i * 0.1;
                const r = Math.sin(5 * theta) + 2;
                const y = (Math.random() - 0.5) * 1;
                const scale = 0.5;
                return new THREE.Vector3(r * Math.cos(theta) * scale, y, r * Math.sin(theta) * scale);
            },
            saturn: (i, count) => {
                const isRing = i > count * 0.4;
                if (isRing) {
                    const angle = Math.random() * Math.PI * 2;
                    const radius = 2.0 + Math.random() * 1.5;
                    return new THREE.Vector3(Math.cos(angle) * radius, (Math.random() - 0.5) * 0.1, Math.sin(angle) * radius);
                } else {
                    const phi = Math.acos(-1 + (2 * i) / (count * 0.4));
                    const theta = Math.sqrt(count * Math.PI) * phi;
                    const r = 1.0;
                    return new THREE.Vector3(r * Math.cos(theta) * Math.sin(phi), r * Math.sin(theta) * Math.sin(phi), r * Math.cos(phi));
                }
            },
            buddha: (i, count) => {
                const p = i / count;
                let center = new THREE.Vector3();
                let r = 1;
                if (p < 0.2) {
                    center.set(0, 1.2, 0); r = 0.5;
                } else if (p < 0.6) {
                    center.set(0, 0, 0); r = 1.0;
                } else {
                    center.set(0, -1.0, 0); r = 1.2;
                }
                const u = Math.random(); const v = Math.random();
                const theta = 2 * Math.PI * u; const phi = Math.acos(2 * v - 1);
                let x = center.x + r * Math.sin(phi) * Math.cos(theta);
                let y = center.y + r * Math.sin(phi) * Math.sin(theta);
                let z = center.z + r * Math.cos(phi);
                if (p >= 0.6) y *= 0.5;
                return new THREE.Vector3(x, y, z);
            },
            fireworks: (i, count) => {
                const theta = Math.random() * Math.PI * 2;
                const phi = Math.acos(Math.random() * 2 - 1);
                const r = Math.random() * 3 + 0.5;
                return new THREE.Vector3(r * Math.sin(phi) * Math.cos(theta), r * Math.sin(phi) * Math.sin(theta), r * Math.cos(phi));
            }
        };

        async function init() {
            scene = new THREE.Scene();
            scene.fog = new THREE.FogExp2(0x000000, 0.05);
            camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 100);
            camera.position.z = 4;
            renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
            renderer.setSize(window.innerWidth, window.innerHeight);
            renderer.setPixelRatio(window.devicePixelRatio);
            document.body.appendChild(renderer.domElement);
            const controls = new OrbitControls(camera, renderer.domElement);
            controls.enableDamping = true;
            controls.autoRotate = true;
            controls.autoRotateSpeed = 1.0;

            createParticles(CONFIG.model);
            setupGUI(controls);
            try {
                await setupMediaPipe();
                document.getElementById('loader').style.opacity = '0';
                setTimeout(() => document.getElementById('loader').style.display = 'none', 500);
            } catch (err) {
                console.error("AI Init Failed:", err);
                document.getElementById('loading-text').innerText = "AI 加载失败,请检查浏览器或网络";
            }
            window.addEventListener('resize', onWindowResize);
            animate(controls);
        }

        function createParticles(shapeKey) {
            if (particles) { scene.remove(particles); geometry.dispose(); material.dispose(); }
            geometry = new THREE.BufferGeometry();
            positionsOriginal = [];
            const positions = [];
            const colors = [];
            const colorObj = new THREE.Color(CONFIG.color);
            const generator = Shapes[shapeKey] || Shapes.heart;
            for (let i = 0; i < CONFIG.particleCount; i++) {
                const vec = generator(i, CONFIG.particleCount);
                positionsOriginal.push(vec.x, vec.y, vec.z);
                positions.push((Math.random() - 0.5) * 10, (Math.random() - 0.5) * 10, (Math.random() - 0.5) * 10);
                const mixedColor = colorObj.clone().offsetHSL(0, 0, (Math.random() - 0.5) * 0.1);
                colors.push(mixedColor.r, mixedColor.g, mixedColor.b);
            }
            geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
            geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
            material = new THREE.PointsMaterial({
                size: CONFIG.particleSize,
                vertexColors: true,
                blending: THREE.AdditiveBlending,
                depthWrite: false,
                transparent: true,
                opacity: 0.8,
                map: createCircleTexture()
            });
            particles = new THREE.Points(geometry, material);
            scene.add(particles);
            currentPositions = geometry.attributes.position.array;
        }

        function createCircleTexture() {
            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.2, 'rgba(255,255,255,0.8)');
            gradient.addColorStop(0.5, 'rgba(255,255,255,0.2)');
            gradient.addColorStop(1, 'rgba(0,0,0,0)');
            context.fillStyle = gradient;
            context.fillRect(0,0,32,32);
            const texture = new THREE.Texture(canvas);
            texture.needsUpdate = true;
            return texture;
        }

        function setupGUI(controls) {
            const gui = new GUI({ title: '控制面板' });
            gui.add(CONFIG, 'model', ['heart', 'sphere', 'flower', 'saturn', 'buddha', 'fireworks'])
               .name('3D 模型').onChange(val => createParticles(val));
            gui.addColor(CONFIG, 'color').name('粒子颜色').onChange(val => {
                const colors = geometry.attributes.color.array;
                const c = new THREE.Color(val);
                for(let i=0; i<colors.length; i+=3) {
                    colors[i] = c.r; colors[i+1] = c.g; colors[i+2] = c.b;
                }
                geometry.attributes.color.needsUpdate = true;
            });
            gui.add(CONFIG, 'particleSize', 0.01, 0.2).name('粒子大小').onChange(v => material.size = v);
            gui.add(controls, 'autoRotate').name('自动旋转');
        }

        async function setupMediaPipe() {
            const vision = await FilesetResolver.forVisionTasks("https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.9/wasm");
            handLandmarker = await HandLandmarker.createFromOptions(vision, {
                baseOptions: {
                    modelAssetPath: `https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task`,
                    delegate: "GPU"
                },
                runningMode: "VIDEO",
                numHands: 2
            });
            webcam = document.getElementById('webcam');
            const stream = await navigator.mediaDevices.getUserMedia({ video: true });
            webcam.srcObject = stream;
            return new Promise(resolve => { webcam.addEventListener('loadeddata', () => { resolve(); }); });
        }

        let lastVideoTime = -1;
        async function predictWebcam() {
            if (handLandmarker && webcam && webcam.videoWidth > 0 && webcam.videoHeight > 0 && webcam.currentTime !== lastVideoTime) {
                lastVideoTime = webcam.currentTime;
                let results;
                try {
                    const startTimeMs = performance.now();
                    results = handLandmarker.detectForVideo(webcam, startTimeMs);
                } catch (e) {
                    console.warn("MediaPipe detect error (skipping frame):", e);
                    requestAnimationFrame(predictWebcam);
                    return;
                }
                const statusDiv = document.getElementById('status-indicator');
                if (results.landmarks && results.landmarks.length > 0) {
                    CONFIG.handDetected = true;
                    statusDiv.innerText = "手势已捕获"; statusDiv.classList.add("active");
                    const hand = results.landmarks[0];
                    const thumb = hand[4]; const index = hand[8];
                    const distance = Math.sqrt(Math.pow(thumb.x - index.x, 2) + Math.pow(thumb.y - index.y, 2));
                    let factor = Math.min(Math.max((distance - 0.02) / 0.15, 0), 1);
                    CONFIG.interactionStrength = THREE.MathUtils.lerp(CONFIG.interactionStrength, factor, 0.1);
                } else {
                    CONFIG.handDetected = false;
                    statusDiv.innerText = "未检测到手势"; statusDiv.classList.remove("active");
                    CONFIG.interactionStrength = THREE.MathUtils.lerp(CONFIG.interactionStrength, 0.5, 0.05);
                }
            }
            requestAnimationFrame(predictWebcam);
        }

        function animate(controls) {
            requestAnimationFrame(() => animate(controls));
            const time = clock.getElapsedTime();
            controls.update();
            if (geometry && positionsOriginal.length > 0) {
                const pos = geometry.attributes.position.array;
                let targetSpread = 0.5;
                if (CONFIG.handDetected) {
                    targetSpread = 0.2 + (CONFIG.interactionStrength * 2.5);
                } else {
                    targetSpread = 1.0 + Math.sin(time) * 0.1;
                }
                for (let i = 0; i < CONFIG.particleCount; i++) {
                    const ix = i * 3; const iy = i * 3 + 1; const iz = i * 3 + 2;
                    const tx = positionsOriginal[ix]; const ty = positionsOriginal[iy]; const tz = positionsOriginal[iz];
                    const noise = Math.sin(time + tx) * 0.05;
                    pos[ix] += ((tx * targetSpread + noise) - pos[ix]) * 0.05;
                    pos[iy] += ((ty * targetSpread + noise) - pos[iy]) * 0.05;
                    pos[iz] += ((tz * targetSpread + noise) - pos[iz]) * 0.05;
                }
                geometry.attributes.position.needsUpdate = true;
            }
            renderer.render(scene, camera);
        }

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

        predictWebcam();
        init();
        window.toggleUI = function() {
            document.body.classList.toggle('ui-hidden');
            const btn = document.getElementById('toggle-ui-btn');
            btn.innerText = document.body.classList.contains('ui-hidden') ? "👁️ 显示界面" : "👁️ 沉浸模式";
        };
    </script>
</body>
</html>



上一篇:SpringBoot3 Bean作用域详解:单例与原型模式在IOC容器中的应用
下一篇:领域事件与集成事件详解:微服务架构设计中的核心解耦模式
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2025-12-17 20:35 , Processed in 0.144637 second(s), 40 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2025 云栈社区.

快速回复 返回顶部 返回列表