Merge pull request #695 from vbasky/fix/535-pixel-motion-vectors

Pixel Motion Blur: synthesize from motion vectors, honour Vector Detail (#535)
This commit is contained in:
Brandon Thomas
2026-10-12 03:02:42 -04:00
4 changed files with 195 additions and 12 deletions
+1 -1
View File
@@ -105,7 +105,7 @@ pub use noise::remove_grain_level;
pub use noise3::{FractalGpu, fractal_gpu};
pub use stylize3::{BumpLight, bump_layer_id};
pub use textfx::{TextLayer, text_layer};
pub use time_fx::{PixelMotion, TimewarpPlan, TwStep, posterized_time, time_frames, timewarp_crop, timewarp_plan};
pub use time_fx::{PixelMotion, TimewarpPlan, TwStep, pmb_plan, posterized_time, time_frames, timewarp_crop, timewarp_plan};
// effectcraft-gpu fx_tone.
pub use ocio::color_stabilizer_maps;
// GPU effects, part B (3D Channel, Immersive Video, colour management, simulation render passes).
+70 -4
View File
@@ -583,6 +583,13 @@ fn smooth_flow(flow: &mut Flow, pm: &PixelMotion) {
/// 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 {
timewarp_flow_search(a, b, pm, 4)
}
/// [`timewarp_flow`] with an explicit coarse search radius: Timewarp's own resampling needs
/// little (motion between adjacent source frames at near-100% speed), but Pixel Motion Blur
/// must find fast full-frame displacements (#535).
fn timewarp_flow_search(a: &Image, b: &Image, pm: &PixelMotion, radius: i32) -> 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)));
@@ -591,7 +598,7 @@ pub fn timewarp_flow(a: &Image, b: &Image, pm: &PixelMotion) -> Flow {
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);
let mut flow = effectcraft_raster::flow::block_flow_window(&ga, &gb, pm.tile, pm.window, radius);
smooth_flow(&mut flow, pm);
flow
}
@@ -923,12 +930,71 @@ fn wide_time(ctx: &EffectCtx, b: Buf) -> Buf {
mean_of(ctx, b, &times)
}
fn pixel_motion_blur(ctx: &EffectCtx, b: Buf) -> Buf {
fn shutter_times(ctx: &EffectCtx) -> Vec<f64> {
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(ctx, b, &times)
(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()
}
fn pixel_motion_blur(ctx: &EffectCtx, b: Buf) -> Buf {
match pmb_plan(ctx).as_ref().map(TimewarpPlan::render_cpu) {
Some(blur) => blur,
None => mean_of(ctx, b, &shutter_times(ctx)),
}
}
/// Pixel Motion Blur as a [`TimewarpPlan`]: each shutter instant is decoded between the two
/// source frames it falls between along a measured motion field (the same synthesis Timewarp
/// uses), and the instants average into the blur. Vector Detail sets the spacing of the motion
/// vectors, so a coarse grid follows one big motion and a fine grid separates neighbouring
/// ones (#535). `None` without a host — the caller then falls back. Shared by the CPU effect
/// and the GPU compositor (`fx_time`), which renders the plan like Timewarp's.
pub fn pmb_plan(ctx: &EffectCtx) -> Option<TimewarpPlan> {
ctx.env.host?;
let fps = ctx.fps();
let samples = shutter_times(ctx);
let n = samples.len();
let mut times = Vec::new();
for &s in &samples {
let (f0, _) = tw_split(s, fps);
times.push(f0 / fps);
times.push((f0 + 1.0) / fps);
}
let fr = fetch(ctx, &times)?;
let idx = |t: f64| times.iter().position(|q| (q - t).abs() < 1e-9).unwrap_or(0);
let mut pm = PixelMotion::from(ctx, fr.grid.scale);
// PMB's own Vector Detail: 0 → one vector per 32 px, 100 → one per 2 px.
let detail = (ctx.params.f("vectorDetail") / 100.0).clamp(0.0, 1.0);
pm.tile = (32.0 * (1.0f64 / 16.0).powf(detail) * fr.grid.scale).round().max(2.0) as u32;
let mut flows: Vec<FlowEntry> = vec![];
let mut steps = vec![];
let w = 1.0 / n as f32;
for &s in &samples {
let (f0, frac) = tw_split(s, fps);
let (i0, i1) = (idx(f0 / fps), idx((f0 + 1.0) / fps));
let step = if frac < 1e-4 {
TwStep::Whole(i0)
} else {
let flow = match flows.iter().position(|fl| fl.key == f0 as i64) {
Some(p) => p,
None => {
let reach = (fr.grid.img.width.max(fr.grid.img.height) / 4).clamp(8, 64);
flows.push(FlowEntry { key: f0 as i64, flow: timewarp_flow_search(&fr.imgs[i0], &fr.imgs[i1], &pm, reach as i32) });
flows.len() - 1
}
};
TwStep::Motion { a: i0, b: i1, w: frac, flow }
};
steps.push((step, w));
}
Some(TimewarpPlan { grid: fr.grid, frames: fr.imgs, steps, flows: flows.into_iter().map(|f| f.flow).collect(), pm })
}
/// A measured flow with the source frame pair it came from (plans cache one per pair).
struct FlowEntry {
key: i64,
flow: Flow,
}
#[cfg(test)]
+12 -7
View File
@@ -45,13 +45,12 @@ pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<G
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)
}
// Pixel Motion Blur synthesizes along measured motion vectors (#535); a plan that
// cannot be built (no host) declines to the CPU effect.
"ec.time.pixelmotionblur" => match effectcraft_effects::pmb_plan(ctx) {
Some(plan) => render_plan(e, &plan),
None => None,
},
"ec.time.timewarp" => timewarp(e, ctx, b),
_ => None,
}
@@ -179,6 +178,12 @@ fn timewarp(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
return Some(b);
}
let Some(plan) = effectcraft_effects::timewarp_plan(ctx) else { return Some(b) };
render_plan(e, &plan)
}
/// Render a prepared Timewarp plan (also Pixel Motion Blur's, `effectcraft_effects::pmb_plan`):
/// every step to an image on the GPU, then the weighted average.
fn render_plan(e: &mut Enc, plan: &effectcraft_effects::TimewarpPlan) -> Option<GBuf> {
// Pixel Motion's in-between frame of frames a, b along motion field `flow`.
let motion = |e: &mut Enc, a: usize, bi: usize, w: f32, flow: usize| -> Option<GpuImage> {
let pa = e.g.upload_image(&effectcraft_effects::timewarp_crop(&plan.frames[a], &plan.pm))?;
+112
View File
@@ -0,0 +1,112 @@
//! Pixel Motion Blur must synthesize blur from motion vectors, not average decoded frames
//! (#535). Two bars move in opposite directions between discrete source frames; the gap
//! pixels between their old and new positions must fill in, and Vector Detail must matter.
use effectcraft_color::Label;
use effectcraft_keyframe::Value;
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{AlphaMode, BitDepth, Comp, Footage, FootageKind, FrameBlend, ItemId, ItemKind, LayerSource, Project};
use effectcraft_render::{FootageSource, Image, RenderOpts, Renderer};
use effectcraft_time::{FrameRate, Tick};
use std::sync::Arc;
// A 12×8 block with a horizontal brightness gradient slides right by 12 px between source
// frames 9 and 10. The gradient gives the block matcher real texture, so Pixel Motion can find
// the +12 vector and smear the block across the gap — unlike a flat bar, whose motion is
// undefined (the aperture problem) and which no synthesizer can invent coverage for.
struct Clip;
impl FootageSource for Clip {
fn frame(&self, _: ItemId, f: &Footage, t: Tick) -> Option<Arc<Image>> {
let k = f.frame_rate.frame_at(t).clamp(0, 19);
let dx = 12 * (k - 10);
let mut image = Image::filled(64, 48, [0.0, 0.0, 0.0, 1.0]);
for y in 0..48 {
for x in 0..64 {
if (16..32).contains(&y) && (20 + dx..32 + dx).contains(&x) {
let g = (x - (20 + dx)) as f32 / 12.0;
image.set(x as u32, y, [0.4 + 0.6 * g, 0.3, 0.2, 1.0]);
}
}
}
Some(Arc::new(image))
}
}
fn scene(detail: Option<f64>) -> (Project, ItemId) {
let mut p = Project::default();
p.settings.bit_depth = BitDepth::Bpc32;
let comp = Comp::new(64, 48, FrameRate::FPS_25, Tick::from_seconds_f64(0.8));
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
let footage = Footage {
path: "generated.mov".into(),
kind: FootageKind::Video,
width: 64,
height: 48,
pixel_aspect: 1.0,
frame_rate: FrameRate::FPS_25,
duration: Tick::from_seconds_f64(0.8),
has_video: true,
has_audio: false,
alpha: AlphaMode::Ignore,
missing: false,
..Default::default()
};
let fid = p.add_item("Clip", Label::Aqua, None, ItemKind::Footage(footage));
let mut layer = build::layer(&mut p, &comp, "Clip", LayerSource::Footage { item: fid }, (64, 48), None);
layer.switches.frame_blend = FrameBlend::Off;
layer.switches.motion_blur = false;
if let Some(detail) = detail {
let spec = effectcraft_effects::find("ec.time.pixelmotionblur").unwrap();
let mut next = p.next_id;
let mut effect = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [64.0, 48.0]);
for (name, value) in [
("shutterControl", Value::Enum(1)),
("shutterAngle", Value::Scalar(180.0)),
("shutterSamples", Value::Scalar(16.0)),
("vectorDetail", Value::Scalar(detail)),
] {
effect.prop_mut(name).unwrap().value = value;
}
p.next_id = next;
layer.props.sub_mut("effects").unwrap().children.push(effect.into());
}
p.comp_mut(cid).unwrap().layers.push(layer);
(p, cid)
}
fn render(p: &Project, cid: ItemId, time: f64) -> Image {
Renderer::new(p, &Clip, RenderOpts::default()).comp_frame(cid, Tick::from_seconds_f64(time))
}
fn difference(a: &Image, b: &Image) -> f64 {
a.data.iter().zip(&b.data).flat_map(|(p, q)| (0..4).map(move |c| (p[c] - q[c]).abs() as f64)).sum()
}
#[test]
fn pixel_motion_blur_synthesizes_from_motion_vectors() {
let (plain, cid) = scene(None);
let still = render(&plain, cid, 0.4);
let average = average_of(&plain, cid);
let (low, low_id) = scene(Some(0.0));
let (high, high_id) = scene(Some(100.0));
let a = render(&low, low_id, 0.4);
let b = render(&high, high_id, 0.4);
assert!(difference(&b, &still) > 1.0, "the blur equals the still frame");
assert!(difference(&b, &average) > 1.0, "the blur is just the plain overlay of the two decoded positions");
assert!((24..32).any(|x| b.get(x, 24)[0] > 0.2), "no motion trail behind the moving block: {:?}", (18..46).map(|x| b.get(x, 24)[0]).collect::<Vec<f32>>());
assert!(difference(&a, &b) > 0.5, "Vector Detail is inert: the motion field does not change with it");
}
fn average_of(p: &Project, cid: ItemId) -> Image {
let mut average = Image::new(64, 48);
for i in 0..16 {
let time = ((0.39 + 0.02 * i as f64 / 15.0) * 1e6).round() / 1e6;
let frame = render(p, cid, time);
for (p, q) in average.data.iter_mut().zip(&frame.data) {
for c in 0..4 {
p[c] += q[c] / 16.0;
}
}
}
average
}