rayzee 7.9.2 → 7.10.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rayzee",
3
- "version": "7.9.2",
3
+ "version": "7.10.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",
@@ -689,6 +689,21 @@ export class PathTracerApp extends EventDispatcher {
689
689
 
690
690
  }
691
691
 
692
+ /**
693
+ * Cancel the in-flight model/environment download, if any. The active
694
+ * loadAsync() rejects with a typed LOAD_CANCELLED error, which callers treat
695
+ * as a user cancellation (not a load failure). Only the network-download phase
696
+ * is cancelable — once processing (BVH/textures) has started this is a no-op.
697
+ * The scene is untouched: replace-loads release the old model only after the
698
+ * download succeeds, and appends parent nothing until then.
699
+ */
700
+ cancelLoad() {
701
+
702
+ if ( ! this._loadingInProgress ) return;
703
+ this.assetLoader?.cancelActiveLoad();
704
+
705
+ }
706
+
692
707
  /**
693
708
  * Set the max material-texture dimension (longest edge) used when processing a
694
709
  * scene's textures into GPU arrays. Clamped to the hardware ceiling. Larger =
@@ -744,6 +759,10 @@ export class PathTracerApp extends EventDispatcher {
744
759
  try {
745
760
 
746
761
  await loadFn();
762
+ // Replace-load clears any dynamically-appended models — but only AFTER
763
+ // loadFn() succeeds, so a failed load leaves the current scene intact.
764
+ // (The old primary was already released by releaseTargetModel() in loadFn.)
765
+ this._clearAppendedModels();
747
766
  this._syncControlsAfterLoad();
748
767
  await this.loadSceneData();
749
768
  this.pipeline?.eventBus.emit( 'autoexposure:resetHistory' );
@@ -770,13 +789,18 @@ export class PathTracerApp extends EventDispatcher {
770
789
  */
