Custom engines & raw WebGPU

Integrate the denoiser with any WebGPU engine (or none) — the device contract, texture requirements, and the CPU fallback for engines that can't share a device.

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

Important

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

inputrequirement
colorfloat texture (rgba16float / rgba32float), linear values, on denoiser.device
LDRvalues in [0,1], omit hdr
HDRpass hdr: true — OIDN's PU transfer + autoexposure run around the model (or pin exposure with inputScale, overriding autoexposure)
albedo / normaloptional aux planes, same texture/device requirements — see Generating aux inputs for the exact conventions
Note

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-owned rgba8unorm texture, 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_BINDING in its usage,
    • be rgba8unorm (clamped, display-ready) or rgba16float (unclamped, keeps HDR range — sRGB texture formats can't be STORAGE_BINDING, so encoding is chosen via transfer instead 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:

optionflipstypical case
inputFlipYcolor readson for compute-written targets (storage-texture output is bottom-up); off for rasterized targets
auxInputFlipYalbedo/normal readsdefaults to inputFlipY; set separately when your aux G-buffer comes from a different pass with the opposite convention
outputFlipYthe resultflip 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