浮生札记

用AI+手势控制一棵3D圣诞树(附完整源码)

2026/03/02
82
0

最近,一个融合了实时手势识别3D渲染物理动画的网页项目在技术圈和设计圈小火了一把。它让你能像魔法师一样,通过手势隔空操控一棵璀璨的圣诞树,还能将个人照片变成环绕的星辰。

✨ 项目是什么?

这是一个运行在浏览器中的交互式3D艺术项目。你不需要任何外设,只需一个摄像头,就能用真实的手势控制屏幕中的虚拟圣诞树:

  • ✊ 握紧拳头:所有3D元素(小球、礼物盒、水晶、你的照片)会迅速聚合成一棵华丽的圣诞树。

  • 🖐️ 张开五指:“魔法”释放,所有元素如烟花般优雅散开,悬浮在三维空间中。

  • 👋 手掌平移​ (散开模式下):你的手变成了“方向盘”,可以控制视角环绕飞行,从各个角度欣赏散落的元素。

  • 🤏 双指捏合:系统会随机抓取一张你上传的照片,将其放大并推到你的面前,带来惊喜的互动瞬间。

🚀 技术栈与亮点

这个项目堪称现代前端炫技的“小全家桶”,核心依赖于以下技术:

  1. MediaPipe Hands (Google):提供高精度、实时的21点手部关键点检测。这是所有手势交互的基石,直接在浏览器中运行,无需后端服务器。

  2. Three.js:强大的WebGL 3D库。用来构建整个3D世界,包括发光材质、粒子系统、动态灯光(环境光、点光源)和逼真的镜头运动。

  3. GSAP:专业的动画库。负责所有元素状态切换(聚合/散开/放大)的流畅动画,以及相机运镜,带来了极其顺滑的“德芙”般体验。

  4. 现代浏览器API:使用<input type=“file”>实现本地照片上传,并实时转化为3D纹理,融入场景。

此外,项目还应用了后期处理特效,为3D物体添加了梦幻的辉光效果,极大地提升了视觉质感。

🤖优化部分

主要优化包括:

  • 交互逻辑调优:重写了手势判定算法,显著提升了“捏合”手势的识别灵敏度和准确性,解决了原版容易误触发的问题。为状态切换添加了智能冷却机制,防止操作冲突。

  • 视觉增强

    • 调整了相机初始视角和运动曲线,让整体观感更大气、更具沉浸感。

    • 优化了圣诞树的生成算法,使其形状更饱满、层次更丰富。

    • 强化了辉光特效的强度与范围,让“星光”和“金边”效果更加突出。

  • 代码结构与体验

    • 重构了状态管理,使“树形”、“散开”、“照片特写”三种模式切换更清晰、稳定。

    • 为所有动态元素(包括后上传的照片)添加了精致的入场动画,用户体验更完整。

    • 完善了UI状态提示,并采用了当前流行的玻璃拟态设计,科技感十足。

🛠️ 如何运行?

代码是纯前端的,运行非常简单:

  1. 将本文末尾的完整HTML代码保存为一个 .html文件。

  2. 用现代浏览器(如Chrome, Edge, Safari)打开这个文件。

  3. 首次运行时需要授权浏览器使用摄像头。(见文章“摄像头问题解决方案”)

  4. 尝试面对摄像头做出握拳、张开手掌、捏合等手势,享受魔法时刻!

  5. 点击界面上的“➕ 添加照片云”按钮,可以上传本地照片,它们会化作新的装饰物融入3D世界。

📁 项目源码

