diff --git a/Cargo.lock b/Cargo.lock index 6977f6fc645..481dba18a71 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -359,6 +359,7 @@ dependencies = [ "core-types", "dyn-any", "glam", + "graphene-cache", "graphene-hash", "graphic-types", "half", @@ -2011,6 +2012,7 @@ dependencies = [ "glam", "graph-craft", "graphene-application-io", + "graphene-cache", "graphene-core", "graphene-hash", "graphic-types", @@ -2055,6 +2057,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "graphene-cache" +version = "0.0.0" +dependencies = [ + "core-types", + "dyn-any", + "glam", + "serde", +] + [[package]] name = "graphene-canvas-utils" version = "0.0.0" diff --git a/Cargo.toml b/Cargo.toml index b8a78957fef..f2fdc92b6ac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -108,6 +108,7 @@ graphite-proc-macros = { path = "proc-macros" } graphite-editor = { path = "editor" } graphene-canvas-utils = { path = "node-graph/libraries/canvas-utils" } ipsum = { path = "libraries/ipsum" } +graphene-cache = { path = "node-graph/libraries/graphene-cache" } # Workspace dependencies rustc-hash = "2.0" diff --git a/editor/src/messages/tool/tool_messages/brush_tool.rs b/editor/src/messages/tool/tool_messages/brush_tool.rs index f7395ec2f40..bcc6fde01fe 100644 --- a/editor/src/messages/tool/tool_messages/brush_tool.rs +++ b/editor/src/messages/tool/tool_messages/brush_tool.rs @@ -624,7 +624,7 @@ fn new_brush_layer(document: &DocumentMessageHandler, responses: &mut VecDeque), - BrushCache(BrushCache), + /// Type-erased handle to a lazily initialized [`graphene_cache::Cache`]. + #[serde(alias = "BrushCache")] + CacheHandle(CacheHandle), // ======================= // AUTO-GENERATED VARIANTS // ======================= @@ -142,7 +145,7 @@ macro_rules! tagged_value { Self::TransferCurve(points) => points.cache_hash(state), Self::GradientRamp(ramp) => ramp.cache_hash(state), Self::Strokes(strokes) => strokes.cache_hash(state), - Self::BrushCache(cache) => cache.cache_hash(state), + Self::CacheHandle(cache) => cache.cache_hash(state), // ======================= // NON-SERIALIZED VARIANTS // ======================= @@ -210,7 +213,7 @@ macro_rules! tagged_value { let list: List = strokes.into_iter().map(core_types::list::Item::new_from_element).collect(); Box::new(list) } - Self::BrushCache(cache) => Box::new(Item::new_from_element(cache)), + Self::CacheHandle(cache) => Box::new(Item::new_from_element(cache)), // ======================= // AUTO-GENERATED VARIANTS // ======================= @@ -278,7 +281,7 @@ macro_rules! tagged_value { let list: List = strokes.into_iter().map(core_types::list::Item::new_from_element).collect(); Arc::new(list) } - Self::BrushCache(cache) => Arc::new(Item::new_from_element(cache)), + Self::CacheHandle(cache) => Arc::new(Item::new_from_element(cache)), // ======================= // AUTO-GENERATED VARIANTS // ======================= @@ -309,7 +312,7 @@ macro_rules! tagged_value { Self::TransferCurve(_) => item!(TransferCurve), Self::GradientRamp(_) => item!(Gradient), Self::Strokes(_) => list!(Stroke), - Self::BrushCache(_) => item!(BrushCache), + Self::CacheHandle(_) => item!(CacheHandle), // ======================= // AUTO-GENERATED VARIANTS // ======================= @@ -351,7 +354,7 @@ macro_rules! tagged_value { x if x == TypeId::of::() => Ok(TaggedValue::GradientRamp(GradientRamp::from(*downcast::(input).unwrap()))), x if x == TypeId::of::>() => Ok(TaggedValue::GradientRamp(GradientRamp::from(&*downcast::>(input).unwrap()))), x if x == TypeId::of::>() => Ok(TaggedValue::Strokes(downcast::>(input).unwrap().into_iter().map(Item::into_element).collect())), - x if x == TypeId::of::>() => Ok(TaggedValue::BrushCache(downcast::>(input).unwrap().into_element())), + x if x == TypeId::of::>() => Ok(TaggedValue::CacheHandle(downcast::>(input).unwrap().into_element())), // ======================= // AUTO-GENERATED VARIANTS // ======================= @@ -387,7 +390,7 @@ macro_rules! tagged_value { x if x == TypeId::of::() => Ok(TaggedValue::GradientRamp(GradientRamp::from(input.downcast_ref::().unwrap()))), x if x == TypeId::of::>() => Ok(TaggedValue::GradientRamp(GradientRamp::from(input.downcast_ref::>().unwrap()))), x if x == TypeId::of::>() => Ok(TaggedValue::Strokes(input.downcast_ref::>().unwrap().iter_element_values().cloned().collect())), - x if x == TypeId::of::>() => Ok(TaggedValue::BrushCache(input.downcast_ref::>().unwrap().element().clone())), + x if x == TypeId::of::>() => Ok(TaggedValue::CacheHandle(input.downcast_ref::>().unwrap().element().clone())), // ======================= // AUTO-GENERATED VARIANTS // ======================= @@ -417,7 +420,7 @@ macro_rules! tagged_value { if name == std::any::type_name::() { return Some(TaggedValue::TransferCurve(TransferCurve::default().points().to_vec())) } $( if name == std::any::type_name::<$ty>() { return Some(TaggedValue::$identifier(Default::default())) } )* if name == std::any::type_name::>() { return Some(TaggedValue::Strokes(Vec::new())) } - if name == std::any::type_name::() { return Some(TaggedValue::BrushCache(Default::default())) } + if name == std::any::type_name::() { return Some(TaggedValue::CacheHandle(Default::default())) } // Unranked types without a variant route through `TypeDefault`, with `to_dynany`/`to_any` constructing the actual default at execution time macro_rules! check_bare { ($type_default:ty) => { @@ -475,7 +478,7 @@ macro_rules! tagged_value { Self::TransferCurve(points) => format!("TransferCurve({points:?})"), Self::GradientRamp(ramp) => format!("GradientRamp({ramp:?})"), Self::Strokes(strokes) => format!("Strokes({strokes:?})"), - Self::BrushCache(cache) => format!("{cache:?}"), + Self::CacheHandle(cache) => format!("{cache:?}"), // ======================= // AUTO-GENERATED VARIANTS // ======================= diff --git a/node-graph/libraries/brush-types/src/cache.rs b/node-graph/libraries/brush-types/src/cache.rs deleted file mode 100644 index 8054804aec0..00000000000 --- a/node-graph/libraries/brush-types/src/cache.rs +++ /dev/null @@ -1,250 +0,0 @@ -//! Opaque render state cached per footprint. -//! -//! ```ignore -//! let state: SomeState = cache.take(ctx.footprint()).unwrap_or_default(); -//! // ...render, freely mutating the state -//! cache.store(ctx.footprint(), state); -//! ``` - -use core_types::transform::Footprint; -use glam::DMat2; -use std::sync::{Arc, Mutex}; - -const STALE_EPOCHS: u64 = 2; -const MAX_VIEWS: usize = 3; - -#[derive(Clone)] -pub struct BrushCache { - state: Arc>, - nonce: u64, // Avoid deduplication of cache entries across different brush nodes. -} - -impl Default for BrushCache { - fn default() -> Self { - Self { - state: Default::default(), - nonce: core_types::uuid::generate_uuid(), - } - } -} - -impl BrushCache { - pub fn take(&self, footprint: &Footprint) -> Option { - let mut guard = self.state.lock().unwrap(); - let state = guard.take(footprint)?; - match state.downcast() { - Ok(state) => Some(*state), - Err(state) => { - guard.store(footprint, state); - None - } - } - } - - pub fn store(&self, footprint: &Footprint, state: S) { - self.state.lock().unwrap().store(footprint, Box::new(state)); - } -} - -impl PartialEq for BrushCache { - fn eq(&self, _: &Self) -> bool { - true - } -} - -impl std::fmt::Debug for BrushCache { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("BrushCache").field("slots", &self.state.lock().unwrap().slots.len()).finish() - } -} - -impl core_types::CacheHash for BrushCache { - fn cache_hash(&self, state: &mut H) { - state.write_u64(self.nonce); - } -} - -unsafe impl dyn_any::StaticType for BrushCache { - type Static = BrushCache; -} - -#[cfg(feature = "serde")] -impl serde::Serialize for BrushCache { - fn serialize(&self, serializer: S) -> Result { - serializer.serialize_unit() - } -} - -#[cfg(feature = "serde")] -impl<'de> serde::Deserialize<'de> for BrushCache { - fn deserialize>(deserializer: D) -> Result { - serde::de::IgnoredAny::deserialize(deserializer)?; - Ok(Self::default()) - } -} - -type BoxedData = Box; - -#[derive(Default)] -struct State { - epoch: u64, - slots: Vec, -} - -struct Slot { - footprint: Footprint, - epoch: u64, - data: BoxedData, -} - -impl Slot { - fn view(&self) -> DMat2 { - self.footprint.transform.matrix2 - } -} - -impl State { - fn take(&mut self, footprint: &Footprint) -> Option { - self.touch(footprint.transform.matrix2); - let index = self.slots.iter().position(|slot| slot.footprint == *footprint); - let hit = index.map(|index| { - let slot = self.slots.remove(index); - if slot.epoch == self.epoch { - self.epoch += 1; - } - slot.data - }); - self.retire(); - hit - } - - fn store(&mut self, footprint: &Footprint, data: BoxedData) { - self.touch(footprint.transform.matrix2); - self.slots.retain(|slot| slot.footprint != *footprint); - self.slots.push(Slot { - footprint: *footprint, - epoch: self.epoch, - data, - }); - self.retire(); - } - - fn touch(&mut self, view: DMat2) { - self.slots.sort_by_key(|slot| slot.view() == view); - } - - fn retire(&mut self) { - let epoch = self.epoch; - self.slots.retain(|slot| epoch - slot.epoch < STALE_EPOCHS); - while self.slots.chunk_by(|a, b| a.view() == b.view()).count() > MAX_VIEWS { - let front = self.slots[0].view(); - let group = self.slots.iter().take_while(|slot| slot.view() == front).count(); - self.slots.drain(..group.max(1)); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use core_types::transform::RenderQuality; - use glam::{DAffine2, DVec2, UVec2}; - - struct Dummy; - - fn view(zoom: f64, rotation: f64, pan: DVec2) -> Footprint { - Footprint { - transform: DAffine2::from_scale_angle_translation(DVec2::splat(zoom), rotation, pan), - resolution: UVec2::new(1920, 1080), - quality: RenderQuality::Full, - } - } - - fn thumbnail(zoom: f64) -> Footprint { - Footprint { - resolution: UVec2::new(150, 150), - ..view(zoom, 0., DVec2::ZERO) - } - } - - fn live(cache: &BrushCache) -> usize { - cache.state.lock().unwrap().slots.len() - } - - fn render(cache: &BrushCache, footprint: &Footprint) -> bool { - let hit = cache.take::(footprint).is_some(); - cache.store(footprint, Dummy); - hit - } - - #[test] - fn continuous_zoom_is_bounded_by_views() { - let cache = BrushCache::default(); - for step in 0..100 { - render(&cache, &view(1. + step as f64 * 0.01, 0., DVec2::ZERO)); - } - assert!(live(&cache) <= MAX_VIEWS); - } - - #[test] - fn continuous_rotation_is_bounded_by_views() { - let cache = BrushCache::default(); - for step in 0..100 { - render(&cache, &view(2., step as f64 * 0.01, DVec2::ZERO)); - } - assert!(live(&cache) <= MAX_VIEWS); - } - - #[test] - fn zooming_reclaims_pan_slots() { - let cache = BrushCache::default(); - for step in 0..30 { - render(&cache, &view(1., 0., DVec2::splat(step as f64 * 100.))); - } - for step in 1..=3 { - render(&cache, &view(1. + step as f64, 0., DVec2::ZERO)); - } - assert_eq!(live(&cache), 3); - } - - #[test] - fn frames_may_hold_many_footprints_per_view() { - let cache = BrushCache::default(); - let footprints: Vec<_> = (0..5).map(|step| view(1., 0., DVec2::splat(step as f64 * 100.))).collect(); - for frame in 0..10 { - for footprint in &footprints { - assert_eq!(render(&cache, footprint), frame > 0, "footprint evicted while its frame still renders it"); - } - } - assert_eq!(live(&cache), 5); - } - - #[test] - fn thumbnail_drift_is_bounded_and_keeps_the_view() { - let cache = BrushCache::default(); - for step in 0..100 { - render(&cache, &thumbnail(1. + step as f64 * 0.001)); - } - assert!(live(&cache) <= MAX_VIEWS); - - let viewport = view(2., 0., DVec2::ZERO); - render(&cache, &viewport); - for step in 0..50 { - render(&cache, &thumbnail(2. + step as f64 * 0.001)); - assert!(render(&cache, &viewport), "thumbnail churn evicted the viewport slot"); - } - } - - #[test] - fn settled_view_retires_stale_slots() { - let cache = BrushCache::default(); - for step in 0..3 { - render(&cache, &view(1. + step as f64, 0., DVec2::ZERO)); - } - assert_eq!(live(&cache), 3); - for _ in 0..STALE_EPOCHS { - render(&cache, &view(1., 0., DVec2::ZERO)); - } - assert_eq!(live(&cache), 1); - } -} diff --git a/node-graph/libraries/brush-types/src/lib.rs b/node-graph/libraries/brush-types/src/lib.rs index 394689a329d..023fb29176e 100644 --- a/node-graph/libraries/brush-types/src/lib.rs +++ b/node-graph/libraries/brush-types/src/lib.rs @@ -1,6 +1,3 @@ -pub mod cache; -pub use cache::BrushCache; - use core_types::CacheHash; use core_types::bounds::{BoundingBox, RenderBoundingBox}; use core_types::render_complexity::RenderComplexity; diff --git a/node-graph/libraries/graphene-cache/Cargo.toml b/node-graph/libraries/graphene-cache/Cargo.toml new file mode 100644 index 00000000000..0e72c7ce70c --- /dev/null +++ b/node-graph/libraries/graphene-cache/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "graphene-cache" +description = "A keyed cache for Graphene" +version.workspace = true +license.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true + +[features] +default = ["serde"] +serde = ["dep:serde", "core-types/serde"] + +[dependencies] +# Local dependencies +core-types = { workspace = true } + +# Workspace dependencies +dyn-any = { workspace = true } +glam = { workspace = true } + +# Optional workspace dependencies +serde = { workspace = true, optional = true } diff --git a/node-graph/libraries/graphene-cache/src/lib.rs b/node-graph/libraries/graphene-cache/src/lib.rs new file mode 100644 index 00000000000..45f01eac87b --- /dev/null +++ b/node-graph/libraries/graphene-cache/src/lib.rs @@ -0,0 +1,613 @@ +use core_types::transform::Footprint; +use glam::DMat2; +use std::{ + any::Any, + sync::{Arc, Mutex, MutexGuard}, +}; + +// =========== +// CacheHandle +// =========== + +#[derive(Clone)] +pub struct CacheHandle { + slot: Arc>>>, + nonce: u64, // Avoid deduplication of cache instances across different nodes. +} + +impl CacheHandle { + fn get_clean_guard(&self) -> MutexGuard<'_, Option>> { + match self.slot.lock() { + Ok(guard) => guard, + Err(poisoned) => { + let mut guard = poisoned.into_inner(); + *guard = None; + self.slot.clear_poison(); + guard + } + } + } + + pub fn get(&self) -> Result { + let mut guard = self.get_clean_guard(); + if guard.is_none() { + *guard = Some(Box::new(T::default())); + } + + match guard.as_ref().unwrap().downcast_ref::() { + Some(cache) => Ok(cache.clone()), + None => Err(CacheTypeError), + } + } +} + +impl PartialEq for CacheHandle { + fn eq(&self, _: &Self) -> bool { + true + } +} + +impl Default for CacheHandle { + fn default() -> Self { + Self { + slot: Default::default(), + nonce: core_types::uuid::generate_uuid(), + } + } +} + +impl core_types::CacheHash for CacheHandle { + fn cache_hash(&self, state: &mut H) { + state.write_u64(self.nonce); + } +} + +unsafe impl dyn_any::StaticType for CacheHandle { + type Static = CacheHandle; +} + +#[cfg(feature = "serde")] +impl serde::Serialize for CacheHandle { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_unit() + } +} + +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for CacheHandle { + fn deserialize>(deserializer: D) -> Result { + serde::de::IgnoredAny::deserialize(deserializer)?; + Ok(Self::default()) + } +} + +impl std::fmt::Debug for CacheHandle { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CacheHandle") + .field("initialized", &self.get_clean_guard().is_some()) + .field("nonce", &self.nonce) + .finish() + } +} + +#[derive(Debug)] +pub struct CacheTypeError; + +// ===== +// Cache +// ===== + +/// A keyed cache backed by a linear `Vec`. +pub struct Cache> { + state: Arc>>, +} + +impl> Cache { + fn get_clean_guard(&self) -> MutexGuard<'_, CacheState> { + match self.state.lock() { + Ok(guard) => guard, + Err(poisoned) => { + let mut guard = poisoned.into_inner(); + *guard = CacheState::default(); + self.state.clear_poison(); + guard + } + } + } +} + +impl> Cache { + /// Removes and returns the value stored for `key`. + pub fn take(&self, key: &K) -> Option { + let mut guard = self.get_clean_guard(); + guard.take(key) + } + + /// Clones the value stored for `key` without removing it. + pub fn get_cloned(&self, key: &K) -> Option + where + V: Clone, + { + let mut guard = self.get_clean_guard(); + guard.get_cloned(key) + } + + /// Stores a value for `key`, replacing any existing value with the same key. + pub fn store(&self, key: &K, value: V) { + self.get_clean_guard().store(key, value); + } +} + +impl> Default for Cache { + fn default() -> Self { + Self { state: Default::default() } + } +} + +impl> Clone for Cache { + fn clone(&self) -> Self { + Self { state: self.state.clone() } + } +} + +impl> std::fmt::Debug for Cache { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Cache").field("entries", &self.get_clean_guard().entries.len()).finish() + } +} + +// =================== +// CachePolicy & Entry +// =================== + +/// Defines the policy state and lifecycle hooks used to manage cached entries. +pub trait CachePolicy: Sized { + /// State shared by all entries in one cache. + type PolicyState: Default; + /// Policy-specific state stored with each cache entry. + type EntryState: Default; + + /// Updates entry ordering or policy state before operating on key. + fn touch(key: &K, entries: &mut Vec>, policy_state: &mut Self::PolicyState); + /// Removes entries that should no longer be retained. + fn retire(entries: &mut Vec>, policy_state: &mut Self::PolicyState); + /// Updates policy state when an entry is accessed successfully. + fn hit(entry_state: &mut Self::EntryState, policy_state: &mut Self::PolicyState); + /// Initializes or updates policy state for a stored entry. + fn store(entry_state: &mut Self::EntryState, policy_state: &mut Self::PolicyState); +} + +pub struct Entry> { + entry_state: P::EntryState, + key: K, + value: V, +} + +// ================================= +// CachePolicy: GenerationalEviction +// ================================= + +/// Retains recently used key groups and evicts entries that exceed the configured age or group limit. +pub struct GenerationalEviction; + +impl CachePolicy for GenerationalEviction { + type PolicyState = u64; + type EntryState = u64; + + fn touch(key: &K, entries: &mut Vec>, _policy_state: &mut Self::PolicyState) { + entries.sort_by_key(|entry| entry.key.group() == key.group()); + } + + fn retire(entries: &mut Vec>, policy_state: &mut Self::PolicyState) { + if *policy_state == u64::MAX { + entries.clear(); + *policy_state = 0; + return; + } + + entries.retain(|entry| *policy_state - entry.entry_state < STALE_EPOCHS); + while entries.chunk_by(|a, b| a.key.group() == b.key.group()).count() > MAX_GROUPS { + let oldest_group = entries[0].key.group(); + let group_len = entries.iter().take_while(|entry| entry.key.group() == oldest_group).count(); + entries.drain(..group_len.max(1)); + } + } + + fn hit(entry_state: &mut Self::EntryState, policy_state: &mut Self::PolicyState) { + if entry_state == policy_state { + *policy_state += 1; + } + *entry_state = *policy_state; + } + + fn store(entry_state: &mut Self::EntryState, policy_state: &mut Self::PolicyState) { + *entry_state = *policy_state; + } +} + +/// Provide a method to group entries by key for eviction. +trait CacheKeyGroup { + type Group: PartialEq; + fn group(&self) -> Self::Group; +} + +impl CacheKeyGroup for Footprint { + type Group = DMat2; + fn group(&self) -> Self::Group { + self.transform.matrix2 + } +} + +// ====================================== +// CachePolicy: LRU (Least Recently Used) +// ====================================== + +/// Retains up to `CAPACITY` entries and evicts the least recently used entry when capacity is exceeded. +pub struct Lru; + +impl CachePolicy for Lru { + // Keep tracks the most recent state. + type PolicyState = u64; + // Stores entry's recency. Larger is more recent. + type EntryState = u64; + + fn touch(_key: &K, _entries: &mut Vec>, _policy_state: &mut Self::PolicyState) {} + + fn retire(entries: &mut Vec>, policy_state: &mut Self::PolicyState) { + if *policy_state == u64::MAX { + entries.clear(); + *policy_state = 0; + return; + } + if entries.len() <= CAPACITY { + return; + }; + let Some((oldest_index, _)) = entries.iter().enumerate().min_by_key(|(_, entry)| entry.entry_state) else { + return; + }; + entries.swap_remove(oldest_index); + } + + fn hit(entry_state: &mut Self::EntryState, policy_state: &mut Self::PolicyState) { + *policy_state += 1; + *entry_state = *policy_state; + } + + fn store(entry_state: &mut Self::EntryState, policy_state: &mut Self::PolicyState) { + *policy_state += 1; + *entry_state = *policy_state; + } +} + +// ========== +// CacheState +// ========== + +struct CacheState> { + policy_state: P::PolicyState, + entries: Vec>, +} + +impl> Default for CacheState { + fn default() -> Self { + Self { + policy_state: Default::default(), + entries: Default::default(), + } + } +} + +impl> CacheState { + fn take(&mut self, key: &K) -> Option { + P::touch(key, &mut self.entries, &mut self.policy_state); + + let index = self.entries.iter().position(|entry| entry.key == *key); + let hit = index.map(|index| { + let mut entry = self.entries.remove(index); + P::hit(&mut entry.entry_state, &mut self.policy_state); + entry.value + }); + + P::retire(&mut self.entries, &mut self.policy_state); + hit + } + + fn get_cloned(&mut self, key: &K) -> Option + where + V: Clone, + { + P::touch(key, &mut self.entries, &mut self.policy_state); + + let index = self.entries.iter().position(|entry| entry.key == *key); + let hit = index.map(|index| { + let entry = self.entries.get_mut(index).unwrap(); + P::hit(&mut entry.entry_state, &mut self.policy_state); + Some(entry.value.clone()) + }); + + P::retire(&mut self.entries, &mut self.policy_state); + hit.flatten() + } + + fn store(&mut self, key: &K, value: V) { + P::touch(key, &mut self.entries, &mut self.policy_state); + + self.entries.retain(|entry| entry.key != *key); + let mut entry = Entry { + key: *key, + value, + entry_state: P::EntryState::default(), + }; + + P::store(&mut entry.entry_state, &mut self.policy_state); + self.entries.push(entry); + P::retire(&mut self.entries, &mut self.policy_state); + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[derive(Copy, Clone, PartialEq, Debug)] + struct DummyKey(usize); + #[derive(Clone, PartialEq, Debug)] + struct DummyValue(usize); + + #[derive(Default)] + struct CallCounts { + touch: usize, + retire: usize, + hit: usize, + store: usize, + } + + struct TestPolicy; + impl CachePolicy for TestPolicy { + type PolicyState = CallCounts; + type EntryState = (); + + fn touch(_key: &K, _entries: &mut Vec>, counts: &mut Self::PolicyState) { + counts.touch += 1; + } + + fn retire(_entries: &mut Vec>, counts: &mut Self::PolicyState) { + counts.retire += 1; + } + + fn hit(_entry_state: &mut Self::EntryState, counts: &mut Self::PolicyState) { + counts.hit += 1; + } + + fn store(_entry_state: &mut Self::EntryState, counts: &mut Self::PolicyState) { + counts.store += 1; + } + } + + type DummyCacheType = Cache; + + fn live>(cache: &Cache) -> usize { + cache.state.lock().unwrap().entries.len() + } + + #[test] + fn get_cache_from_handle() { + let cache_handle = CacheHandle::default(); + let cache1 = cache_handle.get::(); + assert!(cache1.is_ok()); + + let cache2 = cache_handle.clone().get::(); + assert!(cache2.is_ok()); + assert!(Arc::ptr_eq(&cache1.unwrap().state, &cache2.unwrap().state)); + } + + #[test] + fn get_cache_fails_with_wrong_cache_type() { + type WrongCacheType = (); + let cache_handle = CacheHandle::default(); + let cache = cache_handle.get::(); + assert!(cache.is_ok()); + + let wrong_cache = cache_handle.get::(); + assert!(wrong_cache.is_err()); + + // Validate if the first cache is still available from the handle + let cache = cache_handle.get::(); + assert!(cache.is_ok()); + } + + #[test] + fn take_removes_entry() { + let cache = Cache::::default(); + let key = DummyKey(0); + let val = DummyValue(0); + cache.store(&key, val); + let taken_val = cache.take(&key); + + assert_eq!(taken_val, Some(DummyValue(0))); + assert_eq!(live(&cache), 0); + + let state = cache.state.lock().unwrap(); + assert_eq!(state.policy_state.touch, 2); + assert_eq!(state.policy_state.retire, 2); + assert_eq!(state.policy_state.store, 1); + assert_eq!(state.policy_state.hit, 1); + } + + #[test] + fn get_cloned_returns_value_without_removing_entry() { + let cache = Cache::::default(); + let key = DummyKey(0); + let val = DummyValue(0); + cache.store(&key, val); + let cloned_val = cache.get_cloned(&key); + + assert_eq!(cloned_val, Some(DummyValue(0))); + assert_eq!(live(&cache), 1); + + let state = cache.state.lock().unwrap(); + assert_eq!(state.policy_state.touch, 2); + assert_eq!(state.policy_state.retire, 2); + assert_eq!(state.policy_state.store, 1); + assert_eq!(state.policy_state.hit, 1); + } + + mod footprint_generational_eviction { + use super::*; + use core_types::transform::RenderQuality; + use glam::{DAffine2, DVec2, UVec2}; + + const STALE_EPOCHS: u64 = 2; + const MAX_GROUPS: usize = 3; + + fn view(zoom: f64, rotation: f64, pan: DVec2) -> Footprint { + Footprint { + transform: DAffine2::from_scale_angle_translation(DVec2::splat(zoom), rotation, pan), + resolution: UVec2::new(1920, 1080), + quality: RenderQuality::Full, + } + } + + fn thumbnail(zoom: f64) -> Footprint { + Footprint { + resolution: UVec2::new(150, 150), + ..view(zoom, 0., DVec2::ZERO) + } + } + + fn render(cache: &Cache>, footprint: &Footprint) -> bool { + let hit = cache.take(footprint).is_some(); + cache.store(footprint, DummyValue(0)); + hit + } + + #[test] + fn continuous_zoom_is_bounded_by_views() { + let cache = Cache::default(); + for step in 0..100 { + render(&cache, &view(1. + step as f64 * 0.01, 0., DVec2::ZERO)); + } + assert!(live(&cache) <= MAX_GROUPS); + } + + #[test] + fn continuous_rotation_is_bounded_by_views() { + let cache = Cache::default(); + for step in 0..100 { + render(&cache, &view(2., step as f64 * 0.01, DVec2::ZERO)); + } + assert!(live(&cache) <= MAX_GROUPS); + } + + #[test] + fn zooming_reclaims_pan_entries() { + let cache = Cache::default(); + for step in 0..30 { + render(&cache, &view(1., 0., DVec2::splat(step as f64 * 100.))); + } + for step in 1..=3 { + render(&cache, &view(1. + step as f64, 0., DVec2::ZERO)); + } + assert_eq!(live(&cache), 3); + } + + #[test] + fn frames_may_hold_many_footprints_per_view() { + let cache = Cache::default(); + let footprints: Vec<_> = (0..5).map(|step| view(1., 0., DVec2::splat(step as f64 * 100.))).collect(); + for frame in 0..10 { + for footprint in &footprints { + assert_eq!(render(&cache, footprint), frame > 0, "footprint evicted while its frame still renders it"); + } + } + assert_eq!(live(&cache), 5); + } + + #[test] + fn thumbnail_drift_is_bounded_and_keeps_the_view() { + let cache = Cache::default(); + for step in 0..100 { + render(&cache, &thumbnail(1. + step as f64 * 0.001)); + } + assert!(live(&cache) <= MAX_GROUPS); + + let viewport = view(2., 0., DVec2::ZERO); + render(&cache, &viewport); + for step in 0..50 { + render(&cache, &thumbnail(2. + step as f64 * 0.001)); + assert!(render(&cache, &viewport), "thumbnail churn evicted the viewport entry"); + } + } + + #[test] + fn settled_view_retires_stale_entries() { + let cache = Cache::default(); + for step in 0..3 { + render(&cache, &view(1. + step as f64, 0., DVec2::ZERO)); + } + assert_eq!(live(&cache), 3); + for _ in 0..STALE_EPOCHS { + render(&cache, &view(1., 0., DVec2::ZERO)); + } + assert_eq!(live(&cache), 1); + } + } + + mod lru { + use std::array; + + use super::*; + + #[test] + fn evicts_least_recently_used_entry_when_capacity_is_exceeded() { + let cache = Cache::>::default(); + let [(key0, val0), (key1, val1), (key2, val2)] = array::from_fn(|n| (DummyKey(n), DummyValue(n))); + cache.store(&key0, val0); + cache.store(&key1, val1); + let _ = cache.get_cloned(&key0); + cache.store(&key2, val2); + + assert_eq!(live(&cache), 2); + let state = cache.state.lock().unwrap(); + assert!(state.entries.iter().find(|entry| entry.key == key1).is_none()); + } + + #[test] + fn get_cloned_refreshes_entry_recency() { + let cache = Cache::>::default(); + let [(key0, val0), (key1, val1)] = array::from_fn(|n| (DummyKey(n), DummyValue(n))); + cache.store(&key0, val0); + cache.store(&key1, val1); + let _ = cache.get_cloned(&key0); + + let state = cache.state.lock().unwrap(); + assert_eq!(state.entries.iter().find(|entry| entry.key == key0).unwrap().entry_state, state.policy_state); + } + + #[test] + fn storing_existing_key_replaces_value_without_growing_cache() { + let cache = Cache::>::default(); + let [(key0, val0), (_, val1)] = array::from_fn(|n| (DummyKey(n), DummyValue(n))); + cache.store(&key0, val0); + + assert_eq!(live(&cache), 1); + cache.store(&key0, val1); + assert_eq!(live(&cache), 1); + let val = cache.get_cloned(&key0).unwrap(); + assert_eq!(val, DummyValue(1)); + } + + #[test] + fn entry_count_never_exceeds_capacity() { + let cache = Cache::>::default(); + for n in 0..10 { + cache.store(&DummyKey(n), DummyValue(n)); + assert_eq!(live(&cache), n + 1); + } + + for n in 10..20 { + cache.store(&DummyKey(n), DummyValue(n)); + assert_eq!(live(&cache), 10); + } + } + } +} diff --git a/node-graph/nodes/brush/Cargo.toml b/node-graph/nodes/brush/Cargo.toml index 12e8a5cd0d8..eb129579b4b 100644 --- a/node-graph/nodes/brush/Cargo.toml +++ b/node-graph/nodes/brush/Cargo.toml @@ -18,6 +18,7 @@ dyn-any = { workspace = true } brush-types = { workspace = true } core-types = { workspace = true } graphene-hash = { workspace = true } +graphene-cache = { workspace = true } graphic-types = { workspace = true } raster-types = { workspace = true, features = ["wgpu"] } wgpu-executor = { workspace = true } diff --git a/node-graph/nodes/brush/src/basic_brush/cache.rs b/node-graph/nodes/brush/src/basic_brush/cache.rs new file mode 100644 index 00000000000..d1440750312 --- /dev/null +++ b/node-graph/nodes/brush/src/basic_brush/cache.rs @@ -0,0 +1,6 @@ +use core_types::transform::Footprint; +use graphene_cache::{Cache, GenerationalEviction}; + +use crate::basic_brush::render::State; + +pub(super) type BrushCache = Cache>; diff --git a/node-graph/nodes/brush/src/basic_brush/mod.rs b/node-graph/nodes/brush/src/basic_brush/mod.rs index 3fd304cef7c..2679c80d4be 100644 --- a/node-graph/nodes/brush/src/basic_brush/mod.rs +++ b/node-graph/nodes/brush/src/basic_brush/mod.rs @@ -1,3 +1,4 @@ +pub mod cache; mod consts; mod convert; mod kernel; @@ -6,9 +7,10 @@ mod region; mod render; mod stroke; -use brush_types::BrushCache; +use cache::BrushCache; use core_types::list::{ATTR_COLOR, ATTR_DIAMETER, ATTR_FLOW, ATTR_HARDNESS, Item, List}; use core_types::{ATTR_TRANSFORM, Ctx, ExtractFootprint}; +use graphene_cache::CacheHandle; use graphic_types::Graphic; use pipeline::{BasicBrushPipeline, BasicBrushPipelineArgs}; use raster_types::{GPU, Raster}; @@ -18,10 +20,12 @@ use wgpu_executor::{WgpuExecutor, WgpuPipelineCache}; pub async fn basic_brush<'a: 'n>( ctx: impl Ctx + ExtractFootprint, strokes: List, - #[widget(ParsedWidgetOverride::Hidden)] cache: Item, + #[widget(ParsedWidgetOverride::Hidden)] cache: Item, #[scope(basic_brush_pipeline::IDENTIFIER)] pipeline: Item, ) -> List> { - let (cache, pipeline) = (cache.into_element(), pipeline.into_element()); + let (cache_handle, pipeline) = (cache.into_element(), pipeline.into_element()); + let Ok(cache) = cache_handle.get::() else { return List::new() }; + let mut stack = vec![strokes.into_iter()]; let mut strokes = Vec::new(); while let Some(top) = stack.last_mut() { diff --git a/node-graph/nodes/brush/src/basic_brush/pipeline.rs b/node-graph/nodes/brush/src/basic_brush/pipeline.rs index b103f3fb61a..894e4283f64 100644 --- a/node-graph/nodes/brush/src/basic_brush/pipeline.rs +++ b/node-graph/nodes/brush/src/basic_brush/pipeline.rs @@ -1,9 +1,9 @@ +use super::cache::BrushCache; use super::consts::{LUT_SIZE, LUT_T_MAX, LUT_V_MAX, RIDGE_GAIN, SIGMA_CUTOFF}; use super::convert::Convert; use super::kernel::{Kernel, KernelCache}; use super::region::{Crop, Region}; use super::stroke::{Edge, StyledStroke}; -use brush_types::BrushCache; use bytemuck::{Pod, Zeroable}; use core_types::Color; use core_types::transform::Footprint;