Wave World, Particle Playground: keep grids within a cell and particle budget

This commit is contained in:
Dylan M. Taylor
2026-11-02 20:33:37 -05:00
parent 7870b5916a
commit cf686e052d
4 changed files with 95 additions and 29 deletions
+21 -2
View File
@@ -310,13 +310,32 @@ pub fn wave_world_plan(ctx: &EffectCtx, b: &Buf) -> Option<WavePlan> {
wave_world_impl(ctx, b, true).err()
}
/// Most cells of Wave World's grid: the largest Grid Resolution (400) on a layer four times as
/// tall as it is wide. Only the horizontal resolution is a parameter; the vertical one follows
/// the layer's aspect, so a tall layer would otherwise ask for any number of cells.
pub(super) const MAX_WAVE_CELLS: usize = 401 * 1601;
/// Wave World's grid (`nx` × `ny` nodes, square cells) for `res` cells across a `lw` × `lh`
/// layer, coarsened so it has at most [`MAX_WAVE_CELLS`] nodes.
pub(super) fn wave_grid(res: usize, lw: f64, lh: f64) -> (usize, usize) {
let aspect = lh / lw.max(1.0);
let aspect = if aspect.is_finite() { aspect.max(0.0) } else { 0.0 };
let nodes = |r: usize| (r as f64 + 1.0) * ((r as f64 * aspect).round().max(2.0) + 1.0);
let mut res = res.max(2);
if nodes(res) > MAX_WAVE_CELLS as f64 {
res = ((res as f64 * (MAX_WAVE_CELLS as f64 / nodes(res)).sqrt()).floor() as usize).max(2);
}
let nx = res + 1;
let ny = ((res as f64 * aspect).round().min((MAX_WAVE_CELLS / nx - 1) as f64) as usize).max(2) + 1;
(nx, ny)
}
fn wave_world_impl(ctx: &EffectCtx, b: &Buf, plan_only: bool) -> Result<Image, WavePlan> {
let pr = ctx.params;
let [lw, lh] = ctx.layer_size;
let res = pr.f("simulation/gridResolution").clamp(1.0, 400.0) as usize;
let res = if pr.b("simulation/gridResDownsamples") { ((res as f64) * b.scale).round().max(1.0) as usize } else { res };
let nx = res.max(2) + 1;
let ny = ((res as f64 * lh / lw.max(1.0)).round() as usize).max(2) + 1;
let (nx, ny) = wave_grid(res, lw, lh);
let speed = pr.f("simulation/waveSpeed").max(0.0) as f32;
let damping = pr.f("simulation/damping").max(0.0) as f32;
let reflect = pr.e("simulation/reflectEdges");
+15
View File
@@ -483,3 +483,18 @@ fn foam_flow_map_pop_velocity_wobble_textures_and_reflection() {
let z2 = with(&[off.as_slice(), &[("producer/zoomProducerPoint", Value::Bool(true))]].concat(), 1.0);
assert!((cx(&z2) - 8.0).abs() < 2.0 && cx(&z1) < 4.0, "{} vs {}", cx(&z2), cx(&z1));
}
/// Wave World's vertical resolution follows the layer's aspect, but the grid as a whole stays
/// within its cell budget however tall (or degenerate) the layer is.
#[test]
fn wave_world_grid_is_bounded() {
use super::surfaces::{MAX_WAVE_CELLS, wave_grid};
assert_eq!(wave_grid(60, 160.0, 90.0), (61, 35));
assert_eq!(wave_grid(400, 100.0, 400.0), (401, 1601));
for (res, lw, lh) in [(400, 1.0, 1e9), (400, 0.0, f64::MAX), (400, 1.0, f64::INFINITY), (400, 1.0, f64::NAN), (100_000, 1e6, 1e6)] {
let (nx, ny) = wave_grid(res, lw, lh);
assert!(nx >= 3 && ny >= 3 && nx * ny <= MAX_WAVE_CELLS, "{res} {lw}×{lh}: {nx}×{ny}");
}
let img = run("ec.sim.waveworld", &[("simulation/gridResolution", num(400.0))], Image::new(1, 4000), 0.1);
assert_eq!((img.width, img.height), (1, 4000));
}
+41 -27
View File
@@ -501,7 +501,12 @@ impl Pg {
if self.pex_radius > 0.0 {
let mut out = Vec::with_capacity(st.parts.len());
for q in std::mem::take(&mut st.parts) {
if q.src == SRC_PARTICLE_EXPLODER || q.ch != 0 || q.r <= self.pex_radius * 1.01 || self.pex_affects.weight(&q) <= 0.5 || out.len() > 60_000 {
if q.src == SRC_PARTICLE_EXPLODER
|| q.ch != 0
|| q.r <= self.pex_radius * 1.01
|| self.pex_affects.weight(&q) <= 0.5
|| out.len() > MAX_PG_PARTICLES
{
out.push(q);
continue;
}
@@ -529,7 +534,7 @@ impl Pg {
let n = st.carry.floor() as u32;
st.carry -= n as f64;
for _ in 0..n {
if st.parts.len() >= 60_000 {
if st.parts.len() >= MAX_PG_PARTICLES {
break;
}
let id = st.next_id;
@@ -668,7 +673,7 @@ impl Affects {
fn pg_accelerated(ctx: &EffectCtx, key: u64, pg: &Pg) -> Option<std::sync::Arc<PgState>> {
let backend = ctx.env.host?.particles()?;
let steps = steps_at(ctx.time);
let births = crate::psim::births(pg.rate, steps, 60_000)?;
let births = crate::psim::births(pg.rate, steps, MAX_PG_PARTICLES)?;
let desc = crate::psim::CannonDesc {
pos: pg.pos,
barrel_angle: pg.barrel_angle,
@@ -721,6 +726,10 @@ fn particle_playground(ctx: &EffectCtx, b: Buf) -> Buf {
playground_plan(ctx, &b).finish(b)
}
/// Most particles Particle Playground keeps, from all its producers (Grid, Layer Exploder,
/// Cannon, Particle Exploder).
const MAX_PG_PARTICLES: usize = 60_000;
/// Particle Playground's [`PgPlan`] for buffer geometry `b` (its pixels are not read).
pub fn playground_plan(ctx: &EffectCtx, b: &Buf) -> PgPlan {
let pr = ctx.params;
@@ -768,38 +777,43 @@ pub fn playground_plan(ctx: &EffectCtx, b: &Buf) -> PgPlan {
let mut st = PgState::default();
if grid_on && across > 0 && down > 0 {
let mut k = 0usize;
for j in 0..down {
for i in 0..across {
// Edit Grid Text: one character per grid point in reading order; spaces
// leave the point empty.
let ch = if grid_text.is_empty() {
0
} else {
let c = grid_text[k % grid_text.len()];
k += 1;
if c == ' ' {
continue;
}
c as u32
};
let fx = if across > 1 { i as f64 / (across - 1) as f64 - 0.5 } else { 0.0 };
let fy = if down > 1 { j as f64 / (down - 1) as f64 - 0.5 } else { 0.0 };
let p = [(gpos[0] + fx * gw) as f32, (gpos[1] + fy * gh) as f32];
let id = st.next_id;
st.next_id += 1;
let mut q = PgParticle::new(p, [0.0; 2], gcol, grad, id, SRC_GRID);
q.ch = ch;
st.parts.push(q);
}
// The grid's points in reading order, at most the particle budget of them (the
// counts come from the project: 10⁶ × 10⁶ would be a trillion particles).
let points = (across as usize).saturating_mul(down as usize).min(MAX_PG_PARTICLES);
for n in 0..points {
let (i, j) = (n as u32 % across, n as u32 / across);
// Edit Grid Text: one character per grid point in reading order; spaces
// leave the point empty.
let ch = if grid_text.is_empty() {
0
} else {
let c = grid_text[k % grid_text.len()];
k += 1;
if c == ' ' {
continue;
}
c as u32
};
let fx = if across > 1 { i as f64 / (across - 1) as f64 - 0.5 } else { 0.0 };
let fy = if down > 1 { j as f64 / (down - 1) as f64 - 0.5 } else { 0.0 };
let p = [(gpos[0] + fx * gw) as f32, (gpos[1] + fy * gh) as f32];
let id = st.next_id;
st.next_id += 1;
let mut q = PgParticle::new(p, [0.0; 2], gcol, grad, id, SRC_GRID);
q.ch = ch;
st.parts.push(q);
}
}
if let Some(e) = &exploder {
let m = MapImg::from(ctx, e.clone());
let step = (ex_r * 2.0).max(1.0);
let mut y = step * 0.5;
while y < lh {
'explode: while y < lh {
let mut x = step * 0.5;
while x < lw {
if st.parts.len() >= MAX_PG_PARTICLES {
break 'explode;
}
let c = m.at(x, y);
if c[3] > 0.05 {
let id = st.next_id;
+18
View File
@@ -370,3 +370,21 @@ fn cc_hair_grows_from_opaque_pixels() {
let none = run_fx("ec.sim.cchair", &[("length", num(0.0))], img.clone(), 0.0, EffectEnv::default());
assert_eq!(none.img.data, img.data);
}
/// Grid counts from a project are held to the particle budget instead of asking for a
/// trillion particles (or visiting a trillion empty grid points behind a blank Grid Text).
#[test]
fn hostile_grid_counts_stay_within_the_particle_budget() {
for text in ["", " "] {
let img = run(
&[
("cannonEnabled", Value::Bool(false)),
("grid/particlesAcross", num(1e9)),
("grid/particlesDown", num(1e9)),
("options/gridText", Value::Str(text.into())),
],
0.0,
);
assert_eq!((img.width, img.height), (80, 60));
}
}