From 7d1af880007f94bd1ea31564cb3685d183ca0f02 Mon Sep 17 00:00:00 2001 From: Timon Date: Fri, 24 Jul 2026 17:25:45 +0000 Subject: [PATCH] Add the per-brush texture cache value type --- node-graph/graph-craft/src/document/value.rs | 12 +- node-graph/libraries/brush-types/src/cache.rs | 250 ++++++++++++++++++ node-graph/libraries/brush-types/src/lib.rs | 3 + 3 files changed, 262 insertions(+), 3 deletions(-) create mode 100644 node-graph/libraries/brush-types/src/cache.rs diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index a34d28d062..c7c7c8c413 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; +use brush_nodes::{BrushCache, Stroke}; use core_types::color::SRGBA8; use core_types::list::List; use core_types::transform::Footprint; @@ -75,6 +75,7 @@ macro_rules! tagged_value { #[serde(alias = "BrushStrokeTable")] BrushStrokes(Vec), Strokes(Vec), + BrushCache(BrushCache), // ======================= // AUTO-GENERATED VARIANTS // ======================= @@ -118,6 +119,7 @@ macro_rules! tagged_value { Self::Gradient(stops) => stops.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 // ======================= @@ -169,6 +171,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(cache), // ======================= // AUTO-GENERATED VARIANTS // ======================= @@ -223,6 +226,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(cache), // ======================= // AUTO-GENERATED VARIANTS // ======================= @@ -255,6 +259,7 @@ macro_rules! tagged_value { Self::Gradient(_) => concrete!(List), Self::BrushStrokes(_) => concrete!(List), Self::Strokes(_) => concrete!(List), + Self::BrushCache(_) => concrete!(BrushCache), // ======================= // AUTO-GENERATED VARIANTS // ======================= @@ -333,6 +338,8 @@ macro_rules! tagged_value { if name == std::any::type_name::>() { return Some(TaggedValue::F64Array(Vec::new())) } 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())) } + // Types whose `TaggedValue` variant has been removed. They route through `TypeDefault` instead, with `to_dynany`/`to_any` constructing the actual default at execution time. macro_rules! check { ($type_default:ty) => { @@ -363,6 +370,7 @@ macro_rules! tagged_value { Self::Gradient(stops) => format!("Gradient({stops:?})"), Self::BrushStrokes(strokes) => format!("BrushStrokes({strokes:?})"), Self::Strokes(strokes) => format!("Strokes({strokes:?})"), + Self::BrushCache(cache) => format!("{cache:?}"), // ======================= // AUTO-GENERATED VARIANTS // ======================= @@ -625,7 +633,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(descriptor!(List))` /// - `Artboard` (or alias `ArtboardGroup`) → `TaggedValue::TypeDefault(descriptor!(List))` /// - `Raster` (or alias `ImageFrame`/`RasterData`/`Image`): @@ -647,7 +654,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(descriptor!(List)))), "Artboard" | "ArtboardGroup" => return Ok(MemoHash::new(TaggedValue::TypeDefault(descriptor!(List)))), "Raster" | "ImageFrame" | "RasterData" | "Image" => { 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;