简介:面向微信小程序开发者的3D模型加载示例工程,核心解决小程序环境中引入three.js并加载外部OBJ等三维模型的问题。工程包含从环境搭建、场景初始化、渲染器绑定、模型加载到动画循环、交互监听与性能优化的完整演示代码,开发人员可直接对照工程结构,将3D展示能力迁移至自己的小程序场景中。压缩包共11个文件,以4个js逻辑文件、4个json配置和页面结构文件(wxml/wxss各1个)为主,另附README说明文档;整体167KB,体量精简,目录层级清晰,可直接导入微信开发者工具运行。项目采用JavaScript与小程序的canvas组件协作,不依赖外部服务,可作为小程序3D功能的起步模板进行二次开发。目前已有3205人学习下载,适合需要在商品展示、模型预览或互动体验中引入3D效果的前端开发者参考。
1. 微信小程序里加载外部3D模型:为什么你的Three.js一进小程序就报错
把浏览器里的Three.js工程搬进微信小程序,遇到的第一个报错往往是document is not defined或者canvas is undefined。这不是代码写错了,而是小程序的环境里根本没有浏览器那套 DOM 和标准 WebGL 接口。miniprogramThree 这个方向要解决的,就是让 Three.js 的核心能力在小程序的 Canvas 上重新跑起来:能解析 glTF/glb 这类外部模型文件、能贴图、能转场景、能出帧。适合的人群很明确:想在微信小程序里做商品 3D 展示、展厅导览、工业模型预览的开发者,你不需要重新发明一套 3D 引擎,只需要一套能用的适配方案和一条清晰的操作路径。读完这篇,你能搭起最小工程、加载第一个外部模型,并且知道那些“上了真机才炸”的坑都在哪。
2. 小程序端Three.js适配原理:浏览器依赖与小程序的三个关键差异
2.1 没有DOM:从 document 到 Canvas 实例的换血
Three.js 在浏览器里能跑,依赖的是document.createElement('canvas')、window.requestAnimationFrame、Image对象这些浏览器全局能力。小程序里这些东西都不存在,你拿到的是一个通过wx.createCanvas()创建出来的 Canvas 实例。适配层做的最核心一件事,就是把 Three.js 内部对document和window的调用,全部替换成对小程序 Canvas 实例的直接引用。常见做法是初始化时把canvas实例传进去,并手动指定 WebGL 上下文,代码大概是这样:
// 小程序页面里,onReady 之后拿 canvas const canvas = wx.createCanvas(); // 获取 WebGL 上下文 const gl = canvas.getContext('webgl', { antialias: true, alpha: true }); // 把 canvas 和 gl 交给 three 的渲染器 const renderer = new THREE.WebGLRenderer({ canvas: canvas, context: gl, antialias: true });这段代码的逻辑是:小程序不像浏览器那样“自己找 canvas”,你得主动创建并显式传入。antialias决定是否开抗锯齿,开真机预览会更平滑但耗内存;alpha控制背景是否透明,如果你要覆盖在页面 UI 上层,就保持true。注意getContext('webgl')在小程序基础库版本过低时会返回null,所以拿到gl之后先判断一下再往下走,别直接挂。
2.2 渲染循环与帧回调:requestAnimationFrame 在小程序里不自动
浏览器里写requestAnimationFrame(animate)就能驱动每帧渲染,小程序没有这个全局函数,适配层一般会把它映射到小程序的canvas.requestAnimationFrame或者setTimeout模拟。你需要自己维护一个循环函数,并在页面onUnload时手动清掉,否则页面销毁后渲染循环还在跑,内存一路涨。
let isRendering = false; function animate() { if (!isRendering) return; // 更新模型旋转或其他交互 if (windowModel) { windowModel.rotation.y += 0.01; } renderer.render(scene, camera); canvas.requestAnimationFrame(animate); } // 启动循环 isRendering = true; canvas.requestAnimationFrame(animate); // 页面卸载时停止 function stopRender() { isRendering = false; if (canvas && canvas.cancelAnimationFrame) { canvas.cancelAnimationFrame(animate); } }这里要注意的是:canvas.requestAnimationFrame在部分基础库版本里不存在,稳妥做法是直接用setTimeout(animate, 16)兜底,浏览器端测试时再换回requestAnimationFrame。另一个点是循环必须放在onReady之后启动,因为那时候 canvas 才真正挂载到页面上,提前调用可能拿不到宽高导致渲染尺寸为 0。
2.3 模块加载差异:为什么官方示例和你的工程“长得不一样”
小程序不支持浏览器那种 ES Module 的import语法直接拉包,官方适配示例用的是 CommonJS 的require,或者编译工具处理后的模块方式。这就带来一个选型问题:你的项目是普通小程序目录结构,还是用了某种构建工具?我一般会建议:优先用适配库自带的构建产物(通常放在miniprogram_npm或three目录下),而不是自己从 npm 拉原版 Three.js 再手动改。原因很简单,原版 Three.js 内部有大量document和window引用,你不改基本跑不起来,适配库已经把这些问题处理过一遍了,你直接require对应文件即可。
选型判断标准:如果你的模型只是展示、旋转、缩放,不需要光照贴图、骨骼动画这些复杂能力,直接用适配库就够了;如果你要跑粒子系统、后期特效,小程序端性能撑不住,你得评估是否降级到图片序列帧,这是另一条路,但很多项目做到一半发现性能不达标,回头换方案成本很高。
3. 用miniprogramThree跑通外部模型加载:最小工程与初始化代码
3.1 工程结构:模型文件放对位置,比写代码更先一步
一个小程序 3D 页面的目录结构,我习惯是这么安排的:
pages/ model-view/ model-view.js model-view.json model-view.wxml model-view.wxss three/ three.js // 适配版的 three 核心 GLTFLoader.js // 模型加载器,注意路径 models/ demo.glb // 本地模型,建议放分包需要注意:three目录建议放在项目根,而不是pages下,这样多个页面共用不会重复打包;models目录如果模型超过 2MB(主包限制)就必须放分包,并在app.json里声明分包路径,否则真机预览直接报包体积超限。如果你是从网络获取模型,可以不建这个目录,但你需要在小程序后台配置好下载合法域名。
3.2 初始化渲染器、场景和相机:三个对象的参数坑
const THREE = require('../../three/three.js'); const { GLTFLoader } = require('../../three/GLTFLoader.js'); Page({ data: { loading: true, error: '' }, onReady() { // 1. 创建 canvas 并检查 WebGL 上下文 this.canvas = wx.createCanvas(); const gl = this.canvas.getContext('webgl', { antialias: true }); if (!gl) { this.setData({ error: '当前设备不支持 WebGL' }); return; } // 2. 初始化渲染器 this.renderer = new THREE.WebGLRenderer({ canvas: this.canvas, context: gl, antialias: true, alpha: true }); // 关键参数:像素比,设置不当模型会模糊或发虚 const dpr = wx.getSystemInfoSync().pixelRatio || 2; this.renderer.setPixelRatio(Math.min(dpr, 2)); this.renderer.setSize(this.canvas.width, this.canvas.height); // 3. 场景与相机 this.scene = new THREE.Scene(); this.camera = new THREE.PerspectiveCamera(45, this.canvas.width / this.canvas.height, 0.1, 1000); this.camera.position.set(2, 2, 5); this.camera.lookAt(0, 0, 0); // 4. 加个简单光照,避免模型一片黑 const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); this.scene.add(ambientLight); const dirLight = new THREE.DirectionalLight(0xffffff, 0.8); dirLight.position.set(5, 10, 7); this.scene.add(dirLight); this.startRenderLoop(); } });这里最值得展开的是renderer.setPixelRatio。真机像素比经常是 3,如果你直接设成 3,渲染尺寸变成 canvas 实际宽度的 3 倍,很多中低端机直接卡成幻灯片。我一般是取Math.min(dpr, 2),视觉上基本察觉不到差异,性能却能明显稳住。setSize的宽高要和 canvas 的width/height对齐,小程序里 canvas 的样式宽高和像素宽高是两回事,别只设置样式不设置实际尺寸,否则渲染区域只有一小块。
3.3 加载本地glb模型:GLTFLoader 的最简用法
loadLocalModel() { const loader = new GLTFLoader(); const that = this; loader.load( '/models/demo.glb', (gltf) => { const model = gltf.scene; // 缩放场景,因为 glb 单位是米,小程序场景单位需要收紧 model.scale.set(0.5, 0.5, 0.5); this.scene.add(model); this.model = model; that.setData({ loading: false }); }, (xhr) => { // xhr.loaded / xhr.total 是加载进度 if (xhr.total) { const percent = Math.round((xhr.loaded / xhr.total) * 100); that.setData({ loading: true }); console.log('加载进度', percent + '%'); } }, (err) => { that.setData({ error: '模型加载失败:' + err }); } ); }逻辑说明:这里用的是相对路径'/models/demo.glb',小程序里本地资源路径必须以/开头。gltf.scene返回的是一个Group对象,你要手动把它add到scene里,这是新手最容易漏的一步。进度回调里xhr.total有时是 0,尤其在本地分包场景,所以要有if (xhr.total)的保护。远程加载时,GLTFLoader走的是wx.request或wx.downloadFile,适配库里通常会处理好,但域名校验问题在后面避坑章会说。
验证跑通的方法是:真机预览或开发者工具里看 console,如果没有报错,屏幕上出现模型,并且能随model.rotation.y += 0.01转动,说明最小链路已经通了。如果黑屏,优先检查 canvas 绘制区域的 CSS 尺寸是否给了固定宽高,很多黑屏都是因为 wxml 里 canvas 没有设置style="width: 100%; height: 400px;"。
4. 外部模型加载的完整链路:glTF格式选择、纹理与分包策略
4.1 模型格式怎么选:glb 优先,gltf 次之,obj 最后考虑
微信小程序里加载外部 3D 模型,格式决定了一大批坑。glb 是二进制单文件,模型和贴图打包在一起,加载只需要一个请求,最省事;gltf 是 JSON 加外部资源,会引用.bin和贴图文件,小程序本地分包时路径关系很容易乱;obj 是老格式,没有材质绑定规范,适配库里支持也弱,不推荐用在生产环境。我做项目时几乎只用 glb,或者把 gltf 在导出时“打包成 glb”再放进工程。注意:glb 的体积通常比 gltf 散文件小,因为二进制序列化去掉了大量重复的 JSON 键名。
如果你的模型来自某图像处理 Demo 这类测试项目,导出时记得把材质纹理嵌入 glb,勾选Texture > Embed之类的选项。不然你把 glb 单独丢进 models 目录,贴图丢失,加载出来就是白模。
4.2 贴图与纹理加载:远程图片的路径拼接问题
模型带贴图时,最典型的问题是“模型加载成功但全白”。原因通常是:gltf/glb 内部的纹理路径是相对路径,适配版GLTFLoader在解析图片路径时,并没有拿模型所在目录去拼接,而是直接试图从根路径加载。处理办法有两个,一是加载时指定loader.setResourcePath:
const loader = new GLTFLoader(); // 告诉 loader 去哪个目录找贴图,这里如果模型和贴图不在同一层,就写完整路径 loader.setResourcePath('/models/textures/');二是加载完后手动替换贴图。第二种情况多用于远程模型——远程 URL 里的图片路径可能是相对根域的,小程序里直接请求会被域名白名单拦掉,你需要先下载贴图到本地再赋给材质:
// 先把远程贴图下载到临时目录 wx.downloadFile({ url: 'https://example.com/textures/wood.jpg', success(res) { const texture = new THREE.TextureLoader().load(res.tempFilePath); // 找到模型里的某个材质并替换 if (that.model) { const mesh = that.model.children[0]; if (mesh && mesh.material) { mesh.material.map = texture; mesh.material.needsUpdate = true; } } } });这里的核心逻辑是:小程序不能直接把网络图片路径丢给 Three.js 去加载,必须先经过wx.downloadFile变成tempFilePath。另外注意needsUpdate = true必须设置,否则纹理设置不生效。远程贴图这种方案还要处理缓存——小程序临时目录文件在应用重启后会失效,所以每次进入页面重新下载是常见做法,你也可以把贴图存到本地用户目录再复用,但要做好版本管理,不然更新模型后贴图对不上。
4.3 分包策略与模型体积:2MB限制下的三个动作
小程序主包限制 2MB,一个中等精度的 glb 模型经常就 3-8MB 了,所以模型必须放分包。具体做法是在app.json里声明:
{ "pages": ["pages/index/index"], "subpackages": [ { "root": "models", "pages": [] } ] }把models作为分包根目录,模型文件放里面。加载时用'/models/demo.glb'的路径,小程序会自动去分包找。注意:分包预下载要在app.json里配preloadRule,不然用户进入页面时才下载分包,真机上会出现“点击后白屏等半天”的体验。另一个动作是模型压缩:很多 glb 没有经过 draco 压缩,而这种压缩方案在小程序端解压又比较吃性能,所以我的习惯是“能用顶点数据压缩就用,但先在本机跑一下真机预览,解压太慢就直接上简化版模型”。最后一个动作是把模型三角面数控制在一定量级以下,一般展示类场景在 5 万面以内比较稳,超过 10 万面真机预览基本会掉帧。
4.4 远程模型加载:从临时文件到 GLTFLoader 的对接
如果你的模型不放进分包,而是像商品数据一样由后端给 URL,路径会变成“先下载、再加载”。常见做法是:
wx.downloadFile({ url: 'https://your-model-server.com/models/demo.glb', success(res) { // res.tempFilePath 是本地临时文件 const loader = new GLTFLoader(); loader.load(res.tempFilePath, (gltf) => { // 处理模型 }); }, fail(err) { console.error('模型下载失败', err); } });这里要注意:下载域名必须在小程序后台配置到downloadFile合法域名里,否则真机直接报url not in domain list。另外GLTFLoader.load对tempFilePath的路径格式支持要看适配库版本,有的版本要求去掉file://前缀。我一般会做一层保护:
let modelPath = res.tempFilePath; if (modelPath.startsWith('file://')) { modelPath = modelPath.replace('file://', ''); } loader.load(modelPath, ...);这样处理之后,远程模型链路就完整了:下载 → 转成本地路径 → 加载解析 → 材质处理 → 入场景渲染。这条链路也是做商品展示类小程序最常用的方案,而且能把下载进度和加载进度分开展示给用户。
5. 避坑:外部模型在小程序端翻车的5个高频问题
5.1 黑屏:canvas 尺寸和像素尺寸不一致
现象:开发者工具里能看到模型,真机预览黑屏,或者只有一小块渲染区域。原因:小程序 canvas 的 wxml 样式宽高是逻辑像素,wx.createCanvas().width是物理像素,两者没对齐时,Three.js 渲染到物理尺寸外,屏幕上看就是黑的。解决:在onReady里拿到wx.getSystemInfoSync().windowWidth和windowHeight,把canvas.width = windowWidth * dpr,canvas.height = windowHeight * dpr,同时 wxml 里 canvas 样式宽高设置为100%。
5.2 模型加载成功但全白:纹理路径解析失败
现象:控制台没有报错,模型能转,但表面是纯白的,没有贴图。原因:glb/gltf 里的纹理路径在适配版 loader 里拼接错误,找不到图片资源。解决:优先确认导出时勾选了嵌入纹理,否则必须配合setResourcePath指定贴图目录。另外检查贴图格式,小程序不支持部分 WebP 或 HDR 贴图,统一转成 JPG 或 PNG 最省心。
5.3 真机 downLoadFile 报域名不在白名单
现象:远程模型链接在开发者工具里能加载,真机上报url not in domain list。原因:开发者工具默认不校验域名,真机必须校验。解决:在小程序后台的“开发管理 - 服务器域名”里,把模型服务器域名加到downloadFile合法域名。注意必须 HTTPS,并且域名不能带路径只能写根域。这一步做完要等几分钟生效,别改了立刻测就怀疑自己看错。
5.4 页面退出后再进入,内存暴涨或卡死
现象:第一次进页面正常,退出后再次进入,开几个来回之后内存翻倍,最后崩溃。原因:渲染循环没有销毁,renderer 没有释放,WebGL 上下文还挂在 canvas 上。解决:在onUnload里做三件事:停止帧循环、移除场景里所有模型、调用renderer.dispose()。如果用了纹理,还要遍历材质调用texture.dispose()。这是我最早做项目时踩得最深的一坑,“后悔药”就是在每个页面退出时写严格的清理函数。
5.5 模型方向或尺寸不对:glb 单位米和相机单位的错位
现象:模型加载出来要么巨大到穿模,要么小成一个点。原因:glb 的计量单位是米,而小程序场景里如果把camera.position设成(0,0,1),一个 1 米见方的模型刚好填满视野,但如果模型是建模软件里按毫米导出的,就会大出 1000 倍。解决:参考我这个保守做法——模型加载后先console.log(gltf.scene)看包围盒尺寸,再决定缩放系数;一般model.scale.set(0.01, 0.01, 0.01)是常用起步值,配合camera.position一起调。也可以写一个自动适配函数,根据Box3计算模型大小并归一化。
6. 进阶:多模型切换与加载进度条的一个顺手方案
多模型切换的常见需求是:一个页面里展示不同款式、不同颜色的商品模型,用户点击按钮切换。这个场景最容易犯的错是反复scene.add新模型却不清掉旧的,导致场景里叠了几十个模型,帧率骤降。我通常的做法是建一个“模型容器”:
this.modelGroup = new THREE.Group(); this.scene.add(this.modelGroup); function switchModel(newGltf) { // 清空容器内旧模型 while (this.modelGroup.children.length > 0) { const oldModel = this.modelGroup.children[0]; this.modelGroup.remove(oldModel); oldModel.traverse((child) => { if (child.isMesh) { child.geometry.dispose(); if (child.material) child.material.dispose(); } }); } // 加入新模型 const model = newGltf.scene; this.modelGroup.add(model); }加载进度条方面,远程模型可以分成两段进度:wx.downloadFile的下载进度,和GLTFLoader的解析进度。下载进度用wx.downloadFile的onProgressUpdate回调拿,解析进度用 loader 的onProgress,两段合在一起展示给用户更友好:
const task = wx.downloadFile({ url: modelUrl, success: (res) => this.loadGLTF(res.tempFilePath) }); task.onProgressUpdate((res) => { this.setData({ progress: '下载中 ' + res.progress + '%' }); });我最早做这种加载时,只显示一个 loading 动画,用户等半天不知道是卡了还是在加载,后来加了分阶段进度,页面的“玄学卡死”提问明显变少了。另一个验证技巧是:把开发者工具的网络面板打开看模型请求是否走通,真机预览时打开调试模式的 vConsole 看 console 报错——很多问题在开发者工具里不出现,真机上必现,养成真机调试习惯能省大量排查时间。最后收个尾:做小程序 3D 展示,别一上来就追求极致画质,先把“加载快、不崩溃、切换顺”这三件事做好,再谈光影和特效。这个方向值不值得投入,我的答案是——商品展示类业务里,3D 模型带来的交互体验提升非常明显,但前提是你愿意在工程化细节上花时间。这篇踩坑记录如果能帮你少走几步弯路,那就值了,希望帮到你。
本文还有配套的精品资源,点击获取