@needle-tools/materialx 1.6.0 → 1.7.0-next.0d06218

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,223 @@
1
- import { BufferGeometry, Camera, FrontSide, GLSL3, Group, Matrix3, Matrix4, Object3D, Scene, ShaderMaterial, Texture, UniformsLib, Vector3, WebGLRenderer } from "three";
1
+ import { BufferGeometry, Camera, Euler, FrontSide, GLSL3, Group, Matrix4, Object3D, Scene, ShaderMaterial, Texture, UniformsLib, Vector3, WebGLRenderer } from "three";
2
2
  import { debug, getFrame, getTime } from "./utils.js";
3
3
  import { MaterialXEnvironment } from "./materialx.js";
4
4
  import { generateMaterialPropertiesForUniforms, getUniformValues, getLightTypeIds } from "./materialx.helper.js";
5
5
  import { cloneUniforms, cloneUniformsGroups, mergeUniforms } from "three/src/renderers/shaders/UniformsUtils.js";
6
6
 
7
+ export const DEFAULT_ENVIRONMENT_RADIANCE_MODE = "three-pmrem";
8
+
7
9
  // Add helper matrices for uniform updates (similar to MaterialX example)
8
- const normalMat = new Matrix3();
10
+ const worldInverseMat = new Matrix4();
11
+ const worldTransposeMat = new Matrix4();
12
+ const worldInverseTransposeMat = new Matrix4();
13
+ const worldViewMat = new Matrix4();
14
+ const worldViewProjectionMat = new Matrix4();
15
+ const envMat = new Matrix4();
16
+ const envRotation = new Euler();
17
+
18
+ const CUBE_UV_REFLECTION_FUNCTIONS = `
19
+ float mx_cubeuv_getFace(vec3 direction) {
20
+ vec3 absDirection = abs(direction);
21
+ float face = -1.0;
22
+ if (absDirection.x > absDirection.z) {
23
+ if (absDirection.x > absDirection.y)
24
+ face = direction.x > 0.0 ? 0.0 : 3.0;
25
+ else
26
+ face = direction.y > 0.0 ? 1.0 : 4.0;
27
+ } else {
28
+ if (absDirection.z > absDirection.y)
29
+ face = direction.z > 0.0 ? 2.0 : 5.0;
30
+ else
31
+ face = direction.y > 0.0 ? 1.0 : 4.0;
32
+ }
33
+ return face;
34
+ }
35
+
36
+ vec2 mx_cubeuv_getUV(vec3 direction, float face) {
37
+ vec2 uv;
38
+ if (face == 0.0) {
39
+ uv = vec2(direction.z, direction.y) / abs(direction.x);
40
+ } else if (face == 1.0) {
41
+ uv = vec2(-direction.x, -direction.z) / abs(direction.y);
42
+ } else if (face == 2.0) {
43
+ uv = vec2(-direction.x, direction.y) / abs(direction.z);
44
+ } else if (face == 3.0) {
45
+ uv = vec2(-direction.z, direction.y) / abs(direction.x);
46
+ } else if (face == 4.0) {
47
+ uv = vec2(-direction.x, direction.z) / abs(direction.y);
48
+ } else {
49
+ uv = vec2(direction.x, direction.y) / abs(direction.z);
50
+ }
51
+ return 0.5 * (uv + 1.0);
52
+ }
53
+
54
+ vec3 mx_cubeuv_bilinear(sampler2D envMap, vec3 direction, float mipInt) {
55
+ const float cubeUV_minMipLevel = 4.0;
56
+ const float cubeUV_minTileSize = 16.0;
57
+ float face = mx_cubeuv_getFace(direction);
58
+ float filterInt = max(cubeUV_minMipLevel - mipInt, 0.0);
59
+ mipInt = max(mipInt, cubeUV_minMipLevel);
60
+ float faceSize = exp2(mipInt);
61
+ highp vec2 uv = mx_cubeuv_getUV(direction, face) * (faceSize - 2.0) + 1.0;
62
+ if (face > 2.0) {
63
+ uv.y += faceSize;
64
+ face -= 3.0;
65
+ }
66
+ uv.x += face * faceSize;
67
+ uv.x += filterInt * 3.0 * cubeUV_minTileSize;
68
+ uv.y += 4.0 * (exp2(u_envRadianceCubeUVMaxMip) - faceSize);
69
+ uv.x *= u_envRadianceCubeUVTexelWidth;
70
+ uv.y *= u_envRadianceCubeUVTexelHeight;
71
+ return texture(envMap, uv).rgb;
72
+ }
73
+
74
+ float mx_cubeuv_roughnessToMip(float roughness) {
75
+ const float cubeUV_r0 = 1.0;
76
+ const float cubeUV_m0 = -2.0;
77
+ const float cubeUV_r1 = 0.8;
78
+ const float cubeUV_m1 = -1.0;
79
+ const float cubeUV_r4 = 0.4;
80
+ const float cubeUV_m4 = 2.0;
81
+ const float cubeUV_r5 = 0.305;
82
+ const float cubeUV_m5 = 3.0;
83
+ const float cubeUV_r6 = 0.21;
84
+ const float cubeUV_m6 = 4.0;
85
+ float mip = 0.0;
86
+ if (roughness >= cubeUV_r1) {
87
+ mip = (cubeUV_r0 - roughness) * (cubeUV_m1 - cubeUV_m0) / (cubeUV_r0 - cubeUV_r1) + cubeUV_m0;
88
+ } else if (roughness >= cubeUV_r4) {
89
+ mip = (cubeUV_r1 - roughness) * (cubeUV_m4 - cubeUV_m1) / (cubeUV_r1 - cubeUV_r4) + cubeUV_m1;
90
+ } else if (roughness >= cubeUV_r5) {
91
+ mip = (cubeUV_r4 - roughness) * (cubeUV_m5 - cubeUV_m4) / (cubeUV_r4 - cubeUV_r5) + cubeUV_m4;
92
+ } else if (roughness >= cubeUV_r6) {
93
+ mip = (cubeUV_r5 - roughness) * (cubeUV_m6 - cubeUV_m5) / (cubeUV_r5 - cubeUV_r6) + cubeUV_m5;
94
+ } else {
95
+ mip = -2.0 * log2(1.16 * roughness);
96
+ }
97
+ return mip;
98
+ }
99
+
100
+ vec4 mx_cubeuv_texture(sampler2D envMap, vec3 sampleDir, float roughness) {
101
+ float mip = clamp(mx_cubeuv_roughnessToMip(roughness), -2.0, u_envRadianceCubeUVMaxMip);
102
+ float mipF = fract(mip);
103
+ float mipInt = floor(mip);
104
+ vec3 color0 = mx_cubeuv_bilinear(envMap, sampleDir, mipInt);
105
+ if (mipF == 0.0) {
106
+ return vec4(color0, 1.0);
107
+ }
108
+ vec3 color1 = mx_cubeuv_bilinear(envMap, sampleDir, mipInt + 1.0);
109
+ return vec4(mix(color0, color1, mipF), 1.0);
110
+ }
111
+ `;
112
+
113
+ const STANDARD_SURFACE_CLOSURE_GATES = [
114
+ { prefix: 'mx_dielectric_bsdf(closureData, coat,', predicate: 'coat >= M_FLOAT_EPS' },
115
+ { prefix: 'mx_conductor_bsdf(closureData, metalness,', predicate: 'metalness >= M_FLOAT_EPS' },
116
+ { prefix: 'mx_dielectric_bsdf(closureData, specular,', predicate: 'specular >= M_FLOAT_EPS' },
117
+ { prefix: 'mx_dielectric_bsdf(closureData, transmission,', predicate: 'transmission >= M_FLOAT_EPS' },
118
+ { prefix: 'mx_sheen_bsdf(closureData, sheen,', predicate: 'sheen >= M_FLOAT_EPS' },
119
+ { prefix: 'mx_translucent_bsdf(closureData, subsurface,', predicate: 'subsurface >= M_FLOAT_EPS' },
120
+ { prefix: 'mx_subsurface_bsdf(closureData, subsurface,', predicate: 'subsurface >= M_FLOAT_EPS' },
121
+ { prefix: 'mx_mix_bsdf(closureData, translucent_bsdf_out, subsurface_bsdf_out,', predicate: 'subsurface >= M_FLOAT_EPS' },
122
+ { prefix: 'mx_uniform_edf(closureData, emission_weight_out,', predicate: 'emission >= M_FLOAT_EPS' },
123
+ { prefix: 'mx_multiply_edf_color3(closureData, emission_edf_out,', predicate: 'emission >= M_FLOAT_EPS' },
124
+ { prefix: 'mx_generalized_schlick_edf(closureData, emission_color0_out,', predicate: 'emission >= M_FLOAT_EPS' },
125
+ { prefix: 'mx_mix_edf(closureData, coat_emission_edf_out, emission_edf_out,', predicate: 'emission >= M_FLOAT_EPS' },
126
+ ];
127
+
128
+ /**
129
+ * MaterialX's optimized standard_surface graph still emits every closure call,
130
+ * even when a closure weight is a uniform that is zero for the whole draw. These
131
+ * uniform branches let ANGLE/Metal skip zero-weight branches before entering the
132
+ * heavier BSDF/EDF helpers.
133
+ * @param {string} fragmentShader
134
+ * @returns {string}
135
+ */
136
+ export function optimizeMaterialXClosureBranches(fragmentShader) {
137
+ if (!fragmentShader.includes('NG_standard_surface_surfaceshader_optim')) return fragmentShader;
138
+
139
+ let optimized = fragmentShader;
140
+ for (const { prefix, predicate } of STANDARD_SURFACE_CLOSURE_GATES) {
141
+ optimized = gateClosureCall(optimized, prefix, predicate);
142
+ }
143
+ return optimized;
144
+ }
145
+
146
+ /**
147
+ * @param {string} source
148
+ * @param {string} callPrefix
149
+ * @param {string} predicate
150
+ * @returns {string}
151
+ */
152
+ function gateClosureCall(source, callPrefix, predicate) {
153
+ const pattern = new RegExp(`^(\\s*)${escapeRegExp(callPrefix)}([^\\n]*\\);)$`, 'gm');
154
+ return source.replace(pattern, (_match, indent, rest) => {
155
+ return `${indent}if (${predicate}) {\n${indent} ${callPrefix}${rest}\n${indent}}`;
156
+ });
157
+ }
158
+
159
+ /**
160
+ * @param {string} value
161
+ * @returns {string}
162
+ */
163
+ function escapeRegExp(value) {
164
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
165
+ }
166
+
167
+ const TEXTURE_FLIP_Y_FUNCTIONS = `
168
+ vec2 mx_three_flip_y(vec2 uv, bool flipY) {
169
+ return flipY ? vec2(uv.x, 1.0 - uv.y) : uv;
170
+ }
171
+ `;
172
+
173
+ /**
174
+ * ShaderMaterial bypasses Three.js' built-in texture transform chunks, so
175
+ * MaterialX file texture samples need to account for THREE.Texture.flipY here.
176
+ * @param {string} fragmentShader
177
+ * @returns {string}
178
+ */
179
+ function patchTextureFlipY(fragmentShader) {
180
+ const textureNames = [...fragmentShader.matchAll(/\buniform\s+sampler2D\s+([A-Za-z_][A-Za-z0-9_]*)\s*;/g)]
181
+ .map(match => match[1])
182
+ .filter(name => !name.startsWith('u_') && name !== 'albedoTable');
183
+
184
+ if (!textureNames.length) return fragmentShader;
185
+
186
+ for (const name of textureNames) {
187
+ const escapedName = escapeRegExp(name);
188
+ fragmentShader = fragmentShader.replace(
189
+ new RegExp(`\\btexture\\s*\\(\\s*${escapedName}\\s*,\\s*([^,\\)]+)\\)`, 'g'),
190
+ `texture(${name}, mx_three_flip_y($1, ${name}_flipY))`
191
+ );
192
+ fragmentShader = fragmentShader.replace(
193
+ new RegExp(`\\btextureLod\\s*\\(\\s*${escapedName}\\s*,\\s*([^,\\)]+)\\s*,`, 'g'),
194
+ `textureLod(${name}, mx_three_flip_y($1, ${name}_flipY),`
195
+ );
196
+ }
197
+
198
+ const uniforms = textureNames.map(name => `uniform bool ${name}_flipY;`).join('\n');
199
+ return fragmentShader.replace(
200
+ /(precision\s+\w+\s+float;)/,
201
+ `$1\n${uniforms}\n${TEXTURE_FLIP_Y_FUNCTIONS}`
202
+ );
203
+ }
204
+
205
+ /**
206
+ * The stock WebGL image node implementations rely on sampler wrapping, but the
207
+ * MaterialX "constant" address mode returns the node default outside [0, 1].
208
+ * @param {string} fragmentShader
209
+ * @returns {string}
210
+ */
211
+ function patchImageAddressModes(fragmentShader) {
212
+ return fragmentShader.replace(
213
+ /(void\s+mx_image_\w+\([^)]*\bdefaultval\b[^)]*\buaddressmode\b[^)]*\bvaddressmode\b[^)]*\)\s*\{\s*vec2\s+uv\s*=\s*mx_transform_uv\(texcoord,\s*uv_scale,\s*uv_offset\);\s*)result\s*=\s*texture\(([^,]+),\s*uv\)(\.[A-Za-z]+)?;/g,
214
+ (_match, prefix, sampler, swizzle = "") => `${prefix}if ((uaddressmode == 0 && (uv.x < 0.0 || uv.x > 1.0)) || (vaddressmode == 0 && (uv.y < 0.0 || uv.y > 1.0))) {
215
+ result = defaultval;
216
+ } else {
217
+ result = texture(${sampler}, uv)${swizzle};
218
+ }`
219
+ );
220
+ }
9
221
 
