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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rayzee",
3
- "version": "6.3.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 ) );
@@ -1,5 +1,6 @@
1
1
  import { Box3, Vector3, RectAreaLight, Color, FloatType, LinearFilter, EquirectangularReflectionMapping,
2
- TextureLoader, Mesh, MeshStandardMaterial, MeshPhysicalMaterial, CircleGeometry, Points, PointsMaterial, LoadingManager, EventDispatcher
2
+ TextureLoader, Texture, SRGBColorSpace, RepeatWrapping, Mesh, MeshStandardMaterial, MeshPhysicalMaterial,
3
+ CircleGeometry, Points, PointsMaterial, LoadingManager, EventDispatcher
3
4
  } from 'three';
4
5
  import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
5
6
  import { HDRLoader } from 'three/addons/loaders/HDRLoader.js';
@@ -12,6 +13,7 @@ import { unzipSync, strFromU8 } from 'three/addons/libs/fflate.module.js';
12
13
  import { disposeObjectFromMemory, updateLoading } from './utils';
13
14
  import { BuildTimer } from './BuildTimer.js';
14
15
  import { getAssetConfig } from '../AssetConfig.js';
16
+ import { loadPBRTScene, pickEntryPath } from './PBRT/index.js';
15
17
 
16
18
  // Define supported file formats
