微信小程序高性能粒子系统实战:基于Three.js的雨雪天气特效实现
2026/8/12 18:50:25 网站建设 项目流程

1. 项目概述:当微信小程序遇见粒子特效

最近在做一个需要展示3D场景的微信小程序项目,客户要求在特定场景下能动态切换天气效果,比如从晴天切换到下雨或下雪。这让我想到了用three.js的粒子系统来实现。在微信小程序里玩three.js本身就有不少坑,而粒子系统因为涉及大量对象的创建与更新,对性能的挑战更大。但一旦做出来,那种动态、沉浸的视觉效果,对用户体验的提升是巨大的。这篇文章,我就来详细拆解一下如何在微信小程序中,利用three.js构建高性能的雨雪粒子天气系统,分享从原理、实现到性能优化的完整心路历程。

这个方案特别适合需要在电商展示、文旅导览、教育模拟等小程序中增加环境氛围感的场景。无论你是刚开始接触小程序3D开发,还是已经踩过一些坑想寻求优化方案,相信接下来的内容都能给你带来直接的帮助。我们会从最基础的粒子概念讲起,一步步深入到着色器优化,确保你能看懂、能做出来,并且跑得流畅。

2. 核心思路与方案选型:为什么是GPU粒子?

在决定用粒子系统模拟天气时,第一个要回答的问题就是:如何实现?three.js提供了几种创建粒子的方式,比如使用THREE.Points配合THREE.BufferGeometry,或者更基础的THREE.Sprite。对于雨雪这种需要成千上万颗粒子、每帧都要更新位置的场景,性能是首要考虑因素。

2.1 CPU粒子 vs GPU粒子

最初我尝试了最直观的方法:在requestAnimationFrame循环里,遍历一个包含数千个THREE.Mesh(比如使用小球体)的数组,逐个更新它们的position.y。这就是典型的CPU粒子。结果可想而知,在小程序里帧率直接掉到个位数,完全不可用。CPU粒子的瓶颈在于,每一帧都需要JS逻辑计算每个粒子的新位置,然后通过WebGL API调用更新,JS与GPU之间的通信开销巨大。

所以,我们几乎必须选择GPU粒子方案。其核心思想是:将粒子的初始状态(如位置、速度)和运动规律(如重力、风力)通过数据(属性)和算法(着色器)传递给GPU,让GPU在顶点着色器中并行计算所有粒子的位置。JS端每帧只负责更新少数几个全局变量(如时间、风力强度),通信量极小。THREE.Points类就是为这种场景设计的,它代表一个由顶点构成的点云,非常适合用顶点着色器来驱动每个“点”(即粒子)的运动。

2.2 粒子属性的数据结构设计

确定了使用THREE.Points后,我们需要为每个粒子定义一组属性。对于雨滴和雪花,我设计了以下属性:

  1. 初始位置 (positionStart): 粒子诞生时的位置。为了简单,我们可以在一个长方体区域(即“天空”)内随机生成。
  2. 初始速度 (velocityStart): 粒子诞生时的速度向量。雨滴主要是向下的速度(Y轴负方向),并可能带有随机的水平方向(X, Z轴)速度来模拟风。雪花下落慢,且水平飘动更明显。
  3. 创建时间 (startTime): 粒子被“发射”的时间戳。这是实现粒子循环的关键。当一个粒子落到地面以下,我们可以通过判断当前时间与它的“创建时间”之差是否超过一个生命周期,来“重置”它到天空中的随机新位置。但在GPU中,这个“重置”逻辑也写在着色器里。
  4. 随机种子 (seed): 一个随机浮点数。在着色器中,真正的随机函数开销很大且可能造成不同GPU结果不一致。通常我们传入一个随机种子,在着色器内用伪随机函数(如sin(seed * 某大数))来生成各粒子独有的随机偏移,用于计算飘动、旋转等。

这些属性我们将通过THREE.BufferAttribute传递给着色器。一个重要的优化点是:对于不需要每帧从JS更新的属性(如初始位置、初始速度、种子),我们应该将它们作为BufferAttribute一次性传入。而对于需要每帧更新的全局变量(如当前时间、重力、风向量),则通过uniform传递。

2.3 着色器代码的组织策略

在微信小程序中,我们不能像在Web端那样方便地使用THREE.ShaderMaterial并直接传入GLSL代码字符串,因为小程序环境对new Function()eval有严格限制。通常的解决方案是:

  1. 将GLSL代码字符串提前写好,放在一个单独的.glsl.js文件中作为字符串常量导出。
  2. 使用THREE.RawShaderMaterial并传入这些字符串。

