rayzee 7.5.0 → 7.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rayzee",
3
- "version": "7.5.0",
3
+ "version": "7.5.1",
4
4
  "type": "module",
5
5
  "description": "Real-time WebGPU path tracing engine built on Three.js",
6
6
  "main": "dist/rayzee.umd.js",
@@ -88,6 +88,10 @@ export const ENGINE_DEFAULTS = {
88
88
  performanceModeAdaptive: 'medium',
89
89
 
90
90
  fireflyThreshold: 3.0,
91
+ // Wavefront material-coherence sort: global counting-sort of entering rays by material before
92
+ // Shade (material-pure workgroups), under dynamic dispatch. Measured −8% at 1024²/8b. Gated on
93
+ // material count > 8; the histogram bin count is sized per-scene to the material count.
94
+ wavefrontSortMaterials: true,
91
95
  renderLimitMode: 'frames',
92
96
  renderTimeLimit: 30,
93
97
  renderMode: 0,
@@ -12,8 +12,7 @@
12
12
  const WORKGROUP_SIZES = {
13
13
  generate: [ 16, 16, 1 ], // 2D screen-space
14
14
  extend: [ 256, 1, 1 ], // 1D ray-parallel
15
- sort: [ 256, 1, 1 ], // 1D ray-parallel
16
- shade: [ 256, 1, 1 ], // 1D ray-parallel (sorted)
15
+ shade: [ 256, 1, 1 ], // 1D ray-parallel (material-sorted when sort enabled)
17
16
  connect: [ 256, 1, 1 ], // 1D shadow-ray-parallel
18
17
  accumulate: [ 256, 1, 1 ], // 1D shadow-ray-parallel
19
18
  compact: [ 256, 1, 1 ], // 1D ray-parallel
@@ -40,6 +40,9 @@ export class QueueManager {
40
40
  // A/B alternate: one read by current bounce, other written by compaction
41
41
  this.activeIndices = null;
42
42
  this.activeIndicesRO = null;
43
+ this.sortedIndices = null;
44
+ this.sortedIndicesRO = null;
45
+ this.sortGlobalHistogram = null;
43
46
  this.pingPong = 0; // 0 = read A / write B, 1 = read B / write A
44
47
 
45
48
  if ( maxRays > 0 ) {
@@ -83,11 +86,23 @@ export class QueueManager {
83
86
  b: storage( attrB, 'uint' ).toReadOnly(),
84
87
  };
85
88
 
89
+ // Material-sort output: a material-reordered permutation of the active index
90
+ // list, written by the global material sort and read by Shade in place of activeIndices.
91
+ const sortAttr = new StorageInstancedBufferAttribute( new Uint32Array( capacity ), 1 );
92
+ this._sortAttr = sortAttr;
93
+ this.sortedIndices = storage( sortAttr, 'uint' );
94
+ this.sortedIndicesRO = storage( sortAttr, 'uint' ).toReadOnly();
95
+
96
+ // Global material-sort histogram, sized to the bin cap (SORT_GLOBAL_MAX_BINS=256); kernels use
97
+ // only the first `bins` entries (= per-scene material count). 256 × 4B = 1KB.
98
+ this._sortGlobalHistAttr = new StorageInstancedBufferAttribute( new Uint32Array( 256 ), 1 );
99
+ this.sortGlobalHistogram = storage( this._sortGlobalHistAttr, 'uint' ).toAtomic();
100
+
86
101
  this.pingPong = 0;
87
102
 
88
103
  const totalBytes = (
89
104
  COUNTER.COUNT * 4 +
90
- capacity * 4 * 2
105
+ capacity * 4 * 3
91
106
  );
92
107
 
93
108
  console.log(
@@ -131,6 +146,24 @@ export class QueueManager {
131
146
 
132
147
  }
133
148
 
149
+ getSortedRW() {
150
+
151
+ return this.sortedIndices;
152
+
153
+ }
154
+
155
+ getSortedRO() {
156
+
157
+ return this.sortedIndicesRO;
158
+
159
+ }
160
+
161
+ getSortGlobalHistogram() {
162
+
163
+ return this.sortGlobalHistogram;
164
+
165
+ }
166
+
134
167
  // raw attribute for `renderer.getArrayBufferAsync(...)` readback
135
168
  getCountersAttribute() {
136
169
 
@@ -167,6 +200,9 @@ export class QueueManager {
167
200
  this.counters = null;
168
201
  this.activeIndices = null;
169
202
  this.activeIndicesRO = null;
203
+ this.sortedIndices = null;
204
+ this.sortedIndicesRO = null;
205
+ this.sortGlobalHistogram = null;
170
206
  this.capacity = 0;
171
207
 
172
208
  }
@@ -17,6 +17,11 @@ import { buildShadeKernel, SHADE_WG_SIZE } from '../TSL/ShadeKernel.js';
17
17
  import { buildCompactKernel, buildCompactSubgroupKernel, COMPACT_WG_SIZE } from '../TSL/CompactKernel.js';
18
18
  import { buildFinalWriteKernel, FINALWRITE_WG_SIZE } from '../TSL/FinalWriteKernel.js';
19
19
  import { buildDebugKernel, DEBUG_WG_SIZE } from '../TSL/DebugKernel.js';
20
+ import {
21
+ buildResetGlobalHistKernel, buildGlobalHistKernel, buildGlobalPrefixKernel, buildGlobalScatterKernel,
22
+ SORT_GLOBAL_WG_SIZE, SORT_GLOBAL_MAX_BINS,
23
+ } from '../TSL/SortGlobalKernels.js';
24
+ import { ENGINE_DEFAULTS } from '../EngineDefaults.js';
20
25
  import {
21
26
  Fn, uint, atomicStore, atomicLoad, instanceIndex, If, Return,
22
27
  } from 'three/tsl';
@@ -43,6 +48,11 @@ export class PathTracer extends PathTracerStage {
43
48
  // CPU sizes per-bounce kernels from last frame's survivor curve; kernels bound on ENTERING_COUNT so over-sizing is safe. (indirect dispatch not viable — three.js doesn't sync compute-written indirect buffers across submissions)
44
49
  this._useDynamicDispatch = true;
45
50
 
51
+ // Global material-coherence sort: set per-build from ENGINE_DEFAULTS + material count (>8).
52
+ // Reorders entering rays into material-pure workgroups before Shade; runs under dynamic dispatch
53
+ // (compact reads the unsorted active list, so the survivor set is unchanged). Measured −8% at 1024²/8b.
54
+ this._sortMaterials = false;
55
+
46
56
  // Flag-gated off: perf-neutral vs atomic-append and adds a 'subgroups' feature dependency.
47
57
  this._useSubgroupCompact = false;
48
58
 
@@ -90,7 +100,7 @@ export class PathTracer extends PathTracerStage {
90
100
  if ( ! qm ) return null;
91
101
  return [
92
102
  qm._countersAttr, qm._bounceCountsAttr,
93
- qm._attrA, qm._attrB,
103
+ qm._attrA, qm._attrB, qm._sortAttr,
94
104
  ];
95
105
 
96
106
  } );
@@ -250,6 +260,7 @@ export class PathTracer extends PathTracerStage {
250
260
  this._wfCurrentBounce.value = bounce;
251
261
 
252
262
  // 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).
263
+ // Material sort is compatible: shade reads sortedIndices while compact still reads the UNSORTED active list (getActiveReadRO), so the survivor set is unchanged.
253
264
  const useFunctionalCompaction = this._useDynamicDispatch;
254
265
  if ( useFunctionalCompaction ) {
255
266
 
@@ -281,6 +292,8 @@ export class PathTracer extends PathTracerStage {
281
292
  const wg = [ Math.ceil( sized / 256 ), 1, 1 ];
282
293
  km.setDispatchCount( 'extend', wg );
283
294
  km.setDispatchCount( 'shade', wg );
295
+ km.setDispatchCount( 'globalHist', wg );
296
+ km.setDispatchCount( 'globalScatter', wg );
284
297
  km.setDispatchCount( 'compact', wg );
285
298
  km.setDispatchCount( 'compactCopyback', wg );
286
299
 
@@ -291,11 +304,23 @@ export class PathTracer extends PathTracerStage {
291
304
  km.setDispatchCount( 'extend', full );
292
305
  km.setDispatchCount( 'shade', full );
293
306
  km.setDispatchCount( 'compact', full );
307
+ km.setDispatchCount( 'globalHist', full );
308
+ km.setDispatchCount( 'globalScatter', full );
294
309
 
295
310
  }
296
311
 
297
312
  // Extend/Shade kept separate (not fused): a fused kernel's register pressure drops occupancy more than fusion saves.
298
313
  km.dispatch( 'extend' );
314
+ if ( this._sortMaterials ) {
315
+
316
+ // Global material counting sort (material-pure workgroups): reset, histogram, prefix-sum, scatter.
317
+ km.dispatch( 'resetGlobalHist' );
318
+ km.dispatch( 'globalHist' );
319
+ km.dispatch( 'globalPrefix' );
320
+ km.dispatch( 'globalScatter' );
321
+
322
+ }
323
+
299
324
  km.dispatch( 'shade' );
300
325
 
301
326
  km.dispatch( 'resetActiveCounter' );
@@ -660,6 +685,10 @@ export class PathTracer extends PathTracerStage {
660
685
  displacementMaps: freshDisplacementMaps,
661
686
  };
662
687
 
688
+ // Material-coherence sort gate (experiment): only worthwhile above a few materials.
689
+ this._sortMaterials = ( ENGINE_DEFAULTS.wavefrontSortMaterials ?? false )
690
+ && ( this.materialData?.materialCount ?? 0 ) > 8;
691
+
663
692
  const extFn = buildExtendKernel( {
664
693
  bvhBuffer: freshBvh,
665
694
  triangleBuffer: freshTri,
@@ -677,6 +706,50 @@ export class PathTracer extends PathTracerStage {
677
706
  )
678
707
  );
679
708
 
709
+ // Material-coherence sort: reorder the entering-ray indices by material between
710
+ // Extend and Shade. Histogram is workgroup-shared (patches.js §4); Shade reads the output.
711
+ if ( this._sortMaterials ) {
712
+
713
+ const sgHist = qm.getSortGlobalHistogram();
714
+ const sortBins = Math.min( SORT_GLOBAL_MAX_BINS, this.materialData?.materialCount ?? SORT_GLOBAL_MAX_BINS );
715
+ this._kernelManager.register( 'resetGlobalHist',
716
+ buildResetGlobalHistKernel( { sortGlobalHistogram: sgHist, bins: sortBins } )().compute(
717
+ [ 1, 1, 1 ], [ SORT_GLOBAL_WG_SIZE, 1, 1 ]
718
+ )
719
+ );
720
+ this._kernelManager.register( 'globalHist',
721
+ buildGlobalHistKernel( {
722
+ hitBufferRO: pb.hitBuffer.ro,
723
+ activeIndicesReadRO: qm.getActiveReadRO(),
724
+ sortGlobalHistogram: sgHist,
725
+ counters,
726
+ bins: sortBins,
727
+ } )().compute(
728
+ [ Math.ceil( maxRays / SORT_GLOBAL_WG_SIZE ), 1, 1 ],
729
+ [ SORT_GLOBAL_WG_SIZE, 1, 1 ]
730
+ )
731
+ );
732
+ this._kernelManager.register( 'globalPrefix',
733
+ buildGlobalPrefixKernel( { sortGlobalHistogram: sgHist, bins: sortBins } )().compute(
734
+ [ 1, 1, 1 ], [ 1, 1, 1 ]
735
+ )
736
+ );
737
+ this._kernelManager.register( 'globalScatter',
738
+ buildGlobalScatterKernel( {
739
+ hitBufferRO: pb.hitBuffer.ro,
740
+ activeIndicesReadRO: qm.getActiveReadRO(),
741
+ sortedIndicesRW: qm.getSortedRW(),
742
+ sortGlobalHistogram: sgHist,
743
+ counters,
744
+ bins: sortBins,
745
+ } )().compute(
746
+ [ Math.ceil( maxRays / SORT_GLOBAL_WG_SIZE ), 1, 1 ],
747
+ [ SORT_GLOBAL_WG_SIZE, 1, 1 ]
748
+ )
749
+ );
750
+
751
+ }
752
+
680
753
  const shadeFn = buildShadeKernel( {
681
754
  gBufferRW,
682
755
  envCompensationDelta: this.envCompensationDelta,
@@ -689,7 +762,7 @@ export class PathTracer extends PathTracerStage {
689
762
  rngBufferRW: pb.rngBuffer.rw,
690
763
  hitBufferRO: pb.hitBuffer.ro,
691
764
  counters,
692
- activeIndicesRO: qm.getActiveReadRO(),
765
+ activeIndicesRO: this._sortMaterials ? qm.getSortedRO() : qm.getActiveReadRO(),
693
766
  albedoMaps: freshAlbedoMaps,
694
767
  normalMaps: freshNormalMaps,
695
768
  bumpMaps: freshBumpMaps,
@@ -0,0 +1,135 @@
1
+ /**
2
+ * SortGlobalKernels.js — GLOBAL material counting sort of the entering rays.
3
+ *
4
+ * Unlike a per-workgroup sort (locally coherent only), this orders rays by material
5
+ * ACROSS ALL workgroups, so each material's rays occupy one contiguous region of sortedIndices.
6
+ * For materials with many rays that means whole material-PURE workgroups/subgroups → real shading
7
+ * coherence. MEASURED −8% vs no-sort at 1024²/8b (per-WG sort was neutral).
8
+ *
9
+ * Four passes (counting sort): reset → histogram → exclusive prefix sum → scatter.
10
+ * - histogram uses a per-workgroup local histogram (workgroup-shared atomics, patches.js §4)
11
+ * flushed to the global histogram once per bin per workgroup, cutting global-atomic contention
12
+ * from O(rays) to O(workgroups × bins).
13
+ * - the prefix-summed histogram doubles as the per-bin running cursor in scatter.
14
+ *
15
+ * `bins` is chosen per-scene by the caller (= material count, capped). Materials ≥ bins clamp into
16
+ * the last bin (graceful coherence loss). Capped at SORT_GLOBAL_MAX_BINS so a single 256-thread
17
+ * workgroup can zero/flush exactly one bin per thread (no per-thread loop needed).
18
+ */
19
+
20
+ import {
21
+ Fn, uint,
22
+ If, Loop,
23
+ instanceIndex, localId,
24
+ workgroupBarrier,
25
+ atomicAdd, atomicLoad, atomicStore,
26
+ } from 'three/tsl';
27
+
28
+ import { workgroupAtomicArray } from './patches.js';
29
+ import { readHitMaterialIndex } from '../Processor/PackedRayBuffer.js';
30
+ import { COUNTER } from '../Processor/QueueManager.js';
31
+
32
+ const WG_SIZE = 256;
33
+ export const SORT_GLOBAL_MAX_BINS = 256;
34
+
35
+ // Pass 1 — zero the global histogram.
36
+ export function buildResetGlobalHistKernel( { sortGlobalHistogram, bins = SORT_GLOBAL_MAX_BINS } ) {
37
+
38
+ return Fn( () => {
39
+
40
+ If( instanceIndex.lessThan( uint( bins ) ), () => {
41
+
42
+ atomicStore( sortGlobalHistogram.element( instanceIndex ), uint( 0 ) );
43
+
44
+ } );
45
+
46
+ } );
47
+
48
+ }
49
+
50
+ // Pass 2 — histogram: per-workgroup local count (on-chip), flushed to global once per bin/WG.
51
+ export function buildGlobalHistKernel( { hitBufferRO, activeIndicesReadRO, sortGlobalHistogram, counters, bins = SORT_GLOBAL_MAX_BINS } ) {
52
+
53
+ return Fn( () => {
54
+
55
+ const tid = instanceIndex;
56
+ const lid = localId.x;
57
+ const local = workgroupAtomicArray( 'uint', bins );
58
+
59
+ If( lid.lessThan( uint( bins ) ), () => {
60
+
61
+ atomicStore( local.element( lid ), uint( 0 ) );
62
+
63
+ } );
64
+ workgroupBarrier();
65
+
66
+ const entering = atomicLoad( counters.element( uint( COUNTER.ENTERING_COUNT ) ) );
67
+ If( tid.lessThan( entering ), () => {
68
+
69
+ const rayID = activeIndicesReadRO.element( tid );
70
+ const bin = uint( readHitMaterialIndex( hitBufferRO, rayID ) ).clamp( uint( 0 ), uint( bins - 1 ) );
71
+ atomicAdd( local.element( bin ), uint( 1 ) );
72
+
73
+ } );
74
+ workgroupBarrier();
75
+
76
+ If( lid.lessThan( uint( bins ) ), () => {
77
+
78
+ const c = atomicLoad( local.element( lid ) );
79
+ If( c.greaterThan( uint( 0 ) ), () => {
80
+
81
+ atomicAdd( sortGlobalHistogram.element( lid ), c );
82
+
83
+ } );
84
+
85
+ } );
86
+
87
+ } );
88
+
89
+ }
90
+
91
+ // Pass 3 — exclusive prefix sum over global bins (single thread; dispatch [1,1,1]).
92
+ export function buildGlobalPrefixKernel( { sortGlobalHistogram, bins = SORT_GLOBAL_MAX_BINS } ) {
93
+
94
+ return Fn( () => {
95
+
96
+ If( instanceIndex.equal( uint( 0 ) ), () => {
97
+
98
+ const running = uint( 0 ).toVar();
99
+ Loop( { start: uint( 0 ), end: uint( bins ), type: 'uint' }, ( { i } ) => {
100
+
101
+ const slot = sortGlobalHistogram.element( i );
102
+ const c = atomicLoad( slot );
103
+ atomicStore( slot, running );
104
+ running.addAssign( c );
105
+
106
+ } );
107
+
108
+ } );
109
+
110
+ } );
111
+
112
+ }
113
+
114
+ // Pass 4 — scatter: atomicAdd on the prefix-summed histogram gives each ray its global slot
115
+ // within its material's contiguous region.
116
+ export function buildGlobalScatterKernel( { hitBufferRO, activeIndicesReadRO, sortedIndicesRW, sortGlobalHistogram, counters, bins = SORT_GLOBAL_MAX_BINS } ) {
117
+
118
+ return Fn( () => {
119
+
120
+ const tid = instanceIndex;
121
+ const entering = atomicLoad( counters.element( uint( COUNTER.ENTERING_COUNT ) ) );
122
+ If( tid.lessThan( entering ), () => {
123
+
124
+ const rayID = activeIndicesReadRO.element( tid );
125
+ const bin = uint( readHitMaterialIndex( hitBufferRO, rayID ) ).clamp( uint( 0 ), uint( bins - 1 ) );
126
+ const pos = atomicAdd( sortGlobalHistogram.element( bin ), uint( 1 ) );
127
+ sortedIndicesRW.element( pos ).assign( rayID );
128
+
129
+ } );
130
+
131
+ } );
132
+
133
+ }
134
+
135
+ export { WG_SIZE as SORT_GLOBAL_WG_SIZE };
@@ -61,6 +61,10 @@ WebGPUBackend.prototype.createNodeBuilder = function ( object, renderer ) {
61
61
  configurable: true,
62
62
  } );
63
63
 
64
+ // Install the workgroup-atomic-array codegen patch (section 4) lazily off the
65
+ // first builder, so we don't need to import WGSLNodeBuilder directly.
66
+ _installScopedArrayAtomicPatch( builder );
67
+
64
68
  return builder;
65
69
 
66
70
  };
@@ -220,3 +224,89 @@ export function struct( members, name = null ) {
220
224
  return wrappedFactory;
221
225
 
222
226
  }
227
+
228
+ // ---------------------------------------------------------------------------
229
+ // 4. Workgroup-scoped atomic arrays — var<workgroup> array<atomic<T>, N>
230
+ // ---------------------------------------------------------------------------
231
+ // TSL's `workgroupArray(type, count)` emits `var<workgroup> name: array<T, N>`
232
+ // (`WGSLNodeBuilder.getScopedArrays`, r185 ~line 1773) with a NON-atomic element
233
+ // type, so `atomicAdd(arr.element(i), v)` fails WGSL validation. AtomicFunctionNode
234
+ // is itself address-space-agnostic — it emits `atomicAdd(&name[i], v)`, valid for
235
+ // BOTH storage and workgroup pointers — so the ONLY missing piece is the
236
+ // declaration. This patch adds `workgroupAtomicArray()` (a `workgroupArray` tagged
237
+ // atomic) and overrides `getScopedArrays` to wrap the element type in `atomic<…>`
238
+ // for tagged arrays, enabling true on-chip workgroup-shared atomics (e.g. fast
239
+ // per-workgroup histograms) instead of the slow global-storage-atomic fallback.
240
+ // Access tagged arrays ONLY via atomic ops (atomicAdd/atomicLoad/atomicStore).
241
+
242
+ import { workgroupArray } from 'three/tsl';
243
+
244
+ const _WorkgroupInfoNode = workgroupArray( 'uint', 1 ).constructor;
245
+
246
+ // Tag the node so the patched getScopedArrays emits an atomic element type. The
247
+ // array's WGSL name is only known after generate() runs, so we mark the builder's
248
+ // scopedArrays entry there (idempotent across analyze/generate passes).
249
+ const _origWorkgroupGenerate = _WorkgroupInfoNode.prototype.generate;
250
+
251
+ _WorkgroupInfoNode.prototype.generate = function ( builder ) {
252
+
253
+ const name = _origWorkgroupGenerate.call( this, builder );
254
+ if ( this.isAtomicArray === true ) {
255
+
256
+ const entry = builder.scopedArrays && builder.scopedArrays.get( name );
257
+ if ( entry ) entry.isAtomic = true;
258
+
259
+ }
260
+
261
+ return name;
262
+
263
+ };
264
+
265
+ /**
266
+ * Like `workgroupArray(type, count)` but declares the workgroup buffer with an
267
+ * atomic element type: `var<workgroup> name: array<atomic<type>, count>`.
268
+ * Elements MUST be accessed only via atomic ops (atomicAdd/atomicLoad/atomicStore).
269
+ *
270
+ * @param {string} type - Element type (e.g. 'uint').
271
+ * @param {number} count - Number of elements.
272
+ * @returns {WorkgroupInfoNode} The tagged workgroup array node.
273
+ */
274
+ export function workgroupAtomicArray( type, count ) {
275
+
276
+ const node = workgroupArray( type, count );
277
+ node.isAtomicArray = true;
278
+ return node;
279
+
280
+ }
281
+
282
+ let _scopedArraysPatched = false;
283
+
284
+ // Override getScopedArrays on the builder's prototype (compute-stage WGSL
285
+ // assembly) to emit `atomic<T>` element types for tagged workgroup arrays.
286
+ // Installed once, lazily, off the first node builder created.
287
+ function _installScopedArrayAtomicPatch( builder ) {
288
+
289
+ if ( _scopedArraysPatched ) return;
290
+ const proto = Object.getPrototypeOf( builder );
291
+ if ( ! proto || typeof proto.getScopedArrays !== 'function' ) return;
292
+
293
+ proto.getScopedArrays = function ( shaderStage ) {
294
+
295
+ if ( shaderStage !== 'compute' ) return;
296
+
297
+ const snippets = [];
298
+ for ( const { name, scope, bufferType, bufferCount, isAtomic } of this.scopedArrays.values() ) {
299
+
300
+ const type = this.getType( bufferType );
301
+ const elementType = ( isAtomic === true ) ? `atomic< ${ type } >` : type;
302
+ snippets.push( `var<${ scope }> ${ name }: array< ${ elementType }, ${ bufferCount } >;` );
303
+
304
+ }
305
+
306
+ return snippets.join( '\n' );
307
+
308
+ };
309
+
310
+ _scopedArraysPatched = true;
311
+
312
+ }