From b4f46e11f4219daca6d832208375f58efc7450f2 Mon Sep 17 00:00:00 2001 From: wmedrano Date: Mon, 26 Jan 2026 11:13:49 -0800 Subject: [PATCH 1/3] Implement sweep gradient --- src/lib.rs | 2 +- src/pipeline/highp.rs | 29 +++++ src/pipeline/lowp.rs | 1 + src/pipeline/mod.rs | 1 + src/shaders/mod.rs | 24 +++- src/shaders/sweep_gradient.rs | 105 ++++++++++++++++++ .../images/gradients/sweep-gradient-full.png | Bin 0 -> 9939 bytes tests/images/gradients/sweep-gradient.png | Bin 0 -> 6172 bytes tests/integration/gradients.rs | 64 +++++++++++ 9 files changed, 220 insertions(+), 6 deletions(-) create mode 100644 src/shaders/sweep_gradient.rs create mode 100644 tests/images/gradients/sweep-gradient-full.png create mode 100644 tests/images/gradients/sweep-gradient.png diff --git a/src/lib.rs b/src/lib.rs index 718b4ed..6660881 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -63,7 +63,7 @@ pub use mask::{Mask, MaskType}; pub use painter::{FillRule, Paint}; pub use pixmap::{Pixmap, PixmapMut, PixmapRef, BYTES_PER_PIXEL}; pub use shaders::{FilterQuality, GradientStop, PixmapPaint, SpreadMode}; -pub use shaders::{LinearGradient, Pattern, RadialGradient, Shader}; +pub use shaders::{LinearGradient, Pattern, RadialGradient, Shader, SweepGradient}; pub use tiny_skia_path::{IntRect, IntSize, NonZeroRect, Point, Rect, Size, Transform}; pub use tiny_skia_path::{LineCap, LineJoin, Stroke, StrokeDash}; diff --git a/src/pipeline/highp.rs b/src/pipeline/highp.rs index b0df476..214e31a 100644 --- a/src/pipeline/highp.rs +++ b/src/pipeline/highp.rs @@ -113,6 +113,7 @@ pub const STAGES: &[StageFn; super::STAGES_COUNT] = &[ repeat_x1, gradient, evenly_spaced_2_stop_gradient, + xy_to_unit_angle, xy_to_radius, xy_to_2pt_conical_focal_on_circle, xy_to_2pt_conical_well_behaved, @@ -988,6 +989,34 @@ fn evenly_spaced_2_stop_gradient(p: &mut Pipeline) { p.next_stage(); } +fn xy_to_unit_angle(p: &mut Pipeline) { + let x = p.r; + let y = p.g; + let x_abs = x.abs(); + let y_abs = y.abs(); + let slope = x_abs.min(y_abs) / x_abs.max(y_abs); + let s = slope * slope; + // Use a 7th degree polynomial to approximate atan. + // This was generated using sollya.gforge.inria.fr. + // A float optimized polynomial was generated using the following command. + // P1 = fpminimax((1/(2*Pi))*atan(x),[|1,3,5,7|],[|24...|],[2^(-40),1],relative); + let phi = slope + * (f32x8::splat(0.15912117063999176025390625) + + s * (f32x8::splat(-5.185396969318389892578125e-2) + + s * (f32x8::splat(2.476101927459239959716796875e-2) + + s * (f32x8::splat(-7.0547382347285747528076171875e-3))))); + let phi = x_abs.cmp_lt(y_abs).blend(f32x8::splat(0.25) - phi, phi); + let phi = x + .cmp_lt(f32x8::splat(0.0)) + .blend(f32x8::splat(0.5) - phi, phi); + let phi = y + .cmp_lt(f32x8::splat(0.0)) + .blend(f32x8::splat(1.0) - phi, phi); + let phi = phi.cmp_ne(phi).blend(f32x8::splat(0.0), phi); + p.r = phi; + p.next_stage(); +} + fn xy_to_radius(p: &mut Pipeline) { let x2 = p.r * p.r; let y2 = p.g * p.g; diff --git a/src/pipeline/lowp.rs b/src/pipeline/lowp.rs index e3869cc..af9e1e7 100644 --- a/src/pipeline/lowp.rs +++ b/src/pipeline/lowp.rs @@ -126,6 +126,7 @@ pub const STAGES: &[StageFn; super::STAGES_COUNT] = &[ repeat_x1, gradient, evenly_spaced_2_stop_gradient, + null_fn, // XYToUnitAngle xy_to_radius, null_fn, // XYTo2PtConicalFocalOnCircle null_fn, // XYTo2PtConicalWellBehaved diff --git a/src/pipeline/mod.rs b/src/pipeline/mod.rs index 7e7e2ac..c4c0148 100644 --- a/src/pipeline/mod.rs +++ b/src/pipeline/mod.rs @@ -124,6 +124,7 @@ pub enum Stage { RepeatX1, Gradient, EvenlySpaced2StopGradient, + XYToUnitAngle, XYToRadius, XYTo2PtConicalFocalOnCircle, XYTo2PtConicalWellBehaved, diff --git a/src/shaders/mod.rs b/src/shaders/mod.rs index 27c452a..900ab68 100644 --- a/src/shaders/mod.rs +++ b/src/shaders/mod.rs @@ -8,6 +8,7 @@ mod gradient; mod linear_gradient; mod pattern; mod radial_gradient; +mod sweep_gradient; use tiny_skia_path::{NormalizedF32, Scalar}; @@ -15,6 +16,7 @@ pub use gradient::GradientStop; pub use linear_gradient::LinearGradient; pub use pattern::{FilterQuality, Pattern, PixmapPaint}; pub use radial_gradient::RadialGradient; +pub use sweep_gradient::SweepGradient; use crate::{Color, ColorSpace, Transform}; @@ -52,6 +54,8 @@ pub enum Shader<'a> { LinearGradient(LinearGradient), /// A radial gradient shader. RadialGradient(RadialGradient), + /// A sweep gradient shader. + SweepGradient(SweepGradient), /// A pattern shader. Pattern(Pattern<'a>), } @@ -60,9 +64,12 @@ impl Shader<'_> { /// Checks if the shader is guaranteed to produce only opaque colors. pub fn is_opaque(&self) -> bool { match self { - Shader::SolidColor(ref c) => c.is_opaque(), - Shader::LinearGradient(ref g) => g.is_opaque(), + Shader::SolidColor(c) => c.is_opaque(), + Shader::LinearGradient(g) => g.is_opaque(), + // A radial gradient may have points that are "undefined" so we just assume that it is + // not opaque. Shader::RadialGradient(_) => false, + Shader::SweepGradient(g) => g.is_opaque(), Shader::Pattern(_) => false, } } @@ -78,9 +85,10 @@ impl Shader<'_> { p.push_uniform_color(color); true } - Shader::LinearGradient(ref g) => g.push_stages(cs, p), - Shader::RadialGradient(ref g) => g.push_stages(cs, p), - Shader::Pattern(ref patt) => patt.push_stages(cs, p), + Shader::LinearGradient(g) => g.push_stages(cs, p), + Shader::RadialGradient(g) => g.push_stages(cs, p), + Shader::SweepGradient(g) => g.push_stages(cs, p), + Shader::Pattern(patt) => patt.push_stages(cs, p), } } @@ -94,6 +102,9 @@ impl Shader<'_> { Shader::RadialGradient(g) => { g.base.transform = g.base.transform.post_concat(ts); } + Shader::SweepGradient(g) => { + g.base.transform = g.base.transform.post_concat(ts); + } Shader::Pattern(p) => { p.transform = p.transform.post_concat(ts); } @@ -124,6 +135,9 @@ impl Shader<'_> { Shader::RadialGradient(g) => { g.base.apply_opacity(opacity); } + Shader::SweepGradient(g) => { + g.base.apply_opacity(opacity); + } Shader::Pattern(ref mut p) => { p.opacity = NormalizedF32::new(p.opacity.get() * opacity.bound(0.0, 1.0)).unwrap(); } diff --git a/src/shaders/sweep_gradient.rs b/src/shaders/sweep_gradient.rs new file mode 100644 index 0000000..d090aa4 --- /dev/null +++ b/src/shaders/sweep_gradient.rs @@ -0,0 +1,105 @@ +// Copyright 2006 The Android Open Source Project +// +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +use alloc::vec::Vec; + +use tiny_skia_path::Scalar; + +use crate::{ColorSpace, GradientStop, Point, Shader, SpreadMode, Transform}; + +use super::gradient::{Gradient, DEGENERATE_THRESHOLD}; +use crate::pipeline::{RasterPipelineBuilder, Stage}; + +#[cfg(all(not(feature = "std"), feature = "no-std-float"))] +use tiny_skia_path::NoStdFloat; + +/// A radial gradient. +#[derive(Clone, PartialEq, Debug)] +pub struct SweepGradient { + pub(crate) base: Gradient, + t0: f32, + t1: f32, +} + +impl SweepGradient { + /// Creates a new 2-point conical gradient shader. + #[allow(clippy::new_ret_no_self)] + pub fn new( + center: Point, + start_angle: f32, + end_angle: f32, + stops: Vec, + mut mode: SpreadMode, + transform: Transform, + ) -> Option> { + if !start_angle.is_finite() || !end_angle.is_finite() || start_angle > end_angle { + return None; + } + + match stops.as_slice() { + [] => return None, + [stop] => return Some(Shader::SolidColor(stop.color)), + _ => (), + } + transform.invert()?; + if start_angle.is_nearly_equal_within_tolerance(end_angle, DEGENERATE_THRESHOLD) { + if mode == SpreadMode::Pad && end_angle > DEGENERATE_THRESHOLD { + // In this case, the first color is repeated from 0 to the angle, then a hardstop + // switches to the last color (all other colors are compressed to the infinitely + // thin interpolation region). + let front_color = stops.first().unwrap().color; + let back_color = stops.last().unwrap().color; + let mut new_stops = stops; + new_stops.clear(); + new_stops.extend_from_slice(&[ + GradientStop::new(0.0, front_color), + GradientStop::new(1.0, front_color), + GradientStop::new(1.0, back_color), + ]); + return SweepGradient::new(center, 0.0, end_angle, new_stops, mode, transform); + } + // TODO: Consider making a degenerate fallback shader similar to Skia. Tiny Skia + // currently opts to return `None` in some places. + return None; + } + if start_angle <= 0.0 && end_angle >= 360.0 { + mode = SpreadMode::Pad; + } + let t0 = start_angle / 360.0; + let t1 = end_angle / 360.0; + Some(Shader::SweepGradient(SweepGradient { + base: Gradient::new( + stops, + mode, + transform, + Transform::from_translate(-center.x, -center.y), + ), + t0, + t1, + })) + } + + pub(crate) fn is_opaque(&self) -> bool { + self.base.colors_are_opaque + } + + pub(crate) fn push_stages(&self, cs: ColorSpace, p: &mut RasterPipelineBuilder) -> bool { + let scale = 1.0 / (self.t1 - self.t0); + let bias = -scale * self.t0; + p.ctx.two_point_conical_gradient.p0 = scale; + p.ctx.two_point_conical_gradient.p1 = bias; + self.base.push_stages( + p, + cs, + &|p| { + p.push(Stage::XYToUnitAngle); + if scale != 1.0 && bias != 0.0 { + p.push(Stage::ApplyConcentricScaleBias) + } + }, + &|_| {}, + ) + } +} diff --git a/tests/images/gradients/sweep-gradient-full.png b/tests/images/gradients/sweep-gradient-full.png new file mode 100644 index 0000000000000000000000000000000000000000..b9527f77bcfd5fd92fcaa06e22307a9f3f4fb55e GIT binary patch literal 9939 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 LGSozAI7Iv(_P4+N literal 0 HcmV?d00001 diff --git a/tests/integration/gradients.rs b/tests/integration/gradients.rs index dfcf271..87d7af0 100644 --- a/tests/integration/gradients.rs +++ b/tests/integration/gradients.rs @@ -525,3 +525,67 @@ fn conical_smaller_radial() { let expected = Pixmap::load_png("tests/images/gradients/conical-smaller-radial.png").unwrap(); assert_eq!(pixmap, expected); } + +#[test] +fn sweep_gradient() { + let mut paint = Paint::default(); + paint.anti_alias = false; + paint.shader = SweepGradient::new( + Point::from_xy(100.0, 100.0), + 135.0, + 225.0, + vec![ + GradientStop::new(0.0, Color::from_rgba8(50, 127, 150, 200)), + GradientStop::new(1.0, Color::from_rgba8(220, 140, 75, 180)), + ], + SpreadMode::Pad, + Transform::identity(), + ) + .unwrap(); + + let path = PathBuilder::from_rect(Rect::from_ltrb(10.0, 10.0, 190.0, 190.0).unwrap()); + + let mut pixmap = Pixmap::new(200, 200).unwrap(); + pixmap.fill_path( + &path, + &paint, + FillRule::Winding, + Transform::identity(), + None, + ); + + let expected = Pixmap::load_png("tests/images/gradients/sweep-gradient.png").unwrap(); + assert_eq!(pixmap, expected); +} + +#[test] +fn sweep_gradient_full() { + let mut paint = Paint::default(); + paint.anti_alias = false; + paint.shader = SweepGradient::new( + Point::from_xy(100.0, 100.0), + 0.0, + 360.0, + vec![ + GradientStop::new(0.0, Color::from_rgba8(50, 127, 150, 200)), + GradientStop::new(1.0, Color::from_rgba8(220, 140, 75, 180)), + ], + SpreadMode::Pad, + Transform::identity(), + ) + .unwrap(); + + let path = PathBuilder::from_rect(Rect::from_ltrb(10.0, 10.0, 190.0, 190.0).unwrap()); + + let mut pixmap = Pixmap::new(200, 200).unwrap(); + pixmap.fill_path( + &path, + &paint, + FillRule::Winding, + Transform::identity(), + None, + ); + + let expected = Pixmap::load_png("tests/images/gradients/sweep-gradient-full.png").unwrap(); + assert_eq!(pixmap, expected); +} From 0e65199489a6128a5f8f6852749fcc306c34844e Mon Sep 17 00:00:00 2001 From: wmedrano Date: Mon, 26 Jan 2026 12:17:52 -0800 Subject: [PATCH 2/3] Update changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fa9cf0..66a730d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added - 2-point conical gradient support for (`RadialGradient`). Thanks to [@wmedrano](https://github.com/wmedrano) +- Sweep gradient support (`SweepGradient`). + Thanks to [@wmedrano](https://github.com/wmedrano) ### Changed - The `RadialGradient::new` requires a start radius now. Set the second argument From 27b5c530910f64837905655f581454e533886f6f Mon Sep 17 00:00:00 2001 From: wmedrano Date: Mon, 26 Jan 2026 13:17:23 -0800 Subject: [PATCH 3/3] Add TODO for xy_to_unit_angle --- src/pipeline/lowp.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/pipeline/lowp.rs b/src/pipeline/lowp.rs index af9e1e7..fc1a6c5 100644 --- a/src/pipeline/lowp.rs +++ b/src/pipeline/lowp.rs @@ -126,6 +126,8 @@ pub const STAGES: &[StageFn; super::STAGES_COUNT] = &[ repeat_x1, gradient, evenly_spaced_2_stop_gradient, + // TODO: Can be implemented for lowp as well. The implementation is very similar to its highp + // variant. null_fn, // XYToUnitAngle xy_to_radius, null_fn, // XYTo2PtConicalFocalOnCircle