Files
effectcraft/crates/effects/src/sim.rs
T
tylerjenningsw 16cc290a47 Particles: tests for CC Particle Systems II and CC Mr. Mercury with a Birth Rate over time (#1373)
For both: a burst lives on after Birth Rate drops to 0, and is drawn
(particles, blobs) where a constant 0 draws nothing. Its particles move
and age, and are gone after their Longevity. Seeking matches a
simulation from 0, and moving an earlier key with the same current rate
and cache key isn't served from stale checkpoints. A constant rate gives
exactly the constant-rate simulation's particles, with or without a
host.
2026-10-13 22:35:36 -04:00

2334 lines
106 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 effects (particles, physics, liquids): CC Rainfall, CC Snowfall, CC Bubbles,
//! CC Drizzle, CC Star Burst, CC Particle World, CC Particle Systems II and CC Mr. Mercury.
//!
//! Written from the public descriptions of the effects' behaviour. Weather/bubble/star effects
//! are closed-form functions of time (every frame independent); particle systems are stepped
//! from layer time 0 at a fixed rate through [`SimCache`], so any frame can be rendered
//! directly and equals the frame reached by scrubbing.
//!
//! Shared here (also used by `sim2`): a band-bucketed rasteriser for many small items
//! ([`raster`]), particle sprites ([`Sprite`]) and transfer-mode compositing ([`combine`]).
use effectcraft_keyframe::Value;
use effectcraft_project::ParamUi;
use effectcraft_raster::{Image, Px};
use rayon::prelude::*;
use crate::generate::value_noise;
use crate::util::{SimCache, hash1, params_key_except, unpremul};
use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider};
pub(crate) 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 }
}
pub(crate) fn pt(x: f64, y: f64) -> Value {
Value::Vec2([x, y])
}
pub(crate) fn pct() -> ParamUi {
slider(0.0, 100.0, 0.0, 100.0, 1)
}
/// Deterministic hash → [0, 1).
#[inline]
pub(crate) fn h(i: u32, k: u32, seed: u32) -> f32 {
hash1(i, k, seed)
}
/// Signed hash → [-1, 1).
#[inline]
pub(crate) fn hs(i: u32, k: u32, seed: u32) -> f32 {
hash1(i, k, seed) * 2.0 - 1.0
}
// ------------------------------------------------------------------ band rasteriser
const BAND: usize = 16;
/// How rasterised items combine with what is already in the target.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Acc {
/// Premultiplied "over" (later items on top).
Over,
/// Additive (glows, light).
Add,
}
/// Rasterise many small items into `img` (buffer pixels). `bbox` gives an item's pixel bounds
/// `[x0, y0, x1, y1]` (or `None` to skip), `shade` its premultiplied colour at a pixel centre.
/// Items are bucketed into horizontal bands rendered in parallel; within a band they are drawn
/// in slice order.
pub(crate) fn raster<T: Sync>(
img: &mut Image,
items: &[T],
bbox: impl Fn(&T) -> Option<[f32; 4]> + Sync,
shade: impl Fn(&T, f32, f32) -> Option<Px> + Sync,
acc: Acc,
) {
let (w, hgt) = (img.width as usize, img.height as usize);
if w == 0 || hgt == 0 || items.is_empty() {
return;
}
let nb = hgt.div_ceil(BAND);
let boxes: Vec<Option<[i32; 4]>> = items
.par_iter()
.map(|it| {
let b = bbox(it)?;
if !(b[0].is_finite() && b[1].is_finite() && b[2].is_finite() && b[3].is_finite()) {
return None;
}
let x0 = b[0].floor().max(0.0) as i32;
let y0 = b[1].floor().max(0.0) as i32;
let x1 = (b[2].ceil() as i32).min(w as i32 - 1);
let y1 = (b[3].ceil() as i32).min(hgt as i32 - 1);
if x1 < x0 || y1 < y0 || b[2] < 0.0 || b[3] < 0.0 {
return None;
}
Some([x0, y0, x1, y1])
})
.collect();
let mut buckets: Vec<Vec<u32>> = vec![Vec::new(); nb];
for (i, b) in boxes.iter().enumerate() {
if let Some(b) = b {
for band in (b[1] as usize / BAND)..=(b[3] as usize / BAND).min(nb - 1) {
buckets[band].push(i as u32);
}
}
}
img.data.par_chunks_mut(w * BAND).enumerate().for_each(|(band, chunk)| {
let by0 = band * BAND;
let rows = chunk.len() / w;
for &i in &buckets[band] {
let Some(b) = boxes[i as usize] else { continue };
let it = &items[i as usize];
let ya = (b[1] as usize).max(by0);
let yb = (b[3] as usize).min(by0 + rows - 1);
for y in ya..=yb {
let row = &mut chunk[(y - by0) * w..(y - by0 + 1) * w];
for x in b[0] as usize..=b[2] as usize {
if let Some(c) = shade(it, x as f32 + 0.5, y as f32 + 0.5) {
let d = &mut row[x];
match acc {
Acc::Over => {
let k = 1.0 - c[3];
*d = [c[0] + d[0] * k, c[1] + d[1] * k, c[2] + d[2] * k, c[3] + d[3] * k];
}
Acc::Add => {
*d = [d[0] + c[0], d[1] + c[1], d[2] + c[2], (d[3] + c[3]).min(1.0)];
}
}
}
}
}
}
});
}
// ------------------------------------------------------------------ sprites
#[derive(Clone, Copy, PartialEq, Debug)]
pub enum Shape {
/// Soft round dot (Gaussian-like falloff).
Soft,
/// Hard anti-aliased disc.
Disc,
/// Lit sphere (upper-left light, specular).
Sphere,
/// Disc fading linearly to its rim.
Faded,
/// Thin ring with a highlight.
Bubble,
/// Four-point star.
Star,
/// Rotated square (angle in `rot`).
Square,
/// Triangle (angle in `rot`).
Tri,
/// Segment from (x, y) by (dx, dy), half-width `r`.
Line { dx: f32, dy: f32 },
}
/// A particle sprite in buffer pixels; `c` is straight colour + opacity.
#[derive(Clone, Copy, Debug)]
pub struct Sprite {
pub x: f32,
pub y: f32,
pub r: f32,
pub c: [f32; 4],
pub shape: Shape,
pub rot: f32,
}
impl Sprite {
pub(crate) fn new(x: f32, y: f32, r: f32, c: [f32; 4], shape: Shape) -> Sprite {
Sprite { x, y, r, c, shape, rot: 0.0 }
}
}
/// Where a sprite's colour comes from when it depends on the layer's pixels (resolved by
/// [`SpritePlan::resolve`] on the CPU, per pixel by the GPU compositor). Positions are buffer
/// pixels.
#[derive(Clone, Copy, Debug)]
pub enum Tint {
/// The sprite's own colour.
Fixed,
/// CC Rainfall's refracting drops: where the layer shows (alpha > 0), colour × (layer
/// colour × 0.6 + 0.5) sampled at (x, y).
Refract { x: f64, y: f64 },
/// CC Star Burst: the layer's colour at (x, y), opacity × its alpha (none where clear).
Source { x: f64, y: f64 },
/// CC Hair: the root's colour at (x, y) mixed with the hair colour (`inherit` = 1 keeps the
/// root's), × `shade`, + `spec`; no strand where the root's alpha is below ½.
Root { x: f64, y: f64, hair: [f32; 3], inherit: f32, shade: f32, spec: f32 },
}
/// How a sprite layer meets the original layer.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Post {
/// [`combine`] with this mode (0 Composite … 5 sprites only).
Combine(u32),
/// The sprites alone.
Replace,
/// The sprites, mixed toward the original by this fraction.
Lerp(f32),
/// The sprites over opaque black.
OverBlack,
}
/// What a sprite-drawing effect draws this frame: built on the CPU (closed-form or from the
/// simulation state), rasterised by [`SpritePlan::finish`] or the GPU compositor.
#[derive(Clone, Debug)]
pub struct SpritePlan {
pub sprites: Vec<Sprite>,
/// Per sprite (empty = all [`Tint::Fixed`]).
pub tints: Vec<Tint>,
pub acc: Acc,
pub post: Post,
}
impl SpritePlan {
/// Resolve layer-dependent colours against the layer `src`.
pub fn resolve(&mut self, src: &Image) {
let tints = std::mem::take(&mut self.tints);
self.sprites.par_iter_mut().zip(tints.par_iter()).for_each(|(sp, t)| match *t {
Tint::Fixed => {}
Tint::Refract { x, y } => {
let (sc, sa) = unpremul(src.sample_bilinear(x, y));
if sa > 0.0 {
let c = sp.c;
sp.c = [(sc[0] * 0.6 + 0.5) * c[0], (sc[1] * 0.6 + 0.5) * c[1], (sc[2] * 0.6 + 0.5) * c[2], c[3]];
}
}
Tint::Source { x, y } => {
let (c, a) = unpremul(src.sample_bilinear(x, y));
sp.c = if a <= 0.0 { [0.0; 4] } else { [c[0], c[1], c[2], a * sp.c[3]] };
}
Tint::Root { x, y, hair, inherit, shade, spec } => {
let root = src.sample_bilinear(x, y);
if root[3] < 0.5 {
sp.c = [0.0; 4];
return;
}
let (rc, _) = unpremul(root);
let base: [f32; 3] = std::array::from_fn(|k| rc[k] + (hair[k] - rc[k]) * (1.0 - inherit));
sp.c = [base[0] * shade + spec, base[1] * shade + spec, base[2] * shade + spec, sp.c[3]];
}
});
}
/// Rasterise (after [`SpritePlan::resolve`]) and meet the original layer `b`.
pub fn finish(&self, mut b: Buf) -> Buf {
let fx = splat(b.img.width, b.img.height, &self.sprites, self.acc);
b.img = match self.post {
Post::Combine(m) => combine(&b.img, &fx, m),
Post::Replace => fx,
Post::Lerp(t) => {
let mut o = fx;
o.data.par_iter_mut().zip(b.img.data.par_iter()).for_each(|(o, s0)| *o = crate::util::lerp4(*o, *s0, t));
o
}
Post::OverBlack => combine(&Image::filled(b.img.width, b.img.height, [0.0, 0.0, 0.0, 1.0]), &fx, 0),
};
b
}
}
/// The sprite plan of a sprite-drawing simulation effect for buffer geometry `b` (its pixels
/// are not read), `None` for other effects or settings drawn otherwise (the GPU compositor's
/// render pass).
pub fn sprite_plan(id: &str, ctx: &EffectCtx, b: &Buf) -> Option<SpritePlan> {
match id {
"ec.sim.ccrainfall" => Some(rainfall_plan(ctx, b)),
"ec.sim.ccsnowfall" => Some(snowfall_plan(ctx, b)),
"ec.sim.ccstarburst" => Some(star_burst_plan(ctx, b)),
"ec.sim.cchair" => crate::sim3::hair_plan(ctx, b),
"ec.sim.waveworld" => crate::sim2::wave_world_plan(ctx, b).and_then(|p| match p {
crate::sim2::WavePlan::Wire(sp) => Some(sp),
crate::sim2::WavePlan::Height { .. } => None,
}),
"ec.sim.foam" => crate::sim2::foam_plan(ctx, b),
"ec.sim.ccparticleworld" => particle_world_plan(ctx, b),
"ec.sim.ccparticlesystems2" => Some(particle_systems2_plan(ctx, b)),
_ => None,
}
}
fn sprite_bbox(s: &Sprite) -> Option<[f32; 4]> {
if s.c[3] <= 1e-5 || s.r <= 0.0 {
return None;
}
let r = s.r.max(0.5) + 1.0;
Some(match s.shape {
Shape::Line { dx, dy } => [s.x.min(s.x + dx) - r, s.y.min(s.y + dy) - r, s.x.max(s.x + dx) + r, s.y.max(s.y + dy) + r],
Shape::Star => [s.x - r * 2.0, s.y - r * 2.0, s.x + r * 2.0, s.y + r * 2.0],
_ => [s.x - r, s.y - r, s.x + r, s.y + r],
})
}
#[inline]
fn aa(edge: f32) -> f32 {
(edge + 0.5).clamp(0.0, 1.0)
}
fn sprite_shade(s: &Sprite, x: f32, y: f32) -> Option<Px> {
// Sub-pixel sprites keep their energy: draw at radius 0.5 with reduced opacity.
let (r, fade) = if s.r < 0.5 { (0.5, s.r / 0.5) } else { (s.r, 1.0) };
let (dx, dy) = (x - s.x, y - s.y);
let mut rgb = [s.c[0], s.c[1], s.c[2]];
let cov = match s.shape {
Shape::Soft => {
let d2 = (dx * dx + dy * dy) / (r * r);
if d2 >= 1.0 {
return None;
}
let q = 1.0 - d2;
q * q
}
Shape::Disc => aa(r - (dx * dx + dy * dy).sqrt()),
Shape::Faded => {
let d = (dx * dx + dy * dy).sqrt() / r;
(1.0 - d).max(0.0).powf(1.5)
}
Shape::Sphere => {
let d = (dx * dx + dy * dy).sqrt();
let cv = aa(r - d);
if cv <= 0.0 {
return None;
}
let (nx, ny) = (dx / r, dy / r);
let nz = (1.0 - nx * nx - ny * ny).max(0.0).sqrt();
let (lx, ly, lz) = (-0.48, -0.58, 0.66);
let diff = (nx * lx + ny * ly + nz * lz).max(0.0);
let rz = 2.0 * diff * nz - lz;
let spec = rz.max(0.0).powi(20) * 0.6;
let k = 0.2 + 0.8 * diff;
rgb = [rgb[0] * k + spec, rgb[1] * k + spec, rgb[2] * k + spec];
cv
}
Shape::Bubble => {
let d = (dx * dx + dy * dy).sqrt() / r;
if d > 1.0 + 1.0 / r {
return None;
}
let ring = (1.0 - ((d - 0.9) / 0.12).powi(2)).max(0.0);
let hl = (1.0 - (((dx / r) + 0.4).powi(2) + ((dy / r) + 0.4).powi(2)) / 0.04).max(0.0);
(0.12 * (d <= 1.0) as u8 as f32 + ring * 0.8 + hl).min(1.0)
}
Shape::Star => {
let (sn, cs) = s.rot.sin_cos();
let (ux, uy) = ((dx * cs + dy * sn) / r, (-dx * sn + dy * cs) / r);
let d = (ux * ux + uy * uy).sqrt();
let core = (1.0 - d).max(0.0).powi(2);
let sx = (1.0 - ux.abs() / 2.0).max(0.0) * (1.0 - (uy.abs() * 6.0)).max(0.0);
let sy = (1.0 - uy.abs() / 2.0).max(0.0) * (1.0 - (ux.abs() * 6.0)).max(0.0);
(core + sx + sy).min(1.0)
}
Shape::Square => {
let (sn, cs) = s.rot.sin_cos();
let (ux, uy) = (dx * cs + dy * sn, -dx * sn + dy * cs);
let e = r * 0.75;
aa(e - ux.abs()) * aa(e - uy.abs())
}
Shape::Tri => {
let (sn, cs) = s.rot.sin_cos();
let (ux, uy) = (dx * cs + dy * sn, -dx * sn + dy * cs);
// Equilateral triangle with circumradius r: three half-planes.
let e = r * 0.5;
let mut m = f32::INFINITY;
for k in 0..3 {
let a = std::f32::consts::FRAC_PI_2 + k as f32 * std::f32::consts::TAU / 3.0;
let (ns, nc) = a.sin_cos();
m = m.min(e - (ux * nc + uy * ns));
}
aa(m)
}
Shape::Line { dx: lx, dy: ly } => {
let l2 = lx * lx + ly * ly;
let t = if l2 > 0.0 { ((dx * lx + dy * ly) / l2).clamp(0.0, 1.0) } else { 0.0 };
let (px, py) = (dx - lx * t, dy - ly * t);
aa(r - (px * px + py * py).sqrt())
}
};
let a = cov * s.c[3] * fade;
if a <= 1e-6 {
return None;
}
Some([rgb[0] * a, rgb[1] * a, rgb[2] * a, a])
}
/// Draw sprites into a new transparent image of `w`×`h`.
pub(crate) fn splat(w: u32, hgt: u32, sprites: &[Sprite], acc: Acc) -> Image {
let mut img = Image::new(w, hgt);
raster(&mut img, sprites, sprite_bbox, sprite_shade, acc);
img
}
/// Combine an effect layer `fx` with the original `src` (both premultiplied, same size):
/// 0 Composite (fx over src), 1 Screen, 2 Add, 3 Lighten, 4 Darken, 5 fx only.
pub(crate) fn combine(src: &Image, fx: &Image, mode: u32) -> Image {
let mut out = fx.clone();
out.data.par_iter_mut().zip(src.data.par_iter()).for_each(|(o, s)| {
let f = *o;
*o = match mode {
0 => {
let k = 1.0 - f[3];
[f[0] + s[0] * k, f[1] + s[1] * k, f[2] + s[2] * k, f[3] + s[3] * k]
}
1 => [s[0] + f[0] - s[0] * f[0], s[1] + f[1] - s[1] * f[1], s[2] + f[2] - s[2] * f[2], s[3] + f[3] - s[3] * f[3]],
2 => [s[0] + f[0], s[1] + f[1], s[2] + f[2], (s[3] + f[3]).min(1.0)],
3 => [s[0].max(f[0]), s[1].max(f[1]), s[2].max(f[2]), s[3] + f[3] - s[3] * f[3]],
4 => {
let (fc, fa) = unpremul(f);
let m = |c: f32, k: usize| c - (c - c.min(fc[k] * s[3])) * fa;
[m(s[0], 0), m(s[1], 1), m(s[2], 2), s[3]]
}
_ => f,
};
});
out
}
// ------------------------------------------------------------------ CC Rainfall / CC Snowfall
/// Shared weather layout: per-item depth factor `k` in (0, 1] (1 = nearest) from Scene Depth.
#[inline]
fn depth_k(z: f32, scene_depth: f32) -> f32 {
1.0 / (1.0 + z * scene_depth / 2500.0)
}
/// Wrap `v` into `[lo, lo + span)`.
#[inline]
fn wrap(v: f32, lo: f32, span: f32) -> f32 {
if span <= 0.0 { v } else { lo + (v - lo).rem_euclid(span) }
}
fn rainfall(ctx: &EffectCtx, b: Buf) -> Buf {
let mut plan = rainfall_plan(ctx, &b);
plan.resolve(&b.img);
plan.finish(b)
}
fn rainfall_plan(ctx: &EffectCtx, b: &Buf) -> SpritePlan {
let pr = ctx.params;
let n = pr.f("drops").clamp(0.0, 100_000.0) as u32;
let size = pr.f("size").max(0.0) as f32;
let depth = pr.f("sceneDepth").max(0.0) as f32;
let speed = pr.f("speed") as f32;
let wind = pr.f("wind") as f32;
let wvar = pr.f("windVariation") as f32 / 100.0;
let spread = pr.f("spread") as f32;
let color = pr.color("color");
let opacity = pr.f("opacity") as f32 / 100.0;
let mode = pr.e("transferMode");
let with_orig = pr.b("compositeWithOriginal");
let refract = pr.e("extras/appearance") == 0;
let off = pr.v2("extras/offset");
let seed = pr.f("extras/randomSeed") as u32 ^ ctx.seed.wrapping_mul(0x9e37);
let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32);
let s = b.scale as f32;
let t = ctx.time as f32;
let (ox, oy) = (off[0] as f32 - lw * 0.5, off[1] as f32 - lh * 0.5);
let (sprites, tints): (Vec<Sprite>, Vec<Tint>) = (0..n)
.into_par_iter()
.map(|i| {
let z = h(i, 1, seed);
let k = depth_k(z, depth);
let vy = speed / 4000.0 * lh * 2.2 * k;
let w = wind / 1000.0 * lh * (1.0 + wvar * hs(i, 2, seed)) * k + spread * hs(i, 7, seed) * k;
let len = (vy * 0.04 * size.max(0.2)).max(1.0);
let lenx = w * 0.04 * size.max(0.2);
let margin = len + lenx.abs() + 4.0;
let y = wrap(h(i, 3, seed) * (lh + 2.0 * margin) + vy * t + oy, -margin, lh + 2.0 * margin);
let x = wrap(h(i, 4, seed) * (lw + 2.0 * margin) + w * t + ox, -margin, lw + 2.0 * margin);
let (bx, by) = b.to_px([x as f64, y as f64]);
let c = [color[0], color[1], color[2], opacity * (0.35 + 0.65 * k)];
// Refracting drops show the scene behind them, brightened and offset.
let tint = if refract { Tint::Refract { x: bx + 2.0 * s as f64, y: by } } else { Tint::Fixed };
let r = (size * 0.6 * k * s).max(0.25);
(Sprite::new(bx as f32, by as f32, r, c, Shape::Line { dx: -lenx * s, dy: -len * s }), tint)
})
.unzip();
SpritePlan { sprites, tints, acc: Acc::Over, post: weather_post(mode, with_orig) }
}
fn weather_post(mode: u32, with_orig: bool) -> Post {
if with_orig { Post::Combine(if mode == 1 { 3 } else { 0 }) } else { Post::Replace }
}
fn snowfall(ctx: &EffectCtx, b: Buf) -> Buf {
snowfall_plan(ctx, &b).finish(b)
}
fn snowfall_plan(ctx: &EffectCtx, b: &Buf) -> SpritePlan {
let pr = ctx.params;
let n = pr.f("flakes").clamp(0.0, 200_000.0) as u32;
let size = pr.f("size").max(0.0) as f32;
let svar = pr.f("sizeVariation") as f32 / 100.0;
let depth = pr.f("sceneDepth").max(0.0) as f32;
let speed = pr.f("speed") as f32;
let spvar = pr.f("speedVariation") as f32 / 100.0;
let wind = pr.f("wind") as f32;
let wvar = pr.f("windVariation") as f32 / 100.0;
let spread = pr.f("spread") as f32;
let wig = pr.f("wiggle/wiggleAmount") as f32;
let wigvar = pr.f("wiggle/wiggleAmountVariation") as f32 / 100.0;
let wfreq = pr.f("wiggle/wiggleFrequency") as f32;
let wfvar = pr.f("wiggle/wiggleFrequencyVariation") as f32 / 100.0;
let color = pr.color("color");
let opacity = pr.f("opacity") as f32 / 100.0;
let mode = pr.e("transferMode");
let with_orig = pr.b("compositeWithOriginal");
let off = pr.v2("extras/offset");
let seed = pr.f("extras/randomSeed") as u32 ^ ctx.seed.wrapping_mul(0x85eb);
let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32);
let s = b.scale as f32;
let t = ctx.time as f32;
let (ox, oy) = (off[0] as f32 - lw * 0.5, off[1] as f32 - lh * 0.5);
let sprites: Vec<Sprite> = (0..n)
.into_par_iter()
.map(|i| {
let z = h(i, 1, seed);
let k = depth_k(z, depth);
let r = (size * 0.5 * k * (1.0 + svar * hs(i, 2, seed))).max(0.05);
let vy = speed * (lh / 600.0) * k * (1.0 + spvar * hs(i, 3, seed));
let vx = wind * (lh / 1200.0) * k * (1.0 + wvar * hs(i, 4, seed)) + spread * hs(i, 8, seed) * k;
let amp = wig * 2.0 * k * (1.0 + wigvar * hs(i, 5, seed));
let fr = wfreq * (1.0 + wfvar * hs(i, 6, seed));
let ph = h(i, 9, seed) * std::f32::consts::TAU;
let margin = r + amp.abs() + 2.0;
let y = wrap(h(i, 10, seed) * (lh + 2.0 * margin) + vy * t + oy, -margin, lh + 2.0 * margin);
let x = wrap(h(i, 11, seed) * (lw + 2.0 * margin) + vx * t + ox, -margin, lw + 2.0 * margin) + amp * (fr * t * std::f32::consts::TAU + ph).sin();
let (bx, by) = b.to_px([x as f64, y as f64]);
Sprite::new(bx as f32, by as f32, r * s, [color[0], color[1], color[2], opacity * (0.4 + 0.6 * k)], Shape::Soft)
})
.collect();
SpritePlan { sprites, tints: vec![], acc: Acc::Over, post: weather_post(mode, with_orig) }
}
// ------------------------------------------------------------------ CC Bubbles
/// A CC Bubbles bubble in buffer pixels.
#[derive(Clone, Copy, Debug)]
pub struct Bubble {
pub x: f32,
pub y: f32,
pub r: f32,
/// Source sample centre (buffer px).
pub sx: f32,
pub sy: f32,
}
/// CC Bubbles' bubbles this frame (closed-form in time) for buffer geometry `b`.
pub fn bubble_list(ctx: &EffectCtx, b: &Buf) -> Vec<Bubble> {
let pr = ctx.params;
let n = pr.f("bubbleAmount").clamp(0.0, 10_000.0) as u32;
let speed = pr.f("bubbleSpeed") as f32;
let wamp = pr.f("wobbleAmplitude") as f32;
let wfreq = pr.f("wobbleFrequency") as f32;
let bsize = pr.f("bubbleSize").max(0.0) as f32;
let seed = ctx.seed.wrapping_mul(0x27d4);
let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32);
let s = b.scale as f32;
let t = ctx.time as f32;
let base = lw.min(lh) * 0.035 * bsize;
(0..n)
.map(|i| {
let r = base * (0.5 + h(i, 1, seed));
let travel = lh + 2.0 * r;
let v = speed * lh * 0.3 * (0.6 + 0.8 * h(i, 2, seed));
let y = lh + r - (h(i, 3, seed) * travel + v * t).rem_euclid(travel);
let x0 = h(i, 4, seed) * lw;
let x = x0 + wamp * (r / base.max(1e-3)) * (t * wfreq * std::f32::consts::TAU + h(i, 5, seed) * std::f32::consts::TAU).sin();
let (bx, by) = b.to_px([x as f64, y as f64]);
let (sx, sy) = b.to_px([x0 as f64, y as f64]);
Bubble { x: bx as f32, y: by as f32, r: r * s, sx: sx as f32, sy: sy as f32 }
})
.collect()
}
fn bubbles(ctx: &EffectCtx, mut b: Buf) -> Buf {
let pr = ctx.params;
let metal = pr.e("reflectionType") == 1;
let shading = pr.e("shadingType");
let list = bubble_list(ctx, &b);
let src = b.img.clone();
let mut out = Image::new(src.width, src.height);
raster(
&mut out,
&list,
|q| Some([q.x - q.r - 1.0, q.y - q.r - 1.0, q.x + q.r + 1.0, q.y + q.r + 1.0]),
|q, x, y| {
let (dx, dy) = ((x - q.x) / q.r.max(0.5), (y - q.y) / q.r.max(0.5));
let d2 = dx * dx + dy * dy;
let d = d2.sqrt();
let cov = aa((1.0 - d) * q.r);
if cov <= 0.0 {
return None;
}
// Liquid refracts (inverted, magnified), metal reflects (wide-angle mirror).
let k = if metal { 1.6 } else { -0.7 };
let nz = (1.0 - d2).max(0.0).sqrt();
let p = src.sample_bilinear((q.sx + dx * q.r * k) as f64, (q.sy + dy * q.r * k) as f64);
let (c, sa) = unpremul(p);
let mut c = if sa <= 0.0 { [0.6, 0.7, 0.8] } else { c };
let mut a = cov;
match shading {
1 => c = c.map(|v| v * 0.7 + 0.3 * (1.0 - nz)),
2 => c = c.map(|v| v * (0.5 + 0.5 * nz)),
3 => a *= nz.powf(0.5),
4 => a *= 1.0 - nz * 0.8,
_ => {}
}
let hl = (1.0 - ((dx + 0.35).powi(2) + (dy + 0.35).powi(2)) / 0.05).max(0.0) * 0.8;
let c = c.map(|v| v + hl);
Some([c[0] * a, c[1] * a, c[2] * a, a])
},
Acc::Over,
);
b.img = out;
b
}
// ------------------------------------------------------------------ CC Drizzle
/// CC Drizzle's live drops this frame (buffer px: centre x, y, ring radius, amplitude);
/// `None` = no drops yet (the layer passes through).
pub fn drizzle_drops(ctx: &EffectCtx, b: &Buf) -> Option<Vec<[f32; 4]>> {
let pr = ctx.params;
let rate = pr.f("dripRate").max(0.0);
let life = pr.f("longevity").max(0.01);
let spreading = pr.f("spreading") as f32;
let height = pr.f("rippleHeight") as f32 / 100.0;
let seed = ctx.seed.wrapping_mul(0x165667b1);
let t = ctx.time;
let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32);
let s = b.scale as f32;
if rate <= 0.0 || t < 0.0 {
return None;
}
// Drops born at k / rate with hashed positions; alive while younger than the longevity.
let k0 = ((t - life) * rate).floor().max(0.0) as u32;
let k1 = (t * rate).floor() as u32;
let mut drops = Vec::new();
for k in k0..=k1 {
let born = k as f64 / rate;
let age = (t - born) as f32;
if age < 0.0 || age as f64 >= life {
continue;
}
let (x, y) = b.to_px([(h(k, 1, seed) * lw) as f64, (h(k, 2, seed) * lh) as f64]);
let radius = spreading * age * lh / 400.0 * s;
let amp = height * (1.0 - age / life as f32) * (0.6 + 0.4 * h(k, 3, seed));
drops.push([x as f32, y as f32, radius, amp]);
}
Some(drops)
}
fn drizzle(ctx: &EffectCtx, mut b: Buf) -> Buf {
let pr = ctx.params;
let Some(drops) = drizzle_drops(ctx, &b) else { return b };
let rippling = (pr.f("rippling") / 360.0).max(0.05) as f32;
let disp = pr.f("displacement") as f32;
let li = pr.f("light/lightIntensity") as f32 / 100.0;
let lc = pr.color("light/lightColor");
let lheight = pr.f("light/lightHeight") as f32 / 100.0;
let ldir = (pr.f("light/lightDirection") as f32).to_radians();
let ambient = pr.f("shading/ambient") as f32 / 100.0;
let diffuse = pr.f("shading/diffuse") as f32 / 100.0;
let specular = pr.f("shading/specular") as f32 / 100.0;
let rough = pr.f("shading/roughness").max(0.001) as f32;
let lh = ctx.layer_size[1] as f32;
let s = b.scale as f32;
let wavelen = (lh * 0.04 * s).max(2.0) / rippling.clamp(0.25, 4.0);
let width = wavelen * (1.0 + rippling * 2.0);
let field = |x: f32, y: f32| -> f32 {
let mut v = 0.0;
for &[dx, dy, r, a] in &drops {
let d = ((x - dx).powi(2) + (y - dy).powi(2)).sqrt();
let u = (d - r) / width;
if u.abs() < 1.0 {
let env = 0.5 + 0.5 * (u * std::f32::consts::PI).cos();
v += a * env * ((d - r) / wavelen * std::f32::consts::TAU).cos();
}
}
v
};
let src = b.img.clone();
let (ls, lcs) = ldir.sin_cos();
let l = {
let v = [lcs, ls, lheight.max(0.05)];
let n = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
[v[0] / n, v[1] / n, v[2] / n]
};
b.img.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let (fx, fy) = (x as f32 + 0.5, y as f32 + 0.5);
let gx = field(fx + 1.0, fy) - field(fx - 1.0, fy);
let gy = field(fx, fy + 1.0) - field(fx, fy - 1.0);
if gx == 0.0 && gy == 0.0 {
continue;
}
let p = src.sample_bilinear_clamped((fx - gx * disp * s * 4.0) as f64, (fy - gy * disp * s * 4.0) as f64);
let n = {
let v = [-gx * 6.0, -gy * 6.0, 1.0];
let m = (v[0] * v[0] + v[1] * v[1] + 1.0).sqrt();
[v[0] / m, v[1] / m, v[2] / m]
};
let nl = (n[0] * l[0] + n[1] * l[1] + n[2] * l[2]).max(0.0);
let hz = (l[2] + 1.0) * 0.5;
let hv = {
let v = [l[0] * 0.5, l[1] * 0.5, hz];
let m = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
[v[0] / m, v[1] / m, v[2] / m]
};
let sp = (n[0] * hv[0] + n[1] * hv[1] + n[2] * hv[2]).max(0.0).powf(1.0 / rough) * specular * li;
let shade = 1.0 + (ambient + diffuse * nl - diffuse * l[2]) * li;
let a = p[3];
*px = [p[0] * shade + sp * lc[0] * a, p[1] * shade + sp * lc[1] * a, p[2] * shade + sp * lc[2] * a, a];
}
});
b
}
// ------------------------------------------------------------------ CC Star Burst
fn star_burst(ctx: &EffectCtx, b: Buf) -> Buf {
let mut plan = star_burst_plan(ctx, &b);
plan.resolve(&b.img);
plan.finish(b)
}
fn star_burst_plan(ctx: &EffectCtx, b: &Buf) -> SpritePlan {
let pr = ctx.params;
let scatter = pr.f("scatter") as f32;
let speed = pr.f("speed") as f32;
let phase = pr.f("phase") as f32 / 360.0;
let spacing = pr.f("gridSpacing").max(1.0) as f32;
let size = pr.f("size") as f32 / 100.0;
let blend = pr.f("blendWithOriginal") as f32 / 100.0;
let seed = ctx.seed.wrapping_mul(0x3c6e);
let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32);
let s = b.scale as f32;
let t = ctx.time as f32;
let nx = (lw / spacing).ceil().max(1.0) as u32;
let ny = (lh / spacing).ceil().max(1.0) as u32;
let (cx, cy) = (lw * 0.5, lh * 0.5);
let mut list: Vec<(f32, Sprite, Tint)> = (0..nx * ny)
.into_par_iter()
.map(|i| {
let (gx, gy) = ((i % nx) as f32 * spacing + spacing * 0.5, (i / nx) as f32 * spacing + spacing * 0.5);
let (sx, sy) = b.to_px([gx as f64, gy as f64]);
// Depth cycles towards the viewer: z in [0, 1), perspective 1 / (1 - 0.9 z).
let z = (h(i, 1, seed) + t * speed * 0.25 + phase).rem_euclid(1.0);
let persp = 1.0 / (1.0 - 0.9 * z);
let jx = hs(i, 2, seed) * scatter * 0.01 * lw * 0.5;
let jy = hs(i, 3, seed) * scatter * 0.01 * lh * 0.5;
let x = cx + (gx - cx + jx) * persp;
let y = cy + (gy - cy + jy) * persp;
let (bx, by) = b.to_px([x as f64, y as f64]);
let r = spacing * 0.5 * size * persp * s;
let fade = (z * 8.0).min(1.0) * ((1.0 - z) * 6.0).min(1.0);
// The layer's colour at the grid point (none where it is clear).
(z, Sprite::new(bx as f32, by as f32, r, [0.0, 0.0, 0.0, fade], Shape::Disc), Tint::Source { x: sx, y: sy })
})
.collect();
list.sort_by(|a, b| a.0.total_cmp(&b.0));
let (sprites, tints) = list.into_iter().map(|(_, s, t)| (s, t)).unzip();
SpritePlan { sprites, tints, acc: Acc::Over, post: if blend > 0.0 { Post::Lerp(blend) } else { Post::Replace } }
}
// ------------------------------------------------------------------ particle engine
/// Steps per second of the stepped particle simulations.
pub(crate) const SPS: f64 = 60.0;
/// Number of whole steps reached at layer time `t`.
pub(crate) fn steps_at(t: f64) -> u64 {
if t <= 0.0 { 0 } else { (t * SPS + 1e-6).floor() as u64 }
}
#[derive(Clone, Debug)]
pub(crate) struct Particle {
pub p: [f32; 3],
pub v: [f32; 3],
pub age: f32,
pub life: f32,
/// Per-particle random in [0, 1) (size variation, colour jitter…).
pub rnd: f32,
pub id: u32,
}
#[derive(Clone, Debug, Default)]
pub(crate) struct PState {
pub parts: Vec<Particle>,
pub carry: f64,
pub next_id: u32,
/// The birth rates of the steps so far (see [`rates_hash`]): a checkpoint is only resumed
/// from with the same history.
pub hist: u64,
}
/// `hist` after one more step at `rate` particles per second.
fn rates_hash(hist: u64, rate: f64) -> u64 {
(hist ^ rate.to_bits()).wrapping_mul(0x0100_0000_01b3).rotate_left(5)
}
/// Physics configuration (world units; y down).
#[derive(Clone, Copy, Debug)]
pub(crate) struct Phys {
/// Particles per second.
pub rate: f64,
pub life: f32,
pub producer: [f32; 3],
pub radius: [f32; 3],
pub anim: u32,
pub speed: f32,
pub gravity: [f32; 3],
pub resistance: f32,
pub axis: [f32; 3],
pub extra: f32,
pub extra_angle: f32,
pub seed: u32,
pub max: usize,
/// 2D systems keep z = 0.
pub flat: bool,
}
pub(crate) const PW_ANIMS: &[&str] =
&["Explosive", "Direction Axis", "Cone Axis", "Viscouse", "Twirly", "Vortex", "Fire", "Jet Sideways", "Fractal Omni", "Fractal Uni"];
fn norm3(v: [f32; 3]) -> [f32; 3] {
let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
if l > 1e-9 { [v[0] / l, v[1] / l, v[2] / l] } else { [0.0, -1.0, 0.0] }
}
fn rand_dir(i: u32, k: u32, seed: u32, flat: bool) -> [f32; 3] {
if flat {
let a = h(i, k, seed) * std::f32::consts::TAU;
return [a.cos(), a.sin(), 0.0];
}
let z = hs(i, k, seed);
let a = h(i, k + 1, seed) * std::f32::consts::TAU;
let r = (1.0 - z * z).max(0.0).sqrt();
[r * a.cos(), r * a.sin(), z]
}
impl Phys {
fn spawn(&self, id: u32) -> Particle {
let seed = self.seed;
let mut p = [0.0f32; 3];
// Uniform in the producer ellipsoid.
let u = rand_dir(id, 1, seed, self.flat);
let rr = h(id, 3, seed).powf(if self.flat { 0.5 } else { 1.0 / 3.0 });
for k in 0..3 {
p[k] = self.producer[k] + u[k] * self.radius[k] * rr;
}
if self.flat {
p[2] = 0.0;
}
let ax = norm3(self.axis);
let jitter = rand_dir(id, 5, seed, self.flat);
let dir = match self.anim {
1 | 9 => norm3([ax[0] + jitter[0] * 0.08, ax[1] + jitter[1] * 0.08, ax[2] + jitter[2] * 0.08]),
2 => {
let k = (self.extra_angle.to_radians() * 0.5).tan().abs().min(20.0);
norm3([ax[0] + jitter[0] * k * h(id, 8, seed), ax[1] + jitter[1] * k * h(id, 8, seed), ax[2] + jitter[2] * k * h(id, 8, seed)])
}
5 => [jitter[0], 0.0, if self.flat { 0.0 } else { jitter[2] }],
6 => norm3([jitter[0] * 0.25, -1.0, jitter[2] * 0.25]),
7 => norm3([1.0, jitter[1] * 0.05, jitter[2] * 0.05]),
_ => jitter,
};
let sp = self.speed * (0.5 + 0.5 * h(id, 9, seed));
let mut v = [dir[0] * sp, dir[1] * sp, dir[2] * sp];
if self.flat {
v[2] = 0.0;
}
let life = self.life * (0.8 + 0.4 * h(id, 10, seed));
Particle { p, v, age: 0.0, life, rnd: h(id, 11, seed), id }
}
/// One step at the constant [`Phys::rate`] (the tests' reference).
#[cfg(test)]
pub(crate) fn step(&self, st: &mut PState, step: u64) {
self.step_at_rate(st, step, self.rate);
}
/// One step with `rate` particles per second born during it (rather than [`Phys::rate`]):
/// the rate only decides the births, particles already alive move and age as ever.
pub(crate) fn step_at_rate(&self, st: &mut PState, step: u64, rate: f64) {
let dt = (1.0 / SPS) as f32;
let t = step as f32 * dt;
let mut drag = self.resistance;
if self.anim == 3 {
drag += 3.0 * self.extra.max(0.1);
}
let damp = (-drag * dt).exp();
let seed = self.seed;
st.parts.par_iter_mut().for_each(|q| {
let mut a = self.gravity;
match self.anim {
4 => {
// Twirly: swirl around the vertical axis through the producer.
let (dx, dz) = (q.p[0] - self.producer[0], q.p[2] - self.producer[2]);
let k = 4.0 * self.extra;
a[0] += -dz * k;
a[2] += dx * k;
if self.flat {
let dy = q.p[1] - self.producer[1];
a[0] += -dy * k;
a[1] += dx * k;
}
}
5 => {
// Vortex: strong tangential + inward pull.
let (dx, dy) = (q.p[0] - self.producer[0], q.p[1] - self.producer[1]);
let k = 6.0 * self.extra;
a[0] += -dy * k - dx * 2.0;
a[1] += dx * k - dy * 2.0;
}
6 => {
// Fire: buoyant, flickering turbulence.
let n = value_noise(q.p[0] * 8.0, q.p[1] * 8.0, t * 3.0, seed) - 0.5;
a[0] += n * 4.0 * self.extra;
a[1] -= self.gravity[1] * 1.5 + 0.3 * self.extra;
}
8 | 9 => {
let f = 6.0 * self.extra;
a[0] += (value_noise(q.p[0] * 5.0, q.p[1] * 5.0, q.p[2] * 5.0 + t, seed) - 0.5) * f;
a[1] += (value_noise(q.p[0] * 5.0 + 17.0, q.p[1] * 5.0, q.p[2] * 5.0 + t, seed) - 0.5) * f;
if !self.flat {
a[2] += (value_noise(q.p[0] * 5.0, q.p[1] * 5.0 + 31.0, q.p[2] * 5.0 + t, seed) - 0.5) * f;
}
}
_ => {}
}
for k in 0..3 {
q.v[k] = (q.v[k] + a[k] * dt) * damp;
q.p[k] += q.v[k] * dt;
}
if self.flat {
q.p[2] = 0.0;
q.v[2] = 0.0;
}
q.age += dt;
});
st.parts.retain(|q| q.age < q.life);
st.hist = rates_hash(st.hist, rate);
st.carry += rate / SPS;
let n = st.carry.floor() as u32;
st.carry -= n as f64;
for _ in 0..n {
if st.parts.len() >= self.max {
break;
}
let id = st.next_id;
st.next_id = st.next_id.wrapping_add(1);
// Sub-step birth offset keeps continuous streams smooth.
let mut q = self.spawn(id);
let frac = h(id, 12, seed) * dt;
for k in 0..3 {
q.p[k] += q.v[k] * frac;
}
q.age = frac;
st.parts.push(q);
}
}
}
impl Phys {
/// The backend description (per-step and per-spawn constants evaluated here, in f32).
pub(crate) fn desc(&self) -> crate::psim::EngineDesc {
let mut drag = self.resistance;
if self.anim == 3 {
drag += 3.0 * self.extra.max(0.1);
}
let dt = (1.0 / SPS) as f32;
crate::psim::EngineDesc {
life: self.life,
producer: self.producer,
radius: self.radius,
anim: self.anim,
speed: self.speed,
gravity: self.gravity,
axis: norm3(self.axis),
extra: self.extra,
cone: (self.extra_angle.to_radians() * 0.5).tan().abs().min(20.0),
damp: (-drag * dt).exp(),
seed: self.seed,
flat: self.flat,
}
}
}
/// The state at layer time `ctx.time` from the host's particle backend (GPU particles), or
/// `None` (simulate on the CPU). `rates`: the birth rate of each step, when it changes over time
/// (else [`Phys::rate`]).
fn accelerated(ctx: &EffectCtx, key: u64, phys: &Phys, rates: Option<&[f64]>) -> Option<std::sync::Arc<PState>> {
let backend = ctx.env.host?.particles()?;
let steps = steps_at(ctx.time);
let births = match rates {
Some(r) => crate::psim::births_at(steps, phys.max, |i| r.get(i as usize).copied().unwrap_or(0.0))?,
None => crate::psim::births(phys.rate, steps, phys.max)?,
};
let req = crate::psim::SimRequest { key, steps, births: &births, system: crate::psim::ParticleSystem::Engine(phys.desc()) };
let parts = backend.simulate(&req)?;
Some(std::sync::Arc::new(PState {
parts: parts.into_iter().map(|q| Particle { p: q.p, v: q.v, age: q.age, life: q.life, rnd: q.rnd, id: q.id }).collect(),
carry: 0.0,
next_id: births.len() as u32,
hist: 0,
}))
}
/// The birth rate (particles per second) of each step up to layer time `ctx.time`: parameter
/// `id` × `k`, sampled where the step starts (a keyframed or expression-driven Birth Rate only
/// decides the births of its own time, #1373). Without a host that evaluates the effect at
/// other times, the current value for every step.
fn rate_history(ctx: &EffectCtx, id: &str, k: f64) -> Vec<f64> {
let now = ctx.params.f(id);
// A rate without keyframes or an expression is the same at every step.
let host = ctx.env.host.filter(|h| !h.param_is_static(ctx.env.effect_index, id));
(0..steps_at(ctx.time))
.map(|i| {
let v = host.and_then(|h| h.param_at(ctx.env.effect_index, id, i as f64 / SPS)).map_or(now, |v| v.as_f64());
if v.is_finite() { v.max(0.0) * k } else { 0.0 }
})
.collect()
}
/// Run a particle system through `rates` (one birth rate per step, see [`rate_history`]) with
/// `cache`, resuming only from checkpoints reached with the same rates.
fn simulate_rates(cache: &SimCache<PState>, key: u64, phys: &Phys, rates: &[f64]) -> std::sync::Arc<PState> {
// `hist` of the state after each number of steps.
let mut prefix = Vec::with_capacity(rates.len() + 1);
prefix.push(0u64);
for r in rates {
prefix.push(rates_hash(prefix.last().copied().unwrap_or(0), *r));
}
let rate = |i: u64| rates.get(i as usize).copied().unwrap_or(0.0);
cache.run_where(key, rates.len() as u64, PState::default, |st, i| phys.step_at_rate(st, i, rate(i)), |s, st| prefix.get(s as usize) == Some(&st.hist))
}
/// A stepped particle system's particles at `ctx.time`, born at the Birth Rate of their own
/// time (`birthRate` × `k` particles per second, see [`rate_history`]). The cache key is every
/// parameter but Birth Rate (its history is checked through `PState::hist`) and those under
/// `skip`, with `salt`. `gpu`: the host's particle backend may simulate it.
struct Stepped<'a> {
cache: &'a SimCache<PState>,
salt: u64,
skip: &'a [&'a str],
k: f64,
gpu: bool,
}
impl Stepped<'_> {
fn state(&self, ctx: &EffectCtx, phys: &Phys) -> std::sync::Arc<PState> {
let rates = rate_history(ctx, "birthRate", self.k);
let mut skip = self.skip.to_vec();
skip.push("birthRate");
let key = params_key_except(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, self.salt, &skip);
let gpu = if self.gpu { accelerated(ctx, key, phys, Some(&rates)) } else { None };
gpu.unwrap_or_else(|| simulate_rates(self.cache, key, phys, &rates))
}
}
/// CC Particle World.
const PW: Stepped = Stepped { cache: &PW_CACHE, salt: 1, skip: &[PW_EFFECT_CAMERA], k: PW_RATE, gpu: true };
/// CC Particle Systems II.
const PS2: Stepped = Stepped { cache: &PS2_CACHE, salt: 2, skip: &[], k: PS2_RATE, gpu: true };
/// CC Mr. Mercury (its blobs are simulated on the CPU).
const MERCURY: Stepped = Stepped { cache: &MERCURY_CACHE, salt: 3, skip: &[], k: MERCURY_RATE, gpu: false };
/// CC Particle World's particles at `ctx.time`, born at the Birth Rate of their own time.
fn pw_state(ctx: &EffectCtx, phys: &Phys) -> std::sync::Arc<PState> {
PW.state(ctx, phys)
}
/// The live particles of a stepped particle effect (`ec.sim.ccparticleworld`,
/// `ec.sim.ccparticlesystems2`) at `ctx.time`: from the host's particle backend when it has
/// one (GPU particles), else from the CPU simulation. For comparing the two.
pub fn particle_state(id: &str, ctx: &EffectCtx) -> Option<Vec<crate::psim::SimParticle>> {
let st = match id {
"ec.sim.ccparticleworld" => pw_state(ctx, &pw_phys(ctx)),
"ec.sim.ccparticlesystems2" => PS2.state(ctx, &ps2_phys(ctx)),
_ => return None,
};
Some(st.parts.iter().map(|q| crate::psim::SimParticle { p: q.p, v: q.v, age: q.age, life: q.life, rnd: q.rnd, id: q.id }).collect())
}
/// The simulation cache key of a stepped particle effect at a constant Birth Rate (the tests'
/// reference): every parameter but CC Particle World's Effect Camera.
#[cfg(test)]
fn sim_key(ctx: &EffectCtx, salt: u64) -> u64 {
params_key_except(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, salt, &[PW_EFFECT_CAMERA])
}
/// Run a particle system at its constant [`Phys::rate`] to layer time `t` through `cache` (the
/// tests' reference).
#[cfg(test)]
pub(crate) fn simulate(cache: &SimCache<PState>, key: u64, t: f64, phys: &Phys) -> std::sync::Arc<PState> {
cache.run(key, steps_at(t), PState::default, |st, i| phys.step(st, i))
}
pub(crate) const PARTICLE_TYPES: &[&str] =
&["Line", "Shaded Sphere", "Faded Sphere", "Bubble", "Star", "Motion Polygon", "Tri-Polygon", "Cube", "Textured Square"];
/// Colour/size/opacity of a particle at its age.
fn particle_look(q: &Particle, birth: [f32; 4], death: [f32; 4], bsize: f32, dsize: f32, svar: f32, max_op: f32) -> ([f32; 4], f32) {
let u = (q.age / q.life.max(1e-4)).clamp(0.0, 1.0);
let size = (bsize + (dsize - bsize) * u) * (1.0 + svar * (q.rnd * 2.0 - 1.0));
let fade = (u * 20.0).min(1.0) * ((1.0 - u) * 5.0).min(1.0);
let c = [birth[0] + (death[0] - birth[0]) * u, birth[1] + (death[1] - birth[1]) * u, birth[2] + (death[2] - birth[2]) * u, max_op * fade];
(c, size.max(0.0))
}
fn type_sprite(kind: u32, x: f32, y: f32, r: f32, c: [f32; 4], vx: f32, vy: f32, rot: f32) -> Sprite {
let vl = (vx * vx + vy * vy).sqrt().max(1e-6);
let shape = match kind {
0 => Shape::Line { dx: -vx / vl * r * 2.0, dy: -vy / vl * r * 2.0 },
1 => Shape::Sphere,
2 => Shape::Faded,
3 => Shape::Bubble,
4 => Shape::Star,
5 => Shape::Line { dx: -vx * 0.05, dy: -vy * 0.05 },
6 => Shape::Tri,
_ => Shape::Square,
};
let rr = match kind {
0 => (r * 0.08).max(0.5),
5 => r * 0.5,
_ => r,
};
Sprite { x, y, r: rr, c, shape, rot }
}
// ------------------------------------------------------------------ CC Particle World
static PW_CACHE: SimCache<PState> = SimCache::new(6);
/// Particles per second per unit of CC Particle World's Birth Rate.
const PW_RATE: f64 = 120.0;
/// CC Particle World's physics.
fn pw_phys(ctx: &EffectCtx) -> Phys {
let pr = ctx.params;
Phys {
rate: pr.f("birthRate").max(0.0) * PW_RATE,
life: pr.f("longevity").max(0.0) as f32,
producer: [pr.f("producer/producerX") as f32, pr.f("producer/producerY") as f32, pr.f("producer/producerZ") as f32],
radius: [pr.f("producer/radiusX").max(0.0) as f32, pr.f("producer/radiusY").max(0.0) as f32, pr.f("producer/radiusZ").max(0.0) as f32],
anim: pr.e("physics/animation"),
speed: pr.f("physics/velocity") as f32 * 0.5,
gravity: {
let g = pr.f("physics/gravity") as f32;
let gv =
[pr.f("physics/gravityVector/gravityX") as f32, pr.f("physics/gravityVector/gravityY") as f32, pr.f("physics/gravityVector/gravityZ") as f32];
[gv[0] * g, gv[1] * g, gv[2] * g]
},
resistance: pr.f("physics/resistance").max(0.0) as f32,
axis: [pr.f("physics/directionAxis/axisX") as f32, pr.f("physics/directionAxis/axisY") as f32, pr.f("physics/directionAxis/axisZ") as f32],
extra: pr.f("physics/extra") as f32,
extra_angle: pr.f("physics/extraAngle") as f32,
seed: (pr.f("extras/randomSeed") as u32).wrapping_mul(0x9e3779b9) ^ ctx.seed,
max: 40_000,
flat: false,
}
}
fn particle_world(ctx: &EffectCtx, b: Buf) -> Buf {
match particle_world_plan(ctx, &b) {
Some(plan) => plan.finish(b),
None => b,
}
}
/// CC Particle World's Extras ▸ Effect Camera parameters (id prefix).
const PW_EFFECT_CAMERA: &str = "extras/effectCamera/";
/// Effect Camera ▸ FOV's default (degrees).
const PW_FOV: f64 = 50.0;
/// What CC Particle World sees its particles through.
enum PwCamera {
/// Extras ▸ Effect Camera, in world units (layer widths): `dist` from the world origin (the
/// layer centre) along −z, the world turned by `rot` (`None` = not turned) about the origin,
/// `focal` the scale at unit depth; particles nearer than `near` aren't drawn.
Effect { rot: Option<[[f32; 3]; 3]>, dist: f32, focal: f32, near: f32 },
/// The comp's active camera layer, on buffer-world points (After Effects then ignores the
/// Effect Camera).
Comp(crate::card3d::Proj),
}
/// A particle as the camera sees it: depth (larger is farther), buffer pixel, size scale `k`
/// (buffer pixels per world unit = `k · layer width · buffer scale`) and velocity in world units
/// on screen (× the same factor for buffer pixels).
struct PwSeen {
depth: f32,
x: f32,
y: f32,
k: f32,
v: [f32; 2],
}
impl PwCamera {
fn new(ctx: &EffectCtx, b: &Buf) -> PwCamera {
let [lw, lh] = ctx.layer_size;
if let Some(cam) = ctx.env.host.and_then(|h| h.comp_scene()).filter(|sc| sc.camera_layer).and_then(|sc| sc.camera) {
let c = [b.offset[0] + lw * 0.5 * b.scale, b.offset[1] + lh * 0.5 * b.scale];
return PwCamera::Comp(crate::card3d::comp_proj(&cam, c, b));
}
let f = |id: &str, d: f64| ctx.params.get(&format!("{PW_EFFECT_CAMERA}{id}")).map(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(d);
// The camera keeps the origin's plane at the scale Distance gives it as FOV changes
// (moving in for a wider view), so FOV sets how strong the perspective is. Distance 2
// shows the origin's plane at the layer's own scale.
let half = |fov: f64| (fov.clamp(1.0, 179.0).to_radians() * 0.5).tan();
let zoom = half(PW_FOV) / half(f("fov", PW_FOV));
let dist = f("distance", 2.0).max(0.01) * zoom;
let a = [f("rotationX", 0.0), f("rotationY", 0.0), f("rotationZ", 0.0)];
let rot = (a != [0.0; 3]).then(|| {
let r = crate::card3d::rot_ordered(a[0].to_radians(), a[1].to_radians(), a[2].to_radians(), 0);
r.map(|row| row.map(|v| v as f32))
});
PwCamera::Effect { rot, dist: dist as f32, focal: (2.0 * zoom) as f32, near: (0.025 * dist) as f32 }
}
/// How the camera sees world point `p` moving at `v`; `None` behind or too close to it.
fn see(&self, p: [f32; 3], v: [f32; 3], lw: f32, lh: f32, b: &Buf) -> Option<PwSeen> {
match self {
PwCamera::Effect { rot, dist, focal, near } => {
let turn = |r: &[[f32; 3]; 3], q: [f32; 3]| r.map(|row| row[0] * q[0] + row[1] * q[1] + row[2] * q[2]);
let (p, v) = match rot {
Some(r) => (turn(r, p), turn(r, v)),
None => (p, v),
};
let zc = dist + p[2];
if zc.is_nan() || zc <= *near {
return None;
}
let k = focal / zc;
let (x, y) = b.to_px([(lw * 0.5 + p[0] * lw * k) as f64, (lh * 0.5 + p[1] * lw * k) as f64]);
Some(PwSeen { depth: zc, x: x as f32, y: y as f32, k, v: [v[0], v[1]] })
}
PwCamera::Comp(proj) => {
let (s, lw, lh) = (b.scale, lw as f64, lh as f64);
let u = lw * s;
let at = [b.offset[0] + lw * 0.5 * s + p[0] as f64 * u, b.offset[1] + lh * 0.5 * s + p[1] as f64 * u, p[2] as f64 * u];
let seen = proj.view(at, [v[0] as f64, v[1] as f64, v[2] as f64])?;
let k = seen.scale;
Some(PwSeen {
depth: seen.depth as f32,
x: seen.at[0] as f32,
y: seen.at[1] as f32,
k: k as f32,
v: [(seen.vel[0] / k) as f32, (seen.vel[1] / k) as f32],
})
}
}
}
}
/// CC Particle World's sprites (far to near), `None` before anything is born.
fn particle_world_plan(ctx: &EffectCtx, b: &Buf) -> Option<SpritePlan> {
let pr = ctx.params;
let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32);
let phys = pw_phys(ctx);
if phys.rate <= 0.0 && ctx.time <= 0.0 {
return None;
}
let st = pw_state(ctx, &phys);
let kind = pr.e("particle/particleType");
let birth = pr.color("particle/birthColor");
let death = pr.color("particle/deathColor");
let (bs, ds) = (pr.f("particle/birthSize") as f32, pr.f("particle/deathSize") as f32);
let svar = pr.f("particle/sizeVariation") as f32 / 100.0;
let max_op = pr.f("particle/maxOpacity") as f32 / 100.0;
let s = b.scale as f32;
let cam = PwCamera::new(ctx, b);
let mut list: Vec<(f32, Sprite)> = st
.parts
.par_iter()
.filter_map(|q| {
let PwSeen { depth, x, y, k, v } = cam.see(q.p, q.v, lw, lh, b)?;
let (c, size) = particle_look(q, birth, death, bs, ds, svar, max_op);
let r = size * 0.5 * lw * k * s * 0.25;
let sp = type_sprite(kind, x, y, r, c, v[0] * lw * k * s, v[1] * lw * k * s, (q.id % 628) as f32 * 0.01 + q.age * 2.0);
Some((depth, sp))
})
.collect();
list.sort_by(|a, b| b.0.total_cmp(&a.0));
let sprites: Vec<Sprite> = list.into_iter().map(|(_, s)| s).collect();
Some(particle_sprites(sprites, pr.e("particle/transferMode")))
}
/// The CC particle systems' sprites with their Transfer Mode (Composite, Screen, Add, Black
/// Background).
fn particle_sprites(sprites: Vec<Sprite>, mode: u32) -> SpritePlan {
// The particles replace the layer (After Effects shows no trace of the source in any
// Transfer Mode, which sets how the particles meet each other).
SpritePlan { sprites, tints: vec![], acc: if mode == 2 { Acc::Add } else { Acc::Over }, post: Post::Replace }
}
// ------------------------------------------------------------------ CC Particle Systems II
static PS2_CACHE: SimCache<PState> = SimCache::new(6);
/// Particles per second per unit of CC Particle Systems II's Birth Rate.
const PS2_RATE: f64 = 60.0;
/// CC Particle Systems II's physics.
fn ps2_phys(ctx: &EffectCtx) -> Phys {
let pr = ctx.params;
let lh = ctx.layer_size[1] as f32;
let unit = lh.max(1.0);
let pos = pr.v2("producer/position");
let dir = (pr.f("physics/direction") as f32).to_radians();
Phys {
rate: pr.f("birthRate").max(0.0) * PS2_RATE,
life: pr.f("longevity").max(0.0) as f32,
producer: [pos[0] as f32 / unit, pos[1] as f32 / unit, 0.0],
radius: [pr.f("producer/radiusX").max(0.0) as f32 / unit, pr.f("producer/radiusY").max(0.0) as f32 / unit, 0.0],
anim: [0u32, 1, 2, 3, 4, 5, 6, 7, 8, 9][pr.e("physics/animation").min(9) as usize],
speed: pr.f("physics/velocity") as f32 * 0.25,
gravity: [0.0, pr.f("physics/gravity") as f32 * 0.5, 0.0],
resistance: pr.f("physics/resistance").max(0.0) as f32,
// AE angles: 0° = up, clockwise.
axis: [dir.sin(), -dir.cos(), 0.0],
extra: pr.f("physics/extra") as f32,
extra_angle: 45.0 * pr.f("physics/extra") as f32,
seed: (pr.f("randomSeed") as u32).wrapping_mul(0x85ebca6b) ^ ctx.seed,
max: 40_000,
flat: true,
}
}
fn particle_systems2(ctx: &EffectCtx, b: Buf) -> Buf {
particle_systems2_plan(ctx, &b).finish(b)
}
/// CC Particle Systems II's sprites.
fn particle_systems2_plan(ctx: &EffectCtx, b: &Buf) -> SpritePlan {
let pr = ctx.params;
let lh = ctx.layer_size[1] as f32;
let unit = lh.max(1.0);
let st = PS2.state(ctx, &ps2_phys(ctx));
let kind = pr.e("particle/particleType");
let birth = pr.color("particle/birthColor");
let death = pr.color("particle/deathColor");
let (bs, ds) = (pr.f("particle/birthSize") as f32, pr.f("particle/deathSize") as f32);
let svar = pr.f("particle/sizeVariation") as f32 / 100.0;
let max_op = pr.f("particle/maxOpacity") as f32 / 100.0;
let s = b.scale as f32;
let sprites: Vec<Sprite> = st
.parts
.iter()
.map(|q| {
let (bx, by) = b.to_px([(q.p[0] * unit) as f64, (q.p[1] * unit) as f64]);
let (c, size) = particle_look(q, birth, death, bs, ds, svar, max_op);
let r = size * unit * 0.02 * s;
type_sprite(kind, bx as f32, by as f32, r, c, q.v[0] * unit * s, q.v[1] * unit * s, (q.id % 628) as f32 * 0.01 + q.age * 2.0)
})
.collect();
particle_sprites(sprites, pr.e("particle/transferMode"))
}
// ------------------------------------------------------------------ CC Mr. Mercury
static MERCURY_CACHE: SimCache<PState> = SimCache::new(6);
/// Blobs per second per unit of CC Mr. Mercury's Birth Rate.
const MERCURY_RATE: f64 = 10.0;
/// A CC Mr. Mercury blob (buffer px).
#[derive(Clone, Copy, Debug)]
pub struct Blob {
pub x: f32,
pub y: f32,
pub r: f32,
}
/// CC Mr. Mercury's physics.
fn mercury_phys(ctx: &EffectCtx) -> Phys {
let pr = ctx.params;
let unit = (ctx.layer_size[1] as f32).max(1.0);
let pos = pr.v2("producer");
let dir = (pr.f("direction") as f32).to_radians();
Phys {
rate: pr.f("birthRate").max(0.0) * MERCURY_RATE,
life: pr.f("longevity").max(0.0) as f32,
producer: [pos[0] as f32 / unit, pos[1] as f32 / unit, 0.0],
radius: [pr.f("radiusX").max(0.0) as f32 / unit, pr.f("radiusY").max(0.0) as f32 / unit, 0.0],
anim: [0u32, 1, 2, 3, 4, 5, 6, 7, 8, 9][pr.e("animation").min(9) as usize],
speed: pr.f("velocity") as f32 * 0.3,
gravity: [0.0, pr.f("gravity") as f32 * 0.5, 0.0],
resistance: pr.f("resistance").max(0.0) as f32,
axis: [dir.sin(), -dir.cos(), 0.0],
extra: pr.f("extra") as f32,
extra_angle: 45.0 * pr.f("extra") as f32,
seed: ctx.seed.wrapping_mul(0xc2b2ae35),
max: 4_000,
flat: true,
}
}
/// CC Mr. Mercury's blobs this frame, from its particle simulation (for buffer geometry `b`).
pub fn mercury_blobs(ctx: &EffectCtx, b: &Buf) -> Vec<Blob> {
let pr = ctx.params;
let unit = (ctx.layer_size[1] as f32).max(1.0);
let phys = mercury_phys(ctx);
let st = MERCURY.state(ctx, &phys);
let (bs, ds) = (pr.f("blobBirthSize") as f32, pr.f("blobDeathSize") as f32);
let s = b.scale as f32;
st.parts
.iter()
.map(|q| {
let u = (q.age / q.life.max(1e-4)).clamp(0.0, 1.0);
let size = bs + (ds - bs) * u;
let (bx, by) = b.to_px([(q.p[0] * unit) as f64, (q.p[1] * unit) as f64]);
Blob { x: bx as f32, y: by as f32, r: (size * unit * 0.08 * s).max(0.0) }
})
.filter(|q| q.r > 0.1)
.collect()
}
fn mr_mercury(ctx: &EffectCtx, mut b: Buf) -> Buf {
let pr = ctx.params;
let blobs = mercury_blobs(ctx, &b);
let influence = pr.f("blobInfluence") as f32 / 100.0;
let s = b.scale as f32;
let (w, hh) = (b.img.width, b.img.height);
// Field and gradient accumulate additively: [f, gx, gy, _].
let mut field = Image::new(w, hh);
let infl = 1.0 + influence;
raster(
&mut field,
&blobs,
|q| {
let e = q.r * 2.0 * infl;
Some([q.x - e, q.y - e, q.x + e, q.y + e])
},
|q, x, y| {
let (dx, dy) = (x - q.x, y - q.y);
let rr = q.r * 2.0 * infl;
let d2 = (dx * dx + dy * dy) / (rr * rr);
if d2 >= 1.0 {
return None;
}
let k = 1.0 - d2;
let f = k * k * k;
let g = -6.0 * k * k / (rr * rr);
Some([f, g * dx, g * dy, 0.0])
},
Acc::Add,
);
let thr = 0.35f32;
let ambient = pr.f("shading/ambient") as f32 / 100.0;
let diffuse = pr.f("shading/diffuse") as f32 / 100.0;
let specular = pr.f("shading/specular") as f32 / 100.0;
let rough = pr.f("shading/roughness").max(0.001) as f32;
let metal = pr.f("shading/metal") as f32 / 100.0;
let li = pr.f("light/lightIntensity") as f32 / 100.0;
let lc = pr.color("light/lightColor");
let ldir = (pr.f("light/lightDirection") as f32).to_radians();
let lheight = pr.f("light/lightHeight") as f32 / 100.0;
let l = norm3([ldir.sin(), -ldir.cos(), lheight.max(0.05)]);
let src = b.img.clone();
let mut out = Image::new(w, hh);
out.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let fv = field.data[y * w as usize + x];
let f = fv[0];
if f <= thr * 0.8 {
continue;
}
let cov = ((f - thr * 0.8) / (thr * 0.4)).clamp(0.0, 1.0);
let n = norm3([fv[1] * 3.0, fv[2] * 3.0, 1.0 / (1.0 + f)]);
// Refract the layer through the blob.
let sp = src.sample_bilinear_clamped(x as f64 + 0.5 - n[0] as f64 * 12.0 * s as f64, y as f64 + 0.5 - n[1] as f64 * 12.0 * s as f64);
let (c, sa) = unpremul(sp);
let base = if sa > 0.0 { c } else { [0.7, 0.7, 0.75] };
let nl = (n[0] * l[0] + n[1] * l[1] + n[2] * l[2]).max(0.0);
let hv = norm3([l[0], l[1], l[2] + 1.0]);
let spec = (n[0] * hv[0] + n[1] * hv[1] + n[2] * hv[2]).max(0.0).powf(1.0 / rough) * specular * li;
let shade = ambient + diffuse * nl * li;
let cc: [f32; 3] = std::array::from_fn(|k| base[k] * shade + spec * (lc[k] * (1.0 - metal) + base[k] * metal));
*px = [cc[0] * cov, cc[1] * cov, cc[2] * cov, cov];
}
});
b.img = out;
b
}
// ------------------------------------------------------------------ specs
pub fn specs() -> Vec<EffectSpec> {
let transfer = || popup(&["Composite", "Lighten"]);
vec![
spec(
"ec.sim.ccrainfall",
"CC Rainfall",
vec![
p("drops", "Drops", num(5000.0), slider(0.0, 100_000.0, 0.0, 20_000.0, 0)),
p("size", "Size", num(1.0), slider(0.0, 50.0, 0.0, 10.0, 2)),
p("sceneDepth", "Scene Depth", num(5000.0), slider(0.0, 20_000.0, 0.0, 10_000.0, 0)),
p("speed", "Speed", num(4000.0), slider(0.0, 20_000.0, 0.0, 10_000.0, 0)),
p("wind", "Wind", num(0.0), slider(-10_000.0, 10_000.0, -2000.0, 2000.0, 1)),
p("windVariation", "Variation % (Wind)", num(0.0), pct()),
p("spread", "Spread", num(0.0), slider(0.0, 1000.0, 0.0, 100.0, 1)),
p("color", "Color", col(0.8, 0.8, 0.8), ParamUi::Color),
p("opacity", "Opacity", num(25.0), pct()),
p("transferMode", "Transfer Mode", Value::Enum(1), transfer()),
p("compositeWithOriginal", "Composite With Original", Value::Bool(true), ParamUi::Checkbox),
p("extras/appearance", "Appearance", Value::Enum(0), popup(&["Refracting", "Soft Solid"])),
p("extras/offset", "Offset", pt(0.5, 0.5), ParamUi::Point),
p("extras/randomSeed", "Random Seed", num(0.0), slider(0.0, 10_000.0, 0.0, 1000.0, 0)),
],
rainfall,
),
spec(
"ec.sim.ccsnowfall",
"CC Snowfall",
vec![
p("flakes", "Flakes", num(10_000.0), slider(0.0, 200_000.0, 0.0, 50_000.0, 0)),
p("size", "Size", num(3.0), slider(0.0, 50.0, 0.0, 20.0, 2)),
p("sizeVariation", "Variation % (Size)", num(50.0), pct()),
p("sceneDepth", "Scene Depth", num(5000.0), slider(0.0, 20_000.0, 0.0, 10_000.0, 0)),
p("speed", "Speed", num(100.0), slider(0.0, 5000.0, 0.0, 1000.0, 1)),
p("speedVariation", "Variation % (Speed)", num(50.0), pct()),
p("wind", "Wind", num(25.0), slider(-5000.0, 5000.0, -500.0, 500.0, 1)),
p("windVariation", "Variation % (Wind)", num(50.0), pct()),
p("spread", "Spread", num(0.0), slider(0.0, 1000.0, 0.0, 100.0, 1)),
p("wiggle/wiggleAmount", "Amount", num(2.0), slider(0.0, 100.0, 0.0, 20.0, 2)),
p("wiggle/wiggleAmountVariation", "Variation % (Amount)", num(50.0), pct()),
p("wiggle/wiggleFrequency", "Frequency", num(1.0), slider(0.0, 20.0, 0.0, 5.0, 2)),
p("wiggle/wiggleFrequencyVariation", "Variation % (Frequency)", num(50.0), pct()),
p("color", "Color", col(1.0, 1.0, 1.0), ParamUi::Color),
p("opacity", "Opacity", num(100.0), pct()),
p("transferMode", "Transfer Mode", Value::Enum(0), transfer()),
p("compositeWithOriginal", "Composite With Original", Value::Bool(true), ParamUi::Checkbox),
p("extras/offset", "Offset", pt(0.5, 0.5), ParamUi::Point),
p("extras/randomSeed", "Random Seed", num(0.0), slider(0.0, 10_000.0, 0.0, 1000.0, 0)),
],
snowfall,
),
spec(
"ec.sim.ccbubbles",
"CC Bubbles",
vec![
p("bubbleAmount", "Bubble Amount", num(100.0), slider(0.0, 10_000.0, 0.0, 1000.0, 0)),
p("bubbleSpeed", "Bubble Speed", num(0.5), slider(-10.0, 10.0, -2.0, 2.0, 2)),
p("wobbleAmplitude", "Wobble Amplitude", num(5.0), slider(0.0, 500.0, 0.0, 100.0, 1)),
p("wobbleFrequency", "Wobble Frequency", num(1.0), slider(0.0, 50.0, 0.0, 10.0, 2)),
p("bubbleSize", "Bubble Size", num(1.0), slider(0.0, 20.0, 0.0, 5.0, 2)),
p("reflectionType", "Reflection Type", Value::Enum(0), popup(&["Liquid", "Metal"])),
p("shadingType", "Shading Type", Value::Enum(0), popup(&["None", "Lighten", "Darken", "Fade Inwards", "Fade Outwards"])),
],
bubbles,
),
spec(
"ec.sim.ccdrizzle",
"CC Drizzle",
vec![
p("dripRate", "Drip Rate", num(10.0), slider(0.0, 200.0, 0.0, 50.0, 2)),
p("longevity", "Longevity (sec)", num(1.0), slider(0.0, 30.0, 0.0, 5.0, 2)),
p("rippling", "Rippling", num(360.0), ParamUi::Angle),
p("displacement", "Displacement", num(5.0), slider(0.0, 100.0, 0.0, 30.0, 2)),
p("rippleHeight", "Ripple Height", num(30.0), slider(0.0, 200.0, 0.0, 100.0, 1)),
p("spreading", "Spreading", num(100.0), slider(0.0, 1000.0, 0.0, 300.0, 1)),
p("light/lightIntensity", "Light Intensity", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)),
p("light/lightColor", "Light Color", col(1.0, 1.0, 1.0), ParamUi::Color),
p("light/lightHeight", "Light Height", num(25.0), slider(-100.0, 100.0, -100.0, 100.0, 1)),
p("light/lightDirection", "Light Direction", num(-45.0), ParamUi::Angle),
p("shading/ambient", "Ambient", num(0.0), slider(-200.0, 200.0, -100.0, 100.0, 1)),
p("shading/diffuse", "Diffuse", num(0.0), slider(0.0, 200.0, 0.0, 100.0, 1)),
p("shading/specular", "Specular", num(20.0), slider(0.0, 200.0, 0.0, 100.0, 1)),
p("shading/roughness", "Roughness", num(0.05), slider(0.001, 1.0, 0.001, 0.5, 3)),
],
drizzle,
),
spec(
"ec.sim.ccstarburst",
"CC Star Burst",
vec![
p("scatter", "Scatter", num(100.0), slider(0.0, 1000.0, 0.0, 300.0, 1)),
p("speed", "Speed", num(1.0), slider(-10.0, 10.0, -4.0, 4.0, 2)),
p("phase", "Phase", num(0.0), ParamUi::Angle),
p("gridSpacing", "Grid Spacing", num(2.0), slider(1.0, 100.0, 1.0, 20.0, 0)),
p("size", "Size", num(100.0), slider(0.0, 1000.0, 0.0, 300.0, 1)),
p("blendWithOriginal", "Blend w. Original", num(0.0), pct()),
],
star_burst,
),
spec(
"ec.sim.ccparticleworld",
"CC Particle World",
vec![
p("birthRate", "Birth Rate", num(0.5), slider(0.0, 100.0, 0.0, 10.0, 2)),
p("longevity", "Longevity (sec)", num(1.0), slider(0.0, 100.0, 0.0, 10.0, 2)),
p("producer/producerX", "Position X", num(0.0), slider(-10.0, 10.0, -1.0, 1.0, 2)),
p("producer/producerY", "Position Y", num(0.0), slider(-10.0, 10.0, -1.0, 1.0, 2)),
p("producer/producerZ", "Position Z", num(0.0), slider(-10.0, 10.0, -1.0, 1.0, 2)),
p("producer/radiusX", "Radius X", num(0.025), slider(0.0, 10.0, 0.0, 1.0, 3)),
p("producer/radiusY", "Radius Y", num(0.025), slider(0.0, 10.0, 0.0, 1.0, 3)),
p("producer/radiusZ", "Radius Z", num(0.025), slider(0.0, 10.0, 0.0, 1.0, 3)),
p("physics/animation", "Animation", Value::Enum(0), popup(PW_ANIMS)),
p("physics/velocity", "Velocity", num(1.0), slider(-10.0, 10.0, 0.0, 5.0, 2)),
p("physics/inheritVelocity", "Inherit Velocity %", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 1)),
p("physics/gravity", "Gravity", num(0.5), slider(-10.0, 10.0, -2.0, 2.0, 3)),
p("physics/resistance", "Resistance", num(0.0), slider(0.0, 20.0, 0.0, 5.0, 2)),
p("physics/extra", "Extra", num(1.0), slider(0.0, 10.0, 0.0, 2.0, 2)),
p("physics/extraAngle", "Extra Angle", num(360.0), ParamUi::Angle),
p("physics/directionAxis/axisX", "Axis X", num(0.0), slider(-1.0, 1.0, -1.0, 1.0, 2)),
p("physics/directionAxis/axisY", "Axis Y", num(-1.0), slider(-1.0, 1.0, -1.0, 1.0, 2)),
p("physics/directionAxis/axisZ", "Axis Z", num(0.0), slider(-1.0, 1.0, -1.0, 1.0, 2)),
p("physics/gravityVector/gravityX", "Gravity X", num(0.0), slider(-1.0, 1.0, -1.0, 1.0, 2)),
p("physics/gravityVector/gravityY", "Gravity Y", num(1.0), slider(-1.0, 1.0, -1.0, 1.0, 2)),
p("physics/gravityVector/gravityZ", "Gravity Z", num(0.0), slider(-1.0, 1.0, -1.0, 1.0, 2)),
p("particle/particleType", "Particle Type", Value::Enum(0), popup(PARTICLE_TYPES)),
p("particle/birthSize", "Birth Size", num(0.2), slider(0.0, 10.0, 0.0, 1.0, 3)),
p("particle/deathSize", "Death Size", num(0.2), slider(0.0, 10.0, 0.0, 1.0, 3)),
p("particle/sizeVariation", "Size Variation", num(0.0), pct()),
p("particle/maxOpacity", "Max Opacity", num(75.0), pct()),
p("particle/birthColor", "Birth Color", col(1.0, 1.0, 0.0), ParamUi::Color),
p("particle/deathColor", "Death Color", col(0.6, 0.0, 0.0), ParamUi::Color),
p("particle/transferMode", "Transfer Mode", Value::Enum(0), popup(&["Composite", "Screen", "Add", "Black Matte"])),
p("extras/effectCamera/distance", "Distance", num(2.0), slider(0.0, 100.0, 0.0, 10.0, 2)),
p("extras/effectCamera/rotationX", "Rotation X", num(0.0), ParamUi::Angle),
p("extras/effectCamera/rotationY", "Rotation Y", num(0.0), ParamUi::Angle),
p("extras/effectCamera/rotationZ", "Rotation Z", num(0.0), ParamUi::Angle),
p("extras/effectCamera/fov", "FOV", num(PW_FOV), slider(1.0, 179.0, 10.0, 120.0, 1)),
p("extras/randomSeed", "Random Seed", num(0.0), slider(0.0, 10_000.0, 0.0, 1000.0, 0)),
],
particle_world,
),
spec(
"ec.sim.ccparticlesystems2",
"CC Particle Systems II",
vec![
p("birthRate", "Birth Rate", num(2.0), slider(0.0, 100.0, 0.0, 20.0, 2)),
p("longevity", "Longevity (sec)", num(1.0), slider(0.0, 100.0, 0.0, 10.0, 2)),
p("producer/position", "Position", pt(0.5, 0.5), ParamUi::Point),
p("producer/radiusX", "Radius X", num(0.0), slider(0.0, 2000.0, 0.0, 200.0, 1)),
p("producer/radiusY", "Radius Y", num(0.0), slider(0.0, 2000.0, 0.0, 200.0, 1)),
p("physics/animation", "Animation", Value::Enum(0), popup(PW_ANIMS)),
p("physics/velocity", "Velocity", num(2.0), slider(-20.0, 20.0, 0.0, 10.0, 2)),
p("physics/inheritVelocity", "Inherit Velocity %", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 1)),
p("physics/gravity", "Gravity", num(1.0), slider(-20.0, 20.0, -5.0, 5.0, 2)),
p("physics/resistance", "Resistance", num(0.0), slider(0.0, 20.0, 0.0, 5.0, 2)),
p("physics/direction", "Direction", num(0.0), ParamUi::Angle),
p("physics/extra", "Extra", num(1.0), slider(0.0, 10.0, 0.0, 2.0, 2)),
p("particle/particleType", "Particle Type", Value::Enum(0), popup(PARTICLE_TYPES)),
p("particle/birthSize", "Birth Size", num(0.5), slider(0.0, 20.0, 0.0, 5.0, 2)),
p("particle/deathSize", "Death Size", num(1.5), slider(0.0, 20.0, 0.0, 5.0, 2)),
p("particle/sizeVariation", "Size Variation", num(0.0), pct()),
p("particle/maxOpacity", "Max Opacity", num(75.0), pct()),
p("particle/birthColor", "Birth Color", col(1.0, 1.0, 0.0), ParamUi::Color),
p("particle/deathColor", "Death Color", col(0.6, 0.0, 0.0), ParamUi::Color),
p("particle/transferMode", "Transfer Mode", Value::Enum(0), popup(&["Composite", "Screen", "Add", "Black Matte"])),
p("randomSeed", "Random Seed", num(0.0), slider(0.0, 10_000.0, 0.0, 1000.0, 0)),
],
particle_systems2,
),
spec(
"ec.sim.ccmrmercury",
"CC Mr. Mercury",
vec![
p("radiusX", "Radius X", num(50.0), slider(0.0, 2000.0, 0.0, 300.0, 1)),
p("radiusY", "Radius Y", num(50.0), slider(0.0, 2000.0, 0.0, 300.0, 1)),
p("producer", "Producer", pt(0.5, 0.5), ParamUi::Point),
p("direction", "Direction", num(0.0), ParamUi::Angle),
p("velocity", "Velocity", num(1.0), slider(-20.0, 20.0, 0.0, 5.0, 2)),
p("birthRate", "Birth Rate", num(1.0), slider(0.0, 20.0, 0.0, 5.0, 2)),
p("longevity", "Longevity (sec)", num(2.0), slider(0.0, 100.0, 0.0, 10.0, 2)),
p("gravity", "Gravity", num(0.5), slider(-20.0, 20.0, -5.0, 5.0, 2)),
p("resistance", "Resistance", num(0.0), slider(0.0, 20.0, 0.0, 5.0, 2)),
p("extra", "Extra", num(1.0), slider(0.0, 10.0, 0.0, 2.0, 2)),
p("animation", "Animation", Value::Enum(0), popup(PW_ANIMS)),
p("blobInfluence", "Blob Influence", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)),
p("blobBirthSize", "Blob Birth Size", num(0.5), slider(0.0, 10.0, 0.0, 2.0, 2)),
p("blobDeathSize", "Blob Death Size", num(0.25), slider(0.0, 10.0, 0.0, 2.0, 2)),
p("light/lightIntensity", "Light Intensity", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)),
p("light/lightColor", "Light Color", col(1.0, 1.0, 1.0), ParamUi::Color),
p("light/lightHeight", "Light Height", num(50.0), slider(-100.0, 100.0, -100.0, 100.0, 1)),
p("light/lightDirection", "Light Direction", num(-45.0), ParamUi::Angle),
p("shading/ambient", "Ambient", num(0.0), slider(-200.0, 200.0, -100.0, 100.0, 1)),
p("shading/diffuse", "Diffuse", num(75.0), slider(0.0, 200.0, 0.0, 100.0, 1)),
p("shading/specular", "Specular", num(50.0), slider(0.0, 200.0, 0.0, 100.0, 1)),
p("shading/roughness", "Roughness", num(0.05), slider(0.001, 1.0, 0.001, 0.5, 3)),
p("shading/metal", "Metal", num(100.0), pct()),
],
mr_mercury,
),
]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{EffectEnv, run_fx};
fn grey(w: u32, hh: u32) -> Image {
Image::filled(w, hh, [0.2, 0.2, 0.2, 1.0])
}
fn ramp(w: u32, hh: u32) -> Image {
let mut img = Image::new(w, hh);
for y in 0..hh {
for x in 0..w {
img.set(x, y, [x as f32 / w as f32, y as f32 / hh as f32, 0.4, 1.0]);
}
}
img
}
fn run(id: &str, vals: &[(&str, Value)], img: Image, t: f64) -> Image {
run_fx(id, vals, img, t, EffectEnv::default()).img
}
fn diff(a: &Image, b: &Image) -> f32 {
a.data.iter().zip(&b.data).map(|(p, q)| (0..4).map(|k| (p[k] - q[k]).abs()).sum::<f32>()).sum()
}
/// Centre of mass (luminance-weighted difference from `base`), `[-1, -1]` when equal.
fn mass(img: &Image, base: &Image) -> [f32; 2] {
let (mut m, mut sx, mut sy) = (0.0, 0.0, 0.0);
for y in 0..img.height {
for x in 0..img.width {
let i = img.idx(x, y);
let d: f32 = (0..3).map(|k| (img.data[i][k] - base.data[i][k]).abs()).sum();
m += d;
sx += d * x as f32;
sy += d * y as f32;
}
}
if m > 0.0 { [sx / m, sy / m] } else { [-1.0; 2] }
}
/// [`mass`] in y.
fn mass_y(img: &Image, base: &Image) -> f32 {
mass(img, base)[1]
}
#[test]
fn rasteriser_over_and_add() {
let s = [Sprite::new(5.0, 5.0, 2.0, [1.0, 0.0, 0.0, 1.0], Shape::Disc)];
let img = splat(10, 40, &s, Acc::Over);
assert!(img.get(5, 5)[0] > 0.99 && img.get(9, 30)[3] == 0.0);
let two = [s[0], s[0]];
let add = splat(10, 40, &two, Acc::Add);
assert!(add.get(5, 5)[0] > 1.9);
// Items spanning band boundaries are drawn in every band.
let tall = [Sprite::new(5.0, 16.0, 6.0, [0.0, 1.0, 0.0, 1.0], Shape::Square)];
let t = splat(10, 40, &tall, Acc::Over);
assert!(t.get(5, 13)[1] > 0.9 && t.get(5, 18)[1] > 0.9);
}
#[test]
fn rainfall_deterministic_and_falls() {
let vals = [("drops", num(200.0)), ("opacity", num(100.0)), ("extras/appearance", Value::Enum(1)), ("color", col(1.0, 1.0, 1.0))];
let a = run("ec.sim.ccrainfall", &vals, grey(40, 40), 0.5);
let b = run("ec.sim.ccrainfall", &vals, grey(40, 40), 0.5);
assert_eq!(a, b);
assert!(diff(&a, &grey(40, 40)) > 1.0, "drops visible");
let none = run("ec.sim.ccrainfall", &[("drops", num(0.0))], grey(40, 40), 0.5);
assert_eq!(none, grey(40, 40));
let later = run("ec.sim.ccrainfall", &vals, grey(40, 40), 0.52);
assert!(diff(&a, &later) > 0.1, "rain moves");
}
#[test]
fn snowfall_deterministic_and_moves() {
let vals = [("flakes", num(300.0))];
let a = run("ec.sim.ccsnowfall", &vals, grey(40, 40), 1.0);
assert_eq!(a, run("ec.sim.ccsnowfall", &vals, grey(40, 40), 1.0));
assert!(diff(&a, &grey(40, 40)) > 1.0);
assert!(diff(&a, &run("ec.sim.ccsnowfall", &vals, grey(40, 40), 1.2)) > 0.1);
let off = run("ec.sim.ccsnowfall", &[("flakes", num(300.0)), ("compositeWithOriginal", Value::Bool(false))], grey(40, 40), 1.0);
assert!(off.data.iter().any(|p| p[3] == 0.0), "without original the background is transparent");
}
#[test]
fn bubbles_rise() {
let vals = [("bubbleAmount", num(30.0)), ("bubbleSize", num(3.0))];
let a = run("ec.sim.ccbubbles", &vals, ramp(40, 40), 0.3);
assert_eq!(a, run("ec.sim.ccbubbles", &vals, ramp(40, 40), 0.3));
assert!(a.data.iter().any(|p| p[3] > 0.5));
let none = run("ec.sim.ccbubbles", &[("bubbleAmount", num(0.0))], ramp(40, 40), 0.3);
assert!(none.data.iter().all(|p| p[3] == 0.0), "bubbles replace the layer");
// A single slow bubble moves up over a short time.
let one = [("bubbleAmount", num(1.0)), ("bubbleSize", num(4.0)), ("wobbleAmplitude", num(0.0)), ("bubbleSpeed", num(0.2))];
let blank = Image::new(40, 40);
let y0 = mass_y(&run("ec.sim.ccbubbles", &one, ramp(40, 40), 0.0), &blank);
let y1 = mass_y(&run("ec.sim.ccbubbles", &one, ramp(40, 40), 0.1), &blank);
assert!(y0 >= 0.0 && y1 >= 0.0 && (y1 < y0 || y0 < 8.0), "{y0} -> {y1}");
}
#[test]
fn drizzle_ripples() {
let a = run("ec.sim.ccdrizzle", &[], ramp(48, 48), 0.6);
assert_eq!(a, run("ec.sim.ccdrizzle", &[], ramp(48, 48), 0.6));
assert!(diff(&a, &ramp(48, 48)) > 0.1);
let none = run("ec.sim.ccdrizzle", &[("dripRate", num(0.0))], ramp(48, 48), 0.6);
assert_eq!(none, ramp(48, 48));
}
#[test]
fn star_burst_moves_stars() {
let a = run("ec.sim.ccstarburst", &[("gridSpacing", num(4.0))], ramp(40, 40), 0.5);
assert_eq!(a, run("ec.sim.ccstarburst", &[("gridSpacing", num(4.0))], ramp(40, 40), 0.5));
assert!(a.data.iter().any(|p| p[3] > 0.0));
let b = run("ec.sim.ccstarburst", &[("gridSpacing", num(4.0))], ramp(40, 40), 0.9);
assert!(diff(&a, &b) > 0.5);
let full = run("ec.sim.ccstarburst", &[("blendWithOriginal", num(100.0))], ramp(40, 40), 0.5);
assert!(diff(&full, &ramp(40, 40)) < 1e-3);
}
fn pw_vals(seed: f64) -> Vec<(&'static str, Value)> {
vec![
("birthRate", num(1.0)),
("extras/randomSeed", num(seed)),
("particle/particleType", Value::Enum(2)),
("particle/birthSize", num(0.3)),
("particle/deathSize", num(0.3)),
]
}
#[test]
fn particle_world_seek_consistent() {
let v = pw_vals(11.0);
let direct = run("ec.sim.ccparticleworld", &v, Image::new(40, 40), 2.0);
let v2 = pw_vals(12.0);
let _ = run("ec.sim.ccparticleworld", &v2, Image::new(40, 40), 1.0);
let stepped = run("ec.sim.ccparticleworld", &v2, Image::new(40, 40), 2.0);
let direct2 = run("ec.sim.ccparticleworld", &v2, Image::new(40, 40), 2.0);
assert_eq!(stepped, direct2);
assert!(direct.data.iter().any(|p| p[3] > 0.0), "particles alive");
// Before birth nothing is drawn.
let t0 = run("ec.sim.ccparticleworld", &v, Image::new(40, 40), 0.0);
assert!(t0.data.iter().all(|p| p[3] == 0.0));
}
#[test]
fn engine_resume_equals_from_scratch() {
for anim in 0..PW_ANIMS.len() as u32 {
let phys = Phys {
rate: 90.0,
life: 1.5,
producer: [0.0; 3],
radius: [0.05; 3],
anim,
speed: 0.5,
gravity: [0.0, 0.5, 0.0],
resistance: 0.2,
axis: [0.0, -1.0, 0.0],
extra: 1.0,
extra_angle: 60.0,
seed: 9,
max: 10_000,
flat: anim % 2 == 0,
};
let a: SimCache<PState> = SimCache::new(2);
let b: SimCache<PState> = SimCache::new(2);
let direct = simulate(&a, 1, 2.0, &phys);
let _ = simulate(&b, 1, 1.0, &phys);
let _ = simulate(&b, 1, 0.37, &phys);
let resumed = simulate(&b, 1, 2.0, &phys);
assert_eq!(direct.parts.len(), resumed.parts.len(), "anim {anim}");
assert!(direct.parts.iter().zip(&resumed.parts).all(|(p, q)| p.p == q.p && p.v == q.v && p.id == q.id), "anim {anim}");
assert!(!direct.parts.is_empty());
}
}
#[test]
fn particle_world_gravity_pulls_down() {
let base = |g: f64| {
vec![
("birthRate", num(2.0)),
("physics/velocity", num(0.2)),
("physics/gravity", num(g)),
("particle/particleType", Value::Enum(2)),
("particle/birthSize", num(0.2)),
("particle/deathSize", num(0.2)),
("longevity", num(3.0)),
]
};
let blank = Image::new(48, 48);
let up = mass_y(&run("ec.sim.ccparticleworld", &base(0.0), blank.clone(), 1.0), &blank);
let down = mass_y(&run("ec.sim.ccparticleworld", &base(2.0), blank.clone(), 1.0), &blank);
assert!(down > up + 2.0, "{up} {down}");
}
/// A comp whose camera is `camera` ([`crate::CompScene::camera`]); `layer`: a camera layer
/// (else the comp's default view).
struct Comp {
camera: [[f64; 4]; 3],
layer: bool,
}
impl crate::EffectHost for Comp {
fn layer(&self, _: u64, _: bool) -> Option<crate::LayerPixels> {
None
}
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
None
}
fn comp_scene(&self) -> Option<crate::CompScene> {
Some(crate::CompScene { camera: Some(self.camera), camera_layer: self.layer, light: None })
}
}
/// A camera `d` px in front of layer point `c` looking at it, moved right by `dx` px and
/// zoomed by `zoom` (1 = identity on the layer plane): `c + zoom·d·(p − c − dx) / (z + d)`.
fn cam(c: [f64; 2], d: f64, dx: f64, zoom: f64) -> [[f64; 4]; 3] {
let k = zoom * d;
[[k, 0.0, c[0], d * c[0] - k * (c[0] + dx)], [0.0, k, c[1], d * c[1] - k * c[1]], [0.0, 0.0, 1.0, d]]
}
fn pw_in(comp: &Comp, vals: &[(&str, Value)]) -> Image {
run_fx("ec.sim.ccparticleworld", vals, Image::new(48, 48), 1.0, EffectEnv { host: Some(comp), ..Default::default() }).img
}
fn finite(img: &Image) -> bool {
img.data.iter().all(|p| p.iter().all(|c| c.is_finite()))
}
/// #397: with a camera layer in the comp, the particles are seen through it.
#[test]
fn particle_world_follows_the_comp_camera() {
let v = pw_vals(3.0);
let blank = Image::new(48, 48);
let here = Comp { camera: cam([24.0, 24.0], 96.0, 0.0, 1.0), layer: true };
let base = pw_in(&here, &v);
assert!(base.data.iter().any(|p| p[3] > 0.0), "particles alive");
// Moving the camera right moves the particles left.
let moved = pw_in(&Comp { camera: cam([24.0, 24.0], 96.0, 8.0, 1.0), ..here }, &v);
let (a, b) = (mass(&base, &blank), mass(&moved, &blank));
assert!(b[0] < a[0] - 4.0 && (b[1] - a[1]).abs() < 2.0, "{a:?} -> {b:?}");
// Zooming in spreads them out.
let zoomed = pw_in(&Comp { camera: cam([24.0, 24.0], 96.0, 0.0, 2.0), ..here }, &v);
let cover = |i: &Image| i.data.iter().filter(|p| p[3] > 0.01).count();
assert!(cover(&zoomed) > cover(&base), "{} {}", cover(&zoomed), cover(&base));
// The Effect Camera is ignored then.
let mut turned = v.clone();
turned.push(("extras/effectCamera/rotationY", num(60.0)));
assert_eq!(pw_in(&here, &turned), base);
// The comp's default view (no camera layer) leaves it to the Effect Camera.
let default_view = Comp { layer: false, ..here };
assert_eq!(pw_in(&default_view, &v), run("ec.sim.ccparticleworld", &v, blank.clone(), 1.0));
// A camera facing away, or a degenerate one, draws nothing and never NaN.
for camera in [[[96.0, 0.0, 24.0, 0.0], [0.0, 96.0, 24.0, 0.0], [0.0, 0.0, -1.0, -96.0]], [[0.0; 4]; 3], [[f64::NAN; 4]; 3]] {
assert_eq!(pw_in(&Comp { camera, layer: true }, &v), blank);
}
// A camera among the particles draws the ones in front of it.
let inside = pw_in(&Comp { camera: cam([24.0, 24.0], 1.0, 0.0, 1.0), ..here }, &v);
assert!(finite(&inside));
}
/// Extras ▸ Effect Camera: the defaults are the fixed camera Particle World always had.
#[test]
fn particle_world_effect_camera() {
let v = pw_vals(5.0);
let base = run("ec.sim.ccparticleworld", &v, Image::new(48, 48), 1.0);
let with = |extra: &[(&'static str, Value)]| {
let mut w = v.clone();
w.extend_from_slice(extra);
run("ec.sim.ccparticleworld", &w, Image::new(48, 48), 1.0)
};
// Missing parameters (projects saved before) read as the defaults.
let spec = crate::find("ec.sim.ccparticleworld").unwrap();
let mut params = crate::Params { values: spec.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
params.values.retain(|k, _| !k.starts_with(PW_EFFECT_CAMERA));
for (k, val) in &v {
params.values.insert(k.to_string(), val.clone());
}
let ctx = EffectCtx { params: &params, time: 1.0, layer_size: [48.0, 48.0], seed: 1, adjustment: false, env: EffectEnv::default() };
assert_eq!(crate::apply(spec, &ctx, Buf { img: Image::new(48, 48), offset: [0.0; 2], scale: 1.0 }).img, base);
assert_eq!(with(&[("extras/effectCamera/fov", num(f64::NAN))]), base);
// Turning or pulling back the camera changes the view.
let turned = with(&[("extras/effectCamera/rotationY", num(60.0))]);
assert!(diff(&turned, &base) > 1.0);
let cover = |i: &Image| i.data.iter().filter(|p| p[3] > 0.01).count();
let far = with(&[("extras/effectCamera/distance", num(4.0))]);
assert!(cover(&far) < cover(&base), "{} {}", cover(&far), cover(&base));
assert!(diff(&with(&[("extras/effectCamera/fov", num(120.0))]), &base) > 0.1);
// Extremes never panic or produce NaN.
for extra in [
("extras/effectCamera/distance", num(0.0)),
("extras/effectCamera/distance", num(-5.0)),
("extras/effectCamera/fov", num(0.0)),
("extras/effectCamera/fov", num(180.0)),
("extras/effectCamera/rotationX", num(1e12)),
] {
assert!(finite(&with(std::slice::from_ref(&extra))), "{extra:?}");
}
// Moving the camera doesn't restart the simulation.
let key = |pr: &crate::Params| {
sim_key(&EffectCtx { params: pr, time: 1.0, layer_size: [48.0, 48.0], seed: 1, adjustment: false, env: EffectEnv::default() }, 1)
};
let mut moved = params.clone();
moved.values.insert("extras/effectCamera/rotationZ".into(), num(30.0));
assert_eq!(key(&params), key(&moved));
moved.values.insert("extras/randomSeed".into(), num(9.0));
assert_ne!(key(&params), key(&moved));
}
#[test]
fn particle_systems2_seek_and_direction() {
let v = vec![
("producer/position", pt(24.0, 24.0)),
("birthRate", num(3.0)),
("physics/gravity", num(0.0)),
("physics/velocity", num(1.0)),
("physics/animation", Value::Enum(1)),
];
let _ = run("ec.sim.ccparticlesystems2", &v, Image::new(48, 48), 0.5);
let a = run("ec.sim.ccparticlesystems2", &v, Image::new(48, 48), 1.0);
let mut v2 = v.clone();
v2.push(("randomSeed", num(0.0)));
assert_eq!(a, run("ec.sim.ccparticlesystems2", &v2, Image::new(48, 48), 1.0));
// Direction 0° (up): particles above the producer.
let blank = Image::new(48, 48);
let y = mass_y(&a, &blank);
assert!((0.0..24.0).contains(&y), "{y}");
// The particles replace the layer: with none born it is empty.
let none = run("ec.sim.ccparticlesystems2", &[("birthRate", num(0.0))], grey(20, 20), 1.0);
assert_eq!(none, Image::new(20, 20));
}
/// CC Particle World and CC Particle Systems II replace their layer with the particles, in
/// every Transfer Mode (which only sets how particles meet each other). SceneYard frames from
/// After Effects 2026 show no trace of the source solid: "Task Preparation UI" (black solids
/// under fireworks hid a white page), "Burnout matchstick" (a red rectangle around the
/// flame), "Documentary Man Falling" (a black block over the light).
#[test]
fn cc_particles_replace_their_layer() {
let red = || {
let mut img = Image::new(48, 48);
img.data.iter_mut().for_each(|p| *p = [0.7, 0.1, 0.1, 1.0]);
img
};
for id in ["ec.sim.ccparticlesystems2", "ec.sim.ccparticleworld"] {
for mode in 0..4 {
let v = vec![("birthRate", num(3.0)), ("particle/transferMode", Value::Enum(mode))];
let _ = run(id, &v, red(), 0.5);
let out = run(id, &v, red(), 1.0);
// No source left: the corners, far from the producer, are clear.
for (x, y) in [(0, 0), (47, 0), (0, 47), (47, 47)] {
assert!(out.get(x, y)[3] < 1e-6, "{id} mode {mode} ({x},{y}): {:?}", out.get(x, y));
}
assert!(out.data.iter().any(|p| p[3] > 0.05), "{id} mode {mode}: particles drawn");
}
}
}
#[test]
fn mercury_blobs_and_seek() {
let v = vec![("producer", pt(24.0, 16.0)), ("radiusX", num(5.0)), ("radiusY", num(5.0)), ("birthRate", num(3.0))];
let _ = run("ec.sim.ccmrmercury", &v, ramp(48, 48), 0.7);
let a = run("ec.sim.ccmrmercury", &v, ramp(48, 48), 1.4);
let mut v2 = v.clone();
v2.push(("shading/metal", num(100.0)));
let b = run("ec.sim.ccmrmercury", &v2, ramp(48, 48), 1.4);
assert_eq!(a, b);
assert!(a.data.iter().any(|p| p[3] > 0.5), "blobs visible");
let none = run("ec.sim.ccmrmercury", &[("birthRate", num(0.0))], ramp(48, 48), 1.4);
assert!(none.data.iter().all(|p| p[3] == 0.0));
}
#[test]
fn half_resolution_scales_sprites() {
// Downsampled buffers render the same layout at half size.
let s = crate::find("ec.sim.ccsnowfall").unwrap();
let mut params = crate::Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
params.values.insert("flakes".into(), num(50.0));
params.values.insert("offset".into(), pt(20.0, 20.0));
let ctx = EffectCtx { params: &params, time: 0.5, layer_size: [40.0, 40.0], seed: 1, adjustment: false, env: EffectEnv::default() };
let full = crate::apply(s, &ctx, Buf { img: grey(40, 40), offset: [0.0; 2], scale: 1.0 });
let half = crate::apply(s, &ctx, Buf { img: grey(20, 20), offset: [0.0; 2], scale: 0.5 });
let sum = |i: &Image| i.data.iter().map(|p| p[0]).sum::<f32>() / i.data.len() as f32;
assert!((sum(&full.img) - sum(&half.img)).abs() < 0.05, "{} {}", sum(&full.img), sum(&half.img));
}
// ---------------------------------------------------------- Birth Rate over time (#1373)
/// A host that evaluates effect `index`'s Birth Rate at other layer times as `rate(t)`.
struct Rate {
index: usize,
rate: fn(f64) -> f64,
}
impl crate::EffectHost for Rate {
fn layer(&self, _: u64, _: bool) -> Option<crate::LayerPixels> {
None
}
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
None
}
fn param_at(&self, effect_index: usize, id: &str, t: f64) -> Option<Value> {
(effect_index == self.index && id == "birthRate").then(|| num((self.rate)(t)))
}
}
/// Birth Rate 3 until 0.5 s, then 0 (the issue's one-shot burst).
fn burst(t: f64) -> f64 {
if t < 0.5 { 3.0 } else { 0.0 }
}
/// The same burst, cut at 0.25 s (an earlier key moved).
fn short_burst(t: f64) -> f64 {
if t < 0.25 { 3.0 } else { 0.0 }
}
/// Birth Rate falling linearly from 3 at 0 s to 0 at 0.5 s.
fn falling(t: f64) -> f64 {
(3.0 * (1.0 - t / 0.5)).max(0.0)
}
/// CC Particle World's parameters: random seed `seed`, Birth Rate `now` (its value at the
/// rendered time), Longevity 1 s.
fn pw_params(seed: f64, now: f64) -> crate::Params {
let spec = crate::find("ec.sim.ccparticleworld").unwrap();
let mut p = crate::Params { values: spec.params.iter().map(|ps| (ps.id.to_string(), crate::default_value(ps, [100.0, 100.0]))).collect() };
for (k, v) in [("birthRate", num(now)), ("extras/randomSeed", num(seed)), ("longevity", num(1.0))] {
p.values.insert(k.to_string(), v);
}
p
}
fn pw_ctx<'a>(p: &'a crate::Params, t: f64, host: Option<&'a Rate>, index: usize) -> EffectCtx<'a> {
let env = EffectEnv { host: host.map(|h| h as &dyn crate::EffectHost), effect_index: index, ..Default::default() };
EffectCtx { params: p, time: t, layer_size: [100.0, 100.0], seed: 1, adjustment: false, env }
}
/// The live particles at layer time `t` with Birth Rate `now` there and `host`'s history.
fn pw_at(seed: f64, now: f64, t: f64, host: Option<&Rate>) -> Vec<crate::psim::SimParticle> {
let p = pw_params(seed, now);
particle_state("ec.sim.ccparticleworld", &pw_ctx(&p, t, host, host.map_or(0, |h| h.index))).unwrap()
}
/// The same, simulated from layer time 0 with a cache of its own (the reference).
fn pw_fresh(seed: f64, now: f64, t: f64, host: &Rate) -> Vec<crate::psim::SimParticle> {
let p = pw_params(seed, now);
let ctx = pw_ctx(&p, t, Some(host), host.index);
let key = params_key_except(&ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 1, &[PW_EFFECT_CAMERA, "birthRate"]);
let st = simulate_rates(&SimCache::new(1), key, &pw_phys(&ctx), &rate_history(&ctx, "birthRate", PW_RATE));
st.parts.iter().map(|q| crate::psim::SimParticle { p: q.p, v: q.v, age: q.age, life: q.life, rnd: q.rnd, id: q.id }).collect()
}
/// Particles born in the first `t` seconds of `rate`.
fn born_by(rate: fn(f64) -> f64, t: f64) -> usize {
crate::psim::births_at(steps_at(t), 40_000, |i| rate(i as f64 / SPS) * PW_RATE).unwrap().len()
}
/// #1373: Birth Rate only decides the births of its own time. After a burst stops, the
/// particles born in it live on (moving and ageing) until their Longevity runs out, rather
/// than vanishing because the rate is now 0.
#[test]
fn a_burst_lives_on_after_birth_rate_drops_to_zero() {
let host = Rate { index: 2, rate: burst };
// During the burst: as a constant Birth Rate of 3.
assert_eq!(pw_at(101.0, 3.0, 0.4, Some(&host)), pw_at(101.0, 3.0, 0.4, None));
// After it: the burst's particles, none newer.
let late = pw_at(101.0, 0.0, 0.9, Some(&host));
let n = born_by(burst, 0.5);
assert!(late.len() > n / 2 && late.iter().all(|q| (q.id as usize) < n), "{} live of {n} born", late.len());
assert!(pw_at(101.0, 0.0, 0.9, None).is_empty(), "a constant Birth Rate of 0 never bore any");
// They keep moving.
let early = pw_at(101.0, 0.0, 0.7, Some(&host));
let q0 = early.iter().find(|q| q.id == late[0].id).unwrap();
assert!(q0.p != late[0].p && late[0].age > q0.age + 0.19, "{q0:?} -> {:?}", late[0]);
// Longevity 1 s (± 20 %): all gone by 1.8 s.
assert!(pw_at(101.0, 0.0, 1.8, Some(&host)).is_empty());
// The effect asks for its own index: another effect's history isn't its own.
let p = pw_params(101.0, 0.0);
assert!(particle_state("ec.sim.ccparticleworld", &pw_ctx(&p, 0.9, Some(&host), 0)).unwrap().is_empty());
}
/// A Birth Rate ramping down isn't simulated as if its current value had always applied.
#[test]
fn a_ramped_birth_rate_bears_what_each_moment_asked_for() {
let host = Rate { index: 0, rate: falling };
let ramped = pw_at(102.0, falling(0.25), 0.25, Some(&host));
let constant = pw_at(102.0, falling(0.25), 0.25, None);
assert_eq!(ramped.len(), born_by(falling, 0.25), "every particle born so far is alive");
assert_eq!(constant.len(), born_by(|_| 1.5, 0.25));
assert!(ramped.len() > constant.len() + 10, "{} vs {}", ramped.len(), constant.len());
}
/// Seeking backward, forward and to the same time again gives the state simulated from 0.
#[test]
fn seeking_through_a_birth_rate_history_matches_simulating_from_zero() {
let host = Rate { index: 0, rate: burst };
for t in [1.4, 0.6, 0.9, 0.3, 0.9, 1.1] {
let now = burst(t);
assert_eq!(pw_at(103.0, now, t, Some(&host)), pw_fresh(103.0, now, t, &host), "at {t}");
}
}
/// The simulation cache key leaves Birth Rate out (so an animated rate keeps its checkpoints),
/// but a checkpoint is only resumed with the history that reached it: after an earlier key
/// moves, the same current rate (0) gives the new history's particles.
#[test]
fn an_earlier_birth_rate_change_is_not_served_from_stale_checkpoints() {
let (long, short) = (Rate { index: 0, rate: burst }, Rate { index: 0, rate: short_burst });
let a = pw_at(104.0, 0.0, 0.9, Some(&long));
let b = pw_at(104.0, 0.0, 0.9, Some(&short));
assert_eq!(b, pw_fresh(104.0, 0.0, 0.9, &short));
assert!(b.len() < a.len() && !b.is_empty(), "{} vs {}", b.len(), a.len());
assert_eq!(pw_at(104.0, 0.0, 1.0, Some(&long)), pw_fresh(104.0, 0.0, 1.0, &long));
}
/// A constant Birth Rate gives exactly the particles it did before the history was sampled.
#[test]
fn a_constant_birth_rate_is_unchanged() {
let host = Rate { index: 0, rate: |_| 2.0 };
let p = pw_params(105.0, 2.0);
let ctx = pw_ctx(&p, 1.3, None, 0);
let phys = pw_phys(&ctx);
let before = simulate(&SimCache::new(1), sim_key(&ctx, 1), 1.3, &phys);
let now = pw_at(105.0, 2.0, 1.3, Some(&host));
assert_eq!(now.len(), before.parts.len());
assert!(now.iter().zip(&before.parts).all(|(a, b)| a.id == b.id && a.p == b.p && a.v == b.v && a.age == b.age));
assert_eq!(crate::psim::births_at(78, 40_000, |_| phys.rate), crate::psim::births(phys.rate, 78, 40_000));
}
/// The GPU's birth schedule for a rate history is the CPU's: the ids born are the ids the
/// CPU simulation numbers.
#[test]
fn the_birth_schedule_follows_the_rate_history() {
let host = Rate { index: 0, rate: falling };
let p = pw_params(106.0, 0.0);
let ctx = pw_ctx(&p, 0.7, Some(&host), 0);
let rates = rate_history(&ctx, "birthRate", PW_RATE);
let st = simulate_rates(&SimCache::new(1), 9, &pw_phys(&ctx), &rates);
let births = crate::psim::births_at(rates.len() as u64, 40_000, |i| rates[i as usize]).unwrap();
assert_eq!(st.next_id as usize, births.len());
assert!(births.iter().all(|&b| f64::from(b) / SPS < 0.5), "nothing born once the ramp reaches 0");
}
// ------------------------- CC Particle Systems II and CC Mr. Mercury's Birth Rate over time
/// A stepped particle effect: its id, physics and simulation.
struct System {
id: &'static str,
phys: fn(&EffectCtx) -> Phys,
stepped: Stepped<'static>,
}
const SYSTEMS: [System; 2] =
[System { id: "ec.sim.ccparticlesystems2", phys: ps2_phys, stepped: PS2 }, System { id: "ec.sim.ccmrmercury", phys: mercury_phys, stepped: MERCURY }];
/// `id`'s parameters with Birth Rate `now` and Longevity 1 s.
fn sys_params(id: &str, now: f64) -> crate::Params {
let spec = crate::find(id).unwrap();
let mut p = crate::Params { values: spec.params.iter().map(|ps| (ps.id.to_string(), crate::default_value(ps, [100.0, 100.0]))).collect() };
for (k, v) in [("birthRate", num(now)), ("longevity", num(1.0))] {
assert!(p.values.contains_key(k), "{id}: no {k}");
p.values.insert(k.to_string(), v);
}
p
}
/// The context at layer time `t` (`seed` keeps each test's simulations apart).
fn sys_ctx<'a>(p: &'a crate::Params, t: f64, host: Option<&'a Rate>, seed: u32) -> EffectCtx<'a> {
let env = EffectEnv { host: host.map(|h| h as &dyn crate::EffectHost), effect_index: host.map_or(0, |h| h.index), ..Default::default() };
EffectCtx { params: p, time: t, layer_size: [100.0, 100.0], seed, adjustment: false, env }
}
/// The live particles (id, position, age) of `sys` at `t` through its simulation.
fn sys_at(sys: &System, seed: u32, now: f64, t: f64, host: Option<&Rate>) -> Vec<(u32, [f32; 3], f32)> {
let p = sys_params(sys.id, now);
let ctx = sys_ctx(&p, t, host, seed);
sys.stepped.state(&ctx, &(sys.phys)(&ctx)).parts.iter().map(|q| (q.id, q.p, q.age)).collect()
}
/// The same, simulated from layer time 0 with a cache of its own (the reference).
fn sys_fresh(sys: &System, seed: u32, now: f64, t: f64, host: &Rate) -> Vec<(u32, [f32; 3], f32)> {
let p = sys_params(sys.id, now);
let ctx = sys_ctx(&p, t, Some(host), seed);
let mut skip = sys.stepped.skip.to_vec();
skip.push("birthRate");
let key = params_key_except(&ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, sys.stepped.salt, &skip);
let st = simulate_rates(&SimCache::new(1), key, &(sys.phys)(&ctx), &rate_history(&ctx, "birthRate", sys.stepped.k));
st.parts.iter().map(|q| (q.id, q.p, q.age)).collect()
}
/// What `sys` draws at `t`: CC Particle Systems II's particles, CC Mr. Mercury's blobs.
fn sys_drawn(sys: &System, seed: u32, now: f64, t: f64, host: Option<&Rate>) -> usize {
let p = sys_params(sys.id, now);
let ctx = sys_ctx(&p, t, host, seed);
match sys.id {
"ec.sim.ccmrmercury" => mercury_blobs(&ctx, &Buf { img: Image::new(100, 100), offset: [0.0; 2], scale: 1.0 }).len(),
id => particle_state(id, &ctx).unwrap().len(),
}
}
/// #1373 for CC Particle Systems II and CC Mr. Mercury: a burst lives on after Birth Rate
/// drops to 0 (what they draw too), moving and ageing, and is gone after its Longevity.
#[test]
fn particle_systems_ii_and_mr_mercury_keep_a_burst_after_birth_rate_drops() {
let host = Rate { index: 1, rate: burst };
for sys in &SYSTEMS {
let born = crate::psim::births_at(steps_at(0.5), 40_000, |i| burst(i as f64 / SPS) * sys.stepped.k).unwrap().len();
assert_eq!(sys_at(sys, 201, 3.0, 0.4, Some(&host)), sys_at(sys, 201, 3.0, 0.4, None), "{}: during the burst", sys.id);
let late = sys_at(sys, 201, 0.0, 0.9, Some(&host));
assert!(late.len() > born / 2 && late.iter().all(|q| (q.0 as usize) < born), "{}: {} live of {born}", sys.id, late.len());
assert!(sys_drawn(sys, 201, 0.0, 0.9, Some(&host)) > 0, "{}: drawn", sys.id);
assert_eq!(sys_drawn(sys, 201, 0.0, 0.9, None), 0, "{}: a constant 0 bore none", sys.id);
let early = sys_at(sys, 201, 0.0, 0.7, Some(&host));
let q0 = early.iter().find(|q| q.0 == late[0].0).unwrap();
assert!(q0.1 != late[0].1 && late[0].2 > q0.2 + 0.19, "{}: {q0:?} -> {:?}", sys.id, late[0]);
assert!(sys_at(sys, 201, 0.0, 1.8, Some(&host)).is_empty(), "{}: gone after Longevity", sys.id);
}
}
/// Seeking matches a simulation from 0, and moving an earlier key (same current rate, same
/// cache key) isn't served from the old history's checkpoints.
#[test]
fn particle_systems_ii_and_mr_mercury_seek_and_drop_stale_checkpoints() {
let (long, short) = (Rate { index: 0, rate: burst }, Rate { index: 0, rate: short_burst });
for sys in &SYSTEMS {
for t in [1.4, 0.6, 0.9, 0.3, 0.9, 1.1] {
let now = burst(t);
assert_eq!(sys_at(sys, 202, now, t, Some(&long)), sys_fresh(sys, 202, now, t, &long), "{} at {t}", sys.id);
}
let b = sys_at(sys, 202, 0.0, 0.9, Some(&short));
assert_eq!(b, sys_fresh(sys, 202, 0.0, 0.9, &short), "{}: the moved key", sys.id);
assert!(!b.is_empty() && b.len() < sys_at(sys, 202, 0.0, 0.9, Some(&long)).len(), "{}", sys.id);
}
}
/// A constant Birth Rate gives exactly the particles the constant-rate simulation did.
#[test]
fn particle_systems_ii_and_mr_mercury_are_unchanged_at_a_constant_rate() {
let host = Rate { index: 0, rate: |_| 2.0 };
for sys in &SYSTEMS {
let p = sys_params(sys.id, 2.0);
let ctx = sys_ctx(&p, 1.3, None, 203);
let before = simulate(&SimCache::new(1), 1, 1.3, &(sys.phys)(&ctx));
let before: Vec<_> = before.parts.iter().map(|q| (q.id, q.p, q.age)).collect();
assert!(!before.is_empty());
assert_eq!(sys_at(sys, 203, 2.0, 1.3, Some(&host)), before, "{}: keyed to a constant", sys.id);
assert_eq!(sys_at(sys, 203, 2.0, 1.3, None), before, "{}: no host", sys.id);
}
}
}