第二十三章:粒子系统与特效

欢迎来到粒子世界!如果说前面的章节是在学习"怎么画一个东西",那么这一章就是学习"怎么让成千上万个东西一起动起来"。粒子系统是游戏和可视化中最有"魔法感"的技术——烟花、雪花、火焰、魔法光效,全都可以用粒子实现。

前置章节:第10章 动画与缓动原理、第17章 3D渲染基础

预计学习时间:3天


目录

  1. 什么是粒子系统?
  2. 粒子属性:位置、速度、生命周期、大小、颜色
  3. 粒子物理:重力、阻力、力场
  4. 变换矩阵:让粒子动起来
  5. Alpha 混合:粒子的透明艺术
  6. 粒子生成与消亡:生生不息
  7. 噪声驱动动画:让粒子有"灵魂"
  8. 点精灵:一个顶点画一个粒子
  9. 实例化渲染:20万个粒子的秘密
  10. 常见粒子特效:火、烟、火花、雨
  11. 本章 Q&A

1. 什么是粒子系统?

1.1 生活类比:放烟花

想象你在春节夜空中放烟花:

  • 你点燃引信,"嗖"的一声,一颗光点冲上天空——这就是发射器(Emitter)
  • 到达顶点后,"砰"地炸开,变成几百个小火花——这就是粒子爆发(Burst)
  • 每个小火花都有自己的轨迹:有的飞得高,有的飞得低,有的亮,有的暗——这就是粒子属性差异
  • 几秒钟后,所有火花都熄灭消失——这就是生命周期(Lifetime)

粒子系统就是计算机里的"烟花工厂":它批量生成大量微小的"粒子",每个粒子独立运动、变化、消失,合在一起就形成了我们看到的特效。

1.2 本质是什么

从代码角度看,粒子系统 = 粒子数组 + 更新循环 + 渲染循环

粒子系统 {
  粒子数组: [粒子0, 粒子1, 粒子2, ...],
  
  每帧执行 {
    1. 生成新粒子(如果需要)
    2. 更新每个粒子的位置、速度、颜色、大小
    3. 移除生命周期结束的粒子
    4. 渲染所有存活的粒子
  }
}

每个粒子通常是一个小图形(三角形、正方形、圆形),而不是真正的"物理粒子"。我们欺骗眼睛,让几百个简单图形看起来像一团火、一片云。

1.3 代码示例:最简单的粒子系统

来看 akira-graphics/webgl_particles/app.js 的核心逻辑:

let triangles = []; // 存储所有存活粒子的数组

function update(t) {
  // 1. 每帧随机生成 0~5 个新粒子
  for (let i = 0; i < 5 * Math.random(); i++) {
    triangles.push(randomTriangles()); // 创建新粒子
  }

  // 2. 清空画布
  gl.clear(gl.COLOR_BUFFER_BIT);

  // 3. 更新并渲染每个粒子
  triangles.forEach((triangle) => {
    // 计算粒子存活时间(秒)
    triangle.u_time = (performance.now() - triangle.startTime) / 1000;
    setUniforms(gl, triangle); // 把粒子属性传给 GPU
    gl.drawArrays(gl.TRIANGLES, 0, position.length / 2); // 绘制!
  });

  // 4. 过滤掉已经"死亡"的粒子
  triangles = triangles.filter((triangle) => {
    return triangle.u_time <= triangle.u_duration;
  });

  requestAnimationFrame(update);
}

1.4 常见误区

误区 1:"粒子系统需要物理引擎"

不需要!大多数粒子效果用最简单的运动学公式就够了:position += velocity * dt。只有需要复杂碰撞时才引入物理引擎。

误区 2:"粒子越多越好"

100 个精心调教的粒子,可能比 10000 个胡乱堆砌的粒子更好看。粒子的质量(运动轨迹、颜色变化、混合模式)比数量更重要。

1.5 试一试

打开 akira-graphics/webgl_particles/index.html,观察:

  1. 页面加载后,彩色小三角形不断从中心产生
  2. 每个三角形朝不同方向飞出、旋转、变大再消失
  3. 在控制台执行 triangles.length,观察存活粒子数量如何波动

思考题:如果把 5 * Math.random() 改成 50 * Math.random(),会发生什么?帧率会怎么变化?


2. 粒子属性:位置、速度、生命周期、大小、颜色

2.1 生活类比:雪花

想象一片雪花从天空飘落:

  • 位置(Position):雪花现在在哪儿?(x, y, z)
  • 速度(Velocity):雪花往哪个方向飘?每秒移动多少?受风影响吗?
  • 生命周期(Lifetime):雪花从形成到融化有多长时间?
  • 大小(Size):刚形成时大还是小?下落过程中会变大吗?
  • 颜色(Color):刚落下时洁白,落地前可能变灰

每个属性都可以随时间变化,这就是粒子"活"起来的秘密。

2.2 本质是什么

粒子本质上是一个状态机。在任意时刻 t,粒子的完整状态可以用一个结构体表示:

const particle = {
  position: { x: 0, y: 0 },      // 当前位置
  velocity: { x: 1, y: -0.5 },    // 当前速度(像素/秒)
  lifetime: 3.0,                   // 总寿命(秒)
  age: 0.0,                        // 已存活时间(秒)
  size: 10.0,                      // 当前大小(像素)
  color: [1, 0, 0, 1],            // RGBA,当前颜色
};

