rayzee 7.4.1 → 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.4.1",
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,
@@ -1446,12 +1446,18 @@ export class AssetLoader extends EventDispatcher {
1446
1446
 
1447
1447
  if ( userData.type === 'RectAreaLight' ) {
1448
1448
 
1449
+ // Intensity is authored in Watts (Blender-style); emitted radiance
1450
+ // is computed at render time as power/(π·area). Blender emission
1451
+ // defaults: power-normalized, full Lambertian spread (π), rectangle.
1449
1452
  const light = new RectAreaLight(
1450
1453
  new Color( ...userData.color ),
1451
- userData.intensity * 0.1 / Math.PI, // Adjust intensity for better visual results
1454
+ userData.intensity * 0.1,
1452
1455
  userData.width,
1453
1456
  userData.height
1454
1457
  );
1458
+ light.userData.normalize = userData.normalize ?? true;
1459
+ light.userData.spread = Number.isFinite( userData.spread ) ? userData.spread : Math.PI;
1460
+ light.userData.shape = userData.shape ?? 'rectangle';
1455
1461
  light.position.z = - 2;
1456
1462
  light.name = userData.name;
1457
1463
  object.add( light );
@@ -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
@@ -1,4 +1,31 @@
1
1
  import { Vector3, Quaternion } from 'three';
2
+ import { blackbodyToLinearRGB } from './blackbody.js';
3
+
4
+ // Point & spot lights specify Power in Watts; the shader uses radiant intensity
5
+ // (W/sr) as `intensity / dist²`, so convert with I = P / 4π (isotropic emitter),
6
+ // matching Blender. Area lights are already radiant power; the Sun is irradiance.
7
+ const INV_4PI = 1 / ( 4 * Math.PI );
8
+
9
+ // Effective emission colour + intensity after per-light Exposure (EV stops) and
10
+ // optional blackbody Temperature tint (Blender-style). Both fold into the values
11
+ // already sent to the GPU — no shader or struct change needed.
12
+ function effectiveEmission( light ) {
13
+
14
+ const ud = light.userData || {};
15
+ const exposure = Number.isFinite( ud.exposure ) ? ud.exposure : 0;
16
+ const intensity = light.intensity * Math.pow( 2, exposure );
17
+
18
+ let r = light.color.r, g = light.color.g, b = light.color.b;
19
+ if ( ud.useTemperature ) {
20
+
21
+ const [ tr, tg, tb ] = blackbodyToLinearRGB( ud.temperature ?? 6500 );
22
+ r *= tr; g *= tg; b *= tb;
23
+
24
+ }
25
+
26
+ return { r, g, b, intensity };
27
+
28
+ }
2
29
 
3
30
  export class LightSerializer {
4
31
 
@@ -90,6 +117,8 @@ export class LightSerializer {
90
117
  // Get angle parameter from light (default to 0 for sharp shadows)
91
118
  const angle = light.userData.angle || light.angle || 0.0; // In radians
92
119
 
120
+ const em = effectiveEmission( light );
121
+
93
122
  // Optional projection mask. Sign of intensity carries the inverted flag.
94
123
  const gobo = light.userData?.gobo;
95
124
  const goboIndex = ( gobo && Number.isInteger( gobo.index ) ) ? gobo.index : - 1;
@@ -101,8 +130,8 @@ export class LightSerializer {
101
130
  this.directionalLightCache.push( {
102
131
  data: [
103
132
  direction.x, direction.y, direction.z, // direction toward light (3)
104
- light.color.r, light.color.g, light.color.b, // color (3)
105
- light.intensity, // intensity (1)
133
+ em.r, em.g, em.b, // color (3) — incl. temperature tint
134
+ em.intensity, // intensity (1) — incl. exposure
106
135
  angle, // angular diameter in radians (1)
107
136
  goboIndex, // gobo layer index, -1 = none (1)
108
137
  goboIntensity, // signed gobo strength (1)
@@ -133,14 +162,25 @@ export class LightSerializer {
133
162
  // Calculate importance for sorting
134
163
  const importance = this.calculateLightImportance( light, 'area' );
135
164
 
136
- // Store in cache with importance
165
+ // Blender-style emission controls (stored on userData; sensible defaults).
166
+ // normalize ON → radiance ∝ 1/area (resizing keeps total power constant).
167
+ const normalize = ( light.userData?.normalize ?? true ) ? 1.0 : 0.0;
168
+ const spread = Number.isFinite( light.userData?.spread ) ? light.userData.spread : Math.PI;
169
+ const shape = ( light.userData?.shape === 'ellipse' || light.userData?.shape === 'disk' || light.userData?.shape === 1 ) ? 1.0 : 0.0;
170
+
171
+ const em = effectiveEmission( light );
172
+
173
+ // Store in cache with importance (16 floats, vec4-aligned)
137
174
  this.areaLightCache.push( {
138
175
  data: [
139
176
  position.x, position.y, position.z, // position (3)
140
- u.x, u.y, u.z, // u vector (3)
141
- v.x, v.y, v.z, // v vector (3)
142
- light.color.r, light.color.g, light.color.b, // color (3)
143
- light.intensity // intensity (1)
177
+ u.x, u.y, u.z, // u half-vector (3)
178
+ v.x, v.y, v.z, // v half-vector (3)
179
+ em.r, em.g, em.b, // color (3) — incl. temperature tint
180
+ em.intensity, // radiant power in Watts (1) — incl. exposure
181
+ normalize, // power-normalize flag (1)
182
+ spread, // emission spread in radians (1)
183
+ shape, // 0 = rect, 1 = disk/ellipse (1)
144
184
  ],
145
185
  importance: importance,
146
186
  light: light
@@ -159,12 +199,14 @@ export class LightSerializer {
159
199
  // Calculate importance for sorting
160
200
  const importance = this.calculateLightImportance( light, 'point' );
161
201
 
202
+ const em = effectiveEmission( light );
203
+
162
204
  // Store in cache with importance
163
205
  this.pointLightCache.push( {
164
206
  data: [
165
207
  position.x, position.y, position.z, // position (3)
166
- light.color.r, light.color.g, light.color.b, // color (3)
167
- light.intensity, // intensity (1)
208
+ em.r, em.g, em.b, // color (3) — incl. temperature tint
209
+ em.intensity * INV_4PI, // radiant intensity W/sr = power(W)/4π, incl. exposure (1)
168
210
  light.distance || 0.0, // cutoff distance (0 = infinite) (1)
169
211
  light.decay !== undefined ? light.decay : 2.0 // decay exponent (1)
170
212
  ],
@@ -198,13 +240,15 @@ export class LightSerializer {
198
240
  const iesIndex = ( ies && Number.isInteger( ies.index ) ) ? ies.index : - 1;
199
241
  const iesIntensity = ( ies && typeof ies.intensity === 'number' ) ? ies.intensity : 1.0;
200
242
 
243
+ const em = effectiveEmission( light );
244
+
201
245
  // Store in cache with importance
202
246
  this.spotLightCache.push( {
203
247
  data: [
204
248
  position.x, position.y, position.z, // position (3)
205
249
  direction.x, direction.y, direction.z, // direction (3)
206
- light.color.r, light.color.g, light.color.b, // color (3)
207
- light.intensity, // intensity (1)
250
+ em.r, em.g, em.b, // color (3) — incl. temperature tint
251
+ em.intensity * INV_4PI, // radiant intensity W/sr = power(W)/4π, incl. exposure (1)
208
252
  light.angle || Math.PI / 4, // cone half-angle in radians (1)
209
253
  light.penumbra || 0.0, // penumbra [0,1] (1)
210
254
  light.distance || 0.0, // cutoff distance (0 = infinite) (1)
@@ -290,7 +334,7 @@ export class LightSerializer {
290
334
 
291
335
  // Divide flat array lengths by per-light stride to get actual light counts
292
336
  const directionalCount = Math.floor( this.lightData.directional.length / 12 );
293
- const areaCount = Math.floor( this.lightData.rectArea.length / 13 );
337
+ const areaCount = Math.floor( this.lightData.rectArea.length / 16 );
294
338
  const pointCount = Math.floor( this.lightData.point.length / 9 );
295
339
  const spotCount = Math.floor( this.lightData.spot.length / 20 );
296
340
 
@@ -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
  }
@@ -0,0 +1,74 @@
1
+ // Blackbody temperature → linear Rec.709 (= linear sRGB) RGB.
2
+ //
3
+ // Ported verbatim from Blender/Cycles `svm_math_blackbody_color_rec709`
4
+ // (Lukas Stockner's 7-segment rational/cubic fit; coefficients from
5
+ // intern/cycles/kernel/tables.h). The fit is luminance-balanced — the Rec.709
6
+ // luminance of the returned colour is ≈1.0 across the whole range — so it can be
7
+ // multiplied straight into a light's colour without altering its power: the
8
+ // temperature shifts hue only, brightness stays governed by intensity. This is
9
+ // exactly how Cycles applies it (raw multiply into the light colour, no
10
+ // renormalisation).
11
+
12
+ const BB_R = [
13
+ [ 1.61919106e3, - 2.05010916e-3, 5.02995757e0 ],
14
+ [ 2.48845471e3, - 1.11330907e-3, 3.22621544e0 ],
15
+ [ 3.34143193e3, - 4.86551192e-4, 1.76486769e0 ],
16
+ [ 4.09461742e3, - 1.27446582e-4, 7.25731635e-1 ],
17
+ [ 4.67028036e3, 2.91258199e-5, 1.26703442e-1 ],
18
+ [ 4.59509185e3, 2.87495649e-5, 1.50345020e-1 ],
19
+ [ 3.78717450e3, 9.35907826e-6, 3.99075871e-1 ],
20
+ ];
21
+
22
+ const BB_G = [
23
+ [ - 4.88999748e2, 6.04330754e-4, - 7.55807526e-2 ],
24
+ [ - 7.55994277e2, 3.16730098e-4, 4.78306139e-1 ],
25
+ [ - 1.02363977e3, 1.20223470e-4, 9.36662319e-1 ],
26
+ [ - 1.26571316e3, 4.87340896e-6, 1.27054498e0 ],
27
+ [ - 1.42529332e3, - 4.01150431e-5, 1.43972784e0 ],
28
+ [ - 1.17554822e3, - 2.16378048e-5, 1.30408023e0 ],
29
+ [ - 5.00799571e2, - 4.59832026e-6, 1.09098763e0 ],
30
+ ];
31
+
32
+ const BB_B = [
33
+ [ 5.96945309e-11, - 4.85742887e-8, - 9.70622247e-5, - 4.07936148e-3 ],
34
+ [ 2.40430366e-11, 5.55021075e-8, - 1.98503712e-4, 2.89312858e-2 ],
35
+ [ - 1.40949732e-11, 1.89878968e-7, - 3.56632824e-4, 9.10767778e-2 ],
36
+ [ - 3.61460868e-11, 2.84822009e-7, - 4.93211319e-4, 1.56723440e-1 ],
37
+ [ - 1.97075738e-11, 1.75359352e-7, - 2.50542825e-4, - 2.22783266e-2 ],
38
+ [ - 1.61997957e-13, - 1.64216008e-8, 3.86216271e-4, - 7.38077418e-1 ],
39
+ [ 6.72650283e-13, - 2.73078809e-8, 4.24098264e-4, - 7.52335691e-1 ],
40
+ ];
41
+
42
+ /**
43
+ * Convert a colour temperature in Kelvin to linear Rec.709 / linear-sRGB RGB.
44
+ * Negative out-of-gamut channels (very low temperatures) are clamped to 0.
45
+ * @param {number} kelvin
46
+ * @returns {[number, number, number]}
47
+ */
48
+ export function blackbodyToLinearRGB( kelvin ) {
49
+
50
+ let r, g, b;
51
+
52
+ if ( kelvin >= 12000 ) {
53
+
54
+ r = 0.8262954810464208; g = 0.9945080501520986; b = 1.566307710274283;
55
+
56
+ } else if ( kelvin < 800 ) {
57
+
58
+ r = 5.413294490189271; g = - 0.20319390035873933; b = - 0.0822535242887164;
59
+
60
+ } else {
61
+
62
+ const i = kelvin >= 6365 ? 6 : kelvin >= 3315 ? 5 : kelvin >= 1902 ? 4
63
+ : kelvin >= 1449 ? 3 : kelvin >= 1167 ? 2 : kelvin >= 965 ? 1 : 0;
64
+ const cr = BB_R[ i ], cg = BB_G[ i ], cb = BB_B[ i ];
65
+ const tInv = 1 / kelvin;
66
+ r = cr[ 0 ] * tInv + cr[ 1 ] * kelvin + cr[ 2 ];
67
+ g = cg[ 0 ] * tInv + cg[ 1 ] * kelvin + cg[ 2 ];
68
+ b = ( ( cb[ 0 ] * kelvin + cb[ 1 ] ) * kelvin + cb[ 2 ] ) * kelvin + cb[ 3 ];
69
+
70
+ }
71
+
72
+ return [ Math.max( 0, r ), Math.max( 0, g ), Math.max( 0, b ) ];
73
+
74
+ }
@@ -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,
@@ -571,11 +571,11 @@ export class PathTracerStage extends RenderStage {
571
571
 
572
572
  }
573
573
 
574
- // Area lights (13 floats per light)
574
+ // Area lights (16 floats per light — 13 base + normalize/spread/shape)
575
575
  if ( this.areaLightsData && this.areaLightsData.length > 0 ) {
576
576
 
577
577
  this.areaLightsBufferNode.array = Array.from( this.areaLightsData );
578
- this.numAreaLights.value = Math.floor( this.areaLightsData.length / 13 );
578
+ this.numAreaLights.value = Math.floor( this.areaLightsData.length / 16 );
579
579
 
580
580
  } else {
581
581
 
@@ -42,12 +42,15 @@ export const DirectionalLight = struct( {
42
42
 
43
43
  export const AreaLight = struct( {
44
44
  position: 'vec3',
45
- u: 'vec3', // First axis of the rectangular light
46
- v: 'vec3', // Second axis of the rectangular light
45
+ u: 'vec3', // First axis half-vector of the rectangular light
46
+ v: 'vec3', // Second axis half-vector of the rectangular light
47
47
  color: 'vec3',
48
- intensity: 'float',
49
- normal: 'vec3',
50
- area: 'float',
48
+ intensity: 'float', // radiant power (Watts), Blender-style
49
+ normalize: 'float', // 1 = power normalized over area (radiance ∝ 1/area), 0 = constant radiance
50
+ spread: 'float', // emission cone half-fan in radians, π = full Lambertian hemisphere
51
+ shape: 'float', // 0 = rectangle, 1 = disk/ellipse
52
+ normal: 'vec3', // derived
53
+ area: 'float', // derived (true world area: rect = w·h, ellipse = π/4·w·h)
51
54
  } );
52
55
 
53
56
  export const PointLight = struct( {
@@ -125,7 +128,7 @@ export const getDirectionalLight = Fn( ( [ directionalLightsBuffer, index ] ) =>
125
128
 
126
129
  export const getAreaLight = Fn( ( [ areaLightsBuffer, index ] ) => {
127
130
 
128
- const baseIndex = index.mul( 13 );
131
+ const baseIndex = index.mul( 16 );
129
132
  const u = vec3(
130
133
  areaLightsBuffer.element( baseIndex.add( 3 ) ),
131
134
  areaLightsBuffer.element( baseIndex.add( 4 ) ),
@@ -138,6 +141,10 @@ export const getAreaLight = Fn( ( [ areaLightsBuffer, index ] ) => {
138
141
  ).toVar();
139
142
 
140
143
  const crossUV = cross( u, v );
144
+ const shape = areaLightsBuffer.element( baseIndex.add( 15 ) );
145
+
146
+ // u,v are half-vectors → full rectangle area = 4·|u×v|; disk/ellipse = π/4 of that.
147
+ const rectArea = length( crossUV ).mul( 4.0 );
141
148
 
142
149
  return AreaLight( {
143
150
  position: vec3(
@@ -153,8 +160,11 @@ export const getAreaLight = Fn( ( [ areaLightsBuffer, index ] ) => {
153
160
  areaLightsBuffer.element( baseIndex.add( 11 ) ),
154
161
  ),
155
162
  intensity: areaLightsBuffer.element( baseIndex.add( 12 ) ),
163
+ normalize: areaLightsBuffer.element( baseIndex.add( 13 ) ),
164
+ spread: areaLightsBuffer.element( baseIndex.add( 14 ) ),
165
+ shape: shape,
156
166
  normal: normalize( crossUV ),
157
- area: length( crossUV ).mul( 4.0 ),
167
+ area: select( shape.greaterThan( 0.5 ), rectArea.mul( 0.7853981633974483 ), rectArea ),
158
168
  } );
159
169
 
160
170
  } );
@@ -501,12 +511,18 @@ export const intersectAreaLight = Fn( ( [ light, rayOrigin, rayDirection ] ) =>
501
511
  const u_dir = light.u.div( uLen );
502
512
  const v_dir = light.v.div( vLen );
503
513
 
504
- // Project onto axes
505
- const u_proj = dot( localPoint, u_dir );
506
- const v_proj = dot( localPoint, v_dir );
514
+ // Project onto axes, normalized to [-1,1] across the half-extents
515
+ const u_proj = dot( localPoint, u_dir ).div( uLen );
516
+ const v_proj = dot( localPoint, v_dir ).div( vLen );
507
517
 
508
- // Check within rectangle bounds (half-lengths)
509
- If( abs( u_proj ).lessThanEqual( uLen ).and( abs( v_proj ).lessThanEqual( vLen ) ), () => {
518
+ // Rectangle: |u|≤1 |v|≤1. Disk/ellipse: u²+v²≤1.
519
+ const inside = select(
520
+ light.shape.greaterThan( 0.5 ),
521
+ u_proj.mul( u_proj ).add( v_proj.mul( v_proj ) ).lessThanEqual( 1.0 ),
522
+ abs( u_proj ).lessThanEqual( 1.0 ).and( abs( v_proj ).lessThanEqual( 1.0 ) ),
523
+ );
524
+
525
+ If( inside, () => {
510
526
 
511
527
  result.assign( t );
512
528
 
@@ -520,3 +536,114 @@ export const intersectAreaLight = Fn( ( [ light, rayOrigin, rayDirection ] ) =>
520
536
 
521
537
  } );
522
538
 
539
+ // ================================================================================
540
+ // SPHERICAL RECTANGLE SAMPLING (Ureña et al. 2013, "An Area-Preserving
541
+ // Parametrization for Spherical Rectangles"). Same scheme Cycles uses for rect
542
+ // area lights — samples the light's solid angle directly, giving pdf = 1/S in
543
+ // solid-angle measure (much lower variance than uniform-area sampling for
544
+ // large/near lights). Inputs:
545
+ // o = shading point (ray origin)
546
+ // s = rectangle corner = center - u - v
547
+ // ex = full edge vector along u (= 2·u)
548
+ // ey = full edge vector along v (= 2·v)
549
+ // ================================================================================
550
+
551
+ // Solid angle S subtended by the rectangle from o (used for the BSDF-hit MIS pdf).
552
+ export const sphQuadSolidAngle = /*@__PURE__*/ wgslFn( `
553
+ fn sphQuadSolidAngle( o: vec3f, s: vec3f, ex: vec3f, ey: vec3f ) -> f32 {
554
+ let PI = 3.141592653589793f;
555
+ let exl = length( ex );
556
+ let eyl = length( ey );
557
+ let x = ex / max( exl, 1e-12f );
558
+ let y = ey / max( eyl, 1e-12f );
559
+ var z = cross( x, y );
560
+ let d = s - o;
561
+ var z0 = dot( d, z );
562
+ if ( z0 > 0.0f ) { z = -z; z0 = -z0; }
563
+ let x0 = dot( d, x );
564
+ let y0 = dot( d, y );
565
+ let x1 = x0 + exl;
566
+ let y1 = y0 + eyl;
567
+ let v00 = vec3f( x0, y0, z0 );
568
+ let v01 = vec3f( x0, y1, z0 );
569
+ let v10 = vec3f( x1, y0, z0 );
570
+ let v11 = vec3f( x1, y1, z0 );
571
+ let n0 = normalize( cross( v00, v10 ) );
572
+ let n1 = normalize( cross( v10, v11 ) );
573
+ let n2 = normalize( cross( v11, v01 ) );
574
+ let n3 = normalize( cross( v01, v00 ) );
575
+ let g0 = acos( clamp( -dot( n0, n1 ), -1.0f, 1.0f ) );
576
+ let g1 = acos( clamp( -dot( n1, n2 ), -1.0f, 1.0f ) );
577
+ let g2 = acos( clamp( -dot( n2, n3 ), -1.0f, 1.0f ) );
578
+ let g3 = acos( clamp( -dot( n3, n0 ), -1.0f, 1.0f ) );
579
+ return g0 + g1 + g2 + g3 - 2.0f * PI;
580
+ }
581
+ ` );
582
+
583
+ // Sample a direction toward the rectangle uniformly in solid angle.
584
+ // Returns vec4( pointOnRect.xyz, pdf ); pdf = 1/S. pdf <= 0 signals the caller
585
+ // to fall back to uniform-area sampling (degenerate / tiny solid angle).
586
+ export const sampleSphQuad = /*@__PURE__*/ wgslFn( `
587
+ fn sampleSphQuad( o: vec3f, s: vec3f, ex: vec3f, ey: vec3f, uv: vec2f ) -> vec4f {
588
+ let PI = 3.141592653589793f;
589
+ let exl = length( ex );
590
+ let eyl = length( ey );
591
+ let x = ex / max( exl, 1e-12f );
592
+ let y = ey / max( eyl, 1e-12f );
593
+ var z = cross( x, y );
594
+ let d = s - o;
595
+ var z0 = dot( d, z );
596
+ if ( z0 > 0.0f ) { z = -z; z0 = -z0; }
597
+ let z0sq = z0 * z0;
598
+ let x0 = dot( d, x );
599
+ let y0 = dot( d, y );
600
+ let x1 = x0 + exl;
601
+ let y1 = y0 + eyl;
602
+ let y0sq = y0 * y0;
603
+ let y1sq = y1 * y1;
604
+ let v00 = vec3f( x0, y0, z0 );
605
+ let v01 = vec3f( x0, y1, z0 );
606
+ let v10 = vec3f( x1, y0, z0 );
607
+ let v11 = vec3f( x1, y1, z0 );
608
+ let n0 = normalize( cross( v00, v10 ) );
609
+ let n1 = normalize( cross( v10, v11 ) );
610
+ let n2 = normalize( cross( v11, v01 ) );
611
+ let n3 = normalize( cross( v01, v00 ) );
612
+ let g0 = acos( clamp( -dot( n0, n1 ), -1.0f, 1.0f ) );
613
+ let g1 = acos( clamp( -dot( n1, n2 ), -1.0f, 1.0f ) );
614
+ let g2 = acos( clamp( -dot( n2, n3 ), -1.0f, 1.0f ) );
615
+ let g3 = acos( clamp( -dot( n3, n0 ), -1.0f, 1.0f ) );
616
+ let b0 = n0.z;
617
+ let b1 = n2.z;
618
+ let b0sq = b0 * b0;
619
+ let k = 2.0f * PI - g2 - g3;
620
+ let S = g0 + g1 + g2 + g3 - 2.0f * PI;
621
+
622
+ if ( S <= 1e-5f ) {
623
+ return vec4f( 0.0f, 0.0f, 0.0f, -1.0f );
624
+ }
625
+
626
+ let au = uv.x * S + k;
627
+ let sinAu = sin( au );
628
+ let fu = ( cos( au ) * b0 - b1 ) / max( abs( sinAu ), 1e-7f ) * sign( sinAu );
629
+ var cu = select( -1.0f, 1.0f, fu >= 0.0f ) / sqrt( fu * fu + b0sq );
630
+ cu = clamp( cu, -1.0f, 1.0f );
631
+
632
+ var xu = -( cu * z0 ) / max( sqrt( 1.0f - cu * cu ), 1e-7f );
633
+ xu = clamp( xu, x0, x1 );
634
+
635
+ let dd = sqrt( xu * xu + z0sq );
636
+ let h0 = y0 / sqrt( dd * dd + y0sq );
637
+ let h1 = y1 / sqrt( dd * dd + y1sq );
638
+ let hv = h0 + uv.y * ( h1 - h0 );
639
+ let hv2 = hv * hv;
640
+ var yv = y1;
641
+ if ( hv2 < 1.0f - 1e-6f ) {
642
+ yv = ( hv * dd ) / sqrt( 1.0f - hv2 );
643
+ }
644
+
645
+ let p = o + xu * x + yv * y + z0 * z;
646
+ return vec4f( p, 1.0f / S );
647
+ }
648
+ ` );
649
+