# 09 — Hardware Acceleration, Encoding, WASM, GPU Textures This chapter covers Greenfield's hardware acceleration / encoding / WASM pipeline: how native buffers get encoded on the proxy side, transported, decoded in the browser, and turned into WebGL textures or canvas images. ## What Greenfield actually ships ### Two Wasm libraries under `libs/` **`compositor-wasm`** builds **pixman 0.42.2 + xkbcommon 1.5.0** (Emscripten 3.1.46, `-msimd128 -O3 -flto`). Used by the browser for **region math** (damage tracking) and **keyboard layout** — *not* for pixel format conversion or H.264 decode. **`compositor-ffmpeg-h264`** is a **stripped-down FFmpeg n5.1.2** (`--disable-everything --enable-decoder=h264`) compiled to a 1.7 MB single-file Wasm worker. A 30-line `decoder.c` wrapper exposes `create_codec_context` / `decode` / `close_frame` / `destroy_codec_context`. Decodes to **I420 (YUV 4:2:0 planar)** and returns plane pointers; planes are `postMessage`'d back to the main thread as **transferable `ArrayBuffer`s**. No threads (single-threaded, SIMD only). ### Native encoder addon (`packages/compositor-proxy/native/encoding/`) A **GStreamer wrapper** exposed via Node-API. The TS side (`Encoder.ts`) calls `appEndpointNative.encodeFrame(encoder, bufferId, contentSerial, creationSerial)`. The C side (`node_encoder.c`) sniffs the wl_resource: - `zwp_linux_dmabuf_v1` → DMA path - Legacy `wl_drm` → DMA path - `wl_shm_buffer` → SHM path For DMA-BUFs the proxy wraps the dma-buf as an `EGLImage` and then as a `GstGLMemoryEGL`, so pixels **stay on GPU** through the color-convert stages. ### Threading Encoding runs on a dedicated GLib main-loop thread fed by an `eventfd`-signalled `GAsyncQueue`. Output returns to Node via a `napi_threadsafe_function` with **zero-copy** external Buffers (finalised back to C on GC). ## Encoder selection (per-frame, deterministic) `gst_frame_encoder.c::frame_encoder_descriptions[]` declares **four** pipelines: | name | element | when used | split_alpha | coded-size multiple | |---|---|---|---|---| | `x264` | `x264enc` (software, default) | >256×256 + ARGB/XRGB/dmabuf | true (parallel alpha H.264 stream) | 128 | | `nvh264` | `nvh264enc` (Nvidia NVENC) | same, `--encoder=nvh264` | true | 128 | | `vaapih264` | `vaapih264enc` (Intel/AMD VA-API, "experimental") | same, `--encoder=vaapih264` | true | 128 | | `png` | `pngenc` (fallback) | ≤256×256 + ARGB/XRGB/dmabuf | false (PNG has native alpha) | 16 | **Rule**: pixel_count ≤ 256×256 → PNG; otherwise the configured H.264 encoder. YUV420/NV12/NV21/Y444 SHM formats are *not* supported on the H.264 path and only by PNG at small sizes — effectively unsupported for video-class content. ### Canonical H.264 pipeline ``` appsrc → glupload → glcolorconvert → glshader(scale→coded_size) → glcolorconvert → video/x-raw(memory:GLMemory),format=NV12 → gldownload → queue → x264enc tune=zerolatency bitrate=12000 vbv-buf-capacity=160 bframes=0 rc-lookahead=0 sliced-threads byte-stream=true → video/x-h264,profile=high,stream-format=byte-stream,alignment=au → appsink ``` The `glshader` element does two jobs: 1. Pad up to the encoder's macroblock multiple (128 for H.264, 16 for PNG) using a `u_transformation` matrix. 2. For the **alpha pipeline**, run `vec4(pix.a, pix.a, pix.a, 0.)` so alpha is encoded as a grayscale H.264 stream — H.264 has no alpha, so Greenfield ships two parallel AU streams. Bitrates: opaque 12000 / alpha 1200 (x264), 12000 / 1200 (nvh264). ## Wire format Single contiguous blob, little-endian: ``` struct packed props { u32 buffer_id, creation_serial, content_serial, u32 frame_encoding_type (0=h264, 1=png), u32 width, height, coded_width, coded_height } u32 opaque_length u8 opaque[opaque_length] // H.264 AU or PNG blob u32 alpha_length // 0 if no alpha split u8 alpha[alpha_length] // H.264 AU of alpha-only stream ``` Parsed by `remote/EncodedFrame.ts::createEncodedFrame`. ## Transport (`Channel.ts`) Four logical channels per client multiplexed over WebSockets: | Channel | Type | Purpose | |---|---|---| | PROTOCOL | ARQ (KCP) | Wayland wire protocol + out-of-band ops | | FRAME | ARQ (KCP) | Encoded surface frames | | XWM | ARQ (KCP) | XWayland WM messages | | FEEDBACK | SIMPLE (raw WS) | 4-byte feedback messages | ARQ = a JS KCP implementation layered over WebSocket. Tuning: `MTU=64000`, `MAX_BUFFERED_AMOUNT=2949120` (~2.8 MB — the back-pressure ceiling), `SND_WINDOW=128`, `RCV_WINDOW=1024`, `setNoDelay(1, 100, 0, 1)`. ## Browser decode (`packages/compositor/src/remote/`) At session startup `Session.create()` probes `VideoDecoder.isConfigSupported({codec:'avc1.64001f', optimizeForLatency:true, hardwareAcceleration:'prefer-software'})`: ### WebCodecs preferred Software-only; the `prefer-hardware` path is implemented but commented out with `// FIXME hardware decoding frame offset is wrong`. ### FFmpeg Wasm fallback If WebCodecs unavailable: two persistent Workers (`h264NALDecoderOpaque`, `h264NALDecoderAlpha`), one `AVCodecContext` per surface per worker, keyed by `surfaceH264DecodeId`. Encoded NALs are transferred zero-copy into the worker; decoded I420 planes are transferred back. Per-surface decoder context is lazily created on first H.264 frame. ### PNG path (both decoders) Identical in both branches: `new Blob([opaque], {type:'image/png'})` → `createImageBitmap(blob, sx, sy, w, h)` (the `sx/sy` crops the encoder padding back to logical size). ## WebGL upload (`render/`) WebGL 1 with `desynchronized:true` for low-latency presentation. Per Scene (canvas/output), three GL helpers: ### `YUVA2RGBA` FBO pass: uploads Y/U/V/alphaY as **4× LUMINANCE textures**, renders into the surface's RGBA texture via `YUVA2RGBAShader` (BT.601 coefficients, alpha recovered by running the alpha-Y plane through the same YUV→RGB matrix and taking R). ### `RGBXandA2RGBA` FBO pass for the `DualPlaneRGBA*` WebCodecs outputs: opaque + alpha planes composited via `RGBandA2RGBAShader`. ### `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). PNG and web-bitmapbuf paths skip the FBO and call `texImage2D(ImageBitmap)` directly. ### Alpha pre-multiplication Not done explicitly anywhere. The proxy extracts alpha as a Y plane; the browser shader recombines; the canvas blend mode assumes premultiplied but receives straight alpha — works acceptably because Wayland surfaces rarely use partial alpha. ## End-to-end pipeline ``` ┌──────────────────── NATIVE CLIENT PROCESS ────────────────────┐ │ App renders via GTK/Qt/raw EGL into │ │ ┌────────────────────────────────────┐ │ │ │ wl_shm buffer (ARGB8888/XRGB8888)│ ─── or ─────────┐ │ │ │ zwp_linux_dmabuf_v1 (DMA-BUF) │ │ │ │ │ wl_drm create_prime_buffer │ │ │ │ └────────────────────────────────────┘ │ │ └────────────────────────┬──────────────────────────────────┼────┘ │ Wayland socket (UNIX domain) │ ▼ │ ┌────────────────────── COMPOSITOR PROXY (Node + N-API addons)─┐ │ wayland-server-addon.node │ │ └─ wl_display → wl_client → wl_resource (buffer) │ │ └─ initShm(), initDrm("/dev/dri/renderD128") │ │ └─ westfield-egl.c: EGLDevice + EGLDisplay + EGLContext │ │ │ │ proxy-encoding-addon.node (per-surface Encoder) │ │ ┌─ node_encoder.c → frame_buffer {type: SHM|DMA, w, h, fmt} │ │ │ │ │ │ gst_main_loop.c (own GLib thread, eventfd work queue) │ │ │ │ │ │ │ ▼ │ │ │ gst_frame_encoder.c │ │ │ ┌─ Encoder selection (per-frame): │ │ │ │ pixel_count ≤ 256×256 ─► PNG pipeline │ │ │ │ > 256×256 + ARGB/XRGB/dmabuf ─► configured h264 │ │ │ │ │ │ │ │ H.264 pipelines (parallel): │ │ │ │ opaque: appsrc → glupload → glcolorconvert → │ │ │ │ glshader(scale→coded_size) → │ │ │ │ glcolorconvert(NV12) → gldownload → │ │ │ │ queue → x264enc|nvh264enc|vaapih264enc → │ │ │ │ byte-stream AU → appsink │ │ │ │ alpha: same source, fragment shader │ │ │ │ `vec4(pix.a,pix.a,pix.a,0.)` → │ │ │ │ second H.264 stream │ │ │ │ │ │ │ │ PNG pipeline (single): │ │ │ │ appsrc → glupload → glcolorconvert → glshader → │ │ │ │ RGBA → gldownload → pngenc → appsink │ │ │ │ │ │ │ └─ frame_encoding_result_to_encoded_frame(): │ │ │ packed struct {buffer_id, creation_serial, │ │ │ content_serial, type(0=h264,1=png), w, h, │ │ │ coded_w, coded_h} + opaque_len + opaque + │ │ │ alpha_len + alpha → Node Buffer (zero-copy ext.) │ │ │ │ │ └─ SurfaceBufferEncoding.ts │ │ └─ frameDataChannel.send(buffer) │ │ │ │ Channel.ts: │ │ ARQChannel (KCP-reliable over WebSocket, MTU 64 KB) │ │ SimpleChannel (raw WebSocket, for FEEDBACK only) │ │ ▲ │ │ │ FrameFeedback.ts: 16-bit rAF interval + 16-bit avg │ │ │ decode duration → used only to pace wl_frame.done │ └───────┼───────────────────────────────────────────────────────┘ │ 4 logical channels per client: PROTOCOL / FRAME / XWM / FEEDBACK ▼ ┌────────────────────── BROWSER (compositor) ────────────────────┐ │ WebSocket → ReconnectingWebSocket │ │ ↓ │ │ Channel.ts: ARQChannel.input(kcp) → onMessage │ │ ↓ │ │ RemoteAppLauncher.setupFrameDataChannel → │ │ BufferStream.deliverContentToBufferStream (parse serials) │ │ ↓ │ │ StreamingBuffer.getContents(surface, serial) │ │ ↓ │ │ Session.frameDecoder.decode(surface, encodedFrame) │ │ │ │ │ ├─ PNG path (both decoders): │ │ │ new Blob([opaque], {type:'image/png'}) → │ │ │ createImageBitmap(blob, sx, sy, w, h) // crop padding │ │ │ → SinglePlane { bitmap: ImageBitmap } │ │ │ │ │ └─ H.264 path: │ │ (preferred) WebCodecs VideoDecoder │ │ config: avc1.64001f, prefer-software, optimizeForLatency│ │ opaqueDecoder + alphaDecoder per surface │ │ EncodedVideoChunk {type:key|delta, data:nal} │ │ → VideoFrame.output → │ │ if I420: copyTo → DualPlaneYUVAArrayBuffer │ │ else: keep VideoFrame → DualPlaneRGBAVideoFrame │ │ (fallback) FFmpeg Wasm in 2 Web Workers │ │ worker.postMessage(nal, [nal.buffer]) // transferable │ │ libavH264._decode → I420 planes │ │ postMessage back with [y.buffer, u.buffer, v.buffer] │ │ → DualPlaneYUVASplitBuffer │ │ │ │ Renderer.render() (rAF-driven, queueCancellableMicrotask) │ │ ↓ │ │ Scene.render(viewStack) │ │ ├─ Scene['image/png'] → texImage2D(ImageBitmap) │ │ ├─ Scene['image/bitmap'] (web-bitmapbuf path) → texImage2D │ │ └─ Scene['video/h264']: │ │ YUVA2RGBA.convertYUVASplitBufferInto / │ │ convertYUVAArrayBufferInto: │ │ Y/U/V/alphaY → 4× LUMINANCE textures │ │ bind FBO → renderState.texture (RGBA) as color attach │ │ YUVA2RGBAShader (BT.601) → drawArrays → RGBA in tex │ │ or RGBXandA2RGBA.convertInto (WebCodecs RGBA): │ │ VideoFrame/ImageBitmap → texImage2D directly │ │ ↓ │ │ SceneShader.draw(): drawArrays with surface's RGBA texture, │ │ blend gl.ONE / gl.ONE_MINUS_SRC_ALPHA, desynchronized canvas │ │ ↓ │ │ Browser composites canvas into the page │ └─────────────────────────────────────────────────────────────────┘ Same-frame web app fast path (web-bitmapbuf): ┌───────────────┐ MessagePort.postMessage([view, ...fds], ┌──────────────┐ │ Web app │ ──► [imageBitmap]) ──────────────────────────► │ Compositor │ │ (iframe) │ web_bitmapbuf_factory.create_buffer(id, fd) │ WebConnHandl │ └───────────────┘ → wl_buffer → ImageBitmapBuffer └──────────────┘ (no encoding, no WebSocket, no GStreamer) ``` ## Key answers ### Why H.264 over PNG? H.264 at 12 Mbps × 1/60s ≈ 25 KB/frame for 1080p, vs PNG 100 KB – 3 MB. PNG wins for ≤256×256 because H.264 I-frame overhead and alpha-split doubling dominate at small sizes. ### WebCodecs? Yes — preferred path, software-only (`prefer-hardware` is wired but disabled with FIXME "frame offset is wrong"). FFmpeg Wasm is the fallback. ### Trade-offs - H.264 = small wire / decode cost - PNG = medium wire / browser-native fast decode (`createImageBitmap`) - raw SHM = **not used** between proxy and browser — every surface commit is encoded. ### Per-surface policy? No — per-frame deterministic. Only configurable knob is `--encoder=x264|nvh264|vaapih264`. PNG is always automatic fallback. ### Cursors? Always PNG (32×32 ≤ 256×256). Decoded to ImageBitmap and set as the CSS cursor via `setBrowserCursor` — never enters the WebGL compositor. ### Alpha pre-multiplication? Implicit in shaders. Proxy extracts alpha as a Y plane (no premult); browser recombines via BT.601 YUVA→RGBA shader outputting straight alpha; canvas blend is `ONE, ONE_MINUS_SRC_ALPHA` (expects premult) — minor inconsistency, not visually problematic for typical Wayland content. ### OffscreenCanvas / Web Workers? Workers yes (two for Wasm decoder, shared across surfaces). OffscreenCanvas **no** — renderer is main-thread WebGL with `desynchronized:true`. WebGPU/WebGL2 not used. ## Notable design trade-offs for your compositor 1. Always-encode policy (no raw SHM escape hatch even on LAN). 2. One encoder per surface, EOS-and-recreate on resize/format change. 3. Alpha as a separate H.264 stream — doubles cost for transparent windows. 4. Per-surface decoder context, no decoder pooling. 5. Only aggregate feedback (1 Hz rAF interval + decode duration); no per-frame ACKs, no bitrate adaptation; `requestKeyUnit` plumbing exists end-to-end but the browser never calls it. 6. KCP over WebSocket (not WebRTC datachannels) — simpler infra, no STUN/TURN, but no real congestion control. 7. Emscripten SIMD (not threads) — avoids COOP/COEP requirements. 8. Two parallel GL pipelines (opaque + alpha) for H.264. 9. PNG fallback boundary empirically at 256×256 pixels. 10. WebGL 1, not 2/WebGPU — maximum browser compat at the cost of manual LUMINANCE-texture YUV juggling and a hidden FBO intermediate. ## File reference ### Wasm libraries | Path | Role | |---|---| | `libs/compositor-wasm/build_wasm.sh` | Builds pixman 0.42.2 + xkbcommon 1.5.0 to WASM (emsdk 3.1.46) | | `libs/compositor-wasm/emscripten-build.ini` | Emscripten build config | | `libs/compositor-wasm/src/index.ts` | TS wrapper, `init` function | | `libs/compositor-wasm/xkbcommon/build.sh` | xkbcommon build (clones 1.5.0, embeds xkeyboard-config DB) | | `libs/compositor-wasm/pixman/build.sh` | pixman build | | `libs/compositor-ffmpeg-h264/build_wasm.sh` | Builds FFmpeg n5.1.2 to WASM (single-file, decoder-only) | | `libs/compositor-ffmpeg-h264/native/decoder.c` | 30-line wrapper exposing decode API | | `libs/compositor-ffmpeg-h264/src/H264Decoder.ts` | TS wrapper around the Wasm module | | `libs/compositor-ffmpeg-h264/src/H264Worker.ts` | Worker entry point | | `libs/compositor-ffmpeg-h264/src/index.ts` | Exports | ### Native encoder addon | Path | Role | |---|---| | `packages/compositor-proxy/native/encoding/src/node_encoder.c` | Node-API addon (290 lines) | | `packages/compositor-proxy/native/encoding/src/gst_frame_encoder.c` | GStreamer H.264/PNG encoder (~1400 lines) | | `packages/compositor-proxy/native/encoding/src/gst_main_loop.c` | Dedicated GLib main loop for encoding | | `packages/compositor-proxy/native/CMakeLists.txt` | Build config | | `packages/compositor-proxy/src/SurfaceBufferEncoding.ts` | TS-side encoding orchestration | | `packages/compositor-proxy/src/ProxyBuffer.ts` | Proxy-side buffer representation | | `packages/compositor-proxy/src/FrameFeedback.ts` | Receives 4-byte decode feedback | | `packages/compositor-proxy/src/encoding/` | High-level encoding files | ### Browser decode | Path | Role | |---|---| | `packages/compositor/src/remote/EncodedFrame.ts` | Wire format parser for encoded frames | | `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` | Sends 4-byte decode feedback at 1 Hz | | `packages/compositor/src/remote/StreamingBuffer.ts` | Per-surface encoded-frame buffer | | `packages/compositor/src/remote/BufferStream.ts` | Manages serial → frame promise resolution | ### WebGL rendering | Path | Role | |---|---| | `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/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/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 | ### Protocol extensions | Path | Role | |---|---| | `protocol/web-bitmapbuf-unstable-v1.xml` | Greenfield's custom Wayland extension for browser JS apps | | `protocol/wayland-drm.xml` | Standard wl_drm for DMA-BUF (proxy-only) | | `packages/compositor/src/web/WebBuffersFactory.ts` | Browser-side impl of web_bitmapbuf_factory |