From ffe1ee62acb12b64ade63a1644b7511735e6b06c Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 11:45:43 +0000 Subject: [PATCH] fix: gradients drawn pixel by pixel find their stops by binary search --- crates/affinity/src/overlay.rs | 46 +++++++++++++++++++----------- crates/color/src/gradient.rs | 6 ++++ crates/color/src/gradient/tests.rs | 15 ++++++++++ crates/metafile/src/scene.rs | 3 +- 4 files changed, 52 insertions(+), 18 deletions(-) diff --git a/crates/affinity/src/overlay.rs b/crates/affinity/src/overlay.rs index 170c50ec3..80f54f68e 100644 --- a/crates/affinity/src/overlay.rs +++ b/crates/affinity/src/overlay.rs @@ -332,7 +332,7 @@ fn pixels(img: &mut Image, o: &Overlay) -> bool { }; let to_unit = match o.source { Source::Gradient(g) => match invert(g.transform) { - Some(m) => Some((img.transform.then(m), g)), + Some(m) => Some((img.transform.then(m), g, stop_reach(g))), None => return false, }, Source::Solid(_) => None, @@ -343,9 +343,9 @@ fn pixels(img: &mut Image, o: &Overlay) -> bool { } let (s, sa) = match (solid, &to_unit) { (Some(s), _) => s, - (None, Some((m, g))) => { + (None, Some((m, g, reach))) => { let q = m.apply(Point { x: (i % width) as f64 + 0.5, y: (i / width) as f64 + 0.5 }); - let c = sample(g, q); + let c = sample(g, reach, q); (rgb(c), c.alpha()) } (None, None) => continue, @@ -376,8 +376,22 @@ fn decode(bytes: &[u8], width: u32, height: u32) -> Option> { Some(reader.decode().ok()?.into_rgba8().into_raw()) } -/// The gradient's colour at unit-space point `q`. -fn sample(g: &Gradient, q: Point) -> Color { +/// For each pair of neighbouring stops (`k`, `k + 1`), the largest offset of stops `1..=k + 1`: +/// the first pair whose end offset a `t` doesn't pass is the first whose reach it doesn't pass, and +/// reaches never decrease, so [`sample`] finds it by binary search, sorted stops or not. +fn stop_reach(g: &Gradient) -> Vec { + g.stops + .iter() + .skip(1) + .scan(f64::NEG_INFINITY, |m, s| { + *m = m.max(s.offset); + Some(*m) + }) + .collect() +} + +/// The gradient's colour at unit-space point `q` (`reach`: [`stop_reach`] of `g`). +fn sample(g: &Gradient, reach: &[f64], q: Point) -> Color { let t = match g.kind { GradientKind::Linear => q.x, GradientKind::Radial => q.x.hypot(q.y), @@ -388,18 +402,16 @@ fn sample(g: &Gradient, q: Point) -> Color { if t <= first.offset { return first.color; } - for w in g.stops.windows(2) { - let [a, b] = [&w[0], &w[1]]; - if t <= b.offset { - let span = b.offset - a.offset; - let u = if span > 1e-9 { (t - a.offset) / span } else { 1.0 }; - // Affinity's midpoint bias (see `Stop::half_point`). - let k = 1.0 + 8.0 * (a.bias - 0.5).abs(); - let u = if a.bias >= 0.5 { u.powf(k) } else { 1.0 - (1.0 - u).powf(k) }; - let (x, y) = (rgb(a.color), rgb(b.color)); - let [r, g, bl] = [0, 1, 2].map(|i| x[i] + (y[i] - x[i]) * u); - return Color::Rgb { r, g, b: bl, a: a.color.alpha() + (b.color.alpha() - a.color.alpha()) * u }; - } + let k = reach.partition_point(|r| *r < t); + if let (Some(a), Some(b)) = (g.stops.get(k), g.stops.get(k + 1)) { + let span = b.offset - a.offset; + let u = if span > 1e-9 { (t - a.offset) / span } else { 1.0 }; + // Affinity's midpoint bias (see `Stop::half_point`). + let k = 1.0 + 8.0 * (a.bias - 0.5).abs(); + let u = if a.bias >= 0.5 { u.powf(k) } else { 1.0 - (1.0 - u).powf(k) }; + let (x, y) = (rgb(a.color), rgb(b.color)); + let [r, g, bl] = [0, 1, 2].map(|i| x[i] + (y[i] - x[i]) * u); + return Color::Rgb { r, g, b: bl, a: a.color.alpha() + (b.color.alpha() - a.color.alpha()) * u }; } last.color } diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs index 8441ebcc5..652db3340 100644 --- a/crates/color/src/gradient.rs +++ b/crates/color/src/gradient.rs @@ -245,6 +245,12 @@ impl Gradient { pub fn sample(&self, t: f32) -> (Color, f32) { self.sample_with(t, |a, b, v| self.method.mix(a, b, v)) } + /// [`Self::sample`] for many points in no order (a gradient drawn pixel by pixel): the stops + /// are prepared once, so each sample costs a binary search rather than a scan of the stops. + pub fn sampler(&self) -> impl Fn(f32) -> (Color, f32) + '_ { + let reach = StopReach::new(&self.stops); + move |t| reach.sample_with(&self.stops, t, &|a, b, v| self.method.mix(a, b, v)) + } /// [`Self::sample`], a Classic gradient's neighbouring stop colours of one model mixed in it /// (CMYK stays CMYK; [`Color::lerp_keep_model`]) rather than in display RGB, a Perceptual /// gradient's as [`Self::sample`] mixes them. diff --git a/crates/color/src/gradient/tests.rs b/crates/color/src/gradient/tests.rs index da9a3f2cc..1e75feb33 100644 --- a/crates/color/src/gradient/tests.rs +++ b/crates/color/src/gradient/tests.rs @@ -23,6 +23,21 @@ fn increasing_samples_match_single_samples() { assert_eq!(g.sample_increasing_with(ts, Color::lerp_keep_model), one_by_one); } +/// The prepared sampler gives what sampling each position alone does, out-of-order stops included. +#[test] +fn the_sampler_matches_single_samples() { + let stop = |offset: f32, hex: &str| GradientStop::new(offset, Color::from_hex(hex).unwrap()); + let g = Gradient::new( + GradientKind::Linear, + vec![stop(0.1, "#ff0000"), stop(0.4, "#00ff00"), stop(0.4, "#0000ff"), stop(0.2, "#ffff00"), stop(0.9, "#000000")], + ); + let sample = g.sampler(); + for k in (0..=40).rev() { + let t = k as f32 / 40.0; + assert_eq!(sample(t), g.sample(t), "{t}"); + } +} + /// Looking stops up by their reach finds what scanning them does, for any order of offsets, /// repeated, out-of-order and NaN ones included, and for positions outside and between them. #[test] diff --git a/crates/metafile/src/scene.rs b/crates/metafile/src/scene.rs index 7dea2afba..36aa663d8 100644 --- a/crates/metafile/src/scene.rs +++ b/crates/metafile/src/scene.rs @@ -412,6 +412,7 @@ impl<'a> Scene<'a> { field.grid(area, cols, rows).flat_map(|(c, a)| [q(c[0]), q(c[1]), q(c[2]), q(a)]).collect() } else { let geom = g.resolve(bounds); + let sample = g.gradient.sampler(); let (cw, ch) = (area.width() / f64::from(cols), area.height() / f64::from(rows)); (0..usize::from(rows) * usize::from(cols)) .flat_map(|i| { @@ -419,7 +420,7 @@ impl<'a> Scene<'a> { let p = Point::new(area.x0 + (x as f64 + 0.5) * cw, area.y0 + (y as f64 + 0.5) * ch); let t = geom.param_at(g.gradient.kind, p); let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 }; - let (c, a) = g.gradient.sample(t as f32); + let (c, a) = sample(t as f32); c.to_rgba8(a) }) .collect()