HSL and HSV interpolation for UI gradients (#19992)

# Objective

Add interpolation in HSL and HSV colour spaces for UI gradients.

## Solution
Added new variants to `InterpolationColorSpace`: `Hsl`, `HslLong`,
`Hsv`, and `HsvLong`, along with mix functions to the `gradients` shader
for each of them.

#### Limitations
* Didn't include increasing and decreasing path support, it's not
essential and can be done in a follow up if someone feels like it.

* The colour conversions should really be performed before the colours
are sent to the shader but it would need more changes and performance is
good enough for now.

## Testing

```cargo run --example gradients```
This commit is contained in:
ickshonpe 2025-07-07 21:08:51 +01:00 committed by GitHub
parent 12da4e482a
commit 5ea0e4004f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 210 additions and 30 deletions

View File

@ -631,6 +631,14 @@ pub enum InterpolationColorSpace {
Srgb,
/// Interpolates in linear sRGB space.
LinearRgb,
/// Interpolates in HSL space, taking the shortest hue path.
Hsl,
/// Interpolates in HSL space, taking the longest hue path.
HslLong,
/// Interpolates in HSV space, taking the shortest hue path.
Hsv,
/// Interpolates in HSV space, taking the longest hue path.
HsvLong,
}
/// Set the color space used for interpolation.

View File

@ -186,6 +186,10 @@ impl SpecializedRenderPipeline for GradientPipeline {
InterpolationColorSpace::OkLchLong => "IN_OKLCH_LONG",
InterpolationColorSpace::Srgb => "IN_SRGB",
InterpolationColorSpace::LinearRgb => "IN_LINEAR_RGB",
InterpolationColorSpace::Hsl => "IN_HSL",
InterpolationColorSpace::HslLong => "IN_HSL_LONG",
InterpolationColorSpace::Hsv => "IN_HSV",
InterpolationColorSpace::HsvLong => "IN_HSV_LONG",
};
let shader_defs = if key.anti_alias {

View File

@ -31,7 +31,7 @@ struct GradientVertexOutput {
@location(0) uv: vec2<f32>,
@location(1) @interpolate(flat) size: vec2<f32>,
@location(2) @interpolate(flat) flags: u32,
@location(3) @interpolate(flat) radius: vec4<f32>,
@location(3) @interpolate(flat) radius: vec4<f32>,
@location(4) @interpolate(flat) border: vec4<f32>,
// Position relative to the center of the rectangle.
@ -114,27 +114,27 @@ fn fragment(in: GradientVertexOutput) -> @location(0) vec4<f32> {
}
}
// This function converts two linear rgb colors to srgb space, mixes them, and then converts the result back to linear rgb space.
fn mix_linear_rgb_in_srgb_space(a: vec4<f32>, b: vec4<f32>, t: f32) -> vec4<f32> {
// This function converts two linear rgba colors to srgba space, mixes them, and then converts the result back to linear rgb space.
fn mix_linear_rgba_in_srgba_space(a: vec4<f32>, b: vec4<f32>, t: f32) -> vec4<f32> {
let a_srgb = pow(a.rgb, vec3(1. / 2.2));
let b_srgb = pow(b.rgb, vec3(1. / 2.2));
let mixed_srgb = mix(a_srgb, b_srgb, t);
return vec4(pow(mixed_srgb, vec3(2.2)), mix(a.a, b.a, t));
}
fn linear_rgb_to_oklab(c: vec4<f32>) -> vec4<f32> {
fn linear_rgba_to_oklaba(c: vec4<f32>) -> vec4<f32> {
let l = pow(0.41222146 * c.x + 0.53633255 * c.y + 0.051445995 * c.z, 1. / 3.);
let m = pow(0.2119035 * c.x + 0.6806995 * c.y + 0.10739696 * c.z, 1. / 3.);
let s = pow(0.08830246 * c.x + 0.28171885 * c.y + 0.6299787 * c.z, 1. / 3.);
return vec4(
0.21045426 * l + 0.7936178 * m - 0.004072047 * s,
1.9779985 * l - 2.4285922 * m + 0.4505937 * s,
0.025904037 * l + 0.78277177 * m - 0.80867577 * s,
c.w
0.025904037 * l + 0.78277177 * m - 0.80867577 * s,
c.a
);
}
fn oklab_to_linear_rgba(c: vec4<f32>) -> vec4<f32> {
fn oklaba_to_linear_rgba(c: vec4<f32>) -> vec4<f32> {
let l_ = c.x + 0.39633778 * c.y + 0.21580376 * c.z;
let m_ = c.x - 0.105561346 * c.y - 0.06385417 * c.z;
let s_ = c.x - 0.08948418 * c.y - 1.2914855 * c.z;
@ -145,26 +145,127 @@ fn oklab_to_linear_rgba(c: vec4<f32>) -> vec4<f32> {
4.0767417 * l - 3.3077116 * m + 0.23096994 * s,
-1.268438 * l + 2.6097574 * m - 0.34131938 * s,
-0.0041960863 * l - 0.7034186 * m + 1.7076147 * s,
c.w
c.a
);
}
fn mix_linear_rgb_in_oklab_space(a: vec4<f32>, b: vec4<f32>, t: f32) -> vec4<f32> {
return oklab_to_linear_rgba(mix(linear_rgb_to_oklab(a), linear_rgb_to_oklab(b), t));
fn mix_linear_rgba_in_oklaba_space(a: vec4<f32>, b: vec4<f32>, t: f32) -> vec4<f32> {
return oklaba_to_linear_rgba(mix(linear_rgba_to_oklaba(a), linear_rgba_to_oklaba(b), t));
}
fn linear_rgba_to_hsla(c: vec4<f32>) -> vec4<f32> {
let maxc = max(max(c.r, c.g), c.b);
let minc = min(min(c.r, c.g), c.b);
let delta = maxc - minc;
let l = (maxc + minc) * 0.5;
var h: f32 = 0.0;
var s: f32 = 0.0;
if delta != 0.0 {
s = delta / (1.0 - abs(2.0 * l - 1.0));
if maxc == c.r {
h = ((c.g - c.b) / delta) % 6.0;
} else if maxc == c.g {
h = ((c.b - c.r) / delta) + 2.0;
} else {
h = ((c.r - c.g) / delta) + 4.0;
}
h = h / 6.0;
if h < 0.0 {
h = h + 1.0;
}
}
return vec4<f32>(h, s, l, c.a);
}
fn hsla_to_linear_rgba(hsl: vec4<f32>) -> vec4<f32> {
let h = hsl.x;
let s = hsl.y;
let l = hsl.z;
let c = (1.0 - abs(2.0 * l - 1.0)) * s;
let hp = h * 6.0;
let x = c * (1.0 - abs(hp % 2.0 - 1.0));
var r: f32 = 0.0;
var g: f32 = 0.0;
var b: f32 = 0.0;
if 0.0 <= hp && hp < 1.0 {
r = c; g = x; b = 0.0;
} else if 1.0 <= hp && hp < 2.0 {
r = x; g = c; b = 0.0;
} else if 2.0 <= hp && hp < 3.0 {
r = 0.0; g = c; b = x;
} else if 3.0 <= hp && hp < 4.0 {
r = 0.0; g = x; b = c;
} else if 4.0 <= hp && hp < 5.0 {
r = x; g = 0.0; b = c;
} else if 5.0 <= hp && hp < 6.0 {
r = c; g = 0.0; b = x;
}
let m = l - 0.5 * c;
return vec4<f32>(r + m, g + m, b + m, hsl.a);
}
fn linear_rgba_to_hsva(c: vec4<f32>) -> vec4<f32> {
let maxc = max(max(c.r, c.g), c.b);
let minc = min(min(c.r, c.g), c.b);
let delta = maxc - minc;
var h: f32 = 0.0;
var s: f32 = 0.0;
let v: f32 = maxc;
if delta != 0.0 {
s = delta / maxc;
if maxc == c.r {
h = ((c.g - c.b) / delta) % 6.0;
} else if maxc == c.g {
h = ((c.b - c.r) / delta) + 2.0;
} else {
h = ((c.r - c.g) / delta) + 4.0;
}
h = h / 6.0;
if h < 0.0 {
h = h + 1.0;
}
}
return vec4<f32>(h, s, v, c.a);
}
fn hsva_to_linear_rgba(hsva: vec4<f32>) -> vec4<f32> {
let h = hsva.x * 6.0;
let s = hsva.y;
let v = hsva.z;
let c = v * s;
let x = c * (1.0 - abs(h % 2.0 - 1.0));
let m = v - c;
var r: f32 = 0.0;
var g: f32 = 0.0;
var b: f32 = 0.0;
if 0.0 <= h && h < 1.0 {
r = c; g = x; b = 0.0;
} else if 1.0 <= h && h < 2.0 {
r = x; g = c; b = 0.0;
} else if 2.0 <= h && h < 3.0 {
r = 0.0; g = c; b = x;
} else if 3.0 <= h && h < 4.0 {
r = 0.0; g = x; b = c;
} else if 4.0 <= h && h < 5.0 {
r = x; g = 0.0; b = c;
} else if 5.0 <= h && h < 6.0 {
r = c; g = 0.0; b = x;
}
return vec4<f32>(r + m, g + m, b + m, hsva.a);
}
/// hue is left in radians and not converted to degrees
fn linear_rgb_to_oklch(c: vec4<f32>) -> vec4<f32> {
let o = linear_rgb_to_oklab(c);
fn linear_rgba_to_oklcha(c: vec4<f32>) -> vec4<f32> {
let o = linear_rgba_to_oklaba(c);
let chroma = sqrt(o.y * o.y + o.z * o.z);
let hue = atan2(o.z, o.y);
return vec4(o.x, chroma, select(hue + TAU, hue, hue < 0.0), o.w);
return vec4(o.x, chroma, select(hue + TAU, hue, hue < 0.0), o.a);
}
fn oklch_to_linear_rgb(c: vec4<f32>) -> vec4<f32> {
fn oklcha_to_linear_rgba(c: vec4<f32>) -> vec4<f32> {
let a = c.y * cos(c.z);
let b = c.y * sin(c.z);
return oklab_to_linear_rgba(vec4(c.x, a, b, c.w));
return oklaba_to_linear_rgba(vec4(c.x, a, b, c.a));
}
fn rem_euclid(a: f32, b: f32) -> f32 {
@ -181,28 +282,75 @@ fn lerp_hue_long(a: f32, b: f32, t: f32) -> f32 {
return rem_euclid(a + select(diff - TAU, diff + TAU, 0. < diff) * t, TAU);
}
fn mix_oklch(a: vec4<f32>, b: vec4<f32>, t: f32) -> vec4<f32> {
fn mix_oklcha(a: vec4<f32>, b: vec4<f32>, t: f32) -> vec4<f32> {
return vec4(
mix(a.xy, b.xy, t),
lerp_hue(a.z, b.z, t),
mix(a.w, b.w, t)
mix(a.a, b.a, t)
);
}
fn mix_oklch_long(a: vec4<f32>, b: vec4<f32>, t: f32) -> vec4<f32> {
fn mix_oklcha_long(a: vec4<f32>, b: vec4<f32>, t: f32) -> vec4<f32> {
return vec4(
mix(a.xy, b.xy, t),
lerp_hue_long(a.z, b.z, t),
mix(a.w, b.w, t)
mix(a.a, b.a, t)
);
}
fn mix_linear_rgb_in_oklch_space(a: vec4<f32>, b: vec4<f32>, t: f32) -> vec4<f32> {
return oklch_to_linear_rgb(mix_oklch(linear_rgb_to_oklch(a), linear_rgb_to_oklch(b), t));
fn mix_linear_rgba_in_oklcha_space(a: vec4<f32>, b: vec4<f32>, t: f32) -> vec4<f32> {
return oklcha_to_linear_rgba(mix_oklcha(linear_rgba_to_oklcha(a), linear_rgba_to_oklcha(b), t));
}
fn mix_linear_rgb_in_oklch_space_long(a: vec4<f32>, b: vec4<f32>, t: f32) -> vec4<f32> {
return oklch_to_linear_rgb(mix_oklch_long(linear_rgb_to_oklch(a), linear_rgb_to_oklch(b), t));
fn mix_linear_rgba_in_oklcha_space_long(a: vec4<f32>, b: vec4<f32>, t: f32) -> vec4<f32> {
return oklcha_to_linear_rgba(mix_oklcha_long(linear_rgba_to_oklcha(a), linear_rgba_to_oklcha(b), t));
}
fn mix_linear_rgba_in_hsva_space(a: vec4<f32>, b: vec4<f32>, t: f32) -> vec4<f32> {
let ha = linear_rgba_to_hsva(a);
let hb = linear_rgba_to_hsva(b);
var h: f32;
if ha.y == 0. {
h = hb.x;
} else if hb.y == 0. {
h = ha.x;
} else {
h = lerp_hue(ha.x * TAU, hb.x * TAU, t) / TAU;
}
let s = mix(ha.y, hb.y, t);
let v = mix(ha.z, hb.z, t);
let a_alpha = mix(ha.a, hb.a, t);
return hsva_to_linear_rgba(vec4<f32>(h, s, v, a_alpha));
}
fn mix_linear_rgba_in_hsva_space_long(a: vec4<f32>, b: vec4<f32>, t: f32) -> vec4<f32> {
let ha = linear_rgba_to_hsva(a);
let hb = linear_rgba_to_hsva(b);
let h = lerp_hue_long(ha.x * TAU, hb.x * TAU, t) / TAU;
let s = mix(ha.y, hb.y, t);
let v = mix(ha.z, hb.z, t);
let a_alpha = mix(ha.a, hb.a, t);
return hsva_to_linear_rgba(vec4<f32>(h, s, v, a_alpha));
}
fn mix_linear_rgba_in_hsla_space(a: vec4<f32>, b: vec4<f32>, t: f32) -> vec4<f32> {
let ha = linear_rgba_to_hsla(a);
let hb = linear_rgba_to_hsla(b);
let h = lerp_hue(ha.x * TAU, hb.x * TAU, t) / TAU;
let s = mix(ha.y, hb.y, t);
let l = mix(ha.z, hb.z, t);
let a_alpha = mix(ha.a, hb.a, t);
return hsla_to_linear_rgba(vec4<f32>(h, s, l, a_alpha));
}
fn mix_linear_rgba_in_hsla_space_long(a: vec4<f32>, b: vec4<f32>, t: f32) -> vec4<f32> {
let ha = linear_rgba_to_hsla(a);
let hb = linear_rgba_to_hsla(b);
let h = lerp_hue_long(ha.x * TAU, hb.x * TAU, t) / TAU;
let s = mix(ha.y, hb.y, t);
let l = mix(ha.z, hb.z, t);
let a_alpha = mix(ha.a, hb.a, t);
return hsla_to_linear_rgba(vec4<f32>(h, s, l, a_alpha));
}
// These functions are used to calculate the distance in gradient space from the start of the gradient to the point.
@ -277,13 +425,21 @@ fn interpolate_gradient(
}
#ifdef IN_SRGB
return mix_linear_rgb_in_srgb_space(start_color, end_color, t);
return mix_linear_rgba_in_srgba_space(start_color, end_color, t);
#else ifdef IN_OKLAB
return mix_linear_rgb_in_oklab_space(start_color, end_color, t);
return mix_linear_rgba_in_oklaba_space(start_color, end_color, t);
#else ifdef IN_OKLCH
return mix_linear_rgb_in_oklch_space(start_color, end_color, t);
return mix_linear_rgba_in_oklcha_space(start_color, end_color, t);
#else ifdef IN_OKLCH_LONG
return mix_linear_rgb_in_oklch_space_long(start_color, end_color, t);
return mix_linear_rgba_in_oklcha_space_long(start_color, end_color, t);
#else ifdef IN_HSV
return mix_linear_rgba_in_hsva_space(start_color, end_color, t);
#else ifdef IN_HSV_LONG
return mix_linear_rgba_in_hsva_space_long(start_color, end_color, t);
#else ifdef IN_HSL
return mix_linear_rgba_in_hsla_space(start_color, end_color, t);
#else ifdef IN_HSL_LONG
return mix_linear_rgba_in_hsla_space_long(start_color, end_color, t);
#else
return mix(start_color, end_color, t);
#endif

View File

@ -245,6 +245,18 @@ fn setup(mut commands: Commands) {
InterpolationColorSpace::LinearRgb
}
InterpolationColorSpace::LinearRgb => {
InterpolationColorSpace::Hsl
}
InterpolationColorSpace::Hsl => {
InterpolationColorSpace::HslLong
}
InterpolationColorSpace::HslLong => {
InterpolationColorSpace::Hsv
}
InterpolationColorSpace::Hsv => {
InterpolationColorSpace::HsvLong
}
InterpolationColorSpace::HsvLong => {
InterpolationColorSpace::OkLab
}
};

View File

@ -1,7 +1,7 @@
---
title: UI Gradients
authors: ["@Ickshonpe"]
pull_requests: [18139, 19330]
pull_requests: [18139, 19330, 19992]
---
Support for UI node's that display a gradient that transitions smoothly between two or more colors.
@ -14,12 +14,12 @@ Each gradient type consists of the geometric properties for that gradient, a lis
Color stops consist of a color, a position or angle and an optional hint. If no position is specified for a stop, it's evenly spaced between the previous and following stops. Color stop positions are absolute. With the list of stops:
```rust
vec![vec![ColorStop::new(RED, Val::Percent(90.), ColorStop::new(Color::GREEN, Val::Percent(10.))
vec![ColorStop::new(RED, Val::Percent(90.), ColorStop::new(GREEN), Val::Percent(10.))]
```
the colors will be reordered and the gradient will transition from green at 10% to red at 90%.
Colors can be interpolated between the stops in OKLab, OKLCH, SRGB and linear RGB color spaces. The hint is a normalized value that can be used to shift the mid-point where the colors are mixed 50-50 between the stop with the hint and the following stop.
Colors can be interpolated between the stops in OKLab, OKLCH, SRGB, HSL, HSV and linear RGB color spaces. The hint is a normalized value that can be used to shift the mid-point where the colors are mixed 50-50 between the stop with the hint and the following stop. Cylindrical color spaces support interpolation along both short and long hue paths.
For sharp stops with no interpolated transition, place two stops at the same point.