rayzee 9.0.0 → 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 (94) 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 +10344 -5334
  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/PBRT/PBRTCurves.js +104 -27
  28. package/src/Processor/PBRT/PBRTParser.js +132 -22
  29. package/src/Processor/PBRT/PBRTSceneBuilder.js +192 -49
  30. package/src/Processor/PBRT/buffers.js +27 -0
  31. package/src/Processor/PBRT/index.js +55 -15
  32. package/src/Processor/PackedRayBuffer.js +10 -13
  33. package/src/Processor/ParallelBVHBuilder.js +36 -15
  34. package/src/Processor/PhysicalSky.js +308 -0
  35. package/src/Processor/QueueManager.js +1 -0
  36. package/src/Processor/SceneProcessor.js +1132 -181
  37. package/src/Processor/SplitBLAS.js +249 -0
  38. package/src/Processor/SunPosition.js +41 -0
  39. package/src/Processor/TextureCreator.js +21 -0
  40. package/src/Processor/Workers/AIUpscalerWorker.js +79 -52
  41. package/src/Processor/Workers/BVHWorker.js +17 -13
  42. package/src/Processor/ZipReader.js +207 -0
  43. package/src/RenderSettings.js +38 -0
  44. package/src/SceneState/SceneState.js +367 -0
  45. package/src/SceneState/portable.js +52 -0
  46. package/src/Stages/NormalDepth.js +7 -6
  47. package/src/Stages/PathTracer.js +108 -4
  48. package/src/Stages/PathTracerStage.js +251 -104
  49. package/src/Storage/BLASCache.js +190 -0
  50. package/src/Storage/CDFCache.js +77 -0
  51. package/src/Storage/DownloadCache.js +266 -0
  52. package/src/Storage/GeometrySpill.js +217 -0
  53. package/src/Storage/SceneGraphCodec.js +503 -0
  54. package/src/Storage/SpillStore.js +112 -0
  55. package/src/Storage/StorageManager.js +934 -0
  56. package/src/Storage/StorageOps.js +137 -0
  57. package/src/Storage/StorageWorker.js +37 -0
  58. package/src/Storage/events.js +29 -0
  59. package/src/Storage/identity.js +77 -0
  60. package/src/Storage/index.js +9 -0
  61. package/src/Storage/inlineTransport.js +24 -0
  62. package/src/Storage/locks.js +99 -0
  63. package/src/Storage/openStorage.js +87 -0
  64. package/src/Storage/sceneCachePolicy.js +12 -0
  65. package/src/Storage/shared.js +9 -0
  66. package/src/Storage/transport.js +62 -0
  67. package/src/TSL/Atmosphere.js +684 -0
  68. package/src/TSL/BVHTraversal.js +288 -239
  69. package/src/TSL/Common.js +11 -2
  70. package/src/TSL/Displacement.js +6 -6
  71. package/src/TSL/EmissiveSampling.js +5 -5
  72. package/src/TSL/EnvironmentCDF.js +176 -0
  73. package/src/TSL/GenerateKernel.js +3 -3
  74. package/src/TSL/HitFacet.js +4 -4
  75. package/src/TSL/LightsDirect.js +8 -8
  76. package/src/TSL/LightsSampling.js +55 -37
  77. package/src/TSL/Random.js +1 -0
  78. package/src/TSL/ShadeKernel.js +75 -19
  79. package/src/TSL/Sun.js +40 -0
  80. package/src/TSL/TextureSampling.js +6 -7
  81. package/src/index.js +18 -0
  82. package/src/managers/CameraManager.js +169 -1
  83. package/src/managers/EnvironmentManager.js +263 -33
  84. package/src/managers/GoboManager.js +19 -8
  85. package/src/managers/IESManager.js +3 -3
  86. package/src/managers/LightManager.js +98 -5
  87. package/src/managers/MaterialDataManager.js +22 -2
  88. package/src/managers/TransformManager.js +26 -0
  89. package/src/managers/UniformManager.js +5 -3
  90. package/src/managers/VideoRenderManager.js +3 -1
  91. package/src/managers/timeline/CameraTrack.js +22 -0
  92. package/src/managers/timeline/TimelineManager.js +14 -0
  93. package/src/version.js +3 -0
  94. 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';