17
19
  const SUPPORTED_FORMATS = {
@@ -314,6 +316,10 @@ export class AssetLoader extends EventDispatcher {
314
316
 
315
317
  const arrayBuffer = await this.readFileAsArrayBuffer( file );
316
318
  const zip = unzipSync( new Uint8Array( arrayBuffer ) );
319
+
320
+ // A pbrt scene archive takes priority — it owns its own geometry/texture refs.
321
+ if ( pickEntryPath( zip ) ) return await this.loadPBRTFromZip( zip, filename );
322
+
317
323
  const result = await this.processObjMtlPairsInZip( zip, filename );
318
324
  if ( result ) return result;
319
325
  return await this.findAndLoadModelFromZip( zip, filename );
@@ -327,6 +333,150 @@ export class AssetLoader extends EventDispatcher {
327
333
 
328
334
  }
329
335
 
336
+ /**
337
+ * Loads a pbrt-v4 scene from an unzipped archive. Parses the entry .pbrt
338
+ * (following Include/Import), builds a THREE.Group, sets the infinite light
339
+ * as the scene environment, and runs the standard onModelLoad pipeline.
340
+ * @param {Object<string, Uint8Array>} zip - unzipped entries (path → bytes)
341
+ * @param {string} filename - original archive name (for display/events)
342
+ */
343
+ async loadPBRTFromZip( zip, filename ) {
344
+
345
+ updateLoading( { isLoading: true, status: 'Parsing PBRT scene...', progress: 5 } );
346
+
347
+ // Geometry decoder — reuse the cached PLYLoader (pbrt leans on .ply meshes).
348
+ if ( ! this.loaderCache.ply ) {
349
+
350
+ const { PLYLoader } = await import( 'three/examples/jsm/loaders/PLYLoader.js' );
351
+ this.loaderCache.ply = new PLYLoader();
352
+
353
+ }
354
+
355
+ const plyParser = ( buf ) => this.loaderCache.ply.parse( buf );
356
+
357
+ // Texture maps — decode by extension (pbrt uses .png/.jpg but also .exr/.hdr/.tga).
358
+ const imageFromBytes = ( bytes, fname ) => this._pbrtTextureFromBytes( bytes, fname );
359
+
360
+ // Infinite-light maps → HDR/EXR/LDR via the shared environment decoder.
361
+ const envFromBytes = async ( bytes, fname ) => {
362
+
363
+ const ext = fname.split( '.' ).pop().toLowerCase();
364
+ const url = URL.createObjectURL( new Blob( [ bytes ] ) );
365
+ try {
366
+
367
+ return await this.loadEnvironmentByExtension( url, ext );
368
+
369
+ } finally {
370
+
371
+ URL.revokeObjectURL( url );
372
+
373
+ }
374
+
375
+ };
376
+
377
+ const { group, environment, report, warnings, entryPath } = await loadPBRTScene( {
378
+ vfs: zip, plyParser, imageFromBytes, envFromBytes
379
+ } );
380
+
381
+ // Diagnostics — surface what each mesh resolved to (helps debug black/wrong materials).
382
+ if ( report && report.length && typeof console.table === 'function' ) {
383
+
384
+ console.groupCollapsed( `PBRT loader: ${report.length} mesh(es) from "${entryPath}"` );
385
+ console.table( report );
386
+ console.groupEnd();
387
+
388
+ }
389
+
390
+ if ( warnings && warnings.length ) {
391
+
392
+ console.warn( `PBRT loader: ${warnings.length} warning(s) parsing "${entryPath}"` );
393
+ warnings.forEach( w => console.warn( ' •', w ) );
394
+
395
+ }
396
+
397
+ // Infinite light → scene environment (CDF is built later in loadSceneData).
398
+ if ( environment?.texture ) {
399
+
400
+ environment.texture.generateMipmaps = true;
401
+ this.applyEnvironmentToScene( environment.texture );
402
+
403
+ }
404
+
405
+ group.name = entryPath || filename;
406
+ this.releaseTargetModel();
407
+ this.targetModel = group;
408
+
409
+ updateLoading( { isLoading: true, status: 'Processing PBRT geometry...', progress: 10 } );
410
+ await this.onModelLoad( this.targetModel );
411
+
412
+ this.dispatchEvent( { type: 'load', model: group, filename: `${entryPath} (from ZIP)` } );
413
+ return group;
414
+
415
+ }
416
+
417
+ /**
418
+ * Decodes a pbrt texture map from raw bytes, picking a decoder by extension.
419
+ * ImageBitmap can't handle EXR/HDR/TGA, which pbrt scenes use freely.
420
+ * @param {Uint8Array} bytes
421
+ * @param {string} fname
422
+ * @returns {Promise<import('three').Texture>}
423
+ */
424
+ async _pbrtTextureFromBytes( bytes, fname ) {
425
+
426
+ const ext = fname.split( '.' ).pop().toLowerCase();
427
+ let tex;
428
+
429
+ if ( ext === 'exr' || ext === 'hdr' ) {
430
+
431
+ const loader = ext === 'hdr'
432
+ ? ( this.loaderCache.hdr || ( this.loaderCache.hdr = new HDRLoader().setDataType( FloatType ) ) )
433
+ : ( this.loaderCache.exr || ( this.loaderCache.exr = new EXRLoader().setDataType( FloatType ) ) );
434
+ tex = await this._loadViaObjectURL( loader, bytes );
435
+ // HDR/EXR maps are linear — leave colorSpace as the loader set it.
436
+
437
+ } else if ( ext === 'tga' ) {
438
+
439
+ if ( ! this.loaderCache.tga ) {
440
+
441
+ const { TGALoader } = await import( 'three/examples/jsm/loaders/TGALoader.js' );
442
+ this.loaderCache.tga = new TGALoader();
443
+
444
+ }
445
+
446
+ tex = await this._loadViaObjectURL( this.loaderCache.tga, bytes );
447
+ tex.colorSpace = SRGBColorSpace;
448
+
449
+ } else {
450
+
451
+ // png / jpg / webp / gif / bmp
452
+ const bitmap = await createImageBitmap( new Blob( [ bytes ] ) );
453
+ tex = new Texture( bitmap );
454
+ tex.colorSpace = SRGBColorSpace;
455
+
456
+ }
457
+
458
+ tex.wrapS = tex.wrapT = RepeatWrapping;
459
+ tex.needsUpdate = true;
460
+ return tex;
461
+
462
+ }
463
+
464
+ /** Decode bytes through a three loader's loadAsync via a transient object URL. */
465
+ async _loadViaObjectURL( loader, bytes ) {
466
+
467
+ const url = URL.createObjectURL( new Blob( [ bytes ] ) );
468
+ try {
469
+
470
+ return await loader.loadAsync( url );
471
+
472
+ } finally {
473
+
474
+ URL.revokeObjectURL( url );
475
+
476
+ }
477
+
478
+ }
479
+
330
480
  async processObjMtlPairsInZip( zip, filename ) {
331
481
 
332
482
  const objFiles = [];
@@ -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
  };
@@ -0,0 +1,350 @@
1
+ /**
2
+ * Translate pbrt-v4 materials and textures to THREE.MeshPhysicalMaterial.
3
+ *
4
+ * pbrt's BxDF model (diffuse / conductor / dielectric / coateddiffuse / ...) is
5
+ * mapped onto the Disney-style parameters the engine reads off a
6
+ * MeshPhysicalMaterial (see GeometryExtractor.createMaterialObject). The mapping
7
+ * is intentionally lossy — spectral data, measured BRDFs, and layered BxDFs are
8
+ * approximated to RGB. Anything unrecognized falls back to a neutral diffuse.
9
+ */
10
+
11
+ import { MeshPhysicalMaterial, Color, DoubleSide } from 'three';
12
+
13
+ // Normal-incidence reflectance approximations for pbrt's named conductor spectra.
14
+ const METAL_ALBEDO = {
15
+ au: [ 1.0, 0.78, 0.34 ], gold: [ 1.0, 0.78, 0.34 ],
16
+ cu: [ 0.95, 0.64, 0.54 ], copper: [ 0.95, 0.64, 0.54 ],
17
+ ag: [ 0.97, 0.96, 0.91 ], silver: [ 0.97, 0.96, 0.91 ],
18
+ al: [ 0.91, 0.92, 0.92 ], aluminium: [ 0.91, 0.92, 0.92 ], aluminum: [ 0.91, 0.92, 0.92 ],
19
+ mgo: [ 0.9, 0.9, 0.9 ], tio2: [ 0.9, 0.9, 0.9 ]
20
+ };
21
+
22
+ const DEFAULT_METAL = [ 0.92, 0.92, 0.92 ];
23
+
24
+ /** Crude blackbody-temperature → linear RGB (Planckian locus approximation). */
25
+ function blackbodyToRGB( kelvin ) {
26
+
27
+ const t = Math.max( 1000, Math.min( 40000, kelvin ) ) / 100;
28
+ let r, g, b;
29
+
30
+ if ( t <= 66 ) {
31
+
32
+ r = 255;
33
+ g = 99.47 * Math.log( t ) - 161.12;
34
+
35
+ } else {
36
+
37
+ r = 329.7 * Math.pow( t - 60, - 0.1332 );
38
+ g = 288.12 * Math.pow( t - 60, - 0.0755 );
39
+
40
+ }
41
+
42
+ if ( t >= 66 ) b = 255;
43
+ else if ( t <= 19 ) b = 0;
44
+ else b = 138.52 * Math.log( t - 10 ) - 305.04;
45
+
46
+ const clamp = v => Math.max( 0, Math.min( 255, v ) ) / 255;
47
+ // sRGB → approx linear
48
+ return [ clamp( r ) ** 2.2, clamp( g ) ** 2.2, clamp( b ) ** 2.2 ];
49
+
50
+ }
51
+
52
+ // ── param accessors ────────────────────────────────────────────────
53
+
54
+ export function pFloat( params, name, dflt ) {
55
+
56
+ const p = params[ name ];
57
+ return p && typeof p.value[ 0 ] === 'number' ? p.value[ 0 ] : dflt;
58
+
59
+ }
60
+
61
+ export function pString( params, name, dflt ) {
62
+
63
+ const p = params[ name ];
64
+ return p && typeof p.value[ 0 ] === 'string' ? p.value[ 0 ] : dflt;
65
+
66
+ }
67
+
68
+ /**
69
+ * Resolve a spectrum/color/float-valued parameter to an RGB triple and/or a
70
+ * texture. Returns `{ rgb, texture }` — exactly one is typically non-null.
71
+ * @returns {Promise<{rgb:number[]|null, texture:import('three').Texture|null}>}
72
+ */
73
+ export async function resolveSpectrum( params, name, ctx, dfltRGB = null ) {
74
+
75
+ const p = params[ name ];
76
+ if ( ! p ) return { rgb: dfltRGB, texture: null };
77
+
78
+ // Reference to a named texture.
79
+ if ( p.type === 'texture' ) {
80
+
81
+ const texName = p.value[ 0 ];
82
+ const tex = await ctx.resolveNamedTexture( texName );
83
+ if ( tex && tex.texture ) return { rgb: tex.constant ?? null, texture: tex.texture };
84
+ if ( tex && tex.constant ) return { rgb: tex.constant, texture: null };
85
+ ctx.warn( `texture "${texName}" could not be resolved` );
86
+ return { rgb: dfltRGB, texture: null };
87
+
88
+ }
89
+
90
+ if ( p.type === 'rgb' || p.type === 'color' ) {
91
+
92
+ return { rgb: [ p.value[ 0 ], p.value[ 1 ], p.value[ 2 ] ], texture: null };
93
+
94
+ }
95
+
96
+ if ( p.type === 'float' ) {
97
+
98
+ const v = p.value[ 0 ];
99
+ return { rgb: [ v, v, v ], texture: null };
100
+
101
+ }
102
+
103
+ if ( p.type === 'blackbody' ) {
104
+
105
+ return { rgb: blackbodyToRGB( p.value[ 0 ] ), texture: null };
106
+
107
+ }
108
+
109
+ if ( p.type === 'spectrum' ) {
110
+
111
+ // Named spectrum (e.g. "metal-Au-eta") or sampled [lambda val ...].
112
+ if ( typeof p.value[ 0 ] === 'string' ) {
113
+
114
+ const key = namedMetalKey( p.value[ 0 ] );
115
+ if ( key && METAL_ALBEDO[ key ] ) return { rgb: METAL_ALBEDO[ key ].slice(), texture: null };
116
+ ctx.warn( `named spectrum "${p.value[ 0 ]}" approximated to gray` );
117
+ return { rgb: [ 0.5, 0.5, 0.5 ], texture: null };
118
+
119
+ }
120
+
121
+ // Sampled spectrum → average value as gray (coarse).
122
+ let sum = 0, count = 0;
123
+ for ( let i = 1; i < p.value.length; i += 2 ) {
124
+
125
+ sum += p.value[ i ]; count ++;
126
+
127
+ }
128
+
129
+ const v = count ? sum / count : 0.5;
130
+ return { rgb: [ v, v, v ], texture: null };
131
+
132
+ }
133
+
134
+ return { rgb: dfltRGB, texture: null };
135
+
136
+ }
137
+
138
+ function namedMetalKey( s ) {
139
+
140
+ const m = s.toLowerCase().match( /metal-([a-z]+)/ );
141
+ if ( m ) return m[ 1 ];
142
+ for ( const k of Object.keys( METAL_ALBEDO ) ) if ( s.toLowerCase().includes( k ) ) return k;
143
+ return null;
144
+
145
+ }
146
+
147
+ /** Roughness from `roughness` or anisotropic `uroughness`/`vroughness`. */
148
+ function resolveRoughness( params, dflt ) {
149
+
150
+ if ( params.roughness && typeof params.roughness.value[ 0 ] === 'number' ) return params.roughness.value[ 0 ];
151
+ const u = pFloat( params, 'uroughness', null );
152
+ const v = pFloat( params, 'vroughness', null );
153
+ if ( u !== null && v !== null ) return ( u + v ) / 2;
154
+ if ( u !== null ) return u;
155
+ return dflt;
156
+
157
+ }
158
+
159
+ /**
160
+ * Build a MeshPhysicalMaterial from a pbrt material definition.
161
+ * @param {{type:string, params:object}} def
162
+ * @param {object} ctx - { resolveNamedTexture, warn }
163
+ * @returns {Promise<MeshPhysicalMaterial>}
164
+ */
165
+ export async function buildMaterial( def, ctx ) {
166
+
167
+ const type = def?.type || 'diffuse';
168
+ const params = def?.params || {};
169
+ const mat = new MeshPhysicalMaterial( { side: DoubleSide, roughness: 1, metalness: 0 } );
170
+
171
+ const setColor = ( rgb ) => {
172
+
173
+ if ( rgb ) mat.color.setRGB( rgb[ 0 ], rgb[ 1 ], rgb[ 2 ] );
174
+
175
+ };
176
+
177
+ // Apply a resolved reflectance to the base color + map. A `scale` texture
178
+ // yields BOTH an rgb tint and a texture — keep the tint as color so three
179
+ // multiplies map×color. A plain imagemap (no tint) neutralizes color to white.
180
+ const applyAlbedo = ( refl ) => {
181
+
182
+ setColor( refl.rgb );
183
+ if ( refl.texture ) {
184
+
185
+ mat.map = refl.texture;
186
+ if ( ! refl.rgb ) mat.color.setRGB( 1, 1, 1 );
187
+
188
+ }
189
+
190
+ };
191
+
192
+ switch ( type ) {
193
+
194
+ case 'diffuse': {
195
+
196
+ applyAlbedo( await resolveSpectrum( params, 'reflectance', ctx, [ 0.5, 0.5, 0.5 ] ) );
197
+ mat.roughness = 1;
198
+ mat.metalness = 0;
199
+ break;
200
+
201
+ }
202
+
203
+ case 'conductor':
204
+ case 'metal': {
205
+
206
+ const refl = await resolveSpectrum( params, 'reflectance', ctx, null );
207
+ const etaP = params.eta, kP = params.k;
208
+ const etaNamed = etaP && etaP.type === 'spectrum' && typeof etaP.value[ 0 ] === 'string';
209
+
210
+ if ( refl.rgb || refl.texture ) {
211
+
212
+ applyAlbedo( refl );
213
+
214
+ } else if ( etaP && kP && ! etaNamed ) {
215
+
216
+ // Normal-incidence reflectance from complex IOR: ((η-1)²+k²)/((η+1)²+k²).
217
+ const eta = ( await resolveSpectrum( params, 'eta', ctx, [ 0.2, 0.92, 1.1 ] ) ).rgb;
218
+ const k = ( await resolveSpectrum( params, 'k', ctx, [ 3.9, 2.45, 2.14 ] ) ).rgb;
219
+ const fr = ( n, kk ) => ( ( n - 1 ) ** 2 + kk ** 2 ) / ( ( n + 1 ) ** 2 + kk ** 2 );
220
+ setColor( [ fr( eta[ 0 ], k[ 0 ] ), fr( eta[ 1 ], k[ 1 ] ), fr( eta[ 2 ], k[ 2 ] ) ] );
221
+
222
+ } else if ( etaP ) {
223
+
224
+ // Named conductor spectrum → metal albedo table.
225
+ setColor( ( await resolveSpectrum( params, 'eta', ctx, DEFAULT_METAL ) ).rgb || DEFAULT_METAL );
226
+
227
+ } else {
228
+
229
+ setColor( METAL_ALBEDO.cu ); // pbrt-v4 default conductor is copper
230
+
231
+ }
232
+
233
+ mat.metalness = 1;
234
+ mat.roughness = resolveRoughness( params, 0.1 );
235
+ break;
236
+
237
+ }
238
+
239
+ case 'dielectric':
240
+ case 'thindielectric': {
241
+
242
+ mat.transmission = 1;
243
+ mat.metalness = 0;
244
+ mat.color.setRGB( 1, 1, 1 );
245
+ mat.ior = pFloat( params, 'eta', 1.5 );
246
+ mat.roughness = resolveRoughness( params, 0 );
247
+ mat.thickness = type === 'thindielectric' ? 0 : pFloat( params, 'thickness', 0 );
248
+ break;
249
+
250
+ }
251
+
252
+ case 'coateddiffuse': {
253
+
254
+ applyAlbedo( await resolveSpectrum( params, 'reflectance', ctx, [ 0.5, 0.5, 0.5 ] ) );
255
+ mat.roughness = 0.6;
256
+ mat.metalness = 0;
257
+ mat.clearcoat = 1;
258
+ mat.clearcoatRoughness = resolveRoughness( params, 0 );
259
+ break;
260
+
261
+ }
262
+
263
+ case 'diffusetransmission': {
264
+
265
+ const trans = await resolveSpectrum( params, 'transmittance', ctx, [ 0.25, 0.25, 0.25 ] );
266
+ applyAlbedo( await resolveSpectrum( params, 'reflectance', ctx, [ 0.25, 0.25, 0.25 ] ) );
267
+ mat.transmission = trans.rgb ? ( trans.rgb[ 0 ] + trans.rgb[ 1 ] + trans.rgb[ 2 ] ) / 3 : 0.5;
268
+ mat.roughness = 1;
269
+ mat.ior = 1.0;
270
+ break;
271
+
272
+ }
273
+
274
+ case 'interface':
275
+ case 'none':
276
+ case '': {
277
+
278
+ // Medium boundary with no surface scattering — render as clear passthrough.
279
+ mat.transmission = 1;
280
+ mat.ior = 1.0;
281
+ mat.roughness = 0;
282
+ mat.color.setRGB( 1, 1, 1 );
283
+ break;
284
+
285
+ }
286
+
287
+ case 'mix': {
288
+
289
+ // pbrt blends two named materials by `amount`. Without a real layered BxDF
290
+ // we lerp the two resolved MeshPhysicalMaterials' scalar/color properties
291
+ // — physically loose but visually faithful, and crucially silent on success
292
+ // instead of warning per shape. Recursive: mix-in-mix terminates as the
293
+ // chain bottoms out at a non-mix.
294
+ const matNames = params.materials?.value || [];
295
+ const t = Math.max( 0, Math.min( 1, pFloat( params, 'amount', 0.5 ) ) );
296
+ const defA = matNames[ 0 ] ? ctx.namedMaterials?.get( matNames[ 0 ] ) : null;
297
+ const defB = matNames[ 1 ] ? ctx.namedMaterials?.get( matNames[ 1 ] ) : null;
298
+
299
+ if ( defA && defB ) {
300
+
301
+ const [ matA, matB ] = await Promise.all( [ buildMaterial( defA, ctx ), buildMaterial( defB, ctx ) ] );
302
+ const lerp = ( a, b ) => a * ( 1 - t ) + b * t;
303
+ mat.color.lerpColors( matA.color, matB.color, t );
304
+ mat.roughness = lerp( matA.roughness, matB.roughness );
305
+ mat.metalness = lerp( matA.metalness, matB.metalness );
306
+ mat.ior = lerp( matA.ior ?? 1.5, matB.ior ?? 1.5 );
307
+ mat.transmission = lerp( matA.transmission ?? 0, matB.transmission ?? 0 );
308
+ mat.thickness = lerp( matA.thickness ?? 0, matB.thickness ?? 0 );
309
+ mat.clearcoat = lerp( matA.clearcoat ?? 0, matB.clearcoat ?? 0 );
310
+ mat.clearcoatRoughness = lerp( matA.clearcoatRoughness ?? 0, matB.clearcoatRoughness ?? 0 );
311
+ mat.emissive.lerpColors( matA.emissive, matB.emissive, t );
312
+ mat.emissiveIntensity = lerp( matA.emissiveIntensity ?? 0, matB.emissiveIntensity ?? 0 );
313
+ // Maps can't be lerped — pick the dominant side.
314
+ mat.map = ( t < 0.5 ? matA.map : matB.map ) || null;
315
+ break;
316
+
317
+ }
318
+
319
+ ctx.warn( `material "mix" — could not resolve inner materials [${matNames.join( ', ' )}], falling back to diffuse` );
320
+ mat.color.set( new Color( 0.6, 0.6, 0.6 ) );
321
+ break;
322
+
323
+ }
324
+
325
+ case 'subsurface':
326
+ case 'hair':
327
+ case 'measured': {
328
+
329
+ ctx.warn( `material "${type}" not supported — using diffuse approximation` );
330
+ const refl = await resolveSpectrum( params, 'reflectance', ctx, [ 0.5, 0.5, 0.5 ] );
331
+ setColor( refl.rgb );
332
+ mat.roughness = 1;
333
+ break;
334
+
335
+ }
336
+
337
+ default: {
338
+
339
+ ctx.warn( `unknown material "${type}" — using neutral diffuse` );
340
+ mat.color.set( new Color( 0.6, 0.6, 0.6 ) );
341
+ break;
342
+
343
+ }
344
+
345
+ }
346
+
347
+ mat.roughness = Math.max( 0, Math.min( 1, mat.roughness ) );
348
+ return mat;
349
+
350
+ }