Skip to main content

frust_engine/compile/
external.rs

1//! Lowering for [`Command::SceneTexture`](frust_scene::Command::SceneTexture):
2//! a caller-owned GPU texture drawn over a destination rectangle.
3//!
4//! The command is the scene layer's one reference to a texture the engine did
5//! not produce. It carries an opaque `u64` and a destination rectangle and
6//! nothing else — no pixels, no extent, no sampler — because the scene layer
7//! depends on no GPU crate (see `frust_scene::Command::SceneTexture`). So the
8//! two halves of the lowering sit on opposite sides of the frame:
9//!
10//! 1. the *extent* is registry state, learned when the host registers the
11//!    texture and kept here in [`ExternalExtents`] so the walk can compose the
12//!    same natural-pixels-onto-`dest` mapping [`Command::Image`] uses; and
13//! 2. the *view* is the renderer's, resolved after compiling against the same
14//!    registry (see [`crate::gpu::bindings`]).
15//!
16//! The split is what keeps this module free of `wgpu`, like the rest of
17//! `compile`. It is also why an unregistered id is decided here rather than at
18//! encode time: with no extent there is no mapping to compose, so the draw
19//! never reaches the frame's paint table at all.
20//!
21//! Unlike an atlas-backed image, nothing is made resident and nothing is
22//! uploaded — the texels are the caller's, already on the device. The encoded
23//! entry is therefore a plain [`EncodedExternalTexture`] naming the id, the
24//! source region and the inverse device-to-texel transform the shader applies.
25
26use std::collections::{HashMap, HashSet};
27
28use peniko::{ImageQuality, ImageSampler};
29use vello_common::TextureId;
30use vello_common::encode::{EncodedExternalTexture, EncodedPaint};
31use vello_common::geometry::RectU16;
32use vello_common::kurbo::{Affine, Rect};
33use vello_common::paint::{IndexedPaint, Paint};
34
35use crate::gpu::atlas::natural_to_dest;
36
37/// The texel extent of every externally bound texture, keyed by the opaque id
38/// a [`Command::SceneTexture`](frust_scene::Command::SceneTexture) names it by.
39///
40/// A projection of the renderer's own registry, maintained by the one call that
41/// owns both halves (`EngineRenderer::bind_texture`/`unbind_texture`), so the
42/// two can never disagree about which ids are live. Only the extent crosses
43/// over: the view stays on the GPU side, which is what keeps `compile` free of
44/// `wgpu`.
45///
46/// A compiler built standalone starts empty, which reads as "nothing is bound"
47/// — every `SceneTexture` then draws nothing, exactly as an unregistered id
48/// does.
49#[derive(Debug, Default)]
50pub struct ExternalExtents {
51    extents: HashMap<u64, (u16, u16)>,
52    /// Ids already reported as unregistered, so a scene that draws one every
53    /// frame says so once rather than once per frame.
54    warned: HashSet<u64>,
55}
56
57impl ExternalExtents {
58    /// An empty map — nothing bound.
59    #[must_use]
60    pub fn new() -> Self {
61        Self::default()
62    }
63
64    /// Records `id` as bound at `size` texels, returning whether the extent is
65    /// usable at all.
66    ///
67    /// A dimension past `u16::MAX` is refused rather than truncated: the record
68    /// the shader reads packs the source region into `u16` halves, so a
69    /// truncated extent would sample a rectangle the caller never named. A
70    /// zero dimension is refused for the same reason a zero-sized image is —
71    /// there is no scale to map it onto `dest` with.
72    pub fn bind(&mut self, id: u64, size: (u32, u32)) -> bool {
73        let (Ok(width), Ok(height)) = (u16::try_from(size.0), u16::try_from(size.1)) else {
74            self.extents.remove(&id);
75            return false;
76        };
77        if width == 0 || height == 0 {
78            self.extents.remove(&id);
79            return false;
80        }
81        self.extents.insert(id, (width, height));
82        // A freshly bound id is worth reporting again if it is later unbound
83        // while a scene still draws it.
84        self.warned.remove(&id);
85        true
86    }
87
88    /// Forgets `id`, if it was bound.
89    pub fn unbind(&mut self, id: u64) {
90        self.extents.remove(&id);
91    }
92
93    /// The texel extent bound under `id`, if any.
94    #[must_use]
95    pub fn get(&self, id: u64) -> Option<(u16, u16)> {
96        self.extents.get(&id).copied()
97    }
98
99    /// How many textures are bound.
100    #[must_use]
101    pub fn len(&self) -> usize {
102        self.extents.len()
103    }
104
105    /// Whether nothing is bound.
106    #[must_use]
107    pub fn is_empty(&self) -> bool {
108        self.extents.is_empty()
109    }
110
111    /// Whether `id` has not been reported unregistered yet, latching it so the
112    /// next ask answers `false`.
113    fn take_warning(&mut self, id: u64) -> bool {
114        self.warned.insert(id)
115    }
116}
117
118/// Why a [`Command::SceneTexture`](frust_scene::Command::SceneTexture) recorded
119/// no draw.
120///
121/// Every variant is a skipped draw, never a refused frame: the engine's
122/// standing rule is that a frame draws less rather than wrong.
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub enum ExternalSkip {
125    /// No texture is bound under the id the command named.
126    Unregistered {
127        /// The id the display list asked for.
128        id: u64,
129    },
130    /// The destination rectangle has no area to scale the texture onto.
131    DegenerateDest,
132    /// The composed transform has no finite inverse, so device space cannot be
133    /// mapped back to texels.
134    SingularTransform,
135}
136
137/// One encoded external-texture paint: the paint a draw references, and where
138/// its entry landed in the frame's paint table.
139#[derive(Debug, Clone, PartialEq)]
140pub struct ExternalEncoding {
141    /// The paint to record against the draw.
142    pub paint: Paint,
143    /// Index of the entry in the encoded-paint side table.
144    pub paint_index: usize,
145}
146
147/// Encode a `Command::SceneTexture`: the whole of the texture bound under `id`
148/// scaled to fill `dest`, under `transform`.
149///
150/// The natural-to-`dest` composition is [`natural_to_dest`], the same one
151/// [`Command::Image`](frust_scene::Command::Image) takes, so a texture and an
152/// image drawn over the same rectangle land on identical device pixels. The
153/// sampler is the display list's own default — [`ImageQuality::Medium`]
154/// (bilinear) with padded extends — since the command carries no sampling
155/// parameters of its own and a scaled texture filtered at nearest would be
156/// visibly worse.
157///
158/// The entry is marked as possibly transparent unconditionally. The command
159/// carries no opacity statement and the engine never reads the caller's texels,
160/// so every such draw goes to the frame's blended pass; claiming opacity would
161/// route it through the depth-writing pass and let it occlude what it should
162/// have blended over.
163///
164/// # Errors
165///
166/// Returns the [`ExternalSkip`] the draw is dropped for. An unregistered id is
167/// reported once per id at warning level and at debug level thereafter — the
168/// same shape the image residency's own refusals take.
169pub fn encode_scene_texture(
170    id: u64,
171    dest: Rect,
172    transform: Affine,
173    extents: &mut ExternalExtents,
174    encoded_paints: &mut Vec<EncodedPaint>,
175) -> Result<ExternalEncoding, ExternalSkip> {
176    let Some((width, height)) = extents.get(id) else {
177        note_unregistered(id, extents.take_warning(id));
178        return Err(ExternalSkip::Unregistered { id });
179    };
180
181    let paint_transform = natural_to_dest(transform, (u32::from(width), u32::from(height)), dest)
182        .ok_or(ExternalSkip::DegenerateDest)?;
183    let inverse = paint_transform.inverse();
184    if !inverse.as_coeffs().iter().all(|coeff| coeff.is_finite()) {
185        return Err(ExternalSkip::SingularTransform);
186    }
187
188    let paint_index = encoded_paints.len();
189    encoded_paints.push(EncodedPaint::ExternalTexture(EncodedExternalTexture {
190        texture_id: TextureId(id),
191        source_region: RectU16 {
192            x0: 0,
193            y0: 0,
194            x1: width,
195            y1: height,
196        },
197        sampler: ImageSampler {
198            quality: ImageQuality::Medium,
199            ..ImageSampler::default()
200        },
201        may_have_transparency: true,
202        transform: inverse,
203        tint: None,
204    }));
205
206    Ok(ExternalEncoding {
207        paint: Paint::Indexed(IndexedPaint::new(paint_index)),
208        paint_index,
209    })
210}
211
212/// Report a `SceneTexture` naming an id nothing is bound under.
213///
214/// Warning on the first sighting of each id, debug on every later one: a
215/// texture registered a frame late is an ordinary start-up shape and would
216/// otherwise flood the log, while a texture never registered at all is an
217/// invisible blank the caller has no other signal for.
218fn note_unregistered(id: u64, first: bool) {
219    if first {
220        log::warn!(
221            "SceneTexture {id} draws nothing: no texture is registered under that id \
222             (further sightings of this id are logged at debug level)"
223        );
224    } else {
225        log::debug!("SceneTexture {id} draws nothing: no texture is registered under that id");
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    const DEST: Rect = Rect::new(0.0, 0.0, 8.0, 4.0);
234
235    fn bound(size: (u32, u32)) -> ExternalExtents {
236        let mut extents = ExternalExtents::new();
237        assert!(extents.bind(7, size));
238        extents
239    }
240
241    #[test]
242    fn an_unregistered_id_encodes_nothing() {
243        let mut extents = ExternalExtents::new();
244        let mut paints = Vec::new();
245
246        let skip = encode_scene_texture(7, DEST, Affine::IDENTITY, &mut extents, &mut paints);
247
248        assert_eq!(skip, Err(ExternalSkip::Unregistered { id: 7 }));
249        assert!(paints.is_empty(), "a skipped draw leaves no orphan entry");
250    }
251
252    #[test]
253    fn an_unregistered_id_is_reported_once() {
254        let mut extents = ExternalExtents::new();
255
256        assert!(extents.take_warning(7), "the first sighting reports");
257        assert!(!extents.take_warning(7), "a later sighting does not");
258        assert!(extents.take_warning(8), "a different id reports on its own");
259    }
260
261    #[test]
262    fn rebinding_an_id_lets_it_report_again() {
263        let mut extents = ExternalExtents::new();
264        assert!(!extents.take_warning(7) || !extents.take_warning(7));
265
266        assert!(extents.bind(7, (4, 4)));
267
268        assert!(
269            extents.take_warning(7),
270            "a rebound id is a new fact about that id"
271        );
272    }
273
274    #[test]
275    fn a_bound_texture_encodes_its_whole_extent_as_the_source_region() {
276        let mut extents = bound((4, 2));
277        let mut paints = Vec::new();
278
279        let encoding = encode_scene_texture(7, DEST, Affine::IDENTITY, &mut extents, &mut paints)
280            .expect("a bound texture encodes");
281
282        assert_eq!(encoding.paint_index, 0);
283        match paints.as_slice() {
284            [EncodedPaint::ExternalTexture(entry)] => {
285                assert_eq!(entry.texture_id, TextureId(7));
286                assert_eq!(
287                    entry.source_region,
288                    RectU16 {
289                        x0: 0,
290                        y0: 0,
291                        x1: 4,
292                        y1: 2
293                    }
294                );
295                assert!(
296                    entry.may_have_transparency,
297                    "a caller's texels are never claimed opaque"
298                );
299            }
300            other => panic!("expected one external-texture entry, got {other:?}"),
301        }
302    }
303
304    #[test]
305    fn the_encoded_transform_maps_the_destination_back_onto_the_texels() {
306        let mut extents = bound((4, 2));
307        let mut paints = Vec::new();
308
309        encode_scene_texture(7, DEST, Affine::IDENTITY, &mut extents, &mut paints)
310            .expect("a bound texture encodes");
311
312        let EncodedPaint::ExternalTexture(entry) = &paints[0] else {
313            panic!("expected an external-texture entry");
314        };
315        // `dest` is twice the texture's extent on both axes, so the inverse
316        // maps a device point back to half its coordinate in texels.
317        let far = entry.transform * kurbo::Point::new(8.0, 4.0);
318        assert!((far.x - 4.0).abs() < 1e-9, "x mapped to {}", far.x);
319        assert!((far.y - 2.0).abs() < 1e-9, "y mapped to {}", far.y);
320    }
321
322    #[test]
323    fn a_degenerate_destination_encodes_nothing() {
324        let mut extents = bound((4, 2));
325        let mut paints = Vec::new();
326
327        let skip = encode_scene_texture(
328            7,
329            Rect::new(0.0, 0.0, 0.0, 0.0),
330            Affine::scale(0.0),
331            &mut extents,
332            &mut paints,
333        );
334
335        assert_eq!(skip, Err(ExternalSkip::SingularTransform));
336        assert!(paints.is_empty());
337    }
338
339    #[test]
340    fn an_extent_past_the_packed_halves_is_refused_rather_than_truncated() {
341        let mut extents = ExternalExtents::new();
342
343        assert!(!extents.bind(7, (70_000, 8)));
344        assert!(extents.get(7).is_none());
345    }
346
347    #[test]
348    fn a_zero_extent_is_refused() {
349        let mut extents = ExternalExtents::new();
350
351        assert!(!extents.bind(7, (0, 8)));
352        assert!(extents.is_empty());
353    }
354
355    #[test]
356    fn unbinding_forgets_the_extent() {
357        let mut extents = bound((4, 2));
358        assert_eq!(extents.len(), 1);
359
360        extents.unbind(7);
361
362        assert!(extents.is_empty());
363        assert!(extents.get(7).is_none());
364    }
365}