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(®istration.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}