ARTICLE DETAIL

资讯详情

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

微信小程序3D展示:Three.js适配与真机避坑

微信小程序3D展示:Three.js适配与真机避坑

简介:这是一份面向微信小程序开发者的 three.js 三维展示入门实践包,主要解决小程序环境中加载并渲染 3D 模型时容易遇到的配置与兼容问题。压缩包共 18 个文件,约 258KB,包含 8 个 js 逻辑脚本、5 个 json 配置、2 个 wxml 页面结构、2 个 wxss 样式以及 1 个 gltf 模型文件;其中 js 承载 three.js 引入与动画控制,json 与 wxml、wxss 构成页面骨架和布局,gltf 则提供可直接加载的虾模型资源。已有 2307 人学习下载,说明该示例在小程序 3D 场景中具有一定参考价值。读者可据此了解模型上传服务器后在 wxml 中通过 url 引用的完整链路,并基于 js 中的函数自行调整旋转、掉落等动画效果,减少环境配置和渲染调试中的弯路,适合希望快速上手小程序三维展示的初中级开发者。

1. 从一次真机白屏说起:小程序里的 3D 展示到底难在哪

设计师在桌面端导出一个 GLB,双击打开预览正常,丢进小程序后真机一片空白,开发者工具里却跑得好好的——做「微信小程序 3d」展示的人,十个里有八个在第一周就撞上这堵墙。问题不在模型,也不在 Three.js 本身,而在于小程序没有 DOM:没有document,没有new Image(),没有XMLHttpRequest,canvas也不是浏览器那个 canvas。Three.js 是照着浏览器写的库,直接搬进来必然翻车。

这套方案要解决的就是这件事:在一个没有 DOM 的运行时里,把 Three.js 的渲染管线接上小程序的原生 WebGL canvas,让 3D 模型能加载、能旋转缩放、能在低端安卓机上稳住帧率,并且能被验收。适合三类人:做毕设需要交一个可演示 3D 模块的学生、要给产品加「商品 3D 预览」的开发者、以及课程设计要求里写着「使用 WebGL 实现三维交互」的人。

2. 技术选型与运行环境:为什么走 Three.js 加适配层这条路线

2.1 三条可选路线的对比与决策依据

在小程序里展示 3D 模型,能走的路其实就三条,每条都有明确的代价,先把账算清楚再动手,比写到一半推倒重来省事得多。

第一条是自己手写原生 WebGL。优点是零依赖、包体最小、完全可控;缺点是要自己写矩阵变换、着色器、法线光照、模型解析器——光是 glTF 解析这一项,就够写一个课程设计的全部工作量,而且大概率只支持你自己导出的那一个模型。第二条是用web-view组件内嵌一个 H5 页面,页面里用正常的 Three.js。这条路开发最快,但web-view是独立进程,页面通信只能靠 postMessage,交互延迟明显,而且它本质上跳出了小程序的渲染体系,很多场景下体验割裂,答辩时也不好讲清楚「小程序的 3D 能力是怎么用的」。第三条是把 Three.js 引入小程序,用一个适配层补齐它依赖的浏览器 API,直接渲染到<canvas type="webgl">上。

我一般选第三条。理由是成本分摊合理:Three.js 的场景图、材质、加载器这些重活它自己扛了,适配层只需要补几个边界接口,工作量集中且可控;同时它确实跑在小程序的渲染体系里,跟页面生命周期、分包、手势事件都是一体的,产品化和答辩叙述都站得住。手写方案留给「模型只有一个、极致要求包体」的场景,web-view方案留给「必须复用已有 H5 页面」的场景。

路线开发成本包体交互延迟适用场景
原生 WebGL 手写高最小低单一固定模型、追求极致体积
web-view 内嵌 H5低最小明显(跨进程)已有 H5 3D 页面需复用
Three.js + 适配层中较大(可裁剪/分包)低通用模型展示、毕设与产品化

选定路线之后,紧接着要确认一个前提:目标基础库是否支持canvas的type="webgl"以及通过SelectorQuery.node()拿到 canvas 节点。这两件事是整个方案的基石,如果上线范围里还有很老的基础库版本,得在页面里做降级提示,而不是让用户对着白屏猜。

