Overview
Every frame follows the same path: theuseEngine React hook initializes the engine once, then a requestAnimationFrame loop drives the RenderDispatcher, which collects textures, composites layers, and submits everything to the GPU in a single device.queue.submit().
Pipeline diagram
How a frame is rendered
useEngine initializes the engine
src/hooks/useEngine.ts calls engine.initialize(), which sets up WebGPUContext (adapter, device, canvas) and creates all GPU pipelines. The engine is a singleton that survives HMR via import.meta.hot.data.RenderLoop drives the RAF
src/engine/render/RenderLoop.ts starts a requestAnimationFrame loop with:- Idle detection: stops rendering after 1s of inactivity (RAF stays alive)
- Frame rate limiting: ~60 fps during playback, ~30 fps baseline during scrubbing
- Watchdog: checks every 2s, detects 3s stalls, auto-restarts dead RAF loops
RenderDispatcher orchestrates the frame
On each RAF tick,
src/engine/render/RenderDispatcher.ts runs in order:compositorPipeline.beginFrame()— clear frame-scoped cacheslayerCollector.collect()— import textures from all layer sourcesnestedCompRenderer.preRender()— pre-render nested compositionscompositor.composite()— ping-pong compositing with effectsoutputPipeline.renderToCanvas()— output to main preview + all active render targetsslicePipeline.renderSlicedOutput()— sliced output for corner-pin targetsdevice.queue.submit()— single batched GPU submitperformanceStats.recordRenderTiming()— update stats
LayerCollector imports textures
src/engine/render/LayerCollector.ts resolves textures in priority order (default mode, useFullWebCodecsPlayback = false):- Native Helper —
ImageBitmapfrom native decoder - Direct VideoFrame — from parallel decode
- HTML Video —
HTMLVideoElement(active during scrub/pause) - WebCodecs — full mode or export
- Cache fallbacks — scrubbing cache, stall hold frame
- Image / Text Canvas / Nested Composition
scrubGraceUntil (~150ms) keeps the HTML preview path active after scrubbing stops, allowing seek completion before switching back to normal decoding.Compositor runs ping-pong compositing
src/engine/render/Compositor.ts alternates between Ping and Pong textures, compositing one layer per pass:EffectTemp1/2 textures for pre-processing before each layer is composited.NestedCompRenderer handles compositions-in-compositions
Before the main composite,
src/engine/render/NestedCompRenderer.ts pre-renders any nested compositions:- Pooled ping-pong texture pairs keyed by
widthxheight— no per-frame allocation - Frame-level caching: skips re-render if the same time + layer count (quantized to 60 fps)
- Recursive up to
MAX_NESTING_DEPTHlevels - Command buffers are batched with the main composite for a single
device.queue.submit()
OutputPipeline writes to canvases
src/engine/pipeline/OutputPipeline.ts renders the final composited frame to:- Main preview canvas — primary editor display
- Render target canvases — registered via
registerTargetCanvas() - Output windows — external popup windows via
OutputWindowManager - Export canvas —
OffscreenCanvasfor zero-copyVideoFramecreation
uniformBufferGridOn, uniformBufferGridOff, uniformBufferStackedAlpha) allow different targets in the same command encoder to have different transparency grid or alpha states.SlicePipeline renders corner-pin outputs
src/engine/pipeline/SlicePipeline.ts handles corner-pin warped output slices:- 16×16 vertex subdivision per slice for perspective-correct warping
- CPU-computed vertex positions (position.xy + uv.xy + maskFlag per vertex)
- Supports inverted and non-inverted mask strips
Nested composition rendering
Nested compositions (compositions placed on a parent timeline) are rendered to pooled offscreen GPU textures before the parent’s ping-pong composite runs.- Pooled textures: texture pairs are reused across frames (keyed by resolution) to avoid per-frame GPU allocation
- Frame caching: if the nested composition’s time and layer count haven’t changed (quantized to 60 fps), the pre-render is skipped entirely
- Single GPU submit: nested composition command buffers are enqueued alongside the parent’s composite and flushed in one
device.queue.submit()
Export pipeline
Export uses the same render path with two differences:ExportCanvasManagercreates anOffscreenCanvasat export resolution- After rendering each frame,
new VideoFrame(offscreenCanvas)captures the GPU output directly — noreadPixels(), no staging buffers
Performance statistics
src/engine/stats/PerformanceStats.ts tracks per-frame timing:
| Metric | Description |
|---|---|
rafGap | EMA-smoothed gap between RAF calls |
importTexture | Time to import all layer textures |
renderPass | Time for compositing passes |
submit | Time for device.queue.submit() |
total | Full frame time |
fps | Updated every 250ms |
drops | Count, last-second rate, reason |
slow_raf, slow_import, slow_render.
Related
- GPU Engine — subsystem architecture and texture types
- Debugging — how to inspect the render pipeline at runtime