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
@@ -25,6 +25,9 @@ import { LightSerializer } from '../Processor/LightSerializer';
25
25
  // Constants
26
26
  import { ENGINE_DEFAULTS as DEFAULT_STATE } from '../EngineDefaults.js';
27
27
  import { getAssetConfig } from '../AssetConfig.js';
28
+ import { createLogger, fmt } from '../utils/Logger.js';
29
+
30
+ const log = createLogger( 'pathtracer' );
28
31
 
29
32
  /**
30
33
  * Data layout constants
@@ -377,7 +380,7 @@ export class PathTracerStage extends RenderStage {
377
380
 
378
381
  this.stbnScalarTexture = configure( tex );
379
382
  stbnScalarTextureNode.value = tex;
380
- console.log( `PathTracer: STBN scalar atlas loaded ${tex.image.width}x${tex.image.height}` );
383
+ log.debug( `STBN scalar atlas ${fmt.px( tex.image.width, tex.image.height )}` );
381
384
 
382
385
  } );
383
386
 
@@ -385,7 +388,7 @@ export class PathTracerStage extends RenderStage {
385
388
 
386
389
  this.stbnVec2Texture = configure( tex );
387
390
  stbnVec2TextureNode.value = tex;
388
- console.log( `PathTracer: STBN vec2 atlas loaded ${tex.image.width}x${tex.image.height}` );
391
+ log.debug( `STBN vec2 atlas ${fmt.px( tex.image.width, tex.image.height )}` );
389
392
 
390
393
  } );
391
394
 
@@ -548,7 +551,7 @@ export class PathTracerStage extends RenderStage {
548
551
 
549
552
  this.directionalLightsData = mockMaterial.uniforms.directionalLights.value;
550
553
 
551
- console.log( `Sun added as directional light (intensity: ${scaledSunIntensity.toFixed( 2 )})` );
554
+ log.debug( `sun added as directional light · intensity ${scaledSunIntensity.toFixed( 2 )}` );
552
555
 
553
556
  }
554
557
 
@@ -716,7 +719,7 @@ export class PathTracerStage extends RenderStage {
716
719
 
717
720
  this.triangleCount = triangleCount;
718
721
 
719
- console.log( `PathTracer: ${this.triangleCount} triangles (storage buffer)` );
722
+ log.debug( `${fmt.n( this.triangleCount )} triangles (storage buffer)` );
720
723
 
721
724
  }
722
725
 
@@ -744,7 +747,7 @@ export class PathTracerStage extends RenderStage {
744
747
  }
745
748
 
746
749
  this.bvhNodeCount = Math.floor( vec4Count / BVH_VEC4_PER_NODE );
747
- console.log( `PathTracer: ${this.bvhNodeCount} BVH nodes (storage buffer)` );
750
+ log.debug( `${fmt.n( this.bvhNodeCount )} BVH nodes (storage buffer)` );
748
751
 
749
752
  }
750
753
 
@@ -983,14 +986,14 @@ export class PathTracerStage extends RenderStage {
983
986
 
984
987
  if ( ! this.triangleStorageNode ) {
985
988
 
986
- console.error( 'PathTracer: Triangle data required' );
989
+ log.error( 'triangle data required' );
987
990
  return;
988
991
 
989
992
  }
990
993
 
991
994
  if ( ! this.bvhStorageNode ) {
992
995
 
993
- console.error( 'PathTracer: BVH data required' );
996
+ log.error( 'BVH data required' );
994
997
  return;
995
998
 
996
999
  }
@@ -1348,7 +1351,7 @@ export class PathTracerStage extends RenderStage {
1348
1351
  this.emissiveTriangleCount.value = count;
1349
1352
  this.emissiveTotalPower.value = totalPower;
1350
1353
  this._rebuildLightBuffer();
1351
- console.log( `PathTracer: ${count} emissive triangles, totalPower=${totalPower.toFixed( 4 )} (storage buffer)` );
1354
+ log.debug( `${fmt.n( count )} emissive triangles · totalPower ${totalPower.toFixed( 4 )} (storage buffer)` );
1352
1355
 
1353
1356
  }
1354
1357
 
@@ -1359,7 +1362,7 @@ export class PathTracerStage extends RenderStage {
1359
1362
  this._lbvhDataCache = nodeData;
1360
1363
  this.lightBVHNodeCount.value = nodeCount;
1361
1364
  this._rebuildLightBuffer();
1362
- console.log( `PathTracer: Light BVH ${nodeCount} nodes` );
1365
+ log.debug( `light BVH ${fmt.n( nodeCount )} nodes` );
1363
1366
 
1364
1367
  }
1365
1368
 
@@ -1402,7 +1405,7 @@ export class PathTracerStage extends RenderStage {
1402
1405
 
1403
1406
  try {
1404
1407
 
1405
- console.log( 'PathTracer: Starting material rebuild...' );
1408
+ log.debug( 'material rebuild started' );
1406
1409
 
1407
1410
  await this.sdfs.rebuildMaterials( scene );
1408
1411
  this.updateSceneUniforms();
@@ -1410,20 +1413,20 @@ export class PathTracerStage extends RenderStage {
1410
1413
  this.updateLights();
1411
1414
  this.reset();
1412
1415
 
1413
- console.log( 'PathTracer materials rebuilt successfully' );
1416
+ log.debug( 'material rebuild complete' );
1414
1417
 
1415
1418
  } catch ( error ) {
1416
1419
 
1417
- console.error( 'Error rebuilding PathTracer materials:', error );
1420
+ log.error( 'material rebuild failed:', error );
1418
1421
 
1419
1422
  try {
1420
1423
 
1421
- console.warn( 'Attempting recovery by resetting path tracer...' );
1424
+ log.warn( 'attempting recovery by resetting the path tracer' );
1422
1425
  this.reset();
1423
1426
 
1424
1427
  } catch ( recoveryError ) {
1425
1428
 
1426
- console.error( 'Recovery failed:', recoveryError );
1429
+ log.error( 'recovery failed:', recoveryError );
1427
1430
 
1428
1431
  }
1429
1432
 
@@ -404,6 +404,29 @@ export class Variance extends RenderStage {
404
404
 
405
405
  }
406
406
 
407
+ // Reserved-storage change (e.g. 4K toggle): recreate the MAX-preallocated StorageTextures at the new live
408
+ // reserved size + rebuild the compute nodes in place. Same stage object, so refs/wiring stay valid.
409
+ reallocateReservedStorage() {
410
+
411
+ this._computeNodeA?.dispose();
412
+ this._computeNodeB?.dispose();
413
+ this._storageTexA?.dispose();
414
+ this._storageTexB?.dispose();
415
+ const mk = () => {
416
+
417
+ const t = new StorageTexture( MAX_STORAGE_TEXTURE_SIZE, MAX_STORAGE_TEXTURE_SIZE );
418
+ t.type = FloatType; t.format = RGBAFormat; t.minFilter = LinearFilter; t.magFilter = LinearFilter;
419
+ return t;
420
+
421
+ };
422
+
423
+ this._storageTexA = mk();
424
+ this._storageTexB = mk();
425
+ this._buildCompute();
426
+ this._compiled = false;
427
+
428
+ }
429
+
407
430
  dispose() {
408
431
 
409
432
  this._computeNodeA?.dispose();
@@ -1,5 +1,10 @@
1
1
  /**
2
2
  * FinalWriteKernel.js — wavefront final output: temporal accumulation + MRT StorageTexture writes (16×16, 2D).
3
+ *
4
+ * Chunked path pool (docs/internal/specs/wavefront-chunked-pool.md): runs 2D over one row band
5
+ * (renderWidth × chunkRows). A thread's band-local row `localGy` maps to global row `gy = chunkRowBase + localGy`.
6
+ * Path state + the per-chunk G-buffer are read at the LOCAL slot `rayID = localGy·W + gx`; the persistent
7
+ * per-pixel buffers (m2/streak/frozenMask) and the output texture use the GLOBAL pixel `p = gy·W + gx`.
3
8
  */
