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}