微信小程序3D模型展示实战:Three.js适配与性能优化
2026/9/9 0:31:14 网站建设 项目流程

简介:这是一份专注于解决微信小程序内3D模型展示问题的Three.js示例工程,面向需要在微信端实现产品/场景3D预览、或初次尝试小程序+Three.js组合的开发者。示例以最小可运行Demo的方式,展示了从模型加载、场景构建到页面引用的完整流程。整个资源共18个文件,压缩包体积仅约258KB。文件构成上以JS逻辑为主(8个),负责场景渲染、模型加载和动画控制;5个JSON为小程序配置,2个WXML与2个WXSS分别定义页面结构和样式,另有1个GLTF格式的虾模型作为演示素材。目录按小程序常见结构组织,便于定位和修改。目前已有2300人学习或浏览,对于入门类示例而言具备不错的参考热度。源码内含完整项目结构和可直接运行的虾模型,通过调整JS中的函数可快速实现旋转、掉落等3D动画效果;也可将GLTF模型上传到自有服务器,再在WXML中通过URL引用,灵活适配实际项目。整体代码量精简、上手门槛低,能帮助开发者避开小程序端Three.js的常见坑点,适合作为二次开发和教学演示的基础模板。 说实话,微信小程序里搞3D模型展示这件事,我一开始是拒绝的。那时候小程序对WebGL的支持刚开放不久,社区里能搜到的资料少得可怜,官方文档里关于Canvas的说明也停留在2D阶段,真机上能不能跑起来全凭运气。但是做电商类小程序的朋友应该能理解,商品详情页里放一组静态图已经满足不了需求了,用户想拖动看细节,想看角度变化,这时候3D展示几乎是唯一解。所以从评估可行性到最终上线,我在这个方向上踩了一堆坑,也沉淀了一些能直接用的经验,这篇文章就把整个链路从头到尾拆一遍。

不管你是想在小程序里展示家具家电、鞋子包包,还是做工业零部件的在线预览,只要核心需求是“让用户像把玩实物一样旋转查看模型”,这篇文章涉及的方案就都适用。

1. 为什么要在小程序里上Three.js:方案选型与思路

很多人的第一反应是:小程序有原生的3D组件吗?答案是没有。小程序官方只提供Canvas组件,而Three.js本质是一个基于WebGL的3D渲染引擎,它能在Canvas上创建WebGL上下文并执行GPU渲染。小程序的Canvas组件从基础库2.7.0开始支持type="webgl",这就给Three.js留下了运行空间,但也仅仅是有空间,中间还隔着好几层适配问题。

1.1 小程序的WebGL基础与Three.js适配原理

浏览器里用Three.js,核心是创建一个HTMLCanvasElement,然后调用canvas.getContext('webgl')。小程序里的Canvas不是DOM节点,它暴露的是小程序自有的API,比如wx.createCanvas、canvas.requestAnimationFrame等。所以直接把浏览器版Three.js塞进小程序里是跑不起来的,缺的是一层“翻译官”。

目前社区里最成熟的方案是官方维护的threejs-miniprogram适配库。它干的事情就是接管Three.js内部对WebGL接口的调用,把标准的WebGL方法映射到小程序Canvas的底层能力上。使用方式是调用createScopedThreejs传入小程序的canvas节点,拿到一个被适配过的THREE对象,后续的用法和浏览器端基本一致。

