rayzee 8.8.2 → 9.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/README.md +210 -20
  2. package/dist/{NeuralRetouch-CYOJ21Wn.js → NeuralRetouch-BRquqfmZ.js} +1 -1
  3. package/dist/{NeuralSuperRes-Dc6jRYke.js → NeuralSuperRes-CbNvQjCw.js} +5 -2
  4. package/dist/{ToneMapGPU-BVE852iN.js → ToneMapGPU-DpUZDMbQ.js} +2 -1
  5. package/dist/rayzee.es.js +10795 -5324
  6. package/dist/rayzee.umd.js +279 -268
  7. package/package.json +1 -1
  8. package/src/AssetConfig.js +4 -0
  9. package/src/EngineDefaults.js +60 -76
  10. package/src/EngineEvents.js +7 -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/Passes/OIDNTemporalHistory.js +25 -7
  16. package/src/PathTracerApp.js +313 -25
  17. package/src/Processor/ArchiveCache.js +120 -0
  18. package/src/Processor/ArchiveReader.js +179 -32
  19. package/src/Processor/AssetLoader.js +523 -76
  20. package/src/Processor/AtmosphereModel.js +465 -0
  21. package/src/Processor/BVHBuilder.js +4 -1
  22. package/src/Processor/BVHLeafFold.js +100 -0
  23. package/src/Processor/BVHRefitter.js +98 -214
  24. package/src/Processor/ChunkedRecords.js +189 -4
  25. package/src/Processor/EmissiveTriangleBuilder.js +8 -0
  26. package/src/Processor/GeometryExtractor.js +238 -40
  27. package/src/Processor/InstanceTable.js +55 -37
  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 +210 -55
  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 +49 -8
  45. package/src/SceneState/SceneState.js +367 -0
  46. package/src/SceneState/portable.js +52 -0
  47. package/src/Stages/MotionVector.js +8 -42
  48. package/src/Stages/NRD.js +30 -21
  49. package/src/Stages/NormalDepth.js +12 -23
  50. package/src/Stages/PathTracer.js +114 -6
  51. package/src/Stages/PathTracerStage.js +251 -104
  52. package/src/Storage/BLASCache.js +190 -0
  53. package/src/Storage/CDFCache.js +77 -0
  54. package/src/Storage/DownloadCache.js +266 -0
  55. package/src/Storage/GeometrySpill.js +217 -0
  56. package/src/Storage/SceneGraphCodec.js +503 -0
  57. package/src/Storage/SpillStore.js +112 -0
  58. package/src/Storage/StorageManager.js +934 -0
  59. package/src/Storage/StorageOps.js +137 -0
  60. package/src/Storage/StorageWorker.js +37 -0
  61. package/src/Storage/events.js +29 -0
  62. package/src/Storage/identity.js +77 -0
  63. package/src/Storage/index.js +9 -0
  64. package/src/Storage/inlineTransport.js +24 -0
  65. package/src/Storage/locks.js +99 -0
  66. package/src/Storage/openStorage.js +87 -0
  67. package/src/Storage/sceneCachePolicy.js +12 -0
  68. package/src/Storage/shared.js +9 -0
  69. package/src/Storage/transport.js +62 -0
  70. package/src/TSL/Atmosphere.js +684 -0
  71. package/src/TSL/BVHTraversal.js +288 -239
  72. package/src/TSL/CameraRay.js +99 -14
  73. package/src/TSL/Common.js +11 -2
  74. package/src/TSL/DebugKernel.js +2 -2
  75. package/src/TSL/Displacement.js +6 -6
  76. package/src/TSL/EmissiveSampling.js +5 -5
  77. package/src/TSL/EnvironmentCDF.js +176 -0
  78. package/src/TSL/GenerateKernel.js +5 -5
  79. package/src/TSL/HitFacet.js +4 -4
  80. package/src/TSL/LightsDirect.js +8 -8
  81. package/src/TSL/LightsSampling.js +55 -37
  82. package/src/TSL/Random.js +1 -0
  83. package/src/TSL/ShadeKernel.js +75 -19
  84. package/src/TSL/Sun.js +40 -0
  85. package/src/TSL/TextureSampling.js +6 -7
  86. package/src/index.js +22 -0
  87. package/src/managers/CameraManager.js +412 -56
  88. package/src/managers/DenoisingManager.js +4 -3
  89. package/src/managers/EnvironmentManager.js +263 -33
  90. package/src/managers/GoboManager.js +19 -8
  91. package/src/managers/IESManager.js +3 -3
  92. package/src/managers/InteractionManager.js +30 -2
  93. package/src/managers/LightManager.js +98 -5
  94. package/src/managers/MaterialDataManager.js +22 -2
  95. package/src/managers/TransformManager.js +26 -0
  96. package/src/managers/UniformManager.js +11 -7
  97. package/src/managers/VideoRenderManager.js +38 -17
  98. package/src/managers/ViewCamera.js +77 -0
  99. package/src/managers/WalkControls.js +262 -0
  100. package/src/managers/helpers/OutlineHelper.js +22 -2
  101. package/src/managers/timeline/CameraTrack.js +235 -0
  102. package/src/managers/timeline/TimelineManager.js +146 -0
  103. package/src/version.js +3 -0
  104. package/src/Processor/ProceduralSky.js +0 -291
@@ -1,6 +1,6 @@
1
1
  import { Box3, BufferGeometry, Vector3, RectAreaLight, Color, FloatType, LinearFilter, EquirectangularReflectionMapping,
2
2
  TextureLoader, Texture, SRGBColorSpace, RepeatWrapping, Mesh, MeshStandardMaterial, MeshPhysicalMaterial,
3
- CircleGeometry, Points, PointsMaterial, LoadingManager, EventDispatcher
3
+ CircleGeometry, Points, PointsMaterial, LoadingManager, EventDispatcher, LoaderUtils
4
4
  } from 'three';
5
5
  import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
6
6
  import { HDRLoader } from 'three/addons/loaders/HDRLoader.js';
