简介:面向微信小程序开发者的Three.js 3D模型展示Demo,旨在解决Web 3D库在小程序环境中适配难、上手成本高的问题,适合希望快速集成3D场景并实现基础交互效果的初中级开发者。压缩包共18个文件,涵盖8个js(核心逻辑、模型加载与动画控制)、5个json(项目配置与页面注册)、2个wxss与2个wxml(页面样式与结构布局),以及1个gltf虾模型资源,整体仅258KB,轻量化设计便于直接导入开发者工具运行。源码内置虾模型,可将其上传至服务器后通过wxml中的url链接动态引用;开发者还能自行调整js内函数,实现模型旋转、掉落等多种动画效果,有效绕过常见兼容性坑点。已有2305人学习下载,代码结构清晰、关键位置有注释,适合作为快速验证Three.js在小程序端可用性的模板,并支持在此基础之上扩展交互逻辑或切换自定义模型。
1. 微信小程序里的 3D 展示:为什么我选了 Three.js 这条路线
微信小程序里做 3d 模型展示,这个需求我在一个家具商城的项目里接过一次,第一反应是直接上 Three.js,同事劝我谨慎:小程序环境跟 Web 差异不小,Canvas 能力、网络域名、内存上限全都在限制你。跑通之后回头看,该踩的坑一个没少,但 Three.js 依然是目前把 glb 模型塞进小程序的最佳路线之一。这篇笔记会把落地全过程拆开讲清楚,包括技术选型、目录结构、核心渲染链路、手势交互和几个典型的翻车现场,最后给出二次开发的思路。如果你正打算在小程序里做商品 3D 展示、模型查看器之类的功能,照着这篇能少走不少弯路。
2. 技术选型与目录结构:三条路里选一条能长期维护的
小程序里做三维展示,先别急着写代码,把方案定了比什么都重要。我对照过三条路线,最后选了基于 threejs-miniprogram 的适配方案,这一章把比较过程和落地结构都列出来。
2.1 三条路线对比,我为什么放弃另外两条
第一条是原生 WebGL 手写渲染。矩阵变换、光照模型、相机投影、模型解析全都要自己来,一个带纹理的模型文件解析就能写掉上千行,而且后面换人维护基本等于重写。第二条是 threejs-miniprogram,这是 Three.js 官方团队为小程序做的适配包,保留 THREE 核心 API,渲染层自动对接小程序 Canvas。第三条是序列帧或云渲染的"伪 3D",服务器渲一圈图片前端切换,交互非常受限,只能固定几个角度看,达不到"任意角度旋转"的效果。
| 方案 | 真实 3D 能力 | 手势交互 | 开发成本 | 维护成本 |
|---|---|---|---|---|
| 原生 WebGL 自绘 | 完全可控 | 自己造轮子 | 极高 | 极高 |
| threejs-miniprogram | 完整 | 自行绑定 | 中 | 低 |
| 序列帧/云渲染 | 阉割 | 有限 | 低 | 中 |
我最后选了第二条,理由是它保留了 Three.js 的场景、相机、灯光、加载器这套成熟体系,社区资料多,后面接 GLTFLoader、Draco 压缩这些都有现成方案。序列帧方案在低端机上体验尚可,但用户想旋转看模型背面的细节就抓瞎了,对家具商城这种"看细节才下单"的场景不适用。
2.2 项目目录结构:页面、组件与模型资源的分层
工程结构上我参考了小程序官方推荐的分层方式,把三维渲染逻辑封装成一个独立页面加一个自定义组件,模型资源和业务代码分开管理:
miniprogram/ ├── app.js ├── app.json ├── pages/ │ └── model-view/ │ ├── model-view.js │ ├── model-view.json │ ├── model-view.wxml │ └── model-view.wxss ├── components/ │ └── three-canvas/ │ ├── three-canvas.js │ ├── three-canvas.json │ ├── three-canvas.wxml │ └── three-canvas.wxss ├── utils/ │ ├── three-adapter.js │ └── format.js └── models/ ├── chair.glb └── sofa.glbmodel-view 页面负责业务逻辑,比如加载哪个模型、加载进度怎么展示;three-canvas 组件只做一件事——把 Canvas 节点交给 Three.js 渲染器,页面和渲染逻辑通过组件属性通信。models 目录放压缩后的 glb 文件,开发期直接放本地,上线后我一般会迁到 CDN,因为小程序主包有体积限制。
2.3 模型资产处理:glb 格式、Draco 压缩与转换流程
模型格式这块我踩过一轮。设计师给的是 Blender 工程文件和 fbx,fbx 在 Web 端解析麻烦且体积大;gltf 是 json 加外部贴图,传输时要多个请求;最终选 glb——单个二进制文件,贴图全内嵌,一次下载完事,小程序里省一次额外请求就少一个失败点。
确定格式后必须做压缩,不然一个沙发模型动辄十几 MB,加载体验直接崩。我用 gltf-pipeline 做 Draco 压缩,命令很简单:
npx gltf-pipeline -i sofa.gltf -o sofa.glb --draco.compressionLevel=10-draco.compressionLevel 参数控制压缩强度,范围 0 到 10,越高体积越小但解压耗时越长。我一般取 7 到 10 之间,肉眼几乎看不出模型质量差异,体积能压掉 60% 以上。压缩完的 glb 记得在开发者工具里预览确认一次,有时候 Draco 版本不匹配会导致模型加载后一片空白,这个问题后面避坑章节会详细说。
3. 核心模块拆解:模型从下载到上屏的完整渲染链路
这一章是整篇的核心。小程序里跑 Three.js 和 Web 端最大的区别在于:没有 window、没有 document、Canvas 节点要异步获取。我的实现思路是先在适配层把这些差异抹平,再用常规的 Three.js 写法搭场景和加载模型。
3.1 适配层初始化:拿到 Canvas 节点并桥接 THREE
小程序基础库 2.9.0 之后,Canvas 支持 type="webgl" 直接创建 WebGL 上下文。threejs-miniprogram 这个包做的事情,就是把这套环境和 Three.js 的渲染器对接起来。安装完通过 npm 构建后,页面里这样初始化:
// pages/model-view/model-view.js const createThree = require('threejs-miniprogram') Page({ onReady() { this.initCanvas() }, initCanvas() { wx.createSelectorQuery() .select('#modelCanvas') .fields({ node: true, size: true }) .exec((res) => { if (!res || !res[0]) { console.error('canvas 节点获取失败') return } const canvas = res[0].node const dpr = wx.getSystemInfoSync().pixelRatio // 关键:Canvas 实际像素尺寸要乘 dpr,否则渲染出来是糊的 canvas.width = res[0].width * dpr canvas.height = res[0].height * dpr const { THREE, renderer } = createThree(canvas) this.THREE = THREE this.renderer = renderer renderer.setPixelRatio(dpr) this.initScene() }) } })这里有几个关键点。wx.createSelectorQuery 拿到的 node 是 Canvas 2D 节点,但 type 声明为 webgl 后,适配层会从中取 WebGL 上下文;dpr 必须乘上,iPhone 的像素比是 2 到 3,不乘的话模型边缘锯齿明显。createThree 这个工厂函数的导出方式不同版本略有差异,工程里引入后先看一眼 node_modules 里对应包的 main 字段指向的文件,确认返回结构再往下写,能省不少排查时间。
3.2 场景搭建:相机、灯光与坐标系约定
场景、相机、灯光走的是 Three.js 标准套路,重点在于参数怎么定:
// 继续在 initScene 中 initScene() { const { THREE } = this const scene = new THREE.Scene() this.scene = scene // 45 度视场角接近人眼观察商品的习惯 const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100) camera.position.set(0, 1.2, 4) camera.lookAt(0, 0.6, 0) this.camera = camera // 环境光兜底,防止暗面全黑;方向光制造立体感 const ambientLight = new THREE.AmbientLight(0xffffff, 0.6) scene.add(ambientLight) const dirLight = new THREE.DirectionalLight(0xffffff, 1.2) dirLight.position.set(3, 5, 2) scene.add(dirLight) this.loadModel() }透视相机的视场角 45 度是商品展示常用的取值,太宽会产生畸变,太窄看模型像望远镜。near 和 far 分别取 0.1 和 100,覆盖沙发、椅子这类家具模型的尺度绰绰有余。灯光的两个参数要注意:环境光强度 0.6 只负责兜底,真正决定模型立体感的是方向光,它从斜上方照下来,阴影面才有层次。实际调试时我习惯先把方向光强度拉到 1.5 以上看效果,确认明暗关系后再回落到 1.2 左右,避免过曝。
3.3 模型加载与渲染循环:下载、解析、逐帧绘制
小程序里加载 glb 模型,我推荐先用 wx.downloadFile 下载成 ArrayBuffer,再交给 GLTFLoader 解析,这样能拿到下载进度,也能在 statusCode 非 200 时提前拦截错误:
loadModel() { const { THREE } = this // 从 three/examples/jsm/loaders 引入 GLTFLoader const loader = new this.GLTFLoader() const task = wx.downloadFile({ url: 'https://your-cdn.com/models/chair.glb', success: (res) => { if (res.statusCode !== 200) { this.setData({ loading: false, error: true }) return } // 第二个参数是资源路径,纹理相对路径会基于它解析 loader.parse(res.data, '', (gltf) => { const model = gltf.scene this.modelGroup = model this.scene.add(model) this.setData({ loading: false }) }) }, fail: () => { this.setData({ loading: false, error: true }) } }) task.onProgressUpdate((res) => { this.setData({ progress: res.progress }) }) }GLTFLoader.parse 的第一个参数是 ArrayBuffer,第二个是路径前缀,如果 glb 里带了外部贴图,解析时就靠它定位;我们的贴图都内嵌在 glb 里,传空字符串即可。渲染循环这块,小程序适配层不一定支持 renderer.setAnimationLoop,更稳的做法是自己驱动:
startRenderLoop() { const render = () => { if (this.modelGroup) { // 自动旋转的业务需求,后面会改成手势控制 this.modelGroup.rotation.y += 0.005 } this.renderer.render(this.scene, this.camera) this.canvas.requestAnimationFrame(render) } render() }requestAnimationFrame 挂在 canvas 节点上,每次回调重新渲染一帧。注意这里自动旋转直接改 modelGroup 的 rotation,而不是转相机,这样后续接手势控制时,相机的观察角度可以保持稳定。
4. 交互与控制:触摸坐标到旋转缩放的映射实践
模型能渲染出来只完成了一半,商品展示的核心体验在于手能转、能缩放。这一章讲触摸事件的绑定点位、坐标差值怎么映射到旋转角度,以及双指缩放和状态管理。
4.1 触摸事件绑定与坐标差值映射
Canvas 组件的 WXML 上直接绑定触摸事件,注意外层容器要拦截滚动,否则手指在模型上滑动时会触发页面滚动:
<view class="canvas-wrapper" catchtouchmove="noop"> <canvas type="webgl" id="modelCanvas" bindtouchstart="onTouchStart" bindtouchmove="onTouchMove" bindtouchend="onTouchEnd" style="width: 100%; height: 100%;" ></canvas> </view>事件处理的核心是记录上一次的触摸点,计算位移差值,再乘以一个阻尼系数转成旋转角度:
onTouchStart(e) { this.touchStart = { x: e.touches[0].clientX, y: e.touches[0].clientY } this.rotating = true }, onTouchMove(e) { if (!this.rotating) return const currentX = e.touches[0].clientX const currentY = e.touches[0].clientY const deltaX = currentX - this.touchStart.x const deltaY = currentY - this.touchStart.y if (this.modelGroup) { // 0.01 是阻尼系数,太大转得飞起,太小转不动 this.modelGroup.rotation.y += deltaX * 0.01 this.modelGroup.rotation.x += deltaY * 0.01 } this.touchStart = { x: currentX, y: currentY } }, onTouchEnd() { this.rotating = false }阻尼系数 0.01 是反复调出来的值:手指滑动 100 像素,模型转 1 弧度约 57 度,视觉上跟手又不发飘。用 clientX 而不是 x,是因为在 canvas 节点上 x 的坐标系在不同基础库版本下表现不一致,clientX 更稳。每次 move 都要更新 touchStart,否则差值会累加产生惯性漂移。
4.2 双指缩放:基于两指间距的相机距离调整
缩放我用的是改相机 z 轴距离的方式,比缩放模型本身更符合"拉近看细节"的直觉。双指手势的判断依据是 e.touches.length 是否等于 2:
onTouchMove(e) { 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 ) const delta = (this.lastPinchDist || dist) - dist this.lastPinchDist = dist // 限制相机距离在 1.5 到 8 之间,防止怼进模型内部 const z = Math.max(1.5, Math.min(8, this.camera.position.z + delta * 0.01)) this.camera.position.z = z // 双指操作时禁用单指旋转,避免手势冲突 this.rotating = false return } // 单指旋转逻辑同上 ... }, onTouchEnd() { this.lastPinchDist = null this.rotating = true }这里 delta 的计算方式决定了缩放方向:两指张开时距离变大,delta 为负,z 减小,相机拉近;两指合拢则推远。1.5 和 8 是适配家具模型的极限值,改做机械零件或建筑模型时,这个范围要按模型尺寸重新标定。双指操作时置空 rotating 标志很关键,不然单指和双指逻辑同时生效,模型会在缩放的同时乱转。
4.3 加载进度、错误态与重试
用户面对一个黑屏的等待过程很容易直接退出页面,所以加载反馈必须做足。进度条接的是 wx.downloadFile 的 onProgressUpdate,错误态用浮层引导重试:
retryLoad() { this.setData({ error: false, loading: true, progress: 0 }) // 关键:重试前必须清除旧模型,否则重复叠加 this.disposeModel() this.loadModel() }WXML 里根据 data 状态切换展示层:
<view wx:if="{{loading}}" class="loading-mask"> <text>模型加载中 {{progress}}%</text> </view> <view wx:elif="{{error}}" class="error-mask"> <text>模型加载失败,请检查网络</text> <button bindtap="retryLoad">重试</button> </view>disposeModel 的完整实现放在第六章讲,但这里要先建立意识:glb 一旦加入场景,它占用的 GPU 内存不会自动释放,重试加载时不清旧模型,内存会叠加上涨,低端机很快就顶不住。这个问题的危害在避坑章节会看到具体案例。
5. 避坑与排查:跑 Three.js 时的五个翻车现场
这一章全是血泪经验。小程序加 Three.js 的组合,报错信息和 Web 端不一样,很多问题表面上看不出原因,我把最常见的五类现象、根因和解决方式列出来,你遇到时直接对照排查。
5.1 现象一:Canvas 全黑且控制台零报错
项目跑起来页面是黑的,console 里干干净净,没有任何异常提示。原因是 Canvas 的默认宽高是 300x150,如果你在 SelectorQuery 的 size 返回之前就初始化了渲染器,或者忘了乘 dpr,画布区域会以极小尺寸渲染,看起来就像黑屏。另外 Canvas 组件必须声明 type="webgl",默认的 2d 类型拿不到 WebGL 上下文。解决方式是严格按照 3.1 节的顺序:先 fields({ node: true, size: true }) 拿尺寸,再设 canvas.width 和 height,最后初始化渲染器。
5.2 现象二:模型请求总是失败,重试也没用
现象是进度条一直停在 0,network 面板里能看到请求但状态不对。两个根因最常见:一个是小程序对 downloadFile 的域名有白名单校验,上线后必须在小程序后台的"开发设置"里配置 downloadFile 合法域名,开发期可以在开发者工具里勾选"不校验合法域名"临时绕过;另一个是模型文件名带中文或空格,URL 没做编码处理。解决方式是域名统一走 HTTPS 并在后台加白名单,动态拼接地址时用 encodeURI 包一层。
5.3 现象三:iOS 上页面滚动时模型闪烁白条
iOS 真机上,手指从模型区域滑向页面其他部分时,模型会闪白条甚至短暂消失。原因是 canvas 跟随页面滚动的过程中,系统在反复重绘 WebGL 内容,touch 事件没有在 canvas 层被拦截。解决方式是我在 4.1 节里那个 catchtouchmove="noop",作用就是阻止 canvas 区域内的触摸事件冒泡成页面滚动。如果页面结构比较复杂,还可以给 canvas 外层容器固定宽高,用局部滚动而不是整页滚动。
5.4 现象四:大模型加载后直接闪退
一个 10MB 以上的未压缩 glb,低端安卓机加载完操作几秒就闪退。原因有两个叠加:贴图尺寸过大占满了 GPU 内存,以及切换模型时旧资源没有 dispose。解决方式分三步:模型端用 gltf-pipeline 做 Draco 压缩,贴图导出时缩到 2048 以内;代码端在加载新模型前调用 disposeModel 释放旧资源;运行时通过 wx.getPerformance 埋点观察内存峰值,超过 300MB 就要考虑按机型降级,比如低端机只加载压缩版模型。
5.5 现象五:贴图颜色发灰,模型像蒙了一层雾
同一份 glb 文件在桌面浏览器里颜色正常,小程序里整体发灰。根因是渲染器没有开启 sRGB 颜色空间转换,材质贴图的线性色彩被直接输出到了屏幕。解决方式是初始化渲染器后设置输出编码:
// 注意版本差异:r152 之后用 outputColorSpace this.renderer.outputEncoding = this.THREE.sRGBEncoding // 新版写法: // this.renderer.outputColorSpace = this.THREE.SRGBColorSpace如果你用的是 threejs-miniprogram 自带的旧版 Three.js,优先用 outputEncoding 那行;自己升级过 three 版本的话,用 outputColorSpace。判断依据是包内 THREE 的版本号,r152 是分水岭。
6. 二次开发:把展示页升级成能卖货的 3D 商品卡
模型能看能转了,接下来就是跟业务结合。我在家具商城项目里做了两件事:多模型切换和点击部件弹商品卡。多模型切换的核心是资源释放逻辑,不释放旧模型内存只会越涨越高:
disposeModel() { if (!this.modelGroup) return this.modelGroup.traverse((child) => { if (child.isMesh) { child.geometry.dispose() if (Array.isArray(child.material)) { child.material.forEach((mat) => mat.dispose()) } else { child.material.dispose() } } }) this.scene.remove(this.modelGroup) this.modelGroup = null }traverse 递归遍历模型树的每个节点,把几何体和材质显式释放。切换模型时先 dispose 再加载新的,同时把相机拉回初始位置,这样用户每次看到的都是同一个观察角度,商品对比才公平。
点击部件弹商品卡用的是 Three.js 的 Raycaster 射线检测。把触摸点的屏幕坐标换算成归一化设备坐标,然后从相机发出射线:
onTapCanvas(e) { const touchX = e.detail.x const touchY = e.detail.y const rect = this.canvasRect const pointer = new this.THREE.Vector2() pointer.x = (touchX / rect.width) * 2 - 1 pointer.y = -(touchY / rect.height) * 2 + 1 const raycaster = new this.THREE.Raycaster() raycaster.setFromCamera(pointer, this.camera) const hits = raycaster.intersectObject(this.modelGroup, true) if (hits.length > 0) { const materialName = hits[0].object.name || 'default' this.showProductCard(materialName) } }射线命中的 object.name 对应建模时给部件起的名字,比如"木质框架""布艺坐垫",拿到名字就能去商品库里查对应 SKU,再弹出商品卡组件。这一套在真机上验证时,我建议按三个档位测:低端安卓看加载时长和内存峰值,中端机看旋转跟手度,iOS 看滚动流畅度。
完整工程代码我打包放在资源里,导入微信开发者工具、npm 构建后换个自己的模型地址就能跑。从那以后我每次接 3D 展示的需求,都强制自己先走一遍模型压缩、机型分级、资源释放这三步检查,再动手写业务代码,基本没再被灰屏和闪退折腾过。希望帮到你。
本文还有配套的精品资源,点击获取