makepad/libs/effect_sim/tests/stepper.rs
Admin 012e6f7df5 tools, libs and the rest
cargo-makepad, makepad-builder (with an Android step on every OS), CI tools
and tiles, and the remaining libraries.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 12:26:21 +02:00

476 lines
19 KiB
Rust

//! The stepper's gates (KERNELS.md P7): seek == play at random times,
//! stale checkpoints never restored, beat-exact spawns, the pending queue
//! and expiry across seeks, contacts in their window, sims stepping with
//! physics, bodies published through a kernel ring, and the step budget.
use makepad_effect_sim::*;
use makepad_render_kernels::compute::boundary::{LayoutFieldView, LayoutView};
use makepad_render_kernels::compute::kernel::FieldTy;
use makepad_render_kernels::RecordLayout;
use makepad_render_kernels::{compile, ManualFences};
use std::sync::Arc;
const LAUNCH: &str = r#"
let out = output(Body)
let speed = param(2.2)
fn instance(i) {
let a = rand01(seed, i, 0) * 6.2832
// A stream over 0.3 s from the cannon (every tenth on the beat itself).
let delay = if i % 10 == 0 { 0.0 } else { rand01(seed, i, 4) * 0.3 }
out[i] = Body{pos: vec3(0.0, 0.2, 0.0), size: vec3(0.05, 0.004, 0.03), vel: vec3(cos(a) * speed, 6.0 + 3.0 * rand01(seed, i, 1), sin(a) * speed), spin: rand_dir(seed, i, 2) * 14.0, drag: 1.5, delay: delay, color: vec4(rand01(seed, i, 3), 0.5, 0.8, 1.0)}
}
"#;
/// A second burst: stagger with `delay`, gone after `life`.
const STAGGER: &str = r#"
let out = output(Body)
fn instance(i) {
let a = float(i) * 0.7
out[i] = Body{pos: vec3(cos(a) * 0.5, 1.5, sin(a) * 0.5), size: vec3(0.08, 0.08, 0.08), vel: vec3(0.0, 2.0, 0.0), delay: float(i % 8) * 0.05, life: 1.0 + float(i % 3) * 0.25, shape: 2}
}
"#;
const SPARK: LayoutView<'static> = LayoutView {
name: "Spark",
stride: 4,
fields: &[LayoutFieldView { name: "pos", ty: FieldTy::Vec3, offset: 0 }, LayoutFieldView { name: "life", ty: FieldTy::F32, offset: 3 }],
};
fn program(src: &str) -> u64 {
let mut h = Fnv::default();
h.bytes(src.as_bytes());
h.finish()
}
fn call(src: &str, buffer: &str, layouts: &[LayoutView<'_>], inputs: Vec<(String, SimInput)>) -> KernelCall {
let mut all = makepad_effect_sim::layouts().to_vec();
all.extend_from_slice(layouts);
let kernel = compile(src, &all, &makepad_effect_sim::modules()).unwrap_or_else(|e| panic!("{src}\n{e:?}"));
KernelCall { kernel, buffer: buffer.into(), params: Vec::new(), inputs, program: program(src) }
}
/// The sparkle sim: every spark takes a hard contact's place when one is
/// there for it, otherwise it fades and falls.
fn sparks() -> SimDesc {
let srcs = sim_sources(
"Spark",
"i",
"next[i].pos = vec3(0.0, -10.0, 0.0)",
"i",
"next[i] = prev[i]\n if i < int(hits_count) && hits[i].impulse > 0.0005 { next[i].pos = hits[i].pos\n next[i].life = 1.0 } else { next[i].life = max(prev[i].life - 0.05, 0.0)\n next[i].pos = prev[i].pos + vec3(0.0, 0.01, 0.0) }",
"",
"let hits = input(Contact)\nlet hits_count = param(0.0, 0.0, 1000000.0)",
);
let inputs = vec![("hits".to_string(), SimInput::Contacts("confetti".into()))];
SimDesc {
name: "sparks".into(),
state: RecordLayout::of(&SPARK),
count: 64,
every: 2,
init: call(&srcs.init, "next", &[SPARK], inputs.clone()),
update: call(&srcs.update, "next", &[SPARK], inputs),
prev: "prev".into(),
spawn: None,
}
}
struct Paddle;
impl Drive for Paddle {
fn pose(&mut self, key: &str, t: SimTime) -> Option<([f32; 3], [f32; 4])> {
(key == "paddle").then(|| {
let s = t.as_secs() as f32;
([1.2 * (s * 2.0).sin(), 0.15, 0.0], [0.0, (s * 0.5).sin(), 0.0, (s * 0.5).cos()])
})
}
}
/// Confetti on the beat (beat 4 at 120 bpm = 2 s), a staggered burst of
/// balls at 1 s with delays and lives, a kinematic paddle sweeping the
/// floor, and sparks at hard contacts.
fn confetti(doc: u64, checkpoint_bytes: usize) -> PhysicsDesc {
let mut d = PhysicsDesc::new(doc);
d.seed = 7;
d.retain = SimTime::from_ratio(3, 10);
d.checkpoint_bytes = checkpoint_bytes;
d.bodies.push(BodyDesc { name: "floor".into(), group: String::new(), shape: Shape::Plane, motion: Motion::Fixed, record: BodyRecord::default() });
d.bodies.push(BodyDesc {
name: "paddle".into(),
group: String::new(),
shape: Shape::Box,
motion: Motion::Kinematic { key: "paddle".into() },
record: BodyRecord { size: [1.0, 0.3, 0.2], ..Default::default() },
});
d.spawns.push(SpawnDesc {
at: SimTime::from_secs(0.5 * 4.0),
group: "confetti".into(),
count: 600,
shape: Shape::Box,
fixed: false,
size: [0.05, 0.004, 0.03],
from: SpawnFrom::Kernel(call(LAUNCH, "out", &[], Vec::new())),
});
d.spawns.push(SpawnDesc {
at: SimTime::from_secs(1.0),
group: "balls".into(),
count: 24,
shape: Shape::Box,
fixed: false,
size: [0.1; 3],
from: SpawnFrom::Kernel(call(STAGGER, "out", &[], Vec::new())),
});
d.sims.push(sparks());
d
}
/// Deterministic picks.
struct Lcg(u64);
impl Lcg {
fn next(&mut self) -> u64 {
self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
self.0 >> 33
}
}
/// A frame time, or one of its motion-blur sub-frames (4 over a 180°
/// shutter at 60 fps).
fn frame_time(r: &mut Lcg, frames: i64) -> SimTime {
let f = (r.next() % frames as u64) as i64;
if r.next() % 2 == 0 {
SimTime::frame(f, 60, 1)
} else {
SimTime::subframe(f, 60, 1, (r.next() % 4) as u32, 4, 180)
}
}
#[test]
fn stepper_is_send() {
fn send<T: Send>() {}
send::<Stepper>();
}
#[test]
fn seek_equals_play_at_twenty_random_times() {
let frames = 6 * 60;
let mut r = Lcg(0x5eed);
let mut probes: Vec<SimTime> = (0..20).map(|_| frame_time(&mut r, frames)).collect();
probes.sort();
probes.dedup();
// Play: every frame in order (and each probe as it comes).
let mut play = Stepper::new(confetti(1, 64 << 20), &mut Paddle);
let mut want = Vec::new();
let mut next = 0;
for f in 0..frames {
let t = SimTime::frame(f, 60, 1);
while next < probes.len() && probes[next] <= t {
let p = probes[next];
play.seek(p, &mut Paddle);
let v = play.view(p).expect("viewable after seek");
want.push((p, v.hash(), play.state_hash(), play.head()));
next += 1;
}
play.seek(t, &mut Paddle);
assert!(play.view(t).is_some());
}
assert!(play.errors().next().is_none(), "{:?}", play.errors().collect::<Vec<_>>());
let stats = play.stats();
assert!(stats.bodies_made > 600, "{stats:?}");
// Cold seeks in shuffled order on a fresh stepper, then the same
// stepper seeking back and forth (restoring its own checkpoints).
let mut order: Vec<usize> = (0..want.len()).collect();
for i in (1..order.len()).rev() {
let j = (r.next() as usize) % (i + 1);
order.swap(i, j);
}
let mut cold = Stepper::new(confetti(1, 64 << 20), &mut Paddle);
for &k in &order {
let (t, view_hash, state_hash, head) = want[k];
let mut fresh = Stepper::new(confetti(1, 64 << 20), &mut Paddle);
fresh.seek(t, &mut Paddle);
assert_eq!(fresh.view(t).unwrap().hash(), view_hash, "cold seek to {} s", t.as_secs());
assert_eq!(fresh.state_hash(), state_hash, "cold state at {} s", t.as_secs());
cold.seek(t, &mut Paddle);
assert_eq!(cold.view(t).unwrap().hash(), view_hash, "seek to {} s", t.as_secs());
// The whole state where the seek left the world at the same step
// (a seek into kept frames does not step back).
if cold.head() == head {
assert_eq!(cold.state_hash(), state_hash, "state at {} s", t.as_secs());
}
}
assert!(cold.stats().restores > 0, "{:?}", cold.stats());
eprintln!("play: {:?}", stats);
eprintln!("seeks: {:?}", cold.stats());
}
#[test]
fn a_thin_checkpoint_budget_still_seeks_exactly() {
let mut play = Stepper::new(confetti(1, 1 << 20), &mut Paddle);
let t = SimTime::from_secs(4.3);
play.seek(t, &mut Paddle);
let want = play.view(t).unwrap().hash();
let s = play.stats();
assert!(s.thinned > 0 && s.checkpoint_bytes <= 1 << 20 || s.checkpoints == 1, "{s:?}");
// Back to the start and forward again from what is left.
play.seek(SimTime::from_secs(0.1), &mut Paddle);
play.seek(t, &mut Paddle);
assert_eq!(play.view(t).unwrap().hash(), want);
}
#[test]
fn stale_checkpoints_are_never_restored() {
let mut st = Stepper::new(confetti(1, 1 << 30), &mut Paddle);
st.seek(SimTime::from_secs(5.0), &mut Paddle);
assert_eq!(st.stats().checkpoints, 11);
// The same document: nothing changes.
assert!(!st.reload(confetti(1, 1 << 30), &mut Paddle));
// An edit (another document hash, or another launch program).
let mut edited = confetti(2, 1 << 30);
if let SpawnFrom::Kernel(k) = &mut edited.spawns[0].from {
k.params.push(("speed".into(), 3.5));
k.program ^= 1;
}
assert!(st.reload(edited.clone(), &mut Paddle));
let s = st.stats();
assert_eq!(s.stale_discarded, 11, "{s:?}");
assert_eq!(s.restores, 0);
let t = SimTime::from_secs(3.0);
st.seek(t, &mut Paddle);
let mut fresh = Stepper::new(edited, &mut Paddle);
fresh.seek(t, &mut Paddle);
assert_eq!(st.view(t).unwrap().hash(), fresh.view(t).unwrap().hash());
// And it differs from the unedited document (the edit did something).
let mut old = Stepper::new(confetti(1, 64 << 20), &mut Paddle);
old.seek(t, &mut Paddle);
assert_ne!(old.view(t).unwrap().hash(), fresh.view(t).unwrap().hash());
}
#[test]
fn spawns_land_on_the_first_step_at_or_after_the_beat() {
let mut st = Stepper::new(confetti(1, 64 << 20), &mut Paddle);
let dt = st.dt();
assert_eq!(dt.step_ceil(SimTime::from_secs(2.0)), 480);
let count = |st: &mut Stepper, t: SimTime| {
st.seek(t, &mut Paddle);
let mut v = Vec::new();
st.view(t).unwrap().bodies("confetti", &mut v);
v.len()
};
let before = dt.time_of(480).saturating_sub(SimTime::from_ticks(1));
assert_eq!(count(&mut st, before), 0);
assert_eq!(count(&mut st, dt.time_of(480)), 60, "the undelayed tenth lands on the beat's step");
assert_eq!(count(&mut st, dt.time_of(480 + 72)), 600, "the rest within 0.3 s");
// Seeking back before the beat removes them again.
assert_eq!(count(&mut st, SimTime::from_secs(1.9)), 0);
// Negative time is the initial state.
st.seek(SimTime::from_secs(-2.0), &mut Paddle);
let v = st.view(SimTime::from_secs(-2.0)).unwrap();
assert_eq!(v.step(), 0);
assert_eq!(v.alpha(), 0.0);
}
#[test]
fn delayed_bodies_wait_and_expire_across_seeks() {
let mut st = Stepper::new(confetti(1, 64 << 20), &mut Paddle);
let balls = |st: &mut Stepper, secs: f64| {
let t = SimTime::from_secs(secs);
st.seek(t, &mut Paddle);
let mut v = Vec::new();
st.view(t).unwrap().bodies("balls", &mut v);
v.len()
};
// 24 balls from 1.0 s, 3 at each of 8 delays 0.05 s apart.
assert_eq!(balls(&mut st, 1.0), 3);
assert_eq!(balls(&mut st, 1.2), 15);
assert_eq!(balls(&mut st, 1.4), 24);
// Lives 1.0, 1.25 and 1.5 s after their own spawn.
assert_eq!(balls(&mut st, 3.0), 0);
let mut ev = Vec::new();
st.view(SimTime::from_secs(3.0)).unwrap().events("balls", &mut ev);
// Back into the middle of it (a restore), and forward.
assert_eq!(balls(&mut st, 1.3), 21);
// Expired by 2.2 s: delay + life <= 1.2 s for five of them.
assert_eq!(balls(&mut st, 2.2), 24 - 5);
let mut ev2 = Vec::new();
let t = SimTime::from_secs(2.2);
st.view(t).unwrap().events("balls", &mut ev2);
assert!(ev2.iter().any(|e| e.kind == EventKind::Expire));
assert!(ev2.iter().all(|e| e.time as f64 > 2.2 - 0.3 - 1e-6 && e.time as f64 <= 2.2 + 1e-6));
}
#[test]
fn contacts_stay_readable_for_the_retention_window() {
let mut st = Stepper::new(confetti(1, 64 << 20), &mut Paddle);
let t = SimTime::from_secs(3.5);
st.seek(t, &mut Paddle);
let v = st.view(t).unwrap();
let mut c = Vec::new();
v.contacts("confetti", &mut c);
assert!(!c.is_empty(), "confetti landing makes contacts");
for k in &c {
assert!(k.time as f64 > 3.5 - 0.3 - 1e-6 && k.time as f64 <= 3.5 + 1e-6, "{k:?}");
assert!(k.impulse >= 0.0 && k.speed >= 0.05);
}
// Many steps' worth, not only the last one.
let steps: std::collections::BTreeSet<u64> = c.iter().map(|k| k.step).collect();
assert!(steps.len() > 5, "{} steps", steps.len());
// The sparks sim put some of them into its state.
let sparks = v.sim("sparks").unwrap();
let lit = sparks.chunks(4).filter(|s| f32::from_bits(s[3]) > 0.0).count();
assert!(lit > 0);
// Bodies interpolate between the two steps around t.
let dt = st.dt();
let mid = dt.time_of(dt.step_floor(t)).saturating_add(SimTime::from_ticks(dt.ticks() / 2));
st.seek(dt.time_of(dt.step_floor(mid) + 1), &mut Paddle);
let va = st.view(mid).unwrap();
assert_eq!(va.alpha(), 0.5);
let (mut a, mut b, mut m) = (Vec::new(), Vec::new(), Vec::new());
va.bodies("confetti", &mut m);
st.view(dt.time_of(dt.step_floor(mid))).unwrap().bodies("confetti", &mut a);
st.view(dt.time_of(dt.step_floor(mid) + 1)).unwrap().bodies("confetti", &mut b);
for k in 0..m.len() {
for c in 0..3 {
assert!((m[k].pos[c] - (a[k].pos[c] + b[k].pos[c]) * 0.5).abs() < 1e-5);
}
}
}
#[test]
fn bodies_publish_through_a_kernel_ring() {
let mut st = Stepper::new(confetti(1, 64 << 20), &mut Paddle);
let layout = RIGID;
let mut ring = body_ring(0xb0d1, &layout);
let fences = ManualFences::with_delay(2);
for f in 120..160 {
let t = SimTime::frame(f, 60, 1);
st.seek(t, &mut Paddle);
let v = st.view(t).unwrap();
ring.collect(&fences);
let now = publish_bodies(&v, "confetti", &layout, &mut ring, &fences).unwrap();
let mut want = Vec::new();
let n = v.write_bodies("confetti", &layout, &mut want);
let draw = ring.draw(&fences).expect("a published slot");
if now.is_some_and(|l| l.0 == draw.generation) {
assert_eq!(draw.count as usize, n);
assert_eq!(&draw.data[..n * RIGID_WORDS], &want[..]);
}
fences.submit();
fences.complete_all();
}
let rs = ring.stats();
assert!(rs.published > 10 && rs.busy > 0, "{rs:?}");
}
#[test]
fn sims_can_spawn_bodies_and_read_bodies() {
// A fountain sim: every 12 steps each of its 4 elements emits a ball
// above the nearest confetti piece (a query over bodies).
let srcs = sim_sources(
"Tick",
"i",
"next[i].n = 0.0",
"i",
"next[i].n = prev[i].n + 1.0\n if bodies_count > 0.0 { let k = physics.nearest(bodies, int(bodies_count), vec3(float(i), 0.0, 0.0))\n emit(drops, Body{pos: bodies[max(k, 0)].pos + vec3(0.0, 1.0, 0.0), size: vec3(0.05, 0.05, 0.05), life: 0.5, shape: 2}) }",
"",
"let bodies = input(Rigid)\nlet bodies_count = param(0.0, 0.0, 1000000.0)\nlet drops = emit_buffer(Body, 1)",
);
let (init, update) = (srcs.init, srcs.update);
let inputs = vec![("bodies".to_string(), SimInput::Bodies("confetti".into()))];
let tick = LayoutView { name: "Tick", stride: 1, fields: &[LayoutFieldView { name: "n", ty: FieldTy::F32, offset: 0 }] };
let mut d = confetti(3, 64 << 20);
d.sims.push(SimDesc {
name: "fountain".into(),
state: RecordLayout::of(&tick),
count: 4,
every: 12,
init: call(&init, "next", &[tick], inputs.clone()),
update: call(&update, "next", &[tick], inputs),
prev: "prev".into(),
spawn: Some(("drops".into(), "drops".into(), Shape::Sphere)),
});
let mut st = Stepper::new(d.clone(), &mut Paddle);
let t = SimTime::from_secs(2.4);
st.seek(t, &mut Paddle);
assert!(st.errors().next().is_none(), "{:?}", st.errors().collect::<Vec<_>>());
let v = st.view(t).unwrap();
let mut drops = Vec::new();
v.bodies("drops", &mut drops);
// From the beat (step 480) to 2.4 s (576): 4 per 12 steps, 9 runs,
// each drop living 0.5 s.
assert_eq!(drops.len(), 4 * 9);
let n = f32::from_bits(v.sim("fountain").unwrap()[0]);
assert_eq!(n, (576 / 12) as f32);
let h = v.hash();
let mut cold = Stepper::new(d, &mut Paddle);
cold.seek(t, &mut Paddle);
assert_eq!(cold.view(t).unwrap().hash(), h);
}
#[test]
fn realtime_advance_keeps_the_last_state_when_behind() {
let mut st = Stepper::new(confetti(1, 64 << 20), &mut Paddle);
let t = SimTime::from_secs(5.0);
match st.advance_to(t, std::time::Duration::ZERO, &mut Paddle) {
Progress::Behind { head } => assert!(head < st.dt().step_floor(t)),
Progress::Ready => panic!("no budget, no steps"),
}
let v = st.view_or_latest(t);
assert!(v.step() < 1200);
assert_eq!(st.stats().late, 1);
while st.advance_to(t, std::time::Duration::from_millis(50), &mut Paddle) != Progress::Ready {}
let h = st.view(t).unwrap().hash();
let mut locked = Stepper::new(confetti(1, 64 << 20), &mut Paddle);
locked.seek(t, &mut Paddle);
assert_eq!(locked.view(t).unwrap().hash(), h, "realtime reaches the same state as locked time");
}
#[test]
fn records_out_of_range_are_refused_before_anything_is_made() {
let mut d = PhysicsDesc::new(9);
let bad = r#"
let out = output(Body)
fn instance(i) {
out[i] = Body{pos: vec3(0.0, 1.0, 0.0), size: vec3(0.1, 0.1, 0.1), vel: vec3(if i == 3 { 1.0e9 } else { 0.0 }, 0.0, 0.0)}
}
"#;
d.spawns.push(SpawnDesc { at: SimTime::ZERO, group: "g".into(), count: 8, shape: Shape::Box, fixed: false, size: [0.1; 3], from: SpawnFrom::Kernel(call(bad, "out", &[], Vec::new())) });
let mut st = Stepper::new(d, &mut NoDrive);
st.seek(SimTime::ZERO, &mut NoDrive);
let errs: Vec<&String> = st.errors().collect();
assert_eq!(errs.len(), 1, "{errs:?}");
assert!(errs[0].contains("record 3") && errs[0].contains("`vel`"), "{}", errs[0]);
let mut v = Vec::new();
st.view(SimTime::ZERO).unwrap().bodies("g", &mut v);
assert_eq!(v.len(), 7);
}
#[test]
fn two_thousand_bodies_step_within_budget() {
// 2000 boxes dropped into a pile: the effect-physics budget is <= 1 ms
// per 1/120 s of sim (two steps at 1/240) on one worker, including
// pose extraction and history upkeep (KERNELS.md §6).
let recs: Vec<BodyRecord> = (0..2000)
.map(|i| BodyRecord { pos: [(i % 20) as f32 * 0.3 - 3.0, 0.3 + (i / 400) as f32 * 0.3, ((i / 20) % 20) as f32 * 0.3 - 3.0], size: [0.2; 3], ..Default::default() })
.collect();
let mut d = PhysicsDesc::new(11);
d.bodies.push(BodyDesc { name: "floor".into(), group: String::new(), shape: Shape::Plane, motion: Motion::Fixed, record: BodyRecord::default() });
d.spawns.push(SpawnDesc { at: SimTime::ZERO, group: "pile".into(), count: 0, shape: Shape::Box, fixed: false, size: [0.2; 3], from: SpawnFrom::Records(Arc::new(recs)) });
let mut st = Stepper::new(d, &mut NoDrive);
let t0 = std::time::Instant::now();
let t = SimTime::from_secs(2.0);
st.seek(t, &mut NoDrive);
let wall = t0.elapsed();
let s = st.stats();
let per_120 = s.step_ns as f64 / s.steps as f64 * 2.0 / 1e6;
eprintln!(
"2000 bodies: {} steps in {:.1} ms wall; {:.3} ms per 1/120 s (extraction {:.3} ms), worst step {:.3} ms, {} checkpoints {:.1} MiB",
s.steps,
wall.as_secs_f64() * 1e3,
per_120,
s.extract_ns as f64 / s.steps as f64 * 2.0 / 1e6,
s.step_ns_max as f64 / 1e6,
s.checkpoints,
s.checkpoint_bytes as f64 / (1 << 20) as f64
);
assert_eq!(s.bodies_live, 2001);
}