Skip to main content

Module render_split

Module render_split 

Source
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 newer SceneFrame replaces an un-taken one; the render thread always takes the freshest, dropping stale frames) fused with a FIFO lifecycle-command queue behind one std::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-1 Mutex-only slot (no Condvar — 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 split submit_frame can Scene::reset() and reuse the buffer next frame instead of reallocating one via Scene::new() every frame — restoring frust-scene’s documented reuse contract (scene.rs’s Scene::reset docs) in split mode.
  • RenderCommand / RenderEvent / RenderPhase — the surface lifecycle vocabulary (created/changed/destroyed/pause/resume) as owned commands, modelled on frust-render’s SurfacePhase machine: a pure, host-testable next_render_phase transition table gates whether the render thread may render.
  • Ack / AckWaiter — the cross-thread acknowledgment barrier that makes RenderCommand::Pause and RenderCommand::SurfaceDestroyed synchronous: the UI thread blocks until the render thread has honored the command. This is the correctness anchor for two platform hazards: Android can destroy the ANativeWindow while 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’s UiSpans half of the frame timing, which the render thread folds together with its own RenderSpans via FramePasses::from_split.
  • render_thread_enabled / NO_RENDER_THREAD_VAR — the single kill switch the shells consult, parsed exactly like crate::frame_gate’s FRUST_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 a perf-trace build with FRUST_TRACE off pays one cached bool read per RenderSender::send_scene.
  • Per-thread, not process-wide. RenderSender::send_scene moves 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, then acknowledges it — unblocking the UI thread’s paired AckWaiter.
AckWaiter
The UI-thread side of an acknowledgment barrier: the UI thread waits on this after sending a RenderCommand::Pause/ RenderCommand::SurfaceDestroyed, blocking until the render thread has acknowledged (or dropped) the paired Ack.
FrameMeta
Per-frame metadata riding the scene-handoff channel alongside the scene itself.
RenderBatch
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 processes commands first (updating its RenderPhase), then renders scene only if the resulting phase can_render.
RenderReceiver
The render-thread handle to the render channel: the single wait point (Self::wait_next) draining pending commands plus the freshest scene each wakeup.
RenderSender
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.
SceneFrame
One frame handed from the UI thread to the render thread across render_channel: the finished scene, its FrameMeta, and the UI thread’s UiSpans half of the frame timing (the render thread is the single perf emitter).
SceneReturnReceiver
The UI-thread handle to scene_return_channel: polls (never blocks) for a scene the render thread has finished with.
SceneReturnSender
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 crossing render_channel would otherwise leave (a scene with no way back, so every split submit_frame replaced it with Scene::new()).
SurfaceSize
The surface dimensions a SceneFrame / RenderCommand carries — 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§

RenderCommand
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 type W a Self::SurfaceCreated carries (frust-render’s raw-window wrapper in a real shell; a stand-in in host tests), keeping this crate render-free.
RenderEvent
A surface-lifecycle event that drives a render-thread RenderPhase transition — the pure, Copy counterpart of a RenderCommand (mirroring frust-render’s SurfaceEvent/callback split, keeping the transition table host-testable without the owned Ack/window payloads).
RenderPhase
The render thread’s view of surface lifecycle state, modelled on frust-render’s SurfacePhase: the render loop renders a handed-off SceneFrame only while can_render — i.e. only in RenderPhase::Active. RenderPhase::Paused is the cross-thread backgrounding barrier (a leftover scene must NOT be submitted after a Pause, 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_enabled is false and a shell keeps the pre-split single-thread frame path.

Functions§

ack_pair
Create a linked AckWaiter / Ack barrier pair: the UI thread keeps the waiter, the render thread receives the ack (inside the command). Used by RenderSender::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_ledger before painting. Never blocks on the render thread: if the render side is mid-publish (or nothing was ever published) this returns None and the caller keeps the ledger it already has — a one-frame-stale ledger is the design: the renderer refuses a DrawRetained of 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 (see frust_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’s next_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_ledger with 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: frame is the last committed paint serial, pages its (key, extent_px, scale) triples. Render-agnostic (shell-common stays free of frust-render), so each shell adapts its own SurfaceRenderer to 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_serial as 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 an Arc.
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. true unless the NO_RENDER_THREAD_VAR kill switch is engaged (compile-time define or runtime env, any non-"0" value), mirroring crate::perf::enabled’s and crate::frame_gate’s option_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, no Condvar and 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-S charter (see the module docs’ Layering choice).
take_retained_repaint
Take the owed repaint request_retained_repaint recorded: true once per request (however many requests landed since the last take), then false. 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.