rayzee 7.16.0 → 7.18.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.
Files changed (47) hide show
  1. package/README.md +1 -1
  2. package/dist/assets/BVHSubtreeWorker-D7KMbrQ-.js +2 -0
  3. package/dist/assets/BVHSubtreeWorker-D7KMbrQ-.js.map +1 -0
  4. package/dist/assets/BVHWorker-DOaGn6uN.js +2 -0
  5. package/dist/assets/BVHWorker-DOaGn6uN.js.map +1 -0
  6. package/dist/rayzee.es.js +2817 -2537
  7. package/dist/rayzee.es.js.map +1 -1
  8. package/dist/rayzee.umd.js +55 -55
  9. package/dist/rayzee.umd.js.map +1 -1
  10. package/package.json +1 -1
  11. package/src/EngineDefaults.js +27 -11
  12. package/src/Passes/OIDNDenoiser.js +17 -14
  13. package/src/PathTracerApp.js +86 -13
  14. package/src/Processor/BVHBuilder.js +11 -7
  15. package/src/Processor/BuildTimer.js +20 -6
  16. package/src/Processor/EmissiveTriangleBuilder.js +11 -7
  17. package/src/Processor/GeometryExtractor.js +11 -5
  18. package/src/Processor/KernelManager.js +48 -1
  19. package/src/Processor/PackedRayBuffer.js +69 -35
  20. package/src/Processor/ParallelBVHBuilder.js +9 -6
  21. package/src/Processor/QueueManager.js +6 -8
  22. package/src/Processor/SceneProcessor.js +47 -21
  23. package/src/Processor/ShaderBuilder.js +4 -1
  24. package/src/Processor/StorageTexturePool.js +4 -1
  25. package/src/Processor/Workers/BVHWorker.js +12 -6
  26. package/src/RenderSettings.js +1 -1
  27. package/src/Stages/ASVGF.js +38 -0
  28. package/src/Stages/BilateralFilter.js +23 -0
  29. package/src/Stages/EdgeFilter.js +18 -0
  30. package/src/Stages/MotionVector.js +23 -0
  31. package/src/Stages/NormalDepth.js +22 -0
  32. package/src/Stages/PathTracer.js +324 -169
  33. package/src/Stages/PathTracerStage.js +17 -14
  34. package/src/Stages/Variance.js +23 -0
  35. package/src/TSL/FinalWriteKernel.js +30 -18
  36. package/src/TSL/GenerateKernel.js +28 -17
  37. package/src/TSL/ShadeKernel.js +186 -87
  38. package/src/TSL/TextureSampling.js +12 -2
  39. package/src/index.js +3 -0
  40. package/src/managers/EnvironmentManager.js +12 -8
  41. package/src/managers/MaterialDataManager.js +12 -9
  42. package/src/managers/UniformManager.js +1 -1
  43. package/src/utils/Logger.js +291 -0
  44. package/dist/assets/BVHSubtreeWorker-sNzvxn66.js +0 -2
  45. package/dist/assets/BVHSubtreeWorker-sNzvxn66.js.map +0 -1
  46. package/dist/assets/BVHWorker-CiVdFrwe.js +0 -2
  47. package/dist/assets/BVHWorker-CiVdFrwe.js.map +0 -1
@@ -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,11 +23,14 @@ 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
+ import { createLogger, fmt } from '../utils/Logger.js';
27
28
  import {
28
29
  Fn, uint, int, atomicStore, atomicLoad, atomicAdd, instanceIndex, If, Return,
29
30
  } from 'three/tsl';
30
31
 
