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;
|
||||
}
|
||||
|
||||
@@ -580,7 +580,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);
|
||||
|
||||
+45
-23
@@ -21,7 +21,7 @@
|
||||
|
||||
use effectcraft_color::{ColorSpace, Conversion};
|
||||
use effectcraft_project::{Footage, HdrMode, ProjectSettings};
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_raster::{Image, quantize_px};
|
||||
use rayon::prelude::*;
|
||||
|
||||
/// The colour pipeline of a project (see the module docs).
|
||||
@@ -218,27 +218,6 @@ pub fn quantize(img: &mut Image, levels: f32) {
|
||||
quantize_rect(img, PxRect { x0: 0, y0: 0, x1: img.width, y1: img.height }, levels);
|
||||
}
|
||||
|
||||
/// Clamp (colour ≤ alpha ≤ 1, NaN → 0) and round half up; values are non-negative after the
|
||||
/// clamp, so the integer conversion rounds like `round()` and vectorises.
|
||||
#[allow(clippy::manual_clamp)] // max/min map NaN to 0; clamp would keep NaN
|
||||
#[inline(always)]
|
||||
fn quantize_px(p: &mut [f32; 4], levels: f32, inv: f32) {
|
||||
let a = round_half_up(p[3].max(0.0).min(1.0) * levels) * inv;
|
||||
let hi = [a, a, a, a];
|
||||
for c in 0..3 {
|
||||
p[c] = round_half_up(p[c].max(0.0).min(hi[c]) * levels) * inv;
|
||||
}
|
||||
p[3] = a;
|
||||
}
|
||||
|
||||
/// `v` (0..=65 535) rounded half up. The same either way; the fastest on each target: x86-64's
|
||||
/// baseline converts to integers in one instruction but has no `floor` (SSE4.1), while in wasm
|
||||
/// `floor` is one instruction and converting back from `u32` several (about 1.5× faster there).
|
||||
#[inline(always)]
|
||||
fn round_half_up(v: f32) -> f32 {
|
||||
if cfg!(target_arch = "wasm32") { (v + 0.5).floor() } else { (v + 0.5) as u32 as f32 }
|
||||
}
|
||||
|
||||
/// [`quantize`] restricted to a rectangle (already clamped to the image), row-parallel.
|
||||
fn quantize_rect(img: &mut Image, r: PxRect, levels: f32) {
|
||||
if r.x0 >= r.x1 || r.y0 >= r.y1 {
|
||||
@@ -248,7 +227,17 @@ fn quantize_rect(img: &mut Image, r: PxRect, levels: f32) {
|
||||
let w = img.width as usize;
|
||||
let (x0, x1) = (r.x0 as usize, r.x1 as usize);
|
||||
img.data[r.y0 as usize * w..r.y1 as usize * w].par_chunks_mut(w).for_each(|row| {
|
||||
for p in &mut row[x0..x1] {
|
||||
// Runs of all-zero pixels (+0.0 in every channel) quantise to themselves: skip them a
|
||||
// cache line at a time (sparse shape, precomp and glow buffers are mostly empty).
|
||||
let (blocks, rest) = row[x0..x1].as_chunks_mut::<4>();
|
||||
for b in blocks {
|
||||
if b.as_flattened().iter().fold(0, |a, v| a | v.to_bits()) != 0 {
|
||||
for p in b {
|
||||
quantize_px(p, levels, inv);
|
||||
}
|
||||
}
|
||||
}
|
||||
for p in rest {
|
||||
quantize_px(p, levels, inv);
|
||||
}
|
||||
});
|
||||
@@ -307,6 +296,39 @@ pub fn output_hdr(img: &mut Image, to_linear: Option<Conversion>, mode: HdrMode,
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Skipping all-zero runs leaves every pixel as quantising it one by one does, bit for bit
|
||||
/// (negative zeros and lone pixels in empty runs included).
|
||||
#[test]
|
||||
fn quantize_skips_only_zero_runs() {
|
||||
let mut img = Image::new(23, 7);
|
||||
for (i, p) in img.data.iter_mut().enumerate() {
|
||||
*p = match i % 11 {
|
||||
0 => [0.3, -0.2, 0.71, 0.9],
|
||||
3 => [-0.0, 0.0, 0.0, 0.0],
|
||||
5 => [0.0, 0.0, 0.0, -0.0],
|
||||
7 => [1.7, 0.4, f32::NAN, 0.5],
|
||||
_ => [0.0; 4],
|
||||
};
|
||||
}
|
||||
for levels in [255.0f32, 32_768.0] {
|
||||
let mut want = img.clone();
|
||||
for p in &mut want.data {
|
||||
quantize_px(p, levels, 1.0 / levels);
|
||||
}
|
||||
for r in [PxRect { x0: 0, y0: 0, x1: 23, y1: 7 }, PxRect { x0: 3, y0: 1, x1: 20, y1: 6 }] {
|
||||
let mut got = img.clone();
|
||||
quantize_rect(&mut got, r, levels);
|
||||
for (i, (g, p)) in got.data.iter().zip(&img.data).enumerate() {
|
||||
let (x, y) = (i as u32 % 23, i as u32 / 23);
|
||||
let w = if x >= r.x0 && x < r.x1 && y >= r.y0 && y < r.y1 { want.data[i] } else { *p };
|
||||
assert_eq!(g.map(f32::to_bits), w.map(f32::to_bits), "{levels} {r:?} px ({x}, {y})");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Both forms `round_half_up` picks between agree on everything quantising feeds it:
|
||||
/// 0..=levels, exact halves included.
|
||||
#[test]
|
||||
|
||||
+150
-19
@@ -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 };
|
||||
@@ -1033,9 +1102,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));
|
||||
@@ -1045,7 +1114,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
|
||||
|
||||
@@ -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,11 +352,17 @@ 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, 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`]).
|
||||
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() {
|
||||
composite_warp(target, &buf.img, m, &opts);
|
||||
return;
|
||||
return composite_warp_changed(target, &buf.img, m, &opts, quantize);
|
||||
}
|
||||
// Sub-samples sum straight into one buffer (each warp is row-parallel).
|
||||
let mut acc = Image::new(target.width, target.height);
|
||||
@@ -355,6 +371,7 @@ impl<'a> Renderer<'a> {
|
||||
effectcraft_raster::accumulate_warp(&mut acc, &buf.img, m, opts.sampling, k);
|
||||
}
|
||||
target.blend_from(&acc, mode, opacity, pl.seed);
|
||||
None
|
||||
}
|
||||
|
||||
/// Draw a 2D layer into `canvas`; returns the region it may have changed.
|
||||
@@ -377,8 +394,14 @@ impl<'a> Renderer<'a> {
|
||||
let st = self.styled_to_blend(st);
|
||||
let t1 = web_time::Instant::now();
|
||||
let changed = if st.plain {
|
||||
self.composite_layer(ctx, layer, &st.body, canvas, opacity);
|
||||
self.footprint(ctx, layer, &st.body, opacity, blank)
|
||||
// Pixels the warp tracks are quantised as it blends them (nothing left to do); the
|
||||
// footprint is still needed when it doesn't, or when no pixel needs quantising.
|
||||
let footprint = self.footprint(ctx, layer, &st.body, opacity, blank);
|
||||
let quantize = if footprint == Region::Empty { None } else { self.pipe.levels };
|
||||
match self.composite_layer(ctx, layer, &st.body, canvas, opacity, quantize) {
|
||||
Some(_) if quantize.is_some() => Region::Empty,
|
||||
drawn => narrow(footprint, drawn),
|
||||
}
|
||||
} else {
|
||||
self.composite_styled(ctx, layer, &st, canvas, opacity);
|
||||
Region::Full
|
||||
@@ -432,16 +455,18 @@ impl<'a> Renderer<'a> {
|
||||
});
|
||||
}
|
||||
|
||||
fn composite_layer(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf, canvas: &mut Image, opacity: f32) {
|
||||
/// Composite a plain layer; returns the canvas pixels it changed when they are known (then
|
||||
/// also quantised to `quantize` levels).
|
||||
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 {
|
||||
self.place(ctx, layer, buf, canvas, layer.blend_mode, opacity);
|
||||
return;
|
||||
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);
|
||||
self.place(ctx, layer, buf, &mut iso, BlendMode::Normal, 1.0);
|
||||
self.composite_iso(ctx, layer, iso, canvas, opacity);
|
||||
None
|
||||
}
|
||||
|
||||
/// Apply the track matte / preserve transparency to an isolated layer render, then blend.
|
||||
@@ -556,3 +581,21 @@ pub struct Placement {
|
||||
/// Dissolve noise seed.
|
||||
pub seed: u32,
|
||||
}
|
||||
|
||||
/// The part of `footprint` a warp actually changed (`drawn`, see [`composite_warp_changed`]):
|
||||
/// pixels it left alone are still quantised.
|
||||
fn narrow(footprint: Region, drawn: Option<[u32; 4]>) -> Region {
|
||||
let Some([x0, y0, x1, y1]) = drawn else { return footprint };
|
||||
if x0 >= x1 || y0 >= y1 {
|
||||
return Region::Empty;
|
||||
}
|
||||
let r = color::PxRect { x0, y0, x1, y1 };
|
||||
match footprint {
|
||||
Region::Empty => Region::Empty,
|
||||
Region::Full => Region::Rect(r),
|
||||
Region::Rect(f) => {
|
||||
let q = color::PxRect { x0: r.x0.max(f.x0), y0: r.y0.max(f.y0), x1: r.x1.min(f.x1), y1: r.y1.min(f.y1) };
|
||||
if q.x0 >= q.x1 || q.y0 >= q.y1 { Region::Empty } else { Region::Rect(q) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ use effectcraft_effects::{Buf, EffectCtx, EffectEnv, EffectHost, LayerPixels, Pa
|
||||
use effectcraft_geom::{Mat3, Mat4, vec2};
|
||||
use effectcraft_project::{Comp, Footage, FootageKind, FrameBlend, GroupKind, ItemId, ItemKind, Layer, LayerSource, MatteKind, Project, Quality, Sampling};
|
||||
pub use effectcraft_raster::Image;
|
||||
use effectcraft_raster::{WarpOpts, composite_warp};
|
||||
use effectcraft_raster::{WarpOpts, composite_warp_changed};
|
||||
use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick};
|
||||
pub use eval::{EvalCtx, ExprHost, effect_bounds, source_size};
|
||||
use rayon::prelude::*;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -873,7 +872,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| {
|
||||
|
||||
+53
-31
@@ -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() {
|
||||
|
||||
@@ -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];
|
||||
@@ -484,7 +495,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:?}");
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user