简介:面向希望在微信小程序中集成 three.js 并加载外部 3D 模型的开发者,这是一份可直接参考的示例工程,重点解决小程序环境下第三方 3D 库引入、场景初始化、模型加载与交互渲染等常见问题。压缩包共 11 个文件,约 167KB,以 js 脚本和 json 配置为主,辅以 wxml 页面结构、wxss 样式及 md 说明文档,覆盖小程序入口配置、页面组织、three.js 加载器与模型加载模块等关键部分,目录规模精简,便于快速定位核心实现。已有 3208 人学习,适合具备基础 JavaScript 与小程序开发经验、希望了解 3D 模型接入流程和工程组织方式的读者参考;其中的加载器封装、配置文件和页面结构可作为搭建自有 3D 小程序、排查资源路径与渲染问题时的对照样本。
1. 微信小程序加载外部 3D 模型:先算清楚包体和首帧这笔账
商品详情页要放一个能转的模型,客户给了个 6 MB 的 GLB,产品经理说“就加个 3D 吧”。这是我在小程序项目里遇到过最典型的需求。用 JavaScript 在微信小程序里加载外部 3D 模型,技术路线本身并不神秘:<canvas type="webgl">拿到画布,适配一层 three.js,把模型下载到本地用户目录,用GLTFLoader.parse解成场景图,再挂一个渲染循环。真正让人反复返工的是另外三件事——包体积、首帧时间和中低端机的内存。这三件事在浏览器里基本感觉不到,在小程序里却直接决定功能是否上线。这篇内容适合两种人看:一种还没在小程序里跑通过 WebGL,需要一条能照抄的链路;另一种是一名大前端,进度卡在模型发黑、贴图丢失、进出页面几次就闪退的阶段。
2. 拿到 WebGL 画布:canvas 节点、dpr 与渲染循环的三处硬约束
小程序和浏览器最大的差异是没有 DOM。three.js 里那些document.createElement('canvas')、new Image()、window.requestAnimationFrame,在小程序环境里全部不存在。所以第一步不是写 3D 代码,而是先把这块画布和它的运行时环境准备好。画布没拿对,后面所有代码都算白写,而且报错信息往往只有一句“黑屏”,很难排查。
2.1 用 SelectorQuery 取 node,别在 onLoad 里抢先执行
<canvas type="webgl">在小程序里是原生组件,要拿到它的真实 node 对象才能往上挂 WebGL 上下文。取值必须用SelectOrQuery().node(),而且不能在onLoad里做——那个时间点节点还没渲染出来,查出来是null。我一般放在onReady里,或者用wx.nextTick再包一层兜底。
<!-- pages/model/index.wxml --> <canvas id="gl" type="webgl" class="gl-canvas" bindtouchstart="onTouchStart" bindtouchmove="onTouchMove" bindtouchend="onTouchEnd" ></canvas>// pages/model/index.js Page({ onReady() { wx.createSelectorQuery() .select('#gl') .node() .exec((res) => { const canvas = res && res[0] && res[0].node; if (!canvas) { // 节点没取到,90% 是容器 display:none 或查询时机太早 console.error('canvas node 缺失'); return; } const win = wx.getWindowInfo(); // 新基础库用这个,getSystemInfoSync 已不推荐 const dpr = Math.min(win.pixelRatio, 2); // 关键:封顶 2 canvas.width = win.windowWidth * dpr; canvas.height = win.windowHeight * dpr; this.initGL(canvas, win, dpr); }); }, });这段代码里真正要留意的是dpr那行。三倍屏机型上不封顶,等于像素量比两倍屏多出 125%,GPU 填充率瞬间见底,模型面数再少也会掉帧。我自己做过的项目里,封顶 2 之后中端安卓的帧率能稳定回到 40 以上。另外canvas.width/height设的是绘制缓冲区尺寸,样式的宽高交给 WXSS,两者不要混用。
2.2 适配层怎么选:现成适配包、自建 shim 与作用域隔离
three.js 跑在小程序里,主流做法是“作用域化”——不污染全局,而是用一个工厂函数把 canvas 喂进去,返回一份只属于这块画布的THREE。
// libs/adapter.js // 作用:把小程序 canvas 包成 three 认识的最小运行时 function createScopedThreejs(canvas) { const raf = canvas.requestAnimationFrame.bind(canvas); const caf = canvas.cancelAnimationFrame.bind(canvas); // three 内部会找 document / window,这里给最小替身 const fakeDocument = { createElementNS: () => canvas.createImage(), // 只处理 img 这一种 createElement: () => canvas.createImage(), addEventListener: () => {}, removeEventListener: () => {}, }; const fakeWindow = { devicePixelRatio: 2, innerWidth: canvas.width, innerHeight: canvas.height, addEventListener: () => {}, removeEventListener: () => {}, requestAnimationFrame: raf, cancelAnimationFrame: caf, }; return require('./three.min.js')(fakeWindow, fakeDocument); }这段代码属于说明性代码,不是可以直接运行的完整版本。它想表达的是:适配层真正要补的只有四类东西——document.createElementNS(贴图要用)、window上的尺寸与事件空实现、requestAnimationFrame的接管、以及performance.now。社区里已经有一个把 three.js 包好的小程序适配包,用法比我手写的更完整,但它同样需要传入 canvas 节点;这一点和上面的思路完全一致。
选哪个?如果你只是要“加载一个模型转一转”,直接用现成适配包,少踩坑;如果你要做多场景、多页面共享同一份 three 实例,我建议你自己维护适配层,或者至少把适配层包一层,因为现成包的版本升级会牵动全局,而小程序的包体积不允许你同时装两份 three。
要注意的是,适配层返回的THREE是独立的。不要把它和 npm 里那份 three 混用,instanceof判断会失效,材质和几何体互相赋值时会静默失败,这种 bug 排查起来非常痛苦。
2.3 渲染循环与页面生命周期对齐
小程序的页面会被反复onHide/onShow,用户从商品页返回列表再点进来,这个页面实例可能还活着,渲染循环如果不做停止和恢复,GPU 就会一直占用。更严重的是,onUnload之后循环还在跑,会去访问已经销毁的画布,直接导致闪退。
initGL(canvas, win, dpr) { const THREE = createScopedThreejs(canvas); this.THREE = THREE; const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true, }); renderer.setPixelRatio(dpr); renderer.setSize(win.windowWidth, win.windowHeight, false); // 第三个参数 false:不写 style,样式归 WXSS 管 const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(45, win.windowWidth / win.windowHeight, 0.1, 1000); camera.position.set(0, 1.2, 3.5); camera.lookAt(0, 0, 0); // 环境光 + 主光,缺了模型就是一片黑,后面会专门讲 scene.add(new THREE.AmbientLight(0xffffff, 1.2)); const dir = new THREE.DirectionalLight(0xffffff, 1.6); dir.position.set(2, 4, 3); scene.add(dir); this.renderer = renderer; this.scene = scene; this.camera = camera; this.clock = new THREE.Clock(); this.startLoop(); }, startLoop() { if (this._running) return; this._running = true; const tick = () => { if (!this._running) return; const delta = this.clock.getDelta(); if (this.mixer) this.mixer.update(delta); // 骨骼动画 if (this.ctrl) this.ctrl.update(delta); // 手势惯性 this.renderer.render(this.scene, this.camera); this._raf = this.canvasNode.requestAnimationFrame(tick); }; this._raf = this.canvasNode.requestAnimationFrame(tick); }, onHide() { this._running = false; if (this._raf) this.canvasNode.cancelAnimationFrame(this._raf); }, onShow() { if (this.renderer) this.startLoop(); }, onUnload() { this._running = false; if (this._raf) this.canvasNode.cancelAnimationFrame(this._raf); this.scene && this.scene.traverse((obj) => { 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) => m[k] && m[k].isTexture && m[k].dispose()); m.dispose(); }); } }); this.renderer && this.renderer.dispose(); }clock.getDelta()这个细节容易被忽略:如果用固定步长(比如永远+= 0.016),页面切后台再回来,动画会“跳帧”。getDelta拿到的是真实间隔,记得在onShow里重置一下时钟,否则第一次 delta 可能是几十秒,骨骼动画会直接飞出去。
renderer.setSize(w, h, false)的第三个参数,我在第一次写的时候没有传false,结果 three 会去改 canvas 的 style。小程序里的 style 是 WXSS 说了算,两边互相覆盖,表现为画布尺寸忽大忽小。
3. 外部模型加载链路:从 wx.downloadFile 到 GLTFLoader.parse
浏览器里一句loader.load(url, cb)就能搞定的事,在小程序里至少要拆成三步:下载到本地、读出 ArrayBuffer、交给解析器。中间任何一步的路径写错,最终表现都是“模型没出来”,而且没有任何提示。这条链路我建议按“下载 → 缓存 → 读取 → 解析 → 贴图 → 归一化”的顺序逐段验证,每段单独打日志。
3.1 下载与本地缓存:USER_DATA_PATH 的正确用法
外部模型不能像网页那样直接fetch。小程序的网络请求走wx.downloadFile,而且服务器域名必须在后台配到 downloadFile 合法域名里,否则会直接失败。
// utils/download.js const fs = wx.getFileSystemManager(); // 一个够用的字符串哈希,用来做缓存文件名 function hashKey(str) { let h = 5381; for (let i = 0; i < str.length; i++) h = (h * 33) ^ str.charCodeAt(i); return (h >>> 0).toString(36); } function fetchToLocal(url) { const cachePath = `${wx.env.USER_DATA_PATH}/m_${hashKey(url)}.glb`; // 1) 先看缓存,命中直接返回,省流量也省首帧 try { fs.accessSync(cachePath); return Promise.resolve(cachePath); } catch (e) { // 未缓存,继续下载 } // 2) 下载时直接指定 filePath,落盘一次到位,避免 copyFile return new Promise((resolve, reject) => { wx.downloadFile({ url, filePath: cachePath, timeout: 20000, success: (res) => { if (res.statusCode === 200) resolve(cachePath); else reject(new Error(`download ${res.statusCode}`)); }, fail: reject, }); }); } module.exports = { fetchToLocal };三个参数值得说清:filePath必须是wx.env.USER_DATA_PATH下的路径,不能写代码包路径;timeout建议显式给,默认值在弱网下容易让用户以为卡死;hashKey的用途是让同一 URL 反复进入页面时不再重复下载,实测能把二次进入的首帧从 1.2 s 压到 200 ms 以内。
USER_DATA_PATH有容量上限(不同基础库略有差异,量级在 200 MB 左右),模型文件累积多了会被系统清理。我的习惯是:进页面时按需下载,退出时只保留最近 3 个,超出就unlink。
提示:缓存文件名里不要带
/或?,从 CDN 拿到的 URL 经常带签名参数,直接用原 URL 当文件名会创建失败。
3.2 把 ArrayBuffer 喂给 GLTFLoader
拿到本地路径之后,用readFile读成 ArrayBuffer。注意读文件时不要传 encoding,否则拿回来的是字符串,解析器拿到会直接报“Unsupported glTF-Binary header”。
// utils/loader.js function loadGLB(THREE, filePath) { const fs = wx.getFileSystemManager(); return new Promise((resolve, reject) => { fs.readFile({ filePath, // 不传 encoding => 返回 ArrayBuffer success: (res) => { const loader = new THREE.GLTFLoader(); // 第二个参数是资源基准路径,模型若引用外部贴图,会拼在这个目录下找 loader.parse(res.data, `${wx.env.USER_DATA_PATH}/`, resolve, reject); }, fail: reject, }); }); }loader.parse(data, path, onLoad, onError)是这个链路的重点。path不传或者传错,外部贴图全部 404,最终模型是白模——不报错,但不报错反而难排查。如果模型和贴图是放在代码包里的,path就要传代码包内的相对目录,比如'/assets/models/'。
解析完成后的对象结构是gltf.scene(场景图)、gltf.animations(动画片段数组)、gltf.scenes。我一般只取gltf.scene,把它整体加到一个THREE.Group里,后续的所有缩放、旋转都作用在这个 Group 上,不直接改模型根节点——否则模型自带的变换会被覆盖掉,表现为“模型躺倒了”或者“跑到相机后面去了”。
3.3 贴图这一关:ImageLoader 必须被改写
这是整条链路上最容易出错的地方。three.js 的TextureLoader最终会走ImageLoader,而ImageLoader内部用的是document.createElementNS('http://www.w3.org/1999/xhtml', 'img')。小程序的适配层如果只补了createElement没补createElementNS,贴图会全部失败,而且控制台只有一句“Cannot read property 'src' of undefined”。
// utils/patch-image.js function patchImageLoader(THREE, canvas) { THREE.ImageLoader.prototype.load = function (url, onLoad, onProgress, onError) { const manager = this.manager; manager.itemStart(url); const img = canvas.createImage(); img.onload = () => { manager.itemEnd(url); onLoad(img); }; img.onerror = (e) => { manager.itemError(url); manager.itemEnd(url); onError && onError(e); }; // src 支持代码包相对路径、USER_DATA_PATH 绝对路径、base64 img.src = url; return img; }; }改写之后,img.src的取值方式决定了贴图能不能找到。我的经验是:贴图和模型一起打进代码包,用相对路径最稳;放 CDN 的话,除了要配 downloadFile 域名,还要考虑createImage对网络图是否走同一套域名校验,多一层不确定性。
还有一个必须提前知道的事实:GLB 内嵌贴图走不通。three.js 在处理bufferView形式的图片时,会创建Blob再用URL.createObjectURL生成一个临时地址,而小程序两个 API 都没有。所以单文件 GLB 里如果打包了贴图,解析会在这里断掉。解决办法不是打补丁,而是在构建阶段把 GLB 拆开——这件事放到最后一章,用一个几十行的 Node 脚本解决。
3.4 模型落地:归一化、动画与首帧
模型从建模软件导出来,尺寸、朝向、原点位置基本都是随意的。同一个页面要展示三个不同来源的模型,如果不做归一化,就会出现一个大得占满屏、一个小得看不见。
function normalizeModel(THREE, object3d, targetSize = 1) { // 1) 算出包围盒 const box = new THREE.Box3().setFromObject(object3d); const size = new THREE.Vector3(); const center = new THREE.Vector3(); box.getSize(size); box.getCenter(center); // 2) 把模型平移到原点 object3d.position.sub(center); // 3) 按最长边缩放到目标尺寸 const maxAxis = Math.max(size.x, size.y, size.z) || 1; const scale = targetSize / maxAxis; object3d.scale.setScalar(scale); return { box, size, scale }; }setFromObject会遍历整个子树,模型节点多的时候有几十毫秒开销,放到首帧之前做,别在渲染循环里调。缩放之后相机距离也要跟着调,我习惯把相机放到targetSize * 3的位置,再用lookAt对准原点,这样不管模型原始尺寸是 0.01 还是 1000,构图都一致。
动画部分:
if (gltf.animations && gltf.animations.length) { this.mixer = new THREE.AnimationMixer(gltf.scene); const action = this.mixer.clipAction(gltf.animations[0]); action.setLoop(THREE.LoopRepeat, Infinity); action.play(); }AnimationMixer的根节点必须是gltf.scene,不能是外面包的那层 Group——骨骼动画的轨道名称是相对gltf.scene解析的,挂错层级就会“动画播了但模型不动”,这个 bug 很难靠日志发现,只能对比着试。
4. 交互与性能:转得动、不卡顿、不闪退的三条线
模型显示出来只是及格线。用户上手第一件事就是拿手指划,划不动或者划起来像在拖橡皮筋,功能等于没做。性能上更现实:小程序的 JS 逻辑层和渲染层是分开的,长时间的渲染循环会同时占用两边,一旦超出中低端机的承受能力,表现是掉帧、发热、最后闪退。
4.1 手写触摸控制器代替 OrbitControls
three.js 自带的OrbitControls依赖document.addEventListener和pointerdown,小程序里没有。改造成本不低,我一般直接手写一个几百字节的控制器,功能上只保留“拖动旋转 + 双指缩放 + 松手惯性”,足够覆盖商品展示场景。
// utils/touch-ctrl.js function createTouchCtrl(canvas) { let lastX = 0, lastY = 0, lastDist = 0; let vx = 0, vy = 0; // 用于松手后的惯性 const state = { rotX: 0, rotY: 0, scale: 1 }; canvas.addEventListener('touchstart', (e) => { const t = e.touches; if (t.length === 1) { lastX = t[0].x; lastY = t[0].y; vx = vy = 0; } else if (t.length === 2) { lastDist = Math.hypot(t[0].x - t[1].x, t[0].y - t[1].y); } }); canvas.addEventListener('touchmove', (e) => { const t = e.touches; if (t.length === 1) { const dx = t[0].x - lastX; const dy = t[0].y - lastY; lastX = t[0].x; lastY = t[0].y; vy += dx * 0.008; // 0.008 是手感系数,越小越"重" vx += dy * 0.008; vx = Math.max(-0.6, Math.min(0.6, vx)); // 限制俯仰,避免翻过头 } else if (t.length === 2) { const dist = Math.hypot(t[0].x - t[1].x, t[0].y - t[1].y); if (lastDist) { state.scale *= dist / lastDist; state.scale = Math.max(0.5, Math.min(2.5, state.scale)); } lastDist = dist; } }); canvas.addEventListener('touchend', () => { lastDist = 0; }); return { state, update() { // 惯性衰减 vy *= 0.92; vx *= 0.92; state.rotY += vy; state.rotX += vx; }, }; }手感系数0.008和阻尼0.92这两个数不是随便写的:系数偏大,模型转得像陀螺,用户点不准;偏小,又显得迟钝。我在三个不同尺寸的机型上试过,0.006~0.010 之间是比较稳的区间,阻尼 0.90~0.94 之间松手后能滑半圈左右,符合直觉。俯仰角一定要夹紧,否则用户可以把模型翻到背面朝上,看起来像个 bug。
e.touches[0].x/y是小程序 canvas 触摸事件里相对画布的坐标,比clientX/clientY更适合直接算增量。如果基础库给的字段不一致,做个兜底取clientX减去画布偏移就行。
4.2 面数、贴图与 dpr 的预算表
性能这块没有玄学,就是三个量:三角面数、贴图总像素、每帧 drawcall。三个都超,怎么优化都救不回来。下面这张表是我按中端安卓机(大约 4 GB 内存、Adreno 6 系 GPU)实测出的一套保守上限,超过就要在构建阶段处理:
| 项目 | 建议上限 | 超了之后的典型表现 |
|---|---|---|
| 单个模型三角面 | 8 万以内 | 中低端机掉到 20 fps 以下,转动有拖影 |
| 单张贴图边长 | 1024(2048 封顶) | iOS 触发内存告警,页面直接白屏 |
| 贴图总显存占用 | 64 MB 以内 | 进出页面两三次后闪退 |
| dpr | min(pixelRatio, 2) | 三倍屏填充率翻倍,帧率腰斩 |
| 单场景模型数 | 1 个(最多 3 个) | drawcall 与内存同时上涨 |
| 单个 GLB 文件 | 2 MB 以内 | 弱网首帧超过 3 s,用户已经退出去了 |
在线检测很简单,渲染循环里读renderer.info.render.triangles就能看到实际面数——这个数往往比模型文件里写的更大,因为场景里可能还有地面、阴影用的辅助几何体。我在一个项目里就发现面数比预期多了 40%,原因是之前为了调试加了个AxesHelper忘了删。
4.3 加载态、首帧与 dispose
GLTFLoader.parse是异步的,从用户点进页面到第一帧画出来,中间可能有 1~3 秒。这段时间如果没有反馈,用户会以为页面卡死,反复点击,触发多次下载,内存直接翻倍。
做法是分三段给反馈:下载中显示骨架图和百分比,解析中显示“正在生成模型”,首帧渲染完成后再淡入。进度百分比downloadFile的onProgressUpdate能给,但要注意它是分片回调,需要自己做节流,否则setData频率过高,反而把渲染线程拖垮。
const task = wx.downloadFile({ url, filePath }); let lastPct = 0; task.onProgressUpdate((res) => { const pct = Math.floor(res.progress); if (pct - lastPct < 5) return; // 每 5% 才 setData 一次 lastPct = pct; this.setData({ progress: pct }); });dispose这件事我在前面提过一次,这里补充一点:renderer.dispose()之后不要立刻把引用置空,先cancelAnimationFrame,再销毁场景,最后才丢引用。顺序反了,最后一帧的渲染回调会拿到已经 dispose 的上下文,直接导致闪退。
5. 排查清单:小程序加载 3D 模型最容易出问题的五个现场
下面的五条是我自己在几个项目里真实遇到过的,按出现频率排。每条都按“现象 → 原因 → 解决”写,遇到问题时可以直接对号入座。
5.1 现象:页面一片黑,控制台干净得可疑
原因:画布尺寸为 0。常见于三种情况——SelectorQuery在onReady之前执行、canvas 外层容器用了display: none或者高度是auto、页面用了scroll-view而 canvas 没给固定高度。尺寸为 0 时,WebGL 上下文能创建成功,renderer.render也不报错,就是不显示任何东西。
解决:在initGL里先断言canvas.width > 0 && canvas.height > 0,不满足就打日志并重试一次;WXSS 里给 canvas 明确的宽高(width: 100%; height: 60vh;),不要依赖父容器撑开。另外,onReady里取节点时加个wx.nextTick兜底,可以覆盖大部分时序问题。
5.2 现象:downloadFile直接 fail,报“url not in domain list”
原因:模型所在的域名没有配到小程序后台的 downloadFile 合法域名里。开发工具里可以临时勾选“不校验合法域名”,所以本地一切正常,真机一测就挂。
解决:把模型域名加进 downloadFile 白名单,注意是downloadFile不是 request。如果模型和图片不在同一个域名,两个都要配。还有个容易漏的点:CDN 做了 302 跳转的话,跳转后的域名同样要在白名单里,否则跳转那一步就断了。
5.3 现象:模型出来了,但是全黑或者只有轮廓,像剪影
原因:缺光。three.js 的MeshStandardMaterial在没有光源、也没有环境贴图的情况下,渲染出来就是纯黑。很多人以为是自己材质设置错了,其实是场景里只有AmbientLight,而环境光对 PBR 材质的贡献非常有限。
解决:至少加一盏DirectionalLight,强度给到 1.5 以上,位置放在相机右上方;或者用RoomEnvironment生成一张环境贴图挂到scene.environment,PBR 材质的金属感和高光会立刻正常。如果是MeshBasicMaterial还发黑,那问题就不在光,去查贴图和顶点色。
5.4 现象:模型是白的,贴图全丢,日志里出现 “Cannot read property 'src'”
原因:ImageLoader没有被改写,或者改写了但createElementNS没有指向canvas.createImage()。还有一种更隐蔽的情况:GLB 里贴图是内嵌的,走的是Blob+URL.createObjectURL分支,这条分支在小程序里必然失败。
解决:先确认ImageLoader.prototype.load被替换过(打一行日志就行);再确认模型的贴图是外链而不是内嵌。如果是内嵌,别在运行时打补丁,回到构建阶段把 GLB 拆开,让贴图变成独立文件。
5.5 现象:进出页面三五次之后闪退,没有任何报错
原因:资源没有释放。渲染循环cancelAnimationFrame漏了、几何体和贴图没有dispose、AnimationMixer还在引用旧的场景图。小程序的内存管理比浏览器严格得多,iOS 上尤其明显,累计到阈值就是一个没有任何提示的闪退。
解决:在onUnload里按“停循环 → dispose 渲染器 → 遍历场景 dispose 几何体与材质 → 清空引用”的顺序走一遍。想验证有没有漏,可以在开发者工具的内存面板里反复进出页面,看曲线是不是持续上升——只要是一直涨,就一定漏了。
6. 上线前的模型管线:把 GLB 拆开、压小、验一遍
前面反复提到“GLB 内嵌贴图走不通”,这一章把这个坑一次性填掉。我现在的习惯是:任何模型在进代码包之前,都必须先过一遍本地脚本,把它拆成.gltf + .bin + 贴图文件三件套。这样做有三个好处:贴图能被createImage正常加载、构建时能单独压贴图、模型和贴图可以分开做版本管理。
// tools/unpack-glb.js // 用法:node tools/unpack-glb.js ./assets/chair.glb const fs = require('fs'); const path = require('path'); const file = process.argv[2]; const buf = fs.readFileSync(file); // GLB 头:magic(4) + version(4) + length(4) if (buf.readUInt32LE(0) !== 0x46546c67) throw new Error('不是合法的 GLB'); let offset = 12; let json = null; let bin = null; // 逐块读:每块是 length(4) + type(4) + data while (offset < buf.length) { const len = buf.readUInt32LE(offset); const type = buf.readUInt32LE(offset + 4); const chunk = buf.subarray(offset + 8, offset + 8 + len); if (type === 0x4e4f534a) json = JSON.parse(chunk.toString('utf8')); // JSON if (type === 0x004e4942) bin = chunk; // BIN offset += 8 + len; } const dir = path.dirname(file); const base = path.basename(file, '.glb'); // 1) 把 bufferView 里的图片落成独立文件 (json.images || []).forEach((img, i) => { if (img.bufferView === undefined) return; const bv = json.bufferViews[img.bufferView]; const start = bv.byteOffset || 0; const data = bin.subarray(start, start + bv.byteLength); const ext = img.mimeType === 'image/jpeg' ? 'jpg' : 'png'; const name = `${base}_tex_${i}.${ext}`; fs.writeFileSync(path.join(dir, name), data); img.uri = name; // 改写成外链 delete img.bufferView; // 移除内嵌引用 }); // 2) 缓冲区也落盘,并改写 uri fs.writeFileSync(path.join(dir, `${base}.bin`), bin); json.buffers[0].uri = `${base}.bin`; delete json.buffers[0].byteLength; // 可留可删,留着小程序侧更好校验 // 3) 输出 gltf fs.writeFileSync(path.join(dir, `${base}.gltf`), JSON.stringify(json)); console.log('done:', `${base}.gltf`);这个脚本没有做的一件事是把 BIN 里已经被抽出来的图片数据剔掉,所以.bin会比理论值大一些。要彻底瘦身,得重排bufferViews的偏移,工作量翻倍,收益通常在 10%~20%,我一般跳过——真正让包体降下来的还是贴图压缩和减面。
拆完之后还有两道工序。第一道是贴图降采样:把 2048 的贴图统一压到 1024,PNG 转 JPEG(带透明通道的除外),这一步通常能砍掉 60% 以上体积。第二道是减面:超过 8 万面的模型用 Blender 的 Decimate 修改器降到 5 万以内,肉眼几乎看不出差别,帧率能翻一倍。
最后是一张上线前的自检清单,我每次发版都会对一遍:
| 检查项 | 通过标准 |
|---|---|
| 模型格式 | 已拆成 gltf + bin + 贴图,无内嵌 bufferView 图片 |
| 贴图尺寸 | 单边 ≤ 1024,总体积 ≤ 1.5 MB |
| 三角面数 | renderer.info.render.triangles≤ 8 万 |
| dpr | 已封顶为 2 |
| 生命周期 | onHide停循环,onUnload全量 dispose |
| 弱网表现 | 3G 模拟下首帧 ≤ 3 s,有明确加载态 |
| 降级方案 | 低端机 / 加载失败时回落到静态图 |
最后那行“降级方案”是我用一次线上事故换来的。当时一个机型在解析阶段就崩,白屏了整整一天,后来加了一个try/catch+ 静态图兜底才止住。从那以后我每次接 3D 需求,第一件事不是写渲染,而是先把静态图和降级开关做好,再往上叠 WebGL。模型能不能转是加分项,页面能不能打开是底线。这份小程序加载外部 3D 模型的完整链路和配套脚本,按上面的顺序走一遍基本能跑通,希望帮到你。
本文还有配套的精品资源,点击获取