rayzee 6.3.0 → 6.5.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.
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Subsurface.js - Random-walk subsurface scattering.
3
+ *
4
+ * Reuses the refraction interface + medium stack (PathTracerCore.js). The new physics
5
+ * is inside the medium: a ray collides mid-flight (sigma_s > 0) and scatters via a
6
+ * Henyey-Greenstein phase function instead of flying straight + absorbing (glass).
7
+ */
8
+
9
+ import {
10
+ Fn,
11
+ float,
12
+ vec2,
13
+ vec3,
14
+ If,
15
+ select,
16
+ abs,
17
+ dot,
18
+ clamp,
19
+ max,
20
+ exp,
21
+ log,
22
+ sqrt,
23
+ cos,
24
+ sin,
25
+ normalize,
26
+ reflect,
27
+ refract,
28
+ } from 'three/tsl';
29
+
30
+ import { struct } from './patches.js';
31
+ import { TWO_PI, EPSILON, constructTBN } from './Common.js';
32
+ import { RandomValue } from './Random.js';
33
+ import { iorToFresnel0, fresnelSchlickFloat } from './Fresnel.js';
34
+ import { ImportanceSampleGGX } from './MaterialSampling.js';
35
+
36
+ // ================================================================================
37
+ // STRUCTS
38
+ // ================================================================================
39
+
40
+ export const CollisionSample = struct( {
41
+ didScatter: 'bool',
42
+ t: 'float', // collision distance (clamped to surfaceDist when no scatter)
43
+ weight: 'vec3', // throughput multiplier for the segment (chromatic-MIS)
44
+ } );
45
+
46
+ export const MediumCoeffs = struct( {
47
+ sigmaT: 'vec3',
48
+ sigmaS: 'vec3',
49
+ sigmaA: 'vec3',
50
+ } );
51
+
52
+ export const SubsurfaceEntryResult = struct( {
53
+ direction: 'vec3',
54
+ throughput: 'vec3',
55
+ didReflect: 'bool',
56
+ } );
57
+
58
+ // ================================================================================
59
+ // HENYEY-GREENSTEIN PHASE SAMPLING
60
+ // ================================================================================
61
+
62
+ // Returns a scattered direction (unit). cosTheta is relative to the propagation dir
63
+ // `wi`, so g > 0 is forward scattering. Inverse-CDF sampling is exact, so the brute-force
64
+ // walk needs no extra weight at the vertex (hence no pdf is returned).
65
+ export const sampleHenyeyGreenstein = Fn( ( [ wi, g, xi ] ) => {
66
+
67
+ const cosTheta = float( 0.0 ).toVar();
68
+
69
+ If( abs( g ).lessThan( 0.001 ), () => {
70
+
71
+ cosTheta.assign( float( 1.0 ).sub( xi.x.mul( 2.0 ) ) ); // isotropic; avoids 1/(2g)
72
+
73
+ } ).Else( () => {
74
+
75
+ const denom = max( float( 1.0 ).sub( g ).add( g.mul( 2.0 ).mul( xi.x ) ), 1e-4 );
76
+ const sqrTerm = float( 1.0 ).sub( g.mul( g ) ).div( denom );
77
+ cosTheta.assign( float( 1.0 ).add( g.mul( g ) ).sub( sqrTerm.mul( sqrTerm ) ).div( g.mul( 2.0 ) ) );
78
+
79
+ } );
80
+
81
+ cosTheta.assign( clamp( cosTheta, - 1.0, 1.0 ) );
82
+ const sinTheta = sqrt( max( float( 0.0 ), float( 1.0 ).sub( cosTheta.mul( cosTheta ) ) ) );
83
+ const phi = float( TWO_PI ).mul( xi.y );
84
+
85
+ // Basis with wi as 3rd column → result is already unit length.
86
+ const TBN = constructTBN( { N: wi } );
87
+ return TBN.mul( vec3( sinTheta.mul( cos( phi ) ), sinTheta.mul( sin( phi ) ), cosTheta ) );
88
+
89
+ } );
90
+
91
+ // ================================================================================
92
+ // CHROMATIC COLLISION-DISTANCE SAMPLING (hero-channel spectral MIS)
93
+ // ================================================================================
94
+
95
+ // Per-channel sigma_t can't be represented by one scalar distance. Pick a channel
96
+ // ∝ throughput, sample t against it, and weight by the balance-heuristic combined pdf
97
+ // p̄ = Σ pmf_c·p_c — the shared scalar p̄ is what suppresses color fireflies.
98
+ export const sampleChromaticCollision = Fn( ( [ sigmaT, sigmaS, beta, surfaceDist, rngState ] ) => {
99
+
100
+ const w = max( beta, vec3( 1e-4 ) ); // floor so no channel goes unsampled
101
+ const pmf = w.div( w.x.add( w.y ).add( w.z ) ).toVar();
102
+
103
+ // .toVar() pins the single RNG draw (else it re-executes per comparison → state drift).
104
+ const u = RandomValue( rngState ).toVar();
105
+ const cSigmaT = float( 0.0 ).toVar();
106
+ If( u.lessThan( pmf.x ), () => {
107
+
108
+ cSigmaT.assign( sigmaT.x );
109
+
110
+ } ).ElseIf( u.lessThan( pmf.x.add( pmf.y ) ), () => {
111
+
112
+ cSigmaT.assign( sigmaT.y );
113
+
114
+ } ).Else( () => {
115
+
116
+ cSigmaT.assign( sigmaT.z );
117
+
118
+ } );
119
+
120
+ const xi = RandomValue( rngState ).toVar();
121
+ const t = log( max( float( 1.0 ).sub( xi ), 1e-6 ) ).negate().div( max( cSigmaT, 1e-6 ) ).toVar();
122
+
123
+ const didScatter = t.lessThan( surfaceDist ).toVar();
124
+ const tOut = t.toVar();
125
+ const weight = vec3( 0.0 ).toVar();
126
+
127
+ If( didScatter, () => {
128
+
129
+ const Tr = exp( sigmaT.mul( t ).negate() ).toVar();
130
+ const pBar = dot( pmf, sigmaT.mul( Tr ) );
131
+ weight.assign( sigmaS.mul( Tr ).div( max( pBar, 1e-6 ) ) );
132
+
133
+ } ).Else( () => {
134
+
135
+ const Tr = exp( sigmaT.mul( surfaceDist ).negate() ).toVar();
136
+ const pBar = dot( pmf, Tr );
137
+ weight.assign( Tr.div( max( pBar, 1e-6 ) ) );
138
+ tOut.assign( surfaceDist );
139
+
140
+ } );
141
+
142
+ return CollisionSample( { didScatter, t: tOut, weight } );
143
+
144
+ } );
145
+
146
+ // ================================================================================
147
+ // PARAMETER → COEFFICIENT MAPPING (Cycles-style)
148
+ // ================================================================================
149
+
150
+ // sigma_t = 1/(radius·scale), sigma_s = albedo·sigma_t, sigma_a = sigma_t - sigma_s.
151
+ // subsurfaceColor is the single-scatter albedo, so the per-event weight carries the tint.
152
+ export const subsurfaceCoefficients = Fn( ( [ subsurfaceColor, subsurfaceRadius, radiusScale ] ) => {
153
+
154
+ const r = max( subsurfaceRadius.mul( radiusScale ), vec3( 1e-4 ) );
155
+ const sigmaT = vec3( 1.0 ).div( r );
156
+ const sigmaS = subsurfaceColor.mul( sigmaT );
157
+ const sigmaA = max( sigmaT.sub( sigmaS ), vec3( 0.0 ) );
158
+
159
+ return MediumCoeffs( { sigmaT, sigmaS, sigmaA } );
160
+
161
+ } );
162
+
163
+ // ================================================================================
164
+ // DIELECTRIC BOUNDARY (enter / exit the SSS medium)
165
+ // ================================================================================
166
+
167
+ // Dielectric interface driven by material.ior: reflect (Fresnel/TIR) or refract across
168
+ // the boundary. No color tint — the scattering color lives in sigma_s.
169
+ export const handleSubsurfaceEntry = Fn( ( [
170
+ rayDir, normal, material, entering, rngState, currentMediumIOR, previousMediumIOR,
171
+ ] ) => {
172
+
173
+ const result = SubsurfaceEntryResult( {
174
+ direction: vec3( 0.0 ),
175
+ throughput: vec3( 1.0 ),
176
+ didReflect: false,
177
+ } ).toVar();
178
+
179
+ const N = select( entering, normal, normal.negate() ).toVar();
180
+ const n1 = select( entering, currentMediumIOR, material.ior ).toVar();
181
+ const n2 = select( entering, material.ior, previousMediumIOR ).toVar();
182
+
183
+ const cosThetaI = abs( dot( N, rayDir ) );
184
+ const sinThetaT2 = n1.mul( n1 ).div( max( n2.mul( n2 ), EPSILON ) ).mul( float( 1.0 ).sub( cosThetaI.mul( cosThetaI ) ) );
185
+ const tir = sinThetaT2.greaterThan( 1.0 ).toVar();
186
+
187
+ const F0 = iorToFresnel0( n2, n1 );
188
+ const Fr = select( tir, float( 1.0 ), fresnelSchlickFloat( cosThetaI, F0 ) ).toVar();
189
+ const reflectProb = clamp( Fr, 0.02, 0.98 ).toVar();
190
+
191
+ const doReflect = tir.or( RandomValue( rngState ).lessThan( reflectProb ) ).toVar();
192
+ result.didReflect.assign( doReflect );
193
+
194
+ If( doReflect, () => {
195
+
196
+ // GGX-sampled reflection: a perfect mirror here makes SSS surfaces read as polished ceramic.
197
+ const xiR = vec2( RandomValue( rngState ), RandomValue( rngState ) );
198
+ const H = ImportanceSampleGGX( { N, roughness: material.roughness, Xi: xiR } );
199
+ const reflDir = reflect( rayDir, H ).toVar();
200
+ If( dot( reflDir, N ).lessThanEqual( 0.0 ), () => {
201
+
202
+ reflDir.assign( reflect( rayDir, N ) ); // rough sample dipped below surface
203
+
204
+ } );
205
+ result.direction.assign( reflDir );
206
+ result.throughput.assign( vec3( Fr.div( max( reflectProb, 0.02 ) ) ) );
207
+
208
+ } ).Else( () => {
209
+
210
+ const refrDir = refract( rayDir, N, n1.div( max( n2, EPSILON ) ) ).toVar();
211
+
212
+ If( dot( refrDir, refrDir ).lessThan( 0.0001 ), () => {
213
+
214
+ result.direction.assign( reflect( rayDir, N ) );
215
+ result.didReflect.assign( true );
216
+
217
+ } ).Else( () => {
218
+
219
+ result.direction.assign( normalize( refrDir ) );
220
+ // (1-Fr) transmission + (n1/n2)² radiance scale (cancels round-trip).
221
+ const radianceScale = n1.mul( n1 ).div( max( n2.mul( n2 ), EPSILON ) );
222
+ result.throughput.assign( vec3(
223
+ float( 1.0 ).sub( Fr ).div( max( float( 1.0 ).sub( reflectProb ), 0.02 ) ).mul( radianceScale )
224
+ ) );
225
+
226
+ } );
227
+
228
+ } );
229
+
230
+ return result;
231
+
232
+ } );
@@ -307,6 +307,41 @@ export class MaterialDataManager {
307
307
  break;
308
308
  case 'bumpScale': data[ stride + M.BUMP_SCALE ] = value; break;
309
309
  case 'displacementScale': data[ stride + M.DISPLACEMENT_SCALE ] = value; break;
310
+ case 'subsurface': data[ stride + M.SUBSURFACE ] = value; break;
311
+ case 'subsurfaceRadiusScale': data[ stride + M.SUBSURFACE_RADIUS_SCALE ] = value; break;
312
+ case 'subsurfaceAnisotropy': data[ stride + M.SUBSURFACE_ANISOTROPY ] = value; break;
313
+ case 'subsurfaceColor':
314
+ if ( value.r !== undefined ) {
315
+
316
+ data[ stride + M.SUBSURFACE_COLOR ] = value.r;
317
+ data[ stride + M.SUBSURFACE_COLOR + 1 ] = value.g;
318
+ data[ stride + M.SUBSURFACE_COLOR + 2 ] = value.b;
319
+
320
+ } else if ( Array.isArray( value ) ) {
321
+
322
+ data[ stride + M.SUBSURFACE_COLOR ] = value[ 0 ];
323
+ data[ stride + M.SUBSURFACE_COLOR + 1 ] = value[ 1 ];
324
+ data[ stride + M.SUBSURFACE_COLOR + 2 ] = value[ 2 ];
325
+
326
+ }
327
+
328
+ break;
329
+ case 'subsurfaceRadius':
330
+ if ( Array.isArray( value ) ) {
331
+
332
+ data[ stride + M.SUBSURFACE_RADIUS ] = value[ 0 ];
333
+ data[ stride + M.SUBSURFACE_RADIUS + 1 ] = value[ 1 ];
334
+ data[ stride + M.SUBSURFACE_RADIUS + 2 ] = value[ 2 ];
335
+
336
+ } else if ( value.x !== undefined ) {
337
+
338
+ data[ stride + M.SUBSURFACE_RADIUS ] = value.x;
339
+ data[ stride + M.SUBSURFACE_RADIUS + 1 ] = value.y;
340
+ data[ stride + M.SUBSURFACE_RADIUS + 2 ] = value.z;
341
+
342
+ }
343
+
344
+ break;
310
345
  default:
311
346
  console.warn( `Unknown material property: ${property}` );
312
347
  return;
@@ -322,7 +357,7 @@ export class MaterialDataManager {
322
357
 
323
358
  }
324
359
 
325
- const featureProperties = [ 'transmission', 'clearcoat', 'sheen', 'iridescence', 'dispersion', 'transparent', 'opacity', 'alphaTest' ];
360
+ const featureProperties = [ 'transmission', 'clearcoat', 'sheen', 'iridescence', 'dispersion', 'transparent', 'opacity', 'alphaTest', 'subsurface' ];
326
361
  if ( featureProperties.includes( property ) ) {
327
362
 
328
363
  const featuresChanged = this.rescanMaterialFeatures();
@@ -446,6 +481,27 @@ export class MaterialDataManager {
446
481
  data[ stride + M.DISPLACEMENT_SCALE ] = materialData.displacementScale ?? 1;
447
482
  data[ stride + M.DISPLACEMENT_MAP_INDEX ] = materialData.displacementMap ?? - 1;
448
483
 
484
+ // Subsurface scattering
485
+ data[ stride + M.SUBSURFACE ] = materialData.subsurface ?? 0;
486
+ if ( materialData.subsurfaceColor ) {
487
+
488
+ data[ stride + M.SUBSURFACE_COLOR ] = materialData.subsurfaceColor.r ?? materialData.subsurfaceColor[ 0 ] ?? 1;
489
+ data[ stride + M.SUBSURFACE_COLOR + 1 ] = materialData.subsurfaceColor.g ?? materialData.subsurfaceColor[ 1 ] ?? 1;
490
+ data[ stride + M.SUBSURFACE_COLOR + 2 ] = materialData.subsurfaceColor.b ?? materialData.subsurfaceColor[ 2 ] ?? 1;
491
+
492
+ }
493
+
494
+ if ( materialData.subsurfaceRadius ) {
495
+
496
+ data[ stride + M.SUBSURFACE_RADIUS ] = materialData.subsurfaceRadius[ 0 ] ?? 1;
497
+ data[ stride + M.SUBSURFACE_RADIUS + 1 ] = materialData.subsurfaceRadius[ 1 ] ?? 0.2;
498
+ data[ stride + M.SUBSURFACE_RADIUS + 2 ] = materialData.subsurfaceRadius[ 2 ] ?? 0.1;
499
+
500
+ }
501
+
502
+ data[ stride + M.SUBSURFACE_RADIUS_SCALE ] = materialData.subsurfaceRadiusScale ?? 1;
503
+ data[ stride + M.SUBSURFACE_ANISOTROPY ] = materialData.subsurfaceAnisotropy ?? 0;
504
+
449
505
  // Texture transformation matrices (9 floats each, identity if missing)
450
506
  const identity = [ 1, 0, 0, 0, 1, 0, 0, 0, 1 ];
451
507
  const transformEntries = [
@@ -574,6 +630,7 @@ export class MaterialDataManager {
574
630
  hasIridescence: false,
575
631
  hasSheen: false,
576
632
  hasTransparency: false,
633
+ hasSubsurface: false,
577
634
  hasMultiLobeMaterials: false,
578
635
  hasMRTOutputs: true
579
636
  };
@@ -590,6 +647,7 @@ export class MaterialDataManager {
590
647
  const opacity = data[ stride + M.OPACITY ];
591
648
  const transparent = data[ stride + M.TRANSPARENT ];
592
649
  const alphaTest = data[ stride + M.ALPHA_TEST ];
650
+ const subsurface = data[ stride + M.SUBSURFACE ];
593
651
 
594
652
  if ( clearcoat > 0 ) newFeatures.hasClearcoat = true;
595
653
  if ( transmission > 0 ) newFeatures.hasTransmission = true;
@@ -597,6 +655,7 @@ export class MaterialDataManager {
597
655
  if ( iridescence > 0 ) newFeatures.hasIridescence = true;
598
656
  if ( sheen > 0 ) newFeatures.hasSheen = true;
599
657
  if ( transparent > 0 || opacity < 1.0 || alphaTest > 0 ) newFeatures.hasTransparency = true;
658
+ if ( subsurface > 0 ) newFeatures.hasSubsurface = true;
600
659
 
601
660
  const featureCount = [
602
661
  clearcoat > 0,
@@ -164,6 +164,7 @@ export class UniformManager {
164
164
  u( 'samplesPerPixel', DEFAULT_STATE.samplesPerPixel, 'int' );
165
165
  u( 'maxSamples', DEFAULT_STATE.maxSamples, 'int' );
166
166
  u( 'transmissiveBounces', DEFAULT_STATE.transmissiveBounces, 'int' );
167
+ u( 'maxSubsurfaceSteps', DEFAULT_STATE.maxSubsurfaceSteps, 'int' );
167
168
  u( 'visMode', DEFAULT_STATE.debugMode, 'int' );
168
169
  u( 'debugVisScale', DEFAULT_STATE.debugVisScale, 'float' );
169
170