Expand description
Render-thread-split plumbing shared by every shell.
§What lives here
The split moves encode→acquire→blit→present off the UI thread onto a
dedicated render thread: the UI thread keeps
rebuild→layout→paint, then hands the finished Scene across. This
module is the vocabulary for that handoff — the shells
own the threads and the wgpu/vello resources, this crate owns the
platform-free channel types and the pure lifecycle/kill-switch logic they
coordinate through.
render_channel— the single UI→render link: a depth-1, latest-wins scene-handoff slot (a newerSceneFramereplaces an un-taken one; the render thread always takes the freshest, dropping stale frames) fused with a FIFO lifecycle-command queue behind onestd::sync::Condvar, so the render thread has a single wait point (RenderReceiver::wait_next). Depth 1 is deliberate — Flutter’s merged-mode precedent shows pipeline depth drops to 1 when threads merge; deeper queues add latency for no mobile win.scene_return_channel— the reverse, render→UI give-back link: a non-blocking, depth-1Mutex-only slot (noCondvar— the UI thread only ever polls it, never parks) the render thread pushes a drained scene back through once it is done reading it, so a shell’s splitsubmit_framecanScene::reset()and reuse the buffer next frame instead of reallocating one viaScene::new()every frame — restoring frust-scene’s documented reuse contract (scene.rs’sScene::resetdocs) in split mode.RenderCommand/RenderEvent/RenderPhase— the surface lifecycle vocabulary (created/changed/destroyed/pause/resume) as owned commands, modelled onfrust-render’sSurfacePhasemachine: a pure, host-testablenext_render_phasetransition table gates whether the render threadmay render.Ack/AckWaiter— the cross-thread acknowledgment barrier that makesRenderCommand::PauseandRenderCommand::SurfaceDestroyedsynchronous: the UI thread blocks until the render thread has honored the command. This is the correctness anchor for two platform hazards: Android can destroy theANativeWindowwhile the render thread still holds the surface, and iOS can kill a process that submits Metal work after the app backgrounds. Both are barriers, not shared mutable flags.SceneFrame/FrameMeta/SurfaceSize— the per-frame payload crossing the handoff: the scene plus the frame clock, the surface dimensions, and (for the single-emitter perf recording) the UI thread’sUiSpanshalf of the frame timing, which the render thread folds together with its ownRenderSpansviaFramePasses::from_split.render_thread_enabled/NO_RENDER_THREAD_VAR— the single kill switch the shells consult, parsed exactly likecrate::frame_gate’sFRUST_NO_FRAME_GATE(compile-time define or runtime env, any non-"0"value). When engaged, a shell keeps the pre-split single-thread path (kept as an escape hatch until the split’s on-device throughput is fully validated).
§Benchmark scenario markers
The channel carries one thing beside the scene: the benchmark
scenario-window edges crate::perf::mark_scenario_start raises, so
each is logged stamped with the frame that actually carried it. Three
properties of that route are load-bearing here.
perf-trace-only. Every marker field, call and queue in this module is behind the feature; a release-lean build has no marker code in the channel at all, and aperf-tracebuild withFRUST_TRACEoff pays one cached bool read perRenderSender::send_scene.- Per-thread, not process-wide.
RenderSender::send_scenemoves the calling thread’s raised markers into the inbox, and [drain] stages the taken frame’s markers onto the calling (render) thread, which is the thread about to record that frame. No shared queue and no flag decide who owns a marker; the thread that raised it does, until it hands a frame off. - A marker from a thread that hands no frame off is never emitted.
Only the UI thread’s own queue crosses this channel, so a marker raised
on, say, a blocking-pool thread stays there and dies with it — see
crate::perf::mark_scenario_start, which documents the rule and why the alternative (attaching it to some other thread’s frame) is the cross-thread guess this route exists to remove.
§Layering choice
Like crate::perf and crate::frame_gate, this is shell-owned and
platform-free: it takes no frust-render/wgpu/vello dependency, no
unsafe, and no frust-reactive, preserving this crate’s
compiles-everywhere, reactive-free charter (see docs/ARCHITECTURE.md’s
Layer Dependencies). The scene payload (SceneFrame) and the surface
handle a RenderCommand::SurfaceCreated carries are therefore generic
parameters (S/W): a shell instantiates S = frust_scene::Scene and W
= its own raw-window wrapper, while these host tests instantiate cheap
stand-ins, so the whole channel is exercised without a GPU or a platform.
§Wiring
This module ships the channel types + pure logic; the desktop, Android,
and iOS shells each spawn their own render thread on top of it, gated by
render_thread_enabled.
Structs§
- Ack
- The render-thread side of an acknowledgment barrier: the render thread holds
this (moved out of a
RenderCommand::Pause/RenderCommand::SurfaceDestroyed) while honoring the command, thenacknowledges it — unblocking the UI thread’s pairedAckWaiter. - AckWaiter
- The UI-thread side of an acknowledgment barrier: the UI thread
waits on this after sending aRenderCommand::Pause/RenderCommand::SurfaceDestroyed, blocking until the render thread hasacknowledged (or dropped) the pairedAck. - Frame
Meta - Per-frame metadata riding the scene-handoff channel alongside the scene itself.
- Render
Batch - One wakeup’s worth of work handed to the render thread by
RenderReceiver::wait_next/RenderReceiver::try_next: the lifecycle commands to process (FIFO), then the freshest scene to render (if any). A render loop processescommandsfirst (updating itsRenderPhase), then renderssceneonly if the resulting phasecan_render. - Render
Receiver - The render-thread handle to the render channel: the
single wait point (
Self::wait_next) draining pending commands plus the freshest scene each wakeup. - Render
Sender - The UI-thread handle to the render channel: sends scenes
(latest-wins) and lifecycle commands (FIFO). Single-producer by design (the
UI thread), so it is deliberately not
Clone. - Scene
Frame - One frame handed from the UI thread to the render thread across
render_channel: the finished scene, itsFrameMeta, and the UI thread’sUiSpanshalf of the frame timing (the render thread is the single perf emitter). - Scene
Return Receiver - The UI-thread handle to
scene_return_channel: polls (never blocks) for a scene the render thread has finished with. - Scene
Return Sender - The render-thread handle to
scene_return_channel: pushes a drained scene back for the UI thread to reclaim (Scene::reset+ reuse) instead of a shell allocating a fresh one every frame — closing the buffer-reuse gap a scene crossingrender_channelwould otherwise leave (a scene with no way back, so every splitsubmit_framereplaced it withScene::new()). - Surface
Size - The surface dimensions a
SceneFrame/RenderCommandcarries — the physical (device-pixel) swapchain size plus the HiDPI scale factor, so the render thread can (re)configure the surface without consulting the UI thread. Physical-at-the-boundary matches the render thread’s swapchain needs (docs/CODE_STANDARDS.md’s physical-at-FFI, logical-inside rule).
Enums§
- Render
Command - A lifecycle command the UI thread sends to the render thread across
render_channel, as an owned value — not a shared mutable flag. Generic over the surface-handle typeWaSelf::SurfaceCreatedcarries (frust-render’s raw-window wrapper in a real shell; a stand-in in host tests), keeping this crate render-free. - Render
Event - A surface-lifecycle event that drives a render-thread
RenderPhasetransition — the pure,Copycounterpart of aRenderCommand(mirroringfrust-render’sSurfaceEvent/callback split, keeping the transition table host-testable without the ownedAck/window payloads). - Render
Phase - The render thread’s view of surface lifecycle state,
modelled on
frust-render’sSurfacePhase: the render loop renders a handed-offSceneFrameonly whilecan_render— i.e. only inRenderPhase::Active.RenderPhase::Pausedis the cross-thread backgrounding barrier (a leftover scene must NOT be submitted after aPause, per the iOS process-kill hazard).
Constants§
- NO_
RENDER_ THREAD_ VAR - The render-thread-split kill-switch environment/compile-time variable: when
set to any non-
"0"value,render_thread_enabledisfalseand a shell keeps the pre-split single-thread frame path.
Functions§
- ack_
pair - Create a linked
AckWaiter/Ackbarrier pair: the UI thread keeps the waiter, the render thread receives the ack (inside the command). Used byRenderSender::pause/RenderSender::destroy_surface; exposed directly for shells building lifecycle commands by hand. - latest_
retained_ ledger - The most recently published ledger, for the UI thread to hand to
RenderRoot::set_retained_ledgerbefore painting. Never blocks on the render thread: if the render side is mid-publish (or nothing was ever published) this returnsNoneand the caller keeps the ledger it already has — a one-frame-stale ledger is the design: the renderer refuses aDrawRetainedof a page it no longer holds, and the ledger’s paint serial keeps a widget from drawing a page holding an older recording than its latest (seefrust_core::RetainedLedger::covers). Empty until the engine’s retained-page store publishes. - next_
render_ phase - Pure render-phase transition table, the analogue of
frust-render’snext_phase. Total by design: - publish_
empty_ retained_ ledger - Publish the EMPTY ledger — what a surface destroy or a device-scale change
leaves the renderer holding. A shell calls it (or
publish_retained_ledgerwith the renderer’s own, equally empty, ledger) before a destroy returns or its barrier is acknowledged, so the UI thread’s first paint over a re-created surface names no page the torn-down one held. - publish_
resident_ pages - Publish the ledger for a renderer’s resident retained pages:
frameis the last committed paint serial,pagesits(key, extent_px, scale)triples. Render-agnostic (shell-common stays free offrust-render), so each shell adapts its ownSurfaceRendererto this one call. - publish_
retained_ ledger - Publish the ledger describing the frame the renderer just finished. Called
by the render side (after submit) with the retained pages it holds resident,
stamped with the paint serial of the last scene it committed
(
FrameMeta::paint_serialas named to the renderer before the encode); replaces the previous one. May block only for the instant the UI thread holds the slot to clone anArc. - render_
channel - Create the UI→render channel: a depth-1 latest-wins scene slot fused with a FIFO lifecycle-command queue behind one condvar.
- render_
thread_ enabled - Whether a shell should run the render-thread split — the single switch
every shell consults.
trueunless theNO_RENDER_THREAD_VARkill switch is engaged (compile-time define or runtime env, any non-"0"value), mirroringcrate::perf::enabled’s andcrate::frame_gate’soption_env!+ runtime-env parsing precedent. - request_
retained_ repaint - Record that a frame was just refused for drawing a retained page the renderer no longer held (the store was cleared and the empty ledger published): the refused frame presented nothing, so the UI thread owes one more paint against that empty ledger. Called by the render side — which cannot reach the UI thread’s state — right after it published; an atomic store, never blocks.
- scene_
return_ channel - Create the render→UI scene give-back channel: a
non-blocking, depth-1 return slot — the reverse-direction, pull-based
counterpart to
render_channel’s UI→render handoff.Mutex<Option<S>>only, noCondvarand no new dependency: nothing should ever park waiting on this slot, so there is no wait point to back — preserving this module’s no-unsafe, no-new-deps, generic-over-Scharter (see the module docs’ Layering choice). - take_
retained_ repaint - Take the owed repaint
request_retained_repaintrecorded:trueonce per request (however many requests landed since the last take), thenfalse. Read by the UI thread’s frame gate past its surface-ready check, ORed into an input that forces the frame to run. An atomic swap, never blocks.