微信小程序中使用Three.js实现3D模型展示的实战指南 📅 发布时间:2026/9/9 0:35:38 👁 浏览次数: 简介微信小程序中集成Three.js实现3D模型展示的开发资料适合希望在小程序端快速上手3D渲染的前端开发者。资源以完整可运行的Demo形式呈现包含虾模型gltf文件及通过url引用的示例开发者可基于源码中的函数自定义旋转、掉落等动画效果有效规避小程序与Three.js兼容性坑点。压缩包共18个文件涵盖8个JavaScript逻辑脚本、5个JSON配置文件、2个WXSS样式、2个WXML页面结构及1个gltf模型文件容量仅258KB结构轻量清晰便于按需修改。已有2300人学习查看源码目录包含pages页面、components组件、utils工具及3Dmodels模型等模块可直接导入微信开发者工具运行适合用于商品展示、互动小游戏等3D场景的快速原型搭建。 做微信小程序3D展示的第一周我差点把电脑屏幕盯出裂纹。用threejs加载一个glb格式的工业零件模型在开发者工具里跑得好好的一上真机就是黑屏本地预览流畅上传到体验版就报错。后来把渲染器、Canvas类型、模型压缩全部重查一遍才算真正跑通。这个项目折腾下来最大的感受是微信小程序使用threejs实现3D模型的展示技术选型不难难的是小程序这套运行环境里每一个细枝末节的适配。这篇文章不打算给你堆官方文档而是把我实际踩过的坑、验证过的做法、以及最后沉淀下来的项目流程完整写出来。适合三类人看一是准备在小程序里做产品展示、工业模型、数字展厅的开发者二是用threejs做交互但被小程序环境卡住的人三是拿这类项目做毕业设计或作品集的学生。无论你从哪一类进来照着文中步骤走都能省下至少一周的试错时间。1. 先想清楚小程序里真的需要用threejs吗1.1 为什么是threejs而不是Unity或原生WebGL我在项目启动前其实犹豫过一阵子。同样做3D展示Unity在小游戏适配里的方案已经比较成熟原生WebGL也能写为什么偏偏选threejs主要是三方面考虑。第一Unity方案本质上是把整个引擎运行时打包进小程序即便裁剪过的适配方案产物体积依然偏大。对于一个以“展示模型”为单一功能的产品来说这是用牛刀杀鸡用户光加载就会流失一批。第二原生WebGL写一个能用的pbr渲染器从矩阵计算到材质系统全得自己造轮子项目周期根本不允许。第三也是我最看重的threejs的glTF/GLB加载链路非常成熟建模软件导出的模型几乎不需要额外开发就能进场景社区里能抄的轮子也多遇到问题不至于一个人硬扛。如果你的诉求不是单纯展示而是重度游戏化交互角色控制、物理引擎、多人在线那Unity或者小游戏专用引擎更合适。但如果你的核心是“把一个高精模型漂亮地转起来、能看能点”threejs就是性价比最高的答案。1.2 小程序渲染环境对threejs意味着什么小程序的运行环境和浏览器有本质区别。浏览器里写threejs你有一个完整的DOM、一个全局的window对象、一个标准的WebGL上下文小程序里这些概念全都变了。最典型的是Canvas小程序里不是document.createElement(canvas)而是要显式声明typewebgl的Canvas组件然后通过SelectorQuery去拿节点实例。这意味着threejs官方适配浏览器的那些API在小程序里要手动替换一部分canvas.requestAnimationFrame代替window.requestAnimationFramecanvas.createImage()代替new Image()触摸事件要用小程序特有的touchend/touchmove而不是mousedown。这些替换不复杂但如果你不知道第一版代码在真机上就是白屏或直接报错。另一个被忽视的点是包体积限制。threejs核心库压缩后约600KB左右一个中等精度的GLB模型动辄几MB。主包2MB的硬限制摆在那模型放本地基本不现实必须走网络加载或分包。这就牵扯到域名白名单、加载策略、loading状态工作量比想象中多。2. 环境准备Canvas类型与基础库版本是第一个隐形门槛2.1 基础库版本选择与webgl Canvas配置先交代我的环境基础库版本我建议直接锁在2.14.0以上项目里我实际用的2.19.2。微信官方从2.9.0开始提供WebGL支持的Canvas但在早期版本上threejs的兼容问题很多比如纹理上传报错、帧缓冲不完整等。如果你不想在处理版本兼容上浪费时间就按我这个底线来。WXML里的Canvas声明是关键少一个type属性整个项目都会跑不起来canvas typewebgl idmodelCanvas stylewidth: 100%; height: 100%;/canvas注意typewebgl必须显式写出。如果你的代码不写type默认走的是普通2D Canvasthreejs初始化时找不到WebGL上下文会直接抛错。而且id要和后续查询节点时保持一致很多人复制代码时改名改漏排查半天才发现。在JS里获取Canvas实例的方式也和H5不同const query wx.createSelectorQuery() query.select(#modelCanvas) .fields({ node: true, size: true }) .exec((res) { const canvas res[0].node const width res[0].width const height res[0].height initThree(canvas, width, height) })这里的fields({ node: true, size: true })两个字段都要。node返回Canvas实例size返回画布实际宽高。我在第一次写的时候只拿了node导致后面渲染尺寸一直是0画面什么都不显示。2.2 threejs引入方式与npm构建小程序里没有script标签所有依赖都要以模块方式加载。最简单粗暴的做法是把three.min.js下载下来放到libs目录然后用require引入const THREE require(../../libs/three.min.js)这种方式胜在可控不依赖构建链路。但如果你想管理后续升级还是建议走npm构建。在项目根目录执行npm init -y npm install three然后在微信开发者工具里点击“工具 - 构建npm”构建完成后再引用import * as THREE from three有一点必须提醒改用npm方式后开发者工具会生成miniprogram_npm目录这个目录要提交到代码仓库不然别人拉下来跑不了。真机预览时也要确保npm构建产物存在否则会报module three is not defined。整个流程我实测下来比直接放libs文件要麻烦一些但代码提示和版本管理好很多。另外小程序环境下不能直接用import { GLTFLoader } from three/examples/jsm/loaders/GLTFLoader.js这类ESM写法需要根据构建工具调整。不想折腾的话直接引入three/examples/js/loaders/GLTFLoader.js这种UMD版本或者自己封装一个loader都是更稳的路径。3. 模型从哪来格式转换与压缩才是项目进度的大头3.1 从CAD/BIM到glTF/GLB的转换链路很多项目里的模型根本不是给Web准备的。工业零件可能是SolidWorks导出的STP建筑模型可能是Revit导出的FBX这些格式没法直接给threejs用。你需要在中间加一道转换工序把模型统一转成glTF 2.0格式最好是二进制GLB文件更小、加载更快。我的习惯转换链路是这样如果源文件是FBX/OBJ先导入Blender检查材质、贴图和坐标轴方向再导出GLB。如果是STP/IGES这类CAD格式用FreeCAD或在线转换工具先转成STL或OBJ再进Blender处理。这里有个容易忽略的坑导出GLB前一定要检查坐标轴。threejs默认Y轴向上而很多CAD软件导出时是Z轴向上直接进场景模型会横躺在地上。在Blender里选中模型按CtrlA应用旋转确保物体坐标轴和世界坐标轴一致再导出。另一个常见问题是模型面数。CAD直接转换出来的曲面网格往往多到离谱一个螺栓都可能给你几十万三角面。我接过一个矿车车架模型原始面数两千多万直接加载连开发者工具都卡死。解决办法是在Blender里用“Decimate”修改器做减面根据展示距离保留细节主体部件减到30%左右贴图细节的部件可以保留多一些。3.2 用gltf-transform做纹理压缩与Draco压缩模型过了Blender这关之后我还会再过一遍工具链目的是把GLB体积压到合理范围。这里强烈推荐gltf-transform一个命令行工具能一次完成纹理压缩、网格压缩、节点清理。npx gltf-transform/cli optimize model.glb model-optimized.glb --texture-compress webp --compress draco这条命令的含义是将原模型压缩纹理为WebP格式同时用Draco算法压缩网格数据。实测一个6MB的GLB压缩完能到1.2MB左右加载速度提升非常明显。不过要提醒一点--texture-compress webp要看真机兼容性。小程序Canvas的createImage在iOS和Android上对WebP支持不完全一致如果你发现某个机型纹理加载不出来退化方案是只做--compress draco纹理保持JPG/PNG格式尺寸压到1024或512。面数控制方面我的经验是静态展示类项目主模型总三角面控制在10万到50万之间要做旋转交互的模型30万以下带剖切、爆炸图动画的20万左右最稳妥。不要一味追求高精度手机GPU性能和内存都有限过度堆面数只会换来闪退。压缩之后的模型文件我建议放到云存储或自己的服务器走网络加载而不是塞在小程序包里。原因很简单主包体积有限而且后续换模型不需要发版。记得在微信公众平台的“开发管理 - 服务器域名”里配置好downloadFile合法域名不然真机上模型会被拦截加载失败。4. 加载渲染核心流程从glTF到屏幕上的像素4.1 场景、相机、渲染器初始化模型拿到手开始写代码。先把最基础的场景搭起来这一步直接决定后续所有功能的根基。// 假设已经通过SelectorQuery拿到了canvas const canvas res[0].node const width res[0].width const height res[0].height const renderer new THREE.WebGLRenderer({ canvas: canvas, antialias: true, alpha: true }) renderer.setSize(width, height) renderer.setPixelRatio(Math.min(wx.getSystemInfoSync().pixelRatio, 2)) renderer.setClearColor(0xf7f7f7, 1) const scene new THREE.Scene() const camera new THREE.PerspectiveCamera(45, width / height, 0.1, 1000) camera.position.set(0, 3, 8) // 基础灯光环境光方向光让模型有立体感又不至于过曝 const ambientLight new THREE.HemisphereLight(0xffffff, 0x444444, 1.2) scene.add(ambientLight) const dirLight new THREE.DirectionalLight(0xffffff, 1) dirLight.position.set(5, 10, 7) scene.add(dirLight)这里有两个小细节要划重点。第一是setPixelRatio(Math.min(wx.getSystemInfoSync().pixelRatio, 2))不限制像素比的话部分安卓机的devicePixelRatio能到3甚至4渲染压力直接翻好几倍真机上帧率会很难看。第二是antialias: true在部分安卓机型上可能会有性能问题如果掉帧严重可以关掉它靠高分辨率抵消锯齿。4.2 模型加载、自动取景与渲染循环加载GLB模型时我建议不要把路径写死而是封装成可配置项。网络加载用官方loader.load就行const loader new GLTFLoader() loader.load( https://your-domain.com/models/product.glb, (gltf) { const model gltf.scene scene.add(model) // 自动取景计算模型包围盒把相机拉到合适位置 const box new THREE.Box3().setFromObject(model) const size box.getSize(new THREE.Vector3()) const center box.getCenter(new THREE.Vector3()) const maxDim Math.max(size.x, size.y, size.z) const fov camera.fov * (Math.PI / 180) let cameraZ Math.abs(maxDim / Math.tan(fov / 2)) cameraZ * 1.2 // 留一点边距 camera.position.set(center.x, center.y, center.z cameraZ) camera.lookAt(center) // 将模型中心对齐到原点方便后续旋转控制 model.position.sub(center) }, (xhr) { // 加载进度回调可用来更新进度条 const percent Math.floor((xhr.loaded / xhr.total) * 100) }, (err) { console.error(模型加载失败, err) } )这里自动取景的算法是通用的根据模型包围盒的最大边长结合相机fov算出相机应该离模型多远。这段逻辑本身就是抄作业级别的建议谁用谁拿走。渲染循环我推荐用renderer.setAnimationLoop而不是自己写setInterval或requestAnimationFrame因为threejs内部会处理帧率控制和上下文丢失恢复。循环里只需要做两件事更新控制器状态、调用renderer.render。renderer.setAnimationLoop(() { controls.update() renderer.render(scene, camera) })加载进度这个回调在真机上要注意有些CDN开启了gzip后xhr.total可能不准甚至为0导致百分比跳到负数。稳妥的做法是进度不到100%时只显示“加载中”到了100%才切换为“加载完成”。5. 交互与控制触摸事件、相机漫游与模型高亮5.1 自己实现旋转缩放OrbitControls在小程序里的替代方案如果你直接在threejs官方示例里找OrbitControls引入后八成会报错因为它依赖DOM鼠标事件和window对象。在小程序里要么自己写触摸控制要么把OrbitControls源码改造成小程序可用版本。我的做法是自己写了一个精简控制器核心思路是水平滑动改变yaw角垂直滑动改变pitch角双指捏合控制缩放距离。这种实现最可控也最容易配合产品需求定制。let yaw 0 let pitch 0 let distance 8 let lastTouchX 0 let lastTouchY 0 let lastPinchDist 0 canvas.addEventListener(touchstart, (e) { const t e.touches if (t.length 2) { lastPinchDist getDistance(t[0], t[1]) } else { lastTouchX t[0].x lastTouchY t[0].y } }) canvas.addEventListener(touchmove, (e) { const t e.touches if (t.length 2) { const dist getDistance(t[0], t[1]) const delta lastPinchDist - dist distance Math.min(20, Math.max(4, distance delta * 0.02)) lastPinchDist dist } else if (t.length 1) { const dx t[0].x - lastTouchX const dy t[0].y - lastTouchY yaw - dx * 0.01 pitch - dy * 0.01 pitch Math.max(-Math.PI / 3, Math.min(Math.PI / 3, pitch)) lastTouchX t[0].x lastTouchY t[0].y } updateCameraPos() })注意这里触控点的坐标用的是t[0].x和t[0].y不是clientX和clientY。小程序的Touch对象里直接提供了相对Canvas的x/y坐标拿clientX反而会因为页面滚动而错位。这是我调了半天发现的问题官方文档写得不明显很多人会在这里栽跟头。updateCameraPos用球坐标公式把yaw、pitch、distance转成相机位置function updateCameraPos() { const phi pitch const theta yaw camera.position.x distance * Math.sin(phi) * Math.cos(theta) camera.position.y distance * Math.cos(phi) camera.position.z distance * Math.sin(phi) * Math.sin(theta) camera.lookAt(0, 0, 0) }缩放限制在[4, 20]之间是为了防止相机穿模或者缩得太远看不到模型。这个范围你可以根据模型实际大小调整。5.2 射线拾取与模型高亮模型点选是展示类项目里很常见的需求比如点选零件显示名称、高亮某个部件。threejs里的标准做法是射线检测小程序里一样能用但坐标系转换要看清楚。function onTap(e) { const touch e.touches[0] || e.changedTouches[0] // 把触摸点坐标转换为Canvas上的归一化设备坐标NDC const rect canvas.getBoundingClientRect() // 小程序Canvas节点没有getBoundingClientRect需要用节点信息查询 const query wx.createSelectorQuery() query.select(#modelCanvas).boundingClientRect((rect) { const x ((touch.x - rect.left) / rect.width) * 2 - 1 const y -((touch.y - rect.top) / rect.height) * 2 1 raycaster.setFromCamera({ x, y }, camera) const intersects raycaster.intersectObjects(scene.children, true) if (intersects.length 0) { const obj intersects[0].object handleModelSelect(obj) } }).exec() }这里的关键是坐标变换触摸点在页面的坐标减去Canvas左上角坐标再除以Canvas宽高映射到[-1, 1]区间。因为小程序里touches[0].x/y已经是相对Canvas左上角的坐标所以其实可以简化成const x (touch.x / canvasWidth) * 2 - 1 const y -(touch.y / canvasHeight) * 2 1高亮方案我试过几种最简单稳定的是修改材质自发光function highlightModel(mesh) { if (lastSelectedMesh) { lastSelectedMesh.material.emissive.set(lastEmissiveColor) } lastSelectedMesh mesh lastEmissiveColor mesh.material.emissive.getHex() mesh.material.emissive.set(0xffaa00) }这个方法对标准材质都有效而且代码量最少。但要注意如果模型是合并后的单一Mesh无法按零件单独高亮。这时候就需要在建模阶段对每个零件设置不同的userData或者在Blender里给零件拆分命名加载后用traverse按名字分别记录。如果你有几十上百个零件要单选高亮又不想拖垮性能建议把同材质的零件先做几何体合并每个零件保留一根射线检测用的简化碰撞体这样draw call从几十降到几个检测精度也不受影响。这个思路在工业装配体模型上特别管用我从一个threejs合并几何体的项目里学到的实测帧率提升非常明显。6. 真机调试中的性能与常见坑6.1 白屏与不显示的排查链路真机白屏是小程序3D项目最高频的问题而且原因五花八门。我整理了一个排查顺序按这个链路走基本能定位90%的问题第一步确认基础库版本。开发者工具中“详情 - 本地设置 - 调试基础库”看一下是不是低于2.9.0。低于这个版本WebGL Canvas完全不支持白屏是必然的。第二步确认Canvas类型。WXML里有没有写typewebgl。这一步最容易被忽视很多人把H5的Canvas写法直接搬过来代码看着没问题实际上根本没走WebGL通道。第三步看控制台报错。如果是WebGL not supported说明当前环境拿不到WebGL上下文优先查基础库和Canvas配置。如果是model is not defined查加载路径。如果是纹理相关的报错查WebP兼容性换成JPG再试。第四步检查模型尺寸和内存占用。真机的内存限制比开发者工具严格很多一个几十MB的GLB在开发者工具里没问题真机上加载到一半直接被系统杀掉。这种情况不会报错只会白屏或闪退。解决办法是压缩模型或者做LOD分级加载。第五步排查downloadFile域名。模型走网络加载时域名必须在微信公众平台配置过且需要ICP备案。开发阶段可以在开发者工具里勾选“不校验合法域名”但真机预览必须过白名单。6.2 帧率优化与资源释放的实操经验真机上跑3D帧率是最容易翻车的地方。我的优化清单按优先级排列如下像素比严格限制到2甚至低端机可以限制到1.5。这是投入产出比最高的优化一个参数能省下一半的GPU负载。纹理尺寸统一走1024或512。很多建模师导出的贴图动辄2048或4096在移动端屏幕上根本看不出区别但显存占用差好几倍。阴影质量降级或关闭。threejs默认不开启阴影如果你开了renderer.shadowMap建议在真机上先关掉阴影在移动端的计算开销很大非核心展示功能不建议开。几何体合并。工业模型动辄几百个零件每个零件一个Mesh就是几百次draw call。用BufferGeometryUtils.mergeBufferGeometries合并同材质几何体性能提升是立竿见影的。缺点的确是零件独立拾取会失效但可以在交互上用另一个简化模型做射线检测来弥补。关于资源释放小程序页面销毁时不会自动回收WebGL资源需要手动处理。我在onUnload里这样清理onUnload() { if (renderer) { renderer.setAnimationLoop(null) renderer.dispose() } scene.traverse((child) { if (child.geometry) child.geometry.dispose() if (child.material) child.material.dispose() }) }这个步骤不做的话用户在小程序里反复进出3D页面内存会被WebGL上下文慢慢吃光最终微信会直接把小程序杀掉。这个问题在低端Android机型上尤其明显页面进出三次左右就会闪退。关于低端机型的降级策略我的做法是在页面加载时用wx.getSystemInfoSync()判断手机型号和内存对于内存低于4GB的机型直接渲染一张预置的高清模型截图交互简化为图片的轻量放大缩小。虽然不那么酷但至少用户打开不会卡死。等用户设备性能足够时再走完整3D方案。现在我做小程序3D项目第一步永远是先要模型拿压缩工具处理完验一遍面数再开始写页面。整套流程走顺之后从模型到上线基本能控制在一周内。对于刚开始接触这个方向的同学先按本文把环境跑通加载一个最简单的glb立方体再逐步叠加交互、优化和降级策略——3D展示这件事小程序平台虽然限制多但只要摸清它的脾气能做出来的效果远比想象中多。本文还有配套的精品资源点击获取