diff --git a/crates/effects/src/sim2/surfaces.rs b/crates/effects/src/sim2/surfaces.rs index 7c3c032f0..d96a02c1a 100644 --- a/crates/effects/src/sim2/surfaces.rs +++ b/crates/effects/src/sim2/surfaces.rs @@ -310,13 +310,32 @@ pub fn wave_world_plan(ctx: &EffectCtx, b: &Buf) -> Option { 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 { 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"); diff --git a/crates/effects/src/sim2/tests.rs b/crates/effects/src/sim2/tests.rs index 2ef35b122..f14c09735 100644 --- a/crates/effects/src/sim2/tests.rs +++ b/crates/effects/src/sim2/tests.rs @@ -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)); +} diff --git a/crates/effects/src/sim3.rs b/crates/effects/src/sim3.rs index 88a16d981..2cdc3fed2 100644 --- a/crates/effects/src/sim3.rs +++ b/crates/effects/src/sim3.rs @@ -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> { 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; diff --git a/crates/effects/src/sim3/tests.rs b/crates/effects/src/sim3/tests.rs index ffce0ae6d..fb7cb35be 100644 --- a/crates/effects/src/sim3/tests.rs +++ b/crates/effects/src/sim3/tests.rs @@ -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)); + } +}