Skip to main content

Module external_pass

Module external_pass 

Source
Expand description

The seam an app — or a crate sitting beside the facade, a 3D renderer being the motivating one — records its own GPU work through, ahead of the engine’s scene pass and into the engine’s own frame encoder.

§What this closes

frust_engine::EngineRenderer has always been able to draw a texture it did not create: bind_texture registers a wgpu::TextureView under a SceneTextureId, and a display list’s Command::SceneTexture/SceneBuilder::scene_texture naming that id composites it. Nothing outside frust-engine could reach that, though — crate::SurfaceRenderer owns the engine, and the frame’s wgpu::CommandEncoder is created and submitted inside one call — so the only in-tree user of the registry was the engine’s own shader-quad pre-pass. This module is the reachable half: a process-wide registry of caller-supplied ExternalPasses, each handed the live frame (ExternalFrame) once per frame, before anything of the scene’s own is recorded.

§The frame a pass is handed

ExternalPass::record runs on the render thread, inside the renderer’s submit, into the same wgpu::CommandEncoder the scene pass is about to be recorded into and ahead of the engine’s own shader-quad pre-pass. That single-encoder ordering is the whole point, and it is the same one the shader-quad pass relies on: a texture bound during record is already registered when the display list naming it is compiled, and the passes that write it are already recorded ahead of the pass that samples it. One encoder gives both orderings for free, with no second submit and no fence.

A pass records only — it never submits. That is crate::SurfaceRenderer’s job, once, at the end of the frame; a pass that submits the frame’s encoder cannot (the encoder is only borrowed) and one that submits work of its own on its own encoder breaks the ordering it came here for. A pass never shares the frame’s own depth attachment — it renders into attachments it owns, on a target it owns, sized however that target needs to be. frust_gpu::encoder’s two depth caller rules (clear ownership, and matching comparison/direction) are for a host sharing one depth buffer across renderers of its own; they have nothing to do with a pass registered here, which the frame’s depth attachment is never handed to.

§Binding, and what the engine does with it

ExternalFrame::bind_texture forwards to the engine’s own registry, so a pass renders into a target it owns and then hands the engine a view of it. Re-binding the same id replaces the previous view (the engine’s own semantics), which is what lets a pass that re-creates its target on resize keep one stable id. The composited result is always blended, never claimed opaque — the engine never reads the caller’s texels, so it cannot know (docs/LIMITATIONS.md’s engine-scene-texture-always-blended).

Binding happens inside record, immediately — it does not wait for the frame to actually submit. So work a pass recorded is submitted only if the frame is (a refused frame’s encoder is dropped unsubmitted, taking every command a pass recorded with it), but the bind side effect already landed in the engine’s registry and survives the refusal regardless: the next accepted frame composites whatever view was bound, whether or not the work that was meant to fill it ever ran.

On a hand-over (one pass unregisters and a successor registers under the same id before the drain finishes), the queued unbind is NOT cancelled: it runs at the next drain before any pass, so an id between owners draws nothing until its new owner binds a view.

§Lifetime of a registration

A pass persists until unregister_external_pass takes it: the registry is iterated every frame, never drained. Unregistering queues the id for an unbind_texture the next drain performs before it runs any pass, so the engine never keeps a view alive for an id whose owner is gone. Registering the same id again before that flush does NOT cancel the queued unbind — the id has an owner again, and the incoming pass binds whatever it wants, but an id between owners draws nothing until its new owner records a binding.

Unregistering is not a barrier. It removes the pass from the registry under the lock, but a drain that already snapshotted the pass before that call runs is still mid-flight against its own copy of the Arc and will still call record on it once more — no drain that starts after unregister_external_pass returns ever will. The unbind itself is queued, not immediate: it lands on the next frame something actually drains, so it does not happen while the surface presenting it is idle — a caller that needs the binding gone right now, rather than whenever the surface next presents, has to make sure a frame gets requested. The queue it lands in is a map keyed by id, so its size is bounded by the number of distinct ids unregistered since the last drain, never by how many times any one of them is unregistered.

§A panicking pass does not take the frame down

record is called inside catch_unwind. A pass that panics is reported (warn! on the first panic under an id with a given Arc identity, debug! afterwards) and, if the id it panicked under still names the same pass (nothing else registered under it in the meantime — a pass handing its id to a successor inside its own record before panicking leaves that successor alone), removed from the registry and queued for the same unbind an explicit unregistration queues. If a successor already holds the id, the panic is still reported as a debug message saying the predecessor panicked after handing over. Either way the frame goes on to record the scene. This is the same no-panic posture the engine holds on the FFI boundary. It is a debug/dev net rather than a promise: the workspace’s release profile is panic = "abort", where the process is gone before any guard runs, so the shipped contract is still that a pass must not panic.

§Who reaches this

The registry itself is shell-agnostic — a plain process-wide map, with no device, window or platform in it — but the drain lives on the engine tier’s frame path (SurfaceRenderer’s TierBackend::Engine arm), so a registered pass records exactly where an engine-tier surface is presenting frames and nowhere else. HeadlessRenderer drives the engine through its own frame path and does not drain this registry.

One more consequence of a process-wide registry meeting a per-surface engine: with two engine-tier surfaces live at once, every pass records into both frames (binding into each surface’s own engine, which is what a caller wants), but a queued unbind is consumed by whichever surface drains first. One engine-tier surface per process is what every shell does today.

Structs§

ExternalFrame
The live frame handed to ExternalPass::record: the device and queue behind it, the encoder every pass of this frame is recorded into, and the engine’s texture registry to bind results into.

Traits§

ExternalPass
Caller-supplied GPU work recorded into the frame ahead of the scene.

Functions§

register_external_pass
Registers pass under id, to be recorded every frame until unregister_external_pass takes it back.
unregister_external_pass
Removes the pass registered under id and queues the engine binding it left behind for the next drain to clear, answering whether there was one.