2.3 进度值 p:连接时间和属性的桥梁

粒子系统中最核心的概念是进度(progress),通常记为 p

p = age / lifetime   // 0 表示刚诞生,1 表示即将死亡

一旦有了 p,我们就可以用它来控制任何属性的变化:

// 大小从 0 渐变到最大,再缩回 0
size = maxSize * p * (2 - p);  // 这是一个开口向下的抛物线

// 颜色从红色渐变到黄色
color.r = 1.0;
color.g = p;        // 绿色分量从 0 增加到 1
color.b = 0;

// 透明度从 1 渐变到 0(淡出)
alpha = 1 - p;

2.4 公式推导:大小随时间变化

webgl_particles/app.js 中,顶点着色器里有这样一行:

float scale = u_scale * p * (2.0 - p);

让我们推导这个公式的含义:

步骤 1:展开公式

scale = u_scale * p * (2 - p)
      = u_scale * (2p - p²)

步骤 2:分析函数 f(p) = 2p - p² 在 [0, 1] 区间的行为

p f(p) = 2p - p²
0 0
0.25 0.4375
0.5 0.75
1 1

步骤 3:求导数找极值点

f'(p) = 2 - 2p
令 f'(p) = 0 → p = 1

在 [0, 1] 区间内,f(p) 单调递增,从 0 增长到 1。这意味着粒子从诞生到死亡,大小从 0 平滑增长到 u_scale

如果你想让粒子先变大再变小(像烟花爆炸),可以用:

float scale = u_scale * 4.0 * p * (1.0 - p);  // 在 p=0.5 时达到最大值

2.5 代码示例:随机粒子生成器

function randomTriangles() {
  // 随机颜色(RGB + Alpha)
  const u_color = [Math.random(), Math.random(), Math.random(), 1.0];

  // 随机初始旋转角度(0 ~ π)
  const u_rotation = Math.random() * Math.PI;

  // 随机初始大小(0.03 ~ 0.08)
  const u_scale = Math.random() * 0.05 + 0.03;

  // 运动方向:随机角度,单位向量
  const rad = Math.random() * Math.PI * 2;
  const u_dir = [Math.cos(rad), Math.sin(rad)];

  // 持续时间 3 秒
  const u_duration = 3.0;

  // 记录诞生时间
  const startTime = performance.now();

  return { u_color, u_rotation, u_scale, u_dir, u_duration, startTime };
}

2.6 常见误区

误区:"所有粒子用相同的生命周期"

真实世界中,每片雪花、每颗火星的寿命都不同。给生命周期加上随机变化,效果会自然很多:

const u_duration = 2.0 + Math.random() * 2.0; // 2~4 秒随机

2.7 试一试

修改 randomTriangles 函数:

  1. 让颜色偏向暖色调(红色和黄色为主)
  2. 让大小在更大范围内随机(0.01 ~ 0.15)
  3. 给生命周期加上随机性

观察效果的变化。


3. 粒子物理:重力、阻力、力场

3.1 生活类比:抛石子和扔羽毛

想象你同时抛出一颗石子和一根羽毛:

  • 石子:飞得快、飞得远,轨迹接近抛物线
  • 羽毛:飘飘悠悠,很快就落地了

区别在哪里?重力对两者一样,但空气阻力对羽毛的影响大得多。

3.2 本质是什么

粒子物理的核心公式是牛顿第二定律

F = m * a        // 力 = 质量 × 加速度
a = F / m        // 加速度 = 力 / 质量
v = v + a * dt   // 速度更新
p = p + v * dt   // 位置更新

对于粒子系统,我们通常忽略质量(假设所有粒子质量为 1),这样力直接等于加速度。

3.3 公式推导:重力作用下的运动

假设

  • 粒子初始位置 (x0, y0)
  • 初始速度 (vx0, vy0)
  • 重力加速度 g = 9.8 m/s²,方向向下(y 轴负方向)

步骤 1:加速度只有重力

ax = 0
ay = -g

步骤 2:速度随时间变化

vx(t) = vx0 + ax * t = vx0        // 水平速度不变
vy(t) = vy0 + ay * t = vy0 - g*t  // 垂直速度线性减小

步骤 3:位置随时间变化(积分速度)

x(t) = x0 + vx0 * t
y(t) = y0 + vy0 * t - 0.5 * g * t²

注意 y(t) 里的 项!这就是抛物线轨迹的来源。

3.4 代码示例:带重力的粒子

// 顶点着色器:重力影响下的粒子运动
uniform float u_time;       // 已存活时间
uniform vec2 u_dir;         // 初始方向
uniform float u_speed;      // 初始速度
uniform float u_gravity;    // 重力加速度

void main() {
  float t = u_time;

  // 水平位置:匀速运动
  float x = u_dir.x * u_speed * t;

  // 垂直位置:匀加速运动(抛物线)
  float y = u_dir.y * u_speed * t - 0.5 * u_gravity * t * t;

  vec2 position = vec2(x, y);

  gl_Position = vec4(position, 0, 1);
}

3.5 阻力(Drag):让粒子慢下来

真实世界中,空气阻力与速度成正比(或速度的平方成正比):

