@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
JavaScript
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;
}
}