
简介这是一份面向微信小程序开发者的 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()没有XMLHttpRequestcanvas也不是浏览器那个 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 typewebgl上。我一般选第三条。理由是成本分摊合理Three.js 的场景图、材质、加载器这些重活它自己扛了适配层只需要补几个边界接口工作量集中且可控同时它确实跑在小程序的渲染体系里跟页面生命周期、分包、手势事件都是一体的产品化和答辩叙述都站得住。手写方案留给「模型只有一个、极致要求包体」的场景web-view方案留给「必须复用已有 H5 页面」的场景。路线开发成本包体交互延迟适用场景原生 WebGL 手写高最小低单一固定模型、追求极致体积web-view 内嵌 H5低最小明显跨进程已有 H5 3D 页面需复用Three.js 适配层中较大可裁剪/分包低通用模型展示、毕设与产品化选定路线之后紧接着要确认一个前提目标基础库是否支持canvas的typewebgl以及通过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.requestAnimationFrameXMLHttpRequest要么用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里做初始化并进入readyonShow时若为paused则恢复runningonHide时切到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 typewebgl idgl 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 的衔接小程序里没有 XHRGLTFLoader.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 反复进出页面后卡顿、闪退现象第一次进很流畅来回进出五六次之后明显变卡安卓低端机偶尔直接闪退。原因渲染循环没有随页面隐藏停止多个页面实例同时跑 rAFonShow重复触发初始化监听器叠加模型和贴图的 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 相关的交付都强制走一遍上面这张验收表把数字写在文档里再去讲设计。希望帮到你。本文还有配套的精品资源点击获取