rayzee 6.4.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rayzee",
3
- "version": "6.4.0",
3
+ "version": "6.5.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",
@@ -65,6 +65,7 @@ export const ENGINE_DEFAULTS = {
65
65
  bounces: 3,
66
66
  samplesPerPixel: 1,
67
67
  transmissiveBounces: 5,
68
+ maxSubsurfaceSteps: 8, // interactive default: low cap (bounded random-walk SSS)
68
69
  samplingTechnique: 3,
69
70
  enableEmissiveTriangleSampling: false,
70
71
  emissiveBoost: 1.0,
@@ -356,8 +357,8 @@ export const TRIANGLE_DATA_LAYOUT = {
356
357
  // Shared between CPU writers (TextureCreator, MaterialDataManager) and GPU readers (Common.js getMaterial).
357
358
  export const MATERIAL_DATA_LAYOUT = {
358
359
 
359
- SLOTS_PER_MATERIAL: 27, // vec4 slots per material
360
- FLOATS_PER_MATERIAL: 108, // total floats per material (27 × 4)
360
+ SLOTS_PER_MATERIAL: 30, // vec4 slots per material
361
+ FLOATS_PER_MATERIAL: 120, // total floats per material (30 × 4)
361
362
 
362
363
  // ── Flat float offsets (CPU side) ────────────────────────────────
363
364
  // Used as: data[ materialIndex * FLOATS_PER_MATERIAL + offset ]
@@ -399,6 +400,14 @@ export const MATERIAL_DATA_LAYOUT = {
399
400
  BUMP_TRANSFORM: 92,
400
401
  DISPLACEMENT_TRANSFORM: 100,
401
402
 
403
+ // ── Subsurface scattering (3 slots appended after transforms) ────
404
+ // Slot 27: subsurfaceColor.rgb (scatter albedo) + subsurface weight
405
+ SUBSURFACE_COLOR: 108, SUBSURFACE: 111,
406
+ // Slot 28: subsurfaceRadius.rgb (mean free path) + radius scale
407
+ SUBSURFACE_RADIUS: 112, SUBSURFACE_RADIUS_SCALE: 115,
408
+ // Slot 29: anisotropy g (floats 117-119 reserved for future SSS)
409
+ SUBSURFACE_ANISOTROPY: 116,
410
+
402
411
  // ── Vec4 slot indices (GPU/TSL side) ─────────────────────────────
403
412
  // Used with getDatafromStorageBuffer( buf, matIdx, int(slot), int(SLOTS_PER_MATERIAL) )
404
413
  SLOT: {
@@ -422,6 +431,9 @@ export const MATERIAL_DATA_LAYOUT = {
422
431
  EMISSIVE_TRANSFORM_A: 21, EMISSIVE_TRANSFORM_B: 22,
423
432
  BUMP_TRANSFORM_A: 23, BUMP_TRANSFORM_B: 24,
424
433
  DISPLACEMENT_TRANSFORM_A: 25, DISPLACEMENT_TRANSFORM_B: 26,
434
+ SUBSURFACE_A: 27, // subsurfaceColor.rgb, subsurface weight
435
+ SUBSURFACE_B: 28, // subsurfaceRadius.rgb, subsurfaceRadiusScale
436
+ SUBSURFACE_C: 29, // subsurfaceAnisotropy, reserved
425
437
  },
426
438
 
427
439
  };
@@ -434,7 +446,7 @@ export const BVH_LEAF_MARKERS = {
434
446
 
435
447
  // Texture processing constants
436
448
  export const TEXTURE_CONSTANTS = {
437
- PIXELS_PER_MATERIAL: 27,
449
+ PIXELS_PER_MATERIAL: 30,
438
450
  RGBA_COMPONENTS: 4,
439
451
  VEC4_PER_TRIANGLE: 8,
440
452
  VEC4_PER_BVH_NODE: 4,
@@ -454,7 +466,7 @@ export const DEFAULT_TEXTURE_MATRIX = [ 0, 0, 1, 1, 0, 0, 0, 1 ];
454
466
  // 'interactive' — low-sample, bounded bounces, no offline denoising, controls enabled.
455
467
  // 'production' — high-sample, deep bounces, OIDN enabled, controls disabled.
456
468
  export const PRODUCTION_RENDER_CONFIG = {
457
- maxSamples: 30, bounces: 20, transmissiveBounces: 8, samplesPerPixel: 1,
469
+ maxSamples: 30, bounces: 20, transmissiveBounces: 8, maxSubsurfaceSteps: 64, samplesPerPixel: 1,
458
470
  renderMode: 1, enableAlphaShadows: true, tiles: 3, tilesHelper: true,
459
471
  enableOIDN: true, oidnQuality: 'balance',
460
472
  interactionModeEnabled: false,
@@ -464,6 +476,7 @@ export const INTERACTIVE_RENDER_CONFIG = {
464
476
  maxSamples: ENGINE_DEFAULTS.maxSamples, bounces: ENGINE_DEFAULTS.bounces,
465
477
  samplesPerPixel: ENGINE_DEFAULTS.samplesPerPixel, renderMode: ENGINE_DEFAULTS.renderMode, enableAlphaShadows: ENGINE_DEFAULTS.enableAlphaShadows,
466
478
  transmissiveBounces: ENGINE_DEFAULTS.transmissiveBounces,
479
+ maxSubsurfaceSteps: ENGINE_DEFAULTS.maxSubsurfaceSteps,
467
480
  tiles: ENGINE_DEFAULTS.tiles, tilesHelper: ENGINE_DEFAULTS.tilesHelper,
468
481
  enableOIDN: false, oidnQuality: 'fast',
469
482
  interactionModeEnabled: true,
@@ -922,6 +922,7 @@ export class PathTracerApp extends EventDispatcher {
922
922
  maxBounces: config.bounces,
923
923
  samplesPerPixel: config.samplesPerPixel,
924
924
  transmissiveBounces: config.transmissiveBounces,
925
+ maxSubsurfaceSteps: config.maxSubsurfaceSteps,
925
926
  }, { silent: true } );
926
927
 
927
928
  this.stages.pathTracer?.setUniform( 'renderMode', parseInt( config.renderMode ) );
@@ -160,6 +160,7 @@ export class GeometryExtractor {
160
160
  if ( newMaterial.iridescence > 0 ) this.sceneFeatures.hasIridescence = true;
161
161
  if ( newMaterial.sheen > 0 ) this.sceneFeatures.hasSheen = true;
162
162
  if ( newMaterial.transparent || newMaterial.opacity < 1.0 || newMaterial.alphaTest > 0 ) this.sceneFeatures.hasTransparency = true;
163
+ if ( newMaterial.subsurface > 0 ) this.sceneFeatures.hasSubsurface = true;
163
164
 
164
165
  // Detect multi-lobe materials (require multi-lobe MIS for optimal sampling)
165
166
  const featureCount = [
@@ -259,7 +260,13 @@ export class GeometryExtractor {
259
260
  normalScale: { x: 1, y: 1 },
260
261
  bumpScale: 1.0,
261
262
  displacementScale: 1.0,
262
- alphaTest: 0.0
263
+ alphaTest: 0.0,
264
+ // Subsurface scattering (no native MeshPhysicalMaterial equivalent)
265
+ subsurface: 0.0,
266
+ subsurfaceColor: new Color( 0xffffff ),
267
+ subsurfaceRadius: [ 1.0, 0.2, 0.1 ], // skin-like: red travels furthest
268
+ subsurfaceRadiusScale: 1.0,
269
+ subsurfaceAnisotropy: 0.0
263
270
  };
264
271
 
265
272
  }
@@ -390,6 +397,13 @@ export class GeometryExtractor {
390
397
  iridescenceIOR: material.iridescenceIOR ?? defaults.iridescenceIOR,
391
398
  iridescenceThicknessRange: material.iridescenceThicknessRange ?? defaults.iridescenceThicknessRange,
392
399
 
400
+ // Subsurface scattering (custom props; MeshPhysicalMaterial has none)
401
+ subsurface: material.subsurface ?? defaults.subsurface,
402
+ subsurfaceColor: material.subsurfaceColor ?? defaults.subsurfaceColor,
403
+ subsurfaceRadius: material.subsurfaceRadius ?? defaults.subsurfaceRadius,
404
+ subsurfaceRadiusScale: material.subsurfaceRadiusScale ?? defaults.subsurfaceRadiusScale,
405
+ subsurfaceAnisotropy: material.subsurfaceAnisotropy ?? defaults.subsurfaceAnisotropy,
406
+
393
407
  // Specular properties (for compatibility)
394
408
  specularIntensity: legacyMapping.specularIntensity ?? material.specularIntensity ?? defaults.specularIntensity,
395
409
  specularColor: legacyMapping.specularColor ?? material.specularColor ?? defaults.specularColor,
@@ -789,6 +803,7 @@ export class GeometryExtractor {
789
803
  hasIridescence: false,
790
804
  hasSheen: false,
791
805
  hasTransparency: false,
806
+ hasSubsurface: false,
792
807
  hasMultiLobeMaterials: false, // Materials with 2+ BRDF lobes
793
808
  hasMRTOutputs: true // Always enabled for ASVGF/adaptive sampling support
794
809
  };
@@ -411,6 +411,7 @@ export class ShaderBuilder {
411
411
  groundProjectionHeight: stage.groundProjectionHeight,
412
412
  maxBounceCount: stage.maxBounces,
413
413
  transmissiveBounces: stage.transmissiveBounces,
414
+ maxSubsurfaceSteps: stage.maxSubsurfaceSteps,
414
415
  showBackground: stage.showBackground,
415
416
  transparentBackground: stage.transparentBackground,
416
417
  backgroundIntensity: stage.backgroundIntensity,
@@ -965,6 +965,12 @@ export class TextureCreator {
965
965
  bumpMapMatrices[ 4 ], bumpMapMatrices[ 5 ], bumpMapMatrices[ 6 ], 1,
966
966
  displacementMapMatrices[ 0 ], displacementMapMatrices[ 1 ], displacementMapMatrices[ 2 ], displacementMapMatrices[ 3 ],
967
967
  displacementMapMatrices[ 4 ], displacementMapMatrices[ 5 ], displacementMapMatrices[ 6 ], 1,
968
+ // Slot 27: subsurface (subsurfaceColor.rgb, subsurface weight)
969
+ mat.subsurfaceColor?.r ?? 1, mat.subsurfaceColor?.g ?? 1, mat.subsurfaceColor?.b ?? 1, mat.subsurface ?? 0,
970
+ // Slot 28: subsurface (subsurfaceRadius.rgb, subsurfaceRadiusScale)
971
+ mat.subsurfaceRadius?.[ 0 ] ?? 1, mat.subsurfaceRadius?.[ 1 ] ?? 0.2, mat.subsurfaceRadius?.[ 2 ] ?? 0.1, mat.subsurfaceRadiusScale ?? 1,
972
+ // Slot 29: subsurface (anisotropy g, reserved)
973
+ mat.subsurfaceAnisotropy ?? 0, 0, 0, 0,
968
974
  ];
969
975
 
970
976
  data.set( materialData, stride );
@@ -18,6 +18,7 @@ const SETTING_ROUTES = {
18
18
  maxBounces: { uniform: 'maxBounces', reset: true },
19
19
  samplesPerPixel: { uniform: 'samplesPerPixel', reset: true },
20
20
  transmissiveBounces: { uniform: 'transmissiveBounces', reset: true },
21
+ maxSubsurfaceSteps: { uniform: 'maxSubsurfaceSteps', reset: true },
21
22
  environmentIntensity: { uniform: 'environmentIntensity', reset: true },
22
23
  backgroundIntensity: { uniform: 'backgroundIntensity', reset: true },
23
24
  showBackground: { uniform: 'showBackground', reset: true },
@@ -19,6 +19,7 @@ import {
19
19
  lessThan,
20
20
  mat3,
21
21
  array,
22
+ bool as tslBool,
22
23
  } from 'three/tsl';
23
24
 
24
25
  import { Ray, HitInfo } from './Struct.js';
@@ -178,8 +179,13 @@ export const traverseBVH = Fn( ( [
178
179
  ray,
179
180
  bvhBuffer,
180
181
  triangleBuffer,
182
+ insideMedium, // optional: when true (ray inside a medium), bypass front/back culling
181
183
  ] ) => {
182
184
 
185
+ // Interior medium rays (SSS/transmission) must be able to hit boundary faces from
186
+ // either side to find the exit; exterior rays honor the authored side as before.
187
+ const inMedium = insideMedium ?? tslBool( false );
188
+
183
189
  const closestHit = HitInfo( {
184
190
  didHit: false,
185
191
  dst: float( 1e20 ),
@@ -280,7 +286,7 @@ export const traverseBVH = Fn( ( [
280
286
 
281
287
  // Side culling (inline; per-mesh visibility is at the BLAS-pointer level).
282
288
  // 0=front (reject back-facing), 1=back (reject front-facing), 2=double (pass).
283
- const sidePass = side.equal( int( 2 ) )
289
+ const sidePass = inMedium.or( side.equal( int( 2 ) ) )
284
290
  .or( side.equal( int( 0 ) ).and( rayDotNormal.lessThan( - 0.0001 ) ) )
285
291
  .or( side.equal( int( 1 ) ).and( rayDotNormal.greaterThan( 0.0001 ) ) );
286
292
  If( sidePass, () => {
package/src/TSL/Common.js CHANGED
@@ -201,13 +201,14 @@ export const applySoftSuppressionRGB = wgslFn( `
201
201
  `, [ applySoftSuppression ] );
202
202
 
203
203
  // Pre-computed material classification for faster branching
204
- export const classifyMaterial = Fn( ( [ metalness, roughness, transmission, clearcoat, emissive ] ) => {
204
+ export const classifyMaterial = Fn( ( [ metalness, roughness, transmission, clearcoat, emissive, subsurface ] ) => {
205
205
 
206
206
  const isMetallic = metalness.greaterThan( 0.7 ).toVar();
207
207
  const isRough = roughness.greaterThan( 0.8 );
208
208
  const isSmooth = roughness.lessThan( 0.3 ).toVar();
209
209
  const isTransmissive = transmission.greaterThan( 0.5 ).toVar();
210
210
  const hasClearcoat = clearcoat.greaterThan( 0.5 ).toVar();
211
+ const isSubsurface = subsurface.greaterThan( 0.0 ); // only feeds complexityScore below
211
212
 
212
213
  // Fast emissive check using sum
213
214
  const emissiveMag = emissive.x.add( emissive.y ).add( emissive.z );
@@ -218,7 +219,8 @@ export const classifyMaterial = Fn( ( [ metalness, roughness, transmission, clea
218
219
  .add( float( 0.25 ).mul( float( isSmooth ) ) )
219
220
  .add( float( 0.45 ).mul( float( isTransmissive ) ) )
220
221
  .add( float( 0.35 ).mul( float( hasClearcoat ) ) )
221
- .add( float( 0.3 ).mul( float( isEmissive ) ) );
222
+ .add( float( 0.3 ).mul( float( isEmissive ) ) )
223
+ .add( float( 0.4 ).mul( float( isSubsurface ) ) ); // SSS walks are deep + high-value → keep alive in RR
222
224
 
223
225
  // Add material interaction complexity
224
226
  const interactionComplexity = float( 0.0 ).toVar();
@@ -340,6 +342,9 @@ export const getMaterial = Fn( ( [ materialIndex, materialBuffer ] ) => {
340
342
  const data24 = getDatafromStorageBuffer( materialBuffer, materialIndex, int( S.BUMP_TRANSFORM_B ), int( MATERIAL_SLOTS ) ).toVar();
341
343
  const data25 = getDatafromStorageBuffer( materialBuffer, materialIndex, int( S.DISPLACEMENT_TRANSFORM_A ), int( MATERIAL_SLOTS ) ).toVar();
342
344
  const data26 = getDatafromStorageBuffer( materialBuffer, materialIndex, int( S.DISPLACEMENT_TRANSFORM_B ), int( MATERIAL_SLOTS ) ).toVar();
345
+ const data27 = getDatafromStorageBuffer( materialBuffer, materialIndex, int( S.SUBSURFACE_A ), int( MATERIAL_SLOTS ) ).toVar();
346
+ const data28 = getDatafromStorageBuffer( materialBuffer, materialIndex, int( S.SUBSURFACE_B ), int( MATERIAL_SLOTS ) ).toVar();
347
+ const data29 = getDatafromStorageBuffer( materialBuffer, materialIndex, int( S.SUBSURFACE_C ), int( MATERIAL_SLOTS ) ).toVar();
343
348
 
344
349
  return RayTracingMaterial( {
345
350
  color: vec4( data0.rgb, 1.0 ),
@@ -361,6 +366,11 @@ export const getMaterial = Fn( ( [ materialIndex, materialBuffer ] ) => {
361
366
  iridescence: data7.r,
362
367
  iridescenceIOR: data7.g,
363
368
  iridescenceThicknessRange: data7.ba,
369
+ subsurfaceColor: data27.rgb,
370
+ subsurface: data27.a,
371
+ subsurfaceRadius: data28.rgb,
372
+ subsurfaceRadiusScale: data28.a,
373
+ subsurfaceAnisotropy: data29.r,
364
374
  albedoMapIndex: int( data8.r ),
365
375
  normalMapIndex: int( data8.g ),
366
376
  roughnessMapIndex: int( data8.b ),
@@ -27,6 +27,7 @@ import { iorToFresnel0, fresnelSchlickFloat } from './Fresnel.js';
27
27
  import { DistributionGGX } from './MaterialProperties.js';
28
28
  import { ImportanceSampleGGX } from './MaterialSampling.js';
29
29
  import { RandomValue, pcgHash } from './Random.js';
30
+ import { handleSubsurfaceEntry, SubsurfaceEntryResult } from './Subsurface.js';
30
31
 
31
32
  // ================================================================================
32
33
  // STRUCTS (local to transmission)
@@ -43,6 +44,7 @@ export const MaterialInteractionResult = struct( {
43
44
  continueRay: 'bool', // Whether the ray should continue without further BRDF evaluation
44
45
  isTransmissive: 'bool', // Flag to indicate this was a transmissive interaction
45
46
  isAlphaSkip: 'bool', // Flag to indicate this was an alpha skip
47
+ isSubsurface: 'bool', // Flag to indicate this entered/exited a subsurface medium
46
48
  didReflect: 'bool', // Whether TIR/reflection occurred (for medium stack update)
47
49
  entering: 'bool', // Whether ray is entering or exiting medium
48
50
  direction: 'vec3', // New ray direction if continuing
@@ -493,6 +495,7 @@ export const handleMaterialTransparency = Fn( ( [
493
495
  continueRay: false,
494
496
  isTransmissive: false,
495
497
  isAlphaSkip: false,
498
+ isSubsurface: false,
496
499
  didReflect: false,
497
500
  entering: false,
498
501
  direction: ray.direction,
@@ -501,8 +504,9 @@ export const handleMaterialTransparency = Fn( ( [
501
504
  pathWavelength: pathWavelength,
502
505
  } ).toVar();
503
506
 
504
- // Fast path for fully opaque materials (most common case)
505
- If( material.alphaMode.equal( int( 0 ) ).and( material.transmission.lessThanEqual( 0.0 ) ), () => {
507
+ // Fast path for fully opaque, non-scattering materials (most common case).
508
+ // Subsurface materials are opaque (transmission==0) but must NOT take this path.
509
+ If( material.alphaMode.equal( int( 0 ) ).and( material.transmission.lessThanEqual( 0.0 ) ).and( material.subsurface.lessThanEqual( 0.0 ) ), () => {
506
510
 
507
511
  // no interaction needed
508
512
 
@@ -584,6 +588,32 @@ export const handleMaterialTransparency = Fn( ( [
584
588
 
585
589
  } );
586
590
 
591
+ // Subsurface (independent of transmission; works at transmission==0). Entry is a lottery
592
+ // (prob = weight) so 1-weight falls through to the opaque BRDF; exit is deterministic.
593
+ If( handled.not().and( material.subsurface.greaterThan( 0.0 ) ), () => {
594
+
595
+ const entering = dot( ray.direction, normal ).lessThan( 0.0 );
596
+ const doEnter = entering.not().or( RandomValue( rngState ).lessThan( material.subsurface ) );
597
+
598
+ If( doEnter, () => {
599
+
600
+ const ssResult = SubsurfaceEntryResult.wrap( handleSubsurfaceEntry(
601
+ ray.direction, normal, material, entering, rngState,
602
+ currentMediumIOR, previousMediumIOR,
603
+ ) ).toVar();
604
+
605
+ result.direction.assign( ssResult.direction );
606
+ result.throughput.assign( ssResult.throughput );
607
+ result.continueRay.assign( true );
608
+ result.isSubsurface.assign( true );
609
+ result.didReflect.assign( ssResult.didReflect );
610
+ result.entering.assign( entering );
611
+ result.alpha.assign( 1.0 );
612
+
613
+ } );
614
+
615
+ } );
616
+
587
617
  } );
588
618
 
589
619
  return result;
@@ -151,7 +151,7 @@ export const pathTracerMain = ( params ) => {
151
151
  envTotalSum, envCompensationDelta, envResolution,
152
152
  enableEnvironmentLight, useEnvMapIS,
153
153
  groundProjectionEnabled, groundProjectionRadius, groundProjectionHeight,
154
- maxBounceCount, transmissiveBounces,
154
+ maxBounceCount, transmissiveBounces, maxSubsurfaceSteps,
155
155
  showBackground, transparentBackground, backgroundIntensity,
156
156
  fireflyThreshold, globalIlluminationIntensity,
157
157
  enableEmissiveTriangleSampling,
@@ -299,7 +299,7 @@ export const pathTracerMain = ( params ) => {
299
299
  envTotalSum, envCompensationDelta, envResolution,
300
300
  enableEnvironmentLight, useEnvMapIS,
301
301
  groundProjectionEnabled, groundProjectionRadius, groundProjectionHeight,
302
- maxBounceCount, transmissiveBounces,
302
+ maxBounceCount, transmissiveBounces, maxSubsurfaceSteps,
303
303
  backgroundIntensity, showBackground, transparentBackground,
304
304
  fireflyThreshold, globalIlluminationIntensity,
305
305
  enableEmissiveTriangleSampling,
@@ -77,6 +77,7 @@ import { sampleEnvironment, sampleEquirect, getGroundProjectedDirection } from '
77
77
  import { sampleAllMaterialTextures } from './TextureSampling.js';
78
78
  import { refineDisplacedIntersection, DisplacementResult } from './Displacement.js';
79
79
  import { handleMaterialTransparency, MaterialInteractionResult, sampleMicrofacetTransmission, MicrofacetTransmissionResult } from './MaterialTransmission.js';
80
+ import { subsurfaceCoefficients, sampleChromaticCollision, sampleHenyeyGreenstein, MediumCoeffs, CollisionSample } from './Subsurface.js';
80
81
  import {
81
82
  SheenDistribution,
82
83
  calculateVNDFPDF,
@@ -140,7 +141,7 @@ export const getOrCreateMaterialClassification = Fn( ( [
140
141
  result.assign( classifyMaterial(
141
142
  material.metalness, material.roughness,
142
143
  material.transmission, material.clearcoat,
143
- material.emissive,
144
+ material.emissive, material.subsurface,
144
145
  ) );
145
146
 
146
147
  } );
@@ -538,7 +539,7 @@ export const Trace = Fn( ( [
538
539
  enableEnvironmentLight, useEnvMapIS,
539
540
  groundProjectionEnabled, groundProjectionRadius, groundProjectionHeight,
540
541
  // Rendering parameters
541
- maxBounceCount, transmissiveBounces,
542
+ maxBounceCount, transmissiveBounces, maxSubsurfaceSteps,
542
543
  backgroundIntensity, showBackground, transparentBackground,
543
544
  fireflyThreshold, globalIlluminationIntensity,
544
545
  enableEmissiveTriangleSampling,
@@ -584,6 +585,18 @@ export const Trace = Fn( ( [
584
585
  const mediumStack_sigmaA_1 = vec3( 0.0 ).toVar();
585
586
  const mediumStack_sigmaA_2 = vec3( 0.0 ).toVar();
586
587
  const mediumStack_sigmaA_3 = vec3( 0.0 ).toVar();
588
+ // Subsurface: sigma_s>0 makes the medium scatter (random walk) rather than absorb straight.
589
+ const mediumStack_sigmaS_1 = vec3( 0.0 ).toVar();
590
+ const mediumStack_sigmaS_2 = vec3( 0.0 ).toVar();
591
+ const mediumStack_sigmaS_3 = vec3( 0.0 ).toVar();
592
+ const mediumStack_sigmaT_1 = vec3( 0.0 ).toVar();
593
+ const mediumStack_sigmaT_2 = vec3( 0.0 ).toVar();
594
+ const mediumStack_sigmaT_3 = vec3( 0.0 ).toVar();
595
+ const mediumStack_g_1 = float( 0.0 ).toVar();
596
+ const mediumStack_g_2 = float( 0.0 ).toVar();
597
+ const mediumStack_g_3 = float( 0.0 ).toVar();
598
+ // Walk-step budget for the whole path (bounded per render mode + RR).
599
+ const sssSteps = int( 0 ).toVar();
587
600
 
588
601
  // Locked at the first dispersive transmission; reused for subsequent transmissions on
589
602
  // the path so multi-bounce dispersion doesn't collapse under repeated colorWeight ×.
@@ -633,7 +646,7 @@ export const Trace = Fn( ( [
633
646
  const rayDirection = ray.direction.toVar();
634
647
 
635
648
  // Main bounce loop
636
- Loop( { start: int( 0 ), end: maxBounceCount.add( transmissiveBounces ).add( 1 ), type: 'int', condition: '<' }, ( { i: bounceIndex } ) => {
649
+ Loop( { start: int( 0 ), end: maxBounceCount.add( transmissiveBounces ).add( maxSubsurfaceSteps ).add( 1 ), type: 'int', condition: '<' }, ( { i: bounceIndex } ) => {
637
650
 
638
651
  // Update state
639
652
  stateTraversals.assign( maxBounceCount.sub( effectiveBounces ) );
@@ -656,30 +669,82 @@ export const Trace = Fn( ( [
656
669
  currentRay,
657
670
  bvhBuffer,
658
671
  triangleBuffer,
672
+ mediumStackDepth.greaterThan( int( 0 ) ), // inside a medium → bypass front/back culling
659
673
  ) ).toVar();
660
674
 
661
- // KHR_materials_volume: apply Beer's law over the actual distance the ray
662
- // traveled inside the current medium. Top-of-stack holds the medium the ray
663
- // is currently in — depth==0 means air (no absorption). sigma_a was
664
- // precomputed at push time, so this collapses to a single exp().
675
+ // In-medium transport: glass (sigma_s==0) absorbs along a straight line; subsurface
676
+ // (sigma_s>0) random-walks and may scatter mid-flight.
665
677
  If( hitInfo.didHit.and( mediumStackDepth.greaterThan( int( 0 ) ) ), () => {
666
678
 
679
+ // Load current-medium coefficients (chained branch — divergence-safe).
667
680
  const mSigmaA = vec3( 0.0 ).toVar();
681
+ const mSigmaS = vec3( 0.0 ).toVar();
682
+ const mSigmaT = vec3( 0.0 ).toVar();
683
+ const mG = float( 0.0 ).toVar();
668
684
  If( mediumStackDepth.equal( int( 1 ) ), () => {
669
685
 
670
- mSigmaA.assign( mediumStack_sigmaA_1 );
686
+ mSigmaA.assign( mediumStack_sigmaA_1 ); mSigmaS.assign( mediumStack_sigmaS_1 );
687
+ mSigmaT.assign( mediumStack_sigmaT_1 ); mG.assign( mediumStack_g_1 );
671
688
 
672
689
  } ).ElseIf( mediumStackDepth.equal( int( 2 ) ), () => {
673
690
 
674
- mSigmaA.assign( mediumStack_sigmaA_2 );
691
+ mSigmaA.assign( mediumStack_sigmaA_2 ); mSigmaS.assign( mediumStack_sigmaS_2 );
692
+ mSigmaT.assign( mediumStack_sigmaT_2 ); mG.assign( mediumStack_g_2 );
675
693
 
676
694
  } ).ElseIf( mediumStackDepth.equal( int( 3 ) ), () => {
677
695
 
678
- mSigmaA.assign( mediumStack_sigmaA_3 );
696
+ mSigmaA.assign( mediumStack_sigmaA_3 ); mSigmaS.assign( mediumStack_sigmaS_3 );
697
+ mSigmaT.assign( mediumStack_sigmaT_3 ); mG.assign( mediumStack_g_3 );
679
698
 
680
699
  } );
681
700
 
682
- throughput.mulAssign( exp( mSigmaA.mul( hitInfo.dst ).negate() ) );
701
+ If( maxComponent( { v: mSigmaS } ).lessThanEqual( 0.0 ), () => {
702
+
703
+ // Non-scattering medium (glass): straight-line Beer-Lambert absorption.
704
+ throughput.mulAssign( exp( mSigmaA.mul( hitInfo.dst ).negate() ) );
705
+
706
+ } ).Else( () => {
707
+
708
+ // Scattering medium (subsurface): chromatic distance sampling for this segment.
709
+ const coll = CollisionSample.wrap( sampleChromaticCollision(
710
+ mSigmaT, mSigmaS, throughput, hitInfo.dst, rngState,
711
+ ) ).toVar();
712
+ throughput.mulAssign( coll.weight );
713
+
714
+ If( coll.didScatter, () => {
715
+
716
+ // Scatter: move to the collision point, redirect via the HG phase function,
717
+ // continue as a free bounce (no camera-bounce cost).
718
+ const xi2 = vec2( RandomValue( rngState ), RandomValue( rngState ) );
719
+ const scatterPoint = rayOrigin.add( rayDirection.mul( coll.t ) );
720
+ rayOrigin.assign( scatterPoint );
721
+ rayDirection.assign( sampleHenyeyGreenstein( rayDirection, mG, xi2 ) );
722
+ sssSteps.addAssign( 1 );
723
+ stateIsPrimaryRay.assign( tslBool( false ) );
724
+
725
+ // Per-mode step cap: bounded walk (terminate — energy-loss-only bias).
726
+ If( sssSteps.greaterThanEqual( maxSubsurfaceSteps ), () => {
727
+
728
+ Break();
729
+
730
+ } );
731
+
732
+ // Russian roulette so the walk self-terminates before the cap.
733
+ const rrP = clamp( maxComponent( { v: throughput } ), 0.02, 1.0 ).toVar();
734
+ If( RandomValue( rngState ).greaterThan( rrP ), () => {
735
+
736
+ Break();
737
+
738
+ } );
739
+ throughput.divAssign( rrP );
740
+
741
+ Continue();
742
+
743
+ } );
744
+
745
+ // No scatter: reached the boundary (weight applied); fall through to surface handling.
746
+
747
+ } );
683
748
 
684
749
  } );
685
750
 
@@ -732,7 +797,7 @@ export const Trace = Fn( ( [
732
797
 
733
798
  } );
734
799
 
735
- // Get full material (27 reads). Lazy transform loading was tested but regressed
800
+ // Get full material (30 reads). Lazy transform loading was tested but regressed
736
801
  // textured scenes due to identity-construct + conditional-assign overhead.
737
802
  // Shadow rays use getShadowMaterial() (7 reads) — the real bandwidth win.
738
803
  const material = RayTracingMaterial.wrap( getMaterial( hitInfo.materialIndex, materialBuffer ) ).toVar();
@@ -833,6 +898,7 @@ export const Trace = Fn( ( [
833
898
  mediumStack_attColor_1.assign( material.attenuationColor );
834
899
  mediumStack_attDist_1.assign( material.attenuationDistance );
835
900
  mediumStack_sigmaA_1.assign( mSigmaA );
901
+ mediumStack_sigmaS_1.assign( vec3( 0.0 ) ); // glass: no scattering
836
902
 
837
903
  } ).ElseIf( mediumStackDepth.equal( int( 2 ) ), () => {
838
904
 
@@ -840,6 +906,7 @@ export const Trace = Fn( ( [
840
906
  mediumStack_attColor_2.assign( material.attenuationColor );
841
907
  mediumStack_attDist_2.assign( material.attenuationDistance );
842
908
  mediumStack_sigmaA_2.assign( mSigmaA );
909
+ mediumStack_sigmaS_2.assign( vec3( 0.0 ) ); // glass: no scattering
843
910
 
844
911
  } ).ElseIf( mediumStackDepth.equal( int( 3 ) ), () => {
845
912
 
@@ -847,6 +914,7 @@ export const Trace = Fn( ( [
847
914
  mediumStack_attColor_3.assign( material.attenuationColor );
848
915
  mediumStack_attDist_3.assign( material.attenuationDistance );
849
916
  mediumStack_sigmaA_3.assign( mSigmaA );
917
+ mediumStack_sigmaS_3.assign( vec3( 0.0 ) ); // glass: no scattering
850
918
 
851
919
  } );
852
920
 
@@ -865,6 +933,65 @@ export const Trace = Fn( ( [
865
933
 
866
934
  } );
867
935
 
936
+ } ).ElseIf( interaction.isSubsurface, () => {
937
+
938
+ // Subsurface boundary: free bounce (SSS step budget, not camera bounces). Push on enter, pop on exit.
939
+ isFreeBounce.assign( tslBool( true ) );
940
+ stateRayType.assign( int( RAY_TYPE_DIFFUSE ) );
941
+
942
+ If( interaction.didReflect.not(), () => {
943
+
944
+ If( interaction.entering, () => {
945
+
946
+ If( mediumStackDepth.lessThan( int( 3 ) ), () => {
947
+
948
+ mediumStackDepth.addAssign( 1 );
949
+
950
+ const ssCoeffs = MediumCoeffs.wrap( subsurfaceCoefficients(
951
+ material.subsurfaceColor, material.subsurfaceRadius, material.subsurfaceRadiusScale,
952
+ ) ).toVar();
953
+ const ssG = clamp( material.subsurfaceAnisotropy, - 0.99, 0.99 ).toVar();
954
+
955
+ If( mediumStackDepth.equal( int( 1 ) ), () => {
956
+
957
+ mediumStack_ior_1.assign( material.ior );
958
+ mediumStack_sigmaA_1.assign( ssCoeffs.sigmaA );
959
+ mediumStack_sigmaS_1.assign( ssCoeffs.sigmaS );
960
+ mediumStack_sigmaT_1.assign( ssCoeffs.sigmaT );
961
+ mediumStack_g_1.assign( ssG );
962
+
963
+ } ).ElseIf( mediumStackDepth.equal( int( 2 ) ), () => {
964
+
965
+ mediumStack_ior_2.assign( material.ior );
966
+ mediumStack_sigmaA_2.assign( ssCoeffs.sigmaA );
967
+ mediumStack_sigmaS_2.assign( ssCoeffs.sigmaS );
968
+ mediumStack_sigmaT_2.assign( ssCoeffs.sigmaT );
969
+ mediumStack_g_2.assign( ssG );
970
+
971
+ } ).ElseIf( mediumStackDepth.equal( int( 3 ) ), () => {
972
+
973
+ mediumStack_ior_3.assign( material.ior );
974
+ mediumStack_sigmaA_3.assign( ssCoeffs.sigmaA );
975
+ mediumStack_sigmaS_3.assign( ssCoeffs.sigmaS );
976
+ mediumStack_sigmaT_3.assign( ssCoeffs.sigmaT );
977
+ mediumStack_g_3.assign( ssG );
978
+
979
+ } );
980
+
981
+ } );
982
+
983
+ } ).Else( () => {
984
+
985
+ If( mediumStackDepth.greaterThan( int( 0 ) ), () => {
986
+
987
+ mediumStackDepth.subAssign( 1 );
988
+
989
+ } );
990
+
991
+ } );
992
+
993
+ } );
994
+
868
995
  } ).ElseIf( interaction.isAlphaSkip, () => {
869
996
 
870
997
  isFreeBounce.assign( tslBool( true ) );
package/src/TSL/Struct.js CHANGED
@@ -50,6 +50,11 @@ export const RayTracingMaterial = struct( {
50
50
  iridescence: 'float',
51
51
  iridescenceIOR: 'float',
52
52
  iridescenceThicknessRange: 'vec2',
53
+ subsurface: 'float', // 0 = off, blends opaque BRDF → random-walk SSS
54
+ subsurfaceColor: 'vec3', // single-scatter albedo (tint light picks up inside)
55
+ subsurfaceRadius: 'vec3', // per-channel mean free path
56
+ subsurfaceRadiusScale: 'float', // scalar multiplier on radius
57
+ subsurfaceAnisotropy: 'float', // Henyey-Greenstein g (-1..1)
53
58
  } );
54
59
 
55
60
  // Lightweight material for shadow ray evaluation — only the fields needed