最近,一个融合了实时手势识别、3D渲染和物理动画的网页项目在技术圈和设计圈小火了一把。它让你能像魔法师一样,通过手势隔空操控一棵璀璨的圣诞树,还能将个人照片变成环绕的星辰。
这是一个运行在浏览器中的交互式3D艺术项目。你不需要任何外设,只需一个摄像头,就能用真实的手势控制屏幕中的虚拟圣诞树:
✊ 握紧拳头:所有3D元素(小球、礼物盒、水晶、你的照片)会迅速聚合成一棵华丽的圣诞树。
🖐️ 张开五指:“魔法”释放,所有元素如烟花般优雅散开,悬浮在三维空间中。
👋 手掌平移 (散开模式下):你的手变成了“方向盘”,可以控制视角环绕飞行,从各个角度欣赏散落的元素。
🤏 双指捏合:系统会随机抓取一张你上传的照片,将其放大并推到你的面前,带来惊喜的互动瞬间。
这个项目堪称现代前端炫技的“小全家桶”,核心依赖于以下技术:
MediaPipe Hands (Google):提供高精度、实时的21点手部关键点检测。这是所有手势交互的基石,直接在浏览器中运行,无需后端服务器。
Three.js:强大的WebGL 3D库。用来构建整个3D世界,包括发光材质、粒子系统、动态灯光(环境光、点光源)和逼真的镜头运动。
GSAP:专业的动画库。负责所有元素状态切换(聚合/散开/放大)的流畅动画,以及相机运镜,带来了极其顺滑的“德芙”般体验。
现代浏览器API:使用<input type=“file”>实现本地照片上传,并实时转化为3D纹理,融入场景。
此外,项目还应用了后期处理特效,为3D物体添加了梦幻的辉光效果,极大地提升了视觉质感。
主要优化包括:
交互逻辑调优:重写了手势判定算法,显著提升了“捏合”手势的识别灵敏度和准确性,解决了原版容易误触发的问题。为状态切换添加了智能冷却机制,防止操作冲突。
视觉增强:
调整了相机初始视角和运动曲线,让整体观感更大气、更具沉浸感。
优化了圣诞树的生成算法,使其形状更饱满、层次更丰富。
强化了辉光特效的强度与范围,让“星光”和“金边”效果更加突出。
代码结构与体验:
重构了状态管理,使“树形”、“散开”、“照片特写”三种模式切换更清晰、稳定。
为所有动态元素(包括后上传的照片)添加了精致的入场动画,用户体验更完整。
完善了UI状态提示,并采用了当前流行的玻璃拟态设计,科技感十足。
代码是纯前端的,运行非常简单:
将本文末尾的完整HTML代码保存为一个 .html文件。
用现代浏览器(如Chrome, Edge, Safari)打开这个文件。
首次运行时需要授权浏览器使用摄像头。(见文章“摄像头问题解决方案”)
尝试面对摄像头做出握拳、张开手掌、捏合等手势,享受魔法时刻!
点击界面上的“➕ 添加照片云”按钮,可以上传本地照片,它们会化作新的装饰物融入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引入。
这些库的版权分别归其原作者所有。您需要确保对它们的使用遵守其各自的许可证条款。