diff --git a/crates/effects/src/blur2.rs b/crates/effects/src/blur2.rs index 95853e7c..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", @@ -621,7 +629,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..25984984 --- /dev/null +++ b/crates/effects/src/channel2.rs @@ -0,0 +1,448 @@ +//! 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 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 { + 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); + } +} 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..76840774 --- /dev/null +++ b/crates/effects/src/color3.rs @@ -0,0 +1,575 @@ +//! 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 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 { + 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); + } +} 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..e7ca4910 --- /dev/null +++ b/crates/effects/src/distort3.rs @@ -0,0 +1,1056 @@ +//! 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 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 { + 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); + } +} 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..87d82867 --- /dev/null +++ b/crates/effects/src/generate3.rs @@ -0,0 +1,1888 @@ +//! 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 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 { + 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); + } +} diff --git a/crates/effects/src/keying.rs b/crates/effects/src/keying.rs index 106a9d2f..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)), @@ -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..caad75ec --- /dev/null +++ b/crates/effects/src/keying2.rs @@ -0,0 +1,419 @@ +//! 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 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![ + 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)); + } + } +} diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index efe5ae3e..6b8b7a97 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 }) @@ -235,11 +316,16 @@ pub const TIME_DEPENDENT: &[&str] = &[ "ec.noise.addgrain", "ec.noise.noisealpha", "ec.noise.noisehlsauto", + "ec.obsolete.lightning", + "ec.text.timecode", + "ec.text.numbers", ]; /// See [`TIME_DEPENDENT`]. pub fn is_time_dependent(id: &str) -> bool { - TIME_DEPENDENT.contains(&id) + // Simulations replay from layer time 0, so every frame differs even when the defaults + // happen to look static at the test's two sample times. + id.starts_with("ec.sim.") || TIME_DEPENDENT.contains(&id) } /// Run an effect on a buffer. @@ -247,10 +333,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(); @@ -279,7 +394,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); @@ -298,6 +413,7 @@ mod tests { img.set(x, y, [((x * 7 + y * 3) % 11) as f32 / 11.0 * a, y as f32 / 30.0 * a, 0.4 * a, a]); } } + let mut missing = vec![]; for s in registry() { let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; for (k, v) in params.values.iter_mut() { @@ -306,13 +422,14 @@ mod tests { } } let run = |t: f64| { - let ctx = EffectCtx { params: ¶ms, time: t, layer_size: [40.0, 30.0], seed: 7, adjustment: false }; + let ctx = EffectCtx { params: ¶ms, time: t, layer_size: [40.0, 30.0], seed: 7, adjustment: false, env: Default::default() }; apply(s, &ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 }).img }; let (a, b) = (run(0.0), run(1.37)); - if a != b { - assert!(is_time_dependent(s.id), "{} depends on time but is not in TIME_DEPENDENT", s.id); + if a != b && !is_time_dependent(s.id) { + missing.push(s.id); } } + assert!(missing.is_empty(), "these depend on time but are not in TIME_DEPENDENT: {missing:?}"); } } 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..c53ada32 --- /dev/null +++ b/crates/effects/src/noise2.rs @@ -0,0 +1,369 @@ +//! 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 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 { + 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)); + } +} diff --git a/crates/effects/src/obsolete.rs b/crates/effects/src/obsolete.rs new file mode 100644 index 00000000..122ec32d --- /dev/null +++ b/crates/effects/src/obsolete.rs @@ -0,0 +1,184 @@ +//! Obsolete-category effects: Basic 3D (tilt/swivel the layer in a simple perspective space +//! with an optional specular highlight) and Gaussian Blur (Legacy). + +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![ + 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}"); + } + } +} 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..feed0286 --- /dev/null +++ b/crates/effects/src/perspective2.rs @@ -0,0 +1,286 @@ +//! 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 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![ + 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/sim.rs b/crates/effects/src/sim.rs new file mode 100644 index 00000000..024a91fc --- /dev/null +++ b/crates/effects/src/sim.rs @@ -0,0 +1,1530 @@ +//! Simulation effects (particles, physics, liquids): CC Rainfall, CC Snowfall, CC Bubbles, +//! CC Drizzle, CC Star Burst, CC Particle World, CC Particle Systems II and CC Mr. Mercury. +//! +//! Written from the public descriptions of the effects' behaviour. Weather/bubble/star effects +//! are closed-form functions of time (every frame independent); particle systems are stepped +//! from layer time 0 at a fixed rate through [`SimCache`], so any frame can be rendered +//! directly and equals the frame reached by scrubbing. +//! +//! Shared here (also used by `sim2`): a band-bucketed rasteriser for many small items +//! ([`raster`]), particle sprites ([`Sprite`]) and transfer-mode compositing ([`combine`]). + +use effectcraft_keyframe::Value; +use effectcraft_project::ParamUi; +use effectcraft_raster::{Image, Px}; +use rayon::prelude::*; + +use crate::generate::value_noise; +use crate::util::{SimCache, hash1, params_key, unpremul}; +use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; + +pub(crate) fn spec(id: &'static str, name: &'static str, params: Vec, 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 { + 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 new file mode 100644 index 00000000..133dc87e --- /dev/null +++ b/crates/effects/src/sim2.rs @@ -0,0 +1,1610 @@ +//! 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 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![ + 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)); + } +} 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..df46c97e --- /dev/null +++ b/crates/effects/src/stylize3.rs @@ -0,0 +1,659 @@ +//! 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 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 { + 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); + } +} diff --git a/crates/effects/src/textfx.rs b/crates/effects/src/textfx.rs new file mode 100644 index 00000000..4a4a8d35 --- /dev/null +++ b/crates/effects/src/textfx.rs @@ -0,0 +1,981 @@ +//! 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 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 { + 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); + } +} 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..2e1738b2 --- /dev/null +++ b/crates/effects/src/transition2.rs @@ -0,0 +1,544 @@ +//! 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 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 { + 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); + } +} 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..ec768197 --- /dev/null +++ b/crates/effects/src/utility2.rs @@ -0,0 +1,85 @@ +//! 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 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![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:?}"); + } + } + } +} diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index fdc2c91c..773f78e9 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -454,8 +454,12 @@ impl FootageSource for MediaPool { } } } - fn audio(&self, _item: ItemId, footage: &Footage, start: Tick, frames: usize, rate: u32) -> Vec { - self.audio_samples(footage, start, frames, rate) + + 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)) } } diff --git a/crates/render/src/audio.rs b/crates/render/src/audio.rs index 1a9467b2..d7ae4778 100644 --- a/crates/render/src/audio.rs +++ b/crates/render/src/audio.rs @@ -103,7 +103,7 @@ fn mix_into( let lt = l.layer_time(t_a); let buf = match &l.source { LayerSource::Footage { item } => match project.item(*item).map(|i| &i.kind) { - Some(ItemKind::Footage(f)) => footage.audio(*item, f, lt, src_n, rate), + Some(ItemKind::Footage(f)) => footage.audio(*item, f, lt, src_n, rate).unwrap_or_else(|| vec![0.0; src_n * 2]), _ => continue, }, LayerSource::Comp { item } => { @@ -152,8 +152,8 @@ mod tests { fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { None } - fn audio(&self, _: ItemId, _: &Footage, start: Tick, frames: usize, _: u32) -> Vec { - (0..frames).flat_map(|_| if start >= Tick::ZERO { [0.25, -0.25] } else { [0.0, 0.0] }).collect() + fn audio(&self, _: ItemId, _: &Footage, start: Tick, frames: usize, _: u32) -> Option> { + Some((0..frames).flat_map(|_| if start >= Tick::ZERO { [0.25, -0.25] } else { [0.0, 0.0] }).collect()) } } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 4d774e27..8dce390b 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -17,7 +17,7 @@ use std::sync::Arc; pub use cache::{CacheStats, LayerCache}; 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; @@ -30,10 +30,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` stereo sample frames of `item`'s audio from source time `start` at `rate` Hz, - /// interleaved L R L R… (silence where there is none). Default: no audio. - fn audio(&self, _item: ItemId, _footage: &Footage, _start: Tick, frames: usize, _rate: u32) -> Vec { - vec![0.0; frames * 2] + /// `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 { depth: self.r.depth + 1, ..*self.r }; + // The cached layer buffer is shared; effects get their own copy. + let buf = if masks_and_effects { (*sub.layer_buf(self.ctx, other)?).clone() } 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) } } @@ -165,6 +198,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; @@ -172,7 +208,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 }; let t0 = std::time::Instant::now(); buf = effectcraft_effects::apply(spec, &ectx, buf); if let Some(v) = timing.as_deref_mut() { 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 }; diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 877e9329..dc1f3cda 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -169,12 +169,17 @@ fn render_cached(p: &Project, cid: ItemId, t: Tick, cache: Option<&crate::LayerC r.comp_frame(cid, t) } -fn add_effect(p: &mut Project, l: &mut effectcraft_project::Layer, id: &str, params: &[(&str, Value)]) { +/// [`add_effect`] for a 200×100 layer. +fn add_effect_200(p: &mut Project, l: &mut effectcraft_project::Layer, id: &str, vals: &[(&str, Value)]) { + add_effect(p, l, id, [200.0, 100.0], vals); +} + +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, [200.0, 100.0]); + let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, size); p.next_id = next; - for (k, v) in params { + for (k, v) in vals { g.prop_mut(k).unwrap().value = v.clone(); } l.props.sub_mut("effects").unwrap().children.push(g.into()); @@ -184,7 +189,7 @@ fn add_effect(p: &mut Project, l: &mut effectcraft_project::Layer, id: &str, par fn cache_scene() -> (Project, ItemId) { let (mut p, cid, comp) = setup(); let mut bg = solid(&mut p, &comp, [0.2, 0.3, 0.4], 200, 100); - add_effect(&mut p, &mut bg, "ec.color.tint", &[("white", Value::Color([1.0, 0.5, 0.0, 1.0]))]); + add_effect_200(&mut p, &mut bg, "ec.color.tint", &[("white", Value::Color([1.0, 0.5, 0.0, 1.0]))]); let mut shape = build::layer(&mut p, &comp, "Shape", LayerSource::Shape, (200, 100), None); let mut next = p.next_id; { @@ -202,7 +207,7 @@ fn cache_scene() -> (Project, ItemId) { text.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "AB".into(), size: 40.0, ..Default::default() })); text.props.prop_mut("transform/position").unwrap().keys = vec![Keyframe::new(Tick::ZERO, Value::Vec3([20.0, 70.0, 0.0])), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Vec3([120.0, 70.0, 0.0]))]; - add_effect(&mut p, &mut text, "ec.blur.gaussian", &[("blurriness", Value::Scalar(6.0))]); + add_effect_200(&mut p, &mut text, "ec.blur.gaussian", &[("blurriness", Value::Scalar(6.0))]); let c = p.comp_mut(cid).unwrap(); c.layers = vec![text, shape, bg]; (p, cid) @@ -269,7 +274,7 @@ fn edits_invalidate_cached_layers() { "effect added", Box::new(|p, cid| { let mut l = p.comp_mut(cid).unwrap().layers[2].clone(); - add_effect(p, &mut l, "ec.channel.invert", &[]); + add_effect_200(p, &mut l, "ec.channel.invert", &[]); p.comp_mut(cid).unwrap().layers[2] = l; }), ), @@ -323,7 +328,7 @@ fn edits_invalidate_cached_layers() { fn time_dependent_effects_rerender_every_frame() { let (mut p, cid, comp) = setup(); let mut l = solid(&mut p, &comp, [0.5, 0.5, 0.5], 200, 100); - add_effect(&mut p, &mut l, "ec.noise.noise", &[("amount", Value::Scalar(50.0))]); + add_effect_200(&mut p, &mut l, "ec.noise.noise", &[("amount", Value::Scalar(50.0))]); p.comp_mut(cid).unwrap().layers.push(l); let cache = crate::LayerCache::default(); let a = render_cached(&p, cid, Tick::from_seconds_f64(0.1), Some(&cache)); @@ -331,3 +336,35 @@ fn time_dependent_effects_rerender_every_frame() { assert_same(&b, &render_cached(&p, cid, Tick::from_seconds_f64(0.2), None), "noise at 0.2"); assert!(a != b, "noise must animate"); } + +#[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:?}"); +}