ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

小程序 Three.js 3D 渲染实战:GLTF 模型加载与性能优化

小程序 Three.js 3D 渲染实战:GLTF 模型加载与性能优化

简介:面向微信小程序开发者的 three.js 3D 模型展示示例,适合希望在轻量级项目中快速接入三维视图、梳理渲染流程与动画调节思路的初中级开发者。压缩包共 18 个文件,约 258KB,包含 8 个 js 逻辑脚本、5 个 json 配置、2 个 wxss 样式、2 个 wxml 页面结构及 1 个 gltf 虾模型,页面、组件、工具脚本与配置分层组织,便于定位三维场景初始化、模型路径与页面样式等关键环节。示例围绕小程序环境加载并展示 3D 模型展开,源码中预留了可调整的函数入口,读者可据此尝试旋转、掉落等动画效果,并参考模型上传服务器后在 wxml 中通过 url 引用的做法。已有 2309 人学习,适合用作快速上手与避坑参照,也能帮助理解 three.js 在小程序中的基础组织方式与调试要点。对准备做课程设计、作品展示或功能验证的读者较友好,也可作为二次开发的基础模板。

1. 小程序里跑 Three.js:先弄明白它凭什么渲染出第一帧

在微信小程序里做 3D 展示,需求通常很具体:商品转一圈看材质、设备模型放大看结构、展厅模型拖拽缩放。选择 Three.js 是因为生态最全,GLTF、PBR 材质、光照、后处理都有现成方案,但直接把 npm 上的three装进小程序,第一行就会报document is not defined,因为它默认依赖浏览器 DOM 那套东西。这项工作要解决的核心问题是用 canvas node 加一层适配,让 3D 渲染管线在小程序容器里真正跑起来。适合已经会写小程序页面、手上有一个 glb 模型、需要尽快交出可交互展示页的同学;也适合已经跑通但卡在黑屏、卡顿、二次进入白屏的人。

2. 工程骨架:threejs-miniprogram 适配层怎么补上缺失的 DOM

2.1 适配层补的几个关键接口:canvas、rAF、Image

Three.js 在浏览器里依赖四类环境能力:document.createElementNS用来造 canvas、window.requestAnimationFrame驱动渲染循环、new Image()加载贴图、XMLHttpRequest加载外部资源。小程序运行环境里这四样一个都没有,只有wx.createSelectorQuery().node()拿到的 canvas 节点,以及它自带的getContext('webgl')。

threejs-miniprogram这个适配包的思路不是改 Three.js 源码,而是用传入的 canvas 节点反造一个极简的伪 document 和伪 window,把 WebGL 上下文挂上去,再在内部new THREE.WebGLRenderer时取到这个上下文。它暴露的核心 API 只有一个:

// 适配层入口:传入 canvas 节点,返回一套独立的 THREE 命名空间 const { createScopedThreejs } = require('threejs-miniprogram') // 注意 createScopedThreejs 不是单例,每次调用都是一套新的 THREE // 多页面、多实例场景下互不污染,但也意味着不能跨页面共享 geometry const THREE = createScopedThreejs(canvas)

理解scoped这个词很重要。它返回的是隔离过的 Three 对象,不是全局那个THREE。因此你从别处搬来的GLTFLoader、OrbitControls,如果内部写死了全局THREE,直接 import 会拿不到类。常见做法是在createScopedThreejs之后把global.THREE临时指过去,再 require 那些老式 examples 脚本,用完再置空。这个做法看起来不优雅,但在小程序这种没有模块劫持能力的环境里,它是最省事的做法。

2.2 从空目录到能编译:依赖安装与页面配置

先把 npm 依赖装上。小程序官方脚手架支持 npm 构建,前提是在开发者工具里勾选“使用 npm 模块”,然后执行一次“构建 npm”,把node_modules里的包转成小程序可识别的miniprogram_npm。这一步漏掉,运行时会直接报模块找不到。

# 在项目根目录执行,threejs-miniprogram 是适配包,不是 three 本体 npm i threejs-miniprogram --save # 装完后必须手动触发一次“构建 npm”,产物在 miniprogram_npm 目录

