From c91af194f2661670719bd8073987521e0828760d Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 21:43:48 -0400 Subject: [PATCH 01/14] M9.3: effect env (masks, layer params, audio, comp timing), sim cache, polyline helpers; module stubs Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/blur2.rs | 3 +- crates/effects/src/channel.rs | 3 +- crates/effects/src/channel2.rs | 7 + crates/effects/src/color2.rs | 3 +- crates/effects/src/color3.rs | 7 + crates/effects/src/distort2.rs | 2 +- crates/effects/src/distort3.rs | 7 + crates/effects/src/generate2.rs | 2 +- crates/effects/src/generate3.rs | 7 + crates/effects/src/keying.rs | 3 +- crates/effects/src/keying2.rs | 7 + crates/effects/src/lib.rs | 112 ++++++++++++- crates/effects/src/matte.rs | 3 +- crates/effects/src/noise.rs | 2 +- crates/effects/src/noise2.rs | 7 + crates/effects/src/obsolete.rs | 7 + crates/effects/src/perspective.rs | 3 +- crates/effects/src/perspective2.rs | 7 + crates/effects/src/sim.rs | 7 + crates/effects/src/sim2.rs | 7 + crates/effects/src/stylize2.rs | 2 +- crates/effects/src/stylize3.rs | 7 + crates/effects/src/textfx.rs | 7 + crates/effects/src/transition.rs | 3 +- crates/effects/src/transition2.rs | 7 + crates/effects/src/util.rs | 243 +++++++++++++++++++++++++++++ crates/effects/src/utility.rs | 3 +- crates/effects/src/utility2.rs | 7 + crates/media/src/pool.rs | 7 + crates/render/src/lib.rs | 44 +++++- crates/render/src/masks.rs | 28 ++++ 31 files changed, 549 insertions(+), 15 deletions(-) create mode 100644 crates/effects/src/channel2.rs create mode 100644 crates/effects/src/color3.rs create mode 100644 crates/effects/src/distort3.rs create mode 100644 crates/effects/src/generate3.rs create mode 100644 crates/effects/src/keying2.rs create mode 100644 crates/effects/src/noise2.rs create mode 100644 crates/effects/src/obsolete.rs create mode 100644 crates/effects/src/perspective2.rs create mode 100644 crates/effects/src/sim.rs create mode 100644 crates/effects/src/sim2.rs create mode 100644 crates/effects/src/stylize3.rs create mode 100644 crates/effects/src/textfx.rs create mode 100644 crates/effects/src/transition2.rs create mode 100644 crates/effects/src/utility2.rs diff --git a/crates/effects/src/blur2.rs b/crates/effects/src/blur2.rs index 95853e7c..def9d207 100644 --- a/crates/effects/src/blur2.rs +++ b/crates/effects/src/blur2.rs @@ -621,7 +621,8 @@ mod tests { for (k, v) in over { params.values.insert(k.to_string(), v.clone()); } - let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false }; + let ctx = + EffectCtx { params: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false, env: Default::default() }; (s.render)(&ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 }) } diff --git a/crates/effects/src/channel.rs b/crates/effects/src/channel.rs index 161fde37..383a95af 100644 --- a/crates/effects/src/channel.rs +++ b/crates/effects/src/channel.rs @@ -425,7 +425,8 @@ mod tests { for (k, v) in over { params.values.insert(k.to_string(), v.clone()); } - let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: true }; + let ctx = + EffectCtx { params: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: true, env: Default::default() }; apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 }).img } diff --git a/crates/effects/src/channel2.rs b/crates/effects/src/channel2.rs new file mode 100644 index 00000000..70516adc --- /dev/null +++ b/crates/effects/src/channel2.rs @@ -0,0 +1,7 @@ +//! Channel effects, batch 2. + +use crate::EffectSpec; + +pub fn specs() -> Vec { + Vec::new() +} diff --git a/crates/effects/src/color2.rs b/crates/effects/src/color2.rs index 136ba00e..8d707c68 100644 --- a/crates/effects/src/color2.rs +++ b/crates/effects/src/color2.rs @@ -886,7 +886,8 @@ mod tests { for (k, v) in over { params.values.insert(k.to_string(), v.clone()); } - let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false }; + let ctx = + EffectCtx { params: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false, env: Default::default() }; (s.render)(&ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 }).img } diff --git a/crates/effects/src/color3.rs b/crates/effects/src/color3.rs new file mode 100644 index 00000000..cc39ceb9 --- /dev/null +++ b/crates/effects/src/color3.rs @@ -0,0 +1,7 @@ +//! Color Correction effects, batch 3. + +use crate::EffectSpec; + +pub fn specs() -> Vec { + Vec::new() +} diff --git a/crates/effects/src/distort2.rs b/crates/effects/src/distort2.rs index 20eb1bdc..c3496c59 100644 --- a/crates/effects/src/distort2.rs +++ b/crates/effects/src/distort2.rs @@ -690,7 +690,7 @@ mod tests { for (k, v) in set { params.values.insert(k.to_string(), v.clone()); } - let ctx = EffectCtx { params: ¶ms, time: 0.5, layer_size: ls, seed: 3, adjustment: false }; + let ctx = EffectCtx { params: ¶ms, time: 0.5, layer_size: ls, seed: 3, adjustment: false, env: Default::default() }; crate::apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 }) } diff --git a/crates/effects/src/distort3.rs b/crates/effects/src/distort3.rs new file mode 100644 index 00000000..4dca9381 --- /dev/null +++ b/crates/effects/src/distort3.rs @@ -0,0 +1,7 @@ +//! Distort effects, batch 3. + +use crate::EffectSpec; + +pub fn specs() -> Vec { + Vec::new() +} diff --git a/crates/effects/src/generate2.rs b/crates/effects/src/generate2.rs index 784f1916..6aa30682 100644 --- a/crates/effects/src/generate2.rs +++ b/crates/effects/src/generate2.rs @@ -754,7 +754,7 @@ mod tests { for (k, v) in set { params.values.insert(k.to_string(), v.clone()); } - let ctx = EffectCtx { params: ¶ms, time: 0.5, layer_size: ls, seed: 3, adjustment: false }; + let ctx = EffectCtx { params: ¶ms, time: 0.5, layer_size: ls, seed: 3, adjustment: false, env: Default::default() }; crate::apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 }) } diff --git a/crates/effects/src/generate3.rs b/crates/effects/src/generate3.rs new file mode 100644 index 00000000..e50cf9f5 --- /dev/null +++ b/crates/effects/src/generate3.rs @@ -0,0 +1,7 @@ +//! Generate effects, batch 3. + +use crate::EffectSpec; + +pub fn specs() -> Vec { + Vec::new() +} diff --git a/crates/effects/src/keying.rs b/crates/effects/src/keying.rs index 106a9d2f..c451c751 100644 --- a/crates/effects/src/keying.rs +++ b/crates/effects/src/keying.rs @@ -617,7 +617,8 @@ mod tests { for (k, v) in over { params.values.insert(k.to_string(), v.clone()); } - let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false }; + let ctx = + EffectCtx { params: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false, env: Default::default() }; apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 }).img } diff --git a/crates/effects/src/keying2.rs b/crates/effects/src/keying2.rs new file mode 100644 index 00000000..5b514cb9 --- /dev/null +++ b/crates/effects/src/keying2.rs @@ -0,0 +1,7 @@ +//! Keying effects, batch 2. + +use crate::EffectSpec; + +pub fn specs() -> Vec { + Vec::new() +} diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 7dc27fa2..0355bd78 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -8,22 +8,36 @@ mod blur2; mod channel; +mod channel2; mod color2; +mod color3; mod color_fx; mod controls; mod distort; mod distort2; +mod distort3; mod generate; mod generate2; +mod generate3; mod keying; +mod keying2; mod matte; mod misc; mod noise; +mod noise2; +mod obsolete; mod perspective; +mod perspective2; +mod sim; +mod sim2; mod stylize2; +mod stylize3; +mod textfx; mod transition; +mod transition2; pub mod util; mod utility; +mod utility2; use std::collections::HashMap; use std::sync::OnceLock; @@ -122,6 +136,45 @@ impl Buf { } } +/// A layer mask at the current time, flattened to a polyline in layer coordinates. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct MaskShape { + pub name: String, + pub points: Vec<[f64; 2]>, + pub closed: bool, + pub inverted: bool, +} + +/// Pixels of another layer referenced by a layer parameter: its buffer (layer space, see +/// [`Buf`]) and its source size in layer pixels. +#[derive(Clone, Debug, Default)] +pub struct LayerPixels { + pub buf: Buf, + pub size: [f64; 2], +} + +/// Services the renderer offers effects that look beyond their own pixels. +pub trait EffectHost: Sync { + /// Layer `id` of the same composition at the current comp time. `masks_and_effects` + /// selects "Effects & Masks" over "Source". `None` for missing/self/recursive references. + fn layer(&self, id: u64, masks_and_effects: bool) -> Option; + /// `frames` stereo sample frames (interleaved L R …) of layer `id`'s audio starting at comp + /// time `start` seconds, at `rate` Hz. `None` when the layer has no audio. + fn audio(&self, id: u64, start: f64, frames: usize, rate: u32) -> Option>; +} + +/// Extra context the renderer may supply (all optional; `Default` is "nothing known"). +#[derive(Clone, Copy, Default)] +pub struct EffectEnv<'a> { + /// The layer's masks (enabled ones, top to bottom). + pub masks: &'a [MaskShape], + pub host: Option<&'a dyn EffectHost>, + /// Composition time in seconds. + pub comp_time: f64, + /// Composition frame rate (0 when unknown). + pub frame_rate: f64, +} + /// What an effect gets to render with. pub struct EffectCtx<'a> { pub params: &'a Params, @@ -133,6 +186,20 @@ pub struct EffectCtx<'a> { pub seed: u32, /// Rendering for an adjustment layer (operating on the comp below). pub adjustment: bool, + /// Masks, other layers, audio, comp timing. + pub env: EffectEnv<'a>, +} + +impl EffectCtx<'_> { + /// The layer chosen in layer parameter `id`, via the host. + pub fn layer_param(&self, id: &str, masks_and_effects: bool) -> Option { + let lid = self.params.get(id)?.as_layer()?; + self.env.host?.layer(lid, masks_and_effects) + } + /// Frame rate for frame-based maths (comp rate, else 30). + pub fn fps(&self) -> f64 { + if self.env.frame_rate > 0.0 { self.env.frame_rate } else { 30.0 } + } } pub type RenderFn = fn(&EffectCtx, Buf) -> Buf; @@ -187,6 +254,20 @@ pub fn registry() -> &'static [EffectSpec] { v.extend(perspective::specs()); v.extend(transition::specs()); v.extend(utility::specs()); + v.extend(sim::specs()); + v.extend(sim2::specs()); + v.extend(distort3::specs()); + v.extend(perspective2::specs()); + v.extend(utility2::specs()); + v.extend(generate3::specs()); + v.extend(textfx::specs()); + v.extend(stylize3::specs()); + v.extend(transition2::specs()); + v.extend(channel2::specs()); + v.extend(keying2::specs()); + v.extend(noise2::specs()); + v.extend(color3::specs()); + v.extend(obsolete::specs()); v.sort_by(|a, b| a.category.cmp(b.category).then(a.name.cmp(b.name))); v }) @@ -227,10 +308,39 @@ pub fn apply(spec: &EffectSpec, ctx: &EffectCtx, buf: Buf) -> Buf { (spec.render)(ctx, buf) } +/// Test helper: run effect `id` on `img` (scale 1, layer size = image size) with its defaults +/// overridden by `vals`, at layer time `time`, with an optional environment. +#[cfg(test)] +pub(crate) fn run_fx(id: &str, vals: &[(&str, Value)], img: Image, time: f64, env: EffectEnv) -> Buf { + let s = find(id).unwrap_or_else(|| panic!("no effect {id}")); + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + for (k, v) in vals { + assert!(params.values.contains_key(*k), "{id}: unknown param {k}"); + params.values.insert(k.to_string(), v.clone()); + } + let ctx = EffectCtx { params: ¶ms, time, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false, env }; + apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 }) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn run_fx_passthrough() { + let img = Image::filled(4, 3, [0.5, 0.25, 0.125, 1.0]); + let out = run_fx("ec.control.slider", &[("slider", num(3.0))], img.clone(), 0.0, EffectEnv::default()); + assert_eq!(out.img.data, img.data); + } + + #[test] + #[ignore] + fn dump_registry() { + for s in registry() { + println!("{} | {} | {}", s.category, s.name, s.id); + } + } + #[test] fn registry_is_consistent() { let r = registry(); @@ -259,7 +369,7 @@ mod tests { } for s in registry() { let params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; - let ctx = EffectCtx { params: ¶ms, time: 0.5, layer_size: [48.0, 32.0], seed: 1, adjustment: false }; + let ctx = EffectCtx { params: ¶ms, time: 0.5, layer_size: [48.0, 32.0], seed: 1, adjustment: false, env: Default::default() }; let out = apply(s, &ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 }); assert!(!out.img.is_empty(), "{}", s.id); assert!(out.img.data.iter().all(|p| p[3].is_finite() && p[3] >= -1e-4 && p[3] <= 1.0001), "{} alpha out of range", s.id); diff --git a/crates/effects/src/matte.rs b/crates/effects/src/matte.rs index 05dcd451..49914c80 100644 --- a/crates/effects/src/matte.rs +++ b/crates/effects/src/matte.rs @@ -200,7 +200,8 @@ mod tests { for (k, v) in over { params.values.insert(k.to_string(), v.clone()); } - let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: true }; + let ctx = + EffectCtx { params: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: true, env: Default::default() }; apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 }).img } diff --git a/crates/effects/src/noise.rs b/crates/effects/src/noise.rs index 759309e6..8809c1e6 100644 --- a/crates/effects/src/noise.rs +++ b/crates/effects/src/noise.rs @@ -531,7 +531,7 @@ mod tests { for (k, v) in vals { params.values.insert(k.to_string(), v.clone()); } - let ctx = EffectCtx { params: ¶ms, time, layer_size: [img.width as f64, img.height as f64], seed: 3, adjustment: false }; + let ctx = EffectCtx { params: ¶ms, time, layer_size: [img.width as f64, img.height as f64], seed: 3, adjustment: false, env: Default::default() }; crate::apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 }) } diff --git a/crates/effects/src/noise2.rs b/crates/effects/src/noise2.rs new file mode 100644 index 00000000..bb1953bc --- /dev/null +++ b/crates/effects/src/noise2.rs @@ -0,0 +1,7 @@ +//! Noise & Grain effects, batch 2. + +use crate::EffectSpec; + +pub fn specs() -> Vec { + Vec::new() +} diff --git a/crates/effects/src/obsolete.rs b/crates/effects/src/obsolete.rs new file mode 100644 index 00000000..331686c1 --- /dev/null +++ b/crates/effects/src/obsolete.rs @@ -0,0 +1,7 @@ +//! Obsolete-category effects. + +use crate::EffectSpec; + +pub fn specs() -> Vec { + Vec::new() +} diff --git a/crates/effects/src/perspective.rs b/crates/effects/src/perspective.rs index 2a032eca..014353fc 100644 --- a/crates/effects/src/perspective.rs +++ b/crates/effects/src/perspective.rs @@ -452,7 +452,8 @@ mod tests { params.values.insert(ps.id.to_string(), Value::Vec2([f[0] * img.width as f64, f[1] * img.height as f64])); } } - let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false }; + let ctx = + EffectCtx { params: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false, env: Default::default() }; (s.render)(&ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 }) } diff --git a/crates/effects/src/perspective2.rs b/crates/effects/src/perspective2.rs new file mode 100644 index 00000000..60c61d02 --- /dev/null +++ b/crates/effects/src/perspective2.rs @@ -0,0 +1,7 @@ +//! Perspective effects, batch 2. + +use crate::EffectSpec; + +pub fn specs() -> Vec { + Vec::new() +} diff --git a/crates/effects/src/sim.rs b/crates/effects/src/sim.rs new file mode 100644 index 00000000..dc8d20ed --- /dev/null +++ b/crates/effects/src/sim.rs @@ -0,0 +1,7 @@ +//! Simulation effects (particles, physics, liquids). + +use crate::EffectSpec; + +pub fn specs() -> Vec { + Vec::new() +} diff --git a/crates/effects/src/sim2.rs b/crates/effects/src/sim2.rs new file mode 100644 index 00000000..d8d6ce96 --- /dev/null +++ b/crates/effects/src/sim2.rs @@ -0,0 +1,7 @@ +//! Simulation effects, part 2. + +use crate::EffectSpec; + +pub fn specs() -> Vec { + Vec::new() +} diff --git a/crates/effects/src/stylize2.rs b/crates/effects/src/stylize2.rs index 5eee2b4f..52e0b5c8 100644 --- a/crates/effects/src/stylize2.rs +++ b/crates/effects/src/stylize2.rs @@ -698,7 +698,7 @@ mod tests { for (k, v) in vals { params.values.insert(k.to_string(), v.clone()); } - let ctx = EffectCtx { params: ¶ms, time, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false }; + let ctx = EffectCtx { params: ¶ms, time, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false, env: Default::default() }; crate::apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 }) } diff --git a/crates/effects/src/stylize3.rs b/crates/effects/src/stylize3.rs new file mode 100644 index 00000000..16a4399e --- /dev/null +++ b/crates/effects/src/stylize3.rs @@ -0,0 +1,7 @@ +//! Stylize effects, batch 3. + +use crate::EffectSpec; + +pub fn specs() -> Vec { + Vec::new() +} diff --git a/crates/effects/src/textfx.rs b/crates/effects/src/textfx.rs new file mode 100644 index 00000000..ad66f957 --- /dev/null +++ b/crates/effects/src/textfx.rs @@ -0,0 +1,7 @@ +//! Text effects (Numbers, Timecode) and the obsolete text generators, drawn with a built-in stroke font. + +use crate::EffectSpec; + +pub fn specs() -> Vec { + Vec::new() +} diff --git a/crates/effects/src/transition.rs b/crates/effects/src/transition.rs index b1ea6074..49e12cd5 100644 --- a/crates/effects/src/transition.rs +++ b/crates/effects/src/transition.rs @@ -486,7 +486,8 @@ mod tests { for (k, v) in set { params.values.insert(k.to_string(), v.clone()); } - let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 7, adjustment: false }; + let ctx = + EffectCtx { params: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 7, adjustment: false, env: Default::default() }; (s.render)(&ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 }).img } diff --git a/crates/effects/src/transition2.rs b/crates/effects/src/transition2.rs new file mode 100644 index 00000000..5f33c4b3 --- /dev/null +++ b/crates/effects/src/transition2.rs @@ -0,0 +1,7 @@ +//! Transition effects, batch 2. + +use crate::EffectSpec; + +pub fn specs() -> Vec { + Vec::new() +} diff --git a/crates/effects/src/util.rs b/crates/effects/src/util.rs index 346d7619..9249ecf9 100644 --- a/crates/effects/src/util.rs +++ b/crates/effects/src/util.rs @@ -427,10 +427,253 @@ pub fn layer_rect(ctx: &crate::EffectCtx, b: &crate::Buf) -> (f64, f64, f64, f64 (b.offset[0], b.offset[1], ctx.layer_size[0] * b.scale, ctx.layer_size[1] * b.scale) } +/// Resample another layer (a layer parameter) into `b`'s pixel grid. With `stretch` the other +/// layer is scaled to this layer's size ("Stretch to Fit"); otherwise it is centred. +pub fn fit_layer(ctx: &crate::EffectCtx, b: &crate::Buf, other: &crate::LayerPixels, stretch: bool) -> Image { + let ls = ctx.layer_size; + let os = other.size; + let (sx, sy) = if stretch && ls[0] > 0.0 && ls[1] > 0.0 { (os[0] / ls[0], os[1] / ls[1]) } else { (1.0, 1.0) }; + let (dx, dy) = if stretch { (0.0, 0.0) } else { ((os[0] - ls[0]) * 0.5, (os[1] - ls[1]) * 0.5) }; + let inv = 1.0 / b.scale.max(1e-9); + gen_image(b.img.width, b.img.height, |x, y| { + let px = (x as f64 + 0.5 - b.offset[0]) * inv; + let py = (y as f64 + 0.5 - b.offset[1]) * inv; + let qx = px * sx + dx; + let qy = py * sy + dy; + other.buf.img.sample_bilinear(qx * other.buf.scale + other.buf.offset[0], qy * other.buf.scale + other.buf.offset[1]) + }) +} + +/// The layer chosen in layer parameter `id` fitted to `b` (see [`fit_layer`]), or a copy of +/// `b`'s own pixels when none is chosen / available (After Effects' "self" behaviour). +pub fn layer_or_self(ctx: &crate::EffectCtx, b: &crate::Buf, id: &str, masks_and_effects: bool, stretch: bool) -> Image { + match ctx.layer_param(id, masks_and_effects) { + Some(o) => fit_layer(ctx, b, &o, stretch), + None => b.img.clone(), + } +} + +/// A stable hash of an effect instance's evaluated parameters, seed, layer size and buffer +/// scale (cache key for simulations). +pub fn params_key(ctx: &crate::EffectCtx, b: &crate::Buf, salt: u64) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + let mut keys: Vec<&String> = ctx.params.values.keys().collect(); + keys.sort(); + for k in keys { + k.hash(&mut h); + format!("{:?}", ctx.params.values[k]).hash(&mut h); + } + ctx.seed.hash(&mut h); + ctx.layer_size[0].to_bits().hash(&mut h); + ctx.layer_size[1].to_bits().hash(&mut h); + b.scale.to_bits().hash(&mut h); + salt.hash(&mut h); + h.finish() +} + +/// Checkpointed, deterministic, time-seekable simulation cache. +/// +/// A simulation is a state advanced in fixed steps from layer time 0. [`SimCache::run`] returns +/// the state after `target` steps, starting from the nearest cached checkpoint at or before it +/// (so scrubbing forward is incremental and seeking anywhere gives the same answer as +/// simulating from scratch). Entries are keyed by [`params_key`]; the least recently used key +/// is evicted beyond `cap` keys. +pub struct SimCache { + inner: std::sync::Mutex>>, + cap: usize, +} + +struct SimEntry { + key: u64, + used: u64, + checkpoints: Vec<(u64, std::sync::Arc)>, +} + +/// Checkpoint every this many steps. +pub const SIM_CHECKPOINT: u64 = 15; + +impl SimCache { + pub const fn new(cap: usize) -> SimCache { + SimCache { inner: std::sync::Mutex::new(Vec::new()), cap } + } + + /// State after `target` steps. `init` builds step 0; `step(state, i)` advances from step + /// `i` to `i + 1`. + pub fn run(&self, key: u64, target: u64, init: impl FnOnce() -> S, mut step: impl FnMut(&mut S, u64)) -> std::sync::Arc { + use std::sync::Arc; + let start = { + let mut g = self.inner.lock().unwrap_or_else(|e| e.into_inner()); + let tick = g.iter().map(|e| e.used).max().unwrap_or(0) + 1; + g.iter_mut().find(|e| e.key == key).and_then(|e| { + e.used = tick; + e.checkpoints.iter().filter(|(s, _)| *s <= target).max_by_key(|(s, _)| *s).map(|(s, st)| (*s, st.clone())) + }) + }; + let had_start = start.is_some(); + let (mut i, mut st) = match start { + Some((s, st)) if s == target => return st, + Some((s, st)) => (s, (*st).clone()), + None => (0, init()), + }; + let mut fresh: Vec<(u64, Arc)> = Vec::new(); + if !had_start { + fresh.push((0, Arc::new(st.clone()))); + } + while i < target { + step(&mut st, i); + i += 1; + if i % SIM_CHECKPOINT == 0 && i != target { + fresh.push((i, Arc::new(st.clone()))); + } + } + let out = Arc::new(st); + fresh.push((target, out.clone())); + let mut g = self.inner.lock().unwrap_or_else(|e| e.into_inner()); + let tick = g.iter().map(|e| e.used).max().unwrap_or(0) + 1; + let idx = match g.iter().position(|e| e.key == key) { + Some(ix) => ix, + None => { + if g.len() >= self.cap.max(1) { + let old = g.iter().enumerate().min_by_key(|(_, e)| e.used).map(|(ix, _)| ix).unwrap_or(0); + g.remove(old); + } + g.push(SimEntry { key, used: tick, checkpoints: Vec::new() }); + g.len() - 1 + } + }; + let e = &mut g[idx]; + e.used = tick; + for (s, st) in fresh { + if !e.checkpoints.iter().any(|(c, _)| *c == s) { + e.checkpoints.push((s, st)); + } + } + // Keep memory bounded: drop unaligned (exact-target) checkpoints first, then thin. + if e.checkpoints.len() > 256 { + e.checkpoints.retain(|(s, _)| s % SIM_CHECKPOINT == 0); + e.checkpoints.sort_by_key(|(s, _)| *s); + let mut k = 0; + e.checkpoints.retain(|_| { + k += 1; + k % 2 == 1 + }); + } + out + } +} + +/// Point-in-polygon (even-odd) for a closed polyline. +pub fn point_in_poly(pts: &[[f64; 2]], x: f64, y: f64) -> bool { + let n = pts.len(); + if n < 3 { + return false; + } + let mut inside = false; + let mut j = n - 1; + for i in 0..n { + let (a, b) = (pts[i], pts[j]); + if (a[1] > y) != (b[1] > y) && x < (b[0] - a[0]) * (y - a[1]) / (b[1] - a[1]) + a[0] { + inside = !inside; + } + j = i; + } + inside +} + +/// Distance from a point to a polyline (closed adds the last→first segment). +pub fn dist_to_poly(pts: &[[f64; 2]], closed: bool, x: f64, y: f64) -> f64 { + let n = pts.len(); + if n == 0 { + return f64::INFINITY; + } + if n == 1 { + return ((pts[0][0] - x).powi(2) + (pts[0][1] - y).powi(2)).sqrt(); + } + let segs = if closed { n } else { n - 1 }; + let mut best = f64::INFINITY; + for i in 0..segs { + let a = pts[i]; + let b = pts[(i + 1) % n]; + let (dx, dy) = (b[0] - a[0], b[1] - a[1]); + let l2 = dx * dx + dy * dy; + let t = if l2 > 0.0 { (((x - a[0]) * dx + (y - a[1]) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 }; + let (px, py) = (a[0] + dx * t, a[1] + dy * t); + best = best.min((px - x).powi(2) + (py - y).powi(2)); + } + best.sqrt() +} + +/// Total length of a polyline. +pub fn poly_length(pts: &[[f64; 2]], closed: bool) -> f64 { + let n = pts.len(); + if n < 2 { + return 0.0; + } + let segs = if closed { n } else { n - 1 }; + (0..segs) + .map(|i| { + let (a, b) = (pts[i], pts[(i + 1) % n]); + ((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2)).sqrt() + }) + .sum() +} + +/// Point and unit tangent at arc length `s` along a polyline. +pub fn poly_point_at(pts: &[[f64; 2]], closed: bool, s: f64) -> ([f64; 2], [f64; 2]) { + let n = pts.len(); + if n == 0 { + return ([0.0; 2], [1.0, 0.0]); + } + if n == 1 { + return (pts[0], [1.0, 0.0]); + } + let segs = if closed { n } else { n - 1 }; + let mut rem = s.max(0.0); + for i in 0..segs { + let (a, b) = (pts[i], pts[(i + 1) % n]); + let l = ((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2)).sqrt(); + if rem <= l || i == segs - 1 { + let t = if l > 0.0 { (rem / l).min(1.0) } else { 0.0 }; + let d = if l > 0.0 { [(b[0] - a[0]) / l, (b[1] - a[1]) / l] } else { [1.0, 0.0] }; + return ([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t], d); + } + rem -= l; + } + (pts[n - 1], [1.0, 0.0]) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn sim_cache_is_seek_consistent() { + static C: SimCache = SimCache::new(2); + let step = |s: &mut u64, i: u64| *s = s.wrapping_mul(6364136223846793005).wrapping_add(i); + let direct = *C.run(1, 100, || 7, step); + let fresh: SimCache = SimCache::new(2); + let a = *fresh.run(1, 40, || 7, step); + let b = *fresh.run(1, 100, || 7, step); + let c = *fresh.run(1, 40, || 7, step); + let d = *fresh.run(1, 77, || 7, step); + let e = *C.run(1, 77, || 7, step); + assert_eq!(b, direct); + assert_eq!(a, c); + assert_eq!(d, e); + } + + #[test] + fn poly_helpers() { + let sq = [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]]; + assert!(point_in_poly(&sq, 5.0, 5.0)); + assert!(!point_in_poly(&sq, 15.0, 5.0)); + assert!((poly_length(&sq, true) - 40.0).abs() < 1e-9); + assert!((dist_to_poly(&sq, true, 5.0, 5.0) - 5.0).abs() < 1e-9); + let (pt, _) = poly_point_at(&sq, true, 15.0); + assert!((pt[0] - 10.0).abs() < 1e-9 && (pt[1] - 5.0).abs() < 1e-9); + } + #[test] fn minmax_matches_brute_force() { let src: Vec = (0..37).map(|i| ((i * 7919) % 31) as f32).collect(); diff --git a/crates/effects/src/utility.rs b/crates/effects/src/utility.rs index 2c3eebd0..49b7ed36 100644 --- a/crates/effects/src/utility.rs +++ b/crates/effects/src/utility.rs @@ -363,7 +363,8 @@ mod tests { for (k, v) in vals { params.values.insert(k.to_string(), v.clone()); } - let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false }; + let ctx = + EffectCtx { params: ¶ms, time: 0.0, layer_size: [img.width as f64, img.height as f64], seed: 1, adjustment: false, env: Default::default() }; crate::apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 }) } diff --git a/crates/effects/src/utility2.rs b/crates/effects/src/utility2.rs new file mode 100644 index 00000000..fcd43649 --- /dev/null +++ b/crates/effects/src/utility2.rs @@ -0,0 +1,7 @@ +//! Utility effects, batch 2. + +use crate::EffectSpec; + +pub fn specs() -> Vec { + Vec::new() +} diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index 37787442..773f78e9 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -454,6 +454,13 @@ impl FootageSource for MediaPool { } } } + + fn audio(&self, _item: ItemId, footage: &Footage, t: Tick, frames: usize, rate: u32) -> Option> { + if !footage.has_audio || footage.missing { + return None; + } + Some(self.audio_samples(footage, t, frames, rate)) + } } fn lock(m: &Mutex) -> std::sync::MutexGuard<'_, T> { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 83acd335..eeca6aae 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -14,7 +14,7 @@ pub mod text; use std::sync::Arc; use effectcraft_color::BlendMode; -use effectcraft_effects::{Buf, EffectCtx, Params}; +use effectcraft_effects::{Buf, EffectCtx, EffectEnv, EffectHost, LayerPixels, Params}; use effectcraft_geom::{Mat3, vec2}; use effectcraft_project::{Comp, Footage, GroupKind, ItemId, ItemKind, Layer, LayerSource, MatteKind, Node, Project, Quality, Sampling}; pub use effectcraft_raster::Image; @@ -27,6 +27,43 @@ use rayon::prelude::*; pub trait FootageSource: Send + Sync { /// The frame of `item` at source time `t`, straight from the file (any size). fn frame(&self, item: ItemId, footage: &Footage, t: Tick) -> Option>; + /// `frames` interleaved stereo sample frames of `item`'s audio from source time `t` at + /// `rate` Hz (`None` when unavailable). + fn audio(&self, _item: ItemId, _footage: &Footage, _t: Tick, _frames: usize, _rate: u32) -> Option> { + None + } +} + +/// Layer parameters and audio for effects (see [`effectcraft_effects::EffectHost`]). +struct FxHost<'r, 'a, 'c> { + r: &'r Renderer<'a>, + ctx: &'c EvalCtx<'a>, + layer: &'c Layer, +} + +impl EffectHost for FxHost<'_, '_, '_> { + fn layer(&self, id: u64, masks_and_effects: bool) -> Option { + let other = self.ctx.layer(effectcraft_project::LayerId(id))?; + if other.id == self.layer.id || self.r.depth > 8 { + return None; + } + let sub = Renderer { project: self.r.project, footage: self.r.footage, expr: self.r.expr, opts: self.r.opts, depth: self.r.depth + 1 }; + let buf = if masks_and_effects { sub.layer_buf(self.ctx, other)? } else { sub.source(self.ctx, other)? }; + let size = source_size(self.r.project, other); + let size = if size.0 == 0 { [self.ctx.comp.width as f64, self.ctx.comp.height as f64] } else { [size.0 as f64, size.1 as f64] }; + Some(LayerPixels { buf, size }) + } + + fn audio(&self, id: u64, start: f64, frames: usize, rate: u32) -> Option> { + let other = self.ctx.layer(effectcraft_project::LayerId(id))?; + let LayerSource::Footage { item } = &other.source else { return None }; + let ItemKind::Footage(f) = &self.r.project.item(*item)?.kind else { return None }; + if !f.has_audio { + return None; + } + let st = other.layer_time(Tick::from_seconds_f64(start)); + self.r.footage.audio(*item, f, st, frames, rate) + } } /// No footage available (renders footage layers as transparent). @@ -134,6 +171,9 @@ impl<'a> Renderer<'a> { let lt = layer.layer_time(ctx.time); let size = source_size(self.project, layer); let layer_size = if size.0 == 0 { [ctx.comp.width as f64, ctx.comp.height as f64] } else { [size.0 as f64, size.1 as f64] }; + let mask_shapes = masks::shapes(ctx, layer); + let host = FxHost { r: self, ctx, layer }; + let env = EffectEnv { masks: &mask_shapes, host: Some(&host), comp_time: ctx.time.seconds(), frame_rate: ctx.comp.frame_rate.as_f64() }; for g in fx.groups() { if !g.enabled { continue; @@ -141,7 +181,7 @@ impl<'a> Renderer<'a> { let GroupKind::Effect { effect } = &g.kind else { continue }; let Some(spec) = effectcraft_effects::find(effect) else { continue }; let params = self.effect_params(ctx, layer, g); - let ectx = EffectCtx { params: ¶ms, time: lt.seconds(), layer_size, seed: g.uid as u32, adjustment }; + let ectx = EffectCtx { params: ¶ms, time: lt.seconds(), layer_size, seed: g.uid as u32, adjustment, env }; buf = effectcraft_effects::apply(spec, &ectx, buf); } buf diff --git a/crates/render/src/masks.rs b/crates/render/src/masks.rs index 30eea2b3..f2805065 100644 --- a/crates/render/src/masks.rs +++ b/crates/render/src/masks.rs @@ -39,6 +39,34 @@ fn coverage(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, bu Some(cov) } +/// The layer's enabled masks at the context time, flattened to polylines in layer space (for +/// effects that use masks as paths: Stroke, Scribble, Inner/Outer Key, Reshape…). +pub fn shapes(ctx: &EvalCtx, layer: &Layer) -> Vec { + let Some(masks) = layer.masks() else { return Vec::new() }; + let mut out = Vec::new(); + for g in masks.groups().filter(|g| g.enabled) { + let GroupKind::Mask { inverted, .. } = g.kind else { continue }; + let Some(Value::Path(sp)) = ctx.group_value(layer, g, "path") else { continue }; + let path = effectcraft_path::to_kurbo(&sp); + let mut pts: Vec<[f64; 2]> = Vec::new(); + let mut closed = false; + kurbo::flatten(path.iter(), 0.25, |el| match el { + effectcraft_path::PathEl::MoveTo(p) | effectcraft_path::PathEl::LineTo(p) => { + if pts.last() != Some(&[p.x, p.y]) { + pts.push([p.x, p.y]); + } + } + effectcraft_path::PathEl::ClosePath => closed = true, + _ => {} + }); + if closed && pts.len() > 1 && pts.first() == pts.last() { + pts.pop(); + } + out.push(effectcraft_effects::MaskShape { name: g.name.clone(), points: pts, closed: closed || sp.closed, inverted }); + } + out +} + /// Apply the layer's masks to its buffer. pub fn apply(ctx: &EvalCtx, layer: &Layer, buf: &mut Buf) { let Some(masks) = layer.masks() else { return }; From 70c7f7c6167ef269964ca870128bc957ea4f50c8 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 21:58:24 -0400 Subject: [PATCH 02/14] M9.3: Distort effects (CC Bender, CC Blobbylize, CC Power Pin, CC Ripple Pulse, CC Slant, CC Smear, CC Split, CC Split 2, Smear, Reshape, Twirl (Legacy), Warp, Detail-preserving Upscale) Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/distort3.rs | 1055 +++++++++++++++++++++++++++++++- 1 file changed, 1052 insertions(+), 3 deletions(-) diff --git a/crates/effects/src/distort3.rs b/crates/effects/src/distort3.rs index 4dca9381..e7ca4910 100644 --- a/crates/effects/src/distort3.rs +++ b/crates/effects/src/distort3.rs @@ -1,7 +1,1056 @@ -//! Distort effects, batch 3. +//! Distort effects, batch 3: CC Bender, CC Blobbylize, CC Power Pin, CC Ripple Pulse, CC Slant, +//! CC Smear, CC Split, CC Split 2, Smear and Reshape (mask driven), Twirl (Legacy), Warp (the +//! fifteen warp styles) and Detail-preserving Upscale. +//! +//! All warps are inverse mapped: every output pixel finds the source point that lands on it. +//! Pixel-sized parameters are multiplied by the buffer scale (downsampled previews). -use crate::EffectSpec; +use std::f64::consts::{PI, TAU}; + +use effectcraft_geom::{Mat3, vec2}; +use effectcraft_keyframe::Value; +use effectcraft_project::ParamUi; +use effectcraft_raster::{Image, Px}; +use rayon::prelude::*; + +use crate::util::{Plane, dist_to_poly, gauss_plane, layer_or_self, pick, point_in_poly, poly_length, poly_point_at, premul, remap, src_at, unpremul}; +use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; + +fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { + EffectSpec { id, name, category: "Distort", params, render, gpu: false, float: true } +} + +fn pt(x: f64, y: f64) -> Value { + Value::Vec2([x, y]) +} + +/// Mask choices for mask popups ("None", "Mask 1" … "Mask 8"). +const MASK_CHOICES: [&str; 9] = ["None", "Mask 1", "Mask 2", "Mask 3", "Mask 4", "Mask 5", "Mask 6", "Mask 7", "Mask 8"]; + +/// The chosen mask as a polyline in buffer pixels (`None` for "None" or a missing mask). +fn mask_px(ctx: &EffectCtx, b: &Buf, id: &str) -> Option<(Vec<[f64; 2]>, bool)> { + let i = ctx.params.e(id) as usize; + if i == 0 { + return None; + } + let m = ctx.env.masks.get(i - 1)?; + if m.points.len() < 2 { + return None; + } + Some((m.points.iter().map(|q| [q[0] * b.scale + b.offset[0], q[1] * b.scale + b.offset[1]]).collect(), m.closed)) +} + +/// Resample a polyline to `n` points evenly spaced by arc length. +fn resample_poly(pts: &[[f64; 2]], closed: bool, n: usize) -> Vec<[f64; 2]> { + let len = poly_length(pts, closed); + let div = if closed { n as f64 } else { (n - 1).max(1) as f64 }; + (0..n).map(|i| poly_point_at(pts, closed, len * i as f64 / div).0).collect() +} + +fn centroid(pts: &[[f64; 2]]) -> [f64; 2] { + let n = pts.len().max(1) as f64; + let s = pts.iter().fold([0.0, 0.0], |a, q| [a[0] + q[0], a[1] + q[1]]); + [s[0] / n, s[1] / n] +} + +/// Elasticity popup (Stiff … Super Fluid) → inverse-distance weighting exponent. +const ELASTICITY: [&str; 8] = ["Stiff", "Less Stiff", "Below Normal", "Normal", "Above Normal", "Loose", "Liquid", "Super Fluid"]; +fn elastic_exp(i: u32) -> f64 { + [1.0, 1.25, 1.5, 2.0, 2.5, 3.0, 3.5, 4.5][(i as usize).min(7)] +} + +// ---------------------------------------------------------------- CC Bender + +fn cc_bender(ctx: &EffectCtx, mut b: Buf) -> Buf { + let amount = ctx.params.f("amount") / 100.0; + if amount == 0.0 { + return b; + } + let style = ctx.params.e("style"); + let top = b.to_px(ctx.params.v2("top")); + let base = b.to_px(ctx.params.v2("base")); + let (ax, ay) = (top.0 - base.0, top.1 - base.1); + let len = (ax * ax + ay * ay).sqrt().max(1e-6); + let (ux, uy) = (ax / len, ay / len); + // Perpendicular (to the right of the axis when it points up). + let (nx, ny) = (-uy, ux); + let reference = if ctx.params.b("adjustToDistance") { len } else { ctx.layer_size[1] * b.scale }; + let f = move |t: f64| -> f64 { + let t = t.clamp(0.0, 1.0); + match style { + 1 => 0.5 * (PI * t).sin() * t, // Marilyn: billowing middle + 2 => 0.5 * t, // Sharp: straight lean from the base + 3 => 0.25 * (1.0 - (PI * t).cos()) - 0.1 * (TAU * t).sin(), // Boxer: S-shaped + _ => 0.5 * t * t, // Bend: smooth curve + } + }; + b.img = remap(&b.img, false, |x, y| { + let (rx, ry) = (x - base.0, y - base.1); + let t = (rx * ux + ry * uy) / len; + let d = amount * reference * f(t); + Some((x - nx * d, y - ny * d)) + }); + b +} + +// ---------------------------------------------------------------- shading helpers + +/// Unit light vector from AE-style direction (0° = up, clockwise) and height (0–100 %). +fn distant_light(dir_deg: f64, height_pct: f64) -> [f64; 3] { + let el = (height_pct / 100.0).clamp(0.0, 1.0) * PI * 0.5; + let d = dir_deg.to_radians(); + [d.sin() * el.cos(), -d.cos() * el.cos(), el.sin()] +} + +fn norm3(v: [f64; 3]) -> [f64; 3] { + let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-12); + [v[0] / l, v[1] / l, v[2] / l] +} + +fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} + +// ---------------------------------------------------------------- CC Blobbylize + +fn cc_blobbylize(ctx: &EffectCtx, mut b: Buf) -> Buf { + let soft = ctx.params.f("softness").max(0.0) * b.scale; + let cut = (ctx.params.f("cutAway") / 100.0).clamp(0.0, 0.99) as f32; + let blob = layer_or_self(ctx, &b, "blobLayer", true, false); + let prop = src_at(ctx.params.e("property")); + let raw = Plane::from_image(&blob, |px| { + let (c, a) = unpremul(px); + pick(prop, c, a) * if matches!(prop, crate::util::Src::Alpha) { 1.0 } else { a } + }); + let h = gauss_plane(&raw, soft, soft); + let intensity = ctx.params.f("lightIntensity") / 100.0; + let lc = ctx.params.color("lightColor"); + let point = ctx.params.e("lightType") == 1; + let lpos = b.to_px(ctx.params.v2("lightPosition")); + let lheight = ctx.params.f("lightHeight"); + let ldir = distant_light(ctx.params.f("lightDirection"), lheight); + let ambient = ctx.params.f("ambient") / 100.0; + let diffuse = ctx.params.f("diffuse") / 100.0; + let specular = ctx.params.f("specular") / 100.0; + let rough = ctx.params.f("roughness").clamp(0.001, 1.0); + let metal = (ctx.params.f("metal") / 100.0) as f32; + let shininess = (1.0 / rough).min(500.0); + let depth = (soft * 0.5).max(1.0); + let src = b.img.clone(); + let (w, hh) = (b.img.width as usize, b.img.height as usize); + let span = (w.max(hh) as f64).max(1.0); + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let (xi, yi) = (x as i64, y as i64); + let hv = h.get(x, y); + let gx = (h.get_clamped(xi + 1, yi) - h.get_clamped(xi - 1, yi)) as f64 * 0.5; + let gy = (h.get_clamped(xi, yi + 1) - h.get_clamped(xi, yi - 1)) as f64 * 0.5; + // Refraction-like displacement down the slope, then the blobby matte. + let sx = x as f64 + 0.5 - gx * depth * 2.0; + let sy = y as f64 + 0.5 - gy * depth * 2.0; + let s = src.sample_bilinear(sx, sy); + let (c, a) = unpremul(s); + let m = ((hv - cut) / ((1.0 - cut) * 0.5).max(1e-3)).clamp(0.0, 1.0); + let alpha = a.max(s[3]) * m; + if alpha <= 0.0 { + *px = [0.0; 4]; + continue; + } + let n = norm3([-gx * depth, -gy * depth, 1.0]); + let l = if point { norm3([lpos.0 - x as f64, lpos.1 - y as f64, lheight / 100.0 * span]) } else { ldir }; + let ndl = dot3(n, l).max(0.0); + let hv3 = norm3([l[0], l[1], l[2] + 1.0]); + let sp = dot3(n, hv3).max(0.0).powf(shininess) * specular; + let mut out = [0.0f32; 3]; + for i in 0..3 { + let lit = (ambient + diffuse * ndl * intensity) as f32 * lc[i]; + let spec_col = (1.0 - metal) * lc[i] + metal * c[i] * lc[i]; + out[i] = c[i] * lit + (sp * intensity) as f32 * spec_col; + } + *px = premul(out, alpha); + } + }); + b +} + +// ---------------------------------------------------------------- CC Power Pin + +fn cc_power_pin(ctx: &EffectCtx, mut b: Buf) -> Buf { + let (lw, lh) = (ctx.layer_size[0].max(1.0), ctx.layer_size[1].max(1.0)); + let ex = [ctx.params.f("expandTop"), ctx.params.f("expandLeft"), ctx.params.f("expandRight"), ctx.params.f("expandBottom")]; + // Source rectangle (layer space), grown by the Expand percentages. + let (x0, y0) = (-ex[1] / 100.0 * lw, -ex[0] / 100.0 * lh); + let (x1, y1) = (lw + ex[2] / 100.0 * lw, lh + ex[3] / 100.0 * lh); + let corners_layer = [ctx.params.v2("topLeft"), ctx.params.v2("topRight"), ctx.params.v2("bottomRight"), ctx.params.v2("bottomLeft")]; + // Grow the buffer to hold the pinned quad. + if !ctx.adjustment { + let mut need = 0.0f64; + for c in &corners_layer { + let (px, py) = b.to_px(*c); + need = need.max(-px).max(-py).max(px - b.img.width as f64).max(py - b.img.height as f64); + } + if need > 0.0 { + b.pad(need.ceil().min(4096.0) as u32 + 1); + } + } + let q = corners_layer.map(|c| { + let p = b.to_px(c); + vec2(p.0, p.1) + }); + let persp = (ctx.params.f("perspective") / 100.0).clamp(0.0, 1.0); + let Some(inv) = Mat3::square_to_quad(q).inverse() else { return b }; + let src = b.img.clone(); + let (sc, off) = (b.scale, b.offset); + let bil = move |u: f64, v: f64| -> (f64, f64) { + let top = (q[0].x + (q[1].x - q[0].x) * u, q[0].y + (q[1].y - q[0].y) * u); + let bot = (q[3].x + (q[2].x - q[3].x) * u, q[3].y + (q[2].y - q[3].y) * u); + (top.0 + (bot.0 - top.0) * v, top.1 + (bot.1 - top.1) * v) + }; + b.img = crate::util::gen_image(src.width, src.height, |x, y| { + let (px, py) = (x as f64 + 0.5, y as f64 + 0.5); + let m = inv.apply(vec2(px, py)); + let (mut u, mut v) = (m.x, m.y); + if persp < 1.0 { + // Bilinear (non-perspective) inverse by Newton iterations from the projective guess. + let (mut bu, mut bv) = (u, v); + for _ in 0..8 { + let (fx, fy) = bil(bu, bv); + let (rx, ry) = (fx - px, fy - py); + if rx.abs() + ry.abs() < 1e-6 { + break; + } + let e = 1e-4; + let (ax, ay) = bil(bu + e, bv); + let (cx, cy) = bil(bu, bv + e); + let (j00, j10, j01, j11) = ((ax - fx) / e, (ay - fy) / e, (cx - fx) / e, (cy - fy) / e); + let det = j00 * j11 - j01 * j10; + if det.abs() < 1e-12 { + break; + } + bu -= (j11 * rx - j01 * ry) / det; + bv -= (-j10 * rx + j00 * ry) / det; + } + u = bu + (u - bu) * persp; + v = bv + (v - bv) * persp; + } + if !(-1e-9..=1.0 + 1e-9).contains(&u) || !(-1e-9..=1.0 + 1e-9).contains(&v) { + return [0.0; 4]; + } + let lx = x0 + u * (x1 - x0); + let ly = y0 + v * (y1 - y0); + src.sample_bilinear(lx * sc + off[0], ly * sc + off[1]) + }); + b +} + +// ---------------------------------------------------------------- CC Ripple Pulse + +fn cc_ripple_pulse(ctx: &EffectCtx, mut b: Buf) -> Buf { + let level = ctx.params.f("pulseLevel") / 100.0; + let amp = ctx.params.f("amplitude") / 100.0; + if level == 0.0 || amp == 0.0 { + return b; + } + let c = b.to_px(ctx.params.v2("center")); + let span = ctx.params.f("timeSpan").max(0.01); + let size = ctx.layer_size[0].max(ctx.layer_size[1]) * b.scale; + let speed = size * 0.5 / span; // the ring crosses half the layer in one time span + let lambda = (size * 0.06).max(2.0); + let height = level * amp * lambda * 0.5; + let front = speed * ctx.time.max(0.0); + let bump = ctx.params.b("renderBump"); + let wave = move |r: f64| -> f64 { + // A pulse train trailing the front, fading with age. + let age = (front - r) / speed.max(1e-9); + if age < 0.0 || age > span { + return 0.0; + } + let fade = 1.0 - age / span; + (TAU * (r - front) / lambda).sin() * fade + }; + let src = b.img.clone(); + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let (dx, dy) = (x as f64 + 0.5 - c.0, y as f64 + 0.5 - c.1); + let r = (dx * dx + dy * dy).sqrt(); + let d = wave(r) * height; + let (ux, uy) = if r > 1e-9 { (dx / r, dy / r) } else { (0.0, 0.0) }; + let mut s = src.sample_bilinear(x as f64 + 0.5 - ux * d, y as f64 + 0.5 - uy * d); + if bump && d != 0.0 { + let slope = (wave(r + 0.5) - wave(r - 0.5)) * height; + let k = (1.0 - slope * 0.5).clamp(0.0, 2.0) as f32; + for v in s.iter_mut().take(3) { + *v *= k; + } + } + *px = s; + } + }); + b +} + +// ---------------------------------------------------------------- CC Slant + +fn cc_slant(ctx: &EffectCtx, mut b: Buf) -> Buf { + let k = ctx.params.f("slant") / 100.0; + let height = ctx.params.f("height") / 100.0; + if k == 0.0 && (height - 1.0).abs() < 1e-12 { + return b; + } + let stretch = ctx.params.b("stretching"); + let xs = if stretch { 1.0 / (1.0 + k * k).sqrt() } else { 1.0 }; + let hinv = if height.abs() < 1e-6 { 1e6 } else { 1.0 / height }; + if !ctx.adjustment { + let lean = (k * ctx.layer_size[1] * b.scale * height.max(1.0)).abs(); + let grow = ((height - 1.0).max(0.0) * ctx.layer_size[1] * b.scale).max(lean); + b.pad(grow.ceil().min(4096.0) as u32); + } + let floor = b.to_px(ctx.params.v2("floor")); + b.img = remap(&b.img, false, |x, y| { + let sy = floor.1 + (y - floor.1) * hinv; + let sx = floor.0 + (x - floor.0 - (floor.1 - y) * k) / xs; + Some((sx, sy)) + }); + b +} + +// ---------------------------------------------------------------- CC Smear + +fn cc_smear(ctx: &EffectCtx, mut b: Buf) -> Buf { + let from = b.to_px(ctx.params.v2("from")); + let to = b.to_px(ctx.params.v2("to")); + let reach = ctx.params.f("reach") / 100.0; + let radius = (ctx.params.f("radius") * b.scale).max(0.5); + let (dx, dy) = (to.0 - from.0, to.1 - from.1); + let l2 = dx * dx + dy * dy; + if l2 < 1e-12 || reach == 0.0 { + return b; + } + b.img = remap(&b.img, false, |x, y| { + let t = (((x - from.0) * dx + (y - from.1) * dy) / l2).clamp(0.0, 1.0); + let (px, py) = (from.0 + dx * t, from.1 + dy * t); + let d = ((x - px).powi(2) + (y - py).powi(2)).sqrt(); + let w = (1.0 - d / radius).max(0.0); + let w = w * w * (3.0 - 2.0 * w); + let k = reach * w * t; + Some((x - dx * k, y - dy * k)) + }); + b +} + +// ---------------------------------------------------------------- CC Split / CC Split 2 + +fn split_impl(ctx: &EffectCtx, mut b: Buf, s1: f64, s2: f64) -> Buf { + if s1 == 0.0 && s2 == 0.0 { + return b; + } + let a = b.to_px(ctx.params.v2("pointA")); + let c = b.to_px(ctx.params.v2("pointB")); + let (dx, dy) = (c.0 - a.0, c.1 - a.1); + let len = (dx * dx + dy * dy).sqrt(); + if len < 1e-6 { + return b; + } + let (ux, uy) = (dx / len, dy / len); + let (nx, ny) = (-uy, ux); + let reach = len * 0.5; + b.img = remap(&b.img, false, |x, y| { + let (rx, ry) = (x - a.0, y - a.1); + let t = (rx * ux + ry * uy) / len; + let s = rx * nx + ry * ny; + if !(0.0..=1.0).contains(&t) { + return Some((x, y)); + } + let amt = if s >= 0.0 { s1 } else { s2 }; + let g = amt * reach * (PI * t).sin(); + let ad = s.abs(); + if ad < g { + return None; + } + // Push the sides apart, fading out over `reach` beyond the gap. + let push = g * (1.0 - (ad - g) / reach).max(0.0); + let ns = s.signum() * (ad - push); + Some((x + nx * (ns - s), y + ny * (ns - s))) + }); + b +} + +fn cc_split(ctx: &EffectCtx, b: Buf) -> Buf { + let s = ctx.params.f("split") / 100.0; + split_impl(ctx, b, s, s) +} + +fn cc_split2(ctx: &EffectCtx, b: Buf) -> Buf { + split_impl(ctx, b, ctx.params.f("split1") / 100.0, ctx.params.f("split2") / 100.0) +} + +// ---------------------------------------------------------------- Smear (mask based) + +fn smear(ctx: &EffectCtx, mut b: Buf) -> Buf { + let (Some((src_m, _)), Some((bound, _))) = (mask_px(ctx, &b, "sourceMask"), mask_px(ctx, &b, "boundaryMask")) else { return b }; + let pct = ctx.params.f("percent") / 100.0; + let off = ctx.params.v2("maskOffset"); + let (ox, oy) = (off[0] * b.scale, off[1] * b.scale); + let rot = ctx.params.f("maskRotation").to_radians(); + let scl = (ctx.params.f("maskScale") / 100.0).max(1e-3); + let kexp = elastic_exp(ctx.params.e("elasticity")); + let c = centroid(&src_m); + let (sn, cs) = rot.sin_cos(); + let fwd = |p: [f64; 2]| -> [f64; 2] { + let (dx, dy) = ((p[0] - c[0]) * scl, (p[1] - c[1]) * scl); + [c[0] + dx * cs - dy * sn + ox, c[1] + dx * sn + dy * cs + oy] + }; + let inv = |x: f64, y: f64| -> (f64, f64) { + let (dx, dy) = (x - ox - c[0], y - oy - c[1]); + (c[0] + (dx * cs + dy * sn) / scl, c[1] + (-dx * sn + dy * cs) / scl) + }; + let moved: Vec<[f64; 2]> = src_m.iter().map(|&q| fwd(q)).collect(); + if pct == 0.0 { + return b; + } + b.img = remap(&b.img, false, |x, y| { + if !point_in_poly(&bound, x, y) { + return Some((x, y)); + } + let w = if point_in_poly(&moved, x, y) { + 1.0 + } else { + let dm = dist_to_poly(&moved, true, x, y); + let db = dist_to_poly(&bound, true, x, y); + (db / (db + dm).max(1e-9)).powf(1.0 / kexp) + }; + let (ix, iy) = inv(x, y); + let k = (w * pct).clamp(0.0, 1.0); + Some((x + (ix - x) * k, y + (iy - y) * k)) + }); + b +} + +// ---------------------------------------------------------------- Reshape + +fn reshape(ctx: &EffectCtx, mut b: Buf) -> Buf { + let (Some((sm, sc)), Some((dm, dc))) = (mask_px(ctx, &b, "sourceMask"), mask_px(ctx, &b, "destinationMask")) else { return b }; + let pct = ctx.params.f("percent") / 100.0; + if pct == 0.0 { + return b; + } + let bound = mask_px(ctx, &b, "boundaryMask").map(|m| m.0); + let kexp = elastic_exp(ctx.params.e("elasticity")); + const N: usize = 48; + let s = resample_poly(&sm, sc, N); + let d = resample_poly(&dm, dc, N); + // Align the starting correspondence point (closed shapes): best cyclic rotation. + let rot = if sc && dc { + (0..N) + .min_by(|&r1, &r2| { + let cost = |r: usize| (0..N).map(|i| (s[(i + r) % N][0] - d[i][0]).powi(2) + (s[(i + r) % N][1] - d[i][1]).powi(2)).sum::(); + cost(r1).total_cmp(&cost(r2)) + }) + .unwrap_or(0) + } else { + 0 + }; + let disp: Vec<[f64; 2]> = (0..N).map(|i| [s[(i + rot) % N][0] - d[i][0], s[(i + rot) % N][1] - d[i][1]]).collect(); + let smooth = ctx.params.e("interpolation") == 2; + b.img = remap(&b.img, false, |x, y| { + if let Some(bd) = &bound + && !point_in_poly(bd, x, y) + { + return Some((x, y)); + } + let (mut wx, mut wy, mut ws) = (0.0, 0.0, 0.0); + let mut dmin = f64::INFINITY; + for i in 0..N { + let d2 = (x - d[i][0]).powi(2) + (y - d[i][1]).powi(2); + dmin = dmin.min(d2); + let w = 1.0 / (d2 + 1e-6).powf(kexp * 0.5); + wx += w * disp[i][0]; + wy += w * disp[i][1]; + ws += w; + } + let mut fade = 1.0; + if let Some(bd) = &bound { + let db = dist_to_poly(bd, true, x, y); + let dq = dmin.sqrt(); + fade = db / (db + dq).max(1e-9); + if smooth { + fade = fade * fade * (3.0 - 2.0 * fade); + } + } + let k = fade * pct / ws; + Some((x + wx * k, y + wy * k)) + }); + b +} + +// ---------------------------------------------------------------- Twirl (Legacy) + +fn twirl_legacy(ctx: &EffectCtx, mut b: Buf) -> Buf { + let ang = ctx.params.f("angle").to_radians(); + if ang == 0.0 { + return b; + } + let (lw, lh) = (ctx.layer_size[0] * b.scale, ctx.layer_size[1] * b.scale); + let r = (ctx.params.f("radius") / 100.0 * lw.min(lh) * 0.5).max(1.0); + let c = b.to_px(ctx.params.v2("center")); + b.img = remap(&b.img, false, |x, y| { + let (dx, dy) = (x - c.0, y - c.1); + let d = (dx * dx + dy * dy).sqrt(); + if d >= r { + return Some((x, y)); + } + // The legacy twirl falls off linearly with distance from the centre. + let a = -ang * (1.0 - d / r); + let (s, co) = a.sin_cos(); + Some((c.0 + dx * co - dy * s, c.1 + dx * s + dy * co)) + }); + b +} + +// ---------------------------------------------------------------- Warp + +const WARP_STYLES: [&str; 15] = + ["Arc", "Arc Lower", "Arc Upper", "Arch", "Bulge", "Shell Lower", "Shell Upper", "Flag", "Wave", "Fish", "Rise", "Fisheye", "Inflate", "Squeeze", "Twist"]; + +/// Forward warp in normalised coordinates (u across the warp axis' length, v across it, both +/// −1…1); `k` is Bend (−1…1). Returns the warped point. +fn warp_style(style: u32, k: f64, u: f64, v: f64) -> (f64, f64) { + let bell = 1.0 - u * u; + match style { + 0 => (u * (1.0 - 0.3 * k * v), v - k * bell), // Arc + 1 => (u, v - k * bell * (v + 1.0) * 0.5), // Arc Lower + 2 => (u, v - k * bell * (1.0 - v) * 0.5), // Arc Upper + 3 => (u, v - k * bell), // Arch + 4 => (u, v * (1.0 + k * bell)), // Bulge + 5 => (u, v + k * bell * bell * (v + 1.0) * 0.5), // Shell Lower + 6 => (u, v - k * bell * bell * (1.0 - v) * 0.5), // Shell Upper + 7 => (u, v - 0.5 * k * (PI * u).sin()), // Flag + 8 => (u, v - 0.5 * k * (PI * u + 0.5 * PI * v).sin()), // Wave + 9 => (u, v * (1.0 + 0.5 * k * (PI * u).sin())), // Fish + 10 => (u, v - 0.5 * k * (0.5 * PI * u).sin()), // Rise + 11 => { + // Fisheye: radial bulge inside the unit circle. + let r2 = u * u + v * v; + if r2 >= 1.0 { + (u, v) + } else { + let f = 1.0 + k * (1.0 - r2); + (u * f, v * f) + } + } + 12 => (u * (1.0 + 0.5 * k * (1.0 - v * v)), v * (1.0 + 0.5 * k * bell)), // Inflate + 13 => (u * (1.0 - 0.5 * k * (1.0 - v * v)), v * (1.0 + 0.5 * k * bell)), // Squeeze + _ => { + // Twist: rotation decreasing towards the edge of the unit circle. + let r = (u * u + v * v).sqrt(); + if r >= 1.0 { + (u, v) + } else { + let a = k * PI * (1.0 - r); + let (s, c) = a.sin_cos(); + (u * c - v * s, u * s + v * c) + } + } + } +} + +fn warp(ctx: &EffectCtx, mut b: Buf) -> Buf { + let k = ctx.params.f("bend") / 100.0; + let hd = ctx.params.f("horizontalDistortion") / 100.0; + let vd = ctx.params.f("verticalDistortion") / 100.0; + if k == 0.0 && hd == 0.0 && vd == 0.0 { + return b; + } + let style = ctx.params.e("warpStyle"); + let vertical = ctx.params.e("warpAxis") == 1; + let (hx, hy) = (ctx.layer_size[0] * b.scale * 0.5, ctx.layer_size[1] * b.scale * 0.5); + if !ctx.adjustment { + let pad = ((k.abs() + hd.abs() + vd.abs()) * 0.6 * hx.max(hy)).ceil().min(4096.0); + b.pad(pad as u32 + 1); + } + let c = b.to_px([ctx.layer_size[0] * 0.5, ctx.layer_size[1] * 0.5]); + let (hx, hy) = (hx.max(1e-6), hy.max(1e-6)); + let fwd = move |x: f64, y: f64| -> (f64, f64) { + let (u0, v0) = ((x - c.0) / hx, (y - c.1) / hy); + let (u, v) = if vertical { (v0, u0) } else { (u0, v0) }; + let (wu, wv) = warp_style(style, k, u, v); + let (mut x1, mut y1) = if vertical { (wv, wu) } else { (wu, wv) }; + // Horizontal / vertical distortion: perspective-like size change across the layer. + y1 *= 1.0 + 0.5 * hd * x1; + x1 *= 1.0 + 0.5 * vd * y1; + (c.0 + x1 * hx, c.1 + y1 * hy) + }; + b.img = remap(&b.img, false, |x, y| { + let (mut sx, mut sy) = (x, y); + for _ in 0..12 { + let (fx, fy) = fwd(sx, sy); + let (rx, ry) = (fx - x, fy - y); + if rx.abs() + ry.abs() < 1e-7 { + break; + } + let e = 0.01; + let (ax, ay) = fwd(sx + e, sy); + let (bx, by) = fwd(sx, sy + e); + let (j00, j10, j01, j11) = ((ax - fx) / e, (ay - fy) / e, (bx - fx) / e, (by - fy) / e); + let det = j00 * j11 - j01 * j10; + if det.abs() < 1e-9 { + return None; + } + sx -= (j11 * rx - j01 * ry) / det; + sy -= (-j10 * rx + j00 * ry) / det; + } + let (fx, fy) = fwd(sx, sy); + if (fx - x).abs() + (fy - y).abs() > 0.5 { + return None; + } + Some((sx, sy)) + }); + b +} + +// ---------------------------------------------------------------- Detail-preserving Upscale + +/// Catmull-Rom weight. +fn cubic_w(t: f64) -> f64 { + let t = t.abs(); + if t < 1.0 { + 1.5 * t * t * t - 2.5 * t * t + 1.0 + } else if t < 2.0 { + -0.5 * t * t * t + 2.5 * t * t - 4.0 * t + 2.0 + } else { + 0.0 + } +} + +fn bicubic(img: &Image, x: f64, y: f64) -> Px { + let fx = x - 0.5; + let fy = y - 0.5; + let (x0, y0) = (fx.floor(), fy.floor()); + let (tx, ty) = (fx - x0, fy - y0); + let wx = [cubic_w(1.0 + tx), cubic_w(tx), cubic_w(1.0 - tx), cubic_w(2.0 - tx)]; + let wy = [cubic_w(1.0 + ty), cubic_w(ty), cubic_w(1.0 - ty), cubic_w(2.0 - ty)]; + let mut o = [0.0f64; 4]; + for (j, wyj) in wy.iter().enumerate() { + for (i, wxi) in wx.iter().enumerate() { + let p = img.get_clamped(x0 as i64 - 1 + i as i64, y0 as i64 - 1 + j as i64); + let w = wxi * wyj; + for c in 0..4 { + o[c] += p[c] as f64 * w; + } + } + } + let a = o[3].clamp(0.0, 1.0) as f32; + [(o[0] as f32).clamp(0.0, a.max(o[0] as f32)), (o[1] as f32).clamp(0.0, a.max(o[1] as f32)), (o[2] as f32).clamp(0.0, a.max(o[2] as f32)), a] +} + +fn upscale(ctx: &EffectCtx, mut b: Buf) -> Buf { + let k = (ctx.params.f("scale") / 100.0).clamp(1.0, 10.0); + if (k - 1.0).abs() < 1e-9 { + return b; + } + let detail = (ctx.params.f("detail") / 100.0) as f32; + let noise = ctx.params.f("reduceNoise") / 100.0; + let src = if noise > 0.0 { effectcraft_raster::gaussian_blur(&b.img, noise * 1.5, noise * 1.5, true) } else { b.img.clone() }; + let lc = [ctx.layer_size[0] * 0.5, ctx.layer_size[1] * 0.5]; + let mut out = if ctx.adjustment { + // Scale in place about the layer centre. + let c = b.to_px(lc); + let mut o = Image::new(src.width, src.height); + o.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let sx = c.0 + (x as f64 + 0.5 - c.0) / k; + let sy = c.1 + (y as f64 + 0.5 - c.1) / k; + *px = if sx < 0.0 || sy < 0.0 || sx > src.width as f64 || sy > src.height as f64 { [0.0; 4] } else { bicubic(&src, sx, sy) }; + } + }); + o + } else { + let w = ((src.width as f64 * k).round() as u32).clamp(1, 16384); + let h = ((src.height as f64 * k).round() as u32).clamp(1, 16384); + let (kx, ky) = (w as f64 / src.width as f64, h as f64 / src.height as f64); + let mut o = Image::new(w, h); + o.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + *px = bicubic(&src, (x as f64 + 0.5) / kx, (y as f64 + 0.5) / ky); + } + }); + let c_sc = [lc[0] * b.scale, lc[1] * b.scale]; + b.offset = [b.offset[0] * kx + c_sc[0] * (kx - 1.0), b.offset[1] * ky + c_sc[1] * (ky - 1.0)]; + o + }; + if detail > 0.0 { + let sigma = 0.6 * k; + let blur = effectcraft_raster::gaussian_blur(&out, sigma, sigma, true); + out.data.par_iter_mut().zip(blur.data.par_iter()).for_each(|(p, q)| { + let a = p[3]; + for c in 0..3 { + p[c] = (p[c] + (p[c] - q[c]) * detail * 1.5).clamp(0.0, a.max(p[c])); + } + }); + } + b.img = out; + b +} + +// ---------------------------------------------------------------- registry pub fn specs() -> Vec { - Vec::new() + let pct = || slider(0.0, 100.0, 0.0, 100.0, 1); + vec![ + spec( + "ec.distort.ccbender", + "CC Bender", + vec![ + p("amount", "Amount", num(0.0), slider(-200.0, 200.0, -100.0, 100.0, 1)), + p("style", "Style", Value::Enum(0), popup(&["Bend", "Marilyn", "Sharp", "Boxer"])), + p("adjustToDistance", "Adjust To Distance", Value::Bool(false), ParamUi::Checkbox), + p("top", "Top", pt(0.5, 0.0), ParamUi::Point), + p("base", "Base", pt(0.5, 1.0), ParamUi::Point), + ], + cc_bender, + ), + spec( + "ec.distort.ccblobbylize", + "CC Blobbylize", + vec![ + p("blobLayer", "Blob Layer", Value::Layer(None), ParamUi::Layer), + p("property", "Property", Value::Enum(3), popup(&crate::util::SRC_NAMES[..8])), + p("softness", "Softness", num(6.0), slider(0.0, 200.0, 0.0, 50.0, 1)), + p("cutAway", "Cut Away", num(0.0), pct()), + p("lightIntensity", "Light Intensity", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)), + p("lightColor", "Light Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("lightType", "Light Type", Value::Enum(0), popup(&["Distant Light", "Point Light"])), + p("lightHeight", "Light Height", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("lightPosition", "Light Position", pt(0.25, 0.25), ParamUi::Point), + p("lightDirection", "Light Direction", num(-45.0), ParamUi::Angle), + p("ambient", "Ambient", num(20.0), slider(0.0, 200.0, 0.0, 100.0, 1)), + p("diffuse", "Diffuse", num(70.0), slider(0.0, 200.0, 0.0, 100.0, 1)), + p("specular", "Specular", num(40.0), slider(0.0, 200.0, 0.0, 100.0, 1)), + p("roughness", "Roughness", num(0.05), slider(0.001, 0.5, 0.001, 0.5, 3)), + p("metal", "Metal", num(100.0), pct()), + ], + cc_blobbylize, + ), + spec( + "ec.distort.ccpowerpin", + "CC Power Pin", + vec![ + p("topLeft", "Top Left", pt(0.0, 0.0), ParamUi::Point), + p("topRight", "Top Right", pt(1.0, 0.0), ParamUi::Point), + p("bottomLeft", "Bottom Left", pt(0.0, 1.0), ParamUi::Point), + p("bottomRight", "Bottom Right", pt(1.0, 1.0), ParamUi::Point), + p("perspective", "Perspective", num(100.0), pct()), + p("unstretch", "Unstretch", Value::Bool(false), ParamUi::Checkbox), + p("expandTop", "Top", num(0.0), slider(-100.0, 1000.0, 0.0, 100.0, 1)), + p("expandLeft", "Left", num(0.0), slider(-100.0, 1000.0, 0.0, 100.0, 1)), + p("expandRight", "Right", num(0.0), slider(-100.0, 1000.0, 0.0, 100.0, 1)), + p("expandBottom", "Bottom", num(0.0), slider(-100.0, 1000.0, 0.0, 100.0, 1)), + ], + cc_power_pin, + ), + spec( + "ec.distort.ccripplepulse", + "CC Ripple Pulse", + vec![ + p("center", "Center", pt(0.5, 0.5), ParamUi::Point), + p("pulseLevel", "Pulse Level (animate)", num(0.0), slider(-1000.0, 1000.0, -100.0, 100.0, 1)), + p("timeSpan", "Time Span (sec)", num(1.0), slider(0.01, 100.0, 0.01, 10.0, 2)), + p("amplitude", "Amplitude", num(100.0), slider(-1000.0, 1000.0, -200.0, 200.0, 1)), + p("renderBump", "Render Bump As Well", Value::Bool(false), ParamUi::Checkbox), + ], + cc_ripple_pulse, + ), + spec( + "ec.distort.ccslant", + "CC Slant", + vec![ + p("slant", "Slant", num(0.0), slider(-1000.0, 1000.0, -100.0, 100.0, 1)), + p("stretching", "Stretching", Value::Bool(false), ParamUi::Checkbox), + p("height", "Height", num(100.0), slider(-1000.0, 1000.0, 0.0, 200.0, 1)), + p("floor", "Floor", pt(0.5, 1.0), ParamUi::Point), + ], + cc_slant, + ), + spec( + "ec.distort.ccsmear", + "CC Smear", + vec![ + p("from", "From", pt(0.35, 0.5), ParamUi::Point), + p("to", "To", pt(0.65, 0.5), ParamUi::Point), + p("reach", "Reach", num(50.0), slider(-100.0, 100.0, -100.0, 100.0, 1)), + p("radius", "Radius", num(50.0), slider(0.0, 4000.0, 0.0, 400.0, 1)), + ], + cc_smear, + ), + spec( + "ec.distort.ccsplit", + "CC Split", + vec![ + p("pointA", "Point A", pt(0.25, 0.5), ParamUi::Point), + p("pointB", "Point B", pt(0.75, 0.5), ParamUi::Point), + p("split", "Split", num(50.0), slider(0.0, 1000.0, 0.0, 200.0, 1)), + ], + cc_split, + ), + spec( + "ec.distort.ccsplit2", + "CC Split 2", + vec![ + p("pointA", "Point A", pt(0.25, 0.5), ParamUi::Point), + p("pointB", "Point B", pt(0.75, 0.5), ParamUi::Point), + p("split1", "Split 1", num(50.0), slider(0.0, 1000.0, 0.0, 200.0, 1)), + p("split2", "Split 2", num(50.0), slider(0.0, 1000.0, 0.0, 200.0, 1)), + ], + cc_split2, + ), + spec( + "ec.distort.smear", + "Smear", + vec![ + p("sourceMask", "Source Mask", Value::Enum(0), popup(&MASK_CHOICES)), + p("boundaryMask", "Boundary Mask", Value::Enum(0), popup(&MASK_CHOICES)), + p("maskOffset", "Mask Offset", pt(0.0, 0.0), ParamUi::Point), + p("maskRotation", "Mask Rotation", num(0.0), ParamUi::Angle), + p("maskScale", "Mask Scale", num(100.0), slider(1.0, 1000.0, 1.0, 400.0, 1)), + p("percent", "Percent", num(100.0), pct()), + p("elasticity", "Elasticity", Value::Enum(3), popup(&ELASTICITY)), + p("interpolation", "Interpolation", Value::Enum(1), popup(&["Discrete", "Linear", "Smooth"])), + ], + smear, + ), + spec( + "ec.distort.reshape", + "Reshape", + vec![ + p("sourceMask", "Source Mask", Value::Enum(0), popup(&MASK_CHOICES)), + p("destinationMask", "Destination Mask", Value::Enum(0), popup(&MASK_CHOICES)), + p("boundaryMask", "Boundary Mask", Value::Enum(0), popup(&MASK_CHOICES)), + p("percent", "Percent", num(100.0), pct()), + p("elasticity", "Elasticity", Value::Enum(0), popup(&ELASTICITY)), + p("interpolation", "Interpolation", Value::Enum(1), popup(&["Discrete", "Linear", "Smooth"])), + ], + reshape, + ), + spec( + "ec.distort.twirllegacy", + "Twirl (Legacy)", + vec![ + p("angle", "Angle", num(0.0), ParamUi::Angle), + p("radius", "Twirl Radius", num(30.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("center", "Twirl Center", pt(0.5, 0.5), ParamUi::Point), + ], + twirl_legacy, + ), + spec( + "ec.distort.warp", + "Warp", + vec![ + p("warpStyle", "Warp Style", Value::Enum(0), popup(&WARP_STYLES)), + p("warpAxis", "Warp Axis", Value::Enum(0), popup(&["Horizontal", "Vertical"])), + p("bend", "Bend", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 0)), + p("horizontalDistortion", "Horizontal Distortion", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 0)), + p("verticalDistortion", "Vertical Distortion", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 0)), + ], + warp, + ), + spec( + "ec.distort.upscale", + "Detail-preserving Upscale", + vec![ + p("scale", "Scale", num(100.0), slider(100.0, 1000.0, 100.0, 400.0, 1)), + p("detail", "Detail", num(20.0), pct()), + p("reduceNoise", "Reduce Noise", num(0.0), pct()), + ], + upscale, + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{EffectEnv, MaskShape, Params}; + + fn img(w: u32, h: u32) -> Image { + crate::util::gen_image(w, h, |x, y| [x as f32 / w as f32, y as f32 / h as f32, ((x * 7 + y * 3) % 11) as f32 / 11.0, 1.0]) + } + + /// Run with Point defaults scaled to the layer (like `instantiate`). + fn run(id: &str, set: &[(&str, Value)], im: Image, masks: &[MaskShape]) -> Buf { + let s = crate::find(id).unwrap(); + let ls = [im.width as f64, im.height as f64]; + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + for ps in &s.params { + if let (ParamUi::Point, Value::Vec2(v)) = (&ps.ui, &ps.default) { + params.values.insert(ps.id.to_string(), Value::Vec2([v[0] * ls[0], v[1] * ls[1]])); + } + } + for (k, v) in set { + assert!(params.values.contains_key(*k), "{id}: {k}"); + params.values.insert(k.to_string(), v.clone()); + } + let env = EffectEnv { masks, ..Default::default() }; + let ctx = EffectCtx { params: ¶ms, time: 0.5, layer_size: ls, seed: 1, adjustment: false, env }; + crate::apply(s, &ctx, Buf { img: im, offset: [0.0, 0.0], scale: 1.0 }) + } + + fn maxd(a: &Image, b: &Image) -> f32 { + assert_eq!((a.width, a.height), (b.width, b.height)); + a.data.iter().zip(&b.data).map(|(p, q)| (0..4).map(|i| (p[i] - q[i]).abs()).fold(0.0, f32::max)).fold(0.0, f32::max) + } + + fn rect_mask(x0: f64, y0: f64, x1: f64, y1: f64) -> MaskShape { + MaskShape { name: "m".into(), points: vec![[x0, y0], [x1, y0], [x1, y1], [x0, y1]], closed: true, inverted: false } + } + + #[test] + fn identity_at_neutral_settings() { + let im = img(40, 30); + let cases: &[(&str, Vec<(&str, Value)>)] = &[ + ("ec.distort.ccbender", vec![]), + ("ec.distort.ccpowerpin", vec![]), + ("ec.distort.ccripplepulse", vec![]), + ("ec.distort.ccslant", vec![]), + ("ec.distort.ccsmear", vec![("reach", num(0.0))]), + ("ec.distort.ccsplit", vec![("split", num(0.0))]), + ("ec.distort.ccsplit2", vec![("split1", num(0.0)), ("split2", num(0.0))]), + ("ec.distort.smear", vec![]), + ("ec.distort.reshape", vec![]), + ("ec.distort.twirllegacy", vec![]), + ("ec.distort.warp", vec![("warpStyle", Value::Enum(5))]), + ("ec.distort.upscale", vec![]), + ]; + for (id, set) in cases { + let o = run(id, set, im.clone(), &[]); + assert!(maxd(&o.img, &im) < 1e-5, "{id} not identity"); + assert_eq!(o.offset, [0.0, 0.0], "{id}"); + } + // Power pin with perspective 0 at default corners is also identity. + let o = run("ec.distort.ccpowerpin", &[("perspective", num(0.0))], im.clone(), &[]); + assert!(maxd(&o.img, &im) < 1e-4); + } + + #[test] + fn all_deterministic() { + let im = img(36, 28); + let masks = [rect_mask(8.0, 8.0, 16.0, 16.0), rect_mask(12.0, 10.0, 22.0, 20.0), rect_mask(2.0, 2.0, 34.0, 26.0)]; + let cases: &[(&str, Vec<(&str, Value)>)] = &[ + ("ec.distort.ccbender", vec![("amount", num(40.0)), ("style", Value::Enum(3))]), + ("ec.distort.ccblobbylize", vec![]), + ("ec.distort.ccpowerpin", vec![("topLeft", pt(5.0, 3.0)), ("perspective", num(40.0))]), + ("ec.distort.ccripplepulse", vec![("pulseLevel", num(80.0))]), + ("ec.distort.ccslant", vec![("slant", num(30.0)), ("stretching", Value::Bool(true))]), + ("ec.distort.ccsmear", vec![]), + ("ec.distort.ccsplit", vec![]), + ("ec.distort.ccsplit2", vec![("split2", num(10.0))]), + ("ec.distort.smear", vec![("sourceMask", Value::Enum(1)), ("boundaryMask", Value::Enum(3)), ("maskOffset", pt(4.0, 2.0))]), + ("ec.distort.reshape", vec![("sourceMask", Value::Enum(1)), ("destinationMask", Value::Enum(2)), ("boundaryMask", Value::Enum(3))]), + ("ec.distort.twirllegacy", vec![("angle", num(90.0))]), + ("ec.distort.warp", vec![("bend", num(50.0)), ("horizontalDistortion", num(20.0))]), + ("ec.distort.upscale", vec![("scale", num(200.0))]), + ]; + for (id, set) in cases { + let a = run(id, set, im.clone(), &masks); + let b = run(id, set, im.clone(), &masks); + assert_eq!(a.img, b.img, "{id}"); + assert!(a.img.data.iter().all(|p| p.iter().all(|v| v.is_finite())), "{id}"); + assert!(a.img.width != im.width || a.img.height != im.height || maxd(&a.img, &im) > 1e-3, "{id} did nothing"); + } + } + + #[test] + fn every_warp_style_runs_and_bends() { + let im = img(40, 30); + for s in 0..WARP_STYLES.len() as u32 { + for axis in 0..2 { + let o = run("ec.distort.warp", &[("warpStyle", Value::Enum(s)), ("warpAxis", Value::Enum(axis)), ("bend", num(50.0))], im.clone(), &[]); + let pad = o.offset[0] as u32; + // The layer centre's neighbourhood is still made of source pixels. + assert!(o.img.get(pad as i64 + 20, pad as i64 + 15)[3] > 0.5, "style {s}"); + assert!(o.img.width > im.width); + } + } + // Arc with positive bend raises the middle of the layer: the top edge in the middle + // moves up (opaque pixels above the original top). + let o = run("ec.distort.warp", &[("warpStyle", Value::Enum(3)), ("bend", num(50.0))], im.clone(), &[]); + let pad = o.offset[1] as i64; + assert!(o.img.get(o.offset[0] as i64 + 20, pad - 3)[3] > 0.5); + assert!(o.img.get(o.offset[0] as i64 + 1, pad - 3)[3] < 0.5); + } + + #[test] + fn power_pin_moves_corners() { + let im = img(20, 20); + // Pin to the right half: output x in 10..20 shows the whole layer squeezed. + let set = [("topLeft", pt(10.0, 0.0)), ("bottomLeft", pt(10.0, 20.0))]; + let o = run("ec.distort.ccpowerpin", &set, im.clone(), &[]); + assert_eq!(o.img.get(3, 10)[3], 0.0); + let px = o.img.get(15, 10); + assert!((px[0] - 0.5).abs() < 0.1, "{px:?}"); + } + + #[test] + fn split_opens_a_gap() { + let im = img(40, 30); + let o = run("ec.distort.ccsplit", &[("split", num(40.0))], im.clone(), &[]); + assert_eq!(o.img.get(20, 15)[3], 0.0); + assert_eq!(o.img.get(2, 2), im.get(2, 2)); + } + + #[test] + fn slant_shears_from_floor() { + let im = img(20, 20); + let o = run("ec.distort.ccslant", &[("slant", num(50.0))], im.clone(), &[]); + let pad = o.offset[0] as i64; + // Bottom row stays put; the top row shifts right by ~10 px. + assert!((o.img.get(pad + 5, pad + 19)[0] - im.get(5, 19)[0]).abs() < 0.05); + assert!((o.img.get(pad + 15, pad)[0] - im.get(5, 0)[0]).abs() < 0.08); + } + + #[test] + fn reshape_moves_source_to_destination() { + // A bright square at the source mask; reshape to a shifted destination mask. + let im = crate::util::gen_image(40, 40, |x, y| if (10..20).contains(&x) && (10..20).contains(&y) { [1.0; 4] } else { [0.0, 0.0, 0.0, 1.0] }); + let masks = [rect_mask(10.0, 10.0, 20.0, 20.0), rect_mask(16.0, 10.0, 26.0, 20.0), rect_mask(0.0, 0.0, 40.0, 40.0)]; + let o = run("ec.distort.reshape", &[("sourceMask", Value::Enum(1)), ("destinationMask", Value::Enum(2)), ("boundaryMask", Value::Enum(3))], im, &masks); + assert!(o.img.get(24, 15)[0] > 0.8, "{:?}", o.img.get(24, 15)); + assert!(o.img.get(11, 15)[0] < 0.3, "{:?}", o.img.get(11, 15)); + } + + #[test] + fn upscale_grows_about_centre() { + let im = img(20, 10); + let o = run("ec.distort.upscale", &[("scale", num(200.0)), ("detail", num(0.0))], im.clone(), &[]); + assert_eq!((o.img.width, o.img.height), (40, 20)); + // Grown about the layer centre: layer point (0,0) now sits at buffer pixel (10,5), so the + // centre (10,5) lands in the middle of the 40×20 buffer. + assert_eq!(o.offset, [10.0, 5.0]); + let c = o.img.sample_bilinear(20.0, 10.0); + assert!((c[0] - im.sample_bilinear(10.0, 5.0)[0]).abs() < 0.03); + } + + #[test] + fn twirl_legacy_rotates_inside_radius() { + let im = img(40, 40); + let o = run("ec.distort.twirllegacy", &[("angle", num(180.0)), ("radius", num(100.0))], im.clone(), &[]); + assert!(maxd(&o.img, &im) > 0.1); + // Corners lie outside the radius. + assert_eq!(o.img.get(0, 0), im.get(0, 0)); + } + + #[test] + fn half_resolution_matches_scaled_geometry() { + // Bender at scale 0.5: the buffer is half size, parameters in layer pixels. + let s = crate::find("ec.distort.ccbender").unwrap(); + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + params.values.insert("amount".into(), num(30.0)); + params.values.insert("top".into(), pt(20.0, 0.0)); + params.values.insert("base".into(), pt(20.0, 40.0)); + let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [40.0, 40.0], seed: 1, adjustment: false, env: Default::default() }; + let full = crate::apply(s, &ctx, Buf { img: img(40, 40), offset: [0.0; 2], scale: 1.0 }); + let half = crate::apply(s, &ctx, Buf { img: effectcraft_raster::resample(&img(40, 40), 20, 20), offset: [0.0; 2], scale: 0.5 }); + let a = full.img.sample_bilinear(20.0, 4.0)[3]; + let b = half.img.sample_bilinear(10.0, 2.0)[3]; + assert!((a - b).abs() < 0.3); + } } From 685d6bcd04966e8c5e7ea70845f9a86c119e9d1e Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 21:58:24 -0400 Subject: [PATCH 03/14] M9.3: Perspective + Utility effects (CC Environment, 3D Glasses, HDR Compander) Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/perspective2.rs | 285 ++++++++++++++++++++++++++++- crates/effects/src/utility2.rs | 84 ++++++++- 2 files changed, 363 insertions(+), 6 deletions(-) diff --git a/crates/effects/src/perspective2.rs b/crates/effects/src/perspective2.rs index 60c61d02..feed0286 100644 --- a/crates/effects/src/perspective2.rs +++ b/crates/effects/src/perspective2.rs @@ -1,7 +1,286 @@ -//! Perspective effects, batch 2. +//! Perspective effects, batch 2: CC Environment (reflection mapping of an environment layer +//! onto the layer's luminance/alpha relief) and 3D Glasses (stereo pairs and anaglyphs from a +//! left and right view layer). -use crate::EffectSpec; +use std::f64::consts::PI; + +use effectcraft_color::luminance; +use effectcraft_keyframe::Value; +use effectcraft_project::ParamUi; +use effectcraft_raster::{Image, Px}; +use rayon::prelude::*; + +use crate::util::{Plane, fit_layer, gauss_plane, premul, unpremul}; +use crate::{Buf, EffectCtx, EffectSpec, LayerPixels, num, p, popup, slider}; + +fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { + EffectSpec { id, name, category: "Perspective", params, render, gpu: false, float: true } +} + +// ---------------------------------------------------------------- CC Environment + +/// Environment direction (x right, y down, z towards the viewer) → normalised map coordinates. +fn env_uv(mapping: u32, r: [f64; 3]) -> (f64, f64) { + match mapping { + 1 => { + // Probe (mirror ball). + let m = 2.0 * (r[0] * r[0] + r[1] * r[1] + (r[2] + 1.0).powi(2)).sqrt().max(1e-9); + (0.5 + r[0] / m, 0.5 + r[1] / m) + } + 2 => { + // Vertical cross cube map: 3 faces wide, 4 tall. + let (ax, ay, az) = (r[0].abs(), r[1].abs(), r[2].abs()); + let (col, row, u, v) = if ax >= ay && ax >= az { + if r[0] > 0.0 { (2.0, 1.0, -r[2] / ax, r[1] / ax) } else { (0.0, 1.0, r[2] / ax, r[1] / ax) } + } else if ay >= az { + if r[1] < 0.0 { (1.0, 0.0, r[0] / ay, -r[2] / ay) } else { (1.0, 2.0, r[0] / ay, r[2] / ay) } + } else if r[2] > 0.0 { + (1.0, 1.0, r[0] / az, r[1] / az) + } else { + (1.0, 3.0, r[0] / az, -r[1] / az) + }; + ((col + (u + 1.0) * 0.5) / 3.0, (row + (v + 1.0) * 0.5) / 4.0) + } + _ => { + // Spherical (equirectangular). + let lon = r[0].atan2(r[2]); + let lat = r[1].clamp(-1.0, 1.0).asin(); + (0.5 + lon / (2.0 * PI), 0.5 + lat / PI) + } + } +} + +fn cc_environment(ctx: &EffectCtx, mut b: Buf) -> Buf { + let env = ctx.layer_param("environment", true).unwrap_or_else(|| LayerPixels { buf: b.clone(), size: ctx.layer_size }); + let env_img = if ctx.params.b("filterEnvironment") { + let s = 2.0 * env.buf.scale; + effectcraft_raster::gaussian_blur(&env.buf.img, s, s, true) + } else { + env.buf.img.clone() + }; + let mapping = ctx.params.e("mapping"); + let height = ctx.params.f("height") / 100.0 * 20.0; + // Relief: luminance inside, rounded off by the alpha edge. + let relief = Plane::from_image(&b.img, |px| { + let (c, a) = unpremul(px); + (luminance(c[0], c[1], c[2]) * 0.5 + 0.5) * a + }); + let soft = 1.5 * b.scale.max(0.25); + let hmap = gauss_plane(&relief, soft, soft); + let (es, eo, esz) = (env.buf.scale, env.buf.offset, env.size); + let src = b.img.clone(); + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let a = src.get(x as i64, y as i64)[3]; + if a <= 0.0 { + *px = [0.0; 4]; + continue; + } + let (xi, yi) = (x as i64, y as i64); + let gx = (hmap.get_clamped(xi + 1, yi) - hmap.get_clamped(xi - 1, yi)) as f64 * 0.5 / b.scale.max(1e-6); + let gy = (hmap.get_clamped(xi, yi + 1) - hmap.get_clamped(xi, yi - 1)) as f64 * 0.5 / b.scale.max(1e-6); + let n = [-gx * height, -gy * height, 1.0]; + let l = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt(); + let n = [n[0] / l, n[1] / l, n[2] / l]; + // Reflect the view ray (towards −z) about the normal. + let d = -n[2]; + let r = [-2.0 * d * n[0], -2.0 * d * n[1], -1.0 - 2.0 * d * n[2]]; + let r = [r[0], r[1], -r[2]]; + let (u, v) = env_uv(mapping, r); + let ex = u.rem_euclid(1.0) * esz[0] * es + eo[0]; + let ey = v.clamp(0.0, 1.0) * esz[1] * es + eo[1]; + let e = env_img.sample_bilinear_clamped(ex, ey); + let (ec, _) = unpremul(e); + *px = premul(ec, a); + } + }); + b +} + +// ---------------------------------------------------------------- 3D Glasses + +const VIEWS: [&str; 9] = [ + "Stereo Pair (Side by Side)", + "Over Under", + "Interlace Upper L Lower R", + "Red Green LR", + "Red Blue LR", + "Balanced Colored Red Blue", + "Balanced Red Green LR", + "Balanced Red Blue LR", + "Difference", +]; + +fn shift(img: &Image, dx: f64, dy: f64) -> Image { + if dx == 0.0 && dy == 0.0 { + return img.clone(); + } + crate::util::gen_image(img.width, img.height, |x, y| img.sample_bilinear(x as f64 + 0.5 - dx, y as f64 + 0.5 - dy)) +} + +fn lum(p: Px) -> f32 { + luminance(p[0], p[1], p[2]) +} + +fn glasses_3d(ctx: &EffectCtx, mut b: Buf) -> Buf { + let get = |id: &str| match ctx.layer_param(id, true) { + Some(o) => fit_layer(ctx, &b, &o, false), + None => b.img.clone(), + }; + let (mut l, mut r) = (get("leftView"), get("rightView")); + if ctx.params.b("leftRightSwap") { + std::mem::swap(&mut l, &mut r); + } + let pct = ctx.params.e("units") == 1; + let unit = if pct { ctx.layer_size[0] / 100.0 } else { 1.0 } * b.scale; + let conv = ctx.params.f("sceneConvergence") * unit; + let va = ctx.params.f("verticalAlignment") * unit; + let l = shift(&l, conv * 0.5, 0.0); + let r = shift(&r, -conv * 0.5, va); + let view = ctx.params.e("view3d"); + let k = (ctx.params.f("balance") / 20.0).clamp(0.0, 1.0) as f32; + let (w, h) = (b.img.width as f64, b.img.height as f64); + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let (fx, fy) = (x as f64 + 0.5, y as f64 + 0.5); + *px = match view { + 0 => { + if fx < w * 0.5 { + l.sample_bilinear(fx * 2.0, fy) + } else { + r.sample_bilinear((fx - w * 0.5) * 2.0, fy) + } + } + 1 => { + if fy < h * 0.5 { + l.sample_bilinear(fx, fy * 2.0) + } else { + r.sample_bilinear(fx, (fy - h * 0.5) * 2.0) + } + } + 2 => { + if y % 2 == 0 { + l.get(x as i64, y as i64) + } else { + r.get(x as i64, y as i64) + } + } + _ => { + let lp = l.get(x as i64, y as i64); + let rp = r.get(x as i64, y as i64); + let (lc, la) = unpremul(lp); + let (rc, ra) = unpremul(rp); + let a = la.max(ra); + let (ll, rl) = (lum(premul(lc, 1.0)), lum(premul(rc, 1.0))); + let c = match view { + 3 => [ll, rl, 0.0], + 4 => [ll, 0.0, rl], + 5 => [lc[0] + (ll - lc[0]) * k, rc[1], rc[2]], + 6 => [ll * (1.0 - 0.5 * k) + lc[0] * 0.5 * k, rl, 0.0], + 7 => [ll * (1.0 - 0.5 * k) + lc[0] * 0.5 * k, 0.0, rl], + _ => [(lc[0] - rc[0]).abs(), (lc[1] - rc[1]).abs(), (lc[2] - rc[2]).abs()], + }; + premul(c, a) + } + }; + } + }); + b +} pub fn specs() -> Vec { - Vec::new() + vec![ + spec( + "ec.perspective.ccenvironment", + "CC Environment", + vec![ + p("environment", "Environment", Value::Layer(None), ParamUi::Layer), + p("mapping", "Mapping", Value::Enum(0), popup(&["Spherical", "Probe", "Vertical Cross"])), + p("filterEnvironment", "Filter Environment", Value::Bool(true), ParamUi::Checkbox), + p("height", "Height", num(10.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + ], + cc_environment, + ), + spec( + "ec.perspective.3dglasses", + "3D Glasses", + vec![ + p("leftView", "Left View", Value::Layer(None), ParamUi::Layer), + p("rightView", "Right View", Value::Layer(None), ParamUi::Layer), + p("sceneConvergence", "Scene Convergence", num(0.0), slider(-1000.0, 1000.0, -100.0, 100.0, 1)), + p("verticalAlignment", "Vertical Alignment", num(0.0), slider(-1000.0, 1000.0, -100.0, 100.0, 1)), + p("units", "Units", Value::Enum(0), popup(&["Pixels", "% of Source"])), + p("leftRightSwap", "Left Right Swap", Value::Bool(false), ParamUi::Checkbox), + p("view3d", "3D View", Value::Enum(5), popup(&VIEWS)), + p("balance", "Balance", num(7.0), slider(0.0, 20.0, 0.0, 20.0, 1)), + ], + glasses_3d, + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{EffectEnv, EffectHost, run_fx}; + + fn img() -> Image { + crate::util::gen_image(32, 24, |x, y| { + let a = if (4..28).contains(&x) && (4..20).contains(&y) { 1.0 } else { 0.0 }; + [x as f32 / 32.0 * a, y as f32 / 24.0 * a, 0.4 * a, a] + }) + } + + struct Host(LayerPixels); + impl EffectHost for Host { + fn layer(&self, _: u64, _: bool) -> Option { + Some(self.0.clone()) + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + } + + #[test] + fn environment_reflects_env_layer() { + // A flat layer reflects the environment straight back: with a uniform green environment, + // opaque pixels become green, transparent stay transparent. + let env = LayerPixels { buf: Buf { img: Image::filled(16, 8, [0.0, 1.0, 0.0, 1.0]), offset: [0.0; 2], scale: 1.0 }, size: [16.0, 8.0] }; + let host = Host(env); + let e = EffectEnv { host: Some(&host), ..Default::default() }; + let o = run_fx("ec.perspective.ccenvironment", &[("environment", Value::Layer(Some(9)))], img(), 0.0, e); + let px = o.img.get(16, 12); + assert!((px[1] - 1.0).abs() < 1e-4 && px[0].abs() < 1e-4, "{px:?}"); + assert_eq!(o.img.get(0, 0)[3], 0.0); + let again = run_fx("ec.perspective.ccenvironment", &[("environment", Value::Layer(Some(9)))], img(), 0.0, e); + assert_eq!(o.img, again.img); + for m in 0..3 { + let o = run_fx("ec.perspective.ccenvironment", &[("mapping", Value::Enum(m))], img(), 0.0, EffectEnv::default()); + assert!(o.img.data.iter().all(|p| p.iter().all(|v| v.is_finite()))); + } + } + + #[test] + fn glasses_anaglyph_and_pairs() { + let im = img(); + // Same view on both eyes: Red Blue LR puts the luminance in red and blue equally. + let o = run_fx("ec.perspective.3dglasses", &[("view3d", Value::Enum(4))], im.clone(), 0.0, EffectEnv::default()); + let px = o.img.get(16, 12); + assert!((px[0] - px[2]).abs() < 1e-5 && px[1] == 0.0 && px[0] > 0.0); + // Difference of identical views is black. + let o = run_fx("ec.perspective.3dglasses", &[("view3d", Value::Enum(8))], im.clone(), 0.0, EffectEnv::default()); + assert!(o.img.data.iter().all(|p| p[0].abs() < 1e-6 && p[1].abs() < 1e-6)); + // Convergence separates the eyes: difference becomes non-zero. + let o = run_fx("ec.perspective.3dglasses", &[("view3d", Value::Enum(8)), ("sceneConvergence", num(4.0))], im.clone(), 0.0, EffectEnv::default()); + assert!(o.img.data.iter().any(|p| p[0] > 0.05)); + // Every view is deterministic. + for v in 0..VIEWS.len() as u32 { + let a = run_fx("ec.perspective.3dglasses", &[("view3d", Value::Enum(v))], im.clone(), 0.0, EffectEnv::default()); + let b = run_fx("ec.perspective.3dglasses", &[("view3d", Value::Enum(v))], im.clone(), 0.0, EffectEnv::default()); + assert_eq!(a.img, b.img); + } + // Interlace: even rows = left view (self). + let o = run_fx("ec.perspective.3dglasses", &[("view3d", Value::Enum(2))], im.clone(), 0.0, EffectEnv::default()); + assert_eq!(o.img.get(10, 10), im.get(10, 10)); + } } diff --git a/crates/effects/src/utility2.rs b/crates/effects/src/utility2.rs index fcd43649..ec768197 100644 --- a/crates/effects/src/utility2.rs +++ b/crates/effects/src/utility2.rs @@ -1,7 +1,85 @@ -//! Utility effects, batch 2. +//! Utility effects, batch 2: HDR Compander. +//! +//! HDR Compander squeezes high-dynamic-range values into 0–1 (Compress Range) so 8/16 bpc +//! effects can process them, and restores them afterwards (Expand Range) with the same Gain and +//! Gamma; the two modes are exact inverses. -use crate::EffectSpec; +use effectcraft_keyframe::Value; +use rayon::prelude::*; + +use crate::util::{premul, unpremul}; +use crate::{Buf, EffectCtx, EffectSpec, num, p, popup, slider}; + +#[inline] +fn spow(v: f32, e: f32) -> f32 { + v.signum() * v.abs().powf(e) +} + +fn hdr_compander(ctx: &EffectCtx, mut b: Buf) -> Buf { + let expand = ctx.params.e("mode") == 1; + let gain = ctx.params.f("gain").max(1e-6) as f32; + let gamma = ctx.params.f("gamma").max(1e-3) as f32; + b.img.data.par_iter_mut().for_each(|px| { + let (c, a) = unpremul(*px); + if a <= 0.0 { + return; + } + let c = c.map(|v| if expand { spow(v, gamma) * gain } else { spow(v / gain, 1.0 / gamma) }); + *px = premul(c, a); + }); + b +} pub fn specs() -> Vec { - Vec::new() + vec![EffectSpec { + id: "ec.utility.hdrcompander", + name: "HDR Compander", + category: "Utility", + params: vec![ + p("mode", "Mode", Value::Enum(0), popup(&["Compress Range", "Expand Range"])), + p("gain", "Gain", num(1.0), slider(1.0, 100.0, 1.0, 100.0, 2)), + p("gamma", "Gamma", num(1.0), slider(0.1, 10.0, 0.1, 3.0, 2)), + ], + render: hdr_compander, + gpu: false, + float: true, + }] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{EffectEnv, run_fx}; + use effectcraft_raster::Image; + + fn hdr() -> Image { + crate::util::gen_image(8, 4, |x, y| { + let v = x as f32 * 0.6 + y as f32 * 0.1; + [v * 0.8, v * 0.4, v * 0.2, 0.8] + }) + } + + #[test] + fn defaults_are_identity_and_deterministic() { + let im = hdr(); + let a = run_fx("ec.utility.hdrcompander", &[], im.clone(), 0.0, EffectEnv::default()); + let b = run_fx("ec.utility.hdrcompander", &[], im.clone(), 0.0, EffectEnv::default()); + assert_eq!(a.img, b.img); + assert!(a.img.data.iter().zip(&im.data).all(|(p, q)| (0..4).all(|i| (p[i] - q[i]).abs() < 1e-5))); + } + + #[test] + fn compress_then_expand_round_trips() { + let im = hdr(); + let set = [("gain", num(5.0)), ("gamma", num(2.2))]; + let c = run_fx("ec.utility.hdrcompander", &set, im.clone(), 0.0, EffectEnv::default()); + // Compressed values fit in 0–1. + assert!(c.img.data.iter().all(|p| p[0] <= p[3] + 1e-5)); + let e = run_fx("ec.utility.hdrcompander", &[set[0].clone(), set[1].clone(), ("mode", Value::Enum(1))], c.img, 0.0, EffectEnv::default()); + for (p, q) in e.img.data.iter().zip(&im.data) { + for i in 0..4 { + assert!((p[i] - q[i]).abs() < 1e-3, "{p:?} vs {q:?}"); + } + } + } } From 8503f816ae3d2813249a50b5a5dc639388d64642 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 21:56:13 -0400 Subject: [PATCH 04/14] M9.3: Stylize effects (CC Block Load, CC Burn Film, CC Glass, CC HexTile, CC Mr. Smoothie, CC Plastic) Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/stylize3.rs | 658 ++++++++++++++++++++++++++++++++- 1 file changed, 655 insertions(+), 3 deletions(-) diff --git a/crates/effects/src/stylize3.rs b/crates/effects/src/stylize3.rs index 16a4399e..df46c97e 100644 --- a/crates/effects/src/stylize3.rs +++ b/crates/effects/src/stylize3.rs @@ -1,7 +1,659 @@ -//! Stylize effects, batch 3. +//! Stylize effects, batch 3: CC Block Load, CC Burn Film, CC Glass, CC HexTile, CC Mr. Smoothie +//! and CC Plastic. +//! +//! Written from the public descriptions of what these effects do (progressive block loading, +//! burning film, bump-mapped glass / plastic surfaces lit by an effect light, hexagonal tiling, +//! a colour map sampled from the image itself); the maths is our own. -use crate::EffectSpec; +use effectcraft_color::rgb_to_hsl; +use effectcraft_keyframe::Value; +use effectcraft_project::ParamUi; +use effectcraft_raster::{Image, Px}; +use rayon::prelude::*; + +use crate::noise::fbm; +use crate::util::{Plane, gauss_plane, layer_or_self, lerp4, premul, smoothstep, unpremul}; +use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; + +fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { + EffectSpec { id, name, category: "Stylize", params, render, gpu: false, float: true } +} + +fn pct() -> ParamUi { + slider(0.0, 100.0, 0.0, 100.0, 1) +} + +fn pt(x: f64, y: f64) -> Value { + Value::Vec2([x, y]) +} + +// ------------------------------------------------------------------ CC Block Load + +/// Mean colour of every `s`×`s` block (grid of `ceil(w/s)`×`ceil(h/s)`). +fn block_means(img: &Image, s: usize) -> (usize, usize, Vec) { + let (w, h) = (img.width as usize, img.height as usize); + let gw = w.div_ceil(s).max(1); + let gh = h.div_ceil(s).max(1); + let mut out = vec![[0.0f32; 4]; gw * gh]; + out.par_chunks_mut(gw).enumerate().for_each(|(by, row)| { + for (bx, o) in row.iter_mut().enumerate() { + let mut acc = [0.0f32; 4]; + let mut n = 0.0f32; + for y in by * s..((by + 1) * s).min(h) { + for x in bx * s..((bx + 1) * s).min(w) { + let p = img.data[y * w + x]; + for c in 0..4 { + acc[c] += p[c]; + } + n += 1.0; + } + } + if n > 0.0 { + *o = acc.map(|v| v / n); + } + } + }); + (gw, gh, out) +} + +/// Value of pixel (x, y) at block size `s` (nearest block, or bilinear between block centres). +fn block_at(g: &(usize, usize, Vec), s: usize, x: usize, y: usize, bilinear: bool) -> Px { + let (gw, gh, d) = g; + if !bilinear || s == 1 { + return d[(y / s).min(gh - 1) * gw + (x / s).min(gw - 1)]; + } + let fx = (x as f32 + 0.5) / s as f32 - 0.5; + let fy = (y as f32 + 0.5) / s as f32 - 0.5; + let x0 = fx.floor(); + let y0 = fy.floor(); + let (tx, ty) = (fx - x0, fy - y0); + let gx = |v: f32| (v as i64).clamp(0, *gw as i64 - 1) as usize; + let gy = |v: f32| (v as i64).clamp(0, *gh as i64 - 1) as usize; + let a = d[gy(y0) * gw + gx(x0)]; + let b = d[gy(y0) * gw + gx(x0 + 1.0)]; + let c = d[gy(y0 + 1.0) * gw + gx(x0)]; + let e = d[gy(y0 + 1.0) * gw + gx(x0 + 1.0)]; + lerp4(lerp4(a, b, tx), lerp4(c, e, tx), ty) +} + +fn block_load(ctx: &EffectCtx, mut b: Buf) -> Buf { + let c = (ctx.params.f("completion") / 100.0).clamp(0.0, 1.0); + if c >= 1.0 { + return b; + } + let scan = ctx.params.b("scanlines"); + let smooth = ctx.params.b("smoothing"); + let cleared = ctx.params.b("startCleared"); + let bilinear = ctx.params.b("bilinear"); + let (w, h) = (b.img.width as usize, b.img.height as usize); + let nlev = (w.max(h).max(2) as f64).log2().ceil() as u32; + // Levels 0..=nlev with block sizes 2^(nlev-k); level k is "loading" while p is in [k, k+1). + let p = c * (nlev + 1) as f64; + let done = p.floor() as u32; + let frac = (p - done as f64) as f32; + let size = |k: u32| 1usize << (nlev - k.min(nlev)); + let s_new = size(done); + let new = block_means(&b.img, s_new); + let old = if done > 0 { Some((size(done - 1), block_means(&b.img, size(done - 1)))) } else { None }; + let rows_new = h.div_ceil(s_new) as f32; + let soft = if smooth { 1.5f32 } else { 0.0 }; + let mut out = Image::new(w as u32, h as u32); + out.rows_mut().for_each(|(y, row)| { + // Fraction of the new level present on this row. + let t = if scan { + let front = frac * (rows_new + soft) - soft * 0.5; + let r = (y / s_new) as f32 + 0.5; + if soft > 0.0 { + smoothstep(r - soft * 0.5, r + soft * 0.5, front) + } else if r < front { + 1.0 + } else { + 0.0 + } + } else if smooth { + frac + } else { + 0.0 + }; + for (x, o) in row.iter_mut().enumerate() { + let n = if t > 0.0 { block_at(&new, s_new, x, y, bilinear) } else { [0.0; 4] }; + let prev = match &old { + Some((s, g)) => block_at(g, *s, x, y, bilinear), + None if cleared => [0.0; 4], + None => block_at(&new, s_new, x, y, bilinear), + }; + *o = if t >= 1.0 { + n + } else if t <= 0.0 { + prev + } else { + lerp4(prev, n, t) + }; + } + }); + b.img = out; + b +} + +// ------------------------------------------------------------------ CC Burn Film + +fn burn_film(ctx: &EffectCtx, mut b: Buf) -> Buf { + let burn = (ctx.params.f("burn") / 100.0).clamp(0.0, 1.0) as f32; + if burn <= 0.0 { + return b; + } + let (cx, cy) = b.to_px(ctx.params.v2("center")); + let seed = ctx.params.f("randomSeed").max(0.0) as u32; + let s = b.scale; + let diag = (ctx.layer_size[0].hypot(ctx.layer_size[1]) * s).max(1.0); + let feat = (60.0 * s).max(1e-3); + let th = 1.0 - burn * 1.25; + let band = 0.12 * (burn * 10.0).min(1.0); + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let (fx, fy) = (x as f64 + 0.5, y as f64 + 0.5); + let d = ((fx - cx).hypot(fy - cy) / diag) as f32; + let n = fbm((fx / feat) as f32, (fy / feat) as f32, 0.0, seed.wrapping_add(17), 4.0); + // High near the centre and where the noise is high; burning eats from the top down. + let f = n * 0.65 + (1.0 - d.min(1.0)) * 0.35; + if f > th + band { + *px = [0.0; 4]; + continue; + } + if f > th - band * 2.0 { + let (c, a) = unpremul(*px); + // Charred rim (scorched dark) with a hot orange edge right next to the hole. + let k = smoothstep(th - band * 2.0, th, f); + let hot = smoothstep(th, th + band, f); + let char = 1.0 - k * 0.85; + let mut o = [c[0] * char, c[1] * char, c[2] * char]; + o = [o[0] + (1.0 - o[0]) * hot * 0.9, o[1] + (0.45 - o[1]) * hot * 0.9, o[2] * (1.0 - hot)]; + let na = a * (1.0 - hot * 0.6); + *px = premul(o, na); + } + } + }); + b +} + +// ------------------------------------------------------------------ bump-map surfaces (Glass, Plastic) + +const BUMP_PROPS: [&str; 6] = ["Red", "Green", "Blue", "Alpha", "Luminance", "Lightness"]; + +/// Height field (0..1) from the bump-map layer (or the layer itself), softened. +fn height_field(ctx: &EffectCtx, b: &Buf) -> Plane { + let src = layer_or_self(ctx, b, "bumpMap", true, false); + let prop = ctx.params.e("property"); + let mut pl = Plane::from_image(&src, |p| { + let (c, a) = unpremul(p); + let v = match prop { + 0 => c[0], + 1 => c[1], + 2 => c[2], + 3 => return a, + 4 => effectcraft_color::luminance(c[0], c[1], c[2]), + _ => rgb_to_hsl(c[0], c[1], c[2]).2, + }; + v * a + }); + let soft = ctx.params.f("softness") * b.scale * 0.5; + if soft > 0.05 { + pl = gauss_plane(&pl, soft, soft); + } + pl +} + +struct Light { + intensity: f32, + color: [f32; 3], + point: bool, + pos: (f64, f64), + /// Distant light direction (towards the light). + dir: [f32; 3], + height: f32, + ambient: f32, + diffuse: f32, + specular: f32, + shininess: f32, + metal: f32, +} + +impl Light { + fn from(ctx: &EffectCtx, b: &Buf) -> Light { + let pr = &ctx.params; + let elev = (pr.f("lightHeight").clamp(-100.0, 100.0) / 100.0 * 90.0).to_radians(); + let az = pr.f("lightDirection").to_radians(); + let dir = [(az.sin() * elev.cos()) as f32, (-az.cos() * elev.cos()) as f32, elev.sin() as f32]; + let c = pr.color("lightColor"); + let rough = pr.f("roughness").clamp(0.001, 1.0) as f32; + Light { + intensity: (pr.f("lightIntensity") / 100.0) as f32, + color: [c[0], c[1], c[2]], + point: pr.e("lightType") == 1, + pos: b.to_px(pr.v2("lightPosition")), + dir, + height: (pr.f("lightHeight") / 100.0 * ctx.layer_size[0].max(ctx.layer_size[1]) * b.scale * 0.5) as f32, + ambient: (pr.f("ambient") / 100.0) as f32, + diffuse: (pr.f("diffuse") / 100.0) as f32, + specular: (pr.f("specular") / 100.0) as f32, + shininess: 1.0 / rough, + metal: (pr.f("metal") / 100.0) as f32, + } + } + + fn to_light(&self, x: f64, y: f64) -> [f32; 3] { + if self.point { + let v = [(self.pos.0 - x) as f32, (self.pos.1 - y) as f32, self.height.max(1.0)]; + let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-6); + [v[0] / l, v[1] / l, v[2] / l] + } else { + self.dir + } + } + + /// Lit straight colour for surface normal `n`. Diffuse is normalised so that a flat surface + /// keeps its colour; highlights add on top. + fn shade(&self, c: [f32; 3], n: [f32; 3], l: [f32; 3]) -> [f32; 3] { + let ndl = (n[0] * l[0] + n[1] * l[1] + n[2] * l[2]).max(0.0); + let flat = l[2].max(0.0); + let base = self.ambient + self.diffuse * flat; + let lit = if base > 1e-4 { (self.ambient + self.diffuse * ndl) / base } else { 1.0 }; + let lit = 1.0 + (lit - 1.0) * self.intensity; + // Blinn half vector with the viewer straight above. + let hv = [l[0], l[1], l[2] + 1.0]; + let hl = (hv[0] * hv[0] + hv[1] * hv[1] + hv[2] * hv[2]).sqrt().max(1e-6); + let ndh = ((n[0] * hv[0] + n[1] * hv[1] + n[2] * hv[2]) / hl).max(0.0); + let spec = self.specular * self.intensity * ndh.powf(self.shininess); + let mut o = [0.0f32; 3]; + for i in 0..3 { + let hl_col = self.color[i] * (1.0 - self.metal) + c[i] * self.color[i] * self.metal; + o[i] = c[i] * lit * (1.0 + (self.color[i] - 1.0) * self.intensity.min(1.0)) + spec * hl_col; + } + o + } +} + +/// Surface normal from a height field at pixel (x, y) with height scale `k`. +#[inline] +fn normal(h: &Plane, x: usize, y: usize, k: f32) -> [f32; 3] { + let (xi, yi) = (x as i64, y as i64); + let gx = (h.get_clamped(xi + 1, yi) - h.get_clamped(xi - 1, yi)) * 0.5 * k; + let gy = (h.get_clamped(xi, yi + 1) - h.get_clamped(xi, yi - 1)) * 0.5 * k; + let l = (gx * gx + gy * gy + 1.0).sqrt(); + [-gx / l, -gy / l, 1.0 / l] +} + +fn glass(ctx: &EffectCtx, mut b: Buf) -> Buf { + let hf = height_field(ctx, &b); + let height = ctx.params.f("height") as f32 / 100.0; + let k = height * 25.0; + let disp = ctx.params.f("displacement") * b.scale * 0.25; + let light = Light::from(ctx, &b); + let src = b.img.clone(); + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let n = normal(&hf, x, y, k); + let (fx, fy) = (x as f64 + 0.5, y as f64 + 0.5); + let s = if disp != 0.0 && height != 0.0 { + src.sample_bilinear_clamped(fx + n[0] as f64 * disp, fy + n[1] as f64 * disp) + } else { + src.data[y * src.width as usize + x] + }; + let a = src.data[y * src.width as usize + x][3]; + if a <= 0.0 { + *px = [0.0; 4]; + continue; + } + let (c, sa) = unpremul(s); + let c = if sa > 0.0 { c } else { unpremul(src.data[y * src.width as usize + x]).0 }; + let o = light.shade(c, n, light.to_light(fx, fy)); + *px = premul(o, a); + } + }); + b +} + +fn plastic(ctx: &EffectCtx, mut b: Buf) -> Buf { + let mut hf = height_field(ctx, &b); + let lo = (ctx.params.f("cutMin") / 100.0) as f32; + let hi = (ctx.params.f("cutMax") / 100.0) as f32; + if lo > 0.0 || hi < 1.0 { + hf = hf.map(|v| v.clamp(lo.min(hi), hi.max(lo))); + } + let k = ctx.params.f("height") as f32 / 100.0 * 25.0; + let light = Light::from(ctx, &b); + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + if px[3] <= 0.0 { + continue; + } + let (c, a) = unpremul(*px); + let n = normal(&hf, x, y, k); + let o = light.shade(c, n, light.to_light(x as f64 + 0.5, y as f64 + 0.5)); + *px = premul(o, a); + } + }); + b +} + +// ------------------------------------------------------------------ CC HexTile + +/// Axial hex cell (pointy-top) containing (x, y) for circumradius `r`; returns the cell centre +/// and integer cell coordinates. +fn hex_cell(x: f64, y: f64, r: f64) -> ((f64, f64), (i64, i64)) { + let q = (3f64.sqrt() / 3.0 * x - y / 3.0) / r; + let rr = (2.0 / 3.0 * y) / r; + // Cube rounding. + let (cx, cz) = (q, rr); + let cy = -cx - cz; + let (mut rx, ry, mut rz) = (cx.round(), cy.round(), cz.round()); + let (dx, dy, dz) = ((rx - cx).abs(), (ry - cy).abs(), (rz - cz).abs()); + if dx > dy && dx > dz { + rx = -ry - rz; + } else if dy <= dz { + rz = -rx - ry; + } + let px = r * 3f64.sqrt() * (rx + rz / 2.0); + let py = r * 1.5 * rz; + ((px, py), (rx as i64, rz as i64)) +} + +fn hextile(ctx: &EffectCtx, mut b: Buf) -> Buf { + let r = (ctx.params.f("radius") * b.scale).max(0.5); + let (cx, cy) = b.to_px(ctx.params.v2("center")); + let rot = ctx.params.f("rotate").to_radians(); + let (sn, cs) = rot.sin_cos(); + let smear = (ctx.params.f("smearing") / 100.0).clamp(0.0, 1.0); + let mode = ctx.params.e("render"); + let src = b.img.clone(); + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let (dx, dy) = (x as f64 + 0.5 - cx, y as f64 + 0.5 - cy); + // Into the rotated tiling frame. + let (u, v) = (dx * cs + dy * sn, -dx * sn + dy * cs); + let ((hx, hy), (qi, ri)) = hex_cell(u, v, r); + let (mut lu, lv) = (u - hx, v - hy); + let odd = (qi + ri).rem_euclid(2) == 1; + match mode { + 1 if odd => lu = -lu, + 2 if odd => lu = -lu, + _ => {} + } + let lv = if mode == 2 && odd { -lv } else { lv }; + // Every tile shows the hexagon around the centre point. + let (ox, oy) = (lu * cs - lv * sn, lu * sn + lv * cs); + let (tx, ty) = (cx + ox, cy + oy); + let (sx, sy) = (tx + (x as f64 + 0.5 - tx) * smear, ty + (y as f64 + 0.5 - ty) * smear); + *px = src.sample_bilinear_clamped(sx, sy); + } + }); + b +} + +// ------------------------------------------------------------------ CC Mr. Smoothie + +const SMOOTHIE_PROPS: [&str; 8] = ["Red", "Green", "Blue", "Alpha", "Luminance", "Lightness", "Hue", "Saturation"]; + +fn smoothie(ctx: &EffectCtx, mut b: Buf) -> Buf { + let flow = layer_or_self(ctx, &b, "flowLayer", true, false); + let prop = ctx.params.e("property"); + let mut pl = Plane::from_image(&flow, |p| { + let (c, a) = unpremul(p); + match prop { + 0 => c[0], + 1 => c[1], + 2 => c[2], + 3 => a, + 4 => effectcraft_color::luminance(c[0], c[1], c[2]), + 5 => rgb_to_hsl(c[0], c[1], c[2]).2, + 6 => rgb_to_hsl(c[0], c[1], c[2]).0, + _ => rgb_to_hsl(c[0], c[1], c[2]).1, + } + }); + let sm = ctx.params.f("smoothness") * b.scale * 0.5; + if sm > 0.05 { + pl = gauss_plane(&pl, sm, sm); + } + let (ax, ay) = b.to_px(ctx.params.v2("sampleA")); + let (bx, by) = b.to_px(ctx.params.v2("sampleB")); + // Colour palette sampled from the image along A→B. + const N: usize = 256; + let pal: Vec = (0..N) + .map(|i| { + let t = i as f64 / (N - 1) as f64; + let p = b.img.sample_bilinear_clamped(ax + (bx - ax) * t, ay + (by - ay) * t); + let (c, _) = unpremul(p); + [c[0], c[1], c[2], 1.0] + }) + .collect(); + let phase = (ctx.params.f("phase") / 360.0) as f32; + let loops = (ctx.params.e("colorLoop") + 1) as f32; + b.img.data.par_iter_mut().zip(pl.data.par_iter()).for_each(|(px, &v)| { + if px[3] <= 0.0 { + return; + } + let t = (v * loops + phase).rem_euclid(1.0); + // Ping-pong so the palette loops without seams. + let t = if t < 0.5 { t * 2.0 } else { 2.0 - t * 2.0 }; + let f = t * (N - 1) as f32; + let i = (f as usize).min(N - 2); + let c = lerp4(pal[i], pal[i + 1], f - i as f32); + *px = premul([c[0], c[1], c[2]], px[3]); + }); + b +} + +// ------------------------------------------------------------------ specs + +fn light_params() -> Vec { + vec![ + p("using", "Using", Value::Enum(0), popup(&["Effect Light", "AE Lights"])), + p("lightIntensity", "Light Intensity", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)), + p("lightColor", "Light Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("lightType", "Light Type", Value::Enum(0), popup(&["Distant Light", "Point Light"])), + p("lightHeight", "Light Height", num(50.0), slider(-100.0, 100.0, -100.0, 100.0, 1)), + p("lightPosition", "Light Position", pt(0.25, 0.25), ParamUi::Point), + p("lightDirection", "Light Direction", num(-45.0), ParamUi::Angle), + ] +} + +fn shading_params(ambient: f64, diffuse: f64, specular: f64, roughness: f64, metal: f64) -> Vec { + vec![ + p("ambient", "Ambient", num(ambient), slider(0.0, 200.0, 0.0, 100.0, 1)), + p("diffuse", "Diffuse", num(diffuse), slider(0.0, 200.0, 0.0, 100.0, 1)), + p("specular", "Specular", num(specular), slider(0.0, 200.0, 0.0, 100.0, 1)), + p("roughness", "Roughness", num(roughness), slider(0.001, 0.5, 0.001, 0.5, 3)), + p("metal", "Metal", num(metal), pct()), + ] +} pub fn specs() -> Vec { - Vec::new() + let mut glass_p = vec![ + p("bumpMap", "Bump Map", Value::Layer(None), ParamUi::Layer), + p("property", "Property", Value::Enum(3), popup(&BUMP_PROPS)), + p("softness", "Softness", num(10.0), slider(0.0, 200.0, 0.0, 100.0, 1)), + p("height", "Height", num(50.0), slider(-100.0, 100.0, -100.0, 100.0, 1)), + p("displacement", "Displacement", num(100.0), slider(-500.0, 500.0, -200.0, 200.0, 1)), + ]; + glass_p.extend(light_params()); + glass_p.extend(shading_params(75.0, 32.0, 26.0, 0.05, 100.0)); + let mut plastic_p = vec![ + p("bumpLayer", "Bump Layer", Value::Layer(None), ParamUi::Layer), + p("property", "Property", Value::Enum(3), popup(&BUMP_PROPS)), + p("softness", "Softness", num(10.0), slider(0.0, 200.0, 0.0, 100.0, 1)), + p("height", "Height", num(25.0), slider(-100.0, 100.0, -100.0, 100.0, 1)), + p("cutMin", "Cut Min", num(0.0), pct()), + p("cutMax", "Cut Max", num(100.0), pct()), + ]; + plastic_p.extend(light_params()); + plastic_p.extend(shading_params(100.0, 70.0, 50.0, 0.05, 50.0)); + vec![ + spec( + "ec.stylize.ccblockload", + "CC Block Load", + vec![ + p("completion", "Completion", num(0.0), pct()), + p("scanlines", "Scanlines", Value::Bool(true), ParamUi::Checkbox), + p("smoothing", "Smoothing", Value::Bool(true), ParamUi::Checkbox), + p("startCleared", "Start Cleared", Value::Bool(true), ParamUi::Checkbox), + p("bilinear", "Bilinear", Value::Bool(false), ParamUi::Checkbox), + ], + block_load, + ), + spec( + "ec.stylize.ccburnfilm", + "CC Burn Film", + vec![ + p("burn", "Burn", num(0.0), pct()), + p("center", "Center", pt(0.5, 0.5), ParamUi::Point), + p("randomSeed", "Random Seed", num(0.0), slider(0.0, 10000.0, 0.0, 100.0, 0)), + ], + burn_film, + ), + spec("ec.stylize.ccglass", "CC Glass", glass_p, glass), + spec( + "ec.stylize.cchextile", + "CC HexTile", + vec![ + p("render", "Render", Value::Enum(0), popup(&["Normal", "Mirrored", "Rotated"])), + p("radius", "Radius", num(50.0), slider(1.0, 4000.0, 2.0, 500.0, 1)), + p("center", "Center", pt(0.5, 0.5), ParamUi::Point), + p("rotate", "Rotate", num(0.0), ParamUi::Angle), + p("smearing", "Smearing", num(0.0), pct()), + ], + hextile, + ), + spec( + "ec.stylize.ccmrsmoothie", + "CC Mr. Smoothie", + vec![ + p("flowLayer", "Flow Layer", Value::Layer(None), ParamUi::Layer), + p("property", "Property", Value::Enum(4), popup(&SMOOTHIE_PROPS)), + p("smoothness", "Smoothness", num(20.0), slider(0.0, 200.0, 0.0, 100.0, 1)), + p("sampleA", "Sample A", pt(0.25, 0.5), ParamUi::Point), + p("sampleB", "Sample B", pt(0.75, 0.5), ParamUi::Point), + p("phase", "Phase", num(0.0), ParamUi::Angle), + p("colorLoop", "Color Loop", Value::Enum(0), popup(&["1x", "2x", "3x", "4x"])), + ], + smoothie, + ), + spec("ec.stylize.ccplastic", "CC Plastic", plastic_p, plastic), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{EffectEnv, run_fx}; + + fn ramp(w: u32, h: u32) -> Image { + let mut img = Image::new(w, h); + for y in 0..h { + for x in 0..w { + img.set(x, y, [x as f32 / w as f32, y as f32 / h as f32, 0.3, 1.0]); + } + } + img + } + + fn bumpy(w: u32, h: u32) -> Image { + let mut img = ramp(w, h); + for y in 0..h { + for x in 0..w { + let a = if (x as i32 - 20).pow(2) + (y as i32 - 16).pow(2) < 64 { 1.0 } else { 0.5 }; + let p = img.data[(y * w + x) as usize]; + img.set(x, y, [p[0] * a, p[1] * a, p[2] * a, a]); + } + } + img + } + + fn run(id: &str, vals: &[(&str, Value)], img: Image) -> Buf { + run_fx(id, vals, img, 0.0, EffectEnv::default()) + } + + fn max_diff(a: &Image, b: &Image) -> f32 { + a.data.iter().zip(&b.data).flat_map(|(p, q)| (0..4).map(move |c| (p[c] - q[c]).abs())).fold(0.0, f32::max) + } + + const ALL: [&str; 6] = + ["ec.stylize.ccblockload", "ec.stylize.ccburnfilm", "ec.stylize.ccglass", "ec.stylize.cchextile", "ec.stylize.ccmrsmoothie", "ec.stylize.ccplastic"]; + + #[test] + fn all_deterministic() { + for id in ALL { + let vals: &[(&str, Value)] = match id { + "ec.stylize.ccblockload" => &[("completion", num(40.0))], + "ec.stylize.ccburnfilm" => &[("burn", num(50.0))], + _ => &[], + }; + let a = run(id, vals, bumpy(40, 32)); + let b = run(id, vals, bumpy(40, 32)); + assert_eq!(a.img.data, b.img.data, "{id}"); + } + } + + #[test] + fn block_load_complete_is_identity_and_start_cleared() { + let img = ramp(40, 30); + assert_eq!(run("ec.stylize.ccblockload", &[("completion", num(100.0))], img.clone()).img.data, img.data); + let o = run("ec.stylize.ccblockload", &[("completion", num(0.0))], img.clone()); + assert!(o.img.data.iter().all(|p| p[3] == 0.0)); + // Midway: blocky (fewer distinct colours than the source). + let o = run("ec.stylize.ccblockload", &[("completion", num(50.0)), ("smoothing", Value::Bool(false))], img.clone()); + let mut cols: Vec = o.img.data.iter().map(|p| (p[0] * 1000.0) as u32).collect(); + cols.sort(); + cols.dedup(); + assert!(cols.len() < 40, "{}", cols.len()); + } + + #[test] + fn burn_film_zero_identity_full_burns() { + let img = ramp(40, 30); + assert_eq!(run("ec.stylize.ccburnfilm", &[], img.clone()).img.data, img.data); + let o = run("ec.stylize.ccburnfilm", &[("burn", num(100.0))], img); + assert!(o.img.data.iter().all(|p| p[3] == 0.0)); + } + + #[test] + fn glass_flat_without_highlight_is_identity() { + let img = bumpy(40, 32); + let o = run("ec.stylize.ccglass", &[("height", num(0.0)), ("specular", num(0.0))], img.clone()); + assert!(max_diff(&o.img, &img) < 1e-4, "{}", max_diff(&o.img, &img)); + let o = run("ec.stylize.ccglass", &[], img.clone()); + assert!(max_diff(&o.img, &img) > 0.01); + } + + #[test] + fn plastic_shades_bumps() { + let img = bumpy(40, 32); + let o = run("ec.stylize.ccplastic", &[("height", num(0.0)), ("specular", num(0.0))], img.clone()); + assert!(max_diff(&o.img, &img) < 1e-4); + let o = run("ec.stylize.ccplastic", &[("height", num(80.0))], img.clone()); + assert!(max_diff(&o.img, &img) > 0.01); + // Alpha is untouched. + assert!(o.img.data.iter().zip(&img.data).all(|(a, b)| (a[3] - b[3]).abs() < 1e-6)); + } + + #[test] + fn hextile_repeats_centre() { + let img = ramp(64, 64); + let o = run("ec.stylize.cchextile", &[("radius", num(8.0)), ("center", pt(32.0, 32.0))], img.clone()); + assert!(max_diff(&o.img, &img) > 0.1); + // Pixels at the centre are unchanged (inside the central tile). + let c = (32 * 64 + 32) as usize; + assert!((0..4).all(|i| (o.img.data[c][i] - img.data[c][i]).abs() < 1e-4)); + // Full smearing restores the source. + let o = run("ec.stylize.cchextile", &[("radius", num(8.0)), ("smearing", num(100.0))], img.clone()); + assert!(max_diff(&o.img, &img) < 1e-4); + } + + #[test] + fn smoothie_maps_to_sampled_palette() { + let img = ramp(40, 30); + let o = run("ec.stylize.ccmrsmoothie", &[("smoothness", num(0.0)), ("sampleA", pt(10.0, 15.0)), ("sampleB", pt(30.0, 15.0))], img.clone()); + // Every output colour lies on the palette line between A and B (blue stays 0.3, green ≈ 0.5). + assert!(o.img.data.iter().all(|p| (p[2] - 0.3).abs() < 1e-3 && (p[1] - 0.5).abs() < 0.03)); + assert!(max_diff(&o.img, &img) > 0.1); + } } From 0afa24b88f8179afd08c501a1680336a2bbfd6d5 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 22:02:01 -0400 Subject: [PATCH 05/14] M9.3: Transition effects (CC Glass Wipe, CC Image Wipe, CC Jaws, CC Line Sweep, CC Twister, CC WarpoMatic) Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/transition2.rs | 543 +++++++++++++++++++++++++++++- 1 file changed, 540 insertions(+), 3 deletions(-) diff --git a/crates/effects/src/transition2.rs b/crates/effects/src/transition2.rs index 5f33c4b3..2e1738b2 100644 --- a/crates/effects/src/transition2.rs +++ b/crates/effects/src/transition2.rs @@ -1,7 +1,544 @@ -//! Transition effects, batch 2. +//! Transition effects, batch 2: CC Glass Wipe, CC Image Wipe, CC Jaws, CC Line Sweep, +//! CC Twister and CC WarpoMatic. +//! +//! Written from the public descriptions of these transitions (gradient-driven wipes with glass +//! refraction or warping, jagged "jaws" opening, staggered line sweeps, a ribbon twist that +//! reveals a back side); the maths is our own. Completion 0 leaves the layer untouched; +//! completion 100 leaves it fully transparent (or showing the revealed / back-side layer). -use crate::EffectSpec; +use effectcraft_color::rgb_to_hsl; +use effectcraft_keyframe::Value; +use effectcraft_project::ParamUi; +use effectcraft_raster::{Image, Px}; +use rayon::prelude::*; + +use crate::util::{Plane, fit_layer, gauss_plane, layer_or_self, lerp4, smoothstep, unpremul}; +use crate::{Buf, EffectCtx, EffectSpec, num, p, popup, slider}; + +fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { + EffectSpec { id, name, category: "Transition", params, render, gpu: false, float: true } +} + +fn pct() -> ParamUi { + slider(0.0, 100.0, 0.0, 100.0, 1) +} + +fn pt(x: f64, y: f64) -> Value { + Value::Vec2([x, y]) +} + +fn completion(ctx: &EffectCtx) -> f32 { + (ctx.params.f("completion") / 100.0).clamp(0.0, 1.0) as f32 +} + +/// The layer in layer parameter `id` fitted to `b`, if one is chosen. +fn reveal(ctx: &EffectCtx, b: &Buf, id: &str) -> Option { + ctx.layer_param(id, true).map(|o| fit_layer(ctx, b, &o, false)) +} + +const GRAD_PROPS: [&str; 7] = ["Red", "Green", "Blue", "Alpha", "Luminance", "Lightness", "Saturation"]; + +fn prop_plane(img: &Image, prop: u32) -> Plane { + Plane::from_image(img, |p| { + let (c, a) = unpremul(p); + match prop { + 0 => c[0] * a, + 1 => c[1] * a, + 2 => c[2] * a, + 3 => a, + 4 => effectcraft_color::luminance(c[0], c[1], c[2]) * a, + 5 => rgb_to_hsl(c[0], c[1], c[2]).2 * a, + _ => rgb_to_hsl(c[0], c[1], c[2]).1 * a, + } + }) +} + +/// Fraction of a pixel with gradient value `g` that has transitioned, for a soft band of +/// half-width `w`: none at completion 0, all at completion 1. +#[inline] +fn wipe_t(g: f32, c: f32, w: f32) -> f32 { + let w = w.max(1e-3); + let edge = c * (1.0 + 2.0 * w) - w; + 1.0 - smoothstep(edge - w, edge + w, g) +} + +#[inline] +fn grad(pl: &Plane, x: usize, y: usize) -> (f32, f32) { + let (xi, yi) = (x as i64, y as i64); + ((pl.get_clamped(xi + 1, yi) - pl.get_clamped(xi - 1, yi)) * 0.5, (pl.get_clamped(xi, yi + 1) - pl.get_clamped(xi, yi - 1)) * 0.5) +} + +// ------------------------------------------------------------------ CC Glass Wipe + +fn glass_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf { + let c = completion(ctx); + if c <= 0.0 { + return b; + } + let gimg = layer_or_self(ctx, &b, "gradientLayer", true, false); + let mut g = prop_plane(&gimg, 4); + let soft = (ctx.params.f("softness") / 100.0) as f32; + let sm = ctx.params.f("softness") * b.scale * 0.2; + if sm > 0.05 { + g = gauss_plane(&g, sm, sm); + } + let disp = (ctx.params.f("displacementAmount") * b.scale * 4.0) as f32; + let rev = reveal(ctx, &b, "layerToReveal"); + let src = b.img.clone(); + let w = soft * 0.5 + 0.01; + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let t = wipe_t(g.get(x, y), c, w); + let band = 4.0 * t * (1.0 - t); + let (gx, gy) = grad(&g, x, y); + let (fx, fy) = (x as f64 + 0.5, y as f64 + 0.5); + let (ox, oy) = ((gx * disp * band) as f64 * 10.0, (gy * disp * band) as f64 * 10.0); + let a = if band > 1e-4 { src.sample_bilinear(fx + ox, fy + oy) } else { src.data[y * src.width as usize + x] }; + let r = match &rev { + Some(r) if band > 1e-4 => r.sample_bilinear(fx - ox, fy - oy), + Some(r) => r.data[y * r.width as usize + x], + None => [0.0; 4], + }; + *px = lerp4(a, r, t); + } + }); + b +} + +// ------------------------------------------------------------------ CC Image Wipe + +fn image_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf { + let c = completion(ctx); + if c <= 0.0 { + return b; + } + let gimg = layer_or_self(ctx, &b, "layer", true, false); + let mut g = prop_plane(&gimg, ctx.params.e("property")); + let bl = ctx.params.f("blur") * b.scale * 0.5; + if bl > 0.05 { + g = gauss_plane(&g, bl, bl); + } + if ctx.params.b("inverseGradient") { + g = g.map(|v| 1.0 - v); + } + let mut w = (ctx.params.f("borderSoftness") / 100.0) as f32 * 0.5; + if ctx.params.b("autoSoftness") { + w = w.max(0.02); + } + b.img.data.par_iter_mut().zip(g.data.par_iter()).for_each(|(px, &gv)| { + let k = 1.0 - wipe_t(gv, c, w); + *px = px.map(|v| v * k); + }); + b +} + +// ------------------------------------------------------------------ CC Jaws + +fn jaws(ctx: &EffectCtx, mut b: Buf) -> Buf { + let c = completion(ctx) as f64; + if c <= 0.0 { + return b; + } + let s = b.scale; + let (cx, cy) = b.to_px(ctx.params.v2("center")); + let ang = ctx.params.f("direction").to_radians(); + let (sn, cs) = ang.sin_cos(); + let width = (ctx.params.f("width") * s).max(1.0); + let amp = ctx.params.f("height") / 100.0 * width * 0.5; + let shape = ctx.params.e("shape"); + // Far enough at completion 1 that both halves (teeth included) have left the buffer. + let (bw, bh) = (b.img.width as f64, b.img.height as f64); + let vmax = [(0.0, 0.0), (bw, 0.0), (0.0, bh), (bw, bh)].iter().map(|&(x, y)| (-(x - cx) * sn + (y - cy) * cs).abs()).fold(0.0, f64::max); + let d = c * (vmax + 2.0 * amp + 2.0); + // Tooth profile in [-amp, amp] along the cut. + let profile = move |u: f64| -> f64 { + let ph = (u / width).rem_euclid(1.0); + match shape { + 0 => amp * (1.0 - 4.0 * (ph - 0.5).abs()), + 1 => { + // RoboJaw: square teeth with sloped flanks. + let t = (1.0 - 4.0 * (ph - 0.5).abs()) * 2.0; + amp * t.clamp(-1.0, 1.0) + } + 2 => { + if ph < 0.5 { + amp + } else { + -amp + } + } + _ => amp * (ph * std::f64::consts::TAU).sin(), + } + }; + let src = b.img.clone(); + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let (dx, dy) = (x as f64 + 0.5 - cx, y as f64 + 0.5 - cy); + // u along the cut, v across it. + let (u, v) = (dx * cs + dy * sn, -dx * sn + dy * cs); + let f = profile(u); + let back = |vs: f64| { + let (sx, sy) = (u * cs - vs * sn + cx, u * sn + vs * cs + cy); + src.sample_bilinear(sx, sy) + }; + *px = if v + d < f { + back(v + d) + } else if v - d >= f { + back(v - d) + } else { + [0.0; 4] + }; + } + }); + b +} + +// ------------------------------------------------------------------ CC Line Sweep + +fn line_sweep(ctx: &EffectCtx, mut b: Buf) -> Buf { + let c = completion(ctx) as f64; + if c <= 0.0 { + return b; + } + let s = b.scale; + let ang = ctx.params.f("direction").to_radians(); + let (sn, cs) = ang.sin_cos(); + let thick = (ctx.params.f("thickness") * s).max(1.0); + let slant = ctx.params.f("slant") * s; + let flip = ctx.params.b("flipDirection"); + // Projected extents of the buffer along the sweep (u) and across it (v). + let (w, h) = (b.img.width as f64, b.img.height as f64); + let corners = [(0.0, 0.0), (w, 0.0), (0.0, h), (w, h)]; + let proj = |x: f64, y: f64| (x * cs + y * sn, -x * sn + y * cs); + let (mut u0, mut u1, mut v0, mut v1) = (f64::MAX, f64::MIN, f64::MAX, f64::MIN); + for (x, y) in corners { + let (u, v) = proj(x, y); + u0 = u0.min(u); + u1 = u1.max(u); + v0 = v0.min(v); + v1 = v1.max(v); + } + let nstr = ((v1 - v0) / thick).ceil().max(1.0); + let total = (u1 - u0) + slant.abs() * nstr + 1.0; + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let (u, v) = proj(x as f64 + 0.5, y as f64 + 0.5); + let k = ((v - v0) / thick).floor(); + let k = if flip { nstr - 1.0 - k } else { k }; + let stagger = if slant >= 0.0 { k } else { nstr - 1.0 - k }; + let front = u0 + c * total - slant.abs() * stagger; + if u < front { + *px = [0.0; 4]; + } + } + }); + b +} + +// ------------------------------------------------------------------ CC Twister + +fn twister(ctx: &EffectCtx, mut b: Buf) -> Buf { + let c = completion(ctx) as f64; + if c <= 0.0 { + return b; + } + let (cx, cy) = b.to_px(ctx.params.v2("center")); + let ang = ctx.params.f("axis").to_radians(); + let (sn, cs) = ang.sin_cos(); + let shading = ctx.params.b("shading"); + let half = (b.img.width as f64).hypot(b.img.height as f64) * 0.5; + let src = b.img.clone(); + let back = reveal(ctx, &b, "backside"); + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let (dx, dy) = (x as f64 + 0.5 - cx, y as f64 + 0.5 - cy); + // u along the twist axis, v across it. + let (u, v) = (dx * cs + dy * sn, -dx * sn + dy * cs); + let s01 = ((u / half) * 0.5 + 0.5).clamp(0.0, 1.0); + let th = std::f64::consts::PI * (c * 2.0 - s01).clamp(0.0, 1.0); + let k = th.cos(); + if k.abs() < 1e-4 { + *px = [0.0; 4]; + continue; + } + let vs = v / k; + let (sx, sy) = (u * cs - vs * sn + cx, u * sn + vs * cs + cy); + let mut o: Px = if k > 0.0 { + src.sample_bilinear(sx, sy) + } else { + // Back side: the layer itself is seen from behind (mirrored across the axis); a + // Backside layer is mapped so it reads upright when fully flipped. + match &back { + Some(bk) => bk.sample_bilinear(u * cs + vs * sn + cx, u * sn - vs * cs + cy), + None => src.sample_bilinear(sx, sy), + } + }; + if shading { + let l = (0.4 + 0.6 * k.abs()) as f32; + o = [o[0] * l, o[1] * l, o[2] * l, o[3]]; + } + *px = o; + } + }); + b +} + +// ------------------------------------------------------------------ CC WarpoMatic + +fn warpomatic(ctx: &EffectCtx, mut b: Buf) -> Buf { + let c = completion(ctx); + if c <= 0.0 { + return b; + } + let rimg = layer_or_self(ctx, &b, "reactorLayer", true, false); + let mut m = prop_plane(&rimg, 4); + let sm = ctx.params.f("smoothness") * b.scale * 0.5; + if sm > 0.05 { + m = gauss_plane(&m, sm, sm); + } + m = match ctx.params.e("reactor") { + 1 => { + // Contrast differences: distance from the local mean. + let mean = gauss_plane(&m, 8.0 * b.scale, 8.0 * b.scale); + m.zip_map(&mean, |a, b| ((a - b).abs() * 4.0).min(1.0)) + } + 2 => { + // Local differences: gradient magnitude. + let mut o = Plane::new(m.w, m.h); + o.data.par_chunks_mut(m.w.max(1)).enumerate().for_each(|(y, row)| { + for (x, v) in row.iter_mut().enumerate() { + let (gx, gy) = grad(&m, x, y); + *v = ((gx * gx + gy * gy).sqrt() * 8.0).min(1.0); + } + }); + o + } + _ => m, + }; + let amt = (ctx.params.f("warpAmount") * b.scale) as f32 * 20.0; + let dir = ctx.params.e("warpDirection"); + let span = (ctx.params.f("blendSpan") / 100.0) as f32 * 0.5; + let rev = reveal(ctx, &b, "layerToReveal"); + let src = b.img.clone(); + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let q = wipe_t(m.get(x, y), c, span); + let (gx, gy) = grad(&m, x, y); + let (fx, fy) = (x as f64 + 0.5, y as f64 + 0.5); + let (ox, oy) = ((gx * amt * q) as f64, (gy * amt * q) as f64); + let (ix, iy) = ((gx * amt * (1.0 - q)) as f64, (gy * amt * (1.0 - q)) as f64); + let (ix, iy) = match dir { + 1 => (-ix, -iy), + 2 => (-iy, ix), + _ => (ix, iy), + }; + let out = if q < 1.0 { src.sample_bilinear(fx + ox, fy + oy) } else { [0.0; 4] }; + let inc = match &rev { + Some(r) if q > 0.0 => r.sample_bilinear(fx + ix, fy + iy), + _ => [0.0; 4], + }; + *px = lerp4(out, inc, q); + } + }); + b +} + +// ------------------------------------------------------------------ specs pub fn specs() -> Vec { - Vec::new() + let done = || p("completion", "Completion", num(0.0), pct()); + vec![ + spec( + "ec.transition.ccglasswipe", + "CC Glass Wipe", + vec![ + done(), + p("layerToReveal", "Layer to Reveal", Value::Layer(None), ParamUi::Layer), + p("gradientLayer", "Gradient Layer", Value::Layer(None), ParamUi::Layer), + p("softness", "Softness", num(10.0), pct()), + p("displacementAmount", "Displacement Amount", num(10.0), slider(-100.0, 100.0, -100.0, 100.0, 1)), + ], + glass_wipe, + ), + spec( + "ec.transition.ccimagewipe", + "CC Image Wipe", + vec![ + done(), + p("borderSoftness", "Border Softness", num(0.0), pct()), + p("autoSoftness", "Auto Softness", Value::Bool(true), ParamUi::Checkbox), + p("layer", "Layer", Value::Layer(None), ParamUi::Layer), + p("property", "Property", Value::Enum(4), popup(&GRAD_PROPS)), + p("blur", "Blur", num(0.0), slider(0.0, 200.0, 0.0, 100.0, 1)), + p("inverseGradient", "Inverse Gradient", Value::Bool(false), ParamUi::Checkbox), + ], + image_wipe, + ), + spec( + "ec.transition.ccjaws", + "CC Jaws", + vec![ + done(), + p("center", "Center", pt(0.5, 0.5), ParamUi::Point), + p("direction", "Direction", num(0.0), ParamUi::Angle), + p("height", "Height", num(50.0), pct()), + p("width", "Width", num(50.0), slider(1.0, 1000.0, 1.0, 200.0, 1)), + p("shape", "Shape", Value::Enum(0), popup(&["Spikes", "RoboJaw", "Block", "Waves"])), + ], + jaws, + ), + spec( + "ec.transition.cclinesweep", + "CC Line Sweep", + vec![ + done(), + p("direction", "Direction", num(0.0), ParamUi::Angle), + p("thickness", "Thickness", num(20.0), slider(1.0, 500.0, 1.0, 100.0, 1)), + p("slant", "Slant", num(20.0), slider(-500.0, 500.0, -100.0, 100.0, 1)), + p("flipDirection", "Flip Direction", Value::Bool(false), ParamUi::Checkbox), + ], + line_sweep, + ), + spec( + "ec.transition.cctwister", + "CC Twister", + vec![ + done(), + p("backside", "Backside", Value::Layer(None), ParamUi::Layer), + p("shading", "Shading", Value::Bool(true), ParamUi::Checkbox), + p("center", "Center", pt(0.5, 0.5), ParamUi::Point), + p("axis", "Axis", num(45.0), ParamUi::Angle), + ], + twister, + ), + spec( + "ec.transition.ccwarpomatic", + "CC WarpoMatic", + vec![ + done(), + p("layerToReveal", "Layer to Reveal", Value::Layer(None), ParamUi::Layer), + p("reactorLayer", "Reactor Layer", Value::Layer(None), ParamUi::Layer), + p("reactor", "Reactor", Value::Enum(0), popup(&["Brightness", "Contrast Differences", "Local Differences"])), + p("smoothness", "Smoothness", num(10.0), slider(0.0, 200.0, 0.0, 100.0, 1)), + p("warpAmount", "Warp Amount", num(50.0), slider(-1000.0, 1000.0, -200.0, 200.0, 1)), + p("warpDirection", "Warp Direction", Value::Enum(0), popup(&["Joint", "Opposing", "Twisting"])), + p("blendSpan", "Blend Span", num(10.0), pct()), + ], + warpomatic, + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{EffectEnv, run_fx}; + + fn ramp(w: u32, h: u32) -> Image { + let mut img = Image::new(w, h); + for y in 0..h { + for x in 0..w { + img.set(x, y, [x as f32 / w as f32, y as f32 / h as f32, 0.3, 1.0]); + } + } + img + } + + fn run(id: &str, vals: &[(&str, Value)], img: Image) -> Buf { + run_fx(id, vals, img, 0.0, EffectEnv::default()) + } + + const ALL: [&str; 6] = [ + "ec.transition.ccglasswipe", + "ec.transition.ccimagewipe", + "ec.transition.ccjaws", + "ec.transition.cclinesweep", + "ec.transition.cctwister", + "ec.transition.ccwarpomatic", + ]; + + fn centre(id: &str) -> Vec<(&'static str, Value)> { + match id { + "ec.transition.ccjaws" => vec![("center", pt(20.0, 16.0)), ("width", num(10.0))], + "ec.transition.cctwister" => vec![("center", pt(20.0, 16.0))], + _ => vec![], + } + } + + #[test] + fn zero_completion_is_identity() { + let img = ramp(40, 32); + for id in ALL { + let o = run(id, ¢re(id), img.clone()); + assert_eq!(o.img.data, img.data, "{id}"); + } + } + + #[test] + fn full_completion_is_transparent() { + let img = ramp(40, 32); + for id in ALL { + if id == "ec.transition.cctwister" { + continue; + } + let mut v = centre(id); + v.push(("completion", num(100.0))); + let o = run(id, &v, img.clone()); + assert!(o.img.data.iter().all(|p| p[3].abs() < 1e-5), "{id}"); + } + } + + #[test] + fn midway_deterministic_and_partial() { + let img = ramp(40, 32); + for id in ALL { + let mut v = centre(id); + v.push(("completion", num(50.0))); + let a = run(id, &v, img.clone()); + let b = run(id, &v, img.clone()); + assert_eq!(a.img.data, b.img.data, "{id}"); + let opaque = a.img.data.iter().filter(|p| p[3] > 0.99).count(); + let clear = a.img.data.iter().filter(|p| p[3] < 0.01).count(); + if id != "ec.transition.cctwister" { + assert!(opaque > 0 && clear > 0, "{id}: {opaque} {clear}"); + } + assert_ne!(a.img.data, img.data, "{id}"); + } + } + + #[test] + fn twister_full_shows_mirrored_back() { + let img = ramp(40, 32); + let o = run( + "ec.transition.cctwister", + &[("completion", num(100.0)), ("shading", Value::Bool(false)), ("axis", num(0.0)), ("center", pt(20.0, 16.0))], + img.clone(), + ); + // Axis horizontal through the centre: the back is the source flipped vertically. + for (x, y) in [(5usize, 4usize), (30, 20), (12, 27)] { + let got = o.img.data[y * 40 + x]; + let want = img.data[(31 - y) * 40 + x]; + assert!((got[1] - want[1]).abs() < 1e-3 && (got[0] - want[0]).abs() < 1e-3, "{x},{y} {got:?} {want:?}"); + } + } + + #[test] + fn line_sweep_flip_reverses_stagger() { + let img = ramp(40, 32); + let a = run("ec.transition.cclinesweep", &[("completion", num(40.0))], img.clone()); + let b = run("ec.transition.cclinesweep", &[("completion", num(40.0)), ("flipDirection", Value::Bool(true))], img.clone()); + let cleared = |o: &Buf, y: usize| (0..40).filter(|&x| o.img.data[y * 40 + x][3] < 0.01).count(); + // Unflipped: lower stripes trail; flipped: upper stripes trail. + assert!(cleared(&a, 2) > cleared(&a, 30)); + assert!(cleared(&b, 2) < cleared(&b, 30)); + } + + #[test] + fn image_wipe_follows_gradient() { + let img = ramp(40, 32); + let o = run("ec.transition.ccimagewipe", &[("completion", num(50.0)), ("property", Value::Enum(0))], img.clone()); + // Red ramps left→right: dark (left) side goes first. + assert!(o.img.data[16 * 40 + 2][3] < 0.01); + assert!(o.img.data[16 * 40 + 37][3] > 0.99); + } } From d98775a931ede795b0e67cffad4e8d40393c2b78 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 21:55:04 -0400 Subject: [PATCH 06/14] M9.3: Channel effects (Blend, Calculations, CC Composite, Compound Arithmetic) Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/channel2.rs | 447 ++++++++++++++++++++++++++++++++- 1 file changed, 444 insertions(+), 3 deletions(-) diff --git a/crates/effects/src/channel2.rs b/crates/effects/src/channel2.rs index 70516adc..25984984 100644 --- a/crates/effects/src/channel2.rs +++ b/crates/effects/src/channel2.rs @@ -1,7 +1,448 @@ -//! Channel effects, batch 2. +//! Channel effects, batch 2: layer-to-layer combinations (Blend, Calculations, Compound +//! Arithmetic) and CC Composite. Second layers come from layer parameters through the effect +//! host; with no layer chosen the effect uses the layer itself, as After Effects does. -use crate::EffectSpec; +use effectcraft_color::{BlendMode, blend_pixel, hsl_to_rgb, luminance, rgb_to_hsl}; +use effectcraft_keyframe::Value; +use effectcraft_project::ParamUi; +use rayon::prelude::*; + +use crate::util::{layer_or_self, lerp4, premul, unpremul}; +use crate::{Buf, EffectCtx, EffectSpec, num, p, popup, slider}; + +fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { + EffectSpec { id, name, category: "Channel", params, render, gpu: false, float: true } +} + +fn pct() -> ParamUi { + slider(0.0, 100.0, 0.0, 100.0, 1) +} + +fn layer_param(id: &'static str, name: &'static str) -> crate::ParamSpec { + p(id, name, Value::Layer(None), ParamUi::Layer) +} + +const SIZE_DIFF: [&str; 2] = ["Center", "Stretch to Fit"]; + +// ---------------------------------------------------------------- Blend + +fn blend(ctx: &EffectCtx, mut b: Buf) -> Buf { + let mode = ctx.params.e("mode"); + let orig = (ctx.params.f("blendWithOriginal") as f32 / 100.0).clamp(0.0, 1.0); + if orig >= 1.0 { + return b; + } + let other = layer_or_self(ctx, &b, "blendWithLayer", true, ctx.params.e("ifLayerSizesDiffer") == 1); + b.img.data.par_iter_mut().zip(other.data.par_iter()).for_each(|(px, &o)| { + let src = *px; + let blended = match mode { + // Crossfade: the other layer replaces this one. + 0 => o, + // Color Only / Tint Only: hue (and saturation) from the other layer, lightness kept. + 1 | 2 => { + let (c, a) = unpremul(src); + let (oc, oa) = unpremul(o); + if a <= 0.0 || oa <= 0.0 { + src + } else { + let (_, s, l) = rgb_to_hsl(c[0], c[1], c[2]); + let (oh, os, _) = rgb_to_hsl(oc[0], oc[1], oc[2]); + let (h2, s2) = if mode == 1 { + (oh, os) + } else if s > 1e-4 { + (oh, s) + } else { + (0.0, 0.0) + }; + let (r, g, bb) = hsl_to_rgb(h2, s2, l); + let t = oa; + lerp4(src, premul([r, g, bb], a), t) + } + } + 3 => [src[0].min(o[0]), src[1].min(o[1]), src[2].min(o[2]), src[3]], + _ => [src[0].max(o[0]), src[1].max(o[1]), src[2].max(o[2]), src[3].max(o[3])], + }; + *px = lerp4(blended, src, orig); + }); + b +} + +// ---------------------------------------------------------------- Calculations + +/// Blending modes offered by Calculations / CC Composite (menu order). +const CALC_MODES: [BlendMode; 22] = [ + BlendMode::Normal, + BlendMode::Darken, + BlendMode::Multiply, + BlendMode::ColorBurn, + BlendMode::LinearBurn, + BlendMode::Add, + BlendMode::Lighten, + BlendMode::Screen, + BlendMode::ColorDodge, + BlendMode::LinearDodge, + BlendMode::Overlay, + BlendMode::SoftLight, + BlendMode::HardLight, + BlendMode::LinearLight, + BlendMode::VividLight, + BlendMode::PinLight, + BlendMode::HardMix, + BlendMode::Difference, + BlendMode::Exclusion, + BlendMode::Hue, + BlendMode::Saturation, + BlendMode::Luminosity, +]; + +fn calc_mode_names() -> Vec<&'static str> { + CALC_MODES.iter().map(|m| m.label()).collect() +} + +const CHANNELS: [&str; 6] = ["RGBA", "Gray", "Red", "Green", "Blue", "Alpha"]; + +/// Extract channel `ch` (index into [`CHANNELS`]) of a premultiplied pixel as a premultiplied +/// pixel: RGBA unchanged, single channels as opaque grey (Alpha as grey too). +#[inline] +fn extract(px: [f32; 4], ch: u32, invert: bool) -> [f32; 4] { + let (c, a) = unpremul(px); + let g = match ch { + 0 => { + if invert { + return premul([1.0 - c[0], 1.0 - c[1], 1.0 - c[2]], a); + } + return px; + } + 1 => luminance(c[0], c[1], c[2]) * a, + 2 => c[0] * a, + 3 => c[1] * a, + 4 => c[2] * a, + _ => a, + }; + let g = if invert { 1.0 - g } else { g }; + [g, g, g, 1.0] +} + +fn calculations(ctx: &EffectCtx, mut b: Buf) -> Buf { + let in_ch = ctx.params.e("inputChannel"); + let in_inv = ctx.params.b("invertInput"); + let second = ctx.params.get("secondLayer").and_then(Value::as_layer).is_some(); + let sec_ch = ctx.params.e("secondLayerChannel"); + let sec_op = (ctx.params.f("secondLayerOpacity") as f32 / 100.0).clamp(0.0, 1.0); + let sec_inv = ctx.params.b("invertSecondLayer"); + let mode = CALC_MODES[(ctx.params.e("blendingMode") as usize).min(CALC_MODES.len() - 1)]; + let keep_alpha = ctx.params.b("preserveTransparency"); + let other = if second { Some(layer_or_self(ctx, &b, "secondLayer", true, ctx.params.b("stretchSecondLayerToFit"))) } else { None }; + b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { + let orig_a = px[3]; + let mut o = extract(*px, in_ch, in_inv); + if let Some(other) = &other { + let s = extract(other.data[i], sec_ch, sec_inv).map(|v| v * sec_op); + o = blend_pixel(mode, o, s, 0.5); + } + if keep_alpha && o[3] > 1e-6 { + let (c, _) = unpremul(o); + o = premul(c, orig_a); + } + o[3] = o[3].clamp(0.0, 1.0); + *px = o; + }); + b +} + +// ---------------------------------------------------------------- CC Composite + +const CC_COMPOSITE_MODES: [&str; 16] = [ + "In Front", + "In Back", + "Add", + "Multiply", + "Screen", + "Overlay", + "Soft Light", + "Hard Light", + "Darken", + "Lighten", + "Difference", + "Hue", + "Saturation", + "Color", + "Luminosity", + "Stencil Alpha", +]; + +/// Composites the original over/under/with the effect result. The original is the image as it +/// enters this effect (the host does not expose the pre-effect source). +fn cc_composite(ctx: &EffectCtx, mut b: Buf) -> Buf { + let op = (ctx.params.f("opacity") as f32 / 100.0).clamp(0.0, 1.0); + let m = ctx.params.e("compositeOriginal"); + let rgb_only = ctx.params.b("rgbOnly"); + let mode = match m { + 2 => BlendMode::Add, + 3 => BlendMode::Multiply, + 4 => BlendMode::Screen, + 5 => BlendMode::Overlay, + 6 => BlendMode::SoftLight, + 7 => BlendMode::HardLight, + 8 => BlendMode::Darken, + 9 => BlendMode::Lighten, + 10 => BlendMode::Difference, + 11 => BlendMode::Hue, + 12 => BlendMode::Saturation, + 13 => BlendMode::Color, + 14 => BlendMode::Luminosity, + 15 => BlendMode::StencilAlpha, + _ => BlendMode::Normal, + }; + b.img.data.par_iter_mut().for_each(|px| { + let result = *px; + let orig = result.map(|v| v * op); + let mut o = if m == 1 { blend_pixel(BlendMode::Normal, orig, result, 0.5) } else { blend_pixel(mode, result, orig, 0.5) }; + o[3] = o[3].clamp(0.0, 1.0); + if rgb_only { + let (c, _) = unpremul(o); + o = premul(c, result[3]); + } + *px = o; + }); + b +} + +// ---------------------------------------------------------------- Compound Arithmetic + +const OPERATORS: [&str; 15] = + ["Copy", "Add", "Subtract", "Multiply", "Difference", "And", "Or", "Xor", "Lighten", "Darken", "Minimum", "Maximum", "Screen", "Overlay", "Hard Light"]; + +/// One channel operation on straight values in 0..1 (may overflow). +#[inline] +fn arith(op: u32, a: f32, b: f32) -> f32 { + let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u32; + match op { + 0 => b, + 1 => a + b, + 2 => a - b, + 3 => a * b, + 4 => (a - b).abs(), + 5 => (q(a) & q(b)) as f32 / 255.0, + 6 => (q(a) | q(b)) as f32 / 255.0, + 7 => (q(a) ^ q(b)) as f32 / 255.0, + 8 | 11 => a.max(b), + 9 | 10 => a.min(b), + 12 => a + b - a * b, + 13 => { + if a < 0.5 { + 2.0 * a * b + } else { + 1.0 - 2.0 * (1.0 - a) * (1.0 - b) + } + } + _ => { + if b < 0.5 { + 2.0 * a * b + } else { + 1.0 - 2.0 * (1.0 - a) * (1.0 - b) + } + } + } +} + +/// Overflow handling: 0 Clip, 1 Wrap, 2 Scale (map the operator's full range to 0..1). +#[inline] +fn overflow(op: u32, v: f32, how: u32) -> f32 { + match how { + 0 => v.clamp(0.0, 1.0), + 1 => v.rem_euclid(1.0001).min(1.0), + _ => match op { + 1 => v * 0.5, + 2 => (v + 1.0) * 0.5, + _ => v.clamp(0.0, 1.0), + }, + } +} + +fn compound_arithmetic(ctx: &EffectCtx, mut b: Buf) -> Buf { + let op = ctx.params.e("operator"); + let chans = ctx.params.e("operateOnChannels"); + let how = ctx.params.e("overflowBehavior"); + let orig = (ctx.params.f("blendWithOriginal") as f32 / 100.0).clamp(0.0, 1.0); + if orig >= 1.0 { + return b; + } + let other = layer_or_self(ctx, &b, "secondSource", true, ctx.params.b("stretchSecondSourceToFit")); + b.img.data.par_iter_mut().zip(other.data.par_iter()).for_each(|(px, &o)| { + let src = *px; + let (c, a) = unpremul(src); + let (oc, oa) = unpremul(o); + let mut rc = c; + let mut ra = a; + if chans != 2 { + for k in 0..3 { + rc[k] = overflow(op, arith(op, c[k], oc[k]), how); + } + } + if chans != 0 { + ra = overflow(op, arith(op, a, oa), how); + } + let res = premul(rc, ra); + *px = lerp4(res, src, orig); + }); + b +} pub fn specs() -> Vec { - Vec::new() + let calc_modes = calc_mode_names(); + vec![ + spec( + "ec.channel.blend", + "Blend", + vec![ + layer_param("blendWithLayer", "Blend With Layer"), + p("mode", "Mode", Value::Enum(0), popup(&["Crossfade", "Color Only", "Tint Only", "Darken Only", "Lighten Only"])), + p("blendWithOriginal", "Blend With Original", num(0.0), pct()), + p("ifLayerSizesDiffer", "If Layer Sizes Differ", Value::Enum(0), popup(&SIZE_DIFF)), + ], + blend, + ), + spec( + "ec.channel.calculations", + "Calculations", + vec![ + p("inputChannel", "Input Channel", Value::Enum(0), popup(&CHANNELS)), + p("invertInput", "Invert Input", Value::Bool(false), ParamUi::Checkbox), + layer_param("secondLayer", "Second Layer"), + p("secondLayerChannel", "Second Layer Channel", Value::Enum(0), popup(&CHANNELS)), + p("secondLayerOpacity", "Second Layer Opacity", num(100.0), pct()), + p("invertSecondLayer", "Invert Second Layer", Value::Bool(false), ParamUi::Checkbox), + p("stretchSecondLayerToFit", "Stretch Second Layer to Fit", Value::Bool(true), ParamUi::Checkbox), + p("blendingMode", "Blending Mode", Value::Enum(0), ParamUi::Popup { options: calc_modes.iter().map(|s| s.to_string()).collect() }), + p("preserveTransparency", "Preserve Transparency", Value::Bool(false), ParamUi::Checkbox), + ], + calculations, + ), + spec( + "ec.channel.cccomposite", + "CC Composite", + vec![ + p("opacity", "Opacity", num(100.0), pct()), + p("compositeOriginal", "Composite Original", Value::Enum(0), popup(&CC_COMPOSITE_MODES)), + p("rgbOnly", "RGB Only", Value::Bool(false), ParamUi::Checkbox), + ], + cc_composite, + ), + spec( + "ec.channel.compoundarithmetic", + "Compound Arithmetic", + vec![ + layer_param("secondSource", "Second Source Layer"), + p("operator", "Operator", Value::Enum(0), popup(&OPERATORS)), + p("operateOnChannels", "Operate On Channels", Value::Enum(0), popup(&["RGB", "ARGB", "Alpha"])), + p("overflowBehavior", "Overflow Behavior", Value::Enum(0), popup(&["Clip", "Wrap", "Scale"])), + p("stretchSecondSourceToFit", "Stretch Second Source to Fit", Value::Bool(true), ParamUi::Checkbox), + p("blendWithOriginal", "Blend With Original", num(0.0), pct()), + ], + compound_arithmetic, + ), + ] +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use crate::{EffectEnv, EffectHost, LayerPixels, run_fx}; + use effectcraft_raster::Image; + + /// A host that knows one other layer (id 7). + pub(crate) struct FakeHost(pub Image); + impl EffectHost for FakeHost { + fn layer(&self, id: u64, _: bool) -> Option { + (id == 7).then(|| LayerPixels { buf: Buf { img: self.0.clone(), offset: [0.0; 2], scale: 1.0 }, size: [self.0.width as f64, self.0.height as f64] }) + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + } + + pub(crate) fn sample() -> Image { + let mut img = Image::new(8, 6); + for y in 0..6 { + for x in 0..8 { + let a = if x < 6 { 1.0 } else { 0.5 }; + img.set(x, y, [x as f32 / 8.0 * a, y as f32 / 6.0 * a, 0.4 * a, a]); + } + } + img + } + + fn close(a: &Image, b: &Image, tol: f32) -> bool { + a.data.iter().zip(b.data.iter()).all(|(p, q)| p.iter().zip(q.iter()).all(|(x, y)| (x - y).abs() <= tol)) + } + + fn with_host(other: Image, f: impl FnOnce(EffectEnv) -> R) -> R { + let host = FakeHost(other); + f(EffectEnv { host: Some(&host), ..Default::default() }) + } + + #[test] + fn blend_self_crossfade_is_identity_and_layer_crossfades() { + let img = sample(); + let out = run_fx("ec.channel.blend", &[], img.clone(), 0.0, EffectEnv::default()); + assert!(close(&out.img, &img, 1e-6)); + let red = Image::filled(8, 6, [1.0, 0.0, 0.0, 1.0]); + let out = with_host(red, |env| { + run_fx("ec.channel.blend", &[("blendWithLayer", Value::Layer(Some(7))), ("blendWithOriginal", num(50.0))], img.clone(), 0.0, env) + }); + let p = out.img.get(0, 0); + assert!((p[0] - 0.5).abs() < 1e-5 && (p[3] - 1.0).abs() < 1e-5); + let again = with_host(Image::filled(8, 6, [1.0, 0.0, 0.0, 1.0]), |env| { + run_fx("ec.channel.blend", &[("blendWithLayer", Value::Layer(Some(7))), ("blendWithOriginal", num(50.0))], img.clone(), 0.0, env) + }); + assert_eq!(out.img.data, again.img.data); + } + + #[test] + fn calculations_defaults_identity_and_gray() { + let img = sample(); + let out = run_fx("ec.channel.calculations", &[], img.clone(), 0.0, EffectEnv::default()); + assert!(close(&out.img, &img, 1e-6)); + let g = run_fx("ec.channel.calculations", &[("inputChannel", Value::Enum(2))], img.clone(), 0.0, EffectEnv::default()); + let p = g.img.get(4, 2); + assert!((p[0] - 0.5).abs() < 1e-5 && p[0] == p[1] && p[1] == p[2]); + let m = with_host(Image::filled(8, 6, [0.5, 0.5, 0.5, 1.0]), |env| { + run_fx("ec.channel.calculations", &[("secondLayer", Value::Layer(Some(7))), ("blendingMode", Value::Enum(2))], img.clone(), 0.0, env) + }); + let q = m.img.get(4, 0); + assert!((q[0] - 0.25).abs() < 1e-4, "{q:?}"); + } + + #[test] + fn cc_composite_in_front_is_identity_on_opaque() { + let img = Image::filled(4, 4, [0.3, 0.6, 0.2, 1.0]); + let out = run_fx("ec.channel.cccomposite", &[], img.clone(), 0.0, EffectEnv::default()); + assert!(close(&out.img, &img, 1e-6)); + let add = run_fx("ec.channel.cccomposite", &[("compositeOriginal", Value::Enum(2))], img.clone(), 0.0, EffectEnv::default()); + assert!((add.img.get(0, 0)[0] - 0.6).abs() < 1e-5); + } + + #[test] + fn compound_arithmetic_copy_self_identity_and_add() { + let img = sample(); + let out = run_fx("ec.channel.compoundarithmetic", &[], img.clone(), 0.0, EffectEnv::default()); + assert!(close(&out.img, &img, 1e-5)); + let other = Image::filled(8, 6, [0.25, 0.25, 0.25, 1.0]); + let add = with_host(other, |env| { + run_fx("ec.channel.compoundarithmetic", &[("secondSource", Value::Layer(Some(7))), ("operator", Value::Enum(1))], img.clone(), 0.0, env) + }); + let p = add.img.get(4, 3); + assert!((p[0] - (0.5 + 0.25)).abs() < 1e-5 && (p[1] - 0.75).abs() < 1e-5, "{p:?}"); + let sc = with_host(Image::filled(8, 6, [1.0, 1.0, 1.0, 1.0]), |env| { + run_fx( + "ec.channel.compoundarithmetic", + &[("secondSource", Value::Layer(Some(7))), ("operator", Value::Enum(1)), ("overflowBehavior", Value::Enum(2))], + img.clone(), + 0.0, + env, + ) + }); + assert!((sc.img.get(4, 3)[0] - 0.75).abs() < 1e-5); + } } From 5ac5560df9903f1227844f5ee1faec5042d2ec09 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 21:55:04 -0400 Subject: [PATCH 07/14] M9.3: Keying effects (Difference Matte, Inner/Outer Key, Unmult, CC Simple Wire Removal) Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/keying2.rs | 418 +++++++++++++++++++++++++++++++++- 1 file changed, 415 insertions(+), 3 deletions(-) diff --git a/crates/effects/src/keying2.rs b/crates/effects/src/keying2.rs index 5b514cb9..caad75ec 100644 --- a/crates/effects/src/keying2.rs +++ b/crates/effects/src/keying2.rs @@ -1,7 +1,419 @@ -//! Keying effects, batch 2. +//! Keying effects, batch 2: Difference Matte (against another layer), Inner/Outer Key (mattes +//! from masks with colour-based edge estimation and decontamination), Unmult and CC Simple +//! Wire Removal. -use crate::EffectSpec; +use effectcraft_keyframe::Value; +use effectcraft_project::ParamUi; +use effectcraft_raster::gaussian_blur; +use rayon::prelude::*; + +use crate::util::{Plane, dist_to_poly, gauss_plane, layer_or_self, lerp4, morph_frac, point_in_poly, premul, smoothstep, unpremul}; +use crate::{Buf, EffectCtx, EffectSpec, num, p, popup, slider}; + +fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { + EffectSpec { id, name, category: "Keying", params, render, gpu: false, float: true } +} + +fn pct() -> ParamUi { + slider(0.0, 100.0, 0.0, 100.0, 1) +} + +// ---------------------------------------------------------------- Difference Matte + +fn difference_matte(ctx: &EffectCtx, mut b: Buf) -> Buf { + let view = ctx.params.e("view"); + if view == 1 { + return b; + } + let tol = (ctx.params.f("matchingTolerance") / 100.0) as f32; + let soft = (ctx.params.f("matchingSoftness") / 100.0) as f32; + let blur = ctx.params.f("blurBeforeDifference") * b.scale; + let other = layer_or_self(ctx, &b, "differenceLayer", true, ctx.params.e("ifLayerSizesDiffer") == 1); + let (src, other) = if blur > 0.05 { + (gaussian_blur(&b.img, blur * 0.5, blur * 0.5, true), gaussian_blur(&other, blur * 0.5, blur * 0.5, true)) + } else { + (b.img.clone(), other) + }; + let matte_only = view == 2; + b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { + let (c, a) = unpremul(src.data[i]); + let (oc, oa) = unpremul(other.data[i]); + let d = (c[0] - oc[0]).abs().max((c[1] - oc[1]).abs()).max((c[2] - oc[2]).abs()).max((a - oa).abs()); + let m = if d <= tol { + 0.0 + } else if soft <= 1e-6 { + 1.0 + } else { + ((d - tol) / soft).clamp(0.0, 1.0) + }; + if matte_only { + *px = [m * px[3], m * px[3], m * px[3], px[3]]; + } else { + *px = px.map(|v| v * m); + } + }); + b +} + +// ---------------------------------------------------------------- Inner/Outer Key + +fn mask_options() -> ParamUi { + popup(&["None", "Mask 1", "Mask 2", "Mask 3", "Mask 4", "Mask 5", "Mask 6", "Mask 7", "Mask 8", "Mask 9", "Mask 10"]) +} + +/// Coverage plane (1 inside) of the selected closed masks, in buffer pixels. +fn mask_plane(ctx: &EffectCtx, b: &Buf, idx: &[u32]) -> Option { + let shapes: Vec<&crate::MaskShape> = idx.iter().filter(|&&i| i > 0).filter_map(|&i| ctx.env.masks.get(i as usize - 1)).collect(); + if shapes.is_empty() { + return None; + } + let (w, h) = (b.img.width as usize, b.img.height as usize); + let mut pl = Plane::new(w, h); + let inv = 1.0 / b.scale.max(1e-9); + pl.data.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| { + for (x, v) in row.iter_mut().enumerate() { + let lx = (x as f64 + 0.5 - b.offset[0]) * inv; + let ly = (y as f64 + 0.5 - b.offset[1]) * inv; + *v = if shapes.iter().any(|s| point_in_poly(&s.points, lx, ly)) { 1.0 } else { 0.0 }; + } + }); + Some(pl) +} + +/// Signed distance (pixels, positive inside) to the selected mask outlines. +fn mask_sdf(ctx: &EffectCtx, b: &Buf, idx: &[u32]) -> Option { + let shapes: Vec<&crate::MaskShape> = idx.iter().filter(|&&i| i > 0).filter_map(|&i| ctx.env.masks.get(i as usize - 1)).collect(); + if shapes.is_empty() { + return None; + } + let (w, h) = (b.img.width as usize, b.img.height as usize); + let mut pl = Plane::new(w, h); + let inv = 1.0 / b.scale.max(1e-9); + pl.data.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| { + for (x, v) in row.iter_mut().enumerate() { + let lx = (x as f64 + 0.5 - b.offset[0]) * inv; + let ly = (y as f64 + 0.5 - b.offset[1]) * inv; + let inside = shapes.iter().any(|s| point_in_poly(&s.points, lx, ly)); + let d = shapes.iter().map(|s| dist_to_poly(&s.points, true, lx, ly)).fold(f64::INFINITY, f64::min) * b.scale; + *v = if inside { d as f32 } else { -d as f32 }; + } + }); + Some(pl) +} + +fn inner_outer_key(ctx: &EffectCtx, mut b: Buf) -> Buf { + let fg_idx = [ctx.params.e("foreground"), ctx.params.e("additionalForeground")]; + let bg_idx = [ctx.params.e("background"), ctx.params.e("additionalBackground")]; + let radius = (ctx.params.f("singleMaskHighlightRadius") * b.scale) as f32; + let thin = ctx.params.f("edgeThin") * b.scale; + let feather = ctx.params.f("edgeFeather") * b.scale; + let thr = (ctx.params.f("edgeThreshold") / 100.0) as f32; + let invert = ctx.params.b("invertExtraction"); + let orig = (ctx.params.f("blendWithOriginal") / 100.0) as f32; + let (w, h) = (b.img.width as usize, b.img.height as usize); + // Trimap: 1 = known foreground, 0 = known background, 0.5 = unknown. + let fg = mask_plane(ctx, &b, &fg_idx); + let bg = mask_plane(ctx, &b, &bg_idx); + let tri: Plane = match (fg, bg) { + (None, _) => return b, + (Some(f), Some(bk)) => f.zip_map(&bk, |f, bk| { + if f > 0.5 { + 1.0 + } else if bk > 0.5 { + 0.5 + } else { + 0.0 + } + }), + (Some(f), None) => { + if radius <= 0.0 { + f + } else { + let sdf = mask_sdf(ctx, &b, &fg_idx).unwrap_or(f); + sdf.map(|d| { + if d > radius { + 1.0 + } else if d < -radius { + 0.0 + } else { + 0.5 + } + }) + } + } + }; + let unknown = tri.data.par_iter().any(|&t| t > 0.25 && t < 0.75); + let mut alpha = tri.clone(); + let mut colours: Option> = None; + if unknown { + // Local foreground / background colour estimates: normalised blurs over known regions. + let sigma = (radius.max(4.0) as f64).max(w.min(h) as f64 * 0.02); + let known_f = tri.map(|t| if t > 0.75 { 1.0 } else { 0.0 }); + let known_b = tri.map(|t| if t < 0.25 { 1.0 } else { 0.0 }); + let straight: Vec<[f32; 3]> = b.img.data.par_iter().map(|&px| unpremul(px).0).collect(); + let est = |known: &Plane| -> Vec<[f32; 3]> { + let wsum = gauss_plane(known, sigma, sigma); + let chans: Vec = (0..3) + .map(|k| { + let pl = Plane { w, h, data: straight.par_iter().zip(known.data.par_iter()).map(|(c, &m)| c[k] * m).collect() }; + gauss_plane(&pl, sigma, sigma) + }) + .collect(); + (0..w * h).into_par_iter().map(|i| [0, 1, 2].map(|k| chans[k].data[i] / wsum.data[i].max(1e-6))).collect() + }; + let fc = est(&known_f); + let bc = est(&known_b); + alpha.data.par_iter_mut().enumerate().for_each(|(i, a)| { + if *a > 0.25 && *a < 0.75 { + let c = straight[i]; + let d = [fc[i][0] - bc[i][0], fc[i][1] - bc[i][1], fc[i][2] - bc[i][2]]; + let dd = d[0] * d[0] + d[1] * d[1] + d[2] * d[2]; + *a = if dd < 1e-6 { 0.5 } else { (((c[0] - bc[i][0]) * d[0] + (c[1] - bc[i][1]) * d[1] + (c[2] - bc[i][2]) * d[2]) / dd).clamp(0.0, 1.0) }; + } + }); + // Decontaminate edge colours: remove the background contribution. + colours = Some( + (0..w * h) + .into_par_iter() + .map(|i| { + let a = alpha.data[i]; + let c = straight[i]; + if tri.data[i] > 0.25 && tri.data[i] < 0.75 && a > 0.02 { + [0, 1, 2].map(|k| ((c[k] - (1.0 - a) * bc[i][k]) / a).clamp(0.0, 1.0)) + } else { + c + } + }) + .collect(), + ); + } + if thin.abs() > 0.01 { + alpha = morph_frac(&alpha, thin.abs(), thin < 0.0); + } + if feather > 0.05 { + alpha = gauss_plane(&alpha, feather * 0.5, feather * 0.5); + } + let src = b.img.clone(); + b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { + let mut m = alpha.data[i]; + if thr > 0.0 { + m = if m < thr { 0.0 } else { m }; + } + if invert { + m = 1.0 - m; + } + let (c0, a) = unpremul(src.data[i]); + let c = colours.as_ref().map(|v| v[i]).unwrap_or(c0); + let keyed = premul(c, a * m); + *px = lerp4(keyed, src.data[i], orig); + }); + b +} + +// ---------------------------------------------------------------- Unmult + +/// Turns black into transparency: alpha becomes the brightest straight channel and colour is +/// divided by it (premultiplied colour is unchanged). +fn unmult(_: &EffectCtx, mut b: Buf) -> Buf { + b.img.data.par_iter_mut().for_each(|px| { + let (c, a) = unpremul(*px); + let m = c[0].max(c[1]).max(c[2]).clamp(0.0, 1.0); + if m <= 1e-6 { + *px = [0.0; 4]; + return; + } + *px = premul([c[0] / m, c[1] / m, c[2] / m], a * m); + }); + b +} + +// ---------------------------------------------------------------- CC Simple Wire Removal + +fn wire_removal(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pa = ctx.params.v2("pointA"); + let pb = ctx.params.v2("pointB"); + let style = ctx.params.e("removalStyle"); + let half = (ctx.params.f("thickness") * b.scale * 0.5).max(0.0); + if half <= 0.0 { + return b; + } + let slope = (ctx.params.f("slope") / 100.0).clamp(0.0, 1.0) as f32; + let mirror = (ctx.params.f("mirrorBlend") / 100.0).clamp(0.0, 1.0) as f32; + let (ax, ay) = b.to_px(pa); + let (bx, by) = b.to_px(pb); + let (dx, dy) = (bx - ax, by - ay); + let len = (dx * dx + dy * dy).sqrt(); + if len < 1e-6 { + return b; + } + let (tx, ty) = (dx / len, dy / len); + let (nx, ny) = (-ty, tx); + let src = b.img.clone(); + let soft_w = half * slope as f64; + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let (qx, qy) = (x as f64 + 0.5 - ax, y as f64 + 0.5 - ay); + let d = qx * nx + qy * ny; + let ad = d.abs(); + if ad > half + soft_w { + continue; + } + // Positions just outside the wire on both sides. + let base = (x as f64 + 0.5 - d * nx, y as f64 + 0.5 - d * ny); + let edge = half + 1.0; + let pos = |s: f64| src.sample_bilinear_clamped(base.0 + nx * s, base.1 + ny * s); + let repl = match style { + 0 => { + let t = ((d + edge) / (2.0 * edge)) as f32; + lerp4(pos(-edge), pos(edge), t.clamp(0.0, 1.0)) + } + // Displace: shift the image across the wire from the nearer side. + 2 | 1 => { + let s = if d >= 0.0 { d + 2.0 * (half - ad) + 1.0 } else { d - 2.0 * (half - ad) - 1.0 }; + let sh = pos(s); + if mirror > 0.0 { lerp4(sh, pos(-s), mirror * 0.5) } else { sh } + } + _ => { + // Displace Horizontal: sample left/right of the wire along x. + let sx = if nx.abs() > 1e-6 { (half - ad + 1.0) / nx.abs() } else { 0.0 }; + let dir = if d * nx >= 0.0 { 1.0 } else { -1.0 }; + let sh = src.sample_bilinear_clamped(x as f64 + 0.5 + dir * sx, y as f64 + 0.5); + let mi = src.sample_bilinear_clamped(x as f64 + 0.5 - dir * sx, y as f64 + 0.5); + if mirror > 0.0 { lerp4(sh, mi, mirror * 0.5) } else { sh } + } + }; + let k = if ad <= half { 1.0 } else { 1.0 - smoothstep(half as f32, (half + soft_w) as f32, ad as f32) }; + *px = lerp4(*px, repl, k); + } + }); + b +} pub fn specs() -> Vec { - Vec::new() + vec![ + spec( + "ec.key.differencematte", + "Difference Matte", + vec![ + p("view", "View", Value::Enum(0), popup(&["Final Output", "Source Only", "Matte Only"])), + p("differenceLayer", "Difference Layer", Value::Layer(None), ParamUi::Layer), + p("ifLayerSizesDiffer", "If Layer Sizes Differ", Value::Enum(0), popup(&["Center", "Stretch to Fit"])), + p("matchingTolerance", "Matching Tolerance", num(15.0), pct()), + p("matchingSoftness", "Matching Softness", num(0.0), pct()), + p("blurBeforeDifference", "Blur Before Difference", num(0.0), slider(0.0, 1000.0, 0.0, 20.0, 1)), + ], + difference_matte, + ), + spec( + "ec.key.innerouter", + "Inner/Outer Key", + vec![ + p("foreground", "Foreground (Inside)", Value::Enum(0), mask_options()), + p("additionalForeground", "Additional Foreground", Value::Enum(0), mask_options()), + p("background", "Background (Outside)", Value::Enum(0), mask_options()), + p("additionalBackground", "Additional Background", Value::Enum(0), mask_options()), + p("singleMaskHighlightRadius", "Single Mask Highlight Radius", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("edgeThin", "Edge Thin", num(0.0), slider(-10.0, 10.0, -10.0, 10.0, 1)), + p("edgeFeather", "Edge Feather", num(0.0), slider(0.0, 100.0, 0.0, 50.0, 1)), + p("edgeThreshold", "Edge Threshold", num(0.0), pct()), + p("invertExtraction", "Invert Extraction", Value::Bool(false), ParamUi::Checkbox), + p("blendWithOriginal", "Blend with Original", num(0.0), pct()), + ], + inner_outer_key, + ), + spec("ec.key.unmult", "Unmult", vec![], unmult), + spec( + "ec.key.ccsimplewireremoval", + "CC Simple Wire Removal", + vec![ + p("pointA", "Point A", Value::Vec2([0.25, 0.25]), ParamUi::Point), + p("pointB", "Point B", Value::Vec2([0.75, 0.75]), ParamUi::Point), + p("removalStyle", "Removal Style", Value::Enum(2), popup(&["Fade", "Frame Offset", "Displace", "Displace Horizontal"])), + p("thickness", "Thickness", num(5.0), slider(0.0, 100.0, 0.0, 30.0, 1)), + p("slope", "Slope", num(0.0), pct()), + p("mirrorBlend", "Mirror Blend", num(0.0), pct()), + p("frameOffset", "Frame Offset", num(0.0), slider(-100.0, 100.0, -10.0, 10.0, 0)), + ], + wire_removal, + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::channel2::tests::{FakeHost, sample}; + use crate::{EffectEnv, MaskShape, run_fx}; + use effectcraft_raster::Image; + + #[test] + fn difference_matte_self_is_transparent_and_other_keeps() { + let img = sample(); + let out = run_fx("ec.key.differencematte", &[], img.clone(), 0.0, EffectEnv::default()); + assert!(out.img.data.iter().all(|p| p[3] == 0.0)); + let host = FakeHost(Image::filled(8, 6, [0.0, 0.0, 0.4, 1.0])); + let env = EffectEnv { host: Some(&host), ..Default::default() }; + let out = run_fx("ec.key.differencematte", &[("differenceLayer", Value::Layer(Some(7)))], img.clone(), 0.0, env); + assert_eq!(out.img.get(5, 5), img.get(5, 5)); + assert_eq!(out.img.get(0, 0)[3], 0.0, "matching pixel keyed out"); + let again = run_fx("ec.key.differencematte", &[("differenceLayer", Value::Layer(Some(7)))], img.clone(), 0.0, env); + assert_eq!(out.img.data, again.img.data); + } + + #[test] + fn inner_outer_key_inside_mask() { + let img = Image::filled(20, 20, [0.8, 0.2, 0.2, 1.0]); + let masks = vec![MaskShape { name: "Mask 1".into(), points: vec![[5.0, 5.0], [15.0, 5.0], [15.0, 15.0], [5.0, 15.0]], closed: true, inverted: false }]; + let env = EffectEnv { masks: &masks, ..Default::default() }; + let out = run_fx("ec.key.innerouter", &[("foreground", Value::Enum(1))], img.clone(), 0.0, env); + assert!((out.img.get(10, 10)[3] - 1.0).abs() < 1e-6); + assert_eq!(out.img.get(1, 1)[3], 0.0); + // Two masks: unknown band estimated from colour. + let mut img2 = Image::filled(20, 20, [0.0, 0.0, 1.0, 1.0]); + for y in 0..20 { + for x in 7..13 { + img2.set(x, y, [1.0, 0.0, 0.0, 1.0]); + } + } + let masks2 = vec![ + MaskShape { name: "in".into(), points: vec![[8.0, 0.0], [12.0, 0.0], [12.0, 20.0], [8.0, 20.0]], closed: true, inverted: false }, + MaskShape { name: "out".into(), points: vec![[4.0, 0.0], [16.0, 0.0], [16.0, 20.0], [4.0, 20.0]], closed: true, inverted: false }, + ]; + let env2 = EffectEnv { masks: &masks2, ..Default::default() }; + let o2 = run_fx("ec.key.innerouter", &[("foreground", Value::Enum(1)), ("background", Value::Enum(2))], img2.clone(), 0.0, env2); + assert!(o2.img.get(7, 10)[3] > 0.8, "{:?}", o2.img.get(7, 10)); + assert!(o2.img.get(5, 10)[3] < 0.2, "{:?}", o2.img.get(5, 10)); + assert_eq!(o2.img.get(1, 10)[3], 0.0); + let o3 = run_fx("ec.key.innerouter", &[("foreground", Value::Enum(1)), ("background", Value::Enum(2))], img2, 0.0, env2); + assert_eq!(o2.img.data, o3.img.data); + } + + #[test] + fn unmult_black_becomes_transparent() { + let mut img = Image::filled(4, 4, [0.0, 0.0, 0.0, 1.0]); + img.set(1, 1, [0.5, 0.25, 0.0, 1.0]); + let out = run_fx("ec.key.unmult", &[], img, 0.0, EffectEnv::default()); + assert_eq!(out.img.get(0, 0), [0.0; 4]); + let p = out.img.get(1, 1); + assert!((p[3] - 0.5).abs() < 1e-6 && (p[0] - 0.5).abs() < 1e-6); + } + + #[test] + fn wire_removal_hides_a_wire() { + let mut img = Image::filled(20, 20, [0.5, 0.5, 0.5, 1.0]); + for y in 0..20 { + img.set(10, y, [0.0, 0.0, 0.0, 1.0]); + } + for style in 0..4u32 { + let out = run_fx( + "ec.key.ccsimplewireremoval", + &[("pointA", Value::Vec2([10.5, 0.0])), ("pointB", Value::Vec2([10.5, 20.0])), ("removalStyle", Value::Enum(style)), ("thickness", num(3.0))], + img.clone(), + 0.0, + EffectEnv::default(), + ); + assert!((out.img.get(10, 10)[0] - 0.5).abs() < 0.05, "style {style}: {:?}", out.img.get(10, 10)); + assert_eq!(out.img.get(2, 10), img.get(2, 10)); + } + } } From b58167d0c7b415e42bff5d39174fec5ad93f0ed4 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 21:55:04 -0400 Subject: [PATCH 08/14] M9.3: Noise & Grain effects (Match Grain, Median (Legacy), Curl Noise) Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/noise2.rs | 368 ++++++++++++++++++++++++++++++++++- 1 file changed, 365 insertions(+), 3 deletions(-) diff --git a/crates/effects/src/noise2.rs b/crates/effects/src/noise2.rs index bb1953bc..c53ada32 100644 --- a/crates/effects/src/noise2.rs +++ b/crates/effects/src/noise2.rs @@ -1,7 +1,369 @@ -//! Noise & Grain effects, batch 2. +//! Noise & Grain effects, batch 2: Match Grain (grain statistics measured from another layer +//! and synthesised onto this one), Median (Legacy) and Curl Noise (divergence-free flow +//! distortion from the curl of a fractal noise potential). -use crate::EffectSpec; +use effectcraft_color::luminance; +use effectcraft_keyframe::Value; +use effectcraft_project::ParamUi; +use effectcraft_raster::Image; +use rayon::prelude::*; + +use crate::generate::value_noise; +use crate::noise::{fbm, median_image}; +use crate::util::{Plane, gauss_plane, layer_or_self, premul, smoothstep, unpremul}; +use crate::{Buf, EffectCtx, EffectSpec, num, p, popup, slider}; + +fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { + EffectSpec { id, name, category: "Noise & Grain", params, render, gpu: false, float: true } +} + +fn pct() -> ParamUi { + slider(0.0, 100.0, 0.0, 100.0, 1) +} + +// ---------------------------------------------------------------- Match Grain + +/// Grain statistics of an image: per-channel standard deviation of the high-pass residual +/// (over opaque pixels) and a grain size from the lag-1 autocorrelation. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub(crate) struct GrainStats { + pub std: [f32; 3], + pub size: f32, +} + +pub(crate) fn grain_stats(img: &Image) -> GrainStats { + let (w, h) = (img.width as usize, img.height as usize); + if w < 3 || h < 3 { + return GrainStats::default(); + } + let straight: Vec<([f32; 3], f32)> = img.data.par_iter().map(|&p| unpremul(p)).collect(); + let mut std = [0.0f32; 3]; + let mut rho_num = 0.0f64; + let mut rho_den = 0.0f64; + for (k, s) in std.iter_mut().enumerate() { + let pl = Plane { w, h, data: straight.iter().map(|(c, _)| c[k]).collect() }; + let low = gauss_plane(&pl, 1.5, 1.5); + let hp: Vec = pl.data.iter().zip(low.data.iter()).map(|(a, b)| a - b).collect(); + let (mut sum, mut sq, mut n) = (0.0f64, 0.0f64, 0.0f64); + for (i, v) in hp.iter().enumerate() { + if straight[i].1 > 0.5 { + sum += *v as f64; + sq += (*v as f64) * (*v as f64); + n += 1.0; + } + } + if n > 1.0 { + let mean = sum / n; + *s = ((sq / n - mean * mean).max(0.0)).sqrt() as f32; + } + for y in 0..h { + for x in 0..w - 1 { + let i = y * w + x; + if straight[i].1 > 0.5 && straight[i + 1].1 > 0.5 { + rho_num += (hp[i] * hp[i + 1]) as f64; + rho_den += (hp[i] * hp[i]) as f64; + } + } + } + } + let rho = if rho_den > 1e-12 { (rho_num / rho_den).clamp(0.0, 0.95) } else { 0.0 }; + GrainStats { std, size: (1.0 / (1.0 - rho as f32)).clamp(0.5, 20.0) * 0.6 } +} + +fn match_grain(ctx: &EffectCtx, mut b: Buf) -> Buf { + if ctx.params.get("noiseSourceLayer").and_then(Value::as_layer).is_none() { + return b; + } + let view = ctx.params.e("viewingMode"); + let source = layer_or_self(ctx, &b, "noiseSourceLayer", false, false); + let src_stats = grain_stats(&source); + let comp = (ctx.params.f("compensateForExistingNoise") / 100.0) as f32; + let own = if comp > 0.0 { grain_stats(&b.img) } else { GrainStats::default() }; + let intensity = ctx.params.f("intensity") as f32; + let chan = [ctx.params.f("redIntensity") as f32, ctx.params.f("greenIntensity") as f32, ctx.params.f("blueIntensity") as f32]; + let amp: [f32; 3] = [0, 1, 2].map(|k| { + let s = src_stats.std[k]; + let e = own.std[k] * comp; + (s * s - e * e).max(0.0).sqrt() * intensity * chan[k] + }); + let size = (src_stats.size.max(0.5) * ctx.params.f("size") as f32 * b.scale as f32).max(0.05); + let aspect = ctx.params.f("aspectRatio").max(0.05) as f32; + let soft = ctx.params.f("softness") * b.scale; + let mono = ctx.params.b("monochromatic"); + let sat = ctx.params.f("saturation") as f32; + let blend_mode = ctx.params.e("blendingMode"); + let (ws, wm, wh) = (ctx.params.f("shadows") as f32, ctx.params.f("midtones") as f32, ctx.params.f("highlights") as f32); + let seed = (ctx.params.f("randomSeed") as i64 as u32) ^ ctx.seed.wrapping_mul(0x9e37); + let ft = ctx.time * 24.0 * ctx.params.f("animationSpeed"); + let frame = if ctx.params.b("animateSmoothly") { ft as f32 } else { ft.floor() as f32 }; + let (w, h) = (b.img.width as usize, b.img.height as usize); + let nch = if mono { 1 } else { 3 }; + let mut planes: Vec = (0..nch) + .map(|k| { + let mut pl = Plane::new(w, h); + pl.data.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| { + for (x, v) in row.iter_mut().enumerate() { + *v = value_noise(x as f32 / size, y as f32 / (size * aspect), frame, seed.wrapping_add(k as u32 * 131)) - 0.5; + } + }); + pl + }) + .collect(); + if soft > 0.05 { + planes = planes.iter().map(|pl| gauss_plane(pl, soft, soft * aspect as f64)).collect(); + } + // Normalise each synthetic plane to unit standard deviation so the measured stats transfer. + for pl in planes.iter_mut() { + let n = pl.data.len().max(1) as f64; + let mean = pl.data.iter().map(|&v| v as f64).sum::() / n; + let var = pl.data.iter().map(|&v| (v as f64 - mean).powi(2)).sum::() / n; + let k = if var > 1e-12 { 1.0 / var.sqrt() } else { 0.0 }; + *pl = pl.map(|v| ((v as f64 - mean) * k) as f32); + } + b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { + let (c, a) = unpremul(*px); + let mut g = if mono { [planes[0].data[i]; 3] } else { [planes[0].data[i], planes[1].data[i], planes[2].data[i]] }; + let gm = (g[0] + g[1] + g[2]) / 3.0; + g = g.map(|v| gm + (v - gm) * sat); + let l = luminance(c[0], c[1], c[2]).clamp(0.0, 1.0); + let s = 1.0 - smoothstep(0.0, 0.5, l); + let hi = smoothstep(0.5, 1.0, l); + let weight = ws * s + wm * (1.0 - s - hi) + wh * hi; + match view { + 1 => { + let o = [0, 1, 2].map(|k| (0.5 + g[k] * amp[k]).clamp(0.0, 1.0)); + *px = [o[0], o[1], o[2], 1.0]; + } + 2 => *px = [weight.clamp(0.0, 1.0), weight.clamp(0.0, 1.0), weight.clamp(0.0, 1.0), 1.0], + _ => { + if a <= 0.0 { + return; + } + let o = [0, 1, 2].map(|k| { + let n = g[k] * amp[k] * weight; + let v = match blend_mode { + 1 => c[k] * (1.0 + n * 2.0), + 2 => 1.0 - (1.0 - c[k]) * (1.0 - n * 2.0), + _ => c[k] + n, + }; + v.max(0.0) + }); + *px = premul(o, a); + } + } + }); + b +} + +// ---------------------------------------------------------------- Median (Legacy) + +fn median_legacy(ctx: &EffectCtx, mut b: Buf) -> Buf { + let r = (ctx.params.f("radius") * b.scale).round().max(0.0) as usize; + if r == 0 { + return b; + } + if ctx.params.b("operateOnAlphaChannel") { + b.img = median_image(&b.img, r); + return b; + } + // Colour only: median of straight colour, alpha kept. + let mut straight = b.img.clone(); + straight.data.par_iter_mut().for_each(|px| { + let (c, _) = unpremul(*px); + *px = [c[0], c[1], c[2], 1.0]; + }); + let m = median_image(&straight, r); + b.img.data.par_iter_mut().zip(m.data.par_iter()).for_each(|(px, q)| { + *px = premul([q[0], q[1], q[2]], px[3]); + }); + b +} + +// ---------------------------------------------------------------- Curl Noise + +fn curl_noise(ctx: &EffectCtx, mut b: Buf) -> Buf { + let amount = ctx.params.f("displacementAmount") * b.scale; + let view = ctx.params.e("view"); + if amount.abs() < 1e-6 && view == 0 { + return b; + } + let scale = (ctx.params.f("scale").max(1.0) * b.scale) as f32; + let complexity = ctx.params.f("complexity") as f32; + let rot = ctx.params.f("rotation").to_radians() as f32; + let off = ctx.params.v2("offset"); + let (ox, oy) = ((off[0] * b.scale + b.offset[0]) as f32, (off[1] * b.scale + b.offset[1]) as f32); + let evo = (ctx.params.f("evolution") / 360.0) as f32; + let seed = ctx.params.f("randomSeed") as i64 as u32 ^ 0x51_7cc1; + let steps = ctx.params.f("steps").clamp(1.0, 16.0) as usize; + let edge_wrap = ctx.params.e("edgeBehavior") == 1; + let (w, h) = (b.img.width as usize, b.img.height as usize); + let (cr, sr) = (rot.cos(), rot.sin()); + // Potential field on the pixel grid. + let mut pot = Plane::new(w, h); + pot.data.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| { + for (x, v) in row.iter_mut().enumerate() { + let (dx, dy) = (x as f32 + 0.5 - ox, y as f32 + 0.5 - oy); + let u = (dx * cr + dy * sr) / scale; + let vv = (-dx * sr + dy * cr) / scale; + *v = fbm(u, vv, evo * 4.0, seed, complexity); + } + }); + // Curl: v = (dψ/dy, -dψ/dx), normalised so the typical speed is ~1 px per unit amount. + let mut vx = Plane::new(w, h); + let mut vy = Plane::new(w, h); + vx.data.par_chunks_mut(w.max(1)).zip(vy.data.par_chunks_mut(w.max(1))).enumerate().for_each(|(y, (rx, ry))| { + for x in 0..w { + let (xi, yi) = (x as i64, y as i64); + let gx = (pot.get_clamped(xi + 1, yi) - pot.get_clamped(xi - 1, yi)) * 0.5; + let gy = (pot.get_clamped(xi, yi + 1) - pot.get_clamped(xi, yi - 1)) * 0.5; + rx[x] = gy * scale; + ry[x] = -gx * scale; + } + }); + if view == 1 { + b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { + let (a, c) = (vx.data[i], vy.data[i]); + *px = [(0.5 + a * 0.5).clamp(0.0, 1.0), (0.5 + c * 0.5).clamp(0.0, 1.0), 0.5, 1.0]; + }); + return b; + } + let src = b.img.clone(); + let dt = amount as f32 / steps as f32; + let (wf, hf) = (w as f64, h as f64); + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let (mut sx, mut sy) = (x as f64 + 0.5, y as f64 + 0.5); + for _ in 0..steps { + // Trace the streamline backwards (semi-Lagrangian advection). + let (u, v) = (vx.sample(sx, sy), vy.sample(sx, sy)); + sx -= (u * dt) as f64; + sy -= (v * dt) as f64; + } + *px = if edge_wrap { src.sample_bilinear_clamped(sx.rem_euclid(wf), sy.rem_euclid(hf)) } else { src.sample_bilinear_clamped(sx, sy) }; + } + }); + b +} pub fn specs() -> Vec { - Vec::new() + let mult = || slider(0.0, 10.0, 0.0, 2.0, 2); + vec![ + spec( + "ec.noise.matchgrain", + "Match Grain", + vec![ + p("viewingMode", "Viewing Mode", Value::Enum(0), popup(&["Final Output", "Noise Samples", "Blending Matte"])), + p("noiseSourceLayer", "Noise Source Layer", Value::Layer(None), ParamUi::Layer), + p("compensateForExistingNoise", "Compensate for Existing Noise", num(0.0), pct()), + p("intensity", "Intensity", num(1.0), slider(0.0, 100.0, 0.0, 10.0, 2)), + p("size", "Size", num(1.0), slider(0.05, 100.0, 0.1, 10.0, 2)), + p("softness", "Softness", num(0.0), slider(0.0, 100.0, 0.0, 5.0, 2)), + p("aspectRatio", "Aspect Ratio", num(1.0), slider(0.05, 20.0, 0.25, 4.0, 2)), + p("redIntensity", "Red Intensity", num(1.0), mult()), + p("greenIntensity", "Green Intensity", num(1.0), mult()), + p("blueIntensity", "Blue Intensity", num(1.0), mult()), + p("monochromatic", "Monochromatic", Value::Bool(false), ParamUi::Checkbox), + p("saturation", "Saturation", num(1.0), mult()), + p("blendingMode", "Blending Mode", Value::Enum(0), popup(&["Normal", "Multiply", "Screen"])), + p("shadows", "Shadows", num(1.0), mult()), + p("midtones", "Midtones", num(1.0), mult()), + p("highlights", "Highlights", num(1.0), mult()), + p("animationSpeed", "Animation Speed", num(1.0), slider(0.0, 100.0, 0.0, 5.0, 2)), + p("animateSmoothly", "Animate Smoothly", Value::Bool(true), ParamUi::Checkbox), + p("randomSeed", "Random Seed", num(0.0), slider(0.0, 100000.0, 0.0, 1000.0, 0)), + ], + match_grain, + ), + spec( + "ec.noise.medianlegacy", + "Median (Legacy)", + vec![ + p("radius", "Radius", num(1.0), slider(0.0, 255.0, 0.0, 20.0, 0)), + p("operateOnAlphaChannel", "Operate On Alpha Channel", Value::Bool(false), ParamUi::Checkbox), + ], + median_legacy, + ), + spec( + "ec.noise.curlnoise", + "Curl Noise", + vec![ + p("view", "View", Value::Enum(0), popup(&["Final Output", "Flow Field"])), + p("displacementAmount", "Displacement Amount", num(20.0), slider(-1000.0, 1000.0, -100.0, 100.0, 1)), + p("scale", "Scale", num(100.0), slider(1.0, 10000.0, 10.0, 600.0, 1)), + p("rotation", "Rotation", num(0.0), ParamUi::Angle), + p("offset", "Offset Turbulence", Value::Vec2([0.5, 0.5]), ParamUi::Point), + p("complexity", "Complexity", num(3.0), slider(1.0, 20.0, 1.0, 10.0, 1)), + p("evolution", "Evolution", num(0.0), ParamUi::Angle), + p("steps", "Flow Steps", num(4.0), slider(1.0, 16.0, 1.0, 16.0, 0)), + p("edgeBehavior", "Edge Behavior", Value::Enum(0), popup(&["Clamp", "Wrap"])), + p("randomSeed", "Random Seed", num(0.0), slider(0.0, 100000.0, 0.0, 1000.0, 0)), + ], + curl_noise, + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::channel2::tests::FakeHost; + use crate::{EffectEnv, run_fx}; + + fn noisy(w: u32, h: u32, amp: f32, seed: u32) -> Image { + let mut img = Image::new(w, h); + for y in 0..h { + for x in 0..w { + let n = (crate::util::hash1(x, y, seed) - 0.5) * 2.0 * amp; + img.set(x, y, [0.5 + n, 0.5 + n, 0.5 + n, 1.0]); + } + } + img + } + + #[test] + fn match_grain_transfers_grain_strength() { + let flat = Image::filled(48, 48, [0.5, 0.5, 0.5, 1.0]); + let none = run_fx("ec.noise.matchgrain", &[], flat.clone(), 0.0, EffectEnv::default()); + assert_eq!(none.img.data, flat.data); + let host = FakeHost(noisy(48, 48, 0.1, 3)); + let env = EffectEnv { host: Some(&host), ..Default::default() }; + let out = run_fx("ec.noise.matchgrain", &[("noiseSourceLayer", Value::Layer(Some(7)))], flat.clone(), 0.5, env); + let target = grain_stats(&host.0).std[0]; + let got = grain_stats(&out.img).std[0]; + assert!(got > target * 0.4 && got < target * 2.5, "target {target} got {got}"); + let again = run_fx("ec.noise.matchgrain", &[("noiseSourceLayer", Value::Layer(Some(7)))], flat, 0.5, env); + assert_eq!(out.img.data, again.img.data); + } + + #[test] + fn median_legacy_radius_zero_identity_and_removes_speck() { + let mut img = Image::filled(9, 9, [0.2, 0.2, 0.2, 1.0]); + img.set(4, 4, [1.0, 1.0, 1.0, 1.0]); + let id = run_fx("ec.noise.medianlegacy", &[("radius", num(0.0))], img.clone(), 0.0, EffectEnv::default()); + assert_eq!(id.img.data, img.data); + let out = run_fx("ec.noise.medianlegacy", &[("radius", num(1.0))], img.clone(), 0.0, EffectEnv::default()); + assert!((out.img.get(4, 4)[0] - 0.2).abs() < 0.01); + let again = run_fx("ec.noise.medianlegacy", &[("radius", num(1.0))], img, 0.0, EffectEnv::default()); + assert_eq!(out.img.data, again.img.data); + } + + #[test] + fn curl_noise_zero_is_identity_and_flow_preserves_flat() { + let img = crate::channel2::tests::sample(); + let id = run_fx("ec.noise.curlnoise", &[("displacementAmount", num(0.0))], img.clone(), 0.0, EffectEnv::default()); + assert_eq!(id.img.data, img.data); + let flat = Image::filled(16, 16, [0.3, 0.6, 0.9, 1.0]); + let out = run_fx("ec.noise.curlnoise", &[("displacementAmount", num(40.0)), ("scale", num(10.0))], flat.clone(), 0.0, EffectEnv::default()); + assert!(out.img.data.iter().all(|p| (p[1] - 0.6).abs() < 1e-5)); + let mut grad = Image::new(32, 32); + for y in 0..32 { + for x in 0..32 { + grad.set(x, y, [x as f32 / 32.0, y as f32 / 32.0, 0.0, 1.0]); + } + } + let a = run_fx("ec.noise.curlnoise", &[("displacementAmount", num(40.0)), ("scale", num(10.0))], grad.clone(), 0.0, EffectEnv::default()); + let b = run_fx("ec.noise.curlnoise", &[("displacementAmount", num(40.0)), ("scale", num(10.0))], grad.clone(), 0.0, EffectEnv::default()); + assert_eq!(a.img.data, b.img.data); + assert!(a.img.data.iter().zip(grad.data.iter()).any(|(p, q)| (p[0] - q[0]).abs() > 0.01)); + } } From 2c4346a15e4e1ef81db0c896f04a826af367f2ab Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 21:59:05 -0400 Subject: [PATCH 09/14] M9.3: Color Correction effects (Change Color, Video Limiter, CC Color Neutralizer, PS Arbitrary Map, Lumetri Color) Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/color3.rs | 574 ++++++++++++++++++++++++++++++++++- 1 file changed, 571 insertions(+), 3 deletions(-) diff --git a/crates/effects/src/color3.rs b/crates/effects/src/color3.rs index cc39ceb9..76840774 100644 --- a/crates/effects/src/color3.rs +++ b/crates/effects/src/color3.rs @@ -1,7 +1,575 @@ -//! Color Correction effects, batch 3. +//! Color Correction effects, batch 3: Change Color, Video Limiter, CC Color Neutralizer, +//! PS Arbitrary Map and Lumetri Color (Basic Correction, Creative, Curves, Color Wheels, +//! Vignette). -use crate::EffectSpec; +use effectcraft_color::{hsl_to_rgb, luminance, rgb_to_hsl}; +use effectcraft_keyframe::Value; +use effectcraft_project::ParamUi; +use rayon::prelude::*; + +use crate::util::{Plane, gauss_plane, layer_rect, lerp, premul, smoothstep, unpremul}; +use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; + +fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { + EffectSpec { id, name, category: "Color Correction", params, render, gpu: false, float: true } +} + +fn pct() -> ParamUi { + slider(0.0, 100.0, 0.0, 100.0, 1) +} + +fn bipolar() -> ParamUi { + slider(-100.0, 100.0, -100.0, 100.0, 1) +} + +/// Apply `f(straight colour) -> straight colour` to every pixel, keeping alpha. +fn map_colour(b: &mut Buf, f: impl Fn(usize, [f32; 3]) -> [f32; 3] + Sync) { + b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { + let (c, a) = unpremul(*px); + if a <= 0.0 { + return; + } + *px = premul(f(i, c), a); + }); +} + +// ---------------------------------------------------------------- Change Color + +/// Similarity mask (1 = matches) of colour `c` to `key`. +#[inline] +fn colour_match(c: [f32; 3], key: [f32; 3], using: u32, tol: f32, soft: f32) -> f32 { + let d = match using { + 0 => ((c[0] - key[0]).powi(2) + (c[1] - key[1]).powi(2) + (c[2] - key[2]).powi(2)).sqrt() / 3f32.sqrt(), + 1 => { + let (h1, s1, _) = rgb_to_hsl(c[0], c[1], c[2]); + let (h2, _, _) = rgb_to_hsl(key[0], key[1], key[2]); + let dh = (h1 - h2).abs(); + let dh = dh.min(1.0 - dh) * 2.0; + // Greys have no reliable hue. + if s1 < 0.02 { 1.0 } else { dh } + } + _ => { + let chroma = |c: [f32; 3]| { + let s = (c[0] + c[1] + c[2]).max(1e-6); + [c[0] / s, c[1] / s] + }; + let (a, k) = (chroma(c), chroma(key)); + ((a[0] - k[0]).powi(2) + (a[1] - k[1]).powi(2)).sqrt() * 1.5 + } + }; + if d <= tol { + 1.0 + } else if soft <= 1e-6 { + 0.0 + } else { + (1.0 - (d - tol) / soft).clamp(0.0, 1.0) + } +} + +fn change_color(ctx: &EffectCtx, mut b: Buf) -> Buf { + let view_mask = ctx.params.e("view") == 1; + let dh = (ctx.params.f("hueTransform") / 360.0) as f32; + let dl = (ctx.params.f("lightnessTransform") / 100.0) as f32; + let ds = (ctx.params.f("saturationTransform") / 100.0) as f32; + let k = ctx.params.color("colorToChange"); + let key = [k[0], k[1], k[2]]; + let tol = (ctx.params.f("matchingTolerance") / 100.0) as f32; + let soft = (ctx.params.f("matchingSoftness") / 100.0) as f32; + let using = ctx.params.e("matchColors"); + let invert = ctx.params.b("invertColorCorrectionMask"); + if !view_mask && dh == 0.0 && dl == 0.0 && ds == 0.0 { + return b; + } + map_colour(&mut b, |_, c| { + let mut m = colour_match(c, key, using, tol, soft); + if invert { + m = 1.0 - m; + } + if view_mask { + return [m; 3]; + } + if m <= 0.0 { + return c; + } + let (h, s, l) = rgb_to_hsl(c[0], c[1], c[2]); + let h2 = (h + dh).rem_euclid(1.0); + let s2 = (s + ds * if ds > 0.0 { 1.0 - s } else { s }).clamp(0.0, 1.0); + let l2 = (l + dl * if dl > 0.0 { 1.0 - l } else { l }).clamp(0.0, 1.0); + let (r, g, bb) = hsl_to_rgb(h2, s2, l2); + [lerp(c[0], r, m), lerp(c[1], g, m), lerp(c[2], bb, m)] + }); + b +} + +// ---------------------------------------------------------------- Video Limiter + +const CLIP_LEVELS: [f32; 5] = [0.9, 0.95, 1.0, 1.05, 1.09]; + +/// Soft knee: values above `start` approach `limit` smoothly. +#[inline] +fn knee(v: f32, start: f32, limit: f32) -> f32 { + if v <= start || limit <= start { + return v.min(limit); + } + let r = limit - start; + start + r * (1.0 - (-(v - start) / r).exp()) +} + +fn video_limiter(ctx: &EffectCtx, mut b: Buf) -> Buf { + let level = CLIP_LEVELS[(ctx.params.e("clipLevel") as usize).min(CLIP_LEVELS.len() - 1)]; + let method = ctx.params.e("clipMethod"); + let comp = [0.0, 0.03, 0.05, 0.1, 0.2][(ctx.params.e("compressionBeforeClipping") as usize).min(4)]; + let warn = ctx.params.b("gamutWarning"); + let wc = ctx.params.color("gamutWarningColor"); + let start = level * (1.0 - comp); + map_colour(&mut b, |_, c| { + let y = luminance(c[0], c[1], c[2]); + let mut o = c; + // Luma: compress luminance, keep chroma offsets. + if method != 1 { + let y2 = knee(y.max(0.0), start, level); + o = [o[0] - y + y2, o[1] - y + y2, o[2] - y + y2]; + } + // Chroma: scale chroma towards luma until every channel is within 0..level. + if method != 0 { + let yl = luminance(o[0], o[1], o[2]); + let mut t = 1.0f32; + for &v in &o { + if v > level && v - yl > 1e-6 { + t = t.min((level - yl) / (v - yl)); + } + if v < 0.0 && yl - v > 1e-6 { + t = t.min(yl / (yl - v)); + } + } + let t = t.clamp(0.0, 1.0); + o = [yl + (o[0] - yl) * t, yl + (o[1] - yl) * t, yl + (o[2] - yl) * t]; + } + let o = o.map(|v| v.clamp(0.0, level)); + if warn && (c.iter().zip(o.iter()).any(|(a, b)| (a - b).abs() > 1e-4)) { + return [wc[0], wc[1], wc[2]]; + } + o + }); + b +} + +// ---------------------------------------------------------------- CC Color Neutralizer + +fn color_neutralizer(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pairs = [("shadowsUnbalance", "shadowsBalance"), ("midtonesUnbalance", "midtonesBalance"), ("highlightsUnbalance", "highlightsBalance")]; + let off: Vec<[f32; 3]> = pairs + .iter() + .map(|(u, bal)| { + let (u, v) = (ctx.params.color(u), ctx.params.color(bal)); + [v[0] - u[0], v[1] - u[1], v[2] - u[2]] + }) + .collect(); + let pin = (ctx.params.f("pinning") / 100.0) as f32; + let orig = (ctx.params.f("blendWOriginal") / 100.0) as f32; + let contrast = (ctx.params.f("contrast") / 100.0) as f32; + let darks = (ctx.params.f("darks") / 100.0) as f32; + let brights = (ctx.params.f("brights") / 100.0) as f32; + map_colour(&mut b, |_, c| { + let l = luminance(c[0], c[1], c[2]).clamp(0.0, 1.0); + let ws = 1.0 - smoothstep(0.0, 0.5, l); + let wh = smoothstep(0.5, 1.0, l); + let wm = 1.0 - ws - wh; + // Pinning keeps pure black and pure white fixed. + let keep = 1.0 - pin * (1.0 - 4.0 * l * (1.0 - l)).clamp(0.0, 1.0); + let mut o = [0.0; 3]; + for k in 0..3 { + let d = off[0][k] * ws + off[1][k] * wm + off[2][k] * wh; + let mut v = c[k] + d * keep; + v = 0.5 + (v - 0.5) * (1.0 + contrast); + v += darks * 0.25 * (1.0 - l).powi(2) + brights * 0.25 * l * l; + o[k] = v.max(0.0); + } + [lerp(o[0], c[0], orig), lerp(o[1], c[1], orig), lerp(o[2], c[2], orig)] + }); + b +} + +// ---------------------------------------------------------------- PS Arbitrary Map + +/// Parse a map string of 256 comma/space separated values (0..255, or 0..1 floats). An empty or +/// malformed map is the identity. +fn parse_map(s: &str) -> Vec { + let vals: Vec = s.split(|ch: char| ch == ',' || ch.is_whitespace()).filter(|t| !t.is_empty()).filter_map(|t| t.parse::().ok()).collect(); + if vals.len() < 2 { + return (0..256).map(|i| i as f32 / 255.0).collect(); + } + let max = vals.iter().cloned().fold(0.0f32, f32::max); + let k = if max > 1.0 { 1.0 / 255.0 } else { 1.0 }; + vals.iter().map(|v| (v * k).clamp(0.0, 1.0)).collect() +} + +fn arbitrary_map(ctx: &EffectCtx, mut b: Buf) -> Buf { + let map = parse_map(ctx.params.s("map")); + let phase = ctx.params.f("phase"); + let alpha = ctx.params.b("applyPhaseMapToAlpha"); + let n = map.len(); + let lookup = |v: f32| -> f32 { + let x = (v.clamp(0.0, 1.0) as f64 * (n - 1) as f64 + phase * (n - 1) as f64 / 255.0).rem_euclid(n as f64); + let i0 = x.floor() as usize % n; + let i1 = if i0 + 1 < n { i0 + 1 } else { i0 }; + let t = (x - x.floor()) as f32; + lerp(map[i0], map[i1], t) + }; + b.img.data.par_iter_mut().for_each(|px| { + let (c, a) = unpremul(*px); + let o = c.map(lookup); + let na = if alpha { lookup(a) } else { a }; + *px = premul(o, na); + }); + b +} + +// ---------------------------------------------------------------- Lumetri Color + +/// Smooth tone-curve adjustment: offsets for shadows/midtones/highlights (each -1..1), with the +/// end points fixed. +#[inline] +fn tone_curve(x: f32, s: f32, m: f32, h: f32) -> f32 { + if s == 0.0 && m == 0.0 && h == 0.0 { + return x; + } + let t = x.clamp(0.0, 1.0); + let bump = |c: f32| { + let d = (t - c) / 0.25; + (1.0 - d * d).max(0.0).powi(2) + }; + x + 0.25 * (s * bump(0.25) + m * bump(0.5) + h * bump(0.75)) +} + +/// The colour part of a wheel/tint colour (offset from its own grey), 0 for neutral colours. +#[inline] +fn tint_offset(c: [f32; 4]) -> [f32; 3] { + let l = (c[0] + c[1] + c[2]) / 3.0; + [c[0] - l, c[1] - l, c[2] - l] +} + +fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let temp = (pr.f("temperature") / 100.0) as f32; + let tint = (pr.f("tint") / 100.0) as f32; + let expo = 2f32.powf(pr.f("exposure") as f32); + let contrast = (pr.f("contrast") / 100.0) as f32; + let (hi, sh, wh, bl) = + ((pr.f("highlights") / 100.0) as f32, (pr.f("shadows") / 100.0) as f32, (pr.f("whites") / 100.0) as f32, (pr.f("blacks") / 100.0) as f32); + let sat = (pr.f("saturation") / 100.0) as f32; + let intensity = (pr.f("lookIntensity") / 100.0) as f32; + let faded = (pr.f("fadedFilm") / 100.0) as f32; + let vib = (pr.f("vibrance") / 100.0) as f32; + let csat = (pr.f("creativeSaturation") / 100.0) as f32; + let st = tint_offset(pr.color("shadowTint")); + let ht = tint_offset(pr.color("highlightTint")); + let tbal = (pr.f("tintBalance") / 100.0) as f32; + let curve = |k: &str| { + [(pr.f(&format!("{k}Shadows")) / 100.0) as f32, (pr.f(&format!("{k}Midtones")) / 100.0) as f32, (pr.f(&format!("{k}Highlights")) / 100.0) as f32] + }; + let (cm, cr, cg, cb) = (curve("curveMaster"), curve("curveRed"), curve("curveGreen"), curve("curveBlue")); + let ws = tint_offset(pr.color("shadowsWheel")); + let wm = tint_offset(pr.color("midtonesWheel")); + let wh_ = tint_offset(pr.color("highlightsWheel")); + let vig = pr.f("vignetteAmount") as f32; + let vmid = (pr.f("vignetteMidpoint") / 100.0) as f32; + let vround = (pr.f("vignetteRoundness") / 100.0) as f32; + let vfeather = (pr.f("vignetteFeather") / 100.0) as f32; + let sharpen = (pr.f("sharpen") / 100.0) as f32; + let (lx, ly, lw, lh) = layer_rect(ctx, &b); + let w = b.img.width as usize; + let (cx, cy) = (lx + lw * 0.5, ly + lh * 0.5); + map_colour(&mut b, |i, c| { + let mut c = c; + // Basic Correction: white balance, exposure, contrast, tone, saturation. + if temp != 0.0 || tint != 0.0 { + c = [c[0] * (1.0 + 0.25 * temp), c[1] * (1.0 - 0.25 * tint), c[2] * (1.0 - 0.25 * temp)]; + } + if expo != 1.0 { + c = c.map(|v| v * expo); + } + if contrast != 0.0 { + c = c.map(|v| 0.5 + (v - 0.5) * (1.0 + contrast)); + } + if hi != 0.0 || sh != 0.0 || wh != 0.0 || bl != 0.0 { + let l = luminance(c[0], c[1], c[2]).max(0.0); + let lt = l.min(1.0); + let d = 0.25 * hi * smoothstep(0.5, 1.0, lt) + 0.25 * sh * (1.0 - smoothstep(0.0, 0.5, lt)) + 0.2 * wh * lt * lt + 0.2 * bl * (1.0 - lt).powi(2); + c = c.map(|v| v + d); + } + let l = luminance(c[0], c[1], c[2]); + if sat != 1.0 { + c = c.map(|v| l + (v - l) * sat); + } + // Creative. + if intensity != 0.0 && (faded != 0.0 || vib != 0.0 || csat != 1.0 || st != [0.0; 3] || ht != [0.0; 3]) { + let mut d = c; + if faded != 0.0 { + d = d.map(|v| v * (1.0 - 0.25 * faded) + 0.12 * faded); + } + let l = luminance(d[0], d[1], d[2]); + if vib != 0.0 { + let mx = d[0].max(d[1]).max(d[2]); + let mn = d[0].min(d[1]).min(d[2]); + let s = if mx > 1e-6 { (mx - mn) / mx } else { 0.0 }; + let k = 1.0 + vib * (1.0 - s.clamp(0.0, 1.0)); + d = d.map(|v| l + (v - l) * k); + } + if csat != 1.0 { + d = d.map(|v| l + (v - l) * csat); + } + let pivot = (0.5 + 0.25 * tbal).clamp(0.05, 0.95); + let lt = l.clamp(0.0, 1.0); + let wsh = 1.0 - smoothstep(0.0, pivot, lt); + let whi = smoothstep(pivot, 1.0, lt); + for k in 0..3 { + d[k] += 0.5 * (st[k] * wsh + ht[k] * whi); + } + c = [lerp(c[0], d[0], intensity), lerp(c[1], d[1], intensity), lerp(c[2], d[2], intensity)]; + } + // Curves. + c = c.map(|v| tone_curve(v, cm[0], cm[1], cm[2])); + c = [tone_curve(c[0], cr[0], cr[1], cr[2]), tone_curve(c[1], cg[0], cg[1], cg[2]), tone_curve(c[2], cb[0], cb[1], cb[2])]; + // Color wheels. + if ws != [0.0; 3] || wm != [0.0; 3] || wh_ != [0.0; 3] { + let lt = luminance(c[0], c[1], c[2]).clamp(0.0, 1.0); + let a = 1.0 - smoothstep(0.0, 0.5, lt); + let z = smoothstep(0.5, 1.0, lt); + let m = 1.0 - a - z; + for k in 0..3 { + c[k] += 0.5 * (ws[k] * a + wm[k] * m + wh_[k] * z); + } + } + // Vignette. + if vig != 0.0 { + let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5); + let nx = ((x - cx) / (lw * 0.5).max(1e-6)) as f32; + let ny = ((y - cy) / (lh * 0.5).max(1e-6)) as f32; + // Roundness: 0 follows the frame's aspect, +1 a circle, -1 squarer. + let aspect = (lw / lh.max(1e-6)) as f32; + let (nx, ny) = if vround > 0.0 { (nx * lerp(1.0, aspect.max(1.0), vround), ny * lerp(1.0, (1.0 / aspect).max(1.0), vround)) } else { (nx, ny) }; + let pw = 2.0 + (-vround).max(0.0) * 6.0; + let r = (nx.abs().powf(pw) + ny.abs().powf(pw)).powf(1.0 / pw) / std::f32::consts::SQRT_2; + let start = vmid * 0.9; + let f = smoothstep(start, start + 0.05 + vfeather * (1.05 - start), r); + let k = if vig < 0.0 { 1.0 + vig * 0.2 * f } else { 1.0 }; + c = c.map(|v| if vig > 0.0 { v + (1.0 - v) * (vig * 0.2 * f).min(1.0) } else { v * k.max(0.0) }); + } + c.map(|v| v.max(0.0)) + }); + if sharpen != 0.0 { + let luma = Plane::luma(&b.img); + let blur = gauss_plane(&luma, 1.0 * b.scale, 1.0 * b.scale); + b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { + let (c, a) = unpremul(*px); + if a <= 0.0 { + return; + } + let d = (luma.data[i] - blur.data[i]) * sharpen * 2.0; + *px = premul(c.map(|v| (v + d).max(0.0)), a); + }); + } + b +} pub fn specs() -> Vec { - Vec::new() + let grey = || col(0.5, 0.5, 0.5); + let mut lum = vec![ + p("inputLut", "Input LUT", Value::Enum(0), popup(&["None"])), + p("temperature", "Temperature", num(0.0), bipolar()), + p("tint", "Tint", num(0.0), bipolar()), + p("exposure", "Exposure", num(0.0), slider(-5.0, 5.0, -5.0, 5.0, 2)), + p("contrast", "Contrast", num(0.0), bipolar()), + p("highlights", "Highlights", num(0.0), bipolar()), + p("shadows", "Shadows", num(0.0), bipolar()), + p("whites", "Whites", num(0.0), bipolar()), + p("blacks", "Blacks", num(0.0), bipolar()), + p("saturation", "Saturation", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)), + p("look", "Look", Value::Enum(0), popup(&["None"])), + p("lookIntensity", "Intensity", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)), + p("fadedFilm", "Faded Film", num(0.0), pct()), + p("sharpen", "Sharpen", num(0.0), bipolar()), + p("vibrance", "Vibrance", num(0.0), bipolar()), + p("creativeSaturation", "Saturation", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)), + p("shadowTint", "Shadow Tint", grey(), ParamUi::Color), + p("highlightTint", "Highlight Tint", grey(), ParamUi::Color), + p("tintBalance", "Tint Balance", num(0.0), bipolar()), + ]; + const CURVES: [(&str, &str); 12] = [ + ("curveMasterShadows", "RGB Curve Shadows"), + ("curveMasterMidtones", "RGB Curve Midtones"), + ("curveMasterHighlights", "RGB Curve Highlights"), + ("curveRedShadows", "Red Curve Shadows"), + ("curveRedMidtones", "Red Curve Midtones"), + ("curveRedHighlights", "Red Curve Highlights"), + ("curveGreenShadows", "Green Curve Shadows"), + ("curveGreenMidtones", "Green Curve Midtones"), + ("curveGreenHighlights", "Green Curve Highlights"), + ("curveBlueShadows", "Blue Curve Shadows"), + ("curveBlueMidtones", "Blue Curve Midtones"), + ("curveBlueHighlights", "Blue Curve Highlights"), + ]; + for (id, name) in CURVES { + lum.push(p(id, name, num(0.0), bipolar())); + } + lum.extend([ + p("shadowsWheel", "Shadows", grey(), ParamUi::Color), + p("midtonesWheel", "Midtones", grey(), ParamUi::Color), + p("highlightsWheel", "Highlights", grey(), ParamUi::Color), + p("vignetteAmount", "Amount", num(0.0), slider(-5.0, 5.0, -5.0, 5.0, 2)), + p("vignetteMidpoint", "Midpoint", num(50.0), pct()), + p("vignetteRoundness", "Roundness", num(0.0), bipolar()), + p("vignetteFeather", "Feather", num(50.0), pct()), + ]); + vec![ + spec( + "ec.color.changecolor", + "Change Color", + vec![ + p("view", "View", Value::Enum(0), popup(&["Corrected Layer", "Color Correction Mask"])), + p("hueTransform", "Hue Transform", num(0.0), slider(-360.0, 360.0, -180.0, 180.0, 1)), + p("lightnessTransform", "Lightness Transform", num(0.0), bipolar()), + p("saturationTransform", "Saturation Transform", num(0.0), bipolar()), + p("colorToChange", "Color To Change", col(0.8, 0.2, 0.2), ParamUi::Color), + p("matchingTolerance", "Matching Tolerance", num(15.0), pct()), + p("matchingSoftness", "Matching Softness", num(0.0), pct()), + p("matchColors", "Match colors", Value::Enum(1), popup(&["Using RGB", "Using Hue", "Using Chroma"])), + p("invertColorCorrectionMask", "Invert Color Correction Mask", Value::Bool(false), ParamUi::Checkbox), + ], + change_color, + ), + spec( + "ec.color.videolimiter", + "Video Limiter", + vec![ + p("clipLevel", "Clip Level", Value::Enum(2), popup(&["90%", "95%", "100%", "105%", "109%"])), + p("clipMethod", "Clip Method", Value::Enum(2), popup(&["Luma", "Chroma", "Smart Limit"])), + p("compressionBeforeClipping", "Compression before Clipping", Value::Enum(2), popup(&["None", "3%", "5%", "10%", "20%"])), + p("gamutWarning", "Gamut Warning", Value::Bool(false), ParamUi::Checkbox), + p("gamutWarningColor", "Gamut Warning Color", col(1.0, 0.0, 0.0), ParamUi::Color), + ], + video_limiter, + ), + spec( + "ec.color.cccolorneutralizer", + "CC Color Neutralizer", + vec![ + p("shadowsUnbalance", "Shadows Unbalance", col(0.0, 0.0, 0.0), ParamUi::Color), + p("shadowsBalance", "Shadows Balance", col(0.0, 0.0, 0.0), ParamUi::Color), + p("midtonesUnbalance", "Midtones Unbalance", grey(), ParamUi::Color), + p("midtonesBalance", "Midtones Balance", grey(), ParamUi::Color), + p("highlightsUnbalance", "Highlights Unbalance", col(1.0, 1.0, 1.0), ParamUi::Color), + p("highlightsBalance", "Highlights Balance", col(1.0, 1.0, 1.0), ParamUi::Color), + p("pinning", "Pinning", num(0.0), pct()), + p("blendWOriginal", "Blend w. Original", num(0.0), pct()), + p("darks", "Darks", num(0.0), bipolar()), + p("brights", "Brights", num(0.0), bipolar()), + p("contrast", "Contrast", num(0.0), bipolar()), + ], + color_neutralizer, + ), + spec( + "ec.color.psarbitrarymap", + "PS Arbitrary Map", + vec![ + p("phase", "Phase", num(0.0), slider(-1000.0, 1000.0, 0.0, 255.0, 0)), + p("applyPhaseMapToAlpha", "Apply Phase Map To Alpha", Value::Bool(false), ParamUi::Checkbox), + p("map", "Map", Value::Str(String::new()), ParamUi::Hidden), + ], + arbitrary_map, + ), + spec("ec.color.lumetri", "Lumetri Color", lum, lumetri), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{EffectEnv, run_fx}; + use effectcraft_raster::Image; + + fn ramp() -> Image { + let mut img = Image::new(16, 8); + for y in 0..8 { + for x in 0..16 { + let a = if y < 7 { 1.0 } else { 0.5 }; + img.set(x, y, [x as f32 / 16.0 * a, (y as f32 / 8.0) * a, 0.3 * a, a]); + } + } + img + } + + fn close(a: &Image, b: &Image, tol: f32) -> bool { + a.data.iter().zip(b.data.iter()).all(|(p, q)| p.iter().zip(q.iter()).all(|(x, y)| (x - y).abs() <= tol)) + } + + fn run(id: &str, vals: &[(&str, Value)], img: Image) -> Image { + run_fx(id, vals, img, 0.0, EffectEnv::default()).img + } + + #[test] + fn change_color_zero_is_identity_and_hue_shift_moves_red() { + let img = ramp(); + assert!(close(&run("ec.color.changecolor", &[], img.clone()), &img, 0.0)); + let red = Image::filled(4, 4, [0.8, 0.2, 0.2, 1.0]); + let out = run("ec.color.changecolor", &[("hueTransform", num(120.0))], red.clone()); + let p = out.get(1, 1); + assert!(p[1] > p[0] && p[1] > p[2], "{p:?}"); + let blue = Image::filled(4, 4, [0.1, 0.2, 0.9, 1.0]); + assert!(close(&run("ec.color.changecolor", &[("hueTransform", num(120.0))], blue.clone()), &blue, 1e-6)); + let mask = run("ec.color.changecolor", &[("view", Value::Enum(1))], red); + assert!((mask.get(0, 0)[0] - 1.0).abs() < 1e-6); + assert_eq!(out.data, run("ec.color.changecolor", &[("hueTransform", num(120.0))], Image::filled(4, 4, [0.8, 0.2, 0.2, 1.0])).data); + } + + #[test] + fn video_limiter_keeps_legal_and_limits_hot() { + let mid = Image::filled(4, 4, [0.4, 0.5, 0.3, 1.0]); + assert!(close(&run("ec.color.videolimiter", &[], mid.clone()), &mid, 1e-6)); + let hot = Image::filled(4, 4, [1.6, 1.2, 0.9, 1.0]); + let out = run("ec.color.videolimiter", &[], hot); + assert!(out.data.iter().all(|p| p[0] <= 1.0 + 1e-6 && p[1] <= 1.0 + 1e-6)); + let warn = run("ec.color.videolimiter", &[("gamutWarning", Value::Bool(true))], Image::filled(2, 2, [1.6, 1.2, 0.9, 1.0])); + assert_eq!(warn.get(0, 0), [1.0, 0.0, 0.0, 1.0]); + } + + #[test] + fn color_neutralizer_defaults_identity_and_removes_cast() { + let img = ramp(); + assert!(close(&run("ec.color.cccolorneutralizer", &[], img.clone()), &img, 1e-6)); + let cast = Image::filled(4, 4, [0.6, 0.5, 0.4, 1.0]); + let out = run("ec.color.cccolorneutralizer", &[("midtonesUnbalance", col(0.6, 0.5, 0.4)), ("midtonesBalance", col(0.5, 0.5, 0.5))], cast); + let p = out.get(0, 0); + assert!((p[0] - p[2]).abs() < 0.1, "{p:?}"); + } + + #[test] + fn arbitrary_map_identity_and_inverting_map() { + let img = ramp(); + assert!(close(&run("ec.color.psarbitrarymap", &[], img.clone()), &img, 1e-5)); + let inv: Vec = (0..256).map(|i| (255 - i).to_string()).collect(); + let out = run("ec.color.psarbitrarymap", &[("map", Value::Str(inv.join(",")))], Image::filled(2, 2, [0.25, 0.5, 1.0, 1.0])); + let p = out.get(0, 0); + assert!((p[0] - 0.75).abs() < 0.01 && p[2].abs() < 0.01, "{p:?}"); + } + + #[test] + fn lumetri_defaults_identity_and_controls_act() { + let img = ramp(); + assert!(close(&run("ec.color.lumetri", &[], img.clone()), &img, 1e-5)); + let g = Image::filled(8, 8, [0.4, 0.4, 0.4, 1.0]); + let e = run("ec.color.lumetri", &[("exposure", num(1.0))], g.clone()); + assert!((e.get(0, 0)[0] - 0.8).abs() < 1e-5); + let warm = run("ec.color.lumetri", &[("temperature", num(50.0))], g.clone()); + assert!(warm.get(0, 0)[0] > warm.get(0, 0)[2]); + let bw = run("ec.color.lumetri", &[("saturation", num(0.0))], img.clone()); + let p = bw.get(5, 3); + assert!((p[0] - p[1]).abs() < 1e-5 && (p[1] - p[2]).abs() < 1e-5); + let v = run("ec.color.lumetri", &[("vignetteAmount", num(-3.0))], g.clone()); + assert!(v.get(0, 0)[0] < v.get(4, 4)[0]); + let c = run("ec.color.lumetri", &[("curveMasterMidtones", num(50.0))], g.clone()); + assert!(c.get(0, 0)[0] > 0.4); + assert_eq!(c.data, run("ec.color.lumetri", &[("curveMasterMidtones", num(50.0))], g).data); + } } From 43ebf68fe0499ed18812fabc8417be22969631d8 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 22:02:40 -0400 Subject: [PATCH 10/14] M9.3: Obsolete effects (Basic 3D, Gaussian Blur (Legacy)); move Luma Key, Spill Suppressor, Reduce Interlace Flicker to Obsolete Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/blur2.rs | 10 +- crates/effects/src/keying.rs | 4 +- crates/effects/src/obsolete.rs | 183 ++++++++++++++++++++++++++++++++- 3 files changed, 191 insertions(+), 6 deletions(-) diff --git a/crates/effects/src/blur2.rs b/crates/effects/src/blur2.rs index def9d207..8ce9a840 100644 --- a/crates/effects/src/blur2.rs +++ b/crates/effects/src/blur2.rs @@ -596,7 +596,15 @@ pub fn specs() -> Vec { ], vector_blur, ), - spec("ec.blur.reduceflicker", "Reduce Interlace Flicker", vec![p("softness", "Softness", num(0.0), slider(0.0, 100.0, 0.0, 10.0, 2))], reduce_flicker), + EffectSpec { + category: "Obsolete", + ..spec( + "ec.blur.reduceflicker", + "Reduce Interlace Flicker", + vec![p("softness", "Softness", num(0.0), slider(0.0, 100.0, 0.0, 10.0, 2))], + reduce_flicker, + ) + }, spec( "ec.blur.cccross", "CC Cross Blur", diff --git a/crates/effects/src/keying.rs b/crates/effects/src/keying.rs index c451c751..6be96506 100644 --- a/crates/effects/src/keying.rs +++ b/crates/effects/src/keying.rs @@ -485,7 +485,7 @@ pub fn specs() -> Vec { spec( "ec.key.luma", "Luma Key", - "Keying", + "Obsolete", vec![ p("keyType", "Key Type", Value::Enum(1), popup(&["Key Out Brighter", "Key Out Darker", "Key Out Similar", "Key Out Dissimilar"])), p("threshold", "Threshold", num(0.0), b255()), @@ -560,7 +560,7 @@ pub fn specs() -> Vec { spec( "ec.key.spill", "Spill Suppressor", - "Keying", + "Obsolete", vec![ p("colorToSuppress", "Color To Suppress", col(0.0, 0.0, 1.0), ParamUi::Color), p("suppression", "Suppression", num(100.0), slider(0.0, 200.0, 0.0, 100.0, 0)), diff --git a/crates/effects/src/obsolete.rs b/crates/effects/src/obsolete.rs index 331686c1..122ec32d 100644 --- a/crates/effects/src/obsolete.rs +++ b/crates/effects/src/obsolete.rs @@ -1,7 +1,184 @@ -//! Obsolete-category effects. +//! Obsolete-category effects: Basic 3D (tilt/swivel the layer in a simple perspective space +//! with an optional specular highlight) and Gaussian Blur (Legacy). -use crate::EffectSpec; +use effectcraft_keyframe::Value; +use effectcraft_project::ParamUi; +use effectcraft_raster::gaussian_blur; +use rayon::prelude::*; + +use crate::util::layer_rect; +use crate::{Buf, EffectCtx, EffectSpec, num, p, popup, slider}; + +fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { + EffectSpec { id, name, category: "Obsolete", params, render, gpu: false, float: true } +} + +// ---------------------------------------------------------------- Basic 3D + +fn basic_3d(ctx: &EffectCtx, mut b: Buf) -> Buf { + let swivel = ctx.params.f("swivel").to_radians(); + let tilt = ctx.params.f("tilt").to_radians(); + let dist = ctx.params.f("distanceToImage") * b.scale; + let spec_on = ctx.params.b("specularHighlight"); + let wire = ctx.params.b("drawPreviewWireframe"); + if swivel == 0.0 && tilt == 0.0 && dist == 0.0 && !spec_on && !wire { + return b; + } + let (lx, ly, lw, lh) = layer_rect(ctx, &b); + let (cx, cy) = (lx + lw * 0.5, ly + lh * 0.5); + // Eye on the -z axis at a focal distance comparable to a 50 mm lens. + let f = lw.max(lh).max(1.0) * 1.4; + // Plane basis after Y (swivel) then X (tilt) rotations. + let (cs, ss) = (swivel.cos(), swivel.sin()); + let (ct, st) = (tilt.cos(), tilt.sin()); + let ux = [cs, 0.0, -ss]; + let uy = [ss * st, ct, cs * st]; + let n = [ss * ct, -st, cs * ct]; + let dot = |a: [f64; 3], b: [f64; 3]| a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + let src = b.img.clone(); + let light = { + let l = [-0.5f64, -0.6, -1.0]; + let k = 1.0 / dot(l, l).sqrt(); + l.map(|v| v * k) + }; + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + // Ray from the eye (0, 0, -f) through the pixel on the z = 0 plane (centre-relative). + let d = [x as f64 + 0.5 - cx, y as f64 + 0.5 - cy, f]; + let o = [0.0, 0.0, -f]; + let denom = dot(d, n); + if denom.abs() < 1e-9 { + *px = [0.0; 4]; + continue; + } + let pc = [0.0, 0.0, dist]; + let t = dot([pc[0] - o[0], pc[1] - o[1], pc[2] - o[2]], n) / denom; + if t <= 0.0 { + *px = [0.0; 4]; + continue; + } + let hit = [o[0] + d[0] * t - pc[0], o[1] + d[1] * t - pc[1], o[2] + d[2] * t - pc[2]]; + let (u, v) = (dot(hit, ux), dot(hit, uy)); + let mut c = src.sample_bilinear(cx + u, cy + v); + if spec_on && c[3] > 0.0 { + // Reflect the view direction about the normal and compare with the light. + let dl = dot(d, d).sqrt(); + let dv = d.map(|v| v / dl); + let k = 2.0 * dot(dv, n); + let r = [dv[0] - k * n[0], dv[1] - k * n[1], dv[2] - k * n[2]]; + let s = (-dot(r, light)).max(0.0).powi(40) as f32; + let a = c[3]; + for ch in c.iter_mut().take(3) { + *ch += s * a * (1.0 - *ch / a.max(1e-6)).max(0.0); + } + } + if wire { + let e = (u.abs() - lw * 0.5).abs().min((v.abs() - lh * 0.5).abs()); + if e < 0.75 && u.abs() <= lw * 0.5 + 0.75 && v.abs() <= lh * 0.5 + 0.75 { + c = [1.0, 1.0, 1.0, 1.0]; + } + } + *px = c; + } + }); + b +} + +// ---------------------------------------------------------------- Gaussian Blur (Legacy) + +fn gaussian_legacy(ctx: &EffectCtx, mut b: Buf) -> Buf { + let s = ctx.params.f("blurriness").max(0.0) * 0.5 * b.scale; + if s <= 0.0 { + return b; + } + let (kx, ky) = match ctx.params.e("blurDimensions") { + 1 => (1.0, 0.0), + 2 => (0.0, 1.0), + _ => (1.0, 1.0), + }; + if !ctx.adjustment { + b.pad((s * 3.0).ceil() as u32); + } + b.img = gaussian_blur(&b.img, s * kx, s * ky, ctx.adjustment); + b +} pub fn specs() -> Vec { - Vec::new() + vec![ + spec( + "ec.obsolete.basic3d", + "Basic 3D", + vec![ + p("swivel", "Swivel", num(0.0), ParamUi::Angle), + p("tilt", "Tilt", num(0.0), ParamUi::Angle), + p("distanceToImage", "Distance to Image", num(0.0), slider(-1000.0, 30000.0, -100.0, 100.0, 1)), + p("specularHighlight", "Specular Highlight", Value::Bool(false), ParamUi::Checkbox), + p("drawPreviewWireframe", "Draw Preview Wireframe", Value::Bool(false), ParamUi::Checkbox), + ], + basic_3d, + ), + spec( + "ec.obsolete.gaussianlegacy", + "Gaussian Blur (Legacy)", + vec![ + p("blurriness", "Blurriness", num(0.0), slider(0.0, 1000.0, 0.0, 50.0, 1)), + p("blurDimensions", "Blur Dimensions", Value::Enum(0), popup(&["Horizontal and Vertical", "Horizontal", "Vertical"])), + ], + gaussian_legacy, + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{EffectEnv, run_fx}; + use effectcraft_raster::Image; + + fn ramp() -> Image { + let mut img = Image::new(20, 12); + for y in 0..12 { + for x in 0..20 { + img.set(x, y, [x as f32 / 20.0, y as f32 / 12.0, 0.5, 1.0]); + } + } + img + } + + #[test] + fn basic_3d_zero_identity_swivel_narrows_and_is_deterministic() { + let img = ramp(); + let id = run_fx("ec.obsolete.basic3d", &[], img.clone(), 0.0, EffectEnv::default()); + assert_eq!(id.img.data, img.data); + let flat = + run_fx("ec.obsolete.basic3d", &[("distanceToImage", num(0.0)), ("specularHighlight", Value::Bool(false))], img.clone(), 0.0, EffectEnv::default()); + assert_eq!(flat.img.data, img.data); + let sw = run_fx("ec.obsolete.basic3d", &[("swivel", num(60.0))], img.clone(), 0.0, EffectEnv::default()); + assert_eq!(sw.img.get(0, 6)[3], 0.0, "edges uncovered after swivel"); + assert!(sw.img.get(10, 6)[3] > 0.99); + let again = run_fx("ec.obsolete.basic3d", &[("swivel", num(60.0))], img.clone(), 0.0, EffectEnv::default()); + assert_eq!(sw.img.data, again.img.data); + let far = run_fx("ec.obsolete.basic3d", &[("distanceToImage", num(20.0))], img, 0.0, EffectEnv::default()); + assert_eq!(far.img.get(0, 0)[3], 0.0, "farther image is smaller"); + } + + #[test] + fn gaussian_legacy_zero_identity_and_blurs() { + let mut img = Image::new(15, 15); + img.set(7, 7, [1.0, 1.0, 1.0, 1.0]); + let id = run_fx("ec.obsolete.gaussianlegacy", &[], img.clone(), 0.0, EffectEnv::default()); + assert_eq!(id.img.data, img.data); + let out = run_fx("ec.obsolete.gaussianlegacy", &[("blurriness", num(4.0))], img.clone(), 0.0, EffectEnv::default()); + let total: f32 = out.img.data.iter().map(|p| p[3]).sum(); + assert!((total - 1.0).abs() < 1e-3, "{total}"); + let c = out.img.width / 2; + assert!(out.img.get(c as i64, c as i64)[3] < 0.5); + } + + #[test] + fn moved_effects_are_obsolete() { + for id in ["ec.key.luma", "ec.key.spill", "ec.blur.reduceflicker"] { + assert_eq!(crate::find(id).unwrap().category, "Obsolete", "{id}"); + } + } } From 17af5c30c1f1b88ec15a33b482b541f210b88f3d Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 22:02:08 -0400 Subject: [PATCH 11/14] M9.3: Generate effects (Fractal, Scribble, Stroke, Vegas, Write-on, Paint Bucket, Eyedropper Fill, CC Glue Gun, CC Threads, Audio Spectrum, Audio Waveform) Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/generate3.rs | 1887 ++++++++++++++++++++++++++++++- 1 file changed, 1884 insertions(+), 3 deletions(-) diff --git a/crates/effects/src/generate3.rs b/crates/effects/src/generate3.rs index e50cf9f5..87d82867 100644 --- a/crates/effects/src/generate3.rs +++ b/crates/effects/src/generate3.rs @@ -1,7 +1,1888 @@ -//! Generate effects, batch 3. +//! Generate effects, batch 3: Fractal (Mandelbrot/Julia), mask-driven Scribble and Stroke, +//! Vegas (animated segments along image contours or masks), Write-on, Paint Bucket (flood +//! fill), Eyedropper Fill, CC Glue Gun, CC Threads, and the audio visualisers Audio Spectrum +//! and Audio Waveform (fed by the host's audio). +//! +//! Also home of the shared anti-aliased segment rasteriser ([`raster_segs`]) used by the text +//! effects. -use crate::EffectSpec; +use std::f64::consts::PI; + +use effectcraft_color::{hsl_to_rgb, luminance, rgb_to_hsl}; +use effectcraft_keyframe::Value; +use effectcraft_project::ParamUi; +use effectcraft_raster::{Image, Px}; +use rayon::prelude::*; + +use crate::util::{Plane, gauss_plane, hash1, layer_or_self, morph_frac, poly_length, premul, smoothstep, unpremul}; +use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; + +fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { + EffectSpec { id, name, category: "Generate", params, render, gpu: false, float: true } +} + +fn pt(x: f64, y: f64) -> Value { + Value::Vec2([x, y]) +} + +fn pct() -> ParamUi { + slider(0.0, 100.0, 0.0, 100.0, 1) +} + +// ---------------------------------------------------------------- shared rasterisation + +/// A round-capped line segment in buffer pixels with radius `r` and intensity `v`. +#[derive(Clone, Copy, Debug)] +pub(crate) struct Seg { + pub a: [f64; 2], + pub b: [f64; 2], + pub r: f64, + pub v: f32, +} + +/// Rasterise segments into a coverage plane (max-combined). `hardness` (0..1) is the fraction +/// of the radius that is fully opaque; the rest falls off smoothly. +pub(crate) fn raster_segs(w: usize, h: usize, segs: &[Seg], hardness: f64) -> Plane { + let mut out = Plane::new(w, h); + if w == 0 || h == 0 || segs.is_empty() { + return out; + } + let mut buckets: Vec> = vec![Vec::new(); h]; + for (i, s) in segs.iter().enumerate() { + let pad = s.r + 1.0; + let y0 = (s.a[1].min(s.b[1]) - pad).floor().max(0.0) as usize; + let y1 = (s.a[1].max(s.b[1]) + pad).ceil().min(h as f64); + if y1 <= 0.0 || y0 >= h { + continue; + } + for row in buckets.iter_mut().take(y1 as usize).skip(y0) { + row.push(i as u32); + } + } + let hard = hardness.clamp(0.0, 1.0); + out.data.par_chunks_mut(w).enumerate().for_each(|(y, row)| { + let py = y as f64 + 0.5; + for &i in &buckets[y] { + let s = segs[i as usize]; + let pad = s.r + 1.0; + let x0 = (s.a[0].min(s.b[0]) - pad).floor().max(0.0) as usize; + let x1 = ((s.a[0].max(s.b[0]) + pad).ceil().max(0.0) as usize).min(w); + let (dx, dy) = (s.b[0] - s.a[0], s.b[1] - s.a[1]); + let l2 = dx * dx + dy * dy; + let inner = s.r * hard - 0.5; + let outer = s.r + 0.5; + for (x, o) in row.iter_mut().enumerate().take(x1).skip(x0) { + let px = x as f64 + 0.5; + let t = if l2 > 1e-12 { (((px - s.a[0]) * dx + (py - s.a[1]) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 }; + let d = ((px - s.a[0] - dx * t).powi(2) + (py - s.a[1] - dy * t).powi(2)).sqrt(); + if d >= outer { + continue; + } + let c = if d <= inner { 1.0 } else { 1.0 - smoothstep(inner as f32, outer as f32, d as f32) } * s.v; + if c > *o { + *o = c; + } + } + } + }); + out +} + +/// Segments along a polyline (buffer px). +pub(crate) fn poly_segs(pts: &[[f64; 2]], closed: bool, r: f64, v: f32, out: &mut Vec) { + let n = pts.len(); + if n == 1 { + out.push(Seg { a: pts[0], b: pts[0], r, v }); + } + if n < 2 { + return; + } + let segs = if closed { n } else { n - 1 }; + for i in 0..segs { + out.push(Seg { a: pts[i], b: pts[(i + 1) % n], r, v }); + } +} + +/// Portion `[s0, s1]` (arc length) of a polyline. +pub(crate) fn trim_poly(pts: &[[f64; 2]], closed: bool, s0: f64, s1: f64) -> Vec<[f64; 2]> { + let n = pts.len(); + let mut out = Vec::new(); + if n < 2 || s1 <= s0 { + return out; + } + let segs = if closed { n } else { n - 1 }; + let mut acc = 0.0; + for i in 0..segs { + let (a, b) = (pts[i], pts[(i + 1) % n]); + let l = ((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2)).sqrt(); + let (e0, e1) = (acc, acc + l); + acc = e1; + if e1 < s0 || e0 > s1 || l <= 0.0 { + continue; + } + let at = |s: f64| { + let t = ((s - e0) / l).clamp(0.0, 1.0); + [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t] + }; + let p0 = at(s0.max(e0)); + if out.last() != Some(&p0) { + out.push(p0); + } + out.push(at(s1.min(e1))); + } + out +} + +/// How painted coverage meets the layer: 0 On Original Image, 1 On Transparent, 2 Reveal +/// Original Image. +pub(crate) fn paint(img: &mut Image, cov: &Plane, color: [f32; 4], opacity: f32, style: u32) { + img.data.par_iter_mut().zip(cov.data.par_iter()).for_each(|(px, &c)| { + let a = (c * opacity * color[3]).clamp(0.0, 1.0); + let s = [color[0] * a, color[1] * a, color[2] * a, a]; + *px = match style { + 1 => s, + 2 => [px[0] * a, px[1] * a, px[2] * a, px[3] * a], + _ => over(*px, s), + }; + }); +} + +#[inline] +fn over(dst: Px, src: Px) -> Px { + let k = 1.0 - src[3]; + [src[0] + dst[0] * k, src[1] + dst[1] * k, src[2] + dst[2] * k, src[3] + dst[3] * k] +} + +/// Mask `idx` (1-based; 0 = none) mapped to buffer pixels. +pub(crate) fn mask_px(ctx: &EffectCtx, b: &Buf, idx: usize) -> Option<(Vec<[f64; 2]>, bool, bool)> { + let m = ctx.env.masks.get(idx.checked_sub(1)?)?; + if m.points.is_empty() { + return None; + } + Some((m.points.iter().map(|&q| [q[0] * b.scale + b.offset[0], q[1] * b.scale + b.offset[1]]).collect(), m.closed, m.inverted)) +} + +/// All masks mapped to buffer pixels. +fn all_masks_px(ctx: &EffectCtx, b: &Buf) -> Vec<(Vec<[f64; 2]>, bool, bool)> { + (1..=ctx.env.masks.len()).filter_map(|i| mask_px(ctx, b, i)).collect() +} + +/// Chaikin corner cutting (open polyline, endpoints kept). +fn chaikin(pts: &[[f64; 2]], iters: usize) -> Vec<[f64; 2]> { + let mut cur = pts.to_vec(); + for _ in 0..iters { + if cur.len() < 3 { + return cur; + } + let mut nx = Vec::with_capacity(cur.len() * 2); + nx.push(cur[0]); + for w in cur.windows(2) { + let (a, b) = (w[0], w[1]); + nx.push([a[0] * 0.75 + b[0] * 0.25, a[1] * 0.75 + b[1] * 0.25]); + nx.push([a[0] * 0.25 + b[0] * 0.75, a[1] * 0.25 + b[1] * 0.75]); + } + nx.push(*cur.last().unwrap_or(&[0.0; 2])); + cur = nx; + } + cur +} + +// ---------------------------------------------------------------- Fractal + +#[derive(Clone, Copy)] +struct FracSet { + julia: bool, + inverse: bool, +} + +/// Smooth escape count, or `None` inside the set. +fn escape(z0: (f64, f64), c: (f64, f64), power: u32, limit: f64, max_iter: u32) -> Option { + let (mut x, mut y) = z0; + let lim2 = limit * limit; + for i in 0..max_iter { + let (nx, ny) = match power { + 3 => (x * x * x - 3.0 * x * y * y, 3.0 * x * x * y - y * y * y), + 4 => { + let (a, b) = (x * x - y * y, 2.0 * x * y); + (a * a - b * b, 2.0 * a * b) + } + _ => (x * x - y * y, 2.0 * x * y), + }; + x = nx + c.0; + y = ny + c.1; + let m2 = x * x + y * y; + if m2 > lim2 { + let lz = m2.ln() * 0.5; + let nu = (lz / limit.max(1.0001).ln()).max(1e-9).ln() / (power as f64).ln(); + return Some((i as f64 + 1.0 - nu).max(0.0)); + } + } + None +} + +fn fractal(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let set_choice = pr.e("setChoice"); + let power = 2 + pr.e("equation").min(2); + let mc = (pr.f("mandelbrotX"), pr.f("mandelbrotY")); + let mmag = pr.f("mandelbrotMagnification"); + let mlim = pr.f("mandelbrotEscapeLimit").max(1.01); + let jc = (pr.f("juliaX"), pr.f("juliaY")); + let jmag = pr.f("juliaMagnification"); + let jlim = pr.f("juliaEscapeLimit").max(1.01); + let post = (pr.f("postInversionX"), pr.f("postInversionY")); + let overlay = pr.b("overlay"); + let transparent_inside = pr.b("transparency") || overlay; + let palette = pr.e("palette"); + let hue = (pr.f("hue") / 360.0) as f32; + let steps = pr.f("cycleSteps").max(1.0); + let cyc_off = pr.f("cycleOffset") / 360.0; + let edge = pr.b("edgeHighlight"); + let every = pr.e("samplingMethod") == 1; + let factor = pr.f("samplingFactor").round().clamp(1.0, 9.0) as usize; + let (w, h) = (b.img.width as usize, b.img.height as usize); + let lw = ctx.layer_size[0].max(1.0); + let lh = ctx.layer_size[1].max(1.0); + let inv = 1.0 / b.scale.max(1e-9); + let (off_x, off_y) = (b.offset[0], b.offset[1]); + let sets: (FracSet, Option) = match set_choice { + 1 => (FracSet { julia: false, inverse: true }, None), + 2 => (FracSet { julia: false, inverse: false }, Some(FracSet { julia: false, inverse: true })), + 3 => (FracSet { julia: false, inverse: true }, Some(FracSet { julia: false, inverse: false })), + 4 => (FracSet { julia: true, inverse: false }, None), + 5 => (FracSet { julia: true, inverse: true }, None), + _ => (FracSet { julia: false, inverse: false }, None), + }; + let iter_for = |mag: f64| (96.0 + 48.0 * mag.max(0.0)).clamp(64.0, 3000.0) as u32; + let eval_set = |s: FracSet, lx: f64, ly: f64| -> Option { + let (center, mag, lim) = if s.julia { (jc, jmag, jlim) } else { (mc, mmag, mlim) }; + let span = 3.5 / 2f64.powf(mag); + let mut u = (lx - lw * 0.5) / lw * span + center.0; + let mut v = (ly - lh * 0.5) / lw * span + center.1; + if s.inverse { + let d = (u * u + v * v).max(1e-12); + u = u / d + post.0; + v = -v / d + post.1; + } + if s.julia { escape((u, v), mc, power, lim, iter_for(mag)) } else { escape((0.0, 0.0), (u, v), power, lim, iter_for(mag)) } + }; + let eval = |lx: f64, ly: f64| -> Option { + let a = eval_set(sets.0, lx, ly); + match (a, sets.1) { + (None, Some(s2)) => eval_set(s2, lx, ly), + _ => a, + } + }; + let shade = |n: Option| -> Px { + let Some(n) = n else { + return if transparent_inside { [0.0; 4] } else { [0.0, 0.0, 0.0, 1.0] }; + }; + let band = ((n / steps + cyc_off).rem_euclid(1.0)) as f32; + let (r, g, bl) = match palette { + 1 => hsl_to_rgb((hue + band).rem_euclid(1.0), 1.0, 0.5), + 2 => hsl_to_rgb((hue + band).rem_euclid(1.0), 1.0, 0.3 + 0.4 * (band * 4.0).fract()), + _ => hsl_to_rgb(hue, 0.85, band.clamp(0.0, 1.0)), + }; + let mut c = [r, g, bl]; + if edge && n.fract() < 0.12 { + c = [c[0] * 0.4 + 0.6, c[1] * 0.4 + 0.6, c[2] * 0.4 + 0.6]; + } + [c[0], c[1], c[2], 1.0] + }; + // First pass: one sample per pixel. + let mut first: Vec> = vec![None; w * h]; + first.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| { + for (x, v) in row.iter_mut().enumerate() { + *v = eval((x as f64 + 0.5 - off_x) * inv, (y as f64 + 0.5 - off_y) * inv); + } + }); + let src = if overlay { Some(b.img.clone()) } else { None }; + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let here = first[y * w + x]; + let needs_ss = factor > 1 + && (every || { + let mut diff = false; + for (dx, dy) in [(-1i64, 0i64), (1, 0), (0, -1), (0, 1)] { + let (nx, ny) = (x as i64 + dx, y as i64 + dy); + if nx < 0 || ny < 0 || nx >= w as i64 || ny >= h as i64 { + continue; + } + let o = first[ny as usize * w + nx as usize]; + diff |= match (here, o) { + (Some(a), Some(c)) => (a - c).abs() > steps * 0.25, + (None, None) => false, + _ => true, + }; + } + diff + }); + let c = if needs_ss { + let mut acc = [0.0f32; 4]; + for sy in 0..factor { + for sx in 0..factor { + let fx = x as f64 + (sx as f64 + 0.5) / factor as f64; + let fy = y as f64 + (sy as f64 + 0.5) / factor as f64; + let s = shade(eval((fx - off_x) * inv, (fy - off_y) * inv)); + let a = s[3]; + for k in 0..3 { + acc[k] += s[k] * a; + } + acc[3] += a; + } + } + let n = (factor * factor) as f32; + [acc[0] / n, acc[1] / n, acc[2] / n, acc[3] / n] + } else { + let s = shade(here); + [s[0] * s[3], s[1] * s[3], s[2] * s[3], s[3]] + }; + *px = match &src { + Some(o) => over(o.data[y * w + x], c), + None => c, + }; + } + }); + b +} + +// ---------------------------------------------------------------- Scribble + +/// Scan runs (start, end along direction `d`) of the fill region on the line `n·p = s`. +#[allow(clippy::too_many_arguments)] +fn scan_runs(polys: &[(Vec<[f64; 2]>, bool, bool)], fill: u32, ew: f64, d: [f64; 2], n: [f64; 2], s: f64, out: &mut Vec<(f64, f64)>) { + out.clear(); + for (pts, _closed, inverted) in polys { + let m = pts.len(); + if m < 2 { + continue; + } + // Crossings: (t along d, |sin| of crossing angle). + let mut xs: Vec<(f64, f64)> = Vec::new(); + for i in 0..m { + let (a, b) = (pts[i], pts[(i + 1) % m]); + let (na, nb) = (a[0] * n[0] + a[1] * n[1] - s, b[0] * n[0] + b[1] * n[1] - s); + if (na > 0.0) == (nb > 0.0) { + continue; + } + let t = na / (na - nb); + let q = [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]; + let (ex, ey) = (b[0] - a[0], b[1] - a[1]); + let el = (ex * ex + ey * ey).sqrt().max(1e-9); + let sin = ((ex * n[0] + ey * n[1]) / el).abs().max(0.2); + xs.push((q[0] * d[0] + q[1] * d[1], sin)); + } + xs.sort_by(|a, b| a.0.total_cmp(&b.0)); + // Signed area decides which side is "left". + let area: f64 = (0..m).map(|i| pts[i][0] * pts[(i + 1) % m][1] - pts[(i + 1) % m][0] * pts[i][1]).sum(); + let left_is_inside = area < 0.0; + let mode = match fill { + 4 => { + if left_is_inside { + 2 + } else { + 3 + } + } + 5 => { + if left_is_inside { + 3 + } else { + 2 + } + } + f => f, + }; + let mode = if *inverted && mode == 0 { 6 } else { mode }; + for (k, &(t, sin)) in xs.iter().enumerate() { + let entering = k % 2 == 0; + let l = ew / sin; + match mode { + 0 => { + if entering && k + 1 < xs.len() { + out.push((t, xs[k + 1].0)); + } + } + 1 => out.push((t - l * 0.5, t + l * 0.5)), + 2 => out.push(if entering { (t, t + l) } else { (t - l, t) }), + 3 => out.push(if entering { (t - l, t) } else { (t, t + l) }), + _ => {} + } + } + if mode == 6 { + // Inverted Inside: outside of the polygon within the bounds. + let lo = pts.iter().map(|q| q[0] * d[0] + q[1] * d[1]).fold(f64::INFINITY, f64::min) - ew; + let hi = pts.iter().map(|q| q[0] * d[0] + q[1] * d[1]).fold(f64::NEG_INFINITY, f64::max) + ew; + let mut cur = lo; + for pair in xs.chunks(2) { + out.push((cur, pair[0].0)); + cur = pair.get(1).map(|v| v.0).unwrap_or(hi); + } + out.push((cur, hi)); + } + } + out.sort_by(|a, b| a.0.total_cmp(&b.0)); + // Merge overlaps. + let mut merged: Vec<(f64, f64)> = Vec::with_capacity(out.len()); + for &(a, b) in out.iter() { + if let Some(last) = merged.last_mut() + && a <= last.1 + { + last.1 = last.1.max(b); + continue; + } + merged.push((a, b)); + } + *out = merged; +} + +fn scribble(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let which = pr.e("scribble"); + let polys: Vec<_> = if which == 0 { mask_px(ctx, &b, pr.f("mask").round().max(1.0) as usize).into_iter().collect() } else { all_masks_px(ctx, &b) }; + let polys: Vec<_> = polys.into_iter().filter(|p| p.0.len() >= 3).map(|(p, c, i)| (p, c, i && which == 2)).collect(); + let style = match pr.e("composite") { + 0 => 0, + 2 => 2, + _ => 1, + }; + let (w, h) = (b.img.width as usize, b.img.height as usize); + if polys.is_empty() { + if style == 1 { + b.img = Image::new(b.img.width, b.img.height); + } + return b; + } + let sc = b.scale; + let fill = pr.e("fillType"); + let ew = pr.f("edgeWidth").max(0.1) * sc; + let ang = pr.f("angle").to_radians(); + let d = [ang.cos(), ang.sin()]; + let n = [-d[1], d[0]]; + let width = (pr.f("strokeWidth") * sc).max(0.1); + let curv = pr.f("curviness") / 100.0; + let curv_var = pr.f("curvinessVariation") / 100.0; + let spacing = (pr.f("spacing") * sc).max(0.75).max(width * 0.25); + let spacing_var = pr.f("spacingVariation") / 100.0; + let overlap = pr.f("pathOverlap") / 100.0; + let overlap_var = pr.f("pathOverlapVariation") / 100.0; + let start = pr.f("start").clamp(0.0, 100.0) / 100.0; + let end = pr.f("end").clamp(0.0, 100.0) / 100.0; + let sequential = pr.b("fillPathsSequentially"); + let wiggle = pr.e("wiggleType"); + let wps = pr.f("wigglesPerSecond").max(0.0); + let seed = pr.f("randomSeed") as i64 as u32 ^ ctx.seed; + let (step, frac) = match wiggle { + 0 => (0u32, 0.0f32), + 1 => ((ctx.time * wps).floor().max(0.0) as u32, 0.0), + _ => { + let u = (ctx.time * wps).max(0.0); + (u.floor() as u32, smoothstep(0.0, 1.0, u.fract() as f32)) + } + }; + let rnd = |i: u32, k: u32| { + let a = hash1(i, k.wrapping_mul(31).wrapping_add(step.wrapping_mul(7919)), seed) * 2.0 - 1.0; + if frac > 0.0 { + let c = hash1(i, k.wrapping_mul(31).wrapping_add((step + 1).wrapping_mul(7919)), seed) * 2.0 - 1.0; + a + (c - a) * frac + } else { + a + } + }; + let groups: Vec, bool, bool)>> = + if which == 0 || sequential { vec![polys.clone()] } else { polys.iter().map(|p| vec![p.clone()]).collect() }; + // Per group a zig-zag polyline (in buffer px). + let mut lines: Vec>> = Vec::new(); + let mut runs = Vec::new(); + for (gi, g) in groups.iter().enumerate() { + let mut nmin = f64::INFINITY; + let mut nmax = f64::NEG_INFINITY; + for (pts, _, _) in g { + for q in pts { + let v = q[0] * n[0] + q[1] * n[1]; + nmin = nmin.min(v); + nmax = nmax.max(v); + } + } + nmin -= ew; + nmax += ew; + // Chains of zig-zags: each run joins the nearest chain that ended on the previous + // scan line (so separate regions get separate strokes). + let mut chains: Vec<(Vec<[f64; 2]>, u32)> = Vec::new(); + let mut s = nmin + spacing * 0.5; + let mut li = 0u32; + let reach = spacing * 3.0 + ew; + while s < nmax && li < 20_000 { + scan_runs(g, fill, ew, d, n, s, &mut runs); + for (ri, &(a, bb)) in runs.iter().enumerate() { + let k = (gi as u32) << 20 | ri as u32; + let ov = spacing * (overlap + overlap_var * rnd(li, k * 4) as f64 * 0.5).max(-0.45); + let ov2 = spacing * (overlap + overlap_var * rnd(li, k * 4 + 1) as f64 * 0.5).max(-0.45); + let (a, bb) = (a - ov, bb + ov2); + if bb <= a { + continue; + } + let at = |t: f64| [d[0] * t + n[0] * s, d[1] * t + n[1] * s]; + let (pa, pb) = (at(a), at(bb)); + let dist = |p: [f64; 2], q: [f64; 2]| ((p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2)).sqrt(); + let best = chains + .iter() + .enumerate() + .filter(|(_, c)| li > 0 && c.1 == li - 1) + .map(|(i, c)| { + let last = *c.0.last().unwrap_or(&pa); + (i, dist(last, pa).min(dist(last, pb))) + }) + .filter(|&(_, dd)| dd < reach) + .min_by(|x, y| x.1.total_cmp(&y.1)); + match best { + Some((ci, _)) => { + let last = *chains[ci].0.last().unwrap_or(&pa); + let (p0, p1, dir) = if dist(last, pa) <= dist(last, pb) { (pa, pb, -1.0) } else { (pb, pa, 1.0) }; + // Curved turn: an extra point pushed past the edge. + let c = (curv + curv_var * rnd(li, k * 4 + 2) as f64 * 0.5).max(0.0); + let mid = [(last[0] + p0[0]) * 0.5 + d[0] * dir * c * spacing, (last[1] + p0[1]) * 0.5 + d[1] * dir * c * spacing]; + let ch = &mut chains[ci]; + ch.0.extend([mid, p0, p1]); + ch.1 = li; + } + None => chains.push((vec![pa, pb], li)), + } + } + s += spacing * (1.0 + spacing_var * rnd(li, 3) as f64 * 0.5).max(0.25); + li += 1; + } + lines.push(chains.into_iter().map(|(z, _)| if curv > 0.0 { chaikin(&z, 2) } else { z }).collect()); + } + let r = width * 0.5; + let mut segs = Vec::new(); + for group in &lines { + let lens: Vec = group.iter().map(|l| poly_length(l, false)).collect(); + let total: f64 = lens.iter().sum(); + let (g0, g1) = (total * start, total * end); + let mut acc = 0.0; + for (l, len) in group.iter().zip(lens) { + let part = trim_poly(l, false, (g0 - acc).clamp(0.0, len), (g1 - acc).clamp(0.0, len)); + acc += len; + poly_segs(&part, false, r, 1.0, &mut segs); + } + } + let cov = raster_segs(w, h, &segs, 0.9); + let color = pr.color("color"); + paint(&mut b.img, &cov, color, (pr.f("opacity") / 100.0) as f32, style); + b +} + +// ---------------------------------------------------------------- Stroke + +fn stroke(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let all = pr.b("allMasks"); + let polys: Vec<_> = if all { all_masks_px(ctx, &b) } else { mask_px(ctx, &b, pr.f("path").round() as usize).into_iter().collect() }; + let style = pr.e("paintStyle"); + let (w, h) = (b.img.width as usize, b.img.height as usize); + let size = (pr.f("brushSize") * b.scale).max(0.1); + let r = size * 0.5; + let hard = pr.f("brushHardness") / 100.0; + let start = pr.f("start").clamp(0.0, 100.0) / 100.0; + let end = pr.f("end").clamp(0.0, 100.0) / 100.0; + let spacing = pr.f("spacing") / 100.0; + let sequential = pr.b("strokeSequentially"); + let lens: Vec = polys.iter().map(|(p, c, _)| poly_length(p, *c)).collect(); + let total: f64 = lens.iter().sum(); + let mut segs = Vec::new(); + let mut acc = 0.0; + for ((pts, closed, _), &len) in polys.iter().zip(&lens) { + let (s0, s1) = if all && sequential { + let (g0, g1) = (total * start, total * end); + ((g0 - acc).clamp(0.0, len), (g1 - acc).clamp(0.0, len)) + } else { + (len * start, len * end) + }; + acc += len; + if s1 <= s0 { + continue; + } + if spacing <= 0.5 { + let part = trim_poly(pts, *closed, s0, s1); + poly_segs(&part, false, r, 1.0, &mut segs); + } else { + let step = (size * spacing).max(0.5); + let mut s = s0; + while s <= s1 + 1e-9 && segs.len() < 200_000 { + let (q, _) = crate::util::poly_point_at(pts, *closed, s); + segs.push(Seg { a: q, b: q, r, v: 1.0 }); + s += step; + } + } + } + let cov = raster_segs(w, h, &segs, hard); + paint(&mut b.img, &cov, pr.color("color"), (pr.f("opacity") / 100.0) as f32, style); + b +} + +// ---------------------------------------------------------------- Vegas + +/// Marching-squares iso-contours of `pl` at `thr`, chained into polylines (closed flag). +fn contours(pl: &Plane, thr: f32) -> Vec<(Vec<[f64; 2]>, bool)> { + use std::collections::HashMap; + let (w, h) = (pl.w, pl.h); + if w < 2 || h < 2 { + return Vec::new(); + } + // Edge key: horizontal edge (x,y)-(x+1,y) = 2*(y*w+x), vertical (x,y)-(x,y+1) = 2*(y*w+x)+1. + let hk = |x: usize, y: usize| 2 * (y * w + x) as u64; + let vk = |x: usize, y: usize| 2 * (y * w + x) as u64 + 1; + let mut pos: HashMap = HashMap::new(); + let mut adj: HashMap> = HashMap::new(); + let v = |x: usize, y: usize| pl.data[y * w + x]; + let interp = |a: f32, b: f32| if (b - a).abs() > 1e-9 { ((thr - a) / (b - a)).clamp(0.0, 1.0) as f64 } else { 0.5 }; + for y in 0..h - 1 { + for x in 0..w - 1 { + let (a, bb, c, d) = (v(x, y), v(x + 1, y), v(x + 1, y + 1), v(x, y + 1)); + let idx = (a > thr) as u8 | ((bb > thr) as u8) << 1 | ((c > thr) as u8) << 2 | ((d > thr) as u8) << 3; + if idx == 0 || idx == 15 { + continue; + } + // Edge points (pixel centres at +0.5). + let top = (hk(x, y), [x as f64 + 0.5 + interp(a, bb), y as f64 + 0.5]); + let right = (vk(x + 1, y), [x as f64 + 1.5, y as f64 + 0.5 + interp(bb, c)]); + let bottom = (hk(x, y + 1), [x as f64 + 0.5 + interp(d, c), y as f64 + 1.5]); + let left = (vk(x, y), [x as f64 + 0.5, y as f64 + 0.5 + interp(a, d)]); + let pairs: &[(usize, usize)] = match idx { + 1 | 14 => &[(3, 0)], + 2 | 13 => &[(0, 1)], + 3 | 12 => &[(3, 1)], + 4 | 11 => &[(1, 2)], + 5 => &[(3, 0), (1, 2)], + 6 | 9 => &[(0, 2)], + 7 | 8 => &[(3, 2)], + 10 => &[(0, 1), (2, 3)], + _ => &[], + }; + let e = [top, right, bottom, left]; + for &(i, j) in pairs { + pos.insert(e[i].0, e[i].1); + pos.insert(e[j].0, e[j].1); + adj.entry(e[i].0).or_default().push(e[j].0); + adj.entry(e[j].0).or_default().push(e[i].0); + } + } + } + let mut keys: Vec = adj.keys().copied().collect(); + keys.sort_unstable(); + // Start open chains at endpoints (degree 1) first, then closed loops. + keys.sort_by_key(|k| adj[k].len() != 1); + let mut used: std::collections::HashSet<(u64, u64)> = std::collections::HashSet::new(); + let mut out = Vec::new(); + for &k0 in &keys { + for &first in &adj[&k0] { + if used.contains(&(k0.min(first), k0.max(first))) { + continue; + } + let mut chain = vec![pos[&k0]]; + let (mut prev, mut cur) = (k0, first); + used.insert((prev.min(cur), prev.max(cur))); + let mut closed = false; + loop { + chain.push(pos[&cur]); + if cur == k0 { + closed = true; + chain.pop(); + break; + } + let next = adj[&cur].iter().copied().find(|&n| n != prev && !used.contains(&(cur.min(n), cur.max(n)))); + match next { + Some(n) => { + used.insert((cur.min(n), cur.max(n))); + prev = cur; + cur = n; + } + None => break, + } + } + if chain.len() >= 2 { + out.push((chain, closed)); + } + } + } + out +} + +fn vegas(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let (w, h) = (b.img.width as usize, b.img.height as usize); + let sc = b.scale; + let paths: Vec<(Vec<[f64; 2]>, bool)> = if pr.e("stroke") == 1 { + mask_px(ctx, &b, pr.f("path").round() as usize).map(|(p, c, _)| vec![(p, c)]).unwrap_or_default() + } else { + let src = layer_or_self(ctx, &b, "inputLayer", true, pr.e("ifLayerSizesDiffer") == 1); + let ch = pr.e("channel"); + let inv = pr.b("invertInput"); + let mut pl = Plane::from_image(&src, |px| { + let (c, a) = unpremul(px); + let v = match ch { + 1 => c[0], + 2 => c[1], + 3 => c[2], + 4 => a, + _ => luminance(c[0], c[1], c[2]) * a, + }; + if inv { 1.0 - v } else { v } + }); + let blur = pr.f("preBlur") * sc; + if blur > 0.05 { + pl = gauss_plane(&pl, blur, blur); + } + let mut cs = contours(&pl, (pr.f("threshold") / 100.0) as f32); + // Simplify by tolerance (drop points closer than tol × size). + let tol = pr.f("tolerance").max(0.0) * (w.max(h) as f64) * 0.1; + if tol > 0.0 { + for (c, _) in cs.iter_mut() { + let mut keep: Vec<[f64; 2]> = Vec::with_capacity(c.len()); + for &q in c.iter() { + if keep.last().is_none_or(|l: &[f64; 2]| ((l[0] - q[0]).powi(2) + (l[1] - q[1]).powi(2)).sqrt() >= tol) { + keep.push(q); + } + } + *c = keep; + } + } + cs.sort_by(|a, b| poly_length(&b.0, b.1).total_cmp(&poly_length(&a.0, a.1))); + if pr.e("renderMode") == 1 { + let sel = (pr.f("selectedContour").round().max(1.0) as usize) - 1; + cs.into_iter().skip(sel).take(1).collect() + } else { + cs + } + }; + let nseg = pr.f("segments").round().clamp(1.0, 1000.0) as usize; + let seg_len = pr.f("length").clamp(0.0, 1.0); + let bunched = pr.e("segmentDistribution") == 0; + let rot = pr.f("rotation") / 360.0; + let random_phase = pr.b("randomPhase"); + let seed = pr.f("randomSeed") as i64 as u32 ^ ctx.seed; + let width = (pr.f("width") * sc).max(0.1); + let hard = pr.f("hardness").clamp(0.0, 1.0); + let (o0, om, o1, mp) = (pr.f("startOpacity") as f32, pr.f("midpointOpacity") as f32, pr.f("endOpacity") as f32, pr.f("midpointPosition").clamp(0.01, 0.99)); + let mut segs = Vec::new(); + for (ci, (pts, closed)) in paths.iter().enumerate() { + let len = poly_length(pts, *closed); + if len <= 0.0 { + continue; + } + let phase = rot + if random_phase { hash1(ci as u32, 1, seed) as f64 } else { 0.0 }; + let each = len / nseg as f64 * seg_len; + for i in 0..nseg { + let base = if bunched { (i as f64 + (hash1(ci as u32, i as u32 + 7, seed) as f64 - 0.5) * 0.8) / nseg as f64 } else { i as f64 / nseg as f64 }; + let s0 = (base + phase).rem_euclid(1.0) * len; + // Sub-sample the segment so opacity can ramp along it. + let sub = 12usize; + let mut prev: Option<[f64; 2]> = None; + for k in 0..=sub { + let f = k as f64 / sub as f64; + let mut s = s0 + each * f; + if *closed { + s = s.rem_euclid(len); + } else if s > len { + break; + } + let (q, _) = crate::util::poly_point_at(pts, *closed, s); + let op = if f < mp { o0 + (om - o0) * (f / mp) as f32 } else { om + (o1 - om) * ((f - mp) / (1.0 - mp)) as f32 }; + if let Some(pv) = prev + && ((pv[0] - q[0]).powi(2) + (pv[1] - q[1]).powi(2)) < (each * 0.5).powi(2) + 4.0 + { + segs.push(Seg { a: pv, b: q, r: width * 0.5, v: op.clamp(0.0, 1.0) }); + } + prev = Some(q); + } + } + } + let cov = raster_segs(w, h, &segs, hard); + let color = pr.color("color"); + match pr.e("blendMode") { + 1 => paint(&mut b.img, &cov, color, 1.0, 0), + 2 => { + b.img.data.par_iter_mut().zip(cov.data.par_iter()).for_each(|(px, &c)| { + let a = c * color[3]; + *px = over([color[0] * a, color[1] * a, color[2] * a, a], *px); + }); + } + 3 => paint(&mut b.img, &cov, color, 1.0, 2), + _ => paint(&mut b.img, &cov, color, 1.0, 1), + } + b +} + +// ---------------------------------------------------------------- Write-on / Glue Gun + +/// Brush dab history: only the brush position at the current time is known to an effect +/// (parameter histories are not passed in), so the stroke is drawn as the dabs laid along +/// the current position — a single dab when the brush is static. Deterministic. +fn write_on(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let (w, h) = (b.img.width as usize, b.img.height as usize); + let q = b.to_px(pr.v2("brushPosition")); + let r = (pr.f("brushSize") * b.scale * 0.5).max(0.05); + let cov = raster_segs(w, h, &[Seg { a: [q.0, q.1], b: [q.0, q.1], r, v: 1.0 }], pr.f("brushHardness") / 100.0); + paint(&mut b.img, &cov, pr.color("color"), (pr.f("brushOpacity") / 100.0) as f32, pr.e("paintStyle")); + b +} + +fn glue_gun(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let q = b.to_px(pr.v2("brushPosition")); + let rad = (pr.f("strokeWidth") * b.scale * 0.5).max(0.5); + let density = pr.f("density").max(0.0); + let refl = pr.f("reflection").to_radians(); + let strength = (pr.f("strength") / 100.0) as f32; + let glue_only = pr.e("style") == 1; + let src = b.img.clone(); + let light = [refl.cos() * 0.6, -refl.sin() * 0.6, 0.53]; + let beads = (density.round() as usize).clamp(1, 32); + let seed = ctx.seed; + let centers: Vec<([f64; 2], f64)> = (0..beads) + .map(|i| { + let a = hash1(i as u32, 1, seed) as f64 * 2.0 * PI; + let d = if i == 0 { 0.0 } else { hash1(i as u32, 2, seed) as f64 * rad * 0.35 }; + ([q.0 + a.cos() * d, q.1 + a.sin() * d], rad * (0.75 + 0.25 * hash1(i as u32, 3, seed) as f64)) + }) + .collect(); + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let (fx, fy) = (x as f64 + 0.5, y as f64 + 0.5); + // Height field: union of hemispheres. + let mut best = (0.0f64, [0.0f64; 2], 1.0f64); + for &(c, r) in ¢ers { + let (dx, dy) = ((fx - c[0]) / r, (fy - c[1]) / r); + let d2 = dx * dx + dy * dy; + if d2 < 1.0 { + let hgt = (1.0 - d2).sqrt(); + if hgt > best.0 { + best = (hgt, [dx, dy], r); + } + } + } + if best.0 <= 0.0 { + if glue_only { + *px = [0.0; 4]; + } + continue; + } + let nrm = [best.1[0], best.1[1], best.0]; + let ndl = (nrm[0] * light[0] + nrm[1] * light[1] + nrm[2] * light[2]).max(0.0); + let spec = ndl.powi(24) as f32; + // Refraction: look through the bead. + let s = src.sample_bilinear_clamped(fx - nrm[0] * best.2 * 0.5 * strength as f64, fy - nrm[1] * best.2 * 0.5 * strength as f64); + let edge = smoothstep(0.0, 0.15, best.0 as f32); + let shade = 0.65 + 0.35 * ndl as f32; + let g = [s[0] * shade + spec, s[1] * shade + spec, s[2] * shade + spec, s[3].max(0.6)]; + let gp = [g[0].min(g[3] + spec), g[1].min(g[3] + spec), g[2].min(g[3] + spec), g[3].min(1.0)]; + let base = if glue_only { [0.0; 4] } else { *px }; + *px = crate::util::lerp4(base, gp, edge * strength.clamp(0.0, 1.0).max(0.3)); + px[3] = px[3].clamp(0.0, 1.0); + } + }); + b +} + +// ---------------------------------------------------------------- Paint Bucket + +fn paint_bucket(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let (w, h) = (b.img.width as usize, b.img.height as usize); + if w == 0 || h == 0 { + return b; + } + let fp = b.to_px(pr.v2("fillPoint")); + let (sx, sy) = (fp.0.floor() as i64, fp.1.floor() as i64); + let sel = pr.e("fillSelector"); + let tol = (pr.f("tolerance") / 255.0) as f32; + let key = |p: Px| -> [f32; 4] { + let (c, a) = unpremul(p); + match sel { + 1 => [c[0], c[1], c[2], 0.0], + 2 => [0.0, 0.0, 0.0, if a <= 1e-3 { 0.0 } else { 1.0 }], + 3 | 4 => [0.0, 0.0, 0.0, a], + _ => p, + } + }; + let mut region = Plane::new(w, h); + if sx >= 0 && sy >= 0 && (sx as usize) < w && (sy as usize) < h { + let seed = key(b.img.data[sy as usize * w + sx as usize]); + let within = |i: usize| { + let k = key(b.img.data[i]); + if sel == 2 { + return k[3] == seed[3]; + } + (0..4).all(|j| (k[j] - seed[j]).abs() <= tol + 1e-6) + }; + let mut seen = vec![false; w * h]; + let mut stack = vec![(sx as usize, sy as usize)]; + while let Some((x, y)) = stack.pop() { + let row = y * w; + if seen[row + x] || !within(row + x) { + continue; + } + let mut l = x; + while l > 0 && !seen[row + l - 1] && within(row + l - 1) { + l -= 1; + } + let mut r = x; + while r + 1 < w && !seen[row + r + 1] && within(row + r + 1) { + r += 1; + } + for i in l..=r { + seen[row + i] = true; + region.data[row + i] = 1.0; + if y > 0 && !seen[row - w + i] { + stack.push((i, y - 1)); + } + if y + 1 < h && !seen[row + w + i] { + stack.push((i, y + 1)); + } + } + } + } + if pr.b("viewThreshold") { + b.img.data.par_iter_mut().zip(region.data.par_iter()).for_each(|(px, &v)| *px = [v, v, v, 1.0]); + return b; + } + let sc = b.scale; + let mut cov = match pr.e("stroke") { + 1 => { + let s = pr.f("featherSoftness") * sc * 0.5; + if s > 0.05 { gauss_plane(®ion, s, s) } else { region.clone() } + } + 2 => morph_frac(®ion, pr.f("spreadRadius") * sc, true), + 3 => morph_frac(®ion, pr.f("spreadRadius") * sc, false), + 4 => { + let r = pr.f("strokeWidth") * sc; + let out = morph_frac(®ion, r * 0.5, true); + let inn = morph_frac(®ion, r * 0.5, false); + out.zip_map(&inn, |a, b| (a - b).max(0.0)) + } + _ => gauss_plane(®ion, 0.5, 0.5), + }; + if pr.e("stroke") == 0 { + // Antialias: keep the hard interior, soften only the boundary. + cov = cov.zip_map(®ion, |a, r| if r > 0.0 { a.max(0.5) } else { a }); + } + let color = pr.color("color"); + let op = (pr.f("opacity") / 100.0) as f32; + let mode = pr.e("blendingMode"); + b.img.data.par_iter_mut().zip(cov.data.par_iter()).for_each(|(px, &c)| { + let k = (c * op * color[3]).clamp(0.0, 1.0); + if k <= 0.0 { + if mode == 9 { + *px = [0.0; 4]; + } + return; + } + let (dc, da) = unpremul(*px); + let fc = [color[0], color[1], color[2]]; + let mixed = match mode { + 1 => [dc[0] + fc[0], dc[1] + fc[1], dc[2] + fc[2]], + 2 => [dc[0] * fc[0], dc[1] * fc[1], dc[2] * fc[2]], + 3 => [1.0 - (1.0 - dc[0]) * (1.0 - fc[0]), 1.0 - (1.0 - dc[1]) * (1.0 - fc[1]), 1.0 - (1.0 - dc[2]) * (1.0 - fc[2])], + 4..=7 => { + let (dh, ds, dl) = rgb_to_hsl(dc[0], dc[1], dc[2]); + let (fh, fs, fl) = rgb_to_hsl(fc[0], fc[1], fc[2]); + let (r, g, bl) = match mode { + 4 => hsl_to_rgb(fh, ds, dl), + 5 => hsl_to_rgb(dh, fs, dl), + 6 => hsl_to_rgb(fh, fs, dl), + _ => hsl_to_rgb(dh, ds, fl), + }; + [r, g, bl] + } + _ => fc, + }; + *px = match mode { + 8 => over(premul(fc, k), *px), + 9 => premul(fc, k), + 0 => over(*px, premul(fc, k)), + _ => { + if da <= 1e-6 { + return; + } + let c3 = crate::util::lerp3(dc, mixed, k); + premul(c3, da) + } + }; + }); + b +} + +// ---------------------------------------------------------------- Eyedropper Fill + +fn eyedropper_fill(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let (w, h) = (b.img.width as i64, b.img.height as i64); + let c = b.to_px(pr.v2("samplePoint")); + let r = (pr.f("sampleRadius") * b.scale).max(0.0); + let mode = pr.e("averagePixelColors"); + let (cx, cy) = (c.0.floor() as i64, c.1.floor() as i64); + let ri = r.ceil() as i64; + let mut acc = [0.0f64; 4]; + let mut n = 0.0f64; + for y in (cy - ri).max(0)..=(cy + ri).min(h - 1) { + for x in (cx - ri).max(0)..=(cx + ri).min(w - 1) { + if ((x - cx).pow(2) + (y - cy).pow(2)) as f64 > r * r + 0.5 { + continue; + } + let px = b.img.data[(y * w + x) as usize]; + match mode { + 0 => { + if px[3] > 1e-4 { + let (s, _) = unpremul(px); + for k in 0..3 { + acc[k] += s[k] as f64; + } + acc[3] += 1.0; + n += 1.0; + } + } + 1 => { + let (s, _) = unpremul(px); + for k in 0..3 { + acc[k] += s[k] as f64; + } + acc[3] += 1.0; + n += 1.0; + } + 2 => { + for k in 0..3 { + acc[k] += px[k] as f64; + } + acc[3] += 1.0; + n += 1.0; + } + _ => { + for k in 0..4 { + acc[k] += px[k] as f64; + } + n += 1.0; + } + } + } + } + let fill: Px = if n > 0.0 { + if mode == 3 { + let a = (acc[3] / n) as f32; + let s = if a > 1e-6 { [(acc[0] / n) as f32 / a, (acc[1] / n) as f32 / a, (acc[2] / n) as f32 / a] } else { [0.0; 3] }; + [s[0], s[1], s[2], a] + } else { + [(acc[0] / n) as f32, (acc[1] / n) as f32, (acc[2] / n) as f32, 1.0] + } + } else { + [0.0, 0.0, 0.0, 0.0] + }; + let keep_alpha = pr.b("maintainOriginalAlpha"); + let blend = (pr.f("blendWithOriginal") / 100.0) as f32; + b.img.data.par_iter_mut().for_each(|px| { + let a = if keep_alpha { px[3] } else { fill[3] }; + let f = premul([fill[0], fill[1], fill[2]], a); + *px = crate::util::lerp4(f, *px, blend); + }); + b +} + +// ---------------------------------------------------------------- CC Threads + +fn threads(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let sc = b.scale; + let tw = (pr.f("width") * sc).max(1.0); + let th = (pr.f("height") * sc).max(1.0); + let overlaps = pr.f("overlaps").round().clamp(1.0, 20.0) as i64; + let ang = pr.f("direction").to_radians(); + let (sn, cs) = ang.sin_cos(); + let c = b.to_px(pr.v2("center")); + let coverage = (pr.f("coverage") / 100.0).clamp(0.0, 1.0); + let shadow = (pr.f("shadowing") / 100.0) as f32; + let texture = (pr.f("texture") / 100.0) as f32; + let src = b.img.clone(); + let to_px = |u: f64, v: f64| (c.0 + u * cs - v * sn, c.1 + u * sn + v * cs); + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let (dx, dy) = (x as f64 + 0.5 - c.0, y as f64 + 0.5 - c.1); + let u = dx * cs + dy * sn; + let v = -dx * sn + dy * cs; + let (ci, cj) = ((u / tw).floor() as i64, (v / th).floor() as i64); + let (fu, fv) = (u / tw - ci as f64, v / th - cj as f64); + // Horizontal thread j occupies the band |fv-0.5| < coverage/2; vertical thread i the + // band |fu-0.5| < coverage/2. + let in_h = (fv - 0.5).abs() < coverage * 0.5; + let in_v = (fu - 0.5).abs() < coverage * 0.5; + // Twill: horizontal is on top when (i + j) mod (2·overlaps) < overlaps. + let h_top = (ci + cj).rem_euclid(2 * overlaps) < overlaps; + let which = match (in_h, in_v) { + (true, true) => Some(h_top), + (true, false) => Some(true), + (false, true) => Some(false), + _ => None, + }; + let Some(horizontal) = which else { + *px = [0.0; 4]; + continue; + }; + // Thread colour: the source sampled along the thread centre line. + let (sx, sy) = if horizontal { to_px(u, (cj as f64 + 0.5) * th) } else { to_px((ci as f64 + 0.5) * tw, v) }; + let s = src.sample_bilinear_clamped(sx, sy); + // Round profile across the thread + darkening where it dives under its neighbour. + let across = if horizontal { (fv - 0.5) / (coverage * 0.5).max(1e-6) } else { (fu - 0.5) / (coverage * 0.5).max(1e-6) }; + let along = if horizontal { fu } else { fv }; + let profile = (1.0 - across * across).max(0.0).sqrt() as f32; + let dive = 1.0 - (along - 0.5).abs() as f32 * 2.0; + let shade = 1.0 - shadow * (1.0 - profile) * 0.8 - shadow * 0.25 * (1.0 - dive) * if horizontal == h_top { 0.0 } else { 1.0 }; + let fib = + if texture > 0.0 { 1.0 - texture * 0.3 * hash1((along * 64.0) as u32, (across * 8.0 + 8.0) as u32, ci as u32 ^ (cj as u32) << 8) } else { 1.0 }; + let k = (shade * fib).max(0.0); + *px = [s[0] * k, s[1] * k, s[2] * k, s[3]]; + } + }); + b +} + +// ---------------------------------------------------------------- Audio Spectrum / Waveform + +/// In-place iterative radix-2 FFT (`re`, `im` lengths a power of two). +pub(crate) fn fft(re: &mut [f64], im: &mut [f64]) { + let n = re.len(); + if n < 2 { + return; + } + let mut j = 0; + for i in 1..n { + let mut bit = n >> 1; + while j & bit != 0 { + j ^= bit; + bit >>= 1; + } + j |= bit; + if i < j { + re.swap(i, j); + im.swap(i, j); + } + } + let mut len = 2; + while len <= n { + let ang = -2.0 * PI / len as f64; + let (ws, wc) = ang.sin_cos(); + for start in (0..n).step_by(len) { + let (mut cr, mut ci) = (1.0, 0.0); + for k in 0..len / 2 { + let (a, bb) = (start + k, start + k + len / 2); + let tr = re[bb] * cr - im[bb] * ci; + let ti = re[bb] * ci + im[bb] * cr; + re[bb] = re[a] - tr; + im[bb] = im[a] - ti; + re[a] += tr; + im[a] += ti; + let ncr = cr * wc - ci * ws; + ci = cr * ws + ci * wc; + cr = ncr; + } + } + len <<= 1; + } +} + +const AUDIO_RATE: u32 = 48_000; + +/// Fetch mono (or one channel) samples of the audio layer around the current time. +fn audio_window(ctx: &EffectCtx, dur_ms: f64, offset_ms: f64, channel: u32) -> Option> { + let id = ctx.params.get("audioLayer")?.as_layer()?; + let host = ctx.env.host?; + let dur = (dur_ms / 1000.0).max(0.001); + let frames = ((dur * AUDIO_RATE as f64) as usize).clamp(16, 1 << 16); + let start = ctx.env.comp_time + offset_ms / 1000.0 - dur * 0.5; + let s = host.audio(id, start, frames, AUDIO_RATE)?; + Some( + (0..frames) + .map(|i| { + let (l, r) = (*s.get(2 * i).unwrap_or(&0.0) as f64, *s.get(2 * i + 1).unwrap_or(&0.0) as f64); + match channel { + 1 => l, + 2 => r, + _ => (l + r) * 0.5, + } + }) + .collect(), + ) +} + +/// The drawing baseline: a polyline in buffer px (mask path or start→end line), sampled at +/// parameter u∈[0,1] → (point, unit normal). +struct Baseline { + pts: Vec<[f64; 2]>, + closed: bool, + len: f64, + polar: bool, + center: [f64; 2], + radius: f64, +} + +impl Baseline { + fn new(ctx: &EffectCtx, b: &Buf) -> Baseline { + let pr = ctx.params; + let polar = pr.b("usePolarPath"); + let (pts, closed) = match mask_px(ctx, b, pr.f("path").round() as usize) { + Some((p, c, _)) if !polar => (p, c), + _ => { + let s = b.to_px(pr.v2("startPoint")); + let e = b.to_px(pr.v2("endPoint")); + (vec![[s.0, s.1], [e.0, e.1]], false) + } + }; + let len = poly_length(&pts, closed); + let center = pts[0]; + let radius = if pts.len() > 1 { ((pts[1][0] - pts[0][0]).powi(2) + (pts[1][1] - pts[0][1]).powi(2)).sqrt() } else { 1.0 }; + Baseline { pts, closed, len, polar, center, radius } + } + fn at(&self, u: f64) -> ([f64; 2], [f64; 2]) { + if self.polar { + let a = u * 2.0 * PI - PI * 0.5; + let (s, c) = a.sin_cos(); + return ([self.center[0] + c * self.radius * 0.5, self.center[1] + s * self.radius * 0.5], [c, s]); + } + let (q, d) = crate::util::poly_point_at(&self.pts, self.closed, u * self.len); + (q, [d[1], -d[0]]) + } +} + +/// Draw line/dot marks with core (inside colour) and soft halo (outside colour). +fn draw_marks(b: &mut Buf, segs_core: &[Seg], thickness: f64, softness: f64, inside: [f32; 4], outside: [f32; 4], composite: bool) { + let (w, h) = (b.img.width as usize, b.img.height as usize); + let core = raster_segs(w, h, segs_core, 1.0); + let halo_segs: Vec = segs_core.iter().map(|s| Seg { r: s.r + thickness * softness.max(0.0) + 0.5, ..*s }).collect(); + let halo = if softness > 0.0 { raster_segs(w, h, &halo_segs, 0.0) } else { core.clone() }; + b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { + let (c, hl) = (core.data[i], halo.data[i]); + let a = c.max(hl); + let base = if composite { *px } else { [0.0; 4] }; + if a <= 0.0 { + *px = base; + return; + } + let t = if a > 0.0 { c / a } else { 0.0 }; + let col = crate::util::lerp3([outside[0], outside[1], outside[2]], [inside[0], inside[1], inside[2]], t); + *px = over(base, premul(col, a)); + }); +} + +fn audio_spectrum(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let Some(samples) = audio_window(ctx, pr.f("audioDuration"), pr.f("audioOffset"), 0) else { return b }; + let n = samples.len().next_power_of_two().max(64); + let mut re = vec![0.0; n]; + let mut im = vec![0.0; n]; + let m = samples.len(); + for (i, s) in samples.iter().enumerate() { + // Hann window. + re[i] = s * (0.5 - 0.5 * (2.0 * PI * i as f64 / (m.max(2) - 1) as f64).cos()); + } + fft(&mut re, &mut im); + let bands = pr.f("frequencyBands").round().clamp(1.0, 4096.0) as usize; + let f0 = pr.f("startFrequency").max(1.0); + let f1 = pr.f("endFrequency").max(f0 + 1.0); + let hz_per_bin = AUDIO_RATE as f64 / n as f64; + let mag = |f: f64| { + let k = ((f / hz_per_bin).round() as usize).min(n / 2); + (re[k] * re[k] + im[k] * im[k]).sqrt() * 4.0 / m as f64 + }; + let max_h = pr.f("maximumHeight") * b.scale; + let thick = (pr.f("thickness") * b.scale).max(0.1); + let soft = pr.f("softness") / 100.0; + let disp = pr.e("displayOptions"); + let side = pr.e("sideOptions"); + let base = Baseline::new(ctx, &b); + let mut tops = Vec::with_capacity(bands); + let mut segs = Vec::new(); + for i in 0..bands { + let u = if bands > 1 { i as f64 / (bands - 1) as f64 } else { 0.5 }; + let fa = f0 * (f1 / f0).powf(i as f64 / bands as f64); + let fb = f0 * (f1 / f0).powf((i + 1) as f64 / bands as f64); + let mut v = 0.0f64; + let k = 4; + for j in 0..k { + v = v.max(mag(fa + (fb - fa) * j as f64 / k as f64)); + } + let db = (20.0 * v.max(1e-9).log10() + 60.0) / 60.0; + let hgt = db.clamp(0.0, 1.0) * max_h; + let (q, nrm) = base.at(u); + let (ua, ub) = match side { + 0 => (0.0, hgt), + 1 => (-hgt, 0.0), + _ => (-hgt, hgt), + }; + let pa = [q[0] + nrm[0] * ua, q[1] + nrm[1] * ua]; + let pb = [q[0] + nrm[0] * ub, q[1] + nrm[1] * ub]; + match disp { + 0 => segs.push(Seg { a: pa, b: pb, r: thick * 0.5, v: 1.0 }), + _ => tops.push((pa, pb)), + } + } + if disp == 1 { + for w2 in tops.windows(2) { + segs.push(Seg { a: w2[0].1, b: w2[1].1, r: thick * 0.5, v: 1.0 }); + if side == 2 { + segs.push(Seg { a: w2[0].0, b: w2[1].0, r: thick * 0.5, v: 1.0 }); + } + } + } else if disp == 2 { + for &(pa, pb) in &tops { + segs.push(Seg { a: pb, b: pb, r: thick * 0.5, v: 1.0 }); + if side == 2 { + segs.push(Seg { a: pa, b: pa, r: thick * 0.5, v: 1.0 }); + } + } + } + draw_marks(&mut b, &segs, thick, soft * 2.0, pr.color("insideColor"), pr.color("outsideColor"), pr.b("compositeOnOriginal")); + b +} + +fn audio_waveform(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let Some(samples) = audio_window(ctx, pr.f("audioDuration"), pr.f("audioOffset"), pr.e("waveformOptions")) else { return b }; + let count = pr.f("displayedSamples").round().clamp(2.0, 4096.0) as usize; + let max_h = pr.f("maximumHeight") * b.scale; + let thick = (pr.f("thickness") * b.scale).max(0.1); + let soft = pr.f("softness") / 100.0; + let disp = pr.e("displayOptions"); + let base = Baseline::new(ctx, &b); + let m = samples.len(); + let mut pts = Vec::with_capacity(count); + let mut segs = Vec::new(); + for i in 0..count { + let u = i as f64 / (count - 1) as f64; + let a = ((u * (m - 1) as f64) as usize).min(m - 1); + let bnd = (((i + 1) as f64 / count as f64 * (m - 1) as f64) as usize).clamp(a, m - 1); + // Peak with sign of the window for a stable digital display. + let (mut lo, mut hi) = (0.0f64, 0.0f64); + for s in &samples[a..=bnd] { + lo = lo.min(*s); + hi = hi.max(*s); + } + let (q, nrm) = base.at(u); + match disp { + 0 => segs.push(Seg { + a: [q[0] + nrm[0] * lo * max_h, q[1] + nrm[1] * lo * max_h], + b: [q[0] + nrm[0] * hi * max_h, q[1] + nrm[1] * hi * max_h], + r: thick * 0.5, + v: 1.0, + }), + _ => { + let v = samples[a] * max_h; + pts.push([q[0] + nrm[0] * v, q[1] + nrm[1] * v]); + } + } + } + if disp == 1 { + poly_segs(&pts, false, thick * 0.5, 1.0, &mut segs); + } else if disp == 2 { + segs.extend(pts.iter().map(|&q| Seg { a: q, b: q, r: thick * 0.5, v: 1.0 })); + } + draw_marks(&mut b, &segs, thick, soft * 2.0, pr.color("insideColor"), pr.color("outsideColor"), pr.b("compositeOnOriginal")); + b +} + +// ---------------------------------------------------------------- registry pub fn specs() -> Vec { - Vec::new() + let ang = || ParamUi::Angle; + let check = |id: &'static str, name: &'static str, v: bool| p(id, name, Value::Bool(v), ParamUi::Checkbox); + let paint_style = || popup(&["On Original Image", "On Transparent", "Reveal Original Image"]); + let mask_idx = |id: &'static str, name: &'static str, d: f64| p(id, name, num(d), slider(0.0, 32.0, 0.0, 8.0, 0)); + let display = || popup(&["Digital", "Analog lines", "Analog dots"]); + vec![ + spec( + "ec.generate.fractal", + "Fractal", + vec![ + p( + "setChoice", + "Set Choice", + Value::Enum(0), + popup(&["Mandelbrot", "Mandelbrot Inverse", "Mandelbrot Over Inverse", "Mandelbrot Inverse Over Mandelbrot", "Julia", "Julia Inverse"]), + ), + p("equation", "Equation", Value::Enum(0), popup(&["z = z^2 + c", "z = z^3 + c", "z = z^4 + c"])), + p("mandelbrotX", "Mandelbrot: X (Real)", num(-0.74), slider(-2.0, 2.0, -2.0, 2.0, 4)), + p("mandelbrotY", "Mandelbrot: Y (Imaginary)", num(0.15), slider(-2.0, 2.0, -2.0, 2.0, 4)), + p("mandelbrotMagnification", "Mandelbrot: Magnification", num(0.0), slider(-5.0, 30.0, -5.0, 30.0, 2)), + p("mandelbrotEscapeLimit", "Mandelbrot: Escape Limit", num(4.0), slider(1.0, 1000.0, 1.0, 100.0, 1)), + p("juliaX", "Julia: X (Real)", num(0.0), slider(-2.0, 2.0, -2.0, 2.0, 4)), + p("juliaY", "Julia: Y (Imaginary)", num(0.0), slider(-2.0, 2.0, -2.0, 2.0, 4)), + p("juliaMagnification", "Julia: Magnification", num(0.0), slider(-5.0, 30.0, -5.0, 30.0, 2)), + p("juliaEscapeLimit", "Julia: Escape Limit", num(4.0), slider(1.0, 1000.0, 1.0, 100.0, 1)), + p("postInversionX", "Post-Inversion Offset: X Offset", num(0.0), slider(-2.0, 2.0, -2.0, 2.0, 4)), + p("postInversionY", "Post-Inversion Offset: Y Offset", num(0.0), slider(-2.0, 2.0, -2.0, 2.0, 4)), + check("overlay", "Overlay", false), + check("transparency", "Transparency", false), + p("palette", "Palette", Value::Enum(0), popup(&["Lightness Bands", "Hue Bands", "Hue/Lightness Bands"])), + p("hue", "Hue", num(0.0), ang()), + p("cycleSteps", "Cycle Steps", num(32.0), slider(1.0, 1000.0, 1.0, 256.0, 0)), + p("cycleOffset", "Cycle Offset", num(0.0), ang()), + check("edgeHighlight", "Edge Highlight", false), + p("samplingMethod", "Sampling Method", Value::Enum(0), popup(&["Edge Detect", "Every Pixel"])), + p("samplingFactor", "Sampling Factor", num(2.0), slider(1.0, 9.0, 1.0, 9.0, 0)), + ], + fractal, + ), + spec( + "ec.generate.scribble", + "Scribble", + vec![ + p("scribble", "Scribble", Value::Enum(0), popup(&["Single Mask", "All Masks", "All Masks Using Modes"])), + mask_idx("mask", "Mask", 1.0), + p("fillType", "Fill Type", Value::Enum(0), popup(&["Inside", "Centered Edge", "Inside Edge", "Outside Edge", "Left Edge", "Right Edge"])), + p("edgeWidth", "Edge Options: Edge Width", num(10.0), slider(0.0, 1000.0, 0.0, 100.0, 1)), + p("color", "Color", col(1.0, 0.0, 0.0), ParamUi::Color), + p("opacity", "Opacity", num(100.0), pct()), + p("angle", "Angle", num(45.0), ang()), + p("strokeWidth", "Stroke Width", num(2.0), slider(0.1, 50.0, 0.1, 50.0, 1)), + p("curviness", "Stroke Options: Curviness", num(50.0), pct()), + p("curvinessVariation", "Stroke Options: Curviness Variation", num(50.0), pct()), + p("spacing", "Stroke Options: Spacing", num(5.0), slider(0.1, 100.0, 0.1, 50.0, 1)), + p("spacingVariation", "Stroke Options: Spacing Variation", num(50.0), pct()), + p("pathOverlap", "Stroke Options: Path Overlap", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 1)), + p("pathOverlapVariation", "Stroke Options: Path Overlap Variation", num(50.0), pct()), + p("start", "Start", num(0.0), pct()), + p("end", "End", num(100.0), pct()), + check("fillPathsSequentially", "Fill Paths Sequentially", true), + p("wiggleType", "Wiggle Type", Value::Enum(1), popup(&["Static", "Jumpy", "Smooth"])), + p("wigglesPerSecond", "Wiggles/Second", num(5.0), slider(0.0, 100.0, 0.0, 30.0, 2)), + p("randomSeed", "Random Seed", num(1.0), slider(0.0, 10000.0, 0.0, 100.0, 0)), + p("composite", "Composite", Value::Enum(1), popup(&["On Original Image", "On Transparent", "Reveal Original Image"])), + ], + scribble, + ), + spec( + "ec.generate.stroke", + "Stroke", + vec![ + mask_idx("path", "Path", 1.0), + check("allMasks", "All Masks", false), + check("strokeSequentially", "Stroke Sequentially", true), + p("color", "Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("brushSize", "Brush Size", num(2.0), slider(0.0, 50.0, 0.0, 50.0, 1)), + p("brushHardness", "Brush Hardness", num(75.0), pct()), + p("opacity", "Opacity", num(100.0), pct()), + p("start", "Start", num(0.0), pct()), + p("end", "End", num(100.0), pct()), + p("spacing", "Spacing", num(15.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("paintStyle", "Paint Style", Value::Enum(0), paint_style()), + ], + stroke, + ), + spec( + "ec.generate.vegas", + "Vegas", + vec![ + p("stroke", "Stroke", Value::Enum(0), popup(&["Image Contours", "Mask/Path"])), + p("inputLayer", "Image Contours: Input Layer", Value::Layer(None), ParamUi::Layer), + check("invertInput", "Image Contours: Invert Input", false), + p("ifLayerSizesDiffer", "Image Contours: If Layer Sizes Differ", Value::Enum(0), popup(&["Center", "Stretch to Fit"])), + p("channel", "Image Contours: Channel", Value::Enum(0), popup(&["Intensity", "Red", "Green", "Blue", "Alpha"])), + p("threshold", "Image Contours: Threshold", num(50.0), pct()), + p("preBlur", "Image Contours: Pre-Blur", num(1.0), slider(0.0, 100.0, 0.0, 20.0, 1)), + p("tolerance", "Image Contours: Tolerance", num(0.01), slider(0.0, 1.0, 0.0, 0.1, 3)), + p("renderMode", "Image Contours: Render Mode", Value::Enum(0), popup(&["All Contours", "Selected Contour"])), + p("selectedContour", "Image Contours: Selected Contour", num(1.0), slider(1.0, 1000.0, 1.0, 20.0, 0)), + mask_idx("path", "Mask/Path: Path", 1.0), + p("segments", "Segments: Segments", num(32.0), slider(1.0, 1000.0, 1.0, 100.0, 0)), + p("length", "Segments: Length", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("segmentDistribution", "Segments: Segment Distribution", Value::Enum(0), popup(&["Bunched", "Even"])), + p("rotation", "Segments: Rotation", num(0.0), ang()), + check("randomPhase", "Segments: Random Phase", false), + p("randomSeed", "Segments: Random Seed", num(1.0), slider(1.0, 10000.0, 1.0, 100.0, 0)), + p("blendMode", "Rendering: Blend Mode", Value::Enum(1), popup(&["Transparent", "Over", "Under", "Stencil"])), + p("color", "Rendering: Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("width", "Rendering: Width", num(2.0), slider(0.0, 100.0, 0.0, 20.0, 2)), + p("hardness", "Rendering: Hardness", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("startOpacity", "Rendering: Start Opacity", num(1.0), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("midpointOpacity", "Rendering: Mid-point Opacity", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("midpointPosition", "Rendering: Mid-point Position", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("endOpacity", "Rendering: End Opacity", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 3)), + ], + vegas, + ), + spec( + "ec.generate.writeon", + "Write-on", + vec![ + p("brushPosition", "Brush Position", pt(0.5, 0.5), ParamUi::Point), + p("color", "Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("brushSize", "Brush Size", num(6.0), slider(0.0, 50.0, 0.0, 50.0, 1)), + p("brushHardness", "Brush Hardness", num(75.0), pct()), + p("brushOpacity", "Brush Opacity", num(100.0), pct()), + p("strokeLength", "Stroke Length (secs)", num(0.0), slider(0.0, 30.0, 0.0, 30.0, 1)), + p("brushSpacing", "Brush Spacing (secs)", num(0.001), slider(0.001, 5.0, 0.001, 5.0, 3)), + p("paintTimeProperties", "Paint Time Properties", Value::Enum(0), popup(&["None", "Opacity", "Color"])), + p("brushTimeProperties", "Brush Time Properties", Value::Enum(0), popup(&["None", "Size", "Hardness", "Size & Hardness"])), + p("paintStyle", "Paint Style", Value::Enum(0), paint_style()), + ], + write_on, + ), + spec( + "ec.generate.paintbucket", + "Paint Bucket", + vec![ + p("fillPoint", "Fill Point", pt(0.5, 0.5), ParamUi::Point), + p("fillSelector", "Fill Selector", Value::Enum(0), popup(&["Color & Alpha", "Straight Color", "Transparency", "Opacity", "Alpha Channel"])), + p("tolerance", "Tolerance", num(10.0), slider(0.0, 255.0, 0.0, 100.0, 1)), + check("viewThreshold", "View Threshold", false), + p("stroke", "Stroke", Value::Enum(0), popup(&["Antialias", "Feather", "Spread", "Choke", "Stroke"])), + p("featherSoftness", "Feather Softness", num(10.0), slider(0.0, 100.0, 0.0, 50.0, 1)), + p("spreadRadius", "Spread Radius", num(3.0), slider(0.0, 100.0, 0.0, 20.0, 1)), + p("strokeWidth", "Stroke Width", num(2.0), slider(0.0, 100.0, 0.0, 20.0, 1)), + p("color", "Color", col(1.0, 0.0, 0.0), ParamUi::Color), + p("opacity", "Opacity", num(100.0), pct()), + p( + "blendingMode", + "Blending Mode", + Value::Enum(0), + popup(&["Normal", "Add", "Multiply", "Screen", "Hue", "Saturation", "Color", "Luminosity", "Behind", "Fill Only"]), + ), + ], + paint_bucket, + ), + spec( + "ec.generate.eyedropperfill", + "Eyedropper Fill", + vec![ + p("samplePoint", "Sample Point", pt(0.5, 0.5), ParamUi::Point), + p("sampleRadius", "Sample Radius", num(0.0), slider(0.0, 1000.0, 0.0, 100.0, 1)), + p("averagePixelColors", "Average Pixel Colors", Value::Enum(0), popup(&["Skip Empty", "All", "All Premultiplied", "Including Alpha"])), + check("maintainOriginalAlpha", "Maintain Original Alpha", false), + p("blendWithOriginal", "Blend With Original", num(0.0), pct()), + ], + eyedropper_fill, + ), + spec( + "ec.generate.ccgluegun", + "CC Glue Gun", + vec![ + p("brushPosition", "Brush Position", pt(0.5, 0.5), ParamUi::Point), + p("strokeWidth", "Stroke Width", num(25.0), slider(0.0, 500.0, 0.0, 100.0, 1)), + p("density", "Density", num(5.0), slider(0.0, 32.0, 0.0, 32.0, 1)), + p("timeSpan", "Time Span (sec)", num(1.0), slider(0.0, 60.0, 0.0, 10.0, 2)), + p("reflection", "Reflection", num(45.0), ang()), + p("strength", "Strength", num(100.0), pct()), + p("style", "Style", Value::Enum(0), popup(&["Over Layer", "Glue Only"])), + ], + glue_gun, + ), + spec( + "ec.generate.ccthreads", + "CC Threads", + vec![ + p("width", "Width", num(50.0), slider(1.0, 1000.0, 1.0, 200.0, 1)), + p("height", "Height", num(50.0), slider(1.0, 1000.0, 1.0, 200.0, 1)), + p("overlaps", "Overlaps", num(1.0), slider(1.0, 20.0, 1.0, 10.0, 0)), + p("direction", "Direction", num(0.0), ang()), + p("center", "Center", pt(0.5, 0.5), ParamUi::Point), + p("coverage", "Coverage", num(80.0), pct()), + p("shadowing", "Shadowing", num(50.0), pct()), + p("texture", "Texture", num(10.0), pct()), + ], + threads, + ), + spec( + "ec.generate.audiospectrum", + "Audio Spectrum", + vec![ + p("audioLayer", "Audio Layer", Value::Layer(None), ParamUi::Layer), + p("startPoint", "Start Point", pt(0.1, 0.5), ParamUi::Point), + p("endPoint", "End Point", pt(0.9, 0.5), ParamUi::Point), + mask_idx("path", "Path", 0.0), + check("usePolarPath", "Use Polar Path", false), + p("startFrequency", "Start Frequency", num(20.0), slider(1.0, 20000.0, 1.0, 2000.0, 0)), + p("endFrequency", "End Frequency", num(2000.0), slider(1.0, 20000.0, 1.0, 10000.0, 0)), + p("frequencyBands", "Frequency bands", num(64.0), slider(1.0, 4096.0, 1.0, 256.0, 0)), + p("maximumHeight", "Maximum Height", num(50.0), slider(0.0, 10000.0, 0.0, 500.0, 1)), + p("audioDuration", "Audio Duration (milliseconds)", num(90.0), slider(1.0, 30000.0, 1.0, 1000.0, 1)), + p("audioOffset", "Audio Offset (milliseconds)", num(0.0), slider(-30000.0, 30000.0, -1000.0, 1000.0, 1)), + p("thickness", "Thickness", num(3.0), slider(0.0, 1000.0, 0.0, 50.0, 2)), + p("softness", "Softness", num(50.0), pct()), + p("insideColor", "Inside Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("outsideColor", "Outside Color", col(0.2, 0.4, 1.0), ParamUi::Color), + check("blendOverlappingColors", "Blend Overlapping Colors", true), + p("hueInterpolation", "Hue Interpolation", num(0.0), ang()), + check("dynamicHuePhase", "Dynamic Hue Phase", false), + check("colorSymmetry", "Color Symmetry", false), + p("displayOptions", "Display Options", Value::Enum(0), display()), + p("sideOptions", "Side Options", Value::Enum(0), popup(&["Side A", "Side B", "Side A & B"])), + check("durationAveraging", "Duration Averaging", false), + check("compositeOnOriginal", "Composite On Original", false), + ], + audio_spectrum, + ), + spec( + "ec.generate.audiowaveform", + "Audio Waveform", + vec![ + p("audioLayer", "Audio Layer", Value::Layer(None), ParamUi::Layer), + p("startPoint", "Start Point", pt(0.1, 0.5), ParamUi::Point), + p("endPoint", "End Point", pt(0.9, 0.5), ParamUi::Point), + mask_idx("path", "Path", 0.0), + check("usePolarPath", "Use Polar Path", false), + p("displayedSamples", "Displayed Samples", num(200.0), slider(2.0, 4096.0, 2.0, 1000.0, 0)), + p("maximumHeight", "Maximum Height", num(100.0), slider(0.0, 10000.0, 0.0, 500.0, 1)), + p("audioDuration", "Audio Duration (milliseconds)", num(200.0), slider(1.0, 30000.0, 1.0, 1000.0, 1)), + p("audioOffset", "Audio Offset (milliseconds)", num(0.0), slider(-30000.0, 30000.0, -1000.0, 1000.0, 1)), + p("thickness", "Thickness", num(2.0), slider(0.0, 1000.0, 0.0, 50.0, 2)), + p("softness", "Softness", num(10.0), pct()), + p("randomSeed", "Random Seed (Analog)", num(1.0), slider(1.0, 10000.0, 1.0, 100.0, 0)), + p("insideColor", "Inside Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("outsideColor", "Outside Color", col(0.2, 0.4, 1.0), ParamUi::Color), + p("waveformOptions", "Waveform Options", Value::Enum(0), popup(&["Mono", "Left", "Right"])), + p("displayOptions", "Display Options", Value::Enum(1), display()), + check("compositeOnOriginal", "Composite On Original", false), + ], + audio_waveform, + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{EffectEnv, EffectHost, LayerPixels, MaskShape, Params}; + + /// Run with point defaults scaled to the layer size. + pub(crate) fn run_env(id: &str, set: &[(&str, Value)], img: Image, time: f64, env: EffectEnv) -> Buf { + let s = crate::find(id).unwrap(); + let ls = [img.width as f64, img.height as f64]; + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + for p in &s.params { + if let (ParamUi::Point, Value::Vec2(v)) = (&p.ui, &p.default) { + params.values.insert(p.id.to_string(), Value::Vec2([v[0] * ls[0], v[1] * ls[1]])); + } + } + for (k, v) in set { + assert!(params.values.contains_key(*k), "{id}: unknown {k}"); + params.values.insert(k.to_string(), v.clone()); + } + let ctx = EffectCtx { params: ¶ms, time, layer_size: ls, seed: 3, adjustment: false, env }; + crate::apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 }) + } + + fn run(id: &str, set: &[(&str, Value)], img: Image) -> Buf { + run_env(id, set, img, 0.5, EffectEnv::default()) + } + + fn square_mask() -> Vec { + vec![MaskShape { name: "Mask 1".into(), points: vec![[16.0, 16.0], [48.0, 16.0], [48.0, 48.0], [16.0, 48.0]], closed: true, inverted: false }] + } + + fn alpha_sum(img: &Image, x0: usize, y0: usize, x1: usize, y1: usize) -> f32 { + let mut s = 0.0; + for y in y0..y1 { + for x in x0..x1 { + s += img.data[y * img.width as usize + x][3]; + } + } + s + } + + #[test] + fn fractal_deterministic_and_has_inside_set() { + let img = Image::new(64, 48); + let a = run("ec.generate.fractal", &[], img.clone()); + let b = run("ec.generate.fractal", &[], img.clone()); + assert_eq!(a.img.data, b.img.data); + // The default view contains the main cardioid (black, opaque) and escaped (coloured) points. + assert!(a.img.data.iter().any(|p| p[3] > 0.99 && p[0] + p[1] + p[2] < 0.01)); + assert!(a.img.data.iter().any(|p| p[0] + p[1] + p[2] > 0.3)); + let t = run("ec.generate.fractal", &[("transparency", Value::Bool(true))], img); + assert!(t.img.data.iter().any(|p| p[3] == 0.0)); + } + + #[test] + fn scribble_fills_mask_and_animates() { + let masks = square_mask(); + let env = EffectEnv { masks: &masks, ..Default::default() }; + let img = Image::filled(64, 64, [0.0, 0.0, 1.0, 1.0]); + let a = run_env("ec.generate.scribble", &[], img.clone(), 0.5, env); + let b = run_env("ec.generate.scribble", &[], img.clone(), 0.5, env); + assert_eq!(a.img.data, b.img.data); + let inside = alpha_sum(&a.img, 18, 18, 46, 46); + let outside = alpha_sum(&a.img, 0, 0, 10, 64); + assert!(inside > 100.0 && outside < 1.0, "{inside} {outside}"); + let c = run_env("ec.generate.scribble", &[], img.clone(), 1.5, env); + assert_ne!(a.img.data, c.img.data, "jumpy wiggle changes over time"); + let s = run_env("ec.generate.scribble", &[("wiggleType", Value::Enum(0))], img.clone(), 0.5, env); + let s2 = run_env("ec.generate.scribble", &[("wiggleType", Value::Enum(0))], img.clone(), 3.5, env); + assert_eq!(s.img.data, s2.img.data, "static wiggle is constant"); + let half = run_env("ec.generate.scribble", &[("end", num(50.0))], img, 0.5, env); + assert!(alpha_sum(&half.img, 0, 0, 64, 64) < alpha_sum(&a.img, 0, 0, 64, 64)); + } + + #[test] + fn scribble_edge_types() { + let masks = square_mask(); + let env = EffectEnv { masks: &masks, ..Default::default() }; + let img = Image::new(64, 64); + let o = run_env("ec.generate.scribble", &[("fillType", Value::Enum(3)), ("edgeWidth", num(6.0))], img, 0.5, env); + // Outside edge: paint near the border outside, nothing in the centre. + assert!(alpha_sum(&o.img, 26, 26, 38, 38) < 0.5); + assert!(alpha_sum(&o.img, 8, 8, 16, 56) > 5.0); + } + + #[test] + fn stroke_draws_along_mask() { + let masks = square_mask(); + let env = EffectEnv { masks: &masks, ..Default::default() }; + let img = Image::new(64, 64); + let a = run_env("ec.generate.stroke", &[("brushSize", num(4.0)), ("paintStyle", Value::Enum(1))], img.clone(), 0.0, env); + assert!(a.img.get(32, 16)[3] > 0.9, "on the top edge"); + assert!(a.img.get(32, 32)[3] < 0.01, "centre empty"); + let b = run_env("ec.generate.stroke", &[("brushSize", num(4.0)), ("end", num(25.0))], img.clone(), 0.0, env); + assert!(b.img.get(32, 16)[3] > 0.9 && b.img.get(48, 32)[3] < 0.01, "first quarter only"); + let d = run_env("ec.generate.stroke", &[("brushSize", num(4.0)), ("spacing", num(300.0))], img, 0.0, env); + assert!(alpha_sum(&d.img, 0, 0, 64, 64) < alpha_sum(&a.img, 0, 0, 64, 64), "spaced dabs"); + // No masks: unchanged. + let src = Image::filled(8, 8, [0.2, 0.3, 0.4, 1.0]); + assert_eq!(run("ec.generate.stroke", &[], src.clone()).img.data, src.data); + } + + #[test] + fn vegas_traces_contours() { + let mut img = Image::new(64, 64); + for y in 16..48 { + for x in 16..48 { + img.set(x, y, [1.0, 1.0, 1.0, 1.0]); + } + } + let a = run( + "ec.generate.vegas", + &[("blendMode", Value::Enum(0)), ("length", num(1.0)), ("midpointOpacity", num(1.0)), ("endOpacity", num(1.0))], + img.clone(), + ); + let b = run( + "ec.generate.vegas", + &[("blendMode", Value::Enum(0)), ("length", num(1.0)), ("midpointOpacity", num(1.0)), ("endOpacity", num(1.0))], + img.clone(), + ); + assert_eq!(a.img.data, b.img.data); + assert!(alpha_sum(&a.img, 14, 14, 50, 50) > 50.0, "edges drawn"); + assert!(alpha_sum(&a.img, 26, 26, 38, 38) < 0.5, "interior untouched"); + let r = run("ec.generate.vegas", &[("blendMode", Value::Enum(0)), ("rotation", num(90.0))], img); + assert_ne!(r.img.data, a.img.data); + } + + #[test] + fn contours_of_square_are_closed() { + let mut pl = Plane::new(20, 20); + for y in 5..15 { + for x in 5..15 { + pl.data[y * 20 + x] = 1.0; + } + } + let c = contours(&pl, 0.5); + assert_eq!(c.len(), 1); + assert!(c[0].1); + let l = poly_length(&c[0].0, true); + assert!((l - 40.0).abs() < 4.0, "{l}"); + } + + #[test] + fn write_on_and_glue_gun_draw_at_brush() { + let img = Image::new(32, 32); + let a = run("ec.generate.writeon", &[], img.clone()); + assert!(a.img.get(16, 16)[3] > 0.9 && a.img.get(2, 2)[3] == 0.0); + assert_eq!(a.img.data, run("ec.generate.writeon", &[], img.clone()).img.data); + let src = Image::filled(32, 32, [0.5, 0.2, 0.1, 1.0]); + let g = run("ec.generate.ccgluegun", &[], src.clone()); + assert_eq!(g.img.data, run("ec.generate.ccgluegun", &[], src.clone()).img.data); + assert_ne!(g.img.get(16, 16), src.get(16, 16)); + assert_eq!(g.img.get(0, 0), src.get(0, 0)); + } + + #[test] + fn paint_bucket_fills_enclosed_region_only() { + let mut img = Image::filled(32, 32, [1.0, 1.0, 1.0, 1.0]); + for i in 8..24 { + for &(x, y) in &[(i, 8), (i, 23), (8, i), (23, i)] { + img.set(x, y, [0.0, 0.0, 0.0, 1.0]); + } + } + let out = run("ec.generate.paintbucket", &[], img.clone()); + let inside = out.img.get(16, 16); + assert!(inside[0] > 0.99 && inside[1] < 0.01, "{inside:?}"); + assert_eq!(out.img.get(2, 2), [1.0, 1.0, 1.0, 1.0], "outside untouched"); + assert_eq!(out.img.get(8, 16), [0.0, 0.0, 0.0, 1.0], "border untouched"); + assert_eq!(out.img.data, run("ec.generate.paintbucket", &[], img).img.data); + } + + #[test] + fn eyedropper_fill_uses_sample() { + let mut img = Image::filled(16, 16, [0.0, 0.0, 1.0, 1.0]); + img.set(8, 8, [1.0, 0.0, 0.0, 1.0]); + let out = run("ec.generate.eyedropperfill", &[], img.clone()); + assert!(out.img.data.iter().all(|p| *p == [1.0, 0.0, 0.0, 1.0])); + let blended = run("ec.generate.eyedropperfill", &[("blendWithOriginal", num(100.0))], img.clone()); + assert_eq!(blended.img.data, img.data); + } + + #[test] + fn threads_weave_is_deterministic_and_gappy() { + let src = Image::filled(64, 64, [0.4, 0.6, 0.2, 1.0]); + let a = run("ec.generate.ccthreads", &[("width", num(16.0)), ("height", num(16.0)), ("coverage", num(50.0))], src.clone()); + assert_eq!(a.img.data, run("ec.generate.ccthreads", &[("width", num(16.0)), ("height", num(16.0)), ("coverage", num(50.0))], src).img.data); + assert!(a.img.data.iter().any(|p| p[3] == 0.0), "gaps between threads"); + assert!(a.img.data.iter().any(|p| p[3] > 0.99), "threads"); + } + + struct Tone; + impl EffectHost for Tone { + fn layer(&self, _: u64, _: bool) -> Option { + None + } + fn audio(&self, _: u64, start: f64, frames: usize, rate: u32) -> Option> { + Some( + (0..frames) + .flat_map(|i| { + let t = start + i as f64 / rate as f64; + let v = (2.0 * PI * 440.0 * t).sin() as f32 * 0.8; + [v, v] + }) + .collect(), + ) + } + } + + #[test] + fn audio_effects_without_audio_pass_through() { + let src = Image::filled(16, 16, [0.3, 0.3, 0.3, 1.0]); + for id in ["ec.generate.audiospectrum", "ec.generate.audiowaveform"] { + assert_eq!(run(id, &[], src.clone()).img.data, src.data, "{id}"); + } + } + + #[test] + fn audio_effects_draw_with_host() { + let host = Tone; + let env = EffectEnv { host: Some(&host), comp_time: 1.0, ..Default::default() }; + let img = Image::new(96, 64); + for id in ["ec.generate.audiospectrum", "ec.generate.audiowaveform"] { + let a = run_env(id, &[("audioLayer", Value::Layer(Some(1)))], img.clone(), 1.0, env); + let b = run_env(id, &[("audioLayer", Value::Layer(Some(1)))], img.clone(), 1.0, env); + assert_eq!(a.img.data, b.img.data, "{id}"); + assert!(alpha_sum(&a.img, 0, 0, 96, 64) > 10.0, "{id} drew nothing"); + } + } + + #[test] + fn fft_finds_tone() { + let n = 256; + let mut re: Vec = (0..n).map(|i| (2.0 * PI * 8.0 * i as f64 / n as f64).cos()).collect(); + let mut im = vec![0.0; n]; + fft(&mut re, &mut im); + let mags: Vec = (0..n / 2).map(|k| (re[k] * re[k] + im[k] * im[k]).sqrt()).collect(); + let peak = mags.iter().enumerate().max_by(|a, b| a.1.total_cmp(b.1)).unwrap().0; + assert_eq!(peak, 8); + } } From 8bc6d5ba4f61044dc5b219b6e432c40128604384 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 22:09:16 -0400 Subject: [PATCH 12/14] M9.3: Text effects (Numbers, Timecode) + obsolete Basic Text, Path Text, Lightning Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/textfx.rs | 980 ++++++++++++++++++++++++++++++++++- 1 file changed, 977 insertions(+), 3 deletions(-) diff --git a/crates/effects/src/textfx.rs b/crates/effects/src/textfx.rs index ad66f957..4a4a8d35 100644 --- a/crates/effects/src/textfx.rs +++ b/crates/effects/src/textfx.rs @@ -1,7 +1,981 @@ -//! Text effects (Numbers, Timecode) and the obsolete text generators, drawn with a built-in stroke font. +//! Text effects (Numbers, Timecode) and the obsolete generators Basic Text, Path Text and +//! Lightning. +//! +//! Effects sit below the text engine in the crate layering, so text here is drawn with a small +//! built-in single-stroke font (original work: capitals, digits and common punctuation laid out +//! on a 4 × 6 unit grid; lowercase letters use the capitals) and rasterised as anti-aliased round +//! strokes. -use crate::EffectSpec; +use std::f64::consts::PI; + +use effectcraft_keyframe::Value; +use effectcraft_project::ParamUi; +use effectcraft_raster::{Image, Px}; +use rayon::prelude::*; + +use crate::generate3::{Seg, mask_px, poly_segs, raster_segs}; +use crate::util::{Plane, gauss_plane, hash1, poly_length, poly_point_at}; +use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; + +fn spec(id: &'static str, name: &'static str, category: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { + EffectSpec { id, name, category, params, render, gpu: false, float: true } +} + +fn pct() -> ParamUi { + slider(0.0, 100.0, 0.0, 100.0, 1) +} + +// ---------------------------------------------------------------- stroke font + +/// Glyph strokes on a grid x ∈ [0, 4], y ∈ [0, 6] (y down, baseline at 6): polylines separated +/// by `;`, points by spaces. A single point is a dot. +fn glyph_src(c: char) -> &'static str { + match c.to_ascii_uppercase() { + '0' => "1,0 3,0 4,1 4,5 3,6 1,6 0,5 0,1 1,0; 3.6,0.6 0.4,5.4", + '1' => "1,1 2,0 2,6; 1,6 3,6", + '2' => "0,1 1,0 3,0 4,1 4,2 0,6 4,6", + '3' => "0,0 4,0 2,2.5 3,2.5 4,3.5 4,5 3,6 1,6 0,5", + '4' => "3,6 3,0 0,4 4,4", + '5' => "4,0 0,0 0,2.5 3,2.5 4,3.5 4,5 3,6 0,6", + '6' => "3.5,0 1,0 0,1 0,5 1,6 3,6 4,5 4,3.5 3,2.5 0,2.5", + '7' => "0,0 4,0 1.5,6", + '8' => "1,0 3,0 4,1 4,2 3,3 1,3 0,2 0,1 1,0; 1,3 0,4 0,5 1,6 3,6 4,5 4,4 3,3", + '9' => "4,3.5 1,3.5 0,2.5 0,1 1,0 3,0 4,1 4,5 3,6 0.5,6", + 'A' => "0,6 2,0 4,6; 0.7,4 3.3,4", + 'B' => "0,0 0,6 3,6 4,5 4,4 3,3 0,3; 0,0 3,0 4,1 4,2 3,3", + 'C' => "4,1 3,0 1,0 0,1 0,5 1,6 3,6 4,5", + 'D' => "0,0 0,6 2.5,6 4,4.5 4,1.5 2.5,0 0,0", + 'E' => "4,0 0,0 0,6 4,6; 0,3 3,3", + 'F' => "4,0 0,0 0,6; 0,3 3,3", + 'G' => "4,1 3,0 1,0 0,1 0,5 1,6 3,6 4,5 4,3.5 2.5,3.5", + 'H' => "0,0 0,6; 4,0 4,6; 0,3 4,3", + 'I' => "1,0 3,0; 2,0 2,6; 1,6 3,6", + 'J' => "4,0 4,5 3,6 1,6 0,5", + 'K' => "0,0 0,6; 4,0 0,4; 1.3,3 4,6", + 'L' => "0,0 0,6 4,6", + 'M' => "0,6 0,0 2,3 4,0 4,6", + 'N' => "0,6 0,0 4,6 4,0", + 'O' => "1,0 3,0 4,1 4,5 3,6 1,6 0,5 0,1 1,0", + 'P' => "0,6 0,0 3,0 4,1 4,2 3,3 0,3", + 'Q' => "1,0 3,0 4,1 4,5 3,6 1,6 0,5 0,1 1,0; 2.5,4.5 4,6.3", + 'R' => "0,6 0,0 3,0 4,1 4,2 3,3 0,3; 2,3 4,6", + 'S' => "4,1 3,0 1,0 0,1 0,2 1,3 3,3 4,4 4,5 3,6 1,6 0,5", + 'T' => "0,0 4,0; 2,0 2,6", + 'U' => "0,0 0,5 1,6 3,6 4,5 4,0", + 'V' => "0,0 2,6 4,0", + 'W' => "0,0 1,6 2,2 3,6 4,0", + 'X' => "0,0 4,6; 4,0 0,6", + 'Y' => "0,0 2,3 4,0; 2,3 2,6", + 'Z' => "0,0 4,0 0,6 4,6", + ':' => "2,2; 2,5", + ';' => "2,2; 2,5 1.5,6.8", + '.' => "2,6", + ',' => "2,5.6 1.5,6.8", + '-' => "1,3.5 3,3.5", + '+' => "0.5,3.5 3.5,3.5; 2,2 2,5", + '/' => "4,0 0,6", + '(' => "2.5,-0.3 1.5,1.2 1.5,4.8 2.5,6.3", + ')' => "1.5,-0.3 2.5,1.2 2.5,4.8 1.5,6.3", + '\'' => "2,0 2,1.5", + '"' => "1.4,0 1.4,1.5; 2.6,0 2.6,1.5", + '!' => "2,0 2,4.2; 2,6", + '?' => "0,1 1,0 3,0 4,1 4,2 2,3.5 2,4.4; 2,6", + '%' => "0,6 4,0; 0.6,0.6; 3.4,5.4", + '=' => "0.5,2.5 3.5,2.5; 0.5,4.5 3.5,4.5", + '#' => "1.3,0.5 1,5.5; 3,0.5 2.7,5.5; 0,2 4,2; 0,4 4,4", + '&' => "4,6 1,2 1,0.8 1.8,0 2.8,0.8 2.8,1.8 0,4 0,5.2 1,6 2.5,6 4,4", + '*' => "2,1 2,4; 0.7,1.8 3.3,3.2; 3.3,1.8 0.7,3.2", + '_' => "0,6.5 4,6.5", + '<' => "4,1 0,3.5 4,6", + '>' => "0,1 4,3.5 0,6", + '@' => "3,4 3,2 1.8,2 1.2,3 1.8,4 3,4 4,3.5 4,1 3,0 1,0 0,1 0,5 1,6 3.5,6", + '$' => "4,1 3,0.5 1,0.5 0,1.5 1,3 3,3 4,4.5 3,5.5 1,5.5 0,5; 2,-0.3 2,6.3", + _ => "", + } +} + +fn parse_glyph(c: char) -> Vec> { + glyph_src(c) + .split(';') + .map(|s| { + s.split_whitespace() + .filter_map(|pt| { + let (x, y) = pt.split_once(',')?; + Some([x.parse().ok()?, y.parse().ok()?]) + }) + .collect::>() + }) + .filter(|v| !v.is_empty()) + .collect() +} + +/// Advance width in grid units (glyph ink + 1.6 units of side bearing). +fn advance(c: char, proportional: bool) -> f64 { + if c == ' ' { + return 3.0; + } + if !proportional { + return 5.6; + } + let g = parse_glyph(c); + let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY); + for s in &g { + for q in s { + lo = lo.min(q[0]); + hi = hi.max(q[0]); + } + } + if lo > hi { 3.0 } else { (hi - lo) + 1.6 } +} + +/// Left ink edge of a glyph (so proportional glyphs start at their ink). +fn ink_left(c: char, proportional: bool) -> f64 { + if !proportional { + return 0.8; + } + parse_glyph(c).iter().flatten().map(|q| q[0]).fold(f64::INFINITY, f64::min).min(4.0) - 0.8 +} + +/// Text metrics: grid unit in px for a font size (cap height ≈ 0.7 em) and stroke radius. +fn unit(size: f64) -> f64 { + size * 0.7 / 6.0 +} +fn weight(size: f64) -> f64 { + (size * 0.045).max(0.35) +} + +/// Width of a line of text in px (tracking in px between characters). +fn line_width(text: &str, size: f64, tracking: f64, proportional: bool) -> f64 { + let u = unit(size); + let n = text.chars().count(); + if n == 0 { + return 0.0; + } + text.chars().map(|c| advance(c, proportional) * u).sum::() + tracking * (n as f64 - 1.0) - 1.6 * u +} + +/// Polylines (px) of a line of text whose baseline starts at `origin`. +fn layout_line(text: &str, origin: [f64; 2], size: f64, tracking: f64, proportional: bool, out: &mut Vec>) { + let u = unit(size); + let mut x = origin[0]; + for c in text.chars() { + let left = ink_left(c, proportional); + for s in parse_glyph(c) { + out.push(s.iter().map(|q| [x + (q[0] - left - 0.8) * u, origin[1] + (q[1] - 6.0) * u]).collect()); + } + x += advance(c, proportional) * u + tracking; + } +} + +/// How text paints: fill/stroke display option, colours, and compositing. +struct TextLook { + display: u32, + fill: [f32; 4], + stroke: [f32; 4], + stroke_w: f64, + on_original: bool, + opacity: f32, +} + +fn over(dst: Px, src: Px) -> Px { + let k = 1.0 - src[3]; + [src[0] + dst[0] * k, src[1] + dst[1] * k, src[2] + dst[2] * k, src[3] + dst[3] * k] +} + +fn tint(c: [f32; 4], a: f32) -> Px { + let a = (a * c[3]).clamp(0.0, 1.0); + [c[0] * a, c[1] * a, c[2] * a, a] +} + +/// Draw text polylines with the given look (`r` = glyph stroke radius in px). +fn draw_text(b: &mut Buf, polys: &[Vec<[f64; 2]>], r: f64, look: &TextLook) { + let (w, h) = (b.img.width as usize, b.img.height as usize); + let segs_at = |rad: f64| { + let mut s = Vec::new(); + for pl in polys { + poly_segs(pl, false, rad.max(0.05), 1.0, &mut s); + } + s + }; + let fill = raster_segs(w, h, &segs_at(r), 1.0); + let sw = look.stroke_w.max(0.0); + let ring = if look.display == 0 || sw <= 0.0 { + None + } else { + let outer = raster_segs(w, h, &segs_at(r + sw * 0.5), 1.0); + let inner = if r - sw * 0.5 > 0.05 { raster_segs(w, h, &segs_at(r - sw * 0.5), 1.0) } else { Plane::new(w, h) }; + Some(outer.zip_map(&inner, |a, b| (a - b).max(0.0))) + }; + let op = look.opacity; + b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { + let mut out = if look.on_original { *px } else { [0.0; 4] }; + let f = fill.data[i] * op; + let s = ring.as_ref().map(|r| r.data[i]).unwrap_or(0.0) * op; + match look.display { + 1 => out = over(out, tint(look.stroke, s)), + 2 => { + out = over(out, tint(look.stroke, s)); + out = over(out, tint(look.fill, f)); + } + 3 => { + out = over(out, tint(look.fill, f)); + out = over(out, tint(look.stroke, s)); + } + _ => out = over(out, tint(look.fill, f)), + } + *px = out; + }); +} + +fn look_from(ctx: &EffectCtx, b: &Buf, on_original_id: &str) -> TextLook { + let pr = ctx.params; + TextLook { + display: pr.e("displayOptions"), + fill: pr.color("fillColor"), + stroke: pr.color("strokeColor"), + stroke_w: pr.f("strokeWidth") * b.scale, + on_original: pr.b(on_original_id), + opacity: 1.0, + } +} + +// ---------------------------------------------------------------- Numbers + +const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; +const DAYS: [&str; 7] = ["Saturday", "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]; + +/// Proleptic Gregorian date from a day count since 2000-01-01 (a Saturday). +fn date_from_days(days: i64) -> (i64, u32, u32, usize) { + // Days since 0000-03-01 based civil calendar conversion. + let z = days + 730_425; // 2000-01-01 relative to 0000-03-01 + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + let y = if m <= 2 { y + 1 } else { y }; + (y, m, d, days.rem_euclid(7) as usize) +} + +fn timecode(frames: i64, fps: f64) -> String { + let fr = fps.round().max(1.0) as i64; + let neg = frames < 0; + let f = frames.abs(); + let s = format!("{:02}:{:02}:{:02}:{:02}", f / (fr * 3600), (f / (fr * 60)) % 60, (f / fr) % 60, f % fr); + if neg { format!("-{s}") } else { s } +} + +fn numbers_text(ctx: &EffectCtx) -> String { + let pr = ctx.params; + let kind = pr.e("type"); + let dp = pr.f("decimalPlaces").round().clamp(0.0, 10.0) as usize; + let mut v = pr.f("value"); + if pr.b("randomValues") { + let frame = (ctx.time * ctx.fps()).floor() as i64 as u32; + v = hash1(frame, 17, ctx.seed) as f64 * pr.f("value"); + } + let cur = pr.b("currentTimeDate"); + let t = if cur { ctx.time + v } else { v }; + match kind { + 1 => { + let s = format!("{:.*}", dp, v.abs()); + let (ip, fp) = s.split_once('.').map(|(a, b)| (a.to_string(), format!(".{b}"))).unwrap_or((s.clone(), String::new())); + format!("{}{:0>5}{}", if v < 0.0 { "-" } else { "" }, ip, fp) + } + 2..=4 => { + let fps = [30.0, 25.0, 24.0][(kind - 2) as usize]; + timecode((t * fps + 1e-6).floor() as i64, fps) + } + 5 => { + let s = t.floor() as i64; + let a = s.abs(); + format!("{}{:02}:{:02}:{:02}", if s < 0 { "-" } else { "" }, a / 3600, (a / 60) % 60, a % 60) + } + 6..=8 => { + let (y, m, d, wd) = date_from_days(v.floor() as i64); + match kind { + 6 => format!("{:02}/{:02}/{:02}", m, d, y.rem_euclid(100)), + 7 => format!("{} {}, {}", &MONTHS[m as usize - 1][..3], d, y), + _ => format!("{}, {} {}, {}", DAYS[wd], MONTHS[m as usize - 1], d, y), + } + } + 9 => { + let i = v.round() as i64; + if i < 0 { format!("-{:X}", -i) } else { format!("{i:X}") } + } + _ => format!("{:.*}", dp, if cur { t } else { v }), + } +} + +fn numbers(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let text = numbers_text(ctx); + let size = (pr.f("size") * b.scale).max(0.5); + let tracking = pr.f("tracking") * b.scale; + let prop = pr.b("proportionalSpacing"); + let pos = b.to_px(pr.v2("position")); + let wdt = line_width(&text, size, tracking, prop); + let mut polys = Vec::new(); + layout_line(&text, [pos.0 - wdt * 0.5, pos.1 + unit(size) * 3.0], size, tracking, prop, &mut polys); + let look = look_from(ctx, &b, "compositeOnOriginal"); + draw_text(&mut b, &polys, weight(size), &look); + b +} + +// ---------------------------------------------------------------- Timecode + +/// SMPTE drop-frame label for nominal 30/60 fps rates. +fn drop_frame(frames: i64, nominal: i64) -> String { + let drop = nominal / 15; // 2 at 30, 4 at 60 + let per_min = nominal * 60 - drop; + let per_10 = per_min * 10 + drop; + let f = frames.max(0); + let d = f / per_10; + let m = f % per_10; + let adj = if m > drop { f + drop * 9 * d + drop * ((m - drop) / per_min) } else { f + drop * 9 * d }; + format!("{:02};{:02};{:02};{:02}", adj / (nominal * 3600), (adj / (nominal * 60)) % 60, (adj / nominal) % 60, adj % nominal) +} + +fn timecode_fx(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let src = pr.e("timeSource"); + let (t, fps) = match src { + 1 => (ctx.env.comp_time, ctx.fps()), + 2 => (ctx.time, pr.f("timeUnits").max(1.0)), + _ => (ctx.time, ctx.fps()), + }; + let mut frame = (t * fps + 1e-6).floor() as i64; + if src == 2 { + frame += pr.f("startingFrame").round() as i64; + } + let nominal = fps.round() as i64; + let text = match pr.e("displayFormat") { + 1 => format!("{frame}"), + 2 => format!("{}+{:02}", frame.div_euclid(16), frame.rem_euclid(16)), + 3 => format!("{}+{:02}", frame.div_euclid(40), frame.rem_euclid(40)), + _ => { + if pr.b("dropFrame") && (nominal == 30 || nominal == 60) { + drop_frame(frame, nominal) + } else { + timecode(frame, fps) + } + } + }; + let size = (pr.f("textSize") * b.scale).max(0.5); + let pos = b.to_px(pr.v2("textPosition")); + let wdt = line_width(&text, size, 0.0, false); + let op = (pr.f("opacity") / 100.0) as f32; + if !pr.b("renderOnOriginal") { + b.img = Image::new(b.img.width, b.img.height); + } + if pr.b("showBox") { + let bc = pr.color("boxColor"); + let pad = size * 0.25; + let (x0, x1) = (pos.0 - wdt * 0.5 - pad, pos.0 + wdt * 0.5 + pad); + let (y0, y1) = (pos.1 - size * 0.5, pos.1 + size * 0.5); + let a = op * bc[3]; + b.img.rows_mut().for_each(|(y, row)| { + let fy = y as f64 + 0.5; + let cy = ((fy - y0 + 0.5).clamp(0.0, 1.0) * (y1 - fy + 0.5).clamp(0.0, 1.0)) as f32; + if cy <= 0.0 { + return; + } + for (x, px) in row.iter_mut().enumerate() { + let fx = x as f64 + 0.5; + let cx = ((fx - x0 + 0.5).clamp(0.0, 1.0) * (x1 - fx + 0.5).clamp(0.0, 1.0)) as f32; + let k = cx * cy * a; + if k > 0.0 { + *px = over(*px, [bc[0] * k, bc[1] * k, bc[2] * k, k]); + } + } + }); + } + let mut polys = Vec::new(); + layout_line(&text, [pos.0 - wdt * 0.5, pos.1 + unit(size) * 3.0], size, 0.0, false, &mut polys); + let look = TextLook { display: 0, fill: pr.color("textColor"), stroke: [0.0; 4], stroke_w: 0.0, on_original: true, opacity: op }; + draw_text(&mut b, &polys, weight(size), &look); + b +} + +// ---------------------------------------------------------------- Basic Text + +fn basic_text(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let size = (pr.f("size") * b.scale).max(0.5); + let tracking = pr.f("tracking") * b.scale; + let lead = size * pr.f("lineSpacing") / 100.0 * 1.2; + let pos = b.to_px(pr.v2("position")); + let align = pr.e("alignment"); + let lines: Vec<&str> = pr.s("text").split(['\n', '\r']).collect(); + let n = lines.len() as f64; + let mut polys = Vec::new(); + for (i, l) in lines.iter().enumerate() { + let wdt = line_width(l, size, tracking, true); + let x = match align { + 0 => pos.0, + 2 => pos.0 - wdt, + _ => pos.0 - wdt * 0.5, + }; + let y = pos.1 + unit(size) * 3.0 + (i as f64 - (n - 1.0) * 0.5) * lead; + layout_line(l, [x, y], size, tracking, true, &mut polys); + } + let look = look_from(ctx, &b, "compositeOnOriginal"); + draw_text(&mut b, &polys, weight(size), &look); + b +} + +// ---------------------------------------------------------------- Path Text + +fn path_text(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let px = |id: &str| { + let q = b.to_px(pr.v2(id)); + [q.0, q.1] + }; + let (mut path, mut closed) = match mask_px(ctx, &b, pr.f("customPath").round() as usize) { + Some((p, c, _)) => (p, c), + None => match pr.e("shapeType") { + 1 | 2 => { + let c = px("vertex1"); + let s = px("tangent1"); + let r = ((s[0] - c[0]).powi(2) + (s[1] - c[1]).powi(2)).sqrt().max(1.0); + let a0 = (s[1] - c[1]).atan2(s[0] - c[0]); + let n = 128; + ( + (0..n) + .map(|i| { + let a = a0 + 2.0 * PI * i as f64 / n as f64; + [c[0] + a.cos() * r, c[1] + a.sin() * r] + }) + .collect(), + true, + ) + } + 3 => (vec![px("vertex1"), px("vertex2")], false), + _ => { + let (v1, t1, t2, v2) = (px("vertex1"), px("tangent1"), px("tangent2"), px("vertex2")); + ( + (0..=64) + .map(|i| { + let t = i as f64 / 64.0; + let u = 1.0 - t; + let f = |k: usize| u * u * u * v1[k] + 3.0 * u * u * t * t1[k] + 3.0 * u * t * t * t2[k] + t * t * t * v2[k]; + [f(0), f(1)] + }) + .collect(), + false, + ) + } + }, + }; + if pr.b("reversePath") { + path.reverse(); + } + if path.len() < 2 { + closed = false; + } + let len = poly_length(&path, closed); + let text: String = pr.s("text").chars().take(pr.f("visibleCharacters").max(0.0) as usize).collect(); + let size = (pr.f("size") * b.scale).max(0.5); + let u = unit(size); + let tracking = pr.f("tracking") * b.scale; + let lm = pr.f("leftMargin") * b.scale; + let rm = pr.f("rightMargin") * b.scale; + let shift = pr.f("baselineShift") * b.scale; + let rot = pr.f("characterRotation").to_radians(); + let n = text.chars().count(); + let advs: Vec = text.chars().map(|c| advance(c, true) * u).collect(); + let natural: f64 = advs.iter().sum::() + tracking * (n.max(1) as f64 - 1.0); + let avail = (len - lm - rm).max(0.0); + let (mut cursor, extra) = match pr.e("alignment") { + 1 => (len - rm - natural, 0.0), + 2 => (lm + (avail - natural) * 0.5, 0.0), + 3 => (lm, if n > 1 { (avail - natural) / (n as f64 - 1.0) } else { 0.0 }), + _ => (lm, 0.0), + }; + let mut polys = Vec::new(); + for (c, adv) in text.chars().zip(advs) { + let mid = cursor + adv * 0.5; + cursor += adv + tracking + extra; + if !closed && (mid < 0.0 || mid > len) { + continue; + } + let (q, d) = poly_point_at(&path, closed, if closed { mid.rem_euclid(len.max(1e-9)) } else { mid }); + let ang = d[1].atan2(d[0]) + rot; + let (s, co) = ang.sin_cos(); + let left = ink_left(c, true); + for st in parse_glyph(c) { + polys.push( + st.iter() + .map(|g| { + let lx = (g[0] - left - 0.8) * u - adv * 0.5 + 0.8 * u; + let ly = (g[1] - 6.0) * u - shift; + [q[0] + lx * co - ly * s, q[1] + lx * s + ly * co] + }) + .collect(), + ); + } + } + let look = look_from(ctx, &b, "compositeOnOriginal"); + draw_text(&mut b, &polys, weight(size), &look); + b +} + +// ---------------------------------------------------------------- Lightning (obsolete) + +/// Smooth 1-D value noise in [-1, 1]. +fn vnoise(i: u32, t: f64, seed: u32) -> f64 { + let k = t.floor(); + let f = t - k; + let a = hash1(i, k as i64 as u32, seed) as f64 * 2.0 - 1.0; + let c = hash1(i, (k as i64 + 1) as u32, seed) as f64 * 2.0 - 1.0; + let s = f * f * (3.0 - 2.0 * f); + a + (c - a) * s +} + +struct Bolt<'a> { + seed: u32, + phase: f64, + stability: f64, + detail: u32, + detail_amp: f64, + pull: [f64; 2], + out: &'a mut Vec, + counter: u32, +} + +impl Bolt<'_> { + /// One bolt from `a` to `z` with `n` segments of amplitude `amp`; returns its points. + fn path(&mut self, a: [f64; 2], z: [f64; 2], n: usize, amp: f64, fixed_end: bool) -> Vec<[f64; 2]> { + let (dx, dy) = (z[0] - a[0], z[1] - a[1]); + let len = (dx * dx + dy * dy).sqrt().max(1e-9); + let nrm = [-dy / len, dx / len]; + let id = self.counter; + self.counter += 1; + let mut pts = Vec::with_capacity(n + 1); + for i in 0..=n { + let t = i as f64 / n as f64; + let env = if fixed_end { (t * PI).sin() } else { t.sqrt() }; + let off = vnoise(id * 1000 + i as u32, self.phase * (1.0 - self.stability * 0.9) + i as f64 * 0.37, self.seed) * amp * env; + let pull = t * t * len * 0.25; + pts.push([a[0] + dx * t + nrm[0] * off + self.pull[0] * pull, a[1] + dy * t + nrm[1] * off + self.pull[1] * pull]); + } + // Detail: midpoint displacement. + for lvl in 0..self.detail { + let mut nx = Vec::with_capacity(pts.len() * 2); + for (j, w) in pts.windows(2).enumerate() { + let (p0, p1) = (w[0], w[1]); + let (ex, ey) = (p1[0] - p0[0], p1[1] - p0[1]); + let l = (ex * ex + ey * ey).sqrt(); + let o = vnoise(id * 7919 + lvl * 131 + j as u32, self.phase * 1.7 + j as f64 * 0.61, self.seed ^ 0x55) * l * self.detail_amp; + nx.push(p0); + nx.push([(p0[0] + p1[0]) * 0.5 - ey / l.max(1e-9) * o, (p0[1] + p1[1]) * 0.5 + ex / l.max(1e-9) * o]); + } + nx.push(*pts.last().unwrap_or(&a)); + pts = nx; + } + pts + } +} + +fn lightning(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let s = b.to_px(pr.v2("startPoint")); + let e = b.to_px(pr.v2("endPoint")); + let sc = b.scale; + let rerun = pr.b("rerunAtEachFrame"); + let speed = pr.f("speed"); + let frame = (ctx.time * ctx.fps()).floor(); + let seed = (pr.f("randomSeed") as i64 as u32) ^ ctx.seed ^ if rerun { (frame as i64 as u32).wrapping_mul(2_654_435_761) } else { 0 }; + let phase = if rerun { 0.0 } else { ctx.time * speed * 0.25 }; + let pd = (pr.f("pullDirection") - 90.0).to_radians(); + let pf = pr.f("pullForce") / 100.0; + let width = (pr.f("width") * sc).max(0.2); + let wvar = pr.f("widthVariation").clamp(0.0, 1.0); + let core = pr.f("coreWidth").clamp(0.0, 1.0); + let mut segs: Vec = Vec::new(); + let mut bolt = Bolt { + seed, + phase, + stability: pr.f("stability").clamp(0.0, 1.0), + detail: pr.f("detailLevel").round().clamp(0.0, 6.0) as u32, + detail_amp: pr.f("detailAmplitude").clamp(0.0, 1.0) * 0.5, + pull: [pd.cos() * pf, pd.sin() * pf], + out: &mut segs, + counter: 0, + }; + let nseg = pr.f("segments").round().clamp(1.0, 64.0) as usize; + let amp = pr.f("amplitude") * sc; + let main = bolt.path([s.0, s.1], [e.0, e.1], nseg, amp, pr.b("fixedEndpoint")); + let branching = pr.f("branching").clamp(0.0, 1.0); + let rebranch = pr.f("rebranching").clamp(0.0, 1.0); + let bang = pr.f("branchAngle").to_radians(); + let bseg_len = pr.f("branchSegLength").max(0.0); + let bsegs = pr.f("branchSegments").round().clamp(1.0, 32.0) as usize; + let bwidth = pr.f("branchWidth").clamp(0.0, 1.0); + let main_len = poly_length(&main, false); + let seg_len = main_len / nseg as f64; + let mut lines: Vec<(Vec<[f64; 2]>, f64)> = vec![(main.clone(), 1.0)]; + let mut queue: Vec<(Vec<[f64; 2]>, f64, u32)> = vec![(main, 1.0, 0)]; + while let Some((pts, w, depth)) = queue.pop() { + if depth >= 3 || lines.len() > 256 { + continue; + } + let prob = if depth == 0 { branching } else { rebranch }; + for (i, wpair) in pts.windows(2).enumerate() { + if hash1(i as u32 + depth * 977, lines.len() as u32, seed ^ 0xB7) as f64 >= prob * 0.35 { + continue; + } + let (p0, p1) = (wpair[0], wpair[1]); + let base = (p1[1] - p0[1]).atan2(p1[0] - p0[0]); + let side = if hash1(i as u32, depth + 3, seed) < 0.5 { -1.0 } else { 1.0 }; + let a = base + side * bang * (0.5 + hash1(i as u32, depth + 9, seed) as f64); + let l = seg_len * bseg_len * bsegs as f64; + let z = [p0[0] + a.cos() * l, p0[1] + a.sin() * l]; + let br = bolt.path(p0, z, bsegs, amp * 0.5, false); + let bw = w * bwidth; + lines.push((br.clone(), bw)); + queue.push((br, bw, depth + 1)); + } + } + for (li, (pts, w)) in lines.iter().enumerate() { + for (i, wpair) in pts.windows(2).enumerate() { + let v = 1.0 - wvar * hash1(i as u32, li as u32, seed ^ 0x77) as f64; + bolt.out.push(Seg { a: wpair[0], b: wpair[1], r: width * 0.5 * w * v, v: 1.0 }); + } + } + let (wd, ht) = (b.img.width as usize, b.img.height as usize); + let outer = raster_segs(wd, ht, &segs, 0.0); + let core_segs: Vec = segs.iter().map(|g| Seg { r: (g.r * core).max(0.3), ..*g }).collect(); + let inner = raster_segs(wd, ht, &core_segs, 0.6); + let glow = gauss_plane(&outer, width * 0.5, width * 0.5); + let oc = pr.color("outsideColor"); + let ic = pr.color("insideColor"); + let mode = pr.e("blendingMode"); + b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { + let o = outer.data[i].max(glow.data[i] * 0.8); + let c = inner.data[i]; + let a = o.max(c).min(1.0); + if a <= 0.0 { + return; + } + let t = if a > 0.0 { c / a } else { 0.0 }; + let colr = [oc[0] + (ic[0] - oc[0]) * t, oc[1] + (ic[1] - oc[1]) * t, oc[2] + (ic[2] - oc[2]) * t]; + *px = match mode { + 1 => [px[0] + colr[0] * a, px[1] + colr[1] * a, px[2] + colr[2] * a, (px[3] + a * (1.0 - px[3])).min(1.0)], + 2 => { + let sc = |d: f32, s: f32| d + s - d * s; + [sc(px[0], colr[0] * a), sc(px[1], colr[1] * a), sc(px[2], colr[2] * a), sc(px[3], a)] + } + _ => over(*px, [colr[0] * a, colr[1] * a, colr[2] * a, a]), + }; + }); + b +} + +// ---------------------------------------------------------------- registry pub fn specs() -> Vec { - Vec::new() + let ang = || ParamUi::Angle; + let check = |id: &'static str, name: &'static str, v: bool| p(id, name, Value::Bool(v), ParamUi::Checkbox); + let pt = |x: f64, y: f64| Value::Vec2([x, y]); + let display = || popup(&["Fill Only", "Stroke Only", "Fill Over Stroke", "Stroke Over Fill"]); + let size = || slider(0.0, 512.0, 0.0, 200.0, 1); + vec![ + spec( + "ec.text.numbers", + "Numbers", + "Text", + vec![ + p( + "type", + "Format: Type", + Value::Enum(0), + popup(&[ + "Number", + "Number [Leading Zeros]", + "Timecode [30]", + "Timecode [25]", + "Timecode [24]", + "Time", + "Numerical Date", + "Short Date", + "Long Date", + "Hexadecimal", + ]), + ), + check("randomValues", "Format: Random Values", false), + p("value", "Format: Value/Offset/Random Max", num(0.0), slider(-30000.0, 30000.0, -1000.0, 1000.0, 3)), + p("decimalPlaces", "Format: Decimal Places", num(2.0), slider(0.0, 10.0, 0.0, 10.0, 0)), + check("currentTimeDate", "Format: Current Time/Date", false), + p("position", "Fill and Stroke: Position", pt(0.5, 0.5), ParamUi::Point), + p("displayOptions", "Fill and Stroke: Display Options", Value::Enum(0), display()), + p("fillColor", "Fill and Stroke: Fill Color", col(1.0, 0.0, 0.0), ParamUi::Color), + p("strokeColor", "Fill and Stroke: Stroke Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("strokeWidth", "Fill and Stroke: Stroke Width", num(2.0), slider(0.0, 50.0, 0.0, 50.0, 1)), + p("size", "Size", num(36.0), size()), + p("tracking", "Tracking", num(0.0), slider(-100.0, 100.0, -20.0, 50.0, 1)), + check("proportionalSpacing", "Proportional Spacing", true), + check("compositeOnOriginal", "Composite On Original", true), + ], + numbers, + ), + spec( + "ec.text.timecode", + "Timecode", + "Text", + vec![ + p("displayFormat", "Display Format", Value::Enum(0), popup(&["SMPTE HH:MM:SS:FF", "Frames", "Feet + Frames (35mm)", "Feet + Frames (16mm)"])), + p("timeSource", "Time Source", Value::Enum(0), popup(&["Layer Source", "Composition", "Custom"])), + p("timeUnits", "Time Units", num(30.0), slider(1.0, 120.0, 1.0, 60.0, 2)), + check("dropFrame", "Drop Frame", false), + p("startingFrame", "Starting Frame", num(0.0), slider(-1_000_000.0, 1_000_000.0, 0.0, 10_000.0, 0)), + p("textPosition", "Text Position", pt(0.5, 0.9), ParamUi::Point), + p("textSize", "Text Size", num(36.0), size()), + p("textColor", "Text Color", col(1.0, 1.0, 1.0), ParamUi::Color), + check("showBox", "Show Box", true), + p("boxColor", "Box Color", col(0.0, 0.0, 0.0), ParamUi::Color), + p("opacity", "Opacity", num(100.0), pct()), + check("renderOnOriginal", "Render On Original", true), + ], + timecode_fx, + ), + spec( + "ec.obsolete.basictext", + "Basic Text", + "Obsolete", + vec![ + p("text", "Text", Value::Str("Text".into()), ParamUi::Text), + p("alignment", "Alignment", Value::Enum(1), popup(&["Left", "Center", "Right"])), + p("position", "Position", pt(0.5, 0.5), ParamUi::Point), + p("displayOptions", "Fill and Stroke: Display Options", Value::Enum(0), display()), + p("fillColor", "Fill and Stroke: Fill Color", col(1.0, 0.0, 0.0), ParamUi::Color), + p("strokeColor", "Fill and Stroke: Stroke Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("strokeWidth", "Fill and Stroke: Stroke Width", num(2.0), slider(0.0, 50.0, 0.0, 50.0, 1)), + p("size", "Size", num(36.0), size()), + p("tracking", "Tracking", num(0.0), slider(-100.0, 100.0, -20.0, 50.0, 1)), + p("lineSpacing", "Line Spacing", num(100.0), slider(0.0, 1000.0, 0.0, 300.0, 1)), + check("compositeOnOriginal", "Composite On Original", true), + ], + basic_text, + ), + spec( + "ec.obsolete.pathtext", + "Path Text", + "Obsolete", + vec![ + p("text", "Text", Value::Str("Text".into()), ParamUi::Text), + p("shapeType", "Path Options: Shape Type", Value::Enum(0), popup(&["Bezier", "Circle", "Loop", "Line"])), + p("tangent1", "Control Points: Tangent 1/Circle Point", pt(0.3, 0.3), ParamUi::Point), + p("vertex1", "Control Points: Vertex 1/Circle Center", pt(0.1, 0.6), ParamUi::Point), + p("tangent2", "Control Points: Tangent 2", pt(0.7, 0.3), ParamUi::Point), + p("vertex2", "Control Points: Vertex 2/Line Right", pt(0.9, 0.6), ParamUi::Point), + p("customPath", "Path Options: Custom Path", num(0.0), slider(0.0, 32.0, 0.0, 8.0, 0)), + check("reversePath", "Path Options: Reverse Path", false), + p("displayOptions", "Fill and Stroke: Options", Value::Enum(0), display()), + p("fillColor", "Fill and Stroke: Fill Color", col(1.0, 0.0, 0.0), ParamUi::Color), + p("strokeColor", "Fill and Stroke: Stroke Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("strokeWidth", "Fill and Stroke: Stroke Width", num(2.0), slider(0.0, 50.0, 0.0, 50.0, 1)), + p("size", "Character: Size", num(36.0), size()), + p("tracking", "Character: Tracking", num(0.0), slider(-100.0, 100.0, -20.0, 50.0, 1)), + p("characterRotation", "Character: Orientation: Character Rotation", num(0.0), ang()), + p("alignment", "Paragraph: Alignment", Value::Enum(0), popup(&["Left", "Right", "Center", "Force"])), + p("leftMargin", "Paragraph: Left Margin", num(0.0), slider(-10000.0, 10000.0, -500.0, 500.0, 1)), + p("rightMargin", "Paragraph: Right Margin", num(0.0), slider(-10000.0, 10000.0, -500.0, 500.0, 1)), + p("baselineShift", "Paragraph: Baseline Shift", num(0.0), slider(-1000.0, 1000.0, -100.0, 100.0, 1)), + p("visibleCharacters", "Advanced: Visible Characters", num(1024.0), slider(0.0, 1024.0, 0.0, 1024.0, 0)), + check("compositeOnOriginal", "Composite On Original", true), + ], + path_text, + ), + spec( + "ec.obsolete.lightning", + "Lightning", + "Obsolete", + vec![ + p("startPoint", "Start Point", pt(0.1, 0.5), ParamUi::Point), + p("endPoint", "End Point", pt(0.9, 0.5), ParamUi::Point), + p("segments", "Segments", num(7.0), slider(1.0, 64.0, 1.0, 32.0, 0)), + p("amplitude", "Amplitude", num(10.0), slider(0.0, 1000.0, 0.0, 100.0, 1)), + p("detailLevel", "Detail Level", num(2.0), slider(0.0, 6.0, 0.0, 6.0, 0)), + p("detailAmplitude", "Detail Amplitude", num(0.3), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("branching", "Branching", num(0.4), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("rebranching", "Rebranching", num(0.3), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("branchAngle", "Branch Angle", num(20.0), ang()), + p("branchSegLength", "Branch Seg. Length", num(0.5), slider(0.0, 2.0, 0.0, 2.0, 3)), + p("branchSegments", "Branch Segments", num(4.0), slider(1.0, 32.0, 1.0, 16.0, 0)), + p("branchWidth", "Branch Width", num(0.7), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("speed", "Speed", num(10.0), slider(-100.0, 100.0, -50.0, 50.0, 1)), + p("stability", "Stability", num(0.3), slider(0.0, 1.0, 0.0, 1.0, 3)), + check("fixedEndpoint", "Fixed Endpoint", true), + p("width", "Width", num(10.0), slider(0.0, 100.0, 0.0, 50.0, 1)), + p("widthVariation", "Width Variation", num(0.25), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("coreWidth", "Core Width", num(0.4), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("outsideColor", "Outside Color", col(0.36, 0.25, 0.9), ParamUi::Color), + p("insideColor", "Inside Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("pullForce", "Pull Force", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 1)), + p("pullDirection", "Pull Direction", num(0.0), ang()), + p("randomSeed", "Random Seed", num(1.0), slider(0.0, 10000.0, 0.0, 100.0, 0)), + p("blendingMode", "Blending Mode", Value::Enum(0), popup(&["Normal", "Add", "Screen"])), + check("rerunAtEachFrame", "Rerun At Each Frame", false), + ], + lightning, + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{EffectEnv, MaskShape, Params}; + + fn run_env(id: &str, set: &[(&str, Value)], img: Image, time: f64, env: EffectEnv) -> Buf { + let s = crate::find(id).unwrap(); + let ls = [img.width as f64, img.height as f64]; + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + for p in &s.params { + if let (ParamUi::Point, Value::Vec2(v)) = (&p.ui, &p.default) { + params.values.insert(p.id.to_string(), Value::Vec2([v[0] * ls[0], v[1] * ls[1]])); + } + } + for (k, v) in set { + assert!(params.values.contains_key(*k), "{id}: unknown {k}"); + params.values.insert(k.to_string(), v.clone()); + } + let ctx = EffectCtx { params: ¶ms, time, layer_size: ls, seed: 3, adjustment: false, env }; + crate::apply(s, &ctx, Buf { img, offset: [0.0, 0.0], scale: 1.0 }) + } + + fn run(id: &str, set: &[(&str, Value)], img: Image, time: f64) -> Buf { + run_env(id, set, img, time, EffectEnv::default()) + } + + fn ink(img: &Image) -> f32 { + img.data.iter().map(|p| p[3]).sum() + } + + #[test] + fn every_glyph_parses_within_grid() { + for c in (b'!'..=b'~').map(|c| c as char) { + for s in parse_glyph(c) { + for q in s { + assert!((-0.5..=4.5).contains(&q[0]) && (-0.5..=7.0).contains(&q[1]), "{c}: {q:?}"); + } + } + } + assert!(!parse_glyph('A').is_empty() && !parse_glyph('a').is_empty() && parse_glyph(' ').is_empty()); + } + + #[test] + fn numbers_formats() { + let params = |vals: &[(&str, Value)]| { + let s = crate::find("ec.text.numbers").unwrap(); + let mut p = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + for (k, v) in vals { + p.values.insert(k.to_string(), v.clone()); + } + p + }; + let txt = |vals: &[(&str, Value)], t: f64| { + let pr = params(vals); + let ctx = EffectCtx { params: &pr, time: t, layer_size: [10.0, 10.0], seed: 1, adjustment: false, env: Default::default() }; + numbers_text(&ctx) + }; + assert_eq!(txt(&[("value", num(8.0))], 0.0), "8.00"); + assert_eq!(txt(&[("value", num(42.5)), ("type", Value::Enum(1))], 0.0), "00042.50"); + assert_eq!(txt(&[("value", num(3661.5)), ("type", Value::Enum(2))], 0.0), "01:01:01:15"); + assert_eq!(txt(&[("value", num(255.0)), ("type", Value::Enum(9))], 0.0), "FF"); + assert_eq!(txt(&[("value", num(0.0)), ("type", Value::Enum(6))], 0.0), "01/01/00"); + assert_eq!(txt(&[("value", num(60.0)), ("type", Value::Enum(8))], 0.0), "Wednesday, March 1, 2000"); + assert_eq!(txt(&[("value", num(1.0)), ("currentTimeDate", Value::Bool(true)), ("decimalPlaces", num(1.0))], 2.0), "3.0"); + assert_ne!( + txt(&[("value", num(100.0)), ("randomValues", Value::Bool(true))], 0.0), + txt(&[("value", num(100.0)), ("randomValues", Value::Bool(true))], 1.0) + ); + } + + #[test] + fn numbers_draws_glyph_at_position() { + let img = Image::new(80, 60); + let a = run("ec.text.numbers", &[("value", num(8.0)), ("decimalPlaces", num(0.0))], img.clone(), 0.0); + assert_eq!(a.img.data, run("ec.text.numbers", &[("value", num(8.0)), ("decimalPlaces", num(0.0))], img.clone(), 0.0).img.data); + // Ink only around the centre. + let mut inside = 0.0; + let mut outside = 0.0; + for y in 0..60 { + for x in 0..80 { + let v = a.img.get(x, y)[3]; + if (25..55).contains(&x) && (10..50).contains(&y) { + inside += v; + } else { + outside += v; + } + } + } + assert!(inside > 20.0 && outside < 0.5, "{inside} {outside}"); + let stroke = run("ec.text.numbers", &[("value", num(8.0)), ("displayOptions", Value::Enum(2))], img, 0.0); + assert!(stroke.img.data.iter().any(|p| p[3] > 0.5 && p[1] > 0.5 * p[3]), "white stroke visible"); + } + + #[test] + fn timecode_changes_over_time() { + let img = Image::filled(120, 60, [0.2, 0.2, 0.2, 1.0]); + let a = run("ec.text.timecode", &[], img.clone(), 0.0); + let b = run("ec.text.timecode", &[], img.clone(), 1.0); + assert_eq!(a.img.data, run("ec.text.timecode", &[], img.clone(), 0.0).img.data); + assert_ne!(a.img.data, b.img.data); + assert_eq!(timecode(30 * 61 + 5, 30.0), "00:01:01:05"); + assert_eq!(drop_frame(1800, 30), "00;01;00;02"); + assert_eq!(drop_frame(17982, 30), "00;10;00;00"); + } + + #[test] + fn basic_text_draws_and_respects_composite() { + let img = Image::filled(100, 50, [0.0, 0.0, 1.0, 1.0]); + let a = run("ec.obsolete.basictext", &[("text", Value::Str("Hi".into()))], img.clone(), 0.0); + assert_eq!(a.img.get(1, 1), [0.0, 0.0, 1.0, 1.0]); + assert!(a.img.data.iter().any(|p| p[0] > 0.9)); + let t = run("ec.obsolete.basictext", &[("text", Value::Str("Hi".into())), ("compositeOnOriginal", Value::Bool(false))], img, 0.0); + assert_eq!(t.img.get(1, 1)[3], 0.0); + assert!(ink(&t.img) > 10.0); + } + + #[test] + fn path_text_follows_line_and_mask() { + let img = Image::new(120, 60); + let line = run( + "ec.obsolete.pathtext", + &[("shapeType", Value::Enum(3)), ("vertex1", Value::Vec2([5.0, 50.0])), ("vertex2", Value::Vec2([115.0, 50.0])), ("size", num(20.0))], + img.clone(), + 0.0, + ); + assert!(ink(&line.img) > 10.0); + // Text sits above the baseline at y = 50. + let below: f32 = (52..60).flat_map(|y| (0..120).map(move |x| (x, y))).map(|(x, y)| line.img.get(x, y)[3]).sum(); + assert!(below < 1.0); + let masks = vec![MaskShape { name: "M".into(), points: vec![[10.0, 20.0], [110.0, 20.0]], closed: false, inverted: false }]; + let env = EffectEnv { masks: &masks, ..Default::default() }; + let m = run_env("ec.obsolete.pathtext", &[("customPath", num(1.0)), ("size", num(20.0))], img.clone(), 0.0, env); + let near: f32 = (0..24).flat_map(|y| (0..120).map(move |x| (x, y))).map(|(x, y)| m.img.get(x, y)[3]).sum(); + assert!(near > 10.0 && (ink(&m.img) - near).abs() < 1.0); + let circle = run("ec.obsolete.pathtext", &[("shapeType", Value::Enum(1)), ("text", Value::Str("CIRCLE TEXT".into()))], img, 0.0); + assert!(ink(&circle.img) > 10.0); + } + + #[test] + fn lightning_deterministic_and_animates() { + let img = Image::new(96, 64); + let a = run("ec.obsolete.lightning", &[], img.clone(), 0.5); + let b = run("ec.obsolete.lightning", &[], img.clone(), 0.5); + assert_eq!(a.img.data, b.img.data); + assert!(a.img.get(48, 32)[3] > 0.0 || ink(&a.img) > 50.0); + let c = run("ec.obsolete.lightning", &[], img.clone(), 1.5); + assert_ne!(a.img.data, c.img.data); + let r1 = run("ec.obsolete.lightning", &[("rerunAtEachFrame", Value::Bool(true))], img.clone(), 0.5); + let r2 = run("ec.obsolete.lightning", &[("rerunAtEachFrame", Value::Bool(true))], img, 0.5); + assert_eq!(r1.img.data, r2.img.data); + } } From 8599ac5828eb7748144166cb4325ce7db49380e0 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 22:09:59 -0400 Subject: [PATCH 13/14] M9.3: Simulation effects (CC Rainfall, CC Snowfall, CC Bubbles, CC Drizzle, CC Star Burst, CC Particle World, CC Particle Systems II, CC Mr. Mercury, CC Ball Action, CC Pixel Polly, CC Scatterize, Card Dance, Caustics, Wave World, Shatter, Foam) Closed-form weather/bubble/star effects; stepped particle, wave and foam sims through the checkpointed SimCache (seek-consistent); band-bucketed sprite/piece rasteriser and textured 3D pieces via per-piece inverse homography. Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/sim.rs | 1529 +++++++++++++++++++++++++++++++++- crates/effects/src/sim2.rs | 1609 +++++++++++++++++++++++++++++++++++- 2 files changed, 3132 insertions(+), 6 deletions(-) diff --git a/crates/effects/src/sim.rs b/crates/effects/src/sim.rs index dc8d20ed..024a91fc 100644 --- a/crates/effects/src/sim.rs +++ b/crates/effects/src/sim.rs @@ -1,7 +1,1530 @@ -//! Simulation effects (particles, physics, liquids). +//! 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 crate::EffectSpec; +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, 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( + img: &mut Image, + items: &[T], + bbox: impl Fn(&T) -> Option<[f32; 4]> + Sync, + shade: impl Fn(&T, f32, f32) -> Option + 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> = 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![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 { + // 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("appearance") == 0; + let off = pr.v2("offset"); + let seed = pr.f("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 = (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("wiggleAmount") as f32; + let wigvar = pr.f("wiggleAmountVariation") as f32 / 100.0; + let wfreq = pr.f("wiggleFrequency") as f32; + let wfvar = pr.f("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("offset"); + let seed = pr.f("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 = (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 = (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("lightIntensity") as f32 / 100.0; + let lc = pr.color("lightColor"); + let lheight = pr.f("lightHeight") as f32 / 100.0; + let ldir = (pr.f("lightDirection") as f32).to_radians(); + let ambient = pr.f("ambient") as f32 / 100.0; + let diffuse = pr.f("diffuse") as f32 / 100.0; + let specular = pr.f("specular") as f32 / 100.0; + let rough = pr.f("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 = 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, + 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, key: u64, t: f64, phys: &Phys) -> std::sync::Arc { + 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 = 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("producerX") as f32, pr.f("producerY") as f32, pr.f("producerZ") as f32], + radius: [pr.f("radiusX").max(0.0) as f32, pr.f("radiusY").max(0.0) as f32, pr.f("radiusZ").max(0.0) as f32], + anim: pr.e("animation"), + speed: pr.f("velocity") as f32 * 0.5, + gravity: { + let g = pr.f("gravity") as f32; + let gv = [pr.f("gravityX") as f32, pr.f("gravityY") as f32, pr.f("gravityZ") as f32]; + [gv[0] * g, gv[1] * g, gv[2] * g] + }, + resistance: pr.f("resistance").max(0.0) as f32, + axis: [pr.f("axisX") as f32, pr.f("axisY") as f32, pr.f("axisZ") as f32], + extra: pr.f("extra") as f32, + extra_angle: pr.f("extraAngle") as f32, + seed: (pr.f("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("particleType"); + let birth = pr.color("birthColor"); + let death = pr.color("deathColor"); + let (bs, ds) = (pr.f("birthSize") as f32, pr.f("deathSize") as f32); + let svar = pr.f("sizeVariation") as f32 / 100.0; + let max_op = pr.f("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 = list.into_iter().map(|(_, s)| s).collect(); + let mode = pr.e("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 = 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("position"); + let dir = (pr.f("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("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.25, + gravity: [0.0, pr.f("gravity") as f32 * 0.5, 0.0], + resistance: pr.f("resistance").max(0.0) as f32, + // AE angles: 0° = up, clockwise. + axis: [dir.sin(), -dir.cos(), 0.0], + extra: pr.f("extra") as f32, + extra_angle: 45.0 * pr.f("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("particleType"); + let birth = pr.color("birthColor"); + let death = pr.color("deathColor"); + let (bs, ds) = (pr.f("birthSize") as f32, pr.f("deathSize") as f32); + let svar = pr.f("sizeVariation") as f32 / 100.0; + let max_op = pr.f("maxOpacity") as f32 / 100.0; + let s = b.scale as f32; + let sprites: Vec = 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("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 = 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 = 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("ambient") as f32 / 100.0; + let diffuse = pr.f("diffuse") as f32 / 100.0; + let specular = pr.f("specular") as f32 / 100.0; + let rough = pr.f("roughness").max(0.001) as f32; + let metal = pr.f("metal") as f32 / 100.0; + let li = pr.f("lightIntensity") as f32 / 100.0; + let lc = pr.color("lightColor"); + let ldir = (pr.f("lightDirection") as f32).to_radians(); + let lheight = pr.f("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 { - Vec::new() + 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("appearance", "Appearance", Value::Enum(0), popup(&["Refracting", "Soft Solid"])), + p("offset", "Offset", pt(0.5, 0.5), ParamUi::Point), + p("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("wiggleAmount", "Amount (Wiggle)", num(2.0), slider(0.0, 100.0, 0.0, 20.0, 2)), + p("wiggleAmountVariation", "Variation % (Amount)", num(50.0), pct()), + p("wiggleFrequency", "Frequency (Wiggle)", num(1.0), slider(0.0, 20.0, 0.0, 5.0, 2)), + p("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("offset", "Offset", pt(0.5, 0.5), ParamUi::Point), + p("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("lightIntensity", "Light Intensity", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)), + p("lightColor", "Light Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("lightHeight", "Light Height", num(25.0), slider(-100.0, 100.0, -100.0, 100.0, 1)), + p("lightDirection", "Light Direction", num(-45.0), ParamUi::Angle), + p("ambient", "Ambient", num(0.0), slider(-200.0, 200.0, -100.0, 100.0, 1)), + p("diffuse", "Diffuse", num(0.0), slider(0.0, 200.0, 0.0, 100.0, 1)), + p("specular", "Specular", num(20.0), slider(0.0, 200.0, 0.0, 100.0, 1)), + p("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("producerX", "Position X", num(0.0), slider(-10.0, 10.0, -1.0, 1.0, 2)), + p("producerY", "Position Y", num(0.0), slider(-10.0, 10.0, -1.0, 1.0, 2)), + p("producerZ", "Position Z", num(0.0), slider(-10.0, 10.0, -1.0, 1.0, 2)), + p("radiusX", "Radius X", num(0.025), slider(0.0, 10.0, 0.0, 1.0, 3)), + p("radiusY", "Radius Y", num(0.025), slider(0.0, 10.0, 0.0, 1.0, 3)), + p("radiusZ", "Radius Z", num(0.025), slider(0.0, 10.0, 0.0, 1.0, 3)), + p("animation", "Animation", Value::Enum(0), popup(PW_ANIMS)), + p("velocity", "Velocity", num(1.0), slider(-10.0, 10.0, 0.0, 5.0, 2)), + p("inheritVelocity", "Inherit Velocity %", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 1)), + p("gravity", "Gravity", num(0.5), slider(-10.0, 10.0, -2.0, 2.0, 3)), + 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("extraAngle", "Extra Angle", num(360.0), ParamUi::Angle), + p("axisX", "Direction Axis X", num(0.0), slider(-1.0, 1.0, -1.0, 1.0, 2)), + p("axisY", "Direction Axis Y", num(-1.0), slider(-1.0, 1.0, -1.0, 1.0, 2)), + p("axisZ", "Direction Axis Z", num(0.0), slider(-1.0, 1.0, -1.0, 1.0, 2)), + p("gravityX", "Gravity Vector X", num(0.0), slider(-1.0, 1.0, -1.0, 1.0, 2)), + p("gravityY", "Gravity Vector Y", num(1.0), slider(-1.0, 1.0, -1.0, 1.0, 2)), + p("gravityZ", "Gravity Vector Z", num(0.0), slider(-1.0, 1.0, -1.0, 1.0, 2)), + p("particleType", "Particle Type", Value::Enum(0), popup(PARTICLE_TYPES)), + p("birthSize", "Birth Size", num(0.2), slider(0.0, 10.0, 0.0, 1.0, 3)), + p("deathSize", "Death Size", num(0.2), slider(0.0, 10.0, 0.0, 1.0, 3)), + p("sizeVariation", "Size Variation", num(0.0), pct()), + p("maxOpacity", "Max Opacity", num(75.0), pct()), + p("birthColor", "Birth Color", col(1.0, 1.0, 0.0), ParamUi::Color), + p("deathColor", "Death Color", col(0.6, 0.0, 0.0), ParamUi::Color), + p("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_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("position", "Position", pt(0.5, 0.5), ParamUi::Point), + p("radiusX", "Radius X", num(0.0), slider(0.0, 2000.0, 0.0, 200.0, 1)), + p("radiusY", "Radius Y", num(0.0), slider(0.0, 2000.0, 0.0, 200.0, 1)), + p("animation", "Animation", Value::Enum(0), popup(PW_ANIMS)), + p("velocity", "Velocity", num(2.0), slider(-20.0, 20.0, 0.0, 10.0, 2)), + p("inheritVelocity", "Inherit Velocity %", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 1)), + p("gravity", "Gravity", num(1.0), 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("direction", "Direction", num(0.0), ParamUi::Angle), + p("extra", "Extra", num(1.0), slider(0.0, 10.0, 0.0, 2.0, 2)), + p("particleType", "Particle Type", Value::Enum(0), popup(PARTICLE_TYPES)), + p("birthSize", "Birth Size", num(0.5), slider(0.0, 20.0, 0.0, 5.0, 2)), + p("deathSize", "Death Size", num(1.5), slider(0.0, 20.0, 0.0, 5.0, 2)), + p("sizeVariation", "Size Variation", num(0.0), pct()), + p("maxOpacity", "Max Opacity", num(75.0), pct()), + p("birthColor", "Birth Color", col(1.0, 1.0, 0.0), ParamUi::Color), + p("deathColor", "Death Color", col(0.6, 0.0, 0.0), ParamUi::Color), + p("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("lightIntensity", "Light Intensity", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)), + p("lightColor", "Light Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("lightHeight", "Light Height", num(50.0), slider(-100.0, 100.0, -100.0, 100.0, 1)), + p("lightDirection", "Light Direction", num(-45.0), ParamUi::Angle), + p("ambient", "Ambient", num(0.0), slider(-200.0, 200.0, -100.0, 100.0, 1)), + p("diffuse", "Diffuse", num(75.0), slider(0.0, 200.0, 0.0, 100.0, 1)), + p("specular", "Specular", num(50.0), slider(0.0, 200.0, 0.0, 100.0, 1)), + p("roughness", "Roughness", num(0.05), slider(0.001, 1.0, 0.001, 0.5, 3)), + p("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::()).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)), ("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)), ("randomSeed", num(seed)), ("particleType", Value::Enum(2)), ("birthSize", num(0.3)), ("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 = SimCache::new(2); + let b: SimCache = 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)), + ("velocity", num(0.2)), + ("gravity", num(g)), + ("particleType", Value::Enum(2)), + ("birthSize", num(0.2)), + ("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![("position", pt(24.0, 24.0)), ("birthRate", num(3.0)), ("gravity", num(0.0)), ("velocity", num(1.0)), ("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(("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: ¶ms, 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::() / i.data.len() as f32; + assert!((sum(&full.img) - sum(&half.img)).abs() < 0.05, "{} {}", sum(&full.img), sum(&half.img)); + } } diff --git a/crates/effects/src/sim2.rs b/crates/effects/src/sim2.rs index d8d6ce96..133dc87e 100644 --- a/crates/effects/src/sim2.rs +++ b/crates/effects/src/sim2.rs @@ -1,7 +1,1610 @@ -//! Simulation effects, part 2. +//! Simulation effects, part 2: layer-shattering and surface simulations — CC Ball Action, +//! CC Pixel Polly, CC Scatterize, Card Dance, Shatter (planar pieces in perspective), Caustics, +//! Wave World (stepped 2D wave equation) and Foam (stepped bubble sim). +//! +//! Written from the public descriptions of the effects' behaviour. Pieces are textured convex +//! polygons placed in 3D and drawn through a per-piece inverse homography, far to near. -use crate::EffectSpec; +use effectcraft_keyframe::Value; +use effectcraft_project::ParamUi; +use effectcraft_raster::{Image, Px}; +use rayon::prelude::*; + +use crate::generate::value_noise; +use crate::sim::{Acc, Shape, Sprite, h, hs, pct, pt, raster, spec, splat}; +use crate::util::{Plane, SimCache, gauss_plane, layer_or_self, params_key, unpremul}; +use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; + +// ------------------------------------------------------------------ 3D pieces + +type M3 = [[f64; 3]; 3]; + +fn mat_mul(a: &M3, b: &M3) -> M3 { + std::array::from_fn(|i| std::array::from_fn(|j| (0..3).map(|k| a[i][k] * b[k][j]).sum())) +} + +/// Rotation from Euler angles (radians), applied X then Y then Z. +fn rot_xyz(x: f64, y: f64, z: f64) -> M3 { + let (sx, cx) = x.sin_cos(); + let (sy, cy) = y.sin_cos(); + let (sz, cz) = z.sin_cos(); + let rx = [[1.0, 0.0, 0.0], [0.0, cx, -sx], [0.0, sx, cx]]; + let ry = [[cy, 0.0, sy], [0.0, 1.0, 0.0], [-sy, 0.0, cy]]; + let rz = [[cz, -sz, 0.0], [sz, cz, 0.0], [0.0, 0.0, 1.0]]; + mat_mul(&rz, &mat_mul(&ry, &rx)) +} + +/// Rotation by `angle` about unit `axis` (Rodrigues). +fn rot_axis(axis: [f64; 3], angle: f64) -> M3 { + let l = (axis[0] * axis[0] + axis[1] * axis[1] + axis[2] * axis[2]).sqrt(); + if l < 1e-12 || angle == 0.0 { + return [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]; + } + let (x, y, z) = (axis[0] / l, axis[1] / l, axis[2] / l); + let (s, c) = angle.sin_cos(); + let t = 1.0 - c; + [ + [t * x * x + c, t * x * y - s * z, t * x * z + s * y], + [t * x * y + s * z, t * y * y + c, t * y * z - s * x], + [t * x * z - s * y, t * y * z + s * x, t * z * z + c], + ] +} + +fn inv3(m: &M3) -> Option { + let det = m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0]) + + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]); + if det.abs() < 1e-12 { + return None; + } + let d = 1.0 / det; + Some([ + [(m[1][1] * m[2][2] - m[1][2] * m[2][1]) * d, (m[0][2] * m[2][1] - m[0][1] * m[2][2]) * d, (m[0][1] * m[1][2] - m[0][2] * m[1][1]) * d], + [(m[1][2] * m[2][0] - m[1][0] * m[2][2]) * d, (m[0][0] * m[2][2] - m[0][2] * m[2][0]) * d, (m[0][2] * m[1][0] - m[0][0] * m[1][2]) * d], + [(m[1][0] * m[2][1] - m[1][1] * m[2][0]) * d, (m[0][1] * m[2][0] - m[0][0] * m[2][1]) * d, (m[0][0] * m[1][1] - m[0][1] * m[1][0]) * d], + ]) +} + +/// Perspective camera looking down +z at the layer plane z = 0, `d` pixels away, centred on +/// `c` (buffer pixels): screen = c + (P - c) · d / (d + z). +#[derive(Clone, Copy)] +struct Cam { + c: [f64; 2], + d: f64, +} + +/// A planar textured piece ready to draw. +#[derive(Clone, Copy)] +struct Piece { + /// Screen (buffer px, homogeneous) → piece-local (a, b, w). + inv: M3, + /// Rest centre of the piece in texture pixels. + c0: [f32; 2], + /// Convex polygon (texture pixels), counter-clockwise in y-down space. + poly: [[f32; 2]; 6], + n: u8, + bbox: [f32; 4], + shade: f32, + alpha: f32, + /// Flat straight colour instead of the texture. + flat: Option<[f32; 3]>, + /// Facing away from the camera (draw the back texture). + back: bool, + depth: f64, +} + +impl Piece { + /// Place polygon `poly` (texture px, around rest centre `c0`) with rotation `r` and its + /// centre at 3D `pos` (buffer px; z away from the camera). + fn new(poly: &[[f32; 2]], c0: [f32; 2], r: &M3, pos: [f64; 3], cam: Cam) -> Option { + let n = poly.len().min(6); + if n < 3 { + return None; + } + let (cx, cy, d) = (cam.c[0], cam.c[1], cam.d); + let zc = pos[2]; + let hm: M3 = [ + [cx * r[2][0] + d * r[0][0], cx * r[2][1] + d * r[0][1], cx * (d + zc) + d * (pos[0] - cx)], + [cy * r[2][0] + d * r[1][0], cy * r[2][1] + d * r[1][1], cy * (d + zc) + d * (pos[1] - cy)], + [r[2][0], r[2][1], d + zc], + ]; + let mut bb = [f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY]; + for v in &poly[..n] { + let (a, b) = ((v[0] - c0[0]) as f64, (v[1] - c0[1]) as f64); + let w = hm[2][0] * a + hm[2][1] * b + hm[2][2]; + if w < d * 0.05 { + return None; + } + let sx = ((hm[0][0] * a + hm[0][1] * b + hm[0][2]) / w) as f32; + let sy = ((hm[1][0] * a + hm[1][1] * b + hm[1][2]) / w) as f32; + bb = [bb[0].min(sx), bb[1].min(sy), bb[2].max(sx), bb[3].max(sy)]; + } + let inv = inv3(&hm)?; + let mut pp = [[0.0f32; 2]; 6]; + pp[..n].copy_from_slice(&poly[..n]); + // Normalise winding so "inside" is cross >= 0 for every edge. + let area: f32 = (0..n).map(|i| pp[i][0] * pp[(i + 1) % n][1] - pp[(i + 1) % n][0] * pp[i][1]).sum(); + if area < 0.0 { + pp[..n].reverse(); + } + // Front normal (0, 0, -1) rotated; facing the camera when its z is negative. + let nz = -r[2][2]; + Some(Piece { + inv, + c0, + poly: pp, + n: n as u8, + bbox: [bb[0] - 1.0, bb[1] - 1.0, bb[2] + 1.0, bb[3] + 1.0], + shade: 1.0, + alpha: 1.0, + flat: None, + back: nz > 0.0, + depth: zc, + }) + } + + /// Lambert factor of the piece normal against a light shining along +z. + fn facing(r: &M3) -> f32 { + r[2][2].abs() as f32 + } +} + +/// Draw pieces (already sorted far → near) over `out`. +fn draw_pieces(out: &mut Image, pieces: &[Piece], front: &Image, back: Option<&Image>) { + raster( + out, + pieces, + |q| Some(q.bbox), + |q, x, y| { + let (xf, yf) = (x as f64, y as f64); + let m = &q.inv; + let w = m[2][0] * xf + m[2][1] * yf + m[2][2]; + if w.abs() < 1e-12 { + return None; + } + let u = ((m[0][0] * xf + m[0][1] * yf + m[0][2]) / w) as f32 + q.c0[0]; + let v = ((m[1][0] * xf + m[1][1] * yf + m[1][2]) / w) as f32 + q.c0[1]; + let n = q.n as usize; + let mut md = f32::INFINITY; + for i in 0..n { + let a = q.poly[i]; + let b = q.poly[(i + 1) % n]; + let (ex, ey) = (b[0] - a[0], b[1] - a[1]); + let l = (ex * ex + ey * ey).sqrt().max(1e-6); + md = md.min((ex * (v - a[1]) - ey * (u - a[0])) / l); + } + // Coverage extends half a pixel past the edge so abutting pieces leave no seams. + let cov = (md + 1.0).clamp(0.0, 1.0); + if cov <= 0.0 { + return None; + } + let (c, a) = match q.flat { + Some(c) => (c, 1.0), + None => { + let tex = if q.back { back.unwrap_or(front) } else { front }; + unpremul(tex.sample_bilinear(u as f64, v as f64)) + } + }; + let a = a * cov * q.alpha; + if a <= 1e-6 { + return None; + } + let k = q.shade; + Some([c[0] * k * a, c[1] * k * a, c[2] * k * a, a]) + }, + Acc::Over, + ); +} + +fn sort_far_first(pieces: &mut [Piece]) { + pieces.sort_by(|a, b| b.depth.total_cmp(&a.depth)); +} + +// ------------------------------------------------------------------ CC Ball Action + +fn ball_action(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let scatter = pr.f("scatter") as f32; + let axis = pr.e("rotationAxis"); + let rot = pr.f("rotation").to_radians(); + let twist_prop = pr.e("twistProperty"); + let twist = pr.f("twistAngle").to_radians(); + let spacing = pr.f("gridSpacing").max(1.0) as f32; + let size = pr.f("ballSize") as f32 / 100.0; + let instab = pr.f("instabilityState").to_radians() as f32; + let seed = ctx.seed.wrapping_mul(0x2545f491); + let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32); + let s = b.scale as f32; + let nx = (lw / spacing).ceil().max(1.0) as u32; + let ny = (lh / spacing).ceil().max(1.0) as u32; + let d = 2.0 * lw.max(lh) as f64; + let (cx, cy) = (lw as f64 * 0.5, lh as f64 * 0.5); + let src = b.img.clone(); + let ax: [f64; 3] = match axis { + 0 => [1.0, 0.0, 0.0], + 1 => [0.0, 1.0, 0.0], + 2 => [0.0, 0.0, 1.0], + 3 => [1.0, 1.0, 0.0], + 4 => [1.0, 0.0, 1.0], + 5 => [0.0, 1.0, 1.0], + _ => [1.0, 1.0, 1.0], + }; + let maxr = ((lw * lw + lh * lh).sqrt() * 0.5).max(1.0); + let mut balls: Vec<(f64, Sprite)> = (0..nx * ny) + .into_par_iter() + .filter_map(|i| { + let gx = (i % nx) as f32 * spacing + spacing * 0.5; + let gy = (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 || size <= 0.0 { + return None; + } + let tp = match twist_prop { + 0 => gx / lw.max(1.0), + 1 => gy / lh.max(1.0), + 2 => ((gx - cx as f32).hypot(gy - cy as f32)) / maxr, + _ => crate::util::rgb3([c[0], c[1], c[2], 0.0]).iter().sum::() / 3.0, + } as f64; + let ang = rot + twist * tp; + // Scatter: random 3D offsets whose direction drifts with the instability state. + let ph = h(i, 1, seed) * std::f32::consts::TAU + instab; + let sc = scatter * h(i, 2, seed); + let off = [(sc * ph.cos()) as f64, (sc * ph.sin()) as f64, (scatter * hs(i, 3, seed)) as f64]; + let p0 = [gx as f64 - cx + off[0], gy as f64 - cy + off[1], off[2]]; + let m = rot_axis(ax, ang); + let q: [f64; 3] = std::array::from_fn(|k| m[k][0] * p0[0] + m[k][1] * p0[1] + m[k][2] * p0[2]); + if d + q[2] <= d * 0.05 { + return None; + } + let persp = d / (d + q[2]); + let (bx, by) = b.to_px([cx + q[0] * persp, cy + q[1] * persp]); + let r = spacing * 0.5 * size * persp as f32 * s; + Some((q[2], Sprite::new(bx as f32, by as f32, r, [c[0], c[1], c[2], a], Shape::Sphere))) + }) + .collect(); + balls.sort_by(|a, b| b.0.total_cmp(&a.0)); + let sprites: Vec = balls.into_iter().map(|(_, s)| s).collect(); + b.img = splat(src.width, src.height, &sprites, Acc::Over); + b +} + +// ------------------------------------------------------------------ CC Pixel Polly + +fn pixel_polly(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let t = ctx.time - pr.f("startTime"); + if t <= 0.0 { + return b; + } + let force = pr.f("force"); + let gravity = pr.f("gravity"); + let spin = pr.f("spinning").to_radians(); + let fc = pr.v2("forceCenter"); + let dir_rand = pr.f("directionRandomness") / 100.0; + let spd_rand = pr.f("speedRandomness") / 100.0; + let g = pr.f("gridSpacing").max(1.0); + let object = pr.e("object"); + let depth_sort = pr.b("enableDepthSort"); + let seed = ctx.seed.wrapping_mul(0x632be5ab); + let [lw, lh] = ctx.layer_size; + let s = b.scale; + let cam = Cam { c: [b.offset[0] + lw * 0.5 * s, b.offset[1] + lh * 0.5 * s], d: 2.0 * lw.max(lh) * s }; + let nx = (lw / g).ceil().max(1.0) as u32; + let ny = (lh / g).ceil().max(1.0) as u32; + let tri = object <= 1; + let textured = object == 1 || object == 3; + let maxd = lw.max(lh); + let src = b.img.clone(); + let per = if tri { 2 } else { 1 }; + let mut pieces: Vec = (0..nx * ny * per) + .into_par_iter() + .filter_map(|i| { + let cell = i / per; + let (x0, y0) = ((cell % nx) as f64 * g, (cell / nx) as f64 * g); + let poly: Vec<[f64; 2]> = if !tri { + vec![[x0, y0], [x0 + g, y0], [x0 + g, y0 + g], [x0, y0 + g]] + } else if i % 2 == 0 { + vec![[x0, y0], [x0 + g, y0], [x0, y0 + g]] + } else { + vec![[x0 + g, y0], [x0 + g, y0 + g], [x0, y0 + g]] + }; + let c = [poly.iter().map(|v| v[0]).sum::() / poly.len() as f64, poly.iter().map(|v| v[1]).sum::() / poly.len() as f64]; + let (dx, dy) = (c[0] - fc[0], c[1] - fc[1]); + let dist = (dx * dx + dy * dy).sqrt().max(1e-6); + let ang = dy.atan2(dx) + dir_rand * std::f64::consts::PI * hs(i, 1, seed) as f64; + let speed = force / 100.0 * 0.6 * maxd / (1.0 + dist / (0.5 * maxd)) * (1.0 + spd_rand * hs(i, 2, seed) as f64); + let vz = -speed * 0.5 * h(i, 3, seed) as f64; + let pos = [c[0] + ang.cos() * speed * t, c[1] + ang.sin() * speed * t + 0.5 * gravity * 0.5 * lh * t * t, vz * t]; + let axis = [hs(i, 4, seed) as f64, hs(i, 5, seed) as f64, hs(i, 6, seed) as f64]; + let r = rot_axis(axis, t * (std::f64::consts::PI * h(i, 7, seed) as f64 + spin)); + let tex: Vec<[f32; 2]> = poly.iter().map(|v| [(v[0] * s + b.offset[0]) as f32, (v[1] * s + b.offset[1]) as f32]).collect(); + let c0 = [(c[0] * s + b.offset[0]) as f32, (c[1] * s + b.offset[1]) as f32]; + let posb = [pos[0] * s + b.offset[0], pos[1] * s + b.offset[1], pos[2] * s]; + let mut pc = Piece::new(&tex, c0, &r, posb, cam)?; + pc.shade = 0.35 + 0.65 * Piece::facing(&r); + if !textured { + let (cc, a) = unpremul(src.sample_bilinear(c0[0] as f64, c0[1] as f64)); + if a <= 0.0 { + return None; + } + pc.flat = Some(cc); + pc.alpha = a; + } + Some(pc) + }) + .collect(); + if depth_sort { + sort_far_first(&mut pieces); + } + let mut out = Image::new(src.width, src.height); + draw_pieces(&mut out, &pieces, &src, None); + b.img = out; + b +} + +// ------------------------------------------------------------------ CC Scatterize + +fn scatterize(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let scatter = pr.f("scatter") as f32; + let right = pr.f("rightTwist").to_radians(); + let left = pr.f("leftTwist").to_radians(); + if scatter == 0.0 && right == 0.0 && left == 0.0 { + return b; + } + let add = pr.e("transferMode") == 1; + let seed = ctx.seed.wrapping_mul(0x7feb352d); + let s = b.scale; + let (w, hh) = (b.img.width, b.img.height); + let (cx, cy) = (b.offset[0] + ctx.layer_size[0] * 0.5 * s, b.offset[1] + ctx.layer_size[1] * 0.5 * s); + let half = (ctx.layer_size[0] * 0.5 * s).max(1.0); + let d = 2.0 * ctx.layer_size[0].max(ctx.layer_size[1]) * s; + let src = &b.img; + let sprites: Vec = (0..w * hh) + .into_par_iter() + .filter_map(|i| { + let px = src.data[i as usize]; + if px[3] <= 0.0 { + return None; + } + let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5); + // Twist about the horizontal axis through the centre, growing towards each side. + let k = ((x - cx) / half).abs().min(1.0); + let ang = if x < cx { left } else { right } * k; + let (sa, ca) = ang.sin_cos(); + let (yy, zz) = ((y - cy) * ca, (y - cy) * sa); + let persp = d / (d + zz).max(d * 0.05); + let jx = hs(i, 1, seed) * scatter * s as f32; + let jy = hs(i, 2, seed) * scatter * s as f32; + let sx = cx + (x - cx) * persp + jx as f64; + let sy = cy + yy * persp + jy as f64; + let (c, a) = unpremul(px); + Some(Sprite::new(sx as f32, sy as f32, 0.6 * persp as f32, [c[0], c[1], c[2], a], Shape::Disc)) + }) + .collect(); + b.img = splat(w, hh, &sprites, if add { Acc::Add } else { Acc::Over }); + b +} + +// ------------------------------------------------------------------ Card Dance + +const CD_SOURCES: &[&str] = &["None", "Intensity 1", "Red 1", "Green 1", "Blue 1", "Alpha 1", "Intensity 2", "Red 2", "Green 2", "Blue 2", "Alpha 2"]; +const CD_PROPS: [(&str, &str); 8] = [ + ("xPos", "X Position"), + ("yPos", "Y Position"), + ("zPos", "Z Position"), + ("xRot", "X Rotation"), + ("yRot", "Y Rotation"), + ("zRot", "Z Rotation"), + ("xScale", "X Scale"), + ("yScale", "Y Scale"), +]; + +fn card_source(src: u32, g1: [f32; 4], g2: [f32; 4]) -> f32 { + let (c1, a1) = unpremul(g1); + let (c2, a2) = unpremul(g2); + let lum = |c: [f32; 3]| effectcraft_color::luminance(c[0], c[1], c[2]); + match src { + 1 => lum(c1), + 2 => c1[0], + 3 => c1[1], + 4 => c1[2], + 5 => a1, + 6 => lum(c2), + 7 => c2[0], + 8 => c2[1], + 9 => c2[2], + 10 => a2, + _ => 0.0, + } +} + +fn card_dance(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let rows = pr.f("rows").clamp(1.0, 1000.0) as u32; + let cols = if pr.e("rowsColumns") == 1 { rows } else { pr.f("columns").clamp(1.0, 1000.0) as u32 }; + let g1 = layer_or_self(ctx, &b, "gradientLayer1", true, true); + let g2 = layer_or_self(ctx, &b, "gradientLayer2", true, true); + let back = ctx.layer_param("backLayer", true).map(|o| crate::util::fit_layer(ctx, &b, &o, true)); + let [lw, lh] = ctx.layer_size; + let s = b.scale; + let (cw, ch) = (lw / cols as f64, lh / rows as f64); + let zpos = pr.f("cameraZ").max(0.05); + let focal = pr.f("focalLength").max(1.0); + let cam = Cam { c: [b.offset[0] + lw * 0.5 * s, b.offset[1] + lh * 0.5 * s], d: lw.max(lh) * s * zpos * focal / 140.0 }; + let ambient = pr.f("ambientLight") as f32; + let diffuse = pr.f("diffuse") as f32; + let intensity = pr.f("lightIntensity") as f32; + let props: Vec<(u32, f64, f64)> = + CD_PROPS.iter().map(|(id, _)| (pr.e(&format!("{id}Source")), pr.f(&format!("{id}Multiplier")), pr.f(&format!("{id}Offset")))).collect(); + let src = b.img.clone(); + let mut pieces: Vec = (0..rows * cols) + .into_par_iter() + .filter_map(|i| { + let (cx0, cy0) = ((i % cols) as f64 * cw, (i / cols) as f64 * ch); + let c = [cx0 + cw * 0.5, cy0 + ch * 0.5]; + let (bx, by) = (c[0] * s + b.offset[0], c[1] * s + b.offset[1]); + let (gp1, gp2) = (g1.sample_bilinear(bx, by), g2.sample_bilinear(bx, by)); + let val = |k: usize| { + let (sr, m, o) = props[k]; + o + m * card_source(sr, gp1, gp2) as f64 + }; + let (sx, sy) = (val(6), val(7)); + let r = rot_xyz(val(3).to_radians(), val(4).to_radians(), val(5).to_radians()); + let rs: M3 = std::array::from_fn(|k| [r[k][0] * sx, r[k][1] * sy, r[k][2]]); + let pos = [bx + val(0) * cw * s, by + val(1) * ch * s, val(2) * cw.max(ch) * s]; + let (tx0, ty0) = ((cx0 * s + b.offset[0]) as f32, (cy0 * s + b.offset[1]) as f32); + let (tw, th) = ((cw * s) as f32, (ch * s) as f32); + let poly = [[tx0, ty0], [tx0 + tw, ty0], [tx0 + tw, ty0 + th], [tx0, ty0 + th]]; + let mut pc = Piece::new(&poly, [bx as f32, by as f32], &rs, pos, cam)?; + pc.shade = ambient + diffuse * intensity * Piece::facing(&r); + Some(pc) + }) + .collect(); + sort_far_first(&mut pieces); + let mut out = Image::new(src.width, src.height); + draw_pieces(&mut out, &pieces, &src, back.as_ref()); + b.img = out; + b +} + +// ------------------------------------------------------------------ Shatter + +const SHATTER_PATTERNS: &[&str] = &["Bricks", "Glass", "Hexagons", "Squares", "Triangles"]; + +/// Pattern cells (polygons in pattern space, before rotation) covering `[x0, x1]×[y0, y1]`. +fn pattern_cells(kind: u32, cell: f64, x0: f64, y0: f64, x1: f64, y1: f64, seed: u32) -> Vec> { + let mut out = Vec::new(); + let i0 = (x0 / cell).floor() as i64 - 1; + let i1 = (x1 / cell).ceil() as i64 + 1; + let j0 = (y0 / cell).floor() as i64 - 1; + let j1 = (y1 / cell).ceil() as i64 + 1; + match kind { + 0 => { + let bh = cell * 0.5; + let r0 = (y0 / bh).floor() as i64 - 1; + let r1 = (y1 / bh).ceil() as i64 + 1; + for r in r0..=r1 { + let sh = if r.rem_euclid(2) == 1 { cell * 0.5 } else { 0.0 }; + for i in i0..=i1 { + let (x, y) = (i as f64 * cell + sh, r as f64 * bh); + out.push(vec![[x, y], [x + cell, y], [x + cell, y + bh], [x, y + bh]]); + } + } + } + 1 => { + // Jittered lattice split into triangles along a random diagonal. + let jit = |i: i64, j: i64| -> [f64; 2] { + let (a, b) = (i as u32, j as u32); + [i as f64 * cell + hs(a, b.wrapping_add(17), seed) as f64 * cell * 0.35, j as f64 * cell + hs(a, b.wrapping_add(91), seed) as f64 * cell * 0.35] + }; + for j in j0..=j1 { + for i in i0..=i1 { + let (p00, p10, p11, p01) = (jit(i, j), jit(i + 1, j), jit(i + 1, j + 1), jit(i, j + 1)); + if h(i as u32, j as u32, seed ^ 0x55) < 0.5 { + out.push(vec![p00, p10, p11]); + out.push(vec![p00, p11, p01]); + } else { + out.push(vec![p00, p10, p01]); + out.push(vec![p10, p11, p01]); + } + } + } + } + 2 => { + let r = cell / 3f64.sqrt(); + let dx = 1.5 * r; + let dy = 3f64.sqrt() * r; + let c0 = (x0 / dx).floor() as i64 - 1; + let c1 = (x1 / dx).ceil() as i64 + 1; + let rr0 = (y0 / dy).floor() as i64 - 1; + let rr1 = (y1 / dy).ceil() as i64 + 1; + for ci in c0..=c1 { + for rj in rr0..=rr1 { + let cx = ci as f64 * dx; + let cy = (rj as f64 + if ci.rem_euclid(2) == 1 { 0.5 } else { 0.0 }) * dy; + out.push( + (0..6) + .map(|k| { + let a = k as f64 * std::f64::consts::PI / 3.0; + [cx + r * a.cos(), cy + r * a.sin()] + }) + .collect(), + ); + } + } + } + 3 => { + for j in j0..=j1 { + for i in i0..=i1 { + let (x, y) = (i as f64 * cell, j as f64 * cell); + out.push(vec![[x, y], [x + cell, y], [x + cell, y + cell], [x, y + cell]]); + } + } + } + _ => { + for j in j0..=j1 { + for i in i0..=i1 { + let (x, y) = (i as f64 * cell, j as f64 * cell); + if (i + j).rem_euclid(2) == 0 { + out.push(vec![[x, y], [x + cell, y], [x, y + cell]]); + out.push(vec![[x + cell, y], [x + cell, y + cell], [x, y + cell]]); + } else { + out.push(vec![[x, y], [x + cell, y], [x + cell, y + cell]]); + out.push(vec![[x, y], [x + cell, y + cell], [x, y + cell]]); + } + } + } + } + } + out +} + +/// `∫0^t e^{-ks} ds` and `∫0^t ∫0^u e^{-ks} ds du` (drag-damped ballistic factors). +fn drag_factors(k: f64, t: f64) -> (f64, f64) { + if k < 1e-6 { + (t, 0.5 * t * t) + } else { + let e = (-k * t).exp(); + ((1.0 - e) / k, t / k - (1.0 - e) / (k * k)) + } +} + +fn shatter(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let view = pr.e("view"); + let render = pr.e("render"); + let kind = pr.e("pattern"); + let reps = pr.f("repetitions").clamp(1.0, 500.0); + let dir = pr.f("direction").to_radians(); + let origin = pr.v2("origin"); + let forces = [ + (pr.v2("force1Position"), pr.f("force1Depth"), pr.f("force1Radius"), pr.f("force1Strength")), + (pr.v2("force2Position"), pr.f("force2Depth"), pr.f("force2Radius"), pr.f("force2Strength")), + ]; + let rot_speed = pr.f("rotationSpeed"); + let tumble = pr.e("tumbleAxis"); + let randomness = pr.f("randomness"); + let k = pr.f("viscosity").max(0.0) * 5.0; + let mass_var = pr.f("massVariance") / 100.0; + let gravity = pr.f("gravity"); + let gdir = pr.f("gravityDirection").to_radians(); + let ginc = pr.f("gravityInclination").to_radians(); + let ambient = pr.f("ambientLight") as f32; + let intensity = pr.f("lightIntensity") as f32; + let seed = (pr.f("randomSeed") as u32).wrapping_mul(0x9e3779b1) ^ ctx.seed; + let [lw, lh] = ctx.layer_size; + let s = b.scale; + let t = ctx.time.max(0.0); + let unit = lw * 0.1; + let cell = lw / reps; + let cam = Cam { c: [b.offset[0] + lw * 0.5 * s, b.offset[1] + lh * 0.5 * s], d: 2.0 * lw.max(lh) * s }; + let (sd, cd) = dir.sin_cos(); + let to_layer = |q: [f64; 2]| [origin[0] + q[0] * cd - q[1] * sd, origin[1] + q[0] * sd + q[1] * cd]; + // Pattern-space bounds of the layer rectangle. + let corners = [[0.0, 0.0], [lw, 0.0], [lw, lh], [0.0, lh]].map(|c: [f64; 2]| { + let (dx, dy) = (c[0] - origin[0], c[1] - origin[1]); + [dx * cd + dy * sd, -dx * sd + dy * cd] + }); + let (px0, px1) = (corners.iter().map(|c| c[0]).fold(f64::INFINITY, f64::min), corners.iter().map(|c| c[0]).fold(f64::NEG_INFINITY, f64::max)); + let (py0, py1) = (corners.iter().map(|c| c[1]).fold(f64::INFINITY, f64::min), corners.iter().map(|c| c[1]).fold(f64::NEG_INFINITY, f64::max)); + let cells = pattern_cells(kind, cell, px0, py0, px1, py1, seed); + let g3 = [gdir.sin() * ginc.cos() * gravity * unit, -gdir.cos() * ginc.cos() * gravity * unit, ginc.sin() * gravity * unit]; + let src = b.img.clone(); + let mut pieces: Vec = cells + .par_iter() + .enumerate() + .filter_map(|(i, poly)| { + let i = i as u32; + let lp: Vec<[f64; 2]> = poly.iter().map(|&q| to_layer(q)).collect(); + let (mnx, mxx) = (lp.iter().map(|v| v[0]).fold(f64::INFINITY, f64::min), lp.iter().map(|v| v[0]).fold(f64::NEG_INFINITY, f64::max)); + let (mny, mxy) = (lp.iter().map(|v| v[1]).fold(f64::INFINITY, f64::min), lp.iter().map(|v| v[1]).fold(f64::NEG_INFINITY, f64::max)); + if mxx < 0.0 || mxy < 0.0 || mnx > lw || mny > lh { + return None; + } + let c = [lp.iter().map(|v| v[0]).sum::() / lp.len() as f64, lp.iter().map(|v| v[1]).sum::() / lp.len() as f64]; + // Broken when inside a force sphere (centred `depth` in front of the layer). + let mut v = [0.0f64; 3]; + let mut broken = false; + for (fp, fd, fr, fs) in forces { + let rad = fr * lw; + if rad <= 0.0 { + continue; + } + let dv = [c[0] - fp[0], c[1] - fp[1], fd * lw]; + let dist = (dv[0] * dv[0] + dv[1] * dv[1] + dv[2] * dv[2]).sqrt(); + if dist < rad { + broken = true; + let f = fs * (1.0 - dist / rad) * unit / dist.max(1e-6); + for q in 0..3 { + v[q] += dv[q] * f; + } + } + } + if (broken && render == 1) || (!broken && render == 2) { + return None; + } + let mut pos = [c[0], c[1], 0.0]; + let mut r = rot_axis([1.0, 0.0, 0.0], 0.0); + if broken && t > 0.0 { + let mass = 1.0 + mass_var * hs(i, 1, seed) as f64; + for q in 0..3 { + v[q] = v[q] / mass + randomness * unit * hs(i, 2 + q as u32, seed) as f64 * 2.0; + } + let (f1, f2) = drag_factors(k, t); + for q in 0..3 { + pos[q] += v[q] * f1 + g3[q] * f2; + } + let axis = match tumble { + 1 => [0.0, 0.0, 0.0], + 2 => [1.0, 0.0, 0.0], + 3 => [0.0, 1.0, 0.0], + 4 => [0.0, 0.0, 1.0], + 5 => [hs(i, 6, seed) as f64, hs(i, 7, seed) as f64, 0.0], + 6 => [hs(i, 6, seed) as f64, 0.0, hs(i, 8, seed) as f64], + 7 => [0.0, hs(i, 7, seed) as f64, hs(i, 8, seed) as f64], + _ => [hs(i, 6, seed) as f64, hs(i, 7, seed) as f64, hs(i, 8, seed) as f64], + }; + let speed = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt() / unit; + let omega = rot_speed * std::f64::consts::TAU * (0.5 + h(i, 9, seed) as f64) * speed.min(10.0); + r = rot_axis(axis, omega * f1); + } + let tex: Vec<[f32; 2]> = lp.iter().map(|v| [(v[0] * s + b.offset[0]) as f32, (v[1] * s + b.offset[1]) as f32]).collect(); + let c0 = [(c[0] * s + b.offset[0]) as f32, (c[1] * s + b.offset[1]) as f32]; + let posb = [pos[0] * s + b.offset[0], pos[1] * s + b.offset[1], pos[2] * s]; + let mut pc = Piece::new(&tex, c0, &r, posb, cam)?; + pc.shade = ambient + (1.0 - ambient) * intensity * Piece::facing(&r); + Some(pc) + }) + .collect(); + sort_far_first(&mut pieces); + let mut out = Image::new(src.width, src.height); + if view == 0 { + draw_pieces(&mut out, &pieces, &src, None); + } else { + // Wireframes: piece outlines (front view: at rest; otherwise animated), plus forces. + let animated = view == 2 || view == 4; + let mut lines = Vec::new(); + for pc in &pieces { + let n = pc.n as usize; + let pts: Vec<[f32; 2]> = if animated { + let fw = inv3(&pc.inv); + match fw { + Some(m) => pc.poly[..n] + .iter() + .map(|v| { + let (a, bb) = ((v[0] - pc.c0[0]) as f64, (v[1] - pc.c0[1]) as f64); + let w = m[2][0] * a + m[2][1] * bb + m[2][2]; + [((m[0][0] * a + m[0][1] * bb + m[0][2]) / w) as f32, ((m[1][0] * a + m[1][1] * bb + m[1][2]) / w) as f32] + }) + .collect(), + None => continue, + } + } else { + pc.poly[..n].to_vec() + }; + for e in 0..n { + let (a, c) = (pts[e], pts[(e + 1) % n]); + lines.push(Sprite::new(a[0], a[1], 0.5, [0.75, 0.85, 1.0, 1.0], Shape::Line { dx: c[0] - a[0], dy: c[1] - a[1] })); + } + } + if view >= 3 { + for (fp, _, fr, _) in forces { + let (fx, fy) = b.to_px(fp); + let rad = fr * lw * s; + for k in 0..64 { + let (a0, a1) = (k as f64 / 64.0 * std::f64::consts::TAU, (k + 1) as f64 / 64.0 * std::f64::consts::TAU); + let (x0, y0) = (fx + rad * a0.cos(), fy + rad * a0.sin()); + let (x1, y1) = (fx + rad * a1.cos(), fy + rad * a1.sin()); + lines.push(Sprite::new(x0 as f32, y0 as f32, 0.5, [1.0, 0.3, 0.3, 1.0], Shape::Line { dx: (x1 - x0) as f32, dy: (y1 - y0) as f32 })); + } + } + } + out = splat(src.width, src.height, &lines, Acc::Over); + } + b.img = out; + b +} + +// ------------------------------------------------------------------ Caustics + +/// Sample with repeat mode: 0 Once (transparent outside), 1 Tiled, 2 Reflected. +fn sample_repeat(img: &Image, x: f64, y: f64, mode: u32) -> Px { + let (w, hh) = (img.width as f64, img.height as f64); + match mode { + 1 => img.sample_bilinear_clamped(x.rem_euclid(w), y.rem_euclid(hh)), + 2 => { + let m = |v: f64, n: f64| { + let r = v.rem_euclid(2.0 * n); + if r > n { 2.0 * n - r } else { r } + }; + img.sample_bilinear_clamped(m(x, w), m(y, hh)) + } + _ => img.sample_bilinear(x, y), + } +} + +fn caustics(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let s = b.scale; + let mut bottom = layer_or_self(ctx, &b, "bottom", true, pr.e("bottomSizeDiffers") == 1); + let blur = pr.f("blur") * s; + if blur > 0.05 { + bottom = effectcraft_raster::gaussian_blur(&bottom, blur * 0.5, blur * 0.5, false); + } + let scaling = pr.f("scaling").max(0.01); + let repeat = pr.e("repeatMode"); + let wave_h = pr.f("waveHeight"); + let smoothing = pr.f("smoothing") * s; + let depth = pr.f("waterDepth"); + let ior = pr.f("refractiveIndex").max(1.0); + let surf = pr.color("surfaceColor"); + let surf_op = pr.f("surfaceOpacity") as f32; + let cstr = pr.f("causticsStrength") as f32; + let li = pr.f("lightIntensity") as f32; + let lc = pr.color("lightColor"); + let lpos = pr.v2("lightPosition"); + let lheight = pr.f("lightHeight").max(0.01); + let ambient = pr.f("ambientLight") as f32; + let diffuse = pr.f("diffuse") as f32; + let specular = pr.f("specular") as f32; + let sharp = pr.f("highlightSharpness").max(1.0) as f32; + let (w, hh) = (b.img.width as usize, b.img.height as usize); + // Height field of the water surface (luminance of the chosen layer; none = flat). + let height = ctx.layer_param("waterSurface", true).map(|o| { + let img = crate::util::fit_layer(ctx, &b, &o, true); + let pl = Plane::luma(&img); + if smoothing > 0.05 { gauss_plane(&pl, smoothing * 0.5, smoothing * 0.5) } else { pl } + }); + let lw = ctx.layer_size[0] * s; + let k = wave_h * depth * (1.0 - 1.0 / ior) * lw * 2.0; + let (cx, cy) = (b.offset[0] + ctx.layer_size[0] * 0.5 * s, b.offset[1] + ctx.layer_size[1] * 0.5 * s); + let (lx, ly) = b.to_px(lpos); + let lv = { + let v = [(lx - cx) / lw.max(1.0), (ly - cy) / lw.max(1.0), lheight]; + let n = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt(); + [(v[0] / n) as f32, (v[1] / n) as f32, (v[2] / n) as f32] + }; + let grad = |x: usize, y: usize| -> (f64, f64) { + match &height { + Some(pl) => { + let gx = (pl.get_clamped(x as i64 + 1, y as i64) - pl.get_clamped(x as i64 - 1, y as i64)) as f64 * 0.5; + let gy = (pl.get_clamped(x as i64, y as i64 + 1) - pl.get_clamped(x as i64, y as i64 - 1)) as f64 * 0.5; + (gx, gy) + } + None => (0.0, 0.0), + } + }; + // Displacement and its Jacobian (caustic focusing) per pixel. + let disp: Vec<(f64, f64)> = (0..w * hh) + .into_par_iter() + .map(|i| { + let (gx, gy) = grad(i % w, i / w); + (-gx * k, -gy * k) + }) + .collect(); + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let i = y * w + x; + let (dx, dy) = disp[i]; + let d = |xx: usize, yy: usize| disp[yy.min(hh - 1) * w + xx.min(w - 1)]; + let (dxr, _) = d(x + 1, y); + let (_, dyd) = d(x, y + 1); + let (dxl, _) = d(x.saturating_sub(1), y); + let (_, dyu) = d(x, y.saturating_sub(1)); + let jac = (1.0 + (dxr - dxl) * 0.5) * (1.0 + (dyd - dyu) * 0.5); + let focus = ((1.0 / jac.abs().max(0.2)) as f32 - 1.0).clamp(-1.0, 4.0); + let sx = cx + (x as f64 + 0.5 + dx - cx) / scaling; + let sy = cy + (y as f64 + 0.5 + dy - cy) / scaling; + let p = sample_repeat(&bottom, sx, sy, repeat); + let (c, a) = unpremul(p); + let (gx, gy) = grad(x, y); + let n = { + let v = [-gx * wave_h * 20.0, -gy * wave_h * 20.0, 1.0]; + let m = (v[0] * v[0] + v[1] * v[1] + 1.0).sqrt(); + [(v[0] / m) as f32, (v[1] / m) as f32, (v[2] / m) as f32] + }; + let nl = (n[0] * lv[0] + n[1] * lv[1] + n[2] * lv[2]).max(0.0); + let hv = { + let v = [lv[0], lv[1], lv[2] + 1.0]; + 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 spec = (n[0] * hv[0] + n[1] * hv[1] + n[2] * hv[2]).max(0.0).powf(sharp) * specular * li; + let light = ambient + diffuse * li * nl + cstr * focus * li; + let cc: [f32; 3] = std::array::from_fn(|q| { + let base = c[q] * light * lc[q]; + base * (1.0 - surf_op) + surf[q] * surf_op * (ambient + diffuse * li * nl) + spec * lc[q] + }); + let a = (a + surf_op * (1.0 - a)).min(1.0); + *px = [cc[0] * a, cc[1] * a, cc[2] * a, a]; + } + }); + b +} + +// ------------------------------------------------------------------ Wave World + +#[derive(Clone, Debug)] +struct Waves { + nx: usize, + ny: usize, + u: Vec, + v: Vec, +} + +struct Producer { + ring: bool, + pos: [f32; 2], + len: f32, + width: f32, + angle: f32, + amp: f32, + freq: f32, + phase: f32, +} + +impl Producer { + /// Weight of a grid point (layer-fraction coordinates, x in widths, y in widths). + fn weight(&self, x: f32, y: f32) -> f32 { + let (dx, dy) = (x - self.pos[0], y - self.pos[1]); + let (sa, ca) = self.angle.sin_cos(); + let (u, v) = (dx * ca + dy * sa, -dx * sa + dy * ca); + let d = if self.ring { + let (a, bb) = ((self.len * 0.5).max(1e-3), (self.width * 0.5).max(1e-3)); + ((u / a).powi(2) + (v / bb).powi(2)).sqrt() + } else { + let l = (self.len * 0.5).max(1e-3); + let wd = (self.width * 0.5).max(1e-3); + (u.abs() / l).max(v.abs() / wd) + }; + (1.0 - d).clamp(0.0, 1.0).min(1.0) + } +} + +static WAVE_CACHE: SimCache = SimCache::new(4); + +fn wave_world(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let [lw, lh] = ctx.layer_size; + let res = pr.f("gridResolution").clamp(1.0, 400.0) as usize; + let res = if pr.b("gridResDownsamples") { ((res as f64) * b.scale).round().max(1.0) as usize } else { res }; + let nx = res.max(2) + 1; + let ny = ((res as f64 * lh / lw.max(1.0)).round() as usize).max(2) + 1; + let speed = pr.f("waveSpeed").max(0.0) as f32; + let damping = pr.f("damping").max(0.0) as f32; + let reflect = pr.e("reflectEdges"); + let preroll = pr.f("preRoll").max(0.0); + let prods: Vec = [1, 2] + .iter() + .map(|k| { + let pos = pr.v2(&format!("producer{k}Position")); + Producer { + ring: pr.e(&format!("producer{k}Type")) == 0, + pos: [(pos[0] / lw.max(1.0)) as f32, (pos[1] / lw.max(1.0)) as f32], + len: pr.f(&format!("producer{k}Length")) as f32, + width: pr.f(&format!("producer{k}Width")) as f32, + angle: (pr.f(&format!("producer{k}Angle")) as f32).to_radians(), + amp: pr.f(&format!("producer{k}Amplitude")) as f32, + freq: pr.f(&format!("producer{k}Frequency")) as f32, + phase: (pr.f(&format!("producer{k}Phase")) as f32).to_radians(), + } + }) + .collect(); + let sps = crate::sim::SPS as f32; + // Wave speed in layer widths per second → cells per second; sub-steps keep it stable. + let cps = speed * (nx - 1) as f32; + let sub = ((cps * 2.0 / sps).ceil() as usize).clamp(1, 64); + let dt = 1.0 / (sps * sub as f32); + let cell = 1.0 / (nx - 1) as f32; + let weights: Vec> = prods.iter().map(|p| (0..nx * ny).map(|i| p.weight((i % nx) as f32 * cell, (i / nx) as f32 * cell)).collect()).collect(); + let key = params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: if pr.b("gridResDownsamples") { b.scale } else { 1.0 } }, 4); + let steps = crate::sim::steps_at(ctx.time + preroll); + let st = WAVE_CACHE.run( + key, + steps, + || Waves { nx, ny, u: vec![0.0; nx * ny], v: vec![0.0; nx * ny] }, + |w, step| { + for k in 0..sub { + let t = (step as f32 + k as f32 / sub as f32) / sps - preroll as f32; + let c2 = cps * cps; + let mut acc = vec![0.0f32; nx * ny]; + for y in 0..ny { + for x in 0..nx { + let i = y * nx + x; + let g = |xx: usize, yy: usize| w.u[yy * nx + xx]; + let l = g(x.saturating_sub(1), y) + g((x + 1).min(nx - 1), y) + g(x, y.saturating_sub(1)) + g(x, (y + 1).min(ny - 1)) - 4.0 * w.u[i]; + acc[i] = c2 * l - damping * 4.0 * w.v[i]; + } + } + for i in 0..nx * ny { + w.v[i] += acc[i] * dt; + w.u[i] += w.v[i] * dt; + } + // Producers drive the surface. + for (p, wt) in prods.iter().zip(&weights) { + if p.amp == 0.0 { + continue; + } + let target = p.amp * (std::f32::consts::TAU * p.freq * t + p.phase).sin(); + for i in 0..nx * ny { + if wt[i] > 0.0 { + w.u[i] += (target - w.u[i]) * wt[i]; + w.v[i] *= 1.0 - wt[i]; + } + } + } + // Edges: reflective ones mirror; the others absorb in a thin sponge layer. + let refl = |side: u32| reflect == 5 || reflect == side; + let sponge = 4usize.min(nx / 4).max(1); + for y in 0..ny { + for x in 0..nx { + let i = y * nx + x; + let mut f = 1.0f32; + if !refl(1) && x < sponge { + f = f.min(x as f32 / sponge as f32); + } + if !refl(3) && nx - 1 - x < sponge { + f = f.min((nx - 1 - x) as f32 / sponge as f32); + } + if !refl(2) && y < sponge { + f = f.min(y as f32 / sponge as f32); + } + if !refl(4) && ny - 1 - y < sponge { + f = f.min((ny - 1 - y) as f32 / sponge as f32); + } + if f < 1.0 { + let k = 0.85 + 0.15 * f; + w.u[i] *= k; + w.v[i] *= k; + } + } + } + } + }, + ); + let s = b.scale; + let (bw, bh) = (b.img.width, b.img.height); + let sample = |x: f64, y: f64| -> f32 { + let gx = (x / lw.max(1.0) * (st.nx - 1) as f64).clamp(0.0, (st.nx - 1) as f64); + let gy = (y / lw.max(1.0) * (st.nx - 1) as f64).clamp(0.0, (st.ny - 1) as f64); + let (x0, y0) = (gx.floor() as usize, gy.floor() as usize); + let (x1, y1) = ((x0 + 1).min(st.nx - 1), (y0 + 1).min(st.ny - 1)); + let (tx, ty) = ((gx - x0 as f64) as f32, (gy - y0 as f64) as f32); + let g = |xx: usize, yy: usize| st.u[yy * st.nx + xx]; + let a = g(x0, y0) + (g(x1, y0) - g(x0, y0)) * tx; + let c = g(x0, y1) + (g(x1, y1) - g(x0, y1)) * tx; + a + (c - a) * ty + }; + if pr.e("view") == 1 { + let bright = pr.f("brightness") as f32; + let contrast = pr.f("contrast") as f32; + let gamma = pr.f("gamma").max(0.01) as f32; + let alpha = 1.0 - pr.f("transparency") as f32; + b.img.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + let lx = (x as f64 + 0.5 - b.offset[0]) / s; + let ly = (y as f64 + 0.5 - b.offset[1]) / s; + let v = (bright + contrast * sample(lx, ly)).clamp(0.0, 1.0).powf(1.0 / gamma); + *px = [v * alpha, v * alpha, v * alpha, alpha]; + } + }); + } else { + // Wireframe preview: the grid seen at an angle, heights lifting the lines. + let lift = lh as f32 * 0.25; + let to = |x: usize, y: usize| -> [f32; 2] { + let lx = x as f64 / (st.nx - 1) as f64 * lw; + let ly = y as f64 / (st.nx - 1) as f64 * lw; + let (px, py) = b.to_px([lx, lh * 0.25 + ly * 0.6]); + [px as f32, py as f32 - st.u[y * st.nx + x] * lift * s as f32] + }; + let mut lines = Vec::new(); + for y in 0..st.ny { + for x in 0..st.nx { + let a = to(x, y); + if x + 1 < st.nx { + let c = to(x + 1, y); + lines.push(Sprite::new(a[0], a[1], 0.4, [0.4, 0.9, 0.4, 1.0], Shape::Line { dx: c[0] - a[0], dy: c[1] - a[1] })); + } + if y + 1 < st.ny { + let c = to(x, y + 1); + lines.push(Sprite::new(a[0], a[1], 0.4, [0.4, 0.9, 0.4, 1.0], Shape::Line { dx: c[0] - a[0], dy: c[1] - a[1] })); + } + } + } + let wire = splat(bw, bh, &lines, Acc::Over); + b.img = crate::sim::combine(&Image::filled(bw, bh, [0.0, 0.0, 0.0, 1.0]), &wire, 0); + } + b +} + +// ------------------------------------------------------------------ Foam + +#[derive(Clone, Debug)] +struct FoamBubble { + p: [f32; 2], + v: [f32; 2], + age: f32, + size: f32, + id: u32, +} + +#[derive(Clone, Debug, Default)] +struct FoamState { + b: Vec, + carry: f32, + next: u32, +} + +static FOAM_CACHE: SimCache = SimCache::new(4); + +/// Foam steps once per frame at 30 frames per second. +const FOAM_FPS: f64 = 30.0; + +fn foam(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 unit = lw * 0.01; + let prod = pr.v2("producerPoint"); + let (psx, psy) = (pr.f("producerXSize") as f32 * lw, pr.f("producerYSize") as f32 * lh); + let porient = (pr.f("producerOrientation") as f32).to_radians(); + let rate = pr.f("productionRate").max(0.0) as f32; + let size = pr.f("size").max(0.0) as f32; + let size_var = pr.f("sizeVariance") as f32; + let lifespan = pr.f("lifespan").max(1.0) as f32; + let growth = pr.f("growthSpeed").max(0.0001) as f32; + let init_speed = pr.f("initialSpeed") as f32; + let init_dir = (pr.f("initialDirection") as f32).to_radians(); + let wind = pr.f("windSpeed") as f32; + let wind_dir = (pr.f("windDirection") as f32).to_radians(); + let turb = pr.f("turbulence") as f32; + let repulsion = pr.f("repulsion") as f32; + let viscosity = pr.f("viscosity").clamp(0.0, 1.0) as f32; + let sticky = pr.f("stickiness").clamp(0.0, 1.0) as f32; + let universe = pr.f("universeSize").max(0.01) as f32; + let seed = (pr.f("randomSeed") as u32).wrapping_mul(0x68e31da4) ^ ctx.seed; + let wvec = [wind_dir.sin() * wind * unit, -wind_dir.cos() * wind * unit]; + let key = params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 5); + let steps = if ctx.time <= 0.0 { 0 } else { (ctx.time * FOAM_FPS + 1e-6).floor() as u64 }; + let st = FOAM_CACHE.run(key, steps, FoamState::default, |st, step| { + let t = step as f32 / FOAM_FPS as f32; + // Births. + st.carry += rate; + let n = st.carry.floor() as u32; + st.carry -= n as f32; + for _ in 0..n { + if st.b.len() >= 3000 { + break; + } + let id = st.next; + st.next = st.next.wrapping_add(1); + let (ux, uy) = (hs(id, 1, seed) * 0.5 * psx, hs(id, 2, seed) * 0.5 * psy); + let (so, co) = porient.sin_cos(); + let p = [prod[0] as f32 + ux * co - uy * so, prod[1] as f32 + ux * so + uy * co]; + let v = [init_dir.sin() * init_speed * unit, -init_dir.cos() * init_speed * unit]; + let sz = (size * (1.0 + size_var * hs(id, 3, seed))).max(0.01); + st.b.push(FoamBubble { p, v, age: 0.0, size: sz, id }); + } + // Forces: drift towards the wind, turbulence, viscosity. + for q in st.b.iter_mut() { + let n1 = value_noise(q.p[0] / (lw * 0.15), q.p[1] / (lw * 0.15), t * 0.5, seed) - 0.5; + let n2 = value_noise(q.p[0] / (lw * 0.15) + 19.0, q.p[1] / (lw * 0.15), t * 0.5, seed) - 0.5; + for k in 0..2 { + q.v[k] += (wvec[k] - q.v[k]) * 0.1; + q.v[k] *= 1.0 - viscosity * 0.5; + } + q.v[0] += n1 * turb * unit; + q.v[1] += n2 * turb * unit; + } + // Repulsion between overlapping bubbles (grid hashed, deterministic order). + let rad = |q: &FoamBubble| q.size * unit * 3.0 * (q.age * growth).min(1.0); + if repulsion > 0.0 && st.b.len() > 1 { + let maxr = st.b.iter().map(rad).fold(0.0f32, f32::max).max(1.0); + let cs = maxr * 2.0; + let mut grid: std::collections::HashMap<(i32, i32), Vec> = std::collections::HashMap::new(); + for (i, q) in st.b.iter().enumerate() { + grid.entry(((q.p[0] / cs).floor() as i32, (q.p[1] / cs).floor() as i32)).or_default().push(i); + } + let mut push = vec![[0.0f32; 2]; st.b.len()]; + for (i, q) in st.b.iter().enumerate() { + let (gx, gy) = ((q.p[0] / cs).floor() as i32, (q.p[1] / cs).floor() as i32); + for dy in -1..=1 { + for dx in -1..=1 { + let Some(list) = grid.get(&(gx + dx, gy + dy)) else { continue }; + for &j in list { + if j <= i { + continue; + } + let o = &st.b[j]; + let (ddx, ddy) = (o.p[0] - q.p[0], o.p[1] - q.p[1]); + let d = (ddx * ddx + ddy * ddy).sqrt().max(1e-4); + let overlap = rad(q) + rad(o) - d; + if overlap > 0.0 { + let f = overlap * 0.5 * repulsion.min(4.0) * (1.0 - sticky * 0.5) / d; + push[i][0] -= ddx * f; + push[i][1] -= ddy * f; + push[j][0] += ddx * f; + push[j][1] += ddy * f; + } + } + } + } + } + for (q, d) in st.b.iter_mut().zip(&push) { + q.p[0] += d[0].clamp(-maxr, maxr); + q.p[1] += d[1].clamp(-maxr, maxr); + } + } + let (ux0, uy0) = (-(universe - 1.0) * 0.5 * lw, -(universe - 1.0) * 0.5 * lh); + let (ux1, uy1) = (lw - ux0, lh - uy0); + for q in st.b.iter_mut() { + q.p[0] += q.v[0]; + q.p[1] += q.v[1]; + q.age += 1.0; + } + st.b.retain(|q| q.age < lifespan && q.p[0] > ux0 && q.p[0] < ux1 && q.p[1] > uy0 && q.p[1] < uy1); + }); + let view = pr.e("view"); + let zoom = pr.f("zoom").max(0.01) as f32; + let texture = pr.e("bubbleTexture"); + let blend = pr.e("blendMode"); + let s = b.scale as f32; + let (cx, cy) = (lw * 0.5, lh * 0.5); + let mut order: Vec<&FoamBubble> = st.b.iter().collect(); + if blend == 2 { + // Solid New on Top: oldest first. + order.sort_by(|a, c| c.age.total_cmp(&a.age).then(a.id.cmp(&c.id))); + } else if blend == 1 { + order.sort_by(|a, c| a.age.total_cmp(&c.age).then(a.id.cmp(&c.id))); + } + let sprites: Vec = order + .iter() + .map(|q| { + let r = q.size * unit * 3.0 * (q.age * growth).min(1.0) * zoom * s; + let (bx, by) = b.to_px([(cx + (q.p[0] - cx) * zoom) as f64, (cy + (q.p[1] - cy) * zoom) as f64]); + let fade = ((lifespan - q.age) / 5.0).clamp(0.0, 1.0); + if view == 0 { + Sprite::new(bx as f32, by as f32, r, [0.55, 0.75, 1.0, fade], Shape::Bubble) + } else { + let (c, shape) = match texture { + 1 => ([0.95, 0.95, 0.92], Shape::Faded), + 2 => ([1.0, 0.6, 0.8], Shape::Bubble), + 3 => ([0.8, 0.85, 0.75], Shape::Bubble), + 4 => ([0.97, 0.97, 0.95], Shape::Sphere), + _ => ([1.0, 1.0, 1.0], Shape::Bubble), + }; + Sprite::new(bx as f32, by as f32, r, [c[0], c[1], c[2], fade], shape) + } + }) + .collect(); + b.img = splat(b.img.width, b.img.height, &sprites, if blend == 0 && view != 0 { Acc::Add } else { Acc::Over }); + b +} + +// ------------------------------------------------------------------ specs + +fn producer_params(k: u32, amp: f64, pos: (f64, f64)) -> Vec { + let ids: [&'static str; 8] = match k { + 1 => [ + "producer1Type", + "producer1Position", + "producer1Length", + "producer1Width", + "producer1Angle", + "producer1Amplitude", + "producer1Frequency", + "producer1Phase", + ], + _ => [ + "producer2Type", + "producer2Position", + "producer2Length", + "producer2Width", + "producer2Angle", + "producer2Amplitude", + "producer2Frequency", + "producer2Phase", + ], + }; + vec![ + p(ids[0], "Type", Value::Enum(0), popup(&["Ring", "Line"])), + p(ids[1], "Position", pt(pos.0, pos.1), ParamUi::Point), + p(ids[2], "Height/Length", num(0.1), slider(0.0, 2.0, 0.0, 1.0, 3)), + p(ids[3], "Width", num(0.1), slider(0.0, 2.0, 0.0, 1.0, 3)), + p(ids[4], "Angle", num(0.0), ParamUi::Angle), + p(ids[5], "Amplitude", num(amp), slider(-5.0, 5.0, -1.0, 1.0, 3)), + p(ids[6], "Frequency", num(1.0), slider(0.0, 30.0, 0.0, 5.0, 3)), + p(ids[7], "Phase", num(0.0), ParamUi::Angle), + ] +} + +fn card_dance_params() -> Vec { + let mut v = vec![ + p("rowsColumns", "Rows & Columns", Value::Enum(0), popup(&["Independent", "Columns Follow Rows"])), + p("rows", "Rows", num(10.0), slider(1.0, 1000.0, 1.0, 100.0, 0)), + p("columns", "Columns", num(10.0), slider(1.0, 1000.0, 1.0, 100.0, 0)), + p("backLayer", "Back Layer", Value::Layer(None), ParamUi::Layer), + p("gradientLayer1", "Gradient Layer 1", Value::Layer(None), ParamUi::Layer), + p("gradientLayer2", "Gradient Layer 2", Value::Layer(None), ParamUi::Layer), + ]; + let names: [[&'static str; 4]; 8] = [ + ["xPosSource", "xPosMultiplier", "xPosOffset", "X Position"], + ["yPosSource", "yPosMultiplier", "yPosOffset", "Y Position"], + ["zPosSource", "zPosMultiplier", "zPosOffset", "Z Position"], + ["xRotSource", "xRotMultiplier", "xRotOffset", "X Rotation"], + ["yRotSource", "yRotMultiplier", "yRotOffset", "Y Rotation"], + ["zRotSource", "zRotMultiplier", "zRotOffset", "Z Rotation"], + ["xScaleSource", "xScaleMultiplier", "xScaleOffset", "X Scale"], + ["yScaleSource", "yScaleMultiplier", "yScaleOffset", "Y Scale"], + ]; + debug_assert_eq!(names.len(), CD_PROPS.len()); + for (i, n) in names.iter().enumerate() { + let rot = (3..6).contains(&i); + let scale = i >= 6; + let (lo, hi) = if rot { (-3600.0, 3600.0) } else { (-100.0, 100.0) }; + v.push(p(n[0], "Source", Value::Enum(0), popup(CD_SOURCES))); + v.push(p(n[1], "Multiplier", num(0.0), slider(lo, hi, if rot { -360.0 } else { -10.0 }, if rot { 360.0 } else { 10.0 }, 2))); + v.push(p(n[2], "Offset", num(if scale { 1.0 } else { 0.0 }), slider(lo, hi, if rot { -360.0 } else { -10.0 }, if rot { 360.0 } else { 10.0 }, 2))); + } + v.extend([ + p("cameraZ", "Z Position", num(2.0), slider(0.05, 10.0, 0.5, 5.0, 2)), + p("focalLength", "Focal Length", num(70.0), slider(1.0, 500.0, 10.0, 200.0, 1)), + p("lightIntensity", "Light Intensity", num(1.0), slider(0.0, 4.0, 0.0, 2.0, 2)), + p("ambientLight", "Ambient Light", num(0.25), slider(0.0, 2.0, 0.0, 1.0, 2)), + p("diffuse", "Diffuse Reflection", num(0.75), slider(0.0, 2.0, 0.0, 1.0, 2)), + ]); + v +} pub fn specs() -> Vec { - Vec::new() + vec![ + spec( + "ec.sim.ccballaction", + "CC Ball Action", + vec![ + p("scatter", "Scatter", num(0.0), slider(0.0, 2000.0, 0.0, 300.0, 1)), + p("rotationAxis", "Rotation Axis", Value::Enum(0), popup(&["X Axis", "Y Axis", "Z Axis", "XY Axis", "XZ Axis", "YZ Axis", "XYZ Axis"])), + p("rotation", "Rotation", num(0.0), ParamUi::Angle), + p("twistProperty", "Twist Property", Value::Enum(0), popup(&["X Axis", "Y Axis", "Radius", "Brightness"])), + p("twistAngle", "Twist Angle", num(0.0), ParamUi::Angle), + p("gridSpacing", "Grid Spacing", num(4.0), slider(1.0, 200.0, 1.0, 50.0, 0)), + p("ballSize", "Ball Size", num(40.0), slider(0.0, 400.0, 0.0, 200.0, 1)), + p("instabilityState", "Instability State", num(0.0), ParamUi::Angle), + ], + ball_action, + ), + spec( + "ec.sim.ccpixelpolly", + "CC Pixel Polly", + vec![ + p("force", "Force", num(100.0), slider(-1000.0, 1000.0, -300.0, 300.0, 1)), + p("gravity", "Gravity", num(1.0), slider(-20.0, 20.0, -5.0, 5.0, 2)), + p("spinning", "Spinning", num(0.0), ParamUi::Angle), + p("forceCenter", "Force Center", pt(0.5, 0.5), ParamUi::Point), + p("directionRandomness", "Direction Randomness", num(10.0), pct()), + p("speedRandomness", "Speed Randomness", num(10.0), pct()), + p("gridSpacing", "Grid Spacing", num(10.0), slider(1.0, 500.0, 2.0, 100.0, 0)), + p("object", "Object", Value::Enum(0), popup(&["Polygon", "Textured Polygon", "Square", "Textured Square"])), + p("enableDepthSort", "Enable Depth Sort", Value::Bool(true), ParamUi::Checkbox), + p("startTime", "Start Time (sec)", num(0.0), slider(-1000.0, 1000.0, 0.0, 10.0, 2)), + ], + pixel_polly, + ), + spec( + "ec.sim.ccscatterize", + "CC Scatterize", + vec![ + p("scatter", "Scatter", num(0.0), slider(0.0, 1000.0, 0.0, 200.0, 1)), + p("rightTwist", "Right Twist", num(0.0), ParamUi::Angle), + p("leftTwist", "Left Twist", num(0.0), ParamUi::Angle), + p("transferMode", "Transfer Mode", Value::Enum(0), popup(&["Composite", "Add"])), + ], + scatterize, + ), + spec("ec.sim.carddance", "Card Dance", card_dance_params(), card_dance), + spec( + "ec.sim.caustics", + "Caustics", + vec![ + p("bottom", "Bottom", Value::Layer(None), ParamUi::Layer), + p("scaling", "Scaling", num(1.0), slider(0.01, 10.0, 0.1, 3.0, 3)), + p("repeatMode", "Repeat Mode", Value::Enum(2), popup(&["Once", "Tiled", "Reflected"])), + p("bottomSizeDiffers", "If Layer Size Differs", Value::Enum(1), popup(&["Center", "Stretch to Fit"])), + p("blur", "Blur", num(0.0), slider(0.0, 100.0, 0.0, 20.0, 1)), + p("waterSurface", "Water Surface", Value::Layer(None), ParamUi::Layer), + p("waveHeight", "Wave Height", num(0.2), slider(-1.0, 1.0, -1.0, 1.0, 3)), + p("smoothing", "Smoothing", num(5.0), slider(0.0, 100.0, 0.0, 50.0, 1)), + p("waterDepth", "Water Depth", num(0.1), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("refractiveIndex", "Refractive Index", num(1.2), slider(1.0, 3.0, 1.0, 2.0, 3)), + p("surfaceColor", "Surface Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("surfaceOpacity", "Surface Opacity", num(0.3), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("causticsStrength", "Caustics Strength", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("lightIntensity", "Light Intensity", num(1.0), slider(0.0, 4.0, 0.0, 2.0, 2)), + p("lightColor", "Light Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("lightPosition", "Light Position", pt(0.0, 0.0), ParamUi::Point), + p("lightHeight", "Light Height", num(1.0), slider(0.0, 10.0, 0.0, 4.0, 2)), + p("ambientLight", "Ambient Light", num(0.35), slider(0.0, 2.0, 0.0, 1.0, 2)), + p("diffuse", "Diffuse Reflection", num(0.75), slider(0.0, 2.0, 0.0, 1.0, 2)), + p("specular", "Specular Reflection", num(0.2), slider(0.0, 2.0, 0.0, 1.0, 2)), + p("highlightSharpness", "Highlight Sharpness", num(15.0), slider(1.0, 100.0, 1.0, 100.0, 1)), + ], + caustics, + ), + spec( + "ec.sim.waveworld", + "Wave World", + { + let mut v = vec![ + p("view", "View", Value::Enum(0), popup(&["Wireframe Preview", "Height Map"])), + p("brightness", "Brightness", num(0.5), slider(-1.0, 2.0, 0.0, 1.0, 3)), + p("contrast", "Contrast", num(0.75), slider(0.0, 4.0, 0.0, 2.0, 3)), + p("gamma", "Gamma Adjustment", num(1.0), slider(0.01, 10.0, 0.2, 5.0, 3)), + p("transparency", "Transparency", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("gridResolution", "Grid Resolution", num(60.0), slider(1.0, 400.0, 1.0, 200.0, 0)), + p("gridResDownsamples", "Grid Res Downsamples", Value::Bool(false), ParamUi::Checkbox), + p("waveSpeed", "Wave Speed", num(0.2), slider(0.0, 5.0, 0.0, 1.0, 3)), + p("damping", "Damping", num(0.05), slider(0.0, 5.0, 0.0, 1.0, 3)), + p("reflectEdges", "Reflect Edges", Value::Enum(0), popup(&["None", "Left", "Top", "Right", "Bottom", "All"])), + p("preRoll", "Pre-roll (seconds)", num(0.0), slider(0.0, 30.0, 0.0, 10.0, 2)), + ]; + v.extend(producer_params(1, 0.5, (0.5, 0.5))); + v.extend(producer_params(2, 0.0, (0.25, 0.25))); + v + }, + wave_world, + ), + spec( + "ec.sim.shatter", + "Shatter", + vec![ + p( + "view", + "View", + Value::Enum(1), + popup(&["Rendered", "Wireframe Front View", "Wireframe", "Wireframe Front View + Forces", "Wireframe + Forces"]), + ), + p("render", "Render", Value::Enum(0), popup(&["All", "Layer", "Pieces"])), + p("pattern", "Pattern", Value::Enum(0), popup(SHATTER_PATTERNS)), + p("repetitions", "Repetitions", num(10.0), slider(1.0, 500.0, 1.0, 100.0, 2)), + p("direction", "Direction", num(0.0), ParamUi::Angle), + p("origin", "Origin", pt(0.5, 0.5), ParamUi::Point), + p("extrusionDepth", "Extrusion Depth", num(0.05), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("force1Position", "Force 1 Position", pt(0.5, 0.5), ParamUi::Point), + p("force1Depth", "Force 1 Depth", num(0.1), slider(-1.0, 1.0, -1.0, 1.0, 3)), + p("force1Radius", "Force 1 Radius", num(0.4), slider(0.0, 4.0, 0.0, 2.0, 3)), + p("force1Strength", "Force 1 Strength", num(5.0), slider(-20.0, 20.0, -10.0, 10.0, 2)), + p("force2Position", "Force 2 Position", pt(0.25, 0.25), ParamUi::Point), + p("force2Depth", "Force 2 Depth", num(0.1), slider(-1.0, 1.0, -1.0, 1.0, 3)), + p("force2Radius", "Force 2 Radius", num(0.0), slider(0.0, 4.0, 0.0, 2.0, 3)), + p("force2Strength", "Force 2 Strength", num(5.0), slider(-20.0, 20.0, -10.0, 10.0, 2)), + p("rotationSpeed", "Rotation Speed", num(0.2), slider(0.0, 5.0, 0.0, 1.0, 3)), + p("tumbleAxis", "Tumble Axis", Value::Enum(0), popup(&["Free", "None", "X", "Y", "Z", "XY", "XZ", "YZ"])), + p("randomness", "Randomness", num(0.1), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("viscosity", "Viscosity", num(0.1), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("massVariance", "Mass Variance", num(30.0), pct()), + p("gravity", "Gravity", num(3.0), slider(0.0, 100.0, 0.0, 10.0, 2)), + p("gravityDirection", "Gravity Direction", num(180.0), ParamUi::Angle), + p("gravityInclination", "Gravity Inclination", num(0.0), slider(-90.0, 90.0, -90.0, 90.0, 1)), + p("lightIntensity", "Light Intensity", num(1.0), slider(0.0, 4.0, 0.0, 2.0, 2)), + p("ambientLight", "Ambient Light", num(0.25), slider(0.0, 2.0, 0.0, 1.0, 2)), + p("randomSeed", "Random Seed", num(0.0), slider(0.0, 10_000.0, 0.0, 1000.0, 0)), + ], + shatter, + ), + spec( + "ec.sim.foam", + "Foam", + vec![ + p("view", "View", Value::Enum(0), popup(&["Draft", "Rendered"])), + p("producerPoint", "Producer Point", pt(0.5, 0.5), ParamUi::Point), + p("producerXSize", "Producer X Size", num(0.05), slider(0.0, 2.0, 0.0, 1.0, 3)), + p("producerYSize", "Producer Y Size", num(0.05), slider(0.0, 2.0, 0.0, 1.0, 3)), + p("producerOrientation", "Producer Orientation", num(0.0), ParamUi::Angle), + p("productionRate", "Production Rate", num(1.0), slider(0.0, 50.0, 0.0, 10.0, 3)), + p("size", "Size", num(0.5), slider(0.0, 10.0, 0.0, 2.0, 3)), + p("sizeVariance", "Size Variance", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("lifespan", "Lifespan", num(300.0), slider(1.0, 30_000.0, 1.0, 1000.0, 1)), + p("growthSpeed", "Bubble Growth Speed", num(0.1), slider(0.0001, 10.0, 0.0, 1.0, 3)), + p("strength", "Strength", num(10.0), slider(0.0, 100.0, 0.0, 20.0, 2)), + p("initialSpeed", "Initial Speed", num(0.0), slider(-100.0, 100.0, -10.0, 10.0, 3)), + p("initialDirection", "Initial Direction", num(0.0), ParamUi::Angle), + p("windSpeed", "Wind Speed", num(0.5), slider(-100.0, 100.0, -10.0, 10.0, 3)), + p("windDirection", "Wind Direction", num(90.0), ParamUi::Angle), + p("turbulence", "Turbulence", num(0.5), slider(0.0, 10.0, 0.0, 2.0, 3)), + p("repulsion", "Repulsion", num(1.0), slider(0.0, 10.0, 0.0, 2.0, 3)), + p("viscosity", "Viscosity", num(0.1), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("stickiness", "Stickiness", num(0.75), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("zoom", "Zoom", num(1.0), slider(0.01, 50.0, 0.1, 5.0, 3)), + p("universeSize", "Universe Size", num(1.0), slider(0.01, 10.0, 0.5, 3.0, 3)), + p("blendMode", "Blend Mode", Value::Enum(0), popup(&["Transparent", "Solid Old on Top", "Solid New on Top"])), + p("bubbleTexture", "Bubble Texture", Value::Enum(0), popup(&["Default Bubble", "Spit", "Bubblegum", "Dishwater", "Milky"])), + p("randomSeed", "Random Seed", num(1.0), slider(0.0, 10_000.0, 0.0, 1000.0, 0)), + ], + foam, + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{EffectEnv, run_fx}; + + 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 max_diff(a: &Image, b: &Image) -> f32 { + a.data.iter().zip(&b.data).map(|(p, q)| (0..4).map(|k| (p[k] - q[k]).abs()).fold(0.0, f32::max)).fold(0.0, f32::max) + } + + fn sum_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::()).sum() + } + + #[test] + fn piece_at_rest_is_identity() { + let src = ramp(16, 16); + let cam = Cam { c: [8.0, 8.0], d: 64.0 }; + let id = rot_axis([1.0, 0.0, 0.0], 0.0); + let pc = Piece::new(&[[0.0, 0.0], [16.0, 0.0], [16.0, 16.0], [0.0, 16.0]], [8.0, 8.0], &id, [8.0, 8.0, 0.0], cam).unwrap(); + let mut out = Image::new(16, 16); + draw_pieces(&mut out, &[pc], &src, None); + assert!(max_diff(&out, &src) < 1e-4); + } + + #[test] + fn ball_action_draws_balls() { + let v = [("ballSize", num(80.0))]; + let a = run("ec.sim.ccballaction", &v, ramp(32, 32), 0.0); + assert_eq!(a, run("ec.sim.ccballaction", &v, ramp(32, 32), 0.0)); + assert!(a.data.iter().any(|p| p[3] > 0.9) && a.data.iter().any(|p| p[3] == 0.0), "balls with gaps"); + let none = run("ec.sim.ccballaction", &[("ballSize", num(0.0))], ramp(32, 32), 0.0); + assert!(none.data.iter().all(|p| p[3] == 0.0)); + let sc = run("ec.sim.ccballaction", &[("ballSize", num(80.0)), ("scatter", num(20.0))], ramp(32, 32), 0.0); + assert!(sum_diff(&a, &sc) > 1.0); + } + + #[test] + fn pixel_polly_breaks_after_start() { + let v = [("forceCenter", pt(16.0, 16.0)), ("gridSpacing", num(4.0))]; + let before = run("ec.sim.ccpixelpolly", &v, ramp(32, 32), 0.0); + assert_eq!(before, ramp(32, 32), "intact before the start time"); + let a = run("ec.sim.ccpixelpolly", &v, ramp(32, 32), 0.5); + assert_eq!(a, run("ec.sim.ccpixelpolly", &v, ramp(32, 32), 0.5)); + assert!(sum_diff(&a, &ramp(32, 32)) > 10.0); + let tex = run("ec.sim.ccpixelpolly", &[("forceCenter", pt(16.0, 16.0)), ("gridSpacing", num(4.0)), ("object", Value::Enum(3))], ramp(32, 32), 0.5); + assert!(sum_diff(&a, &tex) > 0.1); + } + + #[test] + fn scatterize_identity_and_scatter() { + assert_eq!(run("ec.sim.ccscatterize", &[], ramp(24, 24), 0.0), ramp(24, 24)); + let a = run("ec.sim.ccscatterize", &[("scatter", num(5.0))], ramp(24, 24), 0.0); + assert_eq!(a, run("ec.sim.ccscatterize", &[("scatter", num(5.0))], ramp(24, 24), 0.0)); + assert!(sum_diff(&a, &ramp(24, 24)) > 1.0); + let tw = run("ec.sim.ccscatterize", &[("leftTwist", num(80.0)), ("rightTwist", num(80.0))], ramp(24, 24), 0.0); + assert!(sum_diff(&tw, &ramp(24, 24)) > 1.0); + } + + #[test] + fn card_dance_identity_and_rotation() { + let src = ramp(30, 20); + let a = run("ec.sim.carddance", &[], src.clone(), 0.0); + assert!(max_diff(&a, &src) < 0.02, "flat cards reproduce the layer: {}", max_diff(&a, &src)); + let r = run("ec.sim.carddance", &[("yRotSource", Value::Enum(1)), ("yRotMultiplier", num(90.0))], src.clone(), 0.0); + assert_eq!(r, run("ec.sim.carddance", &[("yRotSource", Value::Enum(1)), ("yRotMultiplier", num(90.0))], src.clone(), 0.0)); + assert!(sum_diff(&r, &src) > 5.0); + let z = run("ec.sim.carddance", &[("zPosOffset", num(-1.0))], src.clone(), 0.0); + assert!(sum_diff(&z, &src) > 5.0, "cards pulled towards the camera grow"); + } + + #[test] + fn shatter_at_rest_then_breaks() { + let v = [("view", Value::Enum(0)), ("force1Position", pt(16.0, 16.0)), ("origin", pt(16.0, 16.0))]; + let src = ramp(32, 32); + let t0 = run("ec.sim.shatter", &v, src.clone(), 0.0); + assert!(max_diff(&t0, &src) < 0.03, "{}", max_diff(&t0, &src)); + let t1 = run("ec.sim.shatter", &v, src.clone(), 0.5); + assert_eq!(t1, run("ec.sim.shatter", &v, src.clone(), 0.5)); + assert!(sum_diff(&t1, &src) > 10.0); + for pat in 0..SHATTER_PATTERNS.len() as u32 { + let vv = [("view", Value::Enum(0)), ("pattern", Value::Enum(pat)), ("force1Position", pt(16.0, 16.0)), ("origin", pt(16.0, 16.0))]; + let r = run("ec.sim.shatter", &vv, src.clone(), 0.0); + assert!(max_diff(&r, &src) < 0.05, "pattern {pat} tiles the layer: {}", max_diff(&r, &src)); + } + let wire = run("ec.sim.shatter", &[("repetitions", num(3.0))], src.clone(), 0.0); + assert!(wire.data.iter().any(|p| p[3] > 0.5) && wire.data.iter().any(|p| p[3] == 0.0)); + let layer_only = run("ec.sim.shatter", &[("view", Value::Enum(0)), ("render", Value::Enum(1)), ("force1Position", pt(16.0, 16.0))], src.clone(), 0.5); + assert!(layer_only.get(16, 16)[3] == 0.0, "broken centre removed"); + } + + #[test] + fn caustics_flat_water() { + let src = ramp(24, 24); + let a = run( + "ec.sim.caustics", + &[("surfaceOpacity", num(0.0)), ("ambientLight", num(0.0)), ("diffuse", num(1.0)), ("lightHeight", num(1000.0)), ("specular", num(0.0))], + src.clone(), + 0.0, + ); + assert!(max_diff(&a, &src) < 0.01, "flat water, light overhead = bottom: {}", max_diff(&a, &src)); + let b = run("ec.sim.caustics", &[], src.clone(), 0.0); + assert_eq!(b, run("ec.sim.caustics", &[], src.clone(), 0.0)); + assert!(sum_diff(&b, &src) > 0.1); + } + + #[test] + fn wave_world_seek_and_waves() { + let v = vec![("view", Value::Enum(1)), ("producer1Position", pt(16.0, 16.0)), ("gridResolution", num(24.0))]; + let _ = run("ec.sim.waveworld", &v, Image::new(32, 32), 0.5); + let a = run("ec.sim.waveworld", &v, Image::new(32, 32), 1.0); + let mut v2 = v.clone(); + v2.push(("transparency", num(0.0))); + assert_eq!(a, run("ec.sim.waveworld", &v2, Image::new(32, 32), 1.0)); + // Waves spread: some pixels differ from the flat-water grey. + assert!(a.data.iter().any(|p| (p[0] - 0.5).abs() > 0.05), "waves"); + let flat = run("ec.sim.waveworld", &[("view", Value::Enum(1)), ("producer1Amplitude", num(0.0))], Image::new(32, 32), 1.0); + assert!(flat.data.iter().all(|p| (p[0] - 0.5).abs() < 1e-5)); + let wire = run("ec.sim.waveworld", &[("producer1Position", pt(16.0, 16.0)), ("gridResolution", num(10.0))], Image::new(32, 32), 0.3); + assert!(wire.data.iter().any(|p| p[1] > 0.3)); + } + + #[test] + fn wave_world_steps_match_direct() { + // Fresh caches: resumed simulation equals simulation from scratch. + let v = vec![("view", Value::Enum(1)), ("producer1Position", pt(16.0, 16.0)), ("gridResolution", num(20.0)), ("damping", num(0.07))]; + let a = run("ec.sim.waveworld", &v, Image::new(32, 32), 1.3); + let mut v2 = v.clone(); + v2.push(("damping", num(0.07000001))); + // Different key: computed from scratch at 1.3 directly vs via 0.4 (both fresh keys). + let _ = run("ec.sim.waveworld", &v2, Image::new(32, 32), 0.4); + let b = run("ec.sim.waveworld", &v2, Image::new(32, 32), 1.3); + assert!(max_diff(&a, &b) < 1e-3, "{}", max_diff(&a, &b)); + } + + #[test] + fn foam_bubbles_drift_and_seek() { + let v = vec![("producerPoint", pt(16.0, 16.0)), ("view", Value::Enum(1)), ("productionRate", num(2.0))]; + let none = run("ec.sim.foam", &v, Image::new(32, 32), 0.0); + assert!(none.data.iter().all(|p| p[3] == 0.0)); + let _ = run("ec.sim.foam", &v, Image::new(32, 32), 0.6); + let a = run("ec.sim.foam", &v, Image::new(32, 32), 1.2); + let mut v2 = v.clone(); + v2.push(("randomSeed", num(1.0))); + assert_eq!(a, run("ec.sim.foam", &v2, Image::new(32, 32), 1.2)); + assert!(a.data.iter().any(|p| p[3] > 0.05), "bubbles"); + let draft = run("ec.sim.foam", &[("producerPoint", pt(16.0, 16.0)), ("productionRate", num(2.0))], Image::new(32, 32), 1.0); + assert!(draft.data.iter().any(|p| p[3] > 0.05)); + } } From 210ec66ecf0029dbddfc25fdd84b921ad06d1ce9 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 22:32:50 -0400 Subject: [PATCH 14/14] M9.3: render tests for effect masks and layer parameters Co-Authored-By: Claude Opus 5.5 --- crates/render/src/tests.rs | 43 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index e4607486..ad6eec2f 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -160,3 +160,46 @@ fn effects_run_in_pipeline() { let c = img.get(100, 50); assert!(c[1] > 0.99 && c[0] < 0.01, "{c:?}"); } + +fn add_effect(p: &mut Project, l: &mut effectcraft_project::Layer, id: &str, size: [f64; 2], vals: &[(&str, Value)]) { + let spec = effectcraft_effects::find(id).unwrap(); + let mut next = p.next_id; + let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, size); + p.next_id = next; + for (k, v) in vals { + g.prop_mut(k).unwrap().value = v.clone(); + } + l.props.sub_mut("effects").unwrap().children.push(g.into()); +} + +#[test] +fn effects_see_layer_masks() { + let (mut p, cid, comp) = setup(); + let mut l = solid(&mut p, &comp, [0.0, 0.0, 0.0], 200, 100); + let mut next = p.next_id; + let m = build::mask(&mut Ids(&mut next), "Mask 1", ShapePath::rect([50.0, 50.0], 40.0, 40.0), MaskMode::None, [255, 255, 0]); + p.next_id = next; + l.props.sub_mut("masks").unwrap().children.push(m.into()); + add_effect(&mut p, &mut l, "ec.generate.stroke", [200.0, 100.0], &[("brushSize", Value::Scalar(6.0))]); + p.comp_mut(cid).unwrap().layers.push(l); + let img = render_frame(&p, cid, Tick::ZERO, 1.0); + assert!(img.get(30, 50)[0] > 0.5, "stroke on the mask edge: {:?}", img.get(30, 50)); + assert!(img.get(50, 50)[0] < 0.01, "inside untouched"); + assert!(img.get(150, 50)[3] > 0.99, "mode None mask does not cut the layer"); +} + +#[test] +fn effects_read_layer_params() { + let (mut p, cid, comp) = setup(); + let mut src = solid(&mut p, &comp, [0.0, 0.0, 1.0], 200, 100); + src.switches.video = false; + let src_id = src.id.0; + let mut top = solid(&mut p, &comp, [1.0, 0.0, 0.0], 200, 100); + add_effect(&mut p, &mut top, "ec.channel.blend", [200.0, 100.0], &[("blendWithLayer", Value::Layer(Some(src_id)))]); + let c = p.comp_mut(cid).unwrap(); + c.layers.push(top); + c.layers.push(src); + let img = render_frame(&p, cid, Tick::ZERO, 1.0); + let px = img.get(100, 50); + assert!(px[2] > 0.99 && px[0] < 0.01, "{px:?}"); +}