M13.22: GPU time effects (Time Difference, Time Displacement, CC Force Motion Blur, CC Wide Time, Pixel Motion Blur, Timewarp's frame building; motion vectors and frame fetches on the CPU)

Timewarp without a Matte Layer is now planned once (time_fx::timewarp_plan: frames, shutter
instants, motion fields) and built by the CPU or the GPU from the plan; with a Matte Layer it
renders on the CPU (catalog::gpu_supported).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-03 17:57:03 -04:00
1 parent d0b92948ee
commit a909de43b7
10 files changed
+662 -29

No files matched your search

+4 -2
View File
@@ -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.
+8 -1
View File
@@ -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
+122 -19
View File
@@ -680,23 +680,56 @@ fn fetch_layer(ctx: &EffectCtx, id: &str, times: &[f64], grid: &Buf) -> Option<V
)
}
/// 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;
/// 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<Image>,
pub steps: Vec<(TwStep, f32)>,
pub flows: Vec<Flow>,
pub pm: PixelMotion,
}
impl TimewarpPlan {
pub fn render_cpu(&self) -> Buf {
let f = &self.frames;
let imgs: Vec<Image> = 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<f32> = 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>, 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<f64> = 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<TimewarpPlan> {
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, &times)?;
let pm = PixelMotion::from(ctx, fr.grid.scale);
let warps = fetch_layer(ctx, "warpLayer", &times, &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, &times) else { return b };
let grid = fr.grid.clone();
let frame = |t: f64| -> &Image {
+2
View File
@@ -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)| {
+1
View File
@@ -136,6 +136,7 @@ fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
_ 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),
}
}
+223
View File
@@ -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<GBuf> {
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::<Vec<_>>())
}
"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::<Vec<_>>())
}
"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<f64> = (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, &times)
}
"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<GpuImage> {
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<GBuf> {
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::<Option<Vec<_>>>()?;
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<GBuf> {
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<GBuf> {
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<i32> = 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<f64> = keys.iter().map(|k| ctx.time + *k as f64 * step).collect();
let Some((fgrid, frames)) = effectcraft_effects::time_frames(ctx, &times) 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<u8> = 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<GBuf> {
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<Option<GpuImage>> = vec![None; plan.frames.len()];
let mut frame = |e: &mut Enc, i: usize| -> Option<GpuImage> {
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<f32> = 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<f32> = plan.steps.iter().map(|(_, w)| *w).collect();
average(e, &imgs, &weights)?
};
Some(on_grid(img, &plan.grid))
}
+3
View File
@@ -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;
+176
View File
@@ -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<i32>) -> bool {
let d = out_dims();
return p.x < d.x && p.y < d.y;
}
// util::unpremul: (straight colour, alpha).
fn ftm_unpremul(p: vec4<f32>) -> vec4<f32> {
if (p.w > 1e-6) {
return vec4<f32>(p.xyz / p.w, p.w);
}
return vec4<f32>(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<u32>) {
let p = vec2<i32>(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<u32>) {
let p = vec2<i32>(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<u32>) {
let p = vec2<i32>(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<f32>(0.0), vec3<f32>(1.0));
if (P.u[0].x != 0u) {
rgb = clamp(abs(diff) * gain, vec3<f32>(0.0), vec3<f32>(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<f32>(1.0);
}
default: {
a = ca;
}
}
a = clamp(a, 0.0, 1.0);
textureStore(out, p, vec4<f32>(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<u32>) {
let p = vec2<i32>(gid.xy);
if (!ftm_inside(p)) {
return;
}
let k = bitcast<u32>(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<i32>(P.u[0].z), p.y + bitcast<i32>(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<f32> {
let cols = P.u[0].x;
let rows = P.u[0].y;
if (cols == 0u || rows == 0u) {
return vec2<f32>(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<f32>(data[(y0 * cols + x0) * 2u], data[(y0 * cols + x0) * 2u + 1u]);
let g10 = vec2<f32>(data[(y0 * cols + x1) * 2u], data[(y0 * cols + x1) * 2u + 1u]);
let g01 = vec2<f32>(data[(y1 * cols + x0) * 2u], data[(y1 * cols + x0) * 2u + 1u]);
let g11 = vec2<f32>(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<f32>, x: f32, y: f32) -> vec4<f32> {
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<u32>) {
let p = vec2<i32>(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);
}
+116
View File
@@ -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"));
}
+7 -7
View File
@@ -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.<category>.<name>`) 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