use super::*; use crate::{EffectEnv, run_fx}; fn grey(w: u32, hh: u32) -> Image { Image::filled(w, hh, [0.2, 0.2, 0.2, 1.0]) } fn ramp(w: u32, hh: u32) -> Image { let mut img = Image::new(w, hh); for y in 0..hh { for x in 0..w { img.set(x, y, [x as f32 / w as f32, y as f32 / hh as f32, 0.4, 1.0]); } } img } fn run(id: &str, vals: &[(&str, Value)], img: Image, t: f64) -> Image { run_fx(id, vals, img, t, EffectEnv::default()).img } fn diff(a: &Image, b: &Image) -> f32 { a.data.iter().zip(&b.data).map(|(p, q)| (0..4).map(|k| (p[k] - q[k]).abs()).sum::()).sum() } /// Centre of mass (luminance-weighted difference from `base`), `[-1, -1]` when equal. fn mass(img: &Image, base: &Image) -> [f32; 2] { let (mut m, mut sx, mut sy) = (0.0, 0.0, 0.0); for y in 0..img.height { for x in 0..img.width { let i = img.idx(x, y); let d: f32 = (0..3).map(|k| (img.data[i][k] - base.data[i][k]).abs()).sum(); m += d; sx += d * x as f32; sy += d * y as f32; } } if m > 0.0 { [sx / m, sy / m] } else { [-1.0; 2] } } /// [`mass`] in y. fn mass_y(img: &Image, base: &Image) -> f32 { mass(img, base)[1] } #[test] fn rasteriser_over_and_add() { let s = [Sprite::new(5.0, 5.0, 2.0, [1.0, 0.0, 0.0, 1.0], Shape::Disc)]; let img = splat(10, 40, &s, Acc::Over); assert!(img.get(5, 5)[0] > 0.99 && img.get(9, 30)[3] == 0.0); let two = [s[0], s[0]]; let add = splat(10, 40, &two, Acc::Add); assert!(add.get(5, 5)[0] > 1.9); // Items spanning band boundaries are drawn in every band. let tall = [Sprite::new(5.0, 16.0, 6.0, [0.0, 1.0, 0.0, 1.0], Shape::Square)]; let t = splat(10, 40, &tall, Acc::Over); assert!(t.get(5, 13)[1] > 0.9 && t.get(5, 18)[1] > 0.9); } #[test] fn rainfall_deterministic_and_falls() { let vals = [("drops", num(200.0)), ("opacity", num(100.0)), ("extras/appearance", Value::Enum(1)), ("color", col(1.0, 1.0, 1.0))]; let a = run("ec.sim.ccrainfall", &vals, grey(40, 40), 0.5); let b = run("ec.sim.ccrainfall", &vals, grey(40, 40), 0.5); assert_eq!(a, b); assert!(diff(&a, &grey(40, 40)) > 1.0, "drops visible"); let none = run("ec.sim.ccrainfall", &[("drops", num(0.0))], grey(40, 40), 0.5); assert_eq!(none, grey(40, 40)); let later = run("ec.sim.ccrainfall", &vals, grey(40, 40), 0.52); assert!(diff(&a, &later) > 0.1, "rain moves"); } #[test] fn snowfall_deterministic_and_moves() { let vals = [("flakes", num(300.0))]; let a = run("ec.sim.ccsnowfall", &vals, grey(40, 40), 1.0); assert_eq!(a, run("ec.sim.ccsnowfall", &vals, grey(40, 40), 1.0)); assert!(diff(&a, &grey(40, 40)) > 1.0); assert!(diff(&a, &run("ec.sim.ccsnowfall", &vals, grey(40, 40), 1.2)) > 0.1); let off = run("ec.sim.ccsnowfall", &[("flakes", num(300.0)), ("compositeWithOriginal", Value::Bool(false))], grey(40, 40), 1.0); assert!(off.data.iter().any(|p| p[3] == 0.0), "without original the background is transparent"); } #[test] fn bubbles_rise() { let vals = [("bubbleAmount", num(30.0)), ("bubbleSize", num(3.0))]; let a = run("ec.sim.ccbubbles", &vals, ramp(40, 40), 0.3); assert_eq!(a, run("ec.sim.ccbubbles", &vals, ramp(40, 40), 0.3)); assert!(a.data.iter().any(|p| p[3] > 0.5)); let none = run("ec.sim.ccbubbles", &[("bubbleAmount", num(0.0))], ramp(40, 40), 0.3); assert!(none.data.iter().all(|p| p[3] == 0.0), "bubbles replace the layer"); // A single slow bubble moves up over a short time. let one = [("bubbleAmount", num(1.0)), ("bubbleSize", num(4.0)), ("wobbleAmplitude", num(0.0)), ("bubbleSpeed", num(0.2))]; let blank = Image::new(40, 40); let y0 = mass_y(&run("ec.sim.ccbubbles", &one, ramp(40, 40), 0.0), &blank); let y1 = mass_y(&run("ec.sim.ccbubbles", &one, ramp(40, 40), 0.1), &blank); assert!(y0 >= 0.0 && y1 >= 0.0 && (y1 < y0 || y0 < 8.0), "{y0} -> {y1}"); } #[test] fn drizzle_ripples() { let a = run("ec.sim.ccdrizzle", &[], ramp(48, 48), 0.6); assert_eq!(a, run("ec.sim.ccdrizzle", &[], ramp(48, 48), 0.6)); assert!(diff(&a, &ramp(48, 48)) > 0.1); let none = run("ec.sim.ccdrizzle", &[("dripRate", num(0.0))], ramp(48, 48), 0.6); assert_eq!(none, ramp(48, 48)); } #[test] fn star_burst_moves_stars() { let a = run("ec.sim.ccstarburst", &[("gridSpacing", num(4.0))], ramp(40, 40), 0.5); assert_eq!(a, run("ec.sim.ccstarburst", &[("gridSpacing", num(4.0))], ramp(40, 40), 0.5)); assert!(a.data.iter().any(|p| p[3] > 0.0)); let b = run("ec.sim.ccstarburst", &[("gridSpacing", num(4.0))], ramp(40, 40), 0.9); assert!(diff(&a, &b) > 0.5); let full = run("ec.sim.ccstarburst", &[("blendWithOriginal", num(100.0))], ramp(40, 40), 0.5); assert!(diff(&full, &ramp(40, 40)) < 1e-3); } fn pw_vals(seed: f64) -> Vec<(&'static str, Value)> { vec![ ("birthRate", num(1.0)), ("extras/randomSeed", num(seed)), ("particle/particleType", Value::Enum(2)), ("particle/birthSize", num(0.3)), ("particle/deathSize", num(0.3)), ] } /// #1195: with the comp's shutter open, the particles smear across it: the average of the /// sampled sprite fields differs from the sharp frame, touches more pixels and keeps the /// alpha energy. #[test] fn particle_world_motion_blur_trails() { let v = pw_vals(21.0); let blank = Image::new(40, 40); let sharp = run("ec.sim.ccparticleworld", &v, blank.clone(), 2.0); let blurred = crate::run_fx( "ec.sim.ccparticleworld", &v, blank.clone(), 2.0, EffectEnv { frame_rate: 10.0, shutter: Some((360.0, -180.0, 8)), ..Default::default() }, ) .img; assert!(sharp.data.iter().any(|p| p[3] > 0.0), "sharp frame has particles"); assert_ne!(sharp.data, blurred.data, "the shutter smears the particles"); let lit = |img: &Image| img.data.iter().filter(|p| p[3] > 0.005).count(); let sum = |img: &Image| img.data.iter().map(|p| p[3]).sum::(); assert!(lit(&blurred) > lit(&sharp), "trails light more pixels: {} vs {}", lit(&blurred), lit(&sharp)); let (sa, ba) = (sum(&sharp), sum(&blurred)); assert!((ba - sa).abs() < sa * 0.6 + 1.0, "alpha energy roughly kept: {ba} vs {sa}"); } #[test] fn particle_world_seek_consistent() { let v = pw_vals(11.0); let direct = run("ec.sim.ccparticleworld", &v, Image::new(40, 40), 2.0); let v2 = pw_vals(12.0); let _ = run("ec.sim.ccparticleworld", &v2, Image::new(40, 40), 1.0); let stepped = run("ec.sim.ccparticleworld", &v2, Image::new(40, 40), 2.0); let direct2 = run("ec.sim.ccparticleworld", &v2, Image::new(40, 40), 2.0); assert_eq!(stepped, direct2); assert!(direct.data.iter().any(|p| p[3] > 0.0), "particles alive"); // Before birth nothing is drawn. let t0 = run("ec.sim.ccparticleworld", &v, Image::new(40, 40), 0.0); assert!(t0.data.iter().all(|p| p[3] == 0.0)); } #[test] fn engine_resume_equals_from_scratch() { for anim in 0..PW_ANIMS.len() as u32 { let phys = Phys { rate: 90.0, life: 1.5, producer: [0.0; 3], radius: [0.05; 3], anim, speed: 0.5, gravity: [0.0, 0.5, 0.0], resistance: 0.2, axis: [0.0, -1.0, 0.0], extra: 1.0, extra_angle: 60.0, seed: 9, max: 10_000, flat: anim % 2 == 0, }; let a: SimCache = SimCache::new(2); let b: SimCache = SimCache::new(2); let direct = simulate(&a, 1, 2.0, &phys); let _ = simulate(&b, 1, 1.0, &phys); let _ = simulate(&b, 1, 0.37, &phys); let resumed = simulate(&b, 1, 2.0, &phys); assert_eq!(direct.parts.len(), resumed.parts.len(), "anim {anim}"); assert!(direct.parts.iter().zip(&resumed.parts).all(|(p, q)| p.p == q.p && p.v == q.v && p.id == q.id), "anim {anim}"); assert!(!direct.parts.is_empty()); } } #[test] fn particle_world_gravity_pulls_down() { let base = |g: f64| { vec![ ("birthRate", num(2.0)), ("physics/velocity", num(0.2)), ("physics/gravity", num(g)), ("particle/particleType", Value::Enum(2)), ("particle/birthSize", num(0.2)), ("particle/deathSize", num(0.2)), ("longevity", num(3.0)), ] }; let blank = Image::new(48, 48); let up = mass_y(&run("ec.sim.ccparticleworld", &base(0.0), blank.clone(), 1.0), &blank); let down = mass_y(&run("ec.sim.ccparticleworld", &base(2.0), blank.clone(), 1.0), &blank); assert!(down > up + 2.0, "{up} {down}"); } /// A comp whose camera is `camera` ([`crate::CompScene::camera`]); `layer`: a camera layer /// (else the comp's default view). struct Comp { camera: [[f64; 4]; 3], layer: bool, } impl crate::EffectHost for Comp { fn layer(&self, _: u64, _: bool) -> Option { None } fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { None } fn comp_scene(&self) -> Option { Some(crate::CompScene { camera: Some(self.camera), camera_layer: self.layer, light: None }) } } /// A camera `d` px in front of layer point `c` looking at it, moved right by `dx` px and /// zoomed by `zoom` (1 = identity on the layer plane): `c + zoom·d·(p − c − dx) / (z + d)`. fn cam(c: [f64; 2], d: f64, dx: f64, zoom: f64) -> [[f64; 4]; 3] { let k = zoom * d; [[k, 0.0, c[0], d * c[0] - k * (c[0] + dx)], [0.0, k, c[1], d * c[1] - k * c[1]], [0.0, 0.0, 1.0, d]] } fn pw_in(comp: &Comp, vals: &[(&str, Value)]) -> Image { run_fx("ec.sim.ccparticleworld", vals, Image::new(48, 48), 1.0, EffectEnv { host: Some(comp), ..Default::default() }).img } fn finite(img: &Image) -> bool { img.data.iter().all(|p| p.iter().all(|c| c.is_finite())) } /// #397: with a camera layer in the comp, the particles are seen through it. #[test] fn particle_world_follows_the_comp_camera() { let v = pw_vals(3.0); let blank = Image::new(48, 48); let here = Comp { camera: cam([24.0, 24.0], 96.0, 0.0, 1.0), layer: true }; let base = pw_in(&here, &v); assert!(base.data.iter().any(|p| p[3] > 0.0), "particles alive"); // Moving the camera right moves the particles left. let moved = pw_in(&Comp { camera: cam([24.0, 24.0], 96.0, 8.0, 1.0), ..here }, &v); let (a, b) = (mass(&base, &blank), mass(&moved, &blank)); assert!(b[0] < a[0] - 4.0 && (b[1] - a[1]).abs() < 2.0, "{a:?} -> {b:?}"); // Zooming in spreads them out. let zoomed = pw_in(&Comp { camera: cam([24.0, 24.0], 96.0, 0.0, 2.0), ..here }, &v); let cover = |i: &Image| i.data.iter().filter(|p| p[3] > 0.01).count(); assert!(cover(&zoomed) > cover(&base), "{} {}", cover(&zoomed), cover(&base)); // The Effect Camera is ignored then. let mut turned = v.clone(); turned.push(("extras/effectCamera/rotationY", num(60.0))); assert_eq!(pw_in(&here, &turned), base); // The comp's default view (no camera layer) leaves it to the Effect Camera. let default_view = Comp { layer: false, ..here }; assert_eq!(pw_in(&default_view, &v), run("ec.sim.ccparticleworld", &v, blank.clone(), 1.0)); // A camera facing away, or a degenerate one, draws nothing and never NaN. for camera in [[[96.0, 0.0, 24.0, 0.0], [0.0, 96.0, 24.0, 0.0], [0.0, 0.0, -1.0, -96.0]], [[0.0; 4]; 3], [[f64::NAN; 4]; 3]] { assert_eq!(pw_in(&Comp { camera, layer: true }, &v), blank); } // A camera among the particles draws the ones in front of it. let inside = pw_in(&Comp { camera: cam([24.0, 24.0], 1.0, 0.0, 1.0), ..here }, &v); assert!(finite(&inside)); } /// Extras ▸ Effect Camera: the defaults are the fixed camera Particle World always had. #[test] fn particle_world_effect_camera() { let v = pw_vals(5.0); let base = run("ec.sim.ccparticleworld", &v, Image::new(48, 48), 1.0); let with = |extra: &[(&'static str, Value)]| { let mut w = v.clone(); w.extend_from_slice(extra); run("ec.sim.ccparticleworld", &w, Image::new(48, 48), 1.0) }; // Missing parameters (projects saved before) read as the defaults. let spec = crate::find("ec.sim.ccparticleworld").unwrap(); let mut params = crate::Params { values: spec.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; params.values.retain(|k, _| !k.starts_with(PW_EFFECT_CAMERA)); for (k, val) in &v { params.values.insert(k.to_string(), val.clone()); } let ctx = EffectCtx { params: ¶ms, time: 1.0, layer_size: [48.0, 48.0], seed: 1, adjustment: false, env: EffectEnv::default() }; assert_eq!(crate::apply(spec, &ctx, Buf { img: Image::new(48, 48), offset: [0.0; 2], scale: 1.0 }).img, base); assert_eq!(with(&[("extras/effectCamera/fov", num(f64::NAN))]), base); // Turning or pulling back the camera changes the view. let turned = with(&[("extras/effectCamera/rotationY", num(60.0))]); assert!(diff(&turned, &base) > 1.0); let cover = |i: &Image| i.data.iter().filter(|p| p[3] > 0.01).count(); let far = with(&[("extras/effectCamera/distance", num(4.0))]); assert!(cover(&far) < cover(&base), "{} {}", cover(&far), cover(&base)); assert!(diff(&with(&[("extras/effectCamera/fov", num(120.0))]), &base) > 0.1); // Extremes never panic or produce NaN. for extra in [ ("extras/effectCamera/distance", num(0.0)), ("extras/effectCamera/distance", num(-5.0)), ("extras/effectCamera/fov", num(0.0)), ("extras/effectCamera/fov", num(180.0)), ("extras/effectCamera/rotationX", num(1e12)), ] { assert!(finite(&with(std::slice::from_ref(&extra))), "{extra:?}"); } // Moving the camera doesn't restart the simulation. let key = |pr: &crate::Params| sim_key(&EffectCtx { params: pr, time: 1.0, layer_size: [48.0, 48.0], seed: 1, adjustment: false, env: EffectEnv::default() }, 1); let mut moved = params.clone(); moved.values.insert("extras/effectCamera/rotationZ".into(), num(30.0)); assert_eq!(key(¶ms), key(&moved)); moved.values.insert("extras/randomSeed".into(), num(9.0)); assert_ne!(key(¶ms), key(&moved)); } #[test] fn particle_systems2_seek_and_direction() { let v = vec![ ("producer/position", pt(24.0, 24.0)), ("birthRate", num(3.0)), ("physics/gravity", num(0.0)), ("physics/velocity", num(1.0)), ("physics/animation", Value::Enum(1)), ]; let _ = run("ec.sim.ccparticlesystems2", &v, Image::new(48, 48), 0.5); let a = run("ec.sim.ccparticlesystems2", &v, Image::new(48, 48), 1.0); let mut v2 = v.clone(); v2.push(("randomSeed", num(0.0))); assert_eq!(a, run("ec.sim.ccparticlesystems2", &v2, Image::new(48, 48), 1.0)); // Direction 0° (up): particles above the producer. let blank = Image::new(48, 48); let y = mass_y(&a, &blank); assert!((0.0..24.0).contains(&y), "{y}"); // The particles replace the layer: with none born it is empty. let none = run("ec.sim.ccparticlesystems2", &[("birthRate", num(0.0))], grey(20, 20), 1.0); assert_eq!(none, Image::new(20, 20)); } /// CC Particle World and CC Particle Systems II replace their layer with the particles, in /// every Transfer Mode (which only sets how particles meet each other). SceneYard frames from /// After Effects 2026 show no trace of the source solid: "Task Preparation UI" (black solids /// under fireworks hid a white page), "Burnout matchstick" (a red rectangle around the /// flame), "Documentary Man Falling" (a black block over the light). #[test] fn cc_particles_replace_their_layer() { let red = || { let mut img = Image::new(48, 48); img.data.iter_mut().for_each(|p| *p = [0.7, 0.1, 0.1, 1.0]); img }; for id in ["ec.sim.ccparticlesystems2", "ec.sim.ccparticleworld"] { for mode in 0..4 { let v = vec![("birthRate", num(3.0)), ("particle/transferMode", Value::Enum(mode))]; let _ = run(id, &v, red(), 0.5); let out = run(id, &v, red(), 1.0); // No source left: the corners, far from the producer, are clear. for (x, y) in [(0, 0), (47, 0), (0, 47), (47, 47)] { assert!(out.get(x, y)[3] < 1e-6, "{id} mode {mode} ({x},{y}): {:?}", out.get(x, y)); } assert!(out.data.iter().any(|p| p[3] > 0.05), "{id} mode {mode}: particles drawn"); } } } #[test] fn mercury_blobs_and_seek() { let v = vec![("producer", pt(24.0, 16.0)), ("radiusX", num(5.0)), ("radiusY", num(5.0)), ("birthRate", num(3.0))]; let _ = run("ec.sim.ccmrmercury", &v, ramp(48, 48), 0.7); let a = run("ec.sim.ccmrmercury", &v, ramp(48, 48), 1.4); let mut v2 = v.clone(); v2.push(("shading/metal", num(100.0))); let b = run("ec.sim.ccmrmercury", &v2, ramp(48, 48), 1.4); assert_eq!(a, b); assert!(a.data.iter().any(|p| p[3] > 0.5), "blobs visible"); let none = run("ec.sim.ccmrmercury", &[("birthRate", num(0.0))], ramp(48, 48), 1.4); assert!(none.data.iter().all(|p| p[3] == 0.0)); } #[test] fn half_resolution_scales_sprites() { // Downsampled buffers render the same layout at half size. let s = crate::find("ec.sim.ccsnowfall").unwrap(); let mut params = crate::Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; params.values.insert("flakes".into(), num(50.0)); params.values.insert("offset".into(), pt(20.0, 20.0)); let ctx = EffectCtx { params: ¶ms, time: 0.5, layer_size: [40.0, 40.0], seed: 1, adjustment: false, env: EffectEnv::default() }; let full = crate::apply(s, &ctx, Buf { img: grey(40, 40), offset: [0.0; 2], scale: 1.0 }); let half = crate::apply(s, &ctx, Buf { img: grey(20, 20), offset: [0.0; 2], scale: 0.5 }); let sum = |i: &Image| i.data.iter().map(|p| p[0]).sum::() / i.data.len() as f32; assert!((sum(&full.img) - sum(&half.img)).abs() < 0.05, "{} {}", sum(&full.img), sum(&half.img)); } // ---------------------------------------------------------- Birth Rate over time (#1373) /// A host that evaluates effect `index`'s Birth Rate at other layer times as `rate(t)`. struct Rate { index: usize, rate: fn(f64) -> f64, } impl crate::EffectHost for Rate { fn layer(&self, _: u64, _: bool) -> Option { None } fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { None } fn param_at(&self, effect_index: usize, id: &str, t: f64) -> Option { (effect_index == self.index && id == "birthRate").then(|| num((self.rate)(t))) } } /// Birth Rate 3 until 0.5 s, then 0 (the issue's one-shot burst). fn burst(t: f64) -> f64 { if t < 0.5 { 3.0 } else { 0.0 } } /// The same burst, cut at 0.25 s (an earlier key moved). fn short_burst(t: f64) -> f64 { if t < 0.25 { 3.0 } else { 0.0 } } /// Birth Rate falling linearly from 3 at 0 s to 0 at 0.5 s. fn falling(t: f64) -> f64 { (3.0 * (1.0 - t / 0.5)).max(0.0) } /// CC Particle World's parameters: random seed `seed`, Birth Rate `now` (its value at the /// rendered time), Longevity 1 s. fn pw_params(seed: f64, now: f64) -> crate::Params { let spec = crate::find("ec.sim.ccparticleworld").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)), ("extras/randomSeed", num(seed)), ("longevity", num(1.0))] { p.values.insert(k.to_string(), v); } p } fn pw_ctx<'a>(p: &'a crate::Params, t: f64, host: Option<&'a Rate>, index: usize) -> EffectCtx<'a> { let env = EffectEnv { host: host.map(|h| h as &dyn crate::EffectHost), effect_index: index, ..Default::default() }; EffectCtx { params: p, time: t, layer_size: [100.0, 100.0], seed: 1, adjustment: false, env } } /// The live particles at layer time `t` with Birth Rate `now` there and `host`'s history. fn pw_at(seed: f64, now: f64, t: f64, host: Option<&Rate>) -> Vec { let p = pw_params(seed, now); particle_state("ec.sim.ccparticleworld", &pw_ctx(&p, t, host, host.map_or(0, |h| h.index))).unwrap() } /// The same, simulated from layer time 0 with a cache of its own (the reference). fn pw_fresh(seed: f64, now: f64, t: f64, host: &Rate) -> Vec { let p = pw_params(seed, now); let ctx = pw_ctx(&p, t, Some(host), host.index); let key = params_key_except(&ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 1, &[PW_EFFECT_CAMERA, "birthRate"]); let st = simulate_rates(&SimCache::new(1), key, &pw_phys(&ctx), &rate_history(&ctx, "birthRate", PW_RATE)); 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() } /// Particles born in the first `t` seconds of `rate`. fn born_by(rate: fn(f64) -> f64, t: f64) -> usize { crate::psim::births_at(steps_at(t), 40_000, |i| rate(i as f64 / SPS) * PW_RATE).unwrap().len() } /// #1373: Birth Rate only decides the births of its own time. After a burst stops, the /// particles born in it live on (moving and ageing) until their Longevity runs out, rather /// than vanishing because the rate is now 0. #[test] fn a_burst_lives_on_after_birth_rate_drops_to_zero() { let host = Rate { index: 2, rate: burst }; // During the burst: as a constant Birth Rate of 3. assert_eq!(pw_at(101.0, 3.0, 0.4, Some(&host)), pw_at(101.0, 3.0, 0.4, None)); // After it: the burst's particles, none newer. let late = pw_at(101.0, 0.0, 0.9, Some(&host)); let n = born_by(burst, 0.5); assert!(late.len() > n / 2 && late.iter().all(|q| (q.id as usize) < n), "{} live of {n} born", late.len()); assert!(pw_at(101.0, 0.0, 0.9, None).is_empty(), "a constant Birth Rate of 0 never bore any"); // They keep moving. let early = pw_at(101.0, 0.0, 0.7, Some(&host)); let q0 = early.iter().find(|q| q.id == late[0].id).unwrap(); assert!(q0.p != late[0].p && late[0].age > q0.age + 0.19, "{q0:?} -> {:?}", late[0]); // Longevity 1 s (± 20 %): all gone by 1.8 s. assert!(pw_at(101.0, 0.0, 1.8, Some(&host)).is_empty()); // The effect asks for its own index: another effect's history isn't its own. let p = pw_params(101.0, 0.0); assert!(particle_state("ec.sim.ccparticleworld", &pw_ctx(&p, 0.9, Some(&host), 0)).unwrap().is_empty()); } /// A Birth Rate ramping down isn't simulated as if its current value had always applied. #[test] fn a_ramped_birth_rate_bears_what_each_moment_asked_for() { let host = Rate { index: 0, rate: falling }; let ramped = pw_at(102.0, falling(0.25), 0.25, Some(&host)); let constant = pw_at(102.0, falling(0.25), 0.25, None); assert_eq!(ramped.len(), born_by(falling, 0.25), "every particle born so far is alive"); assert_eq!(constant.len(), born_by(|_| 1.5, 0.25)); assert!(ramped.len() > constant.len() + 10, "{} vs {}", ramped.len(), constant.len()); } /// Seeking backward, forward and to the same time again gives the state simulated from 0. #[test] fn seeking_through_a_birth_rate_history_matches_simulating_from_zero() { let host = Rate { index: 0, rate: burst }; for t in [1.4, 0.6, 0.9, 0.3, 0.9, 1.1] { let now = burst(t); assert_eq!(pw_at(103.0, now, t, Some(&host)), pw_fresh(103.0, now, t, &host), "at {t}"); } } /// The simulation cache key leaves Birth Rate out (so an animated rate keeps its checkpoints), /// but a checkpoint is only resumed with the history that reached it: after an earlier key /// moves, the same current rate (0) gives the new history's particles. #[test] fn an_earlier_birth_rate_change_is_not_served_from_stale_checkpoints() { let (long, short) = (Rate { index: 0, rate: burst }, Rate { index: 0, rate: short_burst }); let a = pw_at(104.0, 0.0, 0.9, Some(&long)); let b = pw_at(104.0, 0.0, 0.9, Some(&short)); assert_eq!(b, pw_fresh(104.0, 0.0, 0.9, &short)); assert!(b.len() < a.len() && !b.is_empty(), "{} vs {}", b.len(), a.len()); assert_eq!(pw_at(104.0, 0.0, 1.0, Some(&long)), pw_fresh(104.0, 0.0, 1.0, &long)); } /// A constant Birth Rate gives exactly the particles it did before the history was sampled. #[test] fn a_constant_birth_rate_is_unchanged() { let host = Rate { index: 0, rate: |_| 2.0 }; let p = pw_params(105.0, 2.0); let ctx = pw_ctx(&p, 1.3, None, 0); let phys = pw_phys(&ctx); let before = simulate(&SimCache::new(1), sim_key(&ctx, 1), 1.3, &phys); let now = pw_at(105.0, 2.0, 1.3, Some(&host)); assert_eq!(now.len(), before.parts.len()); assert!(now.iter().zip(&before.parts).all(|(a, b)| a.id == b.id && a.p == b.p && a.v == b.v && a.age == b.age)); assert_eq!(crate::psim::births_at(78, 40_000, |_| phys.rate), crate::psim::births(phys.rate, 78, 40_000)); } /// The GPU's birth schedule for a rate history is the CPU's: the ids born are the ids the /// CPU simulation numbers. #[test] fn the_birth_schedule_follows_the_rate_history() { let host = Rate { index: 0, rate: falling }; let p = pw_params(106.0, 0.0); let ctx = pw_ctx(&p, 0.7, Some(&host), 0); let rates = rate_history(&ctx, "birthRate", PW_RATE); let st = simulate_rates(&SimCache::new(1), 9, &pw_phys(&ctx), &rates); let births = crate::psim::births_at(rates.len() as u64, 40_000, |i| rates[i as usize]).unwrap(); 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); } }