微信小程序 three.js 3D 展示:适配层与性能优化实战
简介:这是一份面向微信小程序开发者的 three.js 三维模型展示入门示例源码,适合希望在原生小程序中快速接入三维场景、理解模型加载与动画控制的前端学习者,也适合作为课程演示或项目原型参考。压缩包共 18 个文件,约 258KB,其中 8 个 js 文件承载主要逻辑与交互,5 个 json、2 个 wxml 和 2 个 wxss 负责页面配置、结构及样式,另含 1 个 gltf 模型文件,整体轻量、目录清晰,便于按模块阅读和移植。已有 2309 人学习,说明该示例在入门实践中具有一定参考价值。读者可基于示例调整 js 中的函数,实现模型旋转、掉落等动画效果,并借助源码中的虾模型与 three.js 组件了解模型上传服务器后在小程序页面中引用展示的完整流程,同时获得常见问题的排查思路,适合快速上手、避坑和二次改造。
1. 从一张渲染图说起:小程序里跑真 3D 到底卡在哪
产品拿着渲染图来问,能不能在手机里转着看这台设备,还要能换配色。最初方案是序列帧,360 张角度图压完接近 18MB,主包直接装不下,加载像幻灯片。后来改用 WebGL,用 three.js 在微信小程序里跑真模型,包体只多两兆,旋转缩放顺滑,改个材质参数不用重导资源。
微信小程序 3D 展示的难点在运行环境,不在 three.js。浏览器中 three.js 依赖 window、document、requestAnimationFrame、Image 和 fetch,但小程序页面没有 DOM,这些对象全没有。直接 import three 控制台第一行就报document is not defined。面向需要在小程序落地 3D 展示的前端,这篇按真实顺序拆:先补运行环境,再谈渲染和性能。适配层写错,后面的工作都会白费。
2. 技术选型与工程结构:three.js 塞进小程序目录的三条硬约束
2.1 三条路线放在一起比,three.js 赢在哪
同一个小程序 3D 需求通常有三条路:序列帧、裸 WebGL、three.js 适配层。
序列帧用<image>轮流切换图片,实现最简单,任何机型都能跑。代价是包体和角度数成正比,要支持自由缩放、换材质、点选模型部件,序列帧方案就行不通。
裸 WebGL 用小程序原生canvas.getContext('webgl'),手写顶点着色器、MVP 矩阵和拾取逻辑。性能上限最高,但一个旋转惯性和一个 GLTF 解析器就要写几百行,产品改需求时还要重写;项目排期一周时,这条路撑不住。
three.js 适配层用成熟的场景图、GLTFLoader、光照材质体系,缺什么补什么。代价是包体(压缩后 600KB 上下)和适配层调试时间。用适配层后,原来在 H5 上的材质、灯光、动画经验都能直接复用,团队迁移成本最低。
| 路线 | 包体 | 交互自由度 | 开发成本 | 适用场景 |
|---|---|---|---|---|
| 序列帧 | 大(按角度线性增长) | 只能固定角度、固定缩放 | 极低 | 电商主图 360 度旋转 |
| 裸 WebGL | 小 | 完全自控 | 高 | 单一特效、极致性能 |
| three.js 适配层 | 中(约 600KB) | 高,生态齐全 | 中 | 产品展示、模型查看器 |
需求是“转着看、能换色、能点部件”,three.js 是唯一兼顾自由度和工期的选择。
2.2 目录结构:适配层必须跑在 three 之前
目录这样摆,重点是libs/shim.js的加载顺序:
miniprogram/ ├─ app.js # 第一行 import './libs/shim.js' ├─ pages/index/ # 首页,只做入口和预加载 ├─ pkg-model/ # 分包,three 和模型都放这里 │ ├─ libs/ │ │ ├─ shim.js # 伪造 window / document / rAF │ │ ├─ three.weapp.js # 适配过的小程序版 three │ │ └─ GLTFLoader.js │ ├─ components/ │ │ └─ model-viewer/ # 封装好的 3D 组件 │ └─ viewer/ # 展示页 └─ assets/ └─ models/ # 放主包会给 2MB 限制添堵硬约束有两条。第一,ES module 的 import 会提升,但按书写顺序执行,shim.js必须出现在任何 three 相关 import 之前;最稳的做法是在app.js第一行导入它,确保先于页面栈执行。第二,three 和模型都塞进分包,主包留给业务;小程序主包有 2MB 限制,three 加一个像样模型就会超限,分包是唯一解。
2.3 生命周期:onReady 建、onHide 停、onUnload 卸
小程序页面的 canvas 不是 DOM 节点,得等渲染层准备好才拿得到:
// pkg-model/viewer/viewer.js Page({ data: { loading: true, error: '' }, // 用 onReady,不要用 onLoad:onLoad 阶段 select('#webgl') 拿到的是 null onReady() { wx.createSelectorQuery() .select('#webgl') .node() .exec((res) => { if (!res[0] || !res[0].node) { this.setData({ error: 'canvas 初始化失败' }); return; } this.init(res[0].node); }); }, onHide() { // 页面被盖住时不停 loop,等于后台满负荷跑 GPU this.paused = true; if (this.rafId && this.canvas) this.canvas.cancelAnimationFrame(this.rafId); }, onShow() { if (this.paused && this.renderer) { this.paused = false; this.startLoop(); } }, onUnload() { this.dispose(); } });onHide里停掉渲染循环是为了省电。小程序页面跳到详情页时,上一个页面的 canvas 上下文和 WebGL 上下文都还在内存里;不停循环,用户逛三分钟后回来发现手机发烫、电量掉一截。onUnload里的dispose更关键:WebGL 上下文在同一个渲染进程里数量有限,不释放就反复创建,进出五六次后新页面直接黑屏。这两处不写,上线后客诉一定出现。
3. 核心渲染链路拆解:从 GLB 文件到屏幕上那一帧的完整路径
3.1 适配层:三个必须伪造的全局对象
three.js 顶层代码里有大量if (window.xxx)、document.createElementNS、new Image()。少伪造一个,import 阶段就会崩,错误堆栈还很难读。
// pkg-model/libs/shim.js —— 必须在任何 three 相关 import 之前执行 const g = typeof GameGlobal !== 'undefined' ? GameGlobal : globalThis; // 1. three 的 WebGLRenderer 构造、能力检测都会读 window / self g.window = g; g.self = g; // 2. 全局 requestAnimationFrame:小程序里只有 canvas node 上有,全局没有 g.requestAnimationFrame = (cb) => setTimeout(() => cb(Date.now()), 16); g.cancelAnimationFrame = (id) => clearTimeout(id); // 3. document.createElementNS:TextureLoader、CanvasTexture 都会走这里 g.document = { createElementNS(ns, tag) { if (tag === 'canvas') { // 离屏 canvas,给文字贴图、截图用 return (g.__offscreen || (g.__offscreen = wx.createOffscreenCanvas({ type: '2d', width: 1, height: 1 }))); } if (tag === 'img') { // 小程序的 Image 得从真实 canvas node 上创建,所以要先有主 canvas return g.__mainCanvas.createImage(); } return { style: {} }; }, createElement(tag) { return this.createElementNS('', tag); } };需要注意g.__mainCanvas的赋值时机:在页面init(canvasNode)里第一行就g.__mainCanvas = canvasNode,然后再加载模型。否则加载第一个带贴图的 GLB 时会报Cannot read property 'createImage' of undefined。这个错误是真机上最容易卡住新人的一处,堆栈指向 GLTFLoader 内部,看不出跟 canvas 有关。
注意:
requestAnimationFrame用 setTimeout 兜底只适合非渲染路径。真正的渲染循环要用canvasNode.requestAnimationFrame,它跟屏幕刷新同步,setTimeout 会掉帧。
3.2 渲染器、相机、光照:一组能直接用的初始化参数
// 承接上面的 init(canvas) init(canvas) { const sys = wx.getSystemInfoSync(); const width = sys.windowWidth; const height = sys.windowHeight; const dpr = Math.min(sys.pixelRatio, 2); // 上限卡 2,3x 屏按 3 倍渲染会掉帧 canvas.width = width * dpr; canvas.height = height * dpr; g.__mainCanvas = canvas; const renderer = new THREE.WebGLRenderer({ canvas, antialias: dpr < 2, // 高分屏像素密度够,关掉抗锯齿省一半填充率 alpha: false // 不透明背景,省一次合成 }); renderer.setPixelRatio(dpr); renderer.setSize(width, height, false); // 第三参数 false:小程序没有 style 可更新 renderer.outputColorSpace = THREE.SRGBColorSpace; const scene = new THREE.Scene(); scene.background = new THREE.Color(0xf2f4f8); const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 100); camera.position.set(0, 1.2, 4); camera.lookAt(0, 0, 0); // 环境光打底 + 一盏主光,够用且便宜;阴影一律不开 scene.add(new THREE.HemisphereLight(0xffffff, 0x445566, 1.1)); const dir = new THREE.DirectionalLight(0xffffff, 1.4); dir.position.set(3, 6, 4); scene.add(dir); this.renderer = renderer; this.scene = scene; this.camera = camera; this.startLoop(); // 先把空场景跑起来,再挂模型 }fov取 45 是产品展示的常用值,太大模型变形,太小显得平。near/far不要偷懒写 0.1/1000,深度缓冲精度会掉;模型尺寸在 1 到 10 个单位之间时,0.1/100 完全够。renderer.setSize第三参数必须传false,否则 three 会去改canvas.style,小程序 canvas node 上没有这个属性,会静默失败,结果是画面尺寸对不上、模型被拉伸。
光照故意只用两盏。不开启阴影很关键:小程序里shadowMap在低端安卓上会让帧率下降一半以上。要阴影就用烘焙贴图,或在模型正下方贴一张圆形渐变面片模拟,效果八成好,成本接近零。
3.3 GLB 加载:为什么 loader.load 一定失败
GLTFLoader.load内部走FileLoader,最终落到fetch或XMLHttpRequest,小程序两个都没有。正确做法是自己把二进制下载下来喂给parse:
// 用 downloadFile 而不是 request:只有它带 onProgressUpdate function loadGLB(url, onProgress) { return new Promise((resolve, reject) => { const task = wx.downloadFile({ url, timeout: 20000, success(res) { if (res.statusCode !== 200) { return reject(new Error('HTTP ' + res.statusCode)); } // 临时文件读成 ArrayBuffer,GLTFLoader.parse 只认这个类型 wx.getFileSystemManager().readFile({ filePath: res.tempFilePath, success(f) { // 第二个参数是资源基准路径;glb 内嵌贴图时传空串 new GLTFLoader().parse(f.data, '', resolve, reject); }, fail: reject }); }, fail: reject }); task.onProgressUpdate((p) => onProgress && onProgress(p.progress)); }); }onProgressUpdate的progress是 0 到 100 的整数,直接用来驱动 loaded 进度条。真实进度只在下载阶段有;parse阶段是纯 CPU 计算,没有进度可拿,界面要在这时切到“解析中”的文案,否则进度条会卡在 100% 不动,用户以为卡死。
失败处理不能只靠reject。线上常见情况是弱网下载超时,downloadFile的timeout设 20 秒,失败后重试一次,再失败就展示占位图和“重新加载”按钮。整个流程包一层 Promise 后,页面里只写loadGLB(url).then(...).catch(...),逻辑清爽。
3.4 触摸交互:把 touch 事件翻译成模型旋转
小程序的<canvas>支持bindtouchstart/move/end,但坐标系容易踩坑:
<!-- viewer.wxml --> <canvas type="webgl" id="webgl" class="stage" bindtouchstart="onTouchStart" bindtouchmove="onTouchMove" bindtouchend="onTouchEnd" />// viewer.js const clamp = (v, min, max) => Math.max(min, Math.min(max, v)); onTouchStart(e) { const t = e.touches[0]; // 关键:用 t.x / t.y(相对 canvas 左上角),不是 clientX this.last = { x: t.x, y: t.y }; this.velocity = 0; }, onTouchMove(e) { const t = e.touches[0]; const dx = t.x - this.last.x; const dy = t.y - this.last.y; this.last = { x: t.x, y: t.y }; if (!this.model) return; this.model.rotation.y += dx * 0.01; // 上下翻转限制在 ±35 度,转太多会看到模型背面和底部,很怪 this.model.rotation.x = clamp(this.model.rotation.x + dy * 0.01, -0.6, 0.6); this.velocity = dx * 0.01; // 记下来做惯性 }, onTouchEnd() { // 松手后按最后速度继续转,每帧衰减,直到停下 const decay = () => { if (!this.model || Math.abs(this.velocity) < 0.001) return; this.model.rotation.y += this.velocity; this.velocity *= 0.94; this.decayId = setTimeout(decay, 16); }; decay(); }e.touches[0].x与clientX的区别是最容易翻车的点:clientX是相对视口的,如果 canvas 不是从屏幕左上角铺满,所有位移都会偏,表现就是“手指划了三厘米,模型只转了一点点”。手感方面,旋转系数 0.01 是经验值,模型尺度变化大时按包围盒尺寸做归一化更稳。
惯性衰减用 0.94,2 秒左右自然停下。不要用OrbitControls直接接管触摸:它内部依赖addEventListener和 DOM 事件对象,得再写一层事件转发,而且默认右键平移、滚轮缩放在小程序里都没对应手势,不如自己写几十行来得直接。
4. 资源与性能:把 20MB 模型压到 1.8MB,首屏压进 1 秒
4.1 模型瘦身:从原始文件到能上线的三刀
设计给的模型通常是几十 MB 的 fbx 或 blend 导出,直接扔进小程序等于自杀。上线前必须过一遍命令行流水线:
# 1) 先摸清体积构成,看是几何体肥还是贴图肥 npx @gltf-transform/cli inspect raw.glb # 2) 一键优化:去重、合并相同材质、纹理重采样到 1024、转 webp npx @gltf-transform/cli optimize raw.glb model.glb \ --texture-compress webp \ --texture-size 1024 # 3) 再验一遍:node 数、三角面、贴图数量有没有异常 npx @gltf-transform/cli inspect model.glb三刀分别是:几何体焊接去重、贴图降采样并转 webp、删除无用节点和动画轨道。经验值是 20MB 的机械模型能压到 1.8MB 左右,代价是贴图细节有损。这里有个后话:webp 在部分老 iOS 机型上解不出来,贴图会变成纯黑。目标机型覆盖老设备时,--texture-compress jpg更保险,体积大 30% 但不会翻车。
另一个高频误区是上 Draco 压缩。Draco 的解码器是 wasm,小程序里虽然支持,但要额外加载解码器文件,还要处理初始化时序,省下来的那点体积完全不值得折腾。optimize自带的简化已经够用。
4.2 分包与预加载:把等待藏到用户看不见的地方
three 加模型必然超过主包限制,分包配置这样写:
{ "pages": ["pages/index/index"], "subpackages": [ { "root": "pkg-model", "pages": ["viewer/viewer"] } ], "preloadRule": { "pages/index/index": { "network": "all", "packages": ["pkg-model"] } } }preloadRule的作用是用户还在首页时就把分包悄悄拉下来,点进展示页时pkg-model已经在本地,省掉几百毫秒的分包下载。network: "all"表示 Wi-Fi 和 4G 都预载;流量敏感的产品可以改成"wifi",代价是 4G 用户首次进入要等。
模型文件本身可以再提前一步:首页onLoad里调一次wx.downloadFile把 GLB 存到临时目录,展示页直接读缓存路径。注意临时文件在本次启动内有效,跨天会失效,所以要做“缓存不存在就重新下载”的判断,不能无脑读。
4.3 真机观测:帧率、drawcall 和内存怎么看
开发者工具里的帧率参考价值有限,模拟器跑的是软件渲染,跟真机不是一回事。要在真机上量化,就自己统计:
// 每 60 帧汇总一次,别每帧 setData,那是性能杀手 let frames = 0; let last = Date.now(); startLoop() { const canvas = g.__mainCanvas; const loop = () => { if (this.paused) return; this.renderer.render(this.scene, this.camera); if (this.autoRotate && this.model) this.model.rotation.y += 0.004; frames += 1; const now = Date.now(); if (now - last >= 1000) { const info = this.renderer.info.render; console.log( `fps=${Math.round((frames * 1000) / (now - last))} ` + `calls=${info.calls} tris=${info.triangles}` ); frames = 0; last = now; } this.rafId = canvas.requestAnimationFrame(loop); }; this.rafId = canvas.requestAnimationFrame(loop); }三个指标的含义:fps低于 30 就会感觉卡;calls是 drawcall 数,超过 100 要合并材质,常见做法是把用同一张贴图的部件合并成一个 mesh;tris是三角面数,移动端控制在 10 万以内比较稳。
另外三个动作可以立刻提速:关掉antialias、把 dpr 上限压到 2、关闭阴影。三项一起做,中端安卓机上通常能把 25 帧拉到 50 帧以上。
5. 避坑与排查:小程序 WebGL 里五个高频翻车现场
5.1 白屏,控制台只有一句 “document is not defined”
现象:页面完全不渲染,报错指向 three 内部某一行,看不出关联。
原因:shim.js的执行时机晚于 three 的 import。ES module 的 import 会被提升到文件顶部,如果shim.js是写在页面文件里、而 three 在更靠前的 import 行,shim 根本没生效。
解决:把import './libs/shim.js'放到app.js第一行,全局只执行一次;页面里不再重复导入 three,改成从app.js上挂载的全局对象取,避免多处初始化的时序问题。
5.2 模型加载出来了,但整片死黑或者过曝
现象:控制台没有报错,scene.children.length也正常,屏幕上全是黑的或者白得刺眼。
原因:两种可能。一是没加光源,PBR 材质在没有环境光时就是纯黑;二是颜色空间没设对,outputColorSpace不设成 sRGB,贴图会发灰发暗,加上高亮光源后过曝。
解决:先确认scene里至少有一盏HemisphereLight加一盏DirectionalLight;再把renderer.outputColorSpace = THREE.SRGBColorSpace加上,模型贴图的colorSpace设为 sRGB。贴图发黑还有第三种可能:webp 在目标机型上解不出来,换回 jpg 试一次就能定位。
5.3 开发者工具里 60 帧,真机 20 帧还发烫
现象:模拟器上顺得不行,装到真机上转两下就明显卡顿,手机背面烫手。
原因:模拟器是软件渲染,不反映真实 GPU 负载。真机卡通常是三件事叠加:dpr 按 3 倍渲染、开了抗锯齿、开了阴影。像素填充率直接被乘了三倍。
解决:dpr 上限卡 2,antialias只在 dpr 小于 2 时开,阴影全部关掉。再用 4.3 里的统计脚本看calls,超过 100 就去合并材质。做完这三步一般能翻倍。
5.4 进出页面几次后黑屏,或者报 webgl context lost
现象:第一次进正常,返回再进就黑屏,进到第五六次必崩。
原因:onUnload里没有dispose。geometry、material、texture 占的显存没释放,WebGL 上下文也没销毁,同一个渲染进程里上下文数量有上限,超了就分配不到新的。
解决:写一个完整的dispose,遍历场景释放所有资源,再主动丢上下文:
dispose() { if (this.decayId) clearTimeout(this.decayId); if (this.rafId && g.__mainCanvas) g.__mainCanvas.cancelAnimationFrame(this.rafId); this.scene && this.scene.traverse((obj) => { if (obj.geometry) obj.geometry.dispose(); if (!obj.material) return; 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(); }); }); this.renderer && this.renderer.dispose(); if (this.renderer && this.renderer.forceContextLoss) { this.renderer.forceContextLoss(); // 主动丢上下文,别等回收 } this.scene = this.camera = this.renderer = this.model = null; }5.5 手指划三厘米,模型只转一点点
现象:旋转能动,但不管怎么划幅度都很小,或者只有 canvas 左上角一小块区域能响应。
原因:用了e.touches[0].clientX。这是相对整个视口的坐标,canvas 如果不是从屏幕左上角开始铺,起点就错了;同时 canvas 的像素尺寸设成了width * dpr,而事件坐标是 CSS 像素,两者差一个 dpr 倍。
解决:一律用e.touches[0].x / y,它由小程序直接给出相对 canvas 的坐标,已经处理好 dpr 换算。旋转系数按模型包围盒归一化,别写死一个 0.01 就以为通用。
6. 二次开发:把它改造成 properties 驱动的通用 3D 组件
页面里直接写 three,换个场景就得复制一遍。正确的收尾是抽成组件,把变化的部分全部提到 properties:
// pkg-model/components/model-viewer/index.js Component({ properties: { src: String, // glb 地址 autoRotate: { type: Boolean, value: true }, background: { type: String, value: '#f2f4f8' }, maxPixelRatio: { type: Number, value: 2 }, // 低端机可以传 1.5 initialRotationY: { type: Number, value: 0 } }, observers: { // 换模型走这里,不要重新 createComponent,直接换 scene 里的对象 src(val) { if (!val || !this.renderer) return; this.swapModel(val); } } });swapModel里先卸旧对象再挂新的,顺序是:遍历旧 model 释放 geometry 和 material →scene.remove(old)→loadGLB(new)→scene.add(gltf.scene)→ 根据包围盒重新调整相机 distance。少了包围盒归一化这一步,换一个尺寸差十倍的模型,画面里要么只剩一个点,要么整个屏幕都被塞满。
换模型时的验证清单,我每次都照着跑一遍:
| 检查项 | 判断标准 | 失败表现 |
|---|---|---|
| 文件格式 | 单文件 glb,无外挂 bin 和 jpg | 模型缺失,只有光照 |
| 体积 | 小于 3MB | 首屏白屏超过 3 秒 |
| 三角面数 | 小于 10 万 | 真机帧率低于 25 |
| 包围盒 | 长宽高都在 1 到 10 单位之间 | 相机位置不对,模型过大或过小 |
| 贴图格式 | 目标机型能解码(webp 需验证) | 模型全黑 |
| 材质数量 | 与贴图数量匹配,无冗余 | drawcall 过高 |
往上的扩展方向有三个最划算:材质替换,遍历model.traverse找到目标 mesh 改material.color,做配色选择器一下午能完;热点标注,用camera.project把 3D 坐标转成屏幕坐标,再用绝对定位的<view>盖上去,比在 3D 里做文字省事;离屏截图,把renderer.domElement换成wx.createOffscreenCanvas再渲染一次,导出图给分享卡片用。
从那以后我每次接新模型,都强制先跑一遍上面那张表,再上真机看 fps、calls、tris 三个数,绝不靠模拟器的“看起来挺顺”下结论。血泪经验是:小程序里的 3D 问题九成出在适配层和资源,不在渲染代码本身。代码在示例工程的viewer页和model-viewer组件两处,改 properties 就能套到自己的项目里。希望帮到你。
本文还有配套的精品资源,点击获取