@needle-tools/materialx
Version:
Web runtime support to load and display MaterialX materials in Needle Engine and three.js via the MaterialX WebAssembly library. glTF files containing the `NEEDLE_materials_mtlx` extension can be loaded with this package. There is also experimental suppor
571 lines (514 loc) • 21.8 kB
JavaScript
//
// Copyright Contributors to the MaterialX Project
// SPDX-License-Identifier: Apache-2.0
//
import * as THREE from 'three';
import { debug, debugUpdate } from './utils.js';
const IMAGE_PROPERTY_SEPARATOR = "_";
const UADDRESS_MODE_SUFFIX = IMAGE_PROPERTY_SEPARATOR + "uaddressmode";
const VADDRESS_MODE_SUFFIX = IMAGE_PROPERTY_SEPARATOR + "vaddressmode";
const FILTER_TYPE_SUFFIX = IMAGE_PROPERTY_SEPARATOR + "filtertype";
const IMAGE_PATH_SEPARATOR = "/";
/**
* Initializes the environment texture as MaterialX expects it
* @param {THREE.Texture} texture
* @param {Object} capabilities
* @returns {THREE.Texture}
*/
export function prepareEnvTexture(texture, capabilities) {
const newTexture = new THREE.DataTexture(texture.image.data, texture.image.width, texture.image.height, /** @type {any} */(texture.format), texture.type);
newTexture.wrapS = THREE.RepeatWrapping;
newTexture.anisotropy = capabilities.getMaxAnisotropy();
newTexture.minFilter = THREE.LinearMipmapLinearFilter;
newTexture.magFilter = THREE.LinearFilter;
newTexture.generateMipmaps = true;
newTexture.needsUpdate = true;
return newTexture;
}
/**
* Get Three uniform from MaterialX vector
* @param {any} value
* @param {any} dimension
* @returns {Array<number>}
*/
function fromVector(value, dimension) {
let outValue;
if (value) {
outValue = [...value.data()];
}
else {
outValue = [];
for (let i = 0; i < dimension; ++i)
outValue.push(0.0);
}
return outValue;
}
/**
* Get Three uniform from MaterialX matrix
* @param {any} matrix
* @param {number} dimension
* @returns {Array<number>}
*/
function fromMatrix(matrix, dimension) {
const vec = new Array(dimension);
if (matrix) {
for (let i = 0; i < matrix.numRows(); ++i) {
for (let k = 0; k < matrix.numColumns(); ++k) {
vec.push(matrix.getItem(i, k));
}
}
} else {
for (let i = 0; i < dimension; ++i)
vec.push(0.0);
}
return vec;
}
/**
* @typedef {Object} Callbacks
* @property {string} [cacheKey] - Cache key for the loaders, used to identify and reuse textures
* @property {(path: string) => Promise<THREE.Texture | null | void>} getTexture - Get a texture by path
*/
const defaultTexture = new THREE.Texture();
defaultTexture.needsUpdate = true;
defaultTexture.image = new Image();
defaultTexture.image.src = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAAAXNSR0IB2cksfwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAANQTFRFr6+vGqg52AAAAAxJREFUeJxjZGBEgQAAWAAJLpjsTQAAAABJRU5ErkJggg=="
// defaultTexture.image.src = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQBAMAAADt3eJSAAAAAXNSR0IB2cksfwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAB5QTFRFAAAABAQEw8PD////v7+/vb29Xl5eQEBA+/v7PDw8GPBYkgAAAB1JREFUeJxjZGBgYFQSABIUMlxgDGMGBtaIAnIZAKwQCSDYUEZEAAAAAElFTkSuQmCC";
// defaultTexture.wrapS = THREE.RepeatWrapping;
// defaultTexture.wrapT = THREE.RepeatWrapping;
// defaultTexture.minFilter = THREE.NearestFilter;
// defaultTexture.magFilter = THREE.NearestFilter;
// defaultTexture.repeat = new THREE.Vector2(100, 100);
const defaultNormalTexture = new THREE.Texture();
defaultNormalTexture.needsUpdate = true;
defaultNormalTexture.image = new Image();
defaultNormalTexture.image.src = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIBAMAAAA2IaO4AAAAAXNSR0IB2cksfwAAAAlwSFlzAAALEwAACxMBAJqcGAAAABJQTFRFgYH4gIH4gYH3gIH3gIH5gID4m94ORAAAADFJREFUeJxjZBBkfMdo9P/BB0aBj/8FGB0ufghgFGT4r8wo+P8rD2Pgo3sMjIz8jAwAMLoN0ZjS5hgAAAAASUVORK5CYII=";
/**
* @param {string} key
* @returns {any}
*/
function tryGetFromCache(key) {
const wasEnabled = THREE.Cache.enabled;
THREE.Cache.enabled = true;
const value = THREE.Cache.get(key);
THREE.Cache.enabled = wasEnabled;
return value;
}
/**
* @param {string} key
* @param {any} value
*/
function addToCache(key, value) {
const wasEnabled = THREE.Cache.enabled;
THREE.Cache.enabled = true;
THREE.Cache.add(key, value);
THREE.Cache.enabled = wasEnabled;
if (debug) console.log('[MaterialX] Added to cache:', key, value);
}
/**
* Get Three uniform from MaterialX value
* @param {any} uniforms
* @param {string} type
* @param {any} value
* @param {string} name
* @param {Callbacks} loaders
* @param {string} searchPath
* @returns {THREE.Uniform}
*/
function toThreeUniform(uniforms, type, value, name, loaders, searchPath) {
const uniform = new THREE.Uniform(/** @type {any} */(null));
switch (type) {
case 'float':
case 'integer':
case 'boolean':
uniform.value = value;
break;
case 'vector2':
uniform.value = /** @type {any} */ (fromVector(value, 2));
break;
case 'vector3':
case 'color3':
uniform.value = /** @type {any} */ (fromVector(value, 3));
break;
case 'vector4':
case 'color4':
uniform.value = /** @type {any} */ (fromVector(value, 4));
break;
case 'matrix33':
uniform.value = /** @type {any} */ (fromMatrix(value, 9));
break;
case 'matrix44':
uniform.value = /** @type {any} */ (fromMatrix(value, 16));
break;
case 'filename':
if (value) {
// Cache / reuse texture to avoid reload overhead.
// Note: that data blobs and embedded data textures are not cached as they are transient data.
let checkCache = true;
let texturePath = searchPath + IMAGE_PATH_SEPARATOR + value;
if (value.startsWith('blob:')) {
texturePath = value;
checkCache = false;
}
else if (value.startsWith('data:')) {
texturePath = value;
checkCache = false;
}
else if (value.startsWith('http')) {
texturePath = value;
checkCache = true;
}
const cacheKey = loaders.cacheKey?.length ? `${loaders.cacheKey}-${texturePath}` : texturePath;
const cacheValue = checkCache ? tryGetFromCache(cacheKey) : null;
if (cacheValue) {
if (debug) console.log('[MaterialX] Use cached texture: ', cacheKey, cacheValue);
if (cacheValue instanceof Promise) {
cacheValue.then(res => {
if (res) uniform.value = res;
else console.warn(`[MaterialX] Failed to load texture ${name} '${texturePath}'`);
});
}
else {
uniform.value = cacheValue;
}
}
else {
if (debug) console.log('[MaterialX] Load texture:', texturePath);
if (name.toLowerCase().includes("normal")) uniform.value = /** @type {any} */ (defaultNormalTexture);
else uniform.value = /** @type {any} */ (defaultTexture);
const defaultValue = uniform.value;
// Save the loading promise in the cache
const promise = loaders.getTexture(texturePath)
?.then(res => {
if (res) {
res = res.clone(); // we need to clone the texture once to avoid colorSpace issues with other materials
res.colorSpace = THREE.LinearSRGBColorSpace;
setTextureParameters(res, name, uniforms);
}
return res;
})
.catch(err => {
console.error(`[MaterialX] Failed to load texture ${name} '${texturePath}'`, err);
return defaultValue;
});
if (checkCache) addToCache(cacheKey, promise);
promise?.then(res => {
if (res) uniform.value = /** @type {any} */ (res);
else console.warn(`[MaterialX] Failed to load texture ${name} '${texturePath}'`);
});
}
}
break;
case 'samplerCube':
case 'string':
break;
default:
const key = type + ':' + name;
if (!valueTypeWarningMap.has(key)) {
valueTypeWarningMap.set(key, true);
console.warn(`MaterialX: Unsupported uniform type: ${type} for uniform: ${name}`);
}
break;
}
return uniform;
}
/** @type {Map<string, boolean>} */
const valueTypeWarningMap = new Map();
/**
* Get Three wrapping mode
* @param {number} mode
* @returns {THREE.Wrapping}
*/
function getWrapping(mode) {
let wrap;
switch (mode) {
case 1:
wrap = THREE.ClampToEdgeWrapping;
break;
case 2:
wrap = THREE.RepeatWrapping;
break;
case 3:
wrap = THREE.MirroredRepeatWrapping;
break;
default:
wrap = THREE.RepeatWrapping;
break;
}
return wrap;
}
/**
* Set Three texture parameters
* @param {THREE.Texture} texture
* @param {string} name
* @param {any} uniforms
* @param {boolean} [generateMipmaps=true]
*/
function setTextureParameters(texture, name, uniforms, generateMipmaps = true) {
const idx = name.lastIndexOf(IMAGE_PROPERTY_SEPARATOR);
const base = name.substring(0, idx) || name;
if (uniforms.find(base + UADDRESS_MODE_SUFFIX)) {
const uaddressmode = uniforms.find(base + UADDRESS_MODE_SUFFIX).getValue().getData();
texture.wrapS = getWrapping(uaddressmode);
}
if (uniforms.find(base + VADDRESS_MODE_SUFFIX)) {
const vaddressmode = uniforms.find(base + VADDRESS_MODE_SUFFIX).getValue().getData();
texture.wrapT = getWrapping(vaddressmode);
}
const mxFilterType = uniforms.find(base + FILTER_TYPE_SUFFIX) ? uniforms.get(base + FILTER_TYPE_SUFFIX).value : -1;
let minFilter = generateMipmaps ? THREE.LinearMipMapLinearFilter : THREE.LinearFilter;
if (mxFilterType === 0) {
minFilter = /** @type {any} */ (generateMipmaps ? THREE.NearestMipMapNearestFilter : THREE.NearestFilter);
}
texture.minFilter = minFilter;
}
/**
* Return the global light rotation matrix
* @returns {THREE.Matrix4}
*/
export function getLightRotation() {
return new THREE.Matrix4().makeRotationY(Math.PI / 2);
}
/**
* Returns all lights nodes in a MaterialX document
* @param {any} doc
* @returns {Array<any>}
*/
export function findLights(doc) {
let lights = new Array();
for (let node of doc.getNodes()) {
if (node.getType() === "lightshader")
lights.push(node);
}
return lights;
}
/** @type {Object<string, number>} */
let lightTypesBound = {};
/**
* Register lights in shader generation context
* @param {any} mx - MaterialX Module
* @param {any} genContext - Shader generation context
* @returns {Promise<void>}
*/
export async function registerLights(mx, genContext) {
lightTypesBound = {};
const maxLightCount = genContext.getOptions().hwMaxActiveLightSources;
mx.HwShaderGenerator.unbindLightShaders(genContext);
let lightId = 1;
// All light types so that we have NodeDefs for them
const defaultLightRigXml = `xml version="1.0"
<materialx version="1.39">
<directional_light name="default_directional_light" type="lightshader">
</directional_light>
<point_light name="default_point_light" type="lightshader">
</point_light>
<spot_light name="default_spot_light" type="lightshader">
</spot_light>
<!--
<area_light name="default_area_light" type="lightshader">
</area_light>
-->
</materialx>`;
// Load default light rig XML to ensure we have all light types available
const lightRigDoc = mx.createDocument();
await mx.readFromXmlString(lightRigDoc, defaultLightRigXml, "");
const document = mx.createDocument();
const stdlib = mx.loadStandardLibraries(genContext);
document.setDataLibrary(stdlib);
document.importLibrary(lightRigDoc);
const defaultLights = findLights(document);
if (debug) console.log("Default lights in MaterialX document", defaultLights);
// Loading a document seems to reset this option for some reason, so we set it again
genContext.getOptions().hwMaxActiveLightSources = maxLightCount;
// Register types only – we get these from the default light rig XML above
// This is needed to ensure that the light shaders are bound for each light type
for (let light of defaultLights) {
const lightDef = light.getNodeDef();
if (debug) console.log("Default light node definition", lightDef);
if (!lightDef) continue;
const lightName = lightDef.getName();
if (debug) console.log("Registering default light", { lightName, lightDef });
if (!lightTypesBound[lightName]) {
// TODO check if we need to bind light shader for each three.js light instead of once per type
if (debug) console.log("Bind light shader for node", { lightName, lightId, lightDef });
lightTypesBound[lightName] = lightId;
mx.HwShaderGenerator.bindLightShader(lightDef, lightId++, genContext);
}
}
if (debug) console.log("Light types bound in MaterialX context", lightTypesBound);
}
const _lightTypeWarnings = {}
/**
* Converts Three.js light type to MaterialX node name
* @param {string} threeLightType
* @returns {string|null}
*/
function threeLightTypeToMaterialXNodeName(threeLightType) {
switch (threeLightType) {
case 'PointLight':
return 'ND_point_light';
case 'DirectionalLight':
return 'ND_directional_light';
case 'SpotLight':
return 'ND_spot_light';
default:
if (!_lightTypeWarnings[threeLightType]) {
_lightTypeWarnings[threeLightType] = true;
console.warn('MaterialX: Unsupported light type: ' + threeLightType);
}
return null; // Unsupported light type
}
}
/**
* @typedef {Object} LightData
* @property {number} type - Light type ID
* @property {THREE.Vector3} position - Position in world space
* @property {THREE.Vector3} direction - Direction in world space
* @property {THREE.Color} color - Color of the light
* @property {number} intensity - Intensity of the light
* @property {number} decay_rate - Decay rate for point and spot lights
* @property {number} inner_angle - Inner angle for spot lights
* @property {number} outer_angle - Outer angle for spot lights
*/
/**
* Update light data for shader uniforms
* @param {Array<THREE.Light>} lights
* @param {any} genContext
* @returns {{ lightData: LightData[], lightCount: number }}
*/
export function getLightData(lights, genContext) {
const lightData = new Array();
const maxLightCount = genContext.getOptions().hwMaxActiveLightSources;
// Three.js lights
for (let light of lights) {
// Skip if light is not a Three.js light
if (!light?.isLight) continue;
// Types in MaterialX: point_light, directional_light, spot_light
const lightDefinitionName = threeLightTypeToMaterialXNodeName(light.type);
if(!lightDefinitionName){
continue; // Unsupported light type
}
if (!lightTypesBound[lightDefinitionName]) {
if(debug) console.error("MaterialX: Light type not registered in context. Make sure to register light types before using them.", lightDefinitionName);
}
const wp = light.getWorldPosition(new THREE.Vector3());
const wq = light.getWorldQuaternion(new THREE.Quaternion());
const wd = new THREE.Vector3(0, 0, -1).applyQuaternion(wq);
// Shader math from the generated MaterialX shader:
// float low = min(light.inner_angle, light.outer_angle);
// float high = light.inner_angle;
// float cosDir = dot(result.direction, -light.direction);
// float spotAttenuation = smoothstep(low, high, cosDir);
const outerAngleRad = /** @type {THREE.SpotLight} */ (light).angle;
const innerAngleRad = outerAngleRad * (1 - /** @type {THREE.SpotLight} */ (light).penumbra);
const inner_angle = Math.cos(innerAngleRad);
const outer_angle = Math.cos(outerAngleRad);
lightData.push({
type: lightTypesBound[lightDefinitionName],
position: wp.clone(),
direction: wd.clone(),
color: new THREE.Color().fromArray(light.color.toArray()),
// Luminous efficacy for converting radiant power in watts (W) to luminous flux in lumens (lm) at a wavelength of 555 nm.
// Also, three.js lights don't have PI scale baked in, but MaterialX does, so we need to divide by PI for point and spot lights.
intensity: light.intensity * (/** @type {THREE.PointLight} */ (light).isPointLight ? 683.0 / 3.1415 : /** @type {THREE.SpotLight} */ (light).isSpotLight ? 683.0 / 3.1415 : 1.0),
decay_rate: 2.0,
// Approximations for testing – the relevant light has 61.57986...129.4445 as inner/outer spot angle
inner_angle: inner_angle,
outer_angle: outer_angle,
});
}
// Count the number of lights that are not empty
const lightCount = lightData.length;
// If we don't have enough entries in lightData, fill with empty lights
while (lightData.length < maxLightCount) {
const emptyLight = {
type: 0, // Default light type
position: new THREE.Vector3(0, 0, 0),
direction: new THREE.Vector3(0, 0, -1),
color: new THREE.Color(0, 0, 0),
intensity: 0.0,
decay_rate: 2.0,
inner_angle: 0.0,
outer_angle: 0.0,
};
lightData.push(emptyLight);
}
if (debugUpdate) console.log("Registered lights in MaterialX context", lightTypesBound, lightData);
return { lightData, lightCount };
}
/**
* Get uniform values for a shader
* @param {any} shaderStage
* @param {Callbacks} loaders
* @param {string} searchPath
* @returns {Object<string, THREE.Uniform>}
*/
export function getUniformValues(shaderStage, loaders, searchPath) {
/** @type {Object<string, THREE.Uniform>} */
const threeUniforms = {};
const uniformBlocks = shaderStage.getUniformBlocks()
for (const [blockName, uniforms] of Object.entries(uniformBlocks)) {
// Seems struct uniforms (like in LightData) end up here as well, we should filter those out.
if (blockName === "LightData") continue;
if (!uniforms.empty()) {
for (let i = 0; i < uniforms.size(); ++i) {
const variable = uniforms.get(i);
const value = variable.getValue()?.getData();
const uniformName = variable.getVariable();
const type = variable.getType().getName();
threeUniforms[uniformName] = toThreeUniform(uniforms, type, value, uniformName, loaders, searchPath);
if (debug) console.log("Adding uniform", { path: variable.getPath(), type: type, name: uniformName, value: threeUniforms[uniformName], },);
}
}
}
return threeUniforms;
}
/**
* @param {THREE.ShaderMaterial} material
* @param {any} shaderStage
*/
export function generateMaterialPropertiesForUniforms(material, shaderStage) {
const uniformBlocks = shaderStage.getUniformBlocks()
for (const [blockName, uniforms] of Object.entries(uniformBlocks)) {
// Seems struct uniforms (like in LightData) end up here as well, we should filter those out.
if (blockName === "LightData") continue;
if (!uniforms.empty()) {
for (let i = 0; i < uniforms.size(); ++i) {
const variable = uniforms.get(i);
const uniformName = variable.getVariable();
let key = variable.getPath().split('/').pop();
switch (key) {
case "_Color":
key = "color";
break;
case "_Roughness":
key = "roughness";
break;
case "_Metallic":
key = "metalness";
break;
}
if (key) {
if (material.hasOwnProperty(key)) {
if (debug) console.warn(`[MaterialX] Uniform ${uniformName} already exists in material as property ${key}, skipping.`);
}
else {
Object.defineProperty(material, key, {
get: function () {
return this.uniforms?.[uniformName].value
},
set: function (v) {
const uniforms = this.uniforms;
if (!uniforms || !uniforms[uniformName]) {
console.warn(`[MaterialX] Uniform ${uniformName} not found in ${this.name} uniforms`);
return;
}
this.uniforms[uniformName].value = v;
this.uniformsNeedUpdate = true;
}
});
}
}
}
}
}
}