rayzee 9.1.2 → 9.2.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.
Files changed (46) hide show
  1. package/README.md +174 -15
  2. package/dist/{NeuralRetouch-BRquqfmZ.js → NeuralRetouch-DgnfldWO.js} +1 -1
  3. package/dist/{NeuralSuperRes-CbNvQjCw.js → NeuralSuperRes-Baa2w8VU.js} +72 -40
  4. package/dist/{ToneMapGPU-DpUZDMbQ.js → ToneMapGPU-CNgexVto.js} +75 -71
  5. package/dist/rayzee.es.js +4915 -4314
  6. package/dist/rayzee.umd.js +96 -96
  7. package/package.json +3 -2
  8. package/src/AssetConfig.js +10 -5
  9. package/src/EngineEvents.js +41 -1
  10. package/src/EngineIssues.js +3 -0
  11. package/src/Headless.js +23 -7
  12. package/src/HeadlessCanvas.js +104 -0
  13. package/src/Passes/OIDNDenoiser.js +29 -4
  14. package/src/Passes/OIDNTemporalHistory.js +7 -0
  15. package/src/PathTracerApp.js +432 -119
  16. package/src/Platform.js +76 -0
  17. package/src/Processor/AssetLoader.js +66 -4
  18. package/src/Processor/BVHBuilder.js +2 -1
  19. package/src/Processor/EquirectHDRInfo.js +2 -1
  20. package/src/Processor/HostMemory.js +14 -0
  21. package/src/Processor/PBRT/PBRTSceneBuilder.js +26 -0
  22. package/src/Processor/ParallelBVHBuilder.js +7 -6
  23. package/src/Processor/PlatformImageLoader.js +118 -0
  24. package/src/Processor/ResampleRGBA8.js +102 -0
  25. package/src/Processor/SceneProcessor.js +7 -6
  26. package/src/Processor/TextureCreator.js +162 -13
  27. package/src/Processor/Workers/PackWorker.js +28 -0
  28. package/src/Stages/Compositor.js +25 -6
  29. package/src/Stages/PathTracer.js +142 -25
  30. package/src/Stages/PathTracerStage.js +6 -96
  31. package/src/TSL/DebugKernel.js +1 -1
  32. package/src/TSL/GenerateKernel.js +2 -5
  33. package/src/TSL/Random.js +3 -120
  34. package/src/TSL/ShadeKernel.js +3 -3
  35. package/src/index.js +2 -1
  36. package/src/managers/CameraManager.js +2 -2
  37. package/src/managers/DenoisingManager.js +76 -7
  38. package/src/managers/GoboManager.js +8 -1
  39. package/src/managers/IESManager.js +13 -2
  40. package/src/managers/InteractionManager.js +1 -1
  41. package/src/managers/LightManager.js +31 -0
  42. package/src/managers/OverlayManager.js +11 -1
  43. package/src/managers/VideoRenderManager.js +8 -0
  44. package/src/node/NodeWorker.js +144 -0
  45. package/src/node/index.js +9 -0
  46. package/src/node/nodePlatform.js +86 -0
@@ -1,7 +1,7 @@
1
1
  import { WebGPURenderer, RectAreaLightNode, SRGBColorSpace, LinearSRGBColorSpace } from 'three/webgpu';
2
2
  import { texture as _tslTexture, cubeTexture as _tslCubeTexture } from 'three/tsl';
3
3
  import {
4
- Scene, EventDispatcher, Box3, Vector3
4
+ Scene, EventDispatcher, Box3, Vector3, REVISION,
5
5
  } from 'three';
6
6
  import { RectAreaLightTexturesLib } from 'three/addons/lights/RectAreaLightTexturesLib.js';
7
7
  import { SceneHelpers } from './SceneHelpers.js';
@@ -23,9 +23,9 @@ import { BuildTimer } from './Processor/BuildTimer.js';
23
23
  import { TextureReadback } from './Processor/TextureReadback.js';
24
24
  import { createLogger, fmt } from './utils/Logger.js';
25
25
  import { InteractionManager } from './managers/InteractionManager.js';
26
- import { EngineEvents } from './EngineEvents.js';
26
+ import { EngineEvents, LEGACY_EVENT_NAMES } from './EngineEvents.js';
27
27
  import { IssueLog, ISSUE_CODES } from './EngineIssues.js';
28
- import { getAssetConfig } from './AssetConfig.js';
28
+ import { getAssetConfig, isAssetConfigured } from './AssetConfig.js';
29
29
  import { StorageManager } from './Storage/StorageManager.js';
30
30
  import { acquireSharedStorage } from './Storage/openStorage.js';
31
31
  import { nameFromUrl } from './Storage/DownloadCache.js';
@@ -37,6 +37,8 @@ import { getViewTransform } from './Color/ViewTransforms.js';
37
37
  import { AssetLoader } from './Processor/AssetLoader.js';
38
38
  import { SPILL_TRIANGLE_BUDGET, SPILL_PLACEMENT_BUDGET } from './Processor/PBRT/index.js';
39
39
  import { SceneProcessor } from './Processor/SceneProcessor.js';
40
+ import { deviceMemoryGB } from './Processor/HostMemory.js';
41
+ import { createHeadlessCanvas } from './HeadlessCanvas.js';
40
42
 
41
43
  // Managers
42
44
  import { RenderSettings } from './RenderSettings.js';
@@ -53,6 +55,7 @@ import { TransformGizmoHelper } from './managers/helpers/TransformGizmoHelper.js
53
55
  import { captureSceneState, applySceneState } from './SceneState/SceneState.js';
54
56
  import { TRIANGLE_PATCH_PROPERTIES } from './managers/MaterialDataManager.js';
55
57
  import { VERSION } from './version.js';
58
+ import { toPortable } from './SceneState/portable.js';
56
59
 
57
60
  export const RENDER_CHECKPOINT_VERSION = 1;
58
61
 
