rayzee 7.23.3 → 7.25.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.3",
3
+ "version": "7.25.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",
@@ -18,7 +18,7 @@
18
18
  ],
19
19
  "repository": {
20
20
  "type": "git",
21
- "url": "https://github.com/atul-mourya/RayTracing.git",
21
+ "url": "git+https://github.com/atul-mourya/rayzee-renderer.git",
22
22
  "directory": "rayzee"
23
23
  },
24
24
  "keywords": [
@@ -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
  }
@@ -1,5 +1,5 @@
1
1
  import { EventDispatcher, ACESFilmicToneMapping } from 'three';
2
- import { TONE_MAP_FNS, SRGB_GAMMA, applySaturation } from '../Processor/ToneMapCPU.js';
2
+ import { TONE_MAP_FNS, SRGB_GAMMA, applySaturation, effectiveExposure } from '../Processor/ToneMapCPU.js';
3
3
  import { getAssetConfig } from '../AssetConfig.js';
4
4
  import AIUpscalerWorker from '../Processor/Workers/AIUpscalerWorker.js?worker&inline';
5
5
 
@@ -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
 
@@ -393,7 +400,7 @@ export class AIUpscaler extends EventDispatcher {
393
400
  if ( sourceImageData.isHDR ) {
394
401
 
395
402
  this._hdrToneMapFn = TONE_MAP_FNS.get( this.getToneMapping() ) || TONE_MAP_FNS.get( ACESFilmicToneMapping );
396
- this._hdrExposure = this.getExposure();
403
+ this._hdrExposure = effectiveExposure( this.getExposure(), this.getToneMapping() );
397
404
  this._hdrSaturation = this.getSaturation();
398
405
  this._tmOut = new Float32Array( 3 );
399
406
 
@@ -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;
@@ -785,7 +793,7 @@ export class AIUpscaler extends EventDispatcher {
785
793
  const imageData = ctx.createImageData( width, height );
786
794
  const pixels = imageData.data;
787
795
  const tmFn = TONE_MAP_FNS.get( this.getToneMapping() ) || TONE_MAP_FNS.get( ACESFilmicToneMapping );
788
- const exposure = this.getExposure();
796
+ const exposure = effectiveExposure( this.getExposure(), this.getToneMapping() );
789
797
  const saturation = this.getSaturation();
790
798
  const out = new Float32Array( 3 );
791
799
 
@@ -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,51 +16,36 @@ 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
- import { TONE_MAP_FNS, linearToSRGB, applySaturation } from '../Processor/ToneMapCPU.js';
19
+ import { TONE_MAP_FNS, linearToSRGB, applySaturation, effectiveExposure } from '../Processor/ToneMapCPU.js';
49
20
  import { getAssetConfig } from '../AssetConfig.js';
50
21
 
51
22
  /** Reusable RGB output buffer (avoids per-pixel allocation). */
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 },
@@ -702,7 +764,7 @@ export class OIDNDenoiser extends EventDispatcher {
702
764
  /** Display exposure with the input scale divided back out — output arrives pre-multiplied. */
703
765
  _outputExposure() {
704
766
 
705
- return this.getExposure() / this._oidnInputScale;
767
+ return effectiveExposure( this.getExposure(), this.getToneMapping() ) / this._oidnInputScale;
706
768
 
707
769
  }
708
770
 
@@ -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
 
@@ -366,8 +366,11 @@ export class PathTracerApp extends EventDispatcher {
366
366
  memoryPeak: tracker?.peak ?? 0,
367
367
  } );
368
368
 
369
- // Check time limit
370
- if ( this.completion.isTimeLimitReached( this.settings.get( 'renderLimitMode' ), this.settings.get( 'renderTimeLimit' ) ) ) {
369
+ // Only the wall-clock stop — PathTracer.render() retires the ceiling and convergence
370
+ // itself, so whichever of the three arrives first wins.
371
+ if ( this.completion.isTimeLimitReached(
372
+ this.stages.pathTracer, this.settings.get( 'renderLimitMode' ), this.settings.get( 'renderTimeLimit' )
373
+ ) ) {
371
374
 
372
375
  this.stages.pathTracer.isComplete = true;
373
376
 
@@ -381,8 +384,14 @@ export class PathTracerApp extends EventDispatcher {
381
384
  context: this.pipeline?.context,
382
385
  } );
383
386
 
384
- this.dispatchEvent( { type: 'RenderComplete' } );
385
- this.dispatchEvent( { type: EngineEvents.RENDER_COMPLETE } );
387
+ const completionInfo = {
388
+ samples: this.stages.pathTracer.frameCount,
389
+ timeElapsed: this.completion.timeElapsed,
390
+ budgetOverrun: this.completion.budgetOverrun,
391
+ };
392
+
393
+ this.dispatchEvent( { type: 'RenderComplete', ...completionInfo } );
394
+ this.dispatchEvent( { type: EngineEvents.RENDER_COMPLETE, ...completionInfo } );
386
395
 
387
396
  }
388
397
 
@@ -1808,6 +1817,39 @@ export class PathTracerApp extends EventDispatcher {
1808
1817
 
1809
1818
  }
1810
1819
 
1820
+ /**
1821
+ * Arms per-layer GPU timestamping inside the OIDN denoiser for the next denoise only.
1822
+ * Read the result with {@link PathTracerApp#getDenoiseProfile}.
1823
+ *
1824
+ * `getGPUTimings()` cannot see the denoise: oidn-web submits on its own command encoders,
1825
+ * outside the stages three.js times.
1826
+ *
1827
+ * @returns {boolean} whether the capture was armed
1828
+ */
1829
+ profileNextDenoise() {
1830
+
1831
+ return this.denoisingManager?.denoiser?.profileNextDenoise() ?? false;
1832
+
1833
+ }
1834
+
1835
+ /**
1836
+ * Per-layer GPU timings from the denoise armed by {@link PathTracerApp#profileNextDenoise},
1837
+ * plus the live engine/precision/model/tile diagnostics.
1838
+ *
1839
+ * @returns {Promise<{ profile: Object|null, runtime: Object|null }|null>}
1840
+ */
1841
+ async getDenoiseProfile() {
1842
+
1843
+ const denoiser = this.denoisingManager?.denoiser;
1844
+ if ( ! denoiser ) return null;
1845
+
1846
+ return {
1847
+ profile: await denoiser.getLastDenoiseProfile(),
1848
+ runtime: denoiser.getRuntimeInfo(),
1849
+ };
1850
+
1851
+ }
1852
+
1811
1853
  /**
1812
1854
  * Real GPU milliseconds for the last resolved frame, from WebGPU timestamp queries.
1813
1855
  * Returns null unless {@link PathTracerApp#enableGPUTiming} was called.
@@ -1,3 +1,7 @@
1
+ import { createLogger } from '../utils/Logger.js';
2
+
3
+ const log = createLogger( 'engine' );
4
+
1
5
  /**
2
6
  * Tracks render completion state, time limits, and sample limits.
3
7
  *
@@ -12,6 +16,10 @@ export class CompletionTracker {
12
16
  this.lastResetTime = performance.now();
13
17
  this.renderCompleteDispatched = false;
14
18
 
19
+ /** Whether the deadline — rather than the ceiling or convergence — retired the frame. */
20
+ this.budgetOverrun = false;
21
+ this._budgetHeldWarned = false;
22
+
15
23
  }
16
24
 
17
25
  /**
@@ -24,19 +32,48 @@ export class CompletionTracker {
24
32
  }
25
33
 
26
34
  /**
27
- * Checks whether the time-based render limit has been reached.
28
- * @param {string} renderLimitMode - 'time' or 'samples'
35
+ * Checks whether the wall-clock render budget has expired. An additional stop condition,
36
+ * never a replacement — the sample ceiling and convergence still bind when they come first.
37
+ *
38
+ * Floored at one sample: frameCount does not advance while the stage compiles kernels or
39
+ * while the camera is in interaction mode, and retiring in that window denoises an empty
40
+ * buffer and captures a blank canvas.
41
+ *
42
+ * @param {Object} pathTracer - The PathTracer stage
43
+ * @param {string} renderLimitMode - 'time' or 'frames'
29
44
  * @param {number} renderTimeLimit - Time limit in seconds
30
45
  * @returns {boolean}
31
46
  */
32
- isTimeLimitReached( renderLimitMode, renderTimeLimit ) {
47
+ isTimeLimitReached( pathTracer, renderLimitMode, renderTimeLimit ) {
48
+
49
+ if ( renderLimitMode !== 'time' || ! ( renderTimeLimit > 0 ) ) return false;
50
+ if ( this.timeElapsed < renderTimeLimit ) return false;
51
+
52
+ if ( ! ( pathTracer?.frameCount > 0 ) ) {
33
53
 
34
- return renderLimitMode === 'time' && renderTimeLimit > 0 && this.timeElapsed >= renderTimeLimit;
54
+ if ( ! this._budgetHeldWarned ) {
55
+
56
+ this._budgetHeldWarned = true;
57
+ log.warn(
58
+ `render time limit (${renderTimeLimit}s) expired before the first sample landed — ` +
59
+ 'holding the deadline open to avoid capturing an empty frame'
60
+ );
61
+
62
+ }
63
+
64
+ return false;
65
+
66
+ }
67
+
68
+ this.budgetOverrun = true;
69
+ return true;
35
70
 
36
71
  }
37
72
 
38
73
  /**
39
- * Checks whether ANY render limit (time or samples) is reached.
74
+ * Checks whether ANY render limit is reached: sample ceiling, convergence, or time budget,
75
+ * whichever arrives first.
76
+ *
40
77
  * @param {Object} pathTracer - The PathTracer stage
41
78
  * @param {string} renderLimitMode
42
79
  * @param {number} renderTimeLimit
@@ -46,7 +83,7 @@ export class CompletionTracker {
46
83
 
47
84
  if ( ! pathTracer ) return false;
48
85
 
49
- if ( this.isTimeLimitReached( renderLimitMode, renderTimeLimit ) ) return true;
86
+ if ( this.isTimeLimitReached( pathTracer, renderLimitMode, renderTimeLimit ) ) return true;
50
87
 
51
88
  // Tier-1 convergence early-stop must agree with PathTracer.render()'s own check, else the app-level
52
89
  // reconcile would flip isComplete back off and keep dispatching after the frame converged.
@@ -75,6 +112,8 @@ export class CompletionTracker {
75
112
  this.timeElapsed = 0;
76
113
  this.lastResetTime = performance.now();
77
114
  this.renderCompleteDispatched = false;
115
+ this.budgetOverrun = false;
116
+ this._budgetHeldWarned = false;
78
117
 
79
118
  }
80
119