mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-25 18:47:07 -06:00
- Collapsed precomps: the renderer records the precomp layers a nested comp is drawn through; motion-blur sub-samples re-evaluate their transforms (when their motion blur is on) and map times down through each one, so the precomp layer's own motion blurs its nested layers. The containing (outermost) comp's shutter applies to collapsed layers. The GPU compositor gets this through Renderer::placement. - Shape and text layers whose content changes within the shutter (compared by its cache key) draw their source at Samples Per Frame times and average them; the cache key covers every sub-sample's content. - Tests: tests_motion_blur_depth (collapsed vs flat, the containing shutter, shape content vs the same move by Position); each fails without the change. - Docs: roadmap, parity. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
123 lines
6.0 KiB
Rust
123 lines
6.0 KiB
Rust
//! 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)));
|
||
}
|