rayzee 8.0.0 → 8.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,12 @@
1
- import { EventDispatcher, ACESFilmicToneMapping } from 'three';
1
+ import { EventDispatcher, HalfFloatType, RGBAFormat, NearestFilter, NoColorSpace, ClampToEdgeWrapping } from 'three';
2
+ import { ExternalTexture } from 'three/webgpu';
2
3
  import { createLogger, fmt } from '../utils/Logger.js';
3
4
 
4
5
  const log = createLogger( 'oidn' );
5
6
 
7
+ // The tile alignment oidn-web's own fitTileDimension uses (its tileScheduler.js).
8
+ const OIDN_TILE_ALIGNMENT = 16;
9
+
6
10
  let _initUNetFromURL = null;
7
11
  async function getInitUNetFromURL() {
8
12
 
@@ -16,8 +20,6 @@ async function getInitUNetFromURL() {
16
20
 
17
21
  }
18
22
 
19
- import { effectiveExposure } from '../Processor/ToneMapCPU.js';
20
- import { TONE_MAP_WGSL, toneMapMode } from '../Processor/ToneMapWGSL.js';
21
23
  import { getAssetConfig } from '../AssetConfig.js';
22
24
 
23
25
  const MODEL_CONFIG = {
@@ -137,85 +139,69 @@ const SCALE_WG_SIZE = 64;
137
139
  // 1D would exceed maxComputeWorkgroupsPerDimension (65535) past ~2048², so the dispatch is 2D.
138
140
  const SCALE_MAX_WG_X = 32768;
139
141
 
140
- // Denoised linear float -> sRGB bytes, on the GPU. The JS equivalent was a per-pixel loop with
141
- // three pow() calls, and it froze the main thread for 42-51 ms per denoise at 1024².
142
- const PACK_WG_SIZE = 16;
143
- const PACK_PARAMS_BYTES = 48;
144
-
145
- const PACK_WGSL = /* wgsl */`
146
- ${TONE_MAP_WGSL}
142
+ // Denoised linear float -> a picture the pipeline can sample, on the card. The result keeps the
143
+ // renderer's own units: no exposure, no grade, no tone curve. Compositor and the renderer's output
144
+ // pass apply those to this picture exactly as they do to the raw one.
145
+ const UNPACK_WG_SIZE = 16;
146
+ const UNPACK_PARAMS_BYTES = 32;
147
147
 
148
- struct PackParams {
148
+ const UNPACK_WGSL = /* wgsl */`
149
+ struct UnpackParams {
149
150
  srcWidth: u32,
150
151
  tileX: u32,
151
152
  tileY: u32,
152
153
  tileW: u32,
153
154
  tileH: u32,
154
- mode: u32,
155
- useAlpha: u32,
156
155
  pad0: u32,
157
- exposure: f32,
158
- saturation: f32,
159
- pad1: vec2<f32>,
156
+ pad1: u32,
157
+ pad2: u32,
160
158
  };
161
159
 
162
160
  @group(0) @binding(0) var<storage, read> src: array<vec4<f32>>;
163
- @group(0) @binding(1) var<storage, read_write> dst: array<u32>;
164
- @group(0) @binding(2) var<uniform> P: PackParams;
165
- @group(0) @binding(3) var<storage, read> scaleBuf: array<f32>;
166
- @group(0) @binding(4) var<storage, read> inColor: array<vec4<f32>>;
161
+ @group(0) @binding(1) var<uniform> P: UnpackParams;
162
+ @group(0) @binding(2) var<storage, read> scaleBuf: array<f32>;
163
+ @group(0) @binding(3) var<storage, read> inColor: array<vec4<f32>>;
164
+ @group(0) @binding(4) var dst: texture_storage_2d<rgba16float, write>;
167
165
 
168
- @compute @workgroup_size(${PACK_WG_SIZE}, ${PACK_WG_SIZE})
166
+ @compute @workgroup_size(${UNPACK_WG_SIZE}, ${UNPACK_WG_SIZE})
169
167
  fn main( @builtin(global_invocation_id) gid: vec3<u32> ) {
170
168
 
171
169
  if ( gid.x >= P.tileW || gid.y >= P.tileH ) { return; }
172
170
 
173
- let si = ( P.tileY + gid.y ) * P.srcWidth + ( P.tileX + gid.x );
174
- let di = gid.y * P.tileW + gid.x;
175
-
176
- let rgb = toneMapPixel( src[ si ].xyz, P.exposure / scaleBuf[ 0 ], P.saturation, P.mode );
171
+ let px = P.tileX + gid.x;
172
+ let py = P.tileY + gid.y;
173
+ let si = py * P.srcWidth + px;
177
174
 
178
- var a: u32 = 255u;
179
- if ( P.useAlpha == 1u ) { a = u32( clamp( inColor[ si ].w, 0.0, 1.0 ) * 255.0 + 0.5 ); }
180
-
181
- let q = vec3<u32>( clamp( rgb, vec3f( 0.0 ), vec3f( 1.0 ) ) * 255.0 + vec3f( 0.5 ) );
182
- dst[ di ] = q.x | ( q.y << 8u ) | ( q.z << 16u ) | ( a << 24u );
175
+ // scaleBuf is the autoexposure factor the input was multiplied by; dividing it out returns
176
+ // the renderer's units. Alpha rides through from the path tracer so a transparent background
177
+ // survives the denoise.
178
+ textureStore( dst, vec2<u32>( px, py ), vec4<f32>( src[ si ].xyz / scaleBuf[ 0 ], inColor[ si ].w ) );
183
179
  }
184
180
  `;
185
181
 
186
182
  export class OIDNDenoiser extends EventDispatcher {
187
183
 
188
- constructor( output, renderer, scene, camera, options = {} ) {
184
+ /**
185
+ * @param {import('three/webgpu').WebGPURenderer} renderer
186
+ * @param {Object} [options]
187
+ */
188
+ constructor( renderer, options = {} ) {
189
189
 
190
190
  super();
191
191
 
192
- // Validate required parameters
193
- if ( ! output || ! renderer || ! scene || ! camera ) {
194
-
195
- throw new Error( 'OIDNDenoiser requires output canvas, renderer, scene, and camera' );
196
-
197
- }
192
+ if ( ! renderer ) throw new Error( 'OIDNDenoiser requires a renderer' );
198
193
 
199
194
  this.renderer = renderer;
200
- this.scene = scene;
201
- this.camera = camera;
202
- this.input = renderer.domElement;
203
- this.output = output;
195
+ // The size everything is measured in. The denoiser paints no canvas: its result is a
196
+ // picture handed to the pipeline, so this is the only size it knows.
197
+ this._renderWidth = renderer.domElement.width;
198
+ this._renderHeight = renderer.domElement.height;
204
199
 
205
200
  // WebGPU GPU-native path (no CPU readback for inputs)
206
201
  // backendParams: () => { device: GPUDevice, adapterInfo: GPUAdapterInfo|null }
207
202
  // getGPUTextures: () => { color: GPUTexture, albedo: GPUTexture, normal: GPUTexture }
208
- // getExposure: () => number (effective exposure multiplier, pre-computed)
209
- // getToneMapping: () => number (Three.js ToneMapping constant)
210
203
  this.backendParamsGetter = options.backendParams || null;
211
204
  this.getGPUTextures = options.getGPUTextures || null;
212
- // refreshInput: () => void — re-renders the compositor so the WebGPU canvas is readable
213
- this.refreshInput = options.refreshInput || null;
214
- this.getExposure = options.getExposure || ( () => 1.0 );
215
- this.getToneMapping = options.getToneMapping || ( () => ACESFilmicToneMapping );
216
- this.getSaturation = options.getSaturation || ( () => 1.0 );
217
- this.getTransparentBackground = options.getTransparentBackground || ( () => false );
218
- this.isGPUMode = !! this.backendParamsGetter;
219
205
  this.gpuDevice = null;
220
206
 
221
207
  // Cached GPU storage buffers for texture→buffer copies (reused across denoise calls)
@@ -238,7 +224,13 @@ export class OIDNDenoiser extends EventDispatcher {
238
224
 
239
225
  this._colorScalePipeline = null;
240
226
  this._colorScaleBindGroup = null;
241
- this._packPipeline = null;
227
+
228
+ // The denoised picture, written on the card and handed to the pipeline like any other
229
+ // stage's output. `_outTexture` wraps `_outGPUTexture` so three.js can sample it.
230
+ this._unpackPipeline = null;
231
+ this._outGPUTexture = null;
232
+ this._outTexture = null;
233
+ this._outTexSize = { width: 0, height: 0 };
242
234
 
243
235
  // Merge options with defaults
244
236
  this.config = { ...MODEL_CONFIG.DEFAULT_OPTIONS, ...options };
@@ -257,17 +249,15 @@ export class OIDNDenoiser extends EventDispatcher {
257
249
  abortController: null
258
250
  };
259
251
 
260
- // Track in-flight per-tile GPU buffers so they can be destroyed on abort
261
- this._pendingStagingBuffers = new Set();
262
- // Per-run tile-blit promises; done() awaits these so capture waits for every tile to paint.
263
- this._pendingTileBlits = [];
252
+ // Tiles written into the output picture by the run in flight; zero means the library gave
253
+ // no per-tile progress and the whole image has to be written at the end.
254
+ this._tilesWritten = 0;
264
255
 
265
256
  // Identifies the live run. A run superseded by abort() must not clear the state its
266
257
  // successor already owns.
267
258
  this._runId = 0;
268
- // The output canvas holds a valid denoised frame, so the next run must not repaint the
269
- // noisy base over it.
270
- this._hasLatchedFrame = false;
259
+ // The output picture holds a denoised frame the pipeline can show.
260
+ this._hasOutput = false;
271
261
  // Wall time of the last completed denoise, for cadence policy. 0 until one finishes.
272
262
  this.lastDenoiseMs = 0;
273
263
 
@@ -294,7 +284,6 @@ export class OIDNDenoiser extends EventDispatcher {
294
284
 
295
285
  try {
296
286
 
297
- this._setupCanvas();
298
287
  await this._setupUNetDenoiser();
299
288
 
300
289
  } catch ( error ) {
@@ -305,37 +294,6 @@ export class OIDNDenoiser extends EventDispatcher {
305
294
 
306
295
  }
307
296
 
308
- _setupCanvas() {
309
-
310
- if ( ! this.output.getContext ) {
311
-
312
- throw new Error( 'Output must be a valid Canvas element' );
313
-
314
- }
315
-
316
- // Configure canvas for optimal performance
317
- this.output.willReadFrequently = true;
318
- this.output.width = this.input.width;
319
- this.output.height = this.input.height;
320
-
321
- // Apply styling efficiently
322
- Object.assign( this.output.style, {
323
- position: 'absolute',
324
- top: '0',
325
- left: '0',
326
- width: '100%',
327
- height: '100%',
328
- borderRadius: '5px',
329
- background: "repeating-conic-gradient(#808080 0% 25%, transparent 0% 50%) 50% / 20px 20px"
330
- } );
331
-
332
- this.ctx = this.output.getContext( '2d', {
333
- willReadFrequently: true,
334
- alpha: true
335
- } );
336
-
337
- }
338
-
339
297
  /**
340
298
  * Whether the tier's model expects a clean (accumulated) auxiliary guide rather than a
341
299
  * point-sampled one. The aux MRT has to be fed to match, or the guide fights the weights.
@@ -387,9 +345,20 @@ export class OIDNDenoiser extends EventDispatcher {
387
345
  */
388
346
  _resolveTileSize() {
389
347
 
390
- const longest = Math.max( this.output?.width || 0, this.output?.height || 0 );
348
+ const longest = Math.max( this._renderWidth, this._renderHeight );
391
349
  if ( ! longest ) return this.maxTileSize;
392
- return Math.min( longest, this.maxTileSize );
350
+
351
+ // Rounded up to the model's 16-pixel alignment, not clamped to the exact frame. The
352
+ // library's own fitTileDimension aligns the size up and then clamps it back to whatever
353
+ // cap it is handed, so handing it the raw frame size hands it an unaligned tile — and an
354
+ // unaligned tile measured 4x the cost of the whole denoise: 336 ms at 900x900 against
355
+ // 86 ms once aligned, with 896x896 unaffected either way.
356
+ const aligned = Math.ceil( longest / OIDN_TILE_ALIGNMENT ) * OIDN_TILE_ALIGNMENT;
357
+ if ( aligned <= this.maxTileSize ) return aligned;
358
+
359
+ // The cap has to be aligned too, or a cap somebody picked by hand lands on the same slow
360
+ // path. Rounded down, so it never rises above what the caller asked for.
361
+ return Math.max( OIDN_TILE_ALIGNMENT, Math.floor( this.maxTileSize / OIDN_TILE_ALIGNMENT ) * OIDN_TILE_ALIGNMENT );
393
362
 
394
363
  }
395
364
 
@@ -462,7 +431,7 @@ export class OIDNDenoiser extends EventDispatcher {
462
431
  // GPU-native path: share the existing GPUDevice so oidn-web uses the
463
432
  // same device as the renderer — no second device, no CPU roundtrip for inputs.
464
433
  let backendParams;
465
- if ( this.isGPUMode && this.backendParamsGetter ) {
434
+ if ( this.backendParamsGetter ) {
466
435
 
467
436
  const params = this.backendParamsGetter();
468
437
  this.gpuDevice = params?.device ?? null;
@@ -569,9 +538,7 @@ export class OIDNDenoiser extends EventDispatcher {
569
538
 
570
539
  if ( success ) {
571
540
 
572
- this._hasLatchedFrame = true;
573
541
  this.renderer?.resetState?.();
574
- this.input.style.opacity = '0';
575
542
 
576
543
  this.lastDenoiseMs = performance.now() - startTime;
577
544
  log.debug( `denoise complete in ${fmt.ms( this.lastDenoiseMs )} · quality ${this.quality}` );
@@ -582,15 +549,6 @@ export class OIDNDenoiser extends EventDispatcher {
582
549
 
583
550
  }
584
551
 
585
- // Revealing before the first paint shows the output's transparent bitmap — and its
586
- // checker CSS background — over the now-hidden render canvas.
587
- _revealOutput() {
588
-
589
- this.output.style.display = 'block';
590
- this.input.style.opacity = '0';
591
-
592
- }
593
-
594
552
  async execute( continuous = false ) {
595
553
 
596
554
  if ( ! this.enabled || ! this.unet ) return false;
@@ -602,8 +560,9 @@ export class OIDNDenoiser extends EventDispatcher {
602
560
 
603
561
  try {
604
562
 
605
- await this._executeUNet( continuous );
606
- return true;
563
+ // The GPU path bails out early — no textures yet, no device — by returning false, and
564
+ // a caller that read that as success published a picture nothing had written to.
565
+ return await this._executeUNetGPU( continuous ) !== false;
607
566
 
608
567
  } catch ( error ) {
609
568
 
@@ -617,8 +576,6 @@ export class OIDNDenoiser extends EventDispatcher {
617
576
 
618
577
  }
619
578
 
620
- // Restore original rendering on error, unless a previous denoise is still on screen.
621
- if ( ! this._hasLatchedFrame ) this.input.style.opacity = '1';
622
579
  return false;
623
580
 
624
581
  } finally {
@@ -638,12 +595,6 @@ export class OIDNDenoiser extends EventDispatcher {
638
595
 
639
596
  }
640
597
 
641
- async _executeUNet( continuous = false ) {
642
-
643
- return this._executeUNetGPU( continuous );
644
-
645
- }
646
-
647
598
  /**
648
599
  * GPU-native execution path. Copies render target textures into GPU storage buffers
649
600
  * via copyTextureToBuffer (GPU-only, no CPU roundtrip), then passes those buffers to
@@ -653,7 +604,8 @@ export class OIDNDenoiser extends EventDispatcher {
653
604
  */
654
605
  async _executeUNetGPU( continuous = false ) {
655
606
 
656
- const { width, height } = this.output;
607
+ const width = this._renderWidth;
608
+ const height = this._renderHeight;
657
609
 
658
610
  if ( ! this.getGPUTextures ) {
659
611
 
@@ -678,19 +630,6 @@ export class OIDNDenoiser extends EventDispatcher {
678
630
 
679
631
  }
680
632
 
681
- // Capture the base now: the readback below awaits, and a presented WebGPU canvas only
682
- // reads back non-empty straight after a compositor pass — hence the refresh. Skipped
683
- // once a denoised frame is latched: repainting it on every cadence tick would flash
684
- // the noisy image back over an already-clean one.
685
- if ( ! this._hasLatchedFrame ) {
686
-
687
- this.refreshInput?.();
688
- this.ctx.drawImage( this.input, 0, 0, width, height );
689
-
690
- }
691
-
692
- this._revealOutput();
693
-
694
633
  // Ensure storage buffers are sized correctly (recreate on resolution change)
695
634
  this._ensureGPUInputBuffers( width, height );
696
635
 
@@ -874,13 +813,13 @@ export class OIDNDenoiser extends EventDispatcher {
874
813
 
875
814
  }
876
815
 
877
- if ( ! this._packPipeline ) {
816
+ if ( ! this._unpackPipeline ) {
878
817
 
879
- this._packPipeline = device.createComputePipeline( {
880
- label: 'oidn-output-pack',
818
+ this._unpackPipeline = device.createComputePipeline( {
819
+ label: 'oidn-output-unpack',
881
820
  layout: 'auto',
882
821
  compute: {
883
- module: device.createShaderModule( { label: 'oidn-output-pack', code: PACK_WGSL } ),
822
+ module: device.createShaderModule( { label: 'oidn-output-unpack', code: UNPACK_WGSL } ),
884
823
  entryPoint: 'main'
885
824
  }
886
825
  } );
@@ -994,115 +933,135 @@ export class OIDNDenoiser extends EventDispatcher {
994
933
  }
995
934
 
996
935
  /**
997
- * Tone-maps a rectangle of a denoised linear buffer to sRGB bytes on the GPU and paints it
998
- * onto the 2D output canvas.
999
- *
1000
- * The canvas stays 2D on purpose — `getCanvas()` hands it to screenshot and video capture,
1001
- * and AIUpscaler draws into the same element. What moved to the GPU is the conversion, so
1002
- * the readback carries 4 bytes per pixel instead of 16 and the main thread only memcpys.
936
+ * The picture the pipeline samples, or null until a denoise has produced one. Owned here and
937
+ * replaced on a resize, so callers re-read it rather than holding on to it.
938
+ * @returns {?import('three').Texture}
939
+ */
940
+ get outputTexture() {
941
+
942
+ return this._outTexture;
943
+
944
+ }
945
+
946
+ /**
947
+ * Makes sure the output picture exists at the render size. A resize makes a new one rather than
948
+ * resizing in place: three.js caches the card-side handle against the wrapper, so the wrapper
949
+ * has to be new too.
950
+ */
951
+ _ensureOutputTexture( device, width, height ) {
952
+
953
+ if ( this._outGPUTexture && this._outTexSize.width === width && this._outTexSize.height === height ) return;
954
+
955
+ this._releaseOutputTexture();
956
+
957
+ // Half float, like NRD's output: this picture is read once, by the display. Full float
958
+ // would double both the memory it holds and the bandwidth of writing and sampling it, to
959
+ // carry precision the tone curve and an 8-bit canvas immediately throw away. Measured
960
+ // identical to within 0.02/255 of the mean.
961
+ this._outGPUTexture = device.createTexture( {
962
+ label: 'oidn-output',
963
+ size: { width, height, depthOrArrayLayers: 1 },
964
+ format: 'rgba16float',
965
+ usage: GPUTextureUsage.STORAGE_BINDING | GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_SRC
966
+ } );
967
+
968
+ const tex = new ExternalTexture( this._outGPUTexture );
969
+ tex.name = 'oidn:output';
970
+ tex.image = { width, height, depth: 1 };
971
+ tex.type = HalfFloatType;
972
+ tex.format = RGBAFormat;
973
+ tex.colorSpace = NoColorSpace;
974
+ tex.minFilter = NearestFilter;
975
+ tex.magFilter = NearestFilter;
976
+ tex.wrapS = ClampToEdgeWrapping;
977
+ tex.wrapT = ClampToEdgeWrapping;
978
+ tex.generateMipmaps = false;
979
+ tex.flipY = false;
980
+
981
+ this._outTexture = tex;
982
+ this._outTexSize = { width, height };
983
+
984
+ }
985
+
986
+ _releaseOutputTexture() {
987
+
988
+ this._outTexture?.dispose();
989
+ this._outGPUTexture?.destroy();
990
+ this._outTexture = null;
991
+ this._outGPUTexture = null;
992
+ this._outTexSize = { width: 0, height: 0 };
993
+
994
+ }
995
+
996
+ /**
997
+ * Writes a rectangle of the denoised buffer into the output picture, on the card.
1003
998
  *
1004
999
  * @param {GPUBuffer} src - denoised rgba32float, full image
1005
1000
  * @param {number} srcWidth
1006
1001
  * @param {{x: number, y: number, width: number, height: number}} rect
1007
- * @returns {Promise<void>}
1008
1002
  */
1009
- async _packAndBlit( src, srcWidth, rect ) {
1003
+ _unpackToTexture( src, srcWidth, rect ) {
1010
1004
 
1011
1005
  const device = this.gpuDevice;
1012
1006
  if ( ! device || ! this._ensureScalePipelines( device ) ) return;
1013
1007
 
1014
1008
  const { x, y, width, height } = rect;
1015
- const byteSize = width * height * 4;
1016
1009
 
1017
- const dst = device.createBuffer( {
1018
- label: 'oidn-pack-out',
1019
- size: byteSize,
1020
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC
1021
- } );
1022
- const staging = device.createBuffer( {
1023
- label: 'oidn-pack-staging',
1024
- size: byteSize,
1025
- usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST
1026
- } );
1010
+ this._ensureOutputTexture( device, this._renderWidth, this._renderHeight );
1011
+ if ( ! this._outGPUTexture ) return;
1012
+
1027
1013
  const params = device.createBuffer( {
1028
- label: 'oidn-pack-params',
1029
- size: PACK_PARAMS_BYTES,
1014
+ label: 'oidn-unpack-params',
1015
+ size: UNPACK_PARAMS_BYTES,
1030
1016
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
1031
1017
  } );
1032
1018
 
1033
- this._pendingStagingBuffers.add( dst );
1034
- this._pendingStagingBuffers.add( staging );
1035
- this._pendingStagingBuffers.add( params );
1036
-
1037
- const release = () => {
1038
-
1039
- for ( const b of [ dst, staging, params ] ) {
1040
-
1041
- b.destroy();
1042
- this._pendingStagingBuffers.delete( b );
1043
-
1044
- }
1045
-
1046
- };
1047
-
1048
1019
  try {
1049
1020
 
1050
- const words = new ArrayBuffer( PACK_PARAMS_BYTES );
1051
- new Uint32Array( words, 0, 8 ).set( [
1052
- srcWidth, x, y, width, height,
1053
- toneMapMode( this.getToneMapping() ),
1054
- this.getTransparentBackground() ? 1 : 0,
1055
- 0
1056
- ] );
1057
- new Float32Array( words, 32, 2 ).set( [
1058
- effectiveExposure( this.getExposure(), this.getToneMapping() ),
1059
- this.getSaturation()
1060
- ] );
1061
- device.queue.writeBuffer( params, 0, words );
1021
+ device.queue.writeBuffer( params, 0, new Uint32Array( [ srcWidth, x, y, width, height, 0, 0, 0 ] ) );
1062
1022
 
1063
1023
  const bindGroup = device.createBindGroup( {
1064
- label: 'oidn-output-pack',
1065
- layout: this._packPipeline.getBindGroupLayout( 0 ),
1024
+ label: 'oidn-output-unpack',
1025
+ layout: this._unpackPipeline.getBindGroupLayout( 0 ),
1066
1026
  entries: [
1067
1027
  { binding: 0, resource: { buffer: src } },
1068
- { binding: 1, resource: { buffer: dst } },
1069
- { binding: 2, resource: { buffer: params } },
1070
- { binding: 3, resource: { buffer: this._inputScaleBuffer } },
1071
- { binding: 4, resource: { buffer: this._gpuInputBuffers.color } }
1028
+ { binding: 1, resource: { buffer: params } },
1029
+ { binding: 2, resource: { buffer: this._inputScaleBuffer } },
1030
+ { binding: 3, resource: { buffer: this._gpuInputBuffers.color } },
1031
+ { binding: 4, resource: this._outGPUTexture.createView() }
1072
1032
  ]
1073
1033
  } );
1074
1034
 
1075
- const encoder = device.createCommandEncoder( { label: 'oidn-output-pack' } );
1076
- const pass = encoder.beginComputePass( { label: 'oidn-output-pack' } );
1077
- pass.setPipeline( this._packPipeline );
1035
+ const encoder = device.createCommandEncoder( { label: 'oidn-output-unpack' } );
1036
+ const pass = encoder.beginComputePass( { label: 'oidn-output-unpack' } );
1037
+ pass.setPipeline( this._unpackPipeline );
1078
1038
  pass.setBindGroup( 0, bindGroup );
1079
1039
  pass.dispatchWorkgroups(
1080
- Math.ceil( width / PACK_WG_SIZE ),
1081
- Math.ceil( height / PACK_WG_SIZE ),
1040
+ Math.ceil( width / UNPACK_WG_SIZE ),
1041
+ Math.ceil( height / UNPACK_WG_SIZE ),
1082
1042
  1
1083
1043
  );
1084
1044
  pass.end();
1085
- encoder.copyBufferToBuffer( dst, 0, staging, 0, byteSize );
1086
1045
  device.queue.submit( [ encoder.finish() ] );
1087
1046
 
1088
- await staging.mapAsync( GPUMapMode.READ );
1089
- // Copied, not viewed: unmap() detaches the mapped range and ImageData outlives it.
1090
- const bytes = new Uint8ClampedArray( staging.getMappedRange().slice( 0 ) );
1091
- staging.unmap();
1092
-
1093
- this.ctx.putImageData( new ImageData( bytes, width, height ), x, y );
1047
+ // Set here rather than when the run resolves: 'end' is dispatched from execute()'s
1048
+ // finally, which runs first, so a listener that waits for the run would see the very
1049
+ // first denoise as having produced nothing.
1050
+ this._hasOutput = true;
1094
1051
 
1095
1052
  } finally {
1096
1053
 
1097
- release();
1054
+ params.destroy();
1098
1055
 
1099
1056
  }
1100
1057
 
1101
1058
  }
1102
1059
 
1060
+
1103
1061
  /**
1104
- * Promise wrapper around tileExecute for the GPU path.
1105
- * Outputs a GPUBuffer — copied to a staging buffer then converted to ImageData for the 2D canvas.
1062
+ * Promise wrapper around tileExecute for the GPU path. Each tile is written straight into the
1063
+ * output picture on the card, so there is nothing to await between a tile finishing and it
1064
+ * being visible.
1106
1065
  */
1107
1066
  _executeWithAbortGPU( config, continuous = false ) {
1108
1067
 
@@ -1118,7 +1077,7 @@ export class OIDNDenoiser extends EventDispatcher {
1118
1077
  let abortDenoise = null;
1119
1078
 
1120
1079
  // Fresh per-run list of tile-blit promises (the progress callback appends to it).
1121
- this._pendingTileBlits = [];
1080
+ this._tilesWritten = 0;
1122
1081
 
1123
1082
  const abortHandler = () => {
1124
1083
 
@@ -1144,23 +1103,12 @@ export class OIDNDenoiser extends EventDispatcher {
1144
1103
 
1145
1104
  try {
1146
1105
 
1147
- if ( this._pendingTileBlits.length > 0 ) {
1148
-
1149
- // Normal path: the progress callback already painted every tile progressively, with
1150
- // the same exposure/saturation/tonemap/sRGB math the full-frame readback uses (verified:
1151
- // tiles tile the image exactly, no overlap). Just wait for those blits — no redundant
1152
- // full-frame re-read + re-tonemap.
1153
- await Promise.allSettled( this._pendingTileBlits );
1154
-
1155
- } else {
1106
+ // Nothing to wait for on the normal path: the progress callback wrote every
1107
+ // tile straight into the output picture, and tiles cover the image exactly.
1108
+ if ( this._tilesWritten === 0 ) this._displayGPUOutput( output );
1156
1109
 
1157
- // Degenerate fallback (no per-tile progress was emitted): one authoritative full paint.
1158
- await this._displayGPUOutput( output );
1159
-
1160
- }
1161
-
1162
- // DENOISING_END (which gates screenshot/video capture) fires only after this resolves,
1163
- // so the captured canvas is always complete.
1110
+ // DENOISING_END (which gates screenshot/video capture) fires only after this
1111
+ // resolves, so what a capture reads is always a complete picture.
1164
1112
  resolve();
1165
1113
 
1166
1114
  } catch ( err ) {
@@ -1187,20 +1135,16 @@ export class OIDNDenoiser extends EventDispatcher {
1187
1135
 
1188
1136
  const rect = { x: tile.x, y: tile.y, width, height };
1189
1137
 
1190
- // Track the whole chain so done() can await every tile paint before resolving.
1191
- const tileBlit = this._packAndBlit( outputData.data, fullWidth, rect ).then( () => {
1192
-
1193
- this.dispatchEvent( {
1194
- type: 'tileProgress',
1195
- tile: rect,
1196
- imageWidth: fullWidth,
1197
- imageHeight: fullHeight,
1198
- continuous
1199
- } );
1138
+ this._unpackToTexture( outputData.data, fullWidth, rect );
1139
+ this._tilesWritten ++;
1200
1140
 
1201
- } ).catch( () => { /* aborted or GPU lost — _packAndBlit already released */ } );
1202
-
1203
- this._pendingTileBlits.push( tileBlit );
1141
+ this.dispatchEvent( {
1142
+ type: 'tileProgress',
1143
+ tile: rect,
1144
+ imageWidth: fullWidth,
1145
+ imageHeight: fullHeight,
1146
+ continuous
1147
+ } );
1204
1148
 
1205
1149
  }
1206
1150
  } );
@@ -1213,16 +1157,16 @@ export class OIDNDenoiser extends EventDispatcher {
1213
1157
  * Degenerate fallback when no per-tile progress was emitted: one authoritative full paint.
1214
1158
  * @param {{ data: GPUBuffer, width: number, height: number }} output
1215
1159
  */
1216
- async _displayGPUOutput( { data: gpuBuffer, width, height } ) {
1160
+ _displayGPUOutput( { data: gpuBuffer, width, height } ) {
1217
1161
 
1218
1162
  if ( ! this.gpuDevice ) {
1219
1163
 
1220
- log.error( 'gpuDevice not available for output readback' );
1164
+ log.error( 'gpuDevice not available for the output picture' );
1221
1165
  return;
1222
1166
 
1223
1167
  }
1224
1168
 
1225
- await this._packAndBlit( gpuBuffer, width, { x: 0, y: 0, width, height } );
1169
+ this._unpackToTexture( gpuBuffer, width, { x: 0, y: 0, width, height } );
1226
1170
 
1227
1171
  }
1228
1172
 
@@ -1237,12 +1181,6 @@ export class OIDNDenoiser extends EventDispatcher {
1237
1181
  // Signal abort to current operation
1238
1182
  this.state.abortController?.abort();
1239
1183
 
1240
- // Destroy any in-flight tile staging buffers that mapAsync won't resolve
1241
- this._destroyPendingStagingBuffers();
1242
-
1243
- // Restore input visibility
1244
- this.input.style.opacity = '1';
1245
-
1246
1184
  // No 'end' here: the aborted execute()'s finally always runs and dispatches one, tagged
1247
1185
  // with that run's `continuous`. Dispatching here too emitted a second, untagged end —
1248
1186
  // a cancelled cadence run announcing itself as the denoise that finishes a render.
@@ -1252,10 +1190,18 @@ export class OIDNDenoiser extends EventDispatcher {
1252
1190
 
1253
1191
  }
1254
1192
 
1255
- // Call whenever the output canvas stops holding a valid frame — hidden on reset, or resized.
1256
- invalidateLatch() {
1193
+ // Whether the output picture holds a denoised frame the pipeline can show.
1194
+ get hasOutput() {
1257
1195
 
1258
- this._hasLatchedFrame = false;
1196
+ return this._hasOutput;
1197
+
1198
+ }
1199
+
1200
+ // Call when the picture stops describing anything the viewer should see — a scene change, or a
1201
+ // resize. The picture itself is kept and overwritten by the next denoise.
1202
+ invalidateOutput() {
1203
+
1204
+ this._hasOutput = false;
1259
1205
 
1260
1206
  }
1261
1207
 
@@ -1267,9 +1213,11 @@ export class OIDNDenoiser extends EventDispatcher {
1267
1213
 
1268
1214
  }
1269
1215
 
1270
- this.output.width = width;
1271
- this.output.height = height;
1272
- this._hasLatchedFrame = false;
1216
+ this._renderWidth = width;
1217
+ this._renderHeight = height;
1218
+ this._hasOutput = false;
1219
+ // The old measurement describes the old size, and callers size their policy on it.
1220
+ this.lastDenoiseMs = 0;
1273
1221
 
1274
1222
  // Reinitialize denoiser if tile size changes relative to image size
1275
1223
  this._setupUNetDenoiser().catch( error => {
@@ -1280,45 +1228,23 @@ export class OIDNDenoiser extends EventDispatcher {
1280
1228
 
1281
1229
  }
1282
1230
 
1283
- _destroyPendingStagingBuffers() {
1284
-
1285
- for ( const buf of this._pendingStagingBuffers ) {
1286
-
1287
- buf.destroy();
1288
-
1289
- }
1290
-
1291
- this._pendingStagingBuffers.clear();
1292
-
1293
- }
1294
-
1295
1231
  dispose() {
1296
1232
 
1297
1233
  // Abort any ongoing operations
1298
1234
  this.abort();
1299
1235
 
1300
- // Destroy any remaining staging buffers
1301
- this._destroyPendingStagingBuffers();
1302
-
1303
1236
  // Dispose resources
1304
1237
  this.unet?.dispose();
1305
1238
  this._destroyGPUInputBuffers();
1239
+ this._releaseOutputTexture();
1306
1240
  this._colorScalePipeline = null;
1307
1241
  this._lumReducePipeline = null;
1308
1242
  this._lumFinalizePipeline = null;
1309
1243
  this._lumLayout = null;
1310
- this._packPipeline = null;
1311
-
1312
- // Clean up DOM
1313
- if ( this.output?.parentNode ) {
1314
-
1315
- this.output.remove();
1316
-
1317
- }
1244
+ this._unpackPipeline = null;
1318
1245
 
1319
1246
  // Clear references
1320
1247
  this.unet = null;
1321
- this.ctx = null;
1322
1248
  this.state.abortController = null;
1323
1249
 
1324
1250
  // Remove all event listeners