mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-02 06:27:11 -06:00
Merge main into enhance/avi-import
This commit is contained in:
+83
-14
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
+76
-43
@@ -326,26 +326,7 @@ impl EffectHost for FxHost<'_, '_, '_> {
|
||||
}
|
||||
|
||||
fn aux(&self) -> Option<Arc<effectcraft_raster::AuxChannels>> {
|
||||
match &self.layer.source {
|
||||
LayerSource::Footage { item } => {
|
||||
let ItemKind::Footage(f) = &self.r.project.item(*item)?.kind else { return None };
|
||||
let a = self.r.footage.aux(*item, f, self.ctx.source_time(self.layer))?;
|
||||
// Aux pixels per layer pixel (the layer is the footage at its nominal size).
|
||||
if f.width > 0 && a.width > 0 {
|
||||
let mut a = (*a).clone();
|
||||
a.scale = a.width as f64 / f.width as f64;
|
||||
return Some(Arc::new(a));
|
||||
}
|
||||
Some(a)
|
||||
}
|
||||
LayerSource::Comp { item } => {
|
||||
if self.r.depth > MAX_FX_DEPTH || self.r.project.comp_contains(*item, self.ctx.comp_id) {
|
||||
return None;
|
||||
}
|
||||
self.r.nested().comp_aux(*item, self.ctx.nested_time(self.layer)).map(Arc::new)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
self.r.layer_aux(self.ctx, self.layer)
|
||||
}
|
||||
|
||||
fn params_at(&self, layer_time: f64) -> Option<Params> {
|
||||
@@ -1012,8 +993,7 @@ impl<'a> Renderer<'a> {
|
||||
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 = ((comp.width as f64 * s).round() as u32).max(1);
|
||||
let h = ((comp.height as f64 * s).round() as u32).max(1);
|
||||
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.
|
||||
@@ -1196,6 +1176,7 @@ impl<'a> Renderer<'a> {
|
||||
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.
|
||||
@@ -1490,10 +1471,11 @@ impl<'a> Renderer<'a> {
|
||||
let mut nb = (*b).clone();
|
||||
color::convert(&mut nb.img, &c);
|
||||
let nb = Arc::new(nb);
|
||||
if let (Some(cache), Some(k)) = (self.cache_to_keep(), key) {
|
||||
cache.insert(k, nb.clone());
|
||||
// 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,
|
||||
}
|
||||
nb
|
||||
}
|
||||
|
||||
fn layer_buf_keyed(&self, ctx: &EvalCtx, layer: &Layer) -> Option<(Arc<Buf>, Option<u64>)> {
|
||||
@@ -1508,9 +1490,11 @@ impl<'a> Renderer<'a> {
|
||||
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());
|
||||
if let (Some(c), Some(k)) = (self.cache_to_keep(), key) {
|
||||
c.insert(k, flat.clone());
|
||||
}
|
||||
// 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))
|
||||
}
|
||||
|
||||
@@ -1539,9 +1523,11 @@ impl<'a> Renderer<'a> {
|
||||
}
|
||||
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)));
|
||||
if let (Some(c), Some(k)) = (self.cache_to_keep(), key) {
|
||||
c.insert(k, buf.clone());
|
||||
}
|
||||
// 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))
|
||||
}
|
||||
|
||||
@@ -1564,9 +1550,11 @@ impl<'a> Renderer<'a> {
|
||||
buf = self.effect_space_stack(ctx, layer, buf, false, effects, None, origin);
|
||||
}
|
||||
let buf = Arc::new(buf);
|
||||
if let (Some(c), Some(k)) = (self.cache_to_keep(), key) {
|
||||
c.insert(k, buf.clone());
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -1594,12 +1582,13 @@ impl<'a> Renderer<'a> {
|
||||
t.cached = false;
|
||||
t.effects.push(("layerStyles".into(), t0.elapsed().as_secs_f64() * 1e3));
|
||||
}
|
||||
let body = Arc::new(st.body);
|
||||
let passes: Vec<_> = st.passes.into_iter().map(|(b, m)| (Arc::new(b), m)).collect();
|
||||
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) {
|
||||
c.insert(k, body.clone());
|
||||
for (i, (b, _)) in passes.iter().enumerate() {
|
||||
c.insert(cache::derive(k, i as u64 + 1), b.clone());
|
||||
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 })
|
||||
@@ -2055,6 +2044,12 @@ impl<'a> Renderer<'a> {
|
||||
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);
|
||||
}
|
||||
@@ -2115,6 +2110,34 @@ impl<'a> Renderer<'a> {
|
||||
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))
|
||||
@@ -2124,7 +2147,7 @@ impl<'a> Renderer<'a> {
|
||||
/// applied).
|
||||
pub fn visible_layers(&self, ctx: &EvalCtx<'a>) -> Vec<&'a Layer> {
|
||||
let (comp, t) = (ctx.comp, ctx.time);
|
||||
let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.source.is_av());
|
||||
let any_solo = comp.any_solo();
|
||||
comp.layers
|
||||
.iter()
|
||||
.rev()
|
||||
@@ -2227,9 +2250,11 @@ impl<'a> Renderer<'a> {
|
||||
let mut foot = self.source(ctx, layer)?;
|
||||
let _ = masks::apply(ctx, layer, &mut foot, self.mask_blur(ctx, layer));
|
||||
let foot = Arc::new(foot);
|
||||
if let (Some(c), Some(k)) = (self.cache_to_keep(), key) {
|
||||
c.insert(k, foot.clone());
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -2375,6 +2400,12 @@ pub fn extended_region(w: u32, h: u32, visible: [f64; 4], margin: f64) -> Option
|
||||
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)
|
||||
}
|
||||
@@ -2432,6 +2463,8 @@ mod tests_mask_blur;
|
||||
#[cfg(test)]
|
||||
mod tests_mask_expansion;
|
||||
#[cfg(test)]
|
||||
mod tests_motion_tile;
|
||||
#[cfg(test)]
|
||||
mod tests_nested;
|
||||
#[cfg(test)]
|
||||
mod tests_paint;
|
||||
|
||||
@@ -254,6 +254,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 +1026,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);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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]),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user