rayzee 7.19.1 → 7.20.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.19.1",
3
+ "version": "7.20.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",
@@ -56,6 +56,7 @@ const _appsByCanvas = new WeakMap();
56
56
  * - `app.environmentManager` — EnvironmentManager (HDRI, procedural sky, mode switching)
57
57
  * - `app.settings` — {@link RenderSettings} (all render parameters)
58
58
  * - `app.stages` — Named pipeline stages for advanced control
59
+ * - `app.sceneMeshes` — meshes backing the BVH, in buffer order (see {@link refitBVH})
59
60
  *
60
61
  * Extends EventDispatcher for event-driven communication with stores/UI.
61
62
  */
@@ -166,6 +167,11 @@ export class PathTracerApp extends EventDispatcher {
166
167
  this._disposed = false;
167
168
  this._deviceLost = false;
168
169
 
170
+ // Deterministic-render mode — see setDeterministicMode()
171
+ this._deterministic = false;
172
+ this._dispatchPinned = false;
173
+ this._deterministicRestore = null;
174
+
169
175
  }
170
176
 
171
177
  /**
@@ -1149,6 +1155,24 @@ export class PathTracerApp extends EventDispatcher {
1149
1155
  // BVH Refit (Animation)
1150
1156
  // ═══════════════════════════════════════════════════════════════
1151
1157
 
1158
+ /**
1159
+ * The meshes backing the current acceleration structure, in the order their triangles
1160
+ * occupy the shared buffers — which is what "original mesh order" means in
1161
+ * {@link refitBVH} and {@link refitBLASes}, and what `meshIndex` indexes.
1162
+ *
1163
+ * Walking your own model instead is not equivalent: the list is a depth-first pre-order
1164
+ * traversal of the whole mesh scene, so it also contains engine-owned meshes (the hidden
1165
+ * ground-projection disk) and any mesh a multi-material split produced. A positions
1166
+ * buffer built from a different set is silently misaligned.
1167
+ *
1168
+ * @returns {import('three').Mesh[]} Live reference — do not mutate.
1169
+ */
1170
+ get sceneMeshes() {
1171
+
1172
+ return this._sdf?.meshes ?? [];
1173
+
1174
+ }
1175
+
1152
1176
  /**
1153
1177
  * Update vertex positions for animation without full BVH rebuild.
1154
1178
  * O(N) bottom-up AABB refit instead of O(N log N) SAH rebuild.
@@ -1156,7 +1180,7 @@ export class PathTracerApp extends EventDispatcher {
1156
1180
  * Topology must stay the same (same triangle count and connectivity).
1157
1181
  * Call this per-frame for skeletal/morph-target animation.
1158
1182
  *
1159
- * @param {Float32Array} newPositions - 9 floats per triangle (ax,ay,az, bx,by,bz, cx,cy,cz) in original mesh order
1183
+ * @param {Float32Array} newPositions - 9 floats per triangle (ax,ay,az, bx,by,bz, cx,cy,cz) for every triangle in the scene, meshes in {@link sceneMeshes} order and triangles in index order
1160
1184
  * @param {Float32Array} [newNormals] - Optional 9 floats per triangle smooth normals. If omitted, face normals are computed from positions.
1161
1185
  * @returns {Promise<{ refitTimeMs: number }>}
1162
1186
  */
@@ -1446,6 +1470,251 @@ export class PathTracerApp extends EventDispatcher {
1446
1470
 
1447
1471
  }
1448
1472
 
