rayzee 5.3.8 → 5.4.1

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.
Files changed (48) hide show
  1. package/dist/rayzee.es.js +2933 -2888
  2. package/dist/rayzee.es.js.map +1 -1
  3. package/dist/rayzee.umd.js +53 -53
  4. package/dist/rayzee.umd.js.map +1 -1
  5. package/package.json +2 -2
  6. package/src/Passes/AIUpscaler.js +30 -6
  7. package/src/Passes/OIDNDenoiser.js +57 -15
  8. package/src/PathTracerApp.js +154 -21
  9. package/src/Pipeline/RenderPipeline.js +10 -1
  10. package/src/Processor/AssetLoader.js +40 -18
  11. package/src/Processor/EquirectHDRInfo.js +38 -29
  12. package/src/Processor/InstanceTable.js +16 -0
  13. package/src/Processor/SceneProcessor.js +22 -33
  14. package/src/Processor/ShaderBuilder.js +67 -22
  15. package/src/Processor/TLASBuilder.js +9 -4
  16. package/src/Stages/ASVGF.js +4 -4
  17. package/src/Stages/AdaptiveSampling.js +2 -2
  18. package/src/Stages/AutoExposure.js +42 -32
  19. package/src/Stages/BilateralFilter.js +2 -2
  20. package/src/Stages/Display.js +2 -1
  21. package/src/Stages/EdgeFilter.js +6 -3
  22. package/src/Stages/MotionVector.js +2 -2
  23. package/src/Stages/NormalDepth.js +1 -1
  24. package/src/Stages/PathTracer.js +88 -46
  25. package/src/Stages/SSRC.js +4 -4
  26. package/src/Stages/Variance.js +2 -2
  27. package/src/TSL/BVHTraversal.js +15 -63
  28. package/src/TSL/Clearcoat.js +1 -1
  29. package/src/TSL/Displacement.js +1 -1
  30. package/src/TSL/EmissiveSampling.js +17 -13
  31. package/src/TSL/Environment.js +12 -9
  32. package/src/TSL/LightBVHSampling.js +3 -2
  33. package/src/TSL/LightsCore.js +1 -1
  34. package/src/TSL/LightsDirect.js +1 -1
  35. package/src/TSL/LightsIndirect.js +0 -1
  36. package/src/TSL/LightsSampling.js +2 -2
  37. package/src/TSL/MaterialTransmission.js +1 -1
  38. package/src/TSL/PathTracer.js +4 -4
  39. package/src/TSL/PathTracerCore.js +6 -6
  40. package/src/TSL/Struct.js +1 -1
  41. package/src/TSL/patches.js +145 -0
  42. package/src/index.js +1 -1
  43. package/src/managers/EnvironmentManager.js +32 -56
  44. package/src/managers/LightManager.js +20 -0
  45. package/src/managers/UniformManager.js +22 -0
  46. package/src/managers/helpers/OutlineHelper.js +3 -1
  47. package/src/TSL/storageTexturePatch.js +0 -31
  48. package/src/TSL/structProxy.js +0 -87
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rayzee",
3
- "version": "5.3.8",
3
+ "version": "5.4.1",
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 {
@@ -533,8 +639,7 @@ export class PathTracerApp extends EventDispatcher {
533
639
 
534
640
  if ( ! this._sdf.uploadToPathTracer( this.stages.pathTracer, this.lightManager, this.meshScene, environmentTexture ) ) return false;
535
641
 
536
- // Build per-mesh visibility buffer (must happen before setupMaterial so the
537
- // shader graph captures the storage node during compilation)
642
+ // Patch per-mesh visibility into the TLAS leaves we just uploaded
538
643
  this.stages.pathTracer._meshRefs = this.stages.pathTracer._collectMeshRefs( this.meshScene );
539
644
  this.stages.pathTracer.setMeshVisibilityData( this.stages.pathTracer._meshRefs );
540
645
 
@@ -547,6 +652,24 @@ export class PathTracerApp extends EventDispatcher {
547
652
  this.stages.pathTracer.setupMaterial();
548
653
  timer.end( 'Material setup (TSL compile)' );
549
654
 
655
+ // Front-load GPU pipeline creation so the first animate frame is snappy:
656
+ // - compute: Three.js has no async compute compile — one dispatch at
657
+ // build time moves the stall to this loading moment.
658
+ // - raster fallback: compileAsync yields to main thread (r184+).
659
+ timer.start( 'Pipeline precompile' );
660
+ this.stages.pathTracer.shaderBuilder.forceCompile( this.renderer );
661
+ try {
662
+
663
+ await this.renderer.compileAsync( this.meshScene, this.cameraManager.camera );
664
+
665
+ } catch ( err ) {
666
+
667
+ console.warn( 'PathTracerApp: raster fallback precompile failed', err );
668
+
669
+ }
670
+
671
+ timer.end( 'Pipeline precompile' );
672
+
550
673
  // Wait for CDF
551
674
  if ( cdfPromise ) {
552
675
 
@@ -843,6 +966,16 @@ export class PathTracerApp extends EventDispatcher {
843
966
 
844
967
  }
845
968
 
969
+ /**
970
+ * Whether a model/environment load is currently in progress.
971
+ * @returns {boolean}
972
+ */
973
+ get isLoading() {
974
+
975
+ return this._loadingInProgress;
976
+
977
+ }
978
+
846
979
  /**
847
980
  * Whether the path tracer has finished converging.
848
981
  * @returns {boolean}
@@ -1011,7 +1144,7 @@ export class PathTracerApp extends EventDispatcher {
1011
1144
  this.assetLoader.setRenderer( this.renderer );
1012
1145
  this.assetLoader.createFloorPlane();
1013
1146
 
1014
- this.cameraManager.controls.addEventListener( 'change', () => {
1147
+ this._addTrackedListener( this.cameraManager.controls, 'change', () => {
1015
1148
 
1016
1149
  this.needsReset = true;
1017
1150
  this.wake();
@@ -1093,8 +1226,8 @@ export class PathTracerApp extends EventDispatcher {
1093
1226
  _wireEvents() {
1094
1227
 
1095
1228
  // 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 ) );
1229
+ this._addTrackedListener( this.cameraManager, 'CameraSwitched', ( e ) => this.dispatchEvent( e ) );
1230
+ this._addTrackedListener( this.cameraManager, EngineEvents.AUTO_FOCUS_UPDATED, ( e ) => this.dispatchEvent( e ) );
1098
1231
 
1099
1232
  this._forwardEvents( this.denoisingManager, [
1100
1233
  EngineEvents.DENOISING_START, EngineEvents.DENOISING_END,
@@ -1111,12 +1244,12 @@ export class PathTracerApp extends EventDispatcher {
1111
1244
  EngineEvents.ANIMATION_PAUSED,
1112
1245
  EngineEvents.ANIMATION_STOPPED,
1113
1246
  ] );
1114
- this.animationManager.addEventListener( EngineEvents.ANIMATION_PAUSED, () => {
1247
+ this._addTrackedListener( this.animationManager, EngineEvents.ANIMATION_PAUSED, () => {
1115
1248
 
1116
1249
  this._animRefitInFlight = false;
1117
1250
 
1118
1251
  } );
1119
- this.animationManager.addEventListener( EngineEvents.ANIMATION_STOPPED, () => {
1252
+ this._addTrackedListener( this.animationManager, EngineEvents.ANIMATION_STOPPED, () => {
1120
1253
 
1121
1254
  this._animRefitInFlight = false;
1122
1255
 
@@ -1152,7 +1285,7 @@ export class PathTracerApp extends EventDispatcher {
1152
1285
  this.resizeHandler = () => this.onResize();
1153
1286
  if ( this._autoResize ) {
1154
1287
 
1155
- window.addEventListener( 'resize', this.resizeHandler );
1288
+ this._addTrackedListener( window, 'resize', this.resizeHandler );
1156
1289
 
1157
1290
  }
1158
1291
 
@@ -1183,9 +1316,9 @@ export class PathTracerApp extends EventDispatcher {
1183
1316
 
1184
1317
  };
1185
1318
 
1186
- this.assetLoader.addEventListener( 'load', this._onAssetLoaded );
1319
+ this._addTrackedListener( this.assetLoader, 'load', this._onAssetLoaded );
1187
1320
 
1188
- this.assetLoader.addEventListener( 'modelProcessed', ( event ) => {
1321
+ this._addTrackedListener( this.assetLoader, 'modelProcessed', ( event ) => {
1189
1322
 
1190
1323
  const cameras = [ this.cameraManager.camera, ...( event.cameras || [] ) ];
1191
1324
  this.cameraManager.setCameras( cameras );
@@ -1382,7 +1515,7 @@ export class PathTracerApp extends EventDispatcher {
1382
1515
  if ( ! source ) return;
1383
1516
  for ( const type of eventTypes ) {
1384
1517
 
1385
- source.addEventListener( type, ( e ) => this.dispatchEvent( e ) );
1518
+ this._addTrackedListener( source, type, ( e ) => this.dispatchEvent( e ) );
1386
1519
 
1387
1520
  }
1388
1521
 
@@ -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
 
@@ -472,20 +472,28 @@ export class AssetLoader extends EventDispatcher {
472
472
  const loader = await this.createGLTFLoader();
473
473
  loader.manager = manager;
474
474
 
475
- return await new Promise( ( resolve, reject ) => {
475
+ try {
476
476
 
477
- loader.parse( gltfContent, '',
478
- gltf => {
477
+ return await new Promise( ( resolve, reject ) => {
479
478
 
480
- if ( this.targetModel ) disposeObjectFromMemory( this.targetModel );
481
- this.targetModel = gltf.scene;
482
- this.onModelLoad( this.targetModel ).then( () => resolve( gltf ) );
479
+ loader.parse( gltfContent, '',
480
+ gltf => {
483
481
 
484
- },
485
- error => reject( error )
486
- );
482
+ if ( this.targetModel ) disposeObjectFromMemory( this.targetModel );
483
+ this.targetModel = gltf.scene;
484
+ this.onModelLoad( this.targetModel ).then( () => resolve( gltf ) );
487
485
 
488
- } );
486
+ },
487
+ error => reject( error )
488
+ );
489
+
490
+ } );
491
+
492
+ } finally {
493
+
494
+ this._disposeGLTFLoader( loader );
495
+
496
+ }
489
497
 
490
498
  } else {
491
499
 
@@ -697,11 +705,10 @@ export class AssetLoader extends EventDispatcher {
697
705
 
698
706
  }
699
707
 
700
- // Model loading methods
708
+ // Returns a fresh loader each call — DRACOLoader/KTX2Loader hold persistent
709
+ // worker pools. Callers must invoke _disposeGLTFLoader() to terminate them.
701
710
  async createGLTFLoader() {
702
711
 
703
- if ( this.loaderCache.gltf ) return this.loaderCache.gltf;
704
-
705
712
  const dracoLoader = new DRACOLoader();
706
713
  dracoLoader.setDecoderConfig( { type: 'js' } );
707
714
  dracoLoader.setDecoderPath( 'https://www.gstatic.com/draco/v1/decoders/' );
@@ -726,18 +733,23 @@ export class AssetLoader extends EventDispatcher {
726
733
 
727
734
  }
728
735
 
729
- this.loaderCache.ktx2 = ktx2Loader;
730
-
731
736
  const loader = new GLTFLoader();
732
737
  loader.setDRACOLoader( dracoLoader );
733
738
  loader.setKTX2Loader( ktx2Loader );
734
739
  loader.setMeshoptDecoder( MeshoptDecoder );
735
740
 
736
- this.loaderCache.gltf = loader;
737
741
  return loader;
738
742
 
739
743
  }
740
744
 
745
+ _disposeGLTFLoader( loader ) {
746
+
747
+ if ( ! loader ) return;
748
+ loader.dracoLoader?.dispose();
749
+ loader.ktx2Loader?.dispose();
750
+
751
+ }
752
+
741
753
  async loadExampleModels( index, modelFiles ) {
742
754
 
743
755
  if ( ! modelFiles || ! modelFiles[ index ] ) {
@@ -753,9 +765,10 @@ export class AssetLoader extends EventDispatcher {
753
765
 
754
766
  async loadModel( modelUrl ) {
755
767
 
768
+ const loader = await this.createGLTFLoader();
769
+
756
770
  try {
757
771
 
758
- const loader = await this.createGLTFLoader();
759
772
  updateLoading( { status: "Loading Model...", progress: 2 } );
760
773
  const data = await loader.loadAsync( modelUrl );
761
774
  updateLoading( { status: "Processing Data...", progress: 10 } );
@@ -774,15 +787,20 @@ export class AssetLoader extends EventDispatcher {
774
787
  this.dispatchEvent( { type: 'error', message: error.message, filename: modelUrl } );
775
788
  throw error;
776
789
 
790
+ } finally {
791
+
792
+ this._disposeGLTFLoader( loader );
793
+
777
794
  }
778
795
 
779
796
  }
780
797
 
781
798
  async loadGLBFromArrayBuffer( arrayBuffer, filename = 'model.glb' ) {
782
799
 
800
+ const loader = await this.createGLTFLoader();
801
+
783
802
  try {
784
803
 
785
- const loader = await this.createGLTFLoader();
786
804
  updateLoading( { isLoading: true, status: "Processing GLB Data...", progress: 5 } );
787
805
  await new Promise( r => setTimeout( r, 0 ) );
788
806
 
@@ -804,6 +822,10 @@ export class AssetLoader extends EventDispatcher {
804
822
  this.dispatchEvent( { type: 'error', message: error.message, filename } );
805
823
  throw error;
806
824
 
825
+ } finally {
826
+
827
+ this._disposeGLTFLoader( loader );
828
+
807
829
  }
808
830
 
809
831
  }