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. - next_
render_ phase - Pure render-phase transition table, the analogue of
frust-render’snext_phase. Total by design: - 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. - 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).