1473
+ // ═══════════════════════════════════════════════════════════════
1474
+ // Deterministic / headless control
1475
+ // ═══════════════════════════════════════════════════════════════
1476
+
1477
+ /**
1478
+ * Pins every wall-clock- and readback-dependent input so that N samples from a
1479
+ * fresh `reset()` reproduce bit-for-bit.
1480
+ *
1481
+ * The image is always a pure function of (pixel, frame, uniforms) — no shader reads
1482
+ * a clock and there is no `Math.random()` in the render path. What varies run to run
1483
+ * is *which* uniforms and dispatch grids are live on frame k:
1484
+ *
1485
+ * - `useAdaptiveSampling` retires the whole frame off an async CONVERGED_COUNT
1486
+ * readback, so two runs accumulate different sample totals for one `maxSamples`.
1487
+ * - `usePixelFreeze` sizes the bounce-0 grid from a stale active-pixel readback.
1488
+ * - `_bounceEarlyExitThreshold` / `_useDynamicDispatch` both consume the async
1489
+ * survivor curve. Kernels bind on ENTERING_COUNT, so an under-sized grid silently
1490
+ * drops rays, and the frame a readback lands on is GPU-scheduled.
1491
+ * - `interactionModeEnabled` is a 100 ms timer that clamps bounces to 1, disables
1492
+ * accumulation and freezes frameCount; it engages on the very first frame.
1493
+ * - auto-focus raycasts per frame and auto-exposure adapts off `performance.now()`.
1494
+ *
1495
+ * Leaves the rAF loop stopped — drive rendering with {@link PathTracerApp#renderFrames}.
1496
+ * Idempotent; pass `false` to restore the previous configuration.
1497
+ *
1498
+ * `pinDispatch: false` keeps the two readback-driven dispatch heuristics
1499
+ * (`_useDynamicDispatch` and the per-bounce early exit) ACTIVE while still pinning the
1500
+ * render loop, sample count and timers. Output is then no longer bit-reproducible, so
1501
+ * this is only for performance measurement — it exists because those heuristics are
1502
+ * real shipping behaviour, and benchmarking with them disabled measures a configuration
1503
+ * production never runs.
1504
+ *
1505
+ * @param {boolean} [enabled=true]
1506
+ * @param {Object} [options]
1507
+ * @param {boolean} [options.pinDispatch=true] - false to keep production dispatch heuristics
1508
+ * @returns {boolean} whether deterministic mode is now active
1509
+ */
1510
+ setDeterministicMode( enabled = true, { pinDispatch = true } = {} ) {
1511
+
1512
+ const stage = this.stages.pathTracer;
1513
+ if ( ! stage ) return false;
1514
+
1515
+ if ( enabled ) {
1516
+
1517
+ if ( ! this._deterministicRestore ) {
1518
+
1519
+ this._deterministicRestore = {
1520
+ settings: {
1521
+ useAdaptiveSampling: this.settings.get( 'useAdaptiveSampling' ),
1522
+ usePixelFreeze: this.settings.get( 'usePixelFreeze' ),
1523
+ interactionModeEnabled: this.settings.get( 'interactionModeEnabled' ),
1524
+ renderLimitMode: this.settings.get( 'renderLimitMode' ),
1525
+ renderTimeLimit: this.settings.get( 'renderTimeLimit' ),
1526
+ },
1527
+ bounceEarlyExit: stage._bounceEarlyExitThreshold,
1528
+ dynamicDispatch: stage._useDynamicDispatch,
1529
+ autoFocusMode: this.cameraManager?.autoFocusMode,
1530
+ autoExposure: this.stages.autoExposure?.enabled,
1531
+ };
1532
+
1533
+ }
1534
+
1535
+ // Cleared together on purpose: the freeze streak is stamped inside the
1536
+ // convergence block, so freeze-on/adaptive-off would run the freeze path
1537
+ // against a streak buffer nothing writes or clears.
1538
+ this.settings.setMany( {
1539
+ useAdaptiveSampling: false,
1540
+ usePixelFreeze: false,
1541
+ interactionModeEnabled: false,
1542
+ renderLimitMode: 'frames',
1543
+ renderTimeLimit: 0,
1544
+ }, { silent: true } );
1545
+
1546
+ if ( pinDispatch ) {
1547
+
1548
+ // -1 is unreachable by a uint survivor count, and both _buildWavefrontKernels()
1549
+ // and _resizeWavefrontInPlace() preserve the sentinel across rebuilds.
1550
+ stage._bounceEarlyExitThreshold = - 1;
1551
+ stage._useDynamicDispatch = false;
1552
+
1553
+ } else {
1554
+
1555
+ // Restore the values captured on first enable, so a perf pass measures the
1556
+ // same dispatch behaviour a real render uses.
1557
+ stage._bounceEarlyExitThreshold = this._deterministicRestore.bounceEarlyExit;
1558
+ stage._useDynamicDispatch = this._deterministicRestore.dynamicDispatch;
1559
+
1560
+ }
1561
+
1562
+ this.cameraManager?.setAutoFocusMode( 'manual' );
1563
+ if ( this.stages.autoExposure ) this.stages.autoExposure.enabled = false;
1564
+
1565
+ this._deterministic = true;
1566
+ this._dispatchPinned = pinDispatch;
1567
+
1568
+ } else if ( this._deterministicRestore ) {
1569
+
1570
+ const prev = this._deterministicRestore;
1571
+
1572
+ this.settings.setMany( prev.settings, { silent: true } );
1573
+ stage._bounceEarlyExitThreshold = prev.bounceEarlyExit;
1574
+ stage._useDynamicDispatch = prev.dynamicDispatch;
1575
+
1576
+ if ( prev.autoFocusMode !== undefined ) this.cameraManager?.setAutoFocusMode( prev.autoFocusMode );
1577
+ if ( this.stages.autoExposure && prev.autoExposure !== undefined ) {
1578
+
1579
+ this.stages.autoExposure.enabled = prev.autoExposure;
1580
+
1581
+ }
1582
+
1583
+ this._deterministicRestore = null;
1584
+ this._deterministic = false;
1585
+ this._dispatchPinned = false;
1586
+
1587
+ }
1588
+
1589
+ this.reset();
1590
+ this.stopAnimation(); // reset() calls wake(); a manual render loop must not race rAF
1591
+
1592
+ return this._deterministic;
1593
+
1594
+ }
1595
+
1596
+ /**
1597
+ * Whether output is currently bit-reproducible. False when the dispatch heuristics
1598
+ * were left active via `pinDispatch: false`, since those consume async readbacks.
1599
+ */
1600
+ get isDeterministic() {
1601
+
1602
+ return this._deterministic && this._dispatchPinned;
1603
+
1604
+ }
1605
+
1606
+ /**
1607
+ * Accumulates exactly `count` samples synchronously, bypassing the rAF loop.
1608
+ *
1609
+ * Awaits the STBN atlases first — until they land the sampler reads a constant-0.5
1610
+ * placeholder that gets baked permanently into the accumulation buffer.
1611
+ *
1612
+ * @param {number} count - samples to accumulate
1613
+ * @param {Object} [options]
1614
+ * @param {boolean} [options.reset=true] - restart accumulation from sample 0 first
1615
+ * @param {number} [options.yieldEvery=8] - yield to the event loop every N passes (0 disables)
1616
+ * @param {function(number): void} [options.onProgress] - called with the running sample count
1617
+ * @returns {Promise<number>} the final accumulated sample count
1618
+ */
1619
+ async renderFrames( count, { reset = true, yieldEvery = 8, onProgress } = {} ) {
1620
+
1621
+ const stage = this.stages.pathTracer;
1622
+ if ( ! stage ) throw new Error( 'renderFrames: app is not initialized' );
1623
+ if ( ! ( count > 0 ) ) throw new Error( `renderFrames: count must be positive, got ${count}` );
1624
+
1625
+ await stage.blueNoiseReady;
1626
+
1627
+ const target = ( reset ? 0 : stage.frameCount ) + count;
1628
+
1629
+ // completionThreshold is a cached JS number derived from maxSamples, so this must
1630
+ // go through the settings handler — writing the uniform alone would not move it.
1631
+ if ( this.settings.get( 'maxSamples' ) < target ) {
1632
+
1633
+ this.settings.set( 'maxSamples', target, { silent: true, reset: false } );
1634
+
1635
+ }
1636
+
1637
+ if ( reset ) this.reset();
1638
+ this.stopAnimation();
1639
+
1640
+ const maxPasses = count + 64;
1641
+ let passes = 0;
1642
+
1643
+ while ( stage.frameCount < target && passes < maxPasses ) {
1644
+
1645
+ if ( this._deviceLost ) throw new Error( 'renderFrames: WebGPU device lost' );
1646
+ if ( ! stage.isReady ) throw new Error( 'renderFrames: path tracer stage is not ready' );
1647
+
1648
+ this.pipeline.render();
1649
+ passes ++;
1650
+
1651
+ onProgress?.( stage.frameCount );
1652
+
1653
+ if ( yieldEvery > 0 && passes % yieldEvery === 0 ) {
1654
+
1655
+ await new Promise( ( resolve ) => setTimeout( resolve, 0 ) );
1656
+
1657
+ }
1658
+
1659
+ }
1660
+
1661
+ if ( stage.frameCount < target ) {
1662
+
1663
+ throw new Error(
1664
+ `renderFrames: stalled at ${stage.frameCount}/${target} samples after ${passes} passes — ` +
1665
+ 'something retired the render (maxSamples, a stray reset, or a canvas resize)'
1666
+ );
1667
+
1668
+ }
1669
+
1670
+ return stage.frameCount;
1671
+
1672
+ }
1673
+
1674
+ /**
1675
+ * Enables WebGPU timestamp queries so {@link PathTracerApp#getGPUTimings} reports real
1676
+ * GPU time. Off by default because the queries themselves cost time. No-ops when the
1677
+ * device lacks the `timestamp-query` feature.
1678
+ *
1679
+ * @param {boolean} [enabled=true]
1680
+ * @returns {boolean} whether timestamp tracking is now active
1681
+ */
1682
+ enableGPUTiming( enabled = true ) {
1683
+
1684
+ const backend = this.renderer?.backend;
1685
+ if ( ! backend ) return false;
1686
+ if ( enabled && backend.hasFeature?.( 'timestamp-query' ) !== true ) return false;
1687
+
1688
+ backend.trackTimestamp = enabled;
1689
+ return backend.trackTimestamp === enabled;
1690
+
1691
+ }
1692
+
1693
+ /**
1694
+ * Real GPU milliseconds for the last resolved frame, from WebGPU timestamp queries.
1695
+ * Returns null unless {@link PathTracerApp#enableGPUTiming} was called.
1696
+ *
1697
+ * This is the only true GPU metric available: `pipeline.getStats()` times
1698
+ * `performance.now()` around each stage's render(), which is command *encoding*
1699
+ * time and stays flat while GPU cost doubles.
1700
+ *
1701
+ * @returns {Promise<{ compute: number, render: number, total: number }|null>}
1702
+ */
1703
+ async getGPUTimings() {
1704
+
1705
+ const renderer = this.renderer;
1706
+ if ( ! renderer?.backend?.trackTimestamp ) return null;
1707
+
1708
+ await renderer.resolveTimestampsAsync( 'compute' );
1709
+ await renderer.resolveTimestampsAsync( 'render' );
1710
+
1711
+ const compute = renderer.info.compute.timestamp || 0;
1712
+ const render = renderer.info.render.timestamp || 0;
1713
+
1714
+ return { compute, render, total: compute + render };
1715
+
1716
+ }
1717
+
1449
1718
  // ═══════════════════════════════════════════════════════════════
