☰
微信小程序加载外部3D模型:从GLB到WebGL渲染的完整指南
2026/10/14 14:47:49 网站建设 项目流程

简介:微信小程序集成three.js加载外部3D模型的完整示例项目,面向需要在小程序中实现3D展示与交互的前端开发者。资源包含完整的小程序目录结构,演示了从引入three.js、初始化场景与相机,到通过Loader加载OBJ/GLTF模型、驱动动画循环及处理触摸交互的关键实现。压缩包共11个文件,涵盖js逻辑、json配置、wxml页面、wxss样式及md说明文档,整体仅167KB,轻量易读,便于快速对照学习。目前已有3205人学习下载。通过学习可掌握小程序环境下加载外部3D模型的具体流程、性能优化思路以及网络异常等问题的处理方法,适合有一定JavaScript基础、希望拓展小程序开发能力的读者。

1. miniprogramThree 解决什么:微信小程序加载外部 3D 模型的完整链路

miniprogramThree 解决的是微信小程序里加载外部 3D 模型这件事。商品展示、家居预览、工业模型查看,本质都是把 CDN 上的 GLB 模型下载到小程序端,用 WebGL 实时渲染。但小程序不是浏览器:没有 DOM、没有 Blob、网络请求要走 wx.request,three.js 不能直接搬。很多人第一版就在真机上白屏,开发者工具里却一切正常。这个方向的核心是给 three.js 套一层小程序适配,打通外部模型从下载、解析到上屏的全链路。这篇文章写给三类人:准备做小程序 3D 展示的开发者、被真机白屏折腾的人、还没想清模型格式取舍的负责人。照着做,你至少能跑通一个可旋转的外部 GLB 模型。

2. 小程序里的 WebGL 环境:适配层在补什么,模型格式为什么选 GLB

2.1 canvas 节点查询:type="webgl" 与 node() 的正确姿势

先看一个最小 WXML。小程序里要跑 WebGL,canvas 组件必须显式声明type="webgl"。如果你沿用旧习惯写<canvas id="modelCanvas"></canvas>,拿到的节点只有 2d 上下文,后面的getContext('webgl')直接返回 null,代码在初始化渲染器那一行就断掉。

<canvas type="webgl" id="modelCanvas" class="model-canvas"></canvas>
/* viewer.wxss */ .model-canvas { width: 100%; height: 60vh; }

接下来在页面脚本里取节点。注意时机:必须在onReady之后取,此时 canvas 已经渲染完成,selector query 才能拿到节点。

// viewer.js Page({ onReady() { this.initViewer() }, initViewer() { wx.createSelectorQuery() .select('#modelCanvas') .node() .exec((res) => { const canvas = res[0].node if (!canvas) { console.error('canvas 节点获取失败') return } const gl = canvas.getContext('webgl') console.log('WebGL 版本:', gl.getParameter(gl.VERSION)) // 这里继续初始化渲染器、场景、相机 }) } })

代码逻辑不复杂,但有三个关键点。第一,node()需要较新的基础库版本支持,开发时建议把调试基础库调到最近几个版本,否则res[0].node是 undefined。第二,canvas 外面如果套了带 transform、opacity 或 overflow 样式的 view,同层渲染可能出问题,canvas 会浮在其他元素之上,这个放到避坑章展开。第三,node()拿到的 canvas 节点,其宽高由 WXML 样式决定,但 WebGL 的 drawing buffer 大小需要手动确认。真机上 canvas.width 通常是样式宽度乘以像素比,所以需要把像素比传给渲染器。

关于像素比多说一句。我一般会读wx.getSystemInfoSync().pixelRatio,然后传给渲染器的setPixelRatio。如果忽略这一步,模型边缘在真机上会明显发虚,因为 drawing buffer 只有 CSS 像素大小,而屏幕物理像素是它的两三倍。

