Files

22 KiB
Raw Permalink Blame History

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 ArrayBuffers. 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