@@ -10,13 +10,22 @@ import { EXRLoader } from 'three/addons/loaders/EXRLoader.js';
10
10
  import { createMeshesFromMultiMaterialMesh } from 'three/addons/utils/SceneUtils.js';
11
11
  import { clone as cloneWithSkeletons } from 'three/addons/utils/SkeletonUtils.js';
12
12
  import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js';
13
- import { unzipSync, zipSync, strFromU8 } from 'three/addons/libs/fflate.module.js';
13
+ import { zipSync, strFromU8 } from 'three/addons/libs/fflate.module.js';
14
14
  import {
15
- detectArchiveKind, readTarGz, readTar, elementFilter, listArchiveElements, openTar } from './ArchiveReader.js';
15
+ detectArchiveKind, readTarGz, elementFilter, listArchiveElements, openTar, indexTarHeaders } from './ArchiveReader.js';
16
+ import { openZip, readZipDirectory } from './ZipReader.js';
17
+ import { unpackTarGz, loadTarIndex, saveTarIndex } from './ArchiveCache.js';
18
+ import { DownloadCache, nameFromUrl, cachedObjectURL } from '../Storage/DownloadCache.js';
19
+ import { setEnvironmentSource } from '../Storage/CDFCache.js';
20
+ import { fileIdentity, identityKey, sampleHash } from '../Storage/identity.js';
21
+ import { ENGINE_AREAS } from '../Storage/StorageManager.js';
22
+ import { encodeSceneGraph, writeSceneGraph, decodeSceneGraph, SceneGraphUnsupported, SCENE_GRAPH_FORMAT, ARCHIVE_PATH, ARCHIVE_LOADER } from '../Storage/SceneGraphCodec.js';
23
+ import { worthStoring } from '../Storage/sceneCachePolicy.js';
24
+ import { VERSION } from '../version.js';
16
25
  import { disposeEngineOwnedResources, disposeObjectFromMemory, updateLoading } from './utils';
17
26
  import { BuildTimer } from './BuildTimer.js';
18
27
  import { getAssetConfig } from '../AssetConfig.js';
19
- import { loadPBRTScene, pickEntryPath } from './PBRT/index.js';
28
+ import { loadPBRTScene, pickEntryPath, VirtualFS, PBRT_BUILD_REVISION } from './PBRT/index.js';
20
29
  import { extractSceneMetadata } from './SceneMetadata.js';
21
30
  import { ISSUE_CODES, ISSUE_SEVERITY } from '../EngineIssues.js';
22
31
  import { getRenderProfile } from '../EngineDefaults.js';
