mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 21:16:52 -06:00
Merge remote-tracking branch 'origin/main' into HEAD
This commit is contained in:
@@ -2,7 +2,9 @@ use super::frames::content_bounds;
|
||||
use super::fx_host::MAX_FX_DEPTH;
|
||||
use super::layers::StyledLayer;
|
||||
use super::opts::LayerTiming;
|
||||
use super::*;
|
||||
use super::{
|
||||
Arc, AutoPick, Buf, EffectCtx, EvalCtx, Image, ItemId, ItemKind, Layer, LayerSource, Mat3, MatteKind, Renderer, Tick, cache, color, masks, three_d, vec2,
|
||||
};
|
||||
|
||||
/// Which compositor renders a frame (Project Settings ▸ Video Rendering and Effects).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
@@ -44,6 +46,14 @@ pub trait Accelerator: Send + Sync {
|
||||
/// Render a top-level comp frame (what [`Renderer::comp_frame`] returns). `None` = not
|
||||
/// handled (the CPU renders it).
|
||||
fn comp_frame(&self, r: &Renderer, comp: ItemId, t: Tick) -> Option<Image>;
|
||||
/// Render a top-level comp frame like [`Self::comp_frame`], composite it over `bg` and
|
||||
/// convert it to the H.264 export's 8-bit BT.709 limited-range 4:2:0 (Y plane, then U and V
|
||||
/// at half size; see `effectcraft_raster::yuv`). Every byte is within one level of the CPU
|
||||
/// conversion of the same frame (a driver's arithmetic may move a value near a rounding
|
||||
/// boundary). `None` = not handled (the caller renders and converts on the CPU).
|
||||
fn comp_frame_yuv420(&self, _r: &Renderer, _comp: ItemId, _t: Tick, _bg: [f32; 3]) -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
/// The accelerator implements effect `id` with the CPU effect's semantics.
|
||||
fn supports_effect(&self, id: &str) -> bool;
|
||||
/// Run a chain of supported effects on `buf`, clamping and quantising to `levels` after
|
||||
@@ -283,7 +293,7 @@ impl<'a> Renderer<'a> {
|
||||
/// layers).
|
||||
pub fn run_effects_on(&self, ctx: &EvalCtx, layer: &Layer, adjustment: bool, target: &mut dyn FxTarget) {
|
||||
// `target` is used as it is: the comp below for adjustment layers (the callers).
|
||||
self.run_effect_stack(ctx, layer, adjustment, usize::MAX, None, [0.0; 2], target);
|
||||
self.run_effect_stack(ctx, layer, crate::pipeline::EffectStack { adjustment, limit: usize::MAX, origin: [0.0; 2] }, None, target);
|
||||
}
|
||||
|
||||
/// [`Renderer::run_effects_on`] for a (non-adjustment) layer's own pixels: `target` holds
|
||||
@@ -291,7 +301,7 @@ impl<'a> Renderer<'a> {
|
||||
/// there. Returns the effect-space origin in layer coordinates (rebase by its negation).
|
||||
pub fn run_layer_effects_on(&self, ctx: &EvalCtx, layer: &Layer, target: &mut dyn FxTarget) -> [f64; 2] {
|
||||
let origin = ctx.effect_bounds(layer).1;
|
||||
self.run_effect_stack(ctx, layer, false, usize::MAX, None, origin, target);
|
||||
self.run_effect_stack(ctx, layer, crate::pipeline::EffectStack { adjustment: false, limit: usize::MAX, origin }, None, target);
|
||||
origin
|
||||
}
|
||||
|
||||
|
||||
+11
-15
@@ -25,7 +25,7 @@ const MAX_DEPTH: usize = 16;
|
||||
/// interleaved (L R L R …).
|
||||
pub fn mix_comp(project: &Project, footage: &dyn FootageSource, expr: Option<&dyn ExprHost>, comp: ItemId, start: Tick, frames: usize, rate: u32) -> Vec<f32> {
|
||||
let mut out = vec![0.0f32; frames * 2];
|
||||
mix_into(project, footage, expr, comp, start, rate, &mut out, 1.0, 1.0, 0);
|
||||
mix_into(&AudioSources { project, footage, expr, rate }, comp, start, &mut out, 0);
|
||||
out
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@ impl MixEnv<'_> {
|
||||
return out;
|
||||
}
|
||||
let mut sub = vec![0.0f32; src_n * 2];
|
||||
mix_into(self.project, self.footage, self.expr, *item, lt, rate, &mut sub, 1.0, 1.0, self.depth + 1);
|
||||
mix_into(&AudioSources { project: self.project, footage: self.footage, expr: self.expr, rate }, *item, lt, &mut sub, self.depth + 1);
|
||||
sub
|
||||
}
|
||||
_ => return out,
|
||||
@@ -206,19 +206,16 @@ impl MixEnv<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn mix_into(
|
||||
project: &Project,
|
||||
footage: &dyn FootageSource,
|
||||
expr: Option<&dyn ExprHost>,
|
||||
comp_id: ItemId,
|
||||
start: Tick,
|
||||
/// Shared inputs for composition audio mixing, including nested compositions.
|
||||
struct AudioSources<'a> {
|
||||
project: &'a Project,
|
||||
footage: &'a dyn FootageSource,
|
||||
expr: Option<&'a dyn ExprHost>,
|
||||
rate: u32,
|
||||
out: &mut [f32],
|
||||
gl: f32,
|
||||
gr: f32,
|
||||
depth: usize,
|
||||
) {
|
||||
}
|
||||
|
||||
fn mix_into(sources: &AudioSources<'_>, comp_id: ItemId, start: Tick, out: &mut [f32], depth: usize) {
|
||||
let AudioSources { project, footage, expr, rate } = *sources;
|
||||
let Some(comp) = project.comp(comp_id) else { return };
|
||||
if depth >= MAX_DEPTH || rate == 0 {
|
||||
return;
|
||||
@@ -239,7 +236,6 @@ fn mix_into(
|
||||
}
|
||||
let t_a = sample_tick(s0 + a as i64, rate);
|
||||
let (ll, lr) = levels(&env, l, t_a);
|
||||
let (ll, lr) = (ll * gl, lr * gr);
|
||||
if ll == 0.0 && lr == 0.0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -593,7 +593,6 @@ fn key_with(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, f
|
||||
|
||||
/// `blur`: the layer is motion blurred in this render (`Renderer::mb_on`). `effects`: key only
|
||||
/// the first that many effects (a layer's input, see [`input_key`]).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, footage: bool, effects: Option<usize>) -> Option<u64> {
|
||||
let mut h = KeyHasher(0xcbf2_9ce4_8422_2325);
|
||||
ctx.comp.frame_rate.as_f64().to_bits().hash(&mut h);
|
||||
|
||||
+183
-21
@@ -12,9 +12,11 @@
|
||||
//! `FrameSeg::to_bytes` (keyed by their content-keyed chain key and scale, so they are
|
||||
//! reused whenever the same frames, settings and prompt come back, also after a restart). Every file ends in a checksum; damaged files read as misses and
|
||||
//! are deleted.
|
||||
//! * Size limit with least-recently-used eviction. The in-memory index is rebuilt from the
|
||||
//! folder when the cache opens (file modification times order it), so the cache survives
|
||||
//! restarts; reads refresh a file's time.
|
||||
//! * Size limit with least-recently-used eviction, down to 90% of the limit so the eviction scan
|
||||
//! runs once per tenth of the limit written, not on every write. The limit shrinks to keep
|
||||
//! free space on the cache's disk ([`DiskCache::set_disk_space`]). The in-memory index is
|
||||
//! rebuilt from the folder when the cache opens (file modification times order it), so the
|
||||
//! cache survives restarts; reads refresh a file's time.
|
||||
//! * Safe concurrent access: writes go to a unique temporary file that is renamed into place
|
||||
//! (atomic on every desktop OS), so readers in this or another process never see a partial
|
||||
//! entry; a missing or damaged file is just a miss. Writes run on a background thread with a
|
||||
@@ -22,7 +24,7 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
|
||||
use effectcraft_effects::Buf;
|
||||
@@ -75,11 +77,15 @@ pub struct DiskStats {
|
||||
pub entries: usize,
|
||||
pub bytes: u64,
|
||||
pub max_bytes: u64,
|
||||
/// The limit in effect: `max_bytes`, or less when the disk is short of free space.
|
||||
pub limit_bytes: u64,
|
||||
pub hits: u64,
|
||||
pub misses: u64,
|
||||
pub writes: u64,
|
||||
pub evictions: u64,
|
||||
pub dropped: u64,
|
||||
/// Writes that failed (a full disk, permissions).
|
||||
pub failed: u64,
|
||||
}
|
||||
|
||||
struct Meta {
|
||||
@@ -157,15 +163,17 @@ impl DiskIndex {
|
||||
v.into_iter().map(|e| e.1).collect()
|
||||
}
|
||||
|
||||
/// Remove least recently used entries until the total fits `max` bytes; returns them (the
|
||||
/// caller deletes their files).
|
||||
/// When the total exceeds `max` bytes, remove least recently used entries down to 90% of
|
||||
/// it (so the sort runs once per tenth of `max` written, not on every write); returns them
|
||||
/// (the caller deletes their files).
|
||||
pub fn evict(&mut self, max: u64) -> Vec<(Kind, u128)> {
|
||||
if self.total <= max {
|
||||
return vec![];
|
||||
}
|
||||
let target = max - max / 10;
|
||||
let mut out = vec![];
|
||||
for k in self.lru_order() {
|
||||
if self.total <= max {
|
||||
if self.total <= target {
|
||||
break;
|
||||
}
|
||||
self.remove(k.0, k.1);
|
||||
@@ -225,11 +233,16 @@ pub struct DiskCache {
|
||||
dir: PathBuf,
|
||||
index: Mutex<DiskIndex>,
|
||||
max_bytes: AtomicU64,
|
||||
/// The size free disk space allows (`u64::MAX` = unknown), see [`DiskCache::set_disk_space`].
|
||||
disk_cap: AtomicU64,
|
||||
/// Free space is limiting the cache (warned once per episode).
|
||||
space_limited: AtomicBool,
|
||||
hits: AtomicU64,
|
||||
misses: AtomicU64,
|
||||
writes: AtomicU64,
|
||||
evictions: AtomicU64,
|
||||
dropped: AtomicU64,
|
||||
failed: AtomicU64,
|
||||
tmp_counter: AtomicU64,
|
||||
queue: Arc<(Mutex<Queue>, Condvar)>,
|
||||
/// Layer buffers that took less than this to render are not written (ms).
|
||||
@@ -298,16 +311,21 @@ impl DiskCache {
|
||||
dir,
|
||||
index: Mutex::new(DiskIndex::default()),
|
||||
max_bytes: AtomicU64::new(max_bytes),
|
||||
disk_cap: AtomicU64::new(u64::MAX),
|
||||
space_limited: AtomicBool::new(false),
|
||||
hits: AtomicU64::new(0),
|
||||
misses: AtomicU64::new(0),
|
||||
writes: AtomicU64::new(0),
|
||||
evictions: AtomicU64::new(0),
|
||||
dropped: AtomicU64::new(0),
|
||||
failed: AtomicU64::new(0),
|
||||
tmp_counter: AtomicU64::new(0),
|
||||
queue: Arc::new((Mutex::new(Queue { jobs: Default::default(), busy: false }), Condvar::new())),
|
||||
queue: Arc::new((Mutex::new(Queue { jobs: Default::default(), busy: cfg!(not(target_arch = "wasm32")) }), Condvar::new())),
|
||||
min_layer_ms: AtomicU64::new(30),
|
||||
});
|
||||
dc.rescan();
|
||||
// Shrinking a large cache deletes many files: the writer thread does it.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
dc.evict();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
@@ -371,6 +389,32 @@ impl DiskCache {
|
||||
self.evict();
|
||||
}
|
||||
|
||||
/// The limit in effect: the setting, or less when the disk is short of free space.
|
||||
pub fn limit_bytes(&self) -> u64 {
|
||||
self.max_bytes().min(self.disk_cap.load(Ordering::Relaxed))
|
||||
}
|
||||
|
||||
/// The cache's disk: `(total, available)` bytes, `None` = unknown. The cache leaves at least
|
||||
/// 2 GB or 5% of the disk free, whichever is more (evicting when it must).
|
||||
pub fn set_disk_space(&self, space: Option<(u64, u64)>) {
|
||||
let cap = match space {
|
||||
// (a disk reported as 0 bytes, such as an automount map, is unknown)
|
||||
Some((total, available)) if total > 0 => {
|
||||
let reserve = (2u64 << 30).max(total / 20);
|
||||
let used = self.index.lock().map(|g| g.total()).unwrap_or_default();
|
||||
// The cache's own files count as free: below the reserve it gives space back.
|
||||
used.saturating_add(available).saturating_sub(reserve)
|
||||
}
|
||||
_ => u64::MAX,
|
||||
};
|
||||
self.disk_cap.store(cap, Ordering::Relaxed);
|
||||
let limited = cap < self.max_bytes();
|
||||
if self.space_limited.swap(limited, Ordering::Relaxed) != limited && limited {
|
||||
log::warn!("disk cache at {}: little free disk space, limiting the cache to {} MB", self.folder().display(), cap >> 20);
|
||||
}
|
||||
self.evict();
|
||||
}
|
||||
|
||||
/// Layer buffers faster to render than this are not worth a disk write.
|
||||
pub fn set_min_layer_ms(&self, ms: u64) {
|
||||
self.min_layer_ms.store(ms, Ordering::Relaxed);
|
||||
@@ -395,11 +439,13 @@ impl DiskCache {
|
||||
entries,
|
||||
bytes,
|
||||
max_bytes: self.max_bytes(),
|
||||
limit_bytes: self.limit_bytes(),
|
||||
hits: self.hits.load(Ordering::Relaxed),
|
||||
misses: self.misses.load(Ordering::Relaxed),
|
||||
writes: self.writes.load(Ordering::Relaxed),
|
||||
evictions: self.evictions.load(Ordering::Relaxed),
|
||||
dropped: self.dropped.load(Ordering::Relaxed),
|
||||
failed: self.failed.load(Ordering::Relaxed),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -494,30 +540,44 @@ impl DiskCache {
|
||||
}
|
||||
let path = self.path(kind, key);
|
||||
let Some(parent) = path.parent() else { return };
|
||||
if std::fs::create_dir_all(parent).is_err() {
|
||||
if let Err(e) = std::fs::create_dir_all(parent) {
|
||||
self.write_failed(&e);
|
||||
return;
|
||||
}
|
||||
let data = seal(kind, payload);
|
||||
let n = self.tmp_counter.fetch_add(1, Ordering::Relaxed);
|
||||
let tmp = parent.join(format!("{key:032x}.tmp.{}.{n}", std::process::id()));
|
||||
if std::fs::write(&tmp, &data).is_err() {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
// An entry that cannot fit would be evicted straight away.
|
||||
if data.len() as u64 > self.limit_bytes() {
|
||||
return;
|
||||
}
|
||||
if std::fs::rename(&tmp, &path).is_err() {
|
||||
// Make room first, so the cache never exceeds its limit (or the free-space reserve).
|
||||
self.evict_to(self.limit_bytes().saturating_sub(data.len() as u64));
|
||||
let n = self.tmp_counter.fetch_add(1, Ordering::Relaxed);
|
||||
let tmp = parent.join(format!("{key:032x}.tmp.{}.{n}", std::process::id()));
|
||||
if let Err(e) = std::fs::write(&tmp, &data).and_then(|()| std::fs::rename(&tmp, &path)) {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
self.write_failed(&e);
|
||||
return;
|
||||
}
|
||||
self.writes.fetch_add(1, Ordering::Relaxed);
|
||||
if let Ok(mut g) = self.index.lock() {
|
||||
g.insert(kind, key, data.len() as u64);
|
||||
}
|
||||
self.evict();
|
||||
}
|
||||
|
||||
/// Count a failed write; the first one is logged (a full disk shouldn't go unnoticed).
|
||||
fn write_failed(&self, e: &std::io::Error) {
|
||||
if self.failed.fetch_add(1, Ordering::Relaxed) == 0 {
|
||||
log::warn!("disk cache at {}: a write failed: {e}", self.folder().display());
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove least recently used entries until the cache fits its limit.
|
||||
fn evict(&self) {
|
||||
let max = self.max_bytes();
|
||||
self.evict_to(self.limit_bytes());
|
||||
}
|
||||
|
||||
/// Remove least recently used entries until the cache fits `max` bytes.
|
||||
fn evict_to(&self, max: u64) {
|
||||
let victims = match self.index.lock() {
|
||||
Ok(mut g) => g.evict(max),
|
||||
Err(_) => return,
|
||||
@@ -586,6 +646,15 @@ impl DiskCache {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn writer(dc: std::sync::Weak<DiskCache>, q: Arc<(Mutex<Queue>, Condvar)>) {
|
||||
let (m, cv) = &*q;
|
||||
// The eviction at open (a lower limit than the folder holds), off the opening thread;
|
||||
// `busy` starts set, so `flush` waits for it.
|
||||
if let Some(cache) = dc.upgrade() {
|
||||
cache.evict();
|
||||
}
|
||||
if let Ok(mut g) = m.lock() {
|
||||
g.busy = false;
|
||||
}
|
||||
cv.notify_all();
|
||||
loop {
|
||||
let job = {
|
||||
let Ok(mut g) = m.lock() else { return };
|
||||
@@ -772,7 +841,7 @@ fn decode_layer(p: &[u8]) -> Option<Buf> {
|
||||
// ---------------------------------------------------------------- content keys
|
||||
|
||||
/// Bump when rendering changes in a way that makes old disk entries wrong.
|
||||
const RENDER_VERSION: &str = "effectcraft-render-2";
|
||||
const RENDER_VERSION: &str = "effectcraft-render-3";
|
||||
|
||||
fn hash_debug(h: &mut Hash128, v: &impl std::fmt::Debug) {
|
||||
h.write(format!("{v:?}").as_bytes());
|
||||
@@ -841,9 +910,18 @@ pub fn comp_content_key(project: &effectcraft_project::Project, comp: effectcraf
|
||||
|
||||
/// [`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 {
|
||||
comp_content_key_for_renderer(project, comp, virtual_id, RENDER_VERSION)
|
||||
}
|
||||
|
||||
fn comp_content_key_for_renderer(
|
||||
project: &effectcraft_project::Project,
|
||||
comp: effectcraft_project::ItemId,
|
||||
virtual_id: Option<VirtualFileId<'_>>,
|
||||
renderer: &str,
|
||||
) -> u128 {
|
||||
use effectcraft_project::{ItemKind, LayerSource};
|
||||
let mut h = Hash128::default();
|
||||
h.write(RENDER_VERSION.as_bytes());
|
||||
h.write(renderer.as_bytes());
|
||||
hash_debug(&mut h, &project.settings);
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
let mut stack = vec![comp];
|
||||
@@ -1001,6 +1079,28 @@ mod tests {
|
||||
assert!(dc.get_frame(42).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn previous_renderer_frames_are_not_reused() {
|
||||
use effectcraft_project::{Comp, ItemKind, Project};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
|
||||
let mut project = Project::default();
|
||||
let comp = Comp::new(2, 2, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
|
||||
let id = project.add_item("Cached composition", effectcraft_color::Label::None, None, ItemKind::Comp(comp.into()));
|
||||
let key = frame_key(comp_content_key(&project, id), 0, 1000, 0);
|
||||
let previous_key = frame_key(comp_content_key_for_renderer(&project, id, None, "effectcraft-render-2"), 0, 1000, 0);
|
||||
let dir = tmpdir("previous-renderer");
|
||||
{
|
||||
let cache = DiskCache::open(&dir, 1 << 30).unwrap();
|
||||
cache.put_frame_now(previous_key, 2, 2, &[9; 16]);
|
||||
}
|
||||
let cache = DiskCache::open(&dir, 1 << 30).unwrap();
|
||||
assert_eq!(cache.get_frame(previous_key).unwrap().rgba, vec![9; 16]);
|
||||
assert!(cache.get_frame(key).is_none());
|
||||
cache.put_frame_now(key, 2, 2, &[17; 16]);
|
||||
assert_eq!(cache.get_frame(key).unwrap().rgba, vec![17; 16]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persists_across_restarts_and_clears() {
|
||||
let dir = tmpdir("persist");
|
||||
@@ -1030,9 +1130,9 @@ mod tests {
|
||||
let per = dc.stats().bytes / 6;
|
||||
// Touch 0 so it is the most recently used.
|
||||
assert!(dc.get_layer(0).is_some());
|
||||
dc.set_max_bytes(per * 3 + per / 2);
|
||||
dc.set_max_bytes(per * 3 + per * 3 / 4);
|
||||
let st = dc.stats();
|
||||
assert!(st.bytes <= per * 3 + per / 2, "{st:?}");
|
||||
assert!(st.bytes <= per * 3 + per * 3 / 4, "{st:?}");
|
||||
assert_eq!(st.entries, 3);
|
||||
assert!(dc.contains(Kind::Layer, 0), "recently used survives");
|
||||
assert!(!dc.contains(Kind::Layer, 1) && !dc.contains(Kind::Layer, 2) && !dc.contains(Kind::Layer, 3));
|
||||
@@ -1042,7 +1142,69 @@ mod tests {
|
||||
assert_eq!(files, 3);
|
||||
// New writes keep evicting.
|
||||
dc.put_layer_now(99, &buf(9, 64, 64));
|
||||
assert!(dc.stats().bytes <= per * 3 + per / 2);
|
||||
assert!(dc.stats().bytes <= per * 3 + per * 3 / 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eviction_goes_down_to_ninety_percent_in_lru_order() {
|
||||
let mut ix = DiskIndex::default();
|
||||
for k in 0..10u128 {
|
||||
ix.insert(Kind::Layer, k, 100);
|
||||
}
|
||||
assert!(ix.touch(Kind::Layer, 0));
|
||||
// Over 950: down to 855, least recently used first.
|
||||
ix.insert(Kind::Layer, 10, 100);
|
||||
assert_eq!(ix.evict(950), vec![(Kind::Layer, 1), (Kind::Layer, 2), (Kind::Layer, 3)]);
|
||||
assert_eq!(ix.total(), 800);
|
||||
// The slack absorbs the next write without another scan.
|
||||
ix.insert(Kind::Layer, 11, 100);
|
||||
assert!(ix.evict(950).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_disk_space_caps_the_limit() {
|
||||
let dir = tmpdir("space");
|
||||
let dc = DiskCache::open(&dir, 1 << 30).unwrap();
|
||||
for k in 0..4u128 {
|
||||
dc.put_layer_now(k, &buf(k as u32, 64, 64));
|
||||
}
|
||||
let used = dc.stats().bytes;
|
||||
// Unknown space: the setting alone.
|
||||
dc.set_disk_space(None);
|
||||
assert_eq!(dc.stats().limit_bytes, 1 << 30);
|
||||
// A disk reported as 0 bytes is unknown.
|
||||
dc.set_disk_space(Some((0, 0)));
|
||||
assert_eq!(dc.stats().limit_bytes, 1 << 30);
|
||||
// 100 GB disk, 5 GB reserved, exactly 5 GB free: the cache may not grow.
|
||||
dc.set_disk_space(Some((100 << 30, 5 << 30)));
|
||||
let st = dc.stats();
|
||||
assert_eq!((st.max_bytes, st.limit_bytes), (1 << 30, used));
|
||||
dc.put_layer_now(9, &buf(9, 64, 64));
|
||||
let st = dc.stats();
|
||||
assert!(st.bytes <= st.limit_bytes && st.bytes <= used - used / 10 && st.evictions > 0, "{st:?}");
|
||||
assert!(dc.contains(Kind::Layer, 9), "the new entry is the most recently used");
|
||||
// Below the reserve the cache gives its space back, and skips writes that can't fit.
|
||||
dc.set_disk_space(Some((100 << 30, 4 << 30)));
|
||||
dc.put_layer_now(10, &buf(10, 64, 64));
|
||||
let st = dc.stats();
|
||||
assert_eq!((st.limit_bytes, st.entries, st.bytes), (0, 0, 0), "{st:?}");
|
||||
// Plenty of space again: back to the setting.
|
||||
dc.set_disk_space(Some((100 << 30, 50 << 30)));
|
||||
assert_eq!(dc.stats().limit_bytes, 1 << 30);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_writes_are_counted() {
|
||||
let dir = tmpdir("failed");
|
||||
let dc = DiskCache::open(&dir, 1 << 30).unwrap();
|
||||
// A file where the layers folder should be: every layer write fails.
|
||||
std::fs::write(dir.join(VERSION).join(Kind::Layer.dir()), b"x").unwrap();
|
||||
dc.put_layer_now(1, &buf(1, 8, 8));
|
||||
dc.put_layer_now(2, &buf(2, 8, 8));
|
||||
let st = dc.stats();
|
||||
assert_eq!((st.failed, st.writes, st.entries), (2, 0, 0));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
fn walk(d: &Path) -> usize {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Arc, AtomicBool, Footage, Image, ItemId, Ordering, Tick};
|
||||
|
||||
/// How a [`FootageSource`] hands over float footage (OpenEXR, float TIFF), whose values are
|
||||
/// linear light: encoded with a curve, as the compositor takes footage, or as stored. Other
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::footage::NoFootage;
|
||||
use super::opts::RenderOpts;
|
||||
use super::*;
|
||||
use super::{EvalCtx, Footage, FrameBlend, FrameRate, Image, ItemId, Layer, LayerSource, Project, Renderer, TICKS_PER_SECOND, Tick, shapes, source_size, text};
|
||||
|
||||
/// The layer's frame blending mode, if the comp's Enable Frame Blending switch is on.
|
||||
/// One field of an interlaced frame as a full frame: the field's lines (`parity` 0 = even /
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Arc, Buf, EffectHost, EvalCtx, ItemKind, Layer, LayerPixels, LayerSource, Params, Renderer, Tick, three_d};
|
||||
|
||||
/// Layer parameters and audio for effects (see [`effectcraft_effects::EffectHost`]). What it
|
||||
/// hands out is in effect space (see [`effect_bounds`]): the other layer's for its pixels, the
|
||||
|
||||
+15
-14
@@ -1,6 +1,16 @@
|
||||
use super::frames::adaptive_samples;
|
||||
use super::opts::{LayerTiming, Through};
|
||||
use super::*;
|
||||
use super::{
|
||||
Arc, BlendMode, Buf, Comp, EvalCtx, Image, IndexedParallelIterator, IntoParallelRefIterator, IntoParallelRefMutIterator, Layer, LayerSource, Mat3,
|
||||
MatteKind, ParallelIterator, Quality, Region, Renderer, Sampling, Tick, WarpOpts, cache, color, composite_warp_changed, masks, source_size, styles, vec2,
|
||||
};
|
||||
|
||||
/// Blend policy for placing a layer, including optional output-depth quantization.
|
||||
struct PlacementBlend {
|
||||
mode: BlendMode,
|
||||
opacity: f32,
|
||||
quantize: Option<f32>,
|
||||
}
|
||||
|
||||
/// A motion-blur shutter: the comp's, or for a collapsed precomp's layers the outermost
|
||||
/// containing comp's (they are drawn into its frames).
|
||||
@@ -342,22 +352,13 @@ impl<'a> Renderer<'a> {
|
||||
|
||||
/// Draw a processed layer into `target` (transform, motion blur) with `mode`/`opacity`.
|
||||
pub(crate) fn place(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf, target: &mut Image, mode: BlendMode, opacity: f32) {
|
||||
self.place_changed(ctx, layer, buf, target, mode, opacity, None);
|
||||
self.place_changed(ctx, layer, buf, target, PlacementBlend { mode, opacity, quantize: None });
|
||||
}
|
||||
|
||||
/// [`Self::place`], returning the target pixels it changed when the warp tracked them (then
|
||||
/// also quantised to `quantize` levels; see [`composite_warp_changed`]).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn place_changed(
|
||||
&self,
|
||||
ctx: &EvalCtx,
|
||||
layer: &Layer,
|
||||
buf: &Buf,
|
||||
target: &mut Image,
|
||||
mode: BlendMode,
|
||||
opacity: f32,
|
||||
quantize: Option<f32>,
|
||||
) -> Option<[u32; 4]> {
|
||||
fn place_changed(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf, target: &mut Image, blend: PlacementBlend) -> Option<[u32; 4]> {
|
||||
let PlacementBlend { mode, opacity, quantize } = blend;
|
||||
let pl = self.placement(ctx, layer, buf);
|
||||
let opts = WarpOpts { sampling: pl.sampling, opacity, mode, seed: pl.seed, clip: None };
|
||||
if let [m] = pl.matrices.as_slice() {
|
||||
@@ -459,7 +460,7 @@ impl<'a> Renderer<'a> {
|
||||
fn composite_layer(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf, canvas: &mut Image, opacity: f32, quantize: Option<f32>) -> Option<[u32; 4]> {
|
||||
let matte = layer.track_matte.is_some_and(|tm| tm.layer != layer.id && ctx.comp.layer(tm.layer).is_some());
|
||||
if !matte && !layer.preserve_transparency {
|
||||
return self.place_changed(ctx, layer, buf, canvas, layer.blend_mode, opacity, quantize);
|
||||
return self.place_changed(ctx, layer, buf, canvas, PlacementBlend { mode: layer.blend_mode, opacity, quantize });
|
||||
}
|
||||
// Render in isolation, then matte / preserve transparency, then blend.
|
||||
let mut iso = Image::new(canvas.width, canvas.height);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::accel::Backend;
|
||||
use super::*;
|
||||
use super::{Comp, EvalCtx, ItemId, Layer, Mat4, Tick, three_d};
|
||||
|
||||
#[derive(Clone, Copy, Debug, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
|
||||
@@ -3,7 +3,18 @@ use super::footage::{FootageSource, is_vector_footage};
|
||||
use super::frames::{blend_frames, content_bounds, field_frame, frame_blend_mode, frame_position, frame_size, interpret_pixels, roi_inside};
|
||||
use super::fx_host::{FxHost, essential_overrides};
|
||||
use super::opts::{Collapse3d, LayerTiming, MAX_THROUGH, RenderOpts, Through};
|
||||
use super::*;
|
||||
use super::{
|
||||
Arc, AutoKey, AutoPick, BlendMode, Buf, Comp, EffectCtx, EffectEnv, EvalCtx, Footage, FootageKind, FrameBlend, FrameRate, GroupKind, Image, ItemId,
|
||||
ItemKind, Layer, LayerCache, LayerSource, Params, Project, Quality, Region, Renderer, Tick, cache, color, masks, shapes, styles, text, three_d, vec2,
|
||||
};
|
||||
|
||||
/// Coordinate space and stack range shared by CPU and accelerated effect evaluation.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct EffectStack {
|
||||
pub adjustment: bool,
|
||||
pub limit: usize,
|
||||
pub origin: [f64; 2],
|
||||
}
|
||||
|
||||
impl<'a> Renderer<'a> {
|
||||
pub fn new(project: &'a Project, footage: &'a dyn FootageSource, opts: RenderOpts) -> Renderer<'a> {
|
||||
@@ -423,26 +434,16 @@ impl<'a> Renderer<'a> {
|
||||
// The comp below an adjustment layer is not in its layer space: it stays as it is.
|
||||
let origin = if adjustment { [0.0; 2] } else { ctx.effect_bounds(layer).1 };
|
||||
buf.rebase(origin);
|
||||
let mut out = self.effect_space_stack(ctx, layer, buf, adjustment, limit, timing, origin);
|
||||
let mut out = self.effect_space_stack(ctx, layer, buf, EffectStack { adjustment, limit, origin }, timing);
|
||||
out.rebase([-origin[0], -origin[1]]);
|
||||
out
|
||||
}
|
||||
|
||||
/// [`Renderer::apply_effects_timed`] on a buffer already in effect space (measured from
|
||||
/// `origin`, in layer coordinates); the result stays in effect space.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn effect_space_stack(
|
||||
&self,
|
||||
ctx: &EvalCtx,
|
||||
layer: &Layer,
|
||||
buf: Buf,
|
||||
adjustment: bool,
|
||||
limit: usize,
|
||||
timing: Option<&mut Vec<(String, f64)>>,
|
||||
origin: [f64; 2],
|
||||
) -> Buf {
|
||||
fn effect_space_stack(&self, ctx: &EvalCtx, layer: &Layer, buf: Buf, stack: EffectStack, timing: Option<&mut Vec<(String, f64)>>) -> Buf {
|
||||
let mut t = CpuFx { accel: self.active_accel(), levels: self.pipe.levels, buf: Some(buf) };
|
||||
self.run_effect_stack(ctx, layer, adjustment, limit, timing, origin, &mut t);
|
||||
self.run_effect_stack(ctx, layer, stack, timing, &mut t);
|
||||
t.buf.unwrap_or_else(|| Buf { img: Image::new(1, 1), offset: [0.0; 2], scale: self.opts.scale })
|
||||
}
|
||||
|
||||
@@ -450,17 +451,15 @@ impl<'a> Renderer<'a> {
|
||||
/// in layer coordinates): runs of GPU-capable effects go to [`FxTarget::gpu`] (when an
|
||||
/// accelerator is active and supports them), the rest to [`FxTarget::cpu`] one by one
|
||||
/// (quantised to the bit depth after each).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn run_effect_stack(
|
||||
&self,
|
||||
ctx: &EvalCtx,
|
||||
layer: &Layer,
|
||||
adjustment: bool,
|
||||
limit: usize,
|
||||
stack: EffectStack,
|
||||
mut timing: Option<&mut Vec<(String, f64)>>,
|
||||
origin: [f64; 2],
|
||||
target: &mut dyn FxTarget,
|
||||
) {
|
||||
let EffectStack { adjustment, limit, origin } = stack;
|
||||
if !layer.switches.effects {
|
||||
return;
|
||||
}
|
||||
@@ -595,8 +594,10 @@ impl<'a> Renderer<'a> {
|
||||
if self.project.comp_contains(*item, ctx.comp_id) {
|
||||
return None;
|
||||
}
|
||||
// A composition proxy (a rendered still or movie) stands in for the comp.
|
||||
// Nothing before the nested comp starts or after it ends.
|
||||
// A composition proxy (a rendered still or movie) stands in for the comp. It is
|
||||
// decoded like footage: its Separate Fields, Invert Alpha and Interpret As Linear
|
||||
// Light settings and the layer's Frame Blending apply. Nothing before the nested
|
||||
// comp starts or after it ends.
|
||||
let lt = ctx.nested_time(layer);
|
||||
if !self.project.comp(*item).is_some_and(|nc| nc.covers(lt)) {
|
||||
return None;
|
||||
@@ -604,7 +605,7 @@ impl<'a> Renderer<'a> {
|
||||
if let Some(pf) = self.proxy_for(*item)
|
||||
&& let Some(nc) = self.project.comp(*item)
|
||||
{
|
||||
let img = self.footage.frame_with(*item, pf, lt, None, self.pipe.float_encoding())?;
|
||||
let img = self.footage_frame(ctx, layer, *item, pf, lt, None)?;
|
||||
return Some(self.footage_buf(&img, pf, nc.width, nc.height));
|
||||
}
|
||||
// Essential Properties overrides render the nested comp with this instance's
|
||||
@@ -738,6 +739,10 @@ impl<'a> Renderer<'a> {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
(0x00f8_a3e5_u64, item, i, (w as f32).to_bits(), mode as u8, f.frame_rate.num, f.frame_rate.den, &f.path, float).hash(&mut h);
|
||||
(f.alpha as u8).hash(&mut h);
|
||||
if f.alpha == effectcraft_project::AlphaMode::Premultiplied {
|
||||
f.premul_color.map(f32::to_bits).hash(&mut h);
|
||||
}
|
||||
h.finish()
|
||||
});
|
||||
if let (Some(c), Some(k)) = (self.cache, key)
|
||||
@@ -873,7 +878,7 @@ impl<'a> Renderer<'a> {
|
||||
let origin = ctx.effect_bounds(layer).1;
|
||||
buf.rebase(origin);
|
||||
if effects > 0 {
|
||||
buf = self.effect_space_stack(ctx, layer, buf, false, effects, None, origin);
|
||||
buf = self.effect_space_stack(ctx, layer, buf, EffectStack { adjustment: false, limit: effects, origin }, None);
|
||||
}
|
||||
let buf = Arc::new(buf);
|
||||
// The buffer the cache keeps (another worker's, if it kept one first).
|
||||
|
||||
@@ -211,16 +211,16 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<B
|
||||
}
|
||||
"star" => {
|
||||
let star = ctx.e(layer, g, "type") == 0;
|
||||
let p = effectcraft_path::polystar(
|
||||
let p = effectcraft_path::polystar(effectcraft_path::Polystar {
|
||||
star,
|
||||
ctx.f(layer, g, "points", 5.0),
|
||||
ctx.v2(layer, g, "position", [0.0; 2]),
|
||||
ctx.f(layer, g, "rotation", 0.0),
|
||||
ctx.f(layer, g, "innerRadius", 50.0),
|
||||
ctx.f(layer, g, "outerRadius", 100.0),
|
||||
ctx.f(layer, g, "innerRoundness", 0.0),
|
||||
ctx.f(layer, g, "outerRoundness", 0.0),
|
||||
);
|
||||
points: ctx.f(layer, g, "points", 5.0),
|
||||
pos: ctx.v2(layer, g, "position", [0.0; 2]),
|
||||
rotation: ctx.f(layer, g, "rotation", 0.0),
|
||||
inner: ctx.f(layer, g, "innerRadius", 50.0),
|
||||
outer: ctx.f(layer, g, "outerRadius", 100.0),
|
||||
inner_round: ctx.f(layer, g, "innerRoundness", 0.0),
|
||||
outer_round: ctx.f(layer, g, "outerRoundness", 0.0),
|
||||
});
|
||||
push_shape(ctx, layer, g, p, arena, &mut live);
|
||||
}
|
||||
"path" => {
|
||||
@@ -324,6 +324,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<B
|
||||
speed: ctx.f(layer, g, "speed", 2.0),
|
||||
correlation: ctx.f(layer, g, "correlation", 50.0),
|
||||
phase_deg: ctx.f(layer, g, "phase", 0.0),
|
||||
spatial_phase_deg: ctx.f(layer, g, "spatialPhase", 0.0),
|
||||
seed: ctx.f(layer, g, "seed", 0.0),
|
||||
};
|
||||
let t = layer.layer_time(ctx.time).seconds();
|
||||
|
||||
@@ -258,6 +258,24 @@ fn round_corners_clip_corner_pixels() {
|
||||
assert!((coverage(&img) - std::f32::consts::PI * 400.0).abs() < 15.0, "{}", coverage(&img));
|
||||
}
|
||||
|
||||
/// Wiggle Paths' Spatial Phase changes the drawn shape; a whole turn draws it as at 0°.
|
||||
#[test]
|
||||
fn wiggle_paths_spatial_phase_renders() {
|
||||
let render = |phase: f64| {
|
||||
let (p, cid) = shape_comp(|ids| {
|
||||
vec![
|
||||
build::shape_ellipse(ids, [60.0, 60.0], [0.0, 0.0]),
|
||||
op(ids, "wiggle", &[("size", Value::Scalar(8.0)), ("correlation", Value::Scalar(0.0)), ("spatialPhase", Value::Scalar(phase))]),
|
||||
build::shape_fill(ids, RED),
|
||||
]
|
||||
});
|
||||
render_frame(&p, cid, Tick::from_seconds_f64(0.5), 1.0).data
|
||||
};
|
||||
let zero = render(0.0);
|
||||
assert!(render(90.0) != zero, "Spatial Phase 90° draws the shape of 0°");
|
||||
assert!(render(360.0) == zero, "Spatial Phase 360° draws another shape than 0°");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wiggle_paths_vary_over_time_deterministically() {
|
||||
let (p, cid) = shape_comp(|ids| {
|
||||
|
||||
+56
-35
@@ -42,7 +42,7 @@ pub struct CharXf {
|
||||
pub stroke_hsb: [f64; 3],
|
||||
pub stroke_opacity: f64,
|
||||
pub stroke_width: f64,
|
||||
/// Tracking (1/1000 em) added before / after the character.
|
||||
/// Tracking (pixels) added before / after the character.
|
||||
pub track_before: f64,
|
||||
pub track_after: f64,
|
||||
/// Line Anchor (percent) and the selection weight it was set with.
|
||||
@@ -287,11 +287,13 @@ pub fn char_transforms(ctx: &EvalCtx, layer: &Layer, text: &PropGroup, lay: &Tex
|
||||
let pos = get("position").map(|v| c3(v, 0.0));
|
||||
let anchor = get("anchor").map(|v| c3(v, 0.0));
|
||||
let scale = get("scale").map(|v| c3(v, 100.0));
|
||||
let rot = get1("rotation");
|
||||
let rx = get1("rotationX");
|
||||
let ry = get1("rotationY");
|
||||
let skew = get1("skew");
|
||||
let skew_axis = get1("skewAxis");
|
||||
let angles = CharacterAngles {
|
||||
rotation: get1("rotation"),
|
||||
rotation_x: get1("rotationX"),
|
||||
rotation_y: get1("rotationY"),
|
||||
skew: get1("skew"),
|
||||
skew_axis: get1("skewAxis"),
|
||||
};
|
||||
let op = get1("opacity");
|
||||
let fill = props.get("fillColor").map(|p| ctx.value(layer, p).as_color());
|
||||
let stroke = props.get("strokeColor").map(|p| ctx.value(layer, p).as_color());
|
||||
@@ -338,21 +340,7 @@ pub fn char_transforms(ctx: &EvalCtx, layer: &Layer, text: &PropGroup, lay: &Tex
|
||||
c.scale[d] *= 1.0 + (s[d] / 100.0 - 1.0) * k3[d];
|
||||
}
|
||||
}
|
||||
if let Some(r) = rot {
|
||||
c.rotation += r * k;
|
||||
}
|
||||
if let Some(r) = rx {
|
||||
c.rotation_x += r * k;
|
||||
}
|
||||
if let Some(r) = ry {
|
||||
c.rotation_y += r * k;
|
||||
}
|
||||
if let Some(s) = skew {
|
||||
c.skew += s * k;
|
||||
}
|
||||
if let Some(a) = skew_axis {
|
||||
c.skew_axis = a;
|
||||
}
|
||||
angles.apply(c, k);
|
||||
if let Some(o) = op {
|
||||
c.opacity *= 1.0 + (o / 100.0 - 1.0) * k;
|
||||
}
|
||||
@@ -381,17 +369,7 @@ pub fn char_transforms(ctx: &EvalCtx, layer: &Layer, text: &PropGroup, lay: &Tex
|
||||
if let Some(w) = sw {
|
||||
c.stroke_width += w * k;
|
||||
}
|
||||
if let Some(t) = tracking {
|
||||
let t = t * k;
|
||||
match tracking_type {
|
||||
1 => c.track_before += t,
|
||||
2 => c.track_after += t,
|
||||
_ => {
|
||||
c.track_before += t * 0.5;
|
||||
c.track_after += t * 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
apply_tracking(c, tracking, tracking_type, k);
|
||||
if let Some(a) = line_anchor
|
||||
&& k.abs() >= c.line_anchor_k
|
||||
{
|
||||
@@ -432,6 +410,50 @@ pub fn char_transforms(ctx: &EvalCtx, layer: &Layer, text: &PropGroup, lay: &Tex
|
||||
out
|
||||
}
|
||||
|
||||
/// Optional animator orientation properties; an omitted property leaves the glyph unchanged.
|
||||
struct CharacterAngles {
|
||||
rotation: Option<f64>,
|
||||
rotation_x: Option<f64>,
|
||||
rotation_y: Option<f64>,
|
||||
skew: Option<f64>,
|
||||
skew_axis: Option<f64>,
|
||||
}
|
||||
|
||||
impl CharacterAngles {
|
||||
fn apply(&self, c: &mut CharXf, k: f64) {
|
||||
if let Some(r) = self.rotation {
|
||||
c.rotation += r * k;
|
||||
}
|
||||
if let Some(r) = self.rotation_x {
|
||||
c.rotation_x += r * k;
|
||||
}
|
||||
if let Some(r) = self.rotation_y {
|
||||
c.rotation_y += r * k;
|
||||
}
|
||||
if let Some(s) = self.skew {
|
||||
c.skew += s * k;
|
||||
}
|
||||
if let Some(a) = self.skew_axis {
|
||||
c.skew_axis = a;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Distribute selected tracking before and/or after a glyph according to Tracking Type.
|
||||
fn apply_tracking(c: &mut CharXf, tracking: Option<f64>, tracking_type: u32, k: f64) {
|
||||
if let Some(t) = tracking {
|
||||
let t = t * k;
|
||||
match tracking_type {
|
||||
1 => c.track_before += t,
|
||||
2 => c.track_after += t,
|
||||
_ => {
|
||||
c.track_before += t * 0.5;
|
||||
c.track_after += t * 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Character Offset / Character Value substitution (None = unchanged).
|
||||
pub fn substitute(ch: char, x: &CharXf) -> Option<char> {
|
||||
if ch.is_whitespace() {
|
||||
@@ -672,10 +694,9 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option<TextGeom> {
|
||||
let (mut la, mut la_k) = (50.0 * f64::from(align_index(justify)), 0.0);
|
||||
for gi in li_start..li_end {
|
||||
let x = &xfs[gi];
|
||||
let em = lay.styles.get(lay.glyphs[gi].run).map_or(doc.size, |s| s.size) / 1000.0;
|
||||
pen += x.track_before * em;
|
||||
pen += x.track_before;
|
||||
shift[gi] = pen;
|
||||
pen += x.track_after * em + vadv[gi];
|
||||
pen += x.track_after + vadv[gi];
|
||||
if x.line_anchor_k > la_k {
|
||||
la = x.line_anchor;
|
||||
la_k = x.line_anchor_k;
|
||||
|
||||
@@ -220,9 +220,9 @@ fn tracking_line_anchor_and_character_offset() {
|
||||
animator(&mut p, &mut l, &["tracking", "lineAnchor"], &["range"]);
|
||||
p.comp_mut(cid).unwrap().layers.push(l);
|
||||
let w0 = bounds_of(&glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]));
|
||||
set(&mut p.comp_mut(cid).unwrap().layers[0], "text/animators/#1/properties/tracking", Value::Scalar(500.0));
|
||||
set(&mut p.comp_mut(cid).unwrap().layers[0], "text/animators/#1/properties/tracking", Value::Scalar(20.0));
|
||||
let w1 = bounds_of(&glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0]));
|
||||
// 4 chars × 500/1000 em × 40 px: the last glyph moves ~ 3.5 × 20 px further (before & after).
|
||||
// 4 chars with 20 px tracking: the last glyph moves ~ 3.5 × 20 px further (before & after).
|
||||
assert!((w1.width() - w0.width() - 3.0 * 20.0).abs() < 1.5, "{} {}", w0.width(), w1.width());
|
||||
assert!((w1.x0 - w0.x0 - 10.0).abs() < 1.0, "first char shifts by half its tracking: {} {}", w0.x0, w1.x0);
|
||||
// Line Anchor 100%: tracking grows to the left.
|
||||
@@ -262,10 +262,10 @@ fn tracking_grows_from_each_paragraphs_alignment() {
|
||||
p.comp_mut(cid).unwrap().layers.push(l);
|
||||
glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0])
|
||||
};
|
||||
// 4 chars × 500/1000 em × 40 px: 20 px of tracking each, half before and half after.
|
||||
// 4 chars with 20 px of tracking each, half before and half after.
|
||||
for (justify, x0, centre, x1) in [(Justify::Left, 10.0, 40.0, 70.0), (Justify::Center, -30.0, 0.0, 30.0), (Justify::Right, -70.0, -40.0, -10.0)] {
|
||||
let d = TextDoc { justify, ..doc("ABCD", 40.0) };
|
||||
let (w0, w1) = (bounds_of(&tracked(d.clone(), 0.0)), bounds_of(&tracked(d, 500.0)));
|
||||
let (w0, w1) = (bounds_of(&tracked(d.clone(), 0.0)), bounds_of(&tracked(d, 20.0)));
|
||||
let got = [w1.x0 - w0.x0, w1.center().x - w0.center().x, w1.x1 - w0.x1];
|
||||
assert!(got.iter().zip([x0, centre, x1]).all(|(g, w)| (g - w).abs() < 1.0), "{justify:?}: moved {got:?}, want {:?}", [x0, centre, x1]);
|
||||
}
|
||||
@@ -277,11 +277,36 @@ fn tracking_grows_from_each_paragraphs_alignment() {
|
||||
let (a, b): (Vec<_>, Vec<_>) = paths.into_iter().partition(|g| bounds_of(std::slice::from_ref(g)).center().y < 10.0);
|
||||
[bounds_of(&a), bounds_of(&b)]
|
||||
};
|
||||
let ([a0, b0], [a1, b1]) = (lines(tracked(d.clone(), 0.0)), lines(tracked(d, 500.0)));
|
||||
let ([a0, b0], [a1, b1]) = (lines(tracked(d.clone(), 0.0)), lines(tracked(d, 20.0)));
|
||||
assert!((a1.x0 - a0.x0 - 10.0).abs() < 1.0, "the left paragraph grows right: {} {}", a0.x0, a1.x0);
|
||||
assert!((b1.x1 - b0.x1 + 10.0).abs() < 1.0, "the right paragraph grows left: {} {}", b0.x1, b1.x1);
|
||||
}
|
||||
|
||||
/// #1798: After Effects adds Tracking Amount in absolute pixels between neighbouring characters,
|
||||
/// independent of font size.
|
||||
#[test]
|
||||
fn text_animator_tracking_amount_uses_pixels_independent_of_font_size() {
|
||||
let width_diff = |font_size: f64, amount: f64| {
|
||||
let (mut p, cid, comp) = setup(2000, 400);
|
||||
let mut l = text_layer(&mut p, &comp, doc("ABCDE", font_size));
|
||||
animator(&mut p, &mut l, &["tracking"], &["range"]);
|
||||
set(&mut l, "text/animators/#1/properties/tracking", Value::Scalar(amount));
|
||||
p.comp_mut(cid).unwrap().layers.push(l);
|
||||
let w = bounds_of(&glyph_paths(&ctx(&p, cid, 0.0), &p.comp(cid).unwrap().layers[0])).width();
|
||||
let (mut p0, cid0, comp0) = setup(2000, 400);
|
||||
let l0 = text_layer(&mut p0, &comp0, doc("ABCDE", font_size));
|
||||
p0.comp_mut(cid0).unwrap().layers.push(l0);
|
||||
let w0 = bounds_of(&glyph_paths(&ctx(&p0, cid0, 0.0), &p0.comp(cid0).unwrap().layers[0])).width();
|
||||
w - w0
|
||||
};
|
||||
// 5 characters = 4 gaps. In After Effects, Tracking Amount 100 adds 100 px per gap = 400 px total width delta,
|
||||
// regardless of whether font size is 50 px or 200 px.
|
||||
let diff_50 = width_diff(50.0, 100.0);
|
||||
let diff_200 = width_diff(200.0, 100.0);
|
||||
assert!((diff_50 - 400.0).abs() < 1.0, "font_size 50 px width delta: {diff_50}");
|
||||
assert!((diff_200 - 400.0).abs() < 1.0, "font_size 200 px width delta: {diff_200}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchor_grouping_line_rotates_around_line_centre() {
|
||||
let (mut p, cid, comp) = setup(600, 300);
|
||||
|
||||
@@ -82,9 +82,20 @@ pub fn f_schlick(f0: [f32; 3], v_h: f32) -> [f32; 3] {
|
||||
[f0[0] + (1.0 - f0[0]) * k, f0[1] + (1.0 - f0[1]) * k, f0[2] + (1.0 - f0[2]) * k]
|
||||
}
|
||||
|
||||
/// Surface parameters used by the direct-light BRDF after texture sampling.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct BrdfSurface {
|
||||
pub base: [f32; 3],
|
||||
pub metallic: f32,
|
||||
pub roughness: f32,
|
||||
pub specular: f32,
|
||||
pub diffuse: f32,
|
||||
}
|
||||
|
||||
/// Cook–Torrance BRDF × N·L × π for one light: (diffuse colour factor, specular colour).
|
||||
/// Returned as the reflected fraction of the light's irradiance.
|
||||
pub fn brdf(base: [f32; 3], metallic: f32, rough: f32, spec_k: f32, diff_k: f32, n: [f32; 3], v: [f32; 3], l: [f32; 3]) -> [f32; 3] {
|
||||
pub fn brdf(surface: BrdfSurface, n: [f32; 3], v: [f32; 3], l: [f32; 3]) -> [f32; 3] {
|
||||
let BrdfSurface { base, metallic, roughness: rough, specular: spec_k, diffuse: diff_k } = surface;
|
||||
let n_l = dot(n, l);
|
||||
if n_l <= 0.0 {
|
||||
return [0.0; 3];
|
||||
@@ -217,6 +228,25 @@ fn tap_hash(x: i32, y: i32) -> u32 {
|
||||
h
|
||||
}
|
||||
|
||||
/// The jitter of tap (`i`, `j`), each axis in [−0.5, 0.5): [`tap_hash`] values at the four
|
||||
/// corners of the receiver's 1/8-texel lattice cell (`cx`, `cy`), blended bilinearly by the
|
||||
/// receiver's position in the cell (`wx`, `wy`). The jitter is continuous in the receiver's
|
||||
/// map position, so a position that differs between backends in its last bits moves the
|
||||
/// taps by an amount proportional to that difference and does not switch them to another
|
||||
/// cell's jitter.
|
||||
fn tap_jitter(cx: i32, cy: i32, wx: f32, wy: f32, i: i32, j: i32) -> (f32, f32) {
|
||||
let corner = |dx: i32, dy: i32| -> (f32, f32) {
|
||||
let h = tap_hash(cx.wrapping_add(dx).wrapping_add(i.wrapping_mul(7919)), cy.wrapping_add(dy).wrapping_add(j.wrapping_mul(7919)));
|
||||
(((h & 0xFFFF) as f32) * (1.0 / 65536.0) - 0.5, (((h >> 16) & 0xFFFF) as f32) * (1.0 / 65536.0) - 0.5)
|
||||
};
|
||||
let (c00, c10, c01, c11) = (corner(0, 0), corner(1, 0), corner(0, 1), corner(1, 1));
|
||||
let ax = c00.0 + (c10.0 - c00.0) * wx;
|
||||
let bx = c01.0 + (c11.0 - c01.0) * wx;
|
||||
let ay = c00.1 + (c10.1 - c00.1) * wx;
|
||||
let by = c01.1 + (c11.1 - c01.1) * wx;
|
||||
(ax + (bx - ax) * wy, ay + (by - ay) * wy)
|
||||
}
|
||||
|
||||
/// 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).
|
||||
@@ -232,8 +262,9 @@ const PLANE_SLOPE_MAX: f32 = 64.0;
|
||||
///
|
||||
/// 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
|
||||
/// texel footprint, each tap jittered by [`tap_jitter`] (an integer hash of the
|
||||
/// receiver's sub-texel lattice and the tap index, blended across the receiver's
|
||||
/// lattice cell), 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
|
||||
@@ -301,13 +332,13 @@ pub fn shadow_factor(m: &ShadowMap, texels: &[f32], p: [f32; 3], ng: [f32; 3], n
|
||||
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 (gu, gv) = (u * 8.0, v * 8.0);
|
||||
let (cx, cy) = (gu.floor() as i32, gv.floor() as i32);
|
||||
let (wx, wy) = (gu - gu.floor(), gv - gv.floor());
|
||||
let mut lit = 0.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 (jx, jy) = tap_jitter(cx, cy, wx, wy, i, j);
|
||||
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.
|
||||
@@ -484,7 +515,7 @@ pub fn shade(s: &Scene, f: &Frag) -> Option<[f32; 4]> {
|
||||
continue;
|
||||
}
|
||||
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);
|
||||
let b = brdf(BrdfSurface { base: rgb, metallic, roughness: rough, specular: m.specular_k, diffuse: m.diffuse_k }, n, v, to_l);
|
||||
for c in 0..3 {
|
||||
out[c] += b[c] * l.color[c] * k * sh;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ fn brdf_matches_cook_torrance_formula() {
|
||||
let v = shade::norm([0.3, 0.1, 1.0]);
|
||||
let l = shade::norm([-0.4, 0.2, 1.0]);
|
||||
let (base, metal, rough) = ([0.8, 0.4, 0.2], 0.3f32, 0.45f32);
|
||||
let got = shade::brdf(base, metal, rough, 1.0, 1.0, n, v, l);
|
||||
let got = shade::brdf(shade::BrdfSurface { base, metallic: metal, roughness: rough, specular: 1.0, diffuse: 1.0 }, n, v, l);
|
||||
let h = shade::norm([v[0] + l[0], v[1] + l[1], v[2] + l[2]]);
|
||||
let (nv, nl, nh, vh) = (shade::dot(n, v), shade::dot(n, l), shade::dot(n, h), shade::dot(v, h));
|
||||
let a2 = (rough * rough).powi(2);
|
||||
@@ -30,9 +30,9 @@ fn brdf_matches_cook_torrance_formula() {
|
||||
assert!((got[c] - expect).abs() < 1e-5, "channel {c}: {} vs {expect}", got[c]);
|
||||
}
|
||||
// No light from behind the surface.
|
||||
assert_eq!(shade::brdf(base, metal, rough, 1.0, 1.0, n, v, [0.0, 0.0, -1.0]), [0.0; 3]);
|
||||
assert_eq!(shade::brdf(shade::BrdfSurface { base, metallic: metal, roughness: rough, specular: 1.0, diffuse: 1.0 }, n, v, [0.0, 0.0, -1.0]), [0.0; 3]);
|
||||
// A white rough dielectric lit head-on reflects about its base colour (π folded in).
|
||||
let w = shade::brdf([1.0; 3], 0.0, 1.0, 1.0, 1.0, n, n, n);
|
||||
let w = shade::brdf(shade::BrdfSurface { base: [1.0; 3], metallic: 0.0, roughness: 1.0, specular: 1.0, diffuse: 1.0 }, n, n, n);
|
||||
assert!(w[0] > 0.95 && w[0] < 1.05, "{w:?}");
|
||||
}
|
||||
|
||||
@@ -887,6 +887,26 @@ fn penumbra_dithers_along_a_straight_edge() {
|
||||
assert!(distinct(&within) >= 4, "coverage inside one texel takes {} distinct values", distinct(&within));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn penumbra_jitter_is_continuous_across_its_lattice() {
|
||||
// Receiver pairs 1/5000 texel either side of a line of the 1/8-texel jitter lattice,
|
||||
// across u and across v, inside the penumbra. The GPU places a receiver within a few
|
||||
// ulps of the CPU, so coverage that jumps at a lattice line differs between the two
|
||||
// backends there; both sides of each line must agree within one 8-bit level.
|
||||
let (m, texels) = wall_map(64, 1.0, 6.0);
|
||||
let f = |p: [f32; 2]| shade::shadow_factor(&m, &texels, [p[0], p[1], 13.5], FACING, 1.0);
|
||||
let mut worst = 0.0f32;
|
||||
for k in 0..16 {
|
||||
let line = k as f32 * 0.125;
|
||||
for (a, b) in [([-16.0 + line - 2e-4, 0.3], [-16.0 + line + 2e-4, 0.3]), ([-16.3, line - 2e-4], [-16.3, line + 2e-4])] {
|
||||
let (fa, fb) = (f(a), f(b));
|
||||
assert!(fa > 0.05 && fa < 0.95, "{a:?} lies in the penumbra: {fa}");
|
||||
worst = worst.max((fa - fb).abs());
|
||||
}
|
||||
}
|
||||
assert!(worst < 1.0 / 255.0, "coverage jumps by {worst} across a jitter lattice line");
|
||||
}
|
||||
|
||||
#[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
|
||||
|
||||
@@ -197,7 +197,7 @@ fn mb_samples(r: &Renderer, ctx: &EvalCtx, layer: &Layer) -> usize {
|
||||
/// Prepare a layer (buffer, geometry, material). `None` when it shows nothing.
|
||||
pub(crate) fn prepare<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool) -> Option<Item<'a>> {
|
||||
let buf = r.blend_layer_buf(ctx, layer)?;
|
||||
prepare_with(r, ctx, layer, order, out, in_run, buf, Mat4::IDENTITY)
|
||||
prepare_with(r, ctx, layer, order, out, in_run, PlaneInput { buf, local: Mat4::IDENTITY })
|
||||
}
|
||||
|
||||
/// Prepare a layer as planes: one per character for per-character 3D text, else one.
|
||||
@@ -205,24 +205,21 @@ pub(crate) fn prepare_all<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer,
|
||||
if crate::text::per_char_3d(ctx, layer) {
|
||||
return crate::text::per_char_planes(ctx, layer, r.opts.scale)
|
||||
.into_iter()
|
||||
.filter_map(|(buf, m)| prepare_with(r, ctx, layer, order, out, in_run, r.to_blend(Arc::new(buf), None), m))
|
||||
.filter_map(|(buf, m)| prepare_with(r, ctx, layer, order, out, in_run, PlaneInput { buf: r.to_blend(Arc::new(buf), None), local: m }))
|
||||
.collect();
|
||||
}
|
||||
prepare(r, ctx, layer, order, out, in_run).into_iter().collect()
|
||||
}
|
||||
|
||||
/// Prepare a plane showing `buf`, whose space maps into the layer through `local`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn prepare_with<'a>(
|
||||
r: &Renderer,
|
||||
ctx: &EvalCtx<'a>,
|
||||
layer: &'a Layer,
|
||||
order: usize,
|
||||
out: (u32, u32),
|
||||
in_run: bool,
|
||||
mut buf: Arc<Buf>,
|
||||
/// A processed plane and its mapping into the layer's coordinate system.
|
||||
struct PlaneInput {
|
||||
buf: Arc<Buf>,
|
||||
local: Mat4,
|
||||
) -> Option<Item<'a>> {
|
||||
}
|
||||
|
||||
/// Prepare the plane's geometry, motion samples and material.
|
||||
fn prepare_with<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool, plane: PlaneInput) -> Option<Item<'a>> {
|
||||
let PlaneInput { mut buf, local } = plane;
|
||||
if buf.img.is_empty() {
|
||||
return None;
|
||||
}
|
||||
@@ -648,6 +645,13 @@ fn shade_texel(it: &Item, g: &Geo, u: f64, v: f64, t: Px, lights: &[LightState],
|
||||
[lit[0] * a, lit[1] * a, lit[2] * a, a]
|
||||
}
|
||||
|
||||
/// Samples per side of the Shadow Diffusion kernel for a penumbra radius of `r_px` caster pixels:
|
||||
/// odd, at least 3, and enough that neighbouring samples are at most 1.5 pixels apart, up to 15.
|
||||
pub(crate) fn shadow_taps(r_px: f64) -> u32 {
|
||||
let n = ((2.0 * r_px / 1.5).ceil() as u32).clamp(3, 15);
|
||||
n | 1
|
||||
}
|
||||
|
||||
/// RGB transmittance of light `l` reaching `p` past caster `c` (1 = unoccluded).
|
||||
fn occlusion(c: &Item, p: Vec3, l: &LightState) -> [f32; 3] {
|
||||
const FAR: f64 = 1.0e6;
|
||||
@@ -675,11 +679,17 @@ fn occlusion(c: &Item, p: Vec3, l: &LightState) -> [f32; 3] {
|
||||
if r * c.buf.scale < 0.5 {
|
||||
px = c.texel(hit.x, hit.y);
|
||||
} else {
|
||||
for j in -1..=1 {
|
||||
for i in -1..=1 {
|
||||
let q = c.texel(hit.x + i as f64 * r * 0.66, hit.y + j as f64 * r * 0.66);
|
||||
// Box filter over the penumbra square: n × n cell centres, dense enough that a wide
|
||||
// diffusion blurs the caster instead of stamping copies of it (#2444).
|
||||
let n = shadow_taps(r * c.buf.scale);
|
||||
let step = 2.0 * r / n as f64;
|
||||
let h = (n / 2) as i32;
|
||||
let w = 1.0 / (n * n) as f32;
|
||||
for j in -h..=h {
|
||||
for i in -h..=h {
|
||||
let q = c.texel(hit.x + f64::from(i) * step, hit.y + f64::from(j) * step);
|
||||
for k in 0..4 {
|
||||
px[k] += q[k] / 9.0;
|
||||
px[k] += q[k] * w;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -344,6 +344,33 @@ fn shadow_darkness_and_accepts_off() {
|
||||
assert!(close(v, 0.5, 1e-3), "no shadow accepted: {v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadow_diffusion_is_a_soft_ramp_not_copies() {
|
||||
// #2444: a wide Shadow Diffusion stamped 3 × 3 copies of the caster, so the penumbra stepped
|
||||
// through three flat levels instead of ramping smoothly from lit to shadowed.
|
||||
let (mut p, cid, comp) = setup();
|
||||
let mut recv = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 400, 200, [100.0, 50.0, 100.0]);
|
||||
no_specular(&mut recv);
|
||||
let mut caster = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 100, 200, [40.0, 50.0, 0.0]);
|
||||
set(&mut caster, "materialOptions/castsShadows", Value::Enum(2)); // Only: invisible caster
|
||||
let mut l = light(&mut p, &comp, LightKind::Parallel, [100.0, 50.0, -500.0]);
|
||||
set(&mut l, "transform/poi", Value::Vec3([100.0, 50.0, 0.0]));
|
||||
set(&mut l, "lightOptions/castsShadows", Value::Bool(true));
|
||||
// 100 px to the caster over the parallel light's 1000: a 15 px penumbra radius.
|
||||
set(&mut l, "lightOptions/shadowDiffusion", Value::Scalar(150.0));
|
||||
push(&mut p, cid, l);
|
||||
push(&mut p, cid, caster);
|
||||
push(&mut p, cid, recv);
|
||||
let img = render(&p, cid);
|
||||
let row: Vec<f32> = (0..i64::from(W)).map(|x| img.get(x, 50)[0]).collect();
|
||||
// The row crosses both vertical edges of the caster's shadow: lit, ramp, shadowed, ramp, lit.
|
||||
assert!(row.iter().any(|v| *v < 0.01) && close(row[W as usize - 1], 0.5, 1e-3), "shadow and light: {row:?}");
|
||||
let ramp = row.iter().filter(|v| **v > 0.02 && **v < 0.48).count();
|
||||
assert!(ramp >= 16, "two penumbras several pixels wide: {row:?}");
|
||||
let step = row.windows(2).map(|w| (w[1] - w[0]).abs()).fold(0.0f32, f32::max);
|
||||
assert!(step < 0.08, "no jumps between copies of the caster (largest step {step}): {row:?}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- cameras
|
||||
|
||||
fn camera(p: &mut Project, comp: &Comp) -> Layer {
|
||||
|
||||
Reference in New Issue
Block a user