11 时间、发射率与粒子池
前置知识:第 09 章的发射器状态和第 10 章的生命周期。
本章示例:更新频率与槽位复用 · 场景源码 · 粒子池源码。
预期画面:30、60、120 Hz 下绿色计时球按经过的秒数前进,橙色球随频率变速;右侧喷泉的累计出生和复用次数增长,预创建 Sprite 始终为 48。
先看现象:一帧并非固定的一段时间
打开示例,在 60 Hz 下重置计时,两个球几乎同步。切换到 30 Hz,橙色球约为绿色球速度的一半;切换到 120 Hz,约为两倍。这里的频率是页面内部的虚拟更新频率,并未改变显示器刷新率:绿色球每次虚拟更新累加 1 / hz 秒,橙色球故意固定累加 1 / 60 秒。经过一秒墙钟时间,橙色累计约 hz / 60 秒。
喷泉是独立对照:它使用真实动画帧的秒数更新,切换虚拟频率不会改变喷泉的速度。调高发射率,可见“累计获取”“复用次数”继续增长,但“预创建 Sprite”一直为 48。若同时活跃的粒子超过 48,新出生请求被跳过,槽满跳过计数增长。
1. deltaTime 决定每次更新走多远
位置单位是世界单位,速度单位是世界单位/秒。如果 vx = 2 且这一帧实际经过 dt = 0.01 秒,位移应为 0.02 世界单位:
const dt = clock.getDelta(); // 秒
particle.age += dt;
particle.sprite.position.x += particle.vx * dt;
错误写法 position.x += vx / 60 隐含“每秒正好更新 60 次”。高刷、掉帧、后台节流或主动降频都会改变运动速度。寿命同理:age += dt,并用 age >= life 判定死亡;不能把“走过的帧数”直接当秒数。
示例用一个真实帧的 dt 推进喷泉,再用累加器按所选频率执行计时球的虚拟更新:
tickAccumulator += dt;
const step = 1 / hz;
while ( tickAccumulator >= step ) {
correctTime += step;
wrongTime += 1 / 60; // 故意展示的错误
tickAccumulator -= step;
}
这段代码演示“更新次数”和“经过时间”可以分离。若某一真实帧足够长,循环可执行多次;真实显示帧率低于 120 Hz,也仍可近似模拟 120 Hz 的更新。计时球每累计 2 秒回到轨道底部,只是可视化的取模,累计时间没有归零。
2. 每秒发射率需要保存不足一个粒子的余数
令发射率 rate 的单位为个/秒,那么本帧应请求 rate × dt 个。请求数通常不是整数。直接四舍五入会让结果依赖帧率;例如 28 个/秒在 60 Hz 下每帧约 0.47 个,若每帧取整为 0,就永远不发射。
emissionRemainder += rate * dt;
while ( emissionRemainder >= 1 ) {
spawn();
emissionRemainder -= 1;
}
余数跨帧保存;在理想情况下,经过 T 秒大约请求 rate × T 次,整数误差小于 1。示例中 spawn() 即使因池满返回,仍会扣掉一次请求:这表示该时刻出生机会已错过,不会在池恢复空闲后突然补发。若产品需要延迟补发,应另设待发队列与上限,明确“过期请求”如何处理。
3. 为什么使用池化复用
朴素实现每次出生都 new Sprite、new SpriteMaterial、scene.add(),死亡时 scene.remove() 并丢弃引用。持续发射会反复分配和清理 JavaScript 对象,触发垃圾回收(GC);GPU 资源也必须按生命周期正确 dispose()。GC 停顿和场景结构反复变化会造成不稳定的帧时间。
本例启动时一次性创建 48 个 Sprite、48 个材质及状态槽位,之后在“活跃”和“空闲”之间切换。它避免出生时的这些对象分配,但池化不等于批量渲染:每个可见 Sprite 仍是独立对象,数量大时绘制提交会成为成本。第 13—15 章再把数据放进 BufferGeometry。
一个槽位至少要记录位置或对应的可见对象、速度、年龄、寿命、尺寸及活跃状态。再次获取槽位时,必须完整重置本轮状态;否则上一任的速度、颜色或透明度会泄漏到新粒子。示例的 acquire() 重置 age 和可见性,spawn() 随后设置 life、vx/vy、位置、尺寸、颜色和透明度。
4. 空闲栈与紧凑活跃表
pool.js 维护三部分:固定容量的 slots、可取出的 freeIndices 栈、只含存活槽位编号的 activeIndices。出生时从空闲栈弹出一个编号并放入活跃表;更新时只遍历活跃表,不扫全部容量。槽满时 acquire() 返回 null,不越界创建新对象。
死亡时要从活跃表删除一个编号。若用 splice(),后续编号整体左移;本例采用交换删除,用最后一个编号填被删位置,再 pop()。槽位存有自己的 activeListIndex,所以能直接定位。顺序会改变,适合粒子这种通常不依赖活跃表顺序的场景:
function release( slot ) {
if ( ! slot.alive ) return;
const lastIndex = activeIndices.pop();
if ( lastIndex !== slot.index ) {
activeIndices[ slot.activeListIndex ] = lastIndex;
slots[ lastIndex ].activeListIndex = slot.activeListIndex;
}
slot.activeListIndex = -1;
slot.alive = false;
slot.sprite.visible = false;
freeIndices.push( slot.index );
}
更新循环使用正序:release() 后末尾元素被换到当前下标 i,因此死亡分支不递增 i,下一轮继续处理新换来的粒子。这样每个活粒子每帧恰好处理一次:
for ( let i = 0; i < pool.activeIndices.length; ) {
const p = pool.slots[ pool.activeIndices[ i ] ];
p.age += dt;
if ( p.age >= p.life ) {
pool.release( p );
continue; // 新换到 i 的粒子还没处理
}
// 更新运动和外观……
i ++;
}
这也说明池化的正确性不只看“没有 new”,还要检查活跃表的增删语义。若渲染或模拟要求稳定顺序,就不能直接用交换删除,应选择保序结构并接受相应成本。
5. 暂停、长帧和固定步长的边界
示例每帧取 dt = min(clock.getDelta(), 0.1)。标签页从后台恢复时,极大的间隔不会一下把喷泉全部推到死亡,也限制了虚拟更新循环的次数。代价是被截去的时间不再补回,模拟会比墙钟时间慢。运动页还使用了更小的 0.05 秒上限。
若模拟需要可重复、较稳定的碰撞结果,可以用固定步长累加器,例如每次模拟 1/60 秒,渲染仍按屏幕帧率运行;同时要限制单帧最大子步数,防止低帧率下不断追赶导致更慢。固定步长并不意味着“每个显示帧只走一次固定距离”。若需要经过后台的真实时间,也要设计暂停/追赶策略,不能简单把 dt 截断。
常见问题
复用次数增长为什么不能证明帧率更高? 它只证明同一批槽位反复使用。性能还受 JS 更新、对象提交、像素覆盖面积和 GPU 状态变化影响;性能比较要记录设备、画布尺寸、像素比和粒子数。
池满时增大容量一定更好吗? 容量增加会占用更多内存,并可能提高同时可见对象数。先判断是需要更多并发粒子、缩短寿命、降低发射率,还是改成批量渲染。
为什么每帧 new Vector3() 不合适? 在活跃粒子的内层循环中创建临时向量会放大分配和 GC 压力。可直接更新数值分量,或在循环外复用一个临时对象;出生事件少时的偶发分配与每帧每粒子分配不是同一量级。
完成第 11 章练习,再读第 12 章:随机分布与运动积分。参考:Clock.getDelta、Sprite。