rayzee 11.2.1 → 11.4.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 (76) hide show
  1. package/README.md +91 -34
  2. package/dist/ArchiveImporter-Cgyx5AF7.js +7860 -0
  3. package/dist/BidirectionalIntegrator-BKF-cwH2.js +954 -0
  4. package/dist/{BufferLayout-D_IBo2ci.js → BufferLayout-xPRH9jYD.js} +41 -5
  5. package/dist/{NeuralRetouch-Cr-otSW8.js → NeuralRetouch-C3Q2XqbF.js} +4 -3
  6. package/dist/{NeuralSuperRes-B7E3_M0t.js → NeuralSuperRes-DV5TtURP.js} +11 -5
  7. package/dist/{PathTracer-D6nbknj5.js → PathTracer-D3MvEaZK.js} +4384 -4618
  8. package/dist/{PhysicalSky-Dq5VSiCQ.js → PhysicalSky-CY4jiz6a.js} +2 -2
  9. package/dist/{RayzeeRenderer-DbjqO6v-.js → RayzeeRenderer-D23m6NqG.js} +486 -391
  10. package/dist/SolarLimb-bZE6y63S.js +762 -0
  11. package/dist/{ToneMapGPU-BOjpul7A.js → ToneMapGPU-CTDBvmmS.js} +31 -22
  12. package/dist/addons/archives.es.js +1 -1
  13. package/dist/addons/bidirectional.es.js +1 -1
  14. package/dist/addons/physical-sky.es.js +1 -1
  15. package/dist/rayzee-core.es.js +7 -7
  16. package/dist/rayzee.es.js +1327 -838
  17. package/dist/rayzee.umd.js +146 -134
  18. package/dist/sceneCachePolicy-AKOEu2lf.js +1602 -0
  19. package/package.json +1 -1
  20. package/src/EngineDefaults.js +2 -2
  21. package/src/Passes/AIUpscaler.js +2 -0
  22. package/src/PathTracerApp.js +126 -19
  23. package/src/Pipeline/CompletionTracker.js +7 -3
  24. package/src/Processor/ArchiveImporter.js +173 -94
  25. package/src/Processor/ArchiveReader.js +20 -23
  26. package/src/Processor/AssetLoader.js +48 -1
  27. package/src/Processor/BVHRefitter.js +109 -13
  28. package/src/Processor/BufferLayout.js +115 -0
  29. package/src/Processor/ChunkedRecords.js +3 -2
  30. package/src/Processor/EmissiveTriangleBuilder.js +6 -6
  31. package/src/Processor/GeometryExtractor.js +189 -79
  32. package/src/Processor/HostMemory.js +39 -9
  33. package/src/Processor/InstanceTable.js +714 -42
  34. package/src/Processor/PBRT/PBRTCurves.js +224 -6
  35. package/src/Processor/PBRT/PBRTMaterials.js +3 -0
  36. package/src/Processor/PBRT/PBRTSceneBuilder.js +82 -25
  37. package/src/Processor/PackedRayBuffer.js +5 -2
  38. package/src/Processor/SceneProcessor.js +747 -141
  39. package/src/Processor/TLASBuilder.js +32 -17
  40. package/src/Processor/ToneMapCPU.js +16 -4
  41. package/src/Processor/ToneMapGPU.js +30 -14
  42. package/src/Processor/USD/USDLayer.js +1149 -0
  43. package/src/Processor/USD/USDScene.js +1655 -0
  44. package/src/Processor/USD/USDStage.js +682 -0
  45. package/src/Processor/USD/USDText.js +674 -0
  46. package/src/Processor/USD/index.js +138 -0
  47. package/src/Processor/Workers/BVHRefitWorker.js +3 -3
  48. package/src/RayzeeRenderer.js +221 -33
  49. package/src/RenderSettings.js +4 -2
  50. package/src/Stages/AutoExposure.js +484 -494
  51. package/src/Stages/Compositor.js +27 -6
  52. package/src/Stages/LocalExposure.js +546 -0
  53. package/src/Stages/NormalDepth.js +7 -12
  54. package/src/Stages/PathTracer.js +18 -21
  55. package/src/Stages/PathTracerStage.js +282 -103
  56. package/src/Storage/GeometrySpill.js +46 -14
  57. package/src/Storage/SceneGraphCodec.js +17 -3
  58. package/src/Storage/SpillStore.js +9 -0
  59. package/src/TSL/BVHTraversal.js +183 -7
  60. package/src/TSL/Common.js +57 -13
  61. package/src/TSL/ShadeKernel.js +3 -2
  62. package/src/TSL/TextureSampling.js +11 -8
  63. package/src/TSL/patches.js +84 -0
  64. package/src/index.js +1 -0
  65. package/src/integrators/BidirectionalIntegrator.js +20 -2
  66. package/src/managers/DenoisingManager.js +15 -19
  67. package/src/managers/InteractionManager.js +19 -6
  68. package/src/managers/MaterialDataManager.js +22 -5
  69. package/src/managers/UniformManager.js +3 -0
  70. package/src/managers/VideoRenderManager.js +30 -4
  71. package/src/neural/NeuralRetouch.js +1 -0
  72. package/src/neural/NeuralSuperRes.js +6 -1
  73. package/dist/ArchiveImporter-D_x863yu.js +0 -5198
  74. package/dist/BidirectionalIntegrator-DmkxxEOR.js +0 -946
  75. package/dist/SolarLimb-CPaeZVwz.js +0 -721
  76. package/dist/sceneCachePolicy-C4-4qwt6.js +0 -485
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rayzee",
3
- "version": "11.2.1",
3
+ "version": "11.4.0",
4
4
  "type": "module",
