简介本资源是一个面向Web前端开发者与AI应用实践者的网页摄像头交互式创意演示项目聚焦于浏览器端实时人体理解与3D可视化融合解决传统网页交互缺乏深度感知与沉浸感的问题。压缩包共147个文件含39个核心JavaScript逻辑文件模型加载、数据流处理、29个Vue组件UI交互与状态管理、21个GLSL着色器文件如PostEffectBlur.fs、Face.fs等用于Three.js后处理与面部/姿态特效渲染以及PNG、OBJ、MTL等资源文件整体仅432KB轻量易部署。已有64人学习下载适合中高级前端工程师、计算机视觉初学者及创意编程爱好者。读者可直接运行获得完整可交互Demo基于TensorFlow.js实时调用PoseNet人体关键点、FaceMesh468面部特征点与BodyPix像素级人像分割三大模型并通过Three.js实现姿态驱动3D模型、面部表情映射、背景虚化与光效渲染等效果项目结构清晰sketch-webcam-master目录已封装标准化接入流程与模块化渲染管线。 打开摄像头屏幕上不再是那个略显疲惫的自己而是一个由光点和连线组成的3D火柴人你抬手他也抬手你转身他也转身换个模式你的脸变成了一张半透明的多边形网格面具再换个模式画面里的你化作数千个彩色粒子在三维空间里缓缓漂浮。这些看似Magic的效果核心就是TensorFlow.js和Three.js这对组合前者负责在浏览器里跑机器学习模型让人能“看懂”摄像头画面后者负责把识别结果变成实时3D交互体验。PoseNet做人体姿态识别、FaceMesh做面部特征点检测、BodyPix做人体分割——这篇文章我会完整拆解这三个模型如何接入TensorFlow.js、如何把输出映射到Three.js的3D场景里以及整个项目从零搭建时最容易踩的坑和性能优化思路。这个项目非常适合对前端创意编程、Web端AI应用感兴趣的人哪怕你之前没怎么接触过机器学习和3D渲染跟着思路走一遍也会很清楚它不需要买GPU、不需要训练模型用的全是官方预训练模型浏览器打开就能跑。唯一的门槛是你得有点JavaScript基础并且愿意动手改代码验证效果。1. 为什么是 TensorFlow.js Three.js在浏览器里让摄像头“说话”这个组合不是凭空冒出来的它解决的问题非常具体过去做摄像头交互要么用OpenCV加Python写本地服务要么把画面传云端做AI推理。前者部署麻烦后者有延迟和隐私顾虑。TensorFlow.js把模型推理完全放到了浏览器端数据不出本机PoseNet一类的模型在WebGL加速下能做到实时推理而Three.js则是浏览器里最成熟的3D渲染方案负责把推理结果“画出来”。选择这两个库还有一个很实际的理由它们都极其活跃社区资料多出了问题几乎都能搜到解法。TensorFlow.js官方维护了PoseNet、FaceMesh、BodyPix的模型包虽然有的已经进入维护模式文档上写着deprecated但跑起来依然稳定。而Three.js从底层WebGL封装到各种辅助库都很完善不需要自己写GLSL着色器就能做出不错的效果。有些朋友会问为什么不直接用MediaPipe的JS包代替TensorFlow.jsMediaPipe确实更新、性能也更好但从项目标题和实际工程的角度看TensorFlow.js的模型API更统一加载方式简单适合快速出原型。如果你之后想换模型、调参数、做多人识别直接改一改配置就行。这个项目本质上的技术链条是这样的getUserMedia获取摄像头视频流每帧把画面绘制到画布或直接转成TensorTensorFlow.js运行模型输出关键点坐标或分割掩码坐标经过处理和映射变成Three.js场景中物体的位置、旋转或形变渲染循环持续读摄像头、跑推理、更新3D场景形成实时交互听起来不复杂但真正落地时你会遇到分辨率取舍、镜像翻转、帧率控制、模型叠加等一堆“文档里没有”的细节。接下来的内容就是我实际跑通一遍之后的完整记录。2. 起步第一件事摄像头流、画布镜像与坐标映射很多人拿到这个项目第一反应是直接往上堆模型结果发现画面要么是反的要么坐标对不上。所有问题几乎都出在“画面从摄像头到模型再到3D场景”这条链路的坐标体系没有对齐。所以我建议从一开始就把这一步做对。2.1 获取视频流和绘制帧获取摄像头视频流的代码所有人都会写const video document.createElement(video); video.width 640; video.height 480; navigator.mediaDevices.getUserMedia({ video: { width: 640, height: 480, facingMode: user } }).then(stream { video.srcObject stream; video.play(); });这里有两个容易被忽略的点第一视频分辨率不要贪高。PoseNet和BodyPix对输入分辨率敏感但推理速度也跟输入分辨率直接相关。640x480基本是实时性的甜点区间1280x720也可以跑但三模型叠加的时候帧率会明显下降。第二实际识别用的画布分辨率最好和视频分辨率一致不要拿一个静止的canvas反复drawImage那样会产生额外的坐标换算。绘制到画布的代码是这样的const canvas document.createElement(canvas); canvas.width 640; canvas.height 480; const ctx canvas.getContext(2d); function drawVideoFrame() { // 这里做水平镜像让画面像照镜子一样自然 ctx.save(); ctx.translate(canvas.width, 0); ctx.scale(-1, 1); ctx.drawImage(video, 0, 0, canvas.width, canvas.height); ctx.restore(); }注意这一步的镜像处理。前置摄像头的原始画面是反的很多演示项目直接不处理结果关键点坐标和用户看到的画面方向不一致。你举起左手画面里的模型举右手体验非常奇怪。绘制时做一次水平镜像让视频画面、模型输入、3D渲染三个坐标系统一是省心做法。2.2 坐标映射的核心公式PoseNet和FaceMesh输出的坐标是像素坐标FaceMesh是归一化坐标细节后面会说到它们的原点在图片左上角x轴向右y轴向下。而Three.js的世界坐标原点在场景中心x轴向右y轴向上z轴向屏幕外。把一个2D的像素点放到3D场景里需要做三层变换function mapTo3D(px, py, planeWidth, planeHeight, z) { const x (px / planeWidth - 0.5) * 2; const y -(py / planeHeight - 0.5) * 2; return new THREE.Vector3(x, y, z); }这个公式的本质是先把像素坐标归一化到[0, 1]再映射到[-1, 1]区间为什么用[-1, 1]因为Three.js里默认视口和正交相机可以直接用这个区间定位省了很多换算。y取负号是因为屏幕坐标和Three.js坐标的y方向相反。我用这个方式做PoseNet的火柴人时把z值固定在0.5的位置整个3D骨骼就浮在画面前方。你不需要真的做深度估计这个固定z值带来的“平面叠加”效果已经足够好了。如果你想让人物有前后纵深可以后续用“肩膀到髋部的像素距离”反推一个近似深度这个后面讲扩展时再说。2.3 模型输出到3D场景的两种模式坐标映射基本有两种接线方式。一种是直接把识别结果转成Three.js对象的position/rotation比如检测到手腕就往手腕位置放一个小球另一种是把一整组关键点作为几何体的顶点数据整体更新Geometry的position属性比如用FaceMesh的468个点动态驱动一张人脸的三角形网格。第二种方式的效果更惊艳但实现复杂一些需要你把顶点数组和模型的拓扑结构indices对应起来。FaceMesh的顶点顺序是有意义的每个三角形索引可以通过包里的faces数组拿到。后续我会把接线代码展开。3. 三大模型逐个接入PoseNet、FaceMesh、BodyPix 各自的联动姿势这一章是核心。三个模型虽然都是TensorFlow.js体系但输出格式、加载方式、和Three.js联动的思路完全不同。我会按模型一个一个拆给出最简可用的接入方式再讲清楚它们到底适合做什么效果。3.1 PoseNet从17个关键点到3D骨骼火柴人PoseNet输出的关键是points数组每组包含x、y坐标和score置信度。默认配置下是17个关键点按COCO标注顺序排列鼻子、左右眼、左右耳、左右肩、左右肘、左右腕、左右髋、左右膝、左右踝。官方加载模型的方式是这样的import * as posenet from tensorflow-models/posenet; const net await posenet.load({ architecture: MobileNetV1, outputStride: 16, inputResolution: 320, multiplier: 0.75, });参数这里说下我的习惯MobileNetV1架构在浏览器里最快足够做实时交互ResNet50准确率高但推理时间翻几倍实时性很难保证。inputResolution决定神经网络输入图片的尺寸320x320是比较平衡的选择如果设备性能好可以试401。multiplier是网络宽度系数0.5-0.75已经很快精度损失肉眼几乎看不出来。每帧推理和画骨骼的典型代码我把它简化成一个结构清晰的过程const pose await net.estimateSinglePose(canvas, { flipHorizontal: false, // 因为我们已经在绘制时做了镜像这里不要重复翻转 decodingMethod: single-person }); // 在Three.js中动态更新一个LineSegments const positions []; const connections [ [leftShoulder, leftElbow], [leftElbow, leftWrist], [rightShoulder, rightElbow], [rightElbow, rightWrist], [leftShoulder, rightShoulder], [leftShoulder, leftHip], [rightShoulder, rightHip], [leftHip, rightHip], [leftHip, leftKnee], [leftKnee, leftAnkle], [rightHip, rightKnee], [rightKnee, rightAnkle] ]; for (const [a, b] of connections) { const pa pose.keypoints.find(k k.part a); const pb pose.keypoints.find(k k.part b); if (pa.score 0.3 || pb.score 0.3) { // 置信度不够就拉远相当于隐藏该条线 positions.push(-10, -10, -10, -10, -10, -10); } else { const va mapTo3D(pa.position.x, pa.position.y, 640, 480, 0.5); const vb mapTo3D(pb.position.x, pb.position.y, 640, 480, 0.5); positions.push(va.x, va.y, va.z, vb.x, vb.y, vb.z); } }我把连线逻辑里的一个小细节提出来单独说置信度过滤。PoseNet在一个人完全被遮挡、或者手部快速挥动时置信度会骤降。如果不过滤那些错误的关键点会让骨骼线条疯狂抖动看起来非常廉价。加一个0.3的阈值低于阈值的就把线段顶点拉到屏幕外比如远离坐标系的-10位置实际效果就是那条线“消失”了。0.3是我测试下来比较合适的值太低会有明显抖动太高会导致线条频繁闪烁。Three.js侧用一个动态更新BufferGeometry的LineSegments来承载这套数据const geometry new THREE.BufferGeometry(); geometry.setAttribute(position, new THREE.BufferAttribute(positions, 3)); const material new THREE.LineBasicMaterial({ color: 0x00ffff, linewidth: 1 }); const lineSegments new THREE.LineSegments(geometry, material); scene.add(lineSegments);注意linewidth在WebGL里大多数浏览器会当作1处理你设置大于1的数值往往不生效。想要粗线条效果需要换用THREE.Line2或者把每段骨骼替换成圆柱体Mesh。用圆柱体替代线条的缺点是计算量上升、代码变复杂我建议先做线条版跑通后再按需升级。3.2 FaceMesh468个点驱动3D面具FaceMesh的接入方式和PoseNet差异很大。它输出的不是像素坐标而是归一化坐标0到1的范围使用前必须乘上画面宽高。另一个关键是它返回的是三维坐标数组x、y、z但z轴的尺度是“相对深度估计”和x/y的比例不一致如果你直接用原始的z值面具会显得极端拉伸。我是这样处理的import * as faceLandmarksDetection from tensorflow-models/face-landmarks-detection; const model faceLandmarksDetection.SupportedModels.MediaPipeFaceMesh; const detector await faceLandmarksDetection.createDetector(model, { runtime: tfjs, refineLandmarks: true, maxFaces: 1, }); // 每帧检测 const faces await detector.estimateFaces(canvas); if (faces.length 0) { const landmarks faces[0].keypoints; // 468个 {x, y, z} // 把归一化坐标转成像素坐标 const points landmarks.map(p ({ x: p.x * 640, y: p.y * 480, z: p.z * 80, // 手动缩放z让它和x/y量级一致 })); }这里手动把z乘以80是我实测后的经验值。FaceMesh的z值绝对值非常小直接放进Three.js面部网格会变成一个几乎贴在平面上的纸片。乘以80到120取决于相机距人脸远近后鼻子、眼睛、嘴唇的立体感就出来了。这个系数没有标准答案你应该根据自己想要的效果调整。接下来是“惊艳效果”的实现把468个关键点直接作为BufferGeometry的顶点用FaceMesh提供的三角形索引来构建网格。face-landmarks-detection包导出了TRIANGULATION数组但你自己解析那个数组比较麻烦。通常情况下如果你想从landmarks构建geometry的index属性可以使用包内部输出的faces字段或者使用完整的三角形索引列表。这里我提供一个思路把顶点positions和三角形indices合并成Three.js的Mesh并用一个半透明材质来渲染const geometry new THREE.BufferGeometry(); geometry.setAttribute(position, new THREE.BufferAttribute(vertices, 3)); geometry.setIndex(indices); geometry.computeVertexNormals(); const material new THREE.MeshBasicMaterial({ color: 0xffffff, wireframe: true, // 线框模式最直观 transparent: true, opacity: 0.7, }); const mesh new THREE.Mesh(geometry, material); scene.add(mesh);每帧更新顶点的时候注意别重新new BufferAttribute直接更新原数组const posAttr geometry.attributes.position; const arr posAttr.array; for (let i 0; i points.length; i) { const v mapTo3D(scaleFactor, points[i].x, points[i].y, points[i].z); arr[i * 3] v.x; arr[i * 3 1] v.y; arr[i * 3 2] v.z; } posAttr.needsUpdate true;设置needsUpdate true 是Three.js里对已有geometry进行动态更新的关键漏了这行你看到的画面会一直是第一帧的形状。这个坑我已经见过不止一个人踩了。FaceMesh的“refineLandmarks: true”参数也值得说。它表示使用更精细的二次模型把468个点进一步优化尤其对轮廓和眼唇边缘的处理更好。缺点是多一个推理步骤帧率有小幅下降。如果你只做面具推荐开着如果要做低延迟的眼神交互可以关掉。3.3 BodyPix像素级人体分割与粒子效果BodyPix和前面两个模型完全不是一个路子。PoseNet和FaceMesh输出的是稀疏的关键点BodyPix输出的是密集的像素级掩码。它可以告诉你画面中哪些像素属于“人”的轮廓甚至能区分出24个身体部位。加载和推理代码import * as bodyPix from tensorflow-models/body-pix; const net await bodyPix.load({ architecture: MobileNetV1, outputStride: 16, multiplier: 0.5, quantBytes: 2, }); const segmentation await net.segmentPerson(canvas, { flipHorizontal: false, internalResolution: 0.5, // 内部推理分辨率越低越快 segmentationThreshold: 0.5, });segmentPerson返回的segmentation里最重要的字段是data它是一个Uint8Array长度等于internalResolution下的像素总数每个值是0或1。1代表属于人体0代表背景。还有一个width和height表示这个掩码图的分辨率。拿到掩码之后最直观的Three.js联动方式是把整个画面变成一个粒子系统。思路是把视频帧的每个像素映射成一个粒子点粒子的颜色取视频帧对应的像素颜色粒子的尺寸或者可见性则取决于该像素是否属于人体。这样你能看到一个由成千上万个小圆点组成的“人体”背景粒子则被隐藏或虚化。这个效果的关键代码逻辑是// 假设你在场景中创建了一个包含 width*height 个粒子的Points const positions geometry.attributes.position.array; const colors geometry.attributes.color.array; for (let y 0; y maskHeight; y) { for (let x 0; x maskWidth; x) { const idx y * maskWidth x; const isPerson segmentation.data[idx] 1; const px (x / maskWidth) * 2 - 1; const py -(y / maskHeight) * 2 1; // 从视频帧 canvas 上采样颜色 const imageData ctx.getImageData(x * step, y * step, 1, 1).data; positions[idx * 3] px; positions[idx * 3 1] py; positions[idx * 3 2] isPerson ? 0 : -5; // 背景粒子推到远处 colors[idx * 3] imageData[0] / 255; colors[idx * 3 1] imageData[1] / 255; colors[idx * 3 2] imageData[2] / 255; } }getImageData每一帧调用会拖慢性能。更高效的做法是先把整个视频帧绘制到一个离屏canvas然后用getImageData一次性拿到整帧像素数组再按采样步长填充粒子颜色。粒子数量的控制也很关键。如果你按640x480全分辨率生成粒子那就是30万个粒子Three.js的Points渲染倒是撑得住但CPU侧每帧更新数组的开销非常大。我建议把粒子网格降采样到160x120也就是约2万个粒子视觉上依然是密集的“人体星云”性能却完全没压力。BodyPix还有segmentPersonParts方法可以分部位着色——手臂一个颜色、腿一个颜色、脸一个颜色。如果你想做“人体解剖图”那样的效果可以考虑它。但要注意partSegmentation返回的data里每个值不是0/1而是部位ID0到23你需要建一张部位ID到颜色的映射表。4. 性能优化帧率、分辨率和推理节奏的控制策略三模型同时在一个页面里跑直接让你体验什么叫“PPT交互”。我在第一次把PoseNet、FaceMesh、BodyPix全部打开时帧率掉到7fps整个页面卡得像慢动作。经过一轮优化后至少能稳定在25fps以上。几个关键手段效果立竿见影。4.1 跳帧推理最有效的优化大多数摄像头是30fps但模型推理没法做到每帧都跑或者跑完了也没必要每帧更新位置。我采用“推理频率控制”的策略渲染循环保持60fps但模型推理每隔2到4帧执行一次3D场景继续用上次的推理结果渲染。具体实现是用一个计数器let frameCount 0; const INFERENCE_INTERVAL 3; // 每3帧推理一次 function animate() { requestAnimationFrame(animate); frameCount; if (frameCount % INFERENCE_INTERVAL 0) { runPoseNetInference(); // 其他模型也在这里跑 } updateThreeSceneFromLatestResults(); renderer.render(scene, camera); }这个“间隔跳帧”的收益非常大因为TF.js的前向推理是同步计算会阻塞JS主线程。跳掉2/3的帧等于把主线程的时间让给了Three.js渲染。代价是动作会有轻微的延迟感大概是100毫秒以内的滞后在交互演示场景完全可以接受。如果想更精细一点可以给不同模型分配不同间隔。比如PoseNet每3帧一次FaceMesh每2帧一次BodyPix每5帧一次。这样能削平CPU峰值。4.2 输入分辨率与内部推理分辨率模型输入分辨率直接影响推理速度。前面PoseNet我把inputResolution设成了320比摄像头原始分辨率小了一半这是刻意的。神经网络对输入的细节要求没有你想的那么高320分辨率的PoseNet识别一个完整人体已经足够了。BodyPix的内部推理分辨率比较特殊segmentPerson里有个internalResolution参数0.5表示把输入图缩到一半输出掩码的分辨率也跟着降一半。对于粒子效果来说掩码不需要高清它只是用来判断“这个像素是不是人”0.25甚至都能用。分辨率越低像素块感越强但这也是某种“风格”。4.3 WebGL后端和WASM后端怎么选TensorFlow.js在支持WebGL的浏览器上默认用WebGL后端。WebGL后端通过GPU加速卷积计算实时推理基本靠它。但有个反直觉的情况在部分低端设备或Windows系统上WebGL模式下某些算子反而很慢而WASMWebAssembly后端用CPU多线程计算虽然在数学运算上通常比GPU慢但是它的启动开销小而且更稳定。如果项目跑起来明显卡顿可以试着强制指定WASM后端await tf.setBackend(wasm); await tf.ready();我记得WASM后端还需要单独加载wasm二进制文件用npm安装时通常会自动处理。实际对比效果在苹果芯片的Mac上WebGL表现更好在普通Windows笔记本上WASM有时候更稳。我的建议是默认用WebGL出问题再切WASM做A/B对比。4.4 显存和内存释放TensorFlow.js每次推理返回的Tensor如果忘记dispose内存会被占满。尤其BodyPix输出的掩码数据量大跑几百帧之后页面会越来越卡最后直接崩溃。在写代码时对显式创建的Tensor要记得调用tensor.dispose()对模型返回的结果也一样const segmentation await net.segmentPerson(canvas, options); // 用完后立即释放 segmentation.data null; // 如果用的是Tensor调用 tensor.dispose()不过segmentPerson返回的不是Tensor而是处理过的对象内部已经释放了部分资源。PoseNet的estimateSinglePose返回的也是普通对象。真正需要小心的是如果你自己用tf.browser.fromPixels创建了Tensor那么一定要在推理后调用dispose。不然一帧泄漏一个Tensor几百帧后浏览器的内存就爆了。5. 这类项目最常见的坑从 WebGL 上下文到标签页节流跑通一个演示项目并调试到流畅状态中间一定有各种诡异问题。下面这几个坑我基本都踩过按“踩坑率”排个序排名靠后的可能更隐蔽但往往一卡就是半天。5.1 多WebGL上下文的冲突TensorFlow.js会创建自己私有的WebGL上下文Three.js也会创建渲染上下文。一般情况下各自用各自的没问题但在同一页面里浏览器对WebGL上下文的总数有限制通常是8到16个因浏览器而异。如果你开了多个页面、或者反复加载模型旧的上下文没有释放新上下文创建失败整个页面黑屏。一个常见冲突场景是你定义了一个全局video然后又创建了一个隐藏canvas给TF.js用Three.js再创建一个webgl-canvas。三个上下文同时活跃在某些显卡驱动下有概率直接报“Too many active WebGL contexts”错误。这时候最简单的排查办法关了其他标签页刷新页面如果恢复正常基本上就是上下文溢出。更系统的做法是尽量共享一个上下文。Three.js的WebGLRenderer在创建时可以把context传进去TF.js也支持指定自己的gl上下文。但把两个库接到同一个context会比较绕而且要处理SharedArrayBuffer等问题对于休闲项目来说性价比不高。我的实际建议是保持Three.js自己的canvas渲染3D摄像头帧画到隐藏的2D canvasTF.js用这个2D canvas创建输入Tensor。这样2D canvas和3D canvas互不干扰TF.js内部用自己的WebGL上下文虽不是同一个gl但它们在各自的canvas上操作不会互相破坏状态。5.2 镜像反转翻车前面我特意说了镜像如果绘制视频时做了scale(-1,1)画面上的人和手势是镜像的但你直接把这个canvas交给模型推理模型的输出坐标也是以这个canvas为基准的坐标体系是一致的这是正常情况。真正容易翻车的是“我既想显示视频流又想显示3D叠加”——视频流用css或canvas做了镜像但3D场景没有镜像。结果就是你的左手对应的3D火柴人的右手。要解决其实也很简单让Three.js的scene在渲染时也做一个横向翻转或者直接把3D对象整体scale.x设为-1group.scale.x -1;这样做之后的实际效果是视频里你抬起右手3D火柴人的右手也对应着抬起。镜像问题在做交互演示时非常影响观感务必写在项目注释里。5.3 浏览器后台标签页节流几乎每个做摄像头交互的人都会遇到切到其他标签页几分钟再切回来发现画面卡住或者模型完全失灵。原因有两个一是浏览器为了省电会暂停后台页面的requestAnimationFrame二是getUserMedia的视频流在标签页不可见时浏览器可能会降低视频帧的产生频率。从后台切回来之后requestAnimationFrame恢复正常但Three.js场景可能还停留在切走之前的状态TF.js的推理结果也过期了。这时候需要立刻基于最新一帧视频重新做一次推理把3D状态刷新。我的做法是监听visibilitychange事件document.addEventListener(visibilitychange, () { if (!document.hidden) { video.play(); frameCount 0; // 重置推理计数器 runAllInferenceImmediately(); // 立即跑一次完整推理 } });实测下来这个处理能解决大部分“回到页面后画面瘫痪”的问题。还有个小细节如果视频流用video.play()的promise被拒绝因为页面切到后台期间浏览器暂停了流需要catch一下否则控制台会一直刷Unhandled Promise Rejection。5.4 多个模型同时加载的顺序问题PoseNet、FaceMesh、BodyPix三个模型一起load在低端设备上可能要等好几秒。如果加载时有任何模型包里的wasm比如TF.js自动加载的二进制文件发起请求页面在加载完成前最好给足反馈不然用户会以为页面挂了。我的处理方式是做一个“模型管理器”把加载流程改成串行进度条async function loadAllModels(onProgress) { const tasks [ [PoseNet, () posenet.load(config)], [FaceMesh, () faceLandmarksDetection.createDetector(...)], [BodyPix, () bodyPix.load(config)], ]; for (let i 0; i tasks.length; i) { onProgress(tasks[i][0], (i 1) / tasks.length); await tasks[i][1](); } }有人会想并行加载三个模型现实是TF.js在加载时可能会同时启动多个wasm下载导致网络竞争和内存压力。串行加载虽然慢一点但更稳。6. 项目结构参考与后续扩展从一个演示到一个可玩的作品如果你不是只想要一段贴到页面上就能跑的Demo而是想把这个当成一个持续迭代的创意项目那代码结构从一开始就不能太乱。我分享一下这个项目最终稳定下来时用的模块划分以及我自己在跑通之后又做了哪些有趣的扩展方向。6.1 建议的目录与模块划分我习惯把所有视觉生成相关的代码拆成独立的模块而不是在一个入口文件里堆所有逻辑。实际结构大致是这样的src/ main.js // 入口初始化摄像头、启动渲染循环 models/ poseModel.js // PoseNet的加载、推理、结果缓存 faceModel.js // FaceMesh的加载、推理、结果缓存 bodyModel.js // BodyPix的加载、推理、结果缓存 three/ sceneManager.js // 场景、相机、渲染器的创建 poseVisualizer.js // 把关键点画成线条/小球 faceVisualizer.js // 把468点画成网格面具 particleVisualizer.js // 基于分割掩码的粒子人体 utils/ coordinates.js // mapTo3D等坐标系转换函数 video.js // 视频流获取、canvas绘制三个visualizer各自对外暴露一个update(data)方法负责把新推理数据喂给对应的Three.js对象。main.js里的渲染循环只管从模型模块拿“最新结果”然后逐个调用visualizer的update。这样改动一套视觉表现不会影响模型推理反过来换模型也不动渲染逻辑。6.2 在Three.js里升级视觉效果水面、闪电这类Shader变化当项目基础跑通后你大概率不会满足于朴素的线框和粒子。Three.js最强大的地方在于只要你愿意写GLSL所有视觉都能玩出花。比如PoseNet的火柴人线条默认是LineBasicMaterial纯色线框看起来有点廉价。如果想做得更有质感可以用Line2实现可变宽度的发光线条或者加一道“闪电”效果把骨骼骨骼点之间通过shader渲染成一段带有随机锯齿状的电流。三点之间每两个点生成一个折线再通过时间噪声让折线的每个顶点在垂直于骨骼的方向上抖动呈现出电流闪烁的效果。水面的效果也可以和摄像头结合BodyPix分割出人体之后把人体区域作为“水面波纹扰动源”人体边缘的粒子当作涟漪源头让粒子围绕边缘做放射状起伏。用Three.js写一个自定义顶点shader在顶点着色器里根据距离中心的远近和时间值计算扰动偏移。这种效果跟Three.js官方的water案例不一样自己控制shader才有交互性。这些方向对GLSL的要求直接上了一个台阶但如果只是想看看更炫的过渡效果也有取巧方案用ShaderMaterial叠加一个后处理pass比如在粒子颜色里插值一些动态色带、加一个简单的Bloom辉光后期。Three.js的EffectComposer配合UnrealBloomPass几行代码就能让整个画面多一层“电影感”。6.3 识别精度、稳定性和使用体验的后续目标从Demo期到作品期你在意的变量会从“能跑吗”变成“跑得稳吗”和“用户用起来爽吗”。几个我仍在持续迭代的方向供参考一个是多人追踪。当前PoseNet用estimateSinglePose默认只能识别单个主体如果想多人需要用estimateMultiplePoses并考虑如何把多条骨骼映射到Three.js中做分组显示。多人模式对CPU的要求高很多需要把输入分辨率降到240甚至更小同时接受帧率下滑。另一个是把识别结果做平滑。模型每帧输出的关键点会有一点随机抖动直接使用坐标看起来像手持摄影机没开稳定器。可以用指数平滑或者基于卡尔曼滤波的简易一阶算法对每个关键点坐标做低通滤波smoothedX prevX * 0.7 currentX * 0.3;系数0.3可以理解成“新数据占的权重”权重越低越平滑但延迟越高。实际调的过程中我一般从0.3起步看抖动和延迟的平衡再调。对FaceMesh这种微小面部动作平滑尤其重要不然面具边缘会一直轻微闪烁。最后所有东西跑通之后别忘了做一次性能基准测试。建议跑一个“摄像头单模型渲染”的最小组合记录帧率和推理耗时然后逐步叠加其他功能这样才能定位瓶颈到底出在模型、渲染还是代码逻辑上。我在做这个项目时就发现BodyPix的推理耗时比PoseNet和FaceMesh加起来还高之后所有涉及多模型叠加的场景BodyPix的优先级都是最低的。7. 结尾一点个人体会这类“浏览器里的实时交互”项目最迷人的地方不在于某个技术点有多前沿而在于那种“从数据到视觉”的即时反馈链条。PoseNet把人体变成一串坐标Three.js再把坐标变成屏幕上活生生的影像这种转化的爽感是纯后端AI或纯3D渲染都很难单独带来的。我强烈建议你拿到代码后先别急着调参数把自己放进画面里动一动感受一下延迟和视觉反馈的配合然后再去改代码。你会很快发现自己最想优化的那个点可能就是别人学不走的独门技巧。本文还有配套的精品资源点击获取