rayzee 9.0.0 → 9.1.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 (95) hide show
  1. package/README.md +139 -8
  2. package/dist/{NeuralRetouch-CYOJ21Wn.js → NeuralRetouch-BRquqfmZ.js} +1 -1
  3. package/dist/{NeuralSuperRes-CQLCLpcC.js → NeuralSuperRes-CbNvQjCw.js} +3 -2
  4. package/dist/{ToneMapGPU-BVE852iN.js → ToneMapGPU-DpUZDMbQ.js} +2 -1
  5. package/dist/rayzee.es.js +10353 -5337
  6. package/dist/rayzee.umd.js +265 -265
  7. package/package.json +1 -1
  8. package/src/AssetConfig.js +4 -0
  9. package/src/EngineDefaults.js +38 -56
  10. package/src/EngineEvents.js +3 -0
  11. package/src/EngineIssues.js +6 -0
  12. package/src/Headless.js +3 -1
  13. package/src/Passes/AIUpscaler.js +2 -1
  14. package/src/Passes/OIDNDenoiser.js +3 -6
  15. package/src/PathTracerApp.js +290 -19
  16. package/src/Processor/ArchiveCache.js +120 -0
  17. package/src/Processor/ArchiveReader.js +179 -32
  18. package/src/Processor/AssetLoader.js +514 -76
  19. package/src/Processor/AtmosphereModel.js +465 -0
  20. package/src/Processor/BVHBuilder.js +4 -1
  21. package/src/Processor/BVHLeafFold.js +100 -0
  22. package/src/Processor/BVHRefitter.js +98 -214
  23. package/src/Processor/ChunkedRecords.js +189 -4
  24. package/src/Processor/EmissiveTriangleBuilder.js +8 -0
  25. package/src/Processor/GeometryExtractor.js +238 -40
  26. package/src/Processor/InstanceTable.js +55 -37
  27. package/src/Processor/KernelManager.js +4 -1
  28. package/src/Processor/PBRT/PBRTCurves.js +104 -27
  29. package/src/Processor/PBRT/PBRTParser.js +132 -22
  30. package/src/Processor/PBRT/PBRTSceneBuilder.js +192 -49
  31. package/src/Processor/PBRT/buffers.js +27 -0
  32. package/src/Processor/PBRT/index.js +55 -15
  33. package/src/Processor/PackedRayBuffer.js +10 -13
  34. package/src/Processor/ParallelBVHBuilder.js +36 -15
  35. package/src/Processor/PhysicalSky.js +308 -0
  36. package/src/Processor/QueueManager.js +1 -0
  37. package/src/Processor/SceneProcessor.js +1132 -181
  38. package/src/Processor/SplitBLAS.js +249 -0
  39. package/src/Processor/SunPosition.js +41 -0
  40. package/src/Processor/TextureCreator.js +21 -0
  41. package/src/Processor/Workers/AIUpscalerWorker.js +79 -52
  42. package/src/Processor/Workers/BVHWorker.js +17 -13
  43. package/src/Processor/ZipReader.js +207 -0
  44. package/src/RenderSettings.js +38 -0
  45. package/src/SceneState/SceneState.js +367 -0
  46. package/src/SceneState/portable.js +52 -0
  47. package/src/Stages/NormalDepth.js +7 -6
  48. package/src/Stages/PathTracer.js +108 -4
  49. package/src/Stages/PathTracerStage.js +251 -104
  50. package/src/Storage/BLASCache.js +190 -0
  51. package/src/Storage/CDFCache.js +77 -0
  52. package/src/Storage/DownloadCache.js +266 -0
  53. package/src/Storage/GeometrySpill.js +217 -0
  54. package/src/Storage/SceneGraphCodec.js +503 -0
  55. package/src/Storage/SpillStore.js +112 -0
  56. package/src/Storage/StorageManager.js +934 -0
  57. package/src/Storage/StorageOps.js +137 -0
  58. package/src/Storage/StorageWorker.js +37 -0
  59. package/src/Storage/events.js +29 -0
  60. package/src/Storage/identity.js +77 -0
  61. package/src/Storage/index.js +9 -0
  62. package/src/Storage/inlineTransport.js +24 -0
  63. package/src/Storage/locks.js +99 -0
  64. package/src/Storage/openStorage.js +87 -0
  65. package/src/Storage/sceneCachePolicy.js +12 -0
  66. package/src/Storage/shared.js +9 -0
  67. package/src/Storage/transport.js +62 -0
  68. package/src/TSL/Atmosphere.js +684 -0
  69. package/src/TSL/BVHTraversal.js +288 -239
  70. package/src/TSL/Common.js +11 -2
  71. package/src/TSL/Displacement.js +6 -6
  72. package/src/TSL/EmissiveSampling.js +5 -5
  73. package/src/TSL/EnvironmentCDF.js +176 -0
  74. package/src/TSL/GenerateKernel.js +3 -3
  75. package/src/TSL/HitFacet.js +4 -4
  76. package/src/TSL/LightsDirect.js +8 -8
  77. package/src/TSL/LightsSampling.js +55 -37
  78. package/src/TSL/Random.js +1 -0
  79. package/src/TSL/ShadeKernel.js +75 -19
  80. package/src/TSL/Sun.js +40 -0
  81. package/src/TSL/TextureSampling.js +6 -7
  82. package/src/index.js +18 -0
  83. package/src/managers/CameraManager.js +169 -1
  84. package/src/managers/EnvironmentManager.js +263 -33
  85. package/src/managers/GoboManager.js +19 -8
  86. package/src/managers/IESManager.js +3 -3
  87. package/src/managers/LightManager.js +98 -5
  88. package/src/managers/MaterialDataManager.js +22 -2
  89. package/src/managers/TransformManager.js +26 -0
  90. package/src/managers/UniformManager.js +5 -3
  91. package/src/managers/VideoRenderManager.js +3 -1
  92. package/src/managers/timeline/CameraTrack.js +22 -0
  93. package/src/managers/timeline/TimelineManager.js +14 -0
  94. package/src/version.js +3 -0
  95. package/src/Processor/ProceduralSky.js +0 -291