页面配置上,canvas 必须显式声明type="webgl",否则拿到的是 2D 上下文,WebGL 调用全部静默失败。这一点没有报错,是最容易卡住的地方之一。

<!-- pages/viewer/viewer.wxml --> <canvas id="gl" type="webgl" class="gl" disable-scroll="{{true}}" bindtouchstart="onTouchStart" bindtouchmove="onTouchMove" bindtouchend="onTouchEnd" ></canvas>
/* 宽高必须给死,canvas 没有内容撑高的概念,高度为 0 就是黑屏 */ .gl { display: block; width: 100%; height: 600rpx; }

disable-scroll这个属性建议加上。手指在 canvas 上左右拖动旋转模型时,页面的纵向滚动会同时触发,手感会很怪,加上之后触摸事件会被 canvas 吃掉,旋转才跟手。基础库需要 2.7.0 以上才支持type="webgl"的 node 查询,低版本只能用canvas-id那套旧接口,适配层不兼容,所以上线前把最低基础库版本提上去是必要动作。

2.3 最小可运行:一个能被手指带着转的立方体

先不加载模型,用一个立方体把链路打通。这一步的意义是把“黑屏到底是环境问题还是模型问题”区分开,后面加载模型出问题时,你手上有一个已知能跑的对照。

// pages/viewer/viewer.js const { createScopedThreejs } = require('threejs-miniprogram') Page({ onReady() { // 必须放在 onReady,onLoad 阶段节点还没渲染出来,查询结果是 null this.initScene() }, initScene() { wx.createSelectorQuery() .select('#gl') .node() .exec((res) => { const canvas = res[0].node if (!canvas) { console.error('canvas 节点为空:检查 type="webgl" 和基础库版本') return } this._canvas = canvas const THREE = createScopedThreejs(canvas) // 像素缓冲按 dpr 放大,但上限压到 2,低端机开 3 会明显掉帧 const win = wx.getWindowInfo ? wx.getWindowInfo() : wx.getSystemInfoSync() const dpr = Math.min(win.pixelRatio || 2, 2) const width = win.windowWidth const height = width * 0.8 // 与 wxss 中 600rpx 的宽高比保持一致 const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true }) renderer.setPixelRatio(dpr) // 第三个参数 false 表示不要让 Three 去改 canvas 的 style // 小程序里 style 归 wxss 管,传 true 会把尺寸覆盖成 px,布局会错位 renderer.setSize(width, height, false) const scene = new THREE.Scene() const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 100) camera.position.set(0, 0, 3.2) const dirLight = new THREE.DirectionalLight(0xffffff, 1.2) dirLight.position.set(2, 3, 4) scene.add(dirLight) scene.add(new THREE.AmbientLight(0xffffff, 0.6)) const cube = new THREE.Mesh( new THREE.BoxGeometry(1, 1, 1), new THREE.MeshStandardMaterial({ color: 0x4a90d9, roughness: 0.4 }) ) scene.add(cube) this._model = cube this._scene = scene this._renderer = renderer const clock = new THREE.Clock() const loop = () => { cube.rotation.y += clock.getDelta() * 0.8 renderer.render(scene, camera) // 用 canvas 节点的 rAF,不是全局 window.requestAnimationFrame this._raf = canvas.requestAnimationFrame(loop) } this._raf = canvas.requestAnimationFrame(loop) }) }, onUnload() { if (this._raf && this._canvas) this._canvas.cancelAnimationFrame(this._raf) } })

几个参数值得单独说。setPixelRatio传的是设备像素比,它决定实际渲染的像素缓冲大小,直接影响清晰度和 GPU 负载,2 是清晰度和性能的平衡点;setSize的第三个参数在小程序里几乎必须传false,因为 canvas 的视觉尺寸由 wxss 决定,让渲染器再写一次 style 会造成尺寸二义性,表现就是模型被拉伸或者只渲染左下角一块;渲染循环里用的canvas.requestAnimationFrame和取消用的canvas.cancelAnimationFrame必须来自同一个 canvas 节点,跨节点调用不会生效,页面卸载后循环还在跑,这是内存泄漏的常见源头。

