Skip to main content

frust_render/
external_pass.rs

1//! The seam an app — or a crate sitting beside the facade, a 3D renderer
2//! being the motivating one — records its own GPU work through, ahead of the
3//! engine's scene pass and into the engine's own frame encoder.
4//!
5//! # What this closes
6//!
7//! `frust_engine::EngineRenderer` has always been able to draw a texture it
8//! did not create: `bind_texture` registers a `wgpu::TextureView` under a
9//! [`SceneTextureId`], and a display list's
10//! `Command::SceneTexture`/`SceneBuilder::scene_texture` naming that id
11//! composites it. Nothing outside `frust-engine` could reach that, though —
12//! [`crate::SurfaceRenderer`] owns the engine, and the frame's
13//! `wgpu::CommandEncoder` is created and submitted inside one call — so the
14//! only in-tree user of the registry was the engine's own shader-quad
15//! pre-pass. This module is the reachable half: a process-wide registry of
16//! caller-supplied [`ExternalPass`]es, each handed the live frame
17//! ([`ExternalFrame`]) once per frame, before anything of the scene's own is
18//! recorded.
19//!
20//! # The frame a pass is handed
21//!
22//! [`ExternalPass::record`] runs on the render thread, inside the renderer's
23//! `submit`, into the *same* `wgpu::CommandEncoder` the scene pass is about
24//! to be recorded into and ahead of the engine's own shader-quad pre-pass.
25//! That single-encoder ordering is the whole point, and it is the same one
26//! the shader-quad pass relies on: a texture bound during `record` is already
27//! registered when the display list naming it is compiled, and the passes
28//! that write it are already recorded ahead of the pass that samples it. One
29//! encoder gives both orderings for free, with no second submit and no fence.
30//!
31//! A pass records only — it never submits. That is [`crate::SurfaceRenderer`]'s
32//! job, once, at the end of the frame; a pass that submits the frame's encoder
33//! cannot (the encoder is only borrowed) and one that submits work of its own
34//! on its own encoder breaks the ordering it came here for. A pass never
35//! shares the frame's own depth attachment — it renders into attachments it
36//! owns, on a target it owns, sized however that target needs to be.
37//! `frust_gpu::encoder`'s two depth caller rules (clear ownership, and
38//! matching comparison/direction) are for a *host* sharing one depth buffer
39//! across renderers of its own; they have nothing to do with a pass
40//! registered here, which the frame's depth attachment is never handed to.
41//!
42//! # Binding, and what the engine does with it
43//!
44//! [`ExternalFrame::bind_texture`] forwards to the engine's own registry, so
45//! a pass renders into a target *it* owns and then hands the engine a view of
46//! it. Re-binding the same id replaces the previous view (the engine's own
47//! semantics), which is what lets a pass that re-creates its target on resize
48//! keep one stable id. The composited result is always *blended*, never
49//! claimed opaque — the engine never reads the caller's texels, so it cannot
50//! know (`docs/LIMITATIONS.md`'s `engine-scene-texture-always-blended`).
51//!
52//! Binding happens inside `record`, immediately — it does not wait for the
53//! frame to actually submit. So work a pass recorded is submitted only if the
54//! frame is (a refused frame's encoder is dropped unsubmitted, taking every
55//! command a pass recorded with it), but the bind side effect already landed
56//! in the engine's registry and survives the refusal regardless: the next
57//! *accepted* frame composites whatever view was bound, whether or not the
58//! work that was meant to fill it ever ran.
59//!
60//! On a hand-over (one pass unregisters and a successor registers under the
61//! same id before the drain finishes), the queued unbind is NOT cancelled:
62//! it runs at the next drain before any pass, so an id between owners draws
63//! nothing until its new owner binds a view.
64//!
65//! # Lifetime of a registration
66//!
67//! A pass persists until [`unregister_external_pass`] takes it: the registry
68//! is iterated every frame, never drained. Unregistering queues the id for an
69//! `unbind_texture` the next drain performs *before* it runs any pass, so the
70//! engine never keeps a view alive for an id whose owner is gone. Registering
71//! the same id again before that flush does NOT cancel the queued unbind —
72//! the id has an owner again, and the incoming pass binds whatever it wants,
73//! but an id between owners draws nothing until its new owner records a
74//! binding.
75//!
76//! Unregistering is not a barrier. It removes the pass from the registry
77//! under the lock, but a drain that already snapshotted the pass before that
78//! call runs is still mid-flight against its own copy of the `Arc` and will
79//! still call `record` on it once more — no drain that *starts* after
80//! [`unregister_external_pass`] returns ever will. The unbind itself is
81//! queued, not immediate: it lands on the next frame something actually
82//! drains, so it does not happen while the surface presenting it is idle — a
83//! caller that needs the binding gone right now, rather than whenever the
84//! surface next presents, has to make sure a frame gets requested. The queue
85//! it lands in is a map keyed by id, so its size is bounded by the number of
86//! *distinct* ids unregistered since the last drain, never by how many times
87//! any one of them is unregistered.
88//!
89//! # A panicking pass does not take the frame down
90//!
91//! `record` is called inside `catch_unwind`. A pass that panics is reported
92//! (`warn!` on the first panic under an id with a given Arc identity, `debug!`
93//! afterwards) and, *if* the id it panicked under still names the same pass
94//! (nothing else registered under it in the meantime — a pass handing its id
95//! to a successor inside its own `record` before panicking leaves that
96//! successor alone), removed from the registry and queued for the same unbind
97//! an explicit unregistration queues. If a successor already holds the id, the
98//! panic is still reported as a debug message saying the predecessor panicked
99//! after handing over. Either way the frame goes on to record the scene.
100//! This is the same no-panic posture the engine holds on the FFI boundary.
101//! It is a debug/dev net rather than a promise: the workspace's `release`
102//! profile is `panic = "abort"`, where the process is gone before any guard
103//! runs, so the shipped contract is still that a pass must not panic.
104//!
105//! # Who reaches this
106//!
107//! The registry itself is shell-agnostic — a plain process-wide map, with no
108//! device, window or platform in it — but the drain lives on the engine
109//! tier's frame path (`SurfaceRenderer`'s `TierBackend::Engine` arm), so a
110//! registered pass records exactly where an engine-tier surface is presenting
111//! frames and nowhere else. `HeadlessRenderer` drives the engine through its
112//! own frame path and does not drain this registry.
113//!
114//! One more consequence of a *process-wide* registry meeting a *per-surface*
115//! engine: with two engine-tier surfaces live at once, every pass records
116//! into both frames (binding into each surface's own engine, which is what a
117//! caller wants), but a queued unbind is consumed by whichever surface drains
118//! first. One engine-tier surface per process is what every shell does today.
119
120use std::collections::BTreeMap;
121use std::panic::{AssertUnwindSafe, catch_unwind};
122use std::sync::atomic::{AtomicU64, Ordering};
123use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
124
125use frust_gpu::SceneTextureId;
126
127/// Caller-supplied GPU work recorded into the frame ahead of the scene.
128///
129/// Registered with [`register_external_pass`] under a [`SceneTextureId`] the
130/// caller minted (`SceneTextureId::mint`, or `Texture::as_scene_texture` when
131/// it has a `frust_gpu::Texture` to mint from), and called once per frame
132/// with that frame's live [`ExternalFrame`] until
133/// [`unregister_external_pass`] takes it back.
134///
135/// `Send + Sync` because the registry is process-wide and the call happens on
136/// the render thread, which is not the thread that registered the pass on any
137/// shell that splits the two — and, with two engine-tier surfaces live in one
138/// process at once (each presenting through its own render thread), the same
139/// pass can be called by both at the same time: `record` genuinely may run
140/// concurrently on two threads, not merely sequentially from a changing one.
141/// `record` takes `&self`, so a pass mutating state across frames owns its
142/// own interior mutability, and that interior mutability has to tolerate the
143/// concurrent case, not just the sequential one.
144pub trait ExternalPass: Send + Sync {
145    /// Records this pass's work for one frame.
146    ///
147    /// Record passes and staged uploads only — never a submit, and never a
148    /// `wgpu::RenderPass` left open across the return (see
149    /// `frust_gpu::encoder`'s borrowing contract, which the frame's encoder
150    /// is under for exactly the same reason). The shared encoder must be left
151    /// finishable on every exit path: no open render pass AND balanced
152    /// `push_debug_group`/`pop_debug_group` calls (an unbalanced group makes
153    /// `encoder.finish()` fail with wgpu's MissingPop and loses the frame;
154    /// the pass is retired so it self-heals next frame). Bind whatever this
155    /// frame should composite through [`ExternalFrame::bind_texture`]; the
156    /// engine draws it wherever the frame's display list names the id.
157    fn record(&self, frame: &mut ExternalFrame<'_>);
158}
159
160/// The live frame handed to [`ExternalPass::record`]: the device and queue
161/// behind it, the encoder every pass of this frame is recorded into, and the
162/// engine's texture registry to bind results into.
163///
164/// Borrowed for the duration of one `record` call and never longer — nothing
165/// here can be stashed across frames, which is what keeps a pass from
166/// out-living the surface whose device it was handed. `wgpu::Device` and
167/// `wgpu::Queue` are cheaply `Clone` (both `Arc`-backed underneath), so
168/// [`Self::device`]/[`Self::queue`] returning a borrow does not itself stop a
169/// pass from cloning one and stashing the owned handle past this call —
170/// doing that is a contract violation regardless of whether the borrow
171/// checker catches it: the shell that owns the real device may drop and
172/// recreate it (surface loss, a GPU reset), and a clone a pass kept past
173/// that point references a device that looks alive but is not the one
174/// backing any future frame, so GPU work built against it fails or panics
175/// rather than silently rebinding to the shell's current one.
176///
177/// Built fresh for *each* pass a drain runs, scoped to that one pass's own
178/// registered id ([`Self::id`]) — a pass never sees another pass's id, and
179/// [`Self::bind_texture`]/[`Self::unbind_texture`] act only on its own.
180pub struct ExternalFrame<'a> {
181    device: &'a wgpu::Device,
182    queue: &'a wgpu::Queue,
183    encoder: &'a mut wgpu::CommandEncoder,
184    /// The engine whose external-texture registry
185    /// [`Self::bind_texture`]/[`Self::unbind_texture`] write. Deliberately
186    /// not exposed: the engine's own frame API (`encode`, `end_frame`,
187    /// `resize`) belongs to the renderer driving it, and a pass reaching it
188    /// would be recording a second frame inside this one.
189    engine: &'a mut frust_engine::EngineRenderer,
190    /// The id the pass being recorded this call is registered under — what
191    /// [`Self::id`]/[`Self::bind_texture`]/[`Self::unbind_texture`] act on.
192    id: SceneTextureId,
193    frame_index: u64,
194}
195
196impl<'a> ExternalFrame<'a> {
197    /// Wraps one pass's resources for one frame. Crate-private: an
198    /// `ExternalFrame` is only ever built by [`run_external_passes`], fresh
199    /// per pass, around a real in-flight frame.
200    pub(crate) fn new(
201        device: &'a wgpu::Device,
202        queue: &'a wgpu::Queue,
203        encoder: &'a mut wgpu::CommandEncoder,
204        engine: &'a mut frust_engine::EngineRenderer,
205        id: SceneTextureId,
206        frame_index: u64,
207    ) -> Self {
208        Self {
209            device,
210            queue,
211            encoder,
212            engine,
213            id,
214            frame_index,
215        }
216    }
217
218    /// The device this frame is being recorded on — the shell's own live
219    /// device, not a second one.
220    #[must_use]
221    pub fn device(&self) -> &wgpu::Device {
222        self.device
223    }
224
225    /// The queue the frame will be submitted on, for staged writes
226    /// (`write_texture`, `write_buffer`) a pass needs committed before the
227    /// frame reads them.
228    #[must_use]
229    pub fn queue(&self) -> &wgpu::Queue {
230        self.queue
231    }
232
233    /// The frame's own encoder — the one the scene pass is recorded into
234    /// after every pass has run, and the one the renderer submits once.
235    pub fn encoder(&mut self) -> &mut wgpu::CommandEncoder {
236        self.encoder
237    }
238
239    /// The id the pass being recorded this call is registered under —
240    /// what [`Self::bind_texture`]/[`Self::unbind_texture`] act on.
241    #[must_use]
242    pub fn id(&self) -> SceneTextureId {
243        self.id
244    }
245
246    /// How many frames this process has drained passes in, counting from
247    /// zero.
248    ///
249    /// Advances once per drain that has at least one pass to run, so a pass
250    /// animating off it sees a dense sequence rather than one with the
251    /// pass-free frames of other surfaces punched out of it. It is a frame
252    /// *counter*, not a clock: a pass needing wall-clock time reads one.
253    #[must_use]
254    pub fn frame_index(&self) -> u64 {
255        self.frame_index
256    }
257
258    /// Registers `view` under this pass's own id ([`Self::id`]) so this
259    /// frame's `Command::SceneTexture` naming that id composites it,
260    /// returning whatever was bound under it before.
261    ///
262    /// `size` is the view's extent in texels — the rectangle the display
263    /// list's destination is mapped onto. It is a pass's own choice, read
264    /// however its owner reads layout (a widget's own size, typically); this
265    /// type carries no target extent of its own to hand back. `view` must be
266    /// a non-array 2D view of a float-sampleable texture carrying
267    /// `wgpu::TextureUsages::TEXTURE_BINDING`. A zero extent, or one past
268    /// `u16::MAX` on either axis, registers nothing and leaves scenes naming
269    /// the id drawing nothing; so does an id nothing ever bound. Re-binding
270    /// replaces the previous view.
271    pub fn bind_texture(
272        &mut self,
273        size: (u32, u32),
274        view: wgpu::TextureView,
275    ) -> Option<wgpu::TextureView> {
276        self.engine.bind_texture(self.id, size, view)
277    }
278
279    /// Whether the engine this frame is recorded for currently holds a view
280    /// under this pass's own id ([`Self::id`]).
281    ///
282    /// A binding lives in the engine, not in the pass, and a shell builds a
283    /// fresh engine whenever it installs a fresh surface (an Android resume, a
284    /// surface-format change) on the very same device. A pass that binds only
285    /// when its own content changes reads this to notice that the binding it
286    /// made earlier is gone and bind its current view again.
287    #[must_use]
288    pub fn has_binding(&self) -> bool {
289        self.engine.bound_texture(self.id).is_some()
290    }
291
292    /// Removes the view bound under this pass's own id ([`Self::id`]),
293    /// returning it. A pass tearing its own target down mid-run does this
294    /// itself; a pass that simply stops being registered has it done for it
295    /// (see the module docs).
296    pub fn unbind_texture(&mut self) -> Option<wgpu::TextureView> {
297        self.engine.unbind_texture(self.id)
298    }
299}
300
301/// One registered pass, beside the id it was registered under.
302///
303/// The id rides along with the pass because the maps below are keyed by its
304/// raw `u64`: ordering the drain by that key is what makes the sequence
305/// passes are recorded in — they share one encoder — the same on every frame
306/// and every run, and a [`SceneTextureId`] is not itself ordered.
307struct Registration {
308    id: SceneTextureId,
309    pass: Arc<dyn ExternalPass>,
310}
311
312/// A panic-report slot for one pass, alongside the identity of the pass that
313/// panicked: store the Arc's address as a usize to recognize when the same
314/// pass panics again versus when a fresh pass with a different id panics.
315struct PanicReport {
316    identity: usize,
317}
318
319/// The process-wide registry: the live passes, the ids awaiting an unbind,
320/// and which ids have already had a panic or a reserved-namespace refusal
321/// reported.
322struct Registry {
323    passes: BTreeMap<u64, Registration>,
324    /// Ids whose pass is gone — unregistered, or removed after a panic — and
325    /// whose engine binding the next drain clears. Keyed by id, so this grows
326    /// only with the number of *distinct* ids queued since the last drain,
327    /// never with how many times any one of them is queued.
328    pending_unbind: BTreeMap<u64, SceneTextureId>,
329    /// Ids a panic has already been reported at `warn!` for, alongside the
330    /// identity (Arc pointer) of the pass that panicked. If the same pass id
331    /// panics again with the same Arc, it reports at `debug!` instead of
332    /// `warn!`. If a different Arc binds to the same id, the warn-once flag
333    /// is cleared and the new pass's first panic is a fresh `warn!` too.
334    reported_panics: BTreeMap<u64, PanicReport>,
335    /// Flag for whether a reserved shader-program namespace registration
336    /// attempt has already been reported at `warn!` level. Warn once
337    /// process-wide (the refusal reason is identical for every id), then
338    /// report at `debug!` for any further attempts.
339    reported_reserved: bool,
340    /// The same warn-once flag for the texture-source namespace, kept apart so
341    /// a caller tripping one reserved namespace still hears about the other.
342    reported_texture_source: bool,
343}
344
345impl Registry {
346    const fn new() -> Self {
347        Self {
348            passes: BTreeMap::new(),
349            pending_unbind: BTreeMap::new(),
350            reported_panics: BTreeMap::new(),
351            reported_reserved: false,
352            reported_texture_source: false,
353        }
354    }
355}
356
357static REGISTRY: Mutex<Registry> = Mutex::new(Registry::new());
358
359/// Frames drained so far — the source of [`ExternalFrame::frame_index`].
360static FRAME_INDEX: AtomicU64 = AtomicU64::new(0);
361
362/// The registry, with poison ignored: one pass's panic must not turn every
363/// later frame's drain into a panic of its own, which is the opposite of what
364/// the `catch_unwind` around `record` is for. (Nothing panics while the guard
365/// is held — `record` is called after it is dropped — so poison here would
366/// only ever be collateral.)
367fn registry() -> MutexGuard<'static, Registry> {
368    REGISTRY.lock().unwrap_or_else(PoisonError::into_inner)
369}
370
371/// Registers `pass` under `id`, to be recorded every frame until
372/// [`unregister_external_pass`] takes it back.
373///
374/// Answers `false` and changes nothing when `id` already has a pass: a
375/// registration is a claim on an id, and silently displacing another
376/// component's pass would leave it registered from its own point of view and
377/// never called. Unregister first to hand an id over deliberately.
378///
379/// Also answers `false` and changes nothing when `id` is inside a reserved
380/// namespace: the shader-program one (`SceneTextureId::is_shader_program`) —
381/// that range belongs to the engine's own offscreen shader-effect targets,
382/// and a caller-registered pass claiming one would silently steal a shader
383/// program's target out from under it — or the texture-source one
384/// (`SceneTextureId::is_texture_source`), which belongs to
385/// [`crate::texture_bus`]'s registrations and is reached only through
386/// [`crate::texture_bus::register_texture_source`]. Reported at `warn!` the
387/// first time a namespace is refused this way, `debug!` on every later
388/// attempt, so a caller retrying the same doomed registration every frame
389/// does not spam the log.
390///
391/// A `register` landing before the drain has flushed the same id's queued
392/// unbind cancels that unbind: the id has an owner again. A successful
393/// registration also clears any panic already reported for `id`, so a fresh
394/// pass's first panic is reported fresh rather than silently downgraded by
395/// a predecessor's history.
396pub fn register_external_pass(id: SceneTextureId, pass: Arc<dyn ExternalPass>) -> bool {
397    let raw = id.get();
398    if id.is_shader_program() {
399        let should_warn = {
400            let mut registry = registry();
401            let should_warn = !registry.reported_reserved;
402            if should_warn {
403                registry.reported_reserved = true;
404            }
405            should_warn
406        };
407        if should_warn {
408            log::warn!(
409                "external pass registration for scene texture {raw} refused: the id is inside \
410                 the reserved shader-program namespace (further attempts at this id are logged \
411                 at debug level)"
412            );
413        } else {
414            log::debug!(
415                "external pass registration for scene texture {raw} refused: reserved \
416                 shader-program namespace"
417            );
418        }
419        return false;
420    }
421    if id.is_texture_source() {
422        let should_warn = {
423            let mut registry = registry();
424            let should_warn = !registry.reported_texture_source;
425            if should_warn {
426                registry.reported_texture_source = true;
427            }
428            should_warn
429        };
430        if should_warn {
431            log::warn!(
432                "external pass registration for scene texture {raw} refused: the id is inside \
433                 the reserved texture-source namespace, which only the texture bus registers \
434                 (further attempts are logged at debug level)"
435            );
436        } else {
437            log::debug!(
438                "external pass registration for scene texture {raw} refused: reserved \
439                 texture-source namespace"
440            );
441        }
442        return false;
443    }
444    insert_pass(id, pass)
445}
446
447/// Registers a texture-bus pass under its own texture-source `id` — the one
448/// route into that reserved namespace, taken only by
449/// [`crate::texture_bus::register_texture_source`]. Same claim semantics as
450/// [`register_external_pass`] otherwise.
451pub(crate) fn register_texture_source_pass(
452    id: SceneTextureId,
453    pass: Arc<dyn ExternalPass>,
454) -> bool {
455    debug_assert!(
456        id.is_texture_source(),
457        "texture-bus ids carry their namespace"
458    );
459    insert_pass(id, pass)
460}
461
462/// The claim itself, past any namespace check: refuses an id that already has
463/// a pass, clears a predecessor's reported panic, and inserts.
464fn insert_pass(id: SceneTextureId, pass: Arc<dyn ExternalPass>) -> bool {
465    let raw = id.get();
466    let pass_identity = Arc::as_ptr(&pass) as *const () as usize;
467    let should_clear_panic_flag = {
468        let registry = registry();
469        if registry.passes.contains_key(&raw) {
470            return false;
471        }
472        registry
473            .reported_panics
474            .get(&raw)
475            .is_some_and(|report| report.identity != pass_identity)
476    };
477    if should_clear_panic_flag {
478        let mut registry = registry();
479        registry.reported_panics.remove(&raw);
480    }
481    let mut registry = registry();
482    registry.passes.insert(raw, Registration { id, pass });
483    true
484}
485
486/// Removes the pass registered under `id` and queues the engine binding it
487/// left behind for the next drain to clear, answering whether there was one.
488pub fn unregister_external_pass(id: SceneTextureId) -> bool {
489    let mut registry = registry();
490    if registry.passes.remove(&id.get()).is_none() {
491        return false;
492    }
493    registry.pending_unbind.insert(id.get(), id);
494    true
495}
496
497/// Runs one frame's external passes into `encoder`, after clearing the engine
498/// bindings of every id unregistered since the last run.
499///
500/// Called by [`crate::SurfaceRenderer`] on the engine tier, immediately
501/// before the shader-quad pre-pass and so before anything of the scene's own
502/// is recorded. Hidden from the crate's documented surface: it names the
503/// engine renderer and the frame's raw `wgpu` resources, which are the
504/// renderer's to hold, and it is reachable only so this crate's own GPU test
505/// can drive the identical code path against an engine of its own — a
506/// surface, and so `submit`, is not something a headless test can produce.
507///
508/// Cheap on a process that registered nothing: two `is_empty` checks under
509/// one uncontended lock, and no encoder work at all.
510#[doc(hidden)]
511pub fn run_external_passes(
512    device: &wgpu::Device,
513    queue: &wgpu::Queue,
514    encoder: &mut wgpu::CommandEncoder,
515    engine: &mut frust_engine::EngineRenderer,
516) {
517    // Snapshot under the lock and release it before any `record` runs: a pass
518    // is caller code, free to register or unregister another pass (its own
519    // included) from inside its `record`, which would deadlock against a held
520    // guard.
521    let (pending_unbind, passes) = {
522        let mut registry = registry();
523        if registry.passes.is_empty() && registry.pending_unbind.is_empty() {
524            return;
525        }
526        let pending_unbind = std::mem::take(&mut registry.pending_unbind);
527        let passes: Vec<(SceneTextureId, Arc<dyn ExternalPass>)> = registry
528            .passes
529            .values()
530            .map(|registration| (registration.id, Arc::clone(&registration.pass)))
531            .collect();
532        (pending_unbind, passes)
533    };
534
535    // Before any pass records: an id with no owner keeps no view alive.
536    for id in pending_unbind.into_values() {
537        engine.unbind_texture(id);
538    }
539    if passes.is_empty() {
540        return;
541    }
542
543    let frame_index = FRAME_INDEX.fetch_add(1, Ordering::Relaxed);
544    for (id, pass) in passes {
545        // Built fresh per pass, scoped to that pass's own id — `device`,
546        // `encoder` and `engine` are reborrowed each iteration rather than
547        // moved, so the outer `&mut` references stay usable for the next
548        // pass once this one's `ExternalFrame` goes out of scope.
549        let mut frame =
550            ExternalFrame::new(device, queue, &mut *encoder, &mut *engine, id, frame_index);
551        // A pass is caller code on the render thread: unwinding out of it
552        // would abandon the frame's encoder mid-recording and take the
553        // surface's whole frame loop with it.
554        if catch_unwind(AssertUnwindSafe(|| pass.record(&mut frame))).is_err() {
555            drop_panicking_pass(id, &pass);
556        }
557    }
558}
559
560/// Retires the pass registered under `id` after it panicked, *if* `pass` is
561/// still the one registered there: report it once, take it out of the
562/// registry, and queue its binding for the next drain to clear.
563///
564/// `pass` is the `Arc` [`run_external_passes`] snapshotted before calling
565/// `record` on it — compared against whatever is registered under `id` right
566/// now via [`Arc::ptr_eq`] rather than blindly removed by id. A pass is
567/// caller code, free to hand its own id to a successor (unregister, then
568/// register a replacement) from inside the very `record` call that then
569/// panics; retiring by id alone would tear that successor out along with the
570/// pass that actually failed, even though it was never in the unwind at all.
571/// The panic is still reported either way — it happened regardless of what
572/// is registered under `id` now — but the registry itself is only touched
573/// when the identity check confirms nothing has taken the id over since.
574fn drop_panicking_pass(id: SceneTextureId, pass: &Arc<dyn ExternalPass>) {
575    let raw = id.get();
576    let pass_identity = Arc::as_ptr(pass) as *const () as usize;
577
578    let (still_registered, should_warn, was_different_pass) = {
579        let mut registry = registry();
580        let registration = registry.passes.get(&raw);
581        let still_registered = registration.is_some_and(|r| Arc::ptr_eq(&r.pass, pass));
582
583        let should_warn = registry
584            .reported_panics
585            .get(&raw)
586            .is_none_or(|report| report.identity != pass_identity);
587
588        let was_different_pass = registration.is_some_and(|r| !Arc::ptr_eq(&r.pass, pass));
589
590        if still_registered {
591            registry.passes.remove(&raw);
592            registry.pending_unbind.insert(raw, id);
593        }
594
595        if should_warn {
596            registry.reported_panics.insert(
597                raw,
598                PanicReport {
599                    identity: pass_identity,
600                },
601            );
602        }
603
604        (still_registered, should_warn, was_different_pass)
605    };
606
607    if was_different_pass && !still_registered {
608        log::debug!(
609            "external pass for scene texture {raw} panicked after handing over; \
610             the successor stays registered"
611        );
612    } else if still_registered && should_warn {
613        log::warn!(
614            "external pass for scene texture {raw} panicked and was unregistered; \
615             the frame was recorded without it (further panics of this id are \
616             logged at debug level)"
617        );
618    } else if still_registered {
619        log::debug!("external pass for scene texture {raw} panicked and was unregistered");
620    }
621}
622
623#[cfg(test)]
624mod tests {
625    use super::*;
626
627    /// Serializes the cases: the registry is process-wide, so two cases
628    /// registering at once would see each other's passes. Poison is ignored
629    /// deliberately — one failing case must not cascade into every sibling.
630    static SERIAL: Mutex<()> = Mutex::new(());
631
632    fn serial() -> MutexGuard<'static, ()> {
633        SERIAL.lock().unwrap_or_else(PoisonError::into_inner)
634    }
635
636    /// A pass that records nothing — enough for every claim about the
637    /// registry itself, which never touches a GPU.
638    struct Inert;
639
640    impl ExternalPass for Inert {
641        fn record(&self, _frame: &mut ExternalFrame<'_>) {}
642    }
643
644    /// Snapshot of the registry's own state, since the drain is the only
645    /// thing that reads it and the drain needs a device.
646    fn state(id: SceneTextureId) -> (bool, bool) {
647        let registry = registry();
648        (
649            registry.passes.contains_key(&id.get()),
650            registry.pending_unbind.contains_key(&id.get()),
651        )
652    }
653
654    #[test]
655    fn registering_an_id_twice_keeps_the_first_pass() {
656        let _serial = serial();
657        let id = SceneTextureId::mint();
658
659        assert!(register_external_pass(id, Arc::new(Inert)));
660        assert!(
661            !register_external_pass(id, Arc::new(Inert)),
662            "a second registration must not displace the first"
663        );
664        assert_eq!(state(id), (true, false));
665
666        assert!(unregister_external_pass(id));
667        assert_eq!(
668            state(id),
669            (false, true),
670            "unregistering queues the engine binding for the next drain"
671        );
672    }
673
674    #[test]
675    fn a_texture_source_id_is_refused_to_an_ordinary_pass_but_open_to_the_bus() {
676        let _serial = serial();
677        let id = SceneTextureId::for_texture_source(SceneTextureId::mint().get());
678
679        assert!(
680            !register_external_pass(id, Arc::new(Inert)),
681            "the texture-source namespace belongs to the bus"
682        );
683        assert_eq!(state(id), (false, false));
684        assert!(registry().reported_texture_source);
685
686        assert!(
687            register_texture_source_pass(id, Arc::new(Inert)),
688            "the bus's own route claims the id"
689        );
690        assert!(
691            !register_texture_source_pass(id, Arc::new(Inert)),
692            "a claimed id is still a claim on the bus's route"
693        );
694        assert!(unregister_external_pass(id));
695        registry().pending_unbind.remove(&id.get());
696    }
697
698    #[test]
699    fn unregistering_an_unregistered_id_answers_false() {
700        let _serial = serial();
701        let id = SceneTextureId::mint();
702
703        assert!(!unregister_external_pass(id));
704        assert_eq!(
705            state(id),
706            (false, false),
707            "an id nothing registered queues no unbind"
708        );
709    }
710
711    #[test]
712    fn re_registering_before_the_drain_queues_an_unbind_first() {
713        let _serial = serial();
714        let id = SceneTextureId::mint();
715
716        assert!(register_external_pass(id, Arc::new(Inert)));
717        assert!(unregister_external_pass(id));
718        assert!(register_external_pass(id, Arc::new(Inert)));
719        assert_eq!(
720            state(id),
721            (true, true),
722            "handing an id over leaves a queued unbind (the id draws nothing until the new owner binds)"
723        );
724
725        assert!(unregister_external_pass(id));
726    }
727
728    #[test]
729    fn a_panicking_pass_is_retired_and_queued_for_unbind() {
730        let _serial = serial();
731        let id = SceneTextureId::mint();
732        let pass: Arc<dyn ExternalPass> = Arc::new(Inert);
733
734        assert!(register_external_pass(id, Arc::clone(&pass)));
735        drop_panicking_pass(id, &pass);
736        assert_eq!(
737            state(id),
738            (false, true),
739            "a panicking pass leaves the registry exactly as an unregistered one does"
740        );
741
742        // The second retirement reports at debug rather than warn; what is
743        // checked here is that reporting twice is not itself a panic and
744        // leaves the state alone.
745        drop_panicking_pass(id, &pass);
746        assert_eq!(state(id), (false, true));
747
748        registry().pending_unbind.remove(&id.get());
749    }
750
751    #[test]
752    fn retiring_a_panic_leaves_a_successor_registered_under_the_same_id_alone() {
753        let _serial = serial();
754        let id = SceneTextureId::mint();
755        let original: Arc<dyn ExternalPass> = Arc::new(Inert);
756        assert!(register_external_pass(id, Arc::clone(&original)));
757
758        // What a pass handing its own id to a replacement inside `record`
759        // does before it panics: unregister, then register the successor —
760        // both while the drain's snapshot still holds `original`.
761        assert!(unregister_external_pass(id));
762        let successor: Arc<dyn ExternalPass> = Arc::new(Inert);
763        assert!(register_external_pass(id, Arc::clone(&successor)));
764
765        // The drain retires by comparing the snapshot it took (`original`)
766        // against whatever is registered now, not by id alone.
767        drop_panicking_pass(id, &original);
768
769        assert_eq!(
770            state(id),
771            (true, true),
772            "the successor is left registered, and the queued unbind from the hand-over persists"
773        );
774
775        assert!(unregister_external_pass(id));
776        registry().pending_unbind.remove(&id.get());
777    }
778
779    #[test]
780    fn a_fresh_registration_with_a_different_arc_clears_the_reported_panic_flag() {
781        let _serial = serial();
782        let id = SceneTextureId::mint();
783        let pass1: Arc<dyn ExternalPass> = Arc::new(Inert);
784        assert!(register_external_pass(id, Arc::clone(&pass1)));
785
786        drop_panicking_pass(id, &pass1);
787        assert!(
788            registry().reported_panics.contains_key(&id.get()),
789            "the first panic of this id is recorded as already reported"
790        );
791
792        let pass2: Arc<dyn ExternalPass> = Arc::new(Inert);
793        assert!(register_external_pass(id, Arc::clone(&pass2)));
794        assert!(
795            !registry().reported_panics.contains_key(&id.get()),
796            "registering a different Arc clears the flag, so a new pass's first panic warns again"
797        );
798
799        assert!(unregister_external_pass(id));
800    }
801
802    #[test]
803    fn re_registering_the_same_arc_keeps_the_reported_panic_flag() {
804        let _serial = serial();
805        let id = SceneTextureId::mint();
806        let pass: Arc<dyn ExternalPass> = Arc::new(Inert);
807        assert!(register_external_pass(id, Arc::clone(&pass)));
808
809        drop_panicking_pass(id, &pass);
810        assert!(
811            registry().reported_panics.contains_key(&id.get()),
812            "the first panic is recorded"
813        );
814
815        // The panic removed the pass and queued an unbind; clean that up before re-registering
816        registry().pending_unbind.remove(&id.get());
817
818        // Re-register the same Arc: the panic flag stays since it's the same identity
819        assert!(register_external_pass(id, Arc::clone(&pass)));
820        assert!(
821            registry().reported_panics.contains_key(&id.get()),
822            "re-registering the same Arc keeps the panic flag (no warn on next panic)"
823        );
824
825        assert!(unregister_external_pass(id));
826    }
827
828    #[test]
829    fn panicking_pass_with_successor_already_registered_logs_debug() {
830        let _serial = serial();
831        let id = SceneTextureId::mint();
832        let original: Arc<dyn ExternalPass> = Arc::new(Inert);
833        assert!(register_external_pass(id, Arc::clone(&original)));
834
835        // Hand over: unregister original, register successor
836        assert!(unregister_external_pass(id));
837        let successor: Arc<dyn ExternalPass> = Arc::new(Inert);
838        assert!(register_external_pass(id, Arc::clone(&successor)));
839
840        // Original panics after handing over - successor stays registered
841        drop_panicking_pass(id, &original);
842
843        assert_eq!(
844            state(id),
845            (true, true),
846            "the successor is left registered and the queued unbind from the hand-over remains"
847        );
848
849        assert!(unregister_external_pass(id));
850    }
851
852    #[test]
853    fn pending_unbind_grows_with_distinct_ids_not_with_repeat_unregisters() {
854        let _serial = serial();
855        let id = SceneTextureId::mint();
856        // Other cases in this module may leave their own residual entries
857        // (under their own distinct ids) in this same process-wide map, so
858        // what is asserted is the delta this case itself causes, not an
859        // absolute length.
860        let before = registry().pending_unbind.len();
861
862        assert!(register_external_pass(id, Arc::new(Inert)));
863        assert!(unregister_external_pass(id));
864        // The same id again finds nothing left to remove, so this queues no
865        // second entry — the map is keyed by id, not appended to per call.
866        assert!(!unregister_external_pass(id));
867        assert_eq!(
868            registry().pending_unbind.len(),
869            before + 1,
870            "repeat unregistration of one id does not grow the queue past one entry"
871        );
872
873        registry().pending_unbind.remove(&id.get());
874    }
875}