mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
Pixel Motion Blur: synthesize the blur from motion vectors and honour Vector Detail (#535).
Time > Pixel Motion Blur fetched the layer at its shutter times and averaged the decoded frames as they were, so fast motion rendered as doubled copies and the accepted Vector Detail parameter changed nothing. Build a TimewarpPlan instead: every shutter instant is decoded between the two source frames it falls between along a measured motion field — the same block-matching and in-between synthesis Time > Timewarp uses, with a search radius wide enough for full-frame displacements and the vector grid tightened from Vector Detail 0 (one vector per 32 px) to 100 (one per 2 px) — and the instants average into the blur. CPU and GPU share the plan (fx_time renders it with its Timewarp step loop, declining to the CPU when there is no host), so the existing GPU parity cases for the effect compare the new path. Falling back to the old average is kept for layers without a time host. A render integration test moves a textured block 12 px between source frames, renders the straddling shutter, and asserts the output is neither the still nor the plain frame overlay, lays trail coverage along the block's path, and changes with Vector Detail; without the synthesis it fails at the overlay assertion.
This commit is contained in:
4 files changed
+195
-12
No files matched your search
@@ -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).
|
||||
|
||||
@@ -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, ×)
|
||||
}
|
||||
|
||||
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, ×)
|
||||
(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, ×)?;
|
||||
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)]
|
||||
|
||||
@@ -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, ×)
|
||||
}
|
||||
// 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))?;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user