From 3f70e289488a5a0d000ca77ec08a57bfb8a799e6 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Wed, 5 Aug 2026 00:27:18 -0700 Subject: [PATCH] Add a Clear gradient spread that cuts off to transparency beyond the ends (#4410) --- .../color_picker_message_handler.rs | 9 +- .../libraries/rendering/src/render_ext.rs | 8 +- .../libraries/rendering/src/renderer.rs | 164 +++++++++++++++--- .../libraries/vector-types/src/gradient.rs | 28 ++- .../libraries/vector-types/src/markers.rs | 2 +- node-graph/nodes/math/src/lib.rs | 4 +- 6 files changed, 180 insertions(+), 35 deletions(-) diff --git a/editor/src/messages/color_picker/color_picker_message_handler.rs b/editor/src/messages/color_picker/color_picker_message_handler.rs index 740593df04..944f2ee035 100644 --- a/editor/src/messages/color_picker/color_picker_message_handler.rs +++ b/editor/src/messages/color_picker/color_picker_message_handler.rs @@ -569,7 +569,7 @@ impl ColorPickerMessageHandler { groups.push(LayoutGroup::row(vec![ TextLabel::new("HSV") .tooltip_label("Hue/Saturation/Value") - .tooltip_description("Also known as Hue/Saturation/Brightness (HSB). Not to be confused with Hue/Saturation/Lightness (HSL), a different color model.") + .tooltip_description("Also known as Hue/Saturation/Brightness (HSB), but distinct from Hue/Saturation/Lightness (HSL), a different color model.") .widget_instance(), Separator::new(SeparatorStyle::Related).widget_instance(), hsv_input( @@ -685,12 +685,7 @@ const HUE_DESCRIPTION: &str = "The shade along the spectrum of the rainbow."; const SATURATION_DESCRIPTION: &str = "The vividness from grayscale to full color."; const VALUE_DESCRIPTION: &str = "The brightness from black to full color."; const ALPHA_DESCRIPTION: &str = "The level of translucency, from transparent (0%) to opaque (100%)."; -const ENDS_DESCRIPTION: &str = "\ - How the gradient continues beyond its ends:\n\ - **Pad** extends the end colors outward.\n\ - **Reflect** loops the gradient by mirroring back-and-forth.\n\ - **Repeat** loops the gradient as copies of itself.\ -"; +const ENDS_DESCRIPTION: &str = "The method for how the gradient continues beyond its ends."; /// The popover's background color as sRGB gamma-encoded channels (the `--color-2-mildblack` design token, `#222`). /// Used by the comparison swatch's outline computation to brighten the inset border for colors close to this background. diff --git a/node-graph/libraries/rendering/src/render_ext.rs b/node-graph/libraries/rendering/src/render_ext.rs index c0b5dd21cc..75278c4352 100644 --- a/node-graph/libraries/rendering/src/render_ext.rs +++ b/node-graph/libraries/rendering/src/render_ext.rs @@ -1,4 +1,4 @@ -use crate::renderer::{RenderParams, format_transform_matrix, gradient_placement, transform_is_invertible}; +use crate::renderer::{ClearGuardPlacement, RenderParams, format_transform_matrix, gradient_placement, spread_adjusted_samples, transform_is_invertible}; use crate::{Render, RenderSvgSegmentList, SvgRender}; use core_types::Color; use core_types::attribute::Transform; @@ -112,7 +112,9 @@ pub fn render_gradient_paint let local_gradient_transform: DAffine2 = source.attr::(0); let gradient_spread: GradientSpread = source.attr::(0); - for (position, color, original_midpoint) in stops.interpolated_samples() { + let (samples, _) = spread_adjusted_samples(stops, gradient_spread, gradient_form, ClearGuardPlacement::SvgStopOrder); + + for (position, color, original_midpoint) in samples { stop.push_str(" format!(r#" gradientTransform="{gradient_transform}""#) }; - let gradient_spread = if gradient_spread == GradientSpread::Pad { + let gradient_spread = if matches!(gradient_spread, GradientSpread::Pad | GradientSpread::Clear) { String::new() } else { format!(r#" spreadMethod="{}""#, gradient_spread.svg_name()) diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index 8be707733b..6e0dfdc5f1 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -27,7 +27,7 @@ use graphic_types::graphic::{PaintColumns, PaintOverlay, PaintReach, has_paint, use graphic_types::markers::{EditorMergedLayers, Fill, Stroke}; use graphic_types::raster_types::{BitmapMut, CPU, GPU, Image, Raster, Texture}; use graphic_types::vector_types::gradient::{Gradient, GradientForm}; -use graphic_types::vector_types::markers::{GradientForm as GradientFormAttr, GradientSpread as GradientSpreadAttr}; +use graphic_types::vector_types::markers::{GradientSpread as GradientSpreadAttr, GradientForm as GradientFormAttr}; use graphic_types::vector_types::subpath::Subpath; use graphic_types::vector_types::vector::click_target::{ClickTarget, FreePoint}; use graphic_types::vector_types::vector::style::{PaintOrder, RenderMode, StrokeAlign, StrokeCap, StrokeJoin}; @@ -400,11 +400,79 @@ pub(crate) fn gradient_placement(transform: DAffine2, gradient_form: GradientFor } } +/// Texel count of the baked gradient ramp Vello samples stops through (`N_SAMPLES`/`GRADIENT_WIDTH` in vello_encoding). +const VELLO_GRADIENT_RAMP_TEXELS: f64 = 512.; + +/// Renderable gradient samples of `(position, color, original midpoint)`, as produced by [`Gradient::interpolated_samples`]. +type GradientSamples = Vec<(f64, Color, Option)>; + +/// Where a renderer needs the transparent guard stops that emulate the `Clear` spread, which neither SVG nor Vello supports natively. +#[derive(Copy, Clone, PartialEq)] +pub(crate) enum ClearGuardPlacement { + /// Guards share the range ends' exact offsets, resolved against the visible colors by stop order alone. + SvgStopOrder, + /// Guards own the outermost ramp texel at each cleared end, since Vello's pad extension samples those texels for + /// everything beyond the ends and its ramp bake would tie-break a shared-offset guard away. The visible range + /// compresses inward by one texel per cleared end, costing about 0.4% of the ramp's color resolution. + VelloRampTexels, +} + +/// The gradient's renderable samples plus the gradient-space span `(start, end)` the renderer's 0 to 1 offset range must cover, normally the unit interval with the samples unchanged. +/// +/// The `Clear` spread brackets the samples with transparent guard stops placed per `guards`: the pad extension then +/// paints transparency outward while hard stops cut the paint off exactly at the unit range's boundaries. A radial +/// gradient's span still starts at zero, since its sampling distance never goes below the center. +pub(crate) fn spread_adjusted_samples(gradient: &Gradient, gradient_spread: GradientSpread, gradient_form: GradientForm, guards: ClearGuardPlacement) -> (GradientSamples, (f64, f64)) { + let samples = gradient.interpolated_samples(); + if gradient_spread != GradientSpread::Clear { + return (samples, (0., 1.)); + } + + // The remapped offsets where the visible range's ends land, with the guards owning whatever lies outside them + let texel = 1. / (VELLO_GRADIENT_RAMP_TEXELS - 1.); + let (start_offset, end_offset) = match (guards, gradient_form) { + (ClearGuardPlacement::SvgStopOrder, _) => (0., 1.), + (ClearGuardPlacement::VelloRampTexels, GradientForm::Linear) => (texel, 1. - texel), + (ClearGuardPlacement::VelloRampTexels, GradientForm::Radial) => (0., 1. - texel), + }; + let remap = |position: f64| (1. - position) * start_offset + position * end_offset; + + // The geometric span grows to compensate for the compression, keeping the visible range at the unit interval + let scale = 1. / (end_offset - start_offset); + let span = (-start_offset * scale, (1. - start_offset) * scale); + + // A stopless gradient paints solid black, matching `Gradient::evaluate` + let first_color = samples.first().map_or(Color::BLACK, |&(_, color, _)| color); + let last_color = samples.last().map_or(Color::BLACK, |&(_, color, _)| color); + let needs_start_anchor = samples.first().is_none_or(|&(position, ..)| position > 0.); + let needs_end_anchor = samples.last().is_none_or(|&(position, ..)| position < 1.); + + let mut adjusted = Vec::with_capacity(samples.len() + 4); + + // Lead with the transparent guard (linear only, a radial's center is already the sampling minimum), then anchor the visible range's start color + if gradient_form == GradientForm::Linear { + adjusted.push((0., Color::TRANSPARENT, None)); + } + if needs_start_anchor { + adjusted.push((remap(0.), first_color, None)); + } + + adjusted.extend(samples.into_iter().map(|(position, color, midpoint)| (remap(position), color, midpoint))); + + // Anchor the visible range's end color, then cut to the trailing transparent guard + if needs_end_anchor { + adjusted.push((remap(1.), last_color, None)); + } + adjusted.push((1., Color::TRANSPARENT, None)); + + (adjusted, span) +} + /// Converts a gradient's renderer samples to peniko color stops, duplicating an off-zero first stop at position 0 since Vello ignores the first stop's position and always treats it as 0. -fn peniko_color_stops(gradient: &Gradient) -> peniko::ColorStops { +fn peniko_color_stops(samples: &[(f64, Color, Option)]) -> peniko::ColorStops { let mut peniko_stops = peniko::ColorStops::new(); - for (position, color, _) in gradient.interpolated_samples() { + for &(position, color, _) in samples { let color = peniko::color::DynamicColor::from_alpha_color(SRGBA8::from(color).to_peniko_color()); if peniko_stops.is_empty() && position > 0. { @@ -425,6 +493,16 @@ fn peniko_color_stops(gradient: &Gradient) -> peniko::ColorStops { peniko_stops } +/// The peniko extend mode for a spread; `Clear` rides pad, with the transparent guard stops from `spread_adjusted_samples` doing the clearing. +fn peniko_extend(gradient_spread: GradientSpread) -> peniko::Extend { + match gradient_spread { + GradientSpread::Pad | GradientSpread::Clear => peniko::Extend::Pad, + GradientSpread::Reflect => peniko::Extend::Reflect, + GradientSpread::Repeat => peniko::Extend::Repeat, + } +} + + fn create_peniko_gradient_brush>(gradient_list: &S, multiplied_transform: &DAffine2) -> Option<(peniko::Brush, DAffine2)> { let stops = gradient_list.element(0)?; @@ -432,10 +510,11 @@ fn create_peniko_gradient_brush>(gradient_list let gradient_transform: DAffine2 = gradient_list.attr::(0); let gradient_spread: GradientSpread = gradient_list.attr::(0); - let peniko_stops = peniko_color_stops(stops); + let (samples, span) = spread_adjusted_samples(stops, gradient_spread, gradient_form, ClearGuardPlacement::VelloRampTexels); + let peniko_stops = peniko_color_stops(&samples); // The unit gradient is placed by the desheared frame so a non-uniform transform produces the intended ellipse - let (start, end, gradient_to_device) = (DVec2::ZERO, DVec2::X, gradient_placement(multiplied_transform * gradient_transform, gradient_form)); + let (start, end, gradient_to_device) = (DVec2::X * span.0, DVec2::X * span.1, gradient_placement(multiplied_transform * gradient_transform, gradient_form)); let brush = peniko::Brush::Gradient(peniko::Gradient { kind: match gradient_form { @@ -452,11 +531,7 @@ fn create_peniko_gradient_brush>(gradient_list } .into(), }, - extend: match gradient_spread { - GradientSpread::Pad => peniko::Extend::Pad, - GradientSpread::Reflect => peniko::Extend::Reflect, - GradientSpread::Repeat => peniko::Extend::Repeat, - }, + extend: peniko_extend(gradient_spread), stops: peniko_stops, interpolation_alpha_space: peniko::InterpolationAlphaSpace::Premultiplied, ..Default::default() @@ -2344,8 +2419,10 @@ fn render_gradient_svg>(source: &S, render: &m attributes.push("points", format!("{MAX},{MAX} -{MAX},{MAX} -{MAX},-{MAX} {MAX},-{MAX}")); } + let (samples, _) = spread_adjusted_samples(gradient, gradient_spread, gradient_form, ClearGuardPlacement::SvgStopOrder); + let mut stop_string = String::new(); - for (position, color, original_midpoint) in gradient.interpolated_samples() { + for (position, color, original_midpoint) in samples { let _ = write!(stop_string, r##">(source: &S, render: &m }; let gradient_id = generate_uuid(); - let spread_method_attribute = if gradient_spread == GradientSpread::Pad { + let spread_method_attribute = if matches!(gradient_spread, GradientSpread::Pad | GradientSpread::Clear) { String::new() } else { format!(r#" spreadMethod="{}""#, gradient_spread.svg_name()) @@ -2422,27 +2499,24 @@ fn render_gradient_vello>(source: &S, scene: & let blend_mode = blend_mode_attr.to_peniko(); let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; - let stops = peniko_color_stops(gradient); + let (samples, span) = spread_adjusted_samples(gradient, gradient_spread, gradient_form, ClearGuardPlacement::VelloRampTexels); + let stops = peniko_color_stops(&samples); - let extend = match gradient_spread { - GradientSpread::Pad => peniko::Extend::Pad, - GradientSpread::Reflect => peniko::Extend::Reflect, - GradientSpread::Repeat => peniko::Extend::Repeat, - }; + let extend = peniko_extend(gradient_spread); // The unit gradient line is the +X unit vector in local space, before the item's transform is applied. // For radial, the unit-radius circle at the origin scales out to the line's length once the brush transform applies. let kind = match gradient_form { GradientForm::Linear => peniko::LinearGradientPosition { - start: to_point(DVec2::ZERO), - end: to_point(DVec2::X), + start: to_point(DVec2::X * span.0), + end: to_point(DVec2::X * span.1), } .into(), GradientForm::Radial => peniko::RadialGradientPosition { start_center: to_point(DVec2::ZERO), start_radius: 0., end_center: to_point(DVec2::ZERO), - end_radius: 1., + end_radius: span.1 as f32, } .into(), }; @@ -3161,3 +3235,51 @@ mod group_walk_tests { assert_eq!(native_outlines, legacy); } } + +#[cfg(test)] +mod spread_tests { + use super::*; + #[test] + fn spread_adjusted_samples_wraps_clear_in_transparent_guards() { + let gradient = Gradient::from(vec![Color::BLACK, Color::WHITE]); + + let (samples, span) = spread_adjusted_samples(&gradient, GradientSpread::Repeat, GradientForm::Linear, ClearGuardPlacement::SvgStopOrder); + assert_eq!(span, (0., 1.)); + assert_eq!(samples, gradient.interpolated_samples()); + + // SVG guards share the range ends' exact offsets, ordered so the pad extension resolves to the transparent outer stops + let (samples, span) = spread_adjusted_samples(&gradient, GradientSpread::Clear, GradientForm::Linear, ClearGuardPlacement::SvgStopOrder); + assert_eq!(span, (0., 1.)); + assert_eq!( + samples, + vec![(0., Color::TRANSPARENT, None), (0., Color::BLACK, None), (1., Color::WHITE, None), (1., Color::TRANSPARENT, None)] + ); + + // Vello guards own the outermost ramp texels, with the visible range compressed inward to make room + let texel = 1. / (VELLO_GRADIENT_RAMP_TEXELS - 1.); + let (samples, span) = spread_adjusted_samples(&gradient, GradientSpread::Clear, GradientForm::Linear, ClearGuardPlacement::VelloRampTexels); + assert_eq!( + samples, + vec![ + (0., Color::TRANSPARENT, None), + (texel, Color::BLACK, None), + (1. - texel, Color::WHITE, None), + (1., Color::TRANSPARENT, None) + ] + ); + assert!(span.0 < 0. && span.1 > 1., "the geometry must stretch to compensate for the compressed stops: {span:?}"); + + // A radial keeps its stops and span anchored at zero, with no guard below the center + let (samples, span) = spread_adjusted_samples(&gradient, GradientSpread::Clear, GradientForm::Radial, ClearGuardPlacement::VelloRampTexels); + assert_eq!(span.0, 0.); + assert_eq!(samples.first().unwrap(), &(0., Color::BLACK, None)); + assert_eq!(samples.last().unwrap(), &(1., Color::TRANSPARENT, None)); + } + + #[test] + fn spread_adjusted_samples_keeps_a_stopless_clear_gradient_black_inside_the_range() { + let (samples, _) = spread_adjusted_samples(&Gradient::from(Vec::new()), GradientSpread::Clear, GradientForm::Linear, ClearGuardPlacement::SvgStopOrder); + let colors: Vec = samples.iter().map(|&(_, color, _)| color).collect(); + assert_eq!(colors, vec![Color::TRANSPARENT, Color::BLACK, Color::BLACK, Color::TRANSPARENT]); + } +} diff --git a/node-graph/libraries/vector-types/src/gradient.rs b/node-graph/libraries/vector-types/src/gradient.rs index 472fd5fc37..f4d8c0df2f 100644 --- a/node-graph/libraries/vector-types/src/gradient.rs +++ b/node-graph/libraries/vector-types/src/gradient.rs @@ -612,6 +612,12 @@ impl Gradient { let cycle = t.rem_euclid(2.); if cycle > 1. { 2. - cycle } else { cycle } } + GradientSpread::Clear => { + if !(0. ..=1.).contains(&t) { + return Color::TRANSPARENT; + } + t + } }; let stops = self.normalized_stops(); @@ -798,7 +804,9 @@ pub enum GradientSpread { /// Loops the gradient as copies of itself. #[icon("GradientSpreadRepeat")] Repeat, - // TODO: Add a "Clear" variant that returns transparent black outside the gradient's range + /// Cuts off to transparency beyond the ends. + #[icon("GradientSpreadClear")] + Clear, } impl GradientSpread { @@ -807,6 +815,8 @@ impl GradientSpread { GradientSpread::Pad => "pad", GradientSpread::Reflect => "reflect", GradientSpread::Repeat => "repeat", + // SVG has no clear mode; renderers emulate it over pad with transparent guard stops + GradientSpread::Clear => "pad", } } @@ -948,6 +958,22 @@ mod tests { ); } + #[test] + fn clear_spread_evaluates_to_transparency_outside_the_unit_range() { + let gradient = Gradient::from(vec![Color::BLACK, Color::WHITE]); + + assert_eq!(gradient.evaluate(-0.25, GradientSpread::Clear), Color::TRANSPARENT); + assert_eq!(gradient.evaluate(1.25, GradientSpread::Clear), Color::TRANSPARENT); + + for t in [0., 0.25, 1.] { + assert_eq!( + gradient.evaluate(t, GradientSpread::Clear), + gradient.evaluate(t, GradientSpread::Pad), + "inside the range Clear must match Pad at t = {t}" + ); + } + } + #[test] fn gradient_ui_write_back_elides_default_attributes() { let mut gradient = Gradient::from(vec![Color::BLACK, Color::WHITE, Color::RED]); diff --git a/node-graph/libraries/vector-types/src/markers.rs b/node-graph/libraries/vector-types/src/markers.rs index a5448c7a10..c00319c3bf 100644 --- a/node-graph/libraries/vector-types/src/markers.rs +++ b/node-graph/libraries/vector-types/src/markers.rs @@ -4,7 +4,7 @@ use core_types::attribute::Attribute; core_types::attribute! { - /// Gradient's spread behavior past its endpoints (`Pad`, `Reflect`, or `Repeat`). + /// Gradient's spread behavior past its endpoints (`Pad`, `Reflect`, `Repeat`, or `Clear`). pub GradientSpread("gradient_spread"): crate::gradient::GradientSpread; /// Gradient's shape (`Linear` or `Radial`). pub GradientForm("gradient_form"): crate::gradient::GradientForm; diff --git a/node-graph/nodes/math/src/lib.rs b/node-graph/nodes/math/src/lib.rs index 20f3b6afeb..a9fa19f437 100644 --- a/node-graph/nodes/math/src/lib.rs +++ b/node-graph/nodes/math/src/lib.rs @@ -1209,7 +1209,7 @@ fn gradient_form(_: impl Ctx, gradient: Gradient, gradient_form: vector_types::G (gradient, Attr(gradient_form)) } -/// Sets how each gradient in the input list extends past its endpoints: Pad, Reflect, or Repeat. +/// Sets how each gradient in the input list extends past its endpoints: Pad, Reflect, Repeat, or Clear. #[node_macro::node(category("Gradient"))] fn gradient_spread(_: impl Ctx, gradient: Gradient, gradient_spread: vector_types::GradientSpread) -> (Gradient, Attr) { (gradient, Attr(gradient_spread)) @@ -1237,7 +1237,7 @@ fn gradient_midpoints(_: impl Ctx, mut gradient: Gradient, midpoints: List) gradient } -/// Evaluates the color at the specified position along the gradient, given a position from 0 (left) to 1 (right). Positions beyond that range follow the gradient's `gradient_spread` attribute: Pad (default), Reflect, or Repeat. +/// Evaluates the color at the specified position along the gradient, given a position from 0 (left) to 1 (right). Positions beyond that range follow the gradient's `gradient_spread` attribute: Pad (default), Reflect, Repeat, or Clear. #[node_macro::node(category("Color"))] fn sample_gradient( ctx: impl Ctx + ExtractIndex + InjectIndex + Copy,