From 5ea0e4004fb5607751bbcc6c98cea47d56d22b76 Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Mon, 7 Jul 2025 21:08:51 +0100 Subject: [PATCH] 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``` --- crates/bevy_ui/src/gradients.rs | 8 + crates/bevy_ui_render/src/gradient.rs | 4 + crates/bevy_ui_render/src/gradient.wgsl | 210 +++++++++++++++--- examples/ui/gradients.rs | 12 + release-content/release-notes/ui_gradients.md | 6 +- 5 files changed, 210 insertions(+), 30 deletions(-) diff --git a/crates/bevy_ui/src/gradients.rs b/crates/bevy_ui/src/gradients.rs index eb1d255cc7..7086b37416 100644 --- a/crates/bevy_ui/src/gradients.rs +++ b/crates/bevy_ui/src/gradients.rs @@ -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. diff --git a/crates/bevy_ui_render/src/gradient.rs b/crates/bevy_ui_render/src/gradient.rs index 9bef5340cb..12cfcbeb68 100644 --- a/crates/bevy_ui_render/src/gradient.rs +++ b/crates/bevy_ui_render/src/gradient.rs @@ -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 { diff --git a/crates/bevy_ui_render/src/gradient.wgsl b/crates/bevy_ui_render/src/gradient.wgsl index 074cf35a35..7ee4ce8862 100644 --- a/crates/bevy_ui_render/src/gradient.wgsl +++ b/crates/bevy_ui_render/src/gradient.wgsl @@ -31,7 +31,7 @@ struct GradientVertexOutput { @location(0) uv: vec2, @location(1) @interpolate(flat) size: vec2, @location(2) @interpolate(flat) flags: u32, - @location(3) @interpolate(flat) radius: vec4, + @location(3) @interpolate(flat) radius: vec4, @location(4) @interpolate(flat) border: vec4, // Position relative to the center of the rectangle. @@ -114,27 +114,27 @@ fn fragment(in: GradientVertexOutput) -> @location(0) vec4 { } } -// 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, b: vec4, t: f32) -> vec4 { +// 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, b: vec4, t: f32) -> vec4 { 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) -> vec4 { +fn linear_rgba_to_oklaba(c: vec4) -> vec4 { 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) -> vec4 { +fn oklaba_to_linear_rgba(c: vec4) -> vec4 { 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) -> vec4 { 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, b: vec4, t: f32) -> vec4 { - 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, b: vec4, t: f32) -> vec4 { + return oklaba_to_linear_rgba(mix(linear_rgba_to_oklaba(a), linear_rgba_to_oklaba(b), t)); +} + +fn linear_rgba_to_hsla(c: vec4) -> vec4 { + 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(h, s, l, c.a); +} + +fn hsla_to_linear_rgba(hsl: vec4) -> vec4 { + 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(r + m, g + m, b + m, hsl.a); +} + +fn linear_rgba_to_hsva(c: vec4) -> vec4 { + 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(h, s, v, c.a); +} + +fn hsva_to_linear_rgba(hsva: vec4) -> vec4 { + 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(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) -> vec4 { - let o = linear_rgb_to_oklab(c); +fn linear_rgba_to_oklcha(c: vec4) -> vec4 { + 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) -> vec4 { +fn oklcha_to_linear_rgba(c: vec4) -> vec4 { 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, b: vec4, t: f32) -> vec4 { +fn mix_oklcha(a: vec4, b: vec4, t: f32) -> vec4 { 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, b: vec4, t: f32) -> vec4 { +fn mix_oklcha_long(a: vec4, b: vec4, t: f32) -> vec4 { 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, b: vec4, t: f32) -> vec4 { - 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, b: vec4, t: f32) -> vec4 { + 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, b: vec4, t: f32) -> vec4 { - 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, b: vec4, t: f32) -> vec4 { + 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, b: vec4, t: f32) -> vec4 { + 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(h, s, v, a_alpha)); +} + +fn mix_linear_rgba_in_hsva_space_long(a: vec4, b: vec4, t: f32) -> vec4 { + 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(h, s, v, a_alpha)); +} + +fn mix_linear_rgba_in_hsla_space(a: vec4, b: vec4, t: f32) -> vec4 { + 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(h, s, l, a_alpha)); +} + +fn mix_linear_rgba_in_hsla_space_long(a: vec4, b: vec4, t: f32) -> vec4 { + 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(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 diff --git a/examples/ui/gradients.rs b/examples/ui/gradients.rs index 72b915813c..82c69d4f8e 100644 --- a/examples/ui/gradients.rs +++ b/examples/ui/gradients.rs @@ -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 } }; diff --git a/release-content/release-notes/ui_gradients.md b/release-content/release-notes/ui_gradients.md index 15ea3bf5ed..155e394892 100644 --- a/release-content/release-notes/ui_gradients.md +++ b/release-content/release-notes/ui_gradients.md @@ -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.