rayzee 5.3.7 → 5.4.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": "5.3.7",
3
+ "version": "5.4.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",
@@ -34,7 +34,7 @@
34
34
  "prepublishOnly": "npm run build"
35
35
  },
36
36
  "peerDependencies": {
37
- "three": ">=0.183.2"
37
+ "three": ">=0.184.0"
38
38
  },
39
39
  "dependencies": {
40
40
  "stats-gl": "^4.0.2"
@@ -114,6 +114,14 @@ export class AIUpscaler extends EventDispatcher {
114
114
  this._baseWidth = output.width;
115
115
  this._baseHeight = output.height;
116
116
 
117
+ // Pooled HDR readback staging buffer — reused across _captureSourceHDR calls.
118
+ // Rebuilt only when the source texture dimensions change (same spirit as
119
+ // r184's ReadbackBuffer; we can't use renderer.getArrayBufferAsync directly
120
+ // because our source is a raw GPUTexture, not a Three.js BufferAttribute).
121
+ this._hdrStagingBuffer = null;
122
+ this._hdrStagingWidth = 0;
123
+ this._hdrStagingHeight = 0;
124
+
117
125
  }
118
126
 
119
127
  // ─── Model Management ─────────────────────────────────────────────────────
@@ -477,14 +485,26 @@ export class AIUpscaler extends EventDispatcher {
477
485
  const width = colorTexture.width;
478
486
  const height = colorTexture.height;
479
487
 
480
- // GPU texture → staging buffer → CPU readback
488
+ // GPU texture → pooled staging buffer → CPU readback.
489
+ // The staging buffer is kept alive between calls (unmap, don't destroy)
490
+ // and only re-created when texture dimensions change.
481
491
  const bytesPerRow = Math.ceil( width * 16 / 256 ) * 256; // rgba32float=16 bytes, aligned to 256
482
492
  const bufferSize = bytesPerRow * height;
483
493
 
484
- const stagingBuffer = device.createBuffer( {
485
- size: bufferSize,
486
- usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST
487
- } );
494
+ if ( this._hdrStagingWidth !== width || this._hdrStagingHeight !== height ) {
495
+
496
+ this._hdrStagingBuffer?.destroy();
497
+ this._hdrStagingBuffer = device.createBuffer( {
498
+ label: 'aiupscaler-hdr-readback',
499
+ size: bufferSize,
500
+ usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST
501
+ } );
502
+ this._hdrStagingWidth = width;
503
+ this._hdrStagingHeight = height;
504
+
505
+ }
506
+
507
+ const stagingBuffer = this._hdrStagingBuffer;
488
508
 
489
509
  const encoder = device.createCommandEncoder();
490
510
  encoder.copyTextureToBuffer(
@@ -511,7 +531,6 @@ export class AIUpscaler extends EventDispatcher {
511
531
  }
512
532
 
513
533
  stagingBuffer.unmap();
514
- stagingBuffer.destroy();
515
534
 
516
535
  // Mark as HDR so _extractTile and _tensorToImageData handle it correctly
517
536
  return { data, width, height, isHDR: true };
@@ -856,6 +875,11 @@ export class AIUpscaler extends EventDispatcher {
856
875
  this._upscaledAlpha = null;
857
876
  this.state.abortController = null;
858
877
 
878
+ this._hdrStagingBuffer?.destroy();
879
+ this._hdrStagingBuffer = null;
880
+ this._hdrStagingWidth = 0;
881
+ this._hdrStagingHeight = 0;
882
+
859
883
  console.log( 'AIUpscaler disposed' );
860
884
 
861
885
  }
@@ -76,6 +76,17 @@ export class OIDNDenoiser extends EventDispatcher {
76
76
  // Cached GPU storage buffers for texture→buffer copies (reused across denoise calls)
77
77
  this._gpuInputBuffers = { color: null, albedo: null, normal: null };
78
78
  this._gpuInputBufferSize = { width: 0, height: 0 };
79
+ // Shared pad-strip buffer for non-256-aligned widths. Reused across
80
+ // color/albedo/normal copies within the same encoder (WebGPU command
81
+ // order guarantees the overwrites are serialized).
82
+ this._gpuInputPadBuffer = null;
83
+ this._gpuInputPaddedRowBytes = 0;
84
+ // Pooled MAP_READ staging buffer for _cacheInputAlpha. Only allocated
85
+ // when transparent-background readback is used, destroyed on resolution
86
+ // change or dispose. Same spirit as r184's ReadbackBuffer — we can't use
87
+ // renderer.getArrayBufferAsync because the source is a raw GPUBuffer,
88
+ // not a Three.js BufferAttribute.
89
+ this._alphaReadbackBuffer = null;
79
90
 
80
91
  // Cached alpha channel from the input color buffer (OIDN discards alpha)
81
92
  this._cachedAlpha = null;
@@ -385,13 +396,15 @@ export class OIDNDenoiser extends EventDispatcher {
385
396
 
386
397
  // Copy render target textures → tightly packed GPU storage buffers for oidn-web.
387
398
  // copyTextureToBuffer requires bytesPerRow to be a multiple of 256. When the tight
388
- // row size (width * 16) isn't aligned, copy via a padded staging buffer per texture
389
- // then strip padding row-by-row.
399
+ // row size (width * 16) isn't aligned, copy via a shared pre-allocated padded buffer
400
+ // (see _ensureGPUInputBuffers) then strip padding row-by-row. The pad buffer is
401
+ // reused across color/albedo/normal — safe because WebGPU serializes commands
402
+ // within a single encoder.
390
403
  const encoder = device.createCommandEncoder( { label: 'oidn-tex-to-buf' } );
391
404
  const tightRowBytes = width * 16; // rgba32float
392
- const paddedRowBytes = Math.ceil( tightRowBytes / 256 ) * 256;
393
- const needsPadStrip = paddedRowBytes !== tightRowBytes;
394
- const stagingBufs = [];
405
+ const paddedRowBytes = this._gpuInputPaddedRowBytes;
406
+ const needsPadStrip = paddedRowBytes > tightRowBytes;
407
+ const padBuf = this._gpuInputPadBuffer;
395
408
 
396
409
  const copyTex = ( tex, tightBuf ) => {
397
410
 
@@ -405,9 +418,6 @@ export class OIDNDenoiser extends EventDispatcher {
405
418
 
406
419
  } else {
407
420
 
408
- const padBuf = device.createBuffer( { size: paddedRowBytes * height, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC } );
409
- stagingBufs.push( padBuf );
410
-
411
421
  encoder.copyTextureToBuffer(
412
422
  { texture: tex, mipLevel: 0 },
413
423
  { buffer: padBuf, offset: 0, bytesPerRow: paddedRowBytes, rowsPerImage: height },
@@ -429,7 +439,6 @@ export class OIDNDenoiser extends EventDispatcher {
429
439
  copyTex( textures.normal, this._gpuInputBuffers.normal );
430
440
 
431
441
  device.queue.submit( [ encoder.finish() ] );
432
- for ( const buf of stagingBufs ) buf.destroy();
433
442
 
434
443
  // Cache alpha channel from input color buffer when transparent background is enabled.
435
444
  // OIDN only processes RGB — the alpha channel is lost, so we read it before denoising.
@@ -478,6 +487,25 @@ export class OIDNDenoiser extends EventDispatcher {
478
487
  this._gpuInputBuffers.normal = device.createBuffer( { label: 'oidn-in-normal', size: byteSize, usage } );
479
488
  this._gpuInputBufferSize = { width, height };
480
489
 
490
+ // Pre-allocate the row-pad staging buffer when width * 16 isn't 256-aligned.
491
+ // Shared across the three texture copies; recreated only on resolution change.
492
+ const tightRowBytes = width * 16;
493
+ const paddedRowBytes = Math.ceil( tightRowBytes / 256 ) * 256;
494
+ if ( paddedRowBytes !== tightRowBytes ) {
495
+
496
+ this._gpuInputPadBuffer = device.createBuffer( {
497
+ label: 'oidn-in-pad',
498
+ size: paddedRowBytes * height,
499
+ usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC
500
+ } );
501
+ this._gpuInputPaddedRowBytes = paddedRowBytes;
502
+
503
+ } else {
504
+
505
+ this._gpuInputPaddedRowBytes = tightRowBytes;
506
+
507
+ }
508
+
481
509
  }
482
510
 
483
511
  _destroyGPUInputBuffers() {
@@ -485,7 +513,12 @@ export class OIDNDenoiser extends EventDispatcher {
485
513
  this._gpuInputBuffers.color?.destroy();
486
514
  this._gpuInputBuffers.albedo?.destroy();
487
515
  this._gpuInputBuffers.normal?.destroy();
516
+ this._gpuInputPadBuffer?.destroy();
517
+ this._alphaReadbackBuffer?.destroy();
488
518
  this._gpuInputBuffers = { color: null, albedo: null, normal: null };
519
+ this._gpuInputPadBuffer = null;
520
+ this._gpuInputPaddedRowBytes = 0;
521
+ this._alphaReadbackBuffer = null;
489
522
  this._gpuInputBufferSize = { width: 0, height: 0 };
490
523
 
491
524
  }
@@ -497,11 +530,21 @@ export class OIDNDenoiser extends EventDispatcher {
497
530
  async _cacheInputAlpha( device, width, height ) {
498
531
 
499
532
  const byteSize = width * height * 16; // rgba32float, tightly packed
500
- const staging = device.createBuffer( {
501
- label: 'oidn-alpha-staging',
502
- size: byteSize,
503
- usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST
504
- } );
533
+
534
+ // Lazy-allocate the pooled staging buffer on first call at this resolution.
535
+ // _destroyGPUInputBuffers clears it on resolution change or dispose, so if
536
+ // it is non-null here, it already matches the current resolution.
537
+ if ( this._alphaReadbackBuffer === null ) {
538
+
539
+ this._alphaReadbackBuffer = device.createBuffer( {
540
+ label: 'oidn-alpha-readback',
541
+ size: byteSize,
542
+ usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST
543
+ } );
544
+
545
+ }
546
+
547
+ const staging = this._alphaReadbackBuffer;
505
548
 
506
549
  const enc = device.createCommandEncoder();
507
550
  enc.copyBufferToBuffer( this._gpuInputBuffers.color, 0, staging, 0, byteSize );
@@ -520,7 +563,6 @@ export class OIDNDenoiser extends EventDispatcher {
520
563
  }
521
564
 
522
565
  staging.unmap();
523
- staging.destroy();
524
566
 
525
567
  this._cachedAlpha = alpha;
526
568
  this._cachedAlphaWidth = width;
@@ -19,7 +19,7 @@ import { Display } from './Stages/Display.js';
19
19
  import { RenderPipeline } from './Pipeline/RenderPipeline.js';
20
20
  import { CompletionTracker } from './Pipeline/CompletionTracker.js';
21
21
  import { ENGINE_DEFAULTS as DEFAULT_STATE, FINAL_RENDER_CONFIG, PREVIEW_RENDER_CONFIG } from './EngineDefaults.js';
22
- import { updateStats, updateLoading, resetLoading, setStatusCallback, getDisplaySamples } from './Processor/utils.js';
22
+ import { updateStats, updateLoading, resetLoading, setStatusCallback, getDisplaySamples, disposeObjectFromMemory } from './Processor/utils.js';
23
23
  import { BuildTimer } from './Processor/BuildTimer.js';
24
24
  import { InteractionManager } from './managers/InteractionManager.js';
25
25
  import { EngineEvents } from './EngineEvents.js';
@@ -129,6 +129,45 @@ export class PathTracerApp extends EventDispatcher {
129
129
  // Resolution state
130
130
  this._resizeDebounceTimer = null;
131
131
 
132
+ // Tracked listeners for clean dispose()
133
+ this._trackedListeners = [];
134
+ this._disposed = false;
135
+
136
+ }
137
+
138
+ /**
139
+ * Registers an event listener and tracks it for automatic cleanup on dispose().
140
+ * @param {EventTarget|{addEventListener:Function, removeEventListener:Function}} target
141
+ * @param {string} type
142
+ * @param {Function} handler
143
+ */
144
+ _addTrackedListener( target, type, handler ) {
145
+
146
+ if ( ! target ) return;
147
+ target.addEventListener( type, handler );
148
+ this._trackedListeners.push( { target, type, handler } );
149
+
150
+ }
151
+
152
+ /** Removes all listeners registered via _addTrackedListener. */
153
+ _removeTrackedListeners() {
154
+
155
+ for ( const { target, type, handler } of this._trackedListeners ) {
156
+
157
+ try {
158
+
159
+ target.removeEventListener( type, handler );
160
+
161
+ } catch ( err ) {
162
+
163
+ console.warn( 'PathTracerApp: failed to remove listener', type, err );
164
+
165
+ }
166
+
167
+ }
168
+
169
+ this._trackedListeners.length = 0;
170
+
132
171
  }
133
172
 
134
173
  // ═══════════════════════════════════════════════════════════════
@@ -356,19 +395,22 @@ export class PathTracerApp extends EventDispatcher {
356
395
  */
357
396
  dispose() {
358
397
 
359
- this.animationManager?.dispose();
360
- this.stopAnimation();
361
- setStatusCallback( null );
398
+ if ( this._disposed ) return;
399
+ this._disposed = true;
362
400
 
363
- if ( this.assetLoader && this._onAssetLoaded ) {
401
+ this.stopAnimation();
402
+ clearTimeout( this._resizeDebounceTimer );
364
403
 
365
- this.assetLoader.removeEventListener( 'load', this._onAssetLoaded );
404
+ // Remove all tracked listeners (app-owned subscriptions across managers/DOM)
405
+ this._removeTrackedListeners();
366
406
 
367
- }
407
+ setStatusCallback( null );
368
408
 
409
+ this.animationManager?.dispose();
369
410
  this.transformManager?.dispose();
370
411
  this.overlayManager?.dispose();
371
412
  this._sceneHelpers?.clear();
413
+ this.lightManager?.dispose();
372
414
  this.denoisingManager?.dispose();
373
415
  this.pipeline?.dispose();
374
416
  this.interactionManager?.dispose();
@@ -382,9 +424,6 @@ export class PathTracerApp extends EventDispatcher {
382
424
 
383
425
  }
384
426
 
385
- clearTimeout( this._resizeDebounceTimer );
386
- window.removeEventListener( 'resize', this.resizeHandler );
387
-
388
427
  this.isInitialized = false;
389
428
 
390
429
  }
@@ -393,6 +432,61 @@ export class PathTracerApp extends EventDispatcher {
393
432
  // Asset Loading
394
433
  // ═══════════════════════════════════════════════════════════════
395
434
 
435
+ /**
436
+ * Tears down the current scene: stops animation, deselects, disposes
437
+ * the loaded model + its GPU resources, clears lights, and seeds the
438
+ * path tracer with an empty scene. Leaves the renderer, pipeline, and
439
+ * managers intact so a subsequent loadModel() can reuse them.
440
+ *
441
+ * Safe to call at any point after init() (including while idle).
442
+ * Throws if called concurrently with a load.
443
+ */
444
+ unloadScene() {
445
+
446
+ if ( ! this.isInitialized ) return;
447
+ if ( this._loadingInProgress ) {
448
+
449
+ throw new Error( 'PathTracerApp.unloadScene: cannot unload while a load is in progress' );
450
+
451
+ }
452
+
453
+ if ( this._disposed ) return;
454
+
455
+ // Stop animation + refit
456
+ this.animationManager?.dispose();
457
+ this._animRefitInFlight = false;
458
+
459
+ // Drop selection + transform gizmo attachment
460
+ this.interactionManager?.deselect();
461
+ this.transformManager?.detach?.();
462
+
463
+ // Dispose the loaded model (geometries, materials, textures)
464
+ if ( this.assetLoader?.targetModel ) {
465
+
466
+ disposeObjectFromMemory( this.assetLoader.targetModel );
467
+ this.assetLoader.targetModel = null;
468
+
469
+ }
470
+
471
+ // Clear lights in the WebGPU light scene
472
+ this.lightManager?.clearLights?.();
473
+
474
+ // Seed path tracer with empty data (matches the init-time seed)
475
+ if ( this.stages.pathTracer ) {
476
+
477
+ this.stages.pathTracer.setTriangleData( new Float32Array( 32 ), 0 );
478
+ this.stages.pathTracer.setBVHData( new Float32Array( 16 ) );
479
+ this.stages.pathTracer.materialData.setMaterialData( new Float32Array( 16 ) );
480
+ this.stages.pathTracer.setEmissiveTriangleData?.( new Float32Array( 0 ), 0, 0 );
481
+ this.stages.pathTracer.setupMaterial();
482
+
483
+ }
484
+
485
+ this.reset();
486
+ this.dispatchEvent( { type: 'SceneUnloaded' } );
487
+
488
+ }
489
+
396
490
  /**
397
491
  * Loads a model, builds BVH, and uploads scene data.
398
492
  * @param {string} url - Model URL
@@ -427,6 +521,12 @@ export class PathTracerApp extends EventDispatcher {
427
521
  */
428
522
  async loadEnvironment( url ) {
429
523
 
524
+ if ( this._loadingInProgress ) {
525
+
526
+ throw new Error( 'PathTracerApp.loadEnvironment: another load is already in progress' );
527
+
528
+ }
529
+
430
530
  this._loadingInProgress = true;
431
531
 
432
532
  try {
@@ -469,6 +569,12 @@ export class PathTracerApp extends EventDispatcher {
469
569
  /** Shared pipeline: load asset → sync controls → build BVH → reset → dispatch events */
470
570
  async _loadWithSceneRebuild( loadFn, eventPayload ) {
471
571
 
572
+ if ( this._loadingInProgress ) {
573
+
574
+ throw new Error( 'PathTracerApp: another load is already in progress' );
575
+
576
+ }
577
+
472
578
  this._loadingInProgress = true;
473
579
 
474
580
  try {
@@ -547,6 +653,24 @@ export class PathTracerApp extends EventDispatcher {
547
653
  this.stages.pathTracer.setupMaterial();
548
654
  timer.end( 'Material setup (TSL compile)' );
549
655
 
656
+ // Front-load GPU pipeline creation so the first animate frame is snappy:
657
+ // - compute: Three.js has no async compute compile — one dispatch at
658
+ // build time moves the stall to this loading moment.
659
+ // - raster fallback: compileAsync yields to main thread (r184+).
660
+ timer.start( 'Pipeline precompile' );
661
+ this.stages.pathTracer.shaderBuilder.forceCompile( this.renderer );
662
+ try {
663
+
664
+ await this.renderer.compileAsync( this.meshScene, this.cameraManager.camera );
665
+
666
+ } catch ( err ) {
667
+
668
+ console.warn( 'PathTracerApp: raster fallback precompile failed', err );
669
+
670
+ }
671
+
672
+ timer.end( 'Pipeline precompile' );
673
+
550
674
  // Wait for CDF
551
675
  if ( cdfPromise ) {
552
676
 
@@ -843,6 +967,16 @@ export class PathTracerApp extends EventDispatcher {
843
967
 
844
968
  }
845
969
 
970
+ /**
971
+ * Whether a model/environment load is currently in progress.
972
+ * @returns {boolean}
973
+ */
974
+ get isLoading() {
975
+
976
+ return this._loadingInProgress;
977
+
978
+ }
979
+
846
980
  /**
847
981
  * Whether the path tracer has finished converging.
848
982
  * @returns {boolean}
@@ -1011,7 +1145,7 @@ export class PathTracerApp extends EventDispatcher {
1011
1145
  this.assetLoader.setRenderer( this.renderer );
1012
1146
  this.assetLoader.createFloorPlane();
1013
1147
 
1014
- this.cameraManager.controls.addEventListener( 'change', () => {
1148
+ this._addTrackedListener( this.cameraManager.controls, 'change', () => {
1015
1149
 
1016
1150
  this.needsReset = true;
1017
1151
  this.wake();
@@ -1093,8 +1227,8 @@ export class PathTracerApp extends EventDispatcher {
1093
1227
  _wireEvents() {
1094
1228
 
1095
1229
  // Forward manager events → app events
1096
- this.cameraManager.addEventListener( 'CameraSwitched', ( e ) => this.dispatchEvent( e ) );
1097
- this.cameraManager.addEventListener( EngineEvents.AUTO_FOCUS_UPDATED, ( e ) => this.dispatchEvent( e ) );
1230
+ this._addTrackedListener( this.cameraManager, 'CameraSwitched', ( e ) => this.dispatchEvent( e ) );
1231
+ this._addTrackedListener( this.cameraManager, EngineEvents.AUTO_FOCUS_UPDATED, ( e ) => this.dispatchEvent( e ) );
1098
1232
 
1099
1233
  this._forwardEvents( this.denoisingManager, [
1100
1234
  EngineEvents.DENOISING_START, EngineEvents.DENOISING_END,
@@ -1111,12 +1245,12 @@ export class PathTracerApp extends EventDispatcher {
1111
1245
  EngineEvents.ANIMATION_PAUSED,
1112
1246
  EngineEvents.ANIMATION_STOPPED,
1113
1247
  ] );
1114
- this.animationManager.addEventListener( EngineEvents.ANIMATION_PAUSED, () => {
1248
+ this._addTrackedListener( this.animationManager, EngineEvents.ANIMATION_PAUSED, () => {
1115
1249
 
1116
1250
  this._animRefitInFlight = false;
1117
1251
 
1118
1252
  } );
1119
- this.animationManager.addEventListener( EngineEvents.ANIMATION_STOPPED, () => {
1253
+ this._addTrackedListener( this.animationManager, EngineEvents.ANIMATION_STOPPED, () => {
1120
1254
 
1121
1255
  this._animRefitInFlight = false;
1122
1256
 
@@ -1152,7 +1286,7 @@ export class PathTracerApp extends EventDispatcher {
1152
1286
  this.resizeHandler = () => this.onResize();
1153
1287
  if ( this._autoResize ) {
1154
1288
 
1155
- window.addEventListener( 'resize', this.resizeHandler );
1289
+ this._addTrackedListener( window, 'resize', this.resizeHandler );
1156
1290
 
1157
1291
  }
1158
1292
 
@@ -1183,9 +1317,9 @@ export class PathTracerApp extends EventDispatcher {
1183
1317
 
1184
1318
  };
1185
1319
 
1186
- this.assetLoader.addEventListener( 'load', this._onAssetLoaded );
1320
+ this._addTrackedListener( this.assetLoader, 'load', this._onAssetLoaded );
1187
1321
 
1188
- this.assetLoader.addEventListener( 'modelProcessed', ( event ) => {
1322
+ this._addTrackedListener( this.assetLoader, 'modelProcessed', ( event ) => {
1189
1323
 
1190
1324
  const cameras = [ this.cameraManager.camera, ...( event.cameras || [] ) ];
1191
1325
  this.cameraManager.setCameras( cameras );
@@ -1382,7 +1516,7 @@ export class PathTracerApp extends EventDispatcher {
1382
1516
  if ( ! source ) return;
1383
1517
  for ( const type of eventTypes ) {
1384
1518
 
1385
- source.addEventListener( type, ( e ) => this.dispatchEvent( e ) );
1519
+ this._addTrackedListener( source, type, ( e ) => this.dispatchEvent( e ) );
1386
1520
 
1387
1521
  }
1388
1522
 
@@ -449,7 +449,16 @@ export class RenderPipeline {
449
449
 
450
450
  console.group( '[Pipeline] Performance Stats' );
451
451
  console.log( `Frames: ${stats.frameCount}` );
452
- console.log( `Total: ${stats.total.toFixed( 2 )}ms (min: ${stats.totalMin.toFixed( 2 )}ms, max: ${stats.totalMax.toFixed( 2 )}ms)` );
452
+
453
+ if ( stats.totalMin !== undefined ) {
454
+
455
+ console.log( `Total: ${stats.total.toFixed( 2 )}ms (min: ${stats.totalMin.toFixed( 2 )}ms, max: ${stats.totalMax.toFixed( 2 )}ms)` );
456
+
457
+ } else {
458
+
459
+ console.log( 'Total: no frames recorded yet (pipeline.render() has not run since stats were enabled — the path tracer may have converged and stopped its animation loop)' );
460
+
461
+ }
453
462
 
454
463
  for ( const [ name, timing ] of Object.entries( stats.stages ) ) {
455
464
 
@@ -50,9 +50,18 @@ export class ShaderBuilder {
50
50
  this._dispatchX = 0;
51
51
  this._dispatchY = 0;
52
52
 
53
+ // Reused per-frame dispatchSize array — avoids GC pressure from
54
+ // allocating [x,y,z] on every setFullScreenDispatch/setTileDispatch call.
55
+ // WebGPUBackend only reads indices 0..2 of this array during compute dispatch.
56
+ this._dispatchSize = [ 0, 0, 1 ];
57
+
53
58
  // Scene texture nodes cache (for in-place updates on model change)
54
59
  this._sceneTextureNodes = null;
55
60
 
61
+ // Whether the GPU compute pipeline has been compiled (via a real dispatch).
62
+ // Reset on setupCompute() rebuilds and on dispose().
63
+ this._compiled = false;
64
+
56
65
  }
57
66
 
58
67
  /**
@@ -89,6 +98,9 @@ export class ShaderBuilder {
89
98
  writeTex.color, writeTex.normalDepth, writeTex.albedo
90
99
  );
91
100
 
101
+ // New compute node → needs a fresh GPU pipeline compile
102
+ this._compiled = false;
103
+
92
104
  timer.end( 'Build compute node (TSL)' );
93
105
 
94
106
  timer.print();
@@ -131,7 +143,13 @@ export class ShaderBuilder {
131
143
  this._dispatchX = Math.ceil( width / WG_SIZE );
132
144
  this._dispatchY = Math.ceil( height / WG_SIZE );
133
145
 
134
- if ( this.computeNode ) this.computeNode.setCount( [ this._dispatchX, this._dispatchY, 1 ] );
146
+ if ( this.computeNode ) {
147
+
148
+ this._dispatchSize[ 0 ] = this._dispatchX;
149
+ this._dispatchSize[ 1 ] = this._dispatchY;
150
+ this.computeNode.dispatchSize = this._dispatchSize;
151
+
152
+ }
135
153
 
136
154
  this.renderWidth.value = width;
137
155
  this.renderHeight.value = height;
@@ -158,7 +176,13 @@ export class ShaderBuilder {
158
176
  const dispatchX = Math.ceil( tileWidth / WG_SIZE );
159
177
  const dispatchY = Math.ceil( tileHeight / WG_SIZE );
160
178
 
161
- if ( this.computeNode ) this.computeNode.setCount( [ dispatchX, dispatchY, 1 ] );
179
+ if ( this.computeNode ) {
180
+
181
+ this._dispatchSize[ 0 ] = dispatchX;
182
+ this._dispatchSize[ 1 ] = dispatchY;
183
+ this.computeNode.dispatchSize = this._dispatchSize;
184
+
185
+ }
162
186
 
163
187
  }
164
188
 
@@ -170,13 +194,36 @@ export class ShaderBuilder {
170
194
  this.tileOffsetX.value = 0;
171
195
  this.tileOffsetY.value = 0;
172
196
 
173
- if ( this.computeNode ) this.computeNode.setCount( [ this._dispatchX, this._dispatchY, 1 ] );
197
+ if ( this.computeNode ) {
198
+
199
+ this._dispatchSize[ 0 ] = this._dispatchX;
200
+ this._dispatchSize[ 1 ] = this._dispatchY;
201
+ this.computeNode.dispatchSize = this._dispatchSize;
202
+
203
+ }
174
204
 
175
205
  }
176
206
 
177
- forceCompile() {
207
+ /**
208
+ * Front-load GPU compute pipeline creation via a single dispatch.
209
+ *
210
+ * Three.js WebGPU has no `createComputePipelineAsync` path — compute
211
+ * pipelines always compile synchronously on first `renderer.compute(node)`.
212
+ * Calling this at build time (while a "Compiling shaders…" status is
213
+ * already visible) moves the stall off the first animate frame.
214
+ *
215
+ * The dispatch writes to ping-pong storage textures whose contents are
216
+ * discarded by the subsequent `reset()` (frame counter back to 0 →
217
+ * `hasPreviousAccumulated = 0` → prev textures are not read).
218
+ *
219
+ * @param {object} renderer - WebGPURenderer
220
+ */
221
+ forceCompile( renderer ) {
222
+
223
+ if ( this._compiled || ! this.computeNode || ! renderer ) return;
178
224
 
179
- // No-op — compilation happens on first renderer.compute() call.
225
+ this._compiled = true;
226
+ renderer.compute( this.computeNode );
180
227
 
181
228
  }
182
229
 
@@ -379,6 +426,7 @@ export class ShaderBuilder {
379
426
  this.prevAlbedoTexNode = null;
380
427
  this.adaptiveSamplingTexNode = null;
381
428
  this._sceneTextureNodes = null;
429
+ this._compiled = false;
382
430
 
383
431
  }
384
432
 
@@ -803,10 +803,10 @@ export class ASVGF extends RenderStage {
803
803
  // Update dispatch dimensions
804
804
  this._dispatchX = Math.ceil( width / 8 );
805
805
  this._dispatchY = Math.ceil( height / 8 );
806
- this._gradientNode.setCount( [ this._dispatchX, this._dispatchY, 1 ] );
807
- this._temporalNodeA.setCount( [ this._dispatchX, this._dispatchY, 1 ] );
808
- this._temporalNodeB.setCount( [ this._dispatchX, this._dispatchY, 1 ] );
809
- this._heatmapComputeNode.setCount( [ this._dispatchX, this._dispatchY, 1 ] );
806
+ this._gradientNode.dispatchSize = [ this._dispatchX, this._dispatchY, 1 ];
807
+ this._temporalNodeA.dispatchSize = [ this._dispatchX, this._dispatchY, 1 ];
808
+ this._temporalNodeB.dispatchSize = [ this._dispatchX, this._dispatchY, 1 ];
809
+ this._heatmapComputeNode.dispatchSize = [ this._dispatchX, this._dispatchY, 1 ];
810
810
 
811
811
  }
812
812
 
@@ -422,8 +422,8 @@ export class AdaptiveSampling extends RenderStage {
422
422
  // Update dispatch dimensions
423
423
  this._dispatchX = Math.ceil( width / 16 );
424
424
  this._dispatchY = Math.ceil( height / 16 );
425
- this._computeNode.setCount( [ this._dispatchX, this._dispatchY, 1 ] );
426
- this._heatmapComputeNode.setCount( [ this._dispatchX, this._dispatchY, 1 ] );
425
+ this._computeNode.dispatchSize = [ this._dispatchX, this._dispatchY, 1 ];
426
+ this._heatmapComputeNode.dispatchSize = [ this._dispatchX, this._dispatchY, 1 ];
427
427
 
428
428
  }
429
429