A from-scratch wgpu app that runs in a web worker via WebAssembly. No winit, and no graphics code on the main thread: the worker owns an OffscreenCanvas, drives the render loop with requestAnimationFrame, and renders through WebGPU. The main thread only transfers the canvas and forwards events over Comlink.
This is the raw-wgpu counterpart to bevy-worker: same worker architecture and build pipeline, but a wgpu renderer instead of an engine. The worker approach follows Nick Babcock's write-up on running a Bevy app off the main thread, and the build pipeline follows his post on deconstructing wasm-pack.
For an all-Rust take on this same renderer, see webgpu-worker-leptos: it swaps the TypeScript frontend and Comlink for a Leptos page and a Rust web worker that share their message types through a protocol crate. bevy-worker-leptos is that same all-Rust frontend wrapped around the full Bevy engine instead of a hand-written renderer.
matthewberger.dev/webgpu-worker. Needs a browser with WebGPU and OffscreenCanvas-in-workers support (Chromium 113+, Firefox 141+).
The page renders a spinning, lit 3D cube and a control panel that demonstrates the GPU pipeline and render loop live in the worker:
- The wasm module reports its own JavaScript global scope (
DedicatedWorkerGlobalScope), so the wgpu code itself confirms where it runs. - A "Jam main thread for 3 s" button synchronously blocks the page. The main-thread heartbeat counter freezes, but the cube keeps spinning and the panel reports how many frames wgpu advanced while the page was stalled.
- Dragging orbits the camera and the wheel zooms; rotation-speed and color controls also send events into the worker and update the running scene.
src/lib.rsexposes aWgpuApp(create/update/resize/ control methods) throughwasm-bindgen(--target web).createis async becauserequest_adapterandrequest_deviceare async, so it returns aPromisethe worker awaits.- The surface comes straight from the transferred canvas via
wgpu::SurfaceTarget::OffscreenCanvas, soinstance.create_surface(...)just works. Because nothing routes throughwinitorraw-window-handle, there is nounsafe impl Send + Syncand no custom window-handle wrapper. That plumbing in bevy-worker exists only to satisfy Bevy's winit-shapedWindowlayer, whoseRawHandleWrapperdemands aSend + Synchandle theOffscreenCanvasdoesn't provide. Going straight to wgpu removes the requirement entirely. - The renderer is a plain wgpu setup: instance, surface, adapter, device, a depth texture, one uniform buffer (MVP, model matrix, tint), and a pipeline from inline WGSL that does Lambert shading.
web/src/worker.tsinitializes the module explicitly withinit({ module_or_path })(a?urlimport, novite-plugin-wasm) and drivesapp.update()fromrequestAnimationFrame.web/src/main.tstransfers the canvas withComlink.transferand forwards control and resize events. The UI is plain HTML and CSS.
All communication runs over Comlink, in both directions:
- Page to worker: the worker exposes methods the page calls to forward events. The offscreen canvas can't receive DOM input, so the page captures pointer drag (orbit) and wheel (zoom) on the placeholder canvas, coalesces them to at most one message per frame, and forwards them alongside the resize, rotation-speed, and color controls.
- Worker to page: the page hands the worker a callback wrapped with
Comlink.proxy, which the worker calls to push state up. It fires once with the GPU adapter name and backend that only the worker knows (shown as the panel's "renderer" line), and streams the frame counters so the fps and frame readout is push-driven rather than polled. The jam measurement still pullsstats()on demand for an exact before and after.
Tooling is pinned in mise.toml: node, rust with the wasm32-unknown-unknown target, wasm-bindgen, and wasm-opt. Install mise and just, then:
mise install # fetch the pinned toolchain
just run # build, optimize, and serve at http://localhost:5173Run just with no arguments to list every recipe. The wasm pipeline is a bare cargo build -> wasm-bindgen --target web -> wasm-opt -Oz.
Dual-licensed under MIT or Apache-2.0, at your option.