3. 模型加载链路:GLTF 从服务端到小程序 canvas 的完整路径

3.1 格式选型:为什么 GLTF 比 OBJ、FBX 更适合这个场景

小程序对包体和内存都比较敏感,模型格式的选择直接决定首屏体验。常见的几种格式在这个场景下的差别很明显:

格式单文件内嵌贴图动画解析依赖适合场景
GLB是支持支持GLTFLoader首选,一次请求拿全
GLTF + bin + 贴图否分离支持GLTFLoader需要按需替换贴图时
OBJ否不支持不支持OBJLoader + MTLLoader只有几何体的老资源
FBX是支持支持FBXLoader,体积大美术源文件,不建议直出
STL是不支持不支持STLLoader,无材质工业件单色预览

我一般要求美术导出 GLB,理由有三个:单文件少一次网络往返,小程序并发请求本身就有开销;GLTF 的 PBR 材质和 Three.js 的MeshStandardMaterial是一一对应的,不用做材质转换;动画是采样式的关键帧,解析成本比 FBX 低一个量级。体积上,压缩后的 GLB 控制在 3MB 以内比较稳妥,超过这个数在 4G 网络下首帧等待会明显超出用户耐心阈值,需要走后面第 6 章的预下载。

3.2 用 wx.downloadFile 接管文件加载

Three.js 的FileLoader内部用的是XMLHttpRequest,小程序没有这个对象,所以要么改写FileLoader,要么干脆绕过它,自己下载完直接调loader.parse。后者更可控,我一般用这种方式,因为下载失败、状态码异常、文件读取出错这三类问题都能分别拿到错误信息,而不是一个笼统的 onError。

// utils/load-model.js function loadGLB(THREE, loader, url, onLoad, onError) { wx.downloadFile({ url, success(res) { if (res.statusCode !== 200) { onError(new Error('模型下载失败,HTTP ' + res.statusCode)) return } // 临时文件路径只能通过文件系统接口读,不能直接 fetch wx.getFileSystemManager().readFile({ filePath: res.tempFilePath, success(file) { // 第二个参数是资源基路径,贴图的相对路径会基于它拼接 // 传错会导致贴图全部 404,但模型本体正常显示 const basePath = url.slice(0, url.lastIndexOf('/') + 1) loader.parse(file.data, basePath, onLoad, onError) }, fail: onError }) }, fail: onError }) } module.exports = { loadGLB }

wx.downloadFile需要在后台配置 downloadFile 合法域名,这一条在开发工具里勾了“不校验合法域名”能跑通,真机预览立刻失败,是新人最常踩的坑之一。readFile不传 encoding 时返回的是 ArrayBuffer,正好是parse需要的类型。parse的第二个参数容易被忽略,它决定了 gltf 里"uri": "textures/base.png"这类相对路径拼出来的完整地址,如果模型用的是外置贴图而这里传了空字符串,贴图会全部加载失败,但几何体和材质颜色仍然正常,表现为“模型是灰的”。

3.3 贴图加载:ImageLoader 必须替换

GLTFLoader 内部加载贴图走的是THREE.ImageLoader,而它的实现是document.createElement('img')。小程序没有 document,所以即便模型解析成功,贴图也一定加载不出来。替换方案是用 canvas 节点自带的createImage(),它返回的对象接口和浏览器 Image 基本一致,支持 onload、onerror 和 src 赋值。

// utils/patch-image-loader.js module.exports = function patchImageLoader(THREE, canvas) { THREE.ImageLoader.prototype.load = function (url, onLoad, onProgress, onError) { // GLB 内嵌纹理会生成 blob: 开头的临时地址 // 小程序的 createImage 不支持 blob 协议,这里直接跳过并给出提示 if (/^blob:/.test(url)) { console.warn('跳过 blob 贴图,建议导出时把贴图外置或转成 base64') return } const img = canvas.createImage() img.onload = () => onLoad(img) img.onerror = (e) => onError && onError(e) img.src = url return img } }

