frust_engine/gpu/bindings.rs
1//! The registry of caller-owned textures a frame's strip passes sample, and
2//! the per-frame batching that binds each of them in turn.
3//!
4//! The engine draws two kinds of image. An atlas-backed one lives in the
5//! renderer's own array texture, addressed by layer and rectangle, and every
6//! draw of the frame reads it through the same binding. An *external* one is a
7//! texture the host created and still owns — a video frame, a plugin's import,
8//! an offscreen page some other pass rendered — which the engine never copies
9//! and never packs. It is bound as a whole texture instead, and the strip
10//! shader's image path reads it through a second binding chosen by the encoded
11//! paint's source-kind bit (see `shaders/helpers.wgsl`).
12//!
13//! One binding, many textures: a frame that draws two different external
14//! textures cannot bind both at once, so its instances are split into *runs* —
15//! maximal spans of consecutive instances sharing one texture — and each run is
16//! drawn with that texture bound. [`ExternalRuns`] assigns the slot numbers the
17//! renderer's pass segments name a run by; the renderer builds one group-1 bind
18//! group per slot and sets it between runs.
19//!
20//! Nothing here allocates a texture or copies a texel. [`ExternalTextures`] is
21//! a plain map the host writes through
22//! `EngineRenderer::bind_texture`/`unbind_texture`, and it is generic over the
23//! stored view type — like `frust_gpu::TextureRegistry`, so its own behaviour
24//! stays host-testable with a stand-in value and no GPU device in the loop.
25//!
26//! The *extent* half of the same registry lives on the compiler side, in
27//! [`crate::compile::external::ExternalExtents`]: the walk needs it to compose
28//! a paint transform, while only a pass needs the view. The one call that
29//! writes both keeps them in step.
30//!
31//! ## The strip programs' one explicit layout
32//!
33//! Every strip pipeline — each render-state variant, and each
34//! [paint-class](super::strips::PaintClass) specialisation of it — is built
35//! against ONE explicit `wgpu::PipelineLayout`, [`strip_pipeline_layout`],
36//! rather than the layout wgpu would derive from its module. A derived layout
37//! is whatever bindings the module's entry points statically *use*, and a class
38//! program that no longer reads, say, the atlas array would derive a layout
39//! without it; the bind groups built for one class would then not fit another.
40//! The explicit layout is exactly the one the whole (Uber) program derives —
41//! [`strip_bind_group_layout_entries`] restates it entry for entry, and its
42//! test holds that restatement to the naga reflection of `strip.wgsl` — so a
43//! bind group built against any strip pipeline binds under every other one.
44//!
45//! There is one such layout per program family
46//! ([`crate::gpu::shader_src`]'s *The two program families*): the manual
47//! family's (`FRUST_ENGINE_IMAGE_SAMPLER=0`) is the one above, and the sampler
48//! family's — the default — adds the one sampler its image programs filter the
49//! atlas through — group 1, binding 2 — and declares the atlas binding
50//! filterable. Every binding before it keeps its
51//! number, so the families differ by that one entry, and the four-group
52//! WebGL2 ceiling is untouched.
53
54use std::collections::HashMap;
55
56use frust_gpu::SceneTextureId;
57use vello_common::encode::EncodedExternalTexture;
58
59use super::atlas::{extend_mode, pack_image_offset, pack_image_params, pack_image_size, pack_tint};
60use super::config::GpuConfig;
61use super::paint_texture::{GpuEncodedImage, GpuEncodedPaint};
62
63/// How many bind groups the strip programs declare — the WebGL2 ceiling of
64/// four, exactly (see [`crate::gpu::pipelines`]'s *Bind groups*).
65pub const STRIP_BIND_GROUP_COUNT: usize = 4;
66
67/// One sampled, non-multisampled texture binding: what every strip texture
68/// binding is, every one of them read by `textureLoad` but the sampler
69/// family's atlas array.
70const fn load_only_texture(
71 binding: u32,
72 visibility: wgpu::ShaderStages,
73 sample_type: wgpu::TextureSampleType,
74 view_dimension: wgpu::TextureViewDimension,
75) -> wgpu::BindGroupLayoutEntry {
76 wgpu::BindGroupLayoutEntry {
77 binding,
78 visibility,
79 ty: wgpu::BindingType::Texture {
80 sample_type,
81 view_dimension,
82 multisampled: false,
83 },
84 count: None,
85 }
86}
87
88/// The float sample type every strip `texture_2d<f32>` binding takes but the
89/// sampler family's atlas array.
90///
91/// Unfilterable, because no other strip binding is ever read through a
92/// sampler — exactly what wgpu derives for a float texture no sampler touches,
93/// and what lets the binding accept a filterable *and* an unfilterable float
94/// texture.
95const UNFILTERABLE: wgpu::TextureSampleType = wgpu::TextureSampleType::Float { filterable: false };
96
97/// The float sample type of a texture a filtering sampler reads: the sampler
98/// family's atlas array, which is `Rgba8Unorm` and so always filterable.
99const FILTERABLE: wgpu::TextureSampleType = wgpu::TextureSampleType::Float { filterable: true };
100
101/// The sampler family's sampler binding: group 1, after the two image
102/// textures.
103pub const ATLAS_SAMPLER_BINDING: u32 = 2;
104
105/// The strip programs' bind-group layout entries, group by group, exactly as
106/// wgpu derives them from the whole (Uber) program of the family
107/// `image_sampler` names — binding, type and visibility.
108///
109/// | Group | Binding | WGSL | Read by |
110/// |---|---|---|---|
111/// | 0 | 0 | `alphas_texture: texture_2d<u32>` | fragment |
112/// | 0 | 1 | `config: Config` (uniform, [`GpuConfig::SIZE`] bytes) | vertex, fragment |
113/// | 0 | 2 | `layer_input_texture: texture_2d<f32>` | fragment |
114/// | 1 | 0 | `atlas_texture_array: texture_2d_array<f32>` (filterable in the sampler family) | fragment |
115/// | 1 | 1 | `external_texture: texture_2d<f32>` | fragment |
116/// | 1 | 2 | `atlas_sampler: sampler` (filtering; the sampler family only) | fragment |
117/// | 2 | 0 | `encoded_paints_texture: texture_2d<u32>` | vertex, fragment |
118/// | 3 | 0 | `gradient_texture: texture_2d<f32>` | fragment |
119#[must_use]
120pub fn strip_bind_group_layout_entries(
121 image_sampler: bool,
122) -> [Vec<wgpu::BindGroupLayoutEntry>; STRIP_BIND_GROUP_COUNT] {
123 use wgpu::ShaderStages as S;
124 use wgpu::TextureSampleType as T;
125 use wgpu::TextureViewDimension as D;
126 [
127 vec![
128 load_only_texture(0, S::FRAGMENT, T::Uint, D::D2),
129 wgpu::BindGroupLayoutEntry {
130 binding: 1,
131 visibility: S::VERTEX_FRAGMENT,
132 ty: wgpu::BindingType::Buffer {
133 ty: wgpu::BufferBindingType::Uniform,
134 has_dynamic_offset: false,
135 min_binding_size: wgpu::BufferSize::new(GpuConfig::SIZE),
136 },
137 count: None,
138 },
139 load_only_texture(2, S::FRAGMENT, UNFILTERABLE, D::D2),
140 ],
141 if image_sampler {
142 vec![
143 load_only_texture(0, S::FRAGMENT, FILTERABLE, D::D2Array),
144 load_only_texture(1, S::FRAGMENT, UNFILTERABLE, D::D2),
145 wgpu::BindGroupLayoutEntry {
146 binding: ATLAS_SAMPLER_BINDING,
147 visibility: S::FRAGMENT,
148 ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
149 count: None,
150 },
151 ]
152 } else {
153 vec![
154 load_only_texture(0, S::FRAGMENT, UNFILTERABLE, D::D2Array),
155 load_only_texture(1, S::FRAGMENT, UNFILTERABLE, D::D2),
156 ]
157 },
158 vec![load_only_texture(0, S::VERTEX_FRAGMENT, T::Uint, D::D2)],
159 vec![load_only_texture(0, S::FRAGMENT, UNFILTERABLE, D::D2)],
160 ]
161}
162
163/// The one explicit pipeline layout every strip pipeline of the family
164/// `image_sampler` names is built against.
165///
166/// Created once per renderer, at shader registration
167/// ([`crate::gpu::pipelines::EngineShaders::register`]). A pipeline built
168/// against it hands out these same group layouts from
169/// `get_bind_group_layout`, so the renderer's existing bind-group construction
170/// is unchanged and its groups bind under every strip variant and class.
171#[must_use]
172pub fn strip_pipeline_layout(device: &wgpu::Device, image_sampler: bool) -> wgpu::PipelineLayout {
173 let entries = strip_bind_group_layout_entries(image_sampler);
174 let groups = entries.each_ref().map(|entries| {
175 device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
176 label: Some("frust-engine strip group"),
177 entries,
178 })
179 });
180 let refs = groups.each_ref().map(Some);
181 device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
182 label: Some("frust-engine strip layout"),
183 bind_group_layouts: &refs,
184 immediate_size: 0,
185 })
186}
187
188/// Every externally owned texture currently bound, keyed by the
189/// [`SceneTextureId`] its owner minted.
190///
191/// The stored view must be a non-array 2D view of a float-sampleable texture
192/// whose texture carries `wgpu::TextureUsages::TEXTURE_BINDING`; only mip level
193/// 0 is ever read. `wgpu` rejects anything else when the bind group is built,
194/// which is the frame the mistake surfaces on.
195///
196/// Generic over the view type (default `wgpu::TextureView`, the real engine's)
197/// so the map's own insert/replace/remove behaviour is testable without a
198/// device.
199#[derive(Debug)]
200pub struct ExternalTextures<V = wgpu::TextureView> {
201 /// Keyed by the id's opaque value rather than by [`SceneTextureId`]
202 /// itself: a display list names an external texture by that same plain
203 /// `u64` (`frust_scene::Command::SceneTexture`), and a lookup from a
204 /// compiled frame has nothing but that number to ask with.
205 views: HashMap<u64, V>,
206}
207
208impl<V> Default for ExternalTextures<V> {
209 fn default() -> Self {
210 Self {
211 views: HashMap::new(),
212 }
213 }
214}
215
216impl<V> ExternalTextures<V> {
217 /// An empty registry.
218 #[must_use]
219 pub fn new() -> Self {
220 Self::default()
221 }
222
223 /// Registers `view` under `id`, returning whatever was registered before.
224 pub fn bind(&mut self, id: SceneTextureId, view: V) -> Option<V> {
225 self.views.insert(id.get(), view)
226 }
227
228 /// Removes and returns the view registered under `id`.
229 pub fn unbind(&mut self, id: SceneTextureId) -> Option<V> {
230 self.views.remove(&id.get())
231 }
232
233 /// The view registered under `id`.
234 #[must_use]
235 pub fn get(&self, id: SceneTextureId) -> Option<&V> {
236 self.views.get(&id.get())
237 }
238
239 /// The view registered under the opaque id a display list names, which is
240 /// the only form a compiled frame carries.
241 #[must_use]
242 pub fn view(&self, key: u64) -> Option<&V> {
243 self.views.get(&key)
244 }
245
246 /// How many textures are bound.
247 #[must_use]
248 pub fn len(&self) -> usize {
249 self.views.len()
250 }
251
252 /// Whether nothing is bound.
253 #[must_use]
254 pub fn is_empty(&self) -> bool {
255 self.views.is_empty()
256 }
257}
258
259/// The distinct external textures one frame draws with, in the order it first
260/// names them.
261///
262/// A frame's instances are drawn in painter order, and the single external
263/// binding has to be re-set whenever that order crosses from one texture to
264/// another. Rather than carry the id on every instance, each encoded paint
265/// takes a *slot* — its index here — and the renderer's pass segments carry
266/// that slot, so a run of instances sharing a texture is one segment and one
267/// bind-group set.
268///
269/// Filled while the frame's paints are lowered and read while its passes are
270/// recorded; cleared and refilled per frame, never growing past the number of
271/// external textures a single frame draws.
272#[derive(Debug, Default)]
273pub struct ExternalRuns {
274 keys: Vec<u64>,
275}
276
277impl ExternalRuns {
278 /// An empty set of runs.
279 #[must_use]
280 pub fn new() -> Self {
281 Self::default()
282 }
283
284 /// Forgets the previous frame's textures, keeping the allocation.
285 pub fn clear(&mut self) {
286 self.keys.clear();
287 }
288
289 /// The slot `key` is drawn under, assigning one if this frame has not
290 /// named it yet.
291 ///
292 /// A linear scan rather than a map: a frame draws a handful of external
293 /// textures at most — the binding is re-set between runs, so a scene that
294 /// interleaved hundreds would be paying far more for the pass breaks than
295 /// for the lookup.
296 ///
297 /// `None` once the slot numbering would leave `u32`, which no real frame
298 /// reaches; the paint is then left unresolved and its draws skipped, which
299 /// is the same answer an unbound texture gets.
300 pub fn slot_of(&mut self, key: u64) -> Option<u32> {
301 if let Some(index) = self.keys.iter().position(|held| *held == key) {
302 return u32::try_from(index).ok();
303 }
304 let slot = u32::try_from(self.keys.len()).ok()?;
305 self.keys.push(key);
306 Some(slot)
307 }
308
309 /// The texture each slot names, indexed by slot number.
310 #[must_use]
311 pub fn keys(&self) -> &[u64] {
312 &self.keys
313 }
314
315 /// The texture drawn under `slot`.
316 #[must_use]
317 pub fn key_at(&self, slot: u32) -> Option<u64> {
318 self.keys.get(slot as usize).copied()
319 }
320
321 /// How many distinct external textures the frame draws.
322 #[must_use]
323 pub fn len(&self) -> usize {
324 self.keys.len()
325 }
326
327 /// Whether the frame draws none.
328 #[must_use]
329 pub fn is_empty(&self) -> bool {
330 self.keys.is_empty()
331 }
332}
333
334/// Lower one encoded external texture into the record the strip shader samples.
335///
336/// The external counterpart of [`super::atlas::lower_encoded_image`], and the
337/// same record type: the shader reads both through one `GpuEncodedImage`
338/// layout and picks the binding from the source-kind bit
339/// [`pack_image_params`] sets here. What differs is where the numbers come
340/// from — the source region is the caller's own texel rectangle rather than an
341/// atlas allocation, there is no atlas layer to name (hence the zero index,
342/// which the shader never reads on this path) and no padding, since nothing
343/// was packed beside it.
344///
345/// The transform is already the inverse mapping — device space into texel
346/// space — because that is the direction the shader applies it in; narrowing
347/// it to `f32` here is what the record and the WGSL both read it at.
348#[must_use]
349pub fn lower_encoded_external(entry: &EncodedExternalTexture) -> GpuEncodedPaint {
350 let region = entry.source_region;
351 let (tint, tint_mode) = pack_tint(entry.tint);
352
353 GpuEncodedPaint::Image(GpuEncodedImage {
354 image_params: pack_image_params(
355 entry.sampler.quality as u32,
356 extend_mode(entry.sampler.x_extend),
357 extend_mode(entry.sampler.y_extend),
358 0,
359 true,
360 ),
361 image_size: pack_image_size(
362 region.x1.saturating_sub(region.x0),
363 region.y1.saturating_sub(region.y0),
364 ),
365 image_offset: pack_image_offset(region.x0, region.y0),
366 transform: entry.transform.as_coeffs().map(|coeff| coeff as f32),
367 tint,
368 tint_mode,
369 image_padding: 0,
370 })
371}
372
373#[cfg(test)]
374mod tests {
375 use super::*;
376
377 use peniko::{ImageQuality, ImageSampler};
378 use vello_common::TextureId;
379 use vello_common::geometry::RectU16;
380 use vello_common::kurbo::Affine;
381
382 /// A registry over plain values, which is all the map's own behaviour
383 /// needs — minting a real `wgpu::TextureView` would require a device.
384 fn registry() -> ExternalTextures<&'static str> {
385 ExternalTextures::new()
386 }
387
388 fn entry(region: RectU16) -> EncodedExternalTexture {
389 EncodedExternalTexture {
390 texture_id: TextureId(1),
391 source_region: region,
392 sampler: ImageSampler {
393 quality: ImageQuality::Medium,
394 ..ImageSampler::default()
395 },
396 may_have_transparency: true,
397 transform: Affine::IDENTITY,
398 tint: None,
399 }
400 }
401
402 #[test]
403 fn a_bound_view_is_reachable_by_id_and_by_the_scenes_own_number() {
404 let mut textures = registry();
405 let id = fresh_id();
406
407 assert!(textures.bind(id, "view").is_none());
408
409 assert_eq!(textures.get(id), Some(&"view"));
410 assert_eq!(textures.view(id.get()), Some(&"view"));
411 assert_eq!(textures.len(), 1);
412 }
413
414 #[test]
415 fn rebinding_returns_the_previous_view() {
416 let mut textures = registry();
417 let id = fresh_id();
418 textures.bind(id, "first");
419
420 assert_eq!(textures.bind(id, "second"), Some("first"));
421 assert_eq!(textures.get(id), Some(&"second"));
422 }
423
424 #[test]
425 fn unbinding_returns_the_view_and_empties_the_registry() {
426 let mut textures = registry();
427 let id = fresh_id();
428 textures.bind(id, "view");
429
430 assert_eq!(textures.unbind(id), Some("view"));
431 assert!(textures.is_empty());
432 assert_eq!(textures.view(id.get()), None);
433 }
434
435 #[test]
436 fn an_unbound_id_resolves_to_nothing() {
437 let textures = registry();
438
439 assert_eq!(textures.view(4_242), None);
440 }
441
442 #[test]
443 fn a_slot_is_assigned_once_per_texture_and_reused_after() {
444 let mut runs = ExternalRuns::new();
445
446 assert_eq!(runs.slot_of(7), Some(0));
447 assert_eq!(runs.slot_of(9), Some(1));
448 assert_eq!(runs.slot_of(7), Some(0), "the same texture keeps its slot");
449 assert_eq!(runs.keys(), &[7, 9]);
450 assert_eq!(runs.key_at(1), Some(9));
451 assert_eq!(runs.key_at(2), None);
452 }
453
454 #[test]
455 fn clearing_forgets_the_previous_frames_textures() {
456 let mut runs = ExternalRuns::new();
457 runs.slot_of(7);
458
459 runs.clear();
460
461 assert!(runs.is_empty());
462 assert_eq!(runs.slot_of(9), Some(0), "slots restart each frame");
463 }
464
465 #[test]
466 fn a_lowered_external_record_names_the_external_source_and_no_atlas_layer() {
467 let record = lower_encoded_external(&entry(RectU16 {
468 x0: 0,
469 y0: 0,
470 x1: 64,
471 y1: 32,
472 }));
473
474 let GpuEncodedPaint::Image(image) = record else {
475 panic!("an external texture lowers to the image record");
476 };
477 assert_eq!(
478 (image.image_params >> 14) & 0x1,
479 1,
480 "the source-kind bit selects the external binding"
481 );
482 assert_eq!((image.image_params >> 6) & 0xFF, 0, "no atlas layer");
483 assert_eq!(image.image_size, pack_image_size(64, 32));
484 assert_eq!(image.image_offset, pack_image_offset(0, 0));
485 assert_eq!(image.image_padding, 0, "nothing was packed beside it");
486 }
487
488 #[test]
489 fn a_sub_rectangle_lowers_as_its_own_offset_and_extent() {
490 let record = lower_encoded_external(&entry(RectU16 {
491 x0: 8,
492 y0: 4,
493 x1: 24,
494 y1: 20,
495 }));
496
497 let GpuEncodedPaint::Image(image) = record else {
498 panic!("an external texture lowers to the image record");
499 };
500 assert_eq!(image.image_offset, pack_image_offset(8, 4));
501 assert_eq!(image.image_size, pack_image_size(16, 16));
502 }
503
504 /// The explicit strip layout of each family is the layout wgpu would
505 /// derive from that family's whole strip program, entry for entry: every
506 /// binding the program declares is present with the type naga reflects for
507 /// it — a float texture filterable exactly when a sampler reads it —
508 /// visible to exactly the stages whose entry point uses it, and nothing
509 /// else is declared. That is what makes switching the Uber program from a
510 /// derived to this explicit layout a no-op, and what lets every class
511 /// program of the family — a subset of the Uber one's uses — validate
512 /// against it.
513 #[test]
514 fn the_explicit_strip_layout_is_the_uber_programs_derived_layout() {
515 for image_sampler in [false, true] {
516 explicit_layout_matches_derived(image_sampler);
517 }
518 }
519
520 fn explicit_layout_matches_derived(image_sampler: bool) {
521 use crate::gpu::shader_src::strip_program_source;
522 use crate::gpu::strips::PaintClass;
523 use wgpu::naga;
524 let src = strip_program_source(PaintClass::Uber, image_sampler);
525 let module = naga::front::wgsl::parse_str(&src).expect("the strip source parses");
526 let info = naga::valid::Validator::new(
527 naga::valid::ValidationFlags::all(),
528 naga::valid::Capabilities::empty(),
529 )
530 .validate(&module)
531 .expect("the strip source validates");
532 let sampled = |handle| {
533 (0..module.entry_points.len()).any(|index| {
534 info.get_entry_point(index)
535 .sampling_set
536 .iter()
537 .any(|key| key.image == handle)
538 })
539 };
540
541 let entries = strip_bind_group_layout_entries(image_sampler);
542 let mut declared = 0;
543 for (handle, global) in module.global_variables.iter() {
544 let Some(binding) = &global.binding else {
545 continue;
546 };
547 declared += 1;
548 let entry = entries
549 .get(binding.group as usize)
550 .and_then(|group| group.iter().find(|e| e.binding == binding.binding))
551 .unwrap_or_else(|| panic!("no layout entry for {binding:?}"));
552
553 let mut stages = wgpu::ShaderStages::NONE;
554 for (index, entry_point) in module.entry_points.iter().enumerate() {
555 if !info.get_entry_point(index)[handle].is_empty() {
556 stages |= match entry_point.stage {
557 naga::ShaderStage::Vertex => wgpu::ShaderStages::VERTEX,
558 naga::ShaderStage::Fragment => wgpu::ShaderStages::FRAGMENT,
559 _ => wgpu::ShaderStages::NONE,
560 };
561 }
562 }
563 assert_eq!(entry.visibility, stages, "{:?} visibility", global.name);
564
565 match (&module.types[global.ty].inner, entry.ty) {
566 (
567 naga::TypeInner::Image {
568 dim: naga::ImageDimension::D2,
569 arrayed,
570 class: naga::ImageClass::Sampled { kind, multi: false },
571 },
572 wgpu::BindingType::Texture {
573 sample_type,
574 view_dimension,
575 multisampled: false,
576 },
577 ) => {
578 let expected_dim = if *arrayed {
579 wgpu::TextureViewDimension::D2Array
580 } else {
581 wgpu::TextureViewDimension::D2
582 };
583 assert_eq!(view_dimension, expected_dim, "{:?}", global.name);
584 let expected_sample = match kind {
585 naga::ScalarKind::Uint => wgpu::TextureSampleType::Uint,
586 naga::ScalarKind::Float if sampled(handle) => FILTERABLE,
587 naga::ScalarKind::Float => UNFILTERABLE,
588 other => panic!("unexpected sample kind {other:?}"),
589 };
590 assert_eq!(sample_type, expected_sample, "{:?}", global.name);
591 }
592 (
593 naga::TypeInner::Sampler { comparison: false },
594 wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
595 ) => {
596 assert_eq!(binding.group, 1);
597 assert_eq!(binding.binding, ATLAS_SAMPLER_BINDING);
598 }
599 (
600 naga::TypeInner::Struct { span, .. },
601 wgpu::BindingType::Buffer {
602 ty: wgpu::BufferBindingType::Uniform,
603 has_dynamic_offset: false,
604 min_binding_size,
605 },
606 ) => {
607 assert_eq!(global.space, naga::AddressSpace::Uniform);
608 assert_eq!(
609 min_binding_size.map(|size| size.get()),
610 Some(u64::from(*span))
611 );
612 }
613 (shader, layout) => panic!("{:?}: {shader:?} vs {layout:?}", global.name),
614 }
615 }
616 assert_eq!(
617 declared,
618 entries.iter().map(Vec::len).sum::<usize>(),
619 "the layout declares a binding the program does not"
620 );
621 assert_eq!(
622 entries[1].len(),
623 if image_sampler { 3 } else { 2 },
624 "only the sampler family declares the sampler"
625 );
626 }
627
628 /// A fresh [`SceneTextureId`], minted the only way this crate can: off a
629 /// texture handle built over stand-in values.
630 fn fresh_id() -> SceneTextureId {
631 frust_gpu::Texture::new(
632 (),
633 (),
634 frust_gpu::TextureDesc {
635 width: 1,
636 height: 1,
637 format: wgpu::TextureFormat::Rgba8Unorm,
638 usage: wgpu::TextureUsages::TEXTURE_BINDING,
639 label: None,
640 },
641 )
642 .as_scene_texture()
643 }
644}