frust_engine/compile/paint.rs
1//! Paint encoding: `peniko::Brush` to a `vello_common` [`Paint`] plus its
2//! [`EncodedPaint`] side table.
3//!
4//! A scene's brushes are split into the two things the renderer needs. A solid
5//! colour is self-contained and travels premultiplied inside the [`Paint`] itself,
6//! costing no side-table entry. Anything else is encoded once into a caller-owned
7//! `Vec<EncodedPaint>` and referenced by index, so per-draw state stays small and
8//! two draws sharing a brush share one encoded entry.
9//!
10//! Encoding a gradient also *bakes nothing*: the colour ramp is a separate,
11//! bounded GPU resource, so a gradient encoding yields a [`LutRequest`] naming the
12//! entry whose ramp must be made resident in the [`GradientCache`] before the
13//! frame is drawn. Requests are deliberately not serviced here — a caller
14//! batches them so ramp residency is decided once per frame rather than per draw.
15//!
16//! An image is the one paint that cannot follow that deferred shape. A
17//! gradient's encoded entry carries its own cache key, so the ramp can be baked
18//! afterwards and matched back up; an image's encoded entry has to carry the
19//! [`ImageId`](vello_common::paint::ImageId) *itself*, which only an allocation
20//! against the atlas can mint. Image residency is therefore resolved inline
21//! ([`encode_image`] and its two callers), against a residency the compiler
22//! owns — cheap, because allocation is a rectangle packer over plain values and
23//! only the *upload* it schedules costs anything, and that upload happens once
24//! per image rather than once per frame.
25
26use std::sync::Once;
27
28use peniko::{Brush, Color, ImageBrush, ImageData, ImageQuality, ImageSampler};
29use vello_common::encode::{EncodeExt, EncodedImage, EncodedPaint};
30use vello_common::kurbo::{Affine, Rect};
31use vello_common::paint::{IndexedPaint, Paint, Tint, TintMode};
32
33use crate::cache::images::{ImageResidency, ImageSkip, ResidentImage};
34use crate::cache::{CachedRamp, GradientCache};
35use crate::gpu::atlas::{natural_to_dest, x_y_advances};
36
37/// The colour an image brush paints with when it is encoded with no residency
38/// in hand.
39///
40/// Transparent rather than an arbitrary opaque colour: an unsupported paint should
41/// leave the surface untouched, not stamp a wrong-coloured shape over it.
42const IMAGE_PLACEHOLDER: Color = Color::TRANSPARENT;
43
44static IMAGE_BRUSH_WARNING: Once = Once::new();
45
46/// A gradient whose colour ramp is not yet resident in the [`GradientCache`].
47///
48/// Names the [`EncodedPaint`] entry to bake, not the gradient itself, because the
49/// encoded entry is what carries the cache key the ramp is stored under.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub struct LutRequest {
52 /// Index of the gradient entry in the encoded-paint side table.
53 pub paint_index: usize,
54}
55
56/// One encoded brush: the paint a draw references, plus any ramp it still needs.
57#[derive(Debug, Clone, PartialEq)]
58pub struct BrushEncoding {
59 /// The paint to record against the draw.
60 pub paint: Paint,
61 /// Set when the paint is a gradient whose ramp must be made resident.
62 pub lut_request: Option<LutRequest>,
63}
64
65/// One encoded image paint: the paint a draw references, plus where the atlas
66/// made its texels resident.
67///
68/// The residency travels back out because the record the shader reads is built
69/// from *both* halves — the encoded entry's sampler and transform, and the
70/// atlas layer, offset and extent — and only this call has both in hand at
71/// once (see [`crate::gpu::atlas::lower_encoded_image`]).
72#[derive(Debug, Clone, PartialEq)]
73pub struct ImageEncoding {
74 /// The paint to record against the draw.
75 pub paint: Paint,
76 /// Index of the image entry in the encoded-paint side table.
77 pub paint_index: usize,
78 /// Where the image's texels are resident in the atlas array.
79 pub resident: ResidentImage,
80}
81
82/// Encode `brush` into `paint`'s renderer-facing form, appending to `encoded_paints`.
83///
84/// `transform` is the paint transform in effect for the draw; a gradient's encoded
85/// form folds its inverse in, so the same gradient under two transforms yields two
86/// encoded entries but still shares one colour ramp.
87///
88/// A gradient that is degenerate or malformed — fewer than two stops, unsorted or
89/// out-of-range offsets, a zero-length line, coincident circles, a non-increasing
90/// sweep — is not an error: `vello_common` substitutes a solid fallback, which
91/// arrives here as a plain [`Paint::Solid`] with no side-table entry and therefore
92/// no [`LutRequest`].
93///
94/// Frust's scene layer only ever constructs `Extend::Pad` gradients; other extend
95/// modes are carried through to the encoded entry untouched for the renderer to
96/// honour.
97pub fn encode_brush(
98 brush: &Brush,
99 transform: Affine,
100 encoded_paints: &mut Vec<EncodedPaint>,
101) -> BrushEncoding {
102 match brush {
103 Brush::Solid(color) => BrushEncoding {
104 paint: (*color).into(),
105 lut_request: None,
106 },
107 Brush::Gradient(gradient) => {
108 let paint = gradient.encode_into(encoded_paints, transform, None);
109 // A degenerate gradient falls back to a solid colour and pushes no
110 // entry, so the request follows the encoding result rather than the
111 // brush kind.
112 let lut_request = match &paint {
113 Paint::Indexed(indexed) => Some(LutRequest {
114 paint_index: indexed.index(),
115 }),
116 Paint::Solid(_) => None,
117 };
118
119 BrushEncoding { paint, lut_request }
120 }
121 Brush::Image(_) => {
122 IMAGE_BRUSH_WARNING.call_once(|| {
123 log::warn!(
124 "an image brush encoded with no residency paints transparent \
125 (logged once); the compiler routes image brushes through \
126 `encode_image_brush` instead"
127 );
128 });
129
130 BrushEncoding {
131 paint: IMAGE_PLACEHOLDER.into(),
132 lut_request: None,
133 }
134 }
135 }
136}
137
138/// Encode an image brush drawn at its natural pixel size under `transform`.
139///
140/// The vello contract an image brush carries: the pixels land one-for-one on
141/// the device grid under the paint transform, with the sampler's extend modes
142/// covering everything the shape reaches beyond them.
143///
144/// # Errors
145///
146/// Returns the [`ImageSkip`] the residency refused the image with, or
147/// [`ImageSkip::SingularTransform`] for a paint transform with no inverse.
148pub fn encode_image_brush(
149 brush: &ImageBrush,
150 transform: Affine,
151 encoded_paints: &mut Vec<EncodedPaint>,
152 images: &mut ImageResidency,
153) -> Result<ImageEncoding, ImageSkip> {
154 encode_image(
155 &brush.image,
156 brush.sampler,
157 transform,
158 encoded_paints,
159 images,
160 )
161}
162
163/// Encode a `Command::Image`: `data`'s natural pixel rectangle scaled to fill
164/// `dest`, under `transform`.
165///
166/// The natural-to-`dest` composition is
167/// [`crate::gpu::atlas::natural_to_dest`], the same one `frust-render`'s
168/// CPU-tier lowering applies, so an image lands on identical device pixels
169/// whichever tier drew it. The sampler is the display list's own default —
170/// [`ImageQuality::Medium`] (bilinear) with padded extends, since a
171/// `Command::Image` carries no sampling parameters of its own and a scaled
172/// image filtered at nearest would be visibly worse than the CPU tier's
173/// output.
174///
175/// # Errors
176///
177/// Returns the [`ImageSkip`] the residency refused the image with,
178/// [`ImageSkip::SingularTransform`] for a transform with no inverse, or
179/// [`ImageSkip::DegenerateExtent`] when `data` has no natural area to scale
180/// from.
181pub fn encode_image_command(
182 data: &ImageData,
183 dest: Rect,
184 transform: Affine,
185 encoded_paints: &mut Vec<EncodedPaint>,
186 images: &mut ImageResidency,
187) -> Result<ImageEncoding, ImageSkip> {
188 let paint_transform = natural_to_dest(transform, (data.width, data.height), dest).ok_or(
189 ImageSkip::DegenerateExtent {
190 width: data.width,
191 height: data.height,
192 },
193 )?;
194
195 encode_image(
196 data,
197 ImageSampler::default(),
198 paint_transform,
199 encoded_paints,
200 images,
201 )
202}
203
204/// Make `data` resident and append its encoded entry, returning the paint that
205/// references it.
206///
207/// `paint_transform` is the *forward* mapping from the image's natural pixel
208/// rectangle onto device space; the entry stores its inverse, because that is
209/// the direction the shader applies it in (device fragment to image texel).
210///
211/// A sampler alpha below one is folded into the entry's tint rather than
212/// carried on the sampler: `vello_common`'s own image encoding
213/// `unimplemented!()`s on a non-unit sampler alpha, and the shader has no alpha
214/// field to read one from — but it always multiplies by a tint, so an identity
215/// tint scaled by the alpha produces exactly the same result with nothing left
216/// unimplemented on the frame path (E17).
217///
218/// # Errors
219///
220/// Returns the [`ImageSkip`] the residency refused the image with, or
221/// [`ImageSkip::SingularTransform`] when `paint_transform` has no finite
222/// inverse.
223pub fn encode_image(
224 data: &ImageData,
225 sampler: ImageSampler,
226 paint_transform: Affine,
227 encoded_paints: &mut Vec<EncodedPaint>,
228 images: &mut ImageResidency,
229) -> Result<ImageEncoding, ImageSkip> {
230 let transform = paint_transform.inverse();
231 if !transform.as_coeffs().iter().all(|coeff| coeff.is_finite()) {
232 return Err(ImageSkip::SingularTransform);
233 }
234
235 let resident = images.resolve(data)?;
236
237 let (x_advance, y_advance) = x_y_advances(transform);
238 let tint = alpha_tint(sampler.alpha);
239 let sampler = ImageSampler {
240 quality: resolved_quality(sampler.quality, paint_transform),
241 alpha: 1.0,
242 ..sampler
243 };
244
245 let paint_index = encoded_paints.len();
246 encoded_paints.push(EncodedPaint::Image(EncodedImage {
247 may_have_transparency: resident.may_have_transparency || tint.is_some(),
248 source: resident.source(),
249 sampler,
250 transform,
251 x_advance,
252 y_advance,
253 tint,
254 }));
255
256 Ok(ImageEncoding {
257 paint: Paint::Indexed(IndexedPaint::new(paint_index)),
258 paint_index,
259 resident,
260 })
261}
262
263/// A sampler alpha below one expressed as the identity tint scaled by it, or
264/// `None` for a fully opaque (or malformed) alpha.
265fn alpha_tint(alpha: f32) -> Option<Tint> {
266 if !alpha.is_finite() || alpha >= 1.0 {
267 return None;
268 }
269
270 Some(Tint {
271 color: Color::WHITE.multiply_alpha(alpha.max(0.0)),
272 mode: TintMode::Multiply,
273 })
274}
275
276/// The sampling quality an image is actually drawn at.
277///
278/// A bilinear request under a whole-pixel translation is downgraded to nearest,
279/// which is the same optimization `vello_common`'s own image encoding applies:
280/// the four taps a bilinear filter blends are the same texel when nothing
281/// subpixel is happening, so the downgrade is exact rather than a quality
282/// trade, and it is what keeps this tier's output bit-identical to the CPU
283/// reference for an unscaled image.
284fn resolved_quality(quality: ImageQuality, paint_transform: Affine) -> ImageQuality {
285 if quality != ImageQuality::Medium {
286 return quality;
287 }
288
289 let c = paint_transform.as_coeffs();
290 let unit_translation = (c[0] - 1.0).abs() < NEARLY_ZERO
291 && c[1].abs() < NEARLY_ZERO
292 && c[2].abs() < NEARLY_ZERO
293 && (c[3] - 1.0).abs() < NEARLY_ZERO
294 && (c[4] - c[4].floor()).abs() < NEARLY_ZERO
295 && (c[5] - c[5].floor()).abs() < NEARLY_ZERO;
296
297 if unit_translation {
298 ImageQuality::Low
299 } else {
300 ImageQuality::Medium
301 }
302}
303
304/// The near-zero tolerance `vello_common`'s `is_nearly_zero` compares against,
305/// restated because that trait is not exported.
306const NEARLY_ZERO: f64 = 1.0 / 4096.0;
307
308/// Make the ramp named by `request` resident, returning where it landed.
309///
310/// Returns `None` when the request does not name a gradient entry, which can only
311/// happen if `encoded_paints` is not the table the request was produced against.
312pub fn resolve_lut_request(
313 request: LutRequest,
314 encoded_paints: &[EncodedPaint],
315 cache: &mut GradientCache,
316) -> Option<CachedRamp> {
317 match encoded_paints.get(request.paint_index) {
318 Some(EncodedPaint::Gradient(gradient)) => Some(cache.get_or_create_ramp(gradient)),
319 _ => None,
320 }
321}