2.2 适配层到底补了什么:把 DOM 依赖收进一个作用域

很多人对适配层有误解,以为它是「魔改 Three.js」。实际做法干净得多:不修改 Three.js 源码,而是在创建 THREE 实例时,把一组模拟对象注入进去,让这个实例内部的document、window、Image、XMLHttpRequest、requestAnimationFrame全部指向小程序侧的实现。这样同一个包里可以同时存在多个互不干扰的 THREE 实例,各自绑定各自的 canvas。

需要补齐的接口大致是这几类:document.createElement('canvas')要返回小程序 canvas(用于生成贴图、渲染到纹理);document.createElement('img')和new Image()要返回由canvas.createImage()创建的对象;window.devicePixelRatio、window.innerWidth/innerHeight要从小程序系统信息里取;requestAnimationFrame要转发到canvas.requestAnimationFrame;XMLHttpRequest要么用wx.request实现,要么干脆不用——模型和贴图我倾向走「先下载到本地临时文件再读成 ArrayBuffer」的路子,比模拟 XHR 稳。

提示:适配层不需要覆盖 Three.js 的全部 API,只覆盖你实际用到的那部分。用不到的接口留空实现即可,把所有接口都写一遍反而是维护负担,也会掩盖真正的问题。

2.3 工程目录与依赖组织方式

目录结构直接决定了后面资源放哪、会不会撑爆主包。主包限制是 2MB,完整的 Three.js 打包后在几百 KB 量级,再加上模型和贴图,很容易越线。我的习惯是把 3D 相关的一切丢进分包,页面本体只负责一个入口。

miniprogram/ ├── pages/ │ └── viewer/ # 主包:只放一个跳转入口和骨架 ├── package3d/ # 分包:3D 展示全部逻辑 │ ├── pages/detail/ │ ├── libs/ │ │ ├── three.core.js # 按需裁剪后的 Three.js │ │ └── three-adapter.js # 适配层,导出 createScopedThreejs │ ├── loaders/ # GLTFLoader、DRACOLoader 等 │ └── assets/ │ └── fallback.glb # 兜底模型,体积控制在几十 KB └── utils/

three.core.js建议按需引入,只保留Scene、PerspectiveCamera、WebGLRenderer、Mesh、常用几何体与材质、Raycaster、Group这些;后处理、骨骼动画、物理相关模块如果项目用不上就不要打进去。分包配置写在app.json的subpackages里,页面跳转时预下载。兜底模型的意义在于:网络失败或 CDN 域名没配好时,页面至少有东西可看,不至于交上去一个白屏的作业。

3. 总体架构设计:四层结构与生命周期对齐

3.1 页面层、渲染层、资源层、交互层的职责划分

把整个 3D 展示拆成四层,每层只干一件事,出问题时能快速定位是哪一层挂了,这是我在被白屏折磨过之后固定下来的结构。

页面层负责小程序侧的一切:Page的生命周期、canvas 节点的获取、加载态与错误态的 UI、分包预下载。渲染层负责 Three.js 世界:场景、相机、光照、材质、渲染循环。资源层负责模型与贴图的获取、解压、缓存和释放。交互层负责把触摸事件翻译成相机轨道参数,以及把点击翻译成射线拾取结果。层与层之间只通过明确的数据传递,页面层不直接碰mesh,渲染层也不直接调wx.downloadFile。

这样的划分带来的实际好处是:换模型只动资源层,加手势只动交互层,改 UI 只动页面层。毕设答辩时被问「如果模型要换成另一个格式怎么办」,你可以指着资源层说清楚改动范围,而不是含糊地说「改一下代码」。

3.2 渲染循环与页面生命周期的对齐关系

小程序页面会被onHide、onUnload打断,而渲染循环是个永不停止的requestAnimationFrame。这两者不对齐,就是内存泄漏和掉帧的源头。我的做法是给渲染器加一个明确的状态机:idle → ready → running → paused → disposed,每次页面生命周期回调都只做状态迁移。