32
+ const log = createLogger( 'wavefront' );
33
+
31
34
  export class PathTracer extends PathTracerStage {
32
35
 
33
36
  constructor( renderer, scene, camera, options = {} ) {
@@ -100,12 +103,24 @@ export class PathTracer extends PathTracerStage {
100
103
  this._wfMaxRayCount = uniform( 0, 'uint' );
101
104
  this._wfCurrentBounce = uniform( 0, 'int' );
102
105
 
106
+ // Blender-style chunked path pool (docs/internal/specs/wavefront-chunked-pool.md): the per-path SoA
107
+ // pool holds a fixed device-budget B of paths-in-flight, decoupled from resolution. The image is
108
+ // streamed through it in row bands; these uniforms carry the current band's offset so kernels map
109
+ // local path slot r ↔ global pixel p = pixelBase + r (pixelBase = _wfChunkRowBase · renderWidth).
110
+ this._wfChunkRowBase = uniform( 0, 'int' ); // first GLOBAL row of the current chunk
111
+ this._wfChunkRows = uniform( 0, 'int' ); // number of rows in the current chunk (≤ chunkRows)
112
+ this._wfIsFirstChunk = uniform( 1, 'uint' ); // 1 → zero frame-scoped counters (CONVERGED/FROZEN)
113
+ this._pathBudget = 0; // B, paths-in-flight; computed once from device limits in _buildWavefrontKernels
114
+ this._pathBudgetOverride = 0; // test hook: force B (0 = derive from device)
115
+ this._chunkRows = 0; // rows per chunk = floor(B / renderWidth), clamped ≥1
116
+ this._numChunks = 1; // ceil(renderHeight / chunkRows)
117
+
103
118
  // VRAM accounting — providers are thunks reading CURRENT live resources,
104
119
  // so they survive buffer/texture reallocation (resize, scene/material reload).
105
120
  this.vramTracker = new VRAMTracker( this.renderer );
106
121
  this._registerVRAMProviders();
107
122
 
108
- console.log( 'PathTracer: initialized (wavefront)' );
123
+ log.debug( 'initialized (wavefront)' );
109
124
 
110
125
  }
111
126
 
@@ -298,151 +313,157 @@ export class PathTracer extends PathTracerStage {
298
313
  const maxBounces = this.maxBounces.value;
299
314
  // 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
315
  const loopBound = maxBounces + this.transmissiveBounces.value + this.maxSubsurfaceSteps.value;
301
- const maxRays = this._wfMaxRayCount.value;
302
316
 
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;
317
+ const frameHeight = this._wfRenderHeight.value;
318
+ const chunkRows = this._chunkRows;
319
+ const singleChunk = this._numChunks <= 1;
320
+
321
+ // The survivor curve is a single per-frame buffer; multi-chunk bands would overwrite each other's
322
+ // counts, so curve-based sizing AND the per-bounce early-exit apply only in the single-chunk regime
323
+ // (the common case → behaviour identical to before). Multi-chunk (huge frame / small budget) runs full
324
+ // dispatch over every bounce — correct, mildly slower.
325
+ //
326
+ // The curve survives a maxBounces change (reset() preserves it): for iterations below BOTH the old and
327
+ // new camera-bounce caps the counts are cap-independent, so trust it up to that cutoff (whole curve when
328
+ // same/decreasing budget; only the [0, oldBudget) overlap when increasing). budget=-1 → cutoff 0 → full
329
+ // dispatch everywhere. This avoids the full-work spike on every bounce-count change.
330
+ const curve = this._lastBounceCounts;
331
+ const curveReliableUpto = ( singleChunk && curve )
332
+ ? ( maxBounces <= this._lastBounceCountsBudget ? loopBound + 1 : this._lastBounceCountsBudget )
333
+ : 0;
308
334
 
309
- if ( adaptiveFreeze ) {
335
+ // Blender-style row-band streaming: the fixed-budget path pool processes the image in bands of ≤ chunkRows
336
+ // rows (one chunk when the frame fits the budget → identical to the pre-chunking path). See spec.
337
+ for ( let chunkIndex = 0, rowBase = 0; rowBase < frameHeight; rowBase += chunkRows, chunkIndex ++ ) {
310
338
 
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' );
339
+ const rows = Math.min( chunkRows, frameHeight - rowBase );
340
+ const maxRays = this._setChunk( rowBase, rows, chunkIndex ); // this band's pixel count + per-chunk grids
319
341
 
320
- } else {
342
+ // Tier-2 bounce-0 grid size from the stale settled active-pixel count (single-chunk only; else full).
343
+ // Monotonic freeze ⇒ a stale count is a safe over-estimate; reset/camera-move → _curveSizingValid=false.
344
+ const adaptiveFreeze = this.usePixelFreeze.value > 0;
345
+ const activeBounce0 = ( singleChunk && adaptiveFreeze && this._curveSizingValid && this._lastActivePixelCount > 0 )
346
+ ? this._lastActivePixelCount : maxRays;
321
347
 
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' );
348
+ if ( adaptiveFreeze ) {
326
349
 
327
- }
350
+ // reset counters → compact non-frozen pixel IDs → publish count → list-driven 1D generate.
351
+ const genSized = Math.min( maxRays, Math.ceil( activeBounce0 * 1.5 ) + 1024 );
352
+ km.setDispatchCount( 'generateList', [ Math.ceil( genSized / 256 ), 1, 1 ] );
353
+ km.dispatch( 'resetFrameCounters' );
354
+ km.dispatch( 'buildActivePixels' );
355
+ km.dispatch( 'seedEnter' );
356
+ km.dispatch( 'generateList' );
328
357
 
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;
358
+ } else {
342
359
 
343
- for ( let bounce = 0; bounce <= loopBound; bounce ++ ) {
360
+ km.dispatch( 'generate' );
361
+ // Generate traces every pixel in the band; initActiveIndices seeds the identity active list + counts
362
+ // (it overwrites ACTIVE + ENTERING, so no separate frame-start reset is needed).
363
+ km.dispatch( 'initActiveIndices' );
344
364
 
345
- this._wfCurrentBounce.value = bounce;
365
+ }
346
366
 
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 ) {
367
+ for ( let bounce = 0; bounce <= loopBound; bounce ++ ) {
351
368
 
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 ) {
369
+ this._wfCurrentBounce.value = bounce;
358
370
 
359
- const idx = bounce - 1;
360
- let prev;
361
- if ( idx < curveReliableUpto && curve[ idx ] !== undefined ) {
371
+ // 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).
372
+ // Material sort is compatible: shade reads sortedIndices while compact still reads the UNSORTED active list (getActiveReadRO), so the survivor set is unchanged.
373
+ const useFunctionalCompaction = this._useDynamicDispatch;
374
+ if ( useFunctionalCompaction ) {
362
375
 
363
- prev = curve[ idx ]; // trusted exact count
376
+ // 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).
377
+ let entering = maxRays;
378
+ if ( singleChunk && bounce > 0 && this._curveSizingValid ) {
364
379
 
365
- } else if ( curveReliableUpto > 0 ) {
380
+ const idx = bounce - 1;
381
+ let prev;
382
+ if ( idx < curveReliableUpto && curve[ idx ] !== undefined ) {
366
383
 
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 ];
384
+ prev = curve[ idx ]; // trusted exact count
372
385
 
373
- }
386
+ } else if ( curveReliableUpto > 0 ) {
374
387
 
375
- entering = prev > 0 ? prev : maxRays;
388
+ // Untrusted tail after a maxBounces increase: survivor counts are monotonically
389
+ // non-increasing (rays only terminate), so the last trusted count is a safe upper bound.
390
+ prev = curve[ curveReliableUpto - 1 ];
376
391
 
377
- } else if ( bounce === 0 && adaptiveFreeze ) {
392
+ }
378
393
 
379
- // Bounce 0 traces only non-frozen pixels — size from the stale active count (full until a readback).
380
- entering = activeBounce0;
394
+ entering = prev > 0 ? prev : maxRays;
381
395
 
382
- }
396
+ } else if ( bounce === 0 && adaptiveFreeze ) {
383
397
 
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 );
398
+ // Bounce 0 traces only non-frozen pixels — size from the stale active count (full until a readback).
399
+ entering = activeBounce0;
392
400
 
393
- } else {
401
+ }
394
402
 
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 );
403
+ const sized = Math.min( maxRays, Math.ceil( entering * 1.5 ) + 1024 );
404
+ const wg = [ Math.ceil( sized / 256 ), 1, 1 ];
405
+ km.setDispatchCount( 'extend', wg );
406
+ km.setDispatchCount( 'shade', wg );
407
+ km.setDispatchCount( 'globalHist', wg );
408
+ km.setDispatchCount( 'globalScatter', wg );
409
+ km.setDispatchCount( 'compact', wg );
410
+ km.setDispatchCount( 'compactCopyback', wg );
411
+
412
+ } else {
413
+
414
+ km.dispatch( 'enterFull' );
415
+ const full = [ Math.ceil( maxRays / 256 ), 1, 1 ];
416
+ km.setDispatchCount( 'extend', full );
417
+ km.setDispatchCount( 'shade', full );
418
+ km.setDispatchCount( 'compact', full );
419
+ km.setDispatchCount( 'globalHist', full );
420
+ km.setDispatchCount( 'globalScatter', full );
402
421
 
403
- }
422
+ }
404
423
 
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 ) {
424
+ // Extend/Shade kept separate (not fused): a fused kernel's register pressure drops occupancy more than fusion saves.
425
+ km.dispatch( 'extend' );
426
+ if ( this._sortMaterials ) {
408
427
 
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' );
428
+ // Global material counting sort (material-pure workgroups): reset, histogram, prefix-sum, scatter.
429
+ km.dispatch( 'resetGlobalHist' );
430
+ km.dispatch( 'globalHist' );
431
+ km.dispatch( 'globalPrefix' );
432
+ km.dispatch( 'globalScatter' );
414
433
 
415
- }
434
+ }
416
435
 