4
9
 
5
10
  import {
@@ -35,7 +40,8 @@ export function buildFinalWriteKernel( params ) {
35
40
  enableAccumulation, hasPreviousAccumulated, accumulationAlpha, cameraIsMoving,
36
41
  transparentBackground,
37
42
  prevAccumTexture, prevAlbedoTexture, prevNormalDepthTexture,
38
- renderWidth, renderHeight,
43
+ renderWidth,
44
+ chunkRowBase, chunkRows, // row band offset (global first row) + row count for this chunk
39
45
  visMode,
40
46
  // Aux MRT (normalDepth + albedo) feeds only the denoiser/OIDN. Gated by a live uniform (1 = denoiser
41
47
  // on): when off, skip the G-buffer decode, the prev-frame aux mix, and the two aux stores.
@@ -44,7 +50,8 @@ export function buildFinalWriteKernel( params ) {
44
50
  // OIDN models (calb_cnrm/high, alb_nrm/balanced); off for fast/ASVGF which want the bump normal.
45
51
  cleanAuxNormalEnabled,
46
52
  // Tier-1 convergence early-stop: per-pixel Welford luminance second moment + converged-pixel counter.
47
- counters, m2BufferRW, useAdaptiveSampling, noiseThreshold, darkNoiseFloor, adaptiveMinSamples,
53
+ counters, m2BufferRW, useAdaptiveSampling, noiseThreshold, adaptiveMinSamples,
54
+
48
55
  // Tier-2 per-pixel freeze: streakBufferRW = per-pixel freeze-candidate streak (stamped here);
49
56
  // frozenMaskRO = dilated frozen mask from buildActivePixels (pass-through gates on it, not streak).
50
57
  usePixelFreeze, pixelFreezeThreshold, pixelFreezeStability, streakBufferRW, frozenMaskRO,
@@ -59,12 +66,13 @@ export function buildFinalWriteKernel( params ) {
59
66
  const computeFn = Fn( () => {
60
67
 
61
68
  const gx = int( workgroupId.x ).mul( WG_SIZE ).add( int( localId.x ) );
62
- const gy = int( workgroupId.y ).mul( WG_SIZE ).add( int( localId.y ) );
69
+ const localGy = int( workgroupId.y ).mul( WG_SIZE ).add( int( localId.y ) );
63
70
 
64
- If( gx.lessThan( renderWidth ).and( gy.lessThan( renderHeight ) ), () => {
71
+ If( gx.lessThan( renderWidth ).and( localGy.lessThan( chunkRows ) ), () => {
65
72
 
66
- const pixelIndex = gy.mul( int( resolution.x ) ).add( gx );
67
- const rayID = uint( pixelIndex );
73
+ const gy = localGy.add( chunkRowBase ); // GLOBAL row
74
+ const rayID = uint( localGy.mul( renderWidth ).add( gx ) ); // LOCAL path/gBuffer slot
75
+ const pixelId = uint( gy.mul( renderWidth ).add( gx ) ); // GLOBAL pixel (persistent buffers + texture)
68
76
 
69
77
  const sampleColor = readRayRadiance( rayBufferRO, rayID );
70
78
  const finalColor = sampleColor.xyz.toVar();
@@ -73,9 +81,9 @@ export function buildFinalWriteKernel( params ) {
73
81
  // A pixel frozen in a prior frame was skipped by Generate, so its rayBuffer sample is stale — pass
74
82
  // the accumulated colour through unchanged (below) instead of mixing it. Inert unless adaptiveOn.
75
83
  const wasFrozen = adaptiveOn.and( frame.greaterThan( uint( 0 ) ) )
76
- .and( frozenMaskRO.element( rayID ).equal( uint( 1 ) ) ).toVar();
84
+ .and( frozenMaskRO.element( pixelId ).equal( uint( 1 ) ) ).toVar();
77
85
 
78
- // MRT comes from the per-pixel G-buffer (rayID == pixelIndex). Half-packed: decode.
86
+ // MRT comes from the per-chunk G-buffer (LOCAL slot). Half-packed: decode.
79
87
  // auxOn gates the decode + stores so a no-denoiser frame does no G-buffer read and no aux writes.
80
88
  const finalNormalDepth = vec4( 0.0 ).toVar();
81
89
  const finalAlbedo = vec4( 0.0 ).xyz.toVar();
@@ -134,15 +142,15 @@ export function buildFinalWriteKernel( params ) {
134
142
  // Tier-1 convergence: per-pixel running second moment of LUMINANCE (Welford). luminance() is linear,
135
143
  // so luminance(running-mean color) == running-mean luminance == E[L]; m2 tracks E[L²] under the SAME
136
144
  // global 1/(frame+1) alpha (NO per-pixel alpha). sampleVar = E[L²]-E[L]²; varOfMean = sampleVar/(N);
137
- // relErr = SE(mean)/mean. A pixel counts as converged once frame>=minSamples AND relErr<threshold.
145
+ // absSE = SE(mean); a pixel converges once frame>=minSamples AND the √-normalized error < threshold.
138
146
  // The m2 write runs every frame (incl. frame 0, where alpha==1 self-inits it → no explicit clear).
139
147
  // Sits AFTER the accumulation mix (finalColor is the mean) and BEFORE the visMode-11 mutation.
140
148
  If( convOn, () => {
141
149
 
142
150
  const sampleLum = luminance( sampleColor.xyz );
143
- const prevM2 = m2BufferRW.element( rayID ).toVar();
151
+ const prevM2 = m2BufferRW.element( pixelId ).toVar();
144
152
  const m2 = mix( prevM2, sampleLum.mul( sampleLum ), accumulationAlpha ).toVar();
145
- m2BufferRW.element( rayID ).assign( m2 );
153
+ m2BufferRW.element( pixelId ).assign( m2 );
146
154
 
147
155
  const meanLum = luminance( finalColor ).toVar();
148
156
  const sampleVar = m2.sub( meanLum.mul( meanLum ) ).max( float( 0 ) );
@@ -151,11 +159,15 @@ export function buildFinalWriteKernel( params ) {
151
159
  // Combined criterion: bright pixels converge on relErr<threshold; dark/dim pixels (where relErr
152
160
  // stays high forever) converge on absSE<absFloor, since their absolute noise is imperceptible.
153
161
  const absSE = varOfMean.sqrt().toVar();
154
- const relErr = absSE.div( meanLum.add( float( 1e-4 ) ) );
162
+ const relErr = absSE.div( meanLum.add( float( 1e-4 ) ) ); // Tier-2 freeze predicate reads this
163
+
164
+ // Cycles-style convergence: normalize the standard error by √luminance for dim pixels (lum<1),
165
+ // by luminance for bright ones. The √ loosens the relative bar in shadows so dark pixels converge
166
+ // on their small absolute noise instead of stalling forever on a pure relative bar.
167
+ const convNorm = select( meanLum.lessThan( float( 1.0 ) ), meanLum.sqrt(), meanLum );
168
+ const converged = absSE.div( convNorm.add( float( 1e-4 ) ) ).lessThan( noiseThreshold );
155
169
 
156
- If( frame.greaterThanEqual( uint( adaptiveMinSamples ) ).and(
157
- relErr.lessThan( noiseThreshold ).or( absSE.lessThan( darkNoiseFloor ) )
158
- ), () => {
170
+ If( frame.greaterThanEqual( uint( adaptiveMinSamples ) ).and( converged ), () => {
159
171
 
160
172
  atomicAdd( counters.element( uint( COUNTER.CONVERGED_COUNT ) ), uint( 1 ) );
161
173
 
@@ -168,7 +180,7 @@ export function buildFinalWriteKernel( params ) {
168
180
  If( frame.equal( uint( 0 ) ), () => {
169
181
 
170
182
  // Frame 0 (post-reset/camera-move) clears the frozen state so each run re-freezes fresh.
171
- streakBufferRW.element( rayID ).assign( uint( 0 ) );
183
+ streakBufferRW.element( pixelId ).assign( uint( 0 ) );
172
184
 
173
185
  } );
174
186
 
@@ -183,8 +195,8 @@ export function buildFinalWriteKernel( params ) {
183
195
 
184
196
  const freezeCandidate = frame.greaterThanEqual( uint( adaptiveMinSamples ) )
185
197
  .and( relErr.lessThan( pixelFreezeThreshold ) );
186
- const newStreak = select( freezeCandidate, streakBufferRW.element( rayID ).add( uint( 1 ) ), uint( 0 ) ).toVar();
187
- streakBufferRW.element( rayID ).assign( newStreak );
198
+ const newStreak = select( freezeCandidate, streakBufferRW.element( pixelId ).add( uint( 1 ) ), uint( 0 ) ).toVar();
199
+ streakBufferRW.element( pixelId ).assign( newStreak );
188
200
  // Just reached K → frozen next frame; count it now so the frozen population is current.
189
201
  If( newStreak.greaterThanEqual( uint( pixelFreezeStability ) ), () => {
190
202
 
@@ -2,6 +2,12 @@
2
2
  * GenerateKernel.js — wavefront primary ray generation.
3
3
  * Two dispatch modes from one builder: 2D screen-space (default) and 1D list-driven over the active-pixel
4
4
  * list (Tier-2 per-pixel freeze).
5
+ *
6
+ * Chunked path pool (docs/internal/specs/wavefront-chunked-pool.md): the image is streamed through a fixed
7
+ * device-budget pool in row bands. A kernel thread maps to a LOCAL path slot `rayID` (∈ [0,B)); the global
8
+ * pixel is `pixelBase + rayID` where `pixelBase = chunkRowBase · renderWidth`. Path state / gBuffer are
9
+ * written at the LOCAL slot; camera ray + RNG seed use the GLOBAL pixel coord (so a pixel's sample sequence
10
+ * is stable across chunks and frames).
5
11
  */
6
12
 
7
13
  import {
@@ -22,7 +28,7 @@ import { RAY_FLAG, COUNTER } from '../Processor/QueueManager.js';
22
28
  import {
23
29
  writeRayOriginMeta, writeRayDirFlags, writeRayThroughputPdf,
24
30
  writeRayRadiance, writeGBuffer,
25
- writeMediumStack,
31
+ writeMediumStack, writeFeatureThroughput,
26
32
  } from '../Processor/PackedRayBuffer.js';
27
33
 
28
34
  const WG_SIZE = 16;
@@ -34,23 +40,21 @@ export function buildGenerateKernel( params ) {
34
40
  resolution, frame,
35
41
  cameraWorldMatrix, cameraProjectionMatrixInverse,
36
42
  enableDOF, focalLength, aperture, focusDistance, sceneScale, apertureScale, anamorphicRatio,
37
- renderWidth, renderHeight,
43
+ renderWidth,
44
+ chunkRowBase, chunkRows, // row band offset (global first row) + row count for this chunk
38
45
  transmissiveBounces, // per-ray refraction budget (megakernel parity: PathTracerCore.js:606)
39
46
  transparentBackground, // alpha inits to 1 here (megakernel parity: PathTracerCore.js:554) — env-escape-without-opaque zeroes it in Shade
40
47
  auxGBufferEnabled, // live uniform: 1 = init the per-pixel G-buffer (denoiser on), 0 = skip it
41
- // listDriven: 1D dispatch over the active-pixel list (activeIndicesRO[tid] = pixelID) instead of 2D.
48
+ // listDriven: 1D dispatch over the active-pixel list (activeIndicesRO[tid] = LOCAL slot) instead of 2D.
42
49
  listDriven = false, activeIndicesRO = null, counters = null,
43
50
  } = params;
44
51
 
45
52
  const auxOn = auxGBufferEnabled.greaterThan( uint( 0 ) );
46
53
 
47
- // Shared ray-gen body for a pixel (gx, gy) — identical in both dispatch modes.
48
- const emitRay = ( gx, gy ) => {
54
+ // Shared ray-gen body. (gx, gy) is the GLOBAL pixel; rayID is the LOCAL path-slot (both dispatch modes).
55
+ const emitRay = ( gx, gy, rayID ) => {
49
56
 
50
57
  const pixelCoord = vec2( float( gx ).add( 0.5 ), float( gy ).add( 0.5 ) );
51
- const pixelIndex = gy.mul( int( resolution.x ) ).add( gx );
52
- // One ray per pixel: rayID is the pixel index.
53
- const rayID = uint( pixelIndex );
54
58
 
55
59
  const screenPosition = pixelCoord.div( resolution ).mul( 2.0 ).sub( 1.0 ).toVar();
56
60
  screenPosition.y.assign( screenPosition.y.negate() );
@@ -88,8 +92,10 @@ export function buildGenerateKernel( params ) {
88
92
 
89
93
  If( auxOn, () => {
90
94
 
91
- // default: normal +Z, depth 1 (far), black albedo (background/miss)
92
- writeGBuffer( gBufferRW, uint( pixelIndex ), vec3( 0.0, 0.0, 1.0 ), float( 1.0 ), vec3( 0.0 ) );
95
+ // default: normal +Z, depth 1 (far), black albedo (background/miss). Per-CHUNK G-buffer → LOCAL slot.
96
+ writeGBuffer( gBufferRW, rayID, vec3( 0.0, 0.0, 1.0 ), float( 1.0 ), vec3( 0.0 ) );
97
+ // DDFA: seed the see-through aux tint to white (no tint yet). RMW preserves slot-5 xyz (sigmaA).
98
+ writeFeatureThroughput( rayBufferRW, rayID, vec3( 1.0 ) );
93
99
 
94
100
  } );
95
101
 
@@ -107,21 +113,26 @@ export function buildGenerateKernel( params ) {
107
113
  // Grid is over-sized from a stale readback; bound on the live ENTERING_COUNT so extra threads no-op.
108
114
  If( tid.lessThan( atomicLoad( counters.element( uint( COUNTER.ENTERING_COUNT ) ) ) ), () => {
109
115
 
110
- const pixelIndex = int( activeIndicesRO.element( tid ) );
111
- const gx = pixelIndex.mod( int( resolution.x ) );
112
- const gy = pixelIndex.div( int( resolution.x ) );
113
- emitRay( gx, gy );
116
+ // active list holds LOCAL slots; global pixel = pixelBase + local slot.
117
+ const localSlot = int( activeIndicesRO.element( tid ) );
118
+ const globalPixel = localSlot.add( chunkRowBase.mul( renderWidth ) );
119
+ const gx = globalPixel.mod( renderWidth );
120
+ const gy = globalPixel.div( renderWidth );
121
+ emitRay( gx, gy, uint( localSlot ) );
114
122
 
115
123
  } );
116
124
 
117
125
  } else {
118
126
 
127
+ // 2D grid over (renderWidth × chunkRows). local_gy is the band-local row; global gy adds chunkRowBase.
119
128
  const gx = int( workgroupId.x ).mul( WG_SIZE ).add( int( localId.x ) );
120
- const gy = int( workgroupId.y ).mul( WG_SIZE ).add( int( localId.y ) );
129
+ const localGy = int( workgroupId.y ).mul( WG_SIZE ).add( int( localId.y ) );
121
130
 
122
- If( gx.lessThan( renderWidth ).and( gy.lessThan( renderHeight ) ), () => {
131
+ If( gx.lessThan( renderWidth ).and( localGy.lessThan( chunkRows ) ), () => {
123
132
 
124
- emitRay( gx, gy );
133
+ const gy = localGy.add( chunkRowBase );
134
+ const localRayID = uint( localGy.mul( renderWidth ).add( gx ) );
135
+ emitRay( gx, gy, localRayID );
125
136
 
126
137
  } );
127
138