Skip to main content

frust_gpu/
effects.rs

1//! Offscreen WGSL fragment-shader effects: the GPU half of the
2//! shader-showcase feature.
3//!
4//! A self-contained engine that renders user-supplied WGSL fragment shaders
5//! into offscreen `Rgba8Unorm` textures. It owns everything that job needs and
6//! nothing else:
7//!
8//! - **Lazy per-program pipeline compilation** ([`ShaderEffects::ensure_pipeline`]),
9//!   seeded from the surface's [`wgpu::PipelineCache`] so a persisted cache
10//!   speeds first-frame compilation exactly like the renderer's own pipelines.
11//! - **Per-`(program, quantized size)` target state**
12//!   ([`ShaderEffects::ensure_target`]): an offscreen texture + view, a
13//!   16-byte uniform buffer, and the bind group binding them, keyed by
14//!   `(id, w, h)` where `w`/`h` are [`quantized_target_key`]'s output rather
15//!   than the raw request. Quantizing before the key means a quad resizing
16//!   pixel-by-pixel (a window drag, an animated scale) reuses one texture
17//!   across an entire 256px band instead of minting a fresh one every frame —
18//!   see [`quantized_target_key`]'s own doc for why 256px, the same quantum
19//!   [`crate::pool::TexturePool`] already uses for exactly this reason.
20//! - **Fullscreen-triangle pass encoding** ([`ShaderEffects::encode_pass`]),
21//!   confined by `wgpu`'s viewport to the *requested* sub-rect of the
22//!   (possibly larger, quantized) target: the shader always sees
23//!   `frust_u.resolution` as the caller's exact requested extent, never the
24//!   quantized texture's true size, so a quad samples back a target sized to
25//!   itself even while sharing a texture with nearby sizes.
26//! - **Age-based whole-id reap** ([`ShaderEffects::mark_seen`]/[`ShaderEffects::reap`]):
27//!   a program id absent from every frame's live id set for
28//!   [`MAX_UNSEEN_FRAMES`] consecutive frames has its pipeline, target(s), and
29//!   any recorded compile failure dropped, rather than living until surface
30//!   teardown.
31//! - **Age-based per-target reap**, the same [`ShaderEffects::mark_seen`]
32//!   call's other half: a `(id, quantized w, quantized h)` key absent from
33//!   every frame's live *target* key set for [`MAX_UNSEEN_TARGET_FRAMES`]
34//!   consecutive frames — shorter than the whole-id window, since a size a
35//!   still-drawn program has resized away from is a narrower, more frequent
36//!   event than the program vanishing outright — is dropped on its own,
37//!   independent of its id's own age. Replaces an earlier same-frame-scoped
38//!   eviction policy that reclaimed any other-size same-id target the moment
39//!   a frame's live key set no longer named it; that policy fought
40//!   quantization directly, since two nearby (but not identical) requests
41//!   that shared one quantized texture would otherwise evict each other
42//!   every single frame.
43//! - **Churn detection** ([`should_warn_churn`], consulted by
44//!   [`ShaderEffects::ensure_pipeline`]): a rate-limited `log::warn!` once the
45//!   live compiled-program count crosses [`CHURN_WARN_THRESHOLD`] — a
46//!   detection aid pointing at the `ShaderProgram::new` cache-once contract,
47//!   not itself a bound on growth; the age-based reap above is what actually
48//!   bounds it.
49//! - **Per-id target count bound** ([`MAX_TARGETS_PER_ID`]): a program id may
50//!   hold at most this many quantized target keys at once, evicting its own
51//!   least-recently-seen key immediately — before the age-based reap above
52//!   ever gets a turn — the moment a new key would push it past the cap. This
53//!   bounds a single frame's worth of accumulation (a multi-band resize drag
54//!   minting one key per band per frame) independent of the age window: the
55//!   accepted peak per program is `MAX_TARGETS_PER_ID` × (the largest
56//!   quantized target area it currently holds) × 4 bytes/texel, e.g. two
57//!   1024×1024 `Rgba8Unorm` targets is 8 MiB, not the unbounded run of bands a
58//!   fast drag could otherwise mint before any of them aged out.
59//!
60//! It is deliberately decoupled from any scene vocabulary: the API speaks
61//! `(id: u64, wgsl: &str, size, time)` primitives, never a display list or a
62//! command, so the size-clamp and quad-placement policy that decides *which*
63//! `(id, size)` pairs a frame asks for lives in the layer above
64//! (`frust_engine::effects::shader_quad`), and only the GPU resources live
65//! here.
66//!
67//! # Where the rendered target goes
68//!
69//! [`ShaderEffects::target_view`] is the seam a renderer draws the result
70//! through: the target carries `TEXTURE_BINDING`, so the view it hands back is
71//! registered as a scene texture and sampled by the frame's own passes. The
72//! texture itself is reachable through [`ShaderEffects::target_texture`] for a
73//! read-back — the whole (possibly larger, quantized) attachment, never only
74//! the sub-rect actually rendered into it. [`ShaderEffects::target_extent`]
75//! answers with that sub-rect instead: the requested, device-clamped extent
76//! actually rendered this call — the extent a registration must state — never
77//! the whole texture's own (larger, quantized) extent, which is never itself
78//! exposed for registration. Nothing is registered with a foreign renderer and
79//! nothing is handed back on eviction: the pool owns the texture and a
80//! consumer borrows it.
81//!
82//! ## Target identity
83//!
84//! A `targets` entry also carries a **generation**
85//! ([`ShaderEffects::target_generation`]): a per-key counter bumped every time
86//! [`ShaderEffects::ensure_target`] actually creates a new texture at that
87//! key, rather than reusing an existing one. A caller that only compares
88//! `(id, requested extent)` to decide whether to re-register cannot tell a
89//! steady-state target apart from one silently recreated behind it — the
90//! per-target reap above (or the per-id cap just above it) can drop and
91//! recreate a target at the identical requested extent between two frames,
92//! and a registration keyed on extent alone would then keep sampling the
93//! *old* (still-alive, now-orphaned) texture forever. Comparing generation
94//! too is what lets this crate's caller
95//! (`frust_engine::effects::shader_quad::ShaderQuadPass::register`) tell the
96//! two cases apart and rebind whenever either the extent or the generation
97//! changed.
98//!
99//! ## Alpha
100//!
101//! A target is `Rgba8Unorm` and the pass writes the fragment shader's output
102//! into it unblended, so what the shader returns is what the target holds. The
103//! consumer samples that target as **premultiplied** colour, which is the
104//! convention every other engine paint travels in: a shader returning
105//! `vec4(rgb, a)` must have already multiplied `rgb` by `a`, and one returning
106//! opaque output (`a = 1.0`) is unaffected either way.
107//!
108//! ## Shader contract
109//!
110//! The caller supplies only the **fragment** source. It is compiled after a
111//! fixed prelude ([`VERTEX_PRELUDE`]) that declares the uniform block and the
112//! fullscreen-triangle vertex stage, so a fragment shader:
113//!
114//! - defines the fragment entry point **`fs_main`**
115//!   (`@fragment fn fs_main(in: FrustVsOut) -> @location(0) vec4<f32>`), and
116//! - reads the uniforms as `frust_u.resolution` / `frust_u.time`.
117//!
118//! ## No panics
119//!
120//! Every path here upholds the FFI no-panic invariant
121//! (`docs/CODE_STANDARDS.md`): a shader that fails to compile is recorded in
122//! `ShaderEffects::failed` and skipped (with a rate-limited `log::warn!`)
123//! rather than panicking; the underlying validation error also surfaces
124//! through the device's latched uncaptured-error handler
125//! ([`crate::context::create_device`](crate::context)).
126
127use std::collections::{HashMap, HashSet};
128
129use crate::pool::{DEFAULT_MAX_UNUSED_FRAMES, quantize_extent};
130
131/// Byte size of the uniform buffer: `vec2<f32> resolution` (8) + `f32 time`
132/// (4) + `f32 _pad` (4). The WGSL struct rounds its 12-byte payload up to a
133/// 16-byte alignment boundary, so the buffer and the Rust-side packing both use 16.
134const UNIFORM_SIZE: u64 = 16;
135
136/// How many distinct shader-compile failures are logged at warn level before
137/// the warnings are suppressed — the rate limit that keeps a batch of broken
138/// shaders from flooding the log. A failure past this is still recorded (so it
139/// is skipped), just not re-logged.
140const MAX_FAILED_WARNINGS: u32 = 8;
141
142/// How many consecutive frames a program id may go unseen (absent from every
143/// frame's live key set — see [`ShaderEffects::mark_seen`]) before its
144/// compiled pipeline, offscreen target(s), and any recorded compile failure
145/// are reaped ([`ShaderEffects::reap`]) rather than living until surface
146/// teardown. ~120 frames is roughly 1-2s at a 60-120Hz refresh rate:
147/// generous enough that a shader drawn intermittently (every-other-frame, or
148/// through a brief scene-diff hiccup) is never mistaken for vanished, short
149/// enough that navigating away from a shader-showcase screen reclaims its
150/// GPU state promptly instead of leaking for the rest of the session.
151pub const MAX_UNSEEN_FRAMES: u64 = 120;
152
153/// How many consecutive frames a target *key* — `(program id, quantized
154/// width, quantized height)`, one entry of [`ShaderEffects::ensure_target`]'s
155/// own map — may go absent from every frame's live target-key set (see
156/// [`ShaderEffects::mark_seen`]) before its GPU resources are dropped on
157/// their own, independent of whether the program `id` itself is still being
158/// drawn.
159///
160/// Deliberately shorter than [`MAX_UNSEEN_FRAMES`] and reused directly from
161/// [`crate::pool::DEFAULT_MAX_UNUSED_FRAMES`] (60, ~1s at 60Hz) rather than a
162/// third bespoke number: a size a still-live program has resized away from is
163/// exactly the same shape [`crate::pool::TexturePool`]'s own aging window was
164/// chosen for — a resize drag that revisits a quantized size seconds later
165/// should still find it parked — while a whole program going silent (the
166/// wider [`MAX_UNSEEN_FRAMES`] window) is a rarer, coarser event worth a more
167/// generous grace period.
168pub const MAX_UNSEEN_TARGET_FRAMES: u64 = DEFAULT_MAX_UNUSED_FRAMES;
169
170/// How many quantized target keys one program `id` may hold resident at once,
171/// enforced immediately by [`ShaderEffects::ensure_target`] the moment a new
172/// key would push `id` past it — evicting `id`'s own least-recently-seen key
173/// ([`oldest_target_key_for_id`]) before minting the new one, rather than
174/// waiting for [`MAX_UNSEEN_TARGET_FRAMES`]'s age-based reap to catch up.
175///
176/// 2 — the current quantized band and the immediately preceding one — is
177/// chosen so a resize drag straddling one 256px boundary back and forth does
178/// not thrash a target it only just evicted, while a drag through many bands
179/// in one frame (an animated scale snapping across a wide range, a
180/// multi-band resize) cannot accumulate one target per band with no pressure:
181/// [`MAX_UNSEEN_TARGET_FRAMES`]'s window alone would let such a sweep mint
182/// dozens of resident targets before any of them aged out. See the module
183/// header's "Per-id target count bound" bullet for the accepted peak this
184/// bounds.
185pub const MAX_TARGETS_PER_ID: usize = 2;
186
187/// The `(width, height)` a shader-effect target requested at `(w, h)` is
188/// actually keyed and created at: `w`/`h` quantized up to the next multiple
189/// of [`crate::pool::SIZE_QUANTUM`] (256px, via [`quantize_extent`]) and
190/// never past `ceiling` (the caller's own device-validity floor —
191/// [`ShaderEffects::ensure_target`] passes the device's real
192/// `max_texture_dimension_2d`).
193///
194/// Reusing the pool's own quantum rather than inventing a second one means a
195/// shader-quad target collapses a pixel-by-pixel resize drag onto the same
196/// handful of keys `frust_gpu::pool::TexturePool` already collapses every
197/// other scratch texture onto, so [`ShaderEffects::ensure_target`] mints a
198/// fresh texture only when a request crosses a 256px boundary instead of on
199/// every frame a quad's destination fractionally changes. Both axes are
200/// floored at 1 (a zero-sized texture is invalid) and the result never
201/// exceeds `ceiling`: [`quantize_extent`] alone passes an already-oversized
202/// request through unclamped (so its own caller can detect and warn about
203/// the clamp — see [`ShaderEffects::ensure_target`]'s doc comment), so the
204/// final `min` here is what actually keeps a shader-effect target within the
205/// device's real limit.
206#[must_use]
207pub fn quantized_target_key(w: u32, h: u32, ceiling: u32) -> (u32, u32) {
208    let ceiling = ceiling.max(1);
209    let (quantized_w, quantized_h) = quantize_extent(w.max(1), h.max(1), ceiling);
210    (quantized_w.min(ceiling), quantized_h.min(ceiling))
211}
212
213/// Distinct-compiled-program-id threshold [`should_warn_churn`] compares
214/// `ShaderEffects::pipelines`'s live size against. Crossing it is a
215/// detection signal — not itself a bound on growth (see below) — that
216/// `ShaderProgram::new` is likely being called somewhere that re-runs every
217/// frame/rebuild (a widget's `paint`, or a `Component`'s `build`) instead of
218/// once, per its documented cache-once contract
219/// (`frust_scene::ShaderProgram::new`'s rustdoc). 32 is chosen with generous
220/// headroom over the shader-showcase's own program count (a handful of
221/// fixed shaders) so a legitimate small gallery never trips it, while a
222/// per-frame-minting footgun — which compiles a fresh id on every frame and
223/// only stops accumulating once [`MAX_UNSEEN_FRAMES`]-old entries start
224/// reaping — reliably crosses it within about a second.
225///
226/// **Detection-only**: this constant does not bound the maps' actual growth —
227/// [`ShaderEffects::reap`] (driven by [`MAX_UNSEEN_FRAMES`]) is what keeps
228/// them from growing without bound; this threshold only decides when to *warn*
229/// that growth is happening in the first place.
230pub const CHURN_WARN_THRESHOLD: usize = 32;
231
232/// How many churn-detection warnings (see [`CHURN_WARN_THRESHOLD`]) are
233/// logged before further ones are suppressed — the same rate-limit shape as
234/// [`MAX_FAILED_WARNINGS`], applied to a distinct signal (concurrently
235/// compiled program count, not compile failures).
236const MAX_CHURN_WARNINGS: u32 = 8;
237
238/// The fixed WGSL prelude prepended to every fragment source: the 16-byte
239/// uniform block at `@group(0) @binding(0)` and the vertex-buffer-free
240/// fullscreen-triangle vertex stage.
241///
242/// The fragment source appended after this must define `fs_main` and read
243/// `frust_u.resolution` / `frust_u.time` (see the module-level shader contract).
244pub const VERTEX_PRELUDE: &str = r#"// --- frust shader-effect prelude (generated) ---
245struct Uniforms {
246    resolution: vec2<f32>,
247    time: f32,
248    _pad: f32,
249}
250@group(0) @binding(0) var<uniform> frust_u: Uniforms;
251
252struct FrustVsOut {
253    @builtin(position) position: vec4<f32>,
254    @location(0) uv: vec2<f32>,
255}
256
257// Fullscreen triangle from the vertex index alone — no vertex buffer.
258// Vertices 0,1,2 produce uv (0,0),(2,0),(0,2), covering the [-1,1] clip square
259// with a single oversized triangle.
260@vertex
261fn vs_main(@builtin(vertex_index) i: u32) -> FrustVsOut {
262    var out: FrustVsOut;
263    let uv = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
264    out.uv = uv;
265    out.position = vec4<f32>(uv * 2.0 - 1.0, 0.0, 1.0);
266    return out;
267}
268// --- end prelude ---
269"#;
270
271/// A compiled render pipeline for one shader program plus the bind-group layout
272/// its targets bind against.
273struct PipelineEntry {
274    pipeline: wgpu::RenderPipeline,
275    bind_layout: wgpu::BindGroupLayout,
276}
277
278/// The per-`(program, quantized size)` GPU state a shader effect renders
279/// into: an offscreen `Rgba8Unorm` texture (`RENDER_ATTACHMENT |
280/// TEXTURE_BINDING | COPY_SRC` — the attachment the pass writes, the binding
281/// a consumer samples it through, and the copy source a read-back needs),
282/// its view, the 16-byte uniform buffer, and the bind group wiring the
283/// buffer to `@binding(0)`.
284///
285/// `used_w`/`used_h` are the *quantized*, device-ceiling-clamped extent the
286/// texture was actually created at ([`quantized_target_key`]) — usually
287/// larger than any single caller's requested size, which is why
288/// [`ShaderEffects::encode_pass`] renders into a sub-rect of it rather than
289/// the whole attachment, and [`ShaderEffects::target_extent`] answers with
290/// that sub-rect rather than `used_w`/`used_h` themselves.
291struct TargetEntry {
292    texture: wgpu::Texture,
293    view: wgpu::TextureView,
294    uniforms: wgpu::Buffer,
295    bind_group: wgpu::BindGroup,
296    /// The quantized, device-clamped width this entry's texture was actually
297    /// created at.
298    used_w: u32,
299    /// The quantized, device-clamped height this entry's texture was
300    /// actually created at.
301    used_h: u32,
302    /// This entry's own generation — see [`ShaderEffects::target_generation`]
303    /// and the module header's "Target identity" section. Stamped once, from
304    /// the struct's own generation counter, when the entry is created and
305    /// never changed afterward; a key re-created later (after an
306    /// eviction/reap) gets a fresh, strictly greater value.
307    generation: u64,
308}
309
310/// Owns every GPU resource the shader-showcase feature needs: lazily-compiled
311/// per-program pipelines and per-`(program, quantized size)` offscreen
312/// targets.
313pub struct ShaderEffects {
314    /// Compiled pipelines, keyed by program id.
315    pipelines: HashMap<u64, PipelineEntry>,
316    /// Offscreen targets, keyed by `(program id, quantized width, quantized
317    /// height)` — see [`quantized_target_key`].
318    targets: HashMap<(u64, u32, u32), TargetEntry>,
319    /// A clone of the surface's pipeline cache, seeding pipeline compilation.
320    pipeline_cache: Option<wgpu::PipelineCache>,
321    /// Program ids whose shader failed to compile — skipped on every later
322    /// `ensure_pipeline` so a broken shader is compiled at most once.
323    failed: HashSet<u64>,
324    /// How many compile failures have been logged so far (the [`MAX_FAILED_WARNINGS`]
325    /// rate limit).
326    warned_failures: u32,
327    /// How many churn-detection warnings have been logged so far (the
328    /// [`MAX_CHURN_WARNINGS`] rate limit) — see [`should_warn_churn`].
329    warned_churn: u32,
330    /// Monotonic per-frame counter, bumped once per [`Self::mark_seen`] call —
331    /// the age clock [`reapable_ids`] measures a program id's absence against.
332    frame: u64,
333    /// The frame (per `Self::frame`) each program id was last present in a
334    /// frame's live id set, updated by [`Self::mark_seen`] for every id
335    /// passed in — including one whose pipeline failed to compile, so a
336    /// broken shader's `failed` entry can still age out. An id absent from
337    /// this map has never been seen (or was already reaped).
338    last_seen: HashMap<u64, u64>,
339    /// The frame (per `Self::frame`) each target *key* — `(program id,
340    /// quantized width, quantized height)`, a `targets` key — was last
341    /// present in a frame's live target-key set, updated by
342    /// [`Self::mark_seen`] and by [`Self::ensure_target`] itself (stamped at
343    /// creation, so a target created and never subsequently marked seen in
344    /// the same call still has a valid age to start from rather than aging
345    /// immediately). A key absent from this map has never been created (or
346    /// was already reaped) — see [`MAX_UNSEEN_TARGET_FRAMES`].
347    target_last_seen: HashMap<(u64, u32, u32), u64>,
348    /// Requested extents for which we have already warned about clamping. Keyed
349    /// by `(program id, requested width, requested height)` so the clamp warning
350    /// fires at most once per distinct oversized request, surviving the
351    /// target's own age-based reap (a re-created target at the same requested
352    /// extent does not re-warn). [`Self::reap`] drops an id's entries with the
353    /// rest of its state, which is both the "brand new again" contract and the
354    /// bound on this set's growth.
355    warned_clamped: HashSet<(u64, u32, u32)>,
356    /// The generation the next `targets` entry [`Self::ensure_target`] creates
357    /// will be stamped with — monotonically incremented on every actual
358    /// texture creation (never on a cache hit), so two entries ever alive at
359    /// the same key over time carry strictly increasing values. See
360    /// [`TargetEntry::generation`] and the module header's "Target identity"
361    /// section.
362    next_generation: u64,
363}
364
365/// Pack the 16-byte uniform buffer contents: `resolution.x`, `resolution.y`,
366/// `time`, `_pad` as little-endian `f32`s. Pure — unit-testable byte layout.
367fn uniform_bytes(width: u32, height: u32, time: f32) -> [u8; UNIFORM_SIZE as usize] {
368    let mut bytes = [0u8; UNIFORM_SIZE as usize];
369    bytes[0..4].copy_from_slice(&(width as f32).to_le_bytes());
370    bytes[4..8].copy_from_slice(&(height as f32).to_le_bytes());
371    bytes[8..12].copy_from_slice(&time.to_le_bytes());
372    // bytes[12..16] stays zero — the explicit `_pad` field.
373    bytes
374}
375
376/// Concatenate the fixed [`VERTEX_PRELUDE`] and a caller-supplied fragment
377/// source into the full WGSL module compiled for a program. Pure.
378fn compose_shader(fragment_src: &str) -> String {
379    format!("{VERTEX_PRELUDE}\n{fragment_src}")
380}
381
382/// The target keys in `target_last_seen` whose last-seen frame is at least
383/// `max_age` frames behind `current_frame` — the target-level counterpart of
384/// [`reapable_ids`], keyed by `(id, quantized w, quantized h)` rather than by
385/// program id alone. Pure: operates on the age map alone (no GPU state), so
386/// the per-target reap policy is unit-testable exactly like `reapable_ids`.
387///
388/// Age-based rather than frame-scoped is what lets two distinct (quantized)
389/// sizes of the same still-drawn program coexist within one frame: both keys
390/// are marked seen by the same [`ShaderEffects::mark_seen`] call, so neither
391/// ages — the two-sizes-one-frame case an earlier, frame-scoped eviction
392/// policy had to special-case explicitly (evicting any other-size same-id
393/// entry not named that exact frame) now falls out of the age clock by
394/// construction. A size a quad has resized away from simply stops being
395/// refreshed and ages out over [`MAX_UNSEEN_TARGET_FRAMES`] frames instead of
396/// being reclaimed the instant it is not asked for.
397fn reapable_target_ages(
398    target_last_seen: &HashMap<(u64, u32, u32), u64>,
399    current_frame: u64,
400    max_age: u64,
401) -> Vec<(u64, u32, u32)> {
402    target_last_seen
403        .iter()
404        .filter(|&(_, &seen)| current_frame.saturating_sub(seen) >= max_age)
405        .map(|(&key, _)| key)
406        .collect()
407}
408
409/// Whether a compile failure at `prior_warn_count` prior warnings should still
410/// be logged (the [`MAX_FAILED_WARNINGS`] rate limit). Pure.
411fn should_warn_failure(prior_warn_count: u32) -> bool {
412    prior_warn_count < MAX_FAILED_WARNINGS
413}
414
415/// Whether [`ShaderEffects::ensure_pipeline`] should log its churn-detection
416/// warning: `compiled_ids` (the live compiled-pipeline count) has crossed
417/// [`CHURN_WARN_THRESHOLD`] and fewer than this module's `MAX_CHURN_WARNINGS`
418/// have already been logged — mirroring `should_warn_failure`'s shape for a
419/// distinct signal. Pure: no GPU/log state touched, so the rate-limited decision is
420/// unit-testable on its own.
421///
422/// Detection-only, like the constant it reads: this predicate decides
423/// whether to *warn*, not whether to reap — [`ShaderEffects::reap`] bounds
424/// the actual growth independently of this rate limit.
425pub fn should_warn_churn(compiled_ids: usize, prior_warn_count: u32) -> bool {
426    compiled_ids > CHURN_WARN_THRESHOLD && prior_warn_count < MAX_CHURN_WARNINGS
427}
428
429/// The program ids in `last_seen` whose last-seen frame is at least `max_age`
430/// frames behind `current_frame` — a vanished-id reap candidate list, i.e.
431/// every id [`ShaderEffects::reap`] should drop resources for. Pure: operates
432/// on the age map alone (no GPU state), so the reap policy is unit-testable
433/// exactly like [`reapable_target_ages`].
434///
435/// A vanished id (one whose shader quad stops appearing in the scene entirely
436/// — screen navigation, a dynamic id, etc.) is a distinct case from a
437/// resized-away *size* of a still-drawn id, which
438/// [`reapable_target_ages`] reaps on its own (shorter) clock; this function
439/// is the whole-id counterpart, aged on [`MAX_UNSEEN_FRAMES`] rather than
440/// [`MAX_UNSEEN_TARGET_FRAMES`].
441fn reapable_ids(last_seen: &HashMap<u64, u64>, current_frame: u64, max_age: u64) -> Vec<u64> {
442    last_seen
443        .iter()
444        .filter(|&(_, &seen)| current_frame.saturating_sub(seen) >= max_age)
445        .map(|(&id, _)| id)
446        .collect()
447}
448
449/// The `targets` keys [`ShaderEffects::reap`] should remove for a given
450/// `stale_ids` list — every key whose id is one of them. Pure key-selection,
451/// mirroring [`reapable_target_ages`]'s shape, so `reap`'s target-removal
452/// choice is unit-testable without a `wgpu::Device` (a real `TargetEntry`
453/// can't be constructed without one).
454fn reapable_target_keys<I>(target_keys: I, stale_ids: &HashSet<u64>) -> Vec<(u64, u32, u32)>
455where
456    I: IntoIterator<Item = (u64, u32, u32)>,
457{
458    target_keys
459        .into_iter()
460        .filter(|key| stale_ids.contains(&key.0))
461        .collect()
462}
463
464/// The `targets` key belonging to `id` with the oldest `target_last_seen`
465/// entry, or `None` if `id` holds none — the eviction candidate
466/// [`ShaderEffects::ensure_target`] drops immediately when minting a new key
467/// would push `id` past [`MAX_TARGETS_PER_ID`]. Pure: operates on the age map
468/// alone, mirroring [`reapable_target_ages`]'s shape, so the eviction choice
469/// is unit-testable without a device by simulating a sweep of mints against
470/// `target_last_seen` alone. Ties (equal `seen` values) resolve to the key
471/// [`HashMap::iter`] happens to visit last among them — the cap-enforcement
472/// call site never mints two keys for one id in the same frame, so a tie
473/// among genuinely distinct ages does not arise in practice.
474fn oldest_target_key_for_id(
475    target_last_seen: &HashMap<(u64, u32, u32), u64>,
476    id: u64,
477) -> Option<(u64, u32, u32)> {
478    target_last_seen
479        .iter()
480        .filter(|&(&(key_id, _, _), _)| key_id == id)
481        .min_by_key(|&(_, &seen)| seen)
482        .map(|(&key, _)| key)
483}
484
485impl ShaderEffects {
486    /// Create an empty engine seeded with an optional clone of the surface's
487    /// [`wgpu::PipelineCache`] (used as `RenderPipelineDescriptor.cache` so a
488    /// persisted cache speeds first compilation). `None` on adapters without
489    /// `PIPELINE_CACHE` support (Metal/desktop), which is a plain cold start.
490    pub fn new(pipeline_cache: Option<wgpu::PipelineCache>) -> Self {
491        Self {
492            pipelines: HashMap::new(),
493            targets: HashMap::new(),
494            pipeline_cache,
495            failed: HashSet::new(),
496            warned_failures: 0,
497            warned_churn: 0,
498            frame: 0,
499            last_seen: HashMap::new(),
500            target_last_seen: HashMap::new(),
501            warned_clamped: HashSet::new(),
502            next_generation: 0,
503        }
504    }
505
506    /// Whether program `id` still needs its pipeline compiled — false once it is
507    /// either compiled or known-failed. Pure predicate (the fast-path skip
508    /// [`ensure_pipeline`](Self::ensure_pipeline) consults first), unit-testable
509    /// without a device.
510    pub fn needs_compile(&self, id: u64) -> bool {
511        !self.pipelines.contains_key(&id) && !self.failed.contains(&id)
512    }
513
514    /// Lazily compile the render pipeline for program `id` from `wgsl` (the
515    /// fragment source; see the module-level shader contract). A no-op if the
516    /// program is already compiled or already known-failed.
517    ///
518    /// Compilation is wrapped in a `Validation` error scope: a shader that fails
519    /// to validate records `id` as failed (skipped forever after) with a
520    /// rate-limited `log::warn!`, and **never panics** — the FFI no-panic
521    /// invariant. The validation error also surfaces through the device's
522    /// latched uncaptured-error handler.
523    pub fn ensure_pipeline(&mut self, device: &wgpu::Device, id: u64, wgsl: &str) {
524        if !self.needs_compile(id) {
525            return;
526        }
527
528        let source = compose_shader(wgsl);
529        let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
530
531        let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
532            label: Some("frust shader-effect module"),
533            source: wgpu::ShaderSource::Wgsl(source.into()),
534        });
535        let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
536            label: Some("frust shader-effect binds"),
537            entries: &[wgpu::BindGroupLayoutEntry {
538                binding: 0,
539                visibility: wgpu::ShaderStages::FRAGMENT,
540                ty: wgpu::BindingType::Buffer {
541                    ty: wgpu::BufferBindingType::Uniform,
542                    has_dynamic_offset: false,
543                    min_binding_size: None,
544                },
545                count: None,
546            }],
547        });
548        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
549            label: Some("frust shader-effect layout"),
550            bind_group_layouts: &[Some(&bind_layout)],
551            immediate_size: 0,
552        });
553        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
554            label: Some("frust shader-effect pipeline"),
555            layout: Some(&pipeline_layout),
556            vertex: wgpu::VertexState {
557                module: &module,
558                entry_point: Some("vs_main"),
559                compilation_options: Default::default(),
560                buffers: &[],
561            },
562            primitive: wgpu::PrimitiveState::default(),
563            depth_stencil: None,
564            multisample: wgpu::MultisampleState::default(),
565            fragment: Some(wgpu::FragmentState {
566                module: &module,
567                entry_point: Some("fs_main"),
568                compilation_options: Default::default(),
569                // No blending: the shader's output is written into the
570                // target verbatim, so what a consumer samples is exactly what
571                // the fragment stage returned (premultiplied — see the
572                // module header's alpha note).
573                targets: &[Some(wgpu::ColorTargetState {
574                    format: wgpu::TextureFormat::Rgba8Unorm,
575                    blend: None,
576                    write_mask: wgpu::ColorWrites::ALL,
577                })],
578            }),
579            multiview_mask: None,
580            cache: self.pipeline_cache.as_ref(),
581        });
582
583        if let Some(error) = drain_error_scope(device, scope) {
584            self.record_failed(id, &error.to_string());
585            return;
586        }
587
588        self.pipelines.insert(
589            id,
590            PipelineEntry {
591                pipeline,
592                bind_layout,
593            },
594        );
595
596        // Churn detection only — see CHURN_WARN_THRESHOLD's doc comment.
597        // This does NOT bound the underlying growth (mark_seen/reap, driven by
598        // MAX_UNSEEN_FRAMES, does that); it only surfaces the common
599        // cache-once-contract violation (a fresh ShaderProgram minted every
600        // frame/rebuild instead of created once and cached — see
601        // ShaderProgram::new's rustdoc) via a rate-limited log.
602        if should_warn_churn(self.pipelines.len(), self.warned_churn) {
603            self.warned_churn += 1;
604            log::warn!(
605                "frust-gpu: {} distinct shader programs are concurrently compiled for this \
606                 surface (threshold {CHURN_WARN_THRESHOLD}) — if new ShaderProgram instances are \
607                 being minted every frame/rebuild instead of created once and cached (see \
608                 ShaderProgram::new's cache-once contract), this is why; this warning is \
609                 detection-only and does not itself bound the growth",
610                self.pipelines.len(),
611            );
612        }
613    }
614
615    /// Record a compile failure: mark `id` skipped and warn (rate-limited).
616    fn record_failed(&mut self, id: u64, message: &str) {
617        self.failed.insert(id);
618        if should_warn_failure(self.warned_failures) {
619            self.warned_failures += 1;
620            log::warn!("frust-gpu: shader program {id} failed to compile, skipping: {message}");
621        }
622    }
623
624    /// Bump the frame counter and record every id in `live_ids` (this frame's
625    /// distinct program ids, taken before compile/target work — so an id whose
626    /// pipeline just failed to compile is still marked seen) as last seen at
627    /// the new frame, and every key in `live_target_keys` (this frame's
628    /// distinct `(id, quantized w, quantized h)` target keys — see
629    /// [`quantized_target_key`]) as last seen the same way. Ages out and
630    /// drops any target key that has now gone unseen for at least
631    /// [`MAX_UNSEEN_TARGET_FRAMES`] frames (its own, shorter clock —
632    /// [`reapable_target_ages`]), then returns every program id that has now
633    /// gone unseen for at least [`MAX_UNSEEN_FRAMES`] frames (via
634    /// [`reapable_ids`]), ready to hand to [`Self::reap`].
635    ///
636    /// Called once per frame regardless of whether either set is empty — a
637    /// scene that stops drawing shader quads entirely must still age out and
638    /// eventually reap every previously-seen id and target, not just ones
639    /// still present.
640    pub fn mark_seen(
641        &mut self,
642        live_ids: &HashSet<u64>,
643        live_target_keys: &HashSet<(u64, u32, u32)>,
644    ) -> Vec<u64> {
645        self.frame += 1;
646        for &id in live_ids {
647            self.last_seen.insert(id, self.frame);
648        }
649        for &key in live_target_keys {
650            self.target_last_seen.insert(key, self.frame);
651        }
652        for key in
653            reapable_target_ages(&self.target_last_seen, self.frame, MAX_UNSEEN_TARGET_FRAMES)
654        {
655            self.targets.remove(&key);
656            self.target_last_seen.remove(&key);
657        }
658        reapable_ids(&self.last_seen, self.frame, MAX_UNSEEN_FRAMES)
659    }
660
661    /// Reap every resource for each id in `stale_ids` (a [`Self::mark_seen`]
662    /// output): every `targets` entry whose key's id matches (and its
663    /// `target_last_seen` row), plus the compiled `pipelines` entry, any
664    /// `failed` record, and the `last_seen` row itself. Dropping `failed`
665    /// alongside the rest means a program id that reappears after being
666    /// reaped is treated as brand new: it recompiles cleanly rather than
667    /// hitting a stale skip from a compile failure that happened frames ago
668    /// (or never happened at all). The clamp-warning latch is dropped on the
669    /// same trigger: a reaped id that returns warns afresh on its first
670    /// oversized request, exactly like a brand-new program — and this reap is
671    /// also what bounds the latch set's growth, the same way it bounds every
672    /// other map here.
673    pub fn reap(&mut self, stale_ids: &[u64]) {
674        let stale_id_set: HashSet<u64> = stale_ids.iter().copied().collect();
675        for key in reapable_target_keys(self.targets.keys().copied(), &stale_id_set) {
676            self.targets.remove(&key);
677        }
678        self.target_last_seen
679            .retain(|key, _| !stale_id_set.contains(&key.0));
680        self.warned_clamped
681            .retain(|key| !stale_id_set.contains(&key.0));
682        for &id in stale_ids {
683            self.pipelines.remove(&id);
684            self.failed.remove(&id);
685            self.last_seen.remove(&id);
686        }
687    }
688
689    /// Ensure a target exists for `(id, w, h)`, creating it (at
690    /// [`quantized_target_key`]'s quantized, device-clamped extent) if
691    /// absent. Age-based reclaim of an unseen target — same-quantized-key
692    /// requests aside — is handled separately by [`Self::mark_seen`], so two
693    /// distinct (quantized) sizes of the same program can coexist across
694    /// many frames rather than evicting each other every one.
695    ///
696    /// A no-op if program `id` has no compiled pipeline (never compiled, or
697    /// compile-failed): a target is useless without the pipeline that owns its
698    /// bind-group layout, so the caller compiles first. `w`/`h` are quantized
699    /// and clamped to the device's own `max_texture_dimension_2d` ceiling
700    /// (floored at 1) — a device-validity floor only, not the caller's size
701    /// *policy*: a caller may still apply a tighter policy cap of its own
702    /// (e.g. `frust_engine::effects::shader_quad`'s 8192) before calling, but
703    /// an oversized request that reaches here creates a texture at the
704    /// device ceiling instead of tripping `wgpu` validation, and warns once
705    /// per distinct oversized `(id, requested_w, requested_h)` pair — the
706    /// warning latches the first time this exact request is clamped by the
707    /// ceiling (never merely because quantization rounded it up), then
708    /// repeats only if the request changes. A freshly created entry's
709    /// `target_last_seen` row is stamped at the current frame so it starts
710    /// with a valid age even if the caller's own [`Self::mark_seen`] call for
711    /// this frame has not run yet, and its `generation` is stamped from
712    /// [`Self::next_generation`] — see the module header's "Target identity"
713    /// section.
714    ///
715    /// Before minting a genuinely new key, enforces [`MAX_TARGETS_PER_ID`]:
716    /// if `id` already holds the cap's worth of resident keys, its own
717    /// least-recently-seen one ([`oldest_target_key_for_id`]) is evicted
718    /// immediately, ahead of and independent of [`Self::mark_seen`]'s
719    /// age-based reap — see [`MAX_TARGETS_PER_ID`]'s own doc for why.
720    pub fn ensure_target(&mut self, device: &wgpu::Device, id: u64, w: u32, h: u32) {
721        let (used_w, used_h, ceiling) = Self::target_key(device, w, h);
722        let key = (id, used_w, used_h);
723        if self.targets.contains_key(&key) {
724            return;
725        }
726
727        let Some(pipeline_entry) = self.pipelines.get(&id) else {
728            return;
729        };
730
731        // Warn only when the device's real ceiling actually constrained the
732        // result below what was asked — never merely because quantization
733        // rounded a request up to its (larger) texture's own size.
734        if (w > ceiling || h > ceiling) && self.warned_clamped.insert((id, w, h)) {
735            log::warn!(
736                "frust-gpu: shader-effect target {id} requested {w}x{h}, clamped to \
737                 {used_w}x{used_h} (the device's max_texture_dimension_2d ceiling {ceiling})",
738            );
739        }
740
741        // A genuinely new key: enforce the per-id resident cap before
742        // minting it, evicting the id's own oldest key immediately rather
743        // than letting it ride until the age-based reap catches it.
744        let resident_for_id = self.targets.keys().filter(|k| k.0 == id).count();
745        if resident_for_id >= MAX_TARGETS_PER_ID
746            && let Some(evict_key) = oldest_target_key_for_id(&self.target_last_seen, id)
747        {
748            self.targets.remove(&evict_key);
749            self.target_last_seen.remove(&evict_key);
750        }
751
752        let generation = self.next_generation;
753        self.next_generation += 1;
754
755        let texture = device.create_texture(&wgpu::TextureDescriptor {
756            label: Some("frust shader-effect target"),
757            size: wgpu::Extent3d {
758                width: used_w,
759                height: used_h,
760                depth_or_array_layers: 1,
761            },
762            mip_level_count: 1,
763            sample_count: 1,
764            dimension: wgpu::TextureDimension::D2,
765            format: wgpu::TextureFormat::Rgba8Unorm,
766            usage: wgpu::TextureUsages::RENDER_ATTACHMENT
767                | wgpu::TextureUsages::TEXTURE_BINDING
768                | wgpu::TextureUsages::COPY_SRC,
769            view_formats: &[],
770        });
771        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
772        let uniforms = device.create_buffer(&wgpu::BufferDescriptor {
773            label: Some("frust shader-effect uniforms"),
774            size: UNIFORM_SIZE,
775            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
776            mapped_at_creation: false,
777        });
778        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
779            label: Some("frust shader-effect bind group"),
780            layout: &pipeline_entry.bind_layout,
781            entries: &[wgpu::BindGroupEntry {
782                binding: 0,
783                resource: uniforms.as_entire_binding(),
784            }],
785        });
786
787        self.targets.insert(
788            key,
789            TargetEntry {
790                texture,
791                view,
792                uniforms,
793                bind_group,
794                used_w,
795                used_h,
796                generation,
797            },
798        );
799        self.target_last_seen.insert(key, self.frame);
800    }
801
802    /// Encode one fullscreen-triangle pass for program `id`, requested at
803    /// `requested` (the caller's exact device-space extent — never the
804    /// quantized target's own, larger size), into its target, writing the
805    /// uniform buffer (`resolution`, `time`) first. A no-op if the program's
806    /// pipeline or its (quantized) target is missing. Adds no `queue.submit`
807    /// — the caller owns encoder creation and submission ordering relative to
808    /// the frame's own passes.
809    ///
810    /// Renders into the **sub-rect** `(0, 0)..requested` of the target via
811    /// `wgpu`'s viewport, never the whole (possibly larger, quantized)
812    /// attachment: the resolution uniform a shader reads is `requested`
813    /// itself (clamped to what the target can actually hold, in the rare
814    /// case the device ceiling shrank it below the request), so a quad
815    /// samples back content sized exactly to itself even when its target is
816    /// shared with, or larger than, other nearby-sized requests.
817    pub fn encode_pass(
818        &self,
819        encoder: &mut wgpu::CommandEncoder,
820        queue: &wgpu::Queue,
821        device: &wgpu::Device,
822        id: u64,
823        requested: (u32, u32),
824        time: f32,
825    ) {
826        let (req_w, req_h) = (requested.0.max(1), requested.1.max(1));
827        let (used_w, used_h, _ceiling) = Self::target_key(device, req_w, req_h);
828        let (Some(pipeline_entry), Some(target)) = (
829            self.pipelines.get(&id),
830            self.targets.get(&(id, used_w, used_h)),
831        ) else {
832            return;
833        };
834
835        // The sub-rect this pass actually renders into: the requested
836        // extent, never larger than the (quantized, possibly device-clamped)
837        // texture actually backing it.
838        let render_w = req_w.min(target.used_w);
839        let render_h = req_h.min(target.used_h);
840
841        queue.write_buffer(
842            &target.uniforms,
843            0,
844            &uniform_bytes(render_w, render_h, time),
845        );
846
847        let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
848            label: Some("frust shader-effect pass"),
849            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
850                view: &target.view,
851                depth_slice: None,
852                resolve_target: None,
853                ops: wgpu::Operations {
854                    load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
855                    store: wgpu::StoreOp::Store,
856                },
857            })],
858            depth_stencil_attachment: None,
859            timestamp_writes: None,
860            occlusion_query_set: None,
861            multiview_mask: None,
862        });
863        pass.set_pipeline(&pipeline_entry.pipeline);
864        pass.set_bind_group(0, &target.bind_group, &[]);
865        // Confine the fullscreen triangle to the requested sub-rect of the
866        // (possibly larger) attachment, so a smaller quad sharing a bigger
867        // quantized target only ever writes its own corner.
868        pass.set_viewport(0.0, 0.0, render_w as f32, render_h as f32, 0.0, 1.0);
869        pass.draw(0..3, 0..1);
870    }
871
872    /// The offscreen texture program `id` was rendered into for a request at
873    /// `(w, h)` (looked up by [`quantized_target_key`], `device`'s own
874    /// ceiling), or `None` if that (quantized) target does not exist (never
875    /// [`ensure_target`](Self::ensure_target)ed, or the pipeline compile
876    /// failed).
877    ///
878    /// The read-back seam. A consumer that draws the result instead wants
879    /// [`Self::target_view`], since a scene-texture registration takes a view;
880    /// nothing is registered with a foreign renderer and nothing is handed
881    /// back on eviction — the pool owns the texture and the borrow lives as
882    /// long as the caller holds it. The returned texture is the whole
883    /// (possibly larger, quantized) attachment — a read-back consumer wanting
884    /// only the rendered sub-rect wants [`Self::target_extent`] alongside it.
885    pub fn target_texture(
886        &self,
887        device: &wgpu::Device,
888        id: u64,
889        w: u32,
890        h: u32,
891    ) -> Option<&wgpu::Texture> {
892        let (used_w, used_h, _ceiling) = Self::target_key(device, w, h);
893        self.targets
894            .get(&(id, used_w, used_h))
895            .map(|entry| &entry.texture)
896    }
897
898    /// The full (quantized) extent view of program `id`'s `(w, h)`-requested
899    /// target — the handle a consumer registers to sample the rendered result
900    /// — or `None` on the same terms as [`Self::target_texture`].
901    ///
902    /// The same view the pass wrote through, rather than a fresh one per
903    /// frame: a view is a handle onto the texture, so creating one per
904    /// registration would churn a resource that never changes while its
905    /// target lives. This is a whole-texture view even where only the
906    /// `(0, 0)..target_extent` sub-rect was actually rendered this frame —
907    /// see [`Self::target_extent`] for the sub-rect a registration should
908    /// state instead.
909    pub fn target_view(
910        &self,
911        device: &wgpu::Device,
912        id: u64,
913        w: u32,
914        h: u32,
915    ) -> Option<&wgpu::TextureView> {
916        let (used_w, used_h, _ceiling) = Self::target_key(device, w, h);
917        self.targets
918            .get(&(id, used_w, used_h))
919            .map(|entry| &entry.view)
920    }
921
922    /// The extent a `(w, h)` request at program `id` was actually *rendered*
923    /// at this call — the sub-rect [`Self::encode_pass`] draws into, not the
924    /// (quantized, possibly larger) texture [`Self::target_view`] hands back
925    /// — or `None` on the same terms as [`Self::target_texture`] (no
926    /// (quantized) target exists for this id/extent at all).
927    ///
928    /// Not `(w, h)` echoed back unconditionally: in the rare case the
929    /// device's real ceiling constrains the target below what was asked
930    /// (`w`/`h` past `device`'s `max_texture_dimension_2d`), the answer is
931    /// clamped the same way [`Self::encode_pass`] itself clamps what it
932    /// renders, so a registration built from this value and the target's
933    /// view always agree on what the attachment actually holds at the
934    /// caller's requested corner.
935    pub fn target_extent(
936        &self,
937        device: &wgpu::Device,
938        id: u64,
939        w: u32,
940        h: u32,
941    ) -> Option<(u32, u32)> {
942        let (used_w, used_h, _ceiling) = Self::target_key(device, w, h);
943        let target = self.targets.get(&(id, used_w, used_h))?;
944        Some((w.max(1).min(target.used_w), h.max(1).min(target.used_h)))
945    }
946
947    /// The generation program `id`'s `(w, h)`-requested target was created at
948    /// — bumped by [`Self::ensure_target`] every time it actually creates a
949    /// new texture at the resolved key, never on a cache hit — or `None` on
950    /// the same terms as [`Self::target_texture`] (no target exists for this
951    /// id/extent at all).
952    ///
953    /// The identity half of a registration alongside [`Self::target_extent`]:
954    /// two targets can share an identical requested extent yet be genuinely
955    /// different GPU resources (one reaped and recreated behind a caller that
956    /// only compared extents), and this is what lets a caller (e.g.
957    /// `frust_engine::effects::shader_quad::ShaderQuadPass::register`) tell
958    /// them apart — see the module header's "Target identity" section.
959    #[must_use]
960    pub fn target_generation(&self, device: &wgpu::Device, id: u64, w: u32, h: u32) -> Option<u64> {
961        let (used_w, used_h, _ceiling) = Self::target_key(device, w, h);
962        self.targets
963            .get(&(id, used_w, used_h))
964            .map(|entry| entry.generation)
965    }
966
967    /// Derive the `(quantized w, quantized h, ceiling)` a `(w, h)` request
968    /// resolves to on `device`: `device.limits().max_texture_dimension_2d` as
969    /// [`quantized_target_key`]'s own ceiling parameter. The single place
970    /// every method needing both the resolved key and the raw ceiling
971    /// computes them — factored out of the five call sites that used to
972    /// repeat this pair (hygiene).
973    fn target_key(device: &wgpu::Device, w: u32, h: u32) -> (u32, u32, u32) {
974        let ceiling = device.limits().max_texture_dimension_2d;
975        let (used_w, used_h) = quantized_target_key(w, h, ceiling);
976        (used_w, used_h, ceiling)
977    }
978}
979
980/// Synchronously drain a wgpu error scope, returning the captured validation
981/// error (if any). On native wgpu the [`pop`](wgpu::ErrorScopeGuard::pop) future
982/// is ready as soon as the synchronously-captured creation error is recorded, so
983/// this resolves without an async runtime; the `device.poll` fallback drives any
984/// residual pending state to completion rather than spinning. Never panics.
985fn drain_error_scope(device: &wgpu::Device, scope: wgpu::ErrorScopeGuard) -> Option<wgpu::Error> {
986    use std::task::{Context, Poll, Waker};
987
988    let waker = Waker::noop();
989    let mut cx = Context::from_waker(waker);
990    let mut future = std::pin::pin!(scope.pop());
991    loop {
992        match future.as_mut().poll(&mut cx) {
993            Poll::Ready(error) => return error,
994            Poll::Pending => {
995                let _ = device.poll(wgpu::PollType::wait_indefinitely());
996            }
997        }
998    }
999}
1000
1001#[cfg(test)]
1002mod tests {
1003    use super::*;
1004
1005    #[test]
1006    fn uniform_bytes_is_16_and_little_endian() {
1007        let bytes = uniform_bytes(1920, 1080, 2.5);
1008        assert_eq!(bytes.len(), 16);
1009        assert_eq!(f32::from_le_bytes(bytes[0..4].try_into().unwrap()), 1920.0);
1010        assert_eq!(f32::from_le_bytes(bytes[4..8].try_into().unwrap()), 1080.0);
1011        assert_eq!(f32::from_le_bytes(bytes[8..12].try_into().unwrap()), 2.5);
1012        // The explicit `_pad` word is zero.
1013        assert_eq!(&bytes[12..16], &[0, 0, 0, 0]);
1014    }
1015
1016    #[test]
1017    fn compose_shader_prepends_prelude_and_keeps_fragment() {
1018        let frag = "@fragment fn fs_main(in: FrustVsOut) -> @location(0) vec4<f32> \
1019                    { return vec4<f32>(frust_u.time, 0.0, 0.0, 1.0); }";
1020        let full = compose_shader(frag);
1021        assert!(full.starts_with(VERTEX_PRELUDE));
1022        assert!(full.contains("fn vs_main"));
1023        assert!(full.contains("var<uniform> frust_u: Uniforms"));
1024        assert!(full.contains(frag));
1025        // The fragment is appended after the prelude, never before it.
1026        assert!(full.find("fn vs_main").unwrap() < full.find(frag).unwrap());
1027    }
1028
1029    #[test]
1030    fn needs_compile_tracks_pipeline_and_failed_sets() {
1031        let mut fx = ShaderEffects::new(None);
1032        assert!(fx.needs_compile(7));
1033        // A recorded failure skips further compilation of that id.
1034        fx.record_failed(7, "boom");
1035        assert!(!fx.needs_compile(7));
1036        assert!(fx.failed.contains(&7));
1037        // A different id is still eligible.
1038        assert!(fx.needs_compile(8));
1039    }
1040
1041    #[test]
1042    fn record_failed_warns_only_up_to_the_rate_limit() {
1043        let mut fx = ShaderEffects::new(None);
1044        // Distinct failing ids past the cap: every id is recorded as failed, but
1045        // warnings stop at MAX_FAILED_WARNINGS.
1046        for id in 0..(MAX_FAILED_WARNINGS + 5) as u64 {
1047            fx.record_failed(id, "bad shader");
1048        }
1049        assert_eq!(fx.warned_failures, MAX_FAILED_WARNINGS);
1050        assert_eq!(fx.failed.len(), (MAX_FAILED_WARNINGS + 5) as usize);
1051    }
1052
1053    #[test]
1054    fn should_warn_failure_stops_at_cap() {
1055        assert!(should_warn_failure(0));
1056        assert!(should_warn_failure(MAX_FAILED_WARNINGS - 1));
1057        assert!(!should_warn_failure(MAX_FAILED_WARNINGS));
1058        assert!(!should_warn_failure(MAX_FAILED_WARNINGS + 1));
1059    }
1060
1061    #[test]
1062    fn should_warn_churn_fires_only_past_the_distinct_id_threshold() {
1063        assert!(
1064            !should_warn_churn(CHURN_WARN_THRESHOLD, 0),
1065            "at threshold, not over it, must not warn"
1066        );
1067        assert!(should_warn_churn(CHURN_WARN_THRESHOLD + 1, 0));
1068        assert!(should_warn_churn(CHURN_WARN_THRESHOLD + 100, 0));
1069        assert!(
1070            !should_warn_churn(0, 0),
1071            "well under threshold must never warn"
1072        );
1073    }
1074
1075    #[test]
1076    fn should_warn_churn_stops_at_the_rate_limit_cap() {
1077        assert!(should_warn_churn(CHURN_WARN_THRESHOLD + 1, 0));
1078        assert!(should_warn_churn(
1079            CHURN_WARN_THRESHOLD + 1,
1080            MAX_CHURN_WARNINGS - 1
1081        ));
1082        assert!(!should_warn_churn(
1083            CHURN_WARN_THRESHOLD + 1,
1084            MAX_CHURN_WARNINGS
1085        ));
1086        assert!(!should_warn_churn(
1087            CHURN_WARN_THRESHOLD + 1,
1088            MAX_CHURN_WARNINGS + 1
1089        ));
1090    }
1091
1092    #[test]
1093    fn ensure_pipeline_never_warns_below_the_churn_threshold() {
1094        // A device-free proxy for ensure_pipeline's hook: compiling well
1095        // under CHURN_WARN_THRESHOLD distinct ids must never cross into
1096        // should_warn_churn's true branch, mirroring what ensure_pipeline
1097        // consults after every successful `pipelines.insert`.
1098        for compiled in 0..=CHURN_WARN_THRESHOLD {
1099            assert!(
1100                !should_warn_churn(compiled, 0),
1101                "compiled={compiled} must not warn (at or under threshold)"
1102            );
1103        }
1104    }
1105
1106    #[test]
1107    fn quantized_target_key_rounds_up_to_the_256px_quantum() {
1108        assert_eq!(quantized_target_key(1, 1, 8192), (256, 256));
1109        assert_eq!(quantized_target_key(256, 256, 8192), (256, 256));
1110        assert_eq!(quantized_target_key(257, 4, 8192), (512, 256));
1111        assert_eq!(quantized_target_key(800, 300, 8192), (1024, 512));
1112    }
1113
1114    #[test]
1115    fn quantized_target_key_never_exceeds_the_ceiling() {
1116        assert_eq!(
1117            quantized_target_key(10_000, 10_000, 2048),
1118            (2048, 2048),
1119            "an already-oversized request is clamped to the ceiling here, unlike quantize_extent alone"
1120        );
1121        assert_eq!(quantized_target_key(2000, 2000, 2000), (2000, 2000));
1122    }
1123
1124    #[test]
1125    fn quantized_target_key_floors_zero_to_one_quantum() {
1126        assert_eq!(quantized_target_key(0, 0, 8192), (256, 256));
1127    }
1128
1129    #[test]
1130    fn quantized_target_key_survives_a_degenerate_ceiling() {
1131        assert_eq!(quantized_target_key(100, 100, 0), (1, 1));
1132    }
1133
1134    #[test]
1135    fn quantized_target_key_collapses_a_resize_drag_onto_one_key() {
1136        // Every width from 1 through 256 shares one key — the property the
1137        // whole quantization fix rests on: a pixel-by-pixel resize crosses
1138        // this boundary at most once per 256px.
1139        let mut keys: Vec<(u32, u32)> = (1..=256u32)
1140            .map(|w| quantized_target_key(w, w, 8192))
1141            .collect();
1142        keys.dedup();
1143        assert_eq!(keys, vec![(256, 256)]);
1144    }
1145
1146    #[test]
1147    fn reapable_target_ages_selects_only_keys_unseen_for_at_least_max_age() {
1148        let target_last_seen: HashMap<(u64, u32, u32), u64> = [
1149            ((1, 256, 256), 0),
1150            ((2, 256, 256), 50),
1151            ((3, 256, 256), 100),
1152        ]
1153        .into_iter()
1154        .collect();
1155        // At frame 120: (1, ...) (age 120) and (2, ...) (age 70) are both
1156        // >= max_age 60; (3, ...) (age 20) is not.
1157        let mut stale = reapable_target_ages(&target_last_seen, 120, 60);
1158        stale.sort();
1159        assert_eq!(stale, vec![(1, 256, 256), (2, 256, 256)]);
1160    }
1161
1162    #[test]
1163    fn reapable_target_ages_boundary_is_inclusive() {
1164        let target_last_seen: HashMap<(u64, u32, u32), u64> =
1165            [((1, 256, 256), 0)].into_iter().collect();
1166        assert!(reapable_target_ages(&target_last_seen, 60, 60).contains(&(1, 256, 256)));
1167        assert!(!reapable_target_ages(&target_last_seen, 59, 60).contains(&(1, 256, 256)));
1168    }
1169
1170    #[test]
1171    fn reapable_target_ages_empty_when_every_key_seen_this_frame() {
1172        let mut target_last_seen: HashMap<(u64, u32, u32), u64> = HashMap::new();
1173        for frame in 1..=(MAX_UNSEEN_TARGET_FRAMES * 3) {
1174            target_last_seen.insert((1, 256, 256), frame);
1175            assert!(
1176                reapable_target_ages(&target_last_seen, frame, MAX_UNSEEN_TARGET_FRAMES).is_empty()
1177            );
1178        }
1179    }
1180
1181    #[test]
1182    fn reapable_ids_selects_only_ids_unseen_for_at_least_max_age() {
1183        let last_seen: HashMap<u64, u64> = [(1, 0), (2, 50), (3, 100)].into_iter().collect();
1184        // At frame 120: id 1 (age 120) and id 2 (age 70) are both >= max_age
1185        // 60; id 3 (age 20) is not.
1186        let mut stale = reapable_ids(&last_seen, 120, 60);
1187        stale.sort();
1188        assert_eq!(stale, vec![1, 2]);
1189    }
1190
1191    #[test]
1192    fn reapable_ids_boundary_is_inclusive() {
1193        let last_seen: HashMap<u64, u64> = [(1, 0)].into_iter().collect();
1194        // Age exactly max_age reaps; one frame short does not.
1195        assert!(reapable_ids(&last_seen, 60, 60).contains(&1));
1196        assert!(!reapable_ids(&last_seen, 59, 60).contains(&1));
1197    }
1198
1199    #[test]
1200    fn reapable_ids_empty_when_every_id_seen_this_frame() {
1201        // An id seen every frame is never reaped, however many frames elapse.
1202        let mut last_seen: HashMap<u64, u64> = HashMap::new();
1203        for frame in 1..=(MAX_UNSEEN_FRAMES * 3) {
1204            last_seen.insert(1, frame);
1205            assert!(reapable_ids(&last_seen, frame, MAX_UNSEEN_FRAMES).is_empty());
1206        }
1207    }
1208
1209    #[test]
1210    fn reapable_target_keys_selects_only_matching_stale_ids() {
1211        let keys = [(1, 100, 100), (1, 200, 200), (2, 100, 100), (3, 50, 50)];
1212        let stale_ids: HashSet<u64> = [1, 3].into_iter().collect();
1213        let mut got = reapable_target_keys(keys.iter().copied(), &stale_ids);
1214        got.sort();
1215        assert_eq!(got, vec![(1, 100, 100), (1, 200, 200), (3, 50, 50)]);
1216    }
1217
1218    #[test]
1219    fn reapable_target_keys_empty_for_no_stale_ids() {
1220        let keys = [(1, 100, 100)];
1221        let stale_ids: HashSet<u64> = HashSet::new();
1222        assert!(reapable_target_keys(keys.iter().copied(), &stale_ids).is_empty());
1223    }
1224
1225    #[test]
1226    fn oldest_target_key_for_id_picks_the_least_recently_seen_of_that_id_alone() {
1227        let target_last_seen: HashMap<(u64, u32, u32), u64> = [
1228            ((1, 256, 256), 10),
1229            ((1, 512, 512), 5),
1230            ((1, 768, 768), 20),
1231            // A different id's older entry must never be picked.
1232            ((2, 256, 256), 0),
1233        ]
1234        .into_iter()
1235        .collect();
1236
1237        assert_eq!(
1238            oldest_target_key_for_id(&target_last_seen, 1),
1239            Some((1, 512, 512))
1240        );
1241    }
1242
1243    #[test]
1244    fn oldest_target_key_for_id_none_when_the_id_holds_nothing() {
1245        let target_last_seen: HashMap<(u64, u32, u32), u64> =
1246            [((2, 256, 256), 0)].into_iter().collect();
1247        assert_eq!(oldest_target_key_for_id(&target_last_seen, 1), None);
1248    }
1249
1250    /// Device-free proof of [`MAX_TARGETS_PER_ID`]'s whole point: mirrors
1251    /// [`ShaderEffects::ensure_target`]'s own evict-before-insert order using
1252    /// the same production [`oldest_target_key_for_id`] helper, without a
1253    /// `wgpu::Device` — a sweep across 5 distinct quantized bands for one
1254    /// program id never leaves more than the cap resident.
1255    #[test]
1256    fn a_sweep_across_five_bands_leaves_at_most_the_cap_resident_for_one_id() {
1257        let id = 1u64;
1258        let mut resident: HashSet<(u64, u32, u32)> = HashSet::new();
1259        let mut target_last_seen: HashMap<(u64, u32, u32), u64> = HashMap::new();
1260
1261        for band in 0..5u32 {
1262            let key = (id, band, band);
1263            if resident.len() >= MAX_TARGETS_PER_ID
1264                && let Some(evict) = oldest_target_key_for_id(&target_last_seen, id)
1265            {
1266                resident.remove(&evict);
1267                target_last_seen.remove(&evict);
1268            }
1269            resident.insert(key);
1270            target_last_seen.insert(key, u64::from(band));
1271
1272            assert!(
1273                resident.len() <= MAX_TARGETS_PER_ID,
1274                "band {band}: must never exceed the per-id cap"
1275            );
1276        }
1277
1278        assert_eq!(
1279            resident.len(),
1280            MAX_TARGETS_PER_ID,
1281            "exactly the cap remains resident after the sweep"
1282        );
1283        // The two most recent bands survive; the earlier three were evicted.
1284        assert!(resident.contains(&(id, 3, 3)));
1285        assert!(resident.contains(&(id, 4, 4)));
1286    }
1287
1288    #[test]
1289    fn mark_seen_bumps_frame_and_reaps_ids_unseen_past_max_age() {
1290        let mut fx = ShaderEffects::new(None);
1291        // Frame 1: id 1 is live.
1292        assert!(
1293            fx.mark_seen(&[1].into_iter().collect(), &HashSet::new())
1294                .is_empty()
1295        );
1296        // Id 1 vanishes; keep marking an unrelated id (or nothing) live for
1297        // MAX_UNSEEN_FRAMES more frames — id 1 must not surface as reapable
1298        // until its age actually crosses the threshold.
1299        for _ in 0..(MAX_UNSEEN_FRAMES - 1) {
1300            assert!(fx.mark_seen(&HashSet::new(), &HashSet::new()).is_empty());
1301        }
1302        // One more frame crosses the threshold.
1303        let reapable = fx.mark_seen(&HashSet::new(), &HashSet::new());
1304        assert_eq!(reapable, vec![1]);
1305    }
1306
1307    #[test]
1308    fn mark_seen_never_reaps_an_id_kept_live_every_frame() {
1309        let mut fx = ShaderEffects::new(None);
1310        for _ in 0..(MAX_UNSEEN_FRAMES * 2) {
1311            assert!(
1312                fx.mark_seen(&[1].into_iter().collect(), &HashSet::new())
1313                    .is_empty()
1314            );
1315        }
1316    }
1317
1318    #[test]
1319    fn mark_seen_ages_a_target_key_on_its_own_shorter_clock_than_the_id() {
1320        let mut fx = ShaderEffects::new(None);
1321        let ids: HashSet<u64> = [1].into_iter().collect();
1322        let key = (1u64, 256u32, 256u32);
1323
1324        // Frame 1: the id and its one target key are both live.
1325        fx.mark_seen(&ids, &[key].into_iter().collect());
1326        assert!(fx.target_last_seen.contains_key(&key));
1327
1328        // The size is resized away from — id 1 stays live every frame, but
1329        // this exact target key is not — until its own (shorter)
1330        // MAX_UNSEEN_TARGET_FRAMES window elapses.
1331        for _ in 0..(MAX_UNSEEN_TARGET_FRAMES - 1) {
1332            fx.mark_seen(&ids, &HashSet::new());
1333            assert!(
1334                fx.target_last_seen.contains_key(&key),
1335                "must not age out before its own window elapses"
1336            );
1337        }
1338        fx.mark_seen(&ids, &HashSet::new());
1339        assert!(
1340            !fx.target_last_seen.contains_key(&key),
1341            "ages out on its own clock even though id 1 is still live every frame"
1342        );
1343    }
1344
1345    #[test]
1346    fn mark_seen_keeps_two_target_sizes_of_one_id_live_in_the_same_frame() {
1347        // The regression the old frame-scoped eviction policy needed a
1348        // dedicated case for: one program (id 1) drawn at two distinct
1349        // (quantized) sizes in the same frame. Both keys are marked seen, so
1350        // neither ages — true by construction under the age-based policy,
1351        // not by a frame-scoped special case.
1352        let mut fx = ShaderEffects::new(None);
1353        let ids: HashSet<u64> = [1].into_iter().collect();
1354        let keys: HashSet<(u64, u32, u32)> = [(1, 256, 256), (1, 512, 512)].into_iter().collect();
1355
1356        fx.mark_seen(&ids, &keys);
1357
1358        assert!(fx.target_last_seen.contains_key(&(1, 256, 256)));
1359        assert!(fx.target_last_seen.contains_key(&(1, 512, 512)));
1360    }
1361
1362    #[test]
1363    fn reap_clears_failed_and_last_seen_so_a_redrawn_id_recompiles_cleanly() {
1364        let mut fx = ShaderEffects::new(None);
1365        // Program 1 failed to compile once, and was seen at some prior frame.
1366        fx.record_failed(1, "boom");
1367        fx.last_seen.insert(1, 3);
1368        assert!(!fx.needs_compile(1), "a failed id is skipped, not retried");
1369
1370        fx.reap(&[1]);
1371
1372        assert!(!fx.failed.contains(&1));
1373        assert!(!fx.last_seen.contains_key(&1));
1374        assert!(
1375            fx.needs_compile(1),
1376            "a reaped id must be eligible to recompile cleanly, not stuck in `failed`"
1377        );
1378    }
1379
1380    #[test]
1381    fn reap_only_touches_the_stale_ids_given() {
1382        let mut fx = ShaderEffects::new(None);
1383        fx.record_failed(1, "boom");
1384        fx.record_failed(2, "boom");
1385        fx.last_seen.insert(1, 1);
1386        fx.last_seen.insert(2, 1);
1387
1388        fx.reap(&[1]);
1389
1390        assert!(!fx.failed.contains(&1));
1391        assert!(
1392            fx.failed.contains(&2),
1393            "id 2 was not in stale_ids, must survive"
1394        );
1395        assert!(!fx.last_seen.contains_key(&1));
1396        assert!(fx.last_seen.contains_key(&2));
1397    }
1398
1399    #[test]
1400    fn reap_drops_the_stale_ids_target_last_seen_rows_but_not_others() {
1401        let mut fx = ShaderEffects::new(None);
1402        fx.target_last_seen.insert((1, 256, 256), 5);
1403        fx.target_last_seen.insert((2, 256, 256), 5);
1404
1405        fx.reap(&[1]);
1406
1407        assert!(!fx.target_last_seen.contains_key(&(1, 256, 256)));
1408        assert!(fx.target_last_seen.contains_key(&(2, 256, 256)));
1409    }
1410
1411    /// End-to-end (real device) confirmation that a vanished id's *actual*
1412    /// compiled pipeline and offscreen target — not just the pure key-selection
1413    /// logic above — are dropped by `mark_seen`/`reap`, and that the id
1414    /// recompiles cleanly if redrawn afterward. The pure-logic tests above
1415    /// (`reapable_ids`/`reapable_target_keys`/`reapable_target_ages`/
1416    /// `mark_seen`/`reap`) cover the policy without a device; this covers the
1417    /// real `PipelineEntry`/`TargetEntry` removal a `wgpu::Device` requires to
1418    /// construct at all. Quantization + target-level aging get their own
1419    /// device test below (`a_resize_within_one_quantum_reuses_the_same_target_and_ages_out_once_unseen`).
1420    #[test]
1421    #[ignore = "requires a GPU; run locally with `cargo test -p frust-gpu -- --ignored`"]
1422    fn reap_drops_a_vanished_ids_real_pipeline_and_target() {
1423        pollster::block_on(run());
1424
1425        async fn run() {
1426            let instance = wgpu::Instance::new(
1427                wgpu::InstanceDescriptor::new_without_display_handle_from_env(),
1428            );
1429            let adapter = instance
1430                .request_adapter(&wgpu::RequestAdapterOptions::default())
1431                .await
1432                .expect("no compatible GPU adapter");
1433            let (device, _queue) = adapter
1434                .request_device(&wgpu::DeviceDescriptor {
1435                    label: Some("frust-gpu effects reap test"),
1436                    required_features: wgpu::Features::empty(),
1437                    required_limits: wgpu::Limits::default(),
1438                    ..Default::default()
1439                })
1440                .await
1441                .expect("failed to create device");
1442
1443            const FRAGMENT: &str = "@fragment fn fs_main(in: FrustVsOut) -> \
1444                @location(0) vec4<f32> { return vec4<f32>(frust_u.time, 0.0, 0.0, 1.0); }";
1445
1446            let mut fx = ShaderEffects::new(None);
1447            // With no target created for `(id, w, h)` (never `ensure_target`ed,
1448            // or a failed compile), the draw seam hands back `None` rather
1449            // than fabricating a texture.
1450            assert!(fx.target_texture(&device, 1, 4, 4).is_none());
1451
1452            assert!(
1453                fx.mark_seen(&[1].into_iter().collect(), &HashSet::new())
1454                    .is_empty()
1455            );
1456            fx.ensure_pipeline(&device, 1, FRAGMENT);
1457            fx.ensure_target(&device, 1, 4, 4);
1458            assert!(!fx.needs_compile(1), "compile must have succeeded");
1459            assert!(fx.pipelines.contains_key(&1));
1460            assert!(fx.target_texture(&device, 1, 4, 4).is_some());
1461
1462            // id 1 stops being drawn: mark_seen with an empty live set every
1463            // frame until its age crosses MAX_UNSEEN_FRAMES.
1464            let mut reapable = Vec::new();
1465            for _ in 0..MAX_UNSEEN_FRAMES {
1466                reapable = fx.mark_seen(&HashSet::new(), &HashSet::new());
1467            }
1468            assert_eq!(reapable, vec![1]);
1469            fx.reap(&reapable);
1470
1471            assert!(
1472                !fx.pipelines.contains_key(&1),
1473                "reap must drop the real compiled pipeline"
1474            );
1475            assert!(
1476                fx.target_texture(&device, 1, 4, 4).is_none(),
1477                "reap must drop the real offscreen target"
1478            );
1479            assert!(fx.needs_compile(1));
1480
1481            // Re-drawn: recompiles cleanly, with no stale `failed` skip.
1482            assert!(
1483                fx.mark_seen(&[1].into_iter().collect(), &HashSet::new())
1484                    .is_empty()
1485            );
1486            fx.ensure_pipeline(&device, 1, FRAGMENT);
1487            assert!(
1488                !fx.needs_compile(1),
1489                "a reaped id must recompile cleanly when redrawn"
1490            );
1491            assert!(fx.pipelines.contains_key(&1));
1492        }
1493    }
1494
1495    /// End-to-end (real device) confirmation of the quantization + target-level
1496    /// aging fix: a resize drag that never crosses a 256px quantum boundary
1497    /// reuses one texture, crossing one mints a new one, and a size resized
1498    /// away from ages out on its own clock while the program id itself (and
1499    /// its still-live size) stay untouched.
1500    #[test]
1501    #[ignore = "requires a GPU; run locally with `cargo test -p frust-gpu -- --ignored`"]
1502    fn a_resize_within_one_quantum_reuses_the_same_target_and_ages_out_once_unseen() {
1503        pollster::block_on(run());
1504
1505        async fn run() {
1506            let instance = wgpu::Instance::new(
1507                wgpu::InstanceDescriptor::new_without_display_handle_from_env(),
1508            );
1509            let adapter = instance
1510                .request_adapter(&wgpu::RequestAdapterOptions::default())
1511                .await
1512                .expect("no compatible GPU adapter");
1513            let (device, _queue) = adapter
1514                .request_device(&wgpu::DeviceDescriptor {
1515                    label: Some("frust-gpu effects quantization test"),
1516                    required_features: wgpu::Features::empty(),
1517                    required_limits: wgpu::Limits::default(),
1518                    ..Default::default()
1519                })
1520                .await
1521                .expect("failed to create device");
1522
1523            const FRAGMENT: &str = "@fragment fn fs_main(in: FrustVsOut) -> \
1524                @location(0) vec4<f32> { return vec4<f32>(frust_u.time, 0.0, 0.0, 1.0); }";
1525
1526            let mut fx = ShaderEffects::new(None);
1527            fx.ensure_pipeline(&device, 1, FRAGMENT);
1528
1529            // A resize drag through a range that never crosses a 256px
1530            // quantum boundary: every request lands on the same target.
1531            fx.ensure_target(&device, 1, 4, 4);
1532            let first = fx.target_texture(&device, 1, 4, 4).expect("created") as *const _;
1533            for size in [5u32, 64, 128, 200, 250] {
1534                fx.ensure_target(&device, 1, size, size);
1535                let texture = fx.target_texture(&device, 1, size, size).expect("reused");
1536                assert_eq!(
1537                    texture as *const _, first,
1538                    "size {size} must reuse the 256x256 target"
1539                );
1540            }
1541
1542            // Crossing the boundary mints a distinct target.
1543            fx.ensure_target(&device, 1, 300, 300);
1544            let second = fx
1545                .target_texture(&device, 1, 300, 300)
1546                .expect("created past the quantum");
1547            assert_ne!(
1548                second as *const _, first,
1549                "a quantum crossing must create a new target"
1550            );
1551
1552            // Only 300x300 (quantized to 512x512) is demanded from here on;
1553            // the small (256x256) target ages out on the target-level clock
1554            // while id 1 stays live and compiled throughout.
1555            let live_large: HashSet<(u64, u32, u32)> =
1556                [(1u64, 512u32, 512u32)].into_iter().collect();
1557            for _ in 0..MAX_UNSEEN_TARGET_FRAMES {
1558                fx.mark_seen(&[1].into_iter().collect(), &live_large);
1559            }
1560            assert!(
1561                fx.target_texture(&device, 1, 4, 4).is_none(),
1562                "the small target ages out once unseen"
1563            );
1564            assert!(
1565                fx.target_texture(&device, 1, 300, 300).is_some(),
1566                "the still-live size survives"
1567            );
1568            assert!(
1569                fx.pipelines.contains_key(&1),
1570                "the id itself is untouched by target-level aging"
1571            );
1572        }
1573    }
1574
1575    /// End-to-end (real device) confirmation of `ensure_target`'s own
1576    /// device-validity clamp: a request larger than the device's real
1577    /// `max_texture_dimension_2d` must create a texture at that ceiling
1578    /// instead of tripping `wgpu` validation. `required_limits` downgrades the
1579    /// device's ceiling to a small, fast-to-allocate value so the oversized
1580    /// request stays cheap while still exercising the real clamp against a
1581    /// real device.
1582    #[test]
1583    #[ignore = "requires a GPU; run locally with `cargo test -p frust-gpu -- --ignored`"]
1584    fn ensure_target_clamps_an_oversized_request_to_the_device_ceiling() {
1585        pollster::block_on(run());
1586
1587        async fn run() {
1588            let instance = wgpu::Instance::new(
1589                wgpu::InstanceDescriptor::new_without_display_handle_from_env(),
1590            );
1591            let adapter = instance
1592                .request_adapter(&wgpu::RequestAdapterOptions::default())
1593                .await
1594                .expect("no compatible GPU adapter");
1595            const CEILING: u32 = 256;
1596            let (device, _queue) = adapter
1597                .request_device(&wgpu::DeviceDescriptor {
1598                    label: Some("frust-gpu effects ensure_target clamp test"),
1599                    required_features: wgpu::Features::empty(),
1600                    required_limits: wgpu::Limits {
1601                        max_texture_dimension_2d: CEILING,
1602                        ..wgpu::Limits::default()
1603                    },
1604                    ..Default::default()
1605                })
1606                .await
1607                .expect("failed to create device");
1608            assert_eq!(device.limits().max_texture_dimension_2d, CEILING);
1609
1610            const FRAGMENT: &str = "@fragment fn fs_main(in: FrustVsOut) -> \
1611                @location(0) vec4<f32> { return vec4<f32>(frust_u.time, 0.0, 0.0, 1.0); }";
1612
1613            let mut fx = ShaderEffects::new(None);
1614            fx.ensure_pipeline(&device, 1, FRAGMENT);
1615            assert!(!fx.needs_compile(1), "compile must have succeeded");
1616
1617            // Requested far past the device's ceiling: creating a texture
1618            // this size without clamping would trip wgpu validation rather
1619            // than produce a usable target.
1620            let requested = CEILING * 4;
1621            fx.ensure_target(&device, 1, requested, requested);
1622            let texture = fx
1623                .target_texture(&device, 1, requested, requested)
1624                .expect("an oversized request must still create a (clamped) target");
1625            assert_eq!(texture.width(), CEILING);
1626            assert_eq!(texture.height(), CEILING);
1627        }
1628    }
1629
1630    /// Prove that extent coherence holds under quantization: an oversized
1631    /// request still creates a ceiling-sized texture, `target_extent` answers
1632    /// with the (also ceiling-clamped, here) sub-rect actually rendered, and
1633    /// repeating the same request reuses the existing entry (no second
1634    /// texture creation). Also proves the clamp warning fires only once per
1635    /// distinct `(id, requested_extent)` pair, survives the target's own
1636    /// age-based reap, and is dropped only by a whole-id `reap`.
1637    #[test]
1638    #[ignore = "requires a GPU; run locally with `cargo test -p frust-gpu -- --ignored`"]
1639    fn ensure_target_extent_coherence_oversized_request() {
1640        pollster::block_on(run());
1641
1642        async fn run() {
1643            let instance = wgpu::Instance::new(
1644                wgpu::InstanceDescriptor::new_without_display_handle_from_env(),
1645            );
1646            let adapter = instance
1647                .request_adapter(&wgpu::RequestAdapterOptions::default())
1648                .await
1649                .expect("no compatible GPU adapter");
1650            const CEILING: u32 = 512;
1651            let (device, _queue) = adapter
1652                .request_device(&wgpu::DeviceDescriptor {
1653                    label: Some("frust-gpu effects extent coherence test"),
1654                    required_features: wgpu::Features::empty(),
1655                    required_limits: wgpu::Limits {
1656                        max_texture_dimension_2d: CEILING,
1657                        ..wgpu::Limits::default()
1658                    },
1659                    ..Default::default()
1660                })
1661                .await
1662                .expect("failed to create device");
1663            assert_eq!(device.limits().max_texture_dimension_2d, CEILING);
1664
1665            const FRAGMENT: &str = "@fragment fn fs_main(in: FrustVsOut) -> \
1666                @location(0) vec4<f32> { return vec4<f32>(frust_u.time, 0.0, 0.0, 1.0); }";
1667
1668            let mut fx = ShaderEffects::new(None);
1669            fx.ensure_pipeline(&device, 1, FRAGMENT);
1670            assert!(!fx.needs_compile(1), "compile must have succeeded");
1671
1672            // Request far past the device's ceiling.
1673            let requested = CEILING * 4;
1674            assert!(requested > CEILING, "sanity check: request is oversized");
1675            let quantized_key = (1u64, CEILING, CEILING);
1676
1677            // Ensure the target for the first time.
1678            fx.ensure_target(&device, 1, requested, requested);
1679            let entry_1 = fx
1680                .targets
1681                .get(&quantized_key)
1682                .expect("target must be created");
1683            assert_eq!(entry_1.used_w, CEILING, "used_w must match ceiling");
1684            assert_eq!(entry_1.used_h, CEILING, "used_h must match ceiling");
1685
1686            // The texture itself must have been created at the clamped extent.
1687            let texture_1 = fx
1688                .target_texture(&device, 1, requested, requested)
1689                .expect("texture must exist");
1690            assert_eq!(
1691                texture_1.width(),
1692                CEILING,
1693                "texture width must match clamped extent"
1694            );
1695            assert_eq!(
1696                texture_1.height(),
1697                CEILING,
1698                "texture height must match clamped extent"
1699            );
1700
1701            // Record the texture pointer to verify reuse.
1702            let texture_ptr_1 = texture_1 as *const _;
1703
1704            // Repeat the same oversized request and verify we reuse the entry.
1705            fx.ensure_target(&device, 1, requested, requested);
1706            let texture_2 = fx
1707                .target_texture(&device, 1, requested, requested)
1708                .expect("texture must still exist");
1709            let texture_ptr_2 = texture_2 as *const _;
1710
1711            assert_eq!(
1712                texture_ptr_1, texture_ptr_2,
1713                "repeated request must reuse the same texture"
1714            );
1715
1716            // `target_extent` answers the sub-rect a paint should map onto:
1717            // here the request is itself past the ceiling, so the sub-rect is
1718            // the ceiling too, agreeing with the texture's own size.
1719            let extent = fx
1720                .target_extent(&device, 1, requested, requested)
1721                .expect("registered");
1722            assert_eq!(extent, (CEILING, CEILING));
1723
1724            // Verify the warning latched: warned_clamped must contain the (id,
1725            // requested_w, requested_h) key, showing we warned once.
1726            assert!(
1727                fx.warned_clamped.contains(&(1, requested, requested)),
1728                "warned_clamped must track the oversized request"
1729            );
1730
1731            // Age the target out on its own clock (nothing demands id 1's
1732            // target at this size any more, though nothing else demands a
1733            // *different* size for it either — an empty live-key set every
1734            // frame is enough since target aging does not require the id to
1735            // vanish too).
1736            for _ in 0..MAX_UNSEEN_TARGET_FRAMES {
1737                fx.mark_seen(&HashSet::new(), &HashSet::new());
1738            }
1739            assert!(
1740                !fx.targets.contains_key(&quantized_key),
1741                "the unseen target ages out"
1742            );
1743            assert!(
1744                fx.warned_clamped.contains(&(1, requested, requested)),
1745                "the warn latch survives target-level aging"
1746            );
1747
1748            // Recreate the same oversized request: the latch prevents a
1749            // second warning while it is still set.
1750            fx.ensure_target(&device, 1, requested, requested);
1751            let entry_3 = fx.targets.get(&quantized_key).expect("recreated");
1752            assert_eq!(entry_3.used_w, CEILING, "recreation re-clamps");
1753            assert!(
1754                fx.warned_clamped.contains(&(1, requested, requested)),
1755                "recreation must not disturb the latch"
1756            );
1757
1758            // `reap` is the opposite contract: the id leaves with all of its
1759            // state, latch included, so a reaped id that returns is brand new
1760            // for warning purposes too — and this is what bounds the set.
1761            fx.reap(&[1]);
1762            assert!(
1763                !fx.warned_clamped.contains(&(1, requested, requested)),
1764                "reap must drop the id's warn-latch entries"
1765            );
1766        }
1767    }
1768
1769    /// End-to-end (real device) confirmation of the target-identity fix: a
1770    /// target's generation stays put across a cache hit (the common,
1771    /// steady-state `ensure_target` call), but strictly increases when a key
1772    /// is genuinely recreated after being reaped — the signal
1773    /// `frust_engine::effects::shader_quad::ShaderQuadPass::register` needs
1774    /// to tell a frozen, orphaned old target apart from a fresh one at the
1775    /// same requested extent (see the module header's "Target identity"
1776    /// section and [`Self::target_generation`]'s own doc).
1777    #[test]
1778    #[ignore = "requires a GPU; run locally with `cargo test -p frust-gpu -- --ignored`"]
1779    fn target_generation_bumps_only_when_a_target_is_actually_recreated() {
1780        pollster::block_on(run());
1781
1782        async fn run() {
1783            let instance = wgpu::Instance::new(
1784                wgpu::InstanceDescriptor::new_without_display_handle_from_env(),
1785            );
1786            let adapter = instance
1787                .request_adapter(&wgpu::RequestAdapterOptions::default())
1788                .await
1789                .expect("no compatible GPU adapter");
1790            let (device, _queue) = adapter
1791                .request_device(&wgpu::DeviceDescriptor {
1792                    label: Some("frust-gpu effects target-generation test"),
1793                    required_features: wgpu::Features::empty(),
1794                    required_limits: wgpu::Limits::default(),
1795                    ..Default::default()
1796                })
1797                .await
1798                .expect("failed to create device");
1799
1800            const FRAGMENT: &str = "@fragment fn fs_main(in: FrustVsOut) -> \
1801                @location(0) vec4<f32> { return vec4<f32>(frust_u.time, 0.0, 0.0, 1.0); }";
1802
1803            let mut fx = ShaderEffects::new(None);
1804            fx.ensure_pipeline(&device, 1, FRAGMENT);
1805
1806            fx.ensure_target(&device, 1, 4, 4);
1807            let first_generation = fx
1808                .target_generation(&device, 1, 4, 4)
1809                .expect("created target has a generation");
1810
1811            // A cache hit (the same key already resident) must not bump it.
1812            fx.ensure_target(&device, 1, 4, 4);
1813            assert_eq!(
1814                fx.target_generation(&device, 1, 4, 4),
1815                Some(first_generation),
1816                "a cache hit leaves the generation untouched"
1817            );
1818
1819            // Force the target out (as a whole-id reap would) and recreate it
1820            // at the identical requested extent.
1821            fx.reap(&[1]);
1822            fx.ensure_pipeline(&device, 1, FRAGMENT);
1823            fx.ensure_target(&device, 1, 4, 4);
1824            let second_generation = fx
1825                .target_generation(&device, 1, 4, 4)
1826                .expect("recreated target has a generation");
1827
1828            assert!(
1829                second_generation > first_generation,
1830                "a target recreated at the same extent must carry a strictly \
1831                 greater generation ({second_generation} was not > {first_generation})"
1832            );
1833        }
1834    }
1835}