rayzee 6.2.0 → 6.4.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.2.0",
3
+ "version": "6.4.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",
@@ -146,35 +146,46 @@ export const ENGINE_DEFAULTS = {
146
146
  autoExposureAdaptSpeedDark: 0.5,
147
147
  };
148
148
 
149
+ // Albedo demodulation safety floor. ASVGF and BilateralFilter MUST use the
150
+ // same value — demod (`color / safeAlbedo`) and remod (`lighting * safeAlbedo`)
151
+ // only round-trip exactly when both sides agree.
152
+ export const ALBEDO_EPS = 0.01;
153
+
149
154
  export const ASVGF_QUALITY_PRESETS = {
155
+ // phiColor / phiDepth are RELATIVE tolerances (fractions). Bigger = more
156
+ // permissive. gradientStrength = 0 keeps the adaptive-α boost off; the
157
+ // fixed-floor gradient misfires on 1-SPP noise. Pure SVGF temporal runs.
150
158
  low: {
151
- temporalAlpha: 0.3,
152
- atrousIterations: 1,
153
- phiColor: 30.0,
159
+ temporalAlpha: 0.1,
160
+ gradientStrength: 0.0,
161
+ atrousIterations: 3,
162
+ phiColor: 1.0,
154
163
  phiNormal: 64.0,
155
- phiDepth: 2.0,
164
+ phiDepth: 0.1,
156
165
  phiLuminance: 6.0,
157
- maxAccumFrames: 8,
166
+ maxAccumFrames: 16,
158
167
  varianceBoost: 0.5
159
168
  },
160
169
  medium: {
161
- temporalAlpha: 0.1,
162
- atrousIterations: 3,
163
- phiColor: 20.0,
170
+ temporalAlpha: 0.03,
171
+ gradientStrength: 0.0,
172
+ atrousIterations: 4,
173
+ phiColor: 0.5,
164
174
  phiNormal: 128.0,
165
- phiDepth: 1.0,
166
- phiLuminance: 2.0,
167
- maxAccumFrames: 32,
175
+ phiDepth: 0.05,
176
+ phiLuminance: 4.0,
177
+ maxAccumFrames: 64,
168
178
  varianceBoost: 1.0
169
179
  },
170
180
  high: {
171
- temporalAlpha: 0.05,
172
- atrousIterations: 8,
173
- phiColor: 5.0,
181
+ temporalAlpha: 0.0,
182
+ gradientStrength: 0.0,
183
+ atrousIterations: 6,
184
+ phiColor: 0.3,
174
185
  phiNormal: 256.0,
175
- phiDepth: 0.5,
186
+ phiDepth: 0.02,
176
187
  phiLuminance: 2.0,
177
- maxAccumFrames: 64,
188
+ maxAccumFrames: 128,
178
189
  varianceBoost: 1.5
179
190
  }
180
191
  };
@@ -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 = [];
@@ -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
+ }