Denoiser Full documentation content. [!WARNING] > Photos and sensor noise are the wrong training domain. OIDN's models never saw > camera-sensor grain — feeding one a real photo "does something," but the result > isn't designed to look right and shouldn't be judged as if it were. - **Photos / sensor noise** — off-label use, see above. - **Per-frame real-time denoising with the OIDN networks.** The OIDN models are single-frame, so running them every frame would flicker. For real-time shadows and reflections use the temporal FidelityFX denoisers (`denoiser/ffx`, see [Real-time (temporal) denoising](../guides/realtime-temporal-denoising.mdx)); OIDN doesn't ship temporal weights yet (upstream OIDN 3.x), and a temporal OIDN model is planned. - **Non-WebGPU browsers.** v2 is a WebGPU-only rewrite with no fallback. If you need broader coverage, stay on `denoiser@0.0.11` (v1, TensorFlow.js-based — its WebGL2 fallback covers non-WebGPU browsers) — see [Migrating from v1](../guides/migrating-from-v1.mdx) for what changed and why. ## Browser support Needs WebGPU. The library requests the adapter's maximum device limits itself on creation — whole-frame U-Net intermediates need more buffer headroom than the default WebGPU device grants — so there's nothing extra to configure. | Browser | Status | |---|---| | Chrome / Edge | stable | | Safari | 26+ | | Firefox | stable on Windows; other platforms in progress | Where WebGPU or a required feature is missing, `Denoiser.create()` rejects with a `DenoiserUnsupportedError` rather than failing silently. ]]> [!NOTE] > Everything here was validated on Apple GPUs only. The default runtime is > `AutoRuntime`: WebNN fp16 for base/large models when WebNN is available (Chrome > with a feature flag), the hand-written WGSL runtime otherwise. ## Hello world This is the entire "noisy image in, clean image out" flow — no three.js, no build tricks. It's lifted directly from [`examples/hello-world/src/main.ts`](https://github.com/pmndrs/denoiser/blob/main/examples/hello-world/src/main.ts): ```ts import { Denoiser } from 'denoiser'; async function run(): Promise { const noisyImg = document.querySelector('#noisy-img')!; await noisyImg.decode(); const denoiser = await Denoiser.create({ quality: 'balanced' }); const denoised = await denoiser.denoise(noisyImg); const canvas = document.querySelector('#denoised')!; canvas.getContext('2d')!.putImageData(denoised!, 0, 0); return denoiser; } ``` Three calls do the work: 1. `Denoiser.create()` — creates the WebGPU device (or adopts the one you pass as `device`) and loads a model. `quality` picks its size: `'fast'` (the default, the small network), `'balanced'` (the base network: visibly cleaner, about 1.5x the time) or `'high'` (the large network where one exists). 2. `denoiser.denoise(imageLike)` — runs it. Accepts an `HTMLImageElement`, `HTMLCanvasElement`, `ImageBitmap`, `ImageData`, `HTMLVideoElement`, `OffscreenCanvas`, or a raw `{ data, width, height }` object; returns `ImageData`. 3. `putImageData` — paint the result. [**Live demo →**](https://pmndrs.github.io/denoiser/hello-world/) — the deployed page shows this exact source next to the noisy/denoised comparison. ## What happens on first run `Denoiser.create()` fetches the weights it needs from the default CDN (`tzaUrl`, jsDelivr by default) — nothing ships inside the npm package itself. Weight files run about **0.6–7.7 MB** depending on quality tier (see [Models & weights](./models-and-weights.mdx) for the full size table); only the one variant your call actually needs is fetched, not the full set. The fetched bytes are cached in memory for the life of the page (and by the browser's normal HTTP cache across page loads), so only the very first `denoise()` call pays the download. > [!NOTE] > Production apps should host the weights themselves rather than rely on the default > CDN URL — see [Models & weights](./models-and-weights.mdx#hosting-them) for the > `tzaUrl` option. ## Next - [Models & weights](./models-and-weights.mdx) — quality tiers, aux inputs, self-hosting. - [How it works](../concepts/how-it-works.mdx) — what's actually happening on the GPU. - [The GPUDevice](../concepts/the-gpudevice.mdx) — sharing the device with three.js for a zero-copy pipeline. ]]> .tza ``` The files are sha256-identical to upstream [RenderKit/oidn-weights](https://github.com/RenderKit/oidn-weights). They are committed as ordinary git blobs (not LFS — jsDelivr can't resolve LFS pointers) in a dedicated weights repo, served through jsDelivr's multi-provider CDN with CORS and range requests. GitHub Pages also works if you'd rather publish your own branch than depend on jsDelivr. When you pass your own `runtime`, set the URL on the runtime instead: `new WgslRuntime({ tzaUrl })`, `new AutoRuntime({ tzaUrl })`. ## ONNX models (`denoiser/ort` only) The ORT runtime fetches converted `.onnx` models instead. Install the optional peer dependency (`npm i onnxruntime-web`) and construct the runtime yourself: ```ts import { Denoiser } from 'denoiser'; import { OrtRuntime } from 'denoiser/ort'; const denoiser = await Denoiser.create({ runtime: new OrtRuntime({ weightsUrl: '/models' }), }); ``` > [!WARNING] > ORT computes the `*_alb`, `*_alb_nrm` and `*_large` models wrong (see > [Choosing a runtime](../guides/choosing-a-runtime.mdx#speed-and-quality)). Prefer the > default runtime. **Default scheme:** `https://cdn.jsdelivr.net/gh/pmndrs/denoiser-weights@models-v2/models/.onnx`. Self-hosters should copy the `models/` directory into their static assets and pin `weightsUrl`. ### The `splitAux` artifacts If you self-host the ORT models, mirror the **whole** `models-v2` tag, including files named `*.tail.onnx` and `*.enc0.bin` sitting next to the regular `.onnx` models. Here's why: onnxruntime-web's WebGPU execution provider has a bug where the first convolution of a model with more than 3 input channels — exactly the situation for the 9-channel aux-guided (`*_calb_cnrm`) models — computes garbage, producing visible speckling in the output. It's an upstream defect, not something in this library's code; filed and reproduced at [DennisSmolek/ort-web-webgpu-conv-bug](https://github.com/DennisSmolek/ort-web-webgpu-conv-bug). The `OrtRuntime` `splitAux` option (default **on**) works around it: the affected first convolution is re-exported into a separate small weights file (`*.enc0.bin`) and run in WGSL instead of through ORT, while the rest of the network runs as a `.tail.onnx` graph through ORT as normal. This has been verified to restore output to within 1–2 LSB of native OIDN. It only applies to the 9-channel aux models — 3- and 6-channel models are unaffected and never fetch these extra files (and stay wrong on ORT, see above). If you self-host `models-v1` (or otherwise don't have the tail/enc0 files available), the runtime falls back to the plain speckled model with a one-time console warning. Set `splitAux: false` to silence the warning and force the plain model deliberately. ### Tag policy `models-vN` tags are immutable — once published, a tag's contents never change. Self-hosters should mirror the exact tag their installed library version expects (currently `models-v3` for the `.tza` weights of the default runtime and `models-v2` for the ORT `.onnx` models); pin it the same way you'd pin any other dependency version. ### What doesn't work as a host Two dead ends worth knowing about so you don't re-discover them: GitHub Releases assets send no CORS headers on the download path, so browser `fetch()` fails outright — fine for CLIs, useless for the web. And bundling models inside the npm package itself would balloon every `npm install` and blow past jsDelivr's ~50 MB total-package limit, so the package would simply be refused. ]]> [!IMPORTANT] > 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](../guides/three-js-render-targets.mdx) for the full worked example — unwrapping three.js textures to raw `GPUTexture`s, orientation flags, and the live-loop pacing pattern that goes with a shared device. ]]> [!NOTE] > The figures on this page were measured on the **ORT runtime**, which was the default > when they were taken. `Denoiser` now defaults to `AutoRuntime` (WebNN or WGSL), > which the quiet-GPU eval measured as faster and correct on every model. For current > per-runtime numbers at 1080p and 512x512, see > [Choosing a runtime](../guides/choosing-a-runtime.mdx#speed-and-quality). The ORT-specific > remarks below (graph capture, conv scaling) apply to `denoiser/ort` only. ## Measured: whole-frame inference (v2, fp16, ORT) | size | time | |---|---| | 512×512 | **13.7 ms** | | 1280×720 | **45.1 ms** | | 1920×1080 | **104.1 ms** | fp16 output measures **53.5–53.6 dB PSNR** vs fp32 at the same settings — visually identical. Images larger than 2560×1440 fall back to the tile ladder automatically (1024² tiles, batch 2, seam-free via overlap blending). ## vs v1 (TensorFlow.js) | | v1 (TFJS) | v2 (this library) | speedup | |---|---|---|---| | 512² warm | 37.6 ms (9 tiles) | **13.7 ms** (whole-frame, fp16) | 2.7× | | 1080p warm | 188 ms (45 tiles) | **104 ms** (whole-frame, fp16) | 1.8× | v1 ran everything through TFJS's WebGL/WebGPU backends with tiling only (no whole-frame path); v2's rewrite onto ONNX + the WebGPU EP (the then-default ORT runtime) plus whole-frame inference accounts for the gap. Precision also improved: fp32 was the only usable option in v1, whereas v2's fp16 path is a further ~15% on top of the numbers above. ## Zero-copy GPU pipeline The end-to-end win is bigger than the model timings alone once you count the CPU round-trip a naive integration pays. At 512² in the path tracer example: - **Old CPU path**: float readback → JS tonemap loop → 8-bit quantize → denoise: **68.8 ms**. - **Zero-copy GPU path** (render target straight into `denoiseTextures`, real HDR, no readback): **35.4 ms warm**. See [The GPUDevice](./the-gpudevice.mdx) for how the shared-device setup that makes this possible works. ## What moves the needle Roughly in order of impact, from the measured optimization work: 1. **Whole-frame vs tiled** — the single biggest win. Tiling at 256px/32px overlap infers about 31% more pixels than the image actually has, on top of per-tile `await` overhead; collapsing 1080p from 45 tiles to 1 run dominates every other change. See [How it works](./how-it-works.mdx#whole-frame-vs-tiled-inference). 2. **Image size** — the obvious one, but note it's now closer to linear in pixel count than it was under tiling, since the overlap tax and per-tile sync overhead are gone in whole-frame mode. 3. **Precision (`fp16` vs `fp32`)** — a real but modest ~15% (not the ~2× you might expect from halved IO — onnxruntime-web's WebGPU conv kernels don't scale that cleanly with element size on this workload). Free once `shader-f16` is available. 4. **Quality tier** — `fast` (`*_small`) vs `balanced` vs `high` (`*_large`) trades speed for the network's capacity; `high` is meaningfully slower, benchmark for your target sizes with `examples/bench`. 5. **Aux inputs (albedo + normal)** — a bigger model (9 channels in vs 3) costs some speed, but the quality gain at low sample counts is usually worth it; see [Models & weights](../getting-started/models-and-weights.mdx#what-aux-buys). 6. **Zero-copy IO** — doesn't change the model's own cost, but removes the CPU readback/tonemap/quantize round trip a naive pathtracer integration would otherwise pay every frame (see above). Batching multiple tiles per `session.run()` and ORT's graph-capture feature were both tried and are **not** wins here: batching was small and mostly mattered as groundwork for whole-frame inference, and graph capture measured zero throughput gain (the workload is GPU-bound, not dispatch-bound) while onnxruntime-web 1.27's captured sessions crash after roughly 150–250 cumulative replays — unusable for a live loop. The `graphCapture` option of `OrtRuntime` is opt-in and off by default for that reason. ## The honest ceiling (ORT numbers) This section predates the WGSL and WebNN runtimes; the current gap to native is in [Choosing a runtime](../guides/choosing-a-runtime.mdx#speed-and-quality). Native OIDN on Metal does the same 512² image in **~24.5 ms**, using hardware matrix units (`simdgroup_matrix`) that WGSL has no path to today — that's a measured, not guessed, ~4× gap (a dedicated kernel-spike investigating a custom WGSL conv path came back NO-GO at ≤1.32×, nowhere near enough to justify the complexity). This library **beats native OIDN's CPU path** (334 ms) comfortably; it does not currently beat native GPU. That gap closes if/when WebGPU gets subgroup-matrix operations, or via a future WebNN/NPU backend — both tracked, neither shipped yet. ## Pacing progressive denoise Don't call `denoiseTextures()` every rendered frame — a path tracer typically adds one sample per frame, and re-denoising an image that only gained one new sample of information is wasted GPU time. The pattern used in `examples/three-pathtracer-webgpu`'s live mode: ```ts let denoisingBusy = false; let lastDenoisedSample = -1; function loop() { pathTracer.renderSample(); const s = Math.floor(pathTracer.samples ?? 0); if (!denoisingBusy && s !== lastDenoisedSample && s > 0) { denoisingBusy = true; runDenoise() .then(() => { lastDenoisedSample = s; }) .finally(() => { denoisingBusy = false; }); } requestAnimationFrame(loop); } ``` The busy flag alone naturally paces this to "roughly every N samples, not every frame" — a denoise call typically takes longer than one render sample, so later frames just skip re-triggering until the in-flight one resolves. On top of that, call `denoiser.abort()` whenever the view goes stale (camera moved, scene changed): it doesn't cancel already-submitted GPU work, but it makes the in-flight call resolve `undefined` instead of returning a result for an outdated view, so you never present a denoise that no longer matches what's on screen. `denoiser.stats` carries per-stage timings from the last run if you want a live HUD. ]]> new AutoRuntime({ device }), precision: 'fp16', }); ``` Without a `device`, the runtime creates one with the adapter's max limits/features, so the shared device can hold whole-frame U-Net intermediates. If your renderer creates the device, request high limits there too (a path tracer's own buffers compete for the same budget); `patchWebGPUForMaxLimits()` in `examples/_shared/stack.ts` shows one way. > [!IMPORTANT] > **ORT is the exception.** `OrtRuntime` (`denoiser/ort`, opt-in) always creates its > own device and ignores an injected one > ([onnxruntime#26107](https://github.com/microsoft/onnxruntime/issues/26107)), and > `createDenoiserForRenderer` throws for it. With ORT the order is reversed: > **denoiser first, then hand its device to three.js.** > > ```ts > import { OrtRuntime } from 'denoiser/ort'; > const denoiser = await Denoiser.create({ runtime: new OrtRuntime(), precision: 'fp16' }); > const renderer = new THREE.WebGPURenderer({ canvas, device: denoiser.device }); > await renderer.init(); > ``` **Lifetime rules:** - `denoiser.dispose()` — safe between workloads: frees buffers and extra model sessions, **keeps the device alive**. - `denoiser.destroyDevice()` — full teardown of the denoiser's sessions. With ORT, releasing the last session **destroys the shared device**; your renderer, canvas contexts, and every resource on it die with it. `AutoRuntime` never destroys a device you passed in. Only call it when the whole WebGPU stack is going away. ## 1. Getting a render target IN ### Unwrap the three.js texture to a raw `GPUTexture` three.js doesn't expose the backing `GPUTexture` publicly; it lives in the backend's data map: ```ts const backendGet = (threeTexture: THREE.Texture): GPUTexture | undefined => (renderer.backend as any).get(threeTexture)?.texture; const colorTex = backendGet(myRenderTarget.texture); ``` > [!WARNING] > Two caveats, both learned the hard way: > > - **This is three internal API** — pin your three version and re-check on > upgrades. > - **Fetch it fresh each use.** Render targets are re-created on resize (and > e.g. the path tracer swaps its output target on reset), so a cached > `GPUTexture` can go stale/destroyed. Resolve from the three texture every > frame you use it. ### Input requirements | requirement | detail | |---|---| | format | float texture (`rgba16float` / `rgba32float`), **linear** values | | LDR vs HDR | LDR: values in [0,1], omit `hdr`. HDR: pass `hdr: true` — OIDN's PU transfer + autoexposure are applied around the model (or pin exposure with `inputScale`) | | orientation | three-gpu-pathtracer's `WebGPUPathTracer` output (0.0.27+) and rasterized targets (an MRT G-buffer pass) are both top-down, so the defaults (`inputFlipY` / `auxInputFlipY` false) apply. Older tracer builds and some compute-written targets are **bottom-up** → `inputFlipY: true`. If a result is upside down, toggle these first | | size | anything; up to `maxRunPixels` (default 1080p-ish) runs whole-frame in one model pass, larger tiles automatically — see [Large images & tiling](./large-images-and-tiling) | ### Optional but strongly recommended: aux G-buffer (albedo + normal) OIDN's guided models dramatically improve low-sample quality. Rasterize the same scene once into an MRT target — albedo = unlit base color, normal = view-space normals in [-1,1]: ```ts import { mrt, diffuseColor, normalView } from 'three/tsl'; const gbuffer = new THREE.RenderTarget(W, H, { count: 2, type: THREE.HalfFloatType }); renderer.setMRT(mrt({ albedo: diffuseColor, normal: normalView })); renderer.setRenderTarget(gbuffer); renderer.render(scene, camera); renderer.setRenderTarget(null); renderer.setMRT(null); const albedo = backendGet(gbuffer.textures[0]); const normal = backendGet(gbuffer.textures[1]); ``` Rasterized aux is noise-free, so the higher-quality `cleanAux` (`*_calb_cnrm`) model is selected automatically when both planes are present. The G-buffer is view-dependent — re-render it when the camera moves. For the full set of conventions (env-pixel normals, HDR albedo clamping, first-hit rules) see [Generating aux inputs](./generating-aux-inputs). ## 2. Running the denoiser ```ts const outTex = await denoiser.denoiseTextures({ color: colorTex, // GPUTexture, float, linear albedo, normal, // optional GPUTextures -> guided model hdr: true, // linear-HDR input (path tracer output) // inputFlipY / auxInputFlipY default to false: three-gpu-pathtracer >= 0.0.27 // and raster G-buffers are both top-down. Set true for a bottom-up source. transfer: 'linear', // see output section output: myStorageTex, // optional caller-owned destination }); if (!outTex) return; // aborted mid-flight (e.g. camera moved) ``` ### `denoiseTextures` parameters (complete) | param | type | default | meaning | |---|---|---|---| | `color` | `GPUTexture` | required | noisy beauty, float linear (HDR or LDR) | | `albedo` | `GPUTexture` | – | aux plane, [0,1] floats | | `normal` | `GPUTexture` | – | aux plane, [-1,1] floats (network encoding handled internally) | | `hdr` | `boolean` | `false` | apply OIDN PU transfer + autoexposure around the model | | `inputScale` | `number` | auto | manual HDR exposure scale (overrides autoexposure) | | `inputFlipY` | `boolean` | `false` | color rows are bottom-up (WebGPU render targets) | | `auxInputFlipY` | `boolean` | = `inputFlipY` | aux orientation when it differs from color | | `output` | `GPUTexture` | – | caller-owned destination (`rgba8unorm` or `rgba16float`, must have `STORAGE_BINDING`) | | `transfer` | `'linear' \| 'srgb' \| 'aces-srgb'` | `'linear'` | output encoding — see below | | `outputFlipY` | `boolean` | `false` | flip the result vertically | | `onProgress` | `(p: number) => void` | – | tile progress (only fires in tiled mode) | Returns `GPUTexture` — your `output` if given, else an **engine-owned `rgba8unorm` texture valid until the next call / size change / teardown** — or `undefined` if `abort()` was called mid-run. ### `Denoiser.create` options (complete) | option | type | default | meaning | |---|---|---|---| | `precision` | `'fp32' \| 'fp16'` | `'fp32'` | fp16 models + tensors + WGSL IO when `shader-f16` is available (auto-falls back). ~15% faster, PSNR ≈ 53 dB vs fp32 | | `quality` | `'fast' \| 'balanced' \| 'high'` | `'fast'` | model family: fast = `*_small`, balanced = base, high = `*_large` where available. Mutable later via `denoiser.quality` | | `tzaUrl` | `string` | jsDelivr CDN (`models-v3`) | where the OIDN `.tza` weights are served (default runtime) | | `device` | `GPUDevice` | a max-limits device the runtime creates | run the default runtime on this device, e.g. `renderer.backend.device` | | `maxRunPixels` | `number` | `2048*1152` | above this, tile instead of whole-frame | | `batch` | `number` | `8` | max tiles per model run in tiled mode | | `runtime` | `NetworkRuntime` | `AutoRuntime` | what executes the network; when passed, `tzaUrl` / `device` are ignored. ORT-only settings (`weightsUrl`, `wasmPaths`, `graphCapture`, `splitAux`) live on `new OrtRuntime({...})` from `denoiser/ort` | See the [API reference](../reference/api) for the exhaustive, always-current version of this table. ## 3. Getting the result OUT (three renders it) ### Option A — engine-owned texture (simplest) Call without `output`; blit or sample the returned `rgba8unorm` texture. With `transfer: 'aces-srgb'` it's display-ready — e.g. copy straight to a canvas: ```ts ctx.configure({ device: denoiser.device, format: 'rgba8unorm', usage: GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT }); const enc = denoiser.device.createCommandEncoder(); enc.copyTextureToTexture({ texture: outTex }, { texture: ctx.getCurrentTexture() }, { width: outTex.width, height: outTex.height }); denoiser.device.queue.submit([enc.finish()]); ``` Remember it's reused: copy it out (or render it) before the next `denoiseTextures` call. ### Option B — resolve into a texture three.js owns (composable) Create a `StorageTexture`, register it with the renderer, unwrap it, and pass it as `output`. Then it's an ordinary three.js texture — feed it to materials, post-processing (the example's @pmndrs/upscaler FSR1 pass reads it), anything: ```ts const denoisedTex = new THREE.StorageTexture(W, H); denoisedTex.type = THREE.HalfFloatType; // rgba16float: unclamped linear HDR denoisedTex.colorSpace = THREE.LinearSRGBColorSpace; denoisedTex.generateMipmaps = false; renderer.initTexture(denoisedTex); // materialize the GPU resource const denoisedGpuTex = backendGet(denoisedTex)!; // pass as `output:` ``` Pick `transfer` to match who does color management: - **`'linear'` + `rgba16float` output + let three tonemap** (recommended): the denoiser writes unclamped linear HDR; three's `renderer.toneMapping = ACESFilmicToneMapping` + output color space do the rest. No color handling baked into the denoise. - **`'aces-srgb'`**: display-ready bytes; use when the consumer bypasses three's transforms (raw blits, FSR1's EASU input contract). - **`'srgb'`**: sRGB-encode without tonemapping (LDR pipelines). > [!NOTE] > sRGB texture formats can't be `STORAGE_BINDING` — a caller-owned output > is `rgba8unorm` or `rgba16float`, encoding chosen via `transfer`. ## 4. Live loop pacing (progressive denoise while accumulating) The pattern from the example's live mode: ```ts let busy = false; async function maybeDenoise() { if (busy) return; busy = true; try { await runDenoise(); } finally { busy = false; } } controls.addEventListener('change', () => denoiser.abort()); // stale view -> drop result ``` `abort()` doesn't cancel GPU work already submitted; it makes the in-flight call resolve `undefined` so you never present a result for an outdated camera. `denoiser.stats` has per-stage timings of the last run for HUDs. ## Pitfalls checklist - Result upside down → `inputFlipY` / `auxInputFlipY` / `outputFlipY` (compute targets bottom-up, raster targets top-down). - Black output from a fresh target → did you `renderer.initTexture()` before unwrapping? Is the tracer target actually rendered yet (width matches)? - Stale/destroyed texture errors after resize/reset → you cached a `GPUTexture`; re-resolve via `backendGet` each use. - Whole app's WebGPU dies → something called `destroyDevice()` (with ORT, releasing the last session destroys the shared device). Use `dispose()` between workloads. - Double-image/ghosting in aux mode → G-buffer not re-rendered after camera move. For the TSL nodes (`denoise()`, `ffxShadows()`, `ffxReflections()`) that wrap this flow, see [three.js & TSL](./three-and-tsl). More failure modes (black output from a device mismatch, CORS, device-lost) are covered in [Troubleshooting](./troubleshooting). Live: [three-pathtracer-webgpu](https://pmndrs.github.io/denoiser/three-pathtracer-webgpu/) · [ldraw-eiffel](https://pmndrs.github.io/denoiser/ldraw-eiffel/) ]]> [!NOTE] > Aux is optional — `color`-only models work fine. Only reach for aux once > you've got denoise working end-to-end; it adds a second (or third) render > pass and some conventions that are easy to get subtly wrong (see below). ## What OIDN expects | plane | range | encoding | notes | |---|---|---|---| | `albedo` | `[0,1]` | **linear**, never sRGB | unlit base/diffuse color. Only the `color` input honors an `srgb` option — albedo bytes/floats are read as-is | | `normal` | `[-1,1]` | view-space | image path (`ImgInput`/RGBA8 bytes): pre-encode to `[0,1]` yourself as `n * 0.5 + 0.5`. Texture path (`GPUTexture`): pass raw `[-1,1]` floats — the engine encodes internally | | env / miss pixels | `normal = 0` | — | a null vector, which encodes to `0.5` (mid-gray) — OIDN's convention for "no surface here". Don't leave garbage in these pixels | Both channels are aux **guides**, not literal composited output — they don't need to be physically correct, just consistent with these conventions. ### HDR albedo must be clamped Albedo can exceed `[0,1]` for HDR environment/emissive colors seen directly (an env pixel's "albedo" is its own color, which can be arbitrarily bright). The engine defensively clamps albedo to `[0,1]` in the texture path (`packages/denoiser-core/src/gpu/imageOps.ts`), but don't rely on that alone — an unclamped HDR albedo plane was one of [the three real aux bugs](#the-real-bugs-we-shipped) below, and clamping upstream (where you rasterize) keeps energy loss predictable instead of accidental. ## Rasterizing the G-buffer (three.js MRT) Two passes, because albedo and normal want **different scene state** — see [the real bugs](#the-real-bugs-we-shipped) for why: ```ts import { mrt, diffuseColor, normalView } from 'three/tsl'; // albedo pass: background ON. An env-hit pixel's albedo IS its own color — // that's correct, and the engine clamps it defensively for HDR values. renderer.setMRT(mrt({ albedo: diffuseColor })); renderer.setRenderTarget(albedoRT); renderer.render(scene, camera); // normal pass: background OFF (clear to 0 -> encodes to the 0.5 "no surface" // convention). A rasterized background quad's normalView would otherwise be a // meaningless screen-space gradient, not "no surface". const bg = scene.background; scene.background = null; renderer.setMRT(mrt({ normal: normalView })); renderer.setRenderTarget(normalRT); renderer.render(scene, camera); scene.background = bg; renderer.setRenderTarget(null); renderer.setMRT(null); ``` Then unwrap both targets to `GPUTexture`s the same way as the color target (see [three.js render targets](./three-js-render-targets#unwrap-the-threejs-texture-to-a-raw-gputexture)) and pass them straight through: ```ts const out = await denoiser.denoiseTextures({ color, albedo, normal, hdr: true, // tracer output (three-gpu-pathtracer >= 0.0.27) and raster G-buffer are both // top-down, so the inputFlipY / auxInputFlipY defaults (false) apply }); ``` Rasterized aux is noise-free, so passing both planes auto-selects the higher-quality `cleanAux` (`*_calb_cnrm`) model family. The G-buffer is view-dependent — re-render it whenever the camera moves, or you'll get ghosting between the (stale) guide and the (fresh) color. ### First-hit rule: force opaque for the aux passes > [!IMPORTANT] > Alpha-blended aux is garbage — OIDN's guides assume one surface per pixel. > Force every transparent material opaque before rendering **both** aux > passes (restore it afterward), even if your beauty pass uses real > transparency: ```ts function forceOpaque(scene: THREE.Scene) { const restore: Array<() => void> = []; scene.traverse((obj) => { const mats = obj.isMesh ? [].concat(obj.material) : []; for (const m of mats) { if (m.transparent) { m.transparent = false; m.needsUpdate = true; restore.push(() => { m.transparent = true; m.needsUpdate = true; }); } } }); return restore; } ``` Run this before each aux pass, call every `restore()` fn in a `finally` block (leaked MRT/material state breaks every later render), and always `setRenderTarget(null)` / `setMRT(null)` afterward too. ## The real bugs we shipped These are documented as fixed, not hypothetical — they were real defects in an early example build (`docs/status/STATUS.md`), and are exactly what the [aux-inputs demo](https://pmndrs.github.io/denoiser/aux-inputs/)'s correct/incorrect examples show side by side: 1. **Env normals were a garbage gradient.** Rendering the normal pass with the scene background enabled fed the network a screen-space color gradient from the background shader where it expected `normal = 0`. Fix: disable `scene.background` for the normal pass only (see above) — a cleared target reads as `0`, which is exactly OIDN's env convention. 2. **Alpha-blended normals.** Transparent surfaces blending multiple normals into one pixel produced a nonsensical averaged vector. Fix: force every transparent material opaque for both aux passes (first-hit only), restore after. 3. **Unclamped HDR albedo.** An env pixel's albedo (its own HDR color) could exceed `[0,1]` and was fed to the network un-clamped. Fix: clamp albedo to `[0,1]` defensively in the engine (`packages/denoiser-core/src/gpu/imageOps.ts`) in addition to clamping at render time. ## Debugging: see exactly what the network sees The [aux-inputs demo](https://pmndrs.github.io/denoiser/aux-inputs/) renders color/albedo/normal as raw network input — the same debug view used to catch the bugs above. If your denoised output looks worse *with* aux than without, dump these three planes before anything else; a wrong-looking guide is almost always the cause, not the model. Live: [aux-inputs](https://pmndrs.github.io/denoiser/aux-inputs/) · [gallery](https://pmndrs.github.io/denoiser/gallery/) ]]> [!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](https://github.com/microsoft/onnxruntime/issues/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](./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](./large-images-and-tiling)). ## Output options ```ts 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: | 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](./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. ```ts 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](https://pmndrs.github.io/denoiser/babylon/) 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 `GPUDevice`s** 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. ```ts // 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](https://pmndrs.github.io/denoiser/webgpu-raw/) · [babylon](https://pmndrs.github.io/denoiser/babylon/) ]]> [!WARNING] > If you must support non-WebGPU browsers, stay on v1 (`0.0.11`) for those > users — v2 throws `DenoiserUnsupportedError` where WebGPU/features are > missing. ## API mapping ### Construction ```ts // v1 — sync constructor, backend + context injection, ready-listener const denoiser = new Denoiser('webgpu', canvasOrDevice); denoiser.onBackendReady(() => { /* ... */ }); // v2 — async factory; share the device with your renderer const denoiser = await Denoiser.create({ precision: 'fp16', quality: 'fast', device: renderer.backend.device }); ``` You can no longer hand the denoiser a canvas; hand it a `GPUDevice` through the `device` option (with the default runtime), or share `denoiser.device` with your renderer. Only the opt-in ORT runtime keeps the old constraint: onnxruntime-web creates and owns its `GPUDevice` ([#26107](https://github.com/microsoft/onnxruntime/issues/26107)), so with ORT create the denoiser first. ### Executing ```ts // v1 — stateful: set modes/inputs, execute, receive via listener denoiser.inputMode = 'imgData'; denoiser.outputMode = 'imgData'; denoiser.onExecute((result) => draw(result), 'imgData'); await denoiser.execute(colorImg, albedoImg, normalImg); // v2 — one call, result returned const img = await denoiser.denoise(colorImg, { albedo: albedoImg, normal: normalImg }); ctx.putImageData(img, 0, 0); ``` ### Method-by-method | v1 | v2 | |---|---| | `execute(color, albedo, normal)` | `denoise(color, { albedo, normal })` → `ImageData` | | `setInputImage(name, img, flipY)` | pass inputs directly to `denoise()` (accepts ImageData, img/canvas/video elements, ImageBitmap, or raw `{data,width,height}`) | | `setInputData(name, f32/u8, w, h)` | same — the `ImgInput` union covers raw RGBA8 | | `setInputTexture(name, WebGLTexture)` | **gone (WebGL)** → `denoiseTextures({ color, albedo, normal })` with `GPUTexture`s | | `setInputBuffer(name, GPUBuffer)` | `denoiseTextures(...)` (textures replaced buffers as the GPU interchange) | | `setInputTensor(name, tf.Tensor)` | gone with TFJS | | `inputMode` / `outputMode` | gone — the method called *is* the mode (`denoise` → ImageData, `denoiseToFloat` → floats, `denoiseTextures` → GPUTexture) | | `setCanvas(canvas)` / `outputToCanvas` | gone — `putImageData` the result, or blit the output texture yourself | | `onExecute(cb, returnType)` | `on('executed', cb)`, or just `await` the call | | `onProgress(cb)` | `on('progress', cb)` or per-call `onProgress` | | `onBackendReady(cb)` | unnecessary — `await Denoiser.create()` | | `abort()` | `abort()` (unchanged: in-flight call resolves `undefined`) | | `dispose()` | `dispose()` frees buffers/extra sessions but **keeps the device**; full teardown is `destroyDevice()` (⚠ kills the shared device and everything on it) | ### Properties → per-call options | v1 instance flag | v2 | |---|---| | `quality` | `Denoiser.create({ quality })`, still mutable via `denoiser.quality` | | `hdr` | `denoiseTextures({ hdr: true })` per call | | `srgb` | `denoise(img, { srgb })` (default `true` for image inputs); texture path uses `transfer: 'linear' \| 'srgb' \| 'aces-srgb'` for output encoding | | `flipOutputY` | `flipY` (`denoise`) / `outputFlipY` (`denoiseTextures`); input orientation via `inputFlipY` / `auxInputFlipY` | | `height` / `width` | gone — taken from the input | | `useTiling` / `tileSize` / `batchSize` | automatic: whole-frame up to `maxRunPixels` (create option), tiled above it with `batch` tiles per run — see [Large images & tiling](./large-images-and-tiling) | | `weightsPath` / `weightsUrl` | `Denoiser.create({ tzaUrl })` — still `.tza` weights (`weightsUrl` now belongs to `OrtRuntime`, and points at `.onnx` models) | | `cleanAux` / `dirtyAux` | automatic — rasterized aux is treated as clean when both albedo+normal are present | | `debugging` / `stats` | `denoiser.stats` (per-stage timings of the last run) | ### Errors v2 throws typed errors instead of generic ones: `DenoiserUnsupportedError` (WebGPU/feature availability) and `DenoiserInputError` (mismatched sizes, missing aux, bad formats). Full signatures in the [API reference](../reference/api#errors). ## Weights hosting v1 served `.tza` files from a `/tzas` directory or CDN, and v2's default runtime does too: default CDN (jsDelivr, `models-v3`), override with `tzaUrl`. Only the opt-in ORT runtime serves converted `.onnx` models (`rt_ldr.onnx`, `rt_hdr_alb_nrm_small.fp16.onnx`, …), with `weightsUrl`. Regenerate the ORT models from OIDN `.tza` weights any time with [`tools/onnx-convert`](https://github.com/pmndrs/denoiser/blob/main/tools/onnx-convert/README.md) (Python, no PyTorch). ## Output differences to expect - v2's output parity vs native OIDN was tightened during the port (PU transfer, autoexposure, normal encoding, no final activation — [`docs/specs/oidn-color-reference.md`](https://github.com/pmndrs/denoiser/blob/main/docs/specs/oidn-color-reference.md)), so v2 output is *more* faithful to upstream OIDN than v1 was — i.e. not bit-identical to v1. - fp16 (`precision: 'fp16'`) measures ≈ 53 dB PSNR vs fp32 — visually identical, ~15% faster. ## Breaking changes in 2.x: `AutoRuntime` is the default During the 2.x alpha the default runtime changed from onnxruntime-web (ORT) to `AutoRuntime`, and ORT moved behind an explicit import. If you adopted an earlier 2.x build, update like this: ```ts // before: ORT was the default, configured through Denoiser.create const denoiser = await Denoiser.create({ precision: 'fp16', weightsUrl: '/models', wasmPaths: '/ort/', splitAux: true, }); // after, option 1 (recommended): the default AutoRuntime, OIDN .tza weights const denoiser = await Denoiser.create({ precision: 'fp16', tzaUrl: '/tzas', // optional, defaults to the jsDelivr models-v3 CDN device, // optional: run on an existing GPUDevice }); // after, option 2: keep ORT explicitly (npm i onnxruntime-web) import { OrtRuntime } from 'denoiser/ort'; const denoiser = await Denoiser.create({ runtime: new OrtRuntime({ weightsUrl: '/models', wasmPaths: '/ort/', splitAux: true }), precision: 'fp16', }); ``` - The root `denoiser` entry no longer re-exports `OrtRuntime`, `OrtSession`, `DenoiseEngine` or `Models`; import them from `denoiser/ort`. - `onnxruntime-web` is now an **optional** peer dependency, installed only for `denoiser/ort`. The root entry never loads it. - `weightsUrl`, `wasmPaths`, `graphCapture` and `splitAux` are no longer `Denoiser.create` options; they are `OrtRuntime` options. - New `Denoiser.create` options: `tzaUrl` and `device`. - The default runtime reads `.tza` weights (as v1 did), not `.onnx`. - With the default runtime you no longer have to create the denoiser first to share a device with three.js: create the renderer, then pass its device. Only ORT keeps the "denoiser first" order. - Why: ORT computes the `*_alb`, `*_alb_nrm` and `*_large` models wrong, while WGSL and WebNN match native OIDN (see [Choosing a runtime](./choosing-a-runtime)). ]]> 3-channel input. The `OrtRuntime` `splitAux` option (default **on**) works around it by computing that first conv ourselves in WGSL and running the rest of the graph on WebGPU normally — verified to restore native quality. You'll see speckled aux output if: - **`splitAux: false`** was set explicitly — the workaround is off, so the upstream bug is unmasked. Only do this if you have a specific reason to. - **You're self-hosting an old `models-v1` weights set** without the `.tail.onnx` + `.enc0.bin` split-graph artifacts `splitAux` needs. The runtime falls back to the plain (speckled) model with a one-time console warning in this case, rather than failing outright: ``` Denoiser: splitAux artifacts unavailable for , aux will speckle (ORT-web WebGPU Conv bug). Host .tail.onnx + .enc0.bin next to the model, or set splitAux:false to silence. ``` Mirror the current ORT weights tag (`models-v2` or later) if you self-host — see [Models & weights](../getting-started/models-and-weights) for hosting details. ## "My aux textures are silently ignored" If you pass `albedo`/`normal` keys whose value resolves to `undefined` (e.g. a three.js texture unwrap that failed and returned `undefined`, but you still passed the key), the denoiser degrades to the color-only model rather than throwing — but it warns once per input so this doesn't fail silently forever: ``` Denoiser: albedo texture could not be unwrapped — falling back to color-only model ``` If you see this warning, check whatever produced the texture — most commonly a stale/destroyed render target reference (re-resolve it fresh each frame; see the [render-targets pitfalls checklist](./three-js-render-targets#pitfalls-checklist)) or a G-buffer pass that hasn't rendered yet. ## CORS / hosting errors - **Default (`tzaUrl` unset) works out of the box** — the `.tza` weights are served from jsDelivr's GitHub endpoint (`cdn.jsdelivr.net/gh/...`), which sends `access-control-allow-origin: *`. (The ORT runtime's `weightsUrl` default works the same way.) - **GitHub Releases assets have no CORS support** — verified dead end, don't point `tzaUrl` (or `weightsUrl`) at a Releases download URL. `release-assets.githubusercontent.com` doesn't send `access-control-allow-origin` even on a plain `GET` with an `Origin` header, so browser `fetch()` fails. - For production, host the `.tza` files yourself (copy them into your own static assets/CDN and set `tzaUrl`; for ORT, the `.onnx` files and `weightsUrl`) rather than depending on someone else's default. See [Models & weights](../getting-started/models-and-weights) for the full hosting comparison. ## Device-lost after `dispose()` / `destroyDevice()` These two are not the same: - **`dispose()`** — safe between workloads. Frees per-image buffers and extra model sessions but keeps **one** session alive on purpose, so the shared device survives. - **`destroyDevice()`** — full teardown. With ORT, releasing the *last* session destroys the shared `GPUDevice` — your renderer, canvas contexts, and every other resource built on it die with it (`GPUDevice.lost`, reason `'destroyed'`). Only call this when the entire WebGPU stack is going away (e.g. tearing down the whole app/page). If you're seeing an unexpected device-lost event, search for a `destroyDevice()` call (or code that lets the denoiser's session count hit zero) rather than `dispose()` — `dispose()` cannot destroy the device. ## fp16 unavailable fp16 (`precision: 'fp16'`) needs the WebGPU `shader-f16` feature. Behavior differs by which API you use: - **`Denoiser.create({ precision: 'fp16' })`** — falls back automatically: if session creation fails because `shader-f16` is missing, it retries once at fp32 and logs `Denoiser: fp16 unavailable, falling back to fp32`. You don't need to handle this yourself. - **The lower-level `DenoiseEngine.create()`** — no fallback. It throws `DenoiserUnsupportedError: Denoiser: fp16 needs the shader-f16 WebGPU feature (unavailable on this device)`. Only relevant if you're bypassing `Denoiser` and driving `DenoiseEngine` directly. See the [API reference](../reference/api) for the full option/error surface. ]]> [!WARNING] > Everything on this page was measured on **Apple GPUs only** (an M5 Pro, Chrome > 154). The WGSL runtime in particular was tuned on that one GPU. Validate on > your target hardware before relying on any runtime. The WebNN and kernels > runtimes are **experimental**; WGSL is the stable, flag-free runtime. ## At a glance | runtime | import | what it is | status | |---|---|---|---| | Automatic | `denoiser` (default) or `denoiser/auto` | picks WebNN fp16 (base/large, when available) or WGSL per model, on one shared device | default; never picks ORT | | ONNX Runtime | `denoiser/ort` (opt-in) | onnxruntime-web, WebGPU execution provider, `.onnx` models. Needs the optional peer dependency `onnxruntime-web` | wrong on some models (see below); no longer the default | | HF kernels | `denoiser/kernels` | `@huggingface/kernels` (optional peer dependency): FusedConv / MaxPool / Resize / Concat, 28 calls per frame | experimental; needs a device-sharing shim today | | Hand-written WGSL | `denoiser/wgsl` | the U-Net as 16 (standard) / 19 (large) fused conv dispatches in one command encoder; no dependencies | stable; correct on every model, fastest with no flags (the Auto fallback) | | WebNN | `denoiser/webnn` | the graph built with `MLGraphBuilder`, run by the browser's ML backend (CoreML in Chrome on macOS) | experimental; Chrome flag required | The auto, kernels, WGSL and WebNN runtimes read the upstream OIDN `.tza` weights (fetched from jsDelivr, `models-v3`, by default, or from `{ tzaUrl }`). ORT reads the converted `.onnx` files. See [Models & weights](../getting-started/models-and-weights). ## Speed and quality Mean over models, 1080p, `denoiseTextures` to a texture, warm median, Apple M5 Pro with an idle GPU, headless Chrome 154. Multiples are against native OIDN on Metal. Source: `docs/status/eval-2026-10-08-quiet.md`. | 1080p | small | base | large | |---|---|---|---| | native OIDN, Metal | 14.3 ms | 24.8 ms | 54.2 ms | | ORT fp16 | 101 ms (7.1x) | 210 ms (8.5x) | runs base instead (see below) | | kernels fp16 | 79 ms (5.5x) | 163 ms (6.5x) | 440 ms (8.1x) | | WGSL fp16 | 33.6 ms (2.4x) | 67.0 ms (2.7x) | 167 ms (3.1x) | | WGSL fp32 (subgroup matrix, needs a Chrome feature flag) | 39.5 ms (2.8x) | 76.3 ms (3.1x) | 186 ms (3.4x) | | **WebNN fp16 (gpu)** | **31.5 ms (2.2x)** | **40.1 ms (1.6x)** | **65.4 ms (1.2x)** | | WebNN fp16 (npu, Neural Engine) | 33.1 ms (2.3x) | 43.6 ms (1.8x) | 78.9 ms (1.5x) | | WebNN fp32 (gpu) | 49.6 ms (3.5x) | 74.7 ms (3.0x) | 159 ms (2.9x) | | 512x512 | small | base | large | |---|---|---|---| | native OIDN, Metal | 2.7 ms | 4.0 ms | 7.8 ms | | WGSL fp16 | **4.6 ms** | 8.7 ms | 21.0 ms | | WebNN fp16 (gpu) | 6.4 ms | **7.3 ms** | **10.8 ms** | - **WebNN fp16 is the fastest web runtime for base and large models** at 1080p: 1.7x WGSL fp16 on base and 2.5x on large, and 1.2-1.6x from native Metal. On small models at 1080p the two are within about 7% (31.5 vs 33.6 ms), and at 512x512 WGSL fp16 is as fast or faster on small models; WebNN is ahead on base and large. - **WGSL fp16 is the fastest runtime that needs no browser flag.** Loading a model takes roughly 10-90 ms (the large model about 90 ms), against about 0.7 s for ORT's first-ever load and 0.8-1.0 s per model for kernels. - Every GPU runtime beats native OIDN **CPU** (189 / 331 / 739 ms for small / base / large at 1080p). - WebNN's Neural Engine path is close to its GPU path and leaves the GPU free for your renderer. **Quality** is measured against native OIDN on the CPU (the reference implementation, same weights), as PSNR and maximum 8-bit difference: | runtime | result | |---|---| | kernels, WGSL, WebNN | match native on every model: fp32 70-84 dB, within 1 LSB; fp16 62-78 dB, within 1-2 LSB (WebNN on the Neural Engine within 2 LSB) | | ORT | **wrong** on the 6-channel `*_alb` and 9-channel `*_alb_nrm` models: 32-40 dB, up to 120 LSB, costing up to 4.8 dB against the converged reference | | ORT, large topology | **wrong**: now falls back to the base model with a one-time console warning | One case, `eiffel/rt_ldr_calb_cnrm`, is 5 LSB from native on **every** runtime identically (70.1 dB). That is a shared pre/post difference, not a runtime problem, and it is still open. > [!NOTE] > ORT-WebGPU's first Conv miscomputes inputs with more than three channels. The > `splitAux` workaround covers the clean-aux (`*_calb_cnrm`) models only, so the > `*_alb` and `*_alb_nrm` variants stay wrong, and the facade reaches `*_alb` > whenever you pass albedo without a normal. ORT-WebGPU also miscomputes the > large topology. If you ask for the large model on ORT, you get the base model's > result and a warning, not an error. Use another runtime for these cases. ## Which one should I use? - **Nothing to configure:** `Denoiser.create()` uses `AutoRuntime` (below). It runs WebNN fp16 for base/large models when WebNN is available and WGSL otherwise, on one shared GPUDevice. It never picks ORT, so you get correct output on every model. - **Only if you must stay on ORT:** `OrtRuntime` from `denoiser/ort`, with `onnxruntime-web` installed. Expect the wrong results listed above on the `*_alb`, `*_alb_nrm` and `*_large` models; color-only and clean-aux (`albedo` + `normal`, with `splitAux`) at `fast` or `balanced` are the cases it gets right. - **Best result without browser flags:** `WgslRuntime`. Correct on every model, several times faster than ORT, fastest at small models and at 512x512. - **Best result on Chrome with flags enabled (macOS, Apple silicon):** `WebnnRuntime` with `precision: 'fp16'`, especially for base and large models at 1080p. - **Keep the GPU's compute free for your renderer:** `WebnnRuntime` with `deviceType: 'npu'` and the extra Chrome feature (below). - **Evaluating Hugging Face kernels:** `KernelsRuntime`. It is correct and faster than ORT, but slower than WGSL, and it needs a device-sharing shim (below). ## Selecting a runtime All runtimes plug into the same `Denoiser`. Import the runtime from its subpath entry and pass it as `runtime`. The facade, the options and the output are unchanged. ### ORT (opt-in) ```sh npm install onnxruntime-web # optional peer dependency of denoiser ``` ```ts import { Denoiser } from 'denoiser'; import { OrtRuntime } from 'denoiser/ort'; const denoiser = await Denoiser.create({ runtime: new OrtRuntime({ weightsUrl: '/models' }), // { weightsUrl?, wasmPaths?, graphCapture?, splitAux? } precision: 'fp16', }); ``` The root `denoiser` entry never loads onnxruntime-web. The ORT options (`weightsUrl`, `wasmPaths`, `graphCapture`, `splitAux`) are `OrtRuntime` options, not `Denoiser.create` options. ORT creates its own `GPUDevice` and cannot adopt one you pass, so with three.js create the denoiser first (see [three.js render targets](./three-js-render-targets)). ### Hand-written WGSL ```ts import { Denoiser } from 'denoiser'; import { WgslRuntime } from 'denoiser/wgsl'; const denoiser = await Denoiser.create({ runtime: new WgslRuntime(), // or { tzaUrl: '/tzas' } to self-host the weights precision: 'fp16', // 'fp32' | 'fp16' }); ``` No runtime dependencies. fp32 uses the portable vec4 conv kernel. On devices with Chrome's `chromium-experimental-subgroup-matrix` feature (today only behind `--enable-unsafe-webgpu`) fp32 uses an 8x8 subgroup-matrix kernel that is faster (the 39.5 / 76.3 / 186 ms row above); everyone else gets the portable kernel. For fp16 the portable kernel with f16 sums is used because it is faster than the matrix path (forcing the matrix path at fp16 was about 20% slower on base). ### WebNN ```ts import { Denoiser } from 'denoiser'; import { WebnnRuntime } from 'denoiser/webnn'; if (!WebnnRuntime.isAvailable()) { // navigator.ml / MLGraphBuilder missing: fall back to WgslRuntime } const denoiser = await Denoiser.create({ runtime: new WebnnRuntime({ deviceType: 'gpu' }), // 'gpu' (default) | 'npu' | 'cpu' precision: 'fp16', }); ``` Launch Chrome with: ```sh --enable-features=WebMachineLearningNeuralNetwork # and, to reach the Apple Neural Engine with deviceType: 'npu': --enable-features=WebMachineLearningNeuralNetwork,WebNNCoreMLExplicitGPUOrNPU ``` Caveats worth knowing before you ship anything on it: - **`'npu'` is `'gpu'` unless the second feature is on.** Without `WebNNCoreMLExplicitGPUOrNPU`, Chrome maps `'npu'` to CoreML's "all compute units", which picks the GPU (bit-identical outputs and timings). fp32 graphs on the explicit `'npu'` fall back to the CPU, so use fp16 there. - **Cold-cache compile cost.** Each new run geometry builds a new graph. With a warm CoreML on-disk cache this is 0.4-1.2 s per geometry on the GPU and 1.0-2.7 s on the Neural Engine. The very first build on a machine takes 5-7 s. The speed table above was measured with a warm cache. WGSL has no per-geometry cost. Keep your output size stable. - **Interop is fp16 only.** fp16 uses WebNN to WebGPU tensor export; fp32 goes through the host. Chrome also refuses to export very large tensors (the limit is a 2D surface: the product of all dimensions but the last at most 16384, and the last dimension at most 16384), and the runtime checks this up front and uses the host path for those geometries. In the measurements here, the interop path was not measurably cheaper than the host round trip. - **`navigator.ml.createContext(gpuDevice)` runs on the CPU** on this backend, so the runtime does not use it. - Chrome 154 stable and 157 Canary behaved identically. ### Hugging Face kernels ```ts import { Denoiser } from 'denoiser'; import { KernelsRuntime } from 'denoiser/kernels'; const denoiser = await Denoiser.create({ runtime: new KernelsRuntime(), // weights from jsDelivr, or { tzaUrl: '/tzas' } }); ``` `@huggingface/kernels` is an optional peer dependency. Install it to use this entry. Today (`0.0.1-preview.3`) it always creates its own `GPUDevice`, so the runtime hands it yours through a `navigator.gpu.requestAdapter` shim (`shareDeviceWithKernels`). That is needed for zero-copy IO and is not meant for production use. Never call kernels' `disposeSharedKernelRuntime()` while it runs on a shared device, because it destroys that device. kernels keeps one page-global runtime, so all `KernelsRuntime`s on a page share one device (the first one's). The first load costs about 0.8-1.8 s per model (kernel fetch and compile). ### Automatic (`AutoRuntime`, the default) ```ts import { Denoiser } from 'denoiser'; // the default: same as `runtime: new AutoRuntime({ tzaUrl, device })` const denoiser = await Denoiser.create({ precision: 'fp16', tzaUrl: '/tzas', // optional: self-host the .tza weights device, // optional: run on an existing GPUDevice, e.g. a three.js renderer's }); // explicit, with the WebNN control: import { AutoRuntime } from 'denoiser/auto'; // also exported from 'denoiser' const same = await Denoiser.create({ runtime: new AutoRuntime({ webnn: 'gpu' }), // { tzaUrl?, device?, webnn?: 'gpu' | 'npu' | false } precision: 'fp16', }); ``` Per model it chooses WebNN (fp16, base/large models, when `navigator.ml` can create a context) or the hand-written WGSL runtime (everything else, including all fp32). Both run on one GPUDevice — pass a three.js renderer's device as `device` to share it — so `denoiser.device` stays the same when the model changes. `runtime.lastChoice` reports what served the last load. Set `webnn: false` when first-call latency matters more than steady-state speed: WebNN compiles per image size (0.4–2.7 s each on a cold CoreML cache). ## Which device are you on? ORT creates the `GPUDevice` it runs on and ignores one you inject. The other runtimes (including the default `AutoRuntime`) create a device with the adapter's maximum limits and features, or adopt the `device` you pass, either as the `Denoiser.create({ device })` option or in the runtime's own options. Either way, build your own pipelines on **`denoiser.device`**, as described in [The GPUDevice](../concepts/the-gpudevice) and [Custom engines & raw WebGPU](./custom-engines). ## See it running These live demos have a runtime picker (it reloads the page with `?runtime=`; `auto`, `wgsl`, `webnn`, `ort` and `kernels` are accepted). WebNN is greyed out unless the browser exposes it (Chrome flag `WebMachineLearningNeuralNetwork`). - [Gallery](https://pmndrs.github.io/denoiser/gallery/): pre-rendered scenes, switch runtime and compare, e.g. [`?runtime=wgsl`](https://pmndrs.github.io/denoiser/gallery/?runtime=wgsl) - [three.js path tracer](https://pmndrs.github.io/denoiser/three-pathtracer-webgpu/): live path tracing, e.g. [`?runtime=ort`](https://pmndrs.github.io/denoiser/three-pathtracer-webgpu/?runtime=ort) - [LDraw Eiffel Tower](https://pmndrs.github.io/denoiser/ldraw-eiffel/): the Eiffel Tower model path-traced live, e.g. [`?runtime=webnn`](https://pmndrs.github.io/denoiser/ldraw-eiffel/?runtime=webnn) - [Benchmark](https://pmndrs.github.io/denoiser/bench/): cold and warm timings per runtime, e.g. [`?runtime=wgsl`](https://pmndrs.github.io/denoiser/bench/?runtime=wgsl) ## Reproducing the numbers The harness is `tools/eval` (native OIDN, the reference) plus `examples/eval` (the web runtimes). It runs 25 scene/model cases, with every input rounded to fp16 so each setup sees the same data. See `tools/eval/README.md`. The full tables, including cold-start times, are in `docs/status/eval-2026-10-08-quiet.md` and `docs/status/eval-2026-10-07.md` in the repository. ]]> [!NOTE] > The OIDN RT weights are single-frame models. Running them per frame would > flicker, so they are not wired into this API. The interface is > source-agnostic, so a neural temporal model (OIDN 3, once upstream ships it) > can implement it later. ## Which denoiser do I need? This package has two kinds of denoiser. They solve different problems, so the choice depends on what you render, not on quality. | | `Denoiser` (OIDN) | FFX temporal denoisers | |---|---|---| | Input | a whole path-traced frame (color, optional albedo and normal) | one ray-traced effect at 1 ray per pixel: shadow visibility, or glossy reflections | | Typical app | path tracers, product renders, viewers that refine when the camera stops | real-time raster apps: games, product configurators, archviz walkthroughs | | How often | on a still frame, or every few frames | every frame at 60 fps, with motion vectors and history | | Cost | tens of ms per denoise | about 0.5 ms (shadows) and 1.7 ms (reflections) at 1080p | | You provide | the frame, plus aux buffers if you have them | depth, normals, motion vectors, roughness (reflections); `resetHistory()` on cuts | **Use FFX** when you ship a raster renderer and want one effect that raster defaults can't fake. A shadow map gives hard shadows, or shadows blurred by the same amount everywhere. A ray-traced area light gives soft shadows that stay sharp where objects touch the ground and widen with distance. An env map reflects the environment, not the objects in the room. Glossy ray-traced reflections show the actual scene. Tracing 1 ray per pixel for just that effect is affordable. On its own it's far too noisy to show, and FFX turns it into a clean, stable signal in about 1-2 ms. **Use OIDN** when the whole image is path traced (all the lighting, bounces and glass) and it converges while the camera rests. OIDN denoises one complete frame with no history, which takes too long to run every frame at 60 fps. The [ray-traced shadows and reflections demo](https://pmndrs.github.io/denoiser/three-temporal/shadows.html) shows the FFX case in a three.js scene. Each effect toggles against the raster default (a VSM shadow map, env-map-only reflections) and a converged reference, and a live table shows the per-pass GPU cost. ## The API ```ts interface TemporalDenoiser { readonly name: string; // e.g. 'ffx-shadows' readonly device: GPUDevice; configure({ width, height }): void; // (re)allocate; drops history dispatch(inputs: Inputs, guides: FrameGuides, camera: FrameCamera): GPUTexture; resetHistory(): void; // camera cut readonly gpuTimings?: ReadonlyMap; dispose(): void; // never destroys the device } ``` ### Frame contract - **Synchronous, encode-and-submit.** Call `dispatch()` **after** the frame's inputs have been submitted. It records its passes and submits them on `device.queue` with no awaits and no readback, so anything you submit afterwards sees the result. This is why it fits inside a render loop (the single-image engine has to await). - **The returned texture is owned by the denoiser.** It is valid until the next `dispatch()`, `configure()` or `dispose()`. Sample or copy it before then. - `configure()` allocates for a size and drops history. Call it before the first dispatch and again on resize. - `resetHistory()` drops all history on the next dispatch. Call it on camera cuts, teleports and scene switches. - `dispose()` releases the denoiser's GPU resources and never destroys the device. - `gpuTimings` holds per-pass GPU times in milliseconds when the device supports `timestamp-query`; `total` is the sum of the passes. ### Guides and camera Every temporal filter needs the same reprojection inputs, so they are shared. All textures are the same size as the signal. ```ts interface FrameGuides { depth: GPUTexture; // hardware depth (depth32float / depth24plus) or r32float normal: GPUTexture; // world-space unit normals in [-1, 1] (rgba16float / rgba8snorm) motion: GPUTexture; // NDC delta, current - previous (rg16float) roughness?: GPUTexture; // perceptual roughness [0, 1] (r8unorm / r16float), specular signals albedo?: GPUTexture; // surface albedo [0, 1] } interface FrameCamera { view: Mat4; // column-major 4x4, as three.js Matrix4.elements projection: Mat4; // WITHOUT jitter prevView: Mat4; prevProjection: Mat4; jitter?: readonly [number, number]; // sub-pixel jitter of this frame, in pixels (default [0, 0]) near: number; far: number; position?: readonly [number, number, number]; // world-space camera position reversedDepth?: boolean; // reversed-Z (1 = near), default false } ``` The conventions match [`@pmndrs/upscaler`](https://github.com/pmndrs/upscaler) (FSR3), so a chain such as render, denoise, upscale runs on one device with one set of guides: - **Motion** is the NDC delta, current minus previous, which is three.js' `velocity` node. - **Projection** is the unjittered projection matrix (the upscaler's `unjitteredProjectionMatrix`), plus the jitter separately. - Matrices are column-major. - The API is `configure` / `dispatch` / `resetHistory` / `dispose`. ### Signal inputs Filters are signal-specific because one filter cannot serve them all: shadows need penumbra-aware kernels, reflections need roughness-driven lobes and hit-distance reprojection, GI needs wide kernels and long history. | type | fields | used by | |---|---|---| | `ShadowInputs` | `visibility` (1-spp, [0, 1], 1 = lit), optional `hitDistance` | `FfxShadowDenoiser` | | `SpecularInputs` | `radiance` (linear HDR, rgba16float), `hitDistance` (r16float, 0 = miss) | `FfxReflectionDenoiser` | | `DiffuseInputs` | `radiance`, optional `hitDistance` | none yet | | `BeautyInputs` | `color`, optional `albedo` / `normal`, `hdr` | none yet (reserved for neural temporal models) | ## FidelityFX denoisers (`denoiser/ffx`) `denoiser/ffx` is a WGSL port of the AMD FidelityFX Denoiser from FidelityFX SDK **v1.1.4** (MIT; AMD's notice is kept in every ported file). The constructors take a `GPUDevice` and options. Only core WebGPU is required (core storage formats, at most four storage textures per stage, so default limits are enough); `timestamp-query` is used for `gpuTimings` when present. There are no runtime dependencies. ### Shadows ```ts import { FfxShadowDenoiser } from 'denoiser/ffx'; const shadows = new FfxShadowDenoiser(device); shadows.configure({ width, height }); // every frame, after the frame's inputs are submitted: if (cameraCut) shadows.resetHistory(); const mask = shadows.dispatch( { visibility }, // r32float / r16float / r8unorm, 1 = lit { depth, normal, motion }, // FrameGuides { view, projection, prevView, prevProjection, near, far }, ); // `mask` (rgba16float, .r = denoised visibility) is valid until the next dispatch. ``` Input requirements: - `visibility` is one ray or shadow-map sample per pixel. FFX is a **binary** one-ray denoiser, so fractional visibility (PCF and the like) is thresholded: values at or above `visibilityThreshold` (default 0.5) count as lit. - `hitDistance` is accepted but unused. FFX shadows do not use blocker distance. - Zero-length (sky) normals are guarded against, so they do not turn into NaN filter weights. Options (`FfxShadowDenoiserOptions`): `depthSimilaritySigma` (default 1), `visibilityThreshold` (default 0.5), `timestamps` (default true). ### Reflections ```ts import { FfxReflectionDenoiser } from 'denoiser/ffx'; const reflections = new FfxReflectionDenoiser(device); reflections.configure({ width, height }); const out = reflections.dispatch( { radiance, hitDistance }, // rgba16float linear HDR, r16float (0 = miss) { depth, normal, motion, roughness }, // roughness is required camera, // FrameCamera, including `position` and `far` ); ``` Input requirements: - `guides.roughness` is **required**. It is perceptual roughness by default; set `roughnessIsPerceptual: false` if yours is linear. - `hitDistance` of 0 (or less) means a miss; misses use `missDistance` (default `camera.far`). - Radiance must be **finite and greater than zero wherever there is a reflection**. The resolve pass treats `r+g+b < 1e-4` as "no reflection" and outputs 0, and a single NaN wipes a 7x7 prefilter footprint. - Every pixel is assumed to have been traced. The default `roughnessThreshold` of 1 denoises all pixels. Options (`FfxReflectionDenoiserOptions`): `temporalStabilityFactor` (default 0.7; higher is more stable and more ghosting), `roughnessThreshold` (default 1), `roughnessIsPerceptual` (default true), `missDistance`, `currentFrameStatistics` (default true), `timestamps` (default true). `currentFrameStatistics` fixes a one-frame-off ping-pong in the v1.1.4 host code that blends zeroed history at disocclusions (dark speckles) and outputs a near-black first frame after a reset. `false` reproduces the SDK exactly. `resetHistory()` also drops the geometry history, which FFX's own reset does not. ### How the guides map onto FFX | FFX concept | what you pass | |---|---| | motion vector scale (-0.5, 0.5) | motion as an NDC delta, current minus previous | | normals unpack mul/add 1 / 0 | world normals already in [-1, 1] | | inverted depth | `camera.reversedDepth` | | jitter | ignored: positions are reconstructed from the unjittered projection, and the sub-pixel error is within FFX's tolerances | ### Measured results A test bed (`examples/ffx-denoiser`) renders an analytic scene with an area light, spheres, boxes and a roughness-banded plane, with a scripted 120-frame camera path (orbit and dolly, a hard cut at frame 60 that calls `resetHistory()`, then a lateral truck). Everything is compared against a 256-spp reference of the same estimator at 1920x1080 on an Apple M5 Pro (Chrome 154). **Shadows** | | PSNR all | PSNR penumbra | flicker | bias (penumbra) | |---|---|---|---|---| | noisy 1 spp | 17.63 dB | 7.80 dB | 0.0393 | n/a | | FFX shadows, spatial only | 34.07 dB | 24.55 dB | 0.0055 | -0.030 | | **FFX shadows** | **34.24 dB** | **25.06 dB** | **0.0029** | -0.042 | The temporal part halves flicker and adds about 0.5 dB in the penumbrae. With a static camera, penumbra PSNR peaks after about 10 frames (25.1 dB) and drifts down about 1 dB, because FFX's history is the output of filter pass 0 and the spatial blur compounds over frames (slightly softened contact shadows). **Reflections** (after a Reinhard tonemap) | | PSNR | flicker | bias | |---|---|---|---| | noisy 1 spp | 18.05 dB | 0.0845 | n/a | | FFX reflections, spatial only | 24.50 dB | 0.0157 | -0.039 | | FFX reflections, SDK-exact (`currentFrameStatistics: false`) | 22.17 dB | 0.0174 | -0.050 | | **FFX reflections (default)** | **23.46 dB** | **0.0080** | -0.040 | An honest reading: in this scene FFX reflections halves flicker against its own spatial-only result, at the price of about 1 dB PSNR under fast camera motion (ghosting on glossy surfaces, conservative disocclusion rejection), and the result is biased dark by about 0.04 (tonemapped). That bias is by design: the prefilter and resolve both pull toward a luminance-weighted 8x8 average that suppresses fireflies, and loosening `temporalStabilityFactor` does not change it. On the first frame after a reset the default scores 19.8 dB and the cut frame 30.4 dB, against 4.8 dB and 10.1 dB for SDK-exact. **GPU cost, 1080p** (timestamp queries, median of 100, idle GPU, Apple M5 Pro): | denoiser | GPU time | |---|---| | shadows | **0.49 ms** | | reflections | **1.66 ms** | Not optimized yet: there are no subgroup paths (wave intrinsics are emulated in workgroup memory), and reflection reproject and resolve each build a 9x9 neighbourhood per pixel from workgroup memory. FFX's SSSR tile classifier is part of SSSR, not the denoiser, so it is not in this port. ## Using it with raw WebGPU The temporal API is engine-agnostic. You need a `GPUDevice`, the signal and the guides as `GPUTexture`s in the formats above, and the camera matrices. ```ts import { FfxShadowDenoiser, type FrameCamera } from 'denoiser/ffx'; const adapter = await navigator.gpu.requestAdapter(); const device = await adapter!.requestDevice({ requiredFeatures: adapter!.features.has('timestamp-query') ? ['timestamp-query'] : [], }); const shadows = new FfxShadowDenoiser(device); shadows.configure({ width: canvas.width, height: canvas.height }); function frame(camera: FrameCamera, cut: boolean) { renderGBufferAndShadowRays(device); // your pass: writes depth, normal, motion, visibility if (cut) shadows.resetHistory(); const mask = shadows.dispatch({ visibility }, { depth, normal, motion }, camera); compositePass(device, mask); // your pass: sample `mask.r` this frame } ``` Sizes passed to `configure()` must match the signal and guides. Resize by calling `configure()` again, which drops history. ## three.js and TSL `denoiser/three` wraps a `TemporalDenoiser` as a TSL node the way `@pmndrs/upscaler`'s `UpscalerNode` wraps the upscaler: `ffxShadows()`, `ffxReflections()` and the generic `temporalDenoise()`. The nodes only advance inside `renderer.setAnimationLoop`, and with FSR3 jitter enabled they currently show much higher residual flicker (open issue). See [three.js & TSL](./three-and-tsl). ]]> new AutoRuntime({ device }), precision: 'fp16', }); ``` `WgslRuntime`, `WebnnRuntime`, `KernelsRuntime` and `AutoRuntime` can adopt a device. **`OrtRuntime` cannot**: it always creates its own, so with ORT the order is reversed, denoiser first, then `new WebGPURenderer({ device: denoiser.device })`. `createDenoiserForRenderer` throws a pointed error if the runtime ended up on a different device. For heavy pipelines (path tracers) request a high-limits device first; `patchWebGPUForMaxLimits()` in `examples/_shared/stack.ts` shows how, and the same file implements the whole flow behind a `?runtime=` selector. ## Single images: `denoise()` The denoise is async GPU work (it awaits the network), so the node is not per frame. It keeps showing its input until a result lands in its own `rgba16float` texture, then swaps to it. That fits stills, converged path-tracer frames and progressive refinement. ```ts import { denoise } from 'denoiser/three'; import { texture } from 'three/tsl'; const node = denoise(texture(rawLinearRT.texture), { denoiser, // a ready Denoiser, or `runtime: (device) => ...` to build one lazily hdr: true, // linear HDR input (default); false = LDR in [0, 1] albedo, normal, // optional aux texture nodes (normal in [-1, 1]) -> guided models when: () => pathTracer.samples >= 64, // or `every: 30`, or call node.request() }); post.outputNode = node; // shows the input until node.denoised // camera moved / accumulation restarted: node.invalidate(); ``` How a run gets started, during the next render that includes the node: | control | meaning | |---|---| | `request()` | run on the next frame (coalesces with a queued request) | | `every: N` | auto-request every N rendered frames while idle | | `when: () => boolean` | auto-request while it returns true and the current view has no result | | `invalidate()` | the input changed: stop showing the old result, drop an in-flight one when it lands | | `settled()` | resolves when the run in flight is done; `request()`, render once, then `await settled()` | | `setAux(albedo?, normal?)` | swap the aux inputs; nothing for color-only | | `onDenoised(gpuTexture, ms)` | fires per fresh result | Orientation and color follow the [render-target guide](./three-js-render-targets): `inputFlipY` is `false` by default (rasterized targets are top-down) and should be `true` for compute-written targets such as a path tracer's output. `transfer: 'linear'` (default) writes unclamped linear HDR so three's own tone mapping still applies; `'aces-srgb'` and `'srgb'` bake the display transform in, so present them with a raw write (a `QuadMesh` `fragmentNode`), as [`examples/realtime-compare`](https://github.com/pmndrs/denoiser/blob/main/examples/realtime-compare/src/main.ts) does. ## Real-time: `ffxShadows` and `ffxReflections` These wrap the [FidelityFX denoisers](./realtime-temporal-denoising) as nodes. They convert three's conventions into what the denoisers want: normals become world-space and signed, `velocity` is used as is, depth is read straight from the scene pass's hardware depth texture, and signals that are not already textures are rendered into half-float textures first. ```ts import { pass, mrt, output, vec4, normalView, materialRoughness, packNormalToRGB, velocity } from 'three/tsl'; import { ffxShadows, ffxReflections } from 'denoiser/three'; const scenePass = pass(scene, camera); scenePass.setMRT(mrt({ output, normal: vec4(packNormalToRGB(normalView), materialRoughness), // packed view normal + roughness velocity, })); const depth = scenePass.getTextureNode('depth'); const nTex = scenePass.getTextureNode('normal'); const velTex = scenePass.getTextureNode('velocity'); // visibility: your 1-spp stochastic shadow ray, 1 = lit (a float or texture node) const shadow = ffxShadows(visibility, { camera, depth, velocity: velTex, normal: nTex, normalSpace: 'view', normalEncoding: 'packed', }); // ... use shadow.r in the composite // radiance: three's ssr() node; its alpha is the ray length (hit distance) const refl = ffxReflections(ssrNode, { camera, depth, velocity: velTex, roughness: nTex.a, normal: nTex, normalSpace: 'view', normalEncoding: 'packed', }); // ... use refl.rgb in the composite ``` Notes: - `ffxShadows` returns the denoised visibility in `.r`. FFX shadows are a binary one-ray denoiser, so fractional visibility is thresholded at 0.5. - `ffxReflections` takes `hitDistance` from `radiance.a` unless you pass one, and sanitizes NaN, negative and overflowing radiance first (`sanitize`, default true). Pixels without a reflection must be 0. - `resolutionScale` must match the scene pass's `setResolutionScale` (for example `1 / ratio` when rendering low-res for the upscaler). - Call `.resetHistory()` on the returned node at camera cuts, teleports and scene switches. Set `node.enabled = false` to skip the denoiser; history is dropped and re-enabling starts fresh. - `node.outputTexture` is a stable three texture (resized in place), so consumers that keep a reference can use it, for example feeding the denoised reflection back to three's SSR as history: `ssrNode.setHistory(refl.outputTexture, velocityTexture)`. - `options.denoiser` takes the FFX options (`FfxShadowDenoiserOptions`, `FfxReflectionDenoiserOptions`); `options.node` takes `ignoreViewOffset`, `unjitteredProjection` and `onDispatch`. For another `TemporalDenoiser`, build the node yourself: ```ts import { temporalDenoise } from 'denoiser/three'; import { FfxShadowDenoiser } from 'denoiser/ffx'; const n = temporalDenoise( (device) => new FfxShadowDenoiser(device), { visibility }, // inputs, keyed like the denoiser's Inputs { depth, normal, motion }, // guides: world-space signed normals camera, ); ``` > [!IMPORTANT] > Temporal nodes advance in three's `FRAME` node updates, which three only runs inside > `renderer.setAnimationLoop`. Driving frames with a hand-rolled > `requestAnimationFrame` loop and `pipeline.render()` will not step them. The > examples use `setAnimationLoop` even for headless stepping. ### With FSR3 jitter The temporal nodes read the camera's projection without any view offset, so sub-pixel jitter from `@pmndrs/upscaler` (FSR3) is stripped before reprojection, and you pass the matching `resolutionScale`. The shadows demo builds exactly this chain when you pick "half res + FSR3" (or open it with `?upscale=1`): scene pass at half resolution, ray trace, denoise, composite, `upscale(...)`, then tone mapping. The upscaler stays linear HDR in and out, so tone map and sRGB-encode after it. > [!NOTE] > **Use `@pmndrs/upscaler` 0.5.0 or newer.** The residual flicker seen with FSR3 jitter > came from the upscaler, not the denoisers: in the shadows demo (still camera, > ratio 2) a perfect, noise-free shadow flickered as much as the FFX-denoised one. > 0.5.0 (still-scene convergence fixes) cut the jittered flicker in the shadow region > from 0.0132 to 0.0047, level with the perfect shadow (0.0051). Those numbers come > from the demo's earlier sphere scene. In the current room scene the FFX shadow > measures 0.0041 against 0.0042 for the perfect one. The remaining whole-frame > shimmer is the upscaler's auto-exposure following the jittered input; a fix (a > 1/16-stop adaptation dead-band) is pending in the upscaler repo. The demo's > `__t.diag()` / `__t.fsrAblation()` / `__t.fsrHeatmap()` hooks (with `?upscale=1`) > reproduce these measurements. ## Examples - [`examples/three-temporal`](https://github.com/pmndrs/denoiser/tree/main/examples/three-temporal/src): `shadows.ts` ([live](https://pmndrs.github.io/denoiser/three-temporal/shadows.html)): when to use the FFX nodes. A furnished room in a raster pipeline with 1-ray-per-pixel area-light shadows, traced against the scene meshes with three-mesh-bvh's WebGPU BVH in a TSL pass, and glossy SSR, through `ffxShadows` and `ffxReflections`. Each one is compared with a three.js shadow map or an env map and with a converged reference, with per-pass GPU timings and optional FSR3. `ssr.ts` is a port of three's `webgpu_postprocessing_ssr_denoise` example with `ffxReflections` side by side against three's own denoiser. - [`examples/realtime-compare`](https://github.com/pmndrs/denoiser/blob/main/examples/realtime-compare/src/main.ts): a `denoise()` node on a cadence next to three's denoiser. - [`examples/_shared/stack.ts`](https://github.com/pmndrs/denoiser/blob/main/examples/_shared/stack.ts): the renderer/denoiser bootstrap for every runtime, selected with `?runtime=`. ## Helpers underneath | export | purpose | |---|---| | `getDevice(renderer)` | the `GPUDevice` owned by an initialized `WebGPURenderer`; throws if the renderer is not initialized or not backed by WebGPU | | `getGPUTexture(renderer, texture)` | the raw `GPUTexture` behind a three texture; it must already exist on the GPU (rendered to, or passed through `renderer.initTexture()`) | | `CameraHistory` | builds the [`FrameCamera`](./realtime-temporal-denoising#guides-and-camera) a temporal denoiser needs from a three camera and keeps the previous frame's matrices | ```ts import { getDevice, CameraHistory } from 'denoiser/three'; import { FfxShadowDenoiser } from 'denoiser/ffx'; const shadows = new FfxShadowDenoiser(getDevice(renderer)); shadows.configure({ width, height }); const history = new CameraHistory(); // per frame, after the camera has moved: const cam = history.update(threeCamera, { unjitteredProjection, jitter }); // then: shadows.dispatch(inputs, guides, cam) ``` Call `CameraHistory.update()` once per frame, after the camera has moved and before `dispatch()`. `reset()` stops reprojecting from the old pose after a cut. For the zero-copy path with plain render targets and no nodes, see [three.js render targets in / out](./three-js-render-targets). ]]> ``` #### `DenoiserCreateOptions` | option | type | default | meaning | |---|---|---|---| | `precision` | `'fp32' \| 'fp16'` | `'fp32'` | fp16 models + tensors + WGSL IO when the device's `shader-f16` feature is available. `Denoiser.create` auto-falls-back to fp32 (with a console warning) if session creation fails under fp16 | | `quality` | `Quality` (`'fast' \| 'balanced' \| 'high'`) | `'fast'` | model family: fast = `*_small`, balanced = base, high = `*_large` where that size exists for the variant. Mutable after creation via `denoiser.quality` | | `tzaUrl` | `string` | jsDelivr: `https://cdn.jsdelivr.net/gh/pmndrs/denoiser-weights@models-v3/tzas` | base URL the OIDN `.tza` weight files are fetched from by the default runtime. Ignored when `runtime` is passed | | `device` | `GPUDevice` | a max-limits device the runtime creates | run the default runtime on this existing device, for example a three.js renderer's (`renderer.backend.device`). Ignored when `runtime` is passed. See [The GPUDevice](../concepts/the-gpudevice) | | `maxRunPixels` | `number` | `2048 * 1152` (2 359 296) | per-run pixel budget (padded W×H); above it the engine tiles instead of running whole-frame. See [Large images & tiling](../guides/large-images-and-tiling) | | `batch` | `number` | `8` | max tiles batched into one `session.run` in tiled mode | | `runtime` | `NetworkRuntime` | `AutoRuntime` (built from `tzaUrl` and `device`) | what executes the network — see [Network runtimes](#network-runtimes) and [Choosing a runtime](../guides/choosing-a-runtime). When passed, `tzaUrl` / `device` are ignored (configure the runtime itself). The ORT-only options (`weightsUrl`, `wasmPaths`, `graphCapture`, `splitAux`) are no longer `Denoiser.create` options; they belong to `new OrtRuntime({...})` from `denoiser/ort` | ### Properties | member | type | notes | |---|---|---| | `device` | `GPUDevice` | the shared device: the `device` option if you passed one, otherwise one the runtime created. Share it with your renderer — build your own pipelines on it, don't create your own device. See [Custom engines](../guides/custom-engines) | | `stats` | `DenoiseStats \| undefined` (getter) | per-stage wall-clock timings from the most recently *completed* run; `undefined` before the first call. See [`DenoiseStats`](#denoisestats) below | | `modelName` | `string \| undefined` (getter) | the model file the last call actually ran (e.g. `rt_hdr_calb_cnrm`); `undefined` before the first call. Changes automatically when different inputs (hdr/aux) select a different variant | | `quality` | `Quality` (getter/setter) | current quality tier; assign to switch — the next call rebuilds the engine for the new model if needed | ### `denoise(color, options?)` ```ts async denoise(color: ImgInput, options?: DenoiseImageOptions): Promise ``` Denoises an image-like input on the CPU-array path. Returns `undefined` only if `abort()` was called before the run resolved. #### `DenoiseImageOptions` | option | type | default | meaning | |---|---|---|---| | `albedo` | `ImgInput` | – | aux plane; bytes read as [0,1] **linear** (never sRGB-decoded — only `color` honors `srgb`). Must match `color`'s size or throws `DenoiserInputError` | | `normal` | `ImgInput` | – | aux plane; RGBA8 bytes already encoding `[-1,1]` as `[0,1]` (`n*0.5+0.5`); env/miss pixels should be `0` (→ encodes to mid-gray `0.5`). Must match `color`'s size | | `srgb` | `boolean` | `true` | `color` input is sRGB-encoded (photos/screens) — decoded to linear for the model, re-encoded on the way out. Set `false` if your bytes are already linear | | `flipY` | `boolean` | `false` | flip the result vertically | | `onProgress` | `(p: number) => void` | – | see [progress semantics](#progress-semantics) | ### `denoiseToFloat(color, options?)` ```ts async denoiseToFloat(color: ImgInput, options?: DenoiseImageOptions): Promise ``` Same as `denoise()`, but returns normalized `[0,1]` RGBA floats (`ImageData` bytes ÷ 255) instead of `ImageData`. Same options, same `undefined`-on-abort behavior. Internally calls `denoise()`, so it pays the same 8-bit quantization — use `denoiseTextures` if you need real float/HDR fidelity. ### `denoiseTextures(options)` ```ts async denoiseTextures(options: DenoiseTexturesOptions): Promise ``` Zero-copy: float `GPUTexture`s in, `GPUTexture` out, no CPU round-trip. Returns `undefined` only if `abort()` was called before the run resolved. #### `DenoiseTexturesOptions` | option | type | default | meaning | |---|---|---|---| | `color` | `GPUTexture` | **required** | noisy beauty input — float format (`rgba16float`/`rgba32float`), always treated as **linear** (no sRGB decode on the texture path) | | `albedo` | `GPUTexture` | – | aux plane, `[0,1]` floats | | `normal` | `GPUTexture` | – | aux plane, `[-1,1]` floats — the network's `[0,1]` encoding is applied internally | | `hdr` | `boolean` | `false` | apply OIDN's PU transfer + autoexposure around the model | | `inputScale` | `number` | auto (autoexposure) | manual HDR exposure scale; set to skip the GPU autoexposure pass | | `inputFlipY` | `boolean` | `false` | `color` rows are bottom-up (compute-written WebGPU targets) | | `auxInputFlipY` | `boolean` | = `inputFlipY` | independent flip for `albedo`/`normal` when their convention differs from `color` | | `output` | `GPUTexture` | – | caller-owned destination. Must match `color`'s width/height, have `STORAGE_BINDING` usage, and be `rgba8unorm` or `rgba16float` — else throws. Omit to get an engine-owned texture back instead | | `transfer` | `OutputTransfer` (`'linear' \| 'srgb' \| 'aces-srgb'`) | `'linear'` | output encoding: `'linear'` = raw model output (HDR-safe), `'srgb'` = sRGB-encode, `'aces-srgb'` = ACES tonemap + sRGB-encode (display-ready) | | `outputFlipY` | `boolean` | `false` | flip the result vertically | | `onProgress` | `(p: number) => void` | – | see [progress semantics](#progress-semantics) | Returns your `output` texture if one was given; otherwise an **engine-owned `rgba8unorm` texture** valid only until the next call / a size change / `dispose()`/`destroyDevice()` — copy or sample it before calling again. ### `abort()` ```ts abort(): void ``` Marks the in-flight run so it resolves `undefined` instead of a result. Does **not** cancel GPU work already submitted — the work still completes, its result is just dropped. Typical use: drop a stale in-progress denoise when the camera/scene changed mid-run. ### `dispose()` ```ts dispose(): void ``` Frees per-image GPU buffers and all but the default (first-created) model session/geometry. **Keeps the shared device alive.** Safe to call between unrelated workloads. ### `destroyDevice()` ```ts destroyDevice(): void ``` Full teardown: releases every session, including the last one — which **destroys the shared `GPUDevice`**. Anything else built on `denoiser.device` (a three.js renderer, canvas contexts, your own pipelines) dies with it. Only call this when the entire WebGPU stack is going away. ### `on(event, callback)` ```ts on(event: DenoiserEvent, callback: (value: unknown) => void): () => void ``` `DenoiserEvent` is `'progress' | 'executed'`. Returns an unsubscribe function. - `'progress'` — same callback signature/semantics as the per-call `onProgress` option, fired for *every* call (not just ones that passed their own `onProgress`). See [progress semantics](#progress-semantics). - `'executed'` — fired once per completed call (including aborted ones) with the call's result: `ImageData | Float32Array | GPUTexture | undefined` depending on which method ran. ### Progress semantics `onProgress` (and the `'progress'` event) receive a `0–1` fraction. In **tiled** mode it's called once per batch of tiles as they complete (incremental progress). In **whole-frame** mode there is exactly one implicit batch, so it's called exactly once, near the end of the run, with `1` — not incrementally. Don't rely on it for a smooth progress bar on small/whole-frame images; it's meaningful for large tiled runs. ## `DenoiseStats` Returned by `denoiser.stats` — per-stage wall-clock timings (ms) from the most recently completed run: | field | type | meaning | |---|---|---| | `width`, `height` | `number` | the run's image size | | `tiles` | `number` | total tile count (`1` for whole-frame) | | `batches` | `number` | number of `session.run` calls | | `tileW`, `tileH` | `number` | the chosen tile geometry | | `batchSize` | `number` | tiles per batch actually used | | `uploadMs` | `number` | input upload + accumulate/weight buffer clear | | `encodeMs` | `number` | WGSL extract/accumulate encode + submit (CPU-side) | | `runMs` | `number` | sum of awaited network-run time (`NetworkBinding.run()`). Runtimes submit GPU work asynchronously (ORT and HF kernels both return in ~1 ms), so this is mostly CPU encode/submit time; the network's GPU time lands in `resolveMs`, where the readback waits for the queue. Use `totalMs` to compare runtimes | | `resolveMs` | `number` | resolve pass + readback/map | | `totalMs` | `number` | wall clock for the whole call | ## Errors Both extend `Error` with `name` set to match, so `err.name` or `instanceof` both work. | class | thrown when | |---|---| | `DenoiserUnsupportedError` | WebGPU/device-capability problems: no adapter available (`Denoiser.create()` with no WebGPU support), or `precision:'fp16'` requested on a device without `shader-f16` (only surfaces directly from the lower-level `DenoiseEngine.create()` — `Denoiser.create()` catches this case and falls back to fp32 instead) | | `DenoiserInputError` | bad/inconsistent inputs: `albedo`/`normal` size mismatch vs `color`, a raw `{data,width,height}` input whose byte length isn't `width*height*4`, or a model that requires albedo/normal but didn't get one | > [!NOTE] > An `outputTexture`/`output` with the wrong size, missing `STORAGE_BINDING`, > or an unsupported format throws a **plain `Error`**, not > `DenoiserInputError` — an inconsistency in the current source (worth > catching generically, or catching `Error` broadly, if you specifically > handle `DenoiserInputError` for input validation). See [Troubleshooting](../guides/troubleshooting) for the exact wording of each thrown/warned message and how to fix the underlying cause. ## Types | type | shape | |---|---| | `Quality` | `'fast' \| 'balanced' \| 'high'` | | `ImgInput` | `ImageData \| HTMLImageElement \| HTMLCanvasElement \| HTMLVideoElement \| ImageBitmap \| OffscreenCanvas \| { data: Uint8ClampedArray; width: number; height: number }` | | `OutputTransfer` | `'linear' \| 'srgb' \| 'aces-srgb'` | | `DenoiserEvent` | `'progress' \| 'executed'` | ## Advanced / lower-level exports These are exported from the package (`import { ... } from 'denoiser/ort'`, no longer from the root entry) but sit below `Denoiser` — most integrations never need them directly. Documented here for completeness; treat their shapes as more likely to change than the `Denoiser` surface above. ### `Models` From `denoiser/ort` (no longer exported from the root `denoiser` entry; needs the optional `onnxruntime-web` peer dependency). Singleton `.onnx` model loader/cache the ORT runtime uses internally. | member | type | meaning | |---|---|---| | `Models.getInstance()` | `static (): Models` | the singleton instance | | `path` | `string \| undefined` | subdirectory under the site root, used only when `url` is unset | | `url` | `string` | remote base URL for models (default: the jsDelivr `models-v2` URL — same default as `OrtRuntimeOptions.weightsUrl`) | | `precision` | `'fp32' \| 'fp16'` | default `'fp32'`; `OrtRuntime` sets it from the precision of each model it loads | | `get(name, overrideUrl?)` | `(string, string?) => Promise` | fetch (and cache) a model's raw bytes | | `has(name)` | `(string) => boolean` | whether a model is already cached for the current precision | ### `DenoiseEngine` From `denoiser/ort`. The WebGPU/ONNX inference core of the ORT runtime (session management, tiling/geometry planning, WGSL pre/post). `Denoiser` covers everything most integrations need — reach for `DenoiseEngine` directly only if you're bypassing the high-level model-selection logic. | member | type | meaning | |---|---|---| | `DenoiseEngine.create(modelBytes, options)` | `static (Uint8Array, EngineOptions) => Promise` | builds a session for one specific model file. `precision:'fp16'` without `shader-f16` throws `DenoiserUnsupportedError` here (no auto-fallback — that lives in `Denoiser`) | | `device` | `GPUDevice` | the device this engine (and thus `Denoiser`) runs on | | `channels` | `number` | `3 \| 6 \| 9`, must match the loaded model | | `precision` | `'fp32' \| 'fp16'` (readonly) | | | `maxRunPixels` | `number` | mutable after creation | | `batch` | `number` | max tiles per run in tiled mode | | `lastStats` | `DenoiseStats \| undefined` | same shape as `Denoiser.stats` | | `graphCaptured` | `boolean` | whether this engine's sessions run with graph capture | | `planFor(w, h)` | `(number, number) => Plan` | the whole-frame-vs-tiled geometry the engine would choose for a size | | `denoise(color, w, h, options?)` | `(Uint8ClampedArray, number, number, DenoiseOptions?) => Promise` | CPU-array path | | `denoiseTextures(inputs, options?)` | `(TextureInputs, TextureDenoiseOptions?) => Promise` | texture path | | `tileGrid(w, h)` | `(number, number) => { tilesX: number; tilesY: number }` | tile grid for a size at the current settings | | `trim()` | `() => void` | what `Denoiser.dispose()` calls | | `destroy()` | `() => void` | what `Denoiser.destroyDevice()` calls | `TextureInputs` (`{ color: GPUTexture; albedo?: GPUTexture; normal?: GPUTexture }`) and `TextureDenoiseOptions` (`DenoiseOptions` + `inputFlipY?` + `auxInputFlipY?` + `toTexture?` + `outputTexture?`) are the lower-level shapes `DenoiseTexturesOptions` maps onto. ### Network runtimes `Denoiser` and `TiledEngine` do all tiling, transfer functions and WGSL pre/post themselves; a **`NetworkRuntime`** only executes the U-Net. The default is `AutoRuntime` (WebNN fp16 for base/large models when available, hand-written WGSL otherwise; never ORT). Other runtimes (ORT, HF kernels, WebNN, WGSL) plug in through the same interface: ```ts import { Denoiser } from 'denoiser'; import { OrtRuntime } from 'denoiser/ort'; // needs `npm i onnxruntime-web` const denoiser = await Denoiser.create({ runtime: new OrtRuntime({ weightsUrl: '/models' }), // or any NetworkRuntime }); ``` | type | role | |---|---| | `NetworkRuntime` | `load(model: NetworkModel, hints?) => Promise` — fetches and opens a model by OIDN name (`{ name, channels, precision }`). The first load may create the `GPUDevice` | | `NetworkSession` | `device` (the engine runs its WGSL here too), optional `fixedGeometry`, `bind(geometry) => Promise`, `release()` | | `NetworkBinding` | `input` / `output` `GPUBuffer`s (NCHW `[B, C, H, W]` / `[B, 3, H, W]` in model precision) and `run()`: the engine writes `input`, awaits `run()`, reads `output` | | `TiledEngine.load(runtime, model, options?)` | the runtime-agnostic engine `Denoiser` uses (same members as `DenoiseEngine` below, minus the ORT-specific ones) | | `OrtRuntime` / `OrtSession` | the onnxruntime-web implementation, from `denoiser/ort`; `OrtRuntimeOptions` = `{ weightsUrl?, wasmPaths?, graphCapture?, splitAux? }` | Entry points (one package, shared chunks, a single copy of the core): | import | contents | |---|---| | `denoiser` | `Denoiser` defaulting to `AutoRuntime` (options `tzaUrl`, `device`), plus everything in `denoiser/core` and `AutoRuntime`. Never loads onnxruntime-web | | `denoiser/core` | runtime-agnostic `Denoiser` (requires `runtime`), `TiledEngine`, the runtime and temporal types, `parseTZA` / `halfToFloat` / `DEFAULT_TZA_URL` | | `denoiser/ort` | `OrtRuntime`, `OrtSession`, `Models`, `DenoiseEngine`. Needs the optional peer dependency `onnxruntime-web`. Wrong on the `*_alb`, `*_alb_nrm` and `*_large` models, see [Choosing a runtime](../guides/choosing-a-runtime) | | `denoiser/kernels` | **experimental** `KernelsRuntime` on `@huggingface/kernels` (optional peer dependency, install it to use this entry) | | `denoiser/wgsl` | `WgslRuntime`: the U-Net in hand-written WGSL, no dependencies, no flags, correct on every model (the `AutoRuntime` fallback). Tuned and validated on Apple GPUs | | `denoiser/auto` | `AutoRuntime` (also exported from `denoiser`): picks WebNN (fp16 base/large, when available) or WGSL per model, on one shared GPUDevice. See [Choosing a runtime](../guides/choosing-a-runtime) | | `denoiser/webnn` | **experimental** `WebnnRuntime`: the U-Net via `MLGraphBuilder` (WebNN). Needs Chrome with `--enable-features=WebMachineLearningNeuralNetwork` | | `denoiser/ffx` | `FfxShadowDenoiser` and `FfxReflectionDenoiser`, the real-time `TemporalDenoiser`s (WGSL port of AMD FidelityFX Denoiser v1.1.4). No dependencies | | `denoiser/three` | three.js (`WebGPURenderer`) helpers and TSL nodes: `getDevice`, `getGPUTexture`, `CameraHistory`, `createDenoiserForRenderer`, `DenoiseNode`, `TemporalDenoiseNode` and more. `three` >= r185 is an optional peer dependency. See [three.js & TSL](../guides/three-and-tsl) | The classes from `denoiser` and `denoiser/core` are the same. The interface is new in this release and may change, so pin your version if you implement it. See [Choosing a runtime](../guides/choosing-a-runtime) for measured speed and quality. ### Runtime options All runtimes except ORT read the upstream OIDN `.tza` weights. `AutoRuntime` (the default) delegates to `WebnnRuntime` and `WgslRuntime`. | runtime | options | |---|---| | `AutoRuntime` | `{ tzaUrl?, device?, webnn?: 'gpu' \| 'npu' \| false }`. `webnn` defaults to `'gpu'`; `false` disables WebNN. `lastChoice` reports the runtime that served the last load; `choose(model)` reports which one a model would use | | `OrtRuntime` | `{ weightsUrl?, wasmPaths?, graphCapture?, splitAux? }`. `weightsUrl` is the base URL of the `.onnx` models (default: jsDelivr `models-v2`); `wasmPaths` is where onnxruntime-web loads its wasm (default: jsDelivr, `onnxruntime-web@1.27.0`); `graphCapture` (default `false`) is opt-in ORT WebGPU graph capture, leave off since onnxruntime-web 1.27 crashes captured sessions after about 150-250 cumulative replays; `splitAux` (default `true`) works around an onnxruntime-web WebGPU bug on the 9-channel `cleanAux` models by running the first conv in WGSL, fetching `.tail.onnx` + `.enc0.bin` next to the model, and falls back to the plain model with a one-time console warning if those are not hosted | | `KernelsRuntime` | `{ tzaUrl?, device?, zeroCopy? }`. `zeroCopy` (default `true`) binds the engine straight to kernels-owned IO tensors | | `WgslRuntime` | `{ tzaUrl?, device?, profile?, tiling?, subgroupMatrix?, mmaTiling? }`. `subgroupMatrix` (`'auto'` by default) uses the 8x8 subgroup-matrix conv when the device has `chromium-experimental-subgroup-matrix`, for fp32 only; `profile` records per-layer GPU timings (diagnostics, needs `timestamp-query`); `tiling` and `mmaTiling` are tuning overrides | | `WebnnRuntime` | `{ tzaUrl?, deviceType?, device?, interop?, layout? }`. `deviceType` is `'gpu'` (default), `'npu'` or `'cpu'`; `interop` is `'auto'` by default (WebNN to WebGPU tensor export for fp16 only); `layout` is `'nchw'` (default) or `'nhwc'`. `WebnnRuntime.isAvailable()` reports whether `navigator.ml` and `MLGraphBuilder` exist. `lastBuildMs` holds the graph build time of the most recent bind | `tzaUrl` defaults to `DEFAULT_TZA_URL`. `device` makes the runtime adopt your `GPUDevice`; without it, the runtime creates one with the adapter's maximum limits and features. `OrtRuntime` cannot adopt a device: it creates its own. ### `parseTZA(buf)` / `DEFAULT_TZA_URL` ```ts import { parseTZA, DEFAULT_TZA_URL } from 'denoiser/core'; const buf = await (await fetch(`${DEFAULT_TZA_URL}/rt_hdr_small.tza`)).arrayBuffer(); const tensors = parseTZA(buf); // TensorMap = Map ``` | export | meaning | |---|---| | `DEFAULT_TZA_URL` | base URL of the upstream OIDN `.tza` weights (sha256-identical to RenderKit/oidn-weights), on jsDelivr: `https://cdn.jsdelivr.net/gh/pmndrs/denoiser-weights@models-v3/tzas` | | `parseTZA(buf: ArrayBuffer)` | parses an OIDN `.tza` blob into a `TensorMap`. fp16 tensors are widened to `Float32Array`; conv weights stay OIHW. Throws `Invalid or corrupted weights blob` on a bad magic number and on an unsupported major version | | `halfToFloat(h: number)` | IEEE half (as a 16-bit integer) to number | | `TensorMap`, `WeightTensor` | `Map`; `WeightTensor` = `{ data: Float32Array; shape: number[] }` | These exist for runtimes that read OIDN weights directly. You only need them if you write your own `NetworkRuntime`. ## Temporal (real-time) denoising Exported from `denoiser` and `denoiser/core`; the FidelityFX implementations come from `denoiser/ffx`. See [Real-time (temporal) denoising](../guides/realtime-temporal-denoising) for the frame contract, conventions and measured results. ### `TemporalDenoiser` ```ts interface TemporalDenoiser { readonly name: string; readonly device: GPUDevice; configure(config: TemporalConfig): void; dispatch(inputs: Inputs, guides: FrameGuides, camera: FrameCamera): GPUTexture; resetHistory(): void; readonly gpuTimings?: ReadonlyMap; dispose(): void; } ``` | member | meaning | |---|---| | `configure({ width, height })` | (re)allocate for a size; drops history. Call before the first `dispatch` | | `dispatch(inputs, guides, camera)` | denoise one frame. Synchronous: records and submits its passes on `device.queue`, no awaits, no readback. Call it after the frame's inputs are submitted. The returned texture is owned by the denoiser and is valid until the next `dispatch` / `configure` / `dispose` | | `resetHistory()` | drop all history on the next dispatch (camera cut, teleport, scene switch) | | `gpuTimings` | per-pass GPU ms when timestamp queries are available (`total` = sum of passes) | | `dispose()` | release GPU resources; never destroys the device | ### Frame types | type | shape | |---|---| | `TemporalConfig` | `{ width: number; height: number }` | | `Mat4` | `Float32Array \| ArrayLike`, column-major 4x4 (three.js `Matrix4.elements`) | | `FrameGuides` | `{ depth: GPUTexture; normal: GPUTexture; motion: GPUTexture; roughness?: GPUTexture; albedo?: GPUTexture }`. `depth` is hardware depth or `r32float`; `normal` is world-space in [-1, 1]; `motion` is the NDC delta current minus previous (`rg16float`); `roughness` is perceptual [0, 1] | | `FrameCamera` | `{ view: Mat4; projection: Mat4; prevView: Mat4; prevProjection: Mat4; jitter?: readonly [number, number]; near: number; far: number; position?: readonly [number, number, number]; reversedDepth?: boolean }`. `projection` is unjittered; `jitter` is in pixels (default `[0, 0]`); `reversedDepth` defaults to `false` | ### Signal inputs | type | shape | |---|---| | `ShadowInputs` | `{ visibility: GPUTexture; hitDistance?: GPUTexture }`. `visibility` in [0, 1], 1 = lit | | `SpecularInputs` | `{ radiance: GPUTexture; hitDistance: GPUTexture }`. Linear HDR radiance (`rgba16float`); hit distance `r16float`, 0 = miss | | `DiffuseInputs` | `{ radiance: GPUTexture; hitDistance?: GPUTexture }` | | `BeautyInputs` | `{ color: GPUTexture; albedo?: GPUTexture; normal?: GPUTexture; hdr?: boolean }` | `DiffuseInputs` and `BeautyInputs` are defined for future denoisers; nothing implements them yet. ### `FfxShadowDenoiser` / `FfxReflectionDenoiser` ```ts import { FfxShadowDenoiser, FfxReflectionDenoiser } from 'denoiser/ffx'; new FfxShadowDenoiser(device: GPUDevice, options?: FfxShadowDenoiserOptions); // TemporalDenoiser, name 'ffx-shadows' new FfxReflectionDenoiser(device: GPUDevice, options?: FfxReflectionDenoiserOptions); // TemporalDenoiser, name 'ffx-reflections' ``` | `FfxShadowDenoiserOptions` | default | meaning | |---|---|---| | `depthSimilaritySigma` | `1` | FFX edge-stopping on linear depth, in view units | | `visibilityThreshold` | `0.5` | visibility at or above this counts as lit (FFX takes a binary ray-hit mask) | | `timestamps` | `true` | per-pass GPU timestamps into `gpuTimings` when supported | | `FfxReflectionDenoiserOptions` | default | meaning | |---|---|---| | `temporalStabilityFactor` | `0.7` | history clip box scale; higher is more stable and more ghosting | | `roughnessThreshold` | `1` | pixels with linear roughness at or above this pass through untouched; 1 denoises all pixels | | `roughnessIsPerceptual` | `true` | `guides.roughness` is perceptual (squared before use); `false` for linear | | `missDistance` | `camera.far` | ray length used where `hitDistance <= 0` | | `currentFrameStatistics` | `true` | read this frame's 8x8 average radiance and sample count; `false` reproduces FFX SDK v1.1.4 exactly (dark first frame after a reset) | | `timestamps` | `true` | per-pass GPU timestamps into `gpuTimings` when supported | `FfxReflectionDenoiser` requires `guides.roughness`. The shadow `dispatch` returns an `rgba16float` texture whose `.r` is the denoised visibility. ### `denoiser/three` helpers | export | meaning | |---|---| | `getDevice(renderer)` | the `GPUDevice` of an initialized `WebGPURenderer` | | `getGPUTexture(renderer, texture)` | the raw `GPUTexture` behind a three texture (must already be allocated on the GPU) | | `CameraHistory` | `update(camera, { unjitteredProjection?, jitter? }) => FrameCamera` keeps the previous frame's matrices; `reset()` forgets them | ### `determineModel(selector)` ```ts function determineModel(selector: ModelSelector): { name: string; channels: number } ``` Resolves an OIDN model filename + channel count from a `{ filterType, quality, hdr, useColor, useAlbedo, useNormal, cleanAux, dirtyAux }` selector (`ModelSelector`) — e.g. `{ filterType: 'rt', quality: 'fast', hdr: true, useColor: true, useAlbedo: true, useNormal: true, cleanAux: true, dirtyAux: false }` → `{ name: 'rt_hdr_calb_cnrm_small', channels: 9 }`. `Denoiser` calls this internally on every call to pick which model to run; exported for anyone building their own model-selection logic on `DenoiseEngine` directly. ]]> [!NOTE] > The WebGPU path tracer is early-stage upstream work in `three-gpu-pathtracer` > (main, 0.0.27), pinned to a commit SHA for this demo — expect rough edges. ### [LDraw Eiffel Tower](https://pmndrs.github.io/denoiser/ldraw-eiffel/) A LEGO Eiffel Tower on a table: fast raster preview while you orbit, path-trace + denoise the moment the camera comes to rest. Same pinned-pathtracer caveat as above. ### [Benchmark](https://pmndrs.github.io/denoiser/bench/) The performance harness behind the numbers in [Performance](../concepts/performance.mdx): cold + warm timings, per-stage stats, and fp16-vs-fp32 PSNR across image sizes — reproducible in your own browser, on your own hardware. ## Real-time (temporal) denoising These use the FidelityFX denoisers (`denoiser/ffx`, `denoiser/three`), not OIDN. See [Which denoiser do I need?](../guides/realtime-temporal-denoising.mdx#which-denoiser-do-i-need) ### [Ray-traced shadows and reflections in a raster app](https://pmndrs.github.io/denoiser/three-temporal/shadows.html) The "when would I use this?" demo. A furnished room in a normal three.js raster pipeline, with two effects ray traced at 1 ray per pixel: area-light shadows, traced against the real scene meshes with three-mesh-bvh's WebGPU BVH, and glossy SSR. `ffxShadows()` and `ffxReflections()` denoise them every frame. Toggle each one against what three.js does without ray tracing (a VSM shadow map, env-map-only reflections) and against a converged reference. Use the light-size slider to see contact hardening. A live table shows each pass's GPU cost. There's an optional half-resolution + FSR3 path. ### [Reflections, explained: probe, SSR, ray tracing](https://pmndrs.github.io/denoiser/three-temporal/reflections-hybrid.html) An explainer for the three ways a real-time renderer finds reflections, and the hybrid that mixes them: a box-projected reflection probe (captured once, prefiltered per roughness), three's SSR, and rays traced against the scene with three-mesh-bvh. A guided tour sets up one situation per weakness (a mirror, things off screen, moving objects, contact, rough surfaces) and shows two methods split-screen; a "where reflections come from" view tints every pixel by what answered it. Every noisy mode takes one sample per pixel, denoised by `ffxReflections()`, with a cost table and a "which one should I use?" guide. ### [Reflections in two rooms: probes, SSR and rays](https://pmndrs.github.io/denoiser/three-temporal/reflections-rooms.html) The two rooms from Ben Houston's reflection probes example, with a polished floor, lights that change colour and a mirror ball rolling through the doorway. One probe per room plus a doorway probe give every surface its own room's reflection for almost nothing per frame. SSR and one traced ray per pixel (three-mesh-bvh), denoised by `ffxReflections()`, add what probes can't have: the moving ball (kept out of the captures), contact, and what the probe never saw. A guided tour compares the modes split-screen; the probes re-bake when the lights change (about 2 ms for all three) or can be frozen to show them going stale. A cost table times each pass. ### [Reflection probes on r186 (preview of three.js #35046)](https://pmndrs.github.io/denoiser/three-temporal/reflection-probes.html) Ben Houston's reflection probes ([three.js #35046](https://github.com/mrdoob/three.js/pull/35046)) running on three r186 until that PR lands: probes you place per room, box-projected onto the walls, blended at their edges, with smaller probes (a doorway) taking priority and `scene.environment` filling the rest. Same API and scene as the PR's example. r186 can't bind cube arrays, so the cubes live in a 2D array texture and the lookup picks the face itself, with a seam fix so rough surfaces don't show face edges. Matches the PR's output to within 8-bit noise; three probes bake in 3–5 ms. This is example code, not part of the `denoiser` package: when #35046 ships, swap the imports for three's and re-bake. ### [Baked + dynamic shadows: when to split](https://pmndrs.github.io/denoiser/three-temporal/shadows-split.html) The static / dynamic split engines use for lightmaps, in the browser: the static half of the shadow baked once into a lightmap (16 rays per texel, cleaned by OIDN) or gathered in a world-space cache, and 1 ray per pixel against only the moving objects, denoised every frame by `ffxShadows()`. Explains what the split buys (converged, steady static shadows; rays that only test moving objects) and costs (bake time, UVs, memory, a light that can't move), with measured numbers and when to use it. Both denoisers on one page: OIDN at bake time, FFX live. ### [three.js SSR: FidelityFX vs recurrentDenoise](https://pmndrs.github.io/denoiser/three-temporal/) three's `webgpu_postprocessing_ssr_denoise` example with `ffxReflections()` swapped in, split-screen against three's own temporal denoiser. ### [FidelityFX denoisers, raw WGSL](https://pmndrs.github.io/denoiser/ffx-denoiser/) The test bed behind the numbers in [Real-time (temporal) denoising](../guides/realtime-temporal-denoising.mdx#measured-results): 1-sample shadows and reflections, noisy / denoised / reference panels, no three.js. ]]>