资讯详情

资讯详情

微信小程序3D模型加载实战:基于threejs-miniprogram从入门到上线

简介面向微信小程序开发者的 three.js 外部3D模型加载示例包聚焦小程序环境中接入WebGL 3D场景的常见难点。资源为完整可运行的小程序项目涵盖 three.js 库的本地化引入、Scene/Camera/Renderer 初始化、OBJ/FBX/GLTF 模型异步加载、动画循环与 canvas 触摸交互同时包含针对移动端性能的模型面数精简与 LOD 细节层级优化思路。压缩包共11个文件以 js 业务逻辑、json 项目配置、wxss 页面样式为主另有 wxml 页面结构和 md 说明文档整体167KB轻量紧凑便于按文件结构对照学习。已有3203人学习浏览适合掌握了基础 JavaScript、希望在微信小程序中展示三维模型效果的前端开发者。借助该示例可快速搭建小程序3D渲染框架理解小程序与 WebGL 的适配差异减少自行摸索过程中的空文件引用、接口兼容和渲染性能等坑点。1. 微信小程序里的 3D 模型加载为什么 three.js 不能直接跑接到一个商城小程序需求商品详情页要放一个能旋转查看的 3D 模型。第一反应是把 three.js 装进来import 完发现运行直接白屏。微信小程序没有 DOM、没有 windowthree.js 依赖的 WebGL 上下文也要靠小程序 Canvas 的特殊能力去适配。标题里的 miniprogramThree社区里最常见的落地形态就是 threejs-miniprogram 这个适配层它把 three.js 的渲染能力“搬”进小程序让 GLB/GLTF 外部模型能在 Canvas 上正常显示、旋转、缩放。这个方案适合做商品展示、虚拟展厅也适合微信小程序游戏开发里的角色模型加载。读完这篇你能搭出一个可复用的最小加载管线并知道后续上线该在哪些参数上较劲。2. 选型threejs-miniprogram 适配层与模型格式的取舍2.1 从 three.js 到 threejs-miniprogram适配层到底适配了什么小程序 JavaScript 环境没有 document.getElementById也没有浏览器的 canvas.getContext(webgl)。three.js 的 WebGLRenderer 初始化时要做一堆 DOM 操作直接搬到小程序里就是黑匣子一样地报错。threejs-miniprogram 做的事情很直接把 WebGLRenderer 对 canvas 的依赖改造成小程序 Canvas 节点能识别的接口再把浏览器的 requestAnimationFrame 换成小程序的计时器体系。这样一来three.js 里的 Scene、Camera、Mesh、动画系统、加载器这些上层的逻辑基本可以原样保留。常见替代方案有两个。一个是 web-view 套一个 H5 页面用浏览器里的 three.js 加载模型交互和消息通信都隔着一层页面切换体验也差。另一个是自己用 WebGL 裸写渲染管线开发量太大项目还没上线就先把人耗光了。适配层方案是目前从业者走的最多的一条路因为它让团队里已经会 three.js 的人没有额外学习成本Models、Lights、AnimationMixer 这些概念在小程序里依然成立。需要注意适配层不是 iOS 和 Android 完全一致的。不同基础库版本对 WebGL 的支持程度有差异有些低端安卓机的 GPU 驱动对 WebGL 扩展支持不全会出现同样的模型在 iPhone 上正常、在安卓上黑屏。所以选型之前先确认你的小程序基础库版本足够新最好在 2.7.0 以上再投入开发。2.2 GLB 还是 GLTF小程序环境下的格式选型外部 3D 模型最常见的格式是 GLTF 和 GLB。GLTF 是 JSON 格式描述场景结构、节点层级、材质引用几何数据通常外挂在 .bin 文件里贴图又是一堆独立图片。小程序里加载 GLTF意味着要发出多个请求还要处理贴图路径的本地化非常折腾。GLB 是二进制单文件几何、材质、贴图都打包在同一个文件里一次 wx.request 或者 downloadFile 就能拿全。从格式参数上看两者的差异很直接。GLTF 适合开发调试因为文本格式能直接在编辑器里看结构GLB 适合生产发布体积更小、请求更少、解析更快。我给外部模型做上线准备时基本都会在服务端先用工具把 GLTF 转成 GLB再交给小程序端。对比项GLTFGLB文件构成JSON .bin 多张贴图单个二进制文件小程序请求次数多个一个纹理处理需要下载到本地再关联内嵌开箱即用调试友好度高低生产环境推荐度不推荐推荐有个容易踩的坑是 3D Tiles。这个格式的模型下载下来体积很大但它隶属 Cesium 生态three.js 本身没有原生加载器小程序里更不现实。看到“3d tiles 模型下载”相关资源时别急着接进来先确认格式GLB 才是当前小程序环境最省事的答案。2.3 模型来源与单位转换建模软件和运行时差着 100 倍外部模型不是拿过来就能直接摆到场景里的。建模软件的世界单位不同Blender 默认米3ds Max 默认厘米SketchUp 则经常用英寸。同一栋建筑的模型从不同软件导出到了 three.js 场景里的尺寸可能差 100 倍。加载完第一个动作通常是统一缩放常见做法是model.scale.set(0.01, 0.01, 0.01)把厘米单位缩到米单位。还有轴向问题。three.js 约定 Y 轴向上但有些建模软件导出模型时 Z 轴向上。旋转轴不对的时候模型会“躺着”出现在场景里这时候对模型整体做一次rotateX(-Math.PI / 2)再继续调整位置。验证流程时不需要花大力气找高质量模型拿一个几十 KB 的免费 GLB 模型把管线跑通比一开始就上高精度模型省很多时间。跑通之后再看模型面数和纹理尺寸是否合理。3. 跑通最小 Demo从 npm 构建到外部模型上屏3.1 工程初始化装依赖、构建 npm、确认基础库新起一个小程序项目目录结构用默认模板就行。在项目根目录执行初始化把 threejs-miniprogram 和 three 装进来。两个都要装因为 GLTFLoader 这类加载器要从 three 的 examples 目录引入。npm init -y npm install threejs-miniprogram three安装完成之后关键一步还没到。必须打开微信开发者工具在菜单栏的“工具”里点“构建 npm”。这个操作会把 node_modules 里的包编译到小程序的 miniprogram_npm 目录下。忘记这一步代码里写 import 的时候会直接报模块找不到。构建成功后在项目详情里确认“使用 npm 模块”是打开状态。基础库版本建议在 app.json 里显式指定别让它随客户端自动升级。我一般写 debug 版本时用最新版线上版本锁在一个自己验证过的版本上。WebGL 的 Canvas 节点需要基础库 2.7.0 以上才可靠低于这个版本会出现 canvas 节点创建成功但渲染不出来的情况而且不报错非常玄学。页面 WXML 里放一个 canvas类型写 webgl尺寸先写满可用区域canvas typewebgl idmodelCanvas stylewidth: 100%; height: 300px;/canvas这里 type 必须写死不加的话小程序会当成普通 2D canvas 处理后面 createScopedThreejs 拿到的节点类型对不上渲染直接失败。3.2 最小场景用本地立方体先把渲染管线打通外部模型加载涉及网络、解析、贴图好几个环节任何一个有问题都会白屏。所以第一步永远先用一个本地立方体把渲染管线跑通。这一步能过说明 WebGL 上下文、渲染循环、相机参数这三样基础没问题。import { createScopedThreejs } from threejs-miniprogram Page({ data: {}, onReady() { wx.createSelectorQuery() .select(#modelCanvas) .fields({ node: true, size: true }) .exec((res) { const canvas res[0].node const width res[0].width const height res[0].height const dpr wx.getSystemInfoSync().pixelRatio canvas.width width * dpr canvas.height height * dpr const { three, renderer } createScopedThreejs(canvas) renderer.setPixelRatio(dpr) renderer.setClearColor(#f5f6f8, 1) const scene new three.Scene() const camera new three.PerspectiveCamera(45, width / height, 0.1, 100) camera.position.set(3, 3, 3) camera.lookAt(0, 0, 0) const geometry new three.BoxGeometry(1, 1, 1) const material new three.MeshStandardMaterial({ color: 0x4f8ef7, roughness: 0.6, }) const mesh new three.Mesh(geometry, material) scene.add(mesh) scene.add(new three.AmbientLight(0xffffff, 0.4)) const dirLight new three.DirectionalLight(0xffffff, 0.8) dirLight.position.set(2, 3, 4) scene.add(dirLight) this.renderer renderer this.tick(renderer, scene, camera, mesh) }) }, tick(renderer, scene, camera, mesh) { const animate () { mesh.rotation.y 0.01 renderer.render(scene, camera) renderer.requestAnimationFrame(animate) } animate() }, onUnload() { if (this.renderer) { this.renderer.dispose() } }, })代码里有几个参数值得较真。PerspectiveCamera 的第二个参数是宽高比用 canvas 的逻辑像素计算不是屏幕物理像素。near 和 far 分别是 0.1 和 100模型离相机太远或太大时画面会被裁剪表现为“模型不见了”而不是报错。灯光我加了环境光和方向光两种如果不加灯光MeshStandardMaterial 的物体会黑成一片。渲染循环必须用 renderer.requestAnimationFrame不能用小程序的 requestAnimationFrame两者不完全等价。3.3 加载外部 GLBwx.request 与 GLTFLoader.parse 的配合立方体能转了再换成外部 GLB。模型要先落到内存里再把 ArrayBuffer 直接丢给 GLTFLoader 解析。这里最容易翻车的地方是 wx.request 没设置 responseType拿回来的 res.data 是一段文本不是一个二进制缓冲。import { GLTFLoader } from three/examples/jsm/loaders/GLTFLoader.js wx.request({ url: https://your-cdn.example.com/models/shoe.glb, responseType: arraybuffer, success: (res) { const loader new GLTFLoader() loader.parse(res.data, , (gltf) { const model gltf.scene model.scale.set(0.01, 0.01, 0.01) this.scene.add(model) this.model model }, (err) { console.error(模型解析失败, err) }) }, fail: (err) { console.error(模型下载失败, err) }, })loader.parse 的第一个参数是 ArrayBuffer第二个参数是资源路径。GLB 是单文件内嵌了纹理这里传空字符串没问题。如果加载的是 GLTF 格式第二个参数要填资源的基础路径loader 才能根据相对路径找到外部贴图和 bin 文件。解析回调里拿到的 gltf.scene 就是模型根节点把它加进场景后一般要立刻做缩放和位置调整。外部模型请求必须走小程序的合法域名机制。开发调试时可以在开发者工具里勾选“不校验合法域名”但上线版本必须把模型所在的 CDN 域名配到 request 合法域名里而且必须 HTTPS。配错的表现是开发者工具里一切正常真机上模型加载失败fail 回调里拿到的是一个域名权限错误。3.4 手势操纵单指旋转、双指缩放的小参数模型能上屏了下一步是让用户能转起来。通过绑定 canvas 的 touch 事件把触摸位移映射到模型的旋转和缩放上。这套手势逻辑不需要引入手势库原生 touch 事件就够了。onTouchStart(e) { const t e.touches this.touch { x: t[0].clientX, y: t[0].clientY, distance: t.length 1 ? this.getDistance(t[0], t[1]) : 0, scale: this.model ? this.model.scale.x : 1, } }, onTouchMove(e) { const t e.touches if (!this.touch || !this.model) return if (t.length 1) { const dx t[0].clientX - this.touch.x const dy t[0].clientY - this.touch.y this.model.rotation.y dx * 0.005 this.model.rotation.x dy * 0.005 this.touch.x t[0].clientX this.touch.y t[0].clientY } else if (t.length 1) { const distance this.getDistance(t[0], t[1]) const nextScale this.touch.scale * (distance / this.touch.distance) const safeScale Math.min(3, Math.max(0.3, nextScale)) this.model.scale.set(safeScale, safeScale, safeScale) } }, getDistance(p1, p2) { return Math.sqrt( Math.pow(p2.clientX - p1.clientX, 2) Math.pow(p2.clientY - p1.clientY, 2) ) },旋转系数 0.005 是个经验值手指滑 100 像素模型转 0.5 弧度手感比较跟手。系数太小转不动太大转得头晕。双指缩放时我做了 0.3 到 3 的 clamp防止用户把模型缩到看不见或者大到穿模。onTouchMove 里要持续更新基准点坐标否则每次取差值会把上一次的位移重复累加越转越快。缩放基准用的是双指开始时的距离和缩放值中途手指不能松掉重按不然缩放值会跳变。4. 加载外部模型的避坑记录五个高频翻车现场4.1 白屏且无报错Canvas 的宽高和 DPR 在作怪现象模型代码看起来都对loader 也执行了但 canvas 区域一片空白控制台没有任何报错。原因这是小程序 Canvas 最高频的坑。canvas 节点的逻辑宽高和 WebGL 渲染缓冲区的物理像素尺寸不是一回事。只设置了 WXML 里的 style 宽高没有给 canvas.width 赋值渲染缓冲区默认只有 300x150 物理像素模型就算渲染了也极其模糊有时直接被当成空内容。另一个常见原因是没乘 pixelRatio在 2 倍屏上画面只有实际分辨率的一半。解决在执行 createScopedThreejs 之前用 SelectorQuery 拿到 canvas 节点同时设置 width 和 height 为width * dpr和height * dpr。我后来养成的习惯是固定先打印一份 canvas.width 和 canvas.height 到控制台看到数值异常再做后续排查。4.2 res.data 是空的wx.request 的 arraybuffer 陷阱现象wx.request 的 success 回调里res.data 打印出来是 undefined或者是一个长度不对的对象。GLTFLoader.parse 直接抛错说数据不是有效的 GLB。原因wx.request 默认把响应当作文本处理。服务端返回二进制 GLB 时开发者工具里看着像乱码真机上直接是空。这是请求层最容易忽略的一个参数。解决请求里显式加上responseType: arraybuffer。加了之后res.data 才是一个 ArrayBuffer 实例。这个参数不会影响 JSON 请求所以我在项目里直接把它作为所有模型请求的默认配置。4.3 GLTFLoader 解析崩溃three 作用域不一致现象loader.parse 执行到一半报诸如Cannot read property isGroup of undefined或者THREE.Matrix4 is not a constructor的错误。原因项目里同时存在两个 three 实例。threejs-miniprogram 内部有一套自己的 three 作用域你从 npm 里又单独 import 了一个 three 版本GLTFLoader 解析出来的几何体、材质对象属于外部版本和渲染器所认识的内部版本对不上。对象检查全部失败。解决保证用于加载器的 three 和传进 createScopedThreejs 的 three 是同一套。three 和 threejs-miniprogram 的版本要匹配最好在 package.json 里锁死依赖版本范围。我一般安装时直接写精确版本号不带 ^ 前缀避免 npm 自动升级后产生这种配对问题。4.4 模型黑乎乎的纹理路径与小程序的本地文件权限现象模型的几何形状正常轮廓清楚但所有材质都是黑色或者贴图完全是灰的。原因GLB 内嵌纹理一般不会出现这个问题出现这个问题的大多数是 GLTF 格式。GLTF 的贴图是外部图片loader 解析时尝试加载一个网络 URL小程序环境没有跨域处理也没有下载机制纹理加载静默失败材质只能回落到默认颜色。此时没有报错只有材质参数里的 map 是 null。解决生产环境优先转 GLB把纹理包进去。如果因为历史原因必须用 GLTF先 wx.downloadFile 把所有贴图下载到本地临时目录再让 loader 基于本地路径去解析。这一步常见做法是给 loader 设置一个资源根目录把 GLTF 里的相对路径指到已下载的本地路径上。4.5 模型一旋转就崩溃iOS 内存峰值的典型表现现象Android 上模型加载、旋转都正常到了 iPhone 上转几圈直接黑屏或退到小程序首页。内存占用曲线在旋转期间快速上升。原因iOS 对 WebGL 内存使用更严格。模型里的大纹理、过大的 canvas 物理像素、每一帧都在创建的临时对象叠加起来把内存推到了系统容忍阈值。很多模型纹理是 4K 甚至 8K 的加载之后单张纹理就占几十 MB 显存多张加一起在低端 iPhone 上必炸。解决对纹理尺寸做一个硬性限制加载后超过 2048 的纹理用工具压缩一次再发布。同时把 canvas 的物理像素控制在屏幕实际需要的范围内不要为了“更清晰”无限加大。另外要注意页面离开时调用 renderer.dispose 和相关 geometry、material 的 dispose否则每次进入页面都会累积一份新的 GPU 资源。内存泄漏的坑不会当场暴露但会在用户多逛几个页面后集中爆发。5. 从能跑到能上生产模型压缩、缓存与降级5.1 减面与 Draco 压缩把加载体积先降一个量级外部模型不经过处理直接上线通常是灾难。一个三维扫描模型可能几百万面GLB 几十 MB用户在 4G 网络下要等十几秒。常见的做法是把模型的几何数据做减面再用 Draco 压缩几何。减面工具我常用 Blender 的 Decimate 修改器目标可以把面数砍到原来的十分之一而外观损失不明显。几何压缩则用 gltf-pipeline 这条命令npx gltf-pipeline -i model.gltf -o model_draco.glb -b -d这里的-b表示输出 GLB 格式-d表示应用 Draco 压缩。压缩前后体积经常能从十几 MB 降到两三 MB尤其是包含大量三角面的工业模型。参数上值得注意Draco 压缩对纯几何有效对纹理没有作用纹理压缩是另一套流程。小程序端解码 Draco 没有 worker 可用大模型解码时要卡主线程几百毫秒甚至一秒。用户体验上会出现模型加载转圈结束、画面停顿一下模型才弹出来。这是可以接受的但要做好 loading 提示避免用户以为卡死了。如果模型几何不复杂也可以干脆不做 Draco直接传原始 GLB解码那一步省下来整体耗时反而更短。5.2 本地缓存用 FileSystemManager 把 GLB 存进用户目录外部模型每次启动都从 CDN 重新拉一遍是对用户流量的浪费也是加载速度的大坑。微信小程序可以往用户目录写文件所以模型二次加载可以走本地缓存。常见的模式是先用 wx.downloadFile 下载模型再用 FileSystemManager 存到 USER_DATA_PATH 下面下次启动优先读本地文件读不到再走网络。const fs wx.getFileSystemManager() const modelKey product_123 const filePath ${wx.env.USER_DATA_PATH}/models/${modelKey}.glb function loadModelFromCache(url, onSuccess) { fs.access({ path: filePath, success() { fs.readFile({ filePath, success(res) { parseModel(res.data) }, fail() { downloadAndCache(url, onSuccess) }, }) }, fail() { downloadAndCache(url, onSuccess) }, }) } function downloadAndCache(url, onSuccess) { wx.downloadFile({ url, success(res) { fs.saveFile({ tempFilePath: res.tempFilePath, filePath, success() { fs.readFile({ filePath, success(res) { parseModel(res.data) }, }) }, }) }, }) }这里面有个细节容易踩坑。fs.saveFile 的 filePath 如果已经存在保存会失败所以我在写缓存前先做了一次 fs.access 判断。实际工程里我习惯把模型版本号拼进文件名比如 product_123_v2.glb这样模型内容更新后缓存 key 变化自然而然地触发重新下载不需要手动清缓存。缓存文件放在 USER_DATA_PATH 下不需要额外的权限声明但要注意这个目录的空间是有限的太大的模型不建议长期缓存。5.3 降级与重试别让用户盯着白屏网络环境不稳定时模型下载可能失败。一失败就让用户看着空白页面是体验事故。给加载过程加一个简单的状态机loading 转圈 → 重试 → 降级静态图。重试一般不超过两次超过之后认为是 CDN 或者格式问题直接展示商品图片的占位视图。loadModelWithRetry() { let retryCount 0 const tryLoad () { this.setData({ isLoading: true }) wx.request({ url: this.data.modelUrl, responseType: arraybuffer, success: (res) { try { this.parseModel(res.data) this.setData({ isLoading: false }) } catch (err) { this.handleLoadError(err) } }, fail: () { retryCount 1 if (retryCount 2) { tryLoad() } else { this.setData({ isLoading: false, isFallback: true }) } }, }) } tryLoad() }重试间隔也需要讲究我习惯在两次重试之间等 800ms而不是立即重发。短时间内连续请求失败大概率是网络波动等一会儿再试成功率会高不少。降级到静态图之后用户仍然能获得商品外观信息只是少了交互查看能力。这个兜底方案在弱网场景下的价值比多写几十行代码高得多。5.4 工程层面的体积控制主包、分包与 CDN 的配合小程序有主包体积限制渲染引擎加 three.js 适配层会吃掉相当一部分包体。常见做法是主包只放渲染器、加载器和基础场景代码真实模型全部放 CDN不进代码包。一个不是很大的 Demo 模型可以放分包里用于弱网离线演示但真实业务模型别往包里塞。如果你用的是 uniapp 开发微信小程序构建 npm 的逻辑和原生小程序不完全一致需要在 manifest 里确认 npm 模块被正确打包进微信平台产物。uniapp 工程里 threejs-miniprogram 的 Canvas 初始化获取节点方式也和原生有细节差异条件编译里要单独写一套微信平台的初始化分支别指望一套代码跑通所有端。包体优化上还有个小技巧threejs-miniprogram 内部是按需执行渲染的页面不可见时通过 onHide 暂停渲染循环能明显降低后台运行时的 CPU 消耗。配合分包策略小程序冷启动加载的时间可以控制在一个比较理想的范围。模型动画如果不需要可以在解析后直接把 gltf.animations 清空AnimationMixer 不启动省掉每帧的动画更新开销。6. 真机摸底三件套帧率、内存曲线和一个土办法开发工具里的表现只能当参考真机性能才是能不能交付的标准。工具里跑 60FPS到低端机上只剩十几 FPS 的情况我见过太多次。所以在联调阶段我会做三件事。第一件事是真机调试时打开性能面板先把模型自动旋转跑起来观察 FPS 折线。快速滑动页面、旋转模型、缩放模型每个操作做十几秒记录帧率最低点。第二件事是盯内存曲线在模型加载完成的瞬间、连续操作两分钟后各记一次。如果内存只涨不降说明有不释放的资源用二分法逐段排查是纹理、几何体还是渲染循环的问题。第三件事是我自己惯用的土办法让模型以固定速度绕 Y 轴自动旋转三分钟人离远点让小程序跑着别操作。三分钟后回来看帧率曲线如果曲线保持平直没有持续下跌场景基本稳定。如果帧率阶梯式往下掉那大概率是每帧都在创建新对象或者纹理被反复上传 GPU。别小看这个办法它不需要专业工具普通开发者也能复现内存泄漏。关于模型面数和纹理尺寸我习惯按目标机型倒推。预算严格控制在 10 万面以内纹理单张不超过 2048场景灯光不超过两盏。canvas 的物理像素按逻辑像素乘 pixelRatio 来算别为了清晰度盲目开大。在 iPhone 6 这类老机型上能稳住 25FPS这个模型就敢放上线。这套摸底流程是我每次交付前的固定动作省掉了无数次线上翻车之后的紧急修复。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →