Skip to main content

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}