rayzee 7.24.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.
@@ -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
+ }
@@ -140,15 +140,26 @@ function neutralToneMap( r, g, b, exposure, out ) {
140
140
 
141
141
  }
142
142
 
143
+ /**
144
+ * Three.js clamps the fragment output with `.max( 0 )` *before* tone mapping
145
+ * (NodeMaterial.js, "force unsigned floats"), so the GPU curves never see a negative
146
+ * channel. Compositor's saturation grade (default 1.2) drives complementary channels
147
+ * below zero on a third of a typical frame, and AgX/Neutral mix those negatives across
148
+ * channels instead of clipping them — measured 7-10 levels of shadow error against the
149
+ * viewport until the readback clamps the same way.
150
+ */
151
+ const clampNegative = fn => ( r, g, b, exposure, out ) =>
152
+ fn( r > 0 ? r : 0, g > 0 ? g : 0, b > 0 ? b : 0, exposure, out );
153
+
143
154
  /** Look-up table mapping Three.js ToneMapping constants to CPU functions. */
144
155
  export const TONE_MAP_FNS = new Map( [
145
- [ NoToneMapping, noToneMap ],
146
- [ LinearToneMapping, linearToneMap ],
147
- [ ReinhardToneMapping, reinhardToneMap ],
148
- [ CineonToneMapping, cineonToneMap ],
149
- [ ACESFilmicToneMapping, acesFilmicToneMap ],
150
- [ AgXToneMapping, agxToneMap ],
151
- [ NeutralToneMapping, neutralToneMap ]
156
+ [ NoToneMapping, clampNegative( noToneMap ) ],
157
+ [ LinearToneMapping, clampNegative( linearToneMap ) ],
158
+ [ ReinhardToneMapping, clampNegative( reinhardToneMap ) ],
159
+ [ CineonToneMapping, clampNegative( cineonToneMap ) ],
160
+ [ ACESFilmicToneMapping, clampNegative( acesFilmicToneMap ) ],
161
+ [ AgXToneMapping, clampNegative( agxToneMap ) ],
162
+ [ NeutralToneMapping, clampNegative( neutralToneMap ) ]
152
163
  ] );
153
164
 
154
165
  /** sRGB gamma (1/2.2) — fast pow approximation. Prefer `linearToSRGB` when matching Three.js's output. */
@@ -165,6 +176,25 @@ export function linearToSRGB( c ) {
165
176
 
166
177
  }
167
178
 
179
+ /**
180
+ * Exposure as the WebGPU output pass applies it.
181
+ *
182
+ * Three.js applies `toneMappingExposure` *inside* ToneMappingNode's tone-mapping branch, and
183
+ * that branch returns the colour untouched for NoToneMapping (ToneMappingNode.js: `if
184
+ * ( toneMapping === NoToneMapping ) return colorNode`). So exposure is a no-op on screen there,
185
+ * and a CPU readback that applies it anyway paints brighter than the viewport it replaces
186
+ * (measured +33.6 % at exposure 2).
187
+ *
188
+ * @param {number} exposure - renderer.toneMappingExposure
189
+ * @param {number} toneMapping - Three.js ToneMapping constant
190
+ * @returns {number}
191
+ */
192
+ export function effectiveExposure( exposure, toneMapping ) {
193
+
194
+ return toneMapping === NoToneMapping ? 1.0 : exposure;
195
+
196
+ }
197
+
168
198
  /** Rec.709 luminance coefficients (same as Display / Common.js). */
169
199
  const LUM_R = 0.2126, LUM_G = 0.7152, LUM_B = 0.0722;
170
200
 
@@ -224,6 +224,7 @@ export class RenderSettings extends EventDispatcher {
224
224
  handleRenderLimitMode: ( value ) => {
225
225
 
226
226
  stages.pathTracer?.setRenderLimitMode?.( value );
227
+ reconcileCompletion?.();
227
228
 
228
229
  },
229
230
 
@@ -1185,25 +1185,15 @@ export class PathTracerStage extends RenderStage {
1185
1185
  }
1186
1186
 
1187
1187
  /**
1188
- * Update completion threshold based on render mode
1188
+ * Update completion threshold based on render mode. The ceiling holds in every limit mode —
1189
+ * uncapping it under a time budget lets a generous deadline silently slow every render.
1189
1190
  */
1190
1191
  updateCompletionThreshold() {
1191
1192
 
1192
- const renderMode = this.renderMode.value;
1193
- const maxFrames = this.maxSamples.value;
1194
-
1195
- if ( this.renderLimitMode === 'time' ) {
1196
-
1197
- this.completionThreshold = Infinity;
1198
-
1199
- } else {
1200
-
1201
- this.completionThreshold = updateCompletionThreshold(
1202
- renderMode,
1203
- maxFrames
1204
- );
1205
-
1206
- }
1193
+ this.completionThreshold = updateCompletionThreshold(
1194
+ this.renderMode.value,
1195
+ this.maxSamples.value
1196
+ );
1207
1197
 
1208
1198
  }
1209
1199
 
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
  /**