微信小程序中跑通three.js:从环境适配到glTF模型加载 简介这是一份面向微信小程序开发者的Three.js 3D模型展示Demo源码包专为需要在小程序里渲染三维物体、并希望以最简单方式实现动态展示的开发者准备。它解决了在小程序环境中引入Three.js渲染3D模型并快速实现旋转、掉落等动画效果的需求博主已在近期版本中验证可用且针对常见集成问题给出了避坑思路。资源共18个文件以8个JavaScript逻辑文件为核心覆盖Three.js组件封装、页面交互与动画控制另有5个json配置文件、2个wxml页面结构、2个wxss样式文件、1个gltf模型文件压缩后仅258KB非常轻量。包内自带虾模型上传至服务器后通过wxml中的url引用即可显示目录采用pages、utils、components等模块划分结构清晰便于按需修改js函数实现不同动画已有2305人学习/下载。通过这套Demo开发者可快速掌握小程序与Three.js的整合方式并将旋转、掉落等效果灵活扩展到商品展示、模型预览等真实场景中避开博主踩过的坑显著降低3D功能的上手门槛和开发成本。1. 微信小程序里跑 threejs这不是网页版 threejs 的目录做过 3D 展示的开发者都知道微信小程序里跑 threejs第一反应是“直接把网页版 threejs 搬进来”。结果一运行第一个报错就是window is not defined。小程序的 JavaScript 运行环境不是浏览器没有window、document、navigatorthreejs 默认依赖这些 DOM API 初始化渲染器这套逻辑在小程序里根本走不通。这篇笔记要做的就是讲清楚在小程序里适配 threejs 的完整路径——从环境适配到 glTF 模型加载再到手势控制与性能兜底。它适合两类人一类是想给小程序加 3D 商品展示、3D 互动页面的前端开发者另一类是熟悉 threejs 但没碰过小程序渲染层的从业者。读完你至少能跑通一个可交互的 3D 模型展示页并且知道哪些问题是必须绕开的——比如 canvas 的宽高自适应、像素比设置、模型文件的本地分包路径。2. 小程序里的 threejs 适配层为什么需要特殊构建版本2.1 渲染器初始化差异从 DOM 到 canvas 节点小程序里没有 DOMthreejs 的WebGLRenderer依赖document.createElement(canvas)来创建绘图表面。在微信小程序里画布是通过wx.createSelectorQuery()获取到页面的 canvas 节点然后调用wx.createWebGLContext()来拿 WebGL 上下文。这个差异决定了你不能直接npm install three然后import * as THREE from three完事必须使用针对小程序适配的构建版本常见做法是使用threejs-miniprogram这个适配库——它本质上是对 threejs 的 WebGLRenderer 做了一层注入把传入的 canvas 节点和 WebGL 上下文作为构造参数。import * as THREE from three; import { createScopedThreejs } from ./libs/threejs-miniprogram; // 获取 canvas 节点并创建 WebGL 上下文 const query wx.createSelectorQuery(); query.select(#modelCanvas) .node() .exec((res) { const canvas res.nodes[0]; const gl canvas.getContext(webgl); // 关键通过 createScopedThreejs 传入 canvas 和 gl const THREE createScopedThreejs(canvas, gl); // 之后的代码就跟桌面端 threejs 一致了 const scene new THREE.Scene(); const camera new THREE.PerspectiveCamera(45, canvas.width / canvas.height, 0.1, 1000); const renderer new THREE.WebGLRenderer({ canvas, gl, antialias: true }); });2.2 适配库的闭包机制与作用域隔离createScopedThreejs做了什么它把一个干净的全局对象注入到 threejs 内部并在闭包内执行 threejs 的完整源码这样才能让 threejs 内部对window的引用落到适配后的对象上。注意看上面的代码const THREE createScopedThreejs(...)之后创建场景、相机、渲染器全部基于这个返回的THREE而不是全局导入的那个import * as THREE。混用一个页面里只用一个THREE实例不要到处重新 import否则会导致原类型和适配类型在两个“世界”里各玩各的报错会让你怀疑人生。scene.add(mesh); // 渲染循环小程序里不能使用 requestAnimationFrame 的浏览器实现 function render() { requestAnimationFrame(render); mesh.rotation.y 0.01; renderer.render(scene, camera); } render();requestAnimationFrame在小程序里也有坑基础库 2.9.0 之前需要手动做时间差控制否则模拟器里正常、真机上掉帧。我一般会封装一个RAF模块优先使用canvas.requestAnimationFrame部分环境支持不支持就走setTimeout兜底。适配后pinch 旋转、缩放这些手势逻辑就能复用 web 端的三维数学库——因为THREE.Vector3、THREE.Quaternion这些核心数学工具在小程序的 JavaScript 引擎里是可以正常工作的它们没有依赖 DOM。2.3 构建版本的包体控制与 tree-shaking直接引入threejs-miniprogram的 ES Module 版本构建时用webpack或Taro的打包配置做sideEffects: false只把用到的模块打进包。实际上我会把整个 threejs 和适配库放进分包——主包只放首页和占位图3D 展示页面作为独立分包用户点进详情页时才拉取分包代码。这样主包体积不受影响审核也容易过。{ subpackages: [ { root: package3d, name: model3d, pages: [pages/preview/preview] } ], preloadRule: { pages/index/index: { network: wifi, packages: [package3d] } } }分包配置注意模型文件最好也放进分包目录wx.loadSubpackage加载分包后用相对路径加载模型文件这样避免主包过大。preloadRule表示在 WiFi 下预加载分包4G 网络下用户手动进入时再加载。这里的root字段配置错了会直接报错找不到分包常见错误是路径最后带了斜杠。3. glTF 模型加载器从本地文件到场景渲染3.1 GLTFLoader 在小程序里的文件读取差异threejs 官方的GLTFLoader在 web 端依赖fetch或XMLHttpRequest加载资源小程序里就没有这东西。小程序环境里的wx.getFileSystemManager().readFile是更稳妥的读取方式——先通过wx.getFileSystemManager()拿到文件系统管理器读取package3d/models/chair.glb这个分包里的模型二进制文件再把 ArrayBuffer 传给 loader 的parse方法。这里拿不到ArrayBuffer类型就用Uint8Array包一层很多字体加载、压缩纹理加载的问题都是数据类型不匹配引发的。const fs wx.getFileSystemManager(); const filePath ${wx.env.USER_DATA_PATH}/models/chair.glb; // 先复制模型文件到用户目录 fs.copyFile({ srcPath: package3d/models/chair.glb, destPath: filePath, success() { fs.readFile({ filePath, success(res) { const data res.data; // ArrayBuffer loader.parse(data, , (gltf) { const model gltf.scene; scene.add(model); }); } }); } });3.2 模型尺寸归一化与坐标修正glTF 模型从 Blender、3ds Max 导出时建模单位可能是厘米、米甚至英寸。如果模型尺寸和场景单位不匹配最常见的问题就是模型看不到——摄像机在原点模型在距离原点 1 万单位的位置直接飞出视锥。我一般会在模型加载后做一个 AABB 包围盒的计算把模型包围盒的整体尺寸归一化到 1~2 个单位再放到场景原点。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 scale 1.5 / maxDim; // 目标尺寸映射到 1.5 个单位 model.scale.setScalar(scale); model.position.x -center.x * scale; model.position.y -center.y * scale; model.position.z -center.z * scale; scene.add(model);这段归一化的逻辑很关键我见过太多模型在页面上显示成黑屏或一个色块的问题根源就是模型原始尺寸过大或过小。Box3.setFromObject是计算模型网格的包围盒getSize拿边长getCenter拿几何中心。注意如果模型里有骨骼动画或 SkinnedMesh缩放最好放在模型根节点而不是 mesh 上否则骨骼矩阵会多乘一层缩放动画表现会怪异。3.3 纹理管理与压缩格式选择glTF 有两种常见形式.gltf 外部纹理和.glb单文件二进制。在小程序里.glb明显更省心——就一个文件不需要处理纹理路径重映射。但如果模型本身就是.gltf加一堆.jpg、.png你要处理纹理路径的问题我一般会把纹理文件转成 base64 内嵌到images字段或者直接用 GLTFTransform 工具把模型重新打包成.glb。小程序的包体积限制是 2MB主包、2MB单个分包一张 2048x2048 的 PBR 纹理压成 jpg 大约 200KB压缩成 basis 超压缩格式可以到 40KB 左右。// 使用 KTX2 压缩纹理需要扩展支持 const ktxLoader new THREE.KTX2Loader(); ktxLoader.setTranscoderPath(package3d/libs/basis/); ktxLoader.detectSupport(renderer); renderer.outputEncoding THREE.sRGBEncoding; renderer.toneMapping THREE.ACESFilmicToneMapping; renderer.toneMappingExposure 1.0;如果项目里用到的环境贴图.hdr较大建议直接预计算 irradiance 贴图不在小程序端做运行时解码——基础库的 WebGL 能力有限HDR 解码和 KTX2 转码都有性能风险。我在项目里只对 512px 以下的环境贴图做运行时解码超过这个尺寸一律预烘焙到 CubeTexture 再导出。4. 手势交互与相机控制让模型在指尖转起来4.1 相机轨道控制不依赖 OrbitControls 的轻量实现OrbitControls是 threejs 官方示例里的控制器它依赖domElement的鼠标事件监听在小程序里没有对应的mousedown、mousemove事件模型。小程序的手势事件是touchstart、touchmove、touchend直接套用会有一堆问题。更轻量的做法是自己写一个交互控制器基于 touch event 的坐标差来计算旋转角度。let lastX 0, lastY 0; let spherical { radius: 5, phi: Math.PI / 2, theta: 0 }; const offset new THREE.Vector3(); function updateCamera() { offset.setFromSpherical(spherical); camera.position.copy(model.position).add(offset); camera.lookAt(model.position); } canvas.addEventListener(touchstart, (e) { const touch e.touches[0]; lastX touch.clientX; lastY touch.clientY; }); canvas.addEventListener(touchmove, (e) { const touch e.touches[0]; const deltaX touch.clientX - lastX; const deltaY touch.clientY - lastY; spherical.theta - deltaX * 0.01; spherical.phi - deltaY * 0.01; // 限制 phi 角防止穿模 spherical.phi Math.max(0.1, Math.min(Math.PI / 2, spherical.phi)); lastX touch.clientX; lastY touch.clientY; updateCamera(); });4.2 双指缩放与惯性滑动单指旋转只是第一步真正让用户觉得“好用”的是双指缩放和惯性滑动。双指缩放的实现逻辑是记录两指之间的距离Math.hypot(x2 - x1, y2 - y1)然后在touchmove中对比距离变化值乘以一个缩放系数来调整球坐标的半径。惯性滑动则需要自己维护一个速度变量——在touchend时记录最后一段位移的速度然后在requestAnimationFrame循环里逐步衰减直到接近零再停止。let velocity 0; let lastMoveTime 0; function onTouchEnd() { const now Date.now(); const dt (now - lastMoveTime) / 1000; velocity deltaTheta / dt; // 角速度 lastMoveTime 0; } function animate(running) { if (running Math.abs(velocity) 0.001) { spherical.theta velocity * 0.016; velocity * 0.95; // 阻尼系数 updateCamera(); } requestAnimationFrame(() animate(true)); }阻尼系数的调参0.9 左右偏滑手停后还要转好久0.98 基本是韩系电商 App 的交互手感。双指缩放的系数一般取 0.015~0.02指尖移动 10px 缩放 0.15 个单位太快容易误触触发。坑在于 iOS 上touchmove的preventDefault失效——单指在 canvas 上滑动时页面会跟着滚动。处理办法是在 canvas 的catchtouchmove而不是bindtouchmove上注册事件catch能阻止事件冒泡到页面滚动层这是我在真机调试里踩过最深的坑之一。4.3 自动旋转与初始姿态控制3D 商品展示页通常有一个缓慢自转的初始状态用户触摸后停止自转松手几秒后再继续。自转逻辑放在场景渲染循环里但要注意不要干扰用户正在进行的拖拽操作。我用一个allowAutoRotate布尔值做开关在touchstart时置为falsetouchend后setTimeout 3秒重置为true。如果用户频繁拖拽这个延时重置逻辑还要配合clearTimeout做防抖。let autoRotateTimer null; canvas.addEventListener(touchend, () { clearTimeout(autoRotateTimer); autoRotateTimer setTimeout(() { allowAutoRotate true; }, 3000); }); function renderLoop() { if (allowAutoRotate) { spherical.theta 0.005; updateCamera(); } renderer.render(scene, camera); }自动旋转的角速度 0.005 弧度/帧对应 60Hz 下约每秒 18 度看起来刚好是“缓缓转过来让你看清背面”的节奏。如果你做的是展会大屏或营销页速度可以快一倍到 0.01如果是珠宝、手表这类精细展示0.003 更合适太快用户看不细节。这个参数我建议做成配置值而不是硬编码方便运营后台调。5. threejs 小程序避坑指南从黑屏到白屏的五个高频问题5.1 模拟器正常但真机黑屏现象开发者工具里模型渲染正常预览真机打开页面后 canvas 区域一片黑屏。原因大多数情况下是真机 WebGL 上下文创建失败。小程序基础库对 WebGL 的支持依赖特定系统版本的 WebView 内核iOS 低版本或 Android 定制 ROM 对 WebGL 2.0 支持不完整。另一类原因是 canvas 尺寸为 0——真机渲染时 canvas 的width和height属性没有被正确设置导致渲染区域为零。解决在两处加防御逻辑。第一处是在创建上下文的exec回调里检查gl是否为空为空就降级显示静态图第二处是把 canvas 的宽高显式设置为屏幕像素宽高的两倍。const { windowWidth, pixelRatio } wx.getSystemInfoSync(); canvas.width windowWidth * pixelRatio; canvas.height windowHeight * pixelRatio; // 用 pixelRatio 计算相机宽高比 camera.aspect canvas.width / canvas.height; camera.updateProjectionMatrix();5.2 模型加载到一半页面白屏现象页面加载时触摸、滑动等交互都无响应画面卡在白屏几秒后才出现模型。原因glTF 的解析和纹理上传是在主线程执行的模型文件较大时解析的同步逻辑阻塞了渲染循环。加上小程序的基础库本身是异步架构同步阻塞会让整个页面失去响应。解决把模型解析放到setTimeout或独立的线程中。实际做法是先把 glTF 数据解析成 JSON 和 ArrayBuffer再用wx.loadSubpackage预加载模型分包最后在loader.parse的第三个回调里执行渲染初始化。同时给页面加 loading 状态模型加载完成后再创建渲染器。loader.parse(data, , (gltf) { wx.hideLoading(); const model gltf.scene; scene.add(model); // 渲染循环延迟启动 setTimeout(() { renderer.render(scene, camera); }, 100); }, (err) { wx.hideLoading(); wx.showToast({ title: 模型加载失败, icon: none }); });5.3 模型贴图颜色发灰或偏暗现象同一份 glTF 文件在电脑浏览器的 threejs 里显示正常在小程序里整体发灰、发暗尤其是金属材质。原因色彩空间不一致。基础库的 WebGL 上下文默认不开启SRGB8_ALPHA8_EXT扩展或者渲染器没有配置outputEncoding和toneMapping。颜色值在线性空间和 sRGB 空间之间来回转换最终输出的颜色偏暗。解决渲染器设置THREE.sRGBEncoding和ACESFilmicToneMapping同时确认模型的纹理贴图在导出时已经完成 sRGB 转换不要在小程序端做二次转换。renderer.outputEncoding THREE.sRGBEncoding; renderer.toneMapping THREE.ACESFilmicToneMapping; renderer.toneMappingExposure 1.2;5.4 iOS 上 canvas 无法截屏或被系统手势覆盖现象iOS 上模型展示页播放时下拉手势或侧滑手势会触发页面返回canvas 被系统手势接管无法正常触摸旋转。原因小程序 iOS 端页面默认支持右滑返回手势canvas 区域的手势事件被页面容器拦截touchmove事件没有正常触发。解决在页面 JSON 里配置disableSwipeBack: true仅 iOS 支持或者在 canvas 外层用一个catchtouchmove的 view 包住 canvas 节点阻止手势冒泡。{ disableSwipeBack: true, usingComponents: {} }另一种做法是加一个全屏透明的movable-area来替代原生 canvas 事件——我一般不推荐因为性能和事件穿透的问题更麻烦。5.5 分包加载后模型路径不存在现象代码写在分包package3d/models/chair.glb开发时srcPath写成/package3d/models/chair.glb开发者工具里正常真机上报错找不到文件。原因开发者工具对本地路径解析宽松真机上分包路径必须以相对路径引用分包根目录不能以斜杠开头。解决统一使用不带前导斜杠的路径并且用wx.env.USER_DATA_PATH做文件拷贝目标。// 正确写法 const srcPath package3d/models/chair.glb; // 错误写法真机必挂 const srcPath /package3d/models/chair.glb;6. 性能调优从帧率 15fps 到 60fps 的验证方法最后一个环节聊性能调优和量化验证。小程序里 3D 场景的性能瓶颈基本在三个地方draw call 数量、像素填充量、内存占用。我用wx.getPerformance()接口加wx.getLogManager()做埋点验证——把帧率数据上报到日志平台方便追踪真机表现。验证方法很直接模型加载完跑 60 秒自动旋转统计每帧performance.now()的时间差超过 20ms即 50fps 以下的记录为卡顿帧。降低 draw call 最常用的手段是几何体合并——把多个零件模型合并成一个 mesh减少渲染批次。但这会带来一个问题合并且后模型整体是一个材质没法单独控制某个零件的显隐和动画。针对商品展示场景我会在 Blender 里把模型按分件导出多个 GLTF然后在小程序里做实例化合并只对需要独立控制的零件保留单独 mesh。另一个做法是用THREE.lightmap和VertexColors把环境光烘焙到顶点色减少法线贴图和光照计算的消耗。// 合并静态网格使用 BufferGeometryUtils import { mergeBufferGeometries } from three/examples/jsm/utils/BufferGeometryUtils.js; const geometries []; model.traverse((child) { if (child.isMesh) { geometries.push(child.geometry); } }); const mergedGeo mergeBufferGeometries(geometries, true); const mergedMesh new THREE.Mesh(mergedGeo, modelMaterial); scene.add(mergedMesh);合并的时候注意不同材质的 mesh 不能直接合并要么先统一材质要么按材质分组多次 merge。我曾经合并过一次包含透明材质的模型结果透明部分全部渲染成黑色块——后来才知道必须单独处理透明材质的深度写入depthWrite: false。帧率和电池发热的取舍也要考虑。小程序里我见过很多团队为了帧率达到 60fps把渲染像素降到屏幕分辨率的 50%结果是模模糊糊一片用户根本看不清模型纹理。正确做法是动态降级真机设备像素比大于 2 时canvas 分辨率用实际像素比帧率连续三帧低于 30fps 时自动把 canvas 分辨率降到 75%再低就降 50%。let fps 60; let downscale 1; function monitorFPS() { const now performance.now(); const delta now - lastFrameTime; lastFrameTime now; fps fps * 0.9 (1000 / delta) * 0.1; if (fps 30) { downscale Math.max(0.5, downscale - 0.25); canvas.width canvasBaseWidth * downscale; canvas.height canvasBaseHeight * downscale; camera.aspect canvas.width / canvas.height; camera.updateProjectionMatrix(); } }从小程序 canvas 的时序来看onRender循环本身可以跑 60fps但真机上的帧率波动主要来自系统降频——温度过高时 CPU/GPU 缩频帧率断崖式下跌。这时候光靠降分辨率已经救不回来还需要把渲染循环从requestAnimationFrame改成setTimeout动态控制帧率检测到帧率连续下跌时直接跳到 30fps 渲染而不是死磕 60fps。从那以后我每次做小程序 3D 方案都会强制先写一个帧率监控模块再开始写业务逻辑——性能是一只灰犀牛等用户反馈崩了再优化不如一上来就把它关在笼子里。希望这些踩坑记录能帮你在做小程序 3D 展示时少走几段弯路。本文还有配套的精品资源点击获取