The GPUDevice
If you're only ever calling denoiser.denoise(image) you can skip this page —
device sharing only matters once you want a zero-copy pipeline with your own
renderer (three.js, a custom WebGPU engine, etc).
Who creates the device
With the default runtime (AutoRuntime) you choose. Either your renderer creates the
device and you hand it to the denoiser through the device option, or the denoiser
creates it and you hand it to your renderer:
// renderer first (the clean flow with the default runtime)
const renderer = new THREE.WebGPURenderer({ canvas });
await renderer.init();
const denoiser = await Denoiser.create({
device: (renderer.backend as { device: GPUDevice }).device,
precision: 'fp16',
});
// or denoiser first
const denoiser2 = await Denoiser.create({ precision: 'fp16' });
const renderer2 = new THREE.WebGPURenderer({ device: denoiser2.device });
The exception is the opt-in ORT runtime (denoiser/ort): onnxruntime-web creates
and owns the GPUDevice and ignores one you try to inject
(onnxruntime#26107), so with
ORT you must create the Denoiser first and hand its device to your renderer.
Do this and your path tracer, denoiser, and any post-processing all run on one
device with zero copies between them — render target in, denoiseTextures(),
render target out.
The max-limits patch
WebGPU devices default to fairly small buffer caps (roughly 128–256 MB depending on
the limit). A whole-frame U-Net's intermediate activations at any real resolution
blow past that easily — and so does a path tracer's own buffers. So when the default
runtime creates the device itself (no device option), it requests the adapter's
full limits and features instead of WebGPU's conservative defaults. This happens
automatically; you don't configure it.
If you pass your own device, it needs those limits too: request them when you create
the renderer's device (the path tracer examples do this with a scoped patch of
GPUAdapter.requestDevice, patchWebGPUForMaxLimits() in examples/_shared/stack.ts).
The ORT runtime creates its own device under a similar scoped patch.
Net effect: the shared device can hold whole-frame U-Net intermediates and your
renderer's own pipelines, without you having to hand-tune requiredLimits.
Device lifetime rules
Two rules apply, the first one specific to ORT owning its device:
- With ORT,
destroyDevice()destroys the shared device. When the last ORT session is released, ORT destroys its device — three.js, canvas contexts, and every other resource on that device die with it (GPUDevice.lost, reason'destroyed').dispose()is the safe between-workloads cleanup instead: it frees buffers and extra sessions but deliberately retains one session, so the device survives. Only calldestroyDevice()when the entire WebGPU stack is going away. The default runtime does not destroy adeviceyou passed in, but treatdestroyDevice()as full teardown anyway. - Model switches are safe. Changing
qualityor otherwise swapping the active model overlaps creating the new engine with disposing the old one, specifically so the session count never hits zero mid-swap (which would trigger rule 1 accidentally). Don't "optimize" that ordering away if you're working in the internals — it's there to prevent releasing the last session by accident during a routine model switch.
If your whole WebGPU stack unexpectedly dies — canvases go blank, three.js throws
device-lost errors — check whether something called destroyDevice() (or, with ORT,
let the last session get released). Use dispose() for routine cleanup between
workloads instead.
dispose() vs destroyDevice()
| keeps device alive | use for | |
|---|---|---|
dispose() | yes | freeing buffers/extra sessions between workloads, safe default |
destroyDevice() | no | full teardown when the whole WebGPU stack is going away |
See three.js render targets in/out for the
full worked example — unwrapping three.js textures to raw GPUTextures, orientation
flags, and the live-loop pacing pattern that goes with a shared device.