rayzee 7.24.0 → 7.25.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.24.0",
3
+ "version": "7.25.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",
@@ -18,7 +18,7 @@
18
18
  ],
19
19
  "repository": {
20
20
  "type": "git",
21
- "url": "https://github.com/atul-mourya/RayTracing.git",
21
+ "url": "git+https://github.com/atul-mourya/rayzee-renderer.git",
22
22
  "directory": "rayzee"
23
23
  },
24
24
  "keywords": [
@@ -1,5 +1,5 @@
1
1
  import { EventDispatcher, ACESFilmicToneMapping } from 'three';
2
- import { TONE_MAP_FNS, SRGB_GAMMA, applySaturation } from '../Processor/ToneMapCPU.js';
2
+ import { TONE_MAP_FNS, SRGB_GAMMA, applySaturation, effectiveExposure } from '../Processor/ToneMapCPU.js';
3
3
  import { getAssetConfig } from '../AssetConfig.js';
4
4
  import AIUpscalerWorker from '../Processor/Workers/AIUpscalerWorker.js?worker&inline';
5
5
 
@@ -400,7 +400,7 @@ export class AIUpscaler extends EventDispatcher {
400
400
  if ( sourceImageData.isHDR ) {
401
401
 
402
402
  this._hdrToneMapFn = TONE_MAP_FNS.get( this.getToneMapping() ) || TONE_MAP_FNS.get( ACESFilmicToneMapping );
403
- this._hdrExposure = this.getExposure();
403
+ this._hdrExposure = effectiveExposure( this.getExposure(), this.getToneMapping() );
404
404
  this._hdrSaturation = this.getSaturation();
405
405
  this._tmOut = new Float32Array( 3 );
406
406
 
@@ -793,7 +793,7 @@ export class AIUpscaler extends EventDispatcher {
793
793
  const imageData = ctx.createImageData( width, height );
794
794
  const pixels = imageData.data;
795
795
  const tmFn = TONE_MAP_FNS.get( this.getToneMapping() ) || TONE_MAP_FNS.get( ACESFilmicToneMapping );
796
- const exposure = this.getExposure();
796
+ const exposure = effectiveExposure( this.getExposure(), this.getToneMapping() );
797
797
  const saturation = this.getSaturation();
798
798
  const out = new Float32Array( 3 );
799
799
 
@@ -16,7 +16,7 @@ async function getInitUNetFromURL() {
16
16
 
17
17
  }
18
18
 
19
- import { TONE_MAP_FNS, linearToSRGB, applySaturation } from '../Processor/ToneMapCPU.js';
19
+ import { TONE_MAP_FNS, linearToSRGB, applySaturation, effectiveExposure } from '../Processor/ToneMapCPU.js';
20
20
  import { getAssetConfig } from '../AssetConfig.js';
21
21
 
22
22
  /** Reusable RGB output buffer (avoids per-pixel allocation). */
@@ -764,7 +764,7 @@ export class OIDNDenoiser extends EventDispatcher {
764
764
  /** Display exposure with the input scale divided back out — output arrives pre-multiplied. */
765
765
  _outputExposure() {
766
766
 
767
- return this.getExposure() / this._oidnInputScale;
767
+ return effectiveExposure( this.getExposure(), this.getToneMapping() ) / this._oidnInputScale;
768
768
 
769
769
  }
770
770
 
@@ -366,8 +366,11 @@ export class PathTracerApp extends EventDispatcher {
366
366
  memoryPeak: tracker?.peak ?? 0,
367
367
  } );
368
368
 
369
- // Check time limit
370
- if ( this.completion.isTimeLimitReached( this.settings.get( 'renderLimitMode' ), this.settings.get( 'renderTimeLimit' ) ) ) {
369
+ // Only the wall-clock stop — PathTracer.render() retires the ceiling and convergence
370
+ // itself, so whichever of the three arrives first wins.
371
+ if ( this.completion.isTimeLimitReached(
372
+ this.stages.pathTracer, this.settings.get( 'renderLimitMode' ), this.settings.get( 'renderTimeLimit' )
373
+ ) ) {
371
374
 
372
375
  this.stages.pathTracer.isComplete = true;
373
376
 
@@ -381,8 +384,14 @@ export class PathTracerApp extends EventDispatcher {
381
384
  context: this.pipeline?.context,
382
385
  } );
383
386
 
384
- this.dispatchEvent( { type: 'RenderComplete' } );
385
- this.dispatchEvent( { type: EngineEvents.RENDER_COMPLETE } );
387
+ const completionInfo = {
388
+ samples: this.stages.pathTracer.frameCount,
389
+ timeElapsed: this.completion.timeElapsed,
390
+ budgetOverrun: this.completion.budgetOverrun,
391
+ };
392
+
393
+ this.dispatchEvent( { type: 'RenderComplete', ...completionInfo } );
394
+ this.dispatchEvent( { type: EngineEvents.RENDER_COMPLETE, ...completionInfo } );
386
395
 
387
396
  }
388
397
 
@@ -1,3 +1,7 @@
1
+ import { createLogger } from '../utils/Logger.js';
2
+
3
+ const log = createLogger( 'engine' );
4
+
1
5
  /**
2
6
  * Tracks render completion state, time limits, and sample limits.
3
7
  *
@@ -12,6 +16,10 @@ export class CompletionTracker {
12
16
  this.lastResetTime = performance.now();
13
17
  this.renderCompleteDispatched = false;
14
18
 
19
+ /** Whether the deadline — rather than the ceiling or convergence — retired the frame. */
20
+ this.budgetOverrun = false;
21
+ this._budgetHeldWarned = false;
22
+
15
23
  }
16
24
 
17
25
  /**
@@ -24,19 +32,48 @@ export class CompletionTracker {
24
32
  }
25
33
 
26
34
  /**
27
- * Checks whether the time-based render limit has been reached.
28
- * @param {string} renderLimitMode - 'time' or 'samples'
35
+ * Checks whether the wall-clock render budget has expired. An additional stop condition,
36
+ * never a replacement — the sample ceiling and convergence still bind when they come first.
37
+ *
38
+ * Floored at one sample: frameCount does not advance while the stage compiles kernels or
39
+ * while the camera is in interaction mode, and retiring in that window denoises an empty
40
+ * buffer and captures a blank canvas.
41
+ *
42
+ * @param {Object} pathTracer - The PathTracer stage
43
+ * @param {string} renderLimitMode - 'time' or 'frames'
29
44
  * @param {number} renderTimeLimit - Time limit in seconds
30
45
  * @returns {boolean}
31
46
  */
32
- isTimeLimitReached( renderLimitMode, renderTimeLimit ) {
47
+ isTimeLimitReached( pathTracer, renderLimitMode, renderTimeLimit ) {
48
+
49
+ if ( renderLimitMode !== 'time' || ! ( renderTimeLimit > 0 ) ) return false;
50
+ if ( this.timeElapsed < renderTimeLimit ) return false;
51
+
52
+ if ( ! ( pathTracer?.frameCount > 0 ) ) {
33
53
 
34
- return renderLimitMode === 'time' && renderTimeLimit > 0 && this.timeElapsed >= renderTimeLimit;
54
+ if ( ! this._budgetHeldWarned ) {
55
+
56
+ this._budgetHeldWarned = true;
57
+ log.warn(
58
+ `render time limit (${renderTimeLimit}s) expired before the first sample landed — ` +
59
+ 'holding the deadline open to avoid capturing an empty frame'
60
+ );
61
+
62
+ }
63
+
64
+ return false;
65
+
66
+ }
67
+
68
+ this.budgetOverrun = true;
69
+ return true;
35
70
 
36
71
  }
37
72
 
38
73
  /**
39
- * Checks whether ANY render limit (time or samples) is reached.
74
+ * Checks whether ANY render limit is reached: sample ceiling, convergence, or time budget,
75
+ * whichever arrives first.
76
+ *
40
77
  * @param {Object} pathTracer - The PathTracer stage
41
78
  * @param {string} renderLimitMode
42
79
  * @param {number} renderTimeLimit
@@ -46,7 +83,7 @@ export class CompletionTracker {
46
83
 
47
84
  if ( ! pathTracer ) return false;
48
85
 
49
- if ( this.isTimeLimitReached( renderLimitMode, renderTimeLimit ) ) return true;
86
+ if ( this.isTimeLimitReached( pathTracer, renderLimitMode, renderTimeLimit ) ) return true;
50
87
 
51
88
  // Tier-1 convergence early-stop must agree with PathTracer.render()'s own check, else the app-level
52
89
  // reconcile would flip isComplete back off and keep dispatching after the frame converged.
@@ -75,6 +112,8 @@ export class CompletionTracker {
75
112
  this.timeElapsed = 0;
76
113
  this.lastResetTime = performance.now();
77
114
  this.renderCompleteDispatched = false;
115
+ this.budgetOverrun = false;
116
+ this._budgetHeldWarned = false;
78
117
 
79
118
  }
80
119
 
@@ -9,7 +9,7 @@ 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
11
  import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js';
12
- import { unzipSync, strFromU8 } from 'three/addons/libs/fflate.module.js';
12
+ import { unzipSync, zipSync, strFromU8 } from 'three/addons/libs/fflate.module.js';
13
13
  import { disposeObjectFromMemory, updateLoading } from './utils';
14
14
  import { BuildTimer } from './BuildTimer.js';
15
15
  import { getAssetConfig } from '../AssetConfig.js';
@@ -21,13 +21,20 @@ const SUPPORTED_FORMATS = {
21
21
  'fbx': { type: 'model', name: 'FBX' }, 'obj': { type: 'model', name: 'OBJ' },
22
22
  'stl': { type: 'model', name: 'STL' }, 'ply': { type: 'model', name: 'PLY (Polygon File Format)' },
23
23
  'dae': { type: 'model', name: 'Collada' }, '3mf': { type: 'model', name: '3D Manufacturing Format' },
24
- 'usdz': { type: 'model', name: 'Universal Scene Description' },
24
+ 'usd': { type: 'model', name: 'USD (Universal Scene Description)' },
25
+ 'usda': { type: 'model', name: 'USDA (USD ASCII)' },
26
+ 'usdc': { type: 'model', name: 'USDC (USD Crate)' },
27
+ 'usdz': { type: 'model', name: 'USDZ (USD Archive)' },
25
28
  'hdr': { type: 'environment', name: 'HDR (High Dynamic Range)' }, 'exr': { type: 'environment', name: 'EXR (OpenEXR)' },
26
29
  'png': { type: 'image', name: 'PNG' }, 'jpg': { type: 'image', name: 'JPEG' },
27
30
  'jpeg': { type: 'image', name: 'JPEG' }, 'webp': { type: 'image', name: 'WebP' },
28
31
  'zip': { type: 'archive', name: 'ZIP Archive' }
29
32
  };
30
33
 
34
+ // Loose USD layers inside a ZIP compose into one scene; these pick out the
35
+ // layers and the image assets they reference.
36
+ const USD_LAYER_RE = /\.(usd|usda|usdc)$/i;
37
+ const USD_IMAGE_RE = /\.(png|jpg|jpeg|avif)$/i;
31
38
  /**
32
39
  * AssetLoader class - handles loading of 3D models, environment maps, and archives
33
40
  */
@@ -234,7 +241,10 @@ export class AssetLoader extends EventDispatcher {
234
241
  case 'ply': return await this.loadPLYFromArrayBuffer( arrayBuffer, filename );
235
242
  case 'dae': return await this.loadColladaFromFile( file, filename );
236
243
  case '3mf': return await this.load3MFFromArrayBuffer( arrayBuffer, filename );
237
- case 'usdz': return await this.loadUSDZFromArrayBuffer( arrayBuffer, filename );
244
+ case 'usd':
245
+ case 'usda':
246
+ case 'usdc':
247
+ case 'usdz': return await this.loadUSDFromArrayBuffer( arrayBuffer, filename );
238
248
  default: throw new Error( `Support for ${extension} files is not yet implemented` );
239
249
 
240
250
  }
@@ -647,6 +657,10 @@ export class AssetLoader extends EventDispatcher {
647
657
  const extension = path.split( '.' ).pop().toLowerCase();
648
658
  if ( SUPPORTED_FORMATS[ extension ] && SUPPORTED_FORMATS[ extension ].type === 'model' ) {
649
659
 
660
+ // A loose layer is only one slice of a USD scene — hand the whole
661
+ // archive over so its references and payloads can resolve.
662
+ if ( USD_LAYER_RE.test( path ) ) return await this.loadUSDHierarchyFromZip( zip );
663
+
650
664
  console.log( `Loading model file from ZIP: ${path}` );
651
665
  return await this.loadModelFromZipEntry( zip[ path ], path, extension, zip );
652
666
 
@@ -658,6 +672,48 @@ export class AssetLoader extends EventDispatcher {
658
672
 
659
673
  }
660
674
 
675
+ // USDLoader only builds a cross-layer asset map on its USDZ branch, so repack
676
+ // the archive as USDZ — root layer first, per AOUSD core spec 16.4.1.2 — and
677
+ // let the loader resolve the references/payloads itself.
678
+ async loadUSDHierarchyFromZip( zip ) {
679
+
680
+ const layers = Object.keys( zip ).filter( name => USD_LAYER_RE.test( name ) );
681
+ if ( layers.length === 0 ) throw new Error( 'No USD layers found in the ZIP archive' );
682
+
683
+ const root = AssetLoader._pickUSDRootLayer( layers );
684
+ console.log( `Loading USD scene from ZIP: ${root} (${layers.length} layers)` );
685
+
686
+ const packed = { [ root ]: [ zip[ root ], { level: 0 } ] };
687
+ for ( const name of Object.keys( zip ) ) {
688
+
689
+ if ( name === root ) continue;
690
+ if ( USD_LAYER_RE.test( name ) || USD_IMAGE_RE.test( name ) ) packed[ name ] = [ zip[ name ], { level: 0 } ];
691
+
692
+ }
693
+
694
+ return await this.loadUSDFromArrayBuffer( zipSync( packed ), root );
695
+
696
+ }
697
+
698
+ // Shallowest layer wins; among ties prefer the <dir>/<dir>.usd convention so a
699
+ // set's entry point beats its sibling variants.
700
+ static _pickUSDRootLayer( layers ) {
701
+
702
+ const depth = name => name.split( '/' ).length;
703
+ const minDepth = Math.min( ...layers.map( depth ) );
704
+ const candidates = layers.filter( name => depth( name ) === minDepth );
705
+
706
+ const conventional = candidates.find( name => {
707
+
708
+ const parts = name.split( '/' );
709
+ return parts.length > 1 && parts[ parts.length - 1 ].replace( USD_LAYER_RE, '' ) === parts[ parts.length - 2 ];
710
+
711
+ } );
712
+
713
+ return conventional || candidates[ 0 ];
714
+
715
+ }
716
+
661
717
  async loadModelFromZipEntry( fileContent, filePath, extension, zipContents ) {
662
718
 
663
719
  try {
@@ -698,8 +754,11 @@ export class AssetLoader extends EventDispatcher {
698
754
  case '3mf':
699
755
  result = await this.load3MFFromArrayBuffer( fileContent.buffer, filePath );
700
756
  break;
757
+ case 'usd':
758
+ case 'usda':
759
+ case 'usdc':
701
760
  case 'usdz':
702
- result = await this.loadUSDZFromArrayBuffer( fileContent.buffer, filePath );
761
+ result = await this.loadUSDFromArrayBuffer( fileContent, filePath );
703
762
  break;
704
763
  default:
705
764
  throw new Error( `Support for ${extension} files is not yet implemented` );
@@ -1396,21 +1455,28 @@ export class AssetLoader extends EventDispatcher {
1396
1455
 
1397
1456
  }
1398
1457
 
1399
- async loadUSDZFromArrayBuffer( arrayBuffer, filename = 'model.usdz' ) {
1458
+ async loadUSDFromArrayBuffer( data, filename = 'model.usd' ) {
1400
1459
 
1401
1460
  try {
1402
1461
 
1403
- updateLoading( { isLoading: true, status: "Processing USDZ Data...", progress: 5 } );
1462
+ updateLoading( { isLoading: true, status: "Processing USD Data...", progress: 5 } );
1404
1463
  await new Promise( r => setTimeout( r, 0 ) );
1405
1464
 
1406
- if ( ! this.loaderCache.usdz ) {
1465
+ if ( ! this.loaderCache.usd ) {
1407
1466
 
1408
- const { USDZLoader } = await import( 'three/examples/jsm/loaders/USDZLoader.js' );
1409
- this.loaderCache.usdz = new USDZLoader();
1467
+ const { USDLoader } = await import( 'three/examples/jsm/loaders/USDLoader.js' );
1468
+ this.loaderCache.usd = new USDLoader();
1410
1469
 
1411
1470
  }
1412
1471
 
1413
- const object = this.loaderCache.usdz.parse( arrayBuffer );
1472
+ // parse() returns the group synchronously but resolves textures async;
1473
+ // setupPathTracing snapshots materials, so maps must land before it runs.
1474
+ const object = await new Promise( ( resolve, reject ) => {
1475
+
1476
+ this.loaderCache.usd.parse( data, '', resolve, reject );
1477
+
1478
+ } );
1479
+
1414
1480
  object.name = filename;
1415
1481
 
1416
1482
  this.releaseTargetModel();
@@ -1424,7 +1490,7 @@ export class AssetLoader extends EventDispatcher {
1424
1490
 
1425
1491
  } catch ( error ) {
1426
1492
 
1427
- console.error( 'Error loading USDZ:', error );
1493
+ console.error( 'Error loading USD:', error );
1428
1494
  this.dispatchEvent( { type: 'error', message: error.message, filename } );
1429
1495
  throw error;
1430
1496
 
@@ -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