10 生命周期:发射、更新、死亡与属性曲线
前置知识:第 09 章的发射器和粒子状态。
本章示例:一次性爆炸 · 场景源码 · 槽位源码。
预期画面:一批粒子从中心向外扩散,先亮后暗,尺寸按所选曲线变化;关闭自动重复后存活数归零,点击按钮才能再爆发。
先看现象
持续火焰靠“每秒不断出生”维持画面;爆炸在一个事件发生时同一轮一次性出生。本例点击“立即爆发一轮”后,粒子在中心产生,沿不同方向散开。每个粒子的寿命略有差异,所以不会同时整齐消失。
把“自动重复”关闭,再触发一轮:当前存活数从本轮数量逐渐降为 0,“累计自然死亡”增加。若在上一轮尚有活粒子时再次点击按钮,示例会先把旧粒子提前回收,这一部分记在单独的计数中,避免把重启当成自然死亡。选择尺寸曲线时,当前粒子的尺寸立即按同一年龄重新计算;数量和基础寿命只影响下一轮出生。
1. 生命周期是状态机
一个槽位可以简化成以下状态转移:
空闲 ──发射/初始化──> 存活(age=0)
存活 ──每帧更新──> 存活(0<age<life)
存活 ──age≥life──> 空闲(自然死亡)
存活 ──重启/取消──> 空闲(提前回收)
“死亡”在程序里通常是不再参与更新与渲染、释放或复用槽位,并不意味着必须销毁 JavaScript 对象或 GPU 材质。本例的 createSpritePool() 在页面初始化时创建固定槽位;acquire() 找到闲置槽位,release() 隐藏 Sprite 并标记为空闲。为什么要这样做、怎样避免扫描与 GC 抖动,留到第 11 章深入展开。
要保证一次出生完整初始化:年龄归零、设置新寿命/速度/位置/尺寸/颜色/透明度,并显示对象。少重置一个字段就可能把上一任粒子的状态带到新粒子上。
2. 归一化年龄把各种寿命映射到同一曲线
令:
t = clamp(age / life, 0, 1)
age 与 life 都以秒计;t=0 是出生,t=1 是死亡边界。一个寿命 0.8 秒和一个寿命 2.4 秒的粒子,只要 t 相同,就可使用同一套“此时应多大、多亮、什么颜色”的曲线。寿命改变的是沿曲线前进的速度,而非曲线形状。
本例为每个粒子设置 life = 基础寿命 × (0.8 到 1.2 的随机因子),所以同一轮内部的死亡时刻不同。这是设计选择,不是粒子系统必须具备的随机性。
3. 尺寸、透明度和颜色是不同曲线
示例提供两种尺寸函数,尺寸均为 Sprite 的世界单位:
// 先膨胀再收缩,出生/死亡附近都小,中段最大
size = baseSize * ( 0.15 + 1.7 * Math.sin( Math.PI * t ) );
// 一开始较大,之后线性收缩
size = baseSize * ( 1.6 - 1.45 * t );
膨胀曲线在出生和死亡边界都返回 0.15 × baseSize;线性收缩曲线从 1.6 × baseSize 开始,死亡边界返回 0.15 × baseSize。死亡时对象立即隐藏。曲线选择实时读取,所以切换后当前活粒子的大小会立即变化。
透明度另用 opacity = 0.9 × (1−t)^1.35,从较亮逐渐消失。颜色从浅黄插值到橙色,再到暗红。颜色区间在 t=0.45 分段,避免只用两端线性插值而缺少中段的橙色。尺寸、Alpha 和颜色可以各有独立的关键点;不要把“年龄增加”直接等同于“所有属性都线性变小”。
if ( t < 0.45 ) {
material.color.copy( startColor ).lerp( middleColor, t / 0.45 );
} else {
material.color.copy( middleColor ).lerp( endColor, ( t - 0.45 ) / 0.55 );
}
这里复用已有 Color 对象,更新循环没有每帧 new THREE.Color()。颜色插值和最终显示还受 Three.js 颜色管理、贴图色彩空间和加法混合影响;这是材质颜色变化规则,不是直接写屏幕像素的 sRGB 数值。
4. 一次性发射与逐帧更新
一次爆炸的出生只发生在事件触发时:
function burst( count ) {
for ( let i = 0; i < count; i ++ ) {
const particle = pool.acquire();
if ( particle === null ) break;
particle.age = 0;
particle.life = baseLife * randomLifeFactor();
particle.sprite.position.set( 0, 0, 0 );
// 初始化方向、速度、基础尺寸和初始外观……
}
}
之后每帧只更新当前存活粒子:age += deltaTime,到期则回收;没到期才移动并按 t 重算外观。示例的径向方向来自随机角度;为让不同寿命的粒子大致扩散到相近范围,速度使用“目标半径 ÷ 该粒子寿命”。随机方向和积分的数学细节在第 12 章展开。
deltaTime 来自 THREE.Clock.getDelta() 并以秒为单位,不能假定每帧等于 1/60 秒。示例把单次步长上限设为 0.05 秒,防止网页后台恢复时巨大的更新步长;这会让后台期间模拟时间走得比墙钟时间慢。需要严格模拟真实经过时间时,应设计固定步长/子步、追赶策略和预算,而不是直接复制本示例的限幅。
5. 生命周期与渲染仍是两件事
粒子的状态能活着,但不一定有可见像素:可能 Alpha 已很低、被墙挡住、在视野外。相反,静止的星光也可能没有显著运动,仍有出生和死亡。把“当前存活数”当作“屏幕上可见点数”会误导调试。
本例一个活粒子对应一个 Sprite 和独立材质,便于单独改变透明度与颜色。SpriteMaterial 使用加法混合、depthTest:true, depthWrite:false,适合发光爆炸的近似。若目标是带烟的爆炸,需要区分火光层与烟雾层,烟可能更适合普通 Alpha 混合,还会遇到第 07 章的排序问题。数量提高后,逐 Sprite 提交开销显著;第 13—17 章会改用 Points、属性数组和着色器。
常见问题
为什么调“基础寿命”后旧粒子没有立刻变慢? 每个粒子在出生时保存自己的 life;控件只改变下一轮的初始化值。若要实时重定时,必须明确如何改已有个体的年龄比例。
为什么再点一次爆发会增加“提前回收”? 示例把旧轮活粒子先清掉,以保证一轮最多占用 160 个槽位;这些粒子未自然走到寿命终点,所以与自然死亡分别计数。
曲线一定要写成公式吗? 不一定。可用关键帧表、查找纹理或着色器函数。关键是明确输入是 t,并处理 t=0、t=1、区间外与插值方式。
完成第 10 章练习。接着读第 11 章:时间、发射率与粒子池,再读第 12 章:随机分布与运动积分。参考:Clock.getDelta、SpriteMaterial。