rayzee 11.7.0 → 11.8.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.
Files changed (59) hide show
  1. package/README.md +13 -0
  2. package/dist/{ArchiveImporter-DYcRXeKE.js → ArchiveImporter-_sJm5uaK.js} +1923 -1243
  3. package/dist/{BidirectionalIntegrator-BvjRpbTP.js → BidirectionalIntegrator-Dzt4Z97x.js} +290 -289
  4. package/dist/{ColorManagement-oeEk7oSu.js → ColorManagement-30MfpHwQ.js} +1 -1
  5. package/dist/{EngineIssues-CIZnXHsF.js → EngineIssues-xljoXM0E.js} +18 -8
  6. package/dist/{NeuralRetouch-ClaMiKYg.js → NeuralRetouch-BKQCsmEs.js} +2 -2
  7. package/dist/{NeuralSuperRes-WNEXun9s.js → NeuralSuperRes-C4ymq68e.js} +2 -2
  8. package/dist/{PathTracer-B9wZPPVL.js → PathTracer-C_to_i40.js} +3156 -3098
  9. package/dist/{PhysicalSky-DcjItqKi.js → PhysicalSky-bMHbm_7H.js} +1 -1
  10. package/dist/{RayzeeRenderer-p-NeE6E2.js → RayzeeRenderer-B_aB6VTJ.js} +370 -371
  11. package/dist/SceneMetadata-CSmlJW4c.js +183 -0
  12. package/dist/{ToneMapGPU-sAs8wdgI.js → ToneMapGPU-4GPwoG_c.js} +13 -6
  13. package/dist/addons/archives.es.js +1 -1
  14. package/dist/addons/bidirectional.es.js +1 -1
  15. package/dist/addons/color.es.js +1 -1
  16. package/dist/addons/physical-sky.es.js +1 -1
  17. package/dist/addons/storage.es.js +1 -1
  18. package/dist/{openStorage-Fe9BoS1_.js → openStorage-B085D9FR.js} +1 -1
  19. package/dist/rayzee-core.es.js +5 -5
  20. package/dist/rayzee.es.js +815 -796
  21. package/dist/rayzee.umd.js +106 -102
  22. package/dist/{sceneCachePolicy-B9w3BnaP.js → sceneCachePolicy-D_uBSbQF.js} +2 -2
  23. package/package.json +1 -1
  24. package/src/EngineIssues.js +20 -6
  25. package/src/Passes/AIUpscaler.js +4 -0
  26. package/src/Passes/OIDNDenoiser.js +8 -6
  27. package/src/PathTracerApp.js +10 -1
  28. package/src/Processor/ArchiveImporter.js +23 -6
  29. package/src/Processor/AssetLoader.js +15 -0
  30. package/src/Processor/LightSerializer.js +6 -2
  31. package/src/Processor/PBRT/PBRTMaterials.js +2 -1
  32. package/src/Processor/PBRT/PBRTSceneBuilder.js +1 -1
  33. package/src/Processor/PlatformImageLoader.js +9 -0
  34. package/src/Processor/TextureCreator.js +44 -4
  35. package/src/Processor/ToneMapCPU.js +34 -5
  36. package/src/Processor/ToneMapGPU.js +15 -9
  37. package/src/Processor/USD/USDScene.js +981 -17
  38. package/src/RayzeeRenderer.js +81 -26
  39. package/src/Stages/AutoExposure.js +21 -8
  40. package/src/Stages/Compositor.js +39 -1
  41. package/src/Stages/LocalExposure.js +44 -18
  42. package/src/Stages/NormalDepth.js +7 -3
  43. package/src/Stages/PathTracer.js +25 -1
  44. package/src/Stages/PathTracerStage.js +9 -1
  45. package/src/TSL/CameraRay.js +12 -1
  46. package/src/TSL/DebugKernel.js +3 -2
  47. package/src/TSL/FinalWriteKernel.js +4 -7
  48. package/src/TSL/GenerateKernel.js +6 -2
  49. package/src/TSL/LightSplatKernel.js +7 -4
  50. package/src/TSL/LightsCore.js +6 -2
  51. package/src/TSL/LightsSampling.js +45 -12
  52. package/src/TSL/SceneResources.js +11 -0
  53. package/src/TSL/ShadeKernel.js +1 -2
  54. package/src/integrators/BidirectionalIntegrator.js +1 -0
  55. package/src/managers/CameraManager.js +9 -0
  56. package/src/managers/DenoisingManager.js +1 -0
  57. package/src/managers/InteractionManager.js +6 -0
  58. package/src/managers/UniformManager.js +3 -1
  59. package/dist/SceneMetadata-D2Sh-0e-.js +0 -110
