All posts
Engineering

Shipping a viewer to WebAssembly without losing the GPU

WebGPU, a shared render backend, and the handful of places where we had to keep two code paths after all.

The pitch for a browser viewer is obvious: share a link, the recipient sees the recording, no install. The problem is that the desktop viewer is a GPU application, and for years "in the browser" meant giving that up.

One backend, two targets

The renderer is written against wgpu, which targets Metal, Vulkan and D3D12 natively and WebGPU in the browser. In principle the same code compiles to both. In practice "in principle" did a lot of work in that sentence.

What genuinely was shared: shaders, the instanced point pipeline, the chunk culling, the camera maths. Roughly 90% of rendering code has no idea which target it is on.

Where it broke

Threads. The desktop viewer decodes on a thread pool. In the browser that means web workers plus SharedArrayBuffer, which requires cross-origin isolation headers, which means a page that embeds the viewer has to set them. We support both, but the single-threaded fallback is meaningfully slower on video-heavy recordings.

Memory. A 32-bit wasm heap caps out around 4 GB, and in practice you want to stay well under. The desktop viewer memory-maps a recording and lets the OS page it. That is not available, so the wasm build streams chunks and maintains a bounded cache with eviction.

File access. No mmap, no random-access reads over a file handle. Everything is HTTP range requests, which turned out to be a blessing: the chunk scheduler we built for the browser was good enough that we moved it to the desktop Hub client too.

#[cfg(target_arch = "wasm32")]
type Reader = HttpRangeReader;   // bounded cache, evicts under pressure

#[cfg(not(target_arch = "wasm32"))]
type Reader = MmapReader;        // let the OS page it

Numbers

The wasm bundle is about 11 MB gzipped, which is large for a web page and small for a 3D application. First paint on a cached bundle is under a second. Rendering throughput is roughly 70% of native on the same machine — the gap is mostly the memory ceiling forcing smaller working sets.

Would we do it again

Yes, and earlier. The constraints the browser imposed — bounded memory, streaming reads, no assumption of threads — made the desktop viewer better. The chunk scheduler that made Hub playback fast exists because the browser forced us to build it.