rayzee 9.1.2 → 9.2.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 (49) hide show
  1. package/README.md +176 -16
  2. package/dist/{NeuralRetouch-BRquqfmZ.js → NeuralRetouch-CYgkQY8Z.js} +1 -1
  3. package/dist/{NeuralSuperRes-CbNvQjCw.js → NeuralSuperRes-PkgDLQcD.js} +72 -40
  4. package/dist/{ToneMapGPU-DpUZDMbQ.js → ToneMapGPU-B6XIe3DU.js} +102 -92
  5. package/dist/rayzee.es.js +6209 -5571
  6. package/dist/rayzee.umd.js +137 -126
  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 +511 -136
  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/TextureReadback.js +26 -10
  28. package/src/Processor/ToneMapGPU.js +45 -15
  29. package/src/Processor/Workers/PackWorker.js +28 -0
  30. package/src/Stages/Compositor.js +25 -6
  31. package/src/Stages/PathTracer.js +142 -25
  32. package/src/Stages/PathTracerStage.js +6 -96
  33. package/src/Storage/DownloadCache.js +30 -7
  34. package/src/TSL/DebugKernel.js +1 -1
  35. package/src/TSL/GenerateKernel.js +2 -5
  36. package/src/TSL/Random.js +3 -120
  37. package/src/TSL/ShadeKernel.js +3 -3
  38. package/src/index.js +2 -1
  39. package/src/managers/CameraManager.js +2 -2
  40. package/src/managers/DenoisingManager.js +76 -7
  41. package/src/managers/GoboManager.js +8 -1
  42. package/src/managers/IESManager.js +13 -2
  43. package/src/managers/InteractionManager.js +1 -1
  44. package/src/managers/LightManager.js +31 -0
  45. package/src/managers/OverlayManager.js +11 -1
  46. package/src/managers/VideoRenderManager.js +8 -0
  47. package/src/node/NodeWorker.js +144 -0
  48. package/src/node/index.js +9 -0
  49. 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,20 +23,23 @@ 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';
32
32
  import { fileIdentity, identityKey } from './Storage/identity.js';
33
33
  import { SETTING_SOURCE } from './RenderSettings.js';
34
34
  import { toneMapToRGBA8 } from './Processor/ToneMapCPU.js';
35
+ import { PackedToneMapper } from './Processor/ToneMapGPU.js';
35
36
  import { ColorManagement, setActiveColorManagement } from './Color/ColorManagement.js';
36
37
  import { getViewTransform } from './Color/ViewTransforms.js';
37
38
  import { AssetLoader } from './Processor/AssetLoader.js';
38
39
  import { SPILL_TRIANGLE_BUDGET, SPILL_PLACEMENT_BUDGET } from './Processor/PBRT/index.js';
39
40
  import { SceneProcessor } from './Processor/SceneProcessor.js';
41
+ import { deviceMemoryGB } from './Processor/HostMemory.js';
42
+ import { createHeadlessCanvas } from './HeadlessCanvas.js';
40
43
 
41
44
  // Managers
42
45
  import { RenderSettings } from './RenderSettings.js';
@@ -53,6 +56,7 @@ import { TransformGizmoHelper } from './managers/helpers/TransformGizmoHelper.js
53
56
  import { captureSceneState, applySceneState } from './SceneState/SceneState.js';
54
57
  import { TRIANGLE_PATCH_PROPERTIES } from './managers/MaterialDataManager.js';
55
58
  import { VERSION } from './version.js';
59
+ import { toPortable } from './SceneState/portable.js';
56
60
 
57
61
  export const RENDER_CHECKPOINT_VERSION = 1;
58
62
 