F_drag = -k * v        // k 是阻力系数

在代码中实现阻力:

// 每帧更新速度
velocity.x *= (1 - drag * dt);
velocity.y *= (1 - drag * dt);

// drag = 0.1 表示每秒钟速度衰减 10%

3.6 力场:让粒子"感受"到力

力场是空间中每一点都有一个力的函数 F(x, y)。常见的力场:

漩涡力场

function vortexForce(x, y, centerX, centerY, strength) {
  const dx = x - centerX;
  const dy = y - centerY;
  const dist = Math.sqrt(dx * dx + dy * dy);

  // 力垂直于半径方向(切向)
  return {
    fx: -strength * dy / dist,
    fy: strength * dx / dist
  };
}

吸引力场(像磁铁一样):

function attractForce(x, y, targetX, targetY, strength) {
  const dx = targetX - x;
  const dy = targetY - y;
  const dist = Math.sqrt(dx * dx + dy * dy);

  // 力指向目标,与距离平方成反比
  return {
    fx: strength * dx / (dist * dist + 0.001),
    fy: strength * dy / (dist * dist + 0.001)
  };
}

3.7 常见误区

误区:"物理模拟必须精确"

粒子系统的物理不需要精确!观众不会拿着尺子量抛物线。很多时候,"看起来对"比"算得准"更重要。

误区:"所有粒子受相同的力"

给力的参数加上随机性,效果更自然。比如重力可以 g = 8 + Math.random() * 4,让每个粒子下落速度略有不同。

3.8 试一试

webgl_particles 的基础上:

  1. 给每个粒子加上向下的重力,让轨迹变成抛物线
  2. 加上阻力系数,让粒子飞不远
  3. 在画面中心加一个吸引力场,让粒子像被黑洞吸引一样

4. 变换矩阵:让粒子动起来

4.1 生活类比:舞蹈编排

想象一群舞者在舞台上表演:

  • 平移(Translate):舞者从舞台左边走到右边
  • 旋转(Rotate):舞者原地转圈
  • 缩放(Scale):舞者散开(变大)或聚拢(变小)

每个粒子也需要这三种基本变换,而变换矩阵就是把它们统一起来的数学工具。

4.2 本质是什么

在 2D 中,我们用 3x3 齐次坐标矩阵表示变换:

| x' |   | m00  m01  m02 |   | x |
| y' | = | m10  m11  m12 | * | y |
| 1  |   |  0    0    1  |   | 1 |

三种基本变换的矩阵形式:

平移矩阵(移动 (tx, ty)):

| 1  0  tx |
| 0  1  ty |
| 0  0  1  |

旋转矩阵(旋转角度 θ):

| cos(θ)  -sin(θ)  0 |
| sin(θ)   cos(θ)  0 |
|   0        0     1 |

缩放矩阵(缩放 (sx, sy)):

| sx  0   0 |
| 0   sy  0 |
| 0   0   1 |

4.3 公式推导:组合变换

webgl_particles/app.js 的顶点着色器中:

vec3 pos = translateMatrix * rotateMatrix * scaleMatrix * vec3(position, 1.0);

为什么要按这个顺序相乘?因为矩阵乘法从右往左应用:

最终位置 = 平移 × (旋转 × (缩放 × 原始顶点))
         = 先缩放 → 再旋转 → 最后平移

步骤 1:缩放

| sx  0   0 |   | x |   | sx * x |
| 0   sy  0 | * | y | = | sy * y |
| 0   0   1 |   | 1 |   |   1    |

步骤 2:旋转(旋转步骤 1 的结果)

| cos  -sin  0 |   | sx*x |   | cos*sx*x - sin*sy*y |
| sin   cos  0 | * | sy*y | = | sin*sx*x + cos*sy*y |
|  0     0   1 |   |  1   |   |         1           |

步骤 3:平移(移动步骤 2 的结果)

| 1  0  tx |   | ... |   | cos*sx*x - sin*sy*y + tx |
| 0  1  ty | * | ... | = | sin*sx*x + cos*sy*y + ty |
| 0  0  1  |   |  1  |   |            1             |

4.4 代码示例:粒子着色器中的矩阵变换

// 顶点着色器(来自 webgl_particles/app.js)
attribute vec2 position;
uniform float u_rotation;
uniform float u_time;
uniform float u_duration;
uniform float u_scale;
uniform vec2 u_dir;

varying float vP;

void main() {
  // p = 当前进度(0~1)
  float p = min(1.0, u_time / u_duration);

  // 旋转角度 = 初始角度 + 10π * p(转 5 圈)
  float rad = u_rotation + 3.14 * 10.0 * p;

  // 大小 = 初始大小 * p * (2 - p)
  float scale = u_scale * p * (2.0 - p);

  // 位移 = 方向 * p²(加速运动)
  vec2 offset = 2.0 * u_dir * p * p;

  // 三个变换矩阵
  mat3 translateMatrix = mat3(
    1.0, 0.0, 0.0,
    0.0, 1.0, 0.0,
    offset.x, offset.y, 1.0
  );

  mat3 rotateMatrix = mat3(
    cos(rad), sin(rad), 0.0,
    -sin(rad), cos(rad), 0.0,
    0.0, 0.0, 1.0
  );

  mat3 scaleMatrix = mat3(
    scale, 0.0, 0.0,
    0.0, scale, 0.0,
    0.0, 0.0, 1.0
  );

  // 组合变换:平移 * 旋转 * 缩放
  vec3 pos = translateMatrix * rotateMatrix * scaleMatrix * vec3(position, 1.0);

  gl_Position = vec4(pos, 1.0);
  vP = p;
}