417
- km.dispatch( 'shade' ); // shade thread 0 folds resetActiveCounter (zeroes ACTIVE_RAY_COUNT before compact)
418
- km.dispatch( 'compact' );
419
- if ( useFunctionalCompaction ) {
436
+ km.dispatch( 'shade' ); // shade thread 0 folds resetActiveCounter (zeroes ACTIVE_RAY_COUNT before compact)
437
+ km.dispatch( 'compact' );
438
+ if ( useFunctionalCompaction ) {
420
439
 
421
- // compactCopyback thread 0 folds snapshotBounceCount (records the survivor curve + seeds ENTERING).
422
- km.dispatch( 'compactCopyback' );
440
+ // compactCopyback thread 0 folds snapshotBounceCount (records the survivor curve + seeds ENTERING).
441
+ km.dispatch( 'compactCopyback' );
423
442
 
424
- } else {
443
+ } else {
425
444
 
426
- km.dispatch( 'snapshotBounceCount' );
445
+ km.dispatch( 'snapshotBounceCount' );
427
446
 
428
- }
429
- // No swap: pingPong stays 0 (kernels are build-time-bound to buffer A).
447
+ }
448
+ // No swap: pingPong stays 0 (kernels are build-time-bound to buffer A).
430
449
 
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
- ) {
450
+ // Early-exit on last frame's per-bounce snapshot (single-chunk only; curveReliableUpto=0 disables it for multi-chunk).
451
+ if (
452
+ bounce < curveReliableUpto
453
+ && bounce < loopBound
454
+ && curve[ bounce ] !== undefined
455
+ && curve[ bounce ] <= this._bounceEarlyExitThreshold
456
+ ) {
438
457
 
439
- break;
458
+ break;
459
+
460
+ }
440
461
 
441
462
  }
442
463
 
443
- }
464
+ km.dispatch( 'finalWrite' );
444
465
 
445
- km.dispatch( 'finalWrite' );
466
+ }
446
467
 
447
468
  this._maybeReadbackCounters();
448
469
 
@@ -578,7 +599,7 @@ export class PathTracer extends PathTracerStage {
578
599
 
579
600
  } ).catch( ( e ) => {
580
601
 
581
- console.warn( 'Wavefront bounceCounts readback failed:', e );
602
+ log.warn( 'bounceCounts readback failed:', e );
582
603
  this._readbackPending = false;
583
604
 
584
605
  } );