const systemInfo = wx.getSystemInfoSync() this.renderer.setPixelRatio(Math.min(systemInfo.pixelRatio, 2)) this.renderer.setSize(canvas.width, canvas.height)

参数说明:setPixelRatio控制渲染分辨率与 CSS 尺寸的倍率。设成 2 意味着 drawing buffer 是 CSS 尺寸的两倍,画面更锐利但 GPU 负载也更高。低端 Android 上设到 3 基本就是主动崩溃,所以我会用Math.min(pixelRatio, 2)兜底。这不是玄学,是 GPU 浮点能力和内存带宽的硬限制。

2.2 适配层:three.js 在小程序里缺什么、补什么

three.js 在浏览器里跑得好好的,一搬进小程序就四处报错,根因是它默认依赖浏览器全局对象。具体来说有三样东西必须替换。

第一是 canvas 创建。three.js 的 WebGLRenderer 默认会调用document.createElement('canvas')来创建画布。小程序里没有 document,所以适配层要允许你传入一个现成的 canvas 节点。社区里常见的适配方案,使用方式通常是先拿到节点,再传给一个工厂函数,之后用这个工厂返回的 THREE 命名空间初始化渲染器。

第二是渲染循环。浏览器用window.requestAnimationFrame驱动,小程序 canvas 节点自带requestAnimationFrame方法,用法和 window 版本基本一致。少数旧版本基础库不提供,可以用 setTimeout 按 16ms 间隔模拟,代价是掉帧。

第三是网络请求。GLTFLoader 内部默认用 fetch 或 XMLHttpRequest 加载资源,小程序里这两个都没有。适配层会把 GLTFLoader 的网络请求替换成 wx.request(responseType 为 arraybuffer),同时把 TextureLoader 的图片加载替换成 wx.downloadFile 得到临时文件路径,再交给 canvas 的 createImage 创建图片对象。任何一个环节漏掉,你都会看到加载回调不触发,或者模型解析到一半报诡异错误。

下面是一段非常简化的适配示意,用来理解原理。实际使用时不建议自己写,直接用社区维护好的适配包。

// 简化版适配思路,仅示意:把 three.js 依赖的浏览器全局替换成小程序对象 function createScopedThreejs(canvas) { const THREE = require('./three.min.js') // 1. 告诉 three.js 不要自己创建 canvas THREE.createCanvas = () => canvas // 2. 用 canvas 的 rAF 驱动渲染循环 THREE.requestAnimationFrame = (cb) => canvas.requestAnimationFrame(cb) // 3. loader 的网络层替换,这里省略细节 return THREE }

这段代码说明了一个事实:适配层本质是依赖注入。自己适配的收益是可控,但成本是你得持续跟进 three.js 上游的改动,三个版本一升,内部对 DOM 的依赖可能就变一次,又得花几个晚上排查。我的建议是直接站在现成适配方案上做业务,把时间花在模型格式、渲染调优和真机适配这些更有价值的地方。

2.3 模型格式选型:为什么 GLB 是默认答案

写代码之前先定格式。外部 3D 模型常见的有 GLB、glTF、OBJ、FBX,小程序端的取舍逻辑和 Web 端不太一样。

GLB 是 glTF 的二进制打包形态,几何、材质、动画、纹理全部塞进一个文件。小程序里加载一个文件只需要一次下载请求,而且纹理内嵌,不用处理模型和贴图分散在不同 URL 的并发问题。这是它成为默认答案的两个硬理由。

glTF(未打包的 JSON 形态)在 Web 端很常见,但小程序里要额外管理 .bin 和贴图文件的多次请求,而且相对路径解析在某些适配版本里实现不完整,踩坑成本高。OBJ 只描述几何,材质在单独的 MTL 文件里,贴图又是独立图片,一套模型三四个文件,下载和缓存都麻烦。FBX 体积大,解析依赖复杂转换层,真机上基本跑不动。

对比起来一张表说清楚:

