Skip to main content

impeller_core/
ramp.rs

1//! Baking a gradient's stops into a texture the shader can sample.
2//!
3//! # Why a texture at all
4//!
5//! The material carries a fixed, small number of stops in push constants, which
6//! covers the overwhelming majority of real gradients and nothing beyond them.
7//! A gradient with more used to be truncated, silently; then it was refused,
8//! honestly. This is the third answer: evaluate the ramp once on the way in and
9//! hand the shader an image of it, so the count stops mattering.
10//!
11//! # Why half-floats, and why straight alpha
12//!
13//! The table is stored the way the walk produced it: sRGB-encoded like
14//! everything else the pipeline carries, unclamped, four half-floats a texel.
15//!
16//! Half rather than the eight bits upstream's gradient texture uses, and the
17//! reason is range rather than precision. An eight-bit table cannot hold a
18//! component outside the sRGB primaries at all, and a wide-gamut gradient has
19//! them. Upstream's table cannot carry one either; it reaches a table so rarely
20//! -- past two hundred and fifty-six stops -- that it has not had to.
21//!
22//! The color is stored straight rather than premultiplied, because the shader
23//! premultiplies stops read from push constants at the very end. A
24//! premultiplied table would fork the two paths at exactly the point this
25//! design exists to join them.
26
27use crate::paint::GradientStop;
28
29/// How many texels a baked ramp gets.
30///
31/// A gradient is a one-dimensional function sampled with a linear filter, so
32/// the question is how finely it has to be tabulated before interpolation
33/// between neighbors is indistinguishable from evaluating it. Between two stops
34/// the function is a lerp and the filter is a lerp, so the table is exact
35/// there however wide it is; what the width actually bounds is how precisely a
36/// stop lands that falls between two texel centers. At this width that is under
37/// half a percent of the ramp's length, which is finer than the four-stop path
38/// it has to agree with can be told apart from.
39pub const RAMP_WIDTH: usize = 256;
40
41/// A gradient's colors, tabulated and encoded for upload.
42///
43/// Owned by the recording rather than by the caller, because the recorder is
44/// what knows the stops and nothing outside it should have to bake them.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct Ramp {
47    /// `RAMP_WIDTH` texels, RGBA, linear, four half-floats each.
48    pub texels: Vec<u8>,
49}
50
51impl Ramp {
52    /// Tabulate `stops` across the width.
53    ///
54    /// The stops are taken as given: in order, and covering whatever part of
55    /// the range they cover. Before the first and after the last the ramp holds
56    /// the end colors, which is not a tile mode — tiling happens to the
57    /// parameter before it reaches the ramp, so by here the question is only
58    /// what a parameter inside the table means.
59    pub fn bake(stops: &[GradientStop]) -> Self {
60        let mut texels = vec![0u8; RAMP_WIDTH * 8];
61        for (i, texel) in texels.chunks_exact_mut(8).enumerate() {
62            // Sampled at texel centers, because that is where a linear filter
63            // reads them: treating the first texel as t=0 would shift the whole
64            // ramp by half a texel against the four-stop path, which is exactly
65            // the kind of difference that shows up as a seam between a gradient
66            // and one drawn beside it with fewer stops.
67            let t = (i as f32 + 0.5) / RAMP_WIDTH as f32;
68            let color = sample_at(stops, t);
69            // Linear, and stored as it is. Little-endian because the device
70            // reads native order and every target here is little-endian.
71            for (channel, out) in color.to_array().iter().zip(texel.chunks_exact_mut(2)) {
72                out.copy_from_slice(&half::f16::from_f32(*channel).to_le_bytes());
73            }
74        }
75        Self { texels }
76    }
77}
78
79/// The color a gradient shows at `t`, from stops in order.
80///
81/// Deliberately the same walk the shader performs over its four stops, so that
82/// a gradient stated within the material's four and the same gradient restated
83/// with more do not differ. Sharing the rule is half of what makes that true;
84/// the other half is that the table holds what the walk produced rather than a
85/// rounded, clamped version of it, which is why it is linear half-floats.
86///
87/// It used to be neither. The table was encoded and quantized to eight bits,
88/// so a component the sRGB primaries cannot hold was flattened on the way in --
89/// and adding a stop that changed nothing about a gradient changed the picture
90/// by twenty-four levels once a color filter brought the difference back inside
91/// the range a target could show.
92fn sample_at(stops: &[GradientStop], t: f32) -> crate::Color {
93    let Some(first) = stops.first() else {
94        return crate::Color::TRANSPARENT;
95    };
96    let mut result = first.color;
97    for pair in stops.windows(2) {
98        let (lower, upper) = (&pair[0], &pair[1]);
99        let span = (upper.offset - lower.offset).max(1e-6);
100        let local = ((t - lower.offset) / span).clamp(0.0, 1.0);
101        if t >= lower.offset {
102            result = mix(lower.color, upper.color, local);
103        }
104    }
105    result
106}
107
108/// Interpolate two stops, in one space rather than component by component.
109///
110/// The two colors need not have been stated against the same primaries, and
111/// mixing their components directly would then average numbers that do not
112/// describe the same axes -- an answer that is subtly wrong rather than
113/// obviously so, and one whose error grows with how far apart the two spaces
114/// are. `to_array` puts both against the primaries the pipeline works in, which
115/// is the same thing every other reader of a color does.
116/// Blend two stops, in the space the shader blends them in.
117///
118/// `to_array` hands back encoded components and `Color::srgb` takes encoded
119/// components, so the pair is a decompose and a recompose with nothing applied
120/// between them. Rebuilding with `Color::linear` would encode a second time,
121/// which is what it did for exactly as long as `to_array` returned light: the
122/// walked and tabulated halves of a gradient then disagreed by fifty-one
123/// levels, on the invariant the shared walk exists to provide.
124fn mix(a: crate::Color, b: crate::Color, t: f32) -> crate::Color {
125    let [ar, ag, ab, aa] = a.to_array();
126    let [br, bg, bb, ba] = b.to_array();
127    crate::Color::srgb(
128        ar + (br - ar) * t,
129        ag + (bg - ag) * t,
130        ab + (bb - ab) * t,
131        aa + (ba - aa) * t,
132    )
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use crate::Color;
139
140    fn stops(n: usize) -> Vec<GradientStop> {
141        (0..n)
142            .map(|i| {
143                let t = i as f32 / (n - 1) as f32;
144                GradientStop::new(Color::srgb(t, 0.0, 1.0 - t, 1.0), t)
145            })
146            .collect()
147    }
148
149    /// One texel, decoded back to the linear components it was baked from.
150    fn texel(ramp: &Ramp, i: usize) -> [f32; 4] {
151        let at = i * 8;
152        let mut out = [0.0; 4];
153        for (channel, slot) in out.iter_mut().enumerate() {
154            let byte = at + channel * 2;
155            *slot = half::f16::from_le_bytes([ramp.texels[byte], ramp.texels[byte + 1]]).to_f32();
156        }
157        out
158    }
159
160    #[test]
161    fn a_ramp_is_the_full_width_and_fully_written() {
162        let ramp = Ramp::bake(&stops(6));
163        assert_eq!(ramp.texels.len(), RAMP_WIDTH * 8);
164        // Every alpha is opaque here, so a texel nothing wrote would show as a
165        // hole rather than blend in with its neighbors.
166        assert!(
167            (0..RAMP_WIDTH).all(|i| texel(&ramp, i)[3] == 1.0),
168            "a texel was left unwritten"
169        );
170    }
171
172    #[test]
173    fn the_ends_hold_the_first_and_last_stop() {
174        let ramp = Ramp::bake(&stops(6));
175        let first = texel(&ramp, 0);
176        let last = texel(&ramp, RAMP_WIDTH - 1);
177        // Not exactly the stop colors: the first texel's center is half a texel
178        // in, so it has already traveled that far along the ramp. Close, and
179        // unmistakably at the right end of it.
180        assert!(first[2] > 0.94 && first[0] < 0.06, "first texel {first:?}");
181        assert!(last[0] > 0.94 && last[2] < 0.06, "last texel {last:?}");
182    }
183
184    #[test]
185    fn every_stop_appears_where_it_was_placed() {
186        // Three stops at a quarter, half and three quarters, each a channel of
187        // its own, so a ramp that placed them evenly instead of where they were
188        // asked for would put the wrong color at each probe.
189        let stops = vec![
190            GradientStop::new(Color::linear(1.0, 0.0, 0.0, 1.0), 0.25),
191            GradientStop::new(Color::linear(0.0, 1.0, 0.0, 1.0), 0.5),
192            GradientStop::new(Color::linear(0.0, 0.0, 1.0, 1.0), 0.75),
193        ];
194        let ramp = Ramp::bake(&stops);
195        let at = |t: f32| {
196            texel(
197                &ramp,
198                ((t * RAMP_WIDTH as f32) as usize).min(RAMP_WIDTH - 1),
199            )
200        };
201        assert!(at(0.25)[0] > 0.78, "the red stop is not at a quarter");
202        assert!(at(0.5)[1] > 0.78, "the green stop is not at the half");
203        assert!(at(0.75)[2] > 0.78, "the blue stop is not at three quarters");
204        // And before the first stop it holds that stop rather than fading in
205        // from nothing, which is what clamping means inside the table.
206        assert!(
207            at(0.05)[0] > 0.78,
208            "the ramp faded in before its first stop"
209        );
210    }
211
212    #[test]
213    fn the_ramp_is_monotonic_where_the_stops_are() {
214        // Two stops from black to white. Every texel must be at least as bright
215        // as the one before it: a ramp that wrapped, reversed, or sampled out
216        // of order would break this without necessarily looking wrong at any
217        // single probe.
218        let ramp = Ramp::bake(&[
219            GradientStop::new(Color::linear(0.0, 0.0, 0.0, 1.0), 0.0),
220            GradientStop::new(Color::linear(1.0, 1.0, 1.0, 1.0), 1.0),
221        ]);
222        let mut previous = 0.0;
223        for i in 0..RAMP_WIDTH {
224            let value = texel(&ramp, i)[0];
225            assert!(
226                value >= previous,
227                "texel {i} is {value} after {previous}, so the ramp goes backwards"
228            );
229            previous = value;
230        }
231        assert!(previous > 0.98, "the ramp never reached white");
232    }
233
234    #[test]
235    fn the_table_holds_what_the_walk_produced() {
236        // In and out unchanged, with no eight-bit step in between: the table
237        // stores the color rather than a rounding of it. Both channels come
238        // back at a half because neither was transformed -- which is what makes
239        // the color one stated in the space the table holds, since a color
240        // stated in light would be encoded on the way in and would not.
241        let ramp = Ramp::bake(&[
242            GradientStop::new(Color::srgb(0.5, 0.5, 0.5, 0.5), 0.0),
243            GradientStop::new(Color::srgb(0.5, 0.5, 0.5, 0.5), 1.0),
244        ]);
245        let t = texel(&ramp, RAMP_WIDTH / 2);
246        assert!((t[0] - 0.5).abs() < 1e-3, "color came back {}", t[0]);
247        assert!((t[3] - 0.5).abs() < 1e-3, "alpha came back {}", t[3]);
248    }
249
250    /// The property the table could not hold when it was eight bits through a
251    /// transfer function, and the reason a gradient of five stops disagreed
252    /// with the same gradient stated in four.
253    #[test]
254    fn a_stop_outside_the_srgb_primaries_survives_being_tabulated() {
255        let ramp = Ramp::bake(&[
256            GradientStop::new(Color::srgb(1.2, -0.3, 0.0, 1.0), 0.0),
257            GradientStop::new(Color::srgb(1.2, -0.3, 0.0, 1.0), 1.0),
258        ]);
259        let t = texel(&ramp, RAMP_WIDTH / 2);
260        assert!((t[0] - 1.2).abs() < 1e-2, "above one became {}", t[0]);
261        assert!((t[1] + 0.3).abs() < 1e-2, "below zero became {}", t[1]);
262    }
263
264    #[test]
265    fn no_stops_bakes_to_nothing_rather_than_to_a_panic() {
266        // Reachable only through a paint that carries no stops, which does not
267        // draw -- but a bake that indexed into an empty slice would take the
268        // process down on the way to finding that out.
269        let ramp = Ramp::bake(&[]);
270        assert_eq!(ramp.texels.len(), RAMP_WIDTH * 8);
271        assert!(ramp.texels.iter().all(|b| *b == 0));
272    }
273}