156 lines
6.6 KiB
Rust
156 lines
6.6 KiB
Rust
//! GPU particles vs the CPU simulation (the oracle): CC Particle World, CC Particle Systems II
|
|
//! and Particle Playground's cannon at fixed seeds must give the same live particles (same
|
|
//! ids) at positions within tolerance, from scratch and when advancing / seeking from GPU
|
|
//! checkpoints.
|
|
|
|
use effectcraft_effects::psim::{ParticleSim, SimParticle};
|
|
use effectcraft_effects::{EffectCtx, EffectEnv, EffectHost, LayerPixels, Params};
|
|
use effectcraft_keyframe::Value;
|
|
|
|
use crate::Gpu;
|
|
use crate::tests::{gpu, n};
|
|
|
|
struct Host(&'static Gpu);
|
|
|
|
impl EffectHost for Host {
|
|
fn layer(&self, _id: u64, _masks_and_effects: bool) -> Option<LayerPixels> {
|
|
None
|
|
}
|
|
fn audio(&self, _id: u64, _start: f64, _frames: usize, _rate: u32) -> Option<Vec<f32>> {
|
|
None
|
|
}
|
|
fn particles(&self) -> Option<&dyn ParticleSim> {
|
|
Some(self.0)
|
|
}
|
|
}
|
|
|
|
fn params(id: &str, vals: &[(&str, Value)]) -> Params {
|
|
let spec = effectcraft_effects::find(id).unwrap();
|
|
let size = [320.0, 240.0];
|
|
let mut p = Params { values: spec.params.iter().map(|ps| (ps.id.to_string(), effectcraft_effects::default_value(ps, size))).collect() };
|
|
for (k, v) in vals {
|
|
assert!(p.values.contains_key(*k), "{id}: no {k}");
|
|
p.values.insert(k.to_string(), v.clone());
|
|
}
|
|
p
|
|
}
|
|
|
|
/// (CPU, GPU) particles of effect `id` at layer time `t`.
|
|
fn states(id: &str, p: &Params, t: f64, host: &Host) -> (Vec<SimParticle>, Vec<SimParticle>) {
|
|
let ctx = |env| EffectCtx { params: p, time: t, layer_size: [320.0, 240.0], seed: 5, adjustment: false, env };
|
|
let run =
|
|
|c: &EffectCtx| if id == "ec.sim.particleplayground" { effectcraft_effects::playground_state(c) } else { effectcraft_effects::particle_state(id, c) };
|
|
let cpu = run(&ctx(EffectEnv::default())).expect("cpu state");
|
|
let gpu = run(&ctx(EffectEnv { host: Some(host), ..Default::default() })).expect("gpu state");
|
|
(cpu, gpu)
|
|
}
|
|
|
|
/// Same ids alive (up to `id_slack` particles dying a step apart), positions within `tol`
|
|
/// (world units) for 99 % of them and on average within `tol / 10`.
|
|
fn agree(label: &str, cpu: &[SimParticle], gpu: &[SimParticle], tol: f32, id_slack: usize) {
|
|
assert!(cpu.len() > 20, "{label}: the scene has particles ({})", cpu.len());
|
|
let gi: std::collections::HashMap<u32, &SimParticle> = gpu.iter().map(|q| (q.id, q)).collect();
|
|
let mut missing = 0;
|
|
let mut errs = vec![];
|
|
for q in cpu {
|
|
match gi.get(&q.id) {
|
|
Some(g) => errs.push((0..3).map(|k| (q.p[k] - g.p[k]).abs()).fold(0.0f32, f32::max)),
|
|
None => missing += 1,
|
|
}
|
|
}
|
|
let extra = gpu.len() + missing - cpu.len();
|
|
assert!(missing + extra <= id_slack, "{label}: {missing} CPU particles missing on the GPU, {extra} extra (of {})", cpu.len());
|
|
errs.sort_by(f32::total_cmp);
|
|
let mean = errs.iter().sum::<f32>() / errs.len() as f32;
|
|
let p99 = errs[(errs.len() * 99 / 100).min(errs.len() - 1)];
|
|
eprintln!("{label}: {} particles, mean err {mean:.2e}, p99 {p99:.2e}, max {:.2e}", cpu.len(), errs.last().unwrap());
|
|
assert!(p99 <= tol && mean <= tol / 10.0, "{label}: positions differ (mean {mean}, p99 {p99})");
|
|
}
|
|
|
|
#[test]
|
|
fn particle_world_matches_the_cpu() {
|
|
let Some(g) = gpu() else { return };
|
|
let host = Host(g);
|
|
for anim in [0u32, 1, 2, 3, 4, 5, 6, 7, 8, 9] {
|
|
let p = params(
|
|
"ec.sim.ccparticleworld",
|
|
&[
|
|
("physics/animation", Value::Enum(anim)),
|
|
("birthRate", n(2.0)),
|
|
("longevity", n(1.2)),
|
|
("physics/extra", n(0.6)),
|
|
("physics/resistance", n(0.5)),
|
|
("extras/randomSeed", n(7.0)),
|
|
],
|
|
);
|
|
let (cpu, gpu) = states("ec.sim.ccparticleworld", &p, 1.5, &host);
|
|
// Fractal / fire animations sample value noise along the path: rounding differences can
|
|
// grow, so they get a looser bound.
|
|
let tol = if matches!(anim, 6 | 8 | 9) { 1e-3 } else { 1e-4 };
|
|
agree(&format!("particle world anim {anim}"), &cpu, &gpu, tol, cpu.len() / 100 + 1);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn particle_systems_ii_matches_the_cpu() {
|
|
let Some(g) = gpu() else { return };
|
|
let host = Host(g);
|
|
for anim in [0u32, 1, 2, 4, 5, 8] {
|
|
let p = params(
|
|
"ec.sim.ccparticlesystems2",
|
|
&[("physics/animation", Value::Enum(anim)), ("birthRate", n(3.0)), ("longevity", n(1.5)), ("physics/gravity", n(0.8)), ("randomSeed", n(3.0))],
|
|
);
|
|
let (cpu, gpu) = states("ec.sim.ccparticlesystems2", &p, 2.0, &host);
|
|
let tol = if anim == 8 { 1e-3 } else { 1e-4 };
|
|
agree(&format!("particle systems ii anim {anim}"), &cpu, &gpu, tol, cpu.len() / 100 + 1);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn playground_cannon_matches_the_cpu_and_checkpoints_reuse() {
|
|
let Some(g) = gpu() else { return };
|
|
let host = Host(g);
|
|
for (radius, spread) in [(4.0, 0.0), (-10.0, 50.0)] {
|
|
let p = params(
|
|
"ec.sim.particleplayground",
|
|
&[
|
|
("grid/particlesAcross", n(0.0)),
|
|
("cannon/barrelRadius", n(radius)),
|
|
("cannon/particlesPerSecond", n(90.0)),
|
|
("cannon/directionRandomSpread", n(30.0)),
|
|
("cannon/velocityRandomSpread", n(40.0)),
|
|
("gravity/gravityForceRandomSpread", n(spread)),
|
|
],
|
|
);
|
|
// Advance through checkpoints (0.5 → 1.0 → 2.0), seek back (1.5), compare each.
|
|
for t in [0.5, 1.0, 2.0, 1.5] {
|
|
let (cpu, gpu) = states("ec.sim.particleplayground", &p, t, &host);
|
|
// Positions are layer pixels here.
|
|
agree(&format!("cannon r {radius} spread {spread} t {t}"), &cpu, &gpu, 0.01, cpu.len() / 100 + 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Through the renderer: the GPU backend's effect host hands the simulation to the GPU, and
|
|
/// the composited frames agree with the CPU render.
|
|
#[test]
|
|
fn particle_effects_render_like_the_cpu() {
|
|
use effectcraft_project::BitDepth;
|
|
use effectcraft_time::Tick;
|
|
|
|
use crate::tests::{Scene, check, compare_at, opts};
|
|
for (id, vals) in [
|
|
("ec.sim.ccparticleworld", vec![("birthRate", n(1.0)), ("physics/animation", Value::Enum(4))]),
|
|
("ec.sim.ccparticlesystems2", vec![("birthRate", n(2.0))]),
|
|
("ec.sim.particleplayground", vec![("grid/particlesAcross", n(0.0)), ("cannon/particlesPerSecond", n(60.0))]),
|
|
] {
|
|
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
|
|
let mut s = Scene::new(depth);
|
|
let mut l = s.solid([0.1, 0.1, 0.15], 97, 61);
|
|
s.effect(&mut l, id, &vals);
|
|
s.push(l);
|
|
check(&format!("{id} {depth:?}"), compare_at(&s, opts(), Tick::from_seconds_f64(1.0)), 0.002);
|
|
}
|
|
}
|
|
}
|