@@ -66,6 +69,29 @@ const partOf = ( { element, pbrtEntry } = {} ) => ( {
66
69
  // instantiates (StrictMode, HMR, etc.) so its rAF loop can't burn CPU.
67
70
  const _appsByCanvas = new WeakMap();
68
71
 
72
+ const KNOWN_EVENTS = new Set( [ ...Object.values( EngineEvents ), ...Object.values( LEGACY_EVENT_NAMES ) ] );
73
+ const _warnedEvents = new Set();
74
+
75
+ // three's Animation.start() calls self.requestAnimationFrame during init() with no guard, and
76
+ // outside a browser there is no `self`. A headless app never uses that loop, so it gets an inert one.
77
+ async function initWithoutFrameLoop( renderer, headless ) {
78
+
79
+ if ( ! headless || typeof self !== 'undefined' ) return renderer.init();
80
+
81
+ globalThis.self = { requestAnimationFrame: () => 0, cancelAnimationFrame: () => {} };
82
+
83
+ try {
84
+
85
+ return await renderer.init();
86
+
87
+ } finally {
88
+
89
+ delete globalThis.self;
90
+
91
+ }
92
+
93
+ }
94
+
69
95
 
70
96
  /**
71
97
  * WebGPU Path Tracer Application.
@@ -137,6 +163,9 @@ export class PathTracerApp extends EventDispatcher {
137
163
  * @param {HTMLCanvasElement} canvas - Canvas element for rendering
138
164
  * @param {Object} [options] - Engine options
139
165
  * @param {boolean} [options.autoResize=true] - Automatically listen for window resize events
166
+ * @param {boolean} [options.headless=false] - a render nobody watches, and the default when no canvas
167
+ * is given: the engine supplies its own canvas (see HeadlessCanvas.js), runs no render loop — drive it
168
+ * with renderFrames() or renderUntilComplete() — and builds neither the overlay nor the gizmo
140
169
  * @param {HTMLElement} [options.container] - Single DOM parent the engine mounts all auxiliary
141
170
  * elements into (HUD overlay, denoiser canvas). Defaults to `canvas.parentNode`.
142
171
  * @param {boolean} [options.strict=false] - Throw at the point of degradation instead of
@@ -146,8 +175,11 @@ export class PathTracerApp extends EventDispatcher {
146
175
  * @param {number} [options.maxSceneBytes] - refuse a scene whose estimated host memory is
147
176
  * above this. The default refuses where the renderer process would be killed instead of
148
177
  * throwing; raise it deliberately, on a fresh browser. See HostMemory.js.
178
+ * @param {number} [options.hostMemoryGB] - the host's memory, where `navigator.deviceMemory` is
179
+ * absent (anything but Chrome) or wrong; it sizes the render reserve and the path pool
149
180
  * @param {false|'auto'|StorageManager} [options.storage] - on-disk storage; defaults to
150
- * `configureAssets( { storage } )`. A host-supplied manager stays the host's to dispose.
181
+ * `configureAssets( { storage } )`, or off under `strict` unless that was set. A host-supplied
182
+ * manager stays the host's to dispose.
151
183
  * @param {boolean} [options.memorySpill=false] - experimental: once a large static scene is
152
184
  * on the GPU, move its triangle records and BLAS nodes to disk (see
153
185
  * {@link ensureSceneResident}). Needs storage; skipped for animated scenes.
@@ -159,6 +191,9 @@ export class PathTracerApp extends EventDispatcher {
159
191
 
160
192
  super();
161
193
 
194
+ this._headless = options.headless === true || ! canvas;
195
+ canvas ??= createHeadlessCanvas();
196
+
162
197
  try {
163
198
 
164
199
  _appsByCanvas.get( canvas )?.dispose();
@@ -172,9 +207,10 @@ export class PathTracerApp extends EventDispatcher {
172
207
  _appsByCanvas.set( canvas, this );
173
208
 
174
209
  this.canvas = canvas;
175
- this._autoResize = options.autoResize !== false;
210
+ this._autoResize = ! this._headless && options.autoResize !== false;
176
211
  // A scene budget the host may raise; read where SceneProcessor is built, well after this.
177
212
  this._maxSceneBytes = options.maxSceneBytes;
213
+ this._hostMemoryGB = options.hostMemoryGB;
178
214
  this._storageOption = options.storage;
179
215
  this._memorySpill = options.memorySpill === true;
180
216
  /** @type {?StorageManager} on-disk storage, null when off or unavailable */
@@ -187,6 +223,7 @@ export class PathTracerApp extends EventDispatcher {
187
223
 
188
224
  // Before the settings: the profile supplies some of their defaults.
189
225
  this._profile = getRenderProfile( options.profile );
226
+ this._profileName = options.profile ?? 'viewer';
190
227
 
191
228
  // First, so no subsystem can degrade unrecorded.
192
229
  this._issues = new IssueLog( {
@@ -378,7 +415,9 @@ export class PathTracerApp extends EventDispatcher {
378
415
 
379
416
  async _initStorage() {
380
417
 
381
- const option = this._storageOption ?? getAssetConfig().storage;
418
+ // A batch render must not be answered from an earlier run's cache unless the host asked for one.
419
+ const option = this._storageOption
420
+ ?? ( this._issues.strict && ! isAssetConfigured( 'storage' ) ? false : getAssetConfig().storage );
382
421
  if ( option === false ) return;
383
422
 
384
423
  if ( option instanceof StorageManager ) {
@@ -418,11 +457,34 @@ export class PathTracerApp extends EventDispatcher {
418
457
 
419
458
  }
420
459
 
460
+ dispatchEvent( event ) {
461
+
462
+ super.dispatchEvent( event );
463
+ const legacy = LEGACY_EVENT_NAMES[ event.type ];
464
+ if ( legacy ) super.dispatchEvent( { ...event, type: legacy } );
465
+
466
+ }
467
+
468
+ addEventListener( type, listener ) {
469
+
470
+ if ( ! KNOWN_EVENTS.has( type ) && ! _warnedEvents.has( type ) ) {
471
+
472
+ _warnedEvents.add( type );
473
+ log.warn( `no engine event is named "${type}", so this listener will never fire — see EngineEvents` );
474
+
475
+ }
476
+
477
+ super.addEventListener( type, listener );
478
+
479
+ }
480
+
421
481
  /**
422
482
  * Starts the animation loop.
423
483
  */
424
484
  animate() {
425
485
 
486
+ if ( this._headless ) throw new Error( 'a headless app has no render loop — drive it with renderFrames() or renderUntilComplete()' );
487
+
426
488
  // Device lost: stop the loop rather than rescheduling render() on a dead device.
427
489
  if ( this._deviceLost ) return;
428
490
 
@@ -523,42 +585,15 @@ export class PathTracerApp extends EventDispatcher {
523
585
 
524
586
  }
525
587
 
526
- this.pipeline.render();
527
- this.denoisingManager?.afterTrace();
528
-
529
- if ( ! this.stages.pathTracer.isComplete ) {
588
+ // A lockstep readback is due and still in flight; the frame would trace nothing.
589
+ if ( this.stages.pathTracer.readbackWait() ) {
530
590
 
531
- this.completion.updateTime();
532
- this.denoisingManager?.tickContinuousDenoise( this.stages.pathTracer.frameCount );
591
+ this.dispatchEvent( { type: EngineEvents.FRAME } );
592
+ return;
533
593
 
534
594
  }
535
595
 
536
- this._ensureVRAMWiring();
537
- // VRAM is monotonic and only changes on allocation events (scene/env
538
- // load, resize — each re-measures via _ensureVRAMWiring). Within an
539
- // accumulation burst nothing reallocates, so re-walking every stage's
540
- // textures each frame is wasted. Measure at burst start (catches any
541
- // reset-triggered allocation) + a periodic backstop; read cached otherwise.
542
- const tracker = this.stages.pathTracer?.vramTracker;
543
- const frame = this.stages.pathTracer?.frameCount ?? 0;
544
- if ( tracker && ( frame <= 1 || frame % 30 === 0 ) ) tracker.measure();
545
-
546
- updateStats( {
547
- timeElapsed: this.completion.timeElapsed,
548
- samples: getDisplaySamples( this.stages.pathTracer ),
549
- memoryUsed: tracker?.current ?? 0,
550
- memoryPeak: tracker?.peak ?? 0,
551
- } );
552
-
553
- // Only the wall-clock stop — PathTracer.render() retires the ceiling and convergence
554
- // itself, so whichever of the three arrives first wins.
555
- if ( this.completion.isTimeLimitReached(
556
- this.stages.pathTracer, this.settings.get( 'renderLimitMode' ), this.settings.get( 'renderTimeLimit' )
557
- ) ) {
558
-
559
- this.stages.pathTracer.isComplete = true;
560
-
561
- }
596
+ this._traceFrame( { liveDenoise: true } );
562
597
 
563
598
  // Render completion → denoise/upscale chain
564
599
  if ( this.stages.pathTracer.isComplete && this.completion.markComplete() ) {
@@ -568,17 +603,7 @@ export class PathTracerApp extends EventDispatcher {
568
603
  context: this.pipeline?.context,
569
604
  } );
570
605
 
571
- const completionInfo = {
572
- samples: this.stages.pathTracer.frameCount,
573
- timeElapsed: this.completion.timeElapsed,
574
- budgetOverrun: this.completion.budgetOverrun,
575
- // null means isComplete was forced rather than earned (the reconcile below);
576
- // a forced stop is closer to the ceiling than to convergence.
577
- reason: this.completion.stopCondition( this.stages.pathTracer ) ?? 'samples',
578
- };
579
-
580
- this.dispatchEvent( { type: 'RenderComplete', ...completionInfo } );
581
- this.dispatchEvent( { type: EngineEvents.RENDER_COMPLETE, ...completionInfo } );
606
+ this.dispatchEvent( { type: EngineEvents.RENDER_COMPLETE, ...this._completionInfo() } );
582
607
 
583
608
  }
584
609
 
@@ -589,6 +614,60 @@ export class PathTracerApp extends EventDispatcher {
589
614
 
590
615
  }
591
616
 
617
+ // One frame of a render and its bookkeeping, shared by animate() and renderUntilComplete().
618
+ _traceFrame( { liveDenoise = false } = {} ) {
619
+
620
+ const stage = this.stages.pathTracer;
621
+ this.pipeline.render();
622
+ this.denoisingManager?.afterTrace();
623
+
624
+ if ( ! stage.isComplete ) {
625
+
626
+ this.completion.updateTime();
627
+ if ( liveDenoise ) this.denoisingManager?.tickContinuousDenoise( stage.frameCount );
628
+
629
+ }
630
+
631
+ this._ensureVRAMWiring();
632
+ // VRAM is monotonic and only changes on allocation events (scene/env
633
+ // load, resize — each re-measures via _ensureVRAMWiring). Within an
634
+ // accumulation burst nothing reallocates, so re-walking every stage's
635
+ // textures each frame is wasted. Measure at burst start (catches any
636
+ // reset-triggered allocation) + a periodic backstop; read cached otherwise.
637
+ const tracker = stage.vramTracker;
638
+ const frame = stage.frameCount ?? 0;
639
+ if ( tracker && ( frame <= 1 || frame % 30 === 0 ) ) tracker.measure();
640
+
641
+ updateStats( {
642
+ timeElapsed: this.completion.timeElapsed,
643
+ samples: getDisplaySamples( stage ),
644
+ memoryUsed: tracker?.current ?? 0,
645
+ memoryPeak: tracker?.peak ?? 0,
646
+ } );
647
+
648
+ // Only the wall-clock stop — PathTracer.render() retires the ceiling and convergence
649
+ // itself, so whichever of the three arrives first wins.
650
+ if ( this.completion.isTimeLimitReached( stage, this.settings.get( 'renderLimitMode' ), this.settings.get( 'renderTimeLimit' ) ) ) {
651
+
652
+ stage.isComplete = true;
653
+
654
+ }
655
+
656
+ }
657
+
658
+ _completionInfo() {
659
+
660
+ return {
661
+ samples: this.stages.pathTracer.frameCount,
662
+ timeElapsed: this.completion.timeElapsed,
663
+ budgetOverrun: this.completion.budgetOverrun,
664
+ // null means isComplete was forced rather than earned (the reconcile below);
665
+ // a forced stop is closer to the ceiling than to convergence.
666
+ reason: this.completion.stopCondition( this.stages.pathTracer ) ?? 'samples',
667
+ };
668
+
669
+ }
670
+
592
671
  /**
593
672
  * Stops the animation loop.
594
673
  */
@@ -621,7 +700,7 @@ export class PathTracerApp extends EventDispatcher {
621
700
  /** Wakes the animation loop if it was stopped due to idle. */
622
701
  wake() {
623
702
 
624
- if ( this._deviceLost ) return;
703
+ if ( this._deviceLost || this._headless ) return;
625
704
  if ( ! this.animationManagerId && this.isInitialized && ! this._paused ) this.animate();
626
705
 
627
706
  }
@@ -638,7 +717,7 @@ export class PathTracerApp extends EventDispatcher {
638
717
  resume() {
639
718
 
640
719
  this._paused = false;
641
- if ( ! this.animationManagerId ) this.animate();
720
+ if ( ! this.animationManagerId && ! this._headless ) this.animate();
642
721
 
643
722
  }
644
723
 
@@ -675,7 +754,6 @@ export class PathTracerApp extends EventDispatcher {
675
754
 
676
755
  this.completion.reset();
677
756
  this.wake();
678
- this.dispatchEvent( { type: 'RenderReset' } );
679
757
  this.dispatchEvent( { type: EngineEvents.RENDER_RESET } );
680
758
 
681
759
  }
@@ -859,7 +937,7 @@ export class PathTracerApp extends EventDispatcher {
859
937
 
860
938
  this.denoisingManager?.dropDisplay();
861
939
  this.reset();
862
- this.dispatchEvent( { type: 'SceneUnloaded' } );
940
+ this.dispatchEvent( { type: EngineEvents.SCENE_UNLOADED } );
863
941
 
864
942
  }
865
943
 
@@ -872,7 +950,7 @@ export class PathTracerApp extends EventDispatcher {
872
950
 
873
951
  await this._loadWithSceneRebuild(
874
952
  () => this.assetLoader.loadModel( url, options ),
875
- { type: 'ModelLoaded', url },
953
+ { type: EngineEvents.MODEL_LOADED, url },
876
954
  { kind: 'url', url, cacheKey: options.cacheKey ?? null }
877
955
  );
878
956
 
@@ -897,7 +975,7 @@ export class PathTracerApp extends EventDispatcher {
897
975
 
898
976
  await this._loadWithSceneRebuild(
899
977
  () => this.assetLoader.loadObject3D( object3d, name ),
900
- { type: 'Object3DLoaded', name },
978
+ { type: EngineEvents.OBJECT3D_LOADED, name },
901
979
  { kind: 'object3d', name }
902
980
  );
903
981
 
@@ -939,7 +1017,7 @@ export class PathTracerApp extends EventDispatcher {
939
1017
 
940
1018
  this.pipeline?.eventBus.emit( 'autoexposure:resetHistory' );
941
1019
  this.reset();
942
- this.dispatchEvent( { type: 'EnvironmentLoaded', url } );
1020
+ this.dispatchEvent( { type: EngineEvents.ENVIRONMENT_LOADED, url } );
943
1021
 
944
1022
  } finally {
945
1023
 
@@ -991,7 +1069,7 @@ export class PathTracerApp extends EventDispatcher {
991
1069
 
992
1070
  await this._loadWithSceneRebuild(
993
1071
  () => this.assetLoader.loadAssetFromUrl( file, { ...options, filename } ),
994
- { type: 'ModelLoaded', filename },
1072
+ { type: EngineEvents.MODEL_LOADED, filename },
995
1073
  { kind: 'url', url: file, filename, cacheKey: options.cacheKey ?? null, ...partOf( options ) }
996
1074
  );
997
1075
  return;
@@ -1006,7 +1084,7 @@ export class PathTracerApp extends EventDispatcher {
1006
1084
  const identity = fileIdentity( file ).catch( () => ( { name: file.name, size: file.size, lastModified: file.lastModified ?? 0, sample: null } ) );
1007
1085
  await this._loadWithSceneRebuild(
1008
1086
  () => this.assetLoader.loadAssetFromFile( file, options ),
1009
- { type: 'ModelLoaded', filename: file.name },
1087
+ { type: EngineEvents.MODEL_LOADED, filename: file.name },
1010
1088
  async () => {
1011
1089
 
1012
1090
  const id = await identity;
@@ -1037,7 +1115,7 @@ export class PathTracerApp extends EventDispatcher {
1037
1115
 
1038
1116
  this.pipeline?.eventBus.emit( 'autoexposure:resetHistory' );
1039
1117
  this.reset();
1040
- this.dispatchEvent( { type: 'EnvironmentLoaded', filename: file.name } );
1118
+ this.dispatchEvent( { type: EngineEvents.ENVIRONMENT_LOADED, filename: file.name } );
1041
1119
 
1042
1120
  } finally {
1043
1121
 
@@ -1056,7 +1134,7 @@ export class PathTracerApp extends EventDispatcher {
1056
1134
 
1057
1135
  await this._loadWithSceneRebuild(
1058
1136
  () => this.assetLoader.loadExampleModels( index, modelFiles ),
1059
- { type: 'ModelLoaded', index },
1137
+ { type: EngineEvents.MODEL_LOADED, index },
1060
1138
  { kind: 'url', url: modelFiles[ index ]?.url ?? null, cacheKey: null }
1061
1139
  );
1062
1140
 
@@ -1106,7 +1184,7 @@ export class PathTracerApp extends EventDispatcher {
1106
1184
 
1107
1185
  await this.loadSceneData();
1108
1186
  this.reset();
1109
- this.dispatchEvent( { type: 'TexturesReprocessed', maxTextureSize: this._maxTextureSize } );
1187
+ this.dispatchEvent( { type: EngineEvents.TEXTURES_REPROCESSED, maxTextureSize: this._maxTextureSize } );
1110
1188
 
1111
1189
  } finally {
1112
1190
 
@@ -1175,6 +1253,11 @@ export class PathTracerApp extends EventDispatcher {
1175
1253
  // there is nothing to fall back to. Leaving it half-built would keep the last
1176
1254
  // frame's buffers on screen under a scene that no longer exists.
1177
1255
  if ( ! error || error.code !== 'LOAD_IN_PROGRESS' ) this._discardFailedLoad();
1256
+
1257
+ // Only the scene build reported its own failure; one in the parse left a host's loading
1258
+ // status spinning on its last step. A part prompt is not a failure, so it just ends it.
1259
+ if ( error?.code === 'ARCHIVE_NEEDS_ELEMENT' ) resetLoading();
1260
+ else if ( error?.code !== 'LOAD_IN_PROGRESS' ) updateLoading( { status: `Error: ${error?.message ?? error}`, failed: true, progress: 100 } );
1178
1261
  throw error;
1179
1262
 
1180
1263
  } finally {
@@ -1446,7 +1529,7 @@ export class PathTracerApp extends EventDispatcher {
1446
1529
 
1447
1530
  this._initAnimationAndTransforms();
1448
1531
 
1449
- this.dispatchEvent( { type: 'SceneRebuild' } );
1532
+ this.dispatchEvent( { type: EngineEvents.SCENE_REBUILD } );
1450
1533
  return true;
1451
1534
 
1452
1535
  }
@@ -1540,7 +1623,7 @@ export class PathTracerApp extends EventDispatcher {
1540
1623
  root.userData.__rayzeeSourceUrl = url;
1541
1624
  if ( cacheKey ) root.userData.__rayzeeCacheKey = cacheKey;
1542
1625
  if ( name ) root.userData.__rayzeeName = name;
1543
- await this._finishRebuildNoReframe( { type: 'ModelAdded', url, id: root.uuid } );
1626
+ await this._finishRebuildNoReframe( { type: EngineEvents.MODEL_ADDED, url, id: root.uuid } );
1544
1627
  return root.uuid;
1545
1628
 
1546
1629
  } finally {
@@ -1575,7 +1658,7 @@ export class PathTracerApp extends EventDispatcher {
1575
1658
  const { root } = this.assetLoader.appendObject3D( object3d, name || 'object3d' );
1576
1659
  root.userData.__rayzeeSceneObject = true;
1577
1660
  if ( name ) root.userData.__rayzeeName = name;
1578
- await this._finishRebuildNoReframe( { type: 'ModelAdded', id: root.uuid } );
1661
+ await this._finishRebuildNoReframe( { type: EngineEvents.MODEL_ADDED, id: root.uuid } );
1579
1662
  return root.uuid;
1580
1663
 
1581
1664
  } finally {
@@ -1627,7 +1710,7 @@ export class PathTracerApp extends EventDispatcher {
1627
1710
 
1628
1711
  // Ground is permanent (removal refused above), so the scene always keeps
1629
1712
  // renderable geometry — a full rebuild is always valid here.
1630
- await this._finishRebuildNoReframe( { type: 'SceneObjectRemoved', id } );
1713
+ await this._finishRebuildNoReframe( { type: EngineEvents.SCENE_OBJECT_REMOVED, id } );
1631
1714
 
1632
1715
  return true;
1633
1716
 
@@ -1745,7 +1828,7 @@ export class PathTracerApp extends EventDispatcher {
1745
1828
 
1746
1829
  if ( ! this._memorySpill || ! this.storage || this.animationManager?.hasAnimations ) return;
1747
1830
  this._sdf?.spillToDisk( this.storage )
1748
- .then( result => result && this.dispatchEvent( { type: 'SceneSpilled', ...result } ) )
1831
+ .then( result => result && this.dispatchEvent( { type: EngineEvents.SCENE_SPILLED, ...result } ) )
1749
1832
  .catch( error => this._issues.warn( ISSUE_CODES.STORAGE_WRITE_FAILED, `memory spill failed: ${error.message}` ) );
1750
1833
 
1751
1834
  }
@@ -1764,7 +1847,7 @@ export class PathTracerApp extends EventDispatcher {
1764
1847
  _dispatchCamerasUpdated() {
1765
1848
 
1766
1849
  this.dispatchEvent( {
1767
- type: 'CamerasUpdated',
1850
+ type: EngineEvents.CAMERAS_UPDATED,
1768
1851
  cameras: this.cameraManager.cameras,
1769
1852
  cameraNames: this.cameraManager.getCameraNames(),
1770
1853
  } );
@@ -2060,12 +2143,24 @@ export class PathTracerApp extends EventDispatcher {
2060
2143
 
2061
2144
  const limits = this.renderer.backend?.device?.limits;
2062
2145
  const maxBinding = limits?.maxStorageBufferBindingSize || ( 128 * 1024 * 1024 );
2063
- const deviceMemGB = ( typeof navigator !== 'undefined' && navigator.deviceMemory ) || 4;
2146
+ const memory = deviceMemoryGB( this._hostMemoryGB );
2064
2147
  // 4K reserve pins the accum MRT (~1.5 GB) + aux; only grant it on clearly-capable GPUs.
2065
- const deviceSafeMax = ( deviceMemGB >= 8 && maxBinding >= 1024 * 1024 * 1024 )
2148
+ const deviceSafeMax = ( memory.gb >= 8 && maxBinding >= 1024 * 1024 * 1024 )
2066
2149
  ? MAX_RESERVABLE_RENDER_SIZE : 2048;
2067
2150
  const applied = setReservedRenderSize( Math.min( target, deviceSafeMax ) );
2068
2151
 
2152
+ if ( applied < Math.floor( requestedPx ) ) {
2153
+
2154
+ this._issues.warn(
2155
+ ISSUE_CODES.RENDER_RESERVE_CAPPED,
2156
+ `reserved render size ${fmt.n( requestedPx )}px was capped to ${fmt.n( applied )}px by this device's limits — ` +
2157
+ `renders above ${fmt.n( applied )}px will be declined` +
2158
+ ( memory.assumed && memory.gb < 8 ? ' (host memory unknown, 4 GB assumed: pass hostMemoryGB)' : '' ),
2159
+ { requested: requestedPx, applied, deviceMemoryGB: memory.gb, assumed: memory.assumed, maxStorageBufferBindingSize: maxBinding }
2160
+ );
2161
+
2162
+ }
2163
+
2069
2164
  // Gate the realloc on the textures that EXIST, not on how the binding moved: a raise applied while
2070
2165
  // nothing was allocated leaves `applied === prev` for every later call, so keying off that let the
2071
2166
  // first ineffective call poison every effective one after it (issue #9). The path tracer's write MRT
@@ -2102,7 +2197,7 @@ export class PathTracerApp extends EventDispatcher {
2102
2197
  }
2103
2198
 
2104
2199
  this.reset();
2105
- this.dispatchEvent( { type: 'reserved_render_size_changed', size: applied } );
2200
+ this.dispatchEvent( { type: EngineEvents.RESERVED_RENDER_SIZE_CHANGED, size: applied } );
2106
2201
 
2107
2202
  }
2108
2203
 
@@ -2124,19 +2219,7 @@ export class PathTracerApp extends EventDispatcher {
2124
2219
  this._pendingReservedRenderSize = null;
2125
2220
 
2126
2221
  const applied = this.setReservedRenderResolution( pending.requestedPx, { allowLower: pending.allowLower } );
2127
-
2128
- if ( applied < pending.requestedPx ) {
2129
-
2130
- this._issues.warn(
2131
- ISSUE_CODES.RENDER_RESERVE_CAPPED,
2132
- `reserved render size ${fmt.n( pending.requestedPx )}px was capped to ${fmt.n( applied )}px by this device's limits — ` +
2133
- `renders above ${fmt.n( applied )}px will be declined`,
2134
- { requested: pending.requestedPx, applied }
2135
- );
2136
-
2137
- }
2138
-
2139
- this.dispatchEvent( { type: 'reserved_render_size_changed', size: applied } );
2222
+ this.dispatchEvent( { type: EngineEvents.RESERVED_RENDER_SIZE_CHANGED, size: applied } );
2140
2223
 
2141
2224
  }
2142
2225
 
@@ -2206,7 +2289,7 @@ export class PathTracerApp extends EventDispatcher {
2206
2289
  this.denoisingManager?.setRenderSize( renderWidth, renderHeight );
2207
2290
  this.needsReset = true;
2208
2291
 
2209
- this.dispatchEvent( { type: 'resolution_changed', width: renderWidth, height: renderHeight } );
2292
+ this.dispatchEvent( { type: EngineEvents.RESOLUTION_CHANGED, width: renderWidth, height: renderHeight } );
2210
2293
 
2211
2294
  }
2212
2295
 
@@ -2382,6 +2465,43 @@ export class PathTracerApp extends EventDispatcher {
2382
2465
 
2383
2466
  }
2384
2467
 
2468
+ // Draws once without waking the loop: a woken loop takes a finished render nothing marked
2469
+ // complete (renderFrames, a video export) as newly finished, and denoises it again.
2470
+ _presentDisplay() {
2471
+
2472
+ if ( this.animationManagerId ) {
2473
+
2474
+ this._needsDisplayRefresh = true;
2475
+ return;
2476
+
2477
+ }
2478
+
2479
+ const context = this.pipeline?.context;
2480
+ if ( this._deviceLost || ! context ) return;
2481
+ this.stages.compositor?.render( context );
2482
+ this._renderHelperOverlay();
2483
+
2484
+ }
2485
+
2486
+ /**
2487
+ * One OIDN pass over the current accumulation at the final tier, resolved once the denoised
2488
+ * picture is published. Does not start the render loop or the upscaler; read the result with
2489
+ * `renderToBuffer( { source: 'display' } )`.
2490
+ * @returns {Promise<boolean>} whether a denoised picture was published
2491
+ */
2492
+ async runFinalDenoise() {
2493
+
2494
+ const dm = this.denoisingManager;
2495
+ if ( await dm?.denoiseOnce() ) return true;
2496
+
2497
+ const reason = ! dm?.denoiser ? 'the denoiser was not built'
2498
+ : ! dm.denoiser.enabled ? 'OIDN was off while accumulating — call denoisingManager.setOIDNEnabled( true ) first'
2499
+ : 'the denoise did not complete';
2500
+ this._issues.record( ISSUE_CODES.DENOISER_UNAVAILABLE, `final denoise produced no picture: ${reason}`, { reason } );
2501
+ return false;
2502
+
2503
+ }
2504
+
2385
2505
  // Aborts any in-flight denoise/upscale and puts the denoiser canvas back at base resolution (the
2386
2506
  // upscaler leaves it enlarged), so the live canvas is what's on screen again.
2387
2507
  _abortPostProcess( { keepDisplay = false } = {} ) {
@@ -2392,7 +2512,7 @@ export class PathTracerApp extends EventDispatcher {
2392
2512
 
2393
2513
  const w = this.denoisingManager._lastRenderWidth;
2394
2514
  const h = this.denoisingManager._lastRenderHeight;
2395
- this.dispatchEvent( { type: 'resolution_changed', width: w, height: h } );
2515
+ this.dispatchEvent( { type: EngineEvents.RESOLUTION_CHANGED, width: w, height: h } );
2396
2516
 
2397
2517
  }
2398
2518
 
@@ -2586,9 +2706,6 @@ export class PathTracerApp extends EventDispatcher {
2586
2706
  /**
2587
2707
  * Accumulates exactly `count` samples synchronously, bypassing the rAF loop.
2588
2708
  *
2589
- * Awaits the STBN atlases first — until they land the sampler reads a constant-0.5
2590
- * placeholder that gets baked permanently into the accumulation buffer.
2591
- *
2592
2709
  * A frame retired by adaptive sampling stops advancing `frameCount`, so a fixed-count loop
2593
2710
  * can never reach `count`. `allowEarlyRetire` makes that an outcome instead of a throw.
2594
2711
  *
@@ -2606,8 +2723,6 @@ export class PathTracerApp extends EventDispatcher {
2606
2723
  if ( ! stage ) throw new Error( 'renderFrames: app is not initialized' );
2607
2724
  if ( ! ( count > 0 ) ) throw new Error( `renderFrames: count must be positive, got ${count}` );
2608
2725
 
2609
- await stage.blueNoiseReady;
2610
-
2611
2726
  const target = ( reset ? 0 : stage.frameCount ) + count;
2612
2727
 
2613
2728
  // completionThreshold is a cached JS number derived from maxSamples, so this must
@@ -2631,6 +2746,14 @@ export class PathTracerApp extends EventDispatcher {
2631
2746
  if ( this._deviceLost ) throw new Error( 'renderFrames: WebGPU device lost' );
2632
2747
  if ( ! stage.isReady ) throw new Error( 'renderFrames: path tracer stage is not ready' );
2633
2748
 
2749
+ const wait = stage.readbackWait();
2750
+ if ( wait ) {
2751
+
2752
+ await this._awaitReadback( wait );
2753
+ continue;
2754
+
2755
+ }
2756
+
2634
2757
  this.pipeline.render();
2635
2758
  passes ++;
2636
2759
 
@@ -2669,6 +2792,156 @@ export class PathTracerApp extends EventDispatcher {
2669
2792
 
2670
2793
  }
2671
2794
 
2795
+ /**
2796
+ * Renders until the render's own stop condition — the sample ceiling, adaptive convergence or the
2797
+ * time limit, whichever comes first — without requestAnimationFrame, then runs the closing denoise
2798
+ * once. The production counterpart of {@link renderFrames}: adaptive sampling stays on, and its
2799
+ * readbacks run in lockstep (see PathTracer.setLockstepReadbacks), so the same input renders the same
2800
+ * image however fast frames are submitted — except under a time limit, which is wall-clock by nature.
2801
+ * The upscaler does not run. `EngineEvents.RENDER_COMPLETE` fires as it does from the loop.
2802
+ *
2803
+ * @param {Object} [options]
2804
+ * @param {boolean} [options.reset=true] - restart accumulation from sample 0 first
2805
+ * @param {boolean} [options.denoise=true] - run the final OIDN pass when `finalDenoise` is on
2806
+ * @param {AbortSignal} [options.signal] - rejects with its reason once aborted
2807
+ * @param {number} [options.drainEvery=4] - every N frames, wait for the GPU to finish the frames
2808
+ * before the last N, so submissions stay at most 2N frames ahead without the GPU idling
2809
+ * @param {function(number): void} [options.onProgress] - called with the running sample count
2810
+ * @returns {Promise<{samples: number, retiredBy: 'samples'|'converged'|'timeLimit', timeElapsed: number,
2811
+ * budgetOverrun: boolean, denoised: boolean}>}
2812
+ */
2813
+ async renderUntilComplete( { reset = true, denoise = true, signal, drainEvery = 4, onProgress } = {} ) {
2814
+
2815
+ const stage = this.stages.pathTracer;
2816
+ if ( ! stage ) throw new Error( 'renderUntilComplete: app is not initialized' );
2817
+ if ( this._loadingInProgress || this._sdf?.isProcessing ) throw new Error( 'renderUntilComplete: a scene is still loading' );
2818
+
2819
+ const lockstepWas = stage.lockstepReadbacks;
2820
+ stage.setLockstepReadbacks( true );
2821
+ // A 100 ms wall-clock mode for camera drags; frames in it do not count, so a batch render has no use for it.
2822
+ const interaction = this.settings.getEffective().interactionModeEnabled;
2823
+ this.settings.set( 'interactionModeEnabled', false, { silent: true, reset: false } );
2824
+
2825
+ try {
2826
+
2827
+ if ( reset ) this.reset();
2828
+ this.stopAnimation();
2829
+
2830
+ const queue = this.renderer.backend.device?.queue;
2831
+ let passes = 0;
2832
+ let previousBatch = null;
2833
+
2834
+ while ( ! stage.isComplete ) {
2835
+
2836
+ signal?.throwIfAborted();
2837
+ if ( this._deviceLost ) throw new Error( 'renderUntilComplete: WebGPU device lost' );
2838
+ if ( ! stage.isReady ) throw new Error( 'renderUntilComplete: path tracer stage is not ready' );
2839
+
2840
+ const wait = stage.readbackWait();
2841
+ if ( wait ) {
2842
+
2843
+ await this._awaitReadback( wait );
2844
+ continue;
2845
+
2846
+ }
2847
+
2848
+ this.cameraManager.camera.updateMatrixWorld();
2849
+ const counted = stage.frameCount;
2850
+ this._traceFrame();
2851
+ onProgress?.( stage.frameCount );
2852
+
2853
+ // A frame that did not count, with no readback to wait on, must still give timers a turn.
2854
+ if ( stage.frameCount === counted && ! stage.isComplete ) await new Promise( ( resolve ) => setTimeout( resolve, 0 ) );
2855
+
2856
+ if ( queue && drainEvery > 0 && ++ passes % drainEvery === 0 ) {
2857
+
2858
+ const batch = queue.onSubmittedWorkDone();
2859
+ if ( previousBatch ) await previousBatch;
2860
+ previousBatch = batch;
2861
+
2862
+ }
2863
+
2864
+ }
2865
+
2866
+ const info = this._completionInfo();
2867
+ if ( this.completion.markComplete() ) this.dispatchEvent( { type: EngineEvents.RENDER_COMPLETE, ...info } );
2868
+
2869
+ const denoised = denoise && this.denoisingManager?.finalDenoise ? await this.runFinalDenoise() : false;
2870
+ const { reason, ...rest } = info;
2871
+ return { ...rest, retiredBy: reason, denoised };
2872
+
2873
+ } finally {
2874
+
2875
+ stage.setLockstepReadbacks( lockstepWas );
2876
+ if ( interaction ) this.settings.set( 'interactionModeEnabled', interaction.value, { silent: true, reset: false, source: interaction.source } );
2877
+
2878
+ }
2879
+
2880
+ }
2881
+
2882
+ /**
2883
+ * What produced the current image, as plain JSON, to keep beside a render: engine and three.js
2884
+ * versions, the profile by name and by value (a name's values can change between releases), the
2885
+ * adapter, every live setting with its source, the colour pipeline, and the modes that decide
2886
+ * whether the render reproduces.
2887
+ * @returns {Object}
2888
+ */
2889
+ getProvenance() {
2890
+
2891
+ const stage = this.stages.pathTracer;
2892
+ const color = this.color?.status();
2893
+
2894
+ return toPortable( {
2895
+ engine: VERSION,
2896
+ three: REVISION,
2897
+ profile: { name: this._profileName, values: { ...this._profile } },
2898
+ adapter: this.adapterInfo ?? null,
2899
+ mode: {
2900
+ headless: this._headless,
2901
+ strict: this._issues.strict,
2902
+ deterministic: this.isDeterministic,
2903
+ lockstep: !! stage?.lockstepReadbacks,
2904
+ },
2905
+ render: stage ? {
2906
+ width: stage.width,
2907
+ height: stage.height,
2908
+ samples: stage.frameCount,
2909
+ complete: stage.isComplete,
2910
+ retiredBy: stage.isComplete ? this.completion.stopCondition( stage ) ?? 'samples' : null,
2911
+ } : null,
2912
+ color: color ? {
2913
+ config: color.config?.id ?? null,
2914
+ workingSpace: color.workingSpace,
2915
+ view: color.activeView ?? color.activeTransform?.id ?? null,
2916
+ context: color.context,
2917
+ } : null,
2918
+ scene: this.sceneSource,
2919
+ settings: this.settings.getEffective(),
2920
+ issues: this.issues.map( ( { code, severity } ) => ( { code, severity } ) ),
2921
+ } );
2922
+
2923
+ }
2924
+
2925
+ /**
2926
+ * Lockstep readbacks for renders driven by the loop or by {@link renderFrames}:
2927
+ * {@link renderUntilComplete} turns them on for its own duration. See PathTracer.setLockstepReadbacks.
2928
+ * @param {boolean} [enabled=true]
2929
+ */
2930
+ setLockstepReadbacks( enabled = true ) {
2931
+
2932
+ this.stages.pathTracer?.setLockstepReadbacks( enabled );
2933
+
2934
+ }
2935
+
2936
+ // A readback that never lands would hold the render forever; a lost device is the one way it can't.
2937
+ async _awaitReadback( wait ) {
2938
+
2939
+ const lost = this.renderer.backend.device?.lost;
2940
+ const outcome = await ( lost ? Promise.race( [ wait.then( () => null ), lost ] ) : wait );
2941
+ if ( outcome ) throw new Error( `WebGPU device lost while awaiting a readback: ${outcome.message ?? outcome.reason}` );
2942
+
2943
+ }
2944
+
2672
2945
  /**
2673
2946
  * A render in progress as data: the accumulated colour (and the aux buffers while they hold
2674
2947
  * samples), the per-pixel convergence buffers, and the counters that choose the next sample.
@@ -2721,7 +2994,9 @@ export class PathTracerApp extends EventDispatcher {
2721
2994
  * @param {'accumulation'|'display'} [options.source='accumulation'] - `'display'` reads what the
2722
2995
  * viewport is showing — denoised when a denoiser has run, without bloom — instead of the raw
2723
2996
  * accumulation
2724
- * @returns {Promise<{data: Float32Array|Uint8ClampedArray, width: number, height: number, colorSpace: string}>}
2997
+ * @returns {Promise<{data: Float32Array|Uint8ClampedArray, width: number, height: number, colorSpace: string,
2998
+ * source: string}>} `source` is what was read: `'accumulation'`, or the denoiser that published
2999
+ * it (`'oidn'`, `'asvgf'`, `'nrd'`, `'edgeFiltering'`, `'bilateralFiltering'`)
2725
3000
  */
2726
3001
  async renderToBuffer( { colorSpace = 'srgb', preserveAlpha = false, source = 'accumulation' } = {} ) {
2727
3002
 
@@ -2741,15 +3016,15 @@ export class PathTracerApp extends EventDispatcher {
2741
3016
  // The pool over-allocates to the reserve, so the texture is larger than the frame.
2742
3017
  const { width, height } = stage;
2743
3018
 
2744
- const linear = source === 'display'
3019
+ const { data: linear, source: read } = source === 'display'
2745
3020
  ? await this._readDisplaySource( target, width, height )
2746
- : await this.renderer.readRenderTargetPixelsAsync( target, 0, 0, width, height, 0 );
3021
+ : { data: await this.renderer.readRenderTargetPixelsAsync( target, 0, 0, width, height, 0 ), source: 'accumulation' };
2747
3022
 
2748
3023
  // `colorSpace` stays 'linear' — callers branch on it. `workingSpace` is the new, additive
2749
3024
  // answer to "linear in what primaries", which only means something once a config is loaded.
2750
3025
  if ( colorSpace === 'linear' ) {
2751
3026
 
2752
- return { data: linear, width, height, colorSpace, workingSpace: this.color?.workingSpace ?? null };
3027
+ return { data: linear, width, height, colorSpace, workingSpace: this.color?.workingSpace ?? null, source: read };
2753
3028
 
2754
3029
  }
2755
3030
 
@@ -2759,7 +3034,7 @@ export class PathTracerApp extends EventDispatcher {
2759
3034
  if ( named ) {
2760
3035
 
2761
3036
  const { rgba, colorSpace: got } = this.color.exportPixels( linear, colorSpace );
2762
- return { data: rgba, width, height, colorSpace: got };
3037
+ return { data: rgba, width, height, colorSpace: got, source: read };
2763
3038
 
2764
3039
  }
2765
3040
 
@@ -2773,6 +3048,7 @@ export class PathTracerApp extends EventDispatcher {
2773
3048
  width,
2774
3049
  height,
2775
3050
  colorSpace,
3051
+ source: read,
2776
3052
  };
2777
3053
 
2778
3054
  }
@@ -3089,6 +3365,8 @@ export class PathTracerApp extends EventDispatcher {
3089
3365
  */
3090
3366
  getMemoryInfo() {
3091
3367
 
3368
+ // Wired from the loop too, which renderFrames never enters: a headless host would miss these categories.
3369
+ this._ensureVRAMWiring();
3092
3370
  return this.stages.pathTracer?.vramTracker?.measure() ?? { current: 0, peak: 0, byCategory: {} };
3093
3371
 
3094
3372
  }
@@ -3121,11 +3399,19 @@ export class PathTracerApp extends EventDispatcher {
3121
3399
  if ( ! tracker ) return; // stages not ready yet
3122
3400
 
3123
3401
  tracker.register( 'stages', () => this._collectStageTextures() );
3402
+ tracker.register( 'denoiser', () => ( { bytes: this.denoisingManager?.gpuBytes() ?? 0 } ) );
3403
+ // One image per presented surface (a browser may keep one or two more), and the buffer
3404
+ // three's output pass tone-maps through.
3405
+ tracker.register( 'canvas', () => [
3406
+ { bytes: this.canvas.width * this.canvas.height * 4 },
3407
+ ...( this.renderer?._frameBufferTargets?.values() ?? [] ),
3408
+ ...( this.overlayManager?.gpuResources() ?? [] ),
3409
+ ] );
3124
3410
 
3125
3411
  const remeasure = () => tracker.measure();
3126
- this._addTrackedListener( this, 'SceneRebuild', remeasure );
3127
- this._addTrackedListener( this, 'EnvironmentLoaded', remeasure );
3128
- this._addTrackedListener( this, 'resolution_changed', remeasure );
3412
+ this._addTrackedListener( this, EngineEvents.SCENE_REBUILD, remeasure );
3413
+ this._addTrackedListener( this, EngineEvents.ENVIRONMENT_LOADED, remeasure );
3414
+ this._addTrackedListener( this, EngineEvents.RESOLUTION_CHANGED, remeasure );
3129
3415
 
3130
3416
  this._vramWired = true;
3131
3417
 
@@ -3376,15 +3662,33 @@ export class PathTracerApp extends EventDispatcher {
3376
3662
  async _readDisplaySource( accumulation, width, height ) {
3377
3663
 
3378
3664
  const context = this.pipeline?.context;
3379
- const texture = context && this.stages.compositor?.resolveLightTexture( context );
3380
- if ( ! texture || texture === accumulation.texture ) {
3665
+ const found = context && this.stages.compositor?.resolveLightSource( context );
3666
+ if ( ! found || found.key === 'pathtracer:color' || found.texture === accumulation.texture ) {
3667
+
3668
+ if ( this._denoiserInUse() ) {
3381
3669
 
3382
- return await this.renderer.readRenderTargetPixelsAsync( accumulation, 0, 0, width, height, 0 );
3670
+ this._issues.record(
3671
+ ISSUE_CODES.OUTPUT_SOURCE_FALLBACK,
3672
+ 'renderToBuffer: \'display\' was asked for, but no denoiser has published a picture — returned the raw accumulation',
3673
+ { requested: 'display', read: 'accumulation' }
3674
+ );
3675
+
3676
+ }
3677
+
3678
+ return { data: await this.renderer.readRenderTargetPixelsAsync( accumulation, 0, 0, width, height, 0 ), source: 'accumulation' };
3383
3679
 
3384
3680
  }
3385
3681
 
3386
3682
  this._textureReadback ??= new TextureReadback( this.renderer );
3387
- return await this._textureReadback.read( texture, width, height );
3683
+ return { data: await this._textureReadback.read( found.texture, width, height ), source: found.key.split( ':' )[ 0 ] };
3684
+
3685
+ }
3686
+
3687
+ // With none, the raw accumulation is the display.
3688
+ _denoiserInUse() {
3689
+
3690
+ const dm = this.denoisingManager;
3691
+ return !! dm?.denoiser?.enabled || ( dm?.denoiserStrategy ?? 'none' ) !== 'none';
3388
3692
 
3389
3693
  }
3390
3694
 
@@ -3548,9 +3852,9 @@ export class PathTracerApp extends EventDispatcher {
3548
3852
 
3549
3853
  setStatusCallback( ( event ) => this.dispatchEvent( event ) );
3550
3854
 
3551
- if ( ! navigator.gpu ) {
3855
+ if ( typeof navigator === 'undefined' || ! navigator.gpu ) {
3552
3856
 
3553
- throw new Error( 'WebGPU is not supported in this browser' );
3857
+ throw new Error( 'WebGPU is not available — outside a browser, install one as navigator.gpu (for Node, the `webgpu` package)' );
3554
3858
 
3555
3859
  }
3556
3860
 
@@ -3614,7 +3918,7 @@ export class PathTracerApp extends EventDispatcher {
3614
3918
  requiredLimits,
3615
3919
  } );
3616
3920
 
3617
- await this.renderer.init();
3921
+ await initWithoutFrameLoop( this.renderer, this._headless );
3618
3922
 
3619
3923
  // WebGPURenderer swaps in WebGL2 on failure with only a warn(). The wavefront path is
3620
3924
  // compute-only, so every frame would fail against an empty canvas.
@@ -3764,23 +4068,31 @@ export class PathTracerApp extends EventDispatcher {
3764
4068
  } );
3765
4069
  this.goboManager = new GoboManager( this.stages.pathTracer, {
3766
4070
  onReset: () => this.reset(),
4071
+ issues: this._issues,
3767
4072
  } );
3768
4073
  this.iesManager = new IESManager( this.stages.pathTracer, {
3769
4074
  onReset: () => this.reset(),
4075
+ issues: this._issues,
3770
4076
  } );
3771
4077
  this._setupDenoisingManager();
3772
- await this._setupOverlayManager();
3773
4078
 
3774
- this.transformManager = new TransformManager( {
3775
- camera: this.cameraManager.camera,
3776
- canvas: this.canvas,
3777
- orbitControls: this.cameraManager.controls,
3778
- app: this,
3779
- } );
4079
+ // A second renderer, a 2D canvas and a gizmo, all for pixels nobody sees.
4080
+ if ( ! this._headless ) {
3780
4081
 
3781
- // The gizmo is part of the scene overlay layer, so it draws on the same
3782
- // view-resolution surface as the light helpers and the outline.
3783
- this.overlayManager.register( 'transform', new TransformGizmoHelper( this.transformManager ) );
4082
+ await this._setupOverlayManager();
4083
+
4084
+ this.transformManager = new TransformManager( {
4085
+ camera: this.cameraManager.camera,
4086
+ canvas: this.canvas,
4087
+ orbitControls: this.cameraManager.controls,
4088
+ app: this,
4089
+ } );
4090
+
4091
+ // The gizmo is part of the scene overlay layer, so it draws on the same
4092
+ // view-resolution surface as the light helpers and the outline.
4093
+ this.overlayManager.register( 'transform', new TransformGizmoHelper( this.transformManager ) );
4094
+
4095
+ }
3784
4096
 
3785
4097
  // Wire cross-manager dependencies
3786
4098
  this.interactionManager.setDependencies( {
@@ -3794,7 +4106,7 @@ export class PathTracerApp extends EventDispatcher {
3794
4106
  this.denoisingManager.setOverlayManager( this.overlayManager );
3795
4107
  this.denoisingManager.setResetCallback( () => this.reset() );
3796
4108
  this.denoisingManager.setPostProcessRefreshCallback( () => this.requestPostProcessRefresh() );
3797
- this.denoisingManager.setDisplayRefreshCallback( () => this.refreshFrame() );
4109
+ this.denoisingManager.setDisplayRefreshCallback( () => this._presentDisplay() );
3798
4110
  this.denoisingManager.setSettings( this.settings );
3799
4111
 
3800
4112
  // Expose environment manager (lives on pathTracer stage)
@@ -3808,7 +4120,7 @@ export class PathTracerApp extends EventDispatcher {
3808
4120
  _wireEvents() {
3809
4121
 
3810
4122
  // Forward manager events → app events
3811
- this._addTrackedListener( this.cameraManager, 'CameraSwitched', ( e ) => this.dispatchEvent( e ) );
4123
+ this._addTrackedListener( this.cameraManager, EngineEvents.CAMERA_SWITCHED, ( e ) => this.dispatchEvent( e ) );
3812
4124
  this._addTrackedListener( this.cameraManager, EngineEvents.AUTO_FOCUS_UPDATED, ( e ) => this.dispatchEvent( e ) );
3813
4125
  this._addTrackedListener( this.cameraManager, EngineEvents.ORTHO_HEIGHT_UPDATED, ( e ) => this.dispatchEvent( e ) );
3814
4126
  this._addTrackedListener( this.timeline, EngineEvents.TIMELINE_CHANGED, ( e ) => this.dispatchEvent( e ) );
@@ -3816,7 +4128,7 @@ export class PathTracerApp extends EventDispatcher {
3816
4128
  this._forwardEvents( this.denoisingManager, [
3817
4129
  EngineEvents.DENOISING_START, EngineEvents.DENOISING_END,
3818
4130
  EngineEvents.UPSCALING_START, EngineEvents.UPSCALING_PROGRESS, EngineEvents.UPSCALING_END,
3819
- 'resolution_changed',
4131
+ EngineEvents.RESOLUTION_CHANGED,
3820
4132
  ] );
3821
4133
 
3822
4134
  this._setupAutoExposureListener();
@@ -3970,7 +4282,7 @@ export class PathTracerApp extends EventDispatcher {
3970
4282
 
3971
4283
  _createStages() {
3972
4284
 
3973
- this.stages.pathTracer = new PathTracer( this.renderer, this.scene, this.cameraManager.camera );
4285
+ this.stages.pathTracer = new PathTracer( this.renderer, this.scene, this.cameraManager.camera, { hostMemoryGB: this._hostMemoryGB } );
3974
4286
  this.stages.normalDepth = new NormalDepth( this.renderer, {
3975
4287
  pathTracer: this.stages.pathTracer
3976
4288
  } );
@@ -4011,6 +4323,7 @@ export class PathTracerApp extends EventDispatcher {
4011
4323
  pipeline: this.pipeline,
4012
4324
  getExposure: () => this.settings.get( 'exposure' ) ?? 1.0,
4013
4325
  getSaturation: () => this.settings.get( 'saturation' ) ?? 1.0,
4326
+ issues: this._issues,
4014
4327
  } );
4015
4328
 
4016
4329
  this.denoisingManager.setupDenoiser();