5
5
  "description": "Real-time WebGPU path tracing engine built on Three.js",
6
6
  "main": "dist/rayzee.umd.js",
@@ -24,8 +24,8 @@ export const ENGINE_DEFAULTS = deepFreeze( {
24
24
 
25
25
  // Sampling and convergence
26
26
  maxSamples: 60,
27
- renderLimitMode: 'frames',
28
- renderTimeLimit: 30,
27
+ renderLimitMode: 'frames', // 'frames' | 'time' (ceiling or deadline, whichever first) | 'timeOnly' (deadline lifts the ceiling)
28
+ renderTimeLimit: 30, // seconds; read in 'time' and 'timeOnly'
29
29
  renderMode: 0, // 0 preview, 1 production; configureForMode() sets it
30
30
  useAdaptiveSampling: true,
31
31
  noiseThreshold: 0.1, // a pixel under this √-luminance noise has converged; the interactive tier's value
@@ -100,6 +100,7 @@ export class AIUpscaler extends EventDispatcher {
100
100
  this.getExposure = options.getExposure || ( () => 1.0 );
101
101
  this.getToneMapping = options.getToneMapping || ( () => ACESFilmicToneMapping );
102
102
  this.getSaturation = options.getSaturation || ( () => 1.0 );
103
+ this.getDisplayGain = options.getDisplayGain || ( () => null );
103
104
 
104
105
  // Configuration
105
106
  this.enabled = false;
@@ -497,6 +498,7 @@ export class AIUpscaler extends EventDispatcher {
497
498
  exposure: this.getExposure(),
498
499
  toneMapping: this.getToneMapping() ?? ACESFilmicToneMapping,
499
500
  saturation: this.getSaturation(),
501
+ gain: this.getDisplayGain(),
500
502
  } );
501
503
  this._toneMapper.release();
502
504
 
@@ -8,6 +8,7 @@ import { Variance } from './Stages/Variance.js';
8
8
  import { BilateralFilter } from './Stages/BilateralFilter.js';
9
9
  import { EdgeFilter } from './Stages/EdgeFilter.js';
10
10
  import { AutoExposure, AUTO_EXPOSURE_DEFAULTS } from './Stages/AutoExposure.js';
11
+ import { LocalExposure, LOCAL_EXPOSURE_DEFAULTS } from './Stages/LocalExposure.js';
11
12
  import { PRODUCTION_RENDER_CONFIG, INTERACTIVE_RENDER_CONFIG, modePresetSettings } from './EngineDefaults.js';
12
13
  import { createLogger } from './utils/Logger.js';
13
14
  import { EngineEvents } from './EngineEvents.js';
@@ -156,10 +157,11 @@ export class PathTracerApp extends RayzeeRenderer {
156
157
  stages.bilateralFilter = new BilateralFilter( renderer, { enabled: false } );
157
158
  stages.edgeFilter = new EdgeFilter( renderer, { enabled: false } );
158
159
  stages.autoExposure = new AutoExposure( renderer, { enabled: AUTO_EXPOSURE_DEFAULTS.autoExposure } );
160
+ stages.localExposure = new LocalExposure( renderer, { enabled: LOCAL_EXPOSURE_DEFAULTS.localExposure } );
159
161
 
160
162
  return [
161
163
  stages.normalDepth, stages.motionVector, stages.nrd, stages.asvgf,
162
- stages.variance, stages.bilateralFilter, stages.edgeFilter, stages.autoExposure,
164
+ stages.variance, stages.bilateralFilter, stages.edgeFilter, stages.autoExposure, stages.localExposure,
163
165
  ];
164
166
 
165
167
  }
@@ -189,7 +191,8 @@ export class PathTracerApp extends RayzeeRenderer {
189
191
  canvas: this.canvas,
190
192
  assetLoader: this.assetLoader,
191
193
  pathTracer: null,
192
- floorPlane: this.assetLoader.floorPlane
194
+ floorPlane: this.assetLoader.floorPlane,
195
+ isGeometryOnDisk: () => this.geometryOnDisk,
193
196
  } );
194
197
 
195
198
  this.interactionManager.wireAppEvents( this );
@@ -324,10 +327,11 @@ export class PathTracerApp extends RayzeeRenderer {
324
327
  const core = super._settingsBindings();
325
328
  return {
326
329
  ...core,
327
- // auto exposure drives the exposure while it is on, and restores this value when turned off
330
+ // the manual exposure, and the compensation on top of auto exposure while that is on
328
331
  applyExposure: ( value ) => {
329
332
 
330
- if ( ! this.stages.autoExposure?.enabled ) core.applyExposure( value );
333
+ if ( this.stages.autoExposure ) this.stages.autoExposure.setCompensation( value );
334
+ else core.applyExposure( value );
331
335
 
332
336
  },
333
337
  onCameraProjection: ( value ) => {
@@ -350,6 +354,7 @@ export class PathTracerApp extends RayzeeRenderer {
350
354
 
351
355
  this.timeline.update();
352
356
  this.cameraManager.updateControls();
357
+ this._updateExposure();
353
358
 
354
359
  this._applyPendingRenderScale();
355
360
 
@@ -405,6 +410,8 @@ export class PathTracerApp extends RayzeeRenderer {
405
410
  // Render completion → denoise/upscale chain
406
411
  _renderCompleted() {
407
412
 
413
+ // _finishImage() landed the exposure just before, with the loop already asleep: draw it.
414
+ if ( this.stages.autoExposure?.enabled ) this._refreshFinished();
408
415
  this.denoisingManager.onRenderComplete( {
409
416
  isStillComplete: () => this.completion.renderCompleteDispatched,
410
417
  context: this.pipeline?.context,
@@ -412,6 +419,65 @@ export class PathTracerApp extends RayzeeRenderer {
412
419
 
413
420
  }
414
421
 
422
+ _settling() {
423
+
424
+ return !! this.stages.autoExposure?.settling;
425
+
426
+ }
427
+
428
+ _displayGain() {
429
+
430
+ const le = this.stages.localExposure;
431
+ // A readback outside the loop (headless) can come before the loop would have built it from this image.
432
+ if ( le?.wantsBuild ) le.build( this.pipeline.context );
433
+ return le?.toneGain() ?? null;
434
+
435
+ }
436
+
437
+ /**
438
+ * Local exposure (Unreal Engine 5's): scales the contrast of large bright and dark areas around middle grey
439
+ * while keeping detail, on the canvas and every tone-mapped readback alike.
440
+ * @param {boolean} enabled
441
+ */
442
+ setLocalExposure( enabled ) {
443
+
444
+ const le = this.stages.localExposure;
445
+ if ( ! le ) return;
446
+ le.setEnabled( enabled );
447
+ this.stages.compositor.setDisplayGain( enabled ? ( rgb, uv ) => le.gainNode( rgb, uv ) : null );
448
+ this.refreshFrame();
449
+
450
+ }
451
+
452
+ /** @param {{highlightContrast?: number, shadowContrast?: number, detailStrength?: number}} params - 1 changes nothing */
453
+ setLocalExposureParams( params ) {
454
+
455
+ this.stages.localExposure?.updateParameters( params );
456
+
457
+ }
458
+
459
+ // A finished render is exposed for its own image: one reading of it, landed on.
460
+ _finishImage() {
461
+
462
+ const ae = this.stages.autoExposure;
463
+ if ( ! ae?.enabled ) return null;
464
+ return ae.meter( this.pipeline.context ).then( () => ae.advance( Infinity ) );
465
+
466
+ }
467
+
468
+ /** The core's, plus the display-only exposure an sRGB readback was shaped by, which no setting records. */
469
+ getProvenance() {
470
+
471
+ const provenance = super.getProvenance();
472
+ const ae = this.stages.autoExposure, le = this.stages.localExposure;
473
+ provenance.exposure = {
474
+ auto: ae?.enabled ? { exposure: ae.getExposure(), strength: ae.strength, metering: ae.metering, keyValue: ae.keyValue } : null,
475
+ local: le?.enabled ? { highlightContrast: le.highlightContrast, shadowContrast: le.shadowContrast, detailStrength: le.detailStrength } : null,
476
+ };
477
+ return provenance;
478
+
479
+ }
480
+
415
481
  _renderHelperOverlay() {
416
482
 
417
483
  super._renderHelperOverlay();
@@ -549,7 +615,7 @@ export class PathTracerApp extends RayzeeRenderer {
549
615
  if ( enabled ) {
550
616
 
551
617
  this.cameraManager?.setAutoFocusMode( 'manual' );
552
- if ( this.stages.autoExposure ) this.stages.autoExposure.enabled = false;
618
+ this.stages.autoExposure?.setEnabled( false );
553
619
  // Cadence denoising is wall-clock driven, so which frame it lands on is not reproducible.
554
620
  this.denoisingManager?.setContinuousDenoise( false );
555
621
  return;
@@ -557,11 +623,7 @@ export class PathTracerApp extends RayzeeRenderer {
557
623
  }
558
624
 
559
625
  if ( snapshot.autoFocusMode !== undefined ) this.cameraManager?.setAutoFocusMode( snapshot.autoFocusMode );
560
- if ( this.stages.autoExposure && snapshot.autoExposure !== undefined ) {
561
-
562
- this.stages.autoExposure.enabled = snapshot.autoExposure;
563
-
564
- }
626
+ if ( snapshot.autoExposure !== undefined ) this.stages.autoExposure?.setEnabled( snapshot.autoExposure );
565
627
 
566
628
  if ( snapshot.continuousDenoise !== undefined ) {
567
629
 
@@ -678,7 +740,7 @@ export class PathTracerApp extends RayzeeRenderer {
678
740
 
679
741
  this._notePlacementsMoving( meshIndices );
680
742
  this._sdf.updateMeshTransforms( meshIndices );
681
- this.stages.pathTracer?.updateBufferRanges( [], [ this._sdf.computeTLASDirtyRange() ] );
743
+ this.stages.pathTracer?.updateBufferRanges( [], this._sdf.takeMoveRanges() );
682
744
  if ( this._sdf.movesEmitters( meshIndices ) ) {
683
745
 
684
746
  this._emittersMoved = true;
@@ -717,7 +779,7 @@ export class PathTracerApp extends RayzeeRenderer {
717
779
 
718
780
  const run = table.placementRunOf( meshIndex );
719
781
  if ( ! run ) continue;
720
- for ( let p = run.start; p < run.start + run.count; p ++ ) dm.notePlacementMoving( table.tlasLeafIndex[ p ], table.world, p * 16 );
782
+ for ( let p = run.start; p < run.start + run.count; p ++ ) dm.notePlacementMoving( table.tlasLeafIndex[ p ], () => table.matrixWorldOf( p ) );
721
783
 
722
784
  }
723
785
 
@@ -838,6 +900,9 @@ export class PathTracerApp extends RayzeeRenderer {
838
900
 
839
901
  this.timeline.stop();
840
902
  this.cameraManager.controls.enabled = ! isProduction;
903
+ // A final render keeps the exposure it starts with through its first, noisiest samples (20 bounces: a 0.4-stop
904
+ // dip and back within 0.2 s at 2–8 samples), then lands as any still image does.
905
+ if ( this.stages.autoExposure ) this.stages.autoExposure.holdSamples = isProduction ? 8 : 0;
841
906
 
842
907
  // Anything with a SETTING_ROUTES entry must go through settings, not setUniform: set() early-returns on
843
908
  // `prev === value`, so a uniform written behind the map leaves it stale and the next set() silently no-ops.
@@ -1052,6 +1117,7 @@ export class PathTracerApp extends RayzeeRenderer {
1052
1117
  pipeline: this.pipeline,
1053
1118
  getExposure: () => this.settings.get( 'exposure' ) ?? 1.0,
1054
1119
  getSaturation: () => this.settings.get( 'saturation' ) ?? 1.0,
1120
+ getDisplayGain: () => this._displayGain(),
1055
1121
  issues: this._issues,
1056
1122
  } );
1057
1123
 
@@ -1071,20 +1137,61 @@ export class PathTracerApp extends RayzeeRenderer {
1071
1137
 
1072
1138
  _setupAutoExposureListener() {
1073
1139
 
1074
- if ( ! this.stages.autoExposure ) return;
1140
+ const ae = this.stages.autoExposure;
1141
+ if ( ! ae ) return;
1075
1142
 
1076
- this.stages.autoExposure.on( 'autoexposure:updated', ( data ) => {
1143
+ ae.setCompensation( this.settings.get( 'exposure' ) ?? 1 );
1144
+ if ( this.stages.localExposure ) this.stages.localExposure.onChange = () => this._refreshFinished();
1145
+ ae.onChange = () => this._refreshFinished();
1077
1146
 
1078
- this.dispatchEvent( {
1079
- type: EngineEvents.AUTO_EXPOSURE_UPDATED,
1080
- exposure: data.exposure,
1081
- luminance: data.luminance
1082
- } );
1147
+ // Another camera is a cut: meter it afresh.
1148
+ this._addTrackedListener( this.cameraManager, EngineEvents.CAMERA_SWITCHED, () => ae.resetHistory() );
1149
+
1150
+ ae.on( 'autoexposure:updated', ( { exposure, autoExposure, targetExposure, luminance } ) => {
1151
+
1152
+ this.dispatchEvent( { type: EngineEvents.AUTO_EXPOSURE_UPDATED, exposure, autoExposure, targetExposure, luminance } );
1083
1153
 
1084
1154
  } );
1085
1155
 
1086
1156
  }
1087
1157
 
1158
+ // Only a finished render's idle loop is woken: renderFrames() and a video export drive frames themselves.
1159
+ _refreshFinished() {
1160
+
1161
+ if ( ! this.completion.renderCompleteDispatched ) return;
1162
+ this._needsDisplayRefresh = true;
1163
+ this.wake();
1164
+
1165
+ }
1166
+
1167
+ // Every loop frame, a finished render included: builds local exposure and meters a finished image when asked, adapts.
1168
+ _updateExposure() {
1169
+
1170
+ const le = this.stages.localExposure;
1171
+ if ( le?.wantsBuild && this.completion.renderCompleteDispatched ) le.build( this.pipeline.context );
1172
+
1173
+ const ae = this.stages.autoExposure;
1174
+ if ( ! ae?.enabled ) return;
1175
+
1176
+ this._noteExposureView();
1177
+ const af = this.cameraManager.afScreenPoint;
1178
+ if ( ae.metering === 'spot' && ( ae.meteringPoint.x !== af.x || ae.meteringPoint.y !== af.y ) ) ae.updateParameters( { meteringPoint: af } );
1179
+ if ( ae.wantsMetering && this.completion.renderCompleteDispatched ) ae.meter( this.pipeline.context );
1180
+ if ( ae.update() ) this._needsDisplayRefresh = true;
1181
+
1182
+ }
1183
+
1184
+ // Whatever moved the camera (controls, walk, timeline, a video export), auto exposure learns of it here.
1185
+ _noteExposureView() {
1186
+
1187
+ const { matrixWorld, projectionMatrix } = this.camera;
1188
+ const last = this._exposureView;
1189
+ if ( last?.view.equals( matrixWorld ) && last.projection.equals( projectionMatrix ) ) return;
1190
+ this._exposureView = { view: matrixWorld.clone(), projection: projectionMatrix.clone() };
1191
+ if ( last ) this.stages.autoExposure?.noteViewChanged();
1192
+
1193
+ }
1194
+
1088
1195
  async _setupOverlayManager() {
1089
1196
 
1090
1197
  this.overlayManager = new OverlayManager( this.cameraManager.camera );
@@ -33,20 +33,24 @@ export class CompletionTracker {
33
33
 
34
34
  /**
35
35
  * Checks whether the wall-clock render budget has expired. An additional stop condition,
36
- * never a replacement — the sample ceiling and convergence still bind when they come first.
36
+ * never a replacement — convergence still binds when it comes first, and in 'time' mode the
37
+ * sample ceiling too ('timeOnly' lifts it: PathTracerStage.updateCompletionThreshold).
37
38
  *
38
39
  * Floored at one sample: frameCount does not advance while the stage compiles kernels or
39
40
  * while the camera is in interaction mode, and retiring in that window denoises an empty
40
41
  * buffer and captures a blank canvas.
41
42
  *
42
43
  * @param {Object} pathTracer - The PathTracer stage
43
- * @param {string} renderLimitMode - 'time' or 'frames'
44
+ * @param {string} renderLimitMode - 'frames', 'time' or 'timeOnly'
44
45
  * @param {number} renderTimeLimit - Time limit in seconds
45
46
  * @returns {boolean}
46
47
  */
47
48
  isTimeLimitReached( pathTracer, renderLimitMode, renderTimeLimit ) {
48
49
 
49
- if ( renderLimitMode !== 'time' || ! ( renderTimeLimit > 0 ) ) return false;
50
+ // Decided afresh on every check: a raised limit or a switch to 'frames' reopens a render the deadline retired.
51
+ this.budgetOverrun = false;
52
+
53
+ if ( ( renderLimitMode !== 'time' && renderLimitMode !== 'timeOnly' ) || ! ( renderTimeLimit > 0 ) ) return false;
50
54
  if ( this.timeElapsed < renderTimeLimit ) return false;
51
55
 
52
56
  if ( ! ( pathTracer?.frameCount > 0 ) ) {
@@ -10,7 +10,7 @@
10
10
  import { FloatType, EquirectangularReflectionMapping, Texture, SRGBColorSpace, RepeatWrapping, LoadingManager } from 'three';
11
11
  import { HDRLoader } from 'three/addons/loaders/HDRLoader.js';
12
12
  import { EXRLoader } from 'three/addons/loaders/EXRLoader.js';
13
- import { zipSync, strFromU8 } from 'three/addons/libs/fflate.module.js';
13
+ import { strFromU8 } from 'three/addons/libs/fflate.module.js';
14
14
  import {
15
15
  detectArchiveKind, readTarGz, elementFilter, listArchiveElements, openTar, indexTarHeaders, openFolder } from './ArchiveReader.js';
16
16
  import { openZip, readZipDirectory } from './ZipReader.js';
@@ -23,16 +23,13 @@ import { worthStoring } from '../Storage/sceneCachePolicy.js';
23
23
  import { VERSION } from '../version.js';
24
24
  import { updateLoading } from './utils';
25
25
  import { loadPBRTScene, pickEntryPath, VirtualFS, PBRT_BUILD_REVISION } from './PBRT/index.js';
26
+ import { loadUSDScene, listUSDRootLayers, listUSDParts, USDFiles, USDStage, USD_LAYER } from './USD/index.js';
26
27
  import { pfmTexture } from './PBRT/PFM.js';
27
28
  import { extractSceneMetadata } from './SceneMetadata.js';
28
29
  import { ISSUE_CODES, ISSUE_SEVERITY } from '../EngineIssues.js';
29
30
  import { ARCHIVE_FORMATS } from './archiveFormats.js';
30
31
  import { withHostWorker } from '../Platform.js';
31
32
 
32
- // Loose USD layers inside a ZIP compose into one scene; these pick out the
33
- // layers and the image assets they reference.
34
- const USD_LAYER_RE = /\.(usd|usda|usdc)$/i;
35
- const USD_IMAGE_RE = /\.(png|jpg|jpeg|avif)$/i;
36
33
  const MTL_TEXTURE_TIMEOUT_MS = 30000;
37
34
  const MAIN_MODEL_FILES = [ 'scene.gltf', 'scene.glb', 'model.gltf', 'model.glb', 'main.gltf', 'main.glb', 'asset.gltf', 'asset.glb' ];
38
35
 
@@ -72,6 +69,9 @@ const bufferOf = async entry => {
72
69
  */
73
70
  export const ARCHIVE_ELEMENT_PROMPT_BYTES = 4_000_000_000;
74
71
 
72
+ /** The same question for a USD scene, in USD layer bytes: binary layers hold far more geometry a byte than pbrt text. */
73
+ export const USD_ELEMENT_PROMPT_BYTES = 1_000_000_000;
74
+
75
75
  export class ArchiveImporter {
76
76
 
77
77
  /** The archive formats it adds to the loader's. */
@@ -83,6 +83,7 @@ export class ArchiveImporter {
83
83
  this.loader = loader;
84
84
  this._pendingGraph = null;
85
85
  this.lastPBRTStats = null;
86
+ this.lastUSDStats = null;
86
87
 
87
88
  }
88
89
 
@@ -274,7 +275,7 @@ export class ArchiveImporter {
274
275
  );
275
276
 
276
277
  const archiveId = this.loader.storage ? identityKey( await folderIdentity( folder ) ) : null;
277
- return await this._loadSource( source, folder.name, { element, pbrtEntry, pbrt, archiveId, kind: 'folder' } );
278
+ return await this._loadSource( source, folder.name, { element, pbrtEntry, pbrt, archiveId, promptBytes, kind: 'folder' } );
278
279
 
279
280
  }
280
281
 
@@ -294,12 +295,12 @@ export class ArchiveImporter {
294
295
  async _loadSeekable( file, filename, { element, promptBytes, pbrtEntry, pbrt, index = null, zip = false, archiveId = null } ) {
295
296
 
296
297
  const source = await this._openSeekableArchive( file, filename, element, promptBytes, { index, zip } );
297
- return await this._loadSource( source, filename, { element, pbrtEntry, pbrt, archiveId } );
298
+ return await this._loadSource( source, filename, { element, pbrtEntry, pbrt, archiveId, promptBytes } );
298
299
 
299
300
  }
300
301
 
301
302
  /** Loads what an opened archive or folder holds. @private */
302
- async _loadSource( source, filename, { element, pbrtEntry, pbrt, archiveId, kind = 'archive' } ) {
303
+ async _loadSource( source, filename, { element, pbrtEntry, pbrt, archiveId, promptBytes, kind = 'archive' } ) {
303
304
 
304
305
  source.archiveId = archiveId;
305
306
  source.elements = ( Array.isArray( element ) ? element : [ element ] ).filter( Boolean );
@@ -311,7 +312,9 @@ export class ArchiveImporter {
311
312
 
312
313
  const entries = Object.create( null );
313
314
  for ( const e of source.listing ) if ( e.offset !== undefined ) entries[ e.path ] = source.entries[ e.path ] ?? await source.slice( e.path );
314
- const result = await this._loadNonPBRTArchive( entries, filename );
315
+ const result = this._mainModelPath( Object.keys( entries ) )?.usd
316
+ ? await this.loadUSDScene( entries, filename, { ...pbrt, element, promptBytes, listing: source.listing } )
317
+ : await this._loadNonPBRTArchive( entries, filename );
315
318
  this.loader._sourceKey = this.loader._keyed( kind, archiveId );
316
319
  return result;
317
320
 
@@ -628,39 +631,8 @@ export class ArchiveImporter {
628
631
  }
629
632
 
630
633
  const plyParser = ( buf ) => this.loader.loaderCache.ply.parse( buf );
631
-
632
- // Texture maps — decode by extension (pbrt uses .png/.jpg but also .exr/.hdr/.tga). Tagged
633
- // with their archive path so a stored scene can read them back instead of keeping pixels.
634
- const imageFromBytes = async ( bytes, fname ) => {
635
-
636
- const texture = await this._pbrtTextureFromBytes( bytes, fname );
637
- if ( texture ) texture.userData[ ARCHIVE_PATH ] = fname;
638
- return texture;
639
-
640
- };
641
-
642
- // Infinite-light maps → HDR/EXR/LDR via the shared environment decoder.
643
- const envFromBytes = async ( bytes, fname ) => {
644
-
645
- const ext = fname.split( '.' ).pop().toLowerCase();
646
- const blob = new Blob( [ bytes ] );
647
- const url = ext === 'pfm' ? null : URL.createObjectURL( blob );
648
- try {
649
-
650
- const texture = url ? await this.loader.loadEnvironmentByExtension( url, ext, { loader: ext === 'exr' ? this._exrLoader() : null } ) : pfmTexture( bytes );
651
- texture.mapping = EquirectangularReflectionMapping;
652
- setEnvironmentSource( texture, `bytes:${await sampleHash( blob )}` );
653
- texture.userData[ ARCHIVE_PATH ] = fname;
654
- texture.userData[ ARCHIVE_LOADER ] = 'environment';
655
- return texture;
656
-
657
- } finally {
658
-
659
- if ( url ) URL.revokeObjectURL( url );
660
-
661
- }
662
-
663
- };
634
+ const imageFromBytes = ( bytes, fname ) => this._archiveImage( bytes, fname );
635
+ const envFromBytes = ( bytes, fname ) => this._archiveEnvironment( bytes, fname );
664
636
 
665
637
  const pbrtStart = performance.now();
666
638
  const shape = {
@@ -754,6 +726,150 @@ export class ArchiveImporter {
754
726
 
755
727
  }
756
728
 
729
+ /** A texture map from an archive, tagged with its path so a stored scene can read it back. @private */
730
+ async _archiveImage( bytes, fname ) {
731
+
732
+ const texture = await this._pbrtTextureFromBytes( bytes, fname );
733
+ if ( texture ) texture.userData[ ARCHIVE_PATH ] = fname;
734
+ return texture;
735
+
736
+ }
737
+
738
+ async _archiveEnvironment( bytes, fname ) {
739
+
740
+ const ext = fname.split( '.' ).pop().toLowerCase();
741
+ const blob = new Blob( [ bytes ] );
742
+ const url = ext === 'pfm' ? null : URL.createObjectURL( blob );
743
+ try {
744
+
745
+ const texture = url ? await this.loader.loadEnvironmentByExtension( url, ext, { loader: ext === 'exr' ? this._exrLoader() : null } ) : pfmTexture( bytes );
746
+ texture.mapping = EquirectangularReflectionMapping;
747
+ setEnvironmentSource( texture, `bytes:${await sampleHash( blob )}` );
748
+ texture.userData[ ARCHIVE_PATH ] = fname;
749
+ texture.userData[ ARCHIVE_LOADER ] = 'environment';
750
+ return texture;
751
+
752
+ } finally {
753
+
754
+ if ( url ) URL.revokeObjectURL( url );
755
+
756
+ }
757
+
758
+ }
759
+
760
+ /**
761
+ * A USD scene from a folder's or archive's files (path → bytes or Blob), each layer read as composition reaches it.
762
+ * @param {object} [options] - `element`, `promptBytes`, `listing` (every file, loaded or not), and loadArchiveFromFile's budgets
763
+ */
764
+ async loadUSDScene( entries, filename, options = {} ) {
765
+
766
+ updateLoading( { isLoading: true, status: 'Reading USD scene...', progress: 5 } );
767
+ const paths = Object.keys( entries );
768
+ const omitted = ( options.listing ?? [] ).filter( e => ! ( e.path in entries ) ).map( e => e.path );
769
+ const files = new USDFiles( paths, async path => ( entries[ path ] ? bytesOf( entries[ path ] ) : null ), { omitted } );
770
+ const candidates = listUSDRootLayers( paths );
771
+ if ( candidates.length === 0 ) throw new Error( `No USD layer found in ${filename}` );
772
+ const entryPath = candidates[ 0 ];
773
+ if ( candidates.length > 1 ) {
774
+
775
+ console.warn( `${filename} holds ${candidates.length} top-level USD layers; loaded "${entryPath}". Others: ${candidates.slice( 1 ).join( ', ' )}` );
776
+ this.loader._issues?.record(
777
+ ISSUE_CODES.ASSET_AMBIGUOUS_ENTRY,
778
+ `${filename} holds ${candidates.length} top-level USD layers; loaded "${entryPath}"`,
779
+ { loaded: entryPath, alternatives: candidates.slice( 1 ) },
780
+ ISSUE_SEVERITY.WARNING
781
+ );
782
+
783
+ }
784
+
785
+ const chosen = ( Array.isArray( options.element ) ? options.element : [ options.element ] ).filter( Boolean );
786
+ if ( chosen.length === 0 ) await this._requireUSDPartChoice( filename, files, entryPath, options.listing ?? paths.map( path => ( { path, size: entries[ path ]?.size ?? entries[ path ]?.byteLength ?? 0 } ) ), options.promptBytes );
787
+
788
+ const start = performance.now();
789
+ const built = await loadUSDScene( {
790
+ files, entryPath,
791
+ resolveImage: async path => {
792
+
793
+ const bytes = await files.read( path );
794
+ return bytes ? this._archiveImage( bytes, path ) : null;
795
+
796
+ },
797
+ resolveEnvironment: async path => {
798
+
799
+ const bytes = await files.read( path );
800
+ return bytes ? this._archiveEnvironment( bytes, path ) : null;
801
+
802
+ },
803
+ maxTriangles: options.maxTriangles, maxPlacements: options.maxPlacements, mergeShapesAbove: options.mergeShapesAbove,
804
+ curveSteps: options.curveSteps, curveSides: options.curveSides, curveTolerance: options.curveTolerance,
805
+ } );
806
+
807
+ const { group, environment, warnings, triangleCount, placementCount, skippedForBudget, meshCount, parseMs, buildMs, fitNote } = built;
808
+ this.lastUSDStats = { parseMs, buildMs, loaderMs: performance.now() - start, triangleCount, placementCount, skippedForBudget, meshCount, fitNote };
809
+ if ( fitNote || skippedForBudget > 0 ) this.loader._issues?.record(
810
+ ISSUE_CODES.SCENE_MEMORY_BUDGET,
811
+ fitNote ?? `${skippedForBudget.toLocaleString()} placements past the scene budgets left out`,
812
+ { triangleCount, placementCount, skippedForBudget },
813
+ ISSUE_SEVERITY.WARNING
814
+ );
815
+ if ( warnings.length ) {
816
+
817
+ console.warn( `USD loader: ${warnings.length} warning(s) loading "${entryPath}"` );
818
+ warnings.forEach( w => console.warn( ' •', w ) );
819
+
820
+ }
821
+
822
+ if ( environment?.texture ) {
823
+
824
+ environment.texture.generateMipmaps = true;
825
+ this.loader.applyEnvironmentToScene( environment.texture );
826
+
827
+ }
828
+
829
+ group.name = entryPath.split( '/' ).pop();
830
+ this.loader.releaseTargetModel();
831
+ this.loader.targetModel = group;
832
+ this.loader.animations = [];
833
+ // Without a dome light the current environment stays: USD assets are usually lit by whatever views them.
834
+ this.loader.sceneMetadata = environment?.texture ? { environment: { rotation: environment.rotation, intensity: environment.intensity } } : null;
835
+
836
+ updateLoading( { isLoading: true, status: 'Processing USD geometry...', progress: 10 } );
837
+ await this.loader.onModelLoad( group );
838
+ this.loader.dispatchEvent( { type: 'load', model: group, filename: `${entryPath} (from ${filename})` } );
839
+ return group;
840
+
841
+ }
842
+
843
+ /** Asks which parts of a large USD scene to load. @private */
844
+ async _requireUSDPartChoice( filename, files, entryPath, listing, promptBytes = USD_ELEMENT_PROMPT_BYTES ) {
845
+
846
+ const layerBytes = listing.reduce( ( n, e ) => n + ( USD_LAYER.test( e.path ) ? e.size : 0 ), 0 );
847
+ if ( layerBytes < promptBytes ) return;
848
+
849
+ const stage = new USDStage( files );
850
+ await stage.open( entryPath );
851
+ const elements = await listUSDParts( stage, listing );
852
+ if ( elements.length < 2 ) return;
853
+
854
+ const totalBytes = listing.reduce( ( n, e ) => n + e.size, 0 );
855
+ const error = new Error(
856
+ `"${filename}" holds ${( layerBytes / 1e9 ).toFixed( 1 )} GB of USD across ${elements.length} parts. ` +
857
+ 'Choose which to load — loading all of them at once may exhaust memory.'
858
+ );
859
+ error.code = 'ARCHIVE_NEEDS_ELEMENT';
860
+ error.root = entryPath.includes( '/' ) ? entryPath.slice( 0, entryPath.lastIndexOf( '/' ) ) : '';
861
+ error.elements = elements;
862
+ error.totalBytes = totalBytes;
863
+ this.loader._issues?.record(
864
+ ISSUE_CODES.ASSET_ARCHIVE_TOO_LARGE,
865
+ `USD scene holds ${( layerBytes / 1e9 ).toFixed( 1 )} GB of layers across ${elements.length} parts; choose which to load`,
866
+ { root: error.root, elements: elements.map( e => e.prefix ), totalBytes },
867
+ ISSUE_SEVERITY.WARNING
868
+ );
869
+ throw error;
870
+
871
+ }
872
+
757
873
  /**
758
874
  * Decodes a pbrt texture map from raw bytes, picking a decoder by extension.
759
875
  * ImageBitmap can't handle EXR/HDR/TGA, which pbrt scenes use freely.
@@ -891,24 +1007,29 @@ export class ArchiveImporter {
891
1007
 
892
1008
  }
893
1009
 
894
- async findAndLoadModelFromZip( zip, filename = 'the ZIP archive' ) {
1010
+ /** The model an archive or folder loads as: a conventional main file, else the shallowest, glTF first. @private */
1011
+ _mainModelPath( paths ) {
895
1012
 
896
- const paths = Object.keys( zip );
897
1013
  const top = paths[ 0 ]?.split( '/' )[ 0 ];
898
1014
  const root = top !== undefined && paths.every( p => p.startsWith( top + '/' ) ) ? top + '/' : '';
899
1015
  const models = paths.filter( p => p.split( '.' ).pop().toLowerCase() === 'obj' || this.loader.getFileFormat( p )?.type === 'model' );
900
- if ( models.length === 0 ) throw new Error( `No supported model files found in ${filename}` );
901
-
902
- // Shallowest first, glTF before other formats at the same depth.
1016
+ if ( models.length === 0 ) return null;
903
1017
  const rank = p => p.split( '/' ).length * 2 + ( /\.(gltf|glb)$/i.test( p ) ? 0 : 1 );
904
- const main = MAIN_MODEL_FILES.map( name => root + name ).find( p => zip[ p ] );
1018
+ const have = new Set( paths );
1019
+ const main = MAIN_MODEL_FILES.map( name => root + name ).find( p => have.has( p ) );
905
1020
  const path = main ?? models.reduce( ( best, p ) => ( rank( p ) < rank( best ) ? p : best ) );
1021
+ return { path, models, main: !! main, usd: ! main && USD_LAYER.test( path ) };
1022
+
1023
+ }
906
1024
 
907
- // A loose layer is only one slice of a USD scene — hand the whole
908
- // archive over so its references and payloads can resolve.
909
- if ( ! main && USD_LAYER_RE.test( path ) ) return await this.loadUSDHierarchyFromZip( zip );
1025
+ async findAndLoadModelFromZip( zip, filename = 'the ZIP archive' ) {
1026
+
1027
+ const found = this._mainModelPath( Object.keys( zip ) );
1028
+ if ( ! found ) throw new Error( `No supported model files found in ${filename}` );
1029
+ if ( found.usd ) return await this.loadUSDScene( zip, filename );
910
1030
 
911
- const others = models.filter( p => p !== path && ! USD_LAYER_RE.test( p ) );
1031
+ const { path, models } = found;
1032
+ const others = models.filter( p => p !== path && ! USD_LAYER.test( p ) );
912
1033
  if ( others.length > 0 ) {
913
1034
 
914
1035
  console.warn( `${filename} holds ${others.length + 1} models; loaded "${path}". Others: ${others.join( ', ' )}` );
@@ -926,48 +1047,6 @@ export class ArchiveImporter {
926
1047
 
927
1048
  }
928
1049
 
929
- // USDLoader only builds a cross-layer asset map on its USDZ branch, so repack
930
- // the archive as USDZ — root layer first, per AOUSD core spec 16.4.1.2 — and
931
- // let the loader resolve the references/payloads itself.
932
- async loadUSDHierarchyFromZip( zip ) {
933
-
934
- const layers = Object.keys( zip ).filter( name => USD_LAYER_RE.test( name ) );
935
- if ( layers.length === 0 ) throw new Error( 'No USD layers found in the ZIP archive' );
936
-
937
- const root = ArchiveImporter._pickUSDRootLayer( layers );
938
- console.log( `Loading USD scene from ZIP: ${root} (${layers.length} layers)` );
939
-
940
- const packed = { [ root ]: [ await bytesOf( zip[ root ] ), { level: 0 } ] };
941
- for ( const name of Object.keys( zip ) ) {
942
-
943
- if ( name === root ) continue;
944
- if ( USD_LAYER_RE.test( name ) || USD_IMAGE_RE.test( name ) ) packed[ name ] = [ await bytesOf( zip[ name ] ), { level: 0 } ];
945
-
946
- }
947
-
948
- return await this.loader._loadModelFileByExtension( new File( [ zipSync( packed ) ], root ), root );
949
-
950
- }
951
-
952
- // Shallowest layer wins; among ties prefer the <dir>/<dir>.usd convention so a
953
- // set's entry point beats its sibling variants.
954
- static _pickUSDRootLayer( layers ) {
955
-
956
- const depth = name => name.split( '/' ).length;
957
- const minDepth = Math.min( ...layers.map( depth ) );
958
- const candidates = layers.filter( name => depth( name ) === minDepth );
959
-
960
- const conventional = candidates.find( name => {
961
-
962
- const parts = name.split( '/' );
963
- return parts.length > 1 && parts[ parts.length - 1 ].replace( USD_LAYER_RE, '' ) === parts[ parts.length - 2 ];
964
-
965
- } );
966
-
967
- return conventional || candidates[ 0 ];
968
-
969
- }
970
-
971
1050
  async loadModelFromZipEntry( fileContent, filePath, extension, zipContents, from = 'ZIP' ) {
972
1051
 
973
1052
  try {