@@ -593,7 +614,9 @@ export class PathTracer extends PathTracerStage {
593
614
 
594
615
  this._convergedReadbackPending = true;
595
616
  const cgen = this._readbackGeneration;
596
- const total = this._wfMaxRayCount.value;
617
+ // Full-FRAME pixel count (CONVERGED_COUNT sums across all chunks) — not _wfMaxRayCount, which
618
+ // after the chunk loop holds only the last band's pixel count.
619
+ const total = this._wfRenderWidth.value * this._wfRenderHeight.value;
597
620
  this.renderer.getArrayBufferAsync( cAttr ).then( ( buf ) => {
598
621
 
599
622
  if ( cgen === this._readbackGeneration && total > 0 ) {
@@ -655,12 +678,13 @@ export class PathTracer extends PathTracerStage {
655
678
  this._convergedFraction = 0;
656
679
  this._lastActivePixelCount = 0;
657
680
 
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 ) {
681
+ // Chunked path pool: the wavefront buffers are sized to the fixed device budget B (not the
682
+ // resolution), and the per-pixel persistent buffers to the reserved max, so a resolution change
683
+ // reallocates NOTHING and rebuilds NO kernels — it only updates the render-size uniforms + the
684
+ // row-band chunk layout. The one-time build happens on model load / light realloc. This is the fix
685
+ // for the resize freeze (was: capacity-grow → recreate all compute nodes → WGSL regen, ~1.8 s on
686
+ // complex scenes). See docs/internal/specs/wavefront-chunked-pool.md.
687
+ if ( ! this._packedBuffers ) {
664
688
 
665
689
  if ( this._kernelManager ) this._kernelManager.dispose();
666
690
  this._wavefrontReady = false;
@@ -674,23 +698,93 @@ export class PathTracer extends PathTracerStage {
674
698
 
675
699
  }
676
700
 
677
- // Same-capacity resize: update render-size uniforms + early-exit threshold, no recompile.
701
+ // Resolution change with a fixed-budget pool: update render-size uniforms + recompute the row-band chunk
702
+ // layout + rescale the early-exit threshold. No buffer realloc, no kernel recompile.
678
703
  _resizeWavefrontInPlace( w, h ) {
679
704
 
680
- const maxRays = w * h;
681
705
  this._wfRenderWidth.value = w;
682
706
  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 ] );
707
+ this._updateChunkLayout();
686
708
  if ( this._bounceEarlyExitThreshold !== - 1 ) {
687
709
 
688
- this._bounceEarlyExitThreshold = Math.max( 100, Math.floor( maxRays / 1000 ) );
710
+ this._bounceEarlyExitThreshold = Math.max( 100, Math.floor( ( w * h ) / 1000 ) );
689
711
 
690
712
  }
691
713
 
692
714
  }
693
715
 
716
+ // Device-adaptive paths-in-flight budget B (Blender path-pool). Constrained by:
717
+ // (1) each single storage buffer ≤ maxStorageBufferBindingSize — the RAY buffer (B·RAY_STRIDE·16 B) binds
718
+ // largest, so B·RAY_STRIDE·16 ≤ 0.9·maxStorageBufferBindingSize;
719
+ // (2) total pool ≤ a fraction of device memory;
720
+ // (3) floor at 512² of paths, cap at the reserved max pixels (no point pooling more paths than pixels).
721
+ // B is resolution-INDEPENDENT, so _cap is fixed and the kernels build once. The render-loop chunking streams
722
+ // any resolution through this pool, so a small B just means more row-bands (works on weak / small-VRAM GPUs).
723
+ _computePathBudget() {
724
+
725
+ if ( this._pathBudgetOverride > 0 ) return this._pathBudgetOverride;
726
+
727
+ const RAY_BYTES = RAY_STRIDE * 16;
728
+ const bytesPerPath = RAY_STRIDE * 16 + HIT_STRIDE * 16 + 4 /* rng */ + GBUFFER_STRIDE * 16
729
+ + 4 + 4 /* activeIndices A/B */ + 4;
730
+
731
+ const limits = this.renderer?.backend?.device?.limits;
732
+ const maxBinding = limits?.maxStorageBufferBindingSize || ( 128 * 1024 * 1024 );
733
+ const maxBuffer = limits?.maxBufferSize || maxBinding;
734
+
735
+ const bByBinding = Math.floor( ( maxBinding * 0.9 ) / RAY_BYTES );
736
+ const deviceMemBytes = ( ( typeof navigator !== 'undefined' && navigator.deviceMemory ) || 4 ) * 1024 * 1024 * 1024;
737
+ const poolVramBudget = Math.min( deviceMemBytes * 0.25, maxBuffer * 4 );
738
+ const bByVram = Math.floor( poolVramBudget / bytesPerPath );
739
+
740
+ const MIN_B = 512 * 512;
741
+ // Working-set ceiling — deliberately DECOUPLED from the reserved framebuffer size. Capping the pool at
742
+ // ~2048² paths (~940 MB) keeps VRAM bounded; frames larger than B (e.g. 4K) just stream in more row-band
743
+ // chunks rather than reserving a giant pool (4K single-chunk would be ~3 GB of path state for ~no speedup).
744
+ const MAX_B = 2048 * 2048;
745
+ let B = Math.min( bByBinding, bByVram, MAX_B );
746
+ B = Math.max( MIN_B, B );
747
+ return B;
748
+
749
+ }
750
+
751
+ // Row-aligned chunk layout for the current resolution and budget B. A chunk is `_chunkRows` full rows so the
752
+ // 2D ray-gen grid stays cache-coherent and pixelBase = rowBase·W is exact. chunkPixels = _chunkRows·W ≤ B.
753
+ _updateChunkLayout() {
754
+
755
+ const w = Math.max( 1, this.storageTextures.renderWidth );
756
+ const h = this.storageTextures.renderHeight;
757
+ const B = this._pathBudget || ( w * h );
758
+ this._chunkRows = Math.max( 1, Math.min( Math.floor( B / w ), h ) );
759
+ this._numChunks = Math.max( 1, Math.ceil( h / this._chunkRows ) );
760
+
761
+ }
762
+
763
+ // Reserved-storage change (e.g. enabling 4K): the MRT StorageTextures are pre-allocated at
764
+ // MAX_STORAGE_TEXTURE_SIZE and can't be resized, so recreate the pool at the (new) live reserved size and
765
+ // rebuild the wavefront kernels in place. The stage OBJECT is unchanged, so manager/event refs stay valid;
766
+ // only GPU textures + compute pipelines are rebuilt. Scene buffers (BVH/tri/material) are resolution-
767
+ // independent and untouched. Caller must have rendering paused. No-op before the first build.
768
+ reallocateReservedStorage() {
769
+
770
+ if ( ! this._packedBuffers || ! ( this.materialData?.materialCount > 0 ) ) return;
771
+
772
+ const w = this.storageTextures.renderWidth || 1;
773
+ const h = this.storageTextures.renderHeight || 1;
774
+
775
+ // Recreate the MRT pool at the new reserved size (create() reallocates the write StorageTextures at the
776
+ // live MAX_STORAGE_TEXTURE_SIZE; the read RenderTarget follows the current render size).
777
+ this.storageTextures.create( w, h );
778
+ this.resolution.value.set( w, h );
779
+
780
+ // Rebuild kernels: re-references the fresh write textures + recreates the per-pixel aux buffers
781
+ // (m2/streak/frozenMask) at the new maxPixels. Prev-frame nodes are repointed per-frame in render().
782
+ if ( this._kernelManager ) this._kernelManager.dispose();
783
+ this._wavefrontReady = false;
784
+ this._buildWavefrontKernels();
785
+
786
+ }
787
+
694
788
  _buildWavefrontKernels() {
695
789
 
696
790
  const texNodes = this.shaderBuilder.getSceneTextureNodes();
@@ -701,51 +795,63 @@ export class PathTracer extends PathTracerStage {
701
795
 
702
796
  const w = this.storageTextures.renderWidth;
703
797
  const h = this.storageTextures.renderHeight;
704
- const maxRays = w * h;
798
+
799
+ // Fixed device-budget path pool B (resolution-independent) + the row-band chunk layout for this frame.
800
+ if ( ! this._pathBudget ) this._pathBudget = this._computePathBudget();
801
+ const B = this._pathBudget;
802
+ this._updateChunkLayout();
803
+
804
+ // Rays in one row-band chunk — the initial dispatch bound for kernel registration. render() overrides
805
+ // every per-ray kernel's dispatch per chunk; this is just a valid starting grid (always ≤ B).
806
+ const maxRays = this._chunkRows * w;
705
807
 
706
808
  if ( this._bounceEarlyExitThreshold !== - 1 ) {
707
809
 
708
- this._bounceEarlyExitThreshold = Math.max( 100, Math.floor( maxRays / 1000 ) );
810
+ this._bounceEarlyExitThreshold = Math.max( 100, Math.floor( ( w * h ) / 1000 ) );
709
811
 
710
812
  }
711
813
 
814
+ // Per-path buffers (RAY/HIT/rng) sized to the budget B and indexed by LOCAL slot r ∈ [0,B). _cap = B is
815
+ // baked into the SoA stride but never changes with resolution, so this build happens once (model load).
712
816
  if ( ! this._packedBuffers ) {
713
817
 
714
- this._packedBuffers = new PackedRayBuffer( maxRays, this.renderer );
818
+ this._packedBuffers = new PackedRayBuffer( B, this.renderer );
715
819
 
716
820
  } else {
717
821
 
718
- this._packedBuffers.resize( maxRays );
822
+ this._packedBuffers.resize( B );
719
823
 
720
824
  }
721
825
 
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
826
+ // Per-CHUNK first-hit G-buffer (LOCAL slot r, size B), 1 uvec4/slot half-packed (pack2x16). Written by
827
+ // Generate + Shade(bounce 0), read by Shade + FinalWrite within the SAME chunk, so per-chunk suffices.
828
+ // uint (not f32): packed lanes can hit the NaN exponent range (snorm 1.0 → 0x7FFF) that an f32 store may
829
+ // canonicalize; u32 stores the bits verbatim.
830
+ const gBufferVec4s = B * GBUFFER_STRIDE;
831
+ freeStorageAttribute( this.renderer, this._gBufferAttr );
729
832
  this._gBufferAttr = new StorageInstancedBufferAttribute( new Uint32Array( gBufferVec4s * 4 ), 4 );
730
833
  const gBufferRW = storage( this._gBufferAttr, 'uvec4' );
731
834
  const gBufferRO = storage( this._gBufferAttr, 'uvec4' ).toReadOnly();
732
835
 
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.
836
+ // Per-PIXEL persistent buffers (m2/streak/frozenMask) are GLOBAL-pixel-indexed (p = pixelBase + r) and
837
+ // must span the whole frame across chunks AND persist across frames, so they're sized to the reserved
838
+ // max resolution (small — 12 B/pixel) and NEVER realloc on a resize.
839
+ const maxPixels = MAX_STORAGE_TEXTURE_SIZE * MAX_STORAGE_TEXTURE_SIZE;
840
+
841
+ // Tier-1 convergence: per-pixel running mean of luminance² (Welford second moment), read+written by FinalWrite.
736
842
  freeStorageAttribute( this.renderer, this._m2Attr );
737
- this._m2Attr = new StorageInstancedBufferAttribute( new Float32Array( PackedRayBuffer.requiredCapacity( maxRays ) ), 1 );
843
+ this._m2Attr = new StorageInstancedBufferAttribute( new Float32Array( maxPixels ), 1 );
738
844
  const m2RW = storage( this._m2Attr, 'float' );
739
845
 
740
- // Tier-2: one u32/pixel freeze-candidate streak. FinalWrite writes (RW), buildActivePixels reads (RO).
846
+ // Tier-2: per-pixel freeze-candidate streak. FinalWrite writes (RW), buildActivePixels reads (RO).
741
847
  freeStorageAttribute( this.renderer, this._streakAttr );
742
- this._streakAttr = new StorageInstancedBufferAttribute( new Uint32Array( PackedRayBuffer.requiredCapacity( maxRays ) ), 1 );
848
+ this._streakAttr = new StorageInstancedBufferAttribute( new Uint32Array( maxPixels ), 1 );
743
849
  const streakRW = storage( this._streakAttr, 'uint' );
744
850
  const streakRO = storage( this._streakAttr, 'uint' ).toReadOnly();
745
851
 
746
852
  // Tier-2: dilated frozen mask (1 = skip). buildActivePixels writes; active-list + FinalWrite read → race-free.
747
853
  freeStorageAttribute( this.renderer, this._frozenMaskAttr );
748
- this._frozenMaskAttr = new StorageInstancedBufferAttribute( new Uint32Array( PackedRayBuffer.requiredCapacity( maxRays ) ), 1 );
854
+ this._frozenMaskAttr = new StorageInstancedBufferAttribute( new Uint32Array( maxPixels ), 1 );
749
855
  const frozenMaskRW = storage( this._frozenMaskAttr, 'uint' );
750
856
  const frozenMaskRO = storage( this._frozenMaskAttr, 'uint' ).toReadOnly();
751
857
 
@@ -770,7 +876,8 @@ export class PathTracer extends PathTracerStage {
770
876
 
771
877
  this._wfRenderWidth.value = w;
772
878
  this._wfRenderHeight.value = h;
773
- this._wfMaxRayCount.value = maxRays;
879
+ // _wfMaxRayCount is the CURRENT chunk's pixel count; set per-chunk in render(). Seed with the first chunk.
880
+ this._wfMaxRayCount.value = Math.min( B, this._chunkRows * w );
774
881
 
775
882
  const prevColor = this.shaderBuilder.prevColorTexNode;
776
883
  const prevAlbedo = this.shaderBuilder.prevAlbedoTexNode;
@@ -801,22 +908,30 @@ export class PathTracer extends PathTracerStage {
801
908
  const initFn = Fn( () => {
802
909
 
803
910
  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.
911
+ // LOCAL-slot identity for this chunk's active list. Bounded on _wfMaxRayCount (= chunkPixels): the
912
+ // pool has no over-allocation margin now, so the dispatch-grid overshoot must not write past it.
913
+ If( tid.lessThan( this._wfMaxRayCount ), () => {
914
+
915
+ activeWriteA.element( tid ).assign( tid );
916
+
917
+ } );
918
+ // Seed ACTIVE/ENTERING from this chunk's ray count. CONVERGED is a per-FRAME counter (summed across
919
+ // chunks in FinalWrite), so zero it only on the first chunk of the frame.
806
920
  If( tid.equal( uint( 0 ) ), () => {
807
921
 
808
922
  atomicStore( counters.element( uint( COUNTER.ACTIVE_RAY_COUNT ) ), this._wfMaxRayCount );
809
923
  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 ) );
924
+ If( this._wfIsFirstChunk.greaterThan( uint( 0 ) ), () => {
925
+
926
+ atomicStore( counters.element( uint( COUNTER.CONVERGED_COUNT ) ), uint( 0 ) );
927
+
928
+ } );
814
929
 
815
930
  } );
816
931
 
817
932
  } );
818
933
  this._kernelManager.register( 'initActiveIndices',
819
- initFn().compute( [ Math.ceil( maxRays / 256 ), 1, 1 ], [ 256, 1, 1 ] )
934
+ initFn().compute( [ Math.ceil( ( this._chunkRows * w ) / 256 ), 1, 1 ], [ 256, 1, 1 ] )
820
935
  );
821
936
 
822
937
  const genParams = {
@@ -836,6 +951,8 @@ export class PathTracer extends PathTracerStage {
836
951
  anamorphicRatio: this.anamorphicRatio,
837
952
  renderWidth: this._wfRenderWidth,
838
953
  renderHeight: this._wfRenderHeight,
954
+ chunkRowBase: this._wfChunkRowBase,
955
+ chunkRows: this._wfChunkRows,
839
956
  transmissiveBounces: this.transmissiveBounces,
840
957
  transparentBackground: this.transparentBackground,
841
958
  auxGBufferEnabled: this._auxGBufferUniform,
@@ -843,7 +960,7 @@ export class PathTracer extends PathTracerStage {
843
960
  const genFn = buildGenerateKernel( genParams );
844
961
  this._kernelManager.register( 'generate',
845
962
  genFn().compute(
846
- [ Math.ceil( w / GENERATE_WG_SIZE ), Math.ceil( h / GENERATE_WG_SIZE ), 1 ],
963
+ [ Math.ceil( w / GENERATE_WG_SIZE ), Math.ceil( this._chunkRows / GENERATE_WG_SIZE ), 1 ],
847
964
  [ GENERATE_WG_SIZE, GENERATE_WG_SIZE, 1 ]
848
965
  )
849
966
  );
@@ -854,9 +971,15 @@ export class PathTracer extends PathTracerStage {
854
971
  const freezeK = this.pixelFreezeStability;
855
972
  const resetFrameFn = Fn( () => {
856
973
 
974
+ // ACTIVE is per-chunk (zeroed before every chunk's scatter). CONVERGED/FROZEN are per-FRAME counters
975
+ // summed across chunks in FinalWrite → zero them only on the first chunk of the frame.
857
976
  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 ) );
977
+ If( this._wfIsFirstChunk.greaterThan( uint( 0 ) ), () => {
978
+
979
+ atomicStore( counters.element( uint( COUNTER.CONVERGED_COUNT ) ), uint( 0 ) );
980
+ atomicStore( counters.element( uint( COUNTER.FROZEN_COUNT ) ), uint( 0 ) );
981
+
982
+ } );
860
983
 
861
984
  } );
862
985
  this._kernelManager.register( 'resetFrameCounters',
@@ -868,19 +991,23 @@ export class PathTracer extends PathTracerStage {
868
991
  const tid = instanceIndex;
869
992
  If( tid.lessThan( this._wfMaxRayCount ), () => {
870
993
 
994
+ // tid is the LOCAL slot in this chunk; p is its GLOBAL pixel (streak/frozenMask are full-res,
995
+ // global-indexed and persist across frames). pixelBase = chunkRowBase · renderWidth.
996
+ const p = uint( int( tid ).add( this._wfChunkRowBase.mul( this._wfRenderWidth ) ) ).toVar();
997
+
871
998
  // frozen=1 skips the pixel this frame. Frame 0 seeds all (streak may be stale post-reset). A pixel
872
999
  // freezes once streak>=K; with dilation ON it stays active if any 8-neighbour is still active
873
1000
  // (streak<K), softening the boundary. Mask is read by FinalWrite too → race-free.
874
1001
  const frozen = uint( 0 ).toVar();
875
1002
 
876
- If( this.frame.greaterThan( uint( 0 ) ).and( streakRO.element( tid ).greaterThanEqual( uint( freezeK ) ) ), () => {
1003
+ If( this.frame.greaterThan( uint( 0 ) ).and( streakRO.element( p ).greaterThanEqual( uint( freezeK ) ) ), () => {
877
1004
 
878
1005
  frozen.assign( uint( 1 ) );
879
1006
 
880
1007
  If( this._dilateFrozenUniform.greaterThan( int( 0 ) ), () => {
881
1008
 
882
- const px = int( tid ).mod( this._wfRenderWidth );
883
- const py = int( tid ).div( this._wfRenderWidth );
1009
+ const px = int( p ).mod( this._wfRenderWidth );
1010
+ const py = int( p ).div( this._wfRenderWidth );
884
1011
 
885
1012
  for ( const [ dx, dy ] of [[ - 1, - 1 ], [ 0, - 1 ], [ 1, - 1 ], [ - 1, 0 ], [ 1, 0 ], [ - 1, 1 ], [ 0, 1 ], [ 1, 1 ]] ) {
886
1013
 
@@ -903,11 +1030,12 @@ export class PathTracer extends PathTracerStage {
903
1030
 
904
1031
  } );
905
1032
 
906
- frozenMaskRW.element( tid ).assign( frozen );
1033
+ frozenMaskRW.element( p ).assign( frozen );
907
1034
 
908
1035
  If( frozen.equal( uint( 0 ) ), () => {
909
1036
 
910
1037
  const slot = atomicAdd( counters.element( uint( COUNTER.ACTIVE_RAY_COUNT ) ), uint( 1 ) );
1038
+ // Scatter the LOCAL slot (path state + list are local); generateList maps it back to global p.
911
1039
  activeWriteA.element( slot ).assign( tid );
912
1040
 
913
1041
  } );
@@ -916,7 +1044,7 @@ export class PathTracer extends PathTracerStage {
916
1044
 
917
1045
  } );
918
1046
  this._kernelManager.register( 'buildActivePixels',
919
- buildActiveFn().compute( [ Math.ceil( maxRays / 256 ), 1, 1 ], [ 256, 1, 1 ] )
1047
+ buildActiveFn().compute( [ Math.ceil( ( this._chunkRows * w ) / 256 ), 1, 1 ], [ 256, 1, 1 ] )
920
1048
  );
921
1049
 
922
1050
  const seedEnterFn = Fn( () => {
@@ -1068,6 +1196,7 @@ export class PathTracer extends PathTracerStage {
1068
1196
  numSpotLights: this.numSpotLights,
1069
1197
  maxBounceCount: this.maxBounces,
1070
1198
  maxSubsurfaceSteps: this.maxSubsurfaceSteps,
1199
+ maxTransparentBounces: this.maxTransparentBounces,
1071
1200
  transparentBackground: this.transparentBackground,
1072
1201
  backgroundIntensity: this.backgroundIntensity,
1073
1202
  backgroundColor: this.backgroundColor,
@@ -1090,6 +1219,7 @@ export class PathTracer extends PathTracerStage {
1090
1219
  reverseMapVec4Offset: this.reverseMapVec4Offset,
1091
1220
  currentBounce: this._wfCurrentBounce,
1092
1221
  maxRayCount: this._wfMaxRayCount,
1222
+ chunkRowBase: this._wfChunkRowBase,
1093
1223
  auxGBufferEnabled: this._auxGBufferUniform,
1094
1224
  } );
1095
1225
  this._kernelManager.register( 'shade',
@@ -1183,7 +1313,6 @@ export class PathTracer extends PathTracerStage {
1183
1313
  m2BufferRW: m2RW,
1184
1314
  useAdaptiveSampling: this.useAdaptiveSampling,
1185
1315
  noiseThreshold: this.noiseThreshold,
1186
- darkNoiseFloor: this.darkNoiseFloor,
1187
1316
  adaptiveMinSamples: this.adaptiveMinSamples,
1188
1317
  // Tier-2 freeze (stamp + pass-through)
1189
1318
  usePixelFreeze: this.usePixelFreeze,
@@ -1191,11 +1320,13 @@ export class PathTracer extends PathTracerStage {
1191
1320
  pixelFreezeStability: this.pixelFreezeStability,
1192
1321
  streakBufferRW: streakRW,
1193
1322
  frozenMaskRO, // dilated frozen mask (read-only; matches the active-list decision)
1323
+ chunkRowBase: this._wfChunkRowBase,
1324
+ chunkRows: this._wfChunkRows,
1194
1325
  } );
1195
1326
  this._kernelManager.register( 'finalWrite',
1196
1327
  // Per-pixel (w×h) — kernel averages the S sample-slots internally.
1197
1328
  fwFn().compute(
1198
- [ Math.ceil( w / FINALWRITE_WG_SIZE ), Math.ceil( h / FINALWRITE_WG_SIZE ), 1 ],
1329
+ [ Math.ceil( w / FINALWRITE_WG_SIZE ), Math.ceil( this._chunkRows / FINALWRITE_WG_SIZE ), 1 ],
1199
1330
  [ FINALWRITE_WG_SIZE, FINALWRITE_WG_SIZE, 1 ]
1200
1331
  )
1201
1332
  );
@@ -1239,23 +1370,25 @@ export class PathTracer extends PathTracerStage {
1239
1370
  );
1240
1371
 
1241
1372
  this._wavefrontReady = true;
1242
- console.log( `PathTracer: all wavefront kernels built (${w}×${h}, ${maxRays} rays)` );
1373
+
1374
+ const bufferBytes = ( this._packedBuffers?.totalBytes ?? 0 ) + ( this._queueManager?.totalBytes ?? 0 );
1375
+
1376
+ log.info( fmt.list( [
1377
+ fmt.px( w, h ),
1378
+ `${fmt.mb( bufferBytes )} wavefront buffers`,
1379
+ `budget ${fmt.n( B )} paths`,
1380
+ this._numChunks > 1 ? `${this._numChunks} chunks of ≤${this._chunkRows} rows` : null,
1381
+ ] ) );
1243
1382
 
1244
1383
  }
1245
1384
 
1385
+ // Debug viz is a single full-frame pass (no chunking, no ray pool). generate/finalWrite are sized
1386
+ // per row-band chunk inside render()'s chunk loop, not here.
1246
1387
  _setWfDispatch() {
1247
1388
 
1248
1389
  const w = this._wfRenderWidth.value;
1249
1390
  const h = this._wfRenderHeight.value;
1250
1391
 
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
1392
  this._kernelManager.setDispatchCount( 'debug', [
1260
1393
  Math.ceil( w / DEBUG_WG_SIZE ),
1261
1394
  Math.ceil( h / DEBUG_WG_SIZE ), 1
@@ -1263,6 +1396,28 @@ export class PathTracer extends PathTracerStage {
1263
1396
 
1264
1397
  }
1265
1398
 
1399
+ // Program the per-chunk dispatch grids + chunk uniforms for one row band, then return its pixel count.
1400
+ // rowBase/rows are the band's GLOBAL first row + row count; chunkIndex 0 ⇒ first chunk (frame-scoped
1401
+ // counter reset). Generate/FinalWrite run 2D over (w × rows); initActiveIndices/buildActivePixels 1D.
1402
+ _setChunk( rowBase, rows, chunkIndex ) {
1403
+
1404
+ const w = this._wfRenderWidth.value;
1405
+ const chunkPixels = rows * w;
1406
+ this._wfChunkRowBase.value = rowBase;
1407
+ this._wfChunkRows.value = rows;
1408
+ this._wfIsFirstChunk.value = chunkIndex === 0 ? 1 : 0;
1409
+ this._wfMaxRayCount.value = chunkPixels;
1410
+
1411
+ const km = this._kernelManager;
1412
+ km.setDispatchCount( 'generate', [ Math.ceil( w / GENERATE_WG_SIZE ), Math.ceil( rows / GENERATE_WG_SIZE ), 1 ] );
1413
+ km.setDispatchCount( 'finalWrite', [ Math.ceil( w / FINALWRITE_WG_SIZE ), Math.ceil( rows / FINALWRITE_WG_SIZE ), 1 ] );
1414
+ km.setDispatchCount( 'initActiveIndices', [ Math.ceil( chunkPixels / 256 ), 1, 1 ] );
1415
+ km.setDispatchCount( 'buildActivePixels', [ Math.ceil( chunkPixels / 256 ), 1, 1 ] );
1416
+
1417
+ return chunkPixels;
1418
+
1419
+ }
1420
+
1266
1421
  dispose() {
1267
1422
 
1268
1423
  super.dispose();