onReady里做初始化并进入ready;onShow时若为paused则恢复running;onHide时切到paused并停掉动画帧;onUnload时释放所有 GPU 资源和事件监听,进入disposed。特别注意onShow会在首次进入时也触发一次,因此初始化必须用一次性标记保护,否则监听器会被重复绑定,表现为「转一下模型转了两倍角度」这种玄学现象。

3.3 资源加载与缓存策略:分包、压缩与本地兜底

资源层要解决三件事:模型从哪来、多大、失败怎么办。

模型来源通常是 CDN,需要在小程序后台配置 downloadFile 合法域名,且必须是 HTTPS。体积上,一个中等复杂度的产品模型导出 GLB 后常在数 MB,直接加载在弱网下体验极差,常规做法是导出时开 Draco 几何压缩、贴图转 WebP 并降到 1024 以内,压缩后往往能降到原来的三分之一甚至更小。缓存策略上,用wx.downloadFile下载后文件名带内容哈希,配合wx.getFileSystemManager().access()判断本地是否已有,命中就直接读本地,避免每次进页面都重新拉一遍。兜底策略上,网络请求超过预设时长或返回非 200,直接切到分包里的fallback.glb,同时把错误码上报,让问题可见而不是静默白屏。

注意:本地临时文件有容量上限且可能被系统清理,别把它当永久缓存用。每次读取前都要access检查一次,读失败就走重新下载。

4. 核心模块实现:从画布初始化到手势交互的关键代码

4.1 初始化:拿到 canvas 节点并把 THREE 注入进去

初始化是整个方案里最容易出错的一步,顺序错了后面全白搭。必须等节点查询回调返回 canvas 实体,再创建 THREE 实例,再创建渲染器,三步不能并行。

// package3d/pages/detail/index.js import { createScopedThreejs } from '../../libs/three-adapter' Page({ data: { status: 'loading' }, // loading | ready | error onReady() { // 只查一次节点,回调里再往下走,避免时序问题 wx.createSelectorQuery() .select('#gl') .node() .exec((res) => { const canvas = res && res[0] && res[0].node if (!canvas) { this.setData({ status: 'error' }) return } // 适配层返回一个只绑定当前 canvas 的 THREE 实例 const THREE = createScopedThreejs(canvas) this.bootScene(canvas, THREE) }) } })

这段代码的关键点有三个。select('#gl')对应的 WXML 节点必须是<canvas type="webgl" id="gl" bindtouchstart=... >,type写错成2d会直接拿不到 WebGL 上下文。.node()返回的是小程序 canvas 对象本身,不是 DOM 节点,所以后续所有尺寸、像素比都要从系统信息里取,不能读canvas.width之外的浏览器属性。createScopedThreejs(canvas)是适配层暴露的唯一入口,它内部把document、window等注入后返回 THREE,页面层拿到的东西和浏览器里用的 THREE 用法一致,这也是这套结构好维护的原因。

4.2 场景搭建:相机、光照与渲染器参数

渲染器创建时直接复用已有 canvas,绝不能让它自己去createElement,否则拿到的是一个没有渲染目标的对象,表现就是「代码不报错但屏幕全黑」。

