rayzee 7.19.0 → 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/README.md +1 -1
- package/dist/rayzee.es.js +1497 -1407
- package/dist/rayzee.es.js.map +1 -1
- package/dist/rayzee.umd.js +3 -3
- package/dist/rayzee.umd.js.map +1 -1
- package/package.json +1 -1
- package/src/PathTracerApp.js +313 -18
- package/src/Pipeline/RenderPipeline.js +6 -0
- package/src/Processor/SceneProcessor.js +46 -0
- package/src/Stages/PathTracer.js +5 -2
- package/src/Stages/PathTracerStage.js +23 -2
- package/src/managers/OverlayManager.js +4 -0
package/package.json
CHANGED
package/src/PathTracerApp.js
CHANGED
|
@@ -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
|
|
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
|
*/
|
|
@@ -1215,10 +1239,12 @@ export class PathTracerApp extends EventDispatcher {
|
|
|
1215
1239
|
*
|
|
1216
1240
|
* MUST be called before the pipeline/stages are constructed (they pre-allocate at this value and cannot
|
|
1217
1241
|
* resize). Calling it after init only takes effect on the next full pipeline (re)build.
|
|
1218
|
-
* @param {number}
|
|
1242
|
+
* @param {number} requestedPx desired reserved size (longest edge)
|
|
1243
|
+
* @param {Object} [opts]
|
|
1244
|
+
* @param {boolean} [opts.allowLower=false] permit lowering, paying a rebuild, to reclaim VRAM
|
|
1219
1245
|
* @returns {number} the applied reserved size
|
|
1220
1246
|
*/
|
|
1221
|
-
setReservedRenderResolution( requestedPx ) {
|
|
1247
|
+
setReservedRenderResolution( requestedPx, { allowLower = false } = {} ) {
|
|
1222
1248
|
|
|
1223
1249
|
const limits = this.renderer?.backend?.device?.limits;
|
|
1224
1250
|
const maxBinding = limits?.maxStorageBufferBindingSize || ( 128 * 1024 * 1024 );
|
|
@@ -1227,7 +1253,11 @@ export class PathTracerApp extends EventDispatcher {
|
|
|
1227
1253
|
const deviceSafeMax = ( deviceMemGB >= 8 && maxBinding >= 1024 * 1024 * 1024 )
|
|
1228
1254
|
? MAX_RESERVABLE_RENDER_SIZE : 2048;
|
|
1229
1255
|
const prev = MAX_STORAGE_TEXTURE_SIZE;
|
|
1230
|
-
|
|
1256
|
+
|
|
1257
|
+
// Monotonic up: UI-driven callers request whatever the current view needs, so honouring decreases made
|
|
1258
|
+
// the reserve oscillate on every preview↔render switch and paid a full kernel rebuild each time.
|
|
1259
|
+
const target = allowLower ? requestedPx : Math.max( requestedPx, prev );
|
|
1260
|
+
const applied = setReservedRenderSize( Math.min( target, deviceSafeMax ) );
|
|
1231
1261
|
|
|
1232
1262
|
// If the pipeline is already built and the reserved size changed, re-init the reserved GPU storage in
|
|
1233
1263
|
// place: each stage recreates its pre-allocated StorageTextures at the new size + rebuilds its compute
|
|
@@ -1240,6 +1270,17 @@ export class PathTracerApp extends EventDispatcher {
|
|
|
1240
1270
|
this.pauseRendering = true;
|
|
1241
1271
|
try {
|
|
1242
1272
|
|
|
1273
|
+
// Lowering below the live backing store makes copyToReadTargets read past the end of the
|
|
1274
|
+
// (now smaller) write textures — a GPUValidationError. Shrink the backing store first.
|
|
1275
|
+
// renderer.setSize, not setCanvasSize/onResize: those also rewrite camera.aspect.
|
|
1276
|
+
const backing = this.renderer.domElement;
|
|
1277
|
+
if ( backing.width > applied || backing.height > applied ) {
|
|
1278
|
+
|
|
1279
|
+
this.renderer.setSize(
|
|
1280
|
+
Math.min( backing.width, applied ), Math.min( backing.height, applied ), false );
|
|
1281
|
+
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1243
1284
|
for ( const stage of Object.values( this.stages ) ) stage?.reallocateReservedStorage?.();
|
|
1244
1285
|
|
|
1245
1286
|
} finally {
|
|
@@ -1257,6 +1298,16 @@ export class PathTracerApp extends EventDispatcher {
|
|
|
1257
1298
|
|
|
1258
1299
|
}
|
|
1259
1300
|
|
|
1301
|
+
/**
|
|
1302
|
+
* The current reserved (pre-allocated) square render size in px.
|
|
1303
|
+
* @returns {number}
|
|
1304
|
+
*/
|
|
1305
|
+
getReservedRenderResolution() {
|
|
1306
|
+
|
|
1307
|
+
return MAX_STORAGE_TEXTURE_SIZE;
|
|
1308
|
+
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1260
1311
|
/**
|
|
1261
1312
|
* Guard against render resolutions the compute pipeline can't support.
|
|
1262
1313
|
* Per-resolution StorageTextures are pre-allocated at MAX_STORAGE_TEXTURE_SIZE
|
|
@@ -1344,28 +1395,27 @@ export class PathTracerApp extends EventDispatcher {
|
|
|
1344
1395
|
|
|
1345
1396
|
this.cameraManager.controls.enabled = ! isProduction;
|
|
1346
1397
|
|
|
1347
|
-
//
|
|
1398
|
+
// Anything with a SETTING_ROUTES entry must go through settings, not setUniform: set() early-returns on
|
|
1399
|
+
// `prev === value`, so a uniform written behind the map leaves it stale and the next set() silently no-ops.
|
|
1348
1400
|
this.settings.setMany( {
|
|
1349
1401
|
maxSamples: config.maxSamples,
|
|
1350
1402
|
maxBounces: config.bounces,
|
|
1351
1403
|
transmissiveBounces: config.transmissiveBounces,
|
|
1352
1404
|
maxSubsurfaceSteps: config.maxSubsurfaceSteps,
|
|
1405
|
+
enableAlphaShadows: config.enableAlphaShadows ?? false,
|
|
1406
|
+
// Tier-1 convergence early-stop
|
|
1407
|
+
useAdaptiveSampling: config.useAdaptiveSampling ?? false,
|
|
1408
|
+
noiseThreshold: config.noiseThreshold ?? DEFAULT_STATE.noiseThreshold,
|
|
1409
|
+
adaptiveStopFraction: config.adaptiveStopFraction ?? DEFAULT_STATE.adaptiveStopFraction,
|
|
1410
|
+
adaptiveMinSamples: config.adaptiveMinSamples ?? DEFAULT_STATE.adaptiveMinSamples,
|
|
1411
|
+
// Tier-2 per-pixel freeze
|
|
1412
|
+
usePixelFreeze: config.usePixelFreeze ?? false,
|
|
1413
|
+
pixelFreezeThreshold: config.pixelFreezeThreshold ?? DEFAULT_STATE.pixelFreezeThreshold,
|
|
1414
|
+
pixelFreezeStability: config.pixelFreezeStability ?? DEFAULT_STATE.pixelFreezeStability,
|
|
1353
1415
|
}, { silent: true } );
|
|
1354
1416
|
|
|
1417
|
+
// renderMode has no SETTING_ROUTES entry
|
|
1355
1418
|
this.stages.pathTracer?.setUniform( 'renderMode', parseInt( config.renderMode ) );
|
|
1356
|
-
this.stages.pathTracer?.setUniform( 'enableAlphaShadows', config.enableAlphaShadows ?? false );
|
|
1357
|
-
|
|
1358
|
-
// Tier-1 convergence early-stop (live uniforms, no kernel rebuild). Threshold/min-samples fall back to
|
|
1359
|
-
// engine defaults when a config doesn't override them.
|
|
1360
|
-
this.stages.pathTracer?.setUniform( 'useAdaptiveSampling', config.useAdaptiveSampling ?? false );
|
|
1361
|
-
this.stages.pathTracer?.setUniform( 'noiseThreshold', config.noiseThreshold ?? DEFAULT_STATE.noiseThreshold );
|
|
1362
|
-
this.stages.pathTracer?.setUniform( 'adaptiveStopFraction', config.adaptiveStopFraction ?? DEFAULT_STATE.adaptiveStopFraction );
|
|
1363
|
-
this.stages.pathTracer?.setUniform( 'adaptiveMinSamples', config.adaptiveMinSamples ?? DEFAULT_STATE.adaptiveMinSamples );
|
|
1364
|
-
|
|
1365
|
-
// Tier-2 per-pixel freeze (per-mode; falls back off when a config doesn't set it).
|
|
1366
|
-
this.stages.pathTracer?.setUniform( 'usePixelFreeze', config.usePixelFreeze ?? false );
|
|
1367
|
-
this.stages.pathTracer?.setUniform( 'pixelFreezeThreshold', config.pixelFreezeThreshold ?? DEFAULT_STATE.pixelFreezeThreshold );
|
|
1368
|
-
this.stages.pathTracer?.setUniform( 'pixelFreezeStability', config.pixelFreezeStability ?? DEFAULT_STATE.pixelFreezeStability );
|
|
1369
1419
|
|
|
1370
1420
|
this.stages.pathTracer?.updateCompletionThreshold?.();
|
|
1371
1421
|
|
|
@@ -1420,6 +1470,251 @@ export class PathTracerApp extends EventDispatcher {
|
|
|
1420
1470
|
|
|
1421
1471
|
}
|
|
1422
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
|
+
|
|
1423
1718
|
// ═══════════════════════════════════════════════════════════════
|
|
1424
1719
|
// Output (absorbed from OutputAPI)
|
|
1425
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
|
package/src/Stages/PathTracer.js
CHANGED
|
@@ -769,8 +769,11 @@ export class PathTracer extends PathTracerStage {
|
|
|
769
769
|
|
|
770
770
|
if ( ! this._packedBuffers || ! ( this.materialData?.materialCount > 0 ) ) return;
|
|
771
771
|
|
|
772
|
-
|
|
773
|
-
|
|
772
|
+
// Clamp into the (possibly just-lowered) reserve: create() sizes the write StorageTextures at
|
|
773
|
+
// MAX_STORAGE_TEXTURE_SIZE but the readTarget at what's passed here, and the tracked render size still
|
|
774
|
+
// holds the pre-change value — unclamped, copyToReadTargets reads past the write textures.
|
|
775
|
+
const w = Math.min( this.storageTextures.renderWidth || 1, MAX_STORAGE_TEXTURE_SIZE );
|
|
776
|
+
const h = Math.min( this.storageTextures.renderHeight || 1, MAX_STORAGE_TEXTURE_SIZE );
|
|
774
777
|
|
|
775
778
|
// Recreate the MRT pool at the new reserved size (create() reallocates the write StorageTextures at the
|
|
776
779
|
// live MAX_STORAGE_TEXTURE_SIZE; the read RenderTarget follows the current render size).
|
|
@@ -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.
|
|
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.
|
|
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
|
/**
|
|
@@ -382,6 +382,10 @@ export class OverlayManager {
|
|
|
382
382
|
|
|
383
383
|
}
|
|
384
384
|
|
|
385
|
+
// Un-park BEFORE measuring: a display:none canvas reports clientWidth 0, which returns early below and
|
|
386
|
+
// skips the un-park at the end — so once parked the HUD could never come back.
|
|
387
|
+
if ( canvas.style.display !== '' ) canvas.style.display = '';
|
|
388
|
+
|
|
385
389
|
// Helpers draw in CSS-pixel coordinates of the unscaled layout box; the
|
|
386
390
|
// backing store is sized off the on-screen box, so nothing is drawn below
|
|
387
391
|
// view resolution.
|