From e75d9e882cec194ed9a4d7048fef31f94369289a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=82=E3=81=99=E3=81=B1=E3=82=8B?= Date: Mon, 25 May 2026 19:45:40 +0900 Subject: [PATCH 1/2] feat: add LRU-backed render context with cache metrics --- Cargo.lock | 213 +++++++++ tellur-core/src/color.rs | 13 + tellur-core/src/dyn_compare.rs | 102 ++++ tellur-core/src/geometry.rs | 45 +- tellur-core/src/layer.rs | 12 +- tellur-core/src/layout.rs | 270 ++++++++++- tellur-core/src/lib.rs | 2 + tellur-core/src/placement.rs | 15 + tellur-core/src/raster.rs | 32 +- tellur-core/src/render_context.rs | 50 ++ tellur-core/src/shapes.rs | 17 +- tellur-core/src/time.rs | 12 + tellur-core/src/timeline.rs | 29 +- tellur-core/src/vector.rs | 50 +- tellur-macros/src/lib.rs | 40 +- tellur-renderer/Cargo.toml | 2 + .../examples/raster_layer_to_png.rs | 3 +- tellur-renderer/examples/scene_to_png.rs | 3 +- tellur-renderer/examples/timeline_to_mp4.rs | 4 +- tellur-renderer/src/lib.rs | 2 + tellur-renderer/src/rasterize.rs | 8 +- tellur-renderer/src/render_context.rs | 441 ++++++++++++++++++ tellur-renderer/src/shadow.rs | 36 +- tellur-renderer/src/video.rs | 53 ++- 24 files changed, 1395 insertions(+), 59 deletions(-) create mode 100644 tellur-core/src/dyn_compare.rs create mode 100644 tellur-core/src/render_context.rs create mode 100644 tellur-renderer/src/render_context.rs diff --git a/Cargo.lock b/Cargo.lock index 47c1ea5..bfc90d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,12 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "arrayref" version = "0.3.9" @@ -62,6 +68,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "crc32fast" version = "1.5.0" @@ -77,6 +89,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "fdeflate" version = "0.3.7" @@ -96,6 +114,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "futures-core" version = "0.3.32" @@ -120,6 +144,17 @@ dependencies = [ "slab", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "indicatif" version = "0.18.4" @@ -157,6 +192,21 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -167,6 +217,15 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -251,6 +310,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sysinfo" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c33cd241af0f2e9e3b5c32163b873b29956890b5342e6745b917ce9d490f4af" +dependencies = [ + "core-foundation-sys", + "libc", + "memchr", + "ntapi", + "windows", +] + [[package]] name = "tellur-core" version = "0.1.0" @@ -277,6 +349,8 @@ dependencies = [ "bytes", "console", "indicatif", + "lru", + "sysinfo", "tellur-core", "thiserror", "tiny-skia", @@ -401,12 +475,87 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" +dependencies = [ + "windows-core", + "windows-targets", +] + +[[package]] +name = "windows-core" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-result", + "windows-targets", +] + +[[package]] +name = "windows-implement" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -415,3 +564,67 @@ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ "windows-link", ] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" diff --git a/tellur-core/src/color.rs b/tellur-core/src/color.rs index d0d273a..824b3d2 100644 --- a/tellur-core/src/color.rs +++ b/tellur-core/src/color.rs @@ -1,3 +1,7 @@ +use std::hash::{Hash, Hasher}; + +use crate::dyn_compare::hash_f32; + /// sRGB with straight alpha. Each component is in the range `[0.0, 1.0]`. #[derive(Debug, Clone, Copy, PartialEq)] pub struct Color { @@ -7,6 +11,15 @@ pub struct Color { pub a: f32, } +impl Hash for Color { + fn hash(&self, state: &mut H) { + hash_f32(self.r, state); + hash_f32(self.g, state); + hash_f32(self.b, state); + hash_f32(self.a, state); + } +} + impl Color { /// Opaque color from 8-bit sRGB components. pub const fn rgb_u8(r: u8, g: u8, b: u8) -> Self { diff --git a/tellur-core/src/dyn_compare.rs b/tellur-core/src/dyn_compare.rs new file mode 100644 index 0000000..a24be24 --- /dev/null +++ b/tellur-core/src/dyn_compare.rs @@ -0,0 +1,102 @@ +//! Trait-object equality and hashing for component trees. +//! +//! [`RasterComponent`](crate::raster::RasterComponent) and +//! [`VectorComponent`](crate::vector::VectorComponent) need to participate in +//! `PartialEq` and `Hash` so that render results can be memoized in a +//! `RenderContext` cache keyed by component identity. The trait-object form +//! `dyn Component` cannot derive these directly — `PartialEq::eq` is not +//! dyn-safe and `Hash::hash` is parameterised over `H: Hasher`. +//! +//! [`DynEq`] and [`DynHash`] are the standard workaround: they expose the +//! type-erased shape of `==` and `hash` via [`Any`] downcast and a +//! `&mut dyn Hasher` argument, and have blanket impls for any `T: PartialEq + +//! Hash + 'static`. A component trait adds them as super-traits, and a +//! manual `impl PartialEq for dyn Component` / `impl Hash for dyn Component` +//! delegates through them. With that wired up, `Box`, +//! `Vec>`, etc. pick up `PartialEq + Hash` automatically +//! through the standard library blanket impls, so `#[derive]` on containers +//! that hold trait objects just works. +//! +//! [`hash_f32`] / [`hash_f32_slice`] are the canonical hashers for `f32` +//! fields and slices in cache-key types. They use `to_bits()` so `-0.0`, +//! `+0.0`, and `NaN` keep distinct hashes (matching the way `PartialEq` on +//! `f32` already treats them distinctly when sourced from bit patterns). + +use std::any::Any; +use std::hash::{Hash, Hasher}; + +/// Object-safe equality. Implemented for all `T: PartialEq + 'static`. +/// +/// Used as a super-trait on `RasterComponent` / `VectorComponent` so a +/// `dyn Component` can compare itself with another `dyn Component` by +/// downcasting to its concrete type. Two trait objects of different +/// concrete types compare as not-equal. +/// +/// The `dyn_eq` argument is `&dyn Any` (not `&dyn DynEq`) so callers can +/// hand it `other.as_any()` directly — this sidesteps trait-object +/// upcasting between component traits and `DynEq`. +pub trait DynEq: Any { + fn as_any(&self) -> &dyn Any; + fn dyn_eq(&self, other: &dyn Any) -> bool; + /// Concrete type name behind this trait object, suitable for + /// diagnostics (e.g. `tellur_renderer::shadow::DropShadow`). Backed + /// by [`std::any::type_name`] specialized to the impl's `Self`, so + /// callers can introspect a `&dyn Component` without a downcast. + fn type_name(&self) -> &'static str; +} + +impl DynEq for T { + fn as_any(&self) -> &dyn Any { + self + } + + fn dyn_eq(&self, other: &dyn Any) -> bool { + match other.downcast_ref::() { + Some(o) => self == o, + None => false, + } + } + + fn type_name(&self) -> &'static str { + std::any::type_name::() + } +} + +/// Object-safe hashing. Implemented for all `T: Hash + ?Sized`. +/// +/// `Hash::hash` is generic over the hasher, so `dyn Component` cannot +/// implement it directly. `DynHash` erases the hasher type behind +/// `&mut dyn Hasher`, which works because the standard library has +/// `impl Hasher for &mut H` — i.e. `&mut dyn Hasher` +/// is itself a `Hasher`, so calling `Hash::hash(&self, &mut state)` from +/// inside the impl is legal. +pub trait DynHash { + fn dyn_hash(&self, state: &mut dyn Hasher); +} + +impl DynHash for T { + fn dyn_hash(&self, mut state: &mut dyn Hasher) { + self.hash(&mut state); + } +} + +/// Hashes an `f32` by its bit pattern. +/// +/// `f32` does not implement `Hash` because its `PartialEq` is not +/// reflexive (`NaN != NaN`), so the standard library refuses to derive a +/// hash that would violate `a == b => hash(a) == hash(b)`. For cache-key +/// use we want a total hash and treat equal bit patterns as equal — this +/// helper makes that intent explicit and consistent across types. +#[inline] +pub fn hash_f32(v: f32, state: &mut H) { + v.to_bits().hash(state); +} + +/// Hashes an `&[f32]` by hashing each element's bit pattern in order. +#[inline] +pub fn hash_f32_slice(vs: &[f32], state: &mut H) { + vs.len().hash(state); + for v in vs { + hash_f32(*v, state); + } +} diff --git a/tellur-core/src/geometry.rs b/tellur-core/src/geometry.rs index 208e3e7..2987180 100644 --- a/tellur-core/src/geometry.rs +++ b/tellur-core/src/geometry.rs @@ -3,11 +3,21 @@ //! The project uses a coordinate system with **origin at the top-left and Y axis //! pointing down**. +use std::hash::{Hash, Hasher}; use std::ops::{Add, Sub}; +use crate::dyn_compare::hash_f32; + #[derive(Debug, Clone, Copy, PartialEq)] pub struct Vec2(pub f32, pub f32); +impl Hash for Vec2 { + fn hash(&self, state: &mut H) { + hash_f32(self.0, state); + hash_f32(self.1, state); + } +} + impl Vec2 { pub const ZERO: Self = Self(0.0, 0.0); @@ -36,7 +46,7 @@ impl Sub for Vec2 { /// /// `origin` is the top-left corner (the smaller-coordinate side); `origin + size` /// is the bottom-right corner. -#[derive(Debug, Clone, Copy, PartialEq)] +#[derive(Debug, Clone, Copy, PartialEq, Hash)] pub struct Rect { pub origin: Vec2, pub size: Vec2, @@ -59,6 +69,17 @@ pub struct Transform { pub ty: f32, } +impl Hash for Transform { + fn hash(&self, state: &mut H) { + hash_f32(self.a, state); + hash_f32(self.b, state); + hash_f32(self.c, state); + hash_f32(self.d, state); + hash_f32(self.tx, state); + hash_f32(self.ty, state); + } +} + impl Transform { pub const IDENTITY: Self = Self { a: 1.0, @@ -92,6 +113,13 @@ pub struct Anchor { pub ry: f32, } +impl Hash for Anchor { + fn hash(&self, state: &mut H) { + hash_f32(self.rx, state); + hash_f32(self.ry, state); + } +} + impl Anchor { pub const TOP_LEFT: Self = Self::new(0.0, 0.0); pub const TOP_CENTER: Self = Self::new(0.5, 0.0); @@ -115,7 +143,7 @@ impl Anchor { } /// A size paired with an anchor on that size, produced by [`Vec2::anchored`]. -#[derive(Debug, Clone, Copy, PartialEq)] +#[derive(Debug, Clone, Copy, PartialEq, Hash)] pub struct AnchoredSize { pub size: Vec2, pub anchor: Anchor, @@ -144,6 +172,15 @@ pub struct EdgeInsets { pub bottom: f32, } +impl Hash for EdgeInsets { + fn hash(&self, state: &mut H) { + hash_f32(self.left, state); + hash_f32(self.top, state); + hash_f32(self.right, state); + hash_f32(self.bottom, state); + } +} + impl EdgeInsets { pub const ZERO: Self = Self { left: 0.0, @@ -205,7 +242,7 @@ impl EdgeInsets { /// "no upper bound" (the parent does not constrain this axis); `min` is /// usually `0.0` for "no lower bound" and equals `max` for fully tight /// constraints. -#[derive(Debug, Clone, Copy, PartialEq)] +#[derive(Debug, Clone, Copy, PartialEq, Hash)] pub struct Constraints { pub min: Vec2, pub max: Vec2, @@ -287,7 +324,7 @@ impl Constraints { /// layout module for stack-axis selection, but kept in `geometry` so /// helpers like [`Constraints::tighten_cross`] can refer to it without a /// dependency cycle. -#[derive(Debug, Clone, Copy, PartialEq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Axis { Horizontal, Vertical, diff --git a/tellur-core/src/layer.rs b/tellur-core/src/layer.rs index 8d3662c..42605a7 100644 --- a/tellur-core/src/layer.rs +++ b/tellur-core/src/layer.rs @@ -24,8 +24,10 @@ use bytes::Bytes; use crate::geometry::{Constraints, Rect, Transform, Vec2}; use crate::placement::Placed; use crate::raster::{PixelFormat, RasterComponent, RasterImage, Resolution}; +use crate::render_context::RenderContext; use crate::vector::{Group, Node, VectorComponent, VectorGraphic}; +#[derive(PartialEq, Hash)] pub struct VectorLayer { pub size: Vec2, pub children: Vec>, @@ -79,6 +81,7 @@ impl VectorComponent for VectorLayer { } } +#[derive(PartialEq, Hash)] pub struct Layer { pub size: Vec2, pub children: Vec>, @@ -117,7 +120,7 @@ impl RasterComponent for Layer { bounds } - fn render(&self, size: Vec2, target: Resolution) -> RasterImage { + fn render(&self, size: Vec2, target: Resolution, ctx: &mut dyn RenderContext) -> RasterImage { let paint_rect = self.paint_bounds(size); let child_constraints = Constraints::loose(size); let placed: Vec<(Vec2, Vec2, &dyn RasterComponent)> = self @@ -128,7 +131,7 @@ impl RasterComponent for Layer { (p.position, child_size, p.child.as_ref()) }) .collect(); - composite_children(paint_rect, target, &placed) + composite_children(paint_rect, target, &placed, ctx) } } @@ -160,6 +163,7 @@ pub(crate) fn composite_children( paint_rect: Rect, target: Resolution, placed: &[(Vec2, Vec2, &dyn RasterComponent)], + ctx: &mut dyn RenderContext, ) -> RasterImage { let pixel_count = (target.width as usize) * (target.height as usize); let mut accum = vec![0u8; pixel_count * 4]; @@ -176,7 +180,9 @@ pub(crate) fn composite_children( let offset_x = (paint_x * scale_x).round() as i32; let offset_y = (paint_y * scale_y).round() as i32; - let image = child.render(*child_size, Resolution::new(child_px_w, child_px_h)); + // Route the child render through the context so cache lookups + // can intercept it before the underlying `render` runs. + let image = ctx.render(*child, *child_size, Resolution::new(child_px_w, child_px_h)); composite_at(&mut accum, target, &image, offset_x, offset_y); } diff --git a/tellur-core/src/layout.rs b/tellur-core/src/layout.rs index e12452b..3a300cf 100644 --- a/tellur-core/src/layout.rs +++ b/tellur-core/src/layout.rs @@ -23,7 +23,10 @@ //! `Box`. Their raster counterparts share the same //! names under [`raster`] and operate on `Box`. +use std::hash::{Hash, Hasher}; + use crate::color::Color; +use crate::dyn_compare::hash_f32; pub use crate::geometry::Axis; use crate::geometry::{Anchor, Constraints, EdgeInsets, Rect, Transform, Vec2}; use crate::vector::{ @@ -34,7 +37,7 @@ use crate::vector::{ /// override `Stack::spacing` and derive the gap from the leftover space /// on the main axis; `Start` / `Center` / `End` keep `Stack::spacing` as /// the inter-child gap. -#[derive(Debug, Clone, Copy, PartialEq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum MainAlign { Start, Center, @@ -47,7 +50,7 @@ pub enum MainAlign { /// Cross-axis alignment of each child inside the stack's cross extent. /// `Stretch` propagates a tight cross-axis constraint to the child so /// it can fill the stack's full cross extent. -#[derive(Debug, Clone, Copy, PartialEq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum CrossAlign { Start, Center, @@ -70,6 +73,15 @@ pub enum SizeMode { Fixed(f32), } +impl Hash for SizeMode { + fn hash(&self, state: &mut H) { + std::mem::discriminant(self).hash(state); + if let SizeMode::Fixed(v) = self { + hash_f32(*v, state); + } + } +} + pub(crate) fn resolve_size_mode Vec2>( width: SizeMode, height: SizeMode, @@ -107,6 +119,19 @@ pub struct Padding { pub child: Box, } +impl PartialEq for Padding { + fn eq(&self, other: &Self) -> bool { + self.insets == other.insets && *self.child == *other.child + } +} + +impl Hash for Padding { + fn hash(&self, state: &mut H) { + self.insets.hash(state); + self.child.hash(state); + } +} + impl Padding { fn inset_size(&self) -> Vec2 { Vec2(self.insets.horizontal(), self.insets.vertical()) @@ -150,6 +175,20 @@ pub struct Sized { pub child: Box, } +impl PartialEq for Sized { + fn eq(&self, other: &Self) -> bool { + self.width == other.width && self.height == other.height && *self.child == *other.child + } +} + +impl Hash for Sized { + fn hash(&self, state: &mut H) { + self.width.hash(state); + self.height.hash(state); + self.child.hash(state); + } +} + impl VectorComponent for Sized { fn layout(&self, constraints: Constraints) -> Vec2 { resolve_size_mode(self.width, self.height, constraints, |c| { @@ -182,6 +221,22 @@ pub struct Place { pub child: Box, } +impl PartialEq for Place { + fn eq(&self, other: &Self) -> bool { + self.child_anchor == other.child_anchor + && self.at == other.at + && *self.child == *other.child + } +} + +impl Hash for Place { + fn hash(&self, state: &mut H) { + self.child_anchor.hash(state); + self.at.hash(state); + self.child.hash(state); + } +} + impl VectorComponent for Place { fn layout(&self, constraints: Constraints) -> Vec2 { let max = Vec2( @@ -222,6 +277,26 @@ pub struct Frame { pub child: Box, } +impl PartialEq for Frame { + fn eq(&self, other: &Self) -> bool { + self.width == other.width + && self.height == other.height + && self.child_anchor == other.child_anchor + && self.at == other.at + && *self.child == *other.child + } +} + +impl Hash for Frame { + fn hash(&self, state: &mut H) { + self.width.hash(state); + self.height.hash(state); + self.child_anchor.hash(state); + self.at.hash(state); + self.child.hash(state); + } +} + impl VectorComponent for Frame { fn layout(&self, constraints: Constraints) -> Vec2 { resolve_size_mode(self.width, self.height, constraints, |c| { @@ -256,6 +331,7 @@ impl VectorComponent for Frame { /// the stack expands to the parent's max constraint on the main axis /// (or collapses to the intrinsic sum if the constraint is unbounded), /// and follows the cross-align rule on the cross axis. +#[derive(PartialEq)] pub struct Stack { pub axis: Axis, pub size: Option, @@ -265,6 +341,17 @@ pub struct Stack { pub children: Vec>, } +impl Hash for Stack { + fn hash(&self, state: &mut H) { + self.axis.hash(state); + self.size.hash(state); + hash_f32(self.spacing, state); + self.main_align.hash(state); + self.cross_align.hash(state); + self.children.hash(state); + } +} + pub(crate) struct StackPass { pub own_size: Vec2, /// `(position, size)` for each child in the input order. @@ -460,6 +547,22 @@ pub struct DecoratedBox { pub border: Option, } +impl PartialEq for DecoratedBox { + fn eq(&self, other: &Self) -> bool { + *self.child == *other.child + && self.background == other.background + && self.border == other.border + } +} + +impl Hash for DecoratedBox { + fn hash(&self, state: &mut H) { + self.child.hash(state); + self.background.hash(state); + self.border.hash(state); + } +} + impl VectorComponent for DecoratedBox { fn layout(&self, constraints: Constraints) -> Vec2 { self.child.layout(constraints) @@ -499,6 +602,7 @@ impl VectorComponent for DecoratedBox { /// An empty box of the given size. Useful as a spacer between stack /// children or to reserve a region without any visible content. +#[derive(PartialEq, Hash)] pub struct SizedBox { pub size: Vec2, } @@ -561,19 +665,35 @@ pub mod raster { use bytes::Bytes; + use std::hash::{Hash, Hasher}; + use super::{ - compute_stack_pass, resolve_size_mode, Axis, Color, CrossAlign, EdgeInsets, MainAlign, - SizeMode, Vec2, + compute_stack_pass, hash_f32, resolve_size_mode, Axis, Color, CrossAlign, EdgeInsets, + MainAlign, SizeMode, Vec2, }; use crate::geometry::{Anchor, Constraints, Rect}; use crate::layer::{composite_children, translate_rect, union_rect}; use crate::raster::{PixelFormat, RasterComponent, RasterImage, Resolution}; + use crate::render_context::RenderContext; pub struct Padding { pub insets: EdgeInsets, pub child: Box, } + impl PartialEq for Padding { + fn eq(&self, other: &Self) -> bool { + self.insets == other.insets && *self.child == *other.child + } + } + + impl Hash for Padding { + fn hash(&self, state: &mut H) { + self.insets.hash(state); + self.child.hash(state); + } + } + impl Padding { fn inset_size(&self) -> Vec2 { Vec2(self.insets.horizontal(), self.insets.vertical()) @@ -600,7 +720,12 @@ pub mod raster { ) } - fn render(&self, size: Vec2, target: Resolution) -> RasterImage { + fn render( + &self, + size: Vec2, + target: Resolution, + ctx: &mut dyn RenderContext, + ) -> RasterImage { let inset = self.inset_size(); let inner_size = Vec2((size.0 - inset.0).max(0.0), (size.1 - inset.1).max(0.0)); let paint_rect = self.paint_bounds(size); @@ -608,6 +733,7 @@ pub mod raster { paint_rect, target, &[(self.insets.top_left(), inner_size, self.child.as_ref())], + ctx, ) } } @@ -620,6 +746,20 @@ pub mod raster { pub child: Box, } + impl PartialEq for Sized { + fn eq(&self, other: &Self) -> bool { + self.width == other.width && self.height == other.height && *self.child == *other.child + } + } + + impl Hash for Sized { + fn hash(&self, state: &mut H) { + self.width.hash(state); + self.height.hash(state); + self.child.hash(state); + } + } + impl RasterComponent for Sized { fn layout(&self, constraints: Constraints) -> Vec2 { resolve_size_mode(self.width, self.height, constraints, |c| { @@ -639,13 +779,19 @@ pub mod raster { ) } - fn render(&self, size: Vec2, target: Resolution) -> RasterImage { + fn render( + &self, + size: Vec2, + target: Resolution, + ctx: &mut dyn RenderContext, + ) -> RasterImage { let child_size = self.child.layout(Constraints::loose(size)); let paint_rect = self.paint_bounds(size); composite_children( paint_rect, target, &[(Vec2::ZERO, child_size, self.child.as_ref())], + ctx, ) } } @@ -658,6 +804,22 @@ pub mod raster { pub child: Box, } + impl PartialEq for Place { + fn eq(&self, other: &Self) -> bool { + self.child_anchor == other.child_anchor + && self.at == other.at + && *self.child == *other.child + } + } + + impl Hash for Place { + fn hash(&self, state: &mut H) { + self.child_anchor.hash(state); + self.at.hash(state); + self.child.hash(state); + } + } + impl RasterComponent for Place { fn layout(&self, constraints: Constraints) -> Vec2 { let max = Vec2( @@ -682,7 +844,12 @@ pub mod raster { ) } - fn render(&self, size: Vec2, target: Resolution) -> RasterImage { + fn render( + &self, + size: Vec2, + target: Resolution, + ctx: &mut dyn RenderContext, + ) -> RasterImage { let child_size = self.child.layout(Constraints::loose(size)); let pos = child_size .anchored(self.child_anchor) @@ -692,6 +859,7 @@ pub mod raster { paint_rect, target, &[(pos, child_size, self.child.as_ref())], + ctx, ) } } @@ -705,6 +873,26 @@ pub mod raster { pub child: Box, } + impl PartialEq for Frame { + fn eq(&self, other: &Self) -> bool { + self.width == other.width + && self.height == other.height + && self.child_anchor == other.child_anchor + && self.at == other.at + && *self.child == *other.child + } + } + + impl Hash for Frame { + fn hash(&self, state: &mut H) { + self.width.hash(state); + self.height.hash(state); + self.child_anchor.hash(state); + self.at.hash(state); + self.child.hash(state); + } + } + impl RasterComponent for Frame { fn layout(&self, constraints: Constraints) -> Vec2 { resolve_size_mode(self.width, self.height, constraints, |c| { @@ -727,7 +915,12 @@ pub mod raster { ) } - fn render(&self, size: Vec2, target: Resolution) -> RasterImage { + fn render( + &self, + size: Vec2, + target: Resolution, + ctx: &mut dyn RenderContext, + ) -> RasterImage { let child_size = self.child.layout(Constraints::loose(size)); let pos = child_size .anchored(self.child_anchor) @@ -737,10 +930,12 @@ pub mod raster { paint_rect, target, &[(pos, child_size, self.child.as_ref())], + ctx, ) } } + #[derive(PartialEq)] pub struct Stack { pub axis: Axis, pub size: Option, @@ -750,6 +945,17 @@ pub mod raster { pub children: Vec>, } + impl Hash for Stack { + fn hash(&self, state: &mut H) { + self.axis.hash(state); + self.size.hash(state); + hash_f32(self.spacing, state); + self.main_align.hash(state); + self.cross_align.hash(state); + self.children.hash(state); + } + } + impl RasterComponent for Stack { fn layout(&self, constraints: Constraints) -> Vec2 { compute_stack_pass( @@ -787,7 +993,12 @@ pub mod raster { bounds } - fn render(&self, size: Vec2, target: Resolution) -> RasterImage { + fn render( + &self, + size: Vec2, + target: Resolution, + ctx: &mut dyn RenderContext, + ) -> RasterImage { let pass = compute_stack_pass( self.axis, self.size, @@ -805,7 +1016,7 @@ pub mod raster { .map(|(child, &(pos, child_size))| (pos, child_size, child.as_ref())) .collect(); let paint_rect = self.paint_bounds(size); - composite_children(paint_rect, target, &placed) + composite_children(paint_rect, target, &placed, ctx) } } @@ -817,6 +1028,19 @@ pub mod raster { pub background: Option, } + impl PartialEq for DecoratedBox { + fn eq(&self, other: &Self) -> bool { + *self.child == *other.child && self.background == other.background + } + } + + impl Hash for DecoratedBox { + fn hash(&self, state: &mut H) { + self.child.hash(state); + self.background.hash(state); + } + } + impl RasterComponent for DecoratedBox { fn layout(&self, constraints: Constraints) -> Vec2 { self.child.layout(constraints) @@ -827,7 +1051,12 @@ pub mod raster { // clip rectangle for children whose paint bounds spill outward // (e.g. drop shadows on outer children). - fn render(&self, size: Vec2, target: Resolution) -> RasterImage { + fn render( + &self, + size: Vec2, + target: Resolution, + ctx: &mut dyn RenderContext, + ) -> RasterImage { let paint_rect = Rect { origin: Vec2::ZERO, size, @@ -839,17 +1068,19 @@ pub mod raster { (Vec2::ZERO, size, &bg as &dyn RasterComponent), (Vec2::ZERO, size, self.child.as_ref()), ]; - composite_children(paint_rect, target, &placed) + composite_children(paint_rect, target, &placed, ctx) } None => composite_children( paint_rect, target, &[(Vec2::ZERO, size, self.child.as_ref())], + ctx, ), } } } + #[derive(PartialEq, Hash)] pub struct SizedBox { pub size: Vec2, } @@ -859,7 +1090,12 @@ pub mod raster { constraints.constrain(self.size) } - fn render(&self, _size: Vec2, target: Resolution) -> RasterImage { + fn render( + &self, + _size: Vec2, + target: Resolution, + _ctx: &mut dyn RenderContext, + ) -> RasterImage { let bytes = (target.width as usize) * (target.height as usize) * 4; RasterImage { width: target.width, @@ -872,6 +1108,7 @@ pub mod raster { /// Internal helper: a solid-color rectangle that fills any layout /// size the parent assigns, rasterized by buffer-filling. + #[derive(PartialEq, Hash)] struct SolidRect { color: Color, } @@ -881,7 +1118,12 @@ pub mod raster { constraints.constrain(constraints.max) } - fn render(&self, _size: Vec2, target: Resolution) -> RasterImage { + fn render( + &self, + _size: Vec2, + target: Resolution, + _ctx: &mut dyn RenderContext, + ) -> RasterImage { let pixels = (target.width as usize) * (target.height as usize); let mut buf = Vec::with_capacity(pixels * 4); let r = (self.color.r * 255.0).round().clamp(0.0, 255.0) as u8; diff --git a/tellur-core/src/lib.rs b/tellur-core/src/lib.rs index b0e07f1..a33a650 100644 --- a/tellur-core/src/lib.rs +++ b/tellur-core/src/lib.rs @@ -1,4 +1,5 @@ pub mod color; +pub mod dyn_compare; pub mod geometry; pub mod interpolate; pub mod layer; @@ -6,6 +7,7 @@ pub mod layout; pub mod phase; pub mod placement; pub mod raster; +pub mod render_context; pub mod shapes; pub mod time; pub mod timeline; diff --git a/tellur-core/src/placement.rs b/tellur-core/src/placement.rs index 8b6ba57..ef97403 100644 --- a/tellur-core/src/placement.rs +++ b/tellur-core/src/placement.rs @@ -21,6 +21,8 @@ //! [`AnchoredSize::snap_to`](crate::geometry::AnchoredSize::snap_to) so the //! geometry vocabulary carries over directly to component placement. +use std::hash::{Hash, Hasher}; + use crate::geometry::{Anchor, Constraints, Vec2}; use crate::raster::RasterComponent; use crate::vector::VectorComponent; @@ -36,6 +38,19 @@ pub struct Placed { pub child: Box, } +impl PartialEq for Placed { + fn eq(&self, other: &Self) -> bool { + self.position == other.position && *self.child == *other.child + } +} + +impl Hash for Placed { + fn hash(&self, state: &mut H) { + self.position.hash(state); + (*self.child).hash(state); + } +} + /// Extension trait that adds placement methods to every [`VectorComponent`]. /// /// Brought into scope alongside `use tellur_core::vector::VectorComponent`, diff --git a/tellur-core/src/raster.rs b/tellur-core/src/raster.rs index f8069b8..cee9d42 100644 --- a/tellur-core/src/raster.rs +++ b/tellur-core/src/raster.rs @@ -1,9 +1,13 @@ +use std::any::Any; +use std::hash::{Hash, Hasher}; use std::io::Write; use bytes::Bytes; use thiserror::Error; +use crate::dyn_compare::{DynEq, DynHash}; use crate::geometry::{Constraints, Rect, Vec2}; +use crate::render_context::RenderContext; #[derive(Debug, Clone)] pub struct RasterImage { @@ -13,7 +17,7 @@ pub struct RasterImage { pub pixels: Bytes, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum PixelFormat { /// 8-bit per channel sRGB with straight (non-premultiplied) alpha. Rgba8, @@ -22,7 +26,7 @@ pub enum PixelFormat { } /// Target output resolution for a `RasterComponent::render` call, in pixels. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct Resolution { pub width: u32, pub height: u32, @@ -47,7 +51,7 @@ impl Resolution { /// 3. Optionally [`paint_bounds`](RasterComponent::paint_bounds) tells /// the parent how far the component paints outside the layout box, /// so `Layer` can grow the sub-resolution accordingly. -pub trait RasterComponent { +pub trait RasterComponent: DynEq + DynHash { /// Decide the layout size given the parent's constraints. fn layout(&self, constraints: Constraints) -> Vec2; @@ -65,7 +69,12 @@ pub trait RasterComponent { /// pixel buffer. The pixel buffer covers exactly the `paint_bounds` /// rectangle for `size` — for default `paint_bounds`, that's the /// `(0, 0)..size` region. - fn render(&self, size: Vec2, target: Resolution) -> RasterImage; + /// + /// The `ctx` argument lets the component delegate child renders back + /// through the driver (typically via `ctx.render(&*child, size, + /// target)`) so cross-cutting policies such as memoization can be + /// applied uniformly across the tree. + fn render(&self, size: Vec2, target: Resolution, ctx: &mut dyn RenderContext) -> RasterImage; /// Type-erases `self` into a heap-allocated trait object. Useful for /// constructing heterogeneous containers like `Layer.children` in @@ -81,6 +90,21 @@ pub trait RasterComponent { // Compile-time guarantee that `RasterComponent` is dyn-safe. const _: Option<&dyn RasterComponent> = None; +impl PartialEq for dyn RasterComponent { + fn eq(&self, other: &Self) -> bool { + DynEq::dyn_eq(self, other.as_any()) + } +} + +impl Hash for dyn RasterComponent { + fn hash(&self, state: &mut H) { + // Include the concrete TypeId so two components with coincidentally + // identical internal hashes but different types remain distinct. + Any::type_id(self.as_any()).hash(state); + DynHash::dyn_hash(self, state); + } +} + #[derive(Debug, Error)] pub enum PngExportError { #[error("PNG export is not supported for pixel format {0:?}")] diff --git a/tellur-core/src/render_context.rs b/tellur-core/src/render_context.rs new file mode 100644 index 0000000..edbab9f --- /dev/null +++ b/tellur-core/src/render_context.rs @@ -0,0 +1,50 @@ +//! Render context for memoizing raster components. +//! +//! The trait defines the interface a renderer driver passes through the +//! `RasterComponent::render` call chain. A component asks the context to +//! render a child rather than calling the child's `render` directly; the +//! context is then free to memoize results, share scratch buffers, or +//! apply other cross-cutting policies. +//! +//! [`PassThrough`] is the trivial implementation — it forwards every +//! request straight to the component without caching. It is enough for +//! tests, single-frame previews, and any caller that doesn't want to +//! pay for cache bookkeeping. The renderer crate provides a caching +//! implementation on top of this trait. + +use crate::geometry::Vec2; +use crate::raster::{RasterComponent, RasterImage, Resolution}; + +/// Drives raster component rendering and provides a hook for caching. +/// +/// Components forward child `render` calls through +/// [`RenderContext::render`] (or call it as a free function via +/// `ctx.render(&*child, size, target)`) so the context can intercept and +/// reuse previously-produced results. +pub trait RenderContext { + /// Renders `component` at the given logical `size` into a + /// `target`-sized pixel buffer, possibly returning a cached result + /// from a previous identical request. + fn render( + &mut self, + component: &dyn RasterComponent, + size: Vec2, + target: Resolution, + ) -> RasterImage; +} + +/// A `RenderContext` that performs no caching. Every call goes straight +/// through to the component's `render` method. Useful for tests and any +/// caller that wants to opt out of memoization. +pub struct PassThrough; + +impl RenderContext for PassThrough { + fn render( + &mut self, + component: &dyn RasterComponent, + size: Vec2, + target: Resolution, + ) -> RasterImage { + component.render(size, target, self) + } +} diff --git a/tellur-core/src/shapes.rs b/tellur-core/src/shapes.rs index ebb612e..2247513 100644 --- a/tellur-core/src/shapes.rs +++ b/tellur-core/src/shapes.rs @@ -6,10 +6,13 @@ //! `Circle` placed under tight non-square constraints renders as an //! ellipse. +use std::hash::{Hash, Hasher}; + +use crate::dyn_compare::hash_f32; use crate::geometry::{Constraints, Rect, Transform, Vec2}; use crate::vector::{Fill, Node, Path, PathCommand, Stroke, VectorComponent, VectorGraphic}; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Hash)] pub struct Rectangle { pub size: Vec2, pub fill: Option, @@ -45,13 +48,21 @@ impl VectorComponent for Rectangle { } } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub struct Circle { pub radius: f32, pub fill: Option, pub stroke: Option, } +impl Hash for Circle { + fn hash(&self, state: &mut H) { + hash_f32(self.radius, state); + self.fill.hash(state); + self.stroke.hash(state); + } +} + impl VectorComponent for Circle { fn layout(&self, constraints: Constraints) -> Vec2 { constraints.constrain(Vec2(self.radius * 2.0, self.radius * 2.0)) @@ -66,7 +77,7 @@ impl VectorComponent for Circle { } } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Hash)] pub struct Ellipse { pub radii: Vec2, pub fill: Option, diff --git a/tellur-core/src/time.rs b/tellur-core/src/time.rs index a798223..5d70f26 100644 --- a/tellur-core/src/time.rs +++ b/tellur-core/src/time.rs @@ -137,6 +137,12 @@ pub struct TimelineTime { seconds: f32, } +impl std::hash::Hash for TimelineTime { + fn hash(&self, state: &mut H) { + crate::dyn_compare::hash_f32(self.seconds, state); + } +} + impl TimelineTime { pub const fn new(seconds: f32) -> Self { Self { seconds } @@ -159,6 +165,12 @@ pub struct LocalTime { seconds: f32, } +impl std::hash::Hash for LocalTime { + fn hash(&self, state: &mut H) { + crate::dyn_compare::hash_f32(self.seconds, state); + } +} + impl LocalTime { pub const fn new(seconds: f32) -> Self { Self { seconds } diff --git a/tellur-core/src/timeline.rs b/tellur-core/src/timeline.rs index 4e5a19d..a3c4696 100644 --- a/tellur-core/src/timeline.rs +++ b/tellur-core/src/timeline.rs @@ -3,9 +3,11 @@ //! A [`Timeline`] produces a [`RasterImage`] for any given [`TimelineTime`] within //! its `duration`. Renderers walk the timeline frame by frame to produce a //! video. The shape of `build` mirrors [`crate::raster::RasterComponent`] so -//! the same `target: Resolution` flow works. +//! the same `target: Resolution` flow works, and a [`RenderContext`] is +//! threaded through so memoization can survive across frames. use crate::raster::{RasterImage, Resolution}; +use crate::render_context::RenderContext; use crate::time::TimelineTime; /// A scene defined over a finite time interval. @@ -14,14 +16,22 @@ pub trait Timeline { fn duration(&self) -> f32; /// Produces the frame for `t` at the requested `target` resolution. - fn build(&self, t: TimelineTime, target: Resolution) -> RasterImage; + /// `ctx` is supplied by the renderer driver so any caching layer can + /// persist across frames. + fn build( + &self, + t: TimelineTime, + target: Resolution, + ctx: &mut dyn RenderContext, + ) -> RasterImage; } /// Builds a [`Timeline`] from a closure. The closure receives the current -/// time and the target resolution, and returns the rasterized frame. +/// time, the target resolution, and a render context, and returns the +/// rasterized frame. pub fn timeline(duration: f32, build: F) -> impl Timeline where - F: Fn(TimelineTime, Resolution) -> RasterImage, + F: Fn(TimelineTime, Resolution, &mut dyn RenderContext) -> RasterImage, { FnTimeline { duration, build } } @@ -33,14 +43,19 @@ struct FnTimeline { impl Timeline for FnTimeline where - F: Fn(TimelineTime, Resolution) -> RasterImage, + F: Fn(TimelineTime, Resolution, &mut dyn RenderContext) -> RasterImage, { fn duration(&self) -> f32 { self.duration } - fn build(&self, t: TimelineTime, target: Resolution) -> RasterImage { - (self.build)(t, target) + fn build( + &self, + t: TimelineTime, + target: Resolution, + ctx: &mut dyn RenderContext, + ) -> RasterImage { + (self.build)(t, target, ctx) } } diff --git a/tellur-core/src/vector.rs b/tellur-core/src/vector.rs index 0a7171c..d7b8a60 100644 --- a/tellur-core/src/vector.rs +++ b/tellur-core/src/vector.rs @@ -1,4 +1,8 @@ +use std::any::Any; +use std::hash::{Hash, Hasher}; + use crate::color::Color; +use crate::dyn_compare::{hash_f32, DynEq, DynHash}; use crate::geometry::{Constraints, Rect, Transform, Vec2}; /// A piece of vector content with a paint-bounds rectangle. @@ -9,7 +13,7 @@ use crate::geometry::{Constraints, Rect, Transform, Vec2}; /// spills to the upper-left) or a `size` larger than the layout size. /// Place the graphic in a parent coordinate space by composing it /// through a `Group` transform or a `VectorLayer`. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Hash)] pub struct VectorGraphic { pub view_box: Rect, pub root: Node, @@ -32,7 +36,7 @@ pub struct VectorGraphic { /// Element components implement `layout` and `render` directly. Composite /// components (produced by `#[vector_component]`) usually do the same, /// internally building a child component and forwarding the protocol. -pub trait VectorComponent { +pub trait VectorComponent: DynEq + DynHash { /// Decide the layout size for this component given the parent's /// constraints. The returned `Vec2` must satisfy `min <= size <= max` /// on each axis. @@ -70,20 +74,41 @@ pub trait VectorComponent { // Compile-time guarantee that `VectorComponent` is dyn-safe. const _: Option<&dyn VectorComponent> = None; -#[derive(Debug, Clone)] +impl PartialEq for dyn VectorComponent { + fn eq(&self, other: &Self) -> bool { + DynEq::dyn_eq(self, other.as_any()) + } +} + +impl Hash for dyn VectorComponent { + fn hash(&self, state: &mut H) { + Any::type_id(self.as_any()).hash(state); + DynHash::dyn_hash(self, state); + } +} + +#[derive(Debug, Clone, PartialEq, Hash)] pub enum Node { Group(Group), Path(Path), } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub struct Group { pub transform: Transform, pub opacity: f32, pub children: Vec, } -#[derive(Debug, Clone)] +impl Hash for Group { + fn hash(&self, state: &mut H) { + self.transform.hash(state); + hash_f32(self.opacity, state); + self.children.hash(state); + } +} + +#[derive(Debug, Clone, PartialEq, Hash)] pub struct Path { pub commands: Vec, pub fill: Option, @@ -91,7 +116,7 @@ pub struct Path { pub transform: Transform, } -#[derive(Debug, Clone, Copy, PartialEq)] +#[derive(Debug, Clone, Copy, PartialEq, Hash)] pub enum PathCommand { MoveTo(Vec2), LineTo(Vec2), @@ -100,18 +125,25 @@ pub enum PathCommand { Close, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Hash)] pub struct Fill { pub paint: Paint, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub struct Stroke { pub paint: Paint, pub width: f32, } -#[derive(Debug, Clone)] +impl Hash for Stroke { + fn hash(&self, state: &mut H) { + self.paint.hash(state); + hash_f32(self.width, state); + } +} + +#[derive(Debug, Clone, PartialEq, Hash)] pub enum Paint { Solid(Color), } diff --git a/tellur-macros/src/lib.rs b/tellur-macros/src/lib.rs index 99a79b6..3f1d0d2 100644 --- a/tellur-macros/src/lib.rs +++ b/tellur-macros/src/lib.rs @@ -127,9 +127,10 @@ fn expand(func: ItemFn, kind: Kind) -> syn::Result { quote!(::tellur_core::raster::RasterImage), quote!( size: ::tellur_core::geometry::Vec2, - target: ::tellur_core::raster::Resolution + target: ::tellur_core::raster::Resolution, + ctx: &mut dyn ::tellur_core::render_context::RenderContext ), - quote!(size, target), + quote!(size, target, ctx), ), }; @@ -190,12 +191,45 @@ fn expand(func: ItemFn, kind: Kind) -> syn::Result { ) }; + // Hash for the struct is built field-by-field so that bare `f32` / + // `f64` fields can be routed through the bit-pattern hashers + // (`f32` and `f64` don't implement `Hash` directly, so `#[derive(Hash)]` + // wouldn't compile on a component that takes a raw float argument). + let hash_field_calls: Vec = field_idents + .iter() + .zip(field_types.iter()) + .map(|(ident, ty)| { + let ty_str = quote!(#ty).to_string(); + // Strip whitespace introduced by token stream stringification to + // make the comparison robust against `& 'static str`-style spacing. + let normalized: String = ty_str.split_whitespace().collect(); + match normalized.as_str() { + "f32" => quote! { + ::tellur_core::dyn_compare::hash_f32(self.#ident, state); + }, + "f64" => quote! { + self.#ident.to_bits().hash(state); + }, + _ => quote! { + ::core::hash::Hash::hash(&self.#ident, state); + }, + } + }) + .collect(); + let output = quote! { - #[derive(::core::clone::Clone)] + #[derive(::core::clone::Clone, ::core::cmp::PartialEq)] #vis struct #struct_ident { #( pub #field_idents: #field_types, )* } + impl ::core::hash::Hash for #struct_ident { + fn hash<__H: ::core::hash::Hasher>(&self, state: &mut __H) { + use ::core::hash::Hash as _; + #( #hash_field_calls )* + } + } + impl #struct_ident { #build_fn } diff --git a/tellur-renderer/Cargo.toml b/tellur-renderer/Cargo.toml index 06aacad..8c265de 100644 --- a/tellur-renderer/Cargo.toml +++ b/tellur-renderer/Cargo.toml @@ -7,6 +7,8 @@ edition = "2021" bytes = "1.11.1" console = "0.16.3" indicatif = "0.18.4" +lru = "0.12" +sysinfo = { version = "0.32", default-features = false, features = ["system"] } tellur-core = { path = "../tellur-core" } thiserror = "2.0.18" tiny-skia = "0.12.0" diff --git a/tellur-renderer/examples/raster_layer_to_png.rs b/tellur-renderer/examples/raster_layer_to_png.rs index 944bb17..4992ac5 100644 --- a/tellur-renderer/examples/raster_layer_to_png.rs +++ b/tellur-renderer/examples/raster_layer_to_png.rs @@ -11,6 +11,7 @@ use tellur_core::geometry::{Anchor, Vec2}; use tellur_core::layer::Layer; use tellur_core::placement::RasterPlacement; use tellur_core::raster::{RasterComponent, Resolution}; +use tellur_core::render_context::PassThrough; use tellur_core::shapes::{Circle, Rectangle}; use tellur_core::vector::Paint; use tellur_core::vector_component; @@ -63,7 +64,7 @@ fn main() { ], }; - let image = scene.render(scene_size, Resolution::new(1280, 720)); + let image = scene.render(scene_size, Resolution::new(1280, 720), &mut PassThrough); let path = "/tmp/raster-scene.png"; let file = File::create(path).expect("create output file"); diff --git a/tellur-renderer/examples/scene_to_png.rs b/tellur-renderer/examples/scene_to_png.rs index be1563e..d4ab9e1 100644 --- a/tellur-renderer/examples/scene_to_png.rs +++ b/tellur-renderer/examples/scene_to_png.rs @@ -7,6 +7,7 @@ use tellur_core::geometry::{Anchor, Vec2}; use tellur_core::layer::VectorLayer; use tellur_core::placement::VectorPlacement; use tellur_core::raster::{RasterComponent, Resolution}; +use tellur_core::render_context::PassThrough; use tellur_core::shapes::{Circle, Rectangle}; use tellur_core::vector::Paint; use tellur_renderer::Rasterizable; @@ -41,7 +42,7 @@ fn main() { let image = scene .rasterize() - .render(scene_size, Resolution::new(1280, 720)); + .render(scene_size, Resolution::new(1280, 720), &mut PassThrough); let path = "/tmp/scene.png"; let file = File::create(path).expect("create output file"); diff --git a/tellur-renderer/examples/timeline_to_mp4.rs b/tellur-renderer/examples/timeline_to_mp4.rs index b956948..f7a8c72 100644 --- a/tellur-renderer/examples/timeline_to_mp4.rs +++ b/tellur-renderer/examples/timeline_to_mp4.rs @@ -60,7 +60,7 @@ fn BouncingDot(t: LocalTime) -> impl RasterComponent { fn main() { let scene_size = Vec2(1280.0, 720.0); - let tl = timeline(5.0, move |t, target| { + let tl = timeline(5.0, move |t, target, ctx| { Stack { axis: Axis::Vertical, size: None, @@ -88,7 +88,7 @@ fn main() { } .padding(EdgeInsets::all(100.0)) .background(Color::rgb_u8(20, 20, 30)) - .render(scene_size, target) + .render(scene_size, target, ctx) }); let out = Path::new("/tmp/timeline.mp4"); diff --git a/tellur-renderer/src/lib.rs b/tellur-renderer/src/lib.rs index 1ce3427..c68490f 100644 --- a/tellur-renderer/src/lib.rs +++ b/tellur-renderer/src/lib.rs @@ -1,7 +1,9 @@ pub mod rasterize; +pub mod render_context; pub mod shadow; pub mod video; pub use rasterize::{Rasterizable, Rasterize}; +pub use render_context::CachingRenderContext; pub use shadow::DropShadow; pub use video::{FfmpegEncoder, FfmpegError}; diff --git a/tellur-renderer/src/rasterize.rs b/tellur-renderer/src/rasterize.rs index 5a6f4ee..483a05a 100644 --- a/tellur-renderer/src/rasterize.rs +++ b/tellur-renderer/src/rasterize.rs @@ -1,18 +1,22 @@ +use std::hash::Hash; + use bytes::Bytes; use tellur_core::color::Color; use tellur_core::geometry::{Constraints, Rect, Transform, Vec2}; use tellur_core::raster::{PixelFormat, RasterComponent, RasterImage, Resolution}; +use tellur_core::render_context::RenderContext; use tellur_core::vector::{Node, Paint, Path, PathCommand, VectorComponent, VectorGraphic}; /// A `RasterComponent` that rasterizes a `VectorComponent`. The layout /// protocol forwards to the wrapped vector: layout / paint_bounds / /// render(size) all delegate, and `render(size, target)` rasterizes the /// vector's `render(size)` output into a `target`-sized pixel buffer. +#[derive(PartialEq, Hash)] pub struct Rasterize { pub vector: V, } -impl RasterComponent for Rasterize { +impl RasterComponent for Rasterize { fn layout(&self, constraints: Constraints) -> Vec2 { self.vector.layout(constraints) } @@ -21,7 +25,7 @@ impl RasterComponent for Rasterize { self.vector.paint_bounds(size) } - fn render(&self, size: Vec2, target: Resolution) -> RasterImage { + fn render(&self, size: Vec2, target: Resolution, _ctx: &mut dyn RenderContext) -> RasterImage { let graphic = self.vector.render(size); rasterize(&graphic, target.width, target.height) } diff --git a/tellur-renderer/src/render_context.rs b/tellur-renderer/src/render_context.rs new file mode 100644 index 0000000..4da927d --- /dev/null +++ b/tellur-renderer/src/render_context.rs @@ -0,0 +1,441 @@ +//! LRU-backed implementation of [`tellur_core::render_context::RenderContext`]. +//! +//! [`CachingRenderContext`] memoizes [`RasterImage`] outputs keyed by +//! `(TypeId, content_hash, size, target)`. The cache is bounded in +//! **bytes** (image pixel buffers), with a soft second limit driven by +//! system memory pressure: if the host's used-memory ratio climbs above +//! the threshold (default 90%), entries are evicted aggressively and +//! new entries are not inserted until pressure drops back. +//! +//! Pixel data lives in `Bytes` (Arc-backed), so cloning a cached +//! `RasterImage` does not copy the buffer — cache hits are cheap. The +//! Arc'd backing also means that downstream consumers (encoder, save, +//! further compositing) hold the same buffer the cache holds. + +use std::any::TypeId; +use std::collections::hash_map::DefaultHasher; +use std::collections::HashMap; +use std::fmt; +use std::hash::{Hash, Hasher}; +use std::time::{Duration, Instant}; + +use lru::LruCache; +use sysinfo::System; +use tellur_core::dyn_compare::DynEq; +use tellur_core::geometry::Vec2; +use tellur_core::raster::{RasterComponent, RasterImage, Resolution}; +use tellur_core::render_context::RenderContext; + +/// Default cache size in bytes (8 GiB) when constructed with +/// [`CachingRenderContext::new`]. +pub const DEFAULT_CAPACITY_BYTES: usize = 8 * 1024 * 1024 * 1024; + +/// System-memory utilization fraction above which the cache stops +/// admitting new entries and starts shedding existing ones. +pub const MEMORY_PRESSURE_THRESHOLD: f32 = 0.90; + +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +struct CacheKey { + type_id: TypeId, + content_hash: u64, + // f32 fields are stored as their bit patterns so the key is `Eq`/`Hash` + // (which `f32` is not, due to NaN inequality). + size_x_bits: u32, + size_y_bits: u32, + target_width: u32, + target_height: u32, +} + +impl CacheKey { + fn of(c: &dyn RasterComponent, size: Vec2, target: Resolution) -> Self { + // `dyn RasterComponent` implements `Hash` by mixing the concrete + // `TypeId` with the component's own content hash; reuse that + // exact hash for the cache key's `content_hash` slot. + let mut hasher = DefaultHasher::new(); + c.hash(&mut hasher); + let content_hash = hasher.finish(); + Self { + type_id: c.as_any().type_id(), + content_hash, + size_x_bits: size.0.to_bits(), + size_y_bits: size.1.to_bits(), + target_width: target.width, + target_height: target.height, + } + } +} + +/// Per-concrete-type hit/miss tally. +/// +/// Surfaced as part of [`CacheMetrics::per_type`] so callers can spot +/// which component types are actually benefiting from memoization and +/// which keep missing every frame (typically a sign that something +/// "downstream" of the type — a `paint_bounds`-derived `Resolution`, a +/// timestamped field — is varying each call). +#[derive(Debug, Clone, Copy, Default)] +pub struct TypeStats { + pub hits: u64, + pub misses: u64, + /// Wall-clock time spent inside `ctx.render` for this type, + /// **including** all nested child renders called through the + /// context. Dominated by whichever level of the tree the time was + /// "really" spent at. + pub inclusive_time: Duration, + /// `inclusive_time` minus the time spent in nested `ctx.render` + /// calls. Approximates the time genuinely consumed by this type's + /// own `render` body plus cache bookkeeping for it — a good proxy + /// for "is this layer the bottleneck?". + pub self_time: Duration, +} + +impl TypeStats { + pub fn total(&self) -> u64 { + self.hits + self.misses + } + + pub fn hit_rate(&self) -> f64 { + let t = self.total(); + if t == 0 { + 0.0 + } else { + self.hits as f64 / t as f64 + } + } +} + +/// Aggregate counters tracking what the cache is doing. +/// +/// All numbers are cumulative since context construction (or the last +/// [`CachingRenderContext::clear_metrics`] call). Useful for confirming +/// that memoization is actually firing on a given timeline, and for +/// understanding why it isn't when it isn't. +#[derive(Debug, Clone, Default)] +pub struct CacheMetrics { + /// Calls to [`RenderContext::render`] that returned a cached image. + pub hits: u64, + /// Calls that had to invoke the component's `render` method. + pub misses: u64, + /// Bytes currently held by the cache. + pub bytes_cached: usize, + /// Bytes released through LRU eviction (capacity-driven). + pub bytes_evicted: u64, + /// Misses where the freshly-produced image was not admitted + /// because system memory pressure was over threshold. + pub pressure_skips: u64, + /// Misses where the freshly-produced image was not admitted + /// because a single image exceeded the configured cap. + pub oversize_skips: u64, + /// Breakdown by the concrete `RasterComponent` type that was queried, + /// keyed by display name (`std::any::type_name`). + pub per_type: HashMap<&'static str, TypeStats>, +} + +impl CacheMetrics { + /// Hit rate as a fraction in `[0, 1]`. Returns `0.0` when no calls + /// have been made yet. + pub fn hit_rate(&self) -> f64 { + let total = self.hits + self.misses; + if total == 0 { + 0.0 + } else { + self.hits as f64 / total as f64 + } + } +} + +/// Multi-line display for `CacheMetrics`. Renders the totals on one row +/// and a per-type breakdown sorted by total call count (descending), so +/// the noisiest types lead. Suitable for `eprintln!("{}", metrics)` or +/// a log line at the end of an export. +impl fmt::Display for CacheMetrics { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!( + f, + "Cache {} hits / {} misses ({:.1}% hit) — {} cached, {} evicted, {} pressure skips, {} oversize skips", + self.hits, + self.misses, + self.hit_rate() * 100.0, + format_bytes(self.bytes_cached as u64), + format_bytes(self.bytes_evicted), + self.pressure_skips, + self.oversize_skips, + )?; + if !self.per_type.is_empty() { + writeln!(f, "Cache by type (sorted by self_time, descending):")?; + // Sort by self_time so the type that's actually burning + // CPU shows up first; that's almost always the question + // the user is trying to answer when they look at this. + let mut rows: Vec<(&&'static str, &TypeStats)> = self.per_type.iter().collect(); + rows.sort_by_key(|(_, s)| std::cmp::Reverse(s.self_time)); + let name_w = rows.iter().map(|(n, _)| n.len()).max().unwrap_or(0); + for (name, s) in rows { + writeln!( + f, + " {name:5} hits / {misses:>5} misses ({rate:>5.1}%) self {self_t:>9} incl {inc:>9}", + name = name, + name_w = name_w, + hits = s.hits, + misses = s.misses, + rate = s.hit_rate() * 100.0, + self_t = format_duration(s.self_time), + inc = format_duration(s.inclusive_time), + )?; + } + } + Ok(()) + } +} + +fn format_duration(d: Duration) -> String { + let micros = d.as_micros(); + if micros >= 1_000_000 { + format!("{:.2}s", d.as_secs_f64()) + } else if micros >= 1_000 { + format!("{:.2}ms", micros as f64 / 1_000.0) + } else { + format!("{micros}µs") + } +} + +fn format_bytes(b: u64) -> String { + const KIB: f64 = 1024.0; + const MIB: f64 = KIB * 1024.0; + const GIB: f64 = MIB * 1024.0; + let bf = b as f64; + if bf >= GIB { + format!("{:.2} GiB", bf / GIB) + } else if bf >= MIB { + format!("{:.2} MiB", bf / MIB) + } else if bf >= KIB { + format!("{:.2} KiB", bf / KIB) + } else { + format!("{b} B") + } +} + +/// A render context that memoizes `RasterImage` outputs. +/// +/// Construct one per export / preview session and pass it into +/// [`tellur_core::timeline::Timeline::build`]; the cache persists across +/// frames so any time-invariant subtree only re-renders once. +pub struct CachingRenderContext { + cache: LruCache, + cur_bytes: usize, + cap_bytes: usize, + system: System, + // Aggregate counters; `per_type` is keyed by `TypeId` for cheap + // updates inside `render`, then projected onto `&'static str` names + // when the user calls `metrics()`. + hits: u64, + misses: u64, + bytes_evicted: u64, + pressure_skips: u64, + oversize_skips: u64, + per_type: HashMap, + // Running total of every `ctx.render` call's inclusive duration. + // A `render` invocation snapshots this on entry and re-reads it on + // exit to derive how much time was spent inside nested child + // renders; the difference between elapsed and that delta is the + // current frame's "self time" contribution. + total_render_time: Duration, +} + +impl CachingRenderContext { + /// Create a context with the default capacity ([`DEFAULT_CAPACITY_BYTES`]). + pub fn new() -> Self { + Self::with_capacity_bytes(DEFAULT_CAPACITY_BYTES) + } + + /// Create a context with a custom byte capacity. + pub fn with_capacity_bytes(cap_bytes: usize) -> Self { + Self { + cache: LruCache::unbounded(), + cur_bytes: 0, + cap_bytes, + system: System::new(), + hits: 0, + misses: 0, + bytes_evicted: 0, + pressure_skips: 0, + oversize_skips: 0, + per_type: HashMap::new(), + total_render_time: Duration::ZERO, + } + } + + /// Current memory footprint of cached images, in bytes. + pub fn current_bytes(&self) -> usize { + self.cur_bytes + } + + /// Configured maximum capacity in bytes. + pub fn capacity_bytes(&self) -> usize { + self.cap_bytes + } + + /// A snapshot of the cumulative cache counters. The per-type table + /// is projected from `TypeId` onto `&'static str` names at this + /// point so the returned struct can be displayed or logged + /// independently of the context. + pub fn metrics(&self) -> CacheMetrics { + let per_type = self + .per_type + .values() + .map(|(stats, name)| (*name, *stats)) + .collect(); + CacheMetrics { + hits: self.hits, + misses: self.misses, + bytes_cached: self.cur_bytes, + bytes_evicted: self.bytes_evicted, + pressure_skips: self.pressure_skips, + oversize_skips: self.oversize_skips, + per_type, + } + } + + /// Reset the counters (does not flush the cache itself). + pub fn clear_metrics(&mut self) { + self.hits = 0; + self.misses = 0; + self.bytes_evicted = 0; + self.pressure_skips = 0; + self.oversize_skips = 0; + self.per_type.clear(); + self.total_render_time = Duration::ZERO; + } + + /// Drop all cached entries. + pub fn clear(&mut self) { + self.cache.clear(); + self.cur_bytes = 0; + } + + /// Refresh and check system-wide memory utilization. + fn under_memory_pressure(&mut self) -> bool { + self.system.refresh_memory(); + let total = self.system.total_memory(); + if total == 0 { + return false; + } + // Compare in u64 space to avoid f32 precision quirks at large RAM sizes. + let used = self.system.used_memory(); + // used / total > 0.90 ⇔ used * 100 > total * 90 + used.saturating_mul(100) > total.saturating_mul(90) + } + + fn image_bytes(image: &RasterImage) -> usize { + image.pixels.len() + } + + /// Evict least-recently-used entries until `needed` more bytes fit + /// under the configured cap. + fn evict_to_fit(&mut self, needed: usize) { + while self.cur_bytes + needed > self.cap_bytes { + match self.cache.pop_lru() { + Some((_, img)) => { + let b = Self::image_bytes(&img); + self.cur_bytes = self.cur_bytes.saturating_sub(b); + self.bytes_evicted = self.bytes_evicted.saturating_add(b as u64); + } + None => break, + } + } + } + + /// Evict entries until system memory pressure subsides or the cache + /// is empty. + fn shed_under_pressure(&mut self) { + while self.under_memory_pressure() { + match self.cache.pop_lru() { + Some((_, img)) => { + let b = Self::image_bytes(&img); + self.cur_bytes = self.cur_bytes.saturating_sub(b); + self.bytes_evicted = self.bytes_evicted.saturating_add(b as u64); + } + None => break, + } + } + } +} + +impl Default for CachingRenderContext { + fn default() -> Self { + Self::new() + } +} + +impl RenderContext for CachingRenderContext { + fn render( + &mut self, + component: &dyn RasterComponent, + size: Vec2, + target: Resolution, + ) -> RasterImage { + // Capture type identity up front so we can record stats per + // concrete type even when we hit the cache and never touch + // `component` again. + let any_ref = component.as_any(); + let type_id = any_ref.type_id(); + let type_name = DynEq::type_name(component); + + // Timing setup. `child_acc_at_start` is the total inclusive + // time recorded for *all* prior `render` calls; on exit the + // delta tells us how much time was spent inside nested child + // renders during *this* call, which we subtract from our own + // elapsed to get self time. + let start = Instant::now(); + let child_acc_at_start = self.total_render_time; + + // hit_or_miss is the common-tail return point so timing + // bookkeeping lives in exactly one place. + let (img, was_hit) = { + let key = CacheKey::of(component, size, target); + if let Some(img) = self.cache.get(&key).cloned() { + (img, true) + } else { + // Miss path: produce the image, then decide whether to + // admit it. Nested `ctx.render` calls happen inside + // `component.render`, which is why timing is wrapped + // around the whole block. + let img = component.render(size, target, self); + let bytes = Self::image_bytes(&img); + + if bytes > self.cap_bytes { + self.oversize_skips += 1; + } else { + self.evict_to_fit(bytes); + if self.under_memory_pressure() { + self.shed_under_pressure(); + self.pressure_skips += 1; + } else { + self.cache.put(key, img.clone()); + self.cur_bytes += bytes; + } + } + (img, false) + } + }; + + let inclusive = start.elapsed(); + let child_inclusive = self.total_render_time.saturating_sub(child_acc_at_start); + let self_time = inclusive.saturating_sub(child_inclusive); + self.total_render_time += inclusive; + + let (stats, _) = self + .per_type + .entry(type_id) + .or_insert_with(|| (TypeStats::default(), type_name)); + if was_hit { + self.hits += 1; + stats.hits += 1; + } else { + self.misses += 1; + stats.misses += 1; + } + stats.inclusive_time += inclusive; + stats.self_time += self_time; + + img + } +} diff --git a/tellur-renderer/src/shadow.rs b/tellur-renderer/src/shadow.rs index 2307643..e559aab 100644 --- a/tellur-renderer/src/shadow.rs +++ b/tellur-renderer/src/shadow.rs @@ -6,10 +6,14 @@ //! enough pixels; its `layout_box` is left unchanged so shadows do not //! disturb layout. +use std::hash::{Hash, Hasher}; + use bytes::Bytes; use tellur_core::color::Color; +use tellur_core::dyn_compare::hash_f32; use tellur_core::geometry::{Constraints, Rect, Vec2}; use tellur_core::raster::{PixelFormat, RasterComponent, RasterImage, Resolution}; +use tellur_core::render_context::RenderContext; pub struct DropShadow { /// Offset of the shadow relative to the child, in logical units. @@ -21,6 +25,24 @@ pub struct DropShadow { pub child: Box, } +impl PartialEq for DropShadow { + fn eq(&self, other: &Self) -> bool { + self.offset == other.offset + && self.blur.to_bits() == other.blur.to_bits() + && self.color == other.color + && *self.child == *other.child + } +} + +impl Hash for DropShadow { + fn hash(&self, state: &mut H) { + self.offset.hash(state); + hash_f32(self.blur, state); + self.color.hash(state); + self.child.hash(state); + } +} + /// 3-pass box blur with kernel radius `r` has a total convolution /// support of `3 * r` on each side of the source. Both `paint_bounds` /// and the per-pixel `make_shadow` padding must agree on this extent so @@ -55,7 +77,7 @@ impl RasterComponent for DropShadow { } } - fn render(&self, size: Vec2, target: Resolution) -> RasterImage { + fn render(&self, size: Vec2, target: Resolution, ctx: &mut dyn RenderContext) -> RasterImage { let paint = self.paint_bounds(size); let child_paint = self.child.paint_bounds(size); if paint.size.0 <= 0.0 || paint.size.1 <= 0.0 { @@ -64,12 +86,16 @@ impl RasterComponent for DropShadow { let sx = target.width as f32 / paint.size.0; let sy = target.height as f32 / paint.size.1; - // Render the child at its own paint-bounds pixel size. + // Render the child through the context so its output is memoized + // independently of the shadow — that's the key win for static + // subtrees where the heavy work is the child render itself. let child_px_w = (child_paint.size.0 * sx).round().max(1.0) as u32; let child_px_h = (child_paint.size.1 * sy).round().max(1.0) as u32; - let child_image = self - .child - .render(size, Resolution::new(child_px_w, child_px_h)); + let child_image = ctx.render( + self.child.as_ref(), + size, + Resolution::new(child_px_w, child_px_h), + ); // Build a padded shadow image whose alpha is a blurred copy of // the child's alpha, tinted with `color`. Padding equals the diff --git a/tellur-renderer/src/video.rs b/tellur-renderer/src/video.rs index 4d64cda..049afce 100644 --- a/tellur-renderer/src/video.rs +++ b/tellur-renderer/src/video.rs @@ -30,6 +30,7 @@ use std::io::{BufRead, BufReader, Read, Write}; use std::path::Path; use std::process::{ChildStdout, Command, Stdio}; use std::thread; +use std::time::{Duration, Instant}; use indicatif::{MultiProgress, ProgressBar, ProgressState, ProgressStyle}; use tellur_core::raster::{PixelFormat, Resolution}; @@ -37,6 +38,8 @@ use tellur_core::time::TimelineTime; use tellur_core::timeline::Timeline; use thiserror::Error; +use crate::render_context::CachingRenderContext; + /// Builder that spawns `ffmpeg` and drives a [`Timeline`] through it. /// /// The frame size is fixed at construction (`resolution`) and frames are @@ -200,10 +203,25 @@ impl FfmpegEncoder { (None, None, None, None, None) }; + // One context for the whole encode so memoization survives across + // frames — that's what makes static subtrees (e.g. a DropShadow + // wrapping a time-invariant child) only re-render once. + let mut ctx = CachingRenderContext::new(); + + // Per-phase timing so we can tell whether wall-clock time is + // being spent inside `tl.build` (= our rendering pipeline) or + // blocked on `stdin.write_all` (= ffmpeg's encoder backpressure). + let mut build_time = Duration::ZERO; + let mut write_time = Duration::ZERO; + let loop_start = Instant::now(); + let write_result = (|| -> Result<(), FfmpegError> { for frame_idx in 0..total_frames { let t = TimelineTime::new(frame_idx as f32 / self.fps as f32); - let image = tl.build(t, self.resolution); + + let build_start = Instant::now(); + let image = tl.build(t, self.resolution, &mut ctx); + build_time += build_start.elapsed(); if image.format != PixelFormat::Rgba8 { return Err(FfmpegError::UnsupportedFormat { @@ -221,12 +239,14 @@ impl FfmpegEncoder { }); } + let write_start = Instant::now(); stdin .write_all(&image.pixels) .map_err(|source| FfmpegError::Write { frame: frame_idx, source, })?; + write_time += write_start.elapsed(); if let Some(bar) = &render_bar { bar.inc(1); @@ -234,6 +254,7 @@ impl FfmpegEncoder { } Ok(()) })(); + let loop_elapsed = loop_start.elapsed(); // Close stdin so ffmpeg can finalize the file, even if frame // production errored partway through — that lets ffmpeg shut down @@ -272,6 +293,25 @@ impl FfmpegEncoder { bar.finish(); } + // Dump cache metrics so users can confirm memoization is firing + // (and diagnose why it isn't when hit_rate stays low). The + // breakdown-by-type rows are particularly useful for spotting + // which component types are not benefiting from the cache. + // The loop-phase timings sit above the cache summary so it's + // immediately visible whether our render path or ffmpeg's + // encoder is the wall-clock bottleneck. + if self.progress { + let other = loop_elapsed.saturating_sub(build_time + write_time); + eprintln!( + "Loop {} total = {} build + {} ffmpeg-write + {} other", + format_duration_short(loop_elapsed), + format_duration_short(build_time), + format_duration_short(write_time), + format_duration_short(other), + ); + eprint!("{}", ctx.metrics()); + } + write_result?; if !status.success() { @@ -326,6 +366,17 @@ fn drive_encode_progress(stdout: ChildStdout, encode_bar: ProgressBar, info_bar: } } +fn format_duration_short(d: Duration) -> String { + let micros = d.as_micros(); + if micros >= 1_000_000 { + format!("{:.2}s", d.as_secs_f64()) + } else if micros >= 1_000 { + format!("{:.2}ms", micros as f64 / 1_000.0) + } else { + format!("{micros}µs") + } +} + fn format_bytes(b: u64) -> String { const KIB: f64 = 1024.0; const MIB: f64 = KIB * 1024.0; From 4875dfb75910a6ea6b9ed78753d835e7e4db909e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=82=E3=81=99=E3=81=B1=E3=82=8B?= Date: Mon, 25 May 2026 19:56:49 +0900 Subject: [PATCH 2/2] feat: add memoization_benchmark example --- .../examples/memoization_benchmark.rs | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 tellur-renderer/examples/memoization_benchmark.rs diff --git a/tellur-renderer/examples/memoization_benchmark.rs b/tellur-renderer/examples/memoization_benchmark.rs new file mode 100644 index 0000000..3711cba --- /dev/null +++ b/tellur-renderer/examples/memoization_benchmark.rs @@ -0,0 +1,147 @@ +//! Compare `CachingRenderContext` against `PassThrough` on the same +//! timeline, with no ffmpeg subprocess in the loop, so the wall-clock +//! difference reflects the rendering pipeline only. +//! +//! Renders the same `timeline_to_mp4` scene twice — once through the +//! bounded LRU cache, once through `PassThrough` — and prints +//! per-frame and total times for both. Cache hits should make the +//! cached run noticeably faster on the static blur subtree +//! (`DropShadow`), while time-varying nodes (`Stack`, `Padding`, +//! `BouncingDot { t }`) hit the cache rarely or not at all. + +use std::time::Instant; + +use tellur_core::color::Color; +use tellur_core::geometry::{Anchor, EdgeInsets, Vec2}; +use tellur_core::layout::raster::{Frame, RasterLayoutExt, Stack}; +use tellur_core::layout::{Axis, CrossAlign, MainAlign, SizeMode}; +use tellur_core::raster::{RasterComponent, Resolution}; +use tellur_core::raster_component; +use tellur_core::render_context::{PassThrough, RenderContext}; +use tellur_core::shapes::Circle; +use tellur_core::time::{LocalTime, Time, TimelineTime}; +use tellur_core::timeline::{timeline, Timeline}; +use tellur_core::vector::Paint; +use tellur_renderer::{CachingRenderContext, DropShadow, Rasterizable}; + +#[raster_component] +fn BouncingDot(t: LocalTime) -> impl RasterComponent { + let (phase, _) = t.bounce(2.5); + let rx = phase.interpolate(0.0, 1.0); + let radius = 30.0; + Frame { + width: SizeMode::Fill, + height: SizeMode::Fixed(60.0), + child_anchor: Anchor::CENTER, + at: Anchor::new(rx, 0.5), + child: DropShadow { + offset: Vec2(0.0, 8.0), + blur: 4.0, + color: Color::rgba_u8(255, 255, 255, 100), + child: Circle { + radius, + fill: Paint::Solid(Color::hsl(200.0, 0.7, 0.6)).into(), + stroke: None, + } + .rasterize() + .boxed(), + } + .boxed(), + } +} + +fn bench( + label: &str, + tl: &impl Timeline, + resolution: Resolution, + fps: u32, + total_frames: u64, + ctx: &mut dyn RenderContext, +) { + let start = Instant::now(); + for frame_idx in 0..total_frames { + let t = TimelineTime::new(frame_idx as f32 / fps as f32); + // Pull the image into a local so the optimizer can't elide the + // render — `bytes::Bytes` clone is cheap so this doesn't skew + // the comparison. + let _image = tl.build(t, resolution, ctx); + } + let elapsed = start.elapsed(); + let per_frame_ms = elapsed.as_secs_f64() * 1000.0 / total_frames as f64; + println!( + "{label:<22} {:>7.2}s total ({:>6.2} ms / frame, {:>6.2} fps)", + elapsed.as_secs_f64(), + per_frame_ms, + 1000.0 / per_frame_ms, + ); +} + +fn main() { + let scene_size = Vec2(1280.0, 720.0); + let resolution = Resolution::new(1920, 1080); + let fps = 60u32; + let duration = 5.0f32; + let total_frames = (duration * fps as f32).ceil() as u64; + + let tl = timeline(duration, move |t, target, ctx| { + Stack { + axis: Axis::Vertical, + size: None, + spacing: 0.0, + main_align: MainAlign::SpaceEvenly, + cross_align: CrossAlign::Stretch, + children: vec![ + BouncingDot { + t: t.fps(60).into(), + } + .boxed(), + BouncingDot { + t: t.fps(30).into(), + } + .boxed(), + BouncingDot { + t: t.fps(24).into(), + } + .boxed(), + BouncingDot { + t: t.fps(16).into(), + } + .boxed(), + ], + } + .padding(EdgeInsets::all(100.0)) + .background(Color::rgb_u8(20, 20, 30)) + .render(scene_size, target, ctx) + }); + + println!( + "Rendering {} frames at {}x{} ({} fps, {:.1}s timeline)\n", + total_frames, resolution.width, resolution.height, fps, duration, + ); + + // PassThrough first: no warmup state to carry between runs, so this + // gives a clean baseline. CachingRenderContext after, with its + // cache cold at the start (mirroring how a real export starts). + let mut pass_ctx = PassThrough; + bench( + "PassThrough", + &tl, + resolution, + fps, + total_frames, + &mut pass_ctx, + ); + + let mut cache_ctx = CachingRenderContext::new(); + bench( + "CachingRenderContext", + &tl, + resolution, + fps, + total_frames, + &mut cache_ctx, + ); + + println!(); + print!("{}", cache_ctx.metrics()); +}