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/README.md +1 -1
- package/dist/rayzee.es.js +3314 -1861
- package/dist/rayzee.es.js.map +1 -1
- package/dist/rayzee.umd.js +27 -25
- package/dist/rayzee.umd.js.map +1 -1
- package/package.json +1 -1
- package/src/EngineDefaults.js +17 -4
- package/src/PathTracerApp.js +1 -0
- package/src/Processor/AssetLoader.js +151 -1
- package/src/Processor/GeometryExtractor.js +16 -1
- package/src/Processor/PBRT/PBRTMaterials.js +350 -0
- package/src/Processor/PBRT/PBRTMath.js +173 -0
- package/src/Processor/PBRT/PBRTParser.js +642 -0
- package/src/Processor/PBRT/PBRTSceneBuilder.js +578 -0
- package/src/Processor/PBRT/PBRTTokenizer.js +141 -0
- package/src/Processor/PBRT/index.js +179 -0
- package/src/Processor/ShaderBuilder.js +1 -0
- package/src/Processor/TextureCreator.js +6 -0
- package/src/RenderSettings.js +1 -0
- package/src/TSL/BVHTraversal.js +7 -1
- package/src/TSL/Common.js +12 -2
- package/src/TSL/MaterialTransmission.js +32 -2
- package/src/TSL/PathTracer.js +2 -2
- package/src/TSL/PathTracerCore.js +151 -12
- package/src/TSL/Struct.js +5 -0
- package/src/TSL/Subsurface.js +232 -0
- package/src/managers/MaterialDataManager.js +60 -1
- package/src/managers/UniformManager.js +1 -0
package/package.json
CHANGED
package/src/EngineDefaults.js
CHANGED
|
@@ -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:
|
|
360
|
-
FLOATS_PER_MATERIAL:
|
|
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:
|
|
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,
|
package/src/PathTracerApp.js
CHANGED
|
@@ -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,
|
|
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
|
+
}
|