Merge pull request #1390 from tylerjenningsw/fix/particle-birth-history-ps2-mercury

Particles: CC Particle Systems II and CC Mr. Mercury keep emitted particles when Birth Rate changes (#1373)
This commit is contained in:
BFlat
2026-10-30 01:25:58 +07:00
committed by GitHub
8 changed files with 279 additions and 58 deletions
+3 -3
View File
@@ -1312,11 +1312,11 @@ pub fn is_time_dependent(id: &str) -> bool {
id.starts_with("ec.sim.") || TIME_DEPENDENT.contains(&id)
}
/// Does effect `id` read its own parameters at earlier times (CC Particle World samples its
/// Birth Rate at every step, #1373)? Caches keyed on the current values can't see what an
/// Does effect `id` read its own parameters at earlier times (the stepped particle systems sample
/// their Birth Rate at every step, #1373)? Caches keyed on the current values can't see what an
/// expression returned then.
pub fn reads_param_history(id: &str) -> bool {
id == "ec.sim.ccparticleworld"
matches!(id, "ec.sim.ccparticleworld" | "ec.sim.ccparticlesystems2" | "ec.sim.ccmrmercury")
}
/// Run an effect on a buffer.
+4 -4
View File
@@ -15,7 +15,7 @@ use effectcraft_raster::{Image, Px};
use rayon::prelude::*;
use crate::generate::value_noise;
use crate::util::{SimCache, hash1, params_key, params_key_except, unpremul};
use crate::util::{SimCache, hash1, params_key_except, unpremul};
use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider};
mod common;
@@ -28,13 +28,13 @@ mod weather;
pub(crate) use common::{h, hs, pct, pt, spec};
pub use engine::particle_state;
#[cfg(test)]
pub(crate) use engine::{PState, PW_ANIMS, Phys, simulate};
use engine::{MERCURY, PS2, PW_EFFECT_CAMERA, Stepped, rate_history, sim_key, simulate_rates};
#[cfg(test)]
use engine::{PW_EFFECT_CAMERA, rate_history, sim_key, simulate_rates};
pub(crate) use engine::{PState, PW_ANIMS, Phys, simulate};
pub(crate) use engine::{SPS, steps_at};
pub use particle_fx::{Blob, mercury_blobs};
#[cfg(test)]
use particle_fx::{PW_RATE, pw_phys};
use particle_fx::{PW_RATE, mercury_phys, ps2_phys, pw_phys};
pub use registry::{specs, sprite_plan};
pub use sprites::{Acc, Post, Shape, Sprite, SpritePlan, Tint};
pub(crate) use sprites::{combine, raster, splat};
+41 -13
View File
@@ -1,5 +1,5 @@
use super::common::{h, hs};
use super::particle_fx::{PS2_CACHE, PW_CACHE, PW_RATE, ps2_phys, pw_phys};
use super::particle_fx::{MERCURY_CACHE, MERCURY_RATE, PS2_CACHE, PS2_RATE, PW_CACHE, PW_RATE, ps2_phys, pw_phys};
use super::sprites::{Shape, Sprite};
use super::{Buf, EffectCtx, Image, IntoParallelRefMutIterator, ParallelIterator, SimCache, params_key_except, value_noise};
@@ -114,6 +114,8 @@ impl Phys {
Particle { p, v, age: 0.0, life, rnd: h(id, 11, seed), id }
}
/// One step at the constant [`Phys::rate`] (the tests' reference).
#[cfg(test)]
pub(crate) fn step(&self, st: &mut PState, step: u64) {
self.step_at_rate(st, step, self.rate);
}
@@ -274,12 +276,39 @@ pub(super) fn simulate_rates(cache: &SimCache<PState>, key: u64, phys: &Phys, ra
cache.run_where(key, rates.len() as u64, PState::default, |st, i| phys.step_at_rate(st, i, rate(i)), |s, st| prefix.get(s as usize) == Some(&st.hist))
}
/// A stepped particle system's particles at `ctx.time`, born at the Birth Rate of their own
/// time (`birthRate` × `k` particles per second, see [`rate_history`]). The cache key is every
/// parameter but Birth Rate (its history is checked through `PState::hist`) and those under
/// `skip`, with `salt`. `gpu`: the host's particle backend may simulate it.
pub(super) struct Stepped<'a> {
pub(super) cache: &'a SimCache<PState>,
pub(super) salt: u64,
pub(super) skip: &'a [&'a str],
pub(super) k: f64,
pub(super) gpu: bool,
}
impl Stepped<'_> {
pub(super) fn state(&self, ctx: &EffectCtx, phys: &Phys) -> std::sync::Arc<PState> {
let rates = rate_history(ctx, "birthRate", self.k);
let mut skip = self.skip.to_vec();
skip.push("birthRate");
let key = params_key_except(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, self.salt, &skip);
let gpu = if self.gpu { accelerated(ctx, key, phys, Some(&rates)) } else { None };
gpu.unwrap_or_else(|| simulate_rates(self.cache, key, phys, &rates))
}
}
/// CC Particle World.
const PW: Stepped = Stepped { cache: &PW_CACHE, salt: 1, skip: &[PW_EFFECT_CAMERA], k: PW_RATE, gpu: true };
/// CC Particle Systems II.
pub(super) const PS2: Stepped = Stepped { cache: &PS2_CACHE, salt: 2, skip: &[], k: PS2_RATE, gpu: true };
/// CC Mr. Mercury (its blobs are simulated on the CPU).
pub(super) const MERCURY: Stepped = Stepped { cache: &MERCURY_CACHE, salt: 3, skip: &[], k: MERCURY_RATE, gpu: false };
/// CC Particle World's particles at `ctx.time`, born at the Birth Rate of their own time.
pub(super) fn pw_state(ctx: &EffectCtx, phys: &Phys) -> std::sync::Arc<PState> {
let rates = rate_history(ctx, "birthRate", PW_RATE);
// Every parameter but Birth Rate (its history is checked through `PState::hist`).
let key = params_key_except(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 1, &[PW_EFFECT_CAMERA, "birthRate"]);
accelerated(ctx, key, phys, Some(&rates)).unwrap_or_else(|| simulate_rates(&PW_CACHE, key, phys, &rates))
PW.state(ctx, phys)
}
/// The live particles of a stepped particle effect (`ec.sim.ccparticleworld`,
@@ -288,23 +317,22 @@ pub(super) fn pw_state(ctx: &EffectCtx, phys: &Phys) -> std::sync::Arc<PState> {
pub fn particle_state(id: &str, ctx: &EffectCtx) -> Option<Vec<crate::psim::SimParticle>> {
let st = match id {
"ec.sim.ccparticleworld" => pw_state(ctx, &pw_phys(ctx)),
"ec.sim.ccparticlesystems2" => {
let (phys, key) = (ps2_phys(ctx), sim_key(ctx, 2));
accelerated(ctx, key, &phys, None).unwrap_or_else(|| simulate(&PS2_CACHE, key, ctx.time, &phys))
}
"ec.sim.ccparticlesystems2" => PS2.state(ctx, &ps2_phys(ctx)),
_ => return None,
};
Some(st.parts.iter().map(|q| crate::psim::SimParticle { p: q.p, v: q.v, age: q.age, life: q.life, rnd: q.rnd, id: q.id }).collect())
}
/// The simulation cache key of a stepped particle effect: every parameter but CC Particle World's
/// Effect Camera, which only changes how the particles are seen (animating it doesn't restart
/// the simulation).
/// The simulation cache key of a stepped particle effect at a constant Birth Rate (the tests'
/// reference): every parameter but CC Particle World's Effect Camera.
#[cfg(test)]
pub(super) fn sim_key(ctx: &EffectCtx, salt: u64) -> u64 {
params_key_except(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, salt, &[PW_EFFECT_CAMERA])
}
/// Run a particle system to layer time `t` through `cache`.
/// Run a particle system at its constant [`Phys::rate`] to layer time `t` through `cache` (the
/// tests' reference).
#[cfg(test)]
pub(crate) fn simulate(cache: &SimCache<PState>, key: u64, t: f64, phys: &Phys) -> std::sync::Arc<PState> {
cache.run(key, steps_at(t), PState::default, |st, i| phys.step(st, i))
}
+25 -15
View File
@@ -1,6 +1,6 @@
use super::engine::{PState, PW_EFFECT_CAMERA, Phys, accelerated, norm3, particle_look, pw_state, sim_key, simulate, type_sprite};
use super::engine::{MERCURY, PS2, PState, PW_EFFECT_CAMERA, Phys, norm3, particle_look, pw_state, type_sprite};
use super::sprites::{Acc, Post, Sprite, SpritePlan, raster, splat};
use super::{Buf, EffectCtx, Image, IntoParallelRefIterator, ParallelIterator, SimCache, Value, params_key, unpremul};
use super::{Buf, EffectCtx, Image, IntoParallelRefIterator, ParallelIterator, SimCache, Value, unpremul};
// ------------------------------------------------------------------ CC Particle World
@@ -192,6 +192,9 @@ fn particle_sprites(sprites: Vec<Sprite>, mode: u32) -> SpritePlan {
pub(super) static PS2_CACHE: SimCache<PState> = SimCache::new(6);
/// Particles per second per unit of CC Particle Systems II's Birth Rate.
pub(super) const PS2_RATE: f64 = 60.0;
/// CC Particle Systems II's physics.
pub(super) fn ps2_phys(ctx: &EffectCtx) -> Phys {
let pr = ctx.params;
@@ -200,7 +203,7 @@ pub(super) fn ps2_phys(ctx: &EffectCtx) -> Phys {
let pos = pr.v2("producer/position");
let dir = (pr.f("physics/direction") as f32).to_radians();
Phys {
rate: pr.f("birthRate").max(0.0) * 60.0,
rate: pr.f("birthRate").max(0.0) * PS2_RATE,
life: pr.f("longevity").max(0.0) as f32,
producer: [pos[0] as f32 / unit, pos[1] as f32 / unit, 0.0],
radius: [pr.f("producer/radiusX").max(0.0) as f32 / unit, pr.f("producer/radiusY").max(0.0) as f32 / unit, 0.0],
@@ -227,9 +230,7 @@ pub(super) fn particle_systems2_plan(ctx: &EffectCtx, b: &Buf) -> SpritePlan {
let pr = ctx.params;
let lh = ctx.layer_size[1] as f32;
let unit = lh.max(1.0);
let phys = ps2_phys(ctx);
let key = sim_key(ctx, 2);
let st = accelerated(ctx, key, &phys, None).unwrap_or_else(|| simulate(&PS2_CACHE, key, ctx.time, &phys));
let st = PS2.state(ctx, &ps2_phys(ctx));
let kind = pr.e("particle/particleType");
let birth = pr.color("particle/birthColor");
let death = pr.color("particle/deathColor");
@@ -252,7 +253,10 @@ pub(super) fn particle_systems2_plan(ctx: &EffectCtx, b: &Buf) -> SpritePlan {
// ------------------------------------------------------------------ CC Mr. Mercury
static MERCURY_CACHE: SimCache<PState> = SimCache::new(6);
pub(super) static MERCURY_CACHE: SimCache<PState> = SimCache::new(6);
/// Blobs per second per unit of CC Mr. Mercury's Birth Rate.
pub(super) const MERCURY_RATE: f64 = 10.0;
/// A CC Mr. Mercury blob (buffer px).
#[derive(Clone, Copy, Debug)]
@@ -262,15 +266,14 @@ pub struct Blob {
pub r: f32,
}
/// CC Mr. Mercury's blobs this frame, from its particle simulation (for buffer geometry `b`).
pub fn mercury_blobs(ctx: &EffectCtx, b: &Buf) -> Vec<Blob> {
/// CC Mr. Mercury's physics.
pub(super) fn mercury_phys(ctx: &EffectCtx) -> Phys {
let pr = ctx.params;
let lh = ctx.layer_size[1] as f32;
let unit = lh.max(1.0);
let unit = (ctx.layer_size[1] as f32).max(1.0);
let pos = pr.v2("producer");
let dir = (pr.f("direction") as f32).to_radians();
let phys = Phys {
rate: pr.f("birthRate").max(0.0) * 10.0,
Phys {
rate: pr.f("birthRate").max(0.0) * MERCURY_RATE,
life: pr.f("longevity").max(0.0) as f32,
producer: [pos[0] as f32 / unit, pos[1] as f32 / unit, 0.0],
radius: [pr.f("radiusX").max(0.0) as f32 / unit, pr.f("radiusY").max(0.0) as f32 / unit, 0.0],
@@ -284,8 +287,15 @@ pub fn mercury_blobs(ctx: &EffectCtx, b: &Buf) -> Vec<Blob> {
seed: ctx.seed.wrapping_mul(0xc2b2ae35),
max: 4_000,
flat: true,
};
let st = simulate(&MERCURY_CACHE, params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 3), ctx.time, &phys);
}
}
/// CC Mr. Mercury's blobs this frame, from its particle simulation (for buffer geometry `b`).
pub fn mercury_blobs(ctx: &EffectCtx, b: &Buf) -> Vec<Blob> {
let pr = ctx.params;
let unit = (ctx.layer_size[1] as f32).max(1.0);
let phys = mercury_phys(ctx);
let st = MERCURY.state(ctx, &phys);
let (bs, ds) = (pr.f("blobBirthSize") as f32, pr.f("blobDeathSize") as f32);
let s = b.scale as f32;
st.parts
+107
View File
@@ -561,3 +561,110 @@ fn the_birth_schedule_follows_the_rate_history() {
assert_eq!(st.next_id as usize, births.len());
assert!(births.iter().all(|&b| f64::from(b) / SPS < 0.5), "nothing born once the ramp reaches 0");
}
// ------------------------- CC Particle Systems II and CC Mr. Mercury's Birth Rate over time
/// A stepped particle effect: its id, physics and simulation.
struct System {
id: &'static str,
phys: fn(&EffectCtx) -> Phys,
stepped: Stepped<'static>,
}
const SYSTEMS: [System; 2] =
[System { id: "ec.sim.ccparticlesystems2", phys: ps2_phys, stepped: PS2 }, System { id: "ec.sim.ccmrmercury", phys: mercury_phys, stepped: MERCURY }];
/// `id`'s parameters with Birth Rate `now` and Longevity 1 s.
fn sys_params(id: &str, now: f64) -> crate::Params {
let spec = crate::find(id).unwrap();
let mut p = crate::Params { values: spec.params.iter().map(|ps| (ps.id.to_string(), crate::default_value(ps, [100.0, 100.0]))).collect() };
for (k, v) in [("birthRate", num(now)), ("longevity", num(1.0))] {
assert!(p.values.contains_key(k), "{id}: no {k}");
p.values.insert(k.to_string(), v);
}
p
}
/// The context at layer time `t` (`seed` keeps each test's simulations apart).
fn sys_ctx<'a>(p: &'a crate::Params, t: f64, host: Option<&'a Rate>, seed: u32) -> EffectCtx<'a> {
let env = EffectEnv { host: host.map(|h| h as &dyn crate::EffectHost), effect_index: host.map_or(0, |h| h.index), ..Default::default() };
EffectCtx { params: p, time: t, layer_size: [100.0, 100.0], seed, adjustment: false, env }
}
/// The live particles (id, position, age) of `sys` at `t` through its simulation.
fn sys_at(sys: &System, seed: u32, now: f64, t: f64, host: Option<&Rate>) -> Vec<(u32, [f32; 3], f32)> {
let p = sys_params(sys.id, now);
let ctx = sys_ctx(&p, t, host, seed);
sys.stepped.state(&ctx, &(sys.phys)(&ctx)).parts.iter().map(|q| (q.id, q.p, q.age)).collect()
}
/// The same, simulated from layer time 0 with a cache of its own (the reference).
fn sys_fresh(sys: &System, seed: u32, now: f64, t: f64, host: &Rate) -> Vec<(u32, [f32; 3], f32)> {
let p = sys_params(sys.id, now);
let ctx = sys_ctx(&p, t, Some(host), seed);
let mut skip = sys.stepped.skip.to_vec();
skip.push("birthRate");
let key = params_key_except(&ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, sys.stepped.salt, &skip);
let st = simulate_rates(&SimCache::new(1), key, &(sys.phys)(&ctx), &rate_history(&ctx, "birthRate", sys.stepped.k));
st.parts.iter().map(|q| (q.id, q.p, q.age)).collect()
}
/// What `sys` draws at `t`: CC Particle Systems II's particles, CC Mr. Mercury's blobs.
fn sys_drawn(sys: &System, seed: u32, now: f64, t: f64, host: Option<&Rate>) -> usize {
let p = sys_params(sys.id, now);
let ctx = sys_ctx(&p, t, host, seed);
match sys.id {
"ec.sim.ccmrmercury" => mercury_blobs(&ctx, &Buf { img: Image::new(100, 100), offset: [0.0; 2], scale: 1.0 }).len(),
id => particle_state(id, &ctx).unwrap().len(),
}
}
/// #1373 for CC Particle Systems II and CC Mr. Mercury: a burst lives on after Birth Rate
/// drops to 0 (what they draw too), moving and ageing, and is gone after its Longevity.
#[test]
fn particle_systems_ii_and_mr_mercury_keep_a_burst_after_birth_rate_drops() {
let host = Rate { index: 1, rate: burst };
for sys in &SYSTEMS {
let born = crate::psim::births_at(steps_at(0.5), 40_000, |i| burst(i as f64 / SPS) * sys.stepped.k).unwrap().len();
assert_eq!(sys_at(sys, 201, 3.0, 0.4, Some(&host)), sys_at(sys, 201, 3.0, 0.4, None), "{}: during the burst", sys.id);
let late = sys_at(sys, 201, 0.0, 0.9, Some(&host));
assert!(late.len() > born / 2 && late.iter().all(|q| (q.0 as usize) < born), "{}: {} live of {born}", sys.id, late.len());
assert!(sys_drawn(sys, 201, 0.0, 0.9, Some(&host)) > 0, "{}: drawn", sys.id);
assert_eq!(sys_drawn(sys, 201, 0.0, 0.9, None), 0, "{}: a constant 0 bore none", sys.id);
let early = sys_at(sys, 201, 0.0, 0.7, Some(&host));
let q0 = early.iter().find(|q| q.0 == late[0].0).unwrap();
assert!(q0.1 != late[0].1 && late[0].2 > q0.2 + 0.19, "{}: {q0:?} -> {:?}", sys.id, late[0]);
assert!(sys_at(sys, 201, 0.0, 1.8, Some(&host)).is_empty(), "{}: gone after Longevity", sys.id);
}
}
/// Seeking matches a simulation from 0, and moving an earlier key (same current rate, same
/// cache key) isn't served from the old history's checkpoints.
#[test]
fn particle_systems_ii_and_mr_mercury_seek_and_drop_stale_checkpoints() {
let (long, short) = (Rate { index: 0, rate: burst }, Rate { index: 0, rate: short_burst });
for sys in &SYSTEMS {
for t in [1.4, 0.6, 0.9, 0.3, 0.9, 1.1] {
let now = burst(t);
assert_eq!(sys_at(sys, 202, now, t, Some(&long)), sys_fresh(sys, 202, now, t, &long), "{} at {t}", sys.id);
}
let b = sys_at(sys, 202, 0.0, 0.9, Some(&short));
assert_eq!(b, sys_fresh(sys, 202, 0.0, 0.9, &short), "{}: the moved key", sys.id);
assert!(!b.is_empty() && b.len() < sys_at(sys, 202, 0.0, 0.9, Some(&long)).len(), "{}", sys.id);
}
}
/// A constant Birth Rate gives exactly the particles the constant-rate simulation did.
#[test]
fn particle_systems_ii_and_mr_mercury_are_unchanged_at_a_constant_rate() {
let host = Rate { index: 0, rate: |_| 2.0 };
for sys in &SYSTEMS {
let p = sys_params(sys.id, 2.0);
let ctx = sys_ctx(&p, 1.3, None, 203);
let before = simulate(&SimCache::new(1), 1, 1.3, &(sys.phys)(&ctx));
let before: Vec<_> = before.parts.iter().map(|q| (q.id, q.p, q.age)).collect();
assert!(!before.is_empty());
assert_eq!(sys_at(sys, 203, 2.0, 1.3, Some(&host)), before, "{}: keyed to a constant", sys.id);
assert_eq!(sys_at(sys, 203, 2.0, 1.3, None), before, "{}: no host", sys.id);
}
}
+37 -19
View File
@@ -113,11 +113,36 @@ impl EffectHost for Keyed {
}
}
/// CC Particle World's live particles at layer time `t` through `host`.
fn keyed_state(p: &Params, t: f64, host: &Keyed) -> Vec<SimParticle> {
/// Effect `id`'s live particles at layer time `t` through `host`.
fn keyed_state(id: &str, p: &Params, t: f64, host: &Keyed) -> Vec<SimParticle> {
let env = EffectEnv { host: Some(host), ..Default::default() };
let ctx = EffectCtx { params: p, time: t, layer_size: [320.0, 240.0], seed: 5, adjustment: false, env };
effectcraft_effects::particle_state("ec.sim.ccparticleworld", &ctx).unwrap()
effectcraft_effects::particle_state(id, &ctx).unwrap()
}
/// 3 a second from 0 to 0.5 s.
fn early(t: f64) -> f64 {
if t < 0.5 { 3.0 } else { 0.0 }
}
/// 3 a second from 0.25 to 0.75 s: as many births by 0.9 s as [`early`], at other steps.
fn late(t: f64) -> f64 {
if (0.25..0.75).contains(&t) { 3.0 } else { 0.0 }
}
/// Effect `id` (parameters `p`) under the [`early`] then the [`late`] Birth Rate history: the
/// GPU's particles match the CPU's, and each really came from the GPU.
fn keyed_agree(id: &str, p: &Params, g: &'static Gpu) {
for (rate, times) in [(early as fn(f64) -> f64, [0.4, 0.9, 1.2]), (late, [0.9, 1.2, 0.6])] {
let (cpu_host, gpu_host) = (Keyed { gpu: None, rate }, Keyed { gpu: Some(g), rate });
for t in times {
let cpu = keyed_state(id, p, t, &cpu_host);
let before = crate::particles::DISPATCHES.load(std::sync::atomic::Ordering::Relaxed);
let gpu = keyed_state(id, p, t, &gpu_host);
assert!(crate::particles::DISPATCHES.load(std::sync::atomic::Ordering::Relaxed) > before, "{id} t {t}: simulated on the GPU");
agree(&format!("{id} keyed birth rate t {t}"), &cpu, &gpu, 1e-4, cpu.len() / 100 + 1);
}
}
}
/// #1373: with a keyed Birth Rate the GPU is given the births of each moment, as the CPU
@@ -127,23 +152,16 @@ fn keyed_state(p: &Params, t: f64, host: &Keyed) -> Vec<SimParticle> {
#[test]
fn particle_world_with_a_keyed_birth_rate_matches_the_cpu() {
let Some(g) = gpu() else { return };
fn early(t: f64) -> f64 {
if t < 0.5 { 3.0 } else { 0.0 }
}
fn late(t: f64) -> f64 {
if (0.25..0.75).contains(&t) { 3.0 } else { 0.0 }
}
let p = params("ec.sim.ccparticleworld", &[("birthRate", n(0.0)), ("longevity", n(1.2)), ("extras/randomSeed", n(21.0))]);
for (rate, times) in [(early as fn(f64) -> f64, [0.4, 0.9, 1.2]), (late, [0.9, 1.2, 0.6])] {
let (cpu_host, gpu_host) = (Keyed { gpu: None, rate }, Keyed { gpu: Some(g), rate });
for t in times {
let cpu = keyed_state(&p, t, &cpu_host);
let before = crate::particles::DISPATCHES.load(std::sync::atomic::Ordering::Relaxed);
let gpu = keyed_state(&p, t, &gpu_host);
assert!(crate::particles::DISPATCHES.load(std::sync::atomic::Ordering::Relaxed) > before, "t {t}: simulated on the GPU");
agree(&format!("keyed birth rate t {t}"), &cpu, &gpu, 1e-4, cpu.len() / 100 + 1);
}
}
keyed_agree("ec.sim.ccparticleworld", &p, g);
}
/// The same for CC Particle Systems II (90 births by 0.9 s in both histories).
#[test]
fn particle_systems_ii_with_a_keyed_birth_rate_matches_the_cpu() {
let Some(g) = gpu() else { return };
let p = params("ec.sim.ccparticlesystems2", &[("birthRate", n(0.0)), ("longevity", n(1.2)), ("randomSeed", n(22.0))]);
keyed_agree("ec.sim.ccparticlesystems2", &p, g);
}
#[test]
+58
View File
@@ -491,6 +491,64 @@ fn particle_world_with_a_birth_rate_expression_is_not_served_from_the_layer_cach
assert!(light(&b) > 1.0 && light(&b) < light(&a), "fewer births: {} vs {}", light(&b), light(&a));
}
/// A grey solid (CC Mr. Mercury shades its blobs from it) with effect `id` (CC Particle Systems
/// II, CC Mr. Mercury): Birth Rate `rate`,
/// or keyed 3 until 0.5 s and 0 a frame later (`None`); Longevity 1 s.
fn system_scene(id: &str, rate: Option<f64>) -> (Project, ItemId) {
let (mut p, cid, comp) = setup();
let mut l = solid(&mut p, &comp, [0.5, 0.5, 0.5], 200, 100);
let vals = [("birthRate", Value::Scalar(rate.unwrap_or(3.0))), ("longevity", Value::Scalar(1.0))];
add_effect(&mut p, &mut l, id, [200.0, 100.0], &vals);
if rate.is_none() {
let fx = l.props.sub_mut("effects").unwrap().children.iter_mut().find_map(|c| match c {
effectcraft_project::Node::Group(g) => Some(g),
_ => None,
});
let key = |t: f64, v: f64| Keyframe::new(Tick::from_seconds_f64(t), Value::Scalar(v));
fx.unwrap().prop_mut("birthRate").unwrap().keys = vec![key(0.0, 3.0), key(0.5, 3.0), key(0.5 + 1.0 / 30.0, 0.0)];
}
p.comp_mut(cid).unwrap().layers = vec![l];
(p, cid)
}
/// #1373 for CC Particle Systems II and CC Mr. Mercury through the renderer: a keyed burst
/// draws as the constant rate during it, still draws after the rate drops to 0 (unlike a rate
/// of 0 throughout), and draws as nothing born once Longevity has run out.
#[test]
fn particle_systems_ii_and_mr_mercury_keep_a_burst_after_birth_rate_drops_to_zero() {
let at = Tick::from_seconds_f64;
for id in ["ec.sim.ccparticlesystems2", "ec.sim.ccmrmercury"] {
let (keyed, cid) = system_scene(id, None);
let (constant, _) = system_scene(id, Some(3.0));
let (none, _) = system_scene(id, Some(0.0));
assert_same(&render_cached(&keyed, cid, at(0.4), None), &render_cached(&constant, cid, at(0.4), None), &format!("{id}: during the burst"));
let (k, z) = (render_cached(&keyed, cid, at(0.9), None), render_cached(&none, cid, at(0.9), None));
assert!((light(&k) - light(&z)).abs() > 1.0, "{id}: the burst is still drawn: {} vs {}", light(&k), light(&z));
assert_same(&render_cached(&keyed, cid, at(1.9), None), &render_cached(&none, cid, at(1.9), None), &format!("{id}: gone after Longevity"));
}
}
/// A Birth Rate expression on any of the stepped particle systems keeps the layer out of the
/// layer cache (its key can't see what the expression returned at earlier times); without one
/// the layer is cached.
#[test]
fn stepped_particle_layers_with_a_birth_rate_expression_are_not_layer_cached() {
for id in ["ec.sim.ccparticleworld", "ec.sim.ccparticlesystems2", "ec.sim.ccmrmercury"] {
let (mut p, cid) = system_scene(id, Some(2.0));
let key = |p: &Project| {
let c = p.comp(cid).unwrap();
crate::cache::layer_key(&crate::eval::EvalCtx::new(p, cid, c, Tick::from_seconds_f64(0.9)), &c.layers[0], 1.0, false, false)
};
assert!(key(&p).is_some(), "{id}: cached without an expression");
let fx = p.comp_mut(cid).unwrap().layers[0].props.sub_mut("effects").unwrap().children.iter_mut().find_map(|c| match c {
effectcraft_project::Node::Group(g) => Some(g),
_ => None,
});
fx.unwrap().prop_mut("birthRate").unwrap().expr = Some(effectcraft_project::Expression { text: "time < 0.5 ? 3 : 0".into(), enabled: true });
assert!(key(&p).is_none(), "{id}: not cached with a Birth Rate expression");
}
}
// ------------------------------------------------------------------------------ layer cache
fn render_cached(p: &Project, cid: ItemId, t: Tick, cache: Option<&crate::LayerCache>) -> crate::Image {
+4 -4
View File
@@ -371,12 +371,12 @@ Playground cannons without interacting forces evolve every particle independentl
backend (reached through `EffectHost::particles` when the GPU compositor is active) simulates one
particle per invocation from the CPU's birth schedule and keeps per-key state checkpoints on the
GPU, like `SimCache`; the CPU simulation stays the oracle (tests compare ids and positions).
CC Particle World samples its Birth Rate at every step (`EffectHost::param_at`, #1373), so a
keyed rate only decides the births of its own time; the rate is left out of the simulation key,
and a checkpoint is resumed only with the same rate history (the CPU state's `hist`, the GPU
CC Particle World, CC Particle Systems II and CC Mr. Mercury sample their Birth Rate at every
step (`EffectHost::param_at`, #1373), so a keyed rate only decides the births of its own time;
the rate is left out of the simulation key, and a checkpoint is resumed only with the same rate history (the CPU state's `hist`, the GPU
checkpoint's births). The rate's keyframes are read at the step's exact layer time
(`EvalCtx::value_at_layer_time`; mapping it to comp time and back rounds under a fractional
stretch), a rate without keyframes or an expression isn't sampled, and a Particle World layer with
stretch), a rate without keyframes or an expression isn't sampled, and a layer of one of them with
expressions isn't layer cached (its key can't see what an expression returned at earlier times).
`effectcraft-cli bench --gpu` reports CPU vs GPU ms/frame, speed-up, Auto's ms/frame, pixel
agreement and the steady-state upload / readback MB per frame for every comp plus an