rayzee 7.23.2 → 7.24.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rayzee",
3
- "version": "7.23.2",
3
+ "version": "7.24.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",
@@ -38,7 +38,7 @@
38
38
  "three": ">=0.185.0"
39
39
  },
40
40
  "optionalDependencies": {
41
- "oidn-web": ">=0.3.0",
41
+ "oidn-web": ">=0.4.0",
42
42
  "onnxruntime-web": ">=1.0.0"
43
43
  }
44
44
  }
@@ -107,6 +107,8 @@ export class AIUpscaler extends EventDispatcher {
107
107
  this.hdr = false;
108
108
  this.scaleFactor = options.scaleFactor || 2;
109
109
  this.quality = options.quality || 'fast';
110
+ // refreshInput: () => void — re-renders the compositor so the WebGPU canvas is readable
111
+ this.refreshInput = options.refreshInput || null;
110
112
  this.tileSize = options.tileSize || MODEL_CONFIG.TILE_SIZE;
111
113
  this._tileSizeOverride = !! options.tileSize;
112
114
 
@@ -254,6 +256,14 @@ export class AIUpscaler extends EventDispatcher {
254
256
 
255
257
  }
256
258
 
259
+ // See OIDNDenoiser._revealOutput. Here the resize below also clears the bitmap.
260
+ _revealOutput() {
261
+
262
+ this.output.style.display = 'block';
263
+ this.input.style.opacity = '0';
264
+
265
+ }
266
+
257
267
  async execute() {
258
268
 
259
269
  if ( ! this.enabled ) return false;
@@ -261,10 +271,6 @@ export class AIUpscaler extends EventDispatcher {
261
271
  this.state.abortController = new AbortController();
262
272
  this.state.isUpscaling = true;
263
273
 
264
- // Show output canvas and hide input (matching OIDN lifecycle)
265
- this.input.style.opacity = '0';
266
- this.output.style.display = 'block';
267
-
268
274
  // Capture source image SYNCHRONOUSLY before any async work.
269
275
  // WebGPU canvas textures expire after each compositor frame,
270
276
  // so we must grab the pixels before awaiting model load.
@@ -292,6 +298,7 @@ export class AIUpscaler extends EventDispatcher {
292
298
  ctx.imageSmoothingEnabled = true;
293
299
  ctx.imageSmoothingQuality = 'high';
294
300
  ctx.drawImage( this._backupCanvas, 0, 0, outW, outH );
301
+ this._revealOutput();
295
302
 
296
303
  try {
297
304
 
@@ -467,10 +474,11 @@ export class AIUpscaler extends EventDispatcher {
467
474
 
468
475
  if ( sourceCanvas && sourceCanvas !== this.output ) {
469
476
 
470
- // Source is the WebGPU canvas — must copy to 2D canvas first.
471
- // WebGPU canvases expire their texture after each compositor frame,
472
- // so the caller (PathTracerApp) should re-render the display stage
473
- // before triggering the upscaler when OIDN is not used.
477
+ // Source is the WebGPU canvas — must copy to a 2D canvas first, and it only reads
478
+ // back non-empty straight after a compositor pass. Enforced here rather than left to
479
+ // the caller: any awaited GPU work in between silently yields a fully transparent
480
+ // capture, and the upscaler would then upscale nothing at all.
481
+ this.refreshInput?.();
474
482
  const offscreen = document.createElement( 'canvas' );
475
483
  offscreen.width = sourceCanvas.width;
476
484
  offscreen.height = sourceCanvas.height;
@@ -4,17 +4,11 @@ import { createLogger, fmt } from '../utils/Logger.js';
4
4
  const log = createLogger( 'oidn' );
5
5
 
6
6
  let _initUNetFromURL = null;
7
- let _tfEngine = null;
8
7
  async function getInitUNetFromURL() {
9
8
 
10
9
  if ( ! _initUNetFromURL ) {
11
10
 
12
- const [ oidnMod, tfMod ] = await Promise.all( [
13
- import( 'oidn-web' ),
14
- import( '@tensorflow/tfjs-core' )
15
- ] );
16
- _initUNetFromURL = oidnMod.initUNetFromURL;
17
- _tfEngine = tfMod.engine;
11
+ _initUNetFromURL = ( await import( 'oidn-web' ) ).initUNetFromURL;
18
12
 
19
13
  }
20
14
 
@@ -22,29 +16,6 @@ async function getInitUNetFromURL() {
22
16
 
23
17
  }
24
18
 
25
- // oidn-web caches its WebGPUBackend in TFJS's global ENGINE under 'webgpu-oidn'.
26
- // On dispose, drop it so the next instance binds to the new GPUDevice instead of
27
- // reusing the destroyed one (which would produce black tiles).
28
- function removeOidnTfjsBackend() {
29
-
30
- if ( ! _tfEngine ) return;
31
- try {
32
-
33
- const eng = _tfEngine();
34
- if ( eng?.registryFactory && 'webgpu-oidn' in eng.registryFactory ) {
35
-
36
- eng.removeBackend( 'webgpu-oidn' );
37
-
38
- }
39
-
40
- } catch ( e ) {
41
-
42
- log.warn( 'failed to clear cached TFJS backend', e );
43
-
44
- }
45
-
46
- }
47
-
48
19
  import { TONE_MAP_FNS, linearToSRGB, applySaturation } from '../Processor/ToneMapCPU.js';
49
20
  import { getAssetConfig } from '../AssetConfig.js';
50
21
 
@@ -52,21 +23,29 @@ import { getAssetConfig } from '../AssetConfig.js';
52
23
  const _tmOut = new Float32Array( 3 );
53
24
 
54
25
  const MODEL_CONFIG = {
55
- // No cleanAux flag in oidn-web, so the blob is it — and must match what setCleanAuxNormal
56
- // feeds: `fast` point-samples the normal (noisy aux), balance/high accumulate it. calb_cnrm
57
- // is also the only model with a `_large` blob.
26
+ // No cleanAux flag in oidn-web, so the blob is it — and it must match what setCleanAuxNormal
27
+ // feeds the aux MRT: an `alb_nrm` model wants the point-sampled normal, a `calb_cnrm` model
28
+ // wants the accumulated one. `cleanAux` is carried here rather than inferred from the tier
29
+ // name, which stopped being possible once a tier other than `fast` used a noisy-aux model.
30
+ //
31
+ // `fast-clean` is the same topology and channel width as `fast` (both 641 KB, widest conv 64)
32
+ // so it costs the same to run, and only differs in being trained for a clean guide.
58
33
  QUALITY_MODELS: {
59
- fast: 'rt_hdr_alb_nrm_small',
60
- balance: 'rt_hdr_calb_cnrm',
61
- high: 'rt_hdr_calb_cnrm_large'
34
+ fast: { model: 'rt_hdr_alb_nrm_small', cleanAux: false },
35
+ 'fast-clean': { model: 'rt_hdr_calb_cnrm_small', cleanAux: true },
36
+ balance: { model: 'rt_hdr_calb_cnrm', cleanAux: true },
37
+ high: { model: 'rt_hdr_calb_cnrm_large', cleanAux: true }
62
38
  },
63
39
  DEFAULT_OPTIONS: {
64
40
  enableOIDN: true,
65
41
  oidnQuality: 'fast',
66
42
  debugGbufferMaps: true,
67
- // Tiles run at tileSize + 2*overlap (96px, 112 for _large), so small tiles pay that padding
68
- // every tile: 256 puts 3.06x the pixels through the UNet at 512². Measured 3.64x faster.
69
- tileSize: 512
43
+ // A cap, not a fixed size — the effective tile is min( max( w, h ), this ), so a frame
44
+ // that fits in one tile pays no overlap padding at all. Every tile otherwise runs at
45
+ // tileSize + 2*overlap (96px, 112 for _large): at 1024²/high, 4x512 tiles measured
46
+ // 422ms against 229ms for one 1024 tile. 1024 caps the one-time activation
47
+ // allocation at ~430MB; beyond it larger frames tile and stay bounded.
48
+ tileSize: 1024
70
49
  }
71
50
  };
72
51
 
@@ -140,6 +119,8 @@ export class OIDNDenoiser extends EventDispatcher {
140
119
  // getToneMapping: () => number (Three.js ToneMapping constant)
141
120
  this.backendParamsGetter = options.backendParams || null;
142
121
  this.getGPUTextures = options.getGPUTextures || null;
122
+ // refreshInput: () => void — re-renders the compositor so the WebGPU canvas is readable
123
+ this.refreshInput = options.refreshInput || null;
143
124
  this.getExposure = options.getExposure || ( () => 1.0 );
144
125
  this.getToneMapping = options.getToneMapping || ( () => ACESFilmicToneMapping );
145
126
  this.getSaturation = options.getSaturation || ( () => 1.0 );
@@ -179,8 +160,8 @@ export class OIDNDenoiser extends EventDispatcher {
179
160
  this.enabled = this.config.enableOIDN;
180
161
  this.quality = this.config.oidnQuality;
181
162
  this.debugGbufferMaps = this.config.debugGbufferMaps;
182
- this.tileSize = this.config.tileSize;
183
- // tileSize actually baked into the live UNet, so a runtime change forces a rebuild.
163
+ this.maxTileSize = this.config.tileSize;
164
+ // The size actually baked into the live UNet, so a change forces a rebuild.
184
165
  this._activeTileSize = 0;
185
166
 
186
167
  // State management
@@ -257,6 +238,63 @@ export class OIDNDenoiser extends EventDispatcher {
257
238
 
258
239
  }
259
240
 
241
+ /**
242
+ * Whether the tier's model expects a clean (accumulated) auxiliary guide rather than a
243
+ * point-sampled one. The aux MRT has to be fed to match, or the guide fights the weights.
244
+ * @param {string} [quality] defaults to the active tier
245
+ * @returns {boolean}
246
+ */
247
+ expectsCleanAux( quality = this.quality ) {
248
+
249
+ const { QUALITY_MODELS } = MODEL_CONFIG;
250
+ return ( QUALITY_MODELS[ quality ] || QUALITY_MODELS.balance ).cleanAux;
251
+
252
+ }
253
+
254
+ /**
255
+ * Engine, precision, model, kernel, tile state and resource counts from the live UNet.
256
+ * @returns {Object|null} null before the weights load
257
+ */
258
+ getRuntimeInfo() {
259
+
260
+ return this.unet?.getRuntimeInfo?.() ?? null;
261
+
262
+ }
263
+
264
+ /**
265
+ * Arms per-layer GPU timestamping for the next denoise. Needs `timestamp-query` on the
266
+ * device; one denoise only, so call it again for each capture.
267
+ * @returns {boolean} whether the capture was armed
268
+ */
269
+ profileNextDenoise() {
270
+
271
+ return this.unet?.profileNextExecution?.() ?? false;
272
+
273
+ }
274
+
275
+ /**
276
+ * Per-layer GPU timings from the last denoise armed by {@link profileNextDenoise}.
277
+ * @returns {Promise<Object|null>}
278
+ */
279
+ async getLastDenoiseProfile() {
280
+
281
+ return ( await this.unet?.getLastExecutionProfile?.() ) ?? null;
282
+
283
+ }
284
+
285
+ /**
286
+ * Largest tile that still covers the frame, bounded by the configured cap. One tile means
287
+ * zero overlap, which is the cheapest configuration oidn-web has.
288
+ * @returns {number}
289
+ */
290
+ _resolveTileSize() {
291
+
292
+ const longest = Math.max( this.output?.width || 0, this.output?.height || 0 );
293
+ if ( ! longest ) return this.maxTileSize;
294
+ return Math.min( longest, this.maxTileSize );
295
+
296
+ }
297
+
260
298
  async _setupUNetDenoiser() {
261
299
 
262
300
  if ( this.state.isLoading ) return;
@@ -264,8 +302,10 @@ export class OIDNDenoiser extends EventDispatcher {
264
302
  this.state.isLoading = true;
265
303
  const tzaUrl = this._generateTzaUrl();
266
304
 
305
+ const tileSize = this._resolveTileSize();
306
+
267
307
  // maxTileSize is a constructor arg, so comparing the URL alone left tileSize changes inert.
268
- if ( this.currentTZAUrl === tzaUrl && this._activeTileSize === this.tileSize && this.unet ) {
308
+ if ( this.currentTZAUrl === tzaUrl && this._activeTileSize === tileSize && this.unet ) {
269
309
 
270
310
  this.state.isLoading = false;
271
311
  return;
@@ -299,11 +339,14 @@ export class OIDNDenoiser extends EventDispatcher {
299
339
  this.unet = await initFn( tzaUrl, backendParams, {
300
340
  aux: true,
301
341
  hdr: true,
302
- maxTileSize: this.tileSize
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
303
346
  } );
304
347
 
305
348
  this.currentTZAUrl = tzaUrl;
306
- this._activeTileSize = this.tileSize;
349
+ this._activeTileSize = tileSize;
307
350
  this.dispatchEvent( { type: 'loaded' } );
308
351
  log.debug( 'UNet weights loaded:', tzaUrl );
309
352
 
@@ -333,8 +376,8 @@ export class OIDNDenoiser extends EventDispatcher {
333
376
 
334
377
  const { oidnWeightsBaseUrl } = getAssetConfig();
335
378
  const { QUALITY_MODELS } = MODEL_CONFIG;
336
- const modelName = QUALITY_MODELS[ this.quality ] || QUALITY_MODELS.balance;
337
- return `${oidnWeightsBaseUrl}${modelName}.tza`;
379
+ const tier = QUALITY_MODELS[ this.quality ] || QUALITY_MODELS.balance;
380
+ return `${oidnWeightsBaseUrl}${tier.model}.tza`;
338
381
 
339
382
  }
340
383
 
@@ -349,7 +392,7 @@ export class OIDNDenoiser extends EventDispatcher {
349
392
  Object.assign( this.config, newConfig );
350
393
  this.quality = this.config.oidnQuality;
351
394
  this.debugGbufferMaps = this.config.debugGbufferMaps;
352
- this.tileSize = this.config.tileSize;
395
+ this.maxTileSize = this.config.tileSize;
353
396
 
354
397
  // Reload denoiser if necessary
355
398
  await this._setupUNetDenoiser();
@@ -376,6 +419,14 @@ export class OIDNDenoiser extends EventDispatcher {
376
419
 
377
420
  }
378
421
 
422
+ // The tile tracks the frame size, so a resolution change since the last build has to be
423
+ // rebuilt into the UNet before denoising — otherwise the frame tiles when it need not.
424
+ if ( this.unet && ! this.state.isLoading && this._activeTileSize !== this._resolveTileSize() ) {
425
+
426
+ await this._setupUNetDenoiser();
427
+
428
+ }
429
+
379
430
  // UNet weights still loading — defer the start (fired from _setupUNetDenoiser's
380
431
  // finally) rather than silently dropping this frame's denoise request.
381
432
  if ( this.state.isLoading ) {
@@ -404,6 +455,15 @@ export class OIDNDenoiser extends EventDispatcher {
404
455
 
405
456
  }
406
457
 
458
+ // Revealing before the first paint shows the output's transparent bitmap — and its
459
+ // checker CSS background — over the now-hidden render canvas.
460
+ _revealOutput() {
461
+
462
+ this.output.style.display = 'block';
463
+ this.input.style.opacity = '0';
464
+
465
+ }
466
+
407
467
  async execute() {
408
468
 
409
469
  if ( ! this.enabled || ! this.unet ) return false;
@@ -411,8 +471,6 @@ export class OIDNDenoiser extends EventDispatcher {
411
471
  // Create abort controller for this execution
412
472
  this.state.abortController = new AbortController();
413
473
  this.state.isDenoising = true;
414
- this.input.style.opacity = '0';
415
- this.output.style.display = 'block';
416
474
 
417
475
  try {
418
476
 
@@ -485,6 +543,12 @@ export class OIDNDenoiser extends EventDispatcher {
485
543
 
486
544
  }
487
545
 
546
+ // 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 );
550
+ this._revealOutput();
551
+
488
552
  // Ensure storage buffers are sized correctly (recreate on resolution change)
489
553
  this._ensureGPUInputBuffers( width, height );
490
554
 
@@ -543,8 +607,6 @@ export class OIDNDenoiser extends EventDispatcher {
543
607
  if ( globalThis.__OIDN_NO_INPUT_SCALE ) this._oidnInputScale = 1.0;
544
608
  this._applyColorScale( device, width * height );
545
609
 
546
- // Draw the current noisy frame as the base — denoised tiles paint on top progressively
547
- this.ctx.drawImage( this.input, 0, 0, width, height );
548
610
  // Pass GPU storage buffers to oidn-web (GPUBuffer path, well-tested)
549
611
  const config = {
550
612
  color: { data: this._gpuInputBuffers.color, width, height },
@@ -1094,9 +1156,6 @@ export class OIDNDenoiser extends EventDispatcher {
1094
1156
 
1095
1157
  // Dispose resources
1096
1158
  this.unet?.dispose();
1097
- // Must precede renderer.dispose() so the GPUDevice is still alive when
1098
- // TFJS tears down the cached backend's buffers/textures.
1099
- removeOidnTfjsBackend();
1100
1159
  this._destroyGPUInputBuffers();
1101
1160
  this._colorScalePipeline = null;
1102
1161
 
@@ -1808,6 +1808,39 @@ export class PathTracerApp extends EventDispatcher {
1808
1808
 
1809
1809
  }
1810
1810
 
1811
+ /**
1812
+ * Arms per-layer GPU timestamping inside the OIDN denoiser for the next denoise only.
1813
+ * Read the result with {@link PathTracerApp#getDenoiseProfile}.
1814
+ *
1815
+ * `getGPUTimings()` cannot see the denoise: oidn-web submits on its own command encoders,
1816
+ * outside the stages three.js times.
1817
+ *
1818
+ * @returns {boolean} whether the capture was armed
1819
+ */
1820
+ profileNextDenoise() {
1821
+
1822
+ return this.denoisingManager?.denoiser?.profileNextDenoise() ?? false;
1823
+
1824
+ }
1825
+
1826
+ /**
1827
+ * Per-layer GPU timings from the denoise armed by {@link PathTracerApp#profileNextDenoise},
1828
+ * plus the live engine/precision/model/tile diagnostics.
1829
+ *
1830
+ * @returns {Promise<{ profile: Object|null, runtime: Object|null }|null>}
1831
+ */
1832
+ async getDenoiseProfile() {
1833
+
1834
+ const denoiser = this.denoisingManager?.denoiser;
1835
+ if ( ! denoiser ) return null;
1836
+
1837
+ return {
1838
+ profile: await denoiser.getLastDenoiseProfile(),
1839
+ runtime: denoiser.getRuntimeInfo(),
1840
+ };
1841
+
1842
+ }
1843
+
1811
1844
  /**
1812
1845
  * Real GPU milliseconds for the last resolved frame, from WebGPU timestamp queries.
1813
1846
  * Returns null unless {@link PathTracerApp#enableGPUTiming} was called.
@@ -138,6 +138,13 @@ export class DenoisingManager extends EventDispatcher {
138
138
  adapterInfo: null
139
139
  } ),
140
140
 
141
+ refreshInput: () => {
142
+
143
+ const ctx = this.pipeline?.context;
144
+ if ( this._stages.compositor && ctx ) this._stages.compositor.render( ctx );
145
+
146
+ },
147
+
141
148
  getGPUTextures: () => {
142
149
 
143
150
  if ( ! pt?.storageTextures?.readTarget ) return null;
@@ -190,6 +197,13 @@ export class DenoisingManager extends EventDispatcher {
190
197
 
191
198
  },
192
199
 
200
+ refreshInput: () => {
201
+
202
+ const ctx = this.pipeline?.context;
203
+ if ( this._stages.compositor && ctx ) this._stages.compositor.render( ctx );
204
+
205
+ },
206
+
193
207
  getGPUTextures: () => {
194
208
 
195
209
  if ( ! pt?.storageTextures?.readTarget ) return null;
@@ -428,13 +442,6 @@ export class DenoisingManager extends EventDispatcher {
428
442
  // Remove any stale completion-chain listener from a previous render cycle
429
443
  this._cleanupCompletionListener();
430
444
 
431
- // Show post-process canvas if any post-process is enabled
432
- if ( ( this.denoiser?.enabled || this.upscaler?.enabled ) && this.denoiserCanvas ) {
433
-
434
- this.denoiserCanvas.style.display = 'block';
435
-
436
- }
437
-
438
445
  // Chain: denoise first (if enabled), then upscale (if enabled)
439
446
  const startUpscaler = () => {
440
447
 
@@ -458,13 +465,6 @@ export class DenoisingManager extends EventDispatcher {
458
465
 
459
466
  } else {
460
467
 
461
- // Re-render compositor stage so WebGPU canvas has valid content
462
- if ( this.upscaler?.enabled && this._stages.compositor && context ) {
463
-
464
- this._stages.compositor.render( context );
465
-
466
- }
467
-
468
468
  startUpscaler();
469
469
 
470
470
  }
@@ -638,8 +638,10 @@ export class DenoisingManager extends EventDispatcher {
638
638
  setOIDNQuality( quality ) {
639
639
 
640
640
  this.denoiser?.updateQuality( quality );
641
- // Clean-aux normal follows the model: balanced/high (clean-aux models) → accumulate; fast → keep bump.
642
- this._stages.pathTracer?.setCleanAuxNormal?.( !! this.denoiser?.enabled && quality !== 'fast' );
641
+ // Clean-aux normal follows the model, not the tier name — see OIDNDenoiser.QUALITY_MODELS.
642
+ this._stages.pathTracer?.setCleanAuxNormal?.(
643
+ !! this.denoiser?.enabled && !! this.denoiser?.expectsCleanAux( quality )
644
+ );
643
645
  this._onPostProcessRefresh?.();
644
646
 
645
647
  }