这段代码里最值得注意的是 blob 那个分支。GLB 把贴图内嵌在二进制里时,GLTFLoader 会通过URL.createObjectURL生成一个临时地址,而小程序既没有URL对象也没有 blob 协议支持,这条路是断的。解决办法有两个:一是导出时用“GLTF 分离”模式,贴图作为独立的 png 放在同目录,靠 basePath 拼地址加载;二是让美术把贴图压到很小后用 base64 data URI 内嵌,createImage对 data URI 是支持的。我在项目里一般选第一种,因为分离式贴图可以单独做 CDN 缓存和格式转换,改图不用重新导出整个模型。

4. 交互与性能:触摸控制、帧率、内存三道关

4.1 自己写触摸旋转与双指缩放,比搬 OrbitControls 更稳

OrbitControls依赖window上的事件监听和document元素,在小程序里搬过来要改的地方比写一套还多。展示类场景的交互其实只有三件事:单指拖动旋转、双指捏合缩放、惯性回弹(可选),自己写不到一百行,参数还完全可控。

// 在 Page 对象上补充三个触摸处理函数 onTouchStart(e) { if (e.touches.length === 1) { this._touch = { mode: 'rotate', x: e.touches[0].x, y: e.touches[0].y } } else if (e.touches.length === 2) { const [a, b] = e.touches this._touch = { mode: 'zoom', dist: Math.hypot(a.x - b.x, a.y - b.y) } } }, onTouchMove(e) { const t = this._touch if (!t || !this._model) return if (t.mode === 'rotate' && e.touches.length === 1) { const dx = e.touches[0].x - t.x const dy = e.touches[0].y - t.y // 0.008 是屏幕像素到弧度的换算系数,调大转得快,调小更跟手 this._model.rotation.y += dx * 0.008 this._model.rotation.x += dy * 0.008 // 俯仰角限幅,避免模型被翻到背面看到中空结构 const limit = Math.PI / 3 this._model.rotation.x = Math.max(-limit, Math.min(limit, this._model.rotation.x)) t.x = e.touches[0].x t.y = e.touches[0].y } if (t.mode === 'zoom' && e.touches.length === 2) { const [a, b] = e.touches const dist = Math.hypot(a.x - b.x, a.y - b.y) // 用乘法而不是加法,缩放手感才均匀 this._model.scale.multiplyScalar(dist / t.dist) const s = this._model.scale.x // 缩放上下限,防止缩成一个点或者撑满到看不见边缘 if (s < 0.4 || s > 4) this._model.scale.setScalar(Math.max(0.4, Math.min(4, s))) t.dist = dist } }, onTouchEnd() { this._touch = null }

坐标取的是touches[0].x / y,这是 canvas 组件坐标系里相对左上角的位置,比clientX更准,因为页面可能有滚动偏移。旋转系数 0.008 不是随便写的,它大致等于“拖动 100 像素转 45 度”,和主流电商 3D 展示的手感比较接近;如果模型本身尺寸很大,可以适当调小到 0.005。缩放用multiplyScalar而不是直接赋值,是为了保留模型原始缩放比例,很多 GLB 导出时自带 0.01 这种单位换算,直接赋值会把比例冲掉。

4.2 帧率与像素比:setPixelRatio 的取值边界

帧率的瓶颈通常不在 JS 逻辑,而在 GPU 的填充率。小程序 canvas 的实际渲染分辨率等于 CSS 尺寸乘以 pixelRatio,一台 dpr 为 3 的手机开满像素比,渲染面积是 dpr 为 1 的九倍,中低端芯片很容易掉到 20 帧以下。

我一般按下面的规则取:

设备像素比建议 setPixelRatio说明
11无需处理
22大多数机型,清晰度和性能平衡
3 及以上2上限压到 2,肉眼差异很小
低端机(内存小于 3GB)1.5靠 canvas 的 CSS 尺寸撑清晰度

判断低端机没有官方接口,常见做法是用wx.getDeviceInfo()里的benchmarkLevel,它是 iOS 和部分安卓机型提供的性能等级,小于 10 的按低端处理。这只是估算,真机上还是要靠实测帧率来定,思路是在渲染循环里每秒统计一次帧数,把结果打到 console 里,连续跑 30 秒看波动:

