CC Particle World: Birth Rate decides the births of its own time, so a burst lives on (#1373)

The simulation runs from layer time 0, but it used the Birth Rate of the
rendered frame for every step. A rate keyed from 3 down to 0 simulated
no births at all once it reached 0, so the burst's particles vanished
that frame although their Longevity had not run out. A ramped rate was
simulated as if its current value had always applied.

Each step now samples Birth Rate at its own layer time through
EffectHost::param_at (with the effect's own index). Without such a host
the current value applies throughout, as before. Particles already alive
keep moving and ageing whatever the rate. The carry and particle ids
continue across rate changes.

Birth Rate is left out of the simulation cache key, so an animated rate
no longer restarts the simulation every frame. In its place:

- The CPU state keeps a hash of the rates it was stepped with (hist). A
  checkpoint is resumed only when that matches the current history, so
  moving an earlier key is never served from a stale checkpoint.
- The GPU gets the birth schedule of the sampled rates (births_at). Its
  checkpoints record which births they hold, and are resumed only when
  the request's births before that step are the same.

A constant Birth Rate gives exactly the particles it did. CC Particle
Systems II and CC Mr. Mercury keep their constant-rate simulation.
This commit is contained in:
tylerjenningsw
2026-10-13 20:45:20 -04:00
parent a00a76e7ed
commit bb0e2b57db
2 changed files with 91 additions and 15 deletions
+18 -2
View File
@@ -23,10 +23,25 @@ struct Checkpoint {
key: u64,
steps: u64,
n: u32,
/// The birth steps of its particles (see [`born`]).
born: u64,
buf: wgpu::Buffer,
used: u64,
}
/// The particles born in the first `steps` steps of `births` (ascending birth steps) and a hash
/// of their birth steps: a checkpoint is only resumed from when the request's births before it
/// are the same (a keyframed Birth Rate isn't part of the system key, #1373).
fn born(births: &[u32], steps: u64) -> (u32, u64) {
let mut h = 0xcbf2_9ce4_8422_2325u64;
let mut n = 0u32;
for &b in births.iter().take_while(|&&b| u64::from(b) < steps) {
h = (h ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3);
n += 1;
}
(n, h)
}
#[derive(Default)]
pub(crate) struct Checkpoints {
list: Vec<Checkpoint>,
@@ -141,7 +156,7 @@ pub(crate) fn simulate(g: &GpuContext, req: &SimRequest) -> Option<Vec<SimPartic
let mut c = g.particle_states.lock().ok()?;
c.clock += 1;
let now = c.clock;
match c.list.iter_mut().filter(|k| k.key == req.key && k.steps <= req.steps).max_by_key(|k| k.steps) {
match c.list.iter_mut().filter(|k| k.key == req.key && k.steps <= req.steps && born(req.births, k.steps) == (k.n, k.born)).max_by_key(|k| k.steps) {
Some(k) => {
k.used = now;
(k.steps, Some(k.buf.clone()), k.n)
@@ -232,7 +247,8 @@ fn keep_checkpoint(g: &GpuContext, req: &SimRequest, n: u32, next: wgpu::Buffer)
if let Ok(mut c) = g.particle_states.lock() {
let now = c.clock;
c.list.retain(|k| !(k.key == req.key && k.steps == req.steps));
c.list.push(Checkpoint { key: req.key, steps: req.steps, n, buf: next, used: now });
let (_, h) = born(req.births, req.steps);
c.list.push(Checkpoint { key: req.key, steps: req.steps, n, born: h, buf: next, used: now });
while c.list.len() > CHECKPOINTS {
let Some(i) = c.list.iter().enumerate().min_by_key(|(_, k)| k.used).map(|(i, _)| i) else { break };
c.list.remove(i);