mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-31 07:38:24 +00:00
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:
commit
9e6332ea27
181 files changed
+8437
-619
No files matched your search
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(©_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, ©_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);
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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)");
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user