Three.js摄像头粒子交互十种创意:从像素信号到空间映射 摄像头交互粒子做多了之后很容易陷入一种“视觉疲劳”视频打开像素颜色取出来粒子跟着变颜色看上去像是一张会变色的贴纸贴在屏幕上人稍微移动一下粒子根本不理会。第一版我主要在处理这种全局颜色驱动玩了两三天就发现一个问题——粒子和画面里的人物、物体之间缺少空间关系。这次重新整理 Ten 条 Three.js 摄像头交互粒子效果创意我最大的调整是不再盯着“平均值颜色”而是把画面当成一组带位置、带亮度、带运动信息的二维信号来读让粒子真的跟着画面里的东西动。适合已经跑通过基础 particle 项目、想往摄像头互动方向更进一步的开发者参考。1. 先理清一个问题摄像头粒子为什么容易做得“像贴纸”我在第一部分里把基础链路跑通之后发现摄像头粒子的效果好坏分水岭不在粒子材质、不在发光贴图而在“你到底从画面里取了什么信息”。第一版最常见的取法是整帧取平均色或者给每个粒子随机分配一个屏幕位置再把颜色喂给它。这种玩法最大的问题是粒子的位置和画面内容没有对应关系画面里人脸朝左移动粒子还在原地随机漂看上去自然像贴纸。所以第二部分这 10 条创意核心是做信息升级。我把一帧画面拆成几类可以驱动粒子的信号像素坐标、亮度值、色相距离、帧间差分、运动向量、人体关键点。每一条创意本质上是选择了一种或几种信号再决定粒子的响应方式。做之前我把这 10 条按“驱动源”做了个大致的分类方便后面写代码时复用序号创意名驱动信号典型视觉适合场景难度1亮度地形粒子亮度阈值粒子向亮部聚集直播间/脸部特效低2目标色喷籽RGB/HSV 色差指定颜色物体往外冒粒子商品展示/互动装置低3运动帧差残影帧间亮度差只有移动物体带光迹夜跑/舞蹈/手势控制中4记忆光绘滞留历史亮度缓存光点长时间停留消散光绘艺术/展览中5指尖粒子引导MediaPipe 手部关键点手指划过产生粒子展厅/教育/手势交互中高6人形粒子蒙皮人物剪影 mask人物轮廓被粒子填充演出/AR 自拍中高7运动向量风场简化光流动作像风一样吹散粒子舞蹈/健身互动高8亮度梯度河流相邻像素梯度粒子沿亮暗边界流动沉浸式空间中9LED 点阵重建规则网格采样画面变成点了阵屏直播背景/大屏低10多色质心喷泉颜色质心每个色块变成喷射源展览装置中后面每个创意我会把核心思路、关键实现、参数选择和避坑点都拆开讲。2. 所有效果通用的底层链路低分辨率像素采样加 CPU 映射粒子10 条创意跑完之后我发现共用的一套代码框架其实占比很高核心链路只有一条摄像头画面降低分辨率重绘到 Canvas 2D逐帧读像素数据再把每个粒子按一定规则映射到对应的采样网格上最后写回 Three.js 的 BufferAttribute。2.1 为什么不要直接读视频帧的高分辨率像素很多第一次做摄像头粒子的同学会尝试直接读取videoWidth × videoHeight的整帧像素比如 1280×720一帧就有 92 万多个像素点。如果每帧在主线程里循环一次同时还在更新几千上万个粒子的位置帧率会直接崩尤其是移动端浏览器。这里推荐的做法是先在离屏 Canvas 上把视频缩放到一个非常小的分辨率。我常用的默认值是宽 64、高 36也就是 2304 个网格点。这样每帧读取的计算量很小剩下的重心就能放在粒子的模拟和渲染上。等到需要更高精度时再升到 96×54 或者 128×72也依然可控。2.2 公共代码骨架先初始化视频和离屏 Canvasconst video document.createElement(video); video.srcObject await navigator.mediaDevices.getUserMedia({ video: { width: 640, height: 480 } }); await video.play(); const SAMPLE_W 64; const SAMPLE_H 36; const GRID_COUNT SAMPLE_W * SAMPLE_H; const canvas2d document.createElement(canvas); canvas2d.width SAMPLE_W; canvas2d.height SAMPLE_H; const ctx canvas2d.getContext(2d, { willReadFrequently: true });然后每帧把视频画进去再读出像素function readPixels() { ctx.drawImage(video, 0, 0, SAMPLE_W, SAMPLE_H); return ctx.getImageData(0, 0, SAMPLE_W, SAMPLE_H).data; }粒子数量不需要和网格数相等。我通常让粒子数大于网格数比如 8000 个粒子对应 2304 个网格点这样粒子在视觉上更有体积感。每个粒子只需要记住自己对应的“采样网格索引”更新时直接读那个格子的颜色、亮度或者运动状态不需要遍历整张图。映射关系可以用一行公式const gridIndex Math.floor(i * GRID_COUNT / PARTICLE_COUNT);这句话是整个系列里最关键的一行代码。它保证了粒子的驱动逻辑是 O(N)而不是 O(N×M)。如果每个粒子都去遍历所有像素后续效果很难扩展。写一个统一的更新框架function tick() { const pixels readPixels(); updateParticlesFromPixels(pixels); renderer.render(scene, camera); requestAnimationFrame(tick); }这之后10 条创意的区别就只集中在updateParticlesFromPixels这一层。3. 第 1、2 条把画面变成“地图”粒子开始认位置这两条相对简单适合作为第二轮改版的热身。3.1 第 1 条亮度地形粒子这条的思路很直接亮度高的区域像“山丘”粒子会被吸引过去亮度低的区域基本不产生影响力粒子自由漂移。实际运行效果是人脸、灯光、白色衣服这些地方会形成粒子密集区整个画面看起来像有一群发光粒子被物体“吸住”。实现上先把像素转成亮度数组const lum new Float32Array(GRID_COUNT); for (let i 0; i GRID_COUNT; i) { const idx i * 4; lum[i] pixels[idx] * 0.299 pixels[idx 1] * 0.587 pixels[idx 2] * 0.114; }然后对每个粒子计算“吸引强度”。我会先设定一个阈值比如 100亮度高于阈值时粒子的目标位置逐渐靠近它对应的网格位置const threshold 100; const maxAttract 60; for (let i 0; i PARTICLE_COUNT; i) { const gi Math.floor(i * GRID_COUNT / PARTICLE_COUNT); const bx gi % SAMPLE_W; const by Math.floor(gi / SAMPLE_W); const attract THREE.MathUtils.clamp((lum[gi] - threshold) / maxAttract, 0, 1); // 目标位置是网格坐标映射到 3D 空间 const targetX (bx / SAMPLE_W - 0.5) * sceneWidth; const targetY (0.5 - by / SAMPLE_H) * sceneHeight; positions[i * 3] (targetX - positions[i * 3]) * 0.03 * attract; positions[i * 3 1] (targetY - positions[i * 3 1]) * 0.03 * attract; // z 方向可以加一个噪声漂移避免画面太死板 positions[i * 3 2] Math.sin(time * 0.005 i) * 0.08 * attract; }几个实际的调试经验粒子目标不要直接怼到网格中心否则很多粒子会挤成点状。给每个粒子分配一个固定偏移量让它们在网格附近保持“一团”而不是“一个点”。吸引系数不要超过 0.05否则粒子会抖得像抽搐。如果想让反应更跟手可以加大每帧的增量而不是提高比例系数。亮度阈值建议做成可调参数。室内正常光照下肤色亮度基本在 100~180 之间想让人脸亮起来阈值设在 80 左右比较合适。3.2 第 2 条目标色喷籽这条效果适合互动装置比如用户拿着一只红色杯子在镜头前晃动杯子周围会不断产生红色粒子换一支绿色笔绿色区域才开始喷射。相当于给画面做了“颜色分离发射器”。实现时最基础的是算 RGB 色彩距离。为了防光照影响我会先转成 HSV再用色调距离判断但 HSV 计算量大一点。实际项目里如果对颜色纯度要求不高直接用 RGB 欧氏距离也能接受function colorDistSq(r1, g1, b1, r2, g2, b2) { const dr r1 - r2; const dg g1 - g2; const db b1 - b2; return dr * dr dg * dg db * db; } const targetR 200, targetG 30, targetB 30; const threshold 120 * 120; for (let gi 0; gi GRID_COUNT; gi) { const idx gi * 4; if (colorDistSq(pixels[idx], pixels[idx 1], pixels[idx 2], targetR, targetG, targetB) threshold) { // 该网格被标记为活跃发射点 active[gi] 1; } }标记完活跃网格之后粒子更新时只处理活跃网格对应的粒子。可以给这些粒子一个向外扩散的速度让它们像“种子”一样从物体上崩出来。这个效果最常用的调参是阈值。阈值太小会导致识别断断续续阈值太大又会把相近颜色一起选中。经验做法是先把阈值设成 9000然后在画面上加一个“点击像素取色”功能用户点击视频区域时自动把该点的 RGB 设为target色值。这样比在控制面板里手动输入数值直观得多。4. 第 3、4 条在时间维度上做文章前面两条只处理单帧的空间信息但摄像头交互最有魅力的地方在于动态。第 3、4 条都是围绕“时间”展开的。4.1 第 3 条运动帧差残影这条效果很简单当人静止不动时画面几乎不产生任何粒子一旦人开始挥手、跳跃、转身运动区域的轮廓会被一大串高亮粒子拖出来形成光迹残影。这是所有交互效果里最容易“让人玩起来”的一条。核心是帧间差分。每帧读亮度和上一帧亮度做差差值大于阈值的网格才把粒子激活。const prevLum new Float32Array(GRID_COUNT); const diffLum new Float32Array(GRID_COUNT); for (let gi 0; gi GRID_COUNT; gi) { const diff Math.abs(lum[gi] - prevLum[gi]); diffLum[gi] diff motionThreshold ? 1 : 0; } prevLum.set(lum);粒子更新时尽量把位置移动到最近的运动网格上同时在运动网格周围随机散布一些粒子。要让残影更好看可以做一个环状缓冲区把过去 6 帧的网格运动状态都存下来const ring []; const RING_DEPTH 6; let ringIndex 0; // 每帧把运动标记推入 ring[ringIndex] diffLum.slice(); ringIndex (ringIndex 1) % RING_DEPTH;粒子用来驱动的“目标格子”从当前帧往前随 index 偏移几个格子这样运动残影会有明显的拖尾分层。做这条时最容易遇到的问题是画面整体亮度闪烁导致大面积误判。比如窗外云飘过、屏幕亮度自动调节整帧亮度变化很大diff 就会全部超阈值。我的解决方法是先做自适应阈值统计这一帧所有 diff 的中位数只有大于中位数一定倍数的网格才算运动点而不是用固定阈值。4.2 第 4 条记忆光绘滞留这条和上一条有一点像但气质完全不同。记忆光绘的效果是光点不会立刻消失它会在空间中慢慢停住颜色还会随真实画面缓缓更新像有人在暗室里用光画线线条在空中停留几秒再慢慢淡去。实现时不再做二值化运动判断而是维护一个“记忆亮度值数组”。每个网格都存一个memoryBrightness每帧向当前真实亮度靠近一点点同时整体缓慢衰减const memoryBrightness new Float32Array(GRID_COUNT); for (let gi 0; gi GRID_COUNT; gi) { const target lum[gi] / 255; memoryBrightness[gi] (target - memoryBrightness[gi]) * 0.15; memoryBrightness[gi] * 0.985; } // 粒子位置读取 memoryBrightness亮的地方让粒子往那个格点靠拢 const strength memoryBrightness[gi];这里的核心参数是0.985的衰减率。衰减率越接近 1光迹停留时间越长。如果想做一个“光画”效果衰减率可以设到 0.995如果要舞台演出用的动态拖尾0.97 到 0.98 会更干净。我实际做的过程中发现0.15 的更新系数很重要。如果系数太高记忆值会完全跟随当前帧瞬态光迹没有了如果系数太低粒子和当前画面完全脱节看起来像是在自嗨。一般取 0.1 到 0.2 之间比较平衡。这条效果适合暗色背景因为记忆光迹在明暗对比强的地方视觉冲击力最大。5. 第 5、6 条让粒子“认人”前几条虽然能感知颜色和运动但并不知道画面里哪个是人。接下来的两条用到了人物检测和关键点效果会更有指向性。5.1 第 5 条指尖粒子引导MediaPipe Hands 对前端特别友好可以直接在浏览器里跑。手部关键点检测到指尖位置之后把坐标映射到 Three.js 场景在指尖位置生成粒子。实际效果就像手指带着一条发光拖尾划过哪里哪里就亮起星屑。关键点坐标是归一化的范围 0 到 1需要转换到场景坐标。我常用的映射是const viewW 16; const viewH 9; // 拿到指尖的归一化坐标