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}