4.5 常见误区

误区:"变换顺序无所谓"

顺序极其重要!旋转 × 平移平移 × 旋转 是完全不同的:

  • 旋转 × 平移:先平移再旋转 = 绕原点旋转(像地球绕太阳转)
  • 平移 × 旋转:先旋转再平移 = 在平移后的位置旋转(像陀螺在地上转)

4.6 试一试

修改着色器中的矩阵乘法顺序:

  1. translateMatrix * rotateMatrix * scaleMatrix 改成 scaleMatrix * rotateMatrix * translateMatrix
  2. 观察粒子的运动方式有什么变化
  3. 思考为什么顺序会影响结果

5. Alpha 混合:粒子的透明艺术

5.1 生活类比:彩色玻璃叠在一起

想象你站在教堂里,阳光穿过彩色玻璃窗:

  • 一块红色玻璃 + 一块蓝色玻璃叠在一起 → 你看到紫色
  • 玻璃越厚(越多层),颜色越浓

Alpha 混合就是计算机里的"彩色玻璃叠加"——让半透明的粒子叠在一起时产生柔和的效果。

5.2 本质是什么

Alpha 混合的数学公式:

最终颜色 = 源颜色 × 源因子 + 目标颜色 × 目标因子

在 WebGL 中,最常用的混合模式是:

gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);

这意味着:

最终颜色 = 源颜色 × 源Alpha + 目标颜色 × (1 - 源Alpha)

举例:一个半透明的红色粒子 (1, 0, 0, 0.5) 画在白色背景 (1, 1, 1, 1) 上:

R = 1 * 0.5 + 1 * (1 - 0.5) = 0.5 + 0.5 = 1.0
G = 0 * 0.5 + 1 * (1 - 0.5) = 0 + 0.5 = 0.5
B = 0 * 0.5 + 1 * (1 - 0.5) = 0 + 0.5 = 0.5

结果 = 粉色 (1, 0.5, 0.5)

5.3 代码示例:粒子淡出效果

// 片元着色器(来自 webgl_particles/app.js)
precision mediump float;

uniform vec4 u_color;
varying float vP;  // 从顶点着色器传入的进度

void main() {
  // RGB 不变
  gl_FragColor.xyz = u_color.xyz;

  // Alpha 随进度从 1 渐变到 0
  gl_FragColor.a = (1.0 - vP) * u_color.a;
}

vP = 0(刚诞生)时,Alpha = 1,完全不透明。 当 vP = 1(即将死亡)时,Alpha = 0,完全透明。

5.4 加法混合:让粒子"发光"

除了标准的 Alpha 混合,粒子系统还常用加法混合

gl.blendFunc(gl.SRC_ALPHA, gl.ONE);

公式变成:

最终颜色 = 源颜色 × 源Alpha + 目标颜色 × 1

这意味着颜色会叠加变亮,而不是被背景"稀释"。多个粒子叠在一起会越来越亮,非常适合火焰、魔法光效。

举例:两个 (0.5, 0.2, 0, 0.5) 的粒子叠在一起:

第一次:R = 0.5 * 0.5 + 0 * 1 = 0.25
第二次:R = 0.5 * 0.5 + 0.25 * 1 = 0.5

越叠越亮,像真正的火焰!

5.5 常见误区

误区:"Alpha 混合不需要开启深度测试"

如果开启了深度测试,半透明的粒子需要从远到近绘制,否则前面的粒子会挡住后面的,混合结果出错。正确的做法是:

  1. 先绘制所有不透明物体
  2. 按深度从远到近排序半透明粒子
  3. 绘制半透明粒子

误区:"加法混合没有透明度"

加法混合确实不"遮挡"背景,但源颜色的 Alpha 仍然参与计算。gl_FragColor.a 在加法混合中影响的是"贡献多少颜色到最终画面"。

5.6 试一试

webgl_particles 中:

  1. 开启 gl.BLEND 并设置 blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
  2. 观察粒子叠加时的颜色变化
  3. 改成 blendFunc(gl.SRC_ALPHA, gl.ONE),观察"发光"效果
  4. 尝试让粒子诞生时 Alpha 从 0 渐变到 1(淡入),死亡时从 1 渐变到 0(淡出)

6. 粒子生成与消亡:生生不息

6.1 生活类比:喷泉

想象公园里的喷泉:

  • 水从喷头不断涌出——持续发射
  • 每滴水在空中飞一会儿——生命周期
  • 落入池中消失——死亡
  • 池中的水被泵抽回喷头——对象池(可选优化)

6.2 本质是什么

粒子系统的生命周期管理有三个核心问题:

  1. 什么时候生成新粒子?
  2. 生成多少?
  3. 什么时候销毁旧粒子?

6.3 发射器模式

持续发射:每帧生成固定数量(或随机数量)的粒子

function update() {
  // 每帧生成 0~5 个粒子
  for (let i = 0; i < 5 * Math.random(); i++) {
    particles.push(createParticle());
  }
  // ...
}

