collective

Browse / github.com/storytold/photocraft / patch

perf(curves): sample graphs directly and cache channel polylines

perf(curves): sample graphs directly and cache channel polylines Every Curves editor repaint built a 4,096-entry spline LUT for each visible point curve, then kept only every sixteenth sample for the graph. Composite view could repeat this for the composite and three channel overlays, even when none of the points had changed. ## Change - Reuse the existing natural spline through `curve_samples<const N>`, so the UI evaluates its 256 graph samples directly. Include both endpoints; the previous stride stopped short of the right edge. - Cache each channel's screen polyline in egui temporary memory under the editor's `cx.mem` ID. Compare point values and the f

Status
open
Score
0.00
Adopters
0 other operators
Author
wanikhawar (imported from pull request #9826, open)
Base
7ac7b8b106acccf6826d45ed5c9e481dfe341035
Size
+180 −14 in 4 files
Review
pending
Shared
2026-10-28
Id
c3c09758bd6dada70901ad5f

How it went

No other operator has reported using it yet.

Diff

raw · crates/compose/src/adjust.rs crates/compose/src/adjust/tone.rs crates/compose/src/tests.rs crates/ui-egui/src/dialogs/adjust_editors/curves.rs

diff --git a/crates/compose/src/adjust.rs b/crates/compose/src/adjust.rs--- a/crates/compose/src/adjust.rs+++ b/crates/compose/src/adjust.rs@@ -31,8 +31,8 @@ pub use color::{ use tone::LUT_SIZE; pub(crate) use tone::apply_tone; pub use tone::{-    TONE_SLOPE_SPAN, contour_curve_lut, curve_lut, curve_lut_mode, curve_map_lut, float_tone_extrapolates, float_tone_identity, levels, levels_float,-    levels_gamma, levels_q, modern_brightness, modern_contrast, modern_lut, posterize, posterize_in, tone_luts, tone_luts_depth, tone_luts_q,+    TONE_SLOPE_SPAN, contour_curve_lut, curve_lut, curve_lut_mode, curve_map_lut, curve_samples, float_tone_extrapolates, float_tone_identity, levels,+    levels_float, levels_gamma, levels_q, modern_brightness, modern_contrast, modern_lut, posterize, posterize_in, tone_luts, tone_luts_depth, tone_luts_q, }; use tone::{lut, map_rgb}; diff --git a/crates/compose/src/adjust/tone.rs b/crates/compose/src/adjust/tone.rs--- a/crates/compose/src/adjust/tone.rs+++ b/crates/compose/src/adjust/tone.rs@@ -402,11 +402,19 @@ pub fn curve_map_lut(points: &[CurvePoint]) -> Vec<f32> { /// overshoot between points, as Photoshop's does), constant outside the /// first/last point, clamped to 0..=1, sampled into a LUT. pub fn curve_lut(points: &[CurvePoint]) -> Vec<f32> {+    curve_samples::<LUT_SIZE>(points)+}++/// The same natural spline as [`curve_lut`], sampled directly at `N` evenly spaced inputs+/// including both endpoints. UI graphs use a smaller table without building the rendering LUT.+/// Zero samples returns an empty table; one sample evaluates at input zero.+pub fn curve_samples<const N: usize>(points: &[CurvePoint]) -> Vec<f32> {+    let last = N.saturating_sub(1).max(1) as f32;     let mut pts: Vec<(f32, f32)> = points.iter().map(|p| (p.input, p.output)).collect();     pts.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));     pts.dedup_by(|a, b| (a.0 - b.0).abs() < 1e-6);     if pts.len() < 2 {-        return (0..LUT_SIZE).map(|i| i as f32 / (LUT_SIZE - 1) as f32).collect();+        return (0..N).map(|i| i as f32 / last).collect();     }     let n = pts.len();     // Second derivatives of the natural spline (tridiagonal solve).@@ -430,9 +438,9 @@ pub fn curve_lut(points: &[CurvePoint]) -> Vec<f32> {             m2[i] = d[i] - c[i] * m2[i + 1];         }     }-    (0..LUT_SIZE)+    (0..N)         .map(|k| {-            let xv = k as f32 / (LUT_SIZE - 1) as f32;+            let xv = k as f32 / last;             if xv <= pts[0].0 {                 return pts[0].1.clamp(0.0, 1.0);             }diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs--- a/crates/compose/src/tests.rs+++ b/crates/compose/src/tests.rs@@ -834,6 +834,32 @@ fn threshold_posterize_levels_curves() {     assert!(s[s.len() / 4] < 0.25 && s[3 * s.len() / 4] > 0.75); } +#[test]+fn curve_samples_preserve_spline_and_endpoint_behavior() {+    use photocraft_doc::adjust::CurvePoint;+    let points = [CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 0.5, output: 1.0 }, CurvePoint { input: 1.0, output: 0.0 }];+    // Analytic natural spline: at x=1/4 and 3/4 the output is 11/16.+    assert_eq!(adjust::curve_samples::<5>(&points), vec![0.0, 0.6875, 1.0, 0.6875, 0.0]);+    let low = adjust::curve_samples::<256>(&points);+    let full = adjust::curve_lut(&points);+    assert_eq!(low.len(), 256);+    assert_eq!(full.len(), 4096);+    // Both grids share these inputs exactly (17/255 == 273/4095).+    for k in 0..=15 {+        assert_eq!(low[k * 17], full[k * 273]);+    }++    let unsorted = [CurvePoint { input: 0.75, output: 0.8 }, CurvePoint { input: 0.25, output: 0.2 }];+    let table = adjust::curve_samples::<5>(&unsorted);+    for (actual, expected) in table.iter().zip([0.2, 0.2, 0.5, 0.8, 0.8]) {+        assert!((actual - expected).abs() < 1e-6);+    }+    assert_eq!(adjust::curve_samples::<3>(&[]), vec![0.0, 0.5, 1.0]);+    assert_eq!(adjust::curve_samples::<3>(&points[..1]), vec![0.0, 0.5, 1.0]);+    assert!(adjust::curve_samples::<0>(&points).is_empty());+    assert_eq!(adjust::curve_samples::<1>(&points), vec![0.0]);+}+ #[test] fn hue_saturation_roundtrips_and_desaturates() {     let c = [0.8, 0.3, 0.1];diff --git a/crates/ui-egui/src/dialogs/adjust_editors/curves.rs b/crates/ui-egui/src/dialogs/adjust_editors/curves.rs--- a/crates/ui-egui/src/dialogs/adjust_editors/curves.rs+++ b/crates/ui-egui/src/dialogs/adjust_editors/curves.rs@@ -103,6 +103,47 @@ pub(super) const CURVE_HANDLE_SIZE: f32 = 7.0;  const CURVE_GRAPH_PADDING: f32 = 5.0; +const CURVE_SAMPLES: usize = 256;++/// One entry per editor/channel, independent of selection, tint and stroke width.+#[derive(Clone, Default)]+struct CurvePolyline {+    points: Vec<[f32; 2]>,+    graph: Option<Rect>,+    line: Vec<Pos2>,+}++impl CurvePolyline {+    /// Returns whether the screen geometry changed. Moving the graph also invalidates screen+    /// coordinates (for example when scrolling the Properties panel or moving the dialog).+    fn update(&mut self, points: &[[f32; 2]], graph: Rect) -> bool {+        if self.points == points && self.graph == Some(graph) {+            return false;+        }+        let cp: Vec<photocraft_doc::adjust::CurvePoint> =+            points.iter().map(|q| photocraft_doc::adjust::CurvePoint { input: q[0] / 255.0, output: q[1] / 255.0 }).collect();+        self.line = photocraft_compose::adjust::curve_samples::<CURVE_SAMPLES>(&cp)+            .into_iter()+            .enumerate()+            .map(|(i, y)| pos2(graph.left() + i as f32 / (CURVE_SAMPLES - 1) as f32 * graph.width(), graph.bottom() - y * graph.height()))+            .collect();+        self.points.clear();+        self.points.extend_from_slice(points);+        self.graph = Some(graph);+        true+    }+}++fn cached_curve_line(ctx: &egui::Context, id: egui::Id, points: &[[f32; 2]], graph: Rect) -> Vec<Pos2> {+    ctx.data_mut(|d| {+        let cache = d.get_temp_mut_or_default::<CurvePolyline>(id);+        cache.update(points, graph);+        // egui owns the submitted path. Only this small copy is needed on a cache hit;+        // normalization, spline solving, sampling and screen mapping are all skipped.+        cache.line.clone()+    })+}+ /// The room left on screen for the Curves dialog's other rows (title, channel and mode, Input and /// Output, eyedroppers, buttons) when its graph is as tall as it can be. const CURVES_CHROME: f32 = 360.0;@@ -287,13 +328,8 @@ pub(super) fn curves(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit {     let vbar = Rect::from_min_max(pos2(full.left(), graph.top()), pos2(full.left() + 8.0, graph.bottom()));     gradient_bar(&p, hbar, Color32::BLACK, bar_color(&chan, &t), false);     gradient_bar(&p, vbar, Color32::BLACK, bar_color(&chan, &t), true);-    let draw_curve = |pts: &[[f32; 2]], color: Color32, width: f32| {-        let cp: Vec<photocraft_doc::adjust::CurvePoint> =-            pts.iter().map(|q| photocraft_doc::adjust::CurvePoint { input: q[0] / 255.0, output: q[1] / 255.0 }).collect();-        let lut = photocraft_compose::adjust::curve_lut(&cp);-        let n = lut.len().max(2);-        let line: Vec<Pos2> =-            (0..n).step_by((n / 256).max(1)).map(|i| to_scr([i as f32 / (n - 1) as f32 * 255.0, lut.get(i).copied().unwrap_or(0.0) * 255.0])).collect();+    let draw_curve = |key: &str, pts: &[[f32; 2]], color: Color32, width: f32| {+        let line = cached_curve_line(ui.ctx(), cx.mem.with(("curves-polyline", key)), pts, graph);         p.add(egui::Shape::line(line, Stroke::new(width, color)));     };     let draw_map = |map: &[f32], color: Color32, width: f32| {@@ -313,7 +349,7 @@ pub(super) fn curves(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit {             }             let cp = read_curve(v, c.key);             if cp.len() > 2 || cp.first().is_some_and(|q| q[1] != 0.0) || cp.last().is_some_and(|q| q[1] != 255.0) {-                draw_curve(&cp, bar_color(c, &t).gamma_multiply(0.8), 1.0);+                draw_curve(c.key, &cp, bar_color(c, &t).gamma_multiply(0.8), 1.0);             }         }     }@@ -321,7 +357,7 @@ pub(super) fn curves(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit {     if pencil {         draw_map(&map, color, 1.5);     } else {-        draw_curve(&pts, color, 1.5);+        draw_curve(key, &pts, color, 1.5);     }     for (i, q) in pts.iter().enumerate().filter(|_| !pencil) {         let r = Rect::from_center_size(to_scr(*q), vec2(CURVE_HANDLE_SIZE, CURVE_HANDLE_SIZE));@@ -383,3 +419,99 @@ pub(super) fn curves(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit {     }     e }++#[cfg(test)]+mod polyline_tests {+    use super::*;++    #[test]+    fn curve_polyline_reuses_static_geometry_and_refreshes_edits_and_rect() {+        let mut cache = CurvePolyline::default();+        let mut points = [[0.0, 0.0], [128.0, 180.0], [255.0, 255.0]];+        let graph = Rect::from_min_size(pos2(20.0, 30.0), vec2(256.0, 256.0));+        assert!(cache.update(&points, graph));+        assert_eq!(cache.line.len(), CURVE_SAMPLES);+        assert_eq!(cache.line.first(), Some(&graph.left_bottom()));+        assert_eq!(cache.line.last(), Some(&graph.right_top()));+        let line = cache.line.clone();+        assert!(!cache.update(&points, graph));+        assert_eq!(cache.line, line);++        points[1][1] = 80.0;+        assert!(cache.update(&points, graph));+        assert_ne!(cache.line, line);+        points[1][1] = 180.0; // Undo/preset restore must also invalidate.+        assert!(cache.update(&points, graph));+        assert_eq!(cache.line, line);++        let moved = graph.translate(vec2(13.0, 25.0));+        assert!(cache.update(&points, moved));+        for (before, after) in line.iter().zip(&cache.line) {+            assert!((*after - (*before + vec2(13.0, 25.0))).length() < 1e-4);+        }+        let resized = Rect::from_min_size(moved.min, vec2(128.0, 180.0));+        assert!(cache.update(&points, resized));+        assert_eq!(cache.line.last(), Some(&resized.right_top()));+        assert!(!cache.update(&points, resized));+    }++    #[test]+    fn curve_polyline_memory_is_separate_per_editor_and_channel() {+        let ctx = egui::Context::default();+        let graph = Rect::from_min_size(Pos2::ZERO, vec2(256.0, 256.0));+        let identity = [[0.0, 0.0], [255.0, 255.0]];+        let inverted = [[0.0, 255.0], [255.0, 0.0]];+        let editor = egui::Id::unique("dialog");+        let master = editor.with(("curves-polyline", "points"));+        let red = editor.with(("curves-polyline", "red"));+        let other_editor = egui::Id::unique("properties").with(("curves-polyline", "points"));+        let line = cached_curve_line(&ctx, master, &identity, graph);+        assert_ne!(cached_curve_line(&ctx, red, &inverted, graph), line);+        assert_ne!(cached_curve_line(&ctx, other_editor, &inverted, graph), line);+        assert_eq!(cached_curve_line(&ctx, master, &identity, graph), line);+        ctx.data_mut(|d| {+            assert!(!d.get_temp_mut_or_default::<CurvePolyline>(master).update(&identity, graph));+            assert!(!d.get_temp_mut_or_default::<CurvePolyline>(red).update(&inverted, graph));+            assert!(!d.get_temp_mut_or_default::<CurvePolyline>(other_editor).update(&inverted, graph));+        });+        assert_ne!(cached_curve_line(&ctx, master, &inverted, graph), line);+    }++    #[test]+    #[ignore = "release timing probe; run on GitHub Actions with --release --ignored --nocapture"]+    fn curve_polyline_release_comparison() {+        use std::{hint::black_box, time::Instant};+        let graph = Rect::from_min_size(pos2(20.0, 30.0), vec2(300.0, 300.0));+        let points = [[0.0, 0.0], [64.0, 48.0], [192.0, 208.0], [255.0, 255.0]];+        let iterations = 10_000;+        let start = Instant::now();+        for _ in 0..iterations {+            let cp: Vec<_> = black_box(&points).iter().map(|q| photocraft_doc::adjust::CurvePoint { input: q[0] / 255.0, output: q[1] / 255.0 }).collect();+            let lut = photocraft_compose::adjust::curve_lut(&cp);+            let line: Vec<_> = lut+                .iter()+                .enumerate()+                .step_by(16)+                .map(|(i, y)| pos2(graph.left() + i as f32 / 4095.0 * graph.width(), graph.bottom() - y * graph.height()))+                .collect();+            black_box(line);+        }+        let old = start.elapsed();+        let start = Instant::now();+        for _ in 0..iterations {+            let mut cache = CurvePolyline::default();+            cache.update(black_box(&points), graph);+            black_box(cache.line.clone());+        }+        let miss = start.elapsed();+        let ctx = egui::Context::default();+        let id = egui::Id::unique("curve-perf");+        cached_curve_line(&ctx, id, &points, graph);+        let start = Instant::now();+        for _ in 0..iterations {+            black_box(cached_curve_line(&ctx, id, black_box(&points), graph));+        }+        let hit = start.elapsed();+        println!("{iterations} polylines: old 4096 samples {old:?}; direct 256 samples {miss:?}; cached screen line {hit:?}");+    }+}