diff --git a/crates/effects/src/blur2.rs b/crates/effects/src/blur2.rs index 3b84d283..e4f2b034 100644 --- a/crates/effects/src/blur2.rs +++ b/crates/effects/src/blur2.rs @@ -282,9 +282,10 @@ fn iris_spans(r: f64, sides: u32, rotation_deg: f64, roundness: f64) -> Vec<(i64 pub fn camera_lens_spans(ctx: &EffectCtx, r: f64) -> (Vec<(i64, i64, i64)>, f64) { let shape = ctx.params.e("irisProperties/irisShape"); let sides = if shape >= 8 { 0 } else { shape + 3 }; - let mut spans = iris_spans(r, sides, ctx.params.f("irisProperties/irisRotation"), ctx.params.f("irisProperties/irisRoundness").clamp(0.0, 100.0) / 100.0); + let roundness = ctx.params.f("irisProperties/irisRoundness").clamp(0.0, 100.0) / 100.0; // Aspect Ratio stretches the iris horizontally (> 1) or squeezes it (< 1). let aspect = ctx.params.f("irisProperties/irisAspectRatio"); + let mut spans = iris_spans(r, sides, ctx.params.f("irisProperties/irisRotation"), roundness); if aspect > 0.0 && (aspect - 1.0).abs() > 1e-6 { for s in spans.iter_mut() { s.1 = (s.1 as f64 * aspect).round() as i64; @@ -294,12 +295,23 @@ pub fn camera_lens_spans(ctx: &EffectCtx, r: f64) -> (Vec<(i64, i64, i64)>, f64) (spans, r * aspect.max(1.0)) } +/// Whether Camera Lens Blur needs more than plain iris spans (Diffraction Fringe or a Blur +/// Map): the GPU kernel handles the plain blur only. +pub fn camera_lens_plain(ctx: &EffectCtx) -> bool { + ctx.params.f("irisProperties/diffractionFringe") <= 0.0 && ctx.params.get("blurMap/blurMapLayer").and_then(|v| v.as_layer()).is_none() +} + fn camera_lens_blur(ctx: &EffectCtx, mut b: Buf) -> Buf { let r = ctx.params.f("blurRadius").max(0.0) * b.scale; if r < 0.5 { return b; } - let (spans, reach) = camera_lens_spans(ctx, r); + let shape = ctx.params.e("irisProperties/irisShape"); + let sides = if shape >= 8 { 0 } else { shape + 3 }; + let roundness = ctx.params.f("irisProperties/irisRoundness").clamp(0.0, 100.0) / 100.0; + // Aspect Ratio stretches the iris horizontally (> 1) or squeezes it (< 1). + let aspect = ctx.params.f("irisProperties/irisAspectRatio"); + let reach = r * aspect.max(1.0); let repeat = ctx.params.b("repeatEdge") || ctx.adjustment; if !repeat { b.pad(reach.ceil() as u32 + 1); @@ -348,58 +360,148 @@ fn camera_lens_blur(ctx: &EffectCtx, mut b: Buf) -> Buf { v }) .collect(); - let count: i64 = spans.iter().map(|(_, l, r)| r - l + 1).sum(); - let norm = 1.0 / count as f64; - let mut out = Image::new(src.width, src.height); - out.rows_mut().for_each(|(y, row)| { - for (x, o) in row.iter_mut().enumerate() { - let mut acc = [0.0f64; 4]; - for &(dy, l, rr) in &spans { - let mut yy = y as i64 + dy; - if yy < 0 || yy >= h as i64 { - if !repeat { - continue; - } - yy = yy.clamp(0, h as i64 - 1); + // Sum of the source over the iris spans centred on (x, y). + let span_sum = |spans: &[(i64, i64, i64)], x: usize, y: usize| -> [f64; 4] { + let mut acc = [0.0f64; 4]; + for &(dy, l, rr) in spans { + let mut yy = y as i64 + dy; + if yy < 0 || yy >= h as i64 { + if !repeat { + continue; } - let pre = &prefix[yy as usize]; - let (mut a, mut bnd) = (x as i64 + l, x as i64 + rr); - if repeat { - if a < 0 { - let n = (-a).min(bnd - a + 1) as f64; - let first = src.data[yy as usize * w]; - for c in 0..4 { - acc[c] += first[c] as f64 * n; - } - a = 0; - } - if bnd >= w as i64 { - let n = (bnd - (w as i64 - 1)).min(bnd - a + 1) as f64; - let last = src.data[yy as usize * w + w - 1]; - for c in 0..4 { - acc[c] += last[c] as f64 * n; - } - bnd = w as i64 - 1; - } - } else { - a = a.max(0); - bnd = bnd.min(w as i64 - 1); - } - if a <= bnd { + yy = yy.clamp(0, h as i64 - 1); + } + let pre = &prefix[yy as usize]; + let (mut a, mut bnd) = (x as i64 + l, x as i64 + rr); + if repeat { + if a < 0 { + let n = (-a).min(bnd - a + 1) as f64; + let first = src.data[yy as usize * w]; for c in 0..4 { - acc[c] += pre[bnd as usize + 1][c] - pre[a as usize][c]; + acc[c] += first[c] as f64 * n; } + a = 0; + } + if bnd >= w as i64 { + let n = (bnd - (w as i64 - 1)).min(bnd - a + 1) as f64; + let last = src.data[yy as usize * w + w - 1]; + for c in 0..4 { + acc[c] += last[c] as f64 * n; + } + bnd = w as i64 - 1; + } + } else { + a = a.max(0); + bnd = bnd.min(w as i64 - 1); + } + if a <= bnd { + for c in 0..4 { + acc[c] += pre[bnd as usize + 1][c] - pre[a as usize][c]; } } - let mut px = acc.map(|v| (v * norm) as f32); - px[3] = px[3].clamp(0.0, 1.0); - if linear { - let (c, a) = unpremul(px); - px = premul(c.map(|v| effectcraft_color::linear_to_srgb(v.max(0.0))), a); - } - *o = px; } + acc + }; + // Diffraction Fringe: the iris edge (outer 20 % of the radius) gets more of the energy: + // 0 = an even disc, 100 = a natural halo, 500 = all of it in the ring. + let fringe = (ctx.params.f("irisProperties/diffractionFringe") / 500.0).clamp(0.0, 1.0); + let (w_in, w_ring) = (1.0 - fringe, 1.0 + 4.0 * fringe); + let iris = |radius: f64| -> (Vec<(i64, i64, i64)>, Vec<(i64, i64, i64)>) { + let stretch = |mut s: Vec<(i64, i64, i64)>| { + if aspect > 0.0 && (aspect - 1.0).abs() > 1e-6 { + for v in s.iter_mut() { + v.1 = (v.1 as f64 * aspect).round() as i64; + v.2 = (v.2 as f64 * aspect).round() as i64; + } + } + s + }; + let outer = stretch(iris_spans(radius, sides, ctx.params.f("irisProperties/irisRotation"), roundness)); + let inner = if fringe > 0.0 && radius * 0.8 >= 1.0 { + stretch(iris_spans(radius * 0.8, sides, ctx.params.f("irisProperties/irisRotation"), roundness)) + } else { + vec![] + }; + (outer, inner) + }; + let count = |s: &[(i64, i64, i64)]| s.iter().map(|(_, l, r)| (r - l + 1) as f64).sum::(); + // One blurred pixel with the given iris. + let blur_px = |sp: &(Vec<(i64, i64, i64)>, Vec<(i64, i64, i64)>), x: usize, y: usize| -> [f32; 4] { + let (outer, inner) = sp; + let acc = if inner.is_empty() { + let n = count(outer).max(1.0); + span_sum(outer, x, y).map(|v| v / n) + } else { + let (so, si) = (span_sum(outer, x, y), span_sum(inner, x, y)); + let (no, ni) = (count(outer), count(inner)); + let norm = (w_in * ni + w_ring * (no - ni)).max(1e-9); + std::array::from_fn(|c| (w_in * si[c] + w_ring * (so[c] - si[c])) / norm) + }; + let mut px = acc.map(|v| v as f32); + px[3] = px[3].clamp(0.0, 1.0); + if linear { + let (c, a) = unpremul(px); + px = premul(c.map(|v| effectcraft_color::linear_to_srgb(v.max(0.0))), a); + } + px + }; + // Blur Map: per-pixel radius from a map layer (no blur at Blur Focal Distance, full radius + // at the farthest value). + let map = ctx.layer_param("blurMap/blurMapLayer", true).map(|o| { + let stretch = ctx.params.e("blurMap/placement") == 1; + let img = crate::util::fit_layer(ctx, &b, &o, stretch); + let ch = ctx.params.e("blurMap/channel"); + let invert = ctx.params.b("blurMap/invertBlurMap"); + let focal = (ctx.params.f("blurMap/blurFocalDistance") / 255.0).clamp(0.0, 1.0) as f32; + let span = focal.max(1.0 - focal).max(1e-3); + img.data + .iter() + .map(|p| { + let (c, a) = unpremul(*p); + let mut v = match ch { + 1 => a, + 2 => c[0].max(c[1]).max(c[2]) * a, + _ => luminance(c[0], c[1], c[2]) * a, + }; + if invert { + v = 1.0 - v; + } + ((v - focal).abs() / span).clamp(0.0, 1.0) + }) + .collect::>() }); + let mut out = Image::new(src.width, src.height); + match &map { + None => { + let sp = iris(r); + out.rows_mut().for_each(|(y, row)| { + for (x, o) in row.iter_mut().enumerate() { + *o = blur_px(&sp, x, y); + } + }); + } + Some(m) => { + // Radii quantised to levels; each pixel blends the two nearest. + let levels = (r.ceil() as usize).clamp(1, 8); + let irises: Vec<_> = (0..=levels).map(|k| iris(r * k as f64 / levels as f64)).collect(); + let srcd = &b.img.data; + out.rows_mut().for_each(|(y, row)| { + for (x, o) in row.iter_mut().enumerate() { + let f = m[y * w + x] * levels as f32; + let k0 = (f.floor() as usize).min(levels); + let t = f - k0 as f32; + let at = |k: usize| if k == 0 || r * k as f64 / (levels as f64) < 0.5 { srcd[y * w + x] } else { blur_px(&irises[k], x, y) }; + let a = at(k0); + *o = if t > 1e-4 && k0 < levels { + let bq = at(k0 + 1); + std::array::from_fn(|c| a[c] + (bq[c] - a[c]) * t) + } else { + a + }; + } + }); + } + } b.img = out; b } @@ -604,6 +706,12 @@ pub fn specs() -> Vec { p("irisProperties/irisRoundness", "Roundness", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)), p("irisProperties/irisAspectRatio", "Aspect Ratio", num(1.0), slider(0.01, 100.0, 0.25, 4.0, 2)), p("irisProperties/irisRotation", "Rotation", num(0.0), ParamUi::Angle), + p("irisProperties/diffractionFringe", "Diffraction Fringe", num(0.0), slider(0.0, 500.0, 0.0, 500.0, 1)), + p("blurMap/blurMapLayer", "Layer", Value::Layer(None), ParamUi::Layer), + p("blurMap/channel", "Channel", Value::Enum(0), popup(&["Luminance", "Alpha", "Color"])), + p("blurMap/placement", "Placement", Value::Enum(1), popup(&["Center", "Stretch"])), + p("blurMap/blurFocalDistance", "Blur Focal Distance", num(0.0), slider(0.0, 255.0, 0.0, 255.0, 0)), + p("blurMap/invertBlurMap", "Invert Blur Map", Value::Bool(false), ParamUi::Checkbox), p("highlight/specularBrightness", "Gain", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)), p("highlight/specularThreshold", "Threshold", num(255.0), slider(0.0, 255.0, 0.0, 255.0, 0)), p("highlight/highlightSaturation", "Saturation", num(100.0), slider(0.0, 100.0, 0.0, 100.0, 1)), @@ -822,4 +930,38 @@ mod tests { let pad = (out.img.width - 16) / 2; assert!(out.img.get(pad as i64, pad as i64)[0] < 0.005); } + + #[test] + fn camera_lens_blur_diffraction_fringe_and_blur_map() { + use crate::{EffectEnv, EffectHost, LayerPixels}; + // A single bright point spreads into a disc; with Diffraction Fringe its rim is brighter. + let mut dot = Image::filled(41, 41, [0.0, 0.0, 0.0, 1.0]); + dot.set(20, 20, [1.0, 1.0, 1.0, 1.0]); + let v = [("blurRadius", num(10.0)), ("irisProperties/irisShape", Value::Enum(8)), ("repeatEdge", Value::Bool(true))]; + let plain = run("ec.blur.cameralens", &dot, &v).img; + let ring = run("ec.blur.cameralens", &dot, &[v.as_slice(), &[("irisProperties/diffractionFringe", num(300.0))]].concat()).img; + assert!((plain.get(20, 20)[0] - plain.get(29, 20)[0]).abs() < 1e-5, "even disc"); + assert!(ring.get(29, 20)[0] > ring.get(20, 20)[0] * 2.0, "{:?} {:?}", ring.get(29, 20), ring.get(20, 20)); + // Blur Map: black (focal distance 0) stays sharp, white gets the full radius. + struct Half; + impl EffectHost for Half { + fn layer(&self, _: u64, _: bool) -> Option { + let mut m = Image::filled(40, 20, [0.0, 0.0, 0.0, 1.0]); + for y in 0..20 { + for x in 20..40 { + m.set(x, y, [1.0, 1.0, 1.0, 1.0]); + } + } + Some(LayerPixels { buf: Buf { img: m, offset: [0.0; 2], scale: 1.0 }, size: [40.0, 20.0] }) + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + } + let stripes = crate::util::gen_image(40, 20, |x, _| if x % 4 < 2 { [1.0, 1.0, 1.0, 1.0] } else { [0.0, 0.0, 0.0, 1.0] }); + let vals = [("blurRadius", num(6.0)), ("repeatEdge", Value::Bool(true)), ("blurMap/blurMapLayer", Value::Layer(Some(1)))]; + let out = crate::run_fx("ec.blur.cameralens", &vals, stripes.clone(), 0.0, EffectEnv { host: Some(&Half), ..Default::default() }).img; + assert_eq!(out.get(4, 10), stripes.get(4, 10), "sharp where the map is at the focal distance"); + assert!((out.get(30, 10)[0] - 0.5).abs() < 0.2, "{:?}", out.get(30, 10)); + } } diff --git a/crates/effects/src/card3d.rs b/crates/effects/src/card3d.rs new file mode 100644 index 00000000..5bb29d10 --- /dev/null +++ b/crates/effects/src/card3d.rs @@ -0,0 +1,413 @@ +//! The 3D system shared by Card Dance, Shatter and Card Wipe: Camera System (Camera Position, +//! Corner Pins, Comp Camera), Lighting (Distant Source, Point Source, First Comp Light) and +//! Material (diffuse, specular, highlight sharpness). +//! +//! Pieces live in "buffer world" space: x, y in buffer pixels, z in buffer pixels away from the +//! viewer (the layer plane is z = 0). A [`Proj`] maps buffer-world points to buffer pixels +//! (homogeneous), so every camera system reduces to one 3×4 matrix. + +use effectcraft_keyframe::Value; +use effectcraft_project::ParamUi; + +use crate::sim::pt; +use crate::{Buf, EffectCtx, ParamSpec, col, num, p, popup, slider}; + +pub(crate) type M3 = [[f64; 3]; 3]; + +/// Camera System options. +pub const CAMERA_SYSTEMS: [&str; 3] = ["Camera Position", "Corner Pins", "Comp Camera"]; +/// Transform Order options (rotation order, and whether the camera position applies before or +/// after the rotation). +pub const TRANSFORM_ORDERS: [&str; 12] = [ + "Rotate XYZ, Position", + "Rotate XZY, Position", + "Rotate YXZ, Position", + "Rotate YZX, Position", + "Rotate ZXY, Position", + "Rotate ZYX, Position", + "Position, Rotate XYZ", + "Position, Rotate XZY", + "Position, Rotate YXZ", + "Position, Rotate YZX", + "Position, Rotate ZXY", + "Position, Rotate ZYX", +]; +/// Light Type options. +pub const LIGHT_TYPES: [&str; 3] = ["Distant Source", "Point Source", "First Comp Light"]; + +/// Camera System, Camera Position and Corner Pins parameters. `z` is the default camera +/// distance (Z Position, in layer sizes at the 70 mm-equivalent Focal Length). +pub(crate) fn camera_params(z: f64) -> Vec { + vec![ + p("cameraSystem", "Camera System", Value::Enum(0), popup(&CAMERA_SYSTEMS)), + p("cameraPosition/xRotation", "X Rotation", num(0.0), ParamUi::Angle), + p("cameraPosition/yRotation", "Y Rotation", num(0.0), ParamUi::Angle), + p("cameraPosition/zRotation", "Z Rotation", num(0.0), ParamUi::Angle), + p("cameraPosition/xyPosition", "X, Y Position", pt(0.5, 0.5), ParamUi::Point), + p("cameraPosition/cameraZ", "Z Position", num(z), slider(0.05, 10.0, 0.5, 5.0, 2)), + p("cameraPosition/focalLength", "Focal Length", num(70.0), slider(1.0, 500.0, 10.0, 200.0, 1)), + p("cameraPosition/transformOrder", "Transform Order", Value::Enum(0), popup(&TRANSFORM_ORDERS)), + p("cornerPins/upperLeftCorner", "Upper Left Corner", pt(0.0, 0.0), ParamUi::Point), + p("cornerPins/upperRightCorner", "Upper Right Corner", pt(1.0, 0.0), ParamUi::Point), + p("cornerPins/lowerLeftCorner", "Lower Left Corner", pt(0.0, 1.0), ParamUi::Point), + p("cornerPins/lowerRightCorner", "Lower Right Corner", pt(1.0, 1.0), ParamUi::Point), + p("cornerPins/autoFocalLength", "Auto Focal Length", Value::Bool(true), ParamUi::Checkbox), + p("cornerPins/focalLength", "Focal Length", num(70.0), slider(1.0, 500.0, 10.0, 200.0, 1)), + ] +} + +/// Lighting parameters. +pub(crate) fn lighting_params(intensity: f64, ambient: f64) -> Vec { + vec![ + p("lighting/lightType", "Light Type", Value::Enum(0), popup(&LIGHT_TYPES)), + p("lighting/lightIntensity", "Light Intensity", num(intensity), slider(0.0, 4.0, 0.0, 2.0, 2)), + p("lighting/lightColor", "Light Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("lighting/lightPosition", "Light Position", pt(0.5, 0.5), ParamUi::Point), + p("lighting/lightDepth", "Light Depth", num(1.0), slider(-10.0, 10.0, 0.0, 5.0, 3)), + p("lighting/ambientLight", "Ambient Light", num(ambient), slider(0.0, 2.0, 0.0, 1.0, 2)), + ] +} + +/// Material parameters. Specular defaults to 0 so pieces at rest reproduce the layer. +pub(crate) fn material_params(diffuse: f64) -> Vec { + vec![ + p("material/diffuse", "Diffuse Reflection", num(diffuse), slider(0.0, 2.0, 0.0, 1.0, 2)), + p("material/specular", "Specular Reflection", num(0.0), slider(0.0, 2.0, 0.0, 1.0, 2)), + p("material/highlightSharpness", "Highlight Sharpness", num(15.0), slider(1.0, 100.0, 1.0, 100.0, 1)), + ] +} + +/// Whether the camera / lighting parameter `param` is shown for the current choices. +pub(crate) fn shown(param: &str, e: &dyn Fn(&str) -> u32) -> Option { + if param == "cameraPosition" || param.starts_with("cameraPosition/") { + return Some(e("cameraSystem") == 0); + } + if param == "cornerPins" || param.starts_with("cornerPins/") { + return Some(e("cameraSystem") == 1); + } + if param == "lighting/lightPosition" || param == "lighting/lightDepth" { + return Some(e("lighting/lightType") != 2); + } + None +} + +pub(crate) 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())) +} + +fn rx(a: f64) -> M3 { + let (s, c) = a.sin_cos(); + [[1.0, 0.0, 0.0], [0.0, c, -s], [0.0, s, c]] +} +fn ry(a: f64) -> M3 { + let (s, c) = a.sin_cos(); + [[c, 0.0, s], [0.0, 1.0, 0.0], [-s, 0.0, c]] +} +fn rz(a: f64) -> M3 { + let (s, c) = a.sin_cos(); + [[c, -s, 0.0], [s, c, 0.0], [0.0, 0.0, 1.0]] +} + +/// Rotation applying the axes in `order` (0 XYZ, 1 XZY, 2 YXZ, 3 YZX, 4 ZXY, 5 ZYX; the first +/// letter is applied first). Angles in radians. +pub(crate) fn rot_ordered(x: f64, y: f64, z: f64, order: u32) -> M3 { + let (mx, my, mz) = (rx(x), ry(y), rz(z)); + let seq: [&M3; 3] = match order % 6 { + 1 => [&mx, &mz, &my], + 2 => [&my, &mx, &mz], + 3 => [&my, &mz, &mx], + 4 => [&mz, &mx, &my], + 5 => [&mz, &my, &mx], + _ => [&mx, &my, &mz], + }; + mat_mul(seq[2], &mat_mul(seq[1], seq[0])) +} + +/// A camera: buffer-world → buffer pixels (homogeneous rows X, Y, W; W is the depth in front +/// of the camera, in the units `near` uses). +#[derive(Clone, Copy, Debug)] +pub(crate) struct Proj { + pub m: [[f64; 4]; 3], + /// Points with W below this are behind / too close to the camera. + pub near: f64, +} + +impl Proj { + /// Looking down +z at the layer plane from `d` pixels in front of buffer point `c`. + pub fn simple(c: [f64; 2], d: f64) -> Proj { + Proj { m: [[d, 0.0, c[0], 0.0], [0.0, d, c[1], 0.0], [0.0, 0.0, 1.0, d]], near: 0.05 * d } + } + + /// `self` after the affine scene transform `p ↦ a·p + t`. + fn after(&self, a: &M3, t: [f64; 3]) -> Proj { + let mut m = [[0.0; 4]; 3]; + for i in 0..3 { + for j in 0..3 { + m[i][j] = (0..3).map(|k| self.m[i][k] * a[k][j]).sum(); + } + m[i][3] = (0..3).map(|k| self.m[i][k] * t[k]).sum::() + self.m[i][3]; + } + Proj { m, near: self.near } + } + + /// A screen-space homography applied after `self`. + fn then(&self, h: &M3) -> Proj { + let mut m = [[0.0; 4]; 3]; + for i in 0..3 { + for j in 0..4 { + m[i][j] = (0..3).map(|k| h[i][k] * self.m[k][j]).sum(); + } + } + // Keep W's scale (and so `near`) comparable when H rescales it. + Proj { m, near: self.near * h[2][2].abs().max(1e-9) } + } + + /// Project a buffer-world point (None behind the camera). + #[cfg(test)] + pub fn project(&self, p: [f64; 3]) -> Option<[f64; 2]> { + let r = |i: usize| self.m[i][0] * p[0] + self.m[i][1] * p[1] + self.m[i][2] * p[2] + self.m[i][3]; + let w = r(2); + (w > self.near).then(|| [r(0) / w, r(1) / w]) + } + + /// The camera's centre in buffer-world space (where the projection collapses), or a point + /// far in front of the layer when it has none (orthographic). + pub fn eye(&self) -> [f64; 3] { + let a: M3 = std::array::from_fn(|i| [self.m[i][0], self.m[i][1], self.m[i][2]]); + match inv3(&a) { + Some(ai) => { + let t = [-self.m[0][3], -self.m[1][3], -self.m[2][3]]; + std::array::from_fn(|i| (0..3).map(|k| ai[i][k] * t[k]).sum()) + } + None => [0.0, 0.0, -1e9], + } + } +} + +pub(crate) 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], + ]) +} + +/// Homography taking the unit square's corners (0,0) (1,0) (1,1) (0,1) to `q`. +fn square_to_quad(q: [[f64; 2]; 4]) -> M3 { + let v = |p: [f64; 2]| effectcraft_geom::Vec2 { x: p[0], y: p[1] }; + effectcraft_geom::Mat3::square_to_quad([v(q[0]), v(q[1]), v(q[2]), v(q[3])]).0 +} + +/// The camera an effect renders through, from its Camera System parameters. +pub(crate) fn projection(ctx: &EffectCtx, b: &Buf) -> Proj { + let pr = ctx.params; + let [lw, lh] = ctx.layer_size; + let s = b.scale; + let c = [b.offset[0] + lw * 0.5 * s, b.offset[1] + lh * 0.5 * s]; + let size = lw.max(lh) * s; + let f = |id: &str, d: f64| pr.get(id).map(Value::as_f64).unwrap_or(d); + match pr.e("cameraSystem") { + 1 => { + // Corner Pins: the layer's corners land on the four pins; the focal length sets how + // strongly depth (pieces leaving the plane) is foreshortened. + let focal = if pr.get("cornerPins/autoFocalLength").is_none_or(|v| v.as_bool()) { 70.0 } else { f("cornerPins/focalLength", 70.0).max(1.0) }; + let base = Proj::simple(c, size * 2.0 * focal / 70.0); + let pin = |id: &str| { + let (x, y) = b.to_px(pr.v2(id)); + [x, y] + }; + let quad = + [pin("cornerPins/upperLeftCorner"), pin("cornerPins/upperRightCorner"), pin("cornerPins/lowerRightCorner"), pin("cornerPins/lowerLeftCorner")]; + let rect = [ + [b.offset[0], b.offset[1]], + [b.offset[0] + lw * s, b.offset[1]], + [b.offset[0] + lw * s, b.offset[1] + lh * s], + [b.offset[0], b.offset[1] + lh * s], + ]; + match inv3(&square_to_quad(rect)) { + Some(ri) => base.then(&mat_mul(&square_to_quad(quad), &ri)), + None => base, + } + } + 2 => { + // Comp Camera: the comp's camera looking at this layer (layer px → layer px). + let base = Proj::simple(c, size * 2.0); + let Some(cam) = ctx.env.host.and_then(|h| h.comp_scene()).and_then(|sc| sc.camera) else { return base }; + // buffer world → layer: (p − offset) / s; layer → buffer: · s + offset. + let mut m = [[0.0; 4]; 3]; + for i in 0..3 { + for j in 0..3 { + m[i][j] = cam[i][j] / s; + } + m[i][3] = cam[i][3] - (cam[i][0] * b.offset[0] + cam[i][1] * b.offset[1]) / s; + } + let back: M3 = [[s, 0.0, b.offset[0]], [0.0, s, b.offset[1]], [0.0, 0.0, 1.0]]; + let p = Proj { m, near: 1e-6 }.then(&back); + // W of the layer centre sets the scale of `near`. + let wc = p.m[2][0] * c[0] + p.m[2][1] * c[1] + p.m[2][3]; + Proj { near: (wc.abs() * 0.02).max(1e-9), ..p } + } + _ => { + let zpos = f("cameraPosition/cameraZ", 2.0).max(0.05); + let focal = f("cameraPosition/focalLength", 70.0).max(1.0); + let base = Proj::simple(c, size * zpos * focal / 140.0); + let order = pr.e("cameraPosition/transformOrder"); + let r = rot_ordered( + f("cameraPosition/xRotation", 0.0).to_radians(), + f("cameraPosition/yRotation", 0.0).to_radians(), + f("cameraPosition/zRotation", 0.0).to_radians(), + order, + ); + let xy = pr.get("cameraPosition/xyPosition").map(|v| v.as_vec2()).unwrap_or([lw * 0.5, lh * 0.5]); + let (px, py) = b.to_px(xy); + let shift = [px - c[0], py - c[1], 0.0]; + // Rotate about the layer centre, then move by the X, Y Position offset (or the + // other way round). + let c3 = [c[0], c[1], 0.0]; + let rc: [f64; 3] = std::array::from_fn(|i| c3[i] - (0..3).map(|k| r[i][k] * c3[k]).sum::()); + let t = if order >= 6 { + // p ↦ R(p + shift − c) + c + std::array::from_fn(|i| rc[i] + (0..3).map(|k| r[i][k] * shift[k]).sum::()) + } else { + std::array::from_fn(|i| rc[i] + shift[i]) + }; + base.after(&r, t) + } + } +} + +/// The comp's first light as the effect sees it (layer pixels), from [`crate::EffectHost::comp_scene`]. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct CompLight { + /// Position in layer pixels (z away from the viewer). + pub pos: [f64; 3], + /// Direction the light travels (parallel / spot lights), unit, layer space. + pub dir: [f64; 3], + /// Colour × intensity (1 = 100 %). + pub color: [f32; 3], + /// 0 parallel, 1 point / spot, 2 ambient. + pub kind: u8, +} + +/// The comp's camera and lights relative to the effect's layer. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct CompScene { + /// Layer pixels (x, y, z) → layer pixels, homogeneous rows X, Y, W: the comp camera's view + /// of the layer brought back into the layer's own pixel grid (identity on the layer plane + /// for the default camera). + pub camera: Option<[[f64; 4]; 3]>, + pub light: Option, +} + +fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] { + [a[0] - b[0], a[1] - b[1], a[2] - b[2]] +} +fn dot(a: [f64; 3], b: [f64; 3]) -> f64 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} +fn norm(a: [f64; 3]) -> [f64; 3] { + let l = dot(a, a).sqrt(); + if l < 1e-12 { [0.0, 0.0, -1.0] } else { [a[0] / l, a[1] / l, a[2] / l] } +} + +/// Lighting and Material, ready to shade pieces. +#[derive(Clone, Copy, Debug)] +pub(crate) struct Lighting { + /// 0 distant, 1 point, 2 ambient only. + kind: u8, + color: [f32; 3], + pos: [f64; 3], + /// Distant light: direction towards the light. + to_light: [f64; 3], + ambient: f32, + kd: f32, + ks: f32, + sharp: f32, +} + +impl Lighting { + pub fn from(ctx: &EffectCtx, b: &Buf) -> Lighting { + let pr = ctx.params; + let f = |id: &str, d: f64| pr.get(id).map(Value::as_f64).unwrap_or(d); + let [lw, lh] = ctx.layer_size; + let s = b.scale; + let centre = [b.offset[0] + lw * 0.5 * s, b.offset[1] + lh * 0.5 * s, 0.0]; + let intensity = f("lighting/lightIntensity", 1.0) as f32; + let lc = pr.get("lighting/lightColor").map(|v| v.as_color()).unwrap_or([1.0; 4]); + let mut color = [lc[0] * intensity, lc[1] * intensity, lc[2] * intensity]; + let lp = pr.get("lighting/lightPosition").map(|v| v.as_vec2()).unwrap_or([lw * 0.5, lh * 0.5]); + let (x, y) = b.to_px(lp); + let mut pos = [x, y, -f("lighting/lightDepth", 1.0) * lw.max(lh) * s]; + let mut kind = if pr.e("lighting/lightType") == 1 { 1 } else { 0 }; + let mut to_light = norm(sub(pos, centre)); + if pr.e("lighting/lightType") == 2 { + match ctx.env.host.and_then(|h| h.comp_scene()).and_then(|sc| sc.light) { + Some(l) => { + color = [l.color[0] * intensity, l.color[1] * intensity, l.color[2] * intensity]; + pos = [l.pos[0] * s + b.offset[0], l.pos[1] * s + b.offset[1], l.pos[2] * s]; + kind = l.kind; + to_light = norm([-l.dir[0], -l.dir[1], -l.dir[2]]); + } + // No light in the comp: ambient only. + None => kind = 2, + } + } + Lighting { + kind, + color, + pos, + to_light, + ambient: f("lighting/ambientLight", 0.25) as f32, + kd: f("material/diffuse", 0.75) as f32, + ks: f("material/specular", 0.0) as f32, + sharp: f("material/highlightSharpness", 15.0).max(1.0) as f32, + } + } + + /// Multiplier and additive highlight for a piece with unit normal `n` (rotated front normal) + /// at `at`, seen from `eye`. Pieces are lit on whichever side faces the viewer. + pub fn shade(&self, n: [f64; 3], at: [f64; 3], eye: [f64; 3]) -> ([f32; 3], [f32; 3]) { + let v = norm(sub(eye, at)); + let n = if dot(n, v) < 0.0 { [-n[0], -n[1], -n[2]] } else { n }; + if self.kind == 2 { + let a = self.ambient; + return ([a + self.kd * self.color[0], a + self.kd * self.color[1], a + self.kd * self.color[2]], [0.0; 3]); + } + let l = if self.kind == 1 { norm(sub(self.pos, at)) } else { self.to_light }; + let ndl = dot(n, l).max(0.0) as f32; + let h = norm([l[0] + v[0], l[1] + v[1], l[2] + v[2]]); + let spec = if self.ks > 0.0 && ndl > 0.0 { self.ks * (dot(n, h).max(0.0) as f32).powf(self.sharp) } else { 0.0 }; + (std::array::from_fn(|k| self.ambient + self.kd * ndl * self.color[k]), std::array::from_fn(|k| spec * self.color[k])) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn simple_camera_keeps_the_layer_plane() { + let p = Proj::simple([10.0, 20.0], 100.0); + assert_eq!(p.project([3.0, 4.0, 0.0]), Some([3.0, 4.0])); + let e = p.eye(); + assert!((e[0] - 10.0).abs() < 1e-9 && (e[1] - 20.0).abs() < 1e-9 && (e[2] + 100.0).abs() < 1e-9); + // Further away looks smaller (towards the centre). + let q = p.project([30.0, 20.0, 100.0]).unwrap(); + assert!((q[0] - 20.0).abs() < 1e-9); + } + + #[test] + fn rotation_orders_differ() { + let a = rot_ordered(0.5, 0.3, 0.2, 0); + let b = rot_ordered(0.5, 0.3, 0.2, 5); + assert!((0..3).any(|i| (0..3).any(|j| (a[i][j] - b[i][j]).abs() > 1e-3))); + let z = rot_ordered(0.0, 0.0, 0.0, 3); + assert_eq!(z, [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]); + } +} diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 4ec20906..f41b4b2e 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -7,51 +7,105 @@ use crate::{CATEGORIES, registry}; /// Effects whose implementation is known to fall short of the reference behaviour: /// `(effect id, what is missing)`. Everything else in the registry is implemented in full as far /// as the public behaviour documentation describes it. -pub const PARTIAL: &[(&str, &str)] = &[ - ("ec.blur.cameralens", "no Diffraction Fringe or Blur Map layer"), - ("ec.channel.minimax", "no Don't Shrink Edges option"), - ("ec.channel.combiner", "no Saturation Multiplied target"), - ("ec.3d.channelextract", "no anti-alias option"), - ("ec.color.huesaturation", "no per-range Channel Control"), - ("ec.color.levels", "master channel only (no Channel popup)"), - ("ec.color.curves", "no Channel popup"), - ("ec.color.colorama", "no Add Phase, editable Output Cycle, Modify, Pixel Selection or Masking"), - ("ec.color.lumetri", "no HSL Secondary, hue/saturation curves, HDR mode or look files"), - ("ec.color.selectivecolor", "simplified Colors / Details layout"), - ("ec.distort.turbulentdisplace", "no locked pinning variants"), - ("ec.distort.opticscompensation", "no Optimal Pixels"), - ("ec.distort.transform", "no shutter-angle motion blur"), - ("ec.distort.reshape", "no correspondence points"), - ("ec.distort.rollingshutterrepair", "Pixel Motion Detail has no effect"), - ("ec.generate.fill", "no Fill Mask / All Masks / feather"), - ("ec.generate.advancedlightning", "no Alpha Obstacle; most Expert Settings missing"), - ("ec.generate.radiowaves", "no Image Contours or Mask wave types, Parameters Are Set At, reflection or stroke profile"), - ("ec.generate.scribble", "no caps, joins or Start/End Apply To"), - ("ec.generate.cellpattern", "HQ variants render like the standard ones"), - ("ec.key.innerouter", "one additional mask per side; no cleanup strokes"), - ("ec.key.advancedspill", "no Tolerance, Desaturate or Spill Color Correction"), - ("ec.keying.keylight", "no Source Crops X/Y Method or Edge Colour; no colour suppression / balancing"), - ("ec.matte.refinesoft", "no Reduce Chatter or motion blur"), - ("ec.matte.refinehard", "no Reduce Chatter or motion blur"), - ("ec.obsolete.pathtext", "no kerning, shear/scale, line spacing, fade time or jitter"), - ("ec.noise.addgrain", "no preview region, presets, channel balance or temporal controls"), - ("ec.noise.matchgrain", "no preview region, presets, sampling or temporal controls"), - ("ec.noise.removegrain", "no preview region, sampling or temporal filtering"), - ("ec.vr.digitalglitch", "reduced control set"), - ("ec.vr.converter", "common layouts only"), - ("ec.time.timewarp", "Pixel Motion blends frames (no optical flow); no matte, warp or crop controls"), - ("ec.sim.carddance", "no camera system, corner pins or lighting/material controls"), - ("ec.sim.shatter", "no custom shatter map, gradient, textures or extrusion rendering"), - ("ec.sim.caustics", "no Sky group or light type"), - ("ec.sim.foam", "no wobble, pop velocity, custom bubble texture, environment map or flow map"), - ("ec.sim.particleplayground", "no Particle Exploder, Ephemeral Property Mapper, Affects groups or text particles"), - ("ec.sim.waveworld", "no wireframe controls, dry-area rendering or ground group"), - ("ec.stylize.glow", "no Glow Operation or arbitrary colour map"), - ("ec.stylize.cartoon", "no Edge Enhancement"), - ("ec.transition.cardwipe", "2D flip only; no back layer picker, camera, lighting or jitter"), - ("ec.transition.blockdissolve", "no Soft Edges option"), +pub const PARTIAL: &[(&str, &str)] = &[]; + +/// Whether Effect Controls shows parameter (or twirl-down group) `param` (spec id path, +/// `group/param`) of an instance of effect `effect`, given the instance's current values +/// (`value(spec id)`). After Effects shows only the controls a popup selects: Levels' channel, +/// Hue/Saturation's Channel Control, the camera system of the card effects, and so on. Every +/// parameter stays animatable and addressable; this is presentation only. +pub fn param_shown(effect: &str, param: &str, value: &dyn Fn(&str) -> Option) -> bool { + let e = |id: &str| value(id).map(|v| v.as_enum()).unwrap_or(0); + let b = |id: &str| value(id).is_some_and(|v| v.as_bool()); + match effect { + "ec.color.levels" => { + let ch = e("channel") as usize; + for (i, pre) in crate::color_fx::LEVELS_CHANNEL_PREFIX.iter().enumerate() { + if param.starts_with(pre) { + return ch == i + 1; + } + } + let master = ["inBlack", "inWhite", "gamma", "outBlack", "outWhite", "clipToOutputBlack", "clipToOutputWhite"]; + !master.contains(¶m) || ch == 0 + } + "ec.color.huesaturation" => { + let ch = e("channelControl") as usize; + for (i, (pre, _, _)) in crate::color_fx::HUESAT_RANGES.iter().enumerate() { + if param.starts_with(pre) { + return ch == i + 1; + } + } + match param { + "hue" | "saturation" | "lightness" => ch == 0, + "colorizeSaturation" | "colorizeLightness" => b("colorize"), + _ => true, + } + } + "ec.color.selectivecolor" => match param.strip_prefix("details/") { + Some(rest) => { + let g = rest.split('/').next().unwrap_or(""); + crate::color2::SC_GROUPS.iter().position(|x| *x == g).is_none_or(|i| i == e("colors") as usize) + } + None => true, + }, + "ec.sim.carddance" | "ec.transition.cardwipe" => crate::card3d::shown(param, &e).unwrap_or(true), + "ec.sim.shatter" => match param { + "shape/customShatterMap" | "shape/whiteTilesFixed" => e("shape/pattern") == 5, + _ => crate::card3d::shown(param, &e).unwrap_or(true), + }, + _ => { + let _ = b(""); + true + } + } +} + +/// Controls of GPU-accelerated effects that only the CPU implements: while any of them is away +/// from its default the effect renders on the CPU. (Prefixes ending in `/` cover a whole group.) +const CPU_ONLY_CONTROLS: &[(&str, &[&str])] = &[ + ( + "ec.color.lumetri", + &["highDynamicRange", "basicCorrection/inputLut", "creative/look", "curves/rgbCurves/", "curves/hueSaturationCurves/", "hslSecondary/"], + ), + ( + "ec.color.colorama", + &["inputPhase/addPhase", "outputCycle/usePresetPalette", "modify/", "pixelSelection/matchingMode", "masking/maskingMode", "masking/compositeOverLayer"], + ), + ( + "ec.keying.keylight", + &[ + "foregroundColourCorrection/colourSuppression", + "foregroundColourCorrection/suppressionAmount", + "foregroundColourCorrection/colourBalanceSaturation", + "edgeColourCorrection/edgeColourSuppression", + "edgeColourCorrection/edgeSuppressionAmount", + "edgeColourCorrection/edgeColourBalanceSaturation", + "sourceCrops/xMethod", + "sourceCrops/yMethod", + "sourceCrops/edgeColourAlpha", + ], + ), + ("ec.noise.addgrain", &["viewingMode", "preset", "color/redBalance", "color/greenBalance", "color/blueBalance", "previewRegion/"]), + ("ec.blur.cameralens", &["irisProperties/diffractionFringe", "blurMap/blurMapLayer"]), ]; +/// Whether the GPU kernel of effect `id` covers the instance's current settings (see +/// [`CPU_ONLY_CONTROLS`]; plus Cell Pattern's HQ variants and Turbulent Displace's locked +/// pinning). +pub fn gpu_supported(id: &str, ctx: &crate::EffectCtx) -> bool { + match id { + "ec.generate.cellpattern" if (6..=10).contains(&ctx.params.e("cellPattern")) => return false, + "ec.distort.turbulentdisplace" if ctx.params.e("pinning") >= 8 => return false, + _ => {} + } + let Some((_, controls)) = CPU_ONLY_CONTROLS.iter().find(|(e, _)| *e == id) else { return true }; + let Some(spec) = crate::find(id) else { return true }; + spec.params + .iter() + .filter(|ps| controls.iter().any(|c| if c.ends_with('/') { ps.id.starts_with(c) } else { ps.id == *c })) + .all(|ps| ctx.params.get(ps.id).is_none_or(|v| *v == crate::default_value(ps, ctx.layer_size))) +} + /// Implementation status of effect `id` (`Implemented` or `Partial: …`). pub fn status(id: &str) -> String { match PARTIAL.iter().find(|(e, _)| *e == id) { @@ -122,6 +176,60 @@ mod tests { assert!(have == want, "docs/effects.md is stale: UPDATE_DOCS=1 cargo test -p effectcraft-effects --lib effects_doc_is_current"); } + #[test] + fn cpu_only_controls_keep_effects_off_the_gpu() { + use effectcraft_keyframe::Value; + let ctx_with = |id: &str, set: &[(&str, Value)]| { + let s = crate::find(id).unwrap(); + let mut values: std::collections::HashMap = + s.params.iter().map(|p| (p.id.to_string(), crate::default_value(p, [64.0, 64.0]))).collect(); + for (k, v) in set { + values.insert(k.to_string(), v.clone()); + } + crate::Params { values } + }; + let ok = |id: &str, set: &[(&str, Value)]| { + let params = ctx_with(id, set); + let ctx = crate::EffectCtx { params: ¶ms, time: 0.0, layer_size: [64.0, 64.0], seed: 0, adjustment: false, env: Default::default() }; + super::gpu_supported(id, &ctx) + }; + assert!(ok("ec.color.lumetri", &[])); + assert!(!ok("ec.color.lumetri", &[("hslSecondary/correction/saturation", Value::Scalar(50.0))])); + assert!(!ok("ec.noise.addgrain", &[("preset", Value::Enum(2))])); + assert!(ok("ec.generate.cellpattern", &[("cellPattern", Value::Enum(3))])); + assert!(!ok("ec.generate.cellpattern", &[("cellPattern", Value::Enum(9))])); + assert!(!ok("ec.distort.turbulentdisplace", &[("pinning", Value::Enum(9))])); + assert!(ok("ec.blur.gaussian", &[])); + } + + #[test] + fn popups_choose_the_shown_controls() { + use effectcraft_keyframe::Value; + let with = |pairs: &'static [(&'static str, u32)]| move |id: &str| pairs.iter().find(|(k, _)| *k == id).map(|(_, v)| Value::Enum(*v)); + let shown = |e: &str, p: &str, pairs: &'static [(&'static str, u32)]| super::param_shown(e, p, &with(pairs)); + // Levels: RGB shows the master controls only; Red shows the red ones. + assert!(shown("ec.color.levels", "inBlack", &[])); + assert!(!shown("ec.color.levels", "redInBlack", &[])); + assert!(shown("ec.color.levels", "redInBlack", &[("channel", 1)])); + assert!(!shown("ec.color.levels", "inBlack", &[("channel", 1)])); + assert!(shown("ec.color.levels", "channel", &[("channel", 1)])); + // Hue/Saturation: Master or one colour range. + assert!(shown("ec.color.huesaturation", "hue", &[])); + assert!(shown("ec.color.huesaturation", "bluesHue", &[("channelControl", 5)])); + assert!(!shown("ec.color.huesaturation", "redsHue", &[("channelControl", 5)])); + // Card effects: the camera system's group. + assert!(shown("ec.sim.carddance", "cameraPosition", &[])); + assert!(!shown("ec.sim.carddance", "cornerPins", &[])); + assert!(shown("ec.transition.cardwipe", "cornerPins/upperLeftCorner", &[("cameraSystem", 1)])); + assert!(!shown("ec.sim.shatter", "shape/customShatterMap", &[])); + // Selective Color: the chosen range's Details group. + assert!(shown("ec.color.selectivecolor", "details/reds", &[])); + assert!(!shown("ec.color.selectivecolor", "details/blues/bluesCyan", &[])); + assert!(shown("ec.color.selectivecolor", "details", &[])); + // Everything else is always shown. + assert!(shown("ec.blur.gaussian", "blurriness", &[])); + } + #[test] fn partial_notes_name_registered_effects() { for (id, note) in super::PARTIAL { diff --git a/crates/effects/src/channel.rs b/crates/effects/src/channel.rs index 17b23521..b763ac21 100644 --- a/crates/effects/src/channel.rs +++ b/crates/effects/src/channel.rs @@ -90,7 +90,8 @@ const COMBINER_FROM: [&str; 14] = [ "Min RGB", ]; /// Channel Combiner's To options (a single value goes into one channel). -const COMBINER_TO: [&str; 8] = ["Red Only", "Green Only", "Blue Only", "Alpha Only", "Lightness Only", "Hue Only", "Saturation Only", "Grayscale"]; +const COMBINER_TO: [&str; 9] = + ["Red Only", "Green Only", "Blue Only", "Alpha Only", "Lightness Only", "Hue Only", "Saturation Only", "Grayscale", "Saturation Multiplied"]; fn channel_combiner(ctx: &EffectCtx, mut b: Buf) -> Buf { let from = ctx.params.e("from"); @@ -160,6 +161,13 @@ fn channel_combiner(ctx: &EffectCtx, mut b: Buf) -> Buf { let (r, g, bb) = hsl_to_rgb(0.0, v.clamp(0.0, 1.0), 0.5); [r, g, bb] } + // Saturation Multiplied: the pixel keeps its hue and lightness; its saturation + // is scaled by the value. + 8 => { + let (h, s, l) = rgb_to_hsl(c[0], c[1], c[2]); + let (r, g, bb) = hsl_to_rgb(h, (s * v).clamp(0.0, 1.0), l); + [r, g, bb] + } _ => [v, v, v], } } @@ -175,6 +183,25 @@ fn channel_combiner(ctx: &EffectCtx, mut b: Buf) -> Buf { /// Minimax's Direction options. pub(crate) const MINIMAX_DIRECTIONS: [&str; 3] = ["Horizontal & Vertical", "Just Horizontal", "Just Vertical"]; +/// `p` with `rx` / `ry` zero pixels added on every side. +fn pad_plane(p: &Plane, rx: usize, ry: usize) -> Plane { + let (w, h) = (p.w + 2 * rx, p.h + 2 * ry); + let mut out = Plane::new(w, h); + for y in 0..p.h { + out.data[(y + ry) * w + rx..(y + ry) * w + rx + p.w].copy_from_slice(&p.data[y * p.w..(y + 1) * p.w]); + } + out +} + +/// The `w`×`h` interior of a plane padded by [`pad_plane`]. +fn crop_plane(p: &Plane, rx: usize, ry: usize, w: usize, h: usize) -> Plane { + let mut out = Plane::new(w, h); + for y in 0..h { + out.data[y * w..(y + 1) * w].copy_from_slice(&p.data[(y + ry) * p.w + rx..(y + ry) * p.w + rx + w]); + } + out +} + fn minimax(ctx: &EffectCtx, mut b: Buf) -> Buf { let r = (ctx.params.f("radius") * b.scale).round().max(0.0) as usize; if r == 0 { @@ -182,6 +209,7 @@ fn minimax(ctx: &EffectCtx, mut b: Buf) -> Buf { } let op = ctx.params.e("operation"); let ch = ctx.params.e("channel"); + let dont_shrink = ctx.params.b("dontShrinkEdges"); let (rx, ry) = match ctx.params.e("direction") { 1 => (r, 0), 2 => (0, r), @@ -204,12 +232,16 @@ fn minimax(ctx: &EffectCtx, mut b: Buf) -> Buf { if !sel[k] { continue; } - *pl = match op { - 0 => morph_plane(pl, rx, ry, false), - 1 => morph_plane(pl, rx, ry, true), - 2 => morph_plane(&morph_plane(pl, rx, ry, false), rx, ry, true), - _ => morph_plane(&morph_plane(pl, rx, ry, true), rx, ry, false), + // Beyond the layer edge is empty (0), so Minimum eats into the edges unless Don't + // Shrink Edges extends the edge pixels instead. + let padded = if dont_shrink { pl.clone() } else { pad_plane(pl, rx, ry) }; + let out = match op { + 0 => morph_plane(&padded, rx, ry, false), + 1 => morph_plane(&padded, rx, ry, true), + 2 => morph_plane(&morph_plane(&padded, rx, ry, false), rx, ry, true), + _ => morph_plane(&morph_plane(&padded, rx, ry, true), rx, ry, false), }; + *pl = if dont_shrink { out } else { crop_plane(&out, rx, ry, pl.w, pl.h) }; } let mut img = join(&planes); img.data.par_iter_mut().for_each(|px| { @@ -408,6 +440,7 @@ pub fn specs() -> Vec { p("radius", "Radius", num(0.0), slider(0.0, 500.0, 0.0, 100.0, 0)), p("channel", "Channel", Value::Enum(0), popup(&["Color", "Alpha", "Alpha and Color", "Red", "Green", "Blue"])), p("direction", "Direction", Value::Enum(0), popup(&MINIMAX_DIRECTIONS)), + p("dontShrinkEdges", "Don't Shrink Edges", Value::Bool(false), ParamUi::Checkbox), ], minimax, ), @@ -658,4 +691,23 @@ mod tests { assert!(close(out.get(0, 0), [0.0, 0.0, 1.0, 1.0])); assert!(close(out.get(1, 0), [1.0, 0.0, 0.0, 1.0])); } + + #[test] + fn minimax_shrinks_layer_edges_unless_told_not_to() { + let img = Image::filled(9, 9, [1.0, 1.0, 1.0, 1.0]); + let shrunk = run("ec.channel.minimax", &[("radius", num(2.0)), ("operation", Value::Enum(0))], img.clone()); + assert_eq!(shrunk.get(0, 4)[0], 0.0); + assert_eq!(shrunk.get(4, 4)[0], 1.0); + let kept = run("ec.channel.minimax", &[("radius", num(2.0)), ("operation", Value::Enum(0)), ("dontShrinkEdges", Value::Bool(true))], img); + assert_eq!(kept.get(0, 4)[0], 1.0); + } + + #[test] + fn channel_combiner_saturation_multiplied() { + // From Lightness (0.5) into Saturation Multiplied halves the saturation, keeping hue. + let red = Image::filled(1, 1, [0.75, 0.25, 0.25, 1.0]); + let out = run("ec.channel.combiner", &[("from", Value::Enum(8)), ("to", Value::Enum(8))], red).get(0, 0); + let (h, s, l) = rgb_to_hsl(out[0], out[1], out[2]); + assert!((h.abs() < 1e-4 || (h - 1.0).abs() < 1e-4) && (s - 0.25).abs() < 1e-3 && (l - 0.5).abs() < 1e-3, "{h} {s} {l}"); + } } diff --git a/crates/effects/src/channel3d.rs b/crates/effects/src/channel3d.rs index 8da44388..3515204e 100644 --- a/crates/effects/src/channel3d.rs +++ b/crates/effects/src/channel3d.rs @@ -31,12 +31,17 @@ fn aux(ctx: &EffectCtx) -> Option> { /// Aux index for each buffer pixel (None outside the aux image). fn index_map(b: &Buf, a: &AuxChannels) -> Vec> { + index_map_at(b, a, [0.5, 0.5]) +} + +/// [`index_map`] sampling each pixel at sub-pixel position `at` (0..1). +fn index_map_at(b: &Buf, a: &AuxChannels, at: [f64; 2]) -> Vec> { let (w, h) = (b.img.width as usize, b.img.height as usize); let inv = 1.0 / b.scale.max(1e-9); (0..w * h) .into_par_iter() .map(|i| { - let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5); + let (x, y) = ((i % w) as f64 + at[0], (i / w) as f64 + at[1]); a.index_at((x - b.offset[0]) * inv, (y - b.offset[1]) * inv) }) .collect() @@ -60,7 +65,28 @@ pub const EXTRACT_CHANNELS: [&str; 8] = ["Z-Depth", "Object ID", "Texture UV", " fn channel_extract(ctx: &EffectCtx, mut b: Buf) -> Buf { let Some(a) = aux(ctx) else { return b }; + if ctx.params.b("antialias") { + // Anti-alias: average four sub-pixel samples of the channel. + let subs = [[0.25, 0.25], [0.75, 0.25], [0.25, 0.75], [0.75, 0.75]]; + let imgs: Vec = subs.iter().map(|s| channel_extract_at(ctx, &b, &a, &index_map_at(&b, &a, *s))).collect::>>().unwrap_or_default(); + if imgs.len() == 4 { + let mut out = imgs[0].clone(); + out.data.par_iter_mut().enumerate().for_each(|(i, p)| { + *p = std::array::from_fn(|c| imgs.iter().map(|m| m.data[i][c]).sum::() * 0.25); + }); + b.img = out; + } + return b; + } let idx = index_map(&b, &a); + if let Some(i) = channel_extract_at(ctx, &b, &a, &idx) { + b.img = i; + } + b +} + +/// 3D Channel Extract through one index map (None when the channel is missing). +fn channel_extract_at(ctx: &EffectCtx, b: &Buf, a: &AuxChannels, idx: &[Option]) -> Option { let (bp, wp) = (ctx.params.f("blackPoint") as f32, ctx.params.f("whitePoint") as f32); // Clamp Output limits the mapped values to 0..1; Invert Depth Map flips them. let (clamp, invert) = (ctx.params.b("clampOutput"), ctx.params.b("invertDepthMap")); @@ -69,24 +95,20 @@ fn channel_extract(ctx: &EffectCtx, mut b: Buf) -> Buf { let lv = |v: f32| if wp >= bp { lv(v) } else { 1.0 - cl((v - wp) / (bp - wp).max(1e-9)) }; let lv = |v: f32| if invert { 1.0 - lv(v) } else { lv(v) }; let get3 = |names: [&[&str]; 3]| -> Option<[&[f32]; 3]> { Some([a.find(names[0])?, a.find(names[1])?, a.find(names[2])?]) }; - let img: Option = match ctx.params.e("channel") { - 0 => a.depth().map(|z| map_grey(&b, &idx, |i| lv(z[i]))), - 1 => a.object_id().map(|c| map_grey(&b, &idx, |i| lv(c[i]))), - 7 => a.material_id().map(|c| map_grey(&b, &idx, |i| lv(c[i]))), - 4 => a.coverage().map(|c| map_grey(&b, &idx, |i| lv(c[i]))), + match ctx.params.e("channel") { + 0 => a.depth().map(|z| map_grey(b, idx, |i| lv(z[i]))), + 1 => a.object_id().map(|c| map_grey(b, idx, |i| lv(c[i]))), + 7 => a.material_id().map(|c| map_grey(b, idx, |i| lv(c[i]))), + 4 => a.coverage().map(|c| map_grey(b, idx, |i| lv(c[i]))), 2 => get3([&["UV.U", "U", "uv.x"], &["UV.V", "V", "uv.y"], &["UV.W", "W", "uv.z"]]) .or_else(|| Some([a.find(&["UV.U", "U", "uv.x"])?, a.find(&["UV.V", "V", "uv.y"])?, a.find(&["UV.V", "V", "uv.y"])?])) - .map(|c| map_rgb(&b, &idx, |i| [lv(c[0][i]), lv(c[1][i]), 0.0])), + .map(|c| map_rgb(b, idx, |i| [lv(c[0][i]), lv(c[1][i]), 0.0])), 3 => get3([&["N.X", "Normal.X", "normals.x"], &["N.Y", "Normal.Y", "normals.y"], &["N.Z", "Normal.Z", "normals.z"]]) - .map(|c| map_rgb(&b, &idx, |i| [lv(c[0][i] * 0.5 + 0.5), lv(c[1][i] * 0.5 + 0.5), lv(c[2][i] * 0.5 + 0.5)])), + .map(|c| map_rgb(b, idx, |i| [lv(c[0][i] * 0.5 + 0.5), lv(c[1][i] * 0.5 + 0.5), lv(c[2][i] * 0.5 + 0.5)])), 5 => get3([&["BG.R", "Background.R"], &["BG.G", "Background.G"], &["BG.B", "Background.B"]]) - .map(|c| map_rgb(&b, &idx, |i| [lv(c[0][i]), lv(c[1][i]), lv(c[2][i])])), - _ => get3([&["R"], &["G"], &["B"]]).map(|c| map_rgb(&b, &idx, |i| [c[0][i], c[1][i], c[2][i]])), - }; - if let Some(i) = img { - b.img = i; + .map(|c| map_rgb(b, idx, |i| [lv(c[0][i]), lv(c[1][i]), lv(c[2][i])])), + _ => get3([&["R"], &["G"], &["B"]]).map(|c| map_rgb(b, idx, |i| [c[0][i], c[1][i], c[2][i]])), } - b } fn map_grey(b: &Buf, idx: &[Option], f: impl Fn(usize) -> f32 + Sync) -> Image { @@ -443,6 +465,7 @@ pub fn specs() -> Vec { p("whitePoint", "White Point", num(10000.0), slider(-1.0e6, 1.0e6, -10000.0, 10000.0, 2)), p("clampOutput", "Clamp Output", Value::Bool(true), ParamUi::Checkbox), p("invertDepthMap", "Invert Depth Map", Value::Bool(false), ParamUi::Checkbox), + p("antialias", "Anti-alias", Value::Bool(false), ParamUi::Checkbox), ], channel_extract, ), @@ -689,4 +712,18 @@ mod tests { let s = run_fx("ec.3d.extractor", &[("red", Value::Str("A".into()))], Image::filled(2, 2, [0.2, 0.3, 0.4, 1.0]), 0.0, EffectEnv::default()); assert_eq!(s.img.get(0, 0), [1.0, 0.3, 0.4, 1.0]); } + + #[test] + fn channel_extract_antialias_averages_sub_pixels() { + // Depth at twice the layer resolution, alternating 0 / 1000 per aux column. + let mut a = AuxChannels::new(40, 20, 2.0); + a.channels = vec![("Z".into(), (0..800).map(|i| if i % 2 == 0 { 0.0 } else { 1000.0 }).collect())]; + let host = AuxHost(Arc::new(a)); + let vals = [("blackPoint", num(0.0)), ("whitePoint", num(1000.0))]; + let hard = run("ec.3d.channelextract", &vals, &host); + let v = hard.get(5, 5)[0]; + assert!(v == 0.0 || v == 1.0, "{v}"); + let soft = run("ec.3d.channelextract", &[vals.as_slice(), &[("antialias", Value::Bool(true))]].concat(), &host); + assert!((soft.get(5, 5)[0] - 0.5).abs() < 1e-5, "{:?}", soft.get(5, 5)); + } } diff --git a/crates/effects/src/color2.rs b/crates/effects/src/color2.rs index f1c918bc..67063d31 100644 --- a/crates/effects/src/color2.rs +++ b/crates/effects/src/color2.rs @@ -504,6 +504,16 @@ const SC_IDS: [[&str; 4]; 9] = [ ["neutralsCyan", "neutralsMagenta", "neutralsYellow", "neutralsBlack"], ["blacksCyan", "blacksMagenta", "blacksYellow", "blacksBlack"], ]; +/// Selective Color's Colors popup (the range whose controls Effect Controls shows) and the +/// Details twirl-down group of each range. +pub const SC_COLORS: [&str; 9] = ["Reds", "Yellows", "Greens", "Cyans", "Blues", "Magentas", "Whites", "Neutrals", "Blacks"]; +pub const SC_GROUPS: [&str; 9] = ["reds", "yellows", "greens", "cyans", "blues", "magentas", "whites", "neutrals", "blacks"]; + +/// Spec id of range `r`'s control `leaf` (`details/reds/redsCyan`). +fn sc_param_id(r: usize, leaf: &str) -> String { + format!("details/{}/{leaf}", SC_GROUPS[r]) +} + const SC_NAMES: [[&str; 4]; 9] = [ ["Reds Cyan", "Reds Magenta", "Reds Yellow", "Reds Black"], ["Yellows Cyan", "Yellows Magenta", "Yellows Yellow", "Yellows Black"], @@ -534,7 +544,7 @@ fn sc_weights(c: [f32; 3]) -> [f32; 9] { } fn selective_color(ctx: &EffectCtx, mut b: Buf) -> Buf { - let adj = SC_IDS.map(|ids| ids.map(|id| ctx.params.f(id) as f32 / 100.0)); + let adj: [[f32; 4]; 9] = std::array::from_fn(|r| SC_IDS[r].map(|id| ctx.params.f(&sc_param_id(r, id)) as f32 / 100.0)); if adj.iter().all(|r| r.iter().all(|v| *v == 0.0)) { return b; } @@ -882,10 +892,12 @@ pub fn specs() -> Vec { } lv_params.push(p("clipToOutputBlack", "Clip To Output Black", Value::Enum(2), popup(&crate::color_fx::LEVELS_CLIP))); lv_params.push(p("clipToOutputWhite", "Clip To Output White", Value::Enum(2), popup(&crate::color_fx::LEVELS_CLIP))); - let mut sc_params = vec![p("method", "Method", Value::Enum(0), popup(&["Relative", "Absolute"]))]; - for (ids, names) in SC_IDS.iter().zip(SC_NAMES.iter()) { + let mut sc_params = vec![p("method", "Method", Value::Enum(0), popup(&["Relative", "Absolute"])), p("colors", "Colors", Value::Enum(0), popup(&SC_COLORS))]; + // Details: every range's Cyan / Magenta / Yellow / Black in its own twirl-down. + let leak = |s: String| -> &'static str { Box::leak(s.into_boxed_str()) }; + for (r, (ids, names)) in SC_IDS.iter().zip(SC_NAMES.iter()).enumerate() { for k in 0..4 { - sc_params.push(p(ids[k], names[k], num(0.0), pct())); + sc_params.push(p(leak(sc_param_id(r, ids[k])), names[k], num(0.0), pct())); } } let clip_params = |extra: bool| { @@ -911,7 +923,15 @@ pub fn specs() -> Vec { spec( "ec.color.curves", "Curves", - vec![curve("rgb", "RGB"), curve("red", "Red"), curve("green", "Green"), curve("blue", "Blue"), curve("alpha", "Alpha")], + vec![ + // The channel the graph edits (the Effect Controls graph's Channel popup). + p("channel", "Channel", Value::Enum(0), ParamUi::Hidden), + curve("rgb", "RGB"), + curve("red", "Red"), + curve("green", "Green"), + curve("blue", "Blue"), + curve("alpha", "Alpha"), + ], curves, ), spec("ec.color.levelsic", "Levels (Individual Controls)", lv_params, levels_ic), @@ -1138,10 +1158,10 @@ mod tests { let img = ramp(); assert_eq!(run("ec.color.selectivecolor", &img, &[]), img); let red = Image::filled(2, 2, [1.0, 0.1, 0.1, 1.0]); - let out = run("ec.color.selectivecolor", &red, &[("redsCyan", num(100.0)), ("method", Value::Enum(1))]); + let out = run("ec.color.selectivecolor", &red, &[("details/reds/redsCyan", num(100.0)), ("method", Value::Enum(1))]); assert!(out.data[0][0] < 0.3, "{:?}", out.data[0]); let blue = Image::filled(2, 2, [0.1, 0.1, 1.0, 1.0]); - assert_eq!(run("ec.color.selectivecolor", &blue, &[("redsCyan", num(100.0))]), blue); + assert_eq!(run("ec.color.selectivecolor", &blue, &[("details/reds/redsCyan", num(100.0))]), blue); } #[test] diff --git a/crates/effects/src/color3.rs b/crates/effects/src/color3.rs index 5b9716ef..7d626d19 100644 --- a/crates/effects/src/color3.rs +++ b/crates/effects/src/color3.rs @@ -249,8 +249,193 @@ fn tint_offset(c: [f32; 4]) -> [f32; 3] { [c[0] - l, c[1] - l, c[2] - l] } +/// Control points `x,y x,y …` (0..1, whitespace or `;` separated), sorted by x. +fn parse_points(s: &str) -> Vec<(f32, f32)> { + let mut pts: Vec<(f32, f32)> = s + .split(|c: char| c.is_whitespace() || c == ';') + .filter_map(|t| { + let (x, y) = t.split_once(',')?; + let (x, y) = (x.trim().parse::().ok()?, y.trim().parse::().ok()?); + (x.is_finite() && y.is_finite()).then_some((x, y)) + }) + .collect(); + pts.sort_by(|a, b| a.0.total_cmp(&b.0)); + pts.dedup_by(|a, b| (a.0 - b.0).abs() < 1e-6); + pts +} + +/// A Lumetri hue / saturation curve: control points `x,y` in 0..1 where y = 0.5 is neutral, +/// interpolated with a cardinal (Catmull-Rom) spline through the points. Hue curves wrap +/// around (x = 0 and x = 1 are the same hue); the others are flat beyond their end points. +/// Tabulated as offsets `y − 0.5`. +#[derive(Clone, Debug)] +pub(crate) struct OffsetCurve { + lut: Vec, +} + +impl OffsetCurve { + const N: usize = 512; + + /// `None` for an empty or flat-neutral curve. + pub(crate) fn parse(s: &str, periodic: bool) -> Option { + let pts = parse_points(s); + if pts.is_empty() || pts.iter().all(|p| (p.1 - 0.5).abs() < 1e-6) { + return None; + } + let mut ext: Vec<(f32, f32)> = vec![]; + if periodic { + let wrapped: Vec<(f32, f32)> = pts.iter().map(|&(x, y)| (x.rem_euclid(1.0), y)).collect(); + let mut w = wrapped; + w.sort_by(|a, b| a.0.total_cmp(&b.0)); + for k in -1..=1 { + ext.extend(w.iter().map(|&(x, y)| (x + k as f32, y))); + } + } else { + let (x0, y0) = pts[0]; + let (x1, y1) = pts[pts.len() - 1]; + ext.push((x0.min(0.0) - 2.0, y0)); + ext.push((x0.min(0.0) - 1.0, y0)); + ext.extend(pts.iter().copied()); + ext.push((x1.max(1.0) + 1.0, y1)); + ext.push((x1.max(1.0) + 2.0, y1)); + } + let n = ext.len(); + // Tangents: the mean of the neighbouring slopes, flat at local extrema and next to flat + // segments so the curve never overshoots its points. + let slope = |k: usize| (ext[k + 1].1 - ext[k].1) / (ext[k + 1].0 - ext[k].0).max(1e-6); + let m: Vec = (0..n) + .map(|k| { + if k == 0 || k == n - 1 { + return 0.0; + } + let (a, b) = (slope(k - 1), slope(k)); + if a * b <= 0.0 { 0.0 } else { (a + b) * 0.5 } + }) + .collect(); + let eval = |x: f32| -> f32 { + let k = ext.partition_point(|p| p.0 <= x).saturating_sub(1).min(n - 2); + let (xa, ya) = ext[k]; + let (xb, yb) = ext[k + 1]; + let h = (xb - xa).max(1e-6); + let t = ((x - xa) / h).clamp(0.0, 1.0); + let (t2, t3) = (t * t, t * t * t); + (2.0 * t3 - 3.0 * t2 + 1.0) * ya + (t3 - 2.0 * t2 + t) * h * m[k] + (-2.0 * t3 + 3.0 * t2) * yb + (t3 - t2) * h * m[k + 1] + }; + let single = pts.len() == 1; + let lut = (0..=Self::N).map(|i| if single { pts[0].1 - 0.5 } else { eval(i as f32 / Self::N as f32) - 0.5 }).collect(); + Some(OffsetCurve { lut }) + } + + /// Offset from neutral at `x` (clamped to 0..1). + pub(crate) fn at(&self, x: f32) -> f32 { + let f = x.clamp(0.0, 1.0) * Self::N as f32; + let i = (f as usize).min(Self::N - 1); + let t = f - i as f32; + self.lut[i] + (self.lut[i + 1] - self.lut[i]) * t + } +} + +/// Lumetri's built-in creative looks (our own formulas; Custom loads a `.cube` file). +pub const LUMETRI_LOOKS: [&str; 8] = + ["None", "Custom", "EC Warm Film", "EC Cool Night", "EC Bleach Bypass", "EC Teal & Orange", "EC Faded Matte", "EC Monochrome"]; + +fn builtin_look(look: u32, c: [f32; 3]) -> [f32; 3] { + let l = luminance(c[0], c[1], c[2]); + match look { + 2 => [c[0] * 1.06 + 0.015, c[1] * 1.01 + 0.01, c[2] * 0.88], + 3 => [c[0] * 0.82, c[1] * 0.92, c[2] * 1.08 + 0.02].map(|v| v * 0.92), + 4 => { + // Silver retained: half the saturation, a luminance overlay for contrast. + let d = c.map(|v| l + (v - l) * 0.45); + let o = |v: f32| if l < 0.5 { 2.0 * v * l } else { 1.0 - 2.0 * (1.0 - v) * (1.0 - l) }; + d.map(|v| v * 0.5 + o(v) * 0.5) + } + 5 => { + let t = (l - 0.5).clamp(-0.5, 0.5); + [c[0] + 0.18 * t, c[1] + 0.03 * t, c[2] - 0.16 * t] + } + 6 => c.map(|v| 0.08 + v * 0.84).map(|v| l + (v - l) * 0.8), + 7 => [l; 3], + _ => c, + } +} + +/// HSL Secondary's Show Mask styles. +const HSL_MASK_STYLES: [&str; 3] = ["Color/Gray", "Color/Black", "White/Black"]; + +/// HSL Secondary key: membership (0..1) of an HSL colour (h, s, l in 0..1). +struct HslKey { + hue: Option<(f32, f32, f32)>, + sat: Option<(f32, f32, f32)>, + luma: Option<(f32, f32, f32)>, +} + +impl HslKey { + fn from(ctx: &EffectCtx) -> HslKey { + let pr = ctx.params; + let k = |id: &str| pr.f(&format!("hslSecondary/key/{id}")) as f32; + let on = |id: &str| pr.get(&format!("hslSecondary/key/{id}")).is_none_or(|v| v.as_bool()); + let band = |lo: &str, hi: &str, soft: &str| (k(lo) / 100.0, k(hi) / 100.0, k(soft) / 100.0); + HslKey { + hue: on("hueEnabled").then(|| (k("hueCenter"), k("hueRange"), k("hueSoftness"))), + sat: on("saturationEnabled").then(|| band("saturationLow", "saturationHigh", "saturationSoftness")), + luma: on("lightnessEnabled").then(|| band("lightnessLow", "lightnessHigh", "lightnessSoftness")), + } + } + fn band(v: f32, (lo, hi, soft): (f32, f32, f32)) -> f32 { + let (lo, hi) = (lo.min(hi), lo.max(hi)); + let d = if v < lo { + lo - v + } else if v > hi { + v - hi + } else { + 0.0 + }; + if d <= 0.0 { + 1.0 + } else if soft > 0.0 { + (1.0 - d / soft).max(0.0) + } else { + 0.0 + } + } + fn weight(&self, h: f32, s: f32, l: f32) -> f32 { + let mut w = 1.0; + if let Some((centre, range, soft)) = self.hue + && range < 180.0 + { + let d = ((h * 360.0 - centre).rem_euclid(360.0)).min((centre - h * 360.0).rem_euclid(360.0)); + let hw = if d <= range { + 1.0 + } else if soft > 0.0 { + (1.0 - (d - range) / soft).max(0.0) + } else { + 0.0 + }; + // Greys have no hue: they belong to a hue key only as far as they are saturated. + w *= hw * (s * 20.0).min(1.0); + } + if let Some(b) = self.sat { + w *= Self::band(s, b); + } + if let Some(b) = self.luma { + w *= Self::band(l, b); + } + w + } +} + +/// Lumetri Color: Basic Correction (input LUT, white balance, HDR range, tone, saturation), +/// Creative (look, adjustments), Curves (tone sliders, RGB curves, hue/saturation curves), +/// Color Wheels, HSL Secondary (key, refine, correction), Vignette, in that order. fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf { let pr = ctx.params; + let hdr = pr.b("highDynamicRange"); + let hdr_white = if hdr { (pr.get("basicCorrection/tone/hdrWhite").map(Value::as_f64).unwrap_or(100.0) / 100.0).max(0.01) as f32 } else { 1.0 }; + let hdr_spec = if hdr { (pr.f("basicCorrection/tone/hdrSpecular") / 100.0) as f32 } else { 0.0 }; + let input_lut = if pr.e("basicCorrection/inputLut") == 1 { crate::utility::load_lut(pr.s("basicCorrection/inputLutFile")) } else { None }; + let look = pr.e("creative/look"); + let look_lut = if look == 1 { crate::utility::load_lut(pr.s("creative/lookFile")) } else { None }; let temp = (pr.f("basicCorrection/whiteBalance/temperature") / 100.0) as f32; let tint = (pr.f("basicCorrection/whiteBalance/tint") / 100.0) as f32; let expo = 2f32.powf(pr.f("basicCorrection/tone/exposure") as f32); @@ -277,6 +462,12 @@ fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf { ] }; let (cm, cr, cg, cb) = (curve("curveMaster"), curve("curveRed"), curve("curveGreen"), curve("curveBlue")); + let rgb_curves = + ["curves/rgbCurves/master", "curves/rgbCurves/red", "curves/rgbCurves/green", "curves/rgbCurves/blue"].map(|id| crate::color2::Curve::parse(pr.s(id))); + let hs = |id: &str, periodic: bool| OffsetCurve::parse(pr.s(&format!("curves/hueSaturationCurves/{id}")), periodic); + let (hue_sat, hue_hue, hue_luma, luma_sat, sat_sat) = + (hs("hueVsSat", true), hs("hueVsHue", true), hs("hueVsLuma", true), hs("lumaVsSat", false), hs("satVsSat", false)); + let any_hs = hue_sat.is_some() || hue_hue.is_some() || hue_luma.is_some() || luma_sat.is_some() || sat_sat.is_some(); let ws = tint_offset(pr.color("colorWheels/shadowsWheel")); let wm = tint_offset(pr.color("colorWheels/midtonesWheel")); let wh_ = tint_offset(pr.color("colorWheels/highlightsWheel")); @@ -285,12 +476,29 @@ fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf { let vround = (pr.f("vignette/vignetteRoundness") / 100.0) as f32; let vfeather = (pr.f("vignette/vignetteFeather") / 100.0) as f32; let sharpen = (pr.f("creative/adjustments/sharpen") / 100.0) as f32; + // HSL Secondary. + let sec = |id: &str, d: f64| pr.get(&format!("hslSecondary/{id}")).map(Value::as_f64).unwrap_or(d) as f32; + let s_temp = sec("correction/temperature", 0.0) / 100.0; + let s_tint = sec("correction/tint", 0.0) / 100.0; + let s_contrast = sec("correction/contrast", 0.0) / 100.0; + let s_sharpen = sec("correction/sharpen", 0.0) / 100.0; + let s_sat = sec("correction/saturation", 100.0) / 100.0; + let s_wheel = pr.get("hslSecondary/correction/correctionWheel").map(|v| tint_offset(v.as_color())).unwrap_or([0.0; 3]); + let show_mask = pr.b("hslSecondary/key/showMask"); + let sec_active = show_mask || s_temp != 0.0 || s_tint != 0.0 || s_contrast != 0.0 || s_sharpen != 0.0 || s_sat != 1.0 || s_wheel != [0.0; 3]; 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| { + map_colour(&mut b, |_, c| { let mut c = c; - // Basic Correction: white balance, exposure, contrast, tone, saturation. + if let Some(l) = &input_lut { + c = l.apply(c); + } + // Basic Correction: white balance, exposure, contrast, tone, saturation. With High + // Dynamic Range the tone controls work on a range whose white is HDR White. + if hdr_white != 1.0 { + c = c.map(|v| v / hdr_white); + } 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)]; } @@ -306,13 +514,24 @@ fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf { 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); } + if hdr_spec != 0.0 { + // HDR Specular scales what lies above white. + c = c.map(|v| if v > 1.0 { 1.0 + (v - 1.0) * (1.0 + hdr_spec).max(0.0) } else { v }); + } + if hdr_white != 1.0 { + c = c.map(|v| v * hdr_white); + } 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; + // Creative: the look, then the adjustments, all at Intensity. + let has_look = look >= 2 || look_lut.is_some(); + if intensity != 0.0 && (has_look || faded != 0.0 || vib != 0.0 || csat != 1.0 || st != [0.0; 3] || ht != [0.0; 3]) { + let mut d = match &look_lut { + Some(lut) => lut.apply(c), + None => builtin_look(look, c), + }; if faded != 0.0 { d = d.map(|v| v * (1.0 - 0.25 * faded) + 0.12 * faded); } @@ -336,9 +555,40 @@ fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf { } c = [lerp(c[0], d[0], intensity), lerp(c[1], d[1], intensity), lerp(c[2], d[2], intensity)]; } - // Curves. + // Curves: tone sliders, RGB curves (master, then each channel), hue/saturation 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])]; + if let Some(m) = &rgb_curves[0] { + c = c.map(|v| m.eval(v)); + } + for k in 0..3 { + if let Some(cv) = &rgb_curves[k + 1] { + c[k] = cv.eval(c[k]); + } + } + if any_hs { + let (h, s, l) = rgb_to_hsl(c[0].clamp(0.0, 1.0), c[1].clamp(0.0, 1.0), c[2].clamp(0.0, 1.0)); + let chroma = (s * 20.0).min(1.0); + let off = |cv: &Option, x: f32| cv.as_ref().map(|cv| cv.at(x)).unwrap_or(0.0); + // y = 1 doubles saturation, y = 0 removes it. + let mut ms = (1.0 + 2.0 * off(&hue_sat, h) * chroma) * (1.0 + 2.0 * off(&luma_sat, l)) * (1.0 + 2.0 * off(&sat_sat, s)); + ms = ms.max(0.0); + let dh = off(&hue_hue, h) * chroma; + let dl = off(&hue_luma, h) * chroma; + let luma = luminance(c[0], c[1], c[2]); + if dh != 0.0 { + let (r, g, b2) = hsl_to_rgb((h + dh).rem_euclid(1.0), s, l); + // Keep values outside 0..1 (HDR) by moving only the in-range part. + let base = [c[0].clamp(0.0, 1.0), c[1].clamp(0.0, 1.0), c[2].clamp(0.0, 1.0)]; + c = [c[0] + r - base[0], c[1] + g - base[1], c[2] + b2 - base[2]]; + } + if ms != 1.0 { + c = c.map(|v| luma + (v - luma) * ms); + } + if dl != 0.0 { + c = c.map(|v| v + dl); + } + } // 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); @@ -349,6 +599,70 @@ fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf { c[k] += 0.5 * (ws[k] * a + wm[k] * m + wh_[k] * z); } } + c + }); + // HSL Secondary: key the graded colour, refine the matte, apply the correction through it. + let mask = sec_active.then(|| { + let key = HslKey::from(ctx); + let mut m = Plane::from_image(&b.img, |px| { + let (c, a) = unpremul(px); + if a <= 0.0 { + return 0.0; + } + let (h, s, l) = rgb_to_hsl(c[0].clamp(0.0, 1.0), c[1].clamp(0.0, 1.0), c[2].clamp(0.0, 1.0)); + key.weight(h, s, l) + }); + let denoise = sec("refine/denoise", 0.0) / 100.0; + if denoise > 0.0 { + // Smooth speckle, then restore the matte's edge contrast. + let s = (denoise * 3.0) as f64 * b.scale; + m = gauss_plane(&m, s, s); + let k = 1.0 + denoise * 2.0; + m.data.iter_mut().for_each(|v| *v = ((*v - 0.5) * k + 0.5).clamp(0.0, 1.0)); + } + let blur = sec("refine/blur", 0.0) / 100.0; + if blur > 0.0 { + let s = (blur * 10.0) as f64 * b.scale; + m = gauss_plane(&m, s, s); + } + if pr.b("hslSecondary/key/invertMask") { + m.data.iter_mut().for_each(|v| *v = 1.0 - *v); + } + m + }); + let style = pr.e("hslSecondary/key/maskStyle"); + map_colour(&mut b, |i, c| { + let mut c = c; + if let Some(m) = &mask { + let k = m.data[i]; + if show_mask { + return match style { + 0 => { + let g = luminance(c[0], c[1], c[2]).clamp(0.0, 1.0) * 0.2 + 0.4; + [lerp(g, c[0], k), lerp(g, c[1], k), lerp(g, c[2], k)] + } + 1 => c.map(|v| v * k), + _ => [k; 3], + }; + } + if k > 0.0 { + let mut d = c; + if s_temp != 0.0 || s_tint != 0.0 { + d = [d[0] * (1.0 + 0.25 * s_temp), d[1] * (1.0 - 0.25 * s_tint), d[2] * (1.0 - 0.25 * s_temp)]; + } + if s_contrast != 0.0 { + d = d.map(|v| 0.5 + (v - 0.5) * (1.0 + s_contrast)); + } + let l = luminance(d[0], d[1], d[2]); + if s_sat != 1.0 { + d = d.map(|v| l + (v - l) * s_sat); + } + for j in 0..3 { + d[j] += 0.5 * s_wheel[j]; + } + c = [lerp(c[0], d[0], k), lerp(c[1], d[1], k), lerp(c[2], d[2], k)]; + } + } // Vignette. if vig != 0.0 { let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5); @@ -366,7 +680,8 @@ fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf { } c.map(|v| v.max(0.0)) }); - if sharpen != 0.0 { + let sec_sharpen = if show_mask { 0.0 } else { s_sharpen }; + if sharpen != 0.0 || (sec_sharpen != 0.0 && mask.is_some()) { 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)| { @@ -374,7 +689,8 @@ fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf { if a <= 0.0 { return; } - let d = (luma.data[i] - blur.data[i]) * sharpen * 2.0; + let amount = sharpen + sec_sharpen * mask.as_ref().map(|m| m.data[i]).unwrap_or(0.0); + let d = (luma.data[i] - blur.data[i]) * amount * 2.0; *px = premul(c.map(|v| (v + d).max(0.0)), a); }); } @@ -384,7 +700,10 @@ fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf { pub fn specs() -> Vec { let grey = || col(0.5, 0.5, 0.5); let mut lum = vec![ - p("basicCorrection/inputLut", "Input LUT", Value::Enum(0), popup(&["None"])), + p("highDynamicRange", "High Dynamic Range", Value::Bool(false), ParamUi::Checkbox), + p("basicCorrection/inputLut", "Input LUT", Value::Enum(0), popup(&["None", "Custom"])), + // A .cube file path (or the file's text) for Input LUT = Custom. + p("basicCorrection/inputLutFile", "Input LUT File", Value::Str(String::new()), ParamUi::Text), p("basicCorrection/whiteBalance/temperature", "Temperature", num(0.0), bipolar()), p("basicCorrection/whiteBalance/tint", "Tint", num(0.0), bipolar()), p("basicCorrection/tone/exposure", "Exposure", num(0.0), slider(-5.0, 5.0, -5.0, 5.0, 2)), @@ -393,8 +712,11 @@ pub fn specs() -> Vec { p("basicCorrection/tone/shadows", "Shadows", num(0.0), bipolar()), p("basicCorrection/tone/whites", "Whites", num(0.0), bipolar()), p("basicCorrection/tone/blacks", "Blacks", num(0.0), bipolar()), + p("basicCorrection/tone/hdrWhite", "HDR White", num(100.0), slider(100.0, 10000.0, 100.0, 4000.0, 0)), + p("basicCorrection/tone/hdrSpecular", "HDR Specular", num(0.0), bipolar()), p("basicCorrection/saturation", "Saturation", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)), - p("creative/look", "Look", Value::Enum(0), popup(&["None"])), + p("creative/look", "Look", Value::Enum(0), popup(&LUMETRI_LOOKS)), + p("creative/lookFile", "Look File", Value::Str(String::new()), ParamUi::Text), p("creative/lookIntensity", "Intensity", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)), p("creative/adjustments/fadedFilm", "Faded Film", num(0.0), pct()), p("creative/adjustments/sharpen", "Sharpen", num(0.0), bipolar()), @@ -421,10 +743,50 @@ pub fn specs() -> Vec { for (id, name) in CURVES { lum.push(p(id, name, num(0.0), bipolar())); } + // Curve graphs: control points "x,y x,y …" in 0..1 (like Curves). RGB curves default to + // the identity diagonal; hue/saturation curves are flat at y = 0.5 (no change) and empty + // means flat. + let pts = |id: &'static str, name: &'static str, d: &str| p(id, name, Value::Str(d.into()), ParamUi::Hidden); + lum.extend([ + pts("curves/rgbCurves/master", "RGB Curve", "0,0 1,1"), + pts("curves/rgbCurves/red", "Red Curve", "0,0 1,1"), + pts("curves/rgbCurves/green", "Green Curve", "0,0 1,1"), + pts("curves/rgbCurves/blue", "Blue Curve", "0,0 1,1"), + pts("curves/hueSaturationCurves/hueVsSat", "Hue vs Sat", ""), + pts("curves/hueSaturationCurves/hueVsHue", "Hue vs Hue", ""), + pts("curves/hueSaturationCurves/hueVsLuma", "Hue vs Luma", ""), + pts("curves/hueSaturationCurves/lumaVsSat", "Luma vs Sat", ""), + pts("curves/hueSaturationCurves/satVsSat", "Sat vs Sat", ""), + ]); lum.extend([ p("colorWheels/shadowsWheel", "Shadows", grey(), ParamUi::Color), p("colorWheels/midtonesWheel", "Midtones", grey(), ParamUi::Color), p("colorWheels/highlightsWheel", "Highlights", grey(), ParamUi::Color), + // HSL Secondary: key (hue centre / range / softness in degrees, saturation and + // lightness bands in %), refine, correction. + p("hslSecondary/key/hueEnabled", "H", Value::Bool(true), ParamUi::Checkbox), + p("hslSecondary/key/hueCenter", "Hue Center", num(0.0), ParamUi::Angle), + p("hslSecondary/key/hueRange", "Hue Range", num(180.0), slider(0.0, 180.0, 0.0, 180.0, 1)), + p("hslSecondary/key/hueSoftness", "Hue Softness", num(0.0), slider(0.0, 180.0, 0.0, 90.0, 1)), + p("hslSecondary/key/saturationEnabled", "S", Value::Bool(true), ParamUi::Checkbox), + p("hslSecondary/key/saturationLow", "Saturation Low", num(0.0), pct()), + p("hslSecondary/key/saturationHigh", "Saturation High", num(100.0), pct()), + p("hslSecondary/key/saturationSoftness", "Saturation Softness", num(0.0), pct()), + p("hslSecondary/key/lightnessEnabled", "L", Value::Bool(true), ParamUi::Checkbox), + p("hslSecondary/key/lightnessLow", "Lightness Low", num(0.0), pct()), + p("hslSecondary/key/lightnessHigh", "Lightness High", num(100.0), pct()), + p("hslSecondary/key/lightnessSoftness", "Lightness Softness", num(0.0), pct()), + p("hslSecondary/key/showMask", "Show Mask", Value::Bool(false), ParamUi::Checkbox), + p("hslSecondary/key/maskStyle", "Mask Style", Value::Enum(0), popup(&HSL_MASK_STYLES)), + p("hslSecondary/key/invertMask", "Invert Mask", Value::Bool(false), ParamUi::Checkbox), + p("hslSecondary/refine/denoise", "Denoise", num(0.0), pct()), + p("hslSecondary/refine/blur", "Blur", num(0.0), pct()), + p("hslSecondary/correction/correctionWheel", "Correction", grey(), ParamUi::Color), + p("hslSecondary/correction/temperature", "Temperature", num(0.0), bipolar()), + p("hslSecondary/correction/tint", "Tint", num(0.0), bipolar()), + p("hslSecondary/correction/contrast", "Contrast", num(0.0), bipolar()), + p("hslSecondary/correction/sharpen", "Sharpen", num(0.0), bipolar()), + p("hslSecondary/correction/saturation", "Saturation", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)), p("vignette/vignetteAmount", "Amount", num(0.0), slider(-5.0, 5.0, -5.0, 5.0, 2)), p("vignette/vignetteMidpoint", "Midpoint", num(50.0), pct()), p("vignette/vignetteRoundness", "Roundness", num(0.0), bipolar()), @@ -580,4 +942,133 @@ mod tests { assert!(c.get(0, 0)[0] > 0.4); assert_eq!(c.data, run("ec.color.lumetri", &[("curves/curveMasterMidtones", num(50.0))], g).data); } + + fn s(v: &str) -> Value { + Value::Str(v.into()) + } + + /// Red, green and grey swatches side by side. + fn swatches() -> Image { + let mut img = Image::new(3, 1); + img.set(0, 0, [0.8, 0.1, 0.1, 1.0]); + img.set(1, 0, [0.1, 0.8, 0.1, 1.0]); + img.set(2, 0, [0.5, 0.5, 0.5, 1.0]); + img + } + + #[test] + fn lumetri_rgb_curves_per_channel() { + let g = Image::filled(2, 2, [0.5, 0.5, 0.5, 1.0]); + let m = run("ec.color.lumetri", &[("curves/rgbCurves/master", s("0,0 0.5,0.75 1,1"))], g.clone()).get(0, 0); + assert!((m[0] - 0.75).abs() < 1e-3 && (m[2] - 0.75).abs() < 1e-3); + let r = run("ec.color.lumetri", &[("curves/rgbCurves/red", s("0,0 0.5,0.25 1,1"))], g).get(0, 0); + assert!((r[0] - 0.25).abs() < 1e-3 && (r[1] - 0.5).abs() < 1e-4); + } + + #[test] + fn lumetri_hue_saturation_curves_target_hues() { + let img = swatches(); + // Hue vs Sat: desaturate reds only (a dip to 0 at hue 0, neutral from 0.2 to 0.8). + let out = run("ec.color.lumetri", &[("curves/hueSaturationCurves/hueVsSat", s("0,0 0.2,0.5 0.8,0.5"))], img.clone()); + let red = out.get(0, 0); + assert!((red[0] - red[1]).abs() < 0.02, "red desaturated: {red:?}"); + assert!(close_px(out.get(1, 0), img.get(1, 0), 1e-3), "green untouched"); + assert!(close_px(out.get(2, 0), img.get(2, 0), 1e-4), "grey untouched"); + // Hue vs Hue: shift reds by +1/6 turn (y = 0.5 + 60/360) towards yellow. + let out = run("ec.color.lumetri", &[("curves/hueSaturationCurves/hueVsHue", s("0,0.6667 0.2,0.5 0.8,0.5"))], img.clone()); + let p = out.get(0, 0); + assert!(p[1] > 0.6 && p[0] > 0.6 && p[2] < 0.2, "red became yellow: {p:?}"); + // Hue vs Luma brightens greens only. + let out = run("ec.color.lumetri", &[("curves/hueSaturationCurves/hueVsLuma", s("0.333,0.7 0.1,0.5 0.6,0.5"))], img.clone()); + assert!(out.get(1, 0)[1] > 0.85 && close_px(out.get(0, 0), img.get(0, 0), 1e-3)); + // Luma vs Sat / Sat vs Sat: y = 0 everywhere removes all saturation. + for id in ["lumaVsSat", "satVsSat"] { + let out = run("ec.color.lumetri", &[(&format!("curves/hueSaturationCurves/{id}"), s("0,0 1,0"))], img.clone()); + let p = out.get(0, 0); + assert!((p[0] - p[1]).abs() < 1e-3 && (p[1] - p[2]).abs() < 1e-3, "{id}: {p:?}"); + } + } + + fn close_px(a: [f32; 4], b: [f32; 4], eps: f32) -> bool { + a.iter().zip(b).all(|(x, y)| (x - y).abs() <= eps) + } + + #[test] + fn lumetri_hsl_secondary_keys_and_corrects() { + let img = swatches(); + let key_red = [("hslSecondary/key/hueCenter", num(0.0)), ("hslSecondary/key/hueRange", num(20.0)), ("hslSecondary/key/hueSoftness", num(10.0))]; + let mut vals: Vec<(&str, Value)> = key_red.to_vec(); + vals.push(("hslSecondary/correction/saturation", num(0.0))); + let out = run("ec.color.lumetri", &vals, img.clone()); + let r = out.get(0, 0); + assert!((r[0] - r[1]).abs() < 1e-3, "red desaturated through the key: {r:?}"); + assert!(close_px(out.get(1, 0), img.get(1, 0), 1e-5) && close_px(out.get(2, 0), img.get(2, 0), 1e-5)); + // Show Mask (White/Black) shows the key; Invert Mask flips it. + let mut vals: Vec<(&str, Value)> = key_red.to_vec(); + vals.extend([("hslSecondary/key/showMask", Value::Bool(true)), ("hslSecondary/key/maskStyle", Value::Enum(2))]); + let m = run("ec.color.lumetri", &vals, img.clone()); + assert_eq!([m.get(0, 0)[0], m.get(1, 0)[0], m.get(2, 0)[0]], [1.0, 0.0, 0.0]); + vals.push(("hslSecondary/key/invertMask", Value::Bool(true))); + let m = run("ec.color.lumetri", &vals, img.clone()); + assert_eq!([m.get(0, 0)[0], m.get(1, 0)[0], m.get(2, 0)[0]], [0.0, 1.0, 1.0]); + // Lightness band keys only the bright grey; Blur softens the matte across pixels. + let vals = [ + ("hslSecondary/key/lightnessLow", num(40.0)), + ("hslSecondary/key/lightnessHigh", num(60.0)), + ("hslSecondary/key/showMask", Value::Bool(true)), + ("hslSecondary/key/maskStyle", Value::Enum(2)), + ]; + let m = run("ec.color.lumetri", &vals, img.clone()); + assert_eq!(m.get(2, 0)[0], 1.0); + let mut wide = Image::new(32, 1); + for x in 0..32 { + wide.set(x, 0, if x < 16 { [0.8, 0.1, 0.1, 1.0] } else { [0.1, 0.8, 0.1, 1.0] }); + } + let mut vals: Vec<(&str, Value)> = key_red.to_vec(); + vals.extend([ + ("hslSecondary/key/showMask", Value::Bool(true)), + ("hslSecondary/key/maskStyle", Value::Enum(2)), + ("hslSecondary/refine/blur", num(50.0)), + ]); + let m = run("ec.color.lumetri", &vals, wide); + let v = m.get(16, 0)[0]; + assert!(v > 0.05 && v < 0.95, "blurred matte edge {v}"); + } + + #[test] + fn lumetri_hdr_range_and_looks() { + // HDR White 200 nits: the tone controls see 1.6 as 0.8, so Highlights act on it. + let bright = Image::filled(2, 2, [1.6, 1.6, 1.6, 1.0]); + let sdr = run("ec.color.lumetri", &[("basicCorrection/tone/highlights", num(-100.0))], bright.clone()).get(0, 0)[0]; + let hdr = run( + "ec.color.lumetri", + &[("highDynamicRange", Value::Bool(true)), ("basicCorrection/tone/hdrWhite", num(200.0)), ("basicCorrection/tone/highlights", num(-100.0))], + bright.clone(), + ) + .get(0, 0)[0]; + assert!(hdr < 1.6 - 0.05 && sdr < 1.6, "{sdr} {hdr}"); + let spec = run("ec.color.lumetri", &[("highDynamicRange", Value::Bool(true)), ("basicCorrection/tone/hdrSpecular", num(100.0))], bright).get(0, 0)[0]; + assert!((spec - 2.2).abs() < 1e-4, "{spec}"); + // Built-in looks change the image at Intensity; 0 % leaves it. + let img = ramp(); + let mono = run("ec.color.lumetri", &[("creative/look", Value::Enum(7))], img.clone()); + let p = mono.get(9, 3); + assert!((p[0] - p[1]).abs() < 1e-5 && (p[1] - p[2]).abs() < 1e-5); + let none = run("ec.color.lumetri", &[("creative/look", Value::Enum(7)), ("creative/lookIntensity", num(0.0))], img.clone()); + assert!(close(&none, &img, 1e-5)); + // A Custom look from inline .cube text (swap red and blue). + let mut cube = String::from("LUT_3D_SIZE 2\n"); + for bb in 0..2 { + for g in 0..2 { + for r in 0..2 { + cube += &format!("{bb} {g} {r}\n"); + } + } + } + let g = Image::filled(1, 1, [0.9, 0.2, 0.1, 1.0]); + let o = run("ec.color.lumetri", &[("creative/look", Value::Enum(1)), ("creative/lookFile", Value::Str(cube.clone()))], g.clone()).get(0, 0); + assert!((o[0] - 0.1).abs() < 1e-3 && (o[2] - 0.9).abs() < 1e-3, "{o:?}"); + let o = run("ec.color.lumetri", &[("basicCorrection/inputLut", Value::Enum(1)), ("basicCorrection/inputLutFile", Value::Str(cube))], g).get(0, 0); + assert!((o[0] - 0.1).abs() < 1e-3, "{o:?}"); + } } diff --git a/crates/effects/src/color_fx.rs b/crates/effects/src/color_fx.rs index 71707daa..0a6663a5 100644 --- a/crates/effects/src/color_fx.rs +++ b/crates/effects/src/color_fx.rs @@ -70,6 +70,85 @@ fn brightness_contrast(ctx: &EffectCtx, mut b: Buf) -> Buf { b } +/// Hue/Saturation's colour ranges (Channel Control entries after Master): id prefix, display +/// name and centre hue in degrees. +pub const HUESAT_RANGES: [(&str, &str, f64); 6] = [ + ("reds", "Reds", 0.0), + ("yellows", "Yellows", 60.0), + ("greens", "Greens", 120.0), + ("cyans", "Cyans", 180.0), + ("blues", "Blues", 240.0), + ("magentas", "Magentas", 300.0), +]; + +/// Hue/Saturation's Channel Control popup. +pub const HUESAT_CHANNELS: [&str; 7] = ["Master", "Reds", "Yellows", "Greens", "Cyans", "Blues", "Magentas"]; + +/// One colour range of Hue/Saturation: the hues it covers fully (`start..end`, degrees, may +/// wrap), the fall-off widths either side, and its hue / saturation / lightness adjustments. +#[derive(Clone, Copy, Debug)] +struct HueRange { + start: f32, + end: f32, + fall_start: f32, + fall_end: f32, + hue: f32, + sat: f32, + light: f32, +} + +impl HueRange { + /// Membership of hue `h` (degrees): 1 inside the range, ramping to 0 across the fall-offs. + fn weight(&self, h: f32) -> f32 { + let width = (self.end - self.start).rem_euclid(360.0); + let d = (h - self.start).rem_euclid(360.0); + if d <= width { + return 1.0; + } + let after = d - width; + let before = 360.0 - d; + let wa = if self.fall_end > 0.0 { 1.0 - after / self.fall_end } else { 0.0 }; + let wb = if self.fall_start > 0.0 { 1.0 - before / self.fall_start } else { 0.0 }; + wa.max(wb).clamp(0.0, 1.0) + } +} + +/// The colour ranges with non-zero adjustments. +fn huesat_ranges(ctx: &EffectCtx) -> Vec { + let f = |id: String, d: f64| ctx.params.get(&id).map(Value::as_f64).unwrap_or(d) as f32; + HUESAT_RANGES + .iter() + .map(|(r, _, c)| HueRange { + start: f(format!("{r}RangeStart"), c - 15.0), + end: f(format!("{r}RangeEnd"), c + 15.0), + fall_start: f(format!("{r}StartFalloff"), 30.0).max(0.0), + fall_end: f(format!("{r}EndFalloff"), 30.0).max(0.0), + hue: f(format!("{r}Hue"), 0.0) / 360.0, + sat: f(format!("{r}Saturation"), 0.0) / 100.0, + light: f(format!("{r}Lightness"), 0.0) / 100.0, + }) + .filter(|r| r.hue != 0.0 || r.sat != 0.0 || r.light != 0.0) + .collect() +} + +/// Whether no colour range of Hue/Saturation adjusts anything (the GPU kernel does Master and +/// Colorize only). +pub fn huesat_ranges_identity(ctx: &EffectCtx) -> bool { + huesat_ranges(ctx).is_empty() +} + +fn adjust_sat(s: f32, sat: f32) -> f32 { + if sat >= 0.0 { s + (1.0 - s) * sat * s.min(1.0) } else { s * (1.0 + sat) } +} + +fn adjust_light(l: f32, light: f32) -> f32 { + if light >= 0.0 { l + (1.0 - l) * light } else { l * (1.0 + light) } +} + +/// Hue/Saturation. Master adjusts every pixel; each colour range (Reds … Magentas, chosen in +/// Channel Control) adjusts pixels whose original hue lies in its range, fading across the +/// fall-offs (greys, having no hue, belong to no range). Colorize replaces hue and +/// saturation. fn hue_saturation(ctx: &EffectCtx, mut b: Buf) -> Buf { let hue = ctx.params.f("hue") as f32 / 360.0; let sat = ctx.params.f("saturation") as f32 / 100.0; @@ -78,14 +157,24 @@ fn hue_saturation(ctx: &EffectCtx, mut b: Buf) -> Buf { let ch = ctx.params.f("colorizeHue") as f32 / 360.0; let cs = ctx.params.f("colorizeSaturation") as f32 / 100.0; let cl = ctx.params.f("colorizeLightness") as f32 / 100.0; + let ranges = huesat_ranges(ctx); b.img.map_straight(|c| { let (h, s, l) = rgb_to_hsl(c[0], c[1], c[2]); let (h, s, l) = if colorize { (ch, cs, (l + cl * if cl > 0.0 { 1.0 - l } else { l }).clamp(0.0, 1.0)) } else { - let s2 = if sat >= 0.0 { s + (1.0 - s) * sat * s.min(1.0) } else { s * (1.0 + sat) }; - let l2 = if light >= 0.0 { l + (1.0 - l) * light } else { l * (1.0 + light) }; - ((h + hue).rem_euclid(1.0), s2.clamp(0.0, 1.0), l2) + let mut s2 = adjust_sat(s, sat); + let mut l2 = adjust_light(l, light); + let mut h2 = h + hue; + for r in &ranges { + let w = r.weight(h * 360.0) * (s * 20.0).min(1.0); + if w > 0.0 { + h2 += r.hue * w; + s2 = adjust_sat(s2.clamp(0.0, 1.0), r.sat * w); + l2 = adjust_light(l2, r.light * w); + } + } + (h2.rem_euclid(1.0), s2.clamp(0.0, 1.0), l2) }; let (r, g, bl) = hsl_to_rgb(h, s, l); [r, g, bl] @@ -107,29 +196,87 @@ pub fn levels_clip(ctx: &EffectCtx) -> (bool, bool) { (on("clipToOutputBlack"), on("clipToOutputWhite")) } +/// Levels' Channel popup. +pub const LEVELS_CHANNELS: [&str; 5] = ["RGB", "Red", "Green", "Blue", "Alpha"]; + +/// Spec-id prefixes of Levels' per-channel controls (`redInBlack`, …; the RGB controls have +/// plain ids). +pub const LEVELS_CHANNEL_PREFIX: [&str; 4] = ["red", "green", "blue", "alpha"]; + +/// Levels' five controls (input black, input white, gamma, output black, output white) for +/// Channel popup index `ch` (0 = RGB). +pub fn levels_channel_ids(ch: usize) -> [String; 5] { + if ch == 0 || ch > 4 { + return ["inBlack", "inWhite", "gamma", "outBlack", "outWhite"].map(String::from); + } + let pre = LEVELS_CHANNEL_PREFIX[ch - 1]; + ["InBlack", "InWhite", "Gamma", "OutBlack", "OutWhite"].map(|s| format!("{pre}{s}")) +} + +fn level(v: f32, [ib, iw, g, ob, ow]: [f32; 5], (clip_b, clip_w): (bool, bool)) -> f32 { + let mut t = (v - ib) / (iw - ib).max(1e-6); + if clip_b { + t = t.max(0.0); + } + if clip_w { + t = t.min(1.0); + } + let t = t.max(0.0).powf(1.0 / g); + ob + (ow - ob) * t +} + +const LEVELS_IDENTITY: [f32; 5] = [0.0, 1.0, 1.0, 0.0, 1.0]; + +/// Levels: the RGB controls map every colour channel, then the Red / Green / Blue controls +/// their own channel and the Alpha controls alpha. All five sets are kept; the Channel popup +/// only picks the set Effect Controls shows. fn levels(ctx: &EffectCtx, mut b: Buf) -> Buf { let ib = ctx.params.f("inBlack") as f32; let iw = ctx.params.f("inWhite") as f32; let g = ctx.params.f("gamma").max(0.01) as f32; let ob = ctx.params.f("outBlack") as f32; let ow = ctx.params.f("outWhite") as f32; - let (clip_b, clip_w) = levels_clip(ctx); - b.img.map_straight(|c| { - c.map(|v| { - let mut t = (v - ib) / (iw - ib).max(1e-6); - if clip_b { - t = t.max(0.0); + let clip = levels_clip(ctx); + b.img.map_straight(|c| c.map(|v| level(v, [ib, iw, g, ob, ow], clip))); + let chans = levels_channel_settings(ctx); + let ident = |l: &([f32; 5], (bool, bool))| l.0 == LEVELS_IDENTITY; + if chans.iter().all(ident) { + return b; + } + use rayon::prelude::*; + b.img.data.par_iter_mut().for_each(|px| { + let (c, a) = crate::util::unpremul(*px); + let mut o = c; + for i in 0..3 { + if !ident(&chans[i]) { + o[i] = level(c[i], chans[i].0, chans[i].1); } - if clip_w { - t = t.min(1.0); - } - let t = t.max(0.0).powf(1.0 / g); - ob + (ow - ob) * t - }) + } + let na = if ident(&chans[3]) { a } else { level(a, chans[3].0, chans[3].1).clamp(0.0, 1.0) }; + *px = crate::util::premul(o, na); }); b } +/// Levels' Red, Green, Blue and Alpha settings and clip switches (identity for projects saved +/// before the Channel popup). +pub fn levels_channel_settings(ctx: &EffectCtx) -> [([f32; 5], (bool, bool)); 4] { + std::array::from_fn(|i| { + let ids = levels_channel_ids(i + 1); + let v: [f32; 5] = std::array::from_fn(|k| ctx.params.get(&ids[k]).map(|v| v.as_f64() as f32).unwrap_or(LEVELS_IDENTITY[k])); + let pre = LEVELS_CHANNEL_PREFIX[i]; + let on = |id: String| ctx.params.get(&id).is_none_or(|v| v.as_enum() != 0); + let clip = if ctx.params.b("noClip") { (false, false) } else { (on(format!("{pre}ClipToOutputBlack")), on(format!("{pre}ClipToOutputWhite"))) }; + ([v[0], v[1], v[2].max(0.01), v[3], v[4]], clip) + }) +} + +/// Whether Levels' per-channel controls are all at their defaults (the GPU kernel handles +/// only the RGB controls). +pub fn levels_channels_identity(ctx: &EffectCtx) -> bool { + levels_channel_settings(ctx).iter().all(|l| l.0 == LEVELS_IDENTITY) +} + /// Exposure's per-channel (gain, offset, gamma) for R, G, B: the Master group's values, or the /// Red / Green / Blue groups' with Channels = Individual Channels. pub fn exposure_settings(ctx: &EffectCtx) -> [(f32, f32, f32); 3] { @@ -193,10 +340,66 @@ fn black_white(ctx: &EffectCtx, mut b: Buf) -> Buf { b } +/// Whether Fill uses masks (Fill Mask or All Masks): the GPU kernel fills alpha only. +pub fn fill_uses_masks(ctx: &EffectCtx) -> bool { + !ctx.env.masks.is_empty() && (ctx.params.b("allMasks") || ctx.params.f("fillMask") >= 1.0) +} + +/// Coverage (0..1, 2×2 supersampled) of the closed polygons `polys` (buffer px) on a `w`×`h` +/// grid: the union of the shapes, each inverted when its mask is. +pub(crate) fn polys_coverage(w: usize, h: usize, polys: &[(Vec<[f64; 2]>, bool)]) -> crate::util::Plane { + use rayon::prelude::*; + let mut out = crate::util::Plane::new(w, h); + out.data.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| { + for (x, v) in row.iter_mut().enumerate() { + let mut hit = 0.0; + for (sx, sy) in [(0.25, 0.25), (0.75, 0.25), (0.25, 0.75), (0.75, 0.75)] { + let (px, py) = (x as f64 + sx, y as f64 + sy); + if polys.iter().any(|(p, inv)| crate::util::point_in_poly(p, px, py) != *inv) { + hit += 0.25; + } + } + *v = hit; + } + }); + out +} + +/// Fill: paints the layer's pixels with Color. With Fill Mask (or All Masks) only the pixels +/// inside that mask (or any mask) are painted, with the mask edge feathered horizontally / +/// vertically; Invert paints outside instead. Without a mask, Invert fills the transparent +/// areas. fn fill(ctx: &EffectCtx, mut b: Buf) -> Buf { let c = ctx.params.color("color"); let invert = ctx.params.b("invert"); let opacity = ctx.params.f("opacity") as f32 / 100.0; + if fill_uses_masks(ctx) { + let all = ctx.params.b("allMasks"); + let idx = ctx.params.f("fillMask").round() as usize; + let polys: Vec<(Vec<[f64; 2]>, bool)> = ctx + .env + .masks + .iter() + .enumerate() + .filter(|(i, m)| (all || i + 1 == idx) && m.points.len() >= 3) + .map(|(_, m)| (m.points.iter().map(|q| [q[0] * b.scale + b.offset[0], q[1] * b.scale + b.offset[1]]).collect(), m.inverted)) + .collect(); + let (w, h) = (b.img.width as usize, b.img.height as usize); + let mut cov = polys_coverage(w, h, &polys); + let (fh, fv) = (ctx.params.f("horizontalFeather").max(0.0) * b.scale, ctx.params.f("verticalFeather").max(0.0) * b.scale); + if fh > 0.0 || fv > 0.0 { + cov = crate::util::gauss_plane(&cov, fh * 0.5, fv * 0.5); + } + use rayon::prelude::*; + b.img.data.par_iter_mut().zip(cov.data.par_iter()).for_each(|(px, &m)| { + let m = if invert { 1.0 - m } else { m } * opacity; + let a = px[3]; + for i in 0..3 { + px[i] += (c[i] * a - px[i]) * m; + } + }); + return b; + } b.img.data.iter_mut().for_each(|px| { let a = if invert { 1.0 - px[3] } else { px[3] }; let f = [c[0] * a, c[1] * a, c[2] * a]; @@ -431,32 +634,226 @@ fn colorama_phase(c: [f32; 3], a: f32, mode: u32) -> f32 { v.clamp(0.0, 1.0) } -/// Colorama with the built-in hue-cycle output palette: the chosen input attribute, shifted by -/// Phase Shift and repeated Cycle Repetitions times, picks a colour on the cycle (Interpolate -/// Palette off posterizes to the palette's six colours). +/// Colorama's Output Cycle presets (our own palettes; Custom uses the Palette parameter). +pub const COLORAMA_PRESETS: [&str; 9] = ["Hue Cycle", "Ramp Grayscale", "Fire", "Ice", "Ramp Red", "Ramp Green", "Ramp Blue", "Pastel Cycle", "Custom"]; +/// Colorama's Modify options: which channels of the pixel take the palette colour. +const COLORAMA_MODIFY: [&str; 12] = + ["All", "Red", "Green", "Blue", "Hue", "Lightness", "Saturation", "Value", "Red & Green", "Green & Blue", "Blue & Red", "None"]; + +/// An output cycle: colour stops around the wheel (position 0..1, straight RGBA), cyclic. +#[derive(Clone, Debug, PartialEq)] +pub struct ColoramaPalette { + pub stops: Vec<(f32, [f32; 4])>, +} + +impl ColoramaPalette { + /// Built-in palette `i` of [`COLORAMA_PRESETS`]. + pub fn preset(i: u32) -> ColoramaPalette { + let s = |v: &[(f32, [f32; 3])]| ColoramaPalette { stops: v.iter().map(|(p, c)| (*p, [c[0], c[1], c[2], 1.0])).collect() }; + match i { + 1 => s(&[(0.0, [0.0; 3]), (0.999, [1.0; 3])]), + 2 => s(&[(0.0, [0.0, 0.0, 0.0]), (0.3, [0.7, 0.05, 0.0]), (0.6, [1.0, 0.55, 0.0]), (0.85, [1.0, 0.95, 0.4]), (0.999, [1.0, 1.0, 1.0])]), + 3 => s(&[(0.0, [0.0, 0.0, 0.1]), (0.4, [0.1, 0.3, 0.8]), (0.75, [0.5, 0.85, 1.0]), (0.999, [1.0, 1.0, 1.0])]), + 4 => s(&[(0.0, [0.0; 3]), (0.999, [1.0, 0.0, 0.0])]), + 5 => s(&[(0.0, [0.0; 3]), (0.999, [0.0, 1.0, 0.0])]), + 6 => s(&[(0.0, [0.0; 3]), (0.999, [0.0, 0.0, 1.0])]), + 7 => s(&[(0.0, [1.0, 0.7, 0.7]), (1.0 / 3.0, [0.7, 1.0, 0.7]), (2.0 / 3.0, [0.7, 0.7, 1.0])]), + _ => s(&[ + (0.0, [1.0, 0.0, 0.0]), + (1.0 / 6.0, [1.0, 1.0, 0.0]), + (2.0 / 6.0, [0.0, 1.0, 0.0]), + (3.0 / 6.0, [0.0, 1.0, 1.0]), + (4.0 / 6.0, [0.0, 0.0, 1.0]), + (5.0 / 6.0, [1.0, 0.0, 1.0]), + ]), + } + } + + /// Parse "pos:r,g,b[,a] pos:r,g,b …" (positions 0..1, channels 0..1). `None` without stops. + pub fn parse(s: &str) -> Option { + let mut stops: Vec<(f32, [f32; 4])> = s + .split_whitespace() + .filter_map(|t| { + let (pos, c) = t.split_once(':')?; + let v: Vec = c.split(',').filter_map(|x| x.trim().parse().ok()).collect(); + (v.len() >= 3).then(|| (pos.trim().parse::().ok().map(|p| p.rem_euclid(1.0)), [v[0], v[1], v[2], v.get(3).copied().unwrap_or(1.0)])) + }) + .filter_map(|(p, c)| p.map(|p| (p, c))) + .collect(); + stops.sort_by(|a, b| a.0.total_cmp(&b.0)); + (!stops.is_empty()).then_some(ColoramaPalette { stops }) + } + + /// The colour at phase `ph` (0..1). Interpolate Palette blends between neighbouring stops + /// (around the wheel); off, each phase takes the stop at or before it. + pub fn eval(&self, ph: f32, interpolate: bool) -> [f32; 4] { + let n = self.stops.len(); + let ph = ph.rem_euclid(1.0); + let k = self.stops.iter().rposition(|s| s.0 <= ph).unwrap_or(n - 1); + let (p0, c0) = self.stops[k]; + if !interpolate || n == 1 { + return c0; + } + let (p1, c1) = self.stops[(k + 1) % n]; + let span = (p1 - p0).rem_euclid(1.0).max(1e-6); + let t = ((ph - p0).rem_euclid(1.0) / span).clamp(0.0, 1.0); + std::array::from_fn(|i| c0[i] + (c1[i] - c0[i]) * t) + } +} + +/// How well colour `c` matches `key` (1 = matches) for Pixel Selection's Matching Mode +/// (1 RGB, 2 Hue, 3 Chroma), with Matching Tolerance / Softness (0..1). +fn colorama_match(c: [f32; 3], key: [f32; 3], mode: u32, tol: f32, soft: f32) -> f32 { + let d = match mode { + 2 => { + 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(); + dh.min(1.0 - dh) * 2.0 + (1.0 - s1.min(1.0)) * 0.5 + } + 3 => { + // Chroma: the colour without its brightness. + let n = |c: [f32; 3]| { + let s = c[0] + c[1] + c[2]; + if s > 1e-5 { [c[0] / s, c[1] / s, c[2] / s] } else { [1.0 / 3.0; 3] } + }; + let (a, b) = (n(c), n(key)); + ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt() * 1.5 + } + _ => ((c[0] - key[0]).powi(2) + (c[1] - key[1]).powi(2) + (c[2] - key[2]).powi(2)).sqrt() / 3f32.sqrt(), + }; + if d <= tol { + 1.0 + } else if soft > 0.0 { + (1.0 - (d - tol) / soft).clamp(0.0, 1.0) + } else { + 0.0 + } +} + +/// Colorama: a phase taken from the pixel (Get Phase From, optionally combined with an Add +/// Phase layer), shifted and repeated around the Output Cycle, picks a palette colour, which +/// replaces the channels Modify names. Pixel Selection limits it to colours near Matching +/// Color; Masking to where a mask layer allows; Blend With Original mixes the result back. fn colorama(ctx: &EffectCtx, mut b: Buf) -> Buf { - let shift = ctx.params.f("inputPhase/phaseShift") as f32 / 360.0; - let mode = ctx.params.e("inputPhase/mode"); - let cycles = ctx.params.f("outputCycle/cycles").max(0.0) as f32; - let smooth = ctx.params.get("outputCycle/interpolatePalette").is_none_or(|v| v.as_bool()); - let blend = 1.0 - ctx.params.f("blend") as f32 / 100.0; - b.img.data.iter_mut().for_each(|px| { + let pr = ctx.params; + let shift = pr.f("inputPhase/phaseShift") as f32 / 360.0; + let mode = pr.e("inputPhase/mode"); + let cycles = pr.f("outputCycle/cycles").max(0.0) as f32; + let smooth = pr.get("outputCycle/interpolatePalette").is_none_or(|v| v.as_bool()); + let preset = pr.e("outputCycle/usePresetPalette"); + let palette = if preset as usize == COLORAMA_PRESETS.len() - 1 { ColoramaPalette::parse(pr.s("outputCycle/palette")) } else { None } + .unwrap_or_else(|| ColoramaPalette::preset(preset)); + let blend = 1.0 - pr.f("blend") as f32 / 100.0; + // Add Phase. + let add = ctx.layer_param("inputPhase/addPhase", true).map(|o| crate::util::fit_layer(ctx, &b, &o, true)); + let add_from = pr.e("inputPhase/addPhaseFrom"); + let add_mode = pr.e("inputPhase/addMode"); + // Modify. + let modify = pr.e("modify/modify"); + let modify_alpha = pr.b("modify/modifyAlpha"); + let change_empty = pr.b("modify/changeEmptyPixels"); + // Pixel Selection. + let match_mode = pr.e("pixelSelection/matchingMode"); + let key = pr.color("pixelSelection/matchingColor"); + let tol = (pr.f("pixelSelection/matchingTolerance") / 100.0) as f32; + let soft = (pr.f("pixelSelection/matchingSoftness") / 100.0) as f32; + // Masking. + let mask_mode = pr.e("masking/maskingMode"); + let mask = (mask_mode != 0).then(|| ctx.layer_param("masking/maskLayer", true).map(|o| crate::util::fit_layer(ctx, &b, &o, true))).flatten(); + let composite = pr.get("masking/compositeOverLayer").is_none_or(|v| v.as_bool()); + use rayon::prelude::*; + b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { let a = px[3]; - if a <= 0.0 { + if a <= 0.0 && !change_empty { return; } - let c = [px[0] / a, px[1] / a, px[2] / a]; - let mut ph = (colorama_phase(c, a, mode) * cycles + shift).rem_euclid(1.0); - if !smooth { - ph = (ph * 6.0).floor() / 6.0; + let c = if a > 0.0 { [px[0] / a, px[1] / a, px[2] / a] } else { [0.0; 3] }; + let mut p = colorama_phase(c, a, mode); + if let Some(img) = &add { + let (ac, aa) = crate::util::unpremul(img.data[i]); + let q = colorama_phase(ac, aa, add_from); + p = match add_mode { + 1 => (p + q).min(1.0), + 2 => (p + q) * 0.5, + 3 => p + q - p * q, + _ => (p + q).rem_euclid(1.0), + }; } - let (r, g, bl) = effectcraft_color::hsv_to_rgb(ph, 1.0, 1.0); - let o = mix(c, [r, g, bl], blend); - *px = [o[0] * a, o[1] * a, o[2] * a, a]; + let ph = (p * cycles + shift).rem_euclid(1.0); + let pc = palette.eval(ph, smooth); + let (h, s, l) = rgb_to_hsl(c[0], c[1], c[2]); + let (ph_, ps, pl) = rgb_to_hsl(pc[0], pc[1], pc[2]); + let hl = |h: f32, s: f32, l: f32| { + let (r, g, bb) = hsl_to_rgb(h, s, l); + [r, g, bb] + }; + let new: [f32; 3] = match modify { + 1 => [pc[0], c[1], c[2]], + 2 => [c[0], pc[1], c[2]], + 3 => [c[0], c[1], pc[2]], + 4 => hl(ph_, s, l), + 5 => hl(h, s, pl), + 6 => hl(h, ps, l), + 7 => { + // Value: scale the colour to the palette colour's maximum. + let (m, pm) = (c[0].max(c[1]).max(c[2]), pc[0].max(pc[1]).max(pc[2])); + if m > 1e-6 { c.map(|v| v * pm / m) } else { [pm; 3] } + } + 8 => [pc[0], pc[1], c[2]], + 9 => [c[0], pc[1], pc[2]], + 10 => [pc[0], c[1], pc[2]], + 11 => c, + _ => [pc[0], pc[1], pc[2]], + }; + let new_a = if modify_alpha { + pc[3] + } else if a > 0.0 { + a + } else { + 1.0 + }; + // Where the effect applies: Pixel Selection × Masking. + let mut w = if match_mode == 0 { 1.0 } else { colorama_match(c, [key[0], key[1], key[2]], match_mode, tol, soft) }; + if let Some(m) = &mask { + let (mc, ma) = crate::util::unpremul(m.data[i]); + let lum = luminance(mc[0], mc[1], mc[2]) * ma; + w *= match mask_mode { + 1 => lum, + 2 => 1.0 - lum, + 3 => ma, + _ => 1.0 - ma, + } + .clamp(0.0, 1.0); + } + let k = w * blend; + let o = mix(c, new, k); + let oa = a + (new_a - a) * k; + // Composite Over Layer off: only the affected pixels remain. + let oa = if composite { oa } else { oa * w }; + *px = [o[0] * oa, o[1] * oa, o[2] * oa, oa.clamp(0.0, 1.0)]; }); b } +/// Levels' Red / Green / Blue / Alpha controls ("Red Input Black", …), in that order. +fn levels_channel_params() -> Vec { + let leak = |s: String| -> &'static str { Box::leak(s.into_boxed_str()) }; + let names = ["Input Black", "Input White", "Gamma", "Output Black", "Output White"]; + let mut v = vec![]; + for (i, pre) in LEVELS_CHANNEL_PREFIX.iter().enumerate() { + let label = LEVELS_CHANNELS[i + 1]; + let ids = levels_channel_ids(i + 1); + for k in 0..5 { + let ui = if k == 2 { slider(0.1, 10.0, 0.1, 3.0, 2) } else { slider(-1.0, 2.0, 0.0, 1.0, 3) }; + v.push(p(leak(ids[k].clone()), leak(format!("{label} {}", names[k])), num(LEVELS_IDENTITY[k] as f64), ui)); + } + v.push(p(leak(format!("{pre}ClipToOutputBlack")), leak(format!("{label} Clip To Output Black")), Value::Enum(2), popup(&LEVELS_CLIP))); + v.push(p(leak(format!("{pre}ClipToOutputWhite")), leak(format!("{label} Clip To Output White")), Value::Enum(2), popup(&LEVELS_CLIP))); + } + v +} + pub fn specs() -> Vec { let pct = || slider(-100.0, 100.0, -100.0, 100.0, 1); let mut gpg = vec![p("blackStretch", "Black Stretch", num(0.0), slider(0.0, 4.0, 0.0, 4.0, 2))]; @@ -501,21 +898,39 @@ pub fn specs() -> Vec { spec( "ec.color.huesaturation", "Hue/Saturation", - vec![ - p("hue", "Master Hue", num(0.0), ParamUi::Angle), - p("saturation", "Master Saturation", num(0.0), pct()), - p("lightness", "Master Lightness", num(0.0), pct()), - p("colorize", "Colorize", Value::Bool(false), ParamUi::Checkbox), - p("colorizeHue", "Colorize Hue", num(0.0), ParamUi::Angle), - p("colorizeSaturation", "Colorize Saturation", num(25.0), slider(0.0, 100.0, 0.0, 100.0, 0)), - p("colorizeLightness", "Colorize Lightness", num(0.0), pct()), - ], + { + let mut v = vec![ + p("channelControl", "Channel Control", Value::Enum(0), popup(&HUESAT_CHANNELS)), + p("hue", "Master Hue", num(0.0), ParamUi::Angle), + p("saturation", "Master Saturation", num(0.0), pct()), + p("lightness", "Master Lightness", num(0.0), pct()), + ]; + let leak = |s: String| -> &'static str { Box::leak(s.into_boxed_str()) }; + for (r, name, c) in HUESAT_RANGES { + let single = name.trim_end_matches('s'); + v.push(p(leak(format!("{r}Hue")), leak(format!("{single} Hue")), num(0.0), ParamUi::Angle)); + v.push(p(leak(format!("{r}Saturation")), leak(format!("{single} Saturation")), num(0.0), pct())); + v.push(p(leak(format!("{r}Lightness")), leak(format!("{single} Lightness")), num(0.0), pct())); + v.push(p(leak(format!("{r}RangeStart")), leak(format!("{single} Range Start")), num(c - 15.0), slider(-360.0, 720.0, -180.0, 360.0, 0))); + v.push(p(leak(format!("{r}RangeEnd")), leak(format!("{single} Range End")), num(c + 15.0), slider(-360.0, 720.0, -180.0, 360.0, 0))); + v.push(p(leak(format!("{r}StartFalloff")), leak(format!("{single} Start Falloff")), num(30.0), slider(0.0, 180.0, 0.0, 90.0, 0))); + v.push(p(leak(format!("{r}EndFalloff")), leak(format!("{single} End Falloff")), num(30.0), slider(0.0, 180.0, 0.0, 90.0, 0))); + } + v.extend([ + p("colorize", "Colorize", Value::Bool(false), ParamUi::Checkbox), + p("colorizeHue", "Colorize Hue", num(0.0), ParamUi::Angle), + p("colorizeSaturation", "Colorize Saturation", num(25.0), slider(0.0, 100.0, 0.0, 100.0, 0)), + p("colorizeLightness", "Colorize Lightness", num(0.0), pct()), + ]); + v + }, hue_saturation, ), spec( "ec.color.levels", "Levels", vec![ + p("channel", "Channel", Value::Enum(0), popup(&LEVELS_CHANNELS)), p("inBlack", "Input Black", num(0.0), slider(-1.0, 2.0, 0.0, 1.0, 3)), p("inWhite", "Input White", num(1.0), slider(-1.0, 2.0, 0.0, 1.0, 3)), p("gamma", "Gamma", num(1.0), slider(0.1, 10.0, 0.1, 3.0, 2)), @@ -525,7 +940,10 @@ pub fn specs() -> Vec { p("clipToOutputWhite", "Clip To Output White", Value::Enum(2), popup(&LEVELS_CLIP)), // Projects saved before the two popups: "Don't Clip". p("noClip", "Don't Clip", Value::Bool(false), ParamUi::Hidden), - ], + ] + .into_iter() + .chain(levels_channel_params()) + .collect(), levels, ), spec( @@ -569,8 +987,12 @@ pub fn specs() -> Vec { name: "Fill", category: "Generate", params: vec![ + p("fillMask", "Fill Mask", num(0.0), ParamUi::Mask), + p("allMasks", "All Masks", Value::Bool(false), ParamUi::Checkbox), p("color", "Color", col(1.0, 0.0, 0.0), ParamUi::Color), p("invert", "Invert", Value::Bool(false), ParamUi::Checkbox), + p("horizontalFeather", "Horizontal Feather", num(0.0), slider(0.0, 5000.0, 0.0, 100.0, 1)), + p("verticalFeather", "Vertical Feather", num(0.0), slider(0.0, 5000.0, 0.0, 100.0, 1)), p("opacity", "Opacity", num(100.0), slider(0.0, 100.0, 0.0, 100.0, 1)), ], render: fill, @@ -673,9 +1095,30 @@ pub fn specs() -> Vec { "Colorama", vec![ p("inputPhase/mode", "Get Phase From", Value::Enum(0), popup(&COLORAMA_PHASE)), + p("inputPhase/addPhase", "Add Phase", Value::Layer(None), ParamUi::Layer), + p("inputPhase/addPhaseFrom", "Add Phase From", Value::Enum(0), popup(&COLORAMA_PHASE)), + p("inputPhase/addMode", "Add Mode", Value::Enum(0), popup(&["Wrap", "Clamp", "Average", "Screen"])), p("inputPhase/phaseShift", "Phase Shift", num(0.0), ParamUi::Angle), + p("outputCycle/usePresetPalette", "Use Preset Palette", Value::Enum(0), popup(&COLORAMA_PRESETS)), + // Use Preset Palette = Custom: "position:r,g,b[,a] …" around the wheel. + p( + "outputCycle/palette", + "Output Cycle", + Value::Str("0:1,0,0 0.1667:1,1,0 0.3333:0,1,0 0.5:0,1,1 0.6667:0,0,1 0.8333:1,0,1".into()), + ParamUi::Text, + ), p("outputCycle/cycles", "Cycle Repetitions", num(1.0), slider(0.0, 100.0, 0.0, 10.0, 1)), p("outputCycle/interpolatePalette", "Interpolate Palette", Value::Bool(true), ParamUi::Checkbox), + p("modify/modify", "Modify", Value::Enum(0), popup(&COLORAMA_MODIFY)), + p("modify/modifyAlpha", "Modify Alpha", Value::Bool(false), ParamUi::Checkbox), + p("modify/changeEmptyPixels", "Change Empty Pixels", Value::Bool(false), ParamUi::Checkbox), + p("pixelSelection/matchingColor", "Matching Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("pixelSelection/matchingTolerance", "Matching Tolerance", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("pixelSelection/matchingSoftness", "Matching Softness", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("pixelSelection/matchingMode", "Matching Mode", Value::Enum(0), popup(&["Off", "RGB", "Hue", "Chroma"])), + p("masking/maskLayer", "Mask Layer", Value::Layer(None), ParamUi::Layer), + p("masking/maskingMode", "Masking Mode", Value::Enum(0), popup(&["Off", "Luminance", "Inverted Luminance", "Alpha", "Inverted Alpha"])), + p("masking/compositeOverLayer", "Composite Over Layer", Value::Bool(true), ParamUi::Checkbox), p("blend", "Blend With Original", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)), ], colorama, @@ -837,4 +1280,119 @@ mod tests { assert!(px("ec.color.gammapedestalgain", &[("blackStretch", num(4.0))], dark)[0] > 0.15); assert!(close(px("ec.color.gammapedestalgain", &[], dark), dark, 1e-6)); } + + #[test] + fn levels_channel_controls_act_on_their_channel() { + let g = [0.5, 0.5, 0.5, 1.0]; + assert!(close(px("ec.color.levels", &[("channel", Value::Enum(1))], g), g, 1e-6), "the popup alone changes nothing"); + let r = px("ec.color.levels", &[("redOutWhite", num(0.5))], g); + assert!(close(r, [0.25, 0.5, 0.5, 1.0], 1e-5), "{r:?}"); + let b = px("ec.color.levels", &[("blueGamma", num(2.0)), ("inWhite", num(0.5))], [0.25, 0.25, 0.25, 1.0]); + assert!(close(b, [0.5, 0.5, 0.5f32.sqrt(), 1.0], 1e-5), "RGB first, then blue: {b:?}"); + let a = px("ec.color.levels", &[("alphaOutWhite", num(0.5))], [0.5, 0.5, 0.5, 1.0]); + assert!(close(a, [0.25, 0.25, 0.25, 0.5], 1e-5), "{a:?}"); + // Per-channel clipping follows its own popups. + let hot = [0.9, 0.9, 0.9, 1.0]; + let c = px("ec.color.levels", &[("greenInWhite", num(0.5))], hot)[1]; + let nc = px("ec.color.levels", &[("greenInWhite", num(0.5)), ("greenClipToOutputWhite", Value::Enum(0))], hot)[1]; + assert!((c - 1.0).abs() < 1e-6 && (nc - 1.8).abs() < 1e-5, "{c} {nc}"); + } + + #[test] + fn hue_saturation_ranges_adjust_their_hues_only() { + let red = [0.8, 0.1, 0.1, 1.0]; + let blue = [0.1, 0.1, 0.8, 1.0]; + let grey = [0.5, 0.5, 0.5, 1.0]; + let v = [("redsSaturation", num(-100.0))]; + let r = px("ec.color.huesaturation", &v, red); + assert!((r[0] - r[1]).abs() < 1e-4, "reds desaturated: {r:?}"); + assert!(close(px("ec.color.huesaturation", &v, blue), blue, 1e-5)); + assert!(close(px("ec.color.huesaturation", &v, grey), grey, 1e-5)); + // Blues hue +120°: blue becomes red. + let b = px("ec.color.huesaturation", &[("bluesHue", num(120.0))], blue); + assert!(b[0] > 0.7 && b[2] < 0.2, "{b:?}"); + // Fall-off: a hue 15° past the range end (Reds: −15..15, fall-off 30) gets half. + let orange_ish = { + let (r, g, b) = hsl_to_rgb(30.0 / 360.0, 0.8, 0.5); + [r, g, b, 1.0] + }; + let o = px("ec.color.huesaturation", &[("redsLightness", num(100.0))], orange_ish); + let (_, _, l) = rgb_to_hsl(o[0], o[1], o[2]); + assert!((l - 0.75).abs() < 0.01, "half weight: {l}"); + // Moving the range changes which hues it takes. + let o2 = px("ec.color.huesaturation", &[("redsLightness", num(100.0)), ("redsRangeEnd", num(40.0))], orange_ish); + assert!(rgb_to_hsl(o2[0], o2[1], o2[2]).2 > 0.99); + let params = crate::Params { values: [("redsHue".to_string(), num(10.0))].into_iter().collect() }; + let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [1.0, 1.0], seed: 0, adjustment: false, env: Default::default() }; + assert!(!huesat_ranges_identity(&ctx)); + } + + #[test] + fn fill_mask_all_masks_feather_and_invert() { + let sq = |x0: f64, x1: f64| crate::MaskShape { + name: String::new(), + points: vec![[x0, 0.0], [x1, 0.0], [x1, 10.0], [x0, 10.0]], + closed: true, + inverted: false, + }; + let masks = [sq(0.0, 5.0), sq(15.0, 20.0)]; + let env = EffectEnv { masks: &masks, ..Default::default() }; + let grey = Image::filled(20, 10, [0.5, 0.5, 0.5, 1.0]); + let run = |vals: &[(&str, Value)]| run_fx("ec.generate.fill", vals, grey.clone(), 0.0, env).img; + let one = run(&[("fillMask", num(1.0))]); + assert_eq!(one.get(2, 5), [1.0, 0.0, 0.0, 1.0]); + assert_eq!(one.get(10, 5), [0.5, 0.5, 0.5, 1.0]); + assert_eq!(one.get(17, 5), [0.5, 0.5, 0.5, 1.0]); + let all = run(&[("allMasks", Value::Bool(true))]); + assert_eq!(all.get(17, 5), [1.0, 0.0, 0.0, 1.0]); + let inv = run(&[("fillMask", num(1.0)), ("invert", Value::Bool(true))]); + assert_eq!(inv.get(2, 5), [0.5, 0.5, 0.5, 1.0]); + assert_eq!(inv.get(10, 5), [1.0, 0.0, 0.0, 1.0]); + let soft = run(&[("fillMask", num(1.0)), ("horizontalFeather", num(6.0))]); + let v = soft.get(6, 5)[1]; + assert!(v > 0.05 && v < 0.5, "feathered edge {v}"); + // Without masks, Fill fills the layer as before. + let plain = run_fx("ec.generate.fill", &[("fillMask", num(1.0))], grey.clone(), 0.0, EffectEnv::default()).img; + assert_eq!(plain.get(10, 5), [1.0, 0.0, 0.0, 1.0]); + } + + #[test] + fn colorama_palettes_modify_selection_masking_and_add_phase() { + let grey = [0.5, 0.5, 0.5, 1.0]; + let red = [1.0, 0.0, 0.0, 1.0]; + // Ramp Grayscale: intensity 0.5 maps to mid grey. + let g = px("ec.color.colorama", &[("outputCycle/usePresetPalette", Value::Enum(1))], grey); + assert!((g[0] - 0.5).abs() < 0.01 && (g[0] - g[2]).abs() < 1e-5, "{g:?}"); + // Custom palette: two stops, black at 0 and blue at 0.5. + let custom = [("outputCycle/usePresetPalette", Value::Enum(8)), ("outputCycle/palette", Value::Str("0:0,0,0 0.5:0,0,1".into()))]; + let c = px("ec.color.colorama", &custom, grey); + assert!(c[2] > 0.99 && c[0] < 0.01, "{c:?}"); + // Modify Lightness keeps the hue of the pixel (red stays red). + let l = px("ec.color.colorama", &[("modify/modify", Value::Enum(5)), ("outputCycle/usePresetPalette", Value::Enum(1))], red); + assert!(l[0] > l[1] + 0.2 && l[1] < 0.01 + l[2] + 1e-4, "{l:?}"); + // Pixel Selection: only colours near Matching Color change. + let sel = [ + ("pixelSelection/matchingMode", Value::Enum(1)), + ("pixelSelection/matchingColor", col(1.0, 0.0, 0.0)), + ("pixelSelection/matchingTolerance", num(10.0)), + ]; + assert!(close(px("ec.color.colorama", &sel, grey), grey, 1e-6)); + assert!(!close(px("ec.color.colorama", &sel, red), red, 1e-3)); + // Composite Over Layer off: unselected pixels vanish. + let mut off = sel.to_vec(); + off.push(("masking/compositeOverLayer", Value::Bool(false))); + assert_eq!(px("ec.color.colorama", &off, grey)[3], 0.0); + // Modify Alpha takes the palette alpha. + let alpha = [ + ("outputCycle/usePresetPalette", Value::Enum(8)), + ("outputCycle/palette", Value::Str("0:1,1,1,0.25".into())), + ("modify/modifyAlpha", Value::Bool(true)), + ]; + assert!((px("ec.color.colorama", &alpha, grey)[3] - 0.25).abs() < 1e-5); + // Palette parsing and evaluation. + let p = ColoramaPalette::parse("0.5:0,0,1 0:1,0,0").unwrap(); + assert_eq!(p.eval(0.25, true), [0.5, 0.0, 0.5, 1.0]); + assert_eq!(p.eval(0.75, true), [0.5, 0.0, 0.5, 1.0], "wraps around"); + assert_eq!(p.eval(0.25, false), [1.0, 0.0, 0.0, 1.0]); + } } diff --git a/crates/effects/src/distort.rs b/crates/effects/src/distort.rs index e5472b1a..173389de 100644 --- a/crates/effects/src/distort.rs +++ b/crates/effects/src/distort.rs @@ -257,6 +257,32 @@ fn polar(ctx: &EffectCtx, mut b: Buf) -> Buf { b } +/// Transform's shutter: (angle, phase) in degrees and the number of samples, or None when it +/// renders without motion blur. Use Composition's Shutter Angle takes the comp's shutter +/// (only when the comp and layer motion-blur switches are on); otherwise Shutter Angle, centred +/// on the frame. +pub fn transform_shutter(ctx: &EffectCtx) -> Option<(f64, f64, u32)> { + let (angle, phase, n) = if ctx.params.b("useCompositionShutterAngle") { + ctx.env.shutter? + } else { + let a = ctx.params.f("shutterAngle").clamp(0.0, 720.0); + (a, -a * 0.5, 16) + }; + (angle > 0.0).then_some((angle, phase, n.clamp(2, 64))) +} + +fn transform_matrix(pr: &crate::Params, b: &Buf) -> Mat3 { + let anchor = b.to_px(pr.v2("anchor")); + let pos = b.to_px(pr.v2("position")); + let sh = pr.f("scaleHeight"); + let sw = if pr.b("uniform") { sh } else { pr.f("scaleWidth") }; + Mat3::translate(vec2(pos.0, pos.1)) + * Mat3::rotate_deg(pr.f("rotation")) + * Mat3::skew_deg(pr.f("skew"), pr.f("skewAxis")) + * Mat3::scale(vec2(sw / 100.0, sh / 100.0)) + * Mat3::translate(vec2(-anchor.0, -anchor.1)) +} + fn transform(ctx: &EffectCtx, mut b: Buf) -> Buf { let anchor = b.to_px(ctx.params.v2("anchor")); let pos = b.to_px(ctx.params.v2("position")); @@ -268,6 +294,34 @@ fn transform(ctx: &EffectCtx, mut b: Buf) -> Buf { let rot = ctx.params.f("rotation"); let opacity = ctx.params.f("opacity") as f32 / 100.0; let sampling = if ctx.params.e("sampling") == 1 { effectcraft_raster::Sampling::Bicubic } else { effectcraft_raster::Sampling::Bilinear }; + // Motion blur: the transform at Samples instants across the shutter, averaged (the + // transform at other times comes from the host; without one the frame renders sharp). + if let Some((angle, phase, n)) = transform_shutter(ctx) + && let Some(host) = ctx.env.host + { + let fd = 1.0 / ctx.fps(); + let mut acc = Image::new(b.img.width, b.img.height); + let mut count = 0.0f32; + for k in 0..n { + let t = ctx.time + (phase + angle * k as f64 / (n - 1) as f64) / 360.0 * fd; + let Some(pr) = host.params_at(t) else { continue }; + let mut one = Image::new(b.img.width, b.img.height); + let op = pr.f("opacity") as f32 / 100.0; + effectcraft_raster::composite_warp( + &mut one, + &b.img, + &transform_matrix(&pr, &b), + &effectcraft_raster::WarpOpts { opacity: op, sampling, ..Default::default() }, + ); + acc.data.iter_mut().zip(&one.data).for_each(|(a, o)| (0..4).for_each(|c| a[c] += o[c])); + count += 1.0; + } + if count > 0.0 { + acc.data.iter_mut().for_each(|p| p.iter_mut().for_each(|v| *v /= count)); + b.img = acc; + return b; + } + } let m = Mat3::translate(vec2(pos.0, pos.1)) * Mat3::rotate_deg(rot) * Mat3::skew_deg(skew, skew_axis) @@ -397,6 +451,8 @@ pub fn specs() -> Vec { p("skewAxis", "Skew Axis", num(0.0), ParamUi::Angle), p("rotation", "Rotation", num(0.0), ParamUi::Angle), p("opacity", "Opacity", num(100.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("useCompositionShutterAngle", "Use Composition's Shutter Angle", Value::Bool(true), ParamUi::Checkbox), + p("shutterAngle", "Shutter Angle", num(0.0), slider(0.0, 720.0, 0.0, 360.0, 1)), p("sampling", "Sampling", Value::Enum(0), popup(&["Bilinear", "Bicubic"])), ], transform, @@ -510,4 +566,44 @@ mod tests { let b = run("ec.distort.transform", &[("rotation", num(17.0)), ("sampling", Value::Enum(1))], im); assert!(a.img.data.iter().zip(&b.img.data).any(|(p, q)| differs(*p, *q))); } + + #[test] + fn transform_shutter_angle_motion_blur() { + /// Position moves right 200 px per second. + struct Moving; + impl crate::EffectHost for Moving { + fn layer(&self, _: u64, _: bool) -> Option { + None + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + fn params_at(&self, t: f64) -> Option { + let s = crate::find("ec.distort.transform").unwrap(); + let mut p = Params { values: s.params.iter().map(|p| (p.id.to_string(), crate::default_value(p, [40.0, 20.0]))).collect() }; + p.values.insert("position".into(), Value::Vec2([20.0 + 200.0 * t, 10.0])); + Some(p) + } + } + let mut sq = Image::new(40, 20); + for y in 5..15 { + for x in 15..25 { + sq.set(x, y, [1.0, 1.0, 1.0, 1.0]); + } + } + let render = |vals: &[(&str, Value)], shutter: Option<(f64, f64, u32)>| { + let env = crate::EffectEnv { host: Some(&Moving), frame_rate: 10.0, shutter, ..Default::default() }; + crate::run_fx("ec.distort.transform", vals, sq.clone(), 0.0, env).img + }; + let partial = |i: &Image| i.data.iter().filter(|p| p[3] > 0.02 && p[3] < 0.98).count(); + // Use Composition Shutter Angle with the comp / layer motion blur off: sharp. + assert_eq!(partial(&render(&[], None)), 0); + // With the comp shutter (180°): smeared along x over 10 px. + let comp = render(&[], Some((180.0, -90.0, 16))); + assert!(partial(&comp) > 50, "{}", partial(&comp)); + // Own Shutter Angle. + let own = render(&[("useCompositionShutterAngle", Value::Bool(false)), ("shutterAngle", num(360.0))], None); + assert!(partial(&own) > partial(&comp)); + assert_eq!(partial(&render(&[("useCompositionShutterAngle", Value::Bool(false))], None)), 0, "0 deg: sharp"); + } } diff --git a/crates/effects/src/distort2.rs b/crates/effects/src/distort2.rs index 2d52f039..43378528 100644 --- a/crates/effects/src/distort2.rs +++ b/crates/effects/src/distort2.rs @@ -62,7 +62,26 @@ const TURBULENT_KINDS: [&str; 9] = [ "Horizontal Displacement", "Cross Displacement", ]; -const TURBULENT_PINNING: [&str; 8] = ["None", "Pin All", "Pin Horizontal", "Pin Vertical", "Pin Left", "Pin Top", "Pin Right", "Pin Bottom"]; +/// Turbulent Displace's Pinning options. The Locked variants also keep every displaced +/// sample inside the layer (no edge pixels pulled in from outside) and ease the pinning over a +/// wider band, so the pinned edges and their neighbourhood stay put. +const TURBULENT_PINNING: [&str; 15] = [ + "None", + "Pin All", + "Pin Horizontal", + "Pin Vertical", + "Pin Left", + "Pin Top", + "Pin Right", + "Pin Bottom", + "Pin All Locked", + "Pin Horizontal Locked", + "Pin Vertical Locked", + "Pin Left Locked", + "Pin Top Locked", + "Pin Right Locked", + "Pin Bottom Locked", +]; fn turbulent_displace(ctx: &EffectCtx, mut b: Buf) -> Buf { let amount = ctx.params.f("amount") * b.scale; @@ -80,10 +99,12 @@ fn turbulent_displace(ctx: &EffectCtx, mut b: Buf) -> Buf { let evo = ctx.params.f("evolution") / 360.0; // Evolution Options: Cycle Evolution loops the noise every `cycle` revolutions. let cycle = (ctx.params.b("evolutionOptions/cycleEvolution")).then(|| ctx.params.f("evolutionOptions/cycle").round().max(1.0)); - let pin = ctx.params.e("pinning"); + let pin_raw = ctx.params.e("pinning"); + let locked = pin_raw >= 8; + let pin = if locked { pin_raw - 7 } else { pin_raw }; let falloff = if (3..=5).contains(&kind) { 0.3 } else { 0.5 }; let (lx, ly, lw, lh) = layer_rect(ctx, &b); - let edge = (lw.min(lh) * 0.1).max(1.0); + let edge = (lw.min(lh) * if locked { 0.25 } else { 0.1 }).max(1.0); let seed = (ctx.seed ^ 0x7d15).wrapping_add((ctx.params.f("evolutionOptions/randomSeed") as i64 as u32).wrapping_mul(0x9e37_79b9)); let n = |u: f64, v: f64, s: u32| { let s = seed.wrapping_add(s); @@ -128,7 +149,11 @@ fn turbulent_displace(ctx: &EffectCtx, mut b: Buf) -> Buf { 7 => bt, _ => 1.0, }; - Some((x + dx * k, y + dy * k)) + let (sx, sy) = (x + dx * k, y + dy * k); + if locked { + return Some((sx.clamp(lx + 0.5, lx + lw - 0.5), sy.clamp(ly + 0.5, ly + lh - 0.5))); + } + Some((sx, sy)) }); b } @@ -223,7 +248,10 @@ fn optics_compensation(ctx: &EffectCtx, mut b: Buf) -> Buf { let f = r_ref / half.tan(); // Resize (with Reverse Lens Distortion): grow the layer so the stretched corners stay // visible, up to 2× / 4× the layer size (Unlimited is capped at 8×). - let resize = ctx.params.e("resize"); + // Optimal Pixels (invalidates Resize): scale the result so the whole corrected picture + // fits the layer (Reverse) or fills it with no empty corners (forward). + let optimal = ctx.params.b("optimalPixels"); + let resize = if optimal { 0 } else { ctx.params.e("resize") }; if reverse && resize > 0 && !ctx.adjustment { let c = b.to_px(ctx.params.v2("viewCenter")); let rc = @@ -234,9 +262,21 @@ fn optics_compensation(ctx: &EffectCtx, mut b: Buf) -> Buf { b.pad(grow.min(cap).min(4096.0).ceil() as u32); } let c = b.to_px(ctx.params.v2("viewCenter")); + let fit = if optimal { + let rc = + [(lx, ly), (lx + lw, ly), (lx, ly + lh), (lx + lw, ly + lh)].iter().map(|p| ((p.0 - c.0).powi(2) + (p.1 - c.1).powi(2)).sqrt()).fold(1.0, f64::max); + if reverse { + let th = (rc / r_ref * half).min(FRAC_PI_2 - 1e-3); + (f * th.tan() / rc).max(1.0) + } else { + ((rc / f).atan() / half * r_ref / rc).min(1.0) + } + } else { + 1.0 + }; b.img = remap(&b.img, false, |x, y| { let (dx, dy) = (x - c.0, y - c.1); - let r = (dx * dx + dy * dy).sqrt(); + let r = (dx * dx + dy * dy).sqrt() * fit; if r < 1e-9 { return Some((x, y)); } @@ -249,7 +289,7 @@ fn optics_compensation(ctx: &EffectCtx, mut b: Buf) -> Buf { } f * th.tan() }; - let k = rs / r; + let k = rs / r * fit; Some((c.0 + dx * k, c.1 + dy * k)) }); b @@ -718,6 +758,7 @@ pub fn specs() -> Vec { p("reverseLensDistortion", "Reverse Lens Distortion", Value::Bool(false), ParamUi::Checkbox), p("fovOrientation", "FOV Orientation", Value::Enum(0), popup(&["Horizontal", "Vertical", "Diagonal"])), p("viewCenter", "View Center", pt(0.5, 0.5), ParamUi::Point), + p("optimalPixels", "Optimal Pixels (Invalidates Resize)", Value::Bool(false), ParamUi::Checkbox), p("resize", "Resize", Value::Enum(0), popup(&["Off", "Max 2X", "Max 4X", "Unlimited"])), ], optics_compensation, @@ -1052,6 +1093,15 @@ mod tests { // Random Seed changes the pattern. let seeded = run("ec.distort.turbulentdisplace", &[("size", num(10.0)), ("evolutionOptions/randomSeed", num(5.0))], img); assert!(max_diff(&seeded.img, &pinned.img) > 1e-3); + // Locked pinning: no sample comes from outside the layer (no transparent pixels pulled + // in) and the border still holds. + let opaque = Image::filled(40, 40, [0.5, 0.5, 0.5, 1.0]); + let strong = [("size", num(10.0)), ("amount", num(80.0))]; + let left = run("ec.distort.turbulentdisplace", &[strong.as_slice(), &[("pinning", Value::Enum(4))]].concat(), opaque.clone()); + let locked = run("ec.distort.turbulentdisplace", &[strong.as_slice(), &[("pinning", Value::Enum(11))]].concat(), opaque.clone()); + assert!(left.img.data.iter().any(|p| p[3] < 0.99), "plain pin lets edges pull in"); + assert!(locked.img.data.iter().all(|p| p[3] > 0.999), "locked keeps everything inside"); + assert_eq!(TURBULENT_PINNING[11], "Pin Left Locked"); } #[test] @@ -1078,4 +1128,20 @@ mod tests { let max4 = run("ec.distort.opticscompensation", &set(2), img); assert!(max4.img.width >= max2.img.width); } + + #[test] + fn optics_optimal_pixels_fills_or_fits_the_layer() { + let img = Image::filled(32, 24, [0.5, 0.5, 0.5, 1.0]); + // Forward: without Optimal Pixels the corners sample outside the layer. + let plain = run("ec.distort.opticscompensation", &[("fieldOfView", num(120.0))], img.clone()); + assert!(plain.img.get(0, 0)[3] < 0.5); + let opt = run("ec.distort.opticscompensation", &[("fieldOfView", num(120.0)), ("optimalPixels", Value::Bool(true))], img.clone()); + assert!(opt.img.get(0, 0)[3] > 0.9 && opt.img.get(31, 23)[3] > 0.9); + // Reverse: everything fits; Resize is ignored. + let rev = [("fieldOfView", num(120.0)), ("reverseLensDistortion", Value::Bool(true)), ("optimalPixels", Value::Bool(true)), ("resize", Value::Enum(2))]; + let r = run("ec.distort.opticscompensation", &rev, img.clone()); + assert_eq!(r.img.width, 32); + // The edges of the source now sit inside the layer: empty space around them. + assert!(r.img.get(0, 0)[3] < 0.5 && r.img.get(16, 12)[3] > 0.9); + } } diff --git a/crates/effects/src/distort3.rs b/crates/effects/src/distort3.rs index 46ca1a66..e1f0a898 100644 --- a/crates/effects/src/distort3.rs +++ b/crates/effects/src/distort3.rs @@ -428,6 +428,42 @@ fn smear(ctx: &EffectCtx, mut b: Buf) -> Buf { // ---------------------------------------------------------------- Reshape +/// Reshape's correspondence points: "s,d s,d …" (outline fractions, sorted by destination). +fn parse_correspondence(s: &str) -> Vec<(f64, f64)> { + let mut v: Vec<(f64, f64)> = s + .split_whitespace() + .filter_map(|t| { + let (a, b) = t.split_once(',')?; + Some((a.trim().parse::().ok()?.rem_euclid(1.0), b.trim().parse::().ok()?.rem_euclid(1.0))) + }) + .collect(); + v.sort_by(|a, b| a.1.total_cmp(&b.1)); + v +} + +/// The source outline fraction matching destination fraction `u` (piecewise linear between +/// correspondence pairs; around the loop for closed shapes). +fn map_fraction(pairs: &[(f64, f64)], u: f64, closed: bool) -> f64 { + let n = pairs.len(); + if n == 1 { + return (pairs[0].0 + (u - pairs[0].1)).rem_euclid(1.0); + } + if !closed { + // Open paths: clamp beyond the first / last pair (ends map to ends). + let ext: Vec<(f64, f64)> = std::iter::once((0.0, 0.0)).chain(pairs.iter().copied()).chain(std::iter::once((1.0, 1.0))).collect(); + let k = ext.iter().rposition(|p| p.1 <= u).unwrap_or(0).min(ext.len() - 2); + let (a, b) = (ext[k], ext[k + 1]); + let t = if b.1 > a.1 { (u - a.1) / (b.1 - a.1) } else { 0.0 }; + return (a.0 + (b.0 - a.0) * t).clamp(0.0, 1.0); + } + let k = pairs.iter().rposition(|p| p.1 <= u).unwrap_or(n - 1); + let (a, b) = (pairs[k], pairs[(k + 1) % n]); + let du = (b.1 - a.1).rem_euclid(1.0).max(1e-9); + let ds = (b.0 - a.0).rem_euclid(1.0); + let t = (u - a.1).rem_euclid(1.0) / du; + (a.0 + ds * t).rem_euclid(1.0) +} + 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; @@ -450,7 +486,25 @@ fn reshape(ctx: &EffectCtx, mut b: Buf) -> Buf { } 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(); + // Correspondence points ("source,destination" pairs of outline fractions 0..1) pin which + // point of the source outline goes to which point of the destination outline; in between + // the outlines are matched proportionally by length. Without them the starting point is + // chosen automatically (above). + let pairs = parse_correspondence(ctx.params.s("correspondencePoints")); + let src_at = |i: usize| -> [f64; 2] { + if pairs.is_empty() { + return s[(i + rot) % N]; + } + let u = map_fraction(&pairs, i as f64 / N as f64, sc && dc); + let len = crate::util::poly_length(&sm, sc); + crate::util::poly_point_at(&sm, sc, u * len).0 + }; + let disp: Vec<[f64; 2]> = (0..N) + .map(|i| { + let q = src_at(i); + [q[0] - d[i][0], q[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 @@ -839,6 +893,8 @@ pub fn specs() -> Vec { p("percent", "Percent", num(100.0), pct()), p("elasticity", "Elasticity", Value::Enum(0), popup(&ELASTICITY)), p("interpolation", "Interpolation Method", Value::Enum(1), popup(&["Discrete", "Linear", "Smooth"])), + // Correspondence points: "source,destination" outline fractions (0..1) per point. + p("correspondencePoints", "Correspondence Points", Value::Str(String::new()), ParamUi::Hidden), ], reshape, ), @@ -1029,6 +1085,22 @@ mod tests { assert!(o.img.get(11, 15)[0] < 0.3, "{:?}", o.img.get(11, 15)); } + #[test] + fn reshape_correspondence_points_steer_the_match() { + // Matching outlines with a zero pair change nothing; a quarter-turn offset twists. + let im = img(40, 40); + let masks = [rect_mask(10.0, 10.0, 30.0, 30.0), rect_mask(10.0, 10.0, 30.0, 30.0), rect_mask(0.0, 0.0, 40.0, 40.0)]; + let base = [("sourceMask", Value::Enum(1)), ("destinationMask", Value::Enum(2)), ("boundaryMask", Value::Enum(3))]; + let same = run("ec.distort.reshape", &[base.as_slice(), &[("correspondencePoints", Value::Str("0,0".into()))]].concat(), im.clone(), &masks); + assert!(maxd(&same.img, &im) < 1e-3, "matching outlines: no change"); + let twisted = run("ec.distort.reshape", &[base.as_slice(), &[("correspondencePoints", Value::Str("0.25,0".into()))]].concat(), im.clone(), &masks); + assert!(maxd(&twisted.img, &im) > 0.1); + // Fraction mapping. + assert!((map_fraction(&[(0.0, 0.0), (0.5, 0.25)], 0.125, true) - 0.25).abs() < 1e-9); + assert!((map_fraction(&[(0.2, 0.0)], 0.9, true) - 0.1).abs() < 1e-9); + assert!((map_fraction(&[(0.5, 0.5)], 0.25, false) - 0.25).abs() < 1e-9); + } + #[test] fn upscale_grows_about_centre() { let im = img(20, 10); diff --git a/crates/effects/src/distort4.rs b/crates/effects/src/distort4.rs index 5a218e66..c576fead 100644 --- a/crates/effects/src/distort4.rs +++ b/crates/effects/src/distort4.rs @@ -435,12 +435,15 @@ fn median(v: &mut [f64]) -> f64 { /// Measure motion around the current frame (`cur`) from `prev` to `next` (all in the same /// buffer grid). `per_row`: Warp method (one velocity per scanline band). -fn measure(cur: &Image, prev: Option<&Image>, next: Option<&Image>, scale: f64, detailed: bool, per_row: bool) -> Option { +/// `detail` (Pixel Motion Detail, 0..1; 0.2 = the standard grid) scales the number of motion +/// vectors the Pixel Motion method measures: more vectors follow finer local motion. +fn measure(cur: &Image, prev: Option<&Image>, next: Option<&Image>, scale: f64, detailed: bool, per_row: bool, detail: f64) -> Option { use effectcraft_track::klt::{GrayPyramid, LkOpts, analysis_factor, track}; let factor = analysis_factor(cur.width, cur.height, if detailed { 640 } else { 320 }); let pc = GrayPyramid::from_image(cur, [0.0; 2], factor, 4); let (w, h) = (pc.width(), pc.height()); - let n = if detailed { 24 } else { 12 }; + let base = if detailed { 24.0 } else { 12.0 }; + let n = if per_row { base as usize } else { (base * (0.5 + detail.clamp(0.0, 1.0) * 2.5)).round().clamp(4.0, 96.0) as usize }; let cell_w = w as f64 / n as f64; let rows = ((n as f64) * h as f64 / w.max(1) as f64).round().max(2.0) as usize; let cell_h = h as f64 / rows as f64; @@ -520,7 +523,17 @@ fn rolling_shutter(ctx: &EffectCtx, mut b: Buf) -> Buf { return b; } let per_row = ctx.params.e("advanced/method") == 0; - let Some(flow) = measure(&b.img, prev.as_ref(), next.as_ref(), 1.0, ctx.params.b("advanced/detailedAnalysis"), per_row) else { return b }; + let Some(flow) = measure( + &b.img, + prev.as_ref(), + next.as_ref(), + 1.0, + ctx.params.b("advanced/detailedAnalysis"), + per_row, + ctx.params.f("advanced/pixelMotionDetail") / 100.0, + ) else { + return b; + }; let dir = ctx.params.e("scanDirection"); let src = b.img.clone(); let (w, h) = (b.img.width as f64, b.img.height as f64); @@ -740,6 +753,20 @@ mod tests { ); assert_eq!(out.img.data, again.img.data); } + // Pixel Motion Detail changes how many vectors Pixel Motion measures (and still repairs). + let detail = |d: f64| { + run_fx( + "ec.distort.rollingshutterrepair", + &[("rollingShutterRate", num(100.0)), ("advanced/method", Value::Enum(1)), ("advanced/pixelMotionDetail", num(d))], + skewed.clone(), + t, + env, + ) + .img + }; + let (fine, coarse) = (detail(100.0), detail(0.0)); + assert_ne!(fine.data, coarse.data); + assert!(err(&fine) < err(&skewed) * 0.5 && err(&coarse) < err(&skewed) * 0.5); let none = run_fx("ec.distort.rollingshutterrepair", &[], skewed.clone(), t, EffectEnv::default()); assert_eq!(none.img.data, skewed.data); } diff --git a/crates/effects/src/generate2.rs b/crates/effects/src/generate2.rs index ddde36d7..28c29be2 100644 --- a/crates/effects/src/generate2.rs +++ b/crates/effects/src/generate2.rs @@ -43,8 +43,9 @@ fn add_light(px: Px, l: [f32; 3], la: f32) -> Px { // ---------------------------------------------------------------- Cell Pattern -/// Cell Pattern options. The HQ variants share the shaping of their standard counterparts -/// (we always render at full precision). +/// Cell Pattern options. The HQ variants share the shaping of their standard counterparts but +/// search a wider neighbourhood of cells (no artefacts at high Disperse) and anti-alias the +/// cell edges (4×4 samples per pixel). const CELL_PATTERNS: [&str; 12] = [ "Bubbles", "Crystals", @@ -75,13 +76,13 @@ pub fn pattern_kind(i: u32) -> u32 { /// Worley distances for point (u, v) in cell units: (F1, F2, hash of the nearest cell). /// `tile` repeats the cell layout every (columns, rows) cells. -fn worley(u: f64, v: f64, disperse: f64, evo: f64, seed: u32, tile: Option<(i64, i64)>) -> (f64, f64, f32) { +fn worley_r(u: f64, v: f64, disperse: f64, evo: f64, seed: u32, tile: Option<(i64, i64)>, reach: i64) -> (f64, f64, f32) { let (cx, cy) = (u.floor() as i64, v.floor() as i64); let mut f1 = f64::INFINITY; let mut f2 = f64::INFINITY; let mut id = 0.0f32; - for j in -1..=1 { - for i in -1..=1 { + for j in -reach..=reach { + for i in -reach..=reach { let (gx, gy) = (cx + i, cy + j); let (tx, ty) = match tile { Some((nx, ny)) => (gx.rem_euclid(nx), gy.rem_euclid(ny)), @@ -135,6 +136,7 @@ fn overflow(v: f32, mode: u32) -> f32 { fn cell_pattern(ctx: &EffectCtx, mut b: Buf) -> Buf { let pattern = pattern_kind(ctx.params.e("cellPattern")); + let hq = (6..=10).contains(&ctx.params.e("cellPattern")); let invert = ctx.params.b("invert"); let contrast = ctx.params.f("contrast") as f32 / 100.0; let ov = ctx.params.e("overflow"); @@ -152,11 +154,19 @@ fn cell_pattern(ctx: &EffectCtx, mut b: Buf) -> Buf { .params .b("tilingOptions/enableTiling") .then(|| (ctx.params.f("tilingOptions/cellsHorizontal").round().max(1.0) as i64, ctx.params.f("tilingOptions/cellsVertical").round().max(1.0) as i64)); + let subs: Vec = if hq { (0..4).map(|k| (k as f64 + 0.5) / 4.0).collect() } else { vec![0.5] }; + let reach = if hq { 2 } else { 1 }; map_xy(&mut b.img, |x, y, _| { - let u = (x as f64 + 0.5 - off.0) / size; - let v = (y as f64 + 0.5 - off.1) / size; - let (f1, f2, id) = worley(u, v, disperse, evo, seed, tile); - let mut val = cell_value(pattern, f1, f2, id); + let mut val = 0.0; + for sy in &subs { + for sx in &subs { + let u = (x as f64 + sx - off.0) / size; + let v = (y as f64 + sy - off.1) / size; + let (f1, f2, id) = worley_r(u, v, disperse, evo, seed, tile, reach); + val += cell_value(pattern, f1, f2, id); + } + } + let mut val = val / (subs.len() * subs.len()) as f32; val = overflow((val - 0.5) * contrast + 0.5, ov); if invert { val = 1.0 - val; @@ -349,60 +359,275 @@ fn outline_dist(pts: &[(f64, f64)], dx: f64, dy: f64) -> f64 { best } +/// Radio Waves' Wave Type options. +const RW_TYPES: [&str; 3] = ["Polygon", "Image Contours", "Mask"]; +/// Radio Waves' Stroke Profile options. +const RW_PROFILES: [&str; 7] = ["Square", "Taper Front", "Taper Back", "Taper Both", "Sine", "Sawtooth Out", "Sawtooth In"]; +/// Image Contour's Value Channel options. +const RW_CHANNELS: [&str; 8] = ["Red", "Green", "Blue", "Alpha", "Luminance", "Hue", "Lightness", "Saturation"]; + +/// Stroke Profile weight across the stroke: `u` from −1 (inner edge) to 1 (outer edge). +fn rw_profile(kind: u32, u: f64) -> f64 { + let u = u.clamp(-1.0, 1.0); + match kind { + 1 => (1.0 - u) * 0.5, + 2 => (1.0 + u) * 0.5, + 3 => 1.0 - u.abs(), + 4 => (u * PI * 0.5).cos(), + 5 if u < 0.0 => 1.0 + u, + 6 if u > 0.0 => 1.0 - u, + _ => 1.0, + } +} + +/// Fold a coordinate into `[lo, hi]` by bouncing off the ends (Reflection). +fn reflect(v: f64, lo: f64, hi: f64) -> f64 { + let span = hi - lo; + if span <= 0.0 { + return lo; + } + let t = (v - lo).rem_euclid(2.0 * span); + lo + if t > span { 2.0 * span - t } else { t } +} + +/// The source shape of a contour wave: signed distance (buffer px, negative inside) on the +/// buffer grid, and the point of the shape that sits on the Producer Point. +struct Contour { + sdf: Plane, + anchor: (f64, f64), +} + +/// Image Contours: the region of the source layer whose Value Channel passes Value Threshold. +fn image_contour(ctx: &EffectCtx, b: &Buf) -> Option { + let pr = ctx.params; + let lp = ctx.layer_param("imageContour/sourceLayer", true)?; + let img = crate::util::fit_layer(ctx, b, &lp, false); + let (w, h) = (img.width as usize, img.height as usize); + let ch = pr.e("imageContour/valueChannel"); + let mut v = Plane::from_image(&img, |px| { + let (c, a) = unpremul(px); + let (hh, s, l) = effectcraft_color::rgb_to_hsl(c[0], c[1], c[2]); + match ch { + 0 => c[0] * a, + 1 => c[1] * a, + 2 => c[2] * a, + 3 => a, + 5 => hh, + 6 => l * a, + 7 => s, + _ => luminance(c[0], c[1], c[2]) * a, + } + }); + let blur = pr.f("imageContour/preBlur").max(0.0) * b.scale; + if blur > 0.0 { + v = gauss_plane(&v, blur * 0.5, blur * 0.5); + } + let thr = (pr.f("imageContour/valueThreshold") / 255.0) as f32; + let invert = pr.b("imageContour/invertInput"); + let mut inside: Vec = v.data.iter().map(|&x| (x >= thr) != invert).collect(); + // Contour: 0 = every region; n = the n-th largest connected region. + let pick = pr.f("imageContour/contour").round() as usize; + if pick > 0 { + let mut label = vec![0u32; w * h]; + let mut sizes = vec![0usize]; + for start in 0..w * h { + if !inside[start] || label[start] != 0 { + continue; + } + let id = sizes.len() as u32; + let mut stack = vec![start]; + label[start] = id; + let mut n = 0; + while let Some(i) = stack.pop() { + n += 1; + let (x, y) = (i % w, i / w); + let mut nb = |j: usize| { + if inside[j] && label[j] == 0 { + label[j] = id; + stack.push(j); + } + }; + if x > 0 { + nb(i - 1); + } + if x + 1 < w { + nb(i + 1); + } + if y > 0 { + nb(i - w); + } + if y + 1 < h { + nb(i + w); + } + } + sizes.push(n); + } + let mut order: Vec = (1..sizes.len()).collect(); + order.sort_by(|a, c| sizes[*c].cmp(&sizes[*a]).then(a.cmp(c))); + let keep = order.get(pick - 1).copied().unwrap_or(usize::MAX) as u32; + inside.iter_mut().zip(&label).for_each(|(v, l)| *v = *v && *l == keep); + } + if !inside.iter().any(|v| *v) { + return None; + } + let mut sdf = crate::util::signed_distance(&inside, w, h); + // Tolerance smooths the traced outline. + let tol = pr.f("imageContour/tolerance").max(0.0) * b.scale; + if tol > 0.0 { + sdf = gauss_plane(&sdf, tol * 0.5, tol * 0.5); + } + Some(Contour { sdf, anchor: b.to_px(pr.v2("imageContour/sourceCenter")) }) +} + +/// Mask: the chosen mask of this layer. +fn mask_contour(ctx: &EffectCtx, b: &Buf) -> Option { + let idx = ctx.params.f("waveMask/mask").round() as usize; + let (pts, _, inverted) = crate::generate3::mask_px(ctx, b, idx)?; + let (w, h) = (b.img.width as usize, b.img.height as usize); + let inside: Vec = (0..w * h).map(|i| crate::util::point_in_poly(&pts, (i % w) as f64 + 0.5, (i / w) as f64 + 0.5) != inverted).collect(); + if !inside.iter().any(|v| *v) { + return None; + } + // Mask waves start where the mask is (anchored at the Producer Point: no shift). + let anchor = b.to_px(ctx.params.v2("producerPoint")); + Some(Contour { sdf: crate::util::signed_distance(&inside, w, h), anchor }) +} + +/// One live wave. +struct Wave { + centre: (f64, f64), + radius: f64, + half_width: f64, + rotation: f64, + fade: f32, + color: [f32; 4], + opacity: f32, + /// Polygon outline (unit shape rotated and scaled), or None for a circle / contour. + outline: Option>, +} + +/// Radio Waves: waves are born at Frequency per second at the Producer Point and grow at +/// Expansion px/s for Lifespan seconds, moving at Velocity towards Direction (bouncing off the +/// layer edges with Reflection) and spinning. Polygon waves are regular polygons or stars; +/// Image Contours waves ripple outward from the outline of a source layer's thresholded +/// channel, Mask waves from a mask. With Parameters Are Set At Birth each wave keeps the values +/// it was born with; Each Frame applies the current values to every wave. Stroke draws each wave +/// with its Profile across the width. fn radio_waves(ctx: &EffectCtx, mut b: Buf) -> Buf { - let c = b.to_px(ctx.params.v2("producerPoint")); - let sides = ctx.params.f("polygon/sides").round().clamp(3.0, 64.0); - let star = ctx.params.b("polygon/star"); - let depth = ctx.params.f("polygon/starDepth").clamp(-1.0, 1.0); - let freq = ctx.params.f("waveMotion/frequency").max(0.01); - let expansion = ctx.params.f("waveMotion/expansion") * b.scale; - let orient = ctx.params.f("waveMotion/orientation").to_radians(); - // Direction: 0° = up, clockwise. - let dirn = ctx.params.f("waveMotion/direction").to_radians(); - let vel = ctx.params.f("waveMotion/velocity") * b.scale; - let spin = ctx.params.f("waveMotion/spin").to_radians(); - let life = ctx.params.f("waveMotion/lifespan").max(0.01); - let color = ctx.params.color("waveStroke/color"); - let op = (ctx.params.f("waveStroke/opacity") / 100.0) as f32; - let fin = ctx.params.f("waveStroke/fadeInTime").max(0.0); - let fout = ctx.params.f("waveStroke/fadeOutTime").max(0.0); - let sw = ctx.params.f("waveStroke/startWidth") * b.scale; - let ew = ctx.params.f("waveStroke/endWidth") * b.scale; + let cur = ctx.params; let t = ctx.time.max(0.0); + let freq = cur.f("waveMotion/frequency").max(0.01); + let life_now = cur.f("waveMotion/lifespan").max(0.01); + let birth = cur.e("parametersAreSetAt") == 0; + let wave_type = cur.e("waveType"); + let quality = cur.get("renderQuality").map(Value::as_f64).unwrap_or(4.0).clamp(1.0, 10.0); + let aa = 0.5 + 1.5 / quality; + let profile = cur.e("waveStroke/profile"); + let reflection = cur.b("waveMotion/reflection"); + let (lx, ly, lw, lh) = layer_rect(ctx, &b); + let contour = match wave_type { + 1 => image_contour(ctx, &b), + 2 => mask_contour(ctx, &b), + _ => None, + }; + if wave_type != 0 && contour.is_none() { + return b; + } let k_hi = (t * freq).floor() as i64; - let k_lo = (((t - life) * freq).floor() as i64).max(0).max(k_hi - 256); - // (centre, radius, half width, rotation, fade) per live wave. - let waves: Vec<((f64, f64), f64, f64, f64, f32)> = (k_lo..=k_hi) + let k_lo = (((t - life_now) * freq).floor() as i64 - 1).max(0).max(k_hi - 256); + let mut cache: std::collections::HashMap = std::collections::HashMap::new(); + let waves: Vec = (k_lo..=k_hi) .filter_map(|k| { - let age = t - k as f64 / freq; + let born = k as f64 / freq; + let age = t - born; + // Birth: the values the wave was born with (when the host can tell). + let pr: &crate::Params = if birth { + if let Some(h) = ctx.env.host + && let std::collections::hash_map::Entry::Vacant(e) = cache.entry(k) + && let Some(p) = h.params_at(born) + { + e.insert(p); + } + cache.get(&k).unwrap_or(cur) + } else { + cur + }; + let life = pr.f("waveMotion/lifespan").max(0.01); if !(0.0..life).contains(&age) { return None; } + let c = b.to_px(pr.v2("producerPoint")); + let fin = pr.f("waveStroke/fadeInTime").max(0.0); + let fout = pr.f("waveStroke/fadeOutTime").max(0.0); let fi = if fin > 0.0 { (age / fin).min(1.0) } else { 1.0 }; let fo = if fout > 0.0 { ((life - age) / fout).min(1.0) } else { 1.0 }; - let centre = (c.0 + dirn.sin() * vel * age, c.1 - dirn.cos() * vel * age); - Some((centre, expansion * age, ((sw + (ew - sw) * age / life) * 0.5).max(0.25), orient + spin * age, (fi * fo) as f32)) + let dirn = pr.f("waveMotion/direction").to_radians(); + let vel = pr.f("waveMotion/velocity") * b.scale; + let mut centre = (c.0 + dirn.sin() * vel * age, c.1 - dirn.cos() * vel * age); + if reflection { + centre = (reflect(centre.0, lx, lx + lw), reflect(centre.1, ly, ly + lh)); + } + let sw = pr.f("waveStroke/startWidth") * b.scale; + let ew = pr.f("waveStroke/endWidth") * b.scale; + let radius = pr.f("waveMotion/expansion") * b.scale * age; + let rotation = pr.f("waveMotion/orientation").to_radians() + pr.f("waveMotion/spin").to_radians() * age; + let outline = (wave_type == 0).then(|| { + let sides = pr.f("polygon/sides").round().clamp(3.0, 64.0); + let star = pr.b("polygon/star"); + let depth = pr.f("polygon/starDepth").clamp(-1.0, 1.0); + if sides >= 64.0 && !star { + return None; + } + let n = sides as usize; + let unit: Vec<(f64, f64)> = if star { + (0..2 * n).map(|i| (PI * i as f64 / n as f64 - PI / 2.0, if i % 2 == 0 { 1.0 } else { (1.0 + depth).max(0.0) })).collect() + } else { + (0..n).map(|i| (2.0 * PI * i as f64 / n as f64 - PI / 2.0, 1.0)).collect() + }; + Some(unit.iter().map(|&(a, k)| ((a + rotation).cos() * radius * k, (a + rotation).sin() * radius * k)).collect()) + }); + Some(Wave { + centre, + radius, + half_width: ((sw + (ew - sw) * age / life) * 0.5).max(0.25), + rotation, + fade: (fi * fo) as f32, + color: pr.color("waveStroke/color"), + opacity: (pr.f("waveStroke/opacity") / 100.0) as f32, + outline: outline.flatten(), + }) }) .collect(); - let circle = sides >= 64.0 && !star; - // Unit outline (radius 1) of the polygon or star, rotated per wave below. - let n = sides as usize; - let unit: Vec<(f64, f64)> = if star { - (0..2 * n).map(|i| (PI * i as f64 / n as f64 - PI / 2.0, if i % 2 == 0 { 1.0 } else { (1.0 + depth).max(0.0) })).collect() - } else { - (0..n).map(|i| (2.0 * PI * i as f64 / n as f64 - PI / 2.0, 1.0)).collect() - }; - let outlines: Vec> = - waves.iter().map(|&(_, rad, _, rot, _)| unit.iter().map(|&(a, k)| ((a + rot).cos() * rad * k, (a + rot).sin() * rad * k)).collect()).collect(); map_xy(&mut b.img, |x, y, px| { - let mut a = 0.0f32; - for (wi, &(cc, rad, hw, _, fade)) in waves.iter().enumerate() { - let (dx, dy) = (x as f64 + 0.5 - cc.0, y as f64 + 0.5 - cc.1); - let d = if circle { ((dx * dx + dy * dy).sqrt() - rad).abs() } else { outline_dist(&outlines[wi], dx, dy) }; - a = a.max((hw - d + 0.5).clamp(0.0, 1.0) as f32 * fade); + let mut out = px; + // Older waves first, so newer ones sit on top. + for wv in &waves { + let (dx, dy) = (x as f64 + 0.5 - wv.centre.0, y as f64 + 0.5 - wv.centre.1); + // Signed distance from the wave line (negative inside the wave). + let d = match (&contour, &wv.outline) { + (Some(ct), _) => { + // The contour placed with its anchor on the wave centre, rotated. + let (s, c) = (-(wv.rotation)).sin_cos(); + let (rx, ry) = (dx * c - dy * s, dx * s + dy * c); + ct.sdf.sample(ct.anchor.0 + rx, ct.anchor.1 + ry) as f64 - wv.radius + } + (None, Some(o)) => { + let ud = outline_dist(o, dx, dy); + let poly: Vec<[f64; 2]> = o.iter().map(|p| [p.0, p.1]).collect(); + if crate::util::point_in_poly(&poly, dx, dy) { -ud } else { ud } + } + (None, None) => (dx * dx + dy * dy).sqrt() - wv.radius, + }; + let ad = d.abs(); + let cov = ((wv.half_width - ad) / aa + 0.5).clamp(0.0, 1.0); + if cov <= 0.0 { + continue; + } + let u = (d / wv.half_width).clamp(-1.0, 1.0); + let a = (cov * rw_profile(profile, u)) as f32 * wv.fade * wv.opacity * wv.color[3]; + out = over(out, [wv.color[0] * a, wv.color[1] * a, wv.color[2] * a, a]); } - let a = a * op * color[3]; - over(px, [color[0] * a, color[1] * a, color[2] * a, a]) + out }); b } @@ -414,7 +639,40 @@ type Seg = ((f64, f64), (f64, f64), f32); /// Advanced Lightning's Lightning Type options. const LIGHTNING_TYPES: [&str; 8] = ["Direction", "Strike", "Breaking", "Bouncy", "Omni", "Anywhere", "Vertical", "Two-Way Strike"]; -fn bolt(a: (f64, f64), z: (f64, f64), depth: u32, inten: f32, turb: f64, fork: f64, decay: f32, rng: &mut Rng, out: &mut Vec) { +/// Advanced Lightning's Fractal Type options. +const FRACTAL_TYPES: [&str; 3] = ["Linear", "Semi-Linear", "Spline"]; + +/// How bolts grow: the general and Expert Settings. +struct BoltCfg<'a> { + turb: f64, + fork: f64, + decay: f32, + /// The layer's alpha (buffer grid) and Alpha Obstacle (−10..10; positive avoids opaque + /// areas, negative is drawn to them). + alpha: Option<&'a Plane>, + obstacle: f64, + main_only: bool, + /// Min Fork Distance (px): shorter stretches do not fork. + min_fork: f64, + /// Termination Threshold: branches fainter than this stop. + term: f32, + /// Zig-zag scale from Fractal Type (Linear 1, Semi-Linear 0.7, Spline 0.45 with an extra + /// subdivision level). + shape: f64, + fork_strength: f32, + fork_var: f32, +} + +impl BoltCfg<'_> { + fn alpha_at(&self, p: (f64, f64)) -> f32 { + self.alpha.map_or(0.0, |a| a.sample(p.0, p.1)) + } +} + +/// Grow a bolt from `a` to `z` by midpoint displacement (`depth` levels), forking at random. +/// Segments go to `out` with their intensity; `mains` marks the main core's segments. +#[allow(clippy::too_many_arguments)] +fn bolt(cfg: &BoltCfg, a: (f64, f64), z: (f64, f64), depth: u32, inten: f32, main: bool, rng: &mut Rng, out: &mut Vec, mains: &mut Vec) { if out.len() > 20_000 { return; } @@ -422,19 +680,38 @@ fn bolt(a: (f64, f64), z: (f64, f64), depth: u32, inten: f32, turb: f64, fork: f let len = (dx * dx + dy * dy).sqrt(); if depth == 0 || len < 1.0 { out.push((a, z, inten)); + mains.push(main); return; } let (nx, ny) = (-dy / len.max(1e-9), dx / len.max(1e-9)); - let off = rng.s() * len * 0.22 * turb; + let mut off = rng.s() * len * 0.22 * cfg.turb * cfg.shape; + // Alpha Obstacle: of the two mirror positions for the midpoint, prefer the one with less + // (positive) or more (negative) alpha, the more strongly the larger the setting. + if cfg.obstacle != 0.0 && cfg.alpha.is_some() && (main || !cfg.main_only) { + let mid = ((a.0 + z.0) * 0.5, (a.1 + z.1) * 0.5); + let p1 = (mid.0 + nx * off, mid.1 + ny * off); + let p2 = (mid.0 - nx * off, mid.1 - ny * off); + let (a1, a2) = (cfg.alpha_at(p1), cfg.alpha_at(p2)); + let worse = if cfg.obstacle > 0.0 { a1 > a2 } else { a1 < a2 }; + if worse && rng.f() < cfg.obstacle.abs() / 10.0 { + off = -off; + } + } let m = ((a.0 + z.0) * 0.5 + nx * off, (a.1 + z.1) * 0.5 + ny * off); - bolt(a, m, depth - 1, inten, turb, fork, decay, rng, out); - bolt(m, z, depth - 1, inten, turb, fork, decay, rng, out); - if rng.f() < fork && inten > 0.05 { + // Positive obstacles stop branches that run into opaque areas. + if cfg.obstacle > 0.0 && !main && cfg.alpha_at(m) > 0.5 && rng.f() < cfg.obstacle / 10.0 { + return; + } + bolt(cfg, a, m, depth - 1, inten, main, rng, out, mains); + bolt(cfg, m, z, depth - 1, inten, main, rng, out, mains); + if rng.f() < cfg.fork && inten > cfg.term && len >= cfg.min_fork { let ang = rng.s() * 0.7; let (s, c) = ang.sin_cos(); let (fx, fy) = ((m.0 - a.0) * c - (m.1 - a.1) * s, (m.0 - a.0) * s + (m.1 - a.1) * c); let k = 1.2; - bolt(m, (m.0 + fx * k, m.1 + fy * k), depth.saturating_sub(1), inten * (1.0 - decay), turb, fork, decay, rng, out); + // Fork Strength 50 % keeps the decayed intensity; Fork Variation randomises it. + let fi = inten * (1.0 - cfg.decay) * cfg.fork_strength * 2.0 * if cfg.fork_var > 0.0 { 1.0 - cfg.fork_var * rng.f() as f32 } else { 1.0 }; + bolt(cfg, m, (m.0 + fx * k, m.1 + fy * k), depth.saturating_sub(1), fi.min(1.0), false, rng, out, mains); } } @@ -446,26 +723,49 @@ fn lightning_segments(ctx: &EffectCtx, b: &Buf) -> Vec { let turb = ctx.params.f("turbulence").max(0.0); let fork = (ctx.params.f("forking") / 100.0).clamp(0.0, 1.0); let decay = ctx.params.f("decay").clamp(0.0, 1.0) as f32; - let depth = ctx.params.f("expertSettings/complexity").round().clamp(1.0, 12.0) as u32; + let fractal = ctx.params.e("expertSettings/fractalType"); + let depth = ctx.params.f("expertSettings/complexity").round().clamp(1.0, 12.0) as u32 + u32::from(fractal == 2); let state = ctx.params.f("conductivityState").floor() as i64 as u64; let mut rng = Rng::new(state ^ ((ctx.seed as u64) << 32)); + let obstacle = ctx.params.f("alphaObstacle").clamp(-10.0, 10.0); + let alpha_plane = (obstacle != 0.0).then(|| Plane::alpha(&b.img)); + let g = |id: &str, dflt: f64| ctx.params.get(id).map(Value::as_f64).unwrap_or(dflt); + let cfg = BoltCfg { + turb, + fork, + decay, + alpha: alpha_plane.as_ref(), + obstacle, + main_only: ctx.params.b("expertSettings/mainCoreCollisionOnly"), + min_fork: g("expertSettings/minForkDistance", 0.0).max(0.0) * b.scale, + term: (g("expertSettings/terminationThreshold", 5.0) / 100.0).clamp(0.0, 1.0) as f32, + shape: match fractal { + 1 => 0.7, + 2 => 0.45, + _ => 1.0, + }, + fork_strength: (g("expertSettings/forkStrength", 50.0) / 100.0).clamp(0.0, 1.0) as f32, + fork_var: (g("expertSettings/forkVariation", 0.0) / 100.0).clamp(0.0, 1.0) as f32, + }; let mut segs = Vec::new(); + let mut mains = Vec::new(); let (h, w) = (b.img.height as f64, b.img.width as f64); + let mut go = |a, z, depth, cfg: &BoltCfg, rng: &mut Rng| bolt(cfg, a, z, depth, 1.0, true, rng, &mut segs, &mut mains); // LIGHTNING_TYPES order. match kind { // Direction: travels toward the Direction point and beyond. 0 => { let z = (o.0 + (d.0 - o.0) * 3.0, o.1 + (d.1 - o.1) * 3.0); - bolt(o, z, depth, 1.0, turb, fork, decay, &mut rng, &mut segs); + go(o, z, depth, &cfg, &mut rng); } // Breaking: more branching as the points move apart. 2 => { let dist = ((d.0 - o.0).powi(2) + (d.1 - o.1).powi(2)).sqrt(); let f = (fork * (0.5 + dist / w.max(h).max(1.0))).min(1.0); - bolt(o, d, depth, 1.0, turb, f, decay, &mut rng, &mut segs); + go(o, d, depth, &BoltCfg { fork: f, ..cfg }, &mut rng); } // Bouncy: a strike with a stronger zig-zag. - 3 => bolt(o, d, depth, 1.0, turb * 1.6, fork, decay, &mut rng, &mut segs), + 3 => go(o, d, depth, &BoltCfg { turb: turb * 1.6, ..cfg }, &mut rng), // Omni / Anywhere: bolts in all directions out to the Outer Radius. 4 | 5 => { let len = ((d.0 - o.0).powi(2) + (d.1 - o.1).powi(2)).sqrt().max(w.min(h) * 0.25); @@ -473,27 +773,37 @@ fn lightning_segments(ctx: &EffectCtx, b: &Buf) -> Vec { let a = rng.f() * 2.0 * PI; let l = if kind == 5 { len * (0.3 + 0.7 * rng.f()) } else { len }; let z = (o.0 + a.cos() * l, o.1 + a.sin() * l); - bolt(o, z, depth, 1.0, turb, fork, decay, &mut rng, &mut segs); + go(o, z, depth, &cfg, &mut rng); } } - 6 => bolt(o, (o.0, h), depth, 1.0, turb, fork, decay, &mut rng, &mut segs), + 6 => go(o, (o.0, h), depth, &cfg, &mut rng), // Two-Way Strike: from both ends toward the middle. 7 => { let m = ((o.0 + d.0) * 0.5, (o.1 + d.1) * 0.5); - bolt(o, m, depth.saturating_sub(1).max(1), 1.0, turb, fork, decay, &mut rng, &mut segs); - bolt(d, m, depth.saturating_sub(1).max(1), 1.0, turb, fork, decay, &mut rng, &mut segs); + go(o, m, depth.saturating_sub(1).max(1), &cfg, &mut rng); + go(d, m, depth.saturating_sub(1).max(1), &cfg, &mut rng); } // Strike. - _ => bolt(o, d, depth, 1.0, turb, fork, decay, &mut rng, &mut segs), + _ => go(o, d, depth, &cfg, &mut rng), } + let dist = |p: (f64, f64)| ((p.0 - o.0).powi(2) + (p.1 - o.1).powi(2)).sqrt(); + let far = segs.iter().map(|(_, z, _)| dist(*z)).fold(1e-9, f64::max); // Decay Main Core: the whole bolt fades with distance from the origin. if decay_main { - let far = segs.iter().map(|(_, z, _)| ((z.0 - o.0).powi(2) + (z.1 - o.1).powi(2)).sqrt()).fold(1e-9, f64::max); for (a, _, i) in &mut segs { - let t = (((a.0 - o.0).powi(2) + (a.1 - o.1).powi(2)).sqrt() / far) as f32; + let t = (dist(*a) / far) as f32; *i *= (1.0 - decay * t).max(0.0); } } + // Core Drain: the main core loses intensity along its length. + let drain = (g("expertSettings/coreDrain", 0.0) / 100.0).clamp(0.0, 1.0) as f32; + if drain > 0.0 { + for ((a, _, i), m) in segs.iter_mut().zip(&mains) { + if *m { + *i *= (1.0 - drain * (dist(*a) / far) as f32).max(0.0); + } + } + } segs } @@ -776,9 +1086,21 @@ pub fn specs() -> Vec { "Radio Waves", vec![ p("producerPoint", "Producer Point", pt(0.5, 0.5), ParamUi::Point), + p("parametersAreSetAt", "Parameters Are Set At", Value::Enum(1), popup(&["Birth", "Each Frame"])), + p("renderQuality", "Render Quality", num(4.0), slider(1.0, 10.0, 1.0, 10.0, 0)), + p("waveType", "Wave Type", Value::Enum(0), popup(&RW_TYPES)), p("polygon/sides", "Sides", num(64.0), slider(3.0, 64.0, 3.0, 64.0, 0)), p("polygon/star", "Star", Value::Bool(false), ParamUi::Checkbox), p("polygon/starDepth", "Star Depth", num(-0.3), slider(-1.0, 1.0, -1.0, 1.0, 2)), + p("imageContour/sourceLayer", "Source Layer", Value::Layer(None), ParamUi::Layer), + p("imageContour/sourceCenter", "Source Center", pt(0.5, 0.5), ParamUi::Point), + p("imageContour/valueChannel", "Value Channel", Value::Enum(4), popup(&RW_CHANNELS)), + p("imageContour/invertInput", "Invert Input", Value::Bool(false), ParamUi::Checkbox), + p("imageContour/valueThreshold", "Value Threshold", num(128.0), slider(0.0, 255.0, 0.0, 255.0, 0)), + p("imageContour/preBlur", "Pre-Blur", num(1.0), slider(0.0, 100.0, 0.0, 20.0, 1)), + p("imageContour/tolerance", "Tolerance", num(1.0), slider(0.0, 100.0, 0.0, 20.0, 1)), + p("imageContour/contour", "Contour", num(0.0), slider(0.0, 100.0, 0.0, 10.0, 0)), + p("waveMask/mask", "Mask", num(0.0), ParamUi::Mask), p("waveMotion/frequency", "Frequency", num(1.0), slider(0.01, 100.0, 0.01, 10.0, 2)), p("waveMotion/expansion", "Expansion", num(100.0), slider(0.0, 10000.0, 0.0, 1000.0, 1)), p("waveMotion/orientation", "Orientation", num(0.0), ang()), @@ -786,6 +1108,8 @@ pub fn specs() -> Vec { p("waveMotion/velocity", "Velocity", num(0.0), slider(0.0, 10000.0, 0.0, 1000.0, 1)), p("waveMotion/spin", "Spin", num(0.0), slider(-3600.0, 3600.0, -360.0, 360.0, 1)), p("waveMotion/lifespan", "Lifespan (sec)", num(2.0), slider(0.01, 100.0, 0.01, 10.0, 2)), + p("waveMotion/reflection", "Reflection", Value::Bool(false), ParamUi::Checkbox), + p("waveStroke/profile", "Profile", Value::Enum(0), popup(&RW_PROFILES)), p("waveStroke/color", "Color", col(1.0, 1.0, 1.0), ParamUi::Color), p("waveStroke/opacity", "Opacity", num(100.0), pct()), p("waveStroke/fadeInTime", "Fade-in Time", num(0.0), slider(0.0, 100.0, 0.0, 5.0, 2)), @@ -813,8 +1137,16 @@ pub fn specs() -> Vec { p("forking", "Forking", num(25.0), pct()), p("decay", "Decay", num(0.3), slider(0.0, 1.0, 0.0, 1.0, 2)), p("decayMainCore", "Decay Main Core", Value::Bool(false), ParamUi::Checkbox), + p("alphaObstacle", "Alpha Obstacle", num(0.0), slider(-10.0, 10.0, -10.0, 10.0, 2)), p("compositeOnOriginal", "Composite On Original", Value::Bool(true), ParamUi::Checkbox), p("expertSettings/complexity", "Complexity", num(6.0), slider(1.0, 12.0, 1.0, 12.0, 0)), + p("expertSettings/minForkDistance", "Min Fork Distance", num(0.0), slider(0.0, 1000.0, 0.0, 100.0, 1)), + p("expertSettings/terminationThreshold", "Termination Threshold", num(5.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("expertSettings/mainCoreCollisionOnly", "Main Core Collision Only", Value::Bool(false), ParamUi::Checkbox), + p("expertSettings/fractalType", "Fractal Type", Value::Enum(0), popup(&FRACTAL_TYPES)), + p("expertSettings/coreDrain", "Core Drain", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("expertSettings/forkStrength", "Fork Strength", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("expertSettings/forkVariation", "Fork Variation", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)), ], advanced_lightning, ), @@ -972,6 +1304,43 @@ mod tests { assert!(o.img.get(46, 18)[3] < 0.01, "no circle there"); } + #[test] + fn lightning_alpha_obstacle_and_expert_settings() { + let sum = |i: &Image| i.data.iter().map(|p| p[3]).sum::(); + let alpha_in = |img: &Image, src: &Image| img.data.iter().zip(&src.data).filter(|(_, s)| s[3] > 0.5).map(|(p, _)| p[3]).sum::(); + // A wall of opaque pixels across the middle of the bolt's path. + let mut wall = Image::new(64, 48); + for y in 0..48 { + for x in 0..64 { + if (18..30).contains(&y) && x < 50 { + wall.set(x, y, [0.0, 0.0, 0.0, 1.0]); + } + } + } + let opts = |o: f64| { + vec![("alphaObstacle", num(o)), ("compositeOnOriginal", Value::Bool(false)), ("glowSettings/glowRadius", num(0.0)), ("forking", num(60.0))] + }; + let free = run("ec.generate.advancedlightning", &opts(0.0), wall.clone()); + let avoid = run("ec.generate.advancedlightning", &opts(10.0), wall.clone()); + assert!(alpha_in(&avoid.img, &wall) < alpha_in(&free.img, &wall), "{} vs {}", alpha_in(&avoid.img, &wall), alpha_in(&free.img, &wall)); + // Fractal Type, Core Drain, Fork Strength, Termination Threshold, Min Fork Distance. + let base = run("ec.generate.advancedlightning", &[], Image::new(64, 48)); + for (id, v) in [ + ("expertSettings/fractalType", Value::Enum(2)), + ("expertSettings/coreDrain", num(100.0)), + ("expertSettings/forkStrength", num(100.0)), + ("expertSettings/forkVariation", num(100.0)), + ] { + assert_ne!(run("ec.generate.advancedlightning", &[(id, v)], Image::new(64, 48)).img, base.img, "{id}"); + } + let drained = run("ec.generate.advancedlightning", &[("expertSettings/coreDrain", num(100.0))], Image::new(64, 48)); + assert!(sum(&drained.img) < sum(&base.img)); + let no_forks = run("ec.generate.advancedlightning", &[("expertSettings/minForkDistance", num(1000.0))], Image::new(64, 48)); + let term = run("ec.generate.advancedlightning", &[("expertSettings/terminationThreshold", num(100.0))], Image::new(64, 48)); + assert_eq!(no_forks.img, term.img, "no forks either way"); + assert!(sum(&no_forks.img) < sum(&base.img)); + } + #[test] fn lightning_types_and_decay_main_core() { let a = run("ec.generate.advancedlightning", &[], Image::new(64, 48)); @@ -1014,4 +1383,130 @@ mod tests { assert!(c > o.img.get(60, 4)[0]); assert!(c > 0.5); } + + fn rw(set: &[(&str, Value)], t: f64, env: crate::EffectEnv) -> Image { + let mut v: Vec<(&str, Value)> = + vec![("producerPoint", Value::Vec2([32.0, 32.0])), ("waveMotion/frequency", num(0.5)), ("waveMotion/expansion", num(100.0))]; + v.extend_from_slice(set); + crate::run_fx("ec.generate.radiowaves", &v, Image::new(64, 64), t, env).img + } + + #[test] + fn signed_distance_of_a_square() { + let (w, h) = (20, 20); + let inside: Vec = (0..w * h).map(|i| (5..15).contains(&(i % w)) && (5..15).contains(&(i / w))).collect(); + let d = crate::util::signed_distance(&inside, w, h); + assert!((d.get(10, 10) + 4.5).abs() < 1e-4, "{}", d.get(10, 10)); + assert!((d.get(0, 10) - 4.5).abs() < 1e-4, "{}", d.get(0, 10)); + assert!((d.get(0, 0) - (50f32.sqrt() - 0.5)).abs() < 1e-4); + } + + #[test] + fn radio_waves_mask_contours_profile_reflection_and_birth() { + // Mask waves: a 10×10 square mask; after 0.1 s (10 px) the wave is 10 px outside it. + let sq = crate::MaskShape { name: String::new(), points: vec![[27.0, 27.0], [37.0, 27.0], [37.0, 37.0], [27.0, 37.0]], closed: true, inverted: false }; + let masks = [sq]; + let env = crate::EffectEnv { masks: &masks, ..Default::default() }; + let m = rw(&[("waveType", Value::Enum(2)), ("waveMask/mask", num(1.0))], 0.1, env); + assert!(m.get(47, 32)[3] > 0.9, "{:?}", m.get(47, 32)); + assert!(m.get(32, 32)[3] < 0.01 && m.get(40, 32)[3] < 0.01); + // Rounded corner: diagonal distance 10 from (37, 37). + assert!(m.get(44, 44)[3] > 0.5, "{:?}", m.get(44, 44)); + // No mask chosen: nothing drawn. + assert!(rw(&[("waveType", Value::Enum(2))], 0.1, env).data.iter().all(|p| p[3] == 0.0)); + // Image Contours: the bright square of a source layer. + struct Src; + impl crate::EffectHost for Src { + fn layer(&self, _: u64, _: bool) -> Option { + let mut img = Image::new(64, 64); + for y in 27..37 { + for x in 27..37 { + img.set(x, y, [1.0, 1.0, 1.0, 1.0]); + } + } + Some(crate::LayerPixels { buf: Buf { img, offset: [0.0; 2], scale: 1.0 }, size: [64.0, 64.0] }) + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + fn params_at(&self, t: f64) -> Option { + // Colour animates from red (born at 0) to green (born later). + let s = crate::find("ec.generate.radiowaves").unwrap(); + let mut p = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + p.values.insert("producerPoint".into(), Value::Vec2([32.0, 32.0])); + p.values.insert("waveMotion/frequency".into(), num(1.0)); + p.values.insert("waveMotion/expansion".into(), num(20.0)); + p.values.insert("waveStroke/color".into(), if t < 0.5 { col(1.0, 0.0, 0.0) } else { col(0.0, 1.0, 0.0) }); + Some(p) + } + } + let env = crate::EffectEnv { host: Some(&Src), ..Default::default() }; + let ic = rw( + &[ + ("waveType", Value::Enum(1)), + ("imageContour/sourceLayer", Value::Layer(Some(1))), + ("imageContour/preBlur", num(0.0)), + ("imageContour/tolerance", num(0.0)), + ], + 0.1, + env, + ); + assert!(ic.get(47, 32)[3] > 0.9 && ic.get(32, 32)[3] < 0.01, "{:?}", ic.get(47, 32)); + // Source Center off the square shifts the contour onto the producer point accordingly. + let sh = rw( + &[ + ("waveType", Value::Enum(1)), + ("imageContour/sourceLayer", Value::Layer(Some(1))), + ("imageContour/sourceCenter", Value::Vec2([22.0, 32.0])), + ("imageContour/preBlur", num(0.0)), + ("imageContour/tolerance", num(0.0)), + ], + 0.1, + env, + ); + assert!(sh.get(57, 32)[3] > 0.9, "{:?}", sh.get(57, 32)); + // Profile: Taper Both thins the stroke edges; Square is solid across. + let wide = [("waveStroke/startWidth", num(10.0)), ("waveStroke/endWidth", num(10.0))]; + let sqr = rw(&wide, 0.2, crate::EffectEnv::default()); + let mut tap = wide.to_vec(); + tap.push(("waveStroke/profile", Value::Enum(3))); + let tap = rw(&tap, 0.2, crate::EffectEnv::default()); + assert!(sqr.get(55, 32)[3] > 0.9 && tap.get(55, 32)[3] < 0.6, "{:?} {:?}", sqr.get(55, 32), tap.get(55, 32)); + assert!(tap.get(52, 32)[3] > 0.8); + // Reflection: a fast wave bounces back off the right edge. + let fast = [("waveMotion/velocity", num(200.0)), ("waveMotion/expansion", num(10.0))]; + let gone = rw(&fast, 0.3, crate::EffectEnv::default()); + let mut rf = fast.to_vec(); + rf.push(("waveMotion/reflection", Value::Bool(true))); + let back = rw(&rf, 0.3, crate::EffectEnv::default()); + let sum = |i: &Image| i.data.iter().map(|p| p[3]).sum::(); + assert!(sum(&back) > sum(&gone) + 5.0, "{} vs {}", sum(&back), sum(&gone)); + // Parameters Are Set At Birth: the older wave keeps red, the newer one is green. + let both = rw(&[("parametersAreSetAt", Value::Enum(0)), ("waveMotion/frequency", num(1.0)), ("waveMotion/expansion", num(20.0))], 1.15, env); + let (old, new) = (both.get(55, 32), both.get(35, 32)); + assert!(old[0] > 0.9 && old[1] < 0.1, "{old:?}"); + assert!(new[1] > 0.9 && new[0] < 0.1, "{new:?}"); + } + + #[test] + fn cell_pattern_hq_variants_antialias() { + let s = crate::find("ec.generate.cellpattern").unwrap(); + let r = |pat: u32| { + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + params.values.insert("cellPattern".into(), Value::Enum(pat)); + params.values.insert("size".into(), num(12.0)); + let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [48.0, 48.0], seed: 1, adjustment: false, env: Default::default() }; + crate::apply(s, &ctx, Buf { img: Image::new(48, 48), offset: [0.0; 2], scale: 1.0 }).img + }; + let distinct = |img: &Image| { + let mut v: Vec = img.data.iter().map(|p| (p[0] * 10000.0) as u32).collect(); + v.sort_unstable(); + v.dedup(); + v.len() + }; + let (std, hq) = (r(4), r(9)); + assert_eq!(CELL_PATTERNS[9], "Crystallize HQ"); + assert_ne!(std, hq); + assert!(distinct(&hq) > distinct(&std) * 2, "{} vs {}", distinct(&hq), distinct(&std)); + } } diff --git a/crates/effects/src/generate3.rs b/crates/effects/src/generate3.rs index 658ce04c..fe0b14a2 100644 --- a/crates/effects/src/generate3.rs +++ b/crates/effects/src/generate3.rs @@ -466,6 +466,141 @@ fn scan_runs(polys: &[(Vec<[f64; 2]>, bool, bool)], fill: u32, ew: f64, d: [f64; *out = merged; } +/// End Cap and Join options (Scribble's Edge Options). +const END_CAPS: [&str; 3] = ["Round", "Butt", "Projecting"]; +const JOINS: [&str; 3] = ["Round", "Miter", "Bevel"]; + +/// Convex polygons whose union is the stroke of the polylines `lines` with half width `r`: +/// one quad per segment, plus the joins (Round discs, Miter wedges up to `miter_limit` × the +/// half width, Bevel triangles) and the caps (Round discs, Butt none, Projecting square ends). +pub(crate) fn stroke_outline(lines: &[Vec<[f64; 2]>], r: f64, cap: u32, join: u32, miter_limit: f64) -> Vec> { + let disc = |c: [f64; 2]| -> Vec<[f64; 2]> { + (0..16).map(|k| (k as f64 / 16.0 * std::f64::consts::TAU).sin_cos()).map(|(s, co)| [c[0] + co * r, c[1] + s * r]).collect() + }; + let mut out = Vec::new(); + for l in lines { + let pts: Vec<[f64; 2]> = l.iter().copied().fold(Vec::new(), |mut v, p| { + if v.last().is_none_or(|q: &[f64; 2]| (q[0] - p[0]).hypot(q[1] - p[1]) > 1e-9) { + v.push(p); + } + v + }); + if pts.len() < 2 { + if let (Some(p), 0) = (pts.first(), cap) { + out.push(disc(*p)); + } + continue; + } + let dir = |a: [f64; 2], b: [f64; 2]| { + let (dx, dy) = (b[0] - a[0], b[1] - a[1]); + let l = dx.hypot(dy).max(1e-12); + [dx / l, dy / l] + }; + let n = pts.len(); + for k in 0..n - 1 { + let (mut a, mut b) = (pts[k], pts[k + 1]); + let d = dir(a, b); + // Projecting caps extend the end segments by the half width. + if cap == 2 && k == 0 { + a = [a[0] - d[0] * r, a[1] - d[1] * r]; + } + if cap == 2 && k == n - 2 { + b = [b[0] + d[0] * r, b[1] + d[1] * r]; + } + let nn = [-d[1] * r, d[0] * r]; + out.push(vec![[a[0] + nn[0], a[1] + nn[1]], [b[0] + nn[0], b[1] + nn[1]], [b[0] - nn[0], b[1] - nn[1]], [a[0] - nn[0], a[1] - nn[1]]]); + } + // Joins. + for k in 1..n - 1 { + let (p, d0, d1) = (pts[k], dir(pts[k - 1], pts[k]), dir(pts[k], pts[k + 1])); + let cross = d0[0] * d1[1] - d0[1] * d1[0]; + if cross.abs() < 1e-9 && d0[0] * d1[0] + d0[1] * d1[1] > 0.0 { + continue; + } + if join == 0 { + out.push(disc(p)); + continue; + } + // The outer side of the turn. + let s = if cross > 0.0 { -1.0 } else { 1.0 }; + let o0 = [p[0] - d0[1] * r * s, p[1] + d0[0] * r * s]; + let o1 = [p[0] - d1[1] * r * s, p[1] + d1[0] * r * s]; + let bevel = vec![p, o0, o1]; + if join == 1 { + // Miter point: where the two offset edges meet. + let bis = [d0[0] - d1[0], d0[1] - d1[1]]; + let bl = bis[0].hypot(bis[1]); + let cos_half = ((1.0 + d0[0] * d1[0] + d0[1] * d1[1]) * 0.5).max(0.0).sqrt(); + let len = if cos_half > 1e-6 { r / cos_half } else { f64::INFINITY }; + if bl > 1e-9 && len <= miter_limit * r { + let m = [p[0] + (o0[0] + o1[0] - 2.0 * p[0]) * 0.5, p[1] + (o0[1] + o1[1] - 2.0 * p[1]) * 0.5]; + let ml = (m[0] - p[0]).hypot(m[1] - p[1]).max(1e-12); + let tip = [p[0] + (m[0] - p[0]) / ml * len, p[1] + (m[1] - p[1]) / ml * len]; + out.push(vec![p, o0, tip, o1]); + continue; + } + } + out.push(bevel); + } + if cap == 0 { + out.push(disc(pts[0])); + out.push(disc(pts[n - 1])); + } + } + out +} + +/// Coverage (union, 4×4 samples per pixel) of convex polygons on a `w`×`h` grid. +pub(crate) fn raster_convex(w: usize, h: usize, polys: &[Vec<[f64; 2]>]) -> Plane { + let mut out = Plane::new(w, h); + let inside = |poly: &[[f64; 2]], x: f64, y: f64| { + let n = poly.len(); + let mut sign = 0.0; + for i in 0..n { + let (a, b) = (poly[i], poly[(i + 1) % n]); + let c = (b[0] - a[0]) * (y - a[1]) - (b[1] - a[1]) * (x - a[0]); + if c.abs() < 1e-12 { + continue; + } + if sign == 0.0 { + sign = c.signum(); + } else if c.signum() != sign { + return false; + } + } + true + }; + for poly in polys { + if poly.len() < 3 { + continue; + } + let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY); + for p in poly { + x0 = x0.min(p[0]); + y0 = y0.min(p[1]); + x1 = x1.max(p[0]); + y1 = y1.max(p[1]); + } + let (xa, xb) = (x0.floor().max(0.0) as usize, (x1.ceil().max(0.0) as usize).min(w)); + let (ya, yb) = (y0.floor().max(0.0) as usize, (y1.ceil().max(0.0) as usize).min(h)); + for y in ya..yb { + for x in xa..xb { + let mut hit = 0; + for sy in 0..4 { + for sx in 0..4 { + if inside(poly, x as f64 + (sx as f64 + 0.5) / 4.0, y as f64 + (sy as f64 + 0.5) / 4.0) { + hit += 1; + } + } + } + let v = &mut out.data[y * w + x]; + *v = v.max(hit as f32 / 16.0); + } + } + } + out +} + fn scribble(ctx: &EffectCtx, mut b: Buf) -> Buf { let pr = ctx.params; let which = pr.e("scribble"); @@ -585,19 +720,33 @@ fn scribble(ctx: &EffectCtx, mut b: Buf) -> Buf { 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(); + // Start / End Apply To: all of a mask's strokes as one path, or each stroke on its own. + let each = pr.e("startEndApplyTo") == 1; + let mut parts: Vec> = 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)); + let part = + if each { trim_poly(l, false, len * start, len * end) } else { 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); + parts.push(part); } } - let cov = raster_segs(w, h, &segs, 0.9); + let cap = pr.e("edgeOptions/endCap"); + let join = pr.e("edgeOptions/join"); + let cov = if cap == 0 && join == 0 { + let mut segs = Vec::new(); + for part in &parts { + poly_segs(part, false, r, 1.0, &mut segs); + } + raster_segs(w, h, &segs, 0.9) + } else { + let polys = stroke_outline(&parts, r, cap, join, pr.f("edgeOptions/miterLimit").max(1.0)); + raster_convex(w, h, &polys) + }; let color = pr.color("color"); paint(&mut b.img, &cov, color, (pr.f("opacity") / 100.0) as f32, style); b @@ -1467,6 +1616,9 @@ pub fn specs() -> Vec { 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("edgeOptions/edgeWidth", "Edge Width", num(10.0), slider(0.0, 1000.0, 0.0, 100.0, 1)), + p("edgeOptions/endCap", "End Cap", Value::Enum(0), popup(&END_CAPS)), + p("edgeOptions/join", "Join", Value::Enum(0), popup(&JOINS)), + p("edgeOptions/miterLimit", "Miter Limit", num(4.0), slider(1.0, 100.0, 1.0, 20.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()), @@ -1479,6 +1631,7 @@ pub fn specs() -> Vec { p("strokeOptions/pathOverlapVariation", "Path Overlap Variation", num(50.0), pct()), p("start", "Start", num(0.0), pct()), p("end", "End", num(100.0), pct()), + p("startEndApplyTo", "Start/End Apply To", Value::Enum(0), popup(&["All Strokes", "Each Stroke"])), 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)), @@ -1936,4 +2089,49 @@ mod tests { let peak = mags.iter().enumerate().max_by(|a, b| a.1.total_cmp(b.1)).unwrap().0; assert_eq!(peak, 8); } + + #[test] + fn stroke_outline_caps_and_joins() { + let line = vec![vec![[10.0, 10.0], [30.0, 10.0]]]; + let cov = |cap: u32| raster_convex(40, 20, &stroke_outline(&line, 3.0, cap, 0, 4.0)); + // Round caps reach 3 px past the ends (on the axis), Butt stops there, Projecting is square. + let (round, butt, proj) = (cov(0), cov(1), cov(2)); + assert!(round.get(8, 10) > 0.9 && butt.get(8, 10) < 0.1 && proj.get(8, 10) > 0.9); + assert!(proj.get(7, 7) > 0.9 && round.get(7, 7) < 0.5, "square corner"); + // A right-angle turn: Miter fills the outer corner, Bevel cuts it. + let corner = vec![vec![[5.0, 30.0], [30.0, 30.0], [30.0, 5.0]]]; + let j = |join: u32| raster_convex(40, 40, &stroke_outline(&corner, 3.0, 1, join, 4.0)); + let (miter, bevel) = (j(1), j(2)); + assert!(miter.get(32, 32) > 0.9 && bevel.get(32, 32) < 0.1, "{} {}", miter.get(32, 32), bevel.get(32, 32)); + // A tight miter limit falls back to a bevel. + let limited = raster_convex(40, 40, &stroke_outline(&corner, 3.0, 1, 1, 1.0)); + assert!(limited.get(32, 32) < 0.1); + } + + #[test] + fn scribble_caps_and_start_end_apply_to() { + let masks = square_mask(); + let env = EffectEnv { masks: &masks, ..Default::default() }; + let img = Image::new(64, 64); + let base = [("wiggleType", Value::Enum(0)), ("strokeWidth", num(3.0))]; + let round = run_env("ec.generate.scribble", &base, img.clone(), 0.0, env); + let butt = run_env( + "ec.generate.scribble", + &[base.as_slice(), &[("edgeOptions/endCap", Value::Enum(1)), ("edgeOptions/join", Value::Enum(2))]].concat(), + img.clone(), + 0.0, + env, + ); + assert_ne!(round.img.data, butt.img.data); + // Each Stroke: every stroke is trimmed on its own, so the half-way image has ink all over. + let two = vec![ + crate::MaskShape { name: String::new(), points: vec![[4.0, 4.0], [28.0, 4.0], [28.0, 60.0], [4.0, 60.0]], closed: true, inverted: false }, + crate::MaskShape { name: String::new(), points: vec![[36.0, 4.0], [60.0, 4.0], [60.0, 60.0], [36.0, 60.0]], closed: true, inverted: false }, + ]; + let env2 = EffectEnv { masks: &two, ..Default::default() }; + let seq = [("scribble", Value::Enum(1)), ("end", num(50.0))]; + let all = run_env("ec.generate.scribble", &[base.as_slice(), &seq].concat(), img.clone(), 0.0, env2); + let each = run_env("ec.generate.scribble", &[base.as_slice(), &seq, &[("startEndApplyTo", Value::Enum(1))]].concat(), img, 0.0, env2); + assert_ne!(all.img.data, each.img.data); + } } diff --git a/crates/effects/src/keying.rs b/crates/effects/src/keying.rs index 6ec88641..9d61ff8c 100644 --- a/crates/effects/src/keying.rs +++ b/crates/effects/src/keying.rs @@ -496,14 +496,36 @@ fn advanced_spill(ctx: &EffectCtx, mut b: Buf) -> Buf { let amt = ctx.params.f("suppression") as f32 / 100.0; let range = ctx.params.f("ultraSettings/spillRange") as f32 / 100.0; let luma = ctx.params.f("ultraSettings/lumaCorrection") as f32 / 100.0; + // Ultra: Tolerance limits suppression to hues near the key colour; Desaturate takes the + // colour out of suppressed pixels; Spill Color Correction puts the removed spill back as + // neutral light (so suppressed areas keep their brightness without the cast). + let key = ctx.params.color("ultraSettings/keyColor"); + let key_hue = effectcraft_color::rgb_to_hsl(key[0], key[1], key[2]).0; + let tol = if ultra { ctx.params.get("ultraSettings/tolerance").map(Value::as_f64).unwrap_or(100.0) as f32 / 100.0 } else { 1.0 }; + let desat = if ultra { ctx.params.f("ultraSettings/desaturate") as f32 / 100.0 } else { 0.0 }; + let neutral = if ultra { ctx.params.f("ultraSettings/spillColorCorrection") as f32 / 100.0 } else { 0.0 }; b.img.map_straight(|mut c| { + let w = if tol < 1.0 { + let h = effectcraft_color::rgb_to_hsl(c[0], c[1], c[2]).0; + let d = (h - key_hue).abs(); + let d = d.min(1.0 - d); + (1.0 - d / (tol * 0.5).max(1e-4)).clamp(0.0, 1.0) + } else { + 1.0 + }; let mx = c[o1].max(c[o2]); let avg = 0.5 * (c[o1] + c[o2]); let limit = mx + (avg - mx) * range; - let spill = (c[pi] - limit).max(0.0) * amt; + let spill = (c[pi] - limit).max(0.0) * amt * w; c[pi] -= spill; - let back = spill * LUMA_W[pi] * luma; - c.map(|v| v + back) + let back = spill * LUMA_W[pi] * luma + spill * neutral / 3.0; + let mut c = c.map(|v| v + back); + if desat > 0.0 && spill > 0.0 { + let l = effectcraft_color::luminance(c[0], c[1], c[2]); + let k = (desat * (spill * 4.0).min(1.0)).min(1.0); + c = c.map(|v| v + (l - v) * k); + } + c }); b } @@ -675,7 +697,10 @@ pub fn specs() -> Vec { p("method", "Method", Value::Enum(0), popup(&["Standard", "Ultra"])), p("suppression", "Suppression", num(100.0), slider(0.0, 200.0, 0.0, 100.0, 0)), p("ultraSettings/keyColor", "Key Color", col(0.0, 1.0, 0.0), ParamUi::Color), + p("ultraSettings/tolerance", "Tolerance", num(50.0), pct()), + p("ultraSettings/desaturate", "Desaturate", num(0.0), pct()), p("ultraSettings/spillRange", "Spill Range", num(50.0), pct()), + p("ultraSettings/spillColorCorrection", "Spill Color Correction", num(0.0), pct()), p("ultraSettings/lumaCorrection", "Luma Correction", num(0.0), pct()), ], advanced_spill, @@ -846,4 +871,26 @@ mod tests { let out = run("ec.key.keycleaner", &[], img.clone()); assert_eq!(out.get(12, 2), img.get(12, 2)); } + + #[test] + fn advanced_spill_ultra_tolerance_desaturate_and_color_correction() { + let ultra = |extra: &[(&str, Value)], c: [f32; 4]| { + let mut v = vec![("method", Value::Enum(1)), ("ultraSettings/keyColor", Value::Color([0.0, 1.0, 0.0, 1.0]))]; + v.extend_from_slice(extra); + run("ec.key.advancedspill", &v, Image::filled(1, 1, c)).get(0, 0) + }; + // A yellow-green pixel (hue away from the key) is left alone with a narrow tolerance. + let yg = [0.6, 0.8, 0.1, 1.0]; + assert!((ultra(&[("ultraSettings/tolerance", num(5.0))], yg)[1] - 0.8).abs() < 1e-5); + assert!(ultra(&[("ultraSettings/tolerance", num(100.0))], yg)[1] < 0.79); + // Desaturate greys the suppressed pixel. + let g = [0.3, 0.8, 0.3, 1.0]; + let d = ultra(&[("ultraSettings/desaturate", num(100.0))], g); + assert!((d[0] - d[1]).abs() < 0.05, "{d:?}"); + // Spill Color Correction keeps the brightness the suppression removed. + let plain = ultra(&[], g); + let cc = ultra(&[("ultraSettings/spillColorCorrection", num(100.0))], g); + let sum = |p: [f32; 4]| p[0] + p[1] + p[2]; + assert!(sum(cc) > sum(plain) + 0.1 && (sum(cc) - sum(g)).abs() < 1e-4, "{plain:?} {cc:?}"); + } } diff --git a/crates/effects/src/keying2.rs b/crates/effects/src/keying2.rs index 41ed1fc0..4467318b 100644 --- a/crates/effects/src/keying2.rs +++ b/crates/effects/src/keying2.rs @@ -102,8 +102,11 @@ fn mask_sdf(ctx: &EffectCtx, b: &Buf, idx: &[u32]) -> Option { } 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")]; + // Foreground (Inside) plus Additional Foreground 1–10; likewise for the background. + let fg_idx: Vec = + std::iter::once(ctx.params.e("foreground")).chain((1..=10).map(|k| ctx.params.e(&format!("additionalForeground/foreground{k}")))).collect(); + let bg_idx: Vec = + std::iter::once(ctx.params.e("background")).chain((1..=10).map(|k| ctx.params.e(&format!("additionalBackground/background{k}")))).collect(); 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; @@ -187,6 +190,26 @@ fn inner_outer_key(ctx: &EffectCtx, mut b: Buf) -> Buf { .collect(), ); } + // Cleanup Foreground / Background: brush strokes along mask paths that paint the matte + // towards opaque / transparent (Brush Radius, Brush Pressure). + for (side, target) in [("Foreground", 1.0f32), ("Background", 0.0)] { + for k in 1..=10 { + let g = format!("cleanup{side}/cleanup{side}{k}"); + let idx = ctx.params.e(&format!("{g}/path")); + let Some(m) = (idx > 0).then(|| ctx.env.masks.get(idx as usize - 1)).flatten() else { continue }; + let rad = (ctx.params.f(&format!("{g}/brushRadius")) * b.scale).max(0.5); + let pressure = (ctx.params.f(&format!("{g}/brushPressure")) / 100.0).clamp(0.0, 1.0) as f32; + let pts: Vec<[f64; 2]> = m.points.iter().map(|q| [q[0] * b.scale + b.offset[0], q[1] * b.scale + b.offset[1]]).collect(); + alpha.data.par_iter_mut().enumerate().for_each(|(i, a)| { + let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5); + let d = dist_to_poly(&pts, m.closed, x, y); + if d < rad + 1.0 { + let cov = ((rad - d) / rad.max(1.0) * 2.0).clamp(0.0, 1.0) as f32 * pressure; + *a += (target - *a) * cov; + } + }); + } + } if thin.abs() > 0.01 { alpha = morph_frac(&alpha, thin.abs(), thin < 0.0); } @@ -332,18 +355,34 @@ pub fn specs() -> Vec { 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()), - ], + { + let leak = |s: String| -> &'static str { Box::leak(s.into_boxed_str()) }; + let mut v = vec![p("foreground", "Foreground (Inside)", Value::Enum(0), mask_options())]; + for k in 1..=10 { + v.push(p(leak(format!("additionalForeground/foreground{k}")), leak(format!("Foreground {k}")), Value::Enum(0), mask_options())); + } + v.push(p("background", "Background (Outside)", Value::Enum(0), mask_options())); + for k in 1..=10 { + v.push(p(leak(format!("additionalBackground/background{k}")), leak(format!("Background {k}")), Value::Enum(0), mask_options())); + } + for side in ["Foreground", "Background"] { + for k in 1..=10 { + let g = format!("cleanup{side}/cleanup{side}{k}"); + v.push(p(leak(format!("{g}/path")), "Path", Value::Enum(0), mask_options())); + v.push(p(leak(format!("{g}/brushRadius")), "Brush Radius", num(10.0), slider(0.0, 1000.0, 0.0, 100.0, 1))); + v.push(p(leak(format!("{g}/brushPressure")), "Brush Pressure", num(80.0), pct())); + } + } + v.extend([ + 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()), + ]); + v + }, inner_outer_key, ), spec( @@ -478,4 +517,47 @@ mod tests { assert_eq!(out.img.get(2, 10), img.get(2, 10)); } } + + #[test] + fn inner_outer_key_more_masks_and_cleanup_strokes() { + let sq = |x0: f64, x1: f64| MaskShape { name: String::new(), points: vec![[x0, 2.0], [x1, 2.0], [x1, 8.0], [x0, 8.0]], closed: true, inverted: false }; + // Masks 1..4: foreground squares; mask 5: a horizontal cleanup stroke across the middle. + let masks = vec![ + sq(1.0, 5.0), + sq(7.0, 11.0), + sq(13.0, 17.0), + sq(19.0, 23.0), + MaskShape { name: String::new(), points: vec![[0.0, 5.0], [30.0, 5.0]], closed: false, inverted: false }, + ]; + let env = EffectEnv { masks: &masks, ..Default::default() }; + let img = Image::filled(30, 10, [0.5, 0.5, 0.5, 1.0]); + let vals = [ + ("foreground", Value::Enum(1)), + ("additionalForeground/foreground1", Value::Enum(2)), + ("additionalForeground/foreground2", Value::Enum(3)), + ("additionalForeground/foreground5", Value::Enum(4)), + ]; + let out = run_fx("ec.key.innerouter", &vals, img.clone(), 0.0, env).img; + for x in [3, 9, 15, 21] { + assert_eq!(out.get(x, 3)[3], 1.0, "foreground square at {x}"); + } + assert_eq!(out.get(27, 3)[3], 0.0); + // Cleanup Background along mask 5 erases a band; Cleanup Foreground restores outside. + let mut bg = vals.to_vec(); + bg.extend([ + ("cleanupBackground/cleanupBackground1/path", Value::Enum(5)), + ("cleanupBackground/cleanupBackground1/brushRadius", num(1.5)), + ("cleanupBackground/cleanupBackground1/brushPressure", num(100.0)), + ]); + let erased = run_fx("ec.key.innerouter", &bg, img.clone(), 0.0, env).img; + assert!(erased.get(3, 5)[3] < 0.1 && erased.get(3, 2)[3] > 0.9); + let mut fg = vals.to_vec(); + fg.extend([ + ("cleanupForeground/cleanupForeground3/path", Value::Enum(5)), + ("cleanupForeground/cleanupForeground3/brushRadius", num(1.5)), + ("cleanupForeground/cleanupForeground3/brushPressure", num(100.0)), + ]); + let painted = run_fx("ec.key.innerouter", &fg, img, 0.0, env).img; + assert!(painted.get(27, 5)[3] > 0.9 && painted.get(27, 2)[3] < 0.1); + } } diff --git a/crates/effects/src/keylight.rs b/crates/effects/src/keylight.rs index 96a4483a..4ccfea42 100644 --- a/crates/effects/src/keylight.rs +++ b/crates/effects/src/keylight.rs @@ -126,6 +126,109 @@ fn correct(c: [f32; 3], sat: f32, contrast: f32, bright: f32) -> [f32; 3] { c.map(|v| ((v - 0.5) * contrast + 0.5) * bright) } +/// Colour Suppression choices. +const SUPPRESS: [&str; 7] = ["None", "Red", "Green", "Blue", "Cyan", "Magenta", "Yellow"]; +/// Source Crops' X / Y Method choices. +const CROP_METHODS: [&str; 4] = ["Colour", "Repeat", "Reflect", "Wrap"]; + +/// (colour, balance 0..1, amount 0..1) of a Colour Suppression setting. +fn suppression(ctx: &EffectCtx, kind: &str, bal: &str, amt: &str) -> (u32, f32, f32) { + (ctx.params.e(kind), (ctx.params.get(bal).map(Value::as_f64).unwrap_or(50.0) / 100.0) as f32, (ctx.params.f(amt) / 100.0) as f32) +} + +/// Colour Suppression: remove the excess of a primary (over a Balance-weighted mix of the other +/// two) or of a secondary (the smaller of its two primaries over the third). +fn suppress(mut c: [f32; 3], (kind, bal, amt): (u32, f32, f32)) -> [f32; 3] { + if kind == 0 || amt <= 0.0 { + return c; + } + match kind { + 1..=3 => { + let k = kind as usize - 1; + let (a, b) = ((k + 1) % 3, (k + 2) % 3); + let limit = c[a] * bal + c[b] * (1.0 - bal); + c[k] -= (c[k] - limit).max(0.0) * amt; + } + _ => { + // Cyan = G + B over R, Magenta = R + B over G, Yellow = R + G over B. + let third = match kind { + 4 => 0, + 5 => 1, + _ => 2, + }; + let (a, b) = ((third + 1) % 3, (third + 2) % 3); + let excess = (c[a].min(c[b]) - c[third]).max(0.0) * amt; + c[a] -= excess; + c[b] -= excess; + } + } + c +} + +/// (tint offset) of a Colour Balancing wheel: hue (degrees) and saturation (0..1). +fn balance(ctx: &EffectCtx, hue: &str, sat: &str) -> [f32; 3] { + let s = (ctx.params.f(sat) / 100.0) as f32; + if s <= 0.0 { + return [0.0; 3]; + } + let (r, g, b) = effectcraft_color::hsl_to_rgb((ctx.params.f(hue) / 360.0).rem_euclid(1.0) as f32, 1.0, 0.5); + let l = luminance(r, g, b); + [(r - l) * s * 0.5, (g - l) * s * 0.5, (b - l) * s * 0.5] +} + +/// Colour Balancing: shift the colour towards the wheel's hue, keeping its luminance. +fn colour_balance(c: [f32; 3], tint: [f32; 3]) -> [f32; 3] { + if tint == [0.0; 3] { + return c; + } + [c[0] + tint[0], c[1] + tint[1], c[2] + tint[2]].map(|v| v.max(0.0)) +} + +/// The layer with Source Crops applied: inside the crop the picture, outside it the Edge Colour +/// (X / Y Method Colour) or the kept picture repeated, reflected or wrapped. +fn crop_source(ctx: &EffectCtx, b: &Buf) -> Image { + let pr = ctx.params; + let crops = [pr.f("sourceCrops/cropLeft"), pr.f("sourceCrops/cropRight"), pr.f("sourceCrops/cropTop"), pr.f("sourceCrops/cropBottom")].map(|v| v / 100.0); + if crops.iter().all(|c| *c <= 0.0) { + return b.img.clone(); + } + let (lw, lh) = (ctx.layer_size[0] * b.scale, ctx.layer_size[1] * b.scale); + let (x0, x1) = (b.offset[0] + crops[0] * lw, b.offset[0] + (1.0 - crops[1]) * lw); + let (y0, y1) = (b.offset[1] + crops[2] * lh, b.offset[1] + (1.0 - crops[3]) * lh); + let methods = [pr.e("sourceCrops/xMethod"), pr.e("sourceCrops/yMethod")]; + let ec = pr.color("sourceCrops/edgeColour"); + let ea = (pr.f("sourceCrops/edgeColourAlpha") / 100.0) as f32; + if methods == [0, 0] && ea <= 0.0 { + // Transparent edge colour: the borders are cut from the result instead (keying sees + // the whole picture). + return b.img.clone(); + } + let edge = premul([ec[0], ec[1], ec[2]], ea); + // Fold a coordinate into [lo, hi) by a method; None = outside with Colour. + let fold = |v: f64, lo: f64, hi: f64, m: u32| -> Option { + let span = (hi - lo).max(1.0); + if v >= lo && v < hi { + return Some(v); + } + match m { + 1 => Some(v.clamp(lo, hi - 0.5)), + 2 => { + let t = (v - lo).rem_euclid(2.0 * span); + Some(lo + if t >= span { 2.0 * span - t - 0.5 } else { t }) + } + 3 => Some(lo + (v - lo).rem_euclid(span)), + _ => None, + } + }; + crate::util::gen_image(b.img.width, b.img.height, |x, y| { + let (fx, fy) = (x as f64 + 0.5, y as f64 + 0.5); + match (fold(fx, x0, x1, methods[0]), fold(fy, y0, y1, methods[1])) { + (Some(sx), Some(sy)) => b.img.get_clamped(sx.floor() as i64, sy.floor() as i64), + _ => edge, + } + }) +} + fn key_light(ctx: &EffectCtx, mut b: Buf) -> Buf { let pr = ctx.params; let view = pr.e("view"); @@ -151,7 +254,9 @@ fn key_light(ctx: &EffectCtx, mut b: Buf) -> Buf { let despot_b = pr.f("screenMatte/screenDespotBlack") * b.scale; let despot_w = pr.f("screenMatte/screenDespotWhite") * b.scale; - let src = b.img.clone(); + // Source Crops: the cropped borders take the Edge Colour, or repeat / reflect / wrap the + // kept picture (X Method / Y Method), before keying. + let src = crop_source(ctx, &b); let blurred; let key_src: &Image = if preblur > 0.0 { blurred = gaussian_blur(&src, preblur * 0.5, preblur * 0.5, true); @@ -247,6 +352,22 @@ fn key_light(ctx: &EffectCtx, mut b: Buf) -> Buf { pr.f("edgeColourCorrection/edgeBrightness") as f32 / 100.0 + 1.0, ); let unpremultiply = pr.b("unpremultiplyResult"); + let (x_method, y_method) = (pr.e("sourceCrops/xMethod"), pr.e("sourceCrops/yMethod")); + let edge_alpha = pr.f("sourceCrops/edgeColourAlpha") as f32 / 100.0; + let fg_sup = suppression( + ctx, + "foregroundColourCorrection/colourSuppression", + "foregroundColourCorrection/suppressionBalance", + "foregroundColourCorrection/suppressionAmount", + ); + let fg_bal = balance(ctx, "foregroundColourCorrection/colourBalanceHue", "foregroundColourCorrection/colourBalanceSaturation"); + let edge_sup = suppression( + ctx, + "edgeColourCorrection/edgeColourSuppression", + "edgeColourCorrection/edgeSuppressionBalance", + "edgeColourCorrection/edgeSuppressionAmount", + ); + let edge_bal = balance(ctx, "edgeColourCorrection/edgeColourBalanceHue", "edgeColourCorrection/edgeColourBalanceSaturation"); let crops = [pr.f("sourceCrops/cropLeft"), pr.f("sourceCrops/cropRight"), pr.f("sourceCrops/cropTop"), pr.f("sourceCrops/cropBottom")].map(|v| v / 100.0); let (lw, lh) = (ctx.layer_size[0], ctx.layer_size[1]); let w = b.img.width as usize; @@ -258,7 +379,7 @@ fn key_light(ctx: &EffectCtx, mut b: Buf) -> Buf { // Source crops (layer fractions from each edge). let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5); let (lx, ly) = ((x - off[0]) / scale / lw.max(1e-9), (y - off[1]) / scale / lh.max(1e-9)); - let cropped = lx < crops[0] || lx > 1.0 - crops[1] || ly < crops[2] || ly > 1.0 - crops[3]; + let cropped = (lx < crops[0] || lx > 1.0 - crops[1] || ly < crops[2] || ly > 1.0 - crops[3]) && edge_alpha <= 0.0 && x_method == 0 && y_method == 0; // Screen removal on the raw (unclipped) transparency, then despill. let ar = raw.data[i].max(1e-4); let mut fg = [0, 1, 2].map(|k| ((c[k] - (1.0 - ar) * screen[k]) / ar).max(0.0)); @@ -287,11 +408,11 @@ fn key_light(ctx: &EffectCtx, mut b: Buf) -> Buf { fg = apply_replace(fg, replace, rc, raised); fg = apply_replace(fg, in_replace, in_rc, raised_in); if fg_cc { - fg = correct(fg, fs, fc, fbr); + fg = colour_balance(suppress(correct(fg, fs, fc, fbr), fg_sup), fg_bal); } let edge = edges.as_ref().map_or(0.0, |e| e[i]); if edge > 0.0 { - let ce = correct(fg, es, ec, ebr); + let ce = colour_balance(suppress(correct(fg, es, ec, ebr), edge_sup), edge_bal); fg = [0, 1, 2].map(|k| fg[k] + (ce[k] - fg[k]) * edge); } let grey = |v: f32| [v, v, v, 1.0]; @@ -380,6 +501,11 @@ pub fn specs() -> Vec { p("foregroundColourCorrection/saturation", "Saturation", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)), cc("foregroundColourCorrection/contrast", "Contrast"), cc("foregroundColourCorrection/brightness", "Brightness"), + p("foregroundColourCorrection/colourSuppression", "Colour Suppression", Value::Enum(0), popup(&SUPPRESS)), + pp("foregroundColourCorrection/suppressionBalance", "Suppression Balance", pct(50.0)), + pp("foregroundColourCorrection/suppressionAmount", "Suppression Amount", pct(0.0)), + p("foregroundColourCorrection/colourBalanceHue", "Colour Balance Hue", num(0.0), ParamUi::Angle), + pp("foregroundColourCorrection/colourBalanceSaturation", "Colour Balance Saturation", pct(0.0)), // Edge Colour Correction p("edgeColourCorrection/enableEdgeColourCorrection", "Enable Edge Colour Correction", Value::Bool(false), ParamUi::Checkbox), pp("edgeColourCorrection/edgeHardness", "Edge Hardness", pct(0.0)), @@ -388,7 +514,16 @@ pub fn specs() -> Vec { p("edgeColourCorrection/edgeSaturation", "Saturation", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)), cc("edgeColourCorrection/edgeContrast", "Contrast"), cc("edgeColourCorrection/edgeBrightness", "Brightness"), + p("edgeColourCorrection/edgeColourSuppression", "Colour Suppression", Value::Enum(0), popup(&SUPPRESS)), + pp("edgeColourCorrection/edgeSuppressionBalance", "Suppression Balance", pct(50.0)), + pp("edgeColourCorrection/edgeSuppressionAmount", "Suppression Amount", pct(0.0)), + p("edgeColourCorrection/edgeColourBalanceHue", "Colour Balance Hue", num(0.0), ParamUi::Angle), + pp("edgeColourCorrection/edgeColourBalanceSaturation", "Colour Balance Saturation", pct(0.0)), // Source Crops + p("sourceCrops/xMethod", "X Method", Value::Enum(0), popup(&CROP_METHODS)), + p("sourceCrops/yMethod", "Y Method", Value::Enum(0), popup(&CROP_METHODS)), + p("sourceCrops/edgeColour", "Edge Colour", col(0.0, 0.0, 0.0), ParamUi::Color), + pp("sourceCrops/edgeColourAlpha", "Edge Colour Alpha", pct(0.0)), pp("sourceCrops/cropLeft", "Left", pct(0.0)), pp("sourceCrops/cropRight", "Right", pct(0.0)), pp("sourceCrops/cropTop", "Top", pct(0.0)), @@ -500,4 +635,44 @@ mod tests { assert_eq!(g.sub("insideMask").and_then(|s| s.get("insideMask")).map(|p| p.name.as_str()), Some("Inside Mask")); assert!(g.get("clipWhite").is_none()); } + + #[test] + fn colour_suppression_balancing_and_crop_methods() { + // Suppress Red pulls red down to the balance of green and blue; Cyan the shared G+B. + assert!((suppress([0.9, 0.3, 0.1], (1, 0.5, 1.0))[0] - 0.2).abs() < 1e-6); + let c = suppress([0.1, 0.8, 0.6], (4, 0.5, 1.0)); + assert!((c[1] - 0.3).abs() < 1e-6 && (c[2] - 0.1).abs() < 1e-6); + assert_eq!(suppress([0.9, 0.3, 0.1], (1, 0.5, 0.0)), [0.9, 0.3, 0.1]); + // Foreground colour correction with suppression / balancing changes the keyed colour. + let base = [("screenColour", green()), ("foregroundColourCorrection/enableColourCorrection", Value::Bool(true))]; + let run = |extra: &[(&str, Value)]| { + let mut v: Vec<(&str, Value)> = base.to_vec(); + v.extend_from_slice(extra); + run_fx("ec.keying.keylight", &v, plate(), 0.0, EffectEnv::default()).img.get(15, 15) + }; + let plain = run(&[]); + let sup = run(&[("foregroundColourCorrection/colourSuppression", Value::Enum(1)), ("foregroundColourCorrection/suppressionAmount", num(100.0))]); + assert!(sup[0] < plain[0] - 0.1, "{sup:?} vs {plain:?}"); + let bal = run(&[("foregroundColourCorrection/colourBalanceHue", num(240.0)), ("foregroundColourCorrection/colourBalanceSaturation", num(100.0))]); + assert!(bal[2] > plain[2] + 0.05); + // Source Crops: Colour fills the cropped border; Repeat copies the kept edge. + let crop = [("screenColour", green()), ("sourceCrops/cropLeft", num(10.0)), ("view", Value::Enum(1))]; + let filled = + run_fx("ec.keying.keylight", &[crop.as_slice(), &[("sourceCrops/edgeColourAlpha", num(100.0))]].concat(), plate(), 0.0, EffectEnv::default()).img; + assert_eq!(filled.get(1, 15)[0], 1.0, "edge colour alpha shows in the source alpha view"); + let src = crop_source( + &crate::EffectCtx { + params: &crate::Params { + values: [("sourceCrops/cropLeft".to_string(), num(25.0)), ("sourceCrops/xMethod".to_string(), Value::Enum(1))].into_iter().collect(), + }, + time: 0.0, + layer_size: [40.0, 30.0], + seed: 0, + adjustment: false, + env: Default::default(), + }, + &Buf { img: plate(), offset: [0.0; 2], scale: 1.0 }, + ); + assert_eq!(src.get(2, 15), plate().get(10, 15), "repeat"); + } } diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index c81cb8db..6f671630 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -10,6 +10,7 @@ pub mod audio_fx; mod blur2; mod blur3; pub mod camera_tracker; +mod card3d; pub mod catalog; mod channel; mod channel2; @@ -64,18 +65,22 @@ pub mod warp_stab; use std::collections::HashMap; use std::sync::OnceLock; -pub use color_fx::{exposure_settings, levels_clip}; +pub use card3d::{CompLight, CompScene}; +pub use color_fx::{ + HUESAT_CHANNELS, LEVELS_CHANNELS, exposure_settings, fill_uses_masks, huesat_ranges_identity, levels_channel_ids, levels_channels_identity, levels_clip, +}; pub use color2::Curve; +pub use distort::transform_shutter; pub use distort2::parse_mesh; use effectcraft_keyframe::Value; use effectcraft_project::build::Ids; use effectcraft_project::{GroupKind, ParamUi, PropGroup, Property}; pub use effectcraft_raster::{AuxChannels, Image}; -pub use misc::{INVERT_ALPHA, INVERT_CHANNELS}; +pub use misc::{INVERT_ALPHA, INVERT_CHANNELS, glow_operation}; pub use sim::particle_state; pub use sim3::playground_state; // CPU helpers the GPU kernels share (effectcraft-gpu). -pub use blur2::camera_lens_spans; +pub use blur2::{camera_lens_plain, camera_lens_spans}; pub use generate::gen_mode; pub use generate2::pattern_kind as cell_pattern_kind; pub use noise::GrainLook; @@ -273,6 +278,16 @@ pub trait EffectHost: Sync { fn aux(&self) -> Option> { None } + /// The composition's camera and first light relative to the effect's layer (Card + /// Dance / Shatter / Card Wipe's Comp Camera and First Comp Light). `None` when unknown. + fn comp_scene(&self) -> Option { + None + } + /// The running effect's own parameters evaluated at another **layer time** (Radio Waves' + /// Parameters Are Set At: Birth). `None` when unavailable. + fn params_at(&self, _layer_time: f64) -> Option { + None + } /// A particle simulation backend (GPU particles; see [`psim`]). `None` = simulate on the /// CPU. fn particles(&self) -> Option<&dyn psim::ParticleSim> { @@ -296,6 +311,10 @@ pub struct EffectEnv<'a> { pub working_space: Option, /// Working-space pixels are linear light (Linearize Working Space). pub working_linear: bool, + /// The composition's shutter (angle and phase in degrees, samples per frame) when both the + /// comp's and the layer's motion blur switches are on (Transform's Use Composition's + /// Shutter Angle). + pub shutter: Option<(f64, f64, u32)>, } /// What an effect gets to render with. @@ -537,6 +556,19 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[ ("toleranceGroup", "Tolerance"), ("inputPhase", "Input Phase"), ("outputCycle", "Output Cycle"), + ("modify", "Modify"), + ("pixelSelection", "Pixel Selection"), + ("masking", "Masking"), + ("details", "Details"), + ("reds", "Reds"), + ("yellows", "Yellows"), + ("greens", "Greens"), + ("cyans", "Cyans"), + ("blues", "Blues"), + ("magentas", "Magentas"), + ("whites", "Whites"), + ("neutrals", "Neutrals"), + ("blacks", "Blacks"), ("moreOptions", "More Options"), ("basicCorrection", "Basic Correction"), ("whiteBalance", "White Balance"), @@ -546,9 +578,16 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[ ("curves", "Curves"), ("colorWheels", "Color Wheels"), ("vignette", "Vignette"), + ("rgbCurves", "RGB Curves"), + ("hueSaturationCurves", "Hue Saturation Curves"), + ("hslSecondary", "HSL Secondary"), + ("key", "Key"), + ("refine", "Refine"), + ("correction", "Correction"), // Camera Lens Blur. ("irisProperties", "Iris Properties"), ("highlight", "Highlight"), + ("blurMap", "Blur Map"), // Numbers. ("format", "Format"), ("fillAndStroke", "Fill and Stroke"), @@ -569,9 +608,15 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[ ("fineTuning", "Fine Tuning"), ("unsharpMask", "Unsharp Mask"), ("noiseOptions", "Noise Options (Animation)"), + ("previewRegion", "Preview Region"), + ("sampling", "Sampling"), + ("temporalFiltering", "Temporal Filtering"), // Time: Timewarp. ("tuning", "Tuning"), ("motionBlur", "Motion Blur"), + ("smoothing", "Smoothing"), + ("weighting", "Weighting"), + ("sourceCrops", "Source Crops"), // Simulation. ("extras", "Extras"), ("wiggle", "Wiggle"), @@ -586,6 +631,7 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[ ("hairColor", "Hair Color"), ("bottom", "Bottom"), ("water", "Water"), + ("sky", "Sky"), ("lighting", "Lighting"), ("material", "Material"), ("shape", "Shape"), @@ -593,7 +639,12 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[ ("force2", "Force 2"), ("bubbles", "Bubbles"), ("rendering", "Rendering"), + ("flowMap", "Flow Map"), ("heightMapControls", "Height Map Controls"), + ("wireframeControls", "Wireframe Controls"), + ("ground", "Ground"), + ("geometric", "Geometric Distortion"), + ("target", "Target Point"), ("simulation", "Simulation"), ("producer1", "Producer 1"), ("producer2", "Producer 2"), @@ -606,6 +657,11 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[ ("xScale", "X Scale"), ("yScale", "Y Scale"), ("cameraPosition", "Camera Position"), + ("cornerPins", "Corner Pins"), + ("positionJitter", "Position Jitter"), + ("rotationJitter", "Rotation Jitter"), + ("textures", "Textures"), + ("gradient", "Gradient"), ("cannon", "Cannon"), ("grid", "Grid"), ("layerExploder", "Layer Exploder"), @@ -614,8 +670,36 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[ ("repel", "Repel"), ("wall", "Wall"), ("persistentPropertyMapper", "Persistent Property Mapper"), + ("ephemeralPropertyMapper", "Ephemeral Property Mapper"), + ("particleExploder", "Particle Exploder"), + ("affects", "Affects"), + ("options", "Options"), // Keying, Matte, Obsolete (Key Light's groups are in `keylight::GROUPS`). ("ultraSettings", "Ultra Settings"), + ("additionalForeground", "Additional Foreground"), + ("additionalBackground", "Additional Background"), + ("cleanupForeground", "Cleanup Foreground"), + ("cleanupBackground", "Cleanup Background"), + ("cleanupForeground1", "Cleanup Foreground 1"), + ("cleanupForeground2", "Cleanup Foreground 2"), + ("cleanupForeground3", "Cleanup Foreground 3"), + ("cleanupForeground4", "Cleanup Foreground 4"), + ("cleanupForeground5", "Cleanup Foreground 5"), + ("cleanupForeground6", "Cleanup Foreground 6"), + ("cleanupForeground7", "Cleanup Foreground 7"), + ("cleanupForeground8", "Cleanup Foreground 8"), + ("cleanupForeground9", "Cleanup Foreground 9"), + ("cleanupForeground10", "Cleanup Foreground 10"), + ("cleanupBackground1", "Cleanup Background 1"), + ("cleanupBackground2", "Cleanup Background 2"), + ("cleanupBackground3", "Cleanup Background 3"), + ("cleanupBackground4", "Cleanup Background 4"), + ("cleanupBackground5", "Cleanup Background 5"), + ("cleanupBackground6", "Cleanup Background 6"), + ("cleanupBackground7", "Cleanup Background 7"), + ("cleanupBackground8", "Cleanup Background 8"), + ("cleanupBackground9", "Cleanup Background 9"), + ("cleanupBackground10", "Cleanup Background 10"), ("fillAndStroke", "Fill and Stroke"), ("pathOptions", "Path Options"), ("controlPoints", "Control Points"), @@ -623,6 +707,7 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[ ("orientation", "Orientation"), ("paragraph", "Paragraph"), ("preview", "Preview"), + ("jitterSettings", "Jitter Settings"), // Generate. ("positionsColors", "Positions & Colors"), ("feather", "Feather"), @@ -630,6 +715,8 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[ ("polygon", "Polygon"), ("waveMotion", "Wave Motion"), ("waveStroke", "Stroke"), + ("imageContour", "Image Contour"), + ("waveMask", "Mask"), ("coreSettings", "Core Settings"), ("glowSettings", "Glow Settings"), ("expertSettings", "Expert Settings"), @@ -732,11 +819,15 @@ pub const TIME_DEPENDENT: &[&str] = &[ "ec.stylize.strobe", "ec.noise.noise", "ec.noise.addgrain", + // Remove Grain's temporal filtering reads neighbouring frames. + "ec.noise.removegrain", "ec.noise.noisealpha", "ec.noise.noisehlsauto", "ec.obsolete.lightning", "ec.text.timecode", "ec.text.numbers", + // Path Text's jitter changes every frame. + "ec.obsolete.pathtext", // Time effects read neighbouring frames of the layer. "ec.time.echo", "ec.time.posterizetime", @@ -746,6 +837,14 @@ pub const TIME_DEPENDENT: &[&str] = &[ "ec.time.ccforcemotionblur", "ec.time.ccwidetime", "ec.time.pixelmotionblur", + // Refine mattes' Reduce Chatter / motion blur read neighbouring frames. + "ec.matte.refinesoft", + "ec.matte.refinehard", + // Card Wipe's position / rotation jitter moves with time. + "ec.transition.cardwipe", + // Transform's motion blur and Radio Waves' birth parameters read the parameters at + // other times. + "ec.distort.transform", // Temporal Smoothing reads neighbouring frames. "ec.color.autolevels", "ec.color.autocontrast", @@ -780,7 +879,8 @@ pub fn apply(spec: &EffectSpec, ctx: &EffectCtx, buf: Buf) -> Buf { #[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() }; + let size = [img.width as f64, img.height as f64]; + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), default_value(p, size))).collect() }; for (k, v) in vals { assert!(params.values.contains_key(*k), "{id}: unknown param {k}"); params.values.insert(k.to_string(), v.clone()); diff --git a/crates/effects/src/matte.rs b/crates/effects/src/matte.rs index d7701158..4c42c9c3 100644 --- a/crates/effects/src/matte.rs +++ b/crates/effects/src/matte.rs @@ -93,38 +93,91 @@ fn refine(ctx: &EffectCtx, mut b: Buf, hard: bool) -> Buf { let hi = morph_plane(&a, r, r, true); let lo = morph_plane(&a, r, r, false); let band = |i: usize| hi.data[i] - lo.data[i] > 1e-3; - let mut na = Plane::new(a.w, a.h); - if edge_details { - let guide = Plane::from_image(&b.img, |px| effectcraft_color::luminance(px[0], px[1], px[2])); - let eps = 1e-4 + smooth * smooth * 0.05; - let filtered = guided_filter(&guide, &a, r, eps); - na.data.par_iter_mut().enumerate().for_each(|(i, v)| { - *v = if band(i) { filtered.data[i].clamp(0.0, 1.0) } else { a.data[i] }; - }); - } else { - na = a.clone(); - if smooth > 0.0 { - let s = smooth as f64 * r as f64 * 0.5; - na = gauss_plane(&na, s, s); - } - } - if feather > 0.0 { - na = gauss_plane(&na, feather, feather); - } - na = na.map(|v| { - let mut v = if choke > 0.0 { - (v - choke) / (1.0 - choke) - } else if choke < 0.0 { - v / (1.0 + choke) + // The refined matte of a frame. + let refine_alpha = |img: &effectcraft_raster::Image| -> Plane { + let a = Plane::alpha(img); + let hi = morph_plane(&a, r, r, true); + let lo = morph_plane(&a, r, r, false); + let mut na = Plane::new(a.w, a.h); + if edge_details { + let guide = Plane::from_image(img, |px| effectcraft_color::luminance(px[0], px[1], px[2])); + let eps = 1e-4 + smooth * smooth * 0.05; + let filtered = guided_filter(&guide, &a, r, eps); + na.data.par_iter_mut().enumerate().for_each(|(i, v)| { + *v = if hi.data[i] - lo.data[i] > 1e-3 { filtered.data[i].clamp(0.0, 1.0) } else { a.data[i] }; + }); } else { - v - }; - if (contrast - 1.0).abs() > 1e-6 { - v = (v - 0.5) * contrast + 0.5; + na = a.clone(); + if smooth > 0.0 { + let s = smooth as f64 * r as f64 * 0.5; + na = gauss_plane(&na, s, s); + } } - let v = v.clamp(0.0, 1.0); - if invert { 1.0 - v } else { v } - }); + if feather > 0.0 { + na = gauss_plane(&na, feather, feather); + } + na.map(|v| { + let mut v = if choke > 0.0 { + (v - choke) / (1.0 - choke) + } else if choke < 0.0 { + v / (1.0 + choke) + } else { + v + }; + if (contrast - 1.0).abs() > 1e-6 { + v = (v - 0.5) * contrast + 0.5; + } + let v = v.clamp(0.0, 1.0); + if invert { 1.0 - v } else { v } + }) + }; + let mut na = refine_alpha(&b.img); + // Neighbouring frames of this effect's input (same pixel grid), refined the same way. + let fps = ctx.fps(); + let frame_at = |t: f64| -> Option { + let o = ctx.env.host?.self_at(t, ctx.env.effect_index)?; + (o.img.width == b.img.width && o.img.height == b.img.height && (o.offset[0] - b.offset[0]).abs() < 1e-6 && (o.offset[1] - b.offset[1]).abs() < 1e-6) + .then(|| refine_alpha(&o.img)) + }; + // Reduce Chatter: where the previous and next frames agree but this one differs, the matte + // is chattering; average the three there. Where they disagree (motion) it is left alone. + let chatter = (pr.f("reduceChatter") / 100.0).clamp(0.0, 1.0) as f32; + if chatter > 0.0 { + let prev = frame_at(ctx.time - 1.0 / fps); + let next = frame_at(ctx.time + 1.0 / fps); + if let (Some(p), Some(n)) = (&prev, &next) { + na.data.par_iter_mut().enumerate().for_each(|(i, v)| { + let (c, a, z) = (*v, p.data[i], n.data[i]); + let steady = (1.0 - (a - z).abs() / 0.5).clamp(0.0, 1.0); + *v = c + ((a + c + z) / 3.0 - c) * chatter * steady; + }); + } + } + // Motion blur: the matte averaged over the shutter (centred on the frame). + if pr.b("useMotionBlur") { + let g = |id: &str| pr.get(&format!("motionBlur/{id}")).map(Value::as_f64); + let samples = g("motionBlurSamples").unwrap_or(16.0).clamp(1.0, 64.0) as usize; + let samples = if pr.b("motionBlur/higherQuality") { samples * 2 } else { samples }.min(64); + let span = g("shutterAngle").unwrap_or(180.0).clamp(0.0, 720.0) / 360.0 / fps; + if samples > 1 && span > 0.0 { + let mut acc = na.clone(); + let mut n = 1.0f32; + for k in 0..samples { + let t = ctx.time - span * 0.5 + span * k as f64 / (samples - 1) as f64; + if (t - ctx.time).abs() < 1e-9 { + continue; + } + if let Some(p) = frame_at(t) { + acc.data.iter_mut().zip(&p.data).for_each(|(a, q)| *a += q); + n += 1.0; + } + } + if n > 1.0 { + acc.data.iter_mut().for_each(|a| *a /= n); + na = acc; + } + } + } if view_edges { // The refined matte in grey with the analysed edge band tinted. b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { @@ -190,6 +243,16 @@ pub fn specs() -> Vec { check(pre[3], "View Decontamination Map", false), ] }; + // Reduce Chatter and the motion-blur settings. + let temporal = || { + vec![ + p("reduceChatter", "Reduce Chatter", num(0.0), pct0()), + check("useMotionBlur", "Use Motion Blur", false), + p("motionBlur/motionBlurSamples", "Motion Blur Samples", num(16.0), slider(1.0, 64.0, 1.0, 64.0, 0)), + p("motionBlur/shutterAngle", "Shutter Angle", num(180.0), slider(0.0, 720.0, 0.0, 360.0, 1)), + check("motionBlur/higherQuality", "Higher Quality", false), + ] + }; let radius = |d: f64| p("radius", "Additional Edge Radius", num(d), slider(1.0, 200.0, 1.0, 50.0, 1)); let mut soft_params = vec![ check("calculateEdgeDetails", "Calculate Edge Details", true), @@ -199,8 +262,9 @@ pub fn specs() -> Vec { p("feather", "Feather", num(10.0), pct0()), p("contrast", "Contrast", num(0.0), pct0()), p("shiftEdge", "Shift Edge", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 0)), - check("decontaminate", "Decontaminate Edge Colors", true), ]; + soft_params.extend(temporal()); + soft_params.push(check("decontaminate", "Decontaminate Edge Colors", true)); soft_params.extend(decon(&[ "decontamination/decontaminationAmount", "decontamination/extendWhereSmoothed", @@ -214,8 +278,9 @@ pub fn specs() -> Vec { p("smooth", "Smooth", num(20.0), pct0()), p("feather", "Feather", num(0.0), pct0()), p("choke", "Choke", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 0)), - check("decontaminate", "Decontaminate Edge Colors", true), ]; + hard_params.extend(temporal()); + hard_params.push(check("decontaminate", "Decontaminate Edge Colors", true)); hard_params.extend(decon(&["decontaminationAmount", "extendWhereSmoothed", "increaseDecontaminationRadius", "viewDecontaminationMap"])); // Our extras: the analysed edge band's radius, alpha contrast and inversion. hard_params.push(radius(3.0)); @@ -345,4 +410,63 @@ mod tests { assert!(out.get(0, 0)[3] < 0.01); assert!(out.data.iter().all(|p| (0.0..=1.0).contains(&p[3]))); } + + /// A white square whose left edge sits at x = 10 + `jitter` × (frame parity) + `speed` × frame. + struct Edge { + jitter: i64, + speed: i64, + } + fn edge(x0: i64) -> Image { + let mut img = Image::new(40, 20); + for y in 4..16 { + for x in x0.max(0)..(x0 + 16).min(40) { + img.set(x as u32, y, [1.0, 1.0, 1.0, 1.0]); + } + } + img + } + impl crate::EffectHost for Edge { + fn layer(&self, _: u64, _: bool) -> Option { + None + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + fn self_at(&self, t: f64, _: usize) -> Option { + let f = (t * 10.0).round() as i64; + Some(Buf { img: edge(10 + self.jitter * (f & 1) + self.speed * f), offset: [0.0; 2], scale: 1.0 }) + } + } + + fn run_t(id: &str, over: &[(&str, Value)], t: f64, host: &Edge) -> Image { + let f = (t * 10.0).round() as i64; + let img = edge(10 + host.jitter * (f & 1) + host.speed * f); + let env = crate::EffectEnv { host: Some(host), frame_rate: 10.0, ..Default::default() }; + crate::run_fx(id, over, img, t, env).img + } + + #[test] + fn refine_mattes_reduce_chatter_and_motion_blur() { + for id in ["ec.matte.refinesoft", "ec.matte.refinehard"] { + let jitter = Edge { jitter: 1, speed: 0 }; + let base = [("feather", num(0.0)), ("smooth", num(0.0)), ("decontaminate", Value::Bool(false))]; + let with = |extra: &[(&str, Value)], t: f64, h: &Edge| { + let mut v: Vec<(&str, Value)> = base.to_vec(); + v.extend_from_slice(extra); + run_t(id, &v, t, h) + }; + let diff = |a: &Image, b: &Image| a.data.iter().zip(&b.data).map(|(p, q)| (p[3] - q[3]).abs()).sum::(); + let (a0, a1) = (with(&[], 1.0, &jitter), with(&[], 1.1, &jitter)); + let (c0, c1) = (with(&[("reduceChatter", num(100.0))], 1.0, &jitter), with(&[("reduceChatter", num(100.0))], 1.1, &jitter)); + assert!(diff(&c0, &c1) < diff(&a0, &a1) * 0.7, "{id}: chatter {} vs {}", diff(&c0, &c1), diff(&a0, &a1)); + // Motion blur smears a moving matte edge along the motion. + let moving = Edge { jitter: 0, speed: 4 }; + let mb = with(&[("useMotionBlur", Value::Bool(true)), ("motionBlur/shutterAngle", num(360.0))], 0.3, &moving); + let sharp = with(&[], 0.3, &moving); + let partial = |img: &Image| img.data.iter().filter(|p| p[3] > 0.05 && p[3] < 0.95).count(); + assert!(partial(&mb) > partial(&sharp) + 10, "{id}: {} vs {}", partial(&mb), partial(&sharp)); + // Without a host both are inert. + assert_eq!(run(id, &[("reduceChatter", num(100.0)), ("useMotionBlur", Value::Bool(true))], edge(10)), run(id, &[], edge(10))); + } + } } diff --git a/crates/effects/src/migrate.rs b/crates/effects/src/migrate.rs index 5b3747e9..e5dc30ba 100644 --- a/crates/effects/src/migrate.rs +++ b/crates/effects/src/migrate.rs @@ -38,6 +38,10 @@ pub const PARAM_ID_ALIASES: &[(&str, &str, &str)] = &[ ("ec.matte.rotobrush", "refineEdgeMatte/extendWhereSmoothed", "refineEdgeMatte/decontamination/extendWhereSmoothed"), ("ec.matte.rotobrush", "refineEdgeMatte/increaseDecontaminationRadius", "refineEdgeMatte/decontamination/increaseDecontaminationRadius"), ("ec.matte.rotobrush", "refineEdgeMatte/viewDecontaminationMap", "refineEdgeMatte/decontamination/viewDecontaminationMap"), + // Keying: Inner/Outer Key's single additional masks became Additional Foreground / + // Background 1 (of 10). + ("ec.key.innerouter", "additionalForeground", "additionalForeground/foreground1"), + ("ec.key.innerouter", "additionalBackground", "additionalBackground/background1"), // Generate: Radio Waves' growth speed was called Velocity; Velocity now moves the wave. ("ec.generate.radiowaves", "velocity", "waveMotion/expansion"), ]; @@ -153,6 +157,10 @@ pub fn legacy_default(effect: &str, param: &str) -> Option { ("ec.noise.removegrain", "noiseReductionSettings/mode") => Value::Enum(1), // Audio: the Compressor's release was always manual. ("ec.audio.compressor", "autoRelease") => Value::Bool(false), + // Keying: Advanced Spill Suppressor's Ultra mode had no hue tolerance. + ("ec.key.advancedspill", "ultraSettings/tolerance") => Value::Scalar(100.0), + // Transition: Block Dissolve's blocks had hard edges. + ("ec.transition.blockdissolve", "softEdges") => Value::Bool(false), _ => return None, }) } @@ -242,6 +250,18 @@ fn is_numeric_ui(u: &ParamUi) -> bool { /// point defaults of added parameters. Returns whether anything changed. pub fn upgrade_instance(spec: &EffectSpec, g: &mut PropGroup, ids: &mut Ids, layer_size: [f64; 2]) -> bool { let before = g.clone(); + // Card Wipe's Back Layer was a None / Self popup; it is a layer parameter now, with "Self" + // kept in the hidden Back Layer Is Self switch. + let mut back_self = None; + if spec.id == "ec.transition.cardwipe" + && let Some(pr) = g.get_mut("backLayer") + && matches!(pr.ui, ParamUi::Popup { .. }) + { + back_self = Some(pr.value.as_enum() == 1); + pr.value = Value::Layer(None); + pr.keys.clear(); + pr.ui = ParamUi::Layer; + } for ps in &spec.params { // Locate the stored property: at its spec path, under an old id, or at top level // before it moved into a twirl-down group. @@ -296,6 +316,11 @@ pub fn upgrade_instance(spec: &EffectSpec, g: &mut PropGroup, ids: &mut Ids, lay } } } + if let Some(on) = back_self + && let Some(pr) = g.get_mut("backSelf") + { + pr.value = Value::Bool(on); + } // Refresh twirl-down group names. fn names(g: &mut PropGroup) { for c in &mut g.children { @@ -368,4 +393,78 @@ mod tests { assert!(g.get("smoothness").is_none()); assert_eq!(prop_at(&mut g, "stabilization/smoothness").unwrap().value, Value::Scalar(12.0)); } + + #[test] + fn card_wipe_back_layer_popup_becomes_a_layer_parameter() { + let spec = find("ec.transition.cardwipe").unwrap(); + let mut next = 1; + for (old, self_) in [(1, true), (0, false)] { + let mut g = instantiate(spec, &mut Ids(&mut next), "Card Wipe", [100.0, 50.0]); + take_prop(&mut g, "backSelf"); + let pr = g.get_mut("backLayer").unwrap(); + pr.ui = ParamUi::Popup { options: vec!["None".into(), "Self".into()] }; + pr.value = Value::Enum(old); + assert!(upgrade_instance(spec, &mut g, &mut Ids(&mut next), [100.0, 50.0])); + assert_eq!(g.get("backLayer").unwrap().value, Value::Layer(None)); + assert_eq!(g.get("backLayer").unwrap().ui, ParamUi::Layer); + assert_eq!(g.get("backSelf").unwrap().value, Value::Bool(self_)); + } + } + + #[test] + fn particle_playground_mapper_targets_remap_by_label() { + let spec = find("ec.sim.particleplayground").unwrap(); + let mut next = 1; + let mut g = instantiate(spec, &mut Ids(&mut next), "Particle Playground", [100.0, 50.0]); + let old: Vec = + ["None", "Red", "Green", "Blue", "Kinetic Friction", "Scale", "X", "Y", "X Speed", "Y Speed", "X Force", "Y Force", "Opacity", "Mass"] + .iter() + .map(|s| s.to_string()) + .collect(); + let pr = prop_at(&mut g, "persistentPropertyMapper/mapRedTo").unwrap(); + pr.ui = ParamUi::Popup { options: old }; + pr.value = Value::Enum(8); + assert!(upgrade_instance(spec, &mut g, &mut Ids(&mut next), [100.0, 50.0])); + assert_eq!(prop_at(&mut g, "persistentPropertyMapper/mapRedTo").unwrap().value, Value::Enum(15), "X Speed"); + } + + #[test] + fn selective_color_ranges_move_into_details() { + let spec = find("ec.color.selectivecolor").unwrap(); + let mut next = 1; + let mut g = instantiate(spec, &mut Ids(&mut next), "Selective Color", [100.0, 50.0]); + let mut pr = take_at(&mut g, "details/reds/redsCyan").unwrap(); + pr.value = Value::Scalar(40.0); + g.children.push(pr.into()); + assert!(upgrade_instance(spec, &mut g, &mut Ids(&mut next), [100.0, 50.0])); + assert!(g.get("redsCyan").is_none()); + assert_eq!(prop_at(&mut g, "details/reds/redsCyan").unwrap().value, Value::Scalar(40.0)); + assert_eq!(g.sub("details").unwrap().sub("reds").unwrap().name, "Reds"); + } + + #[test] + fn inner_outer_key_additional_masks_move_into_their_groups() { + let spec = find("ec.key.innerouter").unwrap(); + let mut next = 1; + let mut g = instantiate(spec, &mut Ids(&mut next), "Inner/Outer Key", [100.0, 50.0]); + let mut pr = take_at(&mut g, "additionalForeground/foreground1").unwrap(); + pr.match_id = "additionalForeground".into(); + pr.value = Value::Enum(3); + g.children.push(pr.into()); + assert!(upgrade_instance(spec, &mut g, &mut Ids(&mut next), [100.0, 50.0])); + assert!(g.get("additionalForeground").is_none()); + assert_eq!(prop_at(&mut g, "additionalForeground/foreground1").unwrap().value, Value::Enum(3)); + } + + #[test] + fn match_grain_viewing_modes_remap_by_label() { + let spec = find("ec.noise.matchgrain").unwrap(); + let mut next = 1; + let mut g = instantiate(spec, &mut Ids(&mut next), "Match Grain", [100.0, 50.0]); + let pr = g.get_mut("viewingMode").unwrap(); + pr.ui = ParamUi::Popup { options: vec!["Final Output".into(), "Noise Samples".into(), "Blending Matte".into()] }; + pr.value = Value::Enum(0); + assert!(upgrade_instance(spec, &mut g, &mut Ids(&mut next), [100.0, 50.0])); + assert_eq!(g.get("viewingMode").unwrap().value, Value::Enum(4), "Final Output"); + } } diff --git a/crates/effects/src/misc.rs b/crates/effects/src/misc.rs index ed3678e8..89b496bb 100644 --- a/crates/effects/src/misc.rs +++ b/crates/effects/src/misc.rs @@ -120,12 +120,48 @@ pub(crate) fn glow_ab_t(l: f32, looping: u32, loops: f32, phase: f32, mid: f32) t.max(0.0).powf(0.5f32.ln() / m.ln()) } +/// Glow Operation options (blend modes the glow is applied with). +pub const GLOW_OPERATIONS: [&str; 17] = [ + "Normal", + "Add", + "Multiply", + "Screen", + "Overlay", + "Soft Light", + "Hard Light", + "Color Dodge", + "Color Burn", + "Darken", + "Lighten", + "Difference", + "Exclusion", + "Hue", + "Saturation", + "Color", + "Luminosity", +]; + +/// Glow's Glow Operation as a blend mode (Add for projects saved before it existed). +pub fn glow_operation(ctx: &EffectCtx) -> effectcraft_color::BlendMode { + let name = GLOW_OPERATIONS.get(ctx.params.get("glowOperation").map(|v| v.as_enum()).unwrap_or(1) as usize).copied().unwrap_or("Add"); + effectcraft_color::BlendMode::ALL.iter().copied().find(|m| m.label() == name).unwrap_or(effectcraft_color::BlendMode::Add) +} + +/// Glow's Arbitrary Map: red, green and blue curves of the glow brightness, as Curves point +/// lists `x,y …` separated by `|` (an empty or missing curve is the identity). +fn glow_map(s: &str) -> Vec> { + let mut parts = s.split('|'); + (0..3).map(|_| parts.next().and_then(crate::Curve::parse)).collect() +} + fn glow(ctx: &EffectCtx, mut b: Buf) -> Buf { let thr = ctx.params.f("threshold") as f32 / 100.0; let radius = ctx.params.f("radius") * b.scale; let intensity = ctx.params.f("intensity") as f32; let alpha_based = ctx.params.e("based") == 0; let use_colors = ctx.params.e("colors") == 1; + let arbitrary = (ctx.params.e("colors") == 2).then(|| glow_map(ctx.params.s("arbitraryMap"))); + let glow_op = glow_operation(ctx); let looping = ctx.params.e("colorLooping"); let loops = ctx.params.f("colorLoops") as f32; let phase = (ctx.params.f("colorPhase") / 360.0) as f32; @@ -147,7 +183,11 @@ fn glow(ctx: &EffectCtx, mut b: Buf) -> Buf { // Glow Based On: brightness from the colours, or from the alpha channel. let l = if alpha_based { a } else { luminance(px[0] / a, px[1] / a, px[2] / a) }; let k = ((l - thr) / (1.0 - thr).max(1e-3)).clamp(0.0, 1.0); - if use_colors { + if let Some(map) = &arbitrary { + let t = glow_ab_t(l, looping, loops, phase, mid); + let c = map.iter().map(|cv| cv.as_ref().map_or(t, |cv| cv.eval(t))).collect::>(); + *px = [c[0] * k * a, c[1] * k * a, c[2] * k * a, k * a]; + } else if use_colors { let t = glow_ab_t(l, looping, loops, phase, mid); let c = [ca[0] + (cb[0] - ca[0]) * t, ca[1] + (cb[1] - ca[1]) * t, ca[2] + (cb[2] - ca[2]) * t]; *px = [c[0] * k * a, c[1] * k * a, c[2] * k * a, k * a]; @@ -170,6 +210,8 @@ fn glow(ctx: &EffectCtx, mut b: Buf) -> Buf { } // None: the glow alone. 2 => *o = [g[0], g[1], g[2], g[3]], + // On Top with another Glow Operation: that blend mode. + _ if glow_op != effectcraft_color::BlendMode::Add => *o = effectcraft_color::blend_pixel(glow_op, *o, g, 0.0), // On Top: add (screen-like add on premultiplied colour; alpha grows by the glow's alpha). _ => { for c in 0..3 { @@ -571,7 +613,10 @@ pub fn specs() -> Vec { p("radius", "Glow Radius", num(10.0), slider(0.0, 1000.0, 0.0, 100.0, 1)), p("intensity", "Glow Intensity", num(1.0), slider(0.0, 255.0, 0.0, 4.0, 1)), p("operation", "Composite Original", Value::Enum(0), popup(&["On Top", "Behind", "None"])), - p("colors", "Glow Colors", Value::Enum(0), popup(&["Original Colors", "A & B Colors"])), + p("glowOperation", "Glow Operation", Value::Enum(1), popup(&GLOW_OPERATIONS)), + p("colors", "Glow Colors", Value::Enum(0), popup(&["Original Colors", "A & B Colors", "Arbitrary Map"])), + // Arbitrary Map: "red points | green points | blue points" (Curves format). + p("arbitraryMap", "Arbitrary Map", Value::Str(String::new()), ParamUi::Hidden), p("colorLooping", "Color Looping", Value::Enum(2), popup(&["Sawtooth A>B", "Sawtooth B>A", "Triangle A>B>A", "Triangle B>A>B"])), p("colorLoops", "Color Loops", num(1.0), slider(1.0, 127.0, 1.0, 10.0, 1)), p("colorPhase", "Color Phase", num(0.0), ParamUi::Angle), @@ -687,3 +732,52 @@ pub fn specs() -> Vec { ), ] } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{EffectEnv, run_fx}; + + /// A bright square on mid grey. + fn scene() -> effectcraft_raster::Image { + let mut img = effectcraft_raster::Image::filled(24, 24, [0.3, 0.3, 0.3, 1.0]); + for y in 8..16 { + for x in 8..16 { + img.set(x, y, [1.0, 1.0, 1.0, 1.0]); + } + } + img + } + + #[test] + fn glow_operation_and_arbitrary_map() { + // Pixels addressed in layer space (Glow pads its buffer). + struct Out(Buf); + impl Out { + fn get(&self, x: i64, y: i64) -> [f32; 4] { + self.0.img.get(x + self.0.offset[0] as i64, y + self.0.offset[1] as i64) + } + } + let run = |vals: &[(&str, Value)]| Out(run_fx("ec.stylize.glow", vals, scene(), 0.0, EffectEnv::default())); + let add = run(&[]); + assert_eq!(GLOW_OPERATIONS[1], "Add"); + // Multiply darkens around the square where Add brightens. + let mul = run(&[("glowOperation", Value::Enum(2))]); + assert!(add.get(6, 12)[0] > 0.31 && mul.get(6, 12)[0] <= 0.3 + 1e-4, "{:?} {:?}", add.get(6, 12), mul.get(6, 12)); + assert_eq!( + glow_operation(&EffectCtx { + params: &crate::Params::default(), + time: 0.0, + layer_size: [1.0; 2], + seed: 0, + adjustment: false, + env: Default::default() + }), + effectcraft_color::BlendMode::Add + ); + // Arbitrary Map: the glow takes the mapped colour (here pure red for every brightness). + let red = run(&[("colors", Value::Enum(2)), ("arbitraryMap", Value::Str("0,1 1,1|0,0 1,0|0,0 1,0".into())), ("operation", Value::Enum(2))]); + let p = red.get(6, 12); + assert!(p[0] > 0.05 && p[1] < 1e-4 && p[2] < 1e-4, "{p:?}"); + } +} diff --git a/crates/effects/src/noise.rs b/crates/effects/src/noise.rs index 4181c070..4681fd99 100644 --- a/crates/effects/src/noise.rs +++ b/crates/effects/src/noise.rs @@ -59,6 +59,8 @@ pub struct GrainLook { /// Grain time (frames of grain at the animation speed) and whether it interpolates. pub frame: f32, pub seed: u32, + /// Color ▸ Red / Green / Blue Balance. + pub balance: [f32; 3], } impl GrainLook { @@ -84,7 +86,28 @@ impl GrainLook { midpoint: pr.get("application/midpoint").map(Value::as_f64).unwrap_or(0.5).clamp(0.01, 0.99) as f32, frame: if smooth && ft.fract() != 0.0 { ft as f32 } else { ft.floor() as f32 }, seed: (pr.f("animation/randomSeed") as i64 as u32) ^ ctx.seed.wrapping_mul(0x9e37), + balance: ch("color/", "redBalance", "greenBalance", "blueBalance"), } + .with_preset(pr.e("preset")) + } + + /// Apply Add Grain's Preset (a film-stock look on top of the controls). + fn with_preset(mut self, preset: u32) -> GrainLook { + // (intensity ×, size ×, softness +, saturation ×, monochrome) + let (ki, ks, soft, ksat, mono) = match preset { + 1 => (0.6, 0.7, 0.2, 0.6, false), + 2 => (1.3, 1.8, 0.4, 0.8, false), + 3 => (1.8, 2.5, 0.8, 0.5, false), + 4 => (1.0, 0.6, 0.0, 1.0, false), + 5 => (1.6, 1.4, 0.3, 1.0, true), + _ => return self, + }; + self.intensity *= ki; + self.size *= ks; + self.softness += soft; + self.saturation *= ksat; + self.mono |= mono; + self } /// Zero-mean noise planes (one, or one per channel) of grain `size` pixels (times each @@ -129,7 +152,7 @@ impl GrainLook { let l = luminance(self.tint[0], self.tint[1], self.tint[2]).max(1e-3); g = [0, 1, 2].map(|k| g[k] * ((1.0 - self.tint_amount) + self.tint_amount * self.tint[k] / l)); } - g + [g[0] * self.balance[0], g[1] * self.balance[1], g[2] * self.balance[2]] } /// Combine source channel `c` with signed grain `n` by the Blending Mode. @@ -171,6 +194,9 @@ pub(crate) fn grain_params() -> (Vec, Vec, V p("color/saturation", "Saturation", num(1.0), mult()), p("color/tintAmount", "Tint Amount", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 2)), p("color/tintColor", "Tint Color", crate::col(1.0, 1.0, 1.0), ParamUi::Color), + p("color/redBalance", "Red Balance", num(1.0), mult()), + p("color/greenBalance", "Green Balance", num(1.0), mult()), + p("color/blueBalance", "Blue Balance", num(1.0), mult()), ]; let application = vec![ p("application/blendingMode", "Blending Mode", Value::Enum(0), popup(&GRAIN_BLEND_MODES)), @@ -187,27 +213,124 @@ pub(crate) fn grain_params() -> (Vec, Vec, V (tweaking, color, application, animation) } +/// Add Grain's Viewing Mode options. +pub(crate) const ADD_GRAIN_VIEWS: [&str; 3] = ["Preview", "Blending Matte", "Final Output"]; +/// Add Grain's Preset options (our own film-stock looks). +pub(crate) const GRAIN_PRESETS: [&str; 6] = ["None", "Fine 35mm", "Coarse 16mm", "Vintage 8mm", "Video Noise", "Monochrome High Speed"]; + +/// The Preview Region controls of the grain effects (centre, size, box display). +pub(crate) fn preview_params() -> Vec { + vec![ + p("previewRegion/center", "Center", Value::Vec2([0.5, 0.5]), ParamUi::Point), + p("previewRegion/width", "Width", num(200.0), slider(1.0, 10000.0, 1.0, 1000.0, 0)), + p("previewRegion/height", "Height", num(200.0), slider(1.0, 10000.0, 1.0, 1000.0, 0)), + p("previewRegion/showBox", "Show Box", Value::Bool(true), ParamUi::Checkbox), + p("previewRegion/boxColor", "Box Color", crate::col(1.0, 1.0, 1.0), ParamUi::Color), + ] +} + +/// Preview viewing mode: the processed pixels inside the Preview Region, the original +/// outside, and (Show Box) the region's outline. +pub(crate) fn preview_compose(ctx: &EffectCtx, b: &mut Buf, orig: &Image) { + let c = b.to_px(ctx.params.v2("previewRegion/center")); + let (hw, hh) = (ctx.params.f("previewRegion/width") * b.scale * 0.5, ctx.params.f("previewRegion/height") * b.scale * 0.5); + let (x0, x1, y0, y1) = (c.0 - hw, c.0 + hw, c.1 - hh, c.1 + hh); + let show = ctx.params.b("previewRegion/showBox"); + let col = ctx.params.color("previewRegion/boxColor"); + let w = b.img.width as usize; + b.img.data.par_iter_mut().zip(orig.data.par_iter()).enumerate().for_each(|(i, (px, o))| { + let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5); + let inside = x >= x0 && x < x1 && y >= y0 && y < y1; + if !inside { + *px = *o; + } + if show { + let near = |v: f64, e: f64| (v - e).abs() < 0.75; + let on = ((near(x, x0) || near(x, x1)) && y >= y0 - 0.75 && y < y1 + 0.75) || ((near(y, y0) || near(y, y1)) && x >= x0 - 0.75 && x < x1 + 0.75); + if on { + *px = [col[0], col[1], col[2], 1.0]; + } + } + }); +} + +/// Boxes (x, y, size in buffer px) where grain is sampled: the flattest of a grid of +/// candidate boxes (real grain shows best where the picture has no detail). +pub(crate) fn sample_boxes(img: &Image, count: usize, size: usize) -> Vec<(usize, usize, usize)> { + let (w, h) = (img.width as usize, img.height as usize); + let size = size.clamp(3, w.min(h).max(3)); + if w < size || h < size || count == 0 { + return vec![]; + } + let low = Plane::luma(img); + let step = (size / 2).max(1); + let mut cands: Vec<(f32, usize, usize)> = Vec::new(); + for y in (0..=h - size).step_by(step) { + for x in (0..=w - size).step_by(step) { + // Variance of the picture inside the box (the flattest boxes hold the least detail). + let (mut s, mut sq, mut n, mut opaque) = (0.0f32, 0.0f32, 0.0f32, true); + for yy in y..y + size { + for xx in x..x + size { + let v = low.data[yy * w + xx]; + s += v; + sq += v * v; + n += 1.0; + opaque &= img.data[yy * w + xx][3] > 0.5; + } + } + if opaque { + cands.push((sq / n - (s / n).powi(2), x, y)); + } + } + } + cands.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.2.cmp(&b.2)).then(a.1.cmp(&b.1))); + cands.into_iter().take(count).map(|(_, x, y)| (x, y, size)).collect() +} + +/// Draw sample boxes' outlines (Noise Samples viewing mode). +pub(crate) fn draw_boxes(img: &mut Image, boxes: &[(usize, usize, usize)], c: [f32; 4]) { + for &(x, y, s) in boxes { + for k in 0..s { + for (px, py) in [(x + k, y), (x + k, y + s - 1), (x, y + k), (x + s - 1, y + k)] { + img.set(px as u32, py as u32, [c[0], c[1], c[2], 1.0]); + } + } + } +} + // ---- Add Grain ---- fn add_grain(ctx: &EffectCtx, mut b: Buf) -> Buf { let look = GrainLook::from_params(ctx); - if look.intensity <= 0.0 { + // Viewing Mode (Final Output for projects saved before it existed). + let view = ctx.params.get("viewingMode").map(|v| v.as_enum()).unwrap_or(2); + if look.intensity <= 0.0 && view != 1 { return b; } + let orig = b.img.clone(); let (w, h) = (b.img.width as usize, b.img.height as usize); let planes = look.planes(w, h, look.size * b.scale as f32, b.scale, 101); let amp = look.intensity * 0.25; b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { let (c, a) = unpremul(*px); + let weight = look.weight(c); + if view == 1 { + // Blending Matte: where (and how strongly) the grain applies. + let v = weight.clamp(0.0, 1.0); + *px = [v, v, v, 1.0]; + return; + } if a <= 0.0 { return; } let raw = if look.mono { [planes[0].data[i]; 3] } else { [planes[0].data[i], planes[1].data[i], planes[2].data[i]] }; let g = look.color(raw); - let weight = look.weight(c); let o = [0, 1, 2].map(|k| look.blend(c[k], g[k] * amp * look.channel_intensity[k] * weight)); *px = premul(o, a); }); + if view == 0 { + preview_compose(ctx, &mut b, &orig); + } b } @@ -333,18 +456,104 @@ fn dust_scratches(ctx: &EffectCtx, mut b: Buf) -> Buf { b } +/// Remove Grain's Viewing Mode options. +pub(crate) const REMOVE_GRAIN_VIEWS: [&str; 4] = ["Preview", "Noise Samples", "Blending Matte", "Final Output"]; + +/// Grain sampling controls (Match Grain, Remove Grain): how many flat boxes of what size the +/// grain is measured in. +pub(crate) fn sampling_params() -> Vec { + vec![ + p("sampling/samplingProcess", "Sampling Process", Value::Enum(0), popup(&["Automatic"])), + p("sampling/numberOfSamples", "Number of Samples", num(80.0), slider(1.0, 500.0, 1.0, 200.0, 0)), + p("sampling/sampleSize", "Sample Size", num(9.0), slider(3.0, 64.0, 3.0, 32.0, 0)), + p("sampling/sampleBoxColor", "Sample Box Color", crate::col(1.0, 1.0, 0.0), ParamUi::Color), + ] +} + +/// The sample boxes for the effect's Sampling settings. +pub(crate) fn sampling_boxes(ctx: &EffectCtx, img: &Image, scale: f64) -> Vec<(usize, usize, usize)> { + let n = ctx.params.get("sampling/numberOfSamples").map(Value::as_f64).unwrap_or(80.0).clamp(1.0, 500.0) as usize; + let s = (ctx.params.get("sampling/sampleSize").map(Value::as_f64).unwrap_or(9.0) * scale).round().max(3.0) as usize; + sample_boxes(img, n, s) +} + fn remove_grain(ctx: &EffectCtx, mut b: Buf) -> Buf { let pr = ctx.params; let amt = pr.f("noiseReductionSettings/noiseReduction") as f32; let um_amount = pr.f("unsharpMask/amount") as f32 / 100.0; - if amt <= 0.0 && um_amount <= 0.0 { + let view = pr.get("viewingMode").map(|v| v.as_enum()).unwrap_or(3); + let boxes = sampling_boxes(ctx, &b.img, b.scale); + if view == 1 { + let c = pr.get("sampling/sampleBoxColor").map(|v| v.as_color()).unwrap_or([1.0, 1.0, 0.0, 1.0]); + draw_boxes(&mut b.img, &boxes, c); return b; } + if view == 2 { + b.img.data.iter_mut().for_each(|p| *p = [1.0, 1.0, 1.0, 1.0]); + return b; + } + let temporal = pr.b("temporalFiltering/enabled"); + if amt <= 0.0 && um_amount <= 0.0 && !temporal { + return b; + } + let before = b.img.clone(); + // Temporal Filtering: steady pixels average with the neighbouring frames (grain changes + // every frame, the picture does not); Motion Sensitivity keeps moving areas out of it. + if temporal { + let fps = ctx.fps(); + let host = ctx.env.host; + let at = |t: f64| host.and_then(|h| h.self_at(t, ctx.env.effect_index)).filter(|o| o.img.width == b.img.width && o.img.height == b.img.height); + let (prev, next) = (at(ctx.time - 1.0 / fps), at(ctx.time + 1.0 / fps)); + let k = (pr.get("temporalFiltering/amount").map(Value::as_f64).unwrap_or(100.0) / 100.0).clamp(0.0, 1.0) as f32; + let sens = pr.get("temporalFiltering/motionSensitivity").map(Value::as_f64).unwrap_or(0.5).clamp(0.0, 1.0) as f32; + let thr = 0.02 + (1.0 - sens) * 0.2; + if prev.is_some() || next.is_some() { + b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| { + let mut acc = *px; + let mut n = 1.0; + for o in [&prev, &next].into_iter().flatten() { + let q = o.img.data[i]; + let d = (0..3).map(|c| (q[c] - px[c]).abs()).fold(0.0f32, f32::max); + if d < thr { + let wgt = 1.0 - d / thr; + for c in 0..4 { + acc[c] += q[c] * wgt; + } + n += wgt; + } + } + for c in 0..4 { + px[c] += (acc[c] / n - px[c]) * k; + } + }); + } + if amt <= 0.0 && um_amount <= 0.0 { + if view == 0 { + preview_compose(ctx, &mut b, &before); + } + return b; + } + } + // The grain level measured in the sample boxes scales the smoothing. + let level = { + let mut m = vec![false; b.img.data.len()]; + let w = b.img.width as usize; + for &(x, y, s) in &boxes { + for yy in y..y + s { + for xx in x..x + s { + m[yy * w + xx] = true; + } + } + } + let st = crate::noise2::grain_stats_masked(&b.img, Some(&m)); + let s = (st.std[0] + st.std[1] + st.std[2]) / 3.0; + if boxes.is_empty() { 1.0 } else { (s / 0.02).clamp(0.5, 2.0) } + }; let passes = pr.f("noiseReductionSettings/passes").round().clamp(1.0, 8.0) as usize; let multichannel = pr.e("noiseReductionSettings/mode") == 0; let texture = (pr.f("fineTuning/texture") as f32).clamp(0.0, 1.0); let r = (2.0 * b.scale).round().max(1.0) as usize; - let eps = (0.02 * amt).powi(2); + let eps = (0.02 * amt * level).powi(2); let orig = split(&b.img); let mut ch = orig.clone(); if amt > 0.0 { @@ -387,6 +596,9 @@ fn remove_grain(ctx: &EffectCtx, mut b: Buf) -> Buf { p[3] = a; }); b.img = out; + if view == 0 { + preview_compose(ctx, &mut b, &before); + } b } @@ -533,7 +745,9 @@ pub fn specs() -> Vec { vec![ { let (tweaking, color, application, animation) = grain_params(); - spec("ec.noise.addgrain", "Add Grain", [tweaking, color, application, animation].into_iter().flatten().collect(), add_grain) + let head = + vec![p("viewingMode", "Viewing Mode", Value::Enum(2), popup(&ADD_GRAIN_VIEWS)), p("preset", "Preset", Value::Enum(0), popup(&GRAIN_PRESETS))]; + spec("ec.noise.addgrain", "Add Grain", [head, preview_params(), tweaking, color, application, animation].into_iter().flatten().collect(), add_grain) }, spec( "ec.noise.median", @@ -557,15 +771,26 @@ pub fn specs() -> Vec { spec( "ec.noise.removegrain", "Remove Grain", - vec![ - p("noiseReductionSettings/noiseReduction", "Noise Reduction", num(1.0), slider(0.0, 5.0, 0.0, 5.0, 2)), - p("noiseReductionSettings/passes", "Passes", num(1.0), slider(1.0, 8.0, 1.0, 4.0, 0)), - p("noiseReductionSettings/mode", "Mode", Value::Enum(0), popup(&["Multichannel", "Single Channel"])), - p("fineTuning/texture", "Texture", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 2)), - p("unsharpMask/amount", "Amount", num(0.0), slider(0.0, 500.0, 0.0, 200.0, 1)), - p("unsharpMask/radius", "Radius", num(1.0), slider(0.1, 100.0, 0.1, 10.0, 1)), - p("unsharpMask/threshold", "Threshold", num(0.0), slider(0.0, 255.0, 0.0, 255.0, 0)), - ], + [ + vec![p("viewingMode", "Viewing Mode", Value::Enum(3), popup(&REMOVE_GRAIN_VIEWS))], + preview_params(), + vec![ + p("noiseReductionSettings/noiseReduction", "Noise Reduction", num(1.0), slider(0.0, 5.0, 0.0, 5.0, 2)), + p("noiseReductionSettings/passes", "Passes", num(1.0), slider(1.0, 8.0, 1.0, 4.0, 0)), + p("noiseReductionSettings/mode", "Mode", Value::Enum(0), popup(&["Multichannel", "Single Channel"])), + p("fineTuning/texture", "Texture", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 2)), + p("unsharpMask/amount", "Amount", num(0.0), slider(0.0, 500.0, 0.0, 200.0, 1)), + p("unsharpMask/radius", "Radius", num(1.0), slider(0.1, 100.0, 0.1, 10.0, 1)), + p("unsharpMask/threshold", "Threshold", num(0.0), slider(0.0, 255.0, 0.0, 255.0, 0)), + p("temporalFiltering/enabled", "Enabled", Value::Bool(false), ParamUi::Checkbox), + p("temporalFiltering/amount", "Amount", num(100.0), pct()), + p("temporalFiltering/motionSensitivity", "Motion Sensitivity", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 2)), + ], + sampling_params(), + ] + .into_iter() + .flatten() + .collect(), remove_grain, ), spec( @@ -768,6 +993,71 @@ mod tests { assert!(tinted.img.data.iter().any(|p| (p[0] - 0.4).abs() > 1e-3)); } + #[test] + fn grain_preview_presets_balance_and_views() { + let base = gray(64, 64, 0.5); + let grain = [("tweaking/intensity", num(3.0))]; + let with = |extra: &[(&str, Value)]| run("ec.noise.addgrain", &[grain.as_slice(), extra].concat(), base.clone(), 0.0).img; + // Preview: grain only inside the region; its box outline drawn. + let prev = with(&[ + ("viewingMode", Value::Enum(0)), + ("previewRegion/center", Value::Vec2([32.0, 32.0])), + ("previewRegion/width", num(20.0)), + ("previewRegion/height", num(20.0)), + ]); + assert_eq!(prev.get(2, 2), base.get(2, 2)); + assert_ne!(prev.get(32, 32), base.get(32, 32)); + assert_eq!(prev.get(22, 32), [1.0, 1.0, 1.0, 1.0], "box outline"); + // Blending Matte shows the tonal weight (1 at mid grey with the defaults). + assert_eq!(with(&[("viewingMode", Value::Enum(1))]).get(5, 5), [1.0, 1.0, 1.0, 1.0]); + // Presets and channel balance change the grain. + let plain = with(&[]); + assert_ne!(with(&[("preset", Value::Enum(3))]), plain); + let no_red = with(&[("color/redBalance", num(0.0))]); + assert!(no_red.data.iter().all(|p| (p[0] - 0.5).abs() < 1e-6)); + // Sample boxes sit in the flat part of a picture. + let mut half = gray(64, 64, 0.5); + for y in 0..64 { + for x in 32..64 { + half.set(x, y, if (x + y) % 2 == 0 { [1.0; 4] } else { [0.0, 0.0, 0.0, 1.0] }); + } + } + let boxes = sample_boxes(&half, 4, 8); + assert_eq!(boxes.len(), 4); + assert!(boxes.iter().all(|(x, _, s)| x + s <= 32), "{boxes:?}"); + // Remove Grain's Noise Samples view draws them. + let shown = run("ec.noise.removegrain", &[("viewingMode", Value::Enum(1))], gray(64, 64, 0.5), 0.0).img; + assert!(shown.data.iter().any(|p| p == &[1.0, 1.0, 0.0, 1.0])); + } + + #[test] + fn remove_grain_temporal_filtering_averages_steady_frames() { + /// Flat grey with per-frame grain. + struct Grainy; + impl crate::EffectHost for Grainy { + fn layer(&self, _: u64, _: bool) -> Option { + None + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + fn self_at(&self, t: f64, _: usize) -> Option { + Some(Buf { img: frame((t * 10.0).round() as u32), offset: [0.0; 2], scale: 1.0 }) + } + } + fn frame(f: u32) -> Image { + crate::util::gen_image(32, 32, |x, y| { + let n = (hash1(x as u32, y as u32, f.wrapping_mul(977)) - 0.5) * 0.06; + [0.5 + n, 0.5 + n, 0.5 + n, 1.0] + }) + } + let env = crate::EffectEnv { host: Some(&Grainy), frame_rate: 10.0, ..Default::default() }; + let vals = [("noiseReductionSettings/noiseReduction", num(0.0)), ("temporalFiltering/enabled", Value::Bool(true))]; + let out = crate::run_fx("ec.noise.removegrain", &vals, frame(5), 0.5, env).img; + let dev = |i: &Image| i.data.iter().map(|p| (p[0] - 0.5).abs()).sum::(); + assert!(dev(&out) < dev(&frame(5)) * 0.75, "{} vs {}", dev(&out), dev(&frame(5))); + } + #[test] fn noise_alpha_cycle_repeats() { let at = |phase: f64| { diff --git a/crates/effects/src/noise2.rs b/crates/effects/src/noise2.rs index cc7667a6..4c22f6d8 100644 --- a/crates/effects/src/noise2.rs +++ b/crates/effects/src/noise2.rs @@ -7,7 +7,7 @@ use effectcraft_project::ParamUi; use effectcraft_raster::Image; use rayon::prelude::*; -use crate::noise::{GrainLook, fbm, grain_params, median_image}; +use crate::noise::{GrainLook, draw_boxes, fbm, grain_params, median_image, preview_params, sampling_boxes, sampling_params}; use crate::util::{Plane, gauss_plane, layer_or_self, premul, unpremul}; use crate::{Buf, EffectCtx, EffectSpec, num, p, popup, slider}; @@ -29,7 +29,14 @@ pub(crate) struct GrainStats { pub size: f32, } +#[cfg(test)] pub(crate) fn grain_stats(img: &Image) -> GrainStats { + grain_stats_masked(img, None) +} + +/// [`grain_stats`] over the pixels `mask` selects (all opaque pixels without one). +pub(crate) fn grain_stats_masked(img: &Image, mask: Option<&[bool]>) -> GrainStats { + let sel = |i: usize| mask.is_none_or(|m| m[i]); let (w, h) = (img.width as usize, img.height as usize); if w < 3 || h < 3 { return GrainStats::default(); @@ -44,7 +51,7 @@ pub(crate) fn grain_stats(img: &Image) -> GrainStats { 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 { + if straight[i].1 > 0.5 && sel(i) { sum += *v as f64; sq += (*v as f64) * (*v as f64); n += 1.0; @@ -57,7 +64,7 @@ pub(crate) fn grain_stats(img: &Image) -> GrainStats { 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 { + if straight[i].1 > 0.5 && straight[i + 1].1 > 0.5 && sel(i) && sel(i + 1) { rho_num += (hp[i] * hp[i + 1]) as f64; rho_den += (hp[i] * hp[i]) as f64; } @@ -68,15 +75,45 @@ pub(crate) fn grain_stats(img: &Image) -> GrainStats { GrainStats { std, size: (1.0 / (1.0 - rho as f32)).clamp(0.5, 20.0) * 0.6 } } +/// Match Grain's Viewing Mode options. +const MATCH_GRAIN_VIEWS: [&str; 5] = ["Preview", "Noise Samples", "Compensation Samples", "Blending Matte", "Final Output"]; + fn match_grain(ctx: &EffectCtx, mut b: Buf) -> Buf { if ctx.params.get("noiseSourceLayer").and_then(Value::as_layer).is_none() { return b; } + // MATCH_GRAIN_VIEWS order. let view = ctx.params.e("viewingMode"); let source = layer_or_self(ctx, &b, "noiseSourceLayer", false, false); - let src_stats = grain_stats(&source); + let src_boxes = sampling_boxes(ctx, &source, b.scale); + let own_boxes = sampling_boxes(ctx, &b.img, b.scale); + let box_col = ctx.params.get("sampling/sampleBoxColor").map(|v| v.as_color()).unwrap_or([1.0, 1.0, 0.0, 1.0]); + if view == 1 || view == 2 { + // Noise Samples (on the source) / Compensation Samples (on this layer). + let (mut img, boxes) = if view == 1 { (source, src_boxes) } else { (b.img.clone(), own_boxes) }; + draw_boxes(&mut img, &boxes, box_col); + b.img = img; + return b; + } + let mask_of = |img: &Image, boxes: &[(usize, usize, usize)]| -> Option> { + if boxes.is_empty() { + return None; + } + let w = img.width as usize; + let mut m = vec![false; img.data.len()]; + for &(x, y, s) in boxes { + for yy in y..y + s { + for xx in x..x + s { + m[yy * w + xx] = true; + } + } + } + Some(m) + }; + let src_stats = grain_stats_masked(&source, mask_of(&source, &src_boxes).as_deref()); let comp = (ctx.params.f("compensateForExistingNoise") / 100.0) as f32; - let own = if comp > 0.0 { grain_stats(&b.img) } else { GrainStats::default() }; + let own = if comp > 0.0 { grain_stats_masked(&b.img, mask_of(&b.img, &own_boxes).as_deref()) } else { GrainStats::default() }; + let orig = b.img.clone(); let look = GrainLook::from_params(ctx); let amp: [f32; 3] = [0, 1, 2].map(|k| { let s = src_stats.std[k]; @@ -100,11 +137,7 @@ fn match_grain(ctx: &EffectCtx, mut b: Buf) -> Buf { let g = look.color(raw); let weight = look.weight(c); 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], + 3 => *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; @@ -114,6 +147,9 @@ fn match_grain(ctx: &EffectCtx, mut b: Buf) -> Buf { } } }); + if view == 0 { + crate::noise::preview_compose(ctx, &mut b, &orig); + } b } @@ -214,13 +250,13 @@ pub fn specs() -> Vec { "ec.noise.matchgrain", "Match Grain", vec![ - p("viewingMode", "Viewing Mode", Value::Enum(0), popup(&["Final Output", "Noise Samples", "Blending Matte"])), + p("viewingMode", "Viewing Mode", Value::Enum(4), popup(&MATCH_GRAIN_VIEWS)), p("noiseSourceLayer", "Noise Source Layer", Value::Layer(None), ParamUi::Layer), p("noiseSourceLayerSource", "Noise Source Layer Source", Value::Enum(0), ParamUi::Hidden), p("compensateForExistingNoise", "Compensate for Existing Noise", num(0.0), pct()), ] .into_iter() - .chain([tweaking, color, application, animation].into_iter().flatten()) + .chain([preview_params(), sampling_params(), tweaking, color, application, animation].into_iter().flatten()) .collect(), match_grain, ), diff --git a/crates/effects/src/noise3.rs b/crates/effects/src/noise3.rs index 7bce1e7a..6a952be1 100644 --- a/crates/effects/src/noise3.rs +++ b/crates/effects/src/noise3.rs @@ -431,7 +431,7 @@ mod tests { fn noise_types_differ_and_block_is_piecewise_constant() { let a = run("ec.noise.fractal", &[("noiseType", Value::Enum(0)), ("complexity", num(1.0)), ("transform/scale", num(10.0))]); // Block noise: neighbours inside one 10 px cell share a value. - assert_eq!(a.get(1, 1), a.get(2, 2)); + assert_eq!(a.get(4, 4), a.get(5, 5)); let others: Vec = (1..4).map(|t| run("ec.noise.fractal", &[("noiseType", Value::Enum(t)), ("transform/scale", num(10.0))])).collect(); assert_ne!(others[0], others[1]); assert_ne!(others[1], others[2]); diff --git a/crates/effects/src/sim2.rs b/crates/effects/src/sim2.rs index 69fd25a6..9e4f092e 100644 --- a/crates/effects/src/sim2.rs +++ b/crates/effects/src/sim2.rs @@ -10,6 +10,7 @@ use effectcraft_project::ParamUi; use effectcraft_raster::{Image, Px}; use rayon::prelude::*; +use crate::card3d::{Lighting, Proj}; 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}; @@ -17,7 +18,7 @@ use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; // ------------------------------------------------------------------ 3D pieces -type M3 = [[f64; 3]; 3]; +pub(crate) 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())) @@ -35,7 +36,7 @@ fn rot_xyz(x: f64, y: f64, z: f64) -> M3 { } /// Rotation by `angle` about unit `axis` (Rodrigues). -fn rot_axis(axis: [f64; 3], angle: f64) -> M3 { +pub(crate) 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]]; @@ -72,9 +73,15 @@ struct Cam { d: f64, } +impl Cam { + fn proj(self) -> Proj { + Proj::simple(self.c, self.d) + } +} + /// A planar textured piece ready to draw. #[derive(Clone, Copy)] -struct Piece { +pub(crate) struct Piece { /// Screen (buffer px, homogeneous) → piece-local (a, b, w). inv: M3, /// Rest centre of the piece in texture pixels. @@ -84,34 +91,43 @@ struct Piece { n: u8, bbox: [f32; 4], shade: f32, - alpha: f32, + /// Lighting: colour multiplier and additive specular highlight. + tint: [f32; 3], + spec: [f32; 3], + pub(crate) alpha: f32, /// Flat straight colour instead of the texture. - flat: Option<[f32; 3]>, + pub(crate) flat: Option<[f32; 3]>, /// Facing away from the camera (draw the back texture). - back: bool, + pub(crate) back: bool, + /// Back faces sample their texture mirrored about the piece centre: 0 no, 1 vertically + /// (flipped about the X axis), 2 horizontally (about the Y axis). + pub(crate) back_mirror: u8, 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 { + /// centre at 3D `pos` (buffer px; z away from the camera), seen through `cam`. + pub(crate) fn new(poly: &[[f32; 2]], c0: [f32; 2], r: &M3, pos: [f64; 3], cam: impl Into) -> Option { + let cam: Proj = cam.into(); 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 m = &cam.m; + let row = |i: usize| -> [f64; 3] { + [ + m[i][0] * r[0][0] + m[i][1] * r[1][0] + m[i][2] * r[2][0], + m[i][0] * r[0][1] + m[i][1] * r[1][1] + m[i][2] * r[2][1], + m[i][0] * pos[0] + m[i][1] * pos[1] + m[i][2] * pos[2] + m[i][3], + ] + }; + let hm: M3 = [row(0), row(1), row(2)]; 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 { + if w < cam.near { return None; } let sx = ((hm[0][0] * a + hm[0][1] * b + hm[0][2]) / w) as f32; @@ -126,8 +142,12 @@ impl Piece { 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]; + // The front faces -z at rest; it faces the camera when its normal points at the eye. + let eye = cam.eye(); + let nrm = [-r[0][2], -r[1][2], -r[2][2]]; + let to_eye = [eye[0] - pos[0], eye[1] - pos[1], eye[2] - pos[2]]; + let facing = nrm[0] * to_eye[0] + nrm[1] * to_eye[1] + nrm[2] * to_eye[2]; + let depth = hm[2][2]; Some(Piece { inv, c0, @@ -135,10 +155,13 @@ impl Piece { n: n as u8, bbox: [bb[0] - 1.0, bb[1] - 1.0, bb[2] + 1.0, bb[3] + 1.0], shade: 1.0, + tint: [1.0; 3], + spec: [0.0; 3], alpha: 1.0, flat: None, - back: nz > 0.0, - depth: zc, + back: facing < 0.0, + back_mirror: 0, + depth, }) } @@ -146,10 +169,23 @@ impl Piece { fn facing(r: &M3) -> f32 { r[2][2].abs() as f32 } + + /// Light the piece (rotation `r`, centre `pos`) with Lighting / Material. + pub(crate) fn light(&mut self, l: &Lighting, r: &M3, pos: [f64; 3], eye: [f64; 3]) { + let (t, s) = l.shade([-r[0][2], -r[1][2], -r[2][2]], pos, eye); + self.tint = t; + self.spec = s; + } +} + +impl From for Proj { + fn from(c: Cam) -> Proj { + c.proj() + } } /// Draw pieces (already sorted far → near) over `out`. -fn draw_pieces(out: &mut Image, pieces: &[Piece], front: &Image, back: Option<&Image>) { +pub(crate) fn draw_pieces(out: &mut Image, pieces: &[Piece], front: &Image, back: Option<&Image>) { raster( out, pieces, @@ -181,6 +217,11 @@ fn draw_pieces(out: &mut Image, pieces: &[Piece], front: &Image, back: Option<&I Some(c) => (c, 1.0), None => { let tex = if q.back { back.unwrap_or(front) } else { front }; + let (u, v) = match (q.back, q.back_mirror) { + (true, 1) => (u, 2.0 * q.c0[1] - v), + (true, 2) => (2.0 * q.c0[0] - u, v), + _ => (u, v), + }; unpremul(tex.sample_bilinear(u as f64, v as f64)) } }; @@ -189,13 +230,14 @@ fn draw_pieces(out: &mut Image, pieces: &[Piece], front: &Image, back: Option<&I return None; } let k = q.shade; - Some([c[0] * k * a, c[1] * k * a, c[2] * k * a, a]) + let o: [f32; 3] = std::array::from_fn(|i| c[i] * k * q.tint[i] + q.spec[i]); + Some([o[0] * a, o[1] * a, o[2] * a, a]) }, Acc::Over, ); } -fn sort_far_first(pieces: &mut [Piece]) { +pub(crate) fn sort_far_first(pieces: &mut [Piece]) { pieces.sort_by(|a, b| b.depth.total_cmp(&a.depth)); } @@ -430,12 +472,9 @@ fn card_dance(ctx: &EffectCtx, mut b: Buf) -> Buf { 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("cameraPosition/cameraZ").max(0.05); - let focal = pr.f("cameraPosition/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("lighting/ambientLight") as f32; - let diffuse = pr.f("material/diffuse") as f32; - let intensity = pr.f("lighting/lightIntensity") as f32; + let cam = crate::card3d::projection(ctx, &b); + let eye = cam.eye(); + let light = Lighting::from(ctx, &b); let props: Vec<(u32, f64, f64)> = CD_PROPS.iter().map(|(id, g)| (pr.e(&format!("{g}/{id}Source")), pr.f(&format!("{g}/{id}Multiplier")), pr.f(&format!("{g}/{id}Offset")))).collect(); let src = b.img.clone(); @@ -458,7 +497,7 @@ fn card_dance(ctx: &EffectCtx, mut b: Buf) -> Buf { 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); + pc.light(&light, &r, pos, eye); Some(pc) }) .collect(); @@ -471,7 +510,8 @@ fn card_dance(ctx: &EffectCtx, mut b: Buf) -> Buf { // ------------------------------------------------------------------ Shatter -const SHATTER_PATTERNS: &[&str] = &["Bricks", "Glass", "Hexagons", "Squares", "Triangles"]; +/// Shatter's Pattern options; the last, Custom, takes its pieces from Custom Shatter Map. +const SHATTER_PATTERNS: &[&str] = &["Bricks", "Glass", "Hexagons", "Squares", "Triangles", "Custom"]; /// 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> { @@ -570,12 +610,40 @@ fn drag_factors(k: f64, t: f64) -> (f64, f64) { ((1.0 - e) / k, t / k - (1.0 - e) / (k * k)) } } +/// Shatter's Front / Side / Back Mode options. +const SHATTER_TEXTURE_MODES: [&str; 3] = ["Color", "Layer", "Tinted Layer"]; + +/// One face's texture: a flat colour, a layer, or a layer tinted by the Textures colour. +struct Face { + mode: u32, + img: Option, +} + +impl Face { + fn from(ctx: &EffectCtx, b: &Buf, mode: &str, layer: &str, default_mode: u32) -> Face { + let mode = ctx.params.get(mode).map(|v| v.as_enum()).unwrap_or(default_mode); + // A face layer of None is the effect's own layer. + let img = (mode != 0).then(|| layer_or_self(ctx, b, layer, true, true)); + Face { mode, img } + } +} + +/// Group key of a Custom Shatter Map colour (pieces of one colour break as one). +fn map_key(c: [f32; 4]) -> u32 { + let (c, a) = unpremul(c); + if a < 0.5 { + return u32::MAX; + } + let q = |v: f32| ((v.clamp(0.0, 1.0) * 15.0).round() as u32) & 15; + q(c[0]) << 8 | q(c[1]) << 4 | q(c[2]) +} 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("shape/pattern"); + let custom = kind as usize == SHATTER_PATTERNS.len() - 1; let reps = pr.f("shape/repetitions").clamp(1.0, 500.0); let dir = pr.f("shape/direction").to_radians(); let origin = pr.v2("shape/origin"); @@ -591,15 +659,29 @@ fn shatter(ctx: &EffectCtx, mut b: Buf) -> Buf { let gravity = pr.f("physics/gravity"); let gdir = pr.f("physics/gravityDirection").to_radians(); let ginc = pr.f("physics/gravityInclination").to_radians(); - let ambient = pr.f("lighting/ambientLight") as f32; - let intensity = pr.f("lighting/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 extrude = pr.f("shape/extrusionDepth").max(0.0) * cell * s; + let cam = crate::card3d::projection(ctx, &b); + let eye = cam.eye(); + let light = Lighting::from(ctx, &b); + // Gradient: with a gradient layer only pieces at least as bright as 1 − Shatter Threshold + // (Invert Gradient: as dark) break. + let gradient = ctx.layer_param("gradient/gradientLayer", true).map(|o| crate::util::fit_layer(ctx, &b, &o, true)); + let threshold = pr.get("gradient/shatterThreshold").map(Value::as_f64).unwrap_or(0.0) / 100.0; + let invert_gradient = pr.b("gradient/invertGradient"); + // Textures. + let tex_color = pr.get("textures/color").map(|v| v.as_color()).unwrap_or([1.0; 4]); + let tex_opacity = pr.get("textures/opacity").map(Value::as_f64).unwrap_or(1.0).clamp(0.0, 1.0) as f32; + let front = Face::from(ctx, &b, "textures/frontMode", "textures/frontLayer", 1); + let side = Face::from(ctx, &b, "textures/sideMode", "textures/sideLayer", 0); + let backf = Face::from(ctx, &b, "textures/backMode", "textures/backLayer", 1); + let custom_map = custom.then(|| layer_or_self(ctx, &b, "shape/customShatterMap", true, true)); + let white_fixed = pr.b("shape/whiteTilesFixed"); 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. @@ -609,21 +691,45 @@ fn shatter(ctx: &EffectCtx, mut b: Buf) -> Buf { }); 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); + // Custom Shatter Map: square cells grouped by the map's colour. + let cells = pattern_cells(if custom { 3 } else { kind }, cell, px0, py0, px1, py1, seed); + let polys: Vec> = cells.iter().map(|poly| poly.iter().map(|&q| to_layer(q)).collect()).collect(); + let centre = |lp: &[[f64; 2]]| [lp.iter().map(|v| v[0]).sum::() / lp.len() as f64, lp.iter().map(|v| v[1]).sum::() / lp.len() as f64]; + let keys: Vec = match &custom_map { + Some(m) => polys + .iter() + .map(|lp| { + let c = centre(lp); + map_key(m.sample_bilinear(c[0] * s + b.offset[0], c[1] * s + b.offset[1])) + }) + .collect(), + None => (0..polys.len() as u32).collect(), + }; + // Group centres (custom map) — each group moves as one rigid piece. + let mut groups: std::collections::HashMap = std::collections::HashMap::new(); + for (lp, &key) in polys.iter().zip(&keys) { + let c = centre(lp); + let e = groups.entry(key).or_insert(([0.0; 2], 0.0)); + e.0[0] += c[0]; + e.0[1] += c[1]; + e.1 += 1.0; + } 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 + let mut pieces: Vec = polys .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(); + .zip(keys.par_iter()) + .flat_map_iter(|(lp, &key)| { + let mut out: Vec = vec![]; 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; + if mxx < 0.0 || mxy < 0.0 || mnx > lw || mny > lh || key == u32::MAX { + return out.into_iter(); } - let c = [lp.iter().map(|v| v[0]).sum::() / lp.len() as f64, lp.iter().map(|v| v[1]).sum::() / lp.len() as f64]; + let pc_c = centre(lp); + let (gsum, gn) = groups[&key]; + let gc = [gsum[0] / gn, gsum[1] / gn]; + let i = key; // Broken when inside a force sphere (centred `depth` in front of the layer). let mut v = [0.0f64; 3]; let mut broken = false; @@ -632,7 +738,7 @@ fn shatter(ctx: &EffectCtx, mut b: Buf) -> Buf { if rad <= 0.0 { continue; } - let dv = [c[0] - fp[0], c[1] - fp[1], fd * lw]; + let dv = [gc[0] - fp[0], gc[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; @@ -642,19 +748,31 @@ fn shatter(ctx: &EffectCtx, mut b: Buf) -> Buf { } } } - if (broken && render == 1) || (!broken && render == 2) { - return None; + if broken && let Some(g) = &gradient { + let (c, _) = unpremul(g.sample_bilinear(gc[0] * s + b.offset[0], gc[1] * s + b.offset[1])); + let mut l = effectcraft_color::luminance(c[0], c[1], c[2]).clamp(0.0, 1.0) as f64; + if invert_gradient { + l = 1.0 - l; + } + broken = threshold > 0.0 && l >= 1.0 - threshold; } - let mut pos = [c[0], c[1], 0.0]; + if broken && custom && white_fixed && key == 0xfff { + broken = false; + } + if (broken && render == 1) || (!broken && render == 2) { + return out.into_iter(); + } + let mut gpos = [gc[0], gc[1], 0.0]; let mut r = rot_axis([1.0, 0.0, 0.0], 0.0); - if broken && t > 0.0 { + let moving = broken && t > 0.0; + if moving { 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; + gpos[q] += v[q] * f1 + g3[q] * f2; } let axis = match tumble { 1 => [0.0, 0.0, 0.0], @@ -670,18 +788,66 @@ fn shatter(ctx: &EffectCtx, mut b: Buf) -> Buf { 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); } + // The piece's centre: its offset from the group centre, rotated with the group. + let off = [pc_c[0] - gc[0], pc_c[1] - gc[1], 0.0]; + let pos: [f64; 3] = std::array::from_fn(|q| gpos[q] + (0..3).map(|k| r[q][k] * off[k]).sum::()); 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 c0 = [(pc_c[0] * s + b.offset[0]) as f32, (pc_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) + let Some(mut pc) = Piece::new(&tex, c0, &r, posb, cam) else { return out.into_iter() }; + pc.light(&light, &r, posb, eye); + pc.alpha = tex_opacity; + let face = if pc.back { &backf } else { &front }; + if face.mode == 0 { + pc.flat = Some([tex_color[0], tex_color[1], tex_color[2]]); + } else if face.mode == 2 { + pc.tint = std::array::from_fn(|q| pc.tint[q] * tex_color[q]); + } + // Extrusion: the sides of a moving piece, from its front outline back by the depth. + if moving && extrude > 0.0 { + let nb = [r[0][2], r[1][2], r[2][2]]; + let n = tex.len(); + for e in 0..n { + let (a, bv) = (tex[e], tex[(e + 1) % n]); + let ed = [(bv[0] - a[0]) as f64, (bv[1] - a[1]) as f64]; + let len = (ed[0] * ed[0] + ed[1] * ed[1]).sqrt(); + if len < 1e-6 { + continue; + } + let ew: [f64; 3] = std::array::from_fn(|q| (r[q][0] * ed[0] + r[q][1] * ed[1]) / len); + let third = [ew[1] * nb[2] - ew[2] * nb[1], ew[2] * nb[0] - ew[0] * nb[2], ew[0] * nb[1] - ew[1] * nb[0]]; + let rs: M3 = std::array::from_fn(|q| [ew[q], nb[q], third[q]]); + // Side centre in world: edge midpoint (world) plus half the depth backwards. + let mid = [((a[0] + bv[0]) * 0.5 - c0[0]) as f64, ((a[1] + bv[1]) * 0.5 - c0[1]) as f64]; + let mw: [f64; 3] = std::array::from_fn(|q| posb[q] + r[q][0] * mid[0] + r[q][1] * mid[1] + nb[q] * extrude * 0.5); + let (hl, hd) = ((len * 0.5) as f32, (extrude * 0.5) as f32); + let cs = [(a[0] + bv[0]) * 0.5, (a[1] + bv[1]) * 0.5]; + let quad = [[cs[0] - hl, cs[1] - hd], [cs[0] + hl, cs[1] - hd], [cs[0] + hl, cs[1] + hd], [cs[0] - hl, cs[1] + hd]]; + if let Some(mut sp) = Piece::new(&quad, cs, &rs, mw, cam) { + sp.light(&light, &rs, mw, eye); + sp.alpha = tex_opacity; + let colour = match (&side.img, side.mode) { + (Some(img), m) if m != 0 => { + let (c, _) = unpremul(img.sample_bilinear(cs[0] as f64, cs[1] as f64)); + if m == 2 { std::array::from_fn(|q| c[q] * tex_color[q]) } else { c } + } + _ => [tex_color[0], tex_color[1], tex_color[2]], + }; + sp.flat = Some(colour); + out.push(sp); + } + } + } + out.push(pc); + out.into_iter() }) .collect(); sort_far_first(&mut pieces); let mut out = Image::new(src.width, src.height); + let front_img = front.img.as_ref().unwrap_or(&src); + let back_img = backf.img.as_ref().unwrap_or(front_img); if view == 0 { - draw_pieces(&mut out, &pieces, &src, None); + draw_pieces(&mut out, &pieces, front_img, Some(back_img)); } else { // Wireframes: piece outlines (front view: at rest; otherwise animated), plus forces. let animated = view == 2 || view == 4; @@ -781,11 +947,38 @@ fn caustics(ctx: &EffectCtx, mut b: Buf) -> Buf { 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(); + // Light Type: Distant Source (one direction, from the light position above the layer + // centre), Point Source (from the light position to each pixel) or First Comp Light. + let light_type = pr.e("lighting/lightType"); + let comp_light = if light_type == 2 { ctx.env.host.and_then(|h| h.comp_scene()).and_then(|sc| sc.light) } else { None }; + let (lc, li, lx, ly, lheight, point) = match (light_type, comp_light) { + (2, Some(l)) => { + let c = [l.color[0], l.color[1], l.color[2], 1.0]; + // Comp space: z away from the viewer; the light above the water is at −z. + let (px, py) = (l.pos[0] * s + b.offset[0], l.pos[1] * s + b.offset[1]); + (c, li, px, py, (-l.pos[2] / ctx.layer_size[0].max(1.0)).max(0.01), l.kind == 1) + } + (2, None) => (lc, 0.0, lx, ly, lheight, false), + (1, _) => (lc, li, lx, ly, lheight, true), + _ => (lc, li, lx, ly, lheight, false), + }; + let norm3 = |v: [f64; 3]| { + let n = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-12); [(v[0] / n) as f32, (v[1] / n) as f32, (v[2] / n) as f32] }; + let lv_at = |x: f64, y: f64| { + if point { + norm3([(lx - x) / lw.max(1.0), (ly - y) / lw.max(1.0), lheight]) + } else { + norm3([(lx - cx) / lw.max(1.0), (ly - cy) / lw.max(1.0), lheight]) + } + }; + // Sky: the layer the water surface reflects (Surface Opacity 1 = a mirror of it). + let sky = ctx.layer_param("sky/sky", true).map(|o| crate::util::fit_layer(ctx, &b, &o, pr.e("sky/skySizeDiffers") == 1)); + let sky_scaling = pr.get("sky/scaling").map(Value::as_f64).unwrap_or(1.0).max(0.01); + let sky_repeat = pr.e("sky/repeatMode"); + let sky_int = pr.get("sky/intensity").map(Value::as_f64).unwrap_or(0.3) as f32; + let convergence = pr.get("sky/convergence").map(Value::as_f64).unwrap_or(0.5).clamp(0.0, 1.0); let grad = |x: usize, y: usize| -> (f64, f64) { match &height { Some(pl) => { @@ -825,6 +1018,7 @@ fn caustics(ctx: &EffectCtx, mut b: Buf) -> Buf { 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 lv = lv_at(x as f64 + 0.5, y as f64 + 0.5); 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]; @@ -833,9 +1027,21 @@ fn caustics(ctx: &EffectCtx, mut b: Buf) -> Buf { }; 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; + // The surface: its colour lit like the water, or the sky it reflects (sampled along + // the surface normal; Convergence pulls the reflection towards the pixel itself). + let surface: [f32; 3] = match &sky { + Some(sk) => { + let reach = (1.0 - convergence) * lw * 0.5; + let (rx, ry) = (x as f64 + 0.5 + n[0] as f64 * reach, y as f64 + 0.5 + n[1] as f64 * reach); + let (rx, ry) = (cx + (rx - cx) / sky_scaling, cy + (ry - cy) / sky_scaling); + let (sc, _) = unpremul(sample_repeat(sk, rx, ry, sky_repeat)); + std::array::from_fn(|q| sc[q] * sky_int * 3.0 * surf[q]) + } + None => std::array::from_fn(|q| surf[q] * (ambient + diffuse * li * nl)), + }; 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] + base * (1.0 - surf_op) + surface[q] * surf_op + 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]; @@ -919,7 +1125,36 @@ fn wave_world(ctx: &EffectCtx, mut b: Buf) -> Buf { 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("simulation/gridResDownsamples") { b.scale } else { 1.0 } }, 4); + // Ground: the ground layer's first frame, its luminance an elevation (× Steepness). Where + // it rises above Height the grid is dry (a wall the waves reflect from); shallow water + // slows the waves by Wave Strength. + let ground = pr.get("ground/ground").and_then(Value::as_layer).and_then(|id| { + let host = ctx.env.host?; + host.layer_at(id, ctx.env.comp_time - ctx.time, true) + }); + let steep = pr.get("ground/steepness").map(Value::as_f64).unwrap_or(0.25) as f32; + let level = pr.get("ground/height").map(Value::as_f64).unwrap_or(0.25) as f32; + let wave_strength = pr.f("ground/waveStrength").clamp(0.0, 1.0) as f32; + // Water depth per grid point (None without ground: deep everywhere). + let depth: Option> = ground.as_ref().map(|lp| { + let (gw, gh) = (lp.size[0].max(1.0), lp.size[1].max(1.0)); + (0..nx * ny) + .map(|i| { + let (fx, fy) = ((i % nx) as f64 / (nx - 1) as f64, (i / nx) as f64 / (ny - 1) as f64); + let (c, a) = unpremul(lp.buf.img.sample_bilinear_clamped(fx * gw * lp.buf.scale + lp.buf.offset[0], fy * gh * lp.buf.scale + lp.buf.offset[1])); + level - steep * effectcraft_color::luminance(c[0], c[1], c[2]) * a + }) + .collect() + }); + let speed_k: Vec = match &depth { + Some(d) => d.iter().map(|&v| if v <= 0.0 { 0.0 } else { 1.0 - wave_strength * (1.0 - (v / level.max(1e-3)).clamp(0.0, 1.0).sqrt()) }).collect(), + None => vec![1.0; nx * ny], + }; + let mut key = + params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: if pr.b("simulation/gridResDownsamples") { b.scale } else { 1.0 } }, 4); + if let Some(d) = &depth { + key ^= d.iter().fold(0u64, |a, v| a.rotate_left(3) ^ v.to_bits() as u64); + } let steps = crate::sim::steps_at(ctx.time + preroll); let st = WAVE_CACHE.run( key, @@ -935,7 +1170,7 @@ fn wave_world(ctx: &EffectCtx, mut b: Buf) -> Buf { 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]; + acc[i] = c2 * speed_k[i] * speed_k[i] * l - damping * 4.0 * w.v[i]; } } for i in 0..nx * ny { @@ -955,6 +1190,13 @@ fn wave_world(ctx: &EffectCtx, mut b: Buf) -> Buf { } } } + // Dry ground holds the surface flat. + for i in 0..nx * ny { + if speed_k[i] <= 0.0 { + w.u[i] = 0.0; + w.v[i] = 0.0; + } + } // 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); @@ -1002,22 +1244,38 @@ fn wave_world(ctx: &EffectCtx, mut b: Buf) -> Buf { let contrast = pr.f("heightMapControls/contrast") as f32; let gamma = pr.f("heightMapControls/gamma").max(0.01) as f32; let alpha = 1.0 - pr.f("heightMapControls/transparency") as f32; + let dry_transparent = pr.e("heightMapControls/renderDryAreasAs") == 1; + let dry_at = |x: f64, y: f64| -> bool { + let Some(d) = &depth else { return false }; + let gx = (x / lw.max(1.0) * (st.nx - 1) as f64).round().clamp(0.0, (st.nx - 1) as f64) as usize; + let gy = (y / lw.max(1.0) * (st.nx - 1) as f64).round().clamp(0.0, (st.ny - 1) as f64) as usize; + d[gy * st.nx + gx] <= 0.0 + }; 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]; + let al = if dry_transparent && dry_at(lx, ly) { 0.0 } else { alpha }; + *px = [v * al, v * al, v * al, al]; } }); } else { - // Wireframe preview: the grid seen at an angle, heights lifting the lines. - let lift = lh as f32 * 0.25; + // Wireframe preview: the grid turned by Horizontal Rotation about the vertical axis, + // tilted by Vertical Rotation, heights scaled by Vertical Scale. + let hr = pr.f("wireframeControls/horizontalRotation").to_radians(); + let vr = pr.get("wireframeControls/verticalRotation").map(Value::as_f64).unwrap_or(53.0).to_radians(); + let vs = pr.get("wireframeControls/verticalScale").map(Value::as_f64).unwrap_or(0.5); + let (sh, ch) = hr.sin_cos(); + let (sv, cv) = vr.sin_cos(); 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] + // Grid point relative to the layer centre (layer px), height in layer px. + let gx = x as f64 / (st.nx - 1) as f64 * lw - lw * 0.5; + let gy = y as f64 / (st.nx - 1) as f64 * lw - (st.ny - 1) as f64 / (st.nx - 1) as f64 * lw * 0.5; + let hgt = st.u[y * st.nx + x] as f64 * vs * lh * 0.5; + let (rx, ry) = (gx * ch - gy * sh, gx * sh + gy * ch); + let (px, py) = b.to_px([lw * 0.5 + rx, lh * 0.5 + ry * cv - hgt * sv]); + [px as f32, py as f32] }; let mut lines = Vec::new(); for y in 0..st.ny { @@ -1047,6 +1305,8 @@ struct FoamBubble { v: [f32; 2], age: f32, size: f32, + /// Spin (radians) for Physical Orientation. + spin: f32, id: u32, } @@ -1062,12 +1322,20 @@ static FOAM_CACHE: SimCache = SimCache::new(4); /// Foam steps once per frame at 30 frames per second. const FOAM_FPS: f64 = 30.0; +/// Foam's Bubble Texture options (the last uses Bubble Texture Layer). +const FOAM_TEXTURES: [&str; 6] = ["Default Bubble", "Spit", "Bubblegum", "Dishwater", "Milky", "User Defined"]; + /// Age (in simulation steps) at which bubble `id` pops: its lifespan, shortened at random for /// frail bubbles (`frailty` 0 = every bubble reaches `lifespan`, 1 = uniformly early). fn foam_pop_age(id: u32, lifespan: f32, frailty: f32, seed: u32) -> f32 { lifespan * (1.0 - frailty * (hs(id, 11, seed) * 0.5 + 0.5)) } +/// Foam: bubbles born in the producer rectangle grow to their size, drift with the wind, +/// turbulence and the Flow Map (bubbles run downhill on its luminance), repel and stick to each +/// other, wobble, and pop at the end of their (Strength-shortened) lifespan, pushing their +/// neighbours away at Pop Velocity. Rendered bubbles use a built-in texture or the Bubble +/// Texture Layer, oriented by Bubble Orientation, and reflect the Environment Map. 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); @@ -1088,14 +1356,50 @@ fn foam(ctx: &EffectCtx, mut b: Buf) -> Buf { let wind = pr.f("physics/windSpeed") as f32; let wind_dir = (pr.f("physics/windDirection") as f32).to_radians(); let turb = pr.f("physics/turbulence") as f32; + let wobble = pr.f("physics/wobbleAmount").max(0.0) as f32; let repulsion = pr.f("physics/repulsion") as f32; + let pop_velocity = pr.f("physics/popVelocity") as f32; let viscosity = pr.f("physics/viscosity").clamp(0.0, 1.0) as f32; let sticky = pr.f("physics/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); + // Flow Map: the map layer's first frame, its luminance a height field (Fits the layer or + // the whole universe). + let flow = pr.get("flowMap/flowMap").and_then(Value::as_layer).and_then(|id| { + let host = ctx.env.host?; + let me = pr.get(&crate::layer_source_id("flowMap/flowMap")).is_none_or(|v| v.as_enum() != 0); + host.layer_at(id, ctx.env.comp_time - ctx.time, me) + }); + let steep = pr.f("flowMap/flowMapSteepness") as f32; + let fits_universe = pr.e("flowMap/flowMapFits") == 1; + let substeps = match pr.e("flowMap/simulationQuality") { + 1 => 2, + 2 => 4, + _ => 1, + }; + let height = flow.as_ref().map(|lp| { + let img = &lp.buf.img; + let pl = Plane::from_image(img, |px| { + let (c, a) = unpremul(px); + effectcraft_color::luminance(c[0], c[1], c[2]) * a + }); + (pl, lp.buf.scale, lp.buf.offset, lp.size) + }); + let (ux0, uy0) = (-(universe - 1.0) * 0.5 * lw, -(universe - 1.0) * 0.5 * lh); + let (ux1, uy1) = (lw - ux0, lh - uy0); + // Height at a layer point (0..1). + let h_at = |x: f32, y: f32| -> f32 { + let Some((pl, s, off, size)) = &height else { return 0.0 }; + let (fx, fy) = if fits_universe { ((x - ux0) / (ux1 - ux0), (y - uy0) / (uy1 - uy0)) } else { (x / lw, y / lh) }; + pl.sample(fx as f64 * size[0] * s + off[0], fy as f64 * size[1] * s + off[1]) + }; + let mut key = params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 5); + if let Some((pl, ..)) = &height { + key ^= pl.data.iter().step_by(5).fold(0u64, |a, v| a.rotate_left(3) ^ v.to_bits() as u64); + } let steps = if ctx.time <= 0.0 { 0 } else { (ctx.time * FOAM_FPS + 1e-6).floor() as u64 }; + let rad = |q: &FoamBubble| q.size * unit * 3.0 * (q.age * growth).min(1.0); let st = FOAM_CACHE.run(key, steps, FoamState::default, |st, step| { let t = step as f32 / FOAM_FPS as f32; // Births. @@ -1113,73 +1417,104 @@ fn foam(ctx: &EffectCtx, mut b: Buf) -> Buf { 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 }); + st.b.push(FoamBubble { p, v, age: 0.0, size: sz, spin: hs(id, 12, seed) * std::f32::consts::PI, 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; + for _ in 0..substeps { + let k = 1.0 / substeps as f32; + // Forces: drift towards the wind, turbulence, viscosity, the flow map's slope. + 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 c in 0..2 { + q.v[c] += (wvec[c] - q.v[c]) * 0.1 * k; + q.v[c] *= 1.0 - viscosity * 0.5 * k; + } + q.v[0] += n1 * turb * unit * k; + q.v[1] += n2 * turb * unit * k; + if height.is_some() && steep != 0.0 { + let e = unit.max(1.0); + let gx = (h_at(q.p[0] + e, q.p[1]) - h_at(q.p[0] - e, q.p[1])) / (2.0 * e); + let gy = (h_at(q.p[0], q.p[1] + e) - h_at(q.p[0], q.p[1] - e)) / (2.0 * e); + // Downhill: from light (high) to dark (low). + q.v[0] -= gx * steep * lw * 0.5 * k; + q.v[1] -= gy * steep * lw * 0.5 * k; + } + // Physical Orientation: bubbles roll with their sideways motion. + q.spin += q.v[0] / (rad(q).max(0.5)) * k; } - 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; + // Repulsion between overlapping bubbles (grid hashed, deterministic order). + 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 * k; + 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); + } } - 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); + for q in st.b.iter_mut() { + q.p[0] += q.v[0] * k; + q.p[1] += q.v[1] * k; } } - 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; } + // Pops: bubbles past their pop age burst, pushing their neighbours away. + let popped: Vec<([f32; 2], f32)> = st.b.iter().filter(|q| q.age >= foam_pop_age(q.id, lifespan, frailty, seed)).map(|q| (q.p, rad(q))).collect(); + if pop_velocity != 0.0 { + for (pp, pr_) in &popped { + let reach = (pr_ * 3.0).max(unit); + for q in st.b.iter_mut() { + let (dx, dy) = (q.p[0] - pp[0], q.p[1] - pp[1]); + let d = (dx * dx + dy * dy).sqrt(); + if d > 1e-4 && d < reach { + let f = pop_velocity * unit * (1.0 - d / reach) / d; + q.v[0] += dx * f; + q.v[1] += dy * f; + } + } + } + } st.b.retain(|q| q.age < foam_pop_age(q.id, lifespan, frailty, seed) && 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 zoom_producer = pr.b("producer/zoomProducerPoint"); let texture = pr.e("rendering/bubbleTexture"); + let orient = pr.e("rendering/bubbleOrientation"); let blend = pr.e("rendering/blendMode"); let s = b.scale as f32; - let (cx, cy) = (lw * 0.5, lh * 0.5); + // Zoom centres on the layer, or on the producer with Zoom Producer Point. + let (cx, cy) = if zoom_producer { (prod[0] as f32, prod[1] as f32) } else { (lw * 0.5, lh * 0.5) }; let mut order: Vec<&FoamBubble> = st.b.iter().collect(); if blend == 2 { // Solid New on Top: oldest first. @@ -1187,14 +1522,27 @@ fn foam(ctx: &EffectCtx, mut b: Buf) -> Buf { } else if blend == 1 { order.sort_by(|a, c| a.age.total_cmp(&c.age).then(a.id.cmp(&c.id))); } + // Where a bubble is drawn: buffer centre, radius, rotation (radians). + let place = |q: &FoamBubble| { + let wob = if wobble > 0.0 { 1.0 + wobble * 0.15 * ((q.age * 0.35) + hs(q.id, 13, seed) * 6.0).sin() } else { 1.0 }; + let r = rad(q) * wob * 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 rot = match orient { + 1 => q.spin, + 2 => q.v[1].atan2(q.v[0]), + _ => 0.0, + }; + (bx, by, r, rot) + }; + let user_tex = (view == 2 && texture == 5).then(|| ctx.layer_param("rendering/bubbleTextureLayer", true)).flatten(); let sprites: Vec = order .iter() + .filter(|_| user_tex.is_none()) .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 (bx, by, r, rot) = place(q); let fade = ((foam_pop_age(q.id, lifespan, frailty, seed) - q.age) / 5.0).clamp(0.0, 1.0); // Draft and Draft + Flow Map show the wireframe-style bubbles. - if view != 2 { + let mut sp = if view != 2 { Sprite::new(bx as f32, by as f32, r, [0.55, 0.75, 1.0, fade], Shape::Bubble) } else { let (c, shape) = match texture { @@ -1205,13 +1553,91 @@ fn foam(ctx: &EffectCtx, mut b: Buf) -> Buf { _ => ([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) - } + }; + sp.rot = rot; + sp }) .collect(); - b.img = splat(b.img.width, b.img.height, &sprites, if blend == 0 && view == 2 { Acc::Add } else { Acc::Over }); + let mut out = splat(b.img.width, b.img.height, &sprites, if blend == 0 && view == 2 { Acc::Add } else { Acc::Over }); + // User Defined: the texture layer fills each bubble's disc. + if let Some(tex) = &user_tex { + for q in &order { + let (bx, by, r, rot) = place(q); + let fade = ((foam_pop_age(q.id, lifespan, frailty, seed) - q.age) / 5.0).clamp(0.0, 1.0); + foam_disc(&mut out, bx, by, r as f64, |u, v| { + // u, v in −1..1 across the bubble, rotated into the texture. + let (sr, cr) = rot.sin_cos(); + let (tu, tv) = (u as f32 * cr + v as f32 * sr, -u as f32 * sr + v as f32 * cr); + let (x, y) = ((tu as f64 * 0.5 + 0.5) * tex.size[0], (tv as f64 * 0.5 + 0.5) * tex.size[1]); + let px = tex.buf.img.sample_bilinear(x * tex.buf.scale + tex.buf.offset[0], y * tex.buf.scale + tex.buf.offset[1]); + px.map(|c| c * fade) + }); + } + } + // Environment Map: each bubble reflects the map along its sphere normal (Reflection + // Convergence pulls the reflection towards the bubble's own spot of the map). + let strength = pr.f("rendering/reflectionStrength").clamp(0.0, 1.0) as f32; + if view == 2 + && strength > 0.0 + && let Some(env) = ctx.layer_param("rendering/environmentMap", true) + { + let env_img = crate::util::fit_layer(ctx, &b, &env, true); + let conv = pr.f("rendering/reflectionConvergence").clamp(0.0, 1.0); + let (w, h) = (b.img.width as f64, b.img.height as f64); + for q in &order { + let (bx, by, r, _) = place(q); + foam_disc(&mut out, bx, by, r as f64, |u, v| { + // Sphere normal; the reflection looks across the whole map, or (convergence 1) + // just behind the bubble. + let (ex, ey) = + if conv >= 1.0 { (bx, by) } else { (bx * conv + (u * 0.5 + 0.5) * w * (1.0 - conv), by * conv + (v * 0.5 + 0.5) * h * (1.0 - conv)) }; + let e = env_img.sample_bilinear(ex, ey); + let rim = ((u * u + v * v) as f32).min(1.0); + let k = strength * (0.3 + 0.7 * rim); + [e[0] * k, e[1] * k, e[2] * k, 0.0] + }); + } + } + // Draft + Flow Map shows the flow map under the bubbles. + if view == 1 + && let Some(lp) = &flow + { + let fm = crate::util::fit_layer(ctx, &b, lp, true); + out.data.par_iter_mut().zip(fm.data.par_iter()).for_each(|(o, f)| { + let k = 1.0 - o[3]; + for c in 0..4 { + o[c] += f[c] * 0.5 * k; + } + }); + } + b.img = out; b } +/// Add `f(u, v)` (premultiplied; u, v ∈ −1..1 across the disc) over the disc of radius `r` at +/// (`x`, `y`) with anti-aliased edges ("over" for opaque texels, additive for alpha 0). +fn foam_disc(out: &mut Image, x: f64, y: f64, r: f64, f: impl Fn(f64, f64) -> Px) { + if r <= 0.0 { + return; + } + let (x0, x1) = ((x - r - 1.0).floor().max(0.0) as i64, (x + r + 1.0).ceil().min(out.width as f64) as i64); + let (y0, y1) = ((y - r - 1.0).floor().max(0.0) as i64, (y + r + 1.0).ceil().min(out.height as f64) as i64); + for py in y0..y1 { + for px in x0..x1 { + let (u, v) = ((px as f64 + 0.5 - x) / r, (py as f64 + 0.5 - y) / r); + let d = (u * u + v * v).sqrt(); + let cov = ((1.0 - d) * r + 0.5).clamp(0.0, 1.0) as f32; + if cov <= 0.0 { + continue; + } + let s = f(u.clamp(-1.0, 1.0), v.clamp(-1.0, 1.0)).map(|c| c * cov); + let o = out.get(px, py); + let k = 1.0 - s[3]; + out.set(px as u32, py as u32, [s[0] + o[0] * k, s[1] + o[1] * k, s[2] + o[2] * k, (s[3] + o[3] * k).min(1.0)]); + } + } +} + // ------------------------------------------------------------------ specs fn producer_params(k: u32, amp: f64, pos: (f64, f64)) -> Vec { @@ -1277,13 +1703,9 @@ fn card_dance_params() -> Vec { 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("cameraPosition/cameraZ", "Z Position", num(2.0), slider(0.05, 10.0, 0.5, 5.0, 2)), - p("cameraPosition/focalLength", "Focal Length", num(70.0), slider(1.0, 500.0, 10.0, 200.0, 1)), - p("lighting/lightIntensity", "Light Intensity", num(1.0), slider(0.0, 4.0, 0.0, 2.0, 2)), - p("lighting/ambientLight", "Ambient Light", num(0.25), slider(0.0, 2.0, 0.0, 1.0, 2)), - p("material/diffuse", "Diffuse Reflection", num(0.75), slider(0.0, 2.0, 0.0, 1.0, 2)), - ]); + v.extend(crate::card3d::camera_params(2.0)); + v.extend(crate::card3d::lighting_params(1.0, 0.25)); + v.extend(crate::card3d::material_params(0.75)); v } @@ -1350,6 +1772,13 @@ pub fn specs() -> Vec { p("water/surfaceColor", "Surface Color", col(1.0, 1.0, 1.0), ParamUi::Color), p("water/surfaceOpacity", "Surface Opacity", num(0.3), slider(0.0, 1.0, 0.0, 1.0, 3)), p("water/causticsStrength", "Caustics Strength", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("sky/sky", "Sky", Value::Layer(None), ParamUi::Layer), + p("sky/scaling", "Scaling", num(1.0), slider(0.01, 10.0, 0.1, 3.0, 3)), + p("sky/repeatMode", "Repeat Mode", Value::Enum(2), popup(&["Once", "Tiled", "Reflected"])), + p("sky/skySizeDiffers", "If Layer Size Differs", Value::Enum(1), popup(&["Center", "Stretch to Fit"])), + p("sky/intensity", "Intensity", num(0.3), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("sky/convergence", "Convergence", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("lighting/lightType", "Light Type", Value::Enum(0), popup(&crate::card3d::LIGHT_TYPES)), p("lighting/lightIntensity", "Light Intensity", num(1.0), slider(0.0, 4.0, 0.0, 2.0, 2)), p("lighting/lightColor", "Light Color", col(1.0, 1.0, 1.0), ParamUi::Color), p("lighting/lightPosition", "Light Position", pt(0.0, 0.0), ParamUi::Point), @@ -1367,9 +1796,13 @@ pub fn specs() -> Vec { { let mut v = vec![ p("view", "View", Value::Enum(0), popup(&["Wireframe Preview", "Height Map"])), + p("wireframeControls/horizontalRotation", "Horizontal Rotation", num(0.0), ParamUi::Angle), + p("wireframeControls/verticalRotation", "Vertical Rotation", num(53.0), slider(0.0, 90.0, 0.0, 90.0, 1)), + p("wireframeControls/verticalScale", "Vertical Scale", num(0.5), slider(0.0, 10.0, 0.0, 2.0, 3)), p("heightMapControls/brightness", "Brightness", num(0.5), slider(-1.0, 2.0, 0.0, 1.0, 3)), p("heightMapControls/contrast", "Contrast", num(0.75), slider(0.0, 4.0, 0.0, 2.0, 3)), p("heightMapControls/gamma", "Gamma Adjustment", num(1.0), slider(0.01, 10.0, 0.2, 5.0, 3)), + p("heightMapControls/renderDryAreasAs", "Render Dry Areas As", Value::Enum(0), popup(&["Solid", "Transparent"])), p("heightMapControls/transparency", "Transparency", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 3)), p("simulation/gridResolution", "Grid Resolution", num(60.0), slider(1.0, 400.0, 1.0, 200.0, 0)), p("simulation/gridResDownsamples", "Grid Res Downsamples", Value::Bool(false), ParamUi::Checkbox), @@ -1377,6 +1810,10 @@ pub fn specs() -> Vec { p("simulation/damping", "Damping", num(0.05), slider(0.0, 5.0, 0.0, 1.0, 3)), p("simulation/reflectEdges", "Reflect Edges", Value::Enum(0), popup(&["None", "Left", "Top", "Right", "Bottom", "All"])), p("simulation/preRoll", "Pre-roll (seconds)", num(0.0), slider(0.0, 30.0, 0.0, 10.0, 2)), + p("ground/ground", "Ground", Value::Layer(None), ParamUi::Layer), + p("ground/steepness", "Steepness", num(0.25), slider(0.0, 2.0, 0.0, 1.0, 3)), + p("ground/height", "Height", num(0.25), slider(0.0, 2.0, 0.0, 1.0, 3)), + p("ground/waveStrength", "Wave Strength", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 3)), ]; v.extend(producer_params(1, 0.5, (0.5, 0.5))); v.extend(producer_params(2, 0.0, (0.25, 0.25))); @@ -1399,6 +1836,8 @@ pub fn specs() -> Vec { p("shape/repetitions", "Repetitions", num(10.0), slider(1.0, 500.0, 1.0, 100.0, 2)), p("shape/direction", "Direction", num(0.0), ParamUi::Angle), p("shape/origin", "Origin", pt(0.5, 0.5), ParamUi::Point), + p("shape/customShatterMap", "Custom Shatter Map", Value::Layer(None), ParamUi::Layer), + p("shape/whiteTilesFixed", "White Tiles Fixed", Value::Bool(false), ParamUi::Checkbox), p("shape/extrusionDepth", "Extrusion Depth", num(0.05), slider(0.0, 1.0, 0.0, 1.0, 3)), p("force1/force1Position", "Position", pt(0.5, 0.5), ParamUi::Point), p("force1/force1Depth", "Depth", num(0.1), slider(-1.0, 1.0, -1.0, 1.0, 3)), @@ -1416,10 +1855,24 @@ pub fn specs() -> Vec { p("physics/gravity", "Gravity", num(3.0), slider(0.0, 100.0, 0.0, 10.0, 2)), p("physics/gravityDirection", "Gravity Direction", num(180.0), ParamUi::Angle), p("physics/gravityInclination", "Gravity Inclination", num(0.0), slider(-90.0, 90.0, -90.0, 90.0, 1)), - p("lighting/lightIntensity", "Light Intensity", num(1.0), slider(0.0, 4.0, 0.0, 2.0, 2)), - p("lighting/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)), - ], + p("gradient/shatterThreshold", "Shatter Threshold", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("gradient/gradientLayer", "Gradient Layer", Value::Layer(None), ParamUi::Layer), + p("gradient/invertGradient", "Invert Gradient", Value::Bool(false), ParamUi::Checkbox), + p("textures/color", "Color", col(1.0, 1.0, 1.0), ParamUi::Color), + p("textures/opacity", "Opacity", num(1.0), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("textures/frontMode", "Front Mode", Value::Enum(1), popup(&SHATTER_TEXTURE_MODES)), + p("textures/sideMode", "Side Mode", Value::Enum(0), popup(&SHATTER_TEXTURE_MODES)), + p("textures/backMode", "Back Mode", Value::Enum(1), popup(&SHATTER_TEXTURE_MODES)), + p("textures/frontLayer", "Front Layer", Value::Layer(None), ParamUi::Layer), + p("textures/sideLayer", "Side Layer", Value::Layer(None), ParamUi::Layer), + p("textures/backLayer", "Back Layer", Value::Layer(None), ParamUi::Layer), + ] + .into_iter() + .chain(crate::card3d::camera_params(4.0)) + .chain(crate::card3d::lighting_params(1.0, 0.25)) + .chain(crate::card3d::material_params(0.75)) + .chain([p("randomSeed", "Random Seed", num(0.0), slider(0.0, 10_000.0, 0.0, 1000.0, 0))]) + .collect(), shatter, ), spec( @@ -1431,6 +1884,7 @@ pub fn specs() -> Vec { p("producer/producerXSize", "Producer X Size", num(0.05), slider(0.0, 2.0, 0.0, 1.0, 3)), p("producer/producerYSize", "Producer Y Size", num(0.05), slider(0.0, 2.0, 0.0, 1.0, 3)), p("producer/producerOrientation", "Producer Orientation", num(0.0), ParamUi::Angle), + p("producer/zoomProducerPoint", "Zoom Producer Point", Value::Bool(false), ParamUi::Checkbox), p("producer/productionRate", "Production Rate", num(1.0), slider(0.0, 50.0, 0.0, 10.0, 3)), p("bubbles/size", "Size", num(0.5), slider(0.0, 10.0, 0.0, 2.0, 3)), p("bubbles/sizeVariance", "Size Variance", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 3)), @@ -1442,13 +1896,24 @@ pub fn specs() -> Vec { p("physics/windSpeed", "Wind Speed", num(0.5), slider(-100.0, 100.0, -10.0, 10.0, 3)), p("physics/windDirection", "Wind Direction", num(90.0), ParamUi::Angle), p("physics/turbulence", "Turbulence", num(0.5), slider(0.0, 10.0, 0.0, 2.0, 3)), + p("physics/wobbleAmount", "Wobble Amount", num(0.05), slider(0.0, 10.0, 0.0, 2.0, 3)), p("physics/repulsion", "Repulsion", num(1.0), slider(0.0, 10.0, 0.0, 2.0, 3)), + p("physics/popVelocity", "Pop Velocity", num(1.0), slider(-100.0, 100.0, -10.0, 10.0, 3)), p("physics/viscosity", "Viscosity", num(0.1), slider(0.0, 1.0, 0.0, 1.0, 3)), p("physics/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("rendering/blendMode", "Blend Mode", Value::Enum(0), popup(&["Transparent", "Solid Old on Top", "Solid New on Top"])), - p("rendering/bubbleTexture", "Bubble Texture", Value::Enum(0), popup(&["Default Bubble", "Spit", "Bubblegum", "Dishwater", "Milky"])), + p("rendering/bubbleTexture", "Bubble Texture", Value::Enum(0), popup(&FOAM_TEXTURES)), + p("rendering/bubbleTextureLayer", "Bubble Texture Layer", Value::Layer(None), ParamUi::Layer), + p("rendering/bubbleOrientation", "Bubble Orientation", Value::Enum(0), popup(&["Fixed", "Physical Orientation", "Bubble Velocity"])), + p("rendering/environmentMap", "Environment Map", Value::Layer(None), ParamUi::Layer), + p("rendering/reflectionStrength", "Reflection Strength", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("rendering/reflectionConvergence", "Reflection Convergence", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 3)), + p("flowMap/flowMap", "Flow Map", Value::Layer(None), ParamUi::Layer), + p("flowMap/flowMapSteepness", "Flow Map Steepness", num(0.05), slider(-100.0, 100.0, -1.0, 1.0, 3)), + p("flowMap/flowMapFits", "Flow Map Fits", Value::Enum(0), popup(&["Background", "Universe"])), + p("flowMap/simulationQuality", "Simulation Quality", Value::Enum(0), popup(&["Normal", "High", "Intense"])), p("randomSeed", "Random Seed", num(1.0), slider(0.0, 10_000.0, 0.0, 1000.0, 0)), ], foam, @@ -1583,6 +2048,57 @@ mod tests { assert!(sum_diff(&b, &src) > 0.1); } + #[test] + fn caustics_sky_and_light_types() { + let src = Image::filled(32, 32, [0.5, 0.5, 0.5, 1.0]); + let env = EffectEnv { host: Some(&FoamHost), ..Default::default() }; + let run_h = |vals: &[(&str, Value)]| run_fx("ec.sim.caustics", vals, src.clone(), 0.0, env).img; + // A mirror-like surface shows the (green) sky. + let mirror = [("water/surfaceOpacity", num(1.0)), ("sky/sky", Value::Layer(Some(2))), ("sky/intensity", num(1.0))]; + let p = run_h(&mirror).get(16, 16); + assert!(p[1] > p[0] + 0.3 && p[1] > p[2] + 0.3, "{p:?}"); + // Point Source lights pixels by their own direction to the light: the image is no + // longer uniform; Distant Source lights the flat water evenly. + let lit = |t: u32| { + run_h(&[ + ("lighting/lightType", Value::Enum(t)), + ("lighting/lightPosition", pt(0.0, 0.0)), + ("lighting/lightHeight", num(0.2)), + ("water/surfaceOpacity", num(0.0)), + ]) + }; + let (distant, point) = (lit(0), lit(1)); + assert!((distant.get(2, 2)[0] - distant.get(30, 30)[0]).abs() < 1e-5); + assert!(point.get(2, 2)[0] > point.get(30, 30)[0] + 0.02, "{:?} {:?}", point.get(2, 2), point.get(30, 30)); + // First Comp Light without a comp light: ambient only. + let none = run_h(&[("lighting/lightType", Value::Enum(2)), ("water/surfaceOpacity", num(0.0))]); + assert!(none.get(16, 16)[0] < distant.get(16, 16)[0]); + } + + #[test] + fn wave_world_wireframe_controls_ground_and_dry_areas() { + let base = [("producer1/producer1Position", pt(8.0, 16.0)), ("simulation/gridResolution", num(24.0))]; + let wire = |extra: &[(&str, Value)]| run("ec.sim.waveworld", &[base.as_slice(), extra].concat(), Image::new(32, 32), 0.5); + assert_ne!(wire(&[]), wire(&[("wireframeControls/horizontalRotation", num(40.0))])); + assert_ne!(wire(&[]), wire(&[("wireframeControls/verticalScale", num(3.0))])); + // Ground (left dark, right light): the light side rises above the water and is dry. + let env = EffectEnv { host: Some(&FoamHost), ..Default::default() }; + let hm = |extra: &[(&str, Value)]| { + let mut v: Vec<(&str, Value)> = base.to_vec(); + v.extend([("view", Value::Enum(1)), ("ground/ground", Value::Layer(Some(1))), ("ground/steepness", num(0.5)), ("ground/height", num(0.25))]); + v.extend_from_slice(extra); + run_fx("ec.sim.waveworld", &v, Image::new(32, 32), 1.0, env).img + }; + let solid = hm(&[]); + let clear = hm(&[("heightMapControls/renderDryAreasAs", Value::Enum(1))]); + assert!(solid.get(30, 16)[3] > 0.99 && clear.get(30, 16)[3] == 0.0); + assert!(clear.get(4, 16)[3] > 0.99, "wet side stays"); + // Dry ground stays flat: mid grey (brightness 0.5) there. + assert!((solid.get(30, 16)[0] - 0.5).abs() < 1e-4); + // Wave Strength slows waves over shallow water: the surface differs. + assert_ne!(hm(&[]), hm(&[("ground/waveStrength", num(1.0))])); + } + #[test] fn wave_world_seek_and_waves() { let v = vec![("view", Value::Enum(1)), ("producer1/producer1Position", pt(16.0, 16.0)), ("simulation/gridResolution", num(24.0))]; @@ -1651,4 +2167,248 @@ mod tests { }; assert!(count(0.0) < count(20.0) * 0.9, "{} vs {}", count(0.0), count(20.0)); } + + /// A host with a comp camera (identity, or tilted) and a first comp light. + struct Scene { + camera: Option<[[f64; 4]; 3]>, + light: Option, + layer: Option, + } + impl crate::EffectHost for Scene { + fn layer(&self, _: u64, _: bool) -> Option { + let img = self.layer.clone()?; + let size = [img.width as f64, img.height as f64]; + Some(crate::LayerPixels { buf: Buf { img, offset: [0.0; 2], scale: 1.0 }, size }) + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + fn comp_scene(&self) -> Option { + Some(crate::CompScene { camera: self.camera, light: self.light }) + } + } + + fn run_env(id: &str, vals: &[(&str, Value)], img: Image, t: f64, host: &Scene) -> Image { + run_fx(id, vals, img, t, EffectEnv { host: Some(host), ..Default::default() }).img + } + + #[test] + fn card_dance_camera_systems() { + let src = ramp(40, 30); + // Camera Position: rotating the camera changes the view; zero rotation is the layer. + let r = run("ec.sim.carddance", &[("cameraPosition/yRotation", num(30.0))], src.clone(), 0.0); + assert!(sum_diff(&r, &src) > 10.0); + let order = run("ec.sim.carddance", &[("cameraPosition/yRotation", num(30.0)), ("cameraPosition/xRotation", num(30.0))], src.clone(), 0.0); + let order2 = run( + "ec.sim.carddance", + &[("cameraPosition/yRotation", num(30.0)), ("cameraPosition/xRotation", num(30.0)), ("cameraPosition/transformOrder", Value::Enum(5))], + src.clone(), + 0.0, + ); + assert!(sum_diff(&order, &order2) > 1.0, "Transform Order matters"); + // X, Y Position moves the view: the layer shifts by the offset. + let moved = run("ec.sim.carddance", &[("cameraPosition/xyPosition", pt(30.0, 15.0))], src.clone(), 0.0); + assert!((moved.get(25, 10)[0] - src.get(15, 10)[0]).abs() < 0.03, "{:?} {:?}", moved.get(25, 10), src.get(15, 10)); + // Corner Pins: the layer's corners land on the pins. + let pins = [ + ("cameraSystem", Value::Enum(1)), + ("cornerPins/upperLeftCorner", pt(10.0, 5.0)), + ("cornerPins/upperRightCorner", pt(30.0, 5.0)), + ("cornerPins/lowerLeftCorner", pt(10.0, 25.0)), + ("cornerPins/lowerRightCorner", pt(30.0, 25.0)), + ]; + let cp = run("ec.sim.carddance", &pins, src.clone(), 0.0); + assert_eq!(cp.get(3, 3)[3], 0.0, "outside the pinned quad"); + assert!(cp.get(20, 15)[3] > 0.99); + assert!((cp.get(20, 15)[0] - src.get(20, 15)[0]).abs() < 0.05, "centre maps to centre"); + // Comp Camera: the identity camera reproduces the layer; a camera that doubles x moves it. + let ident = Scene { camera: Some([[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 0.002, 1.0]]), light: None, layer: None }; + let cc = run_env("ec.sim.carddance", &[("cameraSystem", Value::Enum(2))], src.clone(), 0.0, &ident); + assert!(max_diff(&cc, &src) < 0.02, "{}", max_diff(&cc, &src)); + let half = Scene { camera: Some([[0.5, 0.0, 0.0, 0.0], [0.0, 0.5, 0.0, 0.0], [0.0, 0.0, 0.002, 1.0]]), ..ident }; + let cc = run_env("ec.sim.carddance", &[("cameraSystem", Value::Enum(2))], src.clone(), 0.0, &half); + assert_eq!(cc.get(35, 25)[3], 0.0, "half-size view leaves the far corner empty"); + } + + #[test] + fn card_dance_lighting_and_material() { + let src = Image::filled(40, 30, [0.5, 0.5, 0.5, 1.0]); + let tilt = [("rows", num(1.0)), ("columns", num(1.0)), ("yRotation/yRotSource", Value::Enum(0)), ("yRotation/yRotOffset", num(60.0))]; + let lit = |extra: &[(&str, Value)]| { + let mut v: Vec<(&str, Value)> = tilt.to_vec(); + v.extend_from_slice(extra); + run("ec.sim.carddance", &v, src.clone(), 0.0).get(20, 15) + }; + let base = lit(&[]); + // Tilted cards facing away from a frontal light get darker than flat ones. + assert!(base[0] < 0.45, "{base:?}"); + // Light colour tints; intensity brightens; ambient alone lights evenly. + let red = lit(&[("lighting/lightColor", col(1.0, 0.0, 0.0))]); + assert!(red[0] > red[1] + 0.05); + assert!(lit(&[("lighting/lightIntensity", num(2.0))])[0] > base[0]); + let amb = lit(&[("lighting/ambientLight", num(1.0)), ("material/diffuse", num(0.0))]); + assert!((amb[0] - 0.5).abs() < 1e-3); + // A light placed to the side (Point Source) changes the shading of the tilted card. + let side = lit(&[("lighting/lightType", Value::Enum(1)), ("lighting/lightPosition", pt(200.0, 15.0)), ("lighting/lightDepth", num(0.2))]); + assert!((side[0] - base[0]).abs() > 0.02, "{side:?} vs {base:?}"); + // Specular adds a highlight on flat, frontally lit cards. + let flat = run("ec.sim.carddance", &[("material/specular", num(0.5))], src.clone(), 0.0).get(20, 15); + assert!(flat[0] > 0.9, "{flat:?}"); + // First Comp Light: no light in the comp → ambient only; a light from the host is used. + let none = Scene { camera: None, light: None, layer: None }; + let a = run_env("ec.sim.carddance", &[("lighting/lightType", Value::Enum(2))], src.clone(), 0.0, &none).get(20, 15); + assert!((a[0] - 0.5 * (0.25 + 0.75)).abs() < 1e-3, "{a:?}"); + let blue = Scene { light: Some(crate::CompLight { pos: [20.0, 15.0, -100.0], dir: [0.0, 0.0, 1.0], color: [0.0, 0.0, 1.0], kind: 0 }), ..none }; + let a = run_env("ec.sim.carddance", &[("lighting/lightType", Value::Enum(2))], src.clone(), 0.0, &blue).get(20, 15); + assert!(a[2] > a[0] + 0.2, "{a:?}"); + } + + #[test] + fn shatter_custom_map_gradient_textures_and_extrusion() { + let src = ramp(32, 32); + let base = [("view", Value::Enum(0)), ("force1/force1Position", pt(16.0, 16.0)), ("force1/force1Radius", num(2.0)), ("shape/origin", pt(16.0, 16.0))]; + let with = |extra: &[(&str, Value)], t: f64, host: Option<&Scene>| { + let mut v: Vec<(&str, Value)> = base.to_vec(); + v.extend_from_slice(extra); + match host { + Some(h) => run_env("ec.sim.shatter", &v, src.clone(), t, h), + None => run("ec.sim.shatter", &v, src.clone(), t), + } + }; + // Custom Shatter Map: a map white on the left and black on the right; with White Tiles + // Fixed the left half stays put while the right half breaks away. + let mut map = Image::new(32, 32); + for y in 0..32 { + for x in 0..32 { + map.set(x, y, if x < 16 { [1.0; 4] } else { [0.0, 0.0, 0.0, 1.0] }); + } + } + let host = Scene { camera: None, light: None, layer: Some(map) }; + let custom = [("shape/pattern", Value::Enum(5)), ("shape/customShatterMap", Value::Layer(Some(1))), ("shape/whiteTilesFixed", Value::Bool(true))]; + let at_rest = with(&custom, 0.0, Some(&host)); + assert!(max_diff(&at_rest, &src) < 0.05, "custom pieces tile the layer: {}", max_diff(&at_rest, &src)); + let broke = with(&custom, 1.0, Some(&host)); + assert!(max_diff_region(&broke, &src, 0..12) < 0.05, "white tiles fixed"); + assert!(sum_diff_region(&broke, &src, 20..32) > 5.0, "black group moved"); + // Gradient: a black gradient layer with threshold 50 % keeps everything together. + let black = Scene { camera: None, light: None, layer: Some(Image::filled(32, 32, [0.0, 0.0, 0.0, 1.0])) }; + let g = with(&[("gradient/gradientLayer", Value::Layer(Some(1))), ("gradient/shatterThreshold", num(50.0))], 1.0, Some(&black)); + assert!(max_diff(&g, &src) < 0.05); + let gi = with( + &[("gradient/gradientLayer", Value::Layer(Some(1))), ("gradient/shatterThreshold", num(50.0)), ("gradient/invertGradient", Value::Bool(true))], + 1.0, + Some(&black), + ); + assert!(sum_diff(&gi, &src) > 10.0); + // Textures: Front Mode Color paints the pieces flat; Opacity fades them. + let flat = with(&[("textures/frontMode", Value::Enum(0)), ("textures/color", col(0.0, 1.0, 0.0))], 0.0, None); + let p = flat.get(16, 16); + assert!(p[1] > 0.95 && p[0] < 0.05, "{p:?}"); + let half = with(&[("textures/opacity", num(0.5)), ("shape/repetitions", num(2.0))], 0.0, None); + assert!((half.get(8, 4)[3] - 0.5).abs() < 0.02, "{:?}", half.get(8, 4)); + // Extrusion: thicker pieces cover more once they tumble. + let cover = |d: f64| { + with(&[("shape/extrusionDepth", num(d)), ("textures/sideMode", Value::Enum(0)), ("textures/color", col(1.0, 1.0, 1.0))], 0.6, None) + .data + .iter() + .map(|p| p[3]) + .sum::() + }; + assert!(cover(1.0) > cover(0.0) + 1.0, "{} vs {}", cover(1.0), cover(0.0)); + // Deterministic and seek-consistent. + assert_eq!(with(&custom, 0.7, Some(&host)), with(&custom, 0.7, Some(&host))); + } + + fn max_diff_region(a: &Image, b: &Image, xs: std::ops::Range) -> f32 { + let mut m = 0.0f32; + for y in 0..a.height { + for x in xs.clone() { + let (p, q) = (a.get(x as i64, y as i64), b.get(x as i64, y as i64)); + m = (0..4).map(|k| (p[k] - q[k]).abs()).fold(m, f32::max); + } + } + m + } + + fn sum_diff_region(a: &Image, b: &Image, xs: std::ops::Range) -> f32 { + let mut s = 0.0; + for y in 0..a.height { + for x in xs.clone() { + let (p, q) = (a.get(x as i64, y as i64), b.get(x as i64, y as i64)); + s += (0..4).map(|k| (p[k] - q[k]).abs()).sum::(); + } + } + s + } + + /// Layer 1 = a left-dark, right-light ramp; layer 2 = solid green. + struct FoamHost; + impl crate::EffectHost for FoamHost { + fn layer(&self, id: u64, _: bool) -> Option { + let img = if id == 1 { ramp(32, 32) } else { Image::filled(32, 32, [0.0, 1.0, 0.0, 1.0]) }; + Some(crate::LayerPixels { buf: Buf { img, offset: [0.0; 2], scale: 1.0 }, size: [32.0, 32.0] }) + } + fn layer_at(&self, id: u64, _: f64, me: bool) -> Option { + self.layer(id, me) + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + } + + #[test] + fn foam_flow_map_pop_velocity_wobble_textures_and_reflection() { + let base = vec![ + ("producer/producerPoint", pt(16.0, 16.0)), + ("producer/productionRate", num(2.0)), + ("physics/windSpeed", num(0.0)), + ("physics/turbulence", num(0.0)), + ("view", Value::Enum(2)), + ]; + let with = |extra: &[(&str, Value)], t: f64| { + let mut v = base.clone(); + v.extend_from_slice(extra); + run_fx("ec.sim.foam", &v, Image::new(32, 32), t, EffectEnv { host: Some(&FoamHost), ..Default::default() }).img + }; + let cx = |img: &Image| { + let (mut s, mut n) = (0.0, 0.0); + for y in 0..32 { + for x in 0..32 { + let a = img.get(x, y)[3]; + s += a * x as f32; + n += a; + } + } + s / n.max(1e-6) + }; + // Flow map: bubbles run downhill (towards the dark left side). + let still = with(&[], 1.0); + let flowing = with(&[("flowMap/flowMap", Value::Layer(Some(1))), ("flowMap/flowMapSteepness", num(1.0))], 1.0); + assert!(cx(&flowing) < cx(&still) - 1.0, "{} vs {}", cx(&flowing), cx(&still)); + let hq = + with(&[("flowMap/flowMap", Value::Layer(Some(1))), ("flowMap/flowMapSteepness", num(1.0)), ("flowMap/simulationQuality", Value::Enum(2))], 1.0); + assert_eq!( + hq, + with(&[("flowMap/flowMap", Value::Layer(Some(1))), ("flowMap/flowMapSteepness", num(1.0)), ("flowMap/simulationQuality", Value::Enum(2))], 1.0) + ); + // Pop velocity scatters the survivors when short-lived bubbles pop. + let short = [("bubbles/lifespan", num(8.0)), ("bubbles/strength", num(0.0))]; + let a = with(&[short.as_slice(), &[("physics/popVelocity", num(0.0))]].concat(), 1.0); + let b2 = with(&[short.as_slice(), &[("physics/popVelocity", num(20.0))]].concat(), 1.0); + assert_ne!(a, b2); + // Wobble changes the drawn bubbles. + assert_ne!(with(&[("physics/wobbleAmount", num(0.0))], 1.0), with(&[("physics/wobbleAmount", num(3.0))], 1.0)); + // User Defined texture: bubbles show the texture layer (green). + let tex = with(&[("rendering/bubbleTexture", Value::Enum(5)), ("rendering/bubbleTextureLayer", Value::Layer(Some(2)))], 1.0); + assert!(tex.data.iter().any(|p| p[1] > 0.5 && p[0] < 0.1)); + // Environment map: reflections tint the bubbles. + let refl = with(&[("rendering/environmentMap", Value::Layer(Some(2))), ("rendering/reflectionStrength", num(1.0))], 1.0); + let g = |i: &Image| i.data.iter().map(|p| p[1] - p[0]).sum::(); + assert!(g(&refl) > g(&still) + 1.0); + // Zoom Producer Point zooms about the producer instead of the layer centre. + let off = [("producer/producerPoint", pt(8.0, 8.0)), ("zoom", num(2.0))]; + let z1 = with(&off, 1.0); + let z2 = with(&[off.as_slice(), &[("producer/zoomProducerPoint", Value::Bool(true))]].concat(), 1.0); + assert!((cx(&z2) - 8.0).abs() < 2.0 && cx(&z1) < 4.0, "{} vs {}", cx(&z2), cx(&z1)); + } } diff --git a/crates/effects/src/sim3.rs b/crates/effects/src/sim3.rs index f7425df7..888ebd7d 100644 --- a/crates/effects/src/sim3.rs +++ b/crates/effects/src/sim3.rs @@ -8,10 +8,14 @@ //! random spread, direction), **Repel** (particles push each other apart within a radius), //! **Wall** (a mask of the layer the particles bounce off) and the **Persistent Property //! Mapper** (a map layer's red / green / blue drive particle properties at the particle's -//! position each step). **Layer Map** draws particles with the colours of another layer -//! sampled at each particle's birth position. Map layers are read at the frame being rendered -//! (their pixels are part of the cache key). Particles replace the layer's own pixels, as in -//! After Effects. +//! position each step) and the **Ephemeral Property Mapper** (the same with an operator per +//! channel, holding for one step only). **Particle Exploder** bursts particles into smaller +//! ones. **Layer Map** draws each particle as a copy of another layer, at a time set by its Time +//! Offset Type. Every producer / force / mapper has an **Affects** group (particles from which +//! producer, a selection map, characters, age with feather). The Options' cannon / grid text +//! turns particles into characters (the built-in stroke font). Map layers are read at the frame +//! being rendered (their pixels are part of the cache key). Particles replace the layer's own +//! pixels, as in After Effects. //! //! **CC Hair** grows strands from the layer's opaque pixels (Density per 1 000 px²), each a //! quadratic curve of Length that droops with Weight, optionally steered and scaled by a hairfall @@ -33,20 +37,72 @@ fn spec(id: &'static str, name: &'static str, params: Vec, ren // ---------------------------------------------------------------- Particle Playground +/// Which producer made a particle (Affects ▸ Particles From). +pub const SRC_CANNON: u8 = 0; +pub const SRC_GRID: u8 = 1; +pub const SRC_LAYER_EXPLODER: u8 = 2; +pub const SRC_PARTICLE_EXPLODER: u8 = 3; + #[derive(Clone, Copy, Debug, PartialEq)] pub struct PgParticle { pub p: [f32; 2], pub v: [f32; 2], - /// Layer position where the particle was born (Layer Map lookups). + /// Layer position where the particle was born. pub origin: [f32; 2], pub c: [f32; 4], + /// Radius, or font size for text particles. pub r: f32, pub mass: f32, + /// Kinetic friction (velocity damping) and static friction (force needed to start moving). pub friction: f32, + pub static_friction: f32, pub force: [f32; 2], + /// Rotation (degrees), angular velocity (°/s) and torque (°/s²). + pub angle: f32, + pub ang_vel: f32, + pub torque: f32, + /// X / Y scale (1 = 100 %) and its growth per second. + pub scale: [f32; 2], + pub scale_speed: f32, + /// Age and lifespan in seconds. + pub age: f32, + pub lifespan: f32, + /// Character code of a text particle (0 = a dot). + pub ch: u32, + /// Layer Map time offset (seconds). + pub toff: f32, + /// Producer (`SRC_*`). + pub src: u8, pub id: u32, } +impl PgParticle { + pub fn new(p: [f32; 2], v: [f32; 2], c: [f32; 4], r: f32, id: u32, src: u8) -> PgParticle { + PgParticle { + p, + v, + origin: p, + c, + r, + mass: 1.0, + friction: 0.0, + static_friction: 0.0, + force: [0.0; 2], + angle: 0.0, + ang_vel: 0.0, + torque: 0.0, + scale: [1.0; 2], + scale_speed: 0.0, + age: 0.0, + lifespan: f32::INFINITY, + ch: 0, + toff: 0.0, + src, + id, + } + } +} + #[derive(Clone, Debug, Default)] pub struct PgState { pub parts: Vec, @@ -76,10 +132,216 @@ impl MapImg { } } -/// Particle properties a Property Mapper channel can drive (After Effects' order; the -/// properties we don't model yet are left out). -pub const MAP_TARGETS: [&str; 14] = - ["None", "Red", "Green", "Blue", "Kinetic Friction", "Scale", "X", "Y", "X Speed", "Y Speed", "X Force", "Y Force", "Opacity", "Mass"]; +/// Particle properties a Property Mapper channel can drive, in After Effects' order. +pub const MAP_TARGETS: [&str; 27] = [ + "None", + "Red", + "Green", + "Blue", + "Kinetic Friction", + "Static Friction", + "Angle", + "Angular Velocity", + "Torque", + "Scale", + "X Scale", + "Y Scale", + "X", + "Y", + "Gradient Velocity", + "X Speed", + "Y Speed", + "Gradient Force", + "X Force", + "Y Force", + "Opacity", + "Mass", + "Lifespan", + "Character", + "Font Size", + "Time Offset", + "Scale Speed", +]; + +/// Ephemeral Property Mapper operators. +pub const MAP_OPERATORS: [&str; 7] = ["Set", "Add", "Difference", "Subtract", "Multiply", "Min", "Max"]; + +/// Affects ▸ Particles From options. +pub const PARTICLES_FROM: [&str; 8] = + ["All", "Cannon", "Grid", "Layer Exploder", "Particle Exploder", "Grid & Layer Exploder", "Cannon & Grid", "Cannon & Particle Exploder"]; + +/// Layer Map ▸ Time Offset Type options. +pub const TIME_OFFSET_TYPES: [&str; 4] = ["Relative", "Absolute", "Relative Random", "Absolute Random"]; + +/// A property's current value (`target` in [`MAP_TARGETS`]). +fn prop_get(q: &PgParticle, target: u32) -> f32 { + match target { + 1 => q.c[0], + 2 => q.c[1], + 3 => q.c[2], + 4 => q.friction, + 5 => q.static_friction, + 6 => q.angle, + 7 => q.ang_vel, + 8 => q.torque, + 9 => (q.scale[0] + q.scale[1]) * 0.5, + 10 => q.scale[0], + 11 => q.scale[1], + 12 => q.p[0], + 13 => q.p[1], + 15 => q.v[0], + 16 => q.v[1], + 18 => q.force[0], + 19 => q.force[1], + 20 => q.c[3], + 21 => q.mass, + 22 => q.lifespan, + 23 => q.ch as f32, + 24 => q.r, + 25 => q.toff, + 26 => q.scale_speed, + _ => 0.0, + } +} + +fn prop_set(q: &mut PgParticle, target: u32, v: f32) { + match target { + 1 => q.c[0] = v, + 2 => q.c[1] = v, + 3 => q.c[2] = v, + 4 => q.friction = v.clamp(0.0, 10.0), + 5 => q.static_friction = v.max(0.0), + 6 => q.angle = v, + 7 => q.ang_vel = v, + 8 => q.torque = v, + 9 => q.scale = [v.max(0.0); 2], + 10 => q.scale[0] = v.max(0.0), + 11 => q.scale[1] = v.max(0.0), + 12 => q.p[0] = v, + 13 => q.p[1] = v, + 15 => q.v[0] = v, + 16 => q.v[1] = v, + 18 => q.force[0] = v, + 19 => q.force[1] = v, + 20 => q.c[3] = v.clamp(0.0, 1.0), + 21 => q.mass = v.max(0.01), + 22 => q.lifespan = v.max(0.0), + 23 => q.ch = v.round().clamp(0.0, 0x10ffff as f32) as u32, + 24 => q.r = v.max(0.0), + 25 => q.toff = v, + 26 => q.scale_speed = v, + _ => {} + } +} + +/// An Affects group: which particles a producer, force or mapper acts on, as a weight 0..1. +#[derive(Default)] +struct Affects { + from: u32, + selection: Option, + chars: Vec, + /// Positive: older than; negative: younger than (seconds); 0 = any age. + age: f32, + feather: f32, +} + +impl Affects { + fn from(ctx: &EffectCtx, group: &str) -> Affects { + let pr = ctx.params; + let g = |id: &str| format!("{group}/affects/{id}"); + Affects { + from: pr.e(&g("particlesFrom")), + selection: ctx.layer_param(&g("selectionMap"), true).map(|lp| MapImg::from(ctx, lp)), + chars: pr.s(&g("characters")).chars().collect(), + age: pr.f(&g("olderYoungerThan")) as f32, + feather: pr.f(&g("ageFeather")).max(0.0) as f32, + } + } + fn weight(&self, q: &PgParticle) -> f32 { + let src_ok = match self.from { + 1 => q.src == SRC_CANNON, + 2 => q.src == SRC_GRID, + 3 => q.src == SRC_LAYER_EXPLODER, + 4 => q.src == SRC_PARTICLE_EXPLODER, + 5 => q.src == SRC_GRID || q.src == SRC_LAYER_EXPLODER, + 6 => q.src == SRC_CANNON || q.src == SRC_GRID, + 7 => q.src == SRC_CANNON || q.src == SRC_PARTICLE_EXPLODER, + _ => true, + }; + if !src_ok { + return 0.0; + } + if !self.chars.is_empty() && !char::from_u32(q.ch).is_some_and(|c| self.chars.contains(&c)) { + return 0.0; + } + let mut w = 1.0; + if self.age != 0.0 { + // Signed distance past the age threshold, feathered across Age Feather. + let d = if self.age > 0.0 { q.age - self.age } else { -self.age - q.age }; + w *= if self.feather > 0.0 { + (d / self.feather + 0.5).clamp(0.0, 1.0) + } else if d >= 0.0 { + 1.0 + } else { + 0.0 + }; + } + if let Some(m) = &self.selection { + let c = m.at(q.p[0], q.p[1]); + w *= effectcraft_color::luminance(c[0], c[1], c[2]).clamp(0.0, 1.0) * c[3]; + } + w + } +} + +/// A Persistent or Ephemeral Property Mapper. +struct Mapper { + map: MapImg, + /// Per channel (R, G, B): target, operator, min, max. + chans: [(u32, u32, f32, f32); 3], + affects: Affects, +} + +impl Mapper { + fn apply(&self, q: &mut PgParticle) { + let w = self.affects.weight(q); + if w <= 0.0 { + return; + } + let px = self.map.at(q.p[0], q.p[1]); + for (ch, &(target, op, lo, hi)) in self.chans.iter().enumerate() { + if target == 0 { + continue; + } + let v = lo + (hi - lo) * px[ch]; + if target == 14 || target == 17 { + // Gradient Velocity / Force: along the map channel's gradient. + let gx = self.map.at(q.p[0] + 1.0, q.p[1])[ch] - self.map.at(q.p[0] - 1.0, q.p[1])[ch]; + let gy = self.map.at(q.p[0], q.p[1] + 1.0)[ch] - self.map.at(q.p[0], q.p[1] - 1.0)[ch]; + let (gx, gy) = (gx * 0.5 * (hi - lo) * w, gy * 0.5 * (hi - lo) * w); + if target == 14 { + q.v = [q.v[0] + gx, q.v[1] + gy]; + } else { + q.force = [q.force[0] + gx, q.force[1] + gy]; + } + continue; + } + let cur = prop_get(q, target); + let new = match op { + 1 => cur + v, + 2 => (cur - v).abs(), + 3 => cur - v, + 4 => cur * v, + 5 => cur.min(v), + 6 => cur.max(v), + _ => v, + }; + // (An unlimited lifespan has no in-between.) + let v = if w >= 1.0 || !cur.is_finite() { new } else { cur + (new - cur) * w }; + prop_set(q, target, v); + } + } +} struct Pg { // Cannon @@ -93,17 +355,26 @@ struct Pg { vel_spread: f32, color: [f32; 4], radius: f32, + /// Edit Cannon Text: characters fired in order (empty = dots). + cannon_text: Vec, + // Particle Exploder + pex_radius: f32, + pex_dispersion: f32, + pex_affects: Affects, // Gravity gravity: [f32; 2], grav_spread: f32, + grav_affects: Affects, // Repel repel: f32, repel_radius: f32, + repel_affects: Affects, // Wall wall: Option>, - // Property mapper - mapper: Option, - map_to: [(u32, f32, f32); 3], + wall_affects: Affects, + // Property mappers + persistent: Option, + ephemeral: Option, seed: u32, bounds: [f32; 4], } @@ -125,48 +396,46 @@ impl Pg { [a.cos() * rr, a.sin() * rr] }; let p = [self.pos[0] + off[0], self.pos[1] + off[1]]; - PgParticle { p, v: [d[0] * speed, d[1] * speed], origin: p, c: self.color, r: self.radius, mass: 1.0, friction: 0.0, force: [0.0; 2], id } + let mut q = PgParticle::new(p, [d[0] * speed, d[1] * speed], self.color, self.radius, id, SRC_CANNON); + if !self.cannon_text.is_empty() { + q.ch = self.cannon_text[id as usize % self.cannon_text.len()] as u32; + } + q + } + + /// The particle as the ephemeral mapper sees it this step (unchanged without one). + fn ephemeral(&self, q: &PgParticle) -> PgParticle { + let mut e = *q; + if let Some(m) = &self.ephemeral { + m.apply(&mut e); + } + e } fn step(&self, st: &mut PgState) { let dt = (1.0 / SPS) as f32; let s = self.seed; - // Property mapper (persistent): drive properties from the map at the particle position. - if let Some(m) = &self.mapper { - st.parts.par_iter_mut().for_each(|q| { - let px = m.at(q.p[0], q.p[1]); - for (ch, &(target, lo, hi)) in self.map_to.iter().enumerate() { - let v = lo + (hi - lo) * px[ch]; - match target { - 1 => q.c[0] = v, - 2 => q.c[1] = v, - 3 => q.c[2] = v, - 4 => q.friction = v.clamp(0.0, 1.0), - 5 => q.r = self.radius * v.max(0.0), - 6 => q.p[0] = v, - 7 => q.p[1] = v, - 8 => q.v[0] = v, - 9 => q.v[1] = v, - 10 => q.force[0] = v, - 11 => q.force[1] = v, - 12 => q.c[3] = v.clamp(0.0, 1.0), - 13 => q.mass = v.max(0.01), - _ => {} - } - } - }); + // Persistent Property Mapper: the mapped values stay with the particle. + if let Some(m) = &self.persistent { + st.parts.par_iter_mut().for_each(|q| m.apply(q)); } + // Ephemeral Property Mapper: the mapped values hold for this step only. + let eph: Vec = st.parts.par_iter().map(|q| self.ephemeral(q)).collect(); // Repel: spatial hash, symmetric pairwise push within the radius. let mut push = vec![[0.0f32; 2]; st.parts.len()]; - if self.repel != 0.0 && self.repel_radius > 0.0 && st.parts.len() > 1 { + if self.repel != 0.0 && self.repel_radius > 0.0 && eph.len() > 1 { let cell = self.repel_radius; let mut grid: std::collections::HashMap<(i32, i32), Vec> = std::collections::HashMap::new(); - for (i, q) in st.parts.iter().enumerate() { + for (i, q) in eph.iter().enumerate() { grid.entry(((q.p[0] / cell).floor() as i32, (q.p[1] / cell).floor() as i32)).or_default().push(i); } - let parts = &st.parts; + let parts = &eph; push.par_iter_mut().enumerate().for_each(|(i, f)| { let q = parts[i]; + let w = self.repel_affects.weight(&q); + if w <= 0.0 { + return; + } let (cx, cy) = ((q.p[0] / cell).floor() as i32, (q.p[1] / cell).floor() as i32); for gy in cy - 1..=cy + 1 { for gx in cx - 1..=cx + 1 { @@ -179,7 +448,7 @@ impl Pg { let (dx, dy) = (q.p[0] - o.p[0], q.p[1] - o.p[1]); let d = (dx * dx + dy * dy).sqrt(); if d < self.repel_radius && d > 1e-4 { - let k = self.repel * (1.0 - d / self.repel_radius) / d; + let k = w * self.repel * (1.0 - d / self.repel_radius) / d; f[0] += dx * k; f[1] += dy * k; } @@ -189,14 +458,26 @@ impl Pg { }); } let wall = self.wall.as_deref(); - st.parts.par_iter_mut().zip(push.par_iter()).for_each(|(q, f)| { + st.parts.par_iter_mut().zip(eph.par_iter()).zip(push.par_iter()).for_each(|((q, e), f)| { + let gw = self.grav_affects.weight(e); let g = if self.grav_spread > 0.0 { 1.0 + (hash1(q.id, 7, s) * 2.0 - 1.0) * self.grav_spread } else { 1.0 }; - let ax = self.gravity[0] * g + (q.force[0] + f[0]) / q.mass; - let ay = self.gravity[1] * g + (q.force[1] + f[1]) / q.mass; - let damp = 1.0 - q.friction * 0.1; - q.v = [(q.v[0] + ax * dt) * damp, (q.v[1] + ay * dt) * damp]; - let np = [q.p[0] + q.v[0] * dt, q.p[1] + q.v[1] * dt]; - if let Some(w) = wall { + let ax = self.gravity[0] * g * gw + (e.force[0] + f[0]) / e.mass; + let ay = self.gravity[1] * g * gw + (e.force[1] + f[1]) / e.mass; + // Static friction: a resting particle stays until the push beats it. + let resting = e.v[0].abs() < 1e-3 && e.v[1].abs() < 1e-3; + let (ax, ay) = if resting && (ax * ax + ay * ay).sqrt() <= e.static_friction { (0.0, 0.0) } else { (ax, ay) }; + let damp = (1.0 - e.friction * 0.1).max(0.0); + let nv = [(e.v[0] + ax * dt) * damp, (e.v[1] + ay * dt) * damp]; + // Keep only the persistent part of the velocity. + q.v = [nv[0] - (e.v[0] - q.v[0]), nv[1] - (e.v[1] - q.v[1])]; + q.ang_vel += e.torque * dt; + q.angle += e.ang_vel * dt; + q.scale = [(q.scale[0] + e.scale_speed * dt).max(0.0), (q.scale[1] + e.scale_speed * dt).max(0.0)]; + q.age += dt; + let np = [q.p[0] + nv[0] * dt, q.p[1] + nv[1] * dt]; + if let Some(w) = wall + && self.wall_affects.weight(e) > 0.5 + { let was = point_in_poly(w, q.p[0] as f64, q.p[1] as f64); let now = point_in_poly(w, np[0] as f64, np[1] as f64); if was != now { @@ -208,7 +489,41 @@ impl Pg { q.p = np; }); let b = self.bounds; - st.parts.retain(|q| q.p[0] > b[0] && q.p[0] < b[2] && q.p[1] > b[1] && q.p[1] < b[3]); + let eph_life: Vec = st.parts.iter().map(|q| self.ephemeral(q).lifespan).collect(); + let mut k = 0; + st.parts.retain(|q| { + let life = eph_life[k]; + k += 1; + q.p[0] > b[0] && q.p[0] < b[2] && q.p[1] > b[1] && q.p[1] < b[3] && q.age <= life + }); + // Particle Exploder: affected particles larger than the new radius burst into smaller + // ones that fly apart (each new particle bursts no further). + if self.pex_radius > 0.0 { + let mut out = Vec::with_capacity(st.parts.len()); + for q in std::mem::take(&mut st.parts) { + if q.src == SRC_PARTICLE_EXPLODER || q.ch != 0 || q.r <= self.pex_radius * 1.01 || self.pex_affects.weight(&q) <= 0.5 || out.len() > 60_000 { + out.push(q); + continue; + } + let n = ((q.r / self.pex_radius).powi(2).round() as u32).clamp(2, 16); + for k in 0..n { + let id = st.next_id; + st.next_id = st.next_id.wrapping_add(1); + let a = (k as f32 + hash1(id, 31, s)) / n as f32 * std::f32::consts::TAU; + let (sa, ca) = a.sin_cos(); + let rr = q.r - self.pex_radius; + let sp = self.pex_dispersion * (0.5 + hash1(id, 32, s)); + let mut c = q; + c.p = [q.p[0] + ca * rr, q.p[1] + sa * rr]; + c.v = [q.v[0] + ca * sp, q.v[1] + sa * sp]; + c.r = self.pex_radius; + c.src = SRC_PARTICLE_EXPLODER; + c.id = id; + out.push(c); + } + } + st.parts = out; + } if self.cannon { st.carry += self.rate / SPS; let n = st.carry.floor() as u32; @@ -231,6 +546,123 @@ impl Pg { static PG_CACHE: SimCache = SimCache::new(6); +fn image_key(img: &Image) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + img.width.hash(&mut h); + img.height.hash(&mut h); + for p in img.data.iter().step_by(7) { + for c in p { + c.to_bits().hash(&mut h); + } + } + h.finish() +} + +fn mapper_from(ctx: &EffectCtx, group: &str, ephemeral: bool) -> Option { + let pr = ctx.params; + let map = ctx.layer_param(&format!("{group}/useLayerAsMap"), true).map(|lp| MapImg::from(ctx, lp))?; + let ch = |c: &str| { + ( + pr.e(&format!("{group}/map{c}To")), + if ephemeral { pr.e(&format!("{group}/{}Operator", c.to_lowercase())) } else { 0 }, + pr.f(&format!("{group}/{}Min", c.to_lowercase())) as f32, + pr.f(&format!("{group}/{}Max", c.to_lowercase())) as f32, + ) + }; + Some(Mapper { map, chans: [ch("Red"), ch("Green"), ch("Blue")], affects: Affects::from(ctx, group) }) +} + +/// Draw a map layer frame as a particle: centred on (x, y) buffer px, rotated `angle` degrees +/// and scaled, over `out`. +fn blit_layer(out: &mut Image, lp: &crate::LayerPixels, x: f64, y: f64, angle: f64, scale: [f64; 2], buf_scale: f64, alpha: f32) { + let (w, h) = (lp.size[0] * scale[0].abs() * buf_scale, lp.size[1] * scale[1].abs() * buf_scale); + let r = (w * w + h * h).sqrt() * 0.5 + 1.0; + let (x0, x1) = ((x - r).floor().max(0.0) as i64, (x + r).ceil().min(out.width as f64) as i64); + let (y0, y1) = ((y - r).floor().max(0.0) as i64, (y + r).ceil().min(out.height as f64) as i64); + if x0 >= x1 || y0 >= y1 || scale[0] == 0.0 || scale[1] == 0.0 { + return; + } + let (sa, ca) = angle.to_radians().sin_cos(); + for py in y0..y1 { + for px in x0..x1 { + let (dx, dy) = ((px as f64 + 0.5 - x) / buf_scale, (py as f64 + 0.5 - y) / buf_scale); + // Undo rotation, then scale; into map layer pixels around its centre. + let (u, v) = ((dx * ca + dy * sa) / scale[0] + lp.size[0] * 0.5, (-dx * sa + dy * ca) / scale[1] + lp.size[1] * 0.5); + if u < 0.0 || v < 0.0 || u >= lp.size[0] || v >= lp.size[1] { + continue; + } + let s = lp.buf.img.sample_bilinear(u * lp.buf.scale + lp.buf.offset[0], v * lp.buf.scale + lp.buf.offset[1]); + if s[3] <= 0.0 { + continue; + } + let d = out.get(px, py); + let k = 1.0 - s[3] * alpha; + out.set(px as u32, py as u32, [s[0] * alpha + d[0] * k, s[1] * alpha + d[1] * k, s[2] * alpha + d[2] * k, s[3] * alpha + d[3] * k]); + } + } +} + +/// Particle Playground's producers and forces. +fn pg_of(ctx: &EffectCtx) -> Pg { + let pr = ctx.params; + let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32); + let grav_dir = (pr.f("gravity/gravityDirection") as f32).to_radians(); + let gf = pr.f("gravity/gravityForce") as f32; + let wall = (pr.f("wall/wallBoundary").round() as usize).checked_sub(1).and_then(|i| ctx.env.masks.get(i)).map(|m| m.points.clone()); + let persistent = if pr.b("mapperEnabled") { mapper_from(ctx, "persistentPropertyMapper", false) } else { None }; + let ephemeral = mapper_from(ctx, "ephemeralPropertyMapper", true); + Pg { + cannon: pr.b("cannonEnabled"), + pos: { pr.v2("cannon/cannonPosition").map(|v| v as f32) }, + barrel_angle: pr.f("cannon/barrelAngle") as f32, + barrel_radius: pr.f("cannon/barrelRadius") as f32, + rate: pr.f("cannon/particlesPerSecond").max(0.0), + dir_spread: pr.f("cannon/directionRandomSpread") as f32, + vel: pr.f("cannon/velocity") as f32, + vel_spread: pr.f("cannon/velocityRandomSpread") as f32, + color: pr.color("cannon/cannonColor"), + radius: pr.f("cannon/cannonParticleRadius").max(0.0) as f32, + cannon_text: pr.s("options/cannonText").chars().collect(), + pex_radius: pr.f("particleExploder/radiusOfNewParticles").max(0.0) as f32, + pex_dispersion: pr.f("particleExploder/velocityDispersion") as f32, + pex_affects: Affects::from(ctx, "particleExploder"), + gravity: [grav_dir.sin() * gf, -grav_dir.cos() * gf], + grav_spread: (pr.f("gravity/gravityForceRandomSpread") / 100.0).max(0.0) as f32, + grav_affects: Affects::from(ctx, "gravity"), + repel: pr.f("repel/repelForce") as f32 * 100.0, + repel_radius: pr.f("repel/repelForceRadius").max(0.0) as f32, + repel_affects: Affects::from(ctx, "repel"), + wall, + wall_affects: Affects::from(ctx, "wall"), + persistent, + ephemeral, + seed: (pr.f("randomSeed") as u32).wrapping_mul(0x9e37_79b9) ^ ctx.seed, + bounds: [-lw * 2.0, -lh * 2.0, lw * 3.0, lh * 3.0], + } +} + +impl Pg { + /// Only a cannon with gravity: every particle moves on its own (what the host's particle + /// backend can simulate). + fn cannon_only(&self) -> bool { + self.cannon + && !(self.repel != 0.0 && self.repel_radius > 0.0) + && self.wall.is_none() + && self.persistent.is_none() + && self.ephemeral.is_none() + && self.pex_radius <= 0.0 + && self.grav_affects.is_all() + } +} + +impl Affects { + /// Affects everything (the defaults). + fn is_all(&self) -> bool { + self.from == 0 && self.selection.is_none() && self.chars.is_empty() && self.age == 0.0 + } +} + /// Particle Playground cannons with gravity only, simulated by the host's particle backend. fn pg_accelerated(ctx: &EffectCtx, key: u64, pg: &Pg) -> Option> { let backend = ctx.env.host?.particles()?; @@ -263,54 +695,6 @@ fn pg_accelerated(ctx: &EffectCtx, key: u64, pg: &Pg) -> Option u64 { - use std::hash::{Hash, Hasher}; - let mut h = std::collections::hash_map::DefaultHasher::new(); - img.width.hash(&mut h); - img.height.hash(&mut h); - for p in img.data.iter().step_by(7) { - for c in p { - c.to_bits().hash(&mut h); - } - } - h.finish() -} - -/// Particle Playground's producers and forces. -fn pg_of(ctx: &EffectCtx) -> Pg { - let pr = ctx.params; - let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32); - let grav_dir = (pr.f("gravity/gravityDirection") as f32).to_radians(); - let gf = pr.f("gravity/gravityForce") as f32; - let wall = (pr.f("wall/wallBoundary").round() as usize).checked_sub(1).and_then(|i| ctx.env.masks.get(i)).map(|m| m.points.clone()); - let mapper = if pr.b("mapperEnabled") { ctx.layer_param("persistentPropertyMapper/useLayerAsMap", true).map(|lp| MapImg::from(ctx, lp)) } else { None }; - Pg { - cannon: pr.b("cannonEnabled"), - pos: { pr.v2("cannon/cannonPosition").map(|v| v as f32) }, - barrel_angle: pr.f("cannon/barrelAngle") as f32, - barrel_radius: pr.f("cannon/barrelRadius") as f32, - rate: pr.f("cannon/particlesPerSecond").max(0.0), - dir_spread: pr.f("cannon/directionRandomSpread") as f32, - vel: pr.f("cannon/velocity") as f32, - vel_spread: pr.f("cannon/velocityRandomSpread") as f32, - color: pr.color("cannon/cannonColor"), - radius: pr.f("cannon/cannonParticleRadius").max(0.0) as f32, - gravity: [grav_dir.sin() * gf, -grav_dir.cos() * gf], - grav_spread: (pr.f("gravity/gravityForceRandomSpread") / 100.0).max(0.0) as f32, - repel: pr.f("repel/repelForce") as f32 * 100.0, - repel_radius: pr.f("repel/repelForceRadius").max(0.0) as f32, - wall, - mapper, - map_to: [ - (pr.e("persistentPropertyMapper/mapRedTo"), pr.f("persistentPropertyMapper/redMin") as f32, pr.f("persistentPropertyMapper/redMax") as f32), - (pr.e("persistentPropertyMapper/mapGreenTo"), pr.f("persistentPropertyMapper/greenMin") as f32, pr.f("persistentPropertyMapper/greenMax") as f32), - (pr.e("persistentPropertyMapper/mapBlueTo"), pr.f("persistentPropertyMapper/blueMin") as f32, pr.f("persistentPropertyMapper/blueMax") as f32), - ], - seed: (pr.f("randomSeed") as u32).wrapping_mul(0x9e37_79b9) ^ ctx.seed, - bounds: [-lw * 2.0, -lh * 2.0, lw * 3.0, lh * 3.0], - } -} - /// The live particles of a Particle Playground whose cannon is the only producer and force /// besides gravity, at `ctx.time`: from the host's particle backend when it has one (GPU /// particles), else from the CPU simulation (`None` for other set-ups). For comparing the two. @@ -319,7 +703,7 @@ pub fn playground_state(ctx: &EffectCtx) -> Option let pr = ctx.params; let grid = pr.b("gridEnabled") && pr.f("grid/particlesAcross").round() > 0.0 && pr.f("grid/particlesDown").round() > 0.0; let exploder = pr.b("exploderEnabled") && pr.get("layerExploder/explodeLayer").and_then(Value::as_layer).is_some(); - if !pg.cannon || grid || exploder || (pg.repel != 0.0 && pg.repel_radius > 0.0) || pg.wall.is_some() || pg.mapper.is_some() { + if !pg.cannon_only() || grid || exploder { return None; } let key = params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 0x7067); @@ -344,6 +728,7 @@ fn particle_playground(ctx: &EffectCtx, mut b: Buf) -> Buf { let (gw, gh) = (pr.f("grid/gridWidth"), pr.f("grid/gridHeight")); let gcol = pr.color("grid/gridColor"); let grad = pr.f("grid/gridParticleRadius").max(0.0) as f32; + let grid_text: Vec = pr.s("options/gridText").chars().collect(); let exploder = if pr.b("exploderEnabled") { let host = ctx.env.host; let lid = pr.get("layerExploder/explodeLayer").and_then(Value::as_layer); @@ -356,11 +741,15 @@ fn particle_playground(ctx: &EffectCtx, mut b: Buf) -> Buf { }; let ex_r = pr.f("layerExploder/radiusOfNewParticles").max(0.5) as f32; let ex_disp = pr.f("layerExploder/velocityDispersion") as f32; - let layer_map = pr.b("layerMapEnabled").then(|| ctx.layer_param("layerMap/layerMapLayer", true).map(|lp| MapImg::from(ctx, lp))).flatten(); let mut key = params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 0x7067); - if let Some(m) = &pg.mapper { - key ^= image_key(&m.img).rotate_left(7); + for m in [&pg.persistent, &pg.ephemeral].into_iter().flatten() { + key ^= image_key(&m.map.img).rotate_left(7); + } + for a in [&pg.pex_affects, &pg.grav_affects, &pg.repel_affects, &pg.wall_affects] { + if let Some(s) = &a.selection { + key ^= image_key(&s.img).rotate_left(11); + } } if let Some(e) = &exploder { key ^= image_key(&e.buf.img).rotate_left(13); @@ -372,14 +761,29 @@ fn particle_playground(ctx: &EffectCtx, mut b: Buf) -> Buf { let init = || { let mut st = PgState::default(); if grid_on && across > 0 && down > 0 { + let mut k = 0usize; for j in 0..down { for i in 0..across { + // Edit Grid Text: one character per grid point in reading order; spaces + // leave the point empty. + let ch = if grid_text.is_empty() { + 0 + } else { + let c = grid_text[k % grid_text.len()]; + k += 1; + if c == ' ' { + continue; + } + c as u32 + }; let fx = if across > 1 { i as f64 / (across - 1) as f64 - 0.5 } else { 0.0 }; let fy = if down > 1 { j as f64 / (down - 1) as f64 - 0.5 } else { 0.0 }; let p = [(gpos[0] + fx * gw) as f32, (gpos[1] + fy * gh) as f32]; let id = st.next_id; st.next_id += 1; - st.parts.push(PgParticle { p, v: [0.0; 2], origin: p, c: gcol, r: grad, mass: 1.0, friction: 0.0, force: [0.0; 2], id }); + let mut q = PgParticle::new(p, [0.0; 2], gcol, grad, id, SRC_GRID); + q.ch = ch; + st.parts.push(q); } } } @@ -398,7 +802,7 @@ fn particle_playground(ctx: &EffectCtx, mut b: Buf) -> Buf { let (dx, dy) = (x - lw * 0.5, y - lh * 0.5); let l = (dx * dx + dy * dy).sqrt().max(1.0); let v = [dx / l * ex_disp + h(1) * ex_disp, dy / l * ex_disp + h(2) * ex_disp]; - st.parts.push(PgParticle { p: [x, y], v, origin: [x, y], c, r: ex_r, mass: 1.0, friction: 0.0, force: [0.0; 2], id }); + st.parts.push(PgParticle::new([x, y], v, c, ex_r, id, SRC_LAYER_EXPLODER)); } x += step; } @@ -408,31 +812,84 @@ fn particle_playground(ctx: &EffectCtx, mut b: Buf) -> Buf { st }; // A cannon with gravity only: every particle moves on its own (GPU particles). - let independent = pg.cannon - && !(grid_on && across > 0 && down > 0) - && exploder.is_none() - && !(pg.repel != 0.0 && pg.repel_radius > 0.0) - && pg.wall.is_none() - && pg.mapper.is_none(); + let independent = pg.cannon_only() && !(grid_on && across > 0 && down > 0) && exploder.is_none(); let gpu = if independent { pg_accelerated(ctx, key, &pg) } else { None }; let st = gpu.unwrap_or_else(|| PG_CACHE.run(key, steps_at(ctx.time), init, |st, _| pg.step(st))); + // Render: what the ephemeral mapper changes shows for this frame only. + let parts: Vec = st.parts.iter().map(|q| pg.ephemeral(q)).collect(); + let auto_orient = pr.b("options/autoOrientRotation"); let s = b.scale as f32; - let sprites: Vec = st - .parts - .iter() - .map(|q| { - let (x, y) = b.to_px([q.p[0] as f64, q.p[1] as f64]); - let c = match &layer_map { - Some(m) => { - let mc = m.at(q.origin[0], q.origin[1]); - [mc[0], mc[1], mc[2], mc[3] * q.c[3]] + // Layer Map: affected particles show the map layer (at a time set by the offset type). + let map_id = if pr.b("layerMapEnabled") { pr.get("layerMap/layerMapLayer").and_then(Value::as_layer) } else { None }; + let map_affects = Affects::from(ctx, "layerMap"); + let offset_type = pr.e("layerMap/timeOffsetType"); + let offset = pr.f("layerMap/timeOffset"); + let fps = ctx.fps(); + let mut frames: std::collections::HashMap> = std::collections::HashMap::new(); + let mut sprites: Vec = vec![]; + let mut blits: Vec<(PgParticle, i64)> = vec![]; + for q in &parts { + let (x, y) = b.to_px([q.p[0] as f64, q.p[1] as f64]); + let angle = if auto_orient && (q.v[0] != 0.0 || q.v[1] != 0.0) { q.v[1].atan2(q.v[0]).to_degrees() + 90.0 } else { q.angle }; + if let (Some(_), true) = (map_id, map_affects.weight(q) > 0.5) { + let rnd = hash1(q.id, 41, seed) as f64; + let t = match offset_type { + 1 => offset, + 2 => ctx.env.comp_time + offset * rnd, + 3 => offset * rnd, + _ => ctx.env.comp_time + offset, + } + q.toff as f64; + let frame = (t * fps).round() as i64; + blits.push((PgParticle { angle, ..*q }, frame)); + continue; + } + let sc = (q.scale[0] + q.scale[1]) * 0.5; + match char::from_u32(q.ch).filter(|c| q.ch != 0 && *c != ' ') { + Some(c) => { + // A text particle: the character's strokes, Font Size tall, rotated. + let size = (q.r * sc * s) as f64; + let u = size * 0.7 / 6.0; + let wgt = (size * 0.045).max(0.35) as f32; + let (sa, ca) = (angle as f64).to_radians().sin_cos(); + let tr = |g: [f64; 2]| { + let (gx, gy) = ((g[0] - 2.0) * u * q.scale[0] as f64 / sc.max(1e-6) as f64, (g[1] - 3.0) * u * q.scale[1] as f64 / sc.max(1e-6) as f64); + [x + gx * ca - gy * sa, y + gx * sa + gy * ca] + }; + for stroke in crate::textfx::glyph_strokes(c) { + if stroke.len() == 1 { + let p0 = tr(stroke[0]); + sprites.push(Sprite::new(p0[0] as f32, p0[1] as f32, wgt, q.c, Shape::Disc)); + } + for w2 in stroke.windows(2) { + let (a0, a1) = (tr(w2[0]), tr(w2[1])); + sprites.push(Sprite::new(a0[0] as f32, a0[1] as f32, wgt, q.c, Shape::Line { dx: (a1[0] - a0[0]) as f32, dy: (a1[1] - a0[1]) as f32 })); + } } - None => q.c, - }; - Sprite::new(x as f32, y as f32, q.r * s, c, Shape::Disc) - }) - .collect(); - let fx = splat(b.img.width, b.img.height, &sprites, Acc::Over); + } + None => sprites.push(Sprite::new(x as f32, y as f32, q.r * sc * s, q.c, Shape::Disc)), + } + } + let mut fx = splat(b.img.width, b.img.height, &sprites, Acc::Over); + if let (Some(id), Some(host)) = (map_id, ctx.env.host) { + let me = pr.get(&crate::layer_source_id("layerMap/layerMapLayer")).is_none_or(|v| v.as_enum() != 0); + for (q, frame) in &blits { + if !frames.contains_key(frame) && frames.len() >= 32 { + // Too many distinct times: reuse the nearest fetched frame. + if let Some(k) = frames.keys().min_by_key(|k| (*k - frame).abs()).copied() + && let Some(Some(lp)) = frames.get(&k) + { + let (x, y) = b.to_px([q.p[0] as f64, q.p[1] as f64]); + blit_layer(&mut fx, lp, x, y, q.angle as f64, [q.scale[0] as f64, q.scale[1] as f64], b.scale, q.c[3]); + } + continue; + } + let lp = frames.entry(*frame).or_insert_with(|| host.layer_at(id, *frame as f64 / fps, me)); + if let Some(lp) = lp { + let (x, y) = b.to_px([q.p[0] as f64, q.p[1] as f64]); + blit_layer(&mut fx, lp, x, y, q.angle as f64, [q.scale[0] as f64, q.scale[1] as f64], b.scale, q.c[3]); + } + } + } // Particles replace the layer's own pixels. b.img = combine(&b.img, &fx, 5); b @@ -541,7 +998,23 @@ pub fn specs() -> Vec { let pt = |x: f64, y: f64| Value::Vec2([x, y]); let px = |d: f64, max: f64| (num(d), slider(0.0, max * 10.0, 0.0, max, 1)); let sp = |id: &'static str, name: &'static str, v: (Value, ParamUi)| p(id, name, v.0, v.1); + let leak = |s: String| -> &'static str { Box::leak(s.into_boxed_str()) }; + // An Affects twirl-down inside `group`. + let affects = |group: &str| -> Vec { + let id = |s: &str| leak(format!("{group}/affects/{s}")); + vec![ + p(id("particlesFrom"), "Particles From", Value::Enum(0), popup(&PARTICLES_FROM)), + p(id("selectionMap"), "Selection Map", Value::Layer(None), ParamUi::Layer), + p(id("characters"), "Characters", Value::Str(String::new()), ParamUi::Text), + p(id("olderYoungerThan"), "Older/Younger than", num(0.0), slider(-1000.0, 1000.0, -10.0, 10.0, 2)), + p(id("ageFeather"), "Age Feather", num(0.0), slider(0.0, 1000.0, 0.0, 10.0, 2)), + ] + }; let mut pg = vec![ + // Options (the Options dialog): text fired by the cannon / placed on the grid. + p("options/cannonText", "Cannon Text", Value::Str(String::new()), ParamUi::Text), + p("options/gridText", "Grid Text", Value::Str(String::new()), ParamUi::Text), + p("options/autoOrientRotation", "Auto-Orient Rotation", Value::Bool(false), ParamUi::Checkbox), // Generator switches (kept for saved projects; After Effects turns a generator off with // its rate / counts instead). p("cannonEnabled", "Cannon Enabled", Value::Bool(true), ParamUi::Hidden), @@ -571,20 +1044,40 @@ pub fn specs() -> Vec { p("layerExploder/explodeLayer", "Explode Layer", Value::Layer(None), ParamUi::Layer), sp("layerExploder/radiusOfNewParticles", "Radius of New Particles", px(2.0, 50.0)), sp("layerExploder/velocityDispersion", "Velocity Dispersion", px(20.0, 500.0)), - // Layer Map + ]; + pg.extend([ + sp("particleExploder/radiusOfNewParticles", "Radius of New Particles", px(0.0, 50.0)), + sp("particleExploder/velocityDispersion", "Velocity Dispersion", px(20.0, 500.0)), + ]); + pg.extend(affects("particleExploder")); + pg.extend([ p("layerMap/layerMapLayer", "Use Layer", Value::Layer(None), ParamUi::Layer), + p("layerMap/timeOffsetType", "Time Offset Type", Value::Enum(0), popup(&TIME_OFFSET_TYPES)), + p("layerMap/timeOffset", "Time Offset", num(0.0), slider(-1000.0, 1000.0, -10.0, 10.0, 2)), + ]); + pg.extend(affects("layerMap")); + pg.extend([ // Gravity sp("gravity/gravityForce", "Force", px(108.0, 1000.0)), sp("gravity/gravityForceRandomSpread", "Force Random Spread", px(0.0, 100.0)), p("gravity/gravityDirection", "Direction", num(180.0), ParamUi::Angle), + ]); + pg.extend(affects("gravity")); + pg.extend([ // Repel p("repel/repelForce", "Force", num(0.0), slider(-100.0, 100.0, -10.0, 10.0, 2)), sp("repel/repelForceRadius", "Force Radius", px(0.0, 100.0)), + ]); + pg.extend(affects("repel")); + pg.extend([ // Wall p("wall/wallBoundary", "Boundary", num(0.0), ParamUi::Mask), + ]); + pg.extend(affects("wall")); + pg.extend([ // Persistent Property Mapper p("persistentPropertyMapper/useLayerAsMap", "Use Layer As Map", Value::Layer(None), ParamUi::Layer), - ]; + ]); for (to, mn, mx, n_to) in [ ("persistentPropertyMapper/mapRedTo", "persistentPropertyMapper/redMin", "persistentPropertyMapper/redMax", "Map Red To"), ("persistentPropertyMapper/mapGreenTo", "persistentPropertyMapper/greenMin", "persistentPropertyMapper/greenMax", "Map Green To"), @@ -594,6 +1087,18 @@ pub fn specs() -> Vec { pg.push(p(mn, "Min", num(0.0), slider(-10000.0, 10000.0, -100.0, 100.0, 2))); pg.push(p(mx, "Max", num(1.0), slider(-10000.0, 10000.0, -100.0, 100.0, 2))); } + pg.extend(affects("persistentPropertyMapper")); + // Ephemeral Property Mapper: like the persistent one, with an operator per channel; the + // values hold for one frame. + pg.push(p("ephemeralPropertyMapper/useLayerAsMap", "Use Layer As Map", Value::Layer(None), ParamUi::Layer)); + for c in ["Red", "Green", "Blue"] { + let l = c.to_lowercase(); + pg.push(p(leak(format!("ephemeralPropertyMapper/map{c}To")), leak(format!("Map {c} To")), Value::Enum(0), popup(&MAP_TARGETS))); + pg.push(p(leak(format!("ephemeralPropertyMapper/{l}Operator")), "Operator", Value::Enum(0), popup(&MAP_OPERATORS))); + pg.push(p(leak(format!("ephemeralPropertyMapper/{l}Min")), "Min", num(0.0), slider(-10000.0, 10000.0, -100.0, 100.0, 2))); + pg.push(p(leak(format!("ephemeralPropertyMapper/{l}Max")), "Max", num(1.0), slider(-10000.0, 10000.0, -100.0, 100.0, 2))); + } + pg.extend(affects("ephemeralPropertyMapper")); pg.push(p("randomSeed", "Random Seed", num(0.0), slider(0.0, 10000.0, 0.0, 1000.0, 0))); let pc = |d: f64| (num(d), slider(0.0, 100.0, 0.0, 100.0, 1)); vec![ @@ -759,7 +1264,19 @@ mod tests { 0.7, ); assert_eq!(a.data, b.data); - let st = Pg { + let st = + Pg { persistent: Some(mapper(Image::filled(4, 4, [1.0, 0.0, 0.5, 1.0]), [(15, 0, 0.0, 120.0), (0, 0, 0.0, 1.0), (9, 0, 0.0, 2.0)])), ..bare(1) }; + let mut s = PgState::default(); + s.parts.push(PgParticle::new([1.0, 1.0], [0.0; 2], [1.0; 4], 2.0, 0, SRC_GRID)); + st.step(&mut s); + // Red 1 → X speed 120 px/s; blue 0.5 → scale 1. + assert!((s.parts[0].v[0] - 120.0).abs() < 1e-3); + assert!((s.parts[0].scale[0] - 1.0).abs() < 1e-3); + } + + /// A Pg with nothing switched on. + fn bare(seed: u32) -> Pg { + Pg { cannon: false, pos: [0.0; 2], barrel_angle: 0.0, @@ -770,47 +1287,32 @@ mod tests { vel_spread: 0.0, color: [1.0; 4], radius: 2.0, + cannon_text: vec![], + pex_radius: 0.0, + pex_dispersion: 0.0, + pex_affects: Affects::default(), gravity: [0.0; 2], grav_spread: 0.0, + grav_affects: Affects::default(), repel: 0.0, repel_radius: 0.0, + repel_affects: Affects::default(), wall: None, - mapper: Some(MapImg { img: Image::filled(4, 4, [1.0, 0.0, 0.5, 1.0]), k: [1.0; 2], o: [0.0; 2] }), - map_to: [(8, 0.0, 120.0), (0, 0.0, 1.0), (5, 0.0, 2.0)], - seed: 1, + wall_affects: Affects::default(), + persistent: None, + ephemeral: None, + seed, bounds: [-1e6, -1e6, 1e6, 1e6], - }; - let mut s = PgState::default(); - s.parts.push(PgParticle { p: [1.0, 1.0], v: [0.0; 2], origin: [1.0, 1.0], c: [1.0; 4], r: 2.0, mass: 1.0, friction: 0.0, force: [0.0; 2], id: 0 }); - st.step(&mut s); - // Red 1 → X velocity 120 px/s; blue 0.5 → scale 1 (radius 2). - assert!((s.parts[0].v[0] - 120.0).abs() < 1e-3); - assert!((s.parts[0].r - 2.0).abs() < 1e-3); + } + } + + fn mapper(img: Image, chans: [(u32, u32, f32, f32); 3]) -> Mapper { + Mapper { map: MapImg { img, k: [1.0; 2], o: [0.0; 2] }, chans, affects: Affects::default() } } #[test] fn barrel_radius_square_or_disc() { - let pg = |r: f32| Pg { - cannon: true, - pos: [0.0; 2], - barrel_angle: 0.0, - barrel_radius: r, - rate: 0.0, - dir_spread: 0.0, - vel: 0.0, - vel_spread: 0.0, - color: [1.0; 4], - radius: 2.0, - gravity: [0.0; 2], - grav_spread: 0.0, - repel: 0.0, - repel_radius: 0.0, - wall: None, - mapper: None, - map_to: [(0, 0.0, 1.0); 3], - seed: 3, - bounds: [-1e6, -1e6, 1e6, 1e6], - }; + let pg = |r: f32| Pg { cannon: true, barrel_radius: r, ..bare(3) }; let (sq, disc) = (pg(10.0), pg(-10.0)); let mut corner = false; for id in 0..400 { @@ -828,37 +1330,170 @@ mod tests { #[test] fn mapper_drives_position_and_opacity_in_ae_order() { - let st = Pg { - cannon: false, - pos: [0.0; 2], - barrel_angle: 0.0, - barrel_radius: 0.0, - rate: 0.0, - dir_spread: 0.0, - vel: 0.0, - vel_spread: 0.0, - color: [1.0; 4], - radius: 2.0, - gravity: [0.0; 2], - grav_spread: 0.0, - repel: 0.0, - repel_radius: 0.0, - wall: None, - mapper: Some(MapImg { img: Image::filled(4, 4, [1.0, 0.5, 0.25, 1.0]), k: [1.0; 2], o: [0.0; 2] }), - map_to: [(6, 0.0, 30.0), (12, 0.0, 1.0), (4, 0.0, 1.0)], - seed: 1, - bounds: [-1e6, -1e6, 1e6, 1e6], - }; - assert_eq!(MAP_TARGETS[6], "X"); - assert_eq!(MAP_TARGETS[12], "Opacity"); + let st = + Pg { persistent: Some(mapper(Image::filled(4, 4, [1.0, 0.5, 0.25, 1.0]), [(12, 0, 0.0, 30.0), (20, 0, 0.0, 1.0), (4, 0, 0.0, 1.0)])), ..bare(1) }; + assert_eq!(MAP_TARGETS[12], "X"); + assert_eq!(MAP_TARGETS[20], "Opacity"); + assert_eq!(MAP_TARGETS[26], "Scale Speed"); let mut s = PgState::default(); - s.parts.push(PgParticle { p: [1.0, 1.0], v: [0.0; 2], origin: [1.0, 1.0], c: [1.0; 4], r: 2.0, mass: 1.0, friction: 0.0, force: [0.0; 2], id: 0 }); + s.parts.push(PgParticle::new([1.0, 1.0], [0.0; 2], [1.0; 4], 2.0, 0, SRC_GRID)); st.step(&mut s); assert!((s.parts[0].p[0] - 30.0).abs() < 1e-3, "{:?}", s.parts[0].p); assert!((s.parts[0].c[3] - 0.5).abs() < 1e-3); assert!((s.parts[0].friction - 0.25).abs() < 1e-3); } + #[test] + fn ephemeral_mapper_holds_for_one_step_with_operators() { + // Ephemeral X Speed = 60 px/s (Set): the particle moves, but keeps no velocity. + let st = Pg { ephemeral: Some(mapper(Image::filled(4, 4, [1.0, 1.0, 1.0, 1.0]), [(15, 0, 0.0, 60.0), (0, 0, 0.0, 1.0), (0, 0, 0.0, 1.0)])), ..bare(1) }; + let mut s = PgState::default(); + s.parts.push(PgParticle::new([1.0, 1.0], [0.0; 2], [1.0; 4], 2.0, 0, SRC_GRID)); + st.step(&mut s); + assert!((s.parts[0].p[0] - 2.0).abs() < 1e-4, "{:?}", s.parts[0].p); + assert!(s.parts[0].v[0].abs() < 1e-4); + // Operators: Add / Multiply / Max on the particle's own value. + let op = |o: u32, cur: f32| { + let m = mapper(Image::filled(4, 4, [1.0, 1.0, 1.0, 1.0]), [(21, o, 0.0, 3.0), (0, 0, 0.0, 1.0), (0, 0, 0.0, 1.0)]); + let mut q = PgParticle::new([1.0, 1.0], [0.0; 2], [1.0; 4], 2.0, 0, SRC_GRID); + q.mass = cur; + m.apply(&mut q); + q.mass + }; + assert_eq!(MAP_OPERATORS[1], "Add"); + assert!((op(1, 2.0) - 5.0).abs() < 1e-5); + assert!((op(4, 2.0) - 6.0).abs() < 1e-5); + assert!((op(6, 5.0) - 5.0).abs() < 1e-5); + assert!((op(2, 5.0) - 2.0).abs() < 1e-5, "Difference"); + } + + #[test] + fn affects_select_by_source_age_and_characters() { + let mut q = PgParticle::new([0.0; 2], [0.0; 2], [1.0; 4], 2.0, 0, SRC_CANNON); + let a = Affects { from: 2, ..Default::default() }; + assert_eq!(a.weight(&q), 0.0, "grid only"); + q.src = SRC_GRID; + assert_eq!(a.weight(&q), 1.0); + let older = Affects { age: 1.0, ..Default::default() }; + q.age = 0.5; + assert_eq!(older.weight(&q), 0.0); + q.age = 1.5; + assert_eq!(older.weight(&q), 1.0); + let younger = Affects { age: -1.0, feather: 1.0, ..Default::default() }; + q.age = 1.0; + assert!((younger.weight(&q) - 0.5).abs() < 1e-5, "feathered"); + let chars = Affects { chars: vec!['A', 'B'], ..Default::default() }; + q.ch = 'B' as u32; + assert_eq!(chars.weight(&q), 1.0); + q.ch = 'C' as u32; + assert_eq!(chars.weight(&q), 0.0); + // Gravity restricted to the cannon leaves grid particles in place. + let st = Pg { gravity: [0.0, 100.0], grav_affects: Affects { from: 1, ..Default::default() }, ..bare(1) }; + let mut s = PgState::default(); + s.parts.push(PgParticle::new([5.0, 5.0], [0.0; 2], [1.0; 4], 2.0, 0, SRC_GRID)); + s.parts.push(PgParticle::new([5.0, 5.0], [0.0; 2], [1.0; 4], 2.0, 1, SRC_CANNON)); + for _ in 0..10 { + st.step(&mut s); + } + assert_eq!(s.parts[0].p, [5.0, 5.0]); + assert!(s.parts[1].p[1] > 5.0); + } + + #[test] + fn particle_exploder_bursts_big_particles() { + let st = Pg { pex_radius: 2.0, pex_dispersion: 30.0, ..bare(1) }; + let mut s = PgState::default(); + s.parts.push(PgParticle::new([50.0, 50.0], [0.0; 2], [1.0; 4], 6.0, 0, SRC_GRID)); + s.next_id = 1; + st.step(&mut s); + assert_eq!(s.parts.len(), 9, "(6/2)² new particles"); + assert!(s.parts.iter().all(|q| q.r == 2.0 && q.src == SRC_PARTICLE_EXPLODER)); + st.step(&mut s); + assert_eq!(s.parts.len(), 9, "new particles do not burst again"); + // Lifespan from a mapper removes particles once they are older. + let life = Pg { persistent: Some(mapper(Image::filled(4, 4, [1.0; 4]), [(22, 0, 0.0, 0.05), (0, 0, 0.0, 1.0), (0, 0, 0.0, 1.0)])), ..bare(1) }; + let mut s = PgState::default(); + s.parts.push(PgParticle::new([1.0, 1.0], [0.0; 2], [1.0; 4], 2.0, 0, SRC_GRID)); + for _ in 0..2 { + life.step(&mut s); + } + assert_eq!(s.parts.len(), 1); + for _ in 0..3 { + life.step(&mut s); + } + assert!(s.parts.is_empty()); + } + + #[test] + fn text_particles_draw_characters() { + let grid = |text: &str| { + run( + &[ + ("cannonEnabled", Value::Bool(false)), + ("grid/gridPosition", Value::Vec2([40.0, 30.0])), + ("grid/gridWidth", num(40.0)), + ("grid/gridHeight", num(0.0)), + ("grid/particlesAcross", num(3.0)), + ("grid/particlesDown", num(1.0)), + ("grid/gridParticleRadius", num(16.0)), + ("gravity/gravityForce", num(0.0)), + ("options/gridText", Value::Str(text.into())), + ], + 0.1, + ) + }; + let dots = grid(""); + let text = grid("I-O"); + assert_ne!(dots.data, text.data); + // "I" is a vertical bar: ink above and below the grid point, not a round dot. + assert!(text.get(20, 26)[3] > 0.3 && text.get(20, 34)[3] > 0.3, "{:?}", text.get(20, 26)); + // A space leaves its grid point empty. + let gap = grid("I O"); + assert!(gap.get(40, 30)[3] < 0.05); + // Cannon text: particles carry the characters in order. + let pg = Pg { cannon_text: vec!['A', 'B'], ..bare(1) }; + assert_eq!(pg.spawn(0).ch, 'A' as u32); + assert_eq!(pg.spawn(3).ch, 'B' as u32); + } + + #[test] + fn layer_map_shows_the_map_layer_at_its_time_offset() { + /// A 6×6 map layer whose colour encodes the comp time it was fetched at. + struct Clock; + impl crate::EffectHost for Clock { + fn layer(&self, _: u64, _: bool) -> Option { + None + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + fn layer_at(&self, _: u64, t: f64, _: bool) -> Option { + let v = (t as f32 / 4.0).clamp(0.0, 1.0); + Some(crate::LayerPixels { buf: Buf { img: Image::filled(6, 6, [v, 0.0, 1.0 - v, 1.0]), offset: [0.0; 2], scale: 1.0 }, size: [6.0, 6.0] }) + } + } + let render = |kind: u32, offset: f64| { + let vals = [ + ("cannonEnabled", Value::Bool(false)), + ("grid/gridPosition", Value::Vec2([40.0, 30.0])), + ("grid/particlesAcross", num(1.0)), + ("grid/particlesDown", num(1.0)), + ("gravity/gravityForce", num(0.0)), + ("layerMap/layerMapLayer", Value::Layer(Some(9))), + ("layerMap/timeOffsetType", Value::Enum(kind)), + ("layerMap/timeOffset", num(offset)), + ]; + let env = EffectEnv { host: Some(&Clock), comp_time: 1.0, frame_rate: 10.0, ..Default::default() }; + run_fx("ec.sim.particleplayground", &vals, Image::filled(80, 60, [0.2, 0.2, 0.2, 1.0]), 1.0, env).img + }; + // The particle is a 6×6 copy of the map layer. + let rel = render(0, 1.0); + assert!(rel.get(40, 30)[3] > 0.99 && rel.get(46, 30)[3] < 0.01); + assert!((rel.get(40, 30)[0] - 0.5).abs() < 1e-3, "relative: now + 1 s = 2 s"); + let abs = render(1, 3.0); + assert!((abs.get(40, 30)[0] - 0.75).abs() < 1e-3, "absolute: 3 s"); + } + #[test] fn cc_hair_grows_from_opaque_pixels() { let mut img = Image::new(60, 60); diff --git a/crates/effects/src/stylize2.rs b/crates/effects/src/stylize2.rs index 56bbedac..06c09d2e 100644 --- a/crates/effects/src/stylize2.rs +++ b/crates/effects/src/stylize2.rs @@ -61,6 +61,41 @@ fn cartoon(ctx: &EffectCtx, mut b: Buf) -> Buf { } else { b.img.clone() }; + // Advanced ▸ Edge Enhancement: one shock-filter step. Each pixel samples from the side of + // the nearest edge it belongs to (along the luma gradient, by the sign of the Laplacian): + // positive values sharpen the transitions, negative ones soften them. + let enh = ctx.params.f("advanced/edgeEnhancement") / 100.0; + let smoothed = if enh < 0.0 { + // Softening: blend towards a blur of the picture. + let s = 1.5 * b.scale; + let blur = effectcraft_raster::gaussian_blur(&smoothed, s, s, true); + let k = (-enh) as f32; + let mut out = smoothed; + out.data.par_iter_mut().zip(blur.data.par_iter()).for_each(|(p, q)| (0..4).for_each(|c| p[c] += (q[c] - p[c]) * k)); + out + } else if enh > 0.0 { + let l = Plane::luma(&smoothed); + let (w, h) = (l.w as i64, l.h as i64); + let s = enh * 2.0 * b.scale; + let src = smoothed.clone(); + let mut out = smoothed; + out.data.par_iter_mut().enumerate().for_each(|(i, px)| { + let (x, y) = ((i as i64) % w, (i as i64) / w); + let g = |dx: i64, dy: i64| l.get_clamped(x + dx, y + dy); + let (gx, gy) = ((g(1, 0) - g(-1, 0)) * 0.5, (g(0, 1) - g(0, -1)) * 0.5); + let lap = g(1, 0) + g(-1, 0) + g(0, 1) + g(0, -1) - 4.0 * g(0, 0); + let m = (gx * gx + gy * gy).sqrt(); + if m < 1e-4 || lap.abs() < 1e-5 || y >= h { + return; + } + let k = -lap.signum() as f64 * s / m as f64; + let (sx, sy) = (x as f64 + 0.5 + gx as f64 * k, y as f64 + 0.5 + gy as f64 * k); + *px = src.sample_bilinear_clamped(sx, sy); + }); + out + } else { + smoothed + }; let luma = Plane::luma(&smoothed); let edges = if render >= 1 { let mag = sobel(&luma); @@ -543,6 +578,7 @@ pub fn specs() -> Vec { p("edge/edgeWidth", "Width", num(1.5), slider(0.0, 10.0, 0.0, 5.0, 2)), p("edge/edgeSoftness", "Softness", num(60.0), pct()), p("edge/edgeOpacity", "Opacity", num(100.0), pct()), + p("advanced/edgeEnhancement", "Edge Enhancement", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 1)), p("advanced/edgeBlackLevel", "Edge Black Level", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)), p("advanced/edgeContrast", "Edge Contrast", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 2)), ], @@ -993,4 +1029,30 @@ mod tests { assert_eq!(cyc(0.0), cyc(360.0)); assert_ne!(cyc(0.0), cyc(180.0)); } + + #[test] + fn cartoon_edge_enhancement_sharpens_or_softens() { + // A soft 8-pixel ramp from black to white. + let mut img = Image::new(32, 4); + for y in 0..4 { + for x in 0..32 { + let v = 0.5 + 0.5 * ((x as f32 - 15.5) / 4.0).tanh(); + img.set(x, y, [v, v, v, 1.0]); + } + } + let fill = |e: f64| { + let v = [ + ("render", Value::Enum(0)), + ("detailRadius", num(0.0)), + ("fill/shadingSteps", num(64.0)), + ("fill/shadingSmoothness", num(100.0)), + ("advanced/edgeEnhancement", num(e)), + ]; + run_at("ec.stylize.cartoon", &v, img.clone(), 0.0).img + }; + let steep = |o: &Image| (0..31).map(|x| (o.get(x + 1, 2)[0] - o.get(x, 2)[0]).abs()).fold(0.0f32, f32::max); + let (base, sharp, soft) = (fill(0.0), fill(100.0), fill(-100.0)); + assert!(steep(&sharp) > steep(&base) + 0.02, "{} vs {}", steep(&sharp), steep(&base)); + assert!(steep(&soft) < steep(&base) + 1e-4, "{} vs {}", steep(&soft), steep(&base)); + } } diff --git a/crates/effects/src/textfx.rs b/crates/effects/src/textfx.rs index ade9b1ce..87364f6b 100644 --- a/crates/effects/src/textfx.rs +++ b/crates/effects/src/textfx.rs @@ -94,6 +94,11 @@ fn glyph_src(c: char) -> &'static str { } } +/// A glyph's strokes on the 4 × 6 grid (Particle Playground's text particles). +pub(crate) fn glyph_strokes(c: char) -> Vec> { + parse_glyph(c) +} + fn parse_glyph(c: char) -> Vec> { glyph_src(c) .split(';') @@ -503,49 +508,112 @@ fn path_text(ctx: &EffectCtx, mut b: Buf) -> Buf { closed = false; } let len = poly_length(&path, closed); - let text: String = pr.s("text").chars().take(g("advanced", "visibleCharacters").as_f64().max(0.0) as usize).collect(); + let visible = g("advanced", "visibleCharacters").as_f64().max(0.0); + let all: Vec = pr.s("text").chars().collect(); + let n_all = all.len(); let size = (g("character", "size").as_f64() * b.scale).max(0.5); let u = unit(size); let tracking = g("character", "tracking").as_f64() * b.scale; let lm = g("paragraph", "leftMargin").as_f64() * b.scale; let rm = g("paragraph", "rightMargin").as_f64() * b.scale; let shift = g("paragraph", "baselineShift").as_f64() * b.scale; + let line_spacing = pr.get("paragraph/lineSpacing").map(Value::as_f64).unwrap_or(size / b.scale * 1.2) * b.scale; let rot = g("character/orientation", "characterRotation").as_f64().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 g("paragraph", "alignment").as_enum() { - 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 shear = pr.f("character/orientation/characterShear").to_radians().tan(); + let hshear = pr.f("character/horizontalShear").to_radians().tan(); + let sx = pr.get("character/horizontalScale").map(Value::as_f64).unwrap_or(100.0) / 100.0; + let sy = pr.get("character/verticalScale").map(Value::as_f64).unwrap_or(100.0) / 100.0; + // Kerning: "index:value …" — the gap after character `index` (0-based) in 1/1000 em. + let kerning: Vec<(usize, f64)> = pr + .s("character/kerning") + .split_whitespace() + .filter_map(|t| { + let (i, v) = t.split_once(':')?; + Some((i.trim().parse().ok()?, v.trim().parse().ok()?)) + }) + .collect(); + let kern = |i: usize| kerning.iter().filter(|(k, _)| *k == i).map(|(_, v)| v / 1000.0 * size).sum::(); + // Jitter: per character, re-randomised every frame. + let jit = |id: &str| pr.f(&format!("advanced/jitterSettings/{id}")); + let (j_base, j_kern, j_rot, j_scale) = + (jit("baselineJitterMax") * b.scale, jit("kerningJitterMax") * b.scale, jit("rotationJitterMax").to_radians(), jit("scaleJitterMax") / 100.0); + let frame = (ctx.time * ctx.fps()).floor() as i64 as u32; + let rnd = |i: usize, k: u32| hash1(i as u32, k.wrapping_add(frame.wrapping_mul(7919)), 0x7e17) as f64 * 2.0 - 1.0; + // Fade Time: characters fade in over this share of the visible characters. + let fade = pr.f("advanced/fadeTime").clamp(0.0, 100.0) / 100.0 * n_all.max(1) as f64; + let char_alpha = |i: usize| -> f32 { + if fade <= 0.0 { if (i as f64) < visible.floor() { 1.0 } else { 0.0 } } else { ((visible - i as f64) / fade).clamp(0.0, 1.0) as f32 } }; - 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(), - ); + // Lines (separated by line breaks) stack along the path's normal by Line Spacing. + let mut polys_by_alpha: Vec<(f32, Vec>)> = Vec::new(); + let mut gi = 0usize; + for (li, line) in all.split(|c| *c == '\n' || *c == '\r').enumerate() { + let idx0 = gi; + gi += line.len() + 1; + let n = line.len(); + let advs: Vec = line.iter().map(|&c| advance(c, true) * u * sx).collect(); + let kerns: Vec = (0..n).map(|k| kern(idx0 + k) + if j_kern != 0.0 { rnd(idx0 + k, 2) * j_kern } else { 0.0 }).collect(); + let natural: f64 = advs.iter().sum::() + tracking * (n.max(1) as f64 - 1.0) + kerns.iter().take(n.saturating_sub(1)).sum::(); + let avail = (len - lm - rm).max(0.0); + let (mut cursor, extra) = match g("paragraph", "alignment").as_enum() { + 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 line_off = li as f64 * line_spacing; + for (k, (&c, adv)) in line.iter().zip(advs).enumerate() { + let i = idx0 + k; + let mid = cursor + adv * 0.5; + cursor += adv + tracking + extra + kerns[k]; + let alpha = char_alpha(i); + if alpha <= 0.0 || (!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 + if j_rot != 0.0 { rnd(i, 3) * j_rot } else { 0.0 }; + let (s, co) = ang.sin_cos(); + let js = if j_scale != 0.0 { (1.0 + rnd(i, 4) * j_scale).max(0.05) } else { 1.0 }; + let jb = if j_base != 0.0 { rnd(i, 1) * j_base } else { 0.0 }; + let left = ink_left(c, true); + let set = match polys_by_alpha.iter_mut().find(|(a, _)| (*a - alpha).abs() < 1e-4) { + Some((_, v)) => v, + None => { + polys_by_alpha.push((alpha, Vec::new())); + &mut polys_by_alpha.last_mut().expect("pushed").1 + } + }; + for st in parse_glyph(c) { + set.push( + st.iter() + .map(|gl| { + // Glyph space (baseline at 0, y down), then scale, shear and lift. + let gx = ((gl[0] - left - 0.8) * u + 0.8 * u) * sx * js - adv * 0.5; + let gy = (gl[1] - 6.0) * u * sy * js; + let gx = gx - gy * (shear + hshear); + let gy = gy - shift - jb + line_off; + [q[0] + gx * co - gy * s, q[1] + gx * s + gy * co] + }) + .collect(), + ); + } } } - let look = look_from(ctx, &b, "compositeOnOriginal"); - draw_text(&mut b, &polys, weight(size), &look); + let mut look = look_from(ctx, &b, "compositeOnOriginal"); + let base_op = look.opacity; + let mut first = true; + for (alpha, polys) in &polys_by_alpha { + look.opacity = base_op * alpha; + draw_text(&mut b, polys, weight(size), &look); + if first { + // Later passes go over the first one. + look.on_original = true; + first = false; + } + } + if polys_by_alpha.is_empty() { + draw_text(&mut b, &[], weight(size), &look); + } b } @@ -806,12 +874,24 @@ pub fn specs() -> Vec { p("fillAndStroke/strokeWidth", "Stroke Width", num(2.0), slider(0.0, 50.0, 0.0, 50.0, 1)), p("character/size", "Size", num(36.0), size()), p("character/tracking", "Tracking", num(0.0), slider(-100.0, 100.0, -20.0, 50.0, 1)), + // Kerning: "index:value" pairs, the gap after character `index` in 1/1000 em. + p("character/kerning", "Kerning", Value::Str(String::new()), ParamUi::Text), p("character/orientation/characterRotation", "Character Rotation", num(0.0), ang()), + p("character/orientation/characterShear", "Character Shear", num(0.0), slider(-70.0, 70.0, -70.0, 70.0, 1)), + p("character/horizontalShear", "Horizontal Shear", num(0.0), slider(-70.0, 70.0, -70.0, 70.0, 1)), + p("character/horizontalScale", "Horizontal Scale", num(100.0), slider(1.0, 1000.0, 10.0, 400.0, 1)), + p("character/verticalScale", "Vertical Scale", num(100.0), slider(1.0, 1000.0, 10.0, 400.0, 1)), p("paragraph/alignment", "Alignment", Value::Enum(0), popup(&["Left", "Right", "Center", "Force"])), p("paragraph/leftMargin", "Left Margin", num(0.0), slider(-10000.0, 10000.0, -500.0, 500.0, 1)), p("paragraph/rightMargin", "Right Margin", num(0.0), slider(-10000.0, 10000.0, -500.0, 500.0, 1)), + p("paragraph/lineSpacing", "Line Spacing", num(43.0), slider(-1000.0, 1000.0, 0.0, 200.0, 1)), p("paragraph/baselineShift", "Baseline Shift", num(0.0), slider(-1000.0, 1000.0, -100.0, 100.0, 1)), p("advanced/visibleCharacters", "Visible Characters", num(1024.0), slider(0.0, 1024.0, 0.0, 1024.0, 0)), + p("advanced/fadeTime", "Fade Time", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("advanced/jitterSettings/baselineJitterMax", "Baseline Jitter Max", num(0.0), slider(0.0, 1000.0, 0.0, 100.0, 1)), + p("advanced/jitterSettings/kerningJitterMax", "Kerning Jitter Max", num(0.0), slider(0.0, 1000.0, 0.0, 100.0, 1)), + p("advanced/jitterSettings/rotationJitterMax", "Rotation Jitter Max", num(0.0), slider(0.0, 360.0, 0.0, 360.0, 1)), + p("advanced/jitterSettings/scaleJitterMax", "Scale Jitter Max", num(0.0), slider(0.0, 1000.0, 0.0, 100.0, 1)), check("compositeOnOriginal", "Composite On Original", true), ], path_text, @@ -995,6 +1075,39 @@ mod tests { assert!(ink(&circle.img) > 10.0); } + #[test] + fn path_text_kerning_scale_lines_fade_and_jitter() { + let img = Image::new(160, 100); + let line = [ + ("pathOptions/shapeType", Value::Enum(3)), + ("pathOptions/controlPoints/vertex1", Value::Vec2([5.0, 40.0])), + ("pathOptions/controlPoints/vertex2", Value::Vec2([155.0, 40.0])), + ("character/size", num(20.0)), + ("text", Value::Str("AB".into())), + ]; + let with = |extra: &[(&str, Value)], t: f64| run("ec.obsolete.pathtext", &[line.as_slice(), extra].concat(), img.clone(), t).img; + let right_edge = |i: &Image| (0..160).rev().find(|&x| (0..100).any(|y| i.get(x, y)[3] > 0.3)).unwrap_or(0); + let base = with(&[], 0.0); + // Kerning after the first character pushes the second one along. + assert!(right_edge(&with(&[("character/kerning", Value::Str("0:500".into()))], 0.0)) >= right_edge(&base) + 8); + // Horizontal Scale widens, shear changes the shapes. + assert!(right_edge(&with(&[("character/horizontalScale", num(200.0))], 0.0)) > right_edge(&base) + 5); + assert_ne!(with(&[("character/horizontalShear", num(30.0))], 0.0), base); + // A second line sits Line Spacing below the first. + let two = with(&[("text", Value::Str("AB\rCD".into())), ("paragraph/lineSpacing", num(30.0))], 0.0); + let ink_rows = |i: &Image, y0: i64, y1: i64| (y0..y1).flat_map(|y| (0..160).map(move |x| (x, y))).map(|(x, y)| i.get(x, y)[3]).sum::(); + assert!(ink_rows(&two, 52, 72) > 5.0 && ink_rows(&base, 52, 72) < 0.5); + // Fade Time: the character being revealed is partly transparent. + let fading = with(&[("advanced/visibleCharacters", num(1.5)), ("advanced/fadeTime", num(50.0))], 0.0); + let total = |i: &Image| i.data.iter().map(|p| p[3]).sum::(); + let (one, both) = (with(&[("advanced/visibleCharacters", num(1.0))], 0.0), with(&[("advanced/visibleCharacters", num(2.0))], 0.0)); + assert!(total(&fading) > total(&one) + 1.0 && total(&fading) < total(&both) - 1.0, "{} {} {}", total(&one), total(&fading), total(&both)); + // Jitter differs between frames but is repeatable. + let j = [("advanced/jitterSettings/baselineJitterMax", num(10.0))]; + assert_ne!(with(&j, 0.0), with(&j, 0.5)); + assert_eq!(with(&j, 0.5), with(&j, 0.5)); + } + #[test] fn lightning_deterministic_and_animates() { let img = Image::new(96, 64); diff --git a/crates/effects/src/time_fx.rs b/crates/effects/src/time_fx.rs index a6058132..e0c1aad7 100644 --- a/crates/effects/src/time_fx.rs +++ b/crates/effects/src/time_fx.rs @@ -6,8 +6,8 @@ //! masks and ignore effects applied before them (precompose to include those). Without a host //! (tests, thumbnails) they pass their input through. //! -//! Pixel-motion based methods (Timewarp's Pixel Motion, Pixel Motion Blur) are approximated by -//! frame mixing / sub-frame sampling; there is no optical-flow estimation yet. +//! Timewarp's Pixel Motion estimates block-matching optical flow between neighbouring frames +//! ([`effectcraft_raster::flow`]); Pixel Motion Blur averages sub-frame samples. //! //! [`EffectHost::self_at`]: crate::EffectHost::self_at @@ -16,6 +16,8 @@ use effectcraft_project::ParamUi; use effectcraft_raster::{Image, Px}; use rayon::prelude::*; +use effectcraft_raster::flow::Flow; + use crate::util::{fit_layer, unpremul}; use crate::{Buf, EffectCtx, EffectSpec, num, p, popup, slider}; @@ -90,11 +92,29 @@ pub fn specs() -> Vec { p("speed", "Speed", num(50.0), slider(-10000.0, 10000.0, 0.0, 200.0, 1)), p("sourceFrame", "Source Frame", num(0.0), slider(-100000.0, 100000.0, 0.0, 300.0, 1)), p("tuning/vectorDetail", "Vector Detail", num(20.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("tuning/smoothing/globalSmoothness", "Global Smoothness", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 2)), + p("tuning/smoothing/localSmoothness", "Local Smoothness", num(0.5), slider(0.0, 1.0, 0.0, 1.0, 2)), + p("tuning/smoothing/smoothingIterations", "Smoothing Iterations", num(20.0), slider(0.0, 200.0, 0.0, 100.0, 0)), p("tuning/buildFromOneImage", "Build From One Image", Value::Bool(false), ParamUi::Checkbox), + p("tuning/correctLuminanceChanges", "Correct Luminance Changes", Value::Bool(false), ParamUi::Checkbox), + p("tuning/filtering", "Filtering", Value::Enum(0), popup(&["Normal", "Extreme"])), + p("tuning/errorThreshold", "Error Threshold", num(10.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("tuning/blockSize", "Block Size", num(8.0), slider(4.0, 64.0, 4.0, 32.0, 0)), + p("tuning/weighting/redWeighting", "Red Weighting", num(0.3), slider(0.0, 1.0, 0.0, 1.0, 2)), + p("tuning/weighting/greenWeighting", "Green Weighting", num(0.59), slider(0.0, 1.0, 0.0, 1.0, 2)), + p("tuning/weighting/blueWeighting", "Blue Weighting", num(0.11), slider(0.0, 1.0, 0.0, 1.0, 2)), p("motionBlur/enableMotionBlur", "Enable Motion Blur", Value::Bool(false), ParamUi::Checkbox), p("motionBlur/shutterControl", "Shutter Control", Value::Enum(0), popup(&["Automatic", "Manual"])), p("motionBlur/shutterAngle", "Shutter Angle", num(180.0), slider(0.0, 720.0, 0.0, 360.0, 1)), p("motionBlur/shutterSamples", "Shutter Samples", num(5.0), slider(1.0, 64.0, 1.0, 32.0, 0)), + p("matteLayer", "Matte Layer", Value::Layer(None), ParamUi::Layer), + p("matteChannel", "Matte Channel", Value::Enum(0), popup(&TW_MATTE_CHANNELS)), + p("warpLayer", "Warp Layer", Value::Layer(None), ParamUi::Layer), + p("show", "Show", Value::Enum(0), popup(&TW_SHOW)), + p("sourceCrops/leftCrop", "Left Crop", num(0.0), slider(0.0, 4000.0, 0.0, 100.0, 0)), + p("sourceCrops/rightCrop", "Right Crop", num(0.0), slider(0.0, 4000.0, 0.0, 100.0, 0)), + p("sourceCrops/bottomCrop", "Bottom Crop", num(0.0), slider(0.0, 4000.0, 0.0, 100.0, 0)), + p("sourceCrops/topCrop", "Top Crop", num(0.0), slider(0.0, 4000.0, 0.0, 100.0, 0)), ], timewarp, ), @@ -428,6 +448,238 @@ pub fn timewarp_source_time(t: f64, by_frame: bool, speed: f64, source_frame: f6 if by_frame { source_frame / fps } else { t * speed / 100.0 } } +/// Timewarp's Pixel Motion settings (the Tuning group and Source Crops). +#[derive(Clone, Copy, Debug)] +pub struct PixelMotion { + /// Vector spacing in pixels (from Vector Detail). + pub tile: u32, + /// Matching block size in pixels. + pub window: u32, + pub global_smoothness: f32, + pub local_smoothness: f32, + pub iterations: u32, + pub build_from_one: bool, + pub correct_luminance: bool, + pub extreme: bool, + /// Error Threshold (0..1): pixels whose two warped frames disagree by more fall back to a + /// frame mix. + pub error_threshold: f32, + pub weights: [f32; 3], + /// Left, right, top, bottom crops in pixels. + pub crops: [u32; 4], +} + +impl PixelMotion { + fn from(ctx: &EffectCtx, scale: f64) -> PixelMotion { + let pr = ctx.params; + let g = |id: &str, d: f64| pr.get(id).map(Value::as_f64).unwrap_or(d); + let detail = g("tuning/vectorDetail", 20.0).clamp(0.0, 100.0) / 100.0; + // Vector Detail 0 → one vector per 32 px, 100 → one per 2 px (exponential). + let tile = (32.0 * (1.0f64 / 16.0).powf(detail) * scale).round().max(2.0) as u32; + let px = |id: &str| (g(id, 0.0).max(0.0) * scale).round() as u32; + PixelMotion { + tile, + window: ((g("tuning/blockSize", 8.0).max(4.0)) * scale).round().max(4.0) as u32, + global_smoothness: g("tuning/smoothing/globalSmoothness", 0.5).clamp(0.0, 1.0) as f32, + local_smoothness: g("tuning/smoothing/localSmoothness", 0.5).clamp(0.0, 1.0) as f32, + iterations: g("tuning/smoothing/smoothingIterations", 20.0).clamp(0.0, 200.0) as u32, + build_from_one: pr.b("tuning/buildFromOneImage"), + correct_luminance: pr.b("tuning/correctLuminanceChanges"), + extreme: pr.e("tuning/filtering") == 1, + error_threshold: (g("tuning/errorThreshold", 10.0) / 100.0).clamp(0.0, 1.0) as f32, + weights: [g("tuning/weighting/redWeighting", 0.3), g("tuning/weighting/greenWeighting", 0.59), g("tuning/weighting/blueWeighting", 0.11)] + .map(|v| v.max(0.0) as f32), + crops: [px("sourceCrops/leftCrop"), px("sourceCrops/rightCrop"), px("sourceCrops/topCrop"), px("sourceCrops/bottomCrop")], + } + } +} + +/// Replace the cropped edges of `img` with the nearest kept pixel (Source Crops: edge garbage +/// must not drive or smear the motion). +fn crop_edges(img: &Image, [l, r, t, b]: [u32; 4]) -> Image { + if l == 0 && r == 0 && t == 0 && b == 0 { + return img.clone(); + } + let (w, h) = (img.width as i64, img.height as i64); + let (x0, x1) = ((l as i64).min(w - 1), (w - 1 - r as i64).max(0)); + let (y0, y1) = ((t as i64).min(h - 1), (h - 1 - b as i64).max(0)); + let mut out = img.clone(); + out.rows_mut().for_each(|(y, row)| { + for (x, px) in row.iter_mut().enumerate() { + *px = img.get((x as i64).clamp(x0, x1.max(x0)), (y as i64).clamp(y0, y1.max(y0))); + } + }); + out +} + +/// Weighted grey (R·wr + G·wg + B·wb in every colour channel) that the motion is estimated on. +fn motion_grey(img: &Image, wt: [f32; 3], gain: f32) -> Image { + let sum = (wt[0] + wt[1] + wt[2]).max(1e-6); + let mut out = img.clone(); + out.data.par_iter_mut().for_each(|p| { + let g = (p[0] * wt[0] + p[1] * wt[1] + p[2] * wt[2]) / sum * gain; + *p = [g, g, g, p[3]]; + }); + out +} + +fn mean_grey(img: &Image) -> f32 { + let n = img.data.len().max(1) as f32; + img.data.par_iter().map(|p| p[0]).sum::() / n +} + +/// Smooth a motion field: `iterations` passes pulling each vector towards its 3×3 mean by +/// Local Smoothness, then a blend towards a wide average by Global Smoothness. +fn smooth_flow(flow: &mut Flow, pm: &PixelMotion) { + let (c, r) = (flow.cols, flow.rows); + if c * r <= 1 { + return; + } + let mean = |v: &[[f32; 2]], rad: i64| -> Vec<[f32; 2]> { + (0..c * r) + .into_par_iter() + .map(|i| { + let (x, y) = ((i % c) as i64, (i / c) as i64); + let mut s = [0.0f32; 2]; + let mut n = 0.0; + for dy in -rad..=rad { + for dx in -rad..=rad { + let (nx, ny) = (x + dx, y + dy); + if nx >= 0 && ny >= 0 && (nx as usize) < c && (ny as usize) < r { + let q = v[ny as usize * c + nx as usize]; + s[0] += q[0]; + s[1] += q[1]; + n += 1.0; + } + } + } + [s[0] / n, s[1] / n] + }) + .collect() + }; + if pm.local_smoothness > 0.0 { + for _ in 0..pm.iterations { + let m = mean(&flow.v, 1); + for (v, q) in flow.v.iter_mut().zip(m) { + v[0] += (q[0] - v[0]) * pm.local_smoothness * 0.5; + v[1] += (q[1] - v[1]) * pm.local_smoothness * 0.5; + } + } + } + if pm.global_smoothness > 0.0 { + let m = mean(&flow.v, 3); + for (v, q) in flow.v.iter_mut().zip(m) { + v[0] += (q[0] - v[0]) * pm.global_smoothness * 0.5; + v[1] += (q[1] - v[1]) * pm.global_smoothness * 0.5; + } + } +} + +/// The motion field from frame `a` to frame `b` (or from the warp layer's frames). +pub fn timewarp_flow(a: &Image, b: &Image, pm: &PixelMotion) -> Flow { + let ga = motion_grey(&crop_edges(a, pm.crops), pm.weights, 1.0); + let gain = if pm.correct_luminance { + let (ma, mb) = (mean_grey(&ga), mean_grey(&motion_grey(b, pm.weights, 1.0))); + if mb > 1e-4 { ma / mb } else { 1.0 } + } else { + 1.0 + }; + let gb = motion_grey(&crop_edges(b, pm.crops), pm.weights, gain); + let mut flow = effectcraft_raster::flow::block_flow_window(&ga, &gb, pm.tile, pm.window, 4); + smooth_flow(&mut flow, pm); + flow +} + +/// Pixel Motion: the frame at fraction `w` between `a` and `b` built by warping along `flow` +/// (Build From One Image: from the nearer frame only), falling back to a mix where the two +/// warped frames disagree by more than the Error Threshold. +pub fn timewarp_interpolate(a: &Image, b: &Image, w: f32, flow: &Flow, pm: &PixelMotion) -> Image { + let (a, b) = (crop_edges(a, pm.crops), crop_edges(b, pm.crops)); + let mut out = Image::new(a.width, a.height); + let wd = w as f64; + let sample = |img: &Image, x: f64, y: f64| if pm.extreme { img.sample_bicubic(x, y) } else { img.sample_bilinear_clamped(x, y) }; + out.rows_mut().for_each(|(y, row)| { + let cy = y as f64 + 0.5; + for (x, px) in row.iter_mut().enumerate() { + let cx = x as f64 + 0.5; + let f = flow.at(cx, cy); + let (fx, fy) = (f[0] as f64, f[1] as f64); + let pa = sample(&a, cx - wd * fx, cy - wd * fy); + let pb = sample(&b, cx + (1.0 - wd) * fx, cy + (1.0 - wd) * fy); + if pm.build_from_one { + *px = if w < 0.5 { pa } else { pb }; + continue; + } + let err = (0..4).map(|c| (pa[c] - pb[c]).abs()).fold(0.0f32, f32::max); + let (pa, pb) = if pm.error_threshold > 0.0 && err > pm.error_threshold && pm.error_threshold < 1.0 { + (a.get(x as i64, y as i64), b.get(x as i64, y as i64)) + } else { + (pa, pb) + }; + for c in 0..4 { + px[c] = pa[c] + (pb[c] - pa[c]) * w; + } + } + }); + out +} + +/// Timewarp's Matte Channel options. +const TW_MATTE_CHANNELS: [&str; 4] = ["Alpha", "Inverted Alpha", "Luminance", "Inverted Luminance"]; +/// Timewarp's Show options. +const TW_SHOW: [&str; 4] = ["Normal", "Foreground Only", "Background Only", "Matte"]; + +/// A matte plane (foreground = 1) from a matte layer frame. +fn matte_of(img: &Image, channel: u32) -> Vec { + img.data + .par_iter() + .map(|p| { + let (c, a) = unpremul(*p); + let l = (0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]) * a; + match channel { + 1 => 1.0 - a, + 2 => l, + 3 => 1.0 - l, + _ => a, + } + .clamp(0.0, 1.0) + }) + .collect() +} + +fn times_mask(img: &Image, m: &[f32], invert: bool) -> Image { + let mut out = img.clone(); + out.data.par_iter_mut().zip(m.par_iter()).for_each(|(p, &k)| { + let k = if invert { 1.0 - k } else { k }; + p.iter_mut().for_each(|v| *v *= k); + }); + out +} + +/// Another layer (layer parameter `id`) at the comp times matching source layer times `times`, +/// fitted onto `grid`. `None` when no layer is chosen. +fn fetch_layer(ctx: &EffectCtx, id: &str, times: &[f64], grid: &Buf) -> Option> { + let lid = ctx.params.get(id)?.as_layer()?; + let host = ctx.env.host?; + let me = ctx.params.get(&crate::layer_source_id(id)).is_none_or(|v| v.as_enum() != 0); + let shift = ctx.env.comp_time - ctx.time; + Some( + times + .iter() + .map(|t| match host.layer_at(lid, t + shift, me) { + Some(lp) => fit_layer(ctx, grid, &lp, false), + None => Image::new(grid.img.width, grid.img.height), + }) + .collect(), + ) +} + +/// Timewarp. Whole Frames shows the nearest source frame, Frame Mix cross-fades the two +/// neighbouring frames and Pixel Motion builds the in-between frame along estimated motion +/// vectors (block-matching optical flow, [`effectcraft_raster::flow`]) tuned by the Tuning +/// group. A Matte Layer splits foreground and background so each moves on its own vectors; a +/// Warp Layer supplies the motion instead of the layer itself. Motion Blur averages Shutter +/// Samples instants across the shutter. fn timewarp(ctx: &EffectCtx, b: Buf) -> Buf { if ctx.env.host.is_none() { return b; @@ -437,36 +689,126 @@ fn timewarp(ctx: &EffectCtx, b: Buf) -> Buf { let speed = ctx.params.f("speed"); let src = timewarp_source_time(ctx.time, by_frame, speed, ctx.params.f("sourceFrame"), fps); let method = ctx.params.e("method"); - // Build From One Image: interpolated frames come from the nearest source frame alone. let one = method > 0 && ctx.params.b("tuning/buildFromOneImage"); - // Sample times (with weights) for one instant of source time. - let instant = |s: f64| -> Vec<(f64, f32)> { - let f = s * fps; - let f0 = (f + 1e-6).floor(); - let frac = (f - f0) as f32; - if one { - return vec![((f + 1e-6).round() / fps, 1.0)]; - } - if method == 0 || frac < 1e-4 { vec![(f0 / fps, 1.0)] } else { vec![(f0 / fps, 1.0 - frac), ((f0 + 1.0) / fps, frac)] } - }; - let mut samples: Vec<(f64, f32)> = Vec::new(); + // Instants of source time (with weights) across the shutter. + let mut instants: Vec<(f64, f32)> = vec![]; if ctx.params.b("motionBlur/enableMotionBlur") { let angle = if ctx.params.e("motionBlur/shutterControl") == 1 { ctx.params.f("motionBlur/shutterAngle") } else { 180.0 }; let n = ctx.params.f("motionBlur/shutterSamples").round().clamp(1.0, 64.0) as usize; let rate = if by_frame { 1.0 } else { speed / 100.0 }; let span = angle / 360.0 * rate / fps; for i in 0..n { - let s = src + if n > 1 { span * i as f64 / (n - 1) as f64 } else { 0.0 }; - samples.extend(instant(s).into_iter().map(|(t, w)| (t, w / n as f32))); + instants.push((src + if n > 1 { span * i as f64 / (n - 1) as f64 } else { 0.0 }, 1.0 / n as f32)); } } else { - samples = instant(src); + instants.push((src, 1.0)); + } + // Each instant needs the frame at or before it and the next one. + let split = |s: f64| { + let f = s * fps; + let f0 = (f + 1e-6).floor(); + (f0, ((f - f0) as f32).max(0.0)) + }; + let mut times: Vec = vec![]; + for &(s, _) in &instants { + let (f0, _) = split(s); + times.push(f0 / fps); + times.push((f0 + 1.0) / fps); } - let times: Vec = samples.iter().map(|s| s.0).collect(); - let weights: Vec = samples.iter().map(|s| s.1).collect(); let Some(fr) = fetch(ctx, ×) else { return b }; - let img = average(&fr.imgs, &weights); - with_img(fr.grid, img) + let grid = fr.grid.clone(); + let frame = |t: f64| -> &Image { + let k = times.iter().position(|q| (q - t).abs() < 1e-9).unwrap_or(0); + &fr.imgs[k] + }; + let pm = PixelMotion::from(ctx, grid.scale); + let mattes = fetch_layer(ctx, "matteLayer", ×, &grid); + let warps = fetch_layer(ctx, "warpLayer", ×, &grid); + let matte_ch = ctx.params.e("matteChannel"); + let show = ctx.params.e("show"); + let mut flows: Vec<((i64, bool), Flow)> = vec![]; + let mut out_frames = vec![]; + for &(s, wgt) in &instants { + let (f0, frac) = split(s); + let (t0, t1) = (f0 / fps, (f0 + 1.0) / fps); + let (a, bb) = (frame(t0), frame(t1)); + let idx = |t: f64| times.iter().position(|q| (q - t).abs() < 1e-9).unwrap_or(0); + let ma = mattes.as_ref().map(|m| matte_of(&m[idx(t0)], matte_ch)); + let mb = mattes.as_ref().map(|m| matte_of(&m[idx(t1)], matte_ch)); + let img = if method == 0 || frac < 1e-4 || (one && method == 1) { + // Whole frames (Build From One Image with Frame Mix: the nearest frame). + let pick = if method == 0 || frac < 1e-4 { 0.0 } else { frac.round() }; + let base = if pick < 0.5 { a } else { bb }; + match (show, &ma, &mb) { + (1, Some(m), _) if pick < 0.5 => times_mask(base, m, false), + (2, Some(m), _) if pick < 0.5 => times_mask(base, m, true), + (1, _, Some(m)) => times_mask(base, m, false), + (2, _, Some(m)) => times_mask(base, m, true), + (3, Some(m), _) => matte_image(if pick < 0.5 { m } else { mb.as_ref().unwrap_or(m) }, base), + _ => base.clone(), + } + } else if method == 1 { + match (&ma, &mb, show) { + (Some(m0), Some(m1), 3) => effectcraft_raster::flow::mix(&matte_image(m0, a), &matte_image(m1, bb), frac), + (Some(m0), Some(m1), 1 | 2) => effectcraft_raster::flow::mix(×_mask(a, m0, show == 2), ×_mask(bb, m1, show == 2), frac), + _ => effectcraft_raster::flow::mix(a, bb, frac), + } + } else { + // Pixel Motion: vectors from the warp layer if any, else from the layer. + let key = ((f0 as i64), false); + let flow_ab = |fa: &Image, fb: &Image| -> Flow { timewarp_flow(fa, fb, &pm) }; + let flow = match flows.iter().find(|(k, _)| *k == key) { + Some((_, f)) => f.clone(), + None => { + let f = match &warps { + Some(w) => flow_ab(&w[idx(t0)], &w[idx(t1)]), + None => flow_ab(a, bb), + }; + flows.push((key, f.clone())); + f + } + }; + match (&ma, &mb) { + (Some(m0), Some(m1)) => { + // Foreground and background each move on their own vectors. + let (fa, fb) = (times_mask(a, m0, false), times_mask(bb, m1, false)); + let (ga, gb) = (times_mask(a, m0, true), times_mask(bb, m1, true)); + let fg_flow = if warps.is_some() { flow.clone() } else { flow_ab(&fa, &fb) }; + let bg_flow = if warps.is_some() { flow.clone() } else { flow_ab(&ga, &gb) }; + let fg = timewarp_interpolate(&fa, &fb, frac, &fg_flow, &pm); + let bg = timewarp_interpolate(&ga, &gb, frac, &bg_flow, &pm); + match show { + 1 => fg, + 2 => bg, + 3 => matte_image(&fg.data.iter().map(|p| p[3]).collect::>(), &fg), + _ => { + let mut o = bg; + o.data.par_iter_mut().zip(fg.data.par_iter()).for_each(|(p, q)| { + let k = 1.0 - q[3]; + for c in 0..4 { + p[c] = q[c] + p[c] * k; + } + }); + o + } + } + } + _ => timewarp_interpolate(a, bb, frac, &flow, &pm), + } + }; + out_frames.push((img, wgt)); + } + let imgs: Vec = out_frames.iter().map(|(i, _)| i.clone()).collect(); + let weights: Vec = out_frames.iter().map(|(_, w)| *w).collect(); + let img = if imgs.len() == 1 { imgs.into_iter().next().unwrap_or_default() } else { average(&imgs, &weights) }; + with_img(grid, img) +} + +/// A matte shown as opaque grey. +fn matte_image(m: &[f32], like: &Image) -> Image { + let mut out = Image::new(like.width, like.height); + out.data.par_iter_mut().zip(m.par_iter()).for_each(|(p, &k)| *p = [k, k, k, 1.0]); + out } // ---------------------------------------------------------------- CC Force Motion Blur / CC Wide Time / Pixel Motion Blur @@ -563,6 +905,97 @@ mod tests { assert!((out.img.data[0][0] - 0.5).abs() < 1e-5, "{}", out.img.data[0][0]); } + /// Texture `x − dx` (smooth, non-repeating). + fn texture(w: u32, h: u32, dx: f64) -> Image { + let mut img = Image::new(w, h); + for y in 0..h { + for x in 0..w { + let (u, v) = (x as f64 - dx, y as f64); + let s = (0.5 + 0.2 * (u * 0.21 + v * 0.07).sin() + 0.15 * (u * 0.05 - v * 0.19).cos() + 0.1 * (u * 0.031 + v * 0.017).sin()) as f32; + img.set(x, y, [s, s * 0.8, 1.0 - s, 1.0]); + } + } + img + } + + /// A textured layer moving right 3 px per frame (10 fps); layer 7 (a "warp" / matte layer) + /// is the same texture standing still, layer 8 a matte that is white on the left half. + struct Moving; + impl EffectHost for Moving { + fn layer(&self, _: u64, _: bool) -> Option { + None + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + fn self_at(&self, t: f64, _: usize) -> Option { + Some(Buf { img: texture(96, 64, (t * 10.0).round() * 3.0), offset: [0.0; 2], scale: 1.0 }) + } + fn layer_at(&self, id: u64, _: f64, _: bool) -> Option { + let img = match id { + 7 => texture(96, 64, 0.0), + _ => { + let mut m = Image::new(96, 64); + for y in 0..64 { + for x in 0..48 { + m.set(x, y, [1.0, 1.0, 1.0, 1.0]); + } + } + m + } + }; + Some(LayerPixels { buf: Buf { img, offset: [0.0; 2], scale: 1.0 }, size: [96.0, 64.0] }) + } + } + + fn mae(a: &Image, b: &Image, margin: i64) -> f32 { + let (w, h) = (a.width as i64, a.height as i64); + let mut s = 0.0; + let mut n = 0.0; + for y in margin..h - margin { + for x in margin..w - margin { + let (p, q) = (a.get(x, y), b.get(x, y)); + s += (0..3).map(|c| (p[c] - q[c]).abs()).sum::(); + n += 3.0; + } + } + s / n + } + + fn run_moving(vals: &[(&str, Value)], t: f64) -> Image { + let env = EffectEnv { host: Some(&Moving), frame_rate: 10.0, comp_time: t, ..Default::default() }; + run_fx("ec.time.timewarp", vals, Image::new(96, 64), t, env).img + } + + #[test] + fn timewarp_pixel_motion_follows_the_motion() { + // Speed 50 % at layer time 1.1 s: source 0.55 s, halfway between frames 5 and 6 (15 and + // 18 px), so the true in-between frame is the texture at 16.5 px. + let want = texture(96, 64, 16.5); + let pm = run_moving(&[("method", Value::Enum(2)), ("tuning/vectorDetail", num(60.0))], 1.1); + let mix = run_moving(&[("method", Value::Enum(1))], 1.1); + let (e_pm, e_mix) = (mae(&pm, &want, 8), mae(&mix, &want, 8)); + assert!(e_pm < e_mix * 0.5, "pixel motion {e_pm} vs frame mix {e_mix}"); + // Deterministic and seek-consistent (same time → same pixels). + assert_eq!(pm.data, run_moving(&[("method", Value::Enum(2)), ("tuning/vectorDetail", num(60.0))], 1.1).data); + // Build From One Image warps the nearer frame alone: still close to the truth. + let one = run_moving(&[("method", Value::Enum(2)), ("tuning/buildFromOneImage", Value::Bool(true)), ("tuning/vectorDetail", num(60.0))], 1.1); + assert!(mae(&one, &want, 8) < e_mix * 0.5); + // A Warp Layer standing still supplies zero motion: the result is a plain mix. + let warped = run_moving(&[("method", Value::Enum(2)), ("warpLayer", Value::Layer(Some(7))), ("tuning/errorThreshold", num(100.0))], 1.1); + assert!(mae(&warped, &mix, 0) < 1e-3, "{}", mae(&warped, &mix, 0)); + // Show Matte displays the matte layer; Foreground Only keeps the matte's left half. + let m = run_moving(&[("method", Value::Enum(2)), ("matteLayer", Value::Layer(Some(8))), ("show", Value::Enum(3))], 1.1); + assert!(m.get(10, 10)[0] > 0.99 && m.get(80, 10)[0] < 0.01); + let fg = run_moving(&[("method", Value::Enum(2)), ("matteLayer", Value::Layer(Some(8))), ("show", Value::Enum(1))], 1.1); + assert!(fg.get(10, 30)[3] > 0.99 && fg.get(85, 30)[3] < 0.01); + let normal = run_moving(&[("method", Value::Enum(2)), ("matteLayer", Value::Layer(Some(8))), ("tuning/vectorDetail", num(60.0))], 1.1); + assert!(mae(&normal, &want, 12) < e_mix, "{} {e_mix}", mae(&normal, &want, 12)); + // Source Crops: a crop replicates the kept edge into the cropped band. + let c = run_moving(&[("method", Value::Enum(2)), ("sourceCrops/leftCrop", num(10.0))], 1.1); + assert_eq!(c.get(0, 20), c.get(5, 20)); + } + #[test] fn timewarp_and_wide_time() { let h = Clock { static_layer: false }; diff --git a/crates/effects/src/transition.rs b/crates/effects/src/transition.rs index 9701f0c4..65876aa8 100644 --- a/crates/effects/src/transition.rs +++ b/crates/effects/src/transition.rs @@ -1,5 +1,5 @@ //! Transition effects (batch 2): Block Dissolve, Gradient Wipe (gradient layer or self), Iris Wipe, Card -//! Wipe (2D cards), CC Grid Wipe, CC Radial ScaleWipe, CC Scale Wipe and CC Light Wipe. +//! Wipe (3D cards with camera, lighting and jitter), CC Grid Wipe, CC Radial ScaleWipe, CC Scale Wipe and CC Light Wipe. //! //! Every transition is the identity at 0 % completion. Angles are clockwise from "up". @@ -9,6 +9,7 @@ use effectcraft_project::ParamUi; use effectcraft_raster::Px; use rayon::prelude::*; +use crate::sim2::{Piece, draw_pieces, rot_axis, sort_far_first}; use crate::util::{Plane, gauss_plane, hash1, layer_rect, remap, unpremul}; use crate::{Buf, EffectCtx, EffectSpec, ParamSpec, col, num, p, popup, slider}; @@ -61,14 +62,21 @@ fn block_dissolve(ctx: &EffectCtx, mut b: Buf) -> Buf { let feather = ctx.params.f("feather") * b.scale; let (ox, oy) = (b.offset[0], b.offset[1]); let seed = ctx.seed ^ 0xb10c; + // Soft Edges (Best Quality): block edges falling between pixels are anti-aliased (4×4 + // sub-pixel samples); off, each pixel takes its centre's block. + let soft = ctx.params.b("softEdges"); + let subs: Vec = if soft { (0..4).map(|k| (k as f64 + 0.5) / 4.0).collect() } else { vec![0.5] }; + let kept = |x: f64, y: f64| { + let (bx, by) = (((x - ox) / bw).floor() as i64, ((y - oy) / bh).floor() as i64); + hash1(bx as u32, by as u32, seed) >= done + }; let mut mask = Plane::new(b.img.width as usize, b.img.height as usize); let w = mask.w; + let n = (subs.len() * subs.len()) as f32; mask.data.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| { - let by = ((y as f64 + 0.5 - oy) / bh).floor() as i64; for (x, m) in row.iter_mut().enumerate() { - let bx = ((x as f64 + 0.5 - ox) / bw).floor() as i64; - let r = hash1(bx as u32, by as u32, seed); - *m = if r >= done { 1.0 } else { 0.0 }; + let hits = subs.iter().flat_map(|sy| subs.iter().map(move |sx| (*sx, *sy))).filter(|(sx, sy)| kept(x as f64 + sx, y as f64 + sy)).count(); + *m = hits as f32 / n; } }); if feather > 0.0 { @@ -163,21 +171,47 @@ fn iris_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf { b } +/// Card Wipe: the layer is cut into Rows × Columns cards that flip over in Flip Order, each +/// through a 180° turn about Flip Axis, as textured planes in 3D seen through the Camera +/// System and lit by Lighting / Material. The flipped side shows Back Layer (none: the cards +/// vanish as they turn away). Position / Rotation Jitter wobble the cards over time. fn card_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf { let done = completion(ctx); - if done <= 0.0 { - return b; + let pr = ctx.params; + let f = |id: &str| pr.get(id).map(Value::as_f64).unwrap_or(0.0); + let jitter_on = [ + "positionJitter/xJitterAmount", + "positionJitter/yJitterAmount", + "positionJitter/zJitterAmount", + "rotationJitter/xRotJitterAmount", + "rotationJitter/yRotJitterAmount", + "rotationJitter/zRotJitterAmount", + ] + .iter() + .any(|id| f(id) != 0.0); + if done <= 0.0 + && !jitter_on + && pr.e("cameraSystem") == 0 + && f("cameraPosition/xRotation") == 0.0 + && f("cameraPosition/yRotation") == 0.0 + && f("cameraPosition/zRotation") == 0.0 + { + // Nothing has moved: the layer as is (exactly). + let xy = pr.get("cameraPosition/xyPosition").map(|v| v.as_vec2()); + if xy.is_none_or(|p| (p[0] - ctx.layer_size[0] * 0.5).abs() < 1e-9 && (p[1] - ctx.layer_size[1] * 0.5).abs() < 1e-9) { + return b; + } } - let tw = (ctx.params.f("transitionWidth") / 100.0).clamp(0.01, 1.0); - let rows = ctx.params.f("rows").round().clamp(1.0, 1000.0) as i64; + let tw = (pr.f("transitionWidth") / 100.0).clamp(0.01, 1.0); + let rows = pr.f("rows").round().clamp(1.0, 1000.0) as i64; // Rows & Columns: Independent, or Columns Follows Rows. - let cols = if ctx.params.e("rowsAndColumns") == 1 { rows } else { ctx.params.f("columns").round().clamp(1.0, 1000.0) as i64 }; - let card_scale = ctx.params.f("cardScale").max(0.01); - let axis = ctx.params.e("flipAxis"); - let order = ctx.params.e("flipOrder"); - let back_self = ctx.params.e("backLayer") == 1; - let jitter = ctx.params.f("timingRandomness").clamp(0.0, 1.0); - let seed = ctx.params.f("randomSeed") as u32 ^ 0xca7d; + let cols = if pr.e("rowsAndColumns") == 1 { rows } else { pr.f("columns").round().clamp(1.0, 1000.0) as i64 }; + let card_scale = pr.f("cardScale").max(0.01); + let axis = pr.e("flipAxis"); + let direction = pr.e("flipDirection"); + let order = pr.e("flipOrder"); + let jitter = pr.f("timingRandomness").clamp(0.0, 1.0); + let seed = pr.f("randomSeed") as u32 ^ 0xca7d; let (x0, y0, w, h) = layer_rect(ctx, &b); let (cw, ch) = (w / cols as f64, h / rows as f64); let nx = |i: i64| if cols > 1 { i as f64 / (cols - 1) as f64 } else { 0.0 }; @@ -187,64 +221,91 @@ fn card_wipe(ctx: &EffectCtx, mut b: Buf) -> Buf { Some(o) => place_layer(ctx, &b, &o, 2), None => b.img.clone(), }); - let src = b.img.clone(); - b.img = remap(&src, false, |x, y| { - let (lx, ly) = (x - x0, y - y0); - if lx < 0.0 || ly < 0.0 || lx >= w || ly >= h { - return Some((x, y)); - } - let i = ((lx / cw).floor() as i64).min(cols - 1); - let j = ((ly / ch).floor() as i64).min(rows - 1); - let (ccx, ccy) = (x0 + (i as f64 + 0.5) * cw, y0 + (j as f64 + 0.5) * ch); - let mut o = match order { - 1 => 1.0 - nx(i), - 2 => ny(j), - 3 => 1.0 - ny(j), - 4 => (nx(i) + ny(j)) * 0.5, - 5 => (1.0 - nx(i) + ny(j)) * 0.5, - 6 => (nx(i) + 1.0 - ny(j)) * 0.5, - 7 => 1.0 - (nx(i) + ny(j)) * 0.5, - 8 => { - let g = grad.as_ref().map(|g| g.get_clamped(ccx as i64, ccy as i64)).unwrap_or([0.0; 4]); - let (c, _) = unpremul(g); - luminance(c[0], c[1], c[2]).clamp(0.0, 1.0) as f64 + // Back Layer: a chosen layer (stretched to fit), or this layer for projects saved with the + // old "Self" choice; none hides the cards' backs. + let back = match ctx.layer_param("backLayer", true) { + Some(o) => Some(crate::util::fit_layer(ctx, &b, &o, true)), + None if pr.b("backSelf") => Some(b.img.clone()), + None => None, + }; + let cam = crate::card3d::projection(ctx, &b); + let eye = cam.eye(); + let light = crate::card3d::Lighting::from(ctx, &b); + let t = ctx.time; + let jit = |i: i64, j: i64, k: u32, speed: f64| -> f64 { + let ph = hash1(i as u32, j as u32, seed ^ k) as f64 * std::f64::consts::TAU; + let rate = 0.5 + hash1(i as u32, j as u32, seed ^ (k + 101)) as f64; + (t * speed * rate * std::f64::consts::TAU + ph).sin() + }; + let (jx, jy, jz, js) = + (f("positionJitter/xJitterAmount"), f("positionJitter/yJitterAmount"), f("positionJitter/zJitterAmount"), f("positionJitter/jitterSpeed")); + let (rjx, rjy, rjz, rjs) = + (f("rotationJitter/xRotJitterAmount"), f("rotationJitter/yRotJitterAmount"), f("rotationJitter/zRotJitterAmount"), f("rotationJitter/rotJitterSpeed")); + let mut pieces: Vec = (0..rows * cols) + .into_par_iter() + .filter_map(|k| { + let (i, j) = (k % cols, k / cols); + let (ccx, ccy) = (x0 + (i as f64 + 0.5) * cw, y0 + (j as f64 + 0.5) * ch); + let mut o = match order { + 1 => 1.0 - nx(i), + 2 => ny(j), + 3 => 1.0 - ny(j), + 4 => (nx(i) + ny(j)) * 0.5, + 5 => (1.0 - nx(i) + ny(j)) * 0.5, + 6 => (nx(i) + 1.0 - ny(j)) * 0.5, + 7 => 1.0 - (nx(i) + ny(j)) * 0.5, + 8 => { + let g = grad.as_ref().map(|g| g.get_clamped(ccx as i64, ccy as i64)).unwrap_or([0.0; 4]); + let (c, _) = unpremul(g); + luminance(c[0], c[1], c[2]).clamp(0.0, 1.0) as f64 + } + _ => nx(i), + }; + if jitter > 0.0 { + o += (hash1(i as u32, j as u32, seed ^ 0x7177) as f64 - o) * jitter; } - _ => nx(i), - }; - if jitter > 0.0 { - o += (hash1(i as u32, j as u32, seed ^ 0x7177) as f64 - o) * jitter; - } - let p = ((done - o * (1.0 - tw)) / tw).clamp(0.0, 1.0); - // Card Scale: each card is scaled about its centre (gaps below 1, cropped above 1). - let (mut dx, mut dy) = ((x - ccx) / card_scale, (y - ccy) / card_scale); - if dx.abs() > cw * 0.5 + 1e-9 || dy.abs() > ch * 0.5 + 1e-9 { - return None; - } - if p <= 0.0 { - return Some((ccx + dx, ccy + dy)); - } - let cos = (p * std::f64::consts::PI).cos(); - if cos.abs() < 1e-4 || (cos < 0.0 && !back_self) { - return None; - } - let around_x = match axis { - 1 => false, - 2 => hash1(i as u32, j as u32, seed ^ 0x55) < 0.5, - _ => true, - }; - if around_x { - dy /= cos; - if dy.abs() > ch * 0.5 { + let p = ((done - o * (1.0 - tw)) / tw).clamp(0.0, 1.0); + let around_x = match axis { + 1 => false, + 2 => hash1(i as u32, j as u32, seed ^ 0x55) < 0.5, + _ => true, + }; + let sign = match direction { + 1 => -1.0, + 2 if hash1(i as u32, j as u32, seed ^ 0x99) < 0.5 => -1.0, + _ => 1.0, + }; + let a = p * std::f64::consts::PI * sign; + let flip = if around_x { rot_axis([1.0, 0.0, 0.0], a) } else { rot_axis([0.0, 1.0, 0.0], a) }; + let jr = crate::card3d::rot_ordered( + (rjx * jit(i, j, 11, rjs)).to_radians(), + (rjy * jit(i, j, 12, rjs)).to_radians(), + (rjz * jit(i, j, 13, rjs)).to_radians(), + 0, + ); + let r = crate::card3d::mat_mul(&jr, &flip); + let pos = [ccx + jx * jit(i, j, 21, js) * cw, ccy + jy * jit(i, j, 22, js) * ch, jz * jit(i, j, 23, js) * cw.max(ch)]; + // Card Scale: each card is scaled about its centre (gaps below 1, cropped above 1). + let (sw, sh) = ((cw * 0.5 * card_scale.min(1.0)) as f32, (ch * 0.5 * card_scale.min(1.0)) as f32); + let (cx, cy) = (ccx as f32, ccy as f32); + let poly = [[cx - sw, cy - sh], [cx + sw, cy - sh], [cx + sw, cy + sh], [cx - sw, cy + sh]]; + // Above 1 the card's picture is magnified (cropped to the card). + let k = card_scale.max(1.0); + let rs: crate::card3d::M3 = std::array::from_fn(|q| [r[q][0] * k, r[q][1] * k, r[q][2]]); + let poly = poly.map(|v| [cx + (v[0] - cx) / k as f32, cy + (v[1] - cy) / k as f32]); + let mut pc = Piece::new(&poly, [cx, cy], &rs, pos, cam)?; + if pc.back && back.is_none() { return None; } - } else { - dx /= cos; - if dx.abs() > cw * 0.5 { - return None; - } - } - Some((ccx + dx, ccy + dy)) - }); + pc.back_mirror = if around_x { 1 } else { 2 }; + pc.light(&light, &r, pos, eye); + Some(pc) + }) + .collect(); + sort_far_first(&mut pieces); + let mut out = effectcraft_raster::Image::new(b.img.width, b.img.height); + draw_pieces(&mut out, &pieces, &b.img, back.as_ref()); + b.img = out; b } @@ -412,6 +473,7 @@ pub fn specs() -> Vec { p("blockWidth", "Block Width", num(1.0), slider(1.0, 4000.0, 1.0, 100.0, 1)), p("blockHeight", "Block Height", num(1.0), slider(1.0, 4000.0, 1.0, 100.0, 1)), p("feather", "Feather", num(0.0), slider(0.0, 400.0, 0.0, 50.0, 1)), + p("softEdges", "Soft Edges (Best Quality)", Value::Bool(true), ParamUi::Checkbox), ], block_dissolve, ), @@ -447,12 +509,15 @@ pub fn specs() -> Vec { vec![ done(), p("transitionWidth", "Transition Width", num(50.0), slider(1.0, 100.0, 1.0, 100.0, 0)), - p("backLayer", "Back Layer", Value::Enum(0), popup(&["None", "Self"])), + p("backLayer", "Back Layer", Value::Layer(None), ParamUi::Layer), + // Projects saved when Back Layer was a None / Self popup: Self. + p("backSelf", "Back Layer Is Self", Value::Bool(false), ParamUi::Hidden), p("rowsAndColumns", "Rows & Columns", Value::Enum(0), popup(&["Independent", "Columns Follows Rows"])), p("rows", "Rows", num(8.0), slider(1.0, 1000.0, 1.0, 100.0, 0)), p("columns", "Columns", num(8.0), slider(1.0, 1000.0, 1.0, 100.0, 0)), p("cardScale", "Card Scale", num(1.0), slider(0.01, 10.0, 0.01, 2.0, 2)), p("flipAxis", "Flip Axis", Value::Enum(0), popup(&["X", "Y", "Random"])), + p("flipDirection", "Flip Direction", Value::Enum(0), popup(&["Positive", "Negative", "Random"])), p( "flipOrder", "Flip Order", @@ -472,7 +537,22 @@ pub fn specs() -> Vec { p("gradientLayer", "Gradient Layer", Value::Layer(None), ParamUi::Layer), p("timingRandomness", "Timing Randomness", num(0.0), slider(0.0, 1.0, 0.0, 1.0, 2)), p("randomSeed", "Random Seed", num(1.0), slider(0.0, 10000.0, 0.0, 100.0, 0)), - ], + ] + .into_iter() + .chain(crate::card3d::camera_params(2.0)) + .chain(crate::card3d::lighting_params(1.0, 0.25)) + .chain(crate::card3d::material_params(0.75)) + .chain([ + p("positionJitter/xJitterAmount", "X Jitter Amount", num(0.0), slider(0.0, 100.0, 0.0, 5.0, 2)), + p("positionJitter/jitterSpeed", "Jitter Speed", num(1.0), slider(0.0, 100.0, 0.0, 10.0, 2)), + p("positionJitter/yJitterAmount", "Y Jitter Amount", num(0.0), slider(0.0, 100.0, 0.0, 5.0, 2)), + p("positionJitter/zJitterAmount", "Z Jitter Amount", num(0.0), slider(0.0, 100.0, 0.0, 5.0, 2)), + p("rotationJitter/xRotJitterAmount", "X Rot Jitter Amount", num(0.0), slider(0.0, 360.0, 0.0, 90.0, 1)), + p("rotationJitter/rotJitterSpeed", "Rot Jitter Speed", num(1.0), slider(0.0, 100.0, 0.0, 10.0, 2)), + p("rotationJitter/yRotJitterAmount", "Y Rot Jitter Amount", num(0.0), slider(0.0, 360.0, 0.0, 90.0, 1)), + p("rotationJitter/zRotJitterAmount", "Z Rot Jitter Amount", num(0.0), slider(0.0, 360.0, 0.0, 90.0, 1)), + ]) + .collect(), card_wipe, ), spec( @@ -635,7 +715,7 @@ mod tests { let back = run( "ec.transition.cardwipe", &img, - &[("completion", num(50.0)), ("rows", num(1.0)), ("columns", num(8.0)), ("transitionWidth", num(10.0)), ("backLayer", Value::Enum(1))], + &[("completion", num(50.0)), ("rows", num(1.0)), ("columns", num(8.0)), ("transitionWidth", num(10.0)), ("backSelf", Value::Bool(true))], ); assert_eq!(back.get(2, 20)[3], 1.0); } @@ -746,4 +826,63 @@ mod tests { assert_eq!(out.get(50, 50)[3], 0.0, "centre tiles are gone first"); assert_eq!(out.get(1, 1)[3], 1.0, "corners last"); } + + fn run_t(img: &Image, set: &[(&str, Value)], time: f64, host: Option<&dyn crate::EffectHost>) -> Image { + let s = find("ec.transition.cardwipe").unwrap(); + let size = [img.width as f64, img.height as f64]; + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), crate::default_value(p, size))).collect() }; + for (k, v) in set { + params.values.insert(k.to_string(), v.clone()); + } + let env = crate::EffectEnv { host, ..Default::default() }; + let ctx = EffectCtx { params: ¶ms, time, layer_size: size, seed: 7, adjustment: false, env }; + (s.render)(&ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 }).img + } + + #[test] + fn card_wipe_back_layer_direction_jitter_and_camera() { + struct Green; + impl crate::EffectHost for Green { + fn layer(&self, _: u64, _: bool) -> Option { + Some(crate::LayerPixels { buf: Buf { img: Image::filled(80, 40, [0.0, 1.0, 0.0, 1.0]), offset: [0.0; 2], scale: 1.0 }, size: [80.0, 40.0] }) + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + } + let img = Image::filled(80, 40, [1.0, 0.0, 0.0, 1.0]); + let v = [("completion", num(100.0)), ("rows", num(1.0)), ("columns", num(8.0))]; + // Fully flipped cards show the Back Layer. + let mut b: Vec<(&str, Value)> = v.to_vec(); + b.push(("backLayer", Value::Layer(Some(3)))); + let out = run_t(&img, &b, 0.0, Some(&Green)); + let p = out.get(45, 20); + assert!(p[1] > 0.95 && p[0] < 0.05, "{p:?}"); + // Half way, Flip Direction decides which way the cards turn (the views differ). + let half = [("completion", num(30.0)), ("rows", num(1.0)), ("columns", num(1.0)), ("transitionWidth", num(100.0)), ("flipAxis", Value::Enum(1))]; + let pos = run_t(&img, &[half.as_slice(), &[("cameraPosition/xRotation", num(20.0))]].concat(), 0.0, None); + let neg = run_t(&img, &[half.as_slice(), &[("cameraPosition/xRotation", num(20.0)), ("flipDirection", Value::Enum(1))]].concat(), 0.0, None); + assert_ne!(pos, neg); + // Position Jitter moves the cards over time (and only then). + let j = [("completion", num(0.0)), ("positionJitter/xJitterAmount", num(0.3))]; + let a = run_t(&img, &j, 0.0, None); + let c = run_t(&img, &j, 0.37, None); + assert_ne!(a, c); + assert_eq!(c, run_t(&img, &j, 0.37, None), "deterministic"); + assert_eq!(run_t(&img, &[("completion", num(0.0))], 0.37, None), img, "no jitter, no change"); + // Camera Position rotation tilts the whole wipe in perspective. + let tilt = run_t(&img, &[("completion", num(0.0)), ("cameraPosition/yRotation", num(40.0))], 0.0, None); + assert_eq!(tilt.get(0, 0)[3], 0.0); + } + + #[test] + fn block_dissolve_soft_edges_antialias_blocks() { + let img = Image::filled(40, 4, [1.0, 1.0, 1.0, 1.0]); + let v = [("completion", num(50.0)), ("blockWidth", num(2.5)), ("blockHeight", num(4.0))]; + let frac = |o: &Image| o.data.iter().filter(|p| p[3] > 0.01 && p[3] < 0.99).count(); + let hard = run("ec.transition.blockdissolve", &img, &[v.as_slice(), &[("softEdges", Value::Bool(false))]].concat()); + let soft = run("ec.transition.blockdissolve", &img, &v); + assert_eq!(frac(&hard), 0); + assert!(frac(&soft) > 0); + } } diff --git a/crates/effects/src/util.rs b/crates/effects/src/util.rs index 9249ecf9..06580b71 100644 --- a/crates/effects/src/util.rs +++ b/crates/effects/src/util.rs @@ -563,6 +563,78 @@ impl SimCache { } } +/// 1D squared-distance transform of sampled function `f` (Felzenszwalb & Huttenlocher 2012; +/// `f` is 0 at sites and +∞ elsewhere). +fn sq_dt_1d(f: &[f64], out: &mut [f64]) { + let n = f.len(); + let sites: Vec = (0..n).filter(|&i| f[i].is_finite()).collect(); + if sites.is_empty() { + out.iter_mut().for_each(|o| *o = f64::INFINITY); + return; + } + let mut v = vec![0usize; sites.len()]; + let mut z = vec![0f64; sites.len() + 1]; + let mut k = 0usize; + v[0] = sites[0]; + z[0] = f64::NEG_INFINITY; + z[1] = f64::INFINITY; + let s_of = |q: usize, p: usize| ((f[q] + (q * q) as f64) - (f[p] + (p * p) as f64)) / (2.0 * q as f64 - 2.0 * p as f64); + for &q in &sites[1..] { + let mut s = s_of(q, v[k]); + while s <= z[k] { + k -= 1; + s = s_of(q, v[k]); + } + k += 1; + v[k] = q; + z[k] = s; + z[k + 1] = f64::INFINITY; + } + k = 0; + for (q, o) in out.iter_mut().enumerate() { + while z[k + 1] < q as f64 { + k += 1; + } + let d = q as f64 - v[k] as f64; + *o = d * d + f[v[k]]; + } +} + +/// Euclidean distance from every pixel to the nearest `true` pixel of `mask` (`w`×`h`), +/// infinite when there is none. +pub fn distance_transform(mask: &[bool], w: usize, h: usize) -> Vec { + let mut g: Vec = mask.iter().map(|&m| if m { 0.0 } else { f64::INFINITY }).collect(); + // Columns, then rows. + let cols: Vec> = (0..w) + .into_par_iter() + .map(|x| { + let f: Vec = (0..h).map(|y| g[y * w + x]).collect(); + let mut o = vec![0.0; h]; + sq_dt_1d(&f, &mut o); + o + }) + .collect(); + for (x, col) in cols.iter().enumerate() { + for y in 0..h { + g[y * w + x] = col[y]; + } + } + g.par_chunks_mut(w.max(1)).for_each(|row| { + let f = row.to_vec(); + sq_dt_1d(&f, row); + }); + g.iter().map(|&d| d.sqrt() as f32).collect() +} + +/// Signed distance to the boundary of the `inside` region: negative inside, positive outside +/// (pixel centres; ±0.5 at the boundary). +pub fn signed_distance(inside: &[bool], w: usize, h: usize) -> Plane { + let outside: Vec = inside.iter().map(|v| !v).collect(); + let to_in = distance_transform(inside, w, h); + let to_out = distance_transform(&outside, w, h); + Plane { w, h, data: to_in.iter().zip(&to_out).map(|(a, b)| if *a > 0.0 { a - 0.5 } else { -(b - 0.5) }).collect() } +} + /// 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(); diff --git a/crates/effects/src/vr.rs b/crates/effects/src/vr.rs index 26d71dfc..af311054 100644 --- a/crates/effects/src/vr.rs +++ b/crates/effects/src/vr.rs @@ -154,7 +154,20 @@ fn mtv(m: &[[f64; 3]; 3], v: V3) -> V3 { // ---------------------------------------------------------------- projections (VR Converter) -pub const PROJECTIONS: [&str; 6] = ["Equirectangular 2:1", "Cube-map 4:3", "Cube-map Pano2VR 3:2", "Cube-map GearVR 6:1", "Sphere-map", "2D Source"]; +/// VR Converter's projections / layouts. Cube-map Facebook 3:2 and Cube-map EAC 3:2 put +/// left, front, right over bottom, back, top (EAC with equi-angular faces); Fisheye +/// (FullDome) is an angular fisheye looking up at the zenith with the front at the bottom. +pub const PROJECTIONS: [&str; 9] = [ + "Equirectangular 2:1", + "Cube-map 4:3", + "Cube-map Pano2VR 3:2", + "Cube-map GearVR 6:1", + "Sphere-map", + "2D Source", + "Cube-map Facebook 3:2", + "Cube-map EAC 3:2", + "Fisheye (FullDome)", +]; /// Cube faces: (forward, right, up). const FACES: [(V3, V3, V3); 6] = [ @@ -173,6 +186,8 @@ fn cube_grid(kind: u32) -> ((u32, u32), [(u32, u32); 6]) { 1 => ((4, 3), [(2, 1), (0, 1), (1, 0), (1, 2), (1, 1), (3, 1)]), // 3 × 2: right, left, top / bottom, front, back. 2 => ((3, 2), [(0, 0), (1, 0), (2, 0), (0, 1), (1, 1), (2, 1)]), + // 3 × 2: left, front, right / bottom, back, top (Facebook, EAC). + 6 | 7 => ((3, 2), [(2, 0), (0, 0), (2, 1), (0, 1), (1, 0), (1, 1)]), // 6 × 1 strip in face order. _ => ((6, 1), [(0, 0), (1, 0), (2, 0), (3, 0), (4, 0), (5, 0)]), } @@ -191,16 +206,34 @@ impl Proj { pub fn dir(&self, x: f64, y: f64, w: f64, h: f64) -> Option { match self.kind { 0 => Some(equi_dir(x, y, w, h)), - 1..=3 => { + 1..=3 | 6 | 7 => { let ((gw, gh), cells) = cube_grid(self.kind); let (cw, ch) = (w / gw as f64, h / gh as f64); let (cx, cy) = ((x / cw).floor(), (y / ch).floor()); let f = cells.iter().position(|&(c, r)| c as f64 == cx && r as f64 == cy)?; - let a = (x - cx * cw) / cw * 2.0 - 1.0; - let b = (y - cy * ch) / ch * 2.0 - 1.0; + let mut a = (x - cx * cw) / cw * 2.0 - 1.0; + let mut b = (y - cy * ch) / ch * 2.0 - 1.0; + if self.kind == 7 { + // Equi-angular faces: positions are linear in angle. + a = (a * std::f64::consts::FRAC_PI_4).tan(); + b = (b * std::f64::consts::FRAC_PI_4).tan(); + } let (fw, r, u) = FACES[f]; Some(norm([fw[0] + a * r[0] - b * u[0], fw[1] + a * r[1] - b * u[1], fw[2] + a * r[2] - b * u[2]])) } + 8 => { + // FullDome: 180° angular fisheye centred on the zenith (+Y), front at the bottom. + let s = w.min(h) / 2.0; + let (u, v) = ((x - w / 2.0) / s, (y - h / 2.0) / s); + let r = (u * u + v * v).sqrt(); + if r > 1.0 { + return None; + } + let th = r * std::f64::consts::FRAC_PI_2; + let (cu, cv) = if r > 1e-12 { (u / r, v / r) } else { (0.0, 0.0) }; + // Local: forward = +Y, image right = +X, image down = +Z. + Some([th.sin() * cu, th.cos(), th.sin() * cv]) + } 4 => { // Angular fisheye centred on +Z; radius ∝ angle, `fov` across the image width. let half = (self.fov.clamp(1.0, 360.0) / 2.0).to_radians(); @@ -231,17 +264,32 @@ impl Proj { let (x, y) = equi_px(d, w, h); Some((x, y, None)) } - 1..=3 => { + 1..=3 | 6 | 7 => { let f = (0..6).max_by(|&i, &j| dot(d, FACES[i].0).total_cmp(&dot(d, FACES[j].0)))?; let (fw, r, u) = FACES[f]; let k = dot(d, fw); - let (a, b) = (dot(d, r) / k, -dot(d, u) / k); + let (mut a, mut b) = (dot(d, r) / k, -dot(d, u) / k); + if self.kind == 7 { + a = a.atan() / std::f64::consts::FRAC_PI_4; + b = b.atan() / std::f64::consts::FRAC_PI_4; + } let ((gw, gh), cells) = cube_grid(self.kind); let (cw, ch) = (w / gw as f64, h / gh as f64); let (c, rr) = cells[f]; let (x0, y0) = (c as f64 * cw, rr as f64 * ch); Some((x0 + (a + 1.0) * 0.5 * cw, y0 + (b + 1.0) * 0.5 * ch, Some([x0, y0, x0 + cw, y0 + ch]))) } + 8 => { + let th = d[1].clamp(-1.0, 1.0).acos(); + if th > std::f64::consts::FRAC_PI_2 + 1e-9 { + return None; + } + let s = w.min(h) / 2.0; + let r = th / std::f64::consts::FRAC_PI_2; + let l = (d[0] * d[0] + d[2] * d[2]).sqrt(); + let (cu, cv) = if l > 1e-12 { (d[0] / l, d[2] / l) } else { (0.0, 0.0) }; + Some((w / 2.0 + cu * r * s, h / 2.0 + cv * r * s, None)) + } 4 => { let half = (self.fov.clamp(1.0, 360.0) / 2.0).to_radians(); let th = d[2].clamp(-1.0, 1.0).acos(); @@ -578,7 +626,7 @@ fn vr_converter(ctx: &EffectCtx, mut b: Buf) -> Buf { let from = Proj { kind: ctx.params.e("sourceProjection"), fov: ctx.params.f("sourceHorizontalFov") }; let to = Proj { kind: ctx.params.e("targetProjection"), fov: ctx.params.f("targetHorizontalFov") }; let (t, pn, r) = (ctx.params.f("tilt"), ctx.params.f("pan"), ctx.params.f("roll")); - if from.kind == to.kind && (from.kind <= 3 || from.fov == to.fov) && t == 0.0 && pn == 0.0 && r == 0.0 { + if from.kind == to.kind && (!matches!(from.kind, 4 | 5) || from.fov == to.fov) && t == 0.0 && pn == 0.0 && r == 0.0 { return b; } let m = rot_params(ctx); @@ -825,39 +873,86 @@ fn vr_fractal(ctx: &EffectCtx, mut b: Buf) -> Buf { b } +/// VR Digital Glitch: horizontal bands of the panorama jump sideways (Geometric Distortion, +/// split into Horizontal / Vertical displacement), their colour channels split apart (Color +/// Distortion with per-channel offsets) and scan lines darken alternate rows (Scanline +/// Spacing apart). The pattern re-randomises Distortion Rate times a second, and Distortion +/// Evolution (optionally cycling) moves it smoothly in between. Target Point limits the glitch +/// to a region of the sphere (Radius, Feather; radius 0 = everywhere). fn vr_glitch(ctx: &EffectCtx, mut b: Buf) -> Buf { let pr = ctx.params; let amp = (pr.f("masterAmplitude") / 100.0).max(0.0); if amp <= 0.0 { return b; } + let g = |id: &str, d: f64| pr.get(id).map(Value::as_f64).unwrap_or(d); let rate = pr.f("distortionRate").max(0.0); let geo = pr.f("geometricDistortion") / 100.0 * amp; + let (hdisp, vdisp) = (g("geometric/horizontalDisplacement", 100.0) / 100.0, g("geometric/verticalDisplacement", 0.0) / 100.0); let complexity = pr.f("distortionComplexity").clamp(1.0, 100.0); let rgb = pr.f("colorDistortion") / 100.0 * amp; + let offs = [g("color/redOffset", -1.0), g("color/greenOffset", 0.0), g("color/blueOffset", 1.0)]; let scan = (pr.f("scanlines") / 100.0 * amp) as f32; + let spacing = g("scanlineSpacing", 2.0).round().max(2.0) as usize; let seed = (pr.f("randomSeed") as u32) ^ ctx.seed; - // The glitch pattern changes `rate` times a second (held between changes). - let slot = if rate > 0.0 { (ctx.time * rate).floor() as i64 } else { 0 } as u32; + // Evolution: a fraction of a slot that blends towards the next pattern. + let mut evo = g("distortionEvolution", 0.0) / 360.0; + if pr.b("evolutionOptions/cycleEvolution") { + evo = evo.rem_euclid(g("evolutionOptions/cycle", 1.0).round().max(1.0)); + } + let base = if rate > 0.0 { ctx.time * rate } else { 0.0 } + evo; + let slot = base.floor() as i64 as u32; + let frac = base - base.floor(); + // Target Point. + let target = mv(&rotation(g("target/tilt", 0.0), g("target/pan", 0.0), 0.0), [0.0, 0.0, 1.0]); + let radius = g("target/radius", 0.0).to_radians(); + let feather = g("target/feather", 0.0).to_radians(); b.img = per_eye(&b.img, layout(ctx), |e, eye| { let (w, h) = (e.width as f64, e.height as f64); let bands = complexity.round() as u32; let mut o = Image::new(e.width, e.height); o.rows_mut().for_each(|(y, row)| { let band = ((y as f64 / h) * bands as f64) as u32; - let r0 = hash1(band, slot, seed ^ (eye as u32 * 977)); - let on = r0 < 0.35; - let shift = if on { (hash1(band, slot + 1, seed) as f64 * 2.0 - 1.0) * geo * w * 0.15 } else { 0.0 }; - let split = if on { rgb * w * 0.01 * (1.0 + hash1(band, slot + 2, seed) as f64) } else { 0.0 }; - let dark = if y % 2 == 0 { 1.0 - scan * 0.5 } else { 1.0 }; + let at = |s: u32| { + let r0 = hash1(band, s, seed ^ (eye as u32 * 977)); + let on = r0 < 0.35; + let shift = if on { (hash1(band, s + 1, seed) as f64 * 2.0 - 1.0) * geo } else { 0.0 }; + let split = if on { rgb * w * 0.01 * (1.0 + hash1(band, s + 2, seed) as f64) } else { 0.0 }; + (shift, split) + }; + let (s0, p0) = at(slot); + let (shift, split) = if frac > 0.0 { + let (s1, p1) = at(slot + 1); + (s0 + (s1 - s0) * frac, p0 + (p1 - p0) * frac) + } else { + (s0, p0) + }; + let (dx, dy) = (shift * w * 0.15 * hdisp, shift * h * 0.15 * vdisp); + let dark = if y % spacing == 0 { 1.0 - scan * 0.5 } else { 1.0 }; for (x, px) in row.iter_mut().enumerate() { - let sx = x as f64 + 0.5 - shift; - let sy = y as f64 + 0.5; + let (fx, fy) = (x as f64 + 0.5, y as f64 + 0.5); + let k = if radius > 0.0 { + let a = angle(equi_dir(fx, fy, w, h), target); + if a <= radius { + 1.0 + } else if feather > 0.0 { + (1.0 - (a - radius) / feather).clamp(0.0, 1.0) + } else { + 0.0 + } + } else { + 1.0 + }; + let orig = e.data[y * e.width as usize + x]; + if k <= 0.0 { + *px = orig; + continue; + } + let (sx, sy) = (fx - dx * k, (fy - dy * k).clamp(0.5, h - 0.5)); // Horizontal shifts wrap around the seam (the frame is a full turn). - let g = sample_equi(e, sx, sy); - let r = sample_equi(e, sx - split, sy); - let bb = sample_equi(e, sx + split, sy); - *px = [r[0] * dark, g[1] * dark, bb[2] * dark, g[3].max(r[3]).max(bb[3])]; + let c: [Px; 3] = std::array::from_fn(|i| sample_equi(e, sx + offs[i] * split * k, sy)); + let d = 1.0 - (1.0 - dark) * k as f32; + *px = [c[0][0] * d, c[1][1] * d, c[2][2] * d, c[0][3].max(c[1][3]).max(c[2][3])]; } }); o @@ -947,9 +1042,22 @@ pub fn specs() -> Vec { p("masterAmplitude", "Master Amplitude", num(100.0), slider(0.0, 500.0, 0.0, 200.0, 1)), p("distortionRate", "Distortion Rate", num(5.0), slider(0.0, 60.0, 0.0, 30.0, 2)), p("distortionComplexity", "Distortion Complexity", num(16.0), slider(1.0, 100.0, 1.0, 64.0, 0)), + ang("distortionEvolution", "Distortion Evolution"), + p("evolutionOptions/cycleEvolution", "Cycle Evolution", Value::Bool(false), ParamUi::Checkbox), + p("evolutionOptions/cycle", "Cycle (in Revolutions)", num(1.0), slider(1.0, 1000.0, 1.0, 20.0, 0)), p("geometricDistortion", "Geometric Distortion", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("geometric/horizontalDisplacement", "Horizontal Displacement", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 1)), + p("geometric/verticalDisplacement", "Vertical Displacement", num(0.0), slider(0.0, 400.0, 0.0, 200.0, 1)), p("colorDistortion", "Color Distortion", num(50.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("color/redOffset", "Red Offset", num(-1.0), slider(-10.0, 10.0, -2.0, 2.0, 2)), + p("color/greenOffset", "Green Offset", num(0.0), slider(-10.0, 10.0, -2.0, 2.0, 2)), + p("color/blueOffset", "Blue Offset", num(1.0), slider(-10.0, 10.0, -2.0, 2.0, 2)), p("scanlines", "Scanlines", num(20.0), slider(0.0, 100.0, 0.0, 100.0, 1)), + p("scanlineSpacing", "Scanline Spacing", num(2.0), slider(2.0, 100.0, 2.0, 20.0, 0)), + ang("target/tilt", "Target Tilt"), + ang("target/pan", "Target Pan"), + p("target/radius", "Target Radius", num(0.0), slider(0.0, 180.0, 0.0, 180.0, 1)), + p("target/feather", "Target Feather", num(0.0), slider(0.0, 180.0, 0.0, 90.0, 1)), p("randomSeed", "Random Seed", num(0.0), slider(0.0, 10000.0, 0.0, 1000.0, 0)), ], vr_glitch, @@ -1056,7 +1164,7 @@ mod tests { #[test] fn projections_are_inverse() { - for kind in 0..6u32 { + for kind in 0..9u32 { let pr = Proj { kind, fov: if kind == 4 { 360.0 } else { 100.0 } }; let (w, h) = (240.0, 120.0); for (x, y) in [(10.5, 20.5), (100.5, 60.5), (230.5, 110.5), (130.5, 33.5)] { @@ -1070,7 +1178,7 @@ mod tests { #[test] fn converter_round_trips_equirect_through_cube_maps() { let img = sphere_img(128, 64); - for target in [1u32, 2, 3] { + for target in [1u32, 2, 3, 6, 7] { let cube = run_fx("ec.vr.converter", &[("targetProjection", Value::Enum(target))], img.clone(), 0.0, EffectEnv::default()); let back = run_fx( "ec.vr.converter", @@ -1098,6 +1206,41 @@ mod tests { } } + #[test] + fn digital_glitch_full_controls() { + let img = sphere_img(128, 64); + let run = |vals: &[(&str, Value)], t: f64| run_fx("ec.vr.digitalglitch", vals, img.clone(), t, EffectEnv::default()).img; + let base = run(&[], 0.0); + for (id, v) in + [("distortionEvolution", num(90.0)), ("geometric/verticalDisplacement", num(100.0)), ("color/redOffset", num(3.0)), ("scanlineSpacing", num(5.0))] + { + assert_ne!(run(&[(id, v.clone())], 0.0), base, "{id}"); + } + // Cycle Evolution: one full cycle is the start again. + let cyc = + |e: f64| run(&[("distortionEvolution", num(e)), ("evolutionOptions/cycleEvolution", Value::Bool(true)), ("evolutionOptions/cycle", num(1.0))], 0.0); + assert_eq!(cyc(0.0), cyc(360.0)); + // Target Point: a small region around the front; the back stays untouched. + let t = run(&[("target/radius", num(30.0))], 0.0); + assert_eq!(t.get(0, 32), img.get(0, 32), "behind the viewer"); + } + + #[test] + fn converter_fulldome_and_eac() { + let img = sphere_img(128, 64); + let dome = run_fx("ec.vr.converter", &[("targetProjection", Value::Enum(8))], img.clone(), 0.0, EffectEnv::default()).img; + // The centre looks at the zenith (+Y); the bottom edge at the front (+Z). + let c = dome.get(64, 32); + assert!((c[1] - 0.9).abs() < 0.03, "{c:?}"); + let f = dome.get(64, 62); + assert!(f[2] > 0.8, "{f:?}"); + // EAC faces are equi-angular: a quarter of the front face sits at 22.5 degrees. + let eac = Proj { kind: 7, fov: 90.0 }; + let cw = 128.0 / 3.0; + let d = eac.dir(cw + 0.25 * cw, 16.0, 128.0, 64.0).unwrap(); + assert!((d[0].atan2(d[2]).to_degrees() + 22.5).abs() < 0.5, "{d:?}"); + } + #[test] fn rotate_sphere_pans_and_round_trips() { let img = sphere_img(96, 48); diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index 2ef75c48..3691ae17 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -105,6 +105,10 @@ impl effectcraft_render::FxTarget for GpuFx<'_, '_> { } fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { + // Controls the kernels do not implement render on the CPU. + if !effectcraft_effects::catalog::gpu_supported(id, ctx) { + return None; + } match id { "ec.blur.gaussian" => gaussian(e, ctx, b), "ec.blur.fastbox" => box_blur(e, ctx, b), @@ -218,6 +222,10 @@ fn directional(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { } fn glow(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + // Other Glow Operations and the Arbitrary Map render on the CPU. + if ctx.params.e("colors") == 2 || effectcraft_effects::glow_operation(ctx) != effectcraft_color::BlendMode::Add { + return None; + } let thr = ctx.params.f("threshold") as f32 / 100.0; let radius = ctx.params.f("radius") * b.scale; let intensity = ctx.params.f("intensity") as f32; @@ -276,6 +284,10 @@ fn drop_shadow(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { } fn transform(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + // Motion blur (shutter angle) renders on the CPU. + if ctx.env.host.is_some() && effectcraft_effects::transform_shutter(ctx).is_some() { + return None; + } let anchor = b.to_px(ctx.params.v2("anchor")); let pos = b.to_px(ctx.params.v2("position")); let sh = ctx.params.f("scaleHeight"); @@ -352,11 +364,19 @@ fn pointwise(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { p.f[0] = [br, k, ct, 0.0]; } "ec.color.huesaturation" => { + // Colour ranges (Channel Control) render on the CPU. + if !effectcraft_effects::huesat_ranges_identity(ctx) { + return None; + } p.u[0] = [3, ctx.params.b("colorize") as u32, 0, 0]; p.f[0] = [f("hue") as f32 / 360.0, f("saturation") as f32 / 100.0, f("lightness") as f32 / 100.0, 0.0]; p.f[1] = [f("colorizeHue") as f32 / 360.0, f("colorizeSaturation") as f32 / 100.0, f("colorizeLightness") as f32 / 100.0, 0.0]; } "ec.color.levels" => { + // Red / Green / Blue / Alpha controls render on the CPU. + if !effectcraft_effects::levels_channels_identity(ctx) { + return None; + } let (clip_b, clip_w) = effectcraft_effects::levels_clip(ctx); p.u[0] = [4, clip_b as u32, clip_w as u32, 0]; p.f[0] = [f("inBlack") as f32, f("inWhite") as f32, f("gamma").max(0.01) as f32, f("outBlack") as f32]; @@ -382,6 +402,10 @@ fn pointwise(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { p.f[0][0] = 1.0 - f("blend") as f32 / 100.0; } "ec.generate.fill" => { + // Fill Mask / All Masks render on the CPU. + if effectcraft_effects::fill_uses_masks(ctx) { + return None; + } p.u[0] = [7, ctx.params.b("invert") as u32, 0, 0]; p.f[0] = ctx.params.color("color"); p.f[1][0] = f("opacity") as f32 / 100.0; diff --git a/crates/gpu/src/fx_color.rs b/crates/gpu/src/fx_color.rs index 824f9a50..ee6ab7e8 100644 --- a/crates/gpu/src/fx_color.rs +++ b/crates/gpu/src/fx_color.rs @@ -59,15 +59,15 @@ fn rgb(c: [f32; 4]) -> [f32; 4] { /// Same selective colour ids as `color2::SC_IDS`. const SC_IDS: [[&str; 4]; 9] = [ - ["redsCyan", "redsMagenta", "redsYellow", "redsBlack"], - ["yellowsCyan", "yellowsMagenta", "yellowsYellow", "yellowsBlack"], - ["greensCyan", "greensMagenta", "greensYellow", "greensBlack"], - ["cyansCyan", "cyansMagenta", "cyansYellow", "cyansBlack"], - ["bluesCyan", "bluesMagenta", "bluesYellow", "bluesBlack"], - ["magentasCyan", "magentasMagenta", "magentasYellow", "magentasBlack"], - ["whitesCyan", "whitesMagenta", "whitesYellow", "whitesBlack"], - ["neutralsCyan", "neutralsMagenta", "neutralsYellow", "neutralsBlack"], - ["blacksCyan", "blacksMagenta", "blacksYellow", "blacksBlack"], + ["details/reds/redsCyan", "details/reds/redsMagenta", "details/reds/redsYellow", "details/reds/redsBlack"], + ["details/yellows/yellowsCyan", "details/yellows/yellowsMagenta", "details/yellows/yellowsYellow", "details/yellows/yellowsBlack"], + ["details/greens/greensCyan", "details/greens/greensMagenta", "details/greens/greensYellow", "details/greens/greensBlack"], + ["details/cyans/cyansCyan", "details/cyans/cyansMagenta", "details/cyans/cyansYellow", "details/cyans/cyansBlack"], + ["details/blues/bluesCyan", "details/blues/bluesMagenta", "details/blues/bluesYellow", "details/blues/bluesBlack"], + ["details/magentas/magentasCyan", "details/magentas/magentasMagenta", "details/magentas/magentasYellow", "details/magentas/magentasBlack"], + ["details/whites/whitesCyan", "details/whites/whitesMagenta", "details/whites/whitesYellow", "details/whites/whitesBlack"], + ["details/neutrals/neutralsCyan", "details/neutrals/neutralsMagenta", "details/neutrals/neutralsYellow", "details/neutrals/neutralsBlack"], + ["details/blacks/blacksCyan", "details/blacks/blacksMagenta", "details/blacks/blacksYellow", "details/blacks/blacksBlack"], ]; fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { diff --git a/crates/gpu/src/tests_fx_color.rs b/crates/gpu/src/tests_fx_color.rs index 8fb455b8..28e80278 100644 --- a/crates/gpu/src/tests_fx_color.rs +++ b/crates/gpu/src/tests_fx_color.rs @@ -32,10 +32,24 @@ fn black_white_colorama_selective_color() { effect_case("ec.color.colorama", &[]); effect_case("ec.color.colorama", &[("inputPhase/mode", e(4)), ("inputPhase/phaseShift", n(75.0)), ("outputCycle/cycles", n(2.5)), ("blend", n(40.0))]); effect_case("ec.color.colorama", &[("inputPhase/mode", e(8)), ("outputCycle/cycles", n(1.3))]); - effect_case("ec.color.selectivecolor", &[("redsCyan", n(60.0)), ("bluesYellow", n(-40.0)), ("neutralsBlack", n(20.0)), ("whitesMagenta", n(30.0))]); effect_case( "ec.color.selectivecolor", - &[("method", e(1)), ("greensMagenta", n(50.0)), ("yellowsCyan", n(-30.0)), ("blacksYellow", n(40.0)), ("magentasBlack", n(25.0))], + &[ + ("details/reds/redsCyan", n(60.0)), + ("details/blues/bluesYellow", n(-40.0)), + ("details/neutrals/neutralsBlack", n(20.0)), + ("details/whites/whitesMagenta", n(30.0)), + ], + ); + effect_case( + "ec.color.selectivecolor", + &[ + ("method", e(1)), + ("details/greens/greensMagenta", n(50.0)), + ("details/yellows/yellowsCyan", n(-30.0)), + ("details/blacks/blacksYellow", n(40.0)), + ("details/magentas/magentasBlack", n(25.0)), + ], ); } diff --git a/crates/raster/src/flow.rs b/crates/raster/src/flow.rs index c398eadd..feb033d6 100644 --- a/crates/raster/src/flow.rs +++ b/crates/raster/src/flow.rs @@ -95,18 +95,28 @@ fn sad(a: &Plane, b: &Plane, bx: i64, by: i64, bs: i64, dx: i64, dy: i64) -> f32 /// Block-matching flow from `a` to `b` (same size). `block` is the tile size at full resolution; /// `radius` the search range at the coarsest level (motion up to about `radius · 2^levels`). pub fn block_flow(a: &Image, b: &Image, block: u32, radius: i32) -> Flow { - let bs = block.max(4) as usize; + block_flow_window(a, b, block, block, radius) +} + +/// [`block_flow`] with separate vector spacing and matching window: one vector per +/// `tile × tile` tile, each matched with the `window × window` block centred on its tile +/// (Timewarp's Vector Detail and Block Size). +pub fn block_flow_window(a: &Image, b: &Image, tile: u32, window: u32, radius: i32) -> Flow { + let bs = tile.max(2) as usize; + let win = window.max(4) as i64; + // Window origin relative to the tile origin. + let wo = bs as i64 / 2 - win / 2; let (w, h) = (a.width as usize, a.height as usize); let cols = w.div_ceil(bs).max(1); let rows = h.div_ceil(bs).max(1); let mut flow = Flow { width: a.width, height: a.height, block: bs as u32, cols, rows, v: vec![[0.0; 2]; cols * rows] }; - if a.width != b.width || a.height != b.height || w < bs || h < bs { + if a.width != b.width || a.height != b.height || w < bs || h < bs || (w as i64) < win || (h as i64) < win { return flow; } // Pyramids (level 0 = full resolution); stop while blocks still fit. let mut pa = vec![Plane::luma(a)]; let mut pb = vec![Plane::luma(b)]; - while pa.len() < 4 && pa.last().is_some_and(|p| p.w / 2 >= bs * 2 && p.h / 2 >= bs * 2) { + while pa.len() < 4 && pa.last().is_some_and(|p| p.w / 2 >= (bs as i64).max(win) as usize * 2 && p.h / 2 >= (bs as i64).max(win) as usize * 2) { let (na, nb) = (pa.last().map(Plane::half), pb.last().map(Plane::half)); pa.extend(na); pb.extend(nb); @@ -118,15 +128,15 @@ pub fn block_flow(a: &Image, b: &Image, block: u32, radius: i32) -> Flow { let (la, lb) = (&pa[level], &pb[level]); let k = 1i64 << level; // The block covers the tile's footprint at this level (at least 4 pixels). - let lbs = ((bs as i64) / k).max(4); + let lbs = (win / k).max(4); let r = if level == top { radius as i64 } else { 1 }; let prev = cur.clone(); cur = (0..cols * rows) .into_par_iter() .map(|i| { let (c, rr) = ((i % cols) as i64, (i / cols) as i64); - let bx = c * bs as i64 / k; - let by = rr * bs as i64 / k; + let bx = (c * bs as i64 + wo) / k; + let by = (rr * bs as i64 + wo) / k; // Candidates: this tile's and its neighbours' vectors from the coarser level. let mut seeds: Vec<[i64; 2]> = vec![[0, 0]]; if level != top { @@ -160,18 +170,19 @@ pub fn block_flow(a: &Image, b: &Image, block: u32, radius: i32) -> Flow { // Sub-pixel refinement (parabola through the SAD of the neighbours) at full resolution. let (la, lb) = (&pa[0], &pb[0]); let bsi = bs as i64; + let wsi = win; let fine: Vec<[f32; 2]> = (0..cols * rows) .into_par_iter() .map(|i| { - let (bx, by) = ((i % cols) as i64 * bsi, (i / cols) as i64 * bsi); + let (bx, by) = ((i % cols) as i64 * bsi + wo, (i / cols) as i64 * bsi + wo); let d = cur[i]; - let e0 = sad(la, lb, bx, by, bsi, d[0], d[1]); + let e0 = sad(la, lb, bx, by, wsi, d[0], d[1]); let fit = |em: f32, ep: f32| { let den = em - 2.0 * e0 + ep; if den > 1e-6 { (0.5 * (em - ep) / den).clamp(-0.5, 0.5) } else { 0.0 } }; - let sx = fit(sad(la, lb, bx, by, bsi, d[0] - 1, d[1]), sad(la, lb, bx, by, bsi, d[0] + 1, d[1])); - let sy = fit(sad(la, lb, bx, by, bsi, d[0], d[1] - 1), sad(la, lb, bx, by, bsi, d[0], d[1] + 1)); + let sx = fit(sad(la, lb, bx, by, wsi, d[0] - 1, d[1]), sad(la, lb, bx, by, wsi, d[0] + 1, d[1])); + let sy = fit(sad(la, lb, bx, by, wsi, d[0], d[1] - 1), sad(la, lb, bx, by, wsi, d[0], d[1] + 1)); [d[0] as f32 + sx, d[1] as f32 + sy] }) .collect(); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 2f468782..6204b763 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -157,6 +157,38 @@ impl EffectHost for FxHost<'_, '_, '_> { } } + fn params_at(&self, layer_time: f64) -> Option { + let i = self.index.load(std::sync::atomic::Ordering::Relaxed); + let g = self.layer.effects()?.groups().nth(i)?; + let ctx = self.ctx.at(self.layer.comp_time(Tick::from_seconds_f64(layer_time))); + Some(effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(self.layer, pr))) + } + + fn comp_scene(&self) -> Option { + // The comp camera's view of the layer (as if 3D at its transform), brought back into the + // layer's own pixel grid through the inverse of how the layer itself composites. + let world = self.ctx.world_matrix(self.layer); + let (cam, _, _) = self.ctx.camera(); + let p = (cam * world).0; + let back = self.ctx.layer_to_comp(self.layer).0.inverse()?.0; + let rows = [p[0], p[1], p[3]]; + let camera: [[f64; 4]; 3] = std::array::from_fn(|i| std::array::from_fn(|j| (0..3).map(|k| back[i][k] * rows[k][j]).sum())); + let inv = world.inverse(); + let light = three_d::light::lights_at(self.ctx).first().and_then(|l| { + let inv = inv.as_ref()?; + let pos = inv.apply(l.pos); + let dir = inv.apply_vec(l.dir); + let len = (dir.x * dir.x + dir.y * dir.y + dir.z * dir.z).sqrt().max(1e-12); + let kind = match l.kind { + effectcraft_project::LightKind::Parallel => 0, + effectcraft_project::LightKind::Ambient => 2, + _ => 1, + }; + Some(effectcraft_effects::CompLight { pos: [pos.x, pos.y, pos.z], dir: [dir.x / len, dir.y / len, dir.z / len], color: l.color, kind }) + }); + Some(effectcraft_effects::CompScene { camera: Some(camera), light }) + } + fn layer_at(&self, id: u64, comp_time: f64, masks_and_effects: bool) -> Option { let other = self.ctx.layer(effectcraft_project::LayerId(id))?; if other.id == self.layer.id || self.r.depth > MAX_FX_DEPTH { @@ -741,6 +773,11 @@ impl<'a> Renderer<'a> { effect_index: 0, working_space: self.pipe.space, working_linear: self.pipe.linear, + shutter: (ctx.comp.enable_motion_blur && layer.switches.motion_blur).then_some(( + ctx.comp.shutter_angle, + ctx.comp.shutter_phase, + ctx.comp.motion_blur_samples, + )), }; // Video effects in stack order (index, group, spec); disabled and audio effects skipped. let stack: Vec<(usize, &effectcraft_project::PropGroup, &'static effectcraft_effects::EffectSpec)> = fx diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 55373714..9c39be8f 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -163,6 +163,19 @@ fn effects_run_in_pipeline() { assert!(c[1] > 0.99 && c[0] < 0.01, "{c:?}"); } +/// Card Dance's Comp Camera through the default comp camera reproduces a 2D layer in place. +#[test] +fn card_dance_comp_camera_matches_the_default_view() { + let (mut p, cid, comp) = setup(); + let mut l = solid(&mut p, &comp, [0.2, 0.6, 1.0], 50, 50); + add_effect(&mut p, &mut l, "ec.sim.carddance", [50.0, 50.0], &[("cameraSystem", Value::Enum(2))]); + p.comp_mut(cid).unwrap().layers.push(l); + let img = render_frame(&p, cid, Tick::ZERO, 1.0); + let c = img.get(100, 50); + assert!((c[0] - 0.2).abs() < 0.02 && (c[2] - 1.0).abs() < 0.02 && c[3] > 0.99, "{c:?}"); + assert!(img.get(10, 50)[3] < 0.01); +} + // ------------------------------------------------------------------------------ layer cache fn render_cached(p: &Project, cid: ItemId, t: Tick, cache: Option<&crate::LayerCache>) -> crate::Image { diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index ff6d07a8..02dcb1a9 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -441,6 +441,37 @@ fn dial_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, layer: &Layer, prop: &P } } +/// The effect instance a row belongs to, for conditional visibility +/// ([`effectcraft_engine::effects::catalog::param_shown`]): effect id, the instance's root +/// group and the spec-id path of the group being drawn (`""` or `cameraPosition/`). +struct FxScope<'a> { + effect: &'a str, + root: &'a PropGroup, + path: String, +} + +impl<'a> FxScope<'a> { + fn sub(&self, m: &str) -> FxScope<'a> { + FxScope { effect: self.effect, root: self.root, path: format!("{}{m}/", self.path) } + } + /// Whether child `m` of the current group is shown. + fn shown(&self, layer: &Layer, ectx: &EvalCtx, m: &str) -> bool { + let root = self.root; + let value = |id: &str| -> Option { + let mut g = root; + let mut parts = id.split('/').peekable(); + while let Some(part) = parts.next() { + if parts.peek().is_none() { + return g.get(part).map(|p| ectx.value(layer, p)); + } + g = g.sub(part)?; + } + None + }; + effectcraft_engine::effects::catalog::param_shown(self.effect, &format!("{}{m}", self.path), &value) + } +} + #[allow(clippy::too_many_arguments)] fn group_rows( app: &mut EffectcraftApp, @@ -452,6 +483,7 @@ fn group_rows( rect: Rect, y: &mut f32, depth: usize, + fx: &FxScope, actions: &mut Actions, ) { let t = app.tokens; @@ -459,7 +491,7 @@ fn group_rows( let r = Rect::from_min_size(pos2(rect.min.x, *y), vec2(rect.width(), ROW)); match c { Node::Prop(pr) => { - if matches!(pr.ui, ParamUi::Hidden) || pr.three_d_only && !layer.is_3d() { + if matches!(pr.ui, ParamUi::Hidden) || pr.three_d_only && !layer.is_3d() || !fx.shown(layer, ectx, &pr.match_id) { continue; } *y += ROW; @@ -486,7 +518,7 @@ fn group_rows( } Node::Group(sg) => { // Paint strokes live in the Timeline only (AE's Paint shows Paint on Transparent). - if effectcraft_engine::effects::paint::is_paint(g) { + if effectcraft_engine::effects::paint::is_paint(g) || !fx.shown(layer, ectx, &sg.match_id) { continue; } *y += ROW; @@ -503,7 +535,8 @@ fn group_rows( p.text(pos2(tw.max.x + 6.0, r.center().y), Align2::LEFT_CENTER, &sg.name, Tokens::ui(12.0), t.text); } if open { - group_rows(app, ui, p, layer, sg, ectx, rect, y, depth + 1, actions); + let sub = fx.sub(&sg.match_id); + group_rows(app, ui, p, layer, sg, ectx, rect, y, depth + 1, &sub, actions); } } } @@ -642,12 +675,24 @@ fn channel_popup( fn curves_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, layer: &Layer, g: &PropGroup, ectx: &EvalCtx, r: Rect, actions: &mut Actions) { let t = app.tokens; let euid = g.uid; - let ch = app.ui.fx_curve_channel.get(&euid).copied().unwrap_or(0).min(4); + // The channel lives in the effect's hidden Channel parameter (UI state for instances + // saved before it existed). + let chan_prop = g.get("channel"); + let ch = match chan_prop { + Some(pr) => ectx.value(layer, pr).as_enum() as usize, + None => app.ui.fx_curve_channel.get(&euid).copied().unwrap_or(0), + } + .min(4); let x0 = r.min.x + 30.0; let cy = r.min.y + 17.0; let names: Vec<&str> = fw::CURVE_CHANNELS.iter().map(|c| c.1).collect(); if let Some(n) = channel_popup(app, ui, p, x0, cy, ch, &names, egui::Id::new(("ec-cch", euid)), &format!("effectControls.effect.{euid}.curves.channel")) { - app.ui.fx_curve_channel.insert(euid, n); + match chan_prop { + Some(pr) => actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": pr.uid, "value": n}))), + None => { + app.ui.fx_curve_channel.insert(euid, n); + } + } } let pencil = app.ui.fx_curve_pencil.contains(&euid); let mr = Rect::from_min_size(pos2(x0 + 146.0, cy - 9.0), vec2(56.0, 18.0)); @@ -696,7 +741,7 @@ fn curves_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, /// Levels parameter ids for a channel: input black, input white, gamma, output black, output white. fn levels_ids(effect: &str, ch: usize) -> [String; 5] { if effect == "ec.color.levels" { - return ["inBlack", "inWhite", "gamma", "outBlack", "outWhite"].map(String::from); + return effectcraft_engine::effects::levels_channel_ids(ch); } // Levels (Individual Controls) nests each channel's controls in its twirl-down group. let pre = fw::LEVELS_CHANNELS[ch.min(4)].0; @@ -721,7 +766,13 @@ fn levels_editor( let euid = g.uid; let x0 = r.min.x + 30.0; let cy = r.min.y + 17.0; - let ch = if effect == "ec.color.levelsic" { app.ui.fx_levels_channel.get(&euid).copied().unwrap_or(0).min(4) } else { 0 }; + // Levels keeps its channel in the Channel parameter; Levels (Individual Controls) shows + // every channel and the popup only picks the histogram. + let ch = if effect == "ec.color.levelsic" { + app.ui.fx_levels_channel.get(&euid).copied().unwrap_or(0).min(4) + } else { + g.get("channel").map(|pr| ectx.value(layer, pr).as_enum() as usize).unwrap_or(0).min(4) + }; if effect == "ec.color.levelsic" { let names: Vec<&str> = fw::LEVELS_CHANNELS.iter().map(|c| c.1).collect(); if let Some(n) = channel_popup(app, ui, p, x0, cy, ch, &names, egui::Id::new(("ec-lch", euid)), &format!("effectControls.effect.{euid}.levels.channel")) @@ -959,7 +1010,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } } - group_rows(app, ui, &bp, &layer, g, &ectx, body, &mut y, 0, &mut actions); + let scope = FxScope { effect: effect.as_str(), root: g, path: String::new() }; + group_rows(app, ui, &bp, &layer, g, &ectx, body, &mut y, 0, &scope, &mut actions); } y += 4.0; } diff --git a/docs/effects.md b/docs/effects.md index fd29541b..e35ae5f6 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -3,11 +3,11 @@ -EffectCraft ships 306 effects with 2488 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over. +EffectCraft ships 306 effects with 3009 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over. - **GPU**: 59 effects also run on the GPU compositor with identical results. - **32**: 306 effects process 32-bit float (HDR, overbright) pixels without clamping. -- **Status**: 264 implemented in full, 42 partial (what is missing is listed). +- **Status**: 306 implemented in full, 0 partial (what is missing is listed). Effects are addressed by id (`ec..`) or display name in commands, scripts and the control channel, and every parameter by its id (`effects/#1/blurriness`). @@ -15,7 +15,7 @@ Effects are addressed by id (`ec..`) or display name in commands | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| -| 3D Channel Extract | `ec.3d.channelextract` | 5 | | 32 | Partial: no anti-alias option | +| 3D Channel Extract | `ec.3d.channelextract` | 6 | | 32 | Implemented | | Cryptomatte | `ec.3d.cryptomatte` | 4 | | 32 | Implemented | | Depth Matte | `ec.3d.depthmatte` | 3 | | 32 | Implemented | | Depth of Field | `ec.3d.depthoffield` | 4 | | 32 | Implemented | @@ -51,7 +51,7 @@ Effects are addressed by id (`ec..`) or display name in commands | CC Radial Blur | `ec.blur.ccradial` | 4 | | 32 | Implemented | | CC Radial Fast Blur | `ec.blur.ccradialfast` | 3 | GPU | 32 | Implemented | | CC Vector Blur | `ec.blur.ccvector` | 4 | | 32 | Implemented | -| Camera Lens Blur | `ec.blur.cameralens` | 10 | GPU | 32 | Partial: no Diffraction Fringe or Blur Map layer | +| Camera Lens Blur | `ec.blur.cameralens` | 16 | GPU | 32 | Implemented | | Camera-Shake Deblur | `ec.blur.camerashakedeblur` | 8 | | 32 | Implemented | | Channel Blur | `ec.blur.channel` | 6 | | 32 | Implemented | | Compound Blur | `ec.blur.compound` | 4 | | 32 | Implemented | @@ -71,10 +71,10 @@ Effects are addressed by id (`ec..`) or display name in commands | Blend | `ec.channel.blend` | 4 | | 32 | Implemented | | CC Composite | `ec.channel.cccomposite` | 3 | | 32 | Implemented | | Calculations | `ec.channel.calculations` | 9 | | 32 | Implemented | -| Channel Combiner | `ec.channel.combiner` | 6 | | 32 | Partial: no Saturation Multiplied target | +| Channel Combiner | `ec.channel.combiner` | 6 | | 32 | Implemented | | Compound Arithmetic | `ec.channel.compoundarithmetic` | 6 | | 32 | Implemented | | Invert | `ec.channel.invert` | 2 | GPU | 32 | Implemented | -| Minimax | `ec.channel.minimax` | 4 | | 32 | Partial: no Don't Shrink Edges option | +| Minimax | `ec.channel.minimax` | 5 | | 32 | Implemented | | Remove Color Matting | `ec.channel.removecolormatting` | 2 | | 32 | Implemented | | Set Channels | `ec.channel.setchannels` | 9 | | 32 | Implemented | | Set Matte | `ec.channel.setmatte` | 6 | | 32 | Implemented | @@ -102,16 +102,16 @@ Effects are addressed by id (`ec..`) or display name in commands | Color Balance (HLS) | `ec.color.colorbalancehls` | 3 | | 32 | Implemented | | Color Link | `ec.color.colorlink` | 6 | | 32 | Implemented | | Color Stabilizer | `ec.color.colorstabilizer` | 6 | | 32 | Implemented | -| Colorama | `ec.color.colorama` | 5 | GPU | 32 | Partial: no Add Phase, editable Output Cycle, Modify, Pixel Selection or Masking | -| Curves | `ec.color.curves` | 0 | GPU | 32 | Partial: no Channel popup | +| Colorama | `ec.color.colorama` | 20 | GPU | 32 | Implemented | +| Curves | `ec.color.curves` | 0 | GPU | 32 | Implemented | | Equalize | `ec.color.equalize` | 2 | | 32 | Implemented | | Exposure | `ec.color.exposure` | 14 | GPU | 32 | Implemented | | Gamma/Pedestal/Gain | `ec.color.gammapedestalgain` | 10 | | 32 | Implemented | -| Hue/Saturation | `ec.color.huesaturation` | 7 | GPU | 32 | Partial: no per-range Channel Control | +| Hue/Saturation | `ec.color.huesaturation` | 50 | GPU | 32 | Implemented | | Leave Color | `ec.color.leavecolor` | 5 | | 32 | Implemented | -| Levels | `ec.color.levels` | 7 | GPU | 32 | Partial: master channel only (no Channel popup) | +| Levels | `ec.color.levels` | 36 | GPU | 32 | Implemented | | Levels (Individual Controls) | `ec.color.levelsic` | 27 | | 32 | Implemented | -| Lumetri Color | `ec.color.lumetri` | 38 | GPU | 32 | Partial: no HSL Secondary, hue/saturation curves, HDR mode or look files | +| Lumetri Color | `ec.color.lumetri` | 66 | GPU | 32 | Implemented | | OCIO CDL Transform | `ec.color.ociocdl` | 12 | | 32 | Implemented | | OCIO Color Space Transform | `ec.color.ociocolorspace` | 7 | | 32 | Implemented | | OCIO Display Transform | `ec.color.ociodisplay` | 9 | | 32 | Implemented | @@ -119,7 +119,7 @@ Effects are addressed by id (`ec..`) or display name in commands | OCIO Look Transform | `ec.color.ociolook` | 6 | | 32 | Implemented | | PS Arbitrary Map | `ec.color.psarbitrarymap` | 2 | | 32 | Implemented | | Photo Filter | `ec.color.photofilter` | 4 | | 32 | Implemented | -| Selective Color | `ec.color.selectivecolor` | 37 | GPU | 32 | Partial: simplified Colors / Details layout | +| Selective Color | `ec.color.selectivecolor` | 38 | GPU | 32 | Implemented | | Shadow/Highlight | `ec.color.shadowhighlight` | 14 | | 32 | Implemented | | Tint | `ec.color.tint` | 3 | GPU | 32 | Implemented | | Tritone | `ec.color.tritone` | 4 | GPU | 32 | Implemented | @@ -154,16 +154,16 @@ Effects are addressed by id (`ec..`) or display name in commands | Mesh Warp | `ec.distort.meshwarp` | 3 | GPU | 32 | Implemented | | Mirror | `ec.distort.mirror` | 2 | | 32 | Implemented | | Offset | `ec.distort.offset` | 2 | | 32 | Implemented | -| Optics Compensation | `ec.distort.opticscompensation` | 5 | | 32 | Partial: no Optimal Pixels | +| Optics Compensation | `ec.distort.opticscompensation` | 6 | | 32 | Implemented | | Polar Coordinates | `ec.distort.polar` | 2 | | 32 | Implemented | | Puppet | `ec.distort.puppet` | 1 | | 32 | Implemented | -| Reshape | `ec.distort.reshape` | 6 | | 32 | Partial: no correspondence points | +| Reshape | `ec.distort.reshape` | 6 | | 32 | Implemented | | Ripple | `ec.distort.ripple` | 7 | GPU | 32 | Implemented | -| Rolling Shutter Repair | `ec.distort.rollingshutterrepair` | 5 | | 32 | Partial: Pixel Motion Detail has no effect | +| Rolling Shutter Repair | `ec.distort.rollingshutterrepair` | 5 | | 32 | Implemented | | Smear | `ec.distort.smear` | 8 | | 32 | Implemented | | Spherize | `ec.distort.spherize` | 2 | | 32 | Implemented | -| Transform | `ec.distort.transform` | 10 | GPU | 32 | Partial: no shutter-angle motion blur | -| Turbulent Displace | `ec.distort.turbulentdisplace` | 11 | GPU | 32 | Partial: no locked pinning variants | +| Transform | `ec.distort.transform` | 12 | GPU | 32 | Implemented | +| Turbulent Displace | `ec.distort.turbulentdisplace` | 11 | GPU | 32 | Implemented | | Twirl | `ec.distort.twirl` | 3 | GPU | 32 | Implemented | | Twirl (Legacy) | `ec.distort.twirllegacy` | 3 | | 32 | Implemented | | Warp | `ec.distort.warp` | 5 | | 32 | Implemented | @@ -189,7 +189,7 @@ Effects are addressed by id (`ec..`) or display name in commands | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| | 4-Color Gradient | `ec.generate.fourcolor` | 12 | GPU | 32 | Implemented | -| Advanced Lightning | `ec.generate.advancedlightning` | 16 | | 32 | Partial: no Alpha Obstacle; most Expert Settings missing | +| Advanced Lightning | `ec.generate.advancedlightning` | 24 | | 32 | Implemented | | Audio Spectrum | `ec.generate.audiospectrum` | 23 | | 32 | Implemented | | Audio Waveform | `ec.generate.audiowaveform` | 17 | | 32 | Implemented | | Beam | `ec.generate.beam` | 11 | | 32 | Implemented | @@ -198,19 +198,19 @@ Effects are addressed by id (`ec..`) or display name in commands | CC Light Rays | `ec.generate.cclightrays` | 8 | | 32 | Implemented | | CC Light Sweep | `ec.generate.cclightsweep` | 9 | | 32 | Implemented | | CC Threads | `ec.generate.ccthreads` | 8 | | 32 | Implemented | -| Cell Pattern | `ec.generate.cellpattern` | 14 | GPU | 32 | Partial: HQ variants render like the standard ones | +| Cell Pattern | `ec.generate.cellpattern` | 14 | GPU | 32 | Implemented | | Checkerboard | `ec.generate.checkerboard` | 10 | GPU | 32 | Implemented | | Circle | `ec.generate.circle` | 11 | | 32 | Implemented | | Ellipse | `ec.generate.ellipse` | 8 | | 32 | Implemented | | Eyedropper Fill | `ec.generate.eyedropperfill` | 5 | | 32 | Implemented | -| Fill | `ec.generate.fill` | 3 | GPU | 32 | Partial: no Fill Mask / All Masks / feather | +| Fill | `ec.generate.fill` | 7 | GPU | 32 | Implemented | | Fractal | `ec.generate.fractal` | 21 | | 32 | Implemented | | Gradient Ramp | `ec.generate.gradientramp` | 7 | GPU | 32 | Implemented | | Grid | `ec.generate.grid` | 12 | GPU | 32 | Implemented | | Lens Flare | `ec.generate.lensflare` | 4 | | 32 | Implemented | | Paint Bucket | `ec.generate.paintbucket` | 11 | | 32 | Implemented | -| Radio Waves | `ec.generate.radiowaves` | 17 | | 32 | Partial: no Image Contours or Mask wave types, Parameters Are Set At, reflection or stroke profile | -| Scribble | `ec.generate.scribble` | 21 | | 32 | Partial: no caps, joins or Start/End Apply To | +| Radio Waves | `ec.generate.radiowaves` | 31 | | 32 | Implemented | +| Scribble | `ec.generate.scribble` | 25 | | 32 | Implemented | | Stroke | `ec.generate.stroke` | 11 | | 32 | Implemented | | Vegas | `ec.generate.vegas` | 26 | | 32 | Implemented | | Write-on | `ec.generate.writeon` | 10 | | 32 | Implemented | @@ -222,9 +222,9 @@ Effects are addressed by id (`ec..`) or display name in commands | VR Blur | `ec.vr.blur` | 2 | | 32 | Implemented | | VR Chromatic Aberrations | `ec.vr.chromaticaberrations` | 8 | | 32 | Implemented | | VR Color Gradients | `ec.vr.colorgradients` | 19 | | 32 | Implemented | -| VR Converter | `ec.vr.converter` | 8 | | 32 | Partial: common layouts only | +| VR Converter | `ec.vr.converter` | 8 | | 32 | Implemented | | VR De-Noise | `ec.vr.denoise` | 4 | | 32 | Implemented | -| VR Digital Glitch | `ec.vr.digitalglitch` | 8 | | 32 | Partial: reduced control set | +| VR Digital Glitch | `ec.vr.digitalglitch` | 21 | | 32 | Implemented | | VR Fractal Noise | `ec.vr.fractalnoise` | 16 | | 32 | Implemented | | VR Glow | `ec.vr.glow` | 7 | | 32 | Implemented | | VR Plane to Sphere | `ec.vr.planetosphere` | 6 | | 32 | Implemented | @@ -236,15 +236,15 @@ Effects are addressed by id (`ec..`) or display name in commands | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| -| Advanced Spill Suppressor | `ec.key.advancedspill` | 5 | | 32 | Partial: no Tolerance, Desaturate or Spill Color Correction | +| Advanced Spill Suppressor | `ec.key.advancedspill` | 8 | | 32 | Implemented | | CC Simple Wire Removal | `ec.key.ccsimplewireremoval` | 7 | | 32 | Implemented | | Color Difference Key | `ec.key.colordifference` | 16 | | 32 | Implemented | | Color Range | `ec.key.colorrange` | 8 | | 32 | Implemented | | Difference Matte | `ec.key.differencematte` | 6 | | 32 | Implemented | | Extract | `ec.key.extract` | 6 | | 32 | Implemented | -| Inner/Outer Key | `ec.key.innerouter` | 10 | | 32 | Partial: one additional mask per side; no cleanup strokes | +| Inner/Outer Key | `ec.key.innerouter` | 88 | | 32 | Implemented | | Key Cleaner | `ec.key.keycleaner` | 4 | | 32 | Implemented | -| Key Light | `ec.keying.keylight` | 42 | GPU | 32 | Partial: no Source Crops X/Y Method or Edge Colour; no colour suppression / balancing | +| Key Light | `ec.keying.keylight` | 56 | GPU | 32 | Implemented | | Linear Color Key | `ec.key.linearcolor` | 6 | GPU | 32 | Implemented | | Screen Key | `ec.key.screen` | 11 | | 32 | Implemented | | Unmult | `ec.key.unmult` | 6 | | 32 | Implemented | @@ -255,8 +255,8 @@ Effects are addressed by id (`ec..`) or display name in commands |---|---|---:|:-:|:-:|---| | Matte Choker | `ec.matte.mattechoker` | 7 | | 32 | Implemented | | Mocha shape | `ec.obsolete.mochashape` | 6 | | 32 | Implemented | -| Refine Hard Matte | `ec.matte.refinehard` | 11 | | 32 | Partial: no Reduce Chatter or motion blur | -| Refine Soft Matte | `ec.matte.refinesoft` | 13 | | 32 | Partial: no Reduce Chatter or motion blur | +| Refine Hard Matte | `ec.matte.refinehard` | 16 | | 32 | Implemented | +| Refine Soft Matte | `ec.matte.refinesoft` | 18 | | 32 | Implemented | | Roto Brush & Refine Edge | `ec.matte.rotobrush` | 24 | | 32 | Implemented | | Simple Choker | `ec.matte.simplechoker` | 2 | | 32 | Implemented | @@ -264,18 +264,18 @@ Effects are addressed by id (`ec..`) or display name in commands | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| -| Add Grain | `ec.noise.addgrain` | 22 | GPU | 32 | Partial: no preview region, presets, channel balance or temporal controls | +| Add Grain | `ec.noise.addgrain` | 32 | GPU | 32 | Implemented | | Curl Noise | `ec.noise.curlnoise` | 10 | | 32 | Implemented | | Dust & Scratches | `ec.noise.dustscratches` | 3 | | 32 | Implemented | | Fractal Noise | `ec.noise.fractal` | 26 | GPU | 32 | Implemented | -| Match Grain | `ec.noise.matchgrain` | 25 | | 32 | Partial: no preview region, presets, sampling or temporal controls | +| Match Grain | `ec.noise.matchgrain` | 37 | | 32 | Implemented | | Median | `ec.noise.median` | 2 | | 32 | Implemented | | Median (Legacy) | `ec.noise.medianlegacy` | 2 | | 32 | Implemented | | Noise | `ec.noise.noise` | 3 | GPU | 32 | Implemented | | Noise Alpha | `ec.noise.noisealpha` | 8 | | 32 | Implemented | | Noise HLS | `ec.noise.noisehls` | 6 | | 32 | Implemented | | Noise HLS Auto | `ec.noise.noisehlsauto` | 6 | | 32 | Implemented | -| Remove Grain | `ec.noise.removegrain` | 7 | | 32 | Partial: no preview region, sampling or temporal filtering | +| Remove Grain | `ec.noise.removegrain` | 20 | | 32 | Implemented | | Turbulent Noise | `ec.noise.turbulent` | 22 | | 32 | Implemented | ## Obsolete @@ -288,7 +288,7 @@ Effects are addressed by id (`ec..`) or display name in commands | Gaussian Blur (Legacy) | `ec.obsolete.gaussianlegacy` | 2 | | 32 | Implemented | | Lightning | `ec.obsolete.lightning` | 25 | | 32 | Implemented | | Luma Key | `ec.key.luma` | 5 | | 32 | Implemented | -| Path Text | `ec.obsolete.pathtext` | 21 | | 32 | Partial: no kerning, shear/scale, line spacing, fade time or jitter | +| Path Text | `ec.obsolete.pathtext` | 32 | | 32 | Implemented | | Reduce Interlace Flicker | `ec.blur.reduceflicker` | 1 | | 32 | Implemented | | Spill Suppressor | `ec.key.spill` | 3 | | 32 | Implemented | @@ -329,12 +329,12 @@ Effects are addressed by id (`ec..`) or display name in commands | CC Scatterize | `ec.sim.ccscatterize` | 4 | | 32 | Implemented | | CC Snowfall | `ec.sim.ccsnowfall` | 19 | | 32 | Implemented | | CC Star Burst | `ec.sim.ccstarburst` | 6 | | 32 | Implemented | -| Card Dance | `ec.sim.carddance` | 35 | | 32 | Partial: no camera system, corner pins or lighting/material controls | -| Caustics | `ec.sim.caustics` | 21 | | 32 | Partial: no Sky group or light type | -| Foam | `ec.sim.foam` | 24 | | 32 | Partial: no wobble, pop velocity, custom bubble texture, environment map or flow map | -| Particle Playground | `ec.sim.particleplayground` | 37 | | 32 | Partial: no Particle Exploder, Ephemeral Property Mapper, Affects groups or text particles | -| Shatter | `ec.sim.shatter` | 26 | | 32 | Partial: no custom shatter map, gradient, textures or extrusion rendering | -| Wave World | `ec.sim.waveworld` | 27 | | 32 | Partial: no wireframe controls, dry-area rendering or ground group | +| Card Dance | `ec.sim.carddance` | 53 | | 32 | Implemented | +| Caustics | `ec.sim.caustics` | 28 | | 32 | Implemented | +| Foam | `ec.sim.foam` | 36 | | 32 | Implemented | +| Particle Playground | `ec.sim.particleplayground` | 92 | | 32 | Implemented | +| Shatter | `ec.sim.shatter` | 60 | | 32 | Implemented | +| Wave World | `ec.sim.waveworld` | 35 | | 32 | Implemented | ## Stylize @@ -352,11 +352,11 @@ Effects are addressed by id (`ec..`) or display name in commands | CC Threshold | `ec.stylize.ccthreshold` | 4 | | 32 | Implemented | | CC Threshold RGB | `ec.stylize.ccthresholdrgb` | 7 | | 32 | Implemented | | CC Vignette | `ec.stylize.ccvignette` | 4 | | 32 | Implemented | -| Cartoon | `ec.stylize.cartoon` | 11 | | 32 | Partial: no Edge Enhancement | +| Cartoon | `ec.stylize.cartoon` | 12 | | 32 | Implemented | | Color Emboss | `ec.stylize.coloremboss` | 4 | | 32 | Implemented | | Emboss | `ec.stylize.emboss` | 4 | GPU | 32 | Implemented | | Find Edges | `ec.stylize.findedges` | 2 | GPU | 32 | Implemented | -| Glow | `ec.stylize.glow` | 13 | GPU | 32 | Partial: no Glow Operation or arbitrary colour map | +| Glow | `ec.stylize.glow` | 14 | GPU | 32 | Implemented | | Mosaic | `ec.stylize.mosaic` | 3 | GPU | 32 | Implemented | | Motion Tile | `ec.stylize.motiontile` | 8 | | 32 | Implemented | | Posterize | `ec.stylize.posterize` | 1 | | 32 | Implemented | @@ -384,13 +384,13 @@ Effects are addressed by id (`ec..`) or display name in commands | Posterize Time | `ec.time.posterizetime` | 1 | | 32 | Implemented | | Time Difference | `ec.time.timedifference` | 5 | | 32 | Implemented | | Time Displacement | `ec.time.timedisplacement` | 4 | | 32 | Implemented | -| Timewarp | `ec.time.timewarp` | 10 | | 32 | Partial: Pixel Motion blends frames (no optical flow); no matte, warp or crop controls | +| Timewarp | `ec.time.timewarp` | 28 | | 32 | Implemented | ## Transition | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| -| Block Dissolve | `ec.transition.blockdissolve` | 4 | | 32 | Partial: no Soft Edges option | +| Block Dissolve | `ec.transition.blockdissolve` | 5 | | 32 | Implemented | | CC Glass Wipe | `ec.transition.ccglasswipe` | 5 | | 32 | Implemented | | CC Grid Wipe | `ec.transition.ccgridwipe` | 7 | | 32 | Implemented | | CC Image Wipe | `ec.transition.ccimagewipe` | 7 | | 32 | Implemented | @@ -401,7 +401,7 @@ Effects are addressed by id (`ec..`) or display name in commands | CC Scale Wipe | `ec.transition.ccscalewipe` | 3 | | 32 | Implemented | | CC Twister | `ec.transition.cctwister` | 5 | | 32 | Implemented | | CC WarpoMatic | `ec.transition.ccwarpomatic` | 8 | | 32 | Implemented | -| Card Wipe | `ec.transition.cardwipe` | 12 | | 32 | Partial: 2D flip only; no back layer picker, camera, lighting or jitter | +| Card Wipe | `ec.transition.cardwipe` | 44 | | 32 | Implemented | | Gradient Wipe | `ec.transition.gradientwipe` | 5 | GPU | 32 | Implemented | | Iris Wipe | `ec.transition.iriswipe` | 7 | | 32 | Implemented | | Linear Wipe | `ec.transition.linearwipe` | 3 | GPU | 32 | Implemented |