☰
手势控制3D粒子圣诞树:MediaPipe与Three.js从零到完整实现
2026/10/20 14:45:33 网站建设 项目流程

简介:这是一套基于手势控制的3D粒子圣诞树完整可运行源码,主要面向Web前端、图形学与交互设计方向的开发者,旨在演示如何将Three.js粒子渲染与MediaPipe手势识别有机结合。项目以哑光绿、金属金和圣诞红构成高级感视觉基调,支持合拢态、散开态与照片放大态自由切换,用户还能上传照片融入3D场景。压缩包内共3个文件,包括一个承载核心逻辑的HTML文件、一个inscode工程配置文件以及一个gitignore忽略文件,整体体积仅约10KB,适合快速下载与本地运行。截至目前,已有1975人学习该资源。通过运行此源码,可直接体验握拳、张掌、转掌、捏指等手势驱动粒子形态变化的完整流程,也可将其作为二次开发模板,替换配色、调整识别阈值或增加新的交互状态。对于希望掌握MediaPipe手势关键点提取以及Three.js相机与粒子系统协作的中高级学习者,这是一份轻量而完整的参考资料。

1. 手势控制3D粒子圣诞树:你电脑摄像头就够了,不用买设备

“手势控制3D粒子圣诞树”拆开看是三件事:实时手势识别、GPU粒子渲染、树形点云的生成算法。我在某公司年终活动的互动屏前做过一个类似demo:一台旧笔记本、一个普通USB摄像头、浏览器全屏跑着一棵由数千个粒子组成的圣诞树,人站在摄像头前一抬手,树就跟着手腕旋转,拇指和食指捏一下,树就放大缩小。这个方向最吸引人的一点是不需要专用深度摄像头,也不需要用户装客户端,只要浏览器能开WebGL就能交付。真正花时间的地方不在识别也不在渲染,而在把两条链路稳定地拼在一起:检测的延迟、映射的抖动、粒子数量与帧率的平衡,每一步都有坑。这篇就把这个标题背后的选型、实现和血泪经验一次说清楚,适合前端开发者、交互原型设计者,以及想在活动或展厅里快速做一个体感互动Demo的人。

2. 选型与最小运行方案:为什么是MediaPipe加Three.js

做这个项目,第一步不是写粒子,而是先把手势和渲染两条链路用什么技术定下来。两条链路各有一个主流选择:手势识别用媒体管道任务视觉(MediaPipe Tasks Vision),渲染用Three.js的Points点系统。这个组合的好处是两者都能在浏览器原生跑,不需要装原生依赖,且都有现成文档可查。先别急着堆功能,把最小骨架跑通,后面合入手势时才不会两头都黑屏。

2.1 手势方案:为什么我不选肤色检测和深度摄像头

如果只是做单个点位的互动,肤色检测是最朴素的做法:把视频帧从RGB转到HSV,筛出肤色范围,二值化,再找轮廓质心。听起来简单,实际要调的阈值非常多,不同人种、不同灯光、背景里穿红色衣服的人走过,都会让质心漂移。我用这个方法做过一个互动Demo,换个灯泡之后整个手势区域就偏了,属于典型的“玄学调阈值”,不适合交付。

深度摄像头方案在浏览器里拿深度流需要专门SDK,驱动和权限问题多,如果是临时活动场地,光是装驱动就能耗掉半天。我一般优先选MediaPipe任务视觉:模型通过WebAssembly加WebGL在浏览器里跑,输出21个手部关键点,还带左右手标记。它的稳定性和泛化能力远好于手写阈值分割,唯一代价是模型文件体积不小,首次加载要等一两秒,部署时放到本地静态目录即可。

2.2 渲染层:为什么用Three.js Points而不是Canvas 2D

几千个粒子用Canvas 2D逐帧清除再逐个画圆点,CPU绘制开销会随粒子数线性上涨,到一万个以上时帧率会明显掉。WebGL把顶点数据一次性传到显存,每帧旋转只需要更新矩阵,CPU几乎不参与绘图。Three.js的Points直接封装了BufferGeometry加顶点着色器的流程,比裸写WebGL省很多样板代码。

如果不需要光照、不需要深度测试,PointsMaterial就够用。它有几个关键参数:size决定粒子在画面上的尺寸,尺寸越大越容易看到“颗粒感”;sizeAttenuation表示粒子是否随距离缩放,透视场景里必须设为true,否则远处的粒子和近处的粒子一样大,3D感会消失。到后期要控制粒子扩散效果时,可以把材质换成自定义ShaderMaterial,模型和场景不用动。

2.3 最小可运行demo:先让2000个粒子转起来