@@ -17,7 +17,7 @@ import { AutoExposure } from './Stages/AutoExposure.js';
17
17
  import { Compositor } from './Stages/Compositor.js';
18
18
  import { RenderPipeline } from './Pipeline/RenderPipeline.js';
19
19
  import { CompletionTracker } from './Pipeline/CompletionTracker.js';
20
- import { ENGINE_DEFAULTS as DEFAULT_STATE, PRODUCTION_RENDER_CONFIG, INTERACTIVE_RENDER_CONFIG, modePresetSettings, MAX_STORAGE_TEXTURE_SIZE, MAX_RESERVABLE_RENDER_SIZE, setReservedRenderSize, getRenderProfile } from './EngineDefaults.js';
20
+ import { ENGINE_DEFAULTS as DEFAULT_STATE, TRIANGLE_DATA_LAYOUT, PRODUCTION_RENDER_CONFIG, INTERACTIVE_RENDER_CONFIG, modePresetSettings, MAX_STORAGE_TEXTURE_SIZE, MAX_RESERVABLE_RENDER_SIZE, setReservedRenderSize, getRenderProfile } from './EngineDefaults.js';
21
21
  import { updateStats, updateLoading, resetLoading, setStatusCallback, getDisplaySamples, disposeObjectFromMemory, disposeRenderer } from './Processor/utils.js';
22
22
  import { BuildTimer } from './Processor/BuildTimer.js';
23
23
  import { TextureReadback } from './Processor/TextureReadback.js';
@@ -25,11 +25,17 @@ import { createLogger, fmt } from './utils/Logger.js';
25
25
  import { InteractionManager } from './managers/InteractionManager.js';
26
26
  import { EngineEvents } from './EngineEvents.js';
27
27
  import { IssueLog, ISSUE_CODES } from './EngineIssues.js';
28
+ import { getAssetConfig } from './AssetConfig.js';
29
+ import { StorageManager } from './Storage/StorageManager.js';
30
+ import { acquireSharedStorage } from './Storage/openStorage.js';
31
+ import { nameFromUrl } from './Storage/DownloadCache.js';
32
+ import { fileIdentity, identityKey } from './Storage/identity.js';
28
33
  import { SETTING_SOURCE } from './RenderSettings.js';
29
34
  import { toneMapToRGBA8 } from './Processor/ToneMapCPU.js';
30
35
  import { ColorManagement, setActiveColorManagement } from './Color/ColorManagement.js';
31
36
  import { getViewTransform } from './Color/ViewTransforms.js';
32
37
  import { AssetLoader } from './Processor/AssetLoader.js';
38
+ import { SPILL_TRIANGLE_BUDGET, SPILL_PLACEMENT_BUDGET } from './Processor/PBRT/index.js';
33
39
  import { SceneProcessor } from './Processor/SceneProcessor.js';
34
40
 
35
41
  // Managers
@@ -44,6 +50,17 @@ import { OverlayManager } from './managers/OverlayManager.js';
44
50
  import { AnimationManager } from './managers/AnimationManager.js';
45
51
  import { TransformManager } from './managers/TransformManager.js';
46
52
  import { TransformGizmoHelper } from './managers/helpers/TransformGizmoHelper.js';
53
+ import { captureSceneState, applySceneState } from './SceneState/SceneState.js';
54
+ import { TRIANGLE_PATCH_PROPERTIES } from './managers/MaterialDataManager.js';
55
+ import { VERSION } from './version.js';
56
+
57
+ export const RENDER_CHECKPOINT_VERSION = 1;
58
+
59
+ // The part of an archive a load chose, for sceneSource.
60
+ const partOf = ( { element, pbrtEntry } = {} ) => ( {
61
+ ...( element !== undefined ? { element } : {} ),
62
+ ...( pbrtEntry !== undefined ? { pbrtEntry } : {} ),
63
+ } );
47
64
 
48
65
  // One app per canvas — auto-dispose a prior owner if the caller double-
49
66
  // instantiates (StrictMode, HMR, etc.) so its rAF loop can't burn CPU.
