# 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` 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 |