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§
- External
Frame - 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§
- External
Pass - Caller-supplied GPU work recorded into the frame ahead of the scene.
Functions§
- register_
external_ pass - Registers
passunderid, to be recorded every frame untilunregister_external_passtakes it back. - unregister_
external_ pass - Removes the pass registered under
idand queues the engine binding it left behind for the next drain to clear, answering whether there was one.