@@ -66,6 +70,29 @@ const partOf = ( { element, pbrtEntry } = {} ) => ( {
66
70
  // instantiates (StrictMode, HMR, etc.) so its rAF loop can't burn CPU.
67
71
  const _appsByCanvas = new WeakMap();
68
72
 
73
+ const KNOWN_EVENTS = new Set( [ ...Object.values( EngineEvents ), ...Object.values( LEGACY_EVENT_NAMES ) ] );
74
+ const _warnedEvents = new Set();
75
+
76
+ // three's Animation.start() calls self.requestAnimationFrame during init() with no guard, and
77
+ // outside a browser there is no `self`. A headless app never uses that loop, so it gets an inert one.
78
+ async function initWithoutFrameLoop( renderer, headless ) {
79
+
80
+ if ( ! headless || typeof self !== 'undefined' ) return renderer.init();
81
+
82
+ globalThis.self = { requestAnimationFrame: () => 0, cancelAnimationFrame: () => {} };
83
+
84
+ try {
85
+
86
+ return await renderer.init();
87
+
88
+ } finally {
89
+
90
+ delete globalThis.self;
91
+
92
+ }
93
+
94
+ }
95
+
69
96
 
70
97
  /**
71
98
  * WebGPU Path Tracer Application.
@@ -137,6 +164,9 @@ export class PathTracerApp extends EventDispatcher {
137
164
  * @param {HTMLCanvasElement} canvas - Canvas element for rendering
138
165
  * @param {Object} [options] - Engine options
139
166
  * @param {boolean} [options.autoResize=true] - Automatically listen for window resize events
167
+ * @param {boolean} [options.headless=false] - a render nobody watches, and the default when no canvas
168
+ * is given: the engine supplies its own canvas (see HeadlessCanvas.js), runs no render loop — drive it
169
+ * with renderFrames() or renderUntilComplete() — and builds neither the overlay nor the gizmo
140
170
  * @param {HTMLElement} [options.container] - Single DOM parent the engine mounts all auxiliary
141
171
  * elements into (HUD overlay, denoiser canvas). Defaults to `canvas.parentNode`.
142
172
  * @param {boolean} [options.strict=false] - Throw at the point of degradation instead of
@@ -146,8 +176,11 @@ export class PathTracerApp extends EventDispatcher {
146
176
  * @param {number} [options.maxSceneBytes] - refuse a scene whose estimated host memory is
147
177
  * above this. The default refuses where the renderer process would be killed instead of
148
178
  * throwing; raise it deliberately, on a fresh browser. See HostMemory.js.
179
+ * @param {number} [options.hostMemoryGB] - the host's memory, where `navigator.deviceMemory` is
180
+ * absent (anything but Chrome) or wrong; it sizes the render reserve and the path pool
149
181
  * @param {false|'auto'|StorageManager} [options.storage] - on-disk storage; defaults to
150
- * `configureAssets( { storage } )`. A host-supplied manager stays the host's to dispose.
182
+ * `configureAssets( { storage } )`, or off under `strict` unless that was set. A host-supplied
183
+ * manager stays the host's to dispose.
151
184
  * @param {boolean} [options.memorySpill=false] - experimental: once a large static scene is
152
185
  * on the GPU, move its triangle records and BLAS nodes to disk (see
153
186
  * {@link ensureSceneResident}). Needs storage; skipped for animated scenes.
@@ -159,6 +192,9 @@ export class PathTracerApp extends EventDispatcher {
159
192
 
160
193
  super();
161
194
 
195
+ this._headless = options.headless === true || ! canvas;
196
+ canvas ??= createHeadlessCanvas();
197
+
162
198
  try {
163
199
 
164
200
  _appsByCanvas.get( canvas )?.dispose();
@@ -172,9 +208,10 @@ export class PathTracerApp extends EventDispatcher {
172
208
  _appsByCanvas.set( canvas, this );
173
209
 
174
210
  this.canvas = canvas;
175
- this._autoResize = options.autoResize !== false;
211
+ this._autoResize = ! this._headless && options.autoResize !== false;
176
212
  // A scene budget the host may raise; read where SceneProcessor is built, well after this.
177
213
  this._maxSceneBytes = options.maxSceneBytes;
214
+ this._hostMemoryGB = options.hostMemoryGB;
178
215
  this._storageOption = options.storage;
179
216
  this._memorySpill = options.memorySpill === true;
180
217
  /** @type {?StorageManager} on-disk storage, null when off or unavailable */
@@ -187,6 +224,7 @@ export class PathTracerApp extends EventDispatcher {
187
224
 
188
225
  // Before the settings: the profile supplies some of their defaults.
189
226
  this._profile = getRenderProfile( options.profile );
227
+ this._profileName = options.profile ?? 'viewer';
190
228
 
191
229
  // First, so no subsystem can degrade unrecorded.
192
230
  this._issues = new IssueLog( {
@@ -378,7 +416,9 @@ export class PathTracerApp extends EventDispatcher {
378
416
 
379
417
  async _initStorage() {
380
418
 
381
- const option = this._storageOption ?? getAssetConfig().storage;
419
+ // A batch render must not be answered from an earlier run's cache unless the host asked for one.
420
+ const option = this._storageOption
421
+ ?? ( this._issues.strict && ! isAssetConfigured( 'storage' ) ? false : getAssetConfig().storage );
382
422
  if ( option === false ) return;
383
423
 
384
424
  if ( option instanceof StorageManager ) {
@@ -418,11 +458,34 @@ export class PathTracerApp extends EventDispatcher {
418
458
 
419
459
  }
420
460
 
461
+ dispatchEvent( event ) {
462
+
463
+ super.dispatchEvent( event );
464
+ const legacy = LEGACY_EVENT_NAMES[ event.type ];
465
+ if ( legacy ) super.dispatchEvent( { ...event, type: legacy } );
466
+
467
+ }
468
+
469
+ addEventListener( type, listener ) {
470
+
471
+ if ( ! KNOWN_EVENTS.has( type ) && ! _warnedEvents.has( type ) ) {
472
+
473
+ _warnedEvents.add( type );
474
+ log.warn( `no engine event is named "${type}", so this listener will never fire — see EngineEvents` );
475
+
476
+ }
477
+
478
+ super.addEventListener( type, listener );
479
+
480
+ }
481
+
421
482
  /**
422
483
  * Starts the animation loop.
423
484
  */
424
485
  animate() {
425
486
 
487
+ if ( this._headless ) throw new Error( 'a headless app has no render loop — drive it with renderFrames() or renderUntilComplete()' );
488
+
426
489
  // Device lost: stop the loop rather than rescheduling render() on a dead device.
427
490
  if ( this._deviceLost ) return;
428
491
 
@@ -523,42 +586,15 @@ export class PathTracerApp extends EventDispatcher {
523
586
 
524
587
  }
525
588
 
526
- this.pipeline.render();
527
- this.denoisingManager?.afterTrace();
589
+ // A lockstep readback is due and still in flight; the frame would trace nothing.
590
+ if ( this.stages.pathTracer.readbackWait() ) {
528
591
 
529
- if ( ! this.stages.pathTracer.isComplete ) {
530
-
531
- this.completion.updateTime();
532
- this.denoisingManager?.tickContinuousDenoise( this.stages.pathTracer.frameCount );
592
+ this.dispatchEvent( { type: EngineEvents.FRAME } );
593
+ return;
533
594
 
534
595
  }
535
596
 
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
- }
597
+ this._traceFrame( { liveDenoise: true } );
562
598
 
563
599
  // Render completion → denoise/upscale chain
564
600
  if ( this.stages.pathTracer.isComplete && this.completion.markComplete() ) {
@@ -568,17 +604,7 @@ export class PathTracerApp extends EventDispatcher {
568
604
  context: this.pipeline?.context,
569
605
  } );
570
606
 
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 } );
607
+ this.dispatchEvent( { type: EngineEvents.RENDER_COMPLETE, ...this._completionInfo() } );
582
608
 
583
609
  }
584
610
 
@@ -589,6 +615,60 @@ export class PathTracerApp extends EventDispatcher {
589
615
 
590
616
  }
591
617
 
618
+ // One frame of a render and its bookkeeping, shared by animate() and renderUntilComplete().
619
+ _traceFrame( { liveDenoise = false } = {} ) {
620
+
621
+ const stage = this.stages.pathTracer;
622
+ this.pipeline.render();
623
+ this.denoisingManager?.afterTrace();
624
+
625
+ if ( ! stage.isComplete ) {
626
+
627
+ this.completion.updateTime();
628
+ if ( liveDenoise ) this.denoisingManager?.tickContinuousDenoise( stage.frameCount );
629
+
630
+ }
631
+
632
+ this._ensureVRAMWiring();
633
+ // VRAM is monotonic and only changes on allocation events (scene/env
634
+ // load, resize — each re-measures via _ensureVRAMWiring). Within an
635
+ // accumulation burst nothing reallocates, so re-walking every stage's
636
+ // textures each frame is wasted. Measure at burst start (catches any
637
+ // reset-triggered allocation) + a periodic backstop; read cached otherwise.
638
+ const tracker = stage.vramTracker;
639
+ const frame = stage.frameCount ?? 0;
640
+ if ( tracker && ( frame <= 1 || frame % 30 === 0 ) ) tracker.measure();
641
+
642
+ updateStats( {
643
+ timeElapsed: this.completion.timeElapsed,
644
+ samples: getDisplaySamples( stage ),
645
+ memoryUsed: tracker?.current ?? 0,
646
+ memoryPeak: tracker?.peak ?? 0,
647
+ } );
648
+
649
+ // Only the wall-clock stop — PathTracer.render() retires the ceiling and convergence
650
+ // itself, so whichever of the three arrives first wins.
651
+ if ( this.completion.isTimeLimitReached( stage, this.settings.get( 'renderLimitMode' ), this.settings.get( 'renderTimeLimit' ) ) ) {
652
+
653
+ stage.isComplete = true;
654
+
655
+ }
656
+
657
+ }
658
+
659
+ _completionInfo() {
660
+
661
+ return {
662
+ samples: this.stages.pathTracer.frameCount,
663
+ timeElapsed: this.completion.timeElapsed,
664
+ budgetOverrun: this.completion.budgetOverrun,
665
+ // null means isComplete was forced rather than earned (the reconcile below);
666
+ // a forced stop is closer to the ceiling than to convergence.
667
+ reason: this.completion.stopCondition( this.stages.pathTracer ) ?? 'samples',
668
+ };
669
+
670
+ }
671
+
592
672
  /**
593
673
  * Stops the animation loop.
594
674
  */
@@ -621,7 +701,7 @@ export class PathTracerApp extends EventDispatcher {
621
701
  /** Wakes the animation loop if it was stopped due to idle. */
622
702
  wake() {
623
703
 
624
- if ( this._deviceLost ) return;
704
+ if ( this._deviceLost || this._headless ) return;
625
705
  if ( ! this.animationManagerId && this.isInitialized && ! this._paused ) this.animate();
626
706
 
627
707
  }
@@ -638,7 +718,7 @@ export class PathTracerApp extends EventDispatcher {
638
718
  resume() {
639
719
 
640
720
  this._paused = false;
641
- if ( ! this.animationManagerId ) this.animate();
721
+ if ( ! this.animationManagerId && ! this._headless ) this.animate();
642
722
 
643
723
  }
644
724
 
@@ -675,7 +755,6 @@ export class PathTracerApp extends EventDispatcher {
675
755
 
676
756
  this.completion.reset();
677
757
  this.wake();
678
- this.dispatchEvent( { type: 'RenderReset' } );
679
758
  this.dispatchEvent( { type: EngineEvents.RENDER_RESET } );
680
759
 
681
760
  }
@@ -708,6 +787,8 @@ export class PathTracerApp extends EventDispatcher {
708
787
  this.color = null;
709
788
  this._textureReadback?.dispose();
710
789
  this._textureReadback = null;
790
+ this._readbackToneMapper?.dispose();
791
+ this._readbackToneMapper = null;
711
792
 
712
793
  this.interactionManager?.deselect?.();
713
794
  this.transformManager?.detach?.();
@@ -859,7 +940,7 @@ export class PathTracerApp extends EventDispatcher {
859
940
 
860
941
  this.denoisingManager?.dropDisplay();
861
942
  this.reset();
862
- this.dispatchEvent( { type: 'SceneUnloaded' } );
943
+ this.dispatchEvent( { type: EngineEvents.SCENE_UNLOADED } );
863
944
 
864
945
  }
865
946
 
@@ -872,7 +953,7 @@ export class PathTracerApp extends EventDispatcher {
872
953
 
873
954
  await this._loadWithSceneRebuild(
874
955
  () => this.assetLoader.loadModel( url, options ),
875
- { type: 'ModelLoaded', url },
956
+ { type: EngineEvents.MODEL_LOADED, url },
876
957
  { kind: 'url', url, cacheKey: options.cacheKey ?? null }
877
958
  );
878
959
 
@@ -897,7 +978,7 @@ export class PathTracerApp extends EventDispatcher {
897
978
 
898
979
  await this._loadWithSceneRebuild(
899
980
  () => this.assetLoader.loadObject3D( object3d, name ),
900
- { type: 'Object3DLoaded', name },
981
+ { type: EngineEvents.OBJECT3D_LOADED, name },
901
982
  { kind: 'object3d', name }
902
983
  );
903
984
 
@@ -939,7 +1020,7 @@ export class PathTracerApp extends EventDispatcher {
939
1020
 
940
1021
  this.pipeline?.eventBus.emit( 'autoexposure:resetHistory' );
941
1022
  this.reset();
942
- this.dispatchEvent( { type: 'EnvironmentLoaded', url } );
1023
+ this.dispatchEvent( { type: EngineEvents.ENVIRONMENT_LOADED, url } );
943
1024
 
944
1025
  } finally {
945
1026
 
@@ -991,7 +1072,7 @@ export class PathTracerApp extends EventDispatcher {
991
1072
 
992
1073
  await this._loadWithSceneRebuild(
993
1074
  () => this.assetLoader.loadAssetFromUrl( file, { ...options, filename } ),
994
- { type: 'ModelLoaded', filename },
1075
+ { type: EngineEvents.MODEL_LOADED, filename },
995
1076
  { kind: 'url', url: file, filename, cacheKey: options.cacheKey ?? null, ...partOf( options ) }
996
1077
  );
997
1078
  return;
@@ -1006,7 +1087,7 @@ export class PathTracerApp extends EventDispatcher {
1006
1087
  const identity = fileIdentity( file ).catch( () => ( { name: file.name, size: file.size, lastModified: file.lastModified ?? 0, sample: null } ) );
1007
1088
  await this._loadWithSceneRebuild(
1008
1089
  () => this.assetLoader.loadAssetFromFile( file, options ),
1009
- { type: 'ModelLoaded', filename: file.name },
1090
+ { type: EngineEvents.MODEL_LOADED, filename: file.name },
1010
1091
  async () => {
1011
1092
 
1012
1093
  const id = await identity;
@@ -1037,7 +1118,7 @@ export class PathTracerApp extends EventDispatcher {
1037
1118
 
1038
1119
  this.pipeline?.eventBus.emit( 'autoexposure:resetHistory' );
1039
1120
  this.reset();
1040
- this.dispatchEvent( { type: 'EnvironmentLoaded', filename: file.name } );
1121
+ this.dispatchEvent( { type: EngineEvents.ENVIRONMENT_LOADED, filename: file.name } );
1041
1122
 
1042
1123
  } finally {
1043
1124
 
@@ -1056,7 +1137,7 @@ export class PathTracerApp extends EventDispatcher {
1056
1137
 
1057
1138
  await this._loadWithSceneRebuild(
1058
1139
  () => this.assetLoader.loadExampleModels( index, modelFiles ),
1059
- { type: 'ModelLoaded', index },
1140
+ { type: EngineEvents.MODEL_LOADED, index },
1060
1141
  { kind: 'url', url: modelFiles[ index ]?.url ?? null, cacheKey: null }
1061
1142
  );
1062
1143
 
@@ -1106,7 +1187,7 @@ export class PathTracerApp extends EventDispatcher {
1106
1187
 
1107
1188
  await this.loadSceneData();
1108
1189
  this.reset();
1109
- this.dispatchEvent( { type: 'TexturesReprocessed', maxTextureSize: this._maxTextureSize } );
1190
+ this.dispatchEvent( { type: EngineEvents.TEXTURES_REPROCESSED, maxTextureSize: this._maxTextureSize } );
1110
1191
 
1111
1192
  } finally {
1112
1193
 
@@ -1175,6 +1256,11 @@ export class PathTracerApp extends EventDispatcher {
1175
1256
  // there is nothing to fall back to. Leaving it half-built would keep the last
1176
1257
  // frame's buffers on screen under a scene that no longer exists.
1177
1258
  if ( ! error || error.code !== 'LOAD_IN_PROGRESS' ) this._discardFailedLoad();
1259
+
1260
+ // Only the scene build reported its own failure; one in the parse left a host's loading
1261
+ // status spinning on its last step. A part prompt is not a failure, so it just ends it.
1262
+ if ( error?.code === 'ARCHIVE_NEEDS_ELEMENT' ) resetLoading();
1263
+ else if ( error?.code !== 'LOAD_IN_PROGRESS' ) updateLoading( { status: `Error: ${error?.message ?? error}`, failed: true, progress: 100 } );
1178
1264
  throw error;
1179
1265
 
1180
1266
  } finally {
@@ -1446,7 +1532,7 @@ export class PathTracerApp extends EventDispatcher {
1446
1532
 
1447
1533
  this._initAnimationAndTransforms();
1448
1534
 
1449
- this.dispatchEvent( { type: 'SceneRebuild' } );
1535
+ this.dispatchEvent( { type: EngineEvents.SCENE_REBUILD } );
1450
1536
  return true;
1451
1537
 
1452
1538
  }
@@ -1540,7 +1626,7 @@ export class PathTracerApp extends EventDispatcher {
1540
1626
  root.userData.__rayzeeSourceUrl = url;
1541
1627
  if ( cacheKey ) root.userData.__rayzeeCacheKey = cacheKey;
1542
1628
  if ( name ) root.userData.__rayzeeName = name;
1543
- await this._finishRebuildNoReframe( { type: 'ModelAdded', url, id: root.uuid } );
1629
+ await this._finishRebuildNoReframe( { type: EngineEvents.MODEL_ADDED, url, id: root.uuid } );
1544
1630
  return root.uuid;
1545
1631
 
1546
1632
  } finally {
@@ -1575,7 +1661,7 @@ export class PathTracerApp extends EventDispatcher {
1575
1661
  const { root } = this.assetLoader.appendObject3D( object3d, name || 'object3d' );
1576
1662
  root.userData.__rayzeeSceneObject = true;
1577
1663
  if ( name ) root.userData.__rayzeeName = name;
1578
- await this._finishRebuildNoReframe( { type: 'ModelAdded', id: root.uuid } );
1664
+ await this._finishRebuildNoReframe( { type: EngineEvents.MODEL_ADDED, id: root.uuid } );
1579
1665
  return root.uuid;
1580
1666
 
1581
1667
  } finally {
@@ -1627,7 +1713,7 @@ export class PathTracerApp extends EventDispatcher {
1627
1713
 
1628
1714
  // Ground is permanent (removal refused above), so the scene always keeps
1629
1715
  // renderable geometry — a full rebuild is always valid here.
1630
- await this._finishRebuildNoReframe( { type: 'SceneObjectRemoved', id } );
1716
+ await this._finishRebuildNoReframe( { type: EngineEvents.SCENE_OBJECT_REMOVED, id } );
1631
1717
 
1632
1718
  return true;
1633
1719
 
@@ -1745,7 +1831,7 @@ export class PathTracerApp extends EventDispatcher {
1745
1831
 
1746
1832
  if ( ! this._memorySpill || ! this.storage || this.animationManager?.hasAnimations ) return;
1747
1833
  this._sdf?.spillToDisk( this.storage )
1748
- .then( result => result && this.dispatchEvent( { type: 'SceneSpilled', ...result } ) )
1834
+ .then( result => result && this.dispatchEvent( { type: EngineEvents.SCENE_SPILLED, ...result } ) )
1749
1835
  .catch( error => this._issues.warn( ISSUE_CODES.STORAGE_WRITE_FAILED, `memory spill failed: ${error.message}` ) );
1750
1836
 
1751
1837
  }
@@ -1764,7 +1850,7 @@ export class PathTracerApp extends EventDispatcher {
1764
1850
  _dispatchCamerasUpdated() {
1765
1851
 
1766
1852
  this.dispatchEvent( {
1767
- type: 'CamerasUpdated',
1853
+ type: EngineEvents.CAMERAS_UPDATED,
1768
1854
  cameras: this.cameraManager.cameras,
1769
1855
  cameraNames: this.cameraManager.getCameraNames(),
1770
1856
  } );
@@ -2060,12 +2146,24 @@ export class PathTracerApp extends EventDispatcher {
2060
2146
 
2061
2147
  const limits = this.renderer.backend?.device?.limits;
2062
2148
  const maxBinding = limits?.maxStorageBufferBindingSize || ( 128 * 1024 * 1024 );
2063
- const deviceMemGB = ( typeof navigator !== 'undefined' && navigator.deviceMemory ) || 4;
2149
+ const memory = deviceMemoryGB( this._hostMemoryGB );
2064
2150
  // 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 )
2151
+ const deviceSafeMax = ( memory.gb >= 8 && maxBinding >= 1024 * 1024 * 1024 )
2066
2152
  ? MAX_RESERVABLE_RENDER_SIZE : 2048;
2067
2153
  const applied = setReservedRenderSize( Math.min( target, deviceSafeMax ) );
2068
2154
 
2155
+ if ( applied < Math.floor( requestedPx ) ) {
2156
+
2157
+ this._issues.warn(
2158
+ ISSUE_CODES.RENDER_RESERVE_CAPPED,
2159
+ `reserved render size ${fmt.n( requestedPx )}px was capped to ${fmt.n( applied )}px by this device's limits — ` +
2160
+ `renders above ${fmt.n( applied )}px will be declined` +
2161
+ ( memory.assumed && memory.gb < 8 ? ' (host memory unknown, 4 GB assumed: pass hostMemoryGB)' : '' ),
2162
+ { requested: requestedPx, applied, deviceMemoryGB: memory.gb, assumed: memory.assumed, maxStorageBufferBindingSize: maxBinding }
2163
+ );
2164
+
2165
+ }
2166
+
2069
2167
  // Gate the realloc on the textures that EXIST, not on how the binding moved: a raise applied while
2070
2168
  // nothing was allocated leaves `applied === prev` for every later call, so keying off that let the
2071
2169
  // first ineffective call poison every effective one after it (issue #9). The path tracer's write MRT
@@ -2102,7 +2200,7 @@ export class PathTracerApp extends EventDispatcher {
2102
2200
  }
2103
2201
 
2104
2202
  this.reset();
2105
- this.dispatchEvent( { type: 'reserved_render_size_changed', size: applied } );
2203
+ this.dispatchEvent( { type: EngineEvents.RESERVED_RENDER_SIZE_CHANGED, size: applied } );
2106
2204
 
2107
2205
  }
2108
2206
 
@@ -2124,19 +2222,7 @@ export class PathTracerApp extends EventDispatcher {
2124
2222
  this._pendingReservedRenderSize = null;
2125
2223
 
2126
2224
  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 } );
2225
+ this.dispatchEvent( { type: EngineEvents.RESERVED_RENDER_SIZE_CHANGED, size: applied } );
2140
2226
 
2141
2227
  }
2142
2228
 
@@ -2206,7 +2292,7 @@ export class PathTracerApp extends EventDispatcher {
2206
2292
  this.denoisingManager?.setRenderSize( renderWidth, renderHeight );
2207
2293
  this.needsReset = true;
2208
2294
 
2209
- this.dispatchEvent( { type: 'resolution_changed', width: renderWidth, height: renderHeight } );
2295
+ this.dispatchEvent( { type: EngineEvents.RESOLUTION_CHANGED, width: renderWidth, height: renderHeight } );
2210
2296
 
2211
2297
  }
2212
2298
 
@@ -2382,6 +2468,43 @@ export class PathTracerApp extends EventDispatcher {
2382
2468
 
2383
2469
  }
2384
2470
 
2471
+ // Draws once without waking the loop: a woken loop takes a finished render nothing marked
2472
+ // complete (renderFrames, a video export) as newly finished, and denoises it again.
2473
+ _presentDisplay() {
2474
+
2475
+ if ( this.animationManagerId ) {
2476
+
2477
+ this._needsDisplayRefresh = true;
2478
+ return;
2479
+
2480
+ }
2481
+
2482
+ const context = this.pipeline?.context;
2483
+ if ( this._deviceLost || ! context ) return;
2484
+ this.stages.compositor?.render( context );
2485
+ this._renderHelperOverlay();
2486
+
2487
+ }
2488
+
2489
+ /**
2490
+ * One OIDN pass over the current accumulation at the final tier, resolved once the denoised
2491
+ * picture is published. Does not start the render loop or the upscaler; read the result with
2492
+ * `renderToBuffer( { source: 'display' } )`.
2493
+ * @returns {Promise<boolean>} whether a denoised picture was published
2494
+ */
2495
+ async runFinalDenoise() {
2496
+
2497
+ const dm = this.denoisingManager;
2498
+ if ( await dm?.denoiseOnce() ) return true;
2499
+
2500
+ const reason = ! dm?.denoiser ? 'the denoiser was not built'
2501
+ : ! dm.denoiser.enabled ? 'OIDN was off while accumulating — call denoisingManager.setOIDNEnabled( true ) first'
2502
+ : 'the denoise did not complete';
2503
+ this._issues.record( ISSUE_CODES.DENOISER_UNAVAILABLE, `final denoise produced no picture: ${reason}`, { reason } );
2504
+ return false;
2505
+
2506
+ }
2507
+
2385
2508
  // Aborts any in-flight denoise/upscale and puts the denoiser canvas back at base resolution (the
2386
2509
  // upscaler leaves it enlarged), so the live canvas is what's on screen again.
2387
2510
  _abortPostProcess( { keepDisplay = false } = {} ) {
@@ -2392,7 +2515,7 @@ export class PathTracerApp extends EventDispatcher {
2392
2515
 
2393
2516
  const w = this.denoisingManager._lastRenderWidth;
2394
2517
  const h = this.denoisingManager._lastRenderHeight;
2395
- this.dispatchEvent( { type: 'resolution_changed', width: w, height: h } );
2518
+ this.dispatchEvent( { type: EngineEvents.RESOLUTION_CHANGED, width: w, height: h } );
2396
2519
 
2397
2520
  }
2398
2521
 
@@ -2586,9 +2709,6 @@ export class PathTracerApp extends EventDispatcher {
2586
2709
  /**
2587
2710
  * Accumulates exactly `count` samples synchronously, bypassing the rAF loop.
2588
2711
  *
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
2712
  * A frame retired by adaptive sampling stops advancing `frameCount`, so a fixed-count loop
2593
2713
  * can never reach `count`. `allowEarlyRetire` makes that an outcome instead of a throw.
2594
2714
  *
@@ -2606,8 +2726,6 @@ export class PathTracerApp extends EventDispatcher {
2606
2726
  if ( ! stage ) throw new Error( 'renderFrames: app is not initialized' );
2607
2727
  if ( ! ( count > 0 ) ) throw new Error( `renderFrames: count must be positive, got ${count}` );
2608
2728
 
2609
- await stage.blueNoiseReady;
2610
-
2611
2729
  const target = ( reset ? 0 : stage.frameCount ) + count;
2612
2730
 
2613
2731
  // completionThreshold is a cached JS number derived from maxSamples, so this must
@@ -2631,6 +2749,14 @@ export class PathTracerApp extends EventDispatcher {
2631
2749
  if ( this._deviceLost ) throw new Error( 'renderFrames: WebGPU device lost' );
2632
2750
  if ( ! stage.isReady ) throw new Error( 'renderFrames: path tracer stage is not ready' );
2633
2751
 
2752
+ const wait = stage.readbackWait();
2753
+ if ( wait ) {
2754
+
2755
+ await this._awaitReadback( wait );
2756
+ continue;
2757
+
2758
+ }
2759
+
2634
2760
  this.pipeline.render();
2635
2761
  passes ++;
2636
2762
 
@@ -2669,6 +2795,156 @@ export class PathTracerApp extends EventDispatcher {
2669
2795
 
2670
2796
  }
2671
2797
 
2798
+ /**
2799
+ * Renders until the render's own stop condition — the sample ceiling, adaptive convergence or the
2800
+ * time limit, whichever comes first — without requestAnimationFrame, then runs the closing denoise
2801
+ * once. The production counterpart of {@link renderFrames}: adaptive sampling stays on, and its
2802
+ * readbacks run in lockstep (see PathTracer.setLockstepReadbacks), so the same input renders the same
2803
+ * image however fast frames are submitted — except under a time limit, which is wall-clock by nature.
2804
+ * The upscaler does not run. `EngineEvents.RENDER_COMPLETE` fires as it does from the loop.
2805
+ *
2806
+ * @param {Object} [options]
2807
+ * @param {boolean} [options.reset=true] - restart accumulation from sample 0 first
2808
+ * @param {boolean} [options.denoise=true] - run the final OIDN pass when `finalDenoise` is on
2809
+ * @param {AbortSignal} [options.signal] - rejects with its reason once aborted
2810
+ * @param {number} [options.drainEvery=4] - every N frames, wait for the GPU to finish the frames
2811
+ * before the last N, so submissions stay at most 2N frames ahead without the GPU idling
2812
+ * @param {function(number): void} [options.onProgress] - called with the running sample count
2813
+ * @returns {Promise<{samples: number, retiredBy: 'samples'|'converged'|'timeLimit', timeElapsed: number,
2814
+ * budgetOverrun: boolean, denoised: boolean}>}
2815
+ */
2816
+ async renderUntilComplete( { reset = true, denoise = true, signal, drainEvery = 4, onProgress } = {} ) {
2817
+
2818
+ const stage = this.stages.pathTracer;
2819
+ if ( ! stage ) throw new Error( 'renderUntilComplete: app is not initialized' );
2820
+ if ( this._loadingInProgress || this._sdf?.isProcessing ) throw new Error( 'renderUntilComplete: a scene is still loading' );
2821
+
2822
+ const lockstepWas = stage.lockstepReadbacks;
2823
+ stage.setLockstepReadbacks( true );
2824
+ // A 100 ms wall-clock mode for camera drags; frames in it do not count, so a batch render has no use for it.
2825
+ const interaction = this.settings.getEffective().interactionModeEnabled;
2826
+ this.settings.set( 'interactionModeEnabled', false, { silent: true, reset: false } );
2827
+
2828
+ try {
2829
+
2830
+ if ( reset ) this.reset();
2831
+ this.stopAnimation();
2832
+
2833
+ const queue = this.renderer.backend.device?.queue;
2834
+ let passes = 0;
2835
+ let previousBatch = null;
2836
+
2837
+ while ( ! stage.isComplete ) {
2838
+
2839
+ signal?.throwIfAborted();
2840
+ if ( this._deviceLost ) throw new Error( 'renderUntilComplete: WebGPU device lost' );
2841
+ if ( ! stage.isReady ) throw new Error( 'renderUntilComplete: path tracer stage is not ready' );
2842
+
2843
+ const wait = stage.readbackWait();
2844
+ if ( wait ) {
2845
+
2846
+ await this._awaitReadback( wait );
2847
+ continue;
2848
+
2849
+ }
2850
+
2851
+ this.cameraManager.camera.updateMatrixWorld();
2852
+ const counted = stage.frameCount;
2853
+ this._traceFrame();
2854
+ onProgress?.( stage.frameCount );
2855
+
2856
+ // A frame that did not count, with no readback to wait on, must still give timers a turn.
2857
+ if ( stage.frameCount === counted && ! stage.isComplete ) await new Promise( ( resolve ) => setTimeout( resolve, 0 ) );
2858
+
2859
+ if ( queue && drainEvery > 0 && ++ passes % drainEvery === 0 ) {
2860
+
2861
+ const batch = queue.onSubmittedWorkDone();
2862
+ if ( previousBatch ) await previousBatch;
2863
+ previousBatch = batch;
2864
+
2865
+ }
2866
+
2867
+ }
2868
+
2869
+ const info = this._completionInfo();
2870
+ if ( this.completion.markComplete() ) this.dispatchEvent( { type: EngineEvents.RENDER_COMPLETE, ...info } );
2871
+
2872
+ const denoised = denoise && this.denoisingManager?.finalDenoise ? await this.runFinalDenoise() : false;
2873
+ const { reason, ...rest } = info;
2874
+ return { ...rest, retiredBy: reason, denoised };
2875
+
2876
+ } finally {
2877
+
2878
+ stage.setLockstepReadbacks( lockstepWas );
2879
+ if ( interaction ) this.settings.set( 'interactionModeEnabled', interaction.value, { silent: true, reset: false, source: interaction.source } );
2880
+
2881
+ }
2882
+
2883
+ }
2884
+
2885
+ /**
2886
+ * What produced the current image, as plain JSON, to keep beside a render: engine and three.js
2887
+ * versions, the profile by name and by value (a name's values can change between releases), the
2888
+ * adapter, every live setting with its source, the colour pipeline, and the modes that decide
2889
+ * whether the render reproduces.
2890
+ * @returns {Object}
2891
+ */
2892
+ getProvenance() {
2893
+
2894
+ const stage = this.stages.pathTracer;
2895
+ const color = this.color?.status();
2896
+
2897
+ return toPortable( {
2898
+ engine: VERSION,
2899
+ three: REVISION,
2900
+ profile: { name: this._profileName, values: { ...this._profile } },
2901
+ adapter: this.adapterInfo ?? null,
2902
+ mode: {
2903
+ headless: this._headless,
2904
+ strict: this._issues.strict,
2905
+ deterministic: this.isDeterministic,
2906
+ lockstep: !! stage?.lockstepReadbacks,
2907
+ },
2908
+ render: stage ? {
2909
+ width: stage.width,
2910
+ height: stage.height,
2911
+ samples: stage.frameCount,
2912
+ complete: stage.isComplete,
2913
+ retiredBy: stage.isComplete ? this.completion.stopCondition( stage ) ?? 'samples' : null,
2914
+ } : null,
2915
+ color: color ? {
2916
+ config: color.config?.id ?? null,
2917
+ workingSpace: color.workingSpace,
2918
+ view: color.activeView ?? color.activeTransform?.id ?? null,
2919
+ context: color.context,
2920
+ } : null,
2921
+ scene: this.sceneSource,
2922
+ settings: this.settings.getEffective(),
2923
+ issues: this.issues.map( ( { code, severity } ) => ( { code, severity } ) ),
2924
+ } );
2925
+
2926
+ }
2927
+
2928
+ /**
2929
+ * Lockstep readbacks for renders driven by the loop or by {@link renderFrames}:
2930
+ * {@link renderUntilComplete} turns them on for its own duration. See PathTracer.setLockstepReadbacks.
2931
+ * @param {boolean} [enabled=true]
2932
+ */
2933
+ setLockstepReadbacks( enabled = true ) {
2934
+
2935
+ this.stages.pathTracer?.setLockstepReadbacks( enabled );
2936
+
2937
+ }
2938
+
2939
+ // A readback that never lands would hold the render forever; a lost device is the one way it can't.
2940
+ async _awaitReadback( wait ) {
2941
+
2942
+ const lost = this.renderer.backend.device?.lost;
2943
+ const outcome = await ( lost ? Promise.race( [ wait.then( () => null ), lost ] ) : wait );
2944
+ if ( outcome ) throw new Error( `WebGPU device lost while awaiting a readback: ${outcome.message ?? outcome.reason}` );
2945
+
2946
+ }
2947
+
2672
2948
  /**
2673
2949
  * A render in progress as data: the accumulated colour (and the aux buffers while they hold
2674
2950
  * samples), the per-pixel convergence buffers, and the counters that choose the next sample.
@@ -2721,7 +2997,9 @@ export class PathTracerApp extends EventDispatcher {
2721
2997
  * @param {'accumulation'|'display'} [options.source='accumulation'] - `'display'` reads what the
2722
2998
  * viewport is showing — denoised when a denoiser has run, without bloom — instead of the raw
2723
2999
  * accumulation
2724
- * @returns {Promise<{data: Float32Array|Uint8ClampedArray, width: number, height: number, colorSpace: string}>}
3000
+ * @returns {Promise<{data: Float32Array|Uint8ClampedArray, width: number, height: number, colorSpace: string,
3001
+ * source: string}>} `source` is what was read: `'accumulation'`, or the denoiser that published
3002
+ * it (`'oidn'`, `'asvgf'`, `'nrd'`, `'edgeFiltering'`, `'bilateralFiltering'`)
2725
3003
  */
2726
3004
  async renderToBuffer( { colorSpace = 'srgb', preserveAlpha = false, source = 'accumulation' } = {} ) {
2727
3005
 
@@ -2741,39 +3019,75 @@ export class PathTracerApp extends EventDispatcher {
2741
3019
  // The pool over-allocates to the reserve, so the texture is larger than the frame.
2742
3020
  const { width, height } = stage;
2743
3021
 
2744
- const linear = source === 'display'
3022
+ if ( colorSpace === 'srgb' ) {
3023
+
3024
+ const shown = source === 'display' ? this._displaySource( target ) : null;
3025
+ const tone = {
3026
+ exposure: this.renderer.toneMappingExposure,
3027
+ toneMapping: this.renderer.toneMapping,
3028
+ saturation: this.settings.get( 'saturation' ) ?? 1,
3029
+ preserveAlpha,
3030
+ };
3031
+
3032
+ const data = await this._toneMapOnGPU( shown?.texture ?? null, target, width, height, tone )
3033
+ ?? toneMapToRGBA8( shown
3034
+ ? await this._readTexture( shown.texture, width, height )
3035
+ : await this.renderer.readRenderTargetPixelsAsync( target, 0, 0, width, height, 0 ), tone );
3036
+ return { data, width, height, colorSpace, source: shown?.source ?? 'accumulation' };
3037
+
3038
+ }
3039
+
3040
+ const { data: linear, source: read } = source === 'display'
2745
3041
  ? await this._readDisplaySource( target, width, height )
2746
- : await this.renderer.readRenderTargetPixelsAsync( target, 0, 0, width, height, 0 );
3042
+ : { data: await this.renderer.readRenderTargetPixelsAsync( target, 0, 0, width, height, 0 ), source: 'accumulation' };
2747
3043
 
2748
3044
  // `colorSpace` stays 'linear' — callers branch on it. `workingSpace` is the new, additive
2749
3045
  // answer to "linear in what primaries", which only means something once a config is loaded.
2750
3046
  if ( colorSpace === 'linear' ) {
2751
3047
 
2752
- return { data: linear, width, height, colorSpace, workingSpace: this.color?.workingSpace ?? null };
3048
+ return { data: linear, width, height, colorSpace, workingSpace: this.color?.workingSpace ?? null, source: read };
2753
3049
 
2754
3050
  }
2755
3051
 
2756
3052
  // A named space is a delivery buffer, not a picture: scene-referred float in whatever the
2757
3053
  // config calls that space. Saving an EXR for a compositor and grading through an sRGB view
2758
3054
  // on screen are different questions, and this is the one that answers the first.
2759
- if ( named ) {
3055
+ const { rgba, colorSpace: got } = this.color.exportPixels( linear, colorSpace );
3056
+ return { data: rgba, width, height, colorSpace: got, source: read };
2760
3057
 
2761
- const { rgba, colorSpace: got } = this.color.exportPixels( linear, colorSpace );
2762
- return { data: rgba, width, height, colorSpace: got };
3058
+ }
2763
3059
 
2764
- }
3060
+ /**
3061
+ * Display bytes tone-mapped on the card, or null to do it on the CPU. On the CPU it took 1.4 s at
3062
+ * 4096×2160 on an M-series Mac and 10 s on a cloud L4's host; the GPU pass matches it within one
3063
+ * level (`tests/gpu/toneMapParity.test.js`).
3064
+ */
3065
+ async _toneMapOnGPU( shown, accumulation, width, height, tone ) {
2765
3066
 
2766
- return {
2767
- data: toneMapToRGBA8( linear, {
2768
- exposure: this.renderer.toneMappingExposure,
2769
- toneMapping: this.renderer.toneMapping,
2770
- saturation: this.settings.get( 'saturation' ) ?? 1,
2771
- preserveAlpha,
2772
- } ),
2773
- width,
2774
- height,
2775
- colorSpace,
2776
- };
3067
+ const backend = this.renderer?.backend;
3068
+ if ( ! backend?.device ) return null;
3069
+
3070
+ try {
3071
+
3072
+ const texture = shown ? this._readbackPass().draw( shown, width, height ).texture : accumulation.textures[ 0 ];
3073
+ const gpuTexture = backend.get( texture ).texture;
3074
+ if ( ! gpuTexture ) return null;
3075
+
3076
+ this._readbackToneMapper ??= new PackedToneMapper( backend.device, 'rayzee:readback-tonemap', { input: 'texture' } );
3077
+ this._readbackToneMapper.ensureSize( width, height );
3078
+ return await this._readbackToneMapper.toRGBA8( gpuTexture, tone );
3079
+
3080
+ } catch ( error ) {
3081
+
3082
+ log.warn( `renderToBuffer: tone mapping on the GPU failed, doing it on the CPU: ${error?.message ?? error}` );
3083
+ return null;
3084
+
3085
+ } finally {
3086
+
3087
+ this._readbackToneMapper?.release();
3088
+ if ( shown ) this._textureReadback?.release();
3089
+
3090
+ }
2777
3091
 
2778
3092
  }
2779
3093
 
@@ -3089,6 +3403,8 @@ export class PathTracerApp extends EventDispatcher {
3089
3403
  */
3090
3404
  getMemoryInfo() {
3091
3405
 
3406
+ // Wired from the loop too, which renderFrames never enters: a headless host would miss these categories.
3407
+ this._ensureVRAMWiring();
3092
3408
  return this.stages.pathTracer?.vramTracker?.measure() ?? { current: 0, peak: 0, byCategory: {} };
3093
3409
 
3094
3410
  }
@@ -3121,11 +3437,19 @@ export class PathTracerApp extends EventDispatcher {
3121
3437
  if ( ! tracker ) return; // stages not ready yet
3122
3438
 
3123
3439
  tracker.register( 'stages', () => this._collectStageTextures() );
3440
+ tracker.register( 'denoiser', () => ( { bytes: this.denoisingManager?.gpuBytes() ?? 0 } ) );
3441
+ // One image per presented surface (a browser may keep one or two more), and the buffer
3442
+ // three's output pass tone-maps through.
3443
+ tracker.register( 'canvas', () => [
3444
+ { bytes: this.canvas.width * this.canvas.height * 4 },
3445
+ ...( this.renderer?._frameBufferTargets?.values() ?? [] ),
3446
+ ...( this.overlayManager?.gpuResources() ?? [] ),
3447
+ ] );
3124
3448
 
3125
3449
  const remeasure = () => tracker.measure();
3126
- this._addTrackedListener( this, 'SceneRebuild', remeasure );
3127
- this._addTrackedListener( this, 'EnvironmentLoaded', remeasure );
3128
- this._addTrackedListener( this, 'resolution_changed', remeasure );
3450
+ this._addTrackedListener( this, EngineEvents.SCENE_REBUILD, remeasure );
3451
+ this._addTrackedListener( this, EngineEvents.ENVIRONMENT_LOADED, remeasure );
3452
+ this._addTrackedListener( this, EngineEvents.RESOLUTION_CHANGED, remeasure );
3129
3453
 
3130
3454
  this._vramWired = true;
3131
3455
 
@@ -3368,6 +3692,35 @@ export class PathTracerApp extends EventDispatcher {
3368
3692
 
3369
3693
  }
3370
3694
 
3695
+ /**
3696
+ * What a `'display'` read shows: the texture a denoiser published, or null for the raw
3697
+ * accumulation — recorded as a fallback when a denoiser is in use.
3698
+ * @private
3699
+ */
3700
+ _displaySource( accumulation ) {
3701
+
3702
+ const context = this.pipeline?.context;
3703
+ const found = context && this.stages.compositor?.resolveLightSource( context );
3704
+ if ( found && found.key !== 'pathtracer:color' && found.texture !== accumulation.texture ) {
3705
+
3706
+ return { texture: found.texture, source: found.key.split( ':' )[ 0 ] };
3707
+
3708
+ }
3709
+
3710
+ if ( this._denoiserInUse() ) {
3711
+
3712
+ this._issues.record(
3713
+ ISSUE_CODES.OUTPUT_SOURCE_FALLBACK,
3714
+ 'renderToBuffer: \'display\' was asked for, but no denoiser has published a picture — returned the raw accumulation',
3715
+ { requested: 'display', read: 'accumulation' }
3716
+ );
3717
+
3718
+ }
3719
+
3720
+ return null;
3721
+
3722
+ }
3723
+
3371
3724
  /**
3372
3725
  * What the Compositor is drawing from, as linear float. Falls back to the accumulation when no
3373
3726
  * denoiser has published anything, which is also what the viewport shows then.
@@ -3375,16 +3728,29 @@ export class PathTracerApp extends EventDispatcher {
3375
3728
  */
3376
3729
  async _readDisplaySource( accumulation, width, height ) {
3377
3730
 
3378
- const context = this.pipeline?.context;
3379
- const texture = context && this.stages.compositor?.resolveLightTexture( context );
3380
- if ( ! texture || texture === accumulation.texture ) {
3731
+ const shown = this._displaySource( accumulation );
3732
+ if ( ! shown ) return { data: await this.renderer.readRenderTargetPixelsAsync( accumulation, 0, 0, width, height, 0 ), source: 'accumulation' };
3733
+ return { data: await this._readTexture( shown.texture, width, height ), source: shown.source };
3381
3734
 
3382
- return await this.renderer.readRenderTargetPixelsAsync( accumulation, 0, 0, width, height, 0 );
3735
+ }
3383
3736
 
3384
- }
3737
+ _readbackPass() {
3385
3738
 
3386
- this._textureReadback ??= new TextureReadback( this.renderer );
3387
- return await this._textureReadback.read( texture, width, height );
3739
+ return ( this._textureReadback ??= new TextureReadback( this.renderer ) );
3740
+
3741
+ }
3742
+
3743
+ _readTexture( texture, width, height ) {
3744
+
3745
+ return this._readbackPass().read( texture, width, height );
3746
+
3747
+ }
3748
+
3749
+ // With none, the raw accumulation is the display.
3750
+ _denoiserInUse() {
3751
+
3752
+ const dm = this.denoisingManager;
3753
+ return !! dm?.denoiser?.enabled || ( dm?.denoiserStrategy ?? 'none' ) !== 'none';
3388
3754
 
3389
3755
  }
3390
3756
 
@@ -3548,9 +3914,9 @@ export class PathTracerApp extends EventDispatcher {
3548
3914
 
3549
3915
  setStatusCallback( ( event ) => this.dispatchEvent( event ) );
3550
3916
 
3551
- if ( ! navigator.gpu ) {
3917
+ if ( typeof navigator === 'undefined' || ! navigator.gpu ) {
3552
3918
 
3553
- throw new Error( 'WebGPU is not supported in this browser' );
3919
+ throw new Error( 'WebGPU is not available — outside a browser, install one as navigator.gpu (for Node, the `webgpu` package)' );
3554
3920
 
3555
3921
  }
3556
3922
 
@@ -3614,7 +3980,7 @@ export class PathTracerApp extends EventDispatcher {
3614
3980
  requiredLimits,
3615
3981
  } );
3616
3982
 
3617
- await this.renderer.init();
3983
+ await initWithoutFrameLoop( this.renderer, this._headless );
3618
3984
 
3619
3985
  // WebGPURenderer swaps in WebGL2 on failure with only a warn(). The wavefront path is
3620
3986
  // compute-only, so every frame would fail against an empty canvas.
@@ -3764,23 +4130,31 @@ export class PathTracerApp extends EventDispatcher {
3764
4130
  } );
3765
4131
  this.goboManager = new GoboManager( this.stages.pathTracer, {
3766
4132
  onReset: () => this.reset(),
4133
+ issues: this._issues,
3767
4134
  } );
3768
4135
  this.iesManager = new IESManager( this.stages.pathTracer, {
3769
4136
  onReset: () => this.reset(),
4137
+ issues: this._issues,
3770
4138
  } );
3771
4139
  this._setupDenoisingManager();
3772
- await this._setupOverlayManager();
3773
4140
 
3774
- this.transformManager = new TransformManager( {
3775
- camera: this.cameraManager.camera,
3776
- canvas: this.canvas,
3777
- orbitControls: this.cameraManager.controls,
3778
- app: this,
3779
- } );
4141
+ // A second renderer, a 2D canvas and a gizmo, all for pixels nobody sees.
4142
+ if ( ! this._headless ) {
4143
+
4144
+ await this._setupOverlayManager();
4145
+
4146
+ this.transformManager = new TransformManager( {
4147
+ camera: this.cameraManager.camera,
4148
+ canvas: this.canvas,
4149
+ orbitControls: this.cameraManager.controls,
4150
+ app: this,
4151
+ } );
3780
4152
 
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 ) );
4153
+ // The gizmo is part of the scene overlay layer, so it draws on the same
4154
+ // view-resolution surface as the light helpers and the outline.
4155
+ this.overlayManager.register( 'transform', new TransformGizmoHelper( this.transformManager ) );
4156
+
4157
+ }
3784
4158
 
3785
4159
  // Wire cross-manager dependencies
3786
4160
  this.interactionManager.setDependencies( {
@@ -3794,7 +4168,7 @@ export class PathTracerApp extends EventDispatcher {
3794
4168
  this.denoisingManager.setOverlayManager( this.overlayManager );
3795
4169
  this.denoisingManager.setResetCallback( () => this.reset() );
3796
4170
  this.denoisingManager.setPostProcessRefreshCallback( () => this.requestPostProcessRefresh() );
3797
- this.denoisingManager.setDisplayRefreshCallback( () => this.refreshFrame() );
4171
+ this.denoisingManager.setDisplayRefreshCallback( () => this._presentDisplay() );
3798
4172
  this.denoisingManager.setSettings( this.settings );
3799
4173
 
3800
4174
  // Expose environment manager (lives on pathTracer stage)
@@ -3808,7 +4182,7 @@ export class PathTracerApp extends EventDispatcher {
3808
4182
  _wireEvents() {
3809
4183
 
3810
4184
  // Forward manager events → app events
3811
- this._addTrackedListener( this.cameraManager, 'CameraSwitched', ( e ) => this.dispatchEvent( e ) );
4185
+ this._addTrackedListener( this.cameraManager, EngineEvents.CAMERA_SWITCHED, ( e ) => this.dispatchEvent( e ) );
3812
4186
  this._addTrackedListener( this.cameraManager, EngineEvents.AUTO_FOCUS_UPDATED, ( e ) => this.dispatchEvent( e ) );
3813
4187
  this._addTrackedListener( this.cameraManager, EngineEvents.ORTHO_HEIGHT_UPDATED, ( e ) => this.dispatchEvent( e ) );
3814
4188
  this._addTrackedListener( this.timeline, EngineEvents.TIMELINE_CHANGED, ( e ) => this.dispatchEvent( e ) );
@@ -3816,7 +4190,7 @@ export class PathTracerApp extends EventDispatcher {
3816
4190
  this._forwardEvents( this.denoisingManager, [
3817
4191
  EngineEvents.DENOISING_START, EngineEvents.DENOISING_END,
3818
4192
  EngineEvents.UPSCALING_START, EngineEvents.UPSCALING_PROGRESS, EngineEvents.UPSCALING_END,
3819
- 'resolution_changed',
4193
+ EngineEvents.RESOLUTION_CHANGED,
3820
4194
  ] );
3821
4195
 
3822
4196
  this._setupAutoExposureListener();
@@ -3970,7 +4344,7 @@ export class PathTracerApp extends EventDispatcher {
3970
4344
 
3971
4345
  _createStages() {
3972
4346
 
3973
- this.stages.pathTracer = new PathTracer( this.renderer, this.scene, this.cameraManager.camera );
4347
+ this.stages.pathTracer = new PathTracer( this.renderer, this.scene, this.cameraManager.camera, { hostMemoryGB: this._hostMemoryGB } );
3974
4348
  this.stages.normalDepth = new NormalDepth( this.renderer, {
3975
4349
  pathTracer: this.stages.pathTracer
3976
4350
  } );
@@ -4011,6 +4385,7 @@ export class PathTracerApp extends EventDispatcher {
4011
4385
  pipeline: this.pipeline,
4012
4386
  getExposure: () => this.settings.get( 'exposure' ) ?? 1.0,
4013
4387
  getSaturation: () => this.settings.get( 'saturation' ) ?? 1.0,
4388
+ issues: this._issues,
4014
4389
  } );
4015
4390
 
4016
4391
  this.denoisingManager.setupDenoiser();