GPU Engine
WebGPU rendering, texture management, preview output, export capture, and GPU-backed analysis.
Linux/Mesa: GPU-accelerated canvas, worker
OffscreenCanvas, and WebGPU paths fail silently on open-source Mesa drivers. Before changing any canvas/GPU code, read Linux / Mesa GPU.
Overview
Section titled “Overview”WebGPUEngine is a thin facade over the rendering subsystems. It owns WebGPU context setup, texture and cache managers, the render loop, the render dispatcher, output windows, and export canvas state.
The engine supports:
- Main preview rendering
- Independent preview targets
- Popup output windows
- Sliced output / corner-pin rendering
- RAM preview and scrubbing caches
- Zero-copy export frames when the export canvas path is available
- CPU readback as a fallback path
- Waveform, histogram, and vectorscope GPU panels
- Native 3D scene and Gaussian-splat composition when their enabled runtime flags apply
Architecture
Section titled “Architecture”Core Pieces
Section titled “Core Pieces”WebGPUContexthandles adapter/device setup and device-loss recovery.RenderLoopruns the RAF loop, idle detection, scrub limiting, and watchdog recovery.RenderDispatcherhandles per-frame texture import, compositing, output, and preview fallbacks.RenderTargetManagerowns ping/pong compositing buffers plus independent preview buffers.TextureManagerandMaskTextureManagermanage image/video/mask textures.CacheManagerowns scrubbing cache, composite RAM preview cache, and GPU frame cache.ExportCanvasManagerowns the export OffscreenCanvas and stacked-alpha mode.
Runtime State
Section titled “Runtime State”- The engine keeps a unified
Mapof target canvases. - Preview canvases and output windows are reconfigured after device restore.
- HMR reuses the engine singleton when possible.
Initialization Failures And Device Recovery
Section titled “Initialization Failures And Device Recovery”Startup distinguishes missing WebGPU, an unavailable adapter, adapter/device request timeouts, and renderer-resource failures. The preview displays the specific failure instead of describing every failure as incompatible hardware. A timed-out request can still be pending inside the browser, so recovery does not queue additional requests behind it. Late GPU devices are destroyed.
After unexpected device loss, the context drops the lost device after cache cleanup and makes up to three recovery attempts for settled failures. Recovery waits for asynchronous renderer resources before making the preview ready. Timeouts and unsupported runtimes terminate with a visible error rather than an indefinite loading state. Destroy/reinitialize invalidates older recovery work, and retained output canvases are rebound to the replacement device.
Only the first real loss per session is reported as an error; repeats are
warnings. A loss while the page is hidden, frozen, or unloading (Android Chrome
reports it as “Device was destroyed.”) is a warning and recovery waits until the
page is visible again. Three real losses within a minute stop recovery and show
a driver-reset message instead of looping lose/recover. That stop is its own
incident and files one error, GPU device lost repeatedly; recovery stopped,
with the loss message, its occurrence and the window (and firstLoss when an
earlier loss of the same run was still held), since the GPU stays unavailable
afterwards.
Each loss incident files at most one error. The first real loss’s error waits
for the recovery outcome: a recovered device reports Device lost, while a
recovery that gives up (for example when Chrome refuses a new adapter after
repeated GPU-process crashes) reports only GPU recovery failed, including the
loss message of that incident as lostDevice (also for a later loss whose
Device lost was not filed again). The individual recovery attempts in between
are warnings. The preview then says the GPU stopped responding and asks to save
and reload the editor to restore it. Because the GPU already worked in this
session, a recovery failure shows no setup advice: no Linux Vulkan banner and no
driver/Vulkan help line (setEngineInitFailed(..., { setupAdvice: false })). The console capture in runtime diagnostics
drops the Logger’s clock prefix so a logged failure keeps one stable message.
Targeted coverage: webgpuContext.test.ts, contextRecoveryWiring.test.ts,
and outputCanvasRecovery.test.ts under tests/unit/.
Boot Health And GPU Hazard Recovery
Section titled “Boot Health And GPU Hazard Recovery”Risky GPU startup records a release-scoped boot attempt in bootHealth.ts.
Three interrupted attempts inside 24 hours activate a GPU hazard for that
release and route canvas policy through the existing software fallback. An
attempt becomes healthy only after the active render mode proves a readable
frame; worker submission alone is not treated as pixel proof.
Device loss records the same hazard signal. The visible warning offers a
manual Retry GPU action that clears the safety state and reloads the app.
Clean pagehide completion and a three-second settled startup prevent normal
reloads from being counted as crashes. Runtime owners and the engine singleton
remain HMR-safe.
Pipelines The Driver Cannot Build
Section titled “Pipelines The Driver Cannot Build”A render or compute pipeline can fail in validation, in the driver compiler
(internal, e.g. CreateGraphicsPipelines failed with VK_ERROR_UNKNOWN on
Android Vulkan drivers) or for lack of memory. WebGPU still returns a pipeline
object; setting it, or a bind group made from its auto layout, invalidates the
whole command buffer, so every layer of the frame disappears.
guardGpuDevice(device, source) (src/engine/core/gpuPipelineGuard.ts) is
installed right after every requestDevice that renders: the editor
(WebGPUContext), the render worker, thumbnails, look thumbnails and the photo
guide preview. It scopes every synchronous pipeline creation for all three
error kinds and remembers failed pipelines. Once one failed, render passes,
compute passes and render bundles skip its setPipeline, draws, dispatches and
bind groups derived from it, so only that feature is missing. Work encoded with
a pipeline before its verdict arrives is not protected: by the WebGPU spec the
pass invalidates its encoder, so every browser loses that command buffer, and
shipping Safari also keeps the encoder locked after a pass that set the failed
pipeline, so its next beginComputePass fails with encoder state is invalid. Features that encode right after creating pipelines wait for
gpuPipelineSettled first (effect pipeline cache, flock session gate, flock
renderer). Healthy devices pay nothing: passes
are only wrapped after a failure. Each failure is logged with feature, label,
kind and driver message (huge messages are condensed to their first line and
compiler error lines: WebKit prefixes the whole generated Metal source); the preview shows “Not supported by this graphics
driver” with the affected features (PreviewGpuFailureNotice, full messages as
tooltip). A new device clears the records of its source.
Callers that must react themselves use createCheckedPipeline(device, create),
which returns the pipeline and its verdict without opening competing error
scopes on a guarded device: effects skip and report themselves in the effect
card, and the HAP export encoder falls back to its CPU encoder. On an unguarded
device createCheckedPipeline also records the verdict for gpuPipelineSettled.
markGpuPipelineFallback(pipeline, note) declares that the owner switches to an
equivalent implementation when the pipeline fails: it raises no “not
supported” notice, and is reported once per page session as a non-fatal
ERROR-level log starting with GPU pipeline fallback used (guarded and
unguarded devices), carrying the condensed driver message. Only errors reach
the production diagnostics feed; the classifier gives it the failure code
gpu_pipeline_fallback, so triage can tell it from a skipped feature.
Per-Draw GPU Buffers (Shared Uniform Ring)
Section titled “Per-Draw GPU Buffers (Shared Uniform Ring)”queue.writeBuffer() is staged before the command buffer it belongs to is
submitted. A single buffer rewritten between draws or dispatches recorded into
one encoder therefore gives every recorded pass the last written data (two
layers of one effect both render with the second layer’s settings). Destroying
or replacing a buffer that the unsubmitted encoder still references makes the
whole submit fail.
New GPU nodes and renderers must take per-draw uniforms, settings, and
vertices from src/engine/core/gpuUniformRing.ts instead of owning one shared
buffer:
GpuUniformRing.write(data)returns a buffer that no other call in the same frame receives. The pool grows when a frame needs more slots and rewinds at the next frame start.GpuFrameBuffers.ensure(key, bytes)keeps one growable buffer per key (for example per output target). A replaced buffer is retired and destroyed at the next frame start, never mid-frame. Useretire()for any other buffer that a recorded pass may still use.- Frames are
'explicit'when the owner has a begin hook that runs after all of its encoders were submitted (GaussianSplatGpuRenderer.beginFrame()drives the splat cull, sort, particle, and effector passes). Use'task'for nodes reached from several frame owners without a shared hook (pixel-particle renderer, slice vertex buffers): the frame ends with the current synchronous section, so record and submit an encoder without anawaitin between. - Data that a later frame reads again needs its own key, never a shared scratch buffer. The splat GPU sort writes into one sorted-index buffer per draw stream (uploaded scene plus graph branch). With a sort frequency above 1, skipped frames redraw that stream’s own last order and count, bound to the current data buffer; before, every layer reused the single shared sort output (another layer’s order, or a buffer destroyed after growth). A stream’s buffer is retired when its scene is released or after 600 renderer frames without a draw. Sort frequency 0 draws the current cull/identity order, never a frozen one. Cull counts are tracked per stream too.
- The splat camera uniforms, splat mesh uniforms, and splat graph compute
slots use their own per-call pools rewound in
GaussianSplatGpuRenderer.beginFrame(). They follow the same rule (one slot per call, nothing destroyed before the frame start) without the shared helper.
Runtimes with per-instance GPU state (Analog Signal Lab, compute image graphs) key instances by render scope plus effect id. Nested compositions keep their original effect ids, so the scope keeps two visible occurrences apart.
Targeted coverage: gpuUniformRing.test.ts, gpuFrameScopedRenderers.test.ts
(including two GPU-sorted splat layers across a skipped-sort frame),
splatSortPass.test.ts, and effectsPipelineComputeInstanceScope.test.ts under
tests/unit/.
Texture Paths
Section titled “Texture Paths”HTMLVideoElementis imported as an external texture when the browser supports it.VideoFramecan also be imported as an external texture.- Firefox preview uses copied textures instead of external video import because imported frames can intermittently go black.
- Android Chromium copies HTML-video preview frames into persistent GPU textures during playback, pause, and seeking because its external video textures can otherwise present intermittent black frames.
Images And Canvas
Section titled “Images And Canvas”- Images and canvases are copied into
rgba8unormGPU textures. - Cached image views are reused when possible.
Motion Shapes
Section titled “Motion Shapes”motion-shapeclips render throughsrc/engine/motion/MotionRenderer.ts.- Rectangle, ellipse, polygon, and star primitives are drawn with analytic WGSL SDFs into transparent
rgba8unormtextures. - A bounded uniform layout composites up to 8 ordered color-fill, stroke, linear-gradient, radial-gradient, or texture-fill appearances, with up to 8 stable stops per gradient and six shader blend modes.
- Replicated motion shapes use a per-shape instance buffer and instanced draws in the same shader path. The shader ceiling is 100,000 instances; effective counts are also constrained by the device, render target, and configured user limit.
- The resulting texture view is composited through the normal
CompositorPipeline, so masks, effects, blend mode, nested comps, preview targets, and export share the same downstream path.
- Mask textures are uploaded per layer.
- A 1x1 white fallback texture is used when no mask is present.
Limitation
Section titled “Limitation”- The zero-copy path only applies when the browser and source type support it. Preview and export still have explicit fallback paths.
Render Loop
Section titled “Render Loop”Idle And Scrub Control
Section titled “Idle And Scrub Control”- Idle mode starts after about 1 second of no activity.
- Idle detection is initially suppressed so browser video surfaces can warm up; it is lifted by the first play event or after about 3 seconds.
- Playback is limited to about 60 fps.
- Scrubbing is limited to about 60 fps unless a fresh video frame arrives, which bypasses the limiter.
- While a video plays, its decoded frames also fill the scrubbing cache. That capture keeps to about 1.5 ms of main thread per played frame on average: when
createImageBitmapof a video converts synchronously (about 6 ms per 1080p frame on Linux/Vulkan), the following frames pass uncached, so playback keeps its frame rate.ScrubTextureCache.getPlaybackCaptureStats()reports the last capture cost and the frames that passed. - A render request is consumed only by a frame that reaches the render callback. Frames skipped during device recovery, export, or while editor content publication is blocked (project open or composition switch) keep the request, so the first frame after the gate reopens shows the change. A direct
renderHostPort.render()made while publication is blocked becomes such a request instead of being dropped. - During playback and scrubbing the preview keeps one GPU submission in flight and coalesces newer requests until
onSubmittedWorkDone()settles; a paused preview submits every request for a new picture at once, however many earlier paused submissions are still pending, so a seek onto a new clip is shown immediately even when WebKit settles earlier fences late. A paused request waits and coalesces only when the same picture (composition, timeline time and layer/clip identities) is already in flight, which keeps slider drags bounded; a safety bound of 6 distinct paused targets in flight defers further frames and logsPreview GPU queue saturated.getPreviewGpuBackpressureState(device)reports in-flight submissions, a pending request and the stalled-fence count. A completion fence that does not settle within 1 second releases the queue, renders the newest request, and logsPreview GPU completion did not settlewith a running count, so a stalled fence can no longer freeze the preview until a reload. Until a device completed its first preview fence (pipeline and shader compilation at cold start), the window is 5 seconds and a release is logged at info, so the warning always means a stall of a warmed-up device. - Paused and scrub-settle holds re-present the previous composite only while the current layers show the same clips and sources; a frame whose raster or nested composition is not ready yet on another clip renders empty instead of keeping the previous clip’s picture, and canvas/image rasters that were not collected request follow-up frames.
Watchdog
Section titled “Watchdog”- A watchdog checks for stalls every 2 seconds.
- If the render loop stops producing frames for about 3 seconds while it should be active, it wakes the loop or restarts it.
- Stall time is measured only while frames are wanted and possible: idle periods, a hidden tab, export, device recovery and a held publication (project switch, editing handed to another tab) advance the baseline instead of counting as a stall.
- When the browser delivers no animation frames although the page reports visible (background or occluded window), the check is paused; a one-shot probe frame tells this apart from a loop that lost its callback, which is restarted with a warning.
Export And RAM Preview
Section titled “Export And RAM Preview”- The render loop skips preview rendering while export is active.
- Export and RAM preview both set flags through
ExportCanvasManager. - These modes are separate from the normal preview path, but they share the same engine.
Preview Fallback Chain
Section titled “Preview Fallback Chain”RenderDispatcher uses a best-effort chain for video preview:
- Cached scrubbing frame
- Copied fallback frame
- Live external texture import
- Last known frame or same-clip hold frame
This is why preview can stay stable during seeks while a fresh frame is pending.
Firefox Special Case
Section titled “Firefox Special Case”- Firefox uses
getCopiedHtmlVideoPreviewFrame()for HTML video preview stability. - That path copies the current video frame into a persistent GPU texture and falls back to the previous stable frame when needed.
- Firefox 157 exposes WebGPU without
importExternalTexture/texture_external.supportsExternalTextures(device)(src/engine/texture/externalTextureSupport.ts) detects this and logs one warning. The compositor then creates no external composite/copy pipelines (resources.externalisnull),TextureManager.importVideoTexture()returnsnullso collectors use copied textures, andTemporalFrameUploadercopies decodedVideoFrames into its transfer texture; Slit Scan skips the external-texture shared-source block and uses the atlas path. Without this, startup raised uncaptured shader, bind-group-layout and pipeline validation errors. - On Chromium, an import that fails because the video frame has no GPU backing yet (“doesn’t have back resource”, seen with a just-started live camera) is logged as a warning and retried next frame; other import failures stay errors.
Output And Export
Section titled “Output And Export”OutputPipeline
Section titled “OutputPipeline”- The output pipeline uses separate uniform buffers for transparency grid off/on and stacked-alpha export.
- Full-frame output and compositor passes draw one oversized triangle, avoiding a diagonal seam or missing half-frame when a mobile WebGPU driver drops one primitive from a two-triangle quad.
renderToCanvas()catches canvas-context loss and simply skips that target for the frame.- Bind-group caches are separate per output mode.
Stacked Alpha
Section titled “Stacked Alpha”- Stacked alpha doubles the encoded export height.
- RGB is rendered in the top half and alpha grayscale in the bottom half.
ExportCanvasManagercreates the doubled-height OffscreenCanvas when stacked alpha is enabled.
Readback
Section titled “Readback”readPixels()is the CPU fallback path.- It is used by export fallback paths and preview capture utilities.
- It is slower than the zero-copy export path and should be treated as a fallback, not the normal route.
Export Canvas
Section titled “Export Canvas”ExportCanvasManager is responsible for export-state flags and the export canvas lifecycle.
Current Behavior
Section titled “Current Behavior”initExportCanvas()creates an OffscreenCanvas with WebGPU context.createVideoFrameFromExport()waits fordevice.queue.onSubmittedWorkDone()before constructing aVideoFrame.- If the zero-copy path cannot be created, export falls back to
readPixels(). cleanupExportCanvas()clears the canvas and stacked-alpha state after export.
Limitation
Section titled “Limitation”- Zero-copy export only works when OffscreenCanvas + WebGPU +
VideoFramecreation are available and the export canvas can be configured.
Feature Flags
Section titled “Feature Flags”Runtime flags are exposed on window.__ENGINE_FLAGS__.
useFullWebCodecsPlaybackanddisableHtmlPreviewFallbackare synced with the persisted settings toggle.useLiveSlotTriggerswaps slot-grid clicks from editor-open behavior to direct live triggering.useWarmSlotDecksprepares reusable slot-owned live decks for faster layer adoption.use3DLayersanduseGaussianSplatare enabled in this branch.workerFirstRenderHostexists for worker-host presentation, but defaults tofalse; the main-thread render host remains the default.timelineCanvasWorkeris enabled for eligible timeline rows; it is separate from the main preview render host.- Motion shape/appearance rendering and the Grid Replicator MVP are always part of the native layer path.
Performance Notes
Section titled “Performance Notes”ScrubbingCachekeeps up to 192 video scrub frames at 30 fps quantization, with a 192 MiB texture budget and a 960-pixel maximum source dimension.- Composite RAM preview caches are capped at 900 frames and 512 MB.
- GPU RAM preview cache is capped at 60 frames.
- These are hard cache limits from code.
Preview Quality
Section titled “Preview Quality”useEngine()scales the active-composition base resolution by the persisted preview-quality multiplier before callingengine.setResolution(...).Full,Half, andQuartertherefore change the GPU render size for engine-backed preview targets.- Export output resolution still comes from the composition/export settings, not from preview quality.
Troubleshooting
Section titled “Troubleshooting”| Problem | What To Check |
|---|---|
| Black preview after reload | Video GPU surfaces may not be warm yet. The engine suppresses idle until first play, but browser readiness still matters. |
| Firefox preview instability | HTML video fallback should be using copied textures, not external import. |
| Export canvas missing | Verify WebGPU context creation on OffscreenCanvas and device validity. |
| Preview stuck on an old frame until reload | Look for Preview GPU completion did not settle (stalled GPU fence; the queue now reopens after 1 second) and RenderLoop debug lines about deferred requests. |
| Device lost | The engine attempts recovery and reconfigures canvases, but a manual reload may still be needed. |
Sources
Section titled “Sources”Key implementation files:
src/engine/WebGPUEngine.tssrc/engine/render/RenderDispatcher.tssrc/engine/render/RenderLoop.tssrc/engine/render/htmlVideoPreviewFallback.tssrc/engine/motion/MotionRenderer.tssrc/engine/motion/shaders/motionShapes.wgslsrc/engine/analysis/ScopeRenderer.tssrc/engine/native3d/NativeSceneRenderer.tssrc/engine/pipeline/OutputPipeline.tssrc/engine/pipeline/SlicePipeline.tssrc/engine/managers/ExportCanvasManager.tssrc/engine/texture/ScrubbingCache.tssrc/engine/core/RenderTargetManager.tssrc/engine/core/gpuUniformRing.tssrc/engine/featureFlags.tssrc/engine/video/VideoFrameManager.tssrc/services/render/renderHostPort.ts
