three
Version:
JavaScript 3D library
879 lines (556 loc) • 18.5 kB
JavaScript
import {
Texture,
RepeatWrapping,
ClampToEdgeWrapping,
MirroredRepeatWrapping,
ImageLoader,
ImageBitmapLoader,
Matrix3,
Matrix4,
MeshBasicNodeMaterial,
MeshPhysicalNodeMaterial,
} from 'three/webgpu';
import {
float,
int,
bool,
sub,
vec2,
vec3,
vec4,
color,
uv,
mat3,
mat4,
element,
mx_transform_uv,
mx_srgb_texture_to_lin_rec709,
} from 'three/tsl';
import { MaterialXLogCodes } from './MaterialXLog.js';
import { createMaterialXCompileRegistry, compileNodeFromRegistry } from './compile/MaterialXCompileRegistry.js';
import { parseMaterialXNodeTree, parseMaterialXText } from './parse/MaterialXParser.js';
import { getSurfaceMapper } from './MaterialXSurfaceMappings.js';
import { MtlXLibrary } from './MaterialXNodeLibrary.js';
import { mxHextileCoord, mxHextileComputeBlendWeights } from './MaterialXHextile.js';
import { toBooleanNode } from './MaterialXUtils.js';
const colorSpaceLib = {
mx_srgb_texture_to_lin_rec709,
};
const DEFAULT_DOCUMENT_COLOR_SPACE = 'lin_rec709';
const IDENTITY_MAT3_VALUES = [ 1, 0, 0, 0, 1, 0, 0, 0, 1 ];
const IDENTITY_MAT4_VALUES = [ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 ];
const MATRIX_INVERSE_EPSILON = 1e-8;
const COMPILE_REGISTRY = createMaterialXCompileRegistry();
const TEXTURE_ADDRESS_MODE_WRAPPING = {
constant: ClampToEdgeWrapping,
clamp: ClampToEdgeWrapping,
periodic: RepeatWrapping,
mirror: MirroredRepeatWrapping,
};
const NODE_CLASS_BY_TYPE = {
integer: int,
float,
vector2: vec2,
vector3: vec3,
vector4: vec4,
color4: vec4,
color3: color,
boolean: null,
matrix33: mat3,
matrix44: mat4,
};
const OUTPUT_CHANNELS = {
outx: 0,
outr: 0,
outy: 1,
outg: 1,
outz: 2,
outb: 2,
outw: 3,
outa: 3,
};
function mxFlipUvY( uvNode ) {
return vec2( element( uvNode, 0 ), sub( 1, element( uvNode, 1 ) ) );
}
function mxIdentityUv( uvNode ) {
return uvNode;
}
function getBottomLeftUvSpaceHelpers( uvSpace ) {
const helper = uvSpace === 'top-left' ? mxFlipUvY : mxIdentityUv;
return {
mxToBottomLeftUvSpace: helper,
mxFromBottomLeftUvSpace: helper,
};
}
function normalizeUvSpace( uvSpace ) {
if ( uvSpace === undefined || uvSpace === null ) return 'bottom-left';
if ( uvSpace === 'bottom-left' || uvSpace === 'top-left' ) return uvSpace;
throw new Error( `Unsupported MaterialX uvSpace "${uvSpace}". Expected "bottom-left" or "top-left".` );
}
function isSvgUri( uri ) {
if ( typeof uri !== 'string' ) return false;
return /\.svg(?:$|[?#])/i.test( uri );
}
function normalizeTextureAddressMode( value ) {
if ( value === null || value === undefined || value === '' ) return 'periodic';
const mode = value.trim().toLowerCase();
return mode in TEXTURE_ADDRESS_MODE_WRAPPING ? mode : null;
}
function invertConstantMatrixValues( values, size ) {
if ( ! Array.isArray( values ) || values.length !== size * size ) return null;
if ( size === 3 ) {
const matrix = new Matrix3().setFromArray( values );
if ( Math.abs( matrix.determinant() ) < MATRIX_INVERSE_EPSILON ) return null;
matrix.invert();
// Convert Three.js internal column-major storage back to row-major literal order.
return matrix.transpose().elements;
}
if ( size === 4 ) {
const matrix = new Matrix4().setFromArray( values );
if ( Math.abs( matrix.determinant() ) < MATRIX_INVERSE_EPSILON ) return null;
matrix.invert();
// Convert Three.js internal column-major storage back to row-major literal order.
return matrix.transpose().elements;
}
return null;
}
function getOutputChannel( outputName ) {
return OUTPUT_CHANNELS[ outputName ] || 0;
}
function isChannelOutput( outputName ) {
return outputName in OUTPUT_CHANNELS;
}
class MaterialXNode {
constructor( materialX, nodeXML, nodePath = '' ) {
this.materialX = materialX;
this.nodeXML = nodeXML;
this.nodePath = nodePath ? nodePath + '/' + this.name : this.name;
this.parent = null;
this.node = null;
this.children = [];
}
get element() {
return this.nodeXML.nodeName;
}
get nodeGraph() {
return this.getAttribute( 'nodegraph' );
}
get nodeName() {
return this.getAttribute( 'nodename' );
}
get interfaceName() {
return this.getAttribute( 'interfacename' );
}
get output() {
return this.getAttribute( 'output' );
}
get name() {
return this.getAttribute( 'name' );
}
get type() {
return this.getAttribute( 'type' );
}
get value() {
return this.getAttribute( 'value' );
}
getNodeGraph() {
let nodeX = this;
while ( nodeX !== null ) {
if ( nodeX.element === 'nodegraph' ) break;
nodeX = nodeX.parent;
}
return nodeX;
}
getRoot() {
let nodeX = this;
while ( nodeX.parent !== null ) {
nodeX = nodeX.parent;
}
return nodeX;
}
get referencePath() {
let referencePath = null;
if ( this.nodeGraph !== null && this.output !== null ) {
referencePath = this.nodeGraph + '/' + this.output;
} else if ( this.nodeName !== null || this.interfaceName !== null ) {
const graphNode = this.getNodeGraph();
const scopedReference = this.nodeName || this.interfaceName;
if ( graphNode && scopedReference ) {
referencePath = graphNode.nodePath + '/' + scopedReference;
} else if ( this.nodeName !== null ) {
// Surface-level nodename links can legitimately target top-level siblings.
referencePath = this.nodeName;
}
}
return referencePath;
}
get hasReference() {
return this.referencePath !== null;
}
get isConst() {
return this.element === 'input' && this.value !== null && this.type !== 'filename';
}
getColorSpaceNode() {
const csSource = this.getAttribute( 'colorspace' );
const csTarget = this.getRoot().getAttribute( 'colorspace' );
if ( ! csSource || ! csTarget ) return null;
const nodeName = `mx_${csSource}_to_${csTarget}`;
return colorSpaceLib[ nodeName ] || null;
}
getTextureAddressMode( inputName ) {
const rawMode = this.getInputValueByName( inputName );
const mode = normalizeTextureAddressMode( rawMode );
if ( mode ) return mode;
this.materialX.log.add(
MaterialXLogCodes.INVALID_VALUE,
`Unsupported texture address mode "${rawMode}" on input "${inputName}". Expected constant, clamp, periodic, or mirror.`,
this.name,
);
return 'periodic';
}
getTextureAddressModes() {
return {
u: this.getTextureAddressMode( 'uaddressmode' ),
v: this.getTextureAddressMode( 'vaddressmode' ),
};
}
getTexture() {
const filePrefix = this.getRecursiveAttribute( 'fileprefix' ) || '';
const sourceURI = filePrefix + this.value;
const resolvedURI = this.materialX.resolveTextureURI( sourceURI );
const svgTexture = isSvgUri( resolvedURI );
const textureSourceNode = this.parent && typeof this.parent.getTextureAddressModes === 'function' ? this.parent : this;
const addressModes = textureSourceNode.getTextureAddressModes();
const textureCacheKey = `${resolvedURI}|${addressModes.u}|${addressModes.v}`;
if ( this.materialX.textureCache.has( textureCacheKey ) ) {
return this.materialX.textureCache.get( textureCacheKey );
}
let loader = svgTexture ? this.materialX.imageLoader : this.materialX.textureLoader;
if ( resolvedURI && ! svgTexture ) {
const handler = this.materialX.manager.getHandler( resolvedURI );
if ( handler !== null ) loader = handler;
}
const textureNode = new Texture();
textureNode.wrapS = TEXTURE_ADDRESS_MODE_WRAPPING[ addressModes.u ];
textureNode.wrapT = TEXTURE_ADDRESS_MODE_WRAPPING[ addressModes.v ];
textureNode.flipY = false;
this.materialX.textureCache.set( textureCacheKey, textureNode );
loader.load( resolvedURI, ( imageData ) => {
textureNode.image = imageData;
textureNode.needsUpdate = true;
}, undefined, () => {
throw new Error( `Failed to load texture "${resolvedURI}".` );
} );
return textureNode;
}
getClassFromType( type ) {
return NODE_CLASS_BY_TYPE[ type ] || null;
}
toBooleanNode( node ) {
return toBooleanNode( node );
}
getNode( out = null ) {
if ( this.node !== null && out === null ) return this.node;
let node;
if ( ( this.element === 'separate2' || this.element === 'separate3' || this.element === 'separate4' ) && out ) {
const inNode = this.getNodeByName( 'in' );
return element( inNode, getOutputChannel( out ) );
}
const type = this.type;
const channelRequested = this.element !== 'input' && this.element !== 'gltf_colorimage' && isChannelOutput( out );
if ( this.isConst ) {
if ( type === 'boolean' ) {
const normalized = this.getValue().trim().toLowerCase();
node = bool( normalized === 'true' || normalized === '1' );
} else if ( type === 'matrix33' ) {
node = this.getMatrix( 3 ) || mat3( 1, 0, 0, 0, 1, 0, 0, 0, 1 );
} else if ( type === 'matrix44' ) {
node = this.getMatrix( 4 ) || mat4( 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 );
} else if ( type === 'string' ) {
node = this.getValue();
} else {
const nodeClass = this.getClassFromType( type );
node = nodeClass ? nodeClass( ...this.getVector() ) : float( 0 );
}
} else if ( this.hasReference ) {
if ( this.element === 'output' && this.output && out === null ) out = this.output;
let requestedOutput = out;
// For nodegraph references, this input's `output` attribute selects the graph output
// itself and should not be forwarded as an output selector on the resolved node.
if ( this.element === 'input' && this.nodeGraph !== null && this.output !== null ) {
requestedOutput = null;
}
const referenceNode = this.materialX.getMaterialXNode( this.referencePath );
if ( referenceNode ) {
node = referenceNode.getNode( requestedOutput );
} else {
this.materialX.log.add(
MaterialXLogCodes.MISSING_REFERENCE,
`Missing MaterialX reference "${this.referencePath}" from "${this.name}".`,
this.name,
);
node = float( 0 );
}
} else if ( this.element === 'input' && this.name === 'texcoord' && this.type === 'vector2' ) {
let index = 0;
const defaultGeomProp = this.getAttribute( 'defaultgeomprop' );
if ( defaultGeomProp && /^UV(\d+)$/.test( defaultGeomProp ) ) {
index = parseInt( defaultGeomProp.match( /^UV(\d+)$/ )[ 1 ], 10 );
}
node = this.materialX.compileContext.mxToBottomLeftUvSpace( uv( index ) );
} else {
node = compileNodeFromRegistry( this, out, this.materialX.compileContext );
}
if ( node === null || node === undefined ) {
this.materialX.log.add(
MaterialXLogCodes.UNSUPPORTED_NODE,
`Unsupported MaterialX node category "${this.element}" on "${this.name}".`,
this.name,
);
node = float( 0 );
}
if ( channelRequested ) {
node = element( node, getOutputChannel( out ) );
}
const resolvedType = channelRequested ? 'float' : type;
if ( resolvedType === 'boolean' ) {
node = this.toBooleanNode( node );
} else if ( resolvedType === 'string' ) {
// String-typed inputs (for example transform* fromspace/tospace) are
// valid scalar parameters and should pass through without numeric casting.
node = typeof node === 'string' ? node : this.getValue();
} else {
const nodeToTypeClass = this.getClassFromType( resolvedType );
if ( nodeToTypeClass !== null ) {
node = nodeToTypeClass( node );
} else if ( resolvedType !== null && resolvedType !== undefined && resolvedType !== 'multioutput' ) {
this.materialX.log.add(
MaterialXLogCodes.INVALID_VALUE,
`Unexpected type "${resolvedType}" on node "${this.name}".`,
this.name,
);
node = float( 0 );
}
}
if ( node && typeof node === 'object' ) {
node.name = this.name;
}
this.node = node;
return node;
}
getChildByName( name ) {
for ( const input of this.children ) {
if ( input.name === name ) return input;
}
}
getNodes() {
const nodes = {};
for ( const input of this.children ) {
const value = input.getNode( input.output );
nodes[ input.name ] = value;
}
return nodes;
}
getNodeByName( name ) {
const child = this.getChildByName( name );
return child ? child.getNode( child.output ) : undefined;
}
getInputValueByName( name ) {
const child = this.getChildByName( name );
return child ? child.value : null;
}
getNodesByNames( ...names ) {
const nodes = [];
for ( const name of names ) {
const nodeValue = this.getNodeByName( name );
nodes.push( nodeValue );
}
return nodes;
}
getValue() {
return this.value ? this.value.trim() : '';
}
getVector() {
const vector = [];
for ( const val of this.getValue().split( /[,|\s]/ ) ) {
if ( val !== '' ) vector.push( Number( val.trim() ) );
}
return vector;
}
getMatrix( size ) {
const vector = this.getVector();
const expectedLength = size * size;
if ( vector.length !== expectedLength ) return null;
// MaterialX matrix values are serialized in column-major order.
// Reorder to row-major before constructing TSL matrix nodes so
// transformmatrix semantics match MaterialXJS and MaterialXView.
const reordered = [];
for ( let row = 0; row < size; row += 1 ) {
for ( let column = 0; column < size; column += 1 ) {
reordered.push( vector[ column * size + row ] );
}
}
return size === 3 ? mat3( ...reordered ) : mat4( ...reordered );
}
getAttribute( name ) {
const value = this.nodeXML.getAttribute( name );
if ( value === null && this.element === 'materialx' && name === 'colorspace' ) {
return DEFAULT_DOCUMENT_COLOR_SPACE;
}
return value;
}
getRecursiveAttribute( name ) {
let attribute = this.nodeXML.getAttribute( name );
if ( attribute === null && this.parent !== null ) {
attribute = this.parent.getRecursiveAttribute( name );
}
return attribute;
}
setMaterial( material ) {
const mapper = getSurfaceMapper( this.element );
if ( mapper ) {
mapper.apply( material, this.getNodes(), this.materialX.log, this.name );
} else {
this.materialX.log.add(
MaterialXLogCodes.UNSUPPORTED_NODE,
`Unsupported MaterialX node category "${this.element}" on "${this.name}".`,
this.name,
);
}
}
toBasicMaterial() {
const material = new MeshBasicNodeMaterial();
material.name = this.name;
for ( const nodeX of this.children.toReversed() ) {
if ( nodeX.name === 'out' ) {
material.colorNode = nodeX.getNode();
break;
}
}
return material;
}
resolveSurfaceShaderNode( nodeX ) {
if ( nodeX.hasReference ) {
return this.materialX.getMaterialXNode( nodeX.referencePath ) || null;
}
if ( nodeX.nodeName ) {
return this.materialX.getMaterialXNode( nodeX.nodeName ) || null;
}
return null;
}
toPhysicalMaterial() {
const material = new MeshPhysicalNodeMaterial();
material.name = this.name;
for ( const nodeX of this.children ) {
const shaderProperties = this.resolveSurfaceShaderNode( nodeX );
if ( shaderProperties === null ) {
this.materialX.log.add(
MaterialXLogCodes.MISSING_REFERENCE,
`Missing MaterialX reference "${nodeX.referencePath || nodeX.nodeName || '(unknown)'}" from "${nodeX.name}".`,
nodeX.name,
);
continue;
}
shaderProperties.setMaterial( material );
}
return material;
}
toMaterials( materialName = null ) {
const materials = {};
const surfaceMaterials = this.children.filter( ( nodeX ) => nodeX.element === 'surfacematerial' );
let selectedSurfaceMaterials = surfaceMaterials;
if ( materialName ) {
selectedSurfaceMaterials = surfaceMaterials.filter( ( nodeX ) => nodeX.name === materialName );
if ( selectedSurfaceMaterials.length === 0 ) {
this.materialX.log.add(
MaterialXLogCodes.MISSING_MATERIAL,
`Could not find surfacematerial named "${materialName}".`,
);
}
}
for ( const nodeX of selectedSurfaceMaterials ) {
const material = nodeX.toPhysicalMaterial();
materials[ material.name ] = material;
}
if ( Object.keys( materials ).length === 0 ) {
for ( const nodeX of this.children ) {
if ( nodeX.element === 'nodegraph' ) {
const material = nodeX.toBasicMaterial();
materials[ material.name ] = material;
}
}
}
return materials;
}
add( materialXNode ) {
materialXNode.parent = this;
this.children.push( materialXNode );
}
}
class MaterialXDocument {
constructor( manager, path, log, archiveResolver = null, uvSpace = 'bottom-left' ) {
this.manager = manager;
this.path = path;
this.log = log;
this.archiveResolver = archiveResolver;
this.uvSpace = normalizeUvSpace( uvSpace );
this.nodesXLib = new Map();
this.imageLoader = new ImageLoader( manager );
this.imageLoader.setPath( path );
this.textureLoader = new ImageBitmapLoader( manager );
this.textureLoader.setOptions( { imageOrientation: 'none' } );
this.textureLoader.setPath( path );
this.textureCache = new Map();
const bottomLeftUvSpaceHelpers = getBottomLeftUvSpaceHelpers( this.uvSpace );
this.compileContext = {
compileRegistry: COMPILE_REGISTRY,
nodeLibrary: MtlXLibrary,
...bottomLeftUvSpaceHelpers,
mxTransformUv: mx_transform_uv,
mxHextileCoord,
mxHextileComputeBlendWeights,
invertConstantMatrixValues,
IDENTITY_MAT3_VALUES,
IDENTITY_MAT4_VALUES,
};
}
resolveTextureURI( uri ) {
if ( this.archiveResolver ) {
const archiveURI = this.archiveResolver( uri );
if ( archiveURI ) return archiveURI;
}
return uri;
}
addMaterialXNode( materialXNode ) {
this.nodesXLib.set( materialXNode.nodePath, materialXNode );
}
getMaterialXNode( ...names ) {
return this.nodesXLib.get( names.join( '/' ) );
}
parseNode( nodeXML, nodePath = '' ) {
return parseMaterialXNodeTree(
nodeXML,
( childNodeXML, childNodePath ) => new MaterialXNode( this, childNodeXML, childNodePath ),
( materialXNode ) => this.addMaterialXNode( materialXNode ),
nodePath,
);
}
parse( text, materialName = null, options = {} ) {
const rootNode = parseMaterialXText(
text,
( childNodeXML, childNodePath ) => new MaterialXNode( this, childNodeXML, childNodePath ),
( materialXNode ) => this.addMaterialXNode( materialXNode ),
);
if ( options.interfaceValidator ) {
options.interfaceValidator( rootNode, this.log );
}
const materials = rootNode.toMaterials( materialName );
return {
materials,
log: this.log.entries,
errors: this.log.errors,
warnings: this.log.warnings,
};
}
}
export { MaterialXDocument };