rayzee 9.0.0 → 9.1.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.
Files changed (95) hide show
  1. package/README.md +139 -8
  2. package/dist/{NeuralRetouch-CYOJ21Wn.js → NeuralRetouch-BRquqfmZ.js} +1 -1
  3. package/dist/{NeuralSuperRes-CQLCLpcC.js → NeuralSuperRes-CbNvQjCw.js} +3 -2
  4. package/dist/{ToneMapGPU-BVE852iN.js → ToneMapGPU-DpUZDMbQ.js} +2 -1
  5. package/dist/rayzee.es.js +10353 -5337
  6. package/dist/rayzee.umd.js +265 -265
  7. package/package.json +1 -1
  8. package/src/AssetConfig.js +4 -0
  9. package/src/EngineDefaults.js +38 -56
  10. package/src/EngineEvents.js +3 -0
  11. package/src/EngineIssues.js +6 -0
  12. package/src/Headless.js +3 -1
  13. package/src/Passes/AIUpscaler.js +2 -1
  14. package/src/Passes/OIDNDenoiser.js +3 -6
  15. package/src/PathTracerApp.js +290 -19
  16. package/src/Processor/ArchiveCache.js +120 -0
  17. package/src/Processor/ArchiveReader.js +179 -32
  18. package/src/Processor/AssetLoader.js +514 -76
  19. package/src/Processor/AtmosphereModel.js +465 -0
  20. package/src/Processor/BVHBuilder.js +4 -1
  21. package/src/Processor/BVHLeafFold.js +100 -0
  22. package/src/Processor/BVHRefitter.js +98 -214
  23. package/src/Processor/ChunkedRecords.js +189 -4
  24. package/src/Processor/EmissiveTriangleBuilder.js +8 -0
  25. package/src/Processor/GeometryExtractor.js +238 -40
  26. package/src/Processor/InstanceTable.js +55 -37
  27. package/src/Processor/KernelManager.js +4 -1
  28. package/src/Processor/PBRT/PBRTCurves.js +104 -27
  29. package/src/Processor/PBRT/PBRTParser.js +132 -22
  30. package/src/Processor/PBRT/PBRTSceneBuilder.js +192 -49
  31. package/src/Processor/PBRT/buffers.js +27 -0
  32. package/src/Processor/PBRT/index.js +55 -15
  33. package/src/Processor/PackedRayBuffer.js +10 -13
  34. package/src/Processor/ParallelBVHBuilder.js +36 -15
  35. package/src/Processor/PhysicalSky.js +308 -0
  36. package/src/Processor/QueueManager.js +1 -0
  37. package/src/Processor/SceneProcessor.js +1132 -181
  38. package/src/Processor/SplitBLAS.js +249 -0
  39. package/src/Processor/SunPosition.js +41 -0
  40. package/src/Processor/TextureCreator.js +21 -0
  41. package/src/Processor/Workers/AIUpscalerWorker.js +79 -52
  42. package/src/Processor/Workers/BVHWorker.js +17 -13
  43. package/src/Processor/ZipReader.js +207 -0
  44. package/src/RenderSettings.js +38 -0
  45. package/src/SceneState/SceneState.js +367 -0
  46. package/src/SceneState/portable.js +52 -0
  47. package/src/Stages/NormalDepth.js +7 -6
  48. package/src/Stages/PathTracer.js +108 -4
  49. package/src/Stages/PathTracerStage.js +251 -104
  50. package/src/Storage/BLASCache.js +190 -0
  51. package/src/Storage/CDFCache.js +77 -0
  52. package/src/Storage/DownloadCache.js +266 -0
  53. package/src/Storage/GeometrySpill.js +217 -0
  54. package/src/Storage/SceneGraphCodec.js +503 -0
  55. package/src/Storage/SpillStore.js +112 -0
  56. package/src/Storage/StorageManager.js +934 -0
  57. package/src/Storage/StorageOps.js +137 -0
  58. package/src/Storage/StorageWorker.js +37 -0
  59. package/src/Storage/events.js +29 -0
  60. package/src/Storage/identity.js +77 -0
  61. package/src/Storage/index.js +9 -0
  62. package/src/Storage/inlineTransport.js +24 -0
  63. package/src/Storage/locks.js +99 -0
  64. package/src/Storage/openStorage.js +87 -0
  65. package/src/Storage/sceneCachePolicy.js +12 -0
  66. package/src/Storage/shared.js +9 -0
  67. package/src/Storage/transport.js +62 -0
  68. package/src/TSL/Atmosphere.js +684 -0
  69. package/src/TSL/BVHTraversal.js +288 -239
  70. package/src/TSL/Common.js +11 -2
  71. package/src/TSL/Displacement.js +6 -6
  72. package/src/TSL/EmissiveSampling.js +5 -5
  73. package/src/TSL/EnvironmentCDF.js +176 -0
  74. package/src/TSL/GenerateKernel.js +3 -3
  75. package/src/TSL/HitFacet.js +4 -4
  76. package/src/TSL/LightsDirect.js +8 -8
  77. package/src/TSL/LightsSampling.js +55 -37
  78. package/src/TSL/Random.js +1 -0
  79. package/src/TSL/ShadeKernel.js +75 -19
  80. package/src/TSL/Sun.js +40 -0
  81. package/src/TSL/TextureSampling.js +6 -7
  82. package/src/index.js +18 -0
  83. package/src/managers/CameraManager.js +169 -1
  84. package/src/managers/EnvironmentManager.js +263 -33
  85. package/src/managers/GoboManager.js +19 -8
  86. package/src/managers/IESManager.js +3 -3
  87. package/src/managers/LightManager.js +98 -5
  88. package/src/managers/MaterialDataManager.js +22 -2
  89. package/src/managers/TransformManager.js +26 -0
  90. package/src/managers/UniformManager.js +5 -3
  91. package/src/managers/VideoRenderManager.js +3 -1
  92. package/src/managers/timeline/CameraTrack.js +22 -0
  93. package/src/managers/timeline/TimelineManager.js +14 -0
  94. package/src/version.js +3 -0
  95. package/src/Processor/ProceduralSky.js +0 -291