格式文件形态小程序端下载次数材质/PBR 支持适配层解析风险
GLB单文件二进制1完整低
glTFJSON + bin + 贴图多次完整中(路径解析)
OBJ + MTL文本多文件多次基本中
FBX二进制/ASCII1视导出设置高

所以我的建议很直接:给小程序用的外部模型,让美术或工具链统一输出 GLB。导出时勾选二进制(Blender 导出 glTF 2.0 时选 .glb),贴图尽量内嵌,避免导出成外部纹理。如果模型面数大,再加 Draco 压缩,这个放到最后一章。

选型定了,工程目录也基本定型。常见做法是在项目下建一个 libs/threejs 目录,放适配层和 three.js 核心文件,页面代码只管业务逻辑。

miniprogram/ ├── app.js ├── app.json ├── libs/ │ └── threejs/ │ ├── adapter.js # 小程序适配层 │ ├── three.min.js # three.js 核心 │ ├── GLTFLoader.js # 模型加载器 │ └── DRACOLoader.js # 压缩模型解码器(可选) └── pages/ └── viewer/ ├── viewer.js ├── viewer.json ├── viewer.wxml └── viewer.wxss

这个结构要注意:libs 下的文件都会打进小程序主包。three.js 全量 min 版本加上 loader,对包体是有压力的。如果你的主包已经接近上限,需要把 3D 页面拆成独立分包,libs 放进分包里。这个决定应该在写业务代码之前做,否则后面迁分包会改一堆相对路径,改到怀疑人生。

3. 用 GLTFLoader 加载外部 GLB:初始化、下载、解析一条龙

3.1 初始化渲染器、场景与相机的标准套路

Page 的 onReady 里拿到 canvas 节点之后,接下来是 three.js 的标准三件套:renderer、scene、camera。小程序版的代码和 Web 版几乎一样,只有创建 renderer 时是把 canvas 节点传进去。

// viewer.js const { createScopedThreejs } = require('../../libs/threejs/adapter.js') Page({ data: { progress: 0, loaded: false, errorMsg: '' }, onReady() { this.initViewer() }, initViewer() { wx.createSelectorQuery() .select('#modelCanvas') .node() .exec((res) => { const canvas = res[0].node const THREE = createScopedThreejs(canvas) // 渲染器 this.renderer = new THREE.WebGLRenderer({ canvas: canvas, antialias: true, alpha: true }) const pixelRatio = Math.min(wx.getSystemInfoSync().pixelRatio, 2) this.renderer.setPixelRatio(pixelRatio) this.renderer.setSize(canvas.width, canvas.height) // 场景 this.scene = new THREE.Scene() // 相机:透视相机,参数分别是视角、宽高比、近裁剪面、远裁剪面 this.camera = new THREE.PerspectiveCamera( 45, canvas.width / canvas.height, 0.1, 100 ) this.camera.position.set(3, 2, 5) this.camera.lookAt(0, 0, 0) // 基础光照 const ambient = new THREE.AmbientLight(0xffffff, 0.6) this.scene.add(ambient) const dirLight = new THREE.DirectionalLight(0xffffff, 1.0) dirLight.position.set(5, 8, 6) this.scene.add(dirLight) // 启动渲染循环 this.startRenderLoop(canvas) // 加载模型 this.loadModel() }) }, startRenderLoop(canvas) { const render = () => { if (this.renderer && this.scene && this.camera) { this.renderer.render(this.scene, this.camera) } canvas.requestAnimationFrame(render) } render() } })

参数逐个说。PerspectiveCamera(fov, aspect, near, far):fov 是垂直视角,45 度在手机竖屏上拍一个 3D 物体比较舒服;near 和 far 是裁剪平面,0.1 到 100 覆盖常规场景,模型被裁掉一半说明 far 太小,模型像被穿透了说明 near 太大,这两个值在真机排查白屏时经常要调。AmbientLight(0xffffff, 0.6)提供基础亮度,强度建议 0.4 到 0.7,调太高整个模型发白没有立体感。DirectionalLight(0xffffff, 1.0)是主光源,强度 0.8 到 1.2 常用,位置放在相机同侧偏上方,模型正面有光照,背光面也有自然明暗过渡。

