rayzee 6.0.0 → 6.0.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": "6.0.0",
3
+ "version": "6.0.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",
@@ -1,6 +1,3 @@
1
- // BVH Traversal - Ported from bvhtraverse.fs
2
- // Stack-based BVH traversal for ray-triangle intersection
3
-
4
1
  import {
5
2
  Fn,
6
3
  wgslFn,
@@ -26,17 +23,9 @@ import {
26
23
  } from 'three/tsl';
27
24
 
28
25
  import { Ray, HitInfo } from './Struct.js';
29
- import { getDatafromStorageBuffer, MATERIAL_SLOTS, MATERIAL_SLOT } from './Common.js';
26
+ import { getDatafromStorageBuffer } from './Common.js';
30
27
  import { RandomPointInCircle } from './Random.js';
31
28
 
32
- // ================================================================================
33
- // STRUCTS
34
- // ================================================================================
35
-
36
- // ================================================================================
37
- // CONSTANTS
38
- // ================================================================================
39
-
40
29
  const MAX_STACK_DEPTH = 32;
41
30
  const MAX_BVH_ITERATIONS = 512;
42
31
  const BVH_STRIDE = 4;
@@ -1,11 +1,7 @@
1
- // Clearcoat BRDF - Ported from clearcoat.fs
2
- // Note: evaluateLayeredBRDF lives in MaterialEvaluation.js
3
-
4
1
  import {
5
2
  Fn,
6
3
  vec3,
7
4
  float,
8
- dot,
9
5
  normalize,
10
6
  reflect,
11
7
  max,
@@ -14,7 +10,7 @@ import {
14
10
 
15
11
  import { struct } from './patches.js';
16
12
 
17
- import { Ray, HitInfo, RayTracingMaterial, DotProducts } from './Struct.js';
13
+ import { DotProducts } from './Struct.js';
18
14
  import { PI, MIN_CLEARCOAT_ROUGHNESS, computeDotProducts } from './Common.js';
19
15
  import { DistributionGGX } from './MaterialProperties.js';
20
16
  import { ImportanceSampleGGX, ImportanceSampleCosine } from './MaterialSampling.js';
package/src/TSL/Common.js CHANGED
@@ -1,4 +1,4 @@
1
- import { Fn, wgslFn, float, vec2, vec3, vec4, int, mat3, If, max, min, dot, normalize, cross, abs, pow, clamp, step, mix, bool as tslBool } from 'three/tsl';
1
+ import { Fn, wgslFn, float, vec2, vec3, vec4, int, mat3, If, max, dot, clamp, bool as tslBool } from 'three/tsl';
2
2
 
3
3
  import {
4
4
  DotProducts,
@@ -1,15 +1,3 @@
1
- /**
2
- * Debugger.js - Debug Visualization Modes
3
- *
4
- * Exact port of debugger.fs
5
- * Pure TSL: Fn(), If(), .toVar(), .assign() — NO wgslFn()
6
- *
7
- * Contains:
8
- * - visualizeDepth — depth to grayscale gradient
9
- * - visualizeNormal — normal to RGB mapping
10
- * - TraceDebugMode — main debug mode dispatch (switch on visMode)
11
- */
12
-
13
1
  import {
14
2
  Fn,
15
3
  wgslFn,
@@ -346,8 +346,9 @@ export const calculateIndirectLighting = Fn( ( [
346
346
 
347
347
  // Strategy 3: Transmission
348
348
  const entering = dot( V, N ).greaterThan( 0.0 ).toVar();
349
+ // pathWavelength=0 — MIS evaluation reads only direction/PDF, no spectral tint
349
350
  const mtResult = MicrofacetTransmissionResult.wrap( sampleMicrofacetTransmission(
350
- V, N, material.ior, material.roughness, entering, material.dispersion, sampleRand, rngState
351
+ V, N, material.ior, material.roughness, entering, material.dispersion, sampleRand, rngState, float( 0.0 )
351
352
  ).toVar() );
352
353
  sampleDir.assign( mtResult.direction );
353
354
  samplePdf.assign( mtResult.pdf );
@@ -15,19 +15,14 @@ import {
15
15
  Loop,
16
16
  select,
17
17
  abs,
18
- acos,
19
- sin,
20
- cos,
21
18
  dot,
22
- normalize,
23
19
  reflect,
24
20
  refract,
25
21
  max,
26
22
  min,
27
23
  mix,
28
24
  clamp,
29
- pow,
30
- fract,
25
+ exp,
31
26
  } from 'three/tsl';
32
27
 
33
28
  import { struct } from './patches.js';
@@ -46,6 +41,7 @@ export const TransmissionResult = struct( {
46
41
  direction: 'vec3', // New ray direction after transmission/reflection
47
42
  throughput: 'vec3', // Color throughput including absorption
48
43
  didReflect: 'bool', // Whether the ray was reflected instead of transmitted
44
+ pathWavelength: 'float', // 0 if path is not yet spectral, else locked wavelength in nm
49
45
  } );
50
46
 
51
47
  export const MaterialInteractionResult = struct( {
@@ -57,6 +53,7 @@ export const MaterialInteractionResult = struct( {
57
53
  direction: 'vec3', // New ray direction if continuing
58
54
  throughput: 'vec3', // Color modification for the ray
59
55
  alpha: 'float', // Alpha modification
56
+ pathWavelength: 'float', // 0 if path is not yet spectral, else locked wavelength in nm
60
57
  } );
61
58
 
62
59
  export const Medium = struct( {
@@ -77,6 +74,8 @@ export const MicrofacetTransmissionResult = struct( {
77
74
  halfVector: 'vec3', // Sampled half-vector
78
75
  didReflect: 'bool', // Whether TIR occurred
79
76
  pdf: 'float', // PDF of the sampled direction
77
+ colorWeight: 'vec3', // Spectral tint to apply once; vec3(1) if locked or non-dispersive
78
+ pathWavelength: 'float', // 0 if path is not yet spectral, else locked wavelength in nm
80
79
  } );
81
80
 
82
81
  // Maximum number of nested media
@@ -107,75 +106,52 @@ export const MediumStack = struct( {
107
106
  // DISPERSION
108
107
  // ================================================================================
109
108
 
110
- // Enhanced spectral sampling for realistic dispersion
111
- export const sampleWavelengthForDispersion = Fn( ( [ baseIOR, dispersionStrength, random ] ) => {
112
-
113
- // Map random value to visible spectrum (380-700nm)
114
- const wl = mix( float( 380.0 ), float( 700.0 ), random ).toVar();
115
-
116
- // Convert to micrometers for Cauchy equation
117
- const wlMicron = wl.div( 1000.0 );
118
-
119
- // Strong IOR calculation for dramatic dispersion
120
- const A = baseIOR;
121
- const B = dispersionStrength.mul( 0.03 );
122
- const sampledIOR = A.add( B.div( wlMicron.mul( wlMicron ) ) ).toVar();
123
-
124
- // PURE SATURATED spectral colors
125
- const colorWeight = vec3( 0.0 ).toVar();
126
-
127
- // Deep Violet: 380-420
128
- If( wl.greaterThanEqual( 380.0 ).and( wl.lessThan( 420.0 ) ), () => {
129
-
130
- colorWeight.assign( vec3( 0.9, 0.0, 1.0 ) );
131
-
132
- } );
133
-
134
- // Blue: 420-480
135
- If( wl.greaterThanEqual( 420.0 ).and( wl.lessThan( 480.0 ) ), () => {
109
+ // Cauchy IOR n(λ) = baseIOR + 0.03·dispersion / λ_µm²
110
+ export const iorFromWavelength = /*@__PURE__*/ Fn( ( [ baseIOR, dispersionStrength, wavelength ] ) => {
136
111
 
137
- colorWeight.assign( vec3( 0.0, 0.0, 1.0 ) );
112
+ const wlMicron = wavelength.div( 1000.0 );
113
+ return baseIOR.add( dispersionStrength.mul( 0.03 ).div( wlMicron.mul( wlMicron ) ) );
138
114
 
139
- } );
140
-
141
- // Cyan: 480-500
142
- If( wl.greaterThanEqual( 480.0 ).and( wl.lessThan( 500.0 ) ), () => {
143
-
144
- colorWeight.assign( vec3( 0.0, 1.0, 1.0 ) );
145
-
146
- } );
147
-
148
- // Green: 500-530
149
- If( wl.greaterThanEqual( 500.0 ).and( wl.lessThan( 530.0 ) ), () => {
150
-
151
- colorWeight.assign( vec3( 0.0, 1.0, 0.0 ) );
152
-
153
- } );
115
+ } );
154
116
 
155
- // Yellow: 530-570
156
- If( wl.greaterThanEqual( 530.0 ).and( wl.lessThan( 570.0 ) ), () => {
117
+ // Wyman et al. JCGT 2013 piecewise-Gaussian fit to CIE 1931 2° observer
118
+ const cieGauss = /*@__PURE__*/ Fn( ( [ x, mu, sigmaLo, sigmaHi ] ) => {
157
119
 
158
- colorWeight.assign( vec3( 1.0, 1.0, 0.0 ) );
120
+ const sigma = select( x.lessThan( mu ), sigmaLo, sigmaHi );
121
+ const t = x.sub( mu ).mul( sigma );
122
+ return exp( float( - 0.5 ).mul( t ).mul( t ) );
159
123
 
160
- } );
124
+ } );
161
125
 
162
- // Orange: 570-620
163
- If( wl.greaterThanEqual( 570.0 ).and( wl.lessThan( 620.0 ) ), () => {
126
+ const wavelengthToXYZ = /*@__PURE__*/ Fn( ( [ wl ] ) => {
164
127
 
165
- colorWeight.assign( vec3( 1.0, 0.5, 0.0 ) );
128
+ const X = cieGauss( wl, 442.0, 0.0624, 0.0374 ).mul( 0.362 )
129
+ .add( cieGauss( wl, 599.8, 0.0264, 0.0323 ).mul( 1.056 ) )
130
+ .sub( cieGauss( wl, 501.1, 0.0490, 0.0382 ).mul( 0.065 ) );
131
+ const Y = cieGauss( wl, 568.8, 0.0213, 0.0247 ).mul( 0.821 )
132
+ .add( cieGauss( wl, 530.9, 0.0613, 0.0322 ).mul( 0.286 ) );
133
+ const Z = cieGauss( wl, 437.0, 0.0845, 0.0278 ).mul( 1.217 )
134
+ .add( cieGauss( wl, 459.0, 0.0385, 0.0725 ).mul( 0.681 ) );
135
+ return vec3( X, Y, Z );
166
136
 
167
- } );
137
+ } );
168
138
 
169
- // Red: 620-700
170
- If( wl.greaterThanEqual( 620.0 ).and( wl.lessThanEqual( 700.0 ) ), () => {
139
+ // Sample a wavelength on [380,700]nm and return its IOR + sRGB colorWeight (CIE 1931 →
140
+ // sRGB, gamut-clipped). The (1.819, 2.773, 2.928) factors normalize the clipped per-λ
141
+ // average to vec3(1), so clear glass converges to white as samples accumulate.
142
+ export const sampleWavelengthForDispersion = Fn( ( [ baseIOR, dispersionStrength, random ] ) => {
171
143
 
172
- colorWeight.assign( vec3( 1.0, 0.0, 0.0 ) );
144
+ const wl = mix( float( 380.0 ), float( 700.0 ), random ).toVar();
145
+ const sampledIOR = iorFromWavelength( baseIOR, dispersionStrength, wl ).toVar();
173
146
 
174
- } );
147
+ const xyz = wavelengthToXYZ( wl ).toVar();
148
+ const rgb = vec3(
149
+ xyz.x.mul( 3.2406 ).sub( xyz.y.mul( 1.5372 ) ).sub( xyz.z.mul( 0.4986 ) ),
150
+ xyz.x.mul( - 0.9689 ).add( xyz.y.mul( 1.8758 ) ).add( xyz.z.mul( 0.0415 ) ),
151
+ xyz.x.mul( 0.0557 ).sub( xyz.y.mul( 0.2040 ) ).add( xyz.z.mul( 1.0570 ) ),
152
+ ).toVar();
175
153
 
176
- // Maximum saturation
177
- colorWeight.assign( pow( colorWeight, vec3( 0.4 ) ) );
178
- colorWeight.assign( clamp( colorWeight, vec3( 0.0 ), vec3( 2.0 ) ) );
154
+ const colorWeight = max( rgb, vec3( 0.0 ) ).mul( vec3( 1.819, 2.773, 2.928 ) ).toVar();
179
155
 
180
156
  return SpectralSample( {
181
157
  wavelength: wl,
@@ -245,7 +221,7 @@ export const calculateShadowTransmittance = Fn( ( [ rayDir, normal, material, en
245
221
  // ================================================================================
246
222
 
247
223
  export const sampleMicrofacetTransmission = Fn( ( [
248
- V, N, ior, roughness, entering, dispersion, xi, rngState
224
+ V, N, ior, roughness, entering, dispersion, xi, rngState, pathWavelength
249
225
  ] ) => {
250
226
 
251
227
  const result = MicrofacetTransmissionResult( {
@@ -253,6 +229,8 @@ export const sampleMicrofacetTransmission = Fn( ( [
253
229
  halfVector: vec3( 0.0 ),
254
230
  didReflect: false,
255
231
  pdf: float( 0.0 ),
232
+ colorWeight: vec3( 1.0 ),
233
+ pathWavelength: pathWavelength,
256
234
  } ).toVar();
257
235
 
258
236
  // For smooth surfaces with dispersion, use perfect refraction with spectral IOR
@@ -264,9 +242,20 @@ export const sampleMicrofacetTransmission = Fn( ( [
264
242
  const eta = ior;
265
243
  const etaRatio = select( entering, float( 1.0 ).div( eta ), eta ).toVar();
266
244
 
267
- // Handle dispersion with spectral sampling
268
- const spectralSample = SpectralSample.wrap( sampleWavelengthForDispersion( ior, dispersion, RandomValue( rngState ) ) );
269
- etaRatio.assign( select( entering, float( 1.0 ).div( spectralSample.ior ), spectralSample.ior ) );
245
+ // Reuse the path's locked wavelength if any; else sample a new one and tint once.
246
+ If( pathWavelength.greaterThan( 0.0 ), () => {
247
+
248
+ const lockedIOR = iorFromWavelength( ior, dispersion, pathWavelength );
249
+ etaRatio.assign( select( entering, float( 1.0 ).div( lockedIOR ), lockedIOR ) );
250
+
251
+ } ).Else( () => {
252
+
253
+ const spectralSample = SpectralSample.wrap( sampleWavelengthForDispersion( ior, dispersion, RandomValue( rngState ) ) );
254
+ etaRatio.assign( select( entering, float( 1.0 ).div( spectralSample.ior ), spectralSample.ior ) );
255
+ result.colorWeight.assign( spectralSample.colorWeight );
256
+ result.pathWavelength.assign( spectralSample.wavelength );
257
+
258
+ } );
270
259
 
271
260
  // Perfect refraction using surface normal
272
261
  const refractDir = refract( V.negate(), N, etaRatio ).toVar();
@@ -297,11 +286,22 @@ export const sampleMicrofacetTransmission = Fn( ( [
297
286
  // Compute IOR ratio
298
287
  const etaRatio = select( entering, float( 1.0 ).div( ior ), ior ).toVar();
299
288
 
300
- // Handle dispersion with improved spectral sampling
289
+ // Reuse the path's locked wavelength if any; else sample a new one and tint once.
301
290
  If( dispersion.greaterThan( 0.0 ), () => {
302
291
 
303
- const spectralSample = SpectralSample.wrap( sampleWavelengthForDispersion( ior, dispersion, RandomValue( rngState ) ) );
304
- etaRatio.assign( select( entering, float( 1.0 ).div( spectralSample.ior ), spectralSample.ior ) );
292
+ If( pathWavelength.greaterThan( 0.0 ), () => {
293
+
294
+ const lockedIOR = iorFromWavelength( ior, dispersion, pathWavelength );
295
+ etaRatio.assign( select( entering, float( 1.0 ).div( lockedIOR ), lockedIOR ) );
296
+
297
+ } ).Else( () => {
298
+
299
+ const spectralSample = SpectralSample.wrap( sampleWavelengthForDispersion( ior, dispersion, RandomValue( rngState ) ) );
300
+ etaRatio.assign( select( entering, float( 1.0 ).div( spectralSample.ior ), spectralSample.ior ) );
301
+ result.colorWeight.assign( spectralSample.colorWeight );
302
+ result.pathWavelength.assign( spectralSample.wavelength );
303
+
304
+ } );
305
305
 
306
306
  } );
307
307
 
@@ -353,13 +353,14 @@ export const sampleMicrofacetTransmission = Fn( ( [
353
353
 
354
354
  export const handleTransmission = Fn( ( [
355
355
  rayDir, normal, material, entering, rngState,
356
- currentMediumIOR, previousMediumIOR,
356
+ currentMediumIOR, previousMediumIOR, pathWavelength,
357
357
  ] ) => {
358
358
 
359
359
  const result = TransmissionResult( {
360
360
  direction: vec3( 0.0 ),
361
361
  throughput: vec3( 1.0 ),
362
362
  didReflect: false,
363
+ pathWavelength: pathWavelength,
363
364
  } ).toVar();
364
365
 
365
366
  // Setup surface normal based on ray direction
@@ -416,10 +417,10 @@ export const handleTransmission = Fn( ( [
416
417
 
417
418
  If( doReflect, () => {
418
419
 
419
- // Reflection path
420
+ // Reflection at a transmissive surface — no wavelength locking
420
421
  If( material.roughness.greaterThan( 0.05 ), () => {
421
422
 
422
- const mtResult = MicrofacetTransmissionResult.wrap( sampleMicrofacetTransmission( V, N, material.ior, material.roughness, entering, float( 0.0 ), xi, rngState ) );
423
+ const mtResult = MicrofacetTransmissionResult.wrap( sampleMicrofacetTransmission( V, N, material.ior, material.roughness, entering, float( 0.0 ), xi, rngState, float( 0.0 ) ) );
423
424
  result.direction.assign( mtResult.direction );
424
425
 
425
426
  } ).Else( () => {
@@ -436,125 +437,20 @@ export const handleTransmission = Fn( ( [
436
437
  // Transmission/refraction path
437
438
  If( material.roughness.greaterThan( 0.05 ).or( material.dispersion.greaterThan( 0.0 ) ), () => {
438
439
 
439
- const mtResult = MicrofacetTransmissionResult.wrap( sampleMicrofacetTransmission( V, N, material.ior, material.roughness, entering, material.dispersion, xi, rngState ) );
440
+ const mtResult = MicrofacetTransmissionResult.wrap( sampleMicrofacetTransmission( V, N, material.ior, material.roughness, entering, material.dispersion, xi, rngState, pathWavelength ) );
441
+ result.pathWavelength.assign( mtResult.pathWavelength );
440
442
 
441
- // If TIR occurred during sampling, respect it
442
443
  If( mtResult.didReflect, () => {
443
444
 
445
+ // TIR during intended transmission: compensate for selection probability
444
446
  result.direction.assign( mtResult.direction );
445
447
  result.didReflect.assign( true );
446
- // TIR during intended transmission: compensate for selection probability
447
448
  result.throughput.assign( material.color.xyz.div( max( float( 1.0 ).sub( reflectProb ), 0.05 ) ) );
448
449
 
449
450
  } ).Else( () => {
450
451
 
451
452
  result.direction.assign( mtResult.direction );
452
-
453
- // Handle dispersion coloring
454
- If( material.dispersion.greaterThan( 0.0 ), () => {
455
-
456
- // Calculate refracted ray deviation from original direction
457
- const originalDir = normalize( rayDir );
458
- const refractedDir = normalize( result.direction );
459
-
460
- // Calculate angle-dependent dispersion factor
461
- const edgeFactor = float( 1.0 ).sub( abs( dot( N, originalDir ) ) );
462
- const deviationAngle = acos( clamp( dot( originalDir, refractedDir ), - 1.0, 1.0 ) );
463
-
464
- // Create spatial variation using ray direction and normal
465
- const combinedVec = normalize( originalDir.add( N ) );
466
- const spatialVariation = sin( combinedVec.x.mul( 15.0 ) ).mul( cos( combinedVec.y.mul( 12.0 ) ) ).mul( sin( combinedVec.z.mul( 18.0 ) ) );
467
-
468
- // Add additional variation using refracted direction
469
- const refractVariation = sin( refractedDir.x.mul( 8.0 ).add( refractedDir.y.mul( 6.0 ) ).add( refractedDir.z.mul( 10.0 ) ) );
470
-
471
- // Combine multiple factors for better color distribution
472
- const baseColorIndex = deviationAngle.mul( material.dispersion ).mul( 3.0 );
473
- const spatialBoost = spatialVariation.mul( 0.3 );
474
- const refractBoost = refractVariation.mul( 0.2 );
475
- const edgeBoost = edgeFactor.mul( 0.4 );
476
-
477
- // Create continuous color mapping across the prism
478
- const colorIndex = fract( baseColorIndex.add( spatialBoost ).add( refractBoost ).add( edgeBoost ) ).toVar();
479
-
480
- // ROYGBIV spectrum mapping with smooth transitions
481
- const rainbowColor = vec3( 0.0 ).toVar();
482
-
483
- // Red zone
484
- If( colorIndex.lessThan( 0.143 ), () => {
485
-
486
- const t = colorIndex.div( 0.143 );
487
- rainbowColor.assign( mix( vec3( 0.8, 0.0, 0.0 ), vec3( 1.0, 0.0, 0.0 ), t ) );
488
-
489
- } );
490
-
491
- // Red to Orange
492
- If( colorIndex.greaterThanEqual( 0.143 ).and( colorIndex.lessThan( 0.286 ) ), () => {
493
-
494
- const t = colorIndex.sub( 0.143 ).div( 0.143 );
495
- rainbowColor.assign( mix( vec3( 1.0, 0.0, 0.0 ), vec3( 1.0, 0.6, 0.0 ), t ) );
496
-
497
- } );
498
-
499
- // Orange to Yellow
500
- If( colorIndex.greaterThanEqual( 0.286 ).and( colorIndex.lessThan( 0.429 ) ), () => {
501
-
502
- const t = colorIndex.sub( 0.286 ).div( 0.143 );
503
- rainbowColor.assign( mix( vec3( 1.0, 0.6, 0.0 ), vec3( 1.0, 1.0, 0.0 ), t ) );
504
-
505
- } );
506
-
507
- // Yellow to Green
508
- If( colorIndex.greaterThanEqual( 0.429 ).and( colorIndex.lessThan( 0.571 ) ), () => {
509
-
510
- const t = colorIndex.sub( 0.429 ).div( 0.142 );
511
- rainbowColor.assign( mix( vec3( 1.0, 1.0, 0.0 ), vec3( 0.0, 1.0, 0.0 ), t ) );
512
-
513
- } );
514
-
515
- // Green to Blue
516
- If( colorIndex.greaterThanEqual( 0.571 ).and( colorIndex.lessThan( 0.714 ) ), () => {
517
-
518
- const t = colorIndex.sub( 0.571 ).div( 0.143 );
519
- rainbowColor.assign( mix( vec3( 0.0, 1.0, 0.0 ), vec3( 0.0, 0.4, 1.0 ), t ) );
520
-
521
- } );
522
-
523
- // Blue to Indigo
524
- If( colorIndex.greaterThanEqual( 0.714 ).and( colorIndex.lessThan( 0.857 ) ), () => {
525
-
526
- const t = colorIndex.sub( 0.714 ).div( 0.143 );
527
- rainbowColor.assign( mix( vec3( 0.0, 0.4, 1.0 ), vec3( 0.3, 0.0, 0.8 ), t ) );
528
-
529
- } );
530
-
531
- // Indigo to Violet
532
- If( colorIndex.greaterThanEqual( 0.857 ), () => {
533
-
534
- const t = colorIndex.sub( 0.857 ).div( 0.143 );
535
- rainbowColor.assign( mix( vec3( 0.3, 0.0, 0.8 ), vec3( 0.6, 0.0, 1.0 ), t ) );
536
-
537
- } );
538
-
539
- // Calculate dispersion strength with proper variation
540
- const normalizedDispersion = clamp( material.dispersion.div( 5.0 ), 0.0, 1.0 );
541
- const angleBoost = float( 1.0 ).add( edgeFactor.mul( 1.5 ) );
542
-
543
- // Make dispersion visibility more gradual
544
- const baseVisibility = normalizedDispersion.mul( angleBoost );
545
- const combinedVariation = spatialVariation.add( refractVariation );
546
- const spatialMod = float( 0.5 ).add( float( 0.5 ).mul( sin( combinedVariation.mul( 3.14159 ) ) ) );
547
- const dispersionVisibility = clamp( baseVisibility.mul( spatialMod ), 0.0, 0.8 );
548
-
549
- // Mix rainbow color with clear base for realistic prism effect
550
- result.throughput.assign( mix( vec3( 1.0 ), rainbowColor, dispersionVisibility ) );
551
-
552
- } ).Else( () => {
553
-
554
- // No dispersion - pure white transmission
555
- result.throughput.assign( vec3( 1.0 ) );
556
-
557
- } );
453
+ result.throughput.assign( mtResult.colorWeight );
558
454
 
559
455
  } );
560
456
 
@@ -600,10 +496,9 @@ export const handleTransmission = Fn( ( [
600
496
 
601
497
  export const handleMaterialTransparency = Fn( ( [
602
498
  ray, hitPoint, normal, material, rngState,
603
- // RenderState fields passed individually (since inout not supported)
604
499
  transmissiveTraversals,
605
- // Precomputed medium IOR values from stack
606
500
  currentMediumIOR, previousMediumIOR,
501
+ pathWavelength,
607
502
  ] ) => {
608
503
 
609
504
  const result = MaterialInteractionResult( {
@@ -615,6 +510,7 @@ export const handleMaterialTransparency = Fn( ( [
615
510
  direction: ray.direction,
616
511
  throughput: vec3( 1.0 ),
617
512
  alpha: float( 1.0 ),
513
+ pathWavelength: pathWavelength,
618
514
  } ).toVar();
619
515
 
620
516
  // -----------------------------------------------------------------
@@ -693,7 +589,7 @@ export const handleMaterialTransparency = Fn( ( [
693
589
 
694
590
  const transResult = TransmissionResult.wrap( handleTransmission(
695
591
  ray.direction, normal, material, entering, transmissionSeed,
696
- currentMediumIOR, previousMediumIOR,
592
+ currentMediumIOR, previousMediumIOR, pathWavelength,
697
593
  ) );
698
594
 
699
595
  result.direction.assign( transResult.direction );
@@ -703,6 +599,7 @@ export const handleMaterialTransparency = Fn( ( [
703
599
  result.didReflect.assign( transResult.didReflect );
704
600
  result.entering.assign( entering );
705
601
  result.alpha.assign( float( 1.0 ).sub( material.transmission ) );
602
+ result.pathWavelength.assign( transResult.pathWavelength );
706
603
 
707
604
  } );
708
605
 
@@ -291,8 +291,9 @@ export const generateSampledDirection = Fn( ( [
291
291
  If( sampled.not(), () => {
292
292
 
293
293
  const entering = dot( V, N ).greaterThan( 0.0 ).toVar();
294
+ // pathWavelength=0 — only direction/PDF are consumed here, throughput goes via handleTransmission
294
295
  const mtResult = MicrofacetTransmissionResult.wrap( sampleMicrofacetTransmission(
295
- V, N, material.ior, material.roughness, entering, material.dispersion, xi, rngState,
296
+ V, N, material.ior, material.roughness, entering, material.dispersion, xi, rngState, float( 0.0 ),
296
297
  ) );
297
298
  resultDirection.assign( mtResult.direction );
298
299
  resultPdf.assign( max( mtResult.pdf, MIN_PDF ) );
@@ -619,6 +620,10 @@ export const Trace = Fn( ( [
619
620
  const mediumStack_ior_2 = float( 1.0 ).toVar();
620
621
  const mediumStack_ior_3 = float( 1.0 ).toVar();
621
622
 
623
+ // Locked at the first dispersive transmission; reused for subsequent transmissions on
624
+ // the path so multi-bounce dispersion doesn't collapse under repeated colorWeight ×.
625
+ const pathWavelength = float( 0.0 ).toVar();
626
+
622
627
  // Render state
623
628
  const stateTraversals = maxBounceCount.toVar();
624
629
  const stateTransmissiveTraversals = transmissiveBounces.toVar();
@@ -802,7 +807,9 @@ export const Trace = Fn( ( [
802
807
  currentRay, hitInfo.hitPoint, N, material, rngState,
803
808
  stateTransmissiveTraversals,
804
809
  currentMediumIOR, previousMediumIOR,
810
+ pathWavelength,
805
811
  ) ).toVar();
812
+ pathWavelength.assign( interaction.pathWavelength );
806
813
 
807
814
  If( interaction.continueRay, () => {
808
815