@@ -129,6 +146,11 @@ export class PathTracerApp extends EventDispatcher {
129
146
  * @param {number} [options.maxSceneBytes] - refuse a scene whose estimated host memory is
130
147
  * above this. The default refuses where the renderer process would be killed instead of
131
148
  * throwing; raise it deliberately, on a fresh browser. See HostMemory.js.
149
+ * @param {false|'auto'|StorageManager} [options.storage] - on-disk storage; defaults to
150
+ * `configureAssets( { storage } )`. A host-supplied manager stays the host's to dispose.
151
+ * @param {boolean} [options.memorySpill=false] - experimental: once a large static scene is
152
+ * on the GPU, move its triangle records and BLAS nodes to disk (see
153
+ * {@link ensureSceneResident}). Needs storage; skipped for animated scenes.
132
154
  *
133
155
  * The engine dispatches `EngineEvents.FRAME` after each animate() iteration so hosts can
134
156
  * tick external instrumentation (e.g. a stats panel) without coupling the engine to it.
@@ -153,6 +175,11 @@ export class PathTracerApp extends EventDispatcher {
153
175
  this._autoResize = options.autoResize !== false;
154
176
  // A scene budget the host may raise; read where SceneProcessor is built, well after this.
155
177
  this._maxSceneBytes = options.maxSceneBytes;
178
+ this._storageOption = options.storage;
179
+ this._memorySpill = options.memorySpill === true;
180
+ /** @type {?StorageManager} on-disk storage, null when off or unavailable */
181
+ this.storage = null;
182
+ this._storageRelease = null;
156
183
  this._container = options.container || null;
157
184
  // Apply the environment authored into a model file's metadata on load. See _beginSceneMetadataEnvironment().
158
185
  this._applySceneMetadataEnabled = options.applySceneMetadata !== false;
@@ -324,6 +351,7 @@ export class PathTracerApp extends EventDispatcher {
324
351
  */
325
352
  async init() {
326
353
 
354
+ const storageReady = this._initStorage();
327
355
  await this._initRenderer();
328
356
  this._applyPendingReservedRenderSize();
329
357
  this._initCameraManager();
@@ -332,9 +360,11 @@ export class PathTracerApp extends EventDispatcher {
332
360
  this._initPipeline();
333
361
  await this._initManagers();
334
362
  this._wireEvents();
363
+ await storageReady;
364
+ this._attachStorage();
335
365
 
336
366
  // Seed path tracer with minimal empty scene data
337
- this.stages.pathTracer.setTriangleData( new Float32Array( 32 ), 0 );
367
+ this.stages.pathTracer.setTriangleData( new Uint32Array( TRIANGLE_DATA_LAYOUT.FLOATS_PER_TRIANGLE ), 0 );
338
368
  this.stages.pathTracer.setBVHData( new Float32Array( 16 ) );
339
369
  this.stages.pathTracer.materialData.setMaterialData( new Float32Array( 16 ) );
340
370
  this.stages.pathTracer.setupMaterial();
@@ -346,6 +376,48 @@ export class PathTracerApp extends EventDispatcher {
346
376
 
347
377
  }
348
378
 
379
+ async _initStorage() {
380
+
381
+ const option = this._storageOption ?? getAssetConfig().storage;
382
+ if ( option === false ) return;
383
+
384
+ if ( option instanceof StorageManager ) {
385
+
386
+ this.storage = option;
387
+
388
+ } else {
389
+
390
+ const { storage, reason, release } = await acquireSharedStorage( getAssetConfig().cacheNamespace );
391
+ if ( this._disposed ) {
392
+
393
+ release();
394
+ return;
395
+
396
+ }
397
+
398
+ if ( ! storage ) {
399
+
400
+ this._issues.warn( ISSUE_CODES.STORAGE_UNAVAILABLE, `on-disk storage is off: ${reason}`, { reason } );
401
+ return;
402
+
403
+ }
404
+
405
+ this.storage = storage;
406
+ this._storageRelease = release;
407
+
408
+ }
409
+
410
+ this._addTrackedListener( this.storage, 'change', ( e ) => this.dispatchEvent( { type: EngineEvents.STORAGE_CHANGED, area: e.area } ) );
411
+ this._addTrackedListener( this.storage, 'issue', ( e ) => this._issues.warn( e.code, e.message, e.detail ) );
412
+
413
+ }
414
+
415
+ _attachStorage() {
416
+
417
+ if ( this.assetLoader ) this.assetLoader.storage = this.storage;
418
+
419
+ }
420
+
349
421
  /**
350
422
  * Starts the animation loop.
351
423
  */
@@ -624,6 +696,11 @@ export class PathTracerApp extends EventDispatcher {
624
696
  this._removeTrackedListeners();
625
697
  setStatusCallback( null );
626
698
 
699
+ this._storageRelease?.();
700
+ this._storageRelease = null;
701
+ this.storage = null;
702
+ this._sceneSource = this._sceneSourceFile = null;
703
+
627
704
  this._issues.detach(); // onIssue captures `this`; see IssueLog.detach()
628
705
 
629
706
  // Holds the renderer and the issue log, both of which outlive it otherwise.
@@ -764,6 +841,7 @@ export class PathTracerApp extends EventDispatcher {
764
841
  this.transformManager?.detach?.();
765
842
 
766
843
  this.assetLoader?.releaseTargetModel();
844
+ this._sceneSource = this._sceneSourceFile = null;
767
845
 
768
846
  // Clear lights in the WebGPU light scene
769
847
  this.lightManager?.clearLights?.();
@@ -771,7 +849,7 @@ export class PathTracerApp extends EventDispatcher {
771
849
  // Seed path tracer with empty data (matches the init-time seed)
772
850
  if ( this.stages.pathTracer ) {
773
851
 
774
- this.stages.pathTracer.setTriangleData( new Float32Array( 32 ), 0 );
852
+ this.stages.pathTracer.setTriangleData( new Uint32Array( TRIANGLE_DATA_LAYOUT.FLOATS_PER_TRIANGLE ), 0 );
775
853
  this.stages.pathTracer.setBVHData( new Float32Array( 16 ) );
776
854
  this.stages.pathTracer.materialData.setMaterialData( new Float32Array( 16 ) );
777
855
  this.stages.pathTracer.setEmissiveTriangleData?.( new Float32Array( 0 ), 0, 0 );
@@ -788,12 +866,14 @@ export class PathTracerApp extends EventDispatcher {
788
866
  /**
789
867
  * Loads a model, builds BVH, and uploads scene data.
790
868
  * @param {string} url - Model URL
869
+ * @param {{cacheKey?: string}} [options] - download-cache key for a URL that expires
791
870
  */
792
- async loadModel( url ) {
871
+ async loadModel( url, options = {} ) {
793
872
 
794
873
  await this._loadWithSceneRebuild(
795
- () => this.assetLoader.loadModel( url ),
796
- { type: 'ModelLoaded', url }
874
+ () => this.assetLoader.loadModel( url, options ),
875
+ { type: 'ModelLoaded', url },
876
+ { kind: 'url', url, cacheKey: options.cacheKey ?? null }
797
877
  );
798
878
 
799
879
  }
@@ -817,7 +897,8 @@ export class PathTracerApp extends EventDispatcher {
817
897
 
818
898
  await this._loadWithSceneRebuild(
819
899
  () => this.assetLoader.loadObject3D( object3d, name ),
820
- { type: 'Object3DLoaded', name }
900
+ { type: 'Object3DLoaded', name },
901
+ { kind: 'object3d', name }
821
902
  );
822
903
 
823
904
  }
@@ -891,22 +972,49 @@ export class PathTracerApp extends EventDispatcher {
891
972
  * tracer rendering buffers whose geometry has been freed. Here a concurrent call throws
892
973
  * LOAD_IN_PROGRESS before anything is touched.
893
974
  *
894
- * @param {File} file
975
+ * @param {File|string} file - a File, or a URL to download (through the download cache)
895
976
  * @param {object} [options] - forwarded to the archive loader: `element` to load one
896
- * subtree of a multi-part scene, `pbrtEntry` to choose among several .pbrt scenes.
977
+ * subtree of a multi-part scene, `pbrtEntry` to choose among several .pbrt scenes. For a
978
+ * URL also `filename` and `cacheKey` (see AssetLoader.loadAssetFromUrl).
897
979
  * @returns {Promise<void>}
898
980
  */
899
981
  async loadFile( file, options = {} ) {
900
982
 
983
+ options = this._sceneBudgets( options );
984
+
985
+ if ( typeof file === 'string' ) {
986
+
987
+ const filename = options.filename ?? nameFromUrl( file );
988
+ const format = this.assetLoader?.getFileFormat( filename );
989
+ if ( ! format ) throw new Error( `Unsupported file format: ${filename}` );
990
+ if ( format.type === 'environment' || format.type === 'image' ) return this.loadEnvironment( file );
991
+
992
+ await this._loadWithSceneRebuild(
993
+ () => this.assetLoader.loadAssetFromUrl( file, { ...options, filename } ),
994
+ { type: 'ModelLoaded', filename },
995
+ { kind: 'url', url: file, filename, cacheKey: options.cacheKey ?? null, ...partOf( options ) }
996
+ );
997
+ return;
998
+
999
+ }
1000
+
901
1001
  const format = this.assetLoader?.getFileFormat( file?.name || '' );
902
1002
  if ( ! format ) throw new Error( `Unsupported file format: ${file?.name}` );
903
1003
 
904
1004
  if ( format.type !== 'environment' && format.type !== 'image' ) {
905
1005
 
1006
+ const identity = fileIdentity( file ).catch( () => ( { name: file.name, size: file.size, lastModified: file.lastModified ?? 0, sample: null } ) );
906
1007
  await this._loadWithSceneRebuild(
907
1008
  () => this.assetLoader.loadAssetFromFile( file, options ),
908
- { type: 'ModelLoaded', filename: file.name }
1009
+ { type: 'ModelLoaded', filename: file.name },
1010
+ async () => {
1011
+
1012
+ const id = await identity;
1013
+ return { kind: 'local-file', file: id, key: id.sample ? identityKey( id ) : null, ...partOf( options ) };
1014
+
1015
+ }
909
1016
  );
1017
+ this._sceneSourceFile = file;
910
1018
  return;
911
1019
 
912
1020
  }
@@ -948,7 +1056,8 @@ export class PathTracerApp extends EventDispatcher {
948
1056
 
949
1057
  await this._loadWithSceneRebuild(
950
1058
  () => this.assetLoader.loadExampleModels( index, modelFiles ),
951
- { type: 'ModelLoaded', index }
1059
+ { type: 'ModelLoaded', index },
1060
+ { kind: 'url', url: modelFiles[ index ]?.url ?? null, cacheKey: null }
952
1061
  );
953
1062
 
954
1063
  }
@@ -1009,8 +1118,28 @@ export class PathTracerApp extends EventDispatcher {
1009
1118
 
1010
1119
  }
1011
1120
 
1121
+ /**
1122
+ * Where the model on screen came from, as plain data — what a saved session reopens:
1123
+ * `{ kind: 'url', url, cacheKey, filename?, element?, pbrtEntry? }`,
1124
+ * `{ kind: 'local-file', file: {name, size, lastModified, sample}, key, element?, pbrtEntry? }` or
1125
+ * `{ kind: 'object3d', name }`. Null before a load and after {@link unloadScene}.
1126
+ * @returns {?Object}
1127
+ */
1128
+ get sceneSource() {
1129
+
1130
+ return this._sceneSource ?? null;
1131
+
1132
+ }
1133
+
1134
+ /** The File a local load came from, so a host can save a project that carries it; null otherwise. */
1135
+ get sceneSourceFile() {
1136
+
1137
+ return this._sceneSourceFile ?? null;
1138
+
1139
+ }
1140
+
1012
1141
  /** Shared pipeline: load asset → sync controls → build BVH → reset → dispatch events */
1013
- async _loadWithSceneRebuild( loadFn, eventPayload ) {
1142
+ async _loadWithSceneRebuild( loadFn, eventPayload, source = null ) {
1014
1143
 
1015
1144
  if ( this._loadingInProgress ) throw this._busyError( 'PathTracerApp' );
1016
1145
 
@@ -1019,6 +1148,8 @@ export class PathTracerApp extends EventDispatcher {
1019
1148
  try {
1020
1149
 
1021
1150
  await loadFn();
1151
+ this._sceneSource = typeof source === 'function' ? await source() : source;
1152
+ this._sceneSourceFile = null;
1022
1153
  // A fresh model re-establishes the emissive-sampling auto-default (incremental
1023
1154
  // rebuilds — add/remove object, texture reprocess — preserve the user's choice).
1024
1155
  this._emissiveSamplingUserSet = false;
@@ -1028,6 +1159,7 @@ export class PathTracerApp extends EventDispatcher {
1028
1159
  this._clearAppendedModels();
1029
1160
  this._syncControlsAfterLoad();
1030
1161
  await this.loadSceneData( { pendingEnvironment: this._beginSceneMetadataEnvironment() } );
1162
+ this._maybeSpill();
1031
1163
  this.pipeline?.eventBus.emit( 'autoexposure:resetHistory' );
1032
1164
  // Not held: the first denoise of a new scene lands ~1.1 s after the load (upload and
1033
1165
  // shader compilation come first), and a second of the previous model reads as a bug.
@@ -1060,6 +1192,8 @@ export class PathTracerApp extends EventDispatcher {
1060
1192
  */
1061
1193
  _discardFailedLoad() {
1062
1194
 
1195
+ this._sceneSource = this._sceneSourceFile = null;
1196
+
1063
1197
  try {
1064
1198
 
1065
1199
  this._clearAppendedModels();
@@ -1172,7 +1306,7 @@ export class PathTracerApp extends EventDispatcher {
1172
1306
  * CDF'd concurrently with the BVH build rather than before it.
1173
1307
  * @returns {boolean}
1174
1308
  */
1175
- async loadSceneData( { pendingEnvironment = null } = {} ) {
1309
+ async loadSceneData( { pendingEnvironment = null, keepUserLights = false } = {} ) {
1176
1310
 
1177
1311
  // Clear selection before rebuilding — the old object leaves the scene graph.
1178
1312
  // Skipped on the append path (addModel): the selected object persists, so its
@@ -1188,7 +1322,9 @@ export class PathTracerApp extends EventDispatcher {
1188
1322
  this._tagPrimarySceneObject();
1189
1323
 
1190
1324
  const timer = new BuildTimer( '', { namespace: 'scene', level: 'info' } );
1191
- const environmentTexture = this.meshScene.environment;
1325
+ // meshScene.environment is the HDRI slot: a sky, gradient or colour in use outlives the model.
1326
+ const hdri = () => this.stages.pathTracer.environment.envParams.mode === 'hdri' ? this.meshScene.environment : null;
1327
+ const environmentTexture = hdri();
1192
1328
 
1193
1329
  // Environment CDF build in parallel with BVH
1194
1330
  let cdfPromise = null;
@@ -1231,7 +1367,8 @@ export class PathTracerApp extends EventDispatcher {
1231
1367
  // Build BVH
1232
1368
  timer.start( 'BVH build (SceneProcessor)' );
1233
1369
  this._sdf.setMaxTextureSize( this._maxTextureSize );
1234
- await this._sdf.buildBVH( this.meshScene );
1370
+ await this._sdf.buildBVH( this.meshScene, { sceneKey: this.assetLoader?.sceneSourceKey ?? null, progressive: this._progressiveSpill() } );
1371
+ this.assetLoader?.flushPendingGraph( this._sdf.performanceMetrics.totalProcessingTime );
1235
1372
  timer.end( 'BVH build (SceneProcessor)' );
1236
1373
 
1237
1374
  // Transfer geometry, materials, and textures to GPU
@@ -1241,7 +1378,7 @@ export class PathTracerApp extends EventDispatcher {
1241
1378
 
1242
1379
  // Re-read rather than reusing the snapshot above: a pendingEnvironment may have landed
1243
1380
  // during the BVH build, and the snapshot is then a disposed texture.
1244
- if ( ! this._sdf.uploadToPathTracer( this.stages.pathTracer, this.lightManager, this.meshScene, this.meshScene.environment ) ) return false;
1381
+ if ( ! this._sdf.uploadToPathTracer( this.stages.pathTracer, this.lightManager, this.meshScene, hdri(), { keepUserLights } ) ) return false;
1245
1382
 
1246
1383
  // Patch per-mesh visibility into the TLAS leaves we just uploaded
1247
1384
  this.stages.pathTracer._meshRefs = this.stages.pathTracer._collectMeshRefs( this.meshScene );
@@ -1369,7 +1506,8 @@ export class PathTracerApp extends EventDispatcher {
1369
1506
  /** Reframe-free rebuild sequence. Assumes the _loadingInProgress guard is already held. */
1370
1507
  async _finishRebuildNoReframe( eventPayload ) {
1371
1508
 
1372
- await this.loadSceneData(); // emits 'SceneRebuild'
1509
+ await this.loadSceneData( { keepUserLights: true } ); // emits 'SceneRebuild'
1510
+ this._maybeSpill();
1373
1511
  this._recalibrateControlLimits(); // scene bounds changed — retune zoom limits + near/far (no camera move)
1374
1512
  this.pipeline?.eventBus.emit( 'autoexposure:resetHistory' );
1375
1513
  this.reset();
@@ -1385,7 +1523,7 @@ export class PathTracerApp extends EventDispatcher {
1385
1523
  * @param {string} [opts.name] - Display name for the scene-object list.
1386
1524
  * @returns {Promise<string>} the new object's id (Object3D uuid).
1387
1525
  */
1388
- async addModel( url, { name } = {} ) {
1526
+ async addModel( url, { name, cacheKey } = {} ) {
1389
1527
 
1390
1528
  if ( this._loadingInProgress ) {
1391
1529
 
@@ -1397,8 +1535,10 @@ export class PathTracerApp extends EventDispatcher {
1397
1535
  this._preserveSelectionOnRebuild = true;
1398
1536
  try {
1399
1537
 
1400
- const { root } = await this.assetLoader.appendModel( url );
1538
+ const { root } = await this.assetLoader.appendModel( url, { cacheKey } );
1401
1539
  root.userData.__rayzeeSceneObject = true;
1540
+ root.userData.__rayzeeSourceUrl = url;
1541
+ if ( cacheKey ) root.userData.__rayzeeCacheKey = cacheKey;
1402
1542
  if ( name ) root.userData.__rayzeeName = name;
1403
1543
  await this._finishRebuildNoReframe( { type: 'ModelAdded', url, id: root.uuid } );
1404
1544
  return root.uuid;
@@ -1551,6 +1691,75 @@ export class PathTracerApp extends EventDispatcher {
1551
1691
 
1552
1692
  }
1553
1693
 
1694
+ /**
1695
+ * Everything changed since the scene loaded — render settings, environment, colour, lights,
1696
+ * cameras, timeline keys, material edits, hidden and moved objects — as JSON-safe data. The
1697
+ * model is not in it: {@link importSceneState} applies it once the host has loaded that again.
1698
+ * @returns {Object}
1699
+ */
1700
+ exportSceneState() {
1701
+
1702
+ return captureSceneState( this );
1703
+
1704
+ }
1705
+
1706
+ /**
1707
+ * Applies {@link exportSceneState}'s data to the scene now loaded. Objects, materials and
1708
+ * cameras are matched by position plus name; a scene that differs keeps its own objects and
1709
+ * materials and the rest still applies.
1710
+ * @param {Object} state
1711
+ * @param {Object} [options]
1712
+ * @param {function(Object): Promise<*>} [options.resolve] - supplies what the engine cannot
1713
+ * reach: `{ kind: 'environment', source }` → File | URL | null, `{ kind: 'colorConfig', config }`
1714
+ * → true once loaded. Without it, URL environments and built-in colour configs still restore.
1715
+ * @returns {Promise<{skipped: Array<{section: string, reason: string}>}>}
1716
+ */
1717
+ async importSceneState( state, options ) {
1718
+
1719
+ return await applySceneState( this, state, options );
1720
+
1721
+ }
1722
+
1723
+ /** Triangle and placement budgets for a scene archive: raised when the build can spill. @private */
1724
+ _sceneBudgets( options ) {
1725
+
1726
+ if ( ! this._memorySpill || ! this.storage ) return options;
1727
+ return { maxTriangles: SPILL_TRIANGLE_BUDGET, maxPlacements: SPILL_PLACEMENT_BUDGET, ...options };
1728
+
1729
+ }
1730
+
1731
+ /**
1732
+ * Experimental memory spill, during the build: static scenes only, since a deforming clip
1733
+ * refits every frame. @private
1734
+ */
1735
+ _progressiveSpill() {
1736
+
1737
+ if ( ! this._memorySpill || ! this.storage || ( this.assetLoader?.animations?.length ?? 0 ) > 0 ) return null;
1738
+ const stage = this.stages.pathTracer;
1739
+ return { storage: this.storage, uploader: ( records, kind ) => kind === 'triangles' ? stage.createTriangleChunkUploader( records ) : stage.createChunkUploader( records ) };
1740
+
1741
+ }
1742
+
1743
+ /** Experimental memory spill for a static scene, in the background. @private */
1744
+ _maybeSpill() {
1745
+
1746
+ if ( ! this._memorySpill || ! this.storage || this.animationManager?.hasAnimations ) return;
1747
+ this._sdf?.spillToDisk( this.storage )
1748
+ .then( result => result && this.dispatchEvent( { type: 'SceneSpilled', ...result } ) )
1749
+ .catch( error => this._issues.warn( ISSUE_CODES.STORAGE_WRITE_FAILED, `memory spill failed: ${error.message}` ) );
1750
+
1751
+ }
1752
+
1753
+ /**
1754
+ * Reads back what {@link options.memorySpill} moved to disk. The engine does this itself before
1755
+ * its own readers; a host calling {@link refitBLASes} on a spilled scene awaits it first.
1756
+ */
1757
+ async ensureSceneResident() {
1758
+
1759
+ if ( this._sdf?.spilled ) await this._sdf.ensureResident();
1760
+
1761
+ }
1762
+
1554
1763
  /** Notify consumers that the camera list changed (names / count). */
1555
1764
  _dispatchCamerasUpdated() {
1556
1765
 
@@ -1629,6 +1838,7 @@ export class PathTracerApp extends EventDispatcher {
1629
1838
  */
1630
1839
  async refitBVH( newPositions, newNormals ) {
1631
1840
 
1841
+ await this.ensureSceneResident();
1632
1842
  const result = await this._sdf.refitBVH( newPositions, newNormals );
1633
1843
 
1634
1844
  this.stages.pathTracer.updateTriangleData( this._sdf.triangles );
@@ -1651,6 +1861,7 @@ export class PathTracerApp extends EventDispatcher {
1651
1861
  */
1652
1862
  refitBLASes( affectedMeshIndices, newPositions, newNormals ) {
1653
1863
 
1864
+ if ( this._sdf?.spilled ) throw new Error( 'refitBLASes: the scene is spilled to disk — await app.ensureSceneResident() first' );
1654
1865
  const result = this._sdf.refitBLASes( affectedMeshIndices, newPositions, newNormals );
1655
1866
 
1656
1867
  const { triRanges, bvhRanges } = this._sdf.computeBLASDirtyRanges( affectedMeshIndices );
@@ -1731,6 +1942,13 @@ export class PathTracerApp extends EventDispatcher {
1731
1942
  _refreshMovedEmitters() {
1732
1943
 
1733
1944
  if ( ! this._emittersMoved ) return;
1945
+ if ( this._sdf?.spilled ) {
1946
+
1947
+ this.ensureSceneResident().then( () => this._refreshMovedEmitters() );
1948
+ return;
1949
+
1950
+ }
1951
+
1734
1952
  this._emittersMoved = false;
1735
1953
  this._uploadEmissivePayload( this._sdf?.refreshEmissiveTransforms() ?? null );
1736
1954
 
@@ -2104,6 +2322,10 @@ export class PathTracerApp extends EventDispatcher {
2104
2322
  // renderMode has no SETTING_ROUTES entry
2105
2323
  this.stages.pathTracer?.setUniform( 'renderMode', parseInt( config.renderMode ) );
2106
2324
 
2325
+ // A move just before this left the moving-camera drop in force or queued; a final render
2326
+ // never runs at it.
2327
+ if ( isProduction ) this._applyRenderScale( 1 );
2328
+
2107
2329
  this.stages.pathTracer?.updateCompletionThreshold?.();
2108
2330
 
2109
2331
  const denoiser = this.denoisingManager?.denoiser;
@@ -2447,6 +2669,41 @@ export class PathTracerApp extends EventDispatcher {
2447
2669
 
2448
2670
  }
2449
2671
 
2672
+ /**
2673
+ * A render in progress as data: the accumulated colour (and the aux buffers while they hold
2674
+ * samples), the per-pixel convergence buffers, and the counters that choose the next sample.
2675
+ * {@link restoreRenderCheckpoint} continues it — bit-for-bit in deterministic mode, since the
2676
+ * random sequence is a pure function of pixel, frame and seed tick. At 4K it is ~500 MB.
2677
+ * @returns {Promise<?Object>} null before the path tracer exists
2678
+ */
2679
+ async captureRenderCheckpoint() {
2680
+
2681
+ const stage = this.stages.pathTracer;
2682
+ if ( ! stage ) return null;
2683
+ const accumulation = await stage.captureAccumulation();
2684
+ return { v: RENDER_CHECKPOINT_VERSION, engine: VERSION, savedAt: Date.now(), samples: accumulation.state.frameCount, ...accumulation };
2685
+
2686
+ }
2687
+
2688
+ /**
2689
+ * Continues a {@link captureRenderCheckpoint}. The scene, settings and render size must be the
2690
+ * ones it was taken with (restore the scene state first), and the scene must have rendered a
2691
+ * frame at that size since it last changed. Anything that resets accumulation afterwards
2692
+ * discards it. Rendering is not restarted: call {@link wake} or {@link renderFrames}.
2693
+ * @param {Object} checkpoint
2694
+ */
2695
+ restoreRenderCheckpoint( checkpoint ) {
2696
+
2697
+ if ( checkpoint?.v !== RENDER_CHECKPOINT_VERSION ) throw new Error( `render checkpoint version ${checkpoint?.v} is not ${RENDER_CHECKPOINT_VERSION}` );
2698
+ const stage = this.stages.pathTracer;
2699
+ if ( ! stage ) throw new Error( 'restoreRenderCheckpoint: app is not initialized' );
2700
+
2701
+ stage.restoreAccumulation( checkpoint );
2702
+ this.needsReset = false;
2703
+ this.dispatchEvent( { type: EngineEvents.RENDER_RESET, restored: checkpoint.samples } );
2704
+
2705
+ }
2706
+
2450
2707
  /**
2451
2708
  * The path tracer's accumulation as pixels, read from its storage target rather than the
2452
2709
  * canvas — so it works headless, works while the page is hidden, and cannot pick up a
@@ -2952,6 +3209,14 @@ export class PathTracerApp extends EventDispatcher {
2952
3209
 
2953
3210
  setMaterialProperty( materialIndex, property, value ) {
2954
3211
 
3212
+ // These rewrite triangle records, which a spilled scene has to read back first.
3213
+ if ( this._sdf?.spilled && TRIANGLE_PATCH_PROPERTIES.has( property ) ) {
3214
+
3215
+ this.ensureSceneResident().then( () => this.setMaterialProperty( materialIndex, property, value ) );
3216
+ return;
3217
+
3218
+ }
3219
+
2955
3220
  this.stages.pathTracer?.materialData.updateMaterialProperty( materialIndex, property, value );
2956
3221
 
2957
3222
  // Keep the emissive-NEE structure in sync unconditionally (not gated on the
@@ -3255,6 +3520,9 @@ export class PathTracerApp extends EventDispatcher {
3255
3520
  }
3256
3521
 
3257
3522
  if ( envChanged ) await this.environmentManager?.buildEnvironmentCDF?.();
3523
+ // Its pixels never leave the GPU, so it is baked again in the new space rather than converted.
3524
+ if ( env?._isPhysicalSky ) await this.environmentManager.generateProceduralSkyTexture();
3525
+ this.environmentManager?.refreshSun?.();
3258
3526
 
3259
3527
  this.reset();
3260
3528
 
@@ -3266,6 +3534,7 @@ export class PathTracerApp extends EventDispatcher {
3266
3534
  */
3267
3535
  async rebuildMaterials( scene ) {
3268
3536
 
3537
+ await this.ensureSceneResident();
3269
3538
  await this.stages.pathTracer?.rebuildMaterials( scene || this.meshScene );
3270
3539
  this.reset();
3271
3540
 
@@ -3531,6 +3800,8 @@ export class PathTracerApp extends EventDispatcher {
3531
3800
  // Expose environment manager (lives on pathTracer stage)
3532
3801
  this.environmentManager = this.stages.pathTracer.environment;
3533
3802
  this.environmentManager.callbacks.onAutoExposureReset = () => this.pipeline.eventBus.emit( 'autoexposure:resetHistory' );
3803
+ // A whole-app reset, not the stage's: a sky bake lands after its input, often once the loop is idle.
3804
+ this.environmentManager.callbacks.onReset = () => this.reset();
3534
3805
 
3535
3806
  }
3536
3807
 
@@ -0,0 +1,120 @@
1
+ import { createTarIndexer } from './ArchiveReader.js';
2
+ import { ENGINE_AREAS } from '../Storage/StorageManager.js';
3
+ import { fileIdentity, identityKey } from '../Storage/identity.js';
4
+ import { ISSUE_CODES } from '../EngineIssues.js';
5
+
6
+ const INDEX_VERSION = 1;
7
+ // Scene text compresses 3-5×; reserving too little only means the write fails and falls back.
8
+ const GUESSED_EXPANSION = 3;
9
+
10
+ export async function archiveKey( kind, file ) {
11
+
12
+ return `${kind}:${identityKey( await fileIdentity( file ) )}`;
13
+
14
+ }
15
+
16
+ /**
17
+ * Decompresses a .tar.gz into storage once, indexing it on the way, and hands back the unpacked
18
+ * .tar as a seekable File. A second call for the same file skips the work.
19
+ * @returns {Promise<?{file: File, index: {v:number, listing:Array}, release: function(): void, cached: boolean}>}
20
+ * null when storage cannot take it — the caller reads the archive the old way.
21
+ */
22
+ export async function unpackTarGz( file, { storage, label = file.name, onProgress = null } ) {
23
+
24
+ if ( ! storage || typeof DecompressionStream === 'undefined' ) return null;
25
+
26
+ const area = storage.area( ENGINE_AREAS.ARCHIVES );
27
+ const key = await archiveKey( 'gunzip', file );
28
+
29
+ let entry = await area.open( key );
30
+ const cached = !! entry;
31
+
32
+ if ( ! entry ) {
33
+
34
+ const writer = await area.create( key, { label, expectedBytes: file.size * GUESSED_EXPANSION } );
35
+ if ( ! writer ) return null;
36
+
37
+ const indexer = createTarIndexer();
38
+ const tap = new TransformStream( {
39
+ transform( chunk, controller ) {
40
+
41
+ indexer.push( chunk );
42
+ controller.enqueue( chunk );
43
+
44
+ },
45
+ } );
46
+
47
+ try {
48
+
49
+ const unpacked = file.stream().pipeThrough( new DecompressionStream( 'gzip' ) ).pipeThrough( tap );
50
+ const tarBytes = await writer.writeStream( 'data.tar', unpacked, { onProgress } );
51
+ await writer.writeJSON( 'index.json', { v: INDEX_VERSION, listing: indexer.finish() } );
52
+ await writer.commit( { compressedBytes: file.size, tarBytes } );
53
+
54
+ } catch ( error ) {
55
+
56
+ await writer.abort();
57
+ if ( error?.name !== 'QuotaExceededError' ) throw error;
58
+ storage._issue( ISSUE_CODES.STORAGE_QUOTA_EXCEEDED, `ran out of room unpacking ${label}; reading it in memory instead`, { area: ENGINE_AREAS.ARCHIVES } );
59
+ return null;
60
+
61
+ }
62
+
63
+ entry = await area.open( key, { wait: true } );
64
+ if ( ! entry ) return null;
65
+
66
+ }
67
+
68
+ const tar = await entry.file( 'data.tar' );
69
+ const index = await entry.json( 'index.json' );
70
+ if ( ! tar || index?.v !== INDEX_VERSION ) {
71
+
72
+ entry.release();
73
+ return null;
74
+
75
+ }
76
+
77
+ return { file: tar, index, release: () => entry.release(), cached };
78
+
79
+ }
80
+
81
+ /** A plain .tar's saved header index, so reopening it skips even the header walk. */
82
+ export async function loadTarIndex( file, storage ) {
83
+
84
+ if ( ! storage ) return null;
85
+ const entry = await storage.area( ENGINE_AREAS.ARCHIVES ).open( await archiveKey( 'tarindex', file ) );
86
+ if ( ! entry ) return null;
87
+
88
+ try {
89
+
90
+ const index = await entry.json( 'index.json' );
91
+ return index?.v === INDEX_VERSION ? index : null;
92
+
93
+ } finally {
94
+
95
+ entry.release();
96
+
97
+ }
98
+
99
+ }
100
+
101
+ export async function saveTarIndex( file, storage, index ) {
102
+
103
+ if ( ! storage || ! index ) return false;
104
+ const writer = await storage.area( ENGINE_AREAS.ARCHIVES ).create( await archiveKey( 'tarindex', file ), { label: `${file.name} (index)` } );
105
+ if ( ! writer ) return false;
106
+
107
+ try {
108
+
109
+ await writer.writeJSON( 'index.json', index );
110
+ await writer.commit();
111
+ return true;
112
+
113
+ } catch {
114
+
115
+ await writer.abort();
116
+ return false;
117
+
118
+ }
119
+
120
+ }