rayzee 11.5.0 → 11.6.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,4 +1,4 @@
1
- import { t as e } from "./EngineEvents-Ci8dVWCP.js";
1
+ import { t as e } from "./EngineEvents-CLckhYbL.js";
2
2
  import { a as t, i as n, r } from "./Logger-dDDGj9Av.js";
3
3
  import { f as i, v as a, y as o } from "./BufferLayout-xPRH9jYD.js";
4
4
  import { n as s } from "./EngineIssues-CIZnXHsF.js";
@@ -1594,7 +1594,7 @@ var vt = class {
1594
1594
  samples: this.samples.slice()
1595
1595
  };
1596
1596
  }
1597
- }, yt = "11.5.0", $ = 1e4, bt = 15e5;
1597
+ }, yt = "11.6.0", $ = 1e4, bt = 15e5;
1598
1598
  function xt(e, t, n = $) {
1599
1599
  return e >= n && t / bt < e / 3;
1600
1600
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rayzee",
3
- "version": "11.5.0",
3
+ "version": "11.6.0",
4
4
  "type": "module",
5
5
  "description": "Real-time WebGPU path tracing engine built on Three.js",
6
6
  "main": "dist/rayzee.umd.js",
@@ -60,7 +60,7 @@
60
60
  "three": ">=0.186.0"
61
61
  },
62
62
  "optionalDependencies": {
63
- "oidn-web": ">=0.4.0",
63
+ "oidn-web": ">=0.5.0",
64
64
  "onnxruntime-web": ">=1.0.0"
65
65
  }
66
66
  }
