简介:这份资源面向希望在微信小程序中实现3D可视化与交互的开发者,重点解决小程序环境无法直接引入npm库、加载外部3D模型困难等问题。借助three.js这一基于WebGL的JavaScript 3D库,读者可以掌握在小程序中搭建场景、相机与渲染器,加载OBJ、FBX、GLTF等格式模型,并通过动画循环与触摸事件实现模型旋转、缩放等交互,同时兼顾性能优化与异常处理。资源包共11个文件,包含4个json配置、4个js脚本、1个md说明、1个wxss样式与1个wxml结构文件,压缩包约167KB,目录涵盖threejs、loaders、index等模块,结构清晰便于对照学习。目前已有3205人学习下载,适合具备一定JavaScript基础、想拓展小程序3D能力的中级开发者参考实践。
1. 从一次翻车说起:为什么小程序加载外部 3D 模型值得单独拆一遍
去年接了个需求,要在微信小程序里展示一个可旋转、可缩放的产品模型,模型文件是设计那边用 Blender 导出的 glTF。我第一反应是「这不就是个 canvas 的事」,结果真上手才发现坑比想象中多:小程序没有 DOM,WebGL 上下文得自己管,模型文件动辄几 MB 不能直接塞进代码包,加载完还得处理光照和相机。折腾了两天才跑通,中间翻车无数次。
miniprogramThree这个项目就是冲着这个场景来的——它把微信小程序加载外部 3D 模型的完整链路封装好了,基于 Three.js 做渲染层,模型文件走网络请求动态加载,不占主包体积。适合两类人:一是需要在电商、教育、展示类小程序里嵌入 3D 视图的前端;二是想学小程序 WebGL 渲染管线怎么搭的开发者。JavaScript 是它的主要语言,整个项目结构不复杂,但每一块都踩在关键点上。
2. 项目架构拆解:渲染层、加载层、交互层怎么分工
2.1 整体目录结构与模块职责
拿到源码先别急着跑,花五分钟把目录过一遍,后面改起来心里有数。这个项目的结构大致是这样的:
miniprogramThree/ ├── pages/ │ └── model/ │ ├── model.js # 页面逻辑,负责初始化和生命周期 │ ├── model.wxml # 页面结构,主要是一个 canvas 容器 │ └── model.wxss # 样式,控制 canvas 尺寸和层级 ├── libs/ │ ├── three.min.js # Three.js 核心库(适配小程序的版本) │ └── GLTFLoader.js # glTF 模型加载器 ├── utils/ │ ├── renderer.js # 渲染器封装,管理 WebGL 上下文 │ ├── camera.js # 相机配置与自适应 │ └── loader.js # 模型加载与缓存逻辑 ├── static/ │ └── models/ # 本地测试模型(正式环境走 CDN) ├── app.js ├── app.json └── project.config.json核心思路是三层分离:渲染层(renderer.js + camera.js)只管画,加载层(loader.js + GLTFLoader.js)只管取模型,交互层(model.js 里的触摸事件)只管响应用户操作。这样拆的好处是,你换模型格式也好、换交互方式也好,不用动其他层。
2.2 渲染管线:从 canvas 到 WebGL 上下文
小程序里创建 WebGL 上下文和浏览器不一样,不能直接document.createElement('canvas')。得用小程序提供的createSelectorQuery拿到 canvas 节点,再调getContext('webgl')。这一步是整个渲染的地基,写错了后面全白搭。
// utils/renderer.js function createRenderer(canvasId, pageContext) { return new Promise((resolve, reject) => { // 通过选择器查询拿到 canvas 节点 const query = pageContext.createSelectorQuery(); query.select(`#${canvasId}`) .node() // 关键:node() 拿到真实节点对象 .exec((res) => { if (!res || !res[0] || !res[0].node) { reject(new Error('canvas 节点获取失败')); return; } const canvas = res[0].node; // 获取 WebGL 上下文,开启抗锯齿 const gl = canvas.getContext('webgl', { antialias: true, alpha: true, preserveDrawingBuffer: true }); if (!gl) { reject(new Error('WebGL 上下文创建失败')); return; } // 用 Three.js 的 WebGLRenderer 包装 const renderer = new THREE.WebGLRenderer({ canvas: canvas, context: gl }); // 设置像素比,避免高清屏模糊 const dpr = wx.getSystemInfoSync().pixelRatio; renderer.setPixelRatio(dpr); renderer.setSize(canvas.width, canvas.height); resolve(renderer); }); }); }这段代码有几个参数值得注意。antialias开抗锯齿会让边缘更平滑,但低端机上可能掉帧,如果目标用户设备偏老可以关掉。preserveDrawingBuffer设成 true 是为了截图功能,不需要的话关掉能省一点性能。pixelRatio必须设,不然在 iPhone 上模型边缘会糊得像马赛克。
2.3 相机与场景初始化:别让模型跑出视野
相机配置是新手最容易翻车的地方。模型加载出来一片黑,十有八九是相机位置不对或者没调lookAt。这个项目里相机逻辑单独抽了一个文件:
// utils/camera.js function createCamera(canvasWidth, canvasHeight) { // 透视相机:视野角度 45 度,宽高比按 canvas 实际比例 const camera = new THREE.PerspectiveCamera( 45, // fov:视野角度,越小越"望远" canvasWidth / canvasHeight, // aspect:必须和 canvas 比例一致 0.1, // near:近裁剪面 1000 // far:远裁剪面 ); camera.position.set(0, 0, 5); // 相机放在 z 轴正方向 5 个单位处 camera.lookAt(0, 0, 0); // 看向原点 return camera; }fov设 45 是比较通用的值,想要广角效果可以调到 60,但太大会产生畸变。near和far决定了渲染范围,设得太小或太大都会导致深度精度问题,模型表面出现闪烁的条纹。我一般会先确认模型的包围盒尺寸,再根据尺寸反推相机距离,而不是拍脑袋写个 5。
场景初始化还要加光源,不然模型是纯黑的:
function createScene() { const scene = new THREE.Scene(); // 环境光:提供基础亮度,避免暗面全黑 const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); scene.add(ambientLight); // 平行光:模拟主光源,产生明暗对比 const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8); directionalLight.position.set(1, 1, 1); scene.add(directionalLight); return scene; }环境光强度 0.6 加平行光 0.8 是我试出来比较平衡的组合,纯环境光会让模型看起来很平,没有立体感;纯平行光则暗面死黑。两个搭配着用,效果最接近产品展示的需求。
3. 模型加载全流程:从网络请求到场景挂载
3.1 外部模型加载:为什么不能直接放代码包
微信小程序主包有 2MB 限制,整个包不能超过 20MB。一个稍微精细点的 glTF 模型轻松上 3MB,塞进代码包直接超限。所以外部模型必须走网络加载,放到 CDN 或者自己的服务器上。
这个项目的加载层做了两件事:一是封装wx.request下载模型文件,二是用GLTFLoader解析。注意小程序里没有fetch,也不能用XMLHttpRequest,得用wx.request拿到 ArrayBuffer 再喂给 loader。
// utils/loader.js function loadModel(url, onProgress) { return new Promise((resolve, reject) => { wx.request({ url: url, method: 'GET', responseType: 'arraybuffer', // 关键:必须指定为 arraybuffer success(res) { if (res.statusCode !== 200) { reject(new Error(`模型下载失败,状态码 ${res.statusCode}`)); return; } // 用 GLTFLoader 解析 ArrayBuffer const loader = new THREE.GLTFLoader(); loader.parse( res.data, // 模型二进制数据 '', // 资源路径前缀,模型内嵌纹理时传空 (gltf) => { resolve(gltf.scene); // 返回场景节点 }, (err) => { reject(new Error('模型解析失败:' + err.message)); } ); }, fail(err) { reject(new Error('网络请求失败:' + err.message)); } }); }); }responseType必须设成arraybuffer,默认是 text,拿到的是乱码。loader.parse的第二个参数是资源路径前缀,如果 glTF 文件里引用了外部纹理图片,这里要填纹理所在的目录 URL;如果纹理是内嵌的(base64),传空字符串就行。
3.2 模型挂载与自适应缩放
模型加载回来之后不能直接scene.add,因为不同来源的模型尺寸差异巨大。有的模型导出时单位是米,有的是厘米,直接加进去要么小得看不见,要么大到相机里面全是模型的内壁。
// 模型加载完成后的处理 function fitModelToView(model, camera) { // 计算模型的包围盒 const box = new THREE.Box3().setFromObject(model); const size = box.getSize(new THREE.Vector3()); const center = box.getCenter(new THREE.Vector3()); // 把模型中心移到原点 model.position.sub(center); // 计算最大边长 const maxDim = Math.max(size.x, size.y, size.z); // 根据相机 fov 反推合适的距离 const fov = camera.fov * (Math.PI / 180); const distance = maxDim / (2 * Math.tan(fov / 2)); // 相机后退到合适位置 camera.position.set(0, 0, distance * 1.5); // 1.5 倍留点边距 camera.lookAt(0, 0, 0); camera.updateProjectionMatrix(); }这段逻辑是整个项目里我觉得最实用的部分。Box3算出包围盒,getCenter拿到中心点,把模型平移到原点,这样旋转的时候不会偏心。然后根据fov和模型最大边长反推相机距离,乘以 1.5 留出边距。不管模型多大,都能自动适配到视野里。
3.3 触摸交互:旋转、缩放怎么接
小程序支持touchstart、touchmove、touchend,用它们来实现模型的旋转和双指缩放。核心思路是记录手指移动的增量,映射到模型的旋转角度上。
// pages/model/model.js 中的交互逻辑 let lastX = 0, lastY = 0; let lastDistance = 0; Page({ onTouchStart(e) { if (e.touches.length === 1) { lastX = e.touches[0].x; lastY = e.touches[0].y; } else if (e.touches.length === 2) { // 双指:计算初始距离 const dx = e.touches[0].x - e.touches[1].x; const dy = e.touches[0].y - e.touches[1].y; lastDistance = Math.sqrt(dx * dx + dy * dy); } }, onTouchMove(e) { if (e.touches.length === 1) { // 单指旋转 const deltaX = e.touches[0].x - lastX; const deltaY = e.touches[0].y - lastY; // 0.01 是灵敏度系数,调大转得快 model.rotation.y += deltaX * 0.01; model.rotation.x += deltaY * 0.01; lastX = e.touches[0].x; lastY = e.touches[0].y; } else if (e.touches.length === 2) { // 双指缩放 const dx = e.touches[0].x - e.touches[1].x; const dy = e.touches[0].y - e.touches[1].y; const distance = Math.sqrt(dx * dx + dy * dy); const scale = distance / lastDistance; camera.position.z /= scale; // 相机前后移动实现缩放 camera.position.z = Math.max(1, Math.min(20, camera.position.z)); // 限制范围 lastDistance = distance; } } });旋转灵敏度 0.01 是个经验值,太小了转不动,太大了轻轻一划模型就飞了。缩放用相机前后移动而不是改模型 scale,这样光照效果不会变形。Math.max和Math.min限制相机距离在 1 到 20 之间,防止用户把相机拉到模型内部或者推到看不见的地方。
4. 避坑指南:加载 3D 模型时最容易翻车的五个点
4.1 模型加载出来全黑
现象:模型文件确认加载成功,控制台没报错,但屏幕上什么都看不到,或者只有一片黑色。
原因:九成是光照问题。Three.js 默认不添加任何光源,没有光源的场景渲染出来就是全黑。另一个可能是相机位置不对,模型在视野外面。
解决:先加一个AmbientLight确认模型能显示出来,再逐步加方向光调效果。相机问题用Box3算包围盒后自动适配,别手动猜位置。
4.2 模型文件过大导致加载超时
现象:开发工具里能加载,真机上一直转圈最后超时。
原因:真机网络环境比开发工具差,几 MB 的模型在弱网下请求超时。微信小程序默认请求超时是 60 秒,但用户等不了那么久。
解决:模型导出时做减面处理,纹理压缩成 webp 或降低分辨率。glTF 格式支持 Draco 压缩,能把模型体积压到原来的十分之一。加载时加进度提示,超过 10 秒还没完成就给用户一个反馈。
4.3 iOS 上模型边缘锯齿严重
现象:Android 上看着正常,iOS 真机上模型边缘像狗啃的。
原因:没有设置pixelRatio,或者设置成了固定值。iOS 设备的像素比和 Android 不一样,iPhone 的 Retina 屏 pixelRatio 通常是 2 或 3。
解决:用wx.getSystemInfoSync().pixelRatio动态获取,不要写死。同时确认renderer.setSize用的是 canvas 的实际尺寸而不是 CSS 尺寸。
4.4 触摸旋转时模型跳动
现象:手指按下去模型就跳一下,然后才能正常旋转。
原因:touchstart时没有记录初始位置,第一次touchmove的增量是从 0 开始算的,导致突变。
解决:在touchstart里就把当前触摸点坐标存下来,touchmove里用当前坐标减去上一次记录的坐标,每次移动后更新记录值。
4.5 页面切后台再回来模型消失
现象:小程序切到后台再切回来,canvas 变空白,模型不见了。
原因:小程序切后台会回收 WebGL 上下文,切回来需要重新初始化渲染器和重新加载模型。
解决:在onShow生命周期里检查渲染器状态,如果上下文丢失就重新走一遍初始化流程。模型可以缓存在内存里,不用重新下载。
5. 二次开发改造:换模型格式、加动画、接业务数据
5.1 换成其他模型格式
项目默认用的是 glTF,这是目前 Web 端最推荐的 3D 格式,体积小、加载快、支持 PBR 材质。但如果你的模型是 OBJ 或 FBX 格式,改起来也不难。
OBJ 格式用OBJLoader,FBX 用FBXLoader,思路和 GLTFLoader 一样,都是先下载 ArrayBuffer 再 parse。区别在于 OBJ 不带材质信息,需要额外加载 MTL 文件;FBX 是二进制格式,解析出来直接就是完整的场景节点。
我一般会建议设计那边统一导出 glTF,因为 Three.js 对它的支持最好,而且 glTF 是 Khronos 定的标准,长期来看最稳。
5.2 给模型加旋转动画
静态展示太单调,产品展示通常需要模型自动缓慢旋转。在渲染循环里加一行就行:
function animate() { // 每帧让模型绕 Y 轴转 0.005 弧度 model.rotation.y += 0.005; renderer.render(scene, camera); // 小程序里用 canvas.requestAnimationFrame canvas.requestAnimationFrame(animate); }0.005 这个值大约相当于每秒转 17 度,一圈 20 秒左右,看起来比较舒服。太快了像陀螺,太慢了感觉卡住了。用户触摸的时候暂停自动旋转,松手后恢复,这个逻辑用一个标志位控制就行。
5.3 接入业务数据驱动模型变化
实际项目里 3D 模型往往不是孤立的,要和业务数据联动。比如电商场景,用户选了红色款,模型材质要跟着变;选了不同配置,模型要显示不同的部件。
改造思路是把模型的材质和部件做成可配置的:
// 根据业务数据更新模型外观 function updateModelAppearance(model, config) { model.traverse((child) => { if (child.isMesh) { // 按部件名称匹配配置 const partConfig = config[child.name]; if (partConfig) { if (partConfig.color) { child.material.color.set(partConfig.color); } if (partConfig.visible !== undefined) { child.visible = partConfig.visible; } } } }); }traverse遍历所有子节点,isMesh判断是不是可渲染的网格。按child.name匹配配置,这个 name 是模型导出时给每个部件起的名字,所以建模阶段就要和设计约定好命名规范,不然后面对不上号。
5.4 性能优化:什么时候该收手
3D 渲染在小程序里是性能大户,低端机上很容易掉帧。几个实用的优化手段:模型面数控制在 5 万面以内,纹理尺寸不超过 1024x1024,关闭不必要的阴影和反射。如果还卡,就降低渲染分辨率——把renderer.setPixelRatio设成 1 而不是设备的真实像素比,画面会糊一点但流畅度明显提升。
我自己的习惯是,每次改完渲染相关的代码,一定在低端 Android 真机上跑一遍再提交。模拟器上流畅不代表真机流畅,这个血泪教训踩过太多次了。从那以后我每次接 3D 相关的需求,都强制走一遍「开发工具 → 中端机 → 低端机」的验证流程,宁可多花半小时,也不想上线后被用户投诉卡顿。希望帮到你。
本文还有配套的精品资源,点击获取