Files

19 KiB
Raw Permalink Blame History

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 until commit is called.
  • mergeSurfaceState — the atomic swap in commitPending().

Subsurfaces add a third layer of complexity:

  • subsurfaceChildren arrays (committed + pending, bottom→top)
  • placeAbove / placeBelow reorder pending
  • effectiveSync getter cascades through the ancestor chain (if any ancestor is sync, this subsurface is effectively sync)
  • Sync subsurfaces cache to cachedState and only flush when an ancestor commits via onParentCommit
  • setDesync immediately calls onCommit to flush any pending cache

Commit semantics

When a Wayland client calls wl_surface.commit:

  1. If the surface has a remote streaming buffer, Greenfield pulls the serial = commitSerials.shift() (an out-of-band opcode 7 mechanism that lets StreamingBuffer.getContents(surface, serial) wait for the right encoded frame).
  2. bufferContents = await bufferImpl.getContents(surface, serial) — resolves when the encoded frame for this serial has arrived.
  3. role.onCommit(this) — role-specific logic (XdgToplevel, XdgPopup, Subsurface, etc.).
  4. Surface.commitPending() runs:
    • Commit subsurfaceChildren order + apply pendingPosition → cascade onParentCommit.
    • 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.

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:

  1. view.applyTransformations() — compute parent.transformation * translate(position+offset).
  2. For each renderState in view.renderStates: scene[bufferContents.mimeType](bufferContents, renderState) — dispatch to the right upload path:
    • 'image/bitmap' → texture.setContent(imageBitmap, size) — direct texImage2D / texSubImage2D upload.
    • 'video/h264' → YUVA2RGBA or RGBXandA2RGBA.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().
  3. Collect surface.state.frameCallbacks → Renderer.frameCallbacks.
  4. 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):

  1. sceneShader.use(); updateSceneData(canvas) — viewport, clear, BLEND, projection.
  2. For each view in the view stack (flattened from topLevelViews recursively via Renderer.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.

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 the DualPlaneRGBA* WebCodecs outputs (opaque + alpha planes composited into RGBA).
  • SceneShader — final pass: draws every view's RGBA texture to the canvas with gl.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:

  1. deliverContentToBufferStream (in RemoteAppLauncher.ts) receives incoming frame messages.
  2. BufferStream.onBufferContents parses the encoded frame envelope.
  3. createEncodedFrame (in EncodedFrame.ts) parses {contentSerial, mimeType, size, encodedSize, opaque, alpha?}.
  4. Resolves the onFrameAvailable(serial) promise that StreamingBuffer.getContents is awaiting.
  5. The decoded DecodedFrame is then uploaded into the view's RenderState.texture via the appropriate FBO+shader conversion.

Engineering observations

  1. Vestigial damage tracking — damageRects are accumulated correctly but the renderer doesn't use them for scissoring. Could be a future optimization for partial repaints.
  2. Multi-output texture cost — each view has one texture per scene, so 4 outputs means 4× texture memory for every surface.
  3. 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.
  4. 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.
  5. Encoder feedback telemetry — EncoderFeedback.ts measures client decode duration, but the adaptation loop is wired but not consumed (a missed opportunity for adaptive bitrate).
  6. Context-loss audit gap — WebGL context loss is not audited in the test suite; in production, you should handle webglcontextlost and webglcontextrestored events.
  7. queueCancellableMicrotask misnomer — the function is actually a setTimeout-based coalescer, not a microtask. Naming is misleading.
  8. Frame-callback coalescing — multiple wl_surface.frame requests within one render pass fire on the same RAF tick. Correct Wayland behavior, but worth knowing.
  9. Cursor/DnD bypass — they bypass the GL pipeline entirely for latency. The cursor is rendered as a CSS cursor: url(dataURL).
  10. 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