Merge main into #1739; keep both footage re-exports (Cineon + VIDEO_EXTENSIONS) and main's gaps.md localization row with the PR's AVI row

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatasticandClaude Opus 5.5 committed 2026-10-30 07:44:20 +07:00
commit 9e6332ea27
181 files changed
+8437 -619

No files matched your search

+8
View File
@@ -131,6 +131,14 @@ pub trait FxTarget {
buf
});
}
/// Give the buffer the pixel-grid phase the layer rectangle's cover would
/// ([`effectcraft_effects::Buf::align`]): grows left/top by less than 64 pixels.
fn align(&mut self, size: [f64; 2]) {
self.cpu(&mut |mut buf| {
buf.align(size);
buf
});
}
}
/// The CPU effect target (GPU chains go through [`Accelerator::effects`]: upload, readback).
+4 -4
View File
@@ -406,7 +406,7 @@ mod tests {
pr.value = effectcraft_project::Value::Vec2([-6.0206, -96.0]);
}
let mut comp = comp;
comp.layers = vec![l1, l2];
comp.layers = vec![l1, l2].into();
let cid = p.add_item("C", effectcraft_color::Label::Sandstone, None, ItemKind::Comp(comp.into()));
assert!(comp_has_audio(&p, cid));
let rate = 1000;
@@ -452,11 +452,11 @@ mod tests {
let base = Comp::new(64, 64, FrameRate::new(25, 1), Tick::from_seconds_f64(2.0));
let tone = build::layer(&mut p, &base, "tone", LayerSource::Footage { item: fid }, (0, 0), None);
let mut inner = base.clone();
inner.layers = vec![tone];
inner.layers = vec![tone].into();
let inner_id = p.add_item("Inner", effectcraft_color::Label::Sandstone, None, ItemKind::Comp(inner.into()));
let pre = build::layer(&mut p, &base, "pre", LayerSource::Comp { item: inner_id }, (64, 64), None);
let mut outer = base.clone();
outer.layers = vec![pre];
outer.layers = vec![pre].into();
let outer_id = p.add_item("Outer", effectcraft_color::Label::Sandstone, None, ItemKind::Comp(outer.into()));
let rate = 8000;
let t = Tick::from_seconds_f64(0.5);
@@ -483,7 +483,7 @@ mod tests {
let mut tone = build::layer(&mut p, &base, "tone", LayerSource::Footage { item: fid }, (0, 0), None);
tone.stretch = stretch;
let mut comp = base.clone();
comp.layers = vec![tone];
comp.layers = vec![tone].into();
let cid = p.add_item("C", effectcraft_color::Label::Sandstone, None, ItemKind::Comp(comp.into()));
let n = 2000usize;
let t = Tick::from_seconds_f64(0.5);
+1 -1
View File
@@ -458,7 +458,7 @@ fn time_dependent(layer: &Layer, effects: bool) -> bool {
if matches!(layer.source, LayerSource::Shape)
&& let Some(c) = layer.props.sub("contents")
{
return group_has(c, "wiggle");
return group_has(c, "wiggle") || group_has(c, "wiggler");
}
// Wiggly selectors move with time; Expression selectors evaluate per character (their
// property value alone doesn't capture the result).
+1 -1
View File
@@ -1278,7 +1278,7 @@ mod tests {
let f = Footage { path: "/virtual.mov".into(), width: 16, height: 9, duration: Tick::from_seconds_f64(1.0), has_video: true, ..Default::default() };
let fid = p.add_item("virtual.mov", effectcraft_color::Label::None, None, ItemKind::Footage(f));
let mut comp = Comp::new(16, 9, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
comp.layers = vec![build::layer(&mut p, &comp, "clip", LayerSource::Footage { item: fid }, (16, 9), None)];
comp.layers = vec![build::layer(&mut p, &comp, "clip", LayerSource::Footage { item: fid }, (16, 9), None)].into();
let cid = p.add_item("C", effectcraft_color::Label::None, None, ItemKind::Comp(comp.into()));
let id = |mark: u64| move |path: &str| (path == "/virtual.mov").then_some((8, mark));
+19 -1
View File
@@ -1,6 +1,7 @@
use super::footage::NoFootage;
use super::opts::RenderOpts;
use super::{EvalCtx, Footage, FrameBlend, FrameRate, Image, ItemId, Layer, LayerSource, Project, Renderer, TICKS_PER_SECOND, Tick, shapes, text};
use super::{Arc, EvalCtx, Footage, FrameBlend, FrameRate, Image, ItemId, Layer, LayerSource, Project, Renderer, TICKS_PER_SECOND, Tick, shapes, text};
use rayon::prelude::*;
/// The layer's frame blending mode, if the comp's Enable Frame Blending switch is on.
/// One field of an interlaced frame as a full frame: the field's lines (`parity` 0 = even /
@@ -33,6 +34,23 @@ pub fn field_frame(img: &Image, parity: usize) -> Image {
out
}
/// Interpret Footage ▸ Cineon Settings ▸ Logarithmic Conversion converts the log code values of
/// a decoded (premultiplied) frame of `f` to the Current Gamma. When the conversion does not
/// apply, the frame is returned unchanged.
pub fn cineon_frame(img: Arc<Image>, f: &Footage) -> Arc<Image> {
if !f.cineon_applies() {
return img;
}
let cs = f.cineon;
let mut out = (*img).clone();
out.data.par_iter_mut().for_each(|p| {
let a = p[3];
let c = if a > 1e-6 { [p[0] / a, p[1] / a, p[2] / a] } else { [0.0; 3] }.map(|v| cs.convert(v));
*p = [c[0] * a, c[1] * a, c[2] * a, a];
});
Arc::new(out)
}
/// Interpret Footage ▸ Invert Alpha and Interpret As Linear Light on decoded (premultiplied)
/// pixels; linear light is encoded in `space` ([`color::Pipe::linear_footage_space`]).
pub fn interpret_pixels(img: &Image, f: &Footage, space: effectcraft_color::ColorSpace) -> Image {
+2 -2
View File
@@ -48,8 +48,8 @@ mod pipeline;
pub use accel::{Accelerator, Backend, FxStep, FxTarget};
pub use footage::{CancellableFootage, FloatCurve, FootageSource, NoFootage, is_vector_footage};
pub use frames::{
adaptive_samples, blend_frames, content_bounds, extended_region, field_frame, frame_position, frame_size, interpret_pixels, kurbo_path, render_frame,
roi_inside,
adaptive_samples, blend_frames, cineon_frame, content_bounds, extended_region, field_frame, frame_position, frame_size, interpret_pixels, kurbo_path,
render_frame, roi_inside,
};
#[cfg(test)]
use fx_host::FxHost;
+33 -15
View File
@@ -1,6 +1,6 @@
use super::accel::{Accelerator, Backend, CpuFx, FxStep, FxTarget};
use super::footage::{FootageSource, is_vector_footage};
use super::frames::{blend_frames, content_bounds, field_frame, frame_blend_mode, frame_position, frame_size, interpret_pixels, roi_inside};
use super::frames::{blend_frames, cineon_frame, content_bounds, field_frame, frame_blend_mode, frame_position, frame_size, interpret_pixels, roi_inside};
use super::fx_host::{FxHost, essential_overrides};
use super::opts::{Collapse3d, LayerTiming, MAX_THROUGH, RenderOpts, Through};
use super::{
@@ -566,14 +566,26 @@ impl<'a> Renderer<'a> {
let Some(fx) = layer.effects() else { return };
let lt = layer.layer_time(ctx.time);
let layer_size = ctx.effect_bounds(layer).0;
// #659: an authored layer's buffer spans only its painted content while the stack works
// on the layer's whole region (`layer_size`): give the buffer the room a sized source
// already has, so tiling or displacement can place pixels outside the content instead
// of losing them at its edge. Only when something will actually run, and not for an
// adjustment layer, whose buffer is the comp below and already spans it.
if !adjustment
&& fx.groups().any(|g| g.enabled && matches!(&g.kind, GroupKind::Effect { effect } if effectcraft_effects::find(effect).is_some_and(|s| !effectcraft_effects::audio_fx::is_audio_effect(s.id)))) {
target.cover(layer_size);
// #659 / #1205: an authored layer's buffer spans only its painted content. Effects that
// place pixels outside it grow the buffer themselves, by exactly what they need
// (`layer_cover_pad`: CC Tiler, CC Smear, CC Mosaic sharp) instead of a blanket cover of
// the whole layer rectangle on every stack — which paid a comp-sized texture per layer,
// per frame in flight, for glow and blur that never use the extra room. The buffer still
// takes the cover's pixel-grid phase: a downscaled layer resamples along that grid.
// Effects not known to stay inside their own margins (Twirl, Bulge, Transform, ...) still
// get the cover: they move content past the painted bounds without growing the buffer.
let video = || {
fx.groups().filter(|g| g.enabled).filter_map(|g| match &g.kind {
GroupKind::Effect { effect } => effectcraft_effects::find(effect).filter(|s| !effectcraft_effects::audio_fx::is_audio_effect(s.id)),
_ => None,
})
};
if !adjustment && video().next().is_some() {
if video().all(|s| s.skips_cover()) {
target.align(layer_size);
} else {
target.cover(layer_size);
}
}
let mut mask_shapes = masks::shapes(ctx, layer);
if origin != [0.0; 2] {
@@ -817,16 +829,17 @@ impl<'a> Renderer<'a> {
if f.fields != effectcraft_project::FieldOrder::Off && matches!(f.kind, FootageKind::Video | FootageKind::Sequence) {
let field_rate = FrameRate::new(f.frame_rate.num * 2, f.frame_rate.den);
let i = field_rate.frame_at(t).max(0);
let img = self.footage.frame_with(item, f, f.frame_rate.tick_of(i / 2), None, self.pipe.float_curve(f))?;
let img = cineon_frame(self.footage.frame_with(item, f, f.frame_rate.tick_of(i / 2), None, self.pipe.float_curve(f))?, f);
let dominant_upper = f.fields == effectcraft_project::FieldOrder::UpperFirst;
// Upper field = even lines (0, 2, …).
let parity = if (i % 2 == 0) == dominant_upper { 0 } else { 1 };
return Some(Arc::new(interpret_pixels(&field_frame(&img, parity), f, self.pipe.linear_footage_space(f))));
}
// Interpretation is per pixel but not linear: it runs on the frame before resampling.
let size = size.filter(|_| !(f.invert_alpha || f.linear_light_applies()));
let per_pixel = f.invert_alpha || f.linear_light_applies();
let size = size.filter(|_| !(per_pixel || f.cineon_applies()));
let img = self.footage_frame_raw(ctx, layer, item, f, t, size)?;
if f.invert_alpha || f.linear_light_applies() { Some(Arc::new(interpret_pixels(&img, f, self.pipe.linear_footage_space(f)))) } else { Some(img) }
if per_pixel { Some(Arc::new(interpret_pixels(&img, f, self.pipe.linear_footage_space(f)))) } else { Some(img) }
}
fn footage_frame_raw(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &Footage, t: Tick, size: Option<(u32, u32)>) -> Option<Arc<Image>> {
@@ -844,6 +857,10 @@ impl<'a> Renderer<'a> {
if f.alpha == effectcraft_project::AlphaMode::Premultiplied {
f.premul_color.map(f32::to_bits).hash(&mut h);
}
if f.cineon_applies() {
let c = &f.cineon;
[c.converted_black, c.converted_white, c.black, c.white, c.gamma, c.rolloff].map(f32::to_bits).hash(&mut h);
}
h.finish()
});
if let (Some(c), Some(k)) = (self.cache, key)
@@ -851,8 +868,9 @@ impl<'a> Renderer<'a> {
{
return Some(Arc::new(b.img.clone()));
}
let a = self.footage.frame_with(item, f, f.frame_rate.tick_of(i), None, float);
let b = self.footage.frame_with(item, f, f.frame_rate.tick_of(i + 1), None, float);
// Log scans are converted before they blend: the conversion is not linear.
let a = self.footage.frame_with(item, f, f.frame_rate.tick_of(i), None, float).map(|a| cineon_frame(a, f));
let b = self.footage.frame_with(item, f, f.frame_rate.tick_of(i + 1), None, float).map(|b| cineon_frame(b, f));
if let (Some(a), Some(b)) = (a, b) {
let img = blend_frames(mode, &a, &b, w as f32);
if let (Some(c), Some(k)) = (self.cache_to_keep(), key) {
@@ -862,7 +880,7 @@ impl<'a> Renderer<'a> {
}
}
}
self.footage.frame_with(item, f, t, size, float)
self.footage.frame_with(item, f, t, size, float).map(|img| cineon_frame(img, f))
}
/// Source → masks, clamped/quantised to the project depth.
+80 -3
View File
@@ -2,8 +2,8 @@
//! with the After Effects stacking semantics:
//!
//! - path operators (Trim Paths, Round Corners, Offset Paths, Pucker & Bloat, Twist, Zig Zag,
//! Wiggle Paths, Merge Paths, Repeater) act on all paths above them in the same group, including
//! the paths of nested groups;
//! Wiggle Paths, Wiggle Transform, Merge Paths, Repeater) act on all paths above them in the
//! same group, including the paths of nested groups;
//! - paint items (fills, strokes, gradient fills/strokes) paint the paths above them, *as modified
//! by every operator in the stack* — a Trim Paths below a Stroke still trims it;
//! - items higher in the list draw on top;
@@ -17,7 +17,9 @@ use effectcraft_keyframe::{Gradient, Value};
use effectcraft_path::{BezPath, Cap, FillRule, Join, StrokeStyle, ops, varstroke};
use effectcraft_project::{Layer, Node, PropGroup};
use effectcraft_raster::{Image, Mask};
use effectcraft_text::selectors::{self, Wiggly};
use rayon::prelude::*;
use std::collections::{HashMap, HashSet};
use crate::eval::EvalCtx;
@@ -185,9 +187,75 @@ fn used_slots(live: &[usize], draws: &[Draw]) -> Vec<usize> {
v
}
/// Wiggle Transform: moves the live slots by a random offset of the Transform group's Anchor
/// Point, Position, Scale and Rotation (the most each one wiggles), changing over time. Its
/// Wiggles/Second, Correlation, Temporal Phase, Spatial Phase and Random Seed work as the Wiggly
/// Selector's do, with each Repeater copy (`copy_of`) as one unit: the copies of a Repeater above
/// the operation move on their own as far as Correlation allows, and the copies of a Repeater
/// below it move as one. The four transformations of one operation move together.
fn wiggle_transform(ctx: &EvalCtx, layer: &Layer, g: &PropGroup, arena: &mut [BezPath], live: &[usize], copy_of: &HashMap<usize, usize>, draws: &mut [Draw]) {
let Some(tr) = g.sub("transform") else { return };
let w = Wiggly {
max: 1.0,
min: -1.0,
wiggles_per_second: ctx.f(layer, g, "speed", 2.0),
correlation: ctx.f(layer, g, "correlation", 50.0) / 100.0,
temporal_phase: ctx.f(layer, g, "phase", 0.0),
spatial_phase: ctx.f(layer, g, "spatialPhase", 0.0),
lock_dimensions: false,
seed: ctx.f(layer, g, "seed", 0.0).round() as i64 as u64,
};
let anchor = ctx.v2(layer, tr, "anchor", [0.0; 2]);
let pos = ctx.v2(layer, tr, "position", [0.0; 2]);
let scale = ctx.v2(layer, tr, "scale", [0.0; 2]);
let rot = ctx.f(layer, tr, "rotation", 0.0);
let t = layer.layer_time(ctx.time).seconds();
let matrix = |k: usize| {
let (x, y) = (selectors::wiggly_value(&w, t, k, 0), selectors::wiggly_value(&w, t, k, 1));
Mat3::translate(vec2(pos[0] * x, pos[1] * y))
* Mat3::rotate_deg(rot * x)
* Mat3::scale(vec2(1.0 + scale[0] * x / 100.0, 1.0 + scale[1] * y / 100.0))
* Mat3::translate(vec2(-anchor[0] * x, -anchor[1] * y))
};
let mut by_copy: HashMap<usize, Mat3> = HashMap::new();
let mut done: HashSet<usize> = HashSet::new();
for &s in live {
if !done.insert(s) {
continue;
}
let k = copy_of.get(&s).copied().unwrap_or(0);
let m = *by_copy.entry(k).or_insert_with(|| matrix(k));
arena[s] = mat_path(&arena[s], &m);
}
// A paint above the operation whose paths all moved by one matrix moves its gradient and
// stroke scale with them, as a group transform does. A paint over copies that moved by
// different matrices stays one paint (its fill rule and opacity apply to all of them at once)
// and keeps its gradient and stroke scale.
for d in draws {
let mut ms = d.slots.iter().map(|s| done.contains(s).then(|| by_copy.get(&copy_of.get(s).copied().unwrap_or(0)).copied()).flatten());
let Some(Some(m)) = ms.next() else { continue };
if ms.all(|x| x == Some(m)) {
d.xf = m * d.xf;
}
}
}
/// Collect the draws of a contents group and the path slots live at its end (in group space).
/// `arena` holds every path slot; operators rewrite slots in place.
fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<BezPath>) -> (Vec<Draw>, Vec<usize>) {
collect_in(ctx, layer, contents, arena, &mut Copies::default())
}
/// The Repeater copy each path slot belongs to, for Wiggle Transform: `of` maps a slot a Repeater
/// made to its copy's id (slots no Repeater made are copy 0). Ids are unique across the layer's
/// groups, so copies made in a nested group stay apart in the groups around it.
#[derive(Default)]
struct Copies {
of: HashMap<usize, usize>,
next: usize,
}
fn collect_in(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<BezPath>, copy_ids: &mut Copies) -> (Vec<Draw>, Vec<usize>) {
let mut draws: Vec<Draw> = Vec::new();
let mut live: Vec<usize> = Vec::new();
let opacity = |g: &PropGroup| (ctx.f(layer, g, "opacity", 100.0) / 100.0) as f32;
@@ -330,6 +398,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<B
let t = layer.layer_time(ctx.time).seconds();
apply(arena, &live, |p| ops::wiggle(p, &w, t));
}
"wiggler" => wiggle_transform(ctx, layer, g, arena, &live, &copy_ids.of, &mut draws),
"merge" => {
let mode = ops::MergeMode::from_index(ctx.e(layer, g, "mode"));
let input: Vec<BezPath> = live.iter().map(|&i| arena[i].clone()).collect();
@@ -342,7 +411,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<B
}
"group" => {
let Some(inner) = g.sub("contents") else { continue };
let (cd, cl) = collect(ctx, layer, inner, arena);
let (cd, cl) = collect_in(ctx, layer, inner, arena, copy_ids);
let (m, op) = g.sub("transform").map(|t| group_matrix(ctx, layer, t)).unwrap_or((Mat3::IDENTITY, 1.0));
for i in used_slots(&cl, &cd) {
arena[i] = mat_path(&arena[i], &m);
@@ -379,12 +448,20 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<B
// Each copy gets its own transformed slots, so operators below the repeater act on
// the repeated geometry (copy order: original first).
let mut copy_maps: Vec<std::collections::HashMap<usize, usize>> = Vec::with_capacity(n);
// Each copy of each earlier copy (a second Repeater) gets an id of its own.
let mut ids: HashMap<(usize, usize), usize> = HashMap::new();
for i in 0..n {
let m = step(i as f64 + offset);
let mut map = std::collections::HashMap::new();
for &s in &base_slots {
arena.push(mat_path(&arena[s], &m));
map.insert(s, arena.len() - 1);
let base = copy_ids.of.get(&s).copied().unwrap_or(0);
let id = *ids.entry((i, base)).or_insert_with(|| {
copy_ids.next += 1;
copy_ids.next
});
copy_ids.of.insert(arena.len() - 1, id);
}
live.extend(base_live.iter().map(|s| map[s]));
copy_maps.push(map);
+196
View File
@@ -310,6 +310,202 @@ fn wiggle_paths_vary_over_time_deterministically() {
assert_ne!(a.data, c.data);
}
/// Alpha-weighted centroid of the pixels in columns `x0..x1`.
fn centroid(img: &Image, x0: i64, x1: i64) -> [f32; 2] {
let (mut sx, mut sy, mut sa) = (0.0, 0.0, 0.0);
for x in x0..x1 {
for y in 0..img.height as i64 {
let a = alpha(img, x, y);
sx += a * x as f32;
sy += a * y as f32;
sa += a;
}
}
[sx / sa, sy / sa]
}
fn wiggle_transform(ids: &mut Ids, params: &[(&str, Value)]) -> PropGroup {
op(ids, "wiggler", params)
}
#[test]
fn wiggle_transform_moves_paths_over_time() {
let run = |position: [f64; 2], t: f64| {
let (p, cid) = shape_comp(|ids| {
let w = wiggle_transform(ids, &[("transform/position", Value::Vec2(position))]);
vec![build::shape_rect(ids, [20.0, 20.0], [0.0, 0.0], 0.0), w, build::shape_fill(ids, RED)]
});
render_frame(&p, cid, Tick::from_seconds_f64(t), 1.0)
};
// The defaults wiggle nothing: the square stays centered on pixel 99.5, 49.5.
let rest = [99.5, 49.5];
assert_eq!(centroid(&run([0.0, 0.0], 0.37), 0, 200), rest);
let a = run([15.0, 15.0], 0.37);
assert_eq!(a.data, run([15.0, 15.0], 0.37).data, "the same time gives the same frame");
let b = run([15.0, 15.0], 1.21);
let (ca, cb) = (centroid(&a, 0, 200), centroid(&b, 0, 200));
assert!(ca != rest && ca != cb, "{ca:?} {cb:?}");
for c in [ca, cb] {
assert!((c[0] - rest[0]).abs() <= 15.01 && (c[1] - rest[1]).abs() <= 15.01, "{c:?}");
}
// A translation keeps the square's area.
assert!((coverage(&a) - 400.0).abs() < 1.0, "{}", coverage(&a));
// The layer cache keys a layer with a Wiggle Transform by time.
let (p, cid) = shape_comp(|ids| vec![build::shape_rect(ids, [20.0, 20.0], [0.0, 0.0], 0.0), wiggle_transform(ids, &[]), build::shape_fill(ids, RED)]);
let comp = p.comp(cid).unwrap();
let key = |t: f64| crate::cache::layer_key(&crate::eval::EvalCtx::new(&p, cid, comp, Tick::from_seconds_f64(t)), &comp.layers[0], 1.0, false, false);
assert_ne!(key(0.37), key(1.21));
}
#[test]
fn wiggle_transform_scales_and_rotates() {
let run = |params: &[(&str, Value)]| {
let (p, cid) = shape_comp(|ids| {
let w = wiggle_transform(ids, params);
vec![build::shape_rect(ids, [40.0, 40.0], [0.0, 0.0], 0.0), w, build::shape_fill(ids, RED)]
});
render_frame(&p, cid, Tick::from_seconds_f64(0.37), 1.0)
};
let scaled = coverage(&run(&[("transform/scale", Value::Vec2([50.0, 50.0]))]));
assert!((scaled - 1600.0).abs() > 20.0, "{scaled}");
let rotated = run(&[("transform/rotation", Value::Scalar(45.0))]);
assert!((coverage(&rotated) - 1600.0).abs() < 2.0, "{}", coverage(&rotated));
assert!(alpha(&rotated, 81, 31) < 0.5 || alpha(&rotated, 119, 31) < 0.5, "a corner turned away");
}
/// A gradient above a Wiggle Transform moves with the paths it paints, as under a group transform.
#[test]
fn wiggle_transform_moves_the_gradient_with_the_path() {
let (p, cid) = shape_comp(|ids| {
let g = Gradient { colors: vec![(0.0, [0.0, 0.0, 0.0, 1.0]), (1.0, [1.0, 1.0, 1.0, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 1.0)] };
vec![
build::shape_rect(ids, [40.0, 40.0], [0.0, 0.0], 0.0),
build::shape_gradient_fill(ids, false, [-20.0, 0.0], [20.0, 0.0], g),
wiggle_transform(ids, &[("transform/position", Value::Vec2([60.0, 0.0]))]),
]
});
let img = render_frame(&p, cid, Tick::from_seconds_f64(0.37), 1.0);
let cols: Vec<i64> = (0..200).filter(|&x| alpha(&img, x, 50) > 0.99).collect();
let (l, r) = (cols[0], cols[cols.len() - 1]);
assert!((l - 80).abs() > 3, "the square moved: {l}");
let (cl, cr) = (img.get(l, 50), img.get(r, 50));
assert!(cl[0] < 0.1 && cr[0] > 0.9, "the ramp runs edge to edge: {cl:?} {cr:?}");
}
/// A gradient that paints every copy of a Repeater moves with the copies when they wiggle alike
/// (Correlation 100).
#[test]
fn wiggle_transform_moves_a_gradient_shared_by_repeater_copies() {
let (p, cid) = shape_comp(|ids| {
let g = Gradient { colors: vec![(0.0, [0.0, 0.0, 0.0, 1.0]), (1.0, [1.0, 1.0, 1.0, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 1.0)] };
vec![
build::shape_rect(ids, [20.0, 20.0], [-60.0, 0.0], 0.0),
build::shape_repeater(ids, 3.0, [60.0, 0.0]),
build::shape_gradient_fill(ids, false, [0.0, -10.0], [0.0, 10.0], g),
wiggle_transform(ids, &[("correlation", Value::Scalar(100.0)), ("transform/position", Value::Vec2([0.0, 15.0]))]),
]
});
let img = render_frame(&p, cid, Tick::from_seconds_f64(0.9), 1.0);
for cx in [40, 100, 160] {
let rows: Vec<i64> = (0..100).filter(|&y| alpha(&img, cx, y) > 0.99).collect();
let (t, b) = (rows[0], rows[rows.len() - 1]);
assert!((t - 40).abs() > 3, "copy at {cx} moved: {t}");
let (ct, cb) = (img.get(cx, t), img.get(cx, b));
assert!(ct[0] < 0.1 && cb[0] > 0.9, "copy at {cx}: {ct:?} {cb:?}");
}
}
/// A gradient over copies that wiggle on their own keeps its place: every copy shows the ramp's
/// middle at the group's origin row.
#[test]
fn wiggle_transform_keeps_a_gradient_over_copies_wiggling_apart() {
let (p, cid) = shape_comp(|ids| {
let g = Gradient { colors: vec![(0.0, [0.0, 0.0, 0.0, 1.0]), (1.0, [1.0, 1.0, 1.0, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 1.0)] };
vec![
build::shape_rect(ids, [20.0, 40.0], [-60.0, 0.0], 0.0),
build::shape_repeater(ids, 3.0, [60.0, 0.0]),
build::shape_gradient_fill(ids, false, [0.0, -20.0], [0.0, 20.0], g),
wiggle_transform(ids, &[("correlation", Value::Scalar(0.0)), ("transform/position", Value::Vec2([0.0, 15.0]))]),
]
});
let img = render_frame(&p, cid, Tick::from_seconds_f64(0.9), 1.0);
for cx in [40, 100, 160] {
let c = img.get(cx, 50);
assert!(c[3] > 0.99 && (c[0] - 0.5).abs() < 0.05, "copy at {cx}: {c:?}");
}
}
/// Copies wiggling on their own under one 50 % fill stay one paint: where they overlap, the fill
/// covers them once.
#[test]
fn wiggle_transform_keeps_one_fill_over_overlapping_copies() {
let (p, cid) = shape_comp(|ids| {
let mut fill = build::shape_fill(ids, RED);
set(&mut fill, "opacity", Value::Scalar(50.0));
vec![
build::shape_rect(ids, [40.0, 40.0], [-20.0, 0.0], 0.0),
build::shape_repeater(ids, 3.0, [20.0, 0.0]),
fill,
wiggle_transform(ids, &[("correlation", Value::Scalar(0.0)), ("transform/position", Value::Vec2([0.0, 15.0]))]),
]
});
let img = render_frame(&p, cid, Tick::from_seconds_f64(0.9), 1.0);
let top = img.data.iter().map(|px| px[3]).fold(0.0f32, f32::max);
assert!((top - 0.5).abs() < 0.01, "{top}");
}
/// A long stack of Repeaters, each cleared by a zero-copy Repeater, still renders the last Repeater's copies.
#[test]
fn long_repeater_stack_with_cleared_copies_renders() {
let (p, cid) = shape_comp(|ids| {
let mut items = vec![];
for _ in 0..70 {
items.push(build::shape_rect(ids, [20.0, 20.0], [0.0, 0.0], 0.0));
items.push(build::shape_repeater(ids, 3.0, [10.0, 0.0]));
items.push(build::shape_repeater(ids, 0.0, [10.0, 0.0]));
}
items.push(build::shape_rect(ids, [20.0, 20.0], [0.0, 0.0], 0.0));
items.push(build::shape_repeater(ids, 3.0, [30.0, 0.0]));
items.push(wiggle_transform(ids, &[("correlation", Value::Scalar(0.0)), ("transform/position", Value::Vec2([0.0, 15.0]))]));
items.push(build::shape_fill(ids, RED));
items
});
let img = render_frame(&p, cid, Tick::from_seconds_f64(0.37), 1.0);
assert!((coverage(&img) - 1200.0).abs() < 2.0, "{}", coverage(&img));
}
/// Copies a Repeater makes above a Wiggle Transform wiggle on their own (Correlation 0) or as one
/// (Correlation 100); with the Wiggle Transform above the Repeater every copy wiggles the same.
#[test]
fn wiggle_transform_and_repeater_order() {
let offsets = |wiggle_first: bool, correlation: f64| {
let (p, cid) = shape_comp(|ids| {
let rect = build::shape_rect(ids, [20.0, 20.0], [-60.0, 0.0], 0.0);
let rep = build::shape_repeater(ids, 3.0, [60.0, 0.0]);
let w = wiggle_transform(ids, &[("correlation", Value::Scalar(correlation)), ("transform/position", Value::Vec2([0.0, 15.0]))]);
let fill = build::shape_fill(ids, RED);
if wiggle_first { vec![rect, w, rep, fill] } else { vec![rect, rep, w, fill] }
});
let img = render_frame(&p, cid, Tick::from_seconds_f64(0.37), 1.0);
[(0, 70), (70, 130), (130, 200)].map(|(x0, x1)| centroid(&img, x0, x1)[1] - 50.0)
};
let own = offsets(false, 0.0);
assert!((own[0] - own[1]).abs() > 0.5 || (own[1] - own[2]).abs() > 0.5, "copies wiggle on their own: {own:?}");
// A Repeater inside a group above the Wiggle Transform: its copies still wiggle on their own.
let (p, cid) = shape_comp(|ids| {
let inner = vec![build::shape_rect(ids, [20.0, 20.0], [-60.0, 0.0], 0.0), build::shape_repeater(ids, 3.0, [60.0, 0.0]), build::shape_fill(ids, RED)];
let group = build::shape_group(ids, "Repeated", inner);
vec![group, wiggle_transform(ids, &[("correlation", Value::Scalar(0.0)), ("transform/position", Value::Vec2([0.0, 15.0]))])]
});
let img = render_frame(&p, cid, Tick::from_seconds_f64(0.37), 1.0);
let nested = [(0, 70), (70, 130), (130, 200)].map(|(x0, x1)| centroid(&img, x0, x1)[1] - 50.0);
assert!((nested[0] - nested[1]).abs() > 0.5 || (nested[1] - nested[2]).abs() > 0.5, "nested copies: {nested:?}");
for same in [offsets(false, 100.0), offsets(true, 0.0)] {
assert!(same[0].abs() > 0.5, "{same:?}");
assert!((same[0] - same[1]).abs() < 0.05 && (same[1] - same[2]).abs() < 0.05, "copies wiggle as one: {same:?}");
}
}
#[test]
fn zigzag_and_twist_render() {
let (p, cid) = shape_comp(|ids| {
+16 -16
View File
@@ -314,7 +314,7 @@ fn particle_world_scene(pan: f64) -> (Project, ItemId) {
let Value::Vec3(v) = pr.value else { panic!("{path}: {:?}", pr.value) };
pr.value = Value::Vec3([v[0] + pan, v[1], v[2]]);
}
p.comp_mut(cid).unwrap().layers = vec![cam, l];
p.comp_mut(cid).unwrap().layers = vec![cam, l].into();
(p, cid)
}
@@ -373,7 +373,7 @@ fn burst_scene_keys(cut: Option<f64>, start: f64, stretch: f64, hold: bool) -> (
l.in_point = l.start_time;
l.out_point = Tick::from_seconds_f64(4.0);
l.stretch = stretch;
p.comp_mut(cid).unwrap().layers = vec![l];
p.comp_mut(cid).unwrap().layers = vec![l].into();
(p, cid)
}
@@ -507,7 +507,7 @@ fn system_scene(id: &str, rate: Option<f64>) -> (Project, ItemId) {
let key = |t: f64, v: f64| Keyframe::new(Tick::from_seconds_f64(t), Value::Scalar(v));
fx.unwrap().prop_mut("birthRate").unwrap().keys = vec![key(0.0, 3.0), key(0.5, 3.0), key(0.5 + 1.0 / 30.0, 0.0)];
}
p.comp_mut(cid).unwrap().layers = vec![l];
p.comp_mut(cid).unwrap().layers = vec![l].into();
(p, cid)
}
@@ -614,7 +614,7 @@ fn cache_scene() -> (Project, ItemId) {
vec![Keyframe::new(Tick::ZERO, Value::Vec3([20.0, 70.0, 0.0])), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Vec3([120.0, 70.0, 0.0]))];
add_effect_200(&mut p, &mut text, "ec.blur.gaussian", &[("blurriness", Value::Scalar(6.0))]);
let c = p.comp_mut(cid).unwrap();
c.layers = vec![text, shape, bg];
c.layers = vec![text, shape, bg].into();
(p, cid)
}
@@ -854,7 +854,7 @@ fn edge_pinning_displaces_all_of_a_centred_shape_layer() {
p.comp_mut(cid).unwrap().layers.push(l.clone());
let plain = render_frame(&p, cid, Tick::ZERO, 1.0);
add_effect_200(&mut p, &mut l, id, &vals);
p.comp_mut(cid).unwrap().layers = vec![l];
p.comp_mut(cid).unwrap().layers = vec![l].into();
let warped = render_frame(&p, cid, Tick::ZERO, 1.0);
// Alpha change per comp quadrant (TL, TR, BL, BR): the rectangle's edges move in each.
let mut q = [0.0f32; 4];
@@ -888,7 +888,7 @@ fn default_effect_points_sit_at_the_centre_of_a_shape_layer() {
p.comp_mut(cid).unwrap().layers.push(l.clone());
let plain = render_frame(&p, cid, Tick::ZERO, 1.0);
add_effect_200(&mut p, &mut l, id, &vals);
p.comp_mut(cid).unwrap().layers = vec![l];
p.comp_mut(cid).unwrap().layers = vec![l].into();
let warped = render_frame(&p, cid, Tick::ZERO, 1.0);
// Alpha change per comp quadrant (TL, TR, BL, BR): the top and bottom edges move in each.
let mut q = [0.0f32; 4];
@@ -917,7 +917,7 @@ fn effects_on_text_reach_past_the_text_bounds() {
let plain = render_frame(&p, cid, Tick::ZERO, 1.0);
let painted = plain.data.iter().filter(|px| px[3] > 0.5).count();
add_effect_200(&mut p, &mut l, "ec.distort.cctiler", &[("scale", Value::Scalar(25.0))]);
p.comp_mut(cid).unwrap().layers = vec![l];
p.comp_mut(cid).unwrap().layers = vec![l].into();
let tiled = render_frame(&p, cid, Tick::ZERO, 1.0);
let (mut lo, mut hi) = (u32::MAX, 0u32);
for y in 0..100 {
@@ -951,7 +951,7 @@ fn cc_smear_trails_text_past_its_bounds() {
"ec.distort.ccsmear",
&[("from", Value::Vec2([110.0, 45.0])), ("to", Value::Vec2([185.0, 45.0])), ("reach", Value::Scalar(100.0)), ("radius", Value::Scalar(40.0))],
);
p.comp_mut(cid).unwrap().layers = vec![l];
p.comp_mut(cid).unwrap().layers = vec![l].into();
let smeared = right(&render_frame(&p, cid, Tick::ZERO, 1.0));
assert!(plain > 40 && smeared > plain + 25, "CC Smear trails the glyphs past the text bounds: right edge {plain} → {smeared}");
}
@@ -974,7 +974,7 @@ fn layer_parameters_see_a_whole_shape_layer() {
let matte_id = shape.id;
let mut s = solid(&mut p, &comp, [0.0, 0.0, 1.0], 200, 100);
add_effect_200(&mut p, &mut s, "ec.channel.setmatte", &[("takeMatteFromLayer", Value::Layer(Some(matte_id.0)))]);
p.comp_mut(cid).unwrap().layers = vec![s, shape];
p.comp_mut(cid).unwrap().layers = vec![s, shape].into();
let f = render_frame(&p, cid, Tick::ZERO, 1.0);
// The matte covers x 50..150, y 25..75: kept inside (all four quadrants), gone outside.
for (x, y) in [(60, 30), (140, 30), (60, 70), (140, 70)] {
@@ -1064,7 +1064,7 @@ fn self_inputs_share_source_semantics_across_effects_and_times() {
"ec.channel.blend",
&[("blendWithLayer", Value::Layer(Some(id))), ("blendWithLayerSource", Value::Enum(0)), ("blendWithOriginal", Value::Scalar(0.0))],
);
p.comp_mut(cid).unwrap().layers = vec![l];
p.comp_mut(cid).unwrap().layers = vec![l].into();
let c = p.comp(cid).unwrap();
let ctx = crate::eval::EvalCtx::new(&p, cid, c, Tick::ZERO);
assert!(crate::cache::input_key(&ctx, &c.layers[0], 1.0, false, false, 1).is_some());
@@ -1082,7 +1082,7 @@ fn timed_source_reads_preserve_other_layer_masks() {
let mask = build::mask(&mut Ids(&mut next), "Left", ShapePath::rect([50.0, 50.0], 80.0, 80.0), MaskMode::Add, [255, 255, 0]);
p.next_id = next;
other.props.sub_mut("masks").unwrap().children.push(mask.into());
p.comp_mut(cid).unwrap().layers = vec![owner.clone(), other.clone()];
p.comp_mut(cid).unwrap().layers = vec![owner.clone(), other.clone()].into();
let ctx = crate::EvalCtx::new(&p, cid, p.comp(cid).unwrap(), Tick::ZERO);
let renderer = crate::Renderer::new(&p, &crate::NoFootage, crate::RenderOpts::default());
let host = crate::FxHost { r: &renderer, ctx: &ctx, layer: &owner, origin: [0.0; 2], index: Default::default(), original: None };
@@ -1103,7 +1103,7 @@ fn layer_dependencies_remain_cacheable_and_invalidate_only_related_edits() {
let unrelated = solid(&mut p, &comp, [0.2, 0.3, 0.4], 200, 100);
add_effect_200(&mut p, &mut source, "ec.channel.blend", &[("blendWithLayer", Value::Layer(Some(third.id.0))), ("blendWithOriginal", Value::Scalar(0.0))]);
add_effect_200(&mut p, &mut owner, "ec.channel.blend", &[("blendWithLayer", Value::Layer(Some(source.id.0))), ("blendWithOriginal", Value::Scalar(0.0))]);
p.comp_mut(cid).unwrap().layers = vec![owner, source, third, unrelated];
p.comp_mut(cid).unwrap().layers = vec![owner, source, third, unrelated].into();
let key = |p: &Project, time: f64, limit| {
let comp = p.comp(cid).unwrap();
let ctx = crate::EvalCtx::new(p, cid, comp, Tick::from_seconds_f64(time));
@@ -1144,12 +1144,12 @@ fn precomp_dependencies_hash_nested_sources_and_evaluated_animation() {
child.props.prop_mut("transform/opacity").unwrap().keys =
vec![Keyframe::new(Tick::ZERO, Value::Scalar(100.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(50.0))];
let mut nested = comp.clone();
nested.layers = vec![child];
nested.layers = vec![child].into();
let nested_id = p.add_item("Nested", Label::Sandstone, None, ItemKind::Comp(nested.into()));
let pre = build::layer(&mut p, &comp, "Precomp", LayerSource::Comp { item: nested_id }, (200, 100), None);
let mut owner = solid(&mut p, &comp, [1.0, 0.0, 0.0], 200, 100);
add_effect_200(&mut p, &mut owner, "ec.channel.blend", &[("blendWithLayer", Value::Layer(Some(pre.id.0))), ("blendWithOriginal", Value::Scalar(0.0))]);
p.comp_mut(cid).unwrap().layers = vec![owner, pre];
p.comp_mut(cid).unwrap().layers = vec![owner, pre].into();
let key = |p: &Project, time| {
let c = p.comp(cid).unwrap();
crate::cache::layer_key(&crate::EvalCtx::new(p, cid, c, time), &c.layers[0], 1.0, false, false).unwrap()
@@ -1224,7 +1224,7 @@ fn scene_reader_dependency(pan: f64) -> (Project, ItemId) {
let Value::Vec3(v) = pr.value else { panic!("{path}: {:?}", pr.value) };
pr.value = Value::Vec3([v[0] + pan, v[1], v[2]]);
}
p.comp_mut(cid).unwrap().layers = vec![cam, owner, source];
p.comp_mut(cid).unwrap().layers = vec![cam, owner, source].into();
(p, cid)
}
@@ -1336,7 +1336,7 @@ fn instance_scene(pan: f64) -> (Project, ItemId) {
}
let mut owner = solid(&mut p, &comp, [1.0, 1.0, 1.0], 200, 100);
add_effect_200(&mut p, &mut owner, "ec.3d.instance3d", &[("layer", Value::Layer(Some(model.id.0)))]);
p.comp_mut(cid).unwrap().layers = vec![cam, owner, model];
p.comp_mut(cid).unwrap().layers = vec![cam, owner, model].into();
(p, cid)
}
+2 -2
View File
@@ -78,7 +78,7 @@ fn comp_with(id: Option<&str>, vals: &[(&str, Value)], solid: bool) -> (Project,
l.props.sub_mut("effects").unwrap().children.push(g.into());
}
let mut comp = comp;
comp.layers = vec![l];
comp.layers = vec![l].into();
let cid = p.add_item("C", effectcraft_color::Label::Sandstone, None, ItemKind::Comp(comp.into()));
(p, cid)
}
@@ -272,7 +272,7 @@ fn audio_spectrum_follows_time_through_the_layer_cache() {
g.prop_mut("audioLayer").unwrap().value = Value::Layer(Some(a.id.0));
s.props.sub_mut("effects").unwrap().children.push(g.into());
let mut comp = comp;
comp.layers = vec![s, a];
comp.layers = vec![s, a].into();
let cid = p.add_item("C", effectcraft_color::Label::Sandstone, None, ItemKind::Comp(comp.into()));
let src = Signal(late_tone);
+15 -15
View File
@@ -53,17 +53,17 @@ fn scene(collapse: bool, flatten: bool) -> (Project, ItemId) {
a.parent = Some(null.id);
b.parent = Some(null.id);
let mut c = outer.clone();
c.layers = vec![null, b, a, bg];
c.layers = vec![null, b, a, bg].into();
p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(c.into()))
} else {
let mut inner = inner;
inner.layers = vec![b, a];
inner.layers = vec![b, a].into();
let iid = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.into()));
let mut pre = build::layer(&mut p, &outer, "Inner", LayerSource::Comp { item: iid }, (100, 100), None);
xf(&mut pre);
pre.switches.collapse = collapse;
let mut c = outer.clone();
c.layers = vec![pre, bg];
c.layers = vec![pre, bg].into();
p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(c.into()))
};
(p, cid)
@@ -154,7 +154,7 @@ fn referenced_layer_overrides_change_the_owner_cache_key() {
p.next_id = next;
b.props.sub_mut("effects").unwrap().children.push(fx.into());
let mut c = comp;
c.layers = vec![b, a];
c.layers = vec![b, a].into();
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into()));
let comp = p.comp(cid).unwrap();
let b = comp.layer(bid).unwrap();
@@ -182,7 +182,7 @@ fn collapsed_3d_layers_join_the_parent_scene() {
let mut blue = solid(&mut p, &inner, [0.0, 0.0, 1.0], (60, 60), [100.0, 100.0, -200.0]);
blue.switches.three_d = true;
let mut inner = inner;
inner.layers = vec![blue];
inner.layers = vec![blue].into();
let iid = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.into()));
let mut pre = build::layer(&mut p, &outer, "Inner", LayerSource::Comp { item: iid }, (200, 200), None);
pre.switches.three_d = true;
@@ -190,7 +190,7 @@ fn collapsed_3d_layers_join_the_parent_scene() {
let mut red = solid(&mut p, &outer, [1.0, 0.0, 0.0], (100, 100), [100.0, 100.0, 0.0]);
red.switches.three_d = true;
let mut c = outer;
c.layers = vec![red, pre];
c.layers = vec![red, pre].into();
let cid = p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(c.into()));
render_frame(&p, cid, Tick::ZERO, 1.0).get(100, 100)
};
@@ -228,11 +228,11 @@ fn scaled_square(cr: bool, via_parent: bool) -> Image {
set(&mut null, "transform/scale", Value::Vec3([800.0, 800.0, 100.0]));
set(&mut l, "transform/position", Value::Vec3([0.0, 0.0, 0.0]));
l.parent = Some(null.id);
c.layers = vec![l, null];
c.layers = vec![l, null].into();
} else {
set(&mut l, "transform/position", Value::Vec3([100.3, 100.3, 0.0]));
set(&mut l, "transform/scale", Value::Vec3([800.0, 800.0, 100.0]));
c.layers = vec![l];
c.layers = vec![l].into();
}
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into()));
render_frame(&p, cid, Tick::ZERO, 1.0)
@@ -261,7 +261,7 @@ fn wireframe_quality_draws_the_layer_bounds() {
let mut l = solid(&mut p, &comp, [1.0, 0.0, 0.0], (40, 40), [50.0, 50.0, 0.0]);
l.switches.quality = Quality::Wireframe;
let mut c = comp;
c.layers = vec![l];
c.layers = vec![l].into();
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into()));
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
assert_eq!(img.get(50, 50)[3], 0.0, "no fill");
@@ -272,7 +272,7 @@ fn wireframe_quality_draws_the_layer_bounds() {
let mut l = solid(&mut p, &comp, [1.0, 1.0, 1.0], (40, 40), [50.5, 50.0, 0.0]);
l.switches.quality = Quality::Draft;
let mut c = comp;
c.layers = vec![l];
c.layers = vec![l].into();
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into()));
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
assert!(img.data.iter().all(|q| q[3] == 0.0 || q[3] == 1.0));
@@ -290,13 +290,13 @@ fn collapsed_precomp_of_another_size_renders_through_the_parent_camera() {
let mut blue = solid(&mut p, &inner, [0.0, 0.0, 1.0], (40, 40), [500.0, 300.0, 0.0]);
blue.switches.three_d = true;
let mut inner = inner;
inner.layers = vec![blue];
inner.layers = vec![blue].into();
let iid = p.add_item("Scene", Label::Sandstone, None, ItemKind::Comp(inner.into()));
let mut pre = build::layer(&mut p, &outer, "Scene", LayerSource::Comp { item: iid }, (1000, 600), None);
pre.switches.three_d = three_d_precomp;
pre.switches.collapse = true;
let mut c = outer.clone();
c.layers = vec![pre];
c.layers = vec![pre].into();
if camera {
let cam = build::layer(&mut p, &outer, "Camera", LayerSource::Camera, (300, 200), None);
c.layers.insert(0, cam);
@@ -327,7 +327,7 @@ fn text_scaled_up_stays_sharp_without_the_switch() {
set(&mut l, "transform/scale", Value::Vec3([scale, scale, 100.0]));
assert!(!l.switches.collapse);
let mut c = comp;
c.layers = vec![l];
c.layers = vec![l].into();
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into()));
render_frame(&p, cid, Tick::ZERO, 1.0)
};
@@ -351,7 +351,7 @@ fn unscaled_text_rasterises_at_the_output_scale() {
let mut comp = Comp::new(240, 160, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let mut l = build::layer(&mut p, &comp, "Text", LayerSource::Text, (240, 160), None);
l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "HE".into(), size: 30.0, ..Default::default() }));
comp.layers = vec![l];
comp.layers = vec![l].into();
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into()));
for i in 0..500 {
let pos = [3.1 + i as f64 * 0.37, 7.3 + i as f64 * 0.29, 0.0];
@@ -387,7 +387,7 @@ fn effects_on_a_scaled_shape_follow_its_scale() {
set(&mut l, "transform/position", Value::Vec3([100.0, 100.0, 0.0]));
set(&mut l, "transform/scale", Value::Vec3([scale, scale, 100.0]));
let mut c = comp;
c.layers = vec![l];
c.layers = vec![l].into();
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into()));
render_frame(&p, cid, Tick::ZERO, 1.0)
};
+2 -2
View File
@@ -40,7 +40,7 @@ fn scene(nest: Nest) -> (Project, ItemId, ItemId) {
guide.switches.guide = true;
let bg = solid(&mut p, &inner, [1.0, 0.0, 0.0], (100, 100), three_d);
let mut inner = inner;
inner.layers = vec![guide, bg];
inner.layers = vec![guide, bg].into();
let iid = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.into()));
let mut outer = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
if matches!(nest, Nest::CollapsedAdvanced) {
@@ -49,7 +49,7 @@ fn scene(nest: Nest) -> (Project, ItemId, ItemId) {
let mut pre = build::layer(&mut p, &outer, "Inner", LayerSource::Comp { item: iid }, (100, 100), None);
pre.switches.collapse = !matches!(nest, Nest::Precomp);
pre.switches.three_d = three_d;
outer.layers = vec![pre];
outer.layers = vec![pre].into();
let oid = p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(outer.into()));
(p, iid, oid)
}
+3 -3
View File
@@ -58,7 +58,7 @@ fn adjustment_scene(out: f64, scale: f64) -> (Project, ItemId) {
tile_then_scale(&mut p, &mut adj, out, scale);
let mut layers = vec![adj];
layers.extend(picture(&mut p, &comp));
p.comp_mut(cid).unwrap().layers = layers;
p.comp_mut(cid).unwrap().layers = layers.into();
(p, cid)
}
@@ -68,7 +68,7 @@ fn layer_scene(out: f64, scale: f64) -> (Project, ItemId) {
let inner = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
let iid = p.add_item("Picture", Label::Sandstone, None, ItemKind::Comp(inner.clone().into()));
let layers = picture(&mut p, &inner);
p.comp_mut(iid).unwrap().layers = layers;
p.comp_mut(iid).unwrap().layers = layers.into();
let mut pre = build::layer(&mut p, &comp, "Picture", LayerSource::Comp { item: iid }, (W, H), None);
tile_then_scale(&mut p, &mut pre, out, scale);
p.comp_mut(cid).unwrap().layers.push(pre);
@@ -151,7 +151,7 @@ fn adjustment_motion_tile_matches_the_after_effects_frames() {
}
adj.props.sub_mut("effects").unwrap().children.push(g.into());
}
p.comp_mut(cid).unwrap().layers = vec![adj, red];
p.comp_mut(cid).unwrap().layers = vec![adj, red].into();
// Quarter resolution: comp pixel (x, y) is output pixel (x / 4, y / 4).
let img = render_frame(&p, cid, Tick::ZERO, 0.25);
let at = |x: f64, y: f64| img.get((x / 4.0) as i64, (y / 4.0) as i64);
+1 -1
View File
@@ -29,7 +29,7 @@ fn project(three_d: bool) -> (Project, effectcraft_project::ItemId) {
}
layers.push(l);
}
p.comp_mut(cid).unwrap().layers = layers;
p.comp_mut(cid).unwrap().layers = layers.into();
(p, cid)
}
+1 -1
View File
@@ -44,7 +44,7 @@ fn scene(color: [f32; 3], bg: Option<[f32; 3]>, f: impl FnOnce(&mut Project, &Co
if let Some(bg) = bg {
layers.push(solid(&mut p, &comp, bg, 200, 100));
}
p.comp_mut(cid).unwrap().layers = layers;
p.comp_mut(cid).unwrap().layers = layers.into();
(p, cid)
}
+51 -2
View File
@@ -13,7 +13,7 @@ use std::collections::BinaryHeap;
use rayon::prelude::*;
use super::scene::{Scene, ShadowMap, Vertex};
use super::scene::{EnvInfo, Scene, ShadowMap, Vertex};
use super::shade::{Frag, sample, shade};
/// A rendered target at raster size.
@@ -500,11 +500,39 @@ pub(crate) fn shadow_sizes(width: u32, height: u32, draft: bool, kinds: &[u32])
sizes.into_iter().map(|z| (z > 0).then_some(z)).collect()
}
/// Build the shadow maps of every shadow-casting light (lights marked `shadow == 0`).
/// The Environment light's shadow map: one orthographic map over the scene's bounding sphere
/// from its dominant direction (the light travels from the bright region into the scene), like
/// a parallel light's.
fn env_shadow_map(e: EnvInfo, centre: [f64; 3], radius: f64, size: u32) -> ShadowMap {
// After Effects' environment-light shadows are soft out of the box, and this
// map covers the whole scene bounding sphere — not the view — so its texels
// can be coarse (all the more in a draft render's flat 512 budget). A
// penumbra of at least eight output pixels, or four map texels where those
// are coarser, keeps the edge smooth at any map resolution; Shadow Diffusion
// widens it further, as for the other lights.
let texel = 2.0 * radius / size as f64;
let soft = (4.0 * texel).max(8.0) + (e.shadow_diffusion as f64 * 0.5).min(24.0);
let sun = e.sun.map(|v| v as f64);
let dir = [-sun[0], -sun[1], -sun[2]];
let eye = [centre[0] + sun[0] * radius * 2.0, centre[1] + sun[1] * radius * 2.0, centre[2] + sun[2] * radius * 2.0];
let hint = if dir[1].abs() < 0.9 { [0.0, 1.0, 0.0] } else { [0.0, 0.0, 1.0] };
let focal = (size as f64 * 0.5 / radius) as f32;
ShadowMap { view: basis(eye, dir, hint), focal, ortho: true, size, offset: 0, bias: 1.5, radius: soft as f32 }
}
/// Build the shadow maps of every shadow-casting light (lights marked `shadow == 0`), and of
/// the Environment light when it casts shadows and its image has a dominant direction (marked
/// `env.shadow == 0`, `env.sun != [0; 3]`): one map from that direction, like a parallel light.
pub(crate) fn shadow_maps(s: &mut Scene, draft: bool) {
let casters: Vec<usize> = (0..s.indices.len() / 3)
.filter(|&t| s.materials.get(s.vertices[s.indices[3 * t] as usize].material as usize).is_some_and(|m| m.casts_shadows))
.collect();
// The Environment light casts a shadow when marked and its image has a dominant direction;
// with nothing to cast one, its mark is cleared either way.
let env_shadow = !casters.is_empty() && s.env.is_some_and(|e| e.shadow == 0 && e.sun != [0.0; 3]);
if !env_shadow && let Some(e) = &mut s.env {
e.shadow = -1;
}
if casters.is_empty() {
for l in &mut s.lights {
l.shadow = -1;
@@ -536,6 +564,8 @@ pub(crate) fn shadow_maps(s: &mut Scene, draft: bool) {
1
}
})
// The Environment light's map is a single orthographic one.
.chain(std::iter::once(if env_shadow { 1 } else { 0 }))
.collect();
let sizes = shadow_sizes(s.width, s.height, draft, &kinds);
let mut maps: Vec<ShadowMap> = vec![];
@@ -583,6 +613,25 @@ pub(crate) fn shadow_maps(s: &mut Scene, draft: bool) {
}
s.lights[li].shadow = first;
}
// The Environment light's shadow: one orthographic map over the scene from its dominant
// direction (the light travels from the bright region into the scene), after the lights'.
if env_shadow && let Some(e) = s.env {
match sizes.get(s.lights.len()).copied().flatten() {
Some(size) => {
let first = maps.len() as i32;
maps.push(env_shadow_map(e, centre, radius, size));
if let Some(e) = &mut s.env {
e.shadow = first;
}
}
// The texel budget dropped it.
None => {
if let Some(e) = &mut s.env {
e.shadow = -1;
}
}
}
}
let texels: Vec<Vec<f32>> = maps.par_iter().map(|m| shadow_raster(s, m, &casters)).collect();
for (m, t) in maps.iter_mut().zip(texels) {
m.offset = s.shadow_texels.len() as u32;
+180 -20
View File
@@ -172,6 +172,24 @@ pub struct EnvInfo {
pub intensity: f32,
/// Radians about the vertical axis.
pub rotation: f32,
/// Unit direction from the scene towards the environment's dominant (radiance-weighted)
/// bright region — the direction its shadow is cast from, like a parallel light's
/// `to_light`. `[0; 3]` when the image is directionless (uniform, or opposing bright
/// regions that cancel) or the light casts no shadow (nothing reads it then): no shadow.
pub sun: [f32; 3],
/// The image's directional (key) fraction — `|Σ dir·radiance| / Σ radiance` over the
/// sphere, 0 for a uniform image and near 1 for a single bright spot. An Environment
/// light's shadow occludes only this much of its light: the key lobe is blocked where
/// the map says so, the remaining fill keeps shining, so the shadow stays partial the
/// way After Effects renders an area-light shadow (a broad environment like the default
/// studio shadows softly, a lone bright spot on black almost fully).
pub key: f32,
/// Shadow map index into [`Scene::shadows`]: 0 marked (assigned by `raster::shadow_maps`),
/// -1 casts no shadow. Gated by the light's Cast Shadows.
pub shadow: i32,
pub shadow_darkness: f32,
/// The light's Shadow Diffusion (the penumbra width the shadow map's PCF widens to).
pub shadow_diffusion: f32,
}
#[derive(Clone, Debug, Default)]
@@ -670,9 +688,20 @@ fn card_layer(r: &Renderer, ctx: &EvalCtx, layer: &Layer, b: &mut Builder) {
}
}
/// Evaluated lights: (scene lights, ambient sum, environment light (layer, intensity × colour,
/// rotation)).
fn lights(ctx: &EvalCtx, lin: bool) -> (Vec<Light>, [f32; 3], Option<(Option<u64>, [f32; 3], f32)>) {
/// The evaluated Environment light: its image source, intensity × colour, rotation, and
/// shadow settings (After Effects' Light Settings shows Cast Shadows for Environment lights).
#[derive(Clone, Copy)]
pub(crate) struct EnvLight {
src: Option<u64>,
color: [f32; 3],
rot: f32,
casts_shadows: bool,
shadow_darkness: f64,
shadow_diffusion: f64,
}
/// Evaluated lights: (scene lights, ambient sum, environment light).
fn lights(ctx: &EvalCtx, lin: bool) -> (Vec<Light>, [f32; 3], Option<EnvLight>) {
let mut out = vec![];
let mut ambient = [0.0f32; 3];
let mut env = None;
@@ -693,7 +722,14 @@ fn lights(ctx: &EvalCtx, lin: bool) -> (Vec<Light>, [f32; 3], Option<(Option<u64
if env.is_none() {
let src = g.get("source").and_then(|p| ctx.value(layer, p).as_layer());
let rot = ctx.f(layer, g, "rotation", 0.0).to_radians() as f32;
env = Some((src, color, rot));
env = Some(EnvLight {
src,
color,
rot,
casts_shadows: ctx.b(layer, g, "castsShadows"),
shadow_darkness: ctx.f(layer, g, "shadowDarkness", 100.0).clamp(0.0, 100.0) / 100.0,
shadow_diffusion: ctx.f(layer, g, "shadowDiffusion", 0.0).max(0.0),
});
}
}
_ => {
@@ -783,8 +819,54 @@ pub fn irradiance_map(sw: u32, sh: u32, src: &[[f32; 4]], w: u32, h: u32) -> Vec
out
}
/// The environment image's dominant direction and key strength: the mean texel direction
/// weighted by the radiance the light actually emits — solid-angle weighted, rotation applied,
/// each texel's channels scaled by `tint` (the light's colour, `[1; 3]` for a white one) — or
/// `([0; 3], 0.0)` when that radiance is directionless (a uniform environment cancels to zero,
/// and opposing bright regions that cancel leave no mean either — though a chromatic one need
/// not stay directionless once tinted: a red light over equally bright red and blue opposite
/// regions removes the blue pull and points at the red one). The mean of a single bright spot
/// on black points straight at it; a gradient points somewhere between its ends. The returned
/// key fraction is the mean vector's length over the radiance weight — how directional the
/// image is, 0 (uniform) to ~1 (a lone spot) — and is the most of its light an Environment
/// light's shadow can remove.
pub(crate) fn dominant_direction(w: u32, h: u32, d: &[[f32; 4]], tint: [f32; 3], rot: f32) -> ([f32; 3], f32) {
use super::shade::equirect_dir;
let (mut x, mut y, mut z) = (0.0f32, 0.0f32, 0.0f32);
let mut weight = 0.0f32;
for (i, p) in d.iter().enumerate() {
let (tx, ty) = ((i % w as usize) as u32, (i / w as usize) as u32);
let (u, v) = ((tx as f32 + 0.5) / w as f32, (ty as f32 + 0.5) / h as f32);
// Tinted radiance × solid angle (∝ sin θ): the shadow must follow the light the
// environment emits, not the raw texels.
let lum = (p[0] * tint[0] + p[1] * tint[1] + p[2] * tint[2]) * (v * std::f32::consts::PI).sin();
let dir = equirect_dir(u, v, rot);
x += dir[0] * lum;
y += dir[1] * lum;
z += dir[2] * lum;
weight += lum.abs();
}
// Under 1 % directional, the image is uniform to the eye: no dominant region to shadow
// from. A non-finite sum (a NaN or infinite texel) is garbage, not a direction: none then.
let len = (x * x + y * y + z * z).sqrt();
if !weight.is_finite() || !len.is_finite() || weight <= 1e-9 || len < weight * 0.01 {
return ([0.0; 3], 0.0);
}
([x / len, y / len, z / len], (len / weight).clamp(0.0, 1.0))
}
/// Prefilter an environment image into the scene's textures.
pub(crate) fn add_environment(b: &mut Builder, w: u32, h: u32, data: Vec<[f32; 4]>, intensity: [f32; 3], rotation: f32) {
pub(crate) fn add_environment(b: &mut Builder, env: &EnvLight, w: u32, h: u32, data: Vec<[f32; 4]>) {
// Intensity × colour folded into the radiance (one scalar on the GPU): tint the texels.
// A white (or off) light leaves them as they are.
let k = (env.color[0] + env.color[1] + env.color[2]) / 3.0;
let tint = if k > 0.0 && (env.color[0] != env.color[1] || env.color[1] != env.color[2]) { env.color.map(|c| c / k) } else { [1.0, 1.0, 1.0] };
// The shadow direction and key strength from the full-resolution image, before the mip
// chain averages it — only a shadow-casting light reads them, so only one pays for the
// pass — and weighted by the tint, so the shadow follows the radiance the light emits:
// opposing chromatic regions that cancel untinted need not cancel once the colour removes
// one of them (a red light over equally bright red and blue sides shadows from the red).
let (sun, key) = if env.casts_shadows { dominant_direction(w, h, &data, tint, env.rot) } else { ([0.0; 3], 0.0) };
// Radiance level 0 at most 1024 wide.
let (mut w, mut h, mut d) = (w, h, data);
while w > 1024 {
@@ -807,10 +889,7 @@ pub(crate) fn add_environment(b: &mut Builder, w: u32, h: u32, data: Vec<[f32; 4
let (sw, sh, sd) = small.unwrap_or((w, h, d));
let irr = irradiance_map(sw, sh, &sd, 32, 16);
let ii = b.texture_from(32, 16, [0, 1], irr.into_iter()) as u32;
// Intensity × colour folded into the radiance (one scalar on the GPU): tint the texels.
let k = (intensity[0] + intensity[1] + intensity[2]) / 3.0;
if k > 0.0 && (intensity[0] != intensity[1] || intensity[1] != intensity[2]) {
let tint = intensity.map(|c| c / k);
if tint != [1.0, 1.0, 1.0] {
for t in first..=ii {
let ti = b.s.textures[t as usize];
for p in &mut b.s.texels[ti.offset as usize..(ti.offset + ti.width * ti.height) as usize] {
@@ -820,16 +899,45 @@ pub(crate) fn add_environment(b: &mut Builder, w: u32, h: u32, data: Vec<[f32; 4
}
}
}
b.s.env = Some(EnvInfo { radiance: first, mips: levels, irradiance: ii, intensity: k, rotation });
b.s.env = Some(EnvInfo {
radiance: first,
mips: levels,
irradiance: ii,
intensity: k,
rotation: env.rot,
sun,
key,
// Marked; `raster::shadow_maps` assigns the map (a directionless image casts none).
shadow: if env.casts_shadows { 0 } else { -1 },
shadow_darkness: env.shadow_darkness as f32,
shadow_diffusion: env.shadow_diffusion as f32,
});
}
/// The environment layer's pixels as linear straight RGBA.
fn env_pixels(r: &Renderer, ctx: &EvalCtx, src: Option<u64>, lin: bool) -> Option<(u32, u32, Vec<[f32; 4]>)> {
/// The Environment light's image: `Absent` when the light has no Source and the comp has no
/// Environment Layer (the default studio environment applies — After Effects "uses a default
/// environment map" for an Environment light too), `Image` when the source renders, `Failed`
/// when a selected or found source produced no pixels (a deleted layer, missing footage, a
/// failed decode). A failed source must not silently become the default environment — that
/// would relight and reshadow the scene behind the user's back — so, like any layer whose
/// source fails renders nothing, it contributes nothing (#2436).
enum EnvPixels {
Absent,
Image((u32, u32, Vec<[f32; 4]>)),
Failed,
}
fn env_pixels(r: &Renderer, ctx: &EvalCtx, src: Option<u64>, lin: bool) -> EnvPixels {
let layer = match src {
Some(id) => ctx.comp.layer(effectcraft_project::LayerId(id))?,
None => ctx.comp.layers.iter().find(|l| l.environment && l.is_active_at(ctx.time))?,
Some(id) => ctx.comp.layer(effectcraft_project::LayerId(id)),
None => ctx.comp.layers.iter().find(|l| l.environment && l.is_active_at(ctx.time)),
};
let buf = r.blend_layer_buf(ctx, layer)?;
let Some(layer) = layer else {
// A set Source that resolves to no layer (deleted) failed; an unset one with no
// Environment Layer is simply unconfigured.
return if src.is_some() { EnvPixels::Failed } else { EnvPixels::Absent };
};
let Some(buf) = r.blend_layer_buf(ctx, layer) else { return EnvPixels::Failed };
let img = &buf.img;
let data = img
.data
@@ -843,7 +951,43 @@ fn env_pixels(r: &Renderer, ctx: &EvalCtx, src: Option<u64>, lin: bool) -> Optio
if lin { [s[0], s[1], s[2], 1.0] } else { [srgb_to_linear(s[0]), srgb_to_linear(s[1]), srgb_to_linear(s[2]), 1.0] }
})
.collect();
Some((img.width, img.height, data))
EnvPixels::Image((img.width, img.height, data))
}
/// The default environment when the light has no Source and no Environment Layer: a dim studio
/// gradient, bright above and toward the initial camera, dark below and behind — After
/// Effects "uses a default environment map" for an Environment light too, and its default
/// casts shadows out of the box. The solid-angle-weighted mean is 0.3, a soft studio ambience
/// (a mean of 1 would act like a 100 % white Ambient light and wash the whole scene out, which
/// is not what After Effects' default looks like); the light's colour × intensity still scales
/// the overall level, and its Rotation turns the key around the scene (#2436).
pub(crate) fn default_environment() -> (u32, u32, Vec<[f32; 4]>) {
use super::shade::{dot, equirect_dir};
// The key region's centre: three quarters up, two thirds toward the viewer (the default
// camera looks +z from -z).
let key = [0.0f32, -0.75, -0.66];
let (w, h) = (8u32, 4u32);
let mut data = Vec::with_capacity((w * h) as usize);
let (mut mean, mut weight) = (0.0f32, 0.0f32);
for y in 0..h {
let v = (y as f32 + 0.5) / h as f32;
let sa = (v * std::f32::consts::PI).sin();
for x in 0..w {
let u = (x as f32 + 0.5) / w as f32;
let l = 0.15 + 1.2 * dot(equirect_dir(u, v, 0.0), key).max(0.0);
data.push([l, l, l, 1.0]);
mean += l * sa;
weight += sa;
}
}
// A dim studio: scale the gradient to a weighted mean of exactly 0.3.
let k = if mean.is_finite() && mean > 1e-6 { 0.3 * weight / mean } else { 1.0 };
for p in &mut data {
for c in 0..3 {
p[c] *= k;
}
}
(w, h, data)
}
/// Supersampling factor: 2×2 unless draft or very large.
@@ -935,10 +1079,23 @@ pub(crate) fn build_at(r: &Renderer, ctx: &EvalCtx, sub: Option<&EvalCtx>, run:
let (ls, amb, env) = lights(ctx, lin);
b.s.lights = ls;
b.s.ambient = amb;
if let Some((src, k, rot)) = env
&& let Some((w, h, d)) = env_pixels(r, ctx, src, lin)
{
add_environment(&mut b, w, h, d, k, rot);
if let Some(env) = env {
match env_pixels(r, ctx, env.src, lin) {
EnvPixels::Image((w, h, d)) => add_environment(&mut b, &env, w, h, d),
// No Source and no Environment Layer: the default environment, a soft studio
// gradient of the light's colour × intensity — After Effects "uses a default
// environment map" for an Environment light — so the light always lights the
// scene per its parameters, and its dominant region casts shadows (#2436).
EnvPixels::Absent => {
let (w, h, d) = default_environment();
add_environment(&mut b, &env, w, h, d)
}
// A selected or found source that produced no pixels (deleted layer, missing
// footage, failed decode): no environment at all — the light contributes
// nothing, exactly like a layer whose source failed renders nothing, never a
// silent swap to the default studio's lighting and shadows (#2436).
EnvPixels::Failed => {}
}
}
if r.opts.shadows() {
super::raster::shadow_maps(&mut b.s, r.opts.draft);
@@ -947,6 +1104,9 @@ pub(crate) fn build_at(r: &Renderer, ctx: &EvalCtx, sub: Option<&EvalCtx>, run:
for l in &mut b.s.lights {
l.shadow = -1;
}
if let Some(e) = &mut b.s.env {
e.shadow = -1;
}
}
}
// Camera matrices.
+24 -1
View File
@@ -382,6 +382,23 @@ pub fn light_shadow(s: &Scene, l: &Light, p: [f32; 3], ng: [f32; 3], n_l: f32) -
1.0 - l.shadow_darkness * (1.0 - f)
}
/// The visibility of the Environment light at `p` (1 = unshadowed), darkened by its Shadow
/// Darkness — but only by the image's key fraction: the shadow map occludes the dominant
/// (key) lobe, while the environment's fill keeps shining on the shadowed surface, so an
/// Environment light's shadow stays partial the way After Effects renders an area-light
/// shadow (a broad environment like the default studio shadows softly, a lone bright spot
/// on black almost fully). A directionless image (or a non-casting light) has no map
/// and shades unshadowed.
pub fn env_shadow(s: &Scene, e: &EnvInfo, p: [f32; 3], ng: [f32; 3], n: [f32; 3]) -> f32 {
if e.shadow < 0 || e.sun == [0.0; 3] {
return 1.0;
}
let Some(m) = s.shadows.get(e.shadow as usize) else { return 1.0 };
let n_l = dot(n, e.sun).clamp(0.0, 1.0);
let f = shadow_factor(m, &s.shadow_texels, p, ng, n_l);
1.0 - e.shadow_darkness * e.key * (1.0 - f)
}
/// Unit direction from `p` to light `l`, and the distance (infinite for a parallel light).
fn to_light(l: &Light, p: [f32; 3]) -> ([f32; 3], f32) {
if l.kind == 0 {
@@ -529,9 +546,11 @@ pub fn shade(s: &Scene, f: &Frag) -> Option<[f32; 4]> {
let (irr, spec) = env_light(s, e, n, r, rough);
let f0 = [mix(0.04 * m.specular_k, rgb[0], metallic), mix(0.04 * m.specular_k, rgb[1], metallic), mix(0.04 * m.specular_k, rgb[2], metallic)];
let (a, b) = env_brdf(n_v, rough);
// The Environment light's shadow darkens its own diffuse and specular.
let sh = if m.receives_shadows { env_shadow(s, e, f.pos, ng, n) } else { 1.0 };
for c in 0..3 {
let fs = f0[c] * a + b;
out[c] += (irr[c] * rgb[c] * (1.0 - fs) * (1.0 - metallic) * m.diffuse_k + spec[c] * fs) * ao;
out[c] += (irr[c] * rgb[c] * (1.0 - fs) * (1.0 - metallic) * m.diffuse_k + spec[c] * fs) * ao * sh;
}
}
let c = add(out, emissive);
@@ -557,5 +576,9 @@ fn shadow_only(s: &Scene, m: &Material, f: &Frag, alpha: f32) -> [f32; 4] {
}
amount = amount.max(1.0 - light_shadow(s, l, f.pos, n, n_l));
}
// The Environment light's shadow counts too (it reaches every surface: no falloff).
if let Some(e) = &s.env {
amount = amount.max(1.0 - env_shadow(s, e, f.pos, n, n));
}
[0.0, 0.0, 0.0, alpha * amount]
}
+495 -1
View File
@@ -2,7 +2,7 @@ use effectcraft_keyframe::Value;
use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, LightKind, PrimitiveKind, Project, Renderer as R3, build};
use effectcraft_time::{FrameRate, Tick};
use super::scene::{Material, Scene, TexInfo, Vertex, irradiance_map};
use super::scene::{Material, Scene, TexInfo, Vertex, dominant_direction, irradiance_map};
use super::shade::{self, PI};
use super::*;
use crate::{NoFootage, RenderOpts, Renderer};
@@ -67,6 +67,90 @@ fn constant_environment_has_constant_irradiance() {
assert!(irr[16][0] > 0.9 && irr[15 * 32 + 16][0] < 0.1, "{:?} {:?}", irr[16], irr[15 * 32 + 16]);
}
/// A NaN or infinite texel poisons the sums (every NaN comparison is false, so the thresholds
/// alone would pass it through as a NaN direction); an infinite one even where sin θ = 0 turns
/// into NaN. Either way the sum is garbage, not a direction: no sun and no key, so no shadow
/// (#2436).
#[test]
fn a_non_finite_environment_has_no_sun_direction() {
let nan = vec![[f32::NAN; 4]; 8 * 4];
assert_eq!(dominant_direction(8, 4, &nan, [1.0; 3], 0.0), ([0.0; 3], 0.0));
let inf = vec![[f32::INFINITY, 0.0, 0.0, 1.0]; 8 * 4];
assert_eq!(dominant_direction(8, 4, &inf, [1.0; 3], 0.0), ([0.0; 3], 0.0));
}
/// The key fraction: a uniform image is directionless (key 0), a lone bright spot on black is
/// almost fully directional, and a broad gradient sits in between — the soft studio default
/// shadows visibly but keeps most of its light as fill (#2436).
#[test]
fn environment_key_strength_tracks_directionality() {
let uniform = vec![[0.5f32, 0.5, 0.5, 1.0]; 8 * 4];
assert_eq!(dominant_direction(8, 4, &uniform, [1.0; 3], 0.0).1, 0.0);
let mut spot = vec![[0.0f32; 4]; 64 * 32];
spot[16 * 64 + 32] = [1.0, 1.0, 1.0, 1.0];
let (_, key) = dominant_direction(64, 32, &spot, [1.0; 3], 0.0);
assert!(key > 0.95, "a lone spot is almost fully directional: {key}");
let (w, h, d) = scene::default_environment();
let (_, key) = dominant_direction(w, h, &d, [1.0; 3], 0.0);
assert!((0.35..0.55).contains(&key), "the default studio is a broad key with fill: {key}");
}
/// #2436 review: a non-uniform image can be directionless too — two equal bright spots at
/// opposite azimuths pull the radiance-weighted mean in opposite directions and cancel,
/// leaving no dominant region to shadow from (under 1 % directional): no sun, no key, so no
/// shadow, just like a uniform image. Tilt the balance and a direction re-emerges, pointing
/// at the brighter spot.
#[test]
fn opposing_bright_regions_cancel_to_no_shadow() {
let (w, h) = (64u32, 32u32);
let mut opposed = vec![[0.0f32; 4]; (w * h) as usize];
// Rows 15 and 16 mirror about the equator (v = 0.484375 / 0.515625), and columns 16 and 48
// sit half a width apart (u = 0.2578125 / 0.7578125): the two texel-centre directions are
// exactly opposite — equal solid-angle weight, opposite pull.
let (a, b) = ((15 * w + 16) as usize, (16 * w + 48) as usize);
opposed[a] = [1.0, 1.0, 1.0, 1.0];
opposed[b] = [1.0, 1.0, 1.0, 1.0];
assert_eq!(dominant_direction(w, h, &opposed, [1.0; 3], 0.0), ([0.0; 3], 0.0));
// One side twice as bright: the mean points at the brighter spot with its share of the
// weight (key = 1/3 — two parts against one).
let mut tilted = opposed.clone();
tilted[a] = [2.0, 2.0, 2.0, 1.0];
let (sun, key) = dominant_direction(w, h, &tilted, [1.0; 3], 0.0);
assert!(sun != [0.0; 3] && key > 0.3, "the brighter side wins: {sun:?} key {key}");
let want = shade::equirect_dir(16.5 / 64.0, 15.5 / 32.0, 0.0);
let dot = sun[0] * want[0] + sun[1] * want[1] + sun[2] * want[2];
assert!(dot > 0.999, "the sun points at the brighter spot: {sun:?} vs {want:?}");
}
/// #2436 review: the sun follows the tinted radiance, not the raw texels — equally bright red
/// and blue regions on exactly opposite texels cancel to a white light (no shadow), but the
/// red light's tint removes the blue side's pull, so the mean points straight at the red one
/// (a blue light, at the blue one): the shadow lands where the emitted light comes from.
#[test]
fn a_coloured_light_shadows_from_the_region_it_tints() {
let (w, h) = (64u32, 32u32);
let mut opposed = vec![[0.0f32; 4]; (w * h) as usize];
// Rows 15 and 16 mirror about the equator, columns 16 and 48 half a width apart: the two
// texel-centre directions are exactly opposite — equal solid-angle weight, opposite pull.
let (a, b) = ((15 * w + 16) as usize, (16 * w + 48) as usize);
opposed[a] = [1.0, 0.0, 0.0, 1.0];
opposed[b] = [0.0, 0.0, 1.0, 1.0];
// To a white light the channel sums are equal: they cancel, no shadow.
assert_eq!(dominant_direction(w, h, &opposed, [1.0; 3], 0.0), ([0.0; 3], 0.0));
// A red light's tint zeroes the blue texel's weight: the red one becomes a lone spot.
// (Any positive scale of the tint — the normalised [3, 0, 0] included — points the same
// way; only the channel ratios matter.)
let (sun, key) = dominant_direction(w, h, &opposed, [1.0, 0.0, 0.0], 0.0);
let want = shade::equirect_dir(16.5 / 64.0, 15.5 / 32.0, 0.0);
let dot = sun[0] * want[0] + sun[1] * want[1] + sun[2] * want[2];
assert!(dot > 0.999 && key > 0.95, "the red side wins: {sun:?} key {key}");
// A blue light points at the blue one instead.
let (sun, _) = dominant_direction(w, h, &opposed, [0.0, 0.0, 1.0], 0.0);
let want = shade::equirect_dir(48.5 / 64.0, 16.5 / 32.0, 0.0);
let dot = sun[0] * want[0] + sun[1] * want[1] + sun[2] * want[2];
assert!(dot > 0.999, "the blue side wins: {sun:?}");
}
#[test]
fn equirect_round_trip_and_env_brdf() {
for (u, v) in [(0.1f32, 0.2f32), (0.5, 0.5), (0.9, 0.7)] {
@@ -337,6 +421,365 @@ fn cards_and_environment_light() {
assert_eq!(lit.data[80 * 120 + 110][3], 0.0);
}
/// An Environment light with no Source and no Environment Layer lights the scene with a flat
/// default environment of its colour × intensity — After Effects "uses a default environment
/// map" for an Environment light — not nothing at all (#2436).
#[test]
fn environment_light_without_a_source_lights_the_scene() {
let (mut p, cid) = project(120, 90);
let sid = p.add_item(
"S",
Default::default(),
None,
ItemKind::Solid(effectcraft_project::Solid { color: [0.5, 0.5, 0.5], width: 60, height: 40, pixel_aspect: 1.0 }),
);
add(&mut p, cid, LayerSource::Solid { item: sid }, |l| l.switches.three_d = true);
let unlit = render(&p, cid);
let c = unlit.data[20 * 120 + 30];
assert!((c[0] - 0.5).abs() < 0.01 && c[3] > 0.99, "unlit card keeps its colour: {c:?}");
// No Source, no Environment Layer: the light still lights the card (a flat white
// environment at the light's colour × intensity; a card's Material Options shade it).
add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |_| {});
let lit = render(&p, cid);
let c2 = lit.data[20 * 120 + 30];
assert!((c2[0] - c[0]).abs() > 0.05 && c2[3] > 0.99, "the light has an effect: {c2:?} vs {c:?}");
// The light's parameters drive it: half intensity darkens towards zero intensity's black.
set(p.comp_mut(cid).unwrap().layers.first_mut().unwrap(), "lightOptions/intensity", Value::Scalar(50.0));
let half = render(&p, cid);
let c3 = half.data[20 * 120 + 30];
assert!(c3[0] < c2[0] - 0.02, "half intensity lights less: {c3:?} vs {c2:?}");
set(p.comp_mut(cid).unwrap().layers.first_mut().unwrap(), "lightOptions/intensity", Value::Scalar(0.0));
let dark = render(&p, cid);
let c4 = dark.data[20 * 120 + 30];
assert!(c4[0] < 0.02 && c4[3] > 0.99, "zero intensity lights nothing: {c4:?}");
// And its colour tints the light red.
set(p.comp_mut(cid).unwrap().layers.first_mut().unwrap(), "lightOptions/intensity", Value::Scalar(100.0));
set(p.comp_mut(cid).unwrap().layers.first_mut().unwrap(), "lightOptions/color", Value::Color([1.0, 0.0, 0.0, 1.0]));
let red = render(&p, cid);
let c5 = red.data[20 * 120 + 30];
assert!(c5[0] > 0.15 && c5[1] < 0.05 && c5[2] < 0.05, "red environment: {c5:?}");
}
/// #2436 review: a selected or auto-picked environment source that fails to render (missing
/// footage, a deleted layer) must not silently swap in the default studio environment — the
/// light contributes nothing, exactly like a layer whose source failed renders nothing. Only a
/// light with no Source at all (and no Environment Layer in the comp) uses the default.
#[test]
fn a_failed_environment_source_lights_nothing() {
let (mut p, cid) = project(120, 90);
let sid = p.add_item(
"S",
Default::default(),
None,
ItemKind::Solid(effectcraft_project::Solid { color: [1.0, 1.0, 1.0], width: 60, height: 40, pixel_aspect: 1.0 }),
);
add(&mut p, cid, LayerSource::Solid { item: sid }, |l| l.switches.three_d = true);
let unlit = render(&p, cid);
// The Environment Layer's footage is unreadable (missing; NoFootage serves no frames).
let fid = p.add_item(
"F",
Default::default(),
None,
ItemKind::Footage(effectcraft_project::Footage {
path: "missing.exr".into(),
kind: effectcraft_project::FootageKind::Still,
width: 64,
height: 32,
pixel_aspect: 1.0,
duration: Tick::from_seconds_f64(1.0),
has_video: true,
missing: true,
..Default::default()
}),
);
add(&mut p, cid, LayerSource::Footage { item: fid }, |l| {
l.switches.three_d = true;
l.environment = true;
});
let env_layer = p.comp(cid).unwrap().layers.iter().find(|l| l.environment).unwrap().id;
add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| {
set(l, "lightOptions/castsShadows", Value::Bool(true));
});
fn light(p: &mut Project, cid: ItemId) -> &mut effectcraft_project::Layer {
p.comp_mut(cid).unwrap().layers.iter_mut().find(|l| l.is_light()).unwrap()
}
// Auto-picked Environment Layer, unreadable: the light is off — no studio default, no
// default-environment shadow either.
let failed = render(&p, cid);
assert_eq!(failed.data, unlit.data, "an unreadable Environment Layer lights nothing");
// The light's Source set to that unreadable layer: same.
set(light(&mut p, cid), "lightOptions/source", Value::Layer(Some(env_layer.0)));
let failed = render(&p, cid);
assert_eq!(failed.data, unlit.data, "an unreadable selected Source lights nothing");
// The Source pointing at a deleted layer: same.
set(light(&mut p, cid), "lightOptions/source", Value::Layer(Some(999_999)));
let failed = render(&p, cid);
assert_eq!(failed.data, unlit.data, "a deleted selected Source lights nothing");
// Contrast: with no Source and no Environment Layer, the default studio still lights it.
let comp = p.comp_mut(cid).unwrap();
comp.layers.retain(|l| l.is_light() || !l.environment);
set(comp.layers.iter_mut().find(|l| l.is_light()).unwrap(), "lightOptions/source", Value::Layer(None));
let lit = render(&p, cid);
let (c, c2) = (unlit.data[20 * 120 + 30], lit.data[20 * 120 + 30]);
assert!(c2[3] > 0.99 && (c2[0] - c[0]).abs() > 0.05, "the unconfigured light still lights: {c2:?} vs {c:?}");
}
/// #2436: an Environment light's Cast Shadows works like After Effects' — its image's dominant
/// (brightest) region shadows the scene. A bright disc above the image's centre, turned to
/// shine from up and behind the camera (rotation 180°), shadows a sphere onto the floor below
/// it; Cast Shadows off lights it evenly.
#[test]
fn environment_lights_cast_shadows() {
let (mut p, cid) = project(200, 150);
// The floor facing the camera, a sphere in front of it casting shadows only.
add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Plane }, |l| {
set(l, "geometryOptions/width", Value::Scalar(400.0));
set(l, "geometryOptions/height", Value::Scalar(400.0));
set(l, "transform/position", Value::Vec3([100.0, 75.0, 110.0]));
set(l, "materialOptions/baseColor", Value::Color([1.0, 1.0, 1.0, 1.0]));
});
add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Sphere }, |l| {
set(l, "geometryOptions/radius", Value::Scalar(30.0));
set(l, "transform/position", Value::Vec3([100.0, 75.0, 50.0]));
set(l, "materialOptions/castsShadows", Value::Enum(2));
});
// The Environment Layer: a tight bright key above the image's centre, on black — a
// full-comp rect keeps the layer buffer full-size, the radial gradient is the key.
add(&mut p, cid, LayerSource::Shape, |l| {
l.switches.three_d = true;
let mut next = 10_000u64;
let ids = &mut build::Ids(&mut next);
let rect = build::shape_rect(ids, [200.0, 150.0], [0.0, 0.0], 0.0);
let key = build::shape_gradient_fill(
ids,
true,
[0.0, -30.0],
[0.0, 5.0],
effectcraft_keyframe::Gradient { colors: vec![(0.0, [1.0, 1.0, 1.0, 1.0]), (1.0, [0.0, 0.0, 0.0, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 1.0)] },
);
let g = build::shape_group(ids, "Key", vec![rect, key]);
l.props.sub_mut("contents").unwrap().children.push(g.into());
l.environment = true;
});
// Turned 180°, the key shines from up and behind the camera onto the sphere and floor.
add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| {
set(l, "lightOptions/rotation", Value::Scalar(180.0));
set(l, "lightOptions/castsShadows", Value::Bool(true));
});
// The shadow lands below the sphere on the floor: its core is the darkest floor pixel
// below the sphere; the lit floor is the sample to the left. The frame is sRGB-encoded,
// so the ratios below are gamma-compressed (a linear halving reads as ~0.73).
let luma = |img: &crate::Image, x: usize, y: usize| (img.data[y * 200 + x][0] + img.data[y * 200 + x][1] + img.data[y * 200 + x][2]) / 3.0;
let img = render(&p, cid);
let side = luma(&img, 25, 75);
let (mut core, mut cx, mut cy) = (f32::INFINITY, 0usize, 0usize);
for y in 100..150 {
for x in 60..140 {
if luma(&img, x, y) < core {
core = luma(&img, x, y);
cx = x;
cy = y;
}
}
}
assert!(side > 0.1, "the floor is lit: {side}");
assert!(core < side * 0.65, "the key shadows the sphere onto the floor: {core} vs {side} at ({cx}, {cy})");
// Cast Shadows off: the floor is lit everywhere, core included.
let comp = p.comp_mut(cid).unwrap();
set(&mut comp.layers[0], "lightOptions/castsShadows", Value::Bool(false));
let img = render(&p, cid);
let mid = luma(&img, cx, cy);
assert!(mid > side * 0.9, "no shadow without Cast Shadows: {mid} vs {side}");
}
/// #2436: a uniform environment has no dominant region, so even with Cast Shadows on it casts
/// nothing — the render is the same as with the option off.
#[test]
fn a_uniform_environment_casts_no_shadow() {
let (mut p, cid) = project(120, 90);
let card = p.add_item(
"S",
Default::default(),
None,
ItemKind::Solid(effectcraft_project::Solid { color: [1.0, 1.0, 1.0], width: 60, height: 40, pixel_aspect: 1.0 }),
);
add(&mut p, cid, LayerSource::Solid { item: card }, |l| l.switches.three_d = true);
let env = p.add_item(
"E",
Default::default(),
None,
ItemKind::Solid(effectcraft_project::Solid { color: [1.0, 1.0, 1.0], width: 64, height: 32, pixel_aspect: 1.0 }),
);
add(&mut p, cid, LayerSource::Solid { item: env }, |l| {
l.environment = true;
l.switches.three_d = true;
});
add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| {
set(l, "lightOptions/castsShadows", Value::Bool(true));
});
let casting = render(&p, cid);
let comp = p.comp_mut(cid).unwrap();
set(&mut comp.layers[0], "lightOptions/castsShadows", Value::Bool(false));
let off = render(&p, cid);
assert!(casting.data[20 * 120 + 30][0] > 0.3, "the card is lit");
assert_eq!(casting.data, off.data, "a directionless image casts no shadow");
}
/// #2436 review: a chromatic environment under a coloured light — equally bright red and blue
/// regions on exactly opposite sides of the image cancel to a white light (it casts no
/// shadow), but the red Environment light's tint removes the blue region's pull, so the red
/// one becomes the key and casts the shadow the user asked for, from the red side.
#[test]
fn a_red_light_shadows_from_the_red_region_of_a_chromatic_environment() {
let (mut p, cid) = project(200, 150);
// The floor facing the camera, a sphere in front of it casting shadows only.
add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Plane }, |l| {
set(l, "geometryOptions/width", Value::Scalar(400.0));
set(l, "geometryOptions/height", Value::Scalar(400.0));
set(l, "transform/position", Value::Vec3([100.0, 75.0, 110.0]));
set(l, "materialOptions/baseColor", Value::Color([1.0, 1.0, 1.0, 1.0]));
});
add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Sphere }, |l| {
set(l, "geometryOptions/radius", Value::Scalar(30.0));
set(l, "transform/position", Value::Vec3([100.0, 75.0, 50.0]));
set(l, "materialOptions/castsShadows", Value::Enum(2));
});
// The Environment Layer: a black background with a red square and an equally bright blue
// one at exactly antipodal image positions (the blue is the point mirror of the red through
// the antipodal map x -> x + w/2, y -> h - y: centres (14.5, 44.5) and (114.5, 105.5) in a
// 200x150 image, 20x20 each) — equal solid-angle weight, opposite pull. The red sits up,
// left and toward the camera; the blue down, right and away from it.
add(&mut p, cid, LayerSource::Shape, |l| {
l.environment = true;
let mut next = 10_000u64;
let ids = &mut build::Ids(&mut next);
// Contents stack like the AE timeline — the first group is on top — so the background
// goes in last, under the red and blue squares (and its full-comp rect keeps the
// layer buffer full-size: a shape layer's buffer otherwise crops to its content).
let red_rect = build::shape_rect(ids, [20.0, 20.0], [-85.5, -30.5], 0.0);
let red_fill = build::shape_fill(ids, [1.0, 0.0, 0.0, 1.0]);
let red = build::shape_group(ids, "Red", vec![red_rect, red_fill]);
let blue_rect = build::shape_rect(ids, [20.0, 20.0], [14.5, 30.5], 0.0);
let blue_fill = build::shape_fill(ids, [0.0, 0.0, 1.0, 1.0]);
let blue = build::shape_group(ids, "Blue", vec![blue_rect, blue_fill]);
let bg_rect = build::shape_rect(ids, [200.0, 150.0], [0.0, 0.0], 0.0);
let bg_fill = build::shape_fill(ids, [0.0, 0.0, 0.0, 1.0]);
let bg = build::shape_group(ids, "BG", vec![bg_rect, bg_fill]);
for g in [red, blue, bg] {
l.props.sub_mut("contents").unwrap().children.push(g.into());
}
});
// A white light over the chromatic image: the regions cancel — Cast Shadows changes
// nothing, exactly like a directionless image.
add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| {
set(l, "lightOptions/castsShadows", Value::Bool(true));
});
let white_casting = render(&p, cid);
let comp = p.comp_mut(cid).unwrap();
set(comp.layers.iter_mut().find(|l| l.is_light()).unwrap(), "lightOptions/castsShadows", Value::Bool(false));
let white_off = render(&p, cid);
assert_eq!(white_casting.data, white_off.data, "a white light sees no dominant region in the opposed red and blue");
// The red light: the tint removes the blue region, the red one shadows the sphere onto the
// floor below and right of it (the shadow falls opposite the red region).
let comp = p.comp_mut(cid).unwrap();
let light = comp.layers.iter_mut().find(|l| l.is_light()).unwrap();
set(light, "lightOptions/color", Value::Color([1.0, 0.0, 0.0, 1.0]));
set(light, "lightOptions/castsShadows", Value::Bool(true));
let img = render(&p, cid);
let chan = |img: &crate::Image, x: usize, y: usize| img.data[y * 200 + x][0];
let side = chan(&img, 25, 75);
let (mut core, mut cx) = (f32::INFINITY, 0usize);
for y in 100..150 {
for x in 60..170 {
if chan(&img, x, y) < core {
core = chan(&img, x, y);
cx = x;
}
}
}
assert!(side > 0.1, "the floor is lit red: {side}");
assert!(core < side * 0.65, "the red region shadows the sphere onto the floor: {core} vs {side} at x {cx}");
assert!(cx > 100, "the shadow is opposite the red region (right of the sphere): {cx}");
}
/// #2436: the default environment (no Source, no Environment Layer) is a dim studio — a mean
/// of 0.3, not the 1.0 that would act like a 100 % white Ambient light and wash the scene out
/// (After Effects' default does not) — and directional: its dominant region points up and
/// toward the initial camera, so a shadow-casting Environment light casts soft, partial
/// shadows out of the box, like After Effects' default.
#[test]
fn default_environment_keeps_the_level_and_points_up() {
let (w, h, d) = scene::default_environment();
assert_eq!((w, h), (8, 4));
let (mut mean, mut weight) = (0.0f32, 0.0f32);
for (i, p) in d.iter().enumerate() {
let v = ((i / w as usize) as f32 + 0.5) / h as f32;
let sa = (v * PI).sin();
mean += (p[0] + p[1] + p[2]) / 3.0 * sa;
weight += sa;
}
assert!((mean / weight - 0.3).abs() < 0.01, "dim studio mean: {}", mean / weight);
let (sun, key) = dominant_direction(w, h, &d, [1.0; 3], 0.0);
assert!(sun[1] < -0.5, "the key is up: {sun:?}");
assert!(sun[2] < -0.3, "the key is toward the camera: {sun:?}");
assert!(sun[0].abs() < 0.1, "symmetric left-right: {sun:?}");
assert!((0.35..0.55).contains(&key), "a broad key with fill: {key}");
// No texel clips past white at the default intensity (the fog of a mean-1 environment).
assert!(d.iter().all(|p| p[0] <= 1.0 && p[0] > 0.0), "0 < radiance <= 1");
}
/// #2436: with no Source and no Environment Layer, the Environment light's default environment
/// casts soft, partial shadows like After Effects' default environment map — a sphere shadows
/// the plane behind it, darkening part of the light while the fill keeps the floor visible
/// (an area-light shadow, not the pitch black a point light casts); Cast Shadows off lights
/// the floor evenly.
#[test]
fn the_default_environment_casts_shadows() {
let (mut p, cid) = project(200, 150);
// The floor facing the camera, a sphere in front of it casting shadows only.
add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Plane }, |l| {
set(l, "geometryOptions/width", Value::Scalar(400.0));
set(l, "geometryOptions/height", Value::Scalar(400.0));
set(l, "transform/position", Value::Vec3([100.0, 75.0, 120.0]));
set(l, "materialOptions/baseColor", Value::Color([1.0, 1.0, 1.0, 1.0]));
});
add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Sphere }, |l| {
set(l, "geometryOptions/radius", Value::Scalar(30.0));
set(l, "transform/position", Value::Vec3([100.0, 45.0, 50.0]));
set(l, "materialOptions/castsShadows", Value::Enum(2));
});
add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| {
set(l, "lightOptions/castsShadows", Value::Bool(true));
});
// The default key (up and toward the camera) drops the shadow below the sphere: the core
// is the darkest floor pixel below it, the lit floor the sample to the left.
let luma = |img: &crate::Image, x: usize, y: usize| (img.data[y * 200 + x][0] + img.data[y * 200 + x][1] + img.data[y * 200 + x][2]) / 3.0;
let img = render(&p, cid);
let side = luma(&img, 30, 100);
let (mut core, mut cx, mut cy) = (f32::INFINITY, 0usize, 0usize);
for y in 110..150 {
for x in 80..120 {
if luma(&img, x, y) < core {
core = luma(&img, x, y);
cx = x;
cy = y;
}
}
}
assert!(side > 0.1, "the floor is lit: {side}");
// Partial: the key lobe is occluded (a clear darkening) but the fill keeps shining —
// not the pitch black a point light's shadow would cast at the same darkness. The frame
// is sRGB-encoded, so the ratios are gamma-compressed (a linear halving reads as ~0.73).
assert!(core < side * 0.8, "the default environment shadows the sphere onto the floor: {core} vs {side} at ({cx}, {cy})");
assert!(core > side * 0.45, "an area-light shadow, partial like After Effects': {core} vs {side}");
// Cast Shadows off: the floor is lit everywhere, core included.
let comp = p.comp_mut(cid).unwrap();
set(&mut comp.layers[0], "lightOptions/castsShadows", Value::Bool(false));
let img = render(&p, cid);
let mid = luma(&img, cx, cy);
assert!(mid > side * 0.9, "no shadow without Cast Shadows: {mid} vs {side}");
}
#[test]
fn depth_of_field_blurs_out_of_focus_surfaces() {
let mut img = Image::new(64, 64);
@@ -1103,3 +1546,54 @@ fn hard_edges_ramp_over_two_texels() {
assert!((1.7..3.0).contains(&w), "v {v0}: hard edge ramps over {w} texels");
}
}
// ---------------------------------------------------------------- the After Effects look (#2436)
/// #2436: an Environment light with every option at its default renders like After Effects':
/// a dim studio that leaves the lit floor a mid gray instead of washing it out to white (the
/// fog of a mean-1 default), a partial shadow (the key lobe occluded, the fill still shining —
/// not the pitch black a point light casts), and a soft edge in both quality and draft renders
/// (the map's texel grid never shows through as stair-steps).
#[test]
fn environment_shadows_match_the_after_effects_look() {
let (mut p, cid) = project(800, 600);
add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Plane }, |l| {
set(l, "geometryOptions/width", Value::Scalar(1200.0));
set(l, "geometryOptions/height", Value::Scalar(900.0));
set(l, "transform/position", Value::Vec3([400.0, 300.0, 300.0]));
set(l, "materialOptions/baseColor", Value::Color([1.0, 1.0, 1.0, 1.0]));
});
add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Sphere }, |l| {
set(l, "geometryOptions/radius", Value::Scalar(80.0));
set(l, "transform/position", Value::Vec3([400.0, 300.0, 120.0]));
set(l, "materialOptions/castsShadows", Value::Enum(2));
});
add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| {
set(l, "lightOptions/castsShadows", Value::Bool(true));
});
let luma = |img: &crate::Image, x: usize, y: usize| (img.data[y * 800 + x][0] + img.data[y * 800 + x][1] + img.data[y * 800 + x][2]) / 3.0;
for (name, opts) in [("quality", RenderOpts::default()), ("draft", RenderOpts { draft: true, ..Default::default() })] {
let img = Renderer::new(&p, &NoFootage, opts).comp_frame(cid, Tick::ZERO);
// The shadow core: the darkest pixel of the lower half (the key drops the shadow
// below the sphere); the lit floor is the mean of the band to its left.
let (mut core, mut cx, mut cy) = (f32::INFINITY, 0usize, 0usize);
for y in 420..580 {
for x in 300..500 {
if luma(&img, x, y) < core {
core = luma(&img, x, y);
cx = x;
cy = y;
}
}
}
let lit: f32 = (60..260).map(|x| luma(&img, x, cy)).sum::<f32>() / 200.0;
assert!(lit > 0.15 && lit < 0.85, "{name}: the lit floor is a mid gray, no white fog: {lit}");
// The frame is sRGB-encoded, so the ratio is gamma-compressed (a linear halving
// reads as ~0.73): partial means clearly darker than lit, far from black.
assert!(core > lit * 0.45 && core < lit * 0.85, "{name}: a partial area-light shadow: core {core} vs lit {lit}");
// The edge's 10-90 % ramp from the lit plateau (left) down into the shadow.
let first_below = |t: f32| (260..cx).position(|x| luma(&img, x, cy) < core + (lit - core) * t).map(|i| 260 + i);
let w = (first_below(0.1).unwrap() as f32) - (first_below(0.9).unwrap() as f32);
assert!(w >= 3.5, "{name}: the edge ramps over {w} px (After Effects' env shadows are soft)");
}
}
+1
View File
@@ -523,6 +523,7 @@ fn gather<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, run: &[&'a Layer], out: (u32,
extra = ctx
.comp
.layers
.as_arcs()
.par_iter()
.filter(|l| l.switches.three_d && l.has_video() && l.is_active_at(ctx.time) && !in_run.contains(&l.id) && !l.switches.adjustment)
.filter(|l| Material::of(ctx, l).casts_shadows != 0)