rayzee 7.5.0 → 7.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rayzee",
3
- "version": "7.5.0",
3
+ "version": "7.6.0",
4
4
  "type": "module",
5
5
  "description": "Real-time WebGPU path tracing engine built on Three.js",
6
6
  "main": "dist/rayzee.umd.js",
@@ -10,6 +10,11 @@ export const ENGINE_DEFAULTS = {
10
10
  canvasWidth: 512,
11
11
  canvasHeight: 512,
12
12
 
13
+ // Max material-texture dimension (longest edge) used when processing a scene's
14
+ // textures into GPU arrays. Larger = sharper textures but ~quadratic VRAM. Clamped
15
+ // to TEXTURE_CONSTANTS.MAX_TEXTURE_SIZE (hardware ceiling). Applied at scene load.
16
+ maxTextureSize: 4096,
17
+
13
18
  toneMapping: 4,
14
19
  exposure: 1,
15
20
  saturation: 1.2,
@@ -88,6 +93,10 @@ export const ENGINE_DEFAULTS = {
88
93
  performanceModeAdaptive: 'medium',
89
94
 
90
95
  fireflyThreshold: 3.0,
96
+ // Wavefront material-coherence sort: global counting-sort of entering rays by material before
97
+ // Shade (material-pure workgroups), under dynamic dispatch. Measured −8% at 1024²/8b. Gated on
98
+ // material count > 8; the histogram bin count is sized per-scene to the material count.
99
+ wavefrontSortMaterials: true,
91
100
  renderLimitMode: 'frames',
92
101
  renderTimeLimit: 30,
93
102
  renderMode: 0,
@@ -479,7 +488,11 @@ export const TEXTURE_CONSTANTS = {
479
488
  BUFFER_POOL_SIZE: 20,
480
489
  CANVAS_POOL_SIZE: 12,
481
490
  CACHE_SIZE_LIMIT: 50,
482
- MAX_TEXTURE_SIZE: 8192
491
+ // Hardware ceiling for a single texture-array dimension (WebGPU maxTextureDimension2D
492
+ // guaranteed minimum). The configurable maxTextureSize setting is clamped to this.
493
+ MAX_TEXTURE_SIZE: 8192,
494
+ // Default cap applied when no maxTextureSize is supplied (engine standalone use).
495
+ DEFAULT_MAX_TEXTURE_SIZE: 4096
483
496
  };
484
497
 
485
498
  // Default texture matrix for materials
@@ -103,6 +103,8 @@ export class PathTracerApp extends EventDispatcher {
103
103
  this.assetLoader = null;
104
104
  this._sdf = null;
105
105
  this._animRefitInFlight = false;
106
+ // Max material-texture dimension (longest edge); applied on each scene build.
107
+ this._maxTextureSize = DEFAULT_STATE.maxTextureSize;
106
108
 
107
109
  // ── Pipeline & stages ──
108
110
  this.pipeline = null;
@@ -667,6 +669,47 @@ export class PathTracerApp extends EventDispatcher {
667
669
 
668
670
  }
669
671
 
672
+ /**
673
+ * Set the max material-texture dimension (longest edge) used when processing a
674
+ * scene's textures into GPU arrays. Clamped to the hardware ceiling. Larger =
675
+ * sharper textures, ~quadratic VRAM. By default reprocesses the current scene so
676
+ * the change is visible without a manual reload.
677
+ * @param {number} size
678
+ * @param {Object} [opts]
679
+ * @param {boolean} [opts.reprocess=true] - Rebuild the current scene now.
680
+ * @returns {Promise<void>}
681
+ */
682
+ async setMaxTextureSize( size, { reprocess = true } = {} ) {
683
+
684
+ const prev = this._maxTextureSize;
685
+ const clamped = this._sdf?.setMaxTextureSize( size );
686
+ this._maxTextureSize = clamped ?? size;
687
+ if ( typeof this.stages?.pathTracer?.sdfs?.setMaxTextureSize === 'function' ) {
688
+
689
+ this.stages.pathTracer.sdfs.setMaxTextureSize( this._maxTextureSize );
690
+
691
+ }
692
+
693
+ // Reprocess the loaded scene so the new cap takes effect immediately.
694
+ if ( reprocess && this._maxTextureSize !== prev && this._sdf?.triangleData && ! this._loadingInProgress ) {
695
+
696
+ this._loadingInProgress = true;
697
+ try {
698
+
699
+ await this.loadSceneData();
700
+ this.reset();
701
+ this.dispatchEvent( { type: 'TexturesReprocessed', maxTextureSize: this._maxTextureSize } );
702
+
703
+ } finally {
704
+
705
+ this._loadingInProgress = false;
706
+
707
+ }
708
+
709
+ }
710
+
711
+ }
712
+
670
713
  /** Shared pipeline: load asset → sync controls → build BVH → reset → dispatch events */
671
714
  async _loadWithSceneRebuild( loadFn, eventPayload ) {
672
715
 
@@ -730,6 +773,7 @@ export class PathTracerApp extends EventDispatcher {
730
773
 
731
774
  // Build BVH
732
775
  timer.start( 'BVH build (SceneProcessor)' );
776
+ this._sdf.setMaxTextureSize( this._maxTextureSize );
733
777
  await this._sdf.buildBVH( this.meshScene );
734
778
  timer.end( 'BVH build (SceneProcessor)' );
735
779
 
@@ -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
  }
@@ -9,7 +9,7 @@ import { GeometryExtractor } from './GeometryExtractor.js';
9
9
  import { EmissiveTriangleBuilder } from './EmissiveTriangleBuilder.js';
10
10
  import { updateLoading } from '../Processor/utils.js';
11
11
  import { BuildTimer } from './BuildTimer.js';
12
- import { TRIANGLE_DATA_LAYOUT } from '../EngineDefaults.js';
12
+ import { TRIANGLE_DATA_LAYOUT, TEXTURE_CONSTANTS } from '../EngineDefaults.js';
13
13
  import { fetchAsWorker } from './Workers/fetchAsWorker.js';
14
14
  import BVH_WORKER_URL from './Workers/BVHWorker.js?worker&url';
15
15
  import BVH_REFIT_WORKER_URL from './Workers/BVHRefitWorker.js?worker&url';
@@ -41,6 +41,7 @@ export class SceneProcessor {
41
41
  verbose: false,
42
42
  useFloat32Array: true,
43
43
  textureQuality: 'adaptive', // 'low', 'medium', 'high', 'adaptive'
44
+ maxTextureSize: TEXTURE_CONSTANTS.DEFAULT_MAX_TEXTURE_SIZE, // longest-edge cap for material textures
44
45
  enableTextureCache: true,
45
46
  maxConcurrentTextureTasks: Math.min( navigator.hardwareConcurrency || 4, 6 ),
46
47
  // Treelet optimization configuration
@@ -109,6 +110,17 @@ export class SceneProcessor {
109
110
 
110
111
  }
111
112
 
113
+ /**
114
+ * Set the max material-texture dimension applied on the next scene build.
115
+ * @param {number} size - Longest-edge cap (clamped to the hardware ceiling).
116
+ */
117
+ setMaxTextureSize( size ) {
118
+
119
+ this.config.maxTextureSize = size;
120
+ return this.textureCreator?.setMaxTextureSize( size );
121
+
122
+ }
123
+
112
124
  /**
113
125
  * Initialize processing components with configuration
114
126
  * @private
@@ -131,7 +143,7 @@ export class SceneProcessor {
131
143
  } );
132
144
 
133
145
  // Create and configure texture creator
134
- this.textureCreator = new TextureCreator();
146
+ this.textureCreator = new TextureCreator( { maxTextureSize: this.config.maxTextureSize } );
135
147
  // The optimized TextureCreator will auto-detect capabilities and select optimal methods
136
148
 
137
149
  // Create emissive triangle builder for direct lighting
@@ -446,6 +446,11 @@ export class TextureCreator {
446
446
  this.maxConcurrentWorkers = TEXTURE_CONSTANTS.MAX_CONCURRENT_WORKERS;
447
447
  this.activeWorkers = 0;
448
448
 
449
+ // Longest-edge cap for material-texture arrays. Clamped to the hardware ceiling.
450
+ this.maxTextureSize = this._clampTextureSize(
451
+ options.maxTextureSize ?? TEXTURE_CONSTANTS.DEFAULT_MAX_TEXTURE_SIZE
452
+ );
453
+
449
454
  // Initialize high-performance components
450
455
  this.canvasPool = new CanvasPool();
451
456
  this.bufferPool = new SmartBufferPool( {
@@ -460,6 +465,25 @@ export class TextureCreator {
460
465
 
461
466
  }
462
467
 
468
+ _clampTextureSize( size ) {
469
+
470
+ const n = Math.floor( Number( size ) || TEXTURE_CONSTANTS.DEFAULT_MAX_TEXTURE_SIZE );
471
+ return Math.max( TEXTURE_CONSTANTS.MIN_TEXTURE_WIDTH, Math.min( n, TEXTURE_CONSTANTS.MAX_TEXTURE_SIZE ) );
472
+
473
+ }
474
+
475
+ setMaxTextureSize( size ) {
476
+
477
+ const clamped = this._clampTextureSize( size );
478
+ if ( clamped === this.maxTextureSize ) return clamped; // unchanged — keep cache
479
+ this.maxTextureSize = clamped;
480
+ // Cache keys don't encode size — drop cached arrays so a new cap takes effect.
481
+ this.textureCache?.dispose();
482
+ this.textureCache = new TextureCache();
483
+ return this.maxTextureSize;
484
+
485
+ }
486
+
463
487
  detectCapabilities() {
464
488
 
465
489
  return {
@@ -656,7 +680,7 @@ export class TextureCreator {
656
680
 
657
681
  worker.postMessage( {
658
682
  textures: texturesData,
659
- maxTextureSize: TEXTURE_CONSTANTS.MAX_TEXTURE_SIZE,
683
+ maxTextureSize: this.maxTextureSize,
660
684
  method: 'direct-transfer'
661
685
  }, transferables );
662
686
 
@@ -1256,7 +1280,8 @@ export class TextureCreator {
1256
1280
  maxWidth = Math.pow( 2, Math.ceil( Math.log2( maxWidth ) ) );
1257
1281
  maxHeight = Math.pow( 2, Math.ceil( Math.log2( maxHeight ) ) );
1258
1282
 
1259
- while ( maxWidth >= TEXTURE_CONSTANTS.MAX_TEXTURE_SIZE / 2 || maxHeight >= TEXTURE_CONSTANTS.MAX_TEXTURE_SIZE / 2 ) {
1283
+ // Halve only while a dimension exceeds the cap (preserves native res up to the cap).
1284
+ while ( maxWidth > this.maxTextureSize || maxHeight > this.maxTextureSize ) {
1260
1285
 
1261
1286
  maxWidth = Math.max( 1, Math.floor( maxWidth / 2 ) );
1262
1287
  maxHeight = Math.max( 1, Math.floor( maxHeight / 2 ) );
@@ -3,7 +3,7 @@ let canvas, ctx;
3
3
  // Memory limits and chunking configuration
4
4
  const MEMORY_LIMITS = {
5
5
  MAX_BYTES_PER_TEXTURE: 256 * 1024 * 1024, // 256MB per texture array
6
- MAX_TEXTURE_DIMENSION: 4096, // Maximum dimension for a single texture
6
+ MAX_TEXTURE_DIMENSION: 8192, // Hardware ceiling (WebGPU maxTextureDimension2D guaranteed min); the per-scene maxTextureSize setting is the actual knob
7
7
  CHUNK_SIZE: 8, // Optimized: Process textures in chunks of 8 for better memory locality
8
8
  ADAPTIVE_CHUNK_SIZE: true, // Enable adaptive chunk sizing based on texture dimensions
9
9
  MEMORY_SAFETY_FACTOR: 0.8 // Use only 80% of estimated available memory
@@ -627,8 +627,8 @@ function calculateOptimalDimensions( textures, maxTextureSize ) {
627
627
  maxWidth = Math.min( maxWidth, maxTextureSize, MEMORY_LIMITS.MAX_TEXTURE_DIMENSION );
628
628
  maxHeight = Math.min( maxHeight, maxTextureSize, MEMORY_LIMITS.MAX_TEXTURE_DIMENSION );
629
629
 
630
- // Additional safety check
631
- while ( maxWidth >= maxTextureSize / 2 || maxHeight >= maxTextureSize / 2 ) {
630
+ // Halve only while a dimension exceeds the cap (preserves native res up to the cap).
631
+ while ( maxWidth > maxTextureSize || maxHeight > maxTextureSize ) {
632
632
 
633
633
  maxWidth = Math.max( 1, Math.floor( maxWidth / 2 ) );
634
634
  maxHeight = Math.max( 1, Math.floor( maxHeight / 2 ) );
@@ -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,
@@ -1,4 +1,4 @@
1
- import { Fn, float, vec2, int, If, Loop, abs, normalize, dot, max } from 'three/tsl';
1
+ import { Fn, float, vec2, int, If, Loop, abs, normalize, dot, max, textureSize } from 'three/tsl';
2
2
 
3
3
  import { struct } from './patches.js';
4
4
  import { getDatafromStorageBuffer } from './Common.js';
@@ -8,7 +8,6 @@ import { sampleDisplacementMap } from './TextureSampling.js';
8
8
  const MAX_MARCH_STEPS = 32;
9
9
  const MIN_MARCH_STEPS = 16;
10
10
  const BINARY_STEPS = 5;
11
- const NORMAL_TEXEL_SIZE = 1.0 / 1024.0;
12
11
  const TRI_STRIDE = 8;
13
12
 
14
13
  export const DisplacementResult = struct( {
@@ -194,14 +193,15 @@ export const refineDisplacedIntersection = Fn( ( [
194
193
  );
195
194
  const displacedHeight = finalHeight.sub( 0.5 ).mul( scale );
196
195
 
197
- // Compute displaced normal from height-field gradients using UV tangent vectors
198
- const h = float( NORMAL_TEXEL_SIZE );
196
+ // Compute displaced normal from height-field gradients using UV tangent vectors.
197
+ // Finite-difference step = one texel of the actual displacement-array dimensions.
198
+ const texel = vec2( 1.0 ).div( vec2( textureSize( displacementMaps ) ) ).toVar();
199
199
  const hC = finalHeight;
200
200
  const hU = sampleDisplacementMap(
201
- displacementMaps, material.displacementMapIndex, finalUV.add( vec2( h, 0.0 ) ), material.displacementTransform,
201
+ displacementMaps, material.displacementMapIndex, finalUV.add( vec2( texel.x, 0.0 ) ), material.displacementTransform,
202
202
  );
203
203
  const hV = sampleDisplacementMap(
204
- displacementMaps, material.displacementMapIndex, finalUV.add( vec2( 0.0, h ) ), material.displacementTransform,
204
+ displacementMaps, material.displacementMapIndex, finalUV.add( vec2( 0.0, texel.y ) ), material.displacementTransform,
205
205
  );
206
206
 
207
207
  // Perturb normal using actual tangent/bitangent from UV parameterization
@@ -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 };
@@ -1,4 +1,4 @@
1
- import { Fn, wgslFn, float, vec2, vec3, vec4, int, If, normalize, cross, abs, mix, clamp, texture } from 'three/tsl';
1
+ import { Fn, wgslFn, float, vec2, vec3, vec4, int, If, normalize, cross, abs, mix, clamp, texture, textureSize } from 'three/tsl';
2
2
 
3
3
  import {
4
4
  UVCache,
@@ -283,8 +283,8 @@ export const processBump = Fn( ( [ bumpMaps, currentNormal, material, uvCache ]
283
283
 
284
284
  If( material.bumpMapIndex.greaterThanEqual( int( 0 ) ).and( material.bumpScale.greaterThan( 0.0 ) ), () => {
285
285
 
286
- // Approximate texel size
287
- const texelSize = vec2( 1.0 / 1024.0 ).toVar();
286
+ // Texel size from the actual bump-array dimensions (finite-difference step).
287
+ const texelSize = vec2( 1.0 ).div( vec2( textureSize( bumpMaps ) ) ).toVar();
288
288
 
289
289
  const h_c = texture( bumpMaps, uvCache.bumpUV ).depth( int( material.bumpMapIndex ) ).r;
290
290
  const h_u = texture( bumpMaps, vec2( uvCache.bumpUV.x.add( texelSize.x ), uvCache.bumpUV.y ) ).depth( int( material.bumpMapIndex ) ).r;