@@ -97,6 +106,7 @@ export class AssetLoader extends EventDispatcher {
97
106
  this.targetModel = null;
98
107
  this.floorPlane = null;
99
108
  this.sceneScale = 1.0;
109
+ this.sceneSize = new Vector3( 1, 1, 1 );
100
110
  this.loaderCache = {};
101
111
  this.uploadedFileInfo = null;
102
112
  this.animations = [];
@@ -111,6 +121,13 @@ export class AssetLoader extends EventDispatcher {
111
121
  // signal into its fetch). One load runs at a time (guarded upstream).
112
122
  this._loadingManager = new LoadingManager();
113
123
  this._loadCancelled = false;
124
+ this._urlAbort = null;
125
+
126
+ /** @type {?import('../Storage/StorageManager.js').StorageManager} set by the app once storage opens */
127
+ this.storage = null;
128
+ this._downloads = null;
129
+ this._sourceKey = null;
130
+ this._appended = false;
114
131
 
115
132
  this._issues = issues;
116
133
  this._profile = profile ?? getRenderProfile();
@@ -140,6 +157,66 @@ export class AssetLoader extends EventDispatcher {
140
157
 
141
158
  this._loadCancelled = true;
142
159
  this._loadingManager.abort();
160
+ this._urlAbort?.abort();
161
+
162
+ }
163
+
164
+ /**
165
+ * `url` swapped for an object URL of its stored copy, downloading it first if needed. A .gltf
166
+ * with its resources beside it, and non-network URLs, pass through untouched.
167
+ * @private
168
+ */
169
+ async _viaCache( url, status, cancelable, { cacheKey = url, cachePolicy } = {} ) {
170
+
171
+ if ( ! this.storage || ! AssetLoader._isNetworkUrl( url ) || /\.gltf$/i.test( url.split( /[?#]/ )[ 0 ] ) ) {
172
+
173
+ return { url, release: () => {}, cached: false };
174
+
175
+ }
176
+
177
+ const controller = new AbortController();
178
+ this._urlAbort = controller;
179
+ try {
180
+
181
+ return await cachedObjectURL( url, {
182
+ downloads: this.downloads, key: cacheKey, policy: cachePolicy, name: nameFromUrl( url ),
183
+ signal: controller.signal, onProgress: this._downloadProgress( status, cancelable ),
184
+ } );
185
+
186
+ } catch ( error ) {
187
+
188
+ if ( this._isCancellation( error ) ) throw this._cancellationError();
189
+ throw error;
190
+
191
+ } finally {
192
+
193
+ this._urlAbort = null;
194
+
195
+ }
196
+
197
+ }
198
+
199
+ /**
200
+ * What the scene was loaded from, stable across reloads, or null — for the stored-BVH cache.
201
+ * Null once anything was appended: the scene is no longer one source's.
202
+ */
203
+ get sceneSourceKey() {
204
+
205
+ return this._appended ? null : this._sourceKey;
206
+
207
+ }
208
+
209
+ _keyed( kind, id ) {
210
+
211
+ return id ? `${kind}:${this._profile?.name ?? ''}:${id}` : null;
212
+
213
+ }
214
+
215
+ /** Downloads go through storage when there is some, into memory otherwise. */
216
+ get downloads() {
217
+
218
+ if ( this._downloads?._storage !== this.storage ) this._downloads = new DownloadCache( this.storage );
219
+ return this._downloads;
143
220
 
144
221
  }
145
222
 
@@ -240,6 +317,9 @@ export class AssetLoader extends EventDispatcher {
240
317
  releaseTargetModel() {
241
318
 
242
319
  this.sceneMetadata = null;
320
+ this._sourceKey = null;
321
+ this._appended = false;
322
+ this._pendingGraph = null;
243
323
 
244
324
  if ( ! this.targetModel ) return;
245
325
 
@@ -324,8 +404,75 @@ export class AssetLoader extends EventDispatcher {
324
404
 
325
405
  }
326
406
 
407
+ /**
408
+ * Downloads a URL (through the download cache) and loads it as `loadAssetFromFile` would —
409
+ * archives included. An ARCHIVE_NEEDS_ELEMENT error carries the downloaded `file` to retry with.
410
+ * @param {string} url
411
+ * @param {object} [options] - as loadAssetFromFile, plus `filename` to name the download and
412
+ * `cacheKey` for URLs that expire (signed links)
413
+ */
414
+ async loadAssetFromUrl( url, options = {} ) {
415
+
416
+ const { filename = nameFromUrl( url ), cacheKey = url, cachePolicy, ...loadOptions } = options;
417
+ if ( ! this.getFileFormat( filename ) ) throw new Error( `Unsupported file format: ${filename}` );
418
+
419
+ this._loadCancelled = false;
420
+ const cancelable = AssetLoader._isNetworkUrl( url );
421
+ const status = `Downloading ${filename}...`;
422
+ const controller = new AbortController();
423
+ this._urlAbort = controller;
424
+
425
+ updateLoading( { isLoading: true, status, progress: 2, canCancel: cancelable, loadedBytes: 0, totalBytes: 0 } );
426
+
427
+ let download;
428
+ try {
429
+
430
+ download = await this.downloads.fetch( url, {
431
+ key: cacheKey, name: filename, policy: cachePolicy, signal: controller.signal,
432
+ onProgress: this._downloadProgress( status, cancelable ),
433
+ } );
434
+
435
+ } catch ( error ) {
436
+
437
+ if ( this._isCancellation( error ) ) throw this._cancellationError();
438
+ throw error;
439
+
440
+ } finally {
441
+
442
+ this._urlAbort = null;
443
+
444
+ }
445
+
446
+ this._downloadComplete();
447
+
448
+ try {
449
+
450
+ return await this.loadAssetFromFile( download.file, loadOptions );
451
+
452
+ } catch ( error ) {
453
+
454
+ if ( error?.code === 'ARCHIVE_NEEDS_ELEMENT' ) error.file = download.file;
455
+ throw error;
456
+
457
+ } finally {
458
+
459
+ download.release();
460
+
461
+ }
462
+
463
+ }
464
+
327
465
  async loadModelFromFile( file, filename ) {
328
466
 
467
+ const key = this.storage ? this._keyed( 'file', identityKey( await fileIdentity( file ) ) ) : null;
468
+ const result = await this._loadModelFileByExtension( file, filename );
469
+ this._sourceKey = key;
470
+ return result;
471
+
472
+ }
473
+
474
+ async _loadModelFileByExtension( file, filename ) {
475
+
329
476
  const extension = filename.split( '.' ).pop().toLowerCase();
330
477
  const arrayBuffer = await this.readFileAsArrayBuffer( file );
331
478
 
@@ -356,6 +503,7 @@ export class AssetLoader extends EventDispatcher {
356
503
  try {
357
504
 
358
505
  const texture = await this.loadEnvironment( url );
506
+ setEnvironmentSource( texture, identityKey( await fileIdentity( file ) ) );
359
507
  this.dispatchEvent( { type: 'load', texture, filename } );
360
508
  return texture;
361
509
 
@@ -394,6 +542,7 @@ export class AssetLoader extends EventDispatcher {
394
542
  }
395
543
 
396
544
  texture.generateMipmaps = true;
545
+ if ( AssetLoader._isNetworkUrl( envUrl ) ) setEnvironmentSource( texture, envUrl );
397
546
 
398
547
  this.applyEnvironmentToScene( texture );
399
548
  this.dispatchEvent( { type: 'load', texture, url: envUrl, filename: envUrl.split( /[?#]/ )[ 0 ].split( '/' ).pop() } );
@@ -464,23 +613,33 @@ export class AssetLoader extends EventDispatcher {
464
613
  async loadEnvironmentByExtension( url, extension ) {
465
614
 
466
615
  const cancelable = AssetLoader._isNetworkUrl( url );
467
- const onProgress = this._downloadProgress( "Downloading Environment...", cancelable );
616
+ const status = "Downloading Environment...";
617
+ const source = await this._viaCache( url, status, cancelable );
618
+ const onProgress = source.cached ? undefined : this._downloadProgress( status, cancelable );
468
619
 
469
620
  let texture;
470
- if ( extension === 'hdr' || extension === 'exr' ) {
621
+ try {
471
622
 
472
- const loader = extension === 'hdr'
473
- ? ( this.loaderCache.hdr || ( this.loaderCache.hdr = new HDRLoader( this._loadingManager ).setDataType( FloatType ) ) )
474
- : ( this.loaderCache.exr || ( this.loaderCache.exr = new EXRLoader( this._loadingManager ).setDataType( FloatType ) ) );
475
- texture = await loader.loadAsync( url, onProgress );
623
+ if ( extension === 'hdr' || extension === 'exr' ) {
476
624
 
477
- } else {
625
+ const loader = extension === 'hdr'
626
+ ? ( this.loaderCache.hdr || ( this.loaderCache.hdr = new HDRLoader( this._loadingManager ).setDataType( FloatType ) ) )
627
+ : ( this.loaderCache.exr || ( this.loaderCache.exr = new EXRLoader( this._loadingManager ).setDataType( FloatType ) ) );
628
+ texture = await loader.loadAsync( source.url, onProgress );
478
629
 
479
- if ( ! this.loaderCache.texture ) this.loaderCache.texture = new TextureLoader( this._loadingManager );
480
- texture = await this.loaderCache.texture.loadAsync( url, onProgress );
481
- // LDR env maps (jpg/png/webp) are authored in sRGB; tag them so the backend
482
- // decodes to linear. HDR/EXR are already linear and keep the loader's setting.
483
- texture.colorSpace = SRGBColorSpace;
630
+ } else {
631
+
632
+ if ( ! this.loaderCache.texture ) this.loaderCache.texture = new TextureLoader( this._loadingManager );
633
+ texture = await this.loaderCache.texture.loadAsync( source.url, onProgress );
634
+ // LDR env maps (jpg/png/webp) are authored in sRGB; tag them so the backend
635
+ // decodes to linear. HDR/EXR are already linear and keep the loader's setting.
636
+ texture.colorSpace = SRGBColorSpace;
637
+
638
+ }
639
+
640
+ } finally {
641
+
642
+ source.release();
484
643
 
485
644
  }
486
645
 
@@ -523,36 +682,70 @@ export class AssetLoader extends EventDispatcher {
523
682
  async inspectArchive( file ) {
524
683
 
525
684
  const kind = detectArchiveKind( await this._readHead( file ) );
685
+ const scanning = p => updateLoading( {
686
+ isLoading: true, status: `Scanning archive… ${( p.bytes / 1e9 ).toFixed( 1 )} GB`, progress: 4
687
+ } );
526
688
 
527
- if ( kind === 'gzip' || kind === 'tar' ) {
689
+ let listing;
690
+ if ( kind === 'gzip' ) {
528
691
 
529
- const read = kind === 'gzip' ? readTarGz : readTar;
530
- const { listing } = await read( file, {
531
- filter: () => false,
532
- onProgress: p => updateLoading( {
533
- isLoading: true, status: `Scanning archive… ${( p.bytes / 1e9 ).toFixed( 1 )} GB`, progress: 4
534
- } )
535
- } );
536
- const { root, elements } = listArchiveElements( listing );
537
- return {
538
- kind, root, elements,
539
- entryCount: listing.length,
540
- totalBytes: listing.reduce( ( n, e ) => n + e.size, 0 )
541
- };
692
+ const unpacked = await this._unpackGzip( file, file.name );
693
+ if ( unpacked ) {
694
+
695
+ listing = unpacked.index.listing;
696
+ unpacked.release();
697
+
698
+ } else {
699
+
700
+ ( { listing } = await readTarGz( file, { filter: () => false, onProgress: scanning } ) );
701
+
702
+ }
703
+
704
+ } else if ( kind === 'tar' ) {
705
+
706
+ listing = ( await loadTarIndex( file, this.storage ) )?.listing ?? await indexTarHeaders( file, { onProgress: scanning } );
707
+
708
+ } else {
709
+
710
+ listing = ( await readZipDirectory( file ) ).map( e => ( { path: e.name, size: e.size } ) );
542
711
 
543
712
  }
544
713
 
545
- const zip = unzipSync( new Uint8Array( await this.readFileAsArrayBuffer( file ) ) );
546
- const listing = Object.keys( zip ).map( path => ( { path, size: zip[ path ].length } ) );
547
714
  const { root, elements } = listArchiveElements( listing );
548
715
  return {
549
- kind: 'zip', root, elements,
716
+ kind: kind ?? 'zip', root, elements,
550
717
  entryCount: listing.length,
551
718
  totalBytes: listing.reduce( ( n, e ) => n + e.size, 0 )
552
719
  };
553
720
 
554
721
  }
555
722
 
723
+ /**
724
+ * A .tar.gz unpacked to storage, or null to read it the old way (no storage, or no room).
725
+ * @private
726
+ */
727
+ async _unpackGzip( file, filename ) {
728
+
729
+ try {
730
+
731
+ return await unpackTarGz( file, {
732
+ storage: this.storage,
733
+ label: filename,
734
+ onProgress: bytes => updateLoading( {
735
+ isLoading: true, status: `Unpacking archive… ${( bytes / 1e9 ).toFixed( 1 )} GB`, progress: 4
736
+ } ),
737
+ } );
738
+
739
+ } catch ( error ) {
740
+
741
+ // A corrupt stream fails the same way in memory, where the error reads better.
742
+ console.warn( `Unpacking "${filename}" to storage failed, reading it in memory: ${error.message}` );
743
+ return null;
744
+
745
+ }
746
+
747
+ }
748
+
556
749
  /**
557
750
  * @param {File|Blob} file
558
751
  * @param {string} filename
@@ -571,39 +764,52 @@ export class AssetLoader extends EventDispatcher {
571
764
  * @param {number} [options.mergeShapesAbove] - merge small non-instanced shapes past this count.
572
765
  * @param {number} [options.curveSteps] - samples per spline span when tessellating curves.
573
766
  * @param {number} [options.curveSides] - 1 ribbon, 2 crossed ribbons, >=3 closed tube.
767
+ * @param {number} [options.curveTolerance] - how far a curve strip may stray from the curve, as a
768
+ * fraction of its half-width (default 0.05); 0 samples every span `curveSteps` times.
769
+ * @param {boolean} [options.instanceIncludes] - place a file included again under the same
770
+ * material as another instance of its first reading (default true).
574
771
  */
575
772
  async loadArchiveFromFile( file, filename, { pbrtEntry = null, element = null, byteBudget, promptBytes, ...pbrt } = {} ) {
576
773
 
577
774
  try {
578
775
 
579
776
  const kind = detectArchiveKind( await this._readHead( file ) );
777
+ const archiveId = this.storage ? identityKey( await fileIdentity( file ) ) : null;
778
+ const lazy = { element, promptBytes, pbrtEntry, pbrt, archiveId };
580
779
 
581
- // An uncompressed tar over a File is seekable, so it is indexed rather than read:
582
- // entries are pulled out as the scene asks for them and never all held at once.
583
- // That is the difference between a 7 GB archive being refused and loading.
584
- if ( kind === 'tar' ) {
780
+ // A seekable archive is indexed rather than read: entries are pulled out as the scene
781
+ // asks for them and never all held at once. That is the difference between a 7 GB archive
782
+ // being refused and loading. A .tar.gz becomes seekable by unpacking it to storage.
783
+ if ( kind === 'tar' ) return await this._loadSeekable( file, filename, lazy );
585
784
 
586
- const source = await this._openSeekableArchive( file, filename, element, promptBytes );
587
- if ( source.listing.some( e => e.path.toLowerCase().endsWith( '.pbrt' ) ) ) {
785
+ if ( kind === 'gzip' ) {
588
786
 
589
- return await this.loadPBRTFromZip( {}, filename, pbrtEntry, pbrt, source );
787
+ const unpacked = await this._unpackGzip( file, filename );
788
+ if ( unpacked ) {
590
789
 
591
- }
790
+ try {
592
791
 
593
- // Not a pbrt scene: fall back to materialising it, which those paths still expect.
594
- for ( const e of source.listing ) source.entries[ e.path ] ??= await source.read( e.path );
595
- return await this._loadNonPBRTArchive( source.entries, filename );
792
+ return await this._loadSeekable( unpacked.file, filename, { ...lazy, index: unpacked.index } );
596
793
 
597
- }
794
+ } finally {
795
+
796
+ unpacked.release();
598
797
 
599
- const entries = kind === 'gzip'
600
- ? await this._readStreamedArchive( file, filename, kind, element, byteBudget )
601
- : unzipSync( new Uint8Array( await this.readFileAsArrayBuffer( file ) ) );
798
+ }
799
+
800
+ }
602
801
 
603
- // A pbrt scene archive takes priority — it owns its own geometry/texture refs.
604
- if ( pickEntryPath( entries ) ) return await this.loadPBRTFromZip( entries, filename, pbrtEntry, pbrt );
802
+ const entries = await this._readStreamedArchive( file, filename, kind, element, byteBudget );
605
803
 
606
- return await this._loadNonPBRTArchive( entries, filename );
804
+ // A pbrt scene archive takes priority — it owns its own geometry/texture refs.
805
+ if ( pickEntryPath( entries ) ) return await this.loadPBRTFromZip( entries, filename, pbrtEntry, pbrt );
806
+ const result = await this._loadNonPBRTArchive( entries, filename );
807
+ this._sourceKey = this._keyed( 'archive', archiveId );
808
+ return result;
809
+
810
+ }
811
+
812
+ return await this._loadSeekable( file, filename, { ...lazy, zip: true } );
607
813
 
608
814
  } catch ( error ) {
609
815
 
@@ -623,7 +829,170 @@ export class AssetLoader extends EventDispatcher {
623
829
  }
624
830
 
625
831
  /**
626
- * Index an uncompressed tar without retaining it. Entries are read back from the File on
832
+ * Loads a seekable archive — a .tar (or a .tar.gz unpacked to one) or a .zip — reading each
833
+ * entry only when the scene asks for it.
834
+ * @private
835
+ */
836
+ async _loadSeekable( file, filename, { element, promptBytes, pbrtEntry, pbrt, index = null, zip = false, archiveId = null } ) {
837
+
838
+ const source = await this._openSeekableArchive( file, filename, element, promptBytes, { index, zip } );
839
+ source.archiveId = archiveId;
840
+ source.elements = ( Array.isArray( element ) ? element : [ element ] ).filter( Boolean );
841
+ if ( source.listing.some( e => e.path.toLowerCase().endsWith( '.pbrt' ) ) ) {
842
+
843
+ return await this.loadPBRTFromZip( {}, filename, pbrtEntry, pbrt, source );
844
+
845
+ }
846
+
847
+ // Not a pbrt scene: fall back to materialising it, which those paths still expect.
848
+ for ( const e of source.listing ) if ( e.offset !== undefined ) source.entries[ e.path ] ??= await source.read( e.path );
849
+ const result = await this._loadNonPBRTArchive( source.entries, filename );
850
+ this._sourceKey = this._keyed( 'archive', archiveId );
851
+ return result;
852
+
853
+ }
854
+
855
+ /**
856
+ * The parsed scene stored for this archive and these options, decoded — its textures read back
857
+ * out of the archive — or null.
858
+ * @private
859
+ */
860
+ async _loadStoredGraph( key, source, { imageFromBytes, envFromBytes } ) {
861
+
862
+ const area = this.storage?.area( ENGINE_AREAS.SCENES );
863
+ const entry = area ? await area.open( key ) : null;
864
+ if ( ! entry ) return null;
865
+
866
+ const start = performance.now();
867
+ let failure = null;
868
+ try {
869
+
870
+ updateLoading( { isLoading: true, status: 'Opening the stored scene...', progress: 6 } );
871
+ const manifest = await entry.json( 'graph.json' );
872
+ const data = await entry.file( 'data.bin' );
873
+ const vfs = new VirtualFS( {}, source );
874
+ const decoded = await decodeSceneGraph( manifest, data, {
875
+ loadTexture: async ( path, loader ) => {
876
+
877
+ const bytes = await vfs.readPath( path );
878
+ if ( ! bytes ) return null;
879
+ return loader === 'environment' ? envFromBytes( bytes, path ) : imageFromBytes( bytes, path );
880
+
881
+ },
882
+ } );
883
+
884
+ return {
885
+ ...decoded.stats,
886
+ group: decoded.root,
887
+ environment: decoded.environment ? { texture: decoded.environment } : null,
888
+ animations: decoded.animations,
889
+ report: null,
890
+ parseMs: 0,
891
+ buildMs: performance.now() - start,
892
+ fromStorage: true,
893
+ };
894
+
895
+ } catch ( error ) {
896
+
897
+ failure = error;
898
+ return null;
899
+
900
+ } finally {
901
+
902
+ entry.release();
903
+ if ( failure ) {
904
+
905
+ console.warn( `The stored scene could not be used, parsing the archive instead: ${failure.message}` );
906
+ this._issues?.warn( ISSUE_CODES.STORAGE_ENTRY_CORRUPT, `stored scene unusable: ${failure.message}`, { key } );
907
+ area.remove( key ).catch( () => {} );
908
+
909
+ }
910
+
911
+ }
912
+
913
+ }
914
+
915
+ /**
916
+ * Stores a freshly parsed scene for the next open. Encoded here, before onModelLoad or the
917
+ * build touch it; written in the background.
918
+ * @private
919
+ */
920
+ _storeGraph( key, built, label ) {
921
+
922
+ const area = this.storage?.area( ENGINE_AREAS.SCENES );
923
+ if ( ! area ) return;
924
+
925
+ let encoded;
926
+ try {
927
+
928
+ encoded = encodeSceneGraph( built.group, {
929
+ environment: built.environment?.texture ?? null,
930
+ animations: built.animations ?? [],
931
+ stats: {
932
+ meshCount: built.meshCount, entryPath: built.entryPath, candidates: built.candidates, frames: built.frames,
933
+ warnings: built.warnings, triangleCount: built.triangleCount, placementCount: built.placementCount,
934
+ mergedShapes: built.mergedShapes, skippedForBudget: built.skippedForBudget, droppedNoTemplate: built.droppedNoTemplate,
935
+ },
936
+ } );
937
+
938
+ } catch ( error ) {
939
+
940
+ if ( error instanceof SceneGraphUnsupported ) console.info( `Scene not stored for next time: ${error.message}` );
941
+ else console.warn( 'Scene not stored for next time:', error );
942
+ return;
943
+
944
+ }
945
+
946
+ const pending = { key, encoded, label, parseMs: ( built.parseMs ?? 0 ) + ( built.buildMs ?? 0 ), triangles: built.triangleCount ?? null };
947
+
948
+ // A parse slow enough on its own is written now, during the build. Held until the build
949
+ // ends, it kept every array the build replaces alive with it — the float normals and the
950
+ // instance matrices, ~1 GB on the whole Moana subset, at the build's peak.
951
+ if ( worthStoring( pending.parseMs, encoded.byteLength ) ) this._writeGraph( area, pending );
952
+ else this._pendingGraph = pending;
953
+
954
+ }
955
+
956
+ /**
957
+ * Writes the scene encoded at parse time once the build is done, when the whole cold load —
958
+ * parse plus build — was slow enough to be worth it (decision D6).
959
+ * @param {number} buildMs - the SceneProcessor build that followed the parse
960
+ */
961
+ flushPendingGraph( buildMs ) {
962
+
963
+ const pending = this._pendingGraph;
964
+ this._pendingGraph = null;
965
+ const area = this.storage?.area( ENGINE_AREAS.SCENES );
966
+ if ( ! pending || ! area || ! worthStoring( pending.parseMs + buildMs, pending.encoded.byteLength ) ) return;
967
+ this._writeGraph( area, pending );
968
+
969
+ }
970
+
971
+ /** Writes an encoded graph in the background, letting each array go once it is on disk. @private */
972
+ _writeGraph( area, pending ) {
973
+
974
+ ( async () => {
975
+
976
+ const writer = await area.create( pending.key, { label: `${pending.label} (scene)`, expectedBytes: pending.encoded.byteLength } );
977
+ if ( ! writer ) return;
978
+ try {
979
+
980
+ await writeSceneGraph( writer, pending.encoded, { release: true } );
981
+ await writer.commit( { triangles: pending.triangles } );
982
+
983
+ } catch ( error ) {
984
+
985
+ await writer.abort();
986
+ console.warn( 'Storing the scene failed:', error );
987
+
988
+ }
989
+
990
+ } )().catch( error => console.warn( 'Storing the scene failed:', error ) );
991
+
992
+ }
993
+
994
+ /**
995
+ * Index a seekable archive without retaining it. Entries are read back from the File on
627
996
  * demand, so residency is the open include chain rather than the whole archive.
628
997
  *
629
998
  * Indexing is cheap and the whole archive is never held, but *parsing* all of it is not:
@@ -632,14 +1001,30 @@ export class AssetLoader extends EventDispatcher {
632
1001
  * already built at that point, so the question costs nothing.
633
1002
  * @private
634
1003
  */
635
- async _openSeekableArchive( file, filename, element, promptBytes ) {
1004
+ async _openSeekableArchive( file, filename, element, promptBytes, { index = null, zip = false } = {} ) {
636
1005
 
637
1006
  const chosen = Array.isArray( element ) ? element.filter( Boolean ) : ( element ? [ element ] : [] );
1007
+ const filter = chosen.length ? elementFilter( chosen ) : null;
638
1008
 
639
- const source = await openTar( file, {
640
- filter: chosen.length ? elementFilter( chosen ) : null,
641
- retain: () => false,
642
- } );
1009
+ let source;
1010
+ if ( zip ) {
1011
+
1012
+ source = await openZip( file, { filter } );
1013
+
1014
+ } else {
1015
+
1016
+ const saved = index ?? await loadTarIndex( file, this.storage );
1017
+ source = await openTar( file, {
1018
+ index: saved,
1019
+ headersOnly: true,
1020
+ filter,
1021
+ onProgress: p => updateLoading( {
1022
+ isLoading: true, status: `Indexing archive… ${( p.bytes / 1e9 ).toFixed( 1 )} GB`, progress: 4
1023
+ } ),
1024
+ } );
1025
+ if ( ! saved ) saveTarIndex( file, this.storage, source.index ).catch( () => {} );
1026
+
1027
+ }
643
1028
 
644
1029
  const totalBytes = source.listing.reduce( ( n, e ) => n + e.size, 0 );
645
1030
 
@@ -694,8 +1079,7 @@ export class AssetLoader extends EventDispatcher {
694
1079
  async _readStreamedArchive( file, filename, kind, element, byteBudget ) {
695
1080
 
696
1081
  const chosen = Array.isArray( element ) ? element.filter( Boolean ) : ( element ? [ element ] : [] );
697
- const read = kind === 'gzip' ? readTarGz : readTar;
698
- const { entries, listing, retainedBytes, truncated } = await read( file, {
1082
+ const { entries, listing, retainedBytes, truncated } = await readTarGz( file, {
699
1083
  filter: chosen.length ? elementFilter( chosen ) : null,
700
1084
  ...( byteBudget === undefined ? {} : { byteBudget } ),
701
1085
  onProgress: p => updateLoading( {
@@ -732,6 +1116,19 @@ export class AssetLoader extends EventDispatcher {
732
1116
 
733
1117
  }
734
1118
 
1119
+ if ( truncated ) {
1120
+
1121
+ const keep = elementFilter( chosen );
1122
+ const missing = listing.filter( e => keep( e.path, e.size ) && ! entries[ e.path ] ).length;
1123
+ this._issues?.record(
1124
+ ISSUE_CODES.ASSET_ARCHIVE_TOO_LARGE,
1125
+ `${missing} files of the chosen parts were left out: they did not fit the ${( ( byteBudget ?? 1.5e9 ) / 1e9 ).toFixed( 1 )} GB in-memory read budget`,
1126
+ { elements: chosen, missing, retainedBytes },
1127
+ ISSUE_SEVERITY.WARNING
1128
+ );
1129
+
1130
+ }
1131
+
735
1132
  if ( chosen.length ) {
736
1133
 
737
1134
  console.info(
@@ -766,17 +1163,29 @@ export class AssetLoader extends EventDispatcher {
766
1163
 
767
1164
  const plyParser = ( buf ) => this.loaderCache.ply.parse( buf );
768
1165
 
769
- // Texture maps — decode by extension (pbrt uses .png/.jpg but also .exr/.hdr/.tga).
770
- const imageFromBytes = ( bytes, fname ) => this._pbrtTextureFromBytes( bytes, fname );
1166
+ // Texture maps — decode by extension (pbrt uses .png/.jpg but also .exr/.hdr/.tga). Tagged
1167
+ // with their archive path so a stored scene can read them back instead of keeping pixels.
1168
+ const imageFromBytes = async ( bytes, fname ) => {
1169
+
1170
+ const texture = await this._pbrtTextureFromBytes( bytes, fname );
1171
+ if ( texture ) texture.userData[ ARCHIVE_PATH ] = fname;
1172
+ return texture;
1173
+
1174
+ };
771
1175
 
772
1176
  // Infinite-light maps → HDR/EXR/LDR via the shared environment decoder.
773
1177
  const envFromBytes = async ( bytes, fname ) => {
774
1178
 
775
1179
  const ext = fname.split( '.' ).pop().toLowerCase();
776
- const url = URL.createObjectURL( new Blob( [ bytes ] ) );
1180
+ const blob = new Blob( [ bytes ] );
1181
+ const url = URL.createObjectURL( blob );
777
1182
  try {
778
1183
 
779
- return await this.loadEnvironmentByExtension( url, ext );
1184
+ const texture = await this.loadEnvironmentByExtension( url, ext );
1185
+ setEnvironmentSource( texture, `bytes:${await sampleHash( blob )}` );
1186
+ texture.userData[ ARCHIVE_PATH ] = fname;
1187
+ texture.userData[ ARCHIVE_LOADER ] = 'environment';
1188
+ return texture;
780
1189
 
781
1190
  } finally {
782
1191
 
@@ -787,17 +1196,22 @@ export class AssetLoader extends EventDispatcher {
787
1196
  };
788
1197
 
789
1198
  const pbrtStart = performance.now();
1199
+ const shape = {
1200
+ animation: options.animation, maxTriangles: options.maxTriangles, maxPlacements: options.maxPlacements,
1201
+ mergeShapesAbove: options.mergeShapesAbove, curveSteps: options.curveSteps, curveSides: options.curveSides,
1202
+ curveTolerance: options.curveTolerance, instanceIncludes: options.instanceIncludes,
1203
+ };
1204
+ const graphKey = source?.archiveId
1205
+ ? this._keyed( `pbrt:${SCENE_GRAPH_FORMAT}.${PBRT_BUILD_REVISION}:${VERSION}`, `${source.archiveId}|${entryPath ?? ''}|${[ ...( source.elements ?? [] ) ].sort().join( ',' )}|${JSON.stringify( shape )}` )
1206
+ : null;
1207
+
1208
+ const stored = graphKey ? await this._loadStoredGraph( graphKey, source, { imageFromBytes, envFromBytes } ) : null;
1209
+ const built = stored ?? await loadPBRTScene( { vfs: zip, source, entryPath, plyParser, imageFromBytes, envFromBytes, ...shape } );
1210
+
790
1211
  const { group, environment, animations, report, warnings, meshCount, entryPath: loadedEntry, candidates,
791
1212
  frames, parseMs, buildMs, triangleCount, placementCount, mergedShapes, skippedForBudget,
792
- droppedNoTemplate } = await loadPBRTScene( {
793
- vfs: zip, source, entryPath, plyParser, imageFromBytes, envFromBytes,
794
- animation: options.animation,
795
- maxTriangles: options.maxTriangles,
796
- maxPlacements: options.maxPlacements,
797
- mergeShapesAbove: options.mergeShapesAbove,
798
- curveSteps: options.curveSteps,
799
- curveSides: options.curveSides
800
- } );
1213
+ droppedNoTemplate } = built;
1214
+ if ( stored ) console.info( `PBRT scene "${loadedEntry}" opened from storage in ${( buildMs / 1000 ).toFixed( 1 )} s, skipping the parse` );
801
1215
 
802
1216
  // Phase breakdown for scaling work; the engine's own build timings live in
803
1217
  // SceneProcessor.performanceMetrics.
@@ -851,6 +1265,9 @@ export class AssetLoader extends EventDispatcher {
851
1265
 
852
1266
  group.name = loadedEntry || filename;
853
1267
  this.releaseTargetModel();
1268
+ this._sourceKey = graphKey;
1269
+ // Before onModelLoad and the build touch the new group.
1270
+ if ( graphKey && ! stored ) this._storeGraph( graphKey, built, filename );
854
1271
  this.targetModel = group;
855
1272
  this.animations = animations ?? [];
856
1273
 
@@ -1498,19 +1915,28 @@ export class AssetLoader extends EventDispatcher {
1498
1915
 
1499
1916
  }
1500
1917
 
1501
- async loadModel( modelUrl ) {
1918
+ /**
1919
+ * @param {string} modelUrl
1920
+ * @param {{cacheKey?: string, cachePolicy?: string}} [options] - key a download cache entry by
1921
+ * something steadier than a signed, expiring URL
1922
+ */
1923
+ async loadModel( modelUrl, options = {} ) {
1502
1924
 
1503
1925
  this._loadCancelled = false;
1504
1926
  const loader = await this.createGLTFLoader();
1505
1927
  const cancelable = AssetLoader._isNetworkUrl( modelUrl );
1928
+ let source = null;
1506
1929
 
1507
1930
  try {
1508
1931
 
1509
1932
  updateLoading( { isLoading: true, status: "Downloading Model...", progress: 2, canCancel: cancelable, loadedBytes: 0, totalBytes: 0 } );
1510
- const data = await loader.loadAsync( modelUrl, this._downloadProgress( "Downloading Model...", cancelable ) );
1933
+ source = await this._viaCache( modelUrl, "Downloading Model...", cancelable, options );
1934
+ if ( source.cached ) loader.setResourcePath( LoaderUtils.extractUrlBase( modelUrl ) );
1935
+ const data = await loader.loadAsync( source.url, source.cached ? undefined : this._downloadProgress( "Downloading Model...", cancelable ) );
1511
1936
  this._downloadComplete();
1512
1937
 
1513
1938
  this.releaseTargetModel();
1939
+ this._sourceKey = cancelable ? this._keyed( 'url', options.cacheKey ?? modelUrl ) : null;
1514
1940
 
1515
1941
  this.targetModel = data.scene;
1516
1942
  this.animations = data.animations || [];
@@ -1528,6 +1954,7 @@ export class AssetLoader extends EventDispatcher {
1528
1954
 
1529
1955
  } finally {
1530
1956
 
1957
+ source?.release();
1531
1958
  this._disposeGLTFLoader( loader );
1532
1959
 
1533
1960
  }
@@ -1555,17 +1982,21 @@ export class AssetLoader extends EventDispatcher {
1555
1982
 
1556
1983
  // Append a model from URL without releasing prior models or reframing.
1557
1984
  // Reuses createGLTFLoader() so appended KTX2 textures stay RGBA DataArrayTexture.
1558
- async appendModel( url ) {
1985
+ async appendModel( url, options = {} ) {
1559
1986
 
1560
1987
  this._loadCancelled = false;
1561
1988
  const loader = await this.createGLTFLoader();
1562
1989
  const cancelable = AssetLoader._isNetworkUrl( url );
1990
+ let source = null;
1563
1991
 
1564
1992
  try {
1565
1993
 
1566
1994
  updateLoading( { isLoading: true, status: "Downloading Model...", progress: 2, canCancel: cancelable, loadedBytes: 0, totalBytes: 0 } );
1567
- const data = await loader.loadAsync( url, this._downloadProgress( "Downloading Model...", cancelable ) );
1995
+ source = await this._viaCache( url, "Downloading Model...", cancelable, options );
1996
+ if ( source.cached ) loader.setResourcePath( LoaderUtils.extractUrlBase( url ) );
1997
+ const data = await loader.loadAsync( source.url, source.cached ? undefined : this._downloadProgress( "Downloading Model...", cancelable ) );
1568
1998
  this._downloadComplete();
1999
+ this._appended = true;
1569
2000
  this._processAndParent( data.scene );
1570
2001
  return { root: data.scene, animations: data.animations || [] };
1571
2002
 
@@ -1576,6 +2007,7 @@ export class AssetLoader extends EventDispatcher {
1576
2007
 
1577
2008
  } finally {
1578
2009
 
2010
+ source?.release();
1579
2011
  this._disposeGLTFLoader( loader );
1580
2012
 
1581
2013
  }
@@ -1585,6 +2017,8 @@ export class AssetLoader extends EventDispatcher {
1585
2017
  // Append a copy of a caller-owned Object3D without releasing prior models or reframing.
1586
2018
  appendObject3D( object3d, name = 'object3d' ) {
1587
2019
 
2020
+ this._appended = true;
2021
+
1588
2022
  const root = this._adoptExternalObject( object3d );
1589
2023
  root.name = object3d.name || name;
1590
2024
  this._processAndParent( root );
@@ -2000,6 +2434,7 @@ export class AssetLoader extends EventDispatcher {
2000
2434
 
2001
2435
  // Calculate scene scale factor based on model size
2002
2436
  const sceneScale = maxDim;
2437
+ this.sceneSize.copy( size );
2003
2438
 
2004
2439
  // Rebuild path tracing
2005
2440
  buildTimer.start( 'setupPathTracing' );
@@ -2216,6 +2651,13 @@ export class AssetLoader extends EventDispatcher {
2216
2651
 
2217
2652
  }
2218
2653
 
2654
+ /** The loaded model's bounding-box size along x, y and z, in scene units. */
2655
+ getSceneSize() {
2656
+
2657
+ return this.sceneSize.clone();
2658
+
2659
+ }
2660
+
2219
2661
  getTargetModel() {
2220
2662
 
2221
2663
  return this.targetModel;
@@ -2264,6 +2706,11 @@ export class AssetLoader extends EventDispatcher {
2264
2706
  // onError captures `this`, and a manager outlives the loader via an in-flight fetch.
2265
2707
  this._loadingManager.onError = undefined;
2266
2708
  this._issues = null;
2709
+ this._urlAbort?.abort();
2710
+ this._urlAbort = null;
2711
+ this.storage = null;
2712
+ this._downloads = null;
2713
+ this._pendingGraph = null;
2267
2714
 
2268
2715
  this.releaseTargetModel();
2269
2716