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,179 @@
1
+ /**
2
+ * PBRT-v4 scene loader.
3
+ *
4
+ * Orchestrates: virtual filesystem (from a zip) → tokenize/parse the entry
5
+ * .pbrt (following Include/Import) → build a THREE scene graph. Geometry,
6
+ * image, and HDR decoding are injected by the host (AssetLoader owns the
7
+ * three/examples loaders) so this module stays dependency-light.
8
+ *
9
+ * Usage (from AssetLoader):
10
+ * const { group, environment, warnings } = await loadPBRTScene({
11
+ * vfs, entryPath, plyParser, imageFromBytes, envFromBytes
12
+ * });
13
+ * scene.environment = environment?.texture ?? scene.environment;
14
+ * await loadObject3D(group);
15
+ */
16
+
17
+ import { PBRTParser } from './PBRTParser.js';
18
+ import { PBRTSceneBuilder } from './PBRTSceneBuilder.js';
19
+
20
+ export { PBRTParser } from './PBRTParser.js';
21
+ export { PBRTSceneBuilder } from './PBRTSceneBuilder.js';
22
+ export { tokenize } from './PBRTTokenizer.js';
23
+
24
+ const decoder = new TextDecoder();
25
+
26
+ /** Normalize a path: forward slashes, collapse "./" and "../". */
27
+ function normalizePath( p ) {
28
+
29
+ const parts = p.replace( /\\/g, '/' ).split( '/' );
30
+ const out = [];
31
+ for ( const part of parts ) {
32
+
33
+ if ( part === '' || part === '.' ) continue;
34
+ if ( part === '..' ) out.pop();
35
+ else out.push( part );
36
+
37
+ }
38
+
39
+ return out.join( '/' );
40
+
41
+ }
42
+
43
+ /** Join a base directory with a relative path. */
44
+ function joinPath( dir, rel ) {
45
+
46
+ if ( ! dir ) return normalizePath( rel );
47
+ if ( rel.startsWith( '/' ) ) return normalizePath( rel );
48
+ return normalizePath( `${dir}/${rel}` );
49
+
50
+ }
51
+
52
+ /**
53
+ * Wraps the zip contents with tolerant, case-insensitive lookup that falls back
54
+ * to a basename match — pbrt scenes are inconsistent about path roots.
55
+ */
56
+ class VirtualFS {
57
+
58
+ constructor( entries ) {
59
+
60
+ // entries: { path: Uint8Array }
61
+ this.byPath = new Map(); // normalized lowercase -> { norm, bytes }
62
+ this.byBase = new Map(); // basename lowercase -> [ { norm, bytes } ] (insertion order)
63
+ for ( const key in entries ) {
64
+
65
+ const norm = normalizePath( key ).toLowerCase();
66
+ const rec = { norm, bytes: entries[ key ] };
67
+ this.byPath.set( norm, rec );
68
+ const base = norm.split( '/' ).pop();
69
+ const bucket = this.byBase.get( base );
70
+ if ( bucket ) bucket.push( rec );
71
+ else this.byBase.set( base, [ rec ] );
72
+
73
+ }
74
+
75
+ }
76
+
77
+ find( path ) {
78
+
79
+ const norm = normalizePath( path ).toLowerCase();
80
+ if ( this.byPath.has( norm ) ) return this.byPath.get( norm ).bytes;
81
+
82
+ // Resolve by basename (O(1)); among collisions prefer a path-suffix match,
83
+ // else fall back to the first entry with that name. pbrt scenes are
84
+ // inconsistent about path roots, so this tolerates relative/absolute drift.
85
+ const bucket = this.byBase.get( norm.split( '/' ).pop() );
86
+ if ( ! bucket ) return null;
87
+ const suffixHit = bucket.find( rec => rec.norm.endsWith( '/' + norm ) );
88
+ return ( suffixHit || bucket[ 0 ] ).bytes;
89
+
90
+ }
91
+
92
+ }
93
+
94
+ /** Pick the top-level .pbrt entry: shallowest path, preferring scene/main names. */
95
+ export function pickEntryPath( entries ) {
96
+
97
+ const pbrts = Object.keys( entries ).filter( k => k.toLowerCase().endsWith( '.pbrt' ) );
98
+ if ( pbrts.length === 0 ) return null;
99
+
100
+ const preferred = pbrts.filter( k => /(^|\/)(scene|main)\.pbrt$/i.test( k ) );
101
+ const pool = preferred.length ? preferred : pbrts;
102
+
103
+ // Shallowest (fewest path segments), then shortest name.
104
+ pool.sort( ( a, b ) => {
105
+
106
+ const da = a.split( '/' ).length, db = b.split( '/' ).length;
107
+ return da !== db ? da - db : a.length - b.length;
108
+
109
+ } );
110
+
111
+ return pool[ 0 ];
112
+
113
+ }
114
+
115
+ /**
116
+ * @param {object} args
117
+ * @param {Object<string,Uint8Array>} args.vfs - zip entries (path → bytes)
118
+ * @param {string} [args.entryPath] - top .pbrt; auto-detected if omitted
119
+ * @param {(buf:ArrayBuffer)=>import('three').BufferGeometry} args.plyParser
120
+ * @param {(bytes:Uint8Array, filename:string)=>Promise<import('three').Texture>} args.imageFromBytes
121
+ * @param {(bytes:Uint8Array, filename:string)=>Promise<import('three').Texture>} [args.envFromBytes]
122
+ * @param {boolean} [args.convertHandedness=true]
123
+ * @returns {Promise<{group, camera, environment, warnings, entryPath}>}
124
+ */
125
+ export async function loadPBRTScene( args ) {
126
+
127
+ const { vfs: rawEntries, plyParser, imageFromBytes, envFromBytes, convertHandedness } = args;
128
+ const vfs = new VirtualFS( rawEntries );
129
+
130
+ const entryPath = args.entryPath || pickEntryPath( rawEntries );
131
+ if ( ! entryPath ) throw new Error( 'PBRT loader: no .pbrt file found in archive' );
132
+
133
+ const entryBytes = vfs.find( entryPath );
134
+ if ( ! entryBytes ) throw new Error( `PBRT loader: entry "${entryPath}" not readable` );
135
+
136
+ const baseDir = entryPath.includes( '/' ) ? entryPath.slice( 0, entryPath.lastIndexOf( '/' ) ) : '';
137
+
138
+ // Parse (with Include resolution)
139
+ const parser = new PBRTParser( {
140
+ resolveInclude: ( path, currentDir ) => {
141
+
142
+ const bytes = vfs.find( joinPath( currentDir, path ) ) || vfs.find( path );
143
+ return bytes ? decoder.decode( bytes ) : null;
144
+
145
+ }
146
+ } );
147
+ const ir = parser.parse( decoder.decode( entryBytes ), baseDir );
148
+
149
+ // Build scene graph
150
+ const sliceBuf = ( bytes ) => bytes.buffer.slice( bytes.byteOffset, bytes.byteOffset + bytes.byteLength );
151
+ const builder = new PBRTSceneBuilder( {
152
+ convertHandedness,
153
+ resolvePLY: async ( filename ) => {
154
+
155
+ const bytes = vfs.find( filename );
156
+ if ( ! bytes ) return null;
157
+ return plyParser( sliceBuf( bytes ) );
158
+
159
+ },
160
+ resolveImage: async ( filename ) => {
161
+
162
+ const bytes = vfs.find( filename );
163
+ if ( ! bytes ) return null;
164
+ return imageFromBytes( bytes, filename );
165
+
166
+ },
167
+ resolveEnvironment: async ( filename ) => {
168
+
169
+ const bytes = vfs.find( filename );
170
+ if ( ! bytes ) return null;
171
+ return ( envFromBytes || imageFromBytes )( bytes, filename );
172
+
173
+ }
174
+ } );
175
+
176
+ const result = await builder.build( ir );
177
+ return { ...result, entryPath };
178
+
179
+ }
@@ -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 ) );
@@ -918,6 +1045,18 @@ export const Trace = Fn( ( [
918
1045
  const randomSample = getRandomSample( pixelCoord, rayIndex, bounceIndex, rngState, int( - 1 ), resolution, frame ).toVar();
919
1046
 
920
1047
  const V = rayDirection.negate().toVar();
1048
+
1049
+ // Two-sided shading: flip the shading normal into the viewer's hemisphere.
1050
+ // This is the opaque reflection path (transmissive rays already Continue'd),
1051
+ // so it never disturbs dielectric entering/exiting. No-op when N already
1052
+ // faces V; rescues meshes with inward-facing normals (common in imported
1053
+ // scenes, e.g. pbrt PLY assets) that would otherwise shade black.
1054
+ If( dot( N, V ).lessThan( 0.0 ), () => {
1055
+
1056
+ N.assign( N.negate() );
1057
+
1058
+ } );
1059
+
921
1060
  material.sheenRoughness.assign( clamp( material.sheenRoughness, MIN_ROUGHNESS, MAX_ROUGHNESS ) );
922
1061
 
923
1062
  // Sync material classification cache up front — the materialCache, BRDF
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