Expand description
EngineRenderer: the public seam a host drives one surface’s 2D frames
through.
§The encode contract
EngineRenderer::encode records into a wgpu::CommandEncoder the
caller owns and never submits it. That is the whole point of the seam: a
host compositing 2D over its own 3D content records its passes before and
after the engine’s into one encoder and submits once, and a submit hidden
inside the engine would split that into two command buffers with a pipeline
flush between them. Two obligations follow, and both are contract rather
than preference:
- every pass the engine begins is ended before
encodereturns, so the caller’s nextbegin_render_passon the same encoder is legal; and - the engine issues no
queue.submitfor scene work. It does issuequeue.write_texture/write_bufferuploads, which are ordered ahead of the command buffers submitted after them and so land before the passes that read them.
One thing the engine does submit, and it is worth naming precisely because
the rule above is otherwise absolute: growing the image atlas array submits a
command buffer of its own, holding one texture copy and nothing else, on the
rare frame that grows it. That is maintenance, not scene work — it never
touches the caller’s encoder, records no pass and no draw, and exists because
the queued writes it has to precede would otherwise be flushed ahead of it
(see crate::gpu::atlas’s Why growth submits a command buffer of its
own). The caller’s encoder is still never submitted by the engine, and the
frame’s own passes still reach the queue only when the caller submits it.
§Sharing the depth attachment
A caller recording its own depth-writing passes into that encoder hands the
same attachment in as EngineTarget::depth, and the two renderers then
occlude each other correctly in either order. Three rules make that work,
and crate::gpu::depth is where they are stated in full: the shared
comparison and which end of the range is near
(DEPTH_COMPARE over a buffer whose far
plane is DEPTH_CLEAR); the depth
attachment’s extent matching the colour target’s; and who owns the clear —
whichever pass runs first in the encoder, which the caller states through
EngineRenderer::set_depth_pre_cleared.
What that contract does not extend to is colour. The clear pass below
clears the frame’s colour target unconditionally, so content painted into
that target before encode keeps its depth and loses its pixels: a host
compositing over its own content records that content after the frame, or
into a target of its own the frame composites.
§The frame’s passes
A frame records into that encoder, in this order.
-
Clear. Clears the colour target to the frame’s base colour, and the depth attachment to the far plane unless the caller stated it is already populated (see
crate::gpu::depth). It draws nothing; separating it from the strip passes is what lets a frame with no draws at all still resolve to a clean surface. -
Opaque strips, depth-tested and depth-writing, unblended. Once per frame, ahead of every round: only the fully-covered interior spans of opaque draws targeting the surface reach it — an anti-aliased edge is by definition not opaque, and a page carries no depth attachment for the split to be sound against. The depth it establishes is what lets the alpha passes reject fragments an opaque draw in front of them already covered.
Once, not once per surface round, and that is a correctness requirement: the surface can take several rounds (a cut round is how a wide sibling fan is served), and re-recording this pass ahead of each would re-draw opaque coverage at equal stored depth over composites the round before it had already blended. Running it ahead of the layer rounds rather than after them changes nothing they do: a layer round writes a pooled page and reads neither the surface nor the depth attachment.
-
One pass per round. A page round renders one isolated layer into a pooled intermediate page it clears to transparent, at the page’s own origin — so every instance is shifted by the page’s tile-aligned bounds, and clipped to them, which is what makes a banded layer’s column pages tile their layer instead of each holding a clamped copy of it (see [
PageWindow]) — and through the page’s own viewport uniform; a round continuing a page an earlier round of the same layer opened loads it instead. A surface round draws alpha strips, premultiplied-blended, in painter order: depth-tested but not depth-writing when a depth attachment is in play, a plain painter’s-algorithm pass when it is not. A round’s ops run in the orderSchedule::buildlisted them, so a finished child page composites into its parent exactly where the recording entered it.A filter round is the one round that draws no strip at all: it runs one pass of a filter’s sequence, one instanced quad through
EnginePipeline::Filter, reading the layer’s other pooled page through the engine’s only sampler and clearing the page it writes (seeFilterResources). It is an ordinary round of this walk in every other respect — recorded into the caller’s own encoder, in the order the scheduler listed it, with its pages handed back the moment its pass ends. It is not an own-encoder exception; the atlas replay remains the only one of those. -
The hole punch, destination-out, when the frame recorded a
ClearRect— seecrate::compile::clearfor the whole contract this pass implements. It is issued at the punch’s own painter-order position, not at the end of the frame: a surface round is cut where the punch was recorded, the punch pass goes into that cut, and the round’s remaining ops resume in a pass of their own after it. Everything drawn over the slot is therefore recorded after the erase and survives it, whether or not it wrote depth. A punch past every op of the frame — the ordinary case, aClearRectrecorded last — cuts nothing and lands after the last round exactly as it always did.
With depth unavailable — no attachment, or FRUST_ENGINE_NO_DEPTH set —
pass 2 disappears and every instance travels through the surface rounds,
blended in painter order. That is a correctness requirement rather than a
fallback detail: routing the opaque spans into a separate, earlier pass is
only sound because the depth buffer re-establishes their ordering against the
blended ones.
§Compositing a layer
A finished page reaches its parent as ONE instanced quad through the same
strip program every draw goes through, flagged as a whole rectangle and
naming the layer colour source: the fragment stage then reads the page
bound as layer_input_texture at the quad’s own texel and scales it by the
opacity packed into the instance’s low byte. The page is bound through a
bind group of its own rather than the frame’s shared one, because group 0
carries both the pass’s viewport uniform and that layer input, and a page
round’s viewport is its own.
A composite carries the deepest painter’s-order index of everything inside the layer it composites, nested layers included. That is what keeps a translucent layer correctly ordered against the root round’s own draws without giving the scheduler a depth model: every draw recorded before the layer sits behind that index and every draw recorded after it sits in front.
§Paint resolution
A solid colour travels inside the strip instance itself. Anything else the compiler encoded — a gradient, an image, a blurred rounded rectangle — is resolved once per frame before a single instance is built, in three steps that have to happen in this order:
- residency is settled for the whole frame: the frame’s LUT requests are
serviced through the
GradientCacheso every gradient’s colour ramp has an offset into the packed LUT buffer, and every image paint’s atlas rectangle is looked up (or learned, the first time it is drawn) in the renderer’s own image registry — see [FrameResources::resolve_paints]; - each encoded paint is lowered into the
GpuEncodedPaintrecord the fragment shader samples, carrying that residency; and - the records are serialized back to back, which fixes the texel each one starts at — the index a strip instance names its paint by.
Ramp offsets are only valid within the frame that took them: the cache
compacts and rewrites them in EngineRenderer::end_frame, which is why
residency is decided here rather than at compile time. An image’s atlas
rectangle, by contrast, is stable for as long as the image stays resident
(the compiler’s crate::cache::images::ImageResidency does not move a
live image), so the renderer’s own registry only ever forgets an entry
when the frame that compiled it reports the entry’s region evicted.
A paint that still cannot be resolved — an image the atlas has no room for, a gradient whose ramp could not be baked, an external texture (nothing binds one yet) — leaves its draw skipped rather than stamped in a wrong colour: the same “a frame draws less, never wrong” rule the compiler follows for the commands it does not lower.
An image the atlas holds a minified copy of is the one paint whose lowered
record needs a correction here. The compiler composed its natural-to-device
transform against the source’s declared extent, before residency was
consulted and so before the fit was known; the shader samples the resident
rectangle. ResidentImage::minify_scale is the ratio between the two, and
folding it into the lowered record’s transform is what keeps a downsampled
image landing on the destination rectangle the display list asked for.
Structs§
- Engine
Renderer - One surface’s 2D render engine: a scene in, recorded passes out.
- Filter
Pass Plan - One filter pass’s full recording state.
- Filter
Resources - The GPU resources a frame’s filter rounds are executed with, beyond the two pooled pages they ping-pong between.