Skip to main content

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///
172/// **One layout per renderer is the convention** (two with the image sampler,
173/// where the atlas-layer pass takes the sampler-free one). The pipeline cache
174/// keys a pipeline on the layout through an interning table with a fixed
175/// number of slots (`frust_gpu::pipeline::LAYOUT_BITS`); a layout created per
176/// frame, per class or per variant would fill it and push every later pipeline
177/// onto the uncached, compile-per-request path. Build another only with a
178/// reason the table can afford.
179#[must_use]
180pub fn strip_pipeline_layout(device: &wgpu::Device, image_sampler: bool) -> wgpu::PipelineLayout {
181    let entries = strip_bind_group_layout_entries(image_sampler);
182    let groups = entries.each_ref().map(|entries| {
183        device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
184            label: Some("frust-engine strip group"),
185            entries,
186        })
187    });
188    let refs = groups.each_ref().map(Some);
189    device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
190        label: Some("frust-engine strip layout"),
191        bind_group_layouts: &refs,
192        immediate_size: 0,
193    })
194}
195
196/// Every externally owned texture currently bound, keyed by the
197/// [`SceneTextureId`] its owner minted.
198///
199/// The stored view must be a non-array 2D view of a float-sampleable texture
200/// whose texture carries `wgpu::TextureUsages::TEXTURE_BINDING`; only mip level
201/// 0 is ever read. `wgpu` rejects anything else when the bind group is built,
202/// which is the frame the mistake surfaces on.
203///
204/// Generic over the view type (default `wgpu::TextureView`, the real engine's)
205/// so the map's own insert/replace/remove behaviour is testable without a
206/// device.
207#[derive(Debug)]
208pub struct ExternalTextures<V = wgpu::TextureView> {
209    /// Keyed by the id's opaque value rather than by [`SceneTextureId`]
210    /// itself: a display list names an external texture by that same plain
211    /// `u64` (`frust_scene::Command::SceneTexture`), and a lookup from a
212    /// compiled frame has nothing but that number to ask with.
213    views: HashMap<u64, V>,
214}
215
216impl<V> Default for ExternalTextures<V> {
217    fn default() -> Self {
218        Self {
219            views: HashMap::new(),
220        }
221    }
222}
223
224impl<V> ExternalTextures<V> {
225    /// An empty registry.
226    #[must_use]
227    pub fn new() -> Self {
228        Self::default()
229    }
230
231    /// Registers `view` under `id`, returning whatever was registered before.
232    pub fn bind(&mut self, id: SceneTextureId, view: V) -> Option<V> {
233        self.views.insert(id.get(), view)
234    }
235
236    /// Removes and returns the view registered under `id`.
237    pub fn unbind(&mut self, id: SceneTextureId) -> Option<V> {
238        self.views.remove(&id.get())
239    }
240
241    /// The view registered under `id`.
242    #[must_use]
243    pub fn get(&self, id: SceneTextureId) -> Option<&V> {
244        self.views.get(&id.get())
245    }
246
247    /// The view registered under the opaque id a display list names, which is
248    /// the only form a compiled frame carries.
249    #[must_use]
250    pub fn view(&self, key: u64) -> Option<&V> {
251        self.views.get(&key)
252    }
253
254    /// How many textures are bound.
255    #[must_use]
256    pub fn len(&self) -> usize {
257        self.views.len()
258    }
259
260    /// Whether nothing is bound.
261    #[must_use]
262    pub fn is_empty(&self) -> bool {
263        self.views.is_empty()
264    }
265}
266
267/// The distinct external textures one frame draws with, in the order it first
268/// names them.
269///
270/// A frame's instances are drawn in painter order, and the single external
271/// binding has to be re-set whenever that order crosses from one texture to
272/// another. Rather than carry the id on every instance, each encoded paint
273/// takes a *slot* — its index here — and the renderer's pass segments carry
274/// that slot, so a run of instances sharing a texture is one segment and one
275/// bind-group set.
276///
277/// Filled while the frame's paints are lowered and read while its passes are
278/// recorded; cleared and refilled per frame, never growing past the number of
279/// external textures a single frame draws.
280#[derive(Debug, Default)]
281pub struct ExternalRuns {
282    keys: Vec<u64>,
283}
284
285impl ExternalRuns {
286    /// An empty set of runs.
287    #[must_use]
288    pub fn new() -> Self {
289        Self::default()
290    }
291
292    /// Forgets the previous frame's textures, keeping the allocation.
293    pub fn clear(&mut self) {
294        self.keys.clear();
295    }
296
297    /// The slot `key` is drawn under, assigning one if this frame has not
298    /// named it yet.
299    ///
300    /// A linear scan rather than a map: a frame draws a handful of external
301    /// textures at most — the binding is re-set between runs, so a scene that
302    /// interleaved hundreds would be paying far more for the pass breaks than
303    /// for the lookup.
304    ///
305    /// `None` once the slot numbering would leave `u32`, which no real frame
306    /// reaches; the paint is then left unresolved and its draws skipped, which
307    /// is the same answer an unbound texture gets.
308    pub fn slot_of(&mut self, key: u64) -> Option<u32> {
309        if let Some(index) = self.keys.iter().position(|held| *held == key) {
310            return u32::try_from(index).ok();
311        }
312        let slot = u32::try_from(self.keys.len()).ok()?;
313        self.keys.push(key);
314        Some(slot)
315    }
316
317    /// The texture each slot names, indexed by slot number.
318    #[must_use]
319    pub fn keys(&self) -> &[u64] {
320        &self.keys
321    }
322
323    /// The texture drawn under `slot`.
324    #[must_use]
325    pub fn key_at(&self, slot: u32) -> Option<u64> {
326        self.keys.get(slot as usize).copied()
327    }
328
329    /// How many distinct external textures the frame draws.
330    #[must_use]
331    pub fn len(&self) -> usize {
332        self.keys.len()
333    }
334
335    /// Whether the frame draws none.
336    #[must_use]
337    pub fn is_empty(&self) -> bool {
338        self.keys.is_empty()
339    }
340}
341
342/// Lower one encoded external texture into the record the strip shader samples.
343///
344/// The external counterpart of [`super::atlas::lower_encoded_image`], and the
345/// same record type: the shader reads both through one `GpuEncodedImage`
346/// layout and picks the binding from the source-kind bit
347/// [`pack_image_params`] sets here. What differs is where the numbers come
348/// from — the source region is the caller's own texel rectangle rather than an
349/// atlas allocation, there is no atlas layer to name (hence the zero index,
350/// which the shader never reads on this path) and no padding, since nothing
351/// was packed beside it.
352///
353/// The transform is already the inverse mapping — device space into texel
354/// space — because that is the direction the shader applies it in; narrowing
355/// it to `f32` here is what the record and the WGSL both read it at.
356#[must_use]
357pub fn lower_encoded_external(entry: &EncodedExternalTexture) -> GpuEncodedPaint {
358    let region = entry.source_region;
359    let (tint, tint_mode) = pack_tint(entry.tint);
360
361    GpuEncodedPaint::Image(GpuEncodedImage {
362        image_params: pack_image_params(
363            entry.sampler.quality as u32,
364            extend_mode(entry.sampler.x_extend),
365            extend_mode(entry.sampler.y_extend),
366            0,
367            true,
368        ),
369        image_size: pack_image_size(
370            region.x1.saturating_sub(region.x0),
371            region.y1.saturating_sub(region.y0),
372        ),
373        image_offset: pack_image_offset(region.x0, region.y0),
374        transform: entry.transform.as_coeffs().map(|coeff| coeff as f32),
375        tint,
376        tint_mode,
377        image_padding: 0,
378    })
379}
380
381/// What a cached bind group samples as its layer input.
382#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
383pub enum GroupSource {
384    /// The transparent placeholder, which composites nothing.
385    Placeholder,
386    /// A pooled page, by the id its texture minted for itself. A pool that
387    /// drops a texture and allocates another never reuses the id, so an entry
388    /// naming a dropped texture can only ever miss.
389    Pooled(SceneTextureId),
390    /// A retained page: the address of its view in the store, the store's
391    /// generation and the page extent. The store advances its generation
392    /// whenever a page is added, evicted, re-rendered at another extent or the
393    /// store is cleared, which are the only ways a view's address can come to
394    /// name a different texture.
395    Retained {
396        /// Address of the page's view inside the store.
397        view: usize,
398        /// The store's generation when the key was made.
399        generation: u64,
400        /// The page extent.
401        size: (u32, u32),
402    },
403}
404
405/// Which bind group over a [`GroupSource`] a [`SourceGroupCache`] entry is.
406#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
407pub enum GroupKind {
408    /// Group 0 of a strip pass over page-config buffer `slot`: the frame's
409    /// coverage, that buffer, and the source as the layer input.
410    Resources {
411        /// Index into the renderer's page-config buffers.
412        slot: u32,
413    },
414    /// The filter pass's source group (its group 1).
415    FilterSource,
416}
417
418/// A cache key: one kind of group over one source.
419#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
420pub struct SourceGroupKey {
421    /// Which group.
422    pub kind: GroupKind,
423    /// What it samples.
424    pub source: GroupSource,
425}
426
427/// The most entries a cache keeps before it drops them all.
428///
429/// A steady frame touches a handful (one per live page and composite source);
430/// the bound only matters if the sweep is never driven.
431const SOURCE_GROUP_CAP: usize = 64;
432
433/// Bind groups that name a pooled or retained page, kept across frames.
434///
435/// A page-round resources group, a composite or backdrop segment's group and a
436/// filter pass's source group each name one texture view; rebuilding them for
437/// the same view every frame is pure cost. Entries are keyed by what they
438/// sample ([`SourceGroupKey`]) and **swept every frame**: [`Self::sweep`] drops
439/// every entry the frame did not use, so a group never keeps a texture alive
440/// past the frame after the last one that drew with it — well inside the page
441/// pool's own keep-alive window, which has no eviction hook to invalidate
442/// against. The owner clears the cache outright when an input every entry
443/// shares (the coverage view) is recreated.
444///
445/// Generic over the group type so the keying and sweep rules test without a
446/// device.
447#[derive(Debug)]
448pub struct SourceGroupCache<G = wgpu::BindGroup> {
449    entries: HashMap<SourceGroupKey, (G, bool)>,
450    created: u64,
451}
452
453impl<G> Default for SourceGroupCache<G> {
454    fn default() -> Self {
455        Self {
456            entries: HashMap::new(),
457            created: 0,
458        }
459    }
460}
461
462impl<G: Clone> SourceGroupCache<G> {
463    /// The group under `key`, built by `build` on a miss. Marks it used.
464    pub fn get_or_build(&mut self, key: SourceGroupKey, build: impl FnOnce() -> G) -> G {
465        if let Some((group, used)) = self.entries.get_mut(&key) {
466            *used = true;
467            return group.clone();
468        }
469        if self.entries.len() >= SOURCE_GROUP_CAP {
470            self.entries.clear();
471        }
472        self.created = self.created.saturating_add(1);
473        let group = build();
474        self.entries.insert(key, (group.clone(), true));
475        group
476    }
477
478    /// Ends a frame: drops every entry the frame did not use and marks the
479    /// survivors unused for the next one.
480    pub fn sweep(&mut self) {
481        self.entries.retain(|_, (_, used)| std::mem::take(used));
482    }
483
484    /// Drops every entry.
485    pub fn clear(&mut self) {
486        self.entries.clear();
487    }
488
489    /// How many groups this cache has built, over its life.
490    #[must_use]
491    pub fn created(&self) -> u64 {
492        self.created
493    }
494
495    /// How many entries it holds right now.
496    #[must_use]
497    pub fn len(&self) -> usize {
498        self.entries.len()
499    }
500
501    /// Whether it holds none.
502    #[must_use]
503    pub fn is_empty(&self) -> bool {
504        self.entries.is_empty()
505    }
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511
512    use peniko::{ImageQuality, ImageSampler};
513    use vello_common::TextureId;
514    use vello_common::geometry::RectU16;
515    use vello_common::kurbo::Affine;
516
517    fn key(slot: u32, source: GroupSource) -> SourceGroupKey {
518        SourceGroupKey {
519            kind: GroupKind::Resources { slot },
520            source,
521        }
522    }
523
524    #[test]
525    fn a_source_group_is_built_once_while_it_keeps_being_used() {
526        let mut cache: SourceGroupCache<u32> = SourceGroupCache::default();
527        let page = key(0, GroupSource::Pooled(SceneTextureId::mint()));
528        for _ in 0..4 {
529            assert_eq!(cache.get_or_build(page, || 7), 7);
530            cache.sweep();
531        }
532        assert_eq!(cache.created(), 1);
533        assert_eq!(cache.len(), 1);
534    }
535
536    #[test]
537    fn a_sweep_drops_a_group_the_frame_did_not_use() {
538        let mut cache: SourceGroupCache<u32> = SourceGroupCache::default();
539        let kept = key(0, GroupSource::Placeholder);
540        let gone = key(1, GroupSource::Pooled(SceneTextureId::mint()));
541        cache.get_or_build(kept, || 1);
542        cache.get_or_build(gone, || 2);
543        cache.sweep();
544        assert_eq!(cache.len(), 2, "both were used by the frame just ended");
545        cache.get_or_build(kept, || 1);
546        cache.sweep();
547        assert_eq!(cache.len(), 1, "the unused one is dropped");
548        cache.get_or_build(gone, || 3);
549        assert_eq!(cache.created(), 3, "a dropped group is rebuilt on return");
550    }
551
552    #[test]
553    fn keys_differ_by_slot_source_and_retained_generation() {
554        let mut cache: SourceGroupCache<u32> = SourceGroupCache::default();
555        let retained = |generation| GroupSource::Retained {
556            view: 0x10,
557            generation,
558            size: (8, 8),
559        };
560        cache.get_or_build(key(0, retained(1)), || 1);
561        cache.get_or_build(key(0, retained(2)), || 2);
562        cache.get_or_build(key(1, retained(1)), || 3);
563        cache.get_or_build(key(0, GroupSource::Pooled(SceneTextureId::mint())), || 4);
564        cache.get_or_build(key(0, GroupSource::Pooled(SceneTextureId::mint())), || 5);
565        assert_eq!(cache.created(), 5);
566    }
567
568    #[test]
569    fn clear_forgets_everything_and_a_runaway_cache_is_bounded() {
570        let mut cache: SourceGroupCache<u32> = SourceGroupCache::default();
571        for n in 0..(SOURCE_GROUP_CAP as u32 * 3) {
572            cache.get_or_build(key(n, GroupSource::Placeholder), || n);
573            assert!(cache.len() <= SOURCE_GROUP_CAP);
574        }
575        cache.clear();
576        assert!(cache.is_empty());
577    }
578
579    /// A registry over plain values, which is all the map's own behaviour
580    /// needs — minting a real `wgpu::TextureView` would require a device.
581    fn registry() -> ExternalTextures<&'static str> {
582        ExternalTextures::new()
583    }
584
585    fn entry(region: RectU16) -> EncodedExternalTexture {
586        EncodedExternalTexture {
587            texture_id: TextureId(1),
588            source_region: region,
589            sampler: ImageSampler {
590                quality: ImageQuality::Medium,
591                ..ImageSampler::default()
592            },
593            may_have_transparency: true,
594            transform: Affine::IDENTITY,
595            tint: None,
596        }
597    }
598
599    #[test]
600    fn a_bound_view_is_reachable_by_id_and_by_the_scenes_own_number() {
601        let mut textures = registry();
602        let id = fresh_id();
603
604        assert!(textures.bind(id, "view").is_none());
605
606        assert_eq!(textures.get(id), Some(&"view"));
607        assert_eq!(textures.view(id.get()), Some(&"view"));
608        assert_eq!(textures.len(), 1);
609    }
610
611    #[test]
612    fn rebinding_returns_the_previous_view() {
613        let mut textures = registry();
614        let id = fresh_id();
615        textures.bind(id, "first");
616
617        assert_eq!(textures.bind(id, "second"), Some("first"));
618        assert_eq!(textures.get(id), Some(&"second"));
619    }
620
621    #[test]
622    fn unbinding_returns_the_view_and_empties_the_registry() {
623        let mut textures = registry();
624        let id = fresh_id();
625        textures.bind(id, "view");
626
627        assert_eq!(textures.unbind(id), Some("view"));
628        assert!(textures.is_empty());
629        assert_eq!(textures.view(id.get()), None);
630    }
631
632    #[test]
633    fn an_unbound_id_resolves_to_nothing() {
634        let textures = registry();
635
636        assert_eq!(textures.view(4_242), None);
637    }
638
639    #[test]
640    fn a_slot_is_assigned_once_per_texture_and_reused_after() {
641        let mut runs = ExternalRuns::new();
642
643        assert_eq!(runs.slot_of(7), Some(0));
644        assert_eq!(runs.slot_of(9), Some(1));
645        assert_eq!(runs.slot_of(7), Some(0), "the same texture keeps its slot");
646        assert_eq!(runs.keys(), &[7, 9]);
647        assert_eq!(runs.key_at(1), Some(9));
648        assert_eq!(runs.key_at(2), None);
649    }
650
651    #[test]
652    fn clearing_forgets_the_previous_frames_textures() {
653        let mut runs = ExternalRuns::new();
654        runs.slot_of(7);
655
656        runs.clear();
657
658        assert!(runs.is_empty());
659        assert_eq!(runs.slot_of(9), Some(0), "slots restart each frame");
660    }
661
662    #[test]
663    fn a_lowered_external_record_names_the_external_source_and_no_atlas_layer() {
664        let record = lower_encoded_external(&entry(RectU16 {
665            x0: 0,
666            y0: 0,
667            x1: 64,
668            y1: 32,
669        }));
670
671        let GpuEncodedPaint::Image(image) = record else {
672            panic!("an external texture lowers to the image record");
673        };
674        assert_eq!(
675            (image.image_params >> 14) & 0x1,
676            1,
677            "the source-kind bit selects the external binding"
678        );
679        assert_eq!((image.image_params >> 6) & 0xFF, 0, "no atlas layer");
680        assert_eq!(image.image_size, pack_image_size(64, 32));
681        assert_eq!(image.image_offset, pack_image_offset(0, 0));
682        assert_eq!(image.image_padding, 0, "nothing was packed beside it");
683    }
684
685    #[test]
686    fn a_sub_rectangle_lowers_as_its_own_offset_and_extent() {
687        let record = lower_encoded_external(&entry(RectU16 {
688            x0: 8,
689            y0: 4,
690            x1: 24,
691            y1: 20,
692        }));
693
694        let GpuEncodedPaint::Image(image) = record else {
695            panic!("an external texture lowers to the image record");
696        };
697        assert_eq!(image.image_offset, pack_image_offset(8, 4));
698        assert_eq!(image.image_size, pack_image_size(16, 16));
699    }
700
701    /// The explicit strip layout of each family is the layout wgpu would
702    /// derive from that family's whole strip program, entry for entry: every
703    /// binding the program declares is present with the type naga reflects for
704    /// it — a float texture filterable exactly when a sampler reads it —
705    /// visible to exactly the stages whose entry point uses it, and nothing
706    /// else is declared. That is what makes switching the Uber program from a
707    /// derived to this explicit layout a no-op, and what lets every class
708    /// program of the family — a subset of the Uber one's uses — validate
709    /// against it.
710    #[test]
711    fn the_explicit_strip_layout_is_the_uber_programs_derived_layout() {
712        for image_sampler in [false, true] {
713            explicit_layout_matches_derived(image_sampler);
714        }
715    }
716
717    fn explicit_layout_matches_derived(image_sampler: bool) {
718        use crate::gpu::shader_src::strip_program_source;
719        use crate::gpu::strips::PaintClass;
720        use wgpu::naga;
721        let src = strip_program_source(PaintClass::Uber, image_sampler);
722        let module = naga::front::wgsl::parse_str(&src).expect("the strip source parses");
723        let info = naga::valid::Validator::new(
724            naga::valid::ValidationFlags::all(),
725            naga::valid::Capabilities::empty(),
726        )
727        .validate(&module)
728        .expect("the strip source validates");
729        let sampled = |handle| {
730            (0..module.entry_points.len()).any(|index| {
731                info.get_entry_point(index)
732                    .sampling_set
733                    .iter()
734                    .any(|key| key.image == handle)
735            })
736        };
737
738        let entries = strip_bind_group_layout_entries(image_sampler);
739        let mut declared = 0;
740        for (handle, global) in module.global_variables.iter() {
741            let Some(binding) = &global.binding else {
742                continue;
743            };
744            declared += 1;
745            let entry = entries
746                .get(binding.group as usize)
747                .and_then(|group| group.iter().find(|e| e.binding == binding.binding))
748                .unwrap_or_else(|| panic!("no layout entry for {binding:?}"));
749
750            let mut stages = wgpu::ShaderStages::NONE;
751            for (index, entry_point) in module.entry_points.iter().enumerate() {
752                if !info.get_entry_point(index)[handle].is_empty() {
753                    stages |= match entry_point.stage {
754                        naga::ShaderStage::Vertex => wgpu::ShaderStages::VERTEX,
755                        naga::ShaderStage::Fragment => wgpu::ShaderStages::FRAGMENT,
756                        _ => wgpu::ShaderStages::NONE,
757                    };
758                }
759            }
760            assert_eq!(entry.visibility, stages, "{:?} visibility", global.name);
761
762            match (&module.types[global.ty].inner, entry.ty) {
763                (
764                    naga::TypeInner::Image {
765                        dim: naga::ImageDimension::D2,
766                        arrayed,
767                        class: naga::ImageClass::Sampled { kind, multi: false },
768                    },
769                    wgpu::BindingType::Texture {
770                        sample_type,
771                        view_dimension,
772                        multisampled: false,
773                    },
774                ) => {
775                    let expected_dim = if *arrayed {
776                        wgpu::TextureViewDimension::D2Array
777                    } else {
778                        wgpu::TextureViewDimension::D2
779                    };
780                    assert_eq!(view_dimension, expected_dim, "{:?}", global.name);
781                    let expected_sample = match kind {
782                        naga::ScalarKind::Uint => wgpu::TextureSampleType::Uint,
783                        naga::ScalarKind::Float if sampled(handle) => FILTERABLE,
784                        naga::ScalarKind::Float => UNFILTERABLE,
785                        other => panic!("unexpected sample kind {other:?}"),
786                    };
787                    assert_eq!(sample_type, expected_sample, "{:?}", global.name);
788                }
789                (
790                    naga::TypeInner::Sampler { comparison: false },
791                    wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
792                ) => {
793                    assert_eq!(binding.group, 1);
794                    assert_eq!(binding.binding, ATLAS_SAMPLER_BINDING);
795                }
796                (
797                    naga::TypeInner::Struct { span, .. },
798                    wgpu::BindingType::Buffer {
799                        ty: wgpu::BufferBindingType::Uniform,
800                        has_dynamic_offset: false,
801                        min_binding_size,
802                    },
803                ) => {
804                    assert_eq!(global.space, naga::AddressSpace::Uniform);
805                    assert_eq!(
806                        min_binding_size.map(|size| size.get()),
807                        Some(u64::from(*span))
808                    );
809                }
810                (shader, layout) => panic!("{:?}: {shader:?} vs {layout:?}", global.name),
811            }
812        }
813        assert_eq!(
814            declared,
815            entries.iter().map(Vec::len).sum::<usize>(),
816            "the layout declares a binding the program does not"
817        );
818        assert_eq!(
819            entries[1].len(),
820            if image_sampler { 3 } else { 2 },
821            "only the sampler family declares the sampler"
822        );
823    }
824
825    /// A fresh [`SceneTextureId`], minted the only way this crate can: off a
826    /// texture handle built over stand-in values.
827    fn fresh_id() -> SceneTextureId {
828        frust_gpu::Texture::new(
829            (),
830            (),
831            frust_gpu::TextureDesc {
832                width: 1,
833                height: 1,
834                format: wgpu::TextureFormat::Rgba8Unorm,
835                usage: wgpu::TextureUsages::TEXTURE_BINDING,
836                label: None,
837            },
838        )
839        .as_scene_texture()
840    }
841}