资讯动态

Three.js进阶实战:几何体合并与点击高亮全解析

发布时间:2026/9/8 5:24:14 来源:尧图企业网站定制
最近把 Three.js 进阶的内容重新刷了一遍借着两个小项目把以前很多“会用但没想透”的东西彻底捋顺了。这篇想重点记录这次进阶里最实用的两个方向几何体合并和点击高亮顺便把很多人纠结的Three.js 和 Unity 怎么选这个问题也聊一聊。文章会贴合“threejs进阶”这个主题往下展开该给完整代码的地方绝不省略该说原理的地方也不会一笔带过希望对正在从“能跑 demo”走向“能扛项目”的朋友有帮助。如果你是那种已经把 Three.js 基础过完、但进到真实项目里总觉得卡顿、交互效果做不出来的人这篇文章尤其合适。几何体合并能帮你把 Draw Call 降一个量级点击高亮能让你告别那种“选了没反馈、反馈不准确”的尴尬体验。两个技术点组合在一起基本可以覆盖静态场景优化加基础交互的常见形态。1. 进阶前先想明白Three.js 和 Unity 怎么选1.1 不同赛道不同打法在聊具体技术前我想先说一个绕不开的热搜方向Three.js 和 Unity 哪个好。这个问题我几乎每次分享都会被问到但答案其实不在“好”上而在“你打算干什么”上。Three.js 本质上是 WebGL 之上的一个 3D 渲染库定位非常纯粹在浏览器里渲染 3D 场景。它没有编辑器、没有物理引擎、没有动画状态机这些东西都需要你从生态里去拼。而 Unity 是一个完整的游戏引擎自带编辑器、物理系统、动画系统、导航寻路、光照烘焙等一系列工具链适合做中大型交互应用和游戏。做个不太严谨但容易理解的类比Three.js 有点像一套高精度的手工工具握在手里很灵活配合前端生态能捏出非常贴合页面的效果Unity 则更像一条汽车生产线家具、零件、动力系统都有现成的你往里填内容就快很多但它产出的东西通常是一辆完整的车不是单独一个零件。所以我的建议是做 Web 端的 3D 可视化、数据大屏、产品展示、建筑孪生类项目选 Three.js 更合适做重度交互游戏、跨平台iOS/Android/桌面应用、需要完整物理模拟的复杂场景Unity 的优势更明显。因为它的问题根本不是“谁比谁强”而是“谁更适合你的项目和团队”。1.2 进阶学习的主线在决定入门或进阶 Three.js 之前先明确哪些知识是硬前提。不少人卡在进阶阶段不是因为后面的技术难而是因为前面欠账太多。相机坐标系、矩阵运算、图元创建、材质属性、灯光类型、动画系统、Raycaster 拾取这七个基础点如果没过关应付复杂项目会很吃力。过了基础关之后我建议你按照下面这条主线去进阶这也是这次学习里我收获最大的三条路性能优化方向Draw Call 优化、几何体合并、纹理合图、LOD 分级加载。交互方向物体拾取、点击高亮、TransformControls、DragControls 等控制器扩展。渲染效果方向EffectComposer 后处理管线、Bloom、SSAO、Outline、色调映射。文中前面两个部分合并几何体和点击高亮就是对应性能和交互这两个方向的骨干内容。串起来学能少走很多弯路。实际上项目里它们往往又是一起出现的场景静态元素合并完后点击高亮依然要能正常工作这两个知识点并不孤立。2. 合并几何体第一个进阶必修课2.1 为什么要合并几何体从一次掉帧说起先讲我踩过的一个坑。做一个工厂车间可视化场景里需求方摆了大概 400 个相同的货架模型每个货架由几个 BoxGeometry 拼接而成。我一开始很天真直接循环创建 Mesh 塞进场景。结果一运行FPS 掉到了 20 多画面卡顿严重打开调试面板一看Draw Call 将近 2000。原因很简单每个 Mesh 都是一次独立的绘制提交。即使这些 Mesh 用的是同一个 geometry 和同一个 materialWebGL 也未必能自动合批。每个物体从 CPU 到 GPU 的状态切换、矩阵上传、绑定切换都是有开销的物一多帧率就崩。把 400 个货架的全部几何体合并成一个大的 BufferGeometry然后用一个 Mesh 来渲染Draw Call 就会从 2000 次左右直接降到 10 次以内性能会立刻上一个台阶。这里用个生活类比合并前相当于给 400 个人每个人单独端一盘菜合并后相当于把 400 份菜装进同一个大盘子一次上桌后厨压力小得多。所以只要场景里有大量静态的、材质相同的几何体第一个应该想到的优化方案就是几何体合并。2.2 mergeGeometries 到底做了什么Three.js 在BufferGeometryUtils里提供了mergeGeometries方法可以一次性把多个 BufferGeometry 合并成一个。核心原理并不高深就是把每个几何体的 position、normal、uv、color 这些 attribute 所对应的 Float32Array 按顺序拼接起来同时把索引数组重新映射、拼接然后生成一个新的更大的 BufferGeometry。这里有一个必须注意的技术点合并前要把每个几何体的顶点“变换到世界空间”。因为合并后只会保留一个网格而网格只有一个 transform位置、旋转、缩放。如果你直接拿本地坐标的几何体 merge会发现所有物体最终都挤在世界原点或者错位堆叠在一起。具体做法是先拿到每个 Mesh 的世界矩阵应用到几何体上再做合并。代码长这样import * as THREE from three; import { mergeGeometries } from three/examples/jsm/utils/BufferGeometryUtils.js; const tempMeshes []; // 假设有 500 个位置不同的箱子 for (let i 0; i 500; i) { const box new THREE.BoxGeometry(1, 1, 1); const mesh new THREE.Mesh(box, material); mesh.position.set( (Math.random() - 0.5) * 100, (Math.random() - 0.5) * 100, (Math.random() - 0.5) * 100 ); mesh.rotation.set(Math.random() * Math.PI, Math.random() * Math.PI, 0); mesh.scale.setScalar(0.5 Math.random()); mesh.updateMatrix(); // 关键把 position/rotation/scale 同步到 matrix box.applyMatrix4(mesh.matrix); // 关键把几何体顶点变换到世界空间 tempMeshes.push(box); mesh.geometry.dispose(); // 记得释放原几何体内存 } const mergedGeometry mergeGeometries(tempMeshes); const mergedMesh new THREE.Mesh(mergedGeometry, material); scene.add(mergedMesh);updateMatrix()和applyMatrix4()这两步缺一不可。前者把 mesh 的 position、rotation、scale 计算成一个矩阵后者把这个矩阵应用到几何体的每个顶点上让顶点坐标真正变成世界坐标。犯过错的都知道忘记这一步时场景里的物体会全部叠在一起怎么查都查不出来。2.3 多材质场景useGroups 的正确用法前面这段代码有个隐藏前提所有箱子用的是同一个材质。那如果场景里既有红色箱子、又有蓝色箱子呢直接合并后你会发现材质没法拆开。mergeGeometries的第二个参数useGroups就是解决这个问题的。当传入true时方法会记录每个原始几何体在合并结果中的顶点范围生成 group 信息。然后你可以给合并后的 Mesh 传一个材质数组第一个几何体对应materials[0]第二个对应materials[1]以此类推。const geometries []; const materials []; boxMeshList.forEach((mesh, index) { mesh.updateMatrix(); mesh.geometry.applyMatrix4(mesh.matrix); geometries.push(mesh.geometry); materials.push(mesh.material); // 按顺序收集材质 }); const merged mergeGeometries(geometries, true); const mergedMesh new THREE.Mesh(merged, materials);这里有个性能上的细节开启 group 后每个 group 还是占用一个 draw call所以如果几百个物体每换一个位置就换一种材质合并效果会大打折扣。正确做法是尽量让同材质物体排在一起减少 group 的数量这样才能把合并的收益最大化。同时使用mergeGeometries后通常要手动释放掉原来的几何体避免 GPU 内存泄漏。不要觉得把对象从 scene 移除就完事了geometry.dispose()和material.dispose()都要显式调用。这算是我见过很多人忽略的细节。2.4 合并的代价别盲目合并合并几何体虽然能提升渲染性能但也会带来交互上的损失。合并后你就失去了“单个 Mesh 对象”无法通过点击直接判断点的是哪个子物体也无法单独调整某个子物体的透明度、颜色、可见性。比如一个合并后的管道模型想让其中一小段变红如果没做额外处理是做不到的。所以我在做合并且需要子物体交互时一般会同时维护一份“子物体索引表”或者“包围盒列表”供拾取使用。关于这块下一部分讲点击高亮时有具体的方案。另一个不能合并的场景是动态物体。需要移动、旋转、缩放的物体一旦合并进静态大网格就没法单独变换了。如果你要做一个运动的机械臂机械臂每个关节还是得保留独立 Mesh只把周围静态场景合并就行。我的经验是静态元素合并动态元素保留原样。这是一个优先级很高的项目原则。3. 点击高亮从拾取到后处理3.1 为什么你的 Raycaster 老是选不中Three.js 里做点击交互第一反应就是 Raycaster。它做的事情就是从相机出发射出一条穿过鼠标所在位置的射线然后和场景里的物体做相交检测。用法本身不复杂const raycaster new THREE.Raycaster(); const pointer new THREE.Vector2(); window.addEventListener(click, (event) { // 换算成 NDC 坐标范围是 [-1, 1] pointer.x (event.clientX / window.innerWidth) * 2 - 1; pointer.y -(event.clientY / window.innerHeight) * 2 1; raycaster.setFromCamera(pointer, camera); const intersects raycaster.intersectObjects(scene.children, true); if (intersects.length 0) { console.log(intersects[0].object); } });新手最容易踩的坑有三个一个是clientX / innerWidth的分母用错当 canvas 没有占满整个窗口时拾取坐标会全面偏移另一个是屏幕像素到 NDC 坐标的换算漏掉了devicePixelRatio的缩放还有一个是用了相机但相机矩阵没更新导致射线方向不对。实际项目里建议至少要用canvas.getBoundingClientRect()来算坐标不能直接拿window.innerWidth糊弄。const rect renderer.domElement.getBoundingClientRect(); pointer.x ((event.clientX - rect.left) / rect.width) * 2 - 1; pointer.y -((event.clientY - rect.top) / rect.height) * 2 1;以上只是基础版本的拾取。等放到进阶项目里你会发现还有一个更隐蔽的坑合并后的几何体根本选不中“子物体”。因为场景里只有一个大 MeshRaycaster 命中的永远是这个大网格而不是你脑子里想的那个货架或箱子。3.2 高亮方案对比改材质还是走后处理选中心里想点的物体之后下一个问题是怎么“亮起来”。目前常见的方案有几种我直接给出对比表方案原理优点缺点适用场景修改材质 emissive把选中物体的自发光颜色调高实现简单、性能开销低效果偏平面合并网格中不可用物体数量少、松散独立 Mesh 的场景OutlinePass 后处理描边对选中物体边缘做轮廓描边效果明显、支持多个物体、观感好后处理管线有额外开销高分辨率卡顿大场景物体多、需要精准视觉反馈额外包围壳/复制 Mesh 放大在选中物外叠加一个半透明放大壳兼容合并网格效果可控需要跟随原物体矩阵维护较繁琐合并后的地形、区域选中如果只是做演示修改 emissive 是最快的方案代码简单到不行mesh.material.emissive.setHex(0xffaa00); mesh.material.emissiveIntensity 1.2;但你要是想让效果看起来像样比如鼠标滑过一个管道管道边缘亮一圈轮廓OutlinePass 后处理描边是更主流的选择。它不依赖每个物体的独立材质可以对一个对象数组统一描边还能控制描边颜色、透明度、厚度、脉冲频率。不过它有一个无法回避的问题性能开销。后处理是在整个画面渲染完成后额外做一次全屏处理每个像素都要跑一遍边缘检测。分辨率越高开销越大。实际做项目时我会把OutlinePass用的渲染目标分辨率降到原来的 0.5 倍再用双线性上采样拉回全屏效果损失肉眼几乎看不出来但帧率能好不少。3.3 OutlinePass 完整配置步骤完整走一遍 OutlinePass 比较复杂我直接把能跑通的代码骨架给你建议先复制到你的项目里再改参数import * as THREE from three; import { EffectComposer } from three/examples/jsm/postprocessing/EffectComposer.js; import { RenderPass } from three/examples/jsm/postprocessing/RenderPass.js; import { OutlinePass } from three/examples/jsm/postprocessing/OutlinePass.js; import { OutputPass } from three/examples/jsm/postprocessing/OutputPass.js; const composer new EffectComposer(renderer); composer.setPixelRatio(renderer.getPixelRatio()); composer.setSize(window.innerWidth, window.innerHeight); const renderPass new RenderPass(scene, camera); composer.addPass(renderPass); const outlinePass new OutlinePass( new THREE.Vector2(window.innerWidth, window.innerHeight), scene, camera ); outlinePass.selectedObjects []; // 默认选中列表为空 outlinePass.edgeStrength 3; // 边缘强度 outlinePass.edgeGlow 0.6; // 边缘发光强度 outlinePass.edgeThickness 1.5; // 边缘厚度 outlinePass.pulsePeriod 2.0; // 脉冲周期0 为不闪烁 outlinePass.visibleEdgeColor.set(0x00ffff); // 可见边缘颜色 outlinePass.hiddenEdgeColor.set(0xff0000); // 被遮挡边缘颜色 composer.addPass(outlinePass); const outputPass new OutputPass(); composer.addPass(outputPass); // 关键动画循环里必须用 composer 替代 renderer.render function animate() { requestAnimationFrame(animate); composer.render(); }当用户点击某个物体时把拾取到的 object 塞进outlinePass.selectedObjectsraycaster.setFromCamera(pointer, camera); const intersects raycaster.intersectObjects(scene.children, true); if (intersects.length 0) { const obj intersects[0].object; outlinePass.selectedObjects [obj]; // 不要 push直接赋值避免残留上次选中 } else { outlinePass.selectedObjects []; // 点击空白取消高亮 }这里我有一个经常强调的习惯selectedObjects每次点击都重新赋值而不是用push往数组里添加。否则选过的物体会一直留在高亮列表里你点一圈之后场景里同时亮起十几个东西观感很差。另外务必记得加上OutputPassThree.js r152 之后或者手动处理toneMapping。很多人都栽在这上面加了后处理之后画面变得发灰、发白或者彻底黑屏。原因就是渲染结果不再经过 renderer 默认的色调映射需要后处理链路自己带上。3.4 合并几何体下的高亮实战前面吹了 OutlinePass但你很快会发现一个问题合并后的几何体是一个巨大的 Mesh如果直接把整个合并网格设成selectedObjects那用户点一下整个工厂的 500 个箱子全亮起来完全失去意义。正确思路是把“拾取子物体”和“高亮渲染”拆开处理。我来分享一套我实际用过并且稳定的方案。合并之前先为每个原始几何体记录它的三角形索引区间。Three.js 的intersectObjects返回结果里有一个faceIndex字段表示射线命中的是合并网格的第几个三角形。知道三角形落在哪个区间就能反推出命中的是哪个原始子物体// 记录每个子几何体的三角形区间起始三角形索引 和 三角形数量 const originRanges []; let totalTriangles 0; geometries.forEach((geo, index) { const triCount geo.index ? geo.index.count / 3 : geo.attributes.position.count / 3; originRanges.push({ index, start: totalTriangles, count: triCount, }); totalTriangles triCount; }); // 点击时通过 faceIndex 查表 raycaster.setFromCamera(pointer, camera); const intersects raycaster.intersectObject(mergedMesh); if (intersects.length 0) { const faceIndex intersects[0].faceIndex; const sub originRanges.find( (r) faceIndex r.start faceIndex r.start r.count ); console.log(点中的是第, sub.index, 个子物体); }拿到sub.index之后再根据索引去你的业务数据里找对应的模型 ID、名称、属性等。高亮部分则可以这样做给合并网格单独准备一个“发光材质”但只对当前命中的子物体区域显示高亮或者用一个小的包围盒对象去替代高亮位置。如果你真的需要精确描边就得在合并前保留一份未合并的子物体列表用visible false隐藏渲染但参与逻辑或者用独立的高亮替身 Mesh。这里根据精度需求做取舍没有万能的答案。我个人的推荐是合并网格只负责渲染和性能交互判定单独维护一套轻量数据包围盒/区间表。这样既保住了性能又不牺牲交互的准确性。4. 进阶实战中的常见问题与排查记录4.1 合并后颜色丢了、或场景全乱这是一个出现频率极高的问题。颜色丢失通常和 UV 或者顶点色有关但最常见的原因其实是原几何体带多个材质但合并时没传useGroups true结果合并结果只保留了一个材质槽其他材质全部丢掉了。处理办法就是前面讲过的合并时打开 group传材质数组。场景全乱通常是矩阵没有应用。我在调试时遇到过一次最后发现是一个mesh.scale.setScalar(0.5)忘记在合并前更新矩阵结果部分箱子的大小和位置全都不对。这个问题的排查诀窍是合并之前先把所有mesh.matrixWorld打出来看一眼如果出现大量相同值就说明updateMatrixWorld没触发。4.2 后处理之后画面灰蒙蒙或黑屏排序靠前的问题前面已经提到 OutputPass 和 toneMapping。很多项目在 r150 之前的写法是不带 OutputPass 的升级版本后就不能直接照搬旧代码。另外做后处理时要注意composer.setSize和renderer.setSize保持一致最好是同时处理 window resize 事件window.addEventListener(resize, () { camera.aspect window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); composer.setSize(window.innerWidth, window.innerHeight); });4.3 OutlinePass 闪烁、漏选、多个物体高亮残留闪烁多半跟描边参数有关把pulsePeriod设为 0 可以关闭脉冲如果还闪就检查是否在后续渲染中覆盖了selectedObjects。漏选则要关注 Raycaster 的远近裁剪面相机near设得过大会导致近处物体无法选中far设得过小会导致远处物体无法选中别动不动就学别人设成 0.1 和 10000要跟你的场景尺度匹配。多个物体高亮残留这个坑也很大。很多新手这么写outlinePass.selectedObjects.push(obj);这会把历史选中全部保留。正确做法是outlinePass.selectedObjects [obj];4.4 常见问题速查表现象可能原因排查/解决建议合并后物体全部堆在原点未调用updateMatrix或applyMatrix4合并前遍历 mesh先 updateMatrix 再 applyMatrix4合并后部分材质丢失未开启useGroups true传入第二个参数 true并给 Mesh 传材质数组点击高亮没有反应坐标换算用了window.innerWidth改用canvas.getBoundingClientRect()计算 NDC 坐标后处理开启后画面灰白缺少OutputPass添加 OutputPass并检查 toneMapping 的顺序OutlinePass 高亮残留selectedObjects.push导致旧对象未清除每次点击直接赋值selectedObjects [obj]高分辨率下 OutlinePass 掉帧后处理全屏开销过大降低后处理渲染目标分辨率再上采样回全屏合并网格点击后整个场景高亮未做子物体索引换算用faceIndex配合三角形区间查表定位子物体5. 一些个人进阶体会最后分享一点实际操作里的经验。合并几何体和点击高亮这两个功能我是分开学的但真正把它们放在一起做项目时才发现有冲突。合成一个万级三角形的场景时如果不提前做子物体索引表高亮根本没法精准落到业务元素上。踩过几次坑后我形成了自己的一套固定套路静态场景元素尽量合并但合并前先建立“三角形区间表 业务数据表”的映射交互拾取用区间表反查高亮用 OutlinePass 或者替身 Mesh 方案灵活切换。这个套路已经在两个项目里稳定跑过性能数据和交互反馈都符合预期。Three.js 进阶的过程里还会遇到很多“看似都会、组合起来就炸”的问题。但不要慌把问题拆成渲染、数据、交互三块去定位基本都能找到原因。如果你也在做类似方向建议把文中这些代码自己敲一遍改一改参数看效果变化比单纯收藏有用得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价