@@ -11,31 +11,51 @@ import {
11
11
  RGBAFormat, RedFormat, FloatType, LinearFilter, Vector2, Vector3, Color, Matrix4, DataTexture,
12
12
  } from 'three';
13
13
  import { EquirectHDRInfo } from '../Processor/EquirectHDRInfo.js';
14
- import { ProceduralSky } from '../Processor/ProceduralSky.js';
14
+ import { PhysicalSky } from '../Processor/PhysicalSky.js';
15
15
  import { SimpleSky } from '../Processor/SimpleSky.js';
16
+ import { convertLinearTriple } from '../Color/WorkingMatrix.js';
16
17
  import { createLogger, fmt } from '../utils/Logger.js';
17
18
 
18
19
  const log = createLogger( 'env' );
19
20
  import { ENGINE_DEFAULTS as DEFAULT_STATE } from '../EngineDefaults.js';
20
21
  import { getActiveColorManagement } from '../Color/ColorManagement.js';
22
+ import { loadCDF, saveCDF } from '../Storage/CDFCache.js';
23
+
24
+ const SKY_WIDTH = 1024;
25
+ const SKY_HEIGHT = 512;
26
+ const DEG2RAD = Math.PI / 180;
27
+ const _rotation = new Matrix4();
28
+
29
+ // Bakes queued on the GPU at once. More only queue behind frames already waiting there.
30
+ const SKY_BAKES_IN_FLIGHT = 4;
21
31
 
