diff --git a/crates/render/src/cache.rs b/crates/render/src/cache.rs index 8c85490b..e487e6ce 100644 --- a/crates/render/src/cache.rs +++ b/crates/render/src/cache.rs @@ -478,21 +478,21 @@ fn hash_values(h: &mut KeyHasher, ctx: &EvalCtx, layer: &Layer, g: &PropGroup) { /// Cache key for the processed (source → masks → effects) buffer of `layer` at the context /// time, or `None` when the layer must not be cached (footage, precomps, cameras, adjustment /// layers…). -pub fn layer_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool) -> Option { +pub fn layer_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool) -> Option { // Time effects see property values at other times. Keyframes are part of the hashed // structure (and the layer time is folded in), but expressions may read other layers at // those times, which the key cannot see: such layers are not cached. if reads_other_times(layer) && layer.props.children.iter().any(|c| matches!(c, Node::Group(g) if g.match_id != "transform" && has_expression(g))) { return None; } - key_with(ctx, layer, scale, draft, false) + key_with(ctx, layer, scale, draft, blur, false) } /// Cache key for a layer's *input* at the context time: source → masks → its first `effects` /// effects (see `Renderer::layer_input`). Unlike [`layer_key`], footage layers are cached here /// (keyed by item and source time), since Time effects read many neighbouring frames. -pub fn input_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, effects: usize) -> Option { - let base = key_with(ctx, layer, scale, draft, true)?; +pub fn input_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, effects: usize) -> Option { + let base = key_with(ctx, layer, scale, draft, blur, true)?; let mut h = KeyHasher(base ^ 0x5bd1_e995_7a3c_11d3); effects.hash(&mut h); Some(h.finish()) @@ -515,18 +515,19 @@ fn has_expression(g: &PropGroup) -> bool { /// Cache key for an adjustment layer's footprint (its source through its masks, see /// `Renderer::adjustment_footprint`): the GPU compositor then uploads it once, not every frame. -pub fn footprint_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool) -> Option { - Some(derive(key_any(ctx, layer, scale, draft, false)?, 0xf007_9417)) +pub fn footprint_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool) -> Option { + Some(derive(key_any(ctx, layer, scale, draft, blur, false)?, 0xf007_9417)) } -fn key_with(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, footage: bool) -> Option { +fn key_with(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, footage: bool) -> Option { if layer.switches.adjustment { return None; } - key_any(ctx, layer, scale, draft, footage) + key_any(ctx, layer, scale, draft, blur, footage) } -fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, footage: bool) -> Option { +/// `blur`: the layer is motion blurred in this render (`Renderer::mb_on`). +fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, footage: bool) -> Option { let mut h = KeyHasher(0xcbf2_9ce4_8422_2325); match &layer.source { LayerSource::Solid { item } => { @@ -558,11 +559,14 @@ fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, footage: bool) layer.id.hash(&mut h); hash_debug(&mut h, &layer.source); hash_debug(&mut h, &layer.switches); - // Mask motion blur samples the comp's shutter. - if layer.masks().is_some_and(|m| !m.children.is_empty()) { + // Mask motion blur and effects that read the shutter depend on whether the layer is motion + // blurred in this render and on the comp's shutter. + if layer.masks().is_some_and(|m| !m.children.is_empty()) || layer.effects().is_some_and(|fx| !fx.children.is_empty()) { + blur.hash(&mut h); ctx.comp.enable_motion_blur.hash(&mut h); ctx.comp.shutter_angle.to_bits().hash(&mut h); ctx.comp.shutter_phase.to_bits().hash(&mut h); + ctx.comp.motion_blur_samples.hash(&mut h); } for c in &layer.props.children { // Transform is applied later; Layer Styles are keyed separately (see [`styles_key`]). diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 540ce9e9..e49ee2b8 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -841,11 +841,7 @@ 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, - )), + shutter: self.mb_on(ctx, layer).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 @@ -1085,7 +1081,7 @@ impl<'a> Renderer<'a> { let mut buf = self.source(ctx, layer)?; // A solid is filled with a quantised colour: only masks can take it off the grid. let solid = matches!(layer.source, LayerSource::Solid { .. }); - if masks::apply(ctx, layer, &mut buf) || !solid { + if masks::apply(ctx, layer, &mut buf, self.mask_blur(ctx, layer)) || !solid { self.pipe.quantize(&mut buf.img); } Some(buf) @@ -1152,7 +1148,7 @@ impl<'a> Renderer<'a> { } fn content_buf_timed(&self, ctx: &EvalCtx, layer: &Layer, mut timing: Option<&mut LayerTiming>) -> Option<(Arc, Option)> { - let key = self.cache.and_then(|_| cache::layer_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft)); + let key = self.cache.and_then(|_| cache::layer_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft, self.mb_on(ctx, layer))); if let (Some(c), Some(k)) = (self.cache, key) && let Some(b) = c.get(k) { @@ -1173,7 +1169,7 @@ impl<'a> Renderer<'a> { /// (what Time effects read at neighbouring times). Served from / stored in the layer cache /// under its own key, so scrubbing reuses frames rendered for earlier output frames. pub fn layer_input(&self, ctx: &EvalCtx, layer: &Layer, effects: usize) -> Option> { - let key = self.cache.and_then(|_| cache::input_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft, effects)); + let key = self.cache.and_then(|_| cache::input_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft, self.mb_on(ctx, layer), effects)); if let (Some(c), Some(k)) = (self.cache, key) && let Some(b) = c.get(k) { @@ -1285,11 +1281,22 @@ impl<'a> Renderer<'a> { * Mat3::translate(vec2(-buf.offset[0], -buf.offset[1])) } - /// Whether a layer is motion blurred. - fn mb_on(&self, ctx: &EvalCtx, layer: &Layer) -> bool { + /// Whether a layer is motion blurred: the render options, the comp's switch and the layer's + /// switch (limited by the precomp layers above when switches affect nested comps). Layer + /// motion, mask motion blur, effects that read the shutter and Advanced 3D all ask this. + pub(crate) fn mb_on(&self, ctx: &EvalCtx, layer: &Layer) -> bool { self.opts.motion_blur && ctx.comp.enable_motion_blur && self.layer_motion_blur(layer) } + /// What a layer's mask motion blur may do in this render (see [`masks::MaskBlur`]). + fn mask_blur(&self, ctx: &EvalCtx, layer: &Layer) -> masks::MaskBlur { + masks::MaskBlur { + allowed: self.opts.motion_blur && ctx.comp.enable_motion_blur && self.inherited.is_none_or(|(_, mb)| mb), + layer: self.layer_motion_blur(layer), + samples: if self.opts.draft { 4 } else { ctx.comp.motion_blur_samples as usize }, + } + } + /// Motion-blur sub-samples for a layer buffer (1 = no motion blur). As in After Effects, a 2D /// layer gets about one sample per output pixel its corners travel during the shutter, /// at least Samples Per Frame and at most the comp's Adaptive Sample Limit; a layer that @@ -1545,7 +1552,7 @@ impl<'a> Renderer<'a> { /// Adjustment layer: effects applied to the comp below, limited to the layer's footprint. fn draw_adjustment(&self, ctx: &EvalCtx, layer: &Layer, canvas: &mut Image, opacity: f32) { let Some(mut foot) = self.source(ctx, layer) else { return }; - let _ = masks::apply(ctx, layer, &mut foot); + let _ = masks::apply(ctx, layer, &mut foot, self.mask_blur(ctx, layer)); let mut matte = Image::new(canvas.width, canvas.height); self.place(ctx, layer, &foot, &mut matte, BlendMode::Normal, 1.0); let o = self.roi_offset().map(|(x, y, _, _)| [-x, -y]).unwrap_or([0.0; 2]); @@ -1708,14 +1715,14 @@ impl<'a> Renderer<'a> { /// its effects show. `None` = the layer changes nothing. pub fn adjustment_footprint(&self, ctx: &EvalCtx, layer: &Layer) -> Option> { // Cached, so an accelerator sees the same buffer (and uploads it once) every frame. - let key = self.cache.and_then(|_| cache::footprint_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft)); + let key = self.cache.and_then(|_| cache::footprint_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft, self.mb_on(ctx, layer))); if let (Some(c), Some(k)) = (self.cache, key) && let Some(b) = c.get(k) { return Some(b); } let mut foot = self.source(ctx, layer)?; - let _ = masks::apply(ctx, layer, &mut foot); + let _ = masks::apply(ctx, layer, &mut foot, self.mask_blur(ctx, layer)); let foot = Arc::new(foot); if let (Some(c), Some(k)) = (self.cache, key) { c.insert(k, foot.clone()); @@ -1914,6 +1921,8 @@ mod tests_color; #[cfg(test)] mod tests_frame_blend; #[cfg(test)] +mod tests_mask_blur; +#[cfg(test)] mod tests_nested; #[cfg(test)] mod tests_paint; diff --git a/crates/render/src/masks.rs b/crates/render/src/masks.rs index 8fcb39b7..0cfdc0e7 100644 --- a/crates/render/src/masks.rs +++ b/crates/render/src/masks.rs @@ -10,29 +10,40 @@ use rayon::prelude::*; use crate::eval::EvalCtx; -/// Sub-frame samples for mask motion blur (animated mask paths only). -const MASK_BLUR_SAMPLES: usize = 8; +/// What mask motion blur may do in a render (from the renderer's motion blur gate). +#[derive(Clone, Copy, Debug, Default)] +pub struct MaskBlur { + /// Motion blur is on in this render: the render options, the comp's switch and the Motion + /// Blur switches of the precomp layers above (when switches affect nested comps). + pub allowed: bool, + /// The layer's own Motion Blur switch, as those precomp layers limit it (Same As Layer). + pub layer: bool, + /// Sub-frame samples (the comp's Samples Per Frame; fewer in Draft). + pub samples: usize, +} /// Coverage of one mask in buffer pixels (before mode/opacity/invert). With mask motion blur /// (Layer ▸ Mask ▸ Motion Blur: On, or Same As Layer with the layer's switch on) an animated /// path is averaged over the comp's shutter. -fn coverage(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, buf: &Buf) -> Option { - let mb = match g.kind { - GroupKind::Mask { motion_blur, .. } => match motion_blur { - MaskMotionBlur::On => true, - MaskMotionBlur::Off => false, - MaskMotionBlur::SameAsLayer => layer.switches.motion_blur, - }, - _ => false, - }; +fn coverage(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, buf: &Buf, blur: MaskBlur) -> Option { + let mb = blur.allowed + && match g.kind { + GroupKind::Mask { motion_blur, .. } => match motion_blur { + MaskMotionBlur::On => true, + MaskMotionBlur::Off => false, + MaskMotionBlur::SameAsLayer => blur.layer, + }, + _ => false, + }; let animated = g.get("path").is_some_and(|p| p.keys.len() > 1 || p.has_expression()); - if mb && animated && ctx.comp.enable_motion_blur { + if mb && animated { let fd = ctx.comp.frame_duration().seconds(); let (angle, phase) = (ctx.comp.shutter_angle / 360.0, ctx.comp.shutter_phase / 360.0); let mut acc: Option = None; - let k = 1.0 / MASK_BLUR_SAMPLES as f32; - for i in 0..MASK_BLUR_SAMPLES { - let f = phase + angle * i as f64 / (MASK_BLUR_SAMPLES - 1) as f64; + let n = blur.samples.max(2); + let k = 1.0 / n as f32; + for i in 0..n { + let f = phase + angle * i as f64 / (n - 1) as f64; let sub = ctx.at(ctx.time + effectcraft_time::Tick::from_seconds_f64(f * fd)); let Some(c) = coverage_at(&sub, layer, g, buf) else { continue }; match &mut acc { @@ -121,7 +132,7 @@ pub fn shapes(ctx: &EvalCtx, layer: &Layer) -> Vec bool { +pub fn apply(ctx: &EvalCtx, layer: &Layer, buf: &mut Buf, blur: MaskBlur) -> bool { let Some(masks) = layer.masks() else { return false }; let list: Vec<_> = masks.groups().filter(|g| g.enabled && matches!(g.kind, GroupKind::Mask { mode, .. } if mode != MaskMode::None)).collect(); if list.is_empty() { @@ -133,7 +144,7 @@ pub fn apply(ctx: &EvalCtx, layer: &Layer, buf: &mut Buf) -> bool { let mut acc = vec![if first_sub { 1.0f32 } else { 0.0 }; n]; for g in list { let GroupKind::Mask { mode, inverted, .. } = g.kind else { continue }; - let Some(mut cov) = coverage(ctx, layer, g, buf) else { continue }; + let Some(mut cov) = coverage(ctx, layer, g, buf, blur) else { continue }; let op = (ctx.f(layer, g, "opacity", 100.0) / 100.0) as f32; cov.data.par_iter_mut().for_each(|c| { if inverted { diff --git a/crates/render/src/tests_mask_blur.rs b/crates/render/src/tests_mask_blur.rs new file mode 100644 index 00000000..12b88bbb --- /dev/null +++ b/crates/render/src/tests_mask_blur.rs @@ -0,0 +1,52 @@ +//! Mask motion blur follows the renderer's motion blur gate: the render options (Render +//! Settings ▸ Motion Blur, previews), the comp's switch and the precomp layers above. + +use effectcraft_color::Label; +use effectcraft_keyframe::{Keyframe, ShapePath, Value}; +use effectcraft_project::build::{self, Ids}; +use effectcraft_project::{Comp, GroupKind, ItemId, ItemKind, LayerSource, MaskMode, MaskMotionBlur, Node, Project, Solid}; +use effectcraft_time::{FrameRate, Tick}; + +use crate::{NoFootage, RenderOpts, Renderer}; + +/// A white 100×100 solid whose rect mask jumps from x 10 (frame 0) to x 50 (frame 1), Mask +/// Motion Blur On, the layer's own switch off. Rendered half way, unblurred the mask covers x +/// 30..50; blurred over the shutter it reaches down to x ≈ 20. +fn project(comp_blur: bool) -> (Project, ItemId) { + let mut p = Project::default(); + let mut comp = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + comp.enable_motion_blur = comp_blur; + let sid = p.add_item("S", Label::Red, None, ItemKind::Solid(Solid { color: [1.0; 3], width: 100, height: 100, pixel_aspect: 1.0 })); + let mut l = build::layer(&mut p, &comp, "S", LayerSource::Solid { item: sid }, (100, 100), None); + let mut next = p.next_id; + let mut m = build::mask(&mut Ids(&mut next), "Mask 1", ShapePath::rect([20.0, 50.0], 20.0, 100.0), MaskMode::Add, [255, 0, 0]); + p.next_id = next; + if let GroupKind::Mask { motion_blur, .. } = &mut m.kind { + *motion_blur = MaskMotionBlur::On; + } + let path = m.get_mut("path").unwrap(); + path.keys = vec![ + Keyframe::new(Tick::ZERO, Value::Path(ShapePath::rect([20.0, 50.0], 20.0, 100.0))), + Keyframe::new(FrameRate::FPS_30.tick_of(1), Value::Path(ShapePath::rect([60.0, 50.0], 20.0, 100.0))), + ]; + l.props.sub_mut("masks").unwrap().children.push(Node::Group(m)); + l.switches.motion_blur = false; + let cid = p.add_item("C", Label::Sandstone, None, ItemKind::Comp(comp.into())); + p.comp_mut(cid).unwrap().layers.push(l); + (p, cid) +} + +fn alpha_at_25(p: &Project, cid: ItemId, motion_blur: bool) -> f32 { + let half = Tick::from_seconds_f64(0.5 / 30.0); + Renderer::new(p, &NoFootage, RenderOpts { motion_blur, ..Default::default() }).comp_frame(cid, half).get(25, 50)[3] +} + +#[test] +fn mask_motion_blur_follows_the_render_and_the_comp() { + let (p, cid) = project(true); + let a = alpha_at_25(&p, cid, true); + assert!(a > 0.05 && a < 0.95, "blurred: {a}"); + assert!(alpha_at_25(&p, cid, false) < 0.01, "Render Settings ▸ Motion Blur off"); + let (p, cid) = project(false); + assert!(alpha_at_25(&p, cid, true) < 0.01, "the comp's switch off"); +} diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index b7073667..93913aa8 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -157,8 +157,8 @@ fn wiggly_selector_is_deterministic_and_time_dependent() { assert!(ys(&a).iter().all(|y| y.abs() <= 50.0 + 1e-9)); assert!(ys(&a).windows(2).any(|w| (w[0] - w[1]).abs() > 1e-6)); // The layer cache keys a wiggly text layer by time. - let k1 = crate::cache::layer_key(&ctx(&p, cid, 0.5), l, 1.0, false); - let k2 = crate::cache::layer_key(&ctx(&p, cid, 1.3), l, 1.0, false); + let k1 = crate::cache::layer_key(&ctx(&p, cid, 0.5), l, 1.0, false, false); + let k2 = crate::cache::layer_key(&ctx(&p, cid, 1.3), l, 1.0, false, false); assert_ne!(k1, k2); // Renders identically twice. let r1 = crate::render_frame(&p, cid, Tick::from_seconds_f64(0.5), 0.5); diff --git a/crates/render/src/three_d/adv/scene.rs b/crates/render/src/three_d/adv/scene.rs index 1570c2c6..79b5dc16 100644 --- a/crates/render/src/three_d/adv/scene.rs +++ b/crates/render/src/three_d/adv/scene.rs @@ -882,9 +882,10 @@ pub(crate) fn build(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], out: (u32, u32) build_at(r, ctx, None, run, out) } -/// Whether a layer's motion is blurred (Motion Blur switch, comp setting and render option). +/// Whether a layer's motion is blurred (the renderer's gate: Motion Blur switch, comp setting, +/// render option and the precomp layers above). pub(crate) fn motion_blurred(r: &Renderer, ctx: &EvalCtx, layer: &Layer) -> bool { - r.opts.motion_blur && ctx.comp.enable_motion_blur && layer.switches.motion_blur + r.mb_on(ctx, layer) } /// [`build`] at a motion-blur sub-sample: layers with Motion Blur on, the camera and the lights