关键点先说:antialias 和 alpha 同时打开时,部分 Android 机型的 WebGL 上下文创建会失败。这不是玄学,是 GPU 驱动对 MSAA 加透明通道组合的支持问题。惯例是先把 antialias 关了跑通真机,再根据表现决定要不要开。

3.2 GLTFLoader 直接加载远程 GLB

适配层已经把 loader 的网络请求替换成 wx.request,所以写法可以和 Web 端几乎一样。

// 接在 initViewer 末尾 loadModel() { const THREE = createScopedThreejs(this.canvas) const loader = new THREE.GLTFLoader() const modelUrl = 'https://your-cdn.example.com/models/mecha.glb' loader.load( modelUrl, (gltf) => { // 成功回调:拿到 gltf.scene,是一棵场景树 this.handleModelLoaded(gltf) }, (progress) => { // 进度回调:progress.loaded / progress.total if (progress.total > 0) { const pct = Math.round((progress.loaded / progress.total) * 100) this.setData({ progress: pct }) } }, (error) => { // 错误回调 console.error('加载失败', error) this.setData({ errorMsg: '模型加载失败,请重试' }) } ) }, handleModelLoaded(gltf) { const model = gltf.scene || gltf.scenes[0] if (!model) { this.setData({ errorMsg: '模型内容为空' }) return } // 规整模型:先算包围盒,再居中、缩放 const box = new THREE.Box3().setFromObject(model) const center = box.getCenter(new THREE.Vector3()) const size = box.getSize(new THREE.Vector3()) const maxDim = Math.max(size.x, size.y, size.z) const scale = 2 / maxDim model.position.sub(center) model.scale.set(scale, scale, scale) this.model = model this.scene.add(model) this.setData({ loaded: true, progress: 100 }) }

逻辑说明:loader.load第一个参数是模型 URL;成功回调里的gltf.scene是一棵 Object3D 树,包含网格、材质和可能的动画;进度回调只在服务器返回 Content-Length 时有意义;错误回调能拿到 HTTP 状态或解析异常。规整模型这一步很重要:外部模型来自不同工具链,有的原点在模型底部,有的在几何中心,尺寸有厘米级也有米级。用 Box3 取包围盒,把模型中心移到世界原点、最长边缩放到 2 个单位,无论原始状态多乱,进入场景后都能完整落在相机视野内。

参数说明:scale = 2 / maxDim里的 2 是我常用的目标尺寸。相机离原点 5 个单位、fov 45 度时,最长边为 2 的模型在竖屏里大约占屏高 60% 左右。想要模型占屏更大,把除数调小到 1.5;想要更小,调到 3。这是纯视觉调优参数,跟模型本身无关,你需要根据自己的场景多试几次。

3.3 加载进度、超时兜底与合法域名

progress 回调的progress.total在两种情况下是 0:CDN 没返回 Content-Length,或者适配层用的下载接口不暴露总长度。这时候不能用百分比进度,改用加载中文案即可,别让 UI 卡在 0% 让用户以为死了。

超时兜底是必须做的防御。小程序默认网络请求超时 60 秒,对 3D 模型来说太久了。我一般会加一个自己的计时器:发起加载后 15 秒内成功回调没触发,就主动放弃并提示用户。