这样做的好处是代码清晰,且绕开了小程序的动态代码执行限制。我们将分别编写顶点着色器(vertexShader)和片元着色器(fragmentShader)。顶点着色器负责计算粒子位置,片元着色器负责决定每个粒子点最终在屏幕上呈现的颜色和形状(比如雨滴是拉长的,雪花是六角形)。

3. 核心实现:从零构建雨雪粒子系统

理论讲完,我们开始动手。首先确保你的小程序项目已经正确集成了three.js库(例如使用threejs-miniprogram这个适配版本)。下面我将以“下雨”效果为例,分步骤实现。

3.1 创建粒子几何体与属性

我们首先创建一个THREE.BufferGeometry来承载粒子数据。

// utils/rainParticleSystem.js import * as THREE from 'threejs-miniprogram'; export function createRainParticleSystem(count = 5000) { const particles = count; const geometry = new THREE.BufferGeometry(); // 1. 初始位置属性:在天空盒子内随机分布 const positionsStart = new Float32Array(particles * 3); const velocityStart = new Float32Array(particles * 3); const startTime = new Float32Array(particles); const seed = new Float32Array(particles); const skyWidth = 50; // 天空区域宽度 const skyHeight = 30; // 天空高度 const skyDepth = 50; // 天空深度 for (let i = 0; i < particles; i++) { const i3 = i * 3; // 初始位置:在天空盒子内随机 positionsStart[i3] = (Math.random() - 0.5) * skyWidth; // x positionsStart[i3 + 1] = Math.random() * skyHeight; // y (从地面到天空顶部) positionsStart[i3 + 2] = (Math.random() - 0.5) * skyDepth; // z // 初始速度:主要向下,带一点随机水平方向 const baseSpeed = -15.0; // 基础下落速度 const windStrength = 2.0; // 风力强度 velocityStart[i3] = (Math.random() - 0.5) * windStrength; // x方向速度 velocityStart[i3 + 1] = baseSpeed + (Math.random() - 0.5) * 2.0; // y方向速度 velocityStart[i3 + 2] = (Math.random() - 0.5) * windStrength; // z方向速度 // 创建时间:错开,模拟持续下雨而非同时爆发 startTime[i] = Math.random() * 10.0; // 0-10秒内随机开始 // 随机种子 seed[i] = Math.random() * 1000.0; } geometry.setAttribute('positionStart', new THREE.BufferAttribute(positionsStart, 3)); geometry.setAttribute('velocityStart', new THREE.BufferAttribute(velocityStart, 3)); geometry.setAttribute('startTime', new THREE.BufferAttribute(startTime, 1)); geometry.setAttribute('seed', new THREE.BufferAttribute(seed, 1)); return geometry; }

注意:这里我们创建了positionStart而不是position。因为粒子的实时位置将由着色器计算,THREE.Points需要的position属性我们稍后会在着色器中动态赋值。但THREE.BufferGeometry要求有一个position属性才能被正确渲染,所以我们可以先给一个和positionStart相同的值作为占位符。

3.2 编写着色器材料

这是最核心的部分。我们需要创建顶点和片元着色器代码。

顶点着色器 (rain.vert.glsl):

// 从JS传递进来的统一变量 uniform float uTime; // 全局时间 uniform vec3 uGravity; // 重力,例如 vec3(0.0, -9.8, 0.0) uniform vec3 uWind; // 风力方向,例如 vec3(1.0, 0.0, 0.0) uniform float uLifeTime; // 粒子生命周期 // 从BufferGeometry传递进来的属性 attribute vec3 positionStart; attribute vec3 velocityStart; attribute float startTime; attribute float seed; // 传递给片元着色器的变量(如果需要) varying float vAlpha; // 伪随机函数,用于生成可控的随机值 float rand(float n) { return fract(sin(n) * 43758.5453123); } void main() { // 计算粒子年龄 float age = uTime - startTime; // 取模运算实现循环,当年龄超过生命周期时,粒子“重生” age = mod(age, uLifeTime); // 利用种子生成一些随机扰动 float randOffset = rand(seed) * 2.0 - 1.0; // -1 到 1 // 物理模拟:位置 = 初始位置 + 初始速度 * 时间 + 0.5 * 加速度 * 时间^2 // 加速度 = 重力 + 风力 vec3 acceleration = uGravity + uWind; vec3 newPosition = positionStart + velocityStart * age + 0.5 * acceleration * age * age; // 简单的边界检查与重置(可选,如果mod方式不能满足需求,可以用if判断) // if (newPosition.y < -10.0) { // 落到地面以下 // newPosition.y = 20.0; // 重置到高处 // } // 将计算出的新位置赋值给gl_Position vec4 mvPosition = modelViewMatrix * vec4(newPosition, 1.0); gl_Position = projectionMatrix * mvPosition; // 控制粒子大小(可根据距离或年龄变化) float pointSize = 3.0; // 基础大小 // 让远处的粒子小一点(可选) pointSize *= (1.0 / -mvPosition.z); gl_PointSize = pointSize; // 计算透明度,模拟雨滴下落时的视觉衰减(例如近实远虚,或根据年龄) vAlpha = 1.0 - (age / uLifeTime); // 线性衰减,越老越透明 // 或者基于距离的衰减 // vAlpha = 1.0 / (1.0 + 0.1 * -mvPosition.z); }