1450
1719
  // Output (absorbed from OutputAPI)
1451
1720
  // ═══════════════════════════════════════════════════════════════
@@ -375,6 +375,12 @@ export class RenderPipeline {
375
375
  }
376
376
 
377
377
  // ===== PERFORMANCE TRACKING =====
378
+ //
379
+ // NOTE: these timings wrap stage.render() in performance.now(), which measures
380
+ // command *encoding* on the CPU — not GPU execution. On a compute-heavy pipeline
381
+ // they stay flat while GPU cost doubles, so they are useful for spotting CPU-side
382
+ // stalls only. For real GPU milliseconds use PathTracerApp.enableGPUTiming() +
383
+ // getGPUTimings(), which read WebGPU timestamp queries.
378
384
 
379
385
  /**
380
386
  * Enable performance tracking
@@ -1303,6 +1303,31 @@ export class SceneProcessor {
1303
1303
 
1304
1304
  }
1305
1305
 
1306
+ // The worker reads triCount * 9 floats unconditionally, and the shared position buffer
1307
+ // is sized from the FIRST call's argument — so a short array reads past its end and
1308
+ // writes NaN into triangleData and every AABB above it, with no error anywhere. A
1309
+ // caller that misses a mesh (the hidden ground disk is easy to miss) sees the scene
1310
+ // silently vanish instead of a thrown exception, so check the length up front.
1311
+ const expectedFloats = this.triangleCount * 9;
1312
+
1313
+ if ( newPositions?.length !== expectedFloats ) {
1314
+
1315
+ throw new Error(
1316
+ `SceneProcessor.refitBVH: expected ${expectedFloats} position floats ` +
1317
+ `(${this.triangleCount} triangles × 9), got ${newPositions?.length ?? 'none'}. ` +
1318
+ 'Positions must cover every triangle in the scene, meshes in this.meshes order.'
1319
+ );
1320
+
1321
+ }
1322
+
1323
+ if ( newNormals && newNormals.length !== expectedFloats ) {
1324
+
1325
+ throw new Error(
1326
+ `SceneProcessor.refitBVH: expected ${expectedFloats} normal floats, got ${newNormals.length}.`
1327
+ );
1328
+
1329
+ }
1330
+
1306
1331
  // Lazy-create worker
1307
1332
  if ( ! this._refitWorker ) {
1308
1333
 
@@ -1428,6 +1453,27 @@ export class SceneProcessor {
1428
1453
 
1429
1454
  }
1430
1455
 
1456
+ // Indexed by absolute triangle, so a short buffer reads undefined → NaN bounds for the
1457
+ // affected meshes with no error. Same silent-corruption trap as refitBVH.
1458
+ const expectedFloats = this.triangleCount * 9;
1459
+
1460
+ if ( newPositions?.length !== expectedFloats ) {
1461
+
1462
+ throw new Error(
1463
+ `SceneProcessor.refitBLASes: expected ${expectedFloats} position floats ` +
1464
+ `(${this.triangleCount} triangles × 9, full scene), got ${newPositions?.length ?? 'none'}.`
1465
+ );
1466
+
1467
+ }
1468
+
1469
+ if ( newNormals && newNormals.length !== expectedFloats ) {
1470
+
1471
+ throw new Error(
1472
+ `SceneProcessor.refitBLASes: expected ${expectedFloats} normal floats, got ${newNormals.length}.`
1473
+ );
1474
+
1475
+ }
1476
+
1431
1477
  const start = performance.now();
1432
1478
 
1433
1479
  // Lazy-create refitter instance
@@ -195,6 +195,14 @@ export class PathTracerStage extends RenderStage {
195
195
  this.stbnScalarTexture = null;
196
196
  this.stbnVec2Texture = null;
197
197
 
198
+ /**
199
+ * Resolves once both STBN atlases have loaded. Await before any render whose
200
+ * output must be reproducible — see PathTracerApp.setDeterministicMode().
201
+ * Replaced by the real promise in setupBlueNoise(), which runs after this.
202
+ * @type {Promise<void>}
203
+ */
204
+ this.blueNoiseReady = Promise.resolve();
205
+
198
206
  // Packed light buffer — [lightBVH nodes (4 vec4s each) | emissive triangles (2 vec4s each)]
