简介:这份资源面向希望在微信小程序中实现3D模型展示的开发者,尤其适合刚接触three.js、需要快速跑通渲染流程的初中级前端与小程序学习者。它解决的是小程序端引入3D引擎、加载外部模型并驱动动画的入门问题,可作为可运行的最小示例参考。压缩包共18个文件,约258KB,以8个js脚本承载页面逻辑与three.js相关调用,5个json负责项目与页面配置,另有2个wxml、2个wxss构成界面与样式,并附带1个gltf格式的虾模型资源,整体结构精简、便于按模块阅读。已有2306人学习,说明该示例在小程序3D入门场景中具备一定参考价值。读者可据此了解模型上传服务器后通过url引用的组织方式,并在js中自行调整函数,尝试旋转、掉落等动画效果,同时借助现成示例规避常见配置与加载问题,降低上手成本。
1. 小程序里跑 Three.js:先把「能不能用」这件事说透
微信小程序原生渲染层只认 WebGL 上下文,不给你 DOM,也不给你window。所以第一次把 Three.js 往小程序里塞的人,十个里有九个会卡在document is not defined上,然后开始怀疑这条路是不是根本走不通。答案是能走,但走的不是官方那条路——你得用适配层把 Three.js 对 DOM 和浏览器 API 的依赖「翻译」成小程序能听懂的话,再让 canvas 的 WebGL 上下文接管渲染。这套方案解决的核心问题就一个:在不引入 web-view、不牺牲原生交互的前提下,把 glTF/GLB 模型稳定地展示在小程序页面里,支持旋转、缩放、材质和基础光照。适合谁?手上已经有 Three.js 场景代码、想低成本迁移到小程序的开发者,以及需要在小程序里做产品 3D 预览、展馆导览、教育演示这类轻交互场景的团队。如果你要做的是重度游戏或者复杂后处理,这篇的边界你得先看清楚。
2. 适配层选型与工程骨架:为什么不能直接 npm install three
2.1 小程序渲染层和浏览器渲染层的三处硬差异
先把差异摆清楚,后面所有选型都是围绕这三处展开的。第一处是没有 DOM:Three.js 内部会碰document.createElementNS去造 canvas、碰document.createElement('img')去加载纹理,小程序里这些全是 undefined。第二处是canvas 是组件不是标签:小程序的<canvas type="webgl">需要通过wx.createSelectorQuery()拿到节点,再调node.getContext('webgl'),上下文对象跟浏览器里的canvas.getContext('webgl')长得像但不是一回事。第三处是资源加载走 wx API:纹理图、模型文件不能靠new Image()或fetch,得用wx.getFileSystemManager().readFile读本地包内文件,或者wx.request拿网络资源再转 ArrayBuffer。
这三处差异决定了:Three.js 本体不能直接跑,必须有一个适配层在中间做桥接。常见做法是引入一个社区维护的适配包(本质是一份打了补丁的 Three.js 构建产物 + 一层平台 API 垫片),它把document、window、Image、XMLHttpRequest这些符号在小程序全局环境里做最小实现。我一般会先确认适配包对应的 Three.js 版本,因为不同版本内部 API 变动不小,版本对不上会出现「模型能加载但材质全黑」这种玄学问题。
2.2 工程目录与依赖落位
小程序对 npm 的支持是「构建 npm」那一套,node_modules不会直接进包,得在开发者工具里点一次构建。适配包和 Three.js 的放置方式有两种:一种是把适配包当普通 npm 依赖装,构建后从miniprogram_npm里引;另一种是把适配后的 Three.js 单文件直接丢进libs/目录手动引。前者好升级,后者好排查,我倾向后者——出问题时你能直接翻源码看垫片改了什么。
# 项目根目录初始化,装适配包(包名以实际适配层为准,这里用占位) npm init -y npm install three@0.152.0 npm install three-platform-adapter --save # 装完后在开发者工具「工具 - 构建 npm」生成 miniprogram_npm这段命令的关键不在装什么,而在版本锁定。Three.js 从 r150 之后对颜色管理、纹理编码做了较大调整,适配层如果没跟上,outputEncoding这类属性会静默失效,表现为贴图偏色。参数上,three@0.152.0只是示例锚点,你实际要跟适配包 README 里声明的兼容版本对齐,别自己拍脑袋升到最新。构建 npm 之后,miniprogram_npm目录里应该能看到适配包和 three 两个文件夹,缺一个就说明构建没成功,先解决这个再往下走。
2.3 页面骨架:canvas 节点、上下文与渲染器初始化
页面结构上,canvas 必须给明确的宽高,且type="webgl"、id要和查询选择器对上。下面是一个最小可跑的初始化骨架。
// pages/viewer/viewer.js import * as THREE from 'three' import { createRenderer } from 'three-platform-adapter' Page({ data: { loaded: false }, onReady() { // 1. 拿到 canvas 节点,注意回调里才有 node wx.createSelectorQuery() .select('#glcanvas') .node() .exec((res) => { const canvas = res[0].node // 2. 用适配层创建渲染器,内部会接管 getContext const renderer = createRenderer(canvas, { antialias: true, alpha: false, pixelRatio: wx.getSystemInfoSync().pixelRatio }) // 3. 尺寸必须手动设,小程序不会帮你同步 CSS 尺寸 const info = wx.getSystemInfoSync() renderer.setSize(info.windowWidth, info.windowHeight) this.initScene(renderer) }) }, initScene(renderer) { const scene = new THREE.Scene() scene.background = new THREE.Color(0x1a1a1a) // 透视相机:fov 45 适合产品展示,太广会畸变 const camera = new THREE.PerspectiveCamera( 45, wx.getSystemInfoSync().windowWidth / wx.getSystemInfoSync().windowHeight, 0.1, 1000 ) camera.position.set(0, 1.5, 4) camera.lookAt(0, 0, 0) // 环境光 + 平行光,缺一个模型就会一半黑 scene.add(new THREE.AmbientLight(0xffffff, 0.6)) const dirLight = new THREE.DirectionalLight(0xffffff, 1.2) dirLight.position.set(3, 5, 2) scene.add(dirLight) this.renderer = renderer this.scene = scene this.camera = camera this.animate() }, animate() { const canvas = this.renderer.domElement canvas.requestAnimationFrame(() => { this.renderer.render(this.scene, this.camera) this.animate() }) } })逻辑说明分三层看。第一层是节点获取:createSelectorQuery().select().node()是异步的,exec回调里拿到的res[0].node才是真正的 canvas 节点,在回调外拿永远是 undefined,这是新手第一个翻车点。第二层是渲染器创建:适配层的createRenderer内部会调canvas.getContext('webgl')并把上下文包成 Three.js 认识的形态,antialias开抗锯齿在小程序上性能开销不小,中低端机建议关掉。第三层是尺寸与像素比:setSize用的是逻辑像素,pixelRatio单独传,两者不匹配会导致画面模糊或渲染区域只占左上角。requestAnimationFrame这里挂在 canvas 节点上而不是全局,因为小程序全局没有这个函数,这也是适配层要垫的东西之一。
提示:
onReady里做初始化而不是onLoad,因为onLoad阶段节点还没渲染出来,查询必空。
3. 模型加载与交互:GLB 进包、纹理读取、手势控制
3.1 GLB 模型加载:文件系统读取与加载器接管
小程序包内文件用wx.getFileSystemManager().readFile读成 ArrayBuffer,再交给 GLTFLoader 的parse方法。注意是parse不是load,load走的是 XHR,小程序里没有。
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader' loadModel(path) { const fs = wx.getFileSystemManager() fs.readFile({ filePath: path, // 例如 '/models/product.glb' success: (res) => { const loader = new GLTFLoader() // parse 第二个参数是资源路径前缀,纹理内嵌时可传空 loader.parse(res.data, '', (gltf) => { const model = gltf.scene // 自动居中:模型原点不在几何中心时必做 const box = new THREE.Box3().setFromObject(model) const center = box.getCenter(new THREE.Vector3()) model.position.sub(center) // 按包围盒最大边缩放到 2 个单位,避免大小不一 const size = box.getSize(new THREE.Vector3()) const scale = 2 / Math.max(size.x, size.y, size.z) model.scale.setScalar(scale) this.scene.add(model) this.model = model this.setData({ loaded: true }) }, (err) => { console.error('GLB 解析失败', err) }) }, fail: (err) => console.error('文件读取失败', err) }) }参数上重点说三个。filePath必须是包内绝对路径,以/开头,相对路径在小程序文件系统里不认。parse的第二个参数path是外部资源(比如分离的 .bin 和贴图)的基准路径,GLB 是单文件内嵌所以传空字符串即可,如果你用的是 glTF + 分离资源,这里要传目录且资源得一起打进包。第三个是居中与缩放:不同建模软件导出的模型原点五花八门,不居中就会出现「模型转到背面时飞出视野」,包围盒归一化是必做步骤,2 / max这个系数按你相机距离调,相机在 z=4 时模型最大边 2 个单位刚好占屏一半左右。
3.2 纹理与贴图的读取路径
GLB 内嵌纹理走的是加载器内部逻辑,适配层一般已经处理。但如果你要单独加载一张贴图做背景或 UI 叠加,就得自己读文件再转纹理。
loadTexture(path) { return new Promise((resolve, reject) => { wx.getFileSystemManager().readFile({ filePath: path, success: (res) => { // 小程序读出来是 ArrayBuffer,转 base64 再交给 Image 垫片 const base64 = wx.arrayBufferToBase64(res.data) const img = new Image() // 适配层提供的 Image 垫片 img.onload = () => { const tex = new THREE.Texture(img) tex.needsUpdate = true // 手动标记,否则纹理不刷新 resolve(tex) } img.onerror = reject img.src = 'data:image/png;base64,' + base64 }, fail: reject }) }) }这里的关键是needsUpdate = true。小程序环境里纹理上传时机和浏览器不同,不手动标记会出现「第一次渲染是黑的,转一下才出来」的现象,血泪经验。另外 base64 转换对大图有内存压力,贴图控制在 1024×1024 以内比较稳,超过就考虑压缩或分块。
3.3 手势交互:touch 事件映射到轨道控制
小程序没有 OrbitControls 依赖的鼠标事件,得自己把touchstart/touchmove/touchend映射成旋转和缩放。核心是用触摸位移量驱动模型或相机的球坐标。
onTouchStart(e) { const t = e.touches[0] this.lastX = t.clientX this.lastY = t.clientY if (e.touches.length === 2) { // 双指记录初始距离用于缩放 this.lastDist = this.getDistance(e.touches) } }, onTouchMove(e) { const t = e.touches[0] if (e.touches.length === 1 && this.model) { const dx = t.clientX - this.lastX const dy = t.clientY - this.lastY // 0.01 是灵敏度系数,屏幕越大越要调小 this.model.rotation.y += dx * 0.01 this.model.rotation.x += dy * 0.01 // 限制俯仰角,防止翻过头看到模型底部穿帮 this.model.rotation.x = Math.max(-Math.PI / 3, Math.min(Math.PI / 3, this.model.rotation.x)) } else if (e.touches.length === 2) { const dist = this.getDistance(e.touches) const ratio = dist / this.lastDist this.camera.position.multiplyScalar(1 / ratio) this.lastDist = dist } this.lastX = t.clientX this.lastY = t.clientY }, 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的灵敏度是按 375pt 宽屏幕调的,平板或大屏要降到 0.005 左右,否则轻轻一划模型转半圈。俯仰角限制在 ±60 度是产品展示的常见约束,放开会看到模型底面和穿模。缩放用相机位置乘除而不是改 fov,因为改 fov 会带来透视畸变,视觉上不自然。双指缩放记得在touchend里重置lastDist,不然下一次双指会跳变。
4. 避坑与排查:五个真实翻车现场
4.1 现象:控制台报document is not defined,页面白屏
原因几乎都是 Three.js 本体在适配层之前被加载了,或者你引的是three/build/three.module.js而不是适配包导出的那份。适配层的垫片必须在 Three.js 执行前注入全局。解决:确认 import 顺序,从适配包统一导出 THREE,不要在业务代码里再单独import 'three';检查miniprogram_npm里是不是同时存在两份 three,重复打包会绕过垫片。
4.2 现象:模型加载成功但全黑,转个角度偶尔闪一下
这是光照和材质编码双重问题。一方面环境光强度不够,PBR 材质在没有环境贴图时对光照极其敏感;另一方面 r152 之后默认色彩空间变了,贴图没设colorSpace会偏暗。解决:先加AmbientLight保底,再补一盏平行光;纹理加载后设tex.colorSpace = THREE.SRGBColorSpace;渲染器初始化时确认outputColorSpace与适配层版本匹配。排查顺序是先关材质看纯色几何体亮不亮,亮则问题在材质,不亮则问题在光照或相机。
4.3 现象:真机上帧率掉到十几帧,低端机直接卡死
原因通常是三件事叠加:antialias: true、pixelRatio取了设备原始值(有些机型到 3)、模型面数过高。解决:中低端机关抗锯齿,pixelRatio上限压到 2,模型面数控制在 5 万三角面以内,必要时用 Draco 压缩。另外requestAnimationFrame循环在页面隐藏时不会自动停,onHide里要手动置标志位停掉渲染,否则后台还在烧 GPU。
4.4 现象:纹理第一次渲染是黑的,交互一下才显示
小程序里纹理上传时机和渲染循环不同步,Texture构造后没有触发上传。解决:加载完纹理显式设needsUpdate = true,并且确保纹理加载完成后再把模型加进场景,或者用LoadingManager统一等资源就绪再启动渲染循环。这个坑在浏览器里很少遇到,小程序上几乎必现。
4.5 现象:开发者工具正常,真机报getContext返回 null
工具和真机的 WebGL 实现有差异,常见于 canvas 尺寸为 0 或节点还没布局完成就取上下文。解决:确保 canvas 有明确宽高样式,初始化放在onReady且用nextTick包一层;部分机型对type="webgl"和type="webgl2"支持不同,适配层若不支持 webgl2 就显式降级。真机调试时打开 vConsole 看具体报错,别只信工具里的表现。
5. 性能压测与上线前验证:一套我每次都走的检查清单
上线前我固定做三件事,缺一件都不敢发版。第一件是分档压测:找一台低端安卓、一台中端、一台 iPhone 分别跑,记录首帧时间和稳定帧率。首帧超过 2 秒就要考虑模型懒加载或降面数,稳定帧率低于 30 就得砍效果。第二件是内存曲线观察:连续进出页面十次,看内存是否持续上涨,涨了就是渲染循环或纹理没释放,onUnload里要renderer.dispose()、遍历场景geometry.dispose()和material.dispose(),纹理单独texture.dispose()。第三件是弱网与包体检查:模型走网络加载时测 3G 模拟,包内模型则看主包是否超限,超了就把模型挪到分包。
下面这张表是我常用的参数基线,按设备档位调,别一套参数打天下。
| 参数 | 低端机 | 中端机 | 高端机 |
|---|---|---|---|
| antialias | false | false | true |
| pixelRatio 上限 | 1.5 | 2 | 2 |
| 模型三角面上限 | 3 万 | 5 万 | 10 万 |
| 阴影 | 关闭 | 关闭 | 可选 |
| 纹理最大边长 | 512 | 1024 | 1024 |
验证方法上,我会在渲染循环里挂一个简易帧率采样,每 60 帧算一次平均,低于阈值就在控制台打点,真机跑十分钟看有没有持续劣化。还有一个容易忽略的点:小程序切后台再回来,WebGL 上下文可能丢失,onShow里要检测上下文状态并重建渲染器,否则回来就是黑屏。这套检查我踩过一次线上黑屏之后就成了肌肉记忆,每次发版前必走一遍。希望帮到你。
本文还有配套的精品资源,点击获取