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 <echelon@gmail.com>
This commit is contained in:
Dylan M. TaylorandBrandon Thomas authored and GitHub committed 2026-11-03 00:12:58 -05:00
1 parent ad20c56874
commit 8717728a87
3 files changed
+65 -19

No files matched your search

+24 -18
View File
@@ -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),
}
}
+38
View File
@@ -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());
+3 -1
View File
@@ -79,11 +79,13 @@ fn diamond_pixmap(g: &Gradient, opacity: f32) -> Option<Arc<Pixmap>> {
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]);