From 8717728a876f8bac3192a6ea204ee4e229253334 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 00:12:58 -0500 Subject: [PATCH] perf(color): sort gradient stops once per diamond texture (#1894) Gradient::sample cloned, sanitised and sorted every stop on each call, and the diamond texture sampled it for each of its 65 536 pixels, so a file's gradient with many stops cost that sort 65 536 times. sample_sorted samples stops already sorted, finding the segment by binary search; sample delegates to it and the diamond texture sorts once. Co-authored-by: Brandon Thomas --- crates/color/src/lib.rs | 42 ++++++++++++++++++++++---------------- crates/color/src/tests.rs | 38 ++++++++++++++++++++++++++++++++++ crates/render/src/paint.rs | 4 +++- 3 files changed, 65 insertions(+), 19 deletions(-) diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 190fdbb9c..d47f035b3 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -376,26 +376,32 @@ impl Gradient { s } - /// The colour at `t` (0..1) along the gradient. + /// The colour at `t` (0..1) along the gradient. Sorts the stops on every call: to sample one + /// gradient many times, sort once with [`Self::sorted_stops`] and use [`sample_sorted`]. pub fn sample(&self, t: f32) -> Color { - let stops = self.sorted_stops(); - let t = clamp01(t); - let mut prev = match stops.first() { - Some(s) => *s, - None => return Color::BLACK, - }; - if t <= prev.position { - return prev.color; + sample_sorted(&self.sorted_stops(), t) + } +} + +/// The colour at `t` (0..1) along stops already sorted by [`Gradient::sorted_stops`]: found by a +/// binary search, so a renderer sampling a file's gradient per pixel doesn't scan or sort its +/// stops each time. +pub fn sample_sorted(stops: &[ColorStop], t: f32) -> Color { + let t = clamp01(t); + let Some(first) = stops.first() else { return Color::BLACK }; + if t <= first.position { + return first.color; + } + // The first stop after the first one at or past `t`, and the stop before it. + let rest = stops.get(1..).unwrap_or_default(); + let i = rest.partition_point(|s| s.position < t); + match (rest.get(i), stops.get(i)) { + (Some(s), Some(prev)) => { + let span = s.position - prev.position; + let f = if span > 1e-6 { (t - prev.position) / span } else { 1.0 }; + prev.color.lerp(s.color, f) } - for s in stops.iter().skip(1) { - if t <= s.position { - let span = s.position - prev.position; - let f = if span > 1e-6 { (t - prev.position) / span } else { 1.0 }; - return prev.color.lerp(s.color, f); - } - prev = *s; - } - prev.color + _ => stops.last().map_or(Color::BLACK, |s| s.color), } } diff --git a/crates/color/src/tests.rs b/crates/color/src/tests.rs index a41d0dd2e..e28e1bb10 100644 --- a/crates/color/src/tests.rs +++ b/crates/color/src/tests.rs @@ -59,6 +59,44 @@ fn gradient_sampling() { assert_eq!(g.sample(0.5).to_hex(), "808080"); } +#[test] +fn sampling_sorted_stops_matches_a_scan() { + // The scan `sample` used to make: the first stop at or past `t`, mixed with the one before. + fn scan(stops: &[ColorStop], t: f32) -> Color { + let t = if t.is_nan() { 0.0 } else { t.clamp(0.0, 1.0) }; + let mut prev = stops[0]; + if t <= prev.position { + return prev.color; + } + for s in &stops[1..] { + if t <= s.position { + let span = s.position - prev.position; + let f = if span > 1e-6 { (t - prev.position) / span } else { 1.0 }; + return prev.color.lerp(s.color, f); + } + prev = *s; + } + prev.color + } + let grey = |v: f32| Color { r: v, g: v, b: v, a: 1.0 }; + let positions = [0.3, 0.0, 0.3, 1.0, 0.7, f32::NAN, 0.5, 0.5, 0.9]; + let g = Gradient { + kind: GradientKind::Diamond, + stops: positions.iter().enumerate().map(|(i, p)| ColorStop { position: *p, color: grey(i as f32 / 10.0) }).collect(), + from: (0.5, 0.5), + to: (1.0, 0.5), + width: (0.5, 1.0), + }; + let sorted = g.sorted_stops(); + for i in -10..=110 { + let t = i as f32 / 100.0; + assert_eq!(sample_sorted(&sorted, t), scan(&sorted, t), "t = {t}"); + assert_eq!(g.sample(t), scan(&sorted, t), "t = {t}"); + } + assert_eq!(sample_sorted(&sorted, f32::NAN), scan(&sorted, f32::NAN)); + assert_eq!(sample_sorted(&[], 0.5), Color::BLACK); +} + #[test] fn paint_serde_shape() { let p = Paint::solid(Color::from_hex("FF0000").unwrap()); diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index a6c059480..56de03464 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -79,11 +79,13 @@ fn diamond_pixmap(g: &Gradient, opacity: f32) -> Option> { const N: u32 = 256; let mut p = Pixmap::new(N, N)?; let half = f64::from(N) / 2.0; + // Sorted once for the 65 536 samples below, not once per sample. + let stops = g.sorted_stops(); for (i, px) in p.data_mut().as_chunks_mut::<4>().0.iter_mut().enumerate() { let x = ((i as u32 % N) as f64 + 0.5 - half) / half; let y = ((i as u32 / N) as f64 + 0.5 - half) / half; let t = (x.abs() + y.abs()) as f32; - let c = g.sample(t); + let c = figcraft_color::sample_sorted(&stops, t); let a = c.a * opacity; let q = |v: f32| (v * a * 255.0).round().clamp(0.0, 255.0) as u8; px.copy_from_slice(&[q(c.r), q(c.g), q(c.b), (a * 255.0).round().clamp(0.0, 255.0) as u8]);