rayzee 7.25.0 → 7.26.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rayzee",
3
- "version": "7.25.0",
3
+ "version": "7.26.1",
4
4
  "type": "module",
5
5
  "description": "Real-time WebGPU path tracing engine built on Three.js",
6
6
  "main": "dist/rayzee.umd.js",
@@ -57,6 +57,8 @@ const _appsByCanvas = new WeakMap();
57
57
  * - `app.settings` — {@link RenderSettings} (all render parameters)
58
58
  * - `app.stages` — Named pipeline stages for advanced control
59
59
  * - `app.sceneMeshes` — meshes backing the BVH, in buffer order (see {@link refitBVH})
60
+ * - `app.sceneModel` — root of the rendered model (a copy, for {@link loadObject3D})
61
+ * - `app.getSceneObject(id)` — the rendered root for an appended object's id
60
62
  *
61
63
  * Extends EventDispatcher for event-driven communication with stores/UI.
62
64
  */
@@ -109,6 +111,9 @@ export class PathTracerApp extends EventDispatcher {
109
111
  this.canvas = canvas;
110
112
  this._autoResize = options.autoResize !== false;
111
113
  this._container = options.container || null;
114
+ // Apply the environment authored into a model file's metadata on load. See _beginSceneMetadataEnvironment().
115
+ this._applySceneMetadataEnabled = options.applySceneMetadata !== false;
116
+ this._applyingSceneMetadata = false;
112
117
 
113
118
  // ── Settings (single source of truth for all render parameters) ──
114
119
  this.settings = new RenderSettings( DEFAULT_STATE );
@@ -623,8 +628,6 @@ export class PathTracerApp extends EventDispatcher {
623
628
  this.interactionManager?.deselect();
624
629
  this.transformManager?.detach?.();
625
630
 
626
- // Release the loaded model. If loaded via loadObject3D(), the caller owns it —
627
- // we only detach it from the scene. Otherwise dispose geometries/materials/textures.
628
631
  this.assetLoader?.releaseTargetModel();
629
632
 
630
633
  // Clear lights in the WebGPU light scene
@@ -662,7 +665,13 @@ export class PathTracerApp extends EventDispatcher {
662
665
  /**
663
666
  * Loads a Three.js Object3D directly into the path tracer scene.
664
667
  * Builds BVH from the object's meshes and uploads scene data.
665
- * @param {import('three').Object3D} object3d - The Object3D to render
668
+ *
669
+ * Renders a copy: `object3d` is never reparented, rewritten or disposed, so passing a
670
+ * subtree of a scene the host still renders is safe. Geometry/material/texture are shared
671
+ * by reference, and any ancestor transform is baked in. Later edits to `object3d` do not
672
+ * reach the render — mutate {@link sceneModel}, then {@link refitBVH}/{@link refitBLASes}.
673
+ *
674
+ * @param {import('three').Object3D} object3d - The Object3D to render; left untouched.
666
675
  * @param {string} [name='object3d'] - Display name for the object
667
676
  */
668
677
  async loadObject3D( object3d, name = 'object3d' ) {
@@ -674,17 +683,26 @@ export class PathTracerApp extends EventDispatcher {
674
683
 
675
684
  }
676
685
 
686
+ /**
687
+ * A load is already running. Typed so hosts can tell "you clicked too fast" apart from
688
+ * a genuine load failure and say so, instead of dropping the request on the floor.
689
+ * @private
690
+ */
691
+ _busyError( where ) {
692
+
693
+ const error = new Error( `${where}: another load is already in progress` );
694
+ error.code = 'LOAD_IN_PROGRESS';
695
+ return error;
696
+
697
+ }
698
+
677
699
  /**
678
700
  * Loads an environment map and rebuilds CDF.
679
701
  * @param {string} url - Environment URL
680
702
  */
681
703
  async loadEnvironment( url ) {
682
704
 
683
- if ( this._loadingInProgress ) {
684
-
685
- throw new Error( 'PathTracerApp.loadEnvironment: another load is already in progress' );
686
-
687
- }
705
+ if ( this._loadingInProgress ) throw this._busyError( 'PathTracerApp.loadEnvironment' );
688
706
 
689
707
  this._loadingInProgress = true;
690
708
 
@@ -711,6 +729,63 @@ export class PathTracerApp extends EventDispatcher {
711
729
 
712
730
  }
713
731
 
732
+ /**
733
+ * Loads a user-supplied File (drag-drop, file picker) — model, archive, or environment
734
+ * map, dispatched by extension.
735
+ *
736
+ * Prefer this over driving `assetLoader.loadAssetFromFile()` directly. That bypasses the
737
+ * in-progress guard, and AssetLoader disposes the outgoing model before it knows whether
738
+ * the engine will rebuild: a file dropped mid-load would tear down the live scene, add the
739
+ * new one to the graph, and then be discarded by the `load` handler — leaving the path
740
+ * tracer rendering buffers whose geometry has been freed. Here a concurrent call throws
741
+ * LOAD_IN_PROGRESS before anything is touched.
742
+ *
743
+ * @param {File} file
744
+ * @returns {Promise<void>}
745
+ */
746
+ async loadFile( file ) {
747
+
748
+ const format = this.assetLoader?.getFileFormat( file?.name || '' );
749
+ if ( ! format ) throw new Error( `Unsupported file format: ${file?.name}` );
750
+
751
+ if ( format.type !== 'environment' && format.type !== 'image' ) {
752
+
753
+ await this._loadWithSceneRebuild(
754
+ () => this.assetLoader.loadAssetFromFile( file ),
755
+ { type: 'ModelLoaded', filename: file.name }
756
+ );
757
+ return;
758
+
759
+ }
760
+
761
+ // Environment drop: install it, no scene rebuild.
762
+ if ( this._loadingInProgress ) throw this._busyError( 'PathTracerApp.loadFile' );
763
+
764
+ this._loadingInProgress = true;
765
+
766
+ try {
767
+
768
+ await this.assetLoader.loadAssetFromFile( file );
769
+
770
+ const texture = this.meshScene.environment;
771
+ if ( texture && this.stages.pathTracer ) {
772
+
773
+ await this.stages.pathTracer.environment.applyHDRI( texture );
774
+
775
+ }
776
+
777
+ this.pipeline?.eventBus.emit( 'autoexposure:resetHistory' );
778
+ this.reset();
779
+ this.dispatchEvent( { type: 'EnvironmentLoaded', filename: file.name } );
780
+
781
+ } finally {
782
+
783
+ this._loadingInProgress = false;
784
+
785
+ }
786
+
787
+ }
788
+
714
789
  /**
715
790
  * Loads example models by index.
716
791
  * @param {number} index
@@ -784,11 +859,7 @@ export class PathTracerApp extends EventDispatcher {
784
859
  /** Shared pipeline: load asset → sync controls → build BVH → reset → dispatch events */
785
860
  async _loadWithSceneRebuild( loadFn, eventPayload ) {
786
861
 
787
- if ( this._loadingInProgress ) {
788
-
789
- throw new Error( 'PathTracerApp: another load is already in progress' );
790
-
791
- }
862
+ if ( this._loadingInProgress ) throw this._busyError( 'PathTracerApp' );
792
863
 
793
864
  this._loadingInProgress = true;
794
865
 
@@ -803,7 +874,7 @@ export class PathTracerApp extends EventDispatcher {
803
874
  // (The old primary was already released by releaseTargetModel() in loadFn.)
804
875
  this._clearAppendedModels();
805
876
  this._syncControlsAfterLoad();
806
- await this.loadSceneData();
877
+ await this.loadSceneData( { pendingEnvironment: this._beginSceneMetadataEnvironment() } );
807
878
  this.pipeline?.eventBus.emit( 'autoexposure:resetHistory' );
808
879
  this.reset();
809
880
  this.cameraManager.currentCameraIndex = 0;
@@ -818,11 +889,99 @@ export class PathTracerApp extends EventDispatcher {
818
889
 
819
890
  }
820
891
 
892
+ /**
893
+ * Scene-level authoring metadata carried by the current model file (glTF `extras`),
894
+ * or null when the file has none. See {@link module:Processor/SceneMetadata}.
895
+ * @type {{ environment?: { sourceFile: string, rotation?: number, intensity?: number } }|null}
896
+ */
897
+ get sceneMetadata() {
898
+
899
+ return this.assetLoader?.sceneMetadata ?? null;
900
+
901
+ }
902
+
903
+ /**
904
+ * Starts fetching the environment authored into the just-loaded model's metadata and
905
+ * returns a promise for the texture — deliberately NOT awaited here. The fetch is the
906
+ * longest single step of such a load (measured 1448 ms for a 1k HDRI against 325 ms to
907
+ * build and upload a 112k-triangle scene), so the promise is handed to loadSceneData(),
908
+ * which installs it and builds its CDF while the BVH builds.
909
+ *
910
+ * Called from both replace-load seams: the URL/catalog path (_loadWithSceneRebuild) and
911
+ * the drag-drop / File-open path, which drives the AssetLoader directly and rebuilds via
912
+ * the 'load' event (_onAssetLoaded). Appending a model never applies it — an appended
913
+ * model must not hijack the scene's environment.
914
+ *
915
+ * Resolves to null and never rejects: a failed fetch leaves the model on the current
916
+ * environment. Returns null outright when there is nothing to apply.
917
+ * Opt out per app with `new PathTracerApp( canvas, { applySceneMetadata: false } )`.
918
+ *
919
+ * @returns {Promise<import('three').Texture|null>|null}
920
+ */
921
+ _beginSceneMetadataEnvironment() {
922
+
923
+ const env = this.sceneMetadata?.environment;
924
+ if ( ! this._applySceneMetadataEnabled || ! env?.sourceFile || ! this.stages.pathTracer ) return null;
925
+
926
+ // Claim 'hdri' mode up front: loadEnvironment() fires beforeEnvironmentLoad, and a
927
+ // host that answers it with setMode( 'hdri' ) would otherwise restore the HDRI
928
+ // stashed by the current sky mode on top of the download now in flight.
929
+ this.stages.pathTracer.environment.beginHDRI();
930
+ this._applyingSceneMetadata = true;
931
+
932
+ return this.assetLoader.loadEnvironment( env.sourceFile )
933
+ .catch( error => {
934
+
935
+ log.warn( `scene metadata: environment "${env.sourceFile}" failed to load`, error );
936
+ return null;
937
+
938
+ } )
939
+ .finally( () => {
940
+
941
+ this._applyingSceneMetadata = false;
942
+
943
+ } );
944
+
945
+ }
946
+
947
+ /**
948
+ * Mirrors the authored environment onto the settings once its texture is installed.
949
+ * The authored env is both the light and the backdrop, so the backdrop axis
950
+ * (showBackground / transparentBackground) is forced to the image.
951
+ * @private
952
+ */
953
+ _applySceneMetadataSettings( texture ) {
954
+
955
+ const metadata = this.sceneMetadata;
956
+ const env = metadata?.environment;
957
+ if ( ! env ) return;
958
+
959
+ const updates = { enableEnvironment: true, showBackground: true, transparentBackground: false };
960
+ if ( env.intensity !== undefined ) {
961
+
962
+ updates.environmentIntensity = env.intensity;
963
+ updates.backgroundIntensity = env.intensity;
964
+
965
+ }
966
+
967
+ if ( env.rotation !== undefined ) updates.environmentRotation = env.rotation;
968
+
969
+ this.settings.setMany( updates, { reset: false } );
970
+ if ( this.scene ) this.scene.background = texture;
971
+
972
+ this.dispatchEvent( { type: 'SceneMetadataApplied', metadata, environment: { ...env } } );
973
+
974
+ }
975
+
821
976
  /**
822
977
  * Builds BVH from meshScene and uploads all scene data to the path tracer.
978
+ * @param {Object} [options]
979
+ * @param {Promise<import('three').Texture|null>} [options.pendingEnvironment] - An
980
+ * environment still being fetched (see _beginSceneMetadataEnvironment). Installed and
981
+ * CDF'd concurrently with the BVH build rather than before it.
823
982
  * @returns {boolean}
824
983
  */
825
- async loadSceneData() {
984
+ async loadSceneData( { pendingEnvironment = null } = {} ) {
826
985
 
827
986
  // Clear selection before rebuilding — the old object leaves the scene graph.
828
987
  // Skipped on the append path (addModel): the selected object persists, so its
@@ -841,7 +1000,23 @@ export class PathTracerApp extends EventDispatcher {
841
1000
 
842
1001
  // Environment CDF build in parallel with BVH
843
1002
  let cdfPromise = null;
844
- if ( environmentTexture?.image?.data ) {
1003
+ if ( pendingEnvironment ) {
1004
+
1005
+ // The authored environment is still downloading. Install + CDF it off to the side so
1006
+ // the fetch overlaps the BVH build below instead of gating it.
1007
+ timer.start( 'Environment fetch + CDF (concurrent)' );
1008
+ cdfPromise = pendingEnvironment
1009
+ .then( async texture => {
1010
+
1011
+ if ( ! texture ) return;
1012
+ await this.stages.pathTracer.environment.applyHDRI( texture );
1013
+ this._applySceneMetadataSettings( texture );
1014
+
1015
+ } )
1016
+ .catch( error => log.warn( 'scene metadata: environment install failed', error ) )
1017
+ .finally( () => timer.end( 'Environment fetch + CDF (concurrent)' ) );
1018
+
1019
+ } else if ( environmentTexture?.image?.data ) {
845
1020
 
846
1021
  timer.start( 'Environment CDF build (worker)' );
847
1022
  this.stages.pathTracer.scene.environment = environmentTexture;
@@ -861,7 +1036,9 @@ export class PathTracerApp extends EventDispatcher {
861
1036
  await new Promise( r => setTimeout( r, 0 ) );
862
1037
  timer.start( 'GPU data transfer' );
863
1038
 
864
- if ( ! this._sdf.uploadToPathTracer( this.stages.pathTracer, this.lightManager, this.meshScene, environmentTexture ) ) return false;
1039
+ // Re-read rather than reusing the snapshot above: a pendingEnvironment may have landed
1040
+ // during the BVH build, and the snapshot is then a disposed texture.
1041
+ if ( ! this._sdf.uploadToPathTracer( this.stages.pathTracer, this.lightManager, this.meshScene, this.meshScene.environment ) ) return false;
865
1042
 
866
1043
  // Patch per-mesh visibility into the TLAS leaves we just uploaded
867
1044
  this.stages.pathTracer._meshRefs = this.stages.pathTracer._collectMeshRefs( this.meshScene );
@@ -963,10 +1140,9 @@ export class PathTracerApp extends EventDispatcher {
963
1140
  /** Tag the primary (replace-loaded) model as a removable scene object (read by the Outliner + removeSceneObject). Idempotent. */
964
1141
  _tagPrimarySceneObject() {
965
1142
 
966
- const m = this.assetLoader?.targetModel;
1143
+ const m = this.sceneModel;
967
1144
  if ( ! m ) return;
968
1145
  m.userData.__rayzeeSceneObject = true;
969
- m.userData.__rayzeeExternal = ( m === this.assetLoader._externalModel );
970
1146
 
971
1147
  }
972
1148
 
@@ -976,12 +1152,12 @@ export class PathTracerApp extends EventDispatcher {
976
1152
  const scene = this.meshScene;
977
1153
  if ( ! scene ) return;
978
1154
  const floor = this.assetLoader?.floorPlane;
979
- const primary = this.assetLoader?.targetModel;
1155
+ const primary = this.sceneModel;
980
1156
  for ( const child of [ ...scene.children ] ) {
981
1157
 
982
1158
  if ( child === floor || child === primary ) continue;
983
1159
  if ( ! child.userData?.__rayzeeSceneObject ) continue;
984
- this.assetLoader.removeModelRoot( child, { external: !! child.userData.__rayzeeExternal } );
1160
+ this.assetLoader.removeModelRoot( child );
985
1161
 
986
1162
  }
987
1163
 
@@ -1020,7 +1196,6 @@ export class PathTracerApp extends EventDispatcher {
1020
1196
 
1021
1197
  const { root } = await this.assetLoader.appendModel( url );
1022
1198
  root.userData.__rayzeeSceneObject = true;
1023
- root.userData.__rayzeeExternal = false;
1024
1199
  if ( name ) root.userData.__rayzeeName = name;
1025
1200
  await this._finishRebuildNoReframe( { type: 'ModelAdded', url, id: root.uuid } );
1026
1201
  return root.uuid;
@@ -1036,7 +1211,7 @@ export class PathTracerApp extends EventDispatcher {
1036
1211
 
1037
1212
  /**
1038
1213
  * Append a caller-owned Object3D to the current scene, then rebuild (no reframe).
1039
- * The caller retains ownership — removal only detaches it.
1214
+ * Appends a copy; the object passed in is never mutated. Same rules as {@link loadObject3D}.
1040
1215
  * @param {import('three').Object3D} object3d
1041
1216
  * @param {Object} [opts]
1042
1217
  * @param {string} [opts.name]
@@ -1056,7 +1231,6 @@ export class PathTracerApp extends EventDispatcher {
1056
1231
 
1057
1232
  const { root } = this.assetLoader.appendObject3D( object3d, name || 'object3d' );
1058
1233
  root.userData.__rayzeeSceneObject = true;
1059
- root.userData.__rayzeeExternal = true;
1060
1234
  if ( name ) root.userData.__rayzeeName = name;
1061
1235
  await this._finishRebuildNoReframe( { type: 'ModelAdded', id: root.uuid } );
1062
1236
  return root.uuid;
@@ -1083,7 +1257,7 @@ export class PathTracerApp extends EventDispatcher {
1083
1257
  const floor = this.assetLoader?.floorPlane;
1084
1258
  if ( floor && floor.uuid === id ) return false; // Ground is not deletable
1085
1259
 
1086
- const root = scene.children.find( c => c.uuid === id && c.userData?.__rayzeeSceneObject );
1260
+ const root = this.getSceneObject( id );
1087
1261
  if ( ! root ) return false;
1088
1262
 
1089
1263
  if ( this._loadingInProgress ) {
@@ -1098,13 +1272,13 @@ export class PathTracerApp extends EventDispatcher {
1098
1272
  this.interactionManager?.deselect();
1099
1273
  this.transformManager?.detach?.();
1100
1274
 
1101
- if ( root === this.assetLoader.targetModel ) {
1275
+ if ( root === this.sceneModel ) {
1102
1276
 
1103
1277
  this.assetLoader.releaseTargetModel();
1104
1278
 
1105
1279
  } else {
1106
1280
 
1107
- this.assetLoader.removeModelRoot( root, { external: !! root.userData.__rayzeeExternal } );
1281
+ this.assetLoader.removeModelRoot( root );
1108
1282
 
1109
1283
  }
1110
1284
 
@@ -1207,6 +1381,32 @@ export class PathTracerApp extends EventDispatcher {
1207
1381
 
1208
1382
  }
1209
1383
 
1384
+ /**
1385
+ * Root of the model actually being rendered. For {@link loadObject3D} this is the engine's
1386
+ * copy, not the object you passed — mutate this one, then {@link refitBVH}/{@link refitBLASes}.
1387
+ *
1388
+ * @returns {import('three').Object3D|null} Live reference, or null when nothing is loaded.
1389
+ */
1390
+ get sceneModel() {
1391
+
1392
+ return this.assetLoader?.targetModel ?? null;
1393
+
1394
+ }
1395
+
1396
+ /**
1397
+ * Resolve an id from {@link addModel}/{@link addModelFromObject3D} to the root being
1398
+ * rendered for it — for an appended Object3D, the engine's copy.
1399
+ *
1400
+ * @param {string} id
1401
+ * @returns {import('three').Object3D|null} Live reference, or null if no such object.
1402
+ */
1403
+ getSceneObject( id ) {
1404
+
1405
+ const root = this.meshScene?.children.find( c => c.uuid === id );
1406
+ return root?.userData?.__rayzeeSceneObject ? root : null;
1407
+
1408
+ }
1409
+
1210
1410
  /**
1211
1411
  * Update vertex positions for animation without full BVH rebuild.
1212
1412
  * O(N) bottom-up AABB refit instead of O(N log N) SAH rebuild.
@@ -2612,11 +2812,18 @@ export class PathTracerApp extends EventDispatcher {
2612
2812
 
2613
2813
  if ( this._loadingInProgress ) return;
2614
2814
 
2815
+ // The authored-environment fetch runs through this same AssetLoader, so its 'load'
2816
+ // event lands right back here. We install that texture ourselves — without the
2817
+ // guard the texture branch below would rebuild the CDF a second time and
2818
+ // resetLoading() would tear down the overlay mid-upload. Model events still pass:
2819
+ // a user can drop another file while the environment is in flight.
2820
+ if ( this._applyingSceneMetadata && event.texture ) return;
2821
+
2615
2822
  if ( event.model ) {
2616
2823
 
2617
2824
  // Drag-drop / file load is a replace: clear any appended models first.
2618
2825
  this._clearAppendedModels();
2619
- await this.loadSceneData();
2826
+ await this.loadSceneData( { pendingEnvironment: this._beginSceneMetadataEnvironment() } );
2620
2827
 
2621
2828
  } else if ( event.texture ) {
2622
2829
 
@@ -2661,7 +2868,7 @@ export class PathTracerApp extends EventDispatcher {
2661
2868
  const animations = this.assetLoader?.animations || [];
2662
2869
  if ( animations.length > 0 ) {
2663
2870
 
2664
- const mixerRoot = this.assetLoader?.targetModel || this.meshScene;
2871
+ const mixerRoot = this.sceneModel || this.meshScene;
2665
2872
  this.animationManager.init( this.meshScene, mixerRoot, this._sdf.meshes, animations );
2666
2873
  this.animationManager.onFinished = () => {
2667
2874