片元着色器 (rain.frag.glsl):

// 从顶点着色器传递来的变量 varying float vAlpha; uniform sampler2D uPointTexture; // 粒子的纹理(一个雨滴形状的贴图) void main() { // 获取纹理颜色。gl_PointCoord是片元在点精灵内的坐标(0.0到1.0) vec4 texColor = texture2D(uPointTexture, gl_PointCoord); // 如果纹理是透明背景的,我们可以利用它的alpha通道 // 也可以自己绘制形状,例如画一个拉长的椭圆来模拟雨滴 // 这里我们采用一个简单的圆形,并应用纹理颜色 vec2 center = gl_PointCoord - vec2(0.5); float dist = length(center); if (dist > 0.5) { discard; // 丢弃点精灵圆以外的片元,形成圆形点 } // 最终颜色,可以混合纹理色和自定义色,并应用透明度 vec3 rainColor = vec3(0.7, 0.8, 1.0); // 淡蓝色 gl_FragColor = vec4(rainColor, vAlpha * 0.8); // 整体透明度 }

然后,在JS中创建着色器材质:

// utils/rainParticleSystem.js (续) import rainVert from './shaders/rain.vert.glsl'; import rainFrag from './shaders/rain.frag.glsl'; export function createRainMaterial() { const material = new THREE.RawShaderMaterial({ uniforms: { uTime: { value: 0.0 }, uGravity: { value: new THREE.Vector3(0, -9.8, 0) }, uWind: { value: new THREE.Vector3(2.0, 0, 0.5) }, // 一点斜风 uLifeTime: { value: 5.0 }, // 粒子生命周期5秒 uPointTexture: { value: null } // 纹理稍后设置 }, vertexShader: rainVert, fragmentShader: rainFrag, transparent: true, // 启用透明度 depthWrite: false, // 粒子通常不需要写入深度缓冲区,可以避免排序问题,但可能引起遮挡错误,需根据情况调整 blending: THREE.AdditiveBlending, // 加法混合,能让叠加的雨滴看起来更亮、更柔和 }); return material; }

3.3 组装并添加到场景

最后,我们将几何体和材质组合起来,创建THREE.Points对象,并添加到场景中。

// pages/weather/index.js import * as THREE from 'threejs-miniprogram'; import { createRainParticleSystem, createRainMaterial } from '../../utils/rainParticleSystem'; import { loadTexture } from '../../utils/textureLoader'; // 一个简单的纹理加载器 Page({ data: { /* ... */ }, onReady() { // 初始化three.js场景、相机、渲染器(此部分略,假设已存在) const scene = this.scene; const renderer = this.renderer; // 1. 创建粒子系统 const particleCount = 8000; // 根据性能调整 const rainGeometry = createRainParticleSystem(particleCount); const rainMaterial = createRainMaterial(); // 2. 加载雨滴纹理(一个小的透明PNG,中心白色,边缘透明) loadTexture('https://your-cdn-path.com/raindrop.png').then(texture => { rainMaterial.uniforms.uPointTexture.value = texture; }); // 3. 创建Points对象 const rainParticles = new THREE.Points(rainGeometry, rainMaterial); rainParticles.frustumCulled = false; // 禁用视锥体剔除,确保粒子始终被渲染 scene.add(rainParticles); this.rainParticles = rainParticles; // 4. 在动画循环中更新uTime const clock = new THREE.Clock(); const animate = () => { const deltaTime = clock.getDelta(); this.totalTime += deltaTime; if (this.rainParticles) { this.rainParticles.material.uniforms.uTime.value = this.totalTime; } renderer.render(scene, camera); this.requestID = requestAnimationFrame(animate); }; this.totalTime = 0; animate(); }, onUnload() { if (this.requestID) cancelAnimationFrame(this.requestID); // 妥善处置几何体和材质,避免内存泄漏 if (this.rainParticles) { this.scene.remove(this.rainParticles); this.rainParticles.geometry.dispose(); this.rainParticles.material.dispose(); } } })

