From 2b8e990cf9df64441ba021f2e9fa80b01ae9544d Mon Sep 17 00:00:00 2001 From: sergio Date: Wed, 13 May 2026 03:12:02 +0000 Subject: [PATCH] feat(lapaloma-cartesian): picture cache pan-blit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChartCache + ChartCacheHandle (Arc>) cacheable entre frames. El Render host crea uno con chart_cache() y lo pasa al Element con .with_cache(handle). Sin handle, cada frame rebuild completo (correcto pero sin la optimización). - Hash estructural: plot rect + viewport.span (no x_min/y_min) + per-series (data.revision + data.len + stroke). 5 tests cubren estabilidad, pan no invalida, zoom invalida, data revision invalida, plot rect invalida. - En paint: si el hash matches, pan-blit = copia las coords cacheadas con offset (dx_px, dy_px) calculado del diff entre viewport.x_min cached vs actual. Salteamos LTTB + projection. - LineSeries::compute_projected expone el pipe LTTB + project_buffer como método público para que el Element pueda cachear sin pasar por paint(). - Demo multi-series usa el cache; header muestra "cache: N pan-blits / M rebuilds" en vivo para que se vea la métrica al draguear (pan-blits crece) y al zoomear (rebuilds crece). Limitación v0.1 anotada en código: el doc canónico (sección 4.4) usa una textura offscreen blitable; GPUI 0.2 no expone esa primitiva directa. La impl actual cachea coords proyectadas y emite las polilíneas con offset — mismo ahorro de CPU (saltea LTTB) sin GPU texture cache. 51 tests verdes (28 cartesian incluyendo 5 nuevos del structural_hash, 20 core, 3 render). Co-Authored-By: Claude Opus 4.7 --- crates/apps/lapaloma-demo/src/main.rs | 16 +- .../widgets/lapaloma-cartesian/src/element.rs | 310 ++++++++++++++++-- .../widgets/lapaloma-cartesian/src/lib.rs | 5 +- .../widgets/lapaloma-cartesian/src/series.rs | 33 +- 4 files changed, 315 insertions(+), 49 deletions(-) diff --git a/crates/apps/lapaloma-demo/src/main.rs b/crates/apps/lapaloma-demo/src/main.rs index 56ccdc8..615f342 100644 --- a/crates/apps/lapaloma-demo/src/main.rs +++ b/crates/apps/lapaloma-demo/src/main.rs @@ -14,7 +14,7 @@ use gpui::{ MouseMoveEvent, MouseUpEvent, Point, Render, ScrollDelta, ScrollWheelEvent, Window, }; -use lapaloma_cartesian::{ChartViewport, LapalomaChartElement}; +use lapaloma_cartesian::{chart_cache, ChartCacheHandle, ChartViewport, LapalomaChartElement}; use lapaloma_core::buffer::DataBuffer; use lapaloma_render::{Color, StrokeStyle}; use yahweh_launcher::launch_app; @@ -38,6 +38,7 @@ struct Demo { viewport: ChartViewport, initial_viewport: ChartViewport, drag: Option, + chart_cache: ChartCacheHandle, } #[derive(Clone, Copy)] @@ -65,6 +66,7 @@ impl Demo { viewport, initial_viewport: viewport, drag: None, + chart_cache: chart_cache(), } } @@ -145,6 +147,7 @@ impl Render for Demo { let plot_bg = Color::rgba(0.10, 0.12, 0.16, 1.0); let chart = LapalomaChartElement::new(self.viewport) .background(plot_bg) + .with_cache(self.chart_cache.clone()) .add_series_named( self.series_sin.clone(), StrokeStyle::new(2.0, Color::from_hex(COLOR_SIN)), @@ -162,6 +165,10 @@ impl Render for Demo { ); let drag_active = self.drag.is_some(); + let (pan_blits, rebuilds) = { + let c = self.chart_cache.lock().unwrap(); + (c.pan_blits(), c.rebuilds()) + }; div() .id("lapaloma-demo-root") @@ -200,7 +207,12 @@ impl Render for Demo { .child( div() .text_color(theme.fg_muted) - .child(if drag_active { "· dragging" } else { "" }), + .child(format!( + "· cache: {} pan-blits / {} rebuilds {}", + pan_blits, + rebuilds, + if drag_active { "· dragging" } else { "" }, + )), ), ) .child( diff --git a/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/element.rs b/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/element.rs index e389d7d..6a0a299 100644 --- a/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/element.rs +++ b/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/element.rs @@ -1,17 +1,30 @@ //! `LapalomaChartElement` — el `Element` GPUI que envuelve el //! pipeline cartesian. //! -//! Mantiene un `Vec` con `DataBuffer + StrokeStyle` -//! por serie. En `paint()` arma el `WindowCanvas` adapter, pinta el -//! background, los ejes con sus labels, y delega a una `LineSeries` -//! por cada item. Cada serie emite un solo `paint_path` — N series -//! = N draw calls totales (no N × por punto). +//! ## Picture cache pan-blit //! -//! Todas las series comparten viewport. Para Y axis dual (necesario -//! cuando series tienen rangos muy distintos) la API se va a -//! extender con `y_axis_id` por serie y un viewport segundario. +//! En GPUI cada frame se construye un Element nuevo (el árbol se +//! recrea), así que el cache no puede vivir en el Element. El +//! caller crea un `ChartCacheHandle` (Arc>) una +//! vez y se lo pasa a cada frame. +//! +//! Algoritmo (sección 4.4 del ARCHITECTURE.md adaptada a GPUI): +//! - Hash estructural = plot rect + span (no x_min/y_min) + por +//! serie: revision + len + stroke. +//! - Si hash igual al cached: **pan puro** → emitimos las coords +//! cacheadas con un offset `(dx_px, dy_px)` calculado del +//! diff `viewport.x_min - cached.x_min`. Saltea LTTB + +//! projection. +//! - Si hash distinto: full rebuild. Re-corre LTTB + project, +//! pisa el cache, actualiza el snapshot del viewport. +//! +//! Sin cache, el Element funciona igual: cada frame rebuild +//! completo. Útil para tests/smoke. +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; use std::panic; +use std::sync::{Arc, Mutex}; use gpui::{ App, Bounds, Element, ElementId, GlobalElementId, InspectorElementId, IntoElement, LayoutId, @@ -24,18 +37,58 @@ use lapaloma_render::{Canvas, Color, Point, Rect, StrokeStyle, WindowCanvas}; use crate::axis::{decimate_labels, format_tick, ticks_nice, AxisStyle}; use crate::coord_system::CoordinateSystem; -use crate::series::{LineSeries, PaintCtx, RenderMode, Series}; +use crate::series::LineSeries; use crate::viewport::ChartViewport; const TARGET_TICKS_X: usize = 8; const TARGET_TICKS_Y: usize = 6; - -/// Aproximación del ancho de glifo monoespaciado en función del -/// font size. Suficiente para alinear / decimar labels. const MONO_GLYPH_RATIO: f32 = 0.55; -/// Una serie del chart: data + estilo + nombre opcional (para -/// futura leyenda visual). +/// Cache de coords proyectadas para reuso entre frames. Es lo +/// que habilita el pan-blit: el caller lo crea una vez y lo +/// pasa por handle. +#[derive(Default, Debug)] +pub struct ChartCache { + /// Coords proyectadas por serie. `projected.len()` debe coincidir + /// con la cantidad de series del Element. + projected: Vec>, + /// Hash de la geometría + identidades de data. Si cambia, + /// invalidamos. + structural_hash: u64, + /// `viewport.x_min` con el que se proyectaron las coords. + cached_x_min: f64, + cached_y_min: f64, + /// Estadística informativa: cuántos pan-blits desde el último + /// rebuild. Útil para debugging y para mostrar en demos. + pan_blits: u64, + /// Estadística informativa: cuántos rebuilds totales. + rebuilds: u64, + has_valid_cache: bool, +} + +impl ChartCache { + pub fn new() -> Self { + Self::default() + } + pub fn pan_blits(&self) -> u64 { + self.pan_blits + } + pub fn rebuilds(&self) -> u64 { + self.rebuilds + } + pub fn invalidate(&mut self) { + *self = Self::default(); + } +} + +pub type ChartCacheHandle = Arc>; + +/// Atajo para crear un cache compartido. El caller lo guarda en +/// su `Render` host y le pasa el clone al Element en cada frame. +pub fn chart_cache() -> ChartCacheHandle { + Arc::new(Mutex::new(ChartCache::new())) +} + #[derive(Clone)] pub struct ChartSeriesItem { pub data: DataBuffer, @@ -47,7 +100,6 @@ impl ChartSeriesItem { pub fn new(data: DataBuffer, stroke: StrokeStyle) -> Self { Self { data, stroke, name: None } } - pub fn named(data: DataBuffer, stroke: StrokeStyle, name: impl Into) -> Self { Self { data, stroke, name: Some(name.into()) } } @@ -63,13 +115,13 @@ pub struct LapalomaChartElement { pub margin_left: f32, pub margin_top: f32, pub margin_right: f32, - /// Scratch reusable entre series y entre frames. + /// Cache opcional compartido con el `Render` host. Si está + /// presente, habilita pan-blit. + pub cache: Option, scratch: Vec, } impl LapalomaChartElement { - /// Construye un chart vacío con un viewport. Las series se - /// agregan con `add_series` / `add_series_named`. pub fn new(viewport: ChartViewport) -> Self { Self { series: Vec::new(), @@ -81,6 +133,7 @@ impl LapalomaChartElement { margin_left: 32.0, margin_top: 8.0, margin_right: 8.0, + cache: None, scratch: Vec::new(), } } @@ -118,6 +171,13 @@ impl LapalomaChartElement { self } + /// Enchufa un cache compartido. Sin esto, cada frame es rebuild + /// completo (correcto pero sin la optimización pan-blit). + pub fn with_cache(mut self, cache: ChartCacheHandle) -> Self { + self.cache = Some(cache); + self + } + fn plot_rect(&self, bounds: Rect) -> Rect { Rect::new( bounds.x + self.margin_left, @@ -145,7 +205,6 @@ impl LapalomaChartElement { axis_stroke, ); - // X axis ticks + labels. let x_ticks = ticks_nice(self.viewport.x_min, self.viewport.x_max, TARGET_TICKS_X); let x_step = nice_step(self.viewport.x_min, self.viewport.x_max, TARGET_TICKS_X); let mut x_pos: Vec = Vec::with_capacity(x_ticks.len()); @@ -180,7 +239,6 @@ impl LapalomaChartElement { ); } - // Y axis ticks + labels con decimación vertical. let y_ticks = ticks_nice(self.viewport.y_min, self.viewport.y_max, TARGET_TICKS_Y); let y_step = nice_step(self.viewport.y_min, self.viewport.y_max, TARGET_TICKS_Y); let y_label_pitch = style.label_size_px + style.label_min_spacing_px; @@ -216,6 +274,73 @@ impl LapalomaChartElement { prev_py = Some(py); } } + + /// Rebuild full: LTTB + projection por serie. Pinta directo desde + /// el cache si está enchufado (para no copiar dos veces). + fn rebuild_and_paint(&mut self, cs: &CoordinateSystem, canvas: &mut dyn Canvas) { + if let Some(handle) = self.cache.clone() { + let mut cache = handle.lock().unwrap(); + cache.projected.clear(); + cache.projected.resize_with(self.series.len(), Vec::new); + for (i, item) in self.series.iter().enumerate() { + let series = LineSeries::new(&item.data, item.stroke); + series.compute_projected(cs, &mut cache.projected[i]); + if cache.projected[i].len() >= 4 { + canvas.stroke_polyline(&cache.projected[i], item.stroke); + } + } + cache.structural_hash = structural_hash( + cs.plot, + self.viewport.x_span(), + self.viewport.y_span(), + &self.series, + ); + cache.cached_x_min = self.viewport.x_min; + cache.cached_y_min = self.viewport.y_min; + cache.has_valid_cache = true; + cache.pan_blits = 0; + cache.rebuilds = cache.rebuilds.wrapping_add(1); + } else { + // Sin cache: usamos el scratch local. + for item in &self.series { + let series = LineSeries::new(&item.data, item.stroke); + series.compute_projected(cs, &mut self.scratch); + if self.scratch.len() >= 4 { + canvas.stroke_polyline(&self.scratch, item.stroke); + } + } + } + } + + /// Emite las coords cacheadas con un offset en pixel space. + /// Se usa cuando detectamos pan puro (mismo hash estructural). + fn pan_blit_paint(&mut self, plot: Rect, canvas: &mut dyn Canvas) { + let Some(handle) = self.cache.clone() else { + return; + }; + let mut cache = handle.lock().unwrap(); + let dx_px = ((cache.cached_x_min - self.viewport.x_min) * plot.w as f64 + / self.viewport.x_span()) as f32; + let dy_px = ((self.viewport.y_min - cache.cached_y_min) * plot.h as f64 + / self.viewport.y_span()) as f32; + + for (i, item) in self.series.iter().enumerate() { + let cached = &cache.projected[i]; + if cached.len() < 4 { + continue; + } + self.scratch.clear(); + self.scratch.reserve(cached.len()); + let mut k = 0; + while k + 1 < cached.len() { + self.scratch.push(cached[k] + dx_px); + self.scratch.push(cached[k + 1] + dy_px); + k += 2; + } + canvas.stroke_polyline(&self.scratch, item.stroke); + } + cache.pan_blits = cache.pan_blits.wrapping_add(1); + } } impl IntoElement for LapalomaChartElement { @@ -232,7 +357,6 @@ impl Element for LapalomaChartElement { fn id(&self) -> Option { None } - fn source_location(&self) -> Option<&'static panic::Location<'static>> { None } @@ -288,22 +412,60 @@ impl Element for LapalomaChartElement { self.paint_axes(&mut canvas, &cs); - // Una pasada de paint por serie. Re-usamos el mismo scratch - // entre series — cada `LineSeries::paint` hace `clear()`. - for item in &self.series { - let series = LineSeries::new(&item.data, item.stroke); - let mut ctx = PaintCtx { - cs, - mode: RenderMode::UiRich, - scratch: &mut self.scratch, - }; - series.paint(&mut ctx, &mut canvas); + // Decide rebuild vs pan-blit. + let current_hash = structural_hash( + plot, + self.viewport.x_span(), + self.viewport.y_span(), + &self.series, + ); + let pan_only = self + .cache + .as_ref() + .map(|h| { + let c = h.lock().unwrap(); + c.has_valid_cache + && c.structural_hash == current_hash + && c.projected.len() == self.series.len() + }) + .unwrap_or(false); + + if pan_only { + self.pan_blit_paint(plot, &mut canvas); + } else { + self.rebuild_and_paint(&cs, &mut canvas); } } } -/// Helper de una sola línea. Para multi-series usar -/// `LapalomaChartElement::new(viewport).add_series(...).add_series(...)`. +/// Hash de la geometría + identidades de data. Lo que NO va acá: +/// `viewport.x_min` y `y_min` (el pan los mueve sin invalidar). +fn structural_hash( + plot: Rect, + x_span: f64, + y_span: f64, + series: &[ChartSeriesItem], +) -> u64 { + let mut h = DefaultHasher::new(); + plot.x.to_bits().hash(&mut h); + plot.y.to_bits().hash(&mut h); + plot.w.to_bits().hash(&mut h); + plot.h.to_bits().hash(&mut h); + x_span.to_bits().hash(&mut h); + y_span.to_bits().hash(&mut h); + (series.len() as u64).hash(&mut h); + for s in series { + s.data.revision().hash(&mut h); + (s.data.len() as u64).hash(&mut h); + s.stroke.width.to_bits().hash(&mut h); + s.stroke.color.r.to_bits().hash(&mut h); + s.stroke.color.g.to_bits().hash(&mut h); + s.stroke.color.b.to_bits().hash(&mut h); + s.stroke.color.a.to_bits().hash(&mut h); + } + h.finish() +} + pub fn lapaloma_chart( data: DataBuffer, viewport: ChartViewport, @@ -311,3 +473,85 @@ pub fn lapaloma_chart( ) -> LapalomaChartElement { LapalomaChartElement::new(viewport).add_series(data, stroke) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn structural_hash_estable_para_mismo_estado() { + let mut data = DataBuffer::with_capacity(10); + for i in 0..10 { + data.push(i as f32, (i as f32).sin()); + } + let series = vec![ChartSeriesItem::new( + data, + StrokeStyle::new(2.0, Color::rgb(1.0, 0.0, 0.0)), + )]; + let plot = Rect::new(0.0, 0.0, 100.0, 100.0); + let a = structural_hash(plot, 10.0, 2.0, &series); + let b = structural_hash(plot, 10.0, 2.0, &series); + assert_eq!(a, b); + } + + #[test] + fn structural_hash_pan_no_cambia() { + // Mismo span pero distinto x_min/y_min — hash igual. + let mut data = DataBuffer::with_capacity(10); + for i in 0..10 { + data.push(i as f32, (i as f32).sin()); + } + let series = vec![ChartSeriesItem::new( + data, + StrokeStyle::new(2.0, Color::rgb(1.0, 0.0, 0.0)), + )]; + let plot = Rect::new(0.0, 0.0, 100.0, 100.0); + let a = structural_hash(plot, 10.0, 2.0, &series); + // Pan implícito: x_span/y_span no cambiaron → hash igual. + let b = structural_hash(plot, 10.0, 2.0, &series); + assert_eq!(a, b); + } + + #[test] + fn structural_hash_zoom_invalida() { + let series = vec![ChartSeriesItem::new( + DataBuffer::new(), + StrokeStyle::new(2.0, Color::WHITE), + )]; + let plot = Rect::new(0.0, 0.0, 100.0, 100.0); + let a = structural_hash(plot, 10.0, 2.0, &series); + let b = structural_hash(plot, 5.0, 2.0, &series); // zoom in X + assert_ne!(a, b); + } + + #[test] + fn structural_hash_data_revision_invalida() { + let mut data = DataBuffer::with_capacity(2); + data.push(0.0, 0.0); + let series0 = vec![ChartSeriesItem::new( + data.clone(), + StrokeStyle::new(2.0, Color::WHITE), + )]; + let plot = Rect::new(0.0, 0.0, 100.0, 100.0); + let a = structural_hash(plot, 1.0, 1.0, &series0); + + data.push(1.0, 1.0); // bump revision + let series1 = vec![ChartSeriesItem::new( + data, + StrokeStyle::new(2.0, Color::WHITE), + )]; + let b = structural_hash(plot, 1.0, 1.0, &series1); + assert_ne!(a, b); + } + + #[test] + fn structural_hash_plot_rect_invalida() { + let series = vec![ChartSeriesItem::new( + DataBuffer::new(), + StrokeStyle::new(2.0, Color::WHITE), + )]; + let a = structural_hash(Rect::new(0.0, 0.0, 100.0, 100.0), 1.0, 1.0, &series); + let b = structural_hash(Rect::new(0.0, 0.0, 200.0, 100.0), 1.0, 1.0, &series); + assert_ne!(a, b); + } +} diff --git a/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/lib.rs b/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/lib.rs index 6e8acc8..475ae68 100644 --- a/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/lib.rs +++ b/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/lib.rs @@ -39,4 +39,7 @@ pub use coord_system::CoordinateSystem; pub use series::{LineSeries, PaintCtx, RenderMode, Series}; #[cfg(feature = "gpui")] -pub use element::{lapaloma_chart, LapalomaChartElement}; +pub use element::{ + chart_cache, lapaloma_chart, ChartCache, ChartCacheHandle, ChartSeriesItem, + LapalomaChartElement, +}; diff --git a/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/series.rs b/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/series.rs index 72d2b2e..f288c10 100644 --- a/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/series.rs +++ b/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/series.rs @@ -58,35 +58,42 @@ impl<'a> LineSeries<'a> { Self { data, stroke, lttb_target: None } } - fn effective_target(&self, plot_w: f32) -> usize { + pub fn effective_target(&self, plot_w: f32) -> usize { self.lttb_target.unwrap_or_else(|| (plot_w as usize).saturating_mul(3)) } -} -impl<'a> Series for LineSeries<'a> { - fn paint(&self, ctx: &mut PaintCtx<'_>, canvas: &mut dyn Canvas) { + /// Materializa las coords proyectadas a pixel space en `out`, + /// aplicando LTTB cuando densidad > target. `out` se clearea. + /// + /// Útil para callers que necesitan cachear el resultado + /// (picture cache pan-blit) sin pasar por `paint()`. + pub fn compute_projected(&self, cs: &CoordinateSystem, out: &mut Vec) { + out.clear(); if self.data.len() < 2 { return; } - - let target = self.effective_target(ctx.cs.plot.w); - - ctx.scratch.clear(); - + let target = self.effective_target(cs.plot.w); if self.data.len() > target { - // Decimar primero (en coords de dominio), proyectar después. - let mut idx = Vec::with_capacity(target); + let mut idx: Vec = Vec::with_capacity(target); lttb::lttb_indices(self.data.coords(), target, &mut idx); let mut decimated: Vec = Vec::with_capacity(idx.len() * 2); for i in idx { decimated.push(self.data.coords()[i * 2]); decimated.push(self.data.coords()[i * 2 + 1]); } - ctx.cs.project_buffer(&decimated, ctx.scratch); + cs.project_buffer(&decimated, out); } else { - ctx.cs.project_buffer(self.data.coords(), ctx.scratch); + cs.project_buffer(self.data.coords(), out); } + } +} +impl<'a> Series for LineSeries<'a> { + fn paint(&self, ctx: &mut PaintCtx<'_>, canvas: &mut dyn Canvas) { + self.compute_projected(&ctx.cs, ctx.scratch); + if ctx.scratch.len() < 4 { + return; + } canvas.stroke_polyline(ctx.scratch, self.stroke); }