以下就是完整的HTML代码,集合了所有依赖与逻辑。你可以直接复制使用,也欢迎在此基础上进行二次创作。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>AI 手势控制 3D 圣诞树 - 升级版</title>
    <style>
        body {
            margin: 0;
            overflow: hidden;
            background-color: #030504; /* 更深邃的墨绿背景 */
            font-family: 'Helvetica Neue', Arial, sans-serif;
            color: #fceea7; 
        }
        #canvas-container {
            width: 100vw;
            height: 100vh;
            position: absolute;
            top: 0;
            left: 0;
            z-index: 1;
        }
        /* 顶部状态栏 */
        .top-bar {
            position: absolute;
            top: 20px;
            left: 20px;
            right: 20px;
            z-index: 10;
            display: flex;
            justify-content: space-between;
            pointer-events: none;
        }
        .status-panel {
            background: rgba(10, 30, 15, 0.6);
            backdrop-filter: blur(8px);
            padding: 10px 20px;
            border-radius: 12px;
            border: 1px solid rgba(252, 238, 167, 0.3);
            box-shadow: 0 4px 15px rgba(0,0,0,0.5);
        }
        .status-badge {
            font-size: 14px;
            margin: 5px 0;
            letter-spacing: 1px;
            color: #fff;
        }
        .highlight { color: #ff3366; font-weight: bold; text-shadow: 0 0 5px #ff3366; }
        
        .upload-btn {
            pointer-events: auto;
            background: linear-gradient(135deg, #d4af37, #aa8800);
            color: #000;
            border: none;
            padding: 12px 25px;
            border-radius: 25px;
            cursor: pointer;
            font-weight: bold;
            font-size: 15px;
            box-shadow: 0 0 20px rgba(212, 175, 55, 0.4);
            transition: all 0.3s ease;
        }
        .upload-btn:hover { transform: translateY(-2px); box-shadow: 0 0 30px rgba(212, 175, 55, 0.7); }
        #file-input { display: none; }

        /* 中央操作指南 (玻璃拟态) */
        .guide-modal {
            position: absolute;
            top: 50%;
            left: 20px;
            transform: translateY(-50%);
            z-index: 10;
            background: rgba(20, 25, 20, 0.7);
            backdrop-filter: blur(12px);
            -webkit-backdrop-filter: blur(12px);
            border: 1px solid rgba(212, 175, 55, 0.2);
            padding: 25px;
            border-radius: 20px;
            pointer-events: none;
            box-shadow: 0 10px 30px rgba(0,0,0,0.8);
            width: 280px;
        }
        .guide-modal h2 {
            margin: 0 0 15px 0;
            font-size: 20px;
            text-align: center;
            color: #d4af37;
            text-shadow: 0 0 10px rgba(212, 175, 55, 0.5);
        }
        .guide-item {
            display: flex;
            align-items: center;
            margin-bottom: 15px;
            background: rgba(0,0,0,0.3);
            padding: 10px;
            border-radius: 10px;
        }
        .guide-emoji { font-size: 24px; margin-right: 15px; width: 30px; text-align: center; }
        .guide-text { font-size: 14px; line-height: 1.4; color: #ddd; }
        .guide-text strong { color: #fff; }

        /* 摄像头反馈 */
        .video-container {
            position: absolute;
            bottom: 20px;
            right: 20px;
            width: 150px;
            height: 110px;
            border-radius: 12px;
            overflow: hidden;
            border: 2px solid rgba(212, 175, 55, 0.5);
            box-shadow: 0 0 20px rgba(0,0,0,0.8);
            transform: scaleX(-1);
            z-index: 10;
        }
        .video-container video { width: 100%; height: 100%; object-fit: cover; }
    </style>

    <script type="importmap">
        {
            "imports": {
                "three": "https://unpkg.com/three@0.158.0/build/three.module.js",
                "three/addons/": "https://unpkg.com/three@0.158.0/examples/jsm/"
            }
        }
    </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>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
</head>
<body>

    <div id="canvas-container"></div>

    <div class="top-bar">
        <div class="status-panel">
            <div class="status-badge">🌲 形态: <span id="state-display" style="color:#d4af37; font-weight:bold;">合拢 (Tree)</span></div>
            <div class="status-badge">👁️ 识别: <span id="gesture-display" class="highlight">等待摄像头...</span></div>
        </div>
        <div>
            <input type="file" id="file-input" multiple accept="image/*">
            <button class="upload-btn" onclick="document.getElementById('file-input').click()">➕ 添加照片云</button>
        </div>
    </div>

    <div class="guide-modal">
        <h2>✨ 魔法控制指南</h2>
        <div class="guide-item">
            <div class="guide-emoji">✊</div>
            <div class="guide-text"><strong>握紧拳头</strong><br>将所有元素聚合成圣诞树</div>
        </div>
        <div class="guide-item">
            <div class="guide-emoji">🖐️</div>
            <div class="guide-text"><strong>张开五指</strong><br>释放魔法,让元素星辰般散开</div>
        </div>
        <div class="guide-item">
            <div class="guide-emoji">👋</div>
            <div class="guide-text"><strong>手掌平移</strong><br>在散开态时,平移手掌环绕观看</div>
        </div>
        <div class="guide-item">
            <div class="guide-emoji">🤏</div>
            <div class="guide-text"><strong>双指捏合</strong><br>随机抓取并放大一张你的照片</div>
        </div>
    </div>

    <div class="video-container">
        <video id="input_video" autoplay playsinline></video>
    </div>

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

        let scene, camera, renderer, composer;
        const objects = []; 
        let photos = []; 
        
        const STATES = { CONE: 'CONE', SCATTER: 'SCATTER', ZOOM: 'ZOOM' };
        let currentState = STATES.CONE;
        let cameraTarget = new THREE.Vector3(0, 12, 35); // 稍微抬高视角

        function initThreeJS() {
            const container = document.getElementById('canvas-container');
            
            scene = new THREE.Scene();
            scene.fog = new THREE.FogExp2(0x030504, 0.012);
            camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000);
            camera.position.set(0, 5, 50); // 初始相机远一点,配合进场动画

            renderer = new THREE.WebGLRenderer({ antialias: false, alpha: true });
            renderer.setSize(window.innerWidth, window.innerHeight);
            renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
            renderer.toneMapping = THREE.ACESFilmicToneMapping; // 更真实的色彩映射
            renderer.toneMappingExposure = 1.2;
            container.appendChild(renderer.domElement);

            // 优化灯光,提升亮度
            scene.add(new THREE.AmbientLight(0xffffff, 0.4));
            
            const coreLight = new THREE.PointLight(0xffffff, 2, 40);
            coreLight.position.set(0, 10, 0); // 树中心的光源
            scene.add(coreLight);
            
            const goldLight = new THREE.PointLight(0xffd700, 2.5, 60);
            goldLight.position.set(10, 15, 10);
            scene.add(goldLight);

            // 辉光后处理 (增强阈值和亮度)
            const renderScene = new RenderPass(scene, camera);
            const bloomPass = new UnrealBloomPass(
                new THREE.Vector2(window.innerWidth, window.innerHeight),
                2.0,  // 辉光强度 (提升)
                0.5,  // 半径
                0.6   // 阈值 (降低阈值让更多物体发光)
            );
            composer = new EffectComposer(renderer);
            composer.addPass(renderScene);
            composer.addPass(bloomPass);

            // 升级版发光材质
            const materials = {
                matteGreen: new THREE.MeshStandardMaterial({ color: 0x0a4411, roughness: 0.8, emissive: 0x022205, emissiveIntensity: 0.5 }),
                gold: new THREE.MeshStandardMaterial({ color: 0xffd700, roughness: 0.1, metalness: 1.0, emissive: 0xaa6600, emissiveIntensity: 0.8 }),
                red: new THREE.MeshStandardMaterial({ color: 0xff0022, roughness: 0.2, metalness: 0.3, emissive: 0x880011, emissiveIntensity: 0.8 }),
                snow: new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.5, emissive: 0x333333 })
            };

            const geometries = [
                new THREE.SphereGeometry(0.35, 16, 16), // 球
                new THREE.BoxGeometry(0.5, 0.5, 0.5),   // 礼物盒
                new THREE.OctahedronGeometry(0.4, 0)    // 钻石/水晶形状
            ];

            const totalItems = 600; // 增加密度
            const treeHeight = 22;
            const baseRadius = 9;

            for(let i = 0; i < totalItems; i++) {
                const geo = geometries[Math.floor(Math.random() * geometries.length)];
                const matKeys = Object.keys(materials);
                // 让绿色占据主导地位,构成树的主体
                const matKey = Math.random() > 0.4 ? 'matteGreen' : matKeys[Math.floor(Math.random() * matKeys.length)];
                const mesh = new THREE.Mesh(geo, materials[matKey]);
                
                // 更饱满的树形算法 (指数衰减)
                const y = Math.random() * treeHeight; 
                const radius = baseRadius * Math.pow((treeHeight - y) / treeHeight, 1.2); 
                const angle = Math.random() * Math.PI * 2;
                
                const posCone = new THREE.Vector3(
                    Math.cos(angle) * radius,
                    y,
                    Math.sin(angle) * radius
                );

                const posScatter = new THREE.Vector3(
                    (Math.random() - 0.5) * 50,
                    (Math.random() - 0.5) * 40 + 10,
                    (Math.random() - 0.5) * 50
                );

                const item = { mesh, posCone, posScatter, isPhoto: false };
                objects.push(item);
                scene.add(mesh);
                
                // 进场动画:从底部中心开始,螺旋升空
                mesh.position.set(0, -5, 0);
                mesh.scale.set(0, 0, 0);

                gsap.to(mesh.scale, {
                    x: 1, y: 1, z: 1,
                    duration: 1.5 + Math.random() * 1.5,
                    ease: "elastic.out(1, 0.5)",
                    delay: Math.random() * 1.5 // 错落有致的出现
                });

                gsap.to(mesh.position, {
                    x: posCone.x, y: posCone.y, z: posCone.z,
                    duration: 2 + Math.random() * 2,
                    ease: "power3.out",
                    delay: Math.random() * 1.5
                });
            }

            // 添加树顶之星
            const starGeo = new THREE.IcosahedronGeometry(1.2, 0);
            const starMat = new THREE.MeshStandardMaterial({ 
                color: 0xffffff, emissive: 0xffd700, emissiveIntensity: 2.0 
            });
            const topStar = new THREE.Mesh(starGeo, starMat);
            topStar.position.set(0, treeHeight + 1, 0);
            scene.add(topStar);
            
            // 星星的自转
            gsap.to(topStar.rotation, { y: Math.PI * 2, duration: 10, repeat: -1, ease: "linear" });
            objects.push({ 
                mesh: topStar, 
                posCone: new THREE.Vector3(0, treeHeight + 1, 0), 
                posScatter: new THREE.Vector3(0, 30, 0), // 散开时星星飞到最高处
                isPhoto: false 
            });

            // 进场时相机的推进动画
            gsap.to(camera.position, {
                z: 35, y: 12,
                duration: 4,
                ease: "power2.inOut"
            });

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

            animate();
        }

        function animate() {
            requestAnimationFrame(animate);
            
            camera.position.lerp(cameraTarget, 0.04);
            camera.lookAt(0, 10, 0);

            const time = Date.now() * 0.001;
            objects.forEach((item, index) => {
                if(!item.isZoomed && currentState !== STATES.CONE) {
                    item.mesh.position.y += Math.sin(time + index * 0.1) * 0.015;
                    item.mesh.rotation.x += 0.005;
                    item.mesh.rotation.y += 0.01;
                }
            });

            composer.render();
        }

        // ================= 照片处理 =================
        document.getElementById('file-input').addEventListener('change', function(e) {
            const files = Array.from(e.target.files);
            if(files.length === 0) return;

            files.forEach(file => {
                const reader = new FileReader();
                reader.onload = (event) => {
                    const texture = new THREE.TextureLoader().load(event.target.result);
                    // 给照片加上发光边框效果
                    const geometry = new THREE.PlaneGeometry(3.5, 3.5);
                    const material = new THREE.MeshStandardMaterial({ 
                        map: texture, side: THREE.DoubleSide,
                        emissive: 0x222222, emissiveIntensity: 0.5
                    });
                    
                    const mesh = new THREE.Mesh(geometry, material);
                    
                    const treeHeight = 22;
                    const y = Math.random() * (treeHeight - 5) + 2;
                    const radius = 9 * Math.pow((treeHeight - y) / treeHeight, 1.2) + 1.5; 
                    const angle = Math.random() * Math.PI * 2;
                    
                    const posCone = new THREE.Vector3(Math.cos(angle)*radius, y, Math.sin(angle)*radius);
                    const posScatter = new THREE.Vector3((Math.random() - 0.5) * 40, (Math.random() - 0.5) * 30 + 10, (Math.random() - 0.5) * 40);

                    const item = { mesh, posCone, posScatter, isPhoto: true, isZoomed: false };
                    objects.push(item);
                    photos.push(item);
                    scene.add(mesh);

                    // 出现动画
                    mesh.scale.set(0,0,0);
                    mesh.position.copy(currentState === STATES.CONE ? posCone : posScatter);
                    gsap.to(mesh.scale, { x: 1, y: 1, z: 1, duration: 1, ease: "back.out(2)" });
                };
                reader.readAsDataURL(file);
            });
        });

        // ================= 状态切换 =================
        const stateDisplay = document.getElementById('state-display');

        function toConeState() {
            if(currentState === STATES.CONE) return;
            currentState = STATES.CONE;
            stateDisplay.innerText = "合拢 (Tree)";
            cameraTarget.set(0, 12, 35); 

            objects.forEach((item, index) => {
                item.isZoomed = false;
                gsap.to(item.mesh.position, {
                    x: item.posCone.x, y: item.posCone.y, z: item.posCone.z,
                    duration: 1.5 + Math.random() * 0.5, ease: "back.out(1.2)"
                });
                gsap.to(item.mesh.rotation, { x: 0, y: 0, z: 0, duration: 1.5 });
            });
        }

        function toScatterState() {
            if(currentState === STATES.SCATTER) return;
            currentState = STATES.SCATTER;
            stateDisplay.innerText = "散开 (Scatter)";
            cameraTarget.set(0, 12, 45); // 散开时镜头稍远
            
            objects.forEach((item, index) => {
                item.isZoomed = false;
                gsap.to(item.mesh.position, {
                    x: item.posScatter.x, y: item.posScatter.y, z: item.posScatter.z,
                    duration: 2 + Math.random() * 1, ease: "power3.out"
                });
            });
        }

        function toZoomState() {
            if(currentState === STATES.ZOOM || photos.length === 0) return;
            if(currentState === STATES.CONE) toScatterState(); // 保证背景是散开的
            
            currentState = STATES.ZOOM;
            stateDisplay.innerText = "放大照片 (Zoom)";

            const photoItem = photos[Math.floor(Math.random() * photos.length)];
            photoItem.isZoomed = true;
            
            const offset = new THREE.Vector3(0, 0, -6);
            offset.applyQuaternion(camera.quaternion);
            const targetPos = camera.position.clone().add(offset);

            gsap.to(photoItem.mesh.position, {
                x: targetPos.x, y: targetPos.y, z: targetPos.z,
                duration: 1.2, ease: "power2.out"
            });
            gsap.to(photoItem.mesh.rotation, {
                x: camera.rotation.x, y: camera.rotation.y, z: camera.rotation.z,
                duration: 1.2
            });
        }

        // ================= MediaPipe =================
        const videoElement = document.getElementById('input_video');
        const gestureDisplay = document.getElementById('gesture-display');
        let gestureCooldown = false;

        function onResults(results) {
            if (!results.multiHandLandmarks || results.multiHandLandmarks.length === 0) {
                gestureDisplay.innerText = '未检测到手';
                gestureDisplay.style.color = '#888';
                return;
            }

            const landmarks = results.multiHandLandmarks[0];
            const getDist = (p1, p2) => Math.sqrt(Math.pow(p1.x-p2.x, 2) + Math.pow(p1.y-p2.y, 2));

            const distThumbIndex = getDist(landmarks[4], landmarks[8]);
            
            let openFingers = 0;
            [8, 12, 16, 20].forEach((tip, idx) => {
                const mcp = [5, 9, 13, 17][idx];
                if(getDist(landmarks[tip], landmarks[0]) > getDist(landmarks[mcp], landmarks[0]) * 1.2) {
                    openFingers++;
                }
            });

            // 核心修改区:优化捏合与握拳的检测判定逻辑
            const isPinch = distThumbIndex < 0.09; // 优化1:放宽捏合判定的距离阈值,提升捏合灵敏度
            const isOpen = openFingers >= 3;
            const isFist = openFingers === 0 && !isPinch; // 握拳严格排除捏合状态

            if (!gestureCooldown) {
                // 优化2:优先判断捏合动作,避免在手指弯曲的过程中被误判为握拳
                if (isPinch && currentState !== STATES.ZOOM && currentState === STATES.SCATTER) {
                    gestureDisplay.innerText = '🤏 捏合';
                    gestureDisplay.style.color = '#d4af37';
                    toZoomState();
                    triggerCooldown();
                }
                else if (isFist && currentState !== STATES.CONE) {
                    gestureDisplay.innerText = '✊ 握拳';
                    gestureDisplay.style.color = '#ff3366';
                    toConeState();
                    triggerCooldown();
                } 
                else if (isOpen && currentState !== STATES.SCATTER) {
                    gestureDisplay.innerText = '🖐️ 张开';
                    gestureDisplay.style.color = '#00ffcc';
                    toScatterState();
                    triggerCooldown();
                }
            }

            if (currentState === STATES.SCATTER || currentState === STATES.ZOOM) {
                if (isOpen && !isFist && !isPinch) {
                    gestureDisplay.innerText = '👋 平移视角';
                    gestureDisplay.style.color = '#00ffcc';
                    const palm = landmarks[9];
                    const orbitRadius = 45;
                    const angle = (palm.x - 0.5) * Math.PI; 
                    cameraTarget.set(
                        Math.sin(angle) * orbitRadius,
                        (1 - palm.y) * 25, 
                        Math.cos(angle) * orbitRadius
                    );
                }
            }
        }

        function triggerCooldown() {
            gestureCooldown = true;
            setTimeout(() => { gestureCooldown = false; }, 1500);
        }

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

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

        initThreeJS();
        cameraFeed.start().then(() => {
            gestureDisplay.innerText = '准备就绪';
        });
        
    </script>
</body>
</html>

⚠️ 版权与使用说明

重要声明

1. 关于转载与使用

  • 本文内容可以自由转载,但转载时需注明本站链接。

  • 您可以自由地运行、学习、修改此代码,或用于非商业性质的个人项目与演示中。

2. 关于项目中引用的第三方库

本项目使用了以下开源库,它们遵循各自的许可证:

  • MediaPipe Hands (@mediapipe/hands)​ & Camera Utils (@mediapipe/camera_utils): 来自Google,代码中可见其Apache-2.0许可证声明。通过jsDelivr CDN引入。

  • Three.js (three): 一个MIT许可证的3D库。通过unpkg CDN引入。

  • GSAP (gsap): 一个用于高性能动画的库,拥有自己的许可证。通过Cloudflare CDN引入。

这些库的版权分别归其原作者所有。您需要确保对它们的使用遵守其各自的许可证条款