771
790
  async loadSceneData() {
772
791
 
773
- // Clear selection before rebuilding — the old object leaves the scene graph
774
- this.interactionManager?.deselect();
792
+ // Clear selection before rebuilding — the old object leaves the scene graph.
793
+ // Skipped on the append path (addModel): the selected object persists, so its
794
+ // selection + transform gizmo should survive the rebuild.
795
+ if ( ! this._preserveSelectionOnRebuild ) this.interactionManager?.deselect();
775
796
 
776
797
  // Stop any running animation before rebuilding scene data
777
798
  this.animationManager.dispose();
778
799
  this._animRefitInFlight = false;
779
800
 
801
+ // Tag the primary (replace-loaded) model so it appears in the scene-object list.
802
+ this._tagPrimarySceneObject();
803
+
780
804
  const timer = new BuildTimer( 'loadSceneData' );
781
805
  const environmentTexture = this.meshScene.environment;
782
806
 
@@ -863,6 +887,188 @@ export class PathTracerApp extends EventDispatcher {
863
887
 
864
888
  }
865
889
 
890
+ // ═══════════════════════════════════════════════════════════════
891
+ // Dynamic scene objects (add / remove / list / visibility)
892
+ //
893
+ // Top-level objects are the auto-created "Ground" plane plus each loaded
894
+ // model root parented into meshScene. The scene graph + per-root userData
895
+ // tags are the single source of truth (no separate registry): ids are
896
+ // Object3D uuids (stable across rebuilds, since the same root persists).
897
+ // ═══════════════════════════════════════════════════════════════
898
+
899
+ /** Tag the primary (replace-loaded) model as a removable scene object (read by the Outliner + removeSceneObject). Idempotent. */
900
+ _tagPrimarySceneObject() {
901
+
902
+ const m = this.assetLoader?.targetModel;
903
+ if ( ! m ) return;
904
+ m.userData.__rayzeeSceneObject = true;
905
+ m.userData.__rayzeeExternal = ( m === this.assetLoader._externalModel );
906
+
907
+ }
908
+
909
+ /** Remove + dispose all dynamically-appended models (keeps Ground and the primary). */
910
+ _clearAppendedModels() {
911
+
912
+ const scene = this.meshScene;
913
+ if ( ! scene ) return;
914
+ const floor = this.assetLoader?.floorPlane;
915
+ const primary = this.assetLoader?.targetModel;
916
+ for ( const child of [ ...scene.children ] ) {
917
+
918
+ if ( child === floor || child === primary ) continue;
919
+ if ( ! child.userData?.__rayzeeSceneObject ) continue;
920
+ this.assetLoader.removeModelRoot( child, { external: !! child.userData.__rayzeeExternal } );
921
+
922
+ }
923
+
924
+ }
925
+
926
+ /** Reframe-free rebuild sequence. Assumes the _loadingInProgress guard is already held. */
927
+ async _finishRebuildNoReframe( eventPayload ) {
928
+
929
+ await this.loadSceneData(); // emits 'SceneRebuild'
930
+ this.pipeline?.eventBus.emit( 'autoexposure:resetHistory' );
931
+ this.reset();
932
+ if ( eventPayload ) this.dispatchEvent( eventPayload );
933
+
934
+ }
935
+
936
+ /**
937
+ * Append a model by URL to the current scene (does NOT replace it), then rebuild
938
+ * without reframing the camera.
939
+ * @param {string} url
940
+ * @param {Object} [opts]
941
+ * @param {string} [opts.name] - Display name for the scene-object list.
942
+ * @returns {Promise<string>} the new object's id (Object3D uuid).
943
+ */
944
+ async addModel( url, { name } = {} ) {
945
+
946
+ if ( this._loadingInProgress ) {
947
+
948
+ throw new Error( 'PathTracerApp.addModel: another load is already in progress' );
949
+
950
+ }
951
+
952
+ this._loadingInProgress = true;
953
+ this._preserveSelectionOnRebuild = true;
954
+ try {
955
+
956
+ const { root } = await this.assetLoader.appendModel( url );
957
+ root.userData.__rayzeeSceneObject = true;
958
+ root.userData.__rayzeeExternal = false;
959
+ if ( name ) root.userData.__rayzeeName = name;
960
+ await this._finishRebuildNoReframe( { type: 'ModelAdded', url, id: root.uuid } );
961
+ return root.uuid;
962
+
963
+ } finally {
964
+
965
+ this._preserveSelectionOnRebuild = false;
966
+ this._loadingInProgress = false;
967
+
968
+ }
969
+
970
+ }
971
+
972
+ /**
973
+ * Append a caller-owned Object3D to the current scene, then rebuild (no reframe).
974
+ * The caller retains ownership — removal only detaches it.
975
+ * @param {import('three').Object3D} object3d
976
+ * @param {Object} [opts]
977
+ * @param {string} [opts.name]
978
+ * @returns {Promise<string>} the new object's id (Object3D uuid).
979
+ */
980
+ async addModelFromObject3D( object3d, { name } = {} ) {
981
+
982
+ if ( this._loadingInProgress ) {
983
+
984
+ throw new Error( 'PathTracerApp.addModelFromObject3D: another load is already in progress' );
985
+
986
+ }
987
+
988
+ this._loadingInProgress = true;
989
+ this._preserveSelectionOnRebuild = true;
990
+ try {
991
+
992
+ const { root } = this.assetLoader.appendObject3D( object3d, name || 'object3d' );
993
+ root.userData.__rayzeeSceneObject = true;
994
+ root.userData.__rayzeeExternal = true;
995
+ if ( name ) root.userData.__rayzeeName = name;
996
+ await this._finishRebuildNoReframe( { type: 'ModelAdded', id: root.uuid } );
997
+ return root.uuid;
998
+
999
+ } finally {
1000
+
1001
+ this._preserveSelectionOnRebuild = false;
1002
+ this._loadingInProgress = false;
1003
+
1004
+ }
1005
+
1006
+ }
1007
+
1008
+ /**
1009
+ * Remove a scene object by id (Object3D uuid). The Ground plane is permanent.
1010
+ * @param {string} id
1011
+ * @returns {Promise<boolean>} true if removed.
1012
+ */
1013
+ async removeSceneObject( id ) {
1014
+
1015
+ const scene = this.meshScene;
1016
+ if ( ! scene ) return false;
1017
+
1018
+ const floor = this.assetLoader?.floorPlane;
1019
+ if ( floor && floor.uuid === id ) return false; // Ground is not deletable
1020
+
1021
+ const root = scene.children.find( c => c.uuid === id && c.userData?.__rayzeeSceneObject );
1022
+ if ( ! root ) return false;
1023
+
1024
+ if ( this._loadingInProgress ) {
1025
+
1026
+ throw new Error( 'PathTracerApp.removeSceneObject: another load is already in progress' );
1027
+
1028
+ }
1029
+
1030
+ this._loadingInProgress = true;
1031
+ try {
1032
+
1033
+ this.interactionManager?.deselect();
1034
+ this.transformManager?.detach?.();
1035
+
1036
+ if ( root === this.assetLoader.targetModel ) {
1037
+
1038
+ this.assetLoader.releaseTargetModel();
1039
+
1040
+ } else {
1041
+
1042
+ this.assetLoader.removeModelRoot( root, { external: !! root.userData.__rayzeeExternal } );
1043
+
1044
+ }
1045
+
1046
+ // Ground is permanent (removal refused above), so the scene always keeps
1047
+ // renderable geometry — a full rebuild is always valid here.
1048
+ await this._finishRebuildNoReframe( { type: 'SceneObjectRemoved', id } );
1049
+
1050
+ return true;
1051
+
1052
+ } finally {
1053
+
1054
+ this._loadingInProgress = false;
1055
+
1056
+ }
1057
+
1058
+ }
1059
+
1060
+ /**
1061
+ * Toggle a scene object's visibility without rebuilding (O(1) TLAS-leaf patch).
1062
+ * @param {string} id - Object3D uuid.
1063
+ * @param {boolean | ((prev:boolean)=>boolean)} visible
1064
+ * @returns {boolean|null} new visibility, or null if not found.
1065
+ */
1066
+ setSceneObjectVisibility( id, visible ) {
1067
+
1068
+ return this.setMeshVisibilityByUuid( id, visible );
1069
+
1070
+ }
1071
+
866
1072
  // ═══════════════════════════════════════════════════════════════
867
1073
  // BVH Refit (Animation)
868
1074
  // ═══════════════════════════════════════════════════════════════
@@ -1611,6 +1817,8 @@ export class PathTracerApp extends EventDispatcher {
1611
1817
 
1612
1818
  if ( event.model ) {
1613
1819
 
1820
+ // Drag-drop / file load is a replace: clear any appended models first.
1821
+ this._clearAppendedModels();
1614
1822
  await this.loadSceneData();
1615
1823
 
1616
1824
  } else if ( event.texture ) {
@@ -48,6 +48,75 @@ export class AssetLoader extends EventDispatcher {
48
48
  this.animations = [];
49
49
  this.renderer = null;
50
50
 
51
+ // Shared across every loader so cancelActiveLoad() can abort whichever
52
+ // fetch is in flight (three r185 FileLoader wires the manager's abort
53
+ // signal into its fetch). One load runs at a time (guarded upstream).
54
+ this._loadingManager = new LoadingManager();
55
+ this._loadCancelled = false;
56
+
57
+ }
58
+
59
+ /**
60
+ * Abort the network download for the in-flight load, if any. The aborted
61
+ * loadAsync() rejects with an AbortError, which each load path re-throws as a
62
+ * typed LOAD_CANCELLED error. Only the download phase is cancelable — once the
63
+ * bytes are in and BVH/texture processing has begun, this is a no-op.
64
+ */
65
+ cancelActiveLoad() {
66
+
67
+ this._loadCancelled = true;
68
+ this._loadingManager.abort();
69
+
70
+ }
71
+
72
+ _isCancellation( error ) {
73
+
74
+ return this._loadCancelled || error?.name === 'AbortError' || error?.code === 'LOAD_CANCELLED';
75
+
76
+ }
77
+
78
+ _cancellationError() {
79
+
80
+ const err = new Error( 'Load cancelled' );
81
+ err.code = 'LOAD_CANCELLED';
82
+ return err;
83
+
84
+ }
85
+
86
+ // Build an onProgress handler that reports download byte counts to the UI.
87
+ // `cancelable` gates the Cancel affordance (true only for network URLs — blob
88
+ // and data URLs resolve locally and have nothing to abort). Download maps onto
89
+ // 2→60% of the bar, leaving headroom for the processing phases that follow.
90
+ _downloadProgress( status, cancelable ) {
91
+
92
+ return ( event ) => {
93
+
94
+ const loaded = event?.loaded || 0;
95
+ const total = event?.lengthComputable ? ( event.total || 0 ) : 0;
96
+ updateLoading( {
97
+ isLoading: true,
98
+ status,
99
+ loadedBytes: loaded,
100
+ totalBytes: total,
101
+ canCancel: !! cancelable,
102
+ progress: total ? Math.min( 60, 2 + Math.round( ( loaded / total ) * 58 ) ) : 2,
103
+ } );
104
+
105
+ };
106
+
107
+ }
108
+
109
+ // Called once bytes are in, before the (non-cancelable) processing phases.
110
+ _downloadComplete( status = 'Processing Data...', progress = 62 ) {
111
+
112
+ updateLoading( { status, progress, canCancel: false, loadedBytes: null, totalBytes: null } );
113
+
114
+ }
115
+
116
+ static _isNetworkUrl( url ) {
117
+
118
+ return typeof url === 'string' && /^https?:/i.test( url );
119
+
51
120
  }
52
121
 
53
122
  /**
@@ -72,6 +141,9 @@ export class AssetLoader extends EventDispatcher {
72
141
 
73
142
  this.targetModel = null;
74
143
  this._externalModel = null;
144
+ // Drop the released model's animation clips so a later rebuild doesn't rebind
145
+ // a mixer to disposed nodes. Every load path re-populates this.animations after.
146
+ this.animations = [];
75
147
 
76
148
  }
77
149
 
@@ -189,6 +261,8 @@ export class AssetLoader extends EventDispatcher {
189
261
 
190
262
  async loadEnvironment( envUrl ) {
191
263
 
264
+ this._loadCancelled = false;
265
+
192
266
  try {
193
267
 
194
268
  // Dispatch event before loading environment to allow UI to prepare
@@ -219,6 +293,7 @@ export class AssetLoader extends EventDispatcher {
219
293
 
220
294
  } catch ( error ) {
221
295
 
296
+ if ( this._isCancellation( error ) ) throw this._cancellationError();
222
297
  console.error( "Error loading environment:", error );
223
298
  this.dispatchEvent( { type: 'error', message: error.message, filename: envUrl } );
224
299
  throw error;
@@ -280,24 +355,29 @@ export class AssetLoader extends EventDispatcher {
280
355
 
281
356
  async loadEnvironmentByExtension( url, extension ) {
282
357
 
358
+ const cancelable = AssetLoader._isNetworkUrl( url );
359
+ const onProgress = this._downloadProgress( "Downloading Environment...", cancelable );
360
+
283
361
  let texture;
284
362
  if ( extension === 'hdr' || extension === 'exr' ) {
285
363
 
286
364
  const loader = extension === 'hdr'
287
- ? ( this.loaderCache.hdr || ( this.loaderCache.hdr = new HDRLoader().setDataType( FloatType ) ) )
288
- : ( this.loaderCache.exr || ( this.loaderCache.exr = new EXRLoader().setDataType( FloatType ) ) );
289
- texture = await loader.loadAsync( url );
365
+ ? ( this.loaderCache.hdr || ( this.loaderCache.hdr = new HDRLoader( this._loadingManager ).setDataType( FloatType ) ) )
366
+ : ( this.loaderCache.exr || ( this.loaderCache.exr = new EXRLoader( this._loadingManager ).setDataType( FloatType ) ) );
367
+ texture = await loader.loadAsync( url, onProgress );
290
368
 
291
369
  } else {
292
370
 
293
- if ( ! this.loaderCache.texture ) this.loaderCache.texture = new TextureLoader();
294
- texture = await this.loaderCache.texture.loadAsync( url );
371
+ if ( ! this.loaderCache.texture ) this.loaderCache.texture = new TextureLoader( this._loadingManager );
372
+ texture = await this.loaderCache.texture.loadAsync( url, onProgress );
295
373
  // LDR env maps (jpg/png/webp) are authored in sRGB; tag them so the backend
296
374
  // decodes to linear. HDR/EXR are already linear and keep the loader's setting.
297
375
  texture.colorSpace = SRGBColorSpace;
298
376
 
299
377
  }
300
378
 
379
+ this._downloadComplete( "Processing Environment...", 62 );
380
+
301
381
  texture.mapping = EquirectangularReflectionMapping;
302
382
  texture.minFilter = LinearFilter;
303
383
  texture.magFilter = LinearFilter;
@@ -919,7 +999,7 @@ export class AssetLoader extends EventDispatcher {
919
999
 
920
1000
  }
921
1001
 
922
- const loader = new GLTFLoader();
1002
+ const loader = new GLTFLoader( this._loadingManager );
923
1003
  loader.setDRACOLoader( dracoLoader );
924
1004
  loader.setKTX2Loader( ktx2Loader );
925
1005
  loader.setMeshoptDecoder( MeshoptDecoder );
@@ -951,13 +1031,15 @@ export class AssetLoader extends EventDispatcher {
951
1031
 
952
1032
  async loadModel( modelUrl ) {
953
1033
 
1034
+ this._loadCancelled = false;
954
1035
  const loader = await this.createGLTFLoader();
1036
+ const cancelable = AssetLoader._isNetworkUrl( modelUrl );
955
1037
 
956
1038
  try {
957
1039
 
958
- updateLoading( { status: "Loading Model...", progress: 2 } );
959
- const data = await loader.loadAsync( modelUrl );
960
- updateLoading( { status: "Processing Data...", progress: 10 } );
1040
+ updateLoading( { isLoading: true, status: "Downloading Model...", progress: 2, canCancel: cancelable, loadedBytes: 0, totalBytes: 0 } );
1041
+ const data = await loader.loadAsync( modelUrl, this._downloadProgress( "Downloading Model...", cancelable ) );
1042
+ this._downloadComplete();
961
1043
 
962
1044
  this.releaseTargetModel();
963
1045
 
@@ -969,6 +1051,7 @@ export class AssetLoader extends EventDispatcher {
969
1051
 
970
1052
  } catch ( error ) {
971
1053
 
1054
+ if ( this._isCancellation( error ) ) throw this._cancellationError();
972
1055
  console.error( "Error loading model:", error );
973
1056
  this.dispatchEvent( { type: 'error', message: error.message, filename: modelUrl } );
974
1057
  throw error;
@@ -981,6 +1064,72 @@ export class AssetLoader extends EventDispatcher {
981
1064
 
982
1065
  }
983
1066
 
1067
+ // ─────────────────────────────────────────────────────────────
1068
+ // Append / remove primitives (dynamic object add/remove).
1069
+ // These deliberately do NOT touch targetModel/animations and do NOT
1070
+ // reframe the camera or dispatch load/modelProcessed — the caller
1071
+ // (PathTracerApp) drives a reframe-free scene rebuild.
1072
+ // ─────────────────────────────────────────────────────────────
1073
+
1074
+ // Multi-material split + area-light placeholders, then parent into meshScene.
1075
+ _processAndParent( model ) {
1076
+
1077
+ this.processModelObjects( model );
1078
+ this.scene.add( model );
1079
+ // Refresh world matrices for the whole subtree — the reframe-free rebuild
1080
+ // extracts geometry immediately (rAF is gated off), so nothing else would
1081
+ // update matrices first and any ancestor-node transform would be dropped.
1082
+ model.updateMatrixWorld( true );
1083
+
1084
+ }
1085
+
1086
+ // Append a model from URL without releasing prior models or reframing.
1087
+ // Reuses createGLTFLoader() so appended KTX2 textures stay RGBA DataArrayTexture.
1088
+ async appendModel( url ) {
1089
+
1090
+ this._loadCancelled = false;
1091
+ const loader = await this.createGLTFLoader();
1092
+ const cancelable = AssetLoader._isNetworkUrl( url );
1093
+
1094
+ try {
1095
+
1096
+ updateLoading( { isLoading: true, status: "Downloading Model...", progress: 2, canCancel: cancelable, loadedBytes: 0, totalBytes: 0 } );
1097
+ const data = await loader.loadAsync( url, this._downloadProgress( "Downloading Model...", cancelable ) );
1098
+ this._downloadComplete();
1099
+ this._processAndParent( data.scene );
1100
+ return { root: data.scene, animations: data.animations || [] };
1101
+
1102
+ } catch ( error ) {
1103
+
1104
+ if ( this._isCancellation( error ) ) throw this._cancellationError();
1105
+ throw error;
1106
+
1107
+ } finally {
1108
+
1109
+ this._disposeGLTFLoader( loader );
1110
+
1111
+ }
1112
+
1113
+ }
1114
+
1115
+ // Append a caller-owned Object3D without releasing prior models or reframing.
1116
+ appendObject3D( object3d, name = 'object3d' ) {
1117
+
1118
+ object3d.name = object3d.name || name;
1119
+ this._processAndParent( object3d );
1120
+ return { root: object3d };
1121
+
1122
+ }
1123
+
1124
+ // Detach + dispose an appended root. External (caller-owned) roots are only detached.
1125
+ removeModelRoot( root, { external = false } = {} ) {
1126
+
1127
+ if ( ! root ) return;
1128
+ if ( external ) root.parent?.remove( root );
1129
+ else disposeObjectFromMemory( root );
1130
+
1131
+ }
1132
+
984
1133
  async loadGLBFromArrayBuffer( arrayBuffer, filename = 'model.glb' ) {
985
1134
 
986
1135
  const loader = await this.createGLTFLoader();
@@ -425,9 +425,13 @@ export class PathTracer extends PathTracerStage {
425
425
  // Async readback of the per-bounce snapshot every N frames; never awaited, so the early-exit uses past-frame data.
426
426
  _maybeReadbackCounters() {
427
427
 
428
- // Never sample the survivor curve mid-motion those counts belong to a pose we're leaving and
429
- // would mis-size the first settled frame. Prime the counter so the settled view re-measures promptly.
430
- if ( this.cameraChanged ) {
428
+ // Never sample the survivor curve mid-motion, nor while the CameraOptimizer is holding maxBounces
429
+ // down to its interaction value (1): mid-motion counts belong to a pose we're leaving, and a curve
430
+ // measured at the interaction budget stores _lastBounceCountsBudget=1 — when the real (high) budget
431
+ // is restored on exit that stale curve forces curveReliableUpto=1, killing the per-bounce early-exit
432
+ // and full-sizing the whole loopBound for a few frames (the dramatic FPS drop right when movement
433
+ // ends, worst at high maxBounces). Prime the counter so the first settled frame re-measures promptly.
434
+ if ( this.cameraChanged || this.cameraOptimizer?.isInInteractionMode() ) {
431
435
 
432
436
  this._readbackFrameCounter = this._readbackEveryNFrames;
433
437
  return;
@@ -75,6 +75,10 @@ export class TransformManager {
75
75
  this._posBuffer = new Float32Array( offset * 9 );
76
76
  this._normalBuffer = new Float32Array( offset * 9 );
77
77
 
78
+ // Mesh indices/buffers were just reallocated (e.g. after a scene rebuild) —
79
+ // force the next drag to recompute the transform baseline.
80
+ this._baselineComputed = false;
81
+
78
82
  }
79
83
 
80
84
  /**