爆发发射:一次性生成大量粒子(如爆炸)

function explode(x, y) {
  for (let i = 0; i < 100; i++) {
    particles.push(createParticle(x, y));
  }
}

间隔发射:每隔一段时间生成一批

let lastEmitTime = 0;
const emitInterval = 100; // 每 100ms 发射一次

function update(now) {
  if (now - lastEmitTime > emitInterval) {
    for (let i = 0; i < 10; i++) {
      particles.push(createParticle());
    }
    lastEmitTime = now;
  }
}

6.4 消亡与对象池

简单的做法:每帧过滤掉死掉的粒子

particles = particles.filter(p => p.age < p.lifetime);

问题:频繁创建和销毁对象会导致垃圾回收(GC)卡顿。

优化:对象池(Object Pool)

class ParticlePool {
  constructor(size) {
    this.pool = new Array(size);
    for (let i = 0; i < size; i++) {
      this.pool[i] = { active: false }; // 预创建
    }
    this.activeCount = 0;
  }

  spawn() {
    // 找一个不活跃的粒子
    for (let i = 0; i < this.pool.length; i++) {
      if (!this.pool[i].active) {
        this.pool[i].active = true;
        this.activeCount++;
        return this.pool[i];
      }
    }
    return null; // 池满了
  }

  kill(particle) {
    particle.active = false;
    this.activeCount--;
  }
}

6.5 常见误区

误区:"粒子死亡后立刻从内存删除"

在 JavaScript 中,删除数组元素或用 filter 创建新数组都有开销。对象池是游戏开发中的标准做法,能避免 GC 导致的帧率波动。

误区:"发射器只能从一个点发射"

发射器可以有各种形状:

  • 点发射器:从一个点发射
  • 线发射器:从一条线段上随机位置发射
  • 面发射器:从一个矩形/圆形区域内发射
  • 体发射器:从一个立方体/球体内发射

6.6 试一试

实现一个"点击爆炸"效果:

  1. 点击画布时,在点击位置生成 50 个粒子
  2. 粒子向四周随机方向飞出
  3. 1 秒后全部消失
  4. 用对象池管理粒子,避免频繁创建销毁

7. 噪声驱动动画:让粒子有"灵魂"

7.1 生活类比:风吹过麦田

想象一片麦田:

  • 如果没有风,所有麦子直挺挺地站着——机械、死板
  • 有风时,麦子起伏波动,但没有两株麦子的摆动完全相同——自然、有机

噪声就是计算机里的"风",它让粒子的运动带有随机但连续的波动。

7.2 本质是什么

普通随机数(Math.random())的问题是:完全不相关。前一帧和后一帧的随机值之间没有任何联系。

噪声(Noise)的特点是:连续、平滑、可重复。给定相同的输入,总是输出相同的值;输入稍微变化,输出也只稍微变化。

最常用的噪声是 Perlin NoiseSimplex Noise

7.3 公式推导:噪声的插值原理

Value Noise(最简单的噪声)的原理:

步骤 1:在整数网格点上放置随机值

  0.3      0.7
   +--------+
   |        |
   |   ?    |
   |        |
   +--------+
  0.1      0.5

步骤 2:对于网格内的任意点,用周围 4 个角点的值做双线性插值

// 点 (x, y) 在 [0,1]×[0,1] 内
// 四个角值:v00, v10, v01, v11

// 先沿 x 方向插值
vx0 = v00 * (1-x) + v10 * x
vx1 = v01 * (1-x) + v11 * x

// 再沿 y 方向插值
result = vx0 * (1-y) + vx1 * y

步骤 3:用平滑函数代替线性插值(让导数连续)

smooth(t) = t * t * (3 - 2*t)  // 平滑步函数

7.4 代码示例:噪声驱动的粒子漂移

// 片元着色器中的简单噪声函数
highp float random(vec2 co) {
  highp float a = 12.9898;
  highp float b = 78.233;
  highp float c = 43758.5453;
  highp float dt = dot(co.xy, vec2(a, b));
  highp float sn = mod(dt, 3.14);
  return fract(sin(sn) * c);
}

// 用噪声让粒子位置产生漂移
void main() {
  float t = u_time;

  // 基础运动:直线飞行
  vec2 basePos = u_dir * u_speed * t;

  // 噪声漂移:随时间变化的随机偏移
  float noiseX = random(vec2(t * 0.5, u_id)) * 2.0 - 1.0;
  float noiseY = random(vec2(u_id, t * 0.5)) * 2.0 - 1.0;
  vec2 drift = vec2(noiseX, noiseY) * 0.1 * t;

  vec2 position = basePos + drift;

  gl_Position = vec4(position, 0, 1);
}

7.5 FBM:叠加多层噪声

单层噪声太规则了。真实自然现象(云、山脉、火焰)需要多层噪声叠加

float fbm(vec2 p) {
  float value = 0.0;
  float amplitude = 0.5;
  float frequency = 1.0;

  for (int i = 0; i < 5; i++) {
    value += amplitude * noise(p * frequency);
    amplitude *= 0.5;    // 每层振幅减半
    frequency *= 2.0;    // 每层频率翻倍
  }

  return value;
}

