Merge upstream/main into rfx-masks-shapes

render: upstream moved the tests_*.rs modules into crates/render/src/tests/; the branch's
tests_mask_feather.rs now lives at tests/mask_feather.rs and is registered in tests.rs.
path: rect() keeps the branch's explicit-outline version with an added NaN guard on the
roundness clamp bound (main's fix for the same panic).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76
2026-10-27 16:57:13 +01:00
co-authored by Claude Sonnet 5.5
812 changed files with 67230 additions and 44816 deletions
+352
View File
@@ -0,0 +1,352 @@
use super::frames::content_bounds;
use super::fx_host::MAX_FX_DEPTH;
use super::layers::StyledLayer;
use super::opts::LayerTiming;
use super::*;
/// Which compositor renders a frame (Project Settings ▸ Video Rendering and Effects).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Backend {
/// The CPU reference compositor (Mercury Software Only).
#[default]
Cpu,
/// The GPU compositor whenever an [`Accelerator`] is attached, whatever the project says.
Gpu,
/// The GPU compositor when an [`Accelerator`] is attached and the project's renderer is
/// Mercury GPU Acceleration (the viewer's choice); the CPU otherwise.
Auto,
}
impl Backend {
pub fn parse(s: &str) -> Option<Backend> {
match s.to_ascii_lowercase().as_str() {
"cpu" | "software" | "mercury software only" => Some(Backend::Cpu),
"gpu" | "mercury gpu acceleration" => Some(Backend::Gpu),
"auto" => Some(Backend::Auto),
_ => None,
}
}
}
/// One effect of a GPU effect chain (see [`Accelerator::effects`]).
pub struct FxStep<'x> {
pub spec: &'static effectcraft_effects::EffectSpec,
pub ctx: EffectCtx<'x>,
}
/// A GPU (or other hardware) backend for the compositor. The CPU [`Renderer`] stays the
/// reference: an accelerator renders what it supports and returns `None` for the rest, which
/// then runs on the CPU. Implemented by `effectcraft-gpu`.
pub trait Accelerator: Send + Sync {
/// Adapter / backend name for status readouts ("Apple M2 (Metal)").
fn name(&self) -> String;
/// Render a top-level comp frame (what [`Renderer::comp_frame`] returns). `None` = not
/// handled (the CPU renders it).
fn comp_frame(&self, r: &Renderer, comp: ItemId, t: Tick) -> Option<Image>;
/// The accelerator implements effect `id` with the CPU effect's semantics.
fn supports_effect(&self, id: &str) -> bool;
/// Run a chain of supported effects on `buf`, clamping and quantising to `levels` after
/// every effect when set (8/16 bpc). `None` = not handled.
fn effects(&self, chain: &[FxStep], buf: &Buf, levels: Option<f32>) -> Option<Buf>;
/// Rasterise and shade an Advanced 3D scene at its raster size (depth buffer, PBR, image
/// based light, shadow maps), with the CPU rasteriser's semantics
/// ([`three_d::adv::raster::render`]). `None` = not handled (the CPU renders it).
fn raster_3d(&self, _scene: &three_d::adv::Scene) -> Option<three_d::adv::Target> {
None
}
/// Render a whole prepared Advanced 3D run (every motion-blur sub-sample rasterised and
/// resolved, averaged, depth of field, encoding) with [`three_d::adv::render_prepared`]'s
/// semantics. `None` = not handled (the CPU path runs, using [`Self::raster_3d`]).
fn render_3d(&self, _run: &three_d::adv::Prepared) -> Option<three_d::adv::Rendered> {
None
}
/// A particle simulation backend (GPU particles) for the stepped particle effects, with
/// the CPU simulation's semantics. `None` = they simulate on the CPU.
fn particles(&self) -> Option<&dyn effectcraft_effects::psim::ParticleSim> {
None
}
/// Timing history [`Backend::Auto`] uses to send each comp's top-level frames to the
/// faster compositor. `None` = Auto always tries the accelerator first.
fn auto_pick(&self) -> Option<&AutoPick> {
None
}
/// Whether the accelerator can still render frames. `false` once it has stopped for good
/// (its device was lost or retired): every frame it is asked for falls back to the CPU, so
/// callers that limit frames in flight for it can stop doing so.
fn available(&self) -> bool {
true
}
/// Deferred readbacks: where a GPU readback cannot be waited for (WebGPU in a browser
/// worker), a frame renders in *passes*. A readback the accelerator does not have yet is
/// started, the step returns a cheap placeholder and the pass is marked missed
/// ([`Self::pass_missed`]); the caller waits for the readbacks in flight (asynchronously)
/// and renders the frame again, and the steps whose results arrived are served from them.
/// The frame is done after a pass with no miss. `frame_begin` forgets the previous frame's
/// results, `pass_begin` clears the miss. Synchronous accelerators never miss.
fn frame_begin(&self) {}
fn pass_begin(&self) {}
fn pass_missed(&self) -> bool {
false
}
/// Raised from a pass's first miss to its end (results computed meanwhile may hold
/// placeholders): give it to [`LayerCache::set_gate`] so they are not cached.
fn miss_gate(&self) -> Option<Arc<std::sync::atomic::AtomicBool>> {
None
}
/// With deferred readbacks: a future that resolves once the readbacks in flight have
/// landed (the browser delivers them from its event loop; natively the device is polled
/// before it returns, so the future is ready). `None` = nothing to wait for. See
/// [`passes::in_passes`].
fn settle(&self) -> Option<std::pin::Pin<Box<dyn std::future::Future<Output = ()> + '_>>> {
None
}
}
/// Where an effect stack runs (see [`Renderer::run_effects_on`]): a CPU buffer, or an image
/// resident on an accelerator.
pub trait FxTarget {
/// Run a chain of accelerator-supported effects (quantising after each to the bit depth).
/// `false` = not handled: the effects then run one by one through [`FxTarget::cpu`].
fn gpu(&mut self, steps: &[FxStep]) -> bool;
/// Run one CPU effect: `f` maps the buffer.
fn cpu(&mut self, f: &mut dyn FnMut(Buf) -> Buf);
/// Grow the buffer to cover its whole layer rectangle (`size` layer pixels from its own
/// corner, the `layer_size` handed to effects), as [`effectcraft_effects::Buf::cover`].
/// Authored buffers (text, shapes) only span their painted content; this gives the stack
/// the room a sized source's buffer already has (#659).
fn cover(&mut self, size: [f64; 2]) {
self.cpu(&mut |mut buf| {
buf.cover(size);
buf
});
}
}
/// The CPU effect target (GPU chains go through [`Accelerator::effects`]: upload, readback).
pub(crate) struct CpuFx<'a> {
pub(crate) accel: Option<&'a dyn Accelerator>,
pub(crate) levels: Option<f32>,
pub(crate) buf: Option<Buf>,
}
impl FxTarget for CpuFx<'_> {
fn gpu(&mut self, steps: &[FxStep]) -> bool {
let (Some(a), Some(b)) = (self.accel, &self.buf) else { return false };
match a.effects(steps, b, self.levels) {
Some(out) => {
self.buf = Some(out);
true
}
None => false,
}
}
fn cpu(&mut self, f: &mut dyn FnMut(Buf) -> Buf) {
if let Some(b) = self.buf.take() {
self.buf = Some(f(b));
}
}
}
/// Hooks for accelerated compositors (`effectcraft-gpu`): the pieces of the CPU walk they reuse
/// or fall back to. The CPU compositor itself is [`Renderer::comp_frame_cpu`].
impl<'a> Renderer<'a> {
/// The project's colour pipeline.
pub fn pipe(&self) -> color::Pipe {
self.pipe
}
/// Nesting depth (0 = the top-level comp).
pub fn depth(&self) -> usize {
self.depth
}
/// The auxiliary 3D channels of comp `comp_id` at comp time `t` (depth, layer IDs, normals,
/// UVs, Cryptomatte; see `three_d::compose::aux_pass`), at the renderer's output scale.
pub fn comp_aux(&self, comp_id: ItemId, t: Tick) -> Option<effectcraft_raster::AuxChannels> {
let ctx = self.eval_ctx(comp_id, t)?;
Some(three_d::compose::aux_pass(self, &ctx))
}
/// The auxiliary channels effects on `layer` read ([`EffectHost::aux`]): its multi-layer
/// OpenEXR footage's at the layer's source time (Time Remap included), or a precomp's
/// generated 3D channels at its nested time. `None` for other layers.
pub fn layer_aux(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Arc<effectcraft_raster::AuxChannels>> {
match &layer.source {
LayerSource::Footage { item } => {
let ItemKind::Footage(f) = &self.project.item(*item)?.kind else { return None };
let a = self.footage.aux(*item, f, ctx.source_time(layer))?;
// Aux pixels per layer pixel (the layer is the footage at its nominal size). The
// planes are only copied when that changes the scale.
let scale = a.width as f64 / f.width as f64;
if f.width > 0 && a.width > 0 && a.scale != scale {
let mut a = (*a).clone();
a.scale = scale;
return Some(Arc::new(a));
}
Some(a)
}
LayerSource::Comp { item } => {
if self.depth > MAX_FX_DEPTH || self.project.comp_contains(*item, ctx.comp_id) {
return None;
}
self.nested().comp_aux(*item, ctx.nested_time(layer)).map(Arc::new)
}
_ => None,
}
}
/// Evaluation context of comp `comp_id` at comp time `t`.
pub fn eval_ctx(&self, comp_id: ItemId, t: Tick) -> Option<EvalCtx<'a>> {
Some(self.ctx(comp_id, self.project.comp(comp_id)?, t))
}
/// The layers drawn at the context time, bottom to top (solo, guides and visibility
/// applied).
pub fn visible_layers(&self, ctx: &EvalCtx<'a>) -> Vec<&'a Layer> {
let (comp, t) = (ctx.comp, ctx.time);
let any_solo = comp.any_solo();
comp.layers
.iter()
.rev()
.filter(|l| l.is_active_at(t) && l.has_video() && !l.environment && (!any_solo || l.switches.solo) && (self.opts.guides || !l.switches.guide))
.collect()
}
/// Draw a run of consecutive 3D layers into `canvas` (CPU).
pub fn draw_3d_run(&self, ctx: &EvalCtx<'a>, run: &[&Layer], canvas: &mut Image) {
three_d::compose::draw_run(self, ctx, run, canvas);
}
/// A run of consecutive 3D layers prepared for an accelerator (planes, lights, shadow
/// casters; see [`three_d::Run3d`]) at output size `out`. `None` = draw it with
/// [`Self::draw_3d_run`] (adjustment or wireframe layers in the run, Advanced 3D).
pub fn prepare_3d_run(&self, ctx: &EvalCtx<'a>, run: &[&Layer], out: (u32, u32)) -> Option<three_d::Run3d> {
three_d::compose::gpu_run(self, ctx, run, out)
}
/// A run of consecutive 3D layers of an Advanced 3D comp prepared for an accelerator's
/// compositor ([`three_d::adv::Prepared`]): rendered as one scene and composited (Normal,
/// premultiplied over) onto the canvas. `None` = not an Advanced 3D comp, or a layer needs the
/// 2D compositing path (blend mode, track matte, Preserve Transparency): draw it with
/// [`Self::draw_3d_run`].
pub fn prepare_adv_run<'p>(&'p self, ctx: &'p EvalCtx<'a>, run: &'p [&'p Layer], out: (u32, u32)) -> Option<three_d::adv::Prepared<'p, 'a>> {
three_d::adv::prepare_run(self, ctx, run, out)
}
/// A run of consecutive 3D layers of an Advanced 3D comp whose layers need the 2D
/// compositing path (blend mode, track matte, Preserve Transparency), split for an
/// accelerator's compositor ([`three_d::adv::SplitRun`]). `None` = [`Self::prepare_adv_run`]
/// covers it, or it isn't an Advanced 3D run.
pub fn split_adv_run<'p>(&'p self, ctx: &'p EvalCtx<'a>, run: &'p [&'p Layer], out: (u32, u32)) -> Option<three_d::adv::SplitRun<'p, 'a>> {
three_d::adv::split_run(self, ctx, run, out)
}
/// Whether the comp's 3D layers draw with the Advanced 3D renderer.
pub fn is_adv3d(&self, ctx: &EvalCtx) -> bool {
three_d::adv::active(self, ctx)
}
/// An Environment Light Background layer ready to draw on an accelerator
/// ([`three_d::SkyDraw`]; the CPU draws it in `draw_3d_run`). `None` = nothing to draw.
pub fn sky_draw(&self, ctx: &EvalCtx, layer: &Layer) -> Option<three_d::SkyDraw> {
three_d::compose::sky_draw(self, ctx, layer)
}
/// The pixels a Wireframe-quality layer sets (white, opaque) on a `size` canvas: its
/// bounds' outline, one pixel wide. Empty when the layer has no bounds.
pub fn wireframe_pixels(&self, ctx: &EvalCtx, layer: &Layer, size: (u32, u32)) -> Vec<(u32, u32)> {
let Some(b) = content_bounds(ctx, layer) else { return vec![] };
let s = self.opts.scale;
let (l2c, _) = ctx.layer_to_comp(layer);
let m = Mat3::scale(vec2(s, s)) * self.outer.map_or(l2c, |o| o * l2c);
let c = [(b[0], b[1]), (b[2], b[1]), (b[2], b[3]), (b[0], b[3])].map(|(x, y)| m.apply(vec2(x, y)));
let (w, h) = (size.0 as i64, size.1 as i64);
let mut out = vec![];
for i in 0..4 {
let (p, q) = (c[i], c[(i + 1) % 4]);
let n = ((q.x - p.x).abs().max((q.y - p.y).abs()) * 2.0).ceil().clamp(1.0, 1.0e5) as usize;
for j in 0..=n {
let t = j as f64 / n as f64;
let (x, y) = ((p.x + (q.x - p.x) * t).floor() as i64, (p.y + (q.y - p.y) * t).floor() as i64);
if x >= 0 && y >= 0 && x < w && y < h {
out.push((x as u32, y as u32));
}
}
}
out
}
/// Run the layer's effect stack on `target` (an accelerator's resident image): GPU-capable
/// runs through [`FxTarget::gpu`], the others through [`FxTarget::cpu`], with the CPU's
/// parameters and environment. `adjustment`: the stack runs on the comp below (adjustment
/// layers).
pub fn run_effects_on(&self, ctx: &EvalCtx, layer: &Layer, adjustment: bool, target: &mut dyn FxTarget) {
// `target` is used as it is: the comp below for adjustment layers (the callers).
self.run_effect_stack(ctx, layer, adjustment, usize::MAX, None, [0.0; 2], target);
}
/// [`Renderer::run_effects_on`] for a (non-adjustment) layer's own pixels: `target` holds
/// them in effect space (as [`Renderer::layer_input`] hands them out) and the result stays
/// there. Returns the effect-space origin in layer coordinates (rebase by its negation).
pub fn run_layer_effects_on(&self, ctx: &EvalCtx, layer: &Layer, target: &mut dyn FxTarget) -> [f64; 2] {
let origin = ctx.effect_bounds(layer).1;
self.run_effect_stack(ctx, layer, false, usize::MAX, None, origin, target);
origin
}
/// An adjustment layer's footprint (its source with masks applied, layer space): where
/// its effects show. `None` = the layer changes nothing.
pub fn adjustment_footprint(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Arc<Buf>> {
// Cached, so an accelerator sees the same buffer (and uploads it once) every frame.
let key = self.cache.and_then(|_| cache::footprint_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft, self.mb_on(ctx, layer)));
if let (Some(c), Some(k)) = (self.cache, key)
&& let Some(b) = c.get(k)
{
return Some(b);
}
let mut foot = self.source(ctx, layer)?;
let _ = masks::apply(ctx, layer, &mut foot, self.mask_blur(ctx, layer));
let foot = Arc::new(foot);
// The buffer the cache keeps (another worker's, if it kept one first).
let foot = match (self.cache_to_keep(), key) {
(Some(c), Some(k)) => c.insert(k, foot),
_ => foot,
};
Some(foot)
}
/// Draw one 2D layer into `canvas` on the CPU (any kind: adjustment, wireframe, styled…).
pub fn draw_layer_cpu(&self, ctx: &EvalCtx, layer: &Layer, canvas: &mut Image) {
self.draw_layer(ctx, layer, canvas, false);
}
/// Draw a collapsed precomp layer into `canvas` on the CPU.
pub fn draw_collapsed_cpu(&self, ctx: &EvalCtx<'a>, layer: &Layer, item: ItemId, canvas: &mut Image) {
self.draw_collapsed(ctx, layer, item, canvas, false);
}
/// The layer's finished buffers (styles included) in the blending space.
pub fn styled_layer(&self, ctx: &EvalCtx, layer: &Layer) -> Option<StyledLayer> {
let mut timing = self.profile.map(|_| LayerTiming { depth: self.depth, layer: layer.name.clone(), ..Default::default() });
let t0 = web_time::Instant::now();
let st = self.styled(ctx, layer, timing.as_mut()).map(|st| self.styled_to_blend(st));
if let (Some(prof), Some(mut timing)) = (self.profile, timing) {
timing.process_ms = t0.elapsed().as_secs_f64() * 1e3;
if let Ok(mut v) = prof.lock() {
v.push(timing);
}
}
st
}
/// Opacity the layer composites with (its Opacity × collapsed precomps' opacity).
pub fn layer_opacity(&self, ctx: &EvalCtx, layer: &Layer) -> f32 {
ctx.opacity(layer) as f32 * self.opacity_mul
}
/// The layer's track matte layer and kind, if it has a valid one.
pub fn track_matte<'c>(&self, ctx: &EvalCtx<'c>, layer: &Layer) -> Option<(&'c Layer, MatteKind)> {
layer.track_matte.and_then(|tm| ctx.comp.layer(tm.layer).filter(|m| m.id != layer.id).map(|m| (m, tm.kind)))
}
}
+83 -14
View File
@@ -22,7 +22,7 @@ use std::hash::{Hash, Hasher};
use std::sync::{Arc, Mutex};
use effectcraft_effects::Buf;
use effectcraft_project::{ItemKind, Layer, LayerSource, Node, PropGroup};
use effectcraft_project::{FootageKind, ItemKind, Layer, LayerSource, Node, PropGroup};
use crate::eval::EvalCtx;
@@ -168,15 +168,23 @@ impl LayerCache {
// Disk cache: a hit comes back into memory.
let (dc, dk) = self.disk_key(key)?;
let buf = dc.get_layer(dk)?;
self.insert_mem(key, buf.clone());
Some(buf)
Some(self.insert_mem(key, buf))
}
}
}
pub fn insert(&self, key: u64, buf: Arc<Buf>) {
/// Keep `buf` under `key`, and return the buffer the cache holds for it: the one kept first
/// when another render of the same layer got there before (#1812: preview workers starting
/// together each rendered every layer; sharing the first buffer, the GPU compositor uploads
/// and packs it once instead of once per worker, which ran it out of memory). Renders don't
/// wait for each other: on the frame workers' shared thread pool a wait can end up under
/// the very work it waits for.
pub fn insert(&self, key: u64, buf: Arc<Buf>) -> Arc<Buf> {
if self.gated() {
return;
return buf;
}
if let Some(kept) = self.inner.lock().ok().and_then(|g| g.map.get(&key).map(|e| e.buf.clone())) {
return kept;
}
let started = self.inner.lock().ok().and_then(|mut g| g.missed_at.remove(&key));
if let Some((dc, dk)) = self.disk_key(key) {
@@ -185,20 +193,24 @@ impl LayerCache {
dc.put_layer(dk, &buf);
}
}
self.insert_mem(key, buf);
self.insert_mem(key, buf)
}
fn insert_mem(&self, key: u64, buf: Arc<Buf>) {
/// See [`LayerCache::insert`]: the buffer kept under `key` (`buf`, unless one was kept first
/// or `buf` is too large to keep).
fn insert_mem(&self, key: u64, buf: Arc<Buf>) -> Arc<Buf> {
let bytes = buf.img.data.len() * std::mem::size_of::<effectcraft_raster::Px>();
let Ok(mut g) = self.inner.lock() else { return };
let Ok(mut g) = self.inner.lock() else { return buf };
if bytes > g.budget / 4 {
return;
return buf;
}
g.clock += 1;
let now = g.clock;
if let Some(old) = g.map.insert(key, Entry { buf, bytes, last_use: now }) {
g.bytes -= old.bytes;
if let Some(e) = g.map.get_mut(&key) {
e.last_use = now;
return e.buf.clone();
}
g.map.insert(key, Entry { buf: buf.clone(), bytes, last_use: now });
g.bytes += bytes;
if g.bytes > g.budget {
// Evict least-recently used entries down to 3/4 of the budget.
@@ -214,6 +226,7 @@ impl LayerCache {
}
}
}
buf
}
/// The memory budget in bytes.
@@ -481,7 +494,7 @@ fn hash_values(h: &mut KeyHasher, ctx: &EvalCtx, layer: &Layer, g: &PropGroup) {
}
/// Cache key for the processed (source → masks → effects) buffer of `layer` at the context
/// time, or `None` when the layer must not be cached (footage, precomps, cameras, adjustment
/// time, or `None` when the layer must not be cached (movie footage, precomps, cameras, adjustment
/// layers…).
pub fn layer_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool) -> Option<u64> {
// Time effects see property values at other times. Keyframes are part of the hashed
@@ -514,6 +527,34 @@ fn reads_other_times(layer: &Layer) -> bool {
})
}
/// A still image (no proxy): its layer is cached like a text or shape layer, so the GPU
/// compositor uploads it once, not every frame (#1812: four 21120 px plates re-uploaded by every
/// preview worker ran the GPU out of memory). Movies and sequences stay in the media pool only.
fn still_footage(ctx: &EvalCtx, item: effectcraft_project::ItemId) -> bool {
ctx.project.item(item).is_some_and(|it| it.proxy.is_none() && matches!(&it.kind, ItemKind::Footage(f) if f.kind == FootageKind::Still))
}
/// Does the layer have an animated mask that motion blur may draw at other times (Layer ▸ Mask ▸
/// Motion Blur On, or Same As Layer with the layer blurred)?
fn mask_motion_blur(ctx: &EvalCtx, layer: &Layer, blur: bool) -> bool {
use effectcraft_project::{GroupKind, MaskMotionBlur};
fn animated(g: &PropGroup) -> bool {
g.children.iter().any(|c| match c {
Node::Prop(p) => !p.keys.is_empty() || p.has_expression(),
Node::Group(sub) => animated(sub),
})
}
ctx.comp.enable_motion_blur
&& layer.masks().is_some_and(|m| {
m.groups().any(|g| match g.kind {
GroupKind::Mask { motion_blur, .. } => {
(motion_blur == MaskMotionBlur::On || (motion_blur == MaskMotionBlur::SameAsLayer && blur)) && animated(g)
}
_ => false,
})
})
}
fn has_expression(g: &PropGroup) -> bool {
g.children.iter().any(|c| match c {
Node::Prop(p) => p.has_expression(),
@@ -552,14 +593,17 @@ fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, fo
hash_debug(&mut h, s);
}
LayerSource::Text | LayerSource::Shape => {}
LayerSource::Footage { item } if footage => {
LayerSource::Footage { item } if footage || still_footage(ctx, *item) => {
let it = ctx.project.item(*item)?;
let ItemKind::Footage(f) = &it.kind else { return None };
hash_debug(&mut h, f);
// A proxy (and its Use Proxy switch) changes the pixels.
hash_debug(&mut h, &it.proxy);
item.hash(&mut h);
ctx.source_time(layer).0.hash(&mut h);
// A still is the same at every time (unless a proxy, which may be a movie, stands in).
if f.kind != FootageKind::Still || it.proxy.is_some() {
ctx.source_time(layer).0.hash(&mut h);
}
// Frame blending mixes neighbouring source frames.
ctx.comp.enable_frame_blending.hash(&mut h);
}
@@ -625,6 +669,11 @@ fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, fo
effectcraft_effects::paint::cache_key(g, lt).0.hash(&mut h);
}
}
// Mask motion blur draws animated masks at the shutter's other times too: their values at
// this time don't tell two frames apart (a hold before a move).
if mask_motion_blur(ctx, layer, blur) {
layer.layer_time(ctx.time).0.hash(&mut h);
}
if time_dependent(layer, effects != Some(0)) {
layer.layer_time(ctx.time).0.hash(&mut h);
// Effects also see the layer's time mapping.
@@ -668,3 +717,23 @@ pub fn derive(key: u64, i: u64) -> u64 {
i.hash(&mut h);
h.finish()
}
#[cfg(test)]
mod shared_insert_tests {
use std::sync::Arc;
use super::LayerCache;
use effectcraft_effects::Buf;
use effectcraft_raster::Image;
/// Two renders of one layer kept under one key: both use the buffer kept first.
#[test]
fn the_buffer_kept_first_is_shared() {
let cache = LayerCache::default();
let buf = || Arc::new(Buf { img: Image::new(4, 4), offset: [0.0; 2], scale: 1.0 });
let (a, b) = (buf(), buf());
assert!(Arc::ptr_eq(&cache.insert(3, a.clone()), &a));
assert!(Arc::ptr_eq(&cache.insert(3, b), &a), "the later render gets the first buffer");
assert!(Arc::ptr_eq(&cache.get(3).unwrap(), &a));
}
}
+53 -9
View File
@@ -331,7 +331,7 @@ impl DiskCache {
let Ok(files) = std::fs::read_dir(shard.path()) else { continue };
for f in files.flatten() {
let p = f.path();
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
let name = p.file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("");
if name.contains(".tmp") {
// A writer that died mid-write; old temporaries are removed.
if f.metadata().and_then(|m| m.modified()).is_ok_and(|t| t.elapsed().is_ok_and(|e| e.as_secs() > 600)) {
@@ -779,8 +779,12 @@ fn hash_debug(h: &mut Hash128, v: &impl std::fmt::Debug) {
h.write(&[0xff]);
}
/// A file's size and modification time (so edited footage gets new keys).
fn file_stamp(h: &mut Hash128, path: &str) {
/// Size and content hash of a path that has no native metadata (the browser file table).
pub type VirtualFileId<'a> = &'a (dyn Fn(&str) -> Option<(u64, u64)> + Send + Sync);
/// A file's size and modification time (so edited footage gets new keys). When the path has no
/// native metadata, `virtual_id` supplies a durable size and content hash instead.
fn file_stamp(h: &mut Hash128, path: &str, virtual_id: Option<VirtualFileId<'_>>) {
if path.is_empty() {
return;
}
@@ -791,26 +795,36 @@ fn file_stamp(h: &mut Hash128, path: &str) {
{
h.write_u64(d.as_nanos() as u64);
}
return;
}
if let Some((len, hash)) = virtual_id.and_then(|id| id(path)) {
h.write_u64(len);
h.write_u64(hash);
}
}
fn footage_stamp(h: &mut Hash128, f: &effectcraft_project::Footage) {
file_stamp(h, &f.path);
fn footage_stamp(h: &mut Hash128, f: &effectcraft_project::Footage, virtual_id: Option<VirtualFileId<'_>>) {
file_stamp(h, &f.path, virtual_id);
for p in &f.sequence {
file_stamp(h, p);
file_stamp(h, p, virtual_id);
}
}
/// Salt for layer cache keys on disk: the renderer version and every footage file's identity
/// (layer keys name footage items, not file contents).
pub fn footage_salt(project: &effectcraft_project::Project) -> u64 {
footage_salt_with(project, None)
}
/// [`footage_salt`] with [`VirtualFileId`] for paths `std::fs::metadata` cannot describe.
pub fn footage_salt_with(project: &effectcraft_project::Project, virtual_id: Option<VirtualFileId<'_>>) -> u64 {
let mut h = Hash128::default();
h.write(RENDER_VERSION.as_bytes());
for (id, it) in &project.items {
if let effectcraft_project::ItemKind::Footage(f) = &it.kind {
h.write_u64(id.0);
hash_debug(&mut h, f);
footage_stamp(&mut h, f);
footage_stamp(&mut h, f, virtual_id);
}
}
let v = h.finish();
@@ -822,6 +836,11 @@ pub fn footage_salt(project: &effectcraft_project::Project) -> u64 {
/// files' size and time). With expressions in play (which can read any comp) the whole project
/// counts. Switches that don't change pixels (Audio, Lock, Shy, Hide Shy Layers) don't count.
pub fn comp_content_key(project: &effectcraft_project::Project, comp: effectcraft_project::ItemId) -> u128 {
comp_content_key_with(project, comp, None)
}
/// [`comp_content_key`] with [`VirtualFileId`] for paths `std::fs::metadata` cannot describe.
pub fn comp_content_key_with(project: &effectcraft_project::Project, comp: effectcraft_project::ItemId, virtual_id: Option<VirtualFileId<'_>>) -> u128 {
use effectcraft_project::{ItemKind, LayerSource};
let mut h = Hash128::default();
h.write(RENDER_VERSION.as_bytes());
@@ -855,11 +874,11 @@ pub fn comp_content_key(project: &effectcraft_project::Project, comp: effectcraf
k => hash_debug(&mut h, k),
}
if let ItemKind::Footage(f) = &it.kind {
footage_stamp(&mut h, f);
footage_stamp(&mut h, f, virtual_id);
}
hash_debug(&mut h, &it.proxy);
if let Some(px) = &it.proxy {
footage_stamp(&mut h, &px.footage);
footage_stamp(&mut h, &px.footage, virtual_id);
}
}
h.write_u64(comp.0);
@@ -1082,4 +1101,29 @@ mod tests {
assert_eq!(b.get_layer(k).unwrap().img, buf(k as u32, 16, 16).img);
}
}
/// A path with no native metadata takes its key from the caller's size and content hash (#1253).
#[test]
fn virtual_file_bytes_change_content_keys() {
use effectcraft_project::build;
use effectcraft_project::{Comp, Footage, ItemKind, LayerSource, Project};
use effectcraft_time::{FrameRate, Tick};
let mut p = Project::default();
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)];
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));
let (one, two) = (id(1), id(2));
let a = footage_salt_with(&p, Some(&one));
let b = footage_salt_with(&p, Some(&two));
let ka = comp_content_key_with(&p, cid, Some(&one));
let kb = comp_content_key_with(&p, cid, Some(&two));
assert_ne!(a, b, "replaced virtual media must not reuse the salt");
assert_ne!(ka, kb, "replaced virtual media must not reuse the frame key");
assert_eq!(comp_content_key(&p, cid), comp_content_key_with(&p, cid, None));
}
}
+178
View File
@@ -0,0 +1,178 @@
use super::*;
/// How a [`FootageSource`] hands over float footage (OpenEXR, float TIFF), whose values are
/// linear light: encoded with a curve, as the compositor takes footage, or as stored. Other
/// footage is decoded the same way whatever it says. Interpret Footage ▸ Preserve RGB always
/// reads the values as stored.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum FloatCurve {
/// The sRGB curve (managed projects, and Working Gamma 2.2).
#[default]
Srgb,
/// A pure 2.4 power curve (no working space, Working Gamma 2.4).
Gamma24,
/// The linear values as stored (Interpret As Linear Light encodes them afterwards).
Linear,
}
/// Supplies decoded footage frames (implemented by the media layer).
pub trait FootageSource: Send + Sync {
/// The frame of `item` at source time `t`, straight from the file (any size).
fn frame(&self, item: ItemId, footage: &Footage, t: Tick) -> Option<Arc<Image>>;
/// [`FootageSource::frame`] for a render that shows it at `width` × `height`, at most half its
/// size: either that frame or, when the source can make it more cheaply than the whole frame
/// (movies, see `effectcraft-media`), the frame already resampled to that size as
/// [`effectcraft_raster::resample`] would.
fn frame_at_size(&self, item: ItemId, footage: &Footage, t: Tick, _width: u32, _height: u32) -> Option<Arc<Image>> {
self.frame(item, footage, t)
}
/// [`FootageSource::frame`] (`size: None`) or [`FootageSource::frame_at_size`] with float
/// footage handed over as `float` says ([`FootageSource::frame`] uses [`FloatCurve::Srgb`]).
/// Sources without float footage can leave the default, which ignores `float`.
fn frame_with(&self, item: ItemId, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve) -> Option<Arc<Image>> {
let _ = float;
match size {
Some((w, h)) => self.frame_at_size(item, footage, t, w, h),
None => self.frame(item, footage, t),
}
}
/// `frames` interleaved stereo sample frames of `item`'s audio from source time `t` at
/// `rate` Hz (`None` when unavailable). The first sample is `t.to_units_floor(rate)`;
/// a sample boundary uses the earliest representable tick that selects that sample.
fn audio(&self, _item: ItemId, _footage: &Footage, _t: Tick, _frames: usize, _rate: u32) -> Option<Vec<f32>> {
None
}
/// Auxiliary 3D channels of `item` at source time `t` (multi-layer OpenEXR: depth, IDs,
/// Cryptomatte, any named channel), with `scale` relative to the file's pixels. `None` when
/// the footage has none.
fn aux(&self, _item: ItemId, _footage: &Footage, _t: Tick) -> Option<Arc<effectcraft_raster::AuxChannels>> {
None
}
/// The parsed 3D model of a [`effectcraft_project::FootageKind::Model`] item (Advanced 3D).
fn model(&self, _item: ItemId, _footage: &Footage) -> Option<Arc<effectcraft_model::Model>> {
None
}
/// Set the decoded-frame cache budget in bytes (Settings ▸ Memory & CPU); sources without
/// a cache ignore it.
fn set_cache_budget(&self, _bytes: usize) {}
/// Drop the decoded frames it keeps (Edit ▸ Purge ▸ All Memory / Image Cache Memory): the
/// next reads decode again. Sources without a cache ignore it.
fn purge(&self) {}
/// The file at `path` changed on disk (Reload Footage): drop what it keeps of it (decoded
/// frames, open decoders, parsed models), so the next reads see the new contents. Sources
/// without a cache ignore it.
fn forget(&self, _path: &str) {}
/// Settings ▸ Disk ▸ Conformed Audio Folder: where decoded audio is kept between reads
/// (`None` = off); sources that don't decode audio ignore it.
fn set_conform_folder(&self, _folder: Option<std::path::PathBuf>) {}
/// The decoded-frame cache budget in bytes, if the source has a cache.
fn cache_budget(&self) -> Option<usize> {
None
}
/// Vector footage (SVG) rasterised at `scale` × its pixel size, for Continuously Rasterize.
/// `None` when the footage is not vector or cannot be read.
fn vector_frame(&self, _item: ItemId, _footage: &Footage, _scale: f64) -> Option<Arc<Image>> {
None
}
/// Run `f`, which reads frames from this source, as work that can be called off: once
/// `cancel` is set, long decodes it starts (a movie seeking back to a keyframe and decoding
/// forward) stop early and their frames come back `None`. Sources that can't stop a decode
/// just run `f`. See [`CancellableFootage`].
fn cancellable(&self, _cancel: &Arc<AtomicBool>, f: &mut dyn FnMut()) {
f()
}
/// Reads through this source have been called off: frames may have come back `None` that
/// exist, so what was made from them must not be kept.
fn called_off(&self) -> bool {
false
}
/// Why `footage` shows no picture although its file is there: a read of it failed because
/// no decoder handles its codec, profile, chroma format or bit depth, or the stream is damaged
/// (e.g. "H.264 (High 4:2:2) isn't supported by the built-in decoder: ..."). `None` while it
/// decodes, and for sources that don't track failures. Stays set until the file is forgotten.
fn problem(&self, _footage: &Footage) -> Option<String> {
None
}
}
/// A render's view of a [`FootageSource`] whose frame reads can be called off with `cancel`
/// (a viewer frame nobody waits for any more, #329). Every read goes through
/// [`FootageSource::cancellable`] on its own, so reads made for this render on another thread
/// (a parallel loop) answer to this render's flag, and reads for other renders never do.
pub struct CancellableFootage<'a> {
pub inner: &'a dyn FootageSource,
pub cancel: Arc<AtomicBool>,
}
impl CancellableFootage<'_> {
fn read<R>(&self, f: impl FnOnce() -> Option<R>) -> Option<R> {
if self.cancel.load(Ordering::Relaxed) {
return None;
}
let (mut f, mut out) = (Some(f), None);
self.inner.cancellable(&self.cancel, &mut || out = f.take().and_then(|f| f()));
out
}
}
impl FootageSource for CancellableFootage<'_> {
fn frame(&self, item: ItemId, footage: &Footage, t: Tick) -> Option<Arc<Image>> {
self.read(|| self.inner.frame(item, footage, t))
}
fn frame_at_size(&self, item: ItemId, footage: &Footage, t: Tick, width: u32, height: u32) -> Option<Arc<Image>> {
self.read(|| self.inner.frame_at_size(item, footage, t, width, height))
}
fn audio(&self, item: ItemId, footage: &Footage, t: Tick, frames: usize, rate: u32) -> Option<Vec<f32>> {
self.inner.audio(item, footage, t, frames, rate)
}
fn aux(&self, item: ItemId, footage: &Footage, t: Tick) -> Option<Arc<effectcraft_raster::AuxChannels>> {
self.inner.aux(item, footage, t)
}
fn model(&self, item: ItemId, footage: &Footage) -> Option<Arc<effectcraft_model::Model>> {
self.inner.model(item, footage)
}
fn set_cache_budget(&self, bytes: usize) {
self.inner.set_cache_budget(bytes)
}
fn purge(&self) {
self.inner.purge()
}
fn forget(&self, path: &str) {
self.inner.forget(path)
}
fn set_conform_folder(&self, folder: Option<std::path::PathBuf>) {
self.inner.set_conform_folder(folder)
}
fn cache_budget(&self) -> Option<usize> {
self.inner.cache_budget()
}
fn vector_frame(&self, item: ItemId, footage: &Footage, scale: f64) -> Option<Arc<Image>> {
self.inner.vector_frame(item, footage, scale)
}
fn cancellable(&self, cancel: &Arc<AtomicBool>, f: &mut dyn FnMut()) {
self.inner.cancellable(cancel, f)
}
fn called_off(&self) -> bool {
self.cancel.load(Ordering::Relaxed) || self.inner.called_off()
}
fn problem(&self, footage: &Footage) -> Option<String> {
self.inner.problem(footage)
}
fn frame_with(&self, item: ItemId, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve) -> Option<Arc<Image>> {
self.read(|| self.inner.frame_with(item, footage, t, size, float))
}
}
/// Footage that can be rasterised at any scale (SVG).
pub fn is_vector_footage(f: &Footage) -> bool {
matches!(f.codec.as_str(), "SVG" | "PDF" | "AI" | "EPS")
}
/// No footage available (renders footage layers as transparent).
pub struct NoFootage;
impl FootageSource for NoFootage {
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<Image>> {
None
}
}
+144
View File
@@ -0,0 +1,144 @@
use super::footage::NoFootage;
use super::opts::RenderOpts;
use super::*;
/// 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 /
/// upper, 1 = odd / lower) kept, the other lines interpolated from their neighbours.
pub fn field_frame(img: &Image, parity: usize) -> Image {
let (w, h) = (img.width as usize, img.height as usize);
let mut out = img.clone();
if h < 2 {
return out;
}
for y in 0..h {
if y % 2 == parity {
continue;
}
let above = y.checked_sub(1);
let below = (y + 1 < h).then_some(y + 1);
for x in 0..w {
let px = match (above, below) {
(Some(a), Some(b)) => {
let (p, q) = (img.data[a * w + x], img.data[b * w + x]);
[(p[0] + q[0]) * 0.5, (p[1] + q[1]) * 0.5, (p[2] + q[2]) * 0.5, (p[3] + q[3]) * 0.5]
}
(Some(a), None) => img.data[a * w + x],
(None, Some(b)) => img.data[b * w + x],
(None, None) => img.data[y * w + x],
};
out.data[y * w + x] = px;
}
}
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 {
let linear = f.linear_light_applies();
if !f.invert_alpha && !linear {
return img.clone();
}
let mut out = img.clone();
for p in out.data.iter_mut() {
let a = p[3];
let mut c = if a > 1e-6 { [p[0] / a, p[1] / a, p[2] / a] } else { [0.0; 3] };
if linear {
// The file's values are linear light: encode them like the rest of the footage.
c = c.map(|v| space.encode(v));
}
let a = if f.invert_alpha { 1.0 - a } else { a };
*p = [c[0] * a, c[1] * a, c[2] * a, a];
}
out
}
pub(crate) fn frame_blend_mode(ctx: &EvalCtx, layer: &Layer) -> FrameBlend {
if ctx.comp.enable_frame_blending { layer.switches.frame_blend } else { FrameBlend::Off }
}
/// Position of source time `t` among frames at `rate`: the frame at or before `t` and the
/// fraction (0..1) of the way to the next frame. Exact (integer tick arithmetic), so frames that
/// line up exactly report a fraction of 0.
pub fn frame_position(t: Tick, rate: FrameRate) -> (i64, f64) {
let num = t.0 as i128 * rate.num as i128;
let den = TICKS_PER_SECOND as i128 * rate.den as i128;
(num.div_euclid(den) as i64, num.rem_euclid(den) as f64 / den as f64)
}
/// Blend two neighbouring frames `w` of the way from `a` to `b`: a cross-fade (Frame Mix) or a
/// motion-compensated interpolation (Pixel Motion).
pub fn blend_frames(mode: FrameBlend, a: &Image, b: &Image, w: f32) -> Image {
match mode {
FrameBlend::PixelMotion => effectcraft_raster::flow::interpolate(a, b, w),
_ => effectcraft_raster::flow::mix(a, b, w),
}
}
/// Convenience: render one frame of a comp with no footage source.
/// Adaptive motion-blur sample count for a layer whose corners travel `travel` output pixels
/// during the shutter: one sample per pixel, clamped to Samples Per Frame … Adaptive Sample Limit.
pub fn adaptive_samples(travel: f64, per_frame: u32, limit: u32) -> usize {
let lo = per_frame.clamp(2, 64) as f64;
let hi = (limit.clamp(16, 256) as f64).max(lo);
travel.ceil().clamp(lo, hi) as usize
}
/// A region of interest `[x, y, w, h]` lies within a `w`×`h` comp frame.
pub fn roi_inside(r: [f64; 4], w: u32, h: u32) -> bool {
r[0] >= 0.0 && r[1] >= 0.0 && r[0] + r[2] <= w as f64 + 1e-6 && r[1] + r[3] <= h as f64 + 1e-6
}
/// The Extended Viewer's region of interest: the comp frame grown by `margin` × its size on every
/// side (`margin` = 1 shows a pasteboard one comp wide around the frame), intersected with the
/// `visible` comp-space rectangle `[x0, y0, x1, y1]` and snapped outward to whole 16 px steps from the frame edges
/// so panning re-renders rarely. `None` when the visible area lies inside the frame.
pub fn extended_region(w: u32, h: u32, visible: [f64; 4], margin: f64) -> Option<[f64; 4]> {
let (w, h) = (w as f64, h as f64);
let (mx, my) = (w * margin, h * margin);
let snap_lo = |v: f64| (v / 16.0).floor() * 16.0;
let snap_hi = |v: f64| (v / 16.0).ceil() * 16.0;
let x0 = snap_lo(visible[0].max(-mx)).min(0.0);
let y0 = snap_lo(visible[1].max(-my)).min(0.0);
let x1 = w + snap_hi(visible[2].min(w + mx) - w).max(0.0);
let y1 = h + snap_hi(visible[3].min(h + my) - h).max(0.0);
if x0 >= 0.0 && y0 >= 0.0 && x1 <= w && y1 <= h {
return None;
}
Some([x0, y0, x1 - x0, y1 - y0])
}
/// The size of a `width` x `height` comp frame rendered at `scale` (both compositors, and the
/// frame size Composition Settings shows for a Resolution).
pub fn frame_size(width: u32, height: u32, scale: f64) -> (u32, u32) {
(((width as f64 * scale).round() as u32).max(1), ((height as f64 * scale).round() as u32).max(1))
}
pub fn render_frame(project: &Project, comp: ItemId, t: Tick, scale: f64) -> Image {
Renderer::new(project, &NoFootage, RenderOpts { scale, ..Default::default() }).comp_frame(comp, t)
}
/// Layer-space bounds `[x0, y0, x1, y1]` of a layer's content at the context time (source size
/// for solids/footage/precomps, glyph bounds for text, painted bounds for shapes). Used for viewer
/// handles and hit testing.
pub fn content_bounds(ctx: &EvalCtx, layer: &effectcraft_project::Layer) -> Option<[f64; 4]> {
match &layer.source {
LayerSource::Text => {
let glyphs = text::glyph_paths(ctx, layer);
let paths: Vec<_> = glyphs.into_iter().map(|g| g.0).collect();
effectcraft_path::bounds(&paths).map(|r| [r.x0, r.y0, r.x1, r.y1])
}
LayerSource::Shape => layer.props.sub("contents").and_then(|c| shapes::content_bounds(ctx, layer, c)).map(|r| [r.x0, r.y0, r.x1, r.y1]),
LayerSource::Camera | LayerSource::Light { .. } => None,
_ => {
let (w, h) = source_size(ctx.project, layer);
(w > 0 && h > 0).then_some([0.0, 0.0, w as f64, h as f64])
}
}
}
/// A mask/shape path as a kurbo path (for drawing overlays).
pub fn kurbo_path(sp: &effectcraft_keyframe::ShapePath) -> kurbo::BezPath {
effectcraft_path::to_kurbo(sp)
}
+157
View File
@@ -0,0 +1,157 @@
use super::*;
/// Layer parameters and audio for effects (see [`effectcraft_effects::EffectHost`]). What it
/// hands out is in effect space (see [`effect_bounds`]): the other layer's for its pixels, the
/// running layer's for its own frames and the comp scene.
pub(crate) struct FxHost<'r, 'a, 'c> {
pub(crate) r: &'r Renderer<'a>,
pub(crate) ctx: &'c EvalCtx<'a>,
pub(crate) layer: &'c Layer,
/// Where the stack's effect space starts in layer coordinates (zero on adjustment layers,
/// whose effects run on the comp below).
pub(crate) origin: [f64; 2],
/// Index of the effect being rendered (bounds `self_at` to effects before it).
pub(crate) index: usize,
pub(crate) original: Option<&'c Buf>,
}
impl FxHost<'_, '_, '_> {
/// Another layer's pixels for a layer parameter, in its effect space; `layer_space`: `buf`
/// is still in layer space (its source or its content).
fn pixels(&self, other: &Layer, mut buf: Buf, layer_space: bool) -> LayerPixels {
let (size, origin) = self.ctx.effect_bounds(other);
if layer_space {
buf.rebase(origin);
}
LayerPixels { buf, size }
}
}
/// The comp's camera and first light relative to `layer`, in its layer pixels (see
/// [`EffectHost::comp_scene`]): the comp camera's view of the layer (as if 3D at its transform),
/// brought back into the layer's own pixel grid through the inverse of how the layer itself
/// composites.
pub(crate) fn comp_scene(ctx: &EvalCtx, layer: &Layer) -> Option<effectcraft_effects::CompScene> {
let world = ctx.world_matrix(layer);
let (cam, _, _) = ctx.camera();
let p = (cam * world).0;
let back = ctx.layer_to_comp(layer).0.inverse()?.0;
let rows = [p[0], p[1], p[3]];
let camera: [[f64; 4]; 3] = std::array::from_fn(|i| std::array::from_fn(|j| (0..3).map(|k| back[i][k] * rows[k][j]).sum()));
let inv = world.inverse();
let light = three_d::light::lights_at(ctx).first().and_then(|l| {
let inv = inv.as_ref()?;
let pos = inv.apply(l.pos);
let dir = inv.apply_vec(l.dir);
let len = (dir.x * dir.x + dir.y * dir.y + dir.z * dir.z).sqrt().max(1e-12);
let kind = match l.kind {
effectcraft_project::LightKind::Parallel => 0,
effectcraft_project::LightKind::Ambient => 2,
_ => 1,
};
Some(effectcraft_effects::CompLight { pos: [pos.x, pos.y, pos.z], dir: [dir.x / len, dir.y / len, dir.z / len], color: l.color, kind })
});
let camera_layer = ctx.comp.active_camera(ctx.time).is_some();
Some(effectcraft_effects::CompScene { camera: Some(camera), camera_layer, light })
}
/// Nesting limit for effects that render layers (other layers, other times).
pub(crate) const MAX_FX_DEPTH: usize = 8;
impl EffectHost for FxHost<'_, '_, '_> {
fn original(&self) -> Option<&Buf> {
self.original
}
fn particles(&self) -> Option<&dyn effectcraft_effects::psim::ParticleSim> {
self.r.active_accel().and_then(|a| a.particles())
}
fn layer(&self, id: u64, masks_and_effects: bool) -> Option<LayerPixels> {
let other = self.ctx.layer(effectcraft_project::LayerId(id))?;
if (other.id == self.layer.id && masks_and_effects) || self.r.depth > MAX_FX_DEPTH {
return None;
}
let sub = self.r.nested();
// The cached layer buffer is shared; effects get their own copy.
let buf = if masks_and_effects { (*sub.content_buf(self.ctx, other)?).clone() } else { sub.source(self.ctx, other)? };
Some(self.pixels(other, buf, true))
}
fn layer_masks(&self, id: u64) -> Option<LayerPixels> {
let other = self.ctx.layer(effectcraft_project::LayerId(id))?;
if self.r.depth > MAX_FX_DEPTH {
return None;
}
let sub = self.r.nested();
let buf = (*sub.layer_input(self.ctx, other, 0)?).clone();
Some(self.pixels(other, buf, false))
}
fn audio(&self, id: u64, start: f64, frames: usize, rate: u32) -> Option<Vec<f32>> {
let other = self.ctx.layer(effectcraft_project::LayerId(id))?;
let LayerSource::Footage { item } = &other.source else { return None };
let ItemKind::Footage(f) = &self.r.project.item(*item)?.kind else { return None };
if !f.has_audio {
return None;
}
let st = other.layer_time(Tick::from_seconds_f64(start));
self.r.footage.audio(*item, f, st, frames, rate)
}
fn self_at(&self, layer_time: f64, effects: usize) -> Option<Buf> {
if self.r.depth > MAX_FX_DEPTH || self.layer.switches.adjustment {
return None;
}
let n = effects.min(self.index);
let t = self.layer.comp_time(Tick::from_seconds_f64(layer_time));
let sub = self.r.nested();
sub.layer_input(&self.ctx.at(t), self.layer, n).map(|b| (*b).clone())
}
fn aux(&self) -> Option<Arc<effectcraft_raster::AuxChannels>> {
self.r.layer_aux(self.ctx, self.layer)
}
fn params_at(&self, layer_time: f64) -> Option<Params> {
let g = self.layer.effects()?.groups().nth(self.index)?;
let ctx = self.ctx.at(self.layer.comp_time(Tick::from_seconds_f64(layer_time)));
Some(effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(self.layer, pr)))
}
fn comp_scene(&self) -> Option<effectcraft_effects::CompScene> {
Some(comp_scene(self.ctx, self.layer)?.rebased(self.origin))
}
fn layer_at(&self, id: u64, comp_time: f64, masks_and_effects: bool) -> Option<LayerPixels> {
let other = self.ctx.layer(effectcraft_project::LayerId(id))?;
if (other.id == self.layer.id && masks_and_effects) || !comp_time.is_finite() || self.r.depth > MAX_FX_DEPTH {
return None;
}
let ctx = self.ctx.at(Tick::from_seconds_f64(comp_time));
let sub = self.r.nested();
Some(if masks_and_effects {
self.pixels(other, (*sub.content_buf(&ctx, other)?).clone(), true)
} else if other.id == self.layer.id {
self.pixels(other, sub.source(&ctx, other)?, true)
} else {
// Preserve other-layer reads: time effects historically see source + masks.
self.pixels(other, (*sub.layer_input(&ctx, other, 0)?).clone(), false)
})
}
}
/// The Essential Properties overrides of a precomp layer, evaluated at the context time.
pub fn essential_overrides(ctx: &EvalCtx, layer: &Layer) -> Vec<effectcraft_project::essential::Override> {
use effectcraft_project::essential;
let over = essential::overridden(layer);
let Some(g) = essential::group(layer).filter(|_| !over.is_empty()) else { return vec![] };
let mut out = vec![];
g.walk("", &mut |_, p| {
if over.contains(&p.uid)
&& let Some(control) = essential::control_of(&p.match_id)
{
out.push(essential::Override { control, value: ctx.value(layer, p) });
}
});
out
}
+558
View File
@@ -0,0 +1,558 @@
use super::frames::adaptive_samples;
use super::opts::{LayerTiming, Through};
use super::*;
/// A motion-blur shutter: the comp's, or for a collapsed precomp's layers the outermost
/// containing comp's (they are drawn into its frames).
#[derive(Clone, Copy)]
struct Shutter {
/// Shutter angle and phase as fractions of a frame.
angle: f64,
phase: f64,
/// Frame duration (seconds).
frame: f64,
/// Samples Per Frame and Adaptive Sample Limit.
samples: u32,
limit: u32,
}
impl Shutter {
/// Seconds from the frame time to sub-sample `i` of `n`, spread over the open shutter.
fn offset(&self, i: usize, n: usize) -> f64 {
let f = if n > 1 { i as f64 / (n - 1) as f64 } else { 0.0 };
(self.phase + self.angle * f) * self.frame
}
}
impl<'a> Renderer<'a> {
/// The layer with its styles: exterior passes and body (cached per pass).
pub(crate) fn styled(&self, ctx: &EvalCtx, layer: &Layer, mut timing: Option<&mut LayerTiming>) -> Option<StyledLayer> {
let (content, ckey) = self.content_buf_timed(ctx, layer, timing.as_deref_mut())?;
if !styles::active(ctx, layer) {
return Some(StyledLayer { passes: vec![], body: content.clone(), content, key: ckey, ckey, plain: true });
}
let modes = styles::pass_modes(ctx, layer);
let key = ckey.map(|k| cache::styles_key(ctx, layer, k));
if let (Some(c), Some(k)) = (self.cache, key)
&& let Some(body) = c.get(k)
{
let passes: Option<Vec<_>> = modes.iter().enumerate().map(|(i, m)| c.get(cache::derive(k, i as u64 + 1)).map(|b| (b, *m))).collect();
if let Some(passes) = passes {
return Some(StyledLayer { passes, body, content, key, ckey, plain: false });
}
}
let t0 = web_time::Instant::now();
let size = source_size(self.project, layer);
let layer_size = if size.0 == 0 { [ctx.comp.width as f64, ctx.comp.height as f64] } else { [size.0 as f64, size.1 as f64] };
let st = styles::render(ctx, layer, &content, layer_size);
if let Some(t) = timing {
t.cached = false;
t.effects.push(("layerStyles".into(), t0.elapsed().as_secs_f64() * 1e3));
}
let mut body = Arc::new(st.body);
let mut passes: Vec<_> = st.passes.into_iter().map(|(b, m)| (Arc::new(b), m)).collect();
// The buffers the cache keeps (another worker's, if it kept them first).
if let (Some(c), Some(k)) = (self.cache_to_keep(), key) {
body = c.insert(k, body);
for (i, (b, _)) in passes.iter_mut().enumerate() {
*b = c.insert(cache::derive(k, i as u64 + 1), b.clone());
}
}
Some(StyledLayer { passes, body, content, key, ckey, plain: false })
}
/// The styled layer's buffers in the blending space.
pub(crate) fn styled_to_blend(&self, st: StyledLayer) -> StyledLayer {
if self.pipe.to_blend().is_none() {
return st;
}
let body = self.to_blend(st.body, st.key);
let content = if st.plain { body.clone() } else { self.to_blend(st.content, st.ckey) };
let passes = st.passes.into_iter().enumerate().map(|(i, (b, m))| (self.to_blend(b, st.key.map(|k| cache::derive(k, i as u64 + 1))), m)).collect();
StyledLayer { passes, body, content, ..st }
}
pub(crate) fn sampling(&self, layer: &Layer) -> effectcraft_raster::Sampling {
if self.quality(layer) == Quality::Draft {
// Draft quality: no interpolation (nearest neighbour), as After Effects' Draft.
effectcraft_raster::Sampling::Nearest
} else if self.opts.draft {
effectcraft_raster::Sampling::Bilinear
} else if layer.switches.sampling == Sampling::Bicubic {
effectcraft_raster::Sampling::Bicubic
} else {
effectcraft_raster::Sampling::Bilinear
}
}
/// The region of interest of a top-level 2D frame: (x, y) offset of the output in output
/// pixels and its size. `None` renders the whole comp (no ROI, or a nested comp).
pub(crate) fn roi_offset(&self) -> Option<(f64, f64, u32, u32)> {
let r = self.opts.roi.filter(|_| self.top)?;
let s = self.opts.scale;
if r[2] <= 0.0 || r[3] <= 0.0 {
return None;
}
Some(((r[0] * s).round(), (r[1] * s).round(), ((r[2] * s).round() as u32).max(1), ((r[3] * s).round() as u32).max(1)))
}
/// The region of interest's offset in output pixels for 3D projection (0 without one).
pub(crate) fn out_offset(&self) -> (f64, f64) {
self.roi_offset().map_or((0.0, 0.0), |(x, y, _, _)| (x, y))
}
/// Scaled comp pixel → output pixel: the region of interest's offset.
fn out_matrix(&self) -> Mat3 {
match self.roi_offset() {
Some((x, y, _, _)) => Mat3::translate(vec2(-x, -y)),
None => Mat3::IDENTITY,
}
}
/// Buffer pixel → output pixel matrix for the layer at the context time.
fn buf_matrix(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf) -> Mat3 {
let (l2c, _) = ctx.layer_to_comp(layer);
self.place_matrix(self.outer.map_or(l2c, |o| o * l2c), buf)
}
/// Buffer pixel → output pixel matrix for a layer → (top-level) comp matrix.
fn place_matrix(&self, l2c: Mat3, buf: &Buf) -> Mat3 {
let s = self.opts.scale;
self.out_matrix()
* Mat3::scale(vec2(s, s))
* l2c
* Mat3::scale(vec2(1.0 / buf.scale, 1.0 / buf.scale))
* Mat3::translate(vec2(-buf.offset[0], -buf.offset[1]))
}
/// The collapsed precomp layers this comp is drawn through, outermost first.
fn links(&self) -> impl Iterator<Item = &Through<'a>> {
self.through.iter().map_while(Option::as_ref)
}
/// The shutter of a layer's motion blur: its comp's, or for a collapsed precomp's layers the
/// outermost containing comp's.
fn shutter(&self, ctx: &EvalCtx) -> Shutter {
let c: &Comp = match self.links().next() {
Some(l) => l.comp,
None => ctx.comp,
};
Shutter {
angle: c.shutter_angle / 360.0,
phase: c.shutter_phase / 360.0,
frame: c.frame_duration().seconds(),
samples: c.motion_blur_samples,
limit: c.motion_blur_adaptive_limit,
}
}
/// Whether a layer moves within the shutter: its own motion blur, or that of a collapsed
/// precomp layer it is drawn through.
fn blurred(&self, ctx: &EvalCtx, layer: &Layer) -> bool {
self.mb_on(ctx, layer) || (!self.through_lost && self.links().any(|l| l.blur))
}
/// The collapsed precomps' matrix (nested comp → the frame's comp) and the nested comp's time
/// `dt` seconds into the outermost comp's shutter: each precomp layer moves if it is motion
/// blurred, and times map down through each one (stretch, time remapping).
fn outer_at(&self, ctx: &EvalCtx, dt: f64) -> (Option<Mat3>, Tick) {
let shift = Tick::from_seconds_f64(dt);
let mut outer: Option<Mat3> = None;
let mut t: Option<Tick> = None;
for l in self.links() {
let at = EvalCtx { comp_id: l.comp_id, comp: l.comp, time: t.unwrap_or(l.time + shift), ..*ctx };
let (m, _) = if l.blur { at.layer_to_comp(l.layer) } else { EvalCtx { time: l.time, ..at }.layer_to_comp(l.layer) };
outer = Some(outer.map_or(m, |o| o * m));
t = Some(at.nested_time(l.layer));
}
(outer, t.unwrap_or(ctx.time + shift))
}
/// [`Self::buf_matrix`] for the motion-blur sub-sample `dt` seconds into the shutter: the
/// layer moves if it is motion blurred, the collapsed precomps above it if theirs are.
fn buf_matrix_at(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf, dt: f64) -> Mat3 {
if self.through_lost || self.links().next().is_none() {
return self.buf_matrix(&ctx.at(ctx.time + Tick::from_seconds_f64(dt)), layer, buf);
}
let (outer, t) = self.outer_at(ctx, dt);
let lctx = if self.mb_on(ctx, layer) { ctx.at(t) } else { *ctx };
let (l2c, _) = lctx.layer_to_comp(layer);
self.place_matrix(outer.map_or(l2c, |o| o * l2c), buf)
}
/// Shape and text content that animates within the shutter (paths, Trim Paths, text
/// animators…) of a motion-blurred layer: the times its source is drawn at and averaged,
/// Samples Per Frame of them, as After Effects does for shape layers. `None` when the content
/// holds still (compared through its cache key, which leaves the transform out).
pub(crate) fn content_times(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Vec<Tick>> {
if !matches!(layer.source, LayerSource::Shape | LayerSource::Text) || !self.mb_on(ctx, layer) {
return None;
}
let sh = self.shutter(ctx);
if sh.angle <= 0.0 {
return None;
}
let n = if self.opts.draft { 4 } else { (sh.samples as usize).clamp(2, 64) };
let times: Vec<Tick> = (0..n).map(|i| self.outer_at(ctx, sh.offset(i, n)).1).collect();
let key = |t: Tick| cache::layer_key(&ctx.at(t), layer, 1.0, false, false);
let k0 = key(*times.first()?)?;
let moves = [times.get(n / 2), times.last()].into_iter().flatten().any(|t| key(*t) != Some(k0));
moves.then_some(times)
}
/// The layer's source drawn at each of `times` and averaged (premultiplied) on the union of
/// their bounds. Content that also changes its raster scale within the shutter, or would need
/// an outsized buffer, is drawn once.
pub(crate) fn blurred_source(&self, ctx: &EvalCtx, layer: &Layer, times: &[Tick]) -> Option<Buf> {
let bufs: Vec<Buf> = times.iter().filter_map(|t| self.source(&ctx.at(*t), layer)).collect();
let scale = bufs.first()?.scale;
// Pixel = layer point × scale + offset: with the largest offset every buffer lands at
// x + (off - its offset) ≥ 0.
let off = [0, 1].map(|i| bufs.iter().map(|b| b.offset[i]).fold(f64::MIN, f64::max));
let w = bufs.iter().map(|b| off[0] - b.offset[0] + b.img.width as f64).fold(0.0, f64::max).ceil();
let h = bufs.iter().map(|b| off[1] - b.offset[1] + b.img.height as f64).fold(0.0, f64::max).ceil();
if bufs.iter().any(|b| (b.scale - scale).abs() > 1e-9) || !(w * h).is_finite() || w * h > (1u64 << 26) as f64 {
return self.source(ctx, layer);
}
let mut acc = Image::new(w as u32, h as u32);
let k = 1.0 / bufs.len() as f32;
for b in &bufs {
let m = Mat3::translate(vec2(off[0] - b.offset[0], off[1] - b.offset[1]));
effectcraft_raster::accumulate_warp(&mut acc, &b.img, &m, effectcraft_raster::Sampling::Bilinear, k);
}
Some(Buf { img: acc, offset: off, scale })
}
/// A processed-layer cache key that also covers content motion blur (the averaged source
/// depends on the content at every sub-sample).
pub(crate) fn content_key(&self, ctx: &EvalCtx, layer: &Layer, key: Option<u64>) -> Option<u64> {
let k = key?;
let Some(times) = self.content_times(ctx, layer) else { return Some(k) };
times.iter().try_fold(cache::derive(k, 0x6d62_6c72), |d, t| Some(cache::derive(d, cache::layer_key(&ctx.at(*t), layer, 1.0, false, false)?)))
}
/// Whether a layer is motion blurred: the render options, the comp's switch and the layer's
/// switch (limited by the precomp layers above when switches affect nested comps). Layer
/// motion, mask motion blur, effects that read the shutter and Advanced 3D all ask this.
pub(crate) fn mb_on(&self, ctx: &EvalCtx, layer: &Layer) -> bool {
self.opts.motion_blur && ctx.comp.enable_motion_blur && self.layer_motion_blur(layer)
}
/// What a layer's mask motion blur may do in this render (see [`masks::MaskBlur`]).
pub(crate) fn mask_blur(&self, ctx: &EvalCtx, layer: &Layer) -> masks::MaskBlur {
masks::MaskBlur {
allowed: self.opts.motion_blur && ctx.comp.enable_motion_blur && self.inherited.is_none_or(|(_, mb)| mb),
layer: self.layer_motion_blur(layer),
samples: if self.opts.draft { 4 } else { ctx.comp.motion_blur_samples as usize },
}
}
/// Motion-blur sub-samples for a layer buffer (1 = no motion blur). As in After Effects, a 2D
/// layer gets about one sample per output pixel its corners travel during the shutter,
/// at least Samples Per Frame and at most the comp's Adaptive Sample Limit; a layer that
/// does not move within the shutter is drawn once.
fn mb_samples(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf) -> usize {
if !self.blurred(ctx, layer) {
return 1;
}
let travel = self.shutter_travel(ctx, layer, buf);
if travel < 1e-3 {
return 1;
}
if self.opts.draft {
return 4;
}
let sh = self.shutter(ctx);
adaptive_samples(travel, sh.samples, sh.limit)
}
/// Largest distance (output pixels) a corner of the layer buffer moves during the shutter,
/// measured along the path at a few points.
fn shutter_travel(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf) -> f64 {
let sh = self.shutter(ctx);
let (w, h) = (buf.img.width as f64, buf.img.height as f64);
let corners = [(0.0, 0.0), (w, 0.0), (0.0, h), (w, h), (w * 0.5, h * 0.5)];
const PROBES: usize = 8;
let mut prev: Option<Vec<effectcraft_geom::Vec2>> = None;
let mut travel = vec![0.0f64; corners.len()];
for i in 0..PROBES {
let m = self.buf_matrix_at(ctx, layer, buf, sh.offset(i, PROBES));
let pts: Vec<_> = corners.iter().map(|&(x, y)| m.apply(vec2(x, y))).collect();
if let Some(p) = &prev {
for (k, (a, b)) in p.iter().zip(&pts).enumerate() {
travel[k] += (b.x - a.x).hypot(b.y - a.y);
}
}
prev = Some(pts);
}
travel.into_iter().fold(0.0, f64::max)
}
/// Canvas pixels that compositing `buf` for `layer` can change: the transformed buffer's
/// bounds plus the resampling footprint. Stencil/silhouette modes, motion blur and
/// perspective fall back to the whole canvas.
///
/// A quantised buffer placed pixel-exactly (integer translation, Normal, 100%) over a blank
/// canvas leaves exactly its own (quantised) pixels: nothing to re-quantise.
fn footprint(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf, opacity: f32, blank: bool) -> Region {
if layer.blend_mode.is_stencil() || self.mb_samples(ctx, layer, buf) > 1 {
return Region::Full;
}
let m = self.buf_matrix(ctx, layer, buf);
if !m.is_affine() {
return Region::Full;
}
let matte = layer.track_matte.is_some_and(|tm| tm.layer != layer.id && ctx.comp.layer(tm.layer).is_some());
let k = &m.0;
let integral = |v: f64| v == v.round();
if blank
&& layer.blend_mode == BlendMode::Normal
&& opacity == 1.0
&& !matte
&& !layer.preserve_transparency
&& self.pipe.to_blend().is_none()
&& k[0][0] == 1.0
&& k[1][1] == 1.0
&& k[0][1] == 0.0
&& k[1][0] == 0.0
&& integral(k[0][2])
&& integral(k[1][2])
{
return Region::Empty;
}
let (w, h) = (buf.img.width as f64, buf.img.height as f64);
let c = [(0.0, 0.0), (w, 0.0), (0.0, h), (w, h)].map(|(x, y)| m.apply(vec2(x, y)));
let (x0, x1) = (c.iter().map(|p| p.x).fold(f64::INFINITY, f64::min), c.iter().map(|p| p.x).fold(f64::NEG_INFINITY, f64::max));
let (y0, y1) = (c.iter().map(|p| p.y).fold(f64::INFINITY, f64::min), c.iter().map(|p| p.y).fold(f64::NEG_INFINITY, f64::max));
color::PxRect::covering(x0, y0, x1, y1, 3.0).map_or(Region::Full, Region::Rect)
}
/// How a processed layer buffer lands in the output: one buffer → output matrix, or one per
/// motion-blur sub-sample (averaged), with the layer's sampling filter and dissolve seed.
pub fn placement(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf) -> Placement {
let samples = self.mb_samples(ctx, layer, buf);
let (sampling, seed) = (self.sampling(layer), layer.id.0 as u32);
if samples <= 1 {
return Placement { matrices: vec![self.buf_matrix(ctx, layer, buf)], sampling, seed };
}
let sh = self.shutter(ctx);
let matrices = (0..samples).map(|i| self.buf_matrix_at(ctx, layer, buf, sh.offset(i, samples))).collect();
Placement { matrices, sampling, seed }
}
/// Draw a processed layer into `target` (transform, motion blur) with `mode`/`opacity`.
pub(crate) fn place(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf, target: &mut Image, mode: BlendMode, opacity: f32) {
let pl = self.placement(ctx, layer, buf);
let opts = WarpOpts { sampling: pl.sampling, opacity, mode, seed: pl.seed, clip: None };
if let [m] = pl.matrices.as_slice() {
composite_warp(target, &buf.img, m, &opts);
return;
}
// Sub-samples sum straight into one buffer (each warp is row-parallel).
let mut acc = Image::new(target.width, target.height);
let k = 1.0 / pl.matrices.len() as f32;
for m in &pl.matrices {
effectcraft_raster::accumulate_warp(&mut acc, &buf.img, m, opts.sampling, k);
}
target.blend_from(&acc, mode, opacity, pl.seed);
}
/// Draw a 2D layer into `canvas`; returns the region it may have changed.
pub(crate) fn draw_layer(&self, ctx: &EvalCtx, layer: &Layer, canvas: &mut Image, blank: bool) -> Region {
let opacity = ctx.opacity(layer) as f32 * self.opacity_mul;
if self.quality(layer) == Quality::Wireframe && !layer.switches.adjustment {
self.draw_wireframe(ctx, layer, canvas);
return Region::Full;
}
if opacity <= 0.0 && !layer.switches.adjustment {
return Region::Empty;
}
if layer.switches.adjustment {
self.draw_adjustment(ctx, layer, canvas, opacity);
return Region::Full;
}
let mut timing = self.profile.map(|_| LayerTiming { depth: self.depth, layer: layer.name.clone(), ..Default::default() });
let t0 = web_time::Instant::now();
let Some(st) = self.styled(ctx, layer, timing.as_mut()) else { return Region::Empty };
let st = self.styled_to_blend(st);
let t1 = web_time::Instant::now();
let changed = if st.plain {
self.composite_layer(ctx, layer, &st.body, canvas, opacity);
self.footprint(ctx, layer, &st.body, opacity, blank)
} else {
self.composite_styled(ctx, layer, &st, canvas, opacity);
Region::Full
};
if let (Some(prof), Some(mut timing)) = (self.profile, timing) {
timing.process_ms = (t1 - t0).as_secs_f64() * 1e3;
timing.composite_ms = t1.elapsed().as_secs_f64() * 1e3;
if let Ok(mut v) = prof.lock() {
v.push(timing);
}
}
changed
}
/// Composite a styled layer: exterior passes with their own modes, then the body with the
/// layer's mode; the layer's opacity fades the whole stack. Knockout clears what is below the
/// layer's content; switched-off R/G/B channels keep the values below.
fn composite_styled(&self, ctx: &EvalCtx, layer: &Layer, st: &StyledLayer, canvas: &mut Image, opacity: f32) {
let bl = styles::blending(ctx, layer);
let matte = layer.track_matte.is_some_and(|tm| tm.layer != layer.id && ctx.comp.layer(tm.layer).is_some());
if matte || layer.preserve_transparency {
let mut iso = Image::new(canvas.width, canvas.height);
for (b, m) in &st.passes {
self.place(ctx, layer, b, &mut iso, *m, 1.0);
}
self.place(ctx, layer, &st.body, &mut iso, BlendMode::Normal, 1.0);
self.composite_iso(ctx, layer, iso, canvas, opacity);
return;
}
let mut tmp = canvas.clone();
if bl.knockout > 0 {
let mut k = Image::new(canvas.width, canvas.height);
self.place(ctx, layer, &st.content, &mut k, BlendMode::Normal, 1.0);
tmp.data.par_iter_mut().zip(k.data.par_iter()).for_each(|(p, q)| {
let keep = 1.0 - q[3].clamp(0.0, 1.0);
for c in p.iter_mut() {
*c *= keep;
}
});
}
for (b, m) in &st.passes {
self.place(ctx, layer, b, &mut tmp, *m, 1.0);
}
self.place(ctx, layer, &st.body, &mut tmp, layer.blend_mode, 1.0);
let ch = bl.channels;
canvas.data.par_iter_mut().zip(tmp.data.par_iter()).for_each(|(c, t)| {
for i in 0..4 {
let v = if i < 3 && !ch[i] { c[i] } else { t[i] };
c[i] += (v - c[i]) * opacity;
}
});
}
fn composite_layer(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf, canvas: &mut Image, opacity: f32) {
let matte = layer.track_matte.is_some_and(|tm| tm.layer != layer.id && ctx.comp.layer(tm.layer).is_some());
if !matte && !layer.preserve_transparency {
self.place(ctx, layer, buf, canvas, layer.blend_mode, opacity);
return;
}
// Render in isolation, then matte / preserve transparency, then blend.
let mut iso = Image::new(canvas.width, canvas.height);
self.place(ctx, layer, buf, &mut iso, BlendMode::Normal, 1.0);
self.composite_iso(ctx, layer, iso, canvas, opacity);
}
/// Apply the track matte / preserve transparency to an isolated layer render, then blend.
pub(crate) fn composite_iso(&self, ctx: &EvalCtx, layer: &Layer, iso: Image, canvas: &mut Image, opacity: f32) {
self.composite_iso_with(ctx, layer, iso, canvas, opacity, None);
}
/// [`Self::composite_iso`] with the track matte's pixels already drawn (`matte`, e.g. a 3D
/// matte seen through the camera); otherwise the matte layer is placed in 2D.
pub(crate) fn composite_iso_with(&self, ctx: &EvalCtx, layer: &Layer, mut iso: Image, canvas: &mut Image, opacity: f32, matte_img: Option<Image>) {
self.apply_track_matte(ctx, layer, &mut iso, matte_img);
if layer.preserve_transparency {
iso.data.par_iter_mut().zip(canvas.data.par_iter()).for_each(|(p, q)| {
for c in p.iter_mut() {
*c *= q[3];
}
});
}
canvas.blend_from(&iso, layer.blend_mode, opacity, layer.id.0 as u32);
}
/// Multiply `img` (comp-sized) by the layer's track matte, if it has one; `matte_img` as in
/// [`Self::composite_iso_with`].
fn apply_track_matte(&self, ctx: &EvalCtx, layer: &Layer, img: &mut Image, matte_img: Option<Image>) {
let matte = layer.track_matte.and_then(|tm| ctx.comp.layer(tm.layer).filter(|m| m.id != layer.id).map(|m| (m, tm.kind)));
if let Some((m, kind)) = matte {
let mimg = match matte_img {
Some(i) if i.width == img.width && i.height == img.height => i,
_ => {
let mut mimg = Image::new(img.width, img.height);
if m.is_active_at(ctx.time)
&& let Some(mb) = self.layer_buf(ctx, m)
{
let mo = ctx.opacity(m) as f32;
self.place(ctx, m, &mb, &mut mimg, BlendMode::Normal, mo);
}
mimg
}
};
img.data.par_iter_mut().zip(mimg.data.par_iter()).for_each(|(p, q)| {
let k = match kind {
MatteKind::Alpha => q[3],
MatteKind::AlphaInverted => 1.0 - q[3],
MatteKind::Luma => effectcraft_color::luminance(q[0], q[1], q[2]),
MatteKind::LumaInverted => 1.0 - effectcraft_color::luminance(q[0], q[1], q[2]),
}
.clamp(0.0, 1.0);
for c in p.iter_mut() {
*c *= k;
}
});
}
}
/// Adjustment layer: effects applied to the comp below, limited to the layer's footprint
/// and its track matte.
fn draw_adjustment(&self, ctx: &EvalCtx, layer: &Layer, canvas: &mut Image, opacity: f32) {
let Some(mut foot) = self.source(ctx, layer) else { return };
let _ = masks::apply(ctx, layer, &mut foot, self.mask_blur(ctx, layer));
let mut matte = Image::new(canvas.width, canvas.height);
self.place(ctx, layer, &foot, &mut matte, BlendMode::Normal, 1.0);
self.apply_track_matte(ctx, layer, &mut matte, None);
let o = self.roi_offset().map(|(x, y, _, _)| [-x, -y]).unwrap_or([0.0; 2]);
let mut below = Buf { img: canvas.clone(), offset: o, scale: self.opts.scale };
// Effects run in the working space, not the linear blending space.
if let Some(c) = self.pipe.from_blend() {
color::convert(&mut below.img, &c);
}
let mut adjusted = self.apply_effects(ctx, layer, below, true);
// An effect that changed the frame (Motion Tile's Output size): back onto the canvas.
if let Some((dx, dy)) =
effectcraft_effects::adjustment_crop(adjusted.offset, o, (adjusted.img.width, adjusted.img.height), (canvas.width, canvas.height))
{
adjusted.img = adjusted.img.crop(dx, dy, canvas.width, canvas.height);
}
if let Some(c) = self.pipe.to_blend() {
color::convert(&mut adjusted.img, &c);
}
if adjusted.img.width != canvas.width || adjusted.img.height != canvas.height {
return;
}
canvas.data.par_iter_mut().zip(adjusted.img.data.par_iter()).zip(matte.data.par_iter()).for_each(|((c, a), m)| {
let k = m[3] * opacity;
for i in 0..4 {
c[i] += (a[i] - c[i]) * k;
}
});
}
}
/// A layer ready to composite (see [`Renderer::styled_layer`]).
pub struct StyledLayer {
/// Exterior style passes (Drop Shadow, Outer Glow) with their blend modes.
pub passes: Vec<(Arc<Buf>, BlendMode)>,
pub body: Arc<Buf>,
/// Pre-style pixels (knockout shape).
pub content: Arc<Buf>,
/// Styled cache key (the content key when plain).
pub(crate) key: Option<u64>,
/// Content cache key.
ckey: Option<u64>,
/// No layer styles: composite `body` the usual way.
pub plain: bool,
}
/// Where a layer buffer lands (see [`Renderer::placement`]).
#[derive(Clone, Debug)]
pub struct Placement {
/// Buffer pixel → output pixel; several = motion-blur sub-samples (equal weights).
pub matrices: Vec<Mat3>,
pub sampling: effectcraft_raster::Sampling,
/// Dissolve noise seed.
pub seed: u32,
}
+22 -2349
View File
File diff suppressed because it is too large Load Diff
+97
View File
@@ -0,0 +1,97 @@
use super::accel::Backend;
use super::*;
#[derive(Clone, Copy, Debug, serde::Serialize, serde::Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct RenderOpts {
/// Output scale relative to comp pixels (1 = Full, 0.5 = Half…).
pub scale: f64,
pub motion_blur: bool,
/// Render guide layers (viewer only). Only the top-level comp's own guide layers: nested
/// comps (precomps, collapsed precomps, comps read by effects) never draw theirs, as in
/// After Effects (see `Renderer::nested_opts`).
pub guides: bool,
/// Draft quality: fewer motion-blur samples, bilinear only.
pub draft: bool,
/// 3D view camera override (viewer Front/Top/Custom views); None = the comp's active camera.
pub view: Option<three_d::CameraState>,
/// CPU or GPU compositor (the GPU needs [`Renderer::accel`]).
pub backend: Backend,
/// Region of interest `[x, y, w, h]` in comp pixels (viewer only): the top-level frame covers
/// just this rectangle, and only its pixels are composited.
pub roi: Option<[f64; 4]>,
/// Which proxies stand in for footage and compositions (Render Settings ▸ Proxy Use).
pub proxy: effectcraft_project::render_queue::ProxyUse,
/// Switches Affect Nested Comps (Settings ▸ General): a precomp layer's Quality and Motion
/// Blur switches also limit the layers of the nested comp (Draft/Wireframe or motion blur off
/// propagate down; they never raise a nested layer's own setting).
pub nested_switches: bool,
/// Realtime Shadows in Draft (Settings ▸ 3D): draft renders (`draft`) still cast shadows.
pub draft_shadows: bool,
}
impl RenderOpts {
/// Whether 3D layers cast shadows in this render.
pub fn shadows(&self) -> bool {
!self.draft || self.draft_shadows
}
}
impl Default for RenderOpts {
fn default() -> Self {
RenderOpts {
scale: 1.0,
motion_blur: true,
guides: false,
draft: false,
view: None,
roi: None,
backend: Backend::Cpu,
nested_switches: true,
draft_shadows: true,
proxy: effectcraft_project::render_queue::ProxyUse::CurrentSettings,
}
}
}
/// One collapsed precomp layer a nested comp is drawn through.
#[derive(Clone, Copy)]
pub(crate) struct Through<'a> {
pub(crate) comp_id: ItemId,
pub(crate) comp: &'a Comp,
/// The containing comp's time at this frame.
pub(crate) time: Tick,
pub(crate) layer: &'a Layer,
/// The precomp layer is motion blurred: its own motion blurs the nested layers.
pub(crate) blur: bool,
}
/// Collapsed precomps within collapsed precomps that motion blur follows (more than
/// [`Renderer::collapsed`]'s nesting limit).
pub(crate) const MAX_THROUGH: usize = 16;
/// How a collapsed precomp's 3D layers are placed in the parent (see `Renderer::collapse_into`).
#[derive(Clone, Copy)]
pub(crate) struct Collapse3d<'a> {
/// Nested comp world → parent world (the precomp layer's world transform).
pub world: Mat4,
/// The parent's camera.
pub cam: three_d::CameraState,
/// The outermost comp's context (its lights light the nested layers).
pub parent: EvalCtx<'a>,
}
/// Time spent on one layer of one frame (milliseconds).
#[derive(Clone, Debug, Default)]
pub struct LayerTiming {
pub depth: usize,
pub layer: String,
/// Source + masks + effects (cache lookup only, on a hit).
pub process_ms: f64,
/// Per effect: (effect id, ms). Empty on a cache hit.
pub effects: Vec<(String, f64)>,
/// Transform + matte + blend.
pub composite_ms: f64,
/// The processed buffer came from the layer cache.
pub cached: bool,
}
+886
View File
@@ -0,0 +1,886 @@
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::fx_host::{FxHost, essential_overrides};
use super::opts::{Collapse3d, LayerTiming, MAX_THROUGH, RenderOpts, Through};
use super::*;
impl<'a> Renderer<'a> {
pub fn new(project: &'a Project, footage: &'a dyn FootageSource, opts: RenderOpts) -> Renderer<'a> {
Renderer {
project,
footage,
expr: None,
opts,
cache: None,
profile: None,
accel: None,
depth: 0,
pipe: color::Pipe::of(&project.settings),
outer: None,
opacity_mul: 1.0,
collapse3d: None,
inherited: None,
defer_dof: false,
top: true,
through: [None; MAX_THROUGH],
through_lost: false,
}
}
/// The layer cache to keep this render's buffers in: none once its footage reads were
/// called off, as they may have come back empty ([`FootageSource::called_off`], #329).
pub(crate) fn cache_to_keep(&self) -> Option<&'a LayerCache> {
self.cache.filter(|_| !self.footage.called_off())
}
/// A layer's Quality switch, limited by the precomp layers above it when
/// [`RenderOpts::nested_switches`] is on.
pub fn quality(&self, layer: &Layer) -> Quality {
let rank = |q: Quality| match q {
Quality::Best => 0,
Quality::Draft => 1,
Quality::Wireframe => 2,
};
match self.inherited {
Some((q, _)) if rank(q) > rank(layer.switches.quality) => q,
_ => layer.switches.quality,
}
}
/// A layer's Motion Blur switch, limited by the precomp layers above it.
pub(crate) fn layer_motion_blur(&self, layer: &Layer) -> bool {
layer.switches.motion_blur && self.inherited.is_none_or(|(_, mb)| mb)
}
/// The renderer for the nested comp of precomp layer `layer`: its switches pass down when
/// [`RenderOpts::nested_switches`] is on.
fn nested_through(&self, layer: &Layer) -> Renderer<'a> {
let mut sub = self.nested();
if self.opts.nested_switches {
sub.inherited = Some((self.quality(layer), self.layer_motion_blur(layer)));
}
sub
}
/// The render options one level down (precomps, collapsed precomps, comps read by effects):
/// guide layers render only in their own comp, never when it is nested.
fn nested_opts(&self) -> RenderOpts {
RenderOpts { guides: false, ..self.opts }
}
/// A renderer for nested work (precomps, layers read by effects): one level deeper.
pub(crate) fn nested(&self) -> Renderer<'a> {
Renderer {
opts: self.nested_opts(),
depth: self.depth + 1,
outer: None,
opacity_mul: 1.0,
collapse3d: None,
defer_dof: false,
top: false,
through: [None; MAX_THROUGH],
through_lost: false,
..*self
}
}
/// A precomp layer's nested comp for an accelerator to render resident (no CPU render and
/// upload of the nested frame): the renderer one level down, the comp, its time and the
/// buffer scale, exactly as [`Self::layer_source`]'s comp branch would render it. `None`
/// when that branch does more (composition proxy, Essential Properties overrides, frame
/// blending, recursion) or draws nothing (outside the nested comp's span): use the CPU path.
pub fn precomp_source(&self, ctx: &EvalCtx, layer: &Layer) -> Option<(Renderer<'a>, ItemId, Tick, f64)> {
let LayerSource::Comp { item } = layer.source else { return None };
if layer.switches.adjustment || self.project.comp_contains(item, ctx.comp_id) {
return None;
}
let nc = self.project.comp(item)?;
let lt = ctx.nested_time(layer);
if self.content_times(ctx, layer).is_some() {
return None;
}
if !nc.covers(lt) || self.proxy_for(item).is_some() || !essential_overrides(ctx, layer).is_empty() || frame_blend_mode(ctx, layer) != FrameBlend::Off {
return None;
}
let s = self.opts.scale;
let bs = if s < 1.0 && nc.preserve_resolution { 1.0 } else { s };
let base = self.nested_through(layer);
Some((Renderer { opts: RenderOpts { scale: bs, ..base.opts }, ..base }, item, lt, bs))
}
/// The nested comp of a precomp layer whose transformations collapse into this comp: the
/// Collapse Transformations switch is on and the layer has no masks, effects or layer
/// styles (those force a flattened render of the precomp, as in After Effects).
pub fn collapsed(&self, ctx: &EvalCtx, layer: &Layer) -> Option<ItemId> {
if !layer.switches.collapse || layer.switches.adjustment || self.depth > 12 {
return None;
}
let LayerSource::Comp { item } = &layer.source else { return None };
if self.project.comp_contains(*item, ctx.comp_id) || self.project.comp(*item).is_none() {
return None;
}
if layer.masks().is_some_and(|m| !m.children.is_empty()) {
return None;
}
if layer.switches.effects && layer.effects().is_some_and(|fx| fx.groups().any(|g| g.enabled)) {
return None;
}
if styles::active(ctx, layer) {
return None;
}
// Instance overrides (Essential Properties) need the nested comp rendered on its own.
if !effectcraft_project::essential::overridden(layer).is_empty() {
return None;
}
Some(*item)
}
/// A renderer and context that draw a collapsed precomp's layers straight into this comp:
/// their transforms are concatenated with the precomp layer's (one resample, no
/// rasterisation at the precomp's bounds), their opacity multiplied by `opacity`, and their
/// 3D layers use this comp's camera and lights.
pub fn collapse_into(&self, ctx: &EvalCtx<'a>, layer: &'a Layer, item: ItemId, opacity: f32) -> Option<(Renderer<'a>, EvalCtx<'a>)> {
let nc = self.project.comp(item)?;
let t = ctx.nested_time(layer);
if !nc.covers(t) {
return None;
}
let nctx = EvalCtx { comp_id: item, comp: nc, time: t, ..*ctx };
let (l2c, _) = ctx.layer_to_comp(layer);
let outer = Some(self.outer.map_or(l2c, |o| o * l2c));
let world = ctx.world_matrix(layer);
let c3 = Collapse3d {
world: self.collapse3d.map_or(world, |c| c.world * world),
cam: three_d::compose::camera_for(self, ctx),
parent: self.collapse3d.map_or(*ctx, |c| c.parent),
};
// Motion blur follows the precomp layer's own motion too.
let link = Through { comp_id: ctx.comp_id, comp: ctx.comp, time: ctx.time, layer, blur: self.mb_on(ctx, layer) };
let mut through = self.through;
let slot = through.iter_mut().find(|t| t.is_none());
let through_lost = self.through_lost || slot.is_none();
if let Some(slot) = slot {
*slot = Some(link);
}
Some((
Renderer { opts: self.nested_opts(), depth: self.depth + 1, outer, opacity_mul: opacity, collapse3d: Some(c3), through, through_lost, ..*self },
nctx,
))
}
pub(crate) fn opacity_mul(&self) -> f32 {
self.opacity_mul
}
/// Draw a collapsed 2D precomp layer: nested layers blend straight into `canvas` with their
/// own modes (Normal precomp, no matte); otherwise they are drawn into an isolated buffer
/// that then takes the precomp layer's track matte, Preserve Transparency and blend mode.
/// Returns the region left to re-quantise (nested layers drawn straight into `canvas`
/// quantise their own footprints).
pub(crate) fn draw_collapsed(&self, ctx: &EvalCtx<'a>, layer: &Layer, item: ItemId, canvas: &mut Image, blank: bool) -> Region {
let opacity = ctx.opacity(layer) as f32 * self.opacity_mul;
if opacity <= 0.0 {
return Region::Empty;
}
let matte = layer.track_matte.is_some_and(|tm| tm.layer != layer.id && ctx.comp.layer(tm.layer).is_some());
if layer.blend_mode == BlendMode::Normal && !matte && !layer.preserve_transparency {
if let Some((sub, nctx)) = self.collapse_into(ctx, layer, item, opacity) {
sub.draw_comp(&nctx, canvas, blank);
}
return Region::Empty;
}
let Some((sub, nctx)) = self.collapse_into(ctx, layer, item, 1.0) else { return Region::Empty };
let mut iso = Image::new(canvas.width, canvas.height);
sub.draw_comp(&nctx, &mut iso, true);
self.composite_iso(ctx, layer, iso, canvas, opacity);
Region::Full
}
/// Whether `layer`'s processed buffer (source → masks → effects) is cached and the same as
/// one frame earlier: drawing it from the layer cache (an accelerator uploads it once) beats
/// re-running its effects every frame.
pub fn layer_is_static(&self, ctx: &EvalCtx, layer: &Layer) -> bool {
if self.cache.is_none() {
return false;
}
let (scale, draft, blur) = (self.raster_scale(ctx, layer), self.opts.draft, self.mb_on(ctx, layer));
let prev = ctx.time - ctx.comp.frame_rate.frame_duration();
let now = cache::layer_key(ctx, layer, scale, draft, blur);
now.is_some() && now == cache::layer_key(&ctx.at(prev), layer, scale, draft, blur)
}
/// Rasterisation scale of a layer's source: the output scale, or — for text and shape layers,
/// which are always continuously rasterised (#390, #751), and Continuously Rasterize vector
/// footage — the output scale times the layer's on-screen scale (quarter-octave steps, rounded
/// up, so vector content is drawn at its final size and stays sharp when scaled up).
pub fn raster_scale(&self, ctx: &EvalCtx, layer: &Layer) -> f64 {
let s = self.opts.scale;
let continuous = match layer.source {
LayerSource::Text | LayerSource::Shape => true,
LayerSource::Footage { item } => {
layer.switches.collapse && matches!(self.project.item(item).map(|i| &i.kind), Some(ItemKind::Footage(f)) if is_vector_footage(f))
}
_ => false,
};
if !continuous {
return s;
}
let (l2c, _) = ctx.layer_to_comp(layer);
let m = self.outer.map_or(l2c, |o| o * l2c);
let a = layer.transform().map(|tr| ctx.v3(layer, tr, "anchor", [0.0; 3])).unwrap_or([0.0; 3]);
let p0 = m.apply(vec2(a[0], a[1]));
let px = m.apply(vec2(a[0] + 1.0, a[1]));
let py = m.apply(vec2(a[0], a[1] + 1.0));
let k = ((px.x - p0.x).hypot(px.y - p0.y)).max((py.x - p0.x).hypot(py.y - p0.y));
if !k.is_finite() || k <= 1e-6 {
return s;
}
// The step is rounded up past rounding noise only: an unscaled layer whose position isn't
// a whole number maps the unit vectors to lengths a hair over 1, which must stay 1 (#390:
// rounded up to 2^¼, unscaled text was drawn larger and resampled, softer).
let mut k = 2f64.powf((k.log2() * 4.0 - 1e-6).ceil() / 4.0).clamp(1.0 / 16.0, 64.0);
// Keep the buffer within about 16 million pixels.
if let Some(b) = content_bounds(ctx, layer) {
let area = ((b[2] - b[0]) * (b[3] - b[1])).max(1.0) * s * s;
k = k.min((16.0e6 / area).sqrt().max(1.0));
}
s * k
}
/// Draw a Wireframe-quality layer: its bounds as a one-pixel outline.
pub(crate) fn draw_wireframe(&self, ctx: &EvalCtx, layer: &Layer, canvas: &mut Image) {
for (x, y) in self.wireframe_pixels(ctx, layer, (canvas.width, canvas.height)) {
canvas.set(x, y, [1.0, 1.0, 1.0, 1.0]);
}
}
pub(crate) fn ctx(&self, comp_id: ItemId, comp: &'a Comp, t: Tick) -> EvalCtx<'a> {
EvalCtx { project: self.project, comp_id, comp, time: t, expr: self.expr, footage: Some(self.footage) }
}
/// This renderer with another accelerator (or none): the passes of
/// [`passes::in_passes`] render with the accelerator, its CPU fallback without.
pub fn with_accel(&self, accel: Option<&'a dyn Accelerator>) -> Renderer<'a> {
Renderer { accel, ..*self }
}
/// The accelerator to use for this render, if any (see [`Backend`]).
pub fn active_accel(&self) -> Option<&'a dyn Accelerator> {
let a = self.accel?;
match self.opts.backend {
Backend::Cpu => None,
Backend::Gpu => Some(a),
Backend::Auto => self.project.settings.gpu_acceleration.then_some(a),
}
}
/// Render a composition at comp time `t` (transparent background, comp size × scale).
/// Top-level frames go to the GPU when [`Self::active_accel`] is set and handles them.
pub fn comp_frame(&self, comp_id: ItemId, t: Tick) -> Image {
// Classic 3D runs composite on the GPU too (M12.7), so Auto sends 3D comps there as well;
// Auto renders each comp on whichever compositor measured faster ([`AutoPick`]).
if self.depth == 0
&& let Some(a) = self.active_accel()
{
let auto = self.frame_auto(a, comp_id, false);
if auto.is_none_or(|(p, key)| p.choose(key)) {
let t0 = web_time::Instant::now();
if let Some(img) = a.comp_frame(self, comp_id, t) {
if let Some((p, key)) = auto {
p.record(key, true, t0.elapsed().as_secs_f64() * 1e3);
}
return img;
}
if let Some((p, key)) = auto {
p.declined(key);
}
}
let t0 = web_time::Instant::now();
let img = self.comp_frame_cpu(comp_id, t);
if let Some((p, key)) = auto {
p.record(key, false, t0.elapsed().as_secs_f64() * 1e3);
}
return img;
}
self.comp_frame_cpu(comp_id, t)
}
/// The Auto timing history and key for a top-level frame of `comp` (`None` unless the
/// backend is [`Backend::Auto`] and the accelerator keeps history).
pub fn frame_auto(&self, a: &'a dyn Accelerator, comp: ItemId, display: bool) -> Option<(&'a AutoPick, AutoKey)> {
if self.opts.backend != Backend::Auto || self.opts.roi.is_some() {
return None;
}
Some((a.auto_pick()?, AutoKey::new(comp, self.opts.scale, display)))
}
/// [`Self::comp_frame`] on the CPU compositor only.
pub fn comp_frame_cpu(&self, comp_id: ItemId, t: Tick) -> Image {
let Some(comp) = self.project.comp(comp_id) else { return Image::new(1, 1) };
let s = self.opts.scale;
let (w, h) = frame_size(comp.width, comp.height, s);
// Region of interest on a comp with 3D layers: render the frame and crop it. A region
// reaching past the comp frame (the Extended Viewer) renders Classic 3D natively — the
// planes project through the offset camera — and Advanced 3D as the frame, padded.
if self.depth == 0
&& let Some(r) = self.opts.roi
&& comp.has_3d()
&& (roi_inside(r, comp.width, comp.height) || comp.renderer == effectcraft_project::Renderer::Advanced3D)
{
let full = Renderer { opts: RenderOpts { roi: None, ..self.opts }, ..*self }.comp_frame(comp_id, t);
let (x0, y0) = ((r[0] * s).round() as i64, (r[1] * s).round() as i64);
let (rw, rh) = (((r[2] * s).round() as u32).max(1), ((r[3] * s).round() as u32).max(1));
let mut out = Image::new(rw, rh);
for y in 0..rh as i64 {
let sy = y + y0;
if sy < 0 || sy >= full.height as i64 {
continue;
}
for x in 0..rw as i64 {
let sx = x + x0;
if sx >= 0 && sx < full.width as i64 {
out.data[(y * rw as i64 + x) as usize] = full.data[(sy * full.width as i64 + sx) as usize];
}
}
}
return out;
}
let (w, h) = match self.roi_offset() {
Some((_, _, rw, rh)) => (rw, rh),
None => (w, h),
};
let mut canvas = Image::new(w, h);
if self.depth > 16 {
return canvas;
}
let ctx = self.ctx(comp_id, comp, t);
self.draw_comp(&ctx, &mut canvas, true);
// The canvas is already quantised after every layer; only re-encoding changes it.
if let Some(c) = self.pipe.from_blend() {
color::convert(&mut canvas, &c);
self.pipe.quantize(&mut canvas);
}
// The top-level comp leaves the working space for the (sRGB) display / output.
if self.depth == 0
&& let Some(c) = self.pipe.output()
{
color::convert(&mut canvas, &c);
self.pipe.quantize(&mut canvas);
}
if self.depth == 0
&& let Some((lin, mode, enc)) = self.pipe.output_hdr()
{
color::output_hdr(&mut canvas, lin, mode, enc);
self.pipe.quantize(&mut canvas);
}
canvas
}
/// Draw a comp's layers (bottom to top) into `canvas` (blending space).
/// `blank`: the canvas is fully transparent (lets the first layer skip re-quantising).
fn draw_comp(&self, ctx: &EvalCtx<'a>, canvas: &mut Image, mut blank: bool) {
// Bottom-to-top, with runs of consecutive 3D layers depth-sorted (farthest first).
let visible = self.visible_layers(ctx);
let mut i = 0;
while i < visible.len() {
if visible[i].is_3d() {
let mut j = i;
while j < visible.len() && visible[j].is_3d() {
j += 1;
}
three_d::compose::draw_run(self, ctx, &visible[i..j], canvas);
i = j;
self.pipe.quantize(canvas);
blank = false;
} else {
let changed = match self.collapsed(ctx, visible[i]) {
Some(item) => self.draw_collapsed(ctx, visible[i], item, canvas, blank),
None => self.draw_layer(ctx, visible[i], canvas, blank),
};
i += 1;
// 8/16 bpc: the comp is an integer buffer after every layer (only the pixels the
// layer touched need re-quantising).
self.pipe.quantize_region(canvas, changed);
blank = false;
}
}
}
/// Evaluate an effect instance's parameters.
fn effect_params(&self, ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup) -> Params {
// Nested groups (Paint strokes, Puppet meshes and pins) are flattened too.
effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr))
}
pub(crate) fn apply_effects(&self, ctx: &EvalCtx, layer: &Layer, buf: Buf, adjustment: bool) -> Buf {
self.apply_effects_timed(ctx, layer, buf, adjustment, usize::MAX, None)
}
/// Run the first `limit` effects of the layer's stack (by position; disabled ones count) on
/// `buf` (layer space, or the comp below for an adjustment layer).
fn apply_effects_timed(&self, ctx: &EvalCtx, layer: &Layer, mut buf: Buf, adjustment: bool, limit: usize, timing: Option<&mut Vec<(String, f64)>>) -> Buf {
// The comp below an adjustment layer is not in its layer space: it stays as it is.
let origin = if adjustment { [0.0; 2] } else { ctx.effect_bounds(layer).1 };
buf.rebase(origin);
let mut out = self.effect_space_stack(ctx, layer, buf, adjustment, limit, timing, origin);
out.rebase([-origin[0], -origin[1]]);
out
}
/// [`Renderer::apply_effects_timed`] on a buffer already in effect space (measured from
/// `origin`, in layer coordinates); the result stays in effect space.
#[allow(clippy::too_many_arguments)]
fn effect_space_stack(
&self,
ctx: &EvalCtx,
layer: &Layer,
buf: Buf,
adjustment: bool,
limit: usize,
timing: Option<&mut Vec<(String, f64)>>,
origin: [f64; 2],
) -> Buf {
let mut t = CpuFx { accel: self.active_accel(), levels: self.pipe.levels, buf: Some(buf) };
self.run_effect_stack(ctx, layer, adjustment, limit, timing, origin, &mut t);
t.buf.unwrap_or_else(|| Buf { img: Image::new(1, 1), offset: [0.0; 2], scale: self.opts.scale })
}
/// Run the layer's video effect stack on `target` (in effect space, measured from `origin`
/// in layer coordinates): runs of GPU-capable effects go to [`FxTarget::gpu`] (when an
/// accelerator is active and supports them), the rest to [`FxTarget::cpu`] one by one
/// (quantised to the bit depth after each).
#[allow(clippy::too_many_arguments)]
pub(crate) fn run_effect_stack(
&self,
ctx: &EvalCtx,
layer: &Layer,
adjustment: bool,
limit: usize,
mut timing: Option<&mut Vec<(String, f64)>>,
origin: [f64; 2],
target: &mut dyn FxTarget,
) {
if !layer.switches.effects {
return;
}
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);
}
let mut mask_shapes = masks::shapes(ctx, layer);
if origin != [0.0; 2] {
for q in mask_shapes.iter_mut().flat_map(|m| m.points.iter_mut()) {
*q = [q[0] - origin[0], q[1] - origin[1]];
}
}
// Capture once, before preceding effects can expand or replace the input. An
// adjustment stack's original is the comp below, not its solid source.
let needs_original =
fx.groups().take(limit).any(|g| g.enabled && matches!(&g.kind, GroupKind::Effect { effect } if effect == "ec.channel.cccomposite"));
let mut original = None;
if needs_original {
target.cpu(&mut |buf| {
original = Some(buf.clone());
buf
});
}
let host = FxHost { r: self, ctx, layer, origin, index: 0, original: original.as_ref() };
let env = EffectEnv {
masks: &mask_shapes,
host: Some(&host),
comp_time: ctx.time.seconds(),
frame_rate: ctx.comp.frame_rate.as_f64(),
effect_index: 0,
working_space: self.pipe.space,
working_linear: self.pipe.linear,
float_depth: self.pipe.levels.is_none(),
shutter: self.mb_on(ctx, layer).then_some((ctx.comp.shutter_angle, ctx.comp.shutter_phase, ctx.comp.motion_blur_samples)),
};
// Video effects in stack order (index, group, spec); disabled and audio effects skipped.
let stack: Vec<(usize, &effectcraft_project::PropGroup, &'static effectcraft_effects::EffectSpec)> = fx
.groups()
.enumerate()
.take_while(|(i, _)| *i < limit)
.filter(|(_, g)| g.enabled)
.filter_map(|(i, g)| match &g.kind {
GroupKind::Effect { effect } => effectcraft_effects::find(effect).map(|s| (i, g, s)),
_ => None,
})
.filter(|(_, _, s)| !effectcraft_effects::audio_fx::is_audio_effect(s.id))
.collect();
let accel = self.active_accel();
let mut k = 0;
while k < stack.len() {
let (i, g, spec) = stack[k];
// A run of GPU effects goes to the accelerator in one go.
if let Some(a) = accel
&& spec.gpu
&& a.supports_effect(spec.id)
{
let run: Vec<_> = stack[k..].iter().take_while(|(_, _, s)| s.gpu && a.supports_effect(s.id)).collect();
let params: Vec<Params> = run.iter().map(|(_, g, _)| self.effect_params(ctx, layer, g)).collect();
let hosts: Vec<_> = run.iter().map(|(i, _, _)| FxHost { index: *i, ..host }).collect();
let steps: Vec<FxStep> = run
.iter()
.zip(&params)
.zip(&hosts)
.map(|(((i, g, s), params), host)| FxStep {
spec: s,
ctx: EffectCtx {
params,
time: lt.seconds(),
layer_size,
seed: g.uid as u32,
adjustment,
env: EffectEnv { host: Some(host), effect_index: *i, ..env },
},
})
.collect();
let t0 = web_time::Instant::now();
if target.gpu(&steps) {
if let Some(v) = timing.as_deref_mut() {
let ms = t0.elapsed().as_secs_f64() * 1e3 / run.len() as f64;
v.extend(run.iter().map(|(_, _, s)| (format!("{} (gpu)", s.id), ms)));
}
k += run.len();
continue;
}
}
k += 1;
let params = self.effect_params(ctx, layer, g);
let host = FxHost { index: i, ..host };
let env = EffectEnv { host: Some(&host), effect_index: i, ..env };
let ectx = EffectCtx { params: &params, time: lt.seconds(), layer_size, seed: g.uid as u32, adjustment, env };
let t0 = web_time::Instant::now();
target.cpu(&mut |buf| {
let mut buf = effectcraft_effects::apply(spec, &ectx, buf);
// 8/16 bpc effects write integer pixels.
self.pipe.quantize(&mut buf.img);
buf
});
if let Some(v) = timing.as_deref_mut() {
v.push((spec.id.to_string(), t0.elapsed().as_secs_f64() * 1e3));
}
}
}
/// Source pixels of a layer in layer space (before masks/effects).
pub(crate) fn source(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Buf> {
let s = self.opts.scale;
match &layer.source {
LayerSource::Solid { item } => {
let ItemKind::Solid(sol) = &self.project.item(*item)?.kind else { return None };
let w = ((sol.width as f64 * s).ceil() as u32).max(1);
let h = ((sol.height as f64 * s).ceil() as u32).max(1);
if layer.switches.adjustment {
return Some(Buf { img: Image::filled(w, h, [1.0; 4]), offset: [0.0; 2], scale: s });
}
let mut c = sol.color;
if let Some(conv) = self.pipe.authored_in() {
c = conv.apply(c);
}
let px = self.pipe.quantize_px([c[0], c[1], c[2], 1.0]);
Some(Buf { img: Image::filled(w, h, px), offset: [0.0; 2], scale: s })
}
LayerSource::Comp { item } => {
if self.project.comp_contains(*item, ctx.comp_id) {
return None;
}
// A composition proxy (a rendered still or movie) stands in for the comp.
// Nothing before the nested comp starts or after it ends.
let lt = ctx.nested_time(layer);
if !self.project.comp(*item).is_some_and(|nc| nc.covers(lt)) {
return None;
}
if let Some(pf) = self.proxy_for(*item)
&& let Some(nc) = self.project.comp(*item)
{
let img = self.footage.frame_with(*item, pf, lt, None, self.pipe.float_encoding())?;
return Some(self.footage_buf(&img, pf, nc.width, nc.height));
}
// Essential Properties overrides render the nested comp with this instance's
// values.
let ov = essential_overrides(ctx, layer);
let tmp = if ov.is_empty() { None } else { effectcraft_project::essential::with_overrides(self.project, *item, &ov) };
// Preserve resolution when nested: drawn at full size even when this comp renders
// smaller (the buffer's scale places it).
let full = s < 1.0 && self.project.comp(*item).is_some_and(|nc| nc.preserve_resolution);
let bs = if full { 1.0 } else { s };
let base = self.nested_through(layer);
let base = Renderer { opts: RenderOpts { scale: bs, ..base.opts }, ..base };
let sub = match &tmp {
Some(p) => Renderer { project: p, ..base },
None => base,
};
// Frame blending between the nested comp's frames (time-stretched/remapped).
let mode = frame_blend_mode(ctx, layer);
if mode != FrameBlend::Off
&& let Some(nc) = self.project.comp(*item)
{
let (i, w) = frame_position(lt, nc.frame_rate);
if w > 1e-6 {
let a = sub.comp_frame(*item, nc.frame_rate.tick_of(i));
let b = sub.comp_frame(*item, nc.frame_rate.tick_of(i + 1));
return Some(Buf { img: blend_frames(mode, &a, &b, w as f32), offset: [0.0; 2], scale: bs });
}
}
Some(Buf { img: sub.comp_frame(*item, lt), offset: [0.0; 2], scale: bs })
}
LayerSource::Footage { item } => {
let it = self.project.item(*item)?;
let ItemKind::Footage(f) = &it.kind else { return None };
if !f.has_video {
return None;
}
// Continuously rasterised vector footage: drawn at the on-screen scale.
if layer.switches.collapse && is_vector_footage(f) {
let k = self.raster_scale(ctx, layer);
if let Some(img) = self.footage.vector_frame(*item, f, k) {
let mut buf = Buf { img: (*img).clone(), offset: [0.0; 2], scale: k };
if let Some(c) = self.pipe.media_in(f) {
color::convert(&mut buf.img, &c);
}
return Some(buf);
}
}
let lt = ctx.source_time(layer);
// The proxy is decoded instead, at its own size, and fills the footage's frame.
let proxy = self.proxy_for(*item);
let pf = proxy.unwrap_or(f);
let size = self.reduced_size(f.width, f.height).filter(|_| proxy.is_none());
let img = self.footage_frame(ctx, layer, *item, pf, lt, size)?;
Some(self.footage_buf(&img, pf, f.width, f.height))
}
LayerSource::Text => Some(self.authored(text::render(ctx, layer, self.raster_scale(ctx, layer)))),
LayerSource::Shape => layer.props.sub("contents").map(|c| self.authored(shapes::render(ctx, layer, c, self.raster_scale(ctx, layer)))),
_ => None,
}
}
/// The proxy footage used for `item` under this render's Proxy Use, if any.
pub fn proxy_for(&self, item: ItemId) -> Option<&'a Footage> {
let it = self.project.item(item)?;
let px = it.proxy.as_deref()?;
(self.opts.proxy.uses(matches!(it.kind, ItemKind::Comp(_)), px.enabled) && px.footage.has_video).then_some(&px.footage)
}
/// A decoded frame of `f` as a layer buffer covering `w`×`h` layer pixels (the footage's
/// nominal size; proxies are scaled up to it), converted from its colour profile.
fn footage_buf(&self, img: &Image, f: &Footage, w: u32, h: u32) -> Buf {
let s = self.opts.scale;
let k = if img.width > 0 { w.max(1) as f64 / img.width as f64 } else { 1.0 };
// Keep native pixels when downsampling is small; resample otherwise.
let mut buf = match self.reduced_size(w, h) {
// The source resampled it already (`FootageSource::frame_at_size`).
Some(size) if (img.width, img.height) == size => Buf { img: img.clone(), offset: [0.0; 2], scale: s },
Some((rw, rh)) if k <= 1.0 + 1e-9 => Buf { img: effectcraft_raster::resample(img, rw, rh), offset: [0.0; 2], scale: s },
_ => Buf { img: img.clone(), offset: [0.0; 2], scale: 1.0 / k },
};
if let Some(c) = self.pipe.media_in(f) {
color::convert(&mut buf.img, &c);
}
buf
}
/// The size of `w` × `h` layer pixels in this render when it is reduced enough that footage
/// is resampled to it (below 3/4 scale).
fn reduced_size(&self, w: u32, h: u32) -> Option<(u32, u32)> {
let s = self.opts.scale;
(s < 0.75).then(|| (((w as f64 * s).round() as u32).max(1), ((h as f64 * s).round() as u32).max(1)))
}
/// Authored colours into the working space (linear working spaces).
fn authored(&self, mut buf: Buf) -> Buf {
if let Some(c) = self.pipe.authored_in() {
color::convert(&mut buf.img, &c);
}
buf
}
/// A footage frame at source time `t`, frame-blended between the two nearest source frames
/// when the layer's Frame Blending switch (and the comp's Enable Frame Blending) is on and
/// `t` falls between frames (footage rate ≠ comp rate, time stretch, time remapping).
/// `size`: a plain frame may come resampled to it ([`FootageSource::frame_at_size`]).
fn footage_frame(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &Footage, t: Tick, size: Option<(u32, u32)>) -> Option<Arc<Image>> {
// Interpret Footage ▸ Separate Fields: each field is a frame at twice the rate.
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 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 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) }
}
fn footage_frame_raw(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &Footage, t: Tick, size: Option<(u32, u32)>) -> Option<Arc<Image>> {
let mode = frame_blend_mode(ctx, layer);
let float = self.pipe.float_curve(f);
if mode != FrameBlend::Off && matches!(f.kind, FootageKind::Video | FootageKind::Sequence) {
let (i, w) = frame_position(t, f.frame_rate);
if w > 1e-6 {
// Blended frames are cached (Pixel Motion is expensive).
let key = self.cache.map(|_| {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
(0x00f8_a3e5_u64, item, i, (w as f32).to_bits(), mode as u8, f.frame_rate.num, f.frame_rate.den, &f.path, float).hash(&mut h);
h.finish()
});
if let (Some(c), Some(k)) = (self.cache, key)
&& let Some(b) = c.get(k)
{
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);
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) {
c.insert(k, Arc::new(Buf { img: img.clone(), offset: [0.0; 2], scale: 1.0 }));
}
return Some(Arc::new(img));
}
}
}
self.footage.frame_with(item, f, t, size, float)
}
/// Source → masks, clamped/quantised to the project depth.
fn masked_source(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Buf> {
let mut buf = match self.content_times(ctx, layer) {
Some(times) => self.blurred_source(ctx, layer, &times)?,
None => self.source(ctx, layer)?,
};
// A solid is filled with a quantised colour: only masks can take it off the grid.
let solid = matches!(layer.source, LayerSource::Solid { .. });
if masks::apply(ctx, layer, &mut buf, self.mask_blur(ctx, layer)) || !solid {
self.pipe.quantize(&mut buf.img);
}
Some(buf)
}
/// Fully processed layer buffer (source → masks → effects → layer styles, flattened).
/// Served from the layer cache when the layer's content key matches.
pub fn layer_buf(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Arc<Buf>> {
self.layer_buf_keyed(ctx, layer).map(|(b, _)| b)
}
/// [`Self::layer_buf`] in the blending space (linear with Blend Colors Using 1.0 Gamma).
pub fn blend_layer_buf(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Arc<Buf>> {
let (b, k) = self.layer_buf_keyed(ctx, layer)?;
Some(self.to_blend(b, k))
}
/// A buffer converted to the blending space (cached under a key derived from `key`).
pub(crate) fn to_blend(&self, b: Arc<Buf>, key: Option<u64>) -> Arc<Buf> {
let Some(c) = self.pipe.to_blend() else { return b };
let key = key.map(|k| cache::derive(k, 0x1ea7));
if let (Some(cache), Some(k)) = (self.cache, key)
&& let Some(x) = cache.get(k)
{
return x;
}
let mut nb = (*b).clone();
color::convert(&mut nb.img, &c);
let nb = Arc::new(nb);
// The buffer the cache keeps (another worker's, if it kept one first).
match (self.cache_to_keep(), key) {
(Some(cache), Some(k)) => cache.insert(k, nb),
_ => nb,
}
}
fn layer_buf_keyed(&self, ctx: &EvalCtx, layer: &Layer) -> Option<(Arc<Buf>, Option<u64>)> {
let st = self.styled(ctx, layer, None)?;
if st.passes.is_empty() {
return Some((st.body, st.key));
}
let key = st.key.map(|k| cache::derive(k, 0xf1a7));
if let (Some(c), Some(k)) = (self.cache, key)
&& let Some(b) = c.get(k)
{
return Some((b, key));
}
let flat = Arc::new(styles::Styled { passes: st.passes.iter().map(|(b, m)| ((**b).clone(), *m)).collect(), body: (*st.body).clone() }.flatten());
// The buffer the cache keeps (another worker's, if it kept one first).
let flat = match (self.cache_to_keep(), key) {
(Some(c), Some(k)) => c.insert(k, flat),
_ => flat,
};
Some((flat, key))
}
/// A layer's source pixels at the context time, before masks, effects and the transform
/// (what the motion tracker analyses, like After Effects' Layer panel). `None` for layers
/// without pixels.
pub fn layer_source(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Buf> {
self.source(ctx, layer)
}
/// Layer pixels after source → masks → effects (no layer styles).
pub fn content_buf(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Arc<Buf>> {
self.content_buf_timed(ctx, layer, None).map(|(b, _)| b)
}
pub(crate) fn content_buf_timed(&self, ctx: &EvalCtx, layer: &Layer, mut timing: Option<&mut LayerTiming>) -> Option<(Arc<Buf>, Option<u64>)> {
let key = self.cache.and_then(|_| cache::layer_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft, self.mb_on(ctx, layer)));
let key = self.content_key(ctx, layer, key);
if let (Some(c), Some(k)) = (self.cache, key)
&& let Some(b) = c.get(k)
{
if let Some(t) = timing.as_deref_mut() {
t.cached = true;
}
return Some((b, key));
}
let buf = self.masked_source(ctx, layer)?;
let buf = Arc::new(self.apply_effects_timed(ctx, layer, buf, false, usize::MAX, timing.map(|t| &mut t.effects)));
// The buffer the cache keeps (another worker's, if it kept one first).
let buf = match (self.cache_to_keep(), key) {
(Some(c), Some(k)) => c.insert(k, buf),
_ => buf,
};
Some((buf, key))
}
/// A layer's input at the context time: source → masks → its first `effects` effects
/// (what Time effects read at neighbouring times), in effect space (see [`effect_bounds`]):
/// what effect `effects` sees. Served from / stored in the layer cache under its own key,
/// so scrubbing reuses frames rendered for earlier output frames.
pub fn layer_input(&self, ctx: &EvalCtx, layer: &Layer, effects: usize) -> Option<Arc<Buf>> {
let key = self.cache.and_then(|_| cache::input_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft, self.mb_on(ctx, layer), effects));
let key = self.content_key(ctx, layer, key);
if let (Some(c), Some(k)) = (self.cache, key)
&& let Some(b) = c.get(k)
{
return Some(b);
}
let mut buf = self.masked_source(ctx, layer)?;
let origin = ctx.effect_bounds(layer).1;
buf.rebase(origin);
if effects > 0 {
buf = self.effect_space_stack(ctx, layer, buf, false, effects, None, origin);
}
let buf = Arc::new(buf);
// The buffer the cache keeps (another worker's, if it kept one first).
let buf = match (self.cache_to_keep(), key) {
(Some(c), Some(k)) => c.insert(k, buf),
_ => buf,
};
Some(buf)
}
}
+64
View File
@@ -6,6 +6,25 @@ use effectcraft_time::{FrameRate, Tick};
use crate::render_frame;
mod audio_fx;
mod auto;
mod aux_channels;
mod collapse;
mod color;
mod footage_size;
mod frame_blend;
mod guides;
mod mask_blur;
mod mask_expansion;
mod mask_feather;
mod motion_tile;
mod nested;
mod paint;
mod remap;
mod roi;
mod styles;
mod time;
fn setup() -> (Project, ItemId, Comp) {
let mut p = Project::default();
// Exact float maths; 8/16 bpc quantisation has its own tests (tests_color).
@@ -254,6 +273,22 @@ fn effects_run_in_pipeline() {
assert!(c[1] > 0.99 && c[0] < 0.01, "{c:?}");
}
/// After Effects 2026, sRGB working space (not linearised), Tint at its defaults on full-frame
/// solids: the effect sees the encoded solid colour and weights it Rec. 601.
#[test]
fn tint_in_an_srgb_working_space_matches_after_effects_greys() {
for (col, want) in [([1.0, 0.0, 0.0], 76.0), ([0.0, 1.0, 0.0], 150.0), ([0.0, 0.0, 1.0], 29.0), ([0.5, 0.5, 0.5], 128.0)] {
let (mut p, cid, comp) = setup();
p.settings.working_space = Some(effectcraft_color::ColorSpace::Srgb);
let mut l = solid(&mut p, &comp, col, 200, 100);
add_effect(&mut p, &mut l, "ec.color.tint", [200.0, 100.0], &[]);
p.comp_mut(cid).unwrap().layers.push(l);
let o = render_frame(&p, cid, Tick::ZERO, 1.0).get(100, 50);
let want = want / 255.0;
assert!(o[..3].iter().all(|v| (v - want).abs() <= 1.0 / 255.0), "{col:?}: {o:?} vs {want}");
}
}
/// Card Dance's Comp Camera through the default comp camera reproduces a 2D layer in place.
#[test]
fn card_dance_comp_camera_matches_the_default_view() {
@@ -1010,3 +1045,32 @@ fn a_selected_layer_reading_the_comp_scene_invalidates_its_owner() {
let (a, b) = (particles_x(&here), particles_x(&panned));
assert!(b < a - 20.0, "the blended particles follow the camera: {a} -> {b}");
}
/// #1812: preview workers that render a layer at once end up with one buffer (the one kept
/// first), so the GPU compositor uploads and packs it once, not once per worker.
#[test]
fn workers_rendering_a_layer_at_once_share_one_buffer() {
let (mut p, cid, comp) = setup();
let mut l = solid(&mut p, &comp, [0.2, 0.5, 0.8], 1600, 1200);
add_effect(&mut p, &mut l, "ec.blur.gaussian", [1600.0, 1200.0], &[("blurriness", Value::Scalar(60.0))]);
p.comp_mut(cid).unwrap().layers.push(l);
let cache = crate::LayerCache::default();
let start = std::sync::Barrier::new(8);
let bufs: Vec<_> = std::thread::scope(|s| {
let hs: Vec<_> = (0..8)
.map(|_| {
s.spawn(|| {
let mut r = crate::Renderer::new(&p, &crate::NoFootage, crate::RenderOpts::default());
r.cache = Some(&cache);
let c = p.comp(cid).unwrap();
let ctx = r.eval_ctx(cid, Tick::ZERO).unwrap();
start.wait();
r.layer_buf(&ctx, &c.layers[0]).unwrap()
})
})
.collect();
hs.into_iter().map(|h| h.join().unwrap()).collect()
});
assert!(bufs.iter().all(|b| std::sync::Arc::ptr_eq(b, &bufs[0])), "one shared buffer");
assert_eq!(cache.stats().entries, 1);
}
@@ -9,31 +9,31 @@ use effectcraft_time::{FrameRate, Tick};
use crate::{Accelerator, AutoKey, AutoPick, Backend, NoFootage, RenderOpts, Renderer};
/// An accelerator that takes `delay` per frame (or declines with `handle = false`) and keeps
/// Auto timing history.
struct TimedAccel {
delay: std::time::Duration,
/// An accelerator that returns a representative pixel (or declines) and keeps Auto history.
struct MockAccel {
handle: bool,
calls: AtomicUsize,
pick: AutoPick,
}
impl TimedAccel {
fn new(ms: u64, handle: bool) -> TimedAccel {
TimedAccel { delay: std::time::Duration::from_millis(ms), handle, calls: Default::default(), pick: Default::default() }
impl MockAccel {
/// Construct a renderer stand-in without making assumptions about scheduling or speed.
fn new(handle: bool) -> MockAccel {
MockAccel { handle, calls: Default::default(), pick: Default::default() }
}
/// Number of full-frame requests dispatched to this accelerator.
fn calls(&self) -> usize {
self.calls.load(Ordering::SeqCst)
}
}
impl Accelerator for TimedAccel {
impl Accelerator for MockAccel {
fn name(&self) -> String {
"timed".into()
}
/// Count dispatched frames and return the same centre pixel as the CPU fixture.
fn comp_frame(&self, _r: &Renderer, _comp: ItemId, _t: Tick) -> Option<crate::Image> {
self.calls.fetch_add(1, Ordering::SeqCst);
std::thread::sleep(self.delay);
// The centre of the comp, as the CPU draws it (a red solid).
self.handle.then(|| {
let mut img = crate::Image::new(200, 100);
@@ -52,6 +52,7 @@ impl Accelerator for TimedAccel {
}
}
/// A small red solid whose centre pixel is shared by the CPU and mock GPU fixtures.
fn project() -> (Project, ItemId) {
let mut p = Project::default();
p.settings.gpu_acceleration = true;
@@ -64,7 +65,7 @@ fn project() -> (Project, ItemId) {
}
/// Render `n` frames with `backend`; how many the accelerator was asked for.
fn frames(p: &Project, cid: ItemId, a: &TimedAccel, backend: Backend, n: usize) -> usize {
fn frames(p: &Project, cid: ItemId, a: &MockAccel, backend: Backend, n: usize) -> usize {
let before = a.calls();
for _ in 0..n {
let mut r = Renderer::new(p, &NoFootage, RenderOpts { backend, ..Default::default() });
@@ -76,50 +77,60 @@ fn frames(p: &Project, cid: ItemId, a: &TimedAccel, backend: Backend, n: usize)
a.calls() - before
}
/// Drive the production selector with measured costs supplied by the test, not the OS clock.
fn measured_frames(pick: &AutoPick, key: AutoKey, cpu_ms: f64, gpu_ms: f64, n: usize) -> usize {
(0..n)
.filter(|_| {
let gpu = pick.choose(key);
pick.record(key, gpu, if gpu { gpu_ms } else { cpu_ms });
gpu
})
.count()
}
/// A slower GPU is periodically re-probed; explicit GPU and Software Only ignore that history.
#[test]
fn auto_backend_moves_light_comps_to_the_cpu() {
// A light comp on a slow accelerator: after the warm-up (three frames on
// each side) Auto renders on the CPU, re-measuring the accelerator now and then.
let (p, cid) = project();
// Several times the CPU's frame time (unoptimised test builds are slow).
let t0 = std::time::Instant::now();
for _ in 0..3 {
Renderer::new(&p, &NoFootage, RenderOpts::default()).comp_frame(cid, Tick::ZERO);
}
let cpu_ms = t0.elapsed().as_millis() as u64 / 3;
let slow = TimedAccel::new((cpu_ms * 5).max(20), true);
let slow = MockAccel::new(true);
let key = AutoKey::new(cid, 1.0, false);
let n = 6 + 2 * AutoPick::REPROBE as usize;
let asked = frames(&p, cid, &slow, Backend::Auto, n);
let asked = measured_frames(&slow.pick, key, 1.0, 20.0, n);
assert!((3..=5).contains(&asked), "asked the slow accelerator {asked} times in {n} frames");
let s = slow.pick.stats(AutoKey::new(cid, 1.0, false)).unwrap();
let s = slow.pick.stats(key).unwrap();
assert!(s.gpu_ms.unwrap() > s.cpu_ms.unwrap(), "{s:?}");
// Backend::Gpu ignores the history; Software Only never asks.
// Check renderer wiring with known history before a real measurement can change it.
assert_eq!(frames(&p, cid, &slow, Backend::Auto, 1), 0);
assert_eq!(frames(&p, cid, &slow, Backend::Gpu, 3), 3);
let mut sw = p.clone();
sw.settings.gpu_acceleration = false;
assert_eq!(frames(&sw, cid, &slow, Backend::Auto, 3), 0);
}
/// Faster, tied and slightly noisier GPU costs stay on the GPU except during warm-up/probes.
#[test]
fn auto_backend_keeps_fast_accelerators() {
// As fast as the CPU (ties stay on the accelerator): every frame but the CPU warm-up and
// re-probes.
let (p, cid) = project();
let fast = TimedAccel::new(0, true);
let asked = frames(&p, cid, &fast, Backend::Auto, 40);
assert!(asked >= 36, "asked {asked} of 40");
for gpu_ms in [1.0, 5.0, 5.5] {
let fast = MockAccel::new(true);
let asked = measured_frames(&fast.pick, AutoKey::new(cid, 1.0, false), 5.0, gpu_ms, 40);
assert!(asked >= 36, "asked {asked} of 40 for GPU cost {gpu_ms}");
assert_eq!(frames(&p, cid, &fast, Backend::Auto, 1), 1);
}
}
/// A declining accelerator is retried only periodically while the CPU supplies frames.
#[test]
fn auto_backend_falls_back_when_the_accelerator_declines() {
let (p, cid) = project();
let declining = TimedAccel::new(0, false);
let declining = MockAccel::new(false);
let n = 2 * AutoPick::REPROBE as usize;
let asked = frames(&p, cid, &declining, Backend::Auto, n);
// Asked first, then only on the periodic re-probes.
assert!((1..=4).contains(&asked), "asked {asked} times in {n} frames");
}
/// Periodic probes detect a formerly slow GPU becoming faster, without mixing cache keys.
#[test]
fn auto_pick_follows_changing_costs() {
let pick = AutoPick::default();
@@ -81,3 +81,28 @@ fn reduced_renders_take_footage_resampled_by_the_source() {
Renderer::new(&p, &sized, RenderOpts { scale: 0.5, ..Default::default() }).comp_frame(cid, Tick::ZERO);
assert!(sized.sizes.unwrap().into_inner().unwrap().is_empty());
}
/// #1812: a still's layer is kept in the layer cache across times (its texture then uploads to
/// the GPU once, not every frame); a movie's is fetched again at every time.
#[test]
fn still_layers_are_cached_across_times_and_movies_are_not() {
for (kind, fetches) in [(FootageKind::Still, 1), (FootageKind::Video, 3)] {
let (mut p, cid) = project(BitDepth::Bpc8, false);
let Some(LayerSource::Footage { item }) = p.comp(cid).map(|c| c.layers[0].source.clone()) else { panic!("footage layer") };
if let Some(ItemKind::Footage(f)) = p.item_mut(item).map(|it| &mut it.kind) {
f.kind = kind;
}
let cache = crate::LayerCache::default();
let src = Clip { sizes: Some(Mutex::default()) };
let opts = RenderOpts { scale: 0.5, ..Default::default() };
for t in [0.0, 0.25, 0.5] {
let t = Tick::from_seconds_f64(t);
let mut r = Renderer::new(&p, &src, opts);
r.cache = Some(&cache);
let frame = r.comp_frame(cid, t);
assert_eq!(frame, Renderer::new(&p, &src, opts).comp_frame(cid, t), "{kind:?} at {t:?}");
}
// (each uncached render above fetches once more)
assert_eq!(src.sizes.unwrap().into_inner().unwrap().len(), fetches + 3, "{kind:?}");
}
}
@@ -41,6 +41,31 @@ fn alpha_at_25(p: &Project, cid: ItemId, motion_blur: bool) -> f32 {
Renderer::new(p, &NoFootage, RenderOpts { motion_blur, ..Default::default() }).comp_frame(cid, half).get(25, 50)[3]
}
/// A mask that holds from frame 0 to 2, then moves: frames 1 and 2 have the same mask value, but at
/// frame 2 the shutter already sees it move. The layer cache must not serve frame 1's layer there.
#[test]
fn mask_motion_blur_keys_the_layer_cache_by_time() {
let (mut p, cid) = project(true);
let rect = |x: f64| Value::Path(ShapePath::rect([x, 50.0], 20.0, 100.0));
let l = &mut p.comp_mut(cid).unwrap().layers[0];
let Some(Node::Group(m)) = l.props.sub_mut("masks").unwrap().children.first_mut() else { panic!("mask") };
m.get_mut("path").unwrap().keys = vec![
Keyframe::new(Tick::ZERO, rect(20.0)),
Keyframe::new(FrameRate::FPS_30.tick_of(2), rect(20.0)),
Keyframe::new(FrameRate::FPS_30.tick_of(3), rect(60.0)),
];
let cache = crate::LayerCache::default();
let render = |t: Tick, cached: bool| {
let mut r = Renderer::new(&p, &NoFootage, RenderOpts { motion_blur: true, ..Default::default() });
r.cache = cached.then_some(&cache);
r.comp_frame(cid, t)
};
let (f1, f2) = (FrameRate::FPS_30.tick_of(1), FrameRate::FPS_30.tick_of(2));
assert_ne!(render(f1, false), render(f2, false), "the shutter at frame 2 sees the move");
render(f1, true);
assert_eq!(render(f2, true), render(f2, false));
}
#[test]
fn mask_motion_blur_follows_the_render_and_the_comp() {
let (p, cid) = project(true);
+169
View File
@@ -0,0 +1,169 @@
//! Motion Tile's Output Width / Height on adjustment layers and on full-frame layers, with a
//! Transform after it that scales the tiled result into view (#1727, #1991).
use effectcraft_color::Label;
use effectcraft_keyframe::Value;
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{Comp, ItemId, ItemKind, Layer, LayerSource, Project, Solid};
use effectcraft_time::{FrameRate, Tick};
use crate::{Image, render_frame};
const W: u32 = 200;
const H: u32 = 100;
fn solid(p: &mut Project, comp: &Comp, color: [f32; 3], w: u32, h: u32, pos: [f64; 2]) -> Layer {
let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect: 1.0 }));
let mut l = build::layer(p, comp, "Solid", LayerSource::Solid { item: sid }, (w, h), None);
l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([pos[0], pos[1], 0.0]);
l
}
/// A white frame with a red 20×20 square in its top-left corner.
fn picture(p: &mut Project, comp: &Comp) -> Vec<Layer> {
vec![solid(p, comp, [1.0, 0.0, 0.0], 20, 20, [10.0, 10.0]), solid(p, comp, [1.0, 1.0, 1.0], W, H, [100.0, 50.0])]
}
/// Motion Tile (Output `out` %) then Transform (Scale `scale` %), on `l`.
fn tile_then_scale(p: &mut Project, l: &mut Layer, out: f64, scale: f64) {
let size = [W as f64, H as f64];
for (id, vals) in [
("ec.stylize.motiontile", vec![("outputWidth", Value::Scalar(out)), ("outputHeight", Value::Scalar(out))]),
("ec.distort.transform", vec![("scaleHeight", Value::Scalar(scale)), ("scaleWidth", Value::Scalar(scale))]),
] {
let spec = effectcraft_effects::find(id).unwrap();
let mut next = p.next_id;
let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, size);
p.next_id = next;
for (k, v) in vals {
g.prop_mut(k).unwrap().value = v;
}
l.props.sub_mut("effects").unwrap().children.push(g.into());
}
}
fn comp() -> (Project, ItemId, Comp) {
let mut p = Project::default();
p.settings.bit_depth = effectcraft_project::BitDepth::Bpc32;
let comp = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
(p, cid, comp)
}
/// The picture under an adjustment layer carrying the two effects.
fn adjustment_scene(out: f64, scale: f64) -> (Project, ItemId) {
let (mut p, cid, comp) = comp();
let mut adj = solid(&mut p, &comp, [1.0, 1.0, 1.0], W, H, [100.0, 50.0]);
adj.switches.adjustment = true;
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, cid)
}
/// The picture as a full-frame precomp layer carrying the two effects.
fn layer_scene(out: f64, scale: f64) -> (Project, ItemId) {
let (mut p, cid, comp) = comp();
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;
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);
(p, cid)
}
fn is(img: &Image, x: i64, y: i64, want: [f32; 4], what: &str) {
let got = img.get(x, y);
assert!(got.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.02), "{what}: ({x}, {y}) is {got:?}, want {want:?}");
}
const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0];
const WHITE: [f32; 4] = [1.0; 4];
const CLEAR: [f32; 4] = [0.0; 4];
/// Output 300 % then Scale 50 % about the frame centre: comp pixel (x, y) shows the tiled frame
/// at (100 + 2(x − 100), 50 + 2(y − 50)), so the red square repeats every 100 × 50 pixels and
/// the frame is covered edge to edge, the tiles from beyond the frame included.
fn assert_tiled(img: &Image, what: &str) {
for (x, y) in [(55, 30), (155, 30), (55, 80), (155, 80)] {
is(img, x, y, RED, what);
}
for (x, y) in [(5, 5), (195, 5), (5, 95), (195, 95), (100, 50), (100, 5)] {
is(img, x, y, WHITE, what);
}
}
#[test]
fn adjustment_motion_tile_output_spreads_tiles_past_the_frame() {
let (p, cid) = adjustment_scene(300.0, 50.0);
assert_tiled(&render_frame(&p, cid, Tick::ZERO, 1.0), "adjustment, Output 300 %");
}
#[test]
fn layer_motion_tile_output_spreads_tiles_past_the_frame() {
let (p, cid) = layer_scene(300.0, 50.0);
assert_tiled(&render_frame(&p, cid, Tick::ZERO, 1.0), "full-frame layer, Output 300 %");
}
/// Output under 100 % crops: only the centre 50 % of the frame is left, the rest is transparent
/// (After Effects shows the comp background there).
#[test]
fn adjustment_motion_tile_output_under_100_crops_the_frame() {
let (p, cid) = adjustment_scene(50.0, 100.0);
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
is(&img, 100, 50, WHITE, "inside the kept centre");
is(&img, 60, 30, WHITE, "inside the kept centre");
for (x, y) in [(5, 5), (10, 10), (195, 95), (45, 50), (100, 20)] {
is(&img, x, y, CLEAR, "outside the kept centre");
}
}
/// The After Effects 2026 frames in #1727: a 1920×1080 red solid under an adjustment layer with
/// Motion Tile (Output 910 × 340 %, then 90 × 340 %) and Transform (Scale 12 %, Position 1361, 540).
/// The tiled frame, 9.1 × 3.4 comps scaled to 12 %, is a 2097 × 441 band centred on (1361, 540):
/// x 313–1920 (cut by the frame), y 320–760; at 90 % a 207-pixel column, x 1257–1465.
#[test]
fn adjustment_motion_tile_matches_the_after_effects_frames() {
for (out_w, x0, x1) in [(910.0, 313.0, 1920.0), (90.0, 1257.0, 1465.0)] {
let mut p = Project::default();
p.settings.bit_depth = effectcraft_project::BitDepth::Bpc32;
let comp = Comp::new(1920, 1080, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
let red = solid(&mut p, &comp, [1.0, 0.0, 0.0], 1920, 1080, [960.0, 540.0]);
let mut adj = solid(&mut p, &comp, [1.0, 1.0, 1.0], 1920, 1080, [960.0, 540.0]);
adj.switches.adjustment = true;
for (id, vals) in [
("ec.stylize.motiontile", vec![("outputWidth", Value::Scalar(out_w)), ("outputHeight", Value::Scalar(340.0))]),
(
"ec.distort.transform",
vec![("scaleHeight", Value::Scalar(12.0)), ("scaleWidth", Value::Scalar(12.0)), ("position", Value::Vec2([1361.0, 540.0]))],
),
] {
let spec = effectcraft_effects::find(id).unwrap();
let mut next = p.next_id;
let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [1920.0, 1080.0]);
p.next_id = next;
for (k, v) in vals {
g.prop_mut(k).unwrap().value = v;
}
adj.props.sub_mut("effects").unwrap().children.push(g.into());
}
p.comp_mut(cid).unwrap().layers = vec![adj, red];
// 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);
let what = format!("Output {out_w} %");
for (x, y) in [(x0 + 8.0, 540.0), (x1 - 8.0, 540.0), (1361.0, 328.0), (1361.0, 752.0)] {
assert_eq!(at(x, y), RED, "{what}: ({x}, {y}) is red");
}
for (x, y) in [(x0 - 8.0, 540.0), (1361.0, 312.0), (1361.0, 768.0), (100.0, 100.0)] {
assert_eq!(at(x, y), CLEAR, "{what}: ({x}, {y}) is empty");
}
if x1 < 1920.0 {
assert_eq!(at(x1 + 8.0, 540.0), CLEAR, "{what}: right of the column");
}
}
}
@@ -475,3 +475,35 @@ fn gradient_glow_uses_gradient() {
let px = img.straight(121, 50);
assert!(px[0] > px[2], "near the edge the glow takes the gradient's start colour {px:?}");
}
/// #1812 (review of #1928): a styled layer rendered by several workers at once shares its body and
/// style passes, as unstyled layers share their buffer.
#[test]
fn workers_styling_a_layer_at_once_share_its_buffers() {
let (p, cid) = scene([1.0; 3], None, |p, c, l| {
add(p, c, l, "dropShadow");
add(p, c, l, "stroke");
});
let cache = crate::LayerCache::default();
let start = std::sync::Barrier::new(8);
let styled: Vec<_> = std::thread::scope(|s| {
let hs: Vec<_> = (0..8)
.map(|_| {
s.spawn(|| {
let mut r = Renderer::new(&p, &NoFootage, RenderOpts::default());
r.cache = Some(&cache);
let ctx = r.eval_ctx(cid, Tick::ZERO).unwrap();
start.wait();
r.styled_layer(&ctx, &p.comp(cid).unwrap().layers[0]).unwrap()
})
})
.collect();
hs.into_iter().map(|h| h.join().unwrap()).collect()
});
let first = &styled[0];
assert!(!first.passes.is_empty(), "the drop shadow is a pass");
for st in &styled {
assert!(std::sync::Arc::ptr_eq(&st.body, &first.body), "one body");
assert!(st.passes.iter().zip(&first.passes).all(|(a, b)| std::sync::Arc::ptr_eq(&a.0, &b.0)), "one set of passes");
}
}
+86 -18
View File
@@ -12,7 +12,7 @@ use effectcraft_geom::{Mat3, Mat4, Vec3, vec2, vec3};
use effectcraft_keyframe::{Justify, TextDoc, Value};
use effectcraft_path::{BezPath, FillRule, StrokeStyle};
use effectcraft_project::{Layer, PropGroup};
use effectcraft_raster::Image;
use effectcraft_raster::{Image, Mask};
use effectcraft_text::path_text::{self, PathMeasure};
use effectcraft_text::selectors::{self, BasedOn, Mode, Range, Shape, Wiggly};
use effectcraft_text::{CharGlyph, TextLayout, char_glyph_style, layout_doc};
@@ -187,6 +187,14 @@ pub fn range_value_of(f: f64, offset: f64, units_index: bool, n: usize) -> f64 {
if units_index { (v * n as f64).round() } else { v * 100.0 }
}
/// Whether property `uid` of `layer` is a Range Selector's Start, End or Offset while its Units
/// are Index: the value is then a count of characters (words, lines), not a percentage.
pub fn in_index_units(layer: &Layer, uid: effectcraft_project::Uid) -> bool {
let Some(sel) = layer.props.parent_of(uid).filter(|g| g.match_id == "rangeSelector") else { return false };
let is_range_value = sel.props().any(|p| p.uid == uid && matches!(p.match_id.as_str(), "start" | "end" | "offset"));
is_range_value && sel.prop("advanced/units").is_some_and(|u| u.value == Value::Enum(1))
}
/// Selection of every glyph by an animator's selectors, per dimension.
pub fn animator_selection(ctx: &EvalCtx, layer: &Layer, anim: &PropGroup, lay: &TextLayout) -> Vec<[f64; 3]> {
let sels: Vec<&PropGroup> = anim.sub("selectors").map(|s| s.groups().filter(|g| g.enabled).collect()).unwrap_or_default();
@@ -479,6 +487,9 @@ pub struct PlacedGlyph {
pub fill: [f32; 4],
pub stroke: [f32; 4],
pub stroke_width: f64,
/// Faux Bold (or synthetic bold for a family without a bold face): how far the outline grows
/// on every side, in character space (0 when not bold).
pub bold: f64,
/// The character's style fills it (Character panel fill on).
pub apply_fill: bool,
/// Index of the character in the text.
@@ -498,8 +509,30 @@ impl PlacedGlyph {
pub fn path(&self) -> BezPath {
effectcraft_path::transform(std::slice::from_ref(&self.local), &self.m2()).remove(0)
}
/// [`Self::bold`] in layer space.
fn bold_in_layer(&self) -> f64 {
self.bold * self.m2().mean_scale()
}
/// Whether [`Self::bold`] grows the outline as evenly in layer space as in character space:
/// no bold, or a matrix that only rotates and scales uniformly.
fn bold_is_even(&self) -> bool {
let [[a, b, _], [c, d, _], _] = self.m2().0;
let n = a * a + b * b + c * c + d * d;
self.bold == 0.0 || ((a * a + c * c - b * b - d * d).abs() <= 1e-9 * n && (a * b + c * d).abs() <= 1e-9 * n)
}
}
/// How far a circle of radius `r` reaches along x and along y once mapped by `m`.
fn axis_reach(m: &Mat3, r: f64) -> [f64; 2] {
let [[a, b, _], [c, d, _], _] = m.0;
[r * a.hypot(b), r * c.hypot(d)]
}
/// How far Faux Bold grows a glyph's outline on every side, as a fraction of its size: Inter
/// Regular grown by this has about the stems of Inter Bold, so synthetic bold stands in for a
/// missing bold face.
const FAUX_BOLD_RADIUS: f64 = 0.03;
/// Laid-out, animated text of a layer at the context time.
pub struct TextGeom {
pub doc: TextDoc,
@@ -777,6 +810,7 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option<TextGeom> {
fill,
stroke,
stroke_width: (base_stroke_w + x.stroke_width).max(0.0),
bold: if g.synth_bold { FAUX_BOLD_RADIUS * g.size } else { 0.0 },
apply_fill: st.apply_fill,
char_index: g.char_index,
rest,
@@ -826,17 +860,41 @@ fn passes(geom: &TextGeom) -> Vec<Pass> {
v
}
/// Coverage of the outline grown by `d` on every side (shrunk when `d` is negative): the fill
/// merged with, or cut by, a round stroke `2|d|` wide, which keeps the edges anti-aliased.
fn grown_coverage(paths: &[BezPath], mm: &Mat3, w: u32, h: u32, d: f64) -> Mask {
let mut cov = effectcraft_path::fill_coverage(paths, mm, w, h, FillRule::NonZero);
if d.abs() < 1e-9 {
return cov;
}
let st = StrokeStyle { width: 2.0 * d.abs(), join: effectcraft_path::Join::Round, ..Default::default() };
let ring = effectcraft_path::stroke_coverage(paths, &st, mm, w, h);
for (c, r) in cov.data.iter_mut().zip(&ring.data) {
*c = if d > 0.0 { c.max(*r) } else { c.min(1.0 - r) };
}
cov
}
/// Rasterize one pass of a glyph — or of a run of glyphs painted alike, as one outline — (paths
/// mapped into target pixels by `m`) into a premultiplied patch over the target rect, blurred
/// when the character has Blur.
fn raster_pass(g: &PlacedGlyph, stroke: bool, paths: &[BezPath], m: &Mat3, rect: [i64; 4], blur_px: [f64; 2]) -> Image {
/// when the character has Blur. `bold` is the glyph's Faux Bold in the paths' space.
fn raster_pass(g: &PlacedGlyph, stroke: bool, paths: &[BezPath], m: &Mat3, rect: [i64; 4], blur_px: [f64; 2], bold: f64) -> Image {
let (w, h) = ((rect[2] - rect[0]) as u32, (rect[3] - rect[1]) as u32);
let mm = Mat3::translate(vec2(-rect[0] as f64, -rect[1] as f64)) * *m;
let cov = if stroke {
let cov = if stroke && bold > 0.0 {
// The stroke of the bold outline: the band between it grown and shrunk by half the width.
let half = g.stroke_width / 2.0;
let mut outer = grown_coverage(paths, &mm, w, h, bold + half);
let inner = grown_coverage(paths, &mm, w, h, bold - half);
for (o, i) in outer.data.iter_mut().zip(&inner.data) {
*o = o.min(1.0 - i);
}
outer
} else if stroke {
let st = StrokeStyle { width: g.stroke_width, join: effectcraft_path::Join::Round, ..Default::default() };
effectcraft_path::stroke_coverage(paths, &st, &mm, w, h)
} else {
effectcraft_path::fill_coverage(paths, &mm, w, h, FillRule::NonZero)
grown_coverage(paths, &mm, w, h, bold)
};
let col = if stroke { g.stroke } else { g.fill };
let op = (g.xf.opacity / 100.0).clamp(0.0, 1.0) as f32;
@@ -892,10 +950,16 @@ fn runs(geom: &TextGeom) -> Vec<(Vec<usize>, bool)> {
if g.xf.opacity <= 0.0 {
continue;
}
// A run grows its outline in layer space by one radius, which matches each glyph grown in
// its own space only when the bold stays round there.
if geom.blend == BlendMode::Normal
&& solid_fill(g, stroke)
&& g.bold_is_even()
&& let Some((run, false)) = out.last_mut()
&& run.last().is_some_and(|&p| solid_fill(&geom.glyphs[p], false) && geom.glyphs[p].fill == g.fill)
&& run.last().is_some_and(|&p| {
let q = &geom.glyphs[p];
solid_fill(q, false) && q.fill == g.fill && q.bold_is_even() && (q.bold_in_layer() - g.bold_in_layer()).abs() < 1e-6
})
{
run.push(gi);
continue;
@@ -917,28 +981,31 @@ fn draw_glyphs(geom: &TextGeom, img: &mut Image, to_px: &Mat3, s: f64, only: Opt
// One glyph is drawn from its own outline; a run from its glyphs' outlines in layer
// space, all wound the same way so overlaps add up instead of cancelling (NonZero).
let local = Cow::Borrowed(std::slice::from_ref(&g.local));
let (paths, m): (Cow<[BezPath]>, Mat3) = match (only, run.as_slice()) {
(Some(_), _) => (local, *to_px),
(None, [_]) => (local, *to_px * g.m2()),
let (paths, m, bold): (Cow<[BezPath]>, Mat3, f64) = match (only, run.as_slice()) {
(Some(_), _) => (local, *to_px, g.bold),
(None, [_]) => (local, *to_px * g.m2(), g.bold),
(None, _) => {
let wound = |p: BezPath| if kurbo::Shape::area(&p) < 0.0 { p.reverse_subpaths() } else { p };
(Cow::Owned(run.iter().map(|&gi| wound(geom.glyphs[gi].path())).collect()), *to_px)
(Cow::Owned(run.iter().map(|&gi| wound(geom.glyphs[gi].path())).collect()), *to_px, g.bold_in_layer())
}
};
let Some(b) = effectcraft_path::bounds(&paths) else { continue };
let blur = [g.xf.blur[0] * s, g.xf.blur[1] * s];
let pad = g.stroke_width * m.mean_scale() + 2.0 + blur[0].max(blur[1]) * 1.5;
// Stroke and bold grow the outline in the paths' space: under a non-uniform `m` they reach
// further along one axis than the other.
let [px, py] = axis_reach(&m, g.stroke_width + 2.0 * bold);
let pad = 2.0 + blur[0].max(blur[1]) * 1.5;
let r = m.map_rect(&effectcraft_geom::Rect::new(b.x0, b.y0, b.x1, b.y1));
let rect = [
((r.x0 - pad).floor() as i64).max(0),
((r.y0 - pad).floor() as i64).max(0),
((r.x1 + pad).ceil() as i64).min(w),
((r.y1 + pad).ceil() as i64).min(h),
((r.x0 - px - pad).floor() as i64).max(0),
((r.y0 - py - pad).floor() as i64).max(0),
((r.x1 + px + pad).ceil() as i64).min(w),
((r.y1 + py + pad).ceil() as i64).min(h),
];
if rect[2] <= rect[0] || rect[3] <= rect[1] {
continue;
}
let patch = raster_pass(g, stroke, &paths, &m, rect, blur);
let patch = raster_pass(g, stroke, &paths, &m, rect, blur, bold);
blit(img, &patch, [rect[0], rect[1]], if only.is_some() { BlendMode::Normal } else { geom.blend });
}
}
@@ -950,8 +1017,9 @@ pub fn render(ctx: &EvalCtx, layer: &Layer, s: f64) -> Buf {
let mut bounds: Option<kurbo::Rect> = None;
for g in &geom.glyphs {
if let Some(b) = effectcraft_path::bounds(&[g.path()]) {
let [bx, by] = axis_reach(&g.m2(), 2.0 * g.bold);
let pad = g.stroke_width + 2.0 + g.xf.blur[0].max(g.xf.blur[1]) * 1.5;
let b = b.inflate(pad, pad);
let b = b.inflate(pad + bx, pad + by);
bounds = Some(bounds.map_or(b, |a| a.union(b)));
}
}
@@ -976,7 +1044,7 @@ pub fn per_char_planes(ctx: &EvalCtx, layer: &Layer, s: f64) -> Vec<(Buf, Mat4)>
continue;
}
let Some(b) = effectcraft_path::bounds(std::slice::from_ref(&g.local)) else { continue };
let pad = g.stroke_width + 2.0 + g.xf.blur[0].max(g.xf.blur[1]) * 1.5;
let pad = g.stroke_width + 2.0 * g.bold + 2.0 + g.xf.blur[0].max(g.xf.blur[1]) * 1.5;
let b = b.inflate(pad, pad);
let w = ((b.width() * s).ceil() as u32 + 4).clamp(1, 4096);
let h = ((b.height() * s).ceil() as u32 + 4).clamp(1, 4096);
+77
View File
@@ -99,6 +99,24 @@ fn range_based_on_words_and_lines() {
assert_eq!(s, vec![0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0], "{s:?}");
}
/// With Units = Index a Range Selector's Start, End and Offset are character counts, not
/// percentages (#1814); the selector's other values, and other selectors, are unaffected.
#[test]
fn index_units_mark_start_end_and_offset() {
let (mut p, _cid, comp) = setup(400, 200);
let mut l = text_layer(&mut p, &comp, doc("ABCDEFG", 30.0));
animator(&mut p, &mut l, &["opacity"], &["range", "wiggly"]);
let uid = |l: &Layer, path: &str| l.props.prop(path).unwrap_or_else(|| panic!("no {path}")).uid;
let base = "text/animators/#1/selectors/#1";
let range_values = ["start", "end", "offset"].map(|k| uid(&l, &format!("{base}/{k}")));
assert!(range_values.iter().all(|u| !in_index_units(&l, *u)), "Percentage, the default");
set(&mut l, &format!("{base}/advanced/units"), Value::Enum(1));
assert!(range_values.iter().all(|u| in_index_units(&l, *u)));
assert!(!in_index_units(&l, uid(&l, &format!("{base}/advanced/amount"))), "Amount stays a percentage");
assert!(!in_index_units(&l, uid(&l, "text/animators/#1/properties/opacity")));
assert!(!in_index_units(&l, uid(&l, "transform/opacity")));
}
#[test]
fn range_start_end_and_offset_are_capped() {
let (mut p, cid, comp) = setup(400, 200);
@@ -524,6 +542,7 @@ fn touching_glyphs_join_without_a_seam() {
fill,
stroke: [0.0; 4],
stroke_width: 0.0,
bold: 0.0,
apply_fill: true,
char_index: 0,
rest: [0.0; 2],
@@ -551,6 +570,64 @@ fn touching_glyphs_join_without_a_seam() {
assert!(multiply[3] < 0.9, "{multiply:?}");
}
/// #1676: Faux Bold, and the synthetic bold of a style the family has no face for, are drawn.
#[test]
fn faux_bold_and_synthetic_bold_thicken_the_glyphs() {
// Total alpha and the alpha bounding box of a rendered word.
let ink = |d: TextDoc| {
let (mut p, cid, comp) = setup(400, 120);
let mut l = text_layer(&mut p, &comp, TextDoc { text: "Hamburg".into(), size: 60.0, ..d });
set(&mut l, "transform/position", Value::Vec3([40.0, 85.0, 0.0]));
p.comp_mut(cid).unwrap().layers.push(l);
let img = crate::render_frame(&p, cid, Tick::ZERO, 1.0);
(img.data.iter().map(|px| px[3]).sum::<f32>(), alpha_bbox(&img).unwrap())
};
let (plain, pb) = ink(TextDoc::default());
let (bold, bb) = ink(TextDoc { faux_bold: true, ..Default::default() });
assert!(bold > plain * 1.15, "faux bold adds ink: {plain} → {bold}");
// 0.03 em = 1.8 px on every side.
let grow = (bb[2] - bb[0]) - (pb[2] - pb[0]);
assert!((3..=6).contains(&grow), "and widens the word by about 3.6 px: {pb:?} → {bb:?}");
// Bundled Inter has no Bold Italic face: the italic face is drawn with synthetic bold.
let (italic, _) = ink(TextDoc { style: "Italic".into(), ..Default::default() });
let (bold_italic, _) = ink(TextDoc { style: "Bold Italic".into(), ..Default::default() });
assert!(bold_italic > italic * 1.15, "Bold Italic is bolder than Italic: {italic} → {bold_italic}");
// A stroke follows the bold outline: as wide as before, moved out with it.
let stroked = TextDoc { apply_fill: false, apply_stroke: true, stroke: [1.0; 4], stroke_width: 4.0, ..Default::default() };
let (ring, rb) = ink(stroked.clone());
let (bold_ring, brb) = ink(TextDoc { faux_bold: true, ..stroked });
assert!(bold_ring > ring * 0.95 && bold_ring < ring * 1.3, "the stroke keeps its width: {ring} → {bold_ring}");
assert!(brb[0] < rb[0] && brb[2] > rb[2], "{rb:?} → {brb:?}");
}
/// #1676: under a non-uniform character Scale the bold is grown in the character's space, so it
/// reaches further along the stretched axis; it is neither clipped nor grown evenly in a run.
#[test]
fn faux_bold_under_a_non_uniform_scale() {
use kurbo::Shape;
let glyph = |x0: f64| PlacedGlyph {
local: kurbo::Rect::new(x0, 4.0, x0 + 4.0, 16.0).to_path(0.1),
m: Mat4::scale(vec3(9.0, 1.0, 1.0)),
xf: CharXf::default(),
fill: [1.0; 4],
stroke: [0.0; 4],
stroke_width: 0.0,
bold: 2.0,
apply_fill: true,
char_index: 0,
rest: [0.0; 2],
};
let geom = |glyphs| TextGeom { doc: TextDoc::default(), glyphs, fill_stroke: 0, blend: BlendMode::Normal };
assert_eq!(runs(&geom(vec![glyph(2.0), glyph(6.0)])).len(), 2, "bold glyphs stretched unevenly are drawn one by one");
// x 6..10 → 54..90, grown by 2 × 9 = 18 → 36..108; y 4..16 grown by 2 → 2..18.
let mut img = crate::Image::new(120, 24);
draw_glyphs(&geom(vec![glyph(6.0)]), &mut img, &Mat3::IDENTITY, 1.0, None);
for (x, y) in [(37, 10), (106, 10), (70, 3)] {
assert!(img.get(x, y)[3] > 0.99, "bold ink at ({x}, {y}): {:?}", img.get(x, y));
}
assert!(img.get(34, 10)[3] < 0.01 && img.get(110, 10)[3] < 0.01 && img.get(70, 0)[3] < 0.01);
}
/// #1011: what dragging a Range Selector bar to fraction `f` of the text writes.
#[test]
fn range_bar_drag_values() {
@@ -2,7 +2,7 @@
//! compositor for runs of 3D layers.
//!
//! The 2D compositor (`Renderer::comp_frame`) walks the layer stack bottom-to-top; each run of
//! consecutive 3D layers is handed to [`compose::draw_run`] as a group (2D layers break 3D
//! consecutive 3D layers is handed to `compose::draw_run` as a group (2D layers break 3D
//! groups, as in After Effects).
pub mod adv;
+102 -2
View File
@@ -8,6 +8,9 @@
//! (already sorted back to front) are then shaded per fragment and blended over, depth-tested
//! against the opaque surfaces.
use std::cmp::Reverse;
use std::collections::BinaryHeap;
use rayon::prelude::*;
use super::scene::{Scene, ShadowMap, Vertex};
@@ -422,6 +425,81 @@ fn shadow_raster(s: &Scene, m: &ShadowMap, casters: &[usize]) -> Vec<f32> {
d
}
/// Hard cap on the shadow depth texels one render allocates (f32 depth texels).
/// Quality map sizes derive from the raster resolution, and both the resolution and
/// the light count come from the project, so without a scene-wide budget an
/// arbitrarily large comp with arbitrarily many casting lights (point lights cost
/// six faces) could balloon the allocation. 26 M texels (≈ 104 MB) holds one point
/// light's full 2048-texel cube (6 × 2048² ≈ 25.2 M) and stays under WebGPU's default
/// 128 MiB storage-binding limit; adapters with less fall back to the CPU raster.
pub(crate) const MAX_SHADOW_TEXELS: u64 = 26_000_000;
/// Long side of a quality shadow map: one map texel per output pixel, to a power of
/// two, clamped to 512..4096 (raster sizes are user data and may be absurd). Draft
/// (interactive fast-preview) renders keep the flat 512 budget, unchanged.
pub(crate) fn shadow_map_size(width: u32, height: u32, draft: bool) -> u32 {
if draft {
return 512;
}
let side = width.max(height).clamp(512, 4096);
let mut size = 512u32;
while size < side {
size *= 2;
}
size
}
/// Per-face size of a point light's cube map: the full map size, since each face spans
/// 90° and anything smaller is coarser than a spot light's map in the same comp (a
/// point light low over a floor shows exactly that as stair-steps), capped at 2048 so
/// one cube fits [`MAX_SHADOW_TEXELS`]. Draft keeps half the map size as before.
pub(crate) fn shadow_point_face_size(size: u32, draft: bool) -> u32 {
if draft { (size / 2).max(1) } else { size.min(2048) }
}
/// Per-light map sizes (`None` = this light gets no shadow map). `kinds`: 0 = no
/// shadows (costs nothing), 1 = single map (spot/parallel), 2 = point cube (budgeted
/// as its six faces). Lights start at their full size; while the scene exceeds
/// [`MAX_SHADOW_TEXELS`] the largest map above its floor is halved (power-of-two
/// steps, the earliest light first on ties), and once every light is at its floor the
/// last lights lose their shadows entirely. Deterministic, and O(n log n) in the
/// light count: each light is halved at most a handful of times and dropped once.
pub(crate) fn shadow_sizes(width: u32, height: u32, draft: bool, kinds: &[u32]) -> Vec<Option<u32>> {
let base = shadow_map_size(width, height, draft);
let faces = |k: u32| -> u64 { if k == 2 { 6 } else { 1 } };
let floor = |k: u32| -> u32 { if k == 2 { 128 } else { 512 } };
let mut sizes: Vec<u32> = kinds
.iter()
.map(|&k| match k {
0 => 0,
2 => shadow_point_face_size(base, draft),
_ => base,
})
.collect();
let cost = |z: u32, k: u32| -> u64 { (z as u64) * (z as u64) * faces(k) };
let mut total: u64 = sizes.iter().zip(kinds).map(|(&z, &k)| cost(z, k)).sum();
// Largest size first, then the lowest index.
let mut heap: BinaryHeap<(u32, Reverse<usize>)> =
sizes.iter().zip(kinds).enumerate().filter(|(_, (z, k))| **z > floor(**k)).map(|(i, (&z, _))| (z, Reverse(i))).collect();
while total > MAX_SHADOW_TEXELS {
let Some((z, Reverse(i))) = heap.pop() else { break };
let k = kinds[i];
total -= cost(z, k) - cost(z / 2, k);
sizes[i] = z / 2;
if z / 2 > floor(k) {
heap.push((z / 2, Reverse(i)));
}
}
for i in (0..sizes.len()).rev() {
if total <= MAX_SHADOW_TEXELS {
break;
}
total -= cost(sizes[i], kinds[i]);
sizes[i] = 0;
}
sizes.into_iter().map(|z| (z > 0).then_some(z)).collect()
}
/// Build the shadow maps of every shadow-casting light (lights marked `shadow == 0`).
pub(crate) fn shadow_maps(s: &mut Scene, draft: bool) {
let casters: Vec<usize> = (0..s.indices.len() / 3)
@@ -443,15 +521,37 @@ pub(crate) fn shadow_maps(s: &mut Scene, draft: bool) {
}
let centre = [0, 1, 2].map(|k| (lo[k] + hi[k]) * 0.5);
let radius = ((hi[0] - lo[0]).powi(2) + (hi[1] - lo[1]).powi(2) + (hi[2] - lo[2]).powi(2)).sqrt() * 0.5 + 1.0;
let size = if draft { 512 } else { 1024 };
// Map sizes come from the raster (a quality map gives ~one texel per output
// pixel); the per-light budget keeps the scene allocation bounded because
// comp size and light count are user input (point lights cost six faces).
let kinds: Vec<u32> = s
.lights
.iter()
.map(|l| {
if l.shadow != 0 {
0
} else if l.kind > 1 {
2
} else {
1
}
})
.collect();
let sizes = shadow_sizes(s.width, s.height, draft, &kinds);
let mut maps: Vec<ShadowMap> = vec![];
for li in 0..s.lights.len() {
let Some(size) = sizes.get(li).copied().flatten() else {
s.lights[li].shadow = -1;
continue;
};
if s.lights[li].shadow != 0 {
s.lights[li].shadow = -1;
continue;
}
let l = s.lights[li];
let first = maps.len() as i32;
// Penumbra width in output pixels (the shader turns it into a tap reach at
// the receiver); the sampling bias is deliberately independent of it.
let soft = 1.0 + (l.shadow_diffusion * 0.5).min(24.0);
let pos = l.pos.map(|v| v as f64);
let dir = l.dir.map(|v| v as f64);
@@ -468,7 +568,7 @@ pub(crate) fn shadow_maps(s: &mut Scene, draft: bool) {
maps.push(ShadowMap { view: basis(pos, dir, hint), focal, ortho: false, size, offset: 0, bias: 1.5, radius: soft });
}
_ => {
let fs = size / 2;
let fs = size;
for (f, d) in [
([1.0, 0.0, 0.0], [0.0, 1.0, 0.0]),
([-1.0, 0.0, 0.0], [0.0, 1.0, 0.0]),
+3 -2
View File
@@ -157,7 +157,8 @@ pub struct ShadowMap {
pub offset: u32,
/// Depth bias in texels (scaled by the texel footprint at the receiver).
pub bias: f32,
/// PCF spread (texels).
/// Penumbra width (output pixels) requested by Shadow Diffusion; the shader
/// converts it to a tap reach via the receiver's texel footprint.
pub radius: f32,
}
@@ -879,7 +880,7 @@ fn collapsed_layer<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, item:
let saved = b.outer;
b.outer = saved * ctx.world_matrix(layer);
let t = nctx.time;
let any_solo = nctx.comp.layers.iter().any(|l| l.switches.solo && l.source.is_av());
let any_solo = nctx.comp.any_solo();
for l in nctx.comp.layers.iter().rev() {
if !l.is_active_at(t)
|| l.is_light()
+115 -24
View File
@@ -206,36 +206,125 @@ pub fn shadow_coord(m: &ShadowMap, p: [f32; 3]) -> Option<(f32, f32, f32)> {
}
}
/// Fraction of light reaching `p` through shadow map `m` (1 = lit), PCF over a 5×5 grid
/// spread over `radius` texels.
pub fn shadow_factor(m: &ShadowMap, texels: &[f32], p: [f32; 3], n_l: f32) -> f32 {
/// Integer tap jitter hash: pure u32 bit math so the WGSL backend computes
/// bit-identical jitter from the same inputs (a float hash would diverge between
/// backends and break CPU/GPU parity).
fn tap_hash(x: i32, y: i32) -> u32 {
let mut h = (x as u32).wrapping_mul(0x9E37_79B9) ^ (y as u32).wrapping_mul(0x85EB_CA6B);
h ^= h >> 16;
h = h.wrapping_mul(0x2545_F491);
h ^= h >> 15;
h
}
/// Upper bound of the PCF grid's taps per axis. The grid grows with the reach so taps
/// stay about one texel apart for moderate penumbrae; the cap bounds the per-fragment
/// cost of very wide ones (bilinear taps still overlap and the jitter dithers the rest).
const PCF_MAX_TAPS: i32 = 10;
/// Steepest receiver-plane slope the PCF extrapolates, in texel footprints of depth per
/// texel (about 89°). Steeper planes are edge-on to the light, where their depth runs
/// away; they are clamped to this slope.
const PLANE_SLOPE_MAX: f32 = 64.0;
/// Fraction of light reaching `p` through shadow map `m` (1 = lit). `ng` is the
/// receiver's geometric (not normal-mapped) normal facing the viewer.
///
/// PCF over an N×N tap grid (5 ≤ N ≤ `PCF_MAX_TAPS`) spread over a reach derived
/// from `radius` (the wanted penumbra in output pixels) divided by the receiver's
/// texel footprint, each tap jittered by an integer hash of the receiver's
/// sub-texel cell and the tap index, and each tap comparing against the four
/// nearest texel centres with bilinear weights. The jitter detaches the tap grid
/// from the texel axes (axis-aligned binary taps are what stair-step a straight
/// shadow edge), and keying it below texel size keeps the dither finer than an
/// output pixel even where one texel covers several. The bilinear compare makes
/// each tap's contribution vary smoothly inside a texel instead of snapping.
///
/// Every texel is compared against the depth the receiver's own plane has at that
/// texel's centre, not the receiver point's depth: a wide kernel on a surface tilted
/// towards the light would otherwise see its own nearer depth and shadow itself.
/// With that, the bias stays at texel scale; a diffusion-scaled bias lifts shadows
/// off their casters.
pub fn shadow_factor(m: &ShadowMap, texels: &[f32], p: [f32; 3], ng: [f32; 3], n_l: f32) -> f32 {
let Some((u, v, z)) = shadow_coord(m, p) else { return 1.0 };
let size = m.size as i64;
// Slope-scaled bias (world units are pixels).
// Texel footprint (world units) × PCF reach, steeper for grazing light.
// Slope-scaled bias at texel scale (world units are pixels), steeper for
// grazing light: it absorbs depth-raster error and the gap between the shading
// normal's plane and the faceted surface the map rasterised.
let texel = if m.ortho { 1.0 / m.focal } else { z.abs() / m.focal };
let bias = m.bias * texel * (1.0 + m.radius) * (1.0 + 3.0 * (1.0 - n_l.clamp(0.0, 1.0)));
let step = m.radius / 2.0;
let bias = m.bias * texel * (1.0 + 3.0 * (1.0 - n_l.clamp(0.0, 1.0)));
let at = |xi: i64, yi: i64| -> f32 {
if xi < 0 || yi < 0 || xi >= size || yi >= size {
return f32::INFINITY; // outside the map: the light was never blocked there (WGSL mirrors this with a large finite depth)
}
texels.get(m.offset as usize + (yi * size + xi) as usize).copied().unwrap_or(f32::INFINITY)
};
// The receiver plane in light space as the slope of its depth across the map, in
// texel footprints per texel (`tx`, `ty`; depth grows along −t), clamped where the
// plane grazes the light. `den0` is the plane normal against the light ray through
// the receiver (the depth axis for an orthographic map), negative while it faces it.
let w = &m.view;
let nv = [0, 1, 2].map(|r| w[r][0] * ng[0] + w[r][1] * ng[1] + w[r][2] * ng[2]);
let c = m.size as f32 * 0.5;
let den0 = if m.ortho { nv[2] } else { nv[0] * ((u - c) / m.focal) + nv[1] * ((v - c) / m.focal) + nv[2] };
let (tx, ty) = if den0 < -1e-6 {
((nv[0] / den0).clamp(-PLANE_SLOPE_MAX, PLANE_SLOPE_MAX), (nv[1] / den0).clamp(-PLANE_SLOPE_MAX, PLANE_SLOPE_MAX))
} else {
(0.0, 0.0)
};
// 1 where texel (xi, yi) does not occlude the receiver plane at the texel's centre.
// Perspective depth is not linear across the map but its inverse is, so that
// compare runs on 1/depth (inv · (texel + bias) ≥ 1) without a division; a plane
// whose inverse depth reaches 0 has left the light's side and cannot be occluded.
let (iz, iq) = (1.0 / z, 1.0 / (m.focal * z));
let open = |xi: i64, yi: i64| -> f32 {
let s = at(xi, yi) + bias;
let g = tx * (xi as f32 + 0.5 - u) + ty * (yi as f32 + 0.5 - v);
let lit = if m.ortho {
z - g * texel <= s
} else {
let inv = iz + g * iq;
inv <= 0.0 || inv * s >= 1.0
};
if lit { 1.0 } else { 0.0 }
};
// `radius` is the wanted penumbra in output pixels; converting it through the
// receiver's texel footprint keeps the penumbra width in output pixels at any
// map resolution (and lets perspective penumbra grow with distance from the light).
// At least two texels: a hard edge then ramps across two texels, which hides the
// texel steps of the binary depth compare where one texel spans several output
// pixels (receivers grazing the light); a one-texel ramp leaves a sawtooth.
let mut reach = (m.radius / texel).clamp(2.0, 256.0);
if !reach.is_finite() {
reach = 1.0;
}
let n = (reach.ceil() as i32 + 1).clamp(5, PCF_MAX_TAPS);
let step = reach / (n - 1) as f32;
let half = (n - 1) as f32 * 0.5;
let (cx, cy) = ((u * 8.0).floor() as i32, (v * 8.0).floor() as i32);
let mut lit = 0.0;
for j in -2..=2 {
for i in -2..=2 {
let x = (u + i as f32 * step).floor() as i64;
let y = (v + j as f32 * step).floor() as i64;
if x < 0 || y < 0 || x >= size || y >= size {
lit += 1.0;
continue;
}
let d = texels[m.offset as usize + (y * size + x) as usize];
if z - bias <= d {
lit += 1.0;
}
for j in 0..n {
for i in 0..n {
let h = tap_hash(cx.wrapping_add(i.wrapping_mul(7919)), cy.wrapping_add(j.wrapping_mul(7919)));
let jx = ((h & 0xFFFF) as f32) * (1.0 / 65536.0) - 0.5;
let jy = (((h >> 16) & 0xFFFF) as f32) * (1.0 / 65536.0) - 0.5;
let (su, sv) = (u + ((i as f32 - half) + jx) * step, v + ((j as f32 - half) + jy) * step);
// Texel (x, y) holds the depth at its centre (x + 0.5, y + 0.5); each of the
// four is compared against the receiver plane at that centre.
let (tu, tv) = (su - 0.5, sv - 0.5);
let (fx, fy) = (tu - tu.floor(), tv - tv.floor());
let (x0, y0) = (tu.floor() as i64, tv.floor() as i64);
let (h00, h10, h01, h11) = (open(x0, y0), open(x0 + 1, y0), open(x0, y0 + 1), open(x0 + 1, y0 + 1));
let a = h00 + (h10 - h00) * fx;
let b = h01 + (h11 - h01) * fx;
lit += a + (b - a) * fy;
}
}
lit / 25.0
lit / (n * n) as f32
}
/// The visibility of light `li` at `p` (1 = unshadowed), darkened by Shadow Darkness.
pub fn light_shadow(s: &Scene, l: &Light, p: [f32; 3], n_l: f32) -> f32 {
pub fn light_shadow(s: &Scene, l: &Light, p: [f32; 3], ng: [f32; 3], n_l: f32) -> f32 {
if l.shadow < 0 {
return 1.0;
}
@@ -258,7 +347,7 @@ pub fn light_shadow(s: &Scene, l: &Light, p: [f32; 3], n_l: f32) -> f32 {
s.shadows.get(first)
};
let Some(m) = m else { return 1.0 };
let f = shadow_factor(m, &s.shadow_texels, p, n_l);
let f = shadow_factor(m, &s.shadow_texels, p, ng, n_l);
1.0 - l.shadow_darkness * (1.0 - f)
}
@@ -367,6 +456,8 @@ pub fn shade(s: &Scene, f: &Frag) -> Option<[f32; 4]> {
if !f.front {
n = scale(n, -1.0);
}
// Shadow lookups follow the geometric surface, not the normal map.
let ng = n;
if let Some(t) = tex(m.tex_normal) {
let tn = [(t[0] * 2.0 - 1.0) * m.normal_scale, (t[1] * 2.0 - 1.0) * m.normal_scale, t[2] * 2.0 - 1.0];
let tg = [f.tangent[0], f.tangent[1], f.tangent[2]];
@@ -392,7 +483,7 @@ pub fn shade(s: &Scene, f: &Frag) -> Option<[f32; 4]> {
if n_l <= 0.0 {
continue;
}
let sh = if m.receives_shadows { light_shadow(s, l, f.pos, n_l) } else { 1.0 };
let sh = if m.receives_shadows { light_shadow(s, l, f.pos, ng, n_l) } else { 1.0 };
let b = brdf(rgb, metallic, rough, m.specular_k, m.diffuse_k, n, v, to_l);
for c in 0..3 {
out[c] += b[c] * l.color[c] * k * sh;
@@ -433,7 +524,7 @@ fn shadow_only(s: &Scene, m: &Material, f: &Frag, alpha: f32) -> [f32; 4] {
if n_l <= 0.0 {
continue;
}
amount = amount.max(1.0 - light_shadow(s, l, f.pos, n_l));
amount = amount.max(1.0 - light_shadow(s, l, f.pos, n, n_l));
}
[0.0, 0.0, 0.0, alpha * amount]
}
+366
View File
@@ -681,3 +681,369 @@ fn stroke_regions_are_rings() {
assert_eq!(outlines.len(), 1);
assert_eq!(outlines[0].holes.len(), 1);
}
// ---------------------------------------------------------------- shadow quality
use super::raster::{MAX_SHADOW_TEXELS, shadow_map_size, shadow_point_face_size, shadow_sizes};
use super::scene::ShadowMap;
/// Geometric normal of a receiver squarely facing a `flat_map` light (which looks along +z).
const FACING: [f32; 3] = [0.0, 0.0, -1.0];
/// Identity-view orthographic map: map coords are world x, y × `focal` offset by
/// `size/2` (`focal` texels per world unit).
fn flat_map(size: u32, focal: f32, radius: f32) -> ShadowMap {
ShadowMap { view: [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]], focal, ortho: true, size, offset: 0, bias: 1.5, radius }
}
/// Wall: texel columns ≥ a quarter of the map at depth 10, the rest unoccluded (1000).
/// Its edge sits at world x = −size / (4·focal).
fn wall_map(size: u32, focal: f32, radius: f32) -> (ShadowMap, Vec<f32>) {
let texels: Vec<f32> = (0..size * size).map(|i| if i % size >= size / 4 { 10.0 } else { 1000.0 }).collect();
(flat_map(size, focal, radius), texels)
}
/// Luma of a render of `cid` with its top layer (a light) set to cast shadows or not,
/// at the given Shadow Diffusion.
fn render_light_luma(p: &mut Project, cid: ItemId, casting: bool, diffusion: f64) -> Vec<f32> {
let comp = p.comp_mut(cid).unwrap();
set(&mut comp.layers[0], "lightOptions/castsShadows", Value::Bool(casting));
set(&mut comp.layers[0], "lightOptions/shadowDiffusion", Value::Scalar(diffusion));
render(p, cid).data.iter().map(|q| (q[0] + q[1] + q[2]) / 3.0).collect()
}
#[test]
fn shadow_map_size_tracks_raster_resolution() {
assert_eq!(shadow_map_size(640, 360, false), 1024);
assert_eq!(shadow_map_size(1920, 1080, false), 2048);
assert_eq!(shadow_map_size(3840, 2160, false), 4096);
// Clamped to the pow2 range: small or absurd rasters stay inside 512..4096.
assert_eq!(shadow_map_size(64, 64, false), 512);
assert_eq!(shadow_map_size(0, 0, false), 512);
assert_eq!(shadow_map_size(u32::MAX, u32::MAX, false), 4096);
assert_eq!(shadow_map_size(7, 100000, false), 4096);
// The fast-preview (draft) budget stays at 512 regardless, so interactivity is
// untouched by the quality path's new sizing.
assert_eq!(shadow_map_size(3840, 2160, true), 512);
assert_eq!(shadow_map_size(0, 0, true), 512);
}
#[test]
fn shadow_point_faces_are_capped() {
// Cube faces take the full map size up to 2048 (one 2048 cube ≈ 25 M f32 texels).
assert_eq!(shadow_point_face_size(4096, false), 2048);
assert_eq!(shadow_point_face_size(2048, false), 2048);
assert_eq!(shadow_point_face_size(1024, false), 1024);
assert_eq!(shadow_point_face_size(512, false), 512);
// Draft keeps the historical half-size face.
assert_eq!(shadow_point_face_size(512, true), 256);
}
fn budget_texels(sizes: &[Option<u32>], kinds: &[u32]) -> u64 {
sizes.iter().zip(kinds).map(|(s, &k)| s.map(|z| (z as u64) * (z as u64) * if k == 2 { 6 } else { 1 }).unwrap_or(0)).sum()
}
#[test]
fn shadow_sizes_stay_within_the_scene_budget() {
// Within budget a light gets its full size: one 4K point cube keeps 2048 faces.
assert_eq!(shadow_sizes(4096, 2160, false, &[2]), vec![Some(2048)]);
// Several cubes overflow: deterministically halved (largest first, earliest light
// on ties) until inside: three or four settle at 1024, five push two to 512.
assert_eq!(shadow_sizes(4096, 2160, false, &[2; 3]), vec![Some(1024); 3]);
assert_eq!(shadow_sizes(4096, 2160, false, &[2; 4]), vec![Some(1024); 4]);
let sizes = shadow_sizes(4096, 2160, false, &[2; 5]);
assert_eq!(sizes, vec![Some(512), Some(512), Some(1024), Some(1024), Some(1024)]);
assert!(budget_texels(&sizes, &[2; 5]) <= MAX_SHADOW_TEXELS);
// A hostile light count can never break the cap; sizes only shrink or drop to None.
let many = vec![2u32; 1000];
let sizes = shadow_sizes(3840, 2160, false, &many);
assert_eq!(sizes.len(), 1000);
assert!(budget_texels(&sizes, &many) <= MAX_SHADOW_TEXELS);
assert!(sizes.iter().flatten().all(|&z| (128..=2048).contains(&z)));
// Deterministic: same inputs, same plan.
assert_eq!(sizes, shadow_sizes(3840, 2160, false, &many));
// Non-point lights and a modest scene keep full quality sizes.
assert_eq!(shadow_sizes(1920, 1080, false, &[1; 2]), vec![Some(2048), Some(2048)]);
// A light that casts nothing costs nothing: it stays None and leaves its
// full-size budget to the lights that do cast.
assert_eq!(shadow_sizes(640, 360, false, &[0, 1]), vec![None, Some(1024)]);
assert!(shadow_sizes(640, 360, false, &[0; 500]).iter().all(Option::is_none));
}
#[test]
fn diffusion_does_not_lift_a_deep_shadow() {
// Shadow Diffusion may widen the penumbra but must not scale the depth bias: a
// point deep inside a shadow stays fully shadowed at any diffusion.
let (m, texels) = wall_map(64, 1.0, 1.0);
let deep = [0.0f32, 0.0, 13.5]; // u = 32: ±16 texels of margin around the edge at 16; z − bias 12 > 10
let hard = shade::shadow_factor(&m, &texels, deep, FACING, 1.0);
assert!(hard < 0.01, "deep shadow at radius 1: {hard}");
let wide = shade::shadow_factor(&flat_map(64, 1.0, 25.0), &texels, deep, FACING, 1.0);
assert!(wide < 0.01, "deep shadow at radius 25 (bias must not grow with radius): {wide}");
}
#[test]
fn pcf_varies_within_a_texel() {
// Nearest-texel PCF snaps the edge to texel boundaries: two receivers 1/20 texel
// apart get bit-identical factors, which is what stair-steps straight edges.
// The filtering must produce coverage that varies within a single texel.
let (m, texels) = wall_map(64, 1.0, 2.0);
let mut pairs = 0;
let mut varying = 0;
let mut u = -17.0f32;
while u < -15.3 {
let a = shade::shadow_factor(&m, &texels, [u, 0.0, 13.5], FACING, 1.0);
let b = shade::shadow_factor(&m, &texels, [u + 0.05, 0.0, 13.5], FACING, 1.0);
pairs += 1;
if (a - b).abs() > 1e-6 {
varying += 1;
}
assert!((0.0..=1.0).contains(&a) && (0.0..=1.0).contains(&b), "{a} {b}");
u += 0.05;
}
assert!(pairs >= 30);
assert_eq!(varying, pairs, "coverage varies inside every texel along the edge");
}
#[test]
fn wide_diffusion_keeps_shadow_depth_and_contact() {
// Scene: camera-facing floor, sphere casting, point light behind the camera and
// off to the upper right so most of the shadow lands beside the sphere, not
// behind it. Raising Shadow Diffusion must neither wash the shadow out (a
// diffusion-scaled bias leaks light into it) nor move where it touches.
let (mut p, cid) = project(200, 150);
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, 200.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(1));
});
add(&mut p, cid, LayerSource::Light { kind: LightKind::Point }, |l| {
set(l, "transform/position", Value::Vec3([180.0, 20.0, -300.0]));
set(l, "lightOptions/castsShadows", Value::Bool(true));
});
let noshadow = render_light_luma(&mut p, cid, false, 0.0);
let d0 = render_light_luma(&mut p, cid, true, 0.0);
let d40 = render_light_luma(&mut p, cid, true, 40.0);
// Per-column darkening vs the shadowless render: depth (max darkening) and the
// deepest dark row (contact edge). Diffusion may blur the edge but must keep the
// shadow dark (no bias leak) and keep it where it touches (no lift).
let dep = |img: &[f32], x: usize| -> f32 { (20..140).map(|y| noshadow[y * 200 + x] - img[y * 200 + x]).fold(0.0f32, f32::max) };
let cols: Vec<usize> = (30..170).filter(|&x| dep(&d0, x) > 0.1).collect();
assert!(cols.len() > 30, "shadow covers many columns: {}", cols.len());
// Core depth: the 90th-percentile darkening over the d0 shadow's pixels. A blur
// only softens the rim and leaves the core dark; a bias leak lightens all of it.
let shadow_px: Vec<usize> = (20..140).flat_map(|y| (30..170).map(move |x| y * 200 + x)).filter(|&i| noshadow[i] - d0[i] > 0.1).collect();
let core = |img: &[f32]| -> f32 {
let mut v: Vec<f32> = shadow_px.iter().map(|&i| noshadow[i] - img[i]).collect();
v.sort_by(f32::total_cmp);
v[v.len() * 9 / 10]
};
let (core0, core40) = (core(&d0), core(&d40));
// Width of the shadow across its centre row: a diffusion-scaled bias shrinks the
// shadow (light leaks under its edges), while a correct filter keeps the extent
// and only softens the boundary (threshold crossings move at most with the
// penumbra, not with the bias).
let width = |img: &[f32]| -> f32 {
(30..130)
.map(|y| {
let dark: Vec<bool> = (0..200).map(|x| noshadow[y * 200 + x] - img[y * 200 + x] > 0.06).collect();
match (dark.iter().position(|&d| d), dark.iter().rposition(|&d| d)) {
(Some(l), Some(r)) if r > l => (r - l) as f32,
_ => 0.0,
}
})
.fold(0.0f32, f32::max)
};
let (w0, w40) = (width(&d0), width(&d40));
let wratio = w40 / w0;
assert!(w0 > 20.0, "shadow is wide enough to judge: {w0}");
assert!(core40 >= 0.9 * core0, "diffusion 40 keeps the shadow core dark: {core40:.4} vs {core0:.4}");
assert!(wratio >= 0.8, "diffusion 40 does not shrink the shadow: width {w40} vs {w0}");
}
#[test]
fn penumbra_dithers_along_a_straight_edge() {
// Receivers at the same distance from a straight edge, one texel apart along it.
// A fixed tap grid gives each the same coverage, so the penumbra repeats texel by
// texel (stair-steps and bands); the jitter, keyed below texel size, must vary it.
let (m, texels) = wall_map(64, 1.0, 6.0);
let distinct = |vs: &[f32]| -> usize {
let mut bits: Vec<u32> = vs.iter().map(|v| v.to_bits()).collect();
bits.sort_unstable();
bits.dedup();
bits.len()
};
let along: Vec<f32> = (0..32).map(|k| shade::shadow_factor(&m, &texels, [-16.0, -16.0 + k as f32, 13.5], FACING, 1.0)).collect();
assert!(distinct(&along) >= 16, "coverage along the edge takes {} distinct values", distinct(&along));
// And finer than a texel: where one texel spans several output pixels (a grazing
// floor), dither keyed per texel would show as texel-sized blocks of grain.
let within: Vec<f32> = (0..8).map(|k| shade::shadow_factor(&m, &texels, [-16.0, 0.0625 + k as f32 * 0.125, 13.5], FACING, 1.0)).collect();
assert!(distinct(&within) >= 4, "coverage inside one texel takes {} distinct values", distinct(&within));
}
#[test]
fn penumbra_width_is_independent_of_map_resolution() {
// One wall (edge at world x = −16) in a 64-texel map at 1 texel per world unit and
// a 256-texel map at 4. Shadow Diffusion sets the penumbra in output pixels (world
// units here), so a finer map must not narrow it.
let width = |size: u32, focal: f32| -> f32 {
let (m, texels) = wall_map(size, focal, 8.0);
let xs: Vec<f32> = (0..400).map(|k| -30.0 + k as f32 * 0.07).collect();
let f: Vec<f32> = xs.iter().map(|&x| shade::shadow_factor(&m, &texels, [x, 0.0, 13.5], FACING, 1.0)).collect();
let first_below = |t: f32| f.iter().position(|&v| v < t).map_or(f32::NAN, |i| xs[i]);
first_below(0.1) - first_below(0.9)
};
let (w64, w256) = (width(64, 1.0), width(256, 4.0));
assert!(w64 > 4.0 && w64 < 12.0, "penumbra spans about the requested 8 units: {w64}");
assert!((w256 / w64 - 1.0).abs() < 0.25, "same penumbra at any map resolution: {w64} vs {w256}");
}
#[test]
fn tilted_receivers_do_not_shadow_themselves() {
// A receiver plane z = 100 + 4x (76° from the light, n·l ≈ 0.24), each texel holding
// its depth half a texel off-centre the way a rasteriser can. Wide taps must compare
// against the plane rather than the receiver point, and the slope-scaled bias must
// absorb the half-texel error, at every diffusion.
let (size, g) = (128u32, 4.0f32);
let c = size as f32 * 0.5;
let texels: Vec<f32> = (0..size * size).map(|i| 100.0 + g * ((i % size) as f32 - c)).collect();
let len = (1.0 + g * g).sqrt();
let (ng, n_l) = ([g / len, 0.0, -1.0 / len], 1.0 / len);
for radius in [1.0f32, 6.0, 25.0] {
let m = flat_map(size, 1.0, radius);
for k in 0..40 {
let x = -20.0 + k as f32 * 0.97;
let f = shade::shadow_factor(&m, &texels, [x, 3.3, 100.0 + g * x], ng, n_l);
assert!(f > 0.99, "radius {radius}, x {x}: lit tilted receiver shadowed itself ({f})");
}
}
}
#[test]
fn lit_casters_do_not_shadow_themselves() {
// A sphere and a turned cube that cast shadows, lit by each light kind: the lit
// surface must render as bright as with shadows off at any Shadow Diffusion (a
// wide kernel on a surface tilted to the light must not see its own depth).
// 640×360: faces then span enough texels that near-grazing ones exercise the
// receiver plane's slope limit.
for (prim, rot_y) in [(PrimitiveKind::Sphere, 0.0), (PrimitiveKind::Cube, 35.0)] {
for kind in [LightKind::Point, LightKind::Spot, LightKind::Parallel] {
let (mut p, cid) = project(640, 360);
add(&mut p, cid, LayerSource::Primitive { kind: prim }, |l| {
set(l, "transform/position", Value::Vec3([320.0, 180.0, 100.0]));
if prim == PrimitiveKind::Sphere {
set(l, "geometryOptions/radius", Value::Scalar(120.0));
} else {
for k in ["width", "height", "depth"] {
set(l, &format!("geometryOptions/{k}"), Value::Scalar(216.0));
}
set(l, "transform/rotationX", Value::Scalar(-25.0));
}
set(l, "transform/rotationY", Value::Scalar(rot_y));
set(l, "materialOptions/baseColor", Value::Color([1.0, 1.0, 1.0, 1.0]));
set(l, "materialOptions/castsShadows", Value::Enum(1));
});
add(&mut p, cid, LayerSource::Light { kind }, |l| {
set(l, "transform/position", Value::Vec3([-280.0, -108.0, -600.0]));
if let Some(poi) = l.props.prop_mut("transform/pointOfInterest") {
poi.value = Value::Vec3([320.0, 180.0, 100.0]);
}
});
let noshadow = render_light_luma(&mut p, cid, false, 0.0);
let lit: Vec<usize> = (0..noshadow.len()).filter(|&i| noshadow[i] > 0.15).collect();
assert!(lit.len() > 10_000, "{prim:?} {kind:?}: lit area {}", lit.len());
for diffusion in [0.0, 20.0, 48.0] {
let img = render_light_luma(&mut p, cid, true, diffusion);
let dark = lit.iter().filter(|&&i| img[i] < 0.9 * noshadow[i]).count() as f32 / lit.len() as f32;
assert!(dark <= 0.02, "{prim:?} {kind:?} diffusion {diffusion}: {:.1} % of the lit surface self-shadowed", dark * 100.0);
}
}
}
}
#[test]
fn shadow_sizes_match_the_halve_largest_rule_and_scale_to_many_lights() {
// The specification `shadow_sizes` implements, step by step: halve the largest map
// above its floor (earliest light on ties) until the scene fits, then drop maps
// from the last light.
let spec = |w: u32, h: u32, draft: bool, kinds: &[u32]| -> Vec<Option<u32>> {
let base = shadow_map_size(w, h, draft);
let mut sizes: Vec<u32> = kinds
.iter()
.map(|&k| {
if k == 0 {
0
} else if k == 2 {
shadow_point_face_size(base, draft)
} else {
base
}
})
.collect();
let total = |s: &[u32]| -> u64 { s.iter().zip(kinds).map(|(&z, &k)| (z as u64) * (z as u64) * if k == 2 { 6 } else { 1 }).sum() };
while total(&sizes) > MAX_SHADOW_TEXELS {
let pick = (0..sizes.len()).filter(|&i| sizes[i] > if kinds[i] == 2 { 128 } else { 512 }).max_by_key(|&i| (sizes[i], std::cmp::Reverse(i)));
match pick {
Some(i) => sizes[i] /= 2,
None => match sizes.iter().rposition(|&z| z > 0) {
Some(i) => sizes[i] = 0,
None => break,
},
}
}
sizes.into_iter().map(|z| (z > 0).then_some(z)).collect()
};
let mut seed = 0x2545_F491u32;
for case in 0..300 {
seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
let n = (seed >> 8) as usize % 40;
let kinds: Vec<u32> = (0..n)
.map(|_| {
seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
(seed >> 16) % 3
})
.collect();
let (w, h, draft) = ([640, 1920, 3840, 8000][case % 4], [360, 1080, 2160, 100][case % 4], case % 7 == 0);
assert_eq!(shadow_sizes(w, h, draft, &kinds), spec(w, h, draft, &kinds), "case {case}: {kinds:?}");
}
// A hostile light count stays fast (n log n) and inside the budget.
let many = vec![2u32; 100_000];
let sizes = shadow_sizes(3840, 2160, false, &many);
assert!(budget_texels(&sizes, &many) <= MAX_SHADOW_TEXELS);
}
#[test]
fn wide_penumbrae_are_smooth() {
// Coverage at a fixed distance across a wide penumbra, sampled along a straight
// edge, should be one smooth value plus fine dither. A 5×5 grid stretched over a
// 20-texel reach leaves gaps the jitter turns into coarse grain (σ ≈ 0.04, ten
// 8-bit levels); the grid grows with the reach to keep it below 0.02.
let (m, texels) = wall_map(256, 1.0, 20.0);
for off in [-5.0f32, 0.0, 5.0] {
let vs: Vec<f32> = (0..200).map(|k| shade::shadow_factor(&m, &texels, [-64.0 + off, -100.0 + k as f32, 13.5], FACING, 1.0)).collect();
let mean = vs.iter().sum::<f32>() / vs.len() as f32;
let sd = (vs.iter().map(|v| (v - mean).powi(2)).sum::<f32>() / vs.len() as f32).sqrt();
assert!(sd < 0.02, "offset {off}: penumbra grain σ = {sd:.4}");
}
}
#[test]
fn hard_edges_ramp_over_two_texels() {
// At Shadow Diffusion 0 the edge still ramps across about two texels (10–90 %):
// where one texel spans several output pixels (a floor grazing a point light) a
// one-texel ramp leaves the binary compare's texel steps visible as a sawtooth.
let (m, texels) = wall_map(64, 1.0, 1.0);
for v0 in [0.0f32, 0.3, 7.6] {
let xs: Vec<f32> = (0..400).map(|k| -20.0 + k as f32 * 0.02).collect();
let f: Vec<f32> = xs.iter().map(|&x| shade::shadow_factor(&m, &texels, [x, v0, 13.5], FACING, 1.0)).collect();
let first_below = |t: f32| f.iter().position(|&v| v < t).map_or(f32::NAN, |i| xs[i]);
let w = first_below(0.1) - first_below(0.9);
assert!((1.7..3.0).contains(&w), "v {v0}: hard edge ramps over {w} texels");
}
}
+1 -1
View File
@@ -408,7 +408,7 @@ impl View3D {
}
}
pub fn from_id(s: &str) -> Option<View3D> {
let k: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase();
let k: String = s.chars().filter(char::is_ascii_alphanumeric).collect::<String>().to_ascii_lowercase();
View3D::ALL.into_iter().find(|v| v.id().eq_ignore_ascii_case(&k) || v.label().replace(' ', "").eq_ignore_ascii_case(&k))
}
pub fn is_ortho(self) -> bool {
+3 -3
View File
@@ -497,7 +497,7 @@ fn collapsed_items<'a>(
let op = ctx.opacity(layer) as f32 * r.opacity_mul();
let Some((sub, nctx)) = r.collapse_into(ctx, layer, item, op) else { return vec![] };
let t = nctx.time;
let any_solo = nctx.comp.layers.iter().any(|l| l.switches.solo && l.source.is_av());
let any_solo = nctx.comp.any_solo();
let nested: Vec<&'a Layer> = nctx
.comp
.layers
@@ -703,7 +703,7 @@ fn occlusion(c: &Item, p: Vec3, l: &LightState) -> [f32; 3] {
/// A Classic 3D run prepared for an accelerator ([`Renderer::prepare_3d_run`]): every plane
/// with its geometry, material and buffer, ready for the per-pixel depth sort, lighting and
/// ray-cast shadows of [`draw_run`] on another device.
/// ray-cast shadows of `draw_run` on another device.
#[derive(Clone, Debug)]
pub struct Run3d {
/// Drawn planes in stack order, then shadow casters from outside the run (`draw` false).
@@ -971,7 +971,7 @@ pub(crate) fn environment_rotation(ctx: &EvalCtx) -> f32 {
.to_radians() as f32
}
/// An Environment Light Background layer ready to draw (see [`draw_sky`]): the layer's
/// An Environment Light Background layer ready to draw (see `draw_sky`): the layer's
/// buffer in the blending space, its opacity and the camera's view rays. Also handed to an
/// accelerator's compositor (`Renderer::sky_draw`).
#[derive(Clone)]