Merge main into M13.6

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-03 07:53:31 -04:00
co-authored by Claude Opus 5.5
177 changed files with 22316 additions and 2167 deletions
+87 -7
View File
@@ -157,6 +157,38 @@ impl EffectHost for FxHost<'_, '_, '_> {
}
}
fn params_at(&self, layer_time: f64) -> Option<Params> {
let i = self.index.load(std::sync::atomic::Ordering::Relaxed);
let g = self.layer.effects()?.groups().nth(i)?;
let ctx = self.ctx.at(self.layer.comp_time(Tick::from_seconds_f64(layer_time)));
Some(effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(self.layer, pr)))
}
fn comp_scene(&self) -> Option<effectcraft_effects::CompScene> {
// The comp camera's view of the layer (as if 3D at its transform), brought back into the
// layer's own pixel grid through the inverse of how the layer itself composites.
let world = self.ctx.world_matrix(self.layer);
let (cam, _, _) = self.ctx.camera();
let p = (cam * world).0;
let back = self.ctx.layer_to_comp(self.layer).0.inverse()?.0;
let rows = [p[0], p[1], p[3]];
let camera: [[f64; 4]; 3] = std::array::from_fn(|i| std::array::from_fn(|j| (0..3).map(|k| back[i][k] * rows[k][j]).sum()));
let inv = world.inverse();
let light = three_d::light::lights_at(self.ctx).first().and_then(|l| {
let inv = inv.as_ref()?;
let pos = inv.apply(l.pos);
let dir = inv.apply_vec(l.dir);
let len = (dir.x * dir.x + dir.y * dir.y + dir.z * dir.z).sqrt().max(1e-12);
let kind = match l.kind {
effectcraft_project::LightKind::Parallel => 0,
effectcraft_project::LightKind::Ambient => 2,
_ => 1,
};
Some(effectcraft_effects::CompLight { pos: [pos.x, pos.y, pos.z], dir: [dir.x / len, dir.y / len, dir.z / len], color: l.color, kind })
});
Some(effectcraft_effects::CompScene { camera: Some(camera), light })
}
fn layer_at(&self, id: u64, comp_time: f64, masks_and_effects: bool) -> Option<LayerPixels> {
let other = self.ctx.layer(effectcraft_project::LayerId(id))?;
if other.id == self.layer.id || self.r.depth > MAX_FX_DEPTH {
@@ -365,6 +397,9 @@ pub struct Renderer<'a> {
pub(crate) inherited: Option<(Quality, bool)>,
/// Classic 3D depth of field is left to an accelerator ([`three_d::Plane3d::dof`]).
pub(crate) defer_dof: bool,
/// 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,
}
/// How a collapsed precomp's 3D layers are placed in the parent (see `Renderer::collapse_into`).
@@ -410,6 +445,7 @@ impl<'a> Renderer<'a> {
collapse3d: None,
inherited: None,
defer_dof: false,
top: true,
}
}
@@ -444,7 +480,7 @@ 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, ..*self }
Renderer { depth: self.depth + 1, outer: None, opacity_mul: 1.0, collapse3d: None, defer_dof: false, top: false, ..*self }
}
/// The nested comp of a precomp layer whose transformations collapse into this comp: the
@@ -607,18 +643,28 @@ impl<'a> Renderer<'a> {
let s = self.opts.scale;
let w = ((comp.width as f64 * s).round() as u32).max(1);
let h = ((comp.height as f64 * s).round() as u32).max(1);
// Region of interest on a comp with 3D layers: render the frame and crop it.
// Region of interest on a comp with 3D layers: render the frame and crop it. A region
// reaching past the comp frame (the Extended Viewer) renders Classic 3D natively — the
// planes project through the offset camera — and Advanced 3D as the frame, padded.
if self.depth == 0
&& let Some(r) = self.opts.roi
&& comp.has_3d()
&& (roi_inside(r, comp.width, comp.height) || comp.renderer == effectcraft_project::Renderer::Advanced3D)
{
let full = Renderer { opts: RenderOpts { roi: None, ..self.opts }, ..*self }.comp_frame(comp_id, t);
let (x0, y0) = ((r[0] * s).round().max(0.0) as u32, (r[1] * s).round().max(0.0) as u32);
let (x0, y0) = ((r[0] * s).round() as i64, (r[1] * s).round() as i64);
let (rw, rh) = (((r[2] * s).round() as u32).max(1), ((r[3] * s).round() as u32).max(1));
let mut out = Image::new(rw, rh);
for y in 0..rh.min(full.height.saturating_sub(y0)) {
for x in 0..rw.min(full.width.saturating_sub(x0)) {
out.data[(y * rw + x) as usize] = full.data[((y + y0) * full.width + x + x0) as usize];
for y in 0..rh as i64 {
let sy = y + y0;
if sy < 0 || sy >= full.height as i64 {
continue;
}
for x in 0..rw as i64 {
let sx = x + x0;
if sx >= 0 && sx < full.width as i64 {
out.data[(y * rw as i64 + x) as usize] = full.data[(sy * full.width as i64 + sx) as usize];
}
}
}
return out;
@@ -730,6 +776,11 @@ 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,
)),
};
// 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
@@ -1127,7 +1178,7 @@ impl<'a> Renderer<'a> {
/// The region of interest of a top-level 2D frame: (x, y) offset of the output in output
/// pixels and its size. `None` renders the whole comp (no ROI, or a nested comp).
pub(crate) fn roi_offset(&self) -> Option<(f64, f64, u32, u32)> {
let r = self.opts.roi.filter(|_| self.depth == 0)?;
let r = self.opts.roi.filter(|_| self.top)?;
let s = self.opts.scale;
if r[2] <= 0.0 || r[3] <= 0.0 {
return None;
@@ -1135,6 +1186,11 @@ impl<'a> Renderer<'a> {
Some(((r[0] * s).round(), (r[1] * s).round(), ((r[2] * s).round() as u32).max(1), ((r[3] * s).round() as u32).max(1)))
}
/// The region of interest's offset in output pixels for 3D projection (0 without one).
pub(crate) fn out_offset(&self) -> (f64, f64) {
self.roi_offset().map_or((0.0, 0.0), |(x, y, _, _)| (x, y))
}
/// Scaled comp pixel → output pixel: the region of interest's offset.
fn out_matrix(&self) -> Mat3 {
match self.roi_offset() {
@@ -1648,6 +1704,30 @@ pub fn adaptive_samples(travel: f64, per_frame: u32, limit: u32) -> usize {
travel.ceil().clamp(lo, hi) as usize
}
/// A region of interest `[x, y, w, h]` lies within a `w`×`h` comp frame.
pub fn roi_inside(r: [f64; 4], w: u32, h: u32) -> bool {
r[0] >= 0.0 && r[1] >= 0.0 && r[0] + r[2] <= w as f64 + 1e-6 && r[1] + r[3] <= h as f64 + 1e-6
}
/// The Extended Viewer's region of interest: the comp frame grown by `margin` × its size on every
/// side (`margin` = 1 shows a pasteboard one comp wide around the frame), intersected with the
/// `visible` comp-space rectangle `[x0, y0, x1, y1]` and snapped outward to whole 16 px steps from the frame edges
/// so panning re-renders rarely. `None` when the visible area lies inside the frame.
pub fn extended_region(w: u32, h: u32, visible: [f64; 4], margin: f64) -> Option<[f64; 4]> {
let (w, h) = (w as f64, h as f64);
let (mx, my) = (w * margin, h * margin);
let snap_lo = |v: f64| (v / 16.0).floor() * 16.0;
let snap_hi = |v: f64| (v / 16.0).ceil() * 16.0;
let x0 = snap_lo(visible[0].max(-mx)).min(0.0);
let y0 = snap_lo(visible[1].max(-my)).min(0.0);
let x1 = w + snap_hi(visible[2].min(w + mx) - w).max(0.0);
let y1 = h + snap_hi(visible[3].min(h + my) - h).max(0.0);
if x0 >= 0.0 && y0 >= 0.0 && x1 <= w && y1 <= h {
return None;
}
Some([x0, y0, x1 - x0, y1 - y0])
}
pub fn render_frame(project: &Project, comp: ItemId, t: Tick, scale: f64) -> Image {
Renderer::new(project, &NoFootage, RenderOpts { scale, ..Default::default() }).comp_frame(comp, t)
}
+13
View File
@@ -163,6 +163,19 @@ fn effects_run_in_pipeline() {
assert!(c[1] > 0.99 && c[0] < 0.01, "{c:?}");
}
/// Card Dance's Comp Camera through the default comp camera reproduces a 2D layer in place.
#[test]
fn card_dance_comp_camera_matches_the_default_view() {
let (mut p, cid, comp) = setup();
let mut l = solid(&mut p, &comp, [0.2, 0.6, 1.0], 50, 50);
add_effect(&mut p, &mut l, "ec.sim.carddance", [50.0, 50.0], &[("cameraSystem", Value::Enum(2))]);
p.comp_mut(cid).unwrap().layers.push(l);
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
let c = img.get(100, 50);
assert!((c[0] - 0.2).abs() < 0.02 && (c[2] - 1.0).abs() < 0.02 && c[3] > 0.99, "{c:?}");
assert!(img.get(10, 50)[3] < 0.01);
}
// ------------------------------------------------------------------------------ layer cache
fn render_cached(p: &Project, cid: ItemId, t: Tick, cache: Option<&crate::LayerCache>) -> crate::Image {
+30
View File
@@ -71,3 +71,33 @@ fn roi_at_half_resolution() {
assert_eq!((roi.width, roi.height), (30, 20));
assert_same(&roi, &crop(&full, 20, 10, 30, 20));
}
/// Extended Viewer: a region reaching past the comp frame shows the 3D layer's pixels on the
/// pasteboard, and its inner part equals the normal frame.
#[test]
fn extended_region_renders_3d_layers_beyond_the_frame() {
let (mut p, cid) = project(true);
// Push the 3D (blue) layer half out of the frame on the right.
let comp = p.comp_mut(cid).unwrap();
comp.layers[1].props.prop_mut("transform/position").unwrap().value = Value::Vec3([150.0, 60.0, 0.0]);
comp.layers[1].props.prop_mut("transform/rotation").unwrap().value = Value::Scalar(0.0);
let full = Renderer::new(&p, &NoFootage, RenderOpts::default()).comp_frame(cid, Tick::ZERO);
let region = crate::extended_region(160, 120, [-100.0, -100.0, 260.0, 220.0], 0.5).unwrap();
assert_eq!(region, [-80.0, -64.0, 320.0, 248.0]);
let ext = Renderer::new(&p, &NoFootage, RenderOpts { roi: Some(region), ..Default::default() }).comp_frame(cid, Tick::ZERO);
assert_eq!((ext.width, ext.height), (320, 248));
// Comp x 175 (past the 160 px frame) at y 60: the layer covers x 115..185.
let out = ext.get(175 + 80, 60 + 64);
assert!(out[3] > 0.5 && out[2] > 0.5, "pasteboard pixel {out:?}");
// Nothing is drawn far outside every layer.
assert_eq!(ext.get(5, 5)[3], 0.0);
// The frame part is the normal frame.
assert_same(&crop(&ext, 80, 64, 160, 120), &full);
}
#[test]
fn extended_region_is_none_inside_the_frame() {
assert_eq!(crate::extended_region(160, 120, [10.0, 10.0, 150.0, 110.0], 1.0), None);
// Clamped to the margin.
assert_eq!(crate::extended_region(100, 100, [-1000.0, 0.0, 50.0, 100.0], 1.0), Some([-112.0, 0.0, 212.0, 100.0]));
}
+6 -4
View File
@@ -89,8 +89,10 @@ impl Item<'_> {
}
/// Build the plane geometry of a layer for a camera.
fn geo(cam: &CameraState, world: Mat4, b: [f64; 4], comp: (f64, f64), scale: f64, out: (u32, u32)) -> Option<Geo> {
let proj = Mat4::scale(vec3(scale, scale, 1.0)) * cam.projection(comp.0, comp.1);
fn geo(cam: &CameraState, world: Mat4, b: [f64; 4], comp: (f64, f64), scale: f64, out: (u32, u32), off: (f64, f64)) -> Option<Geo> {
// The output canvas may start at `off` (scaled comp pixels): region of interest / Extended
// Viewer frames.
let proj = Mat4::translate(vec3(-off.0, -off.1, 0.0)) * Mat4::scale(vec3(scale, scale, 1.0)) * cam.projection(comp.0, comp.1);
let full = proj * world;
let h = full.plane_to_mat3();
// Edge-on planes project to a line (singular homography): nothing to draw.
@@ -247,7 +249,7 @@ fn prepare_with<'a>(
}
let n = if in_run { mb_samples(r, ctx, layer) } else { 1 };
let geos: Vec<Geo> = if n <= 1 {
geo(&cam, world, bounds, comp, s, out).into_iter().collect()
geo(&cam, world, bounds, comp, s, out, r.out_offset()).into_iter().collect()
} else {
let fd = ctx.comp.frame_duration().seconds();
let angle = ctx.comp.shutter_angle / 360.0;
@@ -257,7 +259,7 @@ fn prepare_with<'a>(
let f = phase + angle * i as f64 / (n - 1) as f64;
let sub = ctx.at(ctx.time + Tick::from_seconds_f64(f * fd));
let c = camera_for(r, &sub);
geo(&c, outer * sub.world_matrix(layer) * local, bounds, comp, s, out)
geo(&c, outer * sub.world_matrix(layer) * local, bounds, comp, s, out, r.out_offset())
})
.collect()
};