简介:面向微信小程序开发者,提供基于Three.js在微信小程序中展示3D模型的快速上手Demo,适合需要在小程序中集成3D模型、实现交互动画的前端工程师。资源包含完整的虾模型gltf文件,上传至服务器后通过url引用即可加载,同时可在js函数中自定义旋转、掉落等动画效果,已实测可用,能有效避免常见配置与渲染坑点。压缩包共18个文件,以8个js文件为核心逻辑(含Three.js及模型交互脚本),5个json文件负责项目配置,2个wxml和2个wxss构成页面结构与样式,另有1个gltf三维模型资源,整包约258KB,轻量易用。目前已有2305人学习,适合小游戏、产品展示、AR辅助等3D场景实践者参考借鉴。
1. 微信小程序里跑 three.js 3D 展示:不是能不能,是值不值
微信小程序 3D 展示这个需求,这几年出现频率非常高,商品展示、设备预览、数字孪生大屏都有。很多人第一反应是直接把网页里那套 three.js 代码搬进小程序,结果发现页面白屏、报错日志指向 WebGLRenderer 拿不到上下文,才知道小程序环境比想象中特殊。小程序没有 window、document、navigator,new THREE.WebGLRenderer 之前必须先解决适配层,这一步没走对,后面全白搭。这篇文章按“先过适配层,再装模型,再排真机坑”的顺序,把一套能跑起来的完整工程拆开讲,适合刚接手小程序 3D 需求的前端,也适合想评估 three.js 在小程序里值不值得用的技术负责人。
2. 适配层与版本选型:三个硬约束和一套最小可运行工程
2.1 小程序没有 DOM:BOM 缺失的三个环节
three.js 的 WebGLRenderer、TextureLoader、GLTFLoader 都默认跑在浏览器环境,依赖 window、document、navigator 这三个全局对象。小程序里这三个全都没有。更麻烦的是 canvas 获取方式不一样,浏览器里是document.createElement('canvas'),小程序里必须用 WXML 声明<canvas type="webgl">,再通过wx.createSelectorQuery()拿到节点。
三个硬约束分别是:
第一,Renderer 初始化需要 canvas 节点,不能自己创建。小程序 canvas 是离屏节点,必须等节点渲染完成才能取到。第二,requestAnimationFrame不存在,需要桥接到小程序 canvas 节点的方法,或者用 setTimeout 做降级。第三,图片加载不走浏览器 Image 对象,纹理加载要走小程序的图片接口配合适配层包装。
所以直接npm install three然后new THREE.WebGLRenderer()是必挂的,不是代码写得不对,是环境接口缺了。
2.2 版本选型:r90 时代的注入法与新版适配
老教程里流行过一套“全局注入法”,把 navigator、document、window 全部伪造出来,再 hack three.js 内部的调用。这套思路在某几个老版本(r90 左右)能跑,缺点是 three.js 一升级内部实现一变,适配代码就得跟着改,维护成本极高。
现在更常见的是套适配层。适配层核心做三件事:把小程序 canvas 节点包装成符合 WebGL 标准的 canvas 对象;把 requestAnimationFrame 桥接到canvas.requestAnimationFrame(基础库 2.9.0 之后可用);把 Image、addEventListener 缺失对象补上。这样做对 three.js 源码侵入很小,升级版本的阻力低很多。
如果你是从零开始的项目,建议直接选新版 three.js,不要再看老教程的注入法。新版 GLTFLoader 内置了 Draco 压缩支持,模型体积处理方便;r150 之后 three.js 支持按模块 tree-shaking,打包进小程序的体积能压低;渲染器封装更贴近标准 WebGL,真机兼容性实测反而更好。
2.3 最小可运行工程:依赖、canvas 组件与渲染器初始化
先建一个最简工程,目录结构不需要复杂,一个页面就够。
{ "dependencies": { "three": "0.160.0" } }在开发者工具里点击“工具 - 构建 npm”,构建完成后,three 会被放进 miniprogram_npm 目录。适配层源码建议直接放在 utils/three-adapter 下,随项目一起打包上传,避免 npm 版本不匹配的问题。
WXML 里只需要声明一个 webgl canvas:
<canvas type="webgl" id="glCanvas" style="width: 750rpx; height: 100vh;" bindtouchstart="onTouchStart" bindtouchmove="onTouchMove" bindtouchend="onTouchEnd"></canvas>canvas 的 type 必须是webgl,普通 2d canvas 拿不到 WebGL 上下文。节点宽高先用样式撑满,真正的渲染缓冲区尺寸要在 JS 里设置。
初始化渲染器的核心代码:
const adapter = require('../../utils/three-adapter/index.js') const THREE = require('three') Page({ onReady() { const query = wx.createSelectorQuery() query.select('#glCanvas').fields({ node: true, size: true }).exec((res) => { const canvas = res[0].node const info = wx.getWindowInfo() const dpr = info.pixelRatio canvas.width = info.windowWidth * dpr canvas.height = info.windowHeight * dpr this.renderer = new THREE.WebGLRenderer({ canvas }) this.renderer.setPixelRatio(dpr) this.renderer.setSize(info.windowWidth, info.windowHeight) this.scene = new THREE.Scene() this.camera = new THREE.PerspectiveCamera(45, info.windowWidth / info.windowHeight, 0.1, 1000) this.canvas = canvas }) } })canvas.width和canvas.height设置的是渲染缓冲区大小,乘以 dpr 是为了在真机上不糊。renderer.setSize传的是逻辑像素尺寸,内部会做换算。这段逻辑几乎是所有小程序 three.js 工程共用的起点。
提示:
wx.getWindowInfo在基础库较新的版本才支持,低版本可以用wx.getSystemInfoSync()替代,字段名一样。
运行到这,一个空场景就立住了,下一步是往场景里放模型。
3. GLB 模型上屏全链路:加载、渲染、交互的代码拆解
3.1 格式选型:为什么包内建议 GLB,网络建议 glTF 散包
glTF 格式有两种分发形态:.gltf是 JSON +.bin+ 贴图的散包结构,适合 CDN 按需加载单个文件;.glb是二进制单文件,模型、贴图、动画全部打在一个文件里。小程序场景下,我的建议是:模型放在项目包内就用 GLB,单文件好维护,读取也方便;模型放在网络端就用 glTF 散包或者 GLB 都行,但注意网络下载涉及存储路径和缓存管理。
关于体积红线:小程序主包有 2MB 左右的限制,总包大小也有上限。一个带贴图的 GLB 动辄十几 MB,塞主包基本没戏。常规做法是模型走分包,或者下载到本地用户目录再加载。第 5 章会专门讲怎么瘦身。
3.2 场景组装:相机、灯光与模型坐标系
模型放进来之前,先把相机和灯光搭好。新手的典型问题是不加灯光,模型加载进来是一团黑,还以为是文件坏了。
this.camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000) this.camera.position.set(0, 1, 3) const ambientLight = new THREE.AmbientLight(0xffffff, 0.6) const dirLight = new THREE.DirectionalLight(0xffffff, 0.8) dirLight.position.set(1, 2, 1) this.scene.add(ambientLight) this.scene.add(dirLight)PerspectiveCamera四个参数:fov 视角 45 度、宽高比、近裁剪面 0.1、远裁剪面 1000。环境光强度 0.6,方向光强度 0.8,方向光的位置影响高光面,一般放在相机同侧偏上位置。灯光参数不是固定的,如果模型本身带 emissive 发光材质,灯光可以再调弱。
3.3 读取与解析:FileSystemManager 读文件再交给 loader.parse
小程序里不能像浏览器那样直接传 URL 给 GLTFLoader,文件系统不是公开 URL 路径。常见做法是用wx.getFileSystemManager()读文件,拿到 ArrayBuffer 再交给 loader 解析。
const { GLTFLoader } = require('three/examples/jsm/loaders/GLTFLoader.js') loadModel() { const fs = wx.getFileSystemManager() fs.readFile({ filePath: `${wx.env.USER_DATA_PATH}/model.glb`, success: ({ data }) => { const loader = new GLTFLoader() loader.parse(data, '', (gltf) => { const model = gltf.scene this.fitCamera(model) this.scene.add(model) }, (err) => { console.error('模型解析失败:', err) }) }, fail: (err) => { console.error('模型文件读取失败:', err) } }) }wx.env.USER_DATA_PATH是小程序为用户分配的本地目录,模型文件先下载到这个目录再读取。loader.parse的第一个参数是 ArrayBuffer,第二个参数是资源路径前缀,传空字符串即可,因为 GLB 内部资源都是相对自身路径。如果模型解析失败,优先检查是不是 GLB 文件损坏,或者 three.js 版本与模型导出工具版本差太多。
3.4 渲染循环与生命周期:requestAnimationFrame 桥接和暂停恢复
three.js 的场景需要持续重绘,不做渲染循环的话,模型只会出现在第一帧,后面一动不动。渲染循环在小程序里必须跟着页面生命周期走,否则页面切后台时还在跑,真机发热掉电很快。
startRenderLoop() { const render = () => { this.renderer.render(this.scene, this.camera) this.frameId = this.canvas.requestAnimationFrame(render) } render() } onShow() { if (this.renderer && !this.frameId) { this.startRenderLoop() } } onHide() { if (this.frameId) { this.canvas.cancelAnimationFrame(this.frameId) this.frameId = null } } onUnload() { this.scene.traverse((obj) => { if (obj.geometry) obj.geometry.dispose() if (obj.material) { const materials = Array.isArray(obj.material) ? obj.material : [obj.material] materials.forEach((m) => m.dispose()) } }) this.renderer.dispose() }canvas.requestAnimationFrame是基础库 2.9.0 之后 canvas 节点上的原生方法,适配层会把它暴露给 three.js。cancelAnimationFrame配合frameId做停止,避免重复启动渲染循环产生多个循环实例——这个问题在真机上会造成渲染错乱和内存上涨。onUnload里的 dispose 是防止页面销毁后 GPU 资源不释放,多页面来回跳转时尤其重要。
3.5 触摸交互:单指旋转与双指缩放的实现边界
小程序 canvas 上绑定了触摸事件后,事件对象里的 touches 数组区分单指和双指。单指旋转模型,双指缩放模型,这是最基础的展示交互。
onTouchStart(e) { const touches = e.touches if (touches.length === 1) { this.lastX = touches[0].clientX this.lastY = touches[0].clientY } else if (touches.length === 2) { this.lastDistance = this.getDistance(touches) this.baseScale = this.model.scale.x } } onTouchMove(e) { const touches = e.touches if (!this.model) return if (touches.length === 1 && this.lastX !== undefined) { const dx = touches[0].clientX - this.lastX const dy = touches[0].clientY - this.lastY this.model.rotation.y += dx * 0.01 this.model.rotation.x += dy * 0.01 this.lastX = touches[0].clientX this.lastY = touches[0].clientY } else if (touches.length === 2) { const dist = this.getDistance(touches) const scale = this.baseScale * (dist / this.lastDistance) this.model.scale.setScalar(Math.min(Math.max(scale, 0.3), 5)) } } onTouchEnd() { this.lastX = undefined this.lastY = undefined } getDistance(touches) { const dx = touches[0].clientX - touches[1].clientX const dy = touches[0].clientY - touches[1].clientY return Math.sqrt(dx * dx + dy * dy) }旋转系数 0.01 表示每移动 1px 旋转 0.01 弧度,也就是全屏滑一遍大约转半圈。缩放范围夹在 0.3 到 5 倍之间,防止模型被缩到看不见或撑爆视野。onTouchEnd里重置 lastX 是必须的,否则下一次 touchstart 之前如果 touches 数量变化,坐标会跳。
4. 避坑手册:真机黑屏、模型消失、页面卡死的排查顺序
4.1 现象:开发者工具正常,真机一打开就黑屏
开发者工具里画面正常,预览到真机直接黑屏,这是微信小程序 three.js 最常见的坑。
原因:渲染缓冲区尺寸没设置成物理像素。开发者工具模拟器的逻辑像素和物理像素比例接近 1,真机 iPhone 和 Android 的 pixelRatio 都在 2 到 3 之间。canvas 样式撑满了整个屏幕,但缓冲区宽高还是默认的 300x150,渲染结果被拉伸异常,部分机型直接黑屏。
解决:在拿到底层 canvas 节点后,立刻设置宽高和像素比。
const info = wx.getWindowInfo() const dpr = info.pixelRatio canvas.width = info.windowWidth * dpr canvas.height = info.windowHeight * dpr renderer.setPixelRatio(dpr) renderer.setSize(info.windowWidth, info.windowHeight)先乘 dpr 设置缓冲区,再用setSize设置逻辑尺寸,顺序不能反。有些适配层内部会自己处理 dpr,这时就不要重复设置,否则画面会放大两倍显示不全。
4.2 现象:模型加载成功,日志能打印,屏幕上就是看不到
模型文件的读取和解析回调都正常,gltf.scene 也加到了 scene 里,画面上却什么都看不到。
原因大概率是相机和模型的距离不匹配。工程师在建模软件里按真实比例建模,模型可能只有几个毫米大,也可能有几十米大。three.js 默认相机在 z=3 左右看一眼,模型要么在相机近裁剪面 0.1 以内,要么远裁剪面之外。还有一种常见情况:模型是纯黑材质,场景没有灯光,看起来像没渲染。
解决:用包围盒自动适配相机距离,这是最稳定的做法。
fitCamera(model) { const box = new THREE.Box3().setFromObject(model) const size = new THREE.Vector3() const center = new THREE.Vector3() box.getSize(size) box.getCenter(center) const maxDim = Math.max(size.x, size.y, size.z) const fov = this.camera.fov * Math.PI / 180 let dist = maxDim / (2 * Math.tan(fov / 2)) dist *= 1.5 this.camera.position.set(center.x, center.y, center.z + dist) this.camera.near = dist / 100 this.camera.far = dist * 100 this.camera.updateProjectionMatrix() this.camera.lookAt(center) }dist 乘 1.5 是给模型四周留出裕量,避免模型正好卡在视野边缘。near 和 far 跟着模型尺度动态调整,防止近裁剪面把模型切掉一部分。
4.3 现象:从 A 页面跳到 B 页面再返回,画面卡死或闪退
页面切换后回来,模型停在原位置不动,触摸也没反应,甚至直接闪退。
原因:渲染循环没有跟随页面生命周期暂停。onHide 触发时动画帧还在跑,页面切到后台后 canvas 状态不确定,返回时又走了一遍 onShow 启动渲染循环,可能出现两个循环同时跑,或者 canvas 上下文状态异常。
解决:严格按 3.4 节的逻辑,onHide 里 cancel,onShow 里重新启动,onUnload 里 dispose。注意onShow里加!this.frameId的判断,防止循环被重复注册。这个判断在真机上有时候是救命稻草。
4.4 现象:Android 部分机型贴图花屏、模糊或拉伸变形
同一份代码,iOS 正常,Android 某几款机型上贴图出现条纹、马赛克式的花屏,或者纹理明显拉伸。
原因:纹理尺寸不是 2 的幂,且用了 mipmap。WebGL 对非 2 的幂纹理做 mipmap 采样时,不同 GPU 的兼容性有差异,部分 Android 驱动实现直接渲染错误。纹理拉伸则是因为模型的 UV 和贴图分辨率比例问题,这个在导出时就定了。
解决:贴图在导出前统一压到 2 的幂尺寸,比如 512x512、1024x1024。如果纹理太多不好统一,在加载纹理时关闭 mipmap 并改成线性过滤。
texture.wrapS = THREE.ClampToEdgeWrapping texture.wrapT = THREE.ClampToEdgeWrapping texture.generateMipmaps = false texture.minFilter = THREE.LinearFilterminFilter改成 LinearFilter 后渲染质量会略降,但换来的是真机兼容性。纹理数量多的话,还可以在导出工具里把贴图压缩成 ETC1S 或类似格式,纹理内存能降一大截。
4.5 现象:上传代码提示主包或总包体积超限
GLB 模型十几 MB,three.js 本体几百 KB,加上适配层和业务代码,主包直接超了 2MB 的线,开发者工具上传时拒绝编译。
原因:模型、贴图、依赖全塞在主包。GLB 是二进制格式,压缩率有限,带贴图的模型动辄几 MB 到几十 MB。
解决:三步走。第一步,three 用按需引入,只 import 用到的模块,别把整个 three 打进去;第二步,模型文件放到分包,或者从网络下载到wx.env.USER_DATA_PATH后异步加载;第三步,贴图全走 CDN,模型文件不内嵌贴图,运行时再加载贴图绑定材质。主包里只放业务代码和一张 loading 占位图。分包配置在 app.json 里声明。
{ "pages": ["pages/index/index"], "subpackages": [ { "root": "modelPkg", "pages": ["pages/model/model"] } ] }分包之后,模型页面的路径变成modelPkg/pages/model/model,跳转时用这个完整路径。分包不是上线后才做的事,建议项目第一天就设计好,后面再挪成本更高。
5. 进阶技巧:把模型包体压进 2MB 主包的三板斧
5.1 第一板斧:Draco 压缩网格数据
模型格式导出时开启 Draco 压缩,顶点位置、法线、UV 会被量化编码,体积通常能砍掉 60% 到 80%。three.js 新版 GLTFLoader 已经把 DracoLoader 集成进来,需要在工程里引入解码器文件。解码器 wasm 体积不大,放分包或本地目录都行。
加载时指定解码器路径,如果用默认 CDN 路径在真机上会加载失败,务必下载到本地。
const dracoLoader = new THREE.DRACOLoader() dracoLoader.setDecoderPath('/utils/draco/') gltfLoader.setDRACOLoader(dracoLoader)5.2 第二板斧:静态部件合批,减少 draw call
模型部件一多,draw call 上百次,就算包体能过,帧率也扛不住。用 BufferGeometryUtils 把多个静态 geometry 合并成一个,材质相同的部件可以共享一次绘制调用。
const { mergeBufferGeometries } = require('three/examples/jsm/utils/BufferGeometryUtils.js') const geometries = [] model.traverse((child) => { if (child.isMesh && child.material === this.sharedMaterial) { geometries.push(child.geometry) } }) const merged = mergeBufferGeometries(geometries) const mergedMesh = new THREE.Mesh(merged, this.sharedMaterial)合并后保留模型层级里的动画节点不受影响,但如果有骨骼动画或部件级动效,就别合,只合纯静态的装饰部件。
5.3 第三板斧:帧率验证,用打点说话
包体压完了,得确认真机帧率真的达标。用渲染循环里的差值打点,不用开发者工具的性能面板——工具模拟器的帧率不代表真机。
let frameCount = 0 let lastTime = Date.now() const render = () => { frameCount++ const now = Date.now() if (now - lastTime >= 1000) { console.log('fps:', frameCount) frameCount = 0 lastTime = now } this.renderer.render(this.scene, this.camera) this.frameId = this.canvas.requestAnimationFrame(render) }连续跑 30 秒,稳定在 50 帧以上算流畅,30 到 50 帧属于能接受,低于 30 帧就需要回到模型减面或合批上再优化。
从那以后,我每次接小程序 3D 需求,都会强制自己把适配层版本、canvas 缓冲区尺寸、渲染循环生命周期这三件事在真机上完整走一遍。开发者工具显示正常不算数,真机跑一遍才算数。希望帮到你。
本文还有配套的精品资源,点击获取