import { createScopedThreejs } from 'threejs-miniprogram'; Page({ onReady() { wx.createSelectorQuery() .select('#myCanvas') .node() .exec((res) => { const canvas = res[0].node; const THREE = createScopedThreejs(canvas); // 后续创建Scene、Camera、Renderer }); }, });

这段代码是整个项目的地基。有一点要注意:canvas节点必须指定type="webgl",不指定的话默认是2d,createScopedThreejs拿到的上下文就是错的,后续所有渲染都会白屏。

1.2 WebView方案与原生Canvas方案的取舍

除了原生Canvas方案,还有一种常见做法是用web-view内嵌一个H5页面,在H5里正常跑Three.js。这两种方案的优劣很明显,我直接列个对比表格:

对比维度WebView内嵌H5原生Canvas适配
渲染性能受WebView容器限制,GPU调用容易不稳定直接走小程序Canvas,性能更可控
交互联动和原生页面通信麻烦,需要用JSSDK桥接直接监听小程序Canvas的touch事件
模型加载正常浏览器加载方式,比较省事需要适配远程资源下载与解析
包体积影响几乎没有会增加threejs等依赖体积
审核风险部分类目对web-view有限制无额外限制

我在项目初期也考虑过H5方案,但因为业务需要在小程序内完成完整的商品3D展示,并且后续还要接入AR试戴功能,H5方案在交互和权限上的限制太多,最终选择了原生Canvas适配。如果你只是快速验证效果,H5方案确实省事;但如果要做成正式功能,原生方案是更稳妥的路。

2. 基础环境配置:从零搭起可运行的3D小程序

方案定了就开始搭环境。这里有个好消息和一个坏消息。好消息是threejs-miniprogram这个库已经处理了大部分兼容性问题;坏消息是,如果你习惯用npm管理依赖,小程序里直接装three和threejs-miniprogram后,编译时会遇到各种exports报错,因为你只用了适配库暴露的THREE对象,它并不包含Three.js的全部功能,只是裁剪后的核心模块。

2.1 依赖引入方式:npm还是手动拷贝

我强烈建议采用“npm构建+手动拷贝”的混合方式。具体操作是:

  • 全局安装three和threejs-miniprogram。
  • 在开发者工具中点击“工具—构建npm”。
  • 在代码里通过require引用threejs-miniprogram,不要引用three主包。

原因很好理解:three官方npm包的入口指向的是ES Module版本,小程序开发者工具和老版本的webpack在处理ESM的浏览器端API时会有兼容问题。而threejs-miniprogram是专门为小程序打包过的,它的产物是CommonJS格式,构建后可以直接跑。如果你需要GLTFLoader等扩展模块,也不要直接import 'three/examples/jsm/loaders/GLTFLoader.js',因为在适配环境下它会依赖window、XMLHttpRequest等对象,小程序里没有。后面我会说更靠谱的加载方式。

2.2 关键配置项:Canvas类型、编译选项与组件结构

在page.json里,有几项是必须配置的:

{ "disableScroll": true, "navigationBarBackgroundColor": "#000000", "navigationBarTextStyle": "white" }

disableScroll很重要,默认情况下页面可以上下滑动,用户在滑动查看模型时会引起页面滚动,破坏体验。这里的意思是通过禁止页面滚动来保持3D操作区域的稳定。

WXML结构也有讲究:

<view class="canvas-wrapper"> <canvas type="webgl" id="modelCanvas" class="model-canvas" bindtouchstart="onTouchStart" bindtouchmove="onTouchMove" bindtouchend="onTouchEnd"> </canvas> </view>

canvas的宽高不要直接写在标签属性里,用CSS控制。在JS里通过canvas.clientWidth和canvas.clientHeight获取逻辑尺寸,然后乘以设备像素比dpr来设置canvas的实际像素尺寸,否则高分屏下会模糊。

const info = wx.getSystemInfoSync(); const dpr = info.pixelRatio; canvas.width = canvas.clientWidth * dpr; canvas.height = canvas.clientHeight * dpr; renderer.setPixelRatio(dpr); renderer.setSize(canvas.clientWidth, canvas.clientHeight);

这段代码放onReady里,在拿到canvas节点后立刻执行。我第一次做的时候忘了设置canvas.width和height,结果真机上一片糊,字都看不清,后来才发现问题是出在像素尺寸没有适配高分屏。

2.3 第一个Three.js场景的完整代码示例

在适配环境下,创建场景、相机、渲染器的方式和浏览器端几乎一样,但也有几个小坑。比如requestAnimationFrame,在适配库内部已经处理了,你直接调用THREE对象里的方法即可。下面是一个能跑通的最小场景:

import { createScopedThreejs } from 'threejs-miniprogram'; Page({ data: {}, onReady() { wx.createSelectorQuery() .select('#modelCanvas') .node() .exec((res) => { const canvas = res[0].node; const info = wx.getSystemInfoSync(); const dpr = info.pixelRatio; canvas.width = canvas.clientWidth * dpr; canvas.height = canvas.clientHeight * dpr; const THREE = createScopedThreejs(canvas); const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(45, canvas.clientWidth / canvas.clientHeight, 0.1, 1000); camera.position.set(3, 3, 3); camera.lookAt(0, 0, 0); const renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); renderer.setPixelRatio(dpr); renderer.setSize(canvas.clientWidth, canvas.clientHeight); renderer.shadowMap.enabled = true; // 灯光 const ambientLight = new THREE.AmbientLight(0xffffff, 0.5); scene.add(ambientLight); const directionalLight = new THREE.DirectionalLight(0xffffff, 1); directionalLight.position.set(5, 10, 7); scene.add(directionalLight); // 一个简单的立方体 const geometry = new THREE.BoxGeometry(1, 1, 1); const material = new THREE.MeshStandardMaterial({ color: 0x3f7cff }); const mesh = new THREE.Mesh(geometry, material); scene.add(mesh); const animate = () => { mesh.rotation.y += 0.02; renderer.render(scene, camera); canvas.requestAnimationFrame(animate); }; animate(); }); }, });

这里有个容易忽略的细节:animate循环里的canvas.requestAnimationFrame,而不是全局的requestAnimationFrame。小程序页面在后台时,canvas的requestAnimationFrame会暂停,这是符合预期的;但如果你用了全局的requestAnimationFrame,页面切后台再回来,渲染循环可能已经乱了。

3. 3D模型加载与交互实操

搭好场景只是第一步,真正的核心是加载真实业务中的3D模型。这一步的问题最多,也最考验经验。

3.1 模型格式选型:为什么推荐glb

Three.js原生支持很多格式,obj、fbx、gltf、glb等。但在小程序环境下,我强烈推荐GLB格式。原因有三:

  • GLB是二进制格式,一个文件内包含模型网格、材质、纹理贴图,不需要像GLTF那样在外部单独加载bin文件和纹理图,大大减少了网络请求数量。
  • 二进制文件可以直接用arraybuffer读取,非常适合小程序wx.request的responseType="arraybuffer"。
  • 转换工具成熟,Blender、3ds Max、在线转换工具都能一键导出,而且市面上大多数3D模型平台的下载格式本身就支持glb。

选择GLB的一个重要原因是:小程序里发起多个HTTP请求的成本很高,而且每个请求的耗时和失败率都会增加,GLB把所有数据打包在一个文件里,能让加载逻辑简单很多。

3.2 GLTFLoader在小程序中的“魔改”用法

标准的GLTFLoader是依赖浏览器的XMLHttpRequest来做资源加载的,小程序里没有这个对象。我在项目里尝试过直接new THREE.GLTFLoader(),结果报错“XMLHttpRequest is not defined”。

解决方案是自己用wx.request下载模型数组缓冲,再把arraybuffer交给GLTFLoader的parse方法解析。具体流程是:

const loader = new THREE.GLTFLoader(); wx.request({ url: 'https://your-cdn.com/model.glb', responseType: 'arraybuffer', success(res) { const arrayBuffer = res.data; loader.parse( arrayBuffer, '', (gltf) => { const model = gltf.scene; scene.add(model); initModelInteraction(model); }, (error) => { console.error('模型解析失败', error); } ); }, fail(err) { console.error('模型下载失败', err); }, });

注意loader.parse的第二个参数是路径用于解析外部资源,这里传空字符串即可,因为GLB所有资源都在内部。

还有一点很关键:模型加载完成后要处理坐标系和比例问题。很多3D建模工具默认是Y轴向上或者Z轴向上,导入Three.js场景后方向可能不对。如果需要把所有模型调整为面向用户的状态,一般需要统一在代码里做旋转和缩放。

model.rotation.x = -Math.PI / 2; // 有的模型需要绕X轴旋转-90度 model.scale.set(0.01, 0.01, 0.01); // 如果模型单位是厘米

这个设置不是固定的,每个模型导出来都不一样。我建议在开发时做一个“模型调试页”,加载模型后通过滑块实时调整位置、旋转、缩放,把理想参数记下来,再硬编码到正式代码中。

3.3 模型交互:旋转、缩放、单指旋转双指缩放

小程序里没有鼠标,触控交互需要自己实现。监听canvas的touch事件,用两个手指距离变化控制缩放,单指滑动控制旋转。这段逻辑不算复杂,但细节很影响手感。

let currentRotationX = 0; let currentRotationY = 0; let currentScale = 1; let lastTouchDistance = 0; onTouchStart(e) { const touches = e.touches; if (touches.length === 2) { const dx = touches[0].clientX - touches[1].clientX; const dy = touches[0].clientY - touches[1].clientY; lastTouchDistance = Math.sqrt(dx * dx + dy * dy); } this.startX = touches[0].clientX; this.startY = touches[0].clientY; }, onTouchMove(e) { const touches = e.touches; if (touches.length === 2) { const dx = touches[0].clientX - touches[1].clientX; const dy = touches[0].clientY - touches[1].clientY; const distance = Math.sqrt(dx * dx + dy * dy); const delta = distance / lastTouchDistance; currentScale *= delta; currentScale = Math.max(0.5, Math.min(5, currentScale)); // 限制缩放范围 model.scale.set( currentScale * baseScale, currentScale * baseScale, currentScale * baseScale ); lastTouchDistance = distance; return; } const deltaX = touches[0].clientX - this.startX; const deltaY = touches[0].clientY - this.startY; currentRotationY += deltaX * 0.01; currentRotationX += deltaY * 0.01; model.rotation.y = currentRotationY; model.rotation.x = currentRotationX; this.startX = touches[0].clientX; this.startY = touches[0].clientY; },

滑块限制范围这步不能省。如果不限制,用户连续双指放大之后,模型可能被放到一个非常夸张的大小,再想缩回来就难了,体验很差。

还有一个隐藏的控制:旋转灵敏度0.01这个值需要根据模型大小和相机距离微调。模型大、相机近时,灵敏度要降低;模型小、相机远时,要适当提高。不同模型用同一套参数是行不通的。

4. 性能优化:在低端机上也能跑得动

小程序3D展示最大的敌人不是功能实现,而是性能。用户手机从几千块的旗舰到几百块的低端机都有,如果低端机上卡成PPT,这个功能基本就废了。

4.1 模型面数与纹理尺寸的把控

模型面数直接影响draw calls和GPU负载。我建议:

  • 手机展示场景下,单个模型的面数控制在10万以内。
  • 如果模型是精细化工业零件,面数可能上百万,那就需要考虑减面优化,比如在Blender里用Decimate修改器把面数砍到合理范围。
  • 纹理贴图尺寸不要超过2048x2048,一般1024x1024足够使用,尺寸再大对视觉提升有限,但内存和带宽开销翻倍。

一个反面案例是,我接过一个矿车车架的模型,面数有60多万,纹理最大的一张用到了4096x4096,在开发工具里调试还挺流畅,结果一发到iPhone XR上就发热,GPU占用率接近100%,滑动手感极其卡顿。后来把模型从60万面降到8万,纹理切成1024,效果立刻好了。

4.2 数据包体积治理:gzip、远程CDN与分包策略

小程序主包体积限制是2MB,你不可能把模型文件放进代码包里。推荐的做法是:

  • 模型放在CDN服务器或者云存储上,通过HTTPS接口动态加载。
  • 服务端开启gzip或brottli压缩,GLB文件通常能压缩掉50%以上。比如一个30MB的GLB,gzip后可能只有不到15MB。
  • 如果模型特别大,建议做LOD分级加载,先加载低精度版本,用户放大时再加载高精度版本。

负载均衡的CDN也很重要。国内网络环境复杂,如果没有CDN,用户跨运营商访问你的服务器,加载一个30MB模型可能要等好几秒,体验非常糟糕。我一般用云厂商的对象存储来存模型文件,开启CDN加速,该花的钱不能省。

如果模型中包含材质贴图,纹理文件也要走CDN。还要注意小程序request的域名白名单,一定要在开发的时候就在小程序管理后台把CDN域名加到downloadFile合法域名列表里去,否则正式环境会直接请求失败。开发工具调试时可以在“详情—本地设置”里勾选“不校验合法域名”,这只是临时方案,上线前必须配好。

4.3 合并几何体与渲染循环管理的实用技巧

Three.js性能优化的核心是降低draw calls。一个draw call就是GPU的一次绘制调用,调用越多性能越差。一个模型如果有很多独立几何体,那么draw calls就会偏高。

在Three.js中可以使用BufferGeometryUtils.mergeBufferGeometries把多个几何体合并成一个。

const bufferGeometryUtils = require('three/examples/jsm/utils/BufferGeometryUtils.js'); const mergedGeometry = bufferGeometryUtils.mergeBufferGeometries([ geometry1, geometry2, geometry3, ]); const mergedMesh = new THREE.Mesh(mergedGeometry, material); scene.add(mergedMesh);

注意合并几何体有一个代价:合并后整个模型只能用同一个材质,不能分别设置颜色,所以如果你的模型需要不同部分不同材质,合并方案就不适用了。这种情况下可以考虑使用多个材质通道,但复杂度会增加。

渲染循环管理也很关键。页面onHide时要暂停渲染,onShow时恢复,否则后台时canvas仍然在耗电,容易被微信后台杀掉,也影响用户体验。

onHide() { this.isRendering = false; }, onShow() { this.isRendering = true; this.animate(); },

实际上更保险的做法是在onHide里调用canvas.cancelAnimationFrame取消当前帧,onShow时重新启动循环。

5. 常见问题与排查实录

做这个功能的过程中,我整理了多个高频问题,这里直接给出速查表,方便你对照排查。

现象可能原因解决方案
真机黑屏,模拟器正常canvas的type没有设置为webgl检查wxml里canvas的type属性
模拟器白屏threejs-miniprogram版本与three版本不兼容升级到最新版;尽量采用官方demo的依赖组合
模型加载失败域名未加入downloadFile白名单开发工具临时勾选不校验域名;上线前配好合法域名
模型变形错乱坐标系朝向不同手动调整model.rotation.x/-y/z,使用调试页找到正确值
模型比例太大或太小建模所用单位与Three.js不同设置正确的scale值,通常0.01或100
真机卡顿、发热面数过多、纹理过大减面、压缩纹理、合并几何体
双指缩放不灵敏touchmove事件处理中距离计算错误检查lastTouchDistance更新逻辑
页面切后台再回来动画停止渲染循环被暂停后未恢复onShow时重新启动animate循环
分包加载模型失败wx.loadSubpackage回调时机不对在成功回调里再发起模型渲染
5.1 真机白屏的终极排查思路

真机白屏这个坑我踩了两次,第一次是canvas没加type="webgl",第二次是threejs-miniprogram版本和three版本不匹配。第二种情况的排查方式很蠢,浪费了我一整天:在开发者工具里一切正常,真机预览就白屏,仔细看控制台也没有报错。后来我把threejs-miniprogram和three的版本号锁定为与官方demo相同的版本组合,问题才解决。

这里分享一个排错工艺流程:先做静态排查,确认wxml的canvas标签有type="webgl";再做动态排查,在canvas上放一个半透明背景遮罩,如果还能看到遮罩,说明canvas没有渲染内容;最后做依赖排查,直接拉一个官方demo跑真机,如果官方demo也白屏,说明依赖版本不匹配或者基础库版本过低。

5.2 模型加载失败与内存泄漏

GLB文件加载成功后,解析和渲染都会占用大量内存。如果你在快速切换模型时不做清理,内存会持续上升,最终导致页面卡死闪退。最简单的处理方式是在加载新模型前,把旧模型从场景中移除,并调用dispose释放几何体和材质。

function removeModel(model) { scene.remove(model); model.traverse((child) => { if (child.geometry) { child.geometry.dispose(); } if (child.material) { if (Array.isArray(child.material)) { child.material.forEach((mat) => mat.dispose()); } else { child.material.dispose(); } } }); }

内存泄漏这个问题在开发工具里很难发现,因为桌面端内存充足,看起来一切都好。真机上跑几个来回就会暴露,所以建议真机多模型切换测试时重点观察内存占用。

6. 一些更进阶的扩展思路

如果你的项目不只是“转一转看一看”,那这段可能对你有帮助。

第一个扩展方向是热点标注。在模型上挂几个一定偏移量的小球体或者标签,用户点击标签时可以弹出对应部件的说明。实现思路是在3D场景中创建Sprite或者Plane几何体,把位置转换到屏幕坐标后用覆盖层渲染。这样可以把静态的商品展示变成有信息层次的交互体验。

第二个方向是AR预览。小程序提供了VKSession等AR能力,可以把3D模型放在真实场景中体验。虽然目前的API对开发者门槛还有些高,但思路是可行的:在相机画面中叠加模型,让用户看到“这个沙发摆在我家客厅是什么效果”。这也是电商业务里转化率最高的玩法之一。

第三个方向是模型自动旋转与热点自动播放。在商品详情页,用户进入页面后先让模型自动旋转一圈,给用户一个整体印象,然后才进入手动交互模式。这个需求的实现很简单,就是在animate函数里判断用户是否触碰过模型,如果没碰过,就自动叠加旋转角。

最后说点实在的

折腾了一圈,我的感受是:小程序3D展示核心难点其实不在Three.js本身,因为WebGL渲染逻辑和浏览器端基本一样。真正花时间的是小程序环境的适配、资源加载策略和低端机性能调优。你如果只想着“能跑就行”,走H5内嵌方案可能几天就能出效果;但你要做正式上线、追求交互一致性,还是得走原生Canvas这条路。

我个人建议,预算充足且业务量大的团队,可以观望一下小程序官方是否推出更完善的3D能力,毕竟WebGL在小程序里的性能天花板摆在那里,复杂场景终究受限。但如果你的需求只是单模型展示、交互旋转、缩放,这套路线目前已经相当成熟,值得投入。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询