UNPKG

@needle-tools/materialx

Version:

MaterialX material support for three.js and Needle Engine – render physically based MaterialX shaders in the browser via WebAssembly

568 lines (493 loc) 26.2 kB
import { BufferGeometry, Camera, FrontSide, GLSL3, Group, Matrix3, Matrix4, Object3D, Scene, ShaderMaterial, Texture, UniformsLib, Vector3, WebGLRenderer } from "three"; import { debug, getFrame, getTime } from "./utils.js"; import { MaterialXEnvironment } from "./materialx.js"; import { generateMaterialPropertiesForUniforms, getUniformValues, getLightTypeIds } from "./materialx.helper.js"; import { cloneUniforms, cloneUniformsGroups, mergeUniforms } from "three/src/renderers/shaders/UniformsUtils.js"; // Add helper matrices for uniform updates (similar to MaterialX example) const normalMat = new Matrix3(); /** * @typedef {Object} MaterialXMaterialInitParameters * @property {string} name * @property {string | null} [shaderName] - Optional name of the shader * @property {any} shader * @property {import('./materialx.helper.js').Callbacks} loaders * @property {import('./materialx.js').MaterialXContext} context * @property {import('three').MaterialParameters} [parameters] - Optional parameters * @property {boolean} [debug] - Debug flag */ /** * @typedef {"highp" | "mediump" | "lowp"} Precision */ // @dont-generate-component export class MaterialXMaterial extends ShaderMaterial { /** The original name of the shader * @type {string | null} */ shaderName = null; /** * @param {MaterialXMaterial} source * @returns {this} */ copy(source) { super.copy(source); this.shaderName = source.shaderName; this._context = source._context; this._shader = source._shader; this._needsTangents = source._needsTangents; this.uniforms = cloneUniforms(source.uniforms); this.uniformsGroups = cloneUniformsGroups(source.uniformsGroups); this.envMapIntensity = source.envMapIntensity; this.envMap = source.envMap; generateMaterialPropertiesForUniforms(this, this._shader.getStage('pixel')); generateMaterialPropertiesForUniforms(this, this._shader.getStage('vertex')); this.needsUpdate = true; return this; } /** @type {import('./materialx.js').MaterialXContext | null} */ _context = null; /** @type {any} */ _shader = null; /** @type {boolean} */ _needsTangents = false; /** * @param {MaterialXMaterialInitParameters} [init] */ constructor(init) { /** @type {import('three').ShaderMaterialParameters | undefined} */ let materialParameters = undefined; /** @type {string} */ let vertexShader = ""; /** @type {string} */ let fragmentShader = ""; /** @type {Record<string, string>} */ let defines = {}; if (init) { // Get vertex and fragment shader source, and remove #version directive for newer js. // It's added by three.js glslVersion. vertexShader = init.shader.getSourceCode("vertex"); fragmentShader = init.shader.getSourceCode("pixel"); vertexShader = vertexShader.replace(/^#version.*$/gm, '').trim(); fragmentShader = fragmentShader.replace(/^#version.*$/gm, '').trim(); // MaterialX uses different attribute names than js defaults, // so we patch the MaterialX shaders to match the js standard names. // Otherwise, we'd have to modify the mesh attributes (see original MaterialX for reference). // Patch vertexShader vertexShader = vertexShader.replace(/\bi_position\b/g, 'position'); vertexShader = vertexShader.replace(/\bi_normal\b/g, 'normal'); vertexShader = vertexShader.replace(/\bi_texcoord_0\b/g, 'uv'); vertexShader = vertexShader.replace(/\bi_texcoord_1\b/g, 'uv1'); vertexShader = vertexShader.replace(/\bi_texcoord_2\b/g, 'uv2'); vertexShader = vertexShader.replace(/\bi_texcoord_3\b/g, 'uv3'); vertexShader = vertexShader.replace(/\bi_tangent\b/g, 'tangent'); vertexShader = vertexShader.replace(/\bi_color_0\b/g, 'color'); // TODO: do we need to add depthbuffer fragments? https://discourse.threejs.org/t/shadermaterial-render-order-with-logarithmicdepthbuffer-is-wrong/49221/4 // Add logdepthbuf_pars_vertex at the beginning of the vertex shader before main() // vertexShader = vertexShader.replace(/void\s+main\s*\(\s*\)\s*{/, `#include <logdepthbuf_pars_vertex>\nvoid main() {`); // // Add logdepthbuf_vertex to vertex shader if not present at end of main() // vertexShader = vertexShader.replace(/void\s+main\s*\(\s*\)\s*{/, `void main() {\n #include <logdepthbuf_vertex>\n`); // Patch fragmentShader const precision = init.parameters?.precision || "highp"; vertexShader = vertexShader.replace(/precision mediump float;/g, `precision ${precision} float;`); vertexShader = vertexShader.replace(/#define M_FLOAT_EPS 1e-8/g, precision === "highp" ? `#define M_FLOAT_EPS 1e-8` : `#define M_FLOAT_EPS 1e-3`); fragmentShader = fragmentShader.replace(/precision mediump float;/g, `precision ${precision} float;`); fragmentShader = fragmentShader.replace(/#define M_FLOAT_EPS 1e-8/g, precision === "highp" ? `#define M_FLOAT_EPS 1e-8` : `#define M_FLOAT_EPS 1e-3`); fragmentShader = fragmentShader.replace(/\bi_position\b/g, 'position'); fragmentShader = fragmentShader.replace(/\bi_normal\b/g, 'normal'); fragmentShader = fragmentShader.replace(/\bi_texcoord_0\b/g, 'uv'); fragmentShader = fragmentShader.replace(/\bi_texcoord_1\b/g, 'uv1'); fragmentShader = fragmentShader.replace(/\bi_texcoord_2\b/g, 'uv2'); fragmentShader = fragmentShader.replace(/\bi_texcoord_3\b/g, 'uv3'); fragmentShader = fragmentShader.replace(/\bi_tangent\b/g, 'tangent'); fragmentShader = fragmentShader.replace(/\bi_color_0\b/g, 'color'); // Patch env intensity uniform to match Three.js naming convention. // MaterialX generates `u_envLightIntensity`; Three.js uses `envMapIntensity`. // This lets us combine material.envMapIntensity * scene.environmentIntensity // the same way MeshStandardMaterial does. fragmentShader = fragmentShader.replace(/\bu_envLightIntensity\b/g, 'envMapIntensity'); // Capture some vertex shader properties // Detect whether each UV was originally vec2 or vec3 before removing declarations. // Three.js always provides vec2 attributes, so vec3 assignments need wrapping. const uv_is_vec2 = vertexShader.includes('in vec2 uv;'); const uv1_is_vec2 = vertexShader.includes('in vec2 uv1;'); const uv2_is_vec2 = vertexShader.includes('in vec2 uv2;'); const uv3_is_vec2 = vertexShader.includes('in vec2 uv3;'); // Remove `in vec3 position;` and so on since they're already declared by ShaderMaterial vertexShader = vertexShader.replace(/in\s+vec3\s+position;/g, ''); vertexShader = vertexShader.replace(/in\s+vec3\s+normal;/g, ''); vertexShader = vertexShader.replace(/in\s+vec2\s+uv;/g, ''); vertexShader = vertexShader.replace(/in\s+vec3\s+uv;/g, ''); var hasUv1 = vertexShader.includes('in vec3 uv1;'); vertexShader = vertexShader.replace(/in\s+vec3\s+uv1;/g, ''); var hasUv2 = vertexShader.includes('in vec3 uv2;'); vertexShader = vertexShader.replace(/in\s+vec3\s+uv2;/g, ''); var hasUv3 = vertexShader.includes('in vec3 uv3;'); vertexShader = vertexShader.replace(/in\s+vec3\s+uv3;/g, ''); var hasTangent = vertexShader.includes('in vec4 tangent;'); vertexShader = vertexShader.replace(/in\s+vec4\s+tangent;/g, ''); var hasColor = vertexShader.includes('in vec4 color;'); vertexShader = vertexShader.replace(/in\s+vec4\s+color;/g, ''); // Three.js provides `color` as vec3 but MaterialX declares it as vec4. // Wrap assignments to vec4 targets: `color_0 = color;` → `color_0 = vec4(color, 1.0);` if (hasColor) { vertexShader = vertexShader.replace(/\bvec4 (\w+) = color;/g, 'vec4 $1 = vec4(color, 1.0);'); vertexShader = vertexShader.replace(/(\w+) = color;/g, (match, name) => { if (match.includes('vec4')) return match; const isVec4 = new RegExp(`\\bvec4\\s+${name}\\b`).test(vertexShader); return isVec4 ? `${name} = vec4(color, 1.0);` : match; }); } // Patch uv vec2→vec3. Three.js always provides uv/uv1/uv2/uv3 as vec2 // attributes. When MaterialX originally declared them as vec3, any // assignment from these attributes to a vec3 variable needs wrapping. // When the UV was originally vec2, all assignments are already compatible. // Three.js always provides uv/uv1/uv2/uv3 as vec2 attributes. // When the generated shader assigns them to vec3 variables, we need to wrap. // This applies regardless of the original declaration type, because Three.js // always delivers vec2. /** @param {string} shader @param {string} uvName */ function patchUvAssignments(shader, uvName) { // 1. Declaration assignments: `vec3 x = <uv>;` → `vec3 x = vec3(<uv>, 0.0);` shader = shader.replace(new RegExp(`\\bvec3 (\\w+) = ${uvName};`, 'g'), `vec3 $1 = vec3(${uvName}, 0.0);`); // 2. Non-declaration assignments: `x = <uv>;` → wrap only when target is vec3 shader = shader.replace(new RegExp(`(\\w+) = ${uvName};`, 'g'), (match, name) => { if (match.includes('vec3')) return match; // already handled const isVec3 = new RegExp(`\\bvec3\\s+${name}\\b`).test(shader); return isVec3 ? `${name} = vec3(${uvName}, 0.0);` : match; }); return shader; } vertexShader = patchUvAssignments(vertexShader, 'uv'); vertexShader = patchUvAssignments(vertexShader, 'uv1'); vertexShader = patchUvAssignments(vertexShader, 'uv2'); vertexShader = patchUvAssignments(vertexShader, 'uv3'); // Patch units – seems MaterialX uses different units and we end up with wrong light values? // result.direction = light.position - position; fragmentShader = fragmentShader.replace( /result\.direction\s*=\s*light\.position\s*-\s*position;/g, 'result.direction = (light.position - position) * 10.0 / 1.0;'); // Add tonemapping and colorspace handling // Replace `out vec4 out1;` with `out vec4 gl_FragColor;` fragmentShader = fragmentShader.replace( /out\s+vec4\s+out1;/, 'layout(location = 0) out vec4 pc_fragColor;\n#define gl_FragColor pc_fragColor'); // Replace `out1 = vec4(<CAPTURE>)` with `gl_FragColor = vec4(<CAPTURE>)` and tonemapping/colorspace handling fragmentShader = fragmentShader.replace(/^\s*out1\s*=\s*vec4\((.*)\);/gm, ` gl_FragColor = vec4($1); #include <tonemapping_fragment> #include <colorspace_fragment>`); defines = {}; if (hasUv1) defines['USE_UV1'] = ''; if (hasUv2) defines['USE_UV2'] = ''; if (hasUv3) defines['USE_UV3'] = ''; if (hasTangent) defines['USE_TANGENT'] = ''; if (hasColor) defines['USE_COLOR'] = ''; // Detect whether the vertex shader declares the inverse-transpose matrix uniform. // Unlit shaders omit this uniform, so shadow code that references it would fail. const hasShadowUniforms = vertexShader.includes('u_worldInverseTransposeMatrix'); // Add Three.js shadow support (only when the vertex shader has the required uniforms) if (hasShadowUniforms) { // Insert shadow pars before main() in vertex shader vertexShader = vertexShader.replace( /void\s+main\s*\(\s*\)\s*\{/, `#include <common> #include <shadowmap_pars_vertex> void main() {` ); // Insert shadow vertex calculation at the end of vertex main (before the closing brace) // We need to compute worldPosition and transformedNormal for shadow coords // Note: Three.js shadowmap_vertex expects transformedNormal in VIEW space: // it does `inverseTransformDirection(transformedNormal, viewMatrix)` to get world-space normal vertexShader = vertexShader.replace( /(\n\s*)\}(\s*)$/, `$1 // Three.js shadow support $1 vec4 worldPosition = u_worldMatrix * vec4(position, 1.0); $1 vec3 transformedNormal = normalize(mat3(viewMatrix) * mat3(u_worldInverseTransposeMatrix) * normal); $1 #include <shadowmap_vertex> $1}$2` ); // Insert shadow includes at the very beginning of the fragment shader (after precision) // This ensures DirectionalLightShadow struct is defined before getMxShadow uses it fragmentShader = fragmentShader.replace( /(precision\s+\w+\s+float;)/, `$1 #include <common> #include <packing> #include <shadowmap_pars_fragment>` ); // Get MaterialX light type IDs for shadow dispatch const lightTypeIds = getLightTypeIds(); // Generate GLSL helper functions that sample shadow maps using constant indices. // Sampler arrays require constant integral expression indices in GLSL ES 3.0, // so we use if/else chains with literal constants (guarded by preprocessor). const MAX_SHADOW_LIGHTS = 4; // max shadow-casting lights per type let dirShadowCases = ''; for (let i = 0; i < MAX_SHADOW_LIGHTS; i++) { dirShadowCases += ` #if NUM_DIR_LIGHT_SHADOWS > ${i} ${i > 0 ? 'else ' : ''}if (idx == ${i}) { DirectionalLightShadow s = directionalLightShadows[${i}]; return getShadow(directionalShadowMap[${i}], s.shadowMapSize, s.shadowIntensity, s.shadowBias, s.shadowRadius, vDirectionalShadowCoord[${i}]); } #endif`; } let spotShadowCases = ''; for (let i = 0; i < MAX_SHADOW_LIGHTS; i++) { spotShadowCases += ` #if NUM_SPOT_LIGHT_SHADOWS > ${i} ${i > 0 ? 'else ' : ''}if (idx == ${i}) { SpotLightShadow s = spotLightShadows[${i}]; return getShadow(spotShadowMap[${i}], s.shadowMapSize, s.shadowIntensity, s.shadowBias, s.shadowRadius, vSpotLightCoord[${i}]); } #endif`; } let pointShadowCases = ''; for (let i = 0; i < MAX_SHADOW_LIGHTS; i++) { pointShadowCases += ` #if NUM_POINT_LIGHT_SHADOWS > ${i} ${i > 0 ? 'else ' : ''}if (idx == ${i}) { PointLightShadow s = pointLightShadows[${i}]; return getPointShadow(pointShadowMap[${i}], s.shadowMapSize, s.shadowIntensity, s.shadowBias, s.shadowRadius, vPointShadowCoord[${i}], s.shadowCameraNear, s.shadowCameraFar); } #endif`; } // Insert getMxShadow helper function BEFORE sampleLightSource (so it's defined when used) // Supports directional, spot, and point light shadows. // Uses global per-type counters to track which shadow map index to use. fragmentShader = fragmentShader.replace( /void sampleLightSource\(LightData light, vec3 position, out lightshader result\)/, `// MaterialX light type IDs (from registerLights) #define MX_LIGHT_TYPE_DIRECTIONAL ${lightTypeIds.directional} #define MX_LIGHT_TYPE_POINT ${lightTypeIds.point} #define MX_LIGHT_TYPE_SPOT ${lightTypeIds.spot} // Per-type shadow index counters (global so they persist across sampleLightSource calls) int mxDirShadowIdx = 0; int mxSpotShadowIdx = 0; int mxPointShadowIdx = 0; // Shadow sampling helpers using constant indices (required for sampler arrays in GLSL ES 3.0) float sampleMxDirShadow(int idx) { #ifdef USE_SHADOWMAP #if NUM_DIR_LIGHT_SHADOWS > 0 ${dirShadowCases} #endif #endif return 1.0; } float sampleMxSpotShadow(int idx) { #ifdef USE_SHADOWMAP #if NUM_SPOT_LIGHT_SHADOWS > 0 ${spotShadowCases} #endif #endif return 1.0; } float sampleMxPointShadow(int idx) { #ifdef USE_SHADOWMAP #if NUM_POINT_LIGHT_SHADOWS > 0 ${pointShadowCases} #endif #endif return 1.0; } void sampleLightSource(LightData light, vec3 position, out lightshader result)` ); // Find the sampleLightSource function and add shadow + counter increment at the end. // The per-type counters track which Three.js shadow map index to use for each light type. // Lights must be sorted (shadow-casting first per type) to match Three.js shadow map ordering. fragmentShader = fragmentShader.replace( /(void sampleLightSource\(LightData light, vec3 position, out lightshader result\)\s*\{[\s\S]*?)(^\})/m, `$1 // Apply Three.js shadow and increment per-type shadow counters if (light.type == MX_LIGHT_TYPE_DIRECTIONAL) { result.intensity *= sampleMxDirShadow(mxDirShadowIdx); mxDirShadowIdx++; } else if (light.type == MX_LIGHT_TYPE_SPOT) { result.intensity *= sampleMxSpotShadow(mxSpotShadowIdx); mxSpotShadowIdx++; } else if (light.type == MX_LIGHT_TYPE_POINT) { result.intensity *= sampleMxPointShadow(mxPointShadowIdx); mxPointShadowIdx++; } $2` ); } // end hasShadowUniforms const isTransparent = init.parameters?.transparent ?? false; materialParameters = { name: init.name, uniforms: {}, vertexShader: vertexShader, fragmentShader: fragmentShader, glslVersion: GLSL3, depthTest: true, depthWrite: !isTransparent, defines: defines, lights: true, // Enable Three.js light uniforms ...init.parameters, // Spread any additional parameters passed to the material }; } super(materialParameters); // Constructor can be called without init during clone() paths. if (!init) { return; } const searchPath = ""; // Could be derived from the asset path if needed this.shaderName = init.shaderName || null; this._context = init.context; this._shader = init.shader; this._needsTangents = vertexShader.includes('in vec4 tangent;') || vertexShader.includes('in vec3 tangent;'); Object.assign(this.uniforms, { // Three.js light uniforms (required when lights: true) ...UniformsLib.lights, ...getUniformValues(init.shader.getStage('vertex'), init.loaders, searchPath), ...getUniformValues(init.shader.getStage('pixel'), init.loaders, searchPath), u_worldMatrix: { value: new Matrix4() }, u_viewProjectionMatrix: { value: new Matrix4() }, u_viewPosition: { value: new Vector3() }, u_worldInverseTransposeMatrix: { value: new Matrix4() }, u_envMatrix: { value: new Matrix4() }, u_envRadiance: { value: null, type: 't' }, u_envRadianceMips: { value: 8, type: 'i' }, // TODO we need to figure out how we can set a PMREM here... doing many texture samples is prohibitively expensive u_envRadianceSamples: { value: 8, type: 'i' }, u_envIrradiance: { value: null, type: 't' }, envMapIntensity: { value: 1.0 }, u_refractionEnv: { value: true }, u_numActiveLightSources: { value: 0 }, u_lightData: { value: [], needsUpdate: false }, // Array of light data. We need to set needsUpdate to false until we actually update it }); generateMaterialPropertiesForUniforms(this, init.shader.getStage('pixel')); generateMaterialPropertiesForUniforms(this, init.shader.getStage('vertex')); if (debug || init.debug) { // Get lighting and environment data from MaterialX environment console.group("[MaterialX]: ", this.name); console.log(`Vertex shader length: ${vertexShader.length}\n`, vertexShader); console.log(`Fragment shader length: ${fragmentShader.length}\n`, fragmentShader); console.groupEnd(); } } /** @type {boolean} */ _missingTangentsWarned = false; /** * @param {WebGLRenderer} renderer * @param {Scene} _scene * @param {Camera} camera * @param {BufferGeometry} geometry * @param {Object3D} object * @param {Group} _group */ onBeforeRender(renderer, _scene, camera, geometry, object, _group) { if (this._needsTangents && !geometry.attributes.tangent) { if (!this._missingTangentsWarned) { this._missingTangentsWarned = true; console.warn(`[MaterialX] Tangents are required for this material (${this.name}) but not present in the geometry.`); // TODO: can we compute tangents here? } } const time = this._context?.getTime?.() || getTime(); const frame = this._context?.getFrame?.() || getFrame(); const env = MaterialXEnvironment.get(_scene); if (env) { env.update(frame, _scene, renderer); this.updateEnvironmentUniforms(env, _scene); } this.updateUniforms(renderer, object, camera, time, frame); } /** @type {number} */ envMapIntensity = 1.0; // Default intensity for environment map /** @type {Texture | null} */ envMap = null; // Environment map texture, can be set externally /** * @param {WebGLRenderer} _renderer * @param {Object3D} object * @param {Camera} camera * @param {number} [time] * @param {number} [frame] */ updateUniforms = (_renderer, object, camera, time, frame) => { // window.showBalloonMessage(`Updating MaterialX uniforms for ${object.name} at frame ${frame}`); const uniforms = this.uniforms; // Update standard transformation matrices if (uniforms.u_worldMatrix) { uniforms.u_worldMatrix.value.copy(object.matrixWorld); // @ts-ignore uniforms.u_worldMatrix.needsUpdate = true; } // Update view position if (uniforms.u_viewPosition) { uniforms.u_viewPosition.value.setFromMatrixPosition(camera.matrixWorld); // @ts-ignore uniforms.u_viewPosition.needsUpdate = true; } if (uniforms.u_viewProjectionMatrix) { uniforms.u_viewProjectionMatrix.value.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse); // @ts-ignore uniforms.u_viewProjectionMatrix.needsUpdate = true; } if (uniforms.u_worldInverseTransposeMatrix) { uniforms.u_worldInverseTransposeMatrix.value.setFromMatrix3(normalMat.getNormalMatrix(object.matrixWorld)); // @ts-ignore uniforms.u_worldInverseTransposeMatrix.needsUpdate = true; } // if (uniforms.u_shadowMap) { // const light = environment.lights?.[2] || null; // uniforms.u_shadowMatrix.value = light?.shadow?.matrix.clone().premultiply(object.matrixWorld.clone()).invert(); // uniforms.u_shadowMap.value = light.shadow?.map || null; // uniforms.u_shadowMap.needsUpdate = true; // console.log("[MaterialX] Renderer shadow map updated", light); // } // Update time uniforms if (uniforms.u_time) { if (time === undefined) time = getTime(); uniforms.u_time.value = time; } if (uniforms.u_frame) { if (frame === undefined) frame = getFrame(); uniforms.u_frame.value = frame; } this.uniformsNeedUpdate = true; } /** * @private * @param {MaterialXEnvironment} environment * @param {Scene} scene */ updateEnvironmentUniforms = (environment, scene) => { const uniforms = this.uniforms; // Get lighting data from environment const lightData = environment.lightData || null; const lightCount = environment.lightCount || 0; const textures = environment.getTextures(this) || null; // Update light count if (uniforms.u_numActiveLightSources && lightCount >= 0) { uniforms.u_numActiveLightSources.value = lightCount; } // Update light data if (lightData?.length) { uniforms.u_lightData.value = lightData; if ("needsUpdate" in uniforms.u_lightData && uniforms.u_lightData.needsUpdate === false) { if (debug) console.debug(`[MaterialX] LightData assigned (${this.name}, ${this.uuid})`, lightData); uniforms.u_lightData.needsUpdate = undefined; } } // Update environment uniforms if (uniforms.u_envRadiance) { const prev = uniforms.u_envRadiance.value; uniforms.u_envRadiance.value = textures.radianceTexture; // @ts-ignore if (prev != textures.radianceTexture) uniforms.u_envRadiance.needsUpdate = true; } if (uniforms.u_envRadianceMips) { uniforms.u_envRadianceMips.value = Math.trunc(Math.log2(Math.max(textures.radianceTexture?.source.data.width ?? 0, textures.radianceTexture?.source.data.height ?? 0))) + 1; } if (uniforms.u_envIrradiance) { const prev = uniforms.u_envIrradiance.value; uniforms.u_envIrradiance.value = textures.irradianceTexture; // @ts-ignore if (prev != textures.irradianceTexture) uniforms.u_envIrradiance.needsUpdate = true; } // Sync environment intensity: combine per-material envMapIntensity with scene.environmentIntensity // (mirrors MeshStandardMaterial behaviour in Three.js) if (uniforms.envMapIntensity) { uniforms.envMapIntensity.value = (this.envMapIntensity ?? 1.0) * (scene.environmentIntensity ?? 1.0); } // Note: Shadow uniforms are handled by Three.js when lights: true is set this.uniformsNeedUpdate = true; } }