Files
effectcraft/crates/effects/src/sim3.rs
T
Brandon ThomasandClaude Opus 5.5 1c855c3e07 M13.29: GPU effects part D (particles and remaining fallbacks)
GPU render passes for CC Particle World / Systems II, Particle Playground,
CC Ball Action, CC Pixel Polly, CC Scatterize and Curl Noise (fx_particles,
chunked multi-pass raster); Shatter wireframe views, Foam extras, Timewarp
with a Matte Layer, strong Warp Fisheye/Twist (CPU f64 inverse map) and
custom .ocio configs no longer fall back to the CPU. CPU refactors pinned by
particle_golden / timewarp_golden / sim_golden hashes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 01:35:56 -04:00

1555 lines
67 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Simulation: Particle Playground and CC Hair.
//!
//! **Particle Playground** is a deterministic 2D particle system stepped from layer time 0 at
//! [`SPS`] steps per second through [`SimCache`] (seeking anywhere gives the same frame as
//! playing up to it). Producers: **Cannon** (a stream from a barrel with angle, radius, rate,
//! velocity and random spreads), **Grid** (particles placed on a grid at time 0) and **Layer
//! Exploder** (another layer broken into particles at time 0). Forces: **Gravity** (force,
//! 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) 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
//! map layer (luminance → length, horizontal/vertical gradient → lean), and shades them with a
//! Kajiya–Kay-style strand model (diffuse ∝ sin(tangent, light), specular from the half vector).
use effectcraft_keyframe::Value;
use effectcraft_project::ParamUi;
use effectcraft_raster::{Image, Px};
use rayon::prelude::*;
use crate::sim::{Acc, Post, SPS, Shape, Sprite, SpritePlan, Tint, combine, splat, steps_at};
use crate::util::{SimCache, hash1, params_key, point_in_poly, unpremul};
use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider};
fn spec(id: &'static str, name: &'static str, params: Vec<crate::ParamSpec>, render: crate::RenderFn) -> EffectSpec {
EffectSpec { id, name, category: "Simulation", params, render, gpu: false, float: true }
}
// ---------------------------------------------------------------- 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.
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<PgParticle>,
carry: f64,
next_id: u32,
}
/// A map layer sampled in layer coordinates.
struct MapImg {
img: Image,
/// Layer point → pixel: (x · k₀ + o₀, y · k₁ + o₁).
k: [f64; 2],
o: [f64; 2],
}
impl MapImg {
fn from(ctx: &EffectCtx, lp: crate::LayerPixels) -> MapImg {
// Fit the map layer to this layer (stretch), like AE's layer maps.
let (ls, os) = (ctx.layer_size, lp.size);
let sx = if ls[0] > 0.0 { os[0] / ls[0] } else { 1.0 };
let sy = if ls[1] > 0.0 { os[1] / ls[1] } else { 1.0 };
MapImg { img: lp.buf.img, k: [lp.buf.scale * sx, lp.buf.scale * sy], o: lp.buf.offset }
}
fn at(&self, x: f32, y: f32) -> Px {
let (c, a) = unpremul(self.img.sample_bilinear_clamped(x as f64 * self.k[0] + self.o[0], y as f64 * self.k[1] + self.o[1]));
[c[0], c[1], c[2], a]
}
}
/// 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<MapImg>,
chars: Vec<char>,
/// 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
cannon: bool,
pos: [f32; 2],
barrel_angle: f32,
barrel_radius: f32,
rate: f64,
dir_spread: f32,
vel: f32,
vel_spread: f32,
color: [f32; 4],
radius: f32,
/// Edit Cannon Text: characters fired in order (empty = dots).
cannon_text: Vec<char>,
// 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<Vec<[f64; 2]>>,
wall_affects: Affects,
// Property mappers
persistent: Option<Mapper>,
ephemeral: Option<Mapper>,
seed: u32,
bounds: [f32; 4],
}
impl Pg {
fn spawn(&self, id: u32) -> PgParticle {
let s = self.seed;
let h = |k: u32| hash1(id, k, s) * 2.0 - 1.0;
// AE angles: 0° = up, clockwise.
let a = (self.barrel_angle + h(1) * self.dir_spread * 0.5).to_radians();
let d = [a.sin(), -a.cos()];
let speed = (self.vel + h(3) * self.vel_spread).max(0.0);
// Barrel: positive radii are a square around the cannon position, negative ones a disc.
let r = self.barrel_radius;
let off = if r >= 0.0 {
[h(2) * r, h(4) * r]
} else {
let (a, rr) = (hash1(id, 5, s) * std::f32::consts::TAU, hash1(id, 6, s).sqrt() * -r);
[a.cos() * rr, a.sin() * rr]
};
let p = [self.pos[0] + off[0], self.pos[1] + off[1]];
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;
// 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<PgParticle> = 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 && eph.len() > 1 {
let cell = self.repel_radius;
let mut grid: std::collections::HashMap<(i32, i32), Vec<usize>> = std::collections::HashMap::new();
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 = &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 {
let Some(list) = grid.get(&(gx, gy)) else { continue };
for &j in list {
if j == i {
continue;
}
let o = parts[j];
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 = w * self.repel * (1.0 - d / self.repel_radius) / d;
f[0] += dx * k;
f[1] += dy * k;
}
}
}
}
});
}
let wall = self.wall.as_deref();
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 * 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 {
// Bounce: reverse the velocity and stay on the same side.
q.v = [-q.v[0] * 0.9, -q.v[1] * 0.9];
return;
}
}
q.p = np;
});
let b = self.bounds;
let eph_life: Vec<f32> = 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;
st.carry -= n as f64;
for _ in 0..n {
if st.parts.len() >= 60_000 {
break;
}
let id = st.next_id;
st.next_id = st.next_id.wrapping_add(1);
let mut q = self.spawn(id);
// Sub-step birth offset keeps streams smooth.
let frac = hash1(id, 9, s) * dt;
q.p = [q.p[0] + q.v[0] * frac, q.p[1] + q.v[1] * frac];
st.parts.push(q);
}
}
}
}
static PG_CACHE: SimCache<PgState> = SimCache::new(6);
fn image_key(img: &Image) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
img.width.hash(&mut h);
img.height.hash(&mut h);
for p in img.data.iter().step_by(7) {
for c in p {
c.to_bits().hash(&mut h);
}
}
h.finish()
}
fn mapper_from(ctx: &EffectCtx, group: &str, ephemeral: bool) -> Option<Mapper> {
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]);
}
}
}
/// Particle Playground's producers and forces.
fn pg_of(ctx: &EffectCtx) -> Pg {
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 persistent = if pr.b("mapperEnabled") { mapper_from(ctx, "persistentPropertyMapper", false) } else { None };
let ephemeral = mapper_from(ctx, "ephemeralPropertyMapper", true);
Pg {
cannon: pr.b("cannonEnabled"),
pos: { pr.v2("cannon/cannonPosition").map(|v| v as f32) },
barrel_angle: pr.f("cannon/barrelAngle") as f32,
barrel_radius: pr.f("cannon/barrelRadius") as f32,
rate: pr.f("cannon/particlesPerSecond").max(0.0),
dir_spread: pr.f("cannon/directionRandomSpread") as f32,
vel: pr.f("cannon/velocity") as f32,
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,
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],
}
}
impl Pg {
/// Only a cannon with gravity: every particle moves on its own (what the host's particle
/// backend can simulate).
fn cannon_only(&self) -> bool {
self.cannon
&& !(self.repel != 0.0 && self.repel_radius > 0.0)
&& self.wall.is_none()
&& self.persistent.is_none()
&& self.ephemeral.is_none()
&& self.pex_radius <= 0.0
&& self.grav_affects.is_all()
}
}
impl Affects {
/// Affects everything (the defaults).
fn is_all(&self) -> bool {
self.from == 0 && self.selection.is_none() && self.chars.is_empty() && self.age == 0.0
}
}
/// Particle Playground cannons with gravity only, simulated by the host's particle backend.
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 desc = crate::psim::CannonDesc {
pos: pg.pos,
barrel_angle: pg.barrel_angle,
barrel_radius: pg.barrel_radius,
dir_spread: pg.dir_spread,
vel: pg.vel,
vel_spread: pg.vel_spread,
gravity: pg.gravity,
grav_spread: pg.grav_spread,
seed: pg.seed,
bounds: pg.bounds,
};
let req = crate::psim::SimRequest { key, steps, births: &births, system: crate::psim::ParticleSystem::Cannon(desc) };
let parts = backend.simulate(&req)?;
Some(std::sync::Arc::new(PgState {
parts: parts
.into_iter()
.map(|q| {
let born = pg.spawn(q.id);
PgParticle { p: [q.p[0], q.p[1]], v: [q.v[0], q.v[1]], ..born }
})
.collect(),
carry: 0.0,
next_id: births.len() as u32,
}))
}
/// The live particles of a Particle Playground whose cannon is the only producer and force
/// besides gravity, at `ctx.time`: from the host's particle backend when it has one (GPU
/// particles), else from the CPU simulation (`None` for other set-ups). For comparing the two.
pub fn playground_state(ctx: &EffectCtx) -> Option<Vec<crate::psim::SimParticle>> {
let pg = pg_of(ctx);
let pr = ctx.params;
let grid = pr.b("gridEnabled") && pr.f("grid/particlesAcross").round() > 0.0 && pr.f("grid/particlesDown").round() > 0.0;
let exploder = pr.b("exploderEnabled") && pr.get("layerExploder/explodeLayer").and_then(Value::as_layer).is_some();
if !pg.cannon_only() || grid || exploder {
return None;
}
let key = params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 0x7067);
let st = pg_accelerated(ctx, key, &pg).unwrap_or_else(|| PG_CACHE.run(key, steps_at(ctx.time), PgState::default, |st, _| pg.step(st)));
Some(
st.parts
.iter()
.map(|q| crate::psim::SimParticle { p: [q.p[0], q.p[1], 0.0], v: [q.v[0], q.v[1], 0.0], age: 0.0, life: 0.0, rnd: 0.0, id: q.id })
.collect(),
)
}
fn particle_playground(ctx: &EffectCtx, b: Buf) -> Buf {
playground_plan(ctx, &b).finish(b)
}
/// 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;
let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32);
let pg = pg_of(ctx);
// Initial particles: Grid and Layer Exploder.
let grid_on = pr.b("gridEnabled");
let across = pr.f("grid/particlesAcross").max(0.0).round() as u32;
let down = pr.f("grid/particlesDown").max(0.0).round() as u32;
let gpos = pr.v2("grid/gridPosition");
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<char> = 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);
match (host, lid) {
(Some(h), Some(id)) => h.layer_at(id, ctx.env.comp_time - ctx.time, true).or_else(|| h.layer(id, true)),
_ => None,
}
} else {
None
};
let ex_r = pr.f("layerExploder/radiusOfNewParticles").max(0.5) as f32;
let ex_disp = pr.f("layerExploder/velocityDispersion") as f32;
let mut key = params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 0x7067);
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);
}
if let Some(w) = &pg.wall {
key ^= w.iter().fold(0u64, |a, p| a.rotate_left(5) ^ p[0].to_bits() ^ p[1].to_bits().rotate_left(17));
}
let seed = pg.seed;
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;
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 {
let mut x = step * 0.5;
while x < lw {
let c = m.at(x, y);
if c[3] > 0.05 {
let id = st.next_id;
st.next_id += 1;
let h = |k: u32| hash1(id, k, seed) * 2.0 - 1.0;
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::new([x, y], v, c, ex_r, id, SRC_LAYER_EXPLODER));
}
x += step;
}
y += step;
}
}
st
};
// A cannon with gravity only: every particle moves on its own (GPU particles).
let independent = pg.cannon_only() && !(grid_on && across > 0 && down > 0) && exploder.is_none();
let gpu = if independent { pg_accelerated(ctx, key, &pg) } else { None };
let st = gpu.unwrap_or_else(|| 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<PgParticle> = st.parts.iter().map(|q| pg.ephemeral(q)).collect();
let auto_orient = pr.b("options/autoOrientRotation");
let s = b.scale as f32;
// 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 sprites: Vec<Sprite> = 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 => sprites.push(Sprite::new(x as f32, y as f32, q.r * sc * s, q.c, Shape::Disc)),
}
}
let mut plan = PgPlan { sprites: SpritePlan { sprites, tints: vec![], acc: Acc::Over, post: Post::Combine(5) }, blits: vec![], frames: vec![] };
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);
// Fetched frames by frame number: their index in `plan.frames` (`None` = unavailable).
let mut frames: std::collections::HashMap<i64, Option<usize>> = std::collections::HashMap::new();
for (q, frame) in &blits {
let at = if !frames.contains_key(frame) && frames.len() >= 32 {
// Too many distinct times: reuse the nearest fetched frame.
match frames.keys().min_by_key(|k| (*k - frame).abs()).copied() {
Some(k) => frames.get(&k).copied().flatten(),
None => None,
}
} else {
*frames.entry(*frame).or_insert_with(|| {
let lp = host.layer_at(id, *frame as f64 / fps, me)?;
plan.frames.push(lp);
Some(plan.frames.len() - 1)
})
};
if let Some(i) = at {
let (x, y) = b.to_px([q.p[0] as f64, q.p[1] as f64]);
plan.blits.push(PgBlit { frame: i, x, y, angle: q.angle as f64, scale: [q.scale[0] as f64, q.scale[1] as f64], alpha: q.c[3] });
}
}
}
plan
}
/// A Layer Map particle: frame `frame` of [`PgPlan::frames`] centred on (`x`, `y`) buffer px,
/// rotated `angle` degrees, scaled and faded to `alpha`.
#[derive(Clone, Copy, Debug)]
pub struct PgBlit {
pub frame: usize,
pub x: f64,
pub y: f64,
pub angle: f64,
pub scale: [f64; 2],
pub alpha: f32,
}
/// What Particle Playground draws this frame: the dot and text particles as sprites, then the
/// Layer Map particles over them in order; the result replaces the layer's own pixels.
pub struct PgPlan {
pub sprites: SpritePlan,
pub blits: Vec<PgBlit>,
pub frames: Vec<crate::LayerPixels>,
}
impl PgPlan {
pub fn finish(&self, mut b: Buf) -> Buf {
let mut fx = splat(b.img.width, b.img.height, &self.sprites.sprites, self.sprites.acc);
for q in &self.blits {
blit_layer(&mut fx, &self.frames[q.frame], q.x, q.y, q.angle, q.scale, b.scale, q.alpha);
}
// Particles replace the layer's own pixels.
b.img = combine(&b.img, &fx, 5);
b
}
}
// ---------------------------------------------------------------- CC Hair
fn cc_hair(ctx: &EffectCtx, b: Buf) -> Buf {
let Some(mut plan) = hair_plan(ctx, &b) else { return b };
plan.resolve(&b.img);
plan.finish(b)
}
/// CC Hair's strands as line sprites (each coloured from its root, see [`Tint::Root`]);
/// `None` = no hair (the layer passes through).
pub(crate) fn hair_plan(ctx: &EffectCtx, b: &Buf) -> Option<SpritePlan> {
let pr = ctx.params;
let len = pr.f("length").max(0.0) as f32;
let density = pr.f("density").max(0.0) as f32 / 100.0;
if len <= 0.0 || density <= 0.0 {
return None;
}
let thick = pr.f("thickness").max(0.05) as f32;
let weight = pr.f("weight") as f32;
let const_mass = pr.b("constantMass");
let map = ctx.layer_param("hairfallMap/mapLayer", true).map(|lp| MapImg::from(ctx, lp));
let map_strength = (pr.f("hairfallMap/mapStrength") / 100.0) as f32;
let map_soft = pr.f("hairfallMap/mapSoftness").max(0.0) as f32;
let noise = (pr.f("hairfallMap/addNoise") / 100.0) as f32;
let hc = pr.color("hairColor/hairColor");
let bright = (pr.f("hairColor/brightness") / 100.0) as f32;
let opacity = (pr.f("hairColor/opacity") / 100.0) as f32;
let inherit = (pr.f("hairColor/colorInheritance") / 100.0) as f32;
let light_i = (pr.f("light/lightIntensity") / 100.0) as f32;
let ld = (pr.f("light/lightDirection") as f32).to_radians();
let light = [ld.sin(), -ld.cos()];
let (amb, dif, spe) = ((pr.f("shading/ambient") / 100.0) as f32, (pr.f("shading/diffuse") / 100.0) as f32, (pr.f("shading/specular") / 100.0) as f32);
let rough = (pr.f("shading/roughness") / 100.0).clamp(0.01, 1.0) as f32;
let seed = ctx.seed ^ (pr.f("randomSeed") as u32).wrapping_mul(0x85eb_ca6b);
let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32);
// Roots: a jittered grid over the layer, density per 1 000 px².
let count = ((lw * lh / 1000.0) * density * 10.0).min(200_000.0) as u32;
let segs = 6u32;
let strands: Vec<Vec<(Sprite, Tint)>> = (0..count)
.into_par_iter()
.map(|i| {
let rx = hash1(i, 1, seed) * lw;
let ry = hash1(i, 2, seed) * lh;
let (bx, by) = b.to_px([rx as f64, ry as f64]);
let mut l = len * (0.75 + 0.5 * hash1(i, 3, seed));
// Lean: random, plus the map's local gradient (hairfall map).
let mut lean = [(hash1(i, 4, seed) * 2.0 - 1.0) * 0.6, -1.0 + hash1(i, 5, seed) * 0.4];
if let Some(m) = &map {
let lum = |x: f32, y: f32| {
let c = m.at(x, y);
(0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]) * c[3]
};
let d = map_soft.max(1.0);
let (gx, gy) = (lum(rx + d, ry) - lum(rx - d, ry), lum(rx, ry + d) - lum(rx, ry - d));
l *= 1.0 - map_strength + map_strength * lum(rx, ry);
lean = [lean[0] + gx * map_strength * 4.0, lean[1] + gy * map_strength * 4.0];
if noise > 0.0 {
lean[0] += (hash1(i, 6, seed) * 2.0 - 1.0) * noise;
}
}
let ll = (lean[0] * lean[0] + lean[1] * lean[1]).sqrt().max(1e-4);
let dir = [lean[0] / ll, lean[1] / ll];
// Droop: heavier (or thinner with Constant Mass off) hair bends more.
let droop = weight * if const_mass { 1.0 } else { 1.0 / thick.max(0.2) } * l;
let pt = |s: f32| [rx + dir[0] * l * s, ry + dir[1] * l * s + droop * s * s];
let mut out = Vec::with_capacity(segs as usize);
for k in 0..segs {
let (s0, s1) = (k as f32 / segs as f32, (k + 1) as f32 / segs as f32);
let (a, c) = (pt(s0), pt(s1));
let t = {
let (tx, ty) = (c[0] - a[0], c[1] - a[1]);
let tl = (tx * tx + ty * ty).sqrt().max(1e-6);
[tx / tl, ty / tl]
};
// Strand shading (Kajiya–Kay): diffuse ∝ sin(T, L), specular from T·L.
let tl = t[0] * light[0] + t[1] * light[1];
let diff = (1.0 - tl * tl).max(0.0).sqrt();
let spec = (1.0 - tl.abs()).powf(1.0 / rough) * spe;
let shade = (amb + dif * diff * light_i) * bright;
let tip = 1.0 - 0.6 * s1;
let (pa, pc) = (b.to_px([a[0] as f64, a[1] as f64]), b.to_px([c[0] as f64, c[1] as f64]));
let sp = Sprite {
x: pa.0 as f32,
y: pa.1 as f32,
r: thick * 0.5 * b.scale as f32 * tip,
c: [0.0, 0.0, 0.0, opacity],
shape: Shape::Line { dx: (pc.0 - pa.0) as f32, dy: (pc.1 - pa.1) as f32 },
rot: 0.0,
};
// The root's colour mixed with the hair colour (no strand where it is clear).
out.push((sp, Tint::Root { x: bx, y: by, hair: [hc[0], hc[1], hc[2]], inherit, shade, spec: spec * light_i }));
}
out
})
.collect();
let (sprites, tints) = strands.into_iter().flatten().unzip();
Some(SpritePlan { sprites, tints, acc: Acc::Over, post: Post::Combine(0) })
}
pub fn specs() -> Vec<EffectSpec> {
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<crate::ParamSpec> {
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),
p("gridEnabled", "Grid Enabled", Value::Bool(true), ParamUi::Hidden),
p("exploderEnabled", "Layer Exploder Enabled", Value::Bool(true), ParamUi::Hidden),
p("layerMapEnabled", "Layer Map Enabled", Value::Bool(true), ParamUi::Hidden),
p("mapperEnabled", "Persistent Property Mapper Enabled", Value::Bool(true), ParamUi::Hidden),
// Cannon
p("cannon/cannonPosition", "Position", pt(0.5, 0.9), ParamUi::Point),
p("cannon/barrelRadius", "Barrel Radius", num(0.0), slider(-1000.0, 1000.0, -100.0, 100.0, 1)),
sp("cannon/particlesPerSecond", "Particles Per Second", px(60.0, 500.0)),
p("cannon/barrelAngle", "Direction", num(0.0), ParamUi::Angle),
sp("cannon/directionRandomSpread", "Direction Random Spread", px(20.0, 360.0)),
sp("cannon/velocity", "Velocity", px(130.0, 1000.0)),
sp("cannon/velocityRandomSpread", "Velocity Random Spread", px(20.0, 500.0)),
p("cannon/cannonColor", "Color", col(1.0, 0.0, 0.0), ParamUi::Color),
sp("cannon/cannonParticleRadius", "Particle Radius", px(2.0, 50.0)),
// Grid
p("grid/gridPosition", "Position", pt(0.5, 0.5), ParamUi::Point),
sp("grid/gridWidth", "Width", px(100.0, 2000.0)),
sp("grid/gridHeight", "Height", px(100.0, 2000.0)),
sp("grid/particlesAcross", "Particles Across", px(0.0, 100.0)),
sp("grid/particlesDown", "Particles Down", px(0.0, 100.0)),
p("grid/gridColor", "Color", col(1.0, 1.0, 1.0), ParamUi::Color),
sp("grid/gridParticleRadius", "Particle Radius", px(2.0, 50.0)),
// Layer Exploder
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)),
];
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"),
("persistentPropertyMapper/mapBlueTo", "persistentPropertyMapper/blueMin", "persistentPropertyMapper/blueMax", "Map Blue To"),
] {
pg.push(p(to, n_to, Value::Enum(0), popup(&MAP_TARGETS)));
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![
spec("ec.sim.particleplayground", "Particle Playground", pg, particle_playground),
spec(
"ec.sim.cchair",
"CC Hair",
vec![
sp("length", "Length", px(30.0, 200.0)),
p("thickness", "Thickness", num(1.0), slider(0.05, 20.0, 0.1, 5.0, 2)),
p("weight", "Weight", num(0.2), slider(-10.0, 10.0, -2.0, 2.0, 2)),
p("constantMass", "Constant Mass", Value::Bool(false), ParamUi::Checkbox),
sp("density", "Density", px(100.0, 1000.0)),
// Hairfall Map
sp("hairfallMap/mapStrength", "Map Strength", pc(0.0)),
p("hairfallMap/mapLayer", "Map Layer", Value::Layer(None), ParamUi::Layer),
sp("hairfallMap/mapSoftness", "Map Softness", px(0.0, 100.0)),
sp("hairfallMap/addNoise", "Add Noise", pc(0.0)),
// Hair Color
p("hairColor/hairColor", "Color", col(0.35, 0.25, 0.15), ParamUi::Color),
sp("hairColor/brightness", "Brightness", px(100.0, 400.0)),
sp("hairColor/opacity", "Opacity", pc(100.0)),
sp("hairColor/colorInheritance", "Color Inheritance", pc(0.0)),
// Light
sp("light/lightIntensity", "Light Intensity", px(100.0, 400.0)),
p("light/lightDirection", "Light Direction", num(-45.0), ParamUi::Angle),
// Shading
sp("shading/ambient", "Ambient", pc(40.0)),
sp("shading/diffuse", "Diffuse", pc(60.0)),
sp("shading/specular", "Specular", pc(30.0)),
sp("shading/roughness", "Roughness", pc(20.0)),
p("randomSeed", "Random Seed", num(0.0), slider(0.0, 10000.0, 0.0, 1000.0, 0)),
],
cc_hair,
),
]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{EffectEnv, MaskShape, run_fx};
fn run(vals: &[(&str, Value)], t: f64) -> Image {
run_fx("ec.sim.particleplayground", vals, Image::filled(80, 60, [0.2, 0.2, 0.2, 1.0]), t, EffectEnv::default()).img
}
fn cannon(t: f64) -> Image {
run(&[("cannon/cannonPosition", Value::Vec2([40.0, 50.0])), ("cannon/velocity", num(60.0)), ("gravity/gravityForce", num(30.0))], t)
}
#[test]
fn cannon_stream_is_seek_consistent() {
let a = cannon(1.0);
// Particles replace the layer: some coverage, mostly transparent, red.
let cov: f32 = a.data.iter().map(|p| p[3]).sum();
assert!(cov > 5.0, "{cov}");
assert!(a.data.iter().any(|p| p[3] > 0.5 && p[0] > 0.5 && p[1] < 0.1));
// Seek back and forth: the same frame every time.
let _ = cannon(2.0);
let b = cannon(1.0);
assert_eq!(a.data, b.data);
let c = cannon(0.5);
assert_ne!(a.data, c.data);
// Nothing has been fired at time 0.
let z = cannon(0.0);
assert!(z.data.iter().all(|p| p[3] == 0.0));
}
#[test]
fn grid_falls_with_gravity_and_bounces_off_a_wall() {
let grid = |t: f64, env: EffectEnv| {
run_fx(
"ec.sim.particleplayground",
&[
("cannonEnabled", Value::Bool(false)),
("grid/gridPosition", Value::Vec2([40.0, 10.0])),
("grid/gridWidth", num(40.0)),
("grid/gridHeight", num(0.0)),
("grid/particlesAcross", num(5.0)),
("grid/particlesDown", num(1.0)),
("gravity/gravityForce", num(100.0)),
("wall/wallBoundary", num(1.0)),
],
Image::new(80, 60),
t,
env,
)
.img
};
// Alpha-weighted mean height of the particles.
let row_of = |img: &Image| {
let (mut s, mut n) = (0.0f64, 0.0f64);
for y in 0..60 {
for x in 0..80 {
let a = img.get(x, y)[3] as f64;
s += (y as f64 + 0.5) * a;
n += a;
}
}
(s / n.max(1e-9)).round() as i64
};
let t0 = grid(0.0, EffectEnv::default());
let t1 = grid(0.5, EffectEnv::default());
assert_eq!(row_of(&t0), 10);
// ½ g t² = 12.5 px.
assert!((row_of(&t1) - 22).abs() <= 2, "{}", row_of(&t1));
// A wall (mask 1) whose floor is at y = 15 keeps them above it.
let masks = [MaskShape { name: "w".into(), points: vec![[0.0, 0.0], [80.0, 0.0], [80.0, 15.0], [0.0, 15.0]], closed: true, inverted: false }];
let env = EffectEnv { masks: &masks, ..Default::default() };
let w = grid(1.5, env);
assert!(row_of(&w) <= 16, "{}", row_of(&w));
}
#[test]
fn repel_spreads_particles() {
let spread = |force: f64| {
let img = run(
&[
("cannonEnabled", Value::Bool(false)),
("grid/gridPosition", Value::Vec2([40.0, 30.0])),
("grid/gridWidth", num(6.0)),
("grid/gridHeight", num(6.0)),
("grid/particlesAcross", num(3.0)),
("grid/particlesDown", num(3.0)),
("gravity/gravityForce", num(0.0)),
("repel/repelForce", num(force)),
("repel/repelForceRadius", num(20.0)),
],
0.5,
);
let (mut x0, mut x1) = (80, 0);
for y in 0..60 {
for x in 0..80 {
if img.get(x, y)[3] > 0.3 {
x0 = x0.min(x);
x1 = x1.max(x);
}
}
}
x1 - x0
};
assert!(spread(0.2) > spread(0.0) + 4, "{} vs {}", spread(0.2), spread(0.0));
}
#[test]
fn property_mapper_and_layer_map_read_layers() {
// Without a host, the layer-based features are inert (deterministic output).
let a = run(
&[
("persistentPropertyMapper/mapRedTo", Value::Enum(8)),
("persistentPropertyMapper/redMin", num(-50.0)),
("persistentPropertyMapper/redMax", num(50.0)),
],
0.7,
);
let b = run(
&[
("persistentPropertyMapper/mapRedTo", Value::Enum(8)),
("persistentPropertyMapper/redMin", num(-50.0)),
("persistentPropertyMapper/redMax", num(50.0)),
],
0.7,
);
assert_eq!(a.data, b.data);
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,
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,
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,
wall_affects: Affects::default(),
persistent: None,
ephemeral: None,
seed,
bounds: [-1e6, -1e6, 1e6, 1e6],
}
}
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, barrel_radius: r, ..bare(3) };
let (sq, disc) = (pg(10.0), pg(-10.0));
let mut corner = false;
for id in 0..400 {
let a = sq.spawn(id).p;
assert!(a[0].abs() <= 10.0 && a[1].abs() <= 10.0);
// A square barrel fills its corners (both offsets in use, not a line).
corner |= a[0].abs() > 7.5 && a[1].abs() > 7.5;
let b = disc.spawn(id).p;
assert!(b[0].hypot(b[1]) <= 10.0 + 1e-4);
}
assert!(corner);
// Radius 0: every particle starts at the cannon position.
assert_eq!(pg(0.0).spawn(5).p, [0.0, 0.0]);
}
#[test]
fn mapper_drives_position_and_opacity_in_ae_order() {
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::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<crate::LayerPixels> {
None
}
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
None
}
fn layer_at(&self, _: u64, t: f64, _: bool) -> Option<crate::LayerPixels> {
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);
for y in 20..40 {
for x in 20..40 {
img.set(x, y, [0.8, 0.6, 0.4, 1.0]);
}
}
let out = run_fx("ec.sim.cchair", &[("length", num(12.0)), ("density", num(300.0))], img.clone(), 0.0, EffectEnv::default());
// Hair reaches beyond the square (above it: hair leans up, then droops).
let outside: f32 = (0..60).flat_map(|x| (0..20).map(move |y| (x, y))).map(|(x, y)| out.img.get(x, y)[3]).sum();
assert!(outside > 1.0, "{outside}");
// Nothing grows from transparent areas far away.
assert_eq!(out.img.get(2, 58)[3], 0.0);
let again = run_fx("ec.sim.cchair", &[("length", num(12.0)), ("density", num(300.0))], img.clone(), 3.0, EffectEnv::default());
assert_eq!(out.img.data, again.img.data);
let none = run_fx("ec.sim.cchair", &[("length", num(0.0))], img.clone(), 0.0, EffectEnv::default());
assert_eq!(none.img.data, img.data);
}
}