diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 102bd84e..36479261 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -94,12 +94,14 @@ const CPU_ONLY_CONTROLS: &[(&str, &[&str])] = &[ ]; /// 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). +/// [`CPU_ONLY_CONTROLS`]; plus Cell Pattern's HQ variants, Turbulent Displace's locked +/// pinning, Warp's strong Fisheye / Twist and Timewarp with a Matte Layer). 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, + // Timewarp's Matte Layer (foreground and background on their own vectors). + "ec.time.timewarp" if ctx.params.get("matteLayer").and_then(effectcraft_keyframe::Value::as_layer).is_some() => return false, // Fisheye / Twist (a crease at the unit circle) bent past 50 % or with Horizontal / // Vertical Distortion: Newton's inverse wanders chaotically before converging near the // fold, so f32 lands on other pixels than the CPU's f64. diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 4b82cf20..e8126fb2 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -95,7 +95,7 @@ pub use transition::place_layer; pub use noise::remove_grain_level; pub use noise3::{FractalGpu, fractal_gpu}; pub use textfx::{TextLayer, text_layer}; -pub use time_fx::{posterized_time, time_frames}; +pub use time_fx::{PixelMotion, TimewarpPlan, TwStep, posterized_time, time_frames, timewarp_crop, timewarp_plan}; // effectcraft-gpu fx_tone. pub use ocio::color_stabilizer_maps; @@ -959,6 +959,13 @@ pub const GPU_EFFECTS: &[&str] = &[ // effectcraft-gpu fx_text (glyph coverage rasterised on the CPU) "ec.text.numbers", "ec.text.timecode", + // effectcraft-gpu fx_time (frames fetched by the host, combined on the GPU) + "ec.time.timedifference", + "ec.time.timedisplacement", + "ec.time.ccforcemotionblur", + "ec.time.ccwidetime", + "ec.time.pixelmotionblur", + "ec.time.timewarp", ]; /// Effects whose output depends on [`EffectCtx::time`] directly (not only through animated diff --git a/crates/effects/src/time_fx.rs b/crates/effects/src/time_fx.rs index e01283b9..a151e90d 100644 --- a/crates/effects/src/time_fx.rs +++ b/crates/effects/src/time_fx.rs @@ -680,23 +680,56 @@ fn fetch_layer(ctx: &EffectCtx, id: &str, times: &[f64], grid: &Buf) -> Option Buf { - if ctx.env.host.is_none() { - return b; +/// One output instant of a Timewarp without a Matte Layer: a fetched frame, a cross-fade of two, +/// or the in-between frame built along a motion field ([`TimewarpPlan::flows`]). +#[derive(Clone, Copy, Debug)] +pub enum TwStep { + Whole(usize), + Mix(usize, usize, f32), + Motion { a: usize, b: usize, w: f32, flow: usize }, +} + +/// Timewarp without a Matte Layer, prepared for the GPU compositor (effectcraft-gpu `fx_time`): +/// the fetched frames on one grid, the instants across the shutter (with their weights) and the +/// motion fields (block matching on the CPU). [`TimewarpPlan::render_cpu`] is the CPU effect. +pub struct TimewarpPlan { + pub grid: Buf, + pub frames: Vec, + pub steps: Vec<(TwStep, f32)>, + pub flows: Vec, + pub pm: PixelMotion, +} + +impl TimewarpPlan { + pub fn render_cpu(&self) -> Buf { + let f = &self.frames; + let imgs: Vec = self + .steps + .iter() + .map(|(s, _)| match *s { + TwStep::Whole(i) => f[i].clone(), + TwStep::Mix(i, j, w) => effectcraft_raster::flow::mix(&f[i], &f[j], w), + TwStep::Motion { a, b, w, flow } => timewarp_interpolate(&f[a], &f[b], w, &self.flows[flow], &self.pm), + }) + .collect(); + let weights: Vec = self.steps.iter().map(|(_, w)| *w).collect(); + let img = if imgs.len() == 1 { imgs.into_iter().next().unwrap_or_default() } else { average(&imgs, &weights) }; + with_img(self.grid.clone(), img) } +} + +/// Source-crop the edges of a frame (Pixel Motion works on cropped frames). +pub fn timewarp_crop(img: &Image, pm: &PixelMotion) -> Image { + crop_edges(img, pm.crops) +} + +/// The source instants Timewarp shows at the frame (with their weights) and the layer times +/// they need: (instants, frame times, frames per second). +fn timewarp_instants(ctx: &EffectCtx) -> (Vec<(f64, f32)>, Vec, f64) { let fps = ctx.fps(); let by_frame = ctx.params.e("adjustTimeBy") == 1; 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"); - let one = method > 0 && ctx.params.b("tuning/buildFromOneImage"); - // 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 }; @@ -709,18 +742,88 @@ fn timewarp(ctx: &EffectCtx, b: Buf) -> Buf { } else { 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); + let (f0, _) = tw_split(s, fps); times.push(f0 / fps); times.push((f0 + 1.0) / fps); } + (instants, times, fps) +} + +/// The frame at or before source time `s` and the fraction to the next one. +fn tw_split(s: f64, fps: f64) -> (f64, f32) { + let f = s * fps; + let f0 = (f + 1e-6).floor(); + (f0, ((f - f0) as f32).max(0.0)) +} + +/// [`TimewarpPlan`] of a Timewarp instance without a Matte Layer. `None` without the effect host +/// (the layer passes through) or with a Matte Layer. +pub fn timewarp_plan(ctx: &EffectCtx) -> Option { + ctx.env.host?; + if ctx.params.get("matteLayer").and_then(Value::as_layer).is_some() { + return None; + } + let (instants, times, fps) = timewarp_instants(ctx); + let method = ctx.params.e("method"); + let one = method > 0 && ctx.params.b("tuning/buildFromOneImage"); + let fr = fetch(ctx, ×)?; + let pm = PixelMotion::from(ctx, fr.grid.scale); + let warps = fetch_layer(ctx, "warpLayer", ×, &fr.grid); + let idx = |t: f64| times.iter().position(|q| (q - t).abs() < 1e-9).unwrap_or(0); + let mut flows: Vec<(i64, Flow)> = vec![]; + let mut steps = vec![]; + for &(s, wgt) in &instants { + let (f0, frac) = tw_split(s, fps); + let (i0, i1) = (idx(f0 / fps), idx((f0 + 1.0) / fps)); + let step = if method == 0 || frac < 1e-4 || (one && method == 1) { + let pick = if method == 0 || frac < 1e-4 { 0.0 } else { frac.round() }; + TwStep::Whole(if pick < 0.5 { i0 } else { i1 }) + } else if method == 1 { + TwStep::Mix(i0, i1, frac) + } else { + let key = f0 as i64; + let flow = match flows.iter().position(|(k, _)| *k == key) { + Some(k) => k, + None => { + let f = match &warps { + Some(w) => timewarp_flow(&w[i0], &w[i1], &pm), + None => timewarp_flow(&fr.imgs[i0], &fr.imgs[i1], &pm), + }; + flows.push((key, f)); + flows.len() - 1 + } + }; + TwStep::Motion { a: i0, b: i1, w: frac, flow } + }; + steps.push((step, wgt)); + } + Some(TimewarpPlan { grid: fr.grid, frames: fr.imgs, steps, flows: flows.into_iter().map(|(_, f)| f).collect(), pm }) +} + +/// 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; + } + if ctx.params.get("matteLayer").and_then(Value::as_layer).is_none() { + return match timewarp_plan(ctx) { + Some(plan) => plan.render_cpu(), + None => b, + }; + } + // Instants of source time (with weights) across the shutter; each needs the frame at or + // before it and the next one. + let (instants, times, fps) = timewarp_instants(ctx); + let split = |s: f64| tw_split(s, fps); + let method = ctx.params.e("method"); + let one = method > 0 && ctx.params.b("tuning/buildFromOneImage"); let Some(fr) = fetch(ctx, ×) else { return b }; let grid = fr.grid.clone(); let frame = |t: f64| -> &Image { diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index 28cc8b94..a8d2e372 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -317,6 +317,7 @@ impl GpuContext { include_str!("shaders/fx_light.wgsl"), include_str!("shaders/fx_transition.wgsl"), include_str!("shaders/fx_text.wgsl"), + include_str!("shaders/fx_time.wgsl"), ] .concat(); let module = @@ -379,6 +380,7 @@ impl GpuContext { .chain(crate::fx_light::KERNELS) .chain(crate::fx_transition::KERNELS) .chain(crate::fx_text::KERNELS) + .chain(crate::fx_time::KERNELS) .map(|e| (e, &layout)) .chain(EXT_ENTRIES.iter().map(|e| (e, &layout_ext))) .map(|(e, layout)| { diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index a28920e9..92536414 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -136,6 +136,7 @@ fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { _ if crate::fx_light::IDS.contains(&id) => crate::fx_light::apply(e, id, ctx, b), _ if crate::fx_transition::IDS.contains(&id) => crate::fx_transition::apply(e, id, ctx, b), _ if crate::fx_text::IDS.contains(&id) => crate::fx_text::apply(e, id, ctx, b), + _ if crate::fx_time::IDS.contains(&id) => crate::fx_time::apply(e, id, ctx, b), _ => pointwise(e, id, ctx, b), } } diff --git a/crates/gpu/src/fx_time.rs b/crates/gpu/src/fx_time.rs new file mode 100644 index 00000000..e94fb094 --- /dev/null +++ b/crates/gpu/src/fx_time.rs @@ -0,0 +1,223 @@ +//! GPU effects, time (kernels in `shaders/fx_time.wgsl`, every entry point prefixed `ftm_`): +//! Time Difference, Time Displacement, CC Force Motion Blur, CC Wide Time, Pixel Motion Blur and +//! Timewarp. Like Echo (`fx_noise`), the other frames come from the effect host on the CPU +//! (`effectcraft_effects::time_frames`: fetched at the CPU effect's layer times and placed on +//! one grid) and are uploaded; the kernels combine them with the CPU effect's arithmetic in its +//! order (weighted sums one frame per pass, so the float rounding matches). +//! +//! Time Displacement's per-pixel time (the map's luminance, quantised to the time resolution) +//! is computed on the CPU, since it decides which frames to fetch; the GPU gathers each pixel +//! from its frame. Timewarp's motion vectors (block matching and smoothing, +//! `effectcraft_raster::flow`) are estimated on the CPU (`effectcraft_effects::timewarp_plan`); +//! the frame building (Whole Frames, Frame Mix, Pixel Motion's warp along the vectors with its +//! error fallback, the shutter samples' average) runs on the GPU. Timewarp with a Matte Layer +//! renders on the CPU (`catalog::gpu_supported`). + +use effectcraft_effects::util::{fit_layer, unpremul}; +use effectcraft_effects::{Buf, EffectCtx, TwStep}; +use effectcraft_keyframe::Value; + +use crate::context::{Enc, GpuImage, Params}; +use crate::effects::GBuf; + +/// Compute entry points in `fx_time.wgsl`. +pub(crate) const KERNELS: &[&str] = &["ftm_acc", "ftm_mix", "ftm_difference", "ftm_displace", "ftm_motion"]; + +/// Effect ids implemented here. +pub(crate) const IDS: &[&str] = + &["ec.time.timedifference", "ec.time.timedisplacement", "ec.time.ccforcemotionblur", "ec.time.ccwidetime", "ec.time.pixelmotionblur", "ec.time.timewarp"]; + +/// Run effect `id` (one of [`IDS`]); `None` = this parameter combination runs on the CPU. +pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { + match id { + "ec.time.timedifference" => time_difference(e, ctx, b), + "ec.time.timedisplacement" => time_displacement(e, ctx, b), + "ec.time.ccforcemotionblur" => { + let n = ctx.params.f("motionBlurLevels").round().clamp(2.0, 64.0) as usize; + let angle = if ctx.params.b("overrideShutterAngle") { ctx.params.f("shutterAngle") } else { 180.0 }; + let span = angle.clamp(0.0, 3600.0) / 360.0 / ctx.fps(); + mean_of(e, ctx, b, &(0..n).map(|i| ctx.time + span * i as f64 / n as f64).collect::>()) + } + "ec.time.ccwidetime" => { + let fwd = ctx.params.f("forwardSteps").round().clamp(0.0, 45.0) as i64; + let back = ctx.params.f("backwardSteps").round().clamp(0.0, 45.0) as i64; + let fd = 1.0 / ctx.fps(); + mean_of(e, ctx, b, &(-back..=fwd).map(|k| ctx.time + k as f64 * fd).collect::>()) + } + "ec.time.pixelmotionblur" => { + let angle = if ctx.params.e("shutterControl") == 1 { ctx.params.f("shutterAngle") } else { 180.0 }; + let n = ctx.params.f("shutterSamples").round().clamp(1.0, 64.0) as usize; + let span = angle.clamp(0.0, 720.0) / 360.0 / ctx.fps(); + let times: Vec = (0..n).map(|i| ctx.time - span * 0.5 + if n > 1 { span * i as f64 / (n - 1) as f64 } else { span * 0.5 }).collect(); + mean_of(e, ctx, b, ×) + } + "ec.time.timewarp" => timewarp(e, ctx, b), + _ => None, + } +} + +fn kernel(e: &mut Enc, entry: &str, p: &Params, src: &GpuImage, aux: Option<&GpuImage>, data: Option<&wgpu::Buffer>) -> GpuImage { + let out = e.scratch(src.width, src.height); + e.pixels(entry, p, src, aux, &out, data); + out +} + +/// time_fx::average: Σ frame × weight, one frame per pass in the CPU's order. +fn average(e: &mut Enc, frames: &[GpuImage], weights: &[f32]) -> Option { + let first = frames.first()?; + let mut acc = e.zeros(first.width, first.height); + for (f, &k) in frames.iter().zip(weights) { + let mut p = Params::default(); + p.f[0][0] = k; + acc = kernel(e, "ftm_acc", &p, &acc, Some(f), None); + } + Some(acc) +} + +fn on_grid(img: GpuImage, grid: &Buf) -> GBuf { + GBuf { img, offset: grid.offset, scale: grid.scale } +} + +/// time_fx::mean_of: the plain average of the layer at `times`. +fn mean_of(e: &mut Enc, ctx: &EffectCtx, b: GBuf, times: &[f64]) -> Option { + if times.is_empty() { + return Some(b); + } + let Some((grid, imgs)) = effectcraft_effects::time_frames(ctx, times) else { return Some(b) }; + let frames = imgs.iter().map(|i| e.g.upload_image(i)).collect::>>()?; + let k = 1.0 / times.len() as f32; + let img = average(e, &frames, &vec![k; times.len()])?; + Some(on_grid(img, &grid)) +} + +// ---------------------------------------------------------------- Time Difference + +fn time_difference(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let Some(host) = ctx.env.host else { return Some(b) }; + let pr = ctx.params; + let off = pr.f("timeOffset"); + let target_id = pr.get("targetLayer").and_then(Value::as_layer); + let (grid, cur, target) = match target_id.and_then(|id| host.layer_at(id, ctx.env.comp_time + off, false)) { + Some(other) => { + let Some((grid, mut imgs)) = effectcraft_effects::time_frames(ctx, &[ctx.time]) else { return Some(b) }; + let cur = imgs.pop().unwrap_or_default(); + let tgt = fit_layer(ctx, &Buf { img: cur.clone(), ..grid.clone() }, &other, true); + (grid, cur, tgt) + } + None => { + let Some((grid, mut imgs)) = effectcraft_effects::time_frames(ctx, &[ctx.time, ctx.time + off]) else { return Some(b) }; + let tgt = imgs.pop().unwrap_or_default(); + let cur = imgs.pop().unwrap_or_default(); + (grid, cur, tgt) + } + }; + let (cur, target) = (e.g.upload_image(&cur)?, e.g.upload_image(&target)?); + let mut p = Params::default(); + p.u[0] = [pr.b("absoluteDifference") as u32, pr.e("alphaChannel"), 0, 0]; + p.f[0][0] = 1.0 + pr.f("contrast") as f32 / 25.0; + let img = kernel(e, "ftm_difference", &p, &cur, Some(&target), None); + Some(on_grid(img, &grid)) +} + +// ---------------------------------------------------------------- Time Displacement + +fn time_displacement(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + if ctx.env.host.is_none() { + return Some(b); + } + let pr = ctx.params; + let max = pr.f("maxDisplacementTime"); + let res = pr.f("timeResolution").clamp(1.0, 999.0); + let Some((grid, mut imgs)) = effectcraft_effects::time_frames(ctx, &[ctx.time]) else { return Some(b) }; + let grid = Buf { img: imgs.pop().unwrap_or_default(), ..grid }; + let map = match ctx.layer_param("displacementMapLayer", true) { + Some(o) => fit_layer(ctx, &grid, &o, pr.b("stretchMap")), + None => grid.img.clone(), + }; + // The CPU effect's per-pixel time step (luminance → −max..+max, quantised). + let span = 2.0 * max.abs() * res; + let step = if span > 63.0 { 2.0 * max.abs() / 63.0 } else { 1.0 / res }; + let idx: Vec = map + .data + .iter() + .map(|p| { + let (c, a) = unpremul(*p); + let l = if a > 0.0 { effectcraft_color::luminance(c[0], c[1], c[2]) } else { 0.5 }; + (((l as f64 - 0.5) * 2.0 * max) / step).round() as i32 + }) + .collect(); + let mut keys = idx.clone(); + keys.sort_unstable(); + keys.dedup(); + if keys == [0] { + return Some(on_grid(e.g.upload_image(&grid.img)?, &grid)); + } + let times: Vec = keys.iter().map(|k| ctx.time + *k as f64 * step).collect(); + let Some((fgrid, frames)) = effectcraft_effects::time_frames(ctx, ×) else { return Some(on_grid(e.g.upload_image(&grid.img)?, &grid)) }; + let dx = (fgrid.offset[0] - grid.offset[0]).round() as i32; + let dy = (fgrid.offset[1] - grid.offset[1]).round() as i32; + let which: Vec = idx.iter().flat_map(|k| (keys.binary_search(k).unwrap_or(0) as u32).to_le_bytes()).collect(); + let data = e.bytes(which); + let (w, h) = (grid.img.width, grid.img.height); + let mut acc = e.zeros(w, h); + for (fi, f) in frames.iter().enumerate() { + let frame = e.g.upload_image(f)?; + let mut p = Params::default(); + p.u[0] = [fi as u32, w, dx as u32, dy as u32]; + let out = e.scratch(w, h); + e.pixels("ftm_displace", &p, &acc, Some(&frame), &out, Some(&data)); + acc = out; + } + Some(on_grid(acc, &grid)) +} + +// ---------------------------------------------------------------- Timewarp + +fn timewarp(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + if ctx.env.host.is_none() { + return Some(b); + } + // A Matte Layer splits foreground and background: the CPU's path. + if ctx.params.get("matteLayer").and_then(Value::as_layer).is_some() { + return None; + } + let Some(plan) = effectcraft_effects::timewarp_plan(ctx) else { return Some(b) }; + let mut up: Vec> = vec![None; plan.frames.len()]; + let mut frame = |e: &mut Enc, i: usize| -> Option { + if up[i].is_none() { + up[i] = Some(e.g.upload_image(&plan.frames[i])?); + } + up[i].clone() + }; + let mut imgs = vec![]; + for (step, _) in &plan.steps { + let img = match *step { + TwStep::Whole(i) => frame(e, i)?, + TwStep::Mix(i, j, w) => { + let (a, bb) = (frame(e, i)?, frame(e, j)?); + let mut p = Params::default(); + p.f[0][0] = w; + kernel(e, "ftm_mix", &p, &a, Some(&bb), None) + } + TwStep::Motion { a, b: bi, w, flow } => { + let pa = e.g.upload_image(&effectcraft_effects::timewarp_crop(&plan.frames[a], &plan.pm))?; + let pb = e.g.upload_image(&effectcraft_effects::timewarp_crop(&plan.frames[bi], &plan.pm))?; + let f = &plan.flows[flow]; + let v: Vec = f.v.iter().flat_map(|q| [q[0], q[1]]).collect(); + let data = e.data(&v); + let mut p = Params::default(); + p.u[0] = [if f.v.is_empty() { 0 } else { f.cols as u32 }, f.rows as u32, plan.pm.extreme as u32, plan.pm.build_from_one as u32]; + p.f[0] = [f.block as f32, w, plan.pm.error_threshold, 0.0]; + kernel(e, "ftm_motion", &p, &pa, Some(&pb), Some(&data)) + } + }; + imgs.push(img); + } + let img = if imgs.len() == 1 { + imgs.pop()? + } else { + let weights: Vec = plan.steps.iter().map(|(_, w)| *w).collect(); + average(e, &imgs, &weights)? + }; + Some(on_grid(img, &plan.grid)) +} diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index d6ee1c98..3c75ba88 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -56,6 +56,7 @@ mod fx_light; mod fx_noise; mod fx_stylize; mod fx_text; +mod fx_time; mod fx_tone; mod fx_transition; mod fx_warp; @@ -220,6 +221,8 @@ mod tests_fx_stylize; #[cfg(test)] mod tests_fx_text; #[cfg(test)] +mod tests_fx_time; +#[cfg(test)] mod tests_fx_tone; #[cfg(test)] mod tests_fx_transition; diff --git a/crates/gpu/src/shaders/fx_time.wgsl b/crates/gpu/src/shaders/fx_time.wgsl new file mode 100644 index 00000000..c65dccae --- /dev/null +++ b/crates/gpu/src/shaders/fx_time.wgsl @@ -0,0 +1,176 @@ +// GPU effects, time: see src/fx_time.rs. The frames come from the effect host (fetched and +// placed on one grid on the CPU, as Echo's); these kernels combine them as the CPU effects do. + +fn ftm_inside(p: vec2) -> bool { + let d = out_dims(); + return p.x < d.x && p.y < d.y; +} + +// util::unpremul: (straight colour, alpha). +fn ftm_unpremul(p: vec4) -> vec4 { + if (p.w > 1e-6) { + return vec4(p.xyz / p.w, p.w); + } + return vec4(0.0, 0.0, 0.0, max(p.w, 0.0)); +} + +// Weighted sum, one frame per pass (time_fx::average): src + aux × f[0].x. +@compute @workgroup_size(16, 16) +fn ftm_acc(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!ftm_inside(p)) { + return; + } + textureStore(out, p, textureLoad(src, p, 0) + textureLoad(aux, p, 0) * P.f[0].x); +} + +// Cross-fade (raster::flow::mix): src + (aux − src) × f[0].x. +@compute @workgroup_size(16, 16) +fn ftm_mix(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!ftm_inside(p)) { + return; + } + let a = textureLoad(src, p, 0); + textureStore(out, p, a + (textureLoad(aux, p, 0) - a) * P.f[0].x); +} + +// ---------------------------------------------------------------- Time Difference (time_fx::time_difference) +// src = the layer now, aux = the target. u[0] = (absolute difference, alpha channel); +// f[0].x = contrast gain. + +@compute @workgroup_size(16, 16) +fn ftm_difference(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!ftm_inside(p)) { + return; + } + let cu = ftm_unpremul(textureLoad(src, p, 0)); + let tu = ftm_unpremul(textureLoad(aux, p, 0)); + let c = cu.xyz; + let d = tu.xyz; + let ca = cu.w; + let da = tu.w; + let gain = P.f[0].x; + let diff = c - d; + var rgb = clamp(0.5 + diff * 0.5 * gain, vec3(0.0), vec3(1.0)); + if (P.u[0].x != 0u) { + rgb = clamp(abs(diff) * gain, vec3(0.0), vec3(1.0)); + } + let mx = max(max(rgb.x, rgb.y), rgb.z); + let light = (mx + min(min(rgb.x, rgb.y), rgb.z)) * 0.5; + var a: f32; + switch P.u[0].y { + case 1u: { + a = da; + } + case 2u: { + a = (ca + da) * 0.5; + } + case 3u: { + a = max(ca, da); + } + case 4u: { + a = 1.0; + } + case 5u: { + a = light; + } + case 6u: { + a = mx; + } + case 7u: { + a = abs(ca - da) * gain; + } + case 8u: { + a = clamp(abs(ca - da) * gain, 0.0, 1.0); + rgb = vec3(1.0); + } + default: { + a = ca; + } + } + a = clamp(a, 0.0, 1.0); + textureStore(out, p, vec4(rgb * a, a)); +} + +// ---------------------------------------------------------------- Time Displacement (time_fx::time_displacement) +// One pass per sampled time: pixels whose frame index (data, u32 per pixel, row length u[0].y) +// is u[0].x take frame aux at (x + dx, y + dy) (u[0].zw, i32), the others keep src. + +@compute @workgroup_size(16, 16) +fn ftm_displace(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!ftm_inside(p)) { + return; + } + let k = bitcast(data[u32(p.y) * P.u[0].y + u32(p.x)]); + if (k == P.u[0].x) { + textureStore(out, p, tex_get(aux, p.x + bitcast(P.u[0].z), p.y + bitcast(P.u[0].w))); + } else { + textureStore(out, p, textureLoad(src, p, 0)); + } +} + +// ---------------------------------------------------------------- Timewarp Pixel Motion (time_fx::timewarp_interpolate) +// src = frame a, aux = frame b (both source-cropped); data = the motion vectors (x, y pairs). +// u[0] = (columns, rows, extreme filtering, build from one image); f[0] = (vector spacing, +// fraction w, error threshold). + +// raster::flow::Flow::at. +fn ftm_flow_at(x: f32, y: f32) -> vec2 { + let cols = P.u[0].x; + let rows = P.u[0].y; + if (cols == 0u || rows == 0u) { + return vec2(0.0); + } + let b = P.f[0].x; + let fx = clamp(x / b - 0.5, 0.0, f32(cols - 1u)); + let fy = clamp(y / b - 0.5, 0.0, f32(rows - 1u)); + let x0 = u32(floor(fx)); + let y0 = u32(floor(fy)); + let x1 = min(x0 + 1u, cols - 1u); + let y1 = min(y0 + 1u, rows - 1u); + let tx = fx - f32(x0); + let ty = fy - f32(y0); + let g00 = vec2(data[(y0 * cols + x0) * 2u], data[(y0 * cols + x0) * 2u + 1u]); + let g10 = vec2(data[(y0 * cols + x1) * 2u], data[(y0 * cols + x1) * 2u + 1u]); + let g01 = vec2(data[(y1 * cols + x0) * 2u], data[(y1 * cols + x0) * 2u + 1u]); + let g11 = vec2(data[(y1 * cols + x1) * 2u], data[(y1 * cols + x1) * 2u + 1u]); + let top = g00 + (g10 - g00) * tx; + let bot = g01 + (g11 - g01) * tx; + return top + (bot - top) * ty; +} + +fn ftm_sample(t: texture_2d, x: f32, y: f32) -> vec4 { + if (P.u[0].z != 0u) { + return sample_bicubic(t, x, y); + } + return sample_bilinear_clamped(t, x, y); +} + +@compute @workgroup_size(16, 16) +fn ftm_motion(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!ftm_inside(p)) { + return; + } + let cx = f32(p.x) + 0.5; + let cy = f32(p.y) + 0.5; + let f = ftm_flow_at(cx, cy); + let w = P.f[0].y; + var pa = ftm_sample(src, cx - w * f.x, cy - w * f.y); + var pb = ftm_sample(aux, cx + (1.0 - w) * f.x, cy + (1.0 - w) * f.y); + if (P.u[0].w != 0u) { + textureStore(out, p, select(pb, pa, w < 0.5)); + return; + } + let d = abs(pa - pb); + let err = max(max(d.x, d.y), max(d.z, d.w)); + let thr = P.f[0].z; + if (thr > 0.0 && err > thr && thr < 1.0) { + pa = textureLoad(src, p, 0); + pb = textureLoad(aux, p, 0); + } + textureStore(out, p, pa + (pb - pa) * w); +} diff --git a/crates/gpu/src/tests_fx_time.rs b/crates/gpu/src/tests_fx_time.rs new file mode 100644 index 00000000..bb29f737 --- /dev/null +++ b/crates/gpu/src/tests_fx_time.rs @@ -0,0 +1,116 @@ +//! GPU time effects (Time Difference, Time Displacement, CC Force Motion Blur, CC Wide Time, +//! Pixel Motion Blur, Timewarp) vs the CPU effects (the oracle). They fetch other frames through +//! the effect host: a precomp whose content moves, at a nonzero time, composited at 8 and +//! 32 bpc (the direct cases have no host and pass the layer through). + +use effectcraft_color::Label; +use effectcraft_keyframe::{Keyframe, Value}; +use effectcraft_project::{BitDepth, Comp, ItemKind, LayerSource, build}; +use effectcraft_render::RenderOpts; +use effectcraft_time::{FrameRate, Tick}; + +use crate::tests::{Scene, check, compare_at, effect_case, n, opts, set, v3}; + +fn e(v: u32) -> Value { + Value::Enum(v) +} + +fn on() -> Value { + Value::Bool(true) +} + +/// `id` with `vals` on a moving precomp; `map` adds a hidden footage layer and sets parameter +/// `map` to it. +fn moving(id: &str, vals: &[(&str, Value)], map: Option<&str>) { + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + let mut s = Scene::new(depth); + let bg = s.solid([0.15, 0.1, 0.2], 97, 61); + s.push(bg); + let mut vals = vals.to_vec(); + if let Some(param) = map { + let mut m = s.footage(50, 36); + m.switches.video = false; + let mid = s.push(m); + vals.push((param, Value::Layer(Some(mid.0)))); + } + let inner = Comp::new(60, 40, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let inner_id = s.p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.into())); + let mut a = s.footage(30, 24); + let pos = a.props.prop_mut("transform/position").unwrap(); + pos.keys = vec![Keyframe::new(Tick::ZERO, v3(10.0, 12.0)), Keyframe::new(Tick::from_seconds_f64(1.0), v3(50.0, 30.0))]; + let rot = a.props.prop_mut("transform/rotation").unwrap(); + rot.keys = vec![Keyframe::new(Tick::ZERO, Value::Scalar(0.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(90.0))]; + s.p.comp_mut(inner_id).unwrap().layers = vec![a]; + let mut l = build::layer(&mut s.p, &s.comp, "Inner", LayerSource::Comp { item: inner_id }, (60, 40), None); + s.effect(&mut l, id, &vals); + set(&mut l, "transform/rotation", Value::Scalar(8.0)); + s.push(l); + let t = Tick::from_seconds_f64(0.4); + let label = format!("{id} {vals:?} {depth:?}"); + check(&label, compare_at(&s, opts(), t), 0.0); + check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, t), 0.0); + } +} + +#[test] +fn without_a_host() { + for id in + ["ec.time.timedifference", "ec.time.timedisplacement", "ec.time.ccforcemotionblur", "ec.time.ccwidetime", "ec.time.pixelmotionblur", "ec.time.timewarp"] + { + effect_case(id, &[]); + } +} + +#[test] +fn frame_averages() { + moving("ec.time.ccforcemotionblur", &[], None); + moving("ec.time.ccforcemotionblur", &[("motionBlurLevels", n(5.0)), ("shutterAngle", n(400.0))], None); + moving("ec.time.ccwidetime", &[], None); + moving("ec.time.ccwidetime", &[("forwardSteps", n(1.0)), ("backwardSteps", n(4.0))], None); + moving("ec.time.pixelmotionblur", &[], None); + moving("ec.time.pixelmotionblur", &[("shutterControl", e(1)), ("shutterAngle", n(300.0)), ("shutterSamples", n(5.0))], None); +} + +#[test] +fn time_difference() { + moving("ec.time.timedifference", &[("timeOffset", n(-0.1))], None); + for alpha in 0..9 { + moving( + "ec.time.timedifference", + &[("timeOffset", n(0.2)), ("contrast", n(40.0)), ("absoluteDifference", Value::Bool(alpha % 2 == 0)), ("alphaChannel", e(alpha))], + None, + ); + } + moving("ec.time.timedifference", &[("timeOffset", n(0.1)), ("alphaChannel", e(2))], Some("targetLayer")); +} + +#[test] +fn time_displacement() { + moving("ec.time.timedisplacement", &[("maxDisplacementTime", n(0.3)), ("timeResolution", n(10.0))], None); + moving("ec.time.timedisplacement", &[("maxDisplacementTime", n(0.5)), ("timeResolution", n(30.0))], Some("displacementMapLayer")); + moving("ec.time.timedisplacement", &[("maxDisplacementTime", n(2.0)), ("stretchMap", Value::Bool(false))], Some("displacementMapLayer")); +} + +#[test] +fn timewarp() { + for method in 0..3 { + moving("ec.time.timewarp", &[("method", e(method)), ("speed", n(37.0))], None); + } + moving("ec.time.timewarp", &[("method", e(1)), ("adjustTimeBy", e(1)), ("sourceFrame", n(7.4))], None); + moving("ec.time.timewarp", &[("method", e(2)), ("speed", n(43.0)), ("motionBlur/enableMotionBlur", on()), ("motionBlur/shutterSamples", n(3.0))], None); + moving( + "ec.time.timewarp", + &[ + ("speed", n(61.0)), + ("tuning/filtering", e(1)), + ("tuning/errorThreshold", n(30.0)), + ("sourceCrops/leftCrop", n(4.0)), + ("sourceCrops/topCrop", n(3.0)), + ], + None, + ); + moving("ec.time.timewarp", &[("speed", n(55.0)), ("tuning/buildFromOneImage", on())], None); + moving("ec.time.timewarp", &[("speed", n(47.0))], Some("warpLayer")); + // A Matte Layer renders on the CPU (catalog::gpu_supported). + moving("ec.time.timewarp", &[("speed", n(47.0)), ("show", e(0))], Some("matteLayer")); +} diff --git a/docs/effects.md b/docs/effects.md index a44dfb90..ce11f72f 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -5,7 +5,7 @@ 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**: 190 effects also run on the GPU compositor with identical results. +- **GPU**: 196 effects also run on the GPU compositor with identical results. - **32**: 306 effects process 32-bit float (HDR, overbright) pixels without clamping. - **Status**: 306 implemented in full, 0 partial (what is missing is listed). @@ -377,14 +377,14 @@ Effects are addressed by id (`ec..`) or display name in commands | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| -| CC Force Motion Blur | `ec.time.ccforcemotionblur` | 4 | | 32 | Implemented | -| CC Wide Time | `ec.time.ccwidetime` | 3 | | 32 | Implemented | +| CC Force Motion Blur | `ec.time.ccforcemotionblur` | 4 | GPU | 32 | Implemented | +| CC Wide Time | `ec.time.ccwidetime` | 3 | GPU | 32 | Implemented | | Echo | `ec.time.echo` | 5 | GPU | 32 | Implemented | -| Pixel Motion Blur | `ec.time.pixelmotionblur` | 4 | | 32 | Implemented | +| Pixel Motion Blur | `ec.time.pixelmotionblur` | 4 | GPU | 32 | Implemented | | Posterize Time | `ec.time.posterizetime` | 1 | GPU | 32 | Implemented | -| Time Difference | `ec.time.timedifference` | 5 | | 32 | Implemented | -| Time Displacement | `ec.time.timedisplacement` | 4 | | 32 | Implemented | -| Timewarp | `ec.time.timewarp` | 28 | | 32 | Implemented | +| Time Difference | `ec.time.timedifference` | 5 | GPU | 32 | Implemented | +| Time Displacement | `ec.time.timedisplacement` | 4 | GPU | 32 | Implemented | +| Timewarp | `ec.time.timewarp` | 28 | GPU | 32 | Implemented | ## Transition