feat(lapaloma-cartesian): picture cache pan-blit
- ChartCache + ChartCacheHandle (Arc<Mutex<...>>) 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 <noreply@anthropic.com>
2b8e990cf9df64441ba021f2e9fa80b01ae9544d · por sergio · 2026-05-13T03:12:02Z (hace 5 meses) · padre ab03a61db · ver el árbol en este commit
Delta semántico · 26
crates/apps/lapaloma-demo/src/main.rs
cambió de firma struct Demo cambió por dentro fn new cambió por dentro fn render
crates/modules/ui_engine/widgets/lapaloma-cartesian/src/element.rs
crates/modules/ui_engine/widgets/lapaloma-cartesian/src/series.rs
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<DragAnchor>,
+ 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<ChartSeriesItem>` 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<Mutex<ChartCache>>) 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<Vec<f32>>,
+ /// 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<Mutex<ChartCache>>;
+
+/// 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<String>) -> 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<ChartCacheHandle>,
scratch: Vec<f32>,
}
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<f32> = 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<ElementId> {
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<f32>) {
+ 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<usize> = Vec::with_capacity(target);
lttb::lttb_indices(self.data.coords(), target, &mut idx);
let mut decimated: Vec<f32> = 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);
}