这一步的目的很单纯:验证WebGL可用、渲染循环不阻塞、浏览器能正常加载ES模块。整个项目走Vite加原生ES模块,结构简单。先初始化项目:

mkdir gesture-tree && cd gesture-tree npm init -y npm install three npm install --save-dev vite

依赖装好后,把package.json的scripts改为vite启动:

{ "type": "module", "scripts": { "dev": "vite", "build": "vite build" }, "dependencies": { "three": "^0.160.0", "@mediapipe/tasks-vision": "^0.10.0" }, "devDependencies": { "vite": "^5.0.0" } }

版本号以你安装当日npm上的实际版本为准,上面写的是常见稳定线。然后用一段最简代码,把2000个随机粒子渲染出来并持续自转:

import * as THREE from 'three'; const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.1, 100); camera.position.set(0, 2.5, 8); const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(innerWidth, innerHeight); document.body.appendChild(renderer.domElement); // 先用 2000 个随机粒子验证渲染链路,第 3 章再替换成树形生成器 const count = 2000; const positions = new Float32Array(count * 3); for (let i = 0; i < count; i++) { const r = 1.5 * Math.random(); const theta = Math.random() * Math.PI * 2; positions[i * 3] = Math.cos(theta) * r; positions[i * 3 + 1] = Math.random() * 4; // y 轴范围先放开,后面收敛成树形 positions[i * 3 + 2] = Math.sin(theta) * r; } const geometry = new THREE.BufferGeometry(); geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); const material = new THREE.PointsMaterial({ color: 0x22cc66, size: 0.08, sizeAttenuation: true }); const tree = new THREE.Points(geometry, material); scene.add(tree); function animate() { tree.rotation.y += 0.005; // 匀速自转,验证动画循环没有阻塞 renderer.render(scene, camera); requestAnimationFrame(animate); } animate();

这段代码把随机点云放在场景中央,相机距离8,视野60度,保证初始画面能看到全貌。size: 0.08和sizeAttenuation: true是配合用的:粒子尺寸是相对世界坐标的,离远了会变小,这样后续树形生成后才能有立体纵深感。跑npm run dev,浏览器打开Vite打印的地址,看到一团旋转的绿色粒子云,说明渲染链路通了。

3. 粒子圣诞树的生成算法:从点云坐标到一棵能转的树

手头有了能转的粒子云,下一步就是把随机坐标替换成树形坐标。这一步是纯数学问题:在三维空间里,按什么规则撒点才能让粒子看起来像圣诞树。最常见的两个方案是表面聚集和螺旋生长,实际项目中我倾向螺旋骨架加表面扰动混合,兼顾树形轮廓和旋转时的层次感。

3.1 树的点云建模:表面聚集比实心填充更有树形

如果按圆锥体积均匀随机撒点,也就是r取0到R之间的均匀随机数,y取0到高度的均匀随机数,那么大量粒子会落在圆锥内部,远看是一团模糊的绿色“实心锥”,树形轮廓不清晰。这是因为粒子密度在体积里均匀分布,而人眼对轮廓的感知主要来自表面。

常见做法是让粒子贴近锥形表面:在半径方向上不取均匀分布,而是取一个接近表面区的随机数,比如让r落在R的75%到100%之间。这样粒子集中在圆锥侧面上,树的轮廓立刻清晰。另一种方案是螺旋生长:粒子从树顶到底部绕中心轴旋转下降,角度与高度线性绑定。只做螺旋会像一根弹簧,树形太规则,所以通常叠加随机扰动。

方案生成公式视觉特征适合的交互
实心填充r在0到R(1-t)均匀糊成实心锥静态展示
表面聚集r约等于R(1-t)加扰动轮廓清晰有体积感旋转交互
螺旋角度=圈数×高度有纵向流线感旋转时层次变化明显
螺旋+扰动螺旋角加随机偏移兼顾轮廓与层次本项目的最终选择

3.2 粒子属性与调参表

树形最终好不好看,参数比代码更重要。我先给出一组我用下来比较稳的取值,再列一张参数表方便你照抄。

参数常见取值作用调参方向
count3000到8000粒子总数集显建议3000,独显可到8000
turns6到12螺旋圈数圈数多则层次密,过多会糊
height4到6树高(世界坐标)决定相机距离和画面占比
baseRadius1.2到2.0底部半径和height保持锥形比例
扰动幅度0.25到0.4径向随机偏移太小像电线,太大失去树形

粒子数不是越多越好。点数超过一定量后,Points渲染的瓶颈在顶点缓冲上传和像素填充率,超过8000在核显上会明显掉帧。我一般先用5000作为基准,跑起来看FPS再降。

