Files

380 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 |