diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index 66e05c0e5e..66c7e0fa67 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -2,8 +2,8 @@ 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::brush_stroke::{BrushStroke, BrushTrace}; +use brush_nodes::{BrushCache, Stroke}; use core_types::color::SRGBA8; use core_types::list::{Item, List, NodeIdPath}; use core_types::transform::Footprint; @@ -99,6 +99,7 @@ macro_rules! tagged_value { #[serde(alias = "BrushStrokeTable")] BrushStrokes(Vec), Strokes(Vec), + BrushCache(BrushCache), // ======================= // AUTO-GENERATED VARIANTS // ======================= @@ -143,6 +144,7 @@ macro_rules! tagged_value { Self::GradientRamp(ramp) => ramp.cache_hash(state), Self::BrushStrokes(strokes) => strokes.cache_hash(state), Self::Strokes(strokes) => strokes.cache_hash(state), + Self::BrushCache(cache) => cache.cache_hash(state), // ======================= // NON-SERIALIZED VARIANTS // ======================= @@ -210,6 +212,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)), // ======================= // AUTO-GENERATED VARIANTS // ======================= @@ -277,6 +280,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)), // ======================= // AUTO-GENERATED VARIANTS // ======================= @@ -300,13 +304,14 @@ macro_rules! tagged_value { // MANUAL VARIANTS // =============== Self::None => concrete!(()), - Self::TypeDefault(td) => td.clone(), + Self::TypeDefault(td) => td.clone(), Self::F64Array(_) => list!(f64), Self::DashPattern(_) => item!(DashPattern), Self::BoxCorners(_) => item!(BoxCorners), Self::GradientRamp(_) => item!(Gradient), Self::BrushStrokes(_) => item!(BrushTrace), Self::Strokes(_) => list!(Stroke), + Self::BrushCache(_) => item!(BrushCache), // ======================= // AUTO-GENERATED VARIANTS // ======================= @@ -348,6 +353,7 @@ macro_rules! tagged_value { x if x == TypeId::of::>() => Ok(TaggedValue::BrushStrokes(*downcast(input).unwrap())), x if x == TypeId::of::>() => Ok(TaggedValue::BrushStrokes(downcast::>(input).unwrap().into_element().0.iter_element_values().cloned().collect())), 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())), // ======================= // AUTO-GENERATED VARIANTS // ======================= @@ -383,6 +389,7 @@ macro_rules! tagged_value { x if x == TypeId::of::>() => Ok(TaggedValue::BrushStrokes(input.downcast_ref::>().unwrap().clone())), x if x == TypeId::of::>() => Ok(TaggedValue::BrushStrokes(input.downcast_ref::>().unwrap().element().0.iter_element_values().cloned().collect())), 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())), // ======================= // AUTO-GENERATED VARIANTS // ======================= @@ -412,6 +419,7 @@ macro_rules! tagged_value { $( if name == std::any::type_name::<$ty>() { return Some(TaggedValue::$identifier(Default::default())) } )* if name == std::any::type_name::() { return Some(TaggedValue::BrushStrokes(Vec::new())) } if name == std::any::type_name::>() { return Some(TaggedValue::Strokes(Vec::new())) } + 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) => { @@ -469,6 +477,7 @@ macro_rules! tagged_value { Self::GradientRamp(ramp) => format!("GradientRamp({ramp:?})"), Self::BrushStrokes(strokes) => format!("BrushStrokes({strokes:?})"), Self::Strokes(strokes) => format!("Strokes({strokes:?})"), + Self::BrushCache(cache) => format!("{cache:?}"), // ======================= // AUTO-GENERATED VARIANTS // ======================= @@ -739,7 +748,6 @@ impl TaggedValue { /// /// Routes legacy variant names into modern variants, in typed Rust. Each legacy name is also matched against the historical `#[serde(alias = "...")]` spellings the deleted variant accepted, so old-shape inner payloads are caught: /// -/// - `BrushCache` → `TaggedValue::None` (purely runtime cache; no payload to preserve) /// - `Graphic` (or alias `GraphicGroup`/`Group`) → `TaggedValue::TypeDefault(list!(Graphic))` /// - `Artboard` (or alias `ArtboardGroup`) → `TaggedValue::TypeDefault(list!(Artboard))` /// - `Raster` (or alias `ImageFrame`/`RasterData`/`Image`): @@ -764,7 +772,6 @@ pub fn deserialize_tagged_value_with_legacy_migration<'de, D: serde::Deserialize && let Some((tag, content)) = map.iter().next() { match tag.as_str() { - "BrushCache" => return Ok(MemoHash::new(TaggedValue::None)), "Graphic" | "GraphicGroup" | "Group" => return Ok(MemoHash::new(TaggedValue::TypeDefault(list!(Graphic)))), "Artboard" | "ArtboardGroup" => return Ok(MemoHash::new(TaggedValue::TypeDefault(list!(Artboard)))), "Raster" | "ImageFrame" | "RasterData" | "Image" => { diff --git a/node-graph/graph-craft/src/proto.rs b/node-graph/graph-craft/src/proto.rs index db58d1b779..ecfe71fea5 100644 --- a/node-graph/graph-craft/src/proto.rs +++ b/node-graph/graph-craft/src/proto.rs @@ -1059,7 +1059,7 @@ mod test { // If this assert fails: These NodeIds seem to be changing when you modify TaggedValue, just update them. assert_eq!( ids, - vec![NodeId(12331852515109999872), NodeId(5084548161767585362), NodeId(14635346976242256925), NodeId(16015195863711239715)] + vec![NodeId(9617677014563055585), NodeId(3306304180790283913), NodeId(4482673701109291121), NodeId(1535890178157254933)] ); } diff --git a/node-graph/libraries/brush-types/src/cache.rs b/node-graph/libraries/brush-types/src/cache.rs new file mode 100644 index 0000000000..8054804aec --- /dev/null +++ b/node-graph/libraries/brush-types/src/cache.rs @@ -0,0 +1,250 @@ +//! 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 023fb29176..394689a329 100644 --- a/node-graph/libraries/brush-types/src/lib.rs +++ b/node-graph/libraries/brush-types/src/lib.rs @@ -1,3 +1,6 @@ +pub mod cache; +pub use cache::BrushCache; + use core_types::CacheHash; use core_types::bounds::{BoundingBox, RenderBoundingBox}; use core_types::render_complexity::RenderComplexity;