3.4 实现雪花系统

雪花系统的实现逻辑与雨滴高度相似,主要区别在于物理参数和视觉表现:

  1. 物理参数:重力更小(uGravity.y = -1.0),初始速度更慢且随机性更强,风力影响更大,生命周期更长。
  2. 运动轨迹:可以在着色器中加入更多基于sin/cos和随机种子的水平摆动,模拟飘落感。
  3. 视觉表现:片元着色器中,可以绘制更复杂的形状(如六角星),或者使用雪花的纹理。粒子大小 (gl_PointSize) 可以更随机,透明度变化更平缓。
  4. 混合模式:雪花可能更适合THREE.NormalBlending而非AdditiveBlending,以避免过亮。

你可以创建另一套createSnowParticleSystemcreateSnowMaterial,通过切换场景中的粒子系统对象来实现雨雪天气的转换。

4. 性能优化与避坑指南

在小程序里运行3D粒子,不优化等于自杀。下面是我踩过坑后总结的几条黄金法则。

4.1 控制粒子数量与渲染状态

  • 数量是性能第一杀手:先从较少的粒子数(如2000)开始测试,逐步增加直到帧率开始明显下降(建议保持在50-60fps)。中低端手机上,5000-10000个高质量粒子可能是极限。
  • 合并绘制调用:使用一个THREE.Points绘制数万个粒子,远比使用数千个独立的THREE.Mesh高效得多,因为前者只有一个绘制调用(Draw Call)。
  • 谨慎使用透明和混合transparent: trueblending会要求GPU对片元进行排序和混合,开销较大。如果粒子是纯加法效果且不需要与背景复杂混合,可以尝试blending: THREE.NoBlending并关闭透明,用纹理本身的alpha做裁剪。
  • 深度测试与写入:对于像雨雪这种全屏覆盖的半透明粒子,通常设置depthWrite: false。这避免了粒子之间以及粒子与场景其他物体之间不必要的深度比较,能提升性能。但副作用是可能造成错误的遮挡关系(比如雪花穿进建筑内部)。你需要根据场景内容权衡。

4.2 着色器优化技巧

  • 避免着色器条件分支:GPU不喜欢if语句,尤其是在顶点着色器中。尽量用step()mix()等GLSL内置函数来替代条件判断。例如,上面重置粒子的逻辑,用mod取模循环就比用if判断再重置要好。
  • 简化计算:在顶点着色器里,开方(sqrt)、三角函数(sin,cos)、除法都是相对耗时的操作。能省则省。例如计算距离时,可以先比较距离的平方,避免开方。
  • 纹理图集:如果你需要多种粒子形态(比如不同形状的雪花),不要为每种形态创建不同的Points对象。可以将所有形态打包到一张纹理图集(Texture Atlas)中,在着色器里根据粒子的属性(如种子)计算对应的纹理坐标。

4.3 微信小程序特定问题

  • 纹理加载:小程序对网络图片有域名白名单限制。粒子纹理图片需要放在小程序项目内(/assets/images/)或配置了downloadFile合法域名的服务器上。使用wx.createImage()three.jsTextureLoader加载本地图片路径时,注意路径前缀(如./assets/raindrop.png)。
  • 帧率控制:小程序页面切换或失去焦点时,务必停止requestAnimationFrame循环,并在页面显示时重新启动,否则会持续消耗性能。
  • 内存管理:小程序内存管理比浏览器更严格。在onUnload生命周期中,必须手动dispose()掉几何体(geometry)、材质(material)、纹理(texture)和渲染器(renderer)。否则,页面跳转后,这些WebGL资源不会被自动释放,可能导致页面卡顿甚至崩溃。
  • iOS与Android差异:某些Android机型(特别是低端机)的GPU精度较低,可能导致粒子闪烁(Z-fighting)或计算误差。如果遇到,可以尝试在片元着色器中增加一个很小的深度偏移(gl_FragDepth),或者统一降低粒子系统的规模。

4.4 常见问题排查表