@@ -14,6 +14,8 @@ export const EngineEvents = {
14
14
  // Denoiser
15
15
  DENOISING_START: 'engine:denoisingStart',
16
16
  DENOISING_END: 'engine:denoisingEnd',
17
+ // The denoiser fetches and builds its network ({ loading }); a denoise asked for meanwhile waits for it.
18
+ DENOISER_LOADING: 'engine:denoiserLoading',
17
19
 
18
20
  // Upscaler
19
21
  UPSCALING_START: 'engine:upscalingStart',
@@ -4,9 +4,22 @@ import { createLogger, fmt } from '../utils/Logger.js';
4
4
 
5
5
  const log = createLogger( 'oidn' );
6
6
 
7
- // The tile alignment oidn-web's own fitTileDimension uses (its tileScheduler.js).
7
+ // The tile alignment oidn-web's own planner uses (its tileScheduler.js).
8
8
  const OIDN_TILE_ALIGNMENT = 16;
9
9
 
10
+ // Mirrored around the frame: at the network's own boundary (zero padding, or oidn-web's repeated
11
+ // edge pixel) the outer pixels come out worse than not denoising at all. A multiple of 16 keeps
12
+ // the frame on the network's pooling grid, so pixels away from the edges denoise as before.
13
+ const OIDN_BORDER = 16;
14
+
15
+ /** The buffer oidn-web reads: the frame inside a mirrored border, both sides a multiple of 16. */
16
+ function oidnInputSize( width, height ) {
17
+
18
+ const align = n => Math.ceil( ( n + 2 * OIDN_BORDER ) / OIDN_TILE_ALIGNMENT ) * OIDN_TILE_ALIGNMENT;
19
+ return { width: align( width ), height: align( height ) };
20
+
21
+ }
22
+
10
23
  let _initUNetFromBuffer = null;
11
24
  async function getInitUNetFromBuffer() {
12
25
 
@@ -42,11 +55,8 @@ const MODEL_CONFIG = {
42
55
  DEFAULT_OPTIONS: {
43
56
  enableOIDN: true,
44
57
  oidnQuality: 'fast',
45
- // A cap, not a fixed size — the effective tile is min( max( w, h ), this ), so a frame
46
- // that fits in one tile pays no overlap padding at all. Every tile otherwise runs at
47
- // tileSize + 2*overlap (96px, 112 for _large): at 1024²/high, 4x512 tiles measured
48
- // 422ms against 229ms for one 1024 tile. 1024 caps the one-time activation
49
- // allocation at ~430MB; beyond it larger frames tile and stay bounded.
58
+ // A cap on the frame, not a tile size: a frame that fits is one tile, with no overlap.
59
+ // It bounds the network's activation memory (~430 MB at 1024²).
50
60
  tileSize: 1024
51
61
  }
52
62
  };
@@ -89,12 +99,13 @@ fn reduce( @builtin(global_invocation_id) gid: vec3<u32>,
89
99
  @builtin(local_invocation_id) lid: vec3<u32>,
90
100
  @builtin(workgroup_id) wid: vec3<u32> ) {
91
101
 
102
+ // x: frame pixels, y: frame width, z: buffer width, w: border. The border is not metered.
92
103
  let total = params.x;
93
104
  var sum = 0.0;
94
105
  var i = gid.x;
95
106
  loop {
96
107
  if ( i >= total ) { break; }
97
- let c = col[ i ].xyz;
108
+ let c = col[ ( i / params.y + params.w ) * params.z + i % params.y + params.w ].xyz;
98
109
  let lum = 0.212671 * c.r + 0.71516 * c.g + 0.072169 * c.b;
99
110
  sum = sum + log2( max( lum, 0.0 ) + 0.0001 );
100
111
  i = i + WG * GROUPS;
@@ -155,7 +166,7 @@ struct UnpackParams {
155
166
  tileY: u32,
156
167
  tileW: u32,
157
168
  tileH: u32,
158
- pad0: u32,
169
+ border: u32,
159
170
  pad1: u32,
160
171
  pad2: u32,
161
172
  };
@@ -173,7 +184,7 @@ fn main( @builtin(global_invocation_id) gid: vec3<u32> ) {
173
184
 
174
185
  let px = P.tileX + gid.x;
175
186
  let py = P.tileY + gid.y;
176
- let si = py * P.srcWidth + px;
187
+ let si = ( py + P.border ) * P.srcWidth + px + P.border;
177
188
 
178
189
  // scaleBuf is the autoexposure factor the input was multiplied by; dividing it out returns
179
190
  // the renderer's units. Alpha rides through from the path tracer so a transparent background
@@ -186,13 +197,20 @@ const PACK_WG_SIZE = 8;
186
197
  const PACK_WGSL = /* wgsl */`
187
198
  @group(0) @binding(0) var src: texture_2d<f32>;
188
199
  @group(0) @binding(1) var<storage, read_write> dst: array<vec4<f32>>;
189
- @group(0) @binding(2) var<uniform> size: vec2<u32>;
200
+ @group(0) @binding(2) var<uniform> size: vec4<u32>; // buffer w, h; frame w, h
201
+
202
+ fn mirror( i: i32, n: i32 ) -> i32 {
203
+ let m = abs( i );
204
+ return clamp( select( m, 2 * n - 2 - m, m >= n ), 0, n - 1 );
205
+ }
190
206
 
191
207
  @compute @workgroup_size(${PACK_WG_SIZE}, ${PACK_WG_SIZE})
192
208
  fn main( @builtin(global_invocation_id) gid: vec3<u32> ) {
193
209
 
194
210
  if ( gid.x >= size.x || gid.y >= size.y ) { return; }
195
- dst[ gid.y * size.x + gid.x ] = textureLoad( src, gid.xy, 0 );
211
+ let p = vec2<i32>( gid.xy ) - ${OIDN_BORDER};
212
+ let s = vec2<i32>( mirror( p.x, i32( size.z ) ), mirror( p.y, i32( size.w ) ) );
213
+ dst[ gid.y * size.x + gid.x ] = textureLoad( src, s, 0 );
196
214
  }
197
215
  `;
198
216
 
@@ -223,8 +241,9 @@ export class OIDNDenoiser extends EventDispatcher {
223
241
 
224
242
  // Cached GPU storage buffers for texture→buffer copies (reused across denoise calls)
225
243
  this._gpuInputBuffers = { color: null, albedo: null, normal: null };
244
+ // The frame the buffers hold; they are allocated at oidnInputSize() of it.
226
245
  this._gpuInputBufferSize = { width: 0, height: 0 };
227
- // Packs widths copyTextureToBuffer cannot take; see _beginPack.
246
+ // Packs the frame and its mirrored border; see _beginPack.
228
247
  this._packPipeline = null;
229
248
  this._packLayout = null;
230
249
  this._packParams = null;
@@ -366,16 +385,14 @@ export class OIDNDenoiser extends EventDispatcher {
366
385
  const longest = Math.max( this._renderWidth, this._renderHeight );
367
386
  if ( ! longest ) return this.maxTileSize;
368
387
 
369
- // Rounded up to the model's 16-pixel alignment, not clamped to the exact frame. The
370
- // library's own fitTileDimension aligns the size up and then clamps it back to whatever
371
- // cap it is handed, so handing it the raw frame size hands it an unaligned tile — and an
372
- // unaligned tile measured 4x the cost of the whole denoise: 336 ms at 900x900 against
373
- // 86 ms once aligned, with 896x896 unaffected either way.
374
- const aligned = Math.ceil( longest / OIDN_TILE_ALIGNMENT ) * OIDN_TILE_ALIGNMENT;
375
- if ( aligned <= this.maxTileSize ) return aligned;
388
+ // The cap is on the frame, so the border never splits a frame that fits one tile.
389
+ if ( longest <= this.maxTileSize ) {
390
+
391
+ const input = oidnInputSize( this._renderWidth, this._renderHeight );
392
+ return Math.max( input.width, input.height );
393
+
394
+ }
376
395
 
377
- // The cap has to be aligned too, or a cap somebody picked by hand lands on the same slow
378
- // path. Rounded down, so it never rises above what the caller asked for.
379
396
  return Math.max( OIDN_TILE_ALIGNMENT, Math.floor( this.maxTileSize / OIDN_TILE_ALIGNMENT ) * OIDN_TILE_ALIGNMENT );
380
397
 
381
398
  }
@@ -470,7 +487,7 @@ export class OIDNDenoiser extends EventDispatcher {
470
487
 
471
488
  const params = this.backendParamsGetter();
472
489
  this.gpuDevice = params?.device ?? null;
473
- backendParams = params?.device ? params : undefined;
490
+ backendParams = params?.device ? { device: params.device } : undefined;
474
491
 
475
492
  }
476
493
 
@@ -648,8 +665,7 @@ export class OIDNDenoiser extends EventDispatcher {
648
665
 
649
666
  /**
650
667
  * GPU-native execution path. Copies render target textures into GPU storage buffers
651
- * via copyTextureToBuffer (GPU-only, no CPU roundtrip), then passes those buffers to
652
- * oidn-web's well-tested GPUBuffer path.
668
+ * (GPU-only, no CPU roundtrip), then passes those buffers to oidn-web's GPUBuffer path.
653
669
  *
654
670
  * Note: oidn-web's GPUTexture input path produces NaN outputs — using GPUBuffer instead.
655
671
  */
@@ -688,71 +704,55 @@ export class OIDNDenoiser extends EventDispatcher {
688
704
 
689
705
  // Autoexposure and the pre-multiply it drives, both on the GPU and both queued behind the
690
706
  // copies above. Nothing is awaited here — an await would drain the queue before the UNet.
691
- this._computeInputScale( device, width * height );
692
- this._applyColorScale( device, width * height );
707
+ const input = oidnInputSize( width, height );
708
+ this._computeInputScale( device, width, height );
709
+ this._applyColorScale( device, input.width * input.height );
693
710
 
694
711
  // A final denoise is shown tile by tile, so the tiles still to come must show this render
695
712
  // rather than the blank or previous-view picture the output holds between runs.
696
713
  if ( ! continuous && ! this._hasOutput ) {
697
714
 
698
- this._unpackToTexture( this._gpuInputBuffers.color, width, { x: 0, y: 0, width, height }, false );
715
+ this._unpackToTexture( this._gpuInputBuffers.color, { x: 0, y: 0, width, height }, false );
699
716
 
700
717
  }
701
718
 
702
- // Pass GPU storage buffers to oidn-web (GPUBuffer path, well-tested)
719
+ const image = data => ( { data, width: input.width, height: input.height } );
703
720
  const config = {
704
- color: { data: this._gpuInputBuffers.color, width, height },
705
- albedo: { data: this._gpuInputBuffers.albedo, width, height },
706
- normal: { data: this._gpuInputBuffers.normal, width, height }
721
+ color: image( this._gpuInputBuffers.color ),
722
+ albedo: image( this._gpuInputBuffers.albedo ),
723
+ normal: image( this._gpuInputBuffers.normal )
707
724
  };
708
725
 
709
726
  return this._executeWithAbortGPU( config, continuous );
710
727
 
711
728
  }
712
729
 
713
- /** Copies the render's colour, albedo and normal into the tightly packed buffers oidn-web reads. @private */
730
+ /** Copies the render's colour, albedo and normal into the buffers oidn-web reads, border included. @private */
714
731
  _copyInputs( device, textures, width, height ) {
715
732
 
716
- // copyTextureToBuffer takes only 256-byte-aligned rows; any other width packs on the GPU.
733
+ const input = oidnInputSize( width, height );
717
734
  const encoder = device.createCommandEncoder( { label: 'oidn-tex-to-buf' } );
718
- const tightRowBytes = width * 16; // rgba32float
719
- const pass = tightRowBytes % 256 === 0 ? null : this._beginPack( device, encoder, width, height );
720
-
721
- const copyTex = ( tex, tightBuf ) => {
722
-
723
- if ( pass ) {
724
-
725
- pass.setBindGroup( 0, device.createBindGroup( {
726
- layout: this._packLayout,
727
- entries: [
728
- { binding: 0, resource: tex.createView() },
729
- { binding: 1, resource: { buffer: tightBuf } },
730
- { binding: 2, resource: { buffer: this._packParams } },
731
- ],
732
- } ) );
733
- pass.dispatchWorkgroups( Math.ceil( width / PACK_WG_SIZE ), Math.ceil( height / PACK_WG_SIZE ) );
734
-
735
- } else {
736
-
737
- encoder.copyTextureToBuffer(
738
- { texture: tex, mipLevel: 0 },
739
- { buffer: tightBuf, offset: 0, bytesPerRow: tightRowBytes, rowsPerImage: height },
740
- { width, height, depthOrArrayLayers: 1 }
741
- );
742
-
743
- }
744
-
745
- };
735
+ const pass = this._beginPack( device, encoder, input, width, height );
746
736
 
747
- copyTex( textures.color, this._gpuInputBuffers.color );
748
- copyTex( textures.albedo, this._gpuInputBuffers.albedo );
749
737
  // The normal stays [0,1]-encoded. This contradicts OIDN's API ("must be in the [-1,1]
750
738
  // range") but is right for this port: OIDN's own getNormal (cpu_input_process.isph) does
751
739
  // `value*0.5+0.5` before the network, and oidn-web omits that remap. Decoding to [-1,1]
752
740
  // measured 0.878 -> 2.439 denoise ratio at 64 spp. Do not "fix" it.
753
- copyTex( textures.normal, this._gpuInputBuffers.normal );
741
+ for ( const name of [ 'color', 'albedo', 'normal' ] ) {
754
742
 
755
- pass?.end();
743
+ pass.setBindGroup( 0, device.createBindGroup( {
744
+ layout: this._packLayout,
745
+ entries: [
746
+ { binding: 0, resource: textures[ name ].createView() },
747
+ { binding: 1, resource: { buffer: this._gpuInputBuffers[ name ] } },
748
+ { binding: 2, resource: { buffer: this._packParams } },
749
+ ],
750
+ } ) );
751
+ pass.dispatchWorkgroups( Math.ceil( input.width / PACK_WG_SIZE ), Math.ceil( input.height / PACK_WG_SIZE ) );
752
+
753
+ }
754
+
755
+ pass.end();
756
756
  device.queue.submit( [ encoder.finish() ] );
757
757
 
758
758
  }
@@ -760,7 +760,6 @@ export class OIDNDenoiser extends EventDispatcher {
760
760
  /**
761
761
  * Creates or recreates the GPU storage buffers used as oidn-web inputs.
762
762
  * Reuses existing buffers if the resolution hasn't changed.
763
- * Usage: COPY_DST (for copyTextureToBuffer) | STORAGE (for oidn-web WGSL read) | COPY_SRC
764
763
  */
765
764
  _ensureGPUInputBuffers( width, height ) {
766
765
 
@@ -771,7 +770,8 @@ export class OIDNDenoiser extends EventDispatcher {
771
770
  this._destroyGPUInputBuffers();
772
771
 
773
772
  const device = this.gpuDevice;
774
- const byteSize = width * height * 16; // rgba32float, tightly packed for oidn-web
773
+ const input = oidnInputSize( width, height );
774
+ const byteSize = input.width * input.height * 16; // rgba32float
775
775
  const usage = GPUBufferUsage.COPY_DST | GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC;
776
776
 
777
777
  this._gpuInputBuffers.color = device.createBuffer( { label: 'oidn-in-color', size: byteSize, usage } );
@@ -781,8 +781,8 @@ export class OIDNDenoiser extends EventDispatcher {
781
781
 
782
782
  }
783
783
 
784
- /** Opens the compute pass that packs unaligned rows; one dispatch per texture follows. @private */
785
- _beginPack( device, encoder, width, height ) {
784
+ /** Opens the compute pass that packs a frame into its bordered buffer; one dispatch per texture follows. @private */
785
+ _beginPack( device, encoder, input, width, height ) {
786
786
 
787
787
  if ( ! this._packPipeline ) {
788
788
 
@@ -803,12 +803,8 @@ export class OIDNDenoiser extends EventDispatcher {
803
803
 
804
804
  }
805
805
 
806
- if ( ! this._packParams ) {
807
-
808
- this._packParams = device.createBuffer( { label: 'oidn-pack-size', size: 8, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST } );
809
- device.queue.writeBuffer( this._packParams, 0, new Uint32Array( [ width, height ] ) );
810
-
811
- }
806
+ this._packParams ??= device.createBuffer( { label: 'oidn-pack-size', size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST } );
807
+ device.queue.writeBuffer( this._packParams, 0, new Uint32Array( [ input.width, input.height, width, height ] ) );
812
808
 
813
809
  const pass = encoder.beginComputePass( { label: 'oidn-pack' } );
814
810
  pass.setPipeline( this._packPipeline );
@@ -953,7 +949,7 @@ export class OIDNDenoiser extends EventDispatcher {
953
949
  }
954
950
 
955
951
  /** Writes OIDN's autoexposure scale into `_inputScaleBuffer`. Never read back. */
956
- _computeInputScale( device, pixelCount ) {
952
+ _computeInputScale( device, width, height ) {
957
953
 
958
954
  if ( ! this._ensureScalePipelines( device ) ) return;
959
955
 
@@ -964,7 +960,8 @@ export class OIDNDenoiser extends EventDispatcher {
964
960
 
965
961
  }
966
962
 
967
- device.queue.writeBuffer( this._lumParams, 0, new Uint32Array( [ pixelCount, 0, 0, 0 ] ) );
963
+ const input = oidnInputSize( width, height );
964
+ device.queue.writeBuffer( this._lumParams, 0, new Uint32Array( [ width * height, width, input.width, OIDN_BORDER ] ) );
968
965
 
969
966
  const encoder = device.createCommandEncoder( { label: 'oidn-autoexposure' } );
970
967
 
@@ -1078,12 +1075,11 @@ export class OIDNDenoiser extends EventDispatcher {
1078
1075
  /**
1079
1076
  * Writes a rectangle of the denoised buffer into the output picture, on the card.
1080
1077
  *
1081
- * @param {GPUBuffer} src - rgba32float, full image, scaled by the input autoexposure
1082
- * @param {number} srcWidth
1083
- * @param {{x: number, y: number, width: number, height: number}} rect
1078
+ * @param {GPUBuffer} src - rgba32float, the bordered input size, scaled by the input autoexposure
1079
+ * @param {{x: number, y: number, width: number, height: number}} rect - in frame pixels
1084
1080
  * @param {boolean} [denoised=true] - false for the raw render laid under a tiled run
1085
1081
  */
1086
- _unpackToTexture( src, srcWidth, rect, denoised = true ) {
1082
+ _unpackToTexture( src, rect, denoised = true ) {
1087
1083
 
1088
1084
  const device = this.gpuDevice;
1089
1085
  if ( ! device || ! this._ensureScalePipelines( device ) ) return;
@@ -1101,7 +1097,8 @@ export class OIDNDenoiser extends EventDispatcher {
1101
1097
 
1102
1098
  try {
1103
1099
 
1104
- device.queue.writeBuffer( params, 0, new Uint32Array( [ srcWidth, x, y, width, height, 0, 0, 0 ] ) );
1100
+ const srcWidth = oidnInputSize( this._gpuInputBufferSize.width, this._gpuInputBufferSize.height ).width;
1101
+ device.queue.writeBuffer( params, 0, new Uint32Array( [ srcWidth, x, y, width, height, OIDN_BORDER, 0, 0 ] ) );
1105
1102
 
1106
1103
  const bindGroup = device.createBindGroup( {
1107
1104
  label: 'oidn-output-unpack',
@@ -1174,13 +1171,23 @@ export class OIDNDenoiser extends EventDispatcher {
1174
1171
 
1175
1172
  };
1176
1173
 
1177
- this.state.abortController.signal.addEventListener( 'abort', abortHandler, { once: true } );
1174
+ const signal = this.state.abortController.signal;
1175
+ signal.addEventListener( 'abort', abortHandler, { once: true } );
1178
1176
 
1179
1177
  abortDenoise = this.unet.tileExecute( {
1180
1178
  ...config,
1179
+ // Refreshes share the GPU with the render; the closing denoise has it to itself.
1180
+ scheduling: continuous ? 'animation-frame' : 'event-loop',
1181
+ error: ( reason ) => {
1182
+
1183
+ signal.removeEventListener( 'abort', abortHandler );
1184
+ abortDenoise = null;
1185
+ reject( reason );
1186
+
1187
+ },
1181
1188
  done: async ( output ) => {
1182
1189
 
1183
- this.state.abortController.signal.removeEventListener( 'abort', abortHandler );
1190
+ signal.removeEventListener( 'abort', abortHandler );
1184
1191
  abortDenoise = null;
1185
1192
 
1186
1193
  try {
@@ -1203,28 +1210,28 @@ export class OIDNDenoiser extends EventDispatcher {
1203
1210
  progress: ( outputData, _tileData, tile ) => {
1204
1211
 
1205
1212
  // oidn-web GPU path: tileData is null, but outputData holds the assembled
1206
- // full-image buffer updated after each tile. The pack pass reads that tile's
1213
+ // full-image buffer updated after each tile. The unpack pass reads that tile's
1207
1214
  // rectangle straight out of it, so there are no row-by-row buffer copies.
1208
1215
  if ( ! outputData?.data || ! tile ) return;
1209
1216
 
1210
- const fullWidth = outputData.width;
1211
- const fullHeight = outputData.height;
1212
-
1213
- // Clamp tile to image bounds (edge tiles may extend past the image)
1214
- const width = Math.min( tile.width, fullWidth - tile.x );
1215
- const height = Math.min( tile.height, fullHeight - tile.y );
1217
+ // The tile in frame pixels, without the border.
1218
+ const { width: frameWidth, height: frameHeight } = this._gpuInputBufferSize;
1219
+ const x = Math.max( tile.x - OIDN_BORDER, 0 );
1220
+ const y = Math.max( tile.y - OIDN_BORDER, 0 );
1221
+ const width = Math.min( tile.x + tile.width - OIDN_BORDER, frameWidth ) - x;
1222
+ const height = Math.min( tile.y + tile.height - OIDN_BORDER, frameHeight ) - y;
1216
1223
  if ( width <= 0 || height <= 0 ) return;
1217
1224
 
1218
- const rect = { x: tile.x, y: tile.y, width, height };
1225
+ const rect = { x, y, width, height };
1219
1226
 
1220
- this._unpackToTexture( outputData.data, fullWidth, rect );
1227
+ this._unpackToTexture( outputData.data, rect );
1221
1228
  this._tilesWritten ++;
1222
1229
 
1223
1230
  this.dispatchEvent( {
1224
1231
  type: 'tileProgress',
1225
1232
  tile: rect,
1226
- imageWidth: fullWidth,
1227
- imageHeight: fullHeight,
1233
+ imageWidth: frameWidth,
1234
+ imageHeight: frameHeight,
1228
1235
  continuous
1229
1236
  } );
1230
1237
 
@@ -1237,9 +1244,9 @@ export class OIDNDenoiser extends EventDispatcher {
1237
1244
 
1238
1245
  /**
1239
1246
  * Degenerate fallback when no per-tile progress was emitted: one authoritative full paint.
1240
- * @param {{ data: GPUBuffer, width: number, height: number }} output
1247
+ * @param {{ data: GPUBuffer }} output
1241
1248
  */
1242
- _displayGPUOutput( { data: gpuBuffer, width, height } ) {
1249
+ _displayGPUOutput( { data: gpuBuffer } ) {
1243
1250
 
1244
1251
  if ( ! this.gpuDevice ) {
1245
1252
 
@@ -1248,7 +1255,8 @@ export class OIDNDenoiser extends EventDispatcher {
1248
1255
 
1249
1256
  }
1250
1257
 
1251
- this._unpackToTexture( gpuBuffer, width, { x: 0, y: 0, width, height } );
1258
+ const { width, height } = this._gpuInputBufferSize;
1259
+ this._unpackToTexture( gpuBuffer, { x: 0, y: 0, width, height } );
1252
1260
 
1253
1261
  }
1254
1262
 
@@ -252,7 +252,7 @@ export class PathTracerApp extends RayzeeRenderer {
252
252
  this._addTrackedListener( this.timeline, EngineEvents.TIMELINE_CHANGED, ( e ) => this.dispatchEvent( e ) );
253
253
 
254
254
  this._forwardEvents( this.denoisingManager, [
255
- EngineEvents.DENOISING_START, EngineEvents.DENOISING_END,
255
+ EngineEvents.DENOISING_START, EngineEvents.DENOISING_END, EngineEvents.DENOISER_LOADING,
256
256
  EngineEvents.UPSCALING_START, EngineEvents.UPSCALING_PROGRESS, EngineEvents.UPSCALING_END,
257
257
  EngineEvents.RESOLUTION_CHANGED,
258
258
  ] );
@@ -844,7 +844,7 @@ export class PathTracerApp extends RayzeeRenderer {
844
844
 
845
845
  }
846
846
 
847
- // OIDN as the live denoiser rebuilds its network on every size change, so it keeps full size.
847
+ // Live OIDN keeps full size: a resize drops its motion history and shows the raw render until it denoises again.
848
848
  _interactionRenderScale() {
849
849
 
850
850
  if ( this.denoisingManager?.continuousDenoise ) return 1;
@@ -14,6 +14,23 @@ import { RenderTarget, FloatType, RGBAFormat, NearestFilter } from 'three';
14
14
  import { QuadMesh, NodeMaterial } from 'three/webgpu';
15
15
  import { texture as tslTexture, screenCoordinate, ivec2 } from 'three/tsl';
16
16
 
17
+ /**
18
+ * An RGBA render target's pixels without the 256-byte row padding WebGPU copies them with.
19
+ * @returns {Promise<TypedArray>} `width * height * 4`, top row first
20
+ */
21
+ export async function readPixels( renderer, target, width, height, index = 0 ) {
22
+
23
+ const data = await renderer.readRenderTargetPixelsAsync( target, 0, 0, width, height, index );
24
+ const row = width * 4;
25
+ const stride = Math.ceil( row * data.BYTES_PER_ELEMENT / 256 ) * 256 / data.BYTES_PER_ELEMENT;
26
+ if ( stride === row ) return data;
27
+
28
+ const tight = new data.constructor( row * height );
29
+ for ( let y = 0; y < height; y ++ ) tight.set( data.subarray( y * stride, y * stride + row ), y * row );
30
+ return tight;
31
+
32
+ }
33
+
17
34
  export class TextureReadback {
18
35
 
19
36
  constructor( renderer ) {
@@ -48,7 +65,7 @@ export class TextureReadback {
48
65
 
49
66
  try {
50
67
 
51
- return await this.renderer.readRenderTargetPixelsAsync( this.draw( source, width, height ), 0, 0, width, height );
68
+ return await readPixels( this.renderer, this.draw( source, width, height ), width, height );
52
69
 
53
70
  } finally {
54
71
 
@@ -15,7 +15,7 @@ import { usesTextureCoordinates } from './Processor/GeometryExtractor.js';
15
15
  import { MAX_STORAGE_TEXTURE_SIZE, MAX_RESERVABLE_RENDER_SIZE, setReservedRenderSize } from './Processor/StorageTexturePool.js';
16
16
  import { updateStats, updateLoading, resetLoading, setStatusCallback, getDisplaySamples, disposeObjectFromMemory, disposeRenderer } from './Processor/utils.js';
17
17
  import { BuildTimer } from './Processor/BuildTimer.js';
18
- import { TextureReadback } from './Processor/TextureReadback.js';
18
+ import { TextureReadback, readPixels } from './Processor/TextureReadback.js';
19
19
  import { createLogger, fmt } from './utils/Logger.js';
20
20
  import { EngineEvents, LEGACY_EVENT_NAMES } from './EngineEvents.js';
21
21
  import { IssueLog, ISSUE_CODES, EngineIssueError } from './EngineIssues.js';
@@ -2766,14 +2766,14 @@ export class RayzeeRenderer extends EventDispatcher {
2766
2766
  const fn = ! gpu && tone.gain ? await tone.gain.cpu() : null;
2767
2767
  const data = gpu ?? toneMapToRGBA8( shown
2768
2768
  ? await this._readTexture( shown.texture, width, height )
2769
- : await this.renderer.readRenderTargetPixelsAsync( target, 0, 0, width, height, 0 ), { ...tone, pixelGain: fn && { fn, width, height } } );
2769
+ : await readPixels( this.renderer, target, width, height ), { ...tone, pixelGain: fn && { fn, width, height } } );
2770
2770
  return { data, width, height, colorSpace, source: shown?.source ?? 'accumulation', toneMappedOn: gpu ? 'gpu' : 'cpu' };
2771
2771
 
2772
2772
  }
2773
2773
 
2774
2774
  const { data: linear, source: read } = source === 'display'
2775
2775
  ? await this._readDisplaySource( target, width, height )
2776
- : { data: await this.renderer.readRenderTargetPixelsAsync( target, 0, 0, width, height, 0 ), source: 'accumulation' };
2776
+ : { data: await readPixels( this.renderer, target, width, height ), source: 'accumulation' };
2777
2777
 
2778
2778
  // `colorSpace` stays 'linear' — callers branch on it. `workingSpace` is the new, additive
2779
2779
  // answer to "linear in what primaries", which only means something once a config is loaded.
@@ -3450,7 +3450,7 @@ export class RayzeeRenderer extends EventDispatcher {
3450
3450
  async _readDisplaySource( accumulation, width, height ) {
3451
3451
 
3452
3452
  const shown = this._displaySource( accumulation );
3453
- if ( ! shown ) return { data: await this.renderer.readRenderTargetPixelsAsync( accumulation, 0, 0, width, height, 0 ), source: 'accumulation' };
3453
+ if ( ! shown ) return { data: await readPixels( this.renderer, accumulation, width, height ), source: 'accumulation' };
3454
3454
  return { data: await this._readTexture( shown.texture, width, height ), source: shown.source };
3455
3455
 
3456
3456
  }
@@ -28,6 +28,7 @@ import {
28
28
  } from '../TSL/SortGlobalKernels.js';
29
29
  import { ENGINE_DEFAULTS } from '../EngineDefaults.js';
30
30
  import { MAX_STORAGE_TEXTURE_SIZE } from '../Processor/StorageTexturePool.js';
31
+ import { readPixels } from '../Processor/TextureReadback.js';
31
32
  import { ISSUE_CODES } from '../EngineIssues.js';
32
33
  import { createLogger, fmt } from '../utils/Logger.js';
33
34
  import { deviceMemoryGB } from '../Processor/HostMemory.js';
@@ -758,16 +759,7 @@ export class PathTracer extends PathTracerStage {
758
759
  const height = pool.renderHeight;
759
760
  const pixels = width * height;
760
761
 
761
- const readAttachment = async index => {
762
-
763
- const padded = await this.renderer.readRenderTargetPixelsAsync( pool.readTarget, 0, 0, width, height, index );
764
- const stride = Math.ceil( width * 16 / 256 ) * 64;
765
- if ( stride === width * 4 ) return new Float32Array( padded.buffer, padded.byteOffset, pixels * 4 ).slice();
766
- const tight = new Float32Array( pixels * 4 );
767
- for ( let y = 0; y < height; y ++ ) tight.set( padded.subarray( y * stride, y * stride + width * 4 ), y * width * 4 );
768
- return tight;
769
-
770
- };
762
+ const readAttachment = index => readPixels( this.renderer, pool.readTarget, width, height, index );
771
763
 
772
764
  const readBuffer = async ( attr, Type ) => attr ? new Type( await this.renderer.getArrayBufferAsync( attr, null, 0, pixels * 4 ) ) : null;
773
765
  const aux = this._auxGBufferEnabled && this._auxSamples > 0;
@@ -197,6 +197,7 @@ export class DenoisingManager extends EventDispatcher {
197
197
  this._denoiserEndHandler = null;
198
198
  this._denoiserTileHandler = null;
199
199
  this._denoiserErrorHandler = null;
200
+ this._denoiserLoadHandler = null;
200
201
  this._upscalerResChangedHandler = null;
201
202
  this._upscalerStartHandler = null;
202
203
  this._upscalerProgressHandler = null;
@@ -377,6 +378,12 @@ export class DenoisingManager extends EventDispatcher {
377
378
  this.denoiser.addEventListener( 'tileProgress', this._denoiserTileHandler );
378
379
  this.denoiser.addEventListener( 'error', this._denoiserErrorHandler );
379
380
 
381
+ // 'error' too: a failed load never sends 'loaded'. The first load starts inside the constructor, before these listen.
382
+ this._denoiserLoadHandler = e =>
383
+ this.dispatchEvent( { type: EngineEvents.DENOISER_LOADING, loading: e.type === 'loading' } );
384
+ for ( const type of [ 'loading', 'loaded', 'error' ] ) this.denoiser.addEventListener( type, this._denoiserLoadHandler );
385
+ if ( this.denoiser.state?.isLoading ) this.dispatchEvent( { type: EngineEvents.DENOISER_LOADING, loading: true } );
386
+
380
387
  }
381
388
 
382
389
  /**
@@ -1584,6 +1591,13 @@ export class DenoisingManager extends EventDispatcher {
1584
1591
  if ( this._denoiserEndHandler ) this.denoiser.removeEventListener( 'end', this._denoiserEndHandler );
1585
1592
  if ( this._denoiserTileHandler ) this.denoiser.removeEventListener( 'tileProgress', this._denoiserTileHandler );
1586
1593
  if ( this._denoiserErrorHandler ) this.denoiser.removeEventListener( 'error', this._denoiserErrorHandler );
1594
+ if ( this._denoiserLoadHandler ) {
1595
+
1596
+ for ( const type of [ 'loading', 'loaded', 'error' ] ) this.denoiser.removeEventListener( type, this._denoiserLoadHandler );
1597
+ if ( this.denoiser.state?.isLoading ) this.dispatchEvent( { type: EngineEvents.DENOISER_LOADING, loading: false } );
1598
+
1599
+ }
1600
+
1587
1601
  this.denoiser.dispose();
1588
1602
  this.denoiser = null;
1589
1603
 
@@ -1608,6 +1622,7 @@ export class DenoisingManager extends EventDispatcher {
1608
1622
  this._denoiserEndHandler = null;
1609
1623
  this._denoiserTileHandler = null;
1610
1624
  this._denoiserErrorHandler = null;
1625
+ this._denoiserLoadHandler = null;
1611
1626
  this._upscalerResChangedHandler = null;
1612
1627
  this._upscalerStartHandler = null;
1613
1628
  this._upscalerProgressHandler = null;