09 什么是粒子系统:发射器与粒子个体
前置知识:第 02 章的混合、第 05 章的透明深度设置。
本章示例:持续火焰 · 场景源码 · 槽位源码。
预期画面:底座不断冒出暖色光团;关闭“持续发射”后,已有光团继续上升、变大、褪色,最终存活数变成 0。
先看现象
打开火焰示例,观察“当前存活”和“累计出生 / 自然死亡”。发射器以约每秒 55 个的节奏把粒子放到火焰底部,每个粒子带着略有差别的寿命、水平漂移和上升速度。稳定一段时间后,出生与死亡都在增长,存活数却在某个范围内波动。
取消“持续发射”:出生数不再增加,但画面不会立刻清空。已有粒子仍按自己的年龄前进,直到寿命结束;此后存活数为 0,出生总数等于自然死亡总数。再点击“单发一个粒子”,右侧会追踪最近出生者的年龄和归一化进度。这比只看一团火更容易理解系统内部发生了什么。
1. “粒子”不是固定的物体类型
**粒子系统(Particle System)**是一种组织大量相似但各自有状态的个体的方式。一个粒子通常记录“我在哪里、往哪走、活了多久、现在如何显示”,系统按时间批量发射、更新并回收它们。粒子并不必然是物理学中的小球,也不必然对应一个 Three.js 对象。
本章为了看清状态,把每个粒子显示为一个 THREE.Sprite。Sprite 是始终朝向相机的二维片面,适合软圆贴图。但同一套状态与生命周期也可以用 THREE.Points 的一个点、网格四边形、实例化模型,甚至 GPU 纹理中的一条记录来呈现。后续第 13—17 章会换渲染表示;这里先固定概念。
| 层次 | 持有什么 | 火焰中的例子 |
|---|---|---|
| 发射器(Emitter) | 出生规则和全局参数 | 每秒发射数量、出生区域、基础寿命 |
| 粒子状态(Particle State) | 每个个体不同且随时间变化的数据 | age、life、位置、速度、基础尺寸 |
| 更新规则(Update) | 根据经过的时间推进状态 | 上升、漂移、年龄增长、颜色渐变 |
| 渲染表示(Renderer/Representation) | 把状态变成画面 | 带软圆贴图的 SpriteMaterial 和 Sprite |
这四层可以替换组合。雨滴用窄线或实例化网格,雪花用柔和点片,爆炸用一次性径向发射,星光可能几乎不移动;底层仍可共享发射、年龄和回收机制。人群与鸟群也能用“很多个体”来渲染,但若要队形、避让、目标或邻居互动,还需额外的行为规则,不能把独立随机粒子直接当作群体智能。
2. 一个粒子的最小状态
示例预先准备固定数量的槽位。每个槽位大致是:
{
alive: false,
age: 0, life: 1, // 秒
vx: 0, vy: 0, vz: 0, // 世界单位/秒
baseSize: 1, // 世界单位
sprite, material
}
alive 表示槽位当前是否承载活粒子;age 从出生后的 0 秒累加;life 是该粒子的寿命。Sprite.position 持有世界位置,vx/vy/vz 是每秒位移率。baseSize 与年龄曲线相乘得到显示尺寸。示例把状态和显示对象放在同一个槽位里,便于阅读;高性能实现常将数值状态改成紧凑数组,把渲染数据批量提交。
核心区别是状态属于个体,规则属于系统。发射率属于发射器;某个粒子的实际寿命和速度在出生时确定。你调大“基础寿命”,只影响此后出生的粒子,已出生者仍按自己的 life 死亡。
3. 出生、更新、死亡构成闭环
最小的动画循环可写为:
function frame( deltaTime ) {
emissionRemainder += emissionRate * deltaTime;
while ( emissionRemainder >= 1 ) {
spawnParticle();
emissionRemainder -= 1;
}
for ( const particle of slots ) {
if ( ! particle.alive ) continue;
particle.age += deltaTime;
if ( particle.age >= particle.life ) {
releaseParticle( particle );
continue;
}
particle.sprite.position.x += particle.vx * deltaTime;
particle.sprite.position.y += particle.vy * deltaTime;
updateAppearance( particle );
}
}
emissionRate 单位是个/秒,deltaTime 单位是秒,乘积就是本帧希望出生的数量。非整数部分留给下一帧,避免把“每帧发 1 个”误写成随刷新率变化的发射率。releaseParticle 在本例只把槽位标为闲置并隐藏 Sprite,下一次出生可重用它;下一阶段会深入讲池化与发射率累计。
更新时先检查年龄到期,再决定是否移动和显示。实际游戏可采用不同顺序或固定步长,但需明确约定。例如示例把每帧 deltaTime 限到 0.05 秒,避免后台标签恢复后一次性涌出大量粒子;这意味着暂停期间不追赶真实流逝时间,不是通用物理模拟方案。
4. 从状态到火焰画面
出生位置在底座附近随机分布,速度主要向上,水平速度只给少量漂移。出生时颜色偏浅黄;随着 t = age / life 增长,Sprite 逐渐变大、变暗、透明度降低。许多不同年龄的光团同时存在,形成“底部明亮、上方散开”的火焰结构。
const t = particle.age / particle.life;
particle.sprite.scale.setScalar( particle.baseSize * ( 0.55 + 1.25 * t ) );
particle.material.opacity = 0.8 * ( 1 - t ) ** 1.4;
particle.material.color.copy( baseColor ).lerp( tipColor, t );
这里的尺寸是 Sprite 的世界单位,不是 PointsMaterial.size 的屏幕像素。颜色用 Three.js Color 插值;贴图是本地生成的白色软圆,其 RGB 以 SRGBColorSpace 标注。白贴图乘上材质颜色后成为黄、橙、红光团。材质使用 transparent:true、加法混合、depthTest:true、depthWrite:false:火焰可被更近的不透明物体遮挡,却不会让先画的软团在深度缓冲中硬裁后画的团。加法混合适合发光火焰,但不适合所有烟雾。
5. 参数变化与容量
提高发射率会让同时存活的粒子更多。粗略估算,稳定状态下的平均存活数约为“每秒出生数 × 平均寿命”,前提是槽位够用且没有其他提前回收条件。例如默认约 55 × 1.6 ≈ 88 个,与示例观察值同量级,不要求每一帧恰好为 88。
示例固定 140 个槽位。把发射率和寿命都调到很高时,可能找不到空槽;页面的“容量不足跳过”就增加。此时不是粒子凭空延长寿命,而是新的出生请求未被承载。实际项目可增加容量、限制发射、优先替换最不重要的粒子,或换用批量渲染;这些选择会影响视觉与性能。
本例每个活 Sprite 都会单独提交绘制,适合教学的百级数量,不宜据此推导十万粒子的方案。后续 THREE.Points 和属性数组会把许多个体合成更少的绘制对象。
常见问题
停止发射为什么仍有火? 关闭的只是出生;已有粒子继续更新并自然死亡。若要立即清空,需要显式回收所有存活槽位。
粒子系统必须有随机数吗? 不必须。随机数帮助消除机械重复;规则网格、确定性序列也可以是粒子系统。随机分布怎样设计见第 12 章所属阶段。
一个粒子必须对应一个 Sprite 吗? 不必须。本章这样做是为了把状态和画面一一对应;大规模效果通常批量渲染。
完成第 09 章练习,再阅读第 10 章。参考:Sprite、SpriteMaterial。