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's QRhi. All engine GPU work — pipeline creation, shader compile, render, readback — happens in its callbacks, on the worker thread.
  • Host (rhi::WorkerHost): one thread, one QRhi, one producer — the atomic isolation unit. start(producer, config), requestTick(), runOnWorker(fn) for commands, signals frameReady(WorkerFrame) / workerError(QString) marshalled to the GUI thread.
  • Compositor (rhi::RhiPresentWidget subclass in librhiwidget): a minimal QRhiWidget that uploads the latest WorkerFrame, 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

  1. No GPU resource pointer crosses a thread boundary. Only frame values and native handles cross. The producer's QRhi and everything created from it live and die on the worker thread.
  2. Hand scene state over as a value. Construct passes a ConstructSnapshot and the worker rebuilds an isolated model; Paint appends ops to a journal. Never share a live QObject model across threads.
  3. 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 BackpressureQueue for absolute-state command streams.
  4. Everything rebuilds from CPU authority. The WorkerHost drives the whole device-loss chain for you: it detects the lost device after a tick, escalates through a DeviceLossWatchdog, rebuilds the worker QRhi, and calls your onDeviceLoss(freshDevice) — you only implement that one callback (release stale GPU state, rebuild from your CPU authority; the default calls onInitialize). Sustained losses past the HardStop threshold halt the worker (recovery = respawn). Recoverable losses are handled transparently and do not raise workerError — only fatal ones do, so a client that treats workerError as 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.
  5. 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 vs RenderBudget) into a QualityController each 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).
  6. 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:

  1. Bring-up: producer renders a clear colour + Shape-1 readback; widget presents it. Proves the seam before any engine code moves.
  2. Engine hosting: the full RenderPipeline (SDF megashader compile included) moves into onInitialize/onTick. An invariant test pins that pipeline creation never runs on the GUI thread.
  3. Scene handover: ConstructSnapshot → rebuildConstructFromSnapshot on the worker; re-pushed on every structural edit.
  4. 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.
  5. 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 (riding rhi::AuthorityJournal for durability); onDeviceLoss = re-init + PaintEngine::rebuildAll(journal). The replay basis and the live-apply basis are the same function (PaintEngine::applyOperationStamps), proven bit-exact by testReplayParityMatchesLiveApply.
  • 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. A QSettings override still wins for tuning.
  • Adaptive quality that respects a hard constraint (§GSI-S7): the producer publishes a RenderTelemetry sample every frame (measured render+readback time, resident VRAM vs budget) and feeds a QualityController. The sole knob is render-scale (opt-in paint/rhi/adaptiveRenderScale): a degraded tier renders at mOutSize × scale and 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 onDeviceLoss as re-init + PaintEngine::rebuildAll(journal) — the WorkerHost fault chain (rule 4) drives it. Because the app treats workerError as 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/.

In this section
Topics
howto rendering rhi worker gpu architecture