let frames = 0 let lastTick = Date.now() const loop = () => { frames++ const now = Date.now() if (now - lastTick >= 1000) { console.log('fps', frames, 'pixelRatio', renderer.getPixelRatio()) frames = 0 lastTick = now } renderer.render(scene, camera) this._raf = canvas.requestAnimationFrame(loop) }

如果 fps 稳定在 55 以上,说明还有余量;掉到 40 以下就要动手了,优先降 pixelRatio,其次降 antialias,最后才是减面。抗锯齿这个选项在小屏幕上收益不明显,开销却不小,低端机可以直接关掉。

4.3 页面返回后的资源释放与 context 数量限制

小程序对同时存在的 WebGL context 数量有上限,iOS 上尤其明显。如果每次进入 3D 页面都新建 renderer 而不释放,来回进出几次之后就会拿不到新的 context,表现是页面白屏但没有任何报错,这个现象最让人困惑。

释放要分三层做:几何体、材质、纹理,每一层都有dispose();renderer 自身要dispose(),有条件的话再调forceContextLoss()主动交还上下文。纹理藏在材质的各个属性里,所以遍历材质对象的键是最省事的写法。

onUnload() { const { _renderer: renderer, _scene: scene, _canvas: canvas, _raf: raf } = this if (raf && canvas) canvas.cancelAnimationFrame(raf) if (scene) { scene.traverse((obj) => { if (obj.geometry) obj.geometry.dispose() const mats = Array.isArray(obj.material) ? obj.material : [obj.material] mats.forEach((m) => { if (!m) return // 材质上的贴图属性名不固定,遍历一遍最保险 Object.keys(m).forEach((key) => { const val = m[key] if (val && val.isTexture) val.dispose() }) m.dispose() }) }) scene.clear() } if (renderer) { renderer.dispose() if (renderer.forceContextLoss) renderer.forceContextLoss() } this._model = null this._scene = null this._renderer = null }

scene.clear()是清空子节点引用,traverse里做的才是真正释放 GPU 资源,两者顺序不能反。forceContextLoss不是所有版本都有,调用前要判断存在性。这套释放逻辑建议封装成一个disposeScene(renderer, scene)工具函数,每个 3D 页面卸载时都调用一次,别指望某一次忘了之后靠重进页面自愈,那种情况只会越积越多。

5. 避坑与排查:五个真实翻车现场的现象、原因和解决

5.1 渲染类:黑屏、半屏、拉伸

现象一:页面一片空白,控制台没有任何报错。原因通常是createSelectorQuery放在了onLoad里执行,此时 canvas 节点还没完成渲染,拿到的res[0].node是 undefined,后续代码被静默跳过。也可能是type写成了默认值,拿到的是 2D 上下文,new THREE.WebGLRenderer时不报错但什么也画不出来。解决方式是把初始化挪进onReady,并且在拿到节点后加一行判空日志,把“节点没拿到”和“渲染出错”区分开。

现象二:模型只在 canvas 左下角占四分之一,或者被横向拉宽。这是像素缓冲和 CSS 尺寸不一致导致的。常见写法是renderer.setSize(w, h)不传第三个参数,Three 默认会去改 canvas 的 style 宽高,而小程序里 canvas 的视觉尺寸由 wxss 决定,两套尺寸打架就会错位。统一改成setSize(w, h, false),并且让 w、h 与 wxss 里的宽高比严格对应,问题就消失了。像素比也要同步设置,只设setSize不设setPixelRatio,在高分屏上会糊成一团。

现象三:iOS 上模型全黑,安卓正常。优先查光照和色彩空间。GLTF 的贴图默认按 sRGB 处理,如果手动设置过texture.colorSpace或者用了老版本的encoding,iOS 的渲染路径可能和安卓不一致。另一个常见原因是环境光强度给得太低,MeshStandardMaterial在没有环境贴图的情况下非常依赖方向光,把AmbientLight强度提到 0.6 以上再试,能快速确认是不是光照问题。

5.2 加载类:模型 404 与贴图丢失

现象:开发工具里模型正常,真机预览加载失败。八成是 downloadFile 合法域名没配。开发工具可以勾选“不校验合法域名”绕过,真机没有这个开关。解决方式是在后台把模型所在的 CDN 域名加进 downloadFile 白名单,注意是 downloadFile 不是 request,两个白名单是分开配置的。另外 URL 里带中文或者空格时要做encodeURI,否则部分安卓机在拼接请求时会截断。

现象:模型出来了,但整体是灰白色,没有纹理。前面提过,loader.parse的第二个参数传空会导致相对路径拼不出来。还有一种情况是贴图确实请求到了,但flipY不对,表现为纹理上下颠倒,这种情况在 GLTF 里一般不会出现,因为 GLTFLoader 内部会处理,但如果你手动替换过TextureLoader就可能踩到。

现象:贴图全部加载失败,控制台出现 blob 相关警告。GLB 内嵌贴图会走URL.createObjectURL,小程序环境没有这个 API。解决办法在第 3 章讲过:改成 GLTF 分离导出,或者把贴图压成 base64。这个坑的隐蔽之处在于模型本体完全正常,只是没有颜色,容易误判成材质参数问题。

5.3 性能类:卡顿、闪退、二次进入失败

现象:第一次进页面正常,返回再进就白屏。WebGL context 没有释放,累计到上限后新的 renderer 创建失败但不抛错。排查方式是每次创建 renderer 后打一条日志带序号,看第几次开始拿不到。修复就是在onUnload里执行完整的 dispose 链路,包括forceContextLoss。

现象:页面越用越卡,setData 之后尤其明显。把 Three 的对象塞进了data里。Three 的 scene、mesh 内部有大量循环引用,setData会尝试深度序列化,直接卡死主线程。正确做法是 Three 相关对象全部挂在this上,data里只放loading、error这类基础状态。这条经验是用一次线上事故换来的,当时一个页面进去就卡三秒,查了半天才发现是某个同学把renderer放进了 data。

现象:模型面数不高但帧率很低。多半是渲染分辨率太高,或者材质用了大量透明贴图。透明材质会关闭深度写入,产生过度绘制,几层叠在一起开销翻倍。可以在真机上临时把 pixelRatio 降到 1 对比帧率,如果提升明显就说明是填充率瓶颈,按 4.2 的表格调整即可。

6. 进阶:把模型首帧压进 1.5 秒的做法与验证方式

6.1 预下载与本地缓存

3D 页面的首帧时间主要由模型下载决定,所以最有效的手段是把下载提前。我的做法是在首页onLoad时就把模型downloadFile下来,把临时路径挂到getApp().globalData上,用户点进 3D 页时直接读本地文件,省掉一次网络往返。临时文件在本次小程序生命周期内有效,退出后失效,所以还需要配合wx.setStorage记录版本号,版本不变时用wx.saveFile存成持久文件,避免每次冷启动都重新下载。

// app.js 里做一次预热,注意别阻塞首页渲染 wx.downloadFile({ url: MODEL_URL, success(res) { if (res.statusCode === 200) getApp().globalData.modelPath = res.tempFilePath } })

6.2 用打点验证首帧时间,而不是凭感觉

优化有没有效果,得用同一套打点去量。我在三个位置埋时间戳:页面onReady、模型parse成功、第一帧render完成,三者相减分别得到初始化耗时和渲染耗时。真机上连续跑五次取中位数,比开发工具里的数字可靠得多。如果初始化耗时超过 300ms,多半是 renderer 创建或场景构建有冗余;如果渲染耗时超过 800ms,则要回到下载和解析环节去找原因。

这套打点我一般会保留在代码里,用__DEV__之类的开关控制是否输出,方便上线后回捞问题。写到这里也提醒一句:适配层、加载链路、释放逻辑这三块,我每次新开项目都会从头走一遍最小立方体,确认链路通了再上模型。有次跳过了这一步,直接拿生产模型调了两天,最后发现是 canvas 的type属性写漏了,这种低级错误在复杂现场里最难看见。希望这些经验能帮你少走点弯路。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进