From 3ff328e961b5785ac1f9d818aab1a4fd4e1dfbba Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Thu, 10 Sep 2026 15:09:18 +0900 Subject: [PATCH 1/3] 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 e36ea02788d..3031e39455c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -375,6 +375,7 @@ dependencies = [ "core-types", "dyn-any", "glam", + "graphene-cache", "graphene-hash", "graphic-types", "half", @@ -2052,6 +2053,7 @@ dependencies = [ "glam", "graph-craft", "graphene-application-io", + "graphene-cache", "graphene-core", "graphene-hash", "graphic-types", @@ -2096,6 +2098,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.1.0" diff --git a/Cargo.toml b/Cargo.toml index 194897cbdbc..f3220fd4e69 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -105,6 +105,7 @@ wgpu-sync = { path = "libraries/wgpu-sync" } graphite-proc-macros = { path = "proc-macros" } graphite-editor = { path = "editor" } graphene-canvas-utils = { path = "node-graph/libraries/canvas-utils" } +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 688a0ea712e..b384b268cd9 100644 --- a/node-graph/graph-craft/Cargo.toml +++ b/node-graph/graph-craft/Cargo.toml @@ -25,6 +25,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 198cc9affa7..34f34f7d9a6 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 dcc767238e4..977eeb7e511 100644 --- a/node-graph/nodes/brush/Cargo.toml +++ b/node-graph/nodes/brush/Cargo.toml @@ -14,6 +14,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 3f6f2a93075d99132f356746e88917ba9ddde2c3 Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Sun, 13 Sep 2026 14:30:01 +0900 Subject: [PATCH 2/3] 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 3031e39455c..5923f340189 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -375,7 +375,6 @@ dependencies = [ "core-types", "dyn-any", "glam", - "graphene-cache", "graphene-hash", "graphic-types", "half", @@ -394,6 +393,7 @@ dependencies = [ "core-types", "dyn-any", "glam", + "graphene-cache", "graphene-hash", "serde", ] @@ -2053,7 +2053,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 b384b268cd9..688a0ea712e 100644 --- a/node-graph/graph-craft/Cargo.toml +++ b/node-graph/graph-craft/Cargo.toml @@ -25,7 +25,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 34f34f7d9a6..198cc9affa7 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 82ec176975a..fdbd23cbd3e 100644 --- a/node-graph/libraries/brush-types/Cargo.toml +++ b/node-graph/libraries/brush-types/Cargo.toml @@ -13,6 +13,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 977eeb7e511..dcc767238e4 100644 --- a/node-graph/nodes/brush/Cargo.toml +++ b/node-graph/nodes/brush/Cargo.toml @@ -14,7 +14,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 9b850c5fbaf27faf1c64e5ef869e33c1597e65f0 Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Mon, 14 Sep 2026 10:46:08 +0900 Subject: [PATCH 3/3] 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"