Merge pull request #50 from storytold/m4-motion-blur-depth
M4.13: motion blur of collapsed precomps and of animated shape / text content
This commit is contained in:
commit
053c445465
|
|
@ -10,7 +10,7 @@ once.
|
|||
| M1 | Keyframes: temporal and spatial interpolation, Easy Ease, roving, velocity | Done |
|
||||
| M2 | Compositing: 38 blend modes, track mattes, parenting, adjustment layers | Done |
|
||||
| M3 | Project operations: settings dialogs, layer commands, `.ecproj`, undo, After Effects menu bar | Done (unsaved-changes prompts, Solid / Layer Settings, lock-aware layer commands, safe project files; View ▸ New Viewer still opens only the one locked viewer) |
|
||||
| M4 | Preview: precomps, motion blur, cached playback | Mostly done (Preserve frame rate / resolution when nested, one motion blur gate, RAM preview fixes, Cache Frames When Idle; left: motion blur of collapsed precomps and of animated layer content) |
|
||||
| M4 | Preview: precomps, motion blur, cached playback | Mostly done (Preserve frame rate / resolution when nested, one motion blur gate, RAM preview fixes, Cache Frames When Idle, motion blur of collapsed precomps and of animated shape / text content; left: content-keyed RAM preview frames) |
|
||||
| M5 | Timeline depth: graph editor, keyframe clipboard and dialogs, time remapping, pick-whips, expression editor | Done |
|
||||
| M6 | Shapes, masks and footage: shape operators, masks and the pen tool, video and image import | Done |
|
||||
| M7 | 3D: 3D layers, cameras, lights, shadows, depth of field, 3D views and camera tools | In review |
|
||||
|
|
@ -33,7 +33,6 @@ Measured feature by feature in [docs/parity.md](docs/parity.md) (4 October 2026)
|
|||
- **≈ 8.5 agent-hours of work remain**: about **2–3 hours of wall-clock time** with five Claude
|
||||
Opus 5.5 agents in parallel, ≈ 1 hour without learned models for Roto Brush and face tracking.
|
||||
- What is left: GPU kernels for the remaining visual effects, and learned-model quality for Roto
|
||||
Brush and face tracking; in previews, motion blur of collapsed precomps and of animated layer
|
||||
content, and content-keyed RAM preview frames ([parity.md](docs/parity.md#highest-value-gaps-in-order)).
|
||||
Brush and face tracking; in previews, content-keyed RAM preview frames ([parity.md](docs/parity.md#highest-value-gaps-in-order)).
|
||||
|
||||
Come tell us what matters most to you on [Discord](https://discord.gg/artcraft).
|
||||
|
|
|
|||
|
|
@ -970,6 +970,8 @@ mod tests_menu_cmds;
|
|||
#[cfg(test)]
|
||||
mod tests_model3d;
|
||||
#[cfg(test)]
|
||||
mod tests_motion_blur_depth;
|
||||
#[cfg(test)]
|
||||
mod tests_project_items;
|
||||
#[cfg(test)]
|
||||
mod tests_proxy;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,122 @@
|
|||
//! Motion blur of collapsed precomps (the precomp layer's own motion; the containing comp's
|
||||
//! shutter) and of animated shape content, rendered through the commands.
|
||||
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::render::RenderOpts;
|
||||
|
||||
const W: u32 = 160;
|
||||
const H: u32 = 90;
|
||||
|
||||
fn id(v: &Value, k: &str) -> u64 {
|
||||
v[k].as_u64().unwrap()
|
||||
}
|
||||
|
||||
fn render(s: &Session, comp: u64) -> Image {
|
||||
s.render(effectcraft_project::ItemId(comp), Tick::from_seconds_f64(0.5), RenderOpts::default())
|
||||
}
|
||||
|
||||
/// Pixels with partial alpha (motion-blur smear).
|
||||
fn partial(img: &Image) -> usize {
|
||||
img.data.iter().filter(|p| p[3] > 0.05 && p[3] < 0.95).count()
|
||||
}
|
||||
|
||||
fn max_diff(a: &Image, b: &Image) -> f32 {
|
||||
a.data.iter().zip(&b.data).flat_map(|(p, q)| (0..4).map(move |c| (p[c] - q[c]).abs())).fold(0.0, f32::max)
|
||||
}
|
||||
|
||||
/// "Main" holding "Pre" (a 20×20 white square in the middle), the precomp layer moving 480 px/s
|
||||
/// (20 px a frame at 24 fps) with motion blur on. `inner_move`: the square moves inside Pre too
|
||||
/// (with its own motion blur switch), and Pre's shutter is closed (0°).
|
||||
fn scene(collapse: bool, inner_move: bool) -> (Session, u64) {
|
||||
let mut s = Session::default();
|
||||
let pre = id(&s.execute("comp.new", json!({"name": "Pre", "width": W, "height": H, "frameRate": 24, "duration": 2})).unwrap(), "comp");
|
||||
let sq = id(&s.execute("layer.newSolid", json!({"name": "Square", "color": "#ffffff", "width": 20, "height": 20})).unwrap(), "layer");
|
||||
if inner_move {
|
||||
s.execute("comp.settings", json!({"shutterAngle": 0})).unwrap();
|
||||
s.execute("comp.setSwitch", json!({"switch": "motionBlur", "value": true})).unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [sq], "switch": "motionBlur", "value": true})).unwrap();
|
||||
for (t, x) in [(0.0, -160.0), (1.0, 320.0)] {
|
||||
s.execute("prop.addKey", json!({"layer": sq, "path": "transform/position", "time": t, "value": [x, 45.0]})).unwrap();
|
||||
}
|
||||
}
|
||||
let main = id(&s.execute("comp.new", json!({"name": "Main", "width": W, "height": H, "frameRate": 24, "duration": 2})).unwrap(), "comp");
|
||||
s.execute("comp.setSwitch", json!({"switch": "motionBlur", "value": true})).unwrap();
|
||||
let l = id(&s.execute("layer.addItem", json!({"item": pre})).unwrap(), "layer");
|
||||
s.execute("layer.setSwitch", json!({"layers": [l], "switch": "motionBlur", "value": true})).unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [l], "switch": "collapse", "value": collapse})).unwrap();
|
||||
if !inner_move {
|
||||
for (t, x) in [(0.0, -160.0), (1.0, 320.0)] {
|
||||
s.execute("prop.addKey", json!({"layer": l, "path": "transform/position", "time": t, "value": [x, 45.0]})).unwrap();
|
||||
}
|
||||
}
|
||||
(s, main)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_collapsed_precomp_layer_blurs_with_its_own_motion() {
|
||||
let (s, main) = scene(true, false);
|
||||
let collapsed = render(&s, main);
|
||||
// The square sits at x = 80 at 0.5 s and smears over 10 px (180° of 20 px).
|
||||
assert!(partial(&collapsed) > 100, "smeared: {}", partial(&collapsed));
|
||||
// As blurred as the same precomp rendered flat.
|
||||
let (s, main) = scene(false, false);
|
||||
let flat = render(&s, main);
|
||||
assert!(max_diff(&collapsed, &flat) < 0.08, "{}", max_diff(&collapsed, &flat));
|
||||
// Motion blur off for the render: sharp.
|
||||
let sharp = s.render(effectcraft_project::ItemId(main), Tick::from_seconds_f64(0.5), RenderOpts { motion_blur: false, ..Default::default() });
|
||||
assert!(partial(&sharp) < 10, "{}", partial(&sharp));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapsed_layers_use_the_containing_comps_shutter() {
|
||||
// Pre's own shutter is closed; drawn into Main (180°) the square's own motion blurs.
|
||||
let (s, main) = scene(true, true);
|
||||
assert!(partial(&render(&s, main)) > 100, "{}", partial(&render(&s, main)));
|
||||
// Rendered flat, Pre uses its own (closed) shutter.
|
||||
let (s, main) = scene(false, true);
|
||||
assert!(partial(&render(&s, main)) < 10, "{}", partial(&render(&s, main)));
|
||||
}
|
||||
|
||||
/// A shape layer whose rectangle moves inside its contents (not its transform): motion blur
|
||||
/// smears it like the same move made with the layer's Position.
|
||||
#[test]
|
||||
fn animated_shape_content_is_motion_blurred() {
|
||||
let shape = |content: bool, blur: bool| {
|
||||
let mut s = Session::default();
|
||||
let c = id(&s.execute("comp.new", json!({"name": "S", "width": W, "height": H, "frameRate": 24, "duration": 2})).unwrap(), "comp");
|
||||
s.execute("comp.setSwitch", json!({"switch": "motionBlur", "value": true})).unwrap();
|
||||
let l = id(&s.execute("layer.newShape", json!({"kind": "rect", "size": [20, 20], "fill": "#ffffff", "position": [80, 45]})).unwrap(), "layer");
|
||||
s.execute("layer.setSwitch", json!({"layers": [l], "switch": "motionBlur", "value": blur})).unwrap();
|
||||
let (target, base) = if content {
|
||||
let layer = s.active_comp().unwrap().layer(effectcraft_project::LayerId(l)).unwrap().clone();
|
||||
let mut found = None;
|
||||
layer.props.group("contents").unwrap().walk("contents", &mut |_, p| {
|
||||
if found.is_none() && p.match_id == "position" {
|
||||
found = Some((json!({"prop": p.uid}), p.value.as_vec2()));
|
||||
}
|
||||
});
|
||||
found.unwrap()
|
||||
} else {
|
||||
(json!({"path": "transform/position"}), [80.0, 45.0])
|
||||
};
|
||||
for (t, dx) in [(0.0, -240.0), (1.0, 240.0)] {
|
||||
let mut p = target.clone();
|
||||
p["layer"] = json!(l);
|
||||
p["time"] = json!(t);
|
||||
p["value"] = json!([base[0] + dx, base[1]]);
|
||||
s.execute("prop.addKey", p).unwrap();
|
||||
}
|
||||
render(&s, c)
|
||||
};
|
||||
let content = shape(true, true);
|
||||
assert!(partial(&content) > 100, "smeared: {}", partial(&content));
|
||||
// Like the layer moved by its transform…
|
||||
let moved = shape(false, true);
|
||||
assert!(max_diff(&content, &moved) < 0.15, "{}", max_diff(&content, &moved));
|
||||
// …and sharp with the layer's motion blur switch off.
|
||||
assert!(partial(&shape(true, false)) < 40, "{}", partial(&shape(true, false)));
|
||||
}
|
||||
|
|
@ -444,6 +444,49 @@ pub struct Renderer<'a> {
|
|||
/// This renderer draws into the top-level frame's canvas (the top comp, or a collapsed
|
||||
/// precomp drawn straight into it): the region of interest's offset applies.
|
||||
top: bool,
|
||||
/// The collapsed precomp layers this comp is drawn through, outermost first: motion blur
|
||||
/// re-evaluates their transforms at each sub-sample (see [`Renderer::buf_matrix_at`]).
|
||||
through: [Option<Through<'a>>; MAX_THROUGH],
|
||||
/// More collapsed levels than [`MAX_THROUGH`]: their motion is not blurred.
|
||||
through_lost: bool,
|
||||
}
|
||||
|
||||
/// One collapsed precomp layer a nested comp is drawn through.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Through<'a> {
|
||||
comp_id: ItemId,
|
||||
comp: &'a Comp,
|
||||
/// The containing comp's time at this frame.
|
||||
time: Tick,
|
||||
layer: &'a Layer,
|
||||
/// The precomp layer is motion blurred: its own motion blurs the nested layers.
|
||||
blur: bool,
|
||||
}
|
||||
|
||||
/// Collapsed precomps within collapsed precomps that motion blur follows (more than
|
||||
/// [`Renderer::collapsed`]'s nesting limit).
|
||||
const MAX_THROUGH: usize = 16;
|
||||
|
||||
/// A motion-blur shutter: the comp's, or for a collapsed precomp's layers the outermost
|
||||
/// containing comp's (they are drawn into its frames).
|
||||
#[derive(Clone, Copy)]
|
||||
struct Shutter {
|
||||
/// Shutter angle and phase as fractions of a frame.
|
||||
angle: f64,
|
||||
phase: f64,
|
||||
/// Frame duration (seconds).
|
||||
frame: f64,
|
||||
/// Samples Per Frame and Adaptive Sample Limit.
|
||||
samples: u32,
|
||||
limit: u32,
|
||||
}
|
||||
|
||||
impl Shutter {
|
||||
/// Seconds from the frame time to sub-sample `i` of `n`, spread over the open shutter.
|
||||
fn offset(&self, i: usize, n: usize) -> f64 {
|
||||
let f = if n > 1 { i as f64 / (n - 1) as f64 } else { 0.0 };
|
||||
(self.phase + self.angle * f) * self.frame
|
||||
}
|
||||
}
|
||||
|
||||
/// How a collapsed precomp's 3D layers are placed in the parent (see `Renderer::collapse_into`).
|
||||
|
|
@ -490,6 +533,8 @@ impl<'a> Renderer<'a> {
|
|||
inherited: None,
|
||||
defer_dof: false,
|
||||
top: true,
|
||||
through: [None; MAX_THROUGH],
|
||||
through_lost: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -524,7 +569,17 @@ impl<'a> Renderer<'a> {
|
|||
|
||||
/// A renderer for nested work (precomps, layers read by effects): one level deeper.
|
||||
fn nested(&self) -> Renderer<'a> {
|
||||
Renderer { depth: self.depth + 1, outer: None, opacity_mul: 1.0, collapse3d: None, defer_dof: false, top: false, ..*self }
|
||||
Renderer {
|
||||
depth: self.depth + 1,
|
||||
outer: None,
|
||||
opacity_mul: 1.0,
|
||||
collapse3d: None,
|
||||
defer_dof: false,
|
||||
top: false,
|
||||
through: [None; MAX_THROUGH],
|
||||
through_lost: false,
|
||||
..*self
|
||||
}
|
||||
}
|
||||
|
||||
/// The nested comp of a precomp layer whose transformations collapse into this comp: the
|
||||
|
|
@ -558,7 +613,7 @@ impl<'a> Renderer<'a> {
|
|||
/// their transforms are concatenated with the precomp layer's (one resample, no
|
||||
/// rasterisation at the precomp's bounds), their opacity multiplied by `opacity`, and their
|
||||
/// 3D layers use this comp's camera and lights.
|
||||
pub fn collapse_into(&self, ctx: &EvalCtx<'a>, layer: &Layer, item: ItemId, opacity: f32) -> Option<(Renderer<'a>, EvalCtx<'a>)> {
|
||||
pub fn collapse_into(&self, ctx: &EvalCtx<'a>, layer: &'a Layer, item: ItemId, opacity: f32) -> Option<(Renderer<'a>, EvalCtx<'a>)> {
|
||||
let nc = self.project.comp(item)?;
|
||||
let t = ctx.nested_time(layer);
|
||||
if !nc.covers(t) {
|
||||
|
|
@ -573,7 +628,15 @@ impl<'a> Renderer<'a> {
|
|||
cam: three_d::compose::camera_for(self, ctx),
|
||||
parent: self.collapse3d.map_or(*ctx, |c| c.parent),
|
||||
};
|
||||
Some((Renderer { depth: self.depth + 1, outer, opacity_mul: opacity, collapse3d: Some(c3), ..*self }, nctx))
|
||||
// Motion blur follows the precomp layer's own motion too.
|
||||
let link = Through { comp_id: ctx.comp_id, comp: ctx.comp, time: ctx.time, layer, blur: self.mb_on(ctx, layer) };
|
||||
let mut through = self.through;
|
||||
let slot = through.iter_mut().find(|t| t.is_none());
|
||||
let through_lost = self.through_lost || slot.is_none();
|
||||
if let Some(slot) = slot {
|
||||
*slot = Some(link);
|
||||
}
|
||||
Some((Renderer { depth: self.depth + 1, outer, opacity_mul: opacity, collapse3d: Some(c3), through, through_lost, ..*self }, nctx))
|
||||
}
|
||||
|
||||
pub(crate) fn opacity_mul(&self) -> f32 {
|
||||
|
|
@ -1080,7 +1143,10 @@ impl<'a> Renderer<'a> {
|
|||
|
||||
/// Source → masks, clamped/quantised to the project depth.
|
||||
fn masked_source(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Buf> {
|
||||
let mut buf = self.source(ctx, layer)?;
|
||||
let mut buf = match self.content_times(ctx, layer) {
|
||||
Some(times) => self.blurred_source(ctx, layer, ×)?,
|
||||
None => 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, self.mask_blur(ctx, layer)) || !solid {
|
||||
|
|
@ -1151,6 +1217,7 @@ impl<'a> Renderer<'a> {
|
|||
|
||||
fn content_buf_timed(&self, ctx: &EvalCtx, layer: &Layer, mut timing: Option<&mut LayerTiming>) -> Option<(Arc<Buf>, Option<u64>)> {
|
||||
let key = self.cache.and_then(|_| cache::layer_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft, self.mb_on(ctx, layer)));
|
||||
let key = self.content_key(ctx, layer, key);
|
||||
if let (Some(c), Some(k)) = (self.cache, key)
|
||||
&& let Some(b) = c.get(k)
|
||||
{
|
||||
|
|
@ -1172,6 +1239,7 @@ impl<'a> Renderer<'a> {
|
|||
/// 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<Arc<Buf>> {
|
||||
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));
|
||||
let key = self.content_key(ctx, layer, key);
|
||||
if let (Some(c), Some(k)) = (self.cache, key)
|
||||
&& let Some(b) = c.get(k)
|
||||
{
|
||||
|
|
@ -1273,9 +1341,13 @@ impl<'a> Renderer<'a> {
|
|||
|
||||
/// Buffer pixel → output pixel matrix for the layer at the context time.
|
||||
fn buf_matrix(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf) -> Mat3 {
|
||||
let s = self.opts.scale;
|
||||
let (l2c, _) = ctx.layer_to_comp(layer);
|
||||
let l2c = self.outer.map_or(l2c, |o| o * l2c);
|
||||
self.place_matrix(self.outer.map_or(l2c, |o| o * l2c), buf)
|
||||
}
|
||||
|
||||
/// Buffer pixel → output pixel matrix for a layer → (top-level) comp matrix.
|
||||
fn place_matrix(&self, l2c: Mat3, buf: &Buf) -> Mat3 {
|
||||
let s = self.opts.scale;
|
||||
self.out_matrix()
|
||||
* Mat3::scale(vec2(s, s))
|
||||
* l2c
|
||||
|
|
@ -1283,6 +1355,112 @@ impl<'a> Renderer<'a> {
|
|||
* Mat3::translate(vec2(-buf.offset[0], -buf.offset[1]))
|
||||
}
|
||||
|
||||
/// The collapsed precomp layers this comp is drawn through, outermost first.
|
||||
fn links(&self) -> impl Iterator<Item = &Through<'a>> {
|
||||
self.through.iter().map_while(Option::as_ref)
|
||||
}
|
||||
|
||||
/// The shutter of a layer's motion blur: its comp's, or for a collapsed precomp's layers the
|
||||
/// outermost containing comp's.
|
||||
fn shutter(&self, ctx: &EvalCtx) -> Shutter {
|
||||
let c: &Comp = match self.links().next() {
|
||||
Some(l) => l.comp,
|
||||
None => ctx.comp,
|
||||
};
|
||||
Shutter {
|
||||
angle: c.shutter_angle / 360.0,
|
||||
phase: c.shutter_phase / 360.0,
|
||||
frame: c.frame_duration().seconds(),
|
||||
samples: c.motion_blur_samples,
|
||||
limit: c.motion_blur_adaptive_limit,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a layer moves within the shutter: its own motion blur, or that of a collapsed
|
||||
/// precomp layer it is drawn through.
|
||||
fn blurred(&self, ctx: &EvalCtx, layer: &Layer) -> bool {
|
||||
self.mb_on(ctx, layer) || (!self.through_lost && self.links().any(|l| l.blur))
|
||||
}
|
||||
|
||||
/// The collapsed precomps' matrix (nested comp → the frame's comp) and the nested comp's time
|
||||
/// `dt` seconds into the outermost comp's shutter: each precomp layer moves if it is motion
|
||||
/// blurred, and times map down through each one (stretch, time remapping).
|
||||
fn outer_at(&self, ctx: &EvalCtx, dt: f64) -> (Option<Mat3>, Tick) {
|
||||
let shift = Tick::from_seconds_f64(dt);
|
||||
let mut outer: Option<Mat3> = None;
|
||||
let mut t: Option<Tick> = None;
|
||||
for l in self.links() {
|
||||
let at = EvalCtx { comp_id: l.comp_id, comp: l.comp, time: t.unwrap_or(l.time + shift), ..*ctx };
|
||||
let (m, _) = if l.blur { at.layer_to_comp(l.layer) } else { EvalCtx { time: l.time, ..at }.layer_to_comp(l.layer) };
|
||||
outer = Some(outer.map_or(m, |o| o * m));
|
||||
t = Some(at.nested_time(l.layer));
|
||||
}
|
||||
(outer, t.unwrap_or(ctx.time + shift))
|
||||
}
|
||||
|
||||
/// [`Self::buf_matrix`] for the motion-blur sub-sample `dt` seconds into the shutter: the
|
||||
/// layer moves if it is motion blurred, the collapsed precomps above it if theirs are.
|
||||
fn buf_matrix_at(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf, dt: f64) -> Mat3 {
|
||||
if self.through_lost || self.links().next().is_none() {
|
||||
return self.buf_matrix(&ctx.at(ctx.time + Tick::from_seconds_f64(dt)), layer, buf);
|
||||
}
|
||||
let (outer, t) = self.outer_at(ctx, dt);
|
||||
let lctx = if self.mb_on(ctx, layer) { ctx.at(t) } else { *ctx };
|
||||
let (l2c, _) = lctx.layer_to_comp(layer);
|
||||
self.place_matrix(outer.map_or(l2c, |o| o * l2c), buf)
|
||||
}
|
||||
|
||||
/// Shape and text content that animates within the shutter (paths, Trim Paths, text
|
||||
/// animators…) of a motion-blurred layer: the times its source is drawn at and averaged,
|
||||
/// Samples Per Frame of them, as After Effects does for shape layers. `None` when the content
|
||||
/// holds still (compared through its cache key, which leaves the transform out).
|
||||
fn content_times(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Vec<Tick>> {
|
||||
if !matches!(layer.source, LayerSource::Shape | LayerSource::Text) || !self.mb_on(ctx, layer) {
|
||||
return None;
|
||||
}
|
||||
let sh = self.shutter(ctx);
|
||||
if sh.angle <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let n = if self.opts.draft { 4 } else { (sh.samples as usize).clamp(2, 64) };
|
||||
let times: Vec<Tick> = (0..n).map(|i| self.outer_at(ctx, sh.offset(i, n)).1).collect();
|
||||
let key = |t: Tick| cache::layer_key(&ctx.at(t), layer, 1.0, false, false);
|
||||
let k0 = key(*times.first()?)?;
|
||||
let moves = [times.get(n / 2), times.last()].into_iter().flatten().any(|t| key(*t) != Some(k0));
|
||||
moves.then_some(times)
|
||||
}
|
||||
|
||||
/// The layer's source drawn at each of `times` and averaged (premultiplied) on the union of
|
||||
/// their bounds. Content that also changes its raster scale within the shutter, or would need
|
||||
/// an outsized buffer, is drawn once.
|
||||
fn blurred_source(&self, ctx: &EvalCtx, layer: &Layer, times: &[Tick]) -> Option<Buf> {
|
||||
let bufs: Vec<Buf> = times.iter().filter_map(|t| self.source(&ctx.at(*t), layer)).collect();
|
||||
let scale = bufs.first()?.scale;
|
||||
// Pixel = layer point × scale + offset: with the largest offset every buffer lands at
|
||||
// x + (off - its offset) ≥ 0.
|
||||
let off = [0, 1].map(|i| bufs.iter().map(|b| b.offset[i]).fold(f64::MIN, f64::max));
|
||||
let w = bufs.iter().map(|b| off[0] - b.offset[0] + b.img.width as f64).fold(0.0, f64::max).ceil();
|
||||
let h = bufs.iter().map(|b| off[1] - b.offset[1] + b.img.height as f64).fold(0.0, f64::max).ceil();
|
||||
if bufs.iter().any(|b| (b.scale - scale).abs() > 1e-9) || !(w * h).is_finite() || w * h > (1u64 << 26) as f64 {
|
||||
return self.source(ctx, layer);
|
||||
}
|
||||
let mut acc = Image::new(w as u32, h as u32);
|
||||
let k = 1.0 / bufs.len() as f32;
|
||||
for b in &bufs {
|
||||
let m = Mat3::translate(vec2(off[0] - b.offset[0], off[1] - b.offset[1]));
|
||||
effectcraft_raster::accumulate_warp(&mut acc, &b.img, &m, effectcraft_raster::Sampling::Bilinear, k);
|
||||
}
|
||||
Some(Buf { img: acc, offset: off, scale })
|
||||
}
|
||||
|
||||
/// A processed-layer cache key that also covers content motion blur (the averaged source
|
||||
/// depends on the content at every sub-sample).
|
||||
fn content_key(&self, ctx: &EvalCtx, layer: &Layer, key: Option<u64>) -> Option<u64> {
|
||||
let k = key?;
|
||||
let Some(times) = self.content_times(ctx, layer) else { return Some(k) };
|
||||
times.iter().try_fold(cache::derive(k, 0x6d62_6c72), |d, t| Some(cache::derive(d, cache::layer_key(&ctx.at(*t), layer, 1.0, false, false)?)))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
|
@ -1304,7 +1482,7 @@ impl<'a> Renderer<'a> {
|
|||
/// at least Samples Per Frame and at most the comp's Adaptive Sample Limit; a layer that
|
||||
/// does not move within the shutter is drawn once.
|
||||
fn mb_samples(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf) -> usize {
|
||||
if !self.mb_on(ctx, layer) {
|
||||
if !self.blurred(ctx, layer) {
|
||||
return 1;
|
||||
}
|
||||
let travel = self.shutter_travel(ctx, layer, buf);
|
||||
|
|
@ -1314,22 +1492,21 @@ impl<'a> Renderer<'a> {
|
|||
if self.opts.draft {
|
||||
return 4;
|
||||
}
|
||||
adaptive_samples(travel, ctx.comp.motion_blur_samples, ctx.comp.motion_blur_adaptive_limit)
|
||||
let sh = self.shutter(ctx);
|
||||
adaptive_samples(travel, sh.samples, sh.limit)
|
||||
}
|
||||
|
||||
/// Largest distance (output pixels) a corner of the layer buffer moves during the shutter,
|
||||
/// measured along the path at a few points.
|
||||
fn shutter_travel(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf) -> f64 {
|
||||
let fd = ctx.comp.frame_duration().seconds();
|
||||
let (angle, phase) = (ctx.comp.shutter_angle / 360.0, ctx.comp.shutter_phase / 360.0);
|
||||
let sh = self.shutter(ctx);
|
||||
let (w, h) = (buf.img.width as f64, buf.img.height as f64);
|
||||
let corners = [(0.0, 0.0), (w, 0.0), (0.0, h), (w, h), (w * 0.5, h * 0.5)];
|
||||
const PROBES: usize = 8;
|
||||
let mut prev: Option<Vec<effectcraft_geom::Vec2>> = None;
|
||||
let mut travel = vec![0.0f64; corners.len()];
|
||||
for i in 0..PROBES {
|
||||
let f = phase + angle * i as f64 / (PROBES - 1) as f64;
|
||||
let m = self.buf_matrix(&ctx.at(ctx.time + Tick::from_seconds_f64(f * fd)), layer, buf);
|
||||
let m = self.buf_matrix_at(ctx, layer, buf, sh.offset(i, PROBES));
|
||||
let pts: Vec<_> = corners.iter().map(|&(x, y)| m.apply(vec2(x, y))).collect();
|
||||
if let Some(p) = &prev {
|
||||
for (k, (a, b)) in p.iter().zip(&pts).enumerate() {
|
||||
|
|
@ -1388,15 +1565,8 @@ impl<'a> Renderer<'a> {
|
|||
if samples <= 1 {
|
||||
return Placement { matrices: vec![self.buf_matrix(ctx, layer, buf)], sampling, seed };
|
||||
}
|
||||
let fd = ctx.comp.frame_duration().seconds();
|
||||
let angle = ctx.comp.shutter_angle / 360.0;
|
||||
let phase = ctx.comp.shutter_phase / 360.0;
|
||||
let matrices = (0..samples)
|
||||
.map(|i| {
|
||||
let f = phase + angle * i as f64 / (samples - 1) as f64;
|
||||
self.buf_matrix(&ctx.at(ctx.time + Tick::from_seconds_f64(f * fd)), layer, buf)
|
||||
})
|
||||
.collect();
|
||||
let sh = self.shutter(ctx);
|
||||
let matrices = (0..samples).map(|i| self.buf_matrix_at(ctx, layer, buf, sh.offset(i, samples))).collect();
|
||||
Placement { matrices, sampling, seed }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -422,6 +422,13 @@ layers); the Graph Editor has Auto-Select Graph Type (the default: the speed gra
|
|||
spatial properties are shown, else the value graph; choosing Value or Speed turns it off). Graph
|
||||
Editor keys are small squares whatever their interpolation, as in After Effects.
|
||||
|
||||
Since (M4.13): motion blur follows a collapsed precomp layer's own motion. Its nested layers are
|
||||
drawn with the precomp layer's transform at every sub-sample, and with the containing comp's
|
||||
shutter, as they are drawn into its frames. Shape and text layers whose content animates within
|
||||
the shutter (paths, Trim Paths, shape transforms, text animators) are drawn at Samples Per Frame
|
||||
times and averaged, as After Effects does for shape layers; content that holds still is drawn
|
||||
once.
|
||||
|
||||
Since (M3.15): the Timeline shows one tab per open comp, as in After Effects. Double-clicking a
|
||||
comp in the Project panel opens it as a new tab next to the comps already open; it no longer
|
||||
renames (Enter or the context menu still do). A tab's × closes that comp's Timeline, not the
|
||||
|
|
@ -715,8 +722,8 @@ are pinned bit for bit by golden hashes (`crates/effects/tests/particle_golden.r
|
|||
13. ~~Lottie, WebM, SVG and PSD import~~ (landed; a disk cache too).
|
||||
14. ~~Puppet and paint tools~~ (second wave; puppet rigging and follow-through in M13.14–M13.16).
|
||||
15. ~~Preferences and a shortcut editor that can rebind; real Wiggler, Smoother and Motion Sketch; the marker dialog.~~ (second and third waves)
|
||||
16. Motion blur of collapsed precomps (their own motion, the parent camera's) and of animated
|
||||
content inside a layer (shape paths, text animators, nested frames).
|
||||
16. ~~Motion blur of collapsed precomps and of animated content inside a layer (shape paths, text
|
||||
animators).~~ (M4.13)
|
||||
17. Content-keyed RAM preview frames, so an edit keeps the frames of comps it doesn't touch.
|
||||
18. View ▸ New Viewer (several unlocked Composition viewers), nested comp markers on the
|
||||
precomp layer bar, audio scrubbing.
|
||||
|
|
|
|||
Loading…
Reference in New Issue