Skip to main content

frust_engine/effects/
shader_quad.rs

1//! The pre-pass behind [`Command::ShaderQuad`]: a user-supplied WGSL fragment
2//! program rendered into an offscreen texture the frame then draws as a scene
3//! texture.
4//!
5//! # The split
6//!
7//! Every GPU resource the effect needs — the lazily compiled per-program
8//! pipeline, the per-`(program, quantized size)` target pool, the
9//! fullscreen-triangle pass, the age-based reaps and the churn detector —
10//! lives one layer down in [`frust_gpu::effects`], which speaks `(id, wgsl,
11//! size, time)` primitives and knows nothing about scenes. What lives here is
12//! the half that reads a display list: which programs a frame draws, how big
13//! a target each one may ask for, and the id its rendered result is
14//! registered under.
15//!
16//! # How a quad reaches the screen
17//!
18//! [`ShaderQuadPass::prepare`] runs once per frame, before the frame's own
19//! passes are recorded and into the *same* [`wgpu::CommandEncoder`], so the
20//! effect and the frame that samples it can never be submitted apart:
21//!
22//! 1. walk the display list for its [`Command::ShaderQuad`]s
23//!    ([`frame_demands`]), collapsing every quad of one program into a single
24//!    demand and dropping one whose device rectangle does not intersect the
25//!    frame's own target at all, when that extent is known (see
26//!    [`ShaderQuadPass::set_frame_target_extent`]) — except inside an open
27//!    [`Command::PushSnapshot`] bracket, where the body's rendered pixels are
28//!    moved by a presentation transform this walk runs ahead of, so culling
29//!    is skipped there (see [`frame_demands`]'s doc);
30//! 2. compile the program if it is new, size a pooled (quantized) target for
31//!    it, and record its fullscreen-triangle pass into the encoder, confined
32//!    to the sub-rect of that target matching the quad's own exact device
33//!    size — see `frust_gpu::effects::ShaderEffects::encode_pass`;
34//! 3. register that sub-rect with the [`EngineRenderer`] under
35//!    [`SceneTextureId::for_shader_program`], the id the compiler's own
36//!    lowering derives from the same program — rebinding whenever either the
37//!    extent or the underlying target's own identity changed (see
38//!    [`Self::register`]) — and withdraw the registration of any program
39//!    every one of whose quads was culled this frame ([`culled_program_ids`]),
40//!    so a stale texture is never sampled for it.
41//!
42//! The draw itself is then nothing special: the compiler lowers the quad to an
43//! external-texture paint over its destination rectangle, exactly as it lowers
44//! [`Command::SceneTexture`](frust_scene::Command::SceneTexture) — same
45//! natural-pixels-onto-destination mapping, same blended pass — reading only
46//! the registered sub-rect of what may be a larger, quantized texture (see
47//! `crate::compile::external`/`crate::gpu::bindings`'s offset source-region
48//! support).
49//!
50//! # One target per program per frame
51//!
52//! A program drawn twice in one frame renders once, into one target sized to
53//! the largest device-space extent any of its quads asks for, and both quads
54//! sample it. That is what lets the id a target is registered under be a pure
55//! function of the program id: the compiler's walk holds only
56//! `ShaderProgram::id`, so any keying that also involved a size would need the
57//! two halves to agree on a floating-point extent computed twice. The cost is
58//! that the smaller of two quads samples a larger target — a resample, not a
59//! wrong pixel. The `time` the program renders at is the one its first quad in
60//! painter order carries.
61//!
62//! # Target stability under resize
63//!
64//! A quad's `dest` changing by a fraction of a device pixel every frame — a
65//! window resize drag, an animated scale, a spring layout — used to mint a
66//! fresh GPU texture, view, uniform buffer and bind group on
67//! [`frust_gpu::effects::ShaderEffects`] every single such frame, immediately
68//! evicting the previous one. Two changes fix that, both living in
69//! [`frust_gpu::effects`] and reused here rather than reinvented:
70//!
71//! - **Quantization**: a target's true size is rounded up to the next 256px
72//!   quantum before it becomes a key
73//!   (`frust_gpu::effects::quantized_target_key`, reusing
74//!   `frust_gpu::pool`'s own quantum), so a resize drag mints a new texture
75//!   only when it crosses a 256px boundary. The pass still renders — and the
76//!   shader still sees `frust_u.resolution` as — the quad's own *exact*
77//!   requested size, confined to that sub-rect of the (possibly larger)
78//!   texture via a `wgpu` viewport; the target's true extent is never handed
79//!   to the shader or to the frame's own paint mapping.
80//! - **Age-based target reap**: a size a still-drawn program has resized away
81//!   from is no longer reclaimed the instant it is not asked for (the old
82//!   frame-scoped eviction, which actively fought quantization — two nearby
83//!   requests sharing one quantized texture would otherwise evict each other
84//!   every frame); it ages out over its own window, independent of the
85//!   program id's own (wider) unseen-frame window.
86//!
87//! Registration itself ([`ShaderQuadPass::register`]) re-binds the renderer's
88//! own external-texture slot — the strip pipeline's group-1 bind group — when
89//! either a program's *demanded* extent changes from the previous frame
90//! (every frame for a quad genuinely resizing, none for one holding steady:
91//! what quantization removes is the GPU-resource churn one layer down, which
92//! used to happen on every such frame regardless) **or** the underlying
93//! target itself was silently recreated behind an unchanged extent — its
94//! *generation* changed (`frust_gpu::effects::ShaderEffects::target_generation`)
95//! even though `(w, h)` did not, the signature of a per-target reap
96//! ([`MAX_UNSEEN_TARGET_FRAMES`](frust_gpu::effects::MAX_UNSEEN_TARGET_FRAMES))
97//! or a per-id cap eviction (`frust_gpu::effects::MAX_TARGETS_PER_ID`)
98//! reclaiming a target the program then asked for again at the identical
99//! size. Comparing extent alone would miss that second case: the renderer's
100//! bind group would keep sampling the *old* (reaped, but still
101//! reference-counted alive through the clone `register` handed it) texture
102//! forever — frozen content at whatever the last frame before the target
103//! aged out rendered — rather than the fresh one just created for it.
104//!
105//! # Alpha
106//!
107//! A target is `Rgba8Unorm` and the pass writes the fragment shader's return
108//! value into it unblended, so the target holds exactly what the shader
109//! returned. It is then sampled as **premultiplied** colour, the convention
110//! every paint in an engine frame travels in: a shader returning `vec4(rgb, a)`
111//! must have already multiplied `rgb` by `a`, and the result composites over
112//! what is behind it rather than replacing it. Opaque output (`a = 1.0`) is
113//! unaffected by the convention, which is why shaders written against the
114//! earlier opaque-only rule keep rendering identically.
115//!
116//! # Kill switch
117//!
118//! `FRUST_ENGINE_NO_SHADER_EFFECTS=1` ([`crate::config::shader_effects_disabled`])
119//! turns the whole path off: nothing is compiled, no target is allocated, every
120//! registration is dropped, and each quad draws nothing while the compiler
121//! reports the skip once. The escape hatch for a driver that miscompiles a user
122//! program, where the alternative is losing the application rather than one
123//! effect.
124//!
125//! # No panics
126//!
127//! Every path here upholds the FFI no-panic invariant: a shader that fails to
128//! compile is recorded and skipped by [`ShaderEffects`] with a rate-limited
129//! warning, and a quad whose target could not be built simply draws nothing.
130
131use std::collections::{HashMap, HashSet};
132
133use frust_gpu::effects::quantized_target_key;
134use frust_gpu::{SceneTextureId, ShaderEffects};
135use frust_scene::{Command, Scene, ShaderProgram};
136use kurbo::{Affine, Rect};
137
138use crate::config;
139use crate::gpu::atlas::x_y_advances;
140use crate::renderer::EngineRenderer;
141
142/// The conservative upper bound on a shader-effect target's own dimensions,
143/// applied on top of the adapter's `max_texture_dimension_2d` by
144/// [`clamp_size`].
145///
146/// A policy cap rather than a device limit: a quad this large is a footgun on
147/// any adapter — tens of megabytes of offscreen target re-rendered every frame
148/// — long before the device refuses it. The device's own ceiling is enforced
149/// independently one layer down (`ShaderEffects::ensure_target`), so an
150/// oversized request is bounded twice rather than trusted once.
151pub const MAX_TEXTURE_DIM: u32 = 8192;
152
153/// One program's whole demand on a frame: which program, how large a target it
154/// wants, and the time to render it at.
155///
156/// Collapsed across every quad the frame draws that program with — see the
157/// module header's one-target-per-program rule.
158#[derive(Debug, Clone, Copy)]
159pub struct QuadDemand<'a> {
160    /// The program to render, borrowed from the display list.
161    pub program: &'a ShaderProgram,
162    /// The clamped target extent, in texels.
163    pub size: (u32, u32),
164    /// The `time` uniform, from the program's first quad in painter order.
165    pub time: f32,
166}
167
168/// Clamp a requested target size to the renderable range: at most
169/// [`MAX_TEXTURE_DIM`] and the adapter's own `max_texture_dimension_2d`, and at
170/// least 1 per axis (a zero-sized texture is invalid). Pure — no GPU state
171/// touched, so the policy is testable without a device.
172fn clamp_size(requested: (u32, u32), adapter_max: u32) -> (u32, u32) {
173    let cap = MAX_TEXTURE_DIM.min(adapter_max);
174    let clamp = |v: u32| v.clamp(1, cap.max(1));
175    (clamp(requested.0), clamp(requested.1))
176}
177
178/// The target extent a quad over `dest` under `transform` asks for: `dest`'s
179/// own width/height scaled by the transform's linear magnitudes — the
180/// lengths of its transformed unit axes ([`x_y_advances`]) — rounded up so a
181/// fractional edge is covered rather than cropped, and clamped by
182/// [`clamp_size`].
183///
184/// Sized from `dest`'s own dimensions rather than the device-space
185/// axis-aligned bounding box a plain `transform_rect_bbox` would give: a
186/// rotated or skewed quad's bbox is both larger than and a different aspect
187/// from the quad itself, so a target sized from it would squash the rendered
188/// content when the frame later resamples it back onto `dest` (a 100x50
189/// rectangle rotated 45 degrees has a ~106x106 bbox, mapped non-uniformly —
190/// (0.943, 0.472) per axis — onto the 100x50 destination it is actually drawn
191/// into). The transform's own linear magnitudes are exactly the per-axis
192/// scale `dest`'s W/H is stretched by on the way to device space, aspect-true
193/// regardless of rotation or skew — a pure rotation leaves both magnitudes at
194/// 1.0, so the target keeps `dest`'s own aspect exactly.
195///
196/// Rounded up rather than to nearest because the target is *resampled* onto
197/// `dest`: a target a fraction of a pixel too small is a visibly softer edge,
198/// while one a fraction too large costs a row of texels nothing reads.
199///
200/// `None` for geometry no target can be sized from — a non-finite transform or
201/// rectangle — which the frame's own geometry check refuses anyway; answering
202/// `None` here keeps this function honest on its own rather than relying on
203/// that ordering.
204///
205/// `dest`'s width/height are taken as absolute values: `kurbo::Rect::width`/
206/// `height` are plain `x1 - x0`/`y1 - y0` with no normalization, so an
207/// inverted or mirrored `dest` (`x0 > x1` and/or `y0 > y1` — a flip a widget
208/// records directly rather than through a transform) would otherwise flip the
209/// sign of the scaled extent and collapse to the 1px floor below instead of
210/// sizing the target from its true (positive) extent.
211fn requested_size(dest: Rect, transform: Affine, adapter_max: u32) -> Option<(u32, u32)> {
212    if !dest.is_finite() || !transform.as_coeffs().iter().all(|c| c.is_finite()) {
213        return None;
214    }
215    let (x_advance, y_advance) = x_y_advances(transform);
216    let width = dest.width().abs() * x_advance.hypot();
217    let height = dest.height().abs() * y_advance.hypot();
218    if !width.is_finite() || !height.is_finite() {
219        return None;
220    }
221    // Saturating on both ends: a huge-but-finite rectangle becomes the cap
222    // rather than wrapping, and a negative or sub-texel one becomes 1.
223    let axis = |extent: f64| -> u32 {
224        let ceiled = extent.ceil();
225        if ceiled <= 1.0 {
226            1
227        } else if ceiled >= f64::from(u32::MAX) {
228            u32::MAX
229        } else {
230            ceiled as u32
231        }
232    };
233    Some(clamp_size((axis(width), axis(height)), adapter_max))
234}
235
236/// Whether a quad's device-space `bbox` is culled against `target_rect` — the
237/// one decision [`frame_demands`] and [`culled_program_ids`] must agree on,
238/// factored out so the two walks can never drift apart.
239///
240/// Never culled while `inside_snapshot` (an open [`Command::PushSnapshot`]
241/// bracket — see [`frame_demands`]'s own doc for why), nor when `target_rect`
242/// is `None` (no culling requested at all), nor for a non-finite `bbox`
243/// (`requested_size`, or the frame's own up-front geometry check, accounts
244/// for that on its own terms — this function never claims a quad culled just
245/// because its extent could not be sized). Otherwise culled exactly when
246/// `bbox` does not overlap `target_rect`, inclusive of a shared edge (see
247/// [`kurbo::Rect::overlaps`]).
248fn quad_is_culled(bbox: Rect, target_rect: Option<Rect>, inside_snapshot: bool) -> bool {
249    if inside_snapshot {
250        return false;
251    }
252    let Some(target_rect) = target_rect else {
253        return false;
254    };
255    bbox.is_finite() && !bbox.overlaps(target_rect)
256}
257
258/// Every fragment program `scene` draws, in painter order of first appearance,
259/// each with the largest extent its quads ask for and the time its first quad
260/// carries.
261///
262/// Pure over the display list and `root` — no GPU state, no device — so the
263/// frame's whole demand can be asserted without a queue. `root` is the frame
264/// transform the renderer applies ahead of each command's own, so a quad's
265/// device extent here is the one the frame will actually draw it at.
266///
267/// A snapshot bracket's presentation scale is deliberately not folded in: it is
268/// applied to a body's *rendered* pixels, so a quad inside one is rendered at
269/// its own device size and resampled by the bracket, exactly like every other
270/// command in that body.
271///
272/// `target_extent`, when given, culls a quad whose device-space rectangle
273/// does not overlap `(0, 0)..target_extent` at all — an off-screen or fully
274/// clipped quad then demands no target and is neither rendered nor
275/// re-rendered every frame purely to go unseen. The check is conservative
276/// (the quad's axis-aligned bounding box, not its exact rotated/skewed
277/// footprint, and — via [`kurbo::Rect::overlaps`] — inclusive of a shared
278/// edge), so it never culls a quad that is even partly visible. Clip-stack
279/// awareness (culling a quad hidden entirely behind an unrelated clip) is
280/// deliberately out of scope: the frame's own clip stack is a compile-time
281/// concept this walk runs ahead of, so only whole-target intersection is
282/// checked. `None` applies no culling at all — see
283/// [`ShaderQuadPass::set_frame_target_extent`] for why a caller may have
284/// nothing to pass here today; that setter's own doc names the extent this
285/// parameter must be.
286///
287/// Culling is also skipped for a quad recorded inside an open
288/// [`Command::PushSnapshot`] bracket, however far outside `target_extent` its
289/// own device rectangle lands: the bracket's presentation `scale`/`alpha` is
290/// applied to the body's *rendered* pixels by whatever draws the snapshot
291/// (the compiler, or a renderer that rasterizes it), a step this walk runs
292/// ahead of and knows nothing about — a quad this walk sees as entirely
293/// off-target may still be moved onto it by that later transform, so culling
294/// it here would be a real, visible miss rather than a conservative
295/// approximation. [`culled_program_ids`] mirrors this same exemption.
296fn frame_demands(
297    scene: &Scene,
298    root: Affine,
299    adapter_max: u32,
300    target_extent: Option<(u32, u32)>,
301) -> Vec<QuadDemand<'_>> {
302    let mut order: Vec<u64> = Vec::new();
303    let mut demands: HashMap<u64, QuadDemand<'_>> = HashMap::new();
304    let target_rect = target_extent.map(|(w, h)| Rect::new(0.0, 0.0, f64::from(w), f64::from(h)));
305    let mut snapshot_depth: u32 = 0;
306
307    for command in scene.commands() {
308        match command {
309            Command::PushSnapshot { .. } => {
310                snapshot_depth += 1;
311                continue;
312            }
313            Command::PopSnapshot => {
314                snapshot_depth = snapshot_depth.saturating_sub(1);
315                continue;
316            }
317            _ => {}
318        }
319        let Command::ShaderQuad {
320            program,
321            dest,
322            transform,
323            time,
324        } = command
325        else {
326            continue;
327        };
328        let device_transform = root * *transform;
329        let device_bbox = device_transform.transform_rect_bbox(*dest);
330        if quad_is_culled(device_bbox, target_rect, snapshot_depth > 0) {
331            // Entirely outside the frame's own target: no target needed. A
332            // non-finite bbox falls through to `requested_size` below, which
333            // drops it on its own terms instead — `quad_is_culled` never
334            // reports one as culled.
335            continue;
336        }
337        let Some(size) = requested_size(*dest, device_transform, adapter_max) else {
338            continue;
339        };
340        match demands.get_mut(&program.id()) {
341            // A program already demanded this frame keeps its first quad's
342            // time and grows to cover the largest quad drawing it.
343            Some(demand) => {
344                demand.size = (demand.size.0.max(size.0), demand.size.1.max(size.1));
345            }
346            None => {
347                order.push(program.id());
348                demands.insert(
349                    program.id(),
350                    QuadDemand {
351                        program,
352                        size,
353                        time: *time,
354                    },
355                );
356            }
357        }
358    }
359
360    order
361        .into_iter()
362        .filter_map(|id| demands.remove(&id))
363        .collect()
364}
365
366/// Program ids `scene` draws at least one [`Command::ShaderQuad`] for, every
367/// one of whose quads is culled this frame by the same target-extent check
368/// [`frame_demands`] applies (see [`quad_is_culled`], including its
369/// [`Command::PushSnapshot`] exemption) — an id that therefore appears in
370/// neither `frame_demands`' result nor a live target key this frame.
371///
372/// [`ShaderQuadPass::prepare`] withdraws each of these ids' registration
373/// immediately rather than leaving it to age out: the *compiler* still walks
374/// every [`Command::ShaderQuad`] regardless of this pre-pass's own culling
375/// (it has no way to know a program was culled rather than never rendered),
376/// so a registration left standing from an earlier frame — when the same
377/// program's quad landed on-target — would have the compiler sample that
378/// stale, positionally-wrong texture for this frame's (off-target) quad
379/// instead of drawing nothing.
380///
381/// Pure over the display list, `root` and `target_extent` — the same walk
382/// `frame_demands` makes, without the GPU-facing sizing work — so it is
383/// unit-testable without a device. `None` for `target_extent` reports no id
384/// at all, matching `frame_demands`' own "no culling requested" contract for
385/// that case: nothing is culled, so nothing needs withdrawing on culling's
386/// account.
387fn culled_program_ids(
388    scene: &Scene,
389    root: Affine,
390    target_extent: Option<(u32, u32)>,
391) -> HashSet<u64> {
392    let Some((w, h)) = target_extent else {
393        return HashSet::new();
394    };
395    let target_rect = Some(Rect::new(0.0, 0.0, f64::from(w), f64::from(h)));
396    let mut snapshot_depth: u32 = 0;
397    let mut seen: HashSet<u64> = HashSet::new();
398    let mut not_culled: HashSet<u64> = HashSet::new();
399
400    for command in scene.commands() {
401        match command {
402            Command::PushSnapshot { .. } => {
403                snapshot_depth += 1;
404                continue;
405            }
406            Command::PopSnapshot => {
407                snapshot_depth = snapshot_depth.saturating_sub(1);
408                continue;
409            }
410            _ => {}
411        }
412        let Command::ShaderQuad {
413            program,
414            dest,
415            transform,
416            ..
417        } = command
418        else {
419            continue;
420        };
421        let device_bbox = (root * *transform).transform_rect_bbox(*dest);
422        seen.insert(program.id());
423        if !quad_is_culled(device_bbox, target_rect, snapshot_depth > 0) {
424            not_culled.insert(program.id());
425        }
426    }
427
428    seen.difference(&not_culled).copied().collect()
429}
430
431/// Whether [`ShaderQuadPass::register`] must replace `existing` (a program's
432/// current `registered` entry, if any) with `current` — its freshly resolved
433/// `(w, h, generation)` — because there is no existing registration, or
434/// because either the extent or the target's own generation differs.
435///
436/// Pure — no device, no `ShaderEffects` — so the rebind-on-recreation
437/// invariant `register`'s whole fix rests on has a device-free proof: a
438/// target recreated at the identical extent (a per-target reap, or a
439/// per-id-cap eviction, minting a fresh key behind an unchanged requested
440/// size) still carries a strictly greater generation than the one it
441/// replaced, so this reports `true` even though the extent alone is
442/// unchanged — see the module header's "Registration itself" paragraph.
443fn needs_rebind(existing: Option<(u32, u32, u64)>, current: (u32, u32, u64)) -> bool {
444    existing != Some(current)
445}
446
447/// Renders a frame's [`Command::ShaderQuad`]s into pooled offscreen targets and
448/// registers each with the renderer that will draw it.
449///
450/// Owned by whoever owns the frame's [`EngineRenderer`] and the encoder it
451/// records into — the two have to be the same frame's — and driven once per
452/// frame through [`Self::prepare`], including frames drawing no quad at all:
453/// that is the call on which a program the scene has stopped drawing ages
454/// towards its reap.
455pub struct ShaderQuadPass {
456    /// Every GPU resource the effect owns: pipelines, targets, the reap clock.
457    effects: ShaderEffects,
458    /// The extent and target generation each program's target is currently
459    /// registered with the renderer at, keyed by program id: `(w, h,
460    /// generation)` — see [`Self::register`].
461    ///
462    /// What makes registration incremental: a program whose *demanded* extent
463    /// and whose underlying target's own identity are both unchanged frame to
464    /// frame is left bound, so the frame's bind groups naming it survive
465    /// instead of being dropped and rebuilt every frame — the steady-state
466    /// (by far most common) case for a quad that is not actively resizing. An
467    /// entry here is always matched by a live registration on the renderer,
468    /// which is what lets a reap unbind exactly what it reaped.
469    registered: HashMap<u64, (u32, u32, u64)>,
470    /// The frame's own render-target extent, in device pixels — see
471    /// [`Self::set_frame_target_extent`].
472    frame_target_extent: Option<(u32, u32)>,
473}
474
475impl ShaderQuadPass {
476    /// An empty pass seeded with an optional clone of the surface's
477    /// [`wgpu::PipelineCache`], so a persisted cache speeds a user program's
478    /// first compilation exactly as it speeds the engine's own pipelines.
479    #[must_use]
480    pub fn new(pipeline_cache: Option<wgpu::PipelineCache>) -> Self {
481        Self {
482            effects: ShaderEffects::new(pipeline_cache),
483            registered: HashMap::new(),
484            frame_target_extent: None,
485        }
486    }
487
488    /// How many programs currently have a target registered with a renderer.
489    #[must_use]
490    pub fn registered_len(&self) -> usize {
491        self.registered.len()
492    }
493
494    /// Sets the frame's own render-target extent, in device pixels, so the
495    /// next [`Self::prepare`] call can cull a quad whose device-space
496    /// rectangle does not intersect it at all (see [`frame_demands`]'s doc
497    /// comment for the exact rule). `None` (the default a fresh
498    /// [`Self::new`] starts with) applies no culling.
499    ///
500    /// The extent passed here MUST be the frame's own real render target —
501    /// the same size the surface (or headless target) `EngineRenderer::encode`
502    /// is about to be called with — never an approximation or a stale value
503    /// from an earlier resize: a wrong extent culls a quad that would in fact
504    /// have landed on the real target, which is a visible miss, not merely a
505    /// missed optimization.
506    ///
507    /// Opt-in rather than inferred: `prepare`'s own signature is fixed by its
508    /// external caller (`frust-render`'s `SurfaceRenderer`, which records the
509    /// pre-pass ahead of `EngineRenderer::encode` — see the module header),
510    /// and nothing reachable from `prepare`'s existing parameters names the
511    /// frame's target extent today (`EngineRenderer` learns it only when
512    /// `encode` itself is called, afterward). Adding a required parameter to
513    /// `prepare` to carry it through would break that caller — out of scope
514    /// here — so a caller that knows its target extent ahead of time calls
515    /// this setter first instead. Wiring `frust-render`'s own call site to do
516    /// so is a follow-up outside this module.
517    pub fn set_frame_target_extent(&mut self, extent: Option<(u32, u32)>) {
518        self.frame_target_extent = extent;
519    }
520
521    /// Renders every fragment program `scene` draws into its own pooled target,
522    /// recording each pass into `encoder`, and registers the results with
523    /// `engine` so the frame compiled after this call draws them.
524    ///
525    /// Call once per frame, before `engine`'s own encode and with that frame's
526    /// encoder, `scene` and `root`. Nothing is submitted: the passes recorded
527    /// here precede the frame's in the same command buffer, which is the whole
528    /// ordering guarantee the effect needs.
529    ///
530    /// A program that fails to compile, or whose target could not be built, is
531    /// unregistered rather than left pointing at a stale texture — its quads
532    /// draw nothing, and the compiler says so once. When the kill switch is set
533    /// the same unregistration happens for every program and no GPU work is
534    /// done at all.
535    pub fn prepare(
536        &mut self,
537        device: &wgpu::Device,
538        queue: &wgpu::Queue,
539        encoder: &mut wgpu::CommandEncoder,
540        scene: &Scene,
541        root: Affine,
542        engine: &mut EngineRenderer,
543    ) {
544        if config::shader_effects_disabled() {
545            self.unregister_all(engine);
546            return;
547        }
548
549        let adapter_max = device.limits().max_texture_dimension_2d;
550        let demands = frame_demands(scene, root, adapter_max, self.frame_target_extent);
551        let mut live_ids: HashSet<u64> = HashSet::with_capacity(demands.len());
552        let mut live_keys: HashSet<(u64, u32, u32)> = HashSet::with_capacity(demands.len());
553
554        for demand in demands {
555            let id = demand.program.id();
556            let (w, h) = demand.size;
557            let quantized = quantized_target_key(w, h, adapter_max);
558            live_ids.insert(id);
559            live_keys.insert((id, quantized.0, quantized.1));
560
561            self.effects
562                .ensure_pipeline(device, id, demand.program.source());
563            self.effects.ensure_target(device, id, w, h);
564            self.effects
565                .encode_pass(encoder, queue, device, id, (w, h), demand.time);
566            self.register(engine, device, id, (w, h));
567        }
568
569        // A program every one of whose quads was culled this frame (see
570        // `culled_program_ids`) stops drawing immediately rather than waiting
571        // for the age-based reap below: the compiler still walks every
572        // `Command::ShaderQuad` regardless of this pre-pass's own culling, so
573        // a registration left standing from an earlier, on-target frame would
574        // have it sample a stale, positionally-wrong texture for this frame's
575        // off-target quad instead of drawing nothing.
576        for id in culled_program_ids(scene, root, self.frame_target_extent) {
577            self.unregister(engine, id);
578        }
579
580        // The one clock tick per frame — including a frame drawing no quad at
581        // all — that ages both a program id absent from `live_ids` towards
582        // its whole-program reap and a target key absent from `live_keys`
583        // towards its own, shorter, target-level reap (see
584        // `frust_gpu::effects::ShaderEffects::mark_seen`). Only the
585        // whole-program reap has registrations to withdraw: a target-level
586        // reap frees GPU memory for a size this id has resized away from
587        // without touching what is currently registered.
588        let stale = self.effects.mark_seen(&live_ids, &live_keys);
589        self.effects.reap(&stale);
590        for id in stale {
591            self.unregister(engine, id);
592        }
593    }
594
595    /// Registers program `id`'s `(w, h)`-requested target with `engine`, or
596    /// withdraws any earlier registration when there is no such target to
597    /// register.
598    ///
599    /// A no-op when the program is already registered at the extent and
600    /// target generation its demand resolved to this call, which is the
601    /// common (steady-state) case on every frame a quad is not actively
602    /// resizing: re-registering would drop the frame's bind groups naming the
603    /// view and rebuild them for an identical binding. The registered extent
604    /// is the *requested* sub-rect (see
605    /// `frust_gpu::effects::ShaderEffects::target_extent`), never the
606    /// quantized target's own larger size, so the compiler still maps `dest`
607    /// onto exactly the quad's own device pixels — a quad resizing pixel by
608    /// pixel still re-registers every such frame (its demand genuinely
609    /// changes), but the expensive part — a fresh GPU texture, view, uniform
610    /// buffer and bind group — no longer does, since quantization keeps the
611    /// underlying target the same across an entire 256px band.
612    ///
613    /// The **generation**
614    /// (`frust_gpu::effects::ShaderEffects::target_generation`) comparison is
615    /// what keeps that incremental fast path honest: an unchanged extent does
616    /// not by itself mean the same GPU texture is still behind it — a
617    /// per-target reap or a per-id cap eviction can drop and recreate a
618    /// target at the identical requested extent between two frames — so
619    /// [`needs_rebind`] rebinds whenever either the extent or the generation
620    /// changed, never extent alone.
621    fn register(
622        &mut self,
623        engine: &mut EngineRenderer,
624        device: &wgpu::Device,
625        id: u64,
626        size: (u32, u32),
627    ) {
628        let (w, h) = size;
629        let extent = self.effects.target_extent(device, id, w, h);
630        let view = self.effects.target_view(device, id, w, h).cloned();
631        let generation = self.effects.target_generation(device, id, w, h);
632        let (Some(extent), Some(view), Some(generation)) = (extent, view, generation) else {
633            // No target: the program failed to compile, or its size was
634            // refused. Either way it must stop drawing whatever it drew last.
635            self.unregister(engine, id);
636            return;
637        };
638
639        let current = (extent.0, extent.1, generation);
640        if !needs_rebind(self.registered.get(&id).copied(), current) {
641            return;
642        }
643        engine.bind_texture(SceneTextureId::for_shader_program(id), extent, view);
644        self.registered.insert(id, current);
645    }
646
647    /// Withdraws program `id`'s registration, if it has one.
648    fn unregister(&mut self, engine: &mut EngineRenderer, id: u64) {
649        if self.registered.remove(&id).is_some() {
650            engine.unbind_texture(SceneTextureId::for_shader_program(id));
651        }
652    }
653
654    /// Withdraws every registration this pass made — the kill switch's path,
655    /// and the one that makes turning the switch on mid-process take effect on
656    /// the next frame rather than leaving stale targets bound.
657    fn unregister_all(&mut self, engine: &mut EngineRenderer) {
658        for id in self.registered.drain().map(|(id, _)| id) {
659            engine.unbind_texture(SceneTextureId::for_shader_program(id));
660        }
661    }
662}
663
664impl std::fmt::Debug for ShaderQuadPass {
665    /// `ShaderEffects` owns `wgpu` handles that do not print usefully, so the
666    /// pass reports the only state a reader can act on: how many programs are
667    /// registered.
668    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
669        f.debug_struct("ShaderQuadPass")
670            .field("registered", &self.registered.len())
671            .finish_non_exhaustive()
672    }
673}
674
675#[cfg(test)]
676mod tests {
677    use super::*;
678    use frust_scene::SceneBuilder;
679
680    /// A trivial but well-formed fragment source: the demand walk never
681    /// compiles it, so only its identity matters.
682    const SOURCE: &str = "@fragment fn fs_main(in: FrustVsOut) -> @location(0) vec4<f32> \
683                          { return vec4<f32>(1.0, 0.0, 1.0, 1.0); }";
684
685    fn scene_of(record: impl FnOnce(&mut SceneBuilder<'_>)) -> Scene {
686        let mut scene = Scene::new();
687        let mut builder = SceneBuilder::new(&mut scene);
688        record(&mut builder);
689        scene
690    }
691
692    #[test]
693    fn clamp_size_caps_at_the_policy_bound() {
694        assert_eq!(clamp_size((10_000, 10_000), u32::MAX), (8192, 8192));
695    }
696
697    #[test]
698    fn clamp_size_respects_an_adapter_max_below_the_policy_bound() {
699        assert_eq!(clamp_size((6000, 6000), 4096), (4096, 4096));
700    }
701
702    #[test]
703    fn clamp_size_floors_zero_to_one() {
704        assert_eq!(clamp_size((0, 0), 8192), (1, 1));
705        assert_eq!(clamp_size((0, 512), 8192), (1, 512));
706    }
707
708    #[test]
709    fn clamp_size_passes_an_in_range_request_through() {
710        assert_eq!(clamp_size((1290, 2796), 16384), (1290, 2796));
711    }
712
713    #[test]
714    fn clamp_size_survives_a_degenerate_adapter_max() {
715        // A zero ceiling must still never produce a zero dimension.
716        assert_eq!(clamp_size((100, 100), 0), (1, 1));
717    }
718
719    #[test]
720    fn a_requested_size_is_the_destination_in_device_space() {
721        assert_eq!(
722            requested_size(Rect::new(0.0, 0.0, 40.0, 20.0), Affine::IDENTITY, 8192),
723            Some((40, 20))
724        );
725        // The frame's own scale is part of the device extent.
726        assert_eq!(
727            requested_size(Rect::new(0.0, 0.0, 40.0, 20.0), Affine::scale(2.0), 8192),
728            Some((80, 40))
729        );
730    }
731
732    #[test]
733    fn a_requested_size_rounds_a_fractional_extent_up() {
734        assert_eq!(
735            requested_size(Rect::new(0.0, 0.0, 40.5, 20.25), Affine::IDENTITY, 8192),
736            Some((41, 21))
737        );
738    }
739
740    #[test]
741    fn a_requested_size_is_never_zero() {
742        assert_eq!(
743            requested_size(Rect::new(4.0, 4.0, 4.0, 4.0), Affine::IDENTITY, 8192),
744            Some((1, 1))
745        );
746    }
747
748    #[test]
749    fn a_requested_size_is_clamped_rather_than_wrapped() {
750        assert_eq!(
751            requested_size(Rect::new(0.0, 0.0, 1e12, 1e12), Affine::IDENTITY, 8192),
752            Some((8192, 8192))
753        );
754    }
755
756    #[test]
757    fn an_inverted_dest_yields_the_same_target_extent_as_its_normalized_twin() {
758        let normalized = Rect::new(0.0, 0.0, 40.0, 20.0);
759        // Both axes flipped (x0 > x1, y0 > y1): kurbo's own `width`/`height`
760        // are unnormalized `x1 - x0`/`y1 - y0`, so this is negative on both
761        // axes before `requested_size` takes the absolute value.
762        let inverted = Rect::new(40.0, 20.0, 0.0, 0.0);
763        assert_eq!(
764            inverted.width(),
765            -40.0,
766            "sanity check: kurbo does not normalize"
767        );
768
769        assert_eq!(
770            requested_size(inverted, Affine::IDENTITY, 8192),
771            requested_size(normalized, Affine::IDENTITY, 8192)
772        );
773        assert_eq!(
774            requested_size(inverted, Affine::IDENTITY, 8192),
775            Some((40, 20))
776        );
777    }
778
779    #[test]
780    fn a_single_axis_mirrored_dest_yields_the_same_target_extent_as_unmirrored() {
781        // Only the x axis flipped — the common case for a widget mirroring
782        // its content under RTL layout without going through a transform.
783        let unmirrored = Rect::new(0.0, 0.0, 40.0, 20.0);
784        let mirrored_x = Rect::new(40.0, 0.0, 0.0, 20.0);
785
786        assert_eq!(
787            requested_size(mirrored_x, Affine::IDENTITY, 8192),
788            requested_size(unmirrored, Affine::IDENTITY, 8192)
789        );
790    }
791
792    #[test]
793    fn non_finite_geometry_asks_for_no_target() {
794        assert_eq!(
795            requested_size(Rect::new(0.0, 0.0, f64::NAN, 8.0), Affine::IDENTITY, 8192),
796            None
797        );
798        assert_eq!(
799            requested_size(
800                Rect::new(0.0, 0.0, 8.0, 8.0),
801                Affine::translate((f64::INFINITY, 0.0)),
802                8192
803            ),
804            None
805        );
806    }
807
808    #[test]
809    fn a_rotated_quad_sizes_from_dest_dimensions_not_the_axis_aligned_bbox() {
810        // A pure rotation has unit-magnitude axes, so the aspect-true target
811        // keeps dest's own 100x50 exactly — the axis-aligned bbox of a 100x50
812        // rectangle rotated 45 degrees is instead ~106x106, which would
813        // squash the rendered content non-uniformly when resampled back onto
814        // the (still 100x50) destination.
815        let dest = Rect::new(0.0, 0.0, 100.0, 50.0);
816        let transform = Affine::rotate(std::f64::consts::FRAC_PI_4);
817
818        assert_eq!(requested_size(dest, transform, 8192), Some((100, 50)));
819    }
820
821    #[test]
822    fn a_scaled_and_rotated_quad_sizes_aspect_true_too() {
823        // The transform's linear magnitude folds in uniform scale the same
824        // way plain `Affine::scale` already did before this fix; rotation on
825        // top of it changes nothing about the sizing, only the bbox.
826        let dest = Rect::new(0.0, 0.0, 100.0, 50.0);
827        let transform = Affine::rotate(std::f64::consts::FRAC_PI_4) * Affine::scale(2.0);
828
829        assert_eq!(requested_size(dest, transform, 8192), Some((200, 100)));
830    }
831
832    #[test]
833    fn a_scene_without_shader_quads_demands_nothing() {
834        let scene = scene_of(|builder| {
835            builder.fill_rect(
836                Rect::new(0.0, 0.0, 8.0, 8.0),
837                peniko::Brush::Solid(peniko::color::palette::css::RED),
838            );
839        });
840
841        assert!(frame_demands(&scene, Affine::IDENTITY, 8192, None).is_empty());
842    }
843
844    #[test]
845    fn each_program_is_demanded_once_in_painter_order() {
846        let first = ShaderProgram::new(SOURCE);
847        let second = ShaderProgram::new(SOURCE);
848        let scene = scene_of(|builder| {
849            builder.draw_shader(&first, Rect::new(0.0, 0.0, 8.0, 8.0), 0.0);
850            builder.draw_shader(&second, Rect::new(0.0, 0.0, 4.0, 4.0), 0.0);
851            builder.draw_shader(&first, Rect::new(0.0, 0.0, 8.0, 8.0), 0.0);
852        });
853
854        let demands = frame_demands(&scene, Affine::IDENTITY, 8192, None);
855
856        assert_eq!(demands.len(), 2);
857        assert_eq!(demands[0].program.id(), first.id());
858        assert_eq!(demands[1].program.id(), second.id());
859    }
860
861    #[test]
862    fn a_program_drawn_at_two_sizes_demands_the_larger_on_each_axis() {
863        let program = ShaderProgram::new(SOURCE);
864        let scene = scene_of(|builder| {
865            builder.draw_shader(&program, Rect::new(0.0, 0.0, 40.0, 10.0), 0.0);
866            builder.draw_shader(&program, Rect::new(0.0, 0.0, 10.0, 30.0), 0.0);
867        });
868
869        let demands = frame_demands(&scene, Affine::IDENTITY, 8192, None);
870
871        assert_eq!(demands.len(), 1, "one target serves both quads");
872        assert_eq!(demands[0].size, (40, 30));
873    }
874
875    #[test]
876    fn a_program_renders_at_its_first_quads_time() {
877        let program = ShaderProgram::new(SOURCE);
878        let scene = scene_of(|builder| {
879            builder.draw_shader(&program, Rect::new(0.0, 0.0, 8.0, 8.0), 1.5);
880            builder.draw_shader(&program, Rect::new(0.0, 0.0, 8.0, 8.0), 9.0);
881        });
882
883        let demands = frame_demands(&scene, Affine::IDENTITY, 8192, None);
884
885        assert_eq!(demands[0].time, 1.5);
886    }
887
888    #[test]
889    fn a_quad_whose_geometry_no_target_can_be_sized_from_is_dropped() {
890        let program = ShaderProgram::new(SOURCE);
891        let scene = scene_of(|builder| {
892            builder.draw_shader(&program, Rect::new(0.0, 0.0, f64::NAN, 8.0), 0.0);
893        });
894
895        assert!(frame_demands(&scene, Affine::IDENTITY, 8192, None).is_empty());
896    }
897
898    #[test]
899    fn the_frame_transform_scales_the_demand() {
900        let program = ShaderProgram::new(SOURCE);
901        let scene = scene_of(|builder| {
902            builder.draw_shader(&program, Rect::new(0.0, 0.0, 100.0, 50.0), 0.0);
903        });
904
905        let demands = frame_demands(&scene, Affine::scale(3.0), 8192, None);
906
907        assert_eq!(demands[0].size, (300, 150));
908    }
909
910    #[test]
911    fn a_demand_is_clamped_by_the_adapter_ceiling() {
912        let program = ShaderProgram::new(SOURCE);
913        let scene = scene_of(|builder| {
914            builder.draw_shader(&program, Rect::new(0.0, 0.0, 6000.0, 6000.0), 0.0);
915        });
916
917        let demands = frame_demands(&scene, Affine::IDENTITY, 4096, None);
918
919        assert_eq!(demands[0].size, (4096, 4096));
920    }
921
922    #[test]
923    fn a_programs_target_id_matches_the_one_the_compiler_derives() {
924        // The two halves of the seam never exchange a table — each computes
925        // the id from the program alone, so they must agree by construction.
926        let program = ShaderProgram::new(SOURCE);
927
928        assert_eq!(
929            SceneTextureId::for_shader_program(program.id()).get(),
930            crate::compile::shader_quad_texture_id(program.id())
931        );
932    }
933
934    #[test]
935    fn with_no_target_extent_a_far_off_screen_quad_still_demands() {
936        // `None` (a fresh `ShaderQuadPass`'s default) applies no culling at
937        // all — see `ShaderQuadPass::set_frame_target_extent`'s doc comment
938        // for why.
939        let program = ShaderProgram::new(SOURCE);
940        let scene = scene_of(|builder| {
941            builder.draw_shader(
942                &program,
943                Rect::new(10_000.0, 10_000.0, 10_008.0, 10_008.0),
944                0.0,
945            );
946        });
947
948        assert_eq!(frame_demands(&scene, Affine::IDENTITY, 8192, None).len(), 1);
949    }
950
951    #[test]
952    fn a_quad_entirely_outside_the_target_extent_demands_nothing() {
953        let program = ShaderProgram::new(SOURCE);
954        let scene = scene_of(|builder| {
955            builder.draw_shader(
956                &program,
957                Rect::new(10_000.0, 10_000.0, 10_008.0, 10_008.0),
958                0.0,
959            );
960        });
961
962        let demands = frame_demands(&scene, Affine::IDENTITY, 8192, Some((64, 64)));
963
964        assert!(demands.is_empty());
965    }
966
967    #[test]
968    fn a_quad_straddling_the_target_edge_still_demands() {
969        let program = ShaderProgram::new(SOURCE);
970        let scene = scene_of(|builder| {
971            builder.draw_shader(&program, Rect::new(-8.0, -8.0, 8.0, 8.0), 0.0);
972        });
973
974        let demands = frame_demands(&scene, Affine::IDENTITY, 8192, Some((64, 64)));
975
976        assert_eq!(
977            demands.len(),
978            1,
979            "a quad straddling the target boundary is still partly visible"
980        );
981    }
982
983    #[test]
984    fn a_quad_touching_the_target_edge_still_demands() {
985        // `Rect::overlaps` treats a shared edge as overlapping — deliberately
986        // conservative, so a quad exactly abutting the target boundary is
987        // never wrongly culled.
988        let program = ShaderProgram::new(SOURCE);
989        let scene = scene_of(|builder| {
990            builder.draw_shader(&program, Rect::new(64.0, 0.0, 80.0, 16.0), 0.0);
991        });
992
993        let demands = frame_demands(&scene, Affine::IDENTITY, 8192, Some((64, 64)));
994
995        assert_eq!(demands.len(), 1);
996    }
997
998    #[test]
999    fn a_quad_fully_inside_the_target_extent_still_demands() {
1000        let program = ShaderProgram::new(SOURCE);
1001        let scene = scene_of(|builder| {
1002            builder.draw_shader(&program, Rect::new(4.0, 4.0, 12.0, 12.0), 0.0);
1003        });
1004
1005        let demands = frame_demands(&scene, Affine::IDENTITY, 8192, Some((64, 64)));
1006
1007        assert_eq!(demands.len(), 1);
1008    }
1009
1010    #[test]
1011    fn a_quad_with_non_finite_geometry_is_dropped_by_requested_size_not_by_culling() {
1012        // A non-finite device bbox falls through the target-extent check
1013        // (which only culls a *finite* bbox proven not to overlap) rather
1014        // than being treated as "outside", so `requested_size`'s own
1015        // non-finite handling is what actually drops it — proven here by
1016        // still getting an empty result, not a panic or a false demand.
1017        let program = ShaderProgram::new(SOURCE);
1018        let scene = scene_of(|builder| {
1019            builder.draw_shader(&program, Rect::new(0.0, 0.0, f64::NAN, 8.0), 0.0);
1020        });
1021
1022        assert!(frame_demands(&scene, Affine::IDENTITY, 8192, Some((64, 64))).is_empty());
1023    }
1024
1025    #[test]
1026    fn set_frame_target_extent_defaults_to_none() {
1027        let pass = ShaderQuadPass::new(None);
1028        assert_eq!(pass.frame_target_extent, None);
1029    }
1030
1031    #[test]
1032    fn needs_rebind_true_with_no_existing_registration() {
1033        assert!(needs_rebind(None, (64, 64, 1)));
1034    }
1035
1036    #[test]
1037    fn needs_rebind_false_when_extent_and_generation_are_unchanged() {
1038        assert!(!needs_rebind(Some((64, 64, 1)), (64, 64, 1)));
1039    }
1040
1041    #[test]
1042    fn needs_rebind_true_when_the_extent_changes() {
1043        assert!(needs_rebind(Some((64, 64, 1)), (96, 64, 1)));
1044    }
1045
1046    #[test]
1047    fn needs_rebind_true_when_the_generation_changes_at_the_same_extent() {
1048        // The whole point of tracking generation: a target silently recreated
1049        // (a per-target reap, a per-id-cap eviction) at the identical
1050        // requested extent must still force a rebind, or the renderer would
1051        // keep sampling the orphaned old texture forever.
1052        assert!(needs_rebind(Some((64, 64, 1)), (64, 64, 2)));
1053    }
1054
1055    #[test]
1056    fn quad_is_culled_true_for_a_finite_non_overlapping_bbox() {
1057        let target = Rect::new(0.0, 0.0, 64.0, 64.0);
1058        let bbox = Rect::new(1000.0, 1000.0, 1008.0, 1008.0);
1059        assert!(quad_is_culled(bbox, Some(target), false));
1060    }
1061
1062    #[test]
1063    fn quad_is_culled_false_when_overlapping_the_target() {
1064        let target = Rect::new(0.0, 0.0, 64.0, 64.0);
1065        let bbox = Rect::new(0.0, 0.0, 8.0, 8.0);
1066        assert!(!quad_is_culled(bbox, Some(target), false));
1067    }
1068
1069    #[test]
1070    fn quad_is_culled_false_for_a_non_finite_bbox() {
1071        let target = Rect::new(0.0, 0.0, 64.0, 64.0);
1072        let bbox = Rect::new(0.0, 0.0, f64::NAN, 8.0);
1073        assert!(!quad_is_culled(bbox, Some(target), false));
1074    }
1075
1076    #[test]
1077    fn quad_is_culled_false_with_no_target_rect() {
1078        let bbox = Rect::new(1000.0, 1000.0, 1008.0, 1008.0);
1079        assert!(!quad_is_culled(bbox, None, false));
1080    }
1081
1082    #[test]
1083    fn quad_is_culled_false_inside_a_snapshot_even_when_far_outside_the_target() {
1084        // A snapshot bracket's presentation scale can still move an
1085        // off-target body onto the target after this walk runs — see
1086        // `frame_demands`'s doc.
1087        let target = Rect::new(0.0, 0.0, 64.0, 64.0);
1088        let bbox = Rect::new(1000.0, 1000.0, 1008.0, 1008.0);
1089        assert!(!quad_is_culled(bbox, Some(target), true));
1090    }
1091
1092    #[test]
1093    fn culled_program_ids_reports_a_program_whose_only_quad_is_culled() {
1094        let program = ShaderProgram::new(SOURCE);
1095        let scene = scene_of(|builder| {
1096            builder.draw_shader(
1097                &program,
1098                Rect::new(10_000.0, 10_000.0, 10_008.0, 10_008.0),
1099                0.0,
1100            );
1101        });
1102
1103        let culled = culled_program_ids(&scene, Affine::IDENTITY, Some((64, 64)));
1104
1105        assert_eq!(culled, [program.id()].into_iter().collect());
1106    }
1107
1108    #[test]
1109    fn culled_program_ids_empty_when_one_of_the_programs_quads_lands_on_target() {
1110        // The program has two quads this frame; one lands on-target, so the
1111        // program as a whole is demanded (see `frame_demands`) and must not
1112        // be reported culled even though its *other* quad was.
1113        let program = ShaderProgram::new(SOURCE);
1114        let scene = scene_of(|builder| {
1115            builder.draw_shader(
1116                &program,
1117                Rect::new(10_000.0, 10_000.0, 10_008.0, 10_008.0),
1118                0.0,
1119            );
1120            builder.draw_shader(&program, Rect::new(0.0, 0.0, 8.0, 8.0), 0.0);
1121        });
1122
1123        assert!(culled_program_ids(&scene, Affine::IDENTITY, Some((64, 64))).is_empty());
1124    }
1125
1126    #[test]
1127    fn culled_program_ids_empty_with_no_target_extent() {
1128        let program = ShaderProgram::new(SOURCE);
1129        let scene = scene_of(|builder| {
1130            builder.draw_shader(
1131                &program,
1132                Rect::new(10_000.0, 10_000.0, 10_008.0, 10_008.0),
1133                0.0,
1134            );
1135        });
1136
1137        assert!(culled_program_ids(&scene, Affine::IDENTITY, None).is_empty());
1138    }
1139
1140    #[test]
1141    fn culled_program_ids_exempts_a_quad_inside_a_snapshot_bracket() {
1142        let program = ShaderProgram::new(SOURCE);
1143        let far_off_target = Rect::new(10_000.0, 10_000.0, 10_008.0, 10_008.0);
1144        let scene = scene_of(|builder| {
1145            builder.push_snapshot(1, far_off_target, 1.0, 1.0);
1146            builder.draw_shader(&program, far_off_target, 0.0);
1147            builder.pop_snapshot();
1148        });
1149
1150        assert!(
1151            culled_program_ids(&scene, Affine::IDENTITY, Some((64, 64))).is_empty(),
1152            "a quad inside an open PushSnapshot bracket must never be reported culled"
1153        );
1154        // And the same exemption applies on the demand side: the program is
1155        // still demanded, not dropped.
1156        assert_eq!(
1157            frame_demands(&scene, Affine::IDENTITY, 8192, Some((64, 64))).len(),
1158            1
1159        );
1160    }
1161}