199
207
  // emissiveVec4Offset uniform tracks the vec4-count offset where emissive data starts.
200
208
  // Initialized with dummy data so TSL compilation never sees null.
@@ -376,7 +384,7 @@ export class PathTracerStage extends RenderStage {
376
384
 
377
385
  const { stbnScalarAtlas, stbnVec2Atlas } = getAssetConfig();
378
386
 
379
- loader.load( stbnScalarAtlas, ( tex ) => {
387
+ const scalarLoad = loader.loadAsync( stbnScalarAtlas ).then( ( tex ) => {
380
388
 
381
389
  this.stbnScalarTexture = configure( tex );
382
390
  stbnScalarTextureNode.value = tex;
@@ -384,7 +392,7 @@ export class PathTracerStage extends RenderStage {
384
392
 
385
393
  } );
386
394
 
387
- loader.load( stbnVec2Atlas, ( tex ) => {
395
+ const vec2Load = loader.loadAsync( stbnVec2Atlas ).then( ( tex ) => {
388
396
 
389
397
  this.stbnVec2Texture = configure( tex );
390
398
  stbnVec2TextureNode.value = tex;
@@ -392,6 +400,19 @@ export class PathTracerStage extends RenderStage {
392
400
 
393
401
  } );
394
402
 
403
+ // Until the atlases land the STBN sampler reads a constant-0.5 placeholder and bakes
404
+ // that degenerate "noise" permanently into the accumulation buffer. The cut-over frame
405
+ // is disk/network dependent, so reproducible renders must await this. Never rejects.
406
+ this.blueNoiseReady = Promise.allSettled( [ scalarLoad, vec2Load ] ).then( ( results ) => {
407
+
408
+ for ( const result of results ) {
409
+
410
+ if ( result.status === 'rejected' ) log.warn( 'STBN atlas failed to load', result.reason );
411
+
412
+ }
413
+
414
+ } );
415
+
395
416
  }
396
417
 
397
418
  /**