10
222
  /**
11
223
  * @typedef {Object} MaterialXMaterialInitParameters
@@ -15,6 +227,8 @@ const normalMat = new Matrix3();
15
227
  * @property {import('./materialx.helper.js').Callbacks} loaders
16
228
  * @property {import('./materialx.js').MaterialXContext} context
17
229
  * @property {import('three').MaterialParameters} [parameters] - Optional parameters
230
+ * @property {"three-pmrem" | "materialx-prefiltered" | "materialx-fis"} [environmentRadianceMode]
231
+ * @property {boolean} [specularAntialiasing] - Match Three.js glossy specular antialiasing. Defaults to true.
18
232
  * @property {boolean} [debug] - Debug flag
19
233
  */
20
234
 
@@ -43,6 +257,10 @@ export class MaterialXMaterial extends ShaderMaterial {
43
257
  this.uniformsGroups = cloneUniformsGroups(source.uniformsGroups);
44
258
  this.envMapIntensity = source.envMapIntensity;
45
259
  this.envMap = source.envMap;
260
+ this.envMapRotation.copy(source.envMapRotation);
261
+ this.environmentRadianceMode = source.environmentRadianceMode;
262
+ this.specularAntialiasing = source.specularAntialiasing;
263
+ this.ready = source.ready;
46
264
  generateMaterialPropertiesForUniforms(this, this._shader.getStage('pixel'));
47
265
  generateMaterialPropertiesForUniforms(this, this._shader.getStage('vertex'));
48
266
  this.needsUpdate = true;
@@ -55,6 +273,8 @@ export class MaterialXMaterial extends ShaderMaterial {
55
273
  _shader = null;
56
274
  /** @type {boolean} */
57
275
  _needsTangents = false;
276
+ /** @type {Promise<void>} */
277
+ ready = Promise.resolve();
58
278
 
59
279
  /**
60
280
  * @param {MaterialXMaterialInitParameters} [init]
@@ -69,6 +289,9 @@ export class MaterialXMaterial extends ShaderMaterial {
69
289
  let fragmentShader = "";
70
290
  /** @type {Record<string, string>} */
71
291
  let defines = {};
292
+ /** @type {"three-pmrem" | "materialx-prefiltered" | "materialx-fis"} */
293
+ let environmentRadianceMode = DEFAULT_ENVIRONMENT_RADIANCE_MODE;
294
+ let specularAntialiasing = true;
72
295
 
73
296
  if (init) {
74
297
 
@@ -79,6 +302,9 @@ export class MaterialXMaterial extends ShaderMaterial {
79
302
 
80
303
  vertexShader = vertexShader.replace(/^#version.*$/gm, '').trim();
81
304
  fragmentShader = fragmentShader.replace(/^#version.*$/gm, '').trim();
305
+ fragmentShader = optimizeMaterialXClosureBranches(fragmentShader);
306
+ fragmentShader = patchImageAddressModes(fragmentShader);
307
+ fragmentShader = patchTextureFlipY(fragmentShader);
82
308
 
83
309
  // MaterialX uses different attribute names than js defaults,
84
310
  // so we patch the MaterialX shaders to match the js standard names.
@@ -120,6 +346,14 @@ export class MaterialXMaterial extends ShaderMaterial {
120
346
  // This lets us combine material.envMapIntensity * scene.environmentIntensity
121
347
  // the same way MeshStandardMaterial does.
122
348
  fragmentShader = fragmentShader.replace(/\bu_envLightIntensity\b/g, 'envMapIntensity');
349
+ specularAntialiasing = init.specularAntialiasing ?? true;
350
+ if (specularAntialiasing) {
351
+ fragmentShader = patchEnvironmentSpecularAntialiasing(fragmentShader);
352
+ }
353
+ environmentRadianceMode = normalizeEnvironmentRadianceMode(init.environmentRadianceMode);
354
+ if (environmentRadianceMode === "three-pmrem") {
355
+ fragmentShader = patchPrefilteredEnvironmentLookup(fragmentShader);
356
+ }
123
357
 
124
358
  // Capture some vertex shader properties
125
359
  // Detect whether each UV was originally vec2 or vec3 before removing declarations.
@@ -177,6 +411,10 @@ export class MaterialXMaterial extends ShaderMaterial {
177
411
  const isVec3 = new RegExp(`\\bvec3\\s+${name}\\b`).test(shader);
178
412
  return isVec3 ? `${name} = vec3(${uvName}, 0.0);` : match;
179
413
  });
414
+ shader = shader.replace(new RegExp(`(\\w+) = vec2\\(${uvName}\\.x,\\s*1\\.0 - ${uvName}\\.y\\);`, 'g'), (match, name) => {
415
+ const isVec3 = new RegExp(`\\bvec3\\s+${name}\\b`).test(shader);
416
+ return isVec3 ? `${name} = vec3(${uvName}.x, 1.0 - ${uvName}.y, 0.0);` : match;
417
+ });
180
418
  return shader;
181
419
  }
182
420
  vertexShader = patchUvAssignments(vertexShader, 'uv');
@@ -354,7 +592,7 @@ $2`
354
592
  );
355
593
  } // end hasShadowUniforms
356
594
 
357
- const isTransparent = init.parameters?.transparent ?? false;
595
+ const threeParameters = { ...init.parameters };
358
596
  materialParameters = {
359
597
  name: init.name,
360
598
  uniforms: {},
@@ -362,10 +600,9 @@ $2`
362
600
  fragmentShader: fragmentShader,
363
601
  glslVersion: GLSL3,
364
602
  depthTest: true,
365
- depthWrite: !isTransparent,
366
603
  defines: defines,
367
604
  lights: true, // Enable Three.js light uniforms
368
- ...init.parameters, // Spread any additional parameters passed to the material
605
+ ...threeParameters, // Spread any additional parameters passed to the material
369
606
  };
370
607
  }
371
608
 
@@ -377,34 +614,47 @@ $2`
377
614
  }
378
615
 
379
616
  const searchPath = ""; // Could be derived from the asset path if needed
617
+ /** @type {Array<Promise<unknown>>} */
618
+ const pendingTextureLoads = [];
380
619
  this.shaderName = init.shaderName || null;
381
620
  this._context = init.context;
382
621
  this._shader = init.shader;
383
622
  this._needsTangents = vertexShader.includes('in vec4 tangent;') || vertexShader.includes('in vec3 tangent;');
623
+ this.environmentRadianceMode = environmentRadianceMode;
624
+ this.specularAntialiasing = specularAntialiasing;
384
625
 
385
626
  Object.assign(this.uniforms, {
386
627
  // Three.js light uniforms (required when lights: true)
387
628
  ...UniformsLib.lights,
388
629
 
389
- ...getUniformValues(init.shader.getStage('vertex'), init.loaders, searchPath),
390
- ...getUniformValues(init.shader.getStage('pixel'), init.loaders, searchPath),
630
+ ...getUniformValues(init.shader.getStage('vertex'), init.loaders, searchPath, pendingTextureLoads),
631
+ ...getUniformValues(init.shader.getStage('pixel'), init.loaders, searchPath, pendingTextureLoads),
391
632
 
392
633
  u_worldMatrix: { value: new Matrix4() },
634
+ u_worldInverseMatrix: { value: new Matrix4() },
635
+ u_worldTransposeMatrix: { value: new Matrix4() },
636
+ u_worldInverseTransposeMatrix: { value: new Matrix4() },
637
+ u_worldViewMatrix: { value: new Matrix4() },
393
638
  u_viewProjectionMatrix: { value: new Matrix4() },
639
+ u_worldViewProjectionMatrix: { value: new Matrix4() },
394
640
  u_viewPosition: { value: new Vector3() },
395
- u_worldInverseTransposeMatrix: { value: new Matrix4() },
396
641
 
397
642
  u_envMatrix: { value: new Matrix4() },
398
643
  u_envRadiance: { value: null, type: 't' },
399
644
  u_envRadianceMips: { value: 8, type: 'i' },
645
+ u_envRadianceCubeUVTexelWidth: { value: 1 / 768 },
646
+ u_envRadianceCubeUVTexelHeight: { value: 1 / 1024 },
647
+ u_envRadianceCubeUVMaxMip: { value: 8 },
400
648
  // TODO we need to figure out how we can set a PMREM here... doing many texture samples is prohibitively expensive
401
649
  u_envRadianceSamples: { value: 8, type: 'i' },
402
650
  u_envIrradiance: { value: null, type: 't' },
403
651
  envMapIntensity: { value: 1.0 },
404
652
  u_refractionEnv: { value: true },
653
+ u_refractionTwoSided: { value: false },
405
654
  u_numActiveLightSources: { value: 0 },
406
655
  u_lightData: { value: [], needsUpdate: false }, // Array of light data. We need to set needsUpdate to false until we actually update it
407
656
  });
657
+ this.ready = Promise.all(pendingTextureLoads).then(() => undefined);
408
658
 
409
659
  generateMaterialPropertiesForUniforms(this, init.shader.getStage('pixel'));
410
660
  generateMaterialPropertiesForUniforms(this, init.shader.getStage('vertex'));
@@ -452,6 +702,12 @@ $2`
452
702
  envMapIntensity = 1.0; // Default intensity for environment map
453
703
  /** @type {Texture | null} */
454
704
  envMap = null; // Environment map texture, can be set externally
705
+ /** @type {Euler} */
706
+ envMapRotation = new Euler();
707
+ /** @type {"three-pmrem" | "materialx-prefiltered" | "materialx-fis"} */
708
+ environmentRadianceMode = DEFAULT_ENVIRONMENT_RADIANCE_MODE;
709
+ /** @type {boolean} */
710
+ specularAntialiasing = true;
455
711
 
456
712
  /**
457
713
  * @param {WebGLRenderer} _renderer
@@ -473,6 +729,18 @@ $2`
473
729
  uniforms.u_worldMatrix.needsUpdate = true;
474
730
  }
475
731
 
732
+ if (uniforms.u_worldInverseMatrix) {
733
+ uniforms.u_worldInverseMatrix.value.copy(worldInverseMat.copy(object.matrixWorld).invert());
734
+ // @ts-ignore
735
+ uniforms.u_worldInverseMatrix.needsUpdate = true;
736
+ }
737
+
738
+ if (uniforms.u_worldTransposeMatrix) {
739
+ uniforms.u_worldTransposeMatrix.value.copy(worldTransposeMat.copy(object.matrixWorld).transpose());
740
+ // @ts-ignore
741
+ uniforms.u_worldTransposeMatrix.needsUpdate = true;
742
+ }
743
+
476
744
  // Update view position
477
745
  if (uniforms.u_viewPosition) {
478
746
  uniforms.u_viewPosition.value.setFromMatrixPosition(camera.matrixWorld);
@@ -486,8 +754,21 @@ $2`
486
754
  uniforms.u_viewProjectionMatrix.needsUpdate = true;
487
755
  }
488
756
 
757
+ if (uniforms.u_worldViewMatrix) {
758
+ uniforms.u_worldViewMatrix.value.copy(worldViewMat.multiplyMatrices(camera.matrixWorldInverse, object.matrixWorld));
759
+ // @ts-ignore
760
+ uniforms.u_worldViewMatrix.needsUpdate = true;
761
+ }
762
+
763
+ if (uniforms.u_worldViewProjectionMatrix) {
764
+ worldViewMat.multiplyMatrices(camera.matrixWorldInverse, object.matrixWorld);
765
+ uniforms.u_worldViewProjectionMatrix.value.copy(worldViewProjectionMat.multiplyMatrices(camera.projectionMatrix, worldViewMat));
766
+ // @ts-ignore
767
+ uniforms.u_worldViewProjectionMatrix.needsUpdate = true;
768
+ }
769
+
489
770
  if (uniforms.u_worldInverseTransposeMatrix) {
490
- uniforms.u_worldInverseTransposeMatrix.value.setFromMatrix3(normalMat.getNormalMatrix(object.matrixWorld));
771
+ uniforms.u_worldInverseTransposeMatrix.value.copy(worldInverseTransposeMat.copy(object.matrixWorld).invert().transpose());
491
772
  // @ts-ignore
492
773
  uniforms.u_worldInverseTransposeMatrix.needsUpdate = true;
493
774
  }
@@ -549,7 +830,18 @@ $2`
549
830
  if (prev != textures.radianceTexture) uniforms.u_envRadiance.needsUpdate = true;
550
831
  }
551
832
  if (uniforms.u_envRadianceMips) {
552
- uniforms.u_envRadianceMips.value = Math.trunc(Math.log2(Math.max(textures.radianceTexture?.source.data.width ?? 0, textures.radianceTexture?.source.data.height ?? 0))) + 1;
833
+ const radianceWidth = textures.radianceTexture?.source.data.width ?? textures.radianceTexture?.image?.width ?? 0;
834
+ const radianceHeight = textures.radianceTexture?.source.data.height ?? textures.radianceTexture?.image?.height ?? 0;
835
+ const materialXRadianceMips = textures.radianceTexture?.userData?.materialXRadianceMips;
836
+ uniforms.u_envRadianceMips.value = Number.isFinite(materialXRadianceMips)
837
+ ? Math.max(1, Math.trunc(materialXRadianceMips))
838
+ : Math.max(1, Math.trunc(Math.log2(Math.max(radianceWidth, radianceHeight, 1))) + 1);
839
+ }
840
+ if (uniforms.u_envRadianceCubeUVTexelWidth || uniforms.u_envRadianceCubeUVTexelHeight || uniforms.u_envRadianceCubeUVMaxMip) {
841
+ const cubeUVSize = getCubeUVSize(textures.radianceTexture);
842
+ if (uniforms.u_envRadianceCubeUVTexelWidth) uniforms.u_envRadianceCubeUVTexelWidth.value = cubeUVSize.texelWidth;
843
+ if (uniforms.u_envRadianceCubeUVTexelHeight) uniforms.u_envRadianceCubeUVTexelHeight.value = cubeUVSize.texelHeight;
844
+ if (uniforms.u_envRadianceCubeUVMaxMip) uniforms.u_envRadianceCubeUVMaxMip.value = cubeUVSize.maxMip;
553
845
  }
554
846
  if (uniforms.u_envIrradiance) {
555
847
  const prev = uniforms.u_envIrradiance.value;
@@ -557,6 +849,23 @@ $2`
557
849
  // @ts-ignore
558
850
  if (prev != textures.irradianceTexture) uniforms.u_envIrradiance.needsUpdate = true;
559
851
  }
852
+ if (uniforms.u_envMatrix) {
853
+ const rotation = scene.environment && !this.envMap ? scene.environmentRotation : this.envMapRotation;
854
+ const texture = scene.environment && !this.envMap ? scene.environment : this.envMap;
855
+ envRotation.copy(rotation);
856
+ // Match Three.js WebGLMaterials: environment rotations are applied in
857
+ // the shader after converting from Three's left-handed env frame.
858
+ envRotation.x *= -1;
859
+ envRotation.y *= -1;
860
+ envRotation.z *= -1;
861
+ if (texture?.isCubeTexture && texture.isRenderTargetTexture === false) {
862
+ envRotation.y *= -1;
863
+ envRotation.z *= -1;
864
+ }
865
+ uniforms.u_envMatrix.value.copy(envMat.makeRotationFromEuler(envRotation));
866
+ // @ts-ignore
867
+ uniforms.u_envMatrix.needsUpdate = true;
868
+ }
560
869
 
561
870
  // Sync environment intensity: combine per-material envMapIntensity with scene.environmentIntensity
562
871
  // (mirrors MeshStandardMaterial behaviour in Three.js)
@@ -569,3 +878,196 @@ $2`
569
878
  this.uniformsNeedUpdate = true;
570
879
  }
571
880
  }
881
+
882
+ /**
883
+ * MaterialX's "prefiltered" GLSL target expects a latlong texture with roughness
884
+ * in mip levels. Three.js already stores the scene environment as a prefiltered
885
+ * CubeUV PMREM, so sample that texture directly and translate MaterialX's lod
886
+ * back to the alpha/roughness value that produced it.
887
+ * @param {string} fragmentShader
888
+ * @returns {string}
889
+ */
890
+ function patchPrefilteredEnvironmentLookup(fragmentShader) {
891
+ if (!fragmentShader.includes('mx_latlong_alpha_to_lod') || !fragmentShader.includes('mx_latlong_map_lookup')) {
892
+ return fragmentShader;
893
+ }
894
+
895
+ const uniforms = `
896
+ uniform float u_envRadianceCubeUVTexelWidth;
897
+ uniform float u_envRadianceCubeUVTexelHeight;
898
+ uniform float u_envRadianceCubeUVMaxMip;
899
+ `;
900
+ fragmentShader = fragmentShader.replace(/(uniform\s+sampler2D\s+u_envRadiance;\s*)/, `$1${uniforms}`);
901
+ fragmentShader = fragmentShader.replace(
902
+ /(vec3 mx_latlong_map_lookup\(vec3 dir, mat4 transform, float lod, sampler2D tex_sampler\)\s*\{[\s\S]*?\n\})/,
903
+ `$1
904
+
905
+ ${CUBE_UV_REFLECTION_FUNCTIONS}
906
+ float mx_materialx_lod_to_alpha(float lod)
907
+ {
908
+ float lodBias = lod / max(float(u_envRadianceMips - 1), 1.0);
909
+ return (lodBias < 0.5) ? lodBias * lodBias : 2.0 * (lodBias - 0.375);
910
+ }
911
+
912
+ vec3 mx_cubeuv_map_lookup(vec3 dir, mat4 transform, float lod, sampler2D tex_sampler)
913
+ {
914
+ vec3 envDir = normalize((transform * vec4(dir, 0.0)).xyz);
915
+ float roughness = sqrt(clamp(mx_materialx_lod_to_alpha(lod), 0.0, 1.0));
916
+ return mx_cubeuv_texture(tex_sampler, envDir, roughness).rgb;
917
+ }
918
+
919
+ vec3 mx_cubeuv_irradiance_map_lookup(vec3 dir, mat4 transform, float lod, sampler2D tex_sampler)
920
+ {
921
+ vec3 envDir = normalize((transform * vec4(dir, 0.0)).xyz);
922
+ return mx_cubeuv_texture(u_envRadiance, envDir, 1.0).rgb;
923
+ }`
924
+ );
925
+ fragmentShader = replaceCubeUVLatlongLookups(fragmentShader);
926
+ return fragmentShader;
927
+ }
928
+
929
+ /**
930
+ * Three.js avoids sharp IBL aliasing on smooth glossy silhouettes by applying a
931
+ * minimum environment roughness and derivative-based geometry roughness before
932
+ * sampling the prefiltered environment. MaterialX receives squared roughness
933
+ * (alpha) at this point, so convert to roughness, apply the same adjustment,
934
+ * and square it again for the stock MaterialX environment code.
935
+ * @param {string} fragmentShader
936
+ * @returns {string}
937
+ */
938
+ function patchEnvironmentSpecularAntialiasing(fragmentShader) {
939
+ if (fragmentShader.includes('mx_three_antialias_specular_alpha')) {
940
+ return fragmentShader;
941
+ }
942
+
943
+ const helper = `vec2 mx_three_antialias_specular_alpha(vec3 N, vec2 alpha)
944
+ {
945
+ vec3 normal = normalize(N);
946
+ vec3 dxy = max(abs(dFdx(normal)), abs(dFdy(normal)));
947
+ float geometryRoughness = max(max(dxy.x, dxy.y), dxy.z);
948
+ vec2 roughness = sqrt(clamp(alpha, vec2(0.0), vec2(1.0)));
949
+ roughness = min(vec2(1.0), max(roughness, vec2(0.0525)) + vec2(geometryRoughness));
950
+ return roughness * roughness;
951
+ }
952
+
953
+ `;
954
+
955
+ const patched = fragmentShader.replace(
956
+ /\bvec2\s+safeAlpha\s*=\s*clamp\(roughness,\s*M_FLOAT_EPS,\s*1\.0\);/g,
957
+ 'vec2 safeAlpha = mx_three_antialias_specular_alpha(N, clamp(roughness, M_FLOAT_EPS, 1.0));'
958
+ );
959
+ if (patched === fragmentShader) {
960
+ return fragmentShader;
961
+ }
962
+
963
+ const precisionMatch = patched.match(/precision\s+\w+\s+float;\s*/);
964
+ if (!precisionMatch || precisionMatch.index === undefined) {
965
+ return helper + patched;
966
+ }
967
+
968
+ const insertAt = precisionMatch.index + precisionMatch[0].length;
969
+ return patched.slice(0, insertAt) + '\n' + helper + patched.slice(insertAt);
970
+ }
971
+
972
+ /**
973
+ * In direct mode both specular radiance and diffuse irradiance should sample the
974
+ * same Three.js CubeUV PMREM. Radiance uses MaterialX's requested lod; irradiance
975
+ * samples the fully-blurred roughness-1 level, matching Three.js IBL behavior.
976
+ * @param {string} source
977
+ * @returns {string}
978
+ */
979
+ function replaceCubeUVLatlongLookups(source) {
980
+ const needle = 'mx_latlong_map_lookup(';
981
+ let result = '';
982
+ let offset = 0;
983
+
984
+ while (offset < source.length) {
985
+ const start = source.indexOf(needle, offset);
986
+ if (start === -1) {
987
+ result += source.slice(offset);
988
+ break;
989
+ }
990
+
991
+ const argsStart = start + needle.length;
992
+ const end = findMatchingParen(source, argsStart - 1);
993
+ if (end === -1) {
994
+ result += source.slice(offset);
995
+ break;
996
+ }
997
+
998
+ const args = splitTopLevelArguments(source.slice(argsStart, end));
999
+ const samplerName = args.at(-1)?.trim();
1000
+ let functionName = 'mx_latlong_map_lookup';
1001
+ if (samplerName === 'u_envRadiance') {
1002
+ functionName = 'mx_cubeuv_map_lookup';
1003
+ } else if (samplerName === 'u_envIrradiance') {
1004
+ functionName = 'mx_cubeuv_irradiance_map_lookup';
1005
+ }
1006
+
1007
+ result += source.slice(offset, start) + functionName + source.slice(start + 'mx_latlong_map_lookup'.length, end + 1);
1008
+ offset = end + 1;
1009
+ }
1010
+
1011
+ return result;
1012
+ }
1013
+
1014
+ /**
1015
+ * @param {string} source
1016
+ * @param {number} openParenIndex
1017
+ * @returns {number}
1018
+ */
1019
+ function findMatchingParen(source, openParenIndex) {
1020
+ let depth = 0;
1021
+ for (let i = openParenIndex; i < source.length; i++) {
1022
+ const char = source[i];
1023
+ if (char === '(') depth++;
1024
+ else if (char === ')') {
1025
+ depth--;
1026
+ if (depth === 0) return i;
1027
+ }
1028
+ }
1029
+ return -1;
1030
+ }
1031
+
1032
+ /**
1033
+ * @param {string} source
1034
+ * @returns {string[]}
1035
+ */
1036
+ function splitTopLevelArguments(source) {
1037
+ const args = [];
1038
+ let depth = 0;
1039
+ let start = 0;
1040
+ for (let i = 0; i < source.length; i++) {
1041
+ const char = source[i];
1042
+ if (char === '(') depth++;
1043
+ else if (char === ')') depth--;
1044
+ else if (char === ',' && depth === 0) {
1045
+ args.push(source.slice(start, i));
1046
+ start = i + 1;
1047
+ }
1048
+ }
1049
+ args.push(source.slice(start));
1050
+ return args;
1051
+ }
1052
+
1053
+ /**
1054
+ * @param {Texture | null | undefined} texture
1055
+ */
1056
+ function getCubeUVSize(texture) {
1057
+ const imageHeight = texture?.image?.height ?? texture?.source?.data?.height ?? 1024;
1058
+ const maxMip = Math.max(0, Math.log2(imageHeight) - 2);
1059
+ return {
1060
+ maxMip,
1061
+ texelHeight: 1 / imageHeight,
1062
+ texelWidth: 1 / (3 * Math.max(Math.pow(2, maxMip), 7 * 16)),
1063
+ };
1064
+ }
1065
+
1066
+ /**
1067
+ * @param {unknown} value
1068
+ * @returns {"three-pmrem" | "materialx-prefiltered" | "materialx-fis"}
1069
+ */
1070
+ function normalizeEnvironmentRadianceMode(value) {
1071
+ if (value === "materialx-fis") return "materialx-fis";
1072
+ return value === "materialx-prefiltered" ? "materialx-prefiltered" : DEFAULT_ENVIRONMENT_RADIANCE_MODE;
1073
+ }