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.
@@ -1,4 +1,4 @@
1
- import { Box3, Vector3, RectAreaLight, Color, FloatType, LinearFilter, EquirectangularReflectionMapping,
1
+ import { Box3, BufferGeometry, Vector3, RectAreaLight, Color, FloatType, LinearFilter, EquirectangularReflectionMapping,
2
2
  TextureLoader, Texture, SRGBColorSpace, RepeatWrapping, Mesh, MeshStandardMaterial, MeshPhysicalMaterial,
3
3
  CircleGeometry, Points, PointsMaterial, LoadingManager, EventDispatcher
4
4
  } from 'three';
@@ -8,12 +8,14 @@ import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
8
8
  import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js';
9
9
  import { EXRLoader } from 'three/addons/loaders/EXRLoader.js';
10
10
  import { createMeshesFromMultiMaterialMesh } from 'three/addons/utils/SceneUtils.js';
11
+ import { clone as cloneWithSkeletons } from 'three/addons/utils/SkeletonUtils.js';
11
12
  import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js';
12
13
  import { unzipSync, zipSync, strFromU8 } from 'three/addons/libs/fflate.module.js';
13
- import { disposeObjectFromMemory, updateLoading } from './utils';
14
+ import { disposeEngineOwnedResources, disposeObjectFromMemory, updateLoading } from './utils';
14
15
  import { BuildTimer } from './BuildTimer.js';
15
16
  import { getAssetConfig } from '../AssetConfig.js';
16
17
  import { loadPBRTScene, pickEntryPath } from './PBRT/index.js';
18
+ import { extractSceneMetadata } from './SceneMetadata.js';
17
19
 
18
20
  // Define supported file formats
19
21
  const SUPPORTED_FORMATS = {
@@ -35,6 +37,18 @@ const SUPPORTED_FORMATS = {
35
37
  // layers and the image assets they reference.
36
38
  const USD_LAYER_RE = /\.(usd|usda|usdc)$/i;
37
39
  const USD_IMAGE_RE = /\.(png|jpg|jpeg|avif)$/i;
40
+ // A throwaway stand-in for a geometry the engine must not mutate: the split's mergeGroups()
41
+ // reorders and disposes what it is given, but never writes the attributes.
42
+ function standInForSplit( source ) {
43
+
44
+ const geometry = new BufferGeometry();
45
+ for ( const name in source.attributes ) geometry.setAttribute( name, source.attributes[ name ] );
46
+ if ( source.index ) geometry.setIndex( source.index );
47
+ for ( const group of source.groups ) geometry.addGroup( group.start, group.count, group.materialIndex );
48
+ return geometry;
49
+
50
+ }
51
+
38
52
  /**
39
53
  * AssetLoader class - handles loading of 3D models, environment maps, and archives
40
54
  */
@@ -47,7 +61,6 @@ export class AssetLoader extends EventDispatcher {
47
61
  this.camera = camera;
48
62
  this.controls = controls;
49
63
  this.targetModel = null;
50
- this._externalModel = null;
51
64
  this.floorPlane = null;
52
65
  this.sceneScale = 1.0;
53
66
  this.loaderCache = {};
@@ -55,6 +68,10 @@ export class AssetLoader extends EventDispatcher {
55
68
  this.animations = [];
56
69
  this.renderer = null;
57
70
 
71
+ // Scene-level authoring metadata from the current model (glTF `extras`), or null.
72
+ // See SceneMetadata.js. Cleared by releaseTargetModel() on every replace-load.
73
+ this.sceneMetadata = null;
74
+
58
75
  // Shared across every loader so cancelActiveLoad() can abort whichever
59
76
  // fetch is in flight (three r185 FileLoader wires the manager's abort
60
77
  // signal into its fetch). One load runs at a time (guarded upstream).
@@ -127,27 +144,58 @@ export class AssetLoader extends EventDispatcher {
127
144
  }
128
145
 
129
146
  /**
130
- * Releases the current targetModel. If it was supplied by the caller via
131
- * loadObject3D(), we only detach it from its parent — the caller still owns
132
- * that Object3D and may reuse it. Otherwise we disposeObjectFromMemory() to
133
- * free geometry/material/texture GPU resources.
147
+ * Deep-clones a caller-owned Object3D so the engine never mutates the host's tree.
148
+ * Geometry/material/texture ride along by reference, so release frees only what the engine
149
+ * allocated — see removeModelRoot().
150
+ *
151
+ * @param {import('three').Object3D} object3d - the caller's object; left untouched.
152
+ * @returns {import('three').Object3D} the engine-owned copy.
134
153
  */
135
- releaseTargetModel() {
154
+ _adoptExternalObject( object3d ) {
136
155
 
137
- if ( ! this.targetModel ) return;
156
+ // Not Object3D.clone(): that leaves SkinnedMeshes bound to the source's bones.
157
+ let model;
158
+ try {
138
159
 
139
- if ( this.targetModel === this._externalModel ) {
160
+ model = cloneWithSkeletons( object3d );
140
161
 
141
- this.targetModel.parent?.remove( this.targetModel );
162
+ } catch ( error ) {
142
163
 
143
- } else {
164
+ // Object3D.copy() round-trips userData through JSON, so a back-reference throws.
165
+ throw new Error(
166
+ `Cannot render "${object3d.name || object3d.type}": its userData must be JSON-serializable.`,
167
+ { cause: error }
168
+ );
169
+
170
+ }
171
+
172
+ model.userData.__rayzeeExternal = true;
173
+
174
+ // Carried through as the scene-object id, unless a second copy already took it.
175
+ if ( ! this.scene?.children.some( c => c.uuid === object3d.uuid ) ) model.uuid = object3d.uuid;
144
176
 
145
- disposeObjectFromMemory( this.targetModel );
177
+ // The copy sits under the engine's identity root, which keeps only a local transform.
178
+ if ( object3d.parent ) {
179
+
180
+ object3d.parent.updateWorldMatrix( true, false );
181
+ model.applyMatrix4( object3d.parent.matrixWorld );
146
182
 
147
183
  }
148
184
 
185
+ return model;
186
+
187
+ }
188
+
189
+ /** Releases the current targetModel. See removeModelRoot() for what gets freed. */
190
+ releaseTargetModel() {
191
+
192
+ this.sceneMetadata = null;
193
+
194
+ if ( ! this.targetModel ) return;
195
+
196
+ this.removeModelRoot( this.targetModel );
197
+
149
198
  this.targetModel = null;
150
- this._externalModel = null;
151
199
  // Drop the released model's animation clips so a later rebuild doesn't rebind
152
200
  // a mixer to disposed nodes. Every load path re-populates this.animations after.
153
201
  this.animations = [];
@@ -298,7 +346,7 @@ export class AssetLoader extends EventDispatcher {
298
346
  texture.generateMipmaps = true;
299
347
 
300
348
  this.applyEnvironmentToScene( texture );
301
- this.dispatchEvent( { type: 'load', texture } );
349
+ this.dispatchEvent( { type: 'load', texture, url: envUrl, filename: envUrl.split( /[?#]/ )[ 0 ].split( '/' ).pop() } );
302
350
  return texture;
303
351
 
304
352
  } catch ( error ) {
@@ -804,6 +852,7 @@ export class AssetLoader extends EventDispatcher {
804
852
 
805
853
  this.releaseTargetModel();
806
854
  this.targetModel = gltf.scene;
855
+ this.sceneMetadata = extractSceneMetadata( gltf );
807
856
  this.onModelLoad( this.targetModel ).then( () => resolve( gltf ) );
808
857
 
809
858
  },
@@ -1104,6 +1153,7 @@ export class AssetLoader extends EventDispatcher {
1104
1153
 
1105
1154
  this.targetModel = data.scene;
1106
1155
  this.animations = data.animations || [];
1156
+ this.sceneMetadata = extractSceneMetadata( data );
1107
1157
  await this.onModelLoad( this.targetModel );
1108
1158
  this.dispatchEvent( { type: 'load', model: data.scene, filename: modelUrl.split( '/' ).pop() } );
1109
1159
  return data;
@@ -1171,21 +1221,32 @@ export class AssetLoader extends EventDispatcher {
1171
1221
 
1172
1222
  }
1173
1223
 
1174
- // Append a caller-owned Object3D without releasing prior models or reframing.
1224
+ // Append a copy of a caller-owned Object3D without releasing prior models or reframing.
1175
1225
  appendObject3D( object3d, name = 'object3d' ) {
1176
1226
 
1177
- object3d.name = object3d.name || name;
1178
- this._processAndParent( object3d );
1179
- return { root: object3d };
1227
+ const root = this._adoptExternalObject( object3d );
1228
+ root.name = object3d.name || name;
1229
+ this._processAndParent( root );
1230
+ return { root };
1180
1231
 
1181
1232
  }
1182
1233
 
1183
- // Detach + dispose an appended root. External (caller-owned) roots are only detached.
1184
- removeModelRoot( root, { external = false } = {} ) {
1234
+ // Detach + dispose a model root. An adopted root's geometry/materials belong to the
1235
+ // caller, so only the engine's own allocations go.
1236
+ removeModelRoot( root ) {
1185
1237
 
1186
1238
  if ( ! root ) return;
1187
- if ( external ) root.parent?.remove( root );
1188
- else disposeObjectFromMemory( root );
1239
+
1240
+ if ( root.userData.__rayzeeExternal ) {
1241
+
1242
+ disposeEngineOwnedResources( root );
1243
+ root.parent?.remove( root );
1244
+
1245
+ } else {
1246
+
1247
+ disposeObjectFromMemory( root );
1248
+
1249
+ }
1189
1250
 
1190
1251
  }
1191
1252
 
@@ -1204,6 +1265,7 @@ export class AssetLoader extends EventDispatcher {
1204
1265
 
1205
1266
  this.targetModel = data.scene;
1206
1267
  this.animations = data.animations || [];
1268
+ this.sceneMetadata = extractSceneMetadata( data );
1207
1269
  updateLoading( { isLoading: true, status: "Processing Data...", progress: 10 } );
1208
1270
  await this.onModelLoad( this.targetModel );
1209
1271
 
@@ -1500,17 +1562,18 @@ export class AssetLoader extends EventDispatcher {
1500
1562
 
1501
1563
  async loadObject3D( object3d, name = 'object3d' ) {
1502
1564
 
1503
- object3d.name = object3d.name || name;
1504
-
1505
1565
  this.releaseTargetModel();
1506
- this.targetModel = object3d;
1507
- this._externalModel = object3d;
1566
+
1567
+ const model = this._adoptExternalObject( object3d );
1568
+ model.name = object3d.name || name;
1569
+
1570
+ this.targetModel = model;
1508
1571
 
1509
1572
  updateLoading( { isLoading: true, status: "Processing Data...", progress: 10 } );
1510
1573
  await this.onModelLoad( this.targetModel );
1511
1574
 
1512
- this.dispatchEvent( { type: 'load', model: object3d, filename: name } );
1513
- return object3d;
1575
+ this.dispatchEvent( { type: 'load', model, filename: name } );
1576
+ return model;
1514
1577
 
1515
1578
  }
1516
1579
 
@@ -1646,6 +1709,9 @@ export class AssetLoader extends EventDispatcher {
1646
1709
  processModelObjects( model ) {
1647
1710
 
1648
1711
  let visitedAreaLights = [];
1712
+ // Split after the walk: traverse() caches children.length, so splitting in place
1713
+ // shifts later siblings down a slot and skips one.
1714
+ const multiMaterialMeshes = [];
1649
1715
  model.traverse( ( object ) => {
1650
1716
 
1651
1717
  const userData = object.userData;
@@ -1699,19 +1765,29 @@ export class AssetLoader extends EventDispatcher {
1699
1765
  // Handle multi-material meshes
1700
1766
  if ( object.isMesh && Array.isArray( object.material ) ) {
1701
1767
 
1702
- console.log( 'Found multi-material mesh:', object.name );
1703
- const group = createMeshesFromMultiMaterialMesh( object );
1768
+ multiMaterialMeshes.push( object );
1704
1769
 
1705
- if ( object.parent ) {
1770
+ }
1706
1771
 
1707
- object.parent.add( group );
1708
- object.parent.remove( object );
1772
+ } );
1709
1773
 
1710
- }
1774
+ const shared = model.userData.__rayzeeExternal === true;
1711
1775
 
1712
- }
1776
+ for ( const object of multiMaterialMeshes ) {
1713
1777
 
1714
- } );
1778
+ if ( ! object.parent ) continue;
1779
+
1780
+ console.log( 'Found multi-material mesh:', object.name );
1781
+ if ( shared ) object.geometry = standInForSplit( object.geometry );
1782
+
1783
+ const group = createMeshesFromMultiMaterialMesh( object );
1784
+ // Fresh geometry per group; tag it so an adopted model's release can free it.
1785
+ for ( const child of group.children ) child.geometry.userData.__rayzeeOwned = true;
1786
+
1787
+ object.parent.add( group );
1788
+ object.parent.remove( object );
1789
+
1790
+ }
1715
1791
 
1716
1792
  }
1717
1793
 
@@ -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
+ }
@@ -177,6 +177,17 @@ export function disposeObjectFromMemory( object, exeptions = [] ) {
177
177
 
178
178
  }
179
179
 
180
+ /** Frees only geometry the engine allocated inside an adopted model; the rest is the host's. */
181
+ export function disposeEngineOwnedResources( object ) {
182
+
183
+ object?.traverse( ( child ) => {
184
+
185
+ if ( child.geometry?.userData?.__rayzeeOwned ) child.geometry.dispose();
186
+
187
+ } );
188
+
189
+ }
190
+
180
191
 
181
192
 
182
193
  /**
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
  /**