
简介面向微信小程序开发者的Three.js 3D模型展示示例专注于解决在小程序端加载并交互展示gltf模型的常见痛点。资源以一个虾模型为实际素材完整演示了模型上传服务器、wxml页面url引用、三维场景搭建、模型加载与旋转、掉落等基础动画控制并针对小程序环境下的兼容性做了实际验证帮助前端开发者有效规避适配坑。压缩包共18个文件包含8个js脚本、5个json配置文件、2个wxml页面、2个wxss样式以及1个gltf模型目录结构与原生小程序工程高度一致便于直接导入学习或二次定制。目前已有2305人学习使用。通过阅读源码可清晰掌握Three.js在小程序中的封装与调用思路替换模型并调整js函数即可实现不同的3D交互效果适合产品展示、教学演示等应用场景。1. 微信小程序里的 three.js先解决“没有 DOM”这件事微信小程序里用 three.js 做 3D 模型展示听起来就是把 Web 端的代码抄过来改改路径真上手的人都知道一跑就是一个白屏console 里连个报错都懒得给你。这个标题真正的技术含量不在 three.js 本身而在小程序环境和浏览器环境的三个差异没有 window 和 document、canvas 组件需要自己拿节点、网络请求和本地文件读取都不是浏览器那套 API。把这三个差异处理掉3D 展示才能从“能跑”变成“能上线”。这篇文章按我实际做过的方案从最小场景一直写到手势交互、踩坑和真机验证适合正在做商城商品展示、家具预览、工业模型查看这类需求的人照着落地。2. 最小可跑场景从 canvas 到第一个立方体2.1 canvas 组件与 WebGL 上下文的获取方式小程序里所有渲染都要落在 canvas 组件上而且这个 canvas 不是浏览器里 document 创建的是 WXML 声明出来的节点。关键点是 canvas 的 type 属性必须写成webgl不写或者写成2d后面getContext(webgl)拿到的就是 null这一步错了后面全是白费。!-- index.wxml -- canvas idmodelCanvas typewebgl stylewidth: 750rpx; height: 600rpx;/canvas// index.js Page({ onReady() { const query this.createSelectorQuery(); query.select(#modelCanvas) .fields({ node: true, size: true }) .exec((res) { const canvas res[0].node; const gl canvas.getContext(webgl); if (!gl) { console.error(获取 WebGL 上下文失败); return; } this.canvas canvas; this.gl gl; this.initThree(canvas, gl); }); } });fields({ node: true, size: true })会把 canvas 的底层节点和像素尺寸一起取出来size 是 px 单位不是 rpx后续渲染器设置尺寸靠的就是它。getContext(webgl)是小程序基础库提供的接口拿到的是能被 three.js 直接使用的 WebGL 上下文。注意这里不能像 Web 端那样new THREE.WebGLRenderer()之后让 three.js 自己去建 canvas小程序里没有 document必须把外部 canvas 和上下文传进去。常见做法是initThree(canvas, gl) { const renderer new THREE.WebGLRenderer({ canvas: canvas, context: gl, antialias: true }); this.renderer renderer; }传入 context 之后three.js 不会再尝试创建自己的上下文而是复用小程序提供的这个这是适配层能工作的核心。antialias: true在真机上对低端 GPU 有额外开销后面性能紧张时可以关掉。2.2 渲染循环与像素密度为什么真机发糊场景、相机、模型都搭好之后需要手动把渲染器尺寸设置成 canvas 的实际像素宽高。很多人在模拟器上看没问题一到真机就发糊十有八九是没处理 devicePixelRatio。小程序的 canvas 节点宽 750rpx在 iPhone 上实际像素宽度是 375 * 3dpr 为 3如果只按 CSS 像素去 setSize渲染分辨率只有逻辑分辨率肯定糊。const info wx.getSystemInfoSync(); const width res[0].width; // 逻辑像素 const height res[0].height; renderer.setPixelRatio(info.pixelRatio); renderer.setSize(width, height);setPixelRatio让 three.js 按照物理像素渲染setSize设置的是渲染尺寸。这两个配合好真机上的 3D 模型边缘才锐利。代价是像素数变成原来的 dpr 倍低端机压力会大后面可以做成动态开关高性能机型开满低端机强制 dpr 为 2。然后是渲染循环。小程序里requestAnimationFrame可用但要记得在onHide里取消不然页面切后台后动画还在跑白白耗电。startLoop() { const animate () { this.rafId requestAnimationFrame(animate); if (this.mesh) { this.mesh.rotation.y 0.01; } this.renderer.render(this.scene, this.camera); }; animate(); }, onHide() { if (this.rafId) { cancelAnimationFrame(this.rafId); } }循环里每一帧都重新 render这是 three.js 的标准做法。注意this.renderer.render必须在拿到 scene 和 camera 之后调用不然第一次渲染会得到一张空图。另一个新手常踩的点是用了MeshStandardMaterial但场景里没加灯光模型显示成黑色。这种材质依赖光照计算没有灯就一片黑调试时会误以为加载失败。2.3 从零搭一个能转的立方体完整最小代码把上面几段串起来就能得到一个完整的最小场景。这个场景虽然简单但验证了“小程序环境里 three.js 能不能正常初始化、能不能渲染、能不能动”这三个最关键的问题。initThree(canvas, gl) { const renderer new THREE.WebGLRenderer({ canvas, context: gl, antialias: true }); const info wx.getSystemInfoSync(); const width canvas.width / info.pixelRatio; const height canvas.height / info.pixelRatio; renderer.setPixelRatio(info.pixelRatio); renderer.setSize(width, height); const scene new THREE.Scene(); const camera new THREE.PerspectiveCamera(45, width / height, 0.1, 100); camera.position.set(0, 0, 5); camera.lookAt(0, 0, 0); const light new THREE.DirectionalLight(0xffffff, 1); light.position.set(5, 5, 5); scene.add(light); const mesh new THREE.Mesh( new THREE.BoxGeometry(1, 1, 1), new THREE.MeshStandardMaterial({ color: 0x4488ff }) ); scene.add(mesh); this.scene scene; this.camera camera; this.mesh mesh; this.renderer renderer; this.startLoop(); }canvas.width拿到的是物理像素宽除以pixelRatio得到逻辑宽这样传给setSize的尺寸和 WXML 上声明的一致。相机放 z5 是因为边长 1 的立方体在 45° 视角下z5 能完整看到且留有余量。DirectionalLight的位置影响高光方向习惯放在右前上方阴影效果更自然。3. 加载真实模型glTF/GLB 在小程序里的完整链路3.1 为什么选 glTF/GLB 而不是 OBJ 和 FBX模型文件格式的选择直接决定后续加载代码写起来痛不痛。我在小程序项目里只推荐 glTF 的二进制格式 GLB原因很实际第一GLB 把网格、材质、纹理全部打包进一个二进制文件没有外部依赖本地分包和网络请求都省事第二glTF 是显式面向实时渲染设计的PBR 材质属性直接映射到 three.js 的材质系统不需要自己写转换逻辑第三OBJ 不带材质信息FBX 格式解析复杂且体积大都不适合移动端。格式纹理嵌入材质标准小程序加载成本场景适配OBJ外部文件无固定标准高需手工处理不推荐FBX可内嵌依赖 DCC 导出设置高解析库体积大不推荐glTF外部引用PBR 标准中可用GLB单文件内嵌PBR 标准低强烈推荐用 GLB 还有个好处是模型自带的坐标轴信息是标准的 Y-upthree.js 也是 Y-up导进来不用做坐标转换。常见做法是在 Blender 里建模或转换导出时勾选Binary格式压成 GLB 后再做后续瘦身。3.2 wx.request GLTFLoader.parse加载链路的改造three.js 自带的 GLTFLoader 依赖浏览器环境里的XMLHttpRequest在小程序里根本不存在。所以不能直接loader.load(url, callback)需要先用小程序自己的网络接口把 GLB 文件读成 ArrayBuffer再交给GLTFLoader.parse()解析。这个过程不需要改 three.js 内核只是把“拿文件”这一步替换掉。loadModel(url) { wx.request({ url: url, responseType: arraybuffer, success: (res) { if (res.statusCode ! 200) { console.error(模型下载失败, res.statusCode); return; } const loader new THREE.GLTFLoader(); loader.parse(res.data, , (gltf) { this.addModelToScene(gltf); }, (err) { console.error(GLTF 解析失败, err); }); }, fail: (err) { console.error(请求失败, err); } }); }responseType: arraybuffer是这里的关键默认的 responseType 是文本GLB 是二进制文件拿不到 ArrayBuffer 解析必失败。parse()的第二个参数是资源路径GLB 纹理已经内嵌传空字符串即可。如果模型文件放在了小程序代码包里比如分包目录下那就不需要 wx.request改用wx.getFileSystemManager().readFile读取本地文件同样拿到 ArrayBuffer流程一样。解析成功后gltf.scene是一个标准的THREE.Object3D模型的所有网格、材质、节点层级都在里面直接scene.add(gltf.scene)就行。注意 gltf 的场景可能自带 transform如果模型显示很大或很小先看它的包围盒尺寸不要一上来就缩放。3.3 模型瘦身与分包策略别让模型拖垮首屏模型文件体积和面数是小程序 3D 展示的两个硬约束。代码包体积有限GLB 放主包里会挤占空间放分包里又涉及分包加载时序。我的经验是面向展示场景的模型面数控制在 5 万面以内纹理单张不超过 1024整包控制在 2MB 以下。超过这个量级低端安卓机加载时会卡住渲染主线程用户看到的就是白屏加转圈。瘦身习惯第一步在建模软件里删除不可见的面和隐藏在模型内部的重叠网格第二步把纹理导出成 JPG 或 WebP很多 GLB 默认带的是 PNG 或未压缩的 TGA体积能差出几倍第三步是合并材质相同或相近的 Mesh减少 draw call。对展示类模型LOD 可以先不做但面数一定要压。如果模型来自第三方平台下载可以在导出时勾选网格简化工具保留 90% 视觉精度基本看不出差别。4. 手势交互旋转、缩放、平移在小程序端的 three.js 实现4.1 触点坐标到旋转增量事件里的数学处理模型能显示之后下一步就是让用户能转着看。小程序的触摸事件绑定在 canvas 组件上事件对象里的touches数组提供clientX和clientY我们用它做增量计算。最朴素也最可靠的做法是单指拖动旋转模型把横向位移映射到模型绕 Y 轴旋转纵向位移映射到绕 X 轴旋转。onTouchStart(e) { const t e.touches[0]; this.lastX t.clientX; this.lastY t.clientY; }, onTouchMove(e) { const t e.touches[0]; const dx t.clientX - this.lastX; const dy t.clientY - this.lastY; this.lastX t.clientX; this.lastY t.clientY; // 旋转系数根据 canvas 宽度归一化避免大屏转太快、小屏转太慢 const factor 0.01; this.targetRotY dx * factor; this.targetRotX dy * factor; }这里只更新目标角度不直接改模型 rotation。原因很简单touchmove 事件的触发频率远高于渲染帧率如果每次事件都直接写model.rotation会出现旋转一顿一顿、和渲染不同步的现象。把目标值存下来在渲染循环里做插值手感会顺滑很多。4.2 双指缩放与相机距离控制缩放展示常见的实现是双指捏合。两指之间的距离变化映射到相机距离的变化距离越小模型看起来越大。实现时要在 touchstart 时记录两个触点的初始距离touchmove 时计算当前距离两者比值作为缩放因子。onTouchStart(e) { if (e.touches.length 2) { const dx e.touches[0].clientX - e.touches[1].clientX; const dy e.touches[0].clientY - e.touches[1].clientY; this.pinchStartDist Math.sqrt(dx * dx dy * dy); this.pinchStartZoom this.camera.position.z; } }, onTouchMove(e) { if (e.touches.length 2) { const dx e.touches[0].clientX - e.touches[1].clientX; const dy e.touches[0].clientY - e.touches[1].clientY; const dist Math.sqrt(dx * dx dy * dy); const ratio dist / this.pinchStartDist; // 限制缩放范围防止相机穿进模型 this.targetZoom Math.min(8, Math.max(2, this.pinchStartZoom / ratio)); } }相机距离最小值设 2、最大值设 8 是经验值具体要看模型大小。如果模型边长是 1 个单位相机最近不能小于 1.5 倍包围盒半径否则视角会穿模最远不能超过 5 倍否则模型小到没意义。这个区间应该动态计算而不是写死。4.3 插值阻尼让转动不跟手也不抖目标值有了渲染循环里做平滑插值。用线性插值lerp让模型的当前角度逐步逼近目标角度每帧逼近 10% 左右阻尼感比较自然。animate() { this.rafId requestAnimationFrame(() this.animate()); if (this.model) { this.model.rotation.y (this.targetRotY - this.model.rotation.y) * 0.1; this.model.rotation.x (this.targetRotX - this.model.rotation.x) * 0.1; } this.camera.position.z (this.targetZoom - this.camera.position.z) * 0.12; this.renderer.render(this.scene, this.camera); }0.1是阻尼系数数值越接近 1 跟手性越强但容易产生抖动越接近 0 越顺滑但会有“跟不上手”的延迟感。0.08 到 0.15 是手感比较舒服的区间。相机 z 值的插值系数可以略大比如 0.12因为缩放通常比旋转更追求即时反馈。这套“目标值 插值”的写法是小程序端 three.js 交互里最不容易翻车的方案比直接赋值多写几行但手感稳定得多。5. three.js 小程序落地避坑五个高频问题排查记录5.1 真机白屏WebGL 上下文创建的排查路径现象模拟器上模型显示正常一到真机就白屏console 面板干干净净没有任何报错。这个是最磨人的因为开发者工具环境比真机宽松太多。原因基础库版本过低或者机型 GPU 驱动对 WebGL 支持不完整。小程序的 canvas 组件从某个基础库版本才开始支持typewebgl低于这个版本的设备上getContext(webgl)直接返回 null但代码里没判断后续所有 three.js 操作都失败。解决在getContext(webgl)之后立刻做判空写降级逻辑——要么提示用户升级微信要么展示一张静态图预览代替 3D。我用一段兜底代码if (!gl) { this.setData({ unsupported: true }); return; }同时在后台配置里把最低基础库版本调到支持 WebGL 的版本。这样既不闪退也能给用户明确反馈。另一类白屏是工具问题和真机差异的“玄学”开发者工具上换了个机型模拟结果就不一样但真机又和模拟器完全无关。我的习惯是白屏问题一律真机调试别在工具里空转。5.2 模型不显示从加载到渲染的逐段检查现象three.js 初始化没报错代码逻辑看起来全对但模型就是不出来屏幕上只有背景色。原因大概率有三个。第一是 GLB 解析后模型的尺寸不在相机视野内比如一个包围盒边长 100 的模型相机在 z5 的位置朝原点看模型巨大且相机在模型内部自然什么都看不见。第二是模型没有加到 scene 上addModelToScene里记了this.model gltf.scene但忘了this.scene.add(this.model)这种低级错误要排第一位。第三是材质的贴图没加载出来模型显示成黑色或透明。解决用包围盒自动摆相机别手动调位置。加载完模型后取它的Box3根据对角线长度动态计算相机距离和 targetconst box new THREE.Box3().setFromObject(gltf.scene); const size box.getSize(new THREE.Vector3()); const center box.getCenter(new THREE.Vector3()); const dist size.length() * 2; this.camera.position.set(center.x, center.y, center.z dist); this.camera.lookAt(center);这是一种“后悔药”式的稳妥打法直接消灭位置、比例、朝向三类问题。注意setFromObject需要模型先加入 scene 才能正确计算世界坐标所以顺序应该是先 add 再计算。5.3 纹理发黑或发紫材质兼容性现象模型轮廓在但表面材质看起来发黑或者出现大面积紫色。紫色是 three.js 在纹理加载失败时给出的默认提示色发黑常是光照缺失或材质参数不对。原因GLB 内嵌的纹理格式不被小程序支持常见的是带 alpha 通道的 PNG 在某些 Android 机型上采样异常另外 three.js 的 GLTFLoader 解析时对纹理的flipY处理和小程序 canvas 的像素存储顺序有差异导致贴图看起来像被上下反转或完全糊掉。解决痛点不在运行时在出包阶段就别带奇怪格式。导出 GLB 前把纹理统一转成 JPEG不带透明通道的或 WebP图片尺寸压到 1024 以内。如果是发黑检查场景里有没有加环境光和方向光PBR 材质在只有纯色光照下会显暗加一个低强度的AmbientLight兜底。5.4 touchmove 卡顿渲染与事件频率失配现象模型能转但转动过程明显一顿一顿尤其快速滑动时整个页面都在掉帧。原因touchmove 每帧被系统多次回调回调里如果做了复杂计算比如每次都new THREE.Vector3()或者直接操作相机位置主线程被占满渲染帧率自然掉。另一个常见错误是 touchmove 里直接调renderer.render()等于每帧渲染了多次毫无必要。解决事件回调里只存数据不碰渲染相关对象。所有临时变量在回调外复用const tmpVec new THREE.Vector3(); // 复用 tmpVec 而不是每次都 new渲染循环保持独立单帧只渲染一次。这个优化做下来低端机的滑动流畅度能明显提升一截。5.5 内存占用持续走高显存与内存泄漏现象页面反复进入退出之后小程序内存占用越来越高最终被杀进程。原因three.js 在场景被销毁时需要手动释放 GPU 资源。小程序页面 onUnload 只卸载了 WXML 节点three.js 创建的 geometry、material、texture 还留在显存里。切页面再回来又重新创建一份老的不释放累积几次就爆了。解决onUnload 里做资源释放onUnload() { this.scene.traverse((obj) { if (obj.geometry) obj.geometry.dispose(); if (obj.material) { const mats Array.isArray(obj.material) ? obj.material : [obj.material]; mats.forEach((m) { Object.values(m).forEach((v) { if (v v.isTexture) v.dispose(); }); m.dispose(); }); } }); this.renderer.dispose(); }这段代码虽然有点繁琐但长期来看能救回不少低端机的内存。我一般还会在页面收到 onHide 时暂停渲染循环减少后台 GPU 占用。6. 上线前的验证习惯性能指标、模型规范与最后一公里模型能转、手势能用、坑都踩完离上线还差最后一步真机性能验证。我的习惯是拿一台中低端安卓机做基准测试机模拟器怎么流畅都不算数真机能稳定跑才叫能上线。验证清单只盯两个量化指标帧率 FPS 和内存增长。FPS 用wx.getPerformance()可以拿到时间戳记录渲染前后差值就能估算帧率更省事的是在渲染循环里每秒统计一次 render 次数低于 30 就过不了关。内存看wx.getSystemInfoSync().memorySize配合 5.5 的释放逻辑反复进页面五次内存占用波动应该在 10% 以内。检查项合格线处置首屏加载耗时3 秒内出现模型超过则压模型体积真机 FPS不低于 30低则降面数或降 dpr连续进出页面内存涨幅小于 10%高则检查资源释放低端机纹理表现不发黑不发紫检查纹理格式和光照模型导出参数我也固定成一套规范格式 GLB面数上限看场景规模展示单品控制在 3 万面以内纹理 JPG/WebP、尺寸 1024、不嵌 alpha。这套参数是从几个实际项目里压出来的既能保证视觉精度又不会压垮中低端机的 GPU。最后说一个我自己翻车过的教训某个版本上线前在工具和高端机上测得很顺结果用户群里大量中低端机反馈白屏查到最后是纹理压缩格式在部分机型的驱动上有兼容问题。从那之后所有 3D 功能我都默认用最低配机型验证一遍再发版不再迷信模拟器的表现。希望帮到你。本文还有配套的精品资源点击获取