22
32
  export class EnvironmentManager {
23
33
 
24
34
  /**
25
35
  * @param {Object} scene - Three.js scene
26
36
  * @param {import('./UniformManager').UniformManager} uniforms
37
+ * @param {import('three/webgpu').WebGPURenderer} [renderer] - bakes the physical sky
27
38
  */
28
- constructor( scene, uniforms ) {
39
+ constructor( scene, uniforms, renderer = null ) {
29
40
 
30
41
  this.scene = scene;
31
42
  this.uniforms = uniforms;
43
+ this.renderer = renderer;
32
44
 
33
45
  // CDF computation engine
34
46
  this.equirectHdrInfo = new EquirectHDRInfo();
35
47
 
36
48
  // Sky renderers (lazy init)
37
- this.proceduralSkyRenderer = null;
49
+ this.physicalSky = null;
38
50
  this.simpleSkyRenderer = null;
51
+ this._sun = null;
52
+ this._skyRequested = false;
53
+ this._skyScheduled = false;
54
+ this._skyInFlight = 0;
55
+ this._skyCaughtUp = null;
56
+ this._resolveSky = null;
57
+ this._skyBakes = 0;
58
+ this._skyStatsBake = 0;
39
59
 
40
60
  // Environment texture — 1×1 black placeholder for shader compilation. Filters must be
41
61
  // Linear: DataTexture defaults to Nearest, which is unfilterable, and WGSLNodeBuilder then
@@ -71,12 +91,15 @@ export class EnvironmentManager {
71
91
  // Solid Color Sky
72
92
  solidSkyColor: new Color( DEFAULT_STATE.solidSkyColor ),
73
93
 
74
- // Procedural Sky (Preetham Model)
94
+ // Physical sky. The sun direction is in the sky's own frame; environment rotation turns both.
75
95
  skySunDirection: this._calculateInitialSunDirection(),
76
- skySunIntensity: DEFAULT_STATE.skySunIntensity,
77
- skyRayleighDensity: DEFAULT_STATE.skyRayleighDensity,
96
+ skySunStrength: DEFAULT_STATE.skySunStrength,
97
+ skySunSize: DEFAULT_STATE.skySunSize,
78
98
  skyTurbidity: DEFAULT_STATE.skyTurbidity,
79
- skyMieAnisotropy: DEFAULT_STATE.skyMieAnisotropy,
99
+ skyOzone: DEFAULT_STATE.skyOzone,
100
+ skyAirDensity: DEFAULT_STATE.skyAirDensity,
101
+ skyGroundAlbedo: new Color( DEFAULT_STATE.skyGroundAlbedo ),
102
+ skyAltitude: DEFAULT_STATE.skyAltitude,
80
103
  };
81
104
 
82
105
  /**
@@ -93,7 +116,7 @@ export class EnvironmentManager {
93
116
  // ===== MODE STATE MACHINE =====
94
117
 
95
118
  /**
96
- * Switches the environment mode (hdri, gradient, color, procedural).
119
+ * Switches the environment mode (hdri, gradient, color, procedural — the physical sky).
97
120
  * Preserves the HDRI texture when switching away, restores when switching back.
98
121
  * @param {'hdri'|'gradient'|'color'|'procedural'} mode
99
122
  */
@@ -128,6 +151,8 @@ export class EnvironmentManager {
128
151
 
129
152
  }
130
153
 
154
+ if ( mode !== 'procedural' && prev === 'procedural' ) this._releaseSky();
155
+
131
156
  this.markDirty();
132
157
  this.callbacks.onAutoExposureReset?.();
133
158
  this._notifyReset();
@@ -143,6 +168,71 @@ export class EnvironmentManager {
143
168
 
144
169
  }
145
170
 
171
+ // ===== SAVED SESSIONS =====
172
+
173
+ /** Where the HDRI came from (see Storage/CDFCache.js), shown or held while a sky is. */
174
+ get hdriSource() {
175
+
176
+ const hdri = this.envParams.mode === 'hdri' ? this.environmentTexture : this._previousHDRI;
177
+ return hdri?.userData?.__rayzeeSource ?? null;
178
+
179
+ }
180
+
181
+ /** The mode, the sky parameters and the HDRI's source, as plain data. */
182
+ serialize() {
183
+
184
+ const p = this.envParams;
185
+ return {
186
+ mode: p.mode,
187
+ hdri: this.hdriSource,
188
+ gradientZenithColor: p.gradientZenithColor.toArray(),
189
+ gradientHorizonColor: p.gradientHorizonColor.toArray(),
190
+ gradientGroundColor: p.gradientGroundColor.toArray(),
191
+ solidSkyColor: p.solidSkyColor.toArray(),
192
+ skyModel: 'atmosphere',
193
+ skySunDirection: p.skySunDirection.toArray(),
194
+ skySunStrength: p.skySunStrength,
195
+ skySunSize: p.skySunSize,
196
+ skyTurbidity: p.skyTurbidity,
197
+ skyOzone: p.skyOzone,
198
+ skyAirDensity: p.skyAirDensity,
199
+ skyGroundAlbedo: p.skyGroundAlbedo.toArray(),
200
+ skyAltitude: p.skyAltitude,
201
+ };
202
+
203
+ }
204
+
205
+ /**
206
+ * Puts back {@link serialize}'s parameters and mode. The HDRI itself is the caller's to load
207
+ * first: only it can resolve the source.
208
+ */
209
+ async restore( state ) {
210
+
211
+ if ( ! state ) return;
212
+ const p = this.envParams;
213
+ // Sessions saved before the physical sky gave the same names other meanings.
214
+ const atmosphere = state.skyModel === 'atmosphere';
215
+ const colors = [ 'gradientZenithColor', 'gradientHorizonColor', 'gradientGroundColor', 'solidSkyColor', 'skySunDirection' ];
216
+ for ( const key of atmosphere ? [ ...colors, 'skyGroundAlbedo' ] : colors ) {
217
+
218
+ if ( Array.isArray( state[ key ] ) ) p[ key ].fromArray( state[ key ] );
219
+
220
+ }
221
+
222
+ if ( atmosphere ) {
223
+
224
+ for ( const key of [ 'skySunStrength', 'skySunSize', 'skyTurbidity', 'skyOzone', 'skyAirDensity', 'skyAltitude' ] ) {
225
+
226
+ if ( typeof state[ key ] === 'number' ) p[ key ] = state[ key ];
227
+
228
+ }
229
+
230
+ }
231
+
232
+ if ( state.mode && ( state.mode !== 'hdri' || p.mode !== 'hdri' ) ) await this.setMode( state.mode );
233
+
234
+ }
235
+
146
236
  // ===== Aliases (match Sub-API surface for zero-churn migration) =====
147
237
 
148
238
  /** @see envParams */
@@ -219,7 +309,7 @@ export class EnvironmentManager {
219
309
 
220
310
  }
221
311
 
222
- this.envCDFTexture?.dispose?.();
312
+ if ( ! this.envCDFTexture?._isPhysicalSky ) this.envCDFTexture?.dispose?.();
223
313
  this.envCDFTexture = new DataTexture( data, texW, H, RedFormat, FloatType );
224
314
  this.envCDFTexture.needsUpdate = true;
225
315
 
@@ -280,6 +370,7 @@ export class EnvironmentManager {
280
370
  const rotationRadians = rotationDegrees * ( Math.PI / 180 );
281
371
  this.environmentRotationMatrix.makeRotationY( rotationRadians );
282
372
  this.uniforms.get( 'environmentMatrix' ).value.copy( this.environmentRotationMatrix );
373
+ this.refreshSun();
283
374
 
284
375
  }
285
376
 
@@ -294,6 +385,7 @@ export class EnvironmentManager {
294
385
 
295
386
  if ( ! this.scene.environment ) {
296
387
 
388
+ this._cdfSignature = null;
297
389
  this._updateCDFTexture();
298
390
  this.uniforms.set( 'envTotalSum', 0.0 );
299
391
  this.uniforms.set( 'envCompensationDelta', 0.0 );
@@ -301,6 +393,9 @@ export class EnvironmentManager {
301
393
 
302
394
  }
303
395
 
396
+ // Its table is built with it, on the GPU.
397
+ if ( this.scene.environment._isPhysicalSky ) return;
398
+
304
399
  try {
305
400
 
306
401
  const startTime = performance.now();
@@ -308,6 +403,7 @@ export class EnvironmentManager {
308
403
 
309
404
  if ( ! textureForCDF.image ) {
310
405
 
406
+ this._cdfSignature = null;
311
407
  this._updateCDFTexture();
312
408
  this.uniforms.set( 'envTotalSum', 0.0 );
313
409
  this.uniforms.set( 'envCompensationDelta', 0.0 );
@@ -315,7 +411,16 @@ export class EnvironmentManager {
315
411
 
316
412
  }
317
413
 
318
- if ( useWorker ) {
414
+ // The same pixels as last time: the tables and uniforms already describe them.
415
+ const signature = `${textureForCDF.uuid}:${textureForCDF.version}`;
416
+ if ( signature === this._cdfSignature ) return;
417
+
418
+ const cached = await loadCDF( textureForCDF ).catch( () => null );
419
+ if ( cached ) {
420
+
421
+ Object.assign( this.equirectHdrInfo, cached );
422
+
423
+ } else if ( useWorker ) {
319
424
 
320
425
  await this.equirectHdrInfo.updateFromAsync( textureForCDF );
321
426
 
@@ -325,6 +430,8 @@ export class EnvironmentManager {
325
430
 
326
431
  }
327
432
 
433
+ if ( ! cached ) saveCDF( textureForCDF, this.equirectHdrInfo ).catch( () => {} );
434
+ this._cdfSignature = signature;
328
435
  this.cdfBuildTime = performance.now() - startTime;
329
436
 
330
437
  this._updateCDFTexture();
@@ -340,11 +447,12 @@ export class EnvironmentManager {
340
447
 
341
448
  log.info( fmt.list( [
342
449
  fmt.px( this.envTexSize.x, this.envTexSize.y ),
343
- `CDF ${fmt.ms( this.cdfBuildTime )}${useWorker ? '' : ' (main thread)'}`,
450
+ `CDF ${fmt.ms( this.cdfBuildTime )}${cached ? ' (stored)' : useWorker ? '' : ' (main thread)'}`,
344
451
  ] ) );
345
452
 
346
453
  } catch ( error ) {
347
454
 
455
+ this._cdfSignature = null;
348
456
  log.error( 'CDF build failed:', error );
349
457
  this.uniforms.set( 'envTotalSum', 0.0 );
350
458
  this.uniforms.set( 'envCompensationDelta', 0.0 );
@@ -391,7 +499,7 @@ export class EnvironmentManager {
391
499
  // stashed by setMode() for a later sky→hdri restore. Only when actually
392
500
  // installing a new non-null texture (the null-env branch keeps the old ref).
393
501
  const oldTex = this.environmentTexture;
394
- if ( envMap && oldTex && oldTex !== envMap && oldTex !== this._envPlaceholder && oldTex !== this._previousHDRI ) {
502
+ if ( envMap && oldTex && oldTex !== envMap && oldTex !== this._envPlaceholder && oldTex !== this._previousHDRI && ! oldTex._isPhysicalSky ) {
395
503
 
396
504
  oldTex.dispose?.();
397
505
 
@@ -406,6 +514,7 @@ export class EnvironmentManager {
406
514
 
407
515
  } else {
408
516
 
517
+ this._cdfSignature = null;
409
518
  this._updateCDFTexture();
410
519
  this.uniforms.set( 'envTotalSum', 0.0 );
411
520
  this.uniforms.set( 'envCompensationDelta', 0.0 );
@@ -430,6 +539,8 @@ export class EnvironmentManager {
430
539
 
431
540
  }
432
541
 
542
+ if ( envMap && oldTex?._isPhysicalSky && this.envParams.mode !== 'procedural' ) this._releaseSky();
543
+
433
544
  this._notifyReset();
434
545
 
435
546
  }
@@ -528,43 +639,158 @@ export class EnvironmentManager {
528
639
  }
529
640
 
530
641
  /**
531
- * Generate procedural (Preetham) sky texture and set as environment.
642
+ * Bake the physical sky and set it as the environment. Requests made in one task become one
643
+ * bake, queued straight away while the GPU keeps up and otherwise as soon as it finishes an
644
+ * earlier one. Resolves once the environment has caught up with the latest request.
532
645
  */
533
- async generateProceduralSkyTexture() {
646
+ generateProceduralSkyTexture() {
647
+
648
+ this._skyRequested = true;
649
+ this._skyCaughtUp ??= new Promise( resolve => void ( this._resolveSky = resolve ) );
650
+ this._pumpSky();
651
+ return this._skyCaughtUp;
652
+
653
+ }
534
654
 
535
- if ( ! this.proceduralSkyRenderer ) {
655
+ /** @private */
656
+ _pumpSky() {
657
+
658
+ if ( this._skyScheduled || ! this._skyRequested || this._skyInFlight >= SKY_BAKES_IN_FLIGHT ) return;
659
+ this._skyScheduled = true;
660
+ queueMicrotask( () => {
661
+
662
+ this._skyScheduled = false;
663
+ if ( this._skyRequested && this._skyInFlight < SKY_BAKES_IN_FLIGHT ) this._bakePhysicalSky();
536
664
 
537
- this.proceduralSkyRenderer = new ProceduralSky( 512, 256 );
665
+ } );
666
+
667
+ }
668
+
669
+ /** @private */
670
+ _settleSky() {
671
+
672
+ const resolve = this._resolveSky;
673
+ this._skyCaughtUp = this._resolveSky = null;
674
+ resolve?.();
675
+
676
+ }
677
+
678
+ /** @private */
679
+ async _bakePhysicalSky() {
680
+
681
+ this._skyRequested = false;
682
+ const p = this.envParams;
683
+ if ( ! this.renderer || p.mode !== 'procedural' ) {
684
+
685
+ if ( ! this.renderer ) log.warn( 'physical sky needs a renderer' );
686
+ if ( this._skyInFlight === 0 ) this._settleSky();
687
+ return;
538
688
 
539
689
  }
540
690
 
541
- const params = {
542
- sunDirection: this.envParams.skySunDirection.clone(),
543
- sunIntensity: this.envParams.skySunIntensity * 0.05,
544
- rayleighDensity: this.envParams.skyRayleighDensity * 2.0,
545
- mieDensity: this.envParams.skyTurbidity * 0.005,
546
- mieAnisotropy: this.envParams.skyMieAnisotropy,
547
- turbidity: this.envParams.skyTurbidity * 2.0,
548
- };
691
+ const sky = this.physicalSky ??= new PhysicalSky( SKY_WIDTH, SKY_HEIGHT );
692
+ const albedo = p.skyGroundAlbedo;
693
+ const bake = ++ this._skyBakes;
694
+ const latest = () => bake === this._skyBakes && ! this._skyRequested;
695
+ this._skyInFlight ++;
549
696
 
550
697
  try {
551
698
 
552
- const texture = this.proceduralSkyRenderer.render( params );
553
- texture._isGeneratedProcedural = true;
554
- await this.setEnvironmentMap( texture );
699
+ const { texture, cdfTexture, sun, stats } = sky.bake( this.renderer, {
700
+ sunDirection: p.skySunDirection.toArray(),
701
+ turbidity: p.skyTurbidity,
702
+ ozone: p.skyOzone,
703
+ airDensity: p.skyAirDensity,
704
+ groundAlbedo: [ albedo.r, albedo.g, albedo.b ],
705
+ altitude: p.skyAltitude,
706
+ sunAngularDiameter: p.skySunSize * DEG2RAD,
707
+ sunStrength: p.skySunStrength,
708
+ } );
709
+ this._sun = sun;
710
+ this._installSky( texture, cdfTexture );
711
+
712
+ // Frames until they land normalise by an older bake's.
713
+ const { totalSum, compensationDelta } = await stats;
714
+ if ( p.mode === 'procedural' && sky === this.physicalSky && bake > this._skyStatsBake ) {
715
+
716
+ this._skyStatsBake = bake;
717
+ this.uniforms.set( 'envTotalSum', totalSum );
718
+ this.uniforms.set( 'envCompensationDelta', compensationDelta );
719
+ if ( latest() ) this._notifyReset();
555
720
 
556
- this.uniforms.get( 'sunDirection' ).value.copy( this.envParams.skySunDirection );
557
- this.uniforms.set( 'sunAngularSize', 0.0087 );
558
- this.uniforms.set( 'hasSun', 1 );
721
+ }
559
722
 
560
- log.debug( `sun synced · dir ${this.envParams.skySunDirection.toArray().map( v => v.toFixed( 2 ) ).join( ',' )}` );
723
+ if ( latest() ) log.debug( `physical sky ${fmt.ms( sky.getLastRenderTime() )} · sun ${sun.radiance.map( v => v.toPrecision( 3 ) ).join( ',' )}` );
561
724
 
562
725
  } catch ( error ) {
563
726
 
564
- log.error( 'procedural sky generation failed:', error );
727
+ log.error( 'physical sky generation failed:', error );
728
+
729
+ } finally {
730
+
731
+ this._skyInFlight --;
732
+ if ( latest() ) this._settleSky();
733
+ else this._pumpSky();
734
+
735
+ }
736
+
737
+ }
738
+
739
+ /** @private */
740
+ _installSky( texture, cdfTexture ) {
741
+
742
+ const oldTex = this.environmentTexture;
743
+ if ( oldTex && oldTex !== texture && oldTex !== this._envPlaceholder && oldTex !== this._previousHDRI ) oldTex.dispose?.();
744
+ if ( this.envCDFTexture !== cdfTexture ) {
745
+
746
+ this.envCDFTexture?.dispose?.();
747
+ this.envCDFTexture = cdfTexture;
565
748
 
566
749
  }
567
750
 
751
+ // So an HDRI coming back rebuilds its own table.
752
+ this._cdfSignature = null;
753
+ texture._isGeneratedProcedural = true;
754
+ this.scene.environment = texture;
755
+ this.environmentTexture = texture;
756
+ this.envTexSize.set( texture.image.width, texture.image.height );
757
+ this.uniforms.get( 'envResolution' ).value.set( texture.image.width, texture.image.height );
758
+ const nodes = this.callbacks.getSceneTextureNodes?.();
759
+ if ( nodes?.envTex ) nodes.envTex.value = texture;
760
+ this.refreshSun();
761
+ this._notifyReset();
762
+
763
+ }
764
+
765
+ /** @private */
766
+ _releaseSky() {
767
+
768
+ if ( ! this.physicalSky ) return;
769
+ this.physicalSky.dispose( this.renderer );
770
+ this.physicalSky = null;
771
+ this._sun = null;
772
+ this.refreshSun();
773
+
774
+ }
775
+
776
+ /**
777
+ * Push the sun to the shader: into world space through the environment rotation, and into
778
+ * the working colour space. Call after either changes.
779
+ */
780
+ refreshSun() {
781
+
782
+ const sun = this.envParams.mode === 'procedural' ? this._sun : null;
783
+ const on = !! sun && sun.radiance.some( v => v > 0 );
784
+ this.uniforms.set( 'hasSun', on ? 1 : 0 );
785
+ if ( ! on ) return;
786
+
787
+ const direction = this.uniforms.get( 'sunDirection' ).value;
788
+ direction.fromArray( sun.direction ).transformDirection( _rotation.copy( this.environmentRotationMatrix ).transpose() );
789
+ const rgb = [ ...sun.radiance ];
790
+ convertLinearTriple( rgb );
791
+ this.uniforms.get( 'sunRadiance' ).value.fromArray( rgb );
792
+ this.uniforms.get( 'sunParams' ).value.set( sun.cosHalfAngle, sun.solidAngle, 1 / ( sun.sinHalfAngle * sun.sinHalfAngle ), sun.horizonSin );
793
+
568
794
  }
569
795
 
570
796
  // ===== HELPERS =====
@@ -597,8 +823,12 @@ export class EnvironmentManager {
597
823
 
598
824
  dispose() {
599
825
 
600
- this.proceduralSkyRenderer = null;
826
+ this.callbacks = {};
827
+ this._settleSky();
828
+ this.physicalSky?.dispose( this.renderer );
829
+ this.physicalSky = null;
601
830
  this.simpleSkyRenderer = null;
831
+ this._sun = null;
602
832
 
603
833
  this.envCDFTexture?.dispose?.();
604
834
  this.envCDFTexture = null;
@@ -1,4 +1,5 @@
1
1
  import { DataArrayTexture, LinearFilter, RGBAFormat, UnsignedByteType } from 'three';
2
+ import { cachedObjectURL } from '../Storage/DownloadCache.js';
2
3
 
3
4
  /**
4
5
  * Manages projection masks ("gobos" / "cookies") for spot lights.
@@ -256,17 +257,27 @@ export class GoboManager {
256
257
 
257
258
  }
258
259
 
259
- function loadImage( url ) {
260
+ async function loadImage( url ) {
260
261
 
261
- return new Promise( ( resolve, reject ) => {
262
+ const source = await cachedObjectURL( url );
262
263
 
263
- const img = new Image();
264
- img.crossOrigin = 'anonymous';
265
- img.onload = () => resolve( img );
266
- img.onerror = () => reject( new Error( `Failed to load gobo image: ${url}` ) );
267
- img.src = url;
264
+ try {
268
265
 
269
- } );
266
+ return await new Promise( ( resolve, reject ) => {
267
+
268
+ const img = new Image();
269
+ img.crossOrigin = 'anonymous';
270
+ img.onload = () => resolve( img );
271
+ img.onerror = () => reject( new Error( `Failed to load gobo image: ${url}` ) );
272
+ img.src = source.url;
273
+
274
+ } );
275
+
276
+ } finally {
277
+
278
+ source.release();
279
+
280
+ }
270
281
 
271
282
  }
272
283
 
@@ -1,5 +1,6 @@
1
1
  import { DataArrayTexture, LinearFilter, RGBAFormat, UnsignedByteType } from 'three';
2
2
  import { parseIES, resampleIESToGrid, deriveIESBeamAngle, deriveIESPenumbra } from '../Processor/IESParser.js';
3
+ import { sharedDownloads } from '../Storage/DownloadCache.js';
3
4
 
4
5
  /**
5
6
  * Manages IES photometric profiles for spot lights.
@@ -63,9 +64,8 @@ export class IESManager {
63
64
 
64
65
  try {
65
66
 
66
- const res = await fetch( it.url );
67
- if ( ! res.ok ) throw new Error( `HTTP ${res.status}` );
68
- const text = await res.text();
67
+ const { file, release } = await sharedDownloads().fetch( it.url );
68
+ const text = await file.text().finally( release );
69
69
  const profile = parseIES( text, it.name );
70
70
  const grid = resampleIESToGrid( profile, gridWidth, gridHeight );
71
71
  return { it, profile, grid };
@@ -2,6 +2,13 @@ import {
2
2
  EventDispatcher, DirectionalLight, PointLight, SpotLight, RectAreaLight,
3
3
  Object3D, MathUtils
4
4
  } from 'three';
5
+ import { toPortable, fromPortable } from '../SceneState/portable.js';
6
+
7
+ // Set on lights a host added, which live in the light scene alone: a rebuild re-transfers the
8
+ // model's lights and keeps these.
9
+ const USER_LIGHT = '__rayzeeUserLight';
10
+
11
+ const LIGHT_TYPES = { DirectionalLight, PointLight, SpotLight, RectAreaLight };
5
12
 
6
13
  /**
7
14
  * Manages scene lights: add, remove, transfer from mesh scene to WebGPU
@@ -84,6 +91,8 @@ export class LightManager extends EventDispatcher {
84
91
 
85
92
  }
86
93
 
94
+ light.userData[ USER_LIGHT ] = true;
95
+
87
96
  // Blender-style emission controls common to every light type.
88
97
  light.userData.temperature = 6500;
89
98
  light.userData.useTemperature = false;
@@ -158,10 +167,11 @@ export class LightManager extends EventDispatcher {
158
167
  * Clones lights from the mesh scene into the WebGPU light scene,
159
168
  * then updates GPU uniform buffers.
160
169
  * @param {import('three').Scene} meshScene
170
+ * @param {{keepUserLights?: boolean}} [options] - keep the lights a host added (an incremental rebuild)
161
171
  */
162
- transferSceneLights( meshScene ) {
172
+ transferSceneLights( meshScene, { keepUserLights = false } = {} ) {
163
173
 
164
- this._removeAllLights();
174
+ this._removeAllLights( keepUserLights ? light => ! light.userData?.[ USER_LIGHT ] : undefined );
165
175
 
166
176
  const sourceLights = meshScene.getObjectsByProperty( 'isLight', true );
167
177
 
@@ -228,6 +238,89 @@ export class LightManager extends EventDispatcher {
228
238
 
229
239
  }
230
240
 
241
+ /**
242
+ * Every light as plain data, for a saved session: the model's (with any edits) and the host's.
243
+ * @returns {Object[]}
244
+ */
245
+ serialize() {
246
+
247
+ return this.scene.getObjectsByProperty( 'isLight', true ).map( light => {
248
+
249
+ const record = {
250
+ type: light.type,
251
+ name: light.name,
252
+ visible: light.visible,
253
+ color: [ light.color.r, light.color.g, light.color.b ],
254
+ intensity: light.intensity,
255
+ position: light.position.toArray(),
256
+ quaternion: light.quaternion.toArray(),
257
+ scale: light.scale.toArray(),
258
+ userData: toPortable( light.userData ) ?? {},
259
+ };
260
+
261
+ if ( light.isSpotLight ) Object.assign( record, { angle: light.angle, penumbra: light.penumbra, distance: light.distance, decay: light.decay } );
262
+ if ( light.isPointLight ) Object.assign( record, { distance: light.distance, decay: light.decay } );
263
+ if ( light.isRectAreaLight ) Object.assign( record, { width: light.width, height: light.height } );
264
+ if ( light.target && ( light.isSpotLight || light.isDirectionalLight ) ) record.target = light.target.position.toArray();
265
+ return record;
266
+
267
+ } );
268
+
269
+ }
270
+
271
+ /**
272
+ * Replaces every light with the ones {@link serialize} described.
273
+ * @param {Object[]} records
274
+ * @returns {import('three').Light[]} the new lights, in record order (unknown types skipped)
275
+ */
276
+ restore( records ) {
277
+
278
+ this.sceneHelpers?.clear();
279
+ this._removeAllLights();
280
+
281
+ const lights = [];
282
+ for ( const record of records ?? [] ) {
283
+
284
+ const Type = LIGHT_TYPES[ record.type ];
285
+ if ( ! Type ) continue;
286
+
287
+ const light = new Type();
288
+ light.name = record.name ?? '';
289
+ light.visible = record.visible !== false;
290
+ light.color.setRGB( ...record.color );
291
+ light.intensity = record.intensity;
292
+ light.position.fromArray( record.position );
293
+ light.quaternion.fromArray( record.quaternion );
294
+ light.scale.fromArray( record.scale ?? [ 1, 1, 1 ] );
295
+ light.userData = fromPortable( record.userData ) ?? {};
296
+
297
+ for ( const key of [ 'angle', 'penumbra', 'distance', 'decay', 'width', 'height' ] ) {
298
+
299
+ if ( typeof record[ key ] === 'number' ) light[ key ] = record[ key ];
300
+
301
+ }
302
+
303
+ if ( record.target ) {
304
+
305
+ const target = new Object3D();
306
+ target.position.fromArray( record.target );
307
+ this.scene.add( target );
308
+ light.target = target;
309
+
310
+ }
311
+
312
+ this.scene.add( light );
313
+ lights.push( light );
314
+
315
+ }
316
+
317
+ this.updateLights();
318
+ this._syncHelpers();
319
+ this._onReset?.();
320
+ return lights;
321
+
322
+ }
323
+
231
324
  // ── Aliases (match Sub-API surface for zero-churn migration) ──
232
325
 
233
326
  /** @see addLight */
@@ -295,10 +388,10 @@ export class LightManager extends EventDispatcher {
295
388
 
296
389
  // ── Private ───────────────────────────────────────────────────
297
390
 
298
- /** Syncs helpers in sceneHelpers with current scene lights. */
299
- _removeAllLights() {
391
+ /** @param {function(import('three').Light): boolean} [which] - all lights when omitted */
392
+ _removeAllLights( which ) {
300
393
 
301
- this.scene.getObjectsByProperty( 'isLight', true ).forEach( light => {
394
+ this.scene.getObjectsByProperty( 'isLight', true ).filter( light => ! which || which( light ) ).forEach( light => {
302
395
 
303
396
  this._onLightRemoved?.( light );
304
397
  if ( light.target ) this.scene.remove( light.target );