bootScene(canvas, THREE) { const info = wx.getWindowInfo ? wx.getWindowInfo() : wx.getSystemInfoSync() const dpr = Math.min(info.pixelRatio || 2, 2) // 高分屏限到 2,避免低端机被像素拖死 const width = info.windowWidth const height = Math.round(info.windowWidth * 0.75) // 画布按 4:3 占位 const renderer = new THREE.WebGLRenderer({ canvas, // 复用小程序 canvas antialias: dpr <= 1.5, // 低端机直接关抗锯齿 alpha: true // 需要透出页面背景时开启 }) renderer.setPixelRatio(dpr) renderer.setSize(width, height) const scene = new THREE.Scene() const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 100) camera.position.set(0, 1.2, 3.2) camera.lookAt(0, 0.6, 0) // 半球光打底 + 平行光造型,两盏灯基本能覆盖大多数产品模型 scene.add(new THREE.HemisphereLight(0xffffff, 0x444444, 1.6)) const dir = new THREE.DirectionalLight(0xffffff, 1.2) dir.position.set(3, 6, 4) scene.add(dir) this.renderer = renderer this.scene = scene this.camera = camera this.setData({ status: 'ready' }) }

setPixelRatio这里有个容易被忽略的细节:小程序没有window.devicePixelRatio,取值必须走系统信息;同时高分屏上把 dpr 限制到 2 是一个很实际的取舍,像素比从 3 降到 2,片元数量直接少了将近一半,肉眼几乎看不出差别,但低端安卓机的帧率能稳住。antialias同理,用 dpr 做条件分支比一刀切开或关更合理。相机用 45 度视角、距离 3 米左右是个通用起点,模型尺寸差异大时优先调camera.position和lookAt的目标点,而不是去改fov,改fov会带来明显的透视畸变。

4.3 模型加载:下载、读文件与 GLTFLoader.parse 的衔接

小程序里没有 XHR,GLTFLoader.load(url)这条路走不通,必须改成「下载到临时文件 → 读成 ArrayBuffer →parse」。

loadModel(THREE, url) { const fs = wx.getFileSystemManager() const loader = new THREE.GLTFLoader() wx.downloadFile({ url, timeout: 15000, success: ({ statusCode, tempFilePath }) => { if (statusCode !== 200) return this.useFallback(THREE) fs.readFile({ filePath: tempFilePath, success: ({ data }) => { // 第二个参数是资源根路径,glb 内嵌贴图传空串即可 loader.parse(data, '', (gltf) => { this.model = gltf.scene this.model.scale.setScalar(1) this.scene.add(this.model) this.startLoop() // 模型就绪后再启动渲染循环 }, (err) => { console.error('parse fail', err) this.useFallback(THREE) }) }, fail: () => this.useFallback(THREE) }) }, fail: () => this.useFallback(THREE) }) }

downloadFile的timeout显式给一个值,比用默认值更容易在弱网下快速失败并切兜底。readFile不传encoding,返回的就是 ArrayBuffer,正好是parse需要的入参类型。parse的第二个参数是资源根路径,如果模型是.gltf加外链贴图的分离式文件,这里要传贴图所在目录,否则贴图会 404 导致模型灰扑扑的;用.glb自包含格式就没这个问题,所以我在项目里一律要求导出 GLB。第三个参数的成功回调里才启动渲染循环,避免模型还没进来就先跑空帧,白烧电。

4.4 手势交互:旋转缩放与点击拾取的坐标换算

小程序的触摸事件坐标是相对页面的,射线拾取需要相对 canvas 的归一化坐标,这中间差了一个boundingClientRect,漏掉就会出现「点左边选中右边」。

// 旋转:单指拖动,按位移增量累加,不直接乘绝对坐标 onTouchMove(e) { if (e.touches.length !== 1 || !this.model) return const t = e.touches[0] const dx = t.clientX - this.lastX const dy = t.clientY - this.lastY this.lastX = t.clientX this.lastY = t.clientY this.model.rotation.y += dx * 0.01 this.model.rotation.x = Math.max(-0.6, Math.min(0.6, this.model.rotation.x + dy * 0.01)) } // 拾取:屏幕坐标 → NDC,必须减去 canvas 在页面中的偏移 onTap(e) { const { x, y } = e.detail const rect = this.canvasRect // onReady 时用 boundingClientRect 缓存 const ndc = { x: ((x - rect.left) / rect.width) * 2 - 1, y: -((y - rect.top) / rect.height) * 2 + 1 } const raycaster = new this.THREE.Raycaster() raycaster.setFromCamera(ndc, this.camera) const hit = raycaster.intersectObject(this.model, true) if (hit.length) this.setData({ picked: hit[0].object.name }) }

旋转用「增量累加」而不是「绝对坐标映射」,是因为绝对映射在手指抬起再按下时模型会瞬移,体验很差。给 X 轴加 ±0.6 弧度左右的夹紧,防止用户把模型翻到背面看见空壳。缩放走双指touchmove,用当前两指距离除以按下时的初始距离得到比例,再乘到基准camera.position上,务必以初始距离为基准而不是每帧互相除,否则会出现「越缩越快」的失控感。拾取这里,e.detail里的坐标是相对页面的,减掉 canvas 的left/top再除以宽高,才得到正确的归一化设备坐标;intersectObject第二个参数传true表示递归子网格,GLB 导入后模型通常是一个多层Group,不递归基本点不中。

4.5 性能与资源释放:帧率控制与 GPU 内存回收

渲染循环要用 canvas 自己的requestAnimationFrame,不要用setInterval,后者在小程序里会和渲染节奏错位,表现为规律性卡顿。

startLoop() { const loop = () => { if (this.state !== 'running') return this.renderer.render(this.scene, this.camera) this.rafId = this.canvas.requestAnimationFrame(loop) } this.state = 'running' this.rafId = this.canvas.requestAnimationFrame(loop) }, disposeAll() { if (this.rafId) this.canvas.cancelAnimationFrame(this.rafId) this.scene && this.scene.traverse((obj) => { if (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) => { const v = m[k] if (v && v.isTexture) v.dispose() // 贴图不释放,显存会一直涨 }) m.dispose() }) } }) this.renderer && this.renderer.dispose() this.renderer && this.renderer.forceContextLoss() // 主动丢弃 WebGL 上下文 this.state = 'disposed' }

静态展示类页面其实不需要 60 帧常驻,模型不动的时候完全可以只在交互时渲染,或者把帧率压到 30,这对电池和发热的改善在小程序这种长驻场景里非常明显。释放部分,geometry、material、texture三层都要dispose,其中贴图最容易被漏掉,漏了的表现是「来回进出页面十几次之后开始卡」,因为显存只增不减。最后调forceContextLoss()主动丢弃 WebGL 上下文,这是小程序里回收 GPU 资源最有效的一招,配合onUnload调用,能显著降低返回上一页后的内存占用。

5. 避坑与排查:小程序 3D 展示最容易翻车的五个点

5.1 开发者工具正常、真机白屏

现象:模拟器里模型转得好好的,扫码到手机上打开就是一片空白,控制台还可能干干净净什么都不报。

原因:开发者工具的渲染环境更接近浏览器,部分 DOM 接口是模拟出来的,而真机是纯小程序运行时。常见触发点是代码里直接写了new Image()加载贴图、用document.createElement('canvas')生成纹理、或者通过XMLHttpRequest请求资源。这些在工具里可能被垫片兜住,到真机就全部失败,而且失败是静默的。

解决:全局搜一遍document.、new Image、XMLHttpRequest,把业务代码里的这些用法全部换成适配层提供的写法,贴图用canvas.createImage(),纹理画布用适配层导出的创建函数。另外把createScopedThreejs的返回值打印一次,确认拿到的是对象而不是undefined——适配层加载失败也可能表现为白屏。排查顺序建议自上而下:节点有没有拿到 → THREE 有没有创建成功 → 渲染器有没有拿到上下文 → 模型有没有进场景。

5.2 模型加载慢、弱网直接失败

现象:4G 下转圈十几秒,地铁里直接失败;换个模型又好了,看起来毫无规律。

原因:模型体积过大是第一嫌疑,未压缩的 GLB 动辄十几 MB;其次是 downloadFile 合法域名没配或配了 http;再次是每次进入页面都重新下载,没有本地缓存。

解决:导出时开 Draco 几何压缩、贴图转 WebP 并控制在 1024 以内,目标是把单模型压到 2MB 以内。域名配置在小程序后台核对一遍,必须是 HTTPS 且与请求地址完全一致(包括是否带 www)。缓存用内容哈希做文件名,进页面先access再决定下载还是读本地。最后一定要有兜底模型和超时时间,让失败是可预期的降级,而不是无限转圈。

5.3 反复进出页面后卡顿、闪退

现象:第一次进很流畅,来回进出五六次之后明显变卡,安卓低端机偶尔直接闪退。

原因:渲染循环没有随页面隐藏停止,多个页面实例同时跑 rAF;onShow重复触发初始化,监听器叠加;模型和贴图的 GPU 资源从未释放。

解决:用状态机管住渲染循环,onHide必须停帧,onUnload必须走完整释放流程,包括cancelAnimationFrame、逐层dispose、forceContextLoss。初始化加一次性标记,所有bindtouchstart之类的绑定只在首次执行。验证方法很土但有效:进出一个页面二十次,观察小程序内存面板的曲线是不是持续上扬。

提示:forceContextLoss()之后这个 canvas 就报废了,不能再拿来渲染。所以释放只能发生在onUnload,不能在onHide里调。

5.4 模型发黑、过曝或材质像塑料

现象:模型形状对了,但整体偏暗看不清细节,或者亮得发白,金属件看着像塑料玩具。

原因:三个高频原因——场景里只有环境光没有方向光,缺乏明暗关系;色彩空间没设置,贴图颜色整体偏移;用了 PBR 材质但没有环境贴图,金属度高的表面没有可反射的东西,自然一片死黑。

解决:光照至少半球光加平行光两盏,平行光给个明确方向。渲染器输出色彩空间设成 sRGB,贴图的色彩空间也跟着设,两边不一致就会偏色。模型带金属材质时,用一张小尺寸的环境贴图(HDR 转成几百 KB 的立方体贴图即可)作为scene.environment,这一项对质感的提升比调光照参数更明显。

5.5 手势与页面滚动打架、缩放手感失控

现象:想转模型结果整页在滚,或者双指缩放时模型忽大忽小、越缩越快。

原因:canvas 上的touchmove没有阻止冒泡,事件被页面滚动接管;缩放比例用当前距离除以「上一帧距离」而不是「按下时的初始距离」,误差逐帧累积。

解决:canvas 上加catchtouchmove拦截冒泡(绑定一个空函数即可),标题栏和说明区保留正常滚动。缩放记录touchstart时的初始两指距离,每帧用当前距离除以初始距离得到比例,再乘到基准距离上,全程只用一个基准值。同页面如果既有横向滑动切图又有模型旋转,要用touchstart的起点方向判定意图,别让两种手势同时生效。

6. 验收与答辩:怎么证明这套 3D 展示是「能交付」的

验收不是「我这儿能跑」,而是「换一台机器、换一个网络、换一个人操作,都还能跑」。我一般准备一份清单,逐项过一遍再交出去,答辩现场也是照着这份清单讲。

验收项判定标准检查方式
首屏可见进入页面 3 秒内出现模型或兜底模型弱网模式(限速)实测
真机覆盖至少一台 iOS、一台中低端安卓表现为一致真机预览,非模拟器
交互完整单指旋转、双指缩放、点击拾取全部可用手动逐项操作
资源兜底断网状态下不白屏,有提示与兜底模型开飞行模式进入
内存稳定进出页面 20 次内存曲线不上扬开发者工具内存面板
生命周期退到后台再回前台,渲染不停摆不叠加切后台 30 秒后返回
包体合规主包未超限,3D 逻辑在分包内构建产物分析

答辩环节,老师或评委最常追问的三个问题基本可以预判:为什么不用web-view、模型多大、低端机怎么办。第一个问题用第 2 章那张对比表回答,讲清楚跨进程通信的代价和渲染体系的一致性;第二个问题直接报压缩前后的数字,并说明 Draco 和贴图规格的选择理由;第三个问题讲 dpr 限制、抗锯齿条件开关、静态降帧这几条具体手段,而不是笼统说「做了优化」。

演示脚本也值得提前写一遍:先展示正常加载与交互,再切飞行模式展示兜底降级,最后快速进出两三次证明没有内存问题。整个流程控制在一分半以内,比讲十分钟原理有说服力。当年我第一次交课程设计,PPT 写了三十页讲 Three.js 的渲染管线,老师只问了一句「你这个模型多大、手机上卡不卡」,我答不上来。从那以后我每次做 3D 相关的交付,都强制走一遍上面这张验收表,把数字写在文档里再去讲设计。希望帮到你。

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

返回列表