问题现象可能原因排查与解决思路
粒子完全不显示1. 相机位置/朝向不对。
2. 粒子初始位置在相机视锥体外。
3. 着色器编译错误。
1. 调整相机位置,确保看向粒子区域。
2. 设置points.frustumCulled = false
3. 打开小程序开发者工具的“调试”模式,查看控制台是否有WebGL编译错误。检查着色器代码语法。
帧率极低(卡顿)1. 粒子数量过多。
2. 着色器计算太复杂。
3. 每帧在JS中更新了大量BufferAttribute数据。
1. 减少粒子数量(count)。
2. 简化着色器,移除不必要的计算和分支。
3. 确保粒子运动逻辑在GPU(着色器)中完成,JS端只更新uniform(如uTime)。
粒子闪烁或抖动1. 深度测试冲突(Z-fighting)。
2. 时间(uTime)精度问题,数值过大导致浮点精度丢失。
1. 尝试设置材质depthWrite: falsepolygonOffset: true
2. 在着色器中对uTime取模,或每运行一段时间(如一小时)重置uTime
透明度混合异常1. 混合模式(blending)设置不当。
2. 半透明物体渲染顺序问题。
1. 根据需求尝试NormalBlendingAdditiveBlendingNoBlending
2. 确保粒子系统在场景中最后渲染(后添加),或手动设置renderOrder
在iOS上正常,在部分Android机上异常低端Android机GPU精度或驱动支持问题。1. 简化着色器,避免高精度运算(highp)。
2. 使用更保守的粒子数量和效果。
3. 提供画质选项,让用户选择“性能模式”。

5. 效果增强与实战技巧

基础功能跑通后,我们可以进一步打磨效果,让它更真实、更吸引人。

5.1 融入环境交互

静态的雨雪看久了会腻。让粒子与环境互动能极大提升真实感:

  • 地面涟漪:这不是粒子系统本身,但可以配合实现。在片元着色器中,可以根据雨滴位置(投影到地面XZ平面),在一个作为地面的平面纹理上,动态绘制扩散的圆形波纹。这需要额外的渲染目标(Render Target)或动态纹理更新,性能开销大,需谨慎使用。
  • 风力变化:不要让uWind恒定不变。可以在JS端用Math.sin(uTime * 0.1)来生成一个缓慢变化的风力值,并传递给着色器,模拟风力的起伏。
  • 与相机距离关联:在顶点着色器中,可以根据粒子到相机的距离(length(mvPosition))来动态调整粒子大小(gl_PointSize)和透明度(vAlpha)。近处的雨滴大而清晰,远处的细密而模糊,能增强空间纵深感。

5.2 视觉细节打磨

  • 雨滴形态:用一个拉长的椭圆形纹理代替简单的圆形点。在片元着色器中,可以对gl_PointCoord进行拉伸变换,vec2 stretchedCoord = vec2(gl_PointCoord.x, gl_PointCoord.y * 2.0 - 0.5);,然后再判断是否在椭圆内。
  • 雪花旋转:在顶点着色器中,将随机种子传递给片元着色器。在片元着色器中,利用这个种子和当前时间,对gl_PointCoord进行旋转矩阵变换,再采样纹理,就能实现雪花各自旋转飘落的效果。
  • 颜色与光照:简单的颜色 (vec3(1.0)) 很假。可以尝试根据场景的主光源方向,计算粒子表面的简单漫反射。或者,更取巧的方法是,根据粒子在屏幕上的Y坐标(下落高度)来混合颜色,比如高空的雪花偏蓝灰色,近处的偏亮白色。

5.3 性能与效果的平衡艺术

这是实战中最纠结的部分。我的经验是:

  1. 分层渲染:将粒子系统分成近景层和远景层。近景层使用较少的粒子但渲染质量高(更复杂的着色器、更大的点大小);远景层使用更多的粒子但渲染简化(更简单的着色器、更小的点大小、甚至可以用公告板代替)。两者叠加,用较低的成本获得丰富的层次感。
  2. 动态细节等级(LOD):根据设备的帧率或用户设置,动态调整粒子数量、着色器复杂度。可以在动画循环中监测performance.now()计算帧间隔,如果连续几帧太慢,就自动降低一个等级。
  3. 纹理分辨率:粒子纹理不需要很高清,32x32或64x64像素通常就够了。高分辨率纹理只会增加GPU的采样开销和内存占用。

最后,别忘了在小程序真机上(尤其是低端Android机)进行充分的性能测试。开发者工具里的帧率往往很美好,但真机才是试金石。通过不断调整参数、优化代码,最终在效果和流畅度之间找到一个完美的平衡点,那种成就感,正是我们做开发的乐趣所在。

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

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

立即咨询