@@ -21,6 +21,47 @@ const SKIPPED = new Set( [ 'Material', 'Shader', 'NodeGraph', 'GeomSubset', 'Vol
21
21
  const PTEX = /\.(ptx|ptex)$/i;
22
22
  const WRAP = { repeat: RepeatWrapping, mirror: MirroredRepeatWrapping, clamp: ClampToEdgeWrapping, black: ClampToEdgeWrapping };
23
23
 
24
+ // MaterialX nodes are `ND_<node>_<types>`; the node is what a reader dispatches on.
25
+ const MTLX_TYPES = /_(?:float|integer|boolean|string|filename|color3|color4|vector2|vector3|vector4|matrix33|matrix44|surfaceshader|BSDF|EDF)(?:FA|I|B)?(?=_|$)/;
26
+ const mtlxNode = id => {
27
+
28
+ const name = id.slice( 3 );
29
+ const at = name.search( MTLX_TYPES );
30
+ return at > 0 ? name.slice( 0, at ) : name;
31
+
32
+ };
33
+
34
+ // The MaterialX surfaces read here, and the method each goes to.
35
+ const MTLX_SURFACES = { open_pbr_surface: 'openPbrSurface', standard_surface: 'standardSurface', gltf_pbr: 'gltfPbrSurface', UsdPreviewSurface: 'previewSurface', surface: 'bsdfSurface' };
36
+ const MTLX_ADDRESS = { periodic: 'repeat', mirror: 'mirror', clamp: 'clamp', constant: 'black' };
37
+ // A UV set's placement as an affine map, u' = a·u + b·v + c and v' = d·u + e·v + f.
38
+ const UV_IDENTITY = [ 1, 0, 0, 0, 1, 0 ];
39
+ const isNum = v => typeof v === 'number';
40
+ const isValue = v => isNum( v ) || Array.isArray( v );
41
+ const each = ( v, f ) => ( isNum( v ) ? f( v ) : v.map( f ) );
42
+ function zip( a, b, f ) {
43
+
44
+ if ( isNum( a ) && isNum( b ) ) return f( a, b );
45
+ const n = Math.max( isNum( a ) ? 1 : a.length, isNum( b ) ? 1 : b.length );
46
+ return Array.from( { length: n }, ( _, i ) => f( isNum( a ) ? a : a[ i ] ?? a[ 0 ], isNum( b ) ? b : b[ i ] ?? b[ 0 ] ) );
47
+
48
+ }
49
+
50
+ const mean = v => ( isNum( v ) ? v : v.slice( 0, 3 ).reduce( ( s, x ) => s + x, 0 ) / Math.min( v.length, 3 ) );
51
+
52
+ // Placements composed after `m`: scale the result, move it, or turn it as MaterialX's rotate2d does (clockwise).
53
+ const uvScale = ( m, [ sx, sy ] ) => [ m[ 0 ] * sx, m[ 1 ] * sx, m[ 2 ] * sx, m[ 3 ] * sy, m[ 4 ] * sy, m[ 5 ] * sy ];
54
+ const uvTranslate = ( m, [ tx, ty ] ) => [ m[ 0 ], m[ 1 ], m[ 2 ] + tx, m[ 3 ], m[ 4 ], m[ 5 ] + ty ];
55
+ function uvRotate( m, degrees ) {
56
+
57
+ const c = Math.cos( degrees * DEG ), s = Math.sin( degrees * DEG );
58
+ return [
59
+ c * m[ 0 ] + s * m[ 3 ], c * m[ 1 ] + s * m[ 4 ], c * m[ 2 ] + s * m[ 5 ],
60
+ c * m[ 3 ] - s * m[ 0 ], c * m[ 4 ] - s * m[ 1 ], c * m[ 5 ] - s * m[ 2 ],
61
+ ];
62
+
63
+ }
64
+
24
65
  function emptyIR() {
25
66
 
26
67
  return {
@@ -1097,6 +1138,11 @@ export class USDSceneReader {
1097
1138
 
1098
1139
  }
1099
1140
 
1141
+ // MaterialX first: where a material carries both, its preview surface is the simpler stand-in for viewers.
1142
+ const mtlx = await this.stage.connectionSource( prim, 'outputs:mtlx:surface' );
1143
+ const read = MTLX_SURFACES[ mtlxNode( mtlx?.prim.value( 'info:id' ) ?? 'ND_' ) ];
1144
+ if ( read ) return this[ read ]( mtlx.prim );
1145
+
1100
1146
  for ( const output of [ 'outputs:ri:surface', 'outputs:surface', 'outputs:glslfx:surface' ] ) {
1101
1147
 
1102
1148
  const source = await this.stage.connectionSource( prim, output );
@@ -1194,6 +1240,7 @@ export class USDSceneReader {
1194
1240
  }
1195
1241
 
1196
1242
  default:
1243
+ if ( id?.startsWith( 'ND_' ) ) return this.mtlxOutput( prim, id, name, hops );
1197
1244
  this.warnOnce( `shader:${id}`, `USD shader "${id}" is not read — its input left at its default` );
1198
1245
  return null;
1199
1246
 
@@ -1272,8 +1319,7 @@ export class USDSceneReader {
1272
1319
  const map = await this.image( term.texture, term.texture.colorSpace !== 'raw' );
1273
1320
  if ( map ) {
1274
1321
 
1275
- this.transformTexture( map, term.transform );
1276
- mat[ mapSlot ] = map;
1322
+ mat[ mapSlot ] = this.transformTexture( map, term.transform );
1277
1323
  const scale = term.texture.scale;
1278
1324
  mat[ slot ].setRGB( scale?.[ 0 ] ?? 1, scale?.[ 1 ] ?? 1, scale?.[ 2 ] ?? 1 );
1279
1325
  surface.uvSet = term.texture.uvSet;
@@ -1291,7 +1337,8 @@ export class USDSceneReader {
1291
1337
  if ( typeof term.value === 'number' ) mat[ slot ] = term.value;
1292
1338
  else if ( term.texture && mapSlot ) {
1293
1339
 
1294
- if ( term.channel !== channel ) {
1340
+ // A MaterialX float image of a grey map reads the same from any channel.
1341
+ if ( term.channel !== channel && ! term.mono ) {
1295
1342
 
1296
1343
  this.warnOnce( `chan:${mapSlot}:${term.channel}`, `USD ${slot} from a texture's "${term.channel}" channel is not supported (the engine reads "${channel}") — constant used` );
1297
1344
  return;
@@ -1301,8 +1348,7 @@ export class USDSceneReader {
1301
1348
  const map = await this.image( term.texture, false );
1302
1349
  if ( map ) {
1303
1350
 
1304
- this.transformTexture( map, term.transform );
1305
- mat[ mapSlot ] = map;
1351
+ mat[ mapSlot ] = this.transformTexture( map, term.transform );
1306
1352
  mat[ slot ] = term.texture.scale?.[ 0 ] ?? 1;
1307
1353
  surface.uvSet = term.texture.uvSet;
1308
1354
 
@@ -1312,12 +1358,24 @@ export class USDSceneReader {
1312
1358
 
1313
1359
  }
1314
1360
 
1361
+ /** The map placed by `transform`: a copy, since the image cache shares one texture between every material using it. */
1315
1362
  transformTexture( map, transform ) {
1316
1363
 
1317
- if ( ! transform ) return;
1318
- if ( transform.scale ) map.repeat.set( transform.scale[ 0 ], transform.scale[ 1 ] );
1319
- if ( transform.translation ) map.offset.set( transform.translation[ 0 ], transform.translation[ 1 ] );
1320
- if ( transform.rotation ) map.rotation = transform.rotation * DEG;
1364
+ if ( ! transform ) return map;
1365
+ const placed = map.clone();
1366
+ if ( transform.matrix ) {
1367
+
1368
+ const [ a, b, c, d, e, f ] = transform.matrix;
1369
+ placed.matrixAutoUpdate = false;
1370
+ placed.matrix.set( a, b, c, d, e, f, 0, 0, 1 );
1371
+ return placed;
1372
+
1373
+ }
1374
+
1375
+ if ( transform.scale ) placed.repeat.set( transform.scale[ 0 ], transform.scale[ 1 ] );
1376
+ if ( transform.translation ) placed.offset.set( transform.translation[ 0 ], transform.translation[ 1 ] );
1377
+ if ( transform.rotation ) placed.rotation = transform.rotation * DEG;
1378
+ return placed;
1321
1379
 
1322
1380
  }
1323
1381
 
@@ -1526,6 +1584,889 @@ export class USDSceneReader {
1526
1584
 
1527
1585
  }
1528
1586
 
1587
+ // ── MaterialX ────────────────────────────────────────────────
1588
+ // UsdShade networks of ND_* nodes (MaterialX 1.38/1.39 in USD). A node becomes what an input can hold: a constant,
1589
+ // a texture with the placement and gain the engine carries, or a UV set and its placement. What the engine cannot
1590
+ // carry (a blend of a texture with a colour, a hue shift of one, procedural patterns) keeps its main texture or
1591
+ // falls back to the default, and says so once.
1592
+
1593
+ async mtlxOutput( prim, id, name, hops ) {
1594
+
1595
+ const node = mtlxNode( id );
1596
+ const input = n => this.term( prim, n, hops + 1 );
1597
+ const value = async ( n, fallback ) => {
1598
+
1599
+ const v = ( await input( n ) )?.value;
1600
+ return v === undefined ? fallback : v;
1601
+
1602
+ };
1603
+
1604
+ switch ( node ) {
1605
+
1606
+ case 'image':
1607
+ case 'tiledimage':
1608
+ return this.mtlxImage( prim, id, node, hops );
1609
+ case 'texcoord':
1610
+ if ( ( await value( 'index', 0 ) ) > 0 ) this.warnOnce( 'mtlx:texcoord', 'MaterialX texture coordinates past the first set are read as the first' );
1611
+ return { uv: { set: 'st', m: UV_IDENTITY } };
1612
+ case 'geompropvalue': {
1613
+
1614
+ const prop = await value( 'geomprop', null );
1615
+ return prop ? ( /vector2/.test( id ) ? { uv: { set: prop, m: UV_IDENTITY } } : { primvar: prop } ) : input( 'default' );
1616
+
1617
+ }
1618
+
1619
+ case 'constant':
1620
+ return input( 'value' );
1621
+ case 'dot':
1622
+ return input( 'in' );
1623
+ case 'convert':
1624
+ return this.mtlxConvert( await input( 'in' ), id );
1625
+ case 'extract':
1626
+ return this.mtlxChannel( await input( 'in' ), await value( 'index', 0 ) );
1627
+ case 'separate2':
1628
+ case 'separate3':
1629
+ case 'separate4':
1630
+ return this.mtlxChannel( await input( 'in' ), { outx: 0, outy: 1, outz: 2, outw: 3, outr: 0, outg: 1, outb: 2, outa: 3 }[ name ] ?? 0 );
1631
+ case 'combine2':
1632
+ case 'combine3':
1633
+ case 'combine4': {
1634
+
1635
+ const parts = await Promise.all( [ 'in1', 'in2', 'in3', 'in4' ].slice( 0, Number( node.slice( - 1 ) ) ).map( input ) );
1636
+ return parts.every( p => isNum( p?.value ) ) ? { value: parts.map( p => p.value ) } : parts.find( p => p?.texture ) ?? null;
1637
+
1638
+ }
1639
+
1640
+ case 'mix': {
1641
+
1642
+ const [ fg, bg, mix ] = await Promise.all( [ input( 'fg' ), input( 'bg' ), input( 'mix' ) ] );
1643
+ if ( isValue( fg?.value ) && isValue( bg?.value ) && isNum( mix?.value ) ) return { value: zip( bg.value, fg.value, ( b, f ) => b + ( f - b ) * mix.value ) };
1644
+ this.warnOnce( 'mtlx:mix', 'MaterialX mixes of a texture are read as their larger part' );
1645
+ const front = ! isNum( mix?.value ) || mix.value >= 0.5;
1646
+ return ( front ? fg : bg ) ?? ( front ? bg : fg );
1647
+
1648
+ }
1649
+
1650
+ case 'multiply':
1651
+ case 'divide':
1652
+ case 'add':
1653
+ case 'subtract':
1654
+ case 'min':
1655
+ case 'max':
1656
+ case 'power':
1657
+ case 'modulo':
1658
+ return this.mtlxArithmetic( node, await input( 'in1' ), await input( 'in2' ) );
1659
+ case 'invert': {
1660
+
1661
+ const t = await input( 'in' );
1662
+ const amount = await value( 'amount', 1 );
1663
+ return isValue( t?.value ) ? { value: zip( amount, t.value, ( a, v ) => a - v ) } : t;
1664
+
1665
+ }
1666
+
1667
+ case 'clamp': {
1668
+
1669
+ const t = await input( 'in' );
1670
+ if ( ! isValue( t?.value ) ) return t;
1671
+ const [ low, high ] = [ await value( 'low', 0 ), await value( 'high', 1 ) ];
1672
+ return { value: zip( zip( t.value, low, Math.max ), high, Math.min ) };
1673
+
1674
+ }
1675
+
1676
+ case 'hsvadjust':
1677
+ return this.mtlxHsv( await input( 'in' ), await value( 'amount', [ 0, 1, 1 ] ) );
1678
+ case 'rotate2d':
1679
+ case 'rotate3d': {
1680
+
1681
+ const t = await input( 'in' );
1682
+ const amount = await value( 'amount', 0 );
1683
+ const axis = node === 'rotate3d' ? await value( 'axis', [ 0, 1, 0 ] ) : [ 0, 0, 1 ];
1684
+ if ( ! t?.uv || ! amount ) return t;
1685
+ if ( Math.abs( axis[ 0 ] ) > 1e-6 || Math.abs( axis[ 1 ] ) > 1e-6 ) {
1686
+
1687
+ this.warnOnce( 'mtlx:rotate', 'MaterialX texture coordinates turned about an axis other than z are left unturned' );
1688
+ return t;
1689
+
1690
+ }
1691
+
1692
+ return { uv: { ...t.uv, m: uvRotate( t.uv.m, Math.sign( axis[ 2 ] ) * amount ) } };
1693
+
1694
+ }
1695
+
1696
+ case 'place2d': {
1697
+
1698
+ const t = await input( 'texcoord' );
1699
+ if ( ! t?.uv ) return t;
1700
+ const [ pivot, scale, rotate, offset ] = [ await value( 'pivot', [ 0, 0 ] ), await value( 'scale', [ 1, 1 ] ), await value( 'rotate', 0 ), await value( 'offset', [ 0, 0 ] ) ];
1701
+ const trs = ( await value( 'operationorder', 0 ) ) !== 0;
1702
+ let m = uvTranslate( t.uv.m, [ - pivot[ 0 ], - pivot[ 1 ] ] );
1703
+ const inverse = scale.map( s => ( s ? 1 / s : 1 ) );
1704
+ if ( trs ) m = uvScale( uvRotate( uvTranslate( m, [ - offset[ 0 ], - offset[ 1 ] ] ), rotate ), inverse );
1705
+ else m = uvTranslate( uvRotate( uvScale( m, inverse ), rotate ), [ - offset[ 0 ], - offset[ 1 ] ] );
1706
+ return { uv: { ...t.uv, m: uvTranslate( m, pivot ) } };
1707
+
1708
+ }
1709
+
1710
+ case 'normalmap': {
1711
+
1712
+ const t = await input( 'in' );
1713
+ return t?.texture ? { ...t, normalScale: await value( 'scale', 1 ) } : null;
1714
+
1715
+ }
1716
+
1717
+ case 'heighttonormal': {
1718
+
1719
+ const t = await input( 'in' );
1720
+ return t?.texture ? { ...t, bump: await value( 'scale', 1 ) } : null;
1721
+
1722
+ }
1723
+
1724
+ case 'artistic_ior':
1725
+ // Gulbrandsen's reflectivity is the conductor's colour at normal incidence.
1726
+ return { artistic: { reflectivity: await input( 'reflectivity' ), edge: await input( 'edge_color' ) } };
1727
+ case 'normal':
1728
+ case 'tangent':
1729
+ case 'bitangent':
1730
+ case 'position':
1731
+ case 'viewdirection':
1732
+ return null;
1733
+
1734
+ }
1735
+
1736
+ this.warnOnce( `mtlx:${node}`, `MaterialX node "${node}" is not read — its input left at its default` );
1737
+ return null;
1738
+
1739
+ }
1740
+
1741
+ async mtlxImage( prim, id, node, hops ) {
1742
+
1743
+ const file = prim.assetPath( 'inputs:file' );
1744
+ if ( ! file?.path ) {
1745
+
1746
+ if ( file?.asset ) this.warnOnce( `tex:${file.asset}`, `USD texture "${file.asset}" not found` );
1747
+ return this.term( prim, 'default', hops + 1 );
1748
+
1749
+ }
1750
+
1751
+ if ( /<udim>/i.test( file.asset ) ) {
1752
+
1753
+ this.warnOnce( 'udim', 'USD UDIM textures are not supported — skipped' );
1754
+ return null;
1755
+
1756
+ }
1757
+
1758
+ const st = await this.term( prim, 'texcoord', hops + 1 );
1759
+ let m = st?.uv?.m ?? UV_IDENTITY;
1760
+ if ( node === 'tiledimage' ) {
1761
+
1762
+ const tiling = prim.value( 'inputs:uvtiling' ) ?? [ 1, 1 ];
1763
+ const offset = prim.value( 'inputs:uvoffset' ) ?? [ 0, 0 ];
1764
+ m = uvTranslate( uvScale( m, tiling ), [ - offset[ 0 ], - offset[ 1 ] ] );
1765
+
1766
+ }
1767
+
1768
+ const space = prim.propertyMeta( 'inputs:file', 'colorSpace' ) ?? prim.value( 'inputs:file:colorSpace' );
1769
+ const mono = /_float$/.test( id );
1770
+ return {
1771
+ texture: {
1772
+ path: file.path,
1773
+ uvSet: st?.uv?.set ?? st?.primvar ?? 'st',
1774
+ wrapS: MTLX_ADDRESS[ prim.value( 'inputs:uaddressmode' ) ] ?? 'repeat',
1775
+ wrapT: MTLX_ADDRESS[ prim.value( 'inputs:vaddressmode' ) ] ?? 'repeat',
1776
+ colorSpace: ! space ? 'auto' : /srgb/i.test( space ) ? 'sRGB' : 'raw',
1777
+ scale: null,
1778
+ },
1779
+ channel: mono ? 'r' : 'rgb',
1780
+ mono,
1781
+ transform: m.every( ( v, i ) => Math.abs( v - UV_IDENTITY[ i ] ) < 1e-9 ) ? null : { matrix: m },
1782
+ };
1783
+
1784
+ }
1785
+
1786
+ mtlxConvert( term, id ) {
1787
+
1788
+ if ( ! isValue( term?.value ) ) return term;
1789
+ const to = id.slice( id.lastIndexOf( '_' ) + 1 );
1790
+ const size = { float: 1, color3: 3, vector3: 3, color4: 4, vector4: 4, vector2: 2 }[ to ] ?? 3;
1791
+ const v = term.value;
1792
+ if ( size === 1 ) return { value: isNum( v ) ? v : v[ 0 ] };
1793
+ if ( isNum( v ) ) return { value: Array( size ).fill( v ) };
1794
+ return { value: Array.from( { length: size }, ( _, i ) => v[ i ] ?? ( i === 3 ? 1 : 0 ) ) };
1795
+
1796
+ }
1797
+
1798
+ mtlxChannel( term, index ) {
1799
+
1800
+ if ( isValue( term?.value ) ) return { value: isNum( term.value ) ? term.value : term.value[ index ] ?? 0 };
1801
+ if ( term?.texture ) return { ...term, channel: 'rgba'[ index ] ?? 'r', mono: false };
1802
+ return term;
1803
+
1804
+ }
1805
+
1806
+ mtlxArithmetic( node, a, b ) {
1807
+
1808
+ const ops = {
1809
+ multiply: ( x, y ) => x * y, divide: ( x, y ) => ( y ? x / y : 0 ), add: ( x, y ) => x + y, subtract: ( x, y ) => x - y,
1810
+ min: Math.min, max: Math.max, power: ( x, y ) => Math.max( x, 0 ) ** y, modulo: ( x, y ) => ( y ? x - y * Math.floor( x / y ) : 0 ),
1811
+ };
1812
+
1813
+ if ( isValue( a?.value ) && isValue( b?.value ) ) return { value: zip( a.value, b.value, ops[ node ] ) };
1814
+
1815
+ // A UV set scaled or moved by constants: Blender's Mapping node is a chain of these.
1816
+ const uv = a?.uv ?? b?.uv;
1817
+ const other = a?.uv ? b?.value : a?.value;
1818
+ if ( uv && isValue( other ) && ( node === 'multiply' || node === 'add' || ( a?.uv && ( node === 'divide' || node === 'subtract' ) ) ) ) {
1819
+
1820
+ const v = isNum( other ) ? [ other, other ] : other;
1821
+ if ( node === 'multiply' ) return { uv: { ...uv, m: uvScale( uv.m, v ) } };
1822
+ if ( node === 'divide' ) return { uv: { ...uv, m: uvScale( uv.m, v.map( x => ( x ? 1 / x : 1 ) ) ) } };
1823
+ return { uv: { ...uv, m: uvTranslate( uv.m, node === 'add' ? v : v.map( x => - x ) ) } };
1824
+
1825
+ }
1826
+
1827
+ // A texture times a constant is a texture with a gain, which the engine carries.
1828
+ const texture = a?.texture ? a : b?.texture ? b : null;
1829
+ const gain = a?.texture ? b?.value : a?.value;
1830
+ if ( texture && isValue( gain ) && ( node === 'multiply' || ( a?.texture && node === 'divide' ) ) ) {
1831
+
1832
+ const by = node === 'divide' ? each( gain, x => ( x ? 1 / x : 1 ) ) : gain;
1833
+ const scale = zip( texture.texture.scale ?? [ 1, 1, 1 ], by, ( s, x ) => s * x );
1834
+ return { ...texture, texture: { ...texture.texture, scale: isNum( scale ) ? [ scale, scale, scale ] : scale } };
1835
+
1836
+ }
1837
+
1838
+ if ( texture ) this.warnOnce( `mtlx:${node}`, `MaterialX ${node} of a texture is read as the texture alone` );
1839
+ return texture ?? a ?? b;
1840
+
1841
+ }
1842
+
1843
+ mtlxHsv( term, amount ) {
1844
+
1845
+ if ( ! Array.isArray( term?.value ) ) {
1846
+
1847
+ if ( term?.texture && ( amount[ 0 ] || amount[ 1 ] !== 1 || amount[ 2 ] !== 1 ) ) this.warnOnce( 'mtlx:hsv', 'MaterialX hue, saturation and value changes to a texture are not applied' );
1848
+ return term;
1849
+
1850
+ }
1851
+
1852
+ const c = new Color().setRGB( term.value[ 0 ], term.value[ 1 ], term.value[ 2 ] );
1853
+ const hsl = c.getHSL( {} );
1854
+ // three's HSL stands in for HSV: a hue turn is exact, saturation and value scale about it.
1855
+ c.setHSL( ( hsl.h + amount[ 0 ] + 1 ) % 1, Math.min( 1, hsl.s * amount[ 1 ] ), hsl.l );
1856
+ return { value: [ c.r * amount[ 2 ], c.g * amount[ 2 ], c.b * amount[ 2 ] ] };
1857
+
1858
+ }
1859
+
1860
+ /** A normal or height map from a MaterialX normal input. */
1861
+ async mtlxNormal( mat, term, surface ) {
1862
+
1863
+ if ( ! term?.texture ) return;
1864
+ const map = this.transformTexture( await this.image( term.texture, false ), term.transform );
1865
+ if ( ! map ) return;
1866
+ if ( term.bump !== undefined ) {
1867
+
1868
+ mat.bumpMap = map;
1869
+ mat.bumpScale = term.bump;
1870
+
1871
+ } else {
1872
+
1873
+ mat.normalMap = map;
1874
+ mat.normalScale.set( term.normalScale ?? 1, term.normalScale ?? 1 );
1875
+
1876
+ }
1877
+
1878
+ surface.uvSet = term.texture.uvSet;
1879
+
1880
+ }
1881
+
1882
+ /** Coverage: a constant below 1, or the colour map's own alpha. */
1883
+ mtlxOpacity( mat, term ) {
1884
+
1885
+ if ( isValue( term?.value ) && mean( term.value ) < 1 ) {
1886
+
1887
+ mat.opacity = mean( term.value );
1888
+ mat.transparent = true;
1889
+
1890
+ } else if ( term?.texture ) {
1891
+
1892
+ if ( term.texture.path === mat.map?.userData.usdPath && term.channel === 'a' ) mat.transparent = true;
1893
+ else this.warnOnce( 'opacitymap', 'USD opacity from a texture other than the colour map\'s alpha is not supported' );
1894
+
1895
+ }
1896
+
1897
+ }
1898
+
1899
+ /** A weight × colour pair onto a colour slot: the weight scales the colour, or the texture's gain. */
1900
+ async mtlxWeighted( mat, slot, colorTerm, weight, surface, mapSlot ) {
1901
+
1902
+ await this.applyColor( mat, slot, colorTerm, surface, mapSlot );
1903
+ if ( isNum( weight ) ) mat[ slot ].multiplyScalar( weight );
1904
+
1905
+ }
1906
+
1907
+ mtlxSurfaceOf( mat ) {
1908
+
1909
+ return { material: mat, perMesh: null, uvSet: 'st' };
1910
+
1911
+ }
1912
+
1913
+ /** OpenPBR Surface 1.1, which Blender writes for its Principled BSDF. */
1914
+ async openPbrSurface( shader ) {
1915
+
1916
+ const t = name => this.term( shader, name );
1917
+ const num = async ( name, fallback ) => {
1918
+
1919
+ const v = ( await t( name ) )?.value;
1920
+ return isNum( v ) ? v : fallback;
1921
+
1922
+ };
1923
+
1924
+ const mat = new MeshPhysicalMaterial( { side: DoubleSide } );
1925
+ const surface = this.mtlxSurfaceOf( mat );
1926
+ mat.color.setRGB( 0.8, 0.8, 0.8 );
1927
+ await this.mtlxWeighted( mat, 'color', await t( 'base_color' ), await num( 'base_weight', 1 ), surface, 'map' );
1928
+ mat.metalness = 0;
1929
+ await this.applyScalar( mat, 'metalness', await t( 'base_metalness' ), surface, 'metalnessMap', 'b' );
1930
+ mat.roughness = 0.3;
1931
+ await this.applyScalar( mat, 'roughness', await t( 'specular_roughness' ), surface, 'roughnessMap', 'g' );
1932
+ mat.specularIntensity = Math.min( 1, await num( 'specular_weight', 1 ) );
1933
+ const specularColor = ( await t( 'specular_color' ) )?.value;
1934
+ if ( Array.isArray( specularColor ) ) mat.specularColor.setRGB( specularColor[ 0 ], specularColor[ 1 ], specularColor[ 2 ] );
1935
+ mat.ior = await num( 'specular_ior', 1.5 );
1936
+ mat.anisotropy = await num( 'specular_roughness_anisotropy', 0 );
1937
+
1938
+ const transmission = await num( 'transmission_weight', 0 );
1939
+ if ( transmission > 0 ) {
1940
+
1941
+ mat.transmission = transmission;
1942
+ const color = ( await t( 'transmission_color' ) )?.value;
1943
+ const depth = await num( 'transmission_depth', 0 );
1944
+ if ( Array.isArray( color ) && depth > 0 ) {
1945
+
1946
+ mat.attenuationColor.setRGB( color[ 0 ], color[ 1 ], color[ 2 ] );
1947
+ mat.attenuationDistance = depth;
1948
+
1949
+ } else if ( Array.isArray( color ) ) {
1950
+
1951
+ // Without a depth the colour tints at the surface, which the engine takes from the base colour.
1952
+ mat.color.lerp( new Color().setRGB( color[ 0 ], color[ 1 ], color[ 2 ] ), transmission );
1953
+
1954
+ }
1955
+
1956
+ const abbe = await num( 'transmission_dispersion_abbe_number', 20 );
1957
+ const scale = await num( 'transmission_dispersion_scale', 0 );
1958
+ if ( scale > 0 && abbe > 0 ) mat.dispersion = scale * 20 / abbe;
1959
+
1960
+ }
1961
+
1962
+ const subsurface = await num( 'subsurface_weight', 0 );
1963
+ if ( subsurface > 0 ) {
1964
+
1965
+ const color = ( await t( 'subsurface_color' ) )?.value;
1966
+ const radius = await num( 'subsurface_radius', 1 );
1967
+ const scale = ( await t( 'subsurface_radius_scale' ) )?.value;
1968
+ mat.subsurface = subsurface;
1969
+ mat.subsurfaceColor = Array.isArray( color ) ? new Color().setRGB( color[ 0 ], color[ 1 ], color[ 2 ] ) : mat.color.clone();
1970
+ mat.subsurfaceRadius = Array.isArray( scale ) ? [ ...scale ] : [ 1, 1, 1 ];
1971
+ mat.subsurfaceRadiusScale = radius;
1972
+ mat.subsurfaceAnisotropy = await num( 'subsurface_scatter_anisotropy', 0 );
1973
+
1974
+ }
1975
+
1976
+ mat.clearcoat = await num( 'coat_weight', 0 );
1977
+ mat.clearcoatRoughness = await num( 'coat_roughness', 0 );
1978
+
1979
+ const fuzz = await num( 'fuzz_weight', 0 );
1980
+ if ( fuzz > 0 ) {
1981
+
1982
+ const color = ( await t( 'fuzz_color' ) )?.value ?? [ 1, 1, 1 ];
1983
+ mat.sheen = Math.min( 1, fuzz );
1984
+ mat.sheenColor.setRGB( color[ 0 ], color[ 1 ], color[ 2 ] );
1985
+ mat.sheenRoughness = await num( 'fuzz_roughness', 0.5 );
1986
+
1987
+ }
1988
+
1989
+ const film = await num( 'thin_film_weight', 0 );
1990
+ if ( film > 0 ) {
1991
+
1992
+ // OpenPBR states the film in micrometres, the engine in nanometres.
1993
+ const thickness = await num( 'thin_film_thickness', 0.5 ) * 1000;
1994
+ mat.iridescence = Math.min( 1, film );
1995
+ mat.iridescenceIOR = await num( 'thin_film_ior', 1.4 );
1996
+ mat.iridescenceThicknessRange = [ thickness, thickness ];
1997
+
1998
+ }
1999
+
2000
+ const luminance = await num( 'emission_luminance', 0 );
2001
+ if ( luminance > 0 ) {
2002
+
2003
+ mat.emissive.setRGB( 1, 1, 1 );
2004
+ await this.applyColor( mat, 'emissive', await t( 'emission_color' ), surface, 'emissiveMap' );
2005
+ mat.emissiveIntensity = luminance;
2006
+
2007
+ }
2008
+
2009
+ this.mtlxOpacity( mat, await t( 'geometry_opacity' ) );
2010
+ await this.mtlxNormal( mat, await t( 'geometry_normal' ), surface );
2011
+ return surface;
2012
+
2013
+ }
2014
+
2015
+ /** Autodesk Standard Surface, which Maya, Houdini and Omniverse write. */
2016
+ async standardSurface( shader ) {
2017
+
2018
+ const t = name => this.term( shader, name );
2019
+ const num = async ( name, fallback ) => {
2020
+
2021
+ const v = ( await t( name ) )?.value;
2022
+ return isNum( v ) ? v : fallback;
2023
+
2024
+ };
2025
+
2026
+ const mat = new MeshPhysicalMaterial( { side: DoubleSide } );
2027
+ const surface = this.mtlxSurfaceOf( mat );
2028
+ mat.color.setRGB( 0.8, 0.8, 0.8 );
2029
+ await this.mtlxWeighted( mat, 'color', await t( 'base_color' ), await num( 'base', 1 ), surface, 'map' );
2030
+ mat.metalness = 0;
2031
+ await this.applyScalar( mat, 'metalness', await t( 'metalness' ), surface, 'metalnessMap', 'b' );
2032
+ mat.roughness = 0.2;
2033
+ await this.applyScalar( mat, 'roughness', await t( 'specular_roughness' ), surface, 'roughnessMap', 'g' );
2034
+ mat.specularIntensity = Math.min( 1, await num( 'specular', 1 ) );
2035
+ const specularColor = ( await t( 'specular_color' ) )?.value;
2036
+ if ( Array.isArray( specularColor ) ) mat.specularColor.setRGB( specularColor[ 0 ], specularColor[ 1 ], specularColor[ 2 ] );
2037
+ mat.ior = await num( 'specular_IOR', 1.5 );
2038
+ mat.anisotropy = await num( 'specular_anisotropy', 0 );
2039
+ mat.anisotropyRotation = await num( 'specular_rotation', 0 ) * Math.PI * 2;
2040
+
2041
+ const transmission = await num( 'transmission', 0 );
2042
+ if ( transmission > 0 ) {
2043
+
2044
+ mat.transmission = transmission;
2045
+ const color = ( await t( 'transmission_color' ) )?.value;
2046
+ const depth = await num( 'transmission_depth', 0 );
2047
+ if ( Array.isArray( color ) && depth > 0 ) {
2048
+
2049
+ mat.attenuationColor.setRGB( color[ 0 ], color[ 1 ], color[ 2 ] );
2050
+ mat.attenuationDistance = depth;
2051
+
2052
+ } else if ( Array.isArray( color ) ) {
2053
+
2054
+ mat.color.lerp( new Color().setRGB( color[ 0 ], color[ 1 ], color[ 2 ] ), transmission );
2055
+
2056
+ }
2057
+
2058
+ const abbe = await num( 'transmission_dispersion', 0 );
2059
+ if ( abbe > 0 ) mat.dispersion = 20 / abbe;
2060
+
2061
+ }
2062
+
2063
+ const subsurface = await num( 'subsurface', 0 );
2064
+ if ( subsurface > 0 ) {
2065
+
2066
+ const color = ( await t( 'subsurface_color' ) )?.value;
2067
+ const radius = ( await t( 'subsurface_radius' ) )?.value;
2068
+ mat.subsurface = subsurface;
2069
+ mat.subsurfaceColor = Array.isArray( color ) ? new Color().setRGB( color[ 0 ], color[ 1 ], color[ 2 ] ) : mat.color.clone();
2070
+ mat.subsurfaceRadius = Array.isArray( radius ) ? [ ...radius ] : [ 1, 1, 1 ];
2071
+ mat.subsurfaceRadiusScale = await num( 'subsurface_scale', 1 );
2072
+ mat.subsurfaceAnisotropy = await num( 'subsurface_anisotropy', 0 );
2073
+
2074
+ }
2075
+
2076
+ const sheen = await num( 'sheen', 0 );
2077
+ if ( sheen > 0 ) {
2078
+
2079
+ const color = ( await t( 'sheen_color' ) )?.value ?? [ 1, 1, 1 ];
2080
+ mat.sheen = Math.min( 1, sheen );
2081
+ mat.sheenColor.setRGB( color[ 0 ], color[ 1 ], color[ 2 ] );
2082
+ mat.sheenRoughness = await num( 'sheen_roughness', 0.3 );
2083
+
2084
+ }
2085
+
2086
+ mat.clearcoat = await num( 'coat', 0 );
2087
+ mat.clearcoatRoughness = await num( 'coat_roughness', 0.1 );
2088
+
2089
+ const film = await num( 'thin_film_thickness', 0 );
2090
+ if ( film > 0 ) {
2091
+
2092
+ mat.iridescence = 1;
2093
+ mat.iridescenceIOR = await num( 'thin_film_IOR', 1.5 );
2094
+ mat.iridescenceThicknessRange = [ film, film ];
2095
+
2096
+ }
2097
+
2098
+ const emission = await num( 'emission', 0 );
2099
+ if ( emission > 0 ) {
2100
+
2101
+ mat.emissive.setRGB( 1, 1, 1 );
2102
+ await this.applyColor( mat, 'emissive', await t( 'emission_color' ), surface, 'emissiveMap' );
2103
+ mat.emissiveIntensity = emission;
2104
+
2105
+ }
2106
+
2107
+ this.mtlxOpacity( mat, await t( 'opacity' ) );
2108
+ await this.mtlxNormal( mat, await t( 'normal' ), surface );
2109
+ return surface;
2110
+
2111
+ }
2112
+
2113
+ /** MaterialX's glTF PBR surface: glTF's own parameters, one to one. */
2114
+ async gltfPbrSurface( shader ) {
2115
+
2116
+ const t = name => this.term( shader, name );
2117
+ const num = async ( name, fallback ) => {
2118
+
2119
+ const v = ( await t( name ) )?.value;
2120
+ return isNum( v ) ? v : fallback;
2121
+
2122
+ };
2123
+
2124
+ const mat = new MeshPhysicalMaterial( { side: DoubleSide } );
2125
+ const surface = this.mtlxSurfaceOf( mat );
2126
+ await this.applyColor( mat, 'color', await t( 'base_color' ), surface, 'map' );
2127
+ mat.metalness = 1;
2128
+ await this.applyScalar( mat, 'metalness', await t( 'metallic' ), surface, 'metalnessMap', 'b' );
2129
+ mat.roughness = 1;
2130
+ await this.applyScalar( mat, 'roughness', await t( 'roughness' ), surface, 'roughnessMap', 'g' );
2131
+ mat.ior = await num( 'ior', 1.5 );
2132
+ mat.specularIntensity = await num( 'specular', 1 );
2133
+ const specularColor = ( await t( 'specular_color' ) )?.value;
2134
+ if ( Array.isArray( specularColor ) ) mat.specularColor.setRGB( specularColor[ 0 ], specularColor[ 1 ], specularColor[ 2 ] );
2135
+ mat.transmission = await num( 'transmission', 0 );
2136
+ mat.thickness = await num( 'thickness', 0 );
2137
+ const attenuation = ( await t( 'attenuation_color' ) )?.value;
2138
+ if ( Array.isArray( attenuation ) ) mat.attenuationColor.setRGB( attenuation[ 0 ], attenuation[ 1 ], attenuation[ 2 ] );
2139
+ const distance = await num( 'attenuation_distance', 0 );
2140
+ if ( distance > 0 ) mat.attenuationDistance = distance;
2141
+ mat.dispersion = await num( 'dispersion', 0 );
2142
+ mat.clearcoat = await num( 'clearcoat', 0 );
2143
+ mat.clearcoatRoughness = await num( 'clearcoat_roughness', 0 );
2144
+ const sheen = ( await t( 'sheen_color' ) )?.value;
2145
+ if ( Array.isArray( sheen ) && mean( sheen ) > 0 ) {
2146
+
2147
+ mat.sheen = 1;
2148
+ mat.sheenColor.setRGB( sheen[ 0 ], sheen[ 1 ], sheen[ 2 ] );
2149
+ mat.sheenRoughness = await num( 'sheen_roughness', 0 );
2150
+
2151
+ }
2152
+
2153
+ const iridescence = await num( 'iridescence', 0 );
2154
+ if ( iridescence > 0 ) {
2155
+
2156
+ const thickness = await num( 'iridescence_thickness', 100 );
2157
+ mat.iridescence = iridescence;
2158
+ mat.iridescenceIOR = await num( 'iridescence_ior', 1.3 );
2159
+ mat.iridescenceThicknessRange = [ thickness, thickness ];
2160
+
2161
+ }
2162
+
2163
+ await this.applyColor( mat, 'emissive', await t( 'emissive' ), surface, 'emissiveMap' );
2164
+ mat.emissiveIntensity = await num( 'emissive_strength', 1 );
2165
+
2166
+ // alpha_mode: 0 opaque, 1 mask, 2 blend.
2167
+ const mode = await num( 'alpha_mode', 0 );
2168
+ if ( mode > 0 ) {
2169
+
2170
+ this.mtlxOpacity( mat, await t( 'alpha' ) );
2171
+ if ( mode === 1 ) mat.alphaTest = await num( 'alpha_cutoff', 0.5 );
2172
+
2173
+ }
2174
+
2175
+ await this.mtlxNormal( mat, await t( 'normal' ), surface );
2176
+ return surface;
2177
+
2178
+ }
2179
+
2180
+ /**
2181
+ * MaterialX's `surface` over a BSDF graph (pbrlib), as Blender writes its node shaders: the graph's lobes with their
2182
+ * weights, made into the engine's one material. Read as the graph says; where the weights cannot be met (a lobe mix
2183
+ * driven by a texture), the even split stands in.
2184
+ */
2185
+ async bsdfSurface( shader ) {
2186
+
2187
+ const lobes = [];
2188
+ const emission = [];
2189
+ await this.mtlxLobes( shader, 'inputs:bsdf', 1, lobes, 0 );
2190
+ await this.mtlxEmission( shader, 'inputs:edf', 1, emission, 0 );
2191
+
2192
+ const mat = new MeshPhysicalMaterial( { side: DoubleSide } );
2193
+ const surface = this.mtlxSurfaceOf( mat );
2194
+ const sum = kinds => lobes.filter( l => kinds.includes( l.kind ) ).reduce( ( s, l ) => s + l.w, 0 );
2195
+ const base = sum( [ 'diffuse', 'conductor', 'transmission' ] );
2196
+
2197
+ // The base colour: each base lobe's colour by its share; one texture, the largest share's, is kept.
2198
+ if ( base > 0 ) {
2199
+
2200
+ const shares = lobes.filter( l => [ 'diffuse', 'conductor', 'transmission' ].includes( l.kind ) && l.w > 0 );
2201
+ const textured = shares.filter( l => l.color?.texture ).sort( ( a, b ) => b.w - a.w )[ 0 ];
2202
+ const rgb = [ 0, 0, 0 ];
2203
+ for ( const l of shares ) {
2204
+
2205
+ const c = isValue( l.color?.value ) ? l.color.value : l.color?.texture ? ( l.color.texture.scale ?? [ 1, 1, 1 ] ) : [ 0.8, 0.8, 0.8 ];
2206
+ for ( let i = 0; i < 3; i ++ ) rgb[ i ] += ( isNum( c ) ? c : c[ i ] ) * l.w / base;
2207
+
2208
+ }
2209
+
2210
+ if ( textured && textured.w / base >= 0.5 ) {
2211
+
2212
+ await this.applyColor( mat, 'color', textured.color, surface, 'map' );
2213
+ if ( shares.some( l => l !== textured && l.w > 0 ) ) this.warnOnce( 'mtlx:lobecolor', 'MaterialX lobes of different colours over a texture are read as the texture' );
2214
+
2215
+ } else {
2216
+
2217
+ mat.color.setRGB( rgb[ 0 ], rgb[ 1 ], rgb[ 2 ] );
2218
+
2219
+ }
2220
+
2221
+ mat.metalness = sum( [ 'conductor' ] ) / base;
2222
+ mat.transmission = sum( [ 'transmission' ] ) / base;
2223
+
2224
+ } else {
2225
+
2226
+ mat.color.setRGB( 0, 0, 0 );
2227
+
2228
+ }
2229
+
2230
+ // Roughness from the specular lobes by weight; a surface with no specular lobe reflects nothing specular.
2231
+ const specular = lobes.filter( l => [ 'conductor', 'transmission', 'reflection' ].includes( l.kind ) && l.w > 0 );
2232
+ const specularWeight = specular.reduce( ( s, l ) => s + l.w, 0 );
2233
+ mat.roughness = specularWeight > 0 ? specular.reduce( ( s, l ) => s + l.roughness * l.w, 0 ) / specularWeight : 1;
2234
+ const glossy = lobes.filter( l => [ 'transmission', 'reflection' ].includes( l.kind ) && l.w > 0 );
2235
+ mat.ior = glossy.length ? glossy.reduce( ( s, l ) => s + l.ior * l.w, 0 ) / glossy.reduce( ( s, l ) => s + l.w, 0 ) : 1.5;
2236
+ const reflection = lobes.filter( l => l.kind === 'reflection' && l.w > 0 );
2237
+ if ( reflection.length ) {
2238
+
2239
+ const tint = reflection.find( l => isValue( l.tint ) )?.tint;
2240
+ if ( Array.isArray( tint ) ) mat.specularColor.setRGB( tint[ 0 ], tint[ 1 ], tint[ 2 ] );
2241
+ mat.specularIntensity = Math.min( 1, sum( [ 'reflection' ] ) );
2242
+
2243
+ } else if ( mat.transmission === 0 ) {
2244
+
2245
+ mat.specularIntensity = 0;
2246
+
2247
+ }
2248
+
2249
+ const sheen = lobes.find( l => l.kind === 'sheen' );
2250
+ if ( sheen ) {
2251
+
2252
+ mat.sheen = Math.min( 1, sum( [ 'sheen' ] ) );
2253
+ if ( Array.isArray( sheen.color ) ) mat.sheenColor.setRGB( sheen.color[ 0 ], sheen.color[ 1 ], sheen.color[ 2 ] );
2254
+ mat.sheenRoughness = sheen.roughness;
2255
+
2256
+ }
2257
+
2258
+ const subsurface = lobes.find( l => l.kind === 'subsurface' );
2259
+ if ( subsurface && base + subsurface.w > 0 ) {
2260
+
2261
+ mat.subsurface = Math.min( 1, sum( [ 'subsurface' ] ) / ( base + sum( [ 'subsurface' ] ) ) );
2262
+ mat.subsurfaceColor = Array.isArray( subsurface.color ) ? new Color().setRGB( subsurface.color[ 0 ], subsurface.color[ 1 ], subsurface.color[ 2 ] ) : mat.color.clone();
2263
+ mat.subsurfaceRadius = Array.isArray( subsurface.radius ) ? [ ...subsurface.radius ] : [ 1, 1, 1 ];
2264
+ mat.subsurfaceAnisotropy = subsurface.anisotropy;
2265
+
2266
+ }
2267
+
2268
+ const translucent = lobes.find( l => l.kind === 'translucent' );
2269
+ if ( translucent ) {
2270
+
2271
+ const share = sum( [ 'translucent' ] ) / ( sum( [ 'diffuse' ] ) + sum( [ 'translucent' ] ) );
2272
+ mat.diffuseTransmission = Math.min( 1, share );
2273
+ mat.diffuseTransmissionColor = Array.isArray( translucent.color ) ? new Color().setRGB( translucent.color[ 0 ], translucent.color[ 1 ], translucent.color[ 2 ] ) : new Color( 1, 1, 1 );
2274
+
2275
+ }
2276
+
2277
+ if ( emission.length ) {
2278
+
2279
+ const textured = emission.find( e => e.color?.texture );
2280
+ if ( textured ) {
2281
+
2282
+ mat.emissive.setRGB( 1, 1, 1 );
2283
+ await this.applyColor( mat, 'emissive', textured.color, surface, 'emissiveMap' );
2284
+ mat.emissiveIntensity = textured.w;
2285
+
2286
+ } else {
2287
+
2288
+ const rgb = [ 0, 0, 0 ];
2289
+ for ( const e of emission ) {
2290
+
2291
+ const c = isValue( e.color?.value ) ? e.color.value : [ 1, 1, 1 ];
2292
+ for ( let i = 0; i < 3; i ++ ) rgb[ i ] += ( isNum( c ) ? c : c[ i ] ) * e.w;
2293
+
2294
+ }
2295
+
2296
+ const peak = Math.max( ...rgb );
2297
+ if ( peak > 0 ) {
2298
+
2299
+ mat.emissive.setRGB( rgb[ 0 ] / peak, rgb[ 1 ] / peak, rgb[ 2 ] / peak );
2300
+ mat.emissiveIntensity = peak;
2301
+
2302
+ }
2303
+
2304
+ }
2305
+
2306
+ }
2307
+
2308
+ await this.mtlxNormal( mat, lobes.find( l => l.normal?.texture )?.normal, surface );
2309
+ this.mtlxOpacity( mat, await this.term( shader, 'opacity' ) );
2310
+ return surface;
2311
+
2312
+ }
2313
+
2314
+ /** The BSDF lobes below `attribute`, each with its weight in the whole. */
2315
+ async mtlxLobes( prim, attribute, w, lobes, hops ) {
2316
+
2317
+ const source = hops < 32 ? await this.stage.connectionSource( prim, attribute ) : null;
2318
+ const id = source?.prim.value( 'info:id' );
2319
+ if ( ! id || w <= 0 ) return;
2320
+ const node = source.prim;
2321
+ const t = name => this.term( node, name, hops + 1 );
2322
+ const num = async ( name, fallback ) => {
2323
+
2324
+ const v = ( await t( name ) )?.value;
2325
+ return isNum( v ) ? v : Array.isArray( v ) ? mean( v ) : fallback;
2326
+
2327
+ };
2328
+
2329
+ const weight = w * await num( 'weight', 1 );
2330
+ switch ( mtlxNode( id ) ) {
2331
+
2332
+ case 'oren_nayar_diffuse_bsdf':
2333
+ case 'burley_diffuse_bsdf':
2334
+ lobes.push( { kind: 'diffuse', w: weight, color: await t( 'color' ), normal: await t( 'normal' ) } );
2335
+ return;
2336
+ case 'conductor_bsdf': {
2337
+
2338
+ const ior = await t( 'ior' );
2339
+ let color = ior?.artistic?.reflectivity ?? null;
2340
+ if ( ! color ) {
2341
+
2342
+ // F0 from a complex index: ((n − 1)² + k²) / ((n + 1)² + k²) per channel.
2343
+ const n = isValue( ior?.value ) ? ior.value : [ 0.18, 0.42, 1.37 ];
2344
+ const k = ( await t( 'extinction' ) )?.value ?? [ 3.42, 2.35, 1.77 ];
2345
+ color = { value: zip( n, k, ( a, b ) => ( ( a - 1 ) ** 2 + b * b ) / ( ( a + 1 ) ** 2 + b * b ) ) };
2346
+
2347
+ }
2348
+
2349
+ lobes.push( { kind: 'conductor', w: weight, color, roughness: await num( 'roughness', 0.2 ), normal: await t( 'normal' ) } );
2350
+ return;
2351
+
2352
+ }
2353
+
2354
+ case 'dielectric_bsdf':
2355
+ case 'generalized_schlick_bsdf': {
2356
+
2357
+ const mode = ( await t( 'scatter_mode' ) )?.value ?? 'R';
2358
+ const roughness = await num( 'roughness', 0.05 );
2359
+ const normal = await t( 'normal' );
2360
+ if ( mtlxNode( id ) === 'generalized_schlick_bsdf' ) {
2361
+
2362
+ // A Schlick lobe as bright as a metal's is one; a dim one is a dielectric's specular.
2363
+ const color0 = ( await t( 'color0' ) )?.value ?? [ 1, 1, 1 ];
2364
+ if ( mean( color0 ) > 0.2 && mode === 'R' ) lobes.push( { kind: 'conductor', w: weight, color: { value: color0 }, roughness, normal } );
2365
+ else lobes.push( { kind: mode === 'R' ? 'reflection' : 'transmission', w: weight, tint: [ 1, 1, 1 ], color: { value: [ 1, 1, 1 ] }, ior: 1.5, roughness, normal } );
2366
+ return;
2367
+
2368
+ }
2369
+
2370
+ const tint = ( await t( 'tint' ) )?.value ?? [ 1, 1, 1 ];
2371
+ const ior = await num( 'ior', 1.5 );
2372
+ lobes.push( { kind: mode === 'R' ? 'reflection' : 'transmission', w: weight, tint, color: { value: tint }, ior, roughness, normal } );
2373
+ return;
2374
+
2375
+ }
2376
+
2377
+ case 'sheen_bsdf':
2378
+ lobes.push( { kind: 'sheen', w: weight, color: ( await t( 'color' ) )?.value ?? [ 1, 1, 1 ], roughness: await num( 'roughness', 0.3 ) } );
2379
+ return;
2380
+ case 'subsurface_bsdf':
2381
+ lobes.push( { kind: 'subsurface', w: weight, color: ( await t( 'color' ) )?.value, radius: ( await t( 'radius' ) )?.value, anisotropy: await num( 'anisotropy', 0 ) } );
2382
+ return;
2383
+ case 'translucent_bsdf':
2384
+ lobes.push( { kind: 'translucent', w: weight, color: ( await t( 'color' ) )?.value } );
2385
+ return;
2386
+ case 'mix_bsdf': {
2387
+
2388
+ const mix = ( await t( 'mix' ) )?.value;
2389
+ if ( ! isNum( mix ) ) this.warnOnce( 'mtlx:mixbsdf', 'MaterialX BSDF mixes driven by a texture are read as an even split' );
2390
+ const m = isNum( mix ) ? Math.min( 1, Math.max( 0, mix ) ) : 0.5;
2391
+ await this.mtlxLobes( node, 'inputs:fg', w * m, lobes, hops + 1 );
2392
+ await this.mtlxLobes( node, 'inputs:bg', w * ( 1 - m ), lobes, hops + 1 );
2393
+ return;
2394
+
2395
+ }
2396
+
2397
+ case 'add_bsdf':
2398
+ await this.mtlxLobes( node, 'inputs:in1', w, lobes, hops + 1 );
2399
+ await this.mtlxLobes( node, 'inputs:in2', w, lobes, hops + 1 );
2400
+ return;
2401
+ case 'layer':
2402
+ case 'layer_bsdf':
2403
+ await this.mtlxLobes( node, 'inputs:top', w, lobes, hops + 1 );
2404
+ await this.mtlxLobes( node, 'inputs:base', w, lobes, hops + 1 );
2405
+ return;
2406
+ case 'multiply_bsdfC':
2407
+ case 'multiply_bsdfF': {
2408
+
2409
+ const by = ( await t( 'in2' ) )?.value;
2410
+ const start = lobes.length;
2411
+ await this.mtlxLobes( node, 'inputs:in1', w * ( isNum( by ) ? by : 1 ), lobes, hops + 1 );
2412
+ if ( Array.isArray( by ) ) for ( const l of lobes.slice( start ) ) if ( isValue( l.color?.value ) ) l.color = { value: zip( l.color.value, by, ( a, b ) => a * b ) };
2413
+ return;
2414
+
2415
+ }
2416
+
2417
+ case 'thin_film_bsdf':
2418
+ return;
2419
+
2420
+ }
2421
+
2422
+ this.warnOnce( `mtlx:${mtlxNode( id )}`, `MaterialX node "${mtlxNode( id )}" is not read — its lobe left out` );
2423
+
2424
+ }
2425
+
2426
+ /** The emission below `attribute`: uniform EDFs, added, mixed and scaled. */
2427
+ async mtlxEmission( prim, attribute, w, out, hops ) {
2428
+
2429
+ const source = hops < 32 ? await this.stage.connectionSource( prim, attribute ) : null;
2430
+ const id = source?.prim.value( 'info:id' );
2431
+ if ( ! id || w <= 0 ) return;
2432
+ const node = source.prim;
2433
+ const t = name => this.term( node, name, hops + 1 );
2434
+ switch ( mtlxNode( id ) ) {
2435
+
2436
+ case 'uniform_edf':
2437
+ case 'conical_edf':
2438
+ case 'measured_edf':
2439
+ out.push( { w, color: await t( 'color' ) } );
2440
+ return;
2441
+ case 'add_edf':
2442
+ await this.mtlxEmission( node, 'inputs:in1', w, out, hops + 1 );
2443
+ await this.mtlxEmission( node, 'inputs:in2', w, out, hops + 1 );
2444
+ return;
2445
+ case 'mix_edf': {
2446
+
2447
+ const mix = ( await t( 'mix' ) )?.value;
2448
+ const m = isNum( mix ) ? mix : 0.5;
2449
+ await this.mtlxEmission( node, 'inputs:fg', w * m, out, hops + 1 );
2450
+ await this.mtlxEmission( node, 'inputs:bg', w * ( 1 - m ), out, hops + 1 );
2451
+ return;
2452
+
2453
+ }
2454
+
2455
+ case 'multiply_edfC':
2456
+ case 'multiply_edfF': {
2457
+
2458
+ const by = ( await t( 'in2' ) )?.value;
2459
+ await this.mtlxEmission( node, 'inputs:in1', w * ( isValue( by ) ? mean( by ) : 1 ), out, hops + 1 );
2460
+ return;
2461
+
2462
+ }
2463
+
2464
+ }
2465
+
2466
+ this.warnOnce( `mtlx:${mtlxNode( id )}`, `MaterialX node "${mtlxNode( id )}" is not read — its emission left out` );
2467
+
2468
+ }
2469
+
1529
2470
  // ── cameras and lights ───────────────────────────────────────
1530
2471
 
1531
2472
  camera( prim, matrix ) {
@@ -1533,12 +2474,15 @@ export class USDSceneReader {
1533
2474
  const hAperture = prim.value( 'horizontalAperture' ) ?? 20.955;
1534
2475
  const vAperture = prim.value( 'verticalAperture' ) ?? 15.2908;
1535
2476
  const focal = prim.value( 'focalLength' ) ?? 50;
1536
- const [ near, far ] = prim.value( 'clippingRange' ) ?? [ 1, 1000000 ];
2477
+ const clipping = prim.value( 'clippingRange' );
2478
+ const [ near, far ] = clipping ?? [ 1, 1000000 ];
1537
2479
  const camera = prim.value( 'projection' ) === 'orthographic'
1538
2480
  // Apertures are in tenths of a scene unit.
1539
2481
  ? new OrthographicCamera( - hAperture / 20, hAperture / 20, vAperture / 20, - vAperture / 20, near, far )
1540
2482
  : new PerspectiveCamera( 2 * Math.atan( vAperture / ( 2 * focal ) ) / DEG, hAperture / vAperture, near, far );
1541
2483
  camera.name = prim.name;
2484
+ // An authored near clip is a clip plane the path tracer honours (UsdGeom's fallback of 1 would clip every camera).
2485
+ if ( clipping && near > 0 ) camera.userData.clipStart = near;
1542
2486
  const scale = new Vector3();
1543
2487
  matrix.decompose( camera.position, camera.quaternion, scale );
1544
2488
  camera.updateMatrixWorld( true );
@@ -1571,21 +2515,31 @@ export class USDSceneReader {
1571
2515
 
1572
2516
  }
1573
2517
 
2518
+ // UsdLux: `normalize` divides the radiance by the light's size (its surface area; a distant light's disc).
2519
+ const normalize = !! read( 'normalize', false );
1574
2520
  let light;
1575
2521
  if ( type === 'RectLight' || type === 'DiskLight' ) {
1576
2522
 
1577
2523
  const disk = type === 'DiskLight';
1578
2524
  const radius = read( 'radius', 0.5 );
1579
2525
  light = new RectAreaLight( new Color(), radiance, disk ? radius * 2 : read( 'width', 1 ), disk ? radius * 2 : read( 'height', 1 ) );
1580
- light.userData.normalize = read( 'normalize', false );
2526
+ light.userData.normalize = normalize;
1581
2527
  if ( disk ) light.userData.shape = 'disk';
2528
+ // Normalized, the radiance is intensity ÷ world area, so the power is π × intensity whatever the size.
2529
+ if ( normalize ) {
2530
+
2531
+ light.intensity = Math.PI * radiance;
2532
+ light.userData.__radianceConverted = true;
2533
+
2534
+ }
1582
2535
 
1583
2536
  } else if ( type === 'SphereLight' || type === 'CylinderLight' ) {
1584
2537
 
1585
2538
  const radius = read( 'radius', 0.5 );
1586
2539
  const cone = read( 'shaping:cone:angle', 180 );
1587
- // The engine's lamps take 4π × the radiant intensity, which a sphere of radiance L shows as L·πr².
1588
- const intensity = 4 * Math.PI * radiance * Math.PI * radius * radius;
2540
+ const area = type === 'SphereLight' ? 4 * Math.PI * radius * radius : 2 * Math.PI * radius * read( 'length', 1 );
2541
+ // The engine's point and spot lights take their power, π × radiance × area for a surface of radiance L.
2542
+ const intensity = Math.PI * radiance * ( normalize ? 1 : area );
1589
2543
  if ( cone < 90 ) {
1590
2544
 
1591
2545
  light = new SpotLight( new Color(), intensity );
@@ -1600,13 +2554,23 @@ export class USDSceneReader {
1600
2554
 
1601
2555
  light.decay = 2;
1602
2556
  light.distance = 0;
2557
+ // Its size softens its shadows (a disc facing the lit point, LightSerializer); treatAsPoint says it has none.
2558
+ if ( ! read( 'treatAsPoint', false ) && radius > 0 ) light.userData.radius = radius;
1603
2559
  light.userData.__candelaConverted = true;
1604
2560
  if ( type === 'CylinderLight' ) this.warnOnce( 'cylinder', 'USD cylinder lights are placed as point lights' );
1605
2561
 
1606
2562
  } else {
1607
2563
 
1608
- // Lux, as glTF's and three.js's are: the loader takes the luminous efficacy out.
1609
- light = new DirectionalLight( new Color(), radiance );
2564
+ // The irradiance on a surface facing the light: a disc of half-angle θ and radiance L gives π·sin²θ·L
2565
+ // (UsdLux's sizeFactor), so a normalized light's intensity is that irradiance.
2566
+ const angle = Math.max( read( 'angle', 0.53 ), 0 ) * DEG;
2567
+ const half = Math.min( angle / 2, Math.PI );
2568
+ const sin2 = Math.sin( half ) ** 2;
2569
+ const sizeFactor = half === 0 ? 1 : half <= Math.PI / 2 ? Math.PI * sin2 : Math.PI * ( 2 - sin2 );
2570
+ light = new DirectionalLight( new Color(), radiance * ( normalize ? 1 : sizeFactor ) );
2571
+ // The disc's angular diameter, which softens its shadows. Blender writes half its sun angle here.
2572
+ light.userData.angle = angle;
2573
+ light.userData.__luxConverted = true;
1610
2574
 
1611
2575
  }
1612
2576
 
@@ -1618,8 +2582,8 @@ export class USDSceneReader {
1618
2582
  matrix.decompose( light.position, light.quaternion, light.scale );
1619
2583
  if ( light.target ) {
1620
2584
 
1621
- const forward = new Vector3( 0, 0, - 1 ).transformDirection( matrix );
1622
- light.target.position.copy( forward );
2585
+ // A child of the light, so the light's own rotation aims it down its −Z.
2586
+ light.target.position.set( 0, 0, - 1 );
1623
2587
  light.add( light.target );
1624
2588
 
1625
2589
  }