Custom engines & raw WebGPU
The denoiser isn't three.js-specific. denoiseTextures takes and returns
plain GPUTextures on a plain GPUDevice — anything that can get you those
two things works: a hand-written WGSL compute shader, a different
framework's renderer, or nothing at all. This guide covers the parts that
are the same regardless of what's on the other side; the
three.js guide covers three-specific unwrapping.
Live reference implementation (a ~200-line single-file WGSL path tracer, zero framework): webgpu-raw.
The device contract
There is exactly one device in play, and everything has to be built on it. With the default runtime you have two options:
-
Let the denoiser create it and build your own pipelines, buffers, and textures on
denoiser.device:const denoiser = await Denoiser.create({ precision: 'fp16' }); const device = denoiser.device; // GPUDevice — build everything on this const pipeline = device.createComputePipeline({ /* your toy path tracer */ }); -
Hand it yours, if your engine creates the device first:
Denoiser.create({ device }). Request the adapter's max limits/features when you create it; the U-Net's whole-frame intermediates need buffers far larger than WebGPU's default caps. (When the denoiser creates the device itself it requests the max limits for you.)
The opt-in ORT runtime (denoiser/ort) is the exception: onnxruntime-web
creates and owns the GPUDevice it runs on and ignores one you inject
(onnxruntime#26107).
With ORT, create the denoiser first and build on denoiser.device.
Lifetime rules, same as any consumer of denoiser.device:
denoiser.dispose()— frees buffers/extra sessions, keeps the device. Safe between workloads.denoiser.destroyDevice()— full teardown. With ORT, releasing the last session destroys the shared device — your pipelines, buffers, and textures on it die too (GPUDevice.lost, reason'destroyed'). Only call this when the whole WebGPU stack is going away.
Texture requirements for denoiseTextures
| input | requirement |
|---|---|
color | float texture (rgba16float / rgba32float), linear values, on denoiser.device |
| LDR | values in [0,1], omit hdr |
| HDR | pass hdr: true — OIDN's PU transfer + autoexposure run around the model (or pin exposure with inputScale, overriding autoexposure) |
albedo / normal | optional aux planes, same texture/device requirements — see Generating aux inputs for the exact conventions |
Texture inputs are always treated as linear — there's no per-call sRGB
decode for the texture path (unlike the image path's srgb option on
denoise()). If your engine's render target holds sRGB-encoded bytes,
linearize it yourself (in your own shader, or render to a linear float
target in the first place) before handing it to denoiseTextures.
There's no format/size constraint beyond "float and linear" — any size runs,
whole-frame up to maxRunPixels and tiled above it (see
Large images & tiling).
Output options
const out = await denoiser.denoiseTextures({ color, hdr: true, output: myTexture });
- No
output— returns an engine-ownedrgba8unormtexture, valid until the next call / a size change / teardown. Copy or sample it before calling again. output: myTexture— resolves into a texture you own instead. It must:- match the input's width/height exactly,
- have
GPUTextureUsage.STORAGE_BINDINGin its usage, - be
rgba8unorm(clamped, display-ready) orrgba16float(unclamped, keeps HDR range — sRGB texture formats can't beSTORAGE_BINDING, so encoding is chosen viatransferinstead of the texture format).
transfer ('linear' | 'srgb' | 'aces-srgb', default 'linear') picks the
output encoding independent of format — 'linear' for an HDR result your
engine tonemaps itself, 'aces-srgb' for something display-ready you blit
straight to a canvas, 'srgb' for sRGB-encoded-but-not-tonemapped LDR
pipelines.
The flipY family
Three independent flips, because color/aux/output can each have a different vertical convention:
| option | flips | typical case |
|---|---|---|
inputFlipY | color reads | on for compute-written targets (storage-texture output is bottom-up); off for rasterized targets |
auxInputFlipY | albedo/normal reads | defaults to inputFlipY; set separately when your aux G-buffer comes from a different pass with the opposite convention |
outputFlipY | the result | flip the denoised output vertically |
If a result looks upside down, these three are the first thing to check — see Troubleshooting.
When you can't share a device: the CPU path
Some engines create and own their WebGPU device internally with no way to inject an external one. That's fine as long as you can get at their device: the default runtime (WGSL / WebNN) adopts it, the way it adopts three.js's. Only ONNX Runtime can't share, since it always creates its own device (#26107).
Babylon.js: sharing its device
Babylon's WebGPUEngine always creates its own device, and exposes it only as
engine._device (marked internal: there is no public getter or option yet).
Textures cross over with public API: underlyingResource on a texture's hardware
texture gives you the GPUTexture, and engine.wrapWebGPUTexture() turns yours
into a Babylon texture.
const engine = new WebGPUEngine(canvas, { enableAllFeatures: true, setMaximumLimits: true });
await engine.initAsync();
const denoiser = await Denoiser.create({ device: engine._device }); // Babylon's device
const out = engine._device.createTexture({ size: [w, h], format: 'rgba8unorm',
usage: GPUTextureUsage.STORAGE_BINDING | GPUTextureUsage.TEXTURE_BINDING });
const wrapped = engine.wrapWebGPUTexture(out);
wrapped._hardwareTexture.createView(); // Babylon 9.16 doesn't create one: sampling fails without it
layer.texture = new BaseTexture(scene, wrapped);
// every frame (or on demand): Babylon's render target in, your texture out
const color = rtt.getInternalTexture()._hardwareTexture.underlyingResource;
await denoiser.denoiseTextures({ color, output: out, inputFlipY: true, outputFlipY: true });
Babylon stores render targets bottom-up on WebGPU as well as WebGL (hence the two
flips), and enableAllFeatures / setMaximumLimits give the device the
shader-f16 feature and storage limits the denoiser wants. The
babylon demo runs both paths side by
side: about 6–7 ms for a 512² denoise either way here, plus ~1.3 ms of readback on
the CPU path and none on the shared one.
When you can't share a device: the CPU path
If you can't reach the engine's device at all, or you're on ONNX Runtime or a
WebGL engine, you end up with two separate GPUDevices that can't share
textures.
When zero-copy genuinely isn't possible, fall back to the CPU-copy path: read
the rendered frame back to CPU pixels, run the plain (non-texture) denoise()
call, then upload the result into a texture your engine owns.
// 1. read the rendered frame back (full-float; width divisible by 64 is the
// fast path for most engines' readback implementations)
const pixels = await myRenderTarget.readPixels();
// 2. denoise on the CPU-array path — an ordinary ImgInput, no shared device needed
const clean = await denoiser.denoise(pixels, { albedo, normal });
// 3. upload the result back into a texture your engine can sample
myEngineTexture.update(clean.data);
Be honest with yourself about the cost here: this is a full GPU→CPU→GPU
round-trip per denoise, on top of the 8-bit RGBA quantization denoise()
implies (denoiseToFloat() gets you normalized floats back if you need them,
but the readback itself is still the expensive part). It's the right choice
when zero-copy is structurally unavailable, not a style preference — reach
for denoiseTextures and a shared device whenever the engine allows it.
Live: webgpu-raw · babylon