简介:这份压缩包是一套面向微信小程序开发者的 three.js 3D 模型加载示例项目,重点解决小程序环境中引入 JavaScript 3D 库并展示外部模型的问题,适合已具备基础 JavaScript 与小程序开发经验、希望扩展三维交互能力的学习者。包内共 11 个文件,以 4 个 js 脚本和 4 个 json 配置为主,另含 wxml 页面结构、wxss 样式与 README 说明,压缩包约 167KB,整体轻量,便于快速导入开发者工具对照研究。项目围绕 three.js 库、模型加载器与小程序页面配置组织,可参考其工程结构理解 3D 场景初始化、外部模型接入及页面渲染链路的实际落地方式。目前已有 3208 人学习,适合作为小程序三维展示、模型预览类功能的入门参考与二次开发起点。
1. 小程序里加载外部 3D 模型:真正卡住你的是加载链路,不是 Three.js
很多人第一次在微信小程序里加载外部 3D 模型,会以为把 three.js 的构建产物塞进项目就完事了,结果在真机上看到的是一片白。问题几乎不在渲染本身,而在「从网络拿到一个 GLB 文件,再把它变成场景里一个能动的物体」这条链路:小程序没有 DOM、没有 XHR、没有document.createElement('img'),而 Three.js 的模型加载器恰恰把这三样东西当成了默认前提。这份 miniprogramThree 资源要解决的,就是这条链路——用 JavaScript 把外部模型请求、解析、贴图、交互和渲染循环在小程序环境里重新接一遍。它适合已经会一点 Three.js、但被小程序宿主环境卡住的前端;也适合接过 3D 展示需求、需要评估「到底能不能落地、落地成本多大」的从业者。
2. 方案选型与工程骨架:为什么是 scoped Three.js 而不是 web-view
在动手之前,先把路线定死。小程序里做 3D 展示,能看到的路不止一条,但能长期维护、能上真机的其实不多。选错路线,后面所有的加载优化和交互打磨都是白费。
2.1 三条路线的取舍
我把见过的做法归成三类,实际选型时按表里的维度逐条对一遍就能决定:
| 维度 | web-view 内嵌 H5 | 原生 canvas + Three.js 适配 | 平台内置 3D 能力 |
|---|---|---|---|
| 引入成本 | 低,H5 那套直接搬 | 中,需要适配层 | 中,需要学一套新 API |
| 交互能力 | 强,但受 web-view 通信限制 | 强,事件可完全自控 | 受平台能力边界限制 |
| 与小程序页面融合 | 差,web-view 层级独立 | 好,同层渲染可叠加 UI | 好 |
| 首屏性能 | 差,多一次 H5 初始化 | 可控,能预加载 | 好 |
| 调试便利性 | 好 | 中,真机差异明显 | 中 |
| 适用场景 | 活动页、一次性展示 | 需要长期迭代的展示/商品/科普页 | 轻量、标准化场景 |
web-view 方案最大的问题不在渲染,而在「页面不是你的」:手势、滚动、导航栏、分享都隔着一层,用户滑到一半被 web-view 吃掉手势是常态。平台内置 3D 能力适合非常标准化的模型展示,一旦要做自定义材质、自定义光照或者模型动画混合,就会撞墙。
我一般会走中间那条:原生canvas拿 WebGL 上下文,再挂 Three.js。麻烦的地方在于 Three.js 内部到处引用window、document、XMLHttpRequest,所以需要一个薄的适配层。threejs-miniprogram 这类库做的就是这件事——通过createScopedThreejs(canvas)生成一个「绑定到当前 canvas 的 THREE 实例」,把这层环境差异吃掉。
注意:适配层不是万能的,它只处理了渲染器和一部分对象创建。模型加载、纹理加载、事件绑定这三块,仍然需要自己补。这也是后面几章的主要内容。
2.2 从零搭一个可跑的 3D 页面
先装依赖,再让开发者工具构建一次 npm,这一步漏了会直接报模块找不到。
# 在项目根目录执行 npm init -y npm i threejs-miniprogram --save装完之后打开开发者工具的「工具 → 构建 npm」,勾选「使用 npm 模块」。做完这一步,miniprogram_npm目录里才会出现可被小程序解析的产物。很多人卡在这里,以为是库的问题,其实是没构建。
页面结构很简单,一个占满容器的 canvas:
<!-- pages/model/index.wxml --> <view class="stage"> <canvas type="webgl" id="gl" class="gl" bindtouchstart="onTouchStart" bindtouchmove="onTouchMove" bindtouchend="onTouchEnd" ></canvas> </view>type="webgl"是关键,写成普通 canvas 拿不到 WebGL 上下文。样式上.gl给width:100%;height:100%,.stage给一个固定高度(比如100vh),否则 canvas 尺寸为 0,渲染出来什么都看不见——这是新手最容易踩的第一个坑。
接下来是初始化逻辑:
// pages/model/index.js import { createScopedThreejs } from 'threejs-miniprogram' Page({ data: { ready: false }, onReady() { // 必须用 node() 拿到真实的 canvas 节点,而不是 selector 的结果集 wx.createSelectorQuery() .select('#gl') .node() .exec((res) => { const canvas = res[0].node // 用 canvas 生成一个作用域内的 THREE,后续所有对象都从这里取 const THREE = createScopedThreejs(canvas) this.initScene(THREE, canvas) }) }, initScene(THREE, canvas) { const info = wx.getSystemInfoSync() const width = info.windowWidth const height = info.windowHeight const dpr = Math.min(info.pixelRatio, 2) // 像素比封顶,避免高分屏爆显存 // 1. 渲染器:scoped 版本已经绑定了 canvas,不需要再传 domElement const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }) renderer.setSize(width, height) renderer.setPixelRatio(dpr) renderer.setClearColor(0x000000, 0) // 透明背景,方便叠加页面 UI // 2. 场景与相机 const scene = new THREE.Scene() const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 200) camera.position.set(0, 1.2, 3.2) camera.lookAt(0, 0.6, 0) // 3. 光照:环境光打底 + 平行光造型,够用且便宜 scene.add(new THREE.AmbientLight(0xffffff, 0.65)) const key = new THREE.DirectionalLight(0xffffff, 0.85) key.position.set(3, 6, 5) scene.add(key) // 4. 渲染循环挂在 canvas 上,不要用 setTimeout/setInterval const render = () => { renderer.render(scene, camera) canvas.requestAnimationFrame(render) } render() // 存起来,后面加载模型和销毁都要用 this.THREE = THREE this.canvas = canvas this.scene = scene this.camera = camera this.renderer = renderer this.setData({ ready: true }) } })这段代码里有几个参数值得单独说。setPixelRatio用Math.min(pixelRatio, 2)封顶,是因为部分安卓机的pixelRatio报到 3 甚至更高,渲染面积按平方增长,帧率会直接掉到个位数,血泪经验。PerspectiveCamera的near/far不要随手写0.1/10000,深度缓冲精度会被拉垮,模型表面出现奇怪的条纹闪烁(z-fighting),按模型实际包围盒量级给就行。requestAnimationFrame一定要用canvas上的方法而不是全局的,小程序的渲染帧和页面帧不完全是同一套节奏。
2.3 包体积与基础库版本的前置约束
动手之前有两个硬约束必须先确认,不然后面白忙。
第一个是包体积。小程序主包上限 2MB,整个项目所有分包加起来 20MB。threejs-miniprogram 构建后的产物大概在 600KB~800KB 量级(取决于你引入的模块),如果再加上一个几 MB 的 GLB 模型,主包分分钟超限。我的做法是把 3D 页面拆成独立分包,模型文件放 CDN 走网络加载,只有降级用的占位模型留在本地。这样主包体积几乎不受影响。
第二个是基础库版本。canvas的type="webgl"、SelectorQuery.node()、以及 canvas 作为原生组件的同层渲染,都和基础库版本强相关。低于同层渲染能力的版本,canvas 会盖住页面上所有普通元素,你想在模型上面放个「加入购物车」按钮都放不上去。在app.json里把requiredBackgroundModes之外的最低版本设好,然后在wx.getSystemInfoSync().SDKVersion里做一次运行时判断,低于阈值直接走静态图降级。
提示:同层渲染打开后,canvas 上面可以正常叠
view、image、text,但cover-view那一套老写法就别混着用了,两套层级模型混用会出现点击穿透和位置偏移。
3. 把外部 GLB 拉进场景:网络层改写与 loader.parse 两条路
骨架搭好之后,最核心的一步来了:把一个放在 CDN 上的 GLB 文件变成场景里的Object3D。这一步在小程序里之所以麻烦,是因为 GLTFLoader 默认会自己发起请求、自己去解码贴图,而这两件事它依赖的浏览器 API 小程序一个都没有。
3.1 小程序的网络边界:没有 XHR,只有 wx.request
Three.js 的FileLoader内部用的是XMLHttpRequest,ImageLoader用的是document.createElement('img')。小程序运行环境里这两个东西都不存在,所以直接把 GLTFLoader 引进来调用.load(url),结果是静默失败——不报错,也不回调,模型就是不出现,堪称黑匣子。
对这条链路,有两个层次的解法:
一是绕开 loader 自己的请求逻辑,用wx.request把文件拿成ArrayBuffer,再调用loader.parse(buffer, path, onLoad, onError)。这条路最稳,请求、缓存、重试全在自己手里,适合做主流程。
二是重写FileLoader和ImageLoader的原型方法,让 GLTFLoader 以为自己在正常环境里跑。这条路改动更彻底,能支持带外部贴图的 glTF(非 GLB 打包格式),但需要对 Three.js 内部结构比较熟。
实务上我一般两个都用:主模型走parse,因为它可控;如果模型是分离的.gltf + .bin + 贴图结构,就走重写原型的方案。
3.2 用 ArrayBuffer 直读:最稳的 GLB 加载写法
先看主流程的写法。核心是先请求、后解析,把网络和解析彻底解耦:
// utils/loadModel.js export function loadGLB({ THREE, url, onProgress }) { return new Promise((resolve, reject) => { wx.request({ url, method: 'GET', responseType: 'arraybuffer', // 关键:不加这行拿到的是一串字符串 timeout: 20000, success: (res) => { if (res.statusCode !== 200) { reject(new Error(`HTTP ${res.statusCode}`)) return } // 每次加载都新建 loader,避免多次进入页面时 loader 内部状态污染 const loader = new THREE.GLTFLoader() // 第二个参数是资源基准路径,GLB 内嵌贴图时可以传空串 loader.parse( res.data, '', (gltf) => resolve(gltf.scene), (err) => reject(err) ) }, fail: reject }) }) }responseType: 'arraybuffer'是这段代码里最容易被忽略的一行。默认的text类型会把二进制内容按字符串处理,loader.parse拿到后解析头部魔数就对不上,抛出的错误信息往往是「Unexpected token」之类,跟真实原因完全无关。
loader.parse的第二个参数path决定贴图相对路径的解析基准。GLB 是自包含格式,贴图打包在同一个文件里,传空串没问题;如果你加载的是外部引用的.gltf,这里必须传模型所在的目录 URL,否则贴图会 404。
new THREE.GLTFLoader()每次新建而不是复用,是为了避免连续进出页面时 loader 内部的 manager、cache 残留导致的诡异问题。这个开销很小,不值得省。
还有一点:THREE.GLTFLoader在 scoped 实例上不一定存在。threejs-miniprogram 的产物里,loader 需要通过包路径单独引入:
import { GLTFLoader } from 'threejs-miniprogram/examples/jsm/loaders/GLTFLoader.js' // 在 initScene 里把它挂到 scoped THREE 上 THREE.GLTFLoader = GLTFLoader如果你的构建产物里这个路径报找不到模块,就去miniprogram_npm/threejs-miniprogram目录下逐层翻,找到实际的GLTFLoader.js位置再改 import 路径。不同版本的目录结构有差异,这一步只能靠看,不能靠猜。
3.3 重写 FileLoader 与 ImageLoader,让 GLTFLoader 自己干活
当模型是分离结构,或者你希望保留 loader 内部的分块加载、进度回调能力时,就得把底层请求换掉。
// utils/patchLoader.js export function patchLoaders(THREE, canvas) { // 1. 文件加载:XHR -> wx.request THREE.FileLoader.prototype.load = function (url, onLoad, onProgress, onError) { const manager = this.manager manager.itemStart(url) const task = wx.request({ url, responseType: 'arraybuffer', success: (res) => { if (res.statusCode >= 200 && res.statusCode < 300) { onLoad && onLoad(res.data) manager.itemEnd(url) } else { onError && onError(new Error(`HTTP ${res.statusCode}`)) manager.itemError(url) } }, fail: (err) => { onError && onError(err) manager.itemError(url) } }) return task } // 2. 图片加载:document.createElement('img') -> 离屏 canvas 的 createImage const offscreen = wx.createOffscreenCanvas({ type: '2d', width: 1, height: 1 }) THREE.ImageLoader.prototype.load = function (url, onLoad, onProgress, onError) { const manager = this.manager manager.itemStart(url) const img = offscreen.createImage() img.onload = () => { onLoad && onLoad(img) manager.itemEnd(url) } img.onerror = (e) => { onError && onError(e) manager.itemError(url) } img.src = url return img } }这里有个细节:manager.itemStart/itemEnd必须配对调用,Three.js 的LoadingManager靠这对调用来判断「所有资源加载完毕」,漏掉会导致onLoad永远不触发,模型解析卡在最后一步。重写原型方法的时候最容易忘的就是这一对。
wx.createOffscreenCanvas({ type: '2d' })拿到的离屏 canvas 上有createImage(),这是小程序里唯一能构造HTMLImageElement近似物的方式。注意type必须是'2d',传'webgl'拿到的画布没有这个方法。
纹理的尺寸也有约束。WebGL 1.0 下非 2 的幂次尺寸的纹理在RepeatWrapping模式下会失效,小程序底层实现不完全一致,部分机型表现为贴图变成纯色。导出模型时把贴图尺寸统一成 512/1024/2048 这类 2 的幂,能规避掉一类很难查的问题。
3.4 模型归一化:自动居中、自动定相机
不同来源的模型,坐标原点、朝向、单位尺度千差万别。如果按固定相机位置去拍,有的模型小得看不见,有的模型一半卡在相机背后。所以在加载完成后必须做一次归一化。
// utils/normalize.js export function fitCameraToObject(THREE, camera, object, offset = 1.6) { // 1. 计算世界空间包围盒,必须先 updateMatrixWorld object.updateMatrixWorld(true) const box = new THREE.Box3().setFromObject(object) const size = box.getSize(new THREE.Vector3()) const center = box.getCenter(new THREE.Vector3()) // 2. 把模型整体平移到原点 object.position.sub(center) // 3. 按包围盒最大边长反推相机距离 const maxDim = Math.max(size.x, size.y, size.z) const fov = camera.fov * (Math.PI / 180) const distance = (maxDim / 2) / Math.tan(fov / 2) * offset camera.position.set(0, 0, distance) camera.near = distance / 100 camera.far = distance * 100 camera.updateProjectionMatrix() camera.lookAt(0, 0, 0) return { size, distance } }offset是我留的一个余量系数,默认 1.6,意思是让相机退到刚好装下模型的距离再往后 60%,视觉上不会顶边。这个值调到 1.2 会显得很满,调到 2.5 又太空,实际项目里按展示框的宽高比微调。
camera.near/far必须跟着距离一起更新,否则会出现「模型显示但被裁掉一半」或者「深度精度不足导致闪烁」。updateProjectionMatrix()漏掉的话,改了fov、near、far也不会生效,表现就是怎么调都没变化。
物体平移到原点的方式是position.sub(center),前提是物体本身没有父级变换。如果模型有动画且根节点带位移,更好的做法是把它塞进一个Group里,对 Group 做平移,避免破坏动画轨道。
4. 交互与渲染细节:触摸事件、光照和像素比
模型能显示了,接下来是让它看起来像个正经产品:能转、光照不假、不同机型上不变形。这三件事在小程序里都有各自的坑。
4.1 触摸事件到相机旋转:手写一个轻量控制器
Three.js 官方的OrbitControls强依赖domElement上的addEventListener和pointerdown/pointermove事件,小程序里没有这套东西。虽然可以造一个模拟的 domElement,但代价大、脆弱。我的做法是直接手写一个球坐标控制器,代码量不到 fifty 行,行为完全可控。
// utils/orbit.js export function createOrbit(THREE, camera, target, options = {}) { const radius = options.radius || 3 const minPolar = 0.2 const maxPolar = Math.PI - 0.2 let theta = 0 // 水平角 let phi = Math.PI / 2.4 // 极角 let last = null function apply() { const r = radius camera.position.x = r * Math.sin(phi) * Math.sin(theta) camera.position.y = r * Math.cos(phi) camera.position.z = r * Math.sin(phi) * Math.cos(theta) camera.lookAt(target) } return { begin(x, y) { last = { x, y } }, move(x, y) { if (!last) return const dx = x - last.x const dy = y - last.y theta -= dx * 0.01 // 灵敏度:0.01 约等于每 100px 转 57 度 phi = Math.min(maxPolar, Math.max(minPolar, phi - dy * 0.01)) last = { x, y } apply() }, end() { last = null }, reset() { theta = 0; phi = Math.PI / 2.4; apply() }, init() { apply() } } }页面里把 canvas 的触摸事件接上:
onTouchStart(e) { const t = e.touches[0] this.orbit && this.orbit.begin(t.x, t.y) }, onTouchMove(e) { const t = e.touches[0] this.orbit && this.orbit.move(t.x, t.y) }, onTouchEnd() { this.orbit && this.orbit.end() }theta和phi的增量系数决定了拖拽手感。0.01 是中性值;电商类展示通常调到 0.006 让它更沉稳;科普演示调到 0.015 更跟手。极角用minPolar/maxPolar卡住,是为了防止用户滑到正上方或正下方时相机万向锁导致的画面翻转——那一下翻转非常容易让人以为是程序崩了。
用球坐标而不是直接给相机位置做累加,好处是旋转半径恒定,模型不会在拖拽过程中忽远忽近。重置接口留着给「双击复位」或者页面重新可见时用。
4.2 光照与材质在小程序里的实际差异
同一份模型,在 PC 浏览器里看着通透,在小程序真机上经常偏暗或者发灰。原因有几个:
第一,小程序的 WebGL 上下文默认不开启physicallyCorrectLights相关的色彩管理,Three.js 新版本的输出编码策略在 scoped 实例上不一定被完整继承。表现就是整体亮度比预期低一档。
第二,MeshStandardMaterial依赖环境贴图(envMap)才有真实感,而环境贴图通常是 HDR 或者预过滤的立方体贴图,在小程序里加载和解析成本很高,也容易出问题。所以我一般不用 Standard 材质做主材质,改用MeshLambertMaterial或MeshPhongMaterial,配合两到三盏灯手动打光。
// 三段式布光:主光定造型,补光提暗部,轮廓光分离背景 const key = new THREE.DirectionalLight(0xffffff, 0.9) key.position.set(4, 6, 5) const fill = new THREE.DirectionalLight(0xffffff, 0.35) fill.position.set(-5, 2, 3) const rim = new THREE.DirectionalLight(0xffffff, 0.5) rim.position.set(0, 3, -6) scene.add(key, fill, rim) scene.add(new THREE.AmbientLight(0xffffff, 0.4))四个光源听起来多,但DirectionalLight是逐顶点/逐像素的简单计算,在小场景下开销可忽略。强度值加起来不要超过 2.0,否则浅色模型会过曝成一片白,细节全丢。这个组合我调过很多次,对大部分商品模型和角色模型都能出一个不难看的效果。
提示:如果模型自带
KHR_materials_pbrSpecularGlossiness扩展,Three.js 新版本已经移除支持,加载后会掉成默认材质。导出前统一转成MetallicRoughness,别指望运行时兜底。
4.3 尺寸、像素比与 resize
小程序的windowWidth/windowHeight在页面初始化时就固定了,但设备旋转、分屏、以及 iPad 上的窗口调整都会改变视觉尺寸。canvas 的绘制缓冲区尺寸和 CSS 尺寸不一致时,画面会被拉伸。
标准做法是监听wx.onWindowResize,重新计算并同步三者:canvas 的缓冲区尺寸、渲染器尺寸、相机宽高比。
wx.onWindowResize((res) => { const w = res.size.windowWidth const h = res.size.windowHeight this.renderer.setSize(w, h) this.camera.aspect = w / h this.camera.updateProjectionMatrix() })updateProjectionMatrix()这一行漏掉的话,画面会变成横向或纵向压扁,但几何本身没变形,很容易被误判成模型问题。另外记得在onUnload里wx.offWindowResize,不销毁回调会随着用户反复进出页面不断累积。
4.4 渲染循环与帧率
默认的requestAnimationFrame会尽全力跑满帧。对于静态展示类页面,这纯粹是在烧电。常见的优化是做一个「按需渲染」的开关:只有相机在变化、或者模型有动画时才渲染。
let dirty = true const tick = () => { if (dirty) { this.renderer.render(this.scene, this.camera) dirty = false } this.canvas.requestAnimationFrame(tick) }触摸回调里把dirty = true置起来就行。这一改动在低端安卓机上能明显降低发热,续航表现也会好一些。如果模型本身有骨骼动画或者关键帧动画,那就得每帧mixer.update(delta),这时候不能省,退而求其次的做法是用delta做时间缩放,把动画帧率降到 30fps。
还有一个容易忽略的点:页面被onHide之后,渲染循环并没有自动停止。用户切到别的页面,你的 canvas 还在后台跑 WebGL 指令。正确做法是在onHide里把循环标志置为暂停,onShow里恢复。
5. 避坑与排查:真机上翻车的五个现场
这一章是我自己在项目里一条条踩出来的。开发者工具里一切正常、真机上各种翻车,是 3D 小程序调试的常态。下面五条按「现象 → 原因 → 解决」写,遇到对得上的直接照方抓药。
5.1 开发者工具正常,真机白屏
现象:工具里模型转得好好的,真机扫码打开是纯白,控制台没有任何报错。
原因:最常见的是canvas节点获取时机太早。在onLoad里取node(),部分机型上节点还没挂载完成,res[0].node是undefined,后续所有代码在undefined上操作,异常被吞掉。次常见的原因是基础库版本过低,type="webgl"被忽略,拿到的上下文类型不对。
解决:把初始化挪到onReady,并且在exec回调里先做一次空判。同时用wx.getSystemInfoSync().SDKVersion判断版本,低于阈值走降级。真机上排查时,先用一个纯色clearColor渲染一帧,确认渲染管线通了,再往上加模型——这个二分法能省掉大量猜测。
5.2 模型能显示但全黑
现象:轮廓能看出来,但整个模型是黑的,像剪影。
原因:九成是光照问题。要么场景根本没加光源,要么光源位置正好在模型背面,要么材质的color是黑色而贴图没加载成功。还有一种少见情况:模型坐标系是 Z-up(建模软件导出的常见情况),在小程序的 Y-up 世界里「躺」着,光从头顶打下来只照亮侧面。
解决:先用MeshBasicMaterial替换全部材质跑一遍,能看见颜色说明是光照问题,还是黑的说明是贴图或者几何问题。确认是光照后,加一盏AmbientLight打底往往立刻见效。Z-up 的问题在加载后统一object.rotation.x = -Math.PI / 2修正。
5.3 纹理丢失、模型变成纯色块
现象:几何形状正确,但表面是单一的灰白色或者品红色,没有贴图细节。
原因:贴图请求失败了。分离结构的.gltf里贴图路径是相对路径,loader.parse的path参数没给或者给错,导致相对路径解析到小程序本地不存在的地址。另一种情况是贴图 URL 没有加入小程序的下载域名白名单,请求直接被拦。
解决:把path参数补成模型所在目录的完整 URL(结尾带斜杠)。域名问题去后台配置downloadFile合法域名;开发阶段可以在开发者工具里勾选「不校验合法域名」临时绕过,但上线前必须配好。品红色是 Three.js 内部标记贴图加载失败的默认色,看到这个颜色可以直接定位到贴图链路。
5.4 反复进出页面后内存飙升、闪退
现象:第一次进 3D 页面正常,返回再进,再返回再进,几次之后小程序卡顿甚至闪退。
原因:Three.js 的对象不会被 JavaScript 垃圾回收自动释放 GPU 侧资源。几何体、材质、纹理、渲染器都持有显存或上下文资源,页面销毁时不主动dispose(),就会一直累积。另一个来源是wx.onWindowResize这类全局监听没解绑,回调里还引用着已经销毁的页面实例。
解决:在onUnload里做一次完整的资源清理。
onUnload() { // 1. 停掉渲染循环 this.stopped = true // 2. 递归释放场景内所有几何体、材质、纹理 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.keys(m).forEach((k) => { const v = m[k] if (v && v.isTexture) v.dispose() }) m.dispose() }) } }) // 3. 释放渲染器上下文 this.renderer.dispose() this.renderer.forceContextLoss && this.renderer.forceContextLoss() // 4. 解绑全局监听 wx.offWindowResize(this.onResize) }forceContextLoss()这一步很多人不做,但小程序里 WebGL 上下文数量是有上限的,不主动释放,进几次页面就会拿不到新上下文,表现为白屏且无报错。
5.5 模型文件更新了,用户看到的还是旧版本
现象:换了新的模型文件传到 CDN,自己的手机上是新的,部分用户还是旧的。
原因:wx.request的 GET 请求会走客户端缓存,CDN 边缘节点也有缓存。模型文件体积大、更新频率低,很容易命中缓存。
解决:模型 URL 后面拼一个版本号或者文件哈希,比如model.glb?v=20240612a。不要去改wx.request的 header 加no-cache,那样每次都要重新下载几 MB,首屏直接崩。另外建议在业务层做一次本地缓存:用wx.getFileSystemManager().saveFile把模型存到本地,下次先读本地再后台校验版本,这对弱网用户体验提升非常明显。
6. 进阶:把首屏从 6 秒压到 1.5 秒的预处理清单
模型加载链路跑通之后,真正的差距体现在首屏时间上。我经手过的一个展示页,最初什么都没做,从点击进入到模型出现要 6 秒多;做完下面这套预处理和加载策略,稳定在 1.5 秒上下。这里面大部分工作量不在代码,而在导出模型之前。
6.1 导出前必须做的五件事
| 处理项 | 具体做法 | 典型收益 |
|---|---|---|
| 减面 | 用建模软件或网格简化工具把三角面压到 5 万以内 | 体积降 40%~70%,解析时间同步下降 |
| 合并材质 | 同材质的多部件合并成一个 mesh | 减少 draw call,帧率提升明显 |
| 贴图压缩 | 尺寸统一到 1024 以内,转成 JPEG 或 WebP | 体积降 50% 以上,纹理内存降一个量级 |
| 删除冗余 | 清掉相机、灯光、空节点、隐藏物体 | 小模型也能省出几百 KB |
| 格式选择 | 用 GLB 单文件,不要用分离的 gltf+bin+贴图 | 少 N 次网络往返 |
减面这条要多说一句。美术给的源文件动辄几十万面,直接扔进小程序,解析阶段就要两三秒,渲染时低端机直接掉到 10fps。5 万面是展示类场景的经验上限,超过这个数就要开始考虑 LOD 或者删减内部结构——反正用户也看不见手机壳内部的螺丝。
贴图统一到 2 的幂尺寸这件事前面提过,这里再强调一次:它同时解决了显存占用和部分机型的采样异常两个问题,属于改一次长期受益的操作。
注意:Draco 压缩在小程序里基本走不通。解码需要 WebAssembly,而小程序的 WebAssembly 接口对模块路径、加载方式都有额外约束,Three.js 的 DRACOLoader 默认实现无法直接复用。如果一定要压缩几何,优先考虑量化(quantize)而不是 Draco,通用性好得多。
6.2 加载策略:预加载、分包与缓存
代码侧能做的,主要是把「等待」藏到用户感知不到的地方。
第一个是预加载。用户在小程序首页浏览时,后台静默把模型请求发出去,等真正进入 3D 页时直接命中缓存。实现上是在首页onShow里发起一次wx.request,把ArrayBuffer存到wx.setStorageSync之外的文件系统缓存里(ArrayBuffer直接进 storage 不合适,先用FileSystemManager.writeFile落盘)。
// 首页静默预热:不阻塞主流程,失败了也不影响业务 function preloadModel(url, cachePath) { const fs = wx.getFileSystemManager() try { fs.accessSync(cachePath) // 命中缓存直接返回 return Promise.resolve(cachePath) } catch (e) { // 未命中,走网络 } return new Promise((resolve) => { wx.request({ url, responseType: 'arraybuffer', success: (res) => { if (res.statusCode === 200) { fs.writeFile({ filePath: cachePath, data: res.data, success: () => resolve(cachePath), fail: () => resolve(null) }) } else { resolve(null) } }, fail: () => resolve(null) }) }) }进入 3D 页时先fs.readFileSync(cachePath)拿ArrayBuffer,命中就直接loader.parse,整个过程不到 100ms。缓存文件建议用版本号命名,新版本上线时旧文件自然失效,不用额外写清理逻辑。
第二个是分包。把 3D 页和 threejs-miniprogram 一起放进独立分包,app.json里配subpackages,并在首页跳转时用wx.preloadSubpackage提前把分包拉下来。这一步能把分包下载和主包启动并行起来,体感提速非常明显。
第三个是降级策略。网络差或者机型太老时,与其让用户对着白屏等 6 秒,不如直接上一张预渲染好的静态图,点击后行为保持一致。判断依据可以用wx.getNetworkType()加上一次简单的 canvas 能力检测(尝试创建 WebGL 上下文,失败就走降级)。
这几个优化里,性价比最高的是导出前的减面和贴图压缩,几乎不写代码就能拿到一半以上的收益。我现在的习惯是:模型文件进项目之前,强制走一遍体积检查,超过 3MB 就打回重导,不允许「先跑起来再说」。这个规矩立起来之后,后面省掉的调试时间远超过当初那点来回。
从那以后我每次接 3D 展示需求,都会先要模型的原始导出参数和文件体积,再谈交互和动效——顺序反了,做出来的东西在真机上一定不好看。希望帮到你。
本文还有配套的精品资源,点击获取