refactor(effects): move src/sim.rs tests to src/sim/tests.rs

This commit is contained in:
aiongg
2026-10-26 21:49:11 +00:00
parent 93ba0d34cb
commit 111767f196
2 changed files with 409 additions and 410 deletions
+1 -410
View File
@@ -1621,413 +1621,4 @@ pub fn specs() -> Vec<EffectSpec> {
}
#[cfg(test)]
mod tests {
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::<f32>()).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::<f32>();
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<PState> = SimCache::new(2);
let b: SimCache<PState> = 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<crate::LayerPixels> {
None
}
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
None
}
fn comp_scene(&self) -> Option<crate::CompScene> {
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: &params, 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(&params), key(&moved));
moved.values.insert("extras/randomSeed".into(), num(9.0));
assert_ne!(key(&params), 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: &params, 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::<f32>() / i.data.len() as f32;
assert!((sum(&full.img) - sum(&half.img)).abs() < 0.05, "{} {}", sum(&full.img), sum(&half.img));
}
}
mod tests;
+408
View File
@@ -0,0 +1,408 @@
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::<f32>()).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::<f32>();
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<PState> = SimCache::new(2);
let b: SimCache<PState> = 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<crate::LayerPixels> {
None
}
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
None
}
fn comp_scene(&self) -> Option<crate::CompScene> {
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: &params, 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(&params), key(&moved));
moved.values.insert("extras/randomSeed".into(), num(9.0));
assert_ne!(key(&params), 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: &params, 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::<f32>() / i.data.len() as f32;
assert!((sum(&full.img) - sum(&half.img)).abs() < 0.05, "{} {}", sum(&full.img), sum(&half.img));
}