简介:面向需要在微信小程序中展示 3D 模型的前端开发者,这是一套基于 three.js 的完整源码 Demo,重点解决小程序环境下的模型加载、渲染与动画交互难题,适合刚接触 3D 或想快速验证方案的技术人员。包内共 18 个文件,包括 8 个 js 负责场景构建与动画逻辑、5 个 json 用于项目配置、2 个 wxml 搭建页面结构、2 个 wxss 控制样式,另有 1 个 gltf 格式的虾模型文件,压缩包仅 258KB,部署轻便。目前已有 2305 人学习/下载。使用时可把虾模型上传至服务器,并在 wxml 中通过 url 引入,再调整 js 中的模型位置、旋转速度、掉落触发条件等函数参数,即可实现旋转、掉落等各类展示效果;作者实测可用,并整理了小程序与 three.js 适配的常见避坑要点,如组件引用、路径设置等。源码中 pages、components、utils 模块划分清晰,便于直接复用或二次定制,适合快速搭建小程序内的商品预览或模型展示场景,也能作为学习 three.js 在小程序内实践的上手范例。
1. 先说实话:小程序里跑 3D,最不该先想 WebView
在微信小程序里做一个 3D 模型展示页,最顺手的方案听说是 WebView 套一个 H5 页面。但实际把项目拆开跑了一遍之后,我的想法变了:当模型需要跟小程序原生手势、页面滚动和底部 tab 协同,WebView 的通信延迟和白屏重载会让人很想骂人。这个项目走的是另一条路——在小程序内部直接引入 three.js,用 canvas 的 WebGL 上下文做渲染,触摸事件由页面层直接处理。
它能展示 GLB/glTF 模型,支持单指旋转、双指缩放、自动旋转,也能从微信会话或手机存储里选一个模型文件,加载后实时替换当前展示物。适合电商看样、展会摆件预览、后台 3D 素材快速审核这些场景。看完这篇笔记,你会明白它的渲染链路怎么搭,以及改造成自己业务模块时要从哪里下手。
2. 技术选型与工程结构:为什么不用 WebView,而是把 three.js 请进小程序
2.1 小程序 WebGL 的真实限制
微信小程序的 canvas 组件从基础库 2.9.0 开始支持type="webgl",也就是说原生小程序页面里确实能创建一个 WebGL 上下文。但 three.js 诞生于浏览器环境,初始化时会访问window、document、HTMLCanvasElement、Image这些全局对象。在小程序里直接写const THREE = require('three'),第一步就会因为找不到document直接抛错,后面的渲染器、加载器全都没法用。
这个项目能跑通,靠的是在 three.js 和小程序 canvas 之间加了一层轻量适配层。适配层做的事情并不玄学:它把小程序 canvas 节点包装成 three.js 认识的HTMLCanvasElement,再补一个最小可用的window和document对象,让 three.js 的环境检测能通过。这样 three 的场景、相机、灯光、加载器都可以继续工作,唯一变化的只是渲染器拿到的 canvas 来源不再是浏览器 DOM。
选型上有个关键判断:为什么用 three.js 而不是在小程序里手写 WebGL。手写顶点着色器、矩阵变换对简单立方体还行,一旦要做光照、PBR 材质、glTF 解析,工作量完全失控。three.js 把这些封装成高层 API,加载一个 GLB 模型只需要 GLTFLoader 一行调用,换来的包体积代价约 600 多 KB,在小程序主包 2MB 限制下仍然可以接受。实际工程里建议把 three.js 放到分包,或者从 CDN 下载后本地缓存,主包只保留页面逻辑。
2.2 为什么排除 WebView 套 H5 的方案
很多人第一反应是 WebView 里加载一个 H5 3D 页面,微信小程序的web-view组件也确实有这个能力。但项目拆完以后,不建议把它作为默认方案。原因是通信成本太高:H5 页面里的 3D 手势事件和 canvas 都运行在 WebView 层,而小程序的滚动、tabBar、按钮交互都在原生层,两层之间通过postMessage和bindmessage通信,手势响应天然有延迟。
另一个坑是白屏恢复。iOS 上 WebView 内存占用高,3D 场景渲染一会儿就可能被系统回收,切后台再回来页面需要重新加载,用户看到的就是白屏或黑屏。而原生 canvas 配合 three.js,页面生命周期在小程序手里,onHide/onShow里做暂停和恢复渲染更可控。所以这个项目选择原生 canvas 路线,本质上是把渲染和交互都收拢到小程序自己的渲染层,少一层跨容器通信,真机上的可预期性高很多。
2.3 目录结构与资源清单
工程拆开以后,目录结构非常清晰:
miniprogram/ app.js # 小程序入口 app.json # 页面与窗口配置 pages/ index/ index.js # three.js 初始化和触摸交互都在这 index.wxml # canvas 节点 + 上传按钮 index.wxss # 全屏布局 utils/ adapter.js # 小程序环境的 window/document 适配层 model-loader.js # GLB 加载、释放工具函数 libs/ three.js # three 核心库 GLTFLoader.js # glTF/GLB 模型加载器 models/ sample.glb # 内置测试模型utils/adapter.js是整个工程的命脉,没有它 three.js 在小程序里寸步难行。libs/两个文件是纯第三方代码,不参与业务逻辑。models/sample.glb是打包在项目里的测试模型,方便下载后不做任何网络请求就能看到效果。页面逻辑集中在pages/index/index.js,没有做组件化拆分,因为整个页面只有一个 canvas 和一组按钮,拆组件反而增加通信负担。
注意GLTFLoader.js依赖 three.js 全局变量,加载顺序上必须保证three.js先被引入。在小程序里不能用 ES Module 的 import 语法,这里采用的是require顺序加载,然后在adapter.js里把THREE挂到全局对象上。这也是小程序传统开发模式里比较省事的方式,后续换成 TypeScript 或分包异步加载时,再调整成模块化入口。
2.4 数据流:从本地文件到 canvas 上屏
模型数据进入 three.js 场景,这个项目走的是纯小程序本地链路。用户点击按钮后,wx.chooseMessageFile打开微信会话文件列表,拿到文件路径;然后通过wx.getFileSystemManager().readFile把文件内容读成 ArrayBuffer;这个 ArrayBuffer 交给GLTFLoader.parse解析出场景对象;最后加入scene,渲染器逐帧绘制到 canvas。
这个链路里没有服务器参与,所以不涉及域名白名单和跨域配置,第一次跑通的成功率高很多。后续如果要改成网络加载模型,就需要在小程序后台配置downloadFile合法域名,并且模型文件的服务端要支持跨域;那时候GLTFLoader.load的速度和失败排查会复杂不少,这部分留在第 6 章讲改造时专门展开。
3. 核心模块拆解:从 canvas 绑定到模型上屏的完整链路
3.1 初始化适配层与渲染器
index.js里的启动逻辑是理解整个项目的入口。页面onReady之后,canvas 节点才真正可用,所以要通过wx.createSelectorQuery()拿节点,不能提前初始化。
// pages/index/index.js const THREE = require('../../libs/three') const adapter = require('../../utils/adapter') Page({ data: { loading: false }, onReady() { this.initThree() }, initThree() { wx.createSelectorQuery() .select('#model-canvas') .fields({ node: true, size: true }) .exec((res) => { if (!res || !res[0] || !res[0].node) { console.error('canvas 节点未找到') return } const canvas = res[0].node const width = res[0].width const height = res[0].height const dpr = wx.getSystemInfoSync().pixelRatio // 把小程序 canvas 包装成 three.js 认识的 canvas adapter(canvas) this.renderer = new THREE.WebGLRenderer({ canvas: canvas, antialias: true, alpha: true }) this.renderer.setPixelRatio(dpr) this.renderer.setSize(width, height) this.renderer.outputEncoding = THREE.sRGBEncoding this.scene = new THREE.Scene() this.camera = new THREE.PerspectiveCamera( 45, width / height, 0.1, 1000 ) this.camera.position.set(0, 1.5, 4) this.addLights() this.startRenderLoop() }) }, addLights() { const ambient = new THREE.AmbientLight(0xffffff, 0.6) const dir = new THREE.DirectionalLight(0xffffff, 1) dir.position.set(5, 10, 7) this.scene.add(ambient) this.scene.add(dir) }, startRenderLoop() { const tick = () => { if (this.renderer) { this.renderer.render(this.scene, this.camera) } requestAnimationFrame(tick) } tick() } })代码里adapter(canvas)必须在创建 WebGLRenderer 之前调用,因为 three.js 在构造渲染器时就会去读window.devicePixelRatio、canvas.getContext这些环境能力。dpr通过wx.getSystemInfoSync()获取,真机的像素比一般是 2 或 3,如果不设置setPixelRatio,模型边缘会发虚。相机参数里45是纵向视角度数,width/height是宽高比,0.1和1000分别是近裁剪面和远裁剪面,模型距离相机超过 1000 就会被裁掉。
outputEncoding = THREE.sRGBEncoding是值得保留的一行。three.js 在高版本里默认输出线性色彩空间,如果不做转换,GLB 模型在手机屏幕上会明显偏灰,尤其是带贴图材质的模型。光照部分用了常见的环境光加平行光组合,环境光保证暗面不是全黑,平行光负责塑造体积感。如果模型材质偏金属,可以再补一个RectAreaLight,不过真机上光源数量太多会影响帧率,这个项目两个灯已经够用。
3.2 加载内置 GLB 模型
场景和相机就绪后,调用loadModel加载models/sample.glb。这个函数同时承担首次加载和后续替换模型两个职责。
// pages/index/index.js loadModel(url) { const loader = new THREE.GLTFLoader() loader.load( url, (gltf) => { // 清掉上一次的模型,防止叠加 this.clearModel() const model = gltf.scene // 计算包围盒,让模型自动居中并适配视野 const box = new THREE.Box3().setFromObject(model) const size = box.getSize(new THREE.Vector3()) const center = box.getCenter(new THREE.Vector3()) const maxDim = Math.max(size.x, size.y, size.z) model.position.x -= center.x model.position.y -= center.y model.position.z -= center.z // 根据模型实际大小缩放到合适比例 const scale = 2.5 / maxDim model.scale.set(scale, scale, scale) this.scene.add(model) this.model = model this.setData({ loading: false }) }, (progress) => { // 本地文件加载时 progress 事件很快触发 if (progress.total > 0) { const percent = (progress.loaded / progress.total * 100).toFixed(0) console.log('加载进度', percent + '%') } }, (err) => { console.error('模型加载失败', err) this.setData({ loading: false }) } ) }这段代码里最值得抄的是利用 Box3 做自动适配。不同模型文件尺寸差异很大,有的建模时单位是米,有的单位是厘米,不处理的话模型上场要么大得穿墙,要么小得看不见。这里用Box3.setFromObject拿到整个模型的包围盒,把中心点挪到原点,再按最长边缩放到 2.5 个单位,正好适配当前相机位置。2.5是在当前相机参数下调出来的数值,如果相机位置改成z=8,这个系数也要跟着放大。
进度回调里判断了progress.total > 0,因为有些本地文件读取路径不提供 total,直接进 success 回调;只有网络加载或部分真机文件流才会触发多段进度。失败回调里除了打日志,还把loading置回 false,避免按钮一直转圈。
3.3 触摸交互:单指旋转、双指缩放
three.js 自带的 OrbitControls 在小程序里不能直接用,因为它的实现依赖 mouse 事件和 DOM 元素尺寸。这个项目直接在 canvas 节点上监听小程序手势,手动维护旋转和缩放。
// pages/index/index.js bindTouchStart(e) { const touches = e.touches this.touchState = { x: touches[0].clientX, y: touches[0].clientY, distance: touches.length === 2 ? this.getDistance(touches[0], touches[1]) : 0 } }, bindTouchMove(e) { if (!this.model) return const touches = e.touches if (touches.length === 1) { // 单指:旋转模型 const dx = touches[0].clientX - this.touchState.x const dy = touches[0].clientY - this.touchState.y this.model.rotation.y += dx * 0.01 this.model.rotation.x += dy * 0.01 this.touchState.x = touches[0].clientX this.touchState.y = touches[0].clientY } else if (touches.length === 2) { // 双指:缩放模型 const distance = this.getDistance(touches[0], touches[1]) if (this.touchState.distance > 0) { const ratio = distance / this.touchState.distance this.model.scale.multiplyScalar(ratio) } this.touchState.distance = distance } }, getDistance(t1, t2) { const dx = t1.clientX - t2.clientX const dy = t1.clientY - t2.clientY return Math.sqrt(dx * dx + dy * dy) }旋转系数0.01是调出来的敏感度,单位是弧度每像素。真机上手指滑动的位移通常比鼠标大,这个值如果设成0.02以上会觉得模型过于“飘”。双指缩放这里直接对model.scale做乘法,优点是实现简单,缺点是连续缩放后缩放值可能溢出,最好加一个上下限,比如clamp(scale, 0.5, 5)。二次改造那一段会补这个限制。
这里还要注意,触摸事件绑定在 canvas 节点上,但小程序里 canvas 是原生组件,有“同层渲染”的概念。基础库版本低的时候,原生组件会盖在普通组件上面,按钮可能点不到。这个项目要求基础库不低于 2.12.0,否则需要在 canvas 外覆盖一层 view 来接收手势。避坑章节会再展开这个兼容问题。
3.4 从微信会话选择模型文件并替换
这是项目里最能体现落地价值的功能:不写死一个模型,而是让用户从微信里选一个 GLB 文件实时替换展示。
// pages/index/index.js chooseModel() { wx.chooseMessageFile({ count: 1, type: 'file', extension: ['glb', 'gltf'], success: (res) => { const file = res.tempFiles[0] const fs = wx.getFileSystemManager() fs.readFile({ filePath: file.path, encoding: 'binary', success: (readRes) => { this.loadModelFromBuffer(readRes.data) }, fail: (err) => { console.error('读取文件失败', err) } }) } }) }, loadModelFromBuffer(buffer) { const loader = new THREE.GLTFLoader() // buffer 是 ArrayBuffer,用 parse 而不是 load loader.parse( buffer, '', (gltf) => { this.clearModel() const model = gltf.scene // 复用 3.2 里的自动适配逻辑 this.fitModel(model) this.scene.add(model) this.model = model }, (err) => { console.error('解析模型失败', err) } ) }wx.chooseMessageFile的type: 'file'控制文件选择范围,extension限制后缀,这两个参数能极大减少用户选错文件的概率。readFile的encoding: 'binary'是读取二进制文件的标准姿势。这里有一个兼容坑:部分基础库版本在模拟器返回的是字符串而不是 ArrayBuffer,遇到这种情况要先判断readRes.data instanceof ArrayBuffer,不满足就手动转一下。
loader.parse和loader.load的区别在于数据来源:load接收 URL,内部自己发请求;parse接收 ArrayBuffer 和路径字符串,适合本地文件流。后者返回的gltf.scene是相同的场景对象,所以后续的适配、缩放、光照逻辑完全复用。
4. 本地运行与真机预览:从仓库拉到屏幕上的完整步骤
4.1 环境准备与依赖安装
需要准备的工具只有两个:微信开发者工具和 Node.js(用于安装依赖)。电脑上装好 Node.js 后,先到工程根目录执行npm install,把 three.js 装到node_modules。因为项目里libs/three.js是直接拷贝的,这个步骤在默认情况下可以跳过,但如果要换 three 版本,npm 安装是必须的。
cd project-root npm install three@0.160.0这里显式锁版本的原因是 three.js 的 API 变化很快,outputEncoding在 r152 之前和之后就有差别,如果直接npm install three装到最新版,旧的适配层可能跑不通。建议用资源和演示配套的版本,不要盲目升级。安装完成后,在微信开发者工具里确认node_modules目录被识别。
打开微信开发者工具,选择“导入项目”,目录指向工程根目录。注意开发者工具要用“小程序”模式而不是“小游戏”模式,两者对 canvas 的底层实现不同。导入后如果node_modules里有依赖,还需要在编辑器菜单栏执行“工具 -> 构建 npm”,这一步会生成miniprogram_npm目录,小程序端才能真正require到 npm 包。
4.2 页面配置与工程配置
index.wxml是页面里唯一的模板文件,canvas 占满屏幕,按钮固定在底部:
<view class="page"> <canvas id="model-canvas" type="webgl" class="model-canvas" bindtouchstart="bindTouchStart" bindtouchmove="bindTouchMove" catchtouchmove="noop" bindtouchend="bindTouchEnd" ></canvas> <view class="toolbar"> <button bindtap="chooseModel">选择模型</button> </view> </view>这里type="webgl"是 canvas 能拿 WebGL 上下文的前提,少写这个属性,createSelectorQuery拿到的节点就没有getContext('webgl')方法。catchtouchmove="noop"是阻止页面滚动的写法,catch会拦截触摸移动事件冒泡,noop是一个空函数,目的是不让 canvas 上的滑动手势触发页面滚动。如果希望模型区域之外还能滚动,就把catchtouchmove改成bindtouchmove。
app.json里的几个配置也很关键:
{ "pages": ["pages/index/index"], "window": { "navigationBarTitleText": "3D 模型展示", "navigationStyle": "custom" }, "renderer": "webview", "lazyCodeLoading": "requiredComponents" }navigationStyle: custom让页面变成沉浸式,3D 场景从屏幕顶部开始渲染,否则系统导航栏会占掉一块高度。renderer: webview是保持默认的渲染方式,不要改成skyline,目前 skyline 对 canvas 的 WebGL 支持还不稳定。lazyCodeLoading: requiredComponents是微信官方推荐开启的按需注入,能加快冷启动。
4.3 构建与预览
工程配置完成后,回到开发者工具,在pages/index/index.js没有报错的前提下,点击“编译”按钮。编译后模拟器里会加载出内置的sample.glb,这时可以先用鼠标拖拽画布,验证旋转和缩放逻辑。注意开发者工具的模拟器并不是真实 WebGL 环境,它内部用软件渲染模拟,性能表现和真机差异很大,卡顿不代表真机卡顿。
真机测试用“预览”功能,手机扫码后会打开一个体验版页面。进入页面后点击右上角胶囊按钮打开“开发调试”开关,手机上会浮现 vConsole 面板,所有console.log和 three.js 的警告都能看到。这一步是排查模型加载失败的关键,真机上文件读取路径和模拟器不同,很多问题只会在 vConsole 的报错里暴露。
如果选择模型后没有反应,优先看 vConsole 里readFile的 fail 回调。常见失败原因是文件路径带有中文或特殊字符,微信临时文件路径偶尔会编码异常。解决办法是拿到file.path后先decodeURIComponent再传给readFile。真机上chooseMessageFile返回的路径基本稳定,但模拟器上有时会返回http://tmp/...,这时需要走wx.downloadFile下载到本地再读取。
4.4 渲染循环与页面生命周期配合
这个项目还有一个容易被忽视但很重要的代码段:onHide和onShow。微信小程序页面切到后台时,three.js 的requestAnimationFrame并不会自动停止,如果继续渲染,既浪费电量也可能在 iOS 上被系统杀掉。常见做法是维护一个isActive标志:
onHide() { this.isActive = false }, onShow() { this.isActive = true }, startRenderLoop() { const tick = () => { if (this.isActive && this.renderer) { this.renderer.render(this.scene, this.camera) } requestAnimationFrame(tick) } tick() }把页面切后台时停掉渲染,切回来再恢复,代码只加了几行,但对长时间挂着 3D 页面的场景非常关键。调试时也可以验证这个逻辑:打开页面后切到微信其他对话框,再回来,模型不应该白屏或者卡在上一帧。
另外,index.wxss里把 canvas 容器设置成position: fixed并覆盖全屏,再把按钮层用z-index提到 canvas 之上。这是因为 canvas 在低版本基础库里是原生组件,普通元素会被盖住,用同层渲染配合 fixed 布局能减少层级问题。现在基础库版本普遍高于 2.12.0,这套写法基本不会踩坑。
5. 避坑与排查:这个项目我踩过的 5 个坑
5.1 模拟器正常,真机背景全黑
现象:开发工具里模型显示正常,一扫码到真机,canvas 区域全黑,什么模型都看不见。
原因:最常见的是 canvas 的type属性写错或者少了,导致真机上拿到的上下文不是 WebGL。模拟器对 canvas 的兼容性比真机宽松,出了问题不会立刻报错。其次是部分安卓机的系统 WebView 版本过旧,对 WebGL 1.0 的扩展支持不完整。
解决:第一步确认 canvas 标签里有type="webgl"。第二步在initThree里打印canvas.getContext('webgl'),如果返回 null,说明设备不支持 WebGL 或 canvas 节点没有正确创建。第三步在真机调试模式下打开 vConsole,看有没有THREE.WebGLRenderer: Error creating WebGL context这个报错。遇到这个报错,唯一靠谱的兜底是把模型渲染降级为截图序列,或者换一台系统版本更新的测试机。
5.2 GLB 加载成功但贴图全白
现象:模型形状能看到,但所有材质都是纯白,纹理贴图没有生效。
原因:很多 GLB 文件的贴图是外置的,GLTFLoader 在解析时会根据uri字段去加载图片。小程序里没有浏览器的Image对象,适配层提供的图片加载能力有限,外置纹理请求直接失败,材质就退回到默认白色。另外 three.js 默认会把纹理flipY设为 true,部分模型贴图坐标是按 glTF 规范生成的,翻转后 UV 对不上,表现形式同样是白模或者花纹错乱。
解决:优先使用贴图内嵌在 GLB 里的文件,这样的单文件模型不需要外置资源。如果一定要用外置纹理,需要在适配层里实现TextureLoader的图片读取,通过wx.getImageInfo或fs.readFile把图片转成 three.js 可用的纹理。flipY的话,加载纹理后手动设texture.flipY = false,再重新渲染。这个坑排查起来很花时间,一般会先下载模型的源文件,检查 gltf 目录下有没有单独贴图文件。
5.3 页面上下滑动时,模型跟着转
现象:在 canvas 上单指滑动,本意是旋转模型,但页面同时也在上下滚动,手势非常乱。
原因:canvas 上的touchmove事件没有阻止冒泡,小程序默认会把触摸移动交给页面滚动。three.js 的场景渲染在 canvas 内,滚动位置变化会让 canvas 视觉上偏移,而模型的触摸坐标还是按原来的点计算,于是模型和人手对不上。
解决:在 canvas 的绑定事件里用catchtouchmove而不是bindtouchmove,把touchmove冒泡拦住。如果必须保留页面滚动,也可以用wx.stopPullDownRefresh或者给 canvas 外层容器设height: 100vh; overflow: hidden来隔离滚动区域。这个改动很小,但不少项目因为少写一个catch直接把 3D 功能砍掉了。
5.4 iOS 上模型切换多次后闪退
现象:连续选择五六个大模型后,iOS 内存持续上涨,最后页面直接白屏或 App 闪退。
原因:每次替换模型时,旧的Geometry、Mesh、Texture还留在 GPU 内存里。three.js 的scene.remove(model)只会把对象从场景中摘掉,不会自动释放 WebGL 缓冲区和纹理对象。小程序上没有浏览器的自动垃圾回收兜底,内存泄漏非常明显。
解决:加一个clearModel方法,在加载新模型前主动遍历场景,把每个 mesh 的 geometry、material、texture 都调用dispose释放掉。常用贴图槽位都处理一遍:
clearModel() { if (!this.model) return this.scene.traverse((child) => { if (child.isMesh) { child.geometry.dispose() if (Array.isArray(child.material)) { child.material.forEach((m) => this.disposeMaterial(m)) } else { this.disposeMaterial(child.material) } } }) this.scene.remove(this.model) this.model = null }, disposeMaterial(material) { Object.keys(material).forEach((key) => { const value = material[key] if (value && value.isTexture) { value.dispose() } }) material.dispose() }这段代码里的traverse是 three.js 自带的子树遍历方法,能拿到场景里所有子节点。isMesh和isTexture是 three.js 内部用来做类型判断的属性,比instanceof更稳妥。释放完再交给scene.remove,旧模型才能真正腾出内存。这个函数就是这个项目的后悔药,凡是做到模型替换功能,都会先写它。
5.5 构建 npm 后提示找不到 three
现象:开发者工具执行“构建 npm”后,页面报错Module not found: can't resolve 'three',或者构建完成但运行时提示THREE is not defined。
原因:project.config.json里miniprogramRoot没有指到正确目录,或者packNpmManually配置缺失,导致工具不知道去哪里找node_modules。另外,如果代码里用的是const THREE = require('three'),但 npm 包内部依赖浏览器环境的window,适配层没有先执行,three.js 在加载阶段就抛错,错误信息容易被误判成“找不到”。
解决:在project.config.json里确认miniprogramRoot指向miniprogram/,并打开packNpmManually,把packNpmRelationList配成miniprogram和node_modules的对应关系。更重要的是保证require('../../utils/adapter')在require('three')之前执行。这个坑的典型特征是模拟器报错路径和真机不同,看到Module not found时先检查构建产物目录miniprogram_npm是否存在。
6. 二次开发思路:多模型切换、环境贴图和远程模型加载
6.1 多模型切换与缩放上限
当前页面只展示一个模型,改造成多模型列表其实不难。把loadModelFromBuffer和fitModel抽成公共方法,页面维护一个currentIndex,切换时先clearModel再加载新模型。双指缩放的上下限建议用clamp包裹:
const scale = this.model.scale.x const target = Math.min(Math.max(scale * ratio, 0.5), 5) this.model.scale.set(target, target, target)0.5和5按你的模型尺寸调整,目的是避免缩得太小找不到模型,或者放大后穿模穿到相机内部。
6.2 环境贴图和动态背景
three.js 里用scene.background和scene.environment可以快速改变视觉效果。背景可以放一张深色渐变图,环境贴图能给金属、玻璃材质补反射。在小程序里加载 HDR 不方便,常见做法是准备几张 RGBE 格式的 hdr 转成 jpg/png 环境贴图,用THREE.CubeTextureLoader加载。如果模型是标准 PBR 材质,加载一张环境贴图后质感会立刻提升,这也是很多 3D 展示项目看起来高级的捷径。
6.3 对接业务接口加载远程模型
网络加载模型需要做两件事:一是在小程序后台配置downloadFile合法域名,二是把GLTFLoader.load的 url 换成接口返回的文件地址。注意downloadFile默认会先下载文件,如果模型体积大,等待时间要看进度回调。更稳妥的做法是先用wx.downloadFile把模型下到本地,再走loadModelFromBuffer,这样加载进度条、失败重试都可以自己控制。
说实话,拆这个项目之前我一直觉得小程序 3D 是“能跑但没人用”的玩具。真正跑通之后才发现,前置条件只剩一个 canvas 和一层适配层,剩下的完全可以是正经业务功能。那次之后,我每次接手类似需求都会先问一句“模型文件从哪里来,是本地还是网络”,再决定适配层和加载器怎么接。希望你拿到这份工程后也能先把内置模型跑起来,再按自己的业务路径去改。希望帮到你。
本文还有配套的精品资源,点击获取