loadModelWithTimeout() { const timer = setTimeout(() => { console.warn('模型加载超时') this.setData({ errorMsg: '加载超时,请检查网络' }) this.isLoadFinished = true }, 15000) const onSuccess = (gltf) => { clearTimeout(timer) this.handleModelLoaded(gltf) } // 把 onSuccess 传给 loader.load 的成功回调 }

另外,域名白名单是绕不开的。小程序 request 合法域名必须是 https,且不允许带端口和 IP 直连,真机上请求非白名单域名直接失败,失败信息在真机调试的 Network 面板里能看到。开发阶段可以在开发者工具里勾选「不校验合法域名」临时调试,但提审前必须把 CDN 域名加到后台白名单里。

4. 外部模型加载避坑指南:五个翻车现场与对应解药

4.1 现象:开发者工具渲染正常,真机白屏

原因:开发者工具的 WebGL 跑在桌面浏览器上,桌面 GPU 和手机 GPU 对 WebGL 的实现完整度差很多。最常见的差异有三处:antialias 在部分 Android 驱动上创建失败、three.js 默认开启的某些扩展真机不支持却不报错、材质用了真机不支持的高精度纹理格式。

解决:先将渲染器配置改成最小化,逐个排除。

this.renderer = new THREE.WebGLRenderer({ canvas: canvas, antialias: false, // 关闭抗锯齿,优先保证能出画面 alpha: true, powerPreference: 'default' })

如果关掉 antialias 后真机恢复正常,再决定要不要开。我的实际做法是 iOS 开 Android 关,用wx.getSystemInfoSync().platform判断平台分别设置。排查白屏时先看 canvas 有没有实际渲染内容:在渲染循环里打印一帧renderer.info.render.triangles,如果三角形数量在动但屏幕白屏,说明是 canvas 合成层的问题,去查同层渲染;如果三角形数量为 0,说明场景里没有东西,去查模型加载回调有没有触发。

4.2 现象:GLTFLoader 报 fetch is not a function 或 Failed to fetch

原因:适配层替换网络请求不彻底。有些适配方案只替换了 renderer 和 rAF,没替换 loader 的 fetch;有些替换了 fetch 但没处理 responseType,导致返回的是文本而不是 ArrayBuffer;还有的是基础库版本太低,wx.request 的 responseType 参数不生效。

解决:升级适配方案到支持 loader 网络层替换的版本,或者绕开 loader 的网络层:自己用 wx.request 下载 arrayBuffer,再调 loader.parse。

downloadAndParse(url) { wx.request({ url: url, responseType: 'arraybuffer', timeout: 30000, success: (res) => { if (res.statusCode !== 200) { this.setData({ errorMsg: 'HTTP ' + res.statusCode }) return } const THREE = createScopedThreejs(this.canvas) const loader = new THREE.GLTFLoader() loader.parse( res.data, // ArrayBuffer '', // path,外部贴图相对路径的基准,GLB 内嵌纹理可传空 (gltf) => this.handleModelLoaded(gltf), (err) => this.setData({ errorMsg: '模型解析失败' }) ) }, fail: () => { this.setData({ errorMsg: '模型下载失败' }) } }) }

说明:loader.parse(arrayBuffer, path, onLoad, onError)是 three.js 内部把二进制解成场景树的入口。自己下载再 parse 的好处是网络行为完全可控,超时、重试都自己说了算;坏处是要自己处理域名白名单。如果你发现 wx.request 能下、loader 却失败,多半就是适配层问题,直接走 parse 路线最稳。

4.3 现象:进度卡在 0% 或加载完成后回调不触发

原因:进度回调依赖总长度,CDN 未返回 Content-Length 或返回错误值时progress.total为 0,百分比算出来是 NaN。更隐蔽的一种情况是,有些适配方案把进度事件改成了下载完成才触发一次,表现就是 0% 然后突然 100%。

解决:进度条做成不确定状态,或者自己做真实进度估算。一个可用方案是用 wx.downloadFile 的 onProgressUpdate 拿真实进度,下载成功后读文件再 parse。

downloadWithProgress(url) { const task = wx.downloadFile({ url: url, success: (res) => { if (res.statusCode === 200) { // 临时文件路径,需要读成 ArrayBuffer 再交给 loader.parse const fs = wx.getFileSystemManager() fs.readFile({ filePath: res.tempFilePath, success: (readRes) => this.parseBuffer(readRes.data), fail: () => this.setData({ errorMsg: '读取模型文件失败' }) }) } }, fail: () => this.setData({ errorMsg: '下载失败' }) }) task.onProgressUpdate((res) => { if (res.totalBytesExpectedToWrite > 0) { const pct = Math.round(res.totalBytesWritten / res.totalBytesExpectedToWrite * 100) this.setData({ progress: pct }) } }) }

注意:wx.downloadFile成功后拿到的是临时文件路径,不是数据本体,要再用wx.getFileSystemManager().readFile读成 ArrayBuffer。这条链路的请求域名要配在 downloadFile 合法域名而不是 request 合法域名,配错会被拦截。

4.4 现象:模型加载成功但全黑或贴图丢失

原因:GLB 内嵌的纹理一般能正常显示,glTF 引用外部贴图的场景里,贴图 URL 是相对路径,loader 会基于模型 URL 去拼接。适配层如果没把纹理加载替换成 downloadFile,贴图请求就会失败;TextureLoader 直接传网络 URL 在小程序里也大概率失败,因为 canvas 的 createImage 不允许直接加载网络图片,必须先下载成临时文件。

解决:需要外部贴图时,先 wx.downloadFile 拿到临时文件路径,再交给 TextureLoader。

wx.downloadFile({ url: 'https://your-cdn.example.com/textures/wood.jpg', success: (res) => { if (res.statusCode === 200) { const THREE = createScopedThreejs(this.canvas) const texture = new THREE.TextureLoader().load(res.tempFilePath) // 把 texture 赋给对应材质并标记更新 this.model.traverse((child) => { if (child.isMesh && child.material.name === 'body') { child.material.map = texture child.material.needsUpdate = true } }) } } })

逻辑说明:小程序里图片必须走 downloadFile 得到临时文件路径,TextureLoader 才能把它加载成 WebGL 纹理。如果模型其他部分正常只有贴图是灰色,基本就是这条路没打通。更省事的方案是导出模型时把贴图内嵌进 GLB,文件大一点但省掉一切纹理路径问题。

4.5 现象:连续切换多个模型,页面越来越卡最后闪退

原因:three.js 在 Web 端靠浏览器接管 GPU 资源回收,小程序里 canvas 的 GPU 资源只在页面销毁时释放。替换模型时旧模型的 geometry、material、texture 还挂在 GPU 上,内存只增不减。

解决:加载新模型前显式释放旧模型资源。

disposeModel(model) { if (!model) return model.traverse((child) => { if (child.isMesh) { if (child.geometry) child.geometry.dispose() if (child.material) { const mats = Array.isArray(child.material) ? child.material : [child.material] mats.forEach((mat) => { if (mat.map) mat.map.dispose() mat.dispose() }) } } }) this.scene.remove(model) }

这条是血泪经验:某开发者做过一个案例,用户连续切换十余个模型后,iPhone 上直接闪退。加了这个 dispose 逻辑之后内存占用曲线平稳,闪退消失。另外,页面 onHide 时可以考虑暂停渲染循环,onShow 再恢复,减少后台 GPU 占用,这个习惯值得保持。

5. 模型上屏后的渲染调优:光照、坐标规整与触摸交互

5.1 用包围盒把任意外部模型规整到视口内

外部模型来源复杂,有的建模软件 Y 轴朝上,有的 Z 轴朝上,尺寸单位也不统一。glTF 标准规定 Y 轴朝上,但导出时常有偏差。所以模型加载成功后,第一件事不是加效果,而是规整。

function normalizeModel(model) { // 计算包围盒 const box = new THREE.Box3().setFromObject(model) const center = box.getCenter(new THREE.Vector3()) const size = box.getSize(new THREE.Vector3()) // 找到最长边,缩放到目标尺寸 const maxDim = Math.max(size.x, size.y, size.z) const scale = 2 / maxDim // 中心移到原点,缩放 model.position.sub(center) model.scale.set(scale, scale, scale) // 如果模型是 Z 轴朝上导出的,绕 X 轴旋转 90 度修正 // model.rotation.x = Math.PI / 2 return { scale, center } }

参数说明:scale = 2 / maxDim是最长边缩放到 2 个单位,配合 45 度 fov 和距离 5 的相机,模型在画面里占约 60% 高度。如果你发现模型初始朝向不对,优先检查是建模工具轴向问题还是导出设置问题,旋转修正写在规整函数里集中管理,别散落在各处,否则后面调交互时会怀疑人生。

5.2 光照参数与阴影:环境光、平行光怎么配

光照是 3D 展示质感的关键。基础组合是环境光加平行光:环境光保证暗部不纯黑,平行光制造立体感。

// 环境光:基础亮度,不要超过 0.7 const ambient = new THREE.AmbientLight(0xffffff, 0.5) this.scene.add(ambient) // 平行光:主光源 const dirLight = new THREE.DirectionalLight(0xffffff, 1.0) dirLight.position.set(5, 8, 6) this.scene.add(dirLight) // 需要阴影时开启 this.renderer.shadowMap.enabled = true this.renderer.shadowMap.type = THREE.PCFSoftShadowMap dirLight.castShadow = true dirLight.shadow.mapSize.set(1024, 1024)

参数说明:环境光强度超过 0.7 会让模型发白;平行光位置决定了阴影方向和亮面位置,放在相机同侧上方比较自然。shadowMap.mapSize是阴影贴图分辨率,1024 在手机上够用,开 2048 画质更好但发热明显。阴影是小程序端最昂贵的特性之一,建议只给桌面级展示需求开启,常规电商商品展示用不上。

提示:真机调试时,如果发现模型表面闪烁或出现条纹,多半是阴影贴图精度不够或近裁剪面设置不当,优先调 shadow.mapSize 和 light.shadow.camera.near/far。

5.3 触摸交互:单指旋转、双指缩放的实现

模型转起来才算落地。WXML 里在 canvas 外层套一个 view,绑定 touch 事件。

<view class="canvas-wrap" bindtouchstart="onTouchStart" bindtouchmove="onTouchMove" bindtouchend="onTouchEnd"> <canvas type="webgl" id="modelCanvas" class="model-canvas"></canvas> </view>
onTouchStart(e) { if (e.touches.length === 1) { this.touchMode = 'rotate' this.lastX = e.touches[0].clientX this.lastY = e.touches[0].clientY } else if (e.touches.length === 2) { this.touchMode = 'zoom' this.lastPinchDist = this.getDist(e.touches) } }, onTouchMove(e) { if (!this.model) return if (this.touchMode === 'rotate' && e.touches.length === 1) { const x = e.touches[0].clientX const y = e.touches[0].clientY const dx = x - this.lastX const dy = y - this.lastY this.lastX = x this.lastY = y // 旋转灵敏度系数,0.01 和 0.005 是手感调出来的 this.model.rotation.y += dx * 0.01 this.model.rotation.x += dy * 0.005 // 限制俯仰角,防止模型翻转到背面失控 this.model.rotation.x = Math.max(-1.2, Math.min(1.2, this.model.rotation.x)) } else if (this.touchMode === 'zoom' && e.touches.length === 2) { const dist = this.getDist(e.touches) const ratio = dist / this.lastPinchDist this.lastPinchDist = dist // 限制缩放范围,避免缩到看不到或穿模 const s = Math.max(0.3, Math.min(4, this.model.scale.x * ratio)) this.model.scale.set(s, s, s) } }, onTouchEnd() { this.touchMode = '' }, getDist(touches) { const dx = touches[0].clientX - touches[1].clientX const dy = touches[0].clientY - touches[1].clientY return Math.sqrt(dx * dx + dy * dy) }

逻辑说明:单指时记录上一次触摸点,计算 deltaX 和 deltaY 并累加到模型 rotation;双指时通过两指距离比例缩放模型。旋转灵敏度 0.01 和 0.005 不是理论值,是根据真机手感调出来的:竖屏上手指横向滑动 100 像素,模型转 57 度,体感接近自然;俯仰角限制在 ±1.2 弧度,防止模型倒扣。缩放范围限制在 0.3 到 4 倍,防止缩小到看不见或放大到穿模。

如果想让手感更好,可以加惯性:touchend 时记录最后几帧的速度矢量,在渲染循环里按衰减系数递减旋转速度。惯性代码不复杂,但要注意页面切后台时把速度清零,否则回到页面模型还在转。

6. 进阶验证:用 Draco 压缩把外部 GLB 模型加载时间砍掉一半

加载外部模型最直接的瓶颈在文件体积。一个 2.8MB 的 GLB 在 4G 弱网下可能要 5 秒以上,用户早退出了。Draco 压缩是几个三维格式里最适合小程序场景的优化手段:压缩率高、three.js 原生支持解码器、改动量小。

先用命令行工具把 GLB 做压缩。以 gltf-transform 为例,一条命令完成。

# 安装并执行 Draco 压缩,input.glb 是原始文件,output.glb 是压缩产物 npx gltf-transform draco input.glb output.glb

压缩后模型文件可能只有原来的三分之一甚至更小,代价是前端加载时要多一个解码步骤。解码器是 WebAssembly 文件,需要随小程序包一起发布。启用方式是在 GLTFLoader 上挂 DRACOLoader:

const { DRACOLoader } = THREE const dracoLoader = new DRACOLoader() dracoLoader.setDecoderPath('/libs/threejs/draco/') // decoders 放在小程序包内 loader.setDRACOLoader(dracoLoader)

注意setDecoderPath指向的是小程序包内的目录,里面包含 draco_decoder.wasm 和对应的 JS 包装文件。这个 wasm 文件大约 100 到 200KB,会占包体,但相比压缩掉的那几 MB 网络传输,这笔开销非常划算。如果主包空间紧张,把解码器放进子包或者独立分包,加载模型前按需初始化。

验证压缩收益的方法很简单:在加载前后记录时间戳。

const startTime = Date.now() loader.load(url, (gltf) => { const cost = Date.now() - startTime console.log('模型加载解析总耗时(ms)', cost) // 分别在弱网、WiFi 下记录,对比压缩前后的数值 })

更严谨的做法是在开发者工具里模拟弱网:Network 面板选 Slow 3G,测压缩前后各自的加载总耗时。某模型从 2.8MB 压到 800KB 后,弱网下总耗时从 6.2 秒降到 2.1 秒,这个量级的提升在真机体验上是质变。

压缩之后,材质和动画都不受影响吗?理论上是无损的,几何信息解压后重建,视觉差异极小。但如果你用了顶点颜色或自定义属性,压缩前先确认 Draco 编码器保留这些属性,否则模型颜色会丢。纹理不在 Draco 压缩范围内,大贴图要另外用图片压缩策略,比如转成 WebP 或降低分辨率。

还要提醒一点:Draco 压缩适合面数大、顶点密集的模型。如果模型本身只有几千面,压缩率有限,还倒贴解码器的加载时间,不如不压。判断标准是原始 GLB 超过 1MB 就值得试,低于 500KB 直接跳过。

我第一次接小程序 3D 展示时,也是先被真机白屏打懵,后来才意识到适配、格式、压缩这三件事必须一起考虑。现在我的习惯是:任何外部模型先转 GLB,再压 Draco,先在真机验证,再谈上线。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询