From 0fc0346b8b618e5d5ad7fb6b882c3deca85eeaf8 Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Thu, 10 Sep 2026 15:09:18 +0900 Subject: [PATCH 1/9] Extract and generalize BrushCache --- Cargo.lock | 12 + Cargo.toml | 1 + node-graph/graph-craft/Cargo.toml | 1 + node-graph/graph-craft/src/document/value.rs | 14 +- node-graph/libraries/brush-types/src/cache.rs | 250 -------- node-graph/libraries/brush-types/src/lib.rs | 3 - .../libraries/graphene-cache/Cargo.toml | 22 + .../libraries/graphene-cache/src/lib.rs | 565 ++++++++++++++++++ node-graph/nodes/brush/Cargo.toml | 1 + node-graph/nodes/brush/src/basic_brush/mod.rs | 5 +- .../nodes/brush/src/basic_brush/pipeline.rs | 4 +- 11 files changed, 615 insertions(+), 263 deletions(-) delete mode 100644 node-graph/libraries/brush-types/src/cache.rs create mode 100644 node-graph/libraries/graphene-cache/Cargo.toml create mode 100644 node-graph/libraries/graphene-cache/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 6977f6fc645..7d735d789ff 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.1.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/node-graph/graph-craft/Cargo.toml b/node-graph/graph-craft/Cargo.toml index 739b5be2b7f..8c13ff3ff3c 100644 --- a/node-graph/graph-craft/Cargo.toml +++ b/node-graph/graph-craft/Cargo.toml @@ -27,6 +27,7 @@ dyn-any = { workspace = true } graphene-hash = { workspace = true } core-types = { workspace = true, features = ["serde"] } brush-nodes = { workspace = true, features = ["serde"] } +graphene-cache = { workspace = true, features = ["serde"] } graphene-core = { workspace = true, features = ["serde"] } graphene-application-io = { workspace = true, features = ["serde"] } rendering = { workspace = true, features = ["serde"] } diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index 8317bac090c..299844c9cba 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -2,7 +2,7 @@ use super::DocumentNode; use crate::application_io::PlatformEditorApi; use crate::application_io::resource::Resource; use crate::proto::{Any as DAny, FutureAny}; -use brush_nodes::{BrushCache, Stroke}; +use brush_nodes::Stroke; use core_types::color::SRGBA8; use core_types::list::{Item, List, NodeIdPath}; use core_types::transfer_curve::TransferCurve; @@ -13,6 +13,7 @@ pub use dyn_any::StaticType; pub use glam::{DAffine2, DVec2, IVec2, UVec2}; use graphene_application_io::resource::ResourceHash; use graphene_application_io::resource::ResourceId; +use graphene_cache::{Cache, GenerationalEviction}; use graphic_types::raster_types::{CPU, Image, Raster}; use graphic_types::vector_types::vector::misc::BoxCorners; use graphic_types::vector_types::vector::style::DashPattern; @@ -97,7 +98,8 @@ macro_rules! tagged_value { #[serde(alias = "Gradient", alias = "GradientTable", alias = "GradientPositions", alias = "GradientStops")] GradientRamp(GradientRamp), Strokes(Vec), - BrushCache(BrushCache), + #[serde(alias = "NodeCache", alias = "FootprintCache")] + BrushCache(Cache>), // ======================= // AUTO-GENERATED VARIANTS // ======================= @@ -309,7 +311,7 @@ macro_rules! tagged_value { Self::TransferCurve(_) => item!(TransferCurve), Self::GradientRamp(_) => item!(Gradient), Self::Strokes(_) => list!(Stroke), - Self::BrushCache(_) => item!(BrushCache), + Self::BrushCache(_) => item!(Cache>), // ======================= // AUTO-GENERATED VARIANTS // ======================= @@ -351,7 +353,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::BrushCache(downcast::>>>(input).unwrap().into_element())), // ======================= // AUTO-GENERATED VARIANTS // ======================= @@ -387,7 +389,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::BrushCache(input.downcast_ref::>>>().unwrap().element().clone())), // ======================= // AUTO-GENERATED VARIANTS // ======================= @@ -417,7 +419,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::BrushCache(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) => { 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..be6187989c6 --- /dev/null +++ b/node-graph/libraries/graphene-cache/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "graphene-cache" +version = "0.1.0" +edition = "2024" +description = "The footprint-based cache for Graphene" +authors = ["Graphite Authors "] +license = "MIT OR Apache-2.0" + +[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..c2ec2ba3df8 --- /dev/null +++ b/node-graph/libraries/graphene-cache/src/lib.rs @@ -0,0 +1,565 @@ +use core_types::transform::Footprint; +use glam::DMat2; +use std::sync::{Arc, Mutex}; + +// ===== +// Cache +// ===== + +/// A small keyed cache backed by a linear `Vec`. +/// It is not intended for many entries, so its `CachePolicy` must evict entries to keep the cache bounded. +pub struct Cache> { + inner: Arc>>, + nonce: u64, // Avoid deduplication of cache entries across different brush nodes. +} + +impl> Cache { + /// Removes and returns the value stored for `key`. + /// Returns `None` if the key is absent or the stored value has a different type. + /// A type mismatch leaves the original value cached. + pub fn take(&self, key: &K) -> Option { + let mut guard = self.inner.lock().unwrap(); + guard.take::(key) + } + + /// Clones the value stored for `key` without removing it. + /// Returns `None` if the key is absent or the stored value has a different type. + /// Cloning occurs while the cache lock is held. + pub fn get_cloned(&self, key: &K) -> Option { + let mut guard = self.inner.lock().unwrap(); + guard.get_cloned(key) + } + + /// Stores a value for `key`, replacing any existing value with the same key, regardless of its concrete type. + pub fn store(&self, key: &K, value: S) { + self.inner.lock().unwrap().store(key, Box::new(value)); + } +} + +impl> Default for Cache { + fn default() -> Self { + Self { + inner: Default::default(), + nonce: core_types::uuid::generate_uuid(), + } + } +} + +impl> Clone for Cache { + fn clone(&self) -> Self { + Self { + inner: self.inner.clone(), + nonce: self.nonce, + } + } +} + +impl> PartialEq for Cache { + fn eq(&self, _: &Self) -> bool { + true + } +} + +impl> std::fmt::Debug for Cache { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Cache").field("entries", &self.inner.lock().unwrap().entries.len()).finish() + } +} + +impl> core_types::CacheHash for Cache { + fn cache_hash(&self, state: &mut H) { + state.write_u64(self.nonce); + } +} + +unsafe impl + 'static> dyn_any::StaticType for Cache { + type Static = Cache; +} + +#[cfg(feature = "serde")] +impl> serde::Serialize for Cache { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_unit() + } +} + +#[cfg(feature = "serde")] +impl<'de, K, M: CachePolicy> serde::Deserialize<'de> for Cache { + fn deserialize>(deserializer: D) -> Result { + serde::de::IgnoredAny::deserialize(deserializer)?; + Ok(Self::default()) + } +} + +// =================== +// 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 accessing `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 on_hit(entry_state: &mut Self::EntryState, policy_state: &mut Self::PolicyState); + /// Initializes or updates policy state for a stored entry. + fn on_store(entry_state: &mut Self::EntryState, policy_state: &mut Self::PolicyState); +} + +pub struct Entry> { + entry_state: M::EntryState, + key: K, + value: BoxedValue, +} + +// ================================= +// 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 on_hit(entry_state: &mut Self::EntryState, policy_state: &mut Self::PolicyState) { + if entry_state == policy_state { + *policy_state += 1; + } + *entry_state = *policy_state; + } + + fn on_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 full. +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 on_hit(entry_state: &mut Self::EntryState, policy_state: &mut Self::PolicyState) { + *policy_state += 1; + *entry_state = *policy_state; + } + + fn on_store(entry_state: &mut Self::EntryState, policy_state: &mut Self::PolicyState) { + *policy_state += 1; + *entry_state = *policy_state; + } +} + +// ========== +// CacheInner +// ========== + +type BoxedValue = Box; + +struct CacheInner> { + policy_state: E::PolicyState, + entries: Vec>, +} + +impl> Default for CacheInner { + fn default() -> Self { + Self { + policy_state: Default::default(), + entries: Default::default(), + } + } +} + +impl> CacheInner { + fn take(&mut self, key: &K) -> Option { + M::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(index).unwrap(); + ::downcast_ref::(entry.value.as_ref())?; + + let mut entry = self.entries.remove(index); + entry.value.downcast().ok().map(|value| { + M::on_hit(&mut entry.entry_state, &mut self.policy_state); + *value + }) + }); + + M::retire(&mut self.entries, &mut self.policy_state); + hit.flatten() + } + + fn get_cloned(&mut self, key: &K) -> Option { + M::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(); + let value = ::downcast_ref::(entry.value.as_ref())?; + M::on_hit(&mut entry.entry_state, &mut self.policy_state); + Some(value.clone()) + }); + + M::retire(&mut self.entries, &mut self.policy_state); + hit.flatten() + } + + fn store(&mut self, key: &K, value: BoxedValue) { + M::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: M::EntryState::default(), + }; + + M::on_store(&mut entry.entry_state, &mut self.policy_state); + self.entries.push(entry); + M::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, + on_hit: usize, + on_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 on_hit(_entry_state: &mut Self::EntryState, counts: &mut Self::PolicyState) { + counts.on_hit += 1; + } + + fn on_store(_entry_state: &mut Self::EntryState, counts: &mut Self::PolicyState) { + counts.on_store += 1; + } + } + + fn live>(cache: &Cache) -> usize { + cache.inner.lock().unwrap().entries.len() + } + + #[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 inner = cache.inner.lock().unwrap(); + assert_eq!(inner.policy_state.touch, 2); + assert_eq!(inner.policy_state.retire, 2); + assert_eq!(inner.policy_state.on_store, 1); + assert_eq!(inner.policy_state.on_hit, 1); + } + + #[test] + fn take_type_mismatch_preserves_entry() { + let cache = Cache::::default(); + let key = DummyKey(0); + cache.store(&key, DummyValue(0)); + let mismatched_val = cache.take::<()>(&key); + + assert_eq!(live(&cache), 1); + assert!(mismatched_val.is_none()); + + let correct_val = cache.take::(&key); + assert_eq!(correct_val, Some(DummyValue(0))); + + let inner = cache.inner.lock().unwrap(); + assert_eq!(inner.policy_state.touch, 3); + assert_eq!(inner.policy_state.retire, 3); + assert_eq!(inner.policy_state.on_store, 1); + assert_eq!(inner.policy_state.on_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 inner = cache.inner.lock().unwrap(); + assert_eq!(inner.policy_state.touch, 2); + assert_eq!(inner.policy_state.retire, 2); + assert_eq!(inner.policy_state.on_store, 1); + assert_eq!(inner.policy_state.on_hit, 1); + } + + #[test] + fn get_cloned_type_mismatch_preserves_entry() { + let cache = Cache::::default(); + let key = DummyKey(0); + cache.store(&key, DummyValue(0)); + let mismatched_val = cache.get_cloned::<()>(&key); + + assert_eq!(live(&cache), 1); + assert!(mismatched_val.is_none()); + + let correct_val = cache.get_cloned::(&key); + assert_eq!(correct_val, Some(DummyValue(0))); + + let inner = cache.inner.lock().unwrap(); + assert_eq!(inner.policy_state.touch, 3); + assert_eq!(inner.policy_state.retire, 3); + assert_eq!(inner.policy_state.on_store, 1); + assert_eq!(inner.policy_state.on_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 inner = cache.inner.lock().unwrap(); + assert!(inner.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 inner = cache.inner.lock().unwrap(); + assert_eq!(inner.entries.iter().find(|entry| entry.key == key0).unwrap().entry_state, inner.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..595ea251030 100644 --- a/node-graph/nodes/brush/Cargo.toml +++ b/node-graph/nodes/brush/Cargo.toml @@ -16,6 +16,7 @@ serde = ["dep:serde", "core-types/serde", "raster-types/serde"] # Local dependencies dyn-any = { workspace = true } brush-types = { workspace = true } +graphene-cache = { workspace = true } core-types = { workspace = true } graphene-hash = { workspace = true } graphic-types = { workspace = true } diff --git a/node-graph/nodes/brush/src/basic_brush/mod.rs b/node-graph/nodes/brush/src/basic_brush/mod.rs index 3fd304cef7c..3b0e4f00838 100644 --- a/node-graph/nodes/brush/src/basic_brush/mod.rs +++ b/node-graph/nodes/brush/src/basic_brush/mod.rs @@ -6,9 +6,10 @@ mod region; mod render; mod stroke; -use brush_types::BrushCache; use core_types::list::{ATTR_COLOR, ATTR_DIAMETER, ATTR_FLOW, ATTR_HARDNESS, Item, List}; +use core_types::transform::Footprint; use core_types::{ATTR_TRANSFORM, Ctx, ExtractFootprint}; +use graphene_cache::{Cache, GenerationalEviction}; use graphic_types::Graphic; use pipeline::{BasicBrushPipeline, BasicBrushPipelineArgs}; use raster_types::{GPU, Raster}; @@ -18,7 +19,7 @@ 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()); diff --git a/node-graph/nodes/brush/src/basic_brush/pipeline.rs b/node-graph/nodes/brush/src/basic_brush/pipeline.rs index b103f3fb61a..ed11b8ccdca 100644 --- a/node-graph/nodes/brush/src/basic_brush/pipeline.rs +++ b/node-graph/nodes/brush/src/basic_brush/pipeline.rs @@ -3,11 +3,11 @@ 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; use glam::{DAffine2, UVec2}; +use graphene_cache::{Cache, GenerationalEviction}; use raster_types::Texture; use wgpu_executor::{AsyncWgpuPipeline, Buffer, WgpuExecutor}; @@ -68,7 +68,7 @@ pub(super) struct FieldViews { pub struct BasicBrushPipelineArgs<'a> { pub(super) footprint: Footprint, pub(super) strokes: &'a [StyledStroke], - pub(super) cache: &'a BrushCache, + pub(super) cache: &'a Cache>, } impl AsyncWgpuPipeline for BasicBrushPipeline { From 84f5c629d7ef0ece5e7434ff6fb046f037a10a7e Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Sun, 13 Sep 2026 14:30:01 +0900 Subject: [PATCH 2/9] Add type alias BrushCache --- Cargo.lock | 3 +-- node-graph/graph-craft/Cargo.toml | 1 - node-graph/graph-craft/src/document/value.rs | 14 ++++++-------- node-graph/libraries/brush-types/Cargo.toml | 1 + node-graph/libraries/brush-types/src/lib.rs | 4 ++++ node-graph/nodes/brush/Cargo.toml | 1 - node-graph/nodes/brush/src/basic_brush/mod.rs | 6 +++--- node-graph/nodes/brush/src/basic_brush/pipeline.rs | 5 +++-- 8 files changed, 18 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7d735d789ff..68226ff7e3f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -359,7 +359,6 @@ dependencies = [ "core-types", "dyn-any", "glam", - "graphene-cache", "graphene-hash", "graphic-types", "half", @@ -378,6 +377,7 @@ dependencies = [ "core-types", "dyn-any", "glam", + "graphene-cache", "graphene-hash", "serde", ] @@ -2012,7 +2012,6 @@ dependencies = [ "glam", "graph-craft", "graphene-application-io", - "graphene-cache", "graphene-core", "graphene-hash", "graphic-types", diff --git a/node-graph/graph-craft/Cargo.toml b/node-graph/graph-craft/Cargo.toml index 8c13ff3ff3c..739b5be2b7f 100644 --- a/node-graph/graph-craft/Cargo.toml +++ b/node-graph/graph-craft/Cargo.toml @@ -27,7 +27,6 @@ dyn-any = { workspace = true } graphene-hash = { workspace = true } core-types = { workspace = true, features = ["serde"] } brush-nodes = { workspace = true, features = ["serde"] } -graphene-cache = { workspace = true, features = ["serde"] } graphene-core = { workspace = true, features = ["serde"] } graphene-application-io = { workspace = true, features = ["serde"] } rendering = { workspace = true, features = ["serde"] } diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index 299844c9cba..8317bac090c 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -2,7 +2,7 @@ use super::DocumentNode; use crate::application_io::PlatformEditorApi; use crate::application_io::resource::Resource; use crate::proto::{Any as DAny, FutureAny}; -use brush_nodes::Stroke; +use brush_nodes::{BrushCache, Stroke}; use core_types::color::SRGBA8; use core_types::list::{Item, List, NodeIdPath}; use core_types::transfer_curve::TransferCurve; @@ -13,7 +13,6 @@ pub use dyn_any::StaticType; pub use glam::{DAffine2, DVec2, IVec2, UVec2}; use graphene_application_io::resource::ResourceHash; use graphene_application_io::resource::ResourceId; -use graphene_cache::{Cache, GenerationalEviction}; use graphic_types::raster_types::{CPU, Image, Raster}; use graphic_types::vector_types::vector::misc::BoxCorners; use graphic_types::vector_types::vector::style::DashPattern; @@ -98,8 +97,7 @@ macro_rules! tagged_value { #[serde(alias = "Gradient", alias = "GradientTable", alias = "GradientPositions", alias = "GradientStops")] GradientRamp(GradientRamp), Strokes(Vec), - #[serde(alias = "NodeCache", alias = "FootprintCache")] - BrushCache(Cache>), + BrushCache(BrushCache), // ======================= // AUTO-GENERATED VARIANTS // ======================= @@ -311,7 +309,7 @@ macro_rules! tagged_value { Self::TransferCurve(_) => item!(TransferCurve), Self::GradientRamp(_) => item!(Gradient), Self::Strokes(_) => list!(Stroke), - Self::BrushCache(_) => item!(Cache>), + Self::BrushCache(_) => item!(BrushCache), // ======================= // AUTO-GENERATED VARIANTS // ======================= @@ -353,7 +351,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::BrushCache(downcast::>(input).unwrap().into_element())), // ======================= // AUTO-GENERATED VARIANTS // ======================= @@ -389,7 +387,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::BrushCache(input.downcast_ref::>().unwrap().element().clone())), // ======================= // AUTO-GENERATED VARIANTS // ======================= @@ -419,7 +417,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::BrushCache(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) => { diff --git a/node-graph/libraries/brush-types/Cargo.toml b/node-graph/libraries/brush-types/Cargo.toml index 68cf67dc490..8d981faafe1 100644 --- a/node-graph/libraries/brush-types/Cargo.toml +++ b/node-graph/libraries/brush-types/Cargo.toml @@ -15,6 +15,7 @@ serde = ["dep:serde", "core-types/serde"] [dependencies] # Local dependencies core-types = { workspace = true } +graphene-cache = { workspace = true } graphene-hash = { workspace = true } # Workspace dependencies diff --git a/node-graph/libraries/brush-types/src/lib.rs b/node-graph/libraries/brush-types/src/lib.rs index 023fb29176e..c3904c59528 100644 --- a/node-graph/libraries/brush-types/src/lib.rs +++ b/node-graph/libraries/brush-types/src/lib.rs @@ -1,10 +1,14 @@ use core_types::CacheHash; use core_types::bounds::{BoundingBox, RenderBoundingBox}; use core_types::render_complexity::RenderComplexity; +use core_types::transform::Footprint; use dyn_any::DynAny; use glam::{DAffine2, DVec2, Vec2}; +use graphene_cache::{Cache, GenerationalEviction}; use std::f32::consts::{PI, TAU}; +pub type BrushCache = Cache>; + #[derive(Clone, Debug, PartialEq, CacheHash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub enum Channel { diff --git a/node-graph/nodes/brush/Cargo.toml b/node-graph/nodes/brush/Cargo.toml index 595ea251030..12e8a5cd0d8 100644 --- a/node-graph/nodes/brush/Cargo.toml +++ b/node-graph/nodes/brush/Cargo.toml @@ -16,7 +16,6 @@ serde = ["dep:serde", "core-types/serde", "raster-types/serde"] # Local dependencies dyn-any = { workspace = true } brush-types = { workspace = true } -graphene-cache = { workspace = true } core-types = { workspace = true } graphene-hash = { workspace = true } graphic-types = { workspace = true } diff --git a/node-graph/nodes/brush/src/basic_brush/mod.rs b/node-graph/nodes/brush/src/basic_brush/mod.rs index 3b0e4f00838..4e280958b17 100644 --- a/node-graph/nodes/brush/src/basic_brush/mod.rs +++ b/node-graph/nodes/brush/src/basic_brush/mod.rs @@ -7,19 +7,19 @@ mod render; mod stroke; use core_types::list::{ATTR_COLOR, ATTR_DIAMETER, ATTR_FLOW, ATTR_HARDNESS, Item, List}; -use core_types::transform::Footprint; use core_types::{ATTR_TRANSFORM, Ctx, ExtractFootprint}; -use graphene_cache::{Cache, GenerationalEviction}; use graphic_types::Graphic; use pipeline::{BasicBrushPipeline, BasicBrushPipelineArgs}; use raster_types::{GPU, Raster}; use wgpu_executor::{WgpuExecutor, WgpuPipelineCache}; +use crate::BrushCache; + #[node_macro::node(category("Raster: Brush"))] 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()); diff --git a/node-graph/nodes/brush/src/basic_brush/pipeline.rs b/node-graph/nodes/brush/src/basic_brush/pipeline.rs index ed11b8ccdca..27de7c9ac62 100644 --- a/node-graph/nodes/brush/src/basic_brush/pipeline.rs +++ b/node-graph/nodes/brush/src/basic_brush/pipeline.rs @@ -7,10 +7,11 @@ use bytemuck::{Pod, Zeroable}; use core_types::Color; use core_types::transform::Footprint; use glam::{DAffine2, UVec2}; -use graphene_cache::{Cache, GenerationalEviction}; use raster_types::Texture; use wgpu_executor::{AsyncWgpuPipeline, Buffer, WgpuExecutor}; +use crate::BrushCache; + pub(super) const DENSITY_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::R16Float; pub(super) const COMPOSITE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float; @@ -68,7 +69,7 @@ pub(super) struct FieldViews { pub struct BasicBrushPipelineArgs<'a> { pub(super) footprint: Footprint, pub(super) strokes: &'a [StyledStroke], - pub(super) cache: &'a Cache>, + pub(super) cache: &'a BrushCache, } impl AsyncWgpuPipeline for BasicBrushPipeline { From 73102c0c423c0d50fca34c366d6820a3f851f35a Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Mon, 14 Sep 2026 10:46:08 +0900 Subject: [PATCH 3/9] Fix description --- node-graph/libraries/graphene-cache/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/node-graph/libraries/graphene-cache/Cargo.toml b/node-graph/libraries/graphene-cache/Cargo.toml index be6187989c6..8a4b8511289 100644 --- a/node-graph/libraries/graphene-cache/Cargo.toml +++ b/node-graph/libraries/graphene-cache/Cargo.toml @@ -2,7 +2,7 @@ name = "graphene-cache" version = "0.1.0" edition = "2024" -description = "The footprint-based cache for Graphene" +description = "A keyed cache for Graphene" authors = ["Graphite Authors "] license = "MIT OR Apache-2.0" From dd53c9de344133cdd239b9f0bab5d4a88209e76f Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Wed, 23 Sep 2026 17:02:37 +0900 Subject: [PATCH 4/9] Inherit package metadata from workspace --- Cargo.lock | 2 +- node-graph/libraries/graphene-cache/Cargo.toml | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 68226ff7e3f..caa209b9e3b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2058,7 +2058,7 @@ dependencies = [ [[package]] name = "graphene-cache" -version = "0.1.0" +version = "0.0.0" dependencies = [ "core-types", "dyn-any", diff --git a/node-graph/libraries/graphene-cache/Cargo.toml b/node-graph/libraries/graphene-cache/Cargo.toml index 8a4b8511289..0e72c7ce70c 100644 --- a/node-graph/libraries/graphene-cache/Cargo.toml +++ b/node-graph/libraries/graphene-cache/Cargo.toml @@ -1,10 +1,12 @@ [package] name = "graphene-cache" -version = "0.1.0" -edition = "2024" description = "A keyed cache for Graphene" -authors = ["Graphite Authors "] -license = "MIT OR Apache-2.0" +version.workspace = true +license.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +publish.workspace = true [features] default = ["serde"] From c57bc1196f6fdf159e6daaca6039f24a85adb0d4 Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Wed, 23 Sep 2026 17:02:58 +0900 Subject: [PATCH 5/9] Polish type aliases --- .../libraries/graphene-cache/src/lib.rs | 76 +++++++++---------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/node-graph/libraries/graphene-cache/src/lib.rs b/node-graph/libraries/graphene-cache/src/lib.rs index c2ec2ba3df8..533a5bb73da 100644 --- a/node-graph/libraries/graphene-cache/src/lib.rs +++ b/node-graph/libraries/graphene-cache/src/lib.rs @@ -8,35 +8,35 @@ use std::sync::{Arc, Mutex}; /// A small keyed cache backed by a linear `Vec`. /// It is not intended for many entries, so its `CachePolicy` must evict entries to keep the cache bounded. -pub struct Cache> { - inner: Arc>>, +pub struct Cache> { + inner: Arc>>, nonce: u64, // Avoid deduplication of cache entries across different brush nodes. } -impl> Cache { +impl> Cache { /// Removes and returns the value stored for `key`. /// Returns `None` if the key is absent or the stored value has a different type. /// A type mismatch leaves the original value cached. - pub fn take(&self, key: &K) -> Option { + pub fn take(&self, key: &K) -> Option { let mut guard = self.inner.lock().unwrap(); - guard.take::(key) + guard.take::(key) } /// Clones the value stored for `key` without removing it. /// Returns `None` if the key is absent or the stored value has a different type. /// Cloning occurs while the cache lock is held. - pub fn get_cloned(&self, key: &K) -> Option { + pub fn get_cloned(&self, key: &K) -> Option { let mut guard = self.inner.lock().unwrap(); guard.get_cloned(key) } /// Stores a value for `key`, replacing any existing value with the same key, regardless of its concrete type. - pub fn store(&self, key: &K, value: S) { + pub fn store(&self, key: &K, value: V) { self.inner.lock().unwrap().store(key, Box::new(value)); } } -impl> Default for Cache { +impl> Default for Cache { fn default() -> Self { Self { inner: Default::default(), @@ -45,7 +45,7 @@ impl> Default for Cache { } } -impl> Clone for Cache { +impl> Clone for Cache { fn clone(&self) -> Self { Self { inner: self.inner.clone(), @@ -54,37 +54,37 @@ impl> Clone for Cache { } } -impl> PartialEq for Cache { +impl> PartialEq for Cache { fn eq(&self, _: &Self) -> bool { true } } -impl> std::fmt::Debug for Cache { +impl> std::fmt::Debug for Cache { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Cache").field("entries", &self.inner.lock().unwrap().entries.len()).finish() } } -impl> core_types::CacheHash for Cache { +impl> core_types::CacheHash for Cache { fn cache_hash(&self, state: &mut H) { state.write_u64(self.nonce); } } -unsafe impl + 'static> dyn_any::StaticType for Cache { - type Static = Cache; +unsafe impl + 'static> dyn_any::StaticType for Cache { + type Static = Cache; } #[cfg(feature = "serde")] -impl> serde::Serialize for Cache { +impl> serde::Serialize for Cache { fn serialize(&self, serializer: S) -> Result { serializer.serialize_unit() } } #[cfg(feature = "serde")] -impl<'de, K, M: CachePolicy> serde::Deserialize<'de> for Cache { +impl<'de, K, Policy: CachePolicy> serde::Deserialize<'de> for Cache { fn deserialize>(deserializer: D) -> Result { serde::de::IgnoredAny::deserialize(deserializer)?; Ok(Self::default()) @@ -112,8 +112,8 @@ pub trait CachePolicy: Sized { fn on_store(entry_state: &mut Self::EntryState, policy_state: &mut Self::PolicyState); } -pub struct Entry> { - entry_state: M::EntryState, +pub struct Entry> { + entry_state: Policy::EntryState, key: K, value: BoxedValue, } @@ -220,12 +220,12 @@ impl CachePolicy for Lru { type BoxedValue = Box; -struct CacheInner> { - policy_state: E::PolicyState, - entries: Vec>, +struct CacheInner> { + policy_state: Policy::PolicyState, + entries: Vec>, } -impl> Default for CacheInner { +impl> Default for CacheInner { fn default() -> Self { Self { policy_state: Default::default(), @@ -234,54 +234,54 @@ impl> Default for CacheInner { } } -impl> CacheInner { - fn take(&mut self, key: &K) -> Option { - M::touch(key, &mut self.entries, &mut self.policy_state); +impl> CacheInner { + fn take(&mut self, key: &K) -> Option { + Policy::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(index).unwrap(); - ::downcast_ref::(entry.value.as_ref())?; + ::downcast_ref::(entry.value.as_ref())?; let mut entry = self.entries.remove(index); entry.value.downcast().ok().map(|value| { - M::on_hit(&mut entry.entry_state, &mut self.policy_state); + Policy::on_hit(&mut entry.entry_state, &mut self.policy_state); *value }) }); - M::retire(&mut self.entries, &mut self.policy_state); + Policy::retire(&mut self.entries, &mut self.policy_state); hit.flatten() } - fn get_cloned(&mut self, key: &K) -> Option { - M::touch(key, &mut self.entries, &mut self.policy_state); + fn get_cloned(&mut self, key: &K) -> Option { + Policy::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(); - let value = ::downcast_ref::(entry.value.as_ref())?; - M::on_hit(&mut entry.entry_state, &mut self.policy_state); + let value = ::downcast_ref::(entry.value.as_ref())?; + Policy::on_hit(&mut entry.entry_state, &mut self.policy_state); Some(value.clone()) }); - M::retire(&mut self.entries, &mut self.policy_state); + Policy::retire(&mut self.entries, &mut self.policy_state); hit.flatten() } fn store(&mut self, key: &K, value: BoxedValue) { - M::touch(key, &mut self.entries, &mut self.policy_state); + Policy::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: M::EntryState::default(), + entry_state: Policy::EntryState::default(), }; - M::on_store(&mut entry.entry_state, &mut self.policy_state); + Policy::on_store(&mut entry.entry_state, &mut self.policy_state); self.entries.push(entry); - M::retire(&mut self.entries, &mut self.policy_state); + Policy::retire(&mut self.entries, &mut self.policy_state); } } @@ -323,7 +323,7 @@ mod tests { } } - fn live>(cache: &Cache) -> usize { + fn live>(cache: &Cache) -> usize { cache.inner.lock().unwrap().entries.len() } From 62ba3187f2ef9aeb03ac6b82a4e4ae25915c8432 Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Thu, 24 Sep 2026 10:38:02 +0900 Subject: [PATCH 6/9] Apply review feedback on docs and naming --- .../libraries/graphene-cache/src/lib.rs | 163 +++++++++--------- 1 file changed, 81 insertions(+), 82 deletions(-) diff --git a/node-graph/libraries/graphene-cache/src/lib.rs b/node-graph/libraries/graphene-cache/src/lib.rs index 533a5bb73da..82e1cbc4133 100644 --- a/node-graph/libraries/graphene-cache/src/lib.rs +++ b/node-graph/libraries/graphene-cache/src/lib.rs @@ -6,85 +6,84 @@ use std::sync::{Arc, Mutex}; // Cache // ===== -/// A small keyed cache backed by a linear `Vec`. -/// It is not intended for many entries, so its `CachePolicy` must evict entries to keep the cache bounded. +/// A keyed cache backed by a linear `Vec`. pub struct Cache> { - inner: Arc>>, - nonce: u64, // Avoid deduplication of cache entries across different brush nodes. + state: Arc>>, + nonce: u64, // Avoid deduplication of cache instances across different nodes. } -impl> Cache { +impl> Cache { /// Removes and returns the value stored for `key`. /// Returns `None` if the key is absent or the stored value has a different type. /// A type mismatch leaves the original value cached. - pub fn take(&self, key: &K) -> Option { - let mut guard = self.inner.lock().unwrap(); + pub fn take(&self, key: &Key) -> Option { + let mut guard = self.state.lock().unwrap(); guard.take::(key) } /// Clones the value stored for `key` without removing it. /// Returns `None` if the key is absent or the stored value has a different type. /// Cloning occurs while the cache lock is held. - pub fn get_cloned(&self, key: &K) -> Option { - let mut guard = self.inner.lock().unwrap(); + pub fn get_cloned(&self, key: &Key) -> Option { + let mut guard = self.state.lock().unwrap(); guard.get_cloned(key) } /// Stores a value for `key`, replacing any existing value with the same key, regardless of its concrete type. - pub fn store(&self, key: &K, value: V) { - self.inner.lock().unwrap().store(key, Box::new(value)); + pub fn store(&self, key: &Key, value: V) { + self.state.lock().unwrap().store(key, Box::new(value)); } } -impl> Default for Cache { +impl> Default for Cache { fn default() -> Self { Self { - inner: Default::default(), + state: Default::default(), nonce: core_types::uuid::generate_uuid(), } } } -impl> Clone for Cache { +impl> Clone for Cache { fn clone(&self) -> Self { Self { - inner: self.inner.clone(), + state: self.state.clone(), nonce: self.nonce, } } } -impl> PartialEq for Cache { +impl> PartialEq for Cache { fn eq(&self, _: &Self) -> bool { true } } -impl> std::fmt::Debug for Cache { +impl> std::fmt::Debug for Cache { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Cache").field("entries", &self.inner.lock().unwrap().entries.len()).finish() + f.debug_struct("Cache").field("entries", &self.state.lock().unwrap().entries.len()).finish() } } -impl> core_types::CacheHash for Cache { +impl> core_types::CacheHash for Cache { fn cache_hash(&self, state: &mut H) { state.write_u64(self.nonce); } } -unsafe impl + 'static> dyn_any::StaticType for Cache { - type Static = Cache; +unsafe impl + 'static> dyn_any::StaticType for Cache { + type Static = Cache; } #[cfg(feature = "serde")] -impl> serde::Serialize for Cache { +impl> serde::Serialize for Cache { fn serialize(&self, serializer: S) -> Result { serializer.serialize_unit() } } #[cfg(feature = "serde")] -impl<'de, K, Policy: CachePolicy> serde::Deserialize<'de> for Cache { +impl<'de, Key, Policy: CachePolicy> serde::Deserialize<'de> for Cache { fn deserialize>(deserializer: D) -> Result { serde::de::IgnoredAny::deserialize(deserializer)?; Ok(Self::default()) @@ -96,25 +95,25 @@ impl<'de, K, Policy: CachePolicy> serde::Deserialize<'de> for Cache: Sized { +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 accessing `key`. - fn touch(key: &K, entries: &mut Vec>, policy_state: &mut Self::PolicyState); + fn touch(key: &Key, 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); + fn retire(entries: &mut Vec>, policy_state: &mut Self::PolicyState); /// Updates policy state when an entry is accessed successfully. - fn on_hit(entry_state: &mut Self::EntryState, policy_state: &mut Self::PolicyState); + fn hit(entry_state: &mut Self::EntryState, policy_state: &mut Self::PolicyState); /// Initializes or updates policy state for a stored entry. - fn on_store(entry_state: &mut Self::EntryState, policy_state: &mut Self::PolicyState); + fn store(entry_state: &mut Self::EntryState, policy_state: &mut Self::PolicyState); } -pub struct Entry> { +pub struct Entry> { entry_state: Policy::EntryState, - key: K, + key: Key, value: BoxedValue, } @@ -125,15 +124,15 @@ pub struct Entry> { /// Retains recently used key groups and evicts entries that exceed the configured age or group limit. pub struct GenerationalEviction; -impl CachePolicy for GenerationalEviction { +impl CachePolicy for GenerationalEviction { type PolicyState = u64; type EntryState = u64; - fn touch(key: &K, entries: &mut Vec>, _policy_state: &mut Self::PolicyState) { + fn touch(key: &Key, 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) { + fn retire(entries: &mut Vec>, policy_state: &mut Self::PolicyState) { if *policy_state == u64::MAX { entries.clear(); *policy_state = 0; @@ -148,14 +147,14 @@ impl CachePo } } - fn on_hit(entry_state: &mut Self::EntryState, policy_state: &mut Self::PolicyState) { + 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 on_store(entry_state: &mut Self::EntryState, policy_state: &mut Self::PolicyState) { + fn store(entry_state: &mut Self::EntryState, policy_state: &mut Self::PolicyState) { *entry_state = *policy_state; } } @@ -180,15 +179,15 @@ impl CacheKeyGroup for Footprint { /// Retains up to `CAPACITY` entries and evicts the least recently used entry when full. pub struct Lru; -impl CachePolicy for 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 touch(_key: &Key, _entries: &mut Vec>, _policy_state: &mut Self::PolicyState) {} - fn retire(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; @@ -203,29 +202,29 @@ impl CachePolicy for Lru { entries.swap_remove(oldest_index); } - fn on_hit(entry_state: &mut Self::EntryState, policy_state: &mut Self::PolicyState) { + fn hit(entry_state: &mut Self::EntryState, policy_state: &mut Self::PolicyState) { *policy_state += 1; *entry_state = *policy_state; } - fn on_store(entry_state: &mut Self::EntryState, policy_state: &mut Self::PolicyState) { + fn store(entry_state: &mut Self::EntryState, policy_state: &mut Self::PolicyState) { *policy_state += 1; *entry_state = *policy_state; } } // ========== -// CacheInner +// CacheState // ========== type BoxedValue = Box; -struct CacheInner> { +struct CacheState> { policy_state: Policy::PolicyState, - entries: Vec>, + entries: Vec>, } -impl> Default for CacheInner { +impl> Default for CacheState { fn default() -> Self { Self { policy_state: Default::default(), @@ -234,8 +233,8 @@ impl> Default for CacheInner { } } -impl> CacheInner { - fn take(&mut self, key: &K) -> Option { +impl> CacheState { + fn take(&mut self, key: &Key) -> Option { Policy::touch(key, &mut self.entries, &mut self.policy_state); let index = self.entries.iter().position(|entry| entry.key == *key); @@ -245,7 +244,7 @@ impl> CacheInner { let mut entry = self.entries.remove(index); entry.value.downcast().ok().map(|value| { - Policy::on_hit(&mut entry.entry_state, &mut self.policy_state); + Policy::hit(&mut entry.entry_state, &mut self.policy_state); *value }) }); @@ -254,14 +253,14 @@ impl> CacheInner { hit.flatten() } - fn get_cloned(&mut self, key: &K) -> Option { + fn get_cloned(&mut self, key: &Key) -> Option { Policy::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(); let value = ::downcast_ref::(entry.value.as_ref())?; - Policy::on_hit(&mut entry.entry_state, &mut self.policy_state); + Policy::hit(&mut entry.entry_state, &mut self.policy_state); Some(value.clone()) }); @@ -269,7 +268,7 @@ impl> CacheInner { hit.flatten() } - fn store(&mut self, key: &K, value: BoxedValue) { + fn store(&mut self, key: &Key, value: BoxedValue) { Policy::touch(key, &mut self.entries, &mut self.policy_state); self.entries.retain(|entry| entry.key != *key); @@ -279,7 +278,7 @@ impl> CacheInner { entry_state: Policy::EntryState::default(), }; - Policy::on_store(&mut entry.entry_state, &mut self.policy_state); + Policy::store(&mut entry.entry_state, &mut self.policy_state); self.entries.push(entry); Policy::retire(&mut self.entries, &mut self.policy_state); } @@ -302,29 +301,29 @@ mod tests { } struct TestPolicy; - impl CachePolicy for TestPolicy { + impl CachePolicy for TestPolicy { type PolicyState = CallCounts; type EntryState = (); - fn touch(_key: &K, _entries: &mut Vec>, counts: &mut Self::PolicyState) { + fn touch(_key: &Key, _entries: &mut Vec>, counts: &mut Self::PolicyState) { counts.touch += 1; } - fn retire(_entries: &mut Vec>, counts: &mut Self::PolicyState) { + fn retire(_entries: &mut Vec>, counts: &mut Self::PolicyState) { counts.retire += 1; } - fn on_hit(_entry_state: &mut Self::EntryState, counts: &mut Self::PolicyState) { + fn hit(_entry_state: &mut Self::EntryState, counts: &mut Self::PolicyState) { counts.on_hit += 1; } - fn on_store(_entry_state: &mut Self::EntryState, counts: &mut Self::PolicyState) { + fn store(_entry_state: &mut Self::EntryState, counts: &mut Self::PolicyState) { counts.on_store += 1; } } - fn live>(cache: &Cache) -> usize { - cache.inner.lock().unwrap().entries.len() + fn live>(cache: &Cache) -> usize { + cache.state.lock().unwrap().entries.len() } #[test] @@ -338,11 +337,11 @@ mod tests { assert_eq!(taken_val, Some(DummyValue(0))); assert_eq!(live(&cache), 0); - let inner = cache.inner.lock().unwrap(); - assert_eq!(inner.policy_state.touch, 2); - assert_eq!(inner.policy_state.retire, 2); - assert_eq!(inner.policy_state.on_store, 1); - assert_eq!(inner.policy_state.on_hit, 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.on_store, 1); + assert_eq!(state.policy_state.on_hit, 1); } #[test] @@ -358,11 +357,11 @@ mod tests { let correct_val = cache.take::(&key); assert_eq!(correct_val, Some(DummyValue(0))); - let inner = cache.inner.lock().unwrap(); - assert_eq!(inner.policy_state.touch, 3); - assert_eq!(inner.policy_state.retire, 3); - assert_eq!(inner.policy_state.on_store, 1); - assert_eq!(inner.policy_state.on_hit, 1); + let state = cache.state.lock().unwrap(); + assert_eq!(state.policy_state.touch, 3); + assert_eq!(state.policy_state.retire, 3); + assert_eq!(state.policy_state.on_store, 1); + assert_eq!(state.policy_state.on_hit, 1); } #[test] @@ -376,11 +375,11 @@ mod tests { assert_eq!(cloned_val, Some(DummyValue(0))); assert_eq!(live(&cache), 1); - let inner = cache.inner.lock().unwrap(); - assert_eq!(inner.policy_state.touch, 2); - assert_eq!(inner.policy_state.retire, 2); - assert_eq!(inner.policy_state.on_store, 1); - assert_eq!(inner.policy_state.on_hit, 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.on_store, 1); + assert_eq!(state.policy_state.on_hit, 1); } #[test] @@ -396,11 +395,11 @@ mod tests { let correct_val = cache.get_cloned::(&key); assert_eq!(correct_val, Some(DummyValue(0))); - let inner = cache.inner.lock().unwrap(); - assert_eq!(inner.policy_state.touch, 3); - assert_eq!(inner.policy_state.retire, 3); - assert_eq!(inner.policy_state.on_store, 1); - assert_eq!(inner.policy_state.on_hit, 1); + let state = cache.state.lock().unwrap(); + assert_eq!(state.policy_state.touch, 3); + assert_eq!(state.policy_state.retire, 3); + assert_eq!(state.policy_state.on_store, 1); + assert_eq!(state.policy_state.on_hit, 1); } mod footprint_generational_eviction { @@ -519,8 +518,8 @@ mod tests { cache.store(&key2, val2); assert_eq!(live(&cache), 2); - let inner = cache.inner.lock().unwrap(); - assert!(inner.entries.iter().find(|entry| entry.key == key1).is_none()); + let state = cache.state.lock().unwrap(); + assert!(state.entries.iter().find(|entry| entry.key == key1).is_none()); } #[test] @@ -531,8 +530,8 @@ mod tests { cache.store(&key1, val1); let _ = cache.get_cloned::(&key0); - let inner = cache.inner.lock().unwrap(); - assert_eq!(inner.entries.iter().find(|entry| entry.key == key0).unwrap().entry_state, inner.policy_state); + let state = cache.state.lock().unwrap(); + assert_eq!(state.entries.iter().find(|entry| entry.key == key0).unwrap().entry_state, state.policy_state); } #[test] From 5098f16a697db505fec17ccf773037fe7d4b5fda Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Thu, 24 Sep 2026 16:24:39 +0900 Subject: [PATCH 7/9] Make Cache generic over values and introduce CacheHandle --- Cargo.lock | 3 +- .../messages/tool/tool_messages/brush_tool.rs | 2 +- node-graph/graph-craft/Cargo.toml | 1 + node-graph/graph-craft/src/document/value.rs | 23 +- node-graph/libraries/brush-types/Cargo.toml | 1 - node-graph/libraries/brush-types/src/lib.rs | 4 - .../libraries/graphene-cache/src/lib.rs | 347 ++++++++++-------- node-graph/nodes/brush/Cargo.toml | 1 + .../nodes/brush/src/basic_brush/cache.rs | 6 + node-graph/nodes/brush/src/basic_brush/mod.rs | 11 +- .../nodes/brush/src/basic_brush/pipeline.rs | 3 +- 11 files changed, 217 insertions(+), 185 deletions(-) create mode 100644 node-graph/nodes/brush/src/basic_brush/cache.rs diff --git a/Cargo.lock b/Cargo.lock index caa209b9e3b..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", @@ -377,7 +378,6 @@ dependencies = [ "core-types", "dyn-any", "glam", - "graphene-cache", "graphene-hash", "serde", ] @@ -2012,6 +2012,7 @@ dependencies = [ "glam", "graph-craft", "graphene-application-io", + "graphene-cache", "graphene-core", "graphene-hash", "graphic-types", diff --git a/editor/src/messages/tool/tool_messages/brush_tool.rs b/editor/src/messages/tool/tool_messages/brush_tool.rs index d9f57f0f2d4..a280d59ae15 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/Cargo.toml b/node-graph/libraries/brush-types/Cargo.toml index 8d981faafe1..68cf67dc490 100644 --- a/node-graph/libraries/brush-types/Cargo.toml +++ b/node-graph/libraries/brush-types/Cargo.toml @@ -15,7 +15,6 @@ serde = ["dep:serde", "core-types/serde"] [dependencies] # Local dependencies core-types = { workspace = true } -graphene-cache = { workspace = true } graphene-hash = { workspace = true } # Workspace dependencies diff --git a/node-graph/libraries/brush-types/src/lib.rs b/node-graph/libraries/brush-types/src/lib.rs index c3904c59528..023fb29176e 100644 --- a/node-graph/libraries/brush-types/src/lib.rs +++ b/node-graph/libraries/brush-types/src/lib.rs @@ -1,14 +1,10 @@ use core_types::CacheHash; use core_types::bounds::{BoundingBox, RenderBoundingBox}; use core_types::render_complexity::RenderComplexity; -use core_types::transform::Footprint; use dyn_any::DynAny; use glam::{DAffine2, DVec2, Vec2}; -use graphene_cache::{Cache, GenerationalEviction}; use std::f32::consts::{PI, TAU}; -pub type BrushCache = Cache>; - #[derive(Clone, Debug, PartialEq, CacheHash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub enum Channel { diff --git a/node-graph/libraries/graphene-cache/src/lib.rs b/node-graph/libraries/graphene-cache/src/lib.rs index 82e1cbc4133..26c3c426f1a 100644 --- a/node-graph/libraries/graphene-cache/src/lib.rs +++ b/node-graph/libraries/graphene-cache/src/lib.rs @@ -1,120 +1,160 @@ use core_types::transform::Footprint; use glam::DMat2; -use std::sync::{Arc, Mutex}; - -// ===== -// Cache -// ===== - -/// A keyed cache backed by a linear `Vec`. -pub struct Cache> { - state: Arc>>, +use std::{ + any::Any, + sync::{Arc, Mutex}, +}; + +// =========== +// CacheHandle +// =========== + +#[derive(Clone)] +pub struct CacheHandle { + slot: Arc>>>, nonce: u64, // Avoid deduplication of cache instances across different nodes. } -impl> Cache { - /// Removes and returns the value stored for `key`. - /// Returns `None` if the key is absent or the stored value has a different type. - /// A type mismatch leaves the original value cached. - pub fn take(&self, key: &Key) -> Option { - let mut guard = self.state.lock().unwrap(); - guard.take::(key) - } - - /// Clones the value stored for `key` without removing it. - /// Returns `None` if the key is absent or the stored value has a different type. - /// Cloning occurs while the cache lock is held. - pub fn get_cloned(&self, key: &Key) -> Option { - let mut guard = self.state.lock().unwrap(); - guard.get_cloned(key) - } - - /// Stores a value for `key`, replacing any existing value with the same key, regardless of its concrete type. - pub fn store(&self, key: &Key, value: V) { - self.state.lock().unwrap().store(key, Box::new(value)); - } -} - -impl> Default for Cache { - fn default() -> Self { - Self { - state: Default::default(), - nonce: core_types::uuid::generate_uuid(), +impl CacheHandle { + pub fn get(&self) -> Result { + let mut guard = self.slot.lock().unwrap(); + if guard.is_none() { + *guard = Some(Box::new(T::default())); } - } -} -impl> Clone for Cache { - fn clone(&self) -> Self { - Self { - state: self.state.clone(), - nonce: self.nonce, + match guard.as_ref().unwrap().downcast_ref::() { + Some(cache) => Ok(cache.clone()), + None => Err(CacheTypeError), } } } -impl> PartialEq for Cache { +impl PartialEq for CacheHandle { fn eq(&self, _: &Self) -> bool { true } } -impl> std::fmt::Debug for Cache { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Cache").field("entries", &self.state.lock().unwrap().entries.len()).finish() +impl Default for CacheHandle { + fn default() -> Self { + Self { + slot: Default::default(), + nonce: core_types::uuid::generate_uuid(), + } } } -impl> core_types::CacheHash for Cache { +impl core_types::CacheHash for CacheHandle { fn cache_hash(&self, state: &mut H) { state.write_u64(self.nonce); } } -unsafe impl + 'static> dyn_any::StaticType for Cache { - type Static = Cache; +unsafe impl dyn_any::StaticType for CacheHandle { + type Static = CacheHandle; } #[cfg(feature = "serde")] -impl> serde::Serialize for Cache { +impl serde::Serialize for CacheHandle { fn serialize(&self, serializer: S) -> Result { serializer.serialize_unit() } } #[cfg(feature = "serde")] -impl<'de, Key, Policy: CachePolicy> serde::Deserialize<'de> for Cache { +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.slot.lock().unwrap().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 { + /// Removes and returns the value stored for `key`. + pub fn take(&self, key: &K) -> Option { + let mut guard = self.state.lock().unwrap(); + 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.state.lock().unwrap(); + 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.state.lock().unwrap().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.state.lock().unwrap().entries.len()).finish() + } +} + // =================== // CachePolicy & Entry // =================== /// Defines the policy state and lifecycle hooks used to manage cached entries. -pub trait CachePolicy: Sized { +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 accessing `key`. - fn touch(key: &Key, entries: &mut Vec>, policy_state: &mut Self::PolicyState); + /// 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); + 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: Policy::EntryState, - key: Key, - value: BoxedValue, +pub struct Entry> { + entry_state: P::EntryState, + key: K, + value: V, } // ================================= @@ -124,15 +164,15 @@ pub struct Entry> { /// Retains recently used key groups and evicts entries that exceed the configured age or group limit. pub struct GenerationalEviction; -impl CachePolicy for GenerationalEviction { +impl CachePolicy for GenerationalEviction { type PolicyState = u64; type EntryState = u64; - fn touch(key: &Key, entries: &mut Vec>, _policy_state: &mut Self::PolicyState) { + 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) { + fn retire(entries: &mut Vec>, policy_state: &mut Self::PolicyState) { if *policy_state == u64::MAX { entries.clear(); *policy_state = 0; @@ -176,18 +216,18 @@ impl CacheKeyGroup for Footprint { // CachePolicy: LRU (Least Recently Used) // ====================================== -/// Retains up to `CAPACITY` entries and evicts the least recently used entry when full. +/// Retains up to `CAPACITY` entries and evicts the least recently used entry when capacity is exceeded. pub struct Lru; -impl CachePolicy for 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: &Key, _entries: &mut Vec>, _policy_state: &mut Self::PolicyState) {} + fn touch(_key: &K, _entries: &mut Vec>, _policy_state: &mut Self::PolicyState) {} - fn retire(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; @@ -217,14 +257,12 @@ impl CachePolicy for Lru { // CacheState // ========== -type BoxedValue = Box; - -struct CacheState> { - policy_state: Policy::PolicyState, - entries: Vec>, +struct CacheState> { + policy_state: P::PolicyState, + entries: Vec>, } -impl> Default for CacheState { +impl> Default for CacheState { fn default() -> Self { Self { policy_state: Default::default(), @@ -233,54 +271,51 @@ impl> Default for CacheState { } } -impl> CacheState { - fn take(&mut self, key: &Key) -> Option { - Policy::touch(key, &mut self.entries, &mut self.policy_state); +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 entry = self.entries.get(index).unwrap(); - ::downcast_ref::(entry.value.as_ref())?; - let mut entry = self.entries.remove(index); - entry.value.downcast().ok().map(|value| { - Policy::hit(&mut entry.entry_state, &mut self.policy_state); - *value - }) + P::hit(&mut entry.entry_state, &mut self.policy_state); + entry.value }); - Policy::retire(&mut self.entries, &mut self.policy_state); - hit.flatten() + P::retire(&mut self.entries, &mut self.policy_state); + hit } - fn get_cloned(&mut self, key: &Key) -> Option { - Policy::touch(key, &mut self.entries, &mut self.policy_state); + 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(); - let value = ::downcast_ref::(entry.value.as_ref())?; - Policy::hit(&mut entry.entry_state, &mut self.policy_state); - Some(value.clone()) + P::hit(&mut entry.entry_state, &mut self.policy_state); + Some(entry.value.clone()) }); - Policy::retire(&mut self.entries, &mut self.policy_state); + P::retire(&mut self.entries, &mut self.policy_state); hit.flatten() } - fn store(&mut self, key: &Key, value: BoxedValue) { - Policy::touch(key, &mut self.entries, &mut self.policy_state); + 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: Policy::EntryState::default(), + entry_state: P::EntryState::default(), }; - Policy::store(&mut entry.entry_state, &mut self.policy_state); + P::store(&mut entry.entry_state, &mut self.policy_state); self.entries.push(entry); - Policy::retire(&mut self.entries, &mut self.policy_state); + P::retire(&mut self.entries, &mut self.policy_state); } } @@ -296,43 +331,71 @@ mod tests { struct CallCounts { touch: usize, retire: usize, - on_hit: usize, - on_store: usize, + hit: usize, + store: usize, } struct TestPolicy; - impl CachePolicy for TestPolicy { + impl CachePolicy for TestPolicy { type PolicyState = CallCounts; type EntryState = (); - fn touch(_key: &Key, _entries: &mut Vec>, counts: &mut Self::PolicyState) { + fn touch(_key: &K, _entries: &mut Vec>, counts: &mut Self::PolicyState) { counts.touch += 1; } - fn retire(_entries: &mut Vec>, counts: &mut Self::PolicyState) { + fn retire(_entries: &mut Vec>, counts: &mut Self::PolicyState) { counts.retire += 1; } fn hit(_entry_state: &mut Self::EntryState, counts: &mut Self::PolicyState) { - counts.on_hit += 1; + counts.hit += 1; } fn store(_entry_state: &mut Self::EntryState, counts: &mut Self::PolicyState) { - counts.on_store += 1; + counts.store += 1; } } - fn live>(cache: &Cache) -> usize { + 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 cache = Cache::::default(); let key = DummyKey(0); let val = DummyValue(0); cache.store(&key, val); - let taken_val = cache.take::(&key); + let taken_val = cache.take(&key); assert_eq!(taken_val, Some(DummyValue(0))); assert_eq!(live(&cache), 0); @@ -340,37 +403,17 @@ mod tests { 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.on_store, 1); - assert_eq!(state.policy_state.on_hit, 1); - } - - #[test] - fn take_type_mismatch_preserves_entry() { - let cache = Cache::::default(); - let key = DummyKey(0); - cache.store(&key, DummyValue(0)); - let mismatched_val = cache.take::<()>(&key); - - assert_eq!(live(&cache), 1); - assert!(mismatched_val.is_none()); - - let correct_val = cache.take::(&key); - assert_eq!(correct_val, Some(DummyValue(0))); - - let state = cache.state.lock().unwrap(); - assert_eq!(state.policy_state.touch, 3); - assert_eq!(state.policy_state.retire, 3); - assert_eq!(state.policy_state.on_store, 1); - assert_eq!(state.policy_state.on_hit, 1); + 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 cache = Cache::::default(); let key = DummyKey(0); let val = DummyValue(0); cache.store(&key, val); - let cloned_val = cache.get_cloned::(&key); + let cloned_val = cache.get_cloned(&key); assert_eq!(cloned_val, Some(DummyValue(0))); assert_eq!(live(&cache), 1); @@ -378,28 +421,8 @@ mod tests { 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.on_store, 1); - assert_eq!(state.policy_state.on_hit, 1); - } - - #[test] - fn get_cloned_type_mismatch_preserves_entry() { - let cache = Cache::::default(); - let key = DummyKey(0); - cache.store(&key, DummyValue(0)); - let mismatched_val = cache.get_cloned::<()>(&key); - - assert_eq!(live(&cache), 1); - assert!(mismatched_val.is_none()); - - let correct_val = cache.get_cloned::(&key); - assert_eq!(correct_val, Some(DummyValue(0))); - - let state = cache.state.lock().unwrap(); - assert_eq!(state.policy_state.touch, 3); - assert_eq!(state.policy_state.retire, 3); - assert_eq!(state.policy_state.on_store, 1); - assert_eq!(state.policy_state.on_hit, 1); + assert_eq!(state.policy_state.store, 1); + assert_eq!(state.policy_state.hit, 1); } mod footprint_generational_eviction { @@ -425,8 +448,8 @@ mod tests { } } - fn render(cache: &Cache>, footprint: &Footprint) -> bool { - let hit = cache.take::(footprint).is_some(); + fn render(cache: &Cache>, footprint: &Footprint) -> bool { + let hit = cache.take(footprint).is_some(); cache.store(footprint, DummyValue(0)); hit } @@ -510,11 +533,11 @@ mod tests { #[test] fn evicts_least_recently_used_entry_when_capacity_is_exceeded() { - let cache = Cache::>::default(); + 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); + let _ = cache.get_cloned(&key0); cache.store(&key2, val2); assert_eq!(live(&cache), 2); @@ -524,11 +547,11 @@ mod tests { #[test] fn get_cloned_refreshes_entry_recency() { - let cache = Cache::>::default(); + 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 _ = 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); @@ -536,20 +559,20 @@ mod tests { #[test] fn storing_existing_key_replaces_value_without_growing_cache() { - let cache = Cache::>::default(); + 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(); + let val = cache.get_cloned(&key0).unwrap(); assert_eq!(val, DummyValue(1)); } #[test] fn entry_count_never_exceeds_capacity() { - let cache = Cache::>::default(); + let cache = Cache::>::default(); for n in 0..10 { cache.store(&DummyKey(n), DummyValue(n)); assert_eq!(live(&cache), n + 1); 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 4e280958b17..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,23 +7,25 @@ mod region; mod render; mod stroke; +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}; use wgpu_executor::{WgpuExecutor, WgpuPipelineCache}; -use crate::BrushCache; - #[node_macro::node(category("Raster: Brush"))] 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 27de7c9ac62..894e4283f64 100644 --- a/node-graph/nodes/brush/src/basic_brush/pipeline.rs +++ b/node-graph/nodes/brush/src/basic_brush/pipeline.rs @@ -1,3 +1,4 @@ +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}; @@ -10,8 +11,6 @@ use glam::{DAffine2, UVec2}; use raster_types::Texture; use wgpu_executor::{AsyncWgpuPipeline, Buffer, WgpuExecutor}; -use crate::BrushCache; - pub(super) const DENSITY_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::R16Float; pub(super) const COMPOSITE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float; From 345f159350d5a5925acae09973646781dab30f2c Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Thu, 24 Sep 2026 21:35:41 +0900 Subject: [PATCH 8/9] Make the TaggedValue document clickable --- node-graph/graph-craft/src/document/value.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index 7e294c994c5..2041944ca6a 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -98,7 +98,7 @@ macro_rules! tagged_value { #[serde(alias = "Gradient", alias = "GradientTable", alias = "GradientPositions", alias = "GradientStops")] GradientRamp(GradientRamp), Strokes(Vec), - /// Type-erased handle to a lazily initialized `graphene_cache::Cache`. + /// Type-erased handle to a lazily initialized [`graphene_cache::Cache`]. #[serde(alias = "BrushCache")] CacheHandle(CacheHandle), // ======================= From b08392113b50271dabd5ee5574881993af7bcf70 Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Thu, 24 Sep 2026 16:45:52 +0900 Subject: [PATCH 9/9] Add recovery for poisoned states --- .../libraries/graphene-cache/src/lib.rs | 40 +++++++++++++++---- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/node-graph/libraries/graphene-cache/src/lib.rs b/node-graph/libraries/graphene-cache/src/lib.rs index 26c3c426f1a..45f01eac87b 100644 --- a/node-graph/libraries/graphene-cache/src/lib.rs +++ b/node-graph/libraries/graphene-cache/src/lib.rs @@ -2,7 +2,7 @@ use core_types::transform::Footprint; use glam::DMat2; use std::{ any::Any, - sync::{Arc, Mutex}, + sync::{Arc, Mutex, MutexGuard}, }; // =========== @@ -16,8 +16,20 @@ pub struct CacheHandle { } 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.slot.lock().unwrap(); + let mut guard = self.get_clean_guard(); if guard.is_none() { *guard = Some(Box::new(T::default())); } @@ -72,7 +84,7 @@ impl<'de> serde::Deserialize<'de> for CacheHandle { impl std::fmt::Debug for CacheHandle { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("CacheHandle") - .field("initialized", &self.slot.lock().unwrap().is_some()) + .field("initialized", &self.get_clean_guard().is_some()) .field("nonce", &self.nonce) .finish() } @@ -90,10 +102,24 @@ 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.state.lock().unwrap(); + let mut guard = self.get_clean_guard(); guard.take(key) } @@ -102,13 +128,13 @@ impl> Cache { where V: Clone, { - let mut guard = self.state.lock().unwrap(); + 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.state.lock().unwrap().store(key, value); + self.get_clean_guard().store(key, value); } } @@ -126,7 +152,7 @@ impl> Clone for Cache { impl> std::fmt::Debug for Cache { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Cache").field("entries", &self.state.lock().unwrap().entries.len()).finish() + f.debug_struct("Cache").field("entries", &self.get_clean_guard().entries.len()).finish() } }