mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-31 05:27:08 -06:00
229 lines
9.8 KiB
Rust
229 lines
9.8 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};
|
|
|
|
pub(crate) struct Host(pub(crate) &'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)
|
|
}
|
|
}
|
|
|
|
pub(crate) 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`.
|
|
pub(crate) 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);
|
|
}
|
|
}
|
|
|
|
/// A host whose Birth Rate is `rate(layer time)` (#1373), with the GPU backend or without (the
|
|
/// CPU oracle).
|
|
struct Keyed {
|
|
gpu: Option<&'static Gpu>,
|
|
rate: fn(f64) -> f64,
|
|
}
|
|
|
|
impl EffectHost for Keyed {
|
|
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> {
|
|
self.gpu.map(|g| g as &dyn ParticleSim)
|
|
}
|
|
fn param_at(&self, _effect_index: usize, id: &str, t: f64) -> Option<Value> {
|
|
(id == "birthRate").then(|| n((self.rate)(t)))
|
|
}
|
|
}
|
|
|
|
/// 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(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
|
|
/// simulates them: a burst lives on after the rate drops to 0, and a GPU checkpoint of one
|
|
/// history is not resumed for another with the same system key, even when both have born as
|
|
/// many particles (180 by 0.9 s here) at other steps.
|
|
#[test]
|
|
fn particle_world_with_a_keyed_birth_rate_matches_the_cpu() {
|
|
let Some(g) = gpu() else { return };
|
|
let p = params("ec.sim.ccparticleworld", &[("birthRate", n(0.0)), ("longevity", n(1.2)), ("extras/randomSeed", n(21.0))]);
|
|
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]
|
|
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);
|
|
}
|
|
}
|
|
}
|