ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

微信小程序3D模型加载实战:Three.js适配与GLB模型应用解析

微信小程序3D模型加载实战:Three.js适配与GLB模型应用解析 简介微信小程序集成three.js加载外部3D模型的完整示例工程面向有一定JavaScript基础的小程序开发者解决原生小程序难以直接渲染复杂3D场景的痛点。压缩包共11个文件包含4个js脚本核心逻辑与加载器、4个json配置页面与项目设置、1个wxss样式、1个wxml页面及1个说明文档整体仅167KB轻量而结构清晰适合逐文件阅读。目前已有3203人学习下载。工程从前端引入three.js通过wx.createScriptModule方式起步依次演示场景/相机/渲染器初始化、基于Loader的OBJ/GLTF模型加载、动画循环驱动以及触摸事件到三维坐标的映射交互还涵盖模型减面、LOD分级和异步加载等性能处理办法并针对网络错误、格式不支持等常见异常给出了排错思路。附带的README对目录结构、运行顺序做了归纳可帮助读者快速复现演示效果并在自己的项目中迁移改造。对于需要在小程序里实现3D展示、AR试穿或产品预览的开发者这是一份实用的起步参考。1. miniprogramThree 是什么微信小程序加载外部 3D 模型的正确姿势微信小程序里加载外部 3D 模型最容易翻车的不是模型本身而是环境。做过商品 3D 展示或者虚拟试戴的同学都有体会后端把 glb 模型链接一发你以为 npm 装个 three 就能渲染结果小程序一运行直接报 document is not defined。微信小程序的运行环境和浏览器差太远——没有 DOM、没有 window、网络请求也不走 XHR。miniprogramThreenpm 包名是 threejs-miniprogram就是来解决这件事的它是 three.js 在小程序 WebGL canvas 上的移植版渲染器、场景、相机、加载器这套 API 基本保留底层把 DOM 依赖换成了小程序的原生能力。它能处理的是这类诉求商品详情页的 3D 预览、家具摆放的角度查看、微信小程序游戏开发里的角色与场景展示、教育互动课件。适合两类人——写过 three.js 想低成本把模型挂进小程序的前端以及从小程序原生开发起步、需要快速出 3D 效果的团队。下文从 canvas 初始化写到外部模型加载再到真机上的坑照着复现就能跑通。2. 初始化 WebGL 场景canvas 节点与 renderer 对接的三个关键点小程序里搭三维场景第一步就卡在 canvas 上。浏览器里document.getElementById拿到的元素直接能喂给 WebGLRenderer小程序里 canvas 是个组件得先声明typewebgl再用 SelectorQuery 异步把原生节点取出来。这一章把最小可运行场景搭好顺带把渲染循环和页面生命周期绑对。2.1 为什么不能直接 npm install three三个环境差异与适配原理先讲透原理后面排错才有方向。three.js 正常跑在浏览器里依赖三样东西DOM 元素、XHR/fetch 网络层、全局 requestAnimationFrame。小程序这三样全没有miniprogramThree 分别做了替换DOM 替换。浏览器的 WebGLRenderer 内部调用canvas.getContext(webgl)小程序里没有 HTMLCanvasElement只有typewebgl的 canvas 组件节点。miniprogramThree 的 WebGLRenderer 直接接收这个节点内部帮你把上下文创建抹平了。网络层替换。three 的 FileLoader、TextureLoader 默认走 XHR/fetch小程序没有这两个 API所以包内部改造成 wx.request 驱动的加载器。这一点对外部模型特别关键后面第三章会展开讲。动画帧替换。小程序全局没有window.requestAnimationFrame但 WebGL canvas 节点自身带requestAnimationFrame和cancelAnimationFrame渲染循环要挂在节点上。另外注意基础库版本typewebgl的 canvas 和.node()获取方式建议基础库 2.7.0 以上实测低版本机型偶尔拿不到节点。如果你用 uniapp 跨端开发流程本质一样只是取节点换成uni.createSelectorQuery()渲染层代码不用改。2.2 最小可运行场景canvas 节点获取与 renderer 初始化先写页面结构。canvas 组件必须包在一个有确定高度的容器里这是第一坑后面避坑章还会细说!-- index.wxml -- view classmodel-wrap canvas typewebgl idmodelCanvas classmodel-canvas/canvas /view/* index.wxss */ .model-wrap { width: 100%; height: 750rpx; /* 固定高度别指望内容撑开 */ } .model-canvas { width: 100%; height: 100%; }然后是页面 JS。注意 canvas 节点只能在onReady里拿onLoad里布局还没完成SelectorQuery 查不到节点// index.js const THREE require(threejs-miniprogram) // 新版包也可能是这种引入const { createScopedThree } require(threejs-miniprogram) Page({ onReady() { wx.createSelectorQuery() .select(#modelCanvas) .fields({ node: true, size: true }) .exec((res) { if (!res || !res[0] || !res[0].node) { console.error(webgl canvas 获取失败检查 typewebgl 和基础库版本) return } const canvas res[0].node const width res[0].width || 300 const height res[0].height || 300 // 关键渲染 buffer 尺寸要乘上 pixelRatio否则画质发虚 const info wx.getSystemInfoSync() canvas.width width * info.pixelRatio canvas.height height * info.pixelRatio const renderer new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true }) renderer.setClearColor(0x000000, 0) // 透明背景CSS 底色透出来 const scene new THREE.Scene() const camera new THREE.PerspectiveCamera(45, width / height, 0.1, 100) camera.position.set(0, 0, 5) // 灯光先给两盏兜底PBR 材质没光是一团黑 scene.add(new THREE.AmbientLight(0xffffff, 0.6)) const dirLight new THREE.DirectionalLight(0xffffff, 1) dirLight.position.set(5, 8, 6) scene.add(dirLight) this._canvas canvas this._renderer renderer this._scene scene this._camera camera this._startLoop() }) } })逻辑说明fields({ node: true, size: true })里 size 会返回 canvas 在页面里的实际布局宽高width/height 兜底 300 是防止某些机型布局未完成时拿 0。canvas.width width * pixelRatio是让 WebGL 的绘图缓冲比 CSS 尺寸大等于浏览器里的setPixelRatio效果不乘会糊乘太多在低端机上会掉帧我一般只乘一次。相机纵横比用 CSS 尺寸的 width/height 算和绘图像素比无关。灯光先给环境光加平行光两盏MeshStandardMaterial 在没有光照的场景下渲染出来是纯黑的很多新手在这步以为模型坏了。2.3 渲染循环与生命周期onReady 启动、onHide 暂停、onUnload 释放小程序页面有前后台切换渲染循环必须跟着生命周期走否则切后台还继续渲染白费电量还可能触发 WebGL context lost_startLoop() { if (this._rafId) return const render () { this._rafId this._canvas.requestAnimationFrame(render) this._renderer.render(this._scene, this._camera) } render() }, _stopLoop() { if (this._rafId) { this._canvas.cancelAnimationFrame(this._rafId) this._rafId null } }, onHide() { this._stopLoop() }, onShow() { if (this._canvas) this._startLoop() }, onUnload() { this._stopLoop() this._renderer this._renderer.dispose() }逻辑说明渲染函数里先注册下一帧再 render保证两帧之间不丢调度。_rafId做防重入避免 onShow 被频繁调用时叠出多个循环。renderer.dispose()释放 GL 上下文占用的 GPU 资源页面卸载不释放的话反复进出页面会在 iOS 上越跑越卡。到这里一个能显示纯色背景、有相机有灯光的空场景就就绪了。下一步就是正题把外部模型真正装进这个场景。3. 外部模型装载GLB 格式选型与三种入库路径模型要进小程序有三条路远程下载、本地分包、缓存复用。但不管哪条路格式选错都会让你在加载器上耗掉一整天。这一章先把格式选型说清再给完整的装载代码。3.1 格式选型为什么优先要 GLB而不是 OBJ/STL/3D Tiles我经手过的外部模型来源五花八门免费 3d 模型站下的 OBJ、设计给的 FBX 转出的 glTF、工业件 STL、还有想做 GIS 场景的 3D Tiles。先给一张对比表看明白就不纠结格式优点在小程序里的实际麻烦GLB.glb单文件自包含几何贴图动画全在二进制里基本没有前提是贴图别塞 4KglTF.gltf.bin.png文本可读方便调试拆分的相对路径资源parse 时二次请求经常 404OBJ MTL免费模型站主力量大MTL 路径乱、单位乱、材质还原差STL工业件数据多只有三角面没有材质颜色渲染灰白3D Tiles大场景流式加载依赖 Cesium 生态小程序包体和性能扛不住别碰结论很直接外部模型进小程序第一件事就是转 GLB。常见做法是 Blender 导入原模型导出时选 glTF BinaryGLB勾选嵌入贴图命令行党可以用 gltf-pipeline 一键转gltf-pipeline -i model.gltf -b -o model.glb。转完顺手在 Blender 里把贴图降到 2048 以下一张 4096 的 png 在 GPU 上能吃掉 60MB 以上显存真机很容易闪退。免费模型素材我一般去 Sketchfab 免费榜、Free3D 这类站找测试件注意看 license标注不可商用的别往项目里塞。搜微信小程序项目实例拿到的一些源码包里带的 obj 模型也建议先走一遍转换再接入。3.2 远程模型装载wx.request GLTFLoader.parse 完整链路外部模型最常见的存放位置是 CDN 或对象存储。小程序没有 XHR直接把模型 URL 丢给 GLTFLoader.load 是行不通的标准姿势是自己下载字节流再喂给loader.parseconst { GLTFLoader } require(threejs-miniprogram/examples/jsm/loaders/GLTFLoader) function downloadModel(url) { return new Promise((resolve, reject) { wx.request({ url, responseType: arraybuffer, timeout: 15000, success(res) { if (res.statusCode ! 200) { reject(new Error(模型下载失败HTTP res.statusCode)) return } const loader new GLTFLoader() loader.parse(res.data, , (gltf) resolve(gltf), reject) }, fail(err) { reject(new Error(wx.request 失败: err.errMsg)) } }) }) }参数说明responseType: arraybuffer不能漏漏了res.data是字符串GLTFLoader 解析字节流时直接报 Unexpected token。loader.parse(data, path, onLoad, onError)的第二个参数 path是给分离式 glTF 解析相对资源用的纯 GLB 里没有外部引用传空字符串即可。调用起来很简单async initModel() { const gltf await downloadModel(https://your-cdn.example.com/models/sofa.glb) const model gltf.scene this._scene.add(model) }需要加载进度条时我一般改用wx.downloadFile的onProgressUpdate拿百分比下载完再fs.readFile转 arraybuffer 喂给 parse路径在下小节给全。官方仓库虽然把 FileLoader 做了 wx.request 适配分离式 gltf 理论上能自动拉 bin 和贴图但真机上经常栽在 responseType 默认 text 导致二进制解析失败。我的血泪经验就是统一转 GLB别给 parse 留二次请求的隐患。3.3 本地分包与缓存复用包体限制和二次加载提速微信小程序主包 2MB 这条基本没变过总包上限看后台配额。模型文件动辄几百 KB放主包等于自杀。两条路小模型放分包大模型走 CDN 下载后落本地缓存。落缓存的标准做法是用wx.downloadFile拿临时文件再fs.saveFile转持久文件路径记到 Storage下次直接读const fs wx.getFileSystemManager() function loadModelBuffer(url) { const cached wx.getStorageSync(model_ url) return new Promise((resolve, reject) { if (cached) { resolve(fs.readFileSync(cached)) // 已缓存直接读 return } wx.downloadFile({ url, success(dl) { if (dl.statusCode ! 200) { reject(new Error(downloadFile 失败: dl.statusCode)) return } fs.saveFile({ tempFilePath: dl.tempFilePath, success(s) { wx.setStorageSync(model_ url, s.savedFilePath) resolve(fs.readFileSync(s.savedFilePath)) }, fail: reject }) }, fail: reject }) }) }逻辑说明临时文件在冷启动后会被清掉必须saveFile转持久化savedFilePath 是微信生成的哈希路径存下来直接用。fs.readFileSync返回 ArrayBuffer可以直接喂给loader.parse。模型更新时给 URL 加版本号参数?v2绕过缓存重新拉。wx.downloadFile同样受合法域名校验约束且只有 downloadFile 有onProgressUpdate需要进度条就走这条路。注意微信的缓存文件总量也有限额单个模型超过 200MB 的建议转 DRACO 压缩后面避坑章讲理由。4. 模型展示实战灯光、相机与多模型切换的正确姿势模型进了场景只是开始。接下来你会看到三连问为什么发黑为什么巨大为什么旋转绕着奇怪的点转这一章把灯光、相机、模型归一化、多模型切换一次讲完。4.1 灯光配置为什么模型一片黑或一片惨白GLB 内的材质基本都是 PBR 的 MeshStandardMaterial它的颜色计算依赖光照。场景里没灯模型渲染出来就是黑的只有一盏很弱的环境光又会灰扑扑的。我一般一个场景里固定放三盏// 半球光兜底模拟天空和地面的漫反射 const hemiLight new THREE.HemisphereLight(0xffffff, 0x444444, 0.8) scene.add(hemiLight) // 主光定造型 const mainLight new THREE.DirectionalLight(0xffffff, 1.2) mainLight.position.set(2, 5, 3) scene.add(mainLight) // 轮廓光补暗面 const rimLight new THREE.DirectionalLight(0x88ccff, 0.4) rimLight.position.set(-3, 1, -2) scene.add(rimLight)参数说明主光强度 1.01.5方向从左上打下来模型立体感最强半球光 0.60.8 兜底防止暗面死黑轮廓光 0.30.5色温偏冷一点和主光形成冷暖对比。调参顺序有讲究先只开主光调方向满意了再加半球光提亮度最后用轮廓光收边。一次堆五盏灯帧率往下掉效果未必好。注意这个移植版内核锁定的 three 版本比较老光照强度按老式 01 范围调不要按物理光照那套 cd/ 流明去算那是新版 three 的语义。4.2 相机与模型归一化模型太大、太小、偏心一次解决外部模型单位混乱是常态Blender 导出是米3ds Max 做的是厘米SketchUp 可能按英尺同一个模型在不同软件里导出尺寸能差 100 倍。手工去猜缩放系数不靠谱正确做法是用包围盒归一化function fitToView(model) { const box new THREE.Box3().setFromObject(model) const size box.getSize(new THREE.Vector3()) if (size.x 0 size.y 0 size.z 0) { console.warn(模型包围盒为零检查几何体是否为空) return } const sphere box.getBoundingSphere(new THREE.Sphere()) const center sphere.center const radius sphere.radius // 让模型直径约占视口的 2/3 const scale 2.2 / (radius * 2) model.position.sub(center).multiplyScalar(scale) model.scale.setScalar(scale) this._camera.position.set(0, 0, 4) this._camera.lookAt(0, 0, 0) }逻辑说明setFromObject递归遍历模型子节点算出包含所有几何的包围盒。把模型中心移到原点再等比缩放这样后续做旋转才是模型绕自身转不会出现公转。相机放 z4配合 45 度视角模型正好占满画面。这里有个容易忽略的细节模型加载进来先model.position.set(0,0,0)、model.scale.set(1,1,1)重置一次再 fit否则上一次 fit 的变换会叠加模型越弄越飞。4.3 多模型切换与显隐控制商品类场景经常要切换款式沙发换颜色、椅子换型号。反复loader.parse大模型很费正确做法是页面级缓存 gltf切换只做 visible 控制this._modelCache new Map() this._currentModel null async showModel(modelId, url) { let gltf this._modelCache.get(modelId) if (!gltf) { const buffer await loadModelBuffer(url) gltf await new Promise((resolve, reject) { const loader new GLTFLoader() loader.parse(buffer, , resolve, reject) }) this._modelCache.set(modelId, gltf) } // 隐藏上一个 if (this._currentModel) this._currentModel.visible false // 显示并归一化当前模型 const model gltf.scene model.position.set(0, 0, 0) model.scale.set(1, 1, 1) fitToView.call(this, model) this._scene.add(model) this._currentModel model }逻辑说明缓存 Map 的 key 用模型 id同一个 id 只解析一次。切换时隐藏上一个模型而不是 remove保留它在场景里是为了避免重新 add 带来的纹理重新上传开销。但注意 hidden 的模型仍占显存如果切换的模型太多、太频繁还是要把不常用的真正 dispose 掉具体代码在下一章避坑里给。5. 避坑与排查外部模型进小程序的五个翻车现场这一章是实打实的踩坑记录每一条我都用「现象 → 原因 → 解决」的格式写按频率从高到低排。你在真机上遇到的绝大多数问题跑不出这五个。5.1 白屏canvas 尺寸为 0 或父容器高度塌陷现象页面打开一片白控制台没有任何报错renderer 初始化也成功了就是看不到东西。原因canvas 组件没有固定高度的父容器。小程序里 canvas 是原生组件它的高度撑不开时fields({ size: true })返回的 height 是 0WebGL 渲染到一个 0 高的缓冲里自然白屏。还有一种情况是调试时给 canvas 加了opacity: 0或display: none忘了改回来。解决父容器给固定高度rpx 或 px 都行别用百分比依赖内容撑开。取尺寸时做兜底const height res[0].height || 300。保险起见再在 onReady 里console.log(canvas size, width, height)打一条日志白屏时先看这个值。5.2 模型加载失败域名白名单、HTTPS 与 responseType 三个拦路虎现象开发者工具里模型正常显示扫码真机后一直转圈或者直接空白。有的机型 Android 正常、iOS 失败反过来也有。原因小程序网络请求强制 HTTPS且域名必须在后台配置为合法域名。开发阶段容易忽略开发者工具里勾了不校验合法域名就一切正常换真机就 403。另外responseType: arraybuffer漏写时iOS 和 Android 表现还不一样Android 可能侥幸解析iOS 必然报错。解决上线前在「小程序后台 → 开发管理 → 开发设置 → 服务器域名」里把模型 CDN 域名加到 downloadFile 合法域名列表注意是 downloadFile 不是 request两个列表独立。开发期在开发者工具「详情 → 本地设置」勾选不校验域名但记住这只是临时方案。代码里responseType必须显式写并在 success 里先判断statusCode 200再进 parse别吞错。5.3 大模型闪退iOS 内存告警与资源释放现象iPhone 8 这类低内存机型上模型转几圈后小程序直接闪退或者连续切换三个模型后页面越来越卡最后崩溃。Android 中端机反而没事。原因iOS 对 WebGL 显存占用更敏感。每loader.parse一次geometry、material、texture 都进了 GPU 显存切换模型只做 visible 隐藏不释放显存只增不减。加上原始 4096 纹理一张就能占 60MB 以上显存叠加几个模型直接触顶。解决切换模型时对不再使用的旧资源做递归释放代码我每次都在用function disposeObject(node) { if (!node) return if (node.geometry) node.geometry.dispose() if (node.material) { const mats Array.isArray(node.material) ? node.material : [node.material] mats.forEach((m) { if (m.map) m.map.dispose() m.dispose() }) } node.children.forEach((child) disposeObject(child)) }逻辑说明geometry 和 material 是显存大头texture 的 map 要单独 dispose因为 map 是 material 的共享资源。递归是为了把模型组里的子网格全部遍历到。配合 DRACO 压缩几何体能把模型文件压到原来的 1/5代价是要在包内带 decoder 脚本加载逻辑多几步但值得。5.4 贴图全黑PBR 材质无光照与纹理加载失败现象模型轮廓和造型都在但整块是黑的或者某些面半透明能看穿。原因两种最常见。一是场景灯光不够MeshStandardMaterial 在低光照下暗部接近纯黑尤其金属度高的材质二是 GLB 里的贴图没嵌进去加载器拿到的是外链纹理下载失败后 three 回退到黑色默认材质。解决先按第四章的三盏灯方案补光再检查网络面板里有没有失败的纹理请求。GLB 导出时务必勾选嵌入贴图Blender 的 Store images as: Copy 配合打包转换后用gltf-pipeline -i model.glb -d之类工具检查一遍依赖确保单文件自包含。OBJ 模型的 MTL 经常引用绝对路径在浏览器里都容易挂更别说小程序所以 OBJ 一律先转 GLB 再谈加载。5.5 手势旋转方向反了和多指漂移现象手指向左滑模型向右转双指缩放时模型同时旋转乱跳。原因屏幕坐标系 y 轴向下touchmove 的 dy 是向下为正直接加到rotation.x上方向就反了。另一个 bug 是 touchmove 里用 touchstart 记录的单次触点算 delta多指切换时触点对不上增量突变导致模型乱转。解决用上一次移动的触点算增量单指旋转、双指缩放分支处理_onTouchStart(e) { this._lastX e.touches[0].clientX this._lastY e.touches[0].clientY }, _onTouchMove(e) { if (e.touches.length 1) { const dx e.touches[0].clientX - this._lastX const dy e.touches[0].clientY - this._lastY this._model.rotation.y dx * 0.01 // y 轴取反屏幕向下为正模型绕 x 轴应为负方向 this._model.rotation.x - dy * 0.01 this._model.rotation.x Math.max(-Math.PI / 2, Math.min(Math.PI / 2, this._model.rotation.x)) this._lastX e.touches[0].clientX this._lastY e.touches[0].clientY } else if (e.touches.length 2) { const dist Math.hypot( e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY ) this._model.scale.setScalar(this._baseScale * (dist / this._startDist)) } }逻辑说明x 轴 clamp 到正负 90 度防止模型整个翻过去。双指分支先记录touchstart时的两指距离和模型原始缩放move 时按距离比例缩放。每帧更新_lastX/_lastY是关键不更新的话 delta 会累积越滑越快。6. 性能验证与交互收尾帧率自检和惯性旋转的落地写法模型显示正常只是及格小程序里能不能长时间流畅跑才是验收标准。最后一章给两个落地技巧一个是量化帧率一个是让旋转手感更像原生。6.1 帧率自检与渲染调用量控制我习惯在渲染循环里挂一个计数器每秒打印一次实际帧率// onReady 里启动统计 this._frames 0 setInterval(() { console.log(当前 fps:, this._frames) this._frames 0 }, 1000) // 渲染循环里 _render() { this._rafId this._canvas.requestAnimationFrame(() this._render()) this._renderer.render(this._scene, this._camera) this._frames }验证标准静态展示型页面稳定 40fps 以上就算合格需要连续转动的场景争取 60fps。低于 30fps 时先看模型面数再看灯光数量最后检查renderer.info.render.calls如果移植版支持这个字段draw call 超过 60 就该考虑合批或换低模。6.2 惯性旋转让模型松手后平滑减速原生体验的关键在小细节。松手后直接停住很生硬加一段阻尼衰减就顺滑了_onTouchEnd() { this._velocityX this._lastDeltaX this._velocityY this._lastDeltaY this._touching false }, _render() { this._rafId this._canvas.requestAnimationFrame(() this._render()) if (!this._touching) { this._model.rotation.y this._velocityX this._model.rotation.x - this._velocityY this._velocityX * 0.95 this._velocityY * 0.95 if (Math.abs(this._velocityX) 0.0001 Math.abs(this._velocityY) 0.0001) { this._velocityX 0 this._velocityY 0 } } this._renderer.render(this._scene, this._camera) this._frames }参数说明0.95 是每帧衰减系数60fps 下大约 1 秒内停稳。_lastDeltaX在 touchmove 里记录的是这次移动量而不是前后差值松手时把它作为初速度。真机帧率波动大时建议把衰减改成Math.pow(0.001, dt)之类按时间步进的写法避免不同帧率下惯性时长忽长忽短。这套流程做到这份上微信小程序加载外部 3D 模型就算完整落地了。从那以后我每次接新模型都强制走一遍固定四步开发者工具清缓存看 console、确认 canvas 尺寸不为 0、真机 wifi 下看网络面板、再查一轮 draw call。这套习惯帮我躲过了绝大多数低级翻车希望帮到你。本文还有配套的精品资源点击获取
返回列表