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(¬_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}