impeller_hal/material.rs
1//! What fills a shape.
2//!
3//! A material is the paint as a backend sees it: fully resolved, in clip space,
4//! and packed into the layout the shader expects. Resolving happens above,
5//! because gradient geometry has to travel through the same transform the shape
6//! did.
7
8use crate::blend::BlendMode;
9
10/// Floats in the packed representation.
11///
12/// This was 128 bytes for a long time because that is exactly what every
13/// device guarantees as push constants, and the material travelled as one.
14/// That guarantee was also a ceiling, and the material had grown to occupy it
15/// exactly -- so the mechanism was deciding what a paint could hold, which is
16/// the wrong way round. Materials travel in a uniform buffer now, and the
17/// limit that binds is `maxUniformBufferRange`, whose guaranteed minimum is
18/// sixteen kilobytes: two orders of magnitude of room rather than none.
19///
20/// The size has not changed with the mechanism, and should not change without
21/// a material that needs it. Every float here is read by a shader that
22/// branches on the kind, and floats nobody reads are bandwidth in the one
23/// place a renderer spends it per draw.
24pub const MATERIAL_FLOATS: usize = 64;
25
26/// Enforced at compile time rather than by a test, so a material that outgrew
27/// what every device guarantees could not be built at all.
28///
29/// The bound is `maxUniformBufferRange`'s guaranteed minimum. A material is
30/// nowhere near it; the assertion is here because the previous bound was
31/// reached, and the way that was noticed was this line failing to compile.
32const _: () = assert!(
33 MATERIAL_FLOATS * 4 <= 16384,
34 "a material must fit the 16 KiB of uniform buffer range every device guarantees"
35);
36
37/// The most stops a gradient carries in the material itself.
38///
39/// Four covers the overwhelming majority of real gradients, and covers them
40/// without a texture, an upload or a binding. It is not a limit on what can be
41/// drawn: beyond this the recorder tabulates the stops into a ramp and the
42/// shader samples it instead, which is what this constant used to say was
43/// waiting on the HAL learning to sample textures. It has since learned.
44pub const MAX_STOPS: usize = 4;
45
46/// Offsets into the packed layout, matching the shader's declaration.
47///
48/// Public because it is a contract between the shader and every backend, not an
49/// internal detail. Both backends copy the packed floats straight into a
50/// uniform buffer, so a member is found by its offset here rather than by a
51/// name -- and naming the offsets in one place keeps the layout from being
52/// written out again as bare indices that quietly go stale when it grows.
53pub mod layout {
54 /// Four stop colors.
55 pub const STOPS: usize = 0;
56 /// Stop positions.
57 pub const OFFSETS: usize = 16;
58 /// Endpoints, or center plus angles.
59 pub const GEOMETRY: usize = 20;
60 /// Clip space to the paint's own space: three columns of a three-by-three,
61 /// in column order, each padded to four floats.
62 ///
63 /// Twelve floats rather than nine because that is how a `mat3x3` sits in a
64 /// uniform block, and because every member here is a four-component vector
65 /// on purpose -- it is what lets both backends copy the packed material
66 /// straight in without writing padding around anything.
67 ///
68 /// The paint's origin is inside this matrix rather than beside it in
69 /// `GEOMETRY`, which is why the first two floats there are unclaimed for
70 /// every kind that carries a mapping. See `invert_to_local`.
71 pub const TO_LOCAL: usize = 24;
72 /// Stop count, material kind, and two floats whose meaning the kind
73 /// decides -- a corner radius, a stroke width, a blur's deviation, a
74 /// gradient's tile mode, a conical gradient's separation.
75 ///
76 /// A zero stop count means the colors are in a ramp texture; see
77 /// `stop_count_code`.
78 pub const PARAMS: usize = 36;
79 /// A color filter's matrix, by column.
80 pub const FILTER: usize = 40;
81 /// The constant a color filter adds.
82 pub const FILTER_OFFSET: usize = 56;
83 /// Which color filter, if any, and in which form its matrix is stated.
84 ///
85 /// The second float holds the tint blend, and the last two hold the dither;
86 /// see [`DITHER`]. Four unrelated things share a slot because the slot is a
87 /// four-component vector whether or not anything fills it, and a fifth
88 /// vector would cost every draw sixteen bytes to carry three unused floats.
89 pub const FILTER_PARAMS: usize = 60;
90 /// The dither amplitude, and whether the target encodes on write.
91 ///
92 /// Amplitude first, in the target's storage units, with zero meaning no
93 /// dithering; then a flag saying whether a step of that size is a step of
94 /// encoded value rather than of light. Both are written by the backend at
95 /// submission rather than by the recorder, because the target's format is
96 /// what decides them and a recording is made without one -- which is also
97 /// what keeps the color policy intact. The shader is handed two numbers and
98 /// still knows nothing about formats.
99 pub const DITHER: usize = 62;
100}
101
102/// The number the shader reads for a tile mode.
103///
104/// Written once rather than at each call site: an image and a gradient must
105/// agree about what `1.0` means, and two copies of a mapping eventually do not.
106/// The stop count the shader reads, which doubles as the ramp flag.
107///
108/// Zero means the colors are in a texture rather than in this material, and
109/// the shader samples them instead of walking the stops. A sentinel rather
110/// than a flag of its own because the two are mutually exclusive by
111/// construction -- a ramp exists only when the stops outnumbered what fits, so
112/// a material with a ramp has no count worth reporting -- and because the float
113/// this frees is the one a conical gradient needs for its second center. That
114/// is the whole reason the fourth parameter slot was available to it.
115fn stop_count_code(count: usize, ramp: &Option<u32>) -> f32 {
116 match ramp {
117 Some(_) => 0.0,
118 None => count.max(1) as f32,
119 }
120}
121
122fn tile_code(tile: TileMode) -> f32 {
123 match tile {
124 TileMode::Clamp => tile::CLAMP,
125 TileMode::Repeat => tile::REPEAT,
126 TileMode::Decal => tile::DECAL,
127 TileMode::Mirror => tile::MIRROR,
128 }
129}
130
131/// Kind selector shared with the shader.
132pub mod kind {
133 pub const SOLID: f32 = 0.0;
134 pub const LINEAR: f32 = 1.0;
135 pub const RADIAL: f32 = 2.0;
136 pub const SWEEP: f32 = 3.0;
137 pub const IMAGE: f32 = 4.0;
138 pub const GLYPH: f32 = 5.0;
139 pub const BLUR: f32 = 6.0;
140 pub const ROUNDED_RECT: f32 = 7.0;
141 pub const ELLIPSE: f32 = 8.0;
142 pub const CONICAL: f32 = 9.0;
143 pub const MESH: f32 = 10.0;
144 pub const MORPHOLOGY: f32 = 11.0;
145 pub const ROUNDED_RECT_BLUR: f32 = 12.0;
146 pub const POINT_FIELD: f32 = 13.0;
147}
148
149/// How many textures one runtime program may sample.
150///
151/// A fixed ceiling because the descriptor set layout is shared: every pipeline
152/// this renderer builds is built against one layout, so the bindings it
153/// declares are the same for a solid fill and for a caller's program. Raising
154/// this costs an image binding on every draw; removing the ceiling costs a
155/// layout per program.
156///
157/// Four covers what `dart:ui` shaders ask for in practice -- an image and a
158/// mask, an image and a gradient ramp, occasionally a third.
159pub const MAX_EFFECT_TEXTURES: usize = 4;
160
161/// How far one morphology pass reaches, in texels each way.
162///
163/// The shader's loop has to be bounded, and this is the bound. It is not a
164/// limit on the filter: a larger radius becomes more passes, since dilating
165/// twice dilates by the sum. Sixty-five samples per pixel per pass is already
166/// enough bandwidth that splitting is the cheaper answer anyway.
167pub const MORPHOLOGY_TAPS: u32 = 32;
168
169/// How many floats a runtime effect may take.
170///
171/// The whole material block, because an effect replaces the shader that would
172/// have read it as a material. A caller writing an effect declares the same
173/// std140 block and reads these as their own.
174pub const RUNTIME_FLOATS: usize = MATERIAL_FLOATS;
175
176/// How a texture is read between its texels.
177///
178/// Not two samplers. The same argument that keeps tile modes in the shader
179/// keeps this there: a sampler baked with a filter would mean one sampler per
180/// combination and a descriptor set per draw that used a different one. A
181/// linear sampler read exactly at a texel's center returns that texel and
182/// nothing else, so nearest sampling is the coordinate snapped to the nearest
183/// center before the read, which is one multiply-floor-divide and no bindings
184/// at all.
185///
186/// `dart:ui` offers four qualities. These are its first two; the other two are
187/// mipmapped and bicubic, and neither exists here to select.
188#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
189pub enum Sampling {
190 /// Blend the texels around the coordinate. The right default: an image
191 /// drawn at any size but its own is otherwise a mess of hard edges.
192 #[default]
193 Linear,
194 /// The one texel the coordinate falls in.
195 ///
196 /// What pixel art needs, and what a sprite drawn at exactly its own size
197 /// wants in order to be certain no neighbor bled in.
198 Nearest,
199 /// A bicubic reconstruction over the sixteen texels around the coordinate.
200 ///
201 /// What `dart:ui` calls `FilterQuality.high`. Sharper than linear under
202 /// magnification, because the curve through four texels along an axis has
203 /// a slope where a straight line between two has a corner, and it costs
204 /// sixteen reads per fragment to say so.
205 ///
206 /// The particular curve is Mitchell-Netravali with `B` and `C` both a
207 /// third, which is what Skia's high quality has always meant and so what a
208 /// caller porting from Flutter is expecting. It rings slightly -- the
209 /// weights go a little negative between one and two texels out -- and that
210 /// overshoot is the sharpening, not an error in it.
211 Cubic,
212 /// A linear read of the mip level that matches how far the image is being
213 /// minified, blended with the level either side of it.
214 ///
215 /// What `dart:ui` calls `FilterQuality.medium`, and the one quality that
216 /// answers minification rather than magnification. Drawn at half its size
217 /// an image read linearly skips every other texel and aliases; read from
218 /// the level built for that size, every texel of the original contributes.
219 ///
220 /// It needs a texture that was allocated with a chain, since a level that
221 /// was never made cannot be read. On a texture without one every read lands
222 /// on the image itself and this is linear -- which is a picture that is
223 /// merely worse rather than wrong, and is what a sampler does with a level
224 /// it does not have.
225 Mipmap,
226}
227
228/// The number the shader reads for a sampling mode.
229fn sampling_code(sampling: Sampling) -> f32 {
230 match sampling {
231 Sampling::Linear => sampling::LINEAR,
232 Sampling::Nearest => sampling::NEAREST,
233 Sampling::Cubic => sampling::CUBIC,
234 Sampling::Mipmap => sampling::MIPMAP,
235 }
236}
237
238/// Sampling selector shared with the shader.
239pub mod sampling {
240 pub const LINEAR: f32 = 0.0;
241 pub const NEAREST: f32 = 1.0;
242 pub const CUBIC: f32 = 2.0;
243 pub const MIPMAP: f32 = 3.0;
244}
245
246/// Tile mode selector shared with the shader.
247pub mod tile {
248 pub const CLAMP: f32 = 0.0;
249 pub const REPEAT: f32 = 1.0;
250 pub const DECAL: f32 = 2.0;
251 pub const MIRROR: f32 = 3.0;
252}
253
254/// What space a color filter's matrix expects its input in.
255///
256/// A `dart:ui` color matrix is defined on straight color, which is the form a
257/// person writes a saturation or a sepia matrix in. A blend against a constant
258/// color is defined on premultiplied color, which is the form the compositing
259/// rules are stated in. Both are affine, both are one matrix here, and this is
260/// which one -- because the shader works in premultiplied color and has to know
261/// whether to undo that before applying the matrix.
262#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
263pub enum ColorForm {
264 /// Premultiplied, which is what the shader already has.
265 #[default]
266 Premultiplied,
267 /// Straight, which the shader divides out first and restores after.
268 Straight,
269}
270
271/// A function applied to a material's color, after the material and before the
272/// blend.
273///
274/// Only affine functions, which is less of a restriction than it sounds: a
275/// `dart:ui` color matrix is affine by definition, and every separable
276/// Porter-Duff blend against a *constant* color is affine in the other operand.
277/// So one matrix in the shader serves both, and which blend modes are offered
278/// is decided on the processor rather than in the fragment.
279#[derive(Debug, Clone, Copy, PartialEq, Default)]
280pub enum ColorFilter {
281 #[default]
282 None,
283 /// `columns[j]` scales the input's `j`th channel, and `offset` is added.
284 ///
285 /// Stored by column rather than by row -- the transpose of how `dart:ui`
286 /// writes it -- because that is what makes the shader four multiply-adds
287 /// of vectors rather than four dot products, and because a column is a
288 /// `vec4` where a row of the five-wide form is not.
289 Matrix {
290 columns: [[f32; 4]; 4],
291 offset: [f32; 4],
292 form: ColorForm,
293 },
294 /// Blend a constant color against the material's own, in a mode a matrix
295 /// cannot state.
296 ///
297 /// `dart:ui`'s `ColorFilter.mode` takes any blend mode, and most of them
298 /// are affine in the destination once the source is fixed -- those become
299 /// a [`Self::Matrix`] and cost the shader nothing beyond the multiply it
300 /// was already doing. The advanced modes are not affine: they are
301 /// piecewise, or they exchange components between channels. This is where
302 /// they go.
303 ///
304 /// It is arithmetic against a constant rather than against the frame, so
305 /// it needs no framebuffer fetch and no extension -- unlike the same mode
306 /// set on the paint, which reaches the hardware's blending unit and is
307 /// gated on one. The shader evaluates it with the same function a mesh's
308 /// per-vertex tint uses.
309 ///
310 /// `color` is premultiplied, as [`Self::blend`] leaves it and as the
311 /// specification states its operands.
312 Blend { color: [f32; 4], mode: BlendMode },
313 /// The sRGB transfer function, one direction or the other.
314 ///
315 /// The one filter `dart:ui` offers that a matrix cannot express: the curve
316 /// is piecewise, and the piece that covers almost all of the range has an
317 /// exponent in it. Approximating it as a power of 2.2 -- which is the usual
318 /// shortcut -- is wrong by about a percent in the midtones and much more
319 /// near black, where the linear segment is doing the work, so the shader
320 /// evaluates the real thing instead.
321 ///
322 /// Applied to straight color, per channel, leaving alpha alone. Gamma on a
323 /// premultiplied channel would be encoding the alpha along with the color.
324 Gamma { direction: Gamma },
325}
326
327/// Which way through the sRGB transfer function a [`ColorFilter::Gamma`] goes.
328#[derive(Debug, Clone, Copy, PartialEq, Eq)]
329pub enum Gamma {
330 /// Linear light in, sRGB's encoding out. `ColorFilter.linearToSrgbGamma`.
331 LinearToSrgb,
332 /// sRGB's encoding in, linear light out. `ColorFilter.srgbToLinearGamma`.
333 SrgbToLinear,
334}
335
336impl ColorFilter {
337 /// A filter from the twenty numbers `dart:ui` states one in.
338 ///
339 /// Row-major and five wide: four scales and a constant per output channel,
340 /// red row first, applied to straight color with each channel from zero to
341 /// one. The constants are in the same units, so a matrix written against
342 /// the 0-255 convention has to have its last column divided by 255 first.
343 pub fn matrix(rows: [f32; 20]) -> Self {
344 let mut columns = [[0.0f32; 4]; 4];
345 let mut offset = [0.0f32; 4];
346 for (out, row) in rows.chunks_exact(5).enumerate() {
347 for (channel, value) in row[..4].iter().enumerate() {
348 columns[channel][out] = *value;
349 }
350 offset[out] = row[4];
351 }
352 Self::Matrix {
353 columns,
354 offset,
355 form: ColorForm::Straight,
356 }
357 }
358
359 /// Linear light encoded into sRGB, which is `ColorFilter.linearToSrgbGamma`.
360 pub fn linear_to_srgb() -> Self {
361 Self::Gamma {
362 direction: Gamma::LinearToSrgb,
363 }
364 }
365
366 /// sRGB decoded back to linear light, which is
367 /// `ColorFilter.srgbToLinearGamma`.
368 pub fn srgb_to_linear() -> Self {
369 Self::Gamma {
370 direction: Gamma::SrgbToLinear,
371 }
372 }
373
374 /// A filter that blends a constant color against the material's own.
375 ///
376 /// The material is the destination and `color` is the source, which is the
377 /// way round `dart:ui` states `ColorFilter.mode` and the way round that
378 /// makes an icon sheet tinted by `SrcIn` mean what everyone expects.
379 ///
380 /// Most modes are affine in the destination once the source is fixed, so
381 /// each becomes a matrix and the shader needs no blending arithmetic at
382 /// all. The advanced modes are not affine -- they are piecewise, or
383 /// exchange components between channels -- so those become
384 /// [`Self::Blend`] and are evaluated per fragment against the constant, by
385 /// the same function a mesh's per-vertex tint goes through.
386 ///
387 /// Every mode is therefore a filter and none of them is refused. This used
388 /// to answer a `Result` and say in its own documentation that the advanced
389 /// modes were "refused rather than approximated", which the code below has
390 /// never done. Eighteen call sites carried an `expect` that could not fire, and
391 /// several of their messages stated the opposite of what the branch they
392 /// were on does.
393 ///
394 /// `color` is straight, like every color a caller states.
395 pub fn blend(color: [f32; 4], mode: BlendMode) -> Self {
396 use BlendMode as B;
397 let alpha = color[3];
398 // Premultiplied, because the rules below are stated for premultiplied
399 // operands and the shader's own color is premultiplied too.
400 let s = [color[0] * alpha, color[1] * alpha, color[2] * alpha, alpha];
401 let zero = [[0.0f32; 4]; 4];
402 let scaled = |k: f32| {
403 let mut m = zero;
404 for (i, column) in m.iter_mut().enumerate() {
405 column[i] = k;
406 }
407 m
408 };
409 // The destination's alpha reaches every output channel through this
410 // column alone, which is what makes the modes that multiply by it --
411 // or by one minus it -- a matrix rather than a special case.
412 let with_alpha_column = |mut m: [[f32; 4]; 4], column: [f32; 4]| {
413 for (i, value) in column.iter().enumerate() {
414 m[3][i] += *value;
415 }
416 m
417 };
418 let negated = [-s[0], -s[1], -s[2], -s[3]];
419 let (columns, offset) = match mode {
420 B::Clear => (zero, [0.0; 4]),
421 B::Src => (zero, s),
422 B::Dst => (scaled(1.0), [0.0; 4]),
423 B::SrcOver => (scaled(1.0 - alpha), s),
424 B::DstOver => (with_alpha_column(scaled(1.0), negated), s),
425 B::SrcIn => (with_alpha_column(zero, s), [0.0; 4]),
426 B::DstIn => (scaled(alpha), [0.0; 4]),
427 B::SrcOut => (with_alpha_column(zero, negated), s),
428 B::DstOut => (scaled(1.0 - alpha), [0.0; 4]),
429 B::SrcATop => (with_alpha_column(scaled(1.0 - alpha), s), [0.0; 4]),
430 B::DstATop => (with_alpha_column(scaled(alpha), negated), s),
431 B::Xor => (with_alpha_column(scaled(1.0 - alpha), negated), s),
432 B::Plus => (scaled(1.0), s),
433 B::Modulate => {
434 let mut m = zero;
435 for (i, column) in m.iter_mut().enumerate() {
436 column[i] = s[i];
437 }
438 (m, [0.0; 4])
439 }
440 // Not affine, so not a matrix. Evaluated per fragment instead,
441 // against the same constant, by the same function a mesh's
442 // per-vertex tint goes through.
443 mode => return Self::Blend { color: s, mode },
444 };
445 Self::Matrix {
446 columns,
447 offset,
448 form: ColorForm::Premultiplied,
449 }
450 }
451
452 /// Whether this changes anything.
453 pub fn is_identity(&self) -> bool {
454 match self {
455 Self::None => true,
456 Self::Matrix {
457 columns,
458 offset,
459 form: _,
460 } => {
461 offset.iter().all(|v| *v == 0.0)
462 && columns.iter().enumerate().all(|(j, column)| {
463 column
464 .iter()
465 .enumerate()
466 .all(|(i, v)| *v == if i == j { 1.0 } else { 0.0 })
467 })
468 }
469 // `Dst` returns the destination untouched, which is the identity
470 // and is the only mode here that is. It cannot arrive through
471 // `blend`, which sends every affine mode to a matrix, but it can be
472 // written by hand.
473 Self::Blend { mode, .. } => *mode == BlendMode::Dst,
474 // The curve is the identity at exactly three points -- zero, one,
475 // and nowhere else on the range -- so as a function it never is.
476 Self::Gamma { .. } => false,
477 }
478 }
479
480 /// The code the shader reads to choose a path.
481 fn code(&self) -> f32 {
482 match self {
483 Self::None => filter::NONE,
484 Self::Matrix {
485 form: ColorForm::Premultiplied,
486 ..
487 } => filter::PREMULTIPLIED,
488 Self::Matrix {
489 form: ColorForm::Straight,
490 ..
491 } => filter::STRAIGHT,
492 Self::Gamma {
493 direction: Gamma::LinearToSrgb,
494 } => filter::LINEAR_TO_SRGB,
495 Self::Gamma {
496 direction: Gamma::SrgbToLinear,
497 } => filter::SRGB_TO_LINEAR,
498 Self::Blend { .. } => filter::BLEND,
499 }
500 }
501
502 pub(crate) fn pack_into(&self, out: &mut [f32; MATERIAL_FLOATS]) {
503 out[layout::FILTER_PARAMS] = self.code();
504 match self {
505 Self::Matrix {
506 columns, offset, ..
507 } => {
508 for (j, column) in columns.iter().enumerate() {
509 out[layout::FILTER + j * 4..layout::FILTER + j * 4 + 4].copy_from_slice(column);
510 }
511 out[layout::FILTER_OFFSET..layout::FILTER_OFFSET + 4].copy_from_slice(offset);
512 }
513 // The constant goes where a matrix's offset would, which is what it
514 // is: the term that does not depend on the material. The mode goes
515 // in the first float of the matrix itself, which this filter has no
516 // use for -- sixteen floats are already reserved for every draw,
517 // and spending a fifth vector to carry one number would cost every
518 // draw that does not blend.
519 Self::Blend { color, mode } => {
520 out[layout::FILTER_OFFSET..layout::FILTER_OFFSET + 4].copy_from_slice(color);
521 out[layout::FILTER] = mode.code();
522 }
523 Self::None | Self::Gamma { .. } => {}
524 }
525 }
526}
527
528/// Filter selector shared with the shader.
529pub mod filter {
530 pub const NONE: f32 = 0.0;
531 pub const PREMULTIPLIED: f32 = 1.0;
532 pub const STRAIGHT: f32 = 2.0;
533 pub const LINEAR_TO_SRGB: f32 = 3.0;
534 pub const SRGB_TO_LINEAR: f32 = 4.0;
535 /// A blend against a constant color, for the modes a matrix cannot state.
536 pub const BLEND: f32 = 5.0;
537
538 /// Whether a code names a filter that reads straight rather than
539 /// premultiplied color.
540 ///
541 /// The shader decides this by comparison rather than by equality, and gets
542 /// the same answer, so the boundary lives here where both can cite it: a
543 /// filter added above this line is straight unless it says otherwise.
544 ///
545 /// [`BLEND`] says otherwise, which is what the sentence above anticipated.
546 /// The compositing specification states every blend on premultiplied
547 /// operands and the shader's own blend function takes them that way, so
548 /// handing it straight color would be handing it the wrong numbers.
549 pub fn is_straight(code: f32) -> bool {
550 code > PREMULTIPLIED && code < BLEND
551 }
552}
553
554/// A color stop.
555#[derive(Debug, Clone, Copy, PartialEq)]
556pub struct Stop {
557 /// Linear color with straight alpha.
558 pub color: [f32; 4],
559 /// Position along the gradient, from zero to one.
560 pub offset: f32,
561}
562
563impl Stop {
564 pub fn new(color: [f32; 4], offset: f32) -> Self {
565 Self { color, offset }
566 }
567}
568
569/// Maps a clip-space offset into a gradient's own space, in column order.
570///
571/// Clip space is anisotropic whenever the target is not square, and a transform
572/// may rotate or skew as well, so a circle in user space is an ellipse there.
573/// Radial and sweep gradients measure distance and angle, both of which that
574/// distortion changes, so they map back before measuring. A linear gradient
575/// projects onto an axis, which distortion does not affect, and so does not
576/// need this.
577pub type ToLocal = [f32; 12];
578
579/// How a shape is filled.
580#[derive(Debug, Clone, PartialEq)]
581pub enum Material {
582 Solid([f32; 4]),
583 /// A gradient along an axis, starting at a point **in clip space**.
584 ///
585 /// Clip space for the start, because the fragment stage locates itself from
586 /// an interpolated clip position: the alternative, the fragment coordinate
587 /// builtin, has a different origin in each graphics API and would run the
588 /// gradient in opposite directions on the two backends.
589 ///
590 /// The axis is in the gradient's own space, and `to_local` maps a clip-space
591 /// offset into it — the same pairing radial and sweep use, and for the same
592 /// reason. Clip space is anisotropic whenever the target is not square, so
593 /// projecting onto an axis *there* weights the two axes by the target's
594 /// shape: on a target twice as wide as it is tall, a diagonal gradient runs
595 /// in the wrong direction.
596 LinearGradient {
597 /// End minus start, in the gradient's own space.
598 axis: [f32; 2],
599 to_local: ToLocal,
600 stops: Vec<Stop>,
601 /// Texture slot holding this gradient's colors, when they did not fit.
602 ///
603 /// `None` is the ordinary case: the stops travel in the material and
604 /// the shader walks them. `Some` means the recorder tabulated them into
605 /// an image instead, because there were more than [`MAX_STOPS`], and
606 /// the shader reads the color at the parameter rather than computing
607 /// it. The two must agree where both are possible, which is what makes
608 /// the choice invisible to a caller.
609 ramp: Option<u32>,
610 /// What happens beyond the two endpoints.
611 ///
612 /// The parameter a gradient is sampled by runs from zero at one end to
613 /// one at the other and is defined everywhere else too, so a shape
614 /// larger than its gradient asks a question the stops do not answer.
615 /// Clamping holds the end colors, which is the usual choice; repeating
616 /// tiles the ramp, which is what a stripe pattern is; decal draws
617 /// nothing outside, the same meaning it has for an image.
618 tile: TileMode,
619 },
620 /// A gradient outward from a center, **in clip space**, where `to_local`
621 /// carries the radius: it maps the clip-space offset so that the gradient's
622 /// edge lands at unit distance.
623 RadialGradient {
624 to_local: ToLocal,
625 stops: Vec<Stop>,
626 /// Texture slot holding this gradient's colors, when they did not fit.
627 ///
628 /// `None` is the ordinary case: the stops travel in the material and
629 /// the shader walks them. `Some` means the recorder tabulated them into
630 /// an image instead, because there were more than [`MAX_STOPS`], and
631 /// the shader reads the color at the parameter rather than computing
632 /// it. The two must agree where both are possible, which is what makes
633 /// the choice invisible to a caller.
634 ramp: Option<u32>,
635 /// What happens beyond the radius. See [`Material::LinearGradient`].
636 tile: TileMode,
637 },
638 /// A gradient around a center, **in clip space**, running from `start_angle`
639 /// to `end_angle` in radians.
640 SweepGradient {
641 to_local: ToLocal,
642 start_angle: f32,
643 end_angle: f32,
644 stops: Vec<Stop>,
645 /// Texture slot holding this gradient's colors, when they did not fit.
646 ///
647 /// `None` is the ordinary case: the stops travel in the material and
648 /// the shader walks them. `Some` means the recorder tabulated them into
649 /// an image instead, because there were more than [`MAX_STOPS`], and
650 /// the shader reads the color at the parameter rather than computing
651 /// it. The two must agree where both are possible, which is what makes
652 /// the choice invisible to a caller.
653 ramp: Option<u32>,
654 /// What happens outside the swept arc.
655 ///
656 /// Unlike the other two this can be a no-op: a sweep covering the whole
657 /// turn has no outside, and every direction lands within it.
658 tile: TileMode,
659 },
660 /// A gradient between two circles, the general form the other two are
661 /// special cases of.
662 ///
663 /// `center` is the first circle's center **in clip space**, and `to_local`
664 /// maps a clip-space offset into a space where that center is the origin
665 /// and the second circle's center lies at `(separation, 0)`. Putting the
666 /// separation on an axis costs nothing -- the rotation folds into a matrix
667 /// that has to be there anyway -- and buys the second center for one float
668 /// instead of two, which is what makes this fit at all.
669 ///
670 /// The radii are in that same space and are *not* normalized, unlike the
671 /// radial gradient's, because there are two of them and a scale can only
672 /// remove one. Carrying both plainly also means the degenerate cases need
673 /// no special handling: concentric circles are `separation == 0`, and a
674 /// cone rather than a tube is `radius_delta != 0`.
675 ConicalGradient {
676 to_local: ToLocal,
677 /// Radius of the first circle.
678 start_radius: f32,
679 /// Second radius minus the first.
680 radius_delta: f32,
681 /// Distance between the two centers.
682 separation: f32,
683 stops: Vec<Stop>,
684 /// Texture slot holding this gradient's colors, when they did not fit.
685 /// See [`Material::LinearGradient`].
686 ramp: Option<u32>,
687 /// What happens where the parameter leaves the unit interval. See
688 /// [`Material::LinearGradient`].
689 tile: TileMode,
690 },
691 /// A caller's own fragment program, with the floats it reads.
692 ///
693 /// The program is named rather than carried: registering one builds a
694 /// pipeline, which is expensive and outlives any draw, so a context holds
695 /// them and a material names which. The floats travel in the same uniform
696 /// block every other material uses, which is what lets an effect exist
697 /// without a second descriptor set.
698 Runtime {
699 program: u32,
700 uniforms: Vec<f32>,
701 /// The textures the program may sample, in the order it declares them.
702 ///
703 /// `None` in a position means the program does not read that binding,
704 /// and the backend binds its placeholder there -- a pipeline must have
705 /// every binding it declares bound, however unreachable the branch
706 /// reading it.
707 ///
708 /// The count is fixed rather than free because the descriptor set
709 /// layout every pipeline is built against has to be one layout. Four is
710 /// what that costs: three unused image bindings on a draw that samples
711 /// nothing, against a second set and a second layout for the draws that
712 /// want more than one.
713 textures: [Option<u32>; MAX_EFFECT_TEXTURES],
714 },
715 /// A texture, sampled at coordinates the vertices carry.
716 ///
717 /// Deliberately not [`Material::Image`] with a switch on where its
718 /// coordinates come from. An image mapped from clip space needs an origin,
719 /// a matrix and a source rectangle to say which part of a sheet it draws;
720 /// a mesh needs none of them, because a caller stating a coordinate per
721 /// vertex has already answered all three. What is left is small enough to
722 /// be its own thing, and keeping it separate means neither carries a field
723 /// that means nothing for it.
724 ///
725 /// This is what makes a sprite batch one draw: a hundred quads reading a
726 /// hundred different parts of one sheet differ only in their vertices.
727 Mesh {
728 /// Index into the texture table given at submission.
729 slot: u32,
730 /// Scales the sampled color, applied to premultiplied color like
731 /// [`Material::Image`]'s.
732 alpha: f32,
733 /// Straight color the sampled texel is multiplied by; white changes
734 /// nothing.
735 tint: [f32; 4],
736 /// What happens where a vertex names a coordinate outside the texture.
737 tile: TileMode,
738 /// How to read between texels.
739 sampling: Sampling,
740 },
741 /// A texture, sampled through a mapping from clip space.
742 ///
743 /// `origin` and `to_local` together are an affine: a clip-space position
744 /// maps to texture coordinates as `to_local * (clip - origin)`, which lands
745 /// the image's top-left corner at zero and its bottom-right at one. The
746 /// same pair a radial gradient uses, for the same reason — clip space is
747 /// anisotropic on a non-square target, so a mapping that ignored it would
748 /// stretch every image by the aspect ratio.
749 ///
750 /// Which texture is not named here. The material is data the recorder
751 /// produces without touching the device, so it carries a slot into the
752 /// table supplied at submission instead of a backend handle.
753 Image {
754 to_local: ToLocal,
755 /// Index into the texture table given at submission.
756 slot: u32,
757 /// Scales the sampled color, for drawing an image translucently.
758 ///
759 /// Applied to premultiplied color, so it scales the whole texel rather
760 /// than only its alpha. A texture holds premultiplied color whether it
761 /// was uploaded or rendered into, and treating a sample as straight
762 /// alpha would apply the alpha twice — invisible for an opaque image,
763 /// and plain the moment one translucent image is drawn into another.
764 alpha: f32,
765 tile: TileMode,
766 /// How to read between texels.
767 sampling: Sampling,
768 /// The part of the texture to draw, as `[u0, v0, u1, v1]` from zero to
769 /// one.
770 ///
771 /// The whole texture is `[0, 0, 1, 1]`, which is what every caller
772 /// wanted until sprite sheets. Normalized rather than in texels because
773 /// a material is built by a recorder that has never seen the texture
774 /// and cannot know how large it is; the caller who uploaded it does.
775 ///
776 /// Applied after tiling rather than before, so a repeat repeats the
777 /// selected piece rather than the whole sheet -- which is the only
778 /// reading of "tile this sprite" that means anything.
779 source: [f32; 4],
780 /// Straight color the sampled texel is multiplied by; white changes
781 /// nothing.
782 ///
783 /// What turns one monochrome icon sheet into every state a control has.
784 /// A generalization of `alpha` rather than a rival to it: a tint of
785 /// `[1, 1, 1, a]` is exactly that scaling, and both are applied because
786 /// removing the narrower one would break callers for no gain.
787 ///
788 /// Straight rather than premultiplied because that is how a caller
789 /// states a color, and the shader premultiplies it before multiplying a
790 /// texel that already is -- scaling color by the tint's alpha as well,
791 /// which is what keeps the result premultiplied rather than merely
792 /// close to it.
793 tint: [f32; 4],
794 },
795 /// A rounded rectangle evaluated per fragment rather than tessellated.
796 ///
797 /// The shape an interface is mostly made of, and the one where computing
798 /// coverage beats building triangles for it. A tessellated rounded
799 /// rectangle costs vertices proportional to how round it is and has hard
800 /// edges unless the whole pass is multisampled; this is two triangles
801 /// whatever the radius, and antialiases itself from the distance field it
802 /// already computes.
803 ///
804 /// The geometry is in the shape's own space, with `to_local` mapping a
805 /// clip-space position into it -- the same pairing the gradients use, and
806 /// for the same reason: clip space is anisotropic on a target that is not
807 /// square, and a distance measured there would round the corners by
808 /// different amounts on each axis.
809 RoundedRect {
810 color: [f32; 4],
811 /// Half the width and height, in the shape's own space.
812 half_size: [f32; 2],
813 to_local: ToLocal,
814 /// Corner radius, in the shape's own space.
815 radius: f32,
816 /// The radius the outline's *outer* edge turns through, which is not
817 /// always the radius grown by half the stroke.
818 ///
819 /// An outline is the difference of two offset shapes rather than a band
820 /// around one, and the outer offset is where a join shows. Grow a
821 /// rounded corner and you get a bigger rounded corner, so this is
822 /// `radius + stroke / 2` for anything with a radius, and for anything
823 /// with a round join. A *mitered* square corner is the exception: its
824 /// offset is still square, so this is zero and the field draws the
825 /// point the join asks for.
826 ///
827 /// Carried rather than derived because the shader cannot see the join,
828 /// and because deriving it wrongly is what made a square-cornered
829 /// stroke come out with rounded corners and be refused this route
830 /// altogether.
831 outer_radius: f32,
832 /// Trace the outline at this width rather than filling, in the shape's
833 /// own space. Zero fills.
834 ///
835 /// Costs a distance field nothing: the field already says how far every
836 /// fragment is from the edge, so an outline is the band where that is
837 /// small. Tessellating one instead means building a second shape --
838 /// offset inward and outward, with the corners resolved -- which is
839 /// where a stroked outline gets its vertex count and its joins.
840 stroke: f32,
841 },
842 /// A *blurred* rounded rectangle evaluated per fragment, with no blur pass.
843 ///
844 /// The sibling of [`Self::RoundedRect`], and the reason it is worth having
845 /// is that the general route costs three passes per shape -- one for the
846 /// content and two for a separable Gaussian -- where this costs one draw in
847 /// the pass already being recorded. On a Raspberry Pi 5 three shadows that
848 /// way are 7.8 ms of a 26.8 ms frame and nine of its fourteen passes.
849 ///
850 /// The method is Raph Levien's "Blurred rounded rectangles", which is what
851 /// upstream's `SolidRRectBlurContents` evaluates too: the exact convolution
852 /// of a Gaussian with a rounded rectangle has no closed form, and this
853 /// approximates it as a product of two error functions along an axis,
854 /// corrected for the corners by measuring distance with an exponent other
855 /// than two. Every field here is a number the CPU precomputed for that
856 /// expression rather than anything a caller stated -- see
857 /// `Canvas::rrect_blur_material`, which is the only place they are derived.
858 ///
859 /// Only where every corner shares one circular radius, which is upstream's
860 /// condition too. Anything else takes the general route.
861 RoundedRectBlur {
862 color: [f32; 4],
863 to_local: ToLocal,
864 /// Half the rectangle, less `r1`, in the shape's own space.
865 adjust: [f32; 2],
866 /// The corner radius the approximation uses, which is not the caller's:
867 /// it grows with the deviation, because a blurred corner is rounder
868 /// than a sharp one.
869 r1: f32,
870 /// The exponent the corner distance is measured with. Two is a circle;
871 /// this is larger, which is what makes the blurred corner's profile
872 /// match a Gaussian's rather than a circle's.
873 exponent: f32,
874 /// One over the deviation, which is what the error function takes.
875 s_inv: f32,
876 /// The shorter side, which bounds how far the fade can reach before the
877 /// two edges of the shape meet.
878 min_edge: f32,
879 /// Normalizes the fade so that the middle of a large shape reaches full
880 /// coverage.
881 scale: f32,
882 },
883 /// An ellipse evaluated per fragment.
884 ///
885 /// Its own variant rather than a rounded rectangle with a large radius,
886 /// which gives a stadium: past half the shorter side a rounded rectangle
887 /// stops changing, where an ellipse keeps curving along both axes.
888 ///
889 /// The same geometry a rounded rectangle carries, less the radius, which
890 /// the two axes already state.
891 Ellipse {
892 color: [f32; 4],
893 /// The two semi-axes, in the shape's own space.
894 half_size: [f32; 2],
895 to_local: ToLocal,
896 /// Trace the outline at this width rather than filling. Zero fills.
897 stroke: f32,
898 },
899 /// One axis of a separable Gaussian blur of a sampled texture.
900 ///
901 /// Separable because a two-dimensional Gaussian is the product of two
902 /// one-dimensional ones, so blurring along each axis in turn gives the
903 /// same result as a square of taps at a fraction of the cost: at a radius
904 /// of sixteen that is thirty-three taps against a thousand and eighty-nine.
905 /// Two passes are the price, which is why this names an axis rather than
906 /// describing the whole blur.
907 Blur {
908 to_local: ToLocal,
909 slot: u32,
910 /// One tap's step, in the sampled texture's own coordinates.
911 ///
912 /// The axis and the texel size together: a horizontal pass over a
913 /// target `w` wide steps `(1/w, 0)`. Stated here rather than derived in
914 /// the shader because the shader does not know the size of what it is
915 /// sampling.
916 step: [f32; 2],
917 /// Standard deviation, in taps.
918 ///
919 /// The tap count follows from it -- the kernel reaches
920 /// `(sigma - 0.5) * sqrt(3)` each way, which is upstream's radius for a
921 /// given deviation -- so a caller sets how soft the result is and
922 /// nothing else.
923 sigma: f32,
924 },
925 /// One axis of a morphological filter of a finished layer.
926 ///
927 /// The largest or smallest sample within a radius, per channel, which is
928 /// what `dart:ui` calls `ImageFilter.dilate` and `ImageFilter.erode`. Like
929 /// the blur it is separable -- a rectangular structuring element is the
930 /// product of two intervals -- so two passes give the square of taps.
931 ///
932 /// Unlike the blur it is also *decomposable*: dilating by `a` and then by
933 /// `b` is dilating by `a + b` exactly, because the structuring elements add
934 /// under the Minkowski sum. A radius past what one pass can reach is
935 /// therefore split across passes rather than approximated by sampling more
936 /// sparsely. Sparse taps work for a blur, where a missed sample costs a
937 /// little smoothness, and do not work here: the result is a maximum, so a
938 /// missed sample is a scallop in the edge.
939 Morphology {
940 to_local: ToLocal,
941 slot: u32,
942 /// One tap's step, in the sampled texture's own coordinates. As
943 /// [`Self::Blur::step`].
944 step: [f32; 2],
945 /// How many texels each way this pass reaches, at most
946 /// [`MORPHOLOGY_TAPS`].
947 ///
948 /// A whole number of texels, because the structuring element is a set
949 /// of samples rather than a weighting of them and there is no meaning
950 /// to half of one.
951 radius: f32,
952 /// The largest sample in reach rather than the smallest.
953 dilate: bool,
954 },
955 /// Coverage sampled from an atlas, tinting one color.
956 ///
957 /// Distinct from [`Self::Image`] in two ways that matter. The texture is
958 /// read as *coverage* rather than as color — one channel scaling a solid,
959 /// which is what antialiased text is — and the coordinates come from the
960 /// vertices rather than from a mapping in the paint, so a run of glyphs
961 /// reading different parts of one atlas is a single draw.
962 Glyph {
963 /// Linear color with straight alpha, as the text is painted.
964 color: [f32; 4],
965 /// Index into the texture table given at submission.
966 slot: u32,
967 },
968 /// Many discs of one color, evaluated from the vertices rather than the
969 /// paint, so that a field of them is one draw.
970 ///
971 /// Carries no mapping and no size, which is the whole point of it. Every
972 /// other fragment-evaluated shape here locates itself through `to_local`,
973 /// and `to_local` holds the shape's center -- so two of them at different
974 /// places are two materials and cannot share a draw. This one locates
975 /// itself from the interpolated texture coordinate, which the vertices
976 /// carry as the unit circle's corners, so any number of discs at any
977 /// centers are one material and one draw.
978 ///
979 /// The edge is the same edge. `disc_coverage` in the shader differentiates
980 /// the implicit function across the pixel rather than forming a distance,
981 /// and a derivative of an interpolated value is as available as that of a
982 /// computed one.
983 PointField {
984 /// One color for the field, premultiplied as everything here is.
985 color: [f32; 4],
986 },
987}
988
989/// What happens outside an image's own bounds.
990#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
991pub enum TileMode {
992 /// Hold the edge pixel. The usual choice for drawing an image once.
993 #[default]
994 Clamp,
995 /// Repeat the image, tiling the plane.
996 Repeat,
997 /// Draw nothing outside the image.
998 ///
999 /// Distinct from clamping in the only case that matters: a shape larger
1000 /// than the image it is filled with. Clamping smears the border across the
1001 /// remainder, which reads as a rendering fault rather than as a choice.
1002 Decal,
1003 /// Repeat, reversing every other copy.
1004 ///
1005 /// What repeating is for when the two ends do not match. A ramp tiled by
1006 /// [`TileMode::Repeat`] jumps from its last color back to its first at
1007 /// every period, and that discontinuity is a visible seam; reflecting each
1008 /// alternate copy joins end to end and leaves none. The period is twice as
1009 /// long, since a copy and its reflection make one.
1010 Mirror,
1011}
1012
1013impl Material {
1014 pub fn solid(color: [f32; 4]) -> Self {
1015 Self::Solid(color)
1016 }
1017
1018 /// The same material at `factor` of its opacity.
1019 ///
1020 /// Applied to every color a material carries, since a gradient's stops may
1021 /// differ in alpha and scaling them together is what keeps the ramp the
1022 /// same ramp. Straight alpha, so this happens before the premultiply the
1023 /// packing does rather than after it.
1024 ///
1025 /// Two variants are left alone and it is worth saying which. A blur or a
1026 /// morphology is a filter over a finished pass rather than a paint, so
1027 /// there is no color in it to dim -- and nothing asks this of one. A
1028 /// caller's program is the other: its output is whatever it computes, and
1029 /// this renderer has no uniform it may write to. That is a real limit
1030 /// wherever the caller wanted the dimming, and it is stated at the one
1031 /// place that asks for it.
1032 #[must_use]
1033 pub fn with_opacity(mut self, factor: f32) -> Self {
1034 let scale = |color: &mut [f32; 4]| color[3] *= factor;
1035 match &mut self {
1036 Self::Solid(color)
1037 | Self::RoundedRect { color, .. }
1038 | Self::RoundedRectBlur { color, .. }
1039 | Self::Ellipse { color, .. }
1040 | Self::PointField { color }
1041 | Self::Glyph { color, .. } => scale(color),
1042 Self::LinearGradient { stops, .. }
1043 | Self::RadialGradient { stops, .. }
1044 | Self::SweepGradient { stops, .. }
1045 | Self::ConicalGradient { stops, .. } => {
1046 for stop in stops.iter_mut() {
1047 scale(&mut stop.color);
1048 }
1049 }
1050 Self::Mesh { alpha, .. } | Self::Image { alpha, .. } => *alpha *= factor,
1051 Self::Runtime { .. } | Self::Blur { .. } | Self::Morphology { .. } => {}
1052 }
1053 self
1054 }
1055
1056 /// Whether drawing with this would change anything.
1057 pub fn is_invisible(&self) -> bool {
1058 match self {
1059 Self::Solid(color) | Self::PointField { color } => color[3] <= 0.0,
1060 Self::LinearGradient { stops, .. }
1061 | Self::RadialGradient { stops, .. }
1062 | Self::SweepGradient { stops, .. }
1063 | Self::ConicalGradient { stops, .. } => {
1064 stops.is_empty() || stops.iter().all(|s| s.color[3] <= 0.0)
1065 }
1066 // What the texture holds is unknown here, so only a zero alpha
1067 // makes an image provably invisible.
1068 Self::Image { alpha, .. } | Self::Mesh { alpha, .. } => *alpha <= 0.0,
1069 // What a caller's program draws is unknowable from here, so the
1070 // only honest answer is that it might draw something.
1071 Self::Runtime { .. } => false,
1072 // A blur of nothing is nothing, but the pass still has to run: what
1073 // it samples is not knowable from here.
1074 Self::Blur { .. } | Self::Morphology { .. } => false,
1075 // No `half_size` to test: a blurred rectangle with no area still
1076 // draws, because the blur carries color past where the shape is.
1077 Self::RoundedRectBlur { color, .. } => color[3] <= 0.0,
1078 Self::RoundedRect {
1079 color, half_size, ..
1080 }
1081 | Self::Ellipse {
1082 color, half_size, ..
1083 } => color[3] <= 0.0 || half_size[0] <= 0.0 || half_size[1] <= 0.0,
1084 Self::Glyph { color, .. } => color[3] <= 0.0,
1085 }
1086 }
1087
1088 /// The texture slot this samples, for a backend building its bindings.
1089 ///
1090 /// Matched exhaustively rather than with a catch-all. A variant that
1091 /// samples something and is not listed here reports no slot, so the backend
1092 /// binds its placeholder and the draw comes out flat white -- a plausible
1093 /// picture rather than an error, and one nothing else would explain.
1094 /// Every texture slot this material samples, in binding order.
1095 ///
1096 /// One entry for everything but a runtime program, which may declare
1097 /// several. A backend building descriptors needs the whole tuple, since a
1098 /// set holds all of them at once; a backend asking only which slot to bind
1099 /// first has [`Self::texture_slot`].
1100 pub fn texture_slots(&self) -> [Option<u32>; MAX_EFFECT_TEXTURES] {
1101 match self {
1102 Self::Runtime { textures, .. } => *textures,
1103 other => {
1104 let mut slots = [None; MAX_EFFECT_TEXTURES];
1105 slots[0] = other.texture_slot();
1106 slots
1107 }
1108 }
1109 }
1110
1111 pub fn texture_slot(&self) -> Option<u32> {
1112 match self {
1113 Self::Image { slot, .. }
1114 | Self::Mesh { slot, .. }
1115 | Self::Glyph { slot, .. }
1116 | Self::Blur { slot, .. }
1117 | Self::Morphology { slot, .. } => Some(*slot),
1118 // A gradient names a texture only when its colors were too many to
1119 // carry, which is why this is an option rather than a slot.
1120 Self::LinearGradient { ramp, .. }
1121 | Self::RadialGradient { ramp, .. }
1122 | Self::SweepGradient { ramp, .. }
1123 | Self::ConicalGradient { ramp, .. } => *ramp,
1124 Self::Solid(_)
1125 | Self::PointField { .. }
1126 | Self::RoundedRect { .. }
1127 | Self::RoundedRectBlur { .. }
1128 | Self::Ellipse { .. } => None,
1129 // Whatever a caller named, and `None` where they named nothing --
1130 // in which case the placeholder is bound and a program that
1131 // samples anyway reads opaque white.
1132 // The first of them, for the callers that still ask a material for
1133 // one slot. `texture_slots` is what a backend binding several
1134 // should ask.
1135 Self::Runtime { textures, .. } => textures[0],
1136 }
1137 }
1138
1139 /// The stops, for any material that has them.
1140 fn stops(&self) -> &[Stop] {
1141 match self {
1142 Self::Solid(_)
1143 | Self::PointField { .. }
1144 | Self::Image { .. }
1145 | Self::Mesh { .. }
1146 | Self::Glyph { .. }
1147 | Self::Blur { .. }
1148 | Self::Morphology { .. }
1149 | Self::RoundedRect { .. }
1150 | Self::RoundedRectBlur { .. }
1151 | Self::Ellipse { .. }
1152 | Self::Runtime { .. } => &[],
1153 Self::LinearGradient { stops, .. }
1154 | Self::RadialGradient { stops, .. }
1155 | Self::SweepGradient { stops, .. }
1156 | Self::ConicalGradient { stops, .. } => stops,
1157 }
1158 }
1159
1160 /// Pack into the layout the shader declares.
1161 ///
1162 /// Every member is a four-component vector, which is what lets this be a
1163 /// flat array of floats copied straight into a uniform buffer: the std140
1164 /// rules the shader's block is declared with place a `vec4` and an array
1165 /// of them at exactly these offsets, so no member needs padding written
1166 /// around it.
1167 ///
1168 /// Stops beyond the limit are dropped rather than resampled, and the count
1169 /// travels alongside so the shader ignores unused entries instead of
1170 /// blending toward whatever happens to be in them.
1171 pub fn to_uniform(&self) -> [f32; MATERIAL_FLOATS] {
1172 let mut out = [0.0f32; MATERIAL_FLOATS];
1173
1174 if let Self::Solid(color) = self {
1175 out[layout::STOPS..layout::STOPS + 4].copy_from_slice(color);
1176 out[layout::PARAMS] = 1.0;
1177 out[layout::PARAMS + 1] = kind::SOLID;
1178 return out;
1179 }
1180
1181 // A glyph is a solid color plus a texture read; the coordinates come
1182 // from the vertices, so nothing about the mapping is packed here.
1183 if let Self::Glyph { color, .. } = self {
1184 out[layout::STOPS..layout::STOPS + 4].copy_from_slice(color);
1185 out[layout::PARAMS] = 1.0;
1186 out[layout::PARAMS + 1] = kind::GLYPH;
1187 return out;
1188 }
1189
1190 // An image carries no stops and no count, and must be packed before
1191 // the gradient path below decides it has too few to interpolate.
1192 if let Self::PointField { color } = self {
1193 out[layout::STOPS..layout::STOPS + 4].copy_from_slice(color);
1194 out[layout::PARAMS] = 1.0;
1195 out[layout::PARAMS + 1] = kind::POINT_FIELD;
1196 return out;
1197 }
1198
1199 if let Self::Ellipse {
1200 color,
1201 half_size,
1202 to_local,
1203 stroke,
1204 } = self
1205 {
1206 out[layout::STOPS..layout::STOPS + 4].copy_from_slice(color);
1207 out[layout::GEOMETRY + 2] = half_size[0];
1208 out[layout::GEOMETRY + 3] = half_size[1];
1209 out[layout::TO_LOCAL..layout::TO_LOCAL + 12].copy_from_slice(to_local);
1210 out[layout::PARAMS] = 1.0;
1211 out[layout::PARAMS + 1] = kind::ELLIPSE;
1212 out[layout::PARAMS + 3] = *stroke;
1213 return out;
1214 }
1215
1216 if let Self::RoundedRect {
1217 color,
1218 half_size,
1219 to_local,
1220 radius,
1221 outer_radius,
1222 stroke,
1223 } = self
1224 {
1225 out[layout::STOPS..layout::STOPS + 4].copy_from_slice(color);
1226 out[layout::GEOMETRY + 1] = *outer_radius;
1227 out[layout::GEOMETRY + 2] = half_size[0];
1228 out[layout::GEOMETRY + 3] = half_size[1];
1229 out[layout::TO_LOCAL..layout::TO_LOCAL + 12].copy_from_slice(to_local);
1230 out[layout::PARAMS] = 1.0;
1231 out[layout::PARAMS + 1] = kind::ROUNDED_RECT;
1232 out[layout::PARAMS + 2] = *radius;
1233 out[layout::PARAMS + 3] = *stroke;
1234 return out;
1235 }
1236
1237 if let Self::RoundedRectBlur {
1238 color,
1239 to_local,
1240 adjust,
1241 r1,
1242 exponent,
1243 s_inv,
1244 min_edge,
1245 scale,
1246 } = self
1247 {
1248 out[layout::STOPS..layout::STOPS + 4].copy_from_slice(color);
1249 out[layout::TO_LOCAL..layout::TO_LOCAL + 12].copy_from_slice(to_local);
1250 // The first two floats of `GEOMETRY` are unclaimed for every kind
1251 // that carries a mapping, which this does, so the two that follow
1252 // are what a rounded rectangle would have used for its half size --
1253 // and it needs none, because `adjust` already has the shape in it.
1254 out[layout::GEOMETRY] = adjust[0];
1255 out[layout::GEOMETRY + 1] = adjust[1];
1256 out[layout::GEOMETRY + 2] = *s_inv;
1257 out[layout::GEOMETRY + 3] = *min_edge;
1258 // `OFFSETS` holds stop positions for a gradient and nothing for
1259 // anything else, which is what makes it the seventh float this
1260 // needs and the only kind here that borrows it.
1261 out[layout::OFFSETS] = *scale;
1262 out[layout::PARAMS] = 1.0;
1263 out[layout::PARAMS + 1] = kind::ROUNDED_RECT_BLUR;
1264 out[layout::PARAMS + 2] = *r1;
1265 out[layout::PARAMS + 3] = *exponent;
1266 return out;
1267 }
1268
1269 if let Self::Blur {
1270 to_local,
1271 step,
1272 sigma,
1273 ..
1274 } = self
1275 {
1276 out[layout::GEOMETRY + 2] = step[0];
1277 out[layout::GEOMETRY + 3] = step[1];
1278 out[layout::TO_LOCAL..layout::TO_LOCAL + 12].copy_from_slice(to_local);
1279 out[layout::PARAMS] = 1.0;
1280 out[layout::PARAMS + 1] = kind::BLUR;
1281 out[layout::PARAMS + 2] = *sigma;
1282 return out;
1283 }
1284
1285 if let Self::Morphology {
1286 to_local,
1287 step,
1288 radius,
1289 dilate,
1290 ..
1291 } = self
1292 {
1293 out[layout::GEOMETRY + 2] = step[0];
1294 out[layout::GEOMETRY + 3] = step[1];
1295 out[layout::TO_LOCAL..layout::TO_LOCAL + 12].copy_from_slice(to_local);
1296 out[layout::PARAMS] = 1.0;
1297 out[layout::PARAMS + 1] = kind::MORPHOLOGY;
1298 out[layout::PARAMS + 2] = radius.clamp(0.0, MORPHOLOGY_TAPS as f32);
1299 out[layout::PARAMS + 3] = if *dilate { 1.0 } else { 0.0 };
1300 return out;
1301 }
1302
1303 if let Self::Runtime { uniforms, .. } = self {
1304 // Straight into the block, in the order the caller wrote them. No
1305 // kind is set: nothing in the shared shader will read this, and a
1306 // caller's program does not branch on one.
1307 let count = uniforms.len().min(RUNTIME_FLOATS);
1308 out[..count].copy_from_slice(&uniforms[..count]);
1309 return out;
1310 }
1311
1312 if let Self::Mesh {
1313 alpha,
1314 tint,
1315 tile,
1316 sampling,
1317 ..
1318 } = self
1319 {
1320 out[layout::STOPS..layout::STOPS + 4].copy_from_slice(tint);
1321 out[layout::GEOMETRY] = *alpha;
1322 out[layout::GEOMETRY + 1] = tile_code(*tile);
1323 out[layout::PARAMS] = 1.0;
1324 out[layout::PARAMS + 1] = kind::MESH;
1325 out[layout::PARAMS + 2] = sampling_code(*sampling);
1326 return out;
1327 }
1328
1329 if let Self::Image {
1330 to_local,
1331 alpha,
1332 tile,
1333 sampling,
1334 source,
1335 tint,
1336 ..
1337 } = self
1338 {
1339 // Into the stop colors, which an image has none of. Eight floats
1340 // that would otherwise travel as zeros on every image draw.
1341 out[layout::STOPS..layout::STOPS + 4].copy_from_slice(source);
1342 out[layout::STOPS + 4..layout::STOPS + 8].copy_from_slice(tint);
1343 out[layout::GEOMETRY + 2] = *alpha;
1344 out[layout::GEOMETRY + 3] = tile_code(*tile);
1345 out[layout::TO_LOCAL..layout::TO_LOCAL + 12].copy_from_slice(to_local);
1346 out[layout::PARAMS] = 1.0;
1347 out[layout::PARAMS + 1] = kind::IMAGE;
1348 out[layout::PARAMS + 2] = sampling_code(*sampling);
1349 return out;
1350 }
1351
1352 let stops = self.stops();
1353 let count = stops.len().min(MAX_STOPS);
1354 for (i, stop) in stops.iter().take(count).enumerate() {
1355 out[layout::STOPS + i * 4..layout::STOPS + i * 4 + 4].copy_from_slice(&stop.color);
1356 out[layout::OFFSETS + i] = stop.offset;
1357 }
1358 out[layout::PARAMS] = count.max(1) as f32;
1359
1360 // A gradient with fewer than two stops has nothing to interpolate
1361 // between, so it renders as its first color rather than sending the
1362 // shader down a path that would read an entry nothing wrote.
1363 if count < 2 {
1364 out[layout::PARAMS + 1] = kind::SOLID;
1365 return out;
1366 }
1367
1368 match self {
1369 Self::Solid(_)
1370 | Self::PointField { .. }
1371 | Self::Image { .. }
1372 | Self::Mesh { .. }
1373 | Self::Glyph { .. }
1374 | Self::Blur { .. }
1375 | Self::Morphology { .. }
1376 | Self::RoundedRect { .. }
1377 | Self::RoundedRectBlur { .. }
1378 | Self::Ellipse { .. }
1379 | Self::Runtime { .. } => {
1380 unreachable!("handled above")
1381 }
1382 Self::LinearGradient {
1383 ramp,
1384 axis,
1385 to_local,
1386 tile,
1387 ..
1388 } => {
1389 out[layout::PARAMS] = stop_count_code(count, ramp);
1390 out[layout::PARAMS + 2] = tile_code(*tile);
1391 out[layout::GEOMETRY + 2] = axis[0];
1392 out[layout::GEOMETRY + 3] = axis[1];
1393 out[layout::TO_LOCAL..layout::TO_LOCAL + 12].copy_from_slice(to_local);
1394 out[layout::PARAMS + 1] = kind::LINEAR;
1395 }
1396 Self::RadialGradient {
1397 ramp,
1398 to_local,
1399 tile,
1400 ..
1401 } => {
1402 out[layout::PARAMS] = stop_count_code(count, ramp);
1403 out[layout::PARAMS + 2] = tile_code(*tile);
1404 out[layout::TO_LOCAL..layout::TO_LOCAL + 12].copy_from_slice(to_local);
1405 out[layout::PARAMS + 1] = kind::RADIAL;
1406 }
1407 Self::SweepGradient {
1408 ramp,
1409 to_local,
1410 start_angle,
1411 end_angle,
1412 tile,
1413 ..
1414 } => {
1415 out[layout::PARAMS] = stop_count_code(count, ramp);
1416 out[layout::PARAMS + 2] = tile_code(*tile);
1417 out[layout::GEOMETRY + 2] = *start_angle;
1418 out[layout::GEOMETRY + 3] = *end_angle;
1419 out[layout::TO_LOCAL..layout::TO_LOCAL + 12].copy_from_slice(to_local);
1420 out[layout::PARAMS + 1] = kind::SWEEP;
1421 }
1422 Self::ConicalGradient {
1423 ramp,
1424 to_local,
1425 start_radius,
1426 radius_delta,
1427 separation,
1428 tile,
1429 ..
1430 } => {
1431 out[layout::PARAMS] = stop_count_code(count, ramp);
1432 out[layout::PARAMS + 2] = tile_code(*tile);
1433 out[layout::GEOMETRY + 2] = *start_radius;
1434 out[layout::GEOMETRY + 3] = *radius_delta;
1435 out[layout::TO_LOCAL..layout::TO_LOCAL + 12].copy_from_slice(to_local);
1436 out[layout::PARAMS + 1] = kind::CONICAL;
1437 out[layout::PARAMS + 3] = *separation;
1438 }
1439 }
1440 out
1441 }
1442
1443 /// The caller's program this draws with, where it uses one.
1444 ///
1445 /// `None` is every material the built-in shader draws, which is all of
1446 /// them but one.
1447 pub fn program(&self) -> Option<u32> {
1448 match self {
1449 Self::Runtime { program, .. } => Some(*program),
1450 _ => None,
1451 }
1452 }
1453
1454 /// Which shader variant this needs, for keying a pipeline.
1455 ///
1456 /// Every variant lives in one program today, selected by a uniform, so this
1457 /// exists for the moment a material needs its own pipeline rather than
1458 /// pretending that moment has arrived.
1459 pub fn variant(&self) -> MaterialVariant {
1460 match self {
1461 Self::Solid(_) => MaterialVariant::Solid,
1462 _ => MaterialVariant::Gradient,
1463 }
1464 }
1465}
1466
1467#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1468pub enum MaterialVariant {
1469 Solid,
1470 Gradient,
1471}
1472
1473#[cfg(test)]
1474mod tests {
1475 use super::*;
1476
1477 /// A mapping with the given two-by-two linear part and no translation.
1478 ///
1479 /// Most of these tests care that the mapping survives packing, not what it
1480 /// is, and said so in four floats before the paint's origin moved inside
1481 /// it. This keeps them saying that.
1482 fn linear_to_local(m: [f32; 4]) -> ToLocal {
1483 [
1484 m[0], m[1], 0.0, 0.0, //
1485 m[2], m[3], 0.0, 0.0, //
1486 0.0, 0.0, 1.0, 0.0,
1487 ]
1488 }
1489
1490 fn two_stops() -> Vec<Stop> {
1491 vec![
1492 Stop::new([1.0, 0.0, 0.0, 1.0], 0.0),
1493 Stop::new([0.0, 0.0, 1.0, 1.0], 1.0),
1494 ]
1495 }
1496
1497 #[test]
1498 fn a_solid_color_lands_in_the_first_stop_and_selects_the_solid_path() {
1499 let packed = Material::solid([0.25, 0.5, 0.75, 1.0]).to_uniform();
1500 assert_eq!(&packed[0..4], &[0.25, 0.5, 0.75, 1.0]);
1501 assert_eq!(packed[layout::PARAMS], 1.0, "stop count");
1502 assert_eq!(packed[layout::PARAMS + 1], kind::SOLID);
1503 }
1504
1505 #[test]
1506 fn a_linear_gradient_packs_its_stops_axis_and_count() {
1507 let packed = Material::LinearGradient {
1508 axis: [2.0, 0.0],
1509 to_local: linear_to_local([1.0, 0.0, 0.0, 1.0]),
1510 stops: two_stops(),
1511 tile: TileMode::Clamp,
1512 ramp: None,
1513 }
1514 .to_uniform();
1515
1516 assert_eq!(&packed[0..4], &[1.0, 0.0, 0.0, 1.0], "first stop");
1517 assert_eq!(&packed[4..8], &[0.0, 0.0, 1.0, 1.0], "second stop");
1518 assert_eq!(packed[layout::OFFSETS], 0.0);
1519 assert_eq!(packed[layout::OFFSETS + 1], 1.0);
1520 assert_eq!(
1521 &packed[layout::GEOMETRY..layout::GEOMETRY + 4],
1522 &[0.0, 0.0, 2.0, 0.0],
1523 "the axis rather than the end point, and nothing in the half the \
1524 start used to occupy before it moved inside the mapping"
1525 );
1526 assert_eq!(
1527 &packed[layout::TO_LOCAL..layout::TO_LOCAL + 12],
1528 &linear_to_local([1.0, 0.0, 0.0, 1.0])
1529 );
1530 assert_eq!(packed[layout::PARAMS + 1], kind::LINEAR);
1531 }
1532
1533 #[test]
1534 fn a_radial_gradient_packs_its_mapping() {
1535 let packed = Material::RadialGradient {
1536 to_local: linear_to_local([2.0, 0.0, 0.0, 4.0]),
1537 stops: two_stops(),
1538 tile: TileMode::Clamp,
1539 ramp: None,
1540 }
1541 .to_uniform();
1542
1543 // A radial gradient states nothing in the geometry slot at all now: its
1544 // center rode there, and rides inside the mapping instead.
1545 assert_eq!(&packed[layout::GEOMETRY..layout::GEOMETRY + 2], &[0.0, 0.0]);
1546 // The mapping is what makes a circle circular on a non-square target,
1547 // so it has to survive packing intact.
1548 assert_eq!(
1549 &packed[layout::TO_LOCAL..layout::TO_LOCAL + 12],
1550 &linear_to_local([2.0, 0.0, 0.0, 4.0])
1551 );
1552 assert_eq!(packed[layout::PARAMS + 1], kind::RADIAL);
1553 }
1554
1555 #[test]
1556 fn a_sweep_gradient_packs_its_angles_alongside_its_center() {
1557 let packed = Material::SweepGradient {
1558 to_local: linear_to_local([1.0, 0.0, 0.0, 1.0]),
1559 start_angle: 0.5,
1560 end_angle: 2.5,
1561 stops: two_stops(),
1562 tile: TileMode::Clamp,
1563 ramp: None,
1564 }
1565 .to_uniform();
1566
1567 // Angles share the geometry slot with the center, which is why a linear
1568 // gradient's endpoints and a sweep's angles cannot both be present.
1569 assert_eq!(
1570 &packed[layout::GEOMETRY..layout::GEOMETRY + 4],
1571 &[0.0, 0.0, 0.5, 2.5]
1572 );
1573 assert_eq!(packed[layout::PARAMS + 1], kind::SWEEP);
1574 }
1575
1576 #[test]
1577 fn every_gradient_kind_falls_back_to_solid_with_one_stop() {
1578 // Interpolating needs two points. Selecting a gradient path with one
1579 // would have the shader read an entry nothing wrote.
1580 let one = vec![Stop::new([1.0, 1.0, 1.0, 1.0], 0.0)];
1581 let materials = [
1582 Material::LinearGradient {
1583 axis: [1.0 - 0.0, 0.0 - 0.0],
1584 to_local: linear_to_local([1.0, 0.0, 0.0, 1.0]),
1585 stops: one.clone(),
1586 tile: TileMode::Clamp,
1587 ramp: None,
1588 },
1589 Material::RadialGradient {
1590 to_local: linear_to_local([1.0, 0.0, 0.0, 1.0]),
1591 stops: one.clone(),
1592 tile: TileMode::Clamp,
1593 ramp: None,
1594 },
1595 Material::SweepGradient {
1596 to_local: linear_to_local([1.0, 0.0, 0.0, 1.0]),
1597 start_angle: 0.0,
1598 end_angle: 1.0,
1599 stops: one,
1600 tile: TileMode::Clamp,
1601 ramp: None,
1602 },
1603 ];
1604 for material in materials {
1605 let packed = material.to_uniform();
1606 assert_eq!(packed[layout::PARAMS + 1], kind::SOLID, "{material:?}");
1607 assert_eq!(&packed[0..4], &[1.0, 1.0, 1.0, 1.0]);
1608 }
1609 }
1610
1611 #[test]
1612 fn stops_beyond_the_limit_are_dropped_rather_than_overflowing() {
1613 let stops: Vec<Stop> = (0..8)
1614 .map(|i| Stop::new([i as f32 / 8.0, 0.0, 0.0, 1.0], i as f32 / 7.0))
1615 .collect();
1616 let packed = Material::LinearGradient {
1617 axis: [1.0 - 0.0, 0.0 - 0.0],
1618 to_local: linear_to_local([1.0, 0.0, 0.0, 1.0]),
1619 stops,
1620 tile: TileMode::Clamp,
1621 ramp: None,
1622 }
1623 .to_uniform();
1624 // The count is what stops the shader reading past what was written.
1625 assert_eq!(packed[layout::PARAMS], MAX_STOPS as f32);
1626 }
1627
1628 #[test]
1629 fn visibility_accounts_for_every_stop_of_every_kind() {
1630 assert!(Material::solid([1.0, 1.0, 1.0, 0.0]).is_invisible());
1631 assert!(!Material::solid([0.0, 0.0, 0.0, 1.0]).is_invisible());
1632
1633 let clear = vec![
1634 Stop::new([1.0, 0.0, 0.0, 0.0], 0.0),
1635 Stop::new([0.0, 0.0, 1.0, 0.0], 1.0),
1636 ];
1637 assert!(Material::RadialGradient {
1638 to_local: linear_to_local([1.0, 0.0, 0.0, 1.0]),
1639 stops: clear,
1640 tile: TileMode::Clamp,
1641 ramp: None,
1642 }
1643 .is_invisible());
1644
1645 let partly = vec![
1646 Stop::new([1.0, 0.0, 0.0, 0.0], 0.0),
1647 Stop::new([0.0, 0.0, 1.0, 1.0], 1.0),
1648 ];
1649 assert!(
1650 !Material::SweepGradient {
1651 to_local: linear_to_local([1.0, 0.0, 0.0, 1.0]),
1652 start_angle: 0.0,
1653 end_angle: 1.0,
1654 stops: partly,
1655 tile: TileMode::Clamp,
1656 ramp: None,
1657 }
1658 .is_invisible(),
1659 "one visible stop is enough"
1660 );
1661 }
1662
1663 #[test]
1664 fn every_gradient_kind_reports_the_same_variant() {
1665 // They share one program, selected by a uniform, so keying a pipeline
1666 // on the kind would create three identical pipelines.
1667 assert_eq!(Material::solid([0.0; 4]).variant(), MaterialVariant::Solid);
1668 for material in [
1669 Material::LinearGradient {
1670 axis: [1.0, 0.0],
1671 to_local: linear_to_local([1.0, 0.0, 0.0, 1.0]),
1672 stops: two_stops(),
1673 tile: TileMode::Clamp,
1674 ramp: None,
1675 },
1676 Material::RadialGradient {
1677 to_local: linear_to_local([1.0, 0.0, 0.0, 1.0]),
1678 stops: two_stops(),
1679 tile: TileMode::Clamp,
1680 ramp: None,
1681 },
1682 ] {
1683 assert_eq!(material.variant(), MaterialVariant::Gradient);
1684 }
1685 }
1686}