19 KiB
03 — HTML5 Rendering Pipeline
This chapter covers Greenfield's browser-side rendering pipeline: how
incoming Wayland buffers (SHM or encoded) end up as pixels on a browser
canvas/WebGL context, including damage tracking, commit semantics, surface
state stacking, and the compositing loop. The browser side lives in
packages/compositor/src/.
Three buffer types, one BufferImplementation interface
All incoming buffers, regardless of origin, end up as a BufferContents<T>
with a mimeType that dispatches to the right upload path. There are three
concrete buffer types:
1. SHM buffer (LazyImageBitmapBuffer)
Comes from wl_shm_pool — the standard Wayland shared-memory buffer
mechanism. Browser-side implementation lives in Shm.ts. The raw pixel data
arrives as a transferable ArrayBufferView (transferred zero-copy via
MessagePort from a browser-iframe web app, or fetched via the FD endpoint
WebSocket from the proxy). Greenfield wraps the bytes as an ImageData /
BMP-Blob and decodes them via createImageBitmap() to produce an
ImageBitmap ready for WebGL texImage2D.
Notable hack: for xrgb8888 SHM buffers, Greenfield prepends a BMP
header in front of the raw pixel data so the browser's createImageBitmap
decodes it natively, avoiding a per-pixel JS loop. This is a clever trick to
offload pixel conversion to the browser's BMP decoder.
2. Web bitmap buffer (ImageBitmapBuffer via web_bitmapbuf_factory)
Comes from Greenfield's custom web-bitmapbuf-unstable-v1 protocol
extension. Lets browser JS apps hand a GPU-backed ImageBitmap directly to
the compositor as a wl_buffer — bypassing SHM copy, encoding, and dmabuf
import. The fd arg is marshalled as a transferable, so the GPU texture
never leaves VRAM.
3. Remote streaming buffer (StreamingBuffer)
Comes from the native proxy via the FRAME data channel. Receives encoded
frames (H.264 or PNG) on a separate WebSocket per surface. Includes an
out-of-band mechanism for BufferCreation and CommitSerial opcodes so
the browser can match each encoded frame to the right wl_surface.commit
serial.
Surface state stack
Surface.ts implements classic Wayland double-buffered state:
Surface.state— the committed, currently-visible state.Surface.pendingState— the in-flight state, accumulated by client requests untilcommitis called.mergeSurfaceState— the atomic swap incommitPending().
Subsurfaces add a third layer of complexity:
subsurfaceChildrenarrays (committed + pending, bottom→top)placeAbove/placeBelowreorder pendingeffectiveSyncgetter cascades through the ancestor chain (if any ancestor is sync, this subsurface is effectively sync)- Sync subsurfaces cache to
cachedStateand only flush when an ancestor commits viaonParentCommit setDesyncimmediately callsonCommitto flush any pending cache
Commit semantics
When a Wayland client calls wl_surface.commit:
- If the surface has a remote streaming buffer, Greenfield pulls the
serial = commitSerials.shift()(an out-of-band opcode 7 mechanism that letsStreamingBuffer.getContents(surface, serial)wait for the right encoded frame). bufferContents = await bufferImpl.getContents(surface, serial)— resolves when the encoded frame for this serial has arrived.role.onCommit(this)— role-specific logic (XdgToplevel, XdgPopup, Subsurface, etc.).Surface.commitPending()runs:- Commit
subsurfaceChildrenorder + apply pendingPosition → cascadeonParentCommit. - Calculate derived pending state (buffer transform matrix, surface size, pixman region).
- Release old buffer if replaced.
mergeSurfaceState(state, pendingState)— atomic swap.damaged = true; clear pending damage + frame callbacks.
- Commit
Damage tracking
damageRects (post-transform) and bufferDamageRects (buffer-space) are
accumulated correctly on the surface state via pixman-WASM region math. The
damage is tracked faithfully — but vestigial at the GL level. The
renderer doesn't consume it for scissoring — it repaints every visible view
every RAF. The only damage gate is the boolean surface.damaged, used to
decide whether a streaming buffer's frame needs re-upload + release.
RenderState.visibleSceneRegion (pixman) is used only to decide if a
RenderState exists at all (i.e. whether the view is on this output), not
for per-rect clipping.
Compositing pipeline
The renderer is WebGL1 only, no OffscreenCanvas, no WebGL2. The
context is acquired with getContext('webgl', {antialias:false, depth:false, alpha:true, premultipliedAlpha:false, preserveDrawingBuffer:false, desynchronized:true}). FBOs are used for in-GPU YUV→RGBA and
RGB+Alpha→RGBA conversion into per-view textures.
The compositor runs in four phases per render pass:
PHASE 1 — pixel upload (per view per scene)
Coalesced via setTimeout-based cancellable microtask. For each view, for
each scene it's visible on:
view.applyTransformations()— computeparent.transformation * translate(position+offset).- For each
renderStateinview.renderStates:scene[bufferContents.mimeType](bufferContents, renderState)— dispatch to the right upload path:'image/bitmap'→texture.setContent(imageBitmap, size)— directtexImage2D/texSubImage2Dupload.'video/h264'→YUVA2RGBAorRGBXandA2RGBA.convertInto(...)— FBO pass that uploads Y/U/V/alpha-Y as 4× LUMINANCE textures, runs the YUVA→RGBA shader (BT.601 coefficients), writes into the renderState's RGBA texture.'image/png'→texture.setContent(bitmap, size); bitmap.close().
- Collect
surface.state.frameCallbacks→Renderer.frameCallbacks. surface.damaged = false;streamingBuffer.release()(remote only).
PHASE 2 — schedule render frame
Renderer.render() coalesces via a setTimeout-based cancellable
microtask, then schedules a single requestAnimationFrame for PHASE 3.
PHASE 3 — draw (per scene, inside RAF)
For each scene (one per canvas/output):
sceneShader.use();updateSceneData(canvas)— viewport, clear, BLEND, projection.- For each view in the view stack (flattened from
topLevelViewsrecursively viaRenderer.updateViewStack):- If
renderState && view.mapped:sceneShader.updateViewData(view, renderState)— bind texture,u_transform, quad buffer.sceneShader.draw()—gl.drawArrays(TRIANGLE_STRIP, 0, 6)of a textured quad per scene.
- If
PHASE 4 — fire frame callbacks
For each callback in Renderer.frameCallbacks:
callback.done(time) → wl_callback.done(time); wl_callback.destroy().
Then session.flush() to send any pending wire messages.
The time value is the requestAnimationFrame high-resolution timestamp,
so apps see real vsync-aligned frame timing.
Multi-output
Multi-output = one Scene per canvas. Each view has one RenderState (and
one WebGL texture) per scene it's visible on. So a surface visible on two
outputs (e.g. mirroring) has two textures — one per canvas — and gets
uploaded to each on PHASE 1.
Subsurfaces stacking
Subsurfaces stack via subsurfaceChildren arrays (committed + pending,
bottom→top). placeAbove / placeBelow reorder pending;
Renderer.updateViewStack flattens topLevelViews recursively.
Important: no parent-geometry clipping — subsurfaces can render outside their parent. The only clip is the scene's canvas region, enforced implicitly by NDC mapping.
Cursor and DnD bypass GL entirely
Cursor and DnD bypass GL entirely. They draw to a separate singleton 2D
canvas and use CSS cursor: url(dataURL) or HTML5
dataTransfer.setDragImage. Their frame callbacks fire immediately with
Date.now(), not synced to RAF.
This is a deliberate optimization: cursor updates must be low-latency, and going through the full GL pipeline would add a frame of latency.
Decode pipeline (for H.264 frames)
Decoding is delegated to:
Preferred path: WebCodecs VideoDecoder
At session startup, Session.create() probes
VideoDecoder.isConfigSupported({codec:'avc1.64001f', optimizeForLatency:true, hardwareAcceleration:'prefer-software'}). If supported, an opaqueDecoder
and alphaDecoder are created per surface (lazily, on first H.264 frame).
Encoded NALs are wrapped as EncodedVideoChunk {type:key|delta, data:nal}
and fed to the decoder. The output VideoFrame is uploaded directly to a
WebGL texture.
Important: hardware decode is explicitly disabled in Session.ts
with a FIXME comment "hardware decoding frame offset is wrong". Only
software decoding is used today.
Fallback path: FFmpeg Wasm in Web Workers
If WebCodecs is unavailable, two persistent Workers (h264NALDecoderOpaque,
h264NALDecoderAlpha) are created at session startup. Each worker has one
AVCodecContext per surface, keyed by surfaceH264DecodeId. Encoded NALs
are transferred zero-copy into the worker via
worker.postMessage(nal, [nal.buffer]). Decoded I420 planes are transferred
back as [y.buffer, u.buffer, v.buffer] and uploaded as 4× LUMINANCE
textures.
PNG path (both decoders)
The PNG path is identical in both branches:
new Blob([opaque], {type:'image/png'}) →
createImageBitmap(blob, sx, sy, w, h) — the sx/sy/w/h crops the
encoder padding back to logical size. PNG is always used for surfaces ≤
256×256 pixels because H.264 I-frame overhead dominates at small sizes.
WebGL shaders
Three shaders in render/:
YUVA2RGBAShader— converts YUVA (Y, U, V, alpha-Y as 4 LUMINANCE textures) to RGBA in a single FBO pass using BT.601 coefficients. Alpha is recovered by running the alpha-Y plane through the same YUV→RGB matrix and taking the R channel.RGBandA2RGBAShader— for theDualPlaneRGBA*WebCodecs outputs (opaque + alpha planes composited into RGBA).SceneShader— final pass: draws every view's RGBA texture to the canvas withgl.blendFunc(ONE, ONE_MINUS_SRC_ALPHA). Note: expects premultiplied alpha but the YUVA shader outputs straight alpha — a minor inconsistency that works because Wayland surfaces rarely use partial alpha.
ASCII pipeline diagram
CLIENT (Wayland app / web worker / compositor-proxy)
│ wl_surface.attach / damage / frame / commit
▼
Surface.commit()
│ serial = commitSerials.shift() (remote-only)
│ bufferContents = await bufferImpl.getContents(surface, serial)
│ role.onCommit(this)
▼
Surface.commitPending()
│ 1. commit subsurfaceChildren order + apply pendingPosition → cascade onParentCommit
│ 2. calculateDerivedPendingState (bufferTransform matrix, surface.size, pixmanRegion)
│ 3. release old buffer if replaced
│ 4. mergeSurfaceState(state, pendingState) ◄── atomic swap
│ 5. damaged = true; clear pending damage/frame callbacks
▼
Renderer.render()
│ coalesce via queueCancellableMicrotask (setTimeout)
▼
PHASE 1 — pixel upload (per view per scene):
view.applyTransformations() // parent.transformation * translate(position+offset)
for renderState in view.renderStates:
scene[bufferContents.mimeType](bufferContents, renderState)
'image/bitmap' → texture.setContent(imageBitmap, size) // texImage2D / texSubImage2D
'video/h264' → YUVA2RGBA / RGBXandA2RGBA.convertInto(...) // 4 LUMINANCE or 2 RGBA tex
→ FBO + YUVA2RGBAShader (BT.601) → renderState.texture
'image/png' → texture.setContent(bitmap, size); bitmap.close()
collect surface.state.frameCallbacks → Renderer.frameCallbacks
surface.damaged = false; streamingBuffer.release() (remote only)
▼
PHASE 2 — createRenderFrame().then(time => { // = requestAnimationFrame
PHASE 3 — for scene in scenes: // one per canvas/output
sceneShader.use(); updateSceneData(canvas) // viewport, clear, BLEND, projection
for view in viewStack:
if renderState && view.mapped:
sceneShader.updateViewData(view, renderState) // bind texture, u_transform, quad buffer
sceneShader.draw() // gl.drawArrays(TRIANGLE_STRIP, 0, 6)
PHASE 4 — for callback in frameCallbacks: callback.done(time)
→ wl_callback.done(time); wl_callback.destroy()
session.flush()
})
The remote streaming buffer content flow
The remote streaming buffer content arrives in parallel on a separate FRAME WebSocket channel:
deliverContentToBufferStream(inRemoteAppLauncher.ts) receives incoming frame messages.BufferStream.onBufferContentsparses the encoded frame envelope.createEncodedFrame(inEncodedFrame.ts) parses{contentSerial, mimeType, size, encodedSize, opaque, alpha?}.- Resolves the
onFrameAvailable(serial)promise thatStreamingBuffer.getContentsis awaiting. - The decoded
DecodedFrameis then uploaded into the view'sRenderState.texturevia the appropriate FBO+shader conversion.
Engineering observations
- Vestigial damage tracking —
damageRectsare accumulated correctly but the renderer doesn't use them for scissoring. Could be a future optimization for partial repaints. - Multi-output texture cost — each view has one texture per scene, so 4 outputs means 4× texture memory for every surface.
- SHM xrgb8888 BMP-header hack — cleverly offloads pixel conversion to the browser's BMP decoder. Worth knowing if you need to support other SHM formats.
- Commit-serial mechanism — the out-of-band opcode 7 lets the streaming buffer wait for the right encoded frame, ensuring wire-order semantics even with async decode.
- Encoder feedback telemetry —
EncoderFeedback.tsmeasures client decode duration, but the adaptation loop is wired but not consumed (a missed opportunity for adaptive bitrate). - Context-loss audit gap — WebGL context loss is not audited in the
test suite; in production, you should handle
webglcontextlostandwebglcontextrestoredevents. queueCancellableMicrotaskmisnomer — the function is actually asetTimeout-based coalescer, not a microtask. Naming is misleading.- Frame-callback coalescing — multiple
wl_surface.framerequests within one render pass fire on the same RAF tick. Correct Wayland behavior, but worth knowing. - Cursor/DnD bypass — they bypass the GL pipeline entirely for
latency. The cursor is rendered as a CSS
cursor: url(dataURL). - Subsurface sync cascade — three-layer mechanism (
cachedState,commitCache,effectiveSync) is correct but subtle. Easy to break in maintenance.
File reference
| File | Role |
|---|---|
packages/compositor/src/Surface.ts |
wl_surface implementation, double-buffered state, commit semantics |
packages/compositor/src/SurfaceChild.ts |
Surface tree child abstraction |
packages/compositor/src/SurfaceRole.ts |
Role abstraction (SurfaceRole interface) |
packages/compositor/src/Subsurface.ts |
wl_subsurface role, sync/desync mode |
packages/compositor/src/Subcompositor.ts |
wl_subcompositor global |
packages/compositor/src/View.ts |
Positioned surface for compositing |
packages/compositor/src/Output.ts |
wl_output, WebGL canvas binding |
packages/compositor/src/Shm.ts |
wl_shm / shm pool, BMP-header hack |
packages/compositor/src/BufferContents.ts |
Buffer abstraction (with mimeType) |
packages/compositor/src/BufferImplementation.ts |
Buffer implementation interface |
packages/compositor/src/ImageBitmapBuffer.ts |
ImageBitmap-backed buffer |
packages/compositor/src/Region.ts |
wl_region, pixman-WASM damage regions |
packages/compositor/src/Callback.ts |
Frame callback (wl_callback) |
packages/compositor/src/Loop.ts |
Compositor main loop, RAF integration |
packages/compositor/src/Compositor.ts |
wl_compositor global |
packages/compositor/src/Session.ts |
Top-level browser session, decoder setup |
packages/compositor/src/Globals.ts |
Wayland globals registry |
packages/compositor/src/render/Renderer.ts |
4-phase render pipeline |
packages/compositor/src/render/Scene.ts |
One per output, manages view stack + textures |
packages/compositor/src/render/SceneShader.ts |
Final pass: textured quads to canvas |
packages/compositor/src/render/Texture.ts |
WebGL texture wrapper |
packages/compositor/src/render/RenderState.ts |
Per-view-per-scene texture + transform |
packages/compositor/src/render/Program.ts |
WebGL program wrapper |
packages/compositor/src/render/ShaderCompiler.ts |
Shader compile/link helper |
packages/compositor/src/render/ShaderSources.ts |
GLSL source strings |
packages/compositor/src/render/YUVA2RGBA.ts |
YUVA→RGBA FBO pass |
packages/compositor/src/render/YUVA2RGBAShader.ts |
BT.601 YUVA→RGBA shader |
packages/compositor/src/render/YUV2RGBShader.ts |
YUV→RGB shader (no alpha) |
packages/compositor/src/render/RGBXandA2RGBA.ts |
RGB+Alpha → RGBA FBO pass (WebCodecs output) |
packages/compositor/src/render/RGBandA2RGBAShader.ts |
Shader for above |
packages/compositor/src/remote/StreamingBuffer.ts |
Per-surface encoded-frame buffer |
packages/compositor/src/remote/BufferStream.ts |
Manages serial → frame promise resolution |
packages/compositor/src/remote/EncodedFrame.ts |
Wire format parser for encoded frames |
packages/compositor/src/remote/buffer-decoder.ts |
Decoder interface |
packages/compositor/src/remote/webcodec-buffer-decoder.ts |
WebCodecs VideoDecoder path |
packages/compositor/src/remote/wasm-buffer-decoder.ts |
FFmpeg Wasm fallback path |
packages/compositor/src/remote/H264NALDecoder.worker.ts |
Worker entry, runs FFmpeg Wasm decoder |
packages/compositor/src/remote/EncoderFeedback.ts |
Receives 4-byte feedback messages |
packages/compositor/src/remote/Channel.ts |
ARQChannel + SimpleChannel |
packages/compositor/src/remote/RemoteOutOfBandChannel.ts |
OOB opcode dispatch |
packages/compositor/src/remote/RemoteAppLauncher.ts |
Sets up channels per app, frame delivery |
packages/compositor/src/remote/RemoteInputOutput.ts |
FD-over-WebSocket for native apps |
packages/compositor/src/browser/pointer.ts |
DOM pointer → wl_pointer events |
packages/compositor/src/browser/dnd.ts |
DnD bridge |
packages/compositor/src/browser/selection.ts |
Clipboard bridge |
packages/compositor/src/browser/capabilities.ts |
Browser feature detection |
packages/compositor/src/browser/input.ts |
DOM keyboard → wl_keyboard events |
packages/compositor/src/desktop/Desktop.ts |
Floating vs AlwaysFullScreen modes |
packages/compositor/src/math/Mat4.ts |
4×4 matrix math |
packages/compositor/src/math/Rect.ts |
Rectangle math |
packages/compositor/src/polyfill/createImageBitmap.js |
ImageBitmap polyfill |