rayzee 7.30.2 → 7.31.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.
@@ -16,12 +16,10 @@ async function getInitUNetFromURL() {
16
16
 
17
17
  }
18
18
 
19
- import { TONE_MAP_FNS, linearToSRGB, applySaturation, effectiveExposure } from '../Processor/ToneMapCPU.js';
19
+ import { effectiveExposure } from '../Processor/ToneMapCPU.js';
20
+ import { TONE_MAP_WGSL, toneMapMode } from '../Processor/ToneMapWGSL.js';
20
21
  import { getAssetConfig } from '../AssetConfig.js';
21
22
 
22
- /** Reusable RGB output buffer (avoids per-pixel allocation). */
23
- const _tmOut = new Float32Array( 3 );
24
-
25
23
  const MODEL_CONFIG = {
26
24
  // No cleanAux flag in oidn-web, so the blob is it — and it must match what setCleanAuxNormal
27
25
  // feeds the aux MRT: an `alb_nrm` model wants the point-sampled normal, a `calb_cnrm` model
@@ -39,7 +37,6 @@ const MODEL_CONFIG = {
39
37
  DEFAULT_OPTIONS: {
40
38
  enableOIDN: true,
41
39
  oidnQuality: 'fast',
42
- debugGbufferMaps: true,
43
40
  // A cap, not a fixed size — the effective tile is min( max( w, h ), this ), so a frame
44
41
  // that fits in one tile pays no overlap padding at all. Every tile otherwise runs at
45
42
  // tileSize + 2*overlap (96px, 112 for _large): at 1024²/high, 4x512 tiles measured
@@ -52,10 +49,79 @@ const MODEL_CONFIG = {
52
49
  // Stands in for OIDN's inputScale, which oidn-web only applies on its CPU path — the GPUBuffer
53
50
  // path leaves the uniform at 1.0. Equivalent because oidn-web does PUForward(col*inputScale) in
54
51
  // and PUInverse(..)/inputScale out, so pre-multiplying here and dividing it back out of the
55
- // output exposure round-trips. Alpha is preserved for _cacheInputAlpha.
52
+ // output exposure round-trips.
53
+ //
54
+ // The scale is produced and consumed entirely on the GPU. Averaging it on the CPU meant copying
55
+ // the whole colour buffer back (16 MB at 1024²) and running a per-pixel log loop on the main
56
+ // thread, and the `await` on the map drained the queue before the UNet could start.
57
+ const OIDN_AUTOEXPOSURE_KEY = 0.18;
58
+ const LUM_WG_SIZE = 256;
59
+ const LUM_GROUPS = 256;
60
+
61
+ const INPUT_SCALE_WGSL = /* wgsl */`
62
+ const WG: u32 = ${LUM_WG_SIZE}u;
63
+ const GROUPS: u32 = ${LUM_GROUPS}u;
64
+
65
+ @group(0) @binding(0) var<storage, read> col: array<vec4<f32>>;
66
+ @group(0) @binding(1) var<storage, read_write> partials: array<f32>;
67
+ @group(0) @binding(2) var<uniform> params: vec4<u32>;
68
+ @group(0) @binding(3) var<storage, read_write> scaleOut: array<f32>;
69
+
70
+ var<workgroup> sdata: array<f32, WG>;
71
+
72
+ fn treeSum( lid: u32 ) {
73
+ var s: u32 = WG >> 1u;
74
+ loop {
75
+ if ( s == 0u ) { break; }
76
+ if ( lid < s ) { sdata[ lid ] = sdata[ lid ] + sdata[ lid + s ]; }
77
+ workgroupBarrier();
78
+ s = s >> 1u;
79
+ }
80
+ }
81
+
82
+ @compute @workgroup_size(${LUM_WG_SIZE})
83
+ fn reduce( @builtin(global_invocation_id) gid: vec3<u32>,
84
+ @builtin(local_invocation_id) lid: vec3<u32>,
85
+ @builtin(workgroup_id) wid: vec3<u32> ) {
86
+
87
+ let total = params.x;
88
+ var sum = 0.0;
89
+ var i = gid.x;
90
+ loop {
91
+ if ( i >= total ) { break; }
92
+ let c = col[ i ].xyz;
93
+ let lum = 0.212671 * c.r + 0.71516 * c.g + 0.072169 * c.b;
94
+ sum = sum + log2( max( lum, 0.0 ) + 0.0001 );
95
+ i = i + WG * GROUPS;
96
+ }
97
+
98
+ sdata[ lid.x ] = sum;
99
+ workgroupBarrier();
100
+ treeSum( lid.x );
101
+
102
+ if ( lid.x == 0u ) { partials[ wid.x ] = sdata[ 0 ]; }
103
+ }
104
+
105
+ @compute @workgroup_size(${LUM_WG_SIZE})
106
+ fn finalize( @builtin(local_invocation_id) lid: vec3<u32> ) {
107
+
108
+ var v = 0.0;
109
+ if ( lid.x < GROUPS ) { v = partials[ lid.x ]; }
110
+ sdata[ lid.x ] = v;
111
+ workgroupBarrier();
112
+ treeSum( lid.x );
113
+
114
+ if ( lid.x == 0u ) {
115
+ let scale = ${OIDN_AUTOEXPOSURE_KEY} / exp2( sdata[ 0 ] / f32( params.x ) );
116
+ // Mirrors the CPU guard: a non-finite or non-positive scale means no scaling at all.
117
+ scaleOut[ 0 ] = select( 1.0, scale, scale > 0.0 && scale < 3.4e38 );
118
+ }
119
+ }
120
+ `;
121
+
56
122
  const COLOR_SCALE_WGSL = /* wgsl */`
57
123
  @group(0) @binding(0) var<storage, read_write> col: array<vec4<f32>>;
58
- @group(0) @binding(1) var<uniform> scale: f32;
124
+ @group(0) @binding(1) var<storage, read> scaleBuf: array<f32>;
59
125
 
60
126
  @compute @workgroup_size(64)
61
127
  fn main( @builtin(global_invocation_id) gid: vec3<u32>,
@@ -63,7 +129,7 @@ fn main( @builtin(global_invocation_id) gid: vec3<u32>,
63
129
  let i = gid.y * nwg.x * 64u + gid.x;
64
130
  if ( i >= arrayLength( &col ) ) { return; }
65
131
  let c = col[ i ];
66
- col[ i ] = vec4<f32>( c.xyz * scale, c.w );
132
+ col[ i ] = vec4<f32>( c.xyz * scaleBuf[ 0 ], c.w );
67
133
  }
68
134
  `;
69
135
 
@@ -71,24 +137,51 @@ const SCALE_WG_SIZE = 64;
71
137
  // 1D would exceed maxComputeWorkgroupsPerDimension (65535) past ~2048², so the dispatch is 2D.
72
138
  const SCALE_MAX_WG_X = 32768;
73
139
 
74
- // OIDN's autoexposure, mirroring oidn-web's avgLogLum. Whole-frame, so it cannot seam per tile.
75
- const OIDN_AUTOEXPOSURE_KEY = 0.18;
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}
147
+
148
+ struct PackParams {
149
+ srcWidth: u32,
150
+ tileX: u32,
151
+ tileY: u32,
152
+ tileW: u32,
153
+ tileH: u32,
154
+ mode: u32,
155
+ useAlpha: u32,
156
+ pad0: u32,
157
+ exposure: f32,
158
+ saturation: f32,
159
+ pad1: vec2<f32>,
160
+ };
76
161
 
77
- function oidnInputScale( f32, pixelCount ) {
162
+ @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>>;
78
167
 
79
- let logSum = 0;
80
- for ( let i = 0; i < pixelCount; i ++ ) {
168
+ @compute @workgroup_size(${PACK_WG_SIZE}, ${PACK_WG_SIZE})
169
+ fn main( @builtin(global_invocation_id) gid: vec3<u32> ) {
81
170
 
82
- const o = i * 4;
83
- const lum = 0.212671 * f32[ o ] + 0.71516 * f32[ o + 1 ] + 0.072169 * f32[ o + 2 ];
84
- logSum += Math.log2( Math.max( lum, 0 ) + 0.0001 );
171
+ if ( gid.x >= P.tileW || gid.y >= P.tileH ) { return; }
85
172
 
86
- }
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 );
87
177
 
88
- const scale = OIDN_AUTOEXPOSURE_KEY / Math.pow( 2, logSum / pixelCount );
89
- return Number.isFinite( scale ) && scale > 0 ? scale : 1.0;
178
+ var a: u32 = 255u;
179
+ if ( P.useAlpha == 1u ) { a = u32( clamp( inColor[ si ].w, 0.0, 1.0 ) * 255.0 + 0.5 ); }
90
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 );
91
183
  }
184
+ `;
92
185
 
93
186
  export class OIDNDenoiser extends EventDispatcher {
94
187
 
@@ -108,9 +201,6 @@ export class OIDNDenoiser extends EventDispatcher {
108
201
  this.camera = camera;
109
202
  this.input = renderer.domElement;
110
203
  this.output = output;
111
- this.extractGBufferData = options.extractGBufferData || null;
112
- this.getMRTRenderTarget = options.getMRTRenderTarget || null;
113
-
114
204
 
115
205
  // WebGPU GPU-native path (no CPU readback for inputs)
116
206
  // backendParams: () => { device: GPUDevice, adapterInfo: GPUAdapterInfo|null }
@@ -136,22 +226,19 @@ export class OIDNDenoiser extends EventDispatcher {
136
226
  // order guarantees the overwrites are serialized).
137
227
  this._gpuInputPadBuffer = null;
138
228
  this._gpuInputPaddedRowBytes = 0;
139
- // 1.0 until the first readback.
229
+ // The autoexposure scale never leaves the GPU — _applyColorScale and the output pack
230
+ // both read _inputScaleBuffer directly.
231
+ this._lumReducePipeline = null;
232
+ this._lumFinalizePipeline = null;
233
+ this._lumLayout = null;
234
+ this._lumBindGroup = null;
235
+ this._lumPartials = null;
236
+ this._lumParams = null;
237
+ this._inputScaleBuffer = null;
238
+
140
239
  this._colorScalePipeline = null;
141
240
  this._colorScaleBindGroup = null;
142
- this._colorScaleUniform = null;
143
- this._oidnInputScale = 1.0;
144
- // Pooled MAP_READ staging buffer for _cacheInputAlpha. Only allocated
145
- // when transparent-background readback is used, destroyed on resolution
146
- // change or dispose. Same spirit as r184's ReadbackBuffer — we can't use
147
- // renderer.getArrayBufferAsync because the source is a raw GPUBuffer,
148
- // not a Three.js BufferAttribute.
149
- this._alphaReadbackBuffer = null;
150
- this._alphaReadbackMapped = false;
151
-
152
- // Cached alpha channel from the input color buffer (OIDN discards alpha)
153
- this._cachedAlpha = null;
154
- this._cachedAlphaWidth = 0;
241
+ this._packPipeline = null;
155
242
 
156
243
  // Merge options with defaults
157
244
  this.config = { ...MODEL_CONFIG.DEFAULT_OPTIONS, ...options };
@@ -159,7 +246,6 @@ export class OIDNDenoiser extends EventDispatcher {
159
246
  // Destructure for easier access
160
247
  this.enabled = this.config.enableOIDN;
161
248
  this.quality = this.config.oidnQuality;
162
- this.debugGbufferMaps = this.config.debugGbufferMaps;
163
249
  this.maxTileSize = this.config.tileSize;
164
250
  // The size actually baked into the live UNet, so a change forces a rebuild.
165
251
  this._activeTileSize = 0;
@@ -171,16 +257,28 @@ export class OIDNDenoiser extends EventDispatcher {
171
257
  abortController: null
172
258
  };
173
259
 
174
- // Track in-flight tile staging buffers so they can be destroyed on abort
260
+ // Track in-flight per-tile GPU buffers so they can be destroyed on abort
175
261
  this._pendingStagingBuffers = new Set();
176
262
  // Per-run tile-blit promises; done() awaits these so capture waits for every tile to paint.
177
263
  this._pendingTileBlits = [];
178
264
 
265
+ // Identifies the live run. A run superseded by abort() must not clear the state its
266
+ // successor already owns.
267
+ 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;
271
+ // Wall time of the last completed denoise, for cadence policy. 0 until one finishes.
272
+ this.lastDenoiseMs = 0;
273
+
179
274
  this.currentTZAUrl = null;
180
275
  this.unet = null;
181
276
  // A start() requested while the UNet is still loading is deferred here and fired
182
277
  // once loading finishes, instead of being silently dropped.
183
278
  this._pendingStart = false;
279
+ this._pendingStartContinuous = false;
280
+ // A weight load requested while another is in flight, to run when that one finishes.
281
+ this._reloadPending = false;
184
282
 
185
283
  // Initialize asynchronously
186
284
  this._initialize().catch( error => {
@@ -297,79 +395,96 @@ export class OIDNDenoiser extends EventDispatcher {
297
395
 
298
396
  async _setupUNetDenoiser() {
299
397
 
300
- if ( this.state.isLoading ) return;
398
+ if ( this.state.isLoading ) {
301
399
 
302
- this.state.isLoading = true;
303
- const tzaUrl = this._generateTzaUrl();
400
+ // Dropping this would leave `quality` describing weights that were never fetched, and
401
+ // nothing would ever fetch them. Reachable on every render now that the refreshes and
402
+ // the finished image use different tiers. Flag it; the running load picks it up.
403
+ this._reloadPending = true;
404
+ return;
304
405
 
305
- const tileSize = this._resolveTileSize();
406
+ }
306
407
 
307
- // maxTileSize is a constructor arg, so comparing the URL alone left tileSize changes inert.
308
- if ( this.currentTZAUrl === tzaUrl && this._activeTileSize === tileSize && this.unet ) {
408
+ do {
309
409
 
310
- this.state.isLoading = false;
311
- return;
410
+ this._reloadPending = false;
411
+ this.state.isLoading = true;
312
412
 
313
- }
413
+ try {
314
414
 
315
- try {
415
+ await this._loadUNetWeights();
416
+
417
+ } catch ( error ) {
316
418
 
317
- this.dispatchEvent( { type: 'loading', message: 'Loading UNet denoiser...' } );
419
+ log.error( 'UNet weights failed to load:', error );
420
+ this.dispatchEvent( { type: 'error', error: new Error( `Denoiser loading failed: ${error.message}` ) } );
318
421
 
319
- // Dispose previous instance
320
- if ( this.unet ) {
422
+ } finally {
321
423
 
322
- this.unet.dispose();
323
- this.unet = null;
424
+ this.state.isLoading = false;
324
425
 
325
426
  }
326
427
 
327
- // GPU-native path: share the existing GPUDevice so oidn-web uses the
328
- // same device as the renderer — no second device, no CPU roundtrip for inputs.
329
- let backendParams;
330
- if ( this.isGPUMode && this.backendParamsGetter ) {
428
+ } while ( this._reloadPending );
331
429
 
332
- const params = this.backendParamsGetter();
333
- this.gpuDevice = params?.device ?? null;
334
- backendParams = params?.device ? params : undefined;
430
+ // Fire a start() that arrived mid-load. Guard on unet+enabled so a failed load
431
+ // or a disable during loading doesn't kick off a denoise. Outside the loop, so a
432
+ // no-op reload still releases it — it used to be stranded by the early return below.
433
+ if ( this._pendingStart && this.unet && this.enabled ) {
335
434
 
336
- }
435
+ const continuous = this._pendingStartContinuous;
436
+ this._pendingStart = false;
437
+ this._pendingStartContinuous = false;
438
+ this.start( { continuous } );
337
439
 
338
- const initFn = await getInitUNetFromURL();
339
- this.unet = await initFn( tzaUrl, backendParams, {
340
- aux: true,
341
- hdr: true,
342
- maxTileSize: tileSize,
343
- // Adaptive tiling ignores maxTileSize, starts at 384, and cannot see that overlap
344
- // padding only vanishes once a tile covers the image — so it settles smaller.
345
- dynamicTile: false
346
- } );
440
+ }
347
441
 
348
- this.currentTZAUrl = tzaUrl;
349
- this._activeTileSize = tileSize;
350
- this.dispatchEvent( { type: 'loaded' } );
351
- log.debug( 'UNet weights loaded:', tzaUrl );
442
+ }
352
443
 
353
- } catch ( error ) {
444
+ async _loadUNetWeights() {
445
+
446
+ const tzaUrl = this._generateTzaUrl();
447
+ const tileSize = this._resolveTileSize();
354
448
 
355
- log.error( 'UNet weights failed to load:', error );
356
- this.dispatchEvent( { type: 'error', error: new Error( `Denoiser loading failed: ${error.message}` ) } );
449
+ // maxTileSize is a constructor arg, so comparing the URL alone left tileSize changes inert.
450
+ if ( this.currentTZAUrl === tzaUrl && this._activeTileSize === tileSize && this.unet ) return;
357
451
 
358
- } finally {
452
+ this.dispatchEvent( { type: 'loading', message: 'Loading UNet denoiser...' } );
359
453
 
360
- this.state.isLoading = false;
454
+ // Dispose previous instance
455
+ if ( this.unet ) {
361
456
 
362
- // Fire a start() that arrived mid-load. Guard on unet+enabled so a failed load
363
- // or a disable during loading doesn't kick off a denoise.
364
- if ( this._pendingStart && this.unet && this.enabled ) {
457
+ this.unet.dispose();
458
+ this.unet = null;
365
459
 
366
- this._pendingStart = false;
367
- this.start();
460
+ }
368
461
 
369
- }
462
+ // GPU-native path: share the existing GPUDevice so oidn-web uses the
463
+ // same device as the renderer — no second device, no CPU roundtrip for inputs.
464
+ let backendParams;
465
+ if ( this.isGPUMode && this.backendParamsGetter ) {
466
+
467
+ const params = this.backendParamsGetter();
468
+ this.gpuDevice = params?.device ?? null;
469
+ backendParams = params?.device ? params : undefined;
370
470
 
371
471
  }
372
472
 
473
+ const initFn = await getInitUNetFromURL();
474
+ this.unet = await initFn( tzaUrl, backendParams, {
475
+ aux: true,
476
+ hdr: true,
477
+ maxTileSize: tileSize,
478
+ // Adaptive tiling ignores maxTileSize, starts at 384, and cannot see that overlap
479
+ // padding only vanishes once a tile covers the image — so it settles smaller.
480
+ dynamicTile: false
481
+ } );
482
+
483
+ this.currentTZAUrl = tzaUrl;
484
+ this._activeTileSize = tileSize;
485
+ this.dispatchEvent( { type: 'loaded' } );
486
+ log.debug( 'UNet weights loaded:', tzaUrl );
487
+
373
488
  }
374
489
 
375
490
  _generateTzaUrl() {
@@ -391,7 +506,6 @@ export class OIDNDenoiser extends EventDispatcher {
391
506
  // Update configuration
392
507
  Object.assign( this.config, newConfig );
393
508
  this.quality = this.config.oidnQuality;
394
- this.debugGbufferMaps = this.config.debugGbufferMaps;
395
509
  this.maxTileSize = this.config.tileSize;
396
510
 
397
511
  // Reload denoiser if necessary
@@ -411,7 +525,13 @@ export class OIDNDenoiser extends EventDispatcher {
411
525
 
412
526
  }
413
527
 
414
- async start() {
528
+ /**
529
+ * @param {Object} [options]
530
+ * @param {boolean} [options.continuous=false] - A cadence run rather than the final denoise.
531
+ * Tagged onto the start/end events so consumers can tell a background refresh from the
532
+ * one-shot denoise that ends a render.
533
+ */
534
+ async start( { continuous = false } = {} ) {
415
535
 
416
536
  if ( ! this.enabled || this.state.isDenoising ) {
417
537
 
@@ -431,23 +551,30 @@ export class OIDNDenoiser extends EventDispatcher {
431
551
  // finally) rather than silently dropping this frame's denoise request.
432
552
  if ( this.state.isLoading ) {
433
553
 
554
+ // A deferred cadence run must stay a cadence run, or it surfaces as the final
555
+ // denoise: tile border, status badge, upscaler chain. A non-cadence start joining
556
+ // the same deferral wins.
557
+ this._pendingStartContinuous = this._pendingStart
558
+ ? this._pendingStartContinuous && continuous
559
+ : continuous;
434
560
  this._pendingStart = true;
435
561
  return false;
436
562
 
437
563
  }
438
564
 
439
- this.dispatchEvent( { type: 'start' } );
565
+ this.dispatchEvent( { type: 'start', continuous } );
440
566
 
441
567
  const startTime = performance.now();
442
- const success = await this.execute();
568
+ const success = await this.execute( continuous );
443
569
 
444
570
  if ( success ) {
445
571
 
572
+ this._hasLatchedFrame = true;
446
573
  this.renderer?.resetState?.();
447
574
  this.input.style.opacity = '0';
448
575
 
449
- const duration = performance.now() - startTime;
450
- log.debug( `denoise complete in ${fmt.ms( duration )} · quality ${this.quality}` );
576
+ this.lastDenoiseMs = performance.now() - startTime;
577
+ log.debug( `denoise complete in ${fmt.ms( this.lastDenoiseMs )} · quality ${this.quality}` );
451
578
 
452
579
  }
453
580
 
@@ -464,17 +591,18 @@ export class OIDNDenoiser extends EventDispatcher {
464
591
 
465
592
  }
466
593
 
467
- async execute() {
594
+ async execute( continuous = false ) {
468
595
 
469
596
  if ( ! this.enabled || ! this.unet ) return false;
470
597
 
471
598
  // Create abort controller for this execution
599
+ const runId = ++ this._runId;
472
600
  this.state.abortController = new AbortController();
473
601
  this.state.isDenoising = true;
474
602
 
475
603
  try {
476
604
 
477
- await this._executeUNet();
605
+ await this._executeUNet( continuous );
478
606
  return true;
479
607
 
480
608
  } catch ( error ) {
@@ -489,23 +617,30 @@ export class OIDNDenoiser extends EventDispatcher {
489
617
 
490
618
  }
491
619
 
492
- // Restore original rendering on error
493
- this.input.style.opacity = '1';
620
+ // Restore original rendering on error, unless a previous denoise is still on screen.
621
+ if ( ! this._hasLatchedFrame ) this.input.style.opacity = '1';
494
622
  return false;
495
623
 
496
624
  } finally {
497
625
 
498
- this.state.isDenoising = false;
499
- this.state.abortController = null;
500
- this.dispatchEvent( { type: 'end' } );
626
+ // An aborted run resolves after its replacement has already started; clearing the
627
+ // shared state then would strand the live run without an abort controller.
628
+ if ( this._runId === runId ) {
629
+
630
+ this.state.isDenoising = false;
631
+ this.state.abortController = null;
632
+
633
+ }
634
+
635
+ this.dispatchEvent( { type: 'end', continuous } );
501
636
 
502
637
  }
503
638
 
504
639
  }
505
640
 
506
- async _executeUNet() {
641
+ async _executeUNet( continuous = false ) {
507
642
 
508
- return this._executeUNetGPU();
643
+ return this._executeUNetGPU( continuous );
509
644
 
510
645
  }
511
646
 
@@ -516,7 +651,7 @@ export class OIDNDenoiser extends EventDispatcher {
516
651
  *
517
652
  * Note: oidn-web's GPUTexture input path produces NaN outputs — using GPUBuffer instead.
518
653
  */
519
- async _executeUNetGPU() {
654
+ async _executeUNetGPU( continuous = false ) {
520
655
 
521
656
  const { width, height } = this.output;
522
657
 
@@ -544,9 +679,16 @@ export class OIDNDenoiser extends EventDispatcher {
544
679
  }
545
680
 
546
681
  // Capture the base now: the readback below awaits, and a presented WebGPU canvas only
547
- // reads back non-empty straight after a compositor pass — hence the refresh.
548
- this.refreshInput?.();
549
- this.ctx.drawImage( this.input, 0, 0, width, height );
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
+
550
692
  this._revealOutput();
551
693
 
552
694
  // Ensure storage buffers are sized correctly (recreate on resolution change)
@@ -602,9 +744,9 @@ export class OIDNDenoiser extends EventDispatcher {
602
744
 
603
745
  device.queue.submit( [ encoder.finish() ] );
604
746
 
605
- // One readback serves the autoexposure scale and the alpha OIDN discards. Must precede the scale.
606
- await this._readbackColor( device, width, height );
607
- if ( globalThis.__OIDN_NO_INPUT_SCALE ) this._oidnInputScale = 1.0;
747
+ // Autoexposure and the pre-multiply it drives, both on the GPU and both queued behind the
748
+ // copies above. Nothing is awaited here — an await would drain the queue before the UNet.
749
+ this._computeInputScale( device, width * height );
608
750
  this._applyColorScale( device, width * height );
609
751
 
610
752
  // Pass GPU storage buffers to oidn-web (GPUBuffer path, well-tested)
@@ -614,7 +756,7 @@ export class OIDNDenoiser extends EventDispatcher {
614
756
  normal: { data: this._gpuInputBuffers.normal, width, height }
615
757
  };
616
758
 
617
- return this._executeWithAbortGPU( config );
759
+ return this._executeWithAbortGPU( config, continuous );
618
760
 
619
761
  }
620
762
 
@@ -668,113 +810,174 @@ export class OIDNDenoiser extends EventDispatcher {
668
810
  this._gpuInputBuffers.normal?.destroy();
669
811
  this._gpuInputPadBuffer?.destroy();
670
812
 
671
- // Unmap before destroying if a mapAsync resolved but unmap hasn't been called yet.
672
- // If mapAsync is still pending, destroy() will reject it — _cacheInputAlpha's
673
- // catch handler covers that case.
674
- if ( this._alphaReadbackMapped && this._alphaReadbackBuffer ) {
675
-
676
- try {
677
-
678
- this._alphaReadbackBuffer.unmap();
679
-
680
- } catch { /* already unmapped or destroyed */ }
681
-
682
- }
683
-
684
- this._alphaReadbackBuffer?.destroy();
685
- this._alphaReadbackMapped = false;
686
- // Bind group holds the destroyed buffer; the pipeline is device-scoped and survives.
813
+ // Bind groups hold the destroyed buffers; pipelines are device-scoped and survive.
687
814
  this._colorScaleBindGroup = null;
688
- this._colorScaleUniform?.destroy();
689
- this._colorScaleUniform = null;
815
+ this._lumBindGroup = null;
816
+ this._lumPartials?.destroy();
817
+ this._lumPartials = null;
818
+ this._lumParams?.destroy();
819
+ this._lumParams = null;
820
+ this._inputScaleBuffer?.destroy();
821
+ this._inputScaleBuffer = null;
690
822
  this._gpuInputBuffers = { color: null, albedo: null, normal: null };
691
823
  this._gpuInputPadBuffer = null;
692
824
  this._gpuInputPaddedRowBytes = 0;
693
- this._alphaReadbackBuffer = null;
694
825
  this._gpuInputBufferSize = { width: 0, height: 0 };
695
826
 
696
827
  }
697
828
 
698
829
  /**
699
- * Reads back the colour buffer for the autoexposure scale, plus alpha when transparent
700
- * background is on (OIDN discards alpha).
830
+ * Builds the autoexposure reduction, the colour pre-multiply and the output pack. All three
831
+ * share the scale buffer, so they are created together.
832
+ * @returns {boolean} whether the GPU path is usable
701
833
  */
702
- async _readbackColor( device, width, height ) {
834
+ _ensureScalePipelines( device ) {
835
+
836
+ const buffer = this._gpuInputBuffers.color;
837
+ if ( ! device || ! buffer ) return false;
703
838
 
704
- const byteSize = width * height * 16; // rgba32float, tightly packed
839
+ if ( ! this._lumLayout ) {
705
840
 
706
- // Lazy-allocate the pooled staging buffer on first call at this resolution.
707
- // _destroyGPUInputBuffers clears it on resolution change or dispose, so if
708
- // it is non-null here, it already matches the current resolution.
709
- if ( this._alphaReadbackBuffer === null ) {
841
+ // Explicit rather than 'auto': `finalize` does not reference `col`, and an auto
842
+ // layout would drop that binding and reject the shared bind group.
843
+ this._lumLayout = device.createBindGroupLayout( {
844
+ label: 'oidn-autoexposure',
845
+ entries: [
846
+ { binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'read-only-storage' } },
847
+ { binding: 1, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } },
848
+ { binding: 2, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'uniform' } },
849
+ { binding: 3, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } }
850
+ ]
851
+ } );
710
852
 
711
- this._alphaReadbackBuffer = device.createBuffer( {
712
- label: 'oidn-alpha-readback',
713
- size: byteSize,
714
- usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST
853
+ const module = device.createShaderModule( { label: 'oidn-autoexposure', code: INPUT_SCALE_WGSL } );
854
+ const layout = device.createPipelineLayout( { bindGroupLayouts: [ this._lumLayout ] } );
855
+ this._lumReducePipeline = device.createComputePipeline( {
856
+ label: 'oidn-autoexposure-reduce', layout, compute: { module, entryPoint: 'reduce' }
857
+ } );
858
+ this._lumFinalizePipeline = device.createComputePipeline( {
859
+ label: 'oidn-autoexposure-finalize', layout, compute: { module, entryPoint: 'finalize' }
715
860
  } );
716
861
 
717
862
  }
718
863
 
719
- const staging = this._alphaReadbackBuffer;
864
+ if ( ! this._colorScalePipeline ) {
720
865
 
721
- const enc = device.createCommandEncoder();
722
- enc.copyBufferToBuffer( this._gpuInputBuffers.color, 0, staging, 0, byteSize );
723
- device.queue.submit( [ enc.finish() ] );
866
+ this._colorScalePipeline = device.createComputePipeline( {
867
+ label: 'oidn-color-scale',
868
+ layout: 'auto',
869
+ compute: {
870
+ module: device.createShaderModule( { label: 'oidn-color-scale', code: COLOR_SCALE_WGSL } ),
871
+ entryPoint: 'main'
872
+ }
873
+ } );
724
874
 
725
- this._alphaReadbackMapped = true;
726
- try {
875
+ }
727
876
 
728
- await staging.mapAsync( GPUMapMode.READ );
877
+ if ( ! this._packPipeline ) {
878
+
879
+ this._packPipeline = device.createComputePipeline( {
880
+ label: 'oidn-output-pack',
881
+ layout: 'auto',
882
+ compute: {
883
+ module: device.createShaderModule( { label: 'oidn-output-pack', code: PACK_WGSL } ),
884
+ entryPoint: 'main'
885
+ }
886
+ } );
887
+
888
+ }
729
889
 
730
- } catch {
890
+ if ( ! this._inputScaleBuffer ) {
731
891
 
732
- // Buffer was destroyed while mapAsync was pending (resize or dispose)
733
- this._alphaReadbackMapped = false;
734
- return;
892
+ this._inputScaleBuffer = device.createBuffer( {
893
+ label: 'oidn-input-scale',
894
+ // COPY_SRC is for debugging only: it lets the chosen exposure be read back.
895
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC,
896
+ size: 4
897
+ } );
898
+ this._lumPartials = device.createBuffer( {
899
+ label: 'oidn-autoexposure-partials',
900
+ size: LUM_GROUPS * 4,
901
+ usage: GPUBufferUsage.STORAGE
902
+ } );
903
+ this._lumParams = device.createBuffer( {
904
+ label: 'oidn-autoexposure-params',
905
+ size: 16,
906
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
907
+ } );
735
908
 
736
909
  }
737
910
 
738
- const f32 = new Float32Array( staging.getMappedRange() );
739
- const pixelCount = width * height;
911
+ if ( ! this._lumBindGroup ) {
740
912
 
741
- this._oidnInputScale = oidnInputScale( f32, pixelCount );
913
+ this._lumBindGroup = device.createBindGroup( {
914
+ label: 'oidn-autoexposure',
915
+ layout: this._lumLayout,
916
+ entries: [
917
+ { binding: 0, resource: { buffer } },
918
+ { binding: 1, resource: { buffer: this._lumPartials } },
919
+ { binding: 2, resource: { buffer: this._lumParams } },
920
+ { binding: 3, resource: { buffer: this._inputScaleBuffer } }
921
+ ]
922
+ } );
742
923
 
743
- let alpha = null;
744
- if ( this.getTransparentBackground() ) {
924
+ }
745
925
 
746
- // Alpha as uint8; pre-multiplication is not needed — alpha is 0 or 1
747
- alpha = new Uint8Array( pixelCount );
748
- for ( let i = 0; i < pixelCount; i ++ ) {
926
+ if ( ! this._colorScaleBindGroup ) {
749
927
 
750
- alpha[ i ] = Math.min( Math.max( f32[ i * 4 + 3 ] * 255, 0 ), 255 ) | 0;
928
+ this._colorScaleBindGroup = device.createBindGroup( {
929
+ label: 'oidn-color-scale',
930
+ layout: this._colorScalePipeline.getBindGroupLayout( 0 ),
931
+ entries: [
932
+ { binding: 0, resource: { buffer } },
933
+ { binding: 1, resource: { buffer: this._inputScaleBuffer } }
934
+ ]
935
+ } );
751
936
 
752
- }
937
+ }
938
+
939
+ return true;
940
+
941
+ }
942
+
943
+ /** Writes OIDN's autoexposure scale into `_inputScaleBuffer`. Never read back. */
944
+ _computeInputScale( device, pixelCount ) {
945
+
946
+ if ( ! this._ensureScalePipelines( device ) ) return;
947
+
948
+ if ( globalThis.__OIDN_NO_INPUT_SCALE ) {
949
+
950
+ device.queue.writeBuffer( this._inputScaleBuffer, 0, new Float32Array( [ 1.0 ] ) );
951
+ return;
753
952
 
754
953
  }
755
954
 
756
- staging.unmap();
757
- this._alphaReadbackMapped = false;
955
+ device.queue.writeBuffer( this._lumParams, 0, new Uint32Array( [ pixelCount, 0, 0, 0 ] ) );
758
956
 
759
- this._cachedAlpha = alpha;
760
- this._cachedAlphaWidth = width;
957
+ const encoder = device.createCommandEncoder( { label: 'oidn-autoexposure' } );
761
958
 
762
- }
959
+ // Two passes, not two dispatches in one: `finalize` consumes what `reduce` wrote, and
960
+ // pass boundaries are where WebGPU guarantees that ordering.
961
+ const reduce = encoder.beginComputePass( { label: 'oidn-autoexposure-reduce' } );
962
+ reduce.setPipeline( this._lumReducePipeline );
963
+ reduce.setBindGroup( 0, this._lumBindGroup );
964
+ reduce.dispatchWorkgroups( LUM_GROUPS, 1, 1 );
965
+ reduce.end();
763
966
 
764
- /** Display exposure with the input scale divided back out — output arrives pre-multiplied. */
765
- _outputExposure() {
967
+ const finalize = encoder.beginComputePass( { label: 'oidn-autoexposure-finalize' } );
968
+ finalize.setPipeline( this._lumFinalizePipeline );
969
+ finalize.setBindGroup( 0, this._lumBindGroup );
970
+ finalize.dispatchWorkgroups( 1, 1, 1 );
971
+ finalize.end();
766
972
 
767
- return effectiveExposure( this.getExposure(), this.getToneMapping() ) / this._oidnInputScale;
973
+ device.queue.submit( [ encoder.finish() ] );
768
974
 
769
975
  }
770
976
 
771
977
  /** Pre-multiplies the colour buffer by the autoexposure scale. */
772
978
  _applyColorScale( device, pixelCount ) {
773
979
 
774
- const scale = this._oidnInputScale;
775
- if ( ! this._ensureColorScalePipeline( device ) ) return;
776
-
777
- device.queue.writeBuffer( this._colorScaleUniform, 0, new Float32Array( [ scale ] ) );
980
+ if ( ! this._ensureScalePipelines( device ) ) return;
778
981
 
779
982
  const wgTotal = Math.ceil( pixelCount / SCALE_WG_SIZE );
780
983
  const wgX = Math.min( wgTotal, SCALE_MAX_WG_X );
@@ -790,49 +993,110 @@ export class OIDNDenoiser extends EventDispatcher {
790
993
 
791
994
  }
792
995
 
793
- /** @returns {GPUComputePipeline|null} */
794
- _ensureColorScalePipeline( device ) {
996
+ /**
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.
1003
+ *
1004
+ * @param {GPUBuffer} src - denoised rgba32float, full image
1005
+ * @param {number} srcWidth
1006
+ * @param {{x: number, y: number, width: number, height: number}} rect
1007
+ * @returns {Promise<void>}
1008
+ */
1009
+ async _packAndBlit( src, srcWidth, rect ) {
795
1010
 
796
- const buffer = this._gpuInputBuffers.color;
797
- if ( ! device || ! buffer ) return null;
1011
+ const device = this.gpuDevice;
1012
+ if ( ! device || ! this._ensureScalePipelines( device ) ) return;
798
1013
 
799
- if ( ! this._colorScalePipeline ) {
1014
+ const { x, y, width, height } = rect;
1015
+ const byteSize = width * height * 4;
800
1016
 
801
- this._colorScalePipeline = device.createComputePipeline( {
802
- label: 'oidn-color-scale',
803
- layout: 'auto',
804
- compute: {
805
- module: device.createShaderModule( { label: 'oidn-color-scale', code: COLOR_SCALE_WGSL } ),
806
- entryPoint: 'main'
807
- }
808
- } );
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
+ } );
1027
+ const params = device.createBuffer( {
1028
+ label: 'oidn-pack-params',
1029
+ size: PACK_PARAMS_BYTES,
1030
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
1031
+ } );
809
1032
 
810
- }
1033
+ this._pendingStagingBuffers.add( dst );
1034
+ this._pendingStagingBuffers.add( staging );
1035
+ this._pendingStagingBuffers.add( params );
811
1036
 
812
- if ( ! this._colorScaleUniform ) {
1037
+ const release = () => {
813
1038
 
814
- this._colorScaleUniform = device.createBuffer( {
815
- label: 'oidn-color-scale-uniform',
816
- size: 16, // min uniform binding size
817
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
818
- } );
1039
+ for ( const b of [ dst, staging, params ] ) {
819
1040
 
820
- }
1041
+ b.destroy();
1042
+ this._pendingStagingBuffers.delete( b );
821
1043
 
822
- if ( ! this._colorScaleBindGroup ) {
1044
+ }
823
1045
 
824
- this._colorScaleBindGroup = device.createBindGroup( {
825
- label: 'oidn-color-scale',
826
- layout: this._colorScalePipeline.getBindGroupLayout( 0 ),
1046
+ };
1047
+
1048
+ try {
1049
+
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 );
1062
+
1063
+ const bindGroup = device.createBindGroup( {
1064
+ label: 'oidn-output-pack',
1065
+ layout: this._packPipeline.getBindGroupLayout( 0 ),
827
1066
  entries: [
828
- { binding: 0, resource: { buffer } },
829
- { binding: 1, resource: { buffer: this._colorScaleUniform } }
1067
+ { 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 } }
830
1072
  ]
831
1073
  } );
832
1074
 
833
- }
1075
+ const encoder = device.createCommandEncoder( { label: 'oidn-output-pack' } );
1076
+ const pass = encoder.beginComputePass( { label: 'oidn-output-pack' } );
1077
+ pass.setPipeline( this._packPipeline );
1078
+ pass.setBindGroup( 0, bindGroup );
1079
+ pass.dispatchWorkgroups(
1080
+ Math.ceil( width / PACK_WG_SIZE ),
1081
+ Math.ceil( height / PACK_WG_SIZE ),
1082
+ 1
1083
+ );
1084
+ pass.end();
1085
+ encoder.copyBufferToBuffer( dst, 0, staging, 0, byteSize );
1086
+ device.queue.submit( [ encoder.finish() ] );
1087
+
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();
834
1092
 
835
- return this._colorScalePipeline;
1093
+ this.ctx.putImageData( new ImageData( bytes, width, height ), x, y );
1094
+
1095
+ } finally {
1096
+
1097
+ release();
1098
+
1099
+ }
836
1100
 
837
1101
  }
838
1102
 
@@ -840,7 +1104,7 @@ export class OIDNDenoiser extends EventDispatcher {
840
1104
  * Promise wrapper around tileExecute for the GPU path.
841
1105
  * Outputs a GPUBuffer — copied to a staging buffer then converted to ImageData for the 2D canvas.
842
1106
  */
843
- _executeWithAbortGPU( config ) {
1107
+ _executeWithAbortGPU( config, continuous = false ) {
844
1108
 
845
1109
  return new Promise( ( resolve, reject ) => {
846
1110
 
@@ -909,106 +1173,32 @@ export class OIDNDenoiser extends EventDispatcher {
909
1173
  progress: ( outputData, _tileData, tile ) => {
910
1174
 
911
1175
  // oidn-web GPU path: tileData is null, but outputData holds the assembled
912
- // full-image buffer updated after each tile. Extract the tile region via
913
- // row-by-row copyBufferToBuffer (no stride support in WebGPU buffer copies).
1176
+ // full-image buffer updated after each tile. The pack pass reads that tile's
1177
+ // rectangle straight out of it, so there are no row-by-row buffer copies.
914
1178
  if ( ! outputData?.data || ! tile ) return;
915
1179
 
916
- const device = this.gpuDevice;
917
1180
  const fullWidth = outputData.width;
918
1181
  const fullHeight = outputData.height;
919
- const bytesPerPixel = 16; // rgba32float = 4 × float32
920
1182
 
921
1183
  // Clamp tile to image bounds (edge tiles may extend past the image)
922
- const clampedW = Math.min( tile.width, fullWidth - tile.x );
923
- const clampedH = Math.min( tile.height, fullHeight - tile.y );
924
- if ( clampedW <= 0 || clampedH <= 0 ) return;
925
-
926
- const tileRowBytes = clampedW * bytesPerPixel;
927
- const tileByteSize = clampedW * clampedH * bytesPerPixel;
928
-
929
- const staging = device.createBuffer( {
930
- size: tileByteSize,
931
- usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST
932
- } );
933
-
934
- this._pendingStagingBuffers.add( staging );
935
-
936
- // Copy each tile row from its position in the full output buffer
937
- const enc = device.createCommandEncoder();
938
-
939
- for ( let row = 0; row < clampedH; row ++ ) {
940
-
941
- const srcOffset = ( ( tile.y + row ) * fullWidth + tile.x ) * bytesPerPixel;
942
- const dstOffset = row * tileRowBytes;
943
- enc.copyBufferToBuffer( outputData.data, srcOffset, staging, dstOffset, tileRowBytes );
944
-
945
- }
946
-
947
- device.queue.submit( [ enc.finish() ] );
948
-
949
- // Map and blit asynchronously — GPU copy is already queued. Track the whole chain so
950
- // done() can await every tile paint before resolving (replaces the full-frame readback).
951
- const tileBlit = staging.mapAsync( GPUMapMode.READ ).then( () => {
952
-
953
- const f32 = new Float32Array( staging.getMappedRange() );
954
- const tileImageData = new ImageData( clampedW, clampedH );
955
- const exposure = this._outputExposure();
956
- const saturation = this.getSaturation();
957
- const tmFn = TONE_MAP_FNS.get( this.getToneMapping() ) || TONE_MAP_FNS.get( ACESFilmicToneMapping );
958
- const alpha = this._cachedAlpha;
959
- const alphaW = this._cachedAlphaWidth;
960
-
961
- for ( let i = 0, len = f32.length; i < len; i += 4 ) {
962
-
963
- // Exposure + saturation (pre-tonemap, matching Display)
964
- let er = f32[ i ] * exposure, eg = f32[ i + 1 ] * exposure, eb = f32[ i + 2 ] * exposure;
965
- if ( saturation !== 1.0 ) {
1184
+ const width = Math.min( tile.width, fullWidth - tile.x );
1185
+ const height = Math.min( tile.height, fullHeight - tile.y );
1186
+ if ( width <= 0 || height <= 0 ) return;
966
1187
 
967
- _tmOut[ 0 ] = er; _tmOut[ 1 ] = eg; _tmOut[ 2 ] = eb;
968
- applySaturation( _tmOut, saturation );
969
- er = _tmOut[ 0 ]; eg = _tmOut[ 1 ]; eb = _tmOut[ 2 ];
1188
+ const rect = { x: tile.x, y: tile.y, width, height };
970
1189
 
971
- }
1190
+ // Track the whole chain so done() can await every tile paint before resolving.
1191
+ const tileBlit = this._packAndBlit( outputData.data, fullWidth, rect ).then( () => {
972
1192
 
973
- tmFn( er, eg, eb, 1.0, _tmOut );
974
- tileImageData.data[ i ] = linearToSRGB( _tmOut[ 0 ] ) * 255 + 0.5 | 0;
975
- tileImageData.data[ i + 1 ] = linearToSRGB( _tmOut[ 1 ] ) * 255 + 0.5 | 0;
976
- tileImageData.data[ i + 2 ] = linearToSRGB( _tmOut[ 2 ] ) * 255 + 0.5 | 0;
977
-
978
- if ( alpha ) {
979
-
980
- const px = ( i >> 2 ) % clampedW;
981
- const py = ( i >> 2 ) / clampedW | 0;
982
- tileImageData.data[ i + 3 ] = alpha[ ( tile.y + py ) * alphaW + tile.x + px ];
983
-
984
- } else {
985
-
986
- tileImageData.data[ i + 3 ] = 255;
987
-
988
- }
989
-
990
- }
991
-
992
- staging.unmap();
993
- staging.destroy();
994
- this._pendingStagingBuffers.delete( staging );
995
- this.ctx.putImageData( tileImageData, tile.x, tile.y );
996
-
997
- // Emit tile progress for OverlayManager's TileHelper
998
1193
  this.dispatchEvent( {
999
1194
  type: 'tileProgress',
1000
- tile: { x: tile.x, y: tile.y, width: clampedW, height: clampedH },
1195
+ tile: rect,
1001
1196
  imageWidth: fullWidth,
1002
- imageHeight: fullHeight
1197
+ imageHeight: fullHeight,
1198
+ continuous
1003
1199
  } );
1004
1200
 
1005
- } ).catch( () => {
1006
-
1007
- // mapAsync rejected (abort or GPU lost) — destroy the buffer
1008
- staging.destroy();
1009
- this._pendingStagingBuffers.delete( staging );
1010
-
1011
- } );
1201
+ } ).catch( () => { /* aborted or GPU lost — _packAndBlit already released */ } );
1012
1202
 
1013
1203
  this._pendingTileBlits.push( tileBlit );
1014
1204
 
@@ -1020,73 +1210,19 @@ export class OIDNDenoiser extends EventDispatcher {
1020
1210
  }
1021
1211
 
1022
1212
  /**
1023
- * Reads a GPUBuffer (oidn-web output, rgba32float linear) back to CPU via a staging buffer,
1024
- * applies exposure * pow(4) + ACES filmic tonemap + sRGB gamma 2.2, then draws to the 2D canvas.
1213
+ * Degenerate fallback when no per-tile progress was emitted: one authoritative full paint.
1025
1214
  * @param {{ data: GPUBuffer, width: number, height: number }} output
1026
1215
  */
1027
1216
  async _displayGPUOutput( { data: gpuBuffer, width, height } ) {
1028
1217
 
1029
- const device = this.gpuDevice;
1030
- if ( ! device ) {
1218
+ if ( ! this.gpuDevice ) {
1031
1219
 
1032
1220
  log.error( 'gpuDevice not available for output readback' );
1033
1221
  return;
1034
1222
 
1035
1223
  }
1036
1224
 
1037
- const byteSize = width * height * 4 * 4; // rgba32float = 16 bytes/pixel
1038
-
1039
- // Staging buffer with MAP_READ so we can copy the output into it and read from CPU
1040
- const stagingBuffer = device.createBuffer( {
1041
- label: 'oidn-output-staging',
1042
- size: byteSize,
1043
- usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST
1044
- } );
1045
-
1046
- try {
1047
-
1048
- // Queue a copy from the oidn output buffer (STORAGE|COPY_SRC) to staging
1049
- const encoder = device.createCommandEncoder( { label: 'oidn-readback' } );
1050
- encoder.copyBufferToBuffer( gpuBuffer, 0, stagingBuffer, 0, byteSize );
1051
- device.queue.submit( [ encoder.finish() ] );
1052
-
1053
- await stagingBuffer.mapAsync( GPUMapMode.READ );
1054
- const float32 = new Float32Array( stagingBuffer.getMappedRange() );
1055
-
1056
- const imageData = new ImageData( width, height );
1057
- const exposure = this._outputExposure();
1058
- const saturation = this.getSaturation();
1059
- const tmFn = TONE_MAP_FNS.get( this.getToneMapping() ) || TONE_MAP_FNS.get( ACESFilmicToneMapping );
1060
- const alpha = this._cachedAlpha;
1061
-
1062
- for ( let i = 0, len = float32.length; i < len; i += 4 ) {
1063
-
1064
- // Exposure + saturation (pre-tonemap, matching Display)
1065
- let er = float32[ i ] * exposure, eg = float32[ i + 1 ] * exposure, eb = float32[ i + 2 ] * exposure;
1066
- if ( saturation !== 1.0 ) {
1067
-
1068
- _tmOut[ 0 ] = er; _tmOut[ 1 ] = eg; _tmOut[ 2 ] = eb;
1069
- applySaturation( _tmOut, saturation );
1070
- er = _tmOut[ 0 ]; eg = _tmOut[ 1 ]; eb = _tmOut[ 2 ];
1071
-
1072
- }
1073
-
1074
- tmFn( er, eg, eb, 1.0, _tmOut );
1075
- imageData.data[ i ] = linearToSRGB( _tmOut[ 0 ] ) * 255 + 0.5 | 0;
1076
- imageData.data[ i + 1 ] = linearToSRGB( _tmOut[ 1 ] ) * 255 + 0.5 | 0;
1077
- imageData.data[ i + 2 ] = linearToSRGB( _tmOut[ 2 ] ) * 255 + 0.5 | 0;
1078
- imageData.data[ i + 3 ] = alpha ? alpha[ i >> 2 ] : 255;
1079
-
1080
- }
1081
-
1082
- stagingBuffer.unmap();
1083
- this.ctx.putImageData( imageData, 0, 0 );
1084
-
1085
- } finally {
1086
-
1087
- stagingBuffer.destroy();
1088
-
1089
- }
1225
+ await this._packAndBlit( gpuBuffer, width, { x: 0, y: 0, width, height } );
1090
1226
 
1091
1227
  }
1092
1228
 
@@ -1094,6 +1230,7 @@ export class OIDNDenoiser extends EventDispatcher {
1094
1230
 
1095
1231
  // Cancel any start deferred during loading so a reset supersedes it.
1096
1232
  this._pendingStart = false;
1233
+ this._pendingStartContinuous = false;
1097
1234
 
1098
1235
  if ( ! this.enabled || ! this.state.isDenoising ) return;
1099
1236
 
@@ -1106,14 +1243,22 @@ export class OIDNDenoiser extends EventDispatcher {
1106
1243
  // Restore input visibility
1107
1244
  this.input.style.opacity = '1';
1108
1245
 
1109
- // Reset denoising state and dispatch end event
1246
+ // No 'end' here: the aborted execute()'s finally always runs and dispatches one, tagged
1247
+ // with that run's `continuous`. Dispatching here too emitted a second, untagged end —
1248
+ // a cancelled cadence run announcing itself as the denoise that finishes a render.
1110
1249
  this.state.isDenoising = false;
1111
- this.dispatchEvent( { type: 'end' } );
1112
1250
 
1113
1251
  log.debug( 'denoise aborted' );
1114
1252
 
1115
1253
  }
1116
1254
 
1255
+ // Call whenever the output canvas stops holding a valid frame — hidden on reset, or resized.
1256
+ invalidateLatch() {
1257
+
1258
+ this._hasLatchedFrame = false;
1259
+
1260
+ }
1261
+
1117
1262
  setSize( width, height ) {
1118
1263
 
1119
1264
  if ( width <= 0 || height <= 0 ) {
@@ -1124,6 +1269,7 @@ export class OIDNDenoiser extends EventDispatcher {
1124
1269
 
1125
1270
  this.output.width = width;
1126
1271
  this.output.height = height;
1272
+ this._hasLatchedFrame = false;
1127
1273
 
1128
1274
  // Reinitialize denoiser if tile size changes relative to image size
1129
1275
  this._setupUNetDenoiser().catch( error => {
@@ -1158,6 +1304,10 @@ export class OIDNDenoiser extends EventDispatcher {
1158
1304
  this.unet?.dispose();
1159
1305
  this._destroyGPUInputBuffers();
1160
1306
  this._colorScalePipeline = null;
1307
+ this._lumReducePipeline = null;
1308
+ this._lumFinalizePipeline = null;
1309
+ this._lumLayout = null;
1310
+ this._packPipeline = null;
1161
1311
 
1162
1312
  // Clean up DOM
1163
1313
  if ( this.output?.parentNode ) {