ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

微信小程序3D动画实战:Three.js与GSAP的最佳组合方案

微信小程序3D动画实战:Three.js与GSAP的最佳组合方案 微信小程序里的3D动画一直是我做前端这几年最不愿意碰的领域之一。倒不是说Three.js本身难而是小程序和Web标准环境之间隔着太多“墙”没有直接可用的window没有常规的DOMWebGL上下文拿到的方式和浏览器里完全不一样。可需求来了躲不掉今年接了个互动小程序核心玩法就是3D展示加交互动画我硬着头皮把Three.js和gsap整套跑通之后发现这条路其实完全能走而且只要把几个关键点理顺开发体验和最终动效都不会差。这篇文章就围绕“小程序 Three.js gsap”这个组合把我从环境准备到上线实测的完整过程、踩过的坑、优化思路都写出来。内容比较长重点放在能让读者直接参考复现的实操方案上适合已经会基础Three.js、但没在小程序里跑过3D的开发者也适合准备做小程序3D营销页、商品展示或者小游戏的朋友。1. 为什么是“Three.js gsap”这个组合1.1 小程序里的3D动画到底卡在哪很多团队会把小程序3D特效做成“假3D”用序列帧图片连续播放或者直接用CSS的transform: rotateY/rotateX模拟旋转。这两种方案在小体量场景下确实省事序列帧兼容性高CSS 3D在低端机上还能靠原生层做合成。但一旦你需要的不是“看起来像3D”而是真正的光照、阴影、物理碰撞、层级遮挡那序列帧和CSS就完全不够用了。真正的3D引擎在小程序里最现实的选择就是Three.js。小程序环境对Three.js的不友好主要在三块没有浏览器那套window、document、Image对象Three.js官方包直接运行会报错。Canvas获取方式不同需要通过SelectorQuery拿到canvas节点再调用canvas.getContext(webgl)或者node.getContext(webgl)。包体积限制敏感Three.js核心体积本身不小再叠加业务代码很容易触碰主包2MB限制。所以带延伸的渲染任务只有用WebGL方案这一条路。Three.js在这一层做得已经很成熟它并不强制依赖DOM操作只要给它一个能得到WebGL上下文的canvas引用其他事情基本都能在小程序里跑。1.2 为什么渲染选Three.js动效编排选gsapThree.js负责的是“每一帧把画面画出来”但它本身不会帮你编排“什么时候转、转多快、先做什么后做什么”。默认的requestAnimationFrame循环只管持续渲染动画状态需要自己控制。gsap的价值在于它把动画从代码状态机里解放出来了它是纯JavaScript动画库不依赖DOM。时间线Timeline机制极其强大能把多个动画按顺序和重叠关系编排得清清楚楚。缓动函数非常丰富特别是Back.easeOut、Elastic.easeOut这类物理感很强的缓动3D转场里的手感基本全靠它撑起来。简单说Three.js是“手”gsap是“指挥”。我用gsap去修改Three.js对象的position、rotation、scale、相机位置、材质颜色、甚至可以每个tick去控制一个Unity的插值进度。这种模式在浏览器里常见放到小程序里只需要改一点点适配细节核心用法不变。1.3 这个组合适合什么场景从我实际经验看下面这些场景最值得用“小程序Three.jsgsap”商品3D展示比如鞋、箱包、电子产品的360度旋转看款配合gsap做自动旋转和用户拖拽后的惯性回弹。品牌营销互动页3D logo、粒子动画、开屏旋转配合Timeline做分步触发能做出很强的仪式感。轻量化小游戏/互动比如3D拼图、模型拆解、数字人简单动作只要模型面数控制得当都能跑得很顺。数字孪生小工具厂房设备、充电桩、家居空间的简单3D预览通过相机视角切换配合gsap缓动观感比平面图纸强很多。选型这事不能“一招鲜”。如果只是单个立体图标旋转用CSS 3D就够了如果涉及十几万面的建筑模型、多种材质贴图、复杂交互还是尽早考虑原生或者更大的方案。Three.jsgsap适合的是“中等复杂度、动画节奏强、强叙事型”的3D页面。2. 环境准备和微信小程序WebGL的边界2.1 创建Canvas并获取WebGL上下文小程序里使用WebGL先要在WXML里放一个Canvas组件关键属性是typewebgl。我本地实测的微信基础库版本是3.0.2以上同层渲染已经比较稳定不需要像老版本那样做一堆真机兼容。view classpage-root canvas idglCanvas typewebgl classgl-canvas/canvas /view在JS侧挂载的时候不能用wx.createCanvasContext那是2D Canvas用的。WebGL场景下要这样拿节点initCanvas() { const query wx.createSelectorQuery(); query.select(#glCanvas) .fields({ node: true, size: true }) .exec((res) { if (!res || !res[0] || !res[0].node) { console.error(WebGL canvas无效); return; } const canvas res[0].node; const width res[0].width; const height res[0].height; const gl canvas.getContext(webgl); if (!gl) { console.error(当前设备/基础库不支持WebGL); return; } this.canvas canvas; this.gl gl; this.initThree(); }); }注意几个细节fields里必须写node: true否则拿不到Canvas节点对象。Canvas尺寸和实际像素尺寸要区分。res[0].width返回的是逻辑像素WebGL渲染器需要按devicePixelRatio成倍设置否则画面会糊。有些安卓低端机只支持webgl不支持experimental-webgl不用额外传这个上下文参数Canvas组件会自动给最合适的。2.2 Three.js版本和引入方式的选型小程序里直接用npm安装three最新版然后import * as THREE from three在大多数情况下编译会报错因为Three.js的构建产物带了window、document的引用。社区有几个处理方案使用官方适配层threejs-miniprogram这是比较省事的方式。它内置了对小程序的适配包一层专用的registerCanvas逻辑示例代码能直接跑起来。自己动手构建Three.js子集只从three核心模块里按需引入需要的部分配合打包工具打出一个不引用DOM的版本。用老版本Three.js比如r0.147左右源代码里对全局对象的引用相对少一些再配合weapp-adapter补充缺失的API。我最后采用的是“threejs-miniprogram 从Three.js仓库里抽必要的文件”的方案。threejs-miniprogram本质上是转译后的Three.js模块用法上差别不大import * as THREE from threejs-miniprogram; import { registerCanvas } from threejs-miniprogram; const canvas res[0].node; const { width, height } res[0]; registerCanvas(canvas, width, height);这个适配层解决的正是“THREE在无window环境下上下文创建”的问题。我没用最新的three主分支因为小程序不是浏览器很多API比如THREE.FontLoader、CSS3DRenderer在小程序场景下没意义带上只会增加包体积。2.3 gsap在小程序里的引入技巧GSAP其实比Three.js更容易跑进小程序因为它底层只依赖requestAnimationFrame。小程序里是有requestAnimationFrame的不过是在Canvas节点上而不是全局。直接在全局调用requestAnimationFrame低版本可能没有需要用兼容垫片if (typeof globalThis.requestAnimationFrame ! function) { globalThis.requestAnimationFrame (cb) { return setTimeout(() cb(Date.now()), 1000 / 60); }; globalThis.cancelAnimationFrame (id) clearTimeout(id); }我这个项目的做法是引入gsap的ticker来驱动更新但需要让gsap知道用哪个时间源。官方在非浏览器环境里可以用gsap.ticker.lagSmoothing(0)并且手动在渲染循环里调用gsap.update()。翻一下源码就清楚gsap.update()更新的是globalTimeline的时间线状态只要每帧调用它就能驱动所有tween和timeline往前走。import gsap from gsap; // 禁用gsap内置ticker的自动刷新 gsap.ticker.lagSmoothing(0); gsap.ticker.autoSleep false; // 每帧手动刷新 function tick() { const now Date.now(); gsap.updateRoot(now); gsap.ticker.update(now); renderer.render(scene, camera); rafId requestAnimationFrame(tick); } tick();一个小坑是如果你在小程序里同时使用Three.js自带的渲染循环和gsap.ticker会有两套ticker同时跑一旦它们对当前时间的判定不一致动画就会忽快忽慢。推荐做法就是像我上面写的所有动画都由gsap统一驱动Three.js的renderer.render放在同一帧的后面执行。3. 渲染管线和基础场景的搭建3.1 组件生命周期和Canvas尺寸同步小程序组件里attached时不能立刻操作Canvas因为WXML节点可能还没渲染完成。比较稳的做法是在ready生命周期里获取Canvas并同步页面尺寸。Component({ ready() { this.initCanvas(); this.bindResize(); }, detached() { this.disposeScene(); } });尺寸那块我建议不要硬编码宽高。直接根据页面可视区域计算同时考虑windowWidth和windowHeight。如果做了自定义导航栏还要把导航栏高度减去不然3D画面会被刘海屏或胶囊按钮遮挡。initSize() { const { windowWidth, windowHeight } wx.getWindowInfo(); const menuInfo wx.getMenuButtonBoundingClientRect(); const capsulePadding 8; const sceneTop menuInfo.height menuInfo.top capsulePadding; const sceneHeight windowHeight - sceneTop; return { width: windowWidth, height: sceneHeight }; }这是我这版设计里的关键点可交互区域只占页面下半部分上半部分留给固定的导航和产品信息避免3D场景与原生UI互相重叠。3.2 场景、相机、渲染器的初始化和适配层问题创建Three.js场景属于常规操作但因为环境特别有几个点需要格外注意initThree() { const { width, height } this.initSize(); const dpr wx.getWindowInfo ? wx.getWindowInfo().pixelRatio : 2; this.renderer new THREE.WebGLRenderer({ canvas: this.canvas, antialias: true, alpha: true, // 背景透明方便透出页面底图 }); this.renderer.setPixelRatio(Math.min(dpr, 2)); this.renderer.setSize(width, height); this.renderer.outputColorSpace THREE.SRGBColorSpace; this.renderer.toneMapping THREE.ACESFilmicToneMapping; this.renderer.toneMappingExposure 1.0; this.scene new THREE.Scene(); this.camera new THREE.PerspectiveCamera(35, width / height, 0.1, 100); this.camera.position.set(0, 0, 8); this.scene.add(this.camera); const ambientLight new THREE.AmbientLight(0xffffff, 0.8); this.scene.add(ambientLight); const directionalLight new THREE.DirectionalLight(0xffffff, 2.5); directionalLight.position.set(3, 5, 4); this.scene.add(directionalLight); }setPixelRatio是帧率最大的敌人之一。iPhone的dpr普遍是3如果完全不限制渲染压力直接乘以9倍。项目里我强制把像素比压到Math.min(dpr, 2)画面清晰度足够帧率能稳很多。小米、华为一些dpr特别高的机型甚至建议压到1.5这点在后面性能部分还会展开说。3.3 加载glTF模型时绕过DOM依赖小程序里没有Image对象这导致Three.js很多加载器比如TextureLoader、GLTFLoader无法直接用官方版本。我的处理方式是贴图用canvas手动创建ImageData再调用THREE.Texture转为纹理或者将图片转为Base64用一种自定义的纹理解析方式填到PBR材质里。模型选择FaceMesh或简单BufferGeometry不走glTF加载器而是把模型数据作为静态JSON打包进代码里。这样最稳。如果一定要加载外部glTF需要绕行在小程序里通过wx.request请求文件拿到ArrayBuffer然后手动解析GLTFLoader依赖Blob、URL的部分。工作量有点大一般不建议轻易尝试。我做的这个项目产品模型是一个带金属质感的保温杯面数不多我直接手工构建了圆柱圆环瓶盖的几何体组合再用PBR材质模拟质感效果和实际产品图几乎一致但是完全避开了模型加载这个坑。createCup() { const bodyMat new THREE.MeshPhysicalMaterial({ metalness: 0.9, roughness: 0.15, color: 0x555555, clearcoat: 1, clearcoatRoughness: 0.1, }); const bodyGeo new THREE.CylinderGeometry(1.2, 1.2, 2, 64); const body new THREE.Mesh(bodyGeo, bodyMat); body.position.y 0; const capGeo new THREE.CylinderGeometry(1.2, 1.2, 0.3, 64); const cap new THREE.Mesh(capGeo, bodyMat); cap.position.y 1.15; const group new THREE.Group(); group.add(body, cap); this.cupGroup group; this.scene.add(group); }材质方面小程序端不能盲目上4K贴图我全程用程序化材质低分辨率贴图把显存占用控制得很低。4. 用gsap编排交互动画4.1 从浏览器思维切换到时间线思维浏览器里做Three.jsgsap动画大家习惯这么写gsap.to(cube.rotation, { y: Math.PI * 2, duration: 2, repeat: -1, ease: power1.inOut, });这个写法直接搬到小程序里也能跑但有个问题你一旦写了多条互不相关的to动画很容易出现动画状态互相冲突比如一个在转角度另一个在缩放结束之后恢复原状态时乱套。小程序3D动效里我更推荐用gsap.timeline()做总的编排把用户滑动、点击、倒计时等触发统一交到时间线里。下面是一个“开屏自动旋转 - 用户点击后拉近视角 - 循环呼吸动画”的完整编排// 主时间线 const tl gsap.timeline({ defaults: { ease: power3.out }, paused: true, }); tl.to(this.camera.position, { z: 7, duration: 1.5, }, 0) .to(this.cupGroup.rotation, { y: Math.PI * 2, duration: 3, ease: power1.inOut, }, 0) .to(this.camera.position, { z: 3.5, y: 0.4, duration: 1.2, }, 1.5) .to(this.cupGroup.scale, { x: 1.08, y: 1.08, z: 1.08, duration: 1.4, yoyo: true, repeat: -1, ease: sine.inOut, }, 2.7); this.mainTimeline tl; tl.play();注意这里相机的position和模型的rotation、scale都是Three.js对象属性gsap直接改这些属性的数值是没问题的。关键在于一个Timeine里的动画在同一个全局时间进程下走不会出现两条动画各自为战的调度误差。4.2 用户手势和gsap动画的协调小程序里监听手势非常直接绑定touchstart、touchmove、touchend到Canvas所在View上。Touch事件拿到的是屏幕坐标转换成Three.js世界坐标需要做个简单的射线拾取或坐标映射。我这里做了一个比较常见的“360度旋转展示”用户拖拽时模型跟随手指转动松手后不立刻停止而是带着gsap的惯性缓动继续转一段距离再停下。onTouchMove(e) { const dx e.touches[0].clientX - this.lastX; this.cupGroup.rotation.y dx * 0.01; this.lastX e.touches[0].clientX; if (this.mainTimeline) this.mainTimeline.pause(); }关键点一旦用户开始拖拽就把原本自动播放的主时间线暂停保证手动操作不被状态机抢走。拖拽结束后用一个gsap.to做惯性回弹而不是直接修改rotation这样手感更接近原生物理惯性。onTouchEnd(e) { const velocity this.deltaX / this.deltaT; gsap.to(this.cupGroup.rotation, { y: this.cupGroup.rotation.y velocity * 0.2, duration: 1, ease: power2.out, }); }有些开发者会直接在touchmove里改Three对象再接gsap.to结果对象被两套逻辑同时写出现闪烁。我的经验是用户交互阶段用原生事件直接改值松开后立刻用gsap接管并且维护一个isUserDragging标志位渲染循环里跳过gsap对rotation的写入。4.3 用gsap控制相机与材质相机动画在小程序3D里是“氛围感”的来源比如从产品细节慢慢拉远展现出品牌logo或者切视角到全局场景。用gsap控制相机比较简单本质就是改变camera.position和camera.lookAt的目标点但在时间线上需要多花心思因为直接to(camera.position)并不会同时更新lookAt方向。我的做法是同时to camera.position并在onUpdate里重新执行一次camera.lookAt(0, 0, 0)gsap.to(this.camera.position, { x: 3, y: 1, z: 5, duration: 2, ease: power2.inOut, onUpdate: () { this.camera.lookAt(0, 0, 0); }, });材质颜色的流动同样可以用gsap驱动。像杯身穿戴一波高光散射的动画用gsap控制PBR材质的roughness和metalness是有实感的gsap.to(this.cupBodyMaterial, { roughness: 0.5, metalness: 1.0, duration: 1.5, });这里有个小技巧gsap的动画对象不一定必须是THREE.Object3D只要是一个普通JavaScript对象且具备数值属性就能被tween。因此我们可以专门维护一个animState { progress: 0 }在onUpdate里用这个progress去计算贝塞尔曲线或者自定义插值这样能实现比gsap内置缓动更复杂的运动轨迹。就把它当成一个“本地状态机”比直接去动模型属性干净很多。5. 性能压榨让真机不掉帧5.1 小程序WebGL能用的性能预算微信小程序的WebGL跑在底层真机GPU之上但由于渲染线程、JS线程、以及系统WebView调度等原因实际能用的性能预算比浏览器里要低不少。以我测试的设备为例跨设备绘制性能差异很大设备机型档位像素比绘制面数平均FPSiPhone 14 Pro高端2.02万60稳定iPhone 11中端2.02万55左右小米 12中端2.02万50左右红米 Note 8中低端1.52万40左右一眼就能看出低端Android是性能瓶颈。模型面数一旦超过3万低端手机就掉到30帧以下了。所以最初建模或切模型时就要设计好复杂场景面数控制在1到2万以内单个模型尽量不做超过5000面的高模。5.2 降压力最有效的三件事按优先级排序我做过三轮性能优化第一压低像素比。红米Note 8这类设备如果dpr为3渲染面积是屏幕的9倍。我在真机上测试同一个场景dpr3只有22帧dpr1直接满帧60画质清晰度肉眼看差别不大。所以我统一做动态像素比按设备内存或GPU档位调整。const memory wx.getSystemInfoSync().benchmarkLevel; // 0-50之间越高性能越好 const maxDpr memory 40 ? 2 : 1.5; renderer.setPixelRatio(Math.min(getPixelRatio(), maxDpr));第二合并DrawCall。小程序环境GPU指令提交性能不好减少DrawCall往往比减少面数更有效。一个保温杯建模如果用多个几何体拼接会有很多个DrawCall。我把Static模型一次性合并到同一个BufferGeometry上把动态部分单独留出来效果立竿见影import { mergeGeometries } from three/examples/jsm/utils/BufferGeometryUtils.js; const mergedGeo mergeGeometries([bodyGeo, capGeo, someStaticGeo]);不过要注意合并之后就必须共用同一个材质否则不同材质还是会有多次DrawCall。我的做法是把整个杯身用同一个MeshPhysicalMaterial不同部位的视觉效果靠顶点色或UV区分。第三关掉不需要的阴影和实时光照。小程序里的后处理、阴影贴图、环境光贴图都会吃性能。我最终场景只用了一个DirectionalLight和一个AmbientLight阴影全关用PBR材质自带的明暗变化模拟体积感。这样画质虽然不如法师级渲染但帧率稳用户感知更好。5.3 页面卸载与资源释放小程序页面不是浏览器Tab用户在页面间切换时WebGL上下文会被系统回收但Three.js内部的对象、几何体、纹理还留在内存里。如果不释放会出现“切回页面后黑屏”“卡死”“下次进入越来越慢”的情况。正确做法是组件detached时遍历场景把每个Mesh的geometry.dispose()material里所有map、envMap、normalMap都dispose最后renderer.dispose()scene.traverse((child) { if (child.isMesh) { child.geometry?.dispose(); const mats Array.isArray(child.material) ? child.material : [child.material]; mats.forEach((mat) { if (mat.map) mat.map.dispose(); if (mat.envMap) mat.envMap.dispose(); if (mat.emissiveMap) mat.emissiveMap.dispose(); if (mat.aoMap) mat.aoMap.dispose(); if (mat.metalnessMap) mat.metalnessMap.dispose(); if (mat.roughnessMap) mat.roughnessMap.dispose(); mat.dispose(); }); } }); renderer.dispose();另外gsap动画循环也要停掉。我是在detached里执行this.mainTimeline?.kill(); gsap.killTweensOf(this.cupGroup);如果不做这一步gsap的回调会在页面销毁后依然尝试操作已经释放的Three对象轻则报错重则导致微信开发者工具卡死。6. 上线前的踩坑记录和排查手册6.1 黑屏问题的完整排查链路这个问题几乎每一个做小程序3D的人都会遇到。大概是这种场景真机预览正常但在某些机型上首次进入页面画布一直黑屏过几秒才出现画面或者在页面从后台回到前台后黑屏。当时我排查的链路是这样的确认Canvas大小不为0。小程序在早期有Canvas尺寸异步读取的问题特别是当页面存在自定义导航栏时wx.getWindowInfo拿到的窗口高度和Canvas真实渲染区域不一致导致renderer.setSize里的宽度或高度是0渲染器直接画不出来。检查WebGL上下文是否丢失。Three.js在上下文丢失后默认不恢复需要监听webglcontextlost和webglcontextrestored事件在小程序里这两个事件在canvas节点上触发。如果事件没绑定即使浏览器恢复Three.js也不会重新初始化。检查纹理是否异步加载。我们如果从代码里使用非JSON程序化生成纹理从本地文件加载时会有异步时间差首帧渲染时纹理还没到位容易出现黑屏。解决方式是先渲染一个纯色背景等纹理加载完成再替换材质。检查代码里是否有异常被吞掉。小程序开发者工具经常把WebGL相关的报错静默处理我直接用try/catch把初始化包起来黑屏时看错误堆栈。检查组件切后台。从后台切回小程序如果系统回收了WebGL上下文而Three.js没有执行恢复就会黑屏。我的方案是在visible事件触发时重新调用initThree()但要注意重复初始化会导致多个上下文和内存泄漏所以先detach旧的Canvas再重新创建。最终定位到黑屏问题主要是contextlost没监听和Canvas高度为0这两者占了八成以上情况。6.2 微信开发者工具正常但真机抖动抖动微信开发者工具的模拟器和真机渲染差异非常大。工具里帧率再高也不代表真机没问题。我遇到的最典型案例是工具里跑满60帧但真机iPhone 11上只要模型旋转角度大于180度画面就开始掉帧轻微卡顿。后来查了半天发现是单个材质里使用了THREE.Fog和THREE.FogExp2在某些真机GPU驱动上雾化计算的处理效率极低。这也提醒我小程序3D场景里要少用复杂全局效果能不用Fog就不用Fog能用MeshBasicMaterial实现的效果就别强行上PBR物理材质。还有一个常见问题是renderer.setPixelRatio放在setSize之后和之前在某些基础库版本下会有不同的行为。建议严格按照“设置像素比 - 设置尺寸 - 渲染”的顺序写否则真机上会出现渲染尺寸不对、画面被拉伸变形。6.3 输入延迟和交互回弹互相打架在我第二版交互设计里我加入了松手后的回弹动画。真机测试时发现手指还在滑动模型却突然被回弹动画接管出现明显“打架”。排查过程比较有代表性一开始以为是touchend事件触发的时机不对后来加了日志发现touchend时会触发两次第二次是微信小程序的滚动穿透机制导致的即使Canvas没有滚动也会把事件传出去。解决方式是给Canvas外层View添加catch:touchmove和catch:touchend阻止冒泡。同时维护isUserDragging标志在touchstart设为true并在touchend的下一帧设为false回弹动画只在false之后手动触发。经验是小程序触摸事件的所有默认行为都不可靠显式用catch阻止冒泡比依赖stopPropagation更保险。6.4 包体积和加载体验的平衡一个Three.js相关的小程序页面如果按完整引入包体大概在700KB左右gsap大约25KB再算上业务代码主包很难压到2MB以内。我的做法是用threejs-miniprogram这个适配层它本身是按需转译的体积比官方全量包小很多。把3D页面单独放进分包只有用户点进相应tab时才加载。模型数据用对象压缩存储不用JSON明文纹理尽量用压缩格式比如PVR或ASTC小程序里直接用Base64图片走Canvas解析最省心。针对iOS和安卓设置不同的加载兜底iOS内存相对充裕可以加载高精度模型安卓低端机自动切换到低精度版本。分包加载后的实际体验是首页秒开3D页面加载约0.8秒到1.2秒用户感知不强。结语的一点经验跑完这个项目我最大的感觉是“小程序3D没有想象中那么神秘但也没有任何捷径”。Three.js负责底层渲染gsap负责把业务叙事拆成一段段可编排的动画二者配合好就能在小程序里做到接近H5的3D体验。最难的不是技术本身而是调试链路的复杂性开发者工具和真机不一致模型加载器不兼容Canvas生命周期需要自己处理。这些点我在文章里基本都覆盖到了。如果你正准备启动一个“小程序Three.jsgsap”的项目我建议先从最简单的立方体旋转Demo开始把Canvas初始化、渲染循环、gsap update、页面释放这四段模板代码跑通再往里面填业务模型和复杂动画。这套基础骨架带着走完后面基本不会再遇到无解的坑。
返回列表