//! Layer render cache. //! //! The processed pixels of a layer (source → masks → effects, in layer space) depend only on the //! layer's non-transform properties, its source item, selected-layer dependencies and the render //! options. Its own transform is normally applied later; referenced layers' transforms can //! affect their sampled pixels. [`LayerCache`] keeps those buffers keyed //! by a content hash of exactly those inputs, *evaluated at the frame time*, so: //! //! * a static layer (or one whose only animation is in Transform) renders once and is reused on //! every frame while scrubbing or playing; //! * editing one layer re-renders only that layer — every other layer's cached pixels stay valid //! because their keys do not change; //! * any change to a property value, keyframe, expression result, effect, mask, switch or source //! item changes the key, so stale pixels are never returned. //! //! Layers whose pixels depend on time in ways the property values do not capture (time-based //! effects such as Noise or Wave Warp, Wiggle Paths, footage, precomps) either fold the layer //! time into the key or are not cached. use std::collections::HashMap; use std::hash::{Hash, Hasher}; use std::sync::{Arc, Mutex}; use effectcraft_effects::Buf; use effectcraft_project::{ItemKind, Layer, LayerSource, Node, PropGroup}; use crate::eval::EvalCtx; mod dependencies; /// Thread-safe, memory-budgeted cache of processed layer buffers, optionally backed by the /// persistent [`DiskCache`](crate::disk_cache::DiskCache): a memory miss reads the disk, and /// buffers that were slow to render are written there (keys salted per project, see /// [`LayerCache::set_disk`]). pub struct LayerCache { inner: Mutex, disk: Mutex, u64)>>, /// Another persistent store when there is no disk cache (the browser's, [`PrefetchStore`]). store: Mutex, u64)>>, /// While set, inserts are dropped (see [`LayerCache::set_gate`]). gate: Mutex>>, } struct Entry { buf: Arc, bytes: usize, last_use: u64, } struct Inner { map: HashMap, bytes: usize, budget: usize, clock: u64, hits: u64, misses: u64, /// When each recent miss happened (to time the render that follows it). missed_at: HashMap, } /// Hit/miss counters and memory use (for perf readouts and tests). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct CacheStats { pub entries: usize, pub bytes: usize, pub hits: u64, pub misses: u64, } impl Default for LayerCache { fn default() -> Self { LayerCache::new(1 << 30) } } impl LayerCache { /// A cache holding at most `budget` bytes of pixels. pub fn new(budget: usize) -> LayerCache { LayerCache { inner: Mutex::new(Inner { map: HashMap::new(), bytes: 0, budget, clock: 0, hits: 0, misses: 0, missed_at: HashMap::new() }), disk: Mutex::new(None), store: Mutex::new(None), gate: Mutex::new(None), } } /// Drop inserts while `gate` is set. A render whose GPU readbacks are still in flight (the /// browser's deferred readbacks, see [`crate::Accelerator::pass_missed`]) computes some /// buffers from placeholders; the accelerator raises the gate from its first missing /// readback to the end of the pass, so none of them is kept (in memory or on disk). pub fn set_gate(&self, gate: Option>) { if let Ok(mut g) = self.gate.lock() { *g = gate; } } fn gated(&self) -> bool { self.gate.lock().ok().and_then(|g| g.as_ref().map(|g| g.load(std::sync::atomic::Ordering::Relaxed))).unwrap_or(false) } /// Attach (or detach) the disk cache. `salt` separates projects and footage versions (see /// `footage_salt`); keys on disk are `(salt, key)`. pub fn set_disk(&self, disk: Option<(Arc, u64)>) { if let Ok(mut d) = self.disk.lock() { *d = disk; } } /// Change the disk salt (after footage changed) keeping the attached disk cache. pub fn set_disk_salt(&self, salt: u64) { if let Ok(mut d) = self.disk.lock() && let Some((_, s)) = d.as_mut() { *s = salt; } } pub fn disk(&self) -> Option> { self.disk.lock().ok()?.as_ref().map(|d| d.0.clone()) } /// Attach (or detach) a [`LayerStore`] used when no disk cache is attached (the browser's /// Origin Private File System through a [`PrefetchStore`]); `salt` as in /// [`LayerCache::set_disk`]. pub fn set_store(&self, store: Option<(Arc, u64)>) { if let Ok(mut s) = self.store.lock() { *s = store; } } /// Change the store's salt (a new project or footage), keeping the store. pub fn set_store_salt(&self, salt: u64) { if let Ok(mut d) = self.store.lock() && let Some((_, s)) = d.as_mut() { *s = salt; } } fn disk_key(&self, key: u64) -> Option<(Arc, u128)> { let salted = |salt: u64| ((salt as u128) << 64) | key as u128; if let Some((dc, salt)) = self.disk.lock().ok()?.as_ref() { return Some((dc.clone() as Arc, salted(*salt))); } let s = self.store.lock().ok()?; let (st, salt) = s.as_ref()?; Some((st.clone(), salted(*salt))) } pub fn get(&self, key: u64) -> Option> { let mut g = self.inner.lock().ok()?; g.clock += 1; let now = g.clock; match g.map.get_mut(&key) { Some(e) => { e.last_use = now; let b = e.buf.clone(); g.hits += 1; Some(b) } None => { g.misses += 1; if g.missed_at.len() > 4096 { g.missed_at.clear(); } g.missed_at.insert(key, web_time::Instant::now()); drop(g); // Disk cache: a hit comes back into memory. let (dc, dk) = self.disk_key(key)?; let buf = dc.get_layer(dk)?; self.insert_mem(key, buf.clone()); Some(buf) } } } pub fn insert(&self, key: u64, buf: Arc) { if self.gated() { return; } let started = self.inner.lock().ok().and_then(|mut g| g.missed_at.remove(&key)); if let Some((dc, dk)) = self.disk_key(key) { let slow = started.is_some_and(|t| t.elapsed().as_secs_f64() * 1e3 >= dc.min_layer_ms() as f64); if slow { dc.put_layer(dk, &buf); } } self.insert_mem(key, buf); } fn insert_mem(&self, key: u64, buf: Arc) { let bytes = buf.img.data.len() * std::mem::size_of::(); let Ok(mut g) = self.inner.lock() else { return }; if bytes > g.budget / 4 { return; } g.clock += 1; let now = g.clock; if let Some(old) = g.map.insert(key, Entry { buf, bytes, last_use: now }) { g.bytes -= old.bytes; } g.bytes += bytes; if g.bytes > g.budget { // Evict least-recently used entries down to 3/4 of the budget. let target = g.budget / 4 * 3; let mut by_age: Vec<(u64, u64)> = g.map.iter().map(|(k, e)| (e.last_use, *k)).collect(); by_age.sort_unstable(); for (_, k) in by_age { if g.bytes <= target { break; } if let Some(e) = g.map.remove(&k) { g.bytes -= e.bytes; } } } } /// The memory budget in bytes. pub fn budget(&self) -> usize { self.inner.lock().map(|g| g.budget).unwrap_or(0) } /// Change the memory budget (evicts least-recently used entries when shrinking). pub fn set_budget(&self, budget: usize) { let Ok(mut g) = self.inner.lock() else { return }; g.budget = budget; if g.bytes > budget { let mut by_age: Vec<(u64, u64)> = g.map.iter().map(|(k, e)| (e.last_use, *k)).collect(); by_age.sort_unstable(); for (_, k) in by_age { if g.bytes <= budget { break; } if let Some(e) = g.map.remove(&k) { g.bytes -= e.bytes; } } } } pub fn clear(&self) { if let Ok(mut g) = self.inner.lock() { g.map.clear(); g.bytes = 0; } } pub fn stats(&self) -> CacheStats { self.inner.lock().map(|g| CacheStats { entries: g.map.len(), bytes: g.bytes, hits: g.hits, misses: g.misses }).unwrap_or_default() } } /// Persistent storage of layer buffers behind a [`LayerCache`] (keys salted per project). pub trait LayerStore: Send + Sync { /// A stored buffer (synchronous: mid-render). fn get_layer(&self, key: u128) -> Option>; /// Keep a buffer that was slow to render. fn put_layer(&self, key: u128, buf: &Buf); /// Buffers faster to render than this (ms) are not stored. fn min_layer_ms(&self) -> u64 { 0 } } /// Layers buffers rendered slower than this go to the browser's disk cache (ms). pub const PREFETCH_MIN_LAYER_MS: u64 = 20; /// A [`LayerStore`] over storage that can't be read in the middle of a render (the browser's /// Origin Private File System, read asynchronously). Lookups are served from buffers fetched /// before the frame ([`PrefetchStore::provide`]); the keys a render looked up and didn't find /// are recorded ([`PrefetchStore::take_misses`]) so the transport can fetch them before the next /// frame; buffers to store queue up as sealed entries ([`PrefetchStore::take_writes`]) for the /// transport to write after the frame. Fetched buffers are kept least recently used first, up /// to a byte budget. pub struct PrefetchStore { st: Mutex, budget: usize, min_ms: u64, } #[derive(Default)] struct Prefetched { ready: HashMap, u64)>, bytes: usize, clock: u64, misses: Vec, hits: Vec, writes: Vec<(u128, Vec)>, /// Keys stored or queued (not written twice). known: std::collections::HashSet, } impl Default for PrefetchStore { fn default() -> Self { PrefetchStore::new(256 << 20) } } impl PrefetchStore { pub fn new(budget: usize) -> PrefetchStore { PrefetchStore { st: Mutex::new(Prefetched::default()), budget, min_ms: PREFETCH_MIN_LAYER_MS } } /// Store buffers that took at least `ms` to render (default [`PREFETCH_MIN_LAYER_MS`]). pub fn with_min_ms(mut self, ms: u64) -> PrefetchStore { self.min_ms = ms; self } /// Whether `key`'s buffer is here. pub fn has(&self, key: u128) -> bool { self.st.lock().is_ok_and(|s| s.ready.contains_key(&key)) } /// A fetched entry (sealed bytes, [`crate::disk_cache::layer_entry`]) for `key`; `false` /// when damaged. pub fn provide(&self, key: u128, entry: &[u8]) -> bool { let Some(buf) = crate::disk_cache::read_layer_entry(entry) else { return false }; let bytes = buf.img.data.len() * std::mem::size_of::(); let Ok(mut s) = self.st.lock() else { return false }; s.clock += 1; let now = s.clock; if let Some((old, _)) = s.ready.insert(key, (Arc::new(buf), now)) { s.bytes -= old.img.data.len() * std::mem::size_of::(); } s.bytes += bytes; s.known.insert(key); while s.bytes > self.budget { let Some((&k, _)) = s.ready.iter().min_by_key(|(_, (_, t))| *t) else { break }; if let Some((b, _)) = s.ready.remove(&k) { s.bytes -= b.img.data.len() * std::mem::size_of::(); } } true } /// Keys looked up and not found since the last call (each once). pub fn take_misses(&self) -> Vec { self.st.lock().map(|mut s| std::mem::take(&mut s.misses)).unwrap_or_default() } /// Keys served since the last call (each once). pub fn take_hits(&self) -> Vec { self.st.lock().map(|mut s| std::mem::take(&mut s.hits)).unwrap_or_default() } /// Entries to write: (key, sealed bytes). pub fn take_writes(&self) -> Vec<(u128, Vec)> { self.st.lock().map(|mut s| std::mem::take(&mut s.writes)).unwrap_or_default() } /// Forget everything (Empty Disk Cache, Purge). pub fn clear(&self) { if let Ok(mut s) = self.st.lock() { *s = Prefetched::default(); } } } impl LayerStore for PrefetchStore { fn get_layer(&self, key: u128) -> Option> { let mut s = self.st.lock().ok()?; s.clock += 1; let now = s.clock; match s.ready.get_mut(&key) { Some((b, t)) => { *t = now; let b = b.clone(); if !s.hits.contains(&key) { s.hits.push(key); } Some(b) } None => { if !s.misses.contains(&key) && s.misses.len() < 1024 { s.misses.push(key); } None } } } fn put_layer(&self, key: u128, buf: &Buf) { let Ok(mut s) = self.st.lock() else { return }; if s.known.insert(key) { s.writes.push((key, crate::disk_cache::layer_entry(buf))); } } fn min_layer_ms(&self) -> u64 { self.min_ms } } /// FNV-1a style 64-bit hasher fed by `Debug` output (exact float formatting) and raw values. struct KeyHasher(u64); impl Hasher for KeyHasher { fn finish(&self) -> u64 { self.0 } fn write(&mut self, bytes: &[u8]) { for &b in bytes { self.0 ^= b as u64; self.0 = self.0.wrapping_mul(0x0100_0000_01b3); } } } impl std::fmt::Write for KeyHasher { fn write_str(&mut self, s: &str) -> std::fmt::Result { Hasher::write(self, s.as_bytes()); Ok(()) } } fn hash_debug(h: &mut KeyHasher, v: &impl std::fmt::Debug) { use std::fmt::Write; let _ = write!(h, "{v:?}"); Hasher::write_u8(h, 0xff); } /// Time-based layer content: does any part of the source/effects read the clock directly /// (rather than through property values)? fn time_dependent(layer: &Layer) -> bool { if layer.switches.effects && let Some(fx) = layer.effects() { for g in fx.groups() { if let effectcraft_project::GroupKind::Effect { effect } = &g.kind && g.enabled && effectcraft_effects::is_time_dependent(effect) { return true; } // Clone strokes reading another layer or another time. if g.enabled && effectcraft_effects::paint::is_paint(g) && effectcraft_effects::paint::cache_key(g, 0.0).1 { return true; } } } if matches!(layer.source, LayerSource::Shape) && let Some(c) = layer.props.sub("contents") { return group_has(c, "wiggle"); } // Wiggly selectors move with time; Expression selectors evaluate per character (their // property value alone doesn't capture the result). if matches!(layer.source, LayerSource::Text) && let Some(t) = layer.props.sub("text") { return group_has(t, "wigglySelector") || group_has(t, "expressionSelector"); } false } fn group_has(g: &PropGroup, match_id: &str) -> bool { g.children.iter().any(|c| match c { Node::Group(sub) => sub.match_id == match_id || group_has(sub, match_id), Node::Prop(_) => false, }) } /// Feed every property of `g` that can change over time (keyframes or expression) as its value /// at the context time. The static structure is hashed separately. fn hash_values(h: &mut KeyHasher, ctx: &EvalCtx, layer: &Layer, g: &PropGroup) { for c in &g.children { match c { Node::Prop(p) => { if !p.keys.is_empty() || p.has_expression() { p.uid.hash(h); hash_debug(h, &ctx.value(layer, p)); } } Node::Group(sub) => hash_values(h, ctx, layer, sub), } } } /// Cache key for the processed (source → masks → effects) buffer of `layer` at the context /// time, or `None` when the layer must not be cached (footage, precomps, cameras, adjustment /// layers…). pub fn layer_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool) -> Option { // Time effects see property values at other times. Keyframes are part of the hashed // structure (and the layer time is folded in), but expressions may read other layers at // those times, which the key cannot see: such layers are not cached. if reads_other_times(layer) && layer.props.children.iter().any(|c| matches!(c, Node::Group(g) if g.match_id != "transform" && has_expression(g))) { return None; } key_with(ctx, layer, scale, draft, blur, false) } /// Cache key for a layer's *input* at the context time: source → masks → its first `effects` /// effects (see `Renderer::layer_input`). Unlike [`layer_key`], footage layers are cached here /// (keyed by item and source time), since Time effects read many neighbouring frames. pub fn input_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, effects: usize) -> Option { if layer.switches.adjustment { return None; } let base = key_any(ctx, layer, scale, draft, blur, true, Some(effects))?; let mut h = KeyHasher(base ^ 0x5bd1_e995_7a3c_11d3); effects.hash(&mut h); Some(h.finish()) } /// Does the layer run an effect that reads the layer at other times (Echo, Timewarp…)? fn reads_other_times(layer: &Layer) -> bool { layer.switches.effects && layer.effects().is_some_and(|fx| { fx.groups().any(|g| g.enabled && matches!(&g.kind, effectcraft_project::GroupKind::Effect { effect } if effect.starts_with("ec.time."))) }) } fn has_expression(g: &PropGroup) -> bool { g.children.iter().any(|c| match c { Node::Prop(p) => p.has_expression(), Node::Group(sub) => has_expression(sub), }) } /// Cache key for an adjustment layer's footprint (its source through its masks, see /// `Renderer::adjustment_footprint`): the GPU compositor then uploads it once, not every frame. pub fn footprint_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool) -> Option { Some(derive(key_any(ctx, layer, scale, draft, blur, false, None)?, 0xf007_9417)) } fn key_with(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, footage: bool) -> Option { if layer.switches.adjustment { return None; } key_any(ctx, layer, scale, draft, blur, footage, None) } /// `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) -> Option { let mut h = KeyHasher(0xcbf2_9ce4_8422_2325); ctx.comp.frame_rate.as_f64().to_bits().hash(&mut h); ctx.comp.enable_frame_blending.hash(&mut h); dependencies::hash(&mut h, ctx, layer, effects)?; match &layer.source { LayerSource::Solid { item } => { let it = ctx.project.item(*item)?; let ItemKind::Solid(s) = &it.kind else { return None }; hash_debug(&mut h, s); } LayerSource::Text | LayerSource::Shape => {} LayerSource::Footage { item } if footage => { let it = ctx.project.item(*item)?; let ItemKind::Footage(f) = &it.kind else { return None }; hash_debug(&mut h, f); // A proxy (and its Use Proxy switch) changes the pixels. hash_debug(&mut h, &it.proxy); item.hash(&mut h); ctx.source_time(layer).0.hash(&mut h); // Frame blending mixes neighbouring source frames. ctx.comp.enable_frame_blending.hash(&mut h); } _ => return None, } // Bit depth and colour management change the pixels. crate::color::Pipe::of(&ctx.project.settings).key().hash(&mut h); ctx.comp_id.hash(&mut h); ctx.comp.width.hash(&mut h); ctx.comp.height.hash(&mut h); scale.to_bits().hash(&mut h); draft.hash(&mut h); layer.id.hash(&mut h); hash_debug(&mut h, &layer.source); // (Audio, Lock and Shy don't change pixels.) hash_debug(&mut h, &layer.switches.pixels()); // Mask motion blur and effects that read the shutter depend on whether the layer is motion // blurred in this render and on the comp's shutter. if layer.masks().is_some_and(|m| !m.children.is_empty()) || layer.effects().is_some_and(|fx| !fx.children.is_empty()) { blur.hash(&mut h); ctx.comp.enable_motion_blur.hash(&mut h); ctx.comp.shutter_angle.to_bits().hash(&mut h); ctx.comp.shutter_phase.to_bits().hash(&mut h); ctx.comp.motion_blur_samples.hash(&mut h); } for c in &layer.props.children { // Transform is applied later; Layer Styles are keyed separately (see [`styles_key`]). if let Node::Group(g) = c && (g.match_id == "transform" || g.match_id == effectcraft_project::styles::GROUP) { continue; } // A layer's input runs only its first effects: the later ones (a Puppet whose pins are // dragged, #212) don't change it, so it stays cached. if let (Some(n), Node::Group(g)) = (effects, c) && g.match_id == "effects" { let mut first = g.clone(); let mut seen = 0; first.children.retain(|e| match e { Node::Prop(_) => true, Node::Group(_) => { seen += 1; seen <= n } }); hash_debug(&mut h, &first); hash_values(&mut h, ctx, layer, &first); continue; } // Static structure: values, keyframes, expressions, enabled flags, effect ids, modes. hash_debug(&mut h, c); if let Node::Group(g) = c { hash_values(&mut h, ctx, layer, g); } } // Paint strokes appear and disappear with their Duration spans (not property values). if layer.switches.effects && let Some(fx) = layer.effects() { let lt = layer.layer_time(ctx.time).seconds(); for g in fx.groups().filter(|g| g.enabled && effectcraft_effects::paint::is_paint(g)) { effectcraft_effects::paint::cache_key(g, lt).0.hash(&mut h); } } if time_dependent(layer) { layer.layer_time(ctx.time).0.hash(&mut h); // Effects also see the layer's time mapping. layer.start_time.0.hash(&mut h); layer.stretch.to_bits().hash(&mut h); } // Effects that see the comp camera or lights (#397) change when they or the layer move. if reads_comp_scene(layer) { hash_debug(&mut h, &crate::comp_scene(ctx, layer)); } Some(h.finish()) } /// Does the layer run an effect that reads the comp camera or lights (Card Dance, CC Particle /// World…)? fn reads_comp_scene(layer: &Layer) -> bool { layer.switches.effects && layer.effects().is_some_and(|fx| { fx.groups().any(|g| { g.enabled && matches!(&g.kind, effectcraft_project::GroupKind::Effect { effect } if effectcraft_effects::READS_COMP_SCENE.contains(&effect.as_str())) }) }) } /// Cache key for the styled layer (content key + the Layer Styles group: structure, eye /// switches, values, keyframes, expressions and their values at the context time — Global Light /// included, as every layer mirrors it in its Blending Options). pub fn styles_key(ctx: &EvalCtx, layer: &Layer, content_key: u64) -> u64 { let mut h = KeyHasher(content_key ^ 0x9e37_79b9_7f4a_7c15); if let Some(g) = layer.layer_styles() { hash_debug(&mut h, g); hash_values(&mut h, ctx, layer, g); } h.finish() } /// A sub-key of `key` (styled layer passes, flattened buffer). pub fn derive(key: u64, i: u64) -> u64 { let mut h = KeyHasher(key); i.hash(&mut h); h.finish() }