第二十三章:粒子系统与特效
欢迎来到粒子世界!如果说前面的章节是在学习"怎么画一个东西",那么这一章就是学习"怎么让成千上万个东西一起动起来"。粒子系统是游戏和可视化中最有"魔法感"的技术——烟花、雪花、火焰、魔法光效,全都可以用粒子实现。
前置章节:第10章 动画与缓动原理、第17章 3D渲染基础
预计学习时间:3天
目录
- 什么是粒子系统?
- 粒子属性:位置、速度、生命周期、大小、颜色
- 粒子物理:重力、阻力、力场
- 变换矩阵:让粒子动起来
- Alpha 混合:粒子的透明艺术
- 粒子生成与消亡:生生不息
- 噪声驱动动画:让粒子有"灵魂"
- 点精灵:一个顶点画一个粒子
- 实例化渲染:20万个粒子的秘密
- 常见粒子特效:火、烟、火花、雨
- 本章 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,观察:
- 页面加载后,彩色小三角形不断从中心产生
- 每个三角形朝不同方向飞出、旋转、变大再消失
- 在控制台执行
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 函数:
- 让颜色偏向暖色调(红色和黄色为主)
- 让大小在更大范围内随机(0.01 ~ 0.15)
- 给生命周期加上随机性
观察效果的变化。
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) 里的 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 的基础上:
- 给每个粒子加上向下的重力,让轨迹变成抛物线
- 加上阻力系数,让粒子飞不远
- 在画面中心加一个吸引力场,让粒子像被黑洞吸引一样
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 试一试
修改着色器中的矩阵乘法顺序:
- 把
translateMatrix * rotateMatrix * scaleMatrix改成scaleMatrix * rotateMatrix * translateMatrix - 观察粒子的运动方式有什么变化
- 思考为什么顺序会影响结果
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 混合不需要开启深度测试"
如果开启了深度测试,半透明的粒子需要从远到近绘制,否则前面的粒子会挡住后面的,混合结果出错。正确的做法是:
- 先绘制所有不透明物体
- 按深度从远到近排序半透明粒子
- 绘制半透明粒子
误区:"加法混合没有透明度"
加法混合确实不"遮挡"背景,但源颜色的 Alpha 仍然参与计算。gl_FragColor.a 在加法混合中影响的是"贡献多少颜色到最终画面"。
5.6 试一试
在 webgl_particles 中:
- 开启
gl.BLEND并设置blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA) - 观察粒子叠加时的颜色变化
- 改成
blendFunc(gl.SRC_ALPHA, gl.ONE),观察"发光"效果 - 尝试让粒子诞生时 Alpha 从 0 渐变到 1(淡入),死亡时从 1 渐变到 0(淡出)
6. 粒子生成与消亡:生生不息
6.1 生活类比:喷泉
想象公园里的喷泉:
- 水从喷头不断涌出——持续发射
- 每滴水在空中飞一会儿——生命周期
- 落入池中消失——死亡
- 池中的水被泵抽回喷头——对象池(可选优化)
6.2 本质是什么
粒子系统的生命周期管理有三个核心问题:
- 什么时候生成新粒子?
- 生成多少?
- 什么时候销毁旧粒子?
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 试一试
实现一个"点击爆炸"效果:
- 点击画布时,在点击位置生成 50 个粒子
- 粒子向四周随机方向飞出
- 1 秒后全部消失
- 用对象池管理粒子,避免频繁创建销毁
7. 噪声驱动动画:让粒子有"灵魂"
7.1 生活类比:风吹过麦田
想象一片麦田:
- 如果没有风,所有麦子直挺挺地站着——机械、死板
- 有风时,麦子起伏波动,但没有两株麦子的摆动完全相同——自然、有机
噪声就是计算机里的"风",它让粒子的运动带有随机但连续的波动。
7.2 本质是什么
普通随机数(Math.random())的问题是:完全不相关。前一帧和后一帧的随机值之间没有任何联系。
噪声(Noise)的特点是:连续、平滑、可重复。给定相同的输入,总是输出相同的值;输入稍微变化,输出也只稍微变化。
最常用的噪声是 Perlin Noise 和 Simplex 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 常见误区
误区:"噪声就是随机数"
噪声是确定性的——相同的输入总是产生相同的输出。这很重要,因为:
- 你可以用时间作为输入,让噪声随时间变化
- 你可以用粒子 ID 作为输入,让每个粒子有独立的"随机性"
- 噪声是可重复的,适合做循环动画
误区:"噪声计算很快"
真正的 Perlin/Simplex Noise 在 GPU 上很快,但在 CPU 上计算大量噪声会很慢。如果需要在 CPU 端做噪声,建议预计算一张噪声纹理。
7.7 试一试
- 在
webgl_particles中给每个粒子加一个随时间变化的噪声偏移 - 让噪声影响粒子的颜色(暖色到冷色的波动)
- 尝试叠加 2~3 层不同频率的噪声,观察 FBM 效果
8. 点精灵:一个顶点画一个粒子
8.1 生活类比:印章
你要在纸上盖 10000 个圆点:
- 方法一:每个圆点都用圆规画——定位、转一圈,重复 10000 次
- 方法二:做一个圆形印章——蘸一下印泥,"啪"一下就是一个圆
Point Sprite 就是 GPU 的"圆形印章"。
8.2 本质是什么
传统画一个圆需要很多顶点(三角形扇,通常 20+ 个顶点)。
Point Sprite 利用 WebGL 的 gl.POINTS 模式:
- 每个粒子只发一个顶点
(x, y) - 顶点着色器设置
gl_PointSize(点的大小,像素单位) - 片段着色器里用
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 试一试
- 把
webgl_particles的三角形改成 Point Sprite 圆形 - 在片段着色器里把圆改成菱形(用
abs(x) + abs(y) < r) - 尝试给圆加上径向渐变(中心亮、边缘暗)
9. 实例化渲染:20万个粒子的秘密
9.1 生活类比:复印机
你要发 20 万份传单:
- 手写 20 万份:一辈子也写不完
- 油印/复印:刻一张蜡纸,机器哗哗哗地印——模板只准备一次,复制 20 万次
Instanced Rendering 就是 GPU 的"复印机"。
9.2 本质是什么
前面的例子中,每个粒子都要:
- 设置 uniform(颜色、位置等)
- 调用
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 试一试
- 打开
akira-graphics/performance-more/spots-points-batch.html - 把
COUNT从 200000 改到 500000,观察帧率变化 - 在片段着色器里把圆改成发光效果(用
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 试一试
选择上面一种效果,从零开始实现:
- 确定发射器位置和发射频率
- 设计粒子的初始属性(位置、速度、颜色、大小)
- 在更新循环中应用物理(重力、阻力等)
- 在着色器中实现颜色/大小随时间变化
- 调整混合模式,达到想要的视觉效果
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:几个技巧:
- 给所有参数加上随机性(寿命、速度、大小、颜色)
- 用噪声代替纯随机,让运动更自然
- 颜色变化要丰富(火不是纯红的,有黄、橙、白)
- 透明度变化要柔和(淡入淡出)
- 参考真实视频或照片,观察细节
Q10:本章知识在实际项目中怎么用?
A:几个典型场景:
- 游戏:技能特效、爆炸、天气(雨/雪)、魔法光效
- 数据可视化:散点图的大量数据点、热力图
- UI/UX:按钮点击反馈、加载动画、转场特效
- 艺术创作:生成艺术、音画互动、粒子雕塑
本章总结
| 概念 | 核心要点 | 对应 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 |
| 常见特效 | 火、烟、火花、雨的物理特征和实现要点 | 自定义练习 |
延伸阅读与练习
推荐练习
- 烟花效果:点击屏幕发射粒子,粒子向上飞一段后爆炸成更多粒子
- 下雪效果:从屏幕顶部持续生成白色粒子,受重力和风力影响飘落
- 魔法光环:围绕鼠标位置旋转的粒子环,用噪声让轨迹有波动
- 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/ |
"粒子系统不是魔法,只是把几百个简单的小东西放在一起,让它们各自运动。但合在一起,就是魔法。" —— 某位游戏特效师