rayzee 7.25.0 → 7.26.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.25.0",
3
+ "version": "7.26.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",
@@ -109,6 +109,9 @@ export class PathTracerApp extends EventDispatcher {
109
109
  this.canvas = canvas;
110
110
  this._autoResize = options.autoResize !== false;
111
111
  this._container = options.container || null;
112
+ // Apply the environment authored into a model file's metadata on load. See _beginSceneMetadataEnvironment().
113
+ this._applySceneMetadataEnabled = options.applySceneMetadata !== false;
114
+ this._applyingSceneMetadata = false;
112
115
 
113
116
  // ── Settings (single source of truth for all render parameters) ──
114
117
  this.settings = new RenderSettings( DEFAULT_STATE );
@@ -674,17 +677,26 @@ export class PathTracerApp extends EventDispatcher {
674
677
 
675
678
  }
676
679
 
680
+ /**
681
+ * A load is already running. Typed so hosts can tell "you clicked too fast" apart from
682
+ * a genuine load failure and say so, instead of dropping the request on the floor.
683
+ * @private
684
+ */
685
+ _busyError( where ) {
686
+
687
+ const error = new Error( `${where}: another load is already in progress` );
688
+ error.code = 'LOAD_IN_PROGRESS';
689
+ return error;
690
+
691
+ }
692
+
677
693
  /**
678
694
  * Loads an environment map and rebuilds CDF.
679
695
  * @param {string} url - Environment URL
680
696
  */
681
697
  async loadEnvironment( url ) {
682
698
 
683
- if ( this._loadingInProgress ) {
684
-
685
- throw new Error( 'PathTracerApp.loadEnvironment: another load is already in progress' );
686
-
687
- }
699
+ if ( this._loadingInProgress ) throw this._busyError( 'PathTracerApp.loadEnvironment' );
688
700
 
689
701
  this._loadingInProgress = true;
690
702
 
@@ -711,6 +723,63 @@ export class PathTracerApp extends EventDispatcher {
711
723
 
712
724
  }
713
725
 
726
+ /**
727
+ * Loads a user-supplied File (drag-drop, file picker) — model, archive, or environment
728
+ * map, dispatched by extension.
729
+ *
730
+ * Prefer this over driving `assetLoader.loadAssetFromFile()` directly. That bypasses the
731
+ * in-progress guard, and AssetLoader disposes the outgoing model before it knows whether
732
+ * the engine will rebuild: a file dropped mid-load would tear down the live scene, add the
733
+ * new one to the graph, and then be discarded by the `load` handler — leaving the path
734
+ * tracer rendering buffers whose geometry has been freed. Here a concurrent call throws
735
+ * LOAD_IN_PROGRESS before anything is touched.
736
+ *
737
+ * @param {File} file
738
+ * @returns {Promise<void>}
739
+ */
740
+ async loadFile( file ) {
741
+
742
+ const format = this.assetLoader?.getFileFormat( file?.name || '' );
743
+ if ( ! format ) throw new Error( `Unsupported file format: ${file?.name}` );
744
+
745
+ if ( format.type !== 'environment' && format.type !== 'image' ) {
746
+
747
+ await this._loadWithSceneRebuild(
748
+ () => this.assetLoader.loadAssetFromFile( file ),
749
+ { type: 'ModelLoaded', filename: file.name }
750
+ );
751
+ return;
752
+
753
+ }
754
+
755
+ // Environment drop: install it, no scene rebuild.
756
+ if ( this._loadingInProgress ) throw this._busyError( 'PathTracerApp.loadFile' );
757
+
758
+ this._loadingInProgress = true;
759
+
760
+ try {
761
+
762
+ await this.assetLoader.loadAssetFromFile( file );
763
+
764
+ const texture = this.meshScene.environment;
765
+ if ( texture && this.stages.pathTracer ) {
766
+
767
+ await this.stages.pathTracer.environment.applyHDRI( texture );
768
+
769
+ }
770
+
771
+ this.pipeline?.eventBus.emit( 'autoexposure:resetHistory' );
772
+ this.reset();
773
+ this.dispatchEvent( { type: 'EnvironmentLoaded', filename: file.name } );
774
+
775
+ } finally {
776
+
777
+ this._loadingInProgress = false;
778
+
779
+ }
780
+
781
+ }
782
+
714
783
  /**
715
784
  * Loads example models by index.
716
785
  * @param {number} index
@@ -784,11 +853,7 @@ export class PathTracerApp extends EventDispatcher {
784
853
  /** Shared pipeline: load asset → sync controls → build BVH → reset → dispatch events */
785
854
  async _loadWithSceneRebuild( loadFn, eventPayload ) {
786
855
 
787
- if ( this._loadingInProgress ) {
788
-
789
- throw new Error( 'PathTracerApp: another load is already in progress' );
790
-
791
- }
856
+ if ( this._loadingInProgress ) throw this._busyError( 'PathTracerApp' );
792
857
 
793
858
  this._loadingInProgress = true;
794
859
 
@@ -803,7 +868,7 @@ export class PathTracerApp extends EventDispatcher {
803
868
  // (The old primary was already released by releaseTargetModel() in loadFn.)
804
869
  this._clearAppendedModels();
805
870
  this._syncControlsAfterLoad();
806
- await this.loadSceneData();
871
+ await this.loadSceneData( { pendingEnvironment: this._beginSceneMetadataEnvironment() } );
807
872
  this.pipeline?.eventBus.emit( 'autoexposure:resetHistory' );
808
873
  this.reset();
809
874
  this.cameraManager.currentCameraIndex = 0;
@@ -818,11 +883,99 @@ export class PathTracerApp extends EventDispatcher {
818
883
 
819
884
  }
820
885
 
886
+ /**
887
+ * Scene-level authoring metadata carried by the current model file (glTF `extras`),
888
+ * or null when the file has none. See {@link module:Processor/SceneMetadata}.
889
+ * @type {{ environment?: { sourceFile: string, rotation?: number, intensity?: number } }|null}
890
+ */
891
+ get sceneMetadata() {
892
+
893
+ return this.assetLoader?.sceneMetadata ?? null;
894
+
895
+ }
896
+
897
+ /**
898
+ * Starts fetching the environment authored into the just-loaded model's metadata and
899
+ * returns a promise for the texture — deliberately NOT awaited here. The fetch is the
900
+ * longest single step of such a load (measured 1448 ms for a 1k HDRI against 325 ms to
901
+ * build and upload a 112k-triangle scene), so the promise is handed to loadSceneData(),
902
+ * which installs it and builds its CDF while the BVH builds.
903
+ *
904
+ * Called from both replace-load seams: the URL/catalog path (_loadWithSceneRebuild) and
905
+ * the drag-drop / File-open path, which drives the AssetLoader directly and rebuilds via
906
+ * the 'load' event (_onAssetLoaded). Appending a model never applies it — an appended
907
+ * model must not hijack the scene's environment.
908
+ *
909
+ * Resolves to null and never rejects: a failed fetch leaves the model on the current
910
+ * environment. Returns null outright when there is nothing to apply.
911
+ * Opt out per app with `new PathTracerApp( canvas, { applySceneMetadata: false } )`.
912
+ *
913
+ * @returns {Promise<import('three').Texture|null>|null}
914
+ */
915
+ _beginSceneMetadataEnvironment() {
916
+
917
+ const env = this.sceneMetadata?.environment;
918
+ if ( ! this._applySceneMetadataEnabled || ! env?.sourceFile || ! this.stages.pathTracer ) return null;
919
+
920
+ // Claim 'hdri' mode up front: loadEnvironment() fires beforeEnvironmentLoad, and a
921
+ // host that answers it with setMode( 'hdri' ) would otherwise restore the HDRI
922
+ // stashed by the current sky mode on top of the download now in flight.
923
+ this.stages.pathTracer.environment.beginHDRI();
924
+ this._applyingSceneMetadata = true;
925
+
926
+ return this.assetLoader.loadEnvironment( env.sourceFile )
927
+ .catch( error => {
928
+
929
+ log.warn( `scene metadata: environment "${env.sourceFile}" failed to load`, error );
930
+ return null;
931
+
932
+ } )
933
+ .finally( () => {
934
+
935
+ this._applyingSceneMetadata = false;
936
+
937
+ } );
938
+
939
+ }
940
+
941
+ /**
942
+ * Mirrors the authored environment onto the settings once its texture is installed.
943
+ * The authored env is both the light and the backdrop, so the backdrop axis
944
+ * (showBackground / transparentBackground) is forced to the image.
945
+ * @private
946
+ */
947
+ _applySceneMetadataSettings( texture ) {
948
+
949
+ const metadata = this.sceneMetadata;
950
+ const env = metadata?.environment;
951
+ if ( ! env ) return;
952
+
953
+ const updates = { enableEnvironment: true, showBackground: true, transparentBackground: false };
954
+ if ( env.intensity !== undefined ) {
955
+
956
+ updates.environmentIntensity = env.intensity;
957
+ updates.backgroundIntensity = env.intensity;
958
+
959
+ }
960
+
961
+ if ( env.rotation !== undefined ) updates.environmentRotation = env.rotation;
962
+
963
+ this.settings.setMany( updates, { reset: false } );
964
+ if ( this.scene ) this.scene.background = texture;
965
+
966
+ this.dispatchEvent( { type: 'SceneMetadataApplied', metadata, environment: { ...env } } );
967
+
968
+ }
969
+
821
970
  /**
822
971
  * Builds BVH from meshScene and uploads all scene data to the path tracer.
972
+ * @param {Object} [options]
973
+ * @param {Promise<import('three').Texture|null>} [options.pendingEnvironment] - An
974
+ * environment still being fetched (see _beginSceneMetadataEnvironment). Installed and
975
+ * CDF'd concurrently with the BVH build rather than before it.
823
976
  * @returns {boolean}
824
977
  */
825
- async loadSceneData() {
978
+ async loadSceneData( { pendingEnvironment = null } = {} ) {
826
979
 
827
980
  // Clear selection before rebuilding — the old object leaves the scene graph.
828
981
  // Skipped on the append path (addModel): the selected object persists, so its
@@ -841,7 +994,23 @@ export class PathTracerApp extends EventDispatcher {
841
994
 
842
995
  // Environment CDF build in parallel with BVH
843
996
  let cdfPromise = null;
844
- if ( environmentTexture?.image?.data ) {
997
+ if ( pendingEnvironment ) {
998
+
999
+ // The authored environment is still downloading. Install + CDF it off to the side so
1000
+ // the fetch overlaps the BVH build below instead of gating it.
1001
+ timer.start( 'Environment fetch + CDF (concurrent)' );
1002
+ cdfPromise = pendingEnvironment
1003
+ .then( async texture => {
1004
+
1005
+ if ( ! texture ) return;
1006
+ await this.stages.pathTracer.environment.applyHDRI( texture );
1007
+ this._applySceneMetadataSettings( texture );
1008
+
1009
+ } )
1010
+ .catch( error => log.warn( 'scene metadata: environment install failed', error ) )
1011
+ .finally( () => timer.end( 'Environment fetch + CDF (concurrent)' ) );
1012
+
1013
+ } else if ( environmentTexture?.image?.data ) {
845
1014
 
846
1015
  timer.start( 'Environment CDF build (worker)' );
847
1016
  this.stages.pathTracer.scene.environment = environmentTexture;
@@ -861,7 +1030,9 @@ export class PathTracerApp extends EventDispatcher {
861
1030
  await new Promise( r => setTimeout( r, 0 ) );
862
1031
  timer.start( 'GPU data transfer' );
863
1032
 
864
- if ( ! this._sdf.uploadToPathTracer( this.stages.pathTracer, this.lightManager, this.meshScene, environmentTexture ) ) return false;
1033
+ // Re-read rather than reusing the snapshot above: a pendingEnvironment may have landed
1034
+ // during the BVH build, and the snapshot is then a disposed texture.
1035
+ if ( ! this._sdf.uploadToPathTracer( this.stages.pathTracer, this.lightManager, this.meshScene, this.meshScene.environment ) ) return false;
865
1036
 
866
1037
  // Patch per-mesh visibility into the TLAS leaves we just uploaded
867
1038
  this.stages.pathTracer._meshRefs = this.stages.pathTracer._collectMeshRefs( this.meshScene );
@@ -2612,11 +2783,18 @@ export class PathTracerApp extends EventDispatcher {
2612
2783
 
2613
2784
  if ( this._loadingInProgress ) return;
2614
2785
 
2786
+ // The authored-environment fetch runs through this same AssetLoader, so its 'load'
2787
+ // event lands right back here. We install that texture ourselves — without the
2788
+ // guard the texture branch below would rebuild the CDF a second time and
2789
+ // resetLoading() would tear down the overlay mid-upload. Model events still pass:
2790
+ // a user can drop another file while the environment is in flight.
2791
+ if ( this._applyingSceneMetadata && event.texture ) return;
2792
+
2615
2793
  if ( event.model ) {
2616
2794
 
2617
2795
  // Drag-drop / file load is a replace: clear any appended models first.
2618
2796
  this._clearAppendedModels();
2619
- await this.loadSceneData();
2797
+ await this.loadSceneData( { pendingEnvironment: this._beginSceneMetadataEnvironment() } );
2620
2798
 
2621
2799
  } else if ( event.texture ) {
2622
2800
 
@@ -14,6 +14,7 @@ import { disposeObjectFromMemory, updateLoading } from './utils';
14
14
  import { BuildTimer } from './BuildTimer.js';
15
15
  import { getAssetConfig } from '../AssetConfig.js';
16
16
  import { loadPBRTScene, pickEntryPath } from './PBRT/index.js';
17
+ import { extractSceneMetadata } from './SceneMetadata.js';
17
18
 
18
19
  // Define supported file formats
19
20
  const SUPPORTED_FORMATS = {
@@ -55,6 +56,10 @@ export class AssetLoader extends EventDispatcher {
55
56
  this.animations = [];
56
57
  this.renderer = null;
57
58
 
59
+ // Scene-level authoring metadata from the current model (glTF `extras`), or null.
60
+ // See SceneMetadata.js. Cleared by releaseTargetModel() on every replace-load.
61
+ this.sceneMetadata = null;
62
+
58
63
  // Shared across every loader so cancelActiveLoad() can abort whichever
59
64
  // fetch is in flight (three r185 FileLoader wires the manager's abort
60
65
  // signal into its fetch). One load runs at a time (guarded upstream).
@@ -134,6 +139,8 @@ export class AssetLoader extends EventDispatcher {
134
139
  */
135
140
  releaseTargetModel() {
136
141
 
142
+ this.sceneMetadata = null;
143
+
137
144
  if ( ! this.targetModel ) return;
138
145
 
139
146
  if ( this.targetModel === this._externalModel ) {
@@ -298,7 +305,7 @@ export class AssetLoader extends EventDispatcher {
298
305
  texture.generateMipmaps = true;
299
306
 
300
307
  this.applyEnvironmentToScene( texture );
301
- this.dispatchEvent( { type: 'load', texture } );
308
+ this.dispatchEvent( { type: 'load', texture, url: envUrl, filename: envUrl.split( /[?#]/ )[ 0 ].split( '/' ).pop() } );
302
309
  return texture;
303
310
 
304
311
  } catch ( error ) {
@@ -804,6 +811,7 @@ export class AssetLoader extends EventDispatcher {
804
811
 
805
812
  this.releaseTargetModel();
806
813
  this.targetModel = gltf.scene;
814
+ this.sceneMetadata = extractSceneMetadata( gltf );
807
815
  this.onModelLoad( this.targetModel ).then( () => resolve( gltf ) );
808
816
 
809
817
  },
@@ -1104,6 +1112,7 @@ export class AssetLoader extends EventDispatcher {
1104
1112
 
1105
1113
  this.targetModel = data.scene;
1106
1114
  this.animations = data.animations || [];
1115
+ this.sceneMetadata = extractSceneMetadata( data );
1107
1116
  await this.onModelLoad( this.targetModel );
1108
1117
  this.dispatchEvent( { type: 'load', model: data.scene, filename: modelUrl.split( '/' ).pop() } );
1109
1118
  return data;
@@ -1204,6 +1213,7 @@ export class AssetLoader extends EventDispatcher {
1204
1213
 
1205
1214
  this.targetModel = data.scene;
1206
1215
  this.animations = data.animations || [];
1216
+ this.sceneMetadata = extractSceneMetadata( data );
1207
1217
  updateLoading( { isLoading: true, status: "Processing Data...", progress: 10 } );
1208
1218
  await this.onModelLoad( this.targetModel );
1209
1219
 
@@ -0,0 +1,123 @@
1
+ /**
2
+ * SceneMetadata.js
3
+ * Scene-level authoring metadata carried inside an asset file.
4
+ *
5
+ * glTF/GLB carries it in `extras`, which three's GLTFLoader surfaces in three
6
+ * places depending on where the exporter wrote it: top-level `extras` becomes
7
+ * `gltf.userData`, `asset.extras` stays on `gltf.asset.extras`, and a scene's
8
+ * `extras` becomes `gltf.scene.userData`. All three are scanned, and the payload
9
+ * may sit at the root or be namespaced under a `rayzee` key.
10
+ *
11
+ * Recognised payload:
12
+ * {
13
+ * "environment": {
14
+ * "sourceFile": "https://.../brown_photostudio_02_1k.hdr",
15
+ * "rotation": 0, // degrees around Y, matches the environmentRotation setting
16
+ * "intensity": 1 // drives both environmentIntensity and backgroundIntensity
17
+ * }
18
+ * }
19
+ *
20
+ * Values may also arrive as JSON strings — DCC custom-property round-trips often
21
+ * stringify nested objects — so every container is parsed leniently.
22
+ */
23
+
24
+ const ENV_URL_KEYS = [ 'sourceFile', 'url', 'file' ];
25
+
26
+ function asObject( value ) {
27
+
28
+ if ( typeof value === 'string' ) {
29
+
30
+ try {
31
+
32
+ const parsed = JSON.parse( value );
33
+ return ( parsed && typeof parsed === 'object' ) ? parsed : null;
34
+
35
+ } catch {
36
+
37
+ return null;
38
+
39
+ }
40
+
41
+ }
42
+
43
+ return ( value && typeof value === 'object' ) ? value : null;
44
+
45
+ }
46
+
47
+ function asNumber( value ) {
48
+
49
+ const n = typeof value === 'string' ? parseFloat( value ) : value;
50
+ return Number.isFinite( n ) ? n : undefined;
51
+
52
+ }
53
+
54
+ function pickPayload( container ) {
55
+
56
+ const obj = asObject( container );
57
+ if ( ! obj ) return null;
58
+
59
+ const scoped = asObject( obj.rayzee );
60
+ if ( scoped && scoped.environment !== undefined ) return scoped;
61
+
62
+ return obj.environment !== undefined ? obj : null;
63
+
64
+ }
65
+
66
+ function normalizeEnvironment( raw ) {
67
+
68
+ const env = asObject( raw );
69
+ if ( ! env ) return null;
70
+
71
+ const sourceFile = ENV_URL_KEYS
72
+ .map( key => env[ key ] )
73
+ .find( value => typeof value === 'string' && value.trim() );
74
+ if ( ! sourceFile ) return null;
75
+
76
+ const rotation = asNumber( env.rotation );
77
+ const intensity = asNumber( env.intensity );
78
+
79
+ const normalized = { sourceFile: sourceFile.trim() };
80
+ if ( rotation !== undefined ) normalized.rotation = rotation;
81
+ if ( intensity !== undefined ) normalized.intensity = Math.max( 0, intensity );
82
+
83
+ return normalized;
84
+
85
+ }
86
+
87
+ /**
88
+ * Reads scene metadata out of one `extras`-shaped container.
89
+ * @param {Object|string|null} container
90
+ * @returns {{ environment?: { sourceFile: string, rotation?: number, intensity?: number } }|null}
91
+ */
92
+ export function parseSceneMetadata( container ) {
93
+
94
+ const payload = pickPayload( container );
95
+ if ( ! payload ) return null;
96
+
97
+ const environment = normalizeEnvironment( payload.environment );
98
+ return environment ? { environment } : null;
99
+
100
+ }
101
+
102
+ /**
103
+ * Reads scene metadata out of a GLTFLoader parse result.
104
+ * @param {Object} gltf - The object GLTFLoader resolves with
105
+ * @returns {{ environment?: { sourceFile: string, rotation?: number, intensity?: number } }|null}
106
+ */
107
+ export function extractSceneMetadata( gltf ) {
108
+
109
+ if ( ! gltf ) return null;
110
+
111
+ const containers = [ gltf.userData, gltf.asset?.extras, gltf.scene?.userData ];
112
+ if ( Array.isArray( gltf.scenes ) ) containers.push( ...gltf.scenes.map( scene => scene?.userData ) );
113
+
114
+ for ( const container of containers ) {
115
+
116
+ const metadata = parseSceneMetadata( container );
117
+ if ( metadata ) return metadata;
118
+
119
+ }
120
+
121
+ return null;
122
+
123
+ }
package/src/index.js CHANGED
@@ -17,6 +17,9 @@ export { EngineEvents } from './EngineEvents.js';
17
17
  // Asset URL / cache namespace overrides (call before constructing PathTracerApp)
18
18
  export { configureAssets, getAssetConfig } from './AssetConfig.js';
19
19
 
20
+ // Scene-level authoring metadata embedded in model files (glTF `extras`)
21
+ export { extractSceneMetadata, parseSceneMetadata } from './Processor/SceneMetadata.js';
22
+
20
23
  // Logging — leveled/namespaced console output shared with the workers
21
24
  export { Logger, createLogger, fmt, LOG_LEVELS } from './utils/Logger.js';
22
25
 
@@ -360,8 +360,11 @@ export class EnvironmentManager {
360
360
  /**
361
361
  * Set environment map, build CDF, and update shader texture nodes.
362
362
  * @param {import('three').Texture|null} envMap
363
+ * @param {Object} [options]
364
+ * @param {boolean} [options.buildCDF=true] - Skip when the caller builds the CDF itself
365
+ * (PathTracerApp.loadSceneData builds it in parallel with the BVH, then calls applyCDFResults).
363
366
  */
364
- async setEnvironmentMap( envMap ) {
367
+ async setEnvironmentMap( envMap, { buildCDF = true } = {} ) {
365
368
 
366
369
  // Free the outgoing env texture's GPU memory before it is orphaned. Skip: the
367
370
  // reusable placeholder, an idempotent re-set of the same texture, and the HDRI
@@ -379,7 +382,7 @@ export class EnvironmentManager {
379
382
 
380
383
  if ( envMap ) {
381
384
 
382
- await this.buildEnvironmentCDF();
385
+ if ( buildCDF ) await this.buildEnvironmentCDF();
383
386
 
384
387
  } else {
385
388
 
@@ -411,6 +414,35 @@ export class EnvironmentManager {
411
414
 
412
415
  }
413
416
 
417
+ /**
418
+ * Enter 'hdri' mode ahead of an async HDRI install, and drop the texture stashed by a
419
+ * previous setMode( 'gradient' | 'color' | 'procedural' ): a new HDRI makes it
420
+ * unreachable, so leaving it in place both leaks it and lets a setMode( 'hdri' )
421
+ * arriving mid-download restore it over the map we are about to install.
422
+ * @param {import('three').Texture} [incoming] - Never disposed, even if it is the stash.
423
+ */
424
+ beginHDRI( incoming = null ) {
425
+
426
+ if ( this._previousHDRI && this._previousHDRI !== incoming ) this._previousHDRI.dispose?.();
427
+
428
+ this._previousHDRI = null;
429
+ this.envParams.mode = 'hdri';
430
+
431
+ }
432
+
433
+ /**
434
+ * Install an HDRI as the active environment, switching the mode state machine to 'hdri'.
435
+ * @param {import('three').Texture} envMap
436
+ * @param {Object} [options]
437
+ * @param {boolean} [options.buildCDF=true]
438
+ */
439
+ async applyHDRI( envMap, { buildCDF = true } = {} ) {
440
+
441
+ this.beginHDRI( envMap );
442
+ await this.setEnvironmentMap( envMap, { buildCDF } );
443
+
444
+ }
445
+
414
446
  // ===== SKY GENERATORS =====
415
447
 
416
448
  /**