@@ -112,6 +121,13 @@ export class AssetLoader extends EventDispatcher {
112
121
  // signal into its fetch). One load runs at a time (guarded upstream).
113
122
  this._loadingManager = new LoadingManager();
114
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;
115
131
 
116
132
  this._issues = issues;
117
133
  this._profile = profile ?? getRenderProfile();
@@ -141,6 +157,66 @@ export class AssetLoader extends EventDispatcher {
141
157
 
142
158
  this._loadCancelled = true;
143
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;
144
220
 
145
221
  }
146
222
 
@@ -241,6 +317,9 @@ export class AssetLoader extends EventDispatcher {
241
317
  releaseTargetModel() {
242
318
 
243
319
  this.sceneMetadata = null;
320
+ this._sourceKey = null;
321
+ this._appended = false;
322
+ this._pendingGraph = null;
244
323
 
245
324
  if ( ! this.targetModel ) return;
246
325
 
@@ -325,8 +404,75 @@ export class AssetLoader extends EventDispatcher {
325
404
 
326
405
  }
327
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
+
328
465
  async loadModelFromFile( file, filename ) {
329
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
+
330
476
  const extension = filename.split( '.' ).pop().toLowerCase();
331
477
  const arrayBuffer = await this.readFileAsArrayBuffer( file );
332
478
 
@@ -357,6 +503,7 @@ export class AssetLoader extends EventDispatcher {
357
503
  try {
358
504
 
359
505
  const texture = await this.loadEnvironment( url );
506
+ setEnvironmentSource( texture, identityKey( await fileIdentity( file ) ) );
360
507
  this.dispatchEvent( { type: 'load', texture, filename } );
361
508
  return texture;
362
509
 
@@ -395,6 +542,7 @@ export class AssetLoader extends EventDispatcher {
395
542
  }
396
543
 
397
544
  texture.generateMipmaps = true;
545
+ if ( AssetLoader._isNetworkUrl( envUrl ) ) setEnvironmentSource( texture, envUrl );
398
546
 
399
547
  this.applyEnvironmentToScene( texture );
400
548
  this.dispatchEvent( { type: 'load', texture, url: envUrl, filename: envUrl.split( /[?#]/ )[ 0 ].split( '/' ).pop() } );
@@ -465,23 +613,33 @@ export class AssetLoader extends EventDispatcher {
465
613
  async loadEnvironmentByExtension( url, extension ) {
466
614
 
467
615
  const cancelable = AssetLoader._isNetworkUrl( url );
468
- 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 );
469
619
 
470
620
  let texture;
471
- if ( extension === 'hdr' || extension === 'exr' ) {
621
+ try {
472
622
 
473
- const loader = extension === 'hdr'
474
- ? ( this.loaderCache.hdr || ( this.loaderCache.hdr = new HDRLoader( this._loadingManager ).setDataType( FloatType ) ) )
475
- : ( this.loaderCache.exr || ( this.loaderCache.exr = new EXRLoader( this._loadingManager ).setDataType( FloatType ) ) );
476
- texture = await loader.loadAsync( url, onProgress );
623
+ if ( extension === 'hdr' || extension === 'exr' ) {
477
624
 
478
- } 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 );
629
+
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 {
479
641
 
480
- if ( ! this.loaderCache.texture ) this.loaderCache.texture = new TextureLoader( this._loadingManager );
481
- texture = await this.loaderCache.texture.loadAsync( url, onProgress );
482
- // LDR env maps (jpg/png/webp) are authored in sRGB; tag them so the backend
483
- // decodes to linear. HDR/EXR are already linear and keep the loader's setting.
484
- texture.colorSpace = SRGBColorSpace;
642
+ source.release();
485
643
 
486
644
  }
487
645
 
@@ -524,36 +682,70 @@ export class AssetLoader extends EventDispatcher {
524
682
  async inspectArchive( file ) {
525
683
 
526
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
+ } );
527
688
 
528
- if ( kind === 'gzip' || kind === 'tar' ) {
689
+ let listing;
690
+ if ( kind === 'gzip' ) {
529
691
 
530
- const read = kind === 'gzip' ? readTarGz : readTar;
531
- const { listing } = await read( file, {
532
- filter: () => false,
533
- onProgress: p => updateLoading( {
534
- isLoading: true, status: `Scanning archive… ${( p.bytes / 1e9 ).toFixed( 1 )} GB`, progress: 4
535
- } )
536
- } );
537
- const { root, elements } = listArchiveElements( listing );
538
- return {
539
- kind, root, elements,
540
- entryCount: listing.length,
541
- totalBytes: listing.reduce( ( n, e ) => n + e.size, 0 )
542
- };
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 } ) );
543
711
 
544
712
  }
545
713
 
546
- const zip = unzipSync( new Uint8Array( await this.readFileAsArrayBuffer( file ) ) );
547
- const listing = Object.keys( zip ).map( path => ( { path, size: zip[ path ].length } ) );
548
714
  const { root, elements } = listArchiveElements( listing );
549
715
  return {
550
- kind: 'zip', root, elements,
716
+ kind: kind ?? 'zip', root, elements,
551
717
  entryCount: listing.length,
552
718
  totalBytes: listing.reduce( ( n, e ) => n + e.size, 0 )
553
719
  };
554
720
 
555
721
  }
556
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
+
557
749
  /**
558
750
  * @param {File|Blob} file
559
751
  * @param {string} filename
@@ -572,39 +764,52 @@ export class AssetLoader extends EventDispatcher {
572
764
  * @param {number} [options.mergeShapesAbove] - merge small non-instanced shapes past this count.
573
765
  * @param {number} [options.curveSteps] - samples per spline span when tessellating curves.
574
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).
575
771
  */
576
772
  async loadArchiveFromFile( file, filename, { pbrtEntry = null, element = null, byteBudget, promptBytes, ...pbrt } = {} ) {
577
773
 
578
774
  try {
579
775
 
580
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 };
581
779
 
582
- // An uncompressed tar over a File is seekable, so it is indexed rather than read:
583
- // entries are pulled out as the scene asks for them and never all held at once.
584
- // That is the difference between a 7 GB archive being refused and loading.
585
- 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 );
586
784
 
587
- const source = await this._openSeekableArchive( file, filename, element, promptBytes );
588
- if ( source.listing.some( e => e.path.toLowerCase().endsWith( '.pbrt' ) ) ) {
785
+ if ( kind === 'gzip' ) {
589
786
 
590
- return await this.loadPBRTFromZip( {}, filename, pbrtEntry, pbrt, source );
787
+ const unpacked = await this._unpackGzip( file, filename );
788
+ if ( unpacked ) {
591
789
 
592
- }
790
+ try {
593
791
 
594
- // Not a pbrt scene: fall back to materialising it, which those paths still expect.
595
- for ( const e of source.listing ) source.entries[ e.path ] ??= await source.read( e.path );
596
- return await this._loadNonPBRTArchive( source.entries, filename );
792
+ return await this._loadSeekable( unpacked.file, filename, { ...lazy, index: unpacked.index } );
597
793
 
598
- }
794
+ } finally {
795
+
796
+ unpacked.release();
599
797
 
600
- const entries = kind === 'gzip'
601
- ? await this._readStreamedArchive( file, filename, kind, element, byteBudget )
602
- : unzipSync( new Uint8Array( await this.readFileAsArrayBuffer( file ) ) );
798
+ }
603
799
 
604
- // A pbrt scene archive takes priority — it owns its own geometry/texture refs.
605
- if ( pickEntryPath( entries ) ) return await this.loadPBRTFromZip( entries, filename, pbrtEntry, pbrt );
800
+ }
606
801
 
607
- return await this._loadNonPBRTArchive( entries, filename );
802
+ const entries = await this._readStreamedArchive( file, filename, kind, element, byteBudget );
803
+
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 } );
608
813
 
609
814
  } catch ( error ) {
610
815
 
@@ -624,7 +829,170 @@ export class AssetLoader extends EventDispatcher {
624
829
  }
625
830
 
626
831
  /**
627
- * 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
628
996
  * demand, so residency is the open include chain rather than the whole archive.
629
997
  *
630
998
  * Indexing is cheap and the whole archive is never held, but *parsing* all of it is not:
@@ -633,14 +1001,30 @@ export class AssetLoader extends EventDispatcher {
633
1001
  * already built at that point, so the question costs nothing.
634
1002
  * @private
635
1003
  */
636
- async _openSeekableArchive( file, filename, element, promptBytes ) {
1004
+ async _openSeekableArchive( file, filename, element, promptBytes, { index = null, zip = false } = {} ) {
637
1005
 
638
1006
  const chosen = Array.isArray( element ) ? element.filter( Boolean ) : ( element ? [ element ] : [] );
1007
+ const filter = chosen.length ? elementFilter( chosen ) : null;
639
1008
 
640
- const source = await openTar( file, {
641
- filter: chosen.length ? elementFilter( chosen ) : null,
642
- retain: () => false,
643
- } );
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
+ }
644
1028
 
645
1029
  const totalBytes = source.listing.reduce( ( n, e ) => n + e.size, 0 );
646
1030
 
@@ -695,8 +1079,7 @@ export class AssetLoader extends EventDispatcher {
695
1079
  async _readStreamedArchive( file, filename, kind, element, byteBudget ) {
696
1080
 
697
1081
  const chosen = Array.isArray( element ) ? element.filter( Boolean ) : ( element ? [ element ] : [] );
698
- const read = kind === 'gzip' ? readTarGz : readTar;
699
- const { entries, listing, retainedBytes, truncated } = await read( file, {
1082
+ const { entries, listing, retainedBytes, truncated } = await readTarGz( file, {
700
1083
  filter: chosen.length ? elementFilter( chosen ) : null,
701
1084
  ...( byteBudget === undefined ? {} : { byteBudget } ),
702
1085
  onProgress: p => updateLoading( {
@@ -733,6 +1116,19 @@ export class AssetLoader extends EventDispatcher {
733
1116
 
734
1117
  }
735
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
+
736
1132
  if ( chosen.length ) {
737
1133
 
738
1134
  console.info(
@@ -767,17 +1163,29 @@ export class AssetLoader extends EventDispatcher {
767
1163
 
768
1164
  const plyParser = ( buf ) => this.loaderCache.ply.parse( buf );
769
1165
 
770
- // Texture maps — decode by extension (pbrt uses .png/.jpg but also .exr/.hdr/.tga).
771
- 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
+ };
772
1175
 
773
1176
  // Infinite-light maps → HDR/EXR/LDR via the shared environment decoder.
774
1177
  const envFromBytes = async ( bytes, fname ) => {
775
1178
 
776
1179
  const ext = fname.split( '.' ).pop().toLowerCase();
777
- const url = URL.createObjectURL( new Blob( [ bytes ] ) );
1180
+ const blob = new Blob( [ bytes ] );
1181
+ const url = URL.createObjectURL( blob );
778
1182
  try {
779
1183
 
780
- 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;
781
1189
 
782
1190
  } finally {
783
1191
 
@@ -788,17 +1196,22 @@ export class AssetLoader extends EventDispatcher {
788
1196
  };
789
1197
 
790
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
+
791
1211
  const { group, environment, animations, report, warnings, meshCount, entryPath: loadedEntry, candidates,
792
1212
  frames, parseMs, buildMs, triangleCount, placementCount, mergedShapes, skippedForBudget,
793
- droppedNoTemplate } = await loadPBRTScene( {
794
- vfs: zip, source, entryPath, plyParser, imageFromBytes, envFromBytes,
795
- animation: options.animation,
796
- maxTriangles: options.maxTriangles,
797
- maxPlacements: options.maxPlacements,
798
- mergeShapesAbove: options.mergeShapesAbove,
799
- curveSteps: options.curveSteps,
800
- curveSides: options.curveSides
801
- } );
1213
+ droppedNoTemplate } = built;
1214
+ if ( stored ) console.info( `PBRT scene "${loadedEntry}" opened from storage in ${( buildMs / 1000 ).toFixed( 1 )} s, skipping the parse` );
802
1215
 
803
1216
  // Phase breakdown for scaling work; the engine's own build timings live in
804
1217
  // SceneProcessor.performanceMetrics.
@@ -852,6 +1265,9 @@ export class AssetLoader extends EventDispatcher {
852
1265
 
853
1266
  group.name = loadedEntry || filename;
854
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 );
855
1271
  this.targetModel = group;
856
1272
  this.animations = animations ?? [];
857
1273
 
@@ -1499,19 +1915,28 @@ export class AssetLoader extends EventDispatcher {
1499
1915
 
1500
1916
  }
1501
1917
 
1502
- 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 = {} ) {
1503
1924
 
1504
1925
  this._loadCancelled = false;
1505
1926
  const loader = await this.createGLTFLoader();
1506
1927
  const cancelable = AssetLoader._isNetworkUrl( modelUrl );
1928
+ let source = null;
1507
1929
 
1508
1930
  try {
1509
1931
 
1510
1932
  updateLoading( { isLoading: true, status: "Downloading Model...", progress: 2, canCancel: cancelable, loadedBytes: 0, totalBytes: 0 } );
1511
- 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 ) );
1512
1936
  this._downloadComplete();
1513
1937
 
1514
1938
  this.releaseTargetModel();
1939
+ this._sourceKey = cancelable ? this._keyed( 'url', options.cacheKey ?? modelUrl ) : null;
1515
1940
 
1516
1941
  this.targetModel = data.scene;
1517
1942
  this.animations = data.animations || [];
@@ -1529,6 +1954,7 @@ export class AssetLoader extends EventDispatcher {
1529
1954
 
1530
1955
  } finally {
1531
1956
 
1957
+ source?.release();
1532
1958
  this._disposeGLTFLoader( loader );
1533
1959
 
1534
1960
  }
@@ -1556,17 +1982,21 @@ export class AssetLoader extends EventDispatcher {
1556
1982
 
1557
1983
  // Append a model from URL without releasing prior models or reframing.
1558
1984
  // Reuses createGLTFLoader() so appended KTX2 textures stay RGBA DataArrayTexture.
1559
- async appendModel( url ) {
1985
+ async appendModel( url, options = {} ) {
1560
1986
 
1561
1987
  this._loadCancelled = false;
1562
1988
  const loader = await this.createGLTFLoader();
1563
1989
  const cancelable = AssetLoader._isNetworkUrl( url );
1990
+ let source = null;
1564
1991
 
1565
1992
  try {
1566
1993
 
1567
1994
  updateLoading( { isLoading: true, status: "Downloading Model...", progress: 2, canCancel: cancelable, loadedBytes: 0, totalBytes: 0 } );
1568
- 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 ) );
1569
1998
  this._downloadComplete();
1999
+ this._appended = true;
1570
2000
  this._processAndParent( data.scene );
1571
2001
  return { root: data.scene, animations: data.animations || [] };
1572
2002
 
@@ -1577,6 +2007,7 @@ export class AssetLoader extends EventDispatcher {
1577
2007
 
1578
2008
  } finally {
1579
2009
 
2010
+ source?.release();
1580
2011
  this._disposeGLTFLoader( loader );
1581
2012
 
1582
2013
  }
@@ -1586,6 +2017,8 @@ export class AssetLoader extends EventDispatcher {
1586
2017
  // Append a copy of a caller-owned Object3D without releasing prior models or reframing.
1587
2018
  appendObject3D( object3d, name = 'object3d' ) {
1588
2019
 
2020
+ this._appended = true;
2021
+
1589
2022
  const root = this._adoptExternalObject( object3d );
1590
2023
  root.name = object3d.name || name;
1591
2024
  this._processAndParent( root );
@@ -2273,6 +2706,11 @@ export class AssetLoader extends EventDispatcher {
2273
2706
  // onError captures `this`, and a manager outlives the loader via an in-flight fetch.
2274
2707
  this._loadingManager.onError = undefined;
2275
2708
  this._issues = null;
2709
+ this._urlAbort?.abort();
2710
+ this._urlAbort = null;
2711
+ this.storage = null;
2712
+ this._downloads = null;
2713
+ this._pendingGraph = null;
2276
2714
 
2277
2715
  this.releaseTargetModel();
2278
2716