rayzee 7.15.2 → 7.17.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.
@@ -7,7 +7,7 @@
7
7
  import { uniform, texture, storage } from 'three/tsl';
8
8
  import { StorageInstancedBufferAttribute } from 'three/webgpu';
9
9
  import { PathTracerStage } from './PathTracerStage.js';
10
- import { PackedRayBuffer, GBUFFER_STRIDE, freeStorageAttribute } from '../Processor/PackedRayBuffer.js';
10
+ import { PackedRayBuffer, GBUFFER_STRIDE, RAY_STRIDE, HIT_STRIDE, freeStorageAttribute } from '../Processor/PackedRayBuffer.js';
11
11
  import { QueueManager, COUNTER } from '../Processor/QueueManager.js';
12
12
  import { VRAMTracker } from '../Processor/VRAMTracker.js';
13
13
  import { KernelManager } from '../Processor/KernelManager.js';
@@ -23,7 +23,7 @@ import {
23
23
  buildResetGlobalHistKernel, buildGlobalHistKernel, buildGlobalPrefixKernel, buildGlobalScatterKernel,
24
24
  SORT_GLOBAL_WG_SIZE, SORT_GLOBAL_MAX_BINS,
25
25
  } from '../TSL/SortGlobalKernels.js';
26
- import { ENGINE_DEFAULTS } from '../EngineDefaults.js';
26
+ import { ENGINE_DEFAULTS, MAX_STORAGE_TEXTURE_SIZE } from '../EngineDefaults.js';
27
27
  import {
28
28
  Fn, uint, int, atomicStore, atomicLoad, atomicAdd, instanceIndex, If, Return,
29
29
  } from 'three/tsl';