3.3 生成函数与材质调整:代码里的数学长什么样

下面这个函数是核心生成逻辑,直接返回一个带位置和颜色属性的BufferGeometry:

import * as THREE from 'three'; function createTreePoints(count, turns, height, baseRadius) { const positions = new Float32Array(count * 3); const colors = new Float32Array(count * 3); const colorBottom = new THREE.Color(0x0a5c2a); // 底部深绿 const colorMiddle = new THREE.Color(0x2e9e4f); // 中部亮绿 const colorTop = new THREE.Color(0xffd35c); // 顶部金色 for (let i = 0; i < count; i++) { // t 映射粒子在树上的纵向位置:0 为底部,1 为顶部 const t = Math.pow(Math.random(), 0.65); const y = (1 - t) * height; // 半径随高度线性收窄,顶部接近0 const radiusAtY = baseRadius * (1 - t); // 螺旋角度,叠加随机扰动,角度微偏差不会破坏树形只会增加层次 const angle = t * Math.PI * 2 * turns + Math.random() * 0.4; // 径向扰动:让粒子不完全贴在锥体表面 const r = radiusAtY * (0.75 + 0.25 * Math.random()); positions[i * 3] = Math.cos(angle) * r; positions[i * 3 + 1] = y - height * 0.5; // 把树心移到原点,方便旋转 positions[i * 3 + 2] = Math.sin(angle) * r; // 颜色沿高度插值:底部深绿到中部亮绿,再到顶部金色 const c = y < height * 0.5 ? colorBottom.clone().lerp(colorMiddle, y / (height * 0.5)) : colorMiddle.clone().lerp(colorTop, (y - height * 0.5) / (height * 0.5)); colors[i * 3] = c.r; colors[i * 3 + 1] = c.g; colors[i * 3 + 2] = c.b; } const geometry = new THREE.BufferGeometry(); geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)); return geometry; }

位置生成的关键在t = Math.pow(Math.random(), 0.65)这一行:它让粒子在底部分布得更多,顶部更稀疏,接近真实圣诞树下层宽大、上层尖细的比例。角度计算里Math.random() * 0.4是扰动项,不加它粒子会整齐排成一条螺旋线,一眼就看出是人工生成的“弹簧”。颜色插值用lerp在两个颜色样本之间过渡,配合材质开启vertexColors,就能让每个粒子拥有独立颜色。使用这段材质时要设vertexColors: true:

const material = new THREE.PointsMaterial({ size: 0.08, sizeAttenuation: true, vertexColors: true });

另外注意Three.js新版本的颜色空间默认是sRGB,如果发现颜色比预期暗,检查渲染器是否设置renderer.outputColorSpace = THREE.SRGBColorSpace,这属于渲染器颜色管理差异,不是生成代码的锅。

4. 手势识别与控制映射:21个手部关键点里我们到底用哪几个

树形生成完毕,树会在那里原地转,现在把手势接进来。MediaPipe任务视觉输出的手部数据是21个带x、y、z坐标的关键点,坐标都归一化到0到1之间,x和y对应画面宽高比例,z是相对腕关节的深度。我们不用全部21个点,只挑其中四个来算控制量,控制维度一旦多起来,调试成本会指数上升。

4.1 只用四个关键点:腕关节、拇指尖、食指尖、中指尖

四个点的索引分别是:0腕关节、4拇指尖、8食指尖、12中指尖。为什么旋转不用指尖而用腕关节?指尖在画面里抖动幅度最大,哪怕手静止不动,指尖坐标也会因为模型预测噪声上下跳动几个像素,直接映射到旋转角会让树不停颤抖。腕关节是手部最稳定的参考点,用它计算横向偏移做旋转,手感明显稳一个档次。

缩放用拇指尖和食指尖的欧氏距离,这是最直觉的“捏合”手势。张开程度用中指尖到腕关节的距离,手掌张得越开,中指尖离腕关节越远,适合映射粒子扰动强度。剩下17个点不用处理,检测模型会自己负责算出来,我们只读需要的四个。

4.2 手部信息到树参数的映射公式

映射这一步决定交互手感。最直接的错误是把坐标差值直接当成角度或缩放值,结果就是手轻轻一动树就飞出去。我习惯的做法是三层处理:死区过滤、范围限制、阻尼逼近。死区过滤掉手势坐标在0.5附近的微小抖动,范围限制保证输出不超过预设最大旋转角和缩放比,阻尼逼近让树的实际参数逐渐接近目标参数而不是瞬间跳变。

控制量使用的关键点核心公式防抖措施
Y轴旋转腕关节x手指数偏移乘以旋转范围死区+阻尼
整体缩放拇指尖和食指尖距离距离乘以灵敏度限制最小最大缩放
粒子扰动中指尖到腕关节距离距离映射到0到1限制范围+阻尼

阻尼逼近的公式是current += (target - current) * smoothFactor,smoothFactor越小越稳但越迟钝,一般取0.05到0.1之间。死区一般取0.02到0.05,太小没作用,太大会觉得手要移很大距离树才动。

4.3 控制链路完整代码与参数说明

初始化检测器的代码放在入口模块,模型文件和wasm目录指向public下的本地静态资源:

import { FilesetResolver, HandLandmarker } from '@mediapipe/tasks-vision'; const vision = await FilesetResolver.forVisionTasks('wasm'); const handLandmarker = await HandLandmarker.createFromOptions(vision, { baseOptions: { modelAssetPath: 'models/hand_landmarker.task', delegate: 'GPU' // 老旧设备可改 'CPU',但延迟会升高 }, runningMode: 'VIDEO', // 视频流模式,必须配 detectForVideo numHands: 2 // 先开1,第6章做左右手分工再开2 }); const video = document.createElement('video'); video.width = 640; // 检测分辨率不是越高越好,640 足够手势识别 video.height = 480;

检测循环与渲染循环要分离。检测回调只把结果写入缓存,渲染循环读缓存去做阻尼逼近:

let latestHand = null; let lastVideoTime = -1; // 检测循环:只在视频时间戳变化时跑一次 function detectLoop() { if (video.readyState >= 2 && video.currentTime !== lastVideoTime) { lastVideoTime = video.currentTime; const result = handLandmarker.detectForVideo(video, performance.now()); if (result.landmarks && result.landmarks.length > 0) { latestHand = result.landmarks[0]; // 只负责写缓存 } } requestAnimationFrame(detectLoop); }

控制映射放渲染循环里,直接读取latestHand计算目标值:

const params = { deadzone: 0.03, rotationRange: Math.PI, smoothFactor: 0.08, scaleSensitivity: 6, minScale: 0.6, maxScale: 1.6 }; let target = { rotationY: 0, scale: 1 }; function updateTreeFromHand() { if (!latestHand) return; const wrist = latestHand[0]; const thumbTip = latestHand[4]; const indexTip = latestHand[8]; // 腕关节横向偏移 -> 旋转目标角度,越过死区才响应 const dx = wrist.x - 0.5; if (Math.abs(dx) > params.deadzone) { const sign = dx > 0 ? dx - params.deadzone : dx + params.deadzone; target.rotationY = sign * params.rotationRange; } // 拇指-食指距离 -> 缩放目标值,并限制在安全范围 const pinch = Math.hypot(thumbTip.x - indexTip.x, thumbTip.y - indexTip.y); target.scale = Math.min(params.maxScale, Math.max(params.minScale, pinch * params.scaleSensitivity)); // 阻尼逼近:树不会跳变,而是平滑跟随手部目标 tree.rotation.y += (target.rotationY - tree.rotation.y) * params.smoothFactor; tree.scale.setScalar(tree.scale.x + (target.scale - tree.scale.x) * params.smoothFactor); }

提示:检测循环和渲染循环是两个独立的requestAnimationFrame链,不要互相await阻塞,否则手一动整棵树就掉帧。

代码里sign * params.rotationRange中的sign是经过死区修正后的偏移量,保证手到死区边界时旋转目标从0开始渐变,不会跳变。smoothFactor: 0.08是个折中值:0.05时树沉稳但略迟钝,0.15时跟手但容易过冲。捏合手势的灵敏度scaleSensitivity要结合摄像头距离调整,镜头离手越远,两张指尖的像素距离变化越小,灵敏度要适当调大。

5. 避坑指南:手势控制3D粒子圣诞树最常见的5个翻车现场

这一章是整篇文章里最值得保存的部分。我按现象到原因再到解决方案的顺序,把项目中最容易卡住人的五个问题列出来,每个问题都花了不止一个下午排查。

5.1 黑屏:双击index.html打开,只有黑背景

现象:代码写完,没有跑开发服务器,双击index.html直接打开,页面黑屏,控制台报错要么是跨域,要么是wasm加载失败。原因:ES模块和MediaPipe的wasm文件都被浏览器的安全策略拦住了,file协议下无法加载本地模块,模型文件也读取失败。解决:不要用file协议打开,起一个静态服务:

npx vite --host

这也是为什么前面强调用Vite脚手架。如果你在某个部署目录里直接丢html文件给别人看,需要先build生成静态资源,再放到nginx或任意静态服务器里。

5.2 手腕微微一抖,树就疯狂旋转

现象:手放在画面中间,树不动;手稍微往右移一点,树猛地转大半圈,转得人发晕。原因:坐标差异被直接放大成旋转角,没有死区过滤,也没有限制最大角速度。MediaPipe输出的手腕坐标有细微噪声,这些噪声经过大范围乘法之后被放大成肉眼可感知的跳动。解决:按第4.2节的公式加死区,旋转范围限到π以内,再加smoothFactor阻尼。如果还觉得太灵敏,问题多半出在旋转范围太大,把rotationRange从Math.PI降到Math.PI * 0.8试试。

5.3 帧率掉到个位数,风扇狂转

现象:树生成之后本来有50帧,手一举起来变成个位数。原因有三个:视频检测分辨率开到了1280以上,MediaPipe每帧都跑检测,粒子数堆到上万。这三件事全占时,GPU和CPU都在打满。解决:把视频宽度限制到640,检测频率降半,粒子数回调到4000左右:

// 每两帧检测一次,帧率压力直接减半 let frameCounter = 0; function detectLoop() { if (frameCounter++ % 2 === 0) { // 执行 detectForVideo } requestAnimationFrame(detectLoop); }

注意:MediaPipe模型文件不小,如果放在公网CDN加载,网络差时会出现长时间黑屏。建议把模型和wasm目录放到项目静态资源目录,走本地加载。

5.4 手势响应慢半拍,像在拖影

现象:手已经停稳了,树还在慢慢晃;手动了半秒,树才跟上。原因:smoothFactor设得太小,或者渲染循环里直接await了检测结果,导致动画帧被阻塞。检测流程耗时几十毫秒,如果在渲染循环里同步等它,帧率直接掉一半。解决:smoothFactor保持0.08以上,检测结果写入缓存,渲染循环只读缓存。另外一个隐蔽点:MediaPipe的detectForVideo需要传入当前时间戳,如果传入的时间戳不变,模型不会重新计算,所以检测循环里必须保证每帧传入新的performance.now()。

5.5 背景里有其他人走过,树立刻乱动

现象:镜头前只有一个人的手,树控制正常;有人从背景路过,树突然自己转起来。原因:MediaPipe会把画面里所有的手都识别出来,landmarks数组里同时存在多只手的数据,代码直接取第一只,也就可能取到路过者的手。解决:摄像头正对操作者,限制只响应画面中最靠近中心的一只手:

const cx = 0.5, cy = 0.5; let bestHand = null, bestDist = Infinity; for (const lm of result.landmarks) { const dist = Math.hypot(lm[0].x - cx, lm[0].y - cy); if (dist < bestDist) { bestDist = dist; bestHand = lm; } } latestHand = bestHand;

这样即使背景里出现了人手,只要它离画面中心更远,就不会抢占控制权。这个策略不完美但实用,配合提示操作者站在画面中央,能解决绝大多数误触场景。

6. 进阶技巧:左右手分工与性能验证

6.1 左右手分工:MediaPipe的handedness别搞反

如果你想做成一只手控制旋转、另一只手控制缩放,需要把numHands改成2,并读取handedness。这里有个经典坑:MediaPipe返回的“Left”和“Right”是被拍摄者视角,不是画面里看到的左右。也就是说,操作者自己的左手,在返回结果里是“Left”,在画面里看却是右侧。如果你按画面像素坐标判断,会把两只手的角色完全弄反。我后来固定用handedness字段区分角色,不再依赖坐标推断。实现上注意先判断两只手都存在,再分别取出两只手的控制量,任何一只手丢失时保持当前参数不变,避免树突然跳到默认值。

6.2 验证习惯:把关键点和FPS画在页面上

调试这种项目,不要靠“感觉树转得快不快”。我习惯在页面上叠加一层canvas,把21个关键点和它们之间的连线画在视频上,同时用three.js的渲染循环计算FPS,一屏显示出来。这样一眼能看出延迟到底出在检测还是渲染:关键点跟手但树不跟,说明映射或阻尼有问题;关键点也滞后,说明检测速度或摄像头帧率有问题。定位快很多。

我这个项目的教训是:所有手感相关的参数,包括死区、平滑系数、旋转范围、缩放灵敏度,全部集中到一个config对象里,不要散落在各段代码中。第一次做时把参数分散写得“看起来漂亮”,结果换一台电脑全要重调,找参数找了一个下午。后来每跑一台机器,只调三个东西:粒子数、检测分辨率、平滑系数,其他参数维持默认就能交付。这个习惯救了我很多次,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询