From acf250aab58dd829c3049fa5c38548e0967f0d76 Mon Sep 17 00:00:00 2001 From: Ben Houston Date: Mon, 27 Apr 2026 14:24:01 -0400 Subject: [PATCH 01/40] upgraded MaterialX support --- docs/pages/MaterialXLoader.html.md | 42 +- examples/jsm/loaders/MaterialXLoader.js | 1073 +-------------- .../jsm/loaders/materialx/MaterialXArchive.js | 108 ++ .../loaders/materialx/MaterialXDocument.js | 1163 +++++++++++++++++ .../loaders/materialx/MaterialXNodeLibrary.js | 930 +++++++++++++ .../materialx/MaterialXNodeRegistry.js | 86 ++ .../materialx/MaterialXSurfaceMappings.js | 641 +++++++++ .../materialx/MaterialXSurfaceRegistry.js | 43 + .../materialx/MaterialXTranslatorTypes.js | 57 + .../jsm/loaders/materialx/MaterialXUtils.js | 12 + .../loaders/materialx/MaterialXWarnings.js | 205 +++ .../compile/MaterialXCompileRegistry.js | 443 +++++++ .../materialx/parse/MaterialXParser.js | 28 + src/Three.TSL.js | 4 + src/nodes/materialx/MaterialXNodes.js | 398 +++++- 15 files changed, 4173 insertions(+), 1060 deletions(-) create mode 100644 examples/jsm/loaders/materialx/MaterialXArchive.js create mode 100644 examples/jsm/loaders/materialx/MaterialXDocument.js create mode 100644 examples/jsm/loaders/materialx/MaterialXNodeLibrary.js create mode 100644 examples/jsm/loaders/materialx/MaterialXNodeRegistry.js create mode 100644 examples/jsm/loaders/materialx/MaterialXSurfaceMappings.js create mode 100644 examples/jsm/loaders/materialx/MaterialXSurfaceRegistry.js create mode 100644 examples/jsm/loaders/materialx/MaterialXTranslatorTypes.js create mode 100644 examples/jsm/loaders/materialx/MaterialXUtils.js create mode 100644 examples/jsm/loaders/materialx/MaterialXWarnings.js create mode 100644 examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js create mode 100644 examples/jsm/loaders/materialx/parse/MaterialXParser.js diff --git a/docs/pages/MaterialXLoader.html.md b/docs/pages/MaterialXLoader.html.md index 3f9c6e87f88f49..9183e564c9d7c6 100644 --- a/docs/pages/MaterialXLoader.html.md +++ b/docs/pages/MaterialXLoader.html.md @@ -10,7 +10,7 @@ The node materials loaded with this loader can only be used with [WebGPURenderer ```js const loader = new MaterialXLoader().setPath( SAMPLE_PATH ); -const materials = await loader.loadAsync( 'standard_surface_brass_tiled.mtlx' ); +const { materials, report } = await loader.loadAsync( 'standard_surface_brass_tiled.mtlx' ); ``` ## Import @@ -57,7 +57,29 @@ Executed when errors occur. **Returns:** A reference to this loader. -### .parse( text : string ) : Object. +### .loadAsync( url : string, onProgress : onProgressCallback, options : Object ) : Promise + +Asynchronously loads a MaterialX asset. + +**url** + +The path/URL of the file to be loaded. This can be a `.mtlx` file, a `.mtlx.zip` package, or a data URI. + +**onProgress** + +Executed while the loading is in progress. + +**options** + +Optional MaterialX translation options. + +* `materialName`: Selects one `surfacematerial` by name from a multi-material document. +* `issuePolicy`: Controls issue handling. Supported values are `warn`, `error-core`, and `error-all`. +* `onWarning`: Callback executed for structured translation warnings. + +**Returns:** A promise resolving with an object containing `materials` and `report`. + +### .parse( text : string, options : Object ) : Object Parses the given MaterialX data and returns the resulting materials. @@ -82,9 +104,23 @@ Supported standard\_surface inputs: The raw MaterialX data as a string. +**options** + +Optional MaterialX translation options. See [loadAsync](#loadAsync). + **Overrides:** [Loader#parse](Loader.html#parse) -**Returns:** A dictionary holding the parse node materials. +**Returns:** An object containing `materials` and `report`. `materials` is a dictionary holding the parsed node materials. + +### .parseBuffer( data : ArrayBuffer, url : string, options : Object ) : Object + +Parses raw MaterialX data from an `ArrayBuffer`, `Uint8Array`, or string. ZIP buffers are detected automatically. + +**Returns:** An object containing `materials` and `report`. + +### .dispose() : MaterialXLoader + +Releases object URLs created while loading `.mtlx.zip` package resources. ## Source diff --git a/examples/jsm/loaders/MaterialXLoader.js b/examples/jsm/loaders/MaterialXLoader.js index 8c4271c5142589..d64660d396548b 100644 --- a/examples/jsm/loaders/MaterialXLoader.js +++ b/examples/jsm/loaders/MaterialXLoader.js @@ -1,202 +1,44 @@ -import { - FileLoader, Loader, ImageBitmapLoader, Texture, RepeatWrapping, MeshBasicNodeMaterial, - MeshPhysicalNodeMaterial, DoubleSide, -} from 'three/webgpu'; +import { FileLoader, Loader } from 'three/webgpu'; -import { - float, bool, int, vec2, vec3, vec4, color, texture, - positionLocal, positionWorld, uv, vertexColor, - normalLocal, normalWorld, tangentLocal, tangentWorld, - mul, abs, sign, floor, ceil, round, sin, cos, tan, - asin, acos, sqrt, exp, clamp, min, max, normalize, length, dot, cross, normalMap, - remap, smoothstep, luminance, mx_rgbtohsv, mx_hsvtorgb, - mix, saturation, transpose, determinant, inverse, log, reflect, refract, element, - mx_ramplr, mx_ramptb, mx_splitlr, mx_splittb, - mx_fractal_noise_float, mx_noise_float, mx_cell_noise_float, mx_worley_noise_float, - mx_transform_uv, - mx_safepower, mx_contrast, - mx_srgb_texture_to_lin_rec709, - mx_add, mx_atan2, mx_divide, mx_modulo, mx_multiply, mx_power, mx_subtract, - mx_timer, mx_frame, mat3, mx_ramp4, - mx_invert, mx_ifgreater, mx_ifgreatereq, mx_ifequal, distance, - mx_separate, mx_place2d, mx_rotate2d, mx_rotate3d, mx_heighttonormal, - mx_unifiednoise2d, mx_unifiednoise3d -} from 'three/tsl'; +import { MaterialXDocument } from './materialx/MaterialXDocument.js'; +import { MaterialXIssueCollector } from './materialx/MaterialXWarnings.js'; +import { isZipBuffer, readMtlxArchive, createArchiveResolver } from './materialx/MaterialXArchive.js'; -const colorSpaceLib = { - mx_srgb_texture_to_lin_rec709 -}; +const _textDecoder = new TextDecoder(); -class MXElement { +function getResourcePath( loaderPath, url ) { - constructor( name, nodeFunc, params = [] ) { + if ( loaderPath ) return loaderPath; - this.name = name; - this.nodeFunc = nodeFunc; - this.params = params; - - } + const index = url.lastIndexOf( '/' ); + return index === - 1 ? '' : url.slice( 0, index + 1 ); } -// Ref: https://github.com/mrdoob/three.js/issues/24674 - -// Enhanced separate node to support multi-output referencing (outx, outy, outz, outw) - -// Type/arity-aware MaterialX node wrappers - -const MXElements = [ - - // << Math >> - new MXElement( 'add', mx_add, [ 'in1', 'in2' ] ), - new MXElement( 'subtract', mx_subtract, [ 'in1', 'in2' ] ), - new MXElement( 'multiply', mx_multiply, [ 'in1', 'in2' ] ), - new MXElement( 'divide', mx_divide, [ 'in1', 'in2' ] ), - new MXElement( 'modulo', mx_modulo, [ 'in1', 'in2' ] ), - new MXElement( 'absval', abs, [ 'in1', 'in2' ] ), - new MXElement( 'sign', sign, [ 'in1', 'in2' ] ), - new MXElement( 'floor', floor, [ 'in1', 'in2' ] ), - new MXElement( 'ceil', ceil, [ 'in1', 'in2' ] ), - new MXElement( 'round', round, [ 'in1', 'in2' ] ), - new MXElement( 'power', mx_power, [ 'in1', 'in2' ] ), - new MXElement( 'sin', sin, [ 'in' ] ), - new MXElement( 'cos', cos, [ 'in' ] ), - new MXElement( 'tan', tan, [ 'in' ] ), - new MXElement( 'asin', asin, [ 'in' ] ), - new MXElement( 'acos', acos, [ 'in' ] ), - new MXElement( 'atan2', mx_atan2, [ 'in1', 'in2' ] ), - new MXElement( 'sqrt', sqrt, [ 'in' ] ), - new MXElement( 'ln', log, [ 'in' ] ), - new MXElement( 'exp', exp, [ 'in' ] ), - new MXElement( 'clamp', clamp, [ 'in', 'low', 'high' ] ), - new MXElement( 'min', min, [ 'in1', 'in2' ] ), - new MXElement( 'max', max, [ 'in1', 'in2' ] ), - new MXElement( 'normalize', normalize, [ 'in' ] ), - new MXElement( 'magnitude', length, [ 'in1', 'in2' ] ), - new MXElement( 'dotproduct', dot, [ 'in1', 'in2' ] ), - new MXElement( 'crossproduct', cross, [ 'in' ] ), - new MXElement( 'distance', distance, [ 'in1', 'in2' ] ), - new MXElement( 'invert', mx_invert, [ 'in', 'amount' ] ), - //new MtlXElement( 'transformpoint', ... ), - //new MtlXElement( 'transformvector', ... ), - //new MtlXElement( 'transformnormal', ... ), - new MXElement( 'transformmatrix', mul, [ 'in1', 'in2' ] ), - new MXElement( 'normalmap', normalMap, [ 'in', 'scale' ] ), - new MXElement( 'transpose', transpose, [ 'in' ] ), - new MXElement( 'determinant', determinant, [ 'in' ] ), - new MXElement( 'invertmatrix', inverse, [ 'in' ] ), - new MXElement( 'creatematrix', mat3, [ 'in1', 'in2', 'in3' ] ), - //new MtlXElement( 'rotate2d', rotateUV, [ 'in', radians( 'amount' )** ] ), - //new MtlXElement( 'rotate3d', ... ), - //new MtlXElement( 'arrayappend', ... ), - //new MtlXElement( 'dot', ... ), - - new MXElement( 'length', length, [ 'in' ] ), - new MXElement( 'crossproduct', cross, [ 'in1', 'in2' ] ), - new MXElement( 'floor', floor, [ 'in' ] ), - new MXElement( 'ceil', ceil, [ 'in' ] ), - - // << Adjustment >> - new MXElement( 'remap', remap, [ 'in', 'inlow', 'inhigh', 'outlow', 'outhigh' ] ), - new MXElement( 'smoothstep', smoothstep, [ 'in', 'low', 'high' ] ), - //new MtlXElement( 'curveadjust', ... ), - //new MtlXElement( 'curvelookup', ... ), - new MXElement( 'luminance', luminance, [ 'in', 'lumacoeffs' ] ), - new MXElement( 'rgbtohsv', mx_rgbtohsv, [ 'in' ] ), - new MXElement( 'hsvtorgb', mx_hsvtorgb, [ 'in' ] ), - - // << Mix >> - new MXElement( 'mix', mix, [ 'bg', 'fg', 'mix' ] ), +class MaterialXLoader extends Loader { - // << Channel >> - new MXElement( 'combine2', vec2, [ 'in1', 'in2' ] ), - new MXElement( 'combine3', vec3, [ 'in1', 'in2', 'in3' ] ), - new MXElement( 'combine4', vec4, [ 'in1', 'in2', 'in3', 'in4' ] ), + constructor( manager ) { - // << Procedural >> - new MXElement( 'ramplr', mx_ramplr, [ 'valuel', 'valuer', 'texcoord' ] ), - new MXElement( 'ramptb', mx_ramptb, [ 'valuet', 'valueb', 'texcoord' ] ), - new MXElement( 'ramp4', mx_ramp4, [ 'valuetl', 'valuetr', 'valuebl', 'valuebr', 'texcoord' ] ), - new MXElement( 'splitlr', mx_splitlr, [ 'valuel', 'valuer', 'texcoord' ] ), - new MXElement( 'splittb', mx_splittb, [ 'valuet', 'valueb', 'texcoord' ] ), - new MXElement( 'noise2d', mx_noise_float, [ 'texcoord', 'amplitude', 'pivot' ] ), - new MXElement( 'noise3d', mx_noise_float, [ 'texcoord', 'amplitude', 'pivot' ] ), - new MXElement( 'fractal3d', mx_fractal_noise_float, [ 'position', 'octaves', 'lacunarity', 'diminish', 'amplitude' ] ), - new MXElement( 'cellnoise2d', mx_cell_noise_float, [ 'texcoord' ] ), - new MXElement( 'cellnoise3d', mx_cell_noise_float, [ 'texcoord' ] ), - new MXElement( 'worleynoise2d', mx_worley_noise_float, [ 'texcoord', 'jitter' ] ), - new MXElement( 'worleynoise3d', mx_worley_noise_float, [ 'texcoord', 'jitter' ] ), - new MXElement( 'unifiednoise2d', mx_unifiednoise2d, [ 'type', 'texcoord', 'freq', 'offset', 'jitter', 'outmin', 'outmax', 'clampoutput', 'octaves', 'lacunarity', 'diminish' ] ), - new MXElement( 'unifiednoise3d', mx_unifiednoise3d, [ 'type', 'texcoord', 'freq', 'offset', 'jitter', 'outmin', 'outmax', 'clampoutput', 'octaves', 'lacunarity', 'diminish' ] ), - // << Supplemental >> - //new MtlXElement( 'tiledimage', ... ), - //new MtlXElement( 'triplanarprojection', triplanarTextures, [ 'filex', 'filey', 'filez' ] ), - //new MtlXElement( 'ramp4', ... ), - new MXElement( 'place2d', mx_place2d, [ 'texcoord', 'pivot', 'scale', 'rotate', 'offset', 'operationorder' ] ), - new MXElement( 'safepower', mx_safepower, [ 'in1', 'in2' ] ), - new MXElement( 'contrast', mx_contrast, [ 'in', 'amount', 'pivot' ] ), - //new MtlXElement( 'hsvadjust', ... ), - new MXElement( 'saturate', saturation, [ 'in', 'amount' ] ), - new MXElement( 'extract', element, [ 'in', 'index' ] ), - new MXElement( 'separate2', mx_separate, [ 'in' ] ), - new MXElement( 'separate3', mx_separate, [ 'in' ] ), - new MXElement( 'separate4', mx_separate, [ 'in' ] ), - new MXElement( 'reflect', reflect, [ 'in', 'normal' ] ), - new MXElement( 'refract', refract, [ 'in', 'normal', 'ior' ] ), + super( manager ); - new MXElement( 'time', mx_timer ), - new MXElement( 'frame', mx_frame ), - new MXElement( 'ifgreater', mx_ifgreater, [ 'value1', 'value2', 'in1', 'in2' ] ), - new MXElement( 'ifgreatereq', mx_ifgreatereq, [ 'value1', 'value2', 'in1', 'in2' ] ), - new MXElement( 'ifequal', mx_ifequal, [ 'value1', 'value2', 'in1', 'in2' ] ), + this.archiveDisposer = null; - // Placeholder implementations for unsupported nodes - new MXElement( 'rotate2d', mx_rotate2d, [ 'in', 'amount' ] ), - new MXElement( 'rotate3d', mx_rotate3d, [ 'in', 'amount', 'axis' ] ), - new MXElement( 'heighttonormal', mx_heighttonormal, [ 'in', 'scale', 'texcoord' ] ), + } -]; + dispose() { -const MtlXLibrary = {}; -MXElements.forEach( element => MtlXLibrary[ element.name ] = element ); + if ( this.archiveDisposer ) { -/** - * A loader for the MaterialX format. - * - * The node materials loaded with this loader can only be used with {@link WebGPURenderer}. - * - * ```js - * const loader = new MaterialXLoader().setPath( SAMPLE_PATH ); - * const materials = await loader.loadAsync( 'standard_surface_brass_tiled.mtlx' ); - * ``` - * - * @augments Loader - * @three_import import { MaterialXLoader } from 'three/addons/loaders/MaterialXLoader.js'; - */ -class MaterialXLoader extends Loader { + this.archiveDisposer(); + this.archiveDisposer = null; - /** - * Constructs a new MaterialX loader. - * - * @param {LoadingManager} [manager] - The loading manager. - */ - constructor( manager ) { + } - super( manager ); + return this; } - /** - * Starts loading from the given URL and passes the loaded MaterialX asset - * to the `onLoad()` callback. - * - * @param {string} url - The path/URL of the file to be loaded. This can also be a data URI. - * @param {function(Object)} onLoad - Executed when the loading process has been finished. - * @param {onProgressCallback} onProgress - Executed while the loading is in progress. - * @param {onErrorCallback} onError - Executed when errors occur. - * @return {MaterialXLoader} A reference to this loader. - */ - load( url, onLoad, onProgress, onError ) { + load( url, onLoad, onProgress, onError, options = {} ) { const _onError = function ( e ) { @@ -214,11 +56,12 @@ class MaterialXLoader extends Loader { new FileLoader( this.manager ) .setPath( this.path ) - .load( url, async ( text ) => { + .setResponseType( 'arraybuffer' ) + .load( url, ( data ) => { try { - onLoad( this.parse( text ) ); + onLoad( this.parseBuffer( data, url, options ) ); } catch ( e ) { @@ -232,870 +75,72 @@ class MaterialXLoader extends Loader { } - /** - * Parses the given MaterialX data and returns the resulting materials. - * - * Supported standard_surface inputs: - * - base, base_color: Base color/albedo - * - opacity: Alpha/transparency - * - specular_roughness: Surface roughness - * - metalness: Metallic property - * - specular: Specular reflection intensity - * - specular_color: Specular reflection color - * - ior: Index of refraction - * - specular_anisotropy, specular_rotation: Anisotropic reflection - * - transmission, transmission_color: Transmission properties - * - thin_film_thickness, thin_film_ior: Thin film interference - * - sheen, sheen_color, sheen_roughness: Sheen properties - * - normal: Normal map - * - coat, coat_roughness, coat_color: Clearcoat properties - * - emission, emissionColor: Emission properties - * - * @param {string} text - The raw MaterialX data as a string. - * @return {Object} A dictionary holding the parse node materials. - */ - parse( text ) { - - return new MaterialX( this.manager, this.path ).parse( text ); - - } - -} - -class MaterialXNode { - - constructor( materialX, nodeXML, nodePath = '' ) { - - if ( ! materialX || typeof materialX !== 'object' ) { - - console.warn( 'MaterialXNode: materialX argument is not an object!', { 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 ) { - - referencePath = this.getNodeGraph().nodePath + '/' + ( this.nodeName || this.interfaceName ); - - } - - 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' ); - - const nodeName = `mx_${ csSource }_to_${ csTarget }`; - - return colorSpaceLib[ nodeName ]; - - } - - getTexture() { - - const filePrefix = this.getRecursiveAttribute( 'fileprefix' ) || ''; - const uri = filePrefix + this.value; - - if ( this.materialX.textureCache.has( uri ) ) { - - return this.materialX.textureCache.get( uri ); + loadAsync( url, onProgress, options = {} ) { - } - - let loader = this.materialX.textureLoader; + if ( onProgress && typeof onProgress === 'object' ) { - if ( uri ) { - - const handler = this.materialX.manager.getHandler( uri ); - if ( handler !== null ) loader = handler; + options = onProgress; + onProgress = undefined; } - const texture = new Texture(); - texture.wrapS = texture.wrapT = RepeatWrapping; - - this.materialX.textureCache.set( uri, texture ); + return new Promise( ( resolve, reject ) => { - loader.load( uri, function ( imageBitmap ) { - - texture.image = imageBitmap; - texture.needsUpdate = true; + this.load( url, resolve, onProgress, reject, options ); } ); - return texture; - } - getClassFromType( type ) { - - let nodeClass = null; - - if ( type === 'integer' ) nodeClass = int; - else if ( type === 'float' ) nodeClass = float; - else if ( type === 'vector2' ) nodeClass = vec2; - else if ( type === 'vector3' ) nodeClass = vec3; - else if ( type === 'vector4' || type === 'color4' ) nodeClass = vec4; - else if ( type === 'color3' ) nodeClass = color; - else if ( type === 'boolean' ) nodeClass = bool; - - return nodeClass; - - } - - getNode( out = null ) { - - let node = this.node; - - if ( node !== null && out === null ) { - - return node; - - } - - // Handle - if ( - this.element === 'input' && - this.name === 'texcoord' && - this.type === 'vector2' - ) { - - // Try to get index from defaultgeomprop (e.g., "UV0" => 0) - let index = 0; - const defaultGeomProp = this.getAttribute( 'defaultgeomprop' ); - if ( defaultGeomProp && /^UV(\d+)$/.test( defaultGeomProp ) ) { - - index = parseInt( defaultGeomProp.match( /^UV(\d+)$/ )[ 1 ], 10 ); - - } - - node = uv( index ); - - } - - // Multi-output support for separate/separate3 - if ( - ( this.element === 'separate3' || this.element === 'separate2' || this.element === 'separate4' ) && - out && typeof out === 'string' && out.startsWith( 'out' ) - ) { - - const inNode = this.getNodeByName( 'in' ); - return mx_separate( inNode, out ); - - } - - // - - const type = this.type; - - if ( this.isConst ) { - - const nodeClass = this.getClassFromType( type ); - - node = nodeClass( ...this.getVector() ); - - } else if ( this.hasReference ) { - - if ( this.element === 'output' && this.output && out === null ) { - - out = this.output; - - } - - node = this.materialX.getMaterialXNode( this.referencePath ).getNode( out ); - - } else { - - const element = this.element; - - if ( element === 'convert' ) { - - const nodeClass = this.getClassFromType( type ); - - node = nodeClass( this.getNodeByName( 'in' ) ); - - } else if ( element === 'constant' ) { - - node = this.getNodeByName( 'value' ); - - } else if ( element === 'position' ) { - - const space = this.getAttribute( 'space' ); - node = space === 'world' ? positionWorld : positionLocal; - - } else if ( element === 'normal' ) { - - const space = this.getAttribute( 'space' ); - node = space === 'world' ? normalWorld : normalLocal; - - } else if ( element === 'tangent' ) { + parseBuffer( data, url = '', options = {} ) { - const space = this.getAttribute( 'space' ); - node = space === 'world' ? tangentWorld : tangentLocal; + this.dispose(); - } else if ( element === 'texcoord' ) { + let text; + let archiveResolver = null; - const indexNode = this.getChildByName( 'index' ); - const index = indexNode ? parseInt( indexNode.value ) : 0; + if ( data && ( isZipBuffer( data ) || /\.mtlx\.zip$/i.test( url ) ) ) { - node = uv( index ); + const archive = readMtlxArchive( data ); + text = archive.text; + const resolver = createArchiveResolver( archive.files ); + archiveResolver = resolver.resolve; + this.archiveDisposer = resolver.dispose; - } else if ( element === 'geomcolor' ) { + } else if ( typeof data === 'string' ) { - const indexNode = this.getChildByName( 'index' ); - const index = indexNode ? parseInt( indexNode.value ) : 0; + text = data; - node = vertexColor( index ); + } else if ( data instanceof Uint8Array ) { - } else if ( element === 'tiledimage' ) { - - const file = this.getChildByName( 'file' ); - - const textureFile = file.getTexture(); - const uvTiling = mx_transform_uv( ...this.getNodesByNames( [ 'uvtiling', 'uvoffset' ] ) ); - - node = texture( textureFile, uvTiling ); - - const colorSpaceNode = file.getColorSpaceNode(); - - if ( colorSpaceNode ) { - - node = colorSpaceNode( node ); - - } - - } else if ( element === 'image' ) { - - const file = this.getChildByName( 'file' ); - const uvNode = this.getNodeByName( 'texcoord' ); - - const textureFile = file.getTexture(); - - node = texture( textureFile, uvNode ); - - const colorSpaceNode = file.getColorSpaceNode(); - - if ( colorSpaceNode ) { - - node = colorSpaceNode( node ); - - } - - } else if ( MtlXLibrary[ element ] !== undefined ) { - - const nodeElement = MtlXLibrary[ element ]; - - if ( ! nodeElement ) { - - throw new Error( `THREE.MaterialXLoader: Unexpected node ${ new XMLSerializer().serializeToString( this.nodeXML ) }.` ); - - } - - if ( ! nodeElement.nodeFunc ) { - - throw new Error( `THREE.MaterialXLoader: Unexpected node 2 ${ new XMLSerializer().serializeToString( this.nodeXML ) }.` ); - - } - - if ( out !== null ) { - - node = nodeElement.nodeFunc( ...this.getNodesByNames( ...nodeElement.params ), out ); - - } else { - - node = nodeElement.nodeFunc( ...this.getNodesByNames( ...nodeElement.params ) ); - - } - - } - - } - - // - - if ( node === null ) { - - console.warn( `THREE.MaterialXLoader: Unexpected node ${ new XMLSerializer().serializeToString( this.nodeXML ) }.` ); - - node = float( 0 ); - - } - - // - - const nodeToTypeClass = this.getClassFromType( type ); - - if ( nodeToTypeClass !== null ) { - - node = nodeToTypeClass( node ); + text = _textDecoder.decode( data ); } else { - console.warn( `THREE.MaterialXLoader: Unexpected node ${ new XMLSerializer().serializeToString( this.nodeXML ) }.` ); - node = float( 0 ); - - } - - 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 node = input.getNode(); - - nodes[ node.name ] = node; - - } - - return nodes; - - } - - getNodeByName( name ) { - - const child = this.getChildByName( name ); - - return child ? child.getNode( child.output ) : undefined; - - } - - getNodesByNames( ...names ) { - - const nodes = []; - - for ( const name of names ) { - - const node = this.getNodeByName( name ); - - if ( node ) nodes.push( node ); - - } - - return nodes; - - } - - getValue() { - - return this.value.trim(); - - } - - getVector() { - - const vector = []; - - for ( const val of this.getValue().split( /[,|\s]/ ) ) { - - if ( val !== '' ) { - - vector.push( Number( val.trim() ) ); - - } - - } - - return vector; - - } - - getAttribute( name ) { - - return this.nodeXML.getAttribute( name ); - - } - - getRecursiveAttribute( name ) { - - let attribute = this.nodeXML.getAttribute( name ); - - if ( attribute === null && this.parent !== null ) { - - attribute = this.parent.getRecursiveAttribute( name ); - - } - - return attribute; - - } - - setStandardSurfaceToGltfPBR( material ) { - - const inputs = this.getNodes(); - - // - - let colorNode = null; - - if ( inputs.base && inputs.base_color ) colorNode = mul( inputs.base, inputs.base_color ); - else if ( inputs.base ) colorNode = inputs.base; - else if ( inputs.base_color ) colorNode = inputs.base_color; - - // - - let opacityNode = null; - - if ( inputs.opacity ) opacityNode = inputs.opacity; - - // - - let roughnessNode = null; - - if ( inputs.specular_roughness ) roughnessNode = inputs.specular_roughness; - - // - - let metalnessNode = null; - - if ( inputs.metalness ) metalnessNode = inputs.metalness; - - // - - let specularIntensityNode = null; - - if ( inputs.specular ) specularIntensityNode = inputs.specular; - - // - - let specularColorNode = null; - - if ( inputs.specular_color ) specularColorNode = inputs.specular_color; - - // - - let iorNode = null; - - if ( inputs.ior ) iorNode = inputs.ior; - - // - - let anisotropyNode = null; - let anisotropyRotationNode = null; - - if ( inputs.specular_anisotropy ) anisotropyNode = inputs.specular_anisotropy; - if ( inputs.specular_rotation ) anisotropyRotationNode = inputs.specular_rotation; - - // - - let transmissionNode = null; - let transmissionColorNode = null; - - if ( inputs.transmission ) transmissionNode = inputs.transmission; - if ( inputs.transmission_color ) transmissionColorNode = inputs.transmission_color; - - // - - let thinFilmThicknessNode = null; - let thinFilmIorNode = null; - - if ( inputs.thin_film_thickness ) thinFilmThicknessNode = inputs.thin_film_thickness; - - if ( inputs.thin_film_ior ) { - - // Clamp IOR to valid range for Three.js (1.0 to 2.333) - thinFilmIorNode = clamp( inputs.thin_film_ior, float( 1.0 ), float( 2.333 ) ); - - } - - // - - let sheenNode = null; - let sheenColorNode = null; - let sheenRoughnessNode = null; - - if ( inputs.sheen ) sheenNode = inputs.sheen; - if ( inputs.sheen_color ) sheenColorNode = inputs.sheen_color; - if ( inputs.sheen_roughness ) sheenRoughnessNode = inputs.sheen_roughness; - - // - - let clearcoatNode = null; - let clearcoatRoughnessNode = null; - - if ( inputs.coat ) clearcoatNode = inputs.coat; - if ( inputs.coat_roughness ) clearcoatRoughnessNode = inputs.coat_roughness; - - if ( inputs.coat_color ) { - - colorNode = colorNode ? mul( colorNode, inputs.coat_color ) : colorNode; - - } - - // - - let normalNode = null; - - if ( inputs.normal ) normalNode = inputs.normal; - - // - - let emissiveNode = null; - - if ( inputs.emission ) emissiveNode = inputs.emission; - if ( inputs.emissionColor ) { - - emissiveNode = emissiveNode ? mul( emissiveNode, inputs.emissionColor ) : emissiveNode; + text = _textDecoder.decode( new Uint8Array( data ) ); } - // - - material.colorNode = colorNode || color( 0.8, 0.8, 0.8 ); - material.opacityNode = opacityNode || float( 1.0 ); - material.roughnessNode = roughnessNode || float( 0.2 ); - material.metalnessNode = metalnessNode || float( 0 ); - material.specularIntensityNode = specularIntensityNode || float( 0.5 ); - material.specularColorNode = specularColorNode || color( 1.0, 1.0, 1.0 ); - material.iorNode = iorNode || float( 1.5 ); - material.anisotropyNode = anisotropyNode || float( 0 ); - material.anisotropyRotationNode = anisotropyRotationNode || float( 0 ); - material.transmissionNode = transmissionNode || float( 0 ); - material.transmissionColorNode = transmissionColorNode || color( 1.0, 1.0, 1.0 ); - material.thinFilmThicknessNode = thinFilmThicknessNode || float( 0 ); - material.thinFilmIorNode = thinFilmIorNode || float( 1.5 ); - material.sheenNode = sheenNode || float( 0 ); - material.sheenColorNode = sheenColorNode || color( 1.0, 1.0, 1.0 ); - material.sheenRoughnessNode = sheenRoughnessNode || float( 0.5 ); - material.clearcoatNode = clearcoatNode || float( 0 ); - material.clearcoatRoughnessNode = clearcoatRoughnessNode || float( 0 ); - if ( normalNode ) material.normalNode = normalNode; - if ( emissiveNode ) material.emissiveNode = emissiveNode; - - // Auto-enable iridescence when thin film parameters are present - if ( thinFilmThicknessNode && thinFilmThicknessNode.value !== undefined && thinFilmThicknessNode.value > 0 ) { - - material.iridescence = 1.0; - - } - - if ( opacityNode !== null ) { - - material.transparent = true; - - } - - if ( transmissionNode !== null ) { - - material.side = DoubleSide; - material.transparent = true; - - } - - } - - /*setGltfPBR( material ) { - - const inputs = this.getNodes(); - - console.log( inputs ); - - }*/ - - setMaterial( material ) { - - const element = this.element; - - if ( element === 'gltf_pbr' ) { - - //this.setGltfPBR( material ); - - } else if ( element === 'standard_surface' ) { - - this.setStandardSurfaceToGltfPBR( material ); - - } - - } - - 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; - - } - - toPhysicalMaterial() { - - const material = new MeshPhysicalNodeMaterial(); - material.name = this.name; - - for ( const nodeX of this.children ) { - - const shaderProperties = this.materialX.getMaterialXNode( nodeX.nodeName ); - shaderProperties.setMaterial( material ); - - } - - return material; - - } - - toMaterials() { - - const materials = {}; - - let isUnlit = true; - - for ( const nodeX of this.children ) { - - if ( nodeX.element === 'surfacematerial' ) { - - const material = nodeX.toPhysicalMaterial(); - - materials[ material.name ] = material; - - isUnlit = false; - - } - - } - - if ( isUnlit ) { - - 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 MaterialX { - - constructor( manager, path ) { - - this.manager = manager; - this.path = path; - this.resourcePath = ''; - - this.nodesXLib = new Map(); - //this.nodesXRefLib = new WeakMap(); - - this.textureLoader = new ImageBitmapLoader( manager ); - this.textureLoader.setOptions( { imageOrientation: 'flipY' } ); - - this.textureCache = new Map(); - - } - - addMaterialXNode( materialXNode ) { - - this.nodesXLib.set( materialXNode.nodePath, materialXNode ); - - } - - /*getMaterialXNodeFromXML( xmlNode ) { - - return this.nodesXRefLib.get( xmlNode ); - - }*/ - - getMaterialXNode( ...names ) { - - return this.nodesXLib.get( names.join( '/' ) ); - - } - - parseNode( nodeXML, nodePath = '' ) { - - const materialXNode = new MaterialXNode( this, nodeXML, nodePath ); - if ( materialXNode.nodePath ) this.addMaterialXNode( materialXNode ); - - for ( const childNodeXML of nodeXML.children ) { - - const childMXNode = this.parseNode( childNodeXML, materialXNode.nodePath ); - materialXNode.add( childMXNode ); - - } - - return materialXNode; + return this.parse( text, { + ...options, + archiveResolver, + path: options.path || getResourcePath( this.path, url ) + } ); } - parse( text ) { + parse( text, options = {} ) { - const rootXML = new DOMParser().parseFromString( text, 'application/xml' ).documentElement; - - this.textureLoader.setPath( this.path ); - - // + const issueCollector = new MaterialXIssueCollector( { + issuePolicy: options.issuePolicy, + onWarning: options.onWarning || options.warningCallback + } ); - const materials = this.parseNode( rootXML ).toMaterials(); + const document = new MaterialXDocument( this.manager, options.path || this.path, issueCollector, options.archiveResolver || null ); + const result = document.parse( text, options.materialName || null ); - return { materials }; + issueCollector.throwIfNeeded(); + return result; } diff --git a/examples/jsm/loaders/materialx/MaterialXArchive.js b/examples/jsm/loaders/materialx/MaterialXArchive.js new file mode 100644 index 00000000000000..da98c6108c2570 --- /dev/null +++ b/examples/jsm/loaders/materialx/MaterialXArchive.js @@ -0,0 +1,108 @@ +import { unzipSync } from '../../libs/fflate.module.js'; + +const _textDecoder = new TextDecoder(); + +function normalizePath( path ) { + + return path + .split( '\\' ).join( '/' ) + .replace( /^\.?\//, '' ) + .replace( /^\/+/, '' ); + +} + +function isZipBuffer( buffer ) { + + const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array( buffer ); + return bytes.length >= 4 && bytes[ 0 ] === 0x50 && bytes[ 1 ] === 0x4b; + +} + +function readMtlxArchive( buffer ) { + + const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array( buffer ); + const archive = unzipSync( bytes ); + + const fileMap = new Map(); + let mtlxPath = null; + + for ( const path in archive ) { + + const normalizedPath = normalizePath( path ); + fileMap.set( normalizedPath, archive[ path ] ); + + if ( normalizedPath.toLowerCase().endsWith( '.mtlx' ) ) { + + if ( mtlxPath !== null ) { + + throw new Error( 'THREE.MaterialXLoader: Invalid .mtlx.zip package. Exactly one .mtlx file is required.' ); + + } + + mtlxPath = normalizedPath; + + } + + } + + if ( mtlxPath === null ) { + + throw new Error( 'THREE.MaterialXLoader: Invalid .mtlx.zip package. Missing .mtlx file.' ); + + } + + const text = _textDecoder.decode( fileMap.get( mtlxPath ) ); + return { text, mtlxPath, files: fileMap }; + +} + +function createArchiveResolver( files ) { + + const objectUrlCache = new Map(); + + const getFile = ( uri ) => { + + const normalized = normalizePath( decodeURI( uri ) ); + if ( files.has( normalized ) ) return files.get( normalized ); + + for ( const [ path, bytes ] of files ) { + + if ( path.endsWith( normalized ) ) return bytes; + + } + + return null; + + }; + + const resolve = ( uri ) => { + + if ( objectUrlCache.has( uri ) ) return objectUrlCache.get( uri ); + + const bytes = getFile( uri ); + if ( ! bytes ) return null; + + const blob = new Blob( [ bytes ], { type: 'application/octet-stream' } ); + const objectUrl = URL.createObjectURL( blob ); + objectUrlCache.set( uri, objectUrl ); + return objectUrl; + + }; + + const dispose = () => { + + for ( const objectUrl of objectUrlCache.values() ) { + + URL.revokeObjectURL( objectUrl ); + + } + + objectUrlCache.clear(); + + }; + + return { resolve, dispose }; + +} + +export { isZipBuffer, readMtlxArchive, createArchiveResolver }; diff --git a/examples/jsm/loaders/materialx/MaterialXDocument.js b/examples/jsm/loaders/materialx/MaterialXDocument.js new file mode 100644 index 00000000000000..4f85717d1a45be --- /dev/null +++ b/examples/jsm/loaders/materialx/MaterialXDocument.js @@ -0,0 +1,1163 @@ +import { + Texture, + RepeatWrapping, + ImageLoader, + ImageBitmapLoader, + Matrix3, + Matrix4, + MeshBasicNodeMaterial, + MeshPhysicalNodeMaterial, +} from 'three/webgpu'; + +import { + abs, + add, + clamp, + cos, + div, + dot, + float, + floor, + fract, + int, + max, + mix, + mul, + pow, + sin, + step, + sub, + vec2, + vec3, + vec4, + color, + dFdx, + dFdy, + uv, + mat3, + mat4, + element, + mx_transform_uv, + mx_srgb_texture_to_lin_rec709, +} from 'three/tsl'; + +import { createMaterialXCompileRegistry, compileNodeFromRegistry } from './compile/MaterialXCompileRegistry.js'; +import { parseMaterialXNodeTree, parseMaterialXText } from './parse/MaterialXParser.js'; +import { getSurfaceMapper, getSupportedSurfaceCategories } from './MaterialXSurfaceRegistry.js'; +import { MtlXLibrary } from './MaterialXNodeLibrary.js'; +import { validateCategoryCoverage } from './MaterialXNodeRegistry.js'; + +const colorSpaceLib = { + mx_srgb_texture_to_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 HEXTILE_SQRT3_2 = Math.sqrt( 3 ) * 2; +const HEXTILE_EPSILON = 1e-6; +const HEXTILE_PI_OVER_180 = Math.PI / 180; +const COMPILE_REGISTRY = createMaterialXCompileRegistry(); +const ALLOWED_NON_STANDARD_COMPILE_CATEGORIES = [ 'hextiledimage', 'hextilednormalmap', 'gltf_anisotropy_image' ]; +let translatorRegistryValidated = false; + +function toRadians( degrees ) { + + return mul( degrees, HEXTILE_PI_OVER_180 ); + +} + +function mxToUvSpace( uvNode ) { + + return vec2( element( uvNode, 0 ), sub( 1, element( uvNode, 1 ) ) ); + +} + +function mxFromUvSpace( uvNode ) { + + return vec2( element( uvNode, 0 ), sub( 1, element( uvNode, 1 ) ) ); + +} + +function mxHextileHash( point ) { + + const x = element( point, 0 ); + const y = element( point, 1 ); + const p3Base = vec3( x, y, x ); + const p3Scaled = mul( p3Base, vec3( 0.1031, 0.103, 0.0973 ) ); + const p3Fract = fract( p3Scaled ); + const p3YZX = vec3( element( p3Fract, 1 ), element( p3Fract, 2 ), element( p3Fract, 0 ) ); + const p3Offset = add( p3YZX, 33.33 ); + const p3 = add( p3Fract, dot( p3Fract, p3Offset ) ); + const lhs = add( vec2( element( p3, 0 ), element( p3, 0 ) ), vec2( element( p3, 1 ), element( p3, 2 ) ) ); + const rhs = vec2( element( p3, 2 ), element( p3, 1 ) ); + return fract( mul( lhs, rhs ) ); + +} + +function mxSchlickGain( x, r ) { + + const rr = clamp( r, 0.001, 0.999 ); + const a = mul( sub( div( 1, rr ), 2 ), sub( 1, mul( 2, x ) ) ); + const low = div( x, add( a, 1 ) ); + const high = div( sub( a, x ), sub( a, 1 ) ); + return mix( low, high, step( 0.5, x ) ); + +} + +function normalizeBlendWeights( weights ) { + + const wx = element( weights, 0 ); + const wy = element( weights, 1 ); + const wz = element( weights, 2 ); + const sum = max( add( add( wx, wy ), wz ), HEXTILE_EPSILON ); + return div( weights, sum ); + +} + +function mxHextileComputeBlendWeights( luminanceWeights, tileWeights, falloff ) { + + const weighted = mul( luminanceWeights, pow( max( tileWeights, vec3( HEXTILE_EPSILON, HEXTILE_EPSILON, HEXTILE_EPSILON ) ), vec3( 7, 7, 7 ) ) ); + const normalized = normalizeBlendWeights( weighted ); + const gained = vec3( + mxSchlickGain( element( normalized, 0 ), falloff ), + mxSchlickGain( element( normalized, 1 ), falloff ), + mxSchlickGain( element( normalized, 2 ), falloff ), + ); + const gainedNormalized = normalizeBlendWeights( gained ); + const applyFalloff = step( HEXTILE_EPSILON, abs( sub( falloff, 0.5 ) ) ); + return mix( normalized, gainedNormalized, applyFalloff ); + +} + +function mxRotate2d( point, sine, cosine ) { + + return vec2( sub( mul( cosine, element( point, 0 ) ), mul( sine, element( point, 1 ) ) ), add( mul( sine, element( point, 0 ) ), mul( cosine, element( point, 1 ) ) ) ); + +} + +function mxHextileCoord( coord, rotation, rotationRange, scale, scaleRange, offset, offsetRange ) { + + const st = mul( coord, HEXTILE_SQRT3_2 ); + const stSkewed = vec2( add( element( st, 0 ), mul( - 0.57735027, element( st, 1 ) ) ), mul( 1.15470054, element( st, 1 ) ) ); + const stFrac = fract( stSkewed ); + const tx = element( stFrac, 0 ); + const ty = element( stFrac, 1 ); + const tz = sub( sub( 1, tx ), ty ); + const s = step( 0, sub( 0, tz ) ); + const s2 = sub( mul( 2, s ), 1 ); + const w1 = mul( sub( 0, tz ), s2 ); + const w2 = sub( s, mul( ty, s2 ) ); + const w3 = sub( s, mul( tx, s2 ) ); + const baseId = floor( stSkewed ); + const oneMinusS = sub( 1, s ); + const id1 = add( baseId, vec2( s, s ) ); + const id2 = add( baseId, vec2( s, oneMinusS ) ); + const id3 = add( baseId, vec2( oneMinusS, s ) ); + + const toTileCenter = ( tileId ) => { + + const scaled = div( tileId, HEXTILE_SQRT3_2 ); + const sx = element( scaled, 0 ); + const sy = element( scaled, 1 ); + return vec2( add( sx, mul( 0.5, sy ) ), mul( 0.8660254, sy ) ); + + }; + + const ctr1 = toTileCenter( id1 ); + const ctr2 = toTileCenter( id2 ); + const ctr3 = toTileCenter( id3 ); + + const seedOffset = vec2( 0.12345, 0.12345 ); + const rand1 = mxHextileHash( add( id1, seedOffset ) ); + const rand2 = mxHextileHash( add( id2, seedOffset ) ); + const rand3 = mxHextileHash( add( id3, seedOffset ) ); + + const rr = vec2( toRadians( element( rotationRange, 0 ) ), toRadians( element( rotationRange, 1 ) ) ); + const rrMin = element( rr, 0 ); + const rrMax = element( rr, 1 ); + const randX = vec3( element( rand1, 0 ), element( rand2, 0 ), element( rand3, 0 ) ); + const rotations = mix( vec3( rrMin, rrMin, rrMin ), vec3( rrMax, rrMax, rrMax ), mul( randX, rotation ) ); + const randY = vec3( element( rand1, 1 ), element( rand2, 1 ), element( rand3, 1 ) ); + const scaleMin = element( scaleRange, 0 ); + const scaleMax = element( scaleRange, 1 ); + const randomScale = mix( vec3( scaleMin, scaleMin, scaleMin ), vec3( scaleMax, scaleMax, scaleMax ), randY ); + const scales = mix( vec3( 1, 1, 1 ), randomScale, scale ); + const offsetMin = element( offsetRange, 0 ); + const offsetMax = element( offsetRange, 1 ); + const offset1 = mix( vec2( offsetMin, offsetMin ), vec2( offsetMax, offsetMax ), mul( rand1, offset ) ); + const offset2 = mix( vec2( offsetMin, offsetMin ), vec2( offsetMax, offsetMax ), mul( rand2, offset ) ); + const offset3 = mix( vec2( offsetMin, offsetMin ), vec2( offsetMax, offsetMax ), mul( rand3, offset ) ); + + const sampleCoord = ( center, randomOffset, rotationValue, sampleScale ) => { + + const delta = sub( coord, center ); + const rotated = mxRotate2d( delta, sin( rotationValue ), cos( rotationValue ) ); + const safeScale = max( sampleScale, HEXTILE_EPSILON ); + return add( add( div( rotated, vec2( safeScale, safeScale ) ), center ), randomOffset ); + + }; + + const sampleDerivative = ( derivative, rotationValue, sampleScale ) => { + + const rotated = mxRotate2d( derivative, sin( rotationValue ), cos( rotationValue ) ); + const safeScale = max( sampleScale, HEXTILE_EPSILON ); + return div( rotated, vec2( safeScale, safeScale ) ); + + }; + + const ddx = dFdx( coord ); + const ddy = dFdy( coord ); + + return { + coords: [ + sampleCoord( ctr1, offset1, element( rotations, 0 ), element( scales, 0 ) ), + sampleCoord( ctr2, offset2, element( rotations, 1 ), element( scales, 1 ) ), + sampleCoord( ctr3, offset3, element( rotations, 2 ), element( scales, 2 ) ), + ], + ddx: [ + sampleDerivative( ddx, element( rotations, 0 ), element( scales, 0 ) ), + sampleDerivative( ddx, element( rotations, 1 ), element( scales, 1 ) ), + sampleDerivative( ddx, element( rotations, 2 ), element( scales, 2 ) ), + ], + ddy: [ + sampleDerivative( ddy, element( rotations, 0 ), element( scales, 0 ) ), + sampleDerivative( ddy, element( rotations, 1 ), element( scales, 1 ) ), + sampleDerivative( ddy, element( rotations, 2 ), element( scales, 2 ) ), + ], + weights: vec3( w1, w2, w3 ), + }; + +} + +function isSvgUri( uri ) { + + if ( typeof uri !== 'string' ) return false; + return /\.svg(?:$|[?#])/i.test( uri ); + +} + +function invertConstantMatrixValues( values, size ) { + + if ( ! Array.isArray( values ) || values.length !== size * size ) return null; + + if ( size === 3 ) { + + const matrix = new Matrix3().set( + values[ 0 ], + values[ 1 ], + values[ 2 ], + values[ 3 ], + values[ 4 ], + values[ 5 ], + values[ 6 ], + values[ 7 ], + values[ 8 ], + ); + if ( Math.abs( matrix.determinant() ) < MATRIX_INVERSE_EPSILON ) return null; + matrix.invert(); + const e = matrix.elements; + // Convert Three.js internal column-major storage back to row-major literal order. + return [ e[ 0 ], e[ 3 ], e[ 6 ], e[ 1 ], e[ 4 ], e[ 7 ], e[ 2 ], e[ 5 ], e[ 8 ] ]; + + } + + if ( size === 4 ) { + + const matrix = new Matrix4().set( + values[ 0 ], + values[ 1 ], + values[ 2 ], + values[ 3 ], + values[ 4 ], + values[ 5 ], + values[ 6 ], + values[ 7 ], + values[ 8 ], + values[ 9 ], + values[ 10 ], + values[ 11 ], + values[ 12 ], + values[ 13 ], + values[ 14 ], + values[ 15 ], + ); + if ( Math.abs( matrix.determinant() ) < MATRIX_INVERSE_EPSILON ) return null; + matrix.invert(); + const e = matrix.elements; + // Convert Three.js internal column-major storage back to row-major literal order. + return [ e[ 0 ], e[ 4 ], e[ 8 ], e[ 12 ], e[ 1 ], e[ 5 ], e[ 9 ], e[ 13 ], e[ 2 ], e[ 6 ], e[ 10 ], e[ 14 ], e[ 3 ], e[ 7 ], e[ 11 ], e[ 15 ] ]; + + } + + return null; + +} + +function matrixNodeAt( matrixNode, row, col ) { + + return element( element( matrixNode, col ), row ); + +} + +function det2Node( a, b, c, d ) { + + return sub( mul( a, d ), mul( b, c ) ); + +} + +function det3Node( matrixRows ) { + + const a = matrixRows[ 0 ][ 0 ]; + const b = matrixRows[ 0 ][ 1 ]; + const c = matrixRows[ 0 ][ 2 ]; + const d = matrixRows[ 1 ][ 0 ]; + const e = matrixRows[ 1 ][ 1 ]; + const f = matrixRows[ 1 ][ 2 ]; + const g = matrixRows[ 2 ][ 0 ]; + const h = matrixRows[ 2 ][ 1 ]; + const i = matrixRows[ 2 ][ 2 ]; + + const eiMinusFh = det2Node( e, f, h, i ); + const diMinusFg = det2Node( d, f, g, i ); + const dhMinusEg = det2Node( d, e, g, h ); + + return add( sub( mul( a, eiMinusFh ), mul( b, diMinusFg ) ), mul( c, dhMinusEg ) ); + +} + +function readMatrixRows( matrixNode, size ) { + + const rows = []; + for ( let row = 0; row < size; row += 1 ) { + + const rowValues = []; + for ( let col = 0; col < size; col += 1 ) { + + rowValues.push( matrixNodeAt( matrixNode, row, col ) ); + + } + + rows.push( rowValues ); + + } + + return rows; + +} + +function invertMatrixNode( matrixNode, size ) { + + if ( size === 3 ) { + + const m = readMatrixRows( matrixNode, 3 ); + const a = m[ 0 ][ 0 ]; + const b = m[ 0 ][ 1 ]; + const c = m[ 0 ][ 2 ]; + const d = m[ 1 ][ 0 ]; + const e = m[ 1 ][ 1 ]; + const f = m[ 1 ][ 2 ]; + const g = m[ 2 ][ 0 ]; + const h = m[ 2 ][ 1 ]; + const i = m[ 2 ][ 2 ]; + + const determinant = det3Node( m ); + const invDet = div( 1, determinant ); + + const cofactor00 = det2Node( e, f, h, i ); + const cofactor01 = sub( 0, det2Node( d, f, g, i ) ); + const cofactor02 = det2Node( d, e, g, h ); + const cofactor10 = sub( 0, det2Node( b, c, h, i ) ); + const cofactor11 = det2Node( a, c, g, i ); + const cofactor12 = sub( 0, det2Node( a, b, g, h ) ); + const cofactor20 = det2Node( b, c, e, f ); + const cofactor21 = sub( 0, det2Node( a, c, d, f ) ); + const cofactor22 = det2Node( a, b, d, e ); + + return mat3( + mul( cofactor00, invDet ), + mul( cofactor10, invDet ), + mul( cofactor20, invDet ), + mul( cofactor01, invDet ), + mul( cofactor11, invDet ), + mul( cofactor21, invDet ), + mul( cofactor02, invDet ), + mul( cofactor12, invDet ), + mul( cofactor22, invDet ), + ); + + } + + if ( size === 4 ) { + + const m = readMatrixRows( matrixNode, 4 ); + const det3FromMinor = ( rowToRemove, colToRemove ) => { + + const minorRows = []; + for ( let row = 0; row < 4; row += 1 ) { + + if ( row === rowToRemove ) continue; + const minorRow = []; + for ( let col = 0; col < 4; col += 1 ) { + + if ( col === colToRemove ) continue; + minorRow.push( m[ row ][ col ] ); + + } + + minorRows.push( minorRow ); + + } + + return det3Node( minorRows ); + + }; + + const determinant = add( + sub( mul( m[ 0 ][ 0 ], det3FromMinor( 0, 0 ) ), mul( m[ 0 ][ 1 ], det3FromMinor( 0, 1 ) ) ), + add( mul( m[ 0 ][ 2 ], det3FromMinor( 0, 2 ) ), sub( 0, mul( m[ 0 ][ 3 ], det3FromMinor( 0, 3 ) ) ) ), + ); + const invDet = div( 1, determinant ); + + const inverseRows = []; + for ( let row = 0; row < 4; row += 1 ) { + + const inverseRow = []; + for ( let col = 0; col < 4; col += 1 ) { + + const cofactor = det3FromMinor( col, row ); + const signedCofactor = ( col + row ) % 2 === 0 ? cofactor : sub( 0, cofactor ); + inverseRow.push( mul( signedCofactor, invDet ) ); + + } + + inverseRows.push( inverseRow ); + + } + + return mat4( + inverseRows[ 0 ][ 0 ], + inverseRows[ 0 ][ 1 ], + inverseRows[ 0 ][ 2 ], + inverseRows[ 0 ][ 3 ], + inverseRows[ 1 ][ 0 ], + inverseRows[ 1 ][ 1 ], + inverseRows[ 1 ][ 2 ], + inverseRows[ 1 ][ 3 ], + inverseRows[ 2 ][ 0 ], + inverseRows[ 2 ][ 1 ], + inverseRows[ 2 ][ 2 ], + inverseRows[ 2 ][ 3 ], + inverseRows[ 3 ][ 0 ], + inverseRows[ 3 ][ 1 ], + inverseRows[ 3 ][ 2 ], + inverseRows[ 3 ][ 3 ], + ); + + } + + return matrixNode; + +} + +function getOutputChannel( outputName ) { + + if ( outputName === 'outx' || outputName === 'outr' || outputName === 'r' ) return 0; + if ( outputName === 'outy' || outputName === 'outg' || outputName === 'g' ) return 1; + if ( outputName === 'outz' || outputName === 'outb' || outputName === 'b' ) return 2; + if ( outputName === 'outw' || outputName === 'outa' || outputName === 'a' ) return 3; + return 0; + +} + +function isChannelOutput( outputName ) { + + return outputName === 'outx' || outputName === 'outr' || outputName === 'r' || + outputName === 'outy' || outputName === 'outg' || outputName === 'g' || + outputName === 'outz' || outputName === 'outb' || outputName === 'b' || + outputName === 'outw' || outputName === 'outa' || outputName === 'a'; + +} + +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; + + } + + getTexture() { + + const filePrefix = this.getRecursiveAttribute( 'fileprefix' ) || ''; + const sourceURI = filePrefix + this.value; + const resolvedURI = this.materialX.resolveTextureURI( sourceURI ); + const svgTexture = isSvgUri( resolvedURI ); + + if ( this.materialX.textureCache.has( resolvedURI ) ) { + + return this.materialX.textureCache.get( resolvedURI ); + + } + + 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 = textureNode.wrapT = RepeatWrapping; + textureNode.flipY = false; + this.materialX.textureCache.set( resolvedURI, textureNode ); + + loader.load( resolvedURI, ( imageData ) => { + + textureNode.image = imageData; + textureNode.needsUpdate = true; + + }, undefined, () => { + + throw new Error( `Failed to load texture "${resolvedURI}".` ); + + } ); + + return textureNode; + + } + + getClassFromType( type ) { + + if ( type === 'integer' ) return int; + if ( type === 'float' ) return float; + if ( type === 'vector2' ) return vec2; + if ( type === 'vector3' ) return vec3; + if ( type === 'vector4' || type === 'color4' ) return vec4; + if ( type === 'color3' ) return color; + if ( type === 'boolean' ) return null; + if ( type === 'matrix33' ) return mat3; + if ( type === 'matrix44' ) return mat4; + return null; + + } + + toBooleanMaskNode( node ) { + + if ( node && node.nodeType === 'bool' && typeof node.select === 'function' ) { + + return node.select( float( 1 ), float( 0 ) ); + + } + + if ( typeof node === 'boolean' ) { + + return float( node ? 1 : 0 ); + + } + + return node; + + } + + getNode( out = null ) { + + let node = this.node; + if ( node !== null && out === null ) return node; + + 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 = mxToUvSpace( uv( index ) ); + + } + + 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 = float( normalized === 'true' || normalized === '1' ? 1 : 0 ); + + } 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.issueCollector.addMissingReference( this.name, this.referencePath ); + node = float( 0 ); + + } + + } else { + + node = compileNodeFromRegistry( this, out, this.materialX.compileContext ); + + } + + if ( node === null || node === undefined ) { + + this.materialX.issueCollector.addUnsupportedNode( this.element, this.name ); + node = float( 0 ); + + } + + if ( channelRequested ) { + + node = element( node, getOutputChannel( out ) ); + + } + + const resolvedType = channelRequested ? 'float' : type; + if ( resolvedType === 'boolean' ) { + + node = this.toBooleanMaskNode( 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.issueCollector.addInvalidValue( this.name, `Unexpected type "${resolvedType}" on node "${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 ) { + + return this.nodeXML.getAttribute( name ); + + } + + 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.issueCollector, this.name ); + + } else { + + this.materialX.issueCollector.addUnsupportedNode( this.element, 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.issueCollector.addMissingReference( + nodeX.name, + nodeX.referencePath || nodeX.nodeName || '(unknown)', + ); + 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.issueCollector.addMissingMaterial( 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, issueCollector, archiveResolver = null ) { + + this.manager = manager; + this.path = path; + this.issueCollector = issueCollector; + this.archiveResolver = archiveResolver; + + 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(); + + this.compileContext = { + compileRegistry: COMPILE_REGISTRY, + nodeLibrary: MtlXLibrary, + mxToUvSpace, + mxFromUvSpace, + mxTransformUv: mx_transform_uv, + mxHextileCoord, + mxHextileComputeBlendWeights, + invertConstantMatrixValues, + invertMatrixNode, + IDENTITY_MAT3_VALUES, + IDENTITY_MAT4_VALUES, + }; + + if ( ! translatorRegistryValidated ) { + + validateCategoryCoverage( { + compileCategories: [ ...COMPILE_REGISTRY.keys() ], + surfaceCategories: getSupportedSurfaceCategories(), + allowUnknownCompileCategories: ALLOWED_NON_STANDARD_COMPILE_CATEGORIES, + } ); + translatorRegistryValidated = true; + + } + + } + + 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 ) { + + const rootNode = parseMaterialXText( + text, + ( childNodeXML, childNodePath ) => new MaterialXNode( this, childNodeXML, childNodePath ), + ( materialXNode ) => this.addMaterialXNode( materialXNode ), + ); + const materials = rootNode.toMaterials( materialName ); + const report = this.issueCollector.buildReport(); + return { materials, report }; + + } + +} + +export { MaterialXDocument }; diff --git a/examples/jsm/loaders/materialx/MaterialXNodeLibrary.js b/examples/jsm/loaders/materialx/MaterialXNodeLibrary.js new file mode 100644 index 00000000000000..e760fe055dc73d --- /dev/null +++ b/examples/jsm/loaders/materialx/MaterialXNodeLibrary.js @@ -0,0 +1,930 @@ +import { + abs, + add, + clamp, + floor, + ceil, + round, + sign, + sin, + cos, + tan, + asin, + acos, + sqrt, + log, + exp, + min, + max, + normalize, + length, + dot, + cross, + mul, + div, + pow, + distance, + remap, + luminance, + mx_rgbtohsv, + mx_hsvtorgb, + mix, + saturation as mx_saturation, + transpose, + determinant, + inverse, + normalMap, + mat3, + mx_ramplr, + mx_ramptb, + mx_splitlr, + mx_splittb, + mx_fractal_noise_float, + mx_noise_float, + mx_cell_noise_float, + mx_smoothstep, + mx_worley_noise_float_2d, + mx_worley_noise_float_3d, + mx_unifiednoise2d, + mx_unifiednoise3d, + mx_modulo, + mx_place2d, + mx_rotate2d, + mx_rotate3d, + mx_safepower, + mx_contrast, + element, + reflect, + refract, + mx_timer, + mx_frame, + mx_ifgreater, + mx_ifgreatereq, + mx_ifequal, + mx_atan2, + positionLocal, + positionWorld, + mx_heighttonormal, + float, + int, + color, + modelNormalMatrix, + modelWorldMatrix, + modelWorldMatrixInverse, + vec2, + vec3, + vec4, + checker, + fract, + sub, + step, +} from 'three/tsl'; +import { normalizeSpaceName } from './MaterialXUtils.js'; + +class MXElement { + + constructor( name, nodeFunc, params = [], defaults = {} ) { + + this.name = name; + this.nodeFunc = nodeFunc; + this.params = params; + this.defaults = defaults; + + } + +} + +const mx_invert = ( inNode, amount = 1 ) => sub( amount, inNode ); + +const mx_range = ( inNode, inLow, inHigh, outLow, outHigh, gamma = 1 ) => { + + const inSpan = max( sub( inHigh, inLow ), 1e-6 ); + const normalized = div( sub( inNode, inLow ), inSpan ); + const reciprocalGamma = div( 1, gamma ); + const gammaApplied = mul( pow( abs( normalized ), reciprocalGamma ), sign( normalized ) ); + return add( outLow, mul( gammaApplied, sub( outHigh, outLow ) ) ); + +}; + +const mx_open_pbr_anisotropy = ( roughness = 0, anisotropy = 0 ) => { + + const anisoInvert = sub( float( 1 ), anisotropy ); + const anisoInvertSq = mul( anisoInvert, anisoInvert ); + const denom = add( anisoInvertSq, float( 1 ) ); + const fraction = div( float( 2 ), denom ); + const sqrtFraction = sqrt( fraction ); + const roughSq = mul( roughness, roughness ); + const alphaX = mul( roughSq, sqrtFraction ); + const alphaY = mul( anisoInvert, alphaX ); + return vec2( alphaX, alphaY ); + +}; + +const mx_and = ( in1, in2 ) => clamp( mul( in1, in2 ), float( 0 ), float( 1 ) ); +const mx_or = ( in1, in2 ) => clamp( add( in1, in2 ), float( 0 ), float( 1 ) ); +const mx_xor = ( in1, in2 ) => abs( sub( in1, in2 ) ); +const mx_not = ( inNode ) => sub( float( 1 ), inNode ); +const mx_checkerboard = ( color1, color2, texcoord ) => mix( color1, color2, clamp( checker( texcoord ), 0, 1 ) ); + +const mx_circle = ( texcoord, center, radius ) => { + + const delta = sub( texcoord, center ); + const distanceSquared = dot( delta, delta ); + const radiusSquared = mul( radius, radius ); + return mx_ifgreater( distanceSquared, radiusSquared, 0, 1 ); + +}; + +const mx_bump = ( height, scale = 1 ) => normalMap( mx_heighttonormal( height, 1 ), scale ); +const mx_dot = ( inNode ) => inNode; +const mx_viewdirection = () => normalize( mul( positionWorld, float( - 1 ) ) ); +const getRGBChannels = ( input ) => vec3( element( input, 0 ), element( input, 1 ), element( input, 2 ) ); +const mx_blackbody = ( temperature = 5000 ) => { + + const temperatureKelvin = clamp( temperature, float( 800 ), float( 25000 ) ); + const t = div( float( 1000 ), temperatureKelvin ); + const t2 = mul( t, t ); + const t3 = mul( t2, t ); + const lowX = add( add( mul( float( - 0.2661239 ), t3 ), mul( float( - 0.234358 ), t2 ) ), add( mul( float( 0.8776956 ), t ), float( 0.17991 ) ) ); + const highX = add( + add( mul( float( - 3.0258469 ), t3 ), mul( float( 2.1070379 ), t2 ) ), + add( mul( float( 0.2226347 ), t ), float( 0.24039 ) ), + ); + const xc = mx_ifgreatereq( temperatureKelvin, float( 4000 ), highX, lowX ); + const xc2 = mul( xc, xc ); + const xc3 = mul( xc2, xc ); + const ycLow = add( + add( mul( float( - 1.1063814 ), xc3 ), mul( float( - 1.3481102 ), xc2 ) ), + add( mul( float( 2.18555832 ), xc ), float( - 0.20219683 ) ), + ); + const ycMid = add( + add( mul( float( - 0.9549476 ), xc3 ), mul( float( - 1.37418593 ), xc2 ) ), + add( mul( float( 2.09137015 ), xc ), float( - 0.16748867 ) ), + ); + const ycHigh = add( + add( mul( float( 3.081758 ), xc3 ), mul( float( - 5.8733867 ), xc2 ) ), + add( mul( float( 3.75112997 ), xc ), float( - 0.37001483 ) ), + ); + const ycLowMid = mx_ifgreatereq( temperatureKelvin, float( 2222 ), ycMid, ycLow ); + const yc = mx_ifgreatereq( temperatureKelvin, float( 4000 ), ycHigh, ycLowMid ); + const safeYc = max( yc, float( 1e-6 ) ); + const xyz = vec3( div( xc, safeYc ), float( 1 ), div( sub( sub( float( 1 ), xc ), yc ), safeYc ) ); + const rgb = vec3( + add( add( mul( float( 3.2406 ), element( xyz, 0 ) ), mul( float( - 1.5372 ), element( xyz, 1 ) ) ), mul( float( - 0.4986 ), element( xyz, 2 ) ) ), + add( add( mul( float( - 0.9689 ), element( xyz, 0 ) ), mul( float( 1.8758 ), element( xyz, 1 ) ) ), mul( float( 0.0415 ), element( xyz, 2 ) ) ), + add( add( mul( float( 0.0557 ), element( xyz, 0 ) ), mul( float( - 0.204 ), element( xyz, 1 ) ) ), mul( float( 1.057 ), element( xyz, 2 ) ) ), + ); + const clampedRgb = max( rgb, vec3( 0, 0, 0 ) ); + const validYcMask = step( float( 1e-6 ), yc ); + return mix( vec3( 1, 1, 1 ), clampedRgb, validYcMask ); + +}; + +const mx_unpremult = ( input ) => { + + const alpha = element( input, 3 ); + const rgb = getRGBChannels( input ); + const unpremultiplied = alpha.equal( 0 ).mix( rgb, div( rgb, alpha ) ); + return vec4( unpremultiplied, alpha ); + +}; + +const mx_colorcorrect = ( + input, + hue = 0, + saturationAmount = 1, + gamma = 1, + lift = 0, + gain = 1, + contrast = 1, + contrastPivot = 0.5, + exposure = 0, +) => { + + const rgbInput = getRGBChannels( input ); + const hsv = mx_rgbtohsv( rgbInput ); + const hueAdjusted = mx_hsvtorgb( add( hsv, vec3( hue, 0, 0 ) ) ); + const saturationAdjusted = mx_saturation( hueAdjusted, saturationAmount ); + const gammaAdjusted = mx_range( saturationAdjusted, 0, 1, 0, 1, gamma ); + const liftApplied = add( mul( gammaAdjusted, sub( 1, lift ) ), lift ); + const gainApplied = mul( liftApplied, gain ); + const contrastApplied = mx_contrast( gainApplied, contrast, contrastPivot ); + const exposureApplied = mul( contrastApplied, pow( 2, exposure ) ); + const preserveAlpha = input && ( input.nodeType === 'vec4' || input.nodeType === 'color4' ); + return preserveAlpha ? vec4( exposureApplied, element( input, 3 ) ) : exposureApplied; + +}; + +const mx_minus = ( fg, bg, mixval = 1 ) => add( mul( mixval, sub( bg, fg ) ), mul( sub( 1, mixval ), bg ) ); +const mx_difference = ( fg, bg, mixval = 1 ) => add( mul( mixval, abs( sub( bg, fg ) ) ), mul( sub( 1, mixval ), bg ) ); +const mx_screen = ( fg, bg, mixval = 1 ) => { + + const screened = sub( 1, mul( sub( 1, fg ), sub( 1, bg ) ) ); + return mix( bg, screened, mixval ); + +}; + +const mx_overlay = ( fg, bg, mixval = 1 ) => { + + const lowBranch = mul( mul( 2, fg ), bg ); + const highBranch = sub( 1, mul( mul( 2, sub( 1, fg ) ), sub( 1, bg ) ) ); + const overlayed = mix( lowBranch, highBranch, step( 0.5, bg ) ); + return mix( bg, overlayed, mixval ); + +}; + +const mx_transformnormal = ( inNode = vec3( 0, 0, 1 ), fromspace = 'world', tospace = 'world' ) => { + + const from = normalizeSpaceName( fromspace, 'world' ); + const to = normalizeSpaceName( tospace, 'world' ); + const inNormal = vec3( inNode ); + + if ( from === to ) { + + return normalize( inNormal ); + + } + + if ( from === 'object' && to === 'world' ) { + + return normalize( mul( inNormal, modelNormalMatrix ) ); + + } + + return normalize( mul( inNormal, mat3( modelWorldMatrix ) ) ); + +}; + +const mx_transformvector = ( inNode = vec3( 0, 0, 0 ), fromspace = 'world', tospace = 'world' ) => { + + const from = normalizeSpaceName( fromspace, 'world' ); + const to = normalizeSpaceName( tospace, 'world' ); + const inVector = vec3( inNode ); + + if ( from === to ) { + + return inVector; + + } + + if ( from === 'object' && to === 'world' ) { + + return mul( inVector, mat3( modelWorldMatrix ) ); + + } + + return mul( inVector, mat3( modelWorldMatrixInverse ) ); + +}; + +const mx_transformpoint = ( inNode = vec3( 0, 0, 0 ), fromspace = 'world', tospace = 'world' ) => { + + const from = normalizeSpaceName( fromspace, 'world' ); + const to = normalizeSpaceName( tospace, 'world' ); + const inPoint = vec3( inNode ); + + if ( from === to ) { + + return inPoint; + + } + + const point4 = vec4( inPoint, 1 ); + const matrix = from === 'object' && to === 'world' ? modelWorldMatrix : modelWorldMatrixInverse; + const transformed4 = mul( point4, matrix ); + return vec3( element( transformed4, 0 ), element( transformed4, 1 ), element( transformed4, 2 ) ); + +}; + +const mx_burn_channel = ( fg, bg, mixval = 1 ) => { + + const composed = add( mul( mixval, sub( 1, div( sub( 1, bg ), fg ) ) ), mul( sub( 1, mixval ), bg ) ); + return mul( composed, step( float( 1e-6 ), abs( fg ) ) ); + +}; + +const mx_dodge_channel = ( fg, bg, mixval = 1 ) => { + + const composed = add( mul( mixval, div( bg, sub( 1, fg ) ) ), mul( sub( 1, mixval ), bg ) ); + return mul( composed, step( float( 1e-6 ), abs( sub( 1, fg ) ) ) ); + +}; + +const isVec3Like = ( node ) => + node && ( node.nodeType === 'vec3' || node.nodeType === 'color' || node.nodeType === 'color3' ); +const isVec4Like = ( node ) => node && ( node.nodeType === 'vec4' || node.nodeType === 'color4' ); + +const mx_burn = ( fg, bg, mixval = 1 ) => { + + if ( isVec4Like( fg ) || isVec4Like( bg ) ) { + + return vec4( + mx_burn_channel( element( fg, 0 ), element( bg, 0 ), mixval ), + mx_burn_channel( element( fg, 1 ), element( bg, 1 ), mixval ), + mx_burn_channel( element( fg, 2 ), element( bg, 2 ), mixval ), + mx_burn_channel( element( fg, 3 ), element( bg, 3 ), mixval ), + ); + + } + + if ( isVec3Like( fg ) || isVec3Like( bg ) ) { + + return vec3( + mx_burn_channel( element( fg, 0 ), element( bg, 0 ), mixval ), + mx_burn_channel( element( fg, 1 ), element( bg, 1 ), mixval ), + mx_burn_channel( element( fg, 2 ), element( bg, 2 ), mixval ), + ); + + } + + return mx_burn_channel( fg, bg, mixval ); + +}; + +const mx_dodge = ( fg, bg, mixval = 1 ) => { + + if ( isVec4Like( fg ) || isVec4Like( bg ) ) { + + return vec4( + mx_dodge_channel( element( fg, 0 ), element( bg, 0 ), mixval ), + mx_dodge_channel( element( fg, 1 ), element( bg, 1 ), mixval ), + mx_dodge_channel( element( fg, 2 ), element( bg, 2 ), mixval ), + mx_dodge_channel( element( fg, 3 ), element( bg, 3 ), mixval ), + ); + + } + + if ( isVec3Like( fg ) || isVec3Like( bg ) ) { + + return vec3( + mx_dodge_channel( element( fg, 0 ), element( bg, 0 ), mixval ), + mx_dodge_channel( element( fg, 1 ), element( bg, 1 ), mixval ), + mx_dodge_channel( element( fg, 2 ), element( bg, 2 ), mixval ), + ); + + } + + return mx_dodge_channel( fg, bg, mixval ); + +}; + +const mx_ramp4 = ( valuetl, valuetr, valuebl, valuebr, texcoord = vec2( 0, 0 ) ) => { + + const clamped = clamp( texcoord, vec2( 0, 0 ), vec2( 1, 1 ) ); + const s = element( clamped, 0 ); + const t = element( clamped, 1 ); + const topMix = mix( valuetl, valuetr, s ); + const bottomMix = mix( valuebl, valuebr, s ); + return mix( topMix, bottomMix, t ); + +}; + +const mx_ramp_gradient = ( + x = 0, + interval1 = 0, + interval2 = 1, + color1 = vec4( 0, 0, 0, 1 ), + color2 = vec4( 1, 1, 1, 1 ), + interpolation = 1, + prevColor = vec4( 0, 0, 0, 1 ), + intervalNum = 1, + numIntervals = 2, +) => { + + const xFloat = float( x ); + const interval1Float = float( interval1 ); + const interval2Float = float( interval2 ); + const interpolationFloat = float( interpolation ); + const intervalNumFloat = float( intervalNum ); + const numIntervalsFloat = float( numIntervals ); + const mixColor4 = ( bg, fg, factor ) => + vec4( + mix( element( bg, 0 ), element( fg, 0 ), factor ), + mix( element( bg, 1 ), element( fg, 1 ), factor ), + mix( element( bg, 2 ), element( fg, 2 ), factor ), + mix( element( bg, 3 ), element( fg, 3 ), factor ), + ); + const linearClamped = clamp( xFloat, interval1Float, interval2Float ); + const rangeSize = sub( interval2Float, interval1Float ); + const safeRange = max( rangeSize, float( 1e-6 ) ); + const linearRemap = div( sub( linearClamped, interval1Float ), safeRange ); + const smoothVal = mx_smoothstep( xFloat, interval1Float, interval2Float ); + const interpolationDistanceToLinear = abs( sub( interpolationFloat, float( 0 ) ) ); + const useLinear = sub( float( 1 ), step( float( 0.5 ), interpolationDistanceToLinear ) ); + const interpFactor = mix( smoothVal, linearRemap, useLinear ); + const mixedColor = mixColor4( color1, color2, interpFactor ); + const stepColor = mixColor4( color1, color2, step( interval2Float, xFloat ) ); + const interpolationDistanceToStep = abs( sub( interpolationFloat, float( 2 ) ) ); + const useStep = sub( float( 1 ), step( float( 0.5 ), interpolationDistanceToStep ) ); + const interpolated = mixColor4( mixedColor, stepColor, useStep ); + const withinInterval = mixColor4( prevColor, interpolated, step( add( interval1Float, float( 1e-6 ) ), xFloat ) ); + return mixColor4( withinInterval, prevColor, step( numIntervalsFloat, intervalNumFloat ) ); + +}; + +const mx_ramp = ( texcoord = vec2( 0, 0 ), type = 0, interpolation = 1, numIntervals = 2, ...rest ) => { + + const mixColor4 = ( bg, fg, factor ) => + vec4( + mix( element( bg, 0 ), element( fg, 0 ), factor ), + mix( element( bg, 1 ), element( fg, 1 ), factor ), + mix( element( bg, 2 ), element( fg, 2 ), factor ), + mix( element( bg, 3 ), element( fg, 3 ), factor ), + ); + + const rampTypeFloat = float( type ); + const interpolationFloat = float( interpolation ); + const numIntervalsFloat = float( numIntervals ); + + const clamped = clamp( texcoord, vec2( 0, 0 ), vec2( 1, 1 ) ); + const s = element( clamped, 0 ); + const t = element( clamped, 1 ); + + const centeredS = sub( s, float( 0.5 ) ); + const centeredT = sub( t, float( 0.5 ) ); + + const radialDist = sqrt( add( mul( centeredS, centeredS ), mul( centeredT, centeredT ) ) ); + const radialVal = clamp( mul( radialDist, float( 2 ) ), float( 0 ), float( 1 ) ); + const circularAngle = add( div( mx_atan2( centeredT, centeredS ), float( Math.PI * 2 ) ), float( 0.5 ) ); + const boxVal = clamp( mul( max( abs( centeredS ), abs( centeredT ) ), float( 2 ) ), float( 0 ), float( 1 ) ); + + const typeDistanceToRadial = abs( sub( rampTypeFloat, float( 1 ) ) ); + const typeDistanceToCircular = abs( sub( rampTypeFloat, float( 2 ) ) ); + const typeDistanceToBox = abs( sub( rampTypeFloat, float( 3 ) ) ); + const useRadial = sub( float( 1 ), step( float( 0.5 ), typeDistanceToRadial ) ); + const useCircular = sub( float( 1 ), step( float( 0.5 ), typeDistanceToCircular ) ); + const useBox = sub( float( 1 ), step( float( 0.5 ), typeDistanceToBox ) ); + const afterRadial = mix( s, radialVal, useRadial ); + const afterCircular = mix( afterRadial, circularAngle, useCircular ); + const rampX = mix( afterCircular, boxVal, useBox ); + + const intervals = []; + const colors = []; + for ( let i = 0; i < 10; i += 1 ) { + + const intervalInput = rest[ i * 2 ]; + const colorInput = rest[ i * 2 + 1 ]; + intervals.push( intervalInput ?? float( i <= 1 ? i : 1 ) ); + colors.push( colorInput ?? vec4( i === 0 ? 0 : 1, i === 0 ? 0 : 1, i === 0 ? 0 : 1, 1 ) ); + + } + + let result = colors[ 0 ]; + for ( let i = 0; i < 9; i += 1 ) { + + const iv1 = intervals[ i ]; + const iv2 = intervals[ i + 1 ]; + const c1 = colors[ i ]; + const c2 = colors[ i + 1 ]; + const intNum = float( i + 1 ); + + const rangeSize = sub( iv2, iv1 ); + const safeRange = max( rangeSize, float( 1e-6 ) ); + const linearClamped = clamp( rampX, iv1, iv2 ); + const linearRemap = div( sub( linearClamped, iv1 ), safeRange ); + const smoothVal = mx_smoothstep( rampX, iv1, iv2 ); + + const interpolationDistanceToLinear = abs( sub( interpolationFloat, float( 0 ) ) ); + const useLinear = sub( float( 1 ), step( float( 0.5 ), interpolationDistanceToLinear ) ); + const interpFactor = mix( smoothVal, linearRemap, useLinear ); + const mixedColor = mixColor4( c1, c2, interpFactor ); + const stepColor = mixColor4( c1, c2, step( iv2, rampX ) ); + const interpolationDistanceToStep = abs( sub( interpolationFloat, float( 2 ) ) ); + const useStep = sub( float( 1 ), step( float( 0.5 ), interpolationDistanceToStep ) ); + const interpolated = mixColor4( mixedColor, stepColor, useStep ); + const withinInterval = mixColor4( result, interpolated, step( add( iv1, float( 1e-6 ) ), rampX ) ); + result = mixColor4( withinInterval, result, step( numIntervalsFloat, intNum ) ); + + } + + return result; + +}; + +const defaultFloat = ( value ) => () => float( value ); +const defaultInt = ( value ) => () => int( value ); +const defaultBool = ( value ) => () => float( value ? 1 : 0 ); +const defaultColor = ( r, g, b ) => () => color( r, g, b ); +const defaultVec2 = ( x, y ) => () => vec2( x, y ); +const defaultVec3 = ( x, y, z ) => () => vec3( x, y, z ); +const defaultVec4 = ( x, y, z, w ) => () => vec4( x, y, z, w ); + +const MXElements = [ + new MXElement( 'add', add, [ 'in1', 'in2' ], { in1: defaultFloat( 0 ), in2: defaultFloat( 0 ) } ), + new MXElement( 'subtract', sub, [ 'in1', 'in2' ], { in1: defaultFloat( 0 ), in2: defaultFloat( 0 ) } ), + new MXElement( 'multiply', mul, [ 'in1', 'in2' ], { in1: defaultFloat( 0 ), in2: defaultFloat( 1 ) } ), + new MXElement( 'divide', div, [ 'in1', 'in2' ], { in1: defaultFloat( 0 ), in2: defaultFloat( 1 ) } ), + new MXElement( 'modulo', mx_modulo, [ 'in1', 'in2' ], { in1: defaultFloat( 0 ), in2: defaultFloat( 1 ) } ), + new MXElement( 'absval', abs, [ 'in' ], { in: defaultFloat( 0 ) } ), + new MXElement( 'sign', sign, [ 'in' ], { in: defaultFloat( 0 ) } ), + new MXElement( 'floor', floor, [ 'in' ], { in: defaultFloat( 0 ) } ), + new MXElement( 'ceil', ceil, [ 'in' ], { in: defaultFloat( 0 ) } ), + new MXElement( 'round', round, [ 'in' ], { in: defaultFloat( 0 ) } ), + new MXElement( 'power', pow, [ 'in1', 'in2' ], { in1: defaultFloat( 0 ), in2: defaultFloat( 1 ) } ), + new MXElement( 'sin', sin, [ 'in' ], { in: defaultFloat( 0 ) } ), + new MXElement( 'cos', cos, [ 'in' ], { in: defaultFloat( 0 ) } ), + new MXElement( 'tan', tan, [ 'in' ], { in: defaultFloat( 0 ) } ), + new MXElement( 'asin', asin, [ 'in' ], { in: defaultFloat( 0 ) } ), + new MXElement( 'acos', acos, [ 'in' ], { in: defaultFloat( 0 ) } ), + new MXElement( 'atan2', mx_atan2, [ 'iny', 'inx' ], { iny: defaultFloat( 0 ), inx: defaultFloat( 1 ) } ), + new MXElement( 'sqrt', sqrt, [ 'in' ], { in: defaultFloat( 0 ) } ), + new MXElement( 'ln', log, [ 'in' ], { in: defaultFloat( 1 ) } ), + new MXElement( 'exp', exp, [ 'in' ], { in: defaultFloat( 0 ) } ), + new MXElement( 'fract', fract, [ 'in' ], { in: defaultFloat( 0 ) } ), + new MXElement( 'clamp', clamp, [ 'in', 'low', 'high' ], { + in: defaultFloat( 0 ), + low: defaultFloat( 0 ), + high: defaultFloat( 1 ), + } ), + new MXElement( 'min', min, [ 'in1', 'in2' ], { in1: defaultFloat( 0 ), in2: defaultFloat( 0 ) } ), + new MXElement( 'max', max, [ 'in1', 'in2' ], { in1: defaultFloat( 0 ), in2: defaultFloat( 0 ) } ), + new MXElement( 'normalize', normalize, [ 'in' ], { in: defaultFloat( 0 ) } ), + new MXElement( 'magnitude', length, [ 'in' ], { in: defaultFloat( 0 ) } ), + new MXElement( 'length', length, [ 'in' ], { in: defaultFloat( 0 ) } ), + new MXElement( 'dot', mx_dot, [ 'in' ], { in: defaultFloat( 0 ) } ), + new MXElement( 'dotproduct', dot, [ 'in1', 'in2' ], { in1: defaultFloat( 0 ), in2: defaultFloat( 0 ) } ), + new MXElement( 'viewdirection', mx_viewdirection ), + new MXElement( 'crossproduct', cross, [ 'in1', 'in2' ], { in1: defaultVec3( 0, 0, 0 ), in2: defaultVec3( 0, 0, 0 ) } ), + new MXElement( 'distance', distance, [ 'in1', 'in2' ], { in1: defaultFloat( 0 ), in2: defaultFloat( 0 ) } ), + new MXElement( 'invert', mx_invert, [ 'in', 'amount' ], { in: defaultFloat( 0 ), amount: defaultFloat( 1 ) } ), + new MXElement( 'transformmatrix', mul, [ 'in', 'mat' ], { in: defaultFloat( 0 ) } ), + new MXElement( 'transformnormal', mx_transformnormal, [ 'in', 'fromspace', 'tospace' ], { + in: defaultVec3( 0, 0, 1 ), + fromspace: () => 'world', + tospace: () => 'world', + } ), + new MXElement( 'transformpoint', mx_transformpoint, [ 'in', 'fromspace', 'tospace' ], { + in: defaultVec3( 0, 0, 0 ), + fromspace: () => 'world', + tospace: () => 'world', + } ), + new MXElement( 'transformvector', mx_transformvector, [ 'in', 'fromspace', 'tospace' ], { + in: defaultVec3( 0, 0, 0 ), + fromspace: () => 'world', + tospace: () => 'world', + } ), + new MXElement( 'normalmap', normalMap, [ 'in', 'scale' ], { in: defaultVec3( 0.5, 0.5, 1.0 ), scale: defaultFloat( 1 ) } ), + new MXElement( 'transpose', transpose, [ 'in' ] ), + new MXElement( 'determinant', determinant, [ 'in' ] ), + new MXElement( 'invertmatrix', inverse, [ 'in' ] ), + new MXElement( 'creatematrix', mat3, [ 'in1', 'in2', 'in3' ], { + in1: defaultVec3( 1, 0, 0 ), + in2: defaultVec3( 0, 1, 0 ), + in3: defaultVec3( 0, 0, 1 ), + } ), + new MXElement( 'remap', remap, [ 'in', 'inlow', 'inhigh', 'outlow', 'outhigh' ], { + in: defaultFloat( 0 ), + inlow: defaultFloat( 0 ), + inhigh: defaultFloat( 1 ), + outlow: defaultFloat( 0 ), + outhigh: defaultFloat( 1 ), + } ), + new MXElement( 'range', mx_range, [ 'in', 'inlow', 'inhigh', 'outlow', 'outhigh', 'gamma' ], { + in: defaultFloat( 0 ), + inlow: defaultFloat( 0 ), + inhigh: defaultFloat( 1 ), + outlow: defaultFloat( 0 ), + outhigh: defaultFloat( 1 ), + gamma: defaultFloat( 1 ), + } ), + new MXElement( 'open_pbr_anisotropy', mx_open_pbr_anisotropy, [ 'roughness', 'anisotropy' ], { + roughness: defaultFloat( 0 ), + anisotropy: defaultFloat( 0 ), + } ), + new MXElement( 'smoothstep', mx_smoothstep, [ 'in', 'low', 'high' ], { + in: defaultFloat( 0 ), + low: defaultFloat( 0 ), + high: defaultFloat( 1 ), + } ), + new MXElement( 'luminance', luminance, [ 'in', 'lumacoeffs' ], { + in: defaultColor( 0, 0, 0 ), + lumacoeffs: defaultColor( 0.2722287, 0.6740818, 0.0536895 ), + } ), + new MXElement( 'rgbtohsv', mx_rgbtohsv, [ 'in' ], { in: defaultColor( 0, 0, 0 ) } ), + new MXElement( 'hsvtorgb', mx_hsvtorgb, [ 'in' ], { in: defaultColor( 0, 0, 0 ) } ), + new MXElement( 'mix', mix, [ 'bg', 'fg', 'mix' ], { bg: defaultFloat( 0 ), fg: defaultFloat( 0 ), mix: defaultFloat( 0 ) } ), + new MXElement( 'minus', mx_minus, [ 'fg', 'bg', 'mix' ], { + fg: defaultFloat( 0 ), + bg: defaultFloat( 0 ), + mix: defaultFloat( 1 ), + } ), + new MXElement( 'difference', mx_difference, [ 'fg', 'bg', 'mix' ], { + fg: defaultFloat( 0 ), + bg: defaultFloat( 0 ), + mix: defaultFloat( 1 ), + } ), + new MXElement( 'screen', mx_screen, [ 'fg', 'bg', 'mix' ], { + fg: defaultFloat( 0 ), + bg: defaultFloat( 0 ), + mix: defaultFloat( 1 ), + } ), + new MXElement( 'overlay', mx_overlay, [ 'fg', 'bg', 'mix' ], { + fg: defaultFloat( 0 ), + bg: defaultFloat( 0 ), + mix: defaultFloat( 1 ), + } ), + new MXElement( 'burn', mx_burn, [ 'fg', 'bg', 'mix' ], { + fg: defaultFloat( 0 ), + bg: defaultFloat( 0 ), + mix: defaultFloat( 1 ), + } ), + new MXElement( 'dodge', mx_dodge, [ 'fg', 'bg', 'mix' ], { + fg: defaultFloat( 0 ), + bg: defaultFloat( 0 ), + mix: defaultFloat( 1 ), + } ), + new MXElement( + 'colorcorrect', + mx_colorcorrect, + [ 'in', 'hue', 'saturation', 'gamma', 'lift', 'gain', 'contrast', 'contrastpivot', 'exposure' ], + { + in: defaultColor( 1, 1, 1 ), + hue: defaultFloat( 0 ), + saturation: defaultFloat( 1 ), + gamma: defaultFloat( 1 ), + lift: defaultFloat( 0 ), + gain: defaultFloat( 1 ), + contrast: defaultFloat( 1 ), + contrastpivot: defaultFloat( 0.5 ), + exposure: defaultFloat( 0 ), + }, + ), + new MXElement( 'unpremult', mx_unpremult, [ 'in' ], { in: defaultVec4( 0, 0, 0, 1 ) } ), + new MXElement( 'combine2', vec2, [ 'in1', 'in2' ], { in1: defaultFloat( 0 ), in2: defaultFloat( 0 ) } ), + new MXElement( 'combine3', vec3, [ 'in1', 'in2', 'in3' ], { + in1: defaultFloat( 0 ), + in2: defaultFloat( 0 ), + in3: defaultFloat( 0 ), + } ), + new MXElement( 'combine4', vec4, [ 'in1', 'in2', 'in3', 'in4' ], { + in1: defaultFloat( 0 ), + in2: defaultFloat( 0 ), + in3: defaultFloat( 0 ), + in4: defaultFloat( 0 ), + } ), + new MXElement( 'ramplr', mx_ramplr, [ 'valuel', 'valuer', 'texcoord' ], { + valuel: defaultFloat( 0 ), + valuer: defaultFloat( 0 ), + } ), + new MXElement( 'ramptb', mx_ramptb, [ 'valuet', 'valueb', 'texcoord' ], { + valuet: defaultFloat( 0 ), + valueb: defaultFloat( 0 ), + } ), + new MXElement( 'ramp4', mx_ramp4, [ 'valuetl', 'valuetr', 'valuebl', 'valuebr', 'texcoord' ], { + valuetl: defaultColor( 0, 0, 0 ), + valuetr: defaultColor( 0, 0, 0 ), + valuebl: defaultColor( 0, 0, 0 ), + valuebr: defaultColor( 0, 0, 0 ), + texcoord: defaultVec2( 0, 0 ), + } ), + new MXElement( + 'ramp_gradient', + mx_ramp_gradient, + [ 'x', 'interval1', 'interval2', 'color1', 'color2', 'interpolation', 'prev_color', 'interval_num', 'num_intervals' ], + { + x: defaultFloat( 0 ), + interval1: defaultFloat( 0 ), + interval2: defaultFloat( 1 ), + color1: defaultVec4( 0, 0, 0, 1 ), + color2: defaultVec4( 1, 1, 1, 1 ), + interpolation: defaultFloat( 1 ), + prev_color: defaultVec4( 0, 0, 0, 1 ), + interval_num: defaultFloat( 1 ), + num_intervals: defaultFloat( 2 ), + }, + ), + new MXElement( + 'ramp', + mx_ramp, + [ + 'texcoord', + 'type', + 'interpolation', + 'num_intervals', + 'interval1', + 'color1', + 'interval2', + 'color2', + 'interval3', + 'color3', + 'interval4', + 'color4', + 'interval5', + 'color5', + 'interval6', + 'color6', + 'interval7', + 'color7', + 'interval8', + 'color8', + 'interval9', + 'color9', + 'interval10', + 'color10', + ], + { + texcoord: defaultVec2( 0, 0 ), + type: defaultFloat( 0 ), + interpolation: defaultFloat( 1 ), + num_intervals: defaultFloat( 2 ), + interval1: defaultFloat( 0 ), + color1: defaultVec4( 0, 0, 0, 1 ), + interval2: defaultFloat( 1 ), + color2: defaultVec4( 1, 1, 1, 1 ), + interval3: defaultFloat( 1 ), + color3: defaultVec4( 1, 1, 1, 1 ), + interval4: defaultFloat( 1 ), + color4: defaultVec4( 1, 1, 1, 1 ), + interval5: defaultFloat( 1 ), + color5: defaultVec4( 1, 1, 1, 1 ), + interval6: defaultFloat( 1 ), + color6: defaultVec4( 1, 1, 1, 1 ), + interval7: defaultFloat( 1 ), + color7: defaultVec4( 1, 1, 1, 1 ), + interval8: defaultFloat( 1 ), + color8: defaultVec4( 1, 1, 1, 1 ), + interval9: defaultFloat( 1 ), + color9: defaultVec4( 1, 1, 1, 1 ), + interval10: defaultFloat( 1 ), + color10: defaultVec4( 1, 1, 1, 1 ), + }, + ), + new MXElement( 'splitlr', mx_splitlr, [ 'valuel', 'valuer', 'center', 'texcoord' ], { + valuel: defaultFloat( 0 ), + valuer: defaultFloat( 0 ), + center: defaultFloat( 0.5 ), + } ), + new MXElement( 'splittb', mx_splittb, [ 'valuet', 'valueb', 'center', 'texcoord' ], { + valuet: defaultFloat( 0 ), + valueb: defaultFloat( 0 ), + center: defaultFloat( 0.5 ), + } ), + new MXElement( 'noise2d', mx_noise_float, [ 'texcoord', 'amplitude', 'pivot' ], { + texcoord: defaultVec2( 0, 0 ), + amplitude: defaultFloat( 1 ), + pivot: defaultFloat( 0 ), + } ), + new MXElement( 'noise3d', mx_noise_float, [ 'position', 'amplitude', 'pivot' ], { + position: () => positionLocal, + amplitude: defaultFloat( 1 ), + pivot: defaultFloat( 0 ), + } ), + new MXElement( 'fractal3d', mx_fractal_noise_float, [ 'position', 'octaves', 'lacunarity', 'diminish', 'amplitude' ], { + position: () => positionLocal, + octaves: defaultInt( 3 ), + lacunarity: defaultFloat( 2.0 ), + diminish: defaultFloat( 0.5 ), + amplitude: defaultFloat( 1.0 ), + } ), + new MXElement( 'cellnoise2d', mx_cell_noise_float, [ 'texcoord' ], { texcoord: defaultVec2( 0, 0 ) } ), + new MXElement( 'cellnoise3d', mx_cell_noise_float, [ 'position' ], { position: () => positionLocal } ), + new MXElement( 'worleynoise2d', mx_worley_noise_float_2d, [ 'texcoord', 'jitter', 'style' ], { + texcoord: defaultVec2( 0, 0 ), + jitter: defaultFloat( 1 ), + style: defaultInt( 0 ), + } ), + new MXElement( 'worleynoise3d', mx_worley_noise_float_3d, [ 'position', 'jitter', 'style' ], { + position: () => positionLocal, + jitter: defaultFloat( 1 ), + style: defaultInt( 0 ), + } ), + new MXElement( + 'unifiednoise2d', + mx_unifiednoise2d, + [ + 'type', + 'texcoord', + 'freq', + 'offset', + 'jitter', + 'outmin', + 'outmax', + 'clampoutput', + 'octaves', + 'lacunarity', + 'diminish', + 'style', + ], + { + type: defaultInt( 0 ), + texcoord: defaultVec2( 0, 0 ), + freq: defaultVec2( 1, 1 ), + offset: defaultVec2( 0, 0 ), + jitter: defaultFloat( 1 ), + outmin: defaultFloat( 0 ), + outmax: defaultFloat( 1 ), + clampoutput: defaultBool( true ), + octaves: defaultInt( 3 ), + lacunarity: defaultFloat( 2 ), + diminish: defaultFloat( 0.5 ), + style: defaultInt( 0 ), + }, + ), + new MXElement( + 'unifiednoise3d', + mx_unifiednoise3d, + [ + 'type', + 'position', + 'freq', + 'offset', + 'jitter', + 'outmin', + 'outmax', + 'clampoutput', + 'octaves', + 'lacunarity', + 'diminish', + 'style', + ], + { + type: defaultInt( 0 ), + position: () => positionLocal, + freq: defaultVec3( 1, 1, 1 ), + offset: defaultVec3( 0, 0, 0 ), + jitter: defaultFloat( 1 ), + outmin: defaultFloat( 0 ), + outmax: defaultFloat( 1 ), + clampoutput: defaultBool( true ), + octaves: defaultInt( 3 ), + lacunarity: defaultFloat( 2 ), + diminish: defaultFloat( 0.5 ), + style: defaultInt( 0 ), + }, + ), + new MXElement( 'place2d', mx_place2d, [ 'texcoord', 'pivot', 'scale', 'rotate', 'offset', 'operationorder' ], { + texcoord: defaultVec2( 0, 0 ), + pivot: defaultVec2( 0, 0 ), + scale: defaultVec2( 1, 1 ), + rotate: defaultFloat( 0 ), + offset: defaultVec2( 0, 0 ), + operationorder: defaultInt( 0 ), + } ), + new MXElement( 'safepower', mx_safepower, [ 'in1', 'in2' ], { in1: defaultFloat( 0 ), in2: defaultFloat( 1 ) } ), + new MXElement( 'contrast', mx_contrast, [ 'in', 'amount', 'pivot' ], { + in: defaultFloat( 0 ), + amount: defaultFloat( 1 ), + pivot: defaultFloat( 0.5 ), + } ), + new MXElement( 'saturate', mx_saturation, [ 'in', 'amount' ], { in: defaultColor( 0, 0, 0 ), amount: defaultFloat( 1 ) } ), + new MXElement( 'extract', element, [ 'in', 'index' ], { in: defaultFloat( 0 ), index: defaultInt( 0 ) } ), + new MXElement( 'separate2', element, [ 'in' ], { in: defaultVec2( 0, 0 ) } ), + new MXElement( 'separate3', element, [ 'in' ], { in: defaultVec3( 0, 0, 0 ) } ), + new MXElement( 'separate4', element, [ 'in' ], { in: defaultVec4( 0, 0, 0, 0 ) } ), + new MXElement( 'reflect', reflect, [ 'in', 'normal' ], { in: defaultVec3( 1, 0, 0 ) } ), + new MXElement( 'refract', refract, [ 'in', 'normal', 'ior' ], { in: defaultVec3( 1, 0, 0 ), ior: defaultFloat( 1 ) } ), + new MXElement( 'time', mx_timer ), + new MXElement( 'frame', mx_frame ), + new MXElement( 'ifgreater', mx_ifgreater, [ 'value1', 'value2', 'in1', 'in2' ], { + value1: defaultFloat( 1 ), + value2: defaultFloat( 0 ), + in1: defaultFloat( 0 ), + in2: defaultFloat( 0 ), + } ), + new MXElement( 'ifgreatereq', mx_ifgreatereq, [ 'value1', 'value2', 'in1', 'in2' ], { + value1: defaultFloat( 1 ), + value2: defaultFloat( 0 ), + in1: defaultFloat( 0 ), + in2: defaultFloat( 0 ), + } ), + new MXElement( 'ifequal', mx_ifequal, [ 'value1', 'value2', 'in1', 'in2' ], { + value1: defaultFloat( 0 ), + value2: defaultFloat( 0 ), + in1: defaultFloat( 0 ), + in2: defaultFloat( 0 ), + } ), + new MXElement( 'rotate2d', mx_rotate2d, [ 'in', 'amount' ], { in: defaultVec2( 0, 0 ), amount: defaultFloat( 0 ) } ), + new MXElement( 'rotate3d', mx_rotate3d, [ 'in', 'amount', 'axis' ], { + in: defaultVec3( 0, 0, 0 ), + amount: defaultFloat( 0 ), + axis: defaultVec3( 0, 1, 0 ), + } ), + new MXElement( 'heighttonormal', mx_heighttonormal, [ 'in', 'scale', 'texcoord' ], { + in: defaultFloat( 0 ), + scale: defaultFloat( 1 ), + } ), + new MXElement( 'and', mx_and, [ 'in1', 'in2' ], { in1: defaultBool( false ), in2: defaultBool( false ) } ), + new MXElement( 'or', mx_or, [ 'in1', 'in2' ], { in1: defaultBool( false ), in2: defaultBool( false ) } ), + new MXElement( 'xor', mx_xor, [ 'in1', 'in2' ], { in1: defaultBool( false ), in2: defaultBool( false ) } ), + new MXElement( 'not', mx_not, [ 'in' ], { in: defaultBool( false ) } ), + new MXElement( 'checkerboard', mx_checkerboard, [ 'color1', 'color2', 'texcoord' ], { + color1: defaultColor( 1, 1, 1 ), + color2: defaultColor( 0, 0, 0 ), + texcoord: defaultVec2( 0, 0 ), + } ), + new MXElement( 'circle', mx_circle, [ 'texcoord', 'center', 'radius' ], { + center: defaultVec2( 0, 0 ), + radius: defaultFloat( 0.5 ), + } ), + new MXElement( 'bump', mx_bump, [ 'height', 'scale' ], { height: defaultFloat( 0 ), scale: defaultFloat( 1 ) } ), + new MXElement( 'blackbody', mx_blackbody, [ 'temperature' ], { temperature: defaultFloat( 5000 ) } ), +]; + +const MtlXLibrary = {}; +for ( const entry of MXElements ) { + + MtlXLibrary[ entry.name ] = entry; + +} + +export { MtlXLibrary }; diff --git a/examples/jsm/loaders/materialx/MaterialXNodeRegistry.js b/examples/jsm/loaders/materialx/MaterialXNodeRegistry.js new file mode 100644 index 00000000000000..f01c59efd5ec99 --- /dev/null +++ b/examples/jsm/loaders/materialx/MaterialXNodeRegistry.js @@ -0,0 +1,86 @@ +const materialXNodeCategories = new Set(); + +function hasMaterialXCategory( category ) { + + return materialXNodeCategories.has( category ); + +} + +function validateCategoryCoverage( { + compileCategories = [], + surfaceCategories = [], + allowUnknownCompileCategories = [], +} = {} ) { + + materialXNodeCategories.clear(); + + for ( const category of compileCategories ) { + + materialXNodeCategories.add( category ); + + } + + for ( const category of surfaceCategories ) { + + materialXNodeCategories.add( category ); + + } + + for ( const category of allowUnknownCompileCategories ) { + + materialXNodeCategories.add( category ); + + } + + const allowUnknownCompileSet = new Set( allowUnknownCompileCategories ); + const unknownCompile = []; + const unknownSurface = []; + + for ( const category of compileCategories ) { + + if ( ! hasMaterialXCategory( category ) && ! allowUnknownCompileSet.has( category ) ) { + + unknownCompile.push( category ); + + } + + } + + for ( const category of surfaceCategories ) { + + if ( ! hasMaterialXCategory( category ) ) { + + unknownSurface.push( category ); + + } + + } + + if ( unknownCompile.length === 0 && unknownSurface.length === 0 ) { + + return; + + } + + const details = []; + if ( unknownCompile.length > 0 ) { + + details.push( `unknown compile categories: ${unknownCompile.sort().join( ', ' )}` ); + + } + + if ( unknownSurface.length > 0 ) { + + details.push( `unknown surface categories: ${unknownSurface.sort().join( ', ' )}` ); + + } + + throw new Error( `MaterialX translator registry validation failed (${details.join( '; ' )}).` ); + +} + +export { + materialXNodeCategories, + hasMaterialXCategory, + validateCategoryCoverage, +}; diff --git a/examples/jsm/loaders/materialx/MaterialXSurfaceMappings.js b/examples/jsm/loaders/materialx/MaterialXSurfaceMappings.js new file mode 100644 index 00000000000000..45c78c905c23cf --- /dev/null +++ b/examples/jsm/loaders/materialx/MaterialXSurfaceMappings.js @@ -0,0 +1,641 @@ +import { DoubleSide } from 'three/webgpu'; +import { float, color, mul, clamp, vec2, cos, sin, pow, mix, transformNormalToView } from 'three/tsl'; + +const mappedStandardSurfaceInputs = new Set( [ + 'base', + 'base_color', + 'roughness', + 'specular_roughness', + 'metalness', + 'specular', + 'specular_color', + 'specular_anisotropy', + 'specular_rotation', + 'transmission', + 'transmission_color', + 'transmission_depth', + 'thin_film_thickness', + 'thin_film_ior', + 'thin_film_IOR', + 'sheen', + 'sheen_color', + 'sheen_roughness', + 'coat', + 'coat_color', + 'coat_roughness', + 'coat_normal', + 'normal', + 'opacity', + 'ior', + 'specular_IOR', + 'emission', + 'emissionColor', + 'emission_color', +] ); + +const mappedGltfPbrInputs = new Set( [ + 'base_color', + 'occlusion', + 'roughness', + 'metallic', + 'normal', + 'transmission', + 'specular', + 'specular_color', + 'ior', + 'alpha', + 'alpha_mode', + 'alpha_cutoff', + 'iridescence', + 'iridescence_ior', + 'iridescence_thickness', + 'sheen_color', + 'sheen_roughness', + 'clearcoat', + 'clearcoat_roughness', + 'clearcoat_normal', + 'emissive', + 'emissive_strength', + 'attenuation_distance', + 'attenuation_color', + 'thickness', + 'dispersion', + 'anisotropy_strength', + 'anisotropy_rotation', +] ); + +const mappedOpenPbrInputs = new Set( [ + 'base_weight', + 'base_color', + 'specular_weight', + 'specular_color', + 'specular_roughness', + 'base_metalness', + 'specular_roughness_anisotropy', + 'specular_ior', + 'specular_ior_level', + 'coat_weight', + 'coat_ior', + 'coat_color', + 'coat_roughness', + 'geometry_coat_normal', + 'fuzz_weight', + 'fuzz_color', + 'fuzz_roughness', + 'transmission_weight', + 'transmission_color', + 'transmission_depth', + 'transmission_dispersion_scale', + 'transmission_dispersion_abbe_number', + 'geometry_normal', + 'geometry_opacity', + 'geometry_thin_walled', + 'thin_film_weight', + 'thin_film_thickness', + 'thin_film_ior', + 'emission_color', + 'emission_luminance', +] ); + +function warnIgnoredInputs( inputs, mappedInputs, issueCollector, surfaceCategory, nodeName ) { + + for ( const inputName of Object.keys( inputs ) ) { + + if ( mappedInputs.has( inputName ) === false ) { + + issueCollector.addIgnoredSurfaceInput( surfaceCategory, nodeName, inputName ); + + } + + } + +} + +function hasNodeValue( value ) { + + return value !== undefined && value !== null; + +} + +function getConstNumber( node ) { + + if ( typeof node === 'number' ) return node; + if ( ! node || typeof node !== 'object' ) return null; + + let cursor = node; + const visited = new Set(); + while ( cursor && typeof cursor === 'object' ) { + + if ( visited.has( cursor ) ) break; + visited.add( cursor ); + if ( typeof cursor.value === 'number' ) return cursor.value; + cursor = cursor.node; + + } + + return null; + +} + +function isConstNear( node, target, epsilon = 1e-6 ) { + + const value = getConstNumber( node ); + if ( value === null ) return false; + return Math.abs( value - target ) <= epsilon; + +} + +function isEffectivelyZero( node, epsilon = 1e-6 ) { + + return isConstNear( node, 0, epsilon ); + +} + +function isEffectivelyOne( node, epsilon = 1e-6 ) { + + return isConstNear( node, 1, epsilon ); + +} + +function isEnabledWeightNode( node ) { + + if ( hasNodeValue( node ) === false ) return false; + return isEffectivelyZero( node ) === false; + +} + +function setAnisotropy( material, strengthNode, rotationNode ) { + + if ( ! hasNodeValue( strengthNode ) && ! hasNodeValue( rotationNode ) ) return; + if ( isEffectivelyZero( strengthNode ) && isEffectivelyZero( rotationNode ) ) return; + const strength = hasNodeValue( strengthNode ) ? strengthNode : float( 0 ); + const rotation = hasNodeValue( rotationNode ) ? rotationNode : float( 0 ); + material.anisotropyNode = vec2( cos( rotation ), sin( rotation ) ).mul( strength ); + material.anisotropyRotationNode = rotation; + +} + +function setTransmissionFlags( material, transmissionNode, opacityNode, allowOpacityTransparency = true ) { + + if ( allowOpacityTransparency && hasNodeValue( opacityNode ) && isEffectivelyOne( opacityNode ) === false ) { + + material.transparent = true; + + } + + if ( isEnabledWeightNode( transmissionNode ) ) { + + material.side = DoubleSide; + material.transparent = true; + + } + +} + +function toAttenuationDistance( distanceNode, hasAttenuationColorInput ) { + + if ( hasNodeValue( distanceNode ) ) return distanceNode; + // When attenuation tint is authored without a distance, default to a + // finite value so absorption tinting is visible. + return hasAttenuationColorInput ? float( 1 ) : undefined; + +} + +function buildGltfOpacityNode( alphaNode, alphaModeNode ) { + + const alphaMode = getConstNumber( alphaModeNode ); + const roundedMode = alphaMode === null ? 2 : Math.round( alphaMode ); + const alpha = alphaNode ?? float( 1 ); + + if ( roundedMode === 0 ) return float( 1 ); + if ( roundedMode === 1 ) return alpha; + return alpha; + +} + +function applyStandardSurface( material, inputs, issueCollector, nodeName ) { + + let colorNode = null; + if ( inputs.base && inputs.base_color ) colorNode = mul( inputs.base, inputs.base_color ); + else if ( inputs.base ) colorNode = inputs.base; + else if ( inputs.base_color ) colorNode = inputs.base_color; + + if ( inputs.coat_color ) { + + colorNode = colorNode ? mul( colorNode, inputs.coat_color ) : colorNode; + + } + + const roughnessNode = inputs.specular_roughness ?? inputs.roughness; + const opacityNode = inputs.opacity; + const transmissionNode = inputs.transmission; + const transmissionColorNode = inputs.transmission_color; + const transmissionEnabled = isEnabledWeightNode( transmissionNode ); + const sheenEnabled = isEnabledWeightNode( inputs.sheen ); + const clearcoatEnabled = isEnabledWeightNode( inputs.coat ); + + let emissiveNode = inputs.emission; + const emissionColorNode = inputs.emission_color ?? inputs.emissionColor; + if ( hasNodeValue( emissionColorNode ) ) { + + emissiveNode = emissiveNode ? mul( emissiveNode, emissionColorNode ) : emissionColorNode; + + } + + const thinFilmThicknessNode = inputs.thin_film_thickness; + const thinFilmIorNode = clamp( inputs.thin_film_ior || inputs.thin_film_IOR || float( 1.5 ), float( 1.0 ), float( 2.333 ) ); + const thinFilmEnabled = isEnabledWeightNode( thinFilmThicknessNode ); + const transmissionTintNode = hasNodeValue( transmissionColorNode ) ? transmissionColorNode : color( 1, 1, 1 ); + + if ( transmissionEnabled ) { + + const baseForTransmissionMix = colorNode || color( 0.8, 0.8, 0.8 ); + // Suppress diffuse/base tint as transmission ramps up. + colorNode = mix( baseForTransmissionMix, transmissionTintNode, transmissionNode ); + + } + + material.colorNode = colorNode || color( 0.8, 0.8, 0.8 ); + if ( hasNodeValue( opacityNode ) && isEffectivelyOne( opacityNode ) === false ) { + + material.opacityNode = opacityNode; + + } + + material.roughnessNode = roughnessNode || float( 0.2 ); + if ( hasNodeValue( inputs.metalness ) && isEffectivelyZero( inputs.metalness ) === false ) { + + material.metalnessNode = inputs.metalness; + + } + + if ( hasNodeValue( inputs.specular ) && isEffectivelyOne( inputs.specular ) === false ) { + + material.specularIntensityNode = inputs.specular; + + } + + material.specularColorNode = inputs.specular_color || color( 1, 1, 1 ); + const iorNode = inputs.specular_IOR || inputs.ior; + if ( hasNodeValue( iorNode ) && isConstNear( iorNode, 1.5 ) === false ) { + + material.iorNode = iorNode; + + } + + setAnisotropy( material, inputs.specular_anisotropy, inputs.specular_rotation ); + + if ( transmissionEnabled ) { + + material.transmissionNode = transmissionNode; + if ( hasNodeValue( transmissionColorNode ) ) material.transmissionColorNode = transmissionColorNode; + if ( hasNodeValue( inputs.transmission_depth ) && isEffectivelyZero( inputs.transmission_depth ) === false ) { + + material.thicknessNode = inputs.transmission_depth; + + } else { + + // Keep transmissive standard_surface materials volumetric when + // transmission_depth is omitted or authored as zero. + material.thickness = 1; + + } + + } + + if ( thinFilmEnabled ) { + + material.iridescenceThicknessNode = thinFilmThicknessNode; + material.iridescenceIORNode = thinFilmIorNode; + material.iridescenceNode = float( 1 ); + + } + + const sheenColor = inputs.sheen_color || color( 1, 1, 1 ); + if ( sheenEnabled ) { + + const sheenRoughness = hasNodeValue( inputs.sheen_roughness ) ? inputs.sheen_roughness : float( 0.3 ); + material.sheenNode = mul( inputs.sheen, sheenColor ); + material.sheenRoughnessNode = sheenRoughness; + + } + + if ( clearcoatEnabled ) { + + material.clearcoatNode = inputs.coat; + if ( hasNodeValue( inputs.coat_roughness ) ) { + + material.clearcoatRoughnessNode = inputs.coat_roughness; + + } + + } + + if ( clearcoatEnabled && hasNodeValue( inputs.coat_normal ) ) { + + material.clearcoatNormalNode = transformNormalToView( inputs.coat_normal ); + + } + + if ( hasNodeValue( inputs.normal ) ) material.normalNode = transformNormalToView( inputs.normal ); + if ( hasNodeValue( emissiveNode ) ) material.emissiveNode = emissiveNode; + + setTransmissionFlags( material, transmissionNode, opacityNode ); + warnIgnoredInputs( inputs, mappedStandardSurfaceInputs, issueCollector, 'standard_surface', nodeName ); + +} + +function applyGltfPbrSurface( material, inputs, issueCollector, nodeName ) { + + const alphaModeLiteral = getConstNumber( inputs.alpha_mode ); + const alphaMode = alphaModeLiteral === null ? 2 : Math.round( alphaModeLiteral ); + const isAlphaMaskMode = alphaMode === 1; + const isAlphaBlendMode = alphaMode === 2; + const opacityNode = buildGltfOpacityNode( inputs.alpha, inputs.alpha_mode ); + const alphaCutoffNode = inputs.alpha_cutoff ?? float( 0.5 ); + const hasAttenuationColorInput = Object.prototype.hasOwnProperty.call( inputs, 'attenuation_color' ); + const transmissionEnabled = isEnabledWeightNode( inputs.transmission ); + const clearcoatEnabled = isEnabledWeightNode( inputs.clearcoat ); + const sheenEnabled = hasNodeValue( inputs.sheen_color ) || isEnabledWeightNode( inputs.sheen_roughness ); + const iridescenceEnabled = isEnabledWeightNode( inputs.iridescence ); + + material.colorNode = inputs.base_color || color( 1, 1, 1 ); + if ( hasNodeValue( inputs.occlusion ) ) material.aoNode = inputs.occlusion; + material.roughnessNode = inputs.roughness || float( 1 ); + material.metalnessNode = inputs.metallic || float( 1 ); + if ( hasNodeValue( inputs.specular ) && isEffectivelyOne( inputs.specular ) === false ) { + + material.specularIntensityNode = inputs.specular; + + } + + material.specularColorNode = inputs.specular_color || color( 1, 1, 1 ); + if ( hasNodeValue( inputs.ior ) && isConstNear( inputs.ior, 1.5 ) === false ) { + + material.iorNode = inputs.ior; + + } + + if ( hasNodeValue( opacityNode ) && isEffectivelyOne( opacityNode ) === false ) { + + material.opacityNode = opacityNode; + + } + + if ( isAlphaMaskMode ) { + + material.alphaTestNode = alphaCutoffNode; + const alphaCutoff = getConstNumber( alphaCutoffNode ); + if ( alphaCutoff !== null ) material.alphaTest = alphaCutoff; + + } + + if ( transmissionEnabled ) { + + material.transmissionNode = inputs.transmission; + + } + + if ( clearcoatEnabled ) { + + material.clearcoatNode = inputs.clearcoat; + if ( hasNodeValue( inputs.clearcoat_roughness ) && isEffectivelyZero( inputs.clearcoat_roughness ) === false ) { + + material.clearcoatRoughnessNode = inputs.clearcoat_roughness; + + } + + } + + if ( sheenEnabled ) { + + const sheenColor = hasNodeValue( inputs.sheen_color ) ? inputs.sheen_color : color( 0, 0, 0 ); + const sheenRoughness = hasNodeValue( inputs.sheen_roughness ) ? inputs.sheen_roughness : float( 0 ); + material.sheenRoughnessNode = sheenRoughness; + material.sheenNode = sheenColor; + + } + + if ( iridescenceEnabled ) { + + material.iridescenceNode = inputs.iridescence; + if ( hasNodeValue( inputs.iridescence_ior ) && isConstNear( inputs.iridescence_ior, 1.3 ) === false ) { + + material.iridescenceIORNode = inputs.iridescence_ior; + + } + + if ( hasNodeValue( inputs.iridescence_thickness ) && isConstNear( inputs.iridescence_thickness, 100 ) === false ) { + + material.iridescenceThicknessNode = inputs.iridescence_thickness; + + } + + } + + material.attenuationDistanceNode = toAttenuationDistance( inputs.attenuation_distance, hasAttenuationColorInput ); + material.attenuationColorNode = inputs.attenuation_color || color( 1, 1, 1 ); + if ( hasNodeValue( inputs.thickness ) ) { + + if ( isEffectivelyZero( inputs.thickness ) === false ) { + + material.thicknessNode = inputs.thickness; + + } + + } else if ( transmissionEnabled ) { + + // Keep transmissive glTF materials volumetric even when thickness is omitted. + material.thickness = 1; + + } + + if ( hasNodeValue( inputs.dispersion ) && isEffectivelyZero( inputs.dispersion ) === false ) { + + material.dispersionNode = inputs.dispersion; + + } + + const anisotropyStrength = inputs.anisotropy_strength; + const anisotropyRotation = inputs.anisotropy_rotation; + setAnisotropy( material, anisotropyStrength, anisotropyRotation ); + + if ( hasNodeValue( inputs.normal ) ) material.normalNode = transformNormalToView( inputs.normal ); + if ( hasNodeValue( inputs.clearcoat_normal ) ) { + + material.clearcoatNormalNode = transformNormalToView( inputs.clearcoat_normal ); + + } + + if ( hasNodeValue( inputs.emissive ) && hasNodeValue( inputs.emissive_strength ) ) + material.emissiveNode = mul( inputs.emissive, inputs.emissive_strength ); + else if ( hasNodeValue( inputs.emissive ) ) material.emissiveNode = inputs.emissive; + + setTransmissionFlags( material, inputs.transmission, opacityNode, isAlphaBlendMode ); + warnIgnoredInputs( inputs, mappedGltfPbrInputs, issueCollector, 'gltf_pbr', nodeName ); + +} + +function applyOpenPbrSurface( material, inputs, issueCollector, nodeName ) { + + const baseWeight = inputs.base_weight || float( 1 ); + const baseColor = inputs.base_color || color( 0.8, 0.8, 0.8 ); + const coatEnabled = isEnabledWeightNode( inputs.coat_weight ); + const fuzzEnabled = isEnabledWeightNode( inputs.fuzz_weight ); + const transmissionEnabled = isEnabledWeightNode( inputs.transmission_weight ); + const thinFilmEnabled = isEnabledWeightNode( inputs.thin_film_weight ); + material.colorNode = mul( baseWeight, baseColor ); + + if ( hasNodeValue( inputs.base_metalness ) && isEffectivelyZero( inputs.base_metalness ) === false ) { + + material.metalnessNode = inputs.base_metalness; + + } + + material.roughnessNode = inputs.specular_roughness || float( 0.3 ); + if ( hasNodeValue( inputs.specular_weight ) && isEffectivelyOne( inputs.specular_weight ) === false ) { + + material.specularIntensityNode = inputs.specular_weight; + + } + + material.specularColorNode = inputs.specular_color || color( 1, 1, 1 ); + const openPbrIorNode = inputs.specular_ior || inputs.specular_ior_level; + if ( hasNodeValue( openPbrIorNode ) && isConstNear( openPbrIorNode, 1.5 ) === false ) { + + material.iorNode = openPbrIorNode; + + } + + setAnisotropy( material, inputs.specular_roughness_anisotropy, float( 0 ) ); + + if ( coatEnabled ) { + + const coatWeightNode = inputs.coat_weight || float( 0 ); + if ( hasNodeValue( inputs.coat_ior ) ) { + + const coatIorNode = inputs.coat_ior; + const coatIorMinusOne = coatIorNode.sub( float( 1 ) ); + const coatIorPlusOne = coatIorNode.add( float( 1 ) ); + const coatF0Node = coatIorMinusOne.div( coatIorPlusOne ); + const normalizedClearcoatNode = coatF0Node.mul( coatF0Node ).div( float( 0.04 ) ); + material.clearcoatNode = clamp( coatWeightNode.mul( normalizedClearcoatNode ), float( 0 ), float( 1 ) ); + + } else { + + material.clearcoatNode = coatWeightNode; + + } + + if ( hasNodeValue( inputs.coat_roughness ) && isEffectivelyZero( inputs.coat_roughness ) === false ) { + + material.clearcoatRoughnessNode = inputs.coat_roughness; + + } + + if ( hasNodeValue( inputs.geometry_coat_normal ) ) { + + material.clearcoatNormalNode = transformNormalToView( inputs.geometry_coat_normal ); + + } + + } + + const fuzzWeight = inputs.fuzz_weight || float( 0 ); + const fuzzColor = inputs.fuzz_color || color( 1, 1, 1 ); + if ( fuzzEnabled ) { + + material.sheenNode = mul( fuzzWeight, fuzzColor ); + if ( hasNodeValue( inputs.fuzz_roughness ) ) { + + material.sheenRoughnessNode = pow( inputs.fuzz_roughness, float( 1.5 ) ); + + } + + } + + if ( transmissionEnabled ) { + + material.transmissionNode = inputs.transmission_weight; + if ( hasNodeValue( inputs.transmission_color ) ) material.attenuationColorNode = inputs.transmission_color; + + } + + const transmissionDepthNode = inputs.transmission_depth; + if ( transmissionEnabled && hasNodeValue( transmissionDepthNode ) && isEffectivelyZero( transmissionDepthNode ) === false ) { + + material.thicknessNode = hasNodeValue( inputs.geometry_thin_walled ) + ? inputs.geometry_thin_walled.select( float( 0 ), transmissionDepthNode ) + : transmissionDepthNode; + material.attenuationDistanceNode = transmissionDepthNode; + + } else if ( transmissionEnabled ) { + + // Keep transmissive OpenPBR materials volumetric even when depth is omitted. + material.thickness = 1; + + } + + const transmissionDispersionAbbe = inputs.transmission_dispersion_abbe_number || float( 20 ); + if ( transmissionEnabled && hasNodeValue( inputs.transmission_dispersion_scale ) && isEffectivelyZero( inputs.transmission_dispersion_scale ) === false ) { + + material.dispersionNode = inputs.transmission_dispersion_scale.mul( float( 20 ) ).div( transmissionDispersionAbbe ); + + } + + if ( hasNodeValue( inputs.geometry_opacity ) && isEffectivelyOne( inputs.geometry_opacity ) === false ) { + + material.opacityNode = inputs.geometry_opacity; + + } + + if ( hasNodeValue( inputs.geometry_normal ) ) material.normalNode = transformNormalToView( inputs.geometry_normal ); + + if ( thinFilmEnabled ) { + + material.iridescenceNode = inputs.thin_film_weight; + if ( hasNodeValue( inputs.thin_film_thickness ) && isConstNear( inputs.thin_film_thickness, 0.5 ) === false ) { + + material.iridescenceThicknessNode = inputs.thin_film_thickness.mul( float( 1000 ) ); + + } + + if ( hasNodeValue( inputs.thin_film_ior ) && isConstNear( inputs.thin_film_ior, 1.4 ) === false ) { + + material.iridescenceIORNode = inputs.thin_film_ior; + + } + + } + + const emissionColor = hasNodeValue( inputs.emission_color ) ? inputs.emission_color : color( 1, 1, 1 ); + const emissionLuminance = hasNodeValue( inputs.emission_luminance ) ? inputs.emission_luminance : float( 0 ); + if ( isEffectivelyZero( emissionLuminance ) === false ) { + + material.emissiveNode = mul( emissionColor, emissionLuminance ); + + } + + if ( hasNodeValue( inputs.geometry_opacity ) && isEffectivelyOne( inputs.geometry_opacity ) === false ) material.transparent = true; + if ( transmissionEnabled ) material.transparent = true; + + setTransmissionFlags( material, inputs.transmission_weight, inputs.geometry_opacity ); + warnIgnoredInputs( inputs, mappedOpenPbrInputs, issueCollector, 'open_pbr_surface', nodeName ); + +} + +const MaterialXSurfaceMappings = { + standard_surface: applyStandardSurface, + gltf_pbr: applyGltfPbrSurface, + open_pbr_surface: applyOpenPbrSurface, +}; + +export { + MaterialXSurfaceMappings, + applyStandardSurface, + applyGltfPbrSurface, + applyOpenPbrSurface, + mappedStandardSurfaceInputs, + mappedGltfPbrInputs, + mappedOpenPbrInputs, +}; diff --git a/examples/jsm/loaders/materialx/MaterialXSurfaceRegistry.js b/examples/jsm/loaders/materialx/MaterialXSurfaceRegistry.js new file mode 100644 index 00000000000000..0235e5045e0ce3 --- /dev/null +++ b/examples/jsm/loaders/materialx/MaterialXSurfaceRegistry.js @@ -0,0 +1,43 @@ +import { + applyStandardSurface, + applyGltfPbrSurface, + applyOpenPbrSurface, + mappedStandardSurfaceInputs, + mappedGltfPbrInputs, + mappedOpenPbrInputs, +} from './MaterialXSurfaceMappings.js'; +import { toRegistryMap } from './MaterialXTranslatorTypes.js'; + +const surfaceMapperSpecs = [ + { + category: 'standard_surface', + mappedInputs: mappedStandardSurfaceInputs, + apply: applyStandardSurface, + }, + { + category: 'gltf_pbr', + mappedInputs: mappedGltfPbrInputs, + apply: applyGltfPbrSurface, + }, + { + category: 'open_pbr_surface', + mappedInputs: mappedOpenPbrInputs, + apply: applyOpenPbrSurface, + }, +]; + +const surfaceMapperRegistry = toRegistryMap( surfaceMapperSpecs, ( entry ) => entry.category, 'surface' ); + +function getSurfaceMapper( category ) { + + return surfaceMapperRegistry.get( category ); + +} + +function getSupportedSurfaceCategories() { + + return [ ...surfaceMapperRegistry.keys() ]; + +} + +export { surfaceMapperSpecs, surfaceMapperRegistry, getSurfaceMapper, getSupportedSurfaceCategories }; diff --git a/examples/jsm/loaders/materialx/MaterialXTranslatorTypes.js b/examples/jsm/loaders/materialx/MaterialXTranslatorTypes.js new file mode 100644 index 00000000000000..0e43bfea47e9de --- /dev/null +++ b/examples/jsm/loaders/materialx/MaterialXTranslatorTypes.js @@ -0,0 +1,57 @@ +/** + * @typedef {Object} MaterialXPortSpec + * @property {string} name + * @property {string | undefined} [type] + */ + +/** + * @typedef {Object} MaterialXNodeSpec + * @property {string} category + * @property {string | undefined} [nodeDefName] + * @property {string | undefined} [type] + * @property {MaterialXPortSpec[]} inputs + * @property {MaterialXPortSpec[]} outputs + * @property {MaterialXPortSpec[]} parameters + */ + +/** + * @typedef {Object} MaterialXSurfaceMapperSpec + * @property {string} category + * @property {string[]} mappedInputs + * @property {(material: unknown, inputs: Record, issueCollector: unknown, nodeName: string | null) => void} apply + */ + +/** + * @typedef {Object} MaterialXCompileHandlerSpec + * @property {string} category + * @property {(nodeX: unknown, compileContext: unknown) => unknown} compile + */ + +/** + * @template T + * @param {readonly T[]} entries + * @param {(entry: T) => string} keySelector + * @param {string} label + * @returns {Map} + */ +function toRegistryMap( entries, keySelector, label ) { + + const map = new Map(); + for ( const entry of entries ) { + + const key = keySelector( entry ); + if ( map.has( key ) ) { + + throw new Error( `Duplicate ${label} registry key "${key}".` ); + + } + + map.set( key, entry ); + + } + + return map; + +} + +export { toRegistryMap }; diff --git a/examples/jsm/loaders/materialx/MaterialXUtils.js b/examples/jsm/loaders/materialx/MaterialXUtils.js new file mode 100644 index 00000000000000..f47f87315d7f6c --- /dev/null +++ b/examples/jsm/loaders/materialx/MaterialXUtils.js @@ -0,0 +1,12 @@ +function normalizeSpaceName( value, fallback = 'world' ) { + + if ( typeof value !== 'string' ) return fallback; + const normalized = value.trim().toLowerCase(); + if ( normalized === '' ) return fallback; + if ( normalized === 'world' ) return 'world'; + if ( normalized === 'object' || normalized === 'model' ) return 'object'; + return fallback; + +} + +export { normalizeSpaceName }; diff --git a/examples/jsm/loaders/materialx/MaterialXWarnings.js b/examples/jsm/loaders/materialx/MaterialXWarnings.js new file mode 100644 index 00000000000000..784f67f1acdc40 --- /dev/null +++ b/examples/jsm/loaders/materialx/MaterialXWarnings.js @@ -0,0 +1,205 @@ +const ISSUE_CODES = { + UNSUPPORTED_NODE: 'unsupported-node', + IGNORED_SURFACE_INPUT: 'ignored-surface-input', + MISSING_REFERENCE: 'missing-reference', + MISSING_MATERIAL: 'missing-material', + INVALID_VALUE: 'invalid-value', +}; + +const ISSUE_POLICIES = { + WARN: 'warn', + ERROR_CORE: 'error-core', + ERROR_ALL: 'error-all', +}; + +const LEGACY_POLICY_ALIASES = { + error: ISSUE_POLICIES.ERROR_CORE, +}; + +function normalizeIssuePolicy( policy ) { + + const normalized = policy && typeof policy === 'string' ? policy : ISSUE_POLICIES.WARN; + if ( normalized in LEGACY_POLICY_ALIASES ) { + + return LEGACY_POLICY_ALIASES[ normalized ]; + + } + + if ( normalized === ISSUE_POLICIES.WARN || normalized === ISSUE_POLICIES.ERROR_CORE || normalized === ISSUE_POLICIES.ERROR_ALL ) { + + return normalized; + + } + + return ISSUE_POLICIES.WARN; + +} + +class MaterialXIssueCollector { + + constructor( options = {} ) { + + this.issuePolicy = normalizeIssuePolicy( options.issuePolicy || options.unsupportedPolicy ); + this.onWarning = options.onWarning || null; + this.issues = []; + + } + + addIssue( issue ) { + + const normalizedIssue = { + code: issue.code || ISSUE_CODES.INVALID_VALUE, + message: issue.message || 'Unknown MaterialX issue.', + category: issue.category, + nodeName: issue.nodeName, + severity: issue.severity || 'warning', + }; + + this.issues.push( normalizedIssue ); + + if ( normalizedIssue.severity === 'warning' ) { + + if ( this.issuePolicy === ISSUE_POLICIES.WARN ) { + + console.warn( `THREE.MaterialXLoader: ${normalizedIssue.message}` ); + + } + + if ( this.onWarning ) { + + this.onWarning( normalizedIssue ); + + } + + } + + } + + addUnsupportedNode( category, nodeName ) { + + this.addIssue( { + code: ISSUE_CODES.UNSUPPORTED_NODE, + category, + nodeName, + message: `Unsupported MaterialX node category "${category}"${nodeName ? ` on "${nodeName}"` : ''}.`, + } ); + + } + + addIgnoredSurfaceInput( category, nodeName, inputName ) { + + this.addIssue( { + code: ISSUE_CODES.IGNORED_SURFACE_INPUT, + category, + nodeName, + message: `${category} input "${inputName}" is currently ignored in MaterialX translation.`, + } ); + + } + + addMissingReference( nodeName, referencePath ) { + + this.addIssue( { + code: ISSUE_CODES.MISSING_REFERENCE, + nodeName, + message: `Missing MaterialX reference "${referencePath}"${nodeName ? ` from "${nodeName}"` : ''}.`, + } ); + + } + + addInvalidValue( nodeName, message ) { + + this.addIssue( { + code: ISSUE_CODES.INVALID_VALUE, + nodeName, + message, + } ); + + } + + addMissingMaterial( materialName ) { + + this.addIssue( { + code: ISSUE_CODES.MISSING_MATERIAL, + message: materialName + ? `Could not find surfacematerial named "${materialName}".` + : 'Document does not include a surfacematerial node.', + } ); + + } + + buildReport() { + + const ignoredSurfaceInputs = this.issues.filter( ( issue ) => issue.code === ISSUE_CODES.IGNORED_SURFACE_INPUT ); + const missingReferences = this.issues.filter( ( issue ) => issue.code === ISSUE_CODES.MISSING_REFERENCE ); + const invalidValues = this.issues.filter( ( issue ) => issue.code === ISSUE_CODES.INVALID_VALUE ); + + return { + issues: this.issues, + warnings: this.issues, + ignoredSurfaceInputs, + missingReferences, + invalidValues, + }; + + } + + throwIfNeeded() { + + if ( this.issuePolicy === ISSUE_POLICIES.WARN ) return; + + const coreCodes = new Set( [ ISSUE_CODES.UNSUPPORTED_NODE, ISSUE_CODES.MISSING_REFERENCE, ISSUE_CODES.INVALID_VALUE ] ); + const fatalIssues = this.issues.filter( ( issue ) => + this.issuePolicy === ISSUE_POLICIES.ERROR_ALL ? true : coreCodes.has( issue.code ) ); + if ( fatalIssues.length === 0 ) return; + + const detailsByCode = new Map(); + for ( const issue of fatalIssues ) { + + const count = detailsByCode.get( issue.code ) || 0; + detailsByCode.set( issue.code, count + 1 ); + + } + + const details = []; + const unsupportedNodes = this.issues.filter( ( issue ) => issue.code === ISSUE_CODES.UNSUPPORTED_NODE ); + if ( detailsByCode.has( ISSUE_CODES.UNSUPPORTED_NODE ) ) { + + const categoryList = [ ...new Set( unsupportedNodes.map( ( issue ) => issue.category ).filter( Boolean ) ) ].sort().join( ', ' ); + details.push( `unsupported node categories${categoryList ? `: ${categoryList}` : ''} (${detailsByCode.get( ISSUE_CODES.UNSUPPORTED_NODE )})` ); + + } + + if ( detailsByCode.has( ISSUE_CODES.MISSING_REFERENCE ) ) { + + details.push( `missing references (${detailsByCode.get( ISSUE_CODES.MISSING_REFERENCE )})` ); + + } + + if ( detailsByCode.has( ISSUE_CODES.INVALID_VALUE ) ) { + + details.push( `invalid values (${detailsByCode.get( ISSUE_CODES.INVALID_VALUE )})` ); + + } + + if ( detailsByCode.has( ISSUE_CODES.IGNORED_SURFACE_INPUT ) ) { + + details.push( `ignored surface inputs (${detailsByCode.get( ISSUE_CODES.IGNORED_SURFACE_INPUT )})` ); + + } + + if ( detailsByCode.has( ISSUE_CODES.MISSING_MATERIAL ) ) { + + details.push( `missing materials (${detailsByCode.get( ISSUE_CODES.MISSING_MATERIAL )})` ); + + } + + throw new Error( + `THREE.MaterialXLoader: MaterialX translation failed in ${this.issuePolicy} mode; ${details.join( '; ' )}.`, + ); + + } + +} + +export { ISSUE_CODES, ISSUE_POLICIES, MaterialXIssueCollector, normalizeIssuePolicy }; diff --git a/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js b/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js new file mode 100644 index 00000000000000..c42aa3f8b7c509 --- /dev/null +++ b/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js @@ -0,0 +1,443 @@ +import { + element, + float, + mat3, + mat4, + mul, + normalMap, + texture, + uv, + vec2, + vec3, + vec4, + vertexColor, + positionLocal, + positionWorld, + normalLocal, + normalWorld, + tangentLocal, + tangentWorld, + clamp, + add, + sub, + mix, + dot, + normalize, + mx_atan2, +} from 'three/tsl'; +import { normalizeSpaceName } from '../MaterialXUtils.js'; + +const register = ( registry, categories, handler ) => { + + for ( const category of categories ) { + + registry.set( category, handler ); + + } + +}; + +const UV_FALLBACK_CATEGORIES = new Set( [ 'noise2d', 'cellnoise2d', 'worleynoise2d', 'unifiednoise2d' ] ); + +const compileConvertNode = ( nodeX ) => { + + const nodeClass = nodeX.getClassFromType( nodeX.type ) || float; + return nodeClass( nodeX.getNodeByName( 'in' ) ); + +}; + +const compileConstantNode = ( nodeX ) => nodeX.getNodeByName( 'value' ); + +const compileSpaceInputNode = ( nodeX, objectNode, worldNode ) => { + + const rawSpace = nodeX.getInputValueByName( 'space' ) ?? nodeX.getAttribute( 'space' ); + const space = normalizeSpaceName( rawSpace, 'object' ); + return space === 'world' ? worldNode : objectNode; + +}; + +const compileTexcoordNode = ( nodeX, compileContext ) => { + + const indexNode = nodeX.getChildByName( 'index' ); + const index = indexNode ? parseInt( indexNode.value, 10 ) : 0; + return compileContext.mxToUvSpace( uv( index ) ); + +}; + +const compileGeomColorNode = ( nodeX ) => { + + const indexNode = nodeX.getChildByName( 'index' ); + const index = indexNode ? parseInt( indexNode.value, 10 ) : 0; + return vertexColor( index ); + +}; + +const compileImageLikeNode = ( nodeX, compileContext ) => { + + const file = nodeX.getChildByName( 'file' ); + const uvNode = nodeX.getNodeByName( 'texcoord' ) || compileContext.mxToUvSpace( uv( 0 ) ); + const textureFile = file ? file.getTexture() : null; + let node = textureFile ? texture( textureFile, compileContext.mxFromUvSpace( uvNode ) ) : vec4( 0, 0, 0, 1 ); + const colorSpaceNode = file ? file.getColorSpaceNode() : null; + if ( colorSpaceNode ) { + + node = colorSpaceNode( node ); + + } + + return node; + +}; + +const compileTiledImageNode = ( nodeX, compileContext ) => { + + const file = nodeX.getChildByName( 'file' ); + const textureFile = file ? file.getTexture() : null; + if ( ! textureFile ) { + + return vec4( 0, 0, 0, 1 ); + + } + + const uvNode = nodeX.getNodeByName( 'texcoord' ) || compileContext.mxToUvSpace( uv( 0 ) ); + const uvTiling = nodeX.getNodeByName( 'uvtiling' ); + const uvOffset = nodeX.getNodeByName( 'uvoffset' ); + const transformedUv = compileContext.mxTransformUv( uvTiling, uvOffset, uvNode ); + let node = texture( textureFile, compileContext.mxFromUvSpace( transformedUv ) ); + const colorSpaceNode = file.getColorSpaceNode(); + if ( colorSpaceNode ) { + + node = colorSpaceNode( node ); + + } + + return node; + +}; + +const compileHexTiledTextureNode = ( nodeX, compileContext, category ) => { + + const file = nodeX.getChildByName( 'file' ); + if ( ! file ) { + + nodeX.materialX.issueCollector.addInvalidValue( + nodeX.name, + `Texture node "${nodeX.name || nodeX.element}" is missing required input "file".`, + ); + return vec4( 0, 0, 0, 1 ); + + } + + const textureFile = file.getTexture(); + const uvNode = nodeX.getNodeByName( 'texcoord' ) || compileContext.mxToUvSpace( uv( 0 ) ); + const tiling = nodeX.getNodeByName( 'tiling' ) || vec2( 1, 1 ); + const rotation = nodeX.getNodeByName( 'rotation' ) || float( 1 ); + const rotationRange = nodeX.getNodeByName( 'rotationrange' ) || vec2( 0, 360 ); + const scale = nodeX.getNodeByName( 'scale' ) || float( 1 ); + const scaleRange = nodeX.getNodeByName( 'scalerange' ) || vec2( 0.5, 2 ); + const offset = nodeX.getNodeByName( 'offset' ) || float( 1 ); + const offsetRange = nodeX.getNodeByName( 'offsetrange' ) || vec2( 0, 1 ); + const falloff = nodeX.getNodeByName( 'falloff' ) || float( 0.5 ); + const falloffContrast = nodeX.getNodeByName( 'falloffcontrast' ) || float( 0.5 ); + const lumaCoeffs = nodeX.getNodeByName( 'lumacoeffs' ) || vec3( 0.2722287, 0.6740818, 0.0536895 ); + const transformedUv = mul( uvNode, tiling ); + const tileData = compileContext.mxHextileCoord( transformedUv, rotation, rotationRange, scale, scaleRange, offset, offsetRange ); + + const invertY = ( v ) => vec2( element( v, 0 ), mul( element( v, 1 ), - 1 ) ); + let sample0 = texture( textureFile, compileContext.mxFromUvSpace( tileData.coords[ 0 ] ) ).grad( + invertY( tileData.ddx[ 0 ] ), + invertY( tileData.ddy[ 0 ] ), + ); + let sample1 = texture( textureFile, compileContext.mxFromUvSpace( tileData.coords[ 1 ] ) ).grad( + invertY( tileData.ddx[ 1 ] ), + invertY( tileData.ddy[ 1 ] ), + ); + let sample2 = texture( textureFile, compileContext.mxFromUvSpace( tileData.coords[ 2 ] ) ).grad( + invertY( tileData.ddx[ 2 ] ), + invertY( tileData.ddy[ 2 ] ), + ); + const sample0Raw = sample0; + const sample1Raw = sample1; + const sample2Raw = sample2; + + const colorSpaceNode = file.getColorSpaceNode(); + if ( colorSpaceNode ) { + + sample0 = colorSpaceNode( sample0 ); + sample1 = colorSpaceNode( sample1 ); + sample2 = colorSpaceNode( sample2 ); + + } + + const c0 = vec3( element( sample0, 0 ), element( sample0, 1 ), element( sample0, 2 ) ); + const c1 = vec3( element( sample1, 0 ), element( sample1, 1 ), element( sample1, 2 ) ); + const c2 = vec3( element( sample2, 0 ), element( sample2, 1 ), element( sample2, 2 ) ); + const cw = mix( + vec3( 1, 1, 1 ), + vec3( dot( c0, lumaCoeffs ), dot( c1, lumaCoeffs ), dot( c2, lumaCoeffs ) ), + vec3( falloffContrast, falloffContrast, falloffContrast ), + ); + const blendWeights = compileContext.mxHextileComputeBlendWeights( cw, tileData.weights, falloff ); + const alphaWeights = compileContext.mxHextileComputeBlendWeights( vec3( 1, 1, 1 ), tileData.weights, falloff ); + const blendedRgb = add( add( mul( element( blendWeights, 0 ), c0 ), mul( element( blendWeights, 1 ), c1 ) ), mul( element( blendWeights, 2 ), c2 ) ); + const blendedAlpha = add( + add( mul( element( alphaWeights, 0 ), element( sample0Raw, 3 ) ), mul( element( alphaWeights, 1 ), element( sample1Raw, 3 ) ) ), + mul( element( alphaWeights, 2 ), element( sample2Raw, 3 ) ), + ); + const blended = vec4( blendedRgb, blendedAlpha ); + + if ( category === 'hextilednormalmap' ) { + + const normalScale = nodeX.getNodeByName( 'scale' ) || float( 1 ); + return normalMap( blended, normalScale ); + + } + + return blended; + +}; + +const compileGltfTextureNode = ( nodeX, compileContext, category ) => { + + const file = nodeX.getChildByName( 'file' ); + const uvNode = nodeX.getNodeByName( 'texcoord' ) || compileContext.mxToUvSpace( uv( 0 ) ); + const textureFile = file ? file.getTexture() : null; + let node = textureFile ? texture( textureFile, compileContext.mxFromUvSpace( uvNode ) ) : float( 0 ); + + const colorSpaceNode = file ? file.getColorSpaceNode() : null; + if ( colorSpaceNode ) { + + node = colorSpaceNode( node ); + + } + + if ( category === 'gltf_normalmap' ) { + + const normalScale = nodeX.getNodeByName( 'scale' ) || float( 1 ); + return normalMap( node, normalScale ); + + } + + return node; + +}; + +const compileGltfColorImageNode = ( nodeX, out, compileContext ) => { + + const file = nodeX.getChildByName( 'file' ); + const uvNode = nodeX.getNodeByName( 'texcoord' ) || compileContext.mxToUvSpace( uv( 0 ) ); + const textureFile = file ? file.getTexture() : null; + const sampled = textureFile ? texture( textureFile, compileContext.mxFromUvSpace( uvNode ) ) : vec4( 0, 0, 0, 1 ); + + if ( out === 'outa' || out === 'a' ) { + + return element( sampled, 3 ); + + } + + const colorSpaceNode = file ? file.getColorSpaceNode() : null; + if ( colorSpaceNode ) { + + const converted = colorSpaceNode( sampled ); + return vec3( element( converted, 0 ), element( converted, 1 ), element( converted, 2 ) ); + + } + + return vec3( element( sampled, 0 ), element( sampled, 1 ), element( sampled, 2 ) ); + +}; + +const compileGltfAnisotropyImageNode = ( nodeX, out, compileContext ) => { + + const file = nodeX.getChildByName( 'file' ); + const uvNode = nodeX.getNodeByName( 'texcoord' ) || compileContext.mxToUvSpace( uv( 0 ) ); + const defaultInput = nodeX.getNodeByName( 'default' ) || vec3( 1, 0.5, 1 ); + const textureFile = file ? file.getTexture() : null; + const sampled = textureFile + ? texture( textureFile, compileContext.mxFromUvSpace( uvNode ) ) + : vec4( element( defaultInput, 0 ), element( defaultInput, 1 ), element( defaultInput, 2 ), 1 ); + const anisotropyStrengthFactor = nodeX.getNodeByName( 'anisotropy_strength' ) || float( 1 ); + const anisotropyRotationFactor = nodeX.getNodeByName( 'anisotropy_rotation' ) || float( 0 ); + const encodedDirection = vec2( sub( mul( element( sampled, 0 ), 2 ), 1 ), sub( mul( element( sampled, 1 ), 2 ), 1 ) ); + const textureRotation = mx_atan2( element( encodedDirection, 1 ), element( encodedDirection, 0 ) ); + const anisotropyStrengthOut = clamp( mul( anisotropyStrengthFactor, element( sampled, 2 ) ), 0, 1 ); + const anisotropyRotationOut = add( anisotropyRotationFactor, textureRotation ); + + if ( out === 'anisotropy_rotation_out' ) { + + return anisotropyRotationOut; + + } + + return anisotropyStrengthOut; + +}; + +const compileGltfIridescenceThicknessNode = ( nodeX, compileContext ) => { + + const file = nodeX.getChildByName( 'file' ); + const uvNode = nodeX.getNodeByName( 'texcoord' ) || compileContext.mxToUvSpace( uv( 0 ) ); + const textureFile = file ? file.getTexture() : null; + const sampled = textureFile ? texture( textureFile, compileContext.mxFromUvSpace( uvNode ) ) : vec4( 0, 0, 0, 1 ); + const sampledThickness = element( sampled, 0 ); + const thicknessMin = nodeX.getNodeByName( 'thicknessMin' ) || float( 100 ); + const thicknessMax = nodeX.getNodeByName( 'thicknessMax' ) || float( 400 ); + return add( thicknessMin, mul( sampledThickness, sub( thicknessMax, thicknessMin ) ) ); + +}; + +const compileTransformMatrixNode = ( nodeX, compileContext ) => { + + const nodeDefName = nodeX.getAttribute( 'nodedef' ); + const inNode = nodeX.getNodeByName( 'in' ) || float( 0 ); + const matrixNode = + nodeX.getNodeByName( 'mat' ) || + ( nodeDefName === 'ND_transformmatrix_vector2M3' || nodeDefName === 'ND_transformmatrix_vector3' + ? mat3( ...compileContext.IDENTITY_MAT3_VALUES ) + : mat4( ...compileContext.IDENTITY_MAT4_VALUES ) ); + + if ( nodeDefName === 'ND_transformmatrix_vector2M3' ) { + + const transformed = mul( matrixNode, vec3( element( inNode, 0 ), element( inNode, 1 ), 1 ) ); + return vec2( element( transformed, 0 ), element( transformed, 1 ) ); + + } + + if ( nodeDefName === 'ND_transformmatrix_vector3' ) { + + return mul( matrixNode, vec3( element( inNode, 0 ), element( inNode, 1 ), element( inNode, 2 ) ) ); + + } + + if ( nodeDefName === 'ND_transformmatrix_vector3M4' ) { + + const transformed = mul( matrixNode, vec4( element( inNode, 0 ), element( inNode, 1 ), element( inNode, 2 ), 1 ) ); + return vec3( element( transformed, 0 ), element( transformed, 1 ), element( transformed, 2 ) ); + + } + + return mul( matrixNode, vec4( element( inNode, 0 ), element( inNode, 1 ), element( inNode, 2 ), element( inNode, 3 ) ) ); + +}; + +const compileInvertMatrixNode = ( nodeX, compileContext ) => { + + const inInput = nodeX.getChildByName( 'in' ); + const matrixType = inInput ? inInput.type : null; + const isMatrixType = matrixType === 'matrix33' || matrixType === 'matrix44'; + + if ( inInput && inInput.isConst && isMatrixType ) { + + const size = matrixType === 'matrix33' ? 3 : 4; + const identityValues = size === 3 ? compileContext.IDENTITY_MAT3_VALUES : compileContext.IDENTITY_MAT4_VALUES; + const matrixValues = inInput.getVector(); + const invertedValues = compileContext.invertConstantMatrixValues( matrixValues, size ); + + if ( invertedValues === null ) { + + nodeX.materialX.issueCollector.addInvalidValue( + nodeX.name, + `Matrix input for "${nodeX.name || nodeX.element}" is singular; using identity fallback.`, + ); + return size === 3 ? mat3( ...identityValues ) : mat4( ...identityValues ); + + } + + return size === 3 ? mat3( ...invertedValues ) : mat4( ...invertedValues ); + + } + + const inNode = nodeX.getNodeByName( 'in' ); + if ( isMatrixType ) { + + const size = matrixType === 'matrix33' ? 3 : 4; + const fallback = size === 3 ? mat3( ...compileContext.IDENTITY_MAT3_VALUES ) : mat4( ...compileContext.IDENTITY_MAT4_VALUES ); + return compileContext.invertMatrixNode( inNode === undefined || inNode === null ? fallback : inNode, size ); + + } + + return inNode === undefined || inNode === null ? float( 0 ) : inNode; + +}; + +function createMaterialXCompileRegistry() { + + const registry = new Map(); + register( registry, [ 'convert' ], ( nodeX ) => compileConvertNode( nodeX ) ); + register( registry, [ 'constant' ], ( nodeX ) => compileConstantNode( nodeX ) ); + register( registry, [ 'position' ], ( nodeX ) => compileSpaceInputNode( nodeX, positionLocal, positionWorld ) ); + register( registry, [ 'normal' ], ( nodeX ) => normalize( compileSpaceInputNode( nodeX, normalLocal, normalWorld ) ) ); + register( registry, [ 'tangent' ], ( nodeX ) => compileSpaceInputNode( nodeX, tangentLocal, tangentWorld ) ); + register( registry, [ 'texcoord' ], ( nodeX, out, compileContext ) => compileTexcoordNode( nodeX, compileContext ) ); + register( registry, [ 'geomcolor' ], ( nodeX ) => compileGeomColorNode( nodeX ) ); + register( registry, [ 'tiledimage' ], ( nodeX, out, compileContext ) => compileTiledImageNode( nodeX, compileContext ) ); + register( registry, [ 'image' ], ( nodeX, out, compileContext ) => compileImageLikeNode( nodeX, compileContext ) ); + register( registry, [ 'hextiledimage', 'hextilednormalmap' ], ( nodeX, out, compileContext ) => + compileHexTiledTextureNode( nodeX, compileContext, nodeX.element ) ); + register( registry, [ 'gltf_image', 'gltf_normalmap' ], ( nodeX, out, compileContext ) => + compileGltfTextureNode( nodeX, compileContext, nodeX.element ) ); + register( registry, [ 'gltf_colorimage' ], ( nodeX, out, compileContext ) => compileGltfColorImageNode( nodeX, out, compileContext ) ); + register( registry, [ 'gltf_anisotropy_image' ], ( nodeX, out, compileContext ) => + compileGltfAnisotropyImageNode( nodeX, out, compileContext ) ); + register( registry, [ 'gltf_iridescence_thickness' ], ( nodeX, out, compileContext ) => + compileGltfIridescenceThicknessNode( nodeX, compileContext ) ); + register( registry, [ 'transformmatrix' ], ( nodeX, out, compileContext ) => compileTransformMatrixNode( nodeX, compileContext ) ); + register( registry, [ 'invertmatrix' ], ( nodeX, out, compileContext ) => compileInvertMatrixNode( nodeX, compileContext ) ); + return registry; + +} + +function compileNodeFromRegistry( nodeX, out, compileContext ) { + + const handler = compileContext.compileRegistry.get( nodeX.element ); + if ( handler ) { + + return handler( nodeX, out, compileContext ); + + } + + const nodeElement = compileContext.nodeLibrary[ nodeX.element ]; + if ( ! nodeElement ) { + + return undefined; + + } + + const args = nodeX.getNodesByNames( ...nodeElement.params ); + for ( let i = 0; i < nodeElement.params.length; i += 1 ) { + + if ( args[ i ] !== undefined && args[ i ] !== null ) { + + continue; + + } + + const paramName = nodeElement.params[ i ]; + if ( paramName === 'texcoord' && UV_FALLBACK_CATEGORIES.has( nodeX.element ) ) { + + args[ i ] = compileContext.mxToUvSpace( uv( 0 ) ); + continue; + + } + + const defaultValue = nodeElement.defaults ? nodeElement.defaults[ paramName ] : undefined; + if ( defaultValue !== undefined ) { + + args[ i ] = typeof defaultValue === 'function' ? defaultValue() : float( defaultValue ); + continue; + + } + + nodeX.materialX.issueCollector.addInvalidValue( + nodeX.name, + `Missing input "${paramName}" for node "${nodeX.name || nodeX.element}" (${nodeX.element}). Using fallback 0.`, + ); + args[ i ] = float( 0 ); + + } + + return nodeElement.nodeFunc( ...args ); + +} + +export { createMaterialXCompileRegistry, compileNodeFromRegistry }; diff --git a/examples/jsm/loaders/materialx/parse/MaterialXParser.js b/examples/jsm/loaders/materialx/parse/MaterialXParser.js new file mode 100644 index 00000000000000..e276a578144984 --- /dev/null +++ b/examples/jsm/loaders/materialx/parse/MaterialXParser.js @@ -0,0 +1,28 @@ +function parseMaterialXNodeTree( nodeXML, createNode, addNode, nodePath = '' ) { + + const materialXNode = createNode( nodeXML, nodePath ); + if ( materialXNode.nodePath ) { + + addNode( materialXNode ); + + } + + for ( const childNodeXML of nodeXML.children ) { + + const childMXNode = parseMaterialXNodeTree( childNodeXML, createNode, addNode, materialXNode.nodePath ); + materialXNode.add( childMXNode ); + + } + + return materialXNode; + +} + +function parseMaterialXText( text, createNode, addNode ) { + + const rootXML = new DOMParser().parseFromString( text, 'application/xml' ).documentElement; + return parseMaterialXNodeTree( rootXML, createNode, addNode ); + +} + +export { parseMaterialXNodeTree, parseMaterialXText }; diff --git a/src/Three.TSL.js b/src/Three.TSL.js index 48ef7125d7297b..b87115381bddd9 100644 --- a/src/Three.TSL.js +++ b/src/Three.TSL.js @@ -337,6 +337,7 @@ export const mx_aastep = TSL.mx_aastep; export const mx_add = TSL.mx_add; export const mx_atan2 = TSL.mx_atan2; export const mx_cell_noise_float = TSL.mx_cell_noise_float; +export const mx_cell_noise_vec3 = TSL.mx_cell_noise_vec3; export const mx_contrast = TSL.mx_contrast; export const mx_divide = TSL.mx_divide; export const mx_fractal_noise_float = TSL.mx_fractal_noise_float; @@ -365,6 +366,7 @@ export const mx_rotate2d = TSL.mx_rotate2d; export const mx_rotate3d = TSL.mx_rotate3d; export const mx_safepower = TSL.mx_safepower; export const mx_separate = TSL.mx_separate; +export const mx_smoothstep = TSL.mx_smoothstep; export const mx_splitlr = TSL.mx_splitlr; export const mx_splittb = TSL.mx_splittb; export const mx_srgb_texture_to_lin_rec709 = TSL.mx_srgb_texture_to_lin_rec709; @@ -374,6 +376,8 @@ export const mx_transform_uv = TSL.mx_transform_uv; export const mx_unifiednoise2d = TSL.mx_unifiednoise2d; export const mx_unifiednoise3d = TSL.mx_unifiednoise3d; export const mx_worley_noise_float = TSL.mx_worley_noise_float; +export const mx_worley_noise_float_2d = TSL.mx_worley_noise_float_2d; +export const mx_worley_noise_float_3d = TSL.mx_worley_noise_float_3d; export const mx_worley_noise_vec2 = TSL.mx_worley_noise_vec2; export const mx_worley_noise_vec3 = TSL.mx_worley_noise_vec3; export const negate = TSL.negate; diff --git a/src/nodes/materialx/MaterialXNodes.js b/src/nodes/materialx/MaterialXNodes.js index 19e6913071a9bd..7f75ad87aa4803 100644 --- a/src/nodes/materialx/MaterialXNodes.js +++ b/src/nodes/materialx/MaterialXNodes.js @@ -1,18 +1,20 @@ import { mx_perlin_noise_float, mx_perlin_noise_vec3, - mx_worley_noise_float as worley_noise_float, mx_worley_noise_vec2 as worley_noise_vec2, mx_worley_noise_vec3 as worley_noise_vec3, + mx_worley_noise_vec2 as worley_noise_vec2, mx_worley_noise_vec3 as worley_noise_vec3, mx_cell_noise_float as cell_noise_float, - mx_unifiednoise2d as unifiednoise2d, mx_unifiednoise3d as unifiednoise3d, mx_fractal_noise_float as fractal_noise_float, mx_fractal_noise_vec2 as fractal_noise_vec2, mx_fractal_noise_vec3 as fractal_noise_vec3, mx_fractal_noise_vec4 as fractal_noise_vec4 } from './lib/mx_noise.js'; import { mx_hsvtorgb, mx_rgbtohsv } from './lib/mx_hsv.js'; import { mx_srgb_texture_to_lin_rec709 } from './lib/mx_transform_color.js'; -import { float, vec2, vec3, vec4, int, add, sub, mul, div, mod, atan, mix, pow, smoothstep } from '../tsl/TSLBase.js'; +import { + float, vec2, vec3, vec4, int, uint, add, sub, mul, div, atan, mix, pow, smoothstep, + floor, abs, max, clamp, step, fract, sin, cos, dot, sqrt, normalize, If, Fn +} from '../tsl/TSLBase.js'; import { uv } from '../accessors/UV.js'; import { bumpMap } from '../display/BumpMapNode.js'; -import { rotate } from '../utils/RotateNode.js'; import { frameId, time } from '../utils/Timer.js'; +import { Loop } from '../utils/LoopNode.js'; export const mx_aastep = ( threshold, value ) => { @@ -71,10 +73,307 @@ export const mx_noise_vec4 = ( texcoord = uv(), amplitude = 1, pivot = 0 ) => { }; -export const mx_unifiednoise2d = ( noiseType, texcoord = uv(), freq = vec2( 1, 1 ), offset = vec2( 0, 0 ), jitter = 1, outmin = 0, outmax = 1, clampoutput = false, octaves = 1, lacunarity = 2, diminish = .5 ) => unifiednoise2d( noiseType, texcoord.convert( 'vec2|vec3' ), freq, offset, jitter, outmin, outmax, clampoutput, octaves, lacunarity, diminish ); -export const mx_unifiednoise3d = ( noiseType, texcoord = uv(), freq = vec2( 1, 1 ), offset = vec2( 0, 0 ), jitter = 1, outmin = 0, outmax = 1, clampoutput = false, octaves = 1, lacunarity = 2, diminish = .5 ) => unifiednoise3d( noiseType, texcoord.convert( 'vec2|vec3' ), freq, offset, jitter, outmin, outmax, clampoutput, octaves, lacunarity, diminish ); +const mx_rotl32 = ( x, k ) => x.shiftLeft( uint( k ) ).bitOr( x.shiftRight( uint( 32 - k ) ) ); + +const mx_bjmix = ( aInput, bInput, cInput ) => { + + const a = uint( aInput ).toVar(); + const b = uint( bInput ).toVar(); + const c = uint( cInput ).toVar(); + + a.subAssign( c ); + a.assign( a.bitXor( mx_rotl32( c, 4 ) ) ); + c.addAssign( b ); + b.subAssign( a ); + b.assign( b.bitXor( mx_rotl32( a, 6 ) ) ); + a.addAssign( c ); + c.subAssign( b ); + c.assign( c.bitXor( mx_rotl32( b, 8 ) ) ); + b.addAssign( a ); + a.subAssign( c ); + a.assign( a.bitXor( mx_rotl32( c, 16 ) ) ); + c.addAssign( b ); + b.subAssign( a ); + b.assign( b.bitXor( mx_rotl32( a, 19 ) ) ); + a.addAssign( c ); + c.subAssign( b ); + c.assign( c.bitXor( mx_rotl32( b, 4 ) ) ); + b.addAssign( a ); + + return [ a, b, c ]; + +}; + +const mx_bjfinal = ( aInput, bInput, cInput ) => { + + const a = uint( aInput ).toVar(); + const b = uint( bInput ).toVar(); + const c = uint( cInput ).toVar(); + + c.assign( c.bitXor( b ) ); + c.subAssign( mx_rotl32( b, 14 ) ); + a.assign( a.bitXor( c ) ); + a.subAssign( mx_rotl32( c, 11 ) ); + b.assign( b.bitXor( a ) ); + b.subAssign( mx_rotl32( a, 25 ) ); + c.assign( c.bitXor( b ) ); + c.subAssign( mx_rotl32( b, 16 ) ); + a.assign( a.bitXor( c ) ); + a.subAssign( mx_rotl32( c, 4 ) ); + b.assign( b.bitXor( a ) ); + b.subAssign( mx_rotl32( a, 14 ) ); + c.assign( c.bitXor( b ) ); + c.subAssign( mx_rotl32( b, 24 ) ); + + return c; + +}; + +const mx_bits_to_01 = ( bits ) => div( float( bits ), float( uint( 0xffffffff ) ) ); + +export const mx_cell_noise_vec3 = Fn( ( [ positionInput ] ) => { + + const position = vec3( positionInput ).toVar(); + const ix = int( floor( position.x ) ).toVar(); + const iy = int( floor( position.y ) ).toVar(); + const iz = int( floor( position.z ) ).toVar(); + const seed = uint( 0xdeadbeef + ( 4 << 2 ) + 13 ).toVar(); + const a = seed.toVar(); + const b = seed.toVar(); + const c = seed.toVar(); + a.addAssign( uint( ix ) ); + b.addAssign( uint( iy ) ); + c.addAssign( uint( iz ) ); + + const [ mixedA, mixedB, mixedC ] = mx_bjmix( a, b, c ); + const hash0 = mx_bjfinal( mixedA, mixedB, mixedC ); + const hash1 = mx_bjfinal( add( mixedA, uint( 1 ) ), mixedB, mixedC ); + const hash2 = mx_bjfinal( add( mixedA, uint( 2 ) ), mixedB, mixedC ); + + return vec3( + mx_bits_to_01( hash0 ), + mx_bits_to_01( hash1 ), + mx_bits_to_01( hash2 ) + ); + +} ); + +export const mx_smoothstep = ( inNode, low = 0, high = 1 ) => { + + const range = sub( high, low ); + const safeRange = max( abs( range ), float( 1e-6 ) ); + const t = clamp( div( sub( inNode, low ), safeRange ), float( 0 ), float( 1 ) ); + const hermite = mul( mul( t, t ), sub( float( 3 ), mul( float( 2 ), t ) ) ); + const fallback = step( high, inNode ); + const useFallback = step( high, low ); + return mix( hermite, fallback, useFallback ); + +}; + +export const mx_worley_noise_float_3d = Fn( ( [ positionInput, jitterInput, styleInput ] ) => { + + const position = vec3( positionInput ).toVar(); + const jitter = float( jitterInput ).toVar(); + const style = int( styleInput ).toVar(); + const baseCell = vec3( floor( position.x ), floor( position.y ), floor( position.z ) ).toVar(); + const localpos = fract( position ).toVar(); + const sqdist = float( 1e6 ).toVar(); + const minpos = vec3( 0, 0, 0 ).toVar(); + + Loop( { start: - 1, end: int( 1 ), name: 'x', condition: '<=' }, ( { x } ) => { + + Loop( { start: - 1, end: int( 1 ), name: 'y', condition: '<=' }, ( { y } ) => { + + Loop( { start: - 1, end: int( 1 ), name: 'z', condition: '<=' }, ( { z } ) => { + + const cellCoords = vec3( baseCell.x.add( float( x ) ), baseCell.y.add( float( y ) ), baseCell.z.add( float( z ) ) ).toVar(); + const off = vec3( mx_cell_noise_vec3( cellCoords ) ).toVar(); + off.subAssign( 0.5 ); + off.mulAssign( jitter ); + off.addAssign( 0.5 ); + const cellpos = vec3( vec3( float( x ), float( y ), float( z ) ).add( off ).sub( localpos ) ).toVar(); + const dist = dot( cellpos, cellpos ).toVar(); + + If( dist.lessThan( sqdist ), () => { + + sqdist.assign( dist ); + minpos.assign( cellpos ); + + } ); + + } ); + + } ); + + } ); + + If( style.equal( int( 1 ) ), () => { + + sqdist.assign( mx_cell_noise_float( minpos.add( position ) ) ); + + } ).Else( () => { + + sqdist.assign( sqrt( sqdist ) ); + + } ); + + return sqdist; + +} ); + +export const mx_worley_noise_float_2d = Fn( ( [ texcoordInput, jitterInput, styleInput ] ) => { + + const texcoord = vec2( texcoordInput ).toVar(); + const jitter = float( jitterInput ).toVar(); + const style = int( styleInput ).toVar(); + const floorPos = floor( texcoord ).toVar(); + const localpos = vec2( fract( texcoord.x ), fract( texcoord.y ) ).toVar(); + const sqdist = float( 1e6 ).toVar(); + const minpos = vec2( 0, 0 ).toVar(); + + Loop( { start: - 1, end: int( 1 ), name: 'x', condition: '<=' }, ( { x } ) => { + + Loop( { start: - 1, end: int( 1 ), name: 'y', condition: '<=' }, ( { y } ) => { + + const cell = vec2( float( x ), float( y ) ).toVar(); + const seed = vec2( cell.x.add( floorPos.x ), cell.y.add( floorPos.y ) ).toVar(); + const off = vec2( mx_cell_noise_float( vec3( seed.x, seed.y, 0 ) ), mx_cell_noise_float( vec3( seed.x, seed.y, 1 ) ) ).toVar(); + off.subAssign( 0.5 ); + off.mulAssign( jitter ); + off.addAssign( 0.5 ); + const cellpos = vec2( cell.add( off ).sub( localpos ) ).toVar(); + const dist = dot( cellpos, cellpos ).toVar(); + + If( dist.lessThan( sqdist ), () => { -export const mx_worley_noise_float = ( texcoord = uv(), jitter = 1 ) => worley_noise_float( texcoord.convert( 'vec2|vec3' ), jitter, int( 1 ) ); + sqdist.assign( dist ); + minpos.assign( cellpos ); + + } ); + + } ); + + } ); + + If( style.equal( int( 1 ) ), () => { + + sqdist.assign( mx_cell_noise_float( minpos.add( texcoord ) ) ); + + } ).Else( () => { + + sqdist.assign( sqrt( sqdist ) ); + + } ); + + return sqdist; + +} ); + +export const mx_unifiednoise2d = Fn( ( [ + noiseTypeInput, + texcoordInput, + freqInput, + offsetInput, + jitterInput, + outminInput, + outmaxInput, + clampoutputInput, + octavesInput, + lacunarityInput, + diminishInput, + styleInput +] ) => { + + const noiseType = int( noiseTypeInput ).toVar(); + const texcoord = vec2( texcoordInput ).toVar(); + const freq = vec2( freqInput ).toVar(); + const offset = vec2( offsetInput ).toVar(); + const jitter = float( jitterInput ).toVar(); + const outmin = float( outminInput ).toVar(); + const outmax = float( outmaxInput ).toVar(); + const clampoutput = float( clampoutputInput ).toVar(); + const octaves = int( octavesInput ).toVar(); + const lacunarity = float( lacunarityInput ).toVar(); + const diminish = float( diminishInput ).toVar(); + const style = int( styleInput ).toVar(); + + const applyFreq = mul( texcoord, freq ).toVar(); + const applyOffset = add( applyFreq, offset ).toVar(); + const cellJitterMult = mul( sub( jitter, 1 ), 90000 ).toVar(); + const applyCellJitter = mx_rotate2d( applyOffset, cellJitterMult ).toVar(); + const fractalInput = vec3( applyOffset.x, applyOffset.y, cellJitterMult ).toVar(); + const result = float( 0 ).toVar(); + + If( noiseType.equal( int( 0 ) ), () => { + + result.assign( mx_noise_float( applyCellJitter, 0.5, 0.5 ) ); + + } ); + If( noiseType.equal( int( 1 ) ), () => { + + result.assign( mx_cell_noise_float( applyCellJitter ) ); + + } ); + If( noiseType.equal( int( 2 ) ), () => { + + result.assign( mx_worley_noise_float_2d( applyOffset, jitter, style ) ); + + } ); + If( noiseType.equal( int( 3 ) ), () => { + + result.assign( mx_fractal_noise_float( fractalInput, octaves, lacunarity, diminish, 1 ) ); + + } ); + + const ranged = add( outmin, mul( result, sub( outmax, outmin ) ) ).toVar(); + const clamped = clamp( ranged, outmin, outmax ).toVar(); + return mx_ifequal( clampoutput, float( 1 ), clamped, ranged ); + +} ); + +export const mx_unifiednoise3d = ( + noiseType = 0, + position = vec3( 0, 0, 0 ), + freq = vec3( 1, 1, 1 ), + offset = vec3( 0, 0, 0 ), + jitter = 1, + outmin = 0, + outmax = 1, + clampoutput = true, + octaves = 3, + lacunarity = 2, + diminish = 0.5, + style = 0 +) => { + + const applyFreq = mul( position, freq ); + const applyOffset = add( applyFreq, offset ); + const cellJitterMult = mul( sub( jitter, 1 ), 90000 ); + const applyCellJitter = mx_rotate3d( applyOffset, cellJitterMult, vec3( 0.1, 1, 0 ) ); + const perlin = mx_noise_float( applyCellJitter, 0.5, 0.5 ); + const cell = mx_cell_noise_float( applyCellJitter ); + const worley = mx_worley_noise_float_3d( applyOffset, jitter, style ); + const fractal = mx_fractal_noise_float( applyCellJitter, octaves, lacunarity, diminish, 1 ); + + const typeFloat = float( noiseType ); + const switched = mx_ifequal( + typeFloat, + float( 3 ), + fractal, + mx_ifequal( + typeFloat, + float( 2 ), + worley, + mx_ifequal( typeFloat, float( 1 ), cell, perlin ) + ) + ); + const ranged = add( outmin, mul( switched, sub( outmax, outmin ) ) ); + const clamped = clamp( ranged, outmin, outmax ); + return mx_ifequal( clampoutput, float( 1 ), clamped, ranged ); + +}; + +export const mx_worley_noise_float = ( texcoord = uv(), jitter = 1, style = 0 ) => mx_worley_noise_float_3d( texcoord.convert( 'vec2|vec3' ), jitter, style ); export const mx_worley_noise_vec2 = ( texcoord = uv(), jitter = 1 ) => worley_noise_vec2( texcoord.convert( 'vec2|vec3' ), jitter, int( 1 ) ); export const mx_worley_noise_vec3 = ( texcoord = uv(), jitter = 1 ) => worley_noise_vec3( texcoord.convert( 'vec2|vec3' ), jitter, int( 1 ) ); @@ -94,15 +393,15 @@ export const mx_add = ( in1, in2 = float( 0 ) ) => add( in1, in2 ); export const mx_subtract = ( in1, in2 = float( 0 ) ) => sub( in1, in2 ); export const mx_multiply = ( in1, in2 = float( 1 ) ) => mul( in1, in2 ); export const mx_divide = ( in1, in2 = float( 1 ) ) => div( in1, in2 ); -export const mx_modulo = ( in1, in2 = float( 1 ) ) => mod( in1, in2 ); +export const mx_modulo = ( in1, in2 = float( 1 ) ) => sub( in1, mul( in2, floor( div( in1, in2 ) ) ) ); export const mx_power = ( in1, in2 = float( 1 ) ) => pow( in1, in2 ); export const mx_atan2 = ( in1 = float( 0 ), in2 = float( 1 ) ) => atan( in1, in2 ); export const mx_timer = () => time; export const mx_frame = () => frameId; export const mx_invert = ( in1, amount = float( 1 ) ) => sub( amount, in1 ); -export const mx_ifgreater = ( value1, value2, in1, in2 ) => value1.greaterThan( value2 ).mix( in1, in2 ); -export const mx_ifgreatereq = ( value1, value2, in1, in2 ) => value1.greaterThanEqual( value2 ).mix( in1, in2 ); -export const mx_ifequal = ( value1, value2, in1, in2 ) => value1.equal( value2 ).mix( in1, in2 ); +export const mx_ifgreater = ( value1, value2, in1, in2 ) => value1.greaterThan( value2 ).mix( in2, in1 ); +export const mx_ifgreatereq = ( value1, value2, in1, in2 ) => value1.greaterThanEqual( value2 ).mix( in2, in1 ); +export const mx_ifequal = ( value1, value2, in1, in2 ) => value1.equal( value2 ).mix( in2, in1 ); // Enhanced separate node to support multi-output referencing (outx, outy, outz, outw) export const mx_separate = ( in1, channelOrOut = null ) => { @@ -133,57 +432,70 @@ export const mx_separate = ( in1, channelOrOut = null ) => { }; export const mx_place2d = ( - texcoord, pivot = vec2( 0.5, 0.5 ), scale = vec2( 1, 1 ), rotate = float( 0 ), offset = vec2( 0, 0 )/*, operationorder = int( 0 )*/ + texcoord, pivot = vec2( 0, 0 ), scale = vec2( 1, 1 ), rotate = float( 0 ), offset = vec2( 0, 0 ), operationorder = int( 0 ) ) => { - let uv = texcoord; - if ( pivot ) uv = uv.sub( pivot ); - if ( scale ) uv = uv.mul( scale ); - if ( rotate ) { + const centered = sub( texcoord, pivot ); + const srt = add( sub( mx_rotate2d( div( centered, scale ), rotate ), offset ), pivot ); + const trs = add( div( mx_rotate2d( sub( centered, offset ), rotate ), scale ), pivot ); - const rad = rotate.mul( Math.PI / 180.0 ); - const cosR = rad.cos(); - const sinR = rad.sin(); - uv = vec2( - uv.x.mul( cosR ).sub( uv.y.mul( sinR ) ), - uv.x.mul( sinR ).add( uv.y.mul( cosR ) ) - ); + if ( typeof operationorder === 'number' ) return Math.abs( operationorder ) > Number.EPSILON ? trs : srt; - } - - if ( pivot ) uv = uv.add( pivot ); - if ( offset ) uv = uv.add( offset ); - return uv; + return mix( srt, trs, step( 0.5, float( operationorder ) ) ); }; -export const mx_rotate2d = ( input, amount ) => { +export const mx_rotate2d = ( input, amount = 0 ) => { input = vec2( input ); amount = float( amount ); - const radians = amount.mul( Math.PI / 180.0 ); - return rotate( input, radians ); + const rotationRadians = mul( amount, Math.PI / 180.0 ); + const sa = sin( rotationRadians ); + const ca = cos( rotationRadians ); + const x = input.x; + const y = input.y; + + return vec2( add( mul( ca, x ), mul( sa, y ) ), sub( mul( ca, y ), mul( sa, x ) ) ); }; -export const mx_rotate3d = ( input, amount, axis ) => { +export const mx_rotate3d = ( input, amount = 0, axis = vec3( 0, 1, 0 ) ) => { input = vec3( input ); amount = float( amount ); axis = vec3( axis ); - - const radians = amount.mul( Math.PI / 180.0 ); - const nAxis = axis.normalize(); - const cosA = radians.cos(); - const sinA = radians.sin(); - const oneMinusCosA = float( 1 ).sub( cosA ); - const rot = - input.mul( cosA ) - .add( nAxis.cross( input ).mul( sinA ) ) - .add( nAxis.mul( nAxis.dot( input ) ).mul( oneMinusCosA ) ); - return rot; + const normalizedAxis = normalize( axis ); + const rotationRadians = mul( amount, Math.PI / 180.0 ); + const s = sin( rotationRadians ); + const c = cos( rotationRadians ); + const oc = sub( 1, c ); + + const x = input.x; + const y = input.y; + const z = input.z; + const ax = normalizedAxis.x; + const ay = normalizedAxis.y; + const az = normalizedAxis.z; + + const m00 = add( mul( mul( oc, ax ), ax ), c ); + const m01 = sub( mul( mul( oc, ax ), ay ), mul( az, s ) ); + const m02 = add( mul( mul( oc, az ), ax ), mul( ay, s ) ); + + const m10 = add( mul( mul( oc, ax ), ay ), mul( az, s ) ); + const m11 = add( mul( mul( oc, ay ), ay ), c ); + const m12 = sub( mul( mul( oc, ay ), az ), mul( ax, s ) ); + + const m20 = sub( mul( mul( oc, az ), ax ), mul( ay, s ) ); + const m21 = add( mul( mul( oc, ay ), az ), mul( ax, s ) ); + const m22 = add( mul( mul( oc, az ), az ), c ); + + return vec3( + add( add( mul( m00, x ), mul( m10, y ) ), mul( m20, z ) ), + add( add( mul( m01, x ), mul( m11, y ) ), mul( m21, z ) ), + add( add( mul( m02, x ), mul( m12, y ) ), mul( m22, z ) ) + ); }; From 5c359eb5a355937c767d1ac8271eea357ac35e2e Mon Sep 17 00:00:00 2001 From: Ben Houston Date: Mon, 27 Apr 2026 14:30:16 -0400 Subject: [PATCH 02/40] add a new more examples (open_pbr_surface + gltf_pbr) --- .../glass_dispersion/glass_dispersion.mtlx | 17 ++++++++++++++++ .../open_pbr_surface/honey/honey.mtlx | 14 +++++++++++++ .../open_pbr_surface/pearl/pearl.mtlx | 20 +++++++++++++++++++ .../open_pbr_surface/velvet/velvet.mtlx | 13 ++++++++++++ examples/webgpu_loader_materialx.html | 4 ++++ 5 files changed, 68 insertions(+) create mode 100644 examples/materialx/showcase/gltf_pbr/glass_dispersion/glass_dispersion.mtlx create mode 100644 examples/materialx/showcase/open_pbr_surface/honey/honey.mtlx create mode 100644 examples/materialx/showcase/open_pbr_surface/pearl/pearl.mtlx create mode 100644 examples/materialx/showcase/open_pbr_surface/velvet/velvet.mtlx diff --git a/examples/materialx/showcase/gltf_pbr/glass_dispersion/glass_dispersion.mtlx b/examples/materialx/showcase/gltf_pbr/glass_dispersion/glass_dispersion.mtlx new file mode 100644 index 00000000000000..4a7725318d5bfa --- /dev/null +++ b/examples/materialx/showcase/gltf_pbr/glass_dispersion/glass_dispersion.mtlx @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/examples/materialx/showcase/open_pbr_surface/honey/honey.mtlx b/examples/materialx/showcase/open_pbr_surface/honey/honey.mtlx new file mode 100644 index 00000000000000..aff78bee1a0370 --- /dev/null +++ b/examples/materialx/showcase/open_pbr_surface/honey/honey.mtlx @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/examples/materialx/showcase/open_pbr_surface/pearl/pearl.mtlx b/examples/materialx/showcase/open_pbr_surface/pearl/pearl.mtlx new file mode 100644 index 00000000000000..cc716420cb3d73 --- /dev/null +++ b/examples/materialx/showcase/open_pbr_surface/pearl/pearl.mtlx @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/examples/materialx/showcase/open_pbr_surface/velvet/velvet.mtlx b/examples/materialx/showcase/open_pbr_surface/velvet/velvet.mtlx new file mode 100644 index 00000000000000..61c83e2ab273be --- /dev/null +++ b/examples/materialx/showcase/open_pbr_surface/velvet/velvet.mtlx @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/examples/webgpu_loader_materialx.html b/examples/webgpu_loader_materialx.html index 1f3dbd769accb3..78c98a46b8d23c 100644 --- a/examples/webgpu_loader_materialx.html +++ b/examples/webgpu_loader_materialx.html @@ -98,6 +98,10 @@ 'thin_film_rainbow_test.mtlx', 'thin_film_ior_clamp_test.mtlx', 'sheen_test.mtlx', + 'showcase/gltf_pbr/glass_dispersion/glass_dispersion.mtlx', + 'showcase/open_pbr_surface/velvet/velvet.mtlx', + 'showcase/open_pbr_surface/pearl/pearl.mtlx', + 'showcase/open_pbr_surface/honey/honey.mtlx', ]; let camera, scene, renderer; From afb22c9848e2710dda8211865481afdd0a97ee78 Mon Sep 17 00:00:00 2001 From: Ben Houston Date: Mon, 27 Apr 2026 14:47:05 -0400 Subject: [PATCH 03/40] conslidated fixed noise functions into mx_noise.js --- src/nodes/materialx/MaterialXNodes.js | 301 +--------------- src/nodes/materialx/lib/mx_noise.js | 475 +++++++++++++------------- 2 files changed, 243 insertions(+), 533 deletions(-) diff --git a/src/nodes/materialx/MaterialXNodes.js b/src/nodes/materialx/MaterialXNodes.js index 7f75ad87aa4803..3164e3d39a2510 100644 --- a/src/nodes/materialx/MaterialXNodes.js +++ b/src/nodes/materialx/MaterialXNodes.js @@ -1,20 +1,21 @@ import { mx_perlin_noise_float, mx_perlin_noise_vec3, + mx_worley_noise_float_2d as worley_noise_float_2d, mx_worley_noise_float_3d as worley_noise_float_3d, mx_worley_noise_vec2 as worley_noise_vec2, mx_worley_noise_vec3 as worley_noise_vec3, - mx_cell_noise_float as cell_noise_float, + mx_cell_noise_float as cell_noise_float, mx_cell_noise_vec3 as cell_noise_vec3, + mx_unifiednoise2d as unifiednoise2d, mx_unifiednoise3d as unifiednoise3d, mx_fractal_noise_float as fractal_noise_float, mx_fractal_noise_vec2 as fractal_noise_vec2, mx_fractal_noise_vec3 as fractal_noise_vec3, mx_fractal_noise_vec4 as fractal_noise_vec4 } from './lib/mx_noise.js'; import { mx_hsvtorgb, mx_rgbtohsv } from './lib/mx_hsv.js'; import { mx_srgb_texture_to_lin_rec709 } from './lib/mx_transform_color.js'; import { - float, vec2, vec3, vec4, int, uint, add, sub, mul, div, atan, mix, pow, smoothstep, - floor, abs, max, clamp, step, fract, sin, cos, dot, sqrt, normalize, If, Fn + float, vec2, vec3, vec4, int, add, sub, mul, div, atan, mix, pow, smoothstep, + floor, abs, max, clamp, step, sin, cos, normalize } from '../tsl/TSLBase.js'; import { uv } from '../accessors/UV.js'; import { bumpMap } from '../display/BumpMapNode.js'; import { frameId, time } from '../utils/Timer.js'; -import { Loop } from '../utils/LoopNode.js'; export const mx_aastep = ( threshold, value ) => { @@ -73,91 +74,6 @@ export const mx_noise_vec4 = ( texcoord = uv(), amplitude = 1, pivot = 0 ) => { }; -const mx_rotl32 = ( x, k ) => x.shiftLeft( uint( k ) ).bitOr( x.shiftRight( uint( 32 - k ) ) ); - -const mx_bjmix = ( aInput, bInput, cInput ) => { - - const a = uint( aInput ).toVar(); - const b = uint( bInput ).toVar(); - const c = uint( cInput ).toVar(); - - a.subAssign( c ); - a.assign( a.bitXor( mx_rotl32( c, 4 ) ) ); - c.addAssign( b ); - b.subAssign( a ); - b.assign( b.bitXor( mx_rotl32( a, 6 ) ) ); - a.addAssign( c ); - c.subAssign( b ); - c.assign( c.bitXor( mx_rotl32( b, 8 ) ) ); - b.addAssign( a ); - a.subAssign( c ); - a.assign( a.bitXor( mx_rotl32( c, 16 ) ) ); - c.addAssign( b ); - b.subAssign( a ); - b.assign( b.bitXor( mx_rotl32( a, 19 ) ) ); - a.addAssign( c ); - c.subAssign( b ); - c.assign( c.bitXor( mx_rotl32( b, 4 ) ) ); - b.addAssign( a ); - - return [ a, b, c ]; - -}; - -const mx_bjfinal = ( aInput, bInput, cInput ) => { - - const a = uint( aInput ).toVar(); - const b = uint( bInput ).toVar(); - const c = uint( cInput ).toVar(); - - c.assign( c.bitXor( b ) ); - c.subAssign( mx_rotl32( b, 14 ) ); - a.assign( a.bitXor( c ) ); - a.subAssign( mx_rotl32( c, 11 ) ); - b.assign( b.bitXor( a ) ); - b.subAssign( mx_rotl32( a, 25 ) ); - c.assign( c.bitXor( b ) ); - c.subAssign( mx_rotl32( b, 16 ) ); - a.assign( a.bitXor( c ) ); - a.subAssign( mx_rotl32( c, 4 ) ); - b.assign( b.bitXor( a ) ); - b.subAssign( mx_rotl32( a, 14 ) ); - c.assign( c.bitXor( b ) ); - c.subAssign( mx_rotl32( b, 24 ) ); - - return c; - -}; - -const mx_bits_to_01 = ( bits ) => div( float( bits ), float( uint( 0xffffffff ) ) ); - -export const mx_cell_noise_vec3 = Fn( ( [ positionInput ] ) => { - - const position = vec3( positionInput ).toVar(); - const ix = int( floor( position.x ) ).toVar(); - const iy = int( floor( position.y ) ).toVar(); - const iz = int( floor( position.z ) ).toVar(); - const seed = uint( 0xdeadbeef + ( 4 << 2 ) + 13 ).toVar(); - const a = seed.toVar(); - const b = seed.toVar(); - const c = seed.toVar(); - a.addAssign( uint( ix ) ); - b.addAssign( uint( iy ) ); - c.addAssign( uint( iz ) ); - - const [ mixedA, mixedB, mixedC ] = mx_bjmix( a, b, c ); - const hash0 = mx_bjfinal( mixedA, mixedB, mixedC ); - const hash1 = mx_bjfinal( add( mixedA, uint( 1 ) ), mixedB, mixedC ); - const hash2 = mx_bjfinal( add( mixedA, uint( 2 ) ), mixedB, mixedC ); - - return vec3( - mx_bits_to_01( hash0 ), - mx_bits_to_01( hash1 ), - mx_bits_to_01( hash2 ) - ); - -} ); - export const mx_smoothstep = ( inNode, low = 0, high = 1 ) => { const range = sub( high, low ); @@ -170,208 +86,11 @@ export const mx_smoothstep = ( inNode, low = 0, high = 1 ) => { }; -export const mx_worley_noise_float_3d = Fn( ( [ positionInput, jitterInput, styleInput ] ) => { - - const position = vec3( positionInput ).toVar(); - const jitter = float( jitterInput ).toVar(); - const style = int( styleInput ).toVar(); - const baseCell = vec3( floor( position.x ), floor( position.y ), floor( position.z ) ).toVar(); - const localpos = fract( position ).toVar(); - const sqdist = float( 1e6 ).toVar(); - const minpos = vec3( 0, 0, 0 ).toVar(); - - Loop( { start: - 1, end: int( 1 ), name: 'x', condition: '<=' }, ( { x } ) => { - - Loop( { start: - 1, end: int( 1 ), name: 'y', condition: '<=' }, ( { y } ) => { - - Loop( { start: - 1, end: int( 1 ), name: 'z', condition: '<=' }, ( { z } ) => { - - const cellCoords = vec3( baseCell.x.add( float( x ) ), baseCell.y.add( float( y ) ), baseCell.z.add( float( z ) ) ).toVar(); - const off = vec3( mx_cell_noise_vec3( cellCoords ) ).toVar(); - off.subAssign( 0.5 ); - off.mulAssign( jitter ); - off.addAssign( 0.5 ); - const cellpos = vec3( vec3( float( x ), float( y ), float( z ) ).add( off ).sub( localpos ) ).toVar(); - const dist = dot( cellpos, cellpos ).toVar(); - - If( dist.lessThan( sqdist ), () => { - - sqdist.assign( dist ); - minpos.assign( cellpos ); - - } ); - - } ); - - } ); - - } ); - - If( style.equal( int( 1 ) ), () => { - - sqdist.assign( mx_cell_noise_float( minpos.add( position ) ) ); - - } ).Else( () => { - - sqdist.assign( sqrt( sqdist ) ); - - } ); - - return sqdist; - -} ); - -export const mx_worley_noise_float_2d = Fn( ( [ texcoordInput, jitterInput, styleInput ] ) => { - - const texcoord = vec2( texcoordInput ).toVar(); - const jitter = float( jitterInput ).toVar(); - const style = int( styleInput ).toVar(); - const floorPos = floor( texcoord ).toVar(); - const localpos = vec2( fract( texcoord.x ), fract( texcoord.y ) ).toVar(); - const sqdist = float( 1e6 ).toVar(); - const minpos = vec2( 0, 0 ).toVar(); - - Loop( { start: - 1, end: int( 1 ), name: 'x', condition: '<=' }, ( { x } ) => { - - Loop( { start: - 1, end: int( 1 ), name: 'y', condition: '<=' }, ( { y } ) => { - - const cell = vec2( float( x ), float( y ) ).toVar(); - const seed = vec2( cell.x.add( floorPos.x ), cell.y.add( floorPos.y ) ).toVar(); - const off = vec2( mx_cell_noise_float( vec3( seed.x, seed.y, 0 ) ), mx_cell_noise_float( vec3( seed.x, seed.y, 1 ) ) ).toVar(); - off.subAssign( 0.5 ); - off.mulAssign( jitter ); - off.addAssign( 0.5 ); - const cellpos = vec2( cell.add( off ).sub( localpos ) ).toVar(); - const dist = dot( cellpos, cellpos ).toVar(); - - If( dist.lessThan( sqdist ), () => { - - sqdist.assign( dist ); - minpos.assign( cellpos ); - - } ); - - } ); - - } ); - - If( style.equal( int( 1 ) ), () => { - - sqdist.assign( mx_cell_noise_float( minpos.add( texcoord ) ) ); - - } ).Else( () => { - - sqdist.assign( sqrt( sqdist ) ); - - } ); - - return sqdist; - -} ); - -export const mx_unifiednoise2d = Fn( ( [ - noiseTypeInput, - texcoordInput, - freqInput, - offsetInput, - jitterInput, - outminInput, - outmaxInput, - clampoutputInput, - octavesInput, - lacunarityInput, - diminishInput, - styleInput -] ) => { - - const noiseType = int( noiseTypeInput ).toVar(); - const texcoord = vec2( texcoordInput ).toVar(); - const freq = vec2( freqInput ).toVar(); - const offset = vec2( offsetInput ).toVar(); - const jitter = float( jitterInput ).toVar(); - const outmin = float( outminInput ).toVar(); - const outmax = float( outmaxInput ).toVar(); - const clampoutput = float( clampoutputInput ).toVar(); - const octaves = int( octavesInput ).toVar(); - const lacunarity = float( lacunarityInput ).toVar(); - const diminish = float( diminishInput ).toVar(); - const style = int( styleInput ).toVar(); - - const applyFreq = mul( texcoord, freq ).toVar(); - const applyOffset = add( applyFreq, offset ).toVar(); - const cellJitterMult = mul( sub( jitter, 1 ), 90000 ).toVar(); - const applyCellJitter = mx_rotate2d( applyOffset, cellJitterMult ).toVar(); - const fractalInput = vec3( applyOffset.x, applyOffset.y, cellJitterMult ).toVar(); - const result = float( 0 ).toVar(); - - If( noiseType.equal( int( 0 ) ), () => { - - result.assign( mx_noise_float( applyCellJitter, 0.5, 0.5 ) ); - - } ); - If( noiseType.equal( int( 1 ) ), () => { - - result.assign( mx_cell_noise_float( applyCellJitter ) ); - - } ); - If( noiseType.equal( int( 2 ) ), () => { - - result.assign( mx_worley_noise_float_2d( applyOffset, jitter, style ) ); - - } ); - If( noiseType.equal( int( 3 ) ), () => { - - result.assign( mx_fractal_noise_float( fractalInput, octaves, lacunarity, diminish, 1 ) ); - - } ); - - const ranged = add( outmin, mul( result, sub( outmax, outmin ) ) ).toVar(); - const clamped = clamp( ranged, outmin, outmax ).toVar(); - return mx_ifequal( clampoutput, float( 1 ), clamped, ranged ); - -} ); - -export const mx_unifiednoise3d = ( - noiseType = 0, - position = vec3( 0, 0, 0 ), - freq = vec3( 1, 1, 1 ), - offset = vec3( 0, 0, 0 ), - jitter = 1, - outmin = 0, - outmax = 1, - clampoutput = true, - octaves = 3, - lacunarity = 2, - diminish = 0.5, - style = 0 -) => { - - const applyFreq = mul( position, freq ); - const applyOffset = add( applyFreq, offset ); - const cellJitterMult = mul( sub( jitter, 1 ), 90000 ); - const applyCellJitter = mx_rotate3d( applyOffset, cellJitterMult, vec3( 0.1, 1, 0 ) ); - const perlin = mx_noise_float( applyCellJitter, 0.5, 0.5 ); - const cell = mx_cell_noise_float( applyCellJitter ); - const worley = mx_worley_noise_float_3d( applyOffset, jitter, style ); - const fractal = mx_fractal_noise_float( applyCellJitter, octaves, lacunarity, diminish, 1 ); - - const typeFloat = float( noiseType ); - const switched = mx_ifequal( - typeFloat, - float( 3 ), - fractal, - mx_ifequal( - typeFloat, - float( 2 ), - worley, - mx_ifequal( typeFloat, float( 1 ), cell, perlin ) - ) - ); - const ranged = add( outmin, mul( switched, sub( outmax, outmin ) ) ); - const clamped = clamp( ranged, outmin, outmax ); - return mx_ifequal( clampoutput, float( 1 ), clamped, ranged ); - -}; +export const mx_cell_noise_vec3 = ( texcoord = uv() ) => cell_noise_vec3( texcoord.convert( 'vec2|vec3' ) ); +export const mx_worley_noise_float_2d = ( texcoord = uv(), jitter = 1, style = 0 ) => worley_noise_float_2d( texcoord, jitter, style ); +export const mx_worley_noise_float_3d = ( texcoord = uv(), jitter = 1, style = 0 ) => worley_noise_float_3d( texcoord, jitter, style ); +export const mx_unifiednoise2d = ( noiseType, texcoord = uv(), freq = vec2( 1, 1 ), offset = vec2( 0, 0 ), jitter = 1, outmin = 0, outmax = 1, clampoutput = false, octaves = 1, lacunarity = 2, diminish = .5, style = 0 ) => unifiednoise2d( noiseType, texcoord, freq, offset, jitter, outmin, outmax, clampoutput, octaves, lacunarity, diminish, style ); +export const mx_unifiednoise3d = ( noiseType, texcoord = uv(), freq = vec3( 1, 1, 1 ), offset = vec3( 0, 0, 0 ), jitter = 1, outmin = 0, outmax = 1, clampoutput = false, octaves = 1, lacunarity = 2, diminish = .5, style = 0 ) => unifiednoise3d( noiseType, texcoord, freq, offset, jitter, outmin, outmax, clampoutput, octaves, lacunarity, diminish, style ); export const mx_worley_noise_float = ( texcoord = uv(), jitter = 1, style = 0 ) => mx_worley_noise_float_3d( texcoord.convert( 'vec2|vec3' ), jitter, style ); export const mx_worley_noise_vec2 = ( texcoord = uv(), jitter = 1 ) => worley_noise_vec2( texcoord.convert( 'vec2|vec3' ), jitter, int( 1 ) ); diff --git a/src/nodes/materialx/lib/mx_noise.js b/src/nodes/materialx/lib/mx_noise.js index 88913cb88d2c0b..aa1b0e421e1366 100644 --- a/src/nodes/materialx/lib/mx_noise.js +++ b/src/nodes/materialx/lib/mx_noise.js @@ -3,8 +3,8 @@ import { int, uint, float, vec3, bool, uvec3, vec2, vec4, If, Fn } from '../../tsl/TSLBase.js'; import { select } from '../../math/ConditionalNode.js'; -import { sub, mul } from '../../math/OperatorNode.js'; -import { floor, abs, max, dot, min, sqrt, clamp } from '../../math/MathNode.js'; +import { add, sub, mul } from '../../math/OperatorNode.js'; +import { floor, abs, max, dot, sqrt, clamp, fract, sin, cos, normalize } from '../../math/MathNode.js'; import { overloadingFn } from '../../utils/FunctionOverloadingNode.js'; import { Loop } from '../../utils/LoopNode.js'; @@ -763,74 +763,33 @@ export const mx_cell_noise_float_3 = /*@__PURE__*/ Fn( ( [ p_immutable ] ) => { export const mx_cell_noise_float = /*@__PURE__*/ overloadingFn( [ mx_cell_noise_float_0, mx_cell_noise_float_1, mx_cell_noise_float_2, mx_cell_noise_float_3 ] ); -export const mx_cell_noise_vec3_0 = /*@__PURE__*/ Fn( ( [ p_immutable ] ) => { +export const mx_cell_noise_vec3 = /*@__PURE__*/ Fn( ( [ positionInput ] ) => { - const p = float( p_immutable ).toVar(); - const ix = int( mx_floor( p ) ).toVar(); - - return vec3( mx_bits_to_01( mx_hash_int( ix, int( 0 ) ) ), mx_bits_to_01( mx_hash_int( ix, int( 1 ) ) ), mx_bits_to_01( mx_hash_int( ix, int( 2 ) ) ) ); - -} ).setLayout( { - name: 'mx_cell_noise_vec3_0', - type: 'vec3', - inputs: [ - { name: 'p', type: 'float' } - ] -} ); - -export const mx_cell_noise_vec3_1 = /*@__PURE__*/ Fn( ( [ p_immutable ] ) => { - - const p = vec2( p_immutable ).toVar(); - const ix = int( mx_floor( p.x ) ).toVar(); - const iy = int( mx_floor( p.y ) ).toVar(); - - return vec3( mx_bits_to_01( mx_hash_int( ix, iy, int( 0 ) ) ), mx_bits_to_01( mx_hash_int( ix, iy, int( 1 ) ) ), mx_bits_to_01( mx_hash_int( ix, iy, int( 2 ) ) ) ); - -} ).setLayout( { - name: 'mx_cell_noise_vec3_1', - type: 'vec3', - inputs: [ - { name: 'p', type: 'vec2' } - ] -} ); - -export const mx_cell_noise_vec3_2 = /*@__PURE__*/ Fn( ( [ p_immutable ] ) => { - - const p = vec3( p_immutable ).toVar(); - const ix = int( mx_floor( p.x ) ).toVar(); - const iy = int( mx_floor( p.y ) ).toVar(); - const iz = int( mx_floor( p.z ) ).toVar(); - - return vec3( mx_bits_to_01( mx_hash_int( ix, iy, iz, int( 0 ) ) ), mx_bits_to_01( mx_hash_int( ix, iy, iz, int( 1 ) ) ), mx_bits_to_01( mx_hash_int( ix, iy, iz, int( 2 ) ) ) ); + const position = vec3( positionInput ).toVar(); + const ix = int( floor( position.x ) ).toVar(); + const iy = int( floor( position.y ) ).toVar(); + const iz = int( floor( position.z ) ).toVar(); + const seed = uint( 0xdeadbeef + ( 4 << 2 ) + 13 ).toVar(); + const a = seed.toVar(); + const b = seed.toVar(); + const c = seed.toVar(); + a.addAssign( uint( ix ) ); + b.addAssign( uint( iy ) ); + c.addAssign( uint( iz ) ); -} ).setLayout( { - name: 'mx_cell_noise_vec3_2', - type: 'vec3', - inputs: [ - { name: 'p', type: 'vec3' } - ] -} ); - -export const mx_cell_noise_vec3_3 = /*@__PURE__*/ Fn( ( [ p_immutable ] ) => { - - const p = vec4( p_immutable ).toVar(); - const ix = int( mx_floor( p.x ) ).toVar(); - const iy = int( mx_floor( p.y ) ).toVar(); - const iz = int( mx_floor( p.z ) ).toVar(); - const iw = int( mx_floor( p.w ) ).toVar(); + mx_bjmix( a, b, c ); + const hash0 = mx_bjfinal( a, b, c ); + const hash1 = mx_bjfinal( add( a, uint( 1 ) ), b, c ); + const hash2 = mx_bjfinal( add( a, uint( 2 ) ), b, c ); - return vec3( mx_bits_to_01( mx_hash_int( ix, iy, iz, iw, int( 0 ) ) ), mx_bits_to_01( mx_hash_int( ix, iy, iz, iw, int( 1 ) ) ), mx_bits_to_01( mx_hash_int( ix, iy, iz, iw, int( 2 ) ) ) ); + return vec3( + mx_bits_to_01( hash0 ), + mx_bits_to_01( hash1 ), + mx_bits_to_01( hash2 ) + ); -} ).setLayout( { - name: 'mx_cell_noise_vec3_3', - type: 'vec3', - inputs: [ - { name: 'p', type: 'vec4' } - ] } ); -export const mx_cell_noise_vec3 = /*@__PURE__*/ overloadingFn( [ mx_cell_noise_vec3_0, mx_cell_noise_vec3_1, mx_cell_noise_vec3_2, mx_cell_noise_vec3_3 ] ); - export const mx_fractal_noise_float = /*@__PURE__*/ Fn( ( [ p_immutable, octaves_immutable, lacunarity_immutable, diminish_immutable ] ) => { const diminish = float( diminish_immutable ).toVar(); @@ -1028,27 +987,97 @@ export const mx_worley_distance_1 = /*@__PURE__*/ Fn( ( [ p_immutable, x_immutab export const mx_worley_distance = /*@__PURE__*/ overloadingFn( [ mx_worley_distance_0, mx_worley_distance_1 ] ); -export const mx_worley_noise_float_0 = /*@__PURE__*/ Fn( ( [ p_immutable, jitter_immutable, metric_immutable ] ) => { +const mx_noise_float = ( texcoord, amplitude = 1, pivot = 0 ) => mx_perlin_noise_float( texcoord ).mul( amplitude ).add( pivot ); - const metric = int( metric_immutable ).toVar(); - const jitter = float( jitter_immutable ).toVar(); - const p = vec2( p_immutable ).toVar(); - const X = int().toVar(), Y = int().toVar(); - const localpos = vec2( mx_floorfrac( p.x, X ), mx_floorfrac( p.y, Y ) ).toVar(); +const mx_rotate2d_noise = ( inNode, amount = 0 ) => { + + const rotationRadians = mul( amount, Math.PI / 180.0 ); + const sa = sin( rotationRadians ); + const ca = cos( rotationRadians ); + const x = inNode.x; + const y = inNode.y; + + return vec2( add( mul( ca, x ), mul( sa, y ) ), sub( mul( ca, y ), mul( sa, x ) ) ); + +}; + +const mx_rotate3d_noise = ( inNode, amount = 0, axis = vec3( 0, 1, 0 ) ) => { + + const normalizedAxis = normalize( axis ); + const rotationRadians = mul( amount, Math.PI / 180.0 ); + const s = sin( rotationRadians ); + const c = cos( rotationRadians ); + const oc = sub( 1, c ); + + const x = inNode.x; + const y = inNode.y; + const z = inNode.z; + const ax = normalizedAxis.x; + const ay = normalizedAxis.y; + const az = normalizedAxis.z; + + const m00 = add( mul( mul( oc, ax ), ax ), c ); + const m01 = sub( mul( mul( oc, ax ), ay ), mul( az, s ) ); + const m02 = add( mul( mul( oc, az ), ax ), mul( ay, s ) ); + + const m10 = add( mul( mul( oc, ax ), ay ), mul( az, s ) ); + const m11 = add( mul( mul( oc, ay ), ay ), c ); + const m12 = sub( mul( mul( oc, ay ), az ), mul( ax, s ) ); + + const m20 = sub( mul( mul( oc, az ), ax ), mul( ay, s ) ); + const m21 = add( mul( mul( oc, ay ), az ), mul( ax, s ) ); + const m22 = add( mul( mul( oc, az ), az ), c ); + + return vec3( + add( add( mul( m00, x ), mul( m10, y ) ), mul( m20, z ) ), + add( add( mul( m01, x ), mul( m11, y ) ), mul( m21, z ) ), + add( add( mul( m02, x ), mul( m12, y ) ), mul( m22, z ) ) + ); + +}; + +export const mx_worley_noise_float_3d = /*@__PURE__*/ Fn( ( [ positionInput, jitterInput, styleInput ] ) => { + + const position = vec3( positionInput ).toVar(); + const jitter = float( jitterInput ).toVar(); + const style = int( styleInput ).toVar(); + const baseCell = vec3( floor( position.x ), floor( position.y ), floor( position.z ) ).toVar(); + const localpos = fract( position ).toVar(); const sqdist = float( 1e6 ).toVar(); + const minpos = vec3( 0, 0, 0 ).toVar(); Loop( { start: - 1, end: int( 1 ), name: 'x', condition: '<=' }, ( { x } ) => { Loop( { start: - 1, end: int( 1 ), name: 'y', condition: '<=' }, ( { y } ) => { - const dist = float( mx_worley_distance( localpos, x, y, X, Y, jitter, metric ) ).toVar(); - sqdist.assign( min( sqdist, dist ) ); + Loop( { start: - 1, end: int( 1 ), name: 'z', condition: '<=' }, ( { z } ) => { + + const cellCoords = vec3( baseCell.x.add( float( x ) ), baseCell.y.add( float( y ) ), baseCell.z.add( float( z ) ) ).toVar(); + const off = vec3( mx_cell_noise_vec3( cellCoords ) ).toVar(); + off.subAssign( 0.5 ); + off.mulAssign( jitter ); + off.addAssign( 0.5 ); + const cellpos = vec3( vec3( float( x ), float( y ), float( z ) ).add( off ).sub( localpos ) ).toVar(); + const dist = dot( cellpos, cellpos ).toVar(); + + If( dist.lessThan( sqdist ), () => { + + sqdist.assign( dist ); + minpos.assign( cellpos ); + + } ); + + } ); } ); } ); - If( metric.equal( int( 0 ) ), () => { + If( style.equal( int( 1 ) ), () => { + + sqdist.assign( mx_cell_noise_float( minpos.add( position ) ) ); + + } ).Else( () => { sqdist.assign( sqrt( sqdist ) ); @@ -1056,16 +1085,58 @@ export const mx_worley_noise_float_0 = /*@__PURE__*/ Fn( ( [ p_immutable, jitter return sqdist; -} ).setLayout( { - name: 'mx_worley_noise_float_0', - type: 'float', - inputs: [ - { name: 'p', type: 'vec2' }, - { name: 'jitter', type: 'float' }, - { name: 'metric', type: 'int' } - ] } ); +export const mx_worley_noise_float_2d = /*@__PURE__*/ Fn( ( [ texcoordInput, jitterInput, styleInput ] ) => { + + const texcoord = vec2( texcoordInput ).toVar(); + const jitter = float( jitterInput ).toVar(); + const style = int( styleInput ).toVar(); + const floorPos = floor( texcoord ).toVar(); + const localpos = vec2( fract( texcoord.x ), fract( texcoord.y ) ).toVar(); + const sqdist = float( 1e6 ).toVar(); + const minpos = vec2( 0, 0 ).toVar(); + + Loop( { start: - 1, end: int( 1 ), name: 'x', condition: '<=' }, ( { x } ) => { + + Loop( { start: - 1, end: int( 1 ), name: 'y', condition: '<=' }, ( { y } ) => { + + const cell = vec2( float( x ), float( y ) ).toVar(); + const seed = vec2( cell.x.add( floorPos.x ), cell.y.add( floorPos.y ) ).toVar(); + const off = vec2( mx_cell_noise_float( vec3( seed.x, seed.y, 0 ) ), mx_cell_noise_float( vec3( seed.x, seed.y, 1 ) ) ).toVar(); + off.subAssign( 0.5 ); + off.mulAssign( jitter ); + off.addAssign( 0.5 ); + const cellpos = vec2( cell.add( off ).sub( localpos ) ).toVar(); + const dist = dot( cellpos, cellpos ).toVar(); + + If( dist.lessThan( sqdist ), () => { + + sqdist.assign( dist ); + minpos.assign( cellpos ); + + } ); + + } ); + + } ); + + If( style.equal( int( 1 ) ), () => { + + sqdist.assign( mx_cell_noise_float( minpos.add( texcoord ) ) ); + + } ).Else( () => { + + sqdist.assign( sqrt( sqdist ) ); + + } ); + + return sqdist; + +} ); + +export const mx_worley_noise_float = /*@__PURE__*/ overloadingFn( [ mx_worley_noise_float_2d, mx_worley_noise_float_3d ] ); + export const mx_worley_noise_vec2_0 = /*@__PURE__*/ Fn( ( [ p_immutable, jitter_immutable, metric_immutable ] ) => { const metric = int( metric_immutable ).toVar(); @@ -1168,50 +1239,6 @@ export const mx_worley_noise_vec3_0 = /*@__PURE__*/ Fn( ( [ p_immutable, jitter_ ] } ); -export const mx_worley_noise_float_1 = /*@__PURE__*/ Fn( ( [ p_immutable, jitter_immutable, metric_immutable ] ) => { - - const metric = int( metric_immutable ).toVar(); - const jitter = float( jitter_immutable ).toVar(); - const p = vec3( p_immutable ).toVar(); - const X = int().toVar(), Y = int().toVar(), Z = int().toVar(); - const localpos = vec3( mx_floorfrac( p.x, X ), mx_floorfrac( p.y, Y ), mx_floorfrac( p.z, Z ) ).toVar(); - const sqdist = float( 1e6 ).toVar(); - - Loop( { start: - 1, end: int( 1 ), name: 'x', condition: '<=' }, ( { x } ) => { - - Loop( { start: - 1, end: int( 1 ), name: 'y', condition: '<=' }, ( { y } ) => { - - Loop( { start: - 1, end: int( 1 ), name: 'z', condition: '<=' }, ( { z } ) => { - - const dist = float( mx_worley_distance( localpos, x, y, z, X, Y, Z, jitter, metric ) ).toVar(); - sqdist.assign( min( sqdist, dist ) ); - - } ); - - } ); - - } ); - - If( metric.equal( int( 0 ) ), () => { - - sqdist.assign( sqrt( sqdist ) ); - - } ); - - return sqdist; - -} ).setLayout( { - name: 'mx_worley_noise_float_1', - type: 'float', - inputs: [ - { name: 'p', type: 'vec3' }, - { name: 'jitter', type: 'float' }, - { name: 'metric', type: 'int' } - ] -} ); - -export const mx_worley_noise_float = /*@__PURE__*/ overloadingFn( [ mx_worley_noise_float_0, mx_worley_noise_float_1 ] ); - export const mx_worley_noise_vec2_1 = /*@__PURE__*/ Fn( ( [ p_immutable, jitter_immutable, metric_immutable ] ) => { const metric = int( metric_immutable ).toVar(); @@ -1328,164 +1355,128 @@ export const mx_worley_noise_vec3 = /*@__PURE__*/ overloadingFn( [ mx_worley_noi // Unified Noise 2D export const mx_unifiednoise2d = /*@__PURE__*/ Fn( ( [ - noiseType_immutable, texcoord_immutable, freq_immutable, offset_immutable, - jitter_immutable, outmin_immutable, outmax_immutable, clampoutput_immutable, - octaves_immutable, lacunarity_immutable, diminish_immutable + noiseTypeInput, + texcoordInput, + freqInput, + offsetInput, + jitterInput, + outminInput, + outmaxInput, + clampoutputInput, + octavesInput, + lacunarityInput, + diminishInput, + styleInput ] ) => { - const noiseType = int( noiseType_immutable ).toVar(); - const texcoord = vec2( texcoord_immutable ).toVar(); - const freq = vec2( freq_immutable ).toVar(); - const offset = vec2( offset_immutable ).toVar(); - const jitter = float( jitter_immutable ).toVar(); - const outmin = float( outmin_immutable ).toVar(); - const outmax = float( outmax_immutable ).toVar(); - const clampoutput = bool( clampoutput_immutable ).toVar(); - const octaves = int( octaves_immutable ).toVar(); - const lacunarity = float( lacunarity_immutable ).toVar(); - const diminish = float( diminish_immutable ).toVar(); - - // Compute input position - const p = texcoord.mul( freq ).add( offset ); + const noiseType = int( noiseTypeInput ).toVar(); + const texcoord = vec2( texcoordInput ).toVar(); + const freq = vec2( freqInput ).toVar(); + const offset = vec2( offsetInput ).toVar(); + const jitter = float( jitterInput ).toVar(); + const outmin = float( outminInput ).toVar(); + const outmax = float( outmaxInput ).toVar(); + const clampoutput = float( clampoutputInput ).toVar(); + const octaves = int( octavesInput ).toVar(); + const lacunarity = float( lacunarityInput ).toVar(); + const diminish = float( diminishInput ).toVar(); + const style = int( styleInput ).toVar(); + + const applyFreq = mul( texcoord, freq ).toVar(); + const applyOffset = add( applyFreq, offset ).toVar(); + const cellJitterMult = mul( sub( jitter, 1 ), 90000 ).toVar(); + const applyCellJitter = mx_rotate2d_noise( applyOffset, cellJitterMult ).toVar(); + const fractalInput = vec3( applyOffset.x, applyOffset.y, cellJitterMult ).toVar(); + const result = float( 0 ).toVar(); - const result = float( 0.0 ).toVar(); - - // Perlin If( noiseType.equal( int( 0 ) ), () => { - result.assign( mx_perlin_noise_vec3( p ) ); + result.assign( mx_noise_float( applyCellJitter, 0.5, 0.5 ) ); } ); - - // Cell If( noiseType.equal( int( 1 ) ), () => { - result.assign( mx_cell_noise_vec3( p ) ); + result.assign( mx_cell_noise_float( applyCellJitter ) ); } ); - - // Worley (metric=0 = euclidean) If( noiseType.equal( int( 2 ) ), () => { - result.assign( mx_worley_noise_vec3( p, jitter, int( 0 ) ) ); + result.assign( mx_worley_noise_float_2d( applyOffset, jitter, style ) ); } ); - - // Fractal (use vec3(p, 0.0) for 2D input) If( noiseType.equal( int( 3 ) ), () => { - result.assign( mx_fractal_noise_vec3( vec3( p, 0.0 ), octaves, lacunarity, diminish ) ); + result.assign( mx_fractal_noise_float( fractalInput, octaves, lacunarity, diminish ) ); } ); - // Remap output to [outmin, outmax] - result.assign( result.mul( outmax.sub( outmin ) ).add( outmin ) ); + const ranged = add( outmin, mul( result, sub( outmax, outmin ) ) ).toVar(); + const clamped = clamp( ranged, outmin, outmax ).toVar(); + const output = ranged.toVar(); - // Clamp if requested - If( clampoutput, () => { + If( clampoutput.equal( float( 1 ) ), () => { - result.assign( clamp( result, outmin, outmax ) ); + output.assign( clamped ); } ); - return result; + return output; -} ).setLayout( { - name: 'mx_unifiednoise2d', - type: 'float', - inputs: [ - { name: 'noiseType', type: 'int' }, - { name: 'texcoord', type: 'vec2' }, - { name: 'freq', type: 'vec2' }, - { name: 'offset', type: 'vec2' }, - { name: 'jitter', type: 'float' }, - { name: 'outmin', type: 'float' }, - { name: 'outmax', type: 'float' }, - { name: 'clampoutput', type: 'bool' }, - { name: 'octaves', type: 'int' }, - { name: 'lacunarity', type: 'float' }, - { name: 'diminish', type: 'float' } - ] } ); // Unified Noise 3D -export const mx_unifiednoise3d = /*@__PURE__*/ Fn( ( [ - noiseType_immutable, position_immutable, freq_immutable, offset_immutable, - jitter_immutable, outmin_immutable, outmax_immutable, clampoutput_immutable, - octaves_immutable, lacunarity_immutable, diminish_immutable -] ) => { - - const noiseType = int( noiseType_immutable ).toVar(); - const position = vec3( position_immutable ).toVar(); - const freq = vec3( freq_immutable ).toVar(); - const offset = vec3( offset_immutable ).toVar(); - const jitter = float( jitter_immutable ).toVar(); - const outmin = float( outmin_immutable ).toVar(); - const outmax = float( outmax_immutable ).toVar(); - const clampoutput = bool( clampoutput_immutable ).toVar(); - const octaves = int( octaves_immutable ).toVar(); - const lacunarity = float( lacunarity_immutable ).toVar(); - const diminish = float( diminish_immutable ).toVar(); - - // Compute input position - const p = position.mul( freq ).add( offset ); - - const result = float( 0.0 ).toVar(); - - // Perlin - If( noiseType.equal( int( 0 ) ), () => { - - result.assign( mx_perlin_noise_vec3( p ) ); +export const mx_unifiednoise3d = ( + noiseType = 0, + position = vec3( 0, 0, 0 ), + freq = vec3( 1, 1, 1 ), + offset = vec3( 0, 0, 0 ), + jitter = 1, + outmin = 0, + outmax = 1, + clampoutput = true, + octaves = 3, + lacunarity = 2, + diminish = 0.5, + style = 0 +) => { + + const applyFreq = mul( position, freq ); + const applyOffset = add( applyFreq, offset ); + const cellJitterMult = mul( sub( jitter, 1 ), 90000 ); + const applyCellJitter = mx_rotate3d_noise( applyOffset, cellJitterMult, vec3( 0.1, 1, 0 ) ); + const perlin = mx_noise_float( applyCellJitter, 0.5, 0.5 ); + const cell = mx_cell_noise_float( applyCellJitter ); + const worley = mx_worley_noise_float_3d( applyOffset, jitter, style ); + const fractal = mx_fractal_noise_float( applyCellJitter, octaves, lacunarity, diminish ); + const typeFloat = float( noiseType ); + const result = perlin.toVar(); + + If( typeFloat.equal( float( 1 ) ), () => { + + result.assign( cell ); } ); + If( typeFloat.equal( float( 2 ) ), () => { - // Cell - If( noiseType.equal( int( 1 ) ), () => { - - result.assign( mx_cell_noise_vec3( p ) ); + result.assign( worley ); } ); + If( typeFloat.equal( float( 3 ) ), () => { - // Worley (metric=0 = euclidean) - If( noiseType.equal( int( 2 ) ), () => { - - result.assign( mx_worley_noise_vec3( p, jitter, int( 0 ) ) ); + result.assign( fractal ); } ); - // Fractal - If( noiseType.equal( int( 3 ) ), () => { - - result.assign( mx_fractal_noise_vec3( p, octaves, lacunarity, diminish ) ); + const ranged = add( outmin, mul( result, sub( outmax, outmin ) ) ).toVar(); + const clamped = clamp( ranged, outmin, outmax ).toVar(); + const output = ranged.toVar(); - } ); + If( float( clampoutput ).equal( float( 1 ) ), () => { - // Remap output to [outmin, outmax] - result.assign( result.mul( outmax.sub( outmin ) ).add( outmin ) ); - - // Clamp if requested - If( clampoutput, () => { - - result.assign( clamp( result, outmin, outmax ) ); + output.assign( clamped ); } ); - return result; + return output; -} ).setLayout( { - name: 'mx_unifiednoise3d', - type: 'float', - inputs: [ - { name: 'noiseType', type: 'int' }, - { name: 'position', type: 'vec3' }, - { name: 'freq', type: 'vec3' }, - { name: 'offset', type: 'vec3' }, - { name: 'jitter', type: 'float' }, - { name: 'outmin', type: 'float' }, - { name: 'outmax', type: 'float' }, - { name: 'clampoutput', type: 'bool' }, - { name: 'octaves', type: 'int' }, - { name: 'lacunarity', type: 'float' }, - { name: 'diminish', type: 'float' } - ] -} ); +}; From 05483d9afc91e4d91b8d0d19ab9f8b5dc36e4ef1 Mon Sep 17 00:00:00 2001 From: Ben Houston Date: Mon, 27 Apr 2026 14:54:47 -0400 Subject: [PATCH 04/40] conslidate matrix inversion. --- .../loaders/materialx/MaterialXDocument.js | 169 +----------------- 1 file changed, 2 insertions(+), 167 deletions(-) diff --git a/examples/jsm/loaders/materialx/MaterialXDocument.js b/examples/jsm/loaders/materialx/MaterialXDocument.js index 4f85717d1a45be..d3ee2447ae79fa 100644 --- a/examples/jsm/loaders/materialx/MaterialXDocument.js +++ b/examples/jsm/loaders/materialx/MaterialXDocument.js @@ -36,6 +36,7 @@ import { uv, mat3, mat4, + inverse, element, mx_transform_uv, mx_srgb_texture_to_lin_rec709, @@ -294,172 +295,6 @@ function invertConstantMatrixValues( values, size ) { } -function matrixNodeAt( matrixNode, row, col ) { - - return element( element( matrixNode, col ), row ); - -} - -function det2Node( a, b, c, d ) { - - return sub( mul( a, d ), mul( b, c ) ); - -} - -function det3Node( matrixRows ) { - - const a = matrixRows[ 0 ][ 0 ]; - const b = matrixRows[ 0 ][ 1 ]; - const c = matrixRows[ 0 ][ 2 ]; - const d = matrixRows[ 1 ][ 0 ]; - const e = matrixRows[ 1 ][ 1 ]; - const f = matrixRows[ 1 ][ 2 ]; - const g = matrixRows[ 2 ][ 0 ]; - const h = matrixRows[ 2 ][ 1 ]; - const i = matrixRows[ 2 ][ 2 ]; - - const eiMinusFh = det2Node( e, f, h, i ); - const diMinusFg = det2Node( d, f, g, i ); - const dhMinusEg = det2Node( d, e, g, h ); - - return add( sub( mul( a, eiMinusFh ), mul( b, diMinusFg ) ), mul( c, dhMinusEg ) ); - -} - -function readMatrixRows( matrixNode, size ) { - - const rows = []; - for ( let row = 0; row < size; row += 1 ) { - - const rowValues = []; - for ( let col = 0; col < size; col += 1 ) { - - rowValues.push( matrixNodeAt( matrixNode, row, col ) ); - - } - - rows.push( rowValues ); - - } - - return rows; - -} - -function invertMatrixNode( matrixNode, size ) { - - if ( size === 3 ) { - - const m = readMatrixRows( matrixNode, 3 ); - const a = m[ 0 ][ 0 ]; - const b = m[ 0 ][ 1 ]; - const c = m[ 0 ][ 2 ]; - const d = m[ 1 ][ 0 ]; - const e = m[ 1 ][ 1 ]; - const f = m[ 1 ][ 2 ]; - const g = m[ 2 ][ 0 ]; - const h = m[ 2 ][ 1 ]; - const i = m[ 2 ][ 2 ]; - - const determinant = det3Node( m ); - const invDet = div( 1, determinant ); - - const cofactor00 = det2Node( e, f, h, i ); - const cofactor01 = sub( 0, det2Node( d, f, g, i ) ); - const cofactor02 = det2Node( d, e, g, h ); - const cofactor10 = sub( 0, det2Node( b, c, h, i ) ); - const cofactor11 = det2Node( a, c, g, i ); - const cofactor12 = sub( 0, det2Node( a, b, g, h ) ); - const cofactor20 = det2Node( b, c, e, f ); - const cofactor21 = sub( 0, det2Node( a, c, d, f ) ); - const cofactor22 = det2Node( a, b, d, e ); - - return mat3( - mul( cofactor00, invDet ), - mul( cofactor10, invDet ), - mul( cofactor20, invDet ), - mul( cofactor01, invDet ), - mul( cofactor11, invDet ), - mul( cofactor21, invDet ), - mul( cofactor02, invDet ), - mul( cofactor12, invDet ), - mul( cofactor22, invDet ), - ); - - } - - if ( size === 4 ) { - - const m = readMatrixRows( matrixNode, 4 ); - const det3FromMinor = ( rowToRemove, colToRemove ) => { - - const minorRows = []; - for ( let row = 0; row < 4; row += 1 ) { - - if ( row === rowToRemove ) continue; - const minorRow = []; - for ( let col = 0; col < 4; col += 1 ) { - - if ( col === colToRemove ) continue; - minorRow.push( m[ row ][ col ] ); - - } - - minorRows.push( minorRow ); - - } - - return det3Node( minorRows ); - - }; - - const determinant = add( - sub( mul( m[ 0 ][ 0 ], det3FromMinor( 0, 0 ) ), mul( m[ 0 ][ 1 ], det3FromMinor( 0, 1 ) ) ), - add( mul( m[ 0 ][ 2 ], det3FromMinor( 0, 2 ) ), sub( 0, mul( m[ 0 ][ 3 ], det3FromMinor( 0, 3 ) ) ) ), - ); - const invDet = div( 1, determinant ); - - const inverseRows = []; - for ( let row = 0; row < 4; row += 1 ) { - - const inverseRow = []; - for ( let col = 0; col < 4; col += 1 ) { - - const cofactor = det3FromMinor( col, row ); - const signedCofactor = ( col + row ) % 2 === 0 ? cofactor : sub( 0, cofactor ); - inverseRow.push( mul( signedCofactor, invDet ) ); - - } - - inverseRows.push( inverseRow ); - - } - - return mat4( - inverseRows[ 0 ][ 0 ], - inverseRows[ 0 ][ 1 ], - inverseRows[ 0 ][ 2 ], - inverseRows[ 0 ][ 3 ], - inverseRows[ 1 ][ 0 ], - inverseRows[ 1 ][ 1 ], - inverseRows[ 1 ][ 2 ], - inverseRows[ 1 ][ 3 ], - inverseRows[ 2 ][ 0 ], - inverseRows[ 2 ][ 1 ], - inverseRows[ 2 ][ 2 ], - inverseRows[ 2 ][ 3 ], - inverseRows[ 3 ][ 0 ], - inverseRows[ 3 ][ 1 ], - inverseRows[ 3 ][ 2 ], - inverseRows[ 3 ][ 3 ], - ); - - } - - return matrixNode; - -} - function getOutputChannel( outputName ) { if ( outputName === 'outx' || outputName === 'outr' || outputName === 'r' ) return 0; @@ -1091,7 +926,7 @@ class MaterialXDocument { mxHextileCoord, mxHextileComputeBlendWeights, invertConstantMatrixValues, - invertMatrixNode, + invertMatrixNode: inverse, IDENTITY_MAT3_VALUES, IDENTITY_MAT4_VALUES, }; From aeb63e7979bac635c14ffafe755ba0509ea4d180 Mon Sep 17 00:00:00 2001 From: Ben Houston Date: Mon, 27 Apr 2026 15:04:44 -0400 Subject: [PATCH 05/40] simplify code. --- .../loaders/materialx/MaterialXDocument.js | 38 ++----------------- 1 file changed, 4 insertions(+), 34 deletions(-) diff --git a/examples/jsm/loaders/materialx/MaterialXDocument.js b/examples/jsm/loaders/materialx/MaterialXDocument.js index d3ee2447ae79fa..b960ee12fa7b01 100644 --- a/examples/jsm/loaders/materialx/MaterialXDocument.js +++ b/examples/jsm/loaders/materialx/MaterialXDocument.js @@ -244,50 +244,20 @@ function invertConstantMatrixValues( values, size ) { if ( size === 3 ) { - const matrix = new Matrix3().set( - values[ 0 ], - values[ 1 ], - values[ 2 ], - values[ 3 ], - values[ 4 ], - values[ 5 ], - values[ 6 ], - values[ 7 ], - values[ 8 ], - ); + const matrix = new Matrix3().setFromArray(values); if ( Math.abs( matrix.determinant() ) < MATRIX_INVERSE_EPSILON ) return null; matrix.invert(); - const e = matrix.elements; // Convert Three.js internal column-major storage back to row-major literal order. - return [ e[ 0 ], e[ 3 ], e[ 6 ], e[ 1 ], e[ 4 ], e[ 7 ], e[ 2 ], e[ 5 ], e[ 8 ] ]; - + return matrix.transpose().elements; } if ( size === 4 ) { - const matrix = new Matrix4().set( - values[ 0 ], - values[ 1 ], - values[ 2 ], - values[ 3 ], - values[ 4 ], - values[ 5 ], - values[ 6 ], - values[ 7 ], - values[ 8 ], - values[ 9 ], - values[ 10 ], - values[ 11 ], - values[ 12 ], - values[ 13 ], - values[ 14 ], - values[ 15 ], - ); + const matrix = new Matrix4().setFromArray(values); if ( Math.abs( matrix.determinant() ) < MATRIX_INVERSE_EPSILON ) return null; matrix.invert(); - const e = matrix.elements; // Convert Three.js internal column-major storage back to row-major literal order. - return [ e[ 0 ], e[ 4 ], e[ 8 ], e[ 12 ], e[ 1 ], e[ 5 ], e[ 9 ], e[ 13 ], e[ 2 ], e[ 6 ], e[ 10 ], e[ 14 ], e[ 3 ], e[ 7 ], e[ 11 ], e[ 15 ] ]; + return matrix.transpose().elements; } From 7c5a82d9d9d1332c24ab4ad4b63c052d5fd20b00 Mon Sep 17 00:00:00 2001 From: Ben Houston Date: Mon, 27 Apr 2026 15:36:30 -0400 Subject: [PATCH 06/40] simplify MaterialXLoader code. --- .../loaders/materialx/MaterialXDocument.js | 69 +++--- .../loaders/materialx/MaterialXNodeLibrary.js | 226 ++++++++---------- .../materialx/MaterialXNodeRegistry.js | 86 ------- .../materialx/MaterialXSurfaceMappings.js | 20 ++ .../materialx/MaterialXSurfaceRegistry.js | 43 ---- .../materialx/MaterialXTranslatorTypes.js | 57 ----- .../compile/MaterialXCompileRegistry.js | 99 +++----- 7 files changed, 197 insertions(+), 403 deletions(-) delete mode 100644 examples/jsm/loaders/materialx/MaterialXNodeRegistry.js delete mode 100644 examples/jsm/loaders/materialx/MaterialXSurfaceRegistry.js delete mode 100644 examples/jsm/loaders/materialx/MaterialXTranslatorTypes.js diff --git a/examples/jsm/loaders/materialx/MaterialXDocument.js b/examples/jsm/loaders/materialx/MaterialXDocument.js index b960ee12fa7b01..7a0145dcca51ec 100644 --- a/examples/jsm/loaders/materialx/MaterialXDocument.js +++ b/examples/jsm/loaders/materialx/MaterialXDocument.js @@ -44,9 +44,8 @@ import { import { createMaterialXCompileRegistry, compileNodeFromRegistry } from './compile/MaterialXCompileRegistry.js'; import { parseMaterialXNodeTree, parseMaterialXText } from './parse/MaterialXParser.js'; -import { getSurfaceMapper, getSupportedSurfaceCategories } from './MaterialXSurfaceRegistry.js'; +import { getSurfaceMapper } from './MaterialXSurfaceMappings.js'; import { MtlXLibrary } from './MaterialXNodeLibrary.js'; -import { validateCategoryCoverage } from './MaterialXNodeRegistry.js'; const colorSpaceLib = { mx_srgb_texture_to_lin_rec709, @@ -59,8 +58,32 @@ const HEXTILE_SQRT3_2 = Math.sqrt( 3 ) * 2; const HEXTILE_EPSILON = 1e-6; const HEXTILE_PI_OVER_180 = Math.PI / 180; const COMPILE_REGISTRY = createMaterialXCompileRegistry(); -const ALLOWED_NON_STANDARD_COMPILE_CATEGORIES = [ 'hextiledimage', 'hextilednormalmap', 'gltf_anisotropy_image' ]; -let translatorRegistryValidated = false; +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, + r: 0, + outy: 1, + outg: 1, + g: 1, + outz: 2, + outb: 2, + b: 2, + outw: 3, + outa: 3, + a: 3, +}; function toRadians( degrees ) { @@ -244,16 +267,17 @@ function invertConstantMatrixValues( values, size ) { if ( size === 3 ) { - const matrix = new Matrix3().setFromArray(values); + 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); + 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. @@ -267,20 +291,13 @@ function invertConstantMatrixValues( values, size ) { function getOutputChannel( outputName ) { - if ( outputName === 'outx' || outputName === 'outr' || outputName === 'r' ) return 0; - if ( outputName === 'outy' || outputName === 'outg' || outputName === 'g' ) return 1; - if ( outputName === 'outz' || outputName === 'outb' || outputName === 'b' ) return 2; - if ( outputName === 'outw' || outputName === 'outa' || outputName === 'a' ) return 3; - return 0; + return OUTPUT_CHANNELS[ outputName ] || 0; } function isChannelOutput( outputName ) { - return outputName === 'outx' || outputName === 'outr' || outputName === 'r' || - outputName === 'outy' || outputName === 'outg' || outputName === 'g' || - outputName === 'outz' || outputName === 'outb' || outputName === 'b' || - outputName === 'outw' || outputName === 'outa' || outputName === 'a'; + return outputName in OUTPUT_CHANNELS; } @@ -457,16 +474,7 @@ class MaterialXNode { getClassFromType( type ) { - if ( type === 'integer' ) return int; - if ( type === 'float' ) return float; - if ( type === 'vector2' ) return vec2; - if ( type === 'vector3' ) return vec3; - if ( type === 'vector4' || type === 'color4' ) return vec4; - if ( type === 'color3' ) return color; - if ( type === 'boolean' ) return null; - if ( type === 'matrix33' ) return mat3; - if ( type === 'matrix44' ) return mat4; - return null; + return NODE_CLASS_BY_TYPE[ type ] || null; } @@ -901,17 +909,6 @@ class MaterialXDocument { IDENTITY_MAT4_VALUES, }; - if ( ! translatorRegistryValidated ) { - - validateCategoryCoverage( { - compileCategories: [ ...COMPILE_REGISTRY.keys() ], - surfaceCategories: getSupportedSurfaceCategories(), - allowUnknownCompileCategories: ALLOWED_NON_STANDARD_COMPILE_CATEGORIES, - } ); - translatorRegistryValidated = true; - - } - } resolveTextureURI( uri ) { diff --git a/examples/jsm/loaders/materialx/MaterialXNodeLibrary.js b/examples/jsm/loaders/materialx/MaterialXNodeLibrary.js index e760fe055dc73d..95c2035399b38b 100644 --- a/examples/jsm/loaders/materialx/MaterialXNodeLibrary.js +++ b/examples/jsm/loaders/materialx/MaterialXNodeLibrary.js @@ -81,18 +81,7 @@ import { } from 'three/tsl'; import { normalizeSpaceName } from './MaterialXUtils.js'; -class MXElement { - - constructor( name, nodeFunc, params = [], defaults = {} ) { - - this.name = name; - this.nodeFunc = nodeFunc; - this.params = params; - this.defaults = defaults; - - } - -} +const createMXElement = ( name, nodeFunc, params = [], defaults = {} ) => ( { name, nodeFunc, params, defaults } ); const mx_invert = ( inNode, amount = 1 ) => sub( amount, inNode ); @@ -510,76 +499,76 @@ const defaultVec3 = ( x, y, z ) => () => vec3( x, y, z ); const defaultVec4 = ( x, y, z, w ) => () => vec4( x, y, z, w ); const MXElements = [ - new MXElement( 'add', add, [ 'in1', 'in2' ], { in1: defaultFloat( 0 ), in2: defaultFloat( 0 ) } ), - new MXElement( 'subtract', sub, [ 'in1', 'in2' ], { in1: defaultFloat( 0 ), in2: defaultFloat( 0 ) } ), - new MXElement( 'multiply', mul, [ 'in1', 'in2' ], { in1: defaultFloat( 0 ), in2: defaultFloat( 1 ) } ), - new MXElement( 'divide', div, [ 'in1', 'in2' ], { in1: defaultFloat( 0 ), in2: defaultFloat( 1 ) } ), - new MXElement( 'modulo', mx_modulo, [ 'in1', 'in2' ], { in1: defaultFloat( 0 ), in2: defaultFloat( 1 ) } ), - new MXElement( 'absval', abs, [ 'in' ], { in: defaultFloat( 0 ) } ), - new MXElement( 'sign', sign, [ 'in' ], { in: defaultFloat( 0 ) } ), - new MXElement( 'floor', floor, [ 'in' ], { in: defaultFloat( 0 ) } ), - new MXElement( 'ceil', ceil, [ 'in' ], { in: defaultFloat( 0 ) } ), - new MXElement( 'round', round, [ 'in' ], { in: defaultFloat( 0 ) } ), - new MXElement( 'power', pow, [ 'in1', 'in2' ], { in1: defaultFloat( 0 ), in2: defaultFloat( 1 ) } ), - new MXElement( 'sin', sin, [ 'in' ], { in: defaultFloat( 0 ) } ), - new MXElement( 'cos', cos, [ 'in' ], { in: defaultFloat( 0 ) } ), - new MXElement( 'tan', tan, [ 'in' ], { in: defaultFloat( 0 ) } ), - new MXElement( 'asin', asin, [ 'in' ], { in: defaultFloat( 0 ) } ), - new MXElement( 'acos', acos, [ 'in' ], { in: defaultFloat( 0 ) } ), - new MXElement( 'atan2', mx_atan2, [ 'iny', 'inx' ], { iny: defaultFloat( 0 ), inx: defaultFloat( 1 ) } ), - new MXElement( 'sqrt', sqrt, [ 'in' ], { in: defaultFloat( 0 ) } ), - new MXElement( 'ln', log, [ 'in' ], { in: defaultFloat( 1 ) } ), - new MXElement( 'exp', exp, [ 'in' ], { in: defaultFloat( 0 ) } ), - new MXElement( 'fract', fract, [ 'in' ], { in: defaultFloat( 0 ) } ), - new MXElement( 'clamp', clamp, [ 'in', 'low', 'high' ], { + createMXElement( 'add', add, [ 'in1', 'in2' ], { in1: defaultFloat( 0 ), in2: defaultFloat( 0 ) } ), + createMXElement( 'subtract', sub, [ 'in1', 'in2' ], { in1: defaultFloat( 0 ), in2: defaultFloat( 0 ) } ), + createMXElement( 'multiply', mul, [ 'in1', 'in2' ], { in1: defaultFloat( 0 ), in2: defaultFloat( 1 ) } ), + createMXElement( 'divide', div, [ 'in1', 'in2' ], { in1: defaultFloat( 0 ), in2: defaultFloat( 1 ) } ), + createMXElement( 'modulo', mx_modulo, [ 'in1', 'in2' ], { in1: defaultFloat( 0 ), in2: defaultFloat( 1 ) } ), + createMXElement( 'absval', abs, [ 'in' ], { in: defaultFloat( 0 ) } ), + createMXElement( 'sign', sign, [ 'in' ], { in: defaultFloat( 0 ) } ), + createMXElement( 'floor', floor, [ 'in' ], { in: defaultFloat( 0 ) } ), + createMXElement( 'ceil', ceil, [ 'in' ], { in: defaultFloat( 0 ) } ), + createMXElement( 'round', round, [ 'in' ], { in: defaultFloat( 0 ) } ), + createMXElement( 'power', pow, [ 'in1', 'in2' ], { in1: defaultFloat( 0 ), in2: defaultFloat( 1 ) } ), + createMXElement( 'sin', sin, [ 'in' ], { in: defaultFloat( 0 ) } ), + createMXElement( 'cos', cos, [ 'in' ], { in: defaultFloat( 0 ) } ), + createMXElement( 'tan', tan, [ 'in' ], { in: defaultFloat( 0 ) } ), + createMXElement( 'asin', asin, [ 'in' ], { in: defaultFloat( 0 ) } ), + createMXElement( 'acos', acos, [ 'in' ], { in: defaultFloat( 0 ) } ), + createMXElement( 'atan2', mx_atan2, [ 'iny', 'inx' ], { iny: defaultFloat( 0 ), inx: defaultFloat( 1 ) } ), + createMXElement( 'sqrt', sqrt, [ 'in' ], { in: defaultFloat( 0 ) } ), + createMXElement( 'ln', log, [ 'in' ], { in: defaultFloat( 1 ) } ), + createMXElement( 'exp', exp, [ 'in' ], { in: defaultFloat( 0 ) } ), + createMXElement( 'fract', fract, [ 'in' ], { in: defaultFloat( 0 ) } ), + createMXElement( 'clamp', clamp, [ 'in', 'low', 'high' ], { in: defaultFloat( 0 ), low: defaultFloat( 0 ), high: defaultFloat( 1 ), } ), - new MXElement( 'min', min, [ 'in1', 'in2' ], { in1: defaultFloat( 0 ), in2: defaultFloat( 0 ) } ), - new MXElement( 'max', max, [ 'in1', 'in2' ], { in1: defaultFloat( 0 ), in2: defaultFloat( 0 ) } ), - new MXElement( 'normalize', normalize, [ 'in' ], { in: defaultFloat( 0 ) } ), - new MXElement( 'magnitude', length, [ 'in' ], { in: defaultFloat( 0 ) } ), - new MXElement( 'length', length, [ 'in' ], { in: defaultFloat( 0 ) } ), - new MXElement( 'dot', mx_dot, [ 'in' ], { in: defaultFloat( 0 ) } ), - new MXElement( 'dotproduct', dot, [ 'in1', 'in2' ], { in1: defaultFloat( 0 ), in2: defaultFloat( 0 ) } ), - new MXElement( 'viewdirection', mx_viewdirection ), - new MXElement( 'crossproduct', cross, [ 'in1', 'in2' ], { in1: defaultVec3( 0, 0, 0 ), in2: defaultVec3( 0, 0, 0 ) } ), - new MXElement( 'distance', distance, [ 'in1', 'in2' ], { in1: defaultFloat( 0 ), in2: defaultFloat( 0 ) } ), - new MXElement( 'invert', mx_invert, [ 'in', 'amount' ], { in: defaultFloat( 0 ), amount: defaultFloat( 1 ) } ), - new MXElement( 'transformmatrix', mul, [ 'in', 'mat' ], { in: defaultFloat( 0 ) } ), - new MXElement( 'transformnormal', mx_transformnormal, [ 'in', 'fromspace', 'tospace' ], { + createMXElement( 'min', min, [ 'in1', 'in2' ], { in1: defaultFloat( 0 ), in2: defaultFloat( 0 ) } ), + createMXElement( 'max', max, [ 'in1', 'in2' ], { in1: defaultFloat( 0 ), in2: defaultFloat( 0 ) } ), + createMXElement( 'normalize', normalize, [ 'in' ], { in: defaultFloat( 0 ) } ), + createMXElement( 'magnitude', length, [ 'in' ], { in: defaultFloat( 0 ) } ), + createMXElement( 'length', length, [ 'in' ], { in: defaultFloat( 0 ) } ), + createMXElement( 'dot', mx_dot, [ 'in' ], { in: defaultFloat( 0 ) } ), + createMXElement( 'dotproduct', dot, [ 'in1', 'in2' ], { in1: defaultFloat( 0 ), in2: defaultFloat( 0 ) } ), + createMXElement( 'viewdirection', mx_viewdirection ), + createMXElement( 'crossproduct', cross, [ 'in1', 'in2' ], { in1: defaultVec3( 0, 0, 0 ), in2: defaultVec3( 0, 0, 0 ) } ), + createMXElement( 'distance', distance, [ 'in1', 'in2' ], { in1: defaultFloat( 0 ), in2: defaultFloat( 0 ) } ), + createMXElement( 'invert', mx_invert, [ 'in', 'amount' ], { in: defaultFloat( 0 ), amount: defaultFloat( 1 ) } ), + createMXElement( 'transformmatrix', mul, [ 'in', 'mat' ], { in: defaultFloat( 0 ) } ), + createMXElement( 'transformnormal', mx_transformnormal, [ 'in', 'fromspace', 'tospace' ], { in: defaultVec3( 0, 0, 1 ), fromspace: () => 'world', tospace: () => 'world', } ), - new MXElement( 'transformpoint', mx_transformpoint, [ 'in', 'fromspace', 'tospace' ], { + createMXElement( 'transformpoint', mx_transformpoint, [ 'in', 'fromspace', 'tospace' ], { in: defaultVec3( 0, 0, 0 ), fromspace: () => 'world', tospace: () => 'world', } ), - new MXElement( 'transformvector', mx_transformvector, [ 'in', 'fromspace', 'tospace' ], { + createMXElement( 'transformvector', mx_transformvector, [ 'in', 'fromspace', 'tospace' ], { in: defaultVec3( 0, 0, 0 ), fromspace: () => 'world', tospace: () => 'world', } ), - new MXElement( 'normalmap', normalMap, [ 'in', 'scale' ], { in: defaultVec3( 0.5, 0.5, 1.0 ), scale: defaultFloat( 1 ) } ), - new MXElement( 'transpose', transpose, [ 'in' ] ), - new MXElement( 'determinant', determinant, [ 'in' ] ), - new MXElement( 'invertmatrix', inverse, [ 'in' ] ), - new MXElement( 'creatematrix', mat3, [ 'in1', 'in2', 'in3' ], { + createMXElement( 'normalmap', normalMap, [ 'in', 'scale' ], { in: defaultVec3( 0.5, 0.5, 1.0 ), scale: defaultFloat( 1 ) } ), + createMXElement( 'transpose', transpose, [ 'in' ] ), + createMXElement( 'determinant', determinant, [ 'in' ] ), + createMXElement( 'invertmatrix', inverse, [ 'in' ] ), + createMXElement( 'creatematrix', mat3, [ 'in1', 'in2', 'in3' ], { in1: defaultVec3( 1, 0, 0 ), in2: defaultVec3( 0, 1, 0 ), in3: defaultVec3( 0, 0, 1 ), } ), - new MXElement( 'remap', remap, [ 'in', 'inlow', 'inhigh', 'outlow', 'outhigh' ], { + createMXElement( 'remap', remap, [ 'in', 'inlow', 'inhigh', 'outlow', 'outhigh' ], { in: defaultFloat( 0 ), inlow: defaultFloat( 0 ), inhigh: defaultFloat( 1 ), outlow: defaultFloat( 0 ), outhigh: defaultFloat( 1 ), } ), - new MXElement( 'range', mx_range, [ 'in', 'inlow', 'inhigh', 'outlow', 'outhigh', 'gamma' ], { + createMXElement( 'range', mx_range, [ 'in', 'inlow', 'inhigh', 'outlow', 'outhigh', 'gamma' ], { in: defaultFloat( 0 ), inlow: defaultFloat( 0 ), inhigh: defaultFloat( 1 ), @@ -587,53 +576,53 @@ const MXElements = [ outhigh: defaultFloat( 1 ), gamma: defaultFloat( 1 ), } ), - new MXElement( 'open_pbr_anisotropy', mx_open_pbr_anisotropy, [ 'roughness', 'anisotropy' ], { + createMXElement( 'open_pbr_anisotropy', mx_open_pbr_anisotropy, [ 'roughness', 'anisotropy' ], { roughness: defaultFloat( 0 ), anisotropy: defaultFloat( 0 ), } ), - new MXElement( 'smoothstep', mx_smoothstep, [ 'in', 'low', 'high' ], { + createMXElement( 'smoothstep', mx_smoothstep, [ 'in', 'low', 'high' ], { in: defaultFloat( 0 ), low: defaultFloat( 0 ), high: defaultFloat( 1 ), } ), - new MXElement( 'luminance', luminance, [ 'in', 'lumacoeffs' ], { + createMXElement( 'luminance', luminance, [ 'in', 'lumacoeffs' ], { in: defaultColor( 0, 0, 0 ), lumacoeffs: defaultColor( 0.2722287, 0.6740818, 0.0536895 ), } ), - new MXElement( 'rgbtohsv', mx_rgbtohsv, [ 'in' ], { in: defaultColor( 0, 0, 0 ) } ), - new MXElement( 'hsvtorgb', mx_hsvtorgb, [ 'in' ], { in: defaultColor( 0, 0, 0 ) } ), - new MXElement( 'mix', mix, [ 'bg', 'fg', 'mix' ], { bg: defaultFloat( 0 ), fg: defaultFloat( 0 ), mix: defaultFloat( 0 ) } ), - new MXElement( 'minus', mx_minus, [ 'fg', 'bg', 'mix' ], { + createMXElement( 'rgbtohsv', mx_rgbtohsv, [ 'in' ], { in: defaultColor( 0, 0, 0 ) } ), + createMXElement( 'hsvtorgb', mx_hsvtorgb, [ 'in' ], { in: defaultColor( 0, 0, 0 ) } ), + createMXElement( 'mix', mix, [ 'bg', 'fg', 'mix' ], { bg: defaultFloat( 0 ), fg: defaultFloat( 0 ), mix: defaultFloat( 0 ) } ), + createMXElement( 'minus', mx_minus, [ 'fg', 'bg', 'mix' ], { fg: defaultFloat( 0 ), bg: defaultFloat( 0 ), mix: defaultFloat( 1 ), } ), - new MXElement( 'difference', mx_difference, [ 'fg', 'bg', 'mix' ], { + createMXElement( 'difference', mx_difference, [ 'fg', 'bg', 'mix' ], { fg: defaultFloat( 0 ), bg: defaultFloat( 0 ), mix: defaultFloat( 1 ), } ), - new MXElement( 'screen', mx_screen, [ 'fg', 'bg', 'mix' ], { + createMXElement( 'screen', mx_screen, [ 'fg', 'bg', 'mix' ], { fg: defaultFloat( 0 ), bg: defaultFloat( 0 ), mix: defaultFloat( 1 ), } ), - new MXElement( 'overlay', mx_overlay, [ 'fg', 'bg', 'mix' ], { + createMXElement( 'overlay', mx_overlay, [ 'fg', 'bg', 'mix' ], { fg: defaultFloat( 0 ), bg: defaultFloat( 0 ), mix: defaultFloat( 1 ), } ), - new MXElement( 'burn', mx_burn, [ 'fg', 'bg', 'mix' ], { + createMXElement( 'burn', mx_burn, [ 'fg', 'bg', 'mix' ], { fg: defaultFloat( 0 ), bg: defaultFloat( 0 ), mix: defaultFloat( 1 ), } ), - new MXElement( 'dodge', mx_dodge, [ 'fg', 'bg', 'mix' ], { + createMXElement( 'dodge', mx_dodge, [ 'fg', 'bg', 'mix' ], { fg: defaultFloat( 0 ), bg: defaultFloat( 0 ), mix: defaultFloat( 1 ), } ), - new MXElement( + createMXElement( 'colorcorrect', mx_colorcorrect, [ 'in', 'hue', 'saturation', 'gamma', 'lift', 'gain', 'contrast', 'contrastpivot', 'exposure' ], @@ -649,35 +638,35 @@ const MXElements = [ exposure: defaultFloat( 0 ), }, ), - new MXElement( 'unpremult', mx_unpremult, [ 'in' ], { in: defaultVec4( 0, 0, 0, 1 ) } ), - new MXElement( 'combine2', vec2, [ 'in1', 'in2' ], { in1: defaultFloat( 0 ), in2: defaultFloat( 0 ) } ), - new MXElement( 'combine3', vec3, [ 'in1', 'in2', 'in3' ], { + createMXElement( 'unpremult', mx_unpremult, [ 'in' ], { in: defaultVec4( 0, 0, 0, 1 ) } ), + createMXElement( 'combine2', vec2, [ 'in1', 'in2' ], { in1: defaultFloat( 0 ), in2: defaultFloat( 0 ) } ), + createMXElement( 'combine3', vec3, [ 'in1', 'in2', 'in3' ], { in1: defaultFloat( 0 ), in2: defaultFloat( 0 ), in3: defaultFloat( 0 ), } ), - new MXElement( 'combine4', vec4, [ 'in1', 'in2', 'in3', 'in4' ], { + createMXElement( 'combine4', vec4, [ 'in1', 'in2', 'in3', 'in4' ], { in1: defaultFloat( 0 ), in2: defaultFloat( 0 ), in3: defaultFloat( 0 ), in4: defaultFloat( 0 ), } ), - new MXElement( 'ramplr', mx_ramplr, [ 'valuel', 'valuer', 'texcoord' ], { + createMXElement( 'ramplr', mx_ramplr, [ 'valuel', 'valuer', 'texcoord' ], { valuel: defaultFloat( 0 ), valuer: defaultFloat( 0 ), } ), - new MXElement( 'ramptb', mx_ramptb, [ 'valuet', 'valueb', 'texcoord' ], { + createMXElement( 'ramptb', mx_ramptb, [ 'valuet', 'valueb', 'texcoord' ], { valuet: defaultFloat( 0 ), valueb: defaultFloat( 0 ), } ), - new MXElement( 'ramp4', mx_ramp4, [ 'valuetl', 'valuetr', 'valuebl', 'valuebr', 'texcoord' ], { + createMXElement( 'ramp4', mx_ramp4, [ 'valuetl', 'valuetr', 'valuebl', 'valuebr', 'texcoord' ], { valuetl: defaultColor( 0, 0, 0 ), valuetr: defaultColor( 0, 0, 0 ), valuebl: defaultColor( 0, 0, 0 ), valuebr: defaultColor( 0, 0, 0 ), texcoord: defaultVec2( 0, 0 ), } ), - new MXElement( + createMXElement( 'ramp_gradient', mx_ramp_gradient, [ 'x', 'interval1', 'interval2', 'color1', 'color2', 'interpolation', 'prev_color', 'interval_num', 'num_intervals' ], @@ -693,7 +682,7 @@ const MXElements = [ num_intervals: defaultFloat( 2 ), }, ), - new MXElement( + createMXElement( 'ramp', mx_ramp, [ @@ -749,46 +738,46 @@ const MXElements = [ color10: defaultVec4( 1, 1, 1, 1 ), }, ), - new MXElement( 'splitlr', mx_splitlr, [ 'valuel', 'valuer', 'center', 'texcoord' ], { + createMXElement( 'splitlr', mx_splitlr, [ 'valuel', 'valuer', 'center', 'texcoord' ], { valuel: defaultFloat( 0 ), valuer: defaultFloat( 0 ), center: defaultFloat( 0.5 ), } ), - new MXElement( 'splittb', mx_splittb, [ 'valuet', 'valueb', 'center', 'texcoord' ], { + createMXElement( 'splittb', mx_splittb, [ 'valuet', 'valueb', 'center', 'texcoord' ], { valuet: defaultFloat( 0 ), valueb: defaultFloat( 0 ), center: defaultFloat( 0.5 ), } ), - new MXElement( 'noise2d', mx_noise_float, [ 'texcoord', 'amplitude', 'pivot' ], { + createMXElement( 'noise2d', mx_noise_float, [ 'texcoord', 'amplitude', 'pivot' ], { texcoord: defaultVec2( 0, 0 ), amplitude: defaultFloat( 1 ), pivot: defaultFloat( 0 ), } ), - new MXElement( 'noise3d', mx_noise_float, [ 'position', 'amplitude', 'pivot' ], { + createMXElement( 'noise3d', mx_noise_float, [ 'position', 'amplitude', 'pivot' ], { position: () => positionLocal, amplitude: defaultFloat( 1 ), pivot: defaultFloat( 0 ), } ), - new MXElement( 'fractal3d', mx_fractal_noise_float, [ 'position', 'octaves', 'lacunarity', 'diminish', 'amplitude' ], { + createMXElement( 'fractal3d', mx_fractal_noise_float, [ 'position', 'octaves', 'lacunarity', 'diminish', 'amplitude' ], { position: () => positionLocal, octaves: defaultInt( 3 ), lacunarity: defaultFloat( 2.0 ), diminish: defaultFloat( 0.5 ), amplitude: defaultFloat( 1.0 ), } ), - new MXElement( 'cellnoise2d', mx_cell_noise_float, [ 'texcoord' ], { texcoord: defaultVec2( 0, 0 ) } ), - new MXElement( 'cellnoise3d', mx_cell_noise_float, [ 'position' ], { position: () => positionLocal } ), - new MXElement( 'worleynoise2d', mx_worley_noise_float_2d, [ 'texcoord', 'jitter', 'style' ], { + createMXElement( 'cellnoise2d', mx_cell_noise_float, [ 'texcoord' ], { texcoord: defaultVec2( 0, 0 ) } ), + createMXElement( 'cellnoise3d', mx_cell_noise_float, [ 'position' ], { position: () => positionLocal } ), + createMXElement( 'worleynoise2d', mx_worley_noise_float_2d, [ 'texcoord', 'jitter', 'style' ], { texcoord: defaultVec2( 0, 0 ), jitter: defaultFloat( 1 ), style: defaultInt( 0 ), } ), - new MXElement( 'worleynoise3d', mx_worley_noise_float_3d, [ 'position', 'jitter', 'style' ], { + createMXElement( 'worleynoise3d', mx_worley_noise_float_3d, [ 'position', 'jitter', 'style' ], { position: () => positionLocal, jitter: defaultFloat( 1 ), style: defaultInt( 0 ), } ), - new MXElement( + createMXElement( 'unifiednoise2d', mx_unifiednoise2d, [ @@ -820,7 +809,7 @@ const MXElements = [ style: defaultInt( 0 ), }, ), - new MXElement( + createMXElement( 'unifiednoise3d', mx_unifiednoise3d, [ @@ -852,7 +841,7 @@ const MXElements = [ style: defaultInt( 0 ), }, ), - new MXElement( 'place2d', mx_place2d, [ 'texcoord', 'pivot', 'scale', 'rotate', 'offset', 'operationorder' ], { + createMXElement( 'place2d', mx_place2d, [ 'texcoord', 'pivot', 'scale', 'rotate', 'offset', 'operationorder' ], { texcoord: defaultVec2( 0, 0 ), pivot: defaultVec2( 0, 0 ), scale: defaultVec2( 1, 1 ), @@ -860,71 +849,66 @@ const MXElements = [ offset: defaultVec2( 0, 0 ), operationorder: defaultInt( 0 ), } ), - new MXElement( 'safepower', mx_safepower, [ 'in1', 'in2' ], { in1: defaultFloat( 0 ), in2: defaultFloat( 1 ) } ), - new MXElement( 'contrast', mx_contrast, [ 'in', 'amount', 'pivot' ], { + createMXElement( 'safepower', mx_safepower, [ 'in1', 'in2' ], { in1: defaultFloat( 0 ), in2: defaultFloat( 1 ) } ), + createMXElement( 'contrast', mx_contrast, [ 'in', 'amount', 'pivot' ], { in: defaultFloat( 0 ), amount: defaultFloat( 1 ), pivot: defaultFloat( 0.5 ), } ), - new MXElement( 'saturate', mx_saturation, [ 'in', 'amount' ], { in: defaultColor( 0, 0, 0 ), amount: defaultFloat( 1 ) } ), - new MXElement( 'extract', element, [ 'in', 'index' ], { in: defaultFloat( 0 ), index: defaultInt( 0 ) } ), - new MXElement( 'separate2', element, [ 'in' ], { in: defaultVec2( 0, 0 ) } ), - new MXElement( 'separate3', element, [ 'in' ], { in: defaultVec3( 0, 0, 0 ) } ), - new MXElement( 'separate4', element, [ 'in' ], { in: defaultVec4( 0, 0, 0, 0 ) } ), - new MXElement( 'reflect', reflect, [ 'in', 'normal' ], { in: defaultVec3( 1, 0, 0 ) } ), - new MXElement( 'refract', refract, [ 'in', 'normal', 'ior' ], { in: defaultVec3( 1, 0, 0 ), ior: defaultFloat( 1 ) } ), - new MXElement( 'time', mx_timer ), - new MXElement( 'frame', mx_frame ), - new MXElement( 'ifgreater', mx_ifgreater, [ 'value1', 'value2', 'in1', 'in2' ], { + createMXElement( 'saturate', mx_saturation, [ 'in', 'amount' ], { in: defaultColor( 0, 0, 0 ), amount: defaultFloat( 1 ) } ), + createMXElement( 'extract', element, [ 'in', 'index' ], { in: defaultFloat( 0 ), index: defaultInt( 0 ) } ), + createMXElement( 'separate2', element, [ 'in' ], { in: defaultVec2( 0, 0 ) } ), + createMXElement( 'separate3', element, [ 'in' ], { in: defaultVec3( 0, 0, 0 ) } ), + createMXElement( 'separate4', element, [ 'in' ], { in: defaultVec4( 0, 0, 0, 0 ) } ), + createMXElement( 'reflect', reflect, [ 'in', 'normal' ], { in: defaultVec3( 1, 0, 0 ) } ), + createMXElement( 'refract', refract, [ 'in', 'normal', 'ior' ], { in: defaultVec3( 1, 0, 0 ), ior: defaultFloat( 1 ) } ), + createMXElement( 'time', mx_timer ), + createMXElement( 'frame', mx_frame ), + createMXElement( 'ifgreater', mx_ifgreater, [ 'value1', 'value2', 'in1', 'in2' ], { value1: defaultFloat( 1 ), value2: defaultFloat( 0 ), in1: defaultFloat( 0 ), in2: defaultFloat( 0 ), } ), - new MXElement( 'ifgreatereq', mx_ifgreatereq, [ 'value1', 'value2', 'in1', 'in2' ], { + createMXElement( 'ifgreatereq', mx_ifgreatereq, [ 'value1', 'value2', 'in1', 'in2' ], { value1: defaultFloat( 1 ), value2: defaultFloat( 0 ), in1: defaultFloat( 0 ), in2: defaultFloat( 0 ), } ), - new MXElement( 'ifequal', mx_ifequal, [ 'value1', 'value2', 'in1', 'in2' ], { + createMXElement( 'ifequal', mx_ifequal, [ 'value1', 'value2', 'in1', 'in2' ], { value1: defaultFloat( 0 ), value2: defaultFloat( 0 ), in1: defaultFloat( 0 ), in2: defaultFloat( 0 ), } ), - new MXElement( 'rotate2d', mx_rotate2d, [ 'in', 'amount' ], { in: defaultVec2( 0, 0 ), amount: defaultFloat( 0 ) } ), - new MXElement( 'rotate3d', mx_rotate3d, [ 'in', 'amount', 'axis' ], { + createMXElement( 'rotate2d', mx_rotate2d, [ 'in', 'amount' ], { in: defaultVec2( 0, 0 ), amount: defaultFloat( 0 ) } ), + createMXElement( 'rotate3d', mx_rotate3d, [ 'in', 'amount', 'axis' ], { in: defaultVec3( 0, 0, 0 ), amount: defaultFloat( 0 ), axis: defaultVec3( 0, 1, 0 ), } ), - new MXElement( 'heighttonormal', mx_heighttonormal, [ 'in', 'scale', 'texcoord' ], { + createMXElement( 'heighttonormal', mx_heighttonormal, [ 'in', 'scale', 'texcoord' ], { in: defaultFloat( 0 ), scale: defaultFloat( 1 ), } ), - new MXElement( 'and', mx_and, [ 'in1', 'in2' ], { in1: defaultBool( false ), in2: defaultBool( false ) } ), - new MXElement( 'or', mx_or, [ 'in1', 'in2' ], { in1: defaultBool( false ), in2: defaultBool( false ) } ), - new MXElement( 'xor', mx_xor, [ 'in1', 'in2' ], { in1: defaultBool( false ), in2: defaultBool( false ) } ), - new MXElement( 'not', mx_not, [ 'in' ], { in: defaultBool( false ) } ), - new MXElement( 'checkerboard', mx_checkerboard, [ 'color1', 'color2', 'texcoord' ], { + createMXElement( 'and', mx_and, [ 'in1', 'in2' ], { in1: defaultBool( false ), in2: defaultBool( false ) } ), + createMXElement( 'or', mx_or, [ 'in1', 'in2' ], { in1: defaultBool( false ), in2: defaultBool( false ) } ), + createMXElement( 'xor', mx_xor, [ 'in1', 'in2' ], { in1: defaultBool( false ), in2: defaultBool( false ) } ), + createMXElement( 'not', mx_not, [ 'in' ], { in: defaultBool( false ) } ), + createMXElement( 'checkerboard', mx_checkerboard, [ 'color1', 'color2', 'texcoord' ], { color1: defaultColor( 1, 1, 1 ), color2: defaultColor( 0, 0, 0 ), texcoord: defaultVec2( 0, 0 ), } ), - new MXElement( 'circle', mx_circle, [ 'texcoord', 'center', 'radius' ], { + createMXElement( 'circle', mx_circle, [ 'texcoord', 'center', 'radius' ], { center: defaultVec2( 0, 0 ), radius: defaultFloat( 0.5 ), } ), - new MXElement( 'bump', mx_bump, [ 'height', 'scale' ], { height: defaultFloat( 0 ), scale: defaultFloat( 1 ) } ), - new MXElement( 'blackbody', mx_blackbody, [ 'temperature' ], { temperature: defaultFloat( 5000 ) } ), + createMXElement( 'bump', mx_bump, [ 'height', 'scale' ], { height: defaultFloat( 0 ), scale: defaultFloat( 1 ) } ), + createMXElement( 'blackbody', mx_blackbody, [ 'temperature' ], { temperature: defaultFloat( 5000 ) } ), ]; -const MtlXLibrary = {}; -for ( const entry of MXElements ) { - - MtlXLibrary[ entry.name ] = entry; - -} +const MtlXLibrary = Object.fromEntries( MXElements.map( ( entry ) => [ entry.name, entry ] ) ); export { MtlXLibrary }; diff --git a/examples/jsm/loaders/materialx/MaterialXNodeRegistry.js b/examples/jsm/loaders/materialx/MaterialXNodeRegistry.js deleted file mode 100644 index f01c59efd5ec99..00000000000000 --- a/examples/jsm/loaders/materialx/MaterialXNodeRegistry.js +++ /dev/null @@ -1,86 +0,0 @@ -const materialXNodeCategories = new Set(); - -function hasMaterialXCategory( category ) { - - return materialXNodeCategories.has( category ); - -} - -function validateCategoryCoverage( { - compileCategories = [], - surfaceCategories = [], - allowUnknownCompileCategories = [], -} = {} ) { - - materialXNodeCategories.clear(); - - for ( const category of compileCategories ) { - - materialXNodeCategories.add( category ); - - } - - for ( const category of surfaceCategories ) { - - materialXNodeCategories.add( category ); - - } - - for ( const category of allowUnknownCompileCategories ) { - - materialXNodeCategories.add( category ); - - } - - const allowUnknownCompileSet = new Set( allowUnknownCompileCategories ); - const unknownCompile = []; - const unknownSurface = []; - - for ( const category of compileCategories ) { - - if ( ! hasMaterialXCategory( category ) && ! allowUnknownCompileSet.has( category ) ) { - - unknownCompile.push( category ); - - } - - } - - for ( const category of surfaceCategories ) { - - if ( ! hasMaterialXCategory( category ) ) { - - unknownSurface.push( category ); - - } - - } - - if ( unknownCompile.length === 0 && unknownSurface.length === 0 ) { - - return; - - } - - const details = []; - if ( unknownCompile.length > 0 ) { - - details.push( `unknown compile categories: ${unknownCompile.sort().join( ', ' )}` ); - - } - - if ( unknownSurface.length > 0 ) { - - details.push( `unknown surface categories: ${unknownSurface.sort().join( ', ' )}` ); - - } - - throw new Error( `MaterialX translator registry validation failed (${details.join( '; ' )}).` ); - -} - -export { - materialXNodeCategories, - hasMaterialXCategory, - validateCategoryCoverage, -}; diff --git a/examples/jsm/loaders/materialx/MaterialXSurfaceMappings.js b/examples/jsm/loaders/materialx/MaterialXSurfaceMappings.js index 45c78c905c23cf..46714617c681b1 100644 --- a/examples/jsm/loaders/materialx/MaterialXSurfaceMappings.js +++ b/examples/jsm/loaders/materialx/MaterialXSurfaceMappings.js @@ -630,8 +630,28 @@ const MaterialXSurfaceMappings = { open_pbr_surface: applyOpenPbrSurface, }; +const surfaceMapperRegistry = new Map( Object.entries( MaterialXSurfaceMappings ).map( ( [ category, apply ] ) => [ + category, + { category, apply }, +] ) ); + +function getSurfaceMapper( category ) { + + return surfaceMapperRegistry.get( category ); + +} + +function getSupportedSurfaceCategories() { + + return [ ...surfaceMapperRegistry.keys() ]; + +} + export { MaterialXSurfaceMappings, + surfaceMapperRegistry, + getSurfaceMapper, + getSupportedSurfaceCategories, applyStandardSurface, applyGltfPbrSurface, applyOpenPbrSurface, diff --git a/examples/jsm/loaders/materialx/MaterialXSurfaceRegistry.js b/examples/jsm/loaders/materialx/MaterialXSurfaceRegistry.js deleted file mode 100644 index 0235e5045e0ce3..00000000000000 --- a/examples/jsm/loaders/materialx/MaterialXSurfaceRegistry.js +++ /dev/null @@ -1,43 +0,0 @@ -import { - applyStandardSurface, - applyGltfPbrSurface, - applyOpenPbrSurface, - mappedStandardSurfaceInputs, - mappedGltfPbrInputs, - mappedOpenPbrInputs, -} from './MaterialXSurfaceMappings.js'; -import { toRegistryMap } from './MaterialXTranslatorTypes.js'; - -const surfaceMapperSpecs = [ - { - category: 'standard_surface', - mappedInputs: mappedStandardSurfaceInputs, - apply: applyStandardSurface, - }, - { - category: 'gltf_pbr', - mappedInputs: mappedGltfPbrInputs, - apply: applyGltfPbrSurface, - }, - { - category: 'open_pbr_surface', - mappedInputs: mappedOpenPbrInputs, - apply: applyOpenPbrSurface, - }, -]; - -const surfaceMapperRegistry = toRegistryMap( surfaceMapperSpecs, ( entry ) => entry.category, 'surface' ); - -function getSurfaceMapper( category ) { - - return surfaceMapperRegistry.get( category ); - -} - -function getSupportedSurfaceCategories() { - - return [ ...surfaceMapperRegistry.keys() ]; - -} - -export { surfaceMapperSpecs, surfaceMapperRegistry, getSurfaceMapper, getSupportedSurfaceCategories }; diff --git a/examples/jsm/loaders/materialx/MaterialXTranslatorTypes.js b/examples/jsm/loaders/materialx/MaterialXTranslatorTypes.js deleted file mode 100644 index 0e43bfea47e9de..00000000000000 --- a/examples/jsm/loaders/materialx/MaterialXTranslatorTypes.js +++ /dev/null @@ -1,57 +0,0 @@ -/** - * @typedef {Object} MaterialXPortSpec - * @property {string} name - * @property {string | undefined} [type] - */ - -/** - * @typedef {Object} MaterialXNodeSpec - * @property {string} category - * @property {string | undefined} [nodeDefName] - * @property {string | undefined} [type] - * @property {MaterialXPortSpec[]} inputs - * @property {MaterialXPortSpec[]} outputs - * @property {MaterialXPortSpec[]} parameters - */ - -/** - * @typedef {Object} MaterialXSurfaceMapperSpec - * @property {string} category - * @property {string[]} mappedInputs - * @property {(material: unknown, inputs: Record, issueCollector: unknown, nodeName: string | null) => void} apply - */ - -/** - * @typedef {Object} MaterialXCompileHandlerSpec - * @property {string} category - * @property {(nodeX: unknown, compileContext: unknown) => unknown} compile - */ - -/** - * @template T - * @param {readonly T[]} entries - * @param {(entry: T) => string} keySelector - * @param {string} label - * @returns {Map} - */ -function toRegistryMap( entries, keySelector, label ) { - - const map = new Map(); - for ( const entry of entries ) { - - const key = keySelector( entry ); - if ( map.has( key ) ) { - - throw new Error( `Duplicate ${label} registry key "${key}".` ); - - } - - map.set( key, entry ); - - } - - return map; - -} - -export { toRegistryMap }; diff --git a/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js b/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js index c42aa3f8b7c509..7717c6815ab7fc 100644 --- a/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js +++ b/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js @@ -39,6 +39,27 @@ const register = ( registry, categories, handler ) => { const UV_FALLBACK_CATEGORIES = new Set( [ 'noise2d', 'cellnoise2d', 'worleynoise2d', 'unifiednoise2d' ] ); +const getDefaultUvNode = ( compileContext ) => compileContext.mxToUvSpace( uv( 0 ) ); + +const getTextureInputs = ( nodeX, compileContext ) => { + + const file = nodeX.getChildByName( 'file' ); + const uvNode = nodeX.getNodeByName( 'texcoord' ) || getDefaultUvNode( compileContext ); + const textureFile = file ? file.getTexture() : null; + return { file, uvNode, textureFile }; + +}; + +const sampleTexture = ( textureFile, uvNode, compileContext, fallback ) => + textureFile ? texture( textureFile, compileContext.mxFromUvSpace( uvNode ) ) : fallback; + +const applyTextureColorSpace = ( node, file ) => { + + const colorSpaceNode = file ? file.getColorSpaceNode() : null; + return colorSpaceNode ? colorSpaceNode( node ) : node; + +}; + const compileConvertNode = ( nodeX ) => { const nodeClass = nodeX.getClassFromType( nodeX.type ) || float; @@ -74,44 +95,26 @@ const compileGeomColorNode = ( nodeX ) => { const compileImageLikeNode = ( nodeX, compileContext ) => { - const file = nodeX.getChildByName( 'file' ); - const uvNode = nodeX.getNodeByName( 'texcoord' ) || compileContext.mxToUvSpace( uv( 0 ) ); - const textureFile = file ? file.getTexture() : null; - let node = textureFile ? texture( textureFile, compileContext.mxFromUvSpace( uvNode ) ) : vec4( 0, 0, 0, 1 ); - const colorSpaceNode = file ? file.getColorSpaceNode() : null; - if ( colorSpaceNode ) { - - node = colorSpaceNode( node ); - - } - - return node; + const { file, uvNode, textureFile } = getTextureInputs( nodeX, compileContext ); + const node = sampleTexture( textureFile, uvNode, compileContext, vec4( 0, 0, 0, 1 ) ); + return applyTextureColorSpace( node, file ); }; const compileTiledImageNode = ( nodeX, compileContext ) => { - const file = nodeX.getChildByName( 'file' ); - const textureFile = file ? file.getTexture() : null; + const { file, uvNode, textureFile } = getTextureInputs( nodeX, compileContext ); if ( ! textureFile ) { return vec4( 0, 0, 0, 1 ); } - const uvNode = nodeX.getNodeByName( 'texcoord' ) || compileContext.mxToUvSpace( uv( 0 ) ); const uvTiling = nodeX.getNodeByName( 'uvtiling' ); const uvOffset = nodeX.getNodeByName( 'uvoffset' ); const transformedUv = compileContext.mxTransformUv( uvTiling, uvOffset, uvNode ); - let node = texture( textureFile, compileContext.mxFromUvSpace( transformedUv ) ); - const colorSpaceNode = file.getColorSpaceNode(); - if ( colorSpaceNode ) { - - node = colorSpaceNode( node ); - - } - - return node; + const node = sampleTexture( textureFile, transformedUv, compileContext, vec4( 0, 0, 0, 1 ) ); + return applyTextureColorSpace( node, file ); }; @@ -129,7 +132,7 @@ const compileHexTiledTextureNode = ( nodeX, compileContext, category ) => { } const textureFile = file.getTexture(); - const uvNode = nodeX.getNodeByName( 'texcoord' ) || compileContext.mxToUvSpace( uv( 0 ) ); + const uvNode = nodeX.getNodeByName( 'texcoord' ) || getDefaultUvNode( compileContext ); const tiling = nodeX.getNodeByName( 'tiling' ) || vec2( 1, 1 ); const rotation = nodeX.getNodeByName( 'rotation' ) || float( 1 ); const rotationRange = nodeX.getNodeByName( 'rotationrange' ) || vec2( 0, 360 ); @@ -199,17 +202,8 @@ const compileHexTiledTextureNode = ( nodeX, compileContext, category ) => { const compileGltfTextureNode = ( nodeX, compileContext, category ) => { - const file = nodeX.getChildByName( 'file' ); - const uvNode = nodeX.getNodeByName( 'texcoord' ) || compileContext.mxToUvSpace( uv( 0 ) ); - const textureFile = file ? file.getTexture() : null; - let node = textureFile ? texture( textureFile, compileContext.mxFromUvSpace( uvNode ) ) : float( 0 ); - - const colorSpaceNode = file ? file.getColorSpaceNode() : null; - if ( colorSpaceNode ) { - - node = colorSpaceNode( node ); - - } + const { file, uvNode, textureFile } = getTextureInputs( nodeX, compileContext ); + const node = applyTextureColorSpace( sampleTexture( textureFile, uvNode, compileContext, float( 0 ) ), file ); if ( category === 'gltf_normalmap' ) { @@ -224,10 +218,8 @@ const compileGltfTextureNode = ( nodeX, compileContext, category ) => { const compileGltfColorImageNode = ( nodeX, out, compileContext ) => { - const file = nodeX.getChildByName( 'file' ); - const uvNode = nodeX.getNodeByName( 'texcoord' ) || compileContext.mxToUvSpace( uv( 0 ) ); - const textureFile = file ? file.getTexture() : null; - const sampled = textureFile ? texture( textureFile, compileContext.mxFromUvSpace( uvNode ) ) : vec4( 0, 0, 0, 1 ); + const { file, uvNode, textureFile } = getTextureInputs( nodeX, compileContext ); + const sampled = sampleTexture( textureFile, uvNode, compileContext, vec4( 0, 0, 0, 1 ) ); if ( out === 'outa' || out === 'a' ) { @@ -235,27 +227,16 @@ const compileGltfColorImageNode = ( nodeX, out, compileContext ) => { } - const colorSpaceNode = file ? file.getColorSpaceNode() : null; - if ( colorSpaceNode ) { - - const converted = colorSpaceNode( sampled ); - return vec3( element( converted, 0 ), element( converted, 1 ), element( converted, 2 ) ); - - } - - return vec3( element( sampled, 0 ), element( sampled, 1 ), element( sampled, 2 ) ); + const converted = applyTextureColorSpace( sampled, file ); + return vec3( element( converted, 0 ), element( converted, 1 ), element( converted, 2 ) ); }; const compileGltfAnisotropyImageNode = ( nodeX, out, compileContext ) => { - const file = nodeX.getChildByName( 'file' ); - const uvNode = nodeX.getNodeByName( 'texcoord' ) || compileContext.mxToUvSpace( uv( 0 ) ); + const { uvNode, textureFile } = getTextureInputs( nodeX, compileContext ); const defaultInput = nodeX.getNodeByName( 'default' ) || vec3( 1, 0.5, 1 ); - const textureFile = file ? file.getTexture() : null; - const sampled = textureFile - ? texture( textureFile, compileContext.mxFromUvSpace( uvNode ) ) - : vec4( element( defaultInput, 0 ), element( defaultInput, 1 ), element( defaultInput, 2 ), 1 ); + const sampled = sampleTexture( textureFile, uvNode, compileContext, vec4( element( defaultInput, 0 ), element( defaultInput, 1 ), element( defaultInput, 2 ), 1 ) ); const anisotropyStrengthFactor = nodeX.getNodeByName( 'anisotropy_strength' ) || float( 1 ); const anisotropyRotationFactor = nodeX.getNodeByName( 'anisotropy_rotation' ) || float( 0 ); const encodedDirection = vec2( sub( mul( element( sampled, 0 ), 2 ), 1 ), sub( mul( element( sampled, 1 ), 2 ), 1 ) ); @@ -275,10 +256,8 @@ const compileGltfAnisotropyImageNode = ( nodeX, out, compileContext ) => { const compileGltfIridescenceThicknessNode = ( nodeX, compileContext ) => { - const file = nodeX.getChildByName( 'file' ); - const uvNode = nodeX.getNodeByName( 'texcoord' ) || compileContext.mxToUvSpace( uv( 0 ) ); - const textureFile = file ? file.getTexture() : null; - const sampled = textureFile ? texture( textureFile, compileContext.mxFromUvSpace( uvNode ) ) : vec4( 0, 0, 0, 1 ); + const { uvNode, textureFile } = getTextureInputs( nodeX, compileContext ); + const sampled = sampleTexture( textureFile, uvNode, compileContext, vec4( 0, 0, 0, 1 ) ); const sampledThickness = element( sampled, 0 ); const thicknessMin = nodeX.getNodeByName( 'thicknessMin' ) || float( 100 ); const thicknessMax = nodeX.getNodeByName( 'thicknessMax' ) || float( 400 ); @@ -415,7 +394,7 @@ function compileNodeFromRegistry( nodeX, out, compileContext ) { const paramName = nodeElement.params[ i ]; if ( paramName === 'texcoord' && UV_FALLBACK_CATEGORIES.has( nodeX.element ) ) { - args[ i ] = compileContext.mxToUvSpace( uv( 0 ) ); + args[ i ] = getDefaultUvNode( compileContext ); continue; } From f4870357ab3fd1835531cb5414664defdcc5a3cf Mon Sep 17 00:00:00 2001 From: Ben Houston Date: Mon, 27 Apr 2026 15:48:58 -0400 Subject: [PATCH 07/40] more consolidation of MaterialX nodes. --- .../loaders/materialx/MaterialXDocument.js | 186 +----------------- .../jsm/loaders/materialx/MaterialXHextile.js | 183 +++++++++++++++++ .../loaders/materialx/MaterialXNodeLibrary.js | 121 ++++-------- .../{lib/mx_hsv.js => MaterialXColor.js} | 9 +- ...rm_color.js => MaterialXColorTransform.js} | 9 +- src/nodes/materialx/MaterialXNodes.js | 6 +- .../{lib/mx_noise.js => MaterialXNoise.js} | 53 ++--- 7 files changed, 261 insertions(+), 306 deletions(-) create mode 100644 examples/jsm/loaders/materialx/MaterialXHextile.js rename src/nodes/materialx/{lib/mx_hsv.js => MaterialXColor.js} (88%) rename src/nodes/materialx/{lib/mx_transform_color.js => MaterialXColorTransform.js} (63%) rename src/nodes/materialx/{lib/mx_noise.js => MaterialXNoise.js} (97%) diff --git a/examples/jsm/loaders/materialx/MaterialXDocument.js b/examples/jsm/loaders/materialx/MaterialXDocument.js index 7a0145dcca51ec..92b9d6299cf916 100644 --- a/examples/jsm/loaders/materialx/MaterialXDocument.js +++ b/examples/jsm/loaders/materialx/MaterialXDocument.js @@ -10,29 +10,13 @@ import { } from 'three/webgpu'; import { - abs, - add, - clamp, - cos, - div, - dot, float, - floor, - fract, int, - max, - mix, - mul, - pow, - sin, - step, sub, vec2, vec3, vec4, color, - dFdx, - dFdy, uv, mat3, mat4, @@ -46,6 +30,7 @@ import { createMaterialXCompileRegistry, compileNodeFromRegistry } from './compi import { parseMaterialXNodeTree, parseMaterialXText } from './parse/MaterialXParser.js'; import { getSurfaceMapper } from './MaterialXSurfaceMappings.js'; import { MtlXLibrary } from './MaterialXNodeLibrary.js'; +import { mxHextileCoord, mxHextileComputeBlendWeights } from './MaterialXHextile.js'; const colorSpaceLib = { mx_srgb_texture_to_lin_rec709, @@ -54,9 +39,6 @@ const colorSpaceLib = { 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 HEXTILE_SQRT3_2 = Math.sqrt( 3 ) * 2; -const HEXTILE_EPSILON = 1e-6; -const HEXTILE_PI_OVER_180 = Math.PI / 180; const COMPILE_REGISTRY = createMaterialXCompileRegistry(); const NODE_CLASS_BY_TYPE = { integer: int, @@ -85,174 +67,14 @@ const OUTPUT_CHANNELS = { a: 3, }; -function toRadians( degrees ) { - - return mul( degrees, HEXTILE_PI_OVER_180 ); - -} - -function mxToUvSpace( uvNode ) { - - return vec2( element( uvNode, 0 ), sub( 1, element( uvNode, 1 ) ) ); - -} - -function mxFromUvSpace( uvNode ) { +function mxFlipUvY( uvNode ) { return vec2( element( uvNode, 0 ), sub( 1, element( uvNode, 1 ) ) ); } -function mxHextileHash( point ) { - - const x = element( point, 0 ); - const y = element( point, 1 ); - const p3Base = vec3( x, y, x ); - const p3Scaled = mul( p3Base, vec3( 0.1031, 0.103, 0.0973 ) ); - const p3Fract = fract( p3Scaled ); - const p3YZX = vec3( element( p3Fract, 1 ), element( p3Fract, 2 ), element( p3Fract, 0 ) ); - const p3Offset = add( p3YZX, 33.33 ); - const p3 = add( p3Fract, dot( p3Fract, p3Offset ) ); - const lhs = add( vec2( element( p3, 0 ), element( p3, 0 ) ), vec2( element( p3, 1 ), element( p3, 2 ) ) ); - const rhs = vec2( element( p3, 2 ), element( p3, 1 ) ); - return fract( mul( lhs, rhs ) ); - -} - -function mxSchlickGain( x, r ) { - - const rr = clamp( r, 0.001, 0.999 ); - const a = mul( sub( div( 1, rr ), 2 ), sub( 1, mul( 2, x ) ) ); - const low = div( x, add( a, 1 ) ); - const high = div( sub( a, x ), sub( a, 1 ) ); - return mix( low, high, step( 0.5, x ) ); - -} - -function normalizeBlendWeights( weights ) { - - const wx = element( weights, 0 ); - const wy = element( weights, 1 ); - const wz = element( weights, 2 ); - const sum = max( add( add( wx, wy ), wz ), HEXTILE_EPSILON ); - return div( weights, sum ); - -} - -function mxHextileComputeBlendWeights( luminanceWeights, tileWeights, falloff ) { - - const weighted = mul( luminanceWeights, pow( max( tileWeights, vec3( HEXTILE_EPSILON, HEXTILE_EPSILON, HEXTILE_EPSILON ) ), vec3( 7, 7, 7 ) ) ); - const normalized = normalizeBlendWeights( weighted ); - const gained = vec3( - mxSchlickGain( element( normalized, 0 ), falloff ), - mxSchlickGain( element( normalized, 1 ), falloff ), - mxSchlickGain( element( normalized, 2 ), falloff ), - ); - const gainedNormalized = normalizeBlendWeights( gained ); - const applyFalloff = step( HEXTILE_EPSILON, abs( sub( falloff, 0.5 ) ) ); - return mix( normalized, gainedNormalized, applyFalloff ); - -} - -function mxRotate2d( point, sine, cosine ) { - - return vec2( sub( mul( cosine, element( point, 0 ) ), mul( sine, element( point, 1 ) ) ), add( mul( sine, element( point, 0 ) ), mul( cosine, element( point, 1 ) ) ) ); - -} - -function mxHextileCoord( coord, rotation, rotationRange, scale, scaleRange, offset, offsetRange ) { - - const st = mul( coord, HEXTILE_SQRT3_2 ); - const stSkewed = vec2( add( element( st, 0 ), mul( - 0.57735027, element( st, 1 ) ) ), mul( 1.15470054, element( st, 1 ) ) ); - const stFrac = fract( stSkewed ); - const tx = element( stFrac, 0 ); - const ty = element( stFrac, 1 ); - const tz = sub( sub( 1, tx ), ty ); - const s = step( 0, sub( 0, tz ) ); - const s2 = sub( mul( 2, s ), 1 ); - const w1 = mul( sub( 0, tz ), s2 ); - const w2 = sub( s, mul( ty, s2 ) ); - const w3 = sub( s, mul( tx, s2 ) ); - const baseId = floor( stSkewed ); - const oneMinusS = sub( 1, s ); - const id1 = add( baseId, vec2( s, s ) ); - const id2 = add( baseId, vec2( s, oneMinusS ) ); - const id3 = add( baseId, vec2( oneMinusS, s ) ); - - const toTileCenter = ( tileId ) => { - - const scaled = div( tileId, HEXTILE_SQRT3_2 ); - const sx = element( scaled, 0 ); - const sy = element( scaled, 1 ); - return vec2( add( sx, mul( 0.5, sy ) ), mul( 0.8660254, sy ) ); - - }; - - const ctr1 = toTileCenter( id1 ); - const ctr2 = toTileCenter( id2 ); - const ctr3 = toTileCenter( id3 ); - - const seedOffset = vec2( 0.12345, 0.12345 ); - const rand1 = mxHextileHash( add( id1, seedOffset ) ); - const rand2 = mxHextileHash( add( id2, seedOffset ) ); - const rand3 = mxHextileHash( add( id3, seedOffset ) ); - - const rr = vec2( toRadians( element( rotationRange, 0 ) ), toRadians( element( rotationRange, 1 ) ) ); - const rrMin = element( rr, 0 ); - const rrMax = element( rr, 1 ); - const randX = vec3( element( rand1, 0 ), element( rand2, 0 ), element( rand3, 0 ) ); - const rotations = mix( vec3( rrMin, rrMin, rrMin ), vec3( rrMax, rrMax, rrMax ), mul( randX, rotation ) ); - const randY = vec3( element( rand1, 1 ), element( rand2, 1 ), element( rand3, 1 ) ); - const scaleMin = element( scaleRange, 0 ); - const scaleMax = element( scaleRange, 1 ); - const randomScale = mix( vec3( scaleMin, scaleMin, scaleMin ), vec3( scaleMax, scaleMax, scaleMax ), randY ); - const scales = mix( vec3( 1, 1, 1 ), randomScale, scale ); - const offsetMin = element( offsetRange, 0 ); - const offsetMax = element( offsetRange, 1 ); - const offset1 = mix( vec2( offsetMin, offsetMin ), vec2( offsetMax, offsetMax ), mul( rand1, offset ) ); - const offset2 = mix( vec2( offsetMin, offsetMin ), vec2( offsetMax, offsetMax ), mul( rand2, offset ) ); - const offset3 = mix( vec2( offsetMin, offsetMin ), vec2( offsetMax, offsetMax ), mul( rand3, offset ) ); - - const sampleCoord = ( center, randomOffset, rotationValue, sampleScale ) => { - - const delta = sub( coord, center ); - const rotated = mxRotate2d( delta, sin( rotationValue ), cos( rotationValue ) ); - const safeScale = max( sampleScale, HEXTILE_EPSILON ); - return add( add( div( rotated, vec2( safeScale, safeScale ) ), center ), randomOffset ); - - }; - - const sampleDerivative = ( derivative, rotationValue, sampleScale ) => { - - const rotated = mxRotate2d( derivative, sin( rotationValue ), cos( rotationValue ) ); - const safeScale = max( sampleScale, HEXTILE_EPSILON ); - return div( rotated, vec2( safeScale, safeScale ) ); - - }; - - const ddx = dFdx( coord ); - const ddy = dFdy( coord ); - - return { - coords: [ - sampleCoord( ctr1, offset1, element( rotations, 0 ), element( scales, 0 ) ), - sampleCoord( ctr2, offset2, element( rotations, 1 ), element( scales, 1 ) ), - sampleCoord( ctr3, offset3, element( rotations, 2 ), element( scales, 2 ) ), - ], - ddx: [ - sampleDerivative( ddx, element( rotations, 0 ), element( scales, 0 ) ), - sampleDerivative( ddx, element( rotations, 1 ), element( scales, 1 ) ), - sampleDerivative( ddx, element( rotations, 2 ), element( scales, 2 ) ), - ], - ddy: [ - sampleDerivative( ddy, element( rotations, 0 ), element( scales, 0 ) ), - sampleDerivative( ddy, element( rotations, 1 ), element( scales, 1 ) ), - sampleDerivative( ddy, element( rotations, 2 ), element( scales, 2 ) ), - ], - weights: vec3( w1, w2, w3 ), - }; - -} +const mxToUvSpace = mxFlipUvY; +const mxFromUvSpace = mxFlipUvY; function isSvgUri( uri ) { diff --git a/examples/jsm/loaders/materialx/MaterialXHextile.js b/examples/jsm/loaders/materialx/MaterialXHextile.js new file mode 100644 index 00000000000000..ce10fb94a639f9 --- /dev/null +++ b/examples/jsm/loaders/materialx/MaterialXHextile.js @@ -0,0 +1,183 @@ +import { + abs, + add, + clamp, + cos, + dFdx, + dFdy, + div, + dot, + element, + floor, + fract, + max, + mix, + mul, + pow, + sin, + step, + sub, + vec2, + vec3, +} from 'three/tsl'; + +const HEXTILE_SQRT3_2 = Math.sqrt( 3 ) * 2; +const HEXTILE_EPSILON = 1e-6; +const HEXTILE_PI_OVER_180 = Math.PI / 180; + +function toRadians( degrees ) { + + return mul( degrees, HEXTILE_PI_OVER_180 ); + +} + +function mxHextileHash( point ) { + + const x = element( point, 0 ); + const y = element( point, 1 ); + const p3Base = vec3( x, y, x ); + const p3Scaled = mul( p3Base, vec3( 0.1031, 0.103, 0.0973 ) ); + const p3Fract = fract( p3Scaled ); + const p3YZX = vec3( element( p3Fract, 1 ), element( p3Fract, 2 ), element( p3Fract, 0 ) ); + const p3Offset = add( p3YZX, 33.33 ); + const p3 = add( p3Fract, dot( p3Fract, p3Offset ) ); + const lhs = add( vec2( element( p3, 0 ), element( p3, 0 ) ), vec2( element( p3, 1 ), element( p3, 2 ) ) ); + const rhs = vec2( element( p3, 2 ), element( p3, 1 ) ); + return fract( mul( lhs, rhs ) ); + +} + +function mxSchlickGain( x, r ) { + + const rr = clamp( r, 0.001, 0.999 ); + const a = mul( sub( div( 1, rr ), 2 ), sub( 1, mul( 2, x ) ) ); + const low = div( x, add( a, 1 ) ); + const high = div( sub( a, x ), sub( a, 1 ) ); + return mix( low, high, step( 0.5, x ) ); + +} + +function normalizeBlendWeights( weights ) { + + const wx = element( weights, 0 ); + const wy = element( weights, 1 ); + const wz = element( weights, 2 ); + const sum = max( add( add( wx, wy ), wz ), HEXTILE_EPSILON ); + return div( weights, sum ); + +} + +function mxRotate2d( point, sine, cosine ) { + + return vec2( sub( mul( cosine, element( point, 0 ) ), mul( sine, element( point, 1 ) ) ), add( mul( sine, element( point, 0 ) ), mul( cosine, element( point, 1 ) ) ) ); + +} + +function toTileCenter( tileId ) { + + const scaled = div( tileId, HEXTILE_SQRT3_2 ); + const sx = element( scaled, 0 ); + const sy = element( scaled, 1 ); + return vec2( add( sx, mul( 0.5, sy ) ), mul( 0.8660254, sy ) ); + +} + +export function mxHextileComputeBlendWeights( luminanceWeights, tileWeights, falloff ) { + + const weighted = mul( luminanceWeights, pow( max( tileWeights, vec3( HEXTILE_EPSILON, HEXTILE_EPSILON, HEXTILE_EPSILON ) ), vec3( 7, 7, 7 ) ) ); + const normalized = normalizeBlendWeights( weighted ); + const gained = vec3( + mxSchlickGain( element( normalized, 0 ), falloff ), + mxSchlickGain( element( normalized, 1 ), falloff ), + mxSchlickGain( element( normalized, 2 ), falloff ), + ); + const gainedNormalized = normalizeBlendWeights( gained ); + const applyFalloff = step( HEXTILE_EPSILON, abs( sub( falloff, 0.5 ) ) ); + return mix( normalized, gainedNormalized, applyFalloff ); + +} + +export function mxHextileCoord( coord, rotation, rotationRange, scale, scaleRange, offset, offsetRange ) { + + const st = mul( coord, HEXTILE_SQRT3_2 ); + const stSkewed = vec2( add( element( st, 0 ), mul( - 0.57735027, element( st, 1 ) ) ), mul( 1.15470054, element( st, 1 ) ) ); + const stFrac = fract( stSkewed ); + const tx = element( stFrac, 0 ); + const ty = element( stFrac, 1 ); + const tz = sub( sub( 1, tx ), ty ); + const s = step( 0, sub( 0, tz ) ); + const s2 = sub( mul( 2, s ), 1 ); + const w1 = mul( sub( 0, tz ), s2 ); + const w2 = sub( s, mul( ty, s2 ) ); + const w3 = sub( s, mul( tx, s2 ) ); + const baseId = floor( stSkewed ); + const oneMinusS = sub( 1, s ); + const id1 = add( baseId, vec2( s, s ) ); + const id2 = add( baseId, vec2( s, oneMinusS ) ); + const id3 = add( baseId, vec2( oneMinusS, s ) ); + + const ctr1 = toTileCenter( id1 ); + const ctr2 = toTileCenter( id2 ); + const ctr3 = toTileCenter( id3 ); + + const seedOffset = vec2( 0.12345, 0.12345 ); + const rand1 = mxHextileHash( add( id1, seedOffset ) ); + const rand2 = mxHextileHash( add( id2, seedOffset ) ); + const rand3 = mxHextileHash( add( id3, seedOffset ) ); + + const rr = vec2( toRadians( element( rotationRange, 0 ) ), toRadians( element( rotationRange, 1 ) ) ); + const rrMin = element( rr, 0 ); + const rrMax = element( rr, 1 ); + const randX = vec3( element( rand1, 0 ), element( rand2, 0 ), element( rand3, 0 ) ); + const rotations = mix( vec3( rrMin, rrMin, rrMin ), vec3( rrMax, rrMax, rrMax ), mul( randX, rotation ) ); + const randY = vec3( element( rand1, 1 ), element( rand2, 1 ), element( rand3, 1 ) ); + const scaleMin = element( scaleRange, 0 ); + const scaleMax = element( scaleRange, 1 ); + const randomScale = mix( vec3( scaleMin, scaleMin, scaleMin ), vec3( scaleMax, scaleMax, scaleMax ), randY ); + const scales = mix( vec3( 1, 1, 1 ), randomScale, scale ); + const offsetMin = element( offsetRange, 0 ); + const offsetMax = element( offsetRange, 1 ); + const offset1 = mix( vec2( offsetMin, offsetMin ), vec2( offsetMax, offsetMax ), mul( rand1, offset ) ); + const offset2 = mix( vec2( offsetMin, offsetMin ), vec2( offsetMax, offsetMax ), mul( rand2, offset ) ); + const offset3 = mix( vec2( offsetMin, offsetMin ), vec2( offsetMax, offsetMax ), mul( rand3, offset ) ); + + const sampleCoord = ( center, randomOffset, rotationValue, sampleScale ) => { + + const delta = sub( coord, center ); + const rotated = mxRotate2d( delta, sin( rotationValue ), cos( rotationValue ) ); + const safeScale = max( sampleScale, HEXTILE_EPSILON ); + return add( add( div( rotated, vec2( safeScale, safeScale ) ), center ), randomOffset ); + + }; + + const sampleDerivative = ( derivative, rotationValue, sampleScale ) => { + + const rotated = mxRotate2d( derivative, sin( rotationValue ), cos( rotationValue ) ); + const safeScale = max( sampleScale, HEXTILE_EPSILON ); + return div( rotated, vec2( safeScale, safeScale ) ); + + }; + + const ddx = dFdx( coord ); + const ddy = dFdy( coord ); + + return { + coords: [ + sampleCoord( ctr1, offset1, element( rotations, 0 ), element( scales, 0 ) ), + sampleCoord( ctr2, offset2, element( rotations, 1 ), element( scales, 1 ) ), + sampleCoord( ctr3, offset3, element( rotations, 2 ), element( scales, 2 ) ), + ], + ddx: [ + sampleDerivative( ddx, element( rotations, 0 ), element( scales, 0 ) ), + sampleDerivative( ddx, element( rotations, 1 ), element( scales, 1 ) ), + sampleDerivative( ddx, element( rotations, 2 ), element( scales, 2 ) ), + ], + ddy: [ + sampleDerivative( ddy, element( rotations, 0 ), element( scales, 0 ) ), + sampleDerivative( ddy, element( rotations, 1 ), element( scales, 1 ) ), + sampleDerivative( ddy, element( rotations, 2 ), element( scales, 2 ) ), + ], + weights: vec3( w1, w2, w3 ), + }; + +} diff --git a/examples/jsm/loaders/materialx/MaterialXNodeLibrary.js b/examples/jsm/loaders/materialx/MaterialXNodeLibrary.js index 95c2035399b38b..7b7bd183237f19 100644 --- a/examples/jsm/loaders/materialx/MaterialXNodeLibrary.js +++ b/examples/jsm/loaders/materialx/MaterialXNodeLibrary.js @@ -35,6 +35,7 @@ import { inverse, normalMap, mat3, + mx_ramp4, mx_ramplr, mx_ramptb, mx_splitlr, @@ -48,6 +49,7 @@ import { mx_unifiednoise2d, mx_unifiednoise3d, mx_modulo, + mx_invert, mx_place2d, mx_rotate2d, mx_rotate3d, @@ -83,8 +85,6 @@ import { normalizeSpaceName } from './MaterialXUtils.js'; const createMXElement = ( name, nodeFunc, params = [], defaults = {} ) => ( { name, nodeFunc, params, defaults } ); -const mx_invert = ( inNode, amount = 1 ) => sub( amount, inNode ); - const mx_range = ( inNode, inLow, inHigh, outLow, outHigh, gamma = 1 ) => { const inSpan = max( sub( inHigh, inLow ), 1e-6 ); @@ -303,15 +303,15 @@ const isVec3Like = ( node ) => node && ( node.nodeType === 'vec3' || node.nodeType === 'color' || node.nodeType === 'color3' ); const isVec4Like = ( node ) => node && ( node.nodeType === 'vec4' || node.nodeType === 'color4' ); -const mx_burn = ( fg, bg, mixval = 1 ) => { +const applyBlendByChannel = ( channelFunc, fg, bg, mixval = 1 ) => { if ( isVec4Like( fg ) || isVec4Like( bg ) ) { return vec4( - mx_burn_channel( element( fg, 0 ), element( bg, 0 ), mixval ), - mx_burn_channel( element( fg, 1 ), element( bg, 1 ), mixval ), - mx_burn_channel( element( fg, 2 ), element( bg, 2 ), mixval ), - mx_burn_channel( element( fg, 3 ), element( bg, 3 ), mixval ), + channelFunc( element( fg, 0 ), element( bg, 0 ), mixval ), + channelFunc( element( fg, 1 ), element( bg, 1 ), mixval ), + channelFunc( element( fg, 2 ), element( bg, 2 ), mixval ), + channelFunc( element( fg, 3 ), element( bg, 3 ), mixval ), ); } @@ -319,52 +319,43 @@ const mx_burn = ( fg, bg, mixval = 1 ) => { if ( isVec3Like( fg ) || isVec3Like( bg ) ) { return vec3( - mx_burn_channel( element( fg, 0 ), element( bg, 0 ), mixval ), - mx_burn_channel( element( fg, 1 ), element( bg, 1 ), mixval ), - mx_burn_channel( element( fg, 2 ), element( bg, 2 ), mixval ), + channelFunc( element( fg, 0 ), element( bg, 0 ), mixval ), + channelFunc( element( fg, 1 ), element( bg, 1 ), mixval ), + channelFunc( element( fg, 2 ), element( bg, 2 ), mixval ), ); } - return mx_burn_channel( fg, bg, mixval ); + return channelFunc( fg, bg, mixval ); }; -const mx_dodge = ( fg, bg, mixval = 1 ) => { - - if ( isVec4Like( fg ) || isVec4Like( bg ) ) { +const mx_burn = ( fg, bg, mixval = 1 ) => applyBlendByChannel( mx_burn_channel, fg, bg, mixval ); +const mx_dodge = ( fg, bg, mixval = 1 ) => applyBlendByChannel( mx_dodge_channel, fg, bg, mixval ); - return vec4( - mx_dodge_channel( element( fg, 0 ), element( bg, 0 ), mixval ), - mx_dodge_channel( element( fg, 1 ), element( bg, 1 ), mixval ), - mx_dodge_channel( element( fg, 2 ), element( bg, 2 ), mixval ), - mx_dodge_channel( element( fg, 3 ), element( bg, 3 ), mixval ), - ); - - } - - if ( isVec3Like( fg ) || isVec3Like( bg ) ) { - - return vec3( - mx_dodge_channel( element( fg, 0 ), element( bg, 0 ), mixval ), - mx_dodge_channel( element( fg, 1 ), element( bg, 1 ), mixval ), - mx_dodge_channel( element( fg, 2 ), element( bg, 2 ), mixval ), - ); - - } - - return mx_dodge_channel( fg, bg, mixval ); - -}; +const mixColor4 = ( bg, fg, factor ) => + vec4( + mix( element( bg, 0 ), element( fg, 0 ), factor ), + mix( element( bg, 1 ), element( fg, 1 ), factor ), + mix( element( bg, 2 ), element( fg, 2 ), factor ), + mix( element( bg, 3 ), element( fg, 3 ), factor ), + ); -const mx_ramp4 = ( valuetl, valuetr, valuebl, valuebr, texcoord = vec2( 0, 0 ) ) => { +const mxRampSegment = ( x, color1, color2, interval1, interval2, interpolation ) => { - const clamped = clamp( texcoord, vec2( 0, 0 ), vec2( 1, 1 ) ); - const s = element( clamped, 0 ); - const t = element( clamped, 1 ); - const topMix = mix( valuetl, valuetr, s ); - const bottomMix = mix( valuebl, valuebr, s ); - return mix( topMix, bottomMix, t ); + const linearClamped = clamp( x, interval1, interval2 ); + const rangeSize = sub( interval2, interval1 ); + const safeRange = max( rangeSize, float( 1e-6 ) ); + const linearRemap = div( sub( linearClamped, interval1 ), safeRange ); + const smoothVal = mx_smoothstep( x, interval1, interval2 ); + const interpolationDistanceToLinear = abs( sub( interpolation, float( 0 ) ) ); + const useLinear = sub( float( 1 ), step( float( 0.5 ), interpolationDistanceToLinear ) ); + const interpFactor = mix( smoothVal, linearRemap, useLinear ); + const mixedColor = mixColor4( color1, color2, interpFactor ); + const stepColor = mixColor4( color1, color2, step( interval2, x ) ); + const interpolationDistanceToStep = abs( sub( interpolation, float( 2 ) ) ); + const useStep = sub( float( 1 ), step( float( 0.5 ), interpolationDistanceToStep ) ); + return mixColor4( mixedColor, stepColor, useStep ); }; @@ -386,26 +377,7 @@ const mx_ramp_gradient = ( const interpolationFloat = float( interpolation ); const intervalNumFloat = float( intervalNum ); const numIntervalsFloat = float( numIntervals ); - const mixColor4 = ( bg, fg, factor ) => - vec4( - mix( element( bg, 0 ), element( fg, 0 ), factor ), - mix( element( bg, 1 ), element( fg, 1 ), factor ), - mix( element( bg, 2 ), element( fg, 2 ), factor ), - mix( element( bg, 3 ), element( fg, 3 ), factor ), - ); - const linearClamped = clamp( xFloat, interval1Float, interval2Float ); - const rangeSize = sub( interval2Float, interval1Float ); - const safeRange = max( rangeSize, float( 1e-6 ) ); - const linearRemap = div( sub( linearClamped, interval1Float ), safeRange ); - const smoothVal = mx_smoothstep( xFloat, interval1Float, interval2Float ); - const interpolationDistanceToLinear = abs( sub( interpolationFloat, float( 0 ) ) ); - const useLinear = sub( float( 1 ), step( float( 0.5 ), interpolationDistanceToLinear ) ); - const interpFactor = mix( smoothVal, linearRemap, useLinear ); - const mixedColor = mixColor4( color1, color2, interpFactor ); - const stepColor = mixColor4( color1, color2, step( interval2Float, xFloat ) ); - const interpolationDistanceToStep = abs( sub( interpolationFloat, float( 2 ) ) ); - const useStep = sub( float( 1 ), step( float( 0.5 ), interpolationDistanceToStep ) ); - const interpolated = mixColor4( mixedColor, stepColor, useStep ); + const interpolated = mxRampSegment( xFloat, color1, color2, interval1Float, interval2Float, interpolationFloat ); const withinInterval = mixColor4( prevColor, interpolated, step( add( interval1Float, float( 1e-6 ) ), xFloat ) ); return mixColor4( withinInterval, prevColor, step( numIntervalsFloat, intervalNumFloat ) ); @@ -413,14 +385,6 @@ const mx_ramp_gradient = ( const mx_ramp = ( texcoord = vec2( 0, 0 ), type = 0, interpolation = 1, numIntervals = 2, ...rest ) => { - const mixColor4 = ( bg, fg, factor ) => - vec4( - mix( element( bg, 0 ), element( fg, 0 ), factor ), - mix( element( bg, 1 ), element( fg, 1 ), factor ), - mix( element( bg, 2 ), element( fg, 2 ), factor ), - mix( element( bg, 3 ), element( fg, 3 ), factor ), - ); - const rampTypeFloat = float( type ); const interpolationFloat = float( interpolation ); const numIntervalsFloat = float( numIntervals ); @@ -467,20 +431,7 @@ const mx_ramp = ( texcoord = vec2( 0, 0 ), type = 0, interpolation = 1, numInter const c2 = colors[ i + 1 ]; const intNum = float( i + 1 ); - const rangeSize = sub( iv2, iv1 ); - const safeRange = max( rangeSize, float( 1e-6 ) ); - const linearClamped = clamp( rampX, iv1, iv2 ); - const linearRemap = div( sub( linearClamped, iv1 ), safeRange ); - const smoothVal = mx_smoothstep( rampX, iv1, iv2 ); - - const interpolationDistanceToLinear = abs( sub( interpolationFloat, float( 0 ) ) ); - const useLinear = sub( float( 1 ), step( float( 0.5 ), interpolationDistanceToLinear ) ); - const interpFactor = mix( smoothVal, linearRemap, useLinear ); - const mixedColor = mixColor4( c1, c2, interpFactor ); - const stepColor = mixColor4( c1, c2, step( iv2, rampX ) ); - const interpolationDistanceToStep = abs( sub( interpolationFloat, float( 2 ) ) ); - const useStep = sub( float( 1 ), step( float( 0.5 ), interpolationDistanceToStep ) ); - const interpolated = mixColor4( mixedColor, stepColor, useStep ); + const interpolated = mxRampSegment( rampX, c1, c2, iv1, iv2, interpolationFloat ); const withinInterval = mixColor4( result, interpolated, step( add( iv1, float( 1e-6 ) ), rampX ) ); result = mixColor4( withinInterval, result, step( numIntervalsFloat, intNum ) ); diff --git a/src/nodes/materialx/lib/mx_hsv.js b/src/nodes/materialx/MaterialXColor.js similarity index 88% rename from src/nodes/materialx/lib/mx_hsv.js rename to src/nodes/materialx/MaterialXColor.js index 394cda51d27bcf..c05c4302fe7be9 100644 --- a/src/nodes/materialx/lib/mx_hsv.js +++ b/src/nodes/materialx/MaterialXColor.js @@ -1,9 +1,6 @@ -// Three.js Transpiler -// https://github.com/AcademySoftwareFoundation/MaterialX/blob/main/libraries/stdlib/genglsl/lib/mx_hsv.glsl - -import { int, float, vec3, If, Fn } from '../../tsl/TSLBase.js'; -import { add } from '../../math/OperatorNode.js'; -import { floor, trunc, max, min } from '../../math/MathNode.js'; +import { int, float, vec3, If, Fn } from '../tsl/TSLBase.js'; +import { add } from '../math/OperatorNode.js'; +import { floor, trunc, max, min } from '../math/MathNode.js'; export const mx_hsvtorgb = /*@__PURE__*/ Fn( ( [ hsv ] ) => { diff --git a/src/nodes/materialx/lib/mx_transform_color.js b/src/nodes/materialx/MaterialXColorTransform.js similarity index 63% rename from src/nodes/materialx/lib/mx_transform_color.js rename to src/nodes/materialx/MaterialXColorTransform.js index 128e1269f3cd8e..1b29a49a837d32 100644 --- a/src/nodes/materialx/lib/mx_transform_color.js +++ b/src/nodes/materialx/MaterialXColorTransform.js @@ -1,9 +1,6 @@ -// Three.js Transpiler -// https://github.com/AcademySoftwareFoundation/MaterialX/blob/main/libraries/stdlib/genglsl/lib/mx_transform_color.glsl - -import { bvec3, vec3, Fn } from '../../tsl/TSLBase.js'; -import { greaterThan } from '../../math/OperatorNode.js'; -import { max, pow, mix } from '../../math/MathNode.js'; +import { bvec3, vec3, Fn } from '../tsl/TSLBase.js'; +import { greaterThan } from '../math/OperatorNode.js'; +import { max, pow, mix } from '../math/MathNode.js'; export const mx_srgb_texture_to_lin_rec709 = /*@__PURE__*/ Fn( ( [ color_immutable ] ) => { diff --git a/src/nodes/materialx/MaterialXNodes.js b/src/nodes/materialx/MaterialXNodes.js index 3164e3d39a2510..260a9642cbe513 100644 --- a/src/nodes/materialx/MaterialXNodes.js +++ b/src/nodes/materialx/MaterialXNodes.js @@ -5,9 +5,9 @@ import { mx_cell_noise_float as cell_noise_float, mx_cell_noise_vec3 as cell_noise_vec3, mx_unifiednoise2d as unifiednoise2d, mx_unifiednoise3d as unifiednoise3d, mx_fractal_noise_float as fractal_noise_float, mx_fractal_noise_vec2 as fractal_noise_vec2, mx_fractal_noise_vec3 as fractal_noise_vec3, mx_fractal_noise_vec4 as fractal_noise_vec4 -} from './lib/mx_noise.js'; -import { mx_hsvtorgb, mx_rgbtohsv } from './lib/mx_hsv.js'; -import { mx_srgb_texture_to_lin_rec709 } from './lib/mx_transform_color.js'; +} from './MaterialXNoise.js'; +import { mx_hsvtorgb, mx_rgbtohsv } from './MaterialXColor.js'; +import { mx_srgb_texture_to_lin_rec709 } from './MaterialXColorTransform.js'; import { float, vec2, vec3, vec4, int, add, sub, mul, div, atan, mix, pow, smoothstep, diff --git a/src/nodes/materialx/lib/mx_noise.js b/src/nodes/materialx/MaterialXNoise.js similarity index 97% rename from src/nodes/materialx/lib/mx_noise.js rename to src/nodes/materialx/MaterialXNoise.js index aa1b0e421e1366..b9eaf2bcb9f04c 100644 --- a/src/nodes/materialx/lib/mx_noise.js +++ b/src/nodes/materialx/MaterialXNoise.js @@ -1,12 +1,9 @@ -// Three.js Transpiler -// https://raw.githubusercontent.com/AcademySoftwareFoundation/MaterialX/main/libraries/stdlib/genglsl/lib/mx_noise.glsl - -import { int, uint, float, vec3, bool, uvec3, vec2, vec4, If, Fn } from '../../tsl/TSLBase.js'; -import { select } from '../../math/ConditionalNode.js'; -import { add, sub, mul } from '../../math/OperatorNode.js'; -import { floor, abs, max, dot, sqrt, clamp, fract, sin, cos, normalize } from '../../math/MathNode.js'; -import { overloadingFn } from '../../utils/FunctionOverloadingNode.js'; -import { Loop } from '../../utils/LoopNode.js'; +import { int, uint, float, vec3, bool, uvec3, vec2, vec4, If, Fn } from '../tsl/TSLBase.js'; +import { select } from '../math/ConditionalNode.js'; +import { add, sub, mul } from '../math/OperatorNode.js'; +import { floor, abs, max, dot, sqrt, clamp, fract, sin, cos, normalize } from '../math/MathNode.js'; +import { overloadingFn } from '../utils/FunctionOverloadingNode.js'; +import { Loop } from '../utils/LoopNode.js'; export const mx_select = /*@__PURE__*/ Fn( ( [ b_immutable, t_immutable, f_immutable ] ) => { @@ -66,6 +63,22 @@ export const mx_floorfrac = /*@__PURE__*/ Fn( ( [ x_immutable, i ] ) => { } ); +const mxBilerpValue = ( v0, v1, v2, v3, s, t ) => { + + const s1 = float( sub( 1.0, s ) ).toVar(); + return sub( 1.0, t ).mul( v0.mul( s1 ).add( v1.mul( s ) ) ).add( t.mul( v2.mul( s1 ).add( v3.mul( s ) ) ) ); + +}; + +const mxTrilerpValue = ( v0, v1, v2, v3, v4, v5, v6, v7, s, t, r ) => { + + const s1 = float( sub( 1.0, s ) ).toVar(); + const t1 = float( sub( 1.0, t ) ).toVar(); + const r1 = float( sub( 1.0, r ) ).toVar(); + return r1.mul( t1.mul( v0.mul( s1 ).add( v1.mul( s ) ) ).add( t.mul( v2.mul( s1 ).add( v3.mul( s ) ) ) ) ).add( r.mul( t1.mul( v4.mul( s1 ).add( v5.mul( s ) ) ).add( t.mul( v6.mul( s1 ).add( v7.mul( s ) ) ) ) ) ); + +}; + export const mx_bilerp_0 = /*@__PURE__*/ Fn( ( [ v0_immutable, v1_immutable, v2_immutable, v3_immutable, s_immutable, t_immutable ] ) => { const t = float( t_immutable ).toVar(); @@ -74,9 +87,8 @@ export const mx_bilerp_0 = /*@__PURE__*/ Fn( ( [ v0_immutable, v1_immutable, v2_ const v2 = float( v2_immutable ).toVar(); const v1 = float( v1_immutable ).toVar(); const v0 = float( v0_immutable ).toVar(); - const s1 = float( sub( 1.0, s ) ).toVar(); - return sub( 1.0, t ).mul( v0.mul( s1 ).add( v1.mul( s ) ) ).add( t.mul( v2.mul( s1 ).add( v3.mul( s ) ) ) ); + return mxBilerpValue( v0, v1, v2, v3, s, t ); } ).setLayout( { name: 'mx_bilerp_0', @@ -99,9 +111,8 @@ export const mx_bilerp_1 = /*@__PURE__*/ Fn( ( [ v0_immutable, v1_immutable, v2_ const v2 = vec3( v2_immutable ).toVar(); const v1 = vec3( v1_immutable ).toVar(); const v0 = vec3( v0_immutable ).toVar(); - const s1 = float( sub( 1.0, s ) ).toVar(); - return sub( 1.0, t ).mul( v0.mul( s1 ).add( v1.mul( s ) ) ).add( t.mul( v2.mul( s1 ).add( v3.mul( s ) ) ) ); + return mxBilerpValue( v0, v1, v2, v3, s, t ); } ).setLayout( { name: 'mx_bilerp_1', @@ -131,11 +142,8 @@ export const mx_trilerp_0 = /*@__PURE__*/ Fn( ( [ v0_immutable, v1_immutable, v2 const v2 = float( v2_immutable ).toVar(); const v1 = float( v1_immutable ).toVar(); const v0 = float( v0_immutable ).toVar(); - const s1 = float( sub( 1.0, s ) ).toVar(); - const t1 = float( sub( 1.0, t ) ).toVar(); - const r1 = float( sub( 1.0, r ) ).toVar(); - return r1.mul( t1.mul( v0.mul( s1 ).add( v1.mul( s ) ) ).add( t.mul( v2.mul( s1 ).add( v3.mul( s ) ) ) ) ).add( r.mul( t1.mul( v4.mul( s1 ).add( v5.mul( s ) ) ).add( t.mul( v6.mul( s1 ).add( v7.mul( s ) ) ) ) ) ); + return mxTrilerpValue( v0, v1, v2, v3, v4, v5, v6, v7, s, t, r ); } ).setLayout( { name: 'mx_trilerp_0', @@ -168,11 +176,8 @@ export const mx_trilerp_1 = /*@__PURE__*/ Fn( ( [ v0_immutable, v1_immutable, v2 const v2 = vec3( v2_immutable ).toVar(); const v1 = vec3( v1_immutable ).toVar(); const v0 = vec3( v0_immutable ).toVar(); - const s1 = float( sub( 1.0, s ) ).toVar(); - const t1 = float( sub( 1.0, t ) ).toVar(); - const r1 = float( sub( 1.0, r ) ).toVar(); - return r1.mul( t1.mul( v0.mul( s1 ).add( v1.mul( s ) ) ).add( t.mul( v2.mul( s1 ).add( v3.mul( s ) ) ) ) ).add( r.mul( t1.mul( v4.mul( s1 ).add( v5.mul( s ) ) ).add( t.mul( v6.mul( s1 ).add( v7.mul( s ) ) ) ) ) ); + return mxTrilerpValue( v0, v1, v2, v3, v4, v5, v6, v7, s, t, r ); } ).setLayout( { name: 'mx_trilerp_1', @@ -987,7 +992,7 @@ export const mx_worley_distance_1 = /*@__PURE__*/ Fn( ( [ p_immutable, x_immutab export const mx_worley_distance = /*@__PURE__*/ overloadingFn( [ mx_worley_distance_0, mx_worley_distance_1 ] ); -const mx_noise_float = ( texcoord, amplitude = 1, pivot = 0 ) => mx_perlin_noise_float( texcoord ).mul( amplitude ).add( pivot ); +const mx_perlin_noise_float_scaled = ( texcoord, amplitude = 1, pivot = 0 ) => mx_perlin_noise_float( texcoord ).mul( amplitude ).add( pivot ); const mx_rotate2d_noise = ( inNode, amount = 0 ) => { @@ -1391,7 +1396,7 @@ export const mx_unifiednoise2d = /*@__PURE__*/ Fn( ( [ If( noiseType.equal( int( 0 ) ), () => { - result.assign( mx_noise_float( applyCellJitter, 0.5, 0.5 ) ); + result.assign( mx_perlin_noise_float_scaled( applyCellJitter, 0.5, 0.5 ) ); } ); If( noiseType.equal( int( 1 ) ), () => { @@ -1444,7 +1449,7 @@ export const mx_unifiednoise3d = ( const applyOffset = add( applyFreq, offset ); const cellJitterMult = mul( sub( jitter, 1 ), 90000 ); const applyCellJitter = mx_rotate3d_noise( applyOffset, cellJitterMult, vec3( 0.1, 1, 0 ) ); - const perlin = mx_noise_float( applyCellJitter, 0.5, 0.5 ); + const perlin = mx_perlin_noise_float_scaled( applyCellJitter, 0.5, 0.5 ); const cell = mx_cell_noise_float( applyCellJitter ); const worley = mx_worley_noise_float_3d( applyOffset, jitter, style ); const fractal = mx_fractal_noise_float( applyCellJitter, octaves, lacunarity, diminish ); From 737721ddd02adea8c93170b311b6d830cab638a9 Mon Sep 17 00:00:00 2001 From: Ben Houston Date: Tue, 28 Apr 2026 10:17:48 -0400 Subject: [PATCH 08/40] Fix some noise discrepancys, update noise example. --- .../loaders/materialx/MaterialXNodeLibrary.js | 31 +++ .../compile/MaterialXCompileRegistry.js | 2 +- ...on.mtlx => gltf_pbr_glass_dispersion.mtlx} | 0 ...honey.mtlx => open_pbr_surface_honey.mtlx} | 0 ...pearl.mtlx => open_pbr_surface_pearl.mtlx} | 0 ...lvet.mtlx => open_pbr_surface_velvet.mtlx} | 0 ...standard_surface_color3_vec3_cm_test.mtlx} | 0 ...lx => standard_surface_combined_test.mtlx} | 0 ...tandard_surface_conditional_if_float.mtlx} | 0 ...tlx => standard_surface_heightnormal.mtlx} | 0 ..._surface_heighttonormal_normal_input.mtlx} | 0 ... => standard_surface_image_transform.mtlx} | 0 ...st.mtlx => standard_surface_ior_test.mtlx} | 0 ...> standard_surface_opacity_only_test.mtlx} | 0 ...tlx => standard_surface_opacity_test.mtlx} | 0 ...lx => standard_surface_rotate2d_test.mtlx} | 0 ...lx => standard_surface_rotate3d_test.mtlx} | 0 ...x => standard_surface_roughness_test.mtlx} | 0 ....mtlx => standard_surface_sheen_test.mtlx} | 0 ...lx => standard_surface_specular_test.mtlx} | 0 ...tandard_surface_texture_opacity_test.mtlx} | 0 ...ard_surface_thin_film_ior_clamp_test.mtlx} | 0 ...ndard_surface_thin_film_rainbow_test.mtlx} | 0 ...ndard_surface_transmission_only_test.mtlx} | 0 ... standard_surface_transmission_rough.mtlx} | 0 ...> standard_surface_transmission_test.mtlx} | 0 examples/webgpu_loader_materialx.html | 48 ++--- examples/webgpu_materialx_noise.html | 200 ++++++++++++------ src/Three.TSL.js | 1 + src/nodes/materialx/MaterialXNodes.js | 2 + src/nodes/materialx/MaterialXNoise.js | 30 +++ 31 files changed, 227 insertions(+), 87 deletions(-) rename examples/materialx/{showcase/gltf_pbr/glass_dispersion/glass_dispersion.mtlx => gltf_pbr_glass_dispersion.mtlx} (100%) rename examples/materialx/{showcase/open_pbr_surface/honey/honey.mtlx => open_pbr_surface_honey.mtlx} (100%) rename examples/materialx/{showcase/open_pbr_surface/pearl/pearl.mtlx => open_pbr_surface_pearl.mtlx} (100%) rename examples/materialx/{showcase/open_pbr_surface/velvet/velvet.mtlx => open_pbr_surface_velvet.mtlx} (100%) rename examples/materialx/{color3_vec3_cm_test.mtlx => standard_surface_color3_vec3_cm_test.mtlx} (100%) rename examples/materialx/{combined_test.mtlx => standard_surface_combined_test.mtlx} (100%) rename examples/materialx/{conditional_if_float.mtlx => standard_surface_conditional_if_float.mtlx} (100%) rename examples/materialx/{heightnormal.mtlx => standard_surface_heightnormal.mtlx} (100%) rename examples/materialx/{heighttonormal_normal_input.mtlx => standard_surface_heighttonormal_normal_input.mtlx} (100%) rename examples/materialx/{image_transform.mtlx => standard_surface_image_transform.mtlx} (100%) rename examples/materialx/{ior_test.mtlx => standard_surface_ior_test.mtlx} (100%) rename examples/materialx/{opacity_only_test.mtlx => standard_surface_opacity_only_test.mtlx} (100%) rename examples/materialx/{opacity_test.mtlx => standard_surface_opacity_test.mtlx} (100%) rename examples/materialx/{rotate2d_test.mtlx => standard_surface_rotate2d_test.mtlx} (100%) rename examples/materialx/{rotate3d_test.mtlx => standard_surface_rotate3d_test.mtlx} (100%) rename examples/materialx/{roughness_test.mtlx => standard_surface_roughness_test.mtlx} (100%) rename examples/materialx/{sheen_test.mtlx => standard_surface_sheen_test.mtlx} (100%) rename examples/materialx/{specular_test.mtlx => standard_surface_specular_test.mtlx} (100%) rename examples/materialx/{texture_opacity_test.mtlx => standard_surface_texture_opacity_test.mtlx} (100%) rename examples/materialx/{thin_film_ior_clamp_test.mtlx => standard_surface_thin_film_ior_clamp_test.mtlx} (100%) rename examples/materialx/{thin_film_rainbow_test.mtlx => standard_surface_thin_film_rainbow_test.mtlx} (100%) rename examples/materialx/{transmission_only_test.mtlx => standard_surface_transmission_only_test.mtlx} (100%) rename examples/materialx/{transmission_rough.mtlx => standard_surface_transmission_rough.mtlx} (100%) rename examples/materialx/{transmission_test.mtlx => standard_surface_transmission_test.mtlx} (100%) diff --git a/examples/jsm/loaders/materialx/MaterialXNodeLibrary.js b/examples/jsm/loaders/materialx/MaterialXNodeLibrary.js index 7b7bd183237f19..ba1d4920a6fd42 100644 --- a/examples/jsm/loaders/materialx/MaterialXNodeLibrary.js +++ b/examples/jsm/loaders/materialx/MaterialXNodeLibrary.js @@ -80,6 +80,8 @@ import { fract, sub, step, + Fn, + Loop, } from 'three/tsl'; import { normalizeSpaceName } from './MaterialXUtils.js'; @@ -449,6 +451,28 @@ const defaultVec2 = ( x, y ) => () => vec2( x, y ); const defaultVec3 = ( x, y, z ) => () => vec3( x, y, z ); const defaultVec4 = ( x, y, z, w ) => () => vec4( x, y, z, w ); +const mx_fractal_noise_float_materialx_2d = Fn( ( [ texcoordInput, octavesInput, lacunarityInput, diminishInput, amplitudeInput ] ) => { + + const texcoord = vec2( texcoordInput ).toVar(); + const octaves = int( octavesInput ).toVar(); + const lacunarity = float( lacunarityInput ).toVar(); + const diminish = float( diminishInput ).toVar(); + const amplitude = float( amplitudeInput ).toVar(); + const result = float( 0 ).toVar(); + const octaveAmplitude = float( 1 ).toVar(); + + Loop( octaves, () => { + + result.addAssign( mul( octaveAmplitude, mx_noise_float( texcoord, float( 1 ), float( 0 ) ) ) ); + octaveAmplitude.mulAssign( diminish ); + texcoord.mulAssign( lacunarity ); + + } ); + + return mul( result, amplitude ); + +} ); + const MXElements = [ createMXElement( 'add', add, [ 'in1', 'in2' ], { in1: defaultFloat( 0 ), in2: defaultFloat( 0 ) } ), createMXElement( 'subtract', sub, [ 'in1', 'in2' ], { in1: defaultFloat( 0 ), in2: defaultFloat( 0 ) } ), @@ -709,6 +733,13 @@ const MXElements = [ amplitude: defaultFloat( 1 ), pivot: defaultFloat( 0 ), } ), + createMXElement( 'fractal2d', mx_fractal_noise_float_materialx_2d, [ 'texcoord', 'octaves', 'lacunarity', 'diminish', 'amplitude' ], { + texcoord: defaultVec2( 0, 0 ), + octaves: defaultInt( 3 ), + lacunarity: defaultFloat( 2.0 ), + diminish: defaultFloat( 0.5 ), + amplitude: defaultFloat( 1.0 ), + } ), createMXElement( 'fractal3d', mx_fractal_noise_float, [ 'position', 'octaves', 'lacunarity', 'diminish', 'amplitude' ], { position: () => positionLocal, octaves: defaultInt( 3 ), diff --git a/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js b/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js index 7717c6815ab7fc..ea0541d260ffba 100644 --- a/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js +++ b/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js @@ -37,7 +37,7 @@ const register = ( registry, categories, handler ) => { }; -const UV_FALLBACK_CATEGORIES = new Set( [ 'noise2d', 'cellnoise2d', 'worleynoise2d', 'unifiednoise2d' ] ); +const UV_FALLBACK_CATEGORIES = new Set( [ 'noise2d', 'fractal2d', 'cellnoise2d', 'worleynoise2d', 'unifiednoise2d' ] ); const getDefaultUvNode = ( compileContext ) => compileContext.mxToUvSpace( uv( 0 ) ); diff --git a/examples/materialx/showcase/gltf_pbr/glass_dispersion/glass_dispersion.mtlx b/examples/materialx/gltf_pbr_glass_dispersion.mtlx similarity index 100% rename from examples/materialx/showcase/gltf_pbr/glass_dispersion/glass_dispersion.mtlx rename to examples/materialx/gltf_pbr_glass_dispersion.mtlx diff --git a/examples/materialx/showcase/open_pbr_surface/honey/honey.mtlx b/examples/materialx/open_pbr_surface_honey.mtlx similarity index 100% rename from examples/materialx/showcase/open_pbr_surface/honey/honey.mtlx rename to examples/materialx/open_pbr_surface_honey.mtlx diff --git a/examples/materialx/showcase/open_pbr_surface/pearl/pearl.mtlx b/examples/materialx/open_pbr_surface_pearl.mtlx similarity index 100% rename from examples/materialx/showcase/open_pbr_surface/pearl/pearl.mtlx rename to examples/materialx/open_pbr_surface_pearl.mtlx diff --git a/examples/materialx/showcase/open_pbr_surface/velvet/velvet.mtlx b/examples/materialx/open_pbr_surface_velvet.mtlx similarity index 100% rename from examples/materialx/showcase/open_pbr_surface/velvet/velvet.mtlx rename to examples/materialx/open_pbr_surface_velvet.mtlx diff --git a/examples/materialx/color3_vec3_cm_test.mtlx b/examples/materialx/standard_surface_color3_vec3_cm_test.mtlx similarity index 100% rename from examples/materialx/color3_vec3_cm_test.mtlx rename to examples/materialx/standard_surface_color3_vec3_cm_test.mtlx diff --git a/examples/materialx/combined_test.mtlx b/examples/materialx/standard_surface_combined_test.mtlx similarity index 100% rename from examples/materialx/combined_test.mtlx rename to examples/materialx/standard_surface_combined_test.mtlx diff --git a/examples/materialx/conditional_if_float.mtlx b/examples/materialx/standard_surface_conditional_if_float.mtlx similarity index 100% rename from examples/materialx/conditional_if_float.mtlx rename to examples/materialx/standard_surface_conditional_if_float.mtlx diff --git a/examples/materialx/heightnormal.mtlx b/examples/materialx/standard_surface_heightnormal.mtlx similarity index 100% rename from examples/materialx/heightnormal.mtlx rename to examples/materialx/standard_surface_heightnormal.mtlx diff --git a/examples/materialx/heighttonormal_normal_input.mtlx b/examples/materialx/standard_surface_heighttonormal_normal_input.mtlx similarity index 100% rename from examples/materialx/heighttonormal_normal_input.mtlx rename to examples/materialx/standard_surface_heighttonormal_normal_input.mtlx diff --git a/examples/materialx/image_transform.mtlx b/examples/materialx/standard_surface_image_transform.mtlx similarity index 100% rename from examples/materialx/image_transform.mtlx rename to examples/materialx/standard_surface_image_transform.mtlx diff --git a/examples/materialx/ior_test.mtlx b/examples/materialx/standard_surface_ior_test.mtlx similarity index 100% rename from examples/materialx/ior_test.mtlx rename to examples/materialx/standard_surface_ior_test.mtlx diff --git a/examples/materialx/opacity_only_test.mtlx b/examples/materialx/standard_surface_opacity_only_test.mtlx similarity index 100% rename from examples/materialx/opacity_only_test.mtlx rename to examples/materialx/standard_surface_opacity_only_test.mtlx diff --git a/examples/materialx/opacity_test.mtlx b/examples/materialx/standard_surface_opacity_test.mtlx similarity index 100% rename from examples/materialx/opacity_test.mtlx rename to examples/materialx/standard_surface_opacity_test.mtlx diff --git a/examples/materialx/rotate2d_test.mtlx b/examples/materialx/standard_surface_rotate2d_test.mtlx similarity index 100% rename from examples/materialx/rotate2d_test.mtlx rename to examples/materialx/standard_surface_rotate2d_test.mtlx diff --git a/examples/materialx/rotate3d_test.mtlx b/examples/materialx/standard_surface_rotate3d_test.mtlx similarity index 100% rename from examples/materialx/rotate3d_test.mtlx rename to examples/materialx/standard_surface_rotate3d_test.mtlx diff --git a/examples/materialx/roughness_test.mtlx b/examples/materialx/standard_surface_roughness_test.mtlx similarity index 100% rename from examples/materialx/roughness_test.mtlx rename to examples/materialx/standard_surface_roughness_test.mtlx diff --git a/examples/materialx/sheen_test.mtlx b/examples/materialx/standard_surface_sheen_test.mtlx similarity index 100% rename from examples/materialx/sheen_test.mtlx rename to examples/materialx/standard_surface_sheen_test.mtlx diff --git a/examples/materialx/specular_test.mtlx b/examples/materialx/standard_surface_specular_test.mtlx similarity index 100% rename from examples/materialx/specular_test.mtlx rename to examples/materialx/standard_surface_specular_test.mtlx diff --git a/examples/materialx/texture_opacity_test.mtlx b/examples/materialx/standard_surface_texture_opacity_test.mtlx similarity index 100% rename from examples/materialx/texture_opacity_test.mtlx rename to examples/materialx/standard_surface_texture_opacity_test.mtlx diff --git a/examples/materialx/thin_film_ior_clamp_test.mtlx b/examples/materialx/standard_surface_thin_film_ior_clamp_test.mtlx similarity index 100% rename from examples/materialx/thin_film_ior_clamp_test.mtlx rename to examples/materialx/standard_surface_thin_film_ior_clamp_test.mtlx diff --git a/examples/materialx/thin_film_rainbow_test.mtlx b/examples/materialx/standard_surface_thin_film_rainbow_test.mtlx similarity index 100% rename from examples/materialx/thin_film_rainbow_test.mtlx rename to examples/materialx/standard_surface_thin_film_rainbow_test.mtlx diff --git a/examples/materialx/transmission_only_test.mtlx b/examples/materialx/standard_surface_transmission_only_test.mtlx similarity index 100% rename from examples/materialx/transmission_only_test.mtlx rename to examples/materialx/standard_surface_transmission_only_test.mtlx diff --git a/examples/materialx/transmission_rough.mtlx b/examples/materialx/standard_surface_transmission_rough.mtlx similarity index 100% rename from examples/materialx/transmission_rough.mtlx rename to examples/materialx/standard_surface_transmission_rough.mtlx diff --git a/examples/materialx/transmission_test.mtlx b/examples/materialx/standard_surface_transmission_test.mtlx similarity index 100% rename from examples/materialx/transmission_test.mtlx rename to examples/materialx/standard_surface_transmission_test.mtlx diff --git a/examples/webgpu_loader_materialx.html b/examples/webgpu_loader_materialx.html index 78c98a46b8d23c..26b8d78f369b70 100644 --- a/examples/webgpu_loader_materialx.html +++ b/examples/webgpu_loader_materialx.html @@ -78,30 +78,30 @@ ]; const localSamples = [ - 'heightnormal.mtlx', - 'conditional_if_float.mtlx', - 'image_transform.mtlx', - 'color3_vec3_cm_test.mtlx', - 'rotate2d_test.mtlx', - 'rotate3d_test.mtlx', - 'heighttonormal_normal_input.mtlx', - 'roughness_test.mtlx', - 'opacity_test.mtlx', - 'opacity_only_test.mtlx', - 'specular_test.mtlx', - 'ior_test.mtlx', - 'combined_test.mtlx', - 'texture_opacity_test.mtlx', - 'transmission_test.mtlx', - 'transmission_only_test.mtlx', - 'transmission_rough.mtlx', - 'thin_film_rainbow_test.mtlx', - 'thin_film_ior_clamp_test.mtlx', - 'sheen_test.mtlx', - 'showcase/gltf_pbr/glass_dispersion/glass_dispersion.mtlx', - 'showcase/open_pbr_surface/velvet/velvet.mtlx', - 'showcase/open_pbr_surface/pearl/pearl.mtlx', - 'showcase/open_pbr_surface/honey/honey.mtlx', + 'standard_surface_heightnormal.mtlx', + 'standard_surface_conditional_if_float.mtlx', + 'standard_surface_image_transform.mtlx', + 'standard_surface_color3_vec3_cm_test.mtlx', + 'standard_surface_rotate2d_test.mtlx', + 'standard_surface_rotate3d_test.mtlx', + 'standard_surface_heighttonormal_normal_input.mtlx', + 'standard_surface_roughness_test.mtlx', + 'standard_surface_opacity_test.mtlx', + 'standard_surface_opacity_only_test.mtlx', + 'standard_surface_specular_test.mtlx', + 'standard_surface_ior_test.mtlx', + 'standard_surface_combined_test.mtlx', + 'standard_surface_texture_opacity_test.mtlx', + 'standard_surface_transmission_test.mtlx', + 'standard_surface_transmission_only_test.mtlx', + 'standard_surface_transmission_rough.mtlx', + 'standard_surface_thin_film_rainbow_test.mtlx', + 'standard_surface_thin_film_ior_clamp_test.mtlx', + 'standard_surface_sheen_test.mtlx', + 'gltf_pbr_glass_dispersion.mtlx', + 'open_pbr_surface_velvet.mtlx', + 'open_pbr_surface_pearl.mtlx', + 'open_pbr_surface_honey.mtlx', ]; let camera, scene, renderer; diff --git a/examples/webgpu_materialx_noise.html b/examples/webgpu_materialx_noise.html index b4213737495381..918026a6f66732 100644 --- a/examples/webgpu_materialx_noise.html +++ b/examples/webgpu_materialx_noise.html @@ -32,12 +32,23 @@ diff --git a/src/Three.TSL.js b/src/Three.TSL.js index b87115381bddd9..16faf50a6ba839 100644 --- a/src/Three.TSL.js +++ b/src/Three.TSL.js @@ -340,6 +340,7 @@ export const mx_cell_noise_float = TSL.mx_cell_noise_float; export const mx_cell_noise_vec3 = TSL.mx_cell_noise_vec3; export const mx_contrast = TSL.mx_contrast; export const mx_divide = TSL.mx_divide; +export const mx_fractal_noise_float_2d = TSL.mx_fractal_noise_float_2d; export const mx_fractal_noise_float = TSL.mx_fractal_noise_float; export const mx_fractal_noise_vec2 = TSL.mx_fractal_noise_vec2; export const mx_fractal_noise_vec3 = TSL.mx_fractal_noise_vec3; diff --git a/src/nodes/materialx/MaterialXNodes.js b/src/nodes/materialx/MaterialXNodes.js index 260a9642cbe513..7e853b818cf16c 100644 --- a/src/nodes/materialx/MaterialXNodes.js +++ b/src/nodes/materialx/MaterialXNodes.js @@ -4,6 +4,7 @@ import { mx_worley_noise_vec2 as worley_noise_vec2, mx_worley_noise_vec3 as worley_noise_vec3, mx_cell_noise_float as cell_noise_float, mx_cell_noise_vec3 as cell_noise_vec3, mx_unifiednoise2d as unifiednoise2d, mx_unifiednoise3d as unifiednoise3d, + mx_fractal_noise_float_2d as fractal_noise_float_2d, mx_fractal_noise_float as fractal_noise_float, mx_fractal_noise_vec2 as fractal_noise_vec2, mx_fractal_noise_vec3 as fractal_noise_vec3, mx_fractal_noise_vec4 as fractal_noise_vec4 } from './MaterialXNoise.js'; import { mx_hsvtorgb, mx_rgbtohsv } from './MaterialXColor.js'; @@ -98,6 +99,7 @@ export const mx_worley_noise_vec3 = ( texcoord = uv(), jitter = 1 ) => worley_no export const mx_cell_noise_float = ( texcoord = uv() ) => cell_noise_float( texcoord.convert( 'vec2|vec3' ) ); +export const mx_fractal_noise_float_2d = ( texcoord = uv(), octaves = 3, lacunarity = 2, diminish = .5, amplitude = 1 ) => fractal_noise_float_2d( texcoord, int( octaves ), lacunarity, diminish ).mul( amplitude ); export const mx_fractal_noise_float = ( position = uv(), octaves = 3, lacunarity = 2, diminish = .5, amplitude = 1 ) => fractal_noise_float( position, int( octaves ), lacunarity, diminish ).mul( amplitude ); export const mx_fractal_noise_vec2 = ( position = uv(), octaves = 3, lacunarity = 2, diminish = .5, amplitude = 1 ) => fractal_noise_vec2( position, int( octaves ), lacunarity, diminish ).mul( amplitude ); export const mx_fractal_noise_vec3 = ( position = uv(), octaves = 3, lacunarity = 2, diminish = .5, amplitude = 1 ) => fractal_noise_vec3( position, int( octaves ), lacunarity, diminish ).mul( amplitude ); diff --git a/src/nodes/materialx/MaterialXNoise.js b/src/nodes/materialx/MaterialXNoise.js index b9eaf2bcb9f04c..66e482c6e4ae0f 100644 --- a/src/nodes/materialx/MaterialXNoise.js +++ b/src/nodes/materialx/MaterialXNoise.js @@ -795,6 +795,36 @@ export const mx_cell_noise_vec3 = /*@__PURE__*/ Fn( ( [ positionInput ] ) => { } ); +export const mx_fractal_noise_float_2d = /*@__PURE__*/ Fn( ( [ p_immutable, octaves_immutable, lacunarity_immutable, diminish_immutable ] ) => { + + const diminish = float( diminish_immutable ).toVar(); + const lacunarity = float( lacunarity_immutable ).toVar(); + const octaves = int( octaves_immutable ).toVar(); + const p = vec2( p_immutable ).toVar(); + const result = float( 0.0 ).toVar(); + const amplitude = float( 1.0 ).toVar(); + + Loop( octaves, () => { + + result.addAssign( amplitude.mul( mx_perlin_noise_float( p ) ) ); + amplitude.mulAssign( diminish ); + p.mulAssign( lacunarity ); + + } ); + + return result; + +} ).setLayout( { + name: 'mx_fractal_noise_float_2d', + type: 'float', + inputs: [ + { name: 'p', type: 'vec2' }, + { name: 'octaves', type: 'int' }, + { name: 'lacunarity', type: 'float' }, + { name: 'diminish', type: 'float' } + ] +} ); + export const mx_fractal_noise_float = /*@__PURE__*/ Fn( ( [ p_immutable, octaves_immutable, lacunarity_immutable, diminish_immutable ] ) => { const diminish = float( diminish_immutable ).toVar(); From 96cc56fcd598fd08fe9ed256e36f07f70f4ea9fd Mon Sep 17 00:00:00 2001 From: Ben Houston Date: Fri, 1 May 2026 21:53:36 -0400 Subject: [PATCH 09/40] improve converter, add default color space for textures, --- .../loaders/materialx/MaterialXDocument.js | 10 ++++++++- .../compile/MaterialXCompileRegistry.js | 21 ++++++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/examples/jsm/loaders/materialx/MaterialXDocument.js b/examples/jsm/loaders/materialx/MaterialXDocument.js index 92b9d6299cf916..739254f704cd39 100644 --- a/examples/jsm/loaders/materialx/MaterialXDocument.js +++ b/examples/jsm/loaders/materialx/MaterialXDocument.js @@ -36,6 +36,7 @@ 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; @@ -551,7 +552,14 @@ class MaterialXNode { getAttribute( name ) { - return this.nodeXML.getAttribute( name ); + const value = this.nodeXML.getAttribute( name ); + if ( value === null && this.element === 'materialx' && name === 'colorspace' ) { + + return DEFAULT_DOCUMENT_COLOR_SPACE; + + } + + return value; } diff --git a/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js b/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js index ea0541d260ffba..a9d5797159e68a 100644 --- a/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js +++ b/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js @@ -38,6 +38,8 @@ const register = ( registry, categories, handler ) => { }; const UV_FALLBACK_CATEGORIES = new Set( [ 'noise2d', 'fractal2d', 'cellnoise2d', 'worleynoise2d', 'unifiednoise2d' ] ); +const SCALAR_TYPES = new Set( [ 'boolean', 'integer', 'float' ] ); +const THREE_COMPONENT_TYPES = new Set( [ 'vector2', 'vector3', 'vector4', 'color3', 'color4' ] ); const getDefaultUvNode = ( compileContext ) => compileContext.mxToUvSpace( uv( 0 ) ); @@ -62,8 +64,25 @@ const applyTextureColorSpace = ( node, file ) => { const compileConvertNode = ( nodeX ) => { + const input = nodeX.getNodeByName( 'in' ); + const inputElement = nodeX.getChildByName( 'in' ); + const inputType = inputElement ? inputElement.type : null; const nodeClass = nodeX.getClassFromType( nodeX.type ) || float; - return nodeClass( nodeX.getNodeByName( 'in' ) ); + + if ( SCALAR_TYPES.has( inputType ) && THREE_COMPONENT_TYPES.has( nodeX.type ) ) { + + const componentCount = nodeX.type === 'vector2' ? 2 : nodeX.type === 'vector3' || nodeX.type === 'color3' ? 3 : 4; + return nodeClass( ...Array( componentCount ).fill( input ) ); + + } + + if ( THREE_COMPONENT_TYPES.has( inputType ) && SCALAR_TYPES.has( nodeX.type ) ) { + + return nodeClass( element( input, 0 ) ); + + } + + return nodeClass( input ); }; From f29960f22fb5b1bd3514b7d4ea812a762a50f03f Mon Sep 17 00:00:00 2001 From: Ben Houston Date: Sat, 2 May 2026 20:40:11 -0400 Subject: [PATCH 10/40] fix worleynoise3d, add vector2 output. --- .../loaders/materialx/MaterialXNodeLibrary.js | 83 +++++- .../compile/MaterialXCompileRegistry.js | 2 +- src/Three.TSL.js | 1 + src/nodes/materialx/MaterialXNodes.js | 4 +- src/nodes/materialx/MaterialXNoise.js | 281 ++++++++++++++++-- 5 files changed, 332 insertions(+), 39 deletions(-) diff --git a/examples/jsm/loaders/materialx/MaterialXNodeLibrary.js b/examples/jsm/loaders/materialx/MaterialXNodeLibrary.js index ba1d4920a6fd42..5a517d6f1fc8a5 100644 --- a/examples/jsm/loaders/materialx/MaterialXNodeLibrary.js +++ b/examples/jsm/loaders/materialx/MaterialXNodeLibrary.js @@ -41,11 +41,15 @@ import { mx_splitlr, mx_splittb, mx_fractal_noise_float, + mx_fractal_noise_vec3, mx_noise_float, + mx_noise_vec3, mx_cell_noise_float, + mx_cell_noise_vec3, mx_smoothstep, mx_worley_noise_float_2d, mx_worley_noise_float_3d, + mx_worley_noise_vec3_style, mx_unifiednoise2d, mx_unifiednoise3d, mx_modulo, @@ -85,7 +89,7 @@ import { } from 'three/tsl'; import { normalizeSpaceName } from './MaterialXUtils.js'; -const createMXElement = ( name, nodeFunc, params = [], defaults = {} ) => ( { name, nodeFunc, params, defaults } ); +const createMXElement = ( name, nodeFunc, params = [], defaults = {}, usesNode = false ) => ( { name, nodeFunc, params, defaults, usesNode } ); const mx_range = ( inNode, inLow, inHigh, outLow, outHigh, gamma = 1 ) => { @@ -450,6 +454,33 @@ const defaultColor = ( r, g, b ) => () => color( r, g, b ); const defaultVec2 = ( x, y ) => () => vec2( x, y ); const defaultVec3 = ( x, y, z ) => () => vec3( x, y, z ); const defaultVec4 = ( x, y, z, w ) => () => vec4( x, y, z, w ); +const usesVec2Noise = ( nodeX ) => nodeX && nodeX.type === 'vector2'; +const usesVec3Noise = ( nodeX ) => nodeX && ( nodeX.type === 'vector3' || nodeX.type === 'color3' ); + +const mx_noise_materialx = ( texcoord, amplitude, pivot, nodeX ) => + usesVec3Noise( nodeX ) ? mx_noise_vec3( texcoord, vec3( amplitude ), pivot ) : mx_noise_float( texcoord, amplitude, pivot ); + +const mx_fractal_noise_materialx_2d = ( texcoord, octaves, lacunarity, diminish, amplitude, nodeX ) => + usesVec3Noise( nodeX ) ? mx_fractal_noise_vec3_materialx_2d( texcoord, octaves, lacunarity, diminish, amplitude ) : mx_fractal_noise_float_materialx_2d( texcoord, octaves, lacunarity, diminish, amplitude ); + +const mx_fractal_noise_materialx_3d = ( position, octaves, lacunarity, diminish, amplitude, nodeX ) => + usesVec3Noise( nodeX ) ? mx_fractal_noise_vec3( position, octaves, lacunarity, diminish, vec3( amplitude ) ) : mx_fractal_noise_float( position, octaves, lacunarity, diminish, amplitude ); + +const mx_cell_noise_materialx = ( position, nodeX ) => + usesVec3Noise( nodeX ) ? mx_cell_noise_vec3( position ) : mx_cell_noise_float( position ); + +const mx_worley_noise_vec2_style = ( position, jitter, style ) => { + + const result = mx_worley_noise_vec3_style( position, jitter, style, 0 ); + return vec2( element( result, 0 ), element( result, 1 ) ); + +}; + +const mx_worley_noise_materialx_2d = ( texcoord, jitter, style, nodeX ) => + usesVec3Noise( nodeX ) ? mx_worley_noise_vec3_style( texcoord, jitter, style, 0 ) : usesVec2Noise( nodeX ) ? mx_worley_noise_vec2_style( texcoord, jitter, style ) : mx_worley_noise_float_2d( texcoord, jitter, style ); + +const mx_worley_noise_materialx_3d = ( position, jitter, style, nodeX ) => + usesVec3Noise( nodeX ) ? mx_worley_noise_vec3_style( position, jitter, style, 0 ) : usesVec2Noise( nodeX ) ? mx_worley_noise_vec2_style( position, jitter, style ) : mx_worley_noise_float_3d( position, jitter, style ); const mx_fractal_noise_float_materialx_2d = Fn( ( [ texcoordInput, octavesInput, lacunarityInput, diminishInput, amplitudeInput ] ) => { @@ -473,6 +504,28 @@ const mx_fractal_noise_float_materialx_2d = Fn( ( [ texcoordInput, octavesInput, } ); +const mx_fractal_noise_vec3_materialx_2d = Fn( ( [ texcoordInput, octavesInput, lacunarityInput, diminishInput, amplitudeInput ] ) => { + + const texcoord = vec2( texcoordInput ).toVar(); + const octaves = int( octavesInput ).toVar(); + const lacunarity = float( lacunarityInput ).toVar(); + const diminish = float( diminishInput ).toVar(); + const amplitude = vec3( amplitudeInput ).toVar(); + const result = vec3( 0 ).toVar(); + const octaveAmplitude = float( 1 ).toVar(); + + Loop( octaves, () => { + + result.addAssign( mul( octaveAmplitude, mx_noise_vec3( texcoord, vec3( 1 ), float( 0 ) ) ) ); + octaveAmplitude.mulAssign( diminish ); + texcoord.mulAssign( lacunarity ); + + } ); + + return mul( result, amplitude ); + +} ); + const MXElements = [ createMXElement( 'add', add, [ 'in1', 'in2' ], { in1: defaultFloat( 0 ), in2: defaultFloat( 0 ) } ), createMXElement( 'subtract', sub, [ 'in1', 'in2' ], { in1: defaultFloat( 0 ), in2: defaultFloat( 0 ) } ), @@ -723,42 +776,42 @@ const MXElements = [ valueb: defaultFloat( 0 ), center: defaultFloat( 0.5 ), } ), - createMXElement( 'noise2d', mx_noise_float, [ 'texcoord', 'amplitude', 'pivot' ], { + createMXElement( 'noise2d', mx_noise_materialx, [ 'texcoord', 'amplitude', 'pivot' ], { texcoord: defaultVec2( 0, 0 ), amplitude: defaultFloat( 1 ), pivot: defaultFloat( 0 ), - } ), - createMXElement( 'noise3d', mx_noise_float, [ 'position', 'amplitude', 'pivot' ], { + }, true ), + createMXElement( 'noise3d', mx_noise_materialx, [ 'position', 'amplitude', 'pivot' ], { position: () => positionLocal, amplitude: defaultFloat( 1 ), pivot: defaultFloat( 0 ), - } ), - createMXElement( 'fractal2d', mx_fractal_noise_float_materialx_2d, [ 'texcoord', 'octaves', 'lacunarity', 'diminish', 'amplitude' ], { + }, true ), + createMXElement( 'fractal2d', mx_fractal_noise_materialx_2d, [ 'texcoord', 'octaves', 'lacunarity', 'diminish', 'amplitude' ], { texcoord: defaultVec2( 0, 0 ), octaves: defaultInt( 3 ), lacunarity: defaultFloat( 2.0 ), diminish: defaultFloat( 0.5 ), amplitude: defaultFloat( 1.0 ), - } ), - createMXElement( 'fractal3d', mx_fractal_noise_float, [ 'position', 'octaves', 'lacunarity', 'diminish', 'amplitude' ], { + }, true ), + createMXElement( 'fractal3d', mx_fractal_noise_materialx_3d, [ 'position', 'octaves', 'lacunarity', 'diminish', 'amplitude' ], { position: () => positionLocal, octaves: defaultInt( 3 ), lacunarity: defaultFloat( 2.0 ), diminish: defaultFloat( 0.5 ), amplitude: defaultFloat( 1.0 ), - } ), - createMXElement( 'cellnoise2d', mx_cell_noise_float, [ 'texcoord' ], { texcoord: defaultVec2( 0, 0 ) } ), - createMXElement( 'cellnoise3d', mx_cell_noise_float, [ 'position' ], { position: () => positionLocal } ), - createMXElement( 'worleynoise2d', mx_worley_noise_float_2d, [ 'texcoord', 'jitter', 'style' ], { + }, true ), + createMXElement( 'cellnoise2d', mx_cell_noise_materialx, [ 'texcoord' ], { texcoord: defaultVec2( 0, 0 ) }, true ), + createMXElement( 'cellnoise3d', mx_cell_noise_materialx, [ 'position' ], { position: () => positionLocal }, true ), + createMXElement( 'worleynoise2d', mx_worley_noise_materialx_2d, [ 'texcoord', 'jitter', 'style' ], { texcoord: defaultVec2( 0, 0 ), jitter: defaultFloat( 1 ), style: defaultInt( 0 ), - } ), - createMXElement( 'worleynoise3d', mx_worley_noise_float_3d, [ 'position', 'jitter', 'style' ], { + }, true ), + createMXElement( 'worleynoise3d', mx_worley_noise_materialx_3d, [ 'position', 'jitter', 'style' ], { position: () => positionLocal, jitter: defaultFloat( 1 ), style: defaultInt( 0 ), - } ), + }, true ), createMXElement( 'unifiednoise2d', mx_unifiednoise2d, diff --git a/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js b/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js index a9d5797159e68a..148827f0d47e9a 100644 --- a/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js +++ b/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js @@ -434,7 +434,7 @@ function compileNodeFromRegistry( nodeX, out, compileContext ) { } - return nodeElement.nodeFunc( ...args ); + return nodeElement.usesNode ? nodeElement.nodeFunc( ...args, nodeX ) : nodeElement.nodeFunc( ...args ); } diff --git a/src/Three.TSL.js b/src/Three.TSL.js index 16faf50a6ba839..85dc7eda471d6e 100644 --- a/src/Three.TSL.js +++ b/src/Three.TSL.js @@ -381,6 +381,7 @@ export const mx_worley_noise_float_2d = TSL.mx_worley_noise_float_2d; export const mx_worley_noise_float_3d = TSL.mx_worley_noise_float_3d; export const mx_worley_noise_vec2 = TSL.mx_worley_noise_vec2; export const mx_worley_noise_vec3 = TSL.mx_worley_noise_vec3; +export const mx_worley_noise_vec3_style = TSL.mx_worley_noise_vec3_style; export const negate = TSL.negate; export const neutralToneMapping = TSL.neutralToneMapping; export const nodeArray = TSL.nodeArray; diff --git a/src/nodes/materialx/MaterialXNodes.js b/src/nodes/materialx/MaterialXNodes.js index 7e853b818cf16c..a9d8c14d08f65a 100644 --- a/src/nodes/materialx/MaterialXNodes.js +++ b/src/nodes/materialx/MaterialXNodes.js @@ -2,6 +2,7 @@ import { mx_perlin_noise_float, mx_perlin_noise_vec3, mx_worley_noise_float_2d as worley_noise_float_2d, mx_worley_noise_float_3d as worley_noise_float_3d, mx_worley_noise_vec2 as worley_noise_vec2, mx_worley_noise_vec3 as worley_noise_vec3, + mx_worley_noise_vec3_style as worley_noise_vec3_style, mx_cell_noise_float as cell_noise_float, mx_cell_noise_vec3 as cell_noise_vec3, mx_unifiednoise2d as unifiednoise2d, mx_unifiednoise3d as unifiednoise3d, mx_fractal_noise_float_2d as fractal_noise_float_2d, @@ -95,7 +96,8 @@ export const mx_unifiednoise3d = ( noiseType, texcoord = uv(), freq = vec3( 1, 1 export const mx_worley_noise_float = ( texcoord = uv(), jitter = 1, style = 0 ) => mx_worley_noise_float_3d( texcoord.convert( 'vec2|vec3' ), jitter, style ); export const mx_worley_noise_vec2 = ( texcoord = uv(), jitter = 1 ) => worley_noise_vec2( texcoord.convert( 'vec2|vec3' ), jitter, int( 1 ) ); -export const mx_worley_noise_vec3 = ( texcoord = uv(), jitter = 1 ) => worley_noise_vec3( texcoord.convert( 'vec2|vec3' ), jitter, int( 1 ) ); +export const mx_worley_noise_vec3 = ( texcoord = uv(), jitter = 1, metric = 1 ) => worley_noise_vec3( texcoord.convert( 'vec2|vec3' ), jitter, int( metric ) ); +export const mx_worley_noise_vec3_style = ( texcoord = uv(), jitter = 1, style = 0, metric = 0 ) => worley_noise_vec3_style( texcoord.convert( 'vec2|vec3' ), jitter, int( style ), int( metric ) ); export const mx_cell_noise_float = ( texcoord = uv() ) => cell_noise_float( texcoord.convert( 'vec2|vec3' ) ); diff --git a/src/nodes/materialx/MaterialXNoise.js b/src/nodes/materialx/MaterialXNoise.js index 66e482c6e4ae0f..4732d7d819add5 100644 --- a/src/nodes/materialx/MaterialXNoise.js +++ b/src/nodes/materialx/MaterialXNoise.js @@ -363,6 +363,9 @@ export const mx_rotl32 = /*@__PURE__*/ Fn( ( [ x_immutable, k_immutable ] ) => { export const mx_bjmix = /*@__PURE__*/ Fn( ( [ a, b, c ] ) => { + a = uint( a ).toVar(); + b = uint( b ).toVar(); + c = uint( c ).toVar(); a.subAssign( c ); a.bitXorAssign( mx_rotl32( c, int( 4 ) ) ); c.addAssign( b ); @@ -382,6 +385,16 @@ export const mx_bjmix = /*@__PURE__*/ Fn( ( [ a, b, c ] ) => { c.bitXorAssign( mx_rotl32( b, int( 4 ) ) ); b.addAssign( a ); + return uvec3( a, b, c ); + +} ).setLayout( { + name: 'mx_bjmix', + type: 'uvec3', + inputs: [ + { name: 'a', type: 'uint' }, + { name: 'b', type: 'uint' }, + { name: 'c', type: 'uint' } + ] } ); export const mx_bjfinal = /*@__PURE__*/ Fn( ( [ a_immutable, b_immutable, c_immutable ] ) => { @@ -420,7 +433,7 @@ export const mx_bits_to_01 = /*@__PURE__*/ Fn( ( [ bits_immutable ] ) => { const bits = uint( bits_immutable ).toVar(); - return float( bits ).div( float( uint( int( 0xffffffff ) ) ) ); + return float( bits ).div( float( uint( 0xffffffff ) ) ); } ).setLayout( { name: 'mx_bits_to_01', @@ -448,7 +461,7 @@ export const mx_hash_int_0 = /*@__PURE__*/ Fn( ( [ x_immutable ] ) => { const x = int( x_immutable ).toVar(); const len = uint( uint( 1 ) ).toVar(); - const seed = uint( uint( int( 0xdeadbeef ) ).add( len.shiftLeft( uint( 2 ) ) ).add( uint( 13 ) ) ).toVar(); + const seed = uint( uint( 0xdeadbeef ).add( len.shiftLeft( uint( 2 ) ) ).add( uint( 13 ) ) ).toVar(); return mx_bjfinal( seed.add( uint( x ) ), seed, seed ); @@ -466,7 +479,7 @@ export const mx_hash_int_1 = /*@__PURE__*/ Fn( ( [ x_immutable, y_immutable ] ) const x = int( x_immutable ).toVar(); const len = uint( uint( 2 ) ).toVar(); const a = uint().toVar(), b = uint().toVar(), c = uint().toVar(); - a.assign( b.assign( c.assign( uint( int( 0xdeadbeef ) ).add( len.shiftLeft( uint( 2 ) ) ).add( uint( 13 ) ) ) ) ); + a.assign( b.assign( c.assign( uint( 0xdeadbeef ).add( len.shiftLeft( uint( 2 ) ) ).add( uint( 13 ) ) ) ) ); a.addAssign( uint( x ) ); b.addAssign( uint( y ) ); @@ -488,7 +501,7 @@ export const mx_hash_int_2 = /*@__PURE__*/ Fn( ( [ x_immutable, y_immutable, z_i const x = int( x_immutable ).toVar(); const len = uint( uint( 3 ) ).toVar(); const a = uint().toVar(), b = uint().toVar(), c = uint().toVar(); - a.assign( b.assign( c.assign( uint( int( 0xdeadbeef ) ).add( len.shiftLeft( uint( 2 ) ) ).add( uint( 13 ) ) ) ) ); + a.assign( b.assign( c.assign( uint( 0xdeadbeef ).add( len.shiftLeft( uint( 2 ) ) ).add( uint( 13 ) ) ) ) ); a.addAssign( uint( x ) ); b.addAssign( uint( y ) ); c.addAssign( uint( z ) ); @@ -513,11 +526,14 @@ export const mx_hash_int_3 = /*@__PURE__*/ Fn( ( [ x_immutable, y_immutable, z_i const x = int( x_immutable ).toVar(); const len = uint( uint( 4 ) ).toVar(); const a = uint().toVar(), b = uint().toVar(), c = uint().toVar(); - a.assign( b.assign( c.assign( uint( int( 0xdeadbeef ) ).add( len.shiftLeft( uint( 2 ) ) ).add( uint( 13 ) ) ) ) ); + a.assign( b.assign( c.assign( uint( 0xdeadbeef ).add( len.shiftLeft( uint( 2 ) ) ).add( uint( 13 ) ) ) ) ); a.addAssign( uint( x ) ); b.addAssign( uint( y ) ); c.addAssign( uint( z ) ); - mx_bjmix( a, b, c ); + const mixed = uvec3( mx_bjmix( a, b, c ) ).toVar(); + a.assign( mixed.x ); + b.assign( mixed.y ); + c.assign( mixed.z ); a.addAssign( uint( xx ) ); return mx_bjfinal( a, b, c ); @@ -542,11 +558,14 @@ export const mx_hash_int_4 = /*@__PURE__*/ Fn( ( [ x_immutable, y_immutable, z_i const x = int( x_immutable ).toVar(); const len = uint( uint( 5 ) ).toVar(); const a = uint().toVar(), b = uint().toVar(), c = uint().toVar(); - a.assign( b.assign( c.assign( uint( int( 0xdeadbeef ) ).add( len.shiftLeft( uint( 2 ) ) ).add( uint( 13 ) ) ) ) ); + a.assign( b.assign( c.assign( uint( 0xdeadbeef ).add( len.shiftLeft( uint( 2 ) ) ).add( uint( 13 ) ) ) ) ); a.addAssign( uint( x ) ); b.addAssign( uint( y ) ); c.addAssign( uint( z ) ); - mx_bjmix( a, b, c ); + const mixed = uvec3( mx_bjmix( a, b, c ) ).toVar(); + a.assign( mixed.x ); + b.assign( mixed.y ); + c.assign( mixed.z ); a.addAssign( uint( xx ) ); b.addAssign( uint( yy ) ); @@ -768,24 +787,130 @@ export const mx_cell_noise_float_3 = /*@__PURE__*/ Fn( ( [ p_immutable ] ) => { export const mx_cell_noise_float = /*@__PURE__*/ overloadingFn( [ mx_cell_noise_float_0, mx_cell_noise_float_1, mx_cell_noise_float_2, mx_cell_noise_float_3 ] ); -export const mx_cell_noise_vec3 = /*@__PURE__*/ Fn( ( [ positionInput ] ) => { +export const mx_cell_noise_vec3_0 = /*@__PURE__*/ Fn( ( [ p_immutable ] ) => { + + const p = float( p_immutable ).toVar(); + const ix = int( mx_floor( p ) ).toVar(); + + return vec3( + mx_bits_to_01( mx_hash_int( ix, int( 0 ) ) ), + mx_bits_to_01( mx_hash_int( ix, int( 1 ) ) ), + mx_bits_to_01( mx_hash_int( ix, int( 2 ) ) ) + ); + +} ).setLayout( { + name: 'mx_cell_noise_vec3_0', + type: 'vec3', + inputs: [ + { name: 'p', type: 'float' } + ] +} ); + +export const mx_cell_noise_vec3_1 = /*@__PURE__*/ Fn( ( [ p_immutable ] ) => { + + const p = vec2( p_immutable ).toVar(); + const ix = int( mx_floor( p.x ) ).toVar(); + const iy = int( mx_floor( p.y ) ).toVar(); + + return vec3( + mx_bits_to_01( mx_hash_int( ix, iy, int( 0 ) ) ), + mx_bits_to_01( mx_hash_int( ix, iy, int( 1 ) ) ), + mx_bits_to_01( mx_hash_int( ix, iy, int( 2 ) ) ) + ); + +} ).setLayout( { + name: 'mx_cell_noise_vec3_1', + type: 'vec3', + inputs: [ + { name: 'p', type: 'vec2' } + ] +} ); + +export const mx_cell_noise_vec3_2 = /*@__PURE__*/ Fn( ( [ positionInput ] ) => { + + const position = vec3( positionInput ).toVar(); + const ix = int( floor( position.x ) ).toVar(); + const iy = int( floor( position.y ) ).toVar(); + const iz = int( floor( position.z ) ).toVar(); + const seed = uint( 0xdeadbeef + ( 4 << 2 ) + 13 ).toVar(); + const a = uint().toVar(), b = uint().toVar(), c = uint().toVar(); + a.assign( b.assign( c.assign( seed ) ) ); + a.addAssign( uint( ix ) ); + b.addAssign( uint( iy ) ); + c.addAssign( uint( iz ) ); + + const mixed = uvec3( mx_bjmix( a, b, c ) ).toVar(); + const hash0 = mx_bjfinal( mixed.x, mixed.y, mixed.z ); + const hash1 = mx_bjfinal( add( mixed.x, uint( 1 ) ), mixed.y, mixed.z ); + const hash2 = mx_bjfinal( add( mixed.x, uint( 2 ) ), mixed.y, mixed.z ); + + return vec3( + mx_bits_to_01( hash0 ), + mx_bits_to_01( hash1 ), + mx_bits_to_01( hash2 ) + ); + +} ).setLayout( { + name: 'mx_cell_noise_vec3_2', + type: 'vec3', + inputs: [ + { name: 'p', type: 'vec3' } + ] +} ); + +export const mx_cell_noise_vec3_3 = /*@__PURE__*/ Fn( ( [ p_immutable ] ) => { + + const p = vec4( p_immutable ).toVar(); + const ix = int( mx_floor( p.x ) ).toVar(); + const iy = int( mx_floor( p.y ) ).toVar(); + const iz = int( mx_floor( p.z ) ).toVar(); + const iw = int( mx_floor( p.w ) ).toVar(); + const seed = uint( 0xdeadbeef + ( 5 << 2 ) + 13 ).toVar(); + const a = uint().toVar(), b = uint().toVar(), c = uint().toVar(); + a.assign( b.assign( c.assign( seed ) ) ); + a.addAssign( uint( ix ) ); + b.addAssign( uint( iy ) ); + c.addAssign( uint( iz ) ); + + const mixed = uvec3( mx_bjmix( a, b, c ) ).toVar(); + a.assign( mixed.x ); + b.assign( mixed.y ); + c.assign( mixed.z ); + a.addAssign( uint( iw ) ); + + return vec3( + mx_bits_to_01( mx_bjfinal( a, b, c ) ), + mx_bits_to_01( mx_bjfinal( a, add( b, uint( 1 ) ), c ) ), + mx_bits_to_01( mx_bjfinal( a, add( b, uint( 2 ) ), c ) ) + ); + +} ).setLayout( { + name: 'mx_cell_noise_vec3_3', + type: 'vec3', + inputs: [ + { name: 'p', type: 'vec4' } + ] +} ); + +export const mx_cell_noise_vec3 = /*@__PURE__*/ overloadingFn( [ mx_cell_noise_vec3_0, mx_cell_noise_vec3_1, mx_cell_noise_vec3_2, mx_cell_noise_vec3_3 ] ); + +const mx_cell_noise_vec3_3d = /*@__PURE__*/ Fn( ( [ positionInput ] ) => { const position = vec3( positionInput ).toVar(); const ix = int( floor( position.x ) ).toVar(); const iy = int( floor( position.y ) ).toVar(); const iz = int( floor( position.z ) ).toVar(); const seed = uint( 0xdeadbeef + ( 4 << 2 ) + 13 ).toVar(); - const a = seed.toVar(); - const b = seed.toVar(); - const c = seed.toVar(); + const a = uint().toVar(), b = uint().toVar(), c = uint().toVar(); + a.assign( b.assign( c.assign( seed ) ) ); a.addAssign( uint( ix ) ); b.addAssign( uint( iy ) ); c.addAssign( uint( iz ) ); - mx_bjmix( a, b, c ); - const hash0 = mx_bjfinal( a, b, c ); - const hash1 = mx_bjfinal( add( a, uint( 1 ) ), b, c ); - const hash2 = mx_bjfinal( add( a, uint( 2 ) ), b, c ); + const mixed = uvec3( mx_bjmix( a, b, c ) ).toVar(); + const hash0 = mx_bjfinal( mixed.x, mixed.y, mixed.z ); + const hash1 = mx_bjfinal( add( mixed.x, uint( 1 ) ), mixed.y, mixed.z ); + const hash2 = mx_bjfinal( add( mixed.x, uint( 2 ) ), mixed.y, mixed.z ); return vec3( mx_bits_to_01( hash0 ), @@ -983,7 +1108,7 @@ export const mx_worley_distance_1 = /*@__PURE__*/ Fn( ( [ p_immutable, x_immutab const y = int( y_immutable ).toVar(); const x = int( x_immutable ).toVar(); const p = vec3( p_immutable ).toVar(); - const off = vec3( mx_cell_noise_vec3( vec3( x.add( xoff ), y.add( yoff ), z.add( zoff ) ) ) ).toVar(); + const off = vec3( mx_cell_noise_vec3_3d( vec3( x.add( xoff ), y.add( yoff ), z.add( zoff ) ) ) ).toVar(); off.subAssign( 0.5 ); off.mulAssign( jitter ); off.addAssign( 0.5 ); @@ -1076,8 +1201,8 @@ export const mx_worley_noise_float_3d = /*@__PURE__*/ Fn( ( [ positionInput, jit const position = vec3( positionInput ).toVar(); const jitter = float( jitterInput ).toVar(); const style = int( styleInput ).toVar(); - const baseCell = vec3( floor( position.x ), floor( position.y ), floor( position.z ) ).toVar(); - const localpos = fract( position ).toVar(); + const X = int().toVar(), Y = int().toVar(), Z = int().toVar(); + const localpos = vec3( mx_floorfrac( position.x, X ), mx_floorfrac( position.y, Y ), mx_floorfrac( position.z, Z ) ).toVar(); const sqdist = float( 1e6 ).toVar(); const minpos = vec3( 0, 0, 0 ).toVar(); @@ -1087,13 +1212,12 @@ export const mx_worley_noise_float_3d = /*@__PURE__*/ Fn( ( [ positionInput, jit Loop( { start: - 1, end: int( 1 ), name: 'z', condition: '<=' }, ( { z } ) => { - const cellCoords = vec3( baseCell.x.add( float( x ) ), baseCell.y.add( float( y ) ), baseCell.z.add( float( z ) ) ).toVar(); - const off = vec3( mx_cell_noise_vec3( cellCoords ) ).toVar(); + const dist = float( mx_worley_distance( localpos, x, y, z, X, Y, Z, jitter, int( 0 ) ) ).toVar(); + const off = vec3( mx_cell_noise_vec3_3d( vec3( X.add( x ), Y.add( y ), Z.add( z ) ) ) ).toVar(); off.subAssign( 0.5 ); off.mulAssign( jitter ); off.addAssign( 0.5 ); const cellpos = vec3( vec3( float( x ), float( y ), float( z ) ).add( off ).sub( localpos ) ).toVar(); - const dist = dot( cellpos, cellpos ).toVar(); If( dist.lessThan( sqdist ), () => { @@ -1388,6 +1512,119 @@ export const mx_worley_noise_vec3_1 = /*@__PURE__*/ Fn( ( [ p_immutable, jitter_ export const mx_worley_noise_vec3 = /*@__PURE__*/ overloadingFn( [ mx_worley_noise_vec3_0, mx_worley_noise_vec3_1 ] ); +export const mx_worley_noise_vec3_style_0 = /*@__PURE__*/ Fn( ( [ p_immutable, jitter_immutable, style_immutable, metric_immutable ] ) => { + + const metric = int( metric_immutable ).toVar(); + const style = int( style_immutable ).toVar(); + const jitter = float( jitter_immutable ).toVar(); + const p = vec2( p_immutable ).toVar(); + const X = int().toVar(), Y = int().toVar(); + const localpos = vec2( mx_floorfrac( p.x, X ), mx_floorfrac( p.y, Y ) ).toVar(); + const sqdist = float( 1e6 ).toVar(); + const minpos = vec2( 0, 0 ).toVar(); + + Loop( { start: - 1, end: int( 1 ), name: 'x', condition: '<=' }, ( { x } ) => { + + Loop( { start: - 1, end: int( 1 ), name: 'y', condition: '<=' }, ( { y } ) => { + + const dist = float( mx_worley_distance( localpos, x, y, X, Y, jitter, metric ) ).toVar(); + const tmp = vec3( mx_cell_noise_vec3( vec2( X.add( x ), Y.add( y ) ) ) ).toVar(); + const off = vec2( tmp.x, tmp.y ).toVar(); + off.subAssign( 0.5 ); + off.mulAssign( jitter ); + off.addAssign( 0.5 ); + const cellpos = vec2( vec2( float( x ), float( y ) ).add( off ).sub( localpos ) ).toVar(); + + If( dist.lessThan( sqdist ), () => { + + sqdist.assign( dist ); + minpos.assign( cellpos ); + + } ); + + } ); + + } ); + + const result = vec3( mx_worley_noise_vec3( p, jitter, metric ) ).toVar(); + If( style.equal( int( 1 ) ), () => { + + result.assign( mx_cell_noise_vec3( minpos.add( p ) ) ); + + } ); + + return result; + +} ).setLayout( { + name: 'mx_worley_noise_vec3_style_0', + type: 'vec3', + inputs: [ + { name: 'p', type: 'vec2' }, + { name: 'jitter', type: 'float' }, + { name: 'style', type: 'int' }, + { name: 'metric', type: 'int' } + ] +} ); + +export const mx_worley_noise_vec3_style_1 = /*@__PURE__*/ Fn( ( [ p_immutable, jitter_immutable, style_immutable, metric_immutable ] ) => { + + const metric = int( metric_immutable ).toVar(); + const style = int( style_immutable ).toVar(); + const jitter = float( jitter_immutable ).toVar(); + const p = vec3( p_immutable ).toVar(); + const X = int().toVar(), Y = int().toVar(), Z = int().toVar(); + const localpos = vec3( mx_floorfrac( p.x, X ), mx_floorfrac( p.y, Y ), mx_floorfrac( p.z, Z ) ).toVar(); + const sqdist = float( 1e6 ).toVar(); + const minpos = vec3( 0, 0, 0 ).toVar(); + + Loop( { start: - 1, end: int( 1 ), name: 'x', condition: '<=' }, ( { x } ) => { + + Loop( { start: - 1, end: int( 1 ), name: 'y', condition: '<=' }, ( { y } ) => { + + Loop( { start: - 1, end: int( 1 ), name: 'z', condition: '<=' }, ( { z } ) => { + + const dist = float( mx_worley_distance( localpos, x, y, z, X, Y, Z, jitter, metric ) ).toVar(); + const off = vec3( mx_cell_noise_vec3_3d( vec3( X.add( x ), Y.add( y ), Z.add( z ) ) ) ).toVar(); + off.subAssign( 0.5 ); + off.mulAssign( jitter ); + off.addAssign( 0.5 ); + const cellpos = vec3( vec3( float( x ), float( y ), float( z ) ).add( off ).sub( localpos ) ).toVar(); + + If( dist.lessThan( sqdist ), () => { + + sqdist.assign( dist ); + minpos.assign( cellpos ); + + } ); + + } ); + + } ); + + } ); + + const result = vec3( mx_worley_noise_vec3( p, jitter, metric ) ).toVar(); + If( style.equal( int( 1 ) ), () => { + + result.assign( mx_cell_noise_vec3_3d( minpos.add( p ) ) ); + + } ); + + return result; + +} ).setLayout( { + name: 'mx_worley_noise_vec3_style_1', + type: 'vec3', + inputs: [ + { name: 'p', type: 'vec3' }, + { name: 'jitter', type: 'float' }, + { name: 'style', type: 'int' }, + { name: 'metric', type: 'int' } + ] +} ); + +export const mx_worley_noise_vec3_style = /*@__PURE__*/ overloadingFn( [ mx_worley_noise_vec3_style_0, mx_worley_noise_vec3_style_1 ] ); + // Unified Noise 2D export const mx_unifiednoise2d = /*@__PURE__*/ Fn( ( [ noiseTypeInput, From 1abb88d97c3a762baccebb801d7574a9e6d6b9b4 Mon Sep 17 00:00:00 2001 From: Ben Houston Date: Sat, 2 May 2026 20:59:29 -0400 Subject: [PATCH 11/40] fix MaterialX convert node. --- .../compile/MaterialXCompileRegistry.js | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js b/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js index 148827f0d47e9a..4be0aa808df2ce 100644 --- a/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js +++ b/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js @@ -88,6 +88,23 @@ const compileConvertNode = ( nodeX ) => { const compileConstantNode = ( nodeX ) => nodeX.getNodeByName( 'value' ); +const compileBooleanConditionalNode = ( nodeX ) => { + + if ( nodeX.type !== 'boolean' ) return null; + + const value1Default = nodeX.element === 'ifequal' ? float( 0 ) : float( 1 ); + const value2Default = float( 0 ); + const value1 = nodeX.getNodeByName( 'value1' ) || value1Default; + const value2 = nodeX.getNodeByName( 'value2' ) || value2Default; + + if ( nodeX.element === 'ifgreater' ) return value1.greaterThan( value2 ); + if ( nodeX.element === 'ifgreatereq' ) return value1.greaterThanEqual( value2 ); + if ( nodeX.element === 'ifequal' ) return value1.equal( value2 ); + + return null; + +}; + const compileSpaceInputNode = ( nodeX, objectNode, worldNode ) => { const rawSpace = nodeX.getInputValueByName( 'space' ) ?? nodeX.getAttribute( 'space' ); @@ -394,6 +411,13 @@ function compileNodeFromRegistry( nodeX, out, compileContext ) { } + const booleanConditional = compileBooleanConditionalNode( nodeX ); + if ( booleanConditional ) { + + return booleanConditional; + + } + const nodeElement = compileContext.nodeLibrary[ nodeX.element ]; if ( ! nodeElement ) { From 8106e7c60ba28976c3da0352cb17576309cf8b70 Mon Sep 17 00:00:00 2001 From: Ben Houston Date: Sat, 2 May 2026 21:18:52 -0400 Subject: [PATCH 12/40] fix mx_unifiednoise3d --- src/nodes/materialx/MaterialXNoise.js | 62 ++++++++++++++++----------- 1 file changed, 37 insertions(+), 25 deletions(-) diff --git a/src/nodes/materialx/MaterialXNoise.js b/src/nodes/materialx/MaterialXNoise.js index 4732d7d819add5..7ffbbb7d388f8e 100644 --- a/src/nodes/materialx/MaterialXNoise.js +++ b/src/nodes/materialx/MaterialXNoise.js @@ -1697,43 +1697,55 @@ export const mx_unifiednoise2d = /*@__PURE__*/ Fn( ( [ } ); // Unified Noise 3D -export const mx_unifiednoise3d = ( - noiseType = 0, - position = vec3( 0, 0, 0 ), - freq = vec3( 1, 1, 1 ), - offset = vec3( 0, 0, 0 ), - jitter = 1, - outmin = 0, - outmax = 1, - clampoutput = true, - octaves = 3, - lacunarity = 2, - diminish = 0.5, - style = 0 -) => { - - const applyFreq = mul( position, freq ); - const applyOffset = add( applyFreq, offset ); - const cellJitterMult = mul( sub( jitter, 1 ), 90000 ); - const applyCellJitter = mx_rotate3d_noise( applyOffset, cellJitterMult, vec3( 0.1, 1, 0 ) ); +export const mx_unifiednoise3d = /*@__PURE__*/ Fn( ( [ + noiseTypeInput, + positionInput, + freqInput, + offsetInput, + jitterInput, + outminInput, + outmaxInput, + clampoutputInput, + octavesInput, + lacunarityInput, + diminishInput, + styleInput +] ) => { + + const noiseType = int( noiseTypeInput ).toVar(); + const position = vec3( positionInput ).toVar(); + const freq = vec3( freqInput ).toVar(); + const offset = vec3( offsetInput ).toVar(); + const jitter = float( jitterInput ).toVar(); + const outmin = float( outminInput ).toVar(); + const outmax = float( outmaxInput ).toVar(); + const clampoutput = float( clampoutputInput ).toVar(); + const octaves = int( octavesInput ).toVar(); + const lacunarity = float( lacunarityInput ).toVar(); + const diminish = float( diminishInput ).toVar(); + const style = int( styleInput ).toVar(); + + const applyFreq = mul( position, freq ).toVar(); + const applyOffset = add( applyFreq, offset ).toVar(); + const cellJitterMult = mul( sub( jitter, 1 ), 90000 ).toVar(); + const applyCellJitter = mx_rotate3d_noise( applyOffset, cellJitterMult, vec3( 0.1, 1, 0 ) ).toVar(); const perlin = mx_perlin_noise_float_scaled( applyCellJitter, 0.5, 0.5 ); const cell = mx_cell_noise_float( applyCellJitter ); const worley = mx_worley_noise_float_3d( applyOffset, jitter, style ); const fractal = mx_fractal_noise_float( applyCellJitter, octaves, lacunarity, diminish ); - const typeFloat = float( noiseType ); const result = perlin.toVar(); - If( typeFloat.equal( float( 1 ) ), () => { + If( noiseType.equal( int( 1 ) ), () => { result.assign( cell ); } ); - If( typeFloat.equal( float( 2 ) ), () => { + If( noiseType.equal( int( 2 ) ), () => { result.assign( worley ); } ); - If( typeFloat.equal( float( 3 ) ), () => { + If( noiseType.equal( int( 3 ) ), () => { result.assign( fractal ); @@ -1743,7 +1755,7 @@ export const mx_unifiednoise3d = ( const clamped = clamp( ranged, outmin, outmax ).toVar(); const output = ranged.toVar(); - If( float( clampoutput ).equal( float( 1 ) ), () => { + If( clampoutput.equal( float( 1 ) ), () => { output.assign( clamped ); @@ -1751,4 +1763,4 @@ export const mx_unifiednoise3d = ( return output; -}; +} ); From cba0ab5b67535bd278499310cde23ba26afa6c2c Mon Sep 17 00:00:00 2001 From: Ben Houston Date: Sat, 2 May 2026 22:12:25 -0400 Subject: [PATCH 13/40] improved matrix33 and matrix44 support in MaterialX --- .../loaders/materialx/MaterialXDocument.js | 2 - .../compile/MaterialXCompileRegistry.js | 130 +++++++++++++++++- 2 files changed, 129 insertions(+), 3 deletions(-) diff --git a/examples/jsm/loaders/materialx/MaterialXDocument.js b/examples/jsm/loaders/materialx/MaterialXDocument.js index 739254f704cd39..27e8790010a3bd 100644 --- a/examples/jsm/loaders/materialx/MaterialXDocument.js +++ b/examples/jsm/loaders/materialx/MaterialXDocument.js @@ -20,7 +20,6 @@ import { uv, mat3, mat4, - inverse, element, mx_transform_uv, mx_srgb_texture_to_lin_rec709, @@ -734,7 +733,6 @@ class MaterialXDocument { mxHextileCoord, mxHextileComputeBlendWeights, invertConstantMatrixValues, - invertMatrixNode: inverse, IDENTITY_MAT3_VALUES, IDENTITY_MAT4_VALUES, }; diff --git a/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js b/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js index 4be0aa808df2ce..5f25902572199b 100644 --- a/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js +++ b/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js @@ -22,6 +22,7 @@ import { sub, mix, dot, + div, normalize, mx_atan2, } from 'three/tsl'; @@ -335,6 +336,131 @@ const compileTransformMatrixNode = ( nodeX, compileContext ) => { }; +const compileCreateMatrixNode = ( nodeX ) => { + + if ( nodeX.type === 'matrix44' ) { + + const vector3Input = nodeX.getAttribute( 'nodedef' ) === 'ND_creatematrix_vector3_matrix44'; + const toVec4Input = ( name, fallback, w ) => { + + const input = nodeX.getNodeByName( name ) || fallback; + return vector3Input ? vec4( element( input, 0 ), element( input, 1 ), element( input, 2 ), w ) : input; + + }; + const in1 = toVec4Input( 'in1', vector3Input ? vec3( 1, 0, 0 ) : vec4( 1, 0, 0, 0 ), 0 ); + const in2 = toVec4Input( 'in2', vector3Input ? vec3( 0, 1, 0 ) : vec4( 0, 1, 0, 0 ), 0 ); + const in3 = toVec4Input( 'in3', vector3Input ? vec3( 0, 0, 1 ) : vec4( 0, 0, 1, 0 ), 0 ); + const in4 = toVec4Input( 'in4', vector3Input ? vec3( 0, 0, 0 ) : vec4( 0, 0, 0, 1 ), 1 ); + return mat4( in1, in2, in3, in4 ); + + } + + const in1 = nodeX.getNodeByName( 'in1' ) || vec3( 1, 0, 0 ); + const in2 = nodeX.getNodeByName( 'in2' ) || vec3( 0, 1, 0 ); + const in3 = nodeX.getNodeByName( 'in3' ) || vec3( 0, 0, 1 ); + return mat3( in1, in2, in3 ); + +}; + +const getMatrixElement = ( matrixNode, row, column ) => element( element( matrixNode, column ), row ); + +const determinant2 = ( a, b, c, d ) => sub( mul( a, d ), mul( b, c ) ); + +const determinant3 = ( m00, m01, m02, m10, m11, m12, m20, m21, m22 ) => + add( + sub( mul( m00, determinant2( m11, m12, m21, m22 ) ), mul( m01, determinant2( m10, m12, m20, m22 ) ) ), + mul( m02, determinant2( m10, m11, m20, m21 ) ), + ); + +const compileInvertMatrix3Node = ( matrixNode ) => { + + const m00 = getMatrixElement( matrixNode, 0, 0 ); + const m01 = getMatrixElement( matrixNode, 0, 1 ); + const m02 = getMatrixElement( matrixNode, 0, 2 ); + const m10 = getMatrixElement( matrixNode, 1, 0 ); + const m11 = getMatrixElement( matrixNode, 1, 1 ); + const m12 = getMatrixElement( matrixNode, 1, 2 ); + const m20 = getMatrixElement( matrixNode, 2, 0 ); + const m21 = getMatrixElement( matrixNode, 2, 1 ); + const m22 = getMatrixElement( matrixNode, 2, 2 ); + + const inv00 = determinant2( m11, m12, m21, m22 ); + const inv01 = determinant2( m02, m01, m22, m21 ); + const inv02 = determinant2( m01, m02, m11, m12 ); + const inv10 = determinant2( m12, m10, m22, m20 ); + const inv11 = determinant2( m00, m02, m20, m22 ); + const inv12 = determinant2( m02, m00, m12, m10 ); + const inv20 = determinant2( m10, m11, m20, m21 ); + const inv21 = determinant2( m01, m00, m21, m20 ); + const inv22 = determinant2( m00, m01, m10, m11 ); + const determinant = add( add( mul( m00, inv00 ), mul( m01, inv10 ) ), mul( m02, inv20 ) ); + + return mat3( + div( inv00, determinant ), div( inv10, determinant ), div( inv20, determinant ), + div( inv01, determinant ), div( inv11, determinant ), div( inv21, determinant ), + div( inv02, determinant ), div( inv12, determinant ), div( inv22, determinant ), + ); + +}; + +const compileInvertMatrix4Node = ( matrixNode ) => { + + const m = []; + for ( let row = 0; row < 4; row ++ ) { + + m[ row ] = []; + for ( let column = 0; column < 4; column ++ ) { + + m[ row ][ column ] = getMatrixElement( matrixNode, row, column ); + + } + + } + + const getMinor3 = ( skipRow, skipColumn ) => { + + const rows = [ 0, 1, 2, 3 ].filter( ( row ) => row !== skipRow ); + const columns = [ 0, 1, 2, 3 ].filter( ( column ) => column !== skipColumn ); + return determinant3( + m[ rows[ 0 ] ][ columns[ 0 ] ], m[ rows[ 0 ] ][ columns[ 1 ] ], m[ rows[ 0 ] ][ columns[ 2 ] ], + m[ rows[ 1 ] ][ columns[ 0 ] ], m[ rows[ 1 ] ][ columns[ 1 ] ], m[ rows[ 1 ] ][ columns[ 2 ] ], + m[ rows[ 2 ] ][ columns[ 0 ] ], m[ rows[ 2 ] ][ columns[ 1 ] ], m[ rows[ 2 ] ][ columns[ 2 ] ], + ); + + }; + + const cofactors = []; + for ( let row = 0; row < 4; row ++ ) { + + cofactors[ row ] = []; + for ( let column = 0; column < 4; column ++ ) { + + const minor = getMinor3( row, column ); + cofactors[ row ][ column ] = ( row + column ) % 2 === 0 ? minor : mul( minor, - 1 ); + + } + + } + + const determinant = add( + add( mul( m[ 0 ][ 0 ], cofactors[ 0 ][ 0 ] ), mul( m[ 0 ][ 1 ], cofactors[ 0 ][ 1 ] ) ), + add( mul( m[ 0 ][ 2 ], cofactors[ 0 ][ 2 ] ), mul( m[ 0 ][ 3 ], cofactors[ 0 ][ 3 ] ) ), + ); + const values = []; + for ( let column = 0; column < 4; column ++ ) { + + for ( let row = 0; row < 4; row ++ ) { + + values.push( div( cofactors[ column ][ row ], determinant ) ); + + } + + } + + return mat4( ...values ); + +}; + const compileInvertMatrixNode = ( nodeX, compileContext ) => { const inInput = nodeX.getChildByName( 'in' ); @@ -367,7 +493,8 @@ const compileInvertMatrixNode = ( nodeX, compileContext ) => { const size = matrixType === 'matrix33' ? 3 : 4; const fallback = size === 3 ? mat3( ...compileContext.IDENTITY_MAT3_VALUES ) : mat4( ...compileContext.IDENTITY_MAT4_VALUES ); - return compileContext.invertMatrixNode( inNode === undefined || inNode === null ? fallback : inNode, size ); + const matrixNode = inNode === undefined || inNode === null ? fallback : inNode; + return size === 3 ? compileInvertMatrix3Node( matrixNode ) : compileInvertMatrix4Node( matrixNode ); } @@ -397,6 +524,7 @@ function createMaterialXCompileRegistry() { register( registry, [ 'gltf_iridescence_thickness' ], ( nodeX, out, compileContext ) => compileGltfIridescenceThicknessNode( nodeX, compileContext ) ); register( registry, [ 'transformmatrix' ], ( nodeX, out, compileContext ) => compileTransformMatrixNode( nodeX, compileContext ) ); + register( registry, [ 'creatematrix' ], ( nodeX ) => compileCreateMatrixNode( nodeX ) ); register( registry, [ 'invertmatrix' ], ( nodeX, out, compileContext ) => compileInvertMatrixNode( nodeX, compileContext ) ); return registry; From a405964015567fe7a2d1aa22e6ba60d8ac971f0c Mon Sep 17 00:00:00 2001 From: Ben Houston Date: Sun, 3 May 2026 07:59:44 -0400 Subject: [PATCH 14/40] support bool and numeric input to materialx bool operators. --- .../loaders/materialx/MaterialXDocument.js | 24 ++++++++----- .../loaders/materialx/MaterialXNodeLibrary.js | 25 ++++++++++--- .../compile/MaterialXCompileRegistry.js | 35 +++++++++++++++++++ 3 files changed, 71 insertions(+), 13 deletions(-) diff --git a/examples/jsm/loaders/materialx/MaterialXDocument.js b/examples/jsm/loaders/materialx/MaterialXDocument.js index 27e8790010a3bd..9f5639fa834e3a 100644 --- a/examples/jsm/loaders/materialx/MaterialXDocument.js +++ b/examples/jsm/loaders/materialx/MaterialXDocument.js @@ -12,6 +12,7 @@ import { import { float, int, + bool, sub, vec2, vec3, @@ -52,6 +53,7 @@ const NODE_CLASS_BY_TYPE = { matrix33: mat3, matrix44: mat4, }; +const BOOLEAN_OPERATOR_OPS = new Set( [ '&&', '||', '^^', '!', '==', '!=', '<', '>', '<=', '>=' ] ); const OUTPUT_CHANNELS = { outx: 0, outr: 0, @@ -300,21 +302,27 @@ class MaterialXNode { } - toBooleanMaskNode( node ) { + toBooleanNode( node ) { - if ( node && node.nodeType === 'bool' && typeof node.select === 'function' ) { + if ( typeof node === 'boolean' ) { - return node.select( float( 1 ), float( 0 ) ); + return bool( node ); } - if ( typeof node === 'boolean' ) { + if ( typeof node === 'number' ) { - return float( node ? 1 : 0 ); + return bool( node !== 0 ); } - return node; + if ( node && ( node.nodeType === 'bool' || ( node.isOperatorNode && BOOLEAN_OPERATOR_OPS.has( node.op ) ) ) ) { + + return node; + + } + + return node.notEqual( float( 0 ) ); } @@ -352,7 +360,7 @@ class MaterialXNode { if ( type === 'boolean' ) { const normalized = this.getValue().trim().toLowerCase(); - node = float( normalized === 'true' || normalized === '1' ? 1 : 0 ); + node = bool( normalized === 'true' || normalized === '1' ); } else if ( type === 'matrix33' ) { @@ -420,7 +428,7 @@ class MaterialXNode { const resolvedType = channelRequested ? 'float' : type; if ( resolvedType === 'boolean' ) { - node = this.toBooleanMaskNode( node ); + node = this.toBooleanNode( node ); } else if ( resolvedType === 'string' ) { diff --git a/examples/jsm/loaders/materialx/MaterialXNodeLibrary.js b/examples/jsm/loaders/materialx/MaterialXNodeLibrary.js index 5a517d6f1fc8a5..284a384d5044db 100644 --- a/examples/jsm/loaders/materialx/MaterialXNodeLibrary.js +++ b/examples/jsm/loaders/materialx/MaterialXNodeLibrary.js @@ -73,6 +73,7 @@ import { mx_heighttonormal, float, int, + bool, color, modelNormalMatrix, modelWorldMatrix, @@ -84,6 +85,10 @@ import { fract, sub, step, + and as tslAnd, + or as tslOr, + xor as tslXor, + not as tslNot, Fn, Loop, } from 'three/tsl'; @@ -115,10 +120,20 @@ const mx_open_pbr_anisotropy = ( roughness = 0, anisotropy = 0 ) => { }; -const mx_and = ( in1, in2 ) => clamp( mul( in1, in2 ), float( 0 ), float( 1 ) ); -const mx_or = ( in1, in2 ) => clamp( add( in1, in2 ), float( 0 ), float( 1 ) ); -const mx_xor = ( in1, in2 ) => abs( sub( in1, in2 ) ); -const mx_not = ( inNode ) => sub( float( 1 ), inNode ); +const BOOLEAN_OPERATOR_OPS = new Set( [ '&&', '||', '^^', '!', '==', '!=', '<', '>', '<=', '>=' ] ); +const isBooleanNode = ( node ) => node && ( node.nodeType === 'bool' || ( node.isOperatorNode && BOOLEAN_OPERATOR_OPS.has( node.op ) ) ); +const mx_boolean = ( inNode ) => { + + if ( typeof inNode === 'boolean' ) return bool( inNode ); + if ( typeof inNode === 'number' ) return bool( inNode !== 0 ); + if ( isBooleanNode( inNode ) ) return inNode; + return inNode.notEqual( float( 0 ) ); + +}; +const mx_and = ( in1, in2 ) => tslAnd( mx_boolean( in1 ), mx_boolean( in2 ) ); +const mx_or = ( in1, in2 ) => tslOr( mx_boolean( in1 ), mx_boolean( in2 ) ); +const mx_xor = ( in1, in2 ) => tslXor( mx_boolean( in1 ), mx_boolean( in2 ) ); +const mx_not = ( inNode ) => tslNot( mx_boolean( inNode ) ); const mx_checkerboard = ( color1, color2, texcoord ) => mix( color1, color2, clamp( checker( texcoord ), 0, 1 ) ); const mx_circle = ( texcoord, center, radius ) => { @@ -449,7 +464,7 @@ const mx_ramp = ( texcoord = vec2( 0, 0 ), type = 0, interpolation = 1, numInter const defaultFloat = ( value ) => () => float( value ); const defaultInt = ( value ) => () => int( value ); -const defaultBool = ( value ) => () => float( value ? 1 : 0 ); +const defaultBool = ( value ) => () => bool( value ); const defaultColor = ( r, g, b ) => () => color( r, g, b ); const defaultVec2 = ( x, y ) => () => vec2( x, y ); const defaultVec3 = ( x, y, z ) => () => vec3( x, y, z ); diff --git a/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js b/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js index 5f25902572199b..e6f2e20415cbbc 100644 --- a/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js +++ b/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js @@ -1,5 +1,6 @@ import { element, + bool, float, mat3, mat4, @@ -41,9 +42,23 @@ const register = ( registry, categories, handler ) => { const UV_FALLBACK_CATEGORIES = new Set( [ 'noise2d', 'fractal2d', 'cellnoise2d', 'worleynoise2d', 'unifiednoise2d' ] ); const SCALAR_TYPES = new Set( [ 'boolean', 'integer', 'float' ] ); const THREE_COMPONENT_TYPES = new Set( [ 'vector2', 'vector3', 'vector4', 'color3', 'color4' ] ); +const BOOLEAN_OPERATOR_OPS = new Set( [ '&&', '||', '^^', '!', '==', '!=', '<', '>', '<=', '>=' ] ); const getDefaultUvNode = ( compileContext ) => compileContext.mxToUvSpace( uv( 0 ) ); +const isBooleanNode = ( node ) => node && ( node.nodeType === 'bool' || ( node.isOperatorNode && BOOLEAN_OPERATOR_OPS.has( node.op ) ) ); + +const toBooleanNode = ( node ) => { + + if ( typeof node === 'boolean' ) return bool( node ); + if ( typeof node === 'number' ) return bool( node !== 0 ); + if ( isBooleanNode( node ) ) return node; + return node.notEqual( float( 0 ) ); + +}; + +const toBooleanMaskNode = ( node ) => toBooleanNode( node ).select( float( 1 ), float( 0 ) ); + const getTextureInputs = ( nodeX, compileContext ) => { const file = nodeX.getChildByName( 'file' ); @@ -70,6 +85,26 @@ const compileConvertNode = ( nodeX ) => { const inputType = inputElement ? inputElement.type : null; const nodeClass = nodeX.getClassFromType( nodeX.type ) || float; + if ( nodeX.type === 'boolean' ) { + + return toBooleanNode( input ); + + } + + if ( inputType === 'boolean' ) { + + const inputMask = toBooleanMaskNode( input ); + if ( THREE_COMPONENT_TYPES.has( nodeX.type ) ) { + + const componentCount = nodeX.type === 'vector2' ? 2 : nodeX.type === 'vector3' || nodeX.type === 'color3' ? 3 : 4; + return nodeClass( ...Array( componentCount ).fill( inputMask ) ); + + } + + return nodeClass( inputMask ); + + } + if ( SCALAR_TYPES.has( inputType ) && THREE_COMPONENT_TYPES.has( nodeX.type ) ) { const componentCount = nodeX.type === 'vector2' ? 2 : nodeX.type === 'vector3' || nodeX.type === 'color3' ? 3 : 4; From 290b15cde64bea3282b5609c166da4d098f22672 Mon Sep 17 00:00:00 2001 From: Ben Houston Date: Sun, 3 May 2026 09:20:11 -0400 Subject: [PATCH 15/40] checkerboard improvements for materialx. --- .../jsm/loaders/materialx/MaterialXNodeLibrary.js | 13 ++++++++++--- .../materialx/compile/MaterialXCompileRegistry.js | 2 +- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/examples/jsm/loaders/materialx/MaterialXNodeLibrary.js b/examples/jsm/loaders/materialx/MaterialXNodeLibrary.js index 284a384d5044db..5e14575ffb416f 100644 --- a/examples/jsm/loaders/materialx/MaterialXNodeLibrary.js +++ b/examples/jsm/loaders/materialx/MaterialXNodeLibrary.js @@ -81,7 +81,6 @@ import { vec2, vec3, vec4, - checker, fract, sub, step, @@ -134,7 +133,13 @@ const mx_and = ( in1, in2 ) => tslAnd( mx_boolean( in1 ), mx_boolean( in2 ) ); const mx_or = ( in1, in2 ) => tslOr( mx_boolean( in1 ), mx_boolean( in2 ) ); const mx_xor = ( in1, in2 ) => tslXor( mx_boolean( in1 ), mx_boolean( in2 ) ); const mx_not = ( inNode ) => tslNot( mx_boolean( inNode ) ); -const mx_checkerboard = ( color1, color2, texcoord ) => mix( color1, color2, clamp( checker( texcoord ), 0, 1 ) ); +const mx_checkerboard = ( color1, color2, uvtiling, uvoffset, texcoord ) => { + + const tiledUv = sub( mul( texcoord, uvtiling ), uvoffset ); + const checkerMix = mx_modulo( dot( floor( tiledUv ), vec2( 1, 1 ) ), float( 2 ) ); + return mix( color2, color1, checkerMix ); + +}; const mx_circle = ( texcoord, center, radius ) => { @@ -946,9 +951,11 @@ const MXElements = [ createMXElement( 'or', mx_or, [ 'in1', 'in2' ], { in1: defaultBool( false ), in2: defaultBool( false ) } ), createMXElement( 'xor', mx_xor, [ 'in1', 'in2' ], { in1: defaultBool( false ), in2: defaultBool( false ) } ), createMXElement( 'not', mx_not, [ 'in' ], { in: defaultBool( false ) } ), - createMXElement( 'checkerboard', mx_checkerboard, [ 'color1', 'color2', 'texcoord' ], { + createMXElement( 'checkerboard', mx_checkerboard, [ 'color1', 'color2', 'uvtiling', 'uvoffset', 'texcoord' ], { color1: defaultColor( 1, 1, 1 ), color2: defaultColor( 0, 0, 0 ), + uvtiling: defaultVec2( 8, 8 ), + uvoffset: defaultVec2( 0, 0 ), texcoord: defaultVec2( 0, 0 ), } ), createMXElement( 'circle', mx_circle, [ 'texcoord', 'center', 'radius' ], { diff --git a/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js b/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js index e6f2e20415cbbc..f58987aadd7cfe 100644 --- a/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js +++ b/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js @@ -39,7 +39,7 @@ const register = ( registry, categories, handler ) => { }; -const UV_FALLBACK_CATEGORIES = new Set( [ 'noise2d', 'fractal2d', 'cellnoise2d', 'worleynoise2d', 'unifiednoise2d' ] ); +const UV_FALLBACK_CATEGORIES = new Set( [ 'checkerboard', 'noise2d', 'fractal2d', 'cellnoise2d', 'worleynoise2d', 'unifiednoise2d' ] ); const SCALAR_TYPES = new Set( [ 'boolean', 'integer', 'float' ] ); const THREE_COMPONENT_TYPES = new Set( [ 'vector2', 'vector3', 'vector4', 'color3', 'color4' ] ); const BOOLEAN_OPERATOR_OPS = new Set( [ '&&', '||', '^^', '!', '==', '!=', '<', '>', '<=', '>=' ] ); From 8117fb73b9044418ea30421f7c34f7e23c3056e2 Mon Sep 17 00:00:00 2001 From: Ben Houston Date: Mon, 4 May 2026 09:42:34 -0400 Subject: [PATCH 16/40] fix hextile support --- .../compile/MaterialXCompileRegistry.js | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js b/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js index f58987aadd7cfe..3fdb1667a5bf14 100644 --- a/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js +++ b/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js @@ -215,21 +215,20 @@ const compileHexTiledTextureNode = ( nodeX, compileContext, category ) => { const falloff = nodeX.getNodeByName( 'falloff' ) || float( 0.5 ); const falloffContrast = nodeX.getNodeByName( 'falloffcontrast' ) || float( 0.5 ); const lumaCoeffs = nodeX.getNodeByName( 'lumacoeffs' ) || vec3( 0.2722287, 0.6740818, 0.0536895 ); - const transformedUv = mul( uvNode, tiling ); + const transformedUv = compileContext.mxFromUvSpace( mul( uvNode, tiling ) ); const tileData = compileContext.mxHextileCoord( transformedUv, rotation, rotationRange, scale, scaleRange, offset, offsetRange ); - const invertY = ( v ) => vec2( element( v, 0 ), mul( element( v, 1 ), - 1 ) ); - let sample0 = texture( textureFile, compileContext.mxFromUvSpace( tileData.coords[ 0 ] ) ).grad( - invertY( tileData.ddx[ 0 ] ), - invertY( tileData.ddy[ 0 ] ), + let sample0 = texture( textureFile, tileData.coords[ 0 ] ).grad( + tileData.ddx[ 0 ], + tileData.ddy[ 0 ], ); - let sample1 = texture( textureFile, compileContext.mxFromUvSpace( tileData.coords[ 1 ] ) ).grad( - invertY( tileData.ddx[ 1 ] ), - invertY( tileData.ddy[ 1 ] ), + let sample1 = texture( textureFile, tileData.coords[ 1 ] ).grad( + tileData.ddx[ 1 ], + tileData.ddy[ 1 ], ); - let sample2 = texture( textureFile, compileContext.mxFromUvSpace( tileData.coords[ 2 ] ) ).grad( - invertY( tileData.ddx[ 2 ] ), - invertY( tileData.ddy[ 2 ] ), + let sample2 = texture( textureFile, tileData.coords[ 2 ] ).grad( + tileData.ddx[ 2 ], + tileData.ddy[ 2 ], ); const sample0Raw = sample0; const sample1Raw = sample1; From bad73e1ebc003e25c3d00cd24ff7a4a00b3cbe2e Mon Sep 17 00:00:00 2001 From: Ben Houston Date: Mon, 4 May 2026 10:03:07 -0400 Subject: [PATCH 17/40] add hextile example to noise exmaple. --- examples/webgpu_materialx_noise.html | 60 ++++++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 3 deletions(-) diff --git a/examples/webgpu_materialx_noise.html b/examples/webgpu_materialx_noise.html index 918026a6f66732..be1da42dcb06f5 100644 --- a/examples/webgpu_materialx_noise.html +++ b/examples/webgpu_materialx_noise.html @@ -33,9 +33,10 @@ import * as THREE from 'three/webgpu'; import { - Fn, int, normalWorld, time, vec2, vec3, + Fn, add, dot, element, float, floor, int, mix, mul, normalWorld, sub, time, uv, vec2, vec3, mx_cell_noise_float, mx_fractal_noise_vec3, + mx_modulo, mx_noise_vec3, mx_unifiednoise2d, mx_unifiednoise3d, @@ -49,6 +50,7 @@ import { HDRCubeTextureLoader } from 'three/addons/loaders/HDRCubeTextureLoader.js'; import { FontLoader } from 'three/addons/loaders/FontLoader.js'; import { TextGeometry } from 'three/addons/geometries/TextGeometry.js'; + import { mxHextileComputeBlendWeights, mxHextileCoord } from 'three/addons/loaders/materialx/MaterialXHextile.js'; let container; @@ -66,7 +68,7 @@ document.body.appendChild( container ); camera = new THREE.PerspectiveCamera( 27, window.innerWidth / window.innerHeight, 1, 1000 ); - camera.position.z = 120; + camera.position.z = 190; scene = new THREE.Scene(); @@ -90,6 +92,44 @@ const unifiedOffset2D = vec2( time.mul( .35 ), time.mul( .19 ) ); const unifiedOffset3D = vec3( time.mul( .35 ), time.mul( .19 ), time.mul( .27 ) ); const unifiedNoise3D = ( noiseType, position, freq, offset, jitter = 1, outmin = 0, outmax = 1, clampoutput = false, octaves = 1, lacunarity = 2, diminish = .5, style = 0 ) => Fn( () => mx_unifiednoise3d( noiseType, position, freq, offset, jitter, outmin, outmax, clampoutput, octaves, lacunarity, diminish, style ) )(); + const textileTexcoord = ( speedX, speedY ) => uv( 0 ).add( vec2( time.mul( speedX ), time.mul( speedY ) ) ); + const checkerboard = ( color1, color2, uvtiling, uvoffset, texcoord ) => { + + const tiledUv = sub( mul( texcoord, uvtiling ), uvoffset ); + const checkerMix = mx_modulo( dot( floor( tiledUv ), vec2( 1, 1 ) ), float( 2 ) ); + return mix( color2, color1, checkerMix ); + + }; + const hextiledCheckerboard = ( texcoord, { + color1 = vec3( 1, 1, 1 ), + color2 = vec3( 0, 0, 0 ), + uvtiling = vec2( 4, 4 ), + uvoffset = vec2( 0, 0 ), + tiling = vec2( 1, 1 ), + rotation = 0, + rotationrange = vec2( 0, 360 ), + scale = 0, + scalerange = vec2( .5, 2 ), + offset = 0, + offsetrange = vec2( 0, 1 ), + falloff = .5, + falloffcontrast = .5, + lumacoeffs = vec3( 0.2722287, 0.6740818, 0.0536895 ) + } = {} ) => { + + const tileData = mxHextileCoord( mul( texcoord, tiling ), rotation, rotationrange, scale, scalerange, offset, offsetrange ); + const c0 = checkerboard( color1, color2, uvtiling, uvoffset, tileData.coords[ 0 ] ); + const c1 = checkerboard( color1, color2, uvtiling, uvoffset, tileData.coords[ 1 ] ); + const c2 = checkerboard( color1, color2, uvtiling, uvoffset, tileData.coords[ 2 ] ); + const luminanceWeights = mix( + vec3( 1, 1, 1 ), + vec3( dot( c0, lumacoeffs ), dot( c1, lumacoeffs ), dot( c2, lumacoeffs ) ), + vec3( falloffcontrast, falloffcontrast, falloffcontrast ) + ); + const blendWeights = mxHextileComputeBlendWeights( luminanceWeights, tileData.weights, falloff ); + return add( add( mul( element( blendWeights, 0 ), c0 ), mul( element( blendWeights, 1 ), c1 ) ), mul( element( blendWeights, 2 ), c2 ) ); + + }; const noiseExamples = [ { label: [ 'Noise', '2D' ], createNode: () => mx_noise_vec3( position2D ) }, @@ -111,7 +151,21 @@ { label: [ 'Unified Worley', '2D style 1' ], createNode: () => mx_unifiednoise2d( int( 2 ), position2D, vec2( 1, 1 ), unifiedOffset2D, 1, 0, 1, false, 1, 2, .5, 1 ) }, { label: [ 'Unified Worley', '3D style 1' ], createNode: () => unifiedNoise3D( int( 2 ), position3D, vec3( 1, 1, 1 ), unifiedOffset3D, 1, 0, 1, false, 1, 2, .5, 1 ) }, { label: [ 'Unified Fractal', '2D' ], createNode: () => mx_unifiednoise2d( int( 3 ), position2D, vec2( 1, 1 ), unifiedOffset2D, 1, 0, 1, false, 3 ) }, - { label: [ 'Unified Fractal', '3D' ], createNode: () => unifiedNoise3D( int( 3 ), position3D, vec3( 1, 1, 1 ), unifiedOffset3D, 1, 0, 1, false, 3 ) } + { label: [ 'Unified Fractal', '3D' ], createNode: () => unifiedNoise3D( int( 3 ), position3D, vec3( 1, 1, 1 ), unifiedOffset3D, 1, 0, 1, false, 3 ) }, + { + label: [ 'Hex Tiled', 'checkerboard' ], + createNode: () => hextiledCheckerboard( textileTexcoord( .08, .04 ), { + color1: vec3( 1, 1, 1 ), + color2: vec3( 0, 0, 0 ), + uvtiling: vec2( 8, 8 ), + tiling: vec2( 2.2, 2.2 ), + rotation: .25, + scale: .1, + offset: .2, + falloff: .35, + falloffcontrast: .25 + } ) + } ]; createNoiseGrid( noiseExamples, geometry, font, labelMaterial ); From 619cf89b7128ffa2bf4a303e0e03f01628520c62 Mon Sep 17 00:00:00 2001 From: Ben Houston Date: Mon, 4 May 2026 10:06:50 -0400 Subject: [PATCH 18/40] 2D noise examples should use the UVs, not position. --- examples/webgpu_materialx_noise.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/webgpu_materialx_noise.html b/examples/webgpu_materialx_noise.html index be1da42dcb06f5..b8d9f9f0462e18 100644 --- a/examples/webgpu_materialx_noise.html +++ b/examples/webgpu_materialx_noise.html @@ -85,14 +85,14 @@ const geometry = new THREE.SphereGeometry( 5, 64, 32 ); const labelMaterial = new THREE.MeshBasicMaterial( { color: 0xffffff } ); - const position2D = vec2( normalWorld.x, normalWorld.y ).mul( 4 ).add( vec2( time.mul( .35 ), time.mul( .19 ) ) ); + const position2D = uv( 0 ).mul( 12 ).add( vec2( time.mul( .35 ), time.mul( .19 ) ) ); const position3D = normalWorld.mul( 4 ).add( vec3( time.mul( .35 ), time.mul( .19 ), time.mul( .27 ) ) ); const fractal2D = vec3( position2D.x, position2D.y, 0 ).mul( .35 ); const fractal3D = position3D.mul( .35 ); const unifiedOffset2D = vec2( time.mul( .35 ), time.mul( .19 ) ); const unifiedOffset3D = vec3( time.mul( .35 ), time.mul( .19 ), time.mul( .27 ) ); const unifiedNoise3D = ( noiseType, position, freq, offset, jitter = 1, outmin = 0, outmax = 1, clampoutput = false, octaves = 1, lacunarity = 2, diminish = .5, style = 0 ) => Fn( () => mx_unifiednoise3d( noiseType, position, freq, offset, jitter, outmin, outmax, clampoutput, octaves, lacunarity, diminish, style ) )(); - const textileTexcoord = ( speedX, speedY ) => uv( 0 ).add( vec2( time.mul( speedX ), time.mul( speedY ) ) ); + const textileTexcoord = ( speedX, speedY ) => uv( 0 ).mul( 3 ).add( vec2( time.mul( speedX ), time.mul( speedY ) ) ); const checkerboard = ( color1, color2, uvtiling, uvoffset, texcoord ) => { const tiledUv = sub( mul( texcoord, uvtiling ), uvoffset ); From b878788d57ebabca8286c5d41c384ce2adb9bc3b Mon Sep 17 00:00:00 2001 From: sunag Date: Tue, 5 May 2026 01:49:19 -0300 Subject: [PATCH 19/40] update `webgpu_lights_projector` example --- examples/webgpu_lights_projector.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/webgpu_lights_projector.html b/examples/webgpu_lights_projector.html index e4e67aab5efc04..380841e4e8adf2 100644 --- a/examples/webgpu_lights_projector.html +++ b/examples/webgpu_lights_projector.html @@ -91,7 +91,7 @@ const causticEffect = Fn( ( [ projectorUV ] ) => { - const waterLayer0 = mx_worley_noise_float( projectorUV.mul( 10 ).add( time ) ); + const waterLayer0 = mx_worley_noise_float( projectorUV.mul( 10 ).add( time ) ).pow( 2 ); const caustic = waterLayer0.mul( color( 0x5abcd8 ) ).mul( 2 ); From 8f176c38a42863776b69822ab797af124cefa228 Mon Sep 17 00:00:00 2001 From: sunag Date: Tue, 5 May 2026 01:49:52 -0300 Subject: [PATCH 20/40] update `webgpu_backdrop_water` example --- examples/webgpu_backdrop_water.html | 37 +++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/examples/webgpu_backdrop_water.html b/examples/webgpu_backdrop_water.html index eda0616752cb9e..afa54e7ab00315 100644 --- a/examples/webgpu_backdrop_water.html +++ b/examples/webgpu_backdrop_water.html @@ -61,6 +61,16 @@ const sunLight = new THREE.DirectionalLight( 0xFFE499, 5 ); sunLight.position.set( .5, 3, .5 ); + sunLight.castShadow = true; + sunLight.shadow.mapSize.width = 512; + sunLight.shadow.mapSize.height = 512; + sunLight.shadow.camera.near = 0.5; + sunLight.shadow.camera.far = 15; + sunLight.shadow.camera.left = - 1.5; + sunLight.shadow.camera.right = 1.5; + sunLight.shadow.camera.top = 1.5; + sunLight.shadow.camera.bottom = - 1.5; + sunLight.shadow.bias = - 0.001; const waterAmbientLight = new THREE.HemisphereLight( 0x333366, 0x74ccf4, 5 ); const skyAmbientLight = new THREE.HemisphereLight( 0x74ccf4, 0, 1 ); @@ -78,6 +88,16 @@ loader.load( 'models/gltf/Michelle.glb', function ( gltf ) { model = gltf.scene; + model.traverse( ( child ) => { + + if ( child.isMesh ) { + + child.castShadow = true; + child.receiveShadow = true; + + } + + } ); mixer = new THREE.AnimationMixer( model ); @@ -132,8 +152,8 @@ const t = time.mul( .8 ); const floorUV = positionWorld.xzy; - const waterLayer0 = mx_worley_noise_float( floorUV.mul( 4 ).add( t ) ); - const waterLayer1 = mx_worley_noise_float( floorUV.mul( 2 ).add( t ) ); + const waterLayer0 = mx_worley_noise_float( floorUV.mul( 6 ).add( t ) ).pow( 2 ); + const waterLayer1 = mx_worley_noise_float( floorUV.mul( 3 ).add( t ) ).pow( 2 ); const waterIntensity = waterLayer0.mul( waterLayer1 ); const waterColor = waterIntensity.mul( 1.4 ).mix( color( 0x0487e2 ), color( 0x74ccf4 ) ); @@ -156,7 +176,7 @@ const waterMaterial = new THREE.MeshBasicNodeMaterial(); waterMaterial.colorNode = waterColor.toInspector( 'Water / Color' ); - waterMaterial.backdropNode = depthEffect.mix( viewportSharedTexture(), viewportTexture.mul( depthRefraction.mix( 1, waterColor ) ) ); + waterMaterial.backdropNode = depthEffect.mix( viewportSharedTexture(), viewportTexture.mul( depthRefraction.mix( 1, waterColor ) ) ).mul( color( 0xd3ebf8 ) ); waterMaterial.backdropAlphaNode = depthRefraction.oneMinus(); waterMaterial.transparent = true; @@ -168,19 +188,15 @@ floor = new THREE.Mesh( new THREE.CylinderGeometry( 1.1, 1.1, 10 ), new THREE.MeshStandardNodeMaterial( { colorNode: iceColorNode } ) ); floor.position.set( 0, - 5, 0 ); + floor.receiveShadow = true; scene.add( floor ); // caustics - const waterPosY = positionWorld.y.sub( water.position.y ); - - let transition = waterPosY.add( .1 ).saturate().oneMinus(); - transition = waterPosY.lessThan( 0 ).select( transition, normalWorld.y.mix( transition, 0 ) ).toVar(); - - const colorNode = transition.mix( material.colorNode, material.colorNode.add( waterLayer0 ) ); + const causticFade = normalWorld.y.mix( positionWorld.y.distance( 0 ).oneMinus().saturate(), 0 ); //material.colorNode = colorNode; - floor.material.colorNode = colorNode; + floor.material.colorNode = causticFade.mix( material.colorNode, material.colorNode.add( waterLayer0 ) ); // renderer @@ -188,6 +204,7 @@ renderer.setPixelRatio( window.devicePixelRatio ); renderer.setSize( window.innerWidth, window.innerHeight ); renderer.setAnimationLoop( animate ); + renderer.shadowMap.enabled = true; renderer.inspector = new Inspector(); document.body.appendChild( renderer.domElement ); From 66a40e2af016d4973d505ff36ad5f0ba703cb5fd Mon Sep 17 00:00:00 2001 From: sunag Date: Tue, 5 May 2026 02:12:41 -0300 Subject: [PATCH 21/40] fix deepscan --- examples/jsm/loaders/materialx/MaterialXDocument.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/examples/jsm/loaders/materialx/MaterialXDocument.js b/examples/jsm/loaders/materialx/MaterialXDocument.js index 9f5639fa834e3a..183d1de207d683 100644 --- a/examples/jsm/loaders/materialx/MaterialXDocument.js +++ b/examples/jsm/loaders/materialx/MaterialXDocument.js @@ -304,6 +304,8 @@ class MaterialXNode { toBooleanNode( node ) { + if ( ! node ) return bool( false ); + if ( typeof node === 'boolean' ) { return bool( node ); @@ -316,7 +318,7 @@ class MaterialXNode { } - if ( node && ( node.nodeType === 'bool' || ( node.isOperatorNode && BOOLEAN_OPERATOR_OPS.has( node.op ) ) ) ) { + if ( node.nodeType === 'bool' || ( node.isOperatorNode && BOOLEAN_OPERATOR_OPS.has( node.op ) ) ) { return node; From 7c77acc200e40b69b277d6ab0e403a132e14c1c2 Mon Sep 17 00:00:00 2001 From: sunag Date: Tue, 5 May 2026 02:31:38 -0300 Subject: [PATCH 22/40] (try) fix deepscan error by gemini --- .../loaders/materialx/MaterialXDocument.js | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/examples/jsm/loaders/materialx/MaterialXDocument.js b/examples/jsm/loaders/materialx/MaterialXDocument.js index 183d1de207d683..238520c9c40402 100644 --- a/examples/jsm/loaders/materialx/MaterialXDocument.js +++ b/examples/jsm/loaders/materialx/MaterialXDocument.js @@ -333,19 +333,6 @@ class MaterialXNode { let node = this.node; if ( node !== null && out === null ) return node; - 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 = mxToUvSpace( uv( index ) ); - - } if ( ( this.element === 'separate2' || this.element === 'separate3' || this.element === 'separate4' ) && out ) { @@ -408,6 +395,18 @@ class MaterialXNode { } + } 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 = mxToUvSpace( uv( index ) ); + } else { node = compileNodeFromRegistry( this, out, this.materialX.compileContext ); From 8f085c3d4f8cb9a8b596c8f7e7dfdb8a9f36927c Mon Sep 17 00:00:00 2001 From: sunag Date: Tue, 5 May 2026 02:33:38 -0300 Subject: [PATCH 23/40] update screenshots --- .../screenshots/webgpu_backdrop_water.jpg | Bin 45237 -> 46517 bytes .../screenshots/webgpu_lights_projector.jpg | Bin 30410 -> 29645 bytes examples/screenshots/webgpu_tsl_graph.jpg | Bin 43410 -> 43160 bytes 3 files changed, 0 insertions(+), 0 deletions(-) diff --git a/examples/screenshots/webgpu_backdrop_water.jpg b/examples/screenshots/webgpu_backdrop_water.jpg index 9d65952cf1d645117deb8e1dafbdc046710b2607..0295a49eb026de47e25254092407c7485971eaa5 100644 GIT binary patch literal 46517 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 literal 45237 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 C11^F9 diff --git a/examples/screenshots/webgpu_lights_projector.jpg b/examples/screenshots/webgpu_lights_projector.jpg index b8467c6d78c4f9c0ade54107d2ae4a3548495149..ab74e28bef063273c3bcd27886fecfa194f9c1cf 100644 GIT binary patch delta 26455 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 RvbH;Hz-tIc*<1gn{tJmIscZlM delta 27293 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 xEK3dYxs5K)UJs&;iq^^2{|9t+Te|=N diff --git a/examples/screenshots/webgpu_tsl_graph.jpg b/examples/screenshots/webgpu_tsl_graph.jpg index 884b19c65040e9ea01d19541eff7c35f6f0a3e58..46651b3855e7f5ec78b574cd76e0aff1bfa7d710 100644 GIT binary patch delta 40966 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

m)r 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∓ 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 From bc45cd43bf76fbb4d2729f74ac3d970a6c1fd289 Mon Sep 17 00:00:00 2001 From: sunag Date: Tue, 5 May 2026 03:06:15 -0300 Subject: [PATCH 24/40] Update webgpu_loader_materialx.jpg --- .../screenshots/webgpu_loader_materialx.jpg | Bin 69016 -> 37097 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/examples/screenshots/webgpu_loader_materialx.jpg b/examples/screenshots/webgpu_loader_materialx.jpg index 0be4f775e781103e6aa58c1125bdb48022ec585b..94a2b49ca9eeda2b17ed1c8c1410ab36889ac3e8 100644 GIT binary patch literal 37097 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

l~hkJ-5WnfT)z!=Uh7QXJ6F6w`GmMbpe%;ufYlF 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 Date: Tue, 5 May 2026 13:37:00 -0400 Subject: [PATCH 25/40] support both UV spaces that are used in ThreeJS. --- examples/jsm/loaders/MaterialXLoader.js | 2 +- .../loaders/materialx/MaterialXDocument.js | 34 +++++++++++++++---- .../compile/MaterialXCompileRegistry.js | 8 ++--- 3 files changed, 33 insertions(+), 11 deletions(-) diff --git a/examples/jsm/loaders/MaterialXLoader.js b/examples/jsm/loaders/MaterialXLoader.js index d64660d396548b..230ac5419bae37 100644 --- a/examples/jsm/loaders/MaterialXLoader.js +++ b/examples/jsm/loaders/MaterialXLoader.js @@ -136,7 +136,7 @@ class MaterialXLoader extends Loader { onWarning: options.onWarning || options.warningCallback } ); - const document = new MaterialXDocument( this.manager, options.path || this.path, issueCollector, options.archiveResolver || null ); + const document = new MaterialXDocument( this.manager, options.path || this.path, issueCollector, options.archiveResolver || null, options.uvSpace ); const result = document.parse( text, options.materialName || null ); issueCollector.throwIfNeeded(); diff --git a/examples/jsm/loaders/materialx/MaterialXDocument.js b/examples/jsm/loaders/materialx/MaterialXDocument.js index 238520c9c40402..fc3a26a1265cdd 100644 --- a/examples/jsm/loaders/materialx/MaterialXDocument.js +++ b/examples/jsm/loaders/materialx/MaterialXDocument.js @@ -75,8 +75,29 @@ function mxFlipUvY( uvNode ) { } -const mxToUvSpace = mxFlipUvY; -const mxFromUvSpace = mxFlipUvY; +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 ) { @@ -405,7 +426,7 @@ class MaterialXNode { } - node = mxToUvSpace( uv( index ) ); + node = this.materialX.compileContext.mxToBottomLeftUvSpace( uv( index ) ); } else { @@ -718,12 +739,13 @@ class MaterialXNode { class MaterialXDocument { - constructor( manager, path, issueCollector, archiveResolver = null ) { + constructor( manager, path, issueCollector, archiveResolver = null, uvSpace = 'bottom-left' ) { this.manager = manager; this.path = path; this.issueCollector = issueCollector; this.archiveResolver = archiveResolver; + this.uvSpace = normalizeUvSpace( uvSpace ); this.nodesXLib = new Map(); this.imageLoader = new ImageLoader( manager ); @@ -732,12 +754,12 @@ class MaterialXDocument { 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, - mxToUvSpace, - mxFromUvSpace, + ...bottomLeftUvSpaceHelpers, mxTransformUv: mx_transform_uv, mxHextileCoord, mxHextileComputeBlendWeights, diff --git a/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js b/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js index 3fdb1667a5bf14..16034ca79ad17d 100644 --- a/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js +++ b/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js @@ -44,7 +44,7 @@ const SCALAR_TYPES = new Set( [ 'boolean', 'integer', 'float' ] ); const THREE_COMPONENT_TYPES = new Set( [ 'vector2', 'vector3', 'vector4', 'color3', 'color4' ] ); const BOOLEAN_OPERATOR_OPS = new Set( [ '&&', '||', '^^', '!', '==', '!=', '<', '>', '<=', '>=' ] ); -const getDefaultUvNode = ( compileContext ) => compileContext.mxToUvSpace( uv( 0 ) ); +const getDefaultUvNode = ( compileContext ) => compileContext.mxToBottomLeftUvSpace( uv( 0 ) ); const isBooleanNode = ( node ) => node && ( node.nodeType === 'bool' || ( node.isOperatorNode && BOOLEAN_OPERATOR_OPS.has( node.op ) ) ); @@ -69,7 +69,7 @@ const getTextureInputs = ( nodeX, compileContext ) => { }; const sampleTexture = ( textureFile, uvNode, compileContext, fallback ) => - textureFile ? texture( textureFile, compileContext.mxFromUvSpace( uvNode ) ) : fallback; + textureFile ? texture( textureFile, compileContext.mxFromBottomLeftUvSpace( uvNode ) ) : fallback; const applyTextureColorSpace = ( node, file ) => { @@ -153,7 +153,7 @@ const compileTexcoordNode = ( nodeX, compileContext ) => { const indexNode = nodeX.getChildByName( 'index' ); const index = indexNode ? parseInt( indexNode.value, 10 ) : 0; - return compileContext.mxToUvSpace( uv( index ) ); + return compileContext.mxToBottomLeftUvSpace( uv( index ) ); }; @@ -215,7 +215,7 @@ const compileHexTiledTextureNode = ( nodeX, compileContext, category ) => { const falloff = nodeX.getNodeByName( 'falloff' ) || float( 0.5 ); const falloffContrast = nodeX.getNodeByName( 'falloffcontrast' ) || float( 0.5 ); const lumaCoeffs = nodeX.getNodeByName( 'lumacoeffs' ) || vec3( 0.2722287, 0.6740818, 0.0536895 ); - const transformedUv = compileContext.mxFromUvSpace( mul( uvNode, tiling ) ); + const transformedUv = compileContext.mxFromBottomLeftUvSpace( mul( uvNode, tiling ) ); const tileData = compileContext.mxHextileCoord( transformedUv, rotation, rotationRange, scale, scaleRange, offset, offsetRange ); let sample0 = texture( textureFile, tileData.coords[ 0 ] ).grad( From b64980e8b41b4e67a47955672f0c1be73dd7ae97 Mon Sep 17 00:00:00 2001 From: Ben Houston Date: Wed, 6 May 2026 20:49:26 -0400 Subject: [PATCH 26/40] fix ramp4, add binormal support, fix gltf_image. --- .../compile/MaterialXCompileRegistry.js | 64 ++++++++++++++++++- src/nodes/materialx/MaterialXNodes.js | 2 +- 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js b/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js index 16034ca79ad17d..45eb4d2ca3cd02 100644 --- a/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js +++ b/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js @@ -18,6 +18,8 @@ import { normalWorld, tangentLocal, tangentWorld, + bitangentLocal, + bitangentWorld, clamp, add, sub, @@ -149,6 +151,8 @@ const compileSpaceInputNode = ( nodeX, objectNode, worldNode ) => { }; +const compileNormalizedSpaceInputNode = ( nodeX, objectNode, worldNode ) => normalize( compileSpaceInputNode( nodeX, objectNode, worldNode ) ); + const compileTexcoordNode = ( nodeX, compileContext ) => { const indexNode = nodeX.getChildByName( 'index' ); @@ -274,7 +278,46 @@ const compileHexTiledTextureNode = ( nodeX, compileContext, category ) => { const compileGltfTextureNode = ( nodeX, compileContext, category ) => { const { file, uvNode, textureFile } = getTextureInputs( nodeX, compileContext ); - const node = applyTextureColorSpace( sampleTexture( textureFile, uvNode, compileContext, float( 0 ) ), file ); + let transformedUv = uvNode; + const place2d = compileContext.nodeLibrary.place2d; + + if ( place2d ) { + + const pivot = nodeX.getNodeByName( 'pivot' ) || vec2( 0, 1 ); + const scale = nodeX.getNodeByName( 'scale' ) || vec2( 1, 1 ); + const rotate = nodeX.getNodeByName( 'rotate' ) || float( 0 ); + const offset = nodeX.getNodeByName( 'offset' ) || vec2( 0, 0 ); + const operationorder = nodeX.getNodeByName( 'operationorder' ) || int( 0 ); + transformedUv = place2d.nodeFunc( + uvNode, + pivot, + div( vec2( 1, 1 ), scale ), + mul( rotate, - 1 ), + mul( offset, vec2( - 1, 1 ) ), + operationorder, + ); + + } + + const defaultInput = nodeX.getNodeByName( 'default' ); + let fallback = float( 0 ); + + if ( nodeX.type === 'color3' || nodeX.type === 'vector3' ) { + + const defaultColor = defaultInput || vec3( 0, 0, 0 ); + fallback = vec4( element( defaultColor, 0 ), element( defaultColor, 1 ), element( defaultColor, 2 ), 1 ); + + } else if ( nodeX.type === 'color4' || nodeX.type === 'vector4' ) { + + fallback = defaultInput || vec4( 0, 0, 0, 1 ); + + } else { + + fallback = defaultInput || float( 0 ); + + } + + const node = applyTextureColorSpace( sampleTexture( textureFile, transformedUv, compileContext, fallback ), file ); if ( category === 'gltf_normalmap' ) { @@ -283,6 +326,20 @@ const compileGltfTextureNode = ( nodeX, compileContext, category ) => { } + const factor = nodeX.getNodeByName( 'factor' ); + + if ( factor ) { + + if ( nodeX.type === 'color3' || nodeX.type === 'vector3' ) { + + return mul( factor, vec3( element( node, 0 ), element( node, 1 ), element( node, 2 ) ) ); + + } + + return mul( factor, node ); + + } + return node; }; @@ -542,8 +599,9 @@ function createMaterialXCompileRegistry() { register( registry, [ 'convert' ], ( nodeX ) => compileConvertNode( nodeX ) ); register( registry, [ 'constant' ], ( nodeX ) => compileConstantNode( nodeX ) ); register( registry, [ 'position' ], ( nodeX ) => compileSpaceInputNode( nodeX, positionLocal, positionWorld ) ); - register( registry, [ 'normal' ], ( nodeX ) => normalize( compileSpaceInputNode( nodeX, normalLocal, normalWorld ) ) ); - register( registry, [ 'tangent' ], ( nodeX ) => compileSpaceInputNode( nodeX, tangentLocal, tangentWorld ) ); + register( registry, [ 'normal' ], ( nodeX ) => compileNormalizedSpaceInputNode( nodeX, normalLocal, normalWorld ) ); + register( registry, [ 'tangent' ], ( nodeX ) => compileNormalizedSpaceInputNode( nodeX, tangentLocal, tangentWorld ) ); + register( registry, [ 'bitangent' ], ( nodeX ) => compileNormalizedSpaceInputNode( nodeX, bitangentLocal, bitangentWorld ) ); register( registry, [ 'texcoord' ], ( nodeX, out, compileContext ) => compileTexcoordNode( nodeX, compileContext ) ); register( registry, [ 'geomcolor' ], ( nodeX ) => compileGeomColorNode( nodeX ) ); register( registry, [ 'tiledimage' ], ( nodeX, out, compileContext ) => compileTiledImageNode( nodeX, compileContext ) ); diff --git a/src/nodes/materialx/MaterialXNodes.js b/src/nodes/materialx/MaterialXNodes.js index a9d8c14d08f65a..6800c98a41e1fe 100644 --- a/src/nodes/materialx/MaterialXNodes.js +++ b/src/nodes/materialx/MaterialXNodes.js @@ -43,7 +43,7 @@ export const mx_ramp4 = ( const v = texcoord.y.clamp(); const top = mix( valuetl, valuetr, u ); const bottom = mix( valuebl, valuebr, u ); - return mix( top, bottom, v ); + return mix( bottom, top, v ); }; From 95ae5ea3b7c2f675f93b6d345006aee285330259 Mon Sep 17 00:00:00 2001 From: Ben Houston Date: Wed, 6 May 2026 22:22:41 -0400 Subject: [PATCH 27/40] reduce duplication, adopt Rodrigues' rotation formula --- .../loaders/materialx/MaterialXDocument.js | 24 +------- .../loaders/materialx/MaterialXNodeLibrary.js | 18 ++---- .../jsm/loaders/materialx/MaterialXUtils.js | 47 ++++++++++++++- .../compile/MaterialXCompileRegistry.js | 34 +++++------ src/nodes/materialx/MaterialXCore.js | 35 +++++++++++ src/nodes/materialx/MaterialXNodes.js | 58 +------------------ src/nodes/materialx/MaterialXNoise.js | 54 ++--------------- 7 files changed, 107 insertions(+), 163 deletions(-) create mode 100644 src/nodes/materialx/MaterialXCore.js diff --git a/examples/jsm/loaders/materialx/MaterialXDocument.js b/examples/jsm/loaders/materialx/MaterialXDocument.js index fc3a26a1265cdd..6075805d0597bf 100644 --- a/examples/jsm/loaders/materialx/MaterialXDocument.js +++ b/examples/jsm/loaders/materialx/MaterialXDocument.js @@ -31,6 +31,7 @@ import { parseMaterialXNodeTree, parseMaterialXText } from './parse/MaterialXPar 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, @@ -53,7 +54,6 @@ const NODE_CLASS_BY_TYPE = { matrix33: mat3, matrix44: mat4, }; -const BOOLEAN_OPERATOR_OPS = new Set( [ '&&', '||', '^^', '!', '==', '!=', '<', '>', '<=', '>=' ] ); const OUTPUT_CHANNELS = { outx: 0, outr: 0, @@ -325,27 +325,7 @@ class MaterialXNode { toBooleanNode( node ) { - if ( ! node ) return bool( false ); - - if ( typeof node === 'boolean' ) { - - return bool( node ); - - } - - if ( typeof node === 'number' ) { - - return bool( node !== 0 ); - - } - - if ( node.nodeType === 'bool' || ( node.isOperatorNode && BOOLEAN_OPERATOR_OPS.has( node.op ) ) ) { - - return node; - - } - - return node.notEqual( float( 0 ) ); + return toBooleanNode( node ); } diff --git a/examples/jsm/loaders/materialx/MaterialXNodeLibrary.js b/examples/jsm/loaders/materialx/MaterialXNodeLibrary.js index 5e14575ffb416f..2c028ab4a3ddcb 100644 --- a/examples/jsm/loaders/materialx/MaterialXNodeLibrary.js +++ b/examples/jsm/loaders/materialx/MaterialXNodeLibrary.js @@ -91,7 +91,7 @@ import { Fn, Loop, } from 'three/tsl'; -import { normalizeSpaceName } from './MaterialXUtils.js'; +import { normalizeSpaceName, toBooleanNode, toVec3Channels } from './MaterialXUtils.js'; const createMXElement = ( name, nodeFunc, params = [], defaults = {}, usesNode = false ) => ( { name, nodeFunc, params, defaults, usesNode } ); @@ -119,16 +119,7 @@ const mx_open_pbr_anisotropy = ( roughness = 0, anisotropy = 0 ) => { }; -const BOOLEAN_OPERATOR_OPS = new Set( [ '&&', '||', '^^', '!', '==', '!=', '<', '>', '<=', '>=' ] ); -const isBooleanNode = ( node ) => node && ( node.nodeType === 'bool' || ( node.isOperatorNode && BOOLEAN_OPERATOR_OPS.has( node.op ) ) ); -const mx_boolean = ( inNode ) => { - - if ( typeof inNode === 'boolean' ) return bool( inNode ); - if ( typeof inNode === 'number' ) return bool( inNode !== 0 ); - if ( isBooleanNode( inNode ) ) return inNode; - return inNode.notEqual( float( 0 ) ); - -}; +const mx_boolean = ( inNode ) => toBooleanNode( inNode ); const mx_and = ( in1, in2 ) => tslAnd( mx_boolean( in1 ), mx_boolean( in2 ) ); const mx_or = ( in1, in2 ) => tslOr( mx_boolean( in1 ), mx_boolean( in2 ) ); const mx_xor = ( in1, in2 ) => tslXor( mx_boolean( in1 ), mx_boolean( in2 ) ); @@ -153,7 +144,6 @@ const mx_circle = ( texcoord, center, radius ) => { const mx_bump = ( height, scale = 1 ) => normalMap( mx_heighttonormal( height, 1 ), scale ); const mx_dot = ( inNode ) => inNode; const mx_viewdirection = () => normalize( mul( positionWorld, float( - 1 ) ) ); -const getRGBChannels = ( input ) => vec3( element( input, 0 ), element( input, 1 ), element( input, 2 ) ); const mx_blackbody = ( temperature = 5000 ) => { const temperatureKelvin = clamp( temperature, float( 800 ), float( 25000 ) ); @@ -198,7 +188,7 @@ const mx_blackbody = ( temperature = 5000 ) => { const mx_unpremult = ( input ) => { const alpha = element( input, 3 ); - const rgb = getRGBChannels( input ); + const rgb = toVec3Channels( input ); const unpremultiplied = alpha.equal( 0 ).mix( rgb, div( rgb, alpha ) ); return vec4( unpremultiplied, alpha ); @@ -216,7 +206,7 @@ const mx_colorcorrect = ( exposure = 0, ) => { - const rgbInput = getRGBChannels( input ); + const rgbInput = toVec3Channels( input ); const hsv = mx_rgbtohsv( rgbInput ); const hueAdjusted = mx_hsvtorgb( add( hsv, vec3( hue, 0, 0 ) ) ); const saturationAdjusted = mx_saturation( hueAdjusted, saturationAmount ); diff --git a/examples/jsm/loaders/materialx/MaterialXUtils.js b/examples/jsm/loaders/materialx/MaterialXUtils.js index f47f87315d7f6c..b0bf44129794b0 100644 --- a/examples/jsm/loaders/materialx/MaterialXUtils.js +++ b/examples/jsm/loaders/materialx/MaterialXUtils.js @@ -1,3 +1,12 @@ +import { + bool, + element, + float, + vec3, +} from 'three/tsl'; + +const BOOLEAN_OPERATOR_OPS = new Set( [ '&&', '||', '^^', '!', '==', '!=', '<', '>', '<=', '>=' ] ); + function normalizeSpaceName( value, fallback = 'world' ) { if ( typeof value !== 'string' ) return fallback; @@ -9,4 +18,40 @@ function normalizeSpaceName( value, fallback = 'world' ) { } -export { normalizeSpaceName }; +function isBooleanNode( node ) { + + return node && ( node.nodeType === 'bool' || ( node.isOperatorNode && BOOLEAN_OPERATOR_OPS.has( node.op ) ) ); + +} + +function toBooleanNode( node ) { + + if ( ! node ) return bool( false ); + if ( typeof node === 'boolean' ) return bool( node ); + if ( typeof node === 'number' ) return bool( node !== 0 ); + if ( isBooleanNode( node ) ) return node; + return node.notEqual( float( 0 ) ); + +} + +function getComponentCountForType( type ) { + + if ( type === 'vector2' ) return 2; + if ( type === 'vector3' || type === 'color3' ) return 3; + return 4; + +} + +function toVec3Channels( input ) { + + return vec3( element( input, 0 ), element( input, 1 ), element( input, 2 ) ); + +} + +export { + getComponentCountForType, + isBooleanNode, + normalizeSpaceName, + toBooleanNode, + toVec3Channels, +}; diff --git a/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js b/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js index 45eb4d2ca3cd02..5221efae646a57 100644 --- a/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js +++ b/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js @@ -1,6 +1,5 @@ import { element, - bool, float, mat3, mat4, @@ -29,7 +28,12 @@ import { normalize, mx_atan2, } from 'three/tsl'; -import { normalizeSpaceName } from '../MaterialXUtils.js'; +import { + getComponentCountForType, + normalizeSpaceName, + toBooleanNode, + toVec3Channels, +} from '../MaterialXUtils.js'; const register = ( registry, categories, handler ) => { @@ -44,21 +48,9 @@ const register = ( registry, categories, handler ) => { const UV_FALLBACK_CATEGORIES = new Set( [ 'checkerboard', 'noise2d', 'fractal2d', 'cellnoise2d', 'worleynoise2d', 'unifiednoise2d' ] ); const SCALAR_TYPES = new Set( [ 'boolean', 'integer', 'float' ] ); const THREE_COMPONENT_TYPES = new Set( [ 'vector2', 'vector3', 'vector4', 'color3', 'color4' ] ); -const BOOLEAN_OPERATOR_OPS = new Set( [ '&&', '||', '^^', '!', '==', '!=', '<', '>', '<=', '>=' ] ); const getDefaultUvNode = ( compileContext ) => compileContext.mxToBottomLeftUvSpace( uv( 0 ) ); -const isBooleanNode = ( node ) => node && ( node.nodeType === 'bool' || ( node.isOperatorNode && BOOLEAN_OPERATOR_OPS.has( node.op ) ) ); - -const toBooleanNode = ( node ) => { - - if ( typeof node === 'boolean' ) return bool( node ); - if ( typeof node === 'number' ) return bool( node !== 0 ); - if ( isBooleanNode( node ) ) return node; - return node.notEqual( float( 0 ) ); - -}; - const toBooleanMaskNode = ( node ) => toBooleanNode( node ).select( float( 1 ), float( 0 ) ); const getTextureInputs = ( nodeX, compileContext ) => { @@ -98,7 +90,7 @@ const compileConvertNode = ( nodeX ) => { const inputMask = toBooleanMaskNode( input ); if ( THREE_COMPONENT_TYPES.has( nodeX.type ) ) { - const componentCount = nodeX.type === 'vector2' ? 2 : nodeX.type === 'vector3' || nodeX.type === 'color3' ? 3 : 4; + const componentCount = getComponentCountForType( nodeX.type ); return nodeClass( ...Array( componentCount ).fill( inputMask ) ); } @@ -109,7 +101,7 @@ const compileConvertNode = ( nodeX ) => { if ( SCALAR_TYPES.has( inputType ) && THREE_COMPONENT_TYPES.has( nodeX.type ) ) { - const componentCount = nodeX.type === 'vector2' ? 2 : nodeX.type === 'vector3' || nodeX.type === 'color3' ? 3 : 4; + const componentCount = getComponentCountForType( nodeX.type ); return nodeClass( ...Array( componentCount ).fill( input ) ); } @@ -247,9 +239,9 @@ const compileHexTiledTextureNode = ( nodeX, compileContext, category ) => { } - const c0 = vec3( element( sample0, 0 ), element( sample0, 1 ), element( sample0, 2 ) ); - const c1 = vec3( element( sample1, 0 ), element( sample1, 1 ), element( sample1, 2 ) ); - const c2 = vec3( element( sample2, 0 ), element( sample2, 1 ), element( sample2, 2 ) ); + const c0 = toVec3Channels( sample0 ); + const c1 = toVec3Channels( sample1 ); + const c2 = toVec3Channels( sample2 ); const cw = mix( vec3( 1, 1, 1 ), vec3( dot( c0, lumaCoeffs ), dot( c1, lumaCoeffs ), dot( c2, lumaCoeffs ) ), @@ -332,7 +324,7 @@ const compileGltfTextureNode = ( nodeX, compileContext, category ) => { if ( nodeX.type === 'color3' || nodeX.type === 'vector3' ) { - return mul( factor, vec3( element( node, 0 ), element( node, 1 ), element( node, 2 ) ) ); + return mul( factor, toVec3Channels( node ) ); } @@ -356,7 +348,7 @@ const compileGltfColorImageNode = ( nodeX, out, compileContext ) => { } const converted = applyTextureColorSpace( sampled, file ); - return vec3( element( converted, 0 ), element( converted, 1 ), element( converted, 2 ) ); + return toVec3Channels( converted ); }; diff --git a/src/nodes/materialx/MaterialXCore.js b/src/nodes/materialx/MaterialXCore.js new file mode 100644 index 00000000000000..362e7ec130dda4 --- /dev/null +++ b/src/nodes/materialx/MaterialXCore.js @@ -0,0 +1,35 @@ +import { float, vec2, vec3, add, sub, mul, sin, cos, normalize } from '../tsl/TSLBase.js'; + +export const mx_rotate2d = ( input, amount = 0 ) => { + + input = vec2( input ); + amount = float( amount ); + + const rotationRadians = mul( amount, Math.PI / 180.0 ); + const sa = sin( rotationRadians ); + const ca = cos( rotationRadians ); + const x = input.x; + const y = input.y; + + return vec2( add( mul( ca, x ), mul( sa, y ) ), sub( mul( ca, y ), mul( sa, x ) ) ); + +}; + +export const mx_rotate3d = ( input, amount = 0, axis = vec3( 0, 1, 0 ) ) => { + + input = vec3( input ); + amount = float( amount ); + axis = vec3( axis ); + + const normalizedAxis = normalize( axis ); + const rotationRadians = mul( amount, Math.PI / 180.0 ); + const s = sin( rotationRadians ); + const c = cos( rotationRadians ); + const oc = sub( 1, c ); + + // https://en.wikipedia.org/wiki/Rodrigues%27_rotation_formula + return input.mul( c ) + .add( input.cross( normalizedAxis ).mul( s ) ) + .add( normalizedAxis.mul( normalizedAxis.dot( input ).mul( oc ) ) ); + +}; diff --git a/src/nodes/materialx/MaterialXNodes.js b/src/nodes/materialx/MaterialXNodes.js index 6800c98a41e1fe..d2524b90c1a879 100644 --- a/src/nodes/materialx/MaterialXNodes.js +++ b/src/nodes/materialx/MaterialXNodes.js @@ -8,12 +8,13 @@ import { mx_fractal_noise_float_2d as fractal_noise_float_2d, mx_fractal_noise_float as fractal_noise_float, mx_fractal_noise_vec2 as fractal_noise_vec2, mx_fractal_noise_vec3 as fractal_noise_vec3, mx_fractal_noise_vec4 as fractal_noise_vec4 } from './MaterialXNoise.js'; +import { mx_rotate2d, mx_rotate3d } from './MaterialXCore.js'; import { mx_hsvtorgb, mx_rgbtohsv } from './MaterialXColor.js'; import { mx_srgb_texture_to_lin_rec709 } from './MaterialXColorTransform.js'; import { float, vec2, vec3, vec4, int, add, sub, mul, div, atan, mix, pow, smoothstep, - floor, abs, max, clamp, step, sin, cos, normalize + floor, abs, max, clamp, step } from '../tsl/TSLBase.js'; import { uv } from '../accessors/UV.js'; import { bumpMap } from '../display/BumpMapNode.js'; @@ -107,6 +108,7 @@ export const mx_fractal_noise_vec2 = ( position = uv(), octaves = 3, lacunarity export const mx_fractal_noise_vec3 = ( position = uv(), octaves = 3, lacunarity = 2, diminish = .5, amplitude = 1 ) => fractal_noise_vec3( position, int( octaves ), lacunarity, diminish ).mul( amplitude ); export const mx_fractal_noise_vec4 = ( position = uv(), octaves = 3, lacunarity = 2, diminish = .5, amplitude = 1 ) => fractal_noise_vec4( position, int( octaves ), lacunarity, diminish ).mul( amplitude ); +export { mx_rotate2d, mx_rotate3d }; export { mx_hsvtorgb, mx_rgbtohsv, mx_srgb_texture_to_lin_rec709 }; // === Moved from MaterialXLoader.js === @@ -168,60 +170,6 @@ export const mx_place2d = ( }; -export const mx_rotate2d = ( input, amount = 0 ) => { - - input = vec2( input ); - amount = float( amount ); - - const rotationRadians = mul( amount, Math.PI / 180.0 ); - const sa = sin( rotationRadians ); - const ca = cos( rotationRadians ); - const x = input.x; - const y = input.y; - - return vec2( add( mul( ca, x ), mul( sa, y ) ), sub( mul( ca, y ), mul( sa, x ) ) ); - -}; - -export const mx_rotate3d = ( input, amount = 0, axis = vec3( 0, 1, 0 ) ) => { - - input = vec3( input ); - amount = float( amount ); - axis = vec3( axis ); - - const normalizedAxis = normalize( axis ); - const rotationRadians = mul( amount, Math.PI / 180.0 ); - const s = sin( rotationRadians ); - const c = cos( rotationRadians ); - const oc = sub( 1, c ); - - const x = input.x; - const y = input.y; - const z = input.z; - const ax = normalizedAxis.x; - const ay = normalizedAxis.y; - const az = normalizedAxis.z; - - const m00 = add( mul( mul( oc, ax ), ax ), c ); - const m01 = sub( mul( mul( oc, ax ), ay ), mul( az, s ) ); - const m02 = add( mul( mul( oc, az ), ax ), mul( ay, s ) ); - - const m10 = add( mul( mul( oc, ax ), ay ), mul( az, s ) ); - const m11 = add( mul( mul( oc, ay ), ay ), c ); - const m12 = sub( mul( mul( oc, ay ), az ), mul( ax, s ) ); - - const m20 = sub( mul( mul( oc, az ), ax ), mul( ay, s ) ); - const m21 = add( mul( mul( oc, ay ), az ), mul( ax, s ) ); - const m22 = add( mul( mul( oc, az ), az ), c ); - - return vec3( - add( add( mul( m00, x ), mul( m10, y ) ), mul( m20, z ) ), - add( add( mul( m01, x ), mul( m11, y ) ), mul( m21, z ) ), - add( add( mul( m02, x ), mul( m12, y ) ), mul( m22, z ) ) - ); - -}; - export const mx_heighttonormal = ( input, scale/*, texcoord*/ ) => { input = vec3( input ); diff --git a/src/nodes/materialx/MaterialXNoise.js b/src/nodes/materialx/MaterialXNoise.js index 7ffbbb7d388f8e..5d5e73077ad217 100644 --- a/src/nodes/materialx/MaterialXNoise.js +++ b/src/nodes/materialx/MaterialXNoise.js @@ -1,9 +1,10 @@ import { int, uint, float, vec3, bool, uvec3, vec2, vec4, If, Fn } from '../tsl/TSLBase.js'; import { select } from '../math/ConditionalNode.js'; import { add, sub, mul } from '../math/OperatorNode.js'; -import { floor, abs, max, dot, sqrt, clamp, fract, sin, cos, normalize } from '../math/MathNode.js'; +import { floor, abs, max, dot, sqrt, clamp, fract } from '../math/MathNode.js'; import { overloadingFn } from '../utils/FunctionOverloadingNode.js'; import { Loop } from '../utils/LoopNode.js'; +import { mx_rotate2d, mx_rotate3d } from './MaterialXCore.js'; export const mx_select = /*@__PURE__*/ Fn( ( [ b_immutable, t_immutable, f_immutable ] ) => { @@ -1149,53 +1150,6 @@ export const mx_worley_distance = /*@__PURE__*/ overloadingFn( [ mx_worley_dista const mx_perlin_noise_float_scaled = ( texcoord, amplitude = 1, pivot = 0 ) => mx_perlin_noise_float( texcoord ).mul( amplitude ).add( pivot ); -const mx_rotate2d_noise = ( inNode, amount = 0 ) => { - - const rotationRadians = mul( amount, Math.PI / 180.0 ); - const sa = sin( rotationRadians ); - const ca = cos( rotationRadians ); - const x = inNode.x; - const y = inNode.y; - - return vec2( add( mul( ca, x ), mul( sa, y ) ), sub( mul( ca, y ), mul( sa, x ) ) ); - -}; - -const mx_rotate3d_noise = ( inNode, amount = 0, axis = vec3( 0, 1, 0 ) ) => { - - const normalizedAxis = normalize( axis ); - const rotationRadians = mul( amount, Math.PI / 180.0 ); - const s = sin( rotationRadians ); - const c = cos( rotationRadians ); - const oc = sub( 1, c ); - - const x = inNode.x; - const y = inNode.y; - const z = inNode.z; - const ax = normalizedAxis.x; - const ay = normalizedAxis.y; - const az = normalizedAxis.z; - - const m00 = add( mul( mul( oc, ax ), ax ), c ); - const m01 = sub( mul( mul( oc, ax ), ay ), mul( az, s ) ); - const m02 = add( mul( mul( oc, az ), ax ), mul( ay, s ) ); - - const m10 = add( mul( mul( oc, ax ), ay ), mul( az, s ) ); - const m11 = add( mul( mul( oc, ay ), ay ), c ); - const m12 = sub( mul( mul( oc, ay ), az ), mul( ax, s ) ); - - const m20 = sub( mul( mul( oc, az ), ax ), mul( ay, s ) ); - const m21 = add( mul( mul( oc, ay ), az ), mul( ax, s ) ); - const m22 = add( mul( mul( oc, az ), az ), c ); - - return vec3( - add( add( mul( m00, x ), mul( m10, y ) ), mul( m20, z ) ), - add( add( mul( m01, x ), mul( m11, y ) ), mul( m21, z ) ), - add( add( mul( m02, x ), mul( m12, y ) ), mul( m22, z ) ) - ); - -}; - export const mx_worley_noise_float_3d = /*@__PURE__*/ Fn( ( [ positionInput, jitterInput, styleInput ] ) => { const position = vec3( positionInput ).toVar(); @@ -1657,7 +1611,7 @@ export const mx_unifiednoise2d = /*@__PURE__*/ Fn( ( [ const applyFreq = mul( texcoord, freq ).toVar(); const applyOffset = add( applyFreq, offset ).toVar(); const cellJitterMult = mul( sub( jitter, 1 ), 90000 ).toVar(); - const applyCellJitter = mx_rotate2d_noise( applyOffset, cellJitterMult ).toVar(); + const applyCellJitter = mx_rotate2d( applyOffset, cellJitterMult ).toVar(); const fractalInput = vec3( applyOffset.x, applyOffset.y, cellJitterMult ).toVar(); const result = float( 0 ).toVar(); @@ -1728,7 +1682,7 @@ export const mx_unifiednoise3d = /*@__PURE__*/ Fn( ( [ const applyFreq = mul( position, freq ).toVar(); const applyOffset = add( applyFreq, offset ).toVar(); const cellJitterMult = mul( sub( jitter, 1 ), 90000 ).toVar(); - const applyCellJitter = mx_rotate3d_noise( applyOffset, cellJitterMult, vec3( 0.1, 1, 0 ) ).toVar(); + const applyCellJitter = mx_rotate3d( applyOffset, cellJitterMult, vec3( 0.1, 1, 0 ) ).toVar(); const perlin = mx_perlin_noise_float_scaled( applyCellJitter, 0.5, 0.5 ); const cell = mx_cell_noise_float( applyCellJitter ); const worley = mx_worley_noise_float_3d( applyOffset, jitter, style ); From 66f26e24d2cf70127914755c8adbfed0b2171ce7 Mon Sep 17 00:00:00 2001 From: Ben Houston Date: Wed, 6 May 2026 22:42:04 -0400 Subject: [PATCH 28/40] add support for MaterialX image address modes. --- .../loaders/materialx/MaterialXDocument.js | 52 ++++++++- .../compile/MaterialXCompileRegistry.js | 106 +++++++++++++++--- 2 files changed, 137 insertions(+), 21 deletions(-) diff --git a/examples/jsm/loaders/materialx/MaterialXDocument.js b/examples/jsm/loaders/materialx/MaterialXDocument.js index 6075805d0597bf..3ea64e3b2ff65e 100644 --- a/examples/jsm/loaders/materialx/MaterialXDocument.js +++ b/examples/jsm/loaders/materialx/MaterialXDocument.js @@ -1,6 +1,8 @@ import { Texture, RepeatWrapping, + ClampToEdgeWrapping, + MirroredRepeatWrapping, ImageLoader, ImageBitmapLoader, Matrix3, @@ -42,6 +44,12 @@ 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, @@ -106,6 +114,15 @@ function isSvgUri( 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; @@ -276,16 +293,42 @@ class MaterialXNode { } + getTextureAddressMode( inputName ) { + + const rawMode = this.getInputValueByName( inputName ); + const mode = normalizeTextureAddressMode( rawMode ); + if ( mode ) return mode; + + this.materialX.issueCollector.addInvalidValue( + this.name, + `Unsupported texture address mode "${rawMode}" on input "${inputName}". Expected constant, clamp, periodic, or mirror.`, + ); + 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( resolvedURI ) ) { + if ( this.materialX.textureCache.has( textureCacheKey ) ) { - return this.materialX.textureCache.get( resolvedURI ); + return this.materialX.textureCache.get( textureCacheKey ); } @@ -298,9 +341,10 @@ class MaterialXNode { } const textureNode = new Texture(); - textureNode.wrapS = textureNode.wrapT = RepeatWrapping; + textureNode.wrapS = TEXTURE_ADDRESS_MODE_WRAPPING[ addressModes.u ]; + textureNode.wrapT = TEXTURE_ADDRESS_MODE_WRAPPING[ addressModes.v ]; textureNode.flipY = false; - this.materialX.textureCache.set( resolvedURI, textureNode ); + this.materialX.textureCache.set( textureCacheKey, textureNode ); loader.load( resolvedURI, ( imageData ) => { diff --git a/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js b/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js index 5221efae646a57..6a8dc824d1fdb4 100644 --- a/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js +++ b/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js @@ -48,22 +48,88 @@ const register = ( registry, categories, handler ) => { const UV_FALLBACK_CATEGORIES = new Set( [ 'checkerboard', 'noise2d', 'fractal2d', 'cellnoise2d', 'worleynoise2d', 'unifiednoise2d' ] ); const SCALAR_TYPES = new Set( [ 'boolean', 'integer', 'float' ] ); const THREE_COMPONENT_TYPES = new Set( [ 'vector2', 'vector3', 'vector4', 'color3', 'color4' ] ); +const TEXTURE_ADDRESS_MODES = new Set( [ 'constant', 'clamp', 'periodic', 'mirror' ] ); const getDefaultUvNode = ( compileContext ) => compileContext.mxToBottomLeftUvSpace( uv( 0 ) ); const toBooleanMaskNode = ( node ) => toBooleanNode( node ).select( float( 1 ), float( 0 ) ); +const getTextureAddressMode = ( nodeX, inputName ) => { + + const value = nodeX.getInputValueByName( inputName ); + if ( value === null || value === undefined || value === '' ) return 'periodic'; + + const mode = value.trim().toLowerCase(); + return TEXTURE_ADDRESS_MODES.has( mode ) ? mode : 'periodic'; + +}; + +const getTextureAddressModes = ( nodeX ) => ( { + u: getTextureAddressMode( nodeX, 'uaddressmode' ), + v: getTextureAddressMode( nodeX, 'vaddressmode' ), +} ); + +const getZeroNodeForType = ( type ) => { + + if ( type === 'vector2' ) return vec2( 0, 0 ); + if ( type === 'vector3' || type === 'color3' ) return vec3( 0, 0, 0 ); + if ( type === 'vector4' || type === 'color4' ) return vec4( 0, 0, 0, 0 ); + return float( 0 ); + +}; + +const toTextureDefaultNode = ( node, type ) => { + + if ( type === 'vector2' ) return vec4( node, 0, 1 ); + if ( type === 'vector3' || type === 'color3' ) return vec4( node, 1 ); + if ( type === 'vector4' || type === 'color4' ) return vec4( node ); + return vec4( node, 0, 0, 1 ); + +}; + const getTextureInputs = ( nodeX, compileContext ) => { const file = nodeX.getChildByName( 'file' ); const uvNode = nodeX.getNodeByName( 'texcoord' ) || getDefaultUvNode( compileContext ); const textureFile = file ? file.getTexture() : null; - return { file, uvNode, textureFile }; + const defaultNode = nodeX.getNodeByName( 'default' ) || getZeroNodeForType( nodeX.type ); + const addressModes = getTextureAddressModes( nodeX ); + return { file, uvNode, textureFile, defaultNode, addressModes }; + +}; + +const applyTextureAddressModeDefault = ( node, uvNode, addressModes, defaultNode ) => { + + let outsideBounds = null; + + if ( addressModes.u === 'constant' ) { + + const u = element( uvNode, 0 ); + outsideBounds = u.lessThan( 0 ).or( u.greaterThan( 1 ) ); + + } + + if ( addressModes.v === 'constant' ) { + + const v = element( uvNode, 1 ); + const vOutsideBounds = v.lessThan( 0 ).or( v.greaterThan( 1 ) ); + outsideBounds = outsideBounds ? outsideBounds.or( vOutsideBounds ) : vOutsideBounds; + + } + + return outsideBounds ? outsideBounds.select( defaultNode, node ) : node; }; -const sampleTexture = ( textureFile, uvNode, compileContext, fallback ) => - textureFile ? texture( textureFile, compileContext.mxFromBottomLeftUvSpace( uvNode ) ) : fallback; +const sampleTexture = ( textureFile, uvNode, compileContext, fallback, addressModes = null, defaultNode = fallback ) => { + + if ( ! textureFile ) return fallback; + + const textureUvNode = compileContext.mxFromBottomLeftUvSpace( uvNode ); + const sampled = texture( textureFile, textureUvNode ); + return addressModes ? applyTextureAddressModeDefault( sampled, textureUvNode, addressModes, defaultNode ) : sampled; + +}; const applyTextureColorSpace = ( node, file ) => { @@ -163,25 +229,27 @@ const compileGeomColorNode = ( nodeX ) => { const compileImageLikeNode = ( nodeX, compileContext ) => { - const { file, uvNode, textureFile } = getTextureInputs( nodeX, compileContext ); - const node = sampleTexture( textureFile, uvNode, compileContext, vec4( 0, 0, 0, 1 ) ); + const { file, uvNode, textureFile, defaultNode, addressModes } = getTextureInputs( nodeX, compileContext ); + const textureDefault = toTextureDefaultNode( defaultNode, nodeX.type ); + const node = sampleTexture( textureFile, uvNode, compileContext, textureDefault, addressModes, textureDefault ); return applyTextureColorSpace( node, file ); }; const compileTiledImageNode = ( nodeX, compileContext ) => { - const { file, uvNode, textureFile } = getTextureInputs( nodeX, compileContext ); + const { file, uvNode, textureFile, defaultNode, addressModes } = getTextureInputs( nodeX, compileContext ); + const textureDefault = toTextureDefaultNode( defaultNode, nodeX.type ); if ( ! textureFile ) { - return vec4( 0, 0, 0, 1 ); + return textureDefault; } const uvTiling = nodeX.getNodeByName( 'uvtiling' ); const uvOffset = nodeX.getNodeByName( 'uvoffset' ); const transformedUv = compileContext.mxTransformUv( uvTiling, uvOffset, uvNode ); - const node = sampleTexture( textureFile, transformedUv, compileContext, vec4( 0, 0, 0, 1 ) ); + const node = sampleTexture( textureFile, transformedUv, compileContext, textureDefault, addressModes, textureDefault ); return applyTextureColorSpace( node, file ); }; @@ -269,7 +337,7 @@ const compileHexTiledTextureNode = ( nodeX, compileContext, category ) => { const compileGltfTextureNode = ( nodeX, compileContext, category ) => { - const { file, uvNode, textureFile } = getTextureInputs( nodeX, compileContext ); + const { file, uvNode, textureFile, addressModes } = getTextureInputs( nodeX, compileContext ); let transformedUv = uvNode; const place2d = compileContext.nodeLibrary.place2d; @@ -305,11 +373,12 @@ const compileGltfTextureNode = ( nodeX, compileContext, category ) => { } else { - fallback = defaultInput || float( 0 ); + const defaultValue = defaultInput || float( 0 ); + fallback = vec4( defaultValue, 0, 0, 1 ); } - const node = applyTextureColorSpace( sampleTexture( textureFile, transformedUv, compileContext, fallback ), file ); + const node = applyTextureColorSpace( sampleTexture( textureFile, transformedUv, compileContext, fallback, addressModes, fallback ), file ); if ( category === 'gltf_normalmap' ) { @@ -338,8 +407,9 @@ const compileGltfTextureNode = ( nodeX, compileContext, category ) => { const compileGltfColorImageNode = ( nodeX, out, compileContext ) => { - const { file, uvNode, textureFile } = getTextureInputs( nodeX, compileContext ); - const sampled = sampleTexture( textureFile, uvNode, compileContext, vec4( 0, 0, 0, 1 ) ); + const { file, uvNode, textureFile, addressModes } = getTextureInputs( nodeX, compileContext ); + const fallback = vec4( 0, 0, 0, 1 ); + const sampled = sampleTexture( textureFile, uvNode, compileContext, fallback, addressModes, fallback ); if ( out === 'outa' || out === 'a' ) { @@ -354,9 +424,10 @@ const compileGltfColorImageNode = ( nodeX, out, compileContext ) => { const compileGltfAnisotropyImageNode = ( nodeX, out, compileContext ) => { - const { uvNode, textureFile } = getTextureInputs( nodeX, compileContext ); + const { uvNode, textureFile, addressModes } = getTextureInputs( nodeX, compileContext ); const defaultInput = nodeX.getNodeByName( 'default' ) || vec3( 1, 0.5, 1 ); - const sampled = sampleTexture( textureFile, uvNode, compileContext, vec4( element( defaultInput, 0 ), element( defaultInput, 1 ), element( defaultInput, 2 ), 1 ) ); + const fallback = vec4( element( defaultInput, 0 ), element( defaultInput, 1 ), element( defaultInput, 2 ), 1 ); + const sampled = sampleTexture( textureFile, uvNode, compileContext, fallback, addressModes, fallback ); const anisotropyStrengthFactor = nodeX.getNodeByName( 'anisotropy_strength' ) || float( 1 ); const anisotropyRotationFactor = nodeX.getNodeByName( 'anisotropy_rotation' ) || float( 0 ); const encodedDirection = vec2( sub( mul( element( sampled, 0 ), 2 ), 1 ), sub( mul( element( sampled, 1 ), 2 ), 1 ) ); @@ -376,8 +447,9 @@ const compileGltfAnisotropyImageNode = ( nodeX, out, compileContext ) => { const compileGltfIridescenceThicknessNode = ( nodeX, compileContext ) => { - const { uvNode, textureFile } = getTextureInputs( nodeX, compileContext ); - const sampled = sampleTexture( textureFile, uvNode, compileContext, vec4( 0, 0, 0, 1 ) ); + const { uvNode, textureFile, addressModes } = getTextureInputs( nodeX, compileContext ); + const fallback = vec4( 0, 0, 0, 1 ); + const sampled = sampleTexture( textureFile, uvNode, compileContext, fallback, addressModes, fallback ); const sampledThickness = element( sampled, 0 ); const thicknessMin = nodeX.getNodeByName( 'thicknessMin' ) || float( 100 ); const thicknessMax = nodeX.getNodeByName( 'thicknessMax' ) || float( 400 ); From 432258fd4a3e43e5b5a088c4fc2b96b49b8fcec1 Mon Sep 17 00:00:00 2001 From: Ben Houston Date: Fri, 8 May 2026 09:45:09 -0400 Subject: [PATCH 29/40] align viewdirection and add support for world/object space. Add artistic_ior node implementation. --- .../loaders/materialx/MaterialXNodeLibrary.js | 18 +++++++++-- .../compile/MaterialXCompileRegistry.js | 32 +++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/examples/jsm/loaders/materialx/MaterialXNodeLibrary.js b/examples/jsm/loaders/materialx/MaterialXNodeLibrary.js index 2c028ab4a3ddcb..00007f6e268637 100644 --- a/examples/jsm/loaders/materialx/MaterialXNodeLibrary.js +++ b/examples/jsm/loaders/materialx/MaterialXNodeLibrary.js @@ -70,6 +70,7 @@ import { mx_atan2, positionLocal, positionWorld, + cameraPosition, mx_heighttonormal, float, int, @@ -143,7 +144,20 @@ const mx_circle = ( texcoord, center, radius ) => { const mx_bump = ( height, scale = 1 ) => normalMap( mx_heighttonormal( height, 1 ), scale ); const mx_dot = ( inNode ) => inNode; -const mx_viewdirection = () => normalize( mul( positionWorld, float( - 1 ) ) ); +const mx_viewdirection = ( space = 'world' ) => { + + const outputSpace = normalizeSpaceName( space, 'world' ); + const worldDirection = normalize( sub( positionWorld, cameraPosition ) ); + + if ( outputSpace === 'world' ) { + + return worldDirection; + + } + + return mx_transformnormal( worldDirection, 'world', outputSpace ); + +}; const mx_blackbody = ( temperature = 5000 ) => { const temperatureKelvin = clamp( temperature, float( 800 ), float( 25000 ) ); @@ -570,7 +584,7 @@ const MXElements = [ createMXElement( 'length', length, [ 'in' ], { in: defaultFloat( 0 ) } ), createMXElement( 'dot', mx_dot, [ 'in' ], { in: defaultFloat( 0 ) } ), createMXElement( 'dotproduct', dot, [ 'in1', 'in2' ], { in1: defaultFloat( 0 ), in2: defaultFloat( 0 ) } ), - createMXElement( 'viewdirection', mx_viewdirection ), + createMXElement( 'viewdirection', mx_viewdirection, [ 'space' ], { space: () => 'world' } ), createMXElement( 'crossproduct', cross, [ 'in1', 'in2' ], { in1: defaultVec3( 0, 0, 0 ), in2: defaultVec3( 0, 0, 0 ) } ), createMXElement( 'distance', distance, [ 'in1', 'in2' ], { in1: defaultFloat( 0 ), in2: defaultFloat( 0 ) } ), createMXElement( 'invert', mx_invert, [ 'in', 'amount' ], { in: defaultFloat( 0 ), amount: defaultFloat( 1 ) } ), diff --git a/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js b/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js index 6a8dc824d1fdb4..94b11147bb30be 100644 --- a/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js +++ b/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js @@ -25,8 +25,10 @@ import { mix, dot, div, + max, normalize, mx_atan2, + sqrt, } from 'three/tsl'; import { getComponentCountForType, @@ -184,6 +186,35 @@ const compileConvertNode = ( nodeX ) => { const compileConstantNode = ( nodeX ) => nodeX.getNodeByName( 'value' ); +const compileArtisticIorNode = ( nodeX, out ) => { + + const reflectivity = clamp( + nodeX.getNodeByName( 'reflectivity' ) || vec3( 0.944, 0.776, 0.373 ), + vec3( 0, 0, 0 ), + vec3( 0.99, 0.99, 0.99 ), + ); + const edgeColor = nodeX.getNodeByName( 'edge_color' ) || vec3( 0.998, 0.981, 0.751 ); + const one = vec3( 1, 1, 1 ); + const nMin = div( sub( one, reflectivity ), add( one, reflectivity ) ); + const nMax = div( add( one, sqrt( reflectivity ) ), sub( one, sqrt( reflectivity ) ) ); + const ior = mix( nMax, nMin, edgeColor ); + const iorPlusOne = add( ior, one ); + const iorMinusOne = sub( ior, one ); + const k2 = max( + div( + sub( + mul( mul( iorPlusOne, iorPlusOne ), reflectivity ), + mul( iorMinusOne, iorMinusOne ), + ), + sub( one, reflectivity ), + ), + vec3( 0, 0, 0 ), + ); + const extinction = sqrt( k2 ); + return out === 'extinction' ? extinction : ior; + +}; + const compileBooleanConditionalNode = ( nodeX ) => { if ( nodeX.type !== 'boolean' ) return null; @@ -662,6 +693,7 @@ function createMaterialXCompileRegistry() { const registry = new Map(); register( registry, [ 'convert' ], ( nodeX ) => compileConvertNode( nodeX ) ); register( registry, [ 'constant' ], ( nodeX ) => compileConstantNode( nodeX ) ); + register( registry, [ 'artistic_ior' ], ( nodeX, out ) => compileArtisticIorNode( nodeX, out ) ); register( registry, [ 'position' ], ( nodeX ) => compileSpaceInputNode( nodeX, positionLocal, positionWorld ) ); register( registry, [ 'normal' ], ( nodeX ) => compileNormalizedSpaceInputNode( nodeX, normalLocal, normalWorld ) ); register( registry, [ 'tangent' ], ( nodeX ) => compileNormalizedSpaceInputNode( nodeX, tangentLocal, tangentWorld ) ); From 9499ab5e29db53357e5abace21997b555b2c18de Mon Sep 17 00:00:00 2001 From: Ben Houston Date: Fri, 8 May 2026 14:58:00 -0400 Subject: [PATCH 30/40] fix hextile falloff contrast --- .../jsm/loaders/materialx/MaterialXHextile.js | 4 ++-- .../compile/MaterialXCompileRegistry.js | 17 +++++++++++++++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/examples/jsm/loaders/materialx/MaterialXHextile.js b/examples/jsm/loaders/materialx/MaterialXHextile.js index ce10fb94a639f9..97a880c7f17e38 100644 --- a/examples/jsm/loaders/materialx/MaterialXHextile.js +++ b/examples/jsm/loaders/materialx/MaterialXHextile.js @@ -62,7 +62,7 @@ function normalizeBlendWeights( weights ) { const wx = element( weights, 0 ); const wy = element( weights, 1 ); const wz = element( weights, 2 ); - const sum = max( add( add( wx, wy ), wz ), HEXTILE_EPSILON ); + const sum = add( add( wx, wy ), wz ); return div( weights, sum ); } @@ -84,7 +84,7 @@ function toTileCenter( tileId ) { export function mxHextileComputeBlendWeights( luminanceWeights, tileWeights, falloff ) { - const weighted = mul( luminanceWeights, pow( max( tileWeights, vec3( HEXTILE_EPSILON, HEXTILE_EPSILON, HEXTILE_EPSILON ) ), vec3( 7, 7, 7 ) ) ); + const weighted = mul( luminanceWeights, pow( tileWeights, vec3( 7, 7, 7 ) ) ); const normalized = normalizeBlendWeights( weighted ); const gained = vec3( mxSchlickGain( element( normalized, 0 ), falloff ), diff --git a/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js b/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js index 94b11147bb30be..2c0943531203d6 100644 --- a/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js +++ b/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js @@ -309,7 +309,19 @@ const compileHexTiledTextureNode = ( nodeX, compileContext, category ) => { const offsetRange = nodeX.getNodeByName( 'offsetrange' ) || vec2( 0, 1 ); const falloff = nodeX.getNodeByName( 'falloff' ) || float( 0.5 ); const falloffContrast = nodeX.getNodeByName( 'falloffcontrast' ) || float( 0.5 ); - const lumaCoeffs = nodeX.getNodeByName( 'lumacoeffs' ) || vec3( 0.2722287, 0.6740818, 0.0536895 ); + let lumaCoeffs = nodeX.getNodeByName( 'lumacoeffs' ) || vec3( 0.2722287, 0.6740818, 0.0536895 ); + const lumaCoeffsInput = nodeX.getChildByName( 'lumacoeffs' ); + if ( lumaCoeffsInput && lumaCoeffsInput.isConst ) { + + const lumaCoeffValues = lumaCoeffsInput.getVector(); + if ( lumaCoeffValues.length === 3 ) { + + // Treat luminance coefficients as raw numeric values, not display colors. + lumaCoeffs = vec3( lumaCoeffValues[ 0 ], lumaCoeffValues[ 1 ], lumaCoeffValues[ 2 ] ); + + } + + } const transformedUv = compileContext.mxFromBottomLeftUvSpace( mul( uvNode, tiling ) ); const tileData = compileContext.mxHextileCoord( transformedUv, rotation, rotationRange, scale, scaleRange, offset, offsetRange ); @@ -341,10 +353,11 @@ const compileHexTiledTextureNode = ( nodeX, compileContext, category ) => { const c0 = toVec3Channels( sample0 ); const c1 = toVec3Channels( sample1 ); const c2 = toVec3Channels( sample2 ); + const falloffContrastWeight = mul( falloffContrast, 0.5 ); const cw = mix( vec3( 1, 1, 1 ), vec3( dot( c0, lumaCoeffs ), dot( c1, lumaCoeffs ), dot( c2, lumaCoeffs ) ), - vec3( falloffContrast, falloffContrast, falloffContrast ), + vec3( falloffContrastWeight, falloffContrastWeight, falloffContrastWeight ), ); const blendWeights = compileContext.mxHextileComputeBlendWeights( cw, tileData.weights, falloff ); const alphaWeights = compileContext.mxHextileComputeBlendWeights( vec3( 1, 1, 1 ), tileData.weights, falloff ); From 23c55a8c2bf8bc76996d13a65e30475370f8aa76 Mon Sep 17 00:00:00 2001 From: Ben Houston Date: Fri, 8 May 2026 15:01:13 -0400 Subject: [PATCH 31/40] fix matrix order on transformpoint. --- examples/jsm/loaders/materialx/MaterialXNodeLibrary.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/jsm/loaders/materialx/MaterialXNodeLibrary.js b/examples/jsm/loaders/materialx/MaterialXNodeLibrary.js index 00007f6e268637..0b78aeb2fddbc6 100644 --- a/examples/jsm/loaders/materialx/MaterialXNodeLibrary.js +++ b/examples/jsm/loaders/materialx/MaterialXNodeLibrary.js @@ -310,7 +310,7 @@ const mx_transformpoint = ( inNode = vec3( 0, 0, 0 ), fromspace = 'world', tospa const point4 = vec4( inPoint, 1 ); const matrix = from === 'object' && to === 'world' ? modelWorldMatrix : modelWorldMatrixInverse; - const transformed4 = mul( point4, matrix ); + const transformed4 = mul( matrix, point4 ); return vec3( element( transformed4, 0 ), element( transformed4, 1 ), element( transformed4, 2 ) ); }; From eb2f8023088f809eb83e8e9c726992ab126a707a Mon Sep 17 00:00:00 2001 From: Ben Houston Date: Fri, 8 May 2026 16:06:49 -0400 Subject: [PATCH 32/40] forgotten import --- .../jsm/loaders/materialx/compile/MaterialXCompileRegistry.js | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js b/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js index 2c0943531203d6..c7e965c66b4c02 100644 --- a/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js +++ b/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js @@ -1,6 +1,7 @@ import { element, float, + int, mat3, mat4, mul, From 417cd4a41cee5fd30b289742a0021284fe768995 Mon Sep 17 00:00:00 2001 From: Ben Houston Date: Sat, 9 May 2026 21:36:02 -0400 Subject: [PATCH 33/40] fix materialx bump --- .../loaders/materialx/MaterialXNodeLibrary.js | 52 +++++++++++++++++-- src/nodes/materialx/MaterialXNodes.js | 25 ++++++--- 2 files changed, 65 insertions(+), 12 deletions(-) diff --git a/examples/jsm/loaders/materialx/MaterialXNodeLibrary.js b/examples/jsm/loaders/materialx/MaterialXNodeLibrary.js index 0b78aeb2fddbc6..7f7222dda5abc5 100644 --- a/examples/jsm/loaders/materialx/MaterialXNodeLibrary.js +++ b/examples/jsm/loaders/materialx/MaterialXNodeLibrary.js @@ -33,7 +33,6 @@ import { transpose, determinant, inverse, - normalMap, mat3, mx_ramp4, mx_ramplr, @@ -70,6 +69,9 @@ import { mx_atan2, positionLocal, positionWorld, + normalWorld, + tangentWorld, + bitangentWorld, cameraPosition, mx_heighttonormal, float, @@ -142,7 +144,37 @@ const mx_circle = ( texcoord, center, radius ) => { }; -const mx_bump = ( height, scale = 1 ) => normalMap( mx_heighttonormal( height, 1 ), scale ); +const mx_normalmap = ( + value = vec3( 0.5, 0.5, 1.0 ), + scale = 1.0, + normal = vec3( 0.0, 0.0, 1.0 ), + tangent = vec3( 1.0, 0.0, 0.0 ), + bitangent = vec3( 0.0, 1.0, 0.0 ), +) => { + + const mapValue = toVec3Channels( value ); + const decoded = sub( mul( mapValue, 2.0 ), vec3( 1.0, 1.0, 1.0 ) ); + const safeValue = dot( mapValue, mapValue ).equal( 0 ).mix( decoded, vec3( 0.0, 0.0, 1.0 ) ); + const normalScale = vec2( scale ); + const blended = + add( + add( + mul( tangent, mul( element( safeValue, 0 ), element( normalScale, 0 ) ) ), + mul( bitangent, mul( element( safeValue, 1 ), element( normalScale, 1 ) ) ), + ), + mul( normal, element( safeValue, 2 ) ), + ); + return normalize( blended ); + +}; + +const mx_bump = ( + height, + scale = 1, + normal = vec3( 0.0, 0.0, 1.0 ), + tangent = vec3( 1.0, 0.0, 0.0 ), + bitangent = vec3( 0.0, 1.0, 0.0 ), +) => mx_normalmap( mx_heighttonormal( height, 1 ), scale, normal, tangent, bitangent ); const mx_dot = ( inNode ) => inNode; const mx_viewdirection = ( space = 'world' ) => { @@ -604,7 +636,13 @@ const MXElements = [ fromspace: () => 'world', tospace: () => 'world', } ), - createMXElement( 'normalmap', normalMap, [ 'in', 'scale' ], { in: defaultVec3( 0.5, 0.5, 1.0 ), scale: defaultFloat( 1 ) } ), + createMXElement( 'normalmap', mx_normalmap, [ 'in', 'scale', 'normal', 'tangent', 'bitangent' ], { + in: defaultVec3( 0.5, 0.5, 1.0 ), + scale: defaultFloat( 1 ), + normal: () => normalWorld, + tangent: () => tangentWorld, + bitangent: () => bitangentWorld, + } ), createMXElement( 'transpose', transpose, [ 'in' ] ), createMXElement( 'determinant', determinant, [ 'in' ] ), createMXElement( 'invertmatrix', inverse, [ 'in' ] ), @@ -966,7 +1004,13 @@ const MXElements = [ center: defaultVec2( 0, 0 ), radius: defaultFloat( 0.5 ), } ), - createMXElement( 'bump', mx_bump, [ 'height', 'scale' ], { height: defaultFloat( 0 ), scale: defaultFloat( 1 ) } ), + createMXElement( 'bump', mx_bump, [ 'height', 'scale', 'normal', 'tangent', 'bitangent' ], { + height: defaultFloat( 0 ), + scale: defaultFloat( 1 ), + normal: () => normalWorld, + tangent: () => tangentWorld, + bitangent: () => bitangentWorld, + } ), createMXElement( 'blackbody', mx_blackbody, [ 'temperature' ], { temperature: defaultFloat( 5000 ) } ), ]; diff --git a/src/nodes/materialx/MaterialXNodes.js b/src/nodes/materialx/MaterialXNodes.js index d2524b90c1a879..00ca3747054ebb 100644 --- a/src/nodes/materialx/MaterialXNodes.js +++ b/src/nodes/materialx/MaterialXNodes.js @@ -14,10 +14,9 @@ import { mx_srgb_texture_to_lin_rec709 } from './MaterialXColorTransform.js'; import { float, vec2, vec3, vec4, int, add, sub, mul, div, atan, mix, pow, smoothstep, - floor, abs, max, clamp, step + floor, abs, max, clamp, step, cross, dot, normalize } from '../tsl/TSLBase.js'; import { uv } from '../accessors/UV.js'; -import { bumpMap } from '../display/BumpMapNode.js'; import { frameId, time } from '../utils/Timer.js'; export const mx_aastep = ( threshold, value ) => { @@ -170,11 +169,21 @@ export const mx_place2d = ( }; -export const mx_heighttonormal = ( input, scale/*, texcoord*/ ) => { - - input = vec3( input ); - scale = float( scale ); - - return bumpMap( input, scale ); +export const mx_heighttonormal = ( input, scale = 1, texcoord = uv() ) => { + + const sobelScale = float( 1.0 / 16.0 ); + const height = float( input ); + const uvNode = vec2( texcoord ); + const dHdS = vec2( height.dFdx(), height.dFdy() ).mul( float( scale ) ).mul( sobelScale ); + const dUdS = vec2( uvNode.x.dFdx(), uvNode.x.dFdy() ); + const dVdS = vec2( uvNode.y.dFdx(), uvNode.y.dFdy() ); + const tangent = vec3( dUdS.x, dVdS.x, dHdS.x ); + const bitangent = vec3( dUdS.y, dVdS.y, dHdS.y ); + let n = cross( tangent, bitangent ); + const invalid = dot( n, n ).lessThan( float( 1e-12 ) ); + n = invalid.mix( n, vec3( 0, 0, 1 ) ); + const mirrored = n.z.lessThan( float( 0 ) ); + n = mirrored.mix( n, n.mul( - 1 ) ); + return normalize( n ).mul( 0.5 ).add( 0.5 ); }; From 6e14e3fb07428e11d373eba657ad67da08dad362 Mon Sep 17 00:00:00 2001 From: Ben Houston Date: Sun, 10 May 2026 07:27:12 -0400 Subject: [PATCH 34/40] Materialx: fix default for heighttonormal --- .../jsm/loaders/materialx/compile/MaterialXCompileRegistry.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js b/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js index c7e965c66b4c02..0f0a0cbfc28224 100644 --- a/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js +++ b/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js @@ -48,7 +48,7 @@ const register = ( registry, categories, handler ) => { }; -const UV_FALLBACK_CATEGORIES = new Set( [ 'checkerboard', 'noise2d', 'fractal2d', 'cellnoise2d', 'worleynoise2d', 'unifiednoise2d' ] ); +const UV_FALLBACK_CATEGORIES = new Set( [ 'checkerboard', 'noise2d', 'fractal2d', 'cellnoise2d', 'worleynoise2d', 'unifiednoise2d', 'heighttonormal' ] ); const SCALAR_TYPES = new Set( [ 'boolean', 'integer', 'float' ] ); const THREE_COMPONENT_TYPES = new Set( [ 'vector2', 'vector3', 'vector4', 'color3', 'color4' ] ); const TEXTURE_ADDRESS_MODES = new Set( [ 'constant', 'clamp', 'periodic', 'mirror' ] ); From 2d1043dc176302579a341c9bcbc0d5f5ade35fe8 Mon Sep 17 00:00:00 2001 From: Ben Houston Date: Sun, 10 May 2026 07:41:05 -0400 Subject: [PATCH 35/40] MaterialX: fix hextilenormalmap --- .../compile/MaterialXCompileRegistry.js | 71 ++++++++++++++++++- 1 file changed, 69 insertions(+), 2 deletions(-) diff --git a/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js b/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js index 0f0a0cbfc28224..a88ef9612ec375 100644 --- a/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js +++ b/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js @@ -286,6 +286,43 @@ const compileTiledImageNode = ( nodeX, compileContext ) => { }; +const compileHexTiledNormalMapNode = ( nodeX, compileContext, sampleNode ) => { + + const normalMapNodeElement = compileContext.nodeLibrary.normalmap; + const strengthNode = nodeX.getNodeByName( 'strength' ) || float( 1 ); + + if ( ! normalMapNodeElement || typeof normalMapNodeElement.nodeFunc !== 'function' ) { + + return normalMap( vec4( sampleNode, 1 ), strengthNode ); + + } + + const args = normalMapNodeElement.params.map( ( paramName ) => { + + if ( paramName === 'in' ) return sampleNode; + if ( paramName === 'scale' ) return strengthNode; + return nodeX.getNodeByName( paramName ); + + } ); + + for ( let i = 0; i < normalMapNodeElement.params.length; i += 1 ) { + + if ( args[ i ] !== undefined && args[ i ] !== null ) { + + continue; + + } + + const paramName = normalMapNodeElement.params[ i ]; + const defaultValue = normalMapNodeElement.defaults ? normalMapNodeElement.defaults[ paramName ] : undefined; + args[ i ] = defaultValue !== undefined ? ( typeof defaultValue === 'function' ? defaultValue() : float( defaultValue ) ) : float( 0 ); + + } + + return normalMapNodeElement.nodeFunc( ...args ); + +}; + const compileHexTiledTextureNode = ( nodeX, compileContext, category ) => { const file = nodeX.getChildByName( 'file' ); @@ -295,11 +332,27 @@ const compileHexTiledTextureNode = ( nodeX, compileContext, category ) => { nodeX.name, `Texture node "${nodeX.name || nodeX.element}" is missing required input "file".`, ); + if ( category === 'hextilednormalmap' ) { + + return normalize( nodeX.getNodeByName( 'normal' ) || normalLocal ); + + } return vec4( 0, 0, 0, 1 ); } const textureFile = file.getTexture(); + if ( ! textureFile ) { + + if ( category === 'hextilednormalmap' ) { + + return normalize( nodeX.getNodeByName( 'normal' ) || normalLocal ); + + } + return vec4( 0, 0, 0, 1 ); + + } + const uvNode = nodeX.getNodeByName( 'texcoord' ) || getDefaultUvNode( compileContext ); const tiling = nodeX.getNodeByName( 'tiling' ) || vec2( 1, 1 ); const rotation = nodeX.getNodeByName( 'rotation' ) || float( 1 ); @@ -371,8 +424,22 @@ const compileHexTiledTextureNode = ( nodeX, compileContext, category ) => { if ( category === 'hextilednormalmap' ) { - const normalScale = nodeX.getNodeByName( 'scale' ) || float( 1 ); - return normalMap( blended, normalScale ); + const flipGNode = nodeX.getNodeByName( 'flip_g' ); + let normalSample = blended; + + if ( flipGNode ) { + + const flippedSample = vec4( + element( blended, 0 ), + sub( 1, element( blended, 1 ) ), + element( blended, 2 ), + element( blended, 3 ), + ); + normalSample = toBooleanNode( flipGNode ).select( flippedSample, blended ); + + } + + return compileHexTiledNormalMapNode( nodeX, compileContext, toVec3Channels( normalSample ) ); } From c82bb00473682f8e8257ea4c95896bc15c235ff1 Mon Sep 17 00:00:00 2001 From: Ben Houston Date: Sun, 10 May 2026 10:11:27 -0400 Subject: [PATCH 36/40] add support for switch --- .../compile/MaterialXCompileRegistry.js | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js b/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js index a88ef9612ec375..ff7bce179a390e 100644 --- a/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js +++ b/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js @@ -23,6 +23,7 @@ import { clamp, add, sub, + floor, mix, dot, div, @@ -52,6 +53,8 @@ const UV_FALLBACK_CATEGORIES = new Set( [ 'checkerboard', 'noise2d', 'fractal2d' const SCALAR_TYPES = new Set( [ 'boolean', 'integer', 'float' ] ); const THREE_COMPONENT_TYPES = new Set( [ 'vector2', 'vector3', 'vector4', 'color3', 'color4' ] ); const TEXTURE_ADDRESS_MODES = new Set( [ 'constant', 'clamp', 'periodic', 'mirror' ] ); +const SWITCH_MIN_INDEX = 1; +const SWITCH_MAX_INDEX = 10; const getDefaultUvNode = ( compileContext ) => compileContext.mxToBottomLeftUvSpace( uv( 0 ) ); @@ -233,6 +236,31 @@ const compileBooleanConditionalNode = ( nodeX ) => { }; +const getSwitchBranchNode = ( nodeX, index ) => nodeX.getNodeByName( `in${index}` ) || getZeroNodeForType( nodeX.type ); + +const compileSwitchNode = ( nodeX ) => { + + const fallbackNode = getSwitchBranchNode( nodeX, SWITCH_MIN_INDEX ); + const whichInput = nodeX.getNodeByName( 'which' ); + const switchIndex = add( floor( float( whichInput === undefined || whichInput === null ? 0 : whichInput ) ), 1 ); + const whichNode = clamp( + float( switchIndex ), + float( SWITCH_MIN_INDEX ), + float( SWITCH_MAX_INDEX ), + ); + + let result = fallbackNode; + for ( let branchIndex = SWITCH_MIN_INDEX + 1; branchIndex <= SWITCH_MAX_INDEX; branchIndex += 1 ) { + + const branchNode = getSwitchBranchNode( nodeX, branchIndex ); + result = whichNode.equal( float( branchIndex ) ).select( branchNode, result ); + + } + + return result; + +}; + const compileSpaceInputNode = ( nodeX, objectNode, worldNode ) => { const rawSpace = nodeX.getInputValueByName( 'space' ) ?? nodeX.getAttribute( 'space' ); @@ -792,6 +820,7 @@ function createMaterialXCompileRegistry() { compileGltfAnisotropyImageNode( nodeX, out, compileContext ) ); register( registry, [ 'gltf_iridescence_thickness' ], ( nodeX, out, compileContext ) => compileGltfIridescenceThicknessNode( nodeX, compileContext ) ); + register( registry, [ 'switch' ], ( nodeX ) => compileSwitchNode( nodeX ) ); register( registry, [ 'transformmatrix' ], ( nodeX, out, compileContext ) => compileTransformMatrixNode( nodeX, compileContext ) ); register( registry, [ 'creatematrix' ], ( nodeX ) => compileCreateMatrixNode( nodeX ) ); register( registry, [ 'invertmatrix' ], ( nodeX, out, compileContext ) => compileInvertMatrixNode( nodeX, compileContext ) ); From e3aad2715be3df07f9ccd27c6f60d11284451a7a Mon Sep 17 00:00:00 2001 From: Ben Houston Date: Mon, 11 May 2026 08:47:31 -0400 Subject: [PATCH 37/40] MaterialX: fix definition of gltf_colorimage --- .../compile/MaterialXCompileRegistry.js | 36 ++++++++++++++++--- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js b/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js index ff7bce179a390e..3a197bc4b40b77 100644 --- a/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js +++ b/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js @@ -548,17 +548,43 @@ const compileGltfTextureNode = ( nodeX, compileContext, category ) => { const compileGltfColorImageNode = ( nodeX, out, compileContext ) => { const { file, uvNode, textureFile, addressModes } = getTextureInputs( nodeX, compileContext ); - const fallback = vec4( 0, 0, 0, 1 ); - const sampled = sampleTexture( textureFile, uvNode, compileContext, fallback, addressModes, fallback ); + let transformedUv = uvNode; + const place2d = compileContext.nodeLibrary.place2d; - if ( out === 'outa' || out === 'a' ) { + if ( place2d ) { - return element( sampled, 3 ); + const pivot = nodeX.getNodeByName( 'pivot' ) || vec2( 0, 1 ); + const scale = nodeX.getNodeByName( 'scale' ) || vec2( 1, 1 ); + const rotate = nodeX.getNodeByName( 'rotate' ) || float( 0 ); + const offset = nodeX.getNodeByName( 'offset' ) || vec2( 0, 0 ); + // Match MaterialX's ND_gltf_colorimage graph implementation, which wires gltf_image with operationorder=0. + const operationorder = int( 0 ); + transformedUv = place2d.nodeFunc( + uvNode, + pivot, + div( vec2( 1, 1 ), scale ), + mul( rotate, - 1 ), + mul( offset, vec2( - 1, 1 ) ), + operationorder, + ); } + const defaultInput = nodeX.getNodeByName( 'default' ) || vec4( 0, 0, 0, 0 ); + const fallback = vec4( defaultInput ); + const sampled = sampleTexture( textureFile, transformedUv, compileContext, fallback, addressModes, fallback ); const converted = applyTextureColorSpace( sampled, file ); - return toVec3Channels( converted ); + const color = nodeX.getNodeByName( 'color' ) || vec4( 1, 1, 1, 1 ); + const geomcolor = nodeX.getNodeByName( 'geomcolor' ) || vec4( 1, 1, 1, 1 ); + const modulated = mul( mul( converted, color ), geomcolor ); + + if ( out === 'outa' || out === 'a' ) { + + return element( modulated, 3 ); + + } + + return toVec3Channels( modulated ); }; From 6cc970a7eb813a77c220d2241a810fb83a74856a Mon Sep 17 00:00:00 2001 From: Ben Houston Date: Mon, 11 May 2026 10:02:16 -0400 Subject: [PATCH 38/40] explain sign flip in Rodrigues formula. --- src/nodes/materialx/MaterialXCore.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/nodes/materialx/MaterialXCore.js b/src/nodes/materialx/MaterialXCore.js index 362e7ec130dda4..5cd451475678ab 100644 --- a/src/nodes/materialx/MaterialXCore.js +++ b/src/nodes/materialx/MaterialXCore.js @@ -27,7 +27,9 @@ export const mx_rotate3d = ( input, amount = 0, axis = vec3( 0, 1, 0 ) ) => { const c = cos( rotationRadians ); const oc = sub( 1, c ); - // https://en.wikipedia.org/wiki/Rodrigues%27_rotation_formula + // based on https://en.wikipedia.org/wiki/Rodrigues%27_rotation_formula + // but the code in the Wikipedia article is for v' = M * v, where as + // MaterialX is v' = v * M, thus the order of parameters into the first cross are reversed return input.mul( c ) .add( input.cross( normalizedAxis ).mul( s ) ) .add( normalizedAxis.mul( normalizedAxis.dot( input ).mul( oc ) ) ); From ae8d870feb5e90f3b5066449b67fe368af9f50ce Mon Sep 17 00:00:00 2001 From: Ben Houston Date: Mon, 11 May 2026 12:43:36 -0400 Subject: [PATCH 39/40] fix MaterialX range with fliped bounds behavior. --- .../compile/MaterialXCompileRegistry.js | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js b/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js index 3a197bc4b40b77..4ffa9530528de1 100644 --- a/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js +++ b/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js @@ -22,6 +22,8 @@ import { bitangentWorld, clamp, add, + abs, + min, sub, floor, mix, @@ -30,6 +32,7 @@ import { max, normalize, mx_atan2, + pow, sqrt, } from 'three/tsl'; import { @@ -236,6 +239,88 @@ const compileBooleanConditionalNode = ( nodeX ) => { }; +const compileClampNode = ( nodeX ) => { + + const inNode = nodeX.getNodeByName( 'in' ) || float( 0 ); + const low = nodeX.getNodeByName( 'low' ) || float( 0 ); + const high = nodeX.getNodeByName( 'high' ) || float( 1 ); + return min( max( inNode, low ), high ); + +}; + +const compileNormalizeNode = ( nodeX ) => { + + const inNode = nodeX.getNodeByName( 'in' ) || getZeroNodeForType( nodeX.type ); + const zeroNode = getZeroNodeForType( nodeX.type ); + const lengthSquared = dot( inNode, inNode ); + const safeLengthSquared = max( lengthSquared, float( 1e-8 ) ); + const normalized = mul( inNode, div( float( 1 ), sqrt( safeLengthSquared ) ) ); + return abs( lengthSquared ).lessThan( float( 1e-8 ) ).select( zeroNode, normalized ); + +}; + +const compileRemapNode = ( nodeX ) => { + + const inNode = nodeX.getNodeByName( 'in' ) || float( 0 ); + const inLow = nodeX.getNodeByName( 'inlow' ) || float( 0 ); + const inHigh = nodeX.getNodeByName( 'inhigh' ) || float( 1 ); + const outLow = nodeX.getNodeByName( 'outlow' ) || float( 0 ); + const outHigh = nodeX.getNodeByName( 'outhigh' ) || float( 1 ); + const denominator = sub( inHigh, inLow ); + const isDegenerate = abs( denominator ).lessThan( float( 1e-8 ) ); + const safeDenominator = isDegenerate.select( float( 1 ), denominator ); + const remapped = add( + mul( div( sub( inNode, inLow ), safeDenominator ), sub( outHigh, outLow ) ), + outLow, + ); + return isDegenerate.select( outLow, remapped ); + +}; + +const compileRangeNode = ( nodeX ) => { + + const inNode = nodeX.getNodeByName( 'in' ) || float( 0 ); + const inLow = nodeX.getNodeByName( 'inlow' ) || float( 0 ); + const inHigh = nodeX.getNodeByName( 'inhigh' ) || float( 1 ); + const outLow = nodeX.getNodeByName( 'outlow' ) || float( 0 ); + const outHigh = nodeX.getNodeByName( 'outhigh' ) || float( 1 ); + const gamma = nodeX.getNodeByName( 'gamma' ) || float( 1 ); + const doClamp = nodeX.getNodeByName( 'doclamp' ) || int( 0 ); + + const denominator = sub( inHigh, inLow ); + const isDegenerate = abs( denominator ).lessThan( float( 1e-8 ) ); + const safeDenominator = isDegenerate.select( float( 1 ), denominator ); + const normalized = div( sub( inNode, inLow ), safeDenominator ); + const safeGamma = max( abs( gamma ), float( 1e-8 ) ); + const gammaApplied = pow( normalized, div( float( 1 ), safeGamma ) ); + const remapped = add( mul( gammaApplied, sub( outHigh, outLow ) ), outLow ); + const result = isDegenerate.select( outLow, remapped ); + const clamped = min( max( result, outLow ), outHigh ); + + return toBooleanNode( doClamp ).select( clamped, result ); + +}; + +const compileRamplrNode = ( nodeX, compileContext ) => { + + const texcoord = nodeX.getNodeByName( 'texcoord' ) || getDefaultUvNode( compileContext ); + const valuel = nodeX.getNodeByName( 'valuel' ) || float( 0 ); + const valuer = nodeX.getNodeByName( 'valuer' ) || float( 1 ); + const t = min( max( element( texcoord, 0 ), float( 0 ) ), float( 1 ) ); + return mix( valuel, valuer, t ); + +}; + +const compileRamptbNode = ( nodeX, compileContext ) => { + + const texcoord = nodeX.getNodeByName( 'texcoord' ) || getDefaultUvNode( compileContext ); + const valueb = nodeX.getNodeByName( 'valueb' ) || float( 0 ); + const valuet = nodeX.getNodeByName( 'valuet' ) || float( 1 ); + const t = min( max( element( texcoord, 1 ), float( 0 ) ), float( 1 ) ); + return mix( valueb, valuet, t ); + +}; + const getSwitchBranchNode = ( nodeX, index ) => nodeX.getNodeByName( `in${index}` ) || getZeroNodeForType( nodeX.type ); const compileSwitchNode = ( nodeX ) => { @@ -829,6 +914,12 @@ function createMaterialXCompileRegistry() { register( registry, [ 'convert' ], ( nodeX ) => compileConvertNode( nodeX ) ); register( registry, [ 'constant' ], ( nodeX ) => compileConstantNode( nodeX ) ); register( registry, [ 'artistic_ior' ], ( nodeX, out ) => compileArtisticIorNode( nodeX, out ) ); + register( registry, [ 'clamp' ], ( nodeX ) => compileClampNode( nodeX ) ); + register( registry, [ 'normalize' ], ( nodeX ) => compileNormalizeNode( nodeX ) ); + register( registry, [ 'range' ], ( nodeX ) => compileRangeNode( nodeX ) ); + register( registry, [ 'remap' ], ( nodeX ) => compileRemapNode( nodeX ) ); + register( registry, [ 'ramplr' ], ( nodeX, out, compileContext ) => compileRamplrNode( nodeX, compileContext ) ); + register( registry, [ 'ramptb' ], ( nodeX, out, compileContext ) => compileRamptbNode( nodeX, compileContext ) ); register( registry, [ 'position' ], ( nodeX ) => compileSpaceInputNode( nodeX, positionLocal, positionWorld ) ); register( registry, [ 'normal' ], ( nodeX ) => compileNormalizedSpaceInputNode( nodeX, normalLocal, normalWorld ) ); register( registry, [ 'tangent' ], ( nodeX ) => compileNormalizedSpaceInputNode( nodeX, tangentLocal, tangentWorld ) ); From a36f92eac5a09e3d5fb50952d5b2aa02225b659b Mon Sep 17 00:00:00 2001 From: Ben Houston Date: Mon, 11 May 2026 22:02:55 -0400 Subject: [PATCH 40/40] MaterialX: fix ramptb, and range --- .../loaders/materialx/MaterialXNodeLibrary.js | 8 ++--- .../compile/MaterialXCompileRegistry.js | 29 ++++--------------- src/nodes/materialx/MaterialXNodes.js | 4 +-- 3 files changed, 11 insertions(+), 30 deletions(-) diff --git a/examples/jsm/loaders/materialx/MaterialXNodeLibrary.js b/examples/jsm/loaders/materialx/MaterialXNodeLibrary.js index 7f7222dda5abc5..7b29d1cee4c03e 100644 --- a/examples/jsm/loaders/materialx/MaterialXNodeLibrary.js +++ b/examples/jsm/loaders/materialx/MaterialXNodeLibrary.js @@ -745,9 +745,9 @@ const MXElements = [ valuel: defaultFloat( 0 ), valuer: defaultFloat( 0 ), } ), - createMXElement( 'ramptb', mx_ramptb, [ 'valuet', 'valueb', 'texcoord' ], { - valuet: defaultFloat( 0 ), + createMXElement( 'ramptb', mx_ramptb, [ 'valueb', 'valuet', 'texcoord' ], { valueb: defaultFloat( 0 ), + valuet: defaultFloat( 0 ), } ), createMXElement( 'ramp4', mx_ramp4, [ 'valuetl', 'valuetr', 'valuebl', 'valuebr', 'texcoord' ], { valuetl: defaultColor( 0, 0, 0 ), @@ -833,9 +833,9 @@ const MXElements = [ valuer: defaultFloat( 0 ), center: defaultFloat( 0.5 ), } ), - createMXElement( 'splittb', mx_splittb, [ 'valuet', 'valueb', 'center', 'texcoord' ], { - valuet: defaultFloat( 0 ), + createMXElement( 'splittb', mx_splittb, [ 'valueb', 'valuet', 'center', 'texcoord' ], { valueb: defaultFloat( 0 ), + valuet: defaultFloat( 0 ), center: defaultFloat( 0.5 ), } ), createMXElement( 'noise2d', mx_noise_materialx, [ 'texcoord', 'amplitude', 'pivot' ], { diff --git a/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js b/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js index 4ffa9530528de1..318e76ec917b6d 100644 --- a/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js +++ b/examples/jsm/loaders/materialx/compile/MaterialXCompileRegistry.js @@ -34,6 +34,7 @@ import { mx_atan2, pow, sqrt, + sign, } from 'three/tsl'; import { getComponentCountForType, @@ -291,8 +292,10 @@ const compileRangeNode = ( nodeX ) => { const isDegenerate = abs( denominator ).lessThan( float( 1e-8 ) ); const safeDenominator = isDegenerate.select( float( 1 ), denominator ); const normalized = div( sub( inNode, inLow ), safeDenominator ); - const safeGamma = max( abs( gamma ), float( 1e-8 ) ); - const gammaApplied = pow( normalized, div( float( 1 ), safeGamma ) ); + // Match stdlib range nodegraph semantics: + // gamma stage is sign(normalized) * pow(abs(normalized), 1 / gamma). + const reciprocalGamma = div( float( 1 ), gamma ); + const gammaApplied = mul( pow( abs( normalized ), reciprocalGamma ), sign( normalized ) ); const remapped = add( mul( gammaApplied, sub( outHigh, outLow ) ), outLow ); const result = isDegenerate.select( outLow, remapped ); const clamped = min( max( result, outLow ), outHigh ); @@ -301,26 +304,6 @@ const compileRangeNode = ( nodeX ) => { }; -const compileRamplrNode = ( nodeX, compileContext ) => { - - const texcoord = nodeX.getNodeByName( 'texcoord' ) || getDefaultUvNode( compileContext ); - const valuel = nodeX.getNodeByName( 'valuel' ) || float( 0 ); - const valuer = nodeX.getNodeByName( 'valuer' ) || float( 1 ); - const t = min( max( element( texcoord, 0 ), float( 0 ) ), float( 1 ) ); - return mix( valuel, valuer, t ); - -}; - -const compileRamptbNode = ( nodeX, compileContext ) => { - - const texcoord = nodeX.getNodeByName( 'texcoord' ) || getDefaultUvNode( compileContext ); - const valueb = nodeX.getNodeByName( 'valueb' ) || float( 0 ); - const valuet = nodeX.getNodeByName( 'valuet' ) || float( 1 ); - const t = min( max( element( texcoord, 1 ), float( 0 ) ), float( 1 ) ); - return mix( valueb, valuet, t ); - -}; - const getSwitchBranchNode = ( nodeX, index ) => nodeX.getNodeByName( `in${index}` ) || getZeroNodeForType( nodeX.type ); const compileSwitchNode = ( nodeX ) => { @@ -918,8 +901,6 @@ function createMaterialXCompileRegistry() { register( registry, [ 'normalize' ], ( nodeX ) => compileNormalizeNode( nodeX ) ); register( registry, [ 'range' ], ( nodeX ) => compileRangeNode( nodeX ) ); register( registry, [ 'remap' ], ( nodeX ) => compileRemapNode( nodeX ) ); - register( registry, [ 'ramplr' ], ( nodeX, out, compileContext ) => compileRamplrNode( nodeX, compileContext ) ); - register( registry, [ 'ramptb' ], ( nodeX, out, compileContext ) => compileRamptbNode( nodeX, compileContext ) ); register( registry, [ 'position' ], ( nodeX ) => compileSpaceInputNode( nodeX, positionLocal, positionWorld ) ); register( registry, [ 'normal' ], ( nodeX ) => compileNormalizedSpaceInputNode( nodeX, normalLocal, normalWorld ) ); register( registry, [ 'tangent' ], ( nodeX ) => compileNormalizedSpaceInputNode( nodeX, tangentLocal, tangentWorld ) ); diff --git a/src/nodes/materialx/MaterialXNodes.js b/src/nodes/materialx/MaterialXNodes.js index 00ca3747054ebb..0705970c3ac744 100644 --- a/src/nodes/materialx/MaterialXNodes.js +++ b/src/nodes/materialx/MaterialXNodes.js @@ -32,7 +32,7 @@ export const mx_aastep = ( threshold, value ) => { const _ramp = ( a, b, uv, p ) => mix( a, b, uv[ p ].clamp() ); export const mx_ramplr = ( valuel, valuer, texcoord = uv() ) => _ramp( valuel, valuer, texcoord, 'x' ); -export const mx_ramptb = ( valuet, valueb, texcoord = uv() ) => _ramp( valuet, valueb, texcoord, 'y' ); +export const mx_ramptb = ( valueb, valuet, texcoord = uv() ) => _ramp( valueb, valuet, texcoord, 'y' ); // Bilinear ramp: interpolate between four corners (tl, tr, bl, br) using texcoord.x and texcoord.y export const mx_ramp4 = ( @@ -49,7 +49,7 @@ export const mx_ramp4 = ( const _split = ( a, b, center, uv, p ) => mix( a, b, mx_aastep( center, uv[ p ] ) ); export const mx_splitlr = ( valuel, valuer, center, texcoord = uv() ) => _split( valuel, valuer, center, texcoord, 'x' ); -export const mx_splittb = ( valuet, valueb, center, texcoord = uv() ) => _split( valuet, valueb, center, texcoord, 'y' ); +export const mx_splittb = ( valueb, valuet, center, texcoord = uv() ) => _split( valueb, valuet, center, texcoord, 'y' ); export const mx_transform_uv = ( uv_scale = 1, uv_offset = 0, uv_geo = uv() ) => uv_geo.mul( uv_scale ).add( uv_offset );