@@ -100,6 +100,18 @@ export class PathTracer extends PathTracerStage {
100
100
  this._wfMaxRayCount = uniform( 0, 'uint' );
101
101
  this._wfCurrentBounce = uniform( 0, 'int' );
102
102
 
103
+ // Blender-style chunked path pool (docs/internal/specs/wavefront-chunked-pool.md): the per-path SoA
104
+ // pool holds a fixed device-budget B of paths-in-flight, decoupled from resolution. The image is
105
+ // streamed through it in row bands; these uniforms carry the current band's offset so kernels map
106
+ // local path slot r ↔ global pixel p = pixelBase + r (pixelBase = _wfChunkRowBase · renderWidth).
107
+ this._wfChunkRowBase = uniform( 0, 'int' ); // first GLOBAL row of the current chunk
108
+ this._wfChunkRows = uniform( 0, 'int' ); // number of rows in the current chunk (≤ chunkRows)
109
+ this._wfIsFirstChunk = uniform( 1, 'uint' ); // 1 → zero frame-scoped counters (CONVERGED/FROZEN)
110
+ this._pathBudget = 0; // B, paths-in-flight; computed once from device limits in _buildWavefrontKernels
111
+ this._pathBudgetOverride = 0; // test hook: force B (0 = derive from device)
112
+ this._chunkRows = 0; // rows per chunk = floor(B / renderWidth), clamped ≥1
113
+ this._numChunks = 1; // ceil(renderHeight / chunkRows)
114
+
103
115
  // VRAM accounting — providers are thunks reading CURRENT live resources,
104
116
  // so they survive buffer/texture reallocation (resize, scene/material reload).
105
117
  this.vramTracker = new VRAMTracker( this.renderer );
@@ -298,151 +310,157 @@ export class PathTracer extends PathTracerStage {
298
310
  const maxBounces = this.maxBounces.value;
299
311
  // Transmissive/SSS steps consume iterations without advancing camera-bounce depth, so the loop must run far enough for deep glass/subsurface walks (mirror PathTracerCore); the survivor curve + early-exit break it early on non-SSS scenes.
300
312
  const loopBound = maxBounces + this.transmissiveBounces.value + this.maxSubsurfaceSteps.value;
301
- const maxRays = this._wfMaxRayCount.value;
302
313
 
303
- // Tier-2 bounce-0 grid size from the stale settled active-pixel count (maxRays until one lands).
304
- // Monotonic freeze ⇒ a stale count is a safe over-estimate; reset/camera-move → _curveSizingValid=false → maxRays.
305
- const adaptiveFreeze = this.usePixelFreeze.value > 0;
306
- const activeBounce0 = ( adaptiveFreeze && this._curveSizingValid && this._lastActivePixelCount > 0 )
307
- ? this._lastActivePixelCount : maxRays;
314
+ const frameHeight = this._wfRenderHeight.value;
315
+ const chunkRows = this._chunkRows;
316
+ const singleChunk = this._numChunks <= 1;
317
+
318
+ // The survivor curve is a single per-frame buffer; multi-chunk bands would overwrite each other's
319
+ // counts, so curve-based sizing AND the per-bounce early-exit apply only in the single-chunk regime
320
+ // (the common case → behaviour identical to before). Multi-chunk (huge frame / small budget) runs full
321
+ // dispatch over every bounce — correct, mildly slower.
322
+ //
323
+ // The curve survives a maxBounces change (reset() preserves it): for iterations below BOTH the old and
324
+ // new camera-bounce caps the counts are cap-independent, so trust it up to that cutoff (whole curve when
325
+ // same/decreasing budget; only the [0, oldBudget) overlap when increasing). budget=-1 → cutoff 0 → full
326
+ // dispatch everywhere. This avoids the full-work spike on every bounce-count change.
327
+ const curve = this._lastBounceCounts;
328
+ const curveReliableUpto = ( singleChunk && curve )
329
+ ? ( maxBounces <= this._lastBounceCountsBudget ? loopBound + 1 : this._lastBounceCountsBudget )
330
+ : 0;
308
331
 
309
- if ( adaptiveFreeze ) {
332
+ // Blender-style row-band streaming: the fixed-budget path pool processes the image in bands of ≤ chunkRows
333
+ // rows (one chunk when the frame fits the budget → identical to the pre-chunking path). See spec.
334
+ for ( let chunkIndex = 0, rowBase = 0; rowBase < frameHeight; rowBase += chunkRows, chunkIndex ++ ) {
310
335
 
311
- // reset counters → compact non-frozen pixel IDs → publish count → list-driven 1D generate.
312
- const genSized = Math.min( maxRays, Math.ceil( activeBounce0 * 1.5 ) + 1024 );
313
- km.setDispatchCount( 'buildActivePixels', [ Math.ceil( maxRays / 256 ), 1, 1 ] );
314
- km.setDispatchCount( 'generateList', [ Math.ceil( genSized / 256 ), 1, 1 ] );
315
- km.dispatch( 'resetFrameCounters' );
316
- km.dispatch( 'buildActivePixels' );
317
- km.dispatch( 'seedEnter' );
318
- km.dispatch( 'generateList' );
336
+ const rows = Math.min( chunkRows, frameHeight - rowBase );
337
+ const maxRays = this._setChunk( rowBase, rows, chunkIndex ); // this band's pixel count + per-chunk grids
319
338
 
320
- } else {
339
+ // Tier-2 bounce-0 grid size from the stale settled active-pixel count (single-chunk only; else full).
340
+ // Monotonic freeze ⇒ a stale count is a safe over-estimate; reset/camera-move → _curveSizingValid=false.
341
+ const adaptiveFreeze = this.usePixelFreeze.value > 0;
342
+ const activeBounce0 = ( singleChunk && adaptiveFreeze && this._curveSizingValid && this._lastActivePixelCount > 0 )
343
+ ? this._lastActivePixelCount : maxRays;
321
344
 
322
- km.dispatch( 'generate' );
323
- // Generate traces every pixel; seed ACTIVE + ENTERING_COUNT from the full identity active list
324
- // (initActiveIndices overwrites both counters, so no separate frame-start reset is needed).
325
- km.dispatch( 'initActiveIndices' );
345
+ if ( adaptiveFreeze ) {
326
346
 
327
- }
347
+ // reset counters → compact non-frozen pixel IDs → publish count → list-driven 1D generate.
348
+ const genSized = Math.min( maxRays, Math.ceil( activeBounce0 * 1.5 ) + 1024 );
349
+ km.setDispatchCount( 'generateList', [ Math.ceil( genSized / 256 ), 1, 1 ] );
350
+ km.dispatch( 'resetFrameCounters' );
351
+ km.dispatch( 'buildActivePixels' );
352
+ km.dispatch( 'seedEnter' );
353
+ km.dispatch( 'generateList' );
328
354
 
329
- // The survivor curve survives a maxBounces change (reset() preserves it), and is reusable
330
- // across one: for loop iterations below BOTH the old and new camera-bounce caps, no ray has
331
- // been killed by either cap, so the counts are cap-independent. Trust the curve up to that
332
- // cutoff — the whole curve when same-budget or decreasing (old counts only over-estimate, so
333
- // sizing over-sizes and early-exit fires later — both safe); only the overlap [0, oldBudget)
334
- // when increasing (beyond it the old cap already culled rays → under-estimate → would drop
335
- // rays). Past the cutoff, full dispatch + no early-exit. Avoids the full-work spike (a visible
336
- // hitch) on every bounce-count change while a fresh curve is read back. budget=-1 (no curve,
337
- // e.g. cold start / post-resize) → cutoff 0 → full dispatch everywhere, matching prior behavior.
338
- const curve = this._lastBounceCounts;
339
- const curveReliableUpto = curve
340
- ? ( maxBounces <= this._lastBounceCountsBudget ? loopBound + 1 : this._lastBounceCountsBudget )
341
- : 0;
355
+ } else {
342
356
 
343
- for ( let bounce = 0; bounce <= loopBound; bounce ++ ) {
357
+ km.dispatch( 'generate' );
358
+ // Generate traces every pixel in the band; initActiveIndices seeds the identity active list + counts
359
+ // (it overwrites ACTIVE + ENTERING, so no separate frame-start reset is needed).
360
+ km.dispatch( 'initActiveIndices' );
344
361
 
345
- this._wfCurrentBounce.value = bounce;
362
+ }
346
363
 
347
- // Functional-compaction path (dynamic dispatch): copyback keeps the read buffer dense, kernels sized to live survivors. Dynamic-off uses the full path (ENTERING=maxRays, identity buffer).
348
- // Material sort is compatible: shade reads sortedIndices while compact still reads the UNSORTED active list (getActiveReadRO), so the survivor set is unchanged.
349
- const useFunctionalCompaction = this._useDynamicDispatch;
350
- if ( useFunctionalCompaction ) {
364
+ for ( let bounce = 0; bounce <= loopBound; bounce ++ ) {
351
365
 
352
- // ENTERING_COUNT already set (bounce 0 by initActiveIndices, N>0 by snapshotBounceCount); size from last frame's survivor curve with a 1.5×+1024 margin.
353
- // Only trust the curve for sizing when it was measured at the CURRENT (settled) view; while
354
- // the camera moves / before a settled readback, full-size (entering = maxRays) so the tail is
355
- // never dropped. Early-exit below still uses the curve regardless (a stale one only trims empty deep bounces).
356
- let entering = maxRays;
357
- if ( bounce > 0 && this._curveSizingValid ) {
366
+ this._wfCurrentBounce.value = bounce;
358
367
 
359
- const idx = bounce - 1;
360
- let prev;
361
- if ( idx < curveReliableUpto && curve[ idx ] !== undefined ) {
368
+ // Functional-compaction path (dynamic dispatch): copyback keeps the read buffer dense, kernels sized to live survivors. Dynamic-off uses the full path (ENTERING=maxRays, identity buffer).
369
+ // Material sort is compatible: shade reads sortedIndices while compact still reads the UNSORTED active list (getActiveReadRO), so the survivor set is unchanged.
370
+ const useFunctionalCompaction = this._useDynamicDispatch;
371
+ if ( useFunctionalCompaction ) {
362
372
 
363
- prev = curve[ idx ]; // trusted exact count
373
+ // ENTERING_COUNT already set (bounce 0 by initActiveIndices, N>0 by snapshotBounceCount); size from last frame's survivor curve with a 1.5×+1024 margin (single-chunk only).
374
+ let entering = maxRays;
375
+ if ( singleChunk && bounce > 0 && this._curveSizingValid ) {
364
376
 
365
- } else if ( curveReliableUpto > 0 ) {
377
+ const idx = bounce - 1;
378
+ let prev;
379
+ if ( idx < curveReliableUpto && curve[ idx ] !== undefined ) {
366
380
 
367
- // Untrusted tail after a maxBounces increase: survivor counts are monotonically
368
- // non-increasing across bounces (rays only terminate), so the last trusted count is
369
- // a safe upper bound — far below maxRays, so no full-dispatch spike. Safe even if it
370
- // reads low: a count <= threshold would have tripped the early-exit before this bounce.
371
- prev = curve[ curveReliableUpto - 1 ];
381
+ prev = curve[ idx ]; // trusted exact count
372
382
 
373
- }
383
+ } else if ( curveReliableUpto > 0 ) {
374
384
 
375
- entering = prev > 0 ? prev : maxRays;
385
+ // Untrusted tail after a maxBounces increase: survivor counts are monotonically
386
+ // non-increasing (rays only terminate), so the last trusted count is a safe upper bound.
387
+ prev = curve[ curveReliableUpto - 1 ];
376
388
 
377
- } else if ( bounce === 0 && adaptiveFreeze ) {
389
+ }
378
390
 
379
- // Bounce 0 traces only non-frozen pixels — size from the stale active count (full until a readback).
380
- entering = activeBounce0;
391
+ entering = prev > 0 ? prev : maxRays;
381
392
 
382
- }
393
+ } else if ( bounce === 0 && adaptiveFreeze ) {
383
394
 
384
- const sized = Math.min( maxRays, Math.ceil( entering * 1.5 ) + 1024 );
385
- const wg = [ Math.ceil( sized / 256 ), 1, 1 ];
386
- km.setDispatchCount( 'extend', wg );
387
- km.setDispatchCount( 'shade', wg );
388
- km.setDispatchCount( 'globalHist', wg );
389
- km.setDispatchCount( 'globalScatter', wg );
390
- km.setDispatchCount( 'compact', wg );
391
- km.setDispatchCount( 'compactCopyback', wg );
395
+ // Bounce 0 traces only non-frozen pixels — size from the stale active count (full until a readback).
396
+ entering = activeBounce0;
392
397
 
393
- } else {
398
+ }
394
399
 
395
- km.dispatch( 'enterFull' );
396
- const full = [ Math.ceil( maxRays / 256 ), 1, 1 ];
397
- km.setDispatchCount( 'extend', full );
398
- km.setDispatchCount( 'shade', full );
399
- km.setDispatchCount( 'compact', full );
400
- km.setDispatchCount( 'globalHist', full );
401
- km.setDispatchCount( 'globalScatter', full );
400
+ const sized = Math.min( maxRays, Math.ceil( entering * 1.5 ) + 1024 );
401
+ const wg = [ Math.ceil( sized / 256 ), 1, 1 ];
402
+ km.setDispatchCount( 'extend', wg );
403
+ km.setDispatchCount( 'shade', wg );
404
+ km.setDispatchCount( 'globalHist', wg );
405
+ km.setDispatchCount( 'globalScatter', wg );
406
+ km.setDispatchCount( 'compact', wg );
407
+ km.setDispatchCount( 'compactCopyback', wg );
408
+
409
+ } else {
410
+
411
+ km.dispatch( 'enterFull' );
412
+ const full = [ Math.ceil( maxRays / 256 ), 1, 1 ];
413
+ km.setDispatchCount( 'extend', full );
414
+ km.setDispatchCount( 'shade', full );
415
+ km.setDispatchCount( 'compact', full );
416
+ km.setDispatchCount( 'globalHist', full );
417
+ km.setDispatchCount( 'globalScatter', full );
402
418
 
403
- }
419
+ }
404
420
 
405
- // Extend/Shade kept separate (not fused): a fused kernel's register pressure drops occupancy more than fusion saves.
406
- km.dispatch( 'extend' );
407
- if ( this._sortMaterials ) {
421
+ // Extend/Shade kept separate (not fused): a fused kernel's register pressure drops occupancy more than fusion saves.
422
+ km.dispatch( 'extend' );
423
+ if ( this._sortMaterials ) {
408
424
 
409
- // Global material counting sort (material-pure workgroups): reset, histogram, prefix-sum, scatter.
410
- km.dispatch( 'resetGlobalHist' );
411
- km.dispatch( 'globalHist' );
412
- km.dispatch( 'globalPrefix' );
413
- km.dispatch( 'globalScatter' );
425
+ // Global material counting sort (material-pure workgroups): reset, histogram, prefix-sum, scatter.
426
+ km.dispatch( 'resetGlobalHist' );
427
+ km.dispatch( 'globalHist' );
428
+ km.dispatch( 'globalPrefix' );
429
+ km.dispatch( 'globalScatter' );
414
430
 
415
- }
431
+ }
416
432
 
417
- km.dispatch( 'shade' ); // shade thread 0 folds resetActiveCounter (zeroes ACTIVE_RAY_COUNT before compact)
418
- km.dispatch( 'compact' );
419
- if ( useFunctionalCompaction ) {
433
+ km.dispatch( 'shade' ); // shade thread 0 folds resetActiveCounter (zeroes ACTIVE_RAY_COUNT before compact)
434
+ km.dispatch( 'compact' );
435
+ if ( useFunctionalCompaction ) {
420
436
 
421
- // compactCopyback thread 0 folds snapshotBounceCount (records the survivor curve + seeds ENTERING).
422
- km.dispatch( 'compactCopyback' );
437
+ // compactCopyback thread 0 folds snapshotBounceCount (records the survivor curve + seeds ENTERING).
438
+ km.dispatch( 'compactCopyback' );
423
439
 
424
- } else {
440
+ } else {
425
441
 
426
- km.dispatch( 'snapshotBounceCount' );
442
+ km.dispatch( 'snapshotBounceCount' );
427
443
 
428
- }
429
- // No swap: pingPong stays 0 (kernels are build-time-bound to buffer A).
444
+ }
445
+ // No swap: pingPong stays 0 (kernels are build-time-bound to buffer A).
430
446
 
431
- // Early-exit on last frame's per-bounce snapshot (stale via async readback, fine for a heuristic).
432
- if (
433
- bounce < curveReliableUpto
434
- && bounce < loopBound
435
- && curve[ bounce ] !== undefined
436
- && curve[ bounce ] <= this._bounceEarlyExitThreshold
437
- ) {
447
+ // Early-exit on last frame's per-bounce snapshot (single-chunk only; curveReliableUpto=0 disables it for multi-chunk).
448
+ if (
449
+ bounce < curveReliableUpto
450
+ && bounce < loopBound
451
+ && curve[ bounce ] !== undefined
452
+ && curve[ bounce ] <= this._bounceEarlyExitThreshold
453
+ ) {
438
454
 
439
- break;
455
+ break;
456
+
457
+ }
440
458
 
441
459
  }
442
460
 
443
- }
461
+ km.dispatch( 'finalWrite' );
444
462
 
445
- km.dispatch( 'finalWrite' );
463
+ }
446
464
 
447
465
  this._maybeReadbackCounters();
448
466
 
@@ -593,7 +611,9 @@ export class PathTracer extends PathTracerStage {
593
611
 
594
612
  this._convergedReadbackPending = true;
595
613
  const cgen = this._readbackGeneration;
596
- const total = this._wfMaxRayCount.value;
614
+ // Full-FRAME pixel count (CONVERGED_COUNT sums across all chunks) — not _wfMaxRayCount, which
615
+ // after the chunk loop holds only the last band's pixel count.
616
+ const total = this._wfRenderWidth.value * this._wfRenderHeight.value;
597
617
  this.renderer.getArrayBufferAsync( cAttr ).then( ( buf ) => {
598
618
 
599
619
  if ( cgen === this._readbackGeneration && total > 0 ) {
@@ -655,12 +675,13 @@ export class PathTracer extends PathTracerStage {
655
675
  this._convergedFraction = 0;
656
676
  this._lastActivePixelCount = 0;
657
677
 
658
- // Recompile only when buffers reallocate (capacity grows); otherwise resize uniforms in place.
659
- const neededCap = PackedRayBuffer.requiredCapacity( newW * newH );
660
- const mustRebuild = ! this._packedBuffers
661
- || neededCap > this._packedBuffers.capacity;
662
-
663
- if ( mustRebuild ) {
678
+ // Chunked path pool: the wavefront buffers are sized to the fixed device budget B (not the
679
+ // resolution), and the per-pixel persistent buffers to the reserved max, so a resolution change
680
+ // reallocates NOTHING and rebuilds NO kernels — it only updates the render-size uniforms + the
681
+ // row-band chunk layout. The one-time build happens on model load / light realloc. This is the fix
682
+ // for the resize freeze (was: capacity-grow → recreate all compute nodes → WGSL regen, ~1.8 s on
683
+ // complex scenes). See docs/internal/specs/wavefront-chunked-pool.md.
684
+ if ( ! this._packedBuffers ) {
664
685
 
665
686
  if ( this._kernelManager ) this._kernelManager.dispose();
666
687
  this._wavefrontReady = false;
@@ -674,23 +695,93 @@ export class PathTracer extends PathTracerStage {
674
695
 
675
696
  }
676
697
 
677
- // Same-capacity resize: update render-size uniforms + early-exit threshold, no recompile.
698
+ // Resolution change with a fixed-budget pool: update render-size uniforms + recompute the row-band chunk
699
+ // layout + rescale the early-exit threshold. No buffer realloc, no kernel recompile.
678
700
  _resizeWavefrontInPlace( w, h ) {
679
701
 
680
- const maxRays = w * h;
681
702
  this._wfRenderWidth.value = w;
682
703
  this._wfRenderHeight.value = h;
683
- this._wfMaxRayCount.value = maxRays;
684
- // initActiveIndices is dispatched bare (not re-sized per frame); its grid must cover the grown range or the identity buffer is left unseeded over [oldMaxRays, maxRays).
685
- this._kernelManager?.setDispatchCount( 'initActiveIndices', [ Math.ceil( maxRays / 256 ), 1, 1 ] );
704
+ this._updateChunkLayout();
686
705
  if ( this._bounceEarlyExitThreshold !== - 1 ) {
687
706
 
688
- this._bounceEarlyExitThreshold = Math.max( 100, Math.floor( maxRays / 1000 ) );
707
+ this._bounceEarlyExitThreshold = Math.max( 100, Math.floor( ( w * h ) / 1000 ) );
689
708
 
690
709
  }
691
710
 
692
711
  }
693
712
 
713
+ // Device-adaptive paths-in-flight budget B (Blender path-pool). Constrained by:
714
+ // (1) each single storage buffer ≤ maxStorageBufferBindingSize — the RAY buffer (B·RAY_STRIDE·16 B) binds
715
+ // largest, so B·RAY_STRIDE·16 ≤ 0.9·maxStorageBufferBindingSize;
716
+ // (2) total pool ≤ a fraction of device memory;
717
+ // (3) floor at 512² of paths, cap at the reserved max pixels (no point pooling more paths than pixels).
718
+ // B is resolution-INDEPENDENT, so _cap is fixed and the kernels build once. The render-loop chunking streams
719
+ // any resolution through this pool, so a small B just means more row-bands (works on weak / small-VRAM GPUs).
720
+ _computePathBudget() {
721
+
722
+ if ( this._pathBudgetOverride > 0 ) return this._pathBudgetOverride;
723
+
724
+ const RAY_BYTES = RAY_STRIDE * 16;
725
+ const bytesPerPath = RAY_STRIDE * 16 + HIT_STRIDE * 16 + 4 /* rng */ + GBUFFER_STRIDE * 16
726
+ + 4 + 4 /* activeIndices A/B */ + 4;
727
+
728
+ const limits = this.renderer?.backend?.device?.limits;
729
+ const maxBinding = limits?.maxStorageBufferBindingSize || ( 128 * 1024 * 1024 );
730
+ const maxBuffer = limits?.maxBufferSize || maxBinding;
731
+
732
+ const bByBinding = Math.floor( ( maxBinding * 0.9 ) / RAY_BYTES );
733
+ const deviceMemBytes = ( ( typeof navigator !== 'undefined' && navigator.deviceMemory ) || 4 ) * 1024 * 1024 * 1024;
734
+ const poolVramBudget = Math.min( deviceMemBytes * 0.25, maxBuffer * 4 );
735
+ const bByVram = Math.floor( poolVramBudget / bytesPerPath );
736
+
737
+ const MIN_B = 512 * 512;
738
+ // Working-set ceiling — deliberately DECOUPLED from the reserved framebuffer size. Capping the pool at
739
+ // ~2048² paths (~940 MB) keeps VRAM bounded; frames larger than B (e.g. 4K) just stream in more row-band
740
+ // chunks rather than reserving a giant pool (4K single-chunk would be ~3 GB of path state for ~no speedup).
741
+ const MAX_B = 2048 * 2048;
742
+ let B = Math.min( bByBinding, bByVram, MAX_B );
743
+ B = Math.max( MIN_B, B );
744
+ return B;
745
+
746
+ }
747
+
748
+ // Row-aligned chunk layout for the current resolution and budget B. A chunk is `_chunkRows` full rows so the
749
+ // 2D ray-gen grid stays cache-coherent and pixelBase = rowBase·W is exact. chunkPixels = _chunkRows·W ≤ B.
750
+ _updateChunkLayout() {
751
+
752
+ const w = Math.max( 1, this.storageTextures.renderWidth );
753
+ const h = this.storageTextures.renderHeight;
754
+ const B = this._pathBudget || ( w * h );
755
+ this._chunkRows = Math.max( 1, Math.min( Math.floor( B / w ), h ) );
756
+ this._numChunks = Math.max( 1, Math.ceil( h / this._chunkRows ) );
757
+
758
+ }
759
+
760
+ // Reserved-storage change (e.g. enabling 4K): the MRT StorageTextures are pre-allocated at
761
+ // MAX_STORAGE_TEXTURE_SIZE and can't be resized, so recreate the pool at the (new) live reserved size and
762
+ // rebuild the wavefront kernels in place. The stage OBJECT is unchanged, so manager/event refs stay valid;
763
+ // only GPU textures + compute pipelines are rebuilt. Scene buffers (BVH/tri/material) are resolution-
764
+ // independent and untouched. Caller must have rendering paused. No-op before the first build.
765
+ reallocateReservedStorage() {
766
+
767
+ if ( ! this._packedBuffers || ! ( this.materialData?.materialCount > 0 ) ) return;
768
+
769
+ const w = this.storageTextures.renderWidth || 1;
770
+ const h = this.storageTextures.renderHeight || 1;
771
+
772
+ // Recreate the MRT pool at the new reserved size (create() reallocates the write StorageTextures at the
773
+ // live MAX_STORAGE_TEXTURE_SIZE; the read RenderTarget follows the current render size).
774
+ this.storageTextures.create( w, h );
775
+ this.resolution.value.set( w, h );
776
+
777
+ // Rebuild kernels: re-references the fresh write textures + recreates the per-pixel aux buffers
778
+ // (m2/streak/frozenMask) at the new maxPixels. Prev-frame nodes are repointed per-frame in render().
779
+ if ( this._kernelManager ) this._kernelManager.dispose();
780
+ this._wavefrontReady = false;
781
+ this._buildWavefrontKernels();
782
+
783
+ }
784
+
694
785
  _buildWavefrontKernels() {
695
786
 
696
787
  const texNodes = this.shaderBuilder.getSceneTextureNodes();
@@ -701,51 +792,63 @@ export class PathTracer extends PathTracerStage {
701
792
 
702
793
  const w = this.storageTextures.renderWidth;
703
794
  const h = this.storageTextures.renderHeight;
704
- const maxRays = w * h;
795
+
796
+ // Fixed device-budget path pool B (resolution-independent) + the row-band chunk layout for this frame.
797
+ if ( ! this._pathBudget ) this._pathBudget = this._computePathBudget();
798
+ const B = this._pathBudget;
799
+ this._updateChunkLayout();
800
+
801
+ // Rays in one row-band chunk — the initial dispatch bound for kernel registration. render() overrides
802
+ // every per-ray kernel's dispatch per chunk; this is just a valid starting grid (always ≤ B).
803
+ const maxRays = this._chunkRows * w;
705
804
 
706
805
  if ( this._bounceEarlyExitThreshold !== - 1 ) {
707
806
 
708
- this._bounceEarlyExitThreshold = Math.max( 100, Math.floor( maxRays / 1000 ) );
807
+ this._bounceEarlyExitThreshold = Math.max( 100, Math.floor( ( w * h ) / 1000 ) );
709
808
 
710
809
  }
711
810
 
811
+ // Per-path buffers (RAY/HIT/rng) sized to the budget B and indexed by LOCAL slot r ∈ [0,B). _cap = B is
812
+ // baked into the SoA stride but never changes with resolution, so this build happens once (model load).
712
813
  if ( ! this._packedBuffers ) {
713
814
 
714
- this._packedBuffers = new PackedRayBuffer( maxRays, this.renderer );
815
+ this._packedBuffers = new PackedRayBuffer( B, this.renderer );
715
816
 
716
817
  } else {
717
818
 
718
- this._packedBuffers.resize( maxRays );
819
+ this._packedBuffers.resize( B );
719
820
 
720
821
  }
721
822
 
722
- // Per-pixel G-buffer (first-hit MRT: ND + albedo), 1 uvec4/pixel — half-precision packed (pack2x16).
723
- // uint (not f32) buffer: packed lanes can hit the NaN exponent range (e.g. snorm 1.0 → 0x7FFF), which a
724
- // GPU may canonicalize through f32 storage; u32 stores the bits verbatim. Separate from RAY — it's
725
- // per-pixel, written by Generate/Shade bounce-0 and read only by FinalWrite.
726
- // 1.25× margin (same as the per-ray buffers) so it survives the in-place-resize range.
727
- const gBufferVec4s = PackedRayBuffer.requiredCapacity( maxRays ) * GBUFFER_STRIDE;
728
- freeStorageAttribute( this.renderer, this._gBufferAttr ); // free the outgoing G-buffer on capacity growth
823
+ // Per-CHUNK first-hit G-buffer (LOCAL slot r, size B), 1 uvec4/slot half-packed (pack2x16). Written by
824
+ // Generate + Shade(bounce 0), read by Shade + FinalWrite within the SAME chunk, so per-chunk suffices.
825
+ // uint (not f32): packed lanes can hit the NaN exponent range (snorm 1.0 → 0x7FFF) that an f32 store may
826
+ // canonicalize; u32 stores the bits verbatim.
827
+ const gBufferVec4s = B * GBUFFER_STRIDE;
828
+ freeStorageAttribute( this.renderer, this._gBufferAttr );
729
829
  this._gBufferAttr = new StorageInstancedBufferAttribute( new Uint32Array( gBufferVec4s * 4 ), 4 );
730
830
  const gBufferRW = storage( this._gBufferAttr, 'uvec4' );
731
831
  const gBufferRO = storage( this._gBufferAttr, 'uvec4' ).toReadOnly();
732
832
 
733
- // Tier-1 convergence: one f32/pixel holding the running mean of luminance² (Welford second moment),
734
- // read+written by FinalWrite. 1.25× margin (same as the per-ray buffers) so it survives in-place resize;
735
- // allocated only on this capacity-growth/rebuild path, never in _resizeWavefrontInPlace.
833
+ // Per-PIXEL persistent buffers (m2/streak/frozenMask) are GLOBAL-pixel-indexed (p = pixelBase + r) and
834
+ // must span the whole frame across chunks AND persist across frames, so they're sized to the reserved
835
+ // max resolution (small — 12 B/pixel) and NEVER realloc on a resize.
836
+ const maxPixels = MAX_STORAGE_TEXTURE_SIZE * MAX_STORAGE_TEXTURE_SIZE;
837
+
838
+ // Tier-1 convergence: per-pixel running mean of luminance² (Welford second moment), read+written by FinalWrite.
736
839
  freeStorageAttribute( this.renderer, this._m2Attr );
737
- this._m2Attr = new StorageInstancedBufferAttribute( new Float32Array( PackedRayBuffer.requiredCapacity( maxRays ) ), 1 );
840
+ this._m2Attr = new StorageInstancedBufferAttribute( new Float32Array( maxPixels ), 1 );
738
841
  const m2RW = storage( this._m2Attr, 'float' );
739
842
 
740
- // Tier-2: one u32/pixel freeze-candidate streak. FinalWrite writes (RW), buildActivePixels reads (RO).
843
+ // Tier-2: per-pixel freeze-candidate streak. FinalWrite writes (RW), buildActivePixels reads (RO).
741
844
  freeStorageAttribute( this.renderer, this._streakAttr );
742
- this._streakAttr = new StorageInstancedBufferAttribute( new Uint32Array( PackedRayBuffer.requiredCapacity( maxRays ) ), 1 );
845
+ this._streakAttr = new StorageInstancedBufferAttribute( new Uint32Array( maxPixels ), 1 );
743
846
  const streakRW = storage( this._streakAttr, 'uint' );
744
847
  const streakRO = storage( this._streakAttr, 'uint' ).toReadOnly();
745
848
 
746
849
  // Tier-2: dilated frozen mask (1 = skip). buildActivePixels writes; active-list + FinalWrite read → race-free.
747
850
  freeStorageAttribute( this.renderer, this._frozenMaskAttr );
748
- this._frozenMaskAttr = new StorageInstancedBufferAttribute( new Uint32Array( PackedRayBuffer.requiredCapacity( maxRays ) ), 1 );
851
+ this._frozenMaskAttr = new StorageInstancedBufferAttribute( new Uint32Array( maxPixels ), 1 );
749
852
  const frozenMaskRW = storage( this._frozenMaskAttr, 'uint' );
750
853
  const frozenMaskRO = storage( this._frozenMaskAttr, 'uint' ).toReadOnly();
751
854
 
@@ -770,7 +873,8 @@ export class PathTracer extends PathTracerStage {
770
873
 
771
874
  this._wfRenderWidth.value = w;
772
875
  this._wfRenderHeight.value = h;
773
- this._wfMaxRayCount.value = maxRays;
876
+ // _wfMaxRayCount is the CURRENT chunk's pixel count; set per-chunk in render(). Seed with the first chunk.
877
+ this._wfMaxRayCount.value = Math.min( B, this._chunkRows * w );
774
878
 
775
879
  const prevColor = this.shaderBuilder.prevColorTexNode;
776
880
  const prevAlbedo = this.shaderBuilder.prevAlbedoTexNode;
@@ -801,22 +905,30 @@ export class PathTracer extends PathTracerStage {
801
905
  const initFn = Fn( () => {
802
906
 
803
907
  const tid = instanceIndex;
804
- activeWriteA.element( tid ).assign( tid );
805
- // Seed ACTIVE_RAY_COUNT + ENTERING_COUNT from the _wfMaxRayCount uniform (not a literal) so in-place resize works.
908
+ // LOCAL-slot identity for this chunk's active list. Bounded on _wfMaxRayCount (= chunkPixels): the
909
+ // pool has no over-allocation margin now, so the dispatch-grid overshoot must not write past it.
910
+ If( tid.lessThan( this._wfMaxRayCount ), () => {
911
+
912
+ activeWriteA.element( tid ).assign( tid );
913
+
914
+ } );
915
+ // Seed ACTIVE/ENTERING from this chunk's ray count. CONVERGED is a per-FRAME counter (summed across
916
+ // chunks in FinalWrite), so zero it only on the first chunk of the frame.
806
917
  If( tid.equal( uint( 0 ) ), () => {
807
918
 
808
919
  atomicStore( counters.element( uint( COUNTER.ACTIVE_RAY_COUNT ) ), this._wfMaxRayCount );
809
920
  atomicStore( counters.element( uint( COUNTER.ENTERING_COUNT ) ), this._wfMaxRayCount );
810
- // Tier-1 convergence: zero the per-frame converged-pixel counter here — once/frame at frame start,
811
- // before FinalWrite atomicAdds into it. (Was in the now-removed resetCounters kernel; must NOT go
812
- // in shade's per-bounce reset, since FinalWrite accumulates only once, at frame end.)
813
- atomicStore( counters.element( uint( COUNTER.CONVERGED_COUNT ) ), uint( 0 ) );
921
+ If( this._wfIsFirstChunk.greaterThan( uint( 0 ) ), () => {
922
+
923
+ atomicStore( counters.element( uint( COUNTER.CONVERGED_COUNT ) ), uint( 0 ) );
924
+
925
+ } );
814
926
 
815
927
  } );
816
928
 
817
929
  } );
818
930
  this._kernelManager.register( 'initActiveIndices',
819
- initFn().compute( [ Math.ceil( maxRays / 256 ), 1, 1 ], [ 256, 1, 1 ] )
931
+ initFn().compute( [ Math.ceil( ( this._chunkRows * w ) / 256 ), 1, 1 ], [ 256, 1, 1 ] )
820
932
  );
821
933
 
822
934
  const genParams = {
@@ -836,6 +948,8 @@ export class PathTracer extends PathTracerStage {
836
948
  anamorphicRatio: this.anamorphicRatio,
837
949
  renderWidth: this._wfRenderWidth,
838
950
  renderHeight: this._wfRenderHeight,
951
+ chunkRowBase: this._wfChunkRowBase,
952
+ chunkRows: this._wfChunkRows,
839
953
  transmissiveBounces: this.transmissiveBounces,
840
954
  transparentBackground: this.transparentBackground,
841
955
  auxGBufferEnabled: this._auxGBufferUniform,
@@ -843,7 +957,7 @@ export class PathTracer extends PathTracerStage {
843
957
  const genFn = buildGenerateKernel( genParams );
844
958
  this._kernelManager.register( 'generate',
845
959
  genFn().compute(
846
- [ Math.ceil( w / GENERATE_WG_SIZE ), Math.ceil( h / GENERATE_WG_SIZE ), 1 ],
960
+ [ Math.ceil( w / GENERATE_WG_SIZE ), Math.ceil( this._chunkRows / GENERATE_WG_SIZE ), 1 ],
847
961
  [ GENERATE_WG_SIZE, GENERATE_WG_SIZE, 1 ]
848
962
  )
849
963
  );
@@ -854,9 +968,15 @@ export class PathTracer extends PathTracerStage {
854
968
  const freezeK = this.pixelFreezeStability;
855
969
  const resetFrameFn = Fn( () => {
856
970
 
971
+ // ACTIVE is per-chunk (zeroed before every chunk's scatter). CONVERGED/FROZEN are per-FRAME counters
972
+ // summed across chunks in FinalWrite → zero them only on the first chunk of the frame.
857
973
  atomicStore( counters.element( uint( COUNTER.ACTIVE_RAY_COUNT ) ), uint( 0 ) );
858
- atomicStore( counters.element( uint( COUNTER.CONVERGED_COUNT ) ), uint( 0 ) );
859
- atomicStore( counters.element( uint( COUNTER.FROZEN_COUNT ) ), uint( 0 ) );
974
+ If( this._wfIsFirstChunk.greaterThan( uint( 0 ) ), () => {
975
+
976
+ atomicStore( counters.element( uint( COUNTER.CONVERGED_COUNT ) ), uint( 0 ) );
977
+ atomicStore( counters.element( uint( COUNTER.FROZEN_COUNT ) ), uint( 0 ) );
978
+
979
+ } );
860
980
 
861
981
  } );
862
982
  this._kernelManager.register( 'resetFrameCounters',
@@ -868,19 +988,23 @@ export class PathTracer extends PathTracerStage {
868
988
  const tid = instanceIndex;
869
989
  If( tid.lessThan( this._wfMaxRayCount ), () => {
870
990
 
991
+ // tid is the LOCAL slot in this chunk; p is its GLOBAL pixel (streak/frozenMask are full-res,
992
+ // global-indexed and persist across frames). pixelBase = chunkRowBase · renderWidth.
993
+ const p = uint( int( tid ).add( this._wfChunkRowBase.mul( this._wfRenderWidth ) ) ).toVar();
994
+
871
995
  // frozen=1 skips the pixel this frame. Frame 0 seeds all (streak may be stale post-reset). A pixel
872
996
  // freezes once streak>=K; with dilation ON it stays active if any 8-neighbour is still active
873
997
  // (streak<K), softening the boundary. Mask is read by FinalWrite too → race-free.
874
998
  const frozen = uint( 0 ).toVar();
875
999
 
876
- If( this.frame.greaterThan( uint( 0 ) ).and( streakRO.element( tid ).greaterThanEqual( uint( freezeK ) ) ), () => {
1000
+ If( this.frame.greaterThan( uint( 0 ) ).and( streakRO.element( p ).greaterThanEqual( uint( freezeK ) ) ), () => {
877
1001
 
878
1002
  frozen.assign( uint( 1 ) );
879
1003
 
880
1004
  If( this._dilateFrozenUniform.greaterThan( int( 0 ) ), () => {
881
1005
 
882
- const px = int( tid ).mod( this._wfRenderWidth );
883
- const py = int( tid ).div( this._wfRenderWidth );
1006
+ const px = int( p ).mod( this._wfRenderWidth );
1007
+ const py = int( p ).div( this._wfRenderWidth );
884
1008
 
885
1009
  for ( const [ dx, dy ] of [[ - 1, - 1 ], [ 0, - 1 ], [ 1, - 1 ], [ - 1, 0 ], [ 1, 0 ], [ - 1, 1 ], [ 0, 1 ], [ 1, 1 ]] ) {
886
1010
 
@@ -903,11 +1027,12 @@ export class PathTracer extends PathTracerStage {
903
1027
 
904
1028
  } );
905
1029
 
906
- frozenMaskRW.element( tid ).assign( frozen );
1030
+ frozenMaskRW.element( p ).assign( frozen );
907
1031
 
908
1032
  If( frozen.equal( uint( 0 ) ), () => {
909
1033
 
910
1034
  const slot = atomicAdd( counters.element( uint( COUNTER.ACTIVE_RAY_COUNT ) ), uint( 1 ) );
1035
+ // Scatter the LOCAL slot (path state + list are local); generateList maps it back to global p.
911
1036
  activeWriteA.element( slot ).assign( tid );
912
1037
 
913
1038
  } );
@@ -916,7 +1041,7 @@ export class PathTracer extends PathTracerStage {
916
1041
 
917
1042
  } );
918
1043
  this._kernelManager.register( 'buildActivePixels',
919
- buildActiveFn().compute( [ Math.ceil( maxRays / 256 ), 1, 1 ], [ 256, 1, 1 ] )
1044
+ buildActiveFn().compute( [ Math.ceil( ( this._chunkRows * w ) / 256 ), 1, 1 ], [ 256, 1, 1 ] )
920
1045
  );
921
1046
 
922
1047
  const seedEnterFn = Fn( () => {
@@ -1090,6 +1215,7 @@ export class PathTracer extends PathTracerStage {
1090
1215
  reverseMapVec4Offset: this.reverseMapVec4Offset,
1091
1216
  currentBounce: this._wfCurrentBounce,
1092
1217
  maxRayCount: this._wfMaxRayCount,
1218
+ chunkRowBase: this._wfChunkRowBase,
1093
1219
  auxGBufferEnabled: this._auxGBufferUniform,
1094
1220
  } );
1095
1221
  this._kernelManager.register( 'shade',
@@ -1191,11 +1317,13 @@ export class PathTracer extends PathTracerStage {
1191
1317
  pixelFreezeStability: this.pixelFreezeStability,
1192
1318
  streakBufferRW: streakRW,
1193
1319
  frozenMaskRO, // dilated frozen mask (read-only; matches the active-list decision)
1320
+ chunkRowBase: this._wfChunkRowBase,
1321
+ chunkRows: this._wfChunkRows,
1194
1322
  } );
1195
1323
  this._kernelManager.register( 'finalWrite',
1196
1324
  // Per-pixel (w×h) — kernel averages the S sample-slots internally.
1197
1325
  fwFn().compute(
1198
- [ Math.ceil( w / FINALWRITE_WG_SIZE ), Math.ceil( h / FINALWRITE_WG_SIZE ), 1 ],
1326
+ [ Math.ceil( w / FINALWRITE_WG_SIZE ), Math.ceil( this._chunkRows / FINALWRITE_WG_SIZE ), 1 ],
1199
1327
  [ FINALWRITE_WG_SIZE, FINALWRITE_WG_SIZE, 1 ]
1200
1328
  )
1201
1329
  );
@@ -1239,23 +1367,17 @@ export class PathTracer extends PathTracerStage {
1239
1367
  );
1240
1368
 
1241
1369
  this._wavefrontReady = true;
1242
- console.log( `PathTracer: all wavefront kernels built (${w}×${h}, ${maxRays} rays)` );
1370
+ console.log( `PathTracer: wavefront kernels built — budget B=${B} paths, ${w}×${h} in ${this._numChunks} chunk(s) of ≤${this._chunkRows} rows` );
1243
1371
 
1244
1372
  }
1245
1373
 
1374
+ // Debug viz is a single full-frame pass (no chunking, no ray pool). generate/finalWrite are sized
1375
+ // per row-band chunk inside render()'s chunk loop, not here.
1246
1376
  _setWfDispatch() {
1247
1377
 
1248
1378
  const w = this._wfRenderWidth.value;
1249
1379
  const h = this._wfRenderHeight.value;
1250
1380
 
1251
- this._kernelManager.setDispatchCount( 'generate', [
1252
- Math.ceil( w / GENERATE_WG_SIZE ),
1253
- Math.ceil( h / GENERATE_WG_SIZE ), 1
1254
- ] );
1255
- this._kernelManager.setDispatchCount( 'finalWrite', [
1256
- Math.ceil( w / FINALWRITE_WG_SIZE ),
1257
- Math.ceil( h / FINALWRITE_WG_SIZE ), 1
1258
- ] );
1259
1381
  this._kernelManager.setDispatchCount( 'debug', [
1260
1382
  Math.ceil( w / DEBUG_WG_SIZE ),
1261
1383
  Math.ceil( h / DEBUG_WG_SIZE ), 1
@@ -1263,6 +1385,28 @@ export class PathTracer extends PathTracerStage {
1263
1385
 
1264
1386
  }
1265
1387
 
1388
+ // Program the per-chunk dispatch grids + chunk uniforms for one row band, then return its pixel count.
1389
+ // rowBase/rows are the band's GLOBAL first row + row count; chunkIndex 0 ⇒ first chunk (frame-scoped
1390
+ // counter reset). Generate/FinalWrite run 2D over (w × rows); initActiveIndices/buildActivePixels 1D.
1391
+ _setChunk( rowBase, rows, chunkIndex ) {
1392
+
1393
+ const w = this._wfRenderWidth.value;
1394
+ const chunkPixels = rows * w;
1395
+ this._wfChunkRowBase.value = rowBase;
1396
+ this._wfChunkRows.value = rows;
1397
+ this._wfIsFirstChunk.value = chunkIndex === 0 ? 1 : 0;
1398
+ this._wfMaxRayCount.value = chunkPixels;
1399
+
1400
+ const km = this._kernelManager;
1401
+ km.setDispatchCount( 'generate', [ Math.ceil( w / GENERATE_WG_SIZE ), Math.ceil( rows / GENERATE_WG_SIZE ), 1 ] );
1402
+ km.setDispatchCount( 'finalWrite', [ Math.ceil( w / FINALWRITE_WG_SIZE ), Math.ceil( rows / FINALWRITE_WG_SIZE ), 1 ] );
1403
+ km.setDispatchCount( 'initActiveIndices', [ Math.ceil( chunkPixels / 256 ), 1, 1 ] );
1404
+ km.setDispatchCount( 'buildActivePixels', [ Math.ceil( chunkPixels / 256 ), 1, 1 ] );
1405
+
1406
+ return chunkPixels;
1407
+
1408
+ }
1409
+
1266
1410
  dispose() {
1267
1411
 
1268
1412
  super.dispose();