这就是 FBM(Fractal Brownian Motion,分形布朗运动)。它让噪声既有大尺度的波动,又有小尺度的细节。

7.6 常见误区

误区:"噪声就是随机数"

噪声是确定性的——相同的输入总是产生相同的输出。这很重要,因为:

  1. 你可以用时间作为输入,让噪声随时间变化
  2. 你可以用粒子 ID 作为输入,让每个粒子有独立的"随机性"
  3. 噪声是可重复的,适合做循环动画

误区:"噪声计算很快"

真正的 Perlin/Simplex Noise 在 GPU 上很快,但在 CPU 上计算大量噪声会很慢。如果需要在 CPU 端做噪声,建议预计算一张噪声纹理。

7.7 试一试

  1. webgl_particles 中给每个粒子加一个随时间变化的噪声偏移
  2. 让噪声影响粒子的颜色(暖色到冷色的波动)
  3. 尝试叠加 2~3 层不同频率的噪声,观察 FBM 效果

8. 点精灵:一个顶点画一个粒子

8.1 生活类比:印章

你要在纸上盖 10000 个圆点:

  • 方法一:每个圆点都用圆规画——定位、转一圈,重复 10000 次
  • 方法二:做一个圆形印章——蘸一下印泥,"啪"一下就是一个圆

Point Sprite 就是 GPU 的"圆形印章"。

8.2 本质是什么

传统画一个圆需要很多顶点(三角形扇,通常 20+ 个顶点)。

Point Sprite 利用 WebGL 的 gl.POINTS 模式:

  1. 每个粒子只发一个顶点 (x, y)
  2. 顶点着色器设置 gl_PointSize(点的大小,像素单位)
  3. 片段着色器里用 gl_PointCoord 判断当前像素是否在圆内
一个圆 = 1 个顶点(Point Sprite)
       vs
       22 个顶点(三角形扇,20 段)

8.3 代码示例:Point Sprite 圆形粒子

// 顶点着色器
attribute vec2 a_position;
uniform float u_pointSize;

void main() {
  gl_PointSize = u_pointSize;  // 设置点的大小(像素)
  gl_Position = vec4(a_position, 0, 1);
}
// 片段着色器
precision mediump float;

void main() {
  // gl_PointCoord 是当前像素在点内的坐标 [0,1]
  // (0,0) 是左下角,(1,1) 是右上角,(0.5,0.5) 是中心

  vec2 coord = gl_PointCoord - vec2(0.5);  // 映射到 [-0.5, 0.5]
  float dist = length(coord);               // 到中心的距离

  // 距离大于 0.5 就丢弃(画圆)
  if (dist > 0.5) discard;

  // 边缘抗锯齿
  float alpha = 1.0 - smoothstep(0.45, 0.5, dist);

  gl_FragColor = vec4(1.0, 0.5, 0.0, alpha);
}
// JS 端
 gl.drawArrays(gl.POINTS, 0, particleCount);

8.4 gl_PointCoord 的坐标系

(0, 1) -------- (1, 1)
   |     ●       |
   |   (0.5,0.5) |
   |             |
(0, 0) -------- (1, 0)

每个 Point Sprite 在屏幕上是一个正方形区域,gl_PointCoord 告诉你当前片段(像素)在这个正方形内的相对位置。

8.5 常见误区

误区:"Point Sprite 大小无限制"

gl_PointSize 有上限!通常是 64~256 像素,取决于 GPU。如果需要更大的粒子,要用两个三角形拼成的 Billboard。

误区:"Point Sprite 只能画圆"

只要能在片段着色器里用数学公式描述形状(星形、菱形、甚至文字),都可以用 Point Sprite。圆只是最常见的。

