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}