Integrate the GPU stack (worker-decoupled rendering)
Move a heavy interactive render surface off the GUI thread and onto the
shared librhi worker pattern. This is the §GSI-U3 recipe with two worked
examples — the Construct scene viewport and the Paint canvas — both of
which ship on this pattern today.
Audience: OctoMY developers adding or migrating a GPU-rendered surface.
Prerequisites: the RHI-First policy (feature code sees only QRhi-level
types; backend types stay quarantined in librhi).
The shape: producer / host / compositor
One render surface = three pieces, each with one job:
GUI thread worker thread
┌───────────────────────┐ ┌──────────────────────────────┐
│ compositor widget │ ticks │ WorkerHost │
│ (rhi::RhiPresentWidget │───────▶│ owns: QThread + its own QRhi│
│ subclass) │ │ runs: your WorkerProducer │
│ - presents WorkerFrame│◀───────│ - onInitialize(QRhi*) │
│ - forwards input │ frames │ - onTick(QRhi*)→WorkerFrame │
│ - owns NO engine state│ │ - onRelease / onDeviceLoss │
└───────────────────────┘ └──────────────────────────────┘
- Producer (
rhi::WorkerProducer): hosts your engine on the worker'sQRhi. All engine GPU work — pipeline creation, shader compile, render, readback — happens in its callbacks, on the worker thread. - Host (
rhi::WorkerHost): one thread, oneQRhi, one producer — the atomic isolation unit.start(producer, config),requestTick(),runOnWorker(fn)for commands, signalsframeReady(WorkerFrame)/workerError(QString)marshalled to the GUI thread. - Compositor (
rhi::RhiPresentWidgetsubclass inlibrhiwidget): a minimalQRhiWidgetthat uploads the latestWorkerFrame, tonemaps, draws one fullscreen quad, and forwards input. It holds no engine state, so the GUI thread never waits on the engine.
Many producers? Use rhi::WorkerPool: Interactive-class producers get a
dedicated host (1:1), Background-class producers (previews, thumbnails)
share a capped pool (N:1).
The shared components you get for free
| Component | Use it for |
|---|---|
rhi::WorkerHost / rhi::WorkerPool |
thread + device lifecycle, topology |
rhi::WorkerFrame |
the handover value: pixels (Shape-1), later damage-rects / shared textures |
rhi::RhiPresentWidget |
the present-only compositor base widget |
rhi::PipelineForge |
async, content-addressed pipeline builds off the interactive path |
rhi::ContentAddressedStore |
hash-keyed artifact reuse (pipelines, frames, chunks) |
rhi::BackpressureQueue<T> |
bounded latest-wins command mailbox (absolute-state streams) |
rhi::RenderTelemetry + rhi::QualityController |
measured, hysteretic quality adaptation |
rhi::RenderBudget |
probed device VRAM (VK_EXT_memory_budget) + resource ceilings; size caches off real hardware |
rhi::DeviceLossWatchdog |
device-loss escalation policy; the WorkerHost drives it for you (see rule 4) |
rhi::createOffscreenRhi |
backend-neutral worker device (Vulkan-preferred, GL/GLES fallback) |
The rules that keep it correct
- No GPU resource pointer crosses a thread boundary. Only frame
values and native handles cross. The producer's
QRhiand everything created from it live and die on the worker thread. - Hand scene state over as a value. Construct passes a
ConstructSnapshotand the worker rebuilds an isolated model; Paint appends ops to a journal. Never share a live QObject model across threads. - Coalesce input, bound the rate. Accumulate incremental deltas
(camera orbit/pan) GUI-side and flush the net once per tick; keep at
most one tick in flight (depth-1 coalescer). Use
BackpressureQueuefor absolute-state command streams. - Everything rebuilds from CPU authority. The
WorkerHostdrives the whole device-loss chain for you: it detects the lost device after a tick, escalates through aDeviceLossWatchdog, rebuilds the workerQRhi, and calls youronDeviceLoss(freshDevice)— you only implement that one callback (release stale GPU state, rebuild from your CPU authority; the default callsonInitialize). Sustained losses past the HardStop threshold halt the worker (recovery = respawn). Recoverable losses are handled transparently and do not raiseworkerError— only fatal ones do, so a client that treatsworkerErroras fatal (falls back to a GUI-thread path) won't tear down on a blip. If your surface can't rebuild from CPU authority, fix that first. - Degrade visibly, never crash. Failed pipeline/shader creates must
disable the pass and log — a kept half-built pipeline is a driver
crash at submit. For load, feed
RenderTelemetry(measured frame time + VRAM vsRenderBudget) into aQualityControllereach frame and act on its hysteretic level (render-scale, post-off, smaller targets). Keep the knob reversible and never let it change what is drawn — only its fidelity (see paint's render-scale knob below). - GLES-first. Query capabilities at runtime
(
rhi::queryBackendCaps, degradation ladders); never assume Vulkan.
Worked example A — Construct scene viewport
Classes: construct::rendering::ConstructViewportProducer (producer,
hosts RenderPipeline + Scene + SDFLayer + post chain),
construct::rendering::SceneWorkerWidget (compositor, derives
RhiPresentWidget), wired in ConstructShell behind the persisted
Settings key rendering/workerViewport.
What its integration covers, in the order it was built:
- Bring-up: producer renders a clear colour + Shape-1 readback; widget presents it. Proves the seam before any engine code moves.
- Engine hosting: the full
RenderPipeline(SDF megashader compile included) moves intoonInitialize/onTick. An invariant test pins that pipeline creation never runs on the GUI thread. - Scene handover:
ConstructSnapshot→rebuildConstructFromSnapshoton the worker; re-pushed on every structural edit. - Interaction: picking and hover sample the worker's G-buffer; camera deltas coalesce by summation; a depth-1 tick coalescer bounds the render rate; selection/hover highlights run as worker-side post effects.
- Parity details that make it feel native: palette-derived selection colours, full Rendering-Options sync (background/lighting/shading/ tonemap/bloom/exposure/raymarch/ground plane), overlay + corner pills re-hosted onto the worker widget, FPS feed, and the inline widget's resize scheme (camera-aspect-only during a drag, one committed rebuild when it settles).
Tests to copy from: test/testConstructViewportProducer (engine-on-worker,
picking, camera, highlights, hosting parity, stress),
test/testSceneWorkerWidget (lifecycle, GUI responsiveness under bring-up).
Worked example B — Paint canvas
Classes: paint::PaintEngineProducer (rhi::WorkerProducer) hosting the
one PaintEngine on a rhi::WorkerHost; paint::PaintWorkerCanvasWidget
(: rhi::RhiPresentWidget) as the present-only compositor, selected in
PaintActivity behind the persisted paint/rhi/workerCanvas Settings
key (§GSI-P9) with the legacy GUI-thread PaintCanvasWidget as the
caps-gated fallback (§GSI-Q-O1, fallbackRequired()).
What paint adds on top of the base recipe:
- Journal-backed authority (§GSI-S5): every settled op is appended
to a
PaintJournal(ridingrhi::AuthorityJournalfor durability);onDeviceLoss= re-init +PaintEngine::rebuildAll(journal). The replay basis and the live-apply basis are the same function (PaintEngine::applyOperationStamps), proven bit-exact bytestReplayParityMatchesLiveApply. - Provisional-op preview: the in-flight stroke lives in the
engine's LIVE arrays; shape drags re-resolve an Operation each move
and preview through
previewOperation— the same stamp parts the commit and replay use, so what the user drags is what lands. - Seam-free unbounded strokes: freehand input appends ONLY new
dabs through a per-channel MAX blend into preserve views of live
(
StampArgs::accumulate); one commit at pen-up. Stroke length is unbounded and window seams are impossible by construction. - Shape-1D damage present (§GSI-S2.2): the producer accumulates
canvas-space damage per mutation and presents sub-rect frames
(
WorkerFrame::damageRect); pan/zoom is last-wins coalesced while stroke input is never dropped. - Checker + overlays on the compositor: the transparent-background checker is composited in the present shader and re-aligned only on frameReady so background and strokes move in lockstep; named-area + chunk-debug overlays ride the same frameReady hook, with chunk residency published thread-safely by the producer.
- Bounded GPU residency + eviction (§GSI-B5): resident chunks are
capped from probed VRAM (
RenderBudget::vramBudgetBytes÷ per-chunk footprint; ~48 MB/chunk here → 61 chunks on a 9 GB card). Past the cap, off-viewport chunks that are clean in the checkpoint are dropped and stream back through the same progressive-restore queue as document open — eviction never shrinks the durable checkpoint. AQSettingsoverride still wins for tuning. - Adaptive quality that respects a hard constraint (§GSI-S7): the
producer publishes a
RenderTelemetrysample every frame (measured render+readback time, resident VRAM vs budget) and feeds aQualityController. The sole knob is render-scale (opt-inpaint/rhi/adaptiveRenderScale): a degraded tier renders atmOutSize × scaleand the compositor upscales. Because a scaled present is always a full frame, it sidesteps the damage-rect path cleanly. The spectral band count (80 bands, full per-band dynamic range) is a hard constraint and is never reduced — a good reminder that adaptive quality must degrade fidelity, never identity. - Device-loss recovery is the journal (§GSI-S8): paint implements
onDeviceLossasre-init + PaintEngine::rebuildAll(journal)— theWorkerHostfault chain (rule 4) drives it. Because the app treatsworkerErroras fatal, a HardStop cleanly falls back to the legacy GUI-thread canvas, while recoverable losses are invisible to the user.
Tests to copy from: test/testPaintWorkerCanvas (engine-on-worker,
provisional strokes, damage present, undo/redo, residency/eviction,
telemetry + budget cap, adaptive governor + render-scale knob, and the
device-loss / low-VRAM / mixed-workload stress trio),
test/testWorkerHost (deviceLossRecoversThenHardStops — the shared
fault chain with a deterministic injectDeviceLossForTest seam).
Execution record: plan-paint.md; standing audit + legacy parity
matrix: worker-canvas-parity.md (both in the internal §GSI spec).
Diagnostics
--rhi-validate/--no-rhi-validate— enable the Vulkan validation layer (VK_LAYER_KHRONOS_validation). Debug builds default ON, release OFF. When a worker path dies inside the driver, run with validation first: it names the invalid object before the crash.QT_LOGGING_RULES="octomy.rhi.*=true"— worker/host lifecycle logging.
Internal spec
The normative requirements, backend matrix and both execution plans live
in the internal spec: content/doc/internal/specs/gpu-stack-integration/.