8.6 试一试

  1. webgl_particles 的三角形改成 Point Sprite 圆形
  2. 在片段着色器里把圆改成菱形(用 abs(x) + abs(y) < r
  3. 尝试给圆加上径向渐变(中心亮、边缘暗)

9. 实例化渲染:20万个粒子的秘密

9.1 生活类比:复印机

你要发 20 万份传单:

  • 手写 20 万份:一辈子也写不完
  • 油印/复印:刻一张蜡纸,机器哗哗哗地印——模板只准备一次,复制 20 万次

Instanced Rendering 就是 GPU 的"复印机"。

9.2 本质是什么

前面的例子中,每个粒子都要:

  1. 设置 uniform(颜色、位置等)
  2. 调用 gl.drawArrays

如果有 200000 个粒子,就是 200000 次 draw call!CPU 光发指令就累死了。

实例化渲染(Instanced Rendering) 的核心:

  • 模板几何:1 个顶点(一个 Point Sprite)
  • 实例数据:200000 个 (x, y, color, bias) 属性
  • 1 次 draw call:GPU 内部并行处理所有实例

9.3 公式推导:为什么实例化这么快?

假设每个粒子用传统方式绘制:

CPU 时间 = 200000 × (设置 uniform 时间 + draw call 开销)
         = 200000 × (0.01ms + 0.05ms)
         = 12000ms = 12 秒!

用实例化渲染:

CPU 时间 = 1 × draw call 开销 + 初始化缓冲区时间
         = 0.05ms + 50ms
         = 50.05ms

GPU 时间 = 200000 个顶点并行处理
         ≈ 几毫秒(GPU 有上千个核心)

9.4 代码示例:20 万个动画圆点

const COUNT = 200000;

// 初始化:一次性生成所有粒子的数据
function init() {
  const colors = [];
  const pos = [];
  const bias = [];

  for (let i = 0; i < COUNT; i++) {
    colors.push([Math.random(), Math.random(), Math.random(), 1]);
    pos.push([2 * Math.random() - 1, 2 * Math.random() - 1]);
    bias.push(Math.random()); // 动画相位偏移
  }

  renderer.setMeshData({
    mode: renderer.gl.POINTS,    // Point Sprite 模式
    enableBlend: true,           // 开启混合
    positions: pos,              // 20 万个位置
    attributes: {
      color: { data: [...colors] },  // 20 万套颜色
      bias: { data: [...bias] },     // 20 万个动画相位
    },
  });
}
// 顶点着色器
attribute vec2 a_vertexPosition;
attribute vec4 color;       // 每个实例不同的颜色
attribute float bias;       // 每个实例不同的动画相位

uniform float uTime;
uniform vec2 uResolution;

varying vec4 vColor;
varying float vScale;

void main() {
  // 每个实例有独立的缩放动画
  float scale = 0.7 + 0.3 * sin(6.28 * bias + 0.003 * uTime);

  gl_PointSize = 0.05 * uResolution.x * scale;

  vColor = color;
  vScale = scale;

  gl_Position = vec4(a_vertexPosition, 1, 1);
}
// 片段着色器
varying vec4 vColor;
varying float vScale;

void main() {
  vec2 coord = gl_PointCoord - vec2(0.5);
  float dist = length(coord);

  // 用 step 做硬边缘圆
  float d = step(dist, 0.5);

  gl_FragColor = d * vColor;
}

9.5 性能对比

方案 图形数量 Draw Call 帧率
Canvas 2D arc 1000 1000 ~30 fps
WebGL 逐次绘制 5000 5000 ~10 fps
WebGL Point Sprite 100000 1 ~60 fps
WebGL Point Sprite + Instanced 200000 1 ~55 fps

9.6 常见误区

误区:"20 万个点一定比 1000 个点慢"

在 GPU 上,只要顶点数据能装进缓冲区,20 万个点和 1000 个点的绘制时间可能只差几倍,而不是 200 倍——因为 GPU 是并行架构

误区:"实例化只能画完全相同的图形"

错!通过 instance attributes,每个实例可以有完全不同的位置、颜色、大小。在顶点着色器里用 attribute 接收 per-instance 数据,每个实例就独一无二。

9.7 试一试

  1. 打开 akira-graphics/performance-more/spots-points-batch.html
  2. COUNT 从 200000 改到 500000,观察帧率变化
  3. 在片段着色器里把圆改成发光效果(用 1.0 - smoothstep 做径向渐变)

10. 常见粒子特效:火、烟、火花、雨

10.1 火焰

特征

  • 从底部向上运动
  • 颜色从黄/白(底部)渐变到红/橙(中部)再到透明(顶部)
  • 大小先增大后减小
  • 受上升气流影响,左右摇摆

实现要点

// 火焰颜色随高度变化
float height = position.y / maxHeight;
vec3 color = mix(
  mix(vec3(1,1,0), vec3(1,0.5,0), height),  // 黄→橙
  vec3(0.5,0,0),                              // 暗红
  height * height
);

10.2 烟雾

特征

  • 向上飘,速度比火焰慢
  • 颜色:灰白到深灰
  • 体积逐渐扩大(扩散)
  • 透明度低,用加法混合或标准 Alpha 混合

实现要点

// 烟雾扩散:用噪声让位置偏移
vec2 drift = vec2(
  noise(position.xy * 0.1 + time * 0.5),
  noise(position.xy * 0.1 + time * 0.3 + 100)
) * 0.2;

// 大小随时间增大
float size = baseSize * (1.0 + p * 2.0);

// 透明度低
float alpha = 0.3 * (1.0 - p);

10.3 火花(Sparkles)

特征

  • 高速向外喷射
  • 生命周期短(0.2~0.5 秒)
  • 受重力影响,轨迹是抛物线
  • 颜色亮黄/白色
  • 用加法混合产生"发光"感

实现要点

// 爆发式发射
function explode(x, y) {
  for (let i = 0; i < 50; i++) {
    const angle = Math.random() * Math.PI * 2;
    const speed = 2 + Math.random() * 3;
    particles.push({
      x, y,
      vx: Math.cos(angle) * speed,
      vy: Math.sin(angle) * speed,
      life: 0.3 + Math.random() * 0.2,
      color: [1, 0.9, 0.5, 1]
    });
  }
}

10.4 雨

特征

  • 从上方直线下落
  • 速度较快且均匀
  • 生命周期:从屏幕顶部到底部
  • 可以用细长的线条(而非圆点)表示
  • 落地时产生涟漪(可选)

实现要点

// 雨用细线表示
// 顶点着色器:把点拉伸成线
gl_PointSize = 1.0;  // 宽度 1 像素
// 在片段着色器中,根据 y 坐标拉伸

或者用两个顶点画线段:

// 每滴雨由两个顶点组成
const rainDrop = [
  [x, y, 0],      // 底部
  [x, y + 0.05, 0] // 顶部(稍微高一点)
];

10.5 试一试

选择上面一种效果,从零开始实现:

  1. 确定发射器位置和发射频率
  2. 设计粒子的初始属性(位置、速度、颜色、大小)
  3. 在更新循环中应用物理(重力、阻力等)
  4. 在着色器中实现颜色/大小随时间变化
  5. 调整混合模式,达到想要的视觉效果

11. 本章 Q&A

Q1:粒子系统和普通动画有什么区别?

A:普通动画通常控制一个或几个物体的运动,而粒子系统同时管理成百上千个独立运动的小物体。粒子系统的核心是"批量管理大量相似对象",每个对象的生命周期很短,不断生成和消亡。

Q2:粒子数量上限是多少?

A:取决于渲染方式:

  • Canvas 2D:几百到几千个
  • WebGL 逐次绘制:几千个(受 draw call 限制)
  • WebGL Point Sprite + Instanced:几十万到上百万个

实际项目中,几百到几千个精心调教的粒子就能做出很好的效果。

Q3:为什么我的粒子叠加时颜色发灰?

A:你可能用了标准的 Alpha 混合(SRC_ALPHA, ONE_MINUS_SRC_ALPHA)。如果想让粒子"发光",改用加法混合(SRC_ALPHA, ONE)。

Q4:粒子系统需要排序吗?

A:如果粒子是半透明的且开启了深度测试,需要从远到近排序。但如果用 Point Sprite 且关闭深度测试(或只用加法混合),可以不排序——这是粒子系统的常见优化。

Q5:噪声和随机数到底有什么区别?

A:随机数:每次调用结果完全不相关,不可重复。噪声:输入相近 → 输出相近,相同输入 → 相同输出,是确定性的。噪声适合做连续的、有机的变化;随机数适合做离散的、独立的决策。

Q6:实例化渲染的兼容性如何?

A:WebGL 1.0 需要 ANGLE_instanced_arrays 扩展(覆盖率 > 98%)。WebGL 2.0 原生支持。现代引擎(Three.js、OGL、gl-renderer)都封装好了,无需担心。

Q7:粒子系统可以做碰撞检测吗?

A:可以,但通常只和场景中的大物体(地面、墙壁)做简单碰撞。粒子之间的碰撞开销太大,很少做。如果需要,可以用空间分割(如网格、八叉树)优化。

Q8:Point Sprite 有大小限制,怎么办?

A:如果粒子需要很大(超过 256 像素),改用两个三角形拼成的 Billboard。Billboard 是始终面朝相机的四边形,没有大小限制。

Q9:怎么让粒子效果看起来不"假"?

A:几个技巧:

  1. 给所有参数加上随机性(寿命、速度、大小、颜色)
  2. 用噪声代替纯随机,让运动更自然
  3. 颜色变化要丰富(火不是纯红的,有黄、橙、白)
  4. 透明度变化要柔和(淡入淡出)
  5. 参考真实视频或照片,观察细节

Q10:本章知识在实际项目中怎么用?

A:几个典型场景:

  1. 游戏:技能特效、爆炸、天气(雨/雪)、魔法光效
  2. 数据可视化:散点图的大量数据点、热力图
  3. UI/UX:按钮点击反馈、加载动画、转场特效
  4. 艺术创作:生成艺术、音画互动、粒子雕塑

本章总结

概念 核心要点 对应 Demo
粒子系统 大量小对象的批量管理:生成→更新→渲染→消亡 webgl_particles/
粒子属性 位置、速度、生命周期、大小、颜色随进度 p 变化 webgl_particles/app.js
粒子物理 重力、阻力、力场;核心是 F=ma 自定义练习
变换矩阵 平移×旋转×缩放,顺序从右往左 webgl_particles/app.js
Alpha 混合 SRC_ALPHA/ONE_MINUS_SRC_ALPHA 或加法混合 自定义练习
生成与消亡 持续/爆发/间隔发射;对象池避免 GC 自定义练习
噪声驱动 Perlin/Value Noise 让运动连续有机 noise/
Point Sprite 1 个顶点 = 1 个圆,gl_PointCoord 判断形状 performance-more/
实例化渲染 1 次 draw call 绘制 20 万+ 粒子 spots-points-batch.html
常见特效 火、烟、火花、雨的物理特征和实现要点 自定义练习

延伸阅读与练习

推荐练习

  1. 烟花效果:点击屏幕发射粒子,粒子向上飞一段后爆炸成更多粒子
  2. 下雪效果:从屏幕顶部持续生成白色粒子,受重力和风力影响飘落
  3. 魔法光环:围绕鼠标位置旋转的粒子环,用噪声让轨迹有波动
  4. 20 万星空:用 Instanced Point Sprite 做一个星空,支持鼠标拖拽旋转视角

参考文件索引

主题 文件路径
基础粒子系统 akira-graphics/webgl_particles/app.js
20 万点 Instanced akira-graphics/performance-more/spots-points-batch.html
Point Sprite 圆形 akira-graphics/performance-more/point-primitives-circle.html
噪声生成 akira-graphics/noise/
性能对比 akira-graphics/performance-basic/

"粒子系统不是魔法,只是把几百个简单的小东西放在一起,让它们各自运动。但合在一起,就是魔法。" —— 某位游戏特效师