Files
effectcraft/crates/effects/src/sim.rs
T
Brandon ThomasandClaude Opus 5.5 355a6255a2 M9.12: effect parameter fidelity – all categories
Align every effect's display name, category, parameter names, order,
twirl-down groups, popup options, units, defaults and ranges with the
public After Effects effect reference, and implement controls that were
ignored or missing where cheap. One commit because the categories share
the registry, migration tables and GPU kernels (only green states).

Infrastructure
- effects::migrate: saved instances are upgraded on project open
  (Session::replace_project → upgrade_effects): renamed ids
  (PARAM_ID_ALIASES), params moved into twirl-down groups, popup values
  remapped by label (POPUP_LABEL_ALIASES), refreshed names/UI hints,
  added params created with legacy_default values that keep old renders.
- flatten_params also keys nested props by match path (group/param), so
  effects declare grouped params with `group/param` ids; layer-source
  companions sit next to grouped layer params.
- lookup() resolves old effect names (EFFECT_NAME_ALIASES).
- Registry-wide test: unique ids/names, non-empty names, defaults in
  range, popup defaults within options, value kinds match UI.
- docs/effects.md generated from the registry (catalog.rs) with GPU /
  32-bpc badges and per-effect status; a test keeps it current.

Batches
- Blur & Sharpen, Channel, 3D Channel, Perspective, Text, Utility:
  Camera Lens Blur iris/highlight groups, Compound Blur layer, Camera-
  Shake Deblur method/strength, Invert full channel list (CPU HLS/YIQ),
  Set Channels/Set Matte layers, Channel Combiner targets, 3D Channel
  Extract clamp/invert.
- Color Correction: temporal smoothing + scene detect for Auto Color/
  Contrast/Levels and Shadow/Highlight, Brightness & Contrast legacy
  switch, Change to Color tolerance group, Leave Color, Photo Filter
  presets, Exposure channels, Levels clip popups, Colorama phase source,
  Color Link sample list, Lumetri groups (CPU + GPU kernels).
- Distort: Bulge, Displacement Map layer/behaviour, Magnify, Optics
  Compensation resize, Ripple conversion, Transform sampling, Turbulent
  Displace types/pinning/evolution, Wave Warp types/pinning, Twirl and
  Ripple radius fixes.
- Generate: 4-Color Gradient, Checkerboard, Grid, Circle size/feather/
  blending, Advanced Lightning types, Cell Pattern, Radio Waves, Fractal
  palettes, Scribble/Vegas names.
- Keying, Matte, Obsolete: Color Difference Key partials/views, Unmult
  controls, Key Light groups, Refine Soft/Hard Matte, Roto Brush groups,
  Mocha shape moved to Matte, Spill Suppressor accuracy, Basic/Path Text
  groups.
- Noise & Grain, Immersive Video, Audio, Time: Fractal/Turbulent Noise
  full control set (noise3.rs, GPU), grain look groups, Remove Grain,
  VR effects, Compressor/Gate/Distortion controls, Timewarp groups,
  VR Sphere to Plane name.
- Simulation: AE twirl-down groups for all simulations, Foam strength,
  Particle Playground barrel radius and mapper targets.
- Stylize, Transition: Glow looping/dimensions/alpha (CPU + GPU),
  Mosaic averaging, Cartoon groups, Roughen Edges evolution, Texturize
  layer, Gradient Wipe layer, Radial Wipe both, Card Wipe options.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 00:15:06 -04:00

1544 lines
68 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, 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(crate) 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(crate) 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(crate) 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 }
}
}
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 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 src = &b.img;
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()
.filter_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 mut c = [color[0], color[1], color[2], opacity * (0.35 + 0.65 * k)];
if refract {
// Refracting drops show the scene behind them, brightened and offset.
let (sc, sa) = unpremul(src.sample_bilinear(bx + 2.0 * s as f64, by));
let m = |a: f32, cc: f32| (a * 0.6 + 0.5) * cc;
if sa > 0.0 {
c = [m(sc[0], color[0]), m(sc[1], color[1]), m(sc[2], color[2]), c[3]];
}
}
let r = (size * 0.6 * k * s).max(0.25);
Some(Sprite::new(bx as f32, by as f32, r, c, Shape::Line { dx: -lenx * s, dy: -len * s }))
})
.collect();
weather_out(b, &sprites, mode, with_orig)
}
fn weather_out(mut b: Buf, sprites: &[Sprite], mode: u32, with_orig: bool) -> Buf {
let fx = splat(b.img.width, b.img.height, sprites, Acc::Over);
b.img = if with_orig { combine(&b.img, &fx, if mode == 1 { 3 } else { 0 }) } else { fx };
b
}
fn snowfall(ctx: &EffectCtx, b: Buf) -> Buf {
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();
weather_out(b, &sprites, mode, with_orig)
}
// ------------------------------------------------------------------ CC Bubbles
struct Bubble {
x: f32,
y: f32,
r: f32,
/// Source sample centre (buffer px).
sx: f32,
sy: f32,
}
fn bubbles(ctx: &EffectCtx, mut b: Buf) -> Buf {
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 metal = pr.e("reflectionType") == 1;
let shading = pr.e("shadingType");
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;
let list: Vec<Bubble> = (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();
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
fn drizzle(ctx: &EffectCtx, mut b: Buf) -> Buf {
let pr = ctx.params;
let rate = pr.f("dripRate").max(0.0);
let life = pr.f("longevity").max(0.01);
let rippling = (pr.f("rippling") / 360.0).max(0.05) as f32;
let disp = pr.f("displacement") as f32;
let height = pr.f("rippleHeight") as f32 / 100.0;
let spreading = pr.f("spreading") 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 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 b;
}
// 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));
}
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, mut b: Buf) -> Buf {
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 src = b.img.clone();
let (cx, cy) = (lw * 0.5, lh * 0.5);
let mut list: Vec<(f32, Sprite)> = (0..nx * ny)
.into_par_iter()
.filter_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]);
let (c, a) = unpremul(src.sample_bilinear(sx, sy));
if a <= 0.0 {
return None;
}
// 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);
Some((z, Sprite::new(bx as f32, by as f32, r, [c[0], c[1], c[2], a * fade], Shape::Disc)))
})
.collect();
list.sort_by(|a, b| a.0.total_cmp(&b.0));
let sprites: Vec<Sprite> = list.into_iter().map(|(_, s)| s).collect();
let fx = splat(src.width, src.height, &sprites, Acc::Over);
b.img = fx;
if blend > 0.0 {
b.img.data.par_iter_mut().zip(src.data.par_iter()).for_each(|(o, s0)| {
*o = crate::util::lerp4(*o, *s0, blend);
});
}
b
}
// ------------------------------------------------------------------ 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,
}
/// 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 }
}
pub(crate) fn step(&self, st: &mut PState, step: u64) {
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.carry += self.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);
}
}
}
/// Run a particle system to layer time `t` through `cache`.
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);
fn particle_world(ctx: &EffectCtx, mut b: Buf) -> Buf {
let pr = ctx.params;
let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32);
let phys = Phys {
rate: pr.f("birthRate").max(0.0) * 120.0,
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,
};
if phys.rate <= 0.0 && ctx.time <= 0.0 {
return b;
}
let st = simulate(&PW_CACHE, params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 1), ctx.time, &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;
// Camera at distance 2 (world units = layer width) looking at the origin (layer centre).
let cam = 2.0f32;
let mut list: Vec<(f32, Sprite)> = st
.parts
.par_iter()
.filter_map(|q| {
let zc = cam + q.p[2];
if zc <= 0.05 {
return None;
}
let k = cam / zc;
let x = lw * 0.5 + q.p[0] * lw * k;
let y = lh * 0.5 + q.p[1] * lw * k;
let (bx, by) = b.to_px([x as f64, y as f64]);
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, bx as f32, by as f32, r, c, q.v[0] * lw * k * s, q.v[1] * lw * k * s, (q.id % 628) as f32 * 0.01 + q.age * 2.0);
Some((zc, sp))
})
.collect();
list.sort_by(|a, b| b.0.total_cmp(&a.0));
let sprites: Vec<Sprite> = list.into_iter().map(|(_, s)| s).collect();
let mode = pr.e("particle/transferMode");
let fx = splat(b.img.width, b.img.height, &sprites, if mode == 2 { Acc::Add } else { Acc::Over });
b.img = combine(
&b.img,
&fx,
match mode {
0 => 0,
1 => 1,
2 => 2,
3 => 5,
_ => 0,
},
);
b
}
// ------------------------------------------------------------------ CC Particle Systems II
static PS2_CACHE: SimCache<PState> = SimCache::new(6);
fn particle_systems2(ctx: &EffectCtx, mut b: Buf) -> Buf {
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();
let phys = Phys {
rate: pr.f("birthRate").max(0.0) * 60.0,
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,
};
let st = simulate(&PS2_CACHE, params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 2), ctx.time, &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 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();
let mode = pr.e("particle/transferMode");
let fx = splat(b.img.width, b.img.height, &sprites, if mode == 2 { Acc::Add } else { Acc::Over });
b.img = combine(
&b.img,
&fx,
match mode {
0 => 0,
1 => 1,
2 => 2,
3 => 5,
_ => 0,
},
);
b
}
// ------------------------------------------------------------------ CC Mr. Mercury
static MERCURY_CACHE: SimCache<PState> = SimCache::new(6);
struct Blob {
x: f32,
y: f32,
r: f32,
}
fn mr_mercury(ctx: &EffectCtx, mut b: Buf) -> Buf {
let pr = ctx.params;
let lh = ctx.layer_size[1] as f32;
let unit = lh.max(1.0);
let pos = pr.v2("producer");
let dir = (pr.f("direction") as f32).to_radians();
let phys = Phys {
rate: pr.f("birthRate").max(0.0) * 10.0,
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,
};
let st = simulate(&MERCURY_CACHE, params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 3), ctx.time, &phys);
let (bs, ds) = (pr.f("blobBirthSize") as f32, pr.f("blobDeathSize") as f32);
let influence = pr.f("blobInfluence") as f32 / 100.0;
let s = b.scale as f32;
let blobs: Vec<Blob> = 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();
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/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`) in y.
fn mass_y(img: &Image, base: &Image) -> f32 {
let (mut m, mut sy) = (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;
sy += d * y as f32;
}
}
if m > 0.0 { sy / m } else { -1.0 }
}
#[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}");
}
#[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}");
let none = run("ec.sim.ccparticlesystems2", &[("birthRate", num(0.0))], grey(20, 20), 1.0);
assert_eq!(none, grey(20, 20));
}
#[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));
}
}