diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index a1314f59..77f7f49f 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -37,7 +37,6 @@ pub const PARTIAL: &[(&str, &str)] = &[ ("ec.vr.converter", "common layouts only"), ("ec.sim.caustics", "no Sky group or light type"), ("ec.sim.foam", "no wobble, pop velocity, custom bubble texture, environment map or flow map"), - ("ec.sim.particleplayground", "no Particle Exploder, Ephemeral Property Mapper, Affects groups or text particles"), ("ec.sim.waveworld", "no wireframe controls, dry-area rendering or ground group"), ("ec.stylize.glow", "no Glow Operation or arbitrary colour map"), ("ec.stylize.cartoon", "no Edge Enhancement"), diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 91a4cdda..49d98236 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -593,6 +593,10 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[ ("repel", "Repel"), ("wall", "Wall"), ("persistentPropertyMapper", "Persistent Property Mapper"), + ("ephemeralPropertyMapper", "Ephemeral Property Mapper"), + ("particleExploder", "Particle Exploder"), + ("affects", "Affects"), + ("options", "Options"), // Keying, Matte, Obsolete (Key Light's groups are in `keylight::GROUPS`). ("ultraSettings", "Ultra Settings"), ("fillAndStroke", "Fill and Stroke"), diff --git a/crates/effects/src/migrate.rs b/crates/effects/src/migrate.rs index 727b827f..b2f48573 100644 --- a/crates/effects/src/migrate.rs +++ b/crates/effects/src/migrate.rs @@ -402,4 +402,21 @@ mod tests { assert_eq!(g.get("backSelf").unwrap().value, Value::Bool(self_)); } } + + #[test] + fn particle_playground_mapper_targets_remap_by_label() { + let spec = find("ec.sim.particleplayground").unwrap(); + let mut next = 1; + let mut g = instantiate(spec, &mut Ids(&mut next), "Particle Playground", [100.0, 50.0]); + let old: Vec = + ["None", "Red", "Green", "Blue", "Kinetic Friction", "Scale", "X", "Y", "X Speed", "Y Speed", "X Force", "Y Force", "Opacity", "Mass"] + .iter() + .map(|s| s.to_string()) + .collect(); + let pr = prop_at(&mut g, "persistentPropertyMapper/mapRedTo").unwrap(); + pr.ui = ParamUi::Popup { options: old }; + pr.value = Value::Enum(8); + assert!(upgrade_instance(spec, &mut g, &mut Ids(&mut next), [100.0, 50.0])); + assert_eq!(prop_at(&mut g, "persistentPropertyMapper/mapRedTo").unwrap().value, Value::Enum(15), "X Speed"); + } } diff --git a/crates/effects/src/sim3.rs b/crates/effects/src/sim3.rs index 5560917a..a8763d85 100644 --- a/crates/effects/src/sim3.rs +++ b/crates/effects/src/sim3.rs @@ -8,10 +8,14 @@ //! random spread, direction), **Repel** (particles push each other apart within a radius), //! **Wall** (a mask of the layer the particles bounce off) and the **Persistent Property //! Mapper** (a map layer's red / green / blue drive particle properties at the particle's -//! position each step). **Layer Map** draws particles with the colours of another layer -//! sampled at each particle's birth position. Map layers are read at the frame being rendered -//! (their pixels are part of the cache key). Particles replace the layer's own pixels, as in -//! After Effects. +//! position each step) and the **Ephemeral Property Mapper** (the same with an operator per +//! channel, holding for one step only). **Particle Exploder** bursts particles into smaller +//! ones. **Layer Map** draws each particle as a copy of another layer, at a time set by its Time +//! Offset Type. Every producer / force / mapper has an **Affects** group (particles from which +//! producer, a selection map, characters, age with feather). The Options' cannon / grid text +//! turns particles into characters (the built-in stroke font). Map layers are read at the frame +//! being rendered (their pixels are part of the cache key). Particles replace the layer's own +//! pixels, as in After Effects. //! //! **CC Hair** grows strands from the layer's opaque pixels (Density per 1 000 px²), each a //! quadratic curve of Length that droops with Weight, optionally steered and scaled by a hairfall @@ -33,20 +37,72 @@ fn spec(id: &'static str, name: &'static str, params: Vec, ren // ---------------------------------------------------------------- Particle Playground +/// Which producer made a particle (Affects ▸ Particles From). +pub const SRC_CANNON: u8 = 0; +pub const SRC_GRID: u8 = 1; +pub const SRC_LAYER_EXPLODER: u8 = 2; +pub const SRC_PARTICLE_EXPLODER: u8 = 3; + #[derive(Clone, Copy, Debug, PartialEq)] pub struct PgParticle { pub p: [f32; 2], pub v: [f32; 2], - /// Layer position where the particle was born (Layer Map lookups). + /// Layer position where the particle was born. pub origin: [f32; 2], pub c: [f32; 4], + /// Radius, or font size for text particles. pub r: f32, pub mass: f32, + /// Kinetic friction (velocity damping) and static friction (force needed to start moving). pub friction: f32, + pub static_friction: f32, pub force: [f32; 2], + /// Rotation (degrees), angular velocity (°/s) and torque (°/s²). + pub angle: f32, + pub ang_vel: f32, + pub torque: f32, + /// X / Y scale (1 = 100 %) and its growth per second. + pub scale: [f32; 2], + pub scale_speed: f32, + /// Age and lifespan in seconds. + pub age: f32, + pub lifespan: f32, + /// Character code of a text particle (0 = a dot). + pub ch: u32, + /// Layer Map time offset (seconds). + pub toff: f32, + /// Producer (`SRC_*`). + pub src: u8, pub id: u32, } +impl PgParticle { + pub fn new(p: [f32; 2], v: [f32; 2], c: [f32; 4], r: f32, id: u32, src: u8) -> PgParticle { + PgParticle { + p, + v, + origin: p, + c, + r, + mass: 1.0, + friction: 0.0, + static_friction: 0.0, + force: [0.0; 2], + angle: 0.0, + ang_vel: 0.0, + torque: 0.0, + scale: [1.0; 2], + scale_speed: 0.0, + age: 0.0, + lifespan: f32::INFINITY, + ch: 0, + toff: 0.0, + src, + id, + } + } +} + #[derive(Clone, Debug, Default)] pub struct PgState { pub parts: Vec, @@ -76,10 +132,216 @@ impl MapImg { } } -/// Particle properties a Property Mapper channel can drive (After Effects' order; the -/// properties we don't model yet are left out). -pub const MAP_TARGETS: [&str; 14] = - ["None", "Red", "Green", "Blue", "Kinetic Friction", "Scale", "X", "Y", "X Speed", "Y Speed", "X Force", "Y Force", "Opacity", "Mass"]; +/// Particle properties a Property Mapper channel can drive, in After Effects' order. +pub const MAP_TARGETS: [&str; 27] = [ + "None", + "Red", + "Green", + "Blue", + "Kinetic Friction", + "Static Friction", + "Angle", + "Angular Velocity", + "Torque", + "Scale", + "X Scale", + "Y Scale", + "X", + "Y", + "Gradient Velocity", + "X Speed", + "Y Speed", + "Gradient Force", + "X Force", + "Y Force", + "Opacity", + "Mass", + "Lifespan", + "Character", + "Font Size", + "Time Offset", + "Scale Speed", +]; + +/// Ephemeral Property Mapper operators. +pub const MAP_OPERATORS: [&str; 7] = ["Set", "Add", "Difference", "Subtract", "Multiply", "Min", "Max"]; + +/// Affects ▸ Particles From options. +pub const PARTICLES_FROM: [&str; 8] = + ["All", "Cannon", "Grid", "Layer Exploder", "Particle Exploder", "Grid & Layer Exploder", "Cannon & Grid", "Cannon & Particle Exploder"]; + +/// Layer Map ▸ Time Offset Type options. +pub const TIME_OFFSET_TYPES: [&str; 4] = ["Relative", "Absolute", "Relative Random", "Absolute Random"]; + +/// A property's current value (`target` in [`MAP_TARGETS`]). +fn prop_get(q: &PgParticle, target: u32) -> f32 { + match target { + 1 => q.c[0], + 2 => q.c[1], + 3 => q.c[2], + 4 => q.friction, + 5 => q.static_friction, + 6 => q.angle, + 7 => q.ang_vel, + 8 => q.torque, + 9 => (q.scale[0] + q.scale[1]) * 0.5, + 10 => q.scale[0], + 11 => q.scale[1], + 12 => q.p[0], + 13 => q.p[1], + 15 => q.v[0], + 16 => q.v[1], + 18 => q.force[0], + 19 => q.force[1], + 20 => q.c[3], + 21 => q.mass, + 22 => q.lifespan, + 23 => q.ch as f32, + 24 => q.r, + 25 => q.toff, + 26 => q.scale_speed, + _ => 0.0, + } +} + +fn prop_set(q: &mut PgParticle, target: u32, v: f32) { + match target { + 1 => q.c[0] = v, + 2 => q.c[1] = v, + 3 => q.c[2] = v, + 4 => q.friction = v.clamp(0.0, 10.0), + 5 => q.static_friction = v.max(0.0), + 6 => q.angle = v, + 7 => q.ang_vel = v, + 8 => q.torque = v, + 9 => q.scale = [v.max(0.0); 2], + 10 => q.scale[0] = v.max(0.0), + 11 => q.scale[1] = v.max(0.0), + 12 => q.p[0] = v, + 13 => q.p[1] = v, + 15 => q.v[0] = v, + 16 => q.v[1] = v, + 18 => q.force[0] = v, + 19 => q.force[1] = v, + 20 => q.c[3] = v.clamp(0.0, 1.0), + 21 => q.mass = v.max(0.01), + 22 => q.lifespan = v.max(0.0), + 23 => q.ch = v.round().clamp(0.0, 0x10ffff as f32) as u32, + 24 => q.r = v.max(0.0), + 25 => q.toff = v, + 26 => q.scale_speed = v, + _ => {} + } +} + +/// An Affects group: which particles a producer, force or mapper acts on, as a weight 0..1. +#[derive(Default)] +struct Affects { + from: u32, + selection: Option, + chars: Vec, + /// Positive: older than; negative: younger than (seconds); 0 = any age. + age: f32, + feather: f32, +} + +impl Affects { + fn from(ctx: &EffectCtx, group: &str) -> Affects { + let pr = ctx.params; + let g = |id: &str| format!("{group}/affects/{id}"); + Affects { + from: pr.e(&g("particlesFrom")), + selection: ctx.layer_param(&g("selectionMap"), true).map(|lp| MapImg::from(ctx, lp)), + chars: pr.s(&g("characters")).chars().collect(), + age: pr.f(&g("olderYoungerThan")) as f32, + feather: pr.f(&g("ageFeather")).max(0.0) as f32, + } + } + fn weight(&self, q: &PgParticle) -> f32 { + let src_ok = match self.from { + 1 => q.src == SRC_CANNON, + 2 => q.src == SRC_GRID, + 3 => q.src == SRC_LAYER_EXPLODER, + 4 => q.src == SRC_PARTICLE_EXPLODER, + 5 => q.src == SRC_GRID || q.src == SRC_LAYER_EXPLODER, + 6 => q.src == SRC_CANNON || q.src == SRC_GRID, + 7 => q.src == SRC_CANNON || q.src == SRC_PARTICLE_EXPLODER, + _ => true, + }; + if !src_ok { + return 0.0; + } + if !self.chars.is_empty() && !char::from_u32(q.ch).is_some_and(|c| self.chars.contains(&c)) { + return 0.0; + } + let mut w = 1.0; + if self.age != 0.0 { + // Signed distance past the age threshold, feathered across Age Feather. + let d = if self.age > 0.0 { q.age - self.age } else { -self.age - q.age }; + w *= if self.feather > 0.0 { + (d / self.feather + 0.5).clamp(0.0, 1.0) + } else if d >= 0.0 { + 1.0 + } else { + 0.0 + }; + } + if let Some(m) = &self.selection { + let c = m.at(q.p[0], q.p[1]); + w *= effectcraft_color::luminance(c[0], c[1], c[2]).clamp(0.0, 1.0) * c[3]; + } + w + } +} + +/// A Persistent or Ephemeral Property Mapper. +struct Mapper { + map: MapImg, + /// Per channel (R, G, B): target, operator, min, max. + chans: [(u32, u32, f32, f32); 3], + affects: Affects, +} + +impl Mapper { + fn apply(&self, q: &mut PgParticle) { + let w = self.affects.weight(q); + if w <= 0.0 { + return; + } + let px = self.map.at(q.p[0], q.p[1]); + for (ch, &(target, op, lo, hi)) in self.chans.iter().enumerate() { + if target == 0 { + continue; + } + let v = lo + (hi - lo) * px[ch]; + if target == 14 || target == 17 { + // Gradient Velocity / Force: along the map channel's gradient. + let gx = self.map.at(q.p[0] + 1.0, q.p[1])[ch] - self.map.at(q.p[0] - 1.0, q.p[1])[ch]; + let gy = self.map.at(q.p[0], q.p[1] + 1.0)[ch] - self.map.at(q.p[0], q.p[1] - 1.0)[ch]; + let (gx, gy) = (gx * 0.5 * (hi - lo) * w, gy * 0.5 * (hi - lo) * w); + if target == 14 { + q.v = [q.v[0] + gx, q.v[1] + gy]; + } else { + q.force = [q.force[0] + gx, q.force[1] + gy]; + } + continue; + } + let cur = prop_get(q, target); + let new = match op { + 1 => cur + v, + 2 => (cur - v).abs(), + 3 => cur - v, + 4 => cur * v, + 5 => cur.min(v), + 6 => cur.max(v), + _ => v, + }; + // (An unlimited lifespan has no in-between.) + let v = if w >= 1.0 || !cur.is_finite() { new } else { cur + (new - cur) * w }; + prop_set(q, target, v); + } + } +} struct Pg { // Cannon @@ -93,17 +355,26 @@ struct Pg { vel_spread: f32, color: [f32; 4], radius: f32, + /// Edit Cannon Text: characters fired in order (empty = dots). + cannon_text: Vec, + // Particle Exploder + pex_radius: f32, + pex_dispersion: f32, + pex_affects: Affects, // Gravity gravity: [f32; 2], grav_spread: f32, + grav_affects: Affects, // Repel repel: f32, repel_radius: f32, + repel_affects: Affects, // Wall wall: Option>, - // Property mapper - mapper: Option, - map_to: [(u32, f32, f32); 3], + wall_affects: Affects, + // Property mappers + persistent: Option, + ephemeral: Option, seed: u32, bounds: [f32; 4], } @@ -125,48 +396,46 @@ impl Pg { [a.cos() * rr, a.sin() * rr] }; let p = [self.pos[0] + off[0], self.pos[1] + off[1]]; - PgParticle { p, v: [d[0] * speed, d[1] * speed], origin: p, c: self.color, r: self.radius, mass: 1.0, friction: 0.0, force: [0.0; 2], id } + let mut q = PgParticle::new(p, [d[0] * speed, d[1] * speed], self.color, self.radius, id, SRC_CANNON); + if !self.cannon_text.is_empty() { + q.ch = self.cannon_text[id as usize % self.cannon_text.len()] as u32; + } + q + } + + /// The particle as the ephemeral mapper sees it this step (unchanged without one). + fn ephemeral(&self, q: &PgParticle) -> PgParticle { + let mut e = *q; + if let Some(m) = &self.ephemeral { + m.apply(&mut e); + } + e } fn step(&self, st: &mut PgState) { let dt = (1.0 / SPS) as f32; let s = self.seed; - // Property mapper (persistent): drive properties from the map at the particle position. - if let Some(m) = &self.mapper { - st.parts.par_iter_mut().for_each(|q| { - let px = m.at(q.p[0], q.p[1]); - for (ch, &(target, lo, hi)) in self.map_to.iter().enumerate() { - let v = lo + (hi - lo) * px[ch]; - match target { - 1 => q.c[0] = v, - 2 => q.c[1] = v, - 3 => q.c[2] = v, - 4 => q.friction = v.clamp(0.0, 1.0), - 5 => q.r = self.radius * v.max(0.0), - 6 => q.p[0] = v, - 7 => q.p[1] = v, - 8 => q.v[0] = v, - 9 => q.v[1] = v, - 10 => q.force[0] = v, - 11 => q.force[1] = v, - 12 => q.c[3] = v.clamp(0.0, 1.0), - 13 => q.mass = v.max(0.01), - _ => {} - } - } - }); + // Persistent Property Mapper: the mapped values stay with the particle. + if let Some(m) = &self.persistent { + st.parts.par_iter_mut().for_each(|q| m.apply(q)); } + // Ephemeral Property Mapper: the mapped values hold for this step only. + let eph: Vec = st.parts.par_iter().map(|q| self.ephemeral(q)).collect(); // Repel: spatial hash, symmetric pairwise push within the radius. let mut push = vec![[0.0f32; 2]; st.parts.len()]; - if self.repel != 0.0 && self.repel_radius > 0.0 && st.parts.len() > 1 { + if self.repel != 0.0 && self.repel_radius > 0.0 && eph.len() > 1 { let cell = self.repel_radius; let mut grid: std::collections::HashMap<(i32, i32), Vec> = std::collections::HashMap::new(); - for (i, q) in st.parts.iter().enumerate() { + for (i, q) in eph.iter().enumerate() { grid.entry(((q.p[0] / cell).floor() as i32, (q.p[1] / cell).floor() as i32)).or_default().push(i); } - let parts = &st.parts; + let parts = &eph; push.par_iter_mut().enumerate().for_each(|(i, f)| { let q = parts[i]; + let w = self.repel_affects.weight(&q); + if w <= 0.0 { + return; + } let (cx, cy) = ((q.p[0] / cell).floor() as i32, (q.p[1] / cell).floor() as i32); for gy in cy - 1..=cy + 1 { for gx in cx - 1..=cx + 1 { @@ -179,7 +448,7 @@ impl Pg { let (dx, dy) = (q.p[0] - o.p[0], q.p[1] - o.p[1]); let d = (dx * dx + dy * dy).sqrt(); if d < self.repel_radius && d > 1e-4 { - let k = self.repel * (1.0 - d / self.repel_radius) / d; + let k = w * self.repel * (1.0 - d / self.repel_radius) / d; f[0] += dx * k; f[1] += dy * k; } @@ -189,14 +458,26 @@ impl Pg { }); } let wall = self.wall.as_deref(); - st.parts.par_iter_mut().zip(push.par_iter()).for_each(|(q, f)| { + st.parts.par_iter_mut().zip(eph.par_iter()).zip(push.par_iter()).for_each(|((q, e), f)| { + let gw = self.grav_affects.weight(e); let g = if self.grav_spread > 0.0 { 1.0 + (hash1(q.id, 7, s) * 2.0 - 1.0) * self.grav_spread } else { 1.0 }; - let ax = self.gravity[0] * g + (q.force[0] + f[0]) / q.mass; - let ay = self.gravity[1] * g + (q.force[1] + f[1]) / q.mass; - let damp = 1.0 - q.friction * 0.1; - q.v = [(q.v[0] + ax * dt) * damp, (q.v[1] + ay * dt) * damp]; - let np = [q.p[0] + q.v[0] * dt, q.p[1] + q.v[1] * dt]; - if let Some(w) = wall { + let ax = self.gravity[0] * g * gw + (e.force[0] + f[0]) / e.mass; + let ay = self.gravity[1] * g * gw + (e.force[1] + f[1]) / e.mass; + // Static friction: a resting particle stays until the push beats it. + let resting = e.v[0].abs() < 1e-3 && e.v[1].abs() < 1e-3; + let (ax, ay) = if resting && (ax * ax + ay * ay).sqrt() <= e.static_friction { (0.0, 0.0) } else { (ax, ay) }; + let damp = (1.0 - e.friction * 0.1).max(0.0); + let nv = [(e.v[0] + ax * dt) * damp, (e.v[1] + ay * dt) * damp]; + // Keep only the persistent part of the velocity. + q.v = [nv[0] - (e.v[0] - q.v[0]), nv[1] - (e.v[1] - q.v[1])]; + q.ang_vel += e.torque * dt; + q.angle += e.ang_vel * dt; + q.scale = [(q.scale[0] + e.scale_speed * dt).max(0.0), (q.scale[1] + e.scale_speed * dt).max(0.0)]; + q.age += dt; + let np = [q.p[0] + nv[0] * dt, q.p[1] + nv[1] * dt]; + if let Some(w) = wall + && self.wall_affects.weight(e) > 0.5 + { let was = point_in_poly(w, q.p[0] as f64, q.p[1] as f64); let now = point_in_poly(w, np[0] as f64, np[1] as f64); if was != now { @@ -208,7 +489,41 @@ impl Pg { q.p = np; }); let b = self.bounds; - st.parts.retain(|q| q.p[0] > b[0] && q.p[0] < b[2] && q.p[1] > b[1] && q.p[1] < b[3]); + let eph_life: Vec = st.parts.iter().map(|q| self.ephemeral(q).lifespan).collect(); + let mut k = 0; + st.parts.retain(|q| { + let life = eph_life[k]; + k += 1; + q.p[0] > b[0] && q.p[0] < b[2] && q.p[1] > b[1] && q.p[1] < b[3] && q.age <= life + }); + // Particle Exploder: affected particles larger than the new radius burst into smaller + // ones that fly apart (each new particle bursts no further). + 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 { + out.push(q); + continue; + } + let n = ((q.r / self.pex_radius).powi(2).round() as u32).clamp(2, 16); + for k in 0..n { + let id = st.next_id; + st.next_id = st.next_id.wrapping_add(1); + let a = (k as f32 + hash1(id, 31, s)) / n as f32 * std::f32::consts::TAU; + let (sa, ca) = a.sin_cos(); + let rr = q.r - self.pex_radius; + let sp = self.pex_dispersion * (0.5 + hash1(id, 32, s)); + let mut c = q; + c.p = [q.p[0] + ca * rr, q.p[1] + sa * rr]; + c.v = [q.v[0] + ca * sp, q.v[1] + sa * sp]; + c.r = self.pex_radius; + c.src = SRC_PARTICLE_EXPLODER; + c.id = id; + out.push(c); + } + } + st.parts = out; + } if self.cannon { st.carry += self.rate / SPS; let n = st.carry.floor() as u32; @@ -244,13 +559,58 @@ fn image_key(img: &Image) -> u64 { h.finish() } +fn mapper_from(ctx: &EffectCtx, group: &str, ephemeral: bool) -> Option { + let pr = ctx.params; + let map = ctx.layer_param(&format!("{group}/useLayerAsMap"), true).map(|lp| MapImg::from(ctx, lp))?; + let ch = |c: &str| { + ( + pr.e(&format!("{group}/map{c}To")), + if ephemeral { pr.e(&format!("{group}/{}Operator", c.to_lowercase())) } else { 0 }, + pr.f(&format!("{group}/{}Min", c.to_lowercase())) as f32, + pr.f(&format!("{group}/{}Max", c.to_lowercase())) as f32, + ) + }; + Some(Mapper { map, chans: [ch("Red"), ch("Green"), ch("Blue")], affects: Affects::from(ctx, group) }) +} + +/// Draw a map layer frame as a particle: centred on (x, y) buffer px, rotated `angle` degrees +/// and scaled, over `out`. +fn blit_layer(out: &mut Image, lp: &crate::LayerPixels, x: f64, y: f64, angle: f64, scale: [f64; 2], buf_scale: f64, alpha: f32) { + let (w, h) = (lp.size[0] * scale[0].abs() * buf_scale, lp.size[1] * scale[1].abs() * buf_scale); + let r = (w * w + h * h).sqrt() * 0.5 + 1.0; + let (x0, x1) = ((x - r).floor().max(0.0) as i64, (x + r).ceil().min(out.width as f64) as i64); + let (y0, y1) = ((y - r).floor().max(0.0) as i64, (y + r).ceil().min(out.height as f64) as i64); + if x0 >= x1 || y0 >= y1 || scale[0] == 0.0 || scale[1] == 0.0 { + return; + } + let (sa, ca) = angle.to_radians().sin_cos(); + for py in y0..y1 { + for px in x0..x1 { + let (dx, dy) = ((px as f64 + 0.5 - x) / buf_scale, (py as f64 + 0.5 - y) / buf_scale); + // Undo rotation, then scale; into map layer pixels around its centre. + let (u, v) = ((dx * ca + dy * sa) / scale[0] + lp.size[0] * 0.5, (-dx * sa + dy * ca) / scale[1] + lp.size[1] * 0.5); + if u < 0.0 || v < 0.0 || u >= lp.size[0] || v >= lp.size[1] { + continue; + } + let s = lp.buf.img.sample_bilinear(u * lp.buf.scale + lp.buf.offset[0], v * lp.buf.scale + lp.buf.offset[1]); + if s[3] <= 0.0 { + continue; + } + let d = out.get(px, py); + let k = 1.0 - s[3] * alpha; + out.set(px as u32, py as u32, [s[0] * alpha + d[0] * k, s[1] * alpha + d[1] * k, s[2] * alpha + d[2] * k, s[3] * alpha + d[3] * k]); + } + } +} + fn particle_playground(ctx: &EffectCtx, mut b: Buf) -> Buf { let pr = ctx.params; let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32); let grav_dir = (pr.f("gravity/gravityDirection") as f32).to_radians(); let gf = pr.f("gravity/gravityForce") as f32; let wall = (pr.f("wall/wallBoundary").round() as usize).checked_sub(1).and_then(|i| ctx.env.masks.get(i)).map(|m| m.points.clone()); - let mapper = if pr.b("mapperEnabled") { ctx.layer_param("persistentPropertyMapper/useLayerAsMap", true).map(|lp| MapImg::from(ctx, lp)) } else { None }; + let persistent = if pr.b("mapperEnabled") { mapper_from(ctx, "persistentPropertyMapper", false) } else { None }; + let ephemeral = mapper_from(ctx, "ephemeralPropertyMapper", true); let pg = Pg { cannon: pr.b("cannonEnabled"), pos: { pr.v2("cannon/cannonPosition").map(|v| v as f32) }, @@ -262,17 +622,20 @@ fn particle_playground(ctx: &EffectCtx, mut b: Buf) -> Buf { vel_spread: pr.f("cannon/velocityRandomSpread") as f32, color: pr.color("cannon/cannonColor"), radius: pr.f("cannon/cannonParticleRadius").max(0.0) as f32, + cannon_text: pr.s("options/cannonText").chars().collect(), + pex_radius: pr.f("particleExploder/radiusOfNewParticles").max(0.0) as f32, + pex_dispersion: pr.f("particleExploder/velocityDispersion") as f32, + pex_affects: Affects::from(ctx, "particleExploder"), gravity: [grav_dir.sin() * gf, -grav_dir.cos() * gf], grav_spread: (pr.f("gravity/gravityForceRandomSpread") / 100.0).max(0.0) as f32, + grav_affects: Affects::from(ctx, "gravity"), repel: pr.f("repel/repelForce") as f32 * 100.0, repel_radius: pr.f("repel/repelForceRadius").max(0.0) as f32, + repel_affects: Affects::from(ctx, "repel"), wall, - mapper, - map_to: [ - (pr.e("persistentPropertyMapper/mapRedTo"), pr.f("persistentPropertyMapper/redMin") as f32, pr.f("persistentPropertyMapper/redMax") as f32), - (pr.e("persistentPropertyMapper/mapGreenTo"), pr.f("persistentPropertyMapper/greenMin") as f32, pr.f("persistentPropertyMapper/greenMax") as f32), - (pr.e("persistentPropertyMapper/mapBlueTo"), pr.f("persistentPropertyMapper/blueMin") as f32, pr.f("persistentPropertyMapper/blueMax") as f32), - ], + wall_affects: Affects::from(ctx, "wall"), + persistent, + ephemeral, seed: (pr.f("randomSeed") as u32).wrapping_mul(0x9e37_79b9) ^ ctx.seed, bounds: [-lw * 2.0, -lh * 2.0, lw * 3.0, lh * 3.0], }; @@ -284,6 +647,7 @@ fn particle_playground(ctx: &EffectCtx, mut b: Buf) -> Buf { let (gw, gh) = (pr.f("grid/gridWidth"), pr.f("grid/gridHeight")); let gcol = pr.color("grid/gridColor"); let grad = pr.f("grid/gridParticleRadius").max(0.0) as f32; + let grid_text: Vec = pr.s("options/gridText").chars().collect(); let exploder = if pr.b("exploderEnabled") { let host = ctx.env.host; let lid = pr.get("layerExploder/explodeLayer").and_then(Value::as_layer); @@ -296,11 +660,15 @@ fn particle_playground(ctx: &EffectCtx, mut b: Buf) -> Buf { }; let ex_r = pr.f("layerExploder/radiusOfNewParticles").max(0.5) as f32; let ex_disp = pr.f("layerExploder/velocityDispersion") as f32; - let layer_map = pr.b("layerMapEnabled").then(|| ctx.layer_param("layerMap/layerMapLayer", true).map(|lp| MapImg::from(ctx, lp))).flatten(); let mut key = params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 0x7067); - if let Some(m) = &pg.mapper { - key ^= image_key(&m.img).rotate_left(7); + for m in [&pg.persistent, &pg.ephemeral].into_iter().flatten() { + key ^= image_key(&m.map.img).rotate_left(7); + } + for a in [&pg.pex_affects, &pg.grav_affects, &pg.repel_affects, &pg.wall_affects] { + if let Some(s) = &a.selection { + key ^= image_key(&s.img).rotate_left(11); + } } if let Some(e) = &exploder { key ^= image_key(&e.buf.img).rotate_left(13); @@ -312,14 +680,29 @@ fn particle_playground(ctx: &EffectCtx, mut b: Buf) -> Buf { let init = || { 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; - st.parts.push(PgParticle { p, v: [0.0; 2], origin: p, c: gcol, r: grad, mass: 1.0, friction: 0.0, force: [0.0; 2], id }); + let mut q = PgParticle::new(p, [0.0; 2], gcol, grad, id, SRC_GRID); + q.ch = ch; + st.parts.push(q); } } } @@ -338,7 +721,7 @@ fn particle_playground(ctx: &EffectCtx, mut b: Buf) -> Buf { let (dx, dy) = (x - lw * 0.5, y - lh * 0.5); let l = (dx * dx + dy * dy).sqrt().max(1.0); let v = [dx / l * ex_disp + h(1) * ex_disp, dy / l * ex_disp + h(2) * ex_disp]; - st.parts.push(PgParticle { p: [x, y], v, origin: [x, y], c, r: ex_r, mass: 1.0, friction: 0.0, force: [0.0; 2], id }); + st.parts.push(PgParticle::new([x, y], v, c, ex_r, id, SRC_LAYER_EXPLODER)); } x += step; } @@ -348,23 +731,81 @@ fn particle_playground(ctx: &EffectCtx, mut b: Buf) -> Buf { st }; let st = PG_CACHE.run(key, steps_at(ctx.time), init, |st, _| pg.step(st)); + // Render: what the ephemeral mapper changes shows for this frame only. + let parts: Vec = st.parts.iter().map(|q| pg.ephemeral(q)).collect(); + let auto_orient = pr.b("options/autoOrientRotation"); let s = b.scale as f32; - let sprites: Vec = st - .parts - .iter() - .map(|q| { - let (x, y) = b.to_px([q.p[0] as f64, q.p[1] as f64]); - let c = match &layer_map { - Some(m) => { - let mc = m.at(q.origin[0], q.origin[1]); - [mc[0], mc[1], mc[2], mc[3] * q.c[3]] + // Layer Map: affected particles show the map layer (at a time set by the offset type). + let map_id = if pr.b("layerMapEnabled") { pr.get("layerMap/layerMapLayer").and_then(Value::as_layer) } else { None }; + let map_affects = Affects::from(ctx, "layerMap"); + let offset_type = pr.e("layerMap/timeOffsetType"); + let offset = pr.f("layerMap/timeOffset"); + let fps = ctx.fps(); + let mut frames: std::collections::HashMap> = std::collections::HashMap::new(); + let mut sprites: Vec = vec![]; + let mut blits: Vec<(PgParticle, i64)> = vec![]; + for q in &parts { + let (x, y) = b.to_px([q.p[0] as f64, q.p[1] as f64]); + let angle = if auto_orient && (q.v[0] != 0.0 || q.v[1] != 0.0) { q.v[1].atan2(q.v[0]).to_degrees() + 90.0 } else { q.angle }; + if let (Some(_), true) = (map_id, map_affects.weight(q) > 0.5) { + let rnd = hash1(q.id, 41, seed) as f64; + let t = match offset_type { + 1 => offset, + 2 => ctx.env.comp_time + offset * rnd, + 3 => offset * rnd, + _ => ctx.env.comp_time + offset, + } + q.toff as f64; + let frame = (t * fps).round() as i64; + blits.push((PgParticle { angle, ..*q }, frame)); + continue; + } + let sc = (q.scale[0] + q.scale[1]) * 0.5; + match char::from_u32(q.ch).filter(|c| q.ch != 0 && *c != ' ') { + Some(c) => { + // A text particle: the character's strokes, Font Size tall, rotated. + let size = (q.r * sc * s) as f64; + let u = size * 0.7 / 6.0; + let wgt = (size * 0.045).max(0.35) as f32; + let (sa, ca) = (angle as f64).to_radians().sin_cos(); + let tr = |g: [f64; 2]| { + let (gx, gy) = ((g[0] - 2.0) * u * q.scale[0] as f64 / sc.max(1e-6) as f64, (g[1] - 3.0) * u * q.scale[1] as f64 / sc.max(1e-6) as f64); + [x + gx * ca - gy * sa, y + gx * sa + gy * ca] + }; + for stroke in crate::textfx::glyph_strokes(c) { + if stroke.len() == 1 { + let p0 = tr(stroke[0]); + sprites.push(Sprite::new(p0[0] as f32, p0[1] as f32, wgt, q.c, Shape::Disc)); + } + for w2 in stroke.windows(2) { + let (a0, a1) = (tr(w2[0]), tr(w2[1])); + sprites.push(Sprite::new(a0[0] as f32, a0[1] as f32, wgt, q.c, Shape::Line { dx: (a1[0] - a0[0]) as f32, dy: (a1[1] - a0[1]) as f32 })); + } } - None => q.c, - }; - Sprite::new(x as f32, y as f32, q.r * s, c, Shape::Disc) - }) - .collect(); - let fx = splat(b.img.width, b.img.height, &sprites, Acc::Over); + } + None => sprites.push(Sprite::new(x as f32, y as f32, q.r * sc * s, q.c, Shape::Disc)), + } + } + let mut fx = splat(b.img.width, b.img.height, &sprites, Acc::Over); + if let (Some(id), Some(host)) = (map_id, ctx.env.host) { + let me = pr.get(&crate::layer_source_id("layerMap/layerMapLayer")).is_none_or(|v| v.as_enum() != 0); + for (q, frame) in &blits { + if !frames.contains_key(frame) && frames.len() >= 32 { + // Too many distinct times: reuse the nearest fetched frame. + if let Some(k) = frames.keys().min_by_key(|k| (*k - frame).abs()).copied() + && let Some(Some(lp)) = frames.get(&k) + { + let (x, y) = b.to_px([q.p[0] as f64, q.p[1] as f64]); + blit_layer(&mut fx, lp, x, y, q.angle as f64, [q.scale[0] as f64, q.scale[1] as f64], b.scale, q.c[3]); + } + continue; + } + let lp = frames.entry(*frame).or_insert_with(|| host.layer_at(id, *frame as f64 / fps, me)); + if let Some(lp) = lp { + let (x, y) = b.to_px([q.p[0] as f64, q.p[1] as f64]); + blit_layer(&mut fx, lp, x, y, q.angle as f64, [q.scale[0] as f64, q.scale[1] as f64], b.scale, q.c[3]); + } + } + } // Particles replace the layer's own pixels. b.img = combine(&b.img, &fx, 5); b @@ -473,7 +914,23 @@ pub fn specs() -> Vec { let pt = |x: f64, y: f64| Value::Vec2([x, y]); let px = |d: f64, max: f64| (num(d), slider(0.0, max * 10.0, 0.0, max, 1)); let sp = |id: &'static str, name: &'static str, v: (Value, ParamUi)| p(id, name, v.0, v.1); + let leak = |s: String| -> &'static str { Box::leak(s.into_boxed_str()) }; + // An Affects twirl-down inside `group`. + let affects = |group: &str| -> Vec { + let id = |s: &str| leak(format!("{group}/affects/{s}")); + vec![ + p(id("particlesFrom"), "Particles From", Value::Enum(0), popup(&PARTICLES_FROM)), + p(id("selectionMap"), "Selection Map", Value::Layer(None), ParamUi::Layer), + p(id("characters"), "Characters", Value::Str(String::new()), ParamUi::Text), + p(id("olderYoungerThan"), "Older/Younger than", num(0.0), slider(-1000.0, 1000.0, -10.0, 10.0, 2)), + p(id("ageFeather"), "Age Feather", num(0.0), slider(0.0, 1000.0, 0.0, 10.0, 2)), + ] + }; let mut pg = vec![ + // Options (the Options dialog): text fired by the cannon / placed on the grid. + p("options/cannonText", "Cannon Text", Value::Str(String::new()), ParamUi::Text), + p("options/gridText", "Grid Text", Value::Str(String::new()), ParamUi::Text), + p("options/autoOrientRotation", "Auto-Orient Rotation", Value::Bool(false), ParamUi::Checkbox), // Generator switches (kept for saved projects; After Effects turns a generator off with // its rate / counts instead). p("cannonEnabled", "Cannon Enabled", Value::Bool(true), ParamUi::Hidden), @@ -503,20 +960,40 @@ pub fn specs() -> Vec { p("layerExploder/explodeLayer", "Explode Layer", Value::Layer(None), ParamUi::Layer), sp("layerExploder/radiusOfNewParticles", "Radius of New Particles", px(2.0, 50.0)), sp("layerExploder/velocityDispersion", "Velocity Dispersion", px(20.0, 500.0)), - // Layer Map + ]; + pg.extend([ + sp("particleExploder/radiusOfNewParticles", "Radius of New Particles", px(0.0, 50.0)), + sp("particleExploder/velocityDispersion", "Velocity Dispersion", px(20.0, 500.0)), + ]); + pg.extend(affects("particleExploder")); + pg.extend([ p("layerMap/layerMapLayer", "Use Layer", Value::Layer(None), ParamUi::Layer), + p("layerMap/timeOffsetType", "Time Offset Type", Value::Enum(0), popup(&TIME_OFFSET_TYPES)), + p("layerMap/timeOffset", "Time Offset", num(0.0), slider(-1000.0, 1000.0, -10.0, 10.0, 2)), + ]); + pg.extend(affects("layerMap")); + pg.extend([ // Gravity sp("gravity/gravityForce", "Force", px(108.0, 1000.0)), sp("gravity/gravityForceRandomSpread", "Force Random Spread", px(0.0, 100.0)), p("gravity/gravityDirection", "Direction", num(180.0), ParamUi::Angle), + ]); + pg.extend(affects("gravity")); + pg.extend([ // Repel p("repel/repelForce", "Force", num(0.0), slider(-100.0, 100.0, -10.0, 10.0, 2)), sp("repel/repelForceRadius", "Force Radius", px(0.0, 100.0)), + ]); + pg.extend(affects("repel")); + pg.extend([ // Wall p("wall/wallBoundary", "Boundary", num(0.0), ParamUi::Mask), + ]); + pg.extend(affects("wall")); + pg.extend([ // Persistent Property Mapper p("persistentPropertyMapper/useLayerAsMap", "Use Layer As Map", Value::Layer(None), ParamUi::Layer), - ]; + ]); for (to, mn, mx, n_to) in [ ("persistentPropertyMapper/mapRedTo", "persistentPropertyMapper/redMin", "persistentPropertyMapper/redMax", "Map Red To"), ("persistentPropertyMapper/mapGreenTo", "persistentPropertyMapper/greenMin", "persistentPropertyMapper/greenMax", "Map Green To"), @@ -526,6 +1003,18 @@ pub fn specs() -> Vec { pg.push(p(mn, "Min", num(0.0), slider(-10000.0, 10000.0, -100.0, 100.0, 2))); pg.push(p(mx, "Max", num(1.0), slider(-10000.0, 10000.0, -100.0, 100.0, 2))); } + pg.extend(affects("persistentPropertyMapper")); + // Ephemeral Property Mapper: like the persistent one, with an operator per channel; the + // values hold for one frame. + pg.push(p("ephemeralPropertyMapper/useLayerAsMap", "Use Layer As Map", Value::Layer(None), ParamUi::Layer)); + for c in ["Red", "Green", "Blue"] { + let l = c.to_lowercase(); + pg.push(p(leak(format!("ephemeralPropertyMapper/map{c}To")), leak(format!("Map {c} To")), Value::Enum(0), popup(&MAP_TARGETS))); + pg.push(p(leak(format!("ephemeralPropertyMapper/{l}Operator")), "Operator", Value::Enum(0), popup(&MAP_OPERATORS))); + pg.push(p(leak(format!("ephemeralPropertyMapper/{l}Min")), "Min", num(0.0), slider(-10000.0, 10000.0, -100.0, 100.0, 2))); + pg.push(p(leak(format!("ephemeralPropertyMapper/{l}Max")), "Max", num(1.0), slider(-10000.0, 10000.0, -100.0, 100.0, 2))); + } + pg.extend(affects("ephemeralPropertyMapper")); pg.push(p("randomSeed", "Random Seed", num(0.0), slider(0.0, 10000.0, 0.0, 1000.0, 0))); let pc = |d: f64| (num(d), slider(0.0, 100.0, 0.0, 100.0, 1)); vec![ @@ -691,7 +1180,19 @@ mod tests { 0.7, ); assert_eq!(a.data, b.data); - let st = Pg { + let st = + Pg { persistent: Some(mapper(Image::filled(4, 4, [1.0, 0.0, 0.5, 1.0]), [(15, 0, 0.0, 120.0), (0, 0, 0.0, 1.0), (9, 0, 0.0, 2.0)])), ..bare(1) }; + let mut s = PgState::default(); + s.parts.push(PgParticle::new([1.0, 1.0], [0.0; 2], [1.0; 4], 2.0, 0, SRC_GRID)); + st.step(&mut s); + // Red 1 → X speed 120 px/s; blue 0.5 → scale 1. + assert!((s.parts[0].v[0] - 120.0).abs() < 1e-3); + assert!((s.parts[0].scale[0] - 1.0).abs() < 1e-3); + } + + /// A Pg with nothing switched on. + fn bare(seed: u32) -> Pg { + Pg { cannon: false, pos: [0.0; 2], barrel_angle: 0.0, @@ -702,47 +1203,32 @@ mod tests { vel_spread: 0.0, color: [1.0; 4], radius: 2.0, + cannon_text: vec![], + pex_radius: 0.0, + pex_dispersion: 0.0, + pex_affects: Affects::default(), gravity: [0.0; 2], grav_spread: 0.0, + grav_affects: Affects::default(), repel: 0.0, repel_radius: 0.0, + repel_affects: Affects::default(), wall: None, - mapper: Some(MapImg { img: Image::filled(4, 4, [1.0, 0.0, 0.5, 1.0]), k: [1.0; 2], o: [0.0; 2] }), - map_to: [(8, 0.0, 120.0), (0, 0.0, 1.0), (5, 0.0, 2.0)], - seed: 1, + wall_affects: Affects::default(), + persistent: None, + ephemeral: None, + seed, bounds: [-1e6, -1e6, 1e6, 1e6], - }; - let mut s = PgState::default(); - s.parts.push(PgParticle { p: [1.0, 1.0], v: [0.0; 2], origin: [1.0, 1.0], c: [1.0; 4], r: 2.0, mass: 1.0, friction: 0.0, force: [0.0; 2], id: 0 }); - st.step(&mut s); - // Red 1 → X velocity 120 px/s; blue 0.5 → scale 1 (radius 2). - assert!((s.parts[0].v[0] - 120.0).abs() < 1e-3); - assert!((s.parts[0].r - 2.0).abs() < 1e-3); + } + } + + fn mapper(img: Image, chans: [(u32, u32, f32, f32); 3]) -> Mapper { + Mapper { map: MapImg { img, k: [1.0; 2], o: [0.0; 2] }, chans, affects: Affects::default() } } #[test] fn barrel_radius_square_or_disc() { - let pg = |r: f32| Pg { - cannon: true, - pos: [0.0; 2], - barrel_angle: 0.0, - barrel_radius: r, - rate: 0.0, - dir_spread: 0.0, - vel: 0.0, - vel_spread: 0.0, - color: [1.0; 4], - radius: 2.0, - gravity: [0.0; 2], - grav_spread: 0.0, - repel: 0.0, - repel_radius: 0.0, - wall: None, - mapper: None, - map_to: [(0, 0.0, 1.0); 3], - seed: 3, - bounds: [-1e6, -1e6, 1e6, 1e6], - }; + let pg = |r: f32| Pg { cannon: true, barrel_radius: r, ..bare(3) }; let (sq, disc) = (pg(10.0), pg(-10.0)); let mut corner = false; for id in 0..400 { @@ -760,37 +1246,170 @@ mod tests { #[test] fn mapper_drives_position_and_opacity_in_ae_order() { - let st = Pg { - cannon: false, - pos: [0.0; 2], - barrel_angle: 0.0, - barrel_radius: 0.0, - rate: 0.0, - dir_spread: 0.0, - vel: 0.0, - vel_spread: 0.0, - color: [1.0; 4], - radius: 2.0, - gravity: [0.0; 2], - grav_spread: 0.0, - repel: 0.0, - repel_radius: 0.0, - wall: None, - mapper: Some(MapImg { img: Image::filled(4, 4, [1.0, 0.5, 0.25, 1.0]), k: [1.0; 2], o: [0.0; 2] }), - map_to: [(6, 0.0, 30.0), (12, 0.0, 1.0), (4, 0.0, 1.0)], - seed: 1, - bounds: [-1e6, -1e6, 1e6, 1e6], - }; - assert_eq!(MAP_TARGETS[6], "X"); - assert_eq!(MAP_TARGETS[12], "Opacity"); + let st = + Pg { persistent: Some(mapper(Image::filled(4, 4, [1.0, 0.5, 0.25, 1.0]), [(12, 0, 0.0, 30.0), (20, 0, 0.0, 1.0), (4, 0, 0.0, 1.0)])), ..bare(1) }; + assert_eq!(MAP_TARGETS[12], "X"); + assert_eq!(MAP_TARGETS[20], "Opacity"); + assert_eq!(MAP_TARGETS[26], "Scale Speed"); let mut s = PgState::default(); - s.parts.push(PgParticle { p: [1.0, 1.0], v: [0.0; 2], origin: [1.0, 1.0], c: [1.0; 4], r: 2.0, mass: 1.0, friction: 0.0, force: [0.0; 2], id: 0 }); + s.parts.push(PgParticle::new([1.0, 1.0], [0.0; 2], [1.0; 4], 2.0, 0, SRC_GRID)); st.step(&mut s); assert!((s.parts[0].p[0] - 30.0).abs() < 1e-3, "{:?}", s.parts[0].p); assert!((s.parts[0].c[3] - 0.5).abs() < 1e-3); assert!((s.parts[0].friction - 0.25).abs() < 1e-3); } + #[test] + fn ephemeral_mapper_holds_for_one_step_with_operators() { + // Ephemeral X Speed = 60 px/s (Set): the particle moves, but keeps no velocity. + let st = Pg { ephemeral: Some(mapper(Image::filled(4, 4, [1.0, 1.0, 1.0, 1.0]), [(15, 0, 0.0, 60.0), (0, 0, 0.0, 1.0), (0, 0, 0.0, 1.0)])), ..bare(1) }; + let mut s = PgState::default(); + s.parts.push(PgParticle::new([1.0, 1.0], [0.0; 2], [1.0; 4], 2.0, 0, SRC_GRID)); + st.step(&mut s); + assert!((s.parts[0].p[0] - 2.0).abs() < 1e-4, "{:?}", s.parts[0].p); + assert!(s.parts[0].v[0].abs() < 1e-4); + // Operators: Add / Multiply / Max on the particle's own value. + let op = |o: u32, cur: f32| { + let m = mapper(Image::filled(4, 4, [1.0, 1.0, 1.0, 1.0]), [(21, o, 0.0, 3.0), (0, 0, 0.0, 1.0), (0, 0, 0.0, 1.0)]); + let mut q = PgParticle::new([1.0, 1.0], [0.0; 2], [1.0; 4], 2.0, 0, SRC_GRID); + q.mass = cur; + m.apply(&mut q); + q.mass + }; + assert_eq!(MAP_OPERATORS[1], "Add"); + assert!((op(1, 2.0) - 5.0).abs() < 1e-5); + assert!((op(4, 2.0) - 6.0).abs() < 1e-5); + assert!((op(6, 5.0) - 5.0).abs() < 1e-5); + assert!((op(2, 5.0) - 2.0).abs() < 1e-5, "Difference"); + } + + #[test] + fn affects_select_by_source_age_and_characters() { + let mut q = PgParticle::new([0.0; 2], [0.0; 2], [1.0; 4], 2.0, 0, SRC_CANNON); + let a = Affects { from: 2, ..Default::default() }; + assert_eq!(a.weight(&q), 0.0, "grid only"); + q.src = SRC_GRID; + assert_eq!(a.weight(&q), 1.0); + let older = Affects { age: 1.0, ..Default::default() }; + q.age = 0.5; + assert_eq!(older.weight(&q), 0.0); + q.age = 1.5; + assert_eq!(older.weight(&q), 1.0); + let younger = Affects { age: -1.0, feather: 1.0, ..Default::default() }; + q.age = 1.0; + assert!((younger.weight(&q) - 0.5).abs() < 1e-5, "feathered"); + let chars = Affects { chars: vec!['A', 'B'], ..Default::default() }; + q.ch = 'B' as u32; + assert_eq!(chars.weight(&q), 1.0); + q.ch = 'C' as u32; + assert_eq!(chars.weight(&q), 0.0); + // Gravity restricted to the cannon leaves grid particles in place. + let st = Pg { gravity: [0.0, 100.0], grav_affects: Affects { from: 1, ..Default::default() }, ..bare(1) }; + let mut s = PgState::default(); + s.parts.push(PgParticle::new([5.0, 5.0], [0.0; 2], [1.0; 4], 2.0, 0, SRC_GRID)); + s.parts.push(PgParticle::new([5.0, 5.0], [0.0; 2], [1.0; 4], 2.0, 1, SRC_CANNON)); + for _ in 0..10 { + st.step(&mut s); + } + assert_eq!(s.parts[0].p, [5.0, 5.0]); + assert!(s.parts[1].p[1] > 5.0); + } + + #[test] + fn particle_exploder_bursts_big_particles() { + let st = Pg { pex_radius: 2.0, pex_dispersion: 30.0, ..bare(1) }; + let mut s = PgState::default(); + s.parts.push(PgParticle::new([50.0, 50.0], [0.0; 2], [1.0; 4], 6.0, 0, SRC_GRID)); + s.next_id = 1; + st.step(&mut s); + assert_eq!(s.parts.len(), 9, "(6/2)² new particles"); + assert!(s.parts.iter().all(|q| q.r == 2.0 && q.src == SRC_PARTICLE_EXPLODER)); + st.step(&mut s); + assert_eq!(s.parts.len(), 9, "new particles do not burst again"); + // Lifespan from a mapper removes particles once they are older. + let life = Pg { persistent: Some(mapper(Image::filled(4, 4, [1.0; 4]), [(22, 0, 0.0, 0.05), (0, 0, 0.0, 1.0), (0, 0, 0.0, 1.0)])), ..bare(1) }; + let mut s = PgState::default(); + s.parts.push(PgParticle::new([1.0, 1.0], [0.0; 2], [1.0; 4], 2.0, 0, SRC_GRID)); + for _ in 0..2 { + life.step(&mut s); + } + assert_eq!(s.parts.len(), 1); + for _ in 0..3 { + life.step(&mut s); + } + assert!(s.parts.is_empty()); + } + + #[test] + fn text_particles_draw_characters() { + let grid = |text: &str| { + run( + &[ + ("cannonEnabled", Value::Bool(false)), + ("grid/gridPosition", Value::Vec2([40.0, 30.0])), + ("grid/gridWidth", num(40.0)), + ("grid/gridHeight", num(0.0)), + ("grid/particlesAcross", num(3.0)), + ("grid/particlesDown", num(1.0)), + ("grid/gridParticleRadius", num(16.0)), + ("gravity/gravityForce", num(0.0)), + ("options/gridText", Value::Str(text.into())), + ], + 0.1, + ) + }; + let dots = grid(""); + let text = grid("I-O"); + assert_ne!(dots.data, text.data); + // "I" is a vertical bar: ink above and below the grid point, not a round dot. + assert!(text.get(20, 26)[3] > 0.3 && text.get(20, 34)[3] > 0.3, "{:?}", text.get(20, 26)); + // A space leaves its grid point empty. + let gap = grid("I O"); + assert!(gap.get(40, 30)[3] < 0.05); + // Cannon text: particles carry the characters in order. + let pg = Pg { cannon_text: vec!['A', 'B'], ..bare(1) }; + assert_eq!(pg.spawn(0).ch, 'A' as u32); + assert_eq!(pg.spawn(3).ch, 'B' as u32); + } + + #[test] + fn layer_map_shows_the_map_layer_at_its_time_offset() { + /// A 6×6 map layer whose colour encodes the comp time it was fetched at. + struct Clock; + impl crate::EffectHost for Clock { + fn layer(&self, _: u64, _: bool) -> Option { + None + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + fn layer_at(&self, _: u64, t: f64, _: bool) -> Option { + let v = (t as f32 / 4.0).clamp(0.0, 1.0); + Some(crate::LayerPixels { buf: Buf { img: Image::filled(6, 6, [v, 0.0, 1.0 - v, 1.0]), offset: [0.0; 2], scale: 1.0 }, size: [6.0, 6.0] }) + } + } + let render = |kind: u32, offset: f64| { + let vals = [ + ("cannonEnabled", Value::Bool(false)), + ("grid/gridPosition", Value::Vec2([40.0, 30.0])), + ("grid/particlesAcross", num(1.0)), + ("grid/particlesDown", num(1.0)), + ("gravity/gravityForce", num(0.0)), + ("layerMap/layerMapLayer", Value::Layer(Some(9))), + ("layerMap/timeOffsetType", Value::Enum(kind)), + ("layerMap/timeOffset", num(offset)), + ]; + let env = EffectEnv { host: Some(&Clock), comp_time: 1.0, frame_rate: 10.0, ..Default::default() }; + run_fx("ec.sim.particleplayground", &vals, Image::filled(80, 60, [0.2, 0.2, 0.2, 1.0]), 1.0, env).img + }; + // The particle is a 6×6 copy of the map layer. + let rel = render(0, 1.0); + assert!(rel.get(40, 30)[3] > 0.99 && rel.get(46, 30)[3] < 0.01); + assert!((rel.get(40, 30)[0] - 0.5).abs() < 1e-3, "relative: now + 1 s = 2 s"); + let abs = render(1, 3.0); + assert!((abs.get(40, 30)[0] - 0.75).abs() < 1e-3, "absolute: 3 s"); + } + #[test] fn cc_hair_grows_from_opaque_pixels() { let mut img = Image::new(60, 60); diff --git a/crates/effects/src/textfx.rs b/crates/effects/src/textfx.rs index ade9b1ce..4d938faf 100644 --- a/crates/effects/src/textfx.rs +++ b/crates/effects/src/textfx.rs @@ -94,6 +94,11 @@ fn glyph_src(c: char) -> &'static str { } } +/// A glyph's strokes on the 4 × 6 grid (Particle Playground's text particles). +pub(crate) fn glyph_strokes(c: char) -> Vec> { + parse_glyph(c) +} + fn parse_glyph(c: char) -> Vec> { glyph_src(c) .split(';') diff --git a/docs/effects.md b/docs/effects.md index 18c23b28..840b8e5b 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -3,11 +3,11 @@ -EffectCraft ships 303 effects with 2647 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over. +EffectCraft ships 303 effects with 2702 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over. - **GPU**: 24 effects also run on the GPU compositor with identical results. - **32**: 303 effects process 32-bit float (HDR, overbright) pixels without clamping. -- **Status**: 269 implemented in full, 34 partial (what is missing is listed). +- **Status**: 270 implemented in full, 33 partial (what is missing is listed). Effects are addressed by id (`ec..`) or display name in commands, scripts and the control channel, and every parameter by its id (`effects/#1/blurriness`). @@ -331,7 +331,7 @@ Effects are addressed by id (`ec..`) or display name in commands | Card Dance | `ec.sim.carddance` | 53 | | 32 | Implemented | | Caustics | `ec.sim.caustics` | 21 | | 32 | Partial: no Sky group or light type | | Foam | `ec.sim.foam` | 24 | | 32 | Partial: no wobble, pop velocity, custom bubble texture, environment map or flow map | -| Particle Playground | `ec.sim.particleplayground` | 37 | | 32 | Partial: no Particle Exploder, Ephemeral Property Mapper, Affects groups or text particles | +| Particle Playground | `ec.sim.particleplayground` | 92 | | 32 | Implemented | | Shatter | `ec.sim.shatter` | 60 | | 32 | Implemented | | Wave World | `ec.sim.waveworld` | 27 | | 32 | Partial: no wireframe controls, dry-area rendering or ground group |