rayzee 7.22.8 → 7.23.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": "7.22.8",
3
+ "version": "7.23.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",
@@ -524,6 +524,11 @@ export const MATERIAL_DATA_LAYOUT = {
524
524
 
525
525
  };
526
526
 
527
+ // glTF/three.js spell "no volume absorption" as attenuationDistance = Infinity, while the GPU
528
+ // contract is `> 0 = on` (calculateBeerLawAbsorption). Every writer into ATTENUATION_DISTANCE
529
+ // collapses both spellings to 0 so no shader divides by Inf.
530
+ export const normalizeAttenuationDistance = d => ( Number.isFinite( d ) && d > 0 ? d : 0 );
531
+
527
532
  // BVH node leaf markers
528
533
  export const BVH_LEAF_MARKERS = {
529
534
  TRIANGLE_LEAF: - 1, // Leaf containing triangle references
@@ -2104,6 +2104,18 @@ export class PathTracerApp extends EventDispatcher {
2104
2104
 
2105
2105
  }
2106
2106
 
2107
+ /**
2108
+ * Read back the scalar material property the shader is actually using.
2109
+ * @param {number} materialIndex
2110
+ * @param {string} property
2111
+ * @returns {number|undefined}
2112
+ */
2113
+ getMaterialProperty( materialIndex, property ) {
2114
+
2115
+ return this.stages.pathTracer?.materialData.getMaterialProperty( materialIndex, property );
2116
+
2117
+ }
2118
+
2107
2119
  setMaterialProperty( materialIndex, property, value ) {
2108
2120
 
2109
2121
  this.stages.pathTracer?.materialData.updateMaterialProperty( materialIndex, property, value );
@@ -373,20 +373,12 @@ export class GeometryExtractor {
373
373
  const materialType = this.getMaterialType( material );
374
374
  const legacyMapping = this.mapLegacyMaterialToPhysical( material, materialType );
375
375
 
376
- // Determine if material should be treated as dielectric or metallic
376
+ // A transmissive surface is a dielectric interface whose IOR drives refraction, so it wins
377
+ // over the metal Fresnel hack: glTF defaults metallicFactor to 1, which otherwise stamps
378
+ // 2.5 onto water/glass authored as MeshStandardMaterial.
379
+ const isTransmissive = ( material.transmission ?? 0.0 ) > 0.0;
377
380
  const isMetallic = ( material.metalness ?? legacyMapping.metalness ?? 0.0 ) > 0.1;
378
-
379
- // Set appropriate IOR based on material type
380
- let defaultIOR = defaults.ior;
381
- if ( isMetallic ) {
382
-
383
- defaultIOR = 2.5; // Typical metallic IOR
384
-
385
- } else if ( material.transmission > 0.0 ) {
386
-
387
- defaultIOR = 1.5; // Glass-like for transmissive materials
388
-
389
- }
381
+ const defaultIOR = ( isMetallic && ! isTransmissive ) ? 2.5 : defaults.ior;
390
382
 
391
383
  // Handle color conversion for different material types
392
384
  let baseColor = material.color || new Color( 0xffffff );
@@ -1,5 +1,5 @@
1
1
  import { DataArrayTexture, RGBAFormat, LinearFilter, UnsignedByteType, SRGBColorSpace, RepeatWrapping } from "three";
2
- import { TEXTURE_CONSTANTS, MEMORY_CONSTANTS, DEFAULT_TEXTURE_MATRIX, MATERIAL_DATA_LAYOUT } from '../EngineDefaults.js';
2
+ import { TEXTURE_CONSTANTS, MEMORY_CONSTANTS, DEFAULT_TEXTURE_MATRIX, MATERIAL_DATA_LAYOUT, normalizeAttenuationDistance } from '../EngineDefaults.js';
3
3
  import TexturesWorker from './Workers/TexturesWorker.js?worker&inline';
4
4
 
5
5
  // Canvas pooling for efficient reuse of canvas elements
@@ -948,7 +948,7 @@ export class TextureCreator {
948
948
  // Slot 0: shadow core (ior, transmission, thickness, emissiveIntensity)
949
949
  mat.ior, mat.transmission, mat.thickness, mat.emissiveIntensity,
950
950
  // Slot 1: shadow (attenuationColor, attenuationDistance)
951
- mat.attenuationColor.r, mat.attenuationColor.g, mat.attenuationColor.b, mat.attenuationDistance,
951
+ mat.attenuationColor.r, mat.attenuationColor.g, mat.attenuationColor.b, normalizeAttenuationDistance( mat.attenuationDistance ),
952
952
  // Slot 2: shadow + culling (opacity, side, transparent, alphaTest)
953
953
  mat.opacity, mat.side, mat.transparent, mat.alphaTest,
954
954
  // Slot 3: shadow (alphaMode, depthWrite, normalScale)
@@ -8,7 +8,7 @@ import { Ray, HitInfo, RayTracingMaterial, UVCache } from '../TSL/Struct.js';
8
8
  import { traverseBVH } from '../TSL/BVHTraversal.js';
9
9
  import { cameraRayDirection } from '../TSL/CameraRay.js';
10
10
  import { getMaterial } from '../TSL/Common.js';
11
- import { computeUVCache, processNormal, processBump, buildBucketTextureNodes, refreshBucketTextureNodes, setMaterialBucketTextures } from '../TSL/TextureSampling.js';
11
+ import { computeUVCache, processNormal, processBump, triangleUVTangent, buildBucketTextureNodes, refreshBucketTextureNodes, setMaterialBucketTextures } from '../TSL/TextureSampling.js';
12
12
 
13
13
  /**
14
14
  * NormalDepth — primary-ray G-buffer for SVGF gates.
@@ -263,7 +263,16 @@ export class NormalDepth extends RenderStage {
263
263
  getMaterial( hit.materialIndex, matStorage )
264
264
  ).toVar();
265
265
  const uvCache = UVCache.wrap( computeUVCache( hit.uv, material ) ).toVar();
266
- const mapped = processNormal( hit.normal, material, uvCache ).toVar();
266
+ // Same UV tangent frame the Shade kernel uses, or the aux normal disagrees
267
+ // with the shading normal it is supposed to guide the denoiser with.
268
+ const uvTangent = vec4( 0.0 ).toVar();
269
+ If( material.normalMapIndex.greaterThanEqual( int( 0 ) ), () => {
270
+
271
+ uvTangent.assign( triangleUVTangent( triStorage, hit.triangleIndex, hit.normal, material.normalTransform ) );
272
+
273
+ } );
274
+
275
+ const mapped = processNormal( hit.normal, material, uvCache, uvTangent ).toVar();
267
276
  shadingNormal.assign( processBump( mapped, material, uvCache ) );
268
277
 
269
278
  } );
@@ -202,7 +202,7 @@ export const TraceDebugMode = Fn( ( [
202
202
 
203
203
  // Sample all textures to get emissive
204
204
  const matSamples = MaterialSamples.wrap( sampleAllMaterialTextures(
205
- material, hitInfo.uv, hitInfo.normal,
205
+ material, hitInfo.uv, hitInfo.normal, vec4( 0.0 ),
206
206
  ) ).toVar();
207
207
 
208
208
  const emissiveColor = matSamples.emissive.toVar();
@@ -246,7 +246,7 @@ export const TraceDebugMode = Fn( ( [
246
246
 
247
247
  // Get material-mapped normal (same as what's used in main shader)
248
248
  const matSamples = MaterialSamples.wrap( sampleAllMaterialTextures(
249
- material, hitInfo.uv, hitInfo.normal,
249
+ material, hitInfo.uv, hitInfo.normal, vec4( 0.0 ),
250
250
  ) ).toVar();
251
251
 
252
252
  const worldNormal = normalize( matSamples.normal );
@@ -292,7 +292,7 @@ export const TraceDebugMode = Fn( ( [
292
292
  const material = RayTracingMaterial.wrap( getMaterial( hitInfo.materialIndex, materialBuffer ) ).toVar();
293
293
 
294
294
  const matSamples = MaterialSamples.wrap( sampleAllMaterialTextures(
295
- material, hitInfo.uv, hitInfo.normal,
295
+ material, hitInfo.uv, hitInfo.normal, vec4( 0.0 ),
296
296
  ) ).toVar();
297
297
 
298
298
  const objectColor = matSamples.albedo.rgb;
@@ -318,7 +318,7 @@ export const TraceDebugMode = Fn( ( [
318
318
  // Get primary surface material
319
319
  const material = RayTracingMaterial.wrap( getMaterial( hitInfo.materialIndex, materialBuffer ) ).toVar();
320
320
  const matSamples = MaterialSamples.wrap( sampleAllMaterialTextures(
321
- material, hitInfo.uv, hitInfo.normal,
321
+ material, hitInfo.uv, hitInfo.normal, vec4( 0.0 ),
322
322
  ) ).toVar();
323
323
 
324
324
  const albedoA = matSamples.albedo.rgb;
@@ -364,7 +364,7 @@ export const TraceDebugMode = Fn( ( [
364
364
  // Bounce hit surface B — evaluate its contribution
365
365
  const materialB = RayTracingMaterial.wrap( getMaterial( bounceHit.materialIndex, materialBuffer ) ).toVar();
366
366
  const matSamplesB = MaterialSamples.wrap( sampleAllMaterialTextures(
367
- materialB, bounceHit.uv, bounceHit.normal,
367
+ materialB, bounceHit.uv, bounceHit.normal, vec4( 0.0 ),
368
368
  ) ).toVar();
369
369
 
370
370
  // Emissive contribution from surface B
@@ -141,57 +141,16 @@ export const sampleWavelengthForDispersion = Fn( ( [ baseIOR, dispersionStrength
141
141
  // ABSORPTION
142
142
  // ================================================================================
143
143
 
144
- // Apply Beer's law absorption
144
+ // Beer-Lambert transmittance over pathLength. attenuationDistance <= 0 (or non-finite,
145
+ // normalized to 0 by MaterialDataManager) means "no absorption".
145
146
  export const calculateBeerLawAbsorption = /*@__PURE__*/ wgslFn( `
146
- fn calculateBeerLawAbsorption( attenuationColor: vec3f, attenuationDistance: f32, thickness: f32 ) -> vec3f {
147
+ fn calculateBeerLawAbsorption( attenuationColor: vec3f, attenuationDistance: f32, pathLength: f32 ) -> vec3f {
147
148
  if ( attenuationDistance <= 0.0f ) { return vec3f( 1.0f ); }
148
- // Convert RGB attenuation color to absorption coefficients
149
149
  let absorption = -log( max( attenuationColor, vec3f( 0.001f ) ) ) / attenuationDistance;
150
- // Apply Beer's law
151
- return exp( -absorption * thickness );
150
+ return exp( -absorption * pathLength );
152
151
  }
153
152
  ` );
154
153
 
155
- // ================================================================================
156
- // SHADOW TRANSMITTANCE
157
- // ================================================================================
158
-
159
- export const calculateShadowTransmittance = Fn( ( [ rayDir, normal, material, entering ] ) => {
160
-
161
- const n1 = select( entering, float( 1.0 ), material.ior ).toVar();
162
- const n2 = select( entering, material.ior, float( 1.0 ) ).toVar();
163
-
164
- const cosThetaI = abs( dot( normal, rayDir ) );
165
- const sinThetaT2 = n1.mul( n1 ).div( n2.mul( n2 ) ).mul( float( 1.0 ).sub( cosThetaI.mul( cosThetaI ) ) );
166
-
167
- // Handle total internal reflection
168
- const result = float( 0.0 ).toVar();
169
-
170
- If( sinThetaT2.lessThanEqual( 1.0 ), () => {
171
-
172
- // Calculate Fresnel reflectance
173
- const F0 = iorToFresnel0( n2, n1 );
174
- const Fr = fresnelSchlickFloat( cosThetaI, F0 );
175
-
176
- // Base transmission: what gets through after Fresnel reflection
177
- const baseTransmission = float( 1.0 ).sub( Fr ).mul( material.transmission ).toVar();
178
-
179
- // Apply Beer's law absorption for exiting rays
180
- If( entering.not().and( material.attenuationDistance.greaterThan( 0.0 ) ), () => {
181
-
182
- const absorption = calculateBeerLawAbsorption( { attenuationColor: material.attenuationColor, attenuationDistance: material.attenuationDistance, thickness: material.thickness } );
183
- baseTransmission.assign( baseTransmission.mul( absorption.x.add( absorption.y ).add( absorption.z ).div( 3.0 ) ) );
184
-
185
- } );
186
-
187
- result.assign( clamp( baseTransmission, 0.0, 1.0 ) );
188
-
189
- } );
190
-
191
- return result;
192
-
193
- } );
194
-
195
154
  // ================================================================================
196
155
  // MICROFACET TRANSMISSION
197
156
  // ================================================================================
@@ -17,7 +17,7 @@ import {
17
17
  import { sampleEnvironment, sampleEquirectProbability, sampleEquirect, groundProjectedEnvDir } from './Environment.js';
18
18
  import { getMaterial, powerHeuristic, balanceHeuristic, classifyMaterial, REC709_LUMINANCE_COEFFICIENTS, PI_INV, EPSILON, diffuseGroundMaterial } from './Common.js';
19
19
  import { cosineWeightedSample } from './MaterialSampling.js';
20
- import { sampleAllMaterialTextures, processAnisotropyMap, applyExtensionMaps, getTransformedUV } from './TextureSampling.js';
20
+ import { sampleAllMaterialTextures, processAnisotropyMap, applyExtensionMaps, getTransformedUV, triangleUVTangent } from './TextureSampling.js';
21
21
  import { evaluateMaterialResponse } from './MaterialEvaluation.js';
22
22
  import { calculateDirectLightingUnified, calculateMaterialPDF } from './LightsSampling.js';
23
23
  import { traceShadowRay, calculateRayOffset } from './LightsDirect.js';
@@ -616,8 +616,17 @@ export function buildShadeKernel( params ) {
616
616
  }
617
617
  );
618
618
 
619
+ // Normal-map tangent frame. Only paid for when there is a normal map: the arbitrary
620
+ // cross(up, N) fallback inside processNormal is what runs otherwise.
621
+ const uvTangent = vec4( 0.0 ).toVar();
622
+ If( material.normalMapIndex.greaterThanEqual( int( 0 ) ), () => {
623
+
624
+ uvTangent.assign( triangleUVTangent( triangleBuffer, int( hitTriIdx ), N, material.normalTransform ) );
625
+
626
+ } );
627
+
619
628
  const matSamples = MaterialSamples.wrap( sampleAllMaterialTextures(
620
- material, samplingUV, N,
629
+ material, samplingUV, N, uvTangent,
621
630
  ) ).toVar();
622
631
 
623
632
  // DDFA: snapshot the UNCLAMPED roughness (before the min-0.05 clamp below) for the specular↔diffuse
@@ -1,4 +1,4 @@
1
- import { Fn, wgslFn, float, vec2, vec3, vec4, int, If, normalize, cross, abs, atan, mix, clamp, texture, textureSize } from 'three/tsl';
1
+ import { Fn, wgslFn, float, vec2, vec3, vec4, int, If, normalize, cross, dot, length, sign, abs, atan, mix, clamp, texture, textureSize } from 'three/tsl';
2
2
  import { DataArrayTexture, LinearFilter } from 'three';
3
3
 
4
4
  import {
@@ -6,6 +6,7 @@ import {
6
6
  MaterialSamples,
7
7
  ExtMapResult,
8
8
  } from './Struct.js';
9
+ import { getDatafromStorageBuffer } from './Common.js';
9
10
  import { TEXTURE_CONSTANTS } from '../EngineDefaults.js';
10
11
 
11
12
  // ================================================================================
@@ -382,7 +383,80 @@ export const processMetalnessRoughness = Fn( ( [ material, uvCache ] ) => {
382
383
 
383
384
  } );
384
385
 
385
- export const processNormal = Fn( ( [ geometryNormal, material, uvCache ] ) => {
386
+ // (t[1][1], t[0][1]) — the row of the inverse 2x2 linear part that maps dP/du onto dP/du',
387
+ // up to the determinant, which drops out under the normalize that follows.
388
+ const uvTransformJacobian = /*@__PURE__*/ wgslFn( `
389
+ fn uvTransformJacobian( t: mat3x3f ) -> vec2f {
390
+ return vec2f( t[1][1], t[0][1] );
391
+ }
392
+ ` );
393
+
394
+ /**
395
+ * Build the UV tangent for one triangle, as vec4( T.xyz, handedness ).
396
+ *
397
+ * Returned in the *transformed* UV space of `transform`, since that is the space the normal
398
+ * map is sampled in — the inverse of the transform's 2x2 linear part maps dP/du back onto
399
+ * dP/du'. A pure repeat/scale leaves the direction alone; a rotated transform does not.
400
+ * w == 0 signals a degenerate parameterisation, which processNormal treats as "no frame".
401
+ *
402
+ * @param {Node} triangleBuffer packed triangle storage (8 vec4 stride)
403
+ * @param {Node} triIndex absolute triangle index
404
+ * @param {Node} geometryNormal shading-space normal, for the handedness test
405
+ * @param {Node} transform the normal map's mat3 UV transform
406
+ */
407
+ export const triangleUVTangent = Fn( ( [ triangleBuffer, triIndex, geometryNormal, transform ] ) => {
408
+
409
+ const S = int( 8 );
410
+ const pA = getDatafromStorageBuffer( triangleBuffer, triIndex, int( 0 ), S ).xyz;
411
+ const e1 = getDatafromStorageBuffer( triangleBuffer, triIndex, int( 1 ), S ).xyz.sub( pA );
412
+ const e2 = getDatafromStorageBuffer( triangleBuffer, triIndex, int( 2 ), S ).xyz.sub( pA );
413
+
414
+ const uvAB = getDatafromStorageBuffer( triangleBuffer, triIndex, int( 6 ), S );
415
+ const uvC = getDatafromStorageBuffer( triangleBuffer, triIndex, int( 7 ), S ).xy;
416
+ const d1 = uvAB.zw.sub( uvAB.xy );
417
+ const d2 = uvC.sub( uvAB.xy );
418
+
419
+ const det = d1.x.mul( d2.y ).sub( d1.y.mul( d2.x ) );
420
+ const result = vec4( 0.0 ).toVar();
421
+
422
+ If( abs( det ).greaterThan( 1e-12 ), () => {
423
+
424
+ const r = float( 1.0 ).div( det );
425
+ const T = e1.mul( d2.y ).sub( e2.mul( d1.y ) ).mul( r ).toVar();
426
+ const B = e2.mul( d1.x ).sub( e1.mul( d2.x ) ).mul( r ).toVar();
427
+
428
+ // Re-express in the map's transformed UV space (inverse of the 2x2 linear part).
429
+ const uvJ = uvTransformJacobian( { t: transform } );
430
+ const Tt = T.mul( uvJ.x ).sub( B.mul( uvJ.y ) ).toVar();
431
+
432
+ If( length( Tt ).greaterThan( 1e-12 ), () => {
433
+
434
+ T.assign( Tt );
435
+
436
+ } );
437
+
438
+ If( length( T ).greaterThan( 1e-12 ), () => {
439
+
440
+ result.assign( vec4( normalize( T ), sign( dot( cross( geometryNormal, T ), B ) ) ) );
441
+
442
+ } );
443
+
444
+ } );
445
+
446
+ return result;
447
+
448
+ } );
449
+
450
+ /**
451
+ * Per-triangle tangent frame from the UV parameterisation.
452
+ *
453
+ * `uvTangent` is vec4( T.xyz, handedness ); w == 0 means "no usable frame" (degenerate or
454
+ * missing UVs) and falls back to the arbitrary cross(up, N) frame. A frame unrelated to the
455
+ * texture's U axis rotates the whole perturbation: on a vertical fluted-glass panel the
456
+ * arbitrary frame comes out 90 deg off, so the rib normals tilt up/down instead of across
457
+ * the ribs.
458
+ */
459
+ export const processNormal = Fn( ( [ geometryNormal, material, uvCache, uvTangent ] ) => {
386
460
 
387
461
  const result = geometryNormal.toVar();
388
462
 
@@ -393,10 +467,25 @@ export const processNormal = Fn( ( [ geometryNormal, material, uvCache ] ) => {
393
467
  normalMap.x.mulAssign( material.normalScale.x );
394
468
  normalMap.y.assign( normalMap.y.negate().mul( material.normalScale.x ) );
395
469
 
396
- // Fast TBN construction
397
- const up = abs( geometryNormal.z ).lessThan( 0.999 ).select( vec3( 0.0, 0.0, 1.0 ), vec3( 1.0, 0.0, 0.0 ) );
398
- const tangent = normalize( cross( up, geometryNormal ) );
399
- const bitangent = cross( geometryNormal, tangent );
470
+ const tangent = vec3( 0.0 ).toVar();
471
+ const handedness = float( 1.0 ).toVar();
472
+
473
+ // Gram-Schmidt against the interpolated normal, which is not exactly triangle-planar.
474
+ const perp = uvTangent.xyz.sub( geometryNormal.mul( dot( geometryNormal, uvTangent.xyz ) ) ).toVar();
475
+
476
+ If( abs( uvTangent.w ).greaterThan( 0.5 ).and( length( perp ).greaterThan( 1e-6 ) ), () => {
477
+
478
+ tangent.assign( normalize( perp ) );
479
+ handedness.assign( uvTangent.w );
480
+
481
+ } ).Else( () => {
482
+
483
+ const up = abs( geometryNormal.z ).lessThan( 0.999 ).select( vec3( 0.0, 0.0, 1.0 ), vec3( 1.0, 0.0, 0.0 ) );
484
+ tangent.assign( normalize( cross( up, geometryNormal ) ) );
485
+
486
+ } );
487
+
488
+ const bitangent = cross( geometryNormal, tangent ).mul( handedness );
400
489
 
401
490
  result.assign( normalize(
402
491
  tangent.mul( normalMap.x ).add( bitangent.mul( normalMap.y ) ).add( geometryNormal.mul( normalMap.z ) )
@@ -563,7 +652,7 @@ export const processEmissive = Fn( ( [ material, uvCache ] ) => {
563
652
  // MAIN BATCHED SAMPLING FUNCTION
564
653
  // ================================================================================
565
654
 
566
- export const sampleAllMaterialTextures = Fn( ( [ material, uv, geometryNormal ] ) => {
655
+ export const sampleAllMaterialTextures = Fn( ( [ material, uv, geometryNormal, uvTangent ] ) => {
567
656
 
568
657
  const albedo = vec4( 0.0 ).toVar();
569
658
  const emissive = vec3( 0.0 ).toVar();
@@ -591,7 +680,7 @@ export const sampleAllMaterialTextures = Fn( ( [ material, uv, geometryNormal ]
591
680
  metalness.assign( metalRough.x );
592
681
  roughness.assign( metalRough.y );
593
682
 
594
- const currentNormal = processNormal( geometryNormal, material, uvCache ).toVar();
683
+ const currentNormal = processNormal( geometryNormal, material, uvCache, uvTangent ).toVar();
595
684
  normal.assign( processBump( currentNormal, material, uvCache ) );
596
685
 
597
686
  emissive.assign( processEmissive( material, uvCache ) );
@@ -9,7 +9,7 @@
9
9
 
10
10
  import { StorageInstancedBufferAttribute } from 'three/webgpu';
11
11
  import { storage } from 'three/tsl';
12
- import { MATERIAL_DATA_LAYOUT as M, TRIANGLE_DATA_LAYOUT as T } from '../EngineDefaults.js';
12
+ import { MATERIAL_DATA_LAYOUT as M, TRIANGLE_DATA_LAYOUT as T, normalizeAttenuationDistance } from '../EngineDefaults.js';
13
13
  import { createLogger, fmt } from '../utils/Logger.js';
14
14
 
15
15
  const log = createLogger( 'material' );
@@ -23,6 +23,32 @@ const TRI_BLOCKER_OFFSET = T.NORMAL_A_OFFSET + 3; // nA.w in shader (opaque-bloc
23
23
  // Material properties that affect the shadow-ray opaque-blocker flag.
24
24
  const BLOCKER_PROPS = new Set( [ 'transmission', 'transparent', 'opacity', 'alphaMode' ] );
25
25
 
26
+ // Scalar slots readable via getMaterialProperty().
27
+ const SCALAR_PROPERTY_OFFSETS = {
28
+ ior: M.IOR,
29
+ transmission: M.TRANSMISSION,
30
+ thickness: M.THICKNESS,
31
+ emissiveIntensity: M.EMISSIVE_INTENSITY,
32
+ attenuationDistance: M.ATTENUATION_DISTANCE,
33
+ opacity: M.OPACITY,
34
+ alphaTest: M.ALPHA_TEST,
35
+ metalness: M.METALNESS,
36
+ roughness: M.ROUGHNESS,
37
+ clearcoat: M.CLEARCOAT,
38
+ clearcoatRoughness: M.CLEARCOAT_ROUGHNESS,
39
+ dispersion: M.DISPERSION,
40
+ sheen: M.SHEEN,
41
+ sheenRoughness: M.SHEEN_ROUGHNESS,
42
+ specularIntensity: M.SPECULAR_INTENSITY,
43
+ iridescence: M.IRIDESCENCE,
44
+ iridescenceIOR: M.IRIDESCENCE_IOR,
45
+ subsurface: M.SUBSURFACE,
46
+ subsurfaceRadiusScale: M.SUBSURFACE_RADIUS_SCALE,
47
+ subsurfaceAnisotropy: M.SUBSURFACE_ANISOTROPY,
48
+ anisotropy: M.ANISOTROPY,
49
+ anisotropyRotation: M.ANISOTROPY_ROTATION, // radians, unlike the degrees hosts usually show
50
+ };
51
+
26
52
  export class MaterialDataManager {
27
53
 
28
54
  /**
@@ -179,6 +205,28 @@ export class MaterialDataManager {
179
205
 
180
206
  // ===== MATERIAL PROPERTY UPDATES =====
181
207
 
208
+ /**
209
+ * Read back a scalar material property from the storage buffer — the value the shader
210
+ * actually uses. Hosts need this because the engine resolves defaults the three.js
211
+ * material never carries (e.g. IOR on a MeshStandardMaterial), so a UI that falls back
212
+ * to its own default displays a number the renderer is not using.
213
+ * @param {number} materialIndex
214
+ * @param {string} property
215
+ * @returns {number|undefined} undefined if unavailable or not a scalar slot
216
+ */
217
+ getMaterialProperty( materialIndex, property ) {
218
+
219
+ const data = this.materialStorageAttr?.array;
220
+ if ( ! data ) return undefined;
221
+
222
+ const offset = SCALAR_PROPERTY_OFFSETS[ property ];
223
+ if ( offset === undefined ) return undefined;
224
+
225
+ const index = materialIndex * M.FLOATS_PER_MATERIAL + offset;
226
+ return index < data.length ? data[ index ] : undefined;
227
+
228
+ }
229
+
182
230
  /**
183
231
  * Update a single material property in the storage buffer.
184
232
  * @param {number} materialIndex
@@ -253,7 +301,7 @@ export class MaterialDataManager {
253
301
  }
254
302
 
255
303
  break;
256
- case 'attenuationDistance': data[ stride + M.ATTENUATION_DISTANCE ] = value; break;
304
+ case 'attenuationDistance': data[ stride + M.ATTENUATION_DISTANCE ] = normalizeAttenuationDistance( value ); break;
257
305
  case 'dispersion': data[ stride + M.DISPERSION ] = value; break;
258
306
  case 'sheen': data[ stride + M.SHEEN ] = value; break;
259
307
  case 'sheenRoughness': data[ stride + M.SHEEN_ROUGHNESS ] = value; break;
@@ -446,7 +494,7 @@ export class MaterialDataManager {
446
494
 
447
495
  }
448
496
 
449
- data[ stride + M.ATTENUATION_DISTANCE ] = materialData.attenuationDistance ?? Infinity;
497
+ data[ stride + M.ATTENUATION_DISTANCE ] = normalizeAttenuationDistance( materialData.attenuationDistance );
450
498
  data[ stride + M.DISPERSION ] = materialData.dispersion ?? 0;
451
499
  data[ stride + M.VISIBLE ] = 1; // Reserved slot (per-mesh visibility handled at BLAS-pointer level)
452
500
  data[ stride + M.SHEEN ] = materialData.sheen ?? 0;