Skip to content

Add XYZ-to-working-space transform for hardware shaders - #3005

Open
r800360 wants to merge 4 commits into
AcademySoftwareFoundation:mainfrom
r800360:fix/xyz-to-working-space
Open

r800360 wants to merge 4 commits into
AcademySoftwareFoundation:mainfrom
r800360:fix/xyz-to-working-space

Conversation

@r800360

@r800360 r800360 commented Jul 14, 2026

Copy link
Copy Markdown

Fixes #2859

Summary

  • Adds GenOptions::xyzToWorkingSpace, defaulting to the CIE XYZ to lin_rec709 transform.
  • Exposes the transform as the private hardware-shader uniform u_xyzToWorkingSpace.
  • Uses the uniform for blackbody emission and thin-film iridescence.
  • Removes the two hardcoded XYZ-to-RGB matrices.
  • Adds a focused GLSL shader-generation test covering the default transform, custom overrides, private-uniform propagation, token substitution, and both shader consumers.

Testing

  • MaterialXTest "GenShader: GLSL XYZ to Working Space"
  • Full MaterialXTest suite
  • 65 test cases and 14,579 assertions passed

@linux-foundation-easycla

linux-foundation-easycla Bot commented Jul 14, 2026 •

Copy link
Copy Markdown

CLA Signed
The committers listed above are authorized under a signed CLA.

  • ✅ login: r800360 / name: Rohan Sachdeva (803f959)

@jstone-lucasfilm

Copy link
Copy Markdown
Member

Thanks for this contribution, @r800360, and welcome to the project! This is a clean implementation of the proposal in #2859, following the same pattern as the existing u_envMatrix and u_shadowMatrix uniforms in HwShaderGenerator::createShader, and it looks like the row-major Matrix33 round-trips to the same GL and Metal matrix as the original mat3 literal in mx_blackbody.glsl.

One substantive concern is the white point of the thin-film reflectance. The CIE 1931 RGB matrix from the Belcour paper has an equal-energy white point, and since mx_eval_sensitivity is normalized so that a flat reflectance spectrum integrates to XYZ = (1, 1, 1), the original matrix maps a flat spectrum to neutral. A raw XYZ-to-Rec.709 matrix maps the same XYZ to roughly (1.205, 0.948, 0.909), and because the m=0 terms in mx_fresnel_airy are flat-spectrum contributions, this would place a visible warm cast on the base reflectance of every thin-film surface. Blackbody emission, in contrast, is producing an emitter color, so it seems right for that node to use the unadapted transform.

My current thinking is that the simplest robust fix is to normalize in the shader, so that equal-energy white maps to neutral for any matrix the integrator supplies:

// Convert back to RGB reflectance, normalizing so that equal-energy white maps to neutral
I = mx_matrix_mul($xyzToWorkingSpace, I) / mx_matrix_mul($xyzToWorkingSpace, vec3(1.0));
I = clamp(I, 0.0, 1.0);

@doug-walker, I'd value your perspective before we commit to an approach, so that we're not relying solely on our own reading of the color science. Is a per-channel normalization of this kind a reasonable adaptation for reflectance data, or would you recommend a proper chromatic adaptation transform such as Bradford? And do you agree that blackbody emission should remain unadapted while thin-film reflectance is adapted?

Either way, the thin_film_bsdf render references will shift, since the previous code interpreted CIE RGB values as Rec.709, and I'd like to review the before-and-after renders once the white point is settled.

A few smaller notes:

  1. SlangShaderGenerator.cpp still contains the const float3x3 XYZ_to_RGB token replacement, which no longer matches anything and can be removed.

  2. The new option should be added to the Python and JavaScript bindings in PyGenOptions.cpp and JsGenOptions.cpp, alongside the other GenOptions fields.

  3. The whitespace changes in HwShaderGenerator::toVec4 and GenGlsl.cpp are unrelated to this change, so I'd suggest dropping them to keep the diff focused.

  4. In the new test, the checks on the uniform block are the valuable part, while the checks on literal source substrings are tied to variable names in the GLSL and will break on unrelated refactors, and the comparison of defaultOptions.xyzToWorkingSpace against expectedDefault compares the same literal with itself. I'd suggest trimming these.

Finally, since this changelist leaves the option as a fixed lin_rec709 default, an application that already sets targetColorSpaceOverride to acescg would receive correct cmlib transforms everywhere except blackbody and thin-film. Given that MaterialX supports a small, closed set of linear working spaces, it's worth considering whether the default should derive from the target color space in createShader, with GenOptions::xyzToWorkingSpace serving as an explicit override. I'd be interested in your thoughts on this, and it could reasonably be handled as a follow-up. Overall this is a valuable contribution, and your work on it is much appreciated!

@doug-walker doug-walker left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few minor suggestions.

For the record, the original Slack thread was here:
https://academysoftwarefdn.slack.com/archives/C0230LWBE2X/p1775741600062129

{
// XYZ to CIE 1931 RGB color space (using neutral E illuminant)
const mat3 XYZ_TO_RGB = mat3(2.3706743, -0.5138850, 0.0052982, -0.9000405, 1.4253036, -0.0146949, -0.4706338, 0.0885814, 1.0093968);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replace with:
// Use Bradford chromatic adaptation to convert the white point from illuminant E,
// used by the Belcour paper, to illuminant D65, expected by the xyzToWorkingSpace
// matrix. The matrix converts {1., 1., 1.} to {0.95046, 1., 1.08906}. (Note that the mat3
// constructor expects a column-major vector.)
const mat3 E_TO_D65 = mat3(0.95314737, -0.03827266, 0.00261539, -0.02661088, 1.02885095, -0.00304736, 0.02391944, 0.00942171, 1.08948971);

Then keep the matrix multiply below, replacing XYZ_TO_RGB with E_TO_D65.

The LightData struct is built dynamically depending on requirements for
bound light shaders.
$xyzToWorkingSpace u_xyzToWorkingSpace mat3 Transform from CIE XYZ to the renderer's linear working color space,
initialized from GenOptions.xyzToWorkingSpace.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replace "CIE XYZ" with "CIE XYZ D65".

xyzToWorkingSpace(
3.2406f, -0.9689f, 0.0557f,
-1.5372f, 1.8758f, -0.2040f,
-0.4986f, 0.0415f, 1.0570f),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Five digits is not enough to fully initialize a float (needs 6-7). Here are some more digits:
3.240969941905, -0.969243636281, 0.055630079697, -1.53738317757, 1.875967501508, -0.203976958889, -0.498610760293, 0.041555057407, 1.056971514243


/// Transform from CIE XYZ to the renderer's linear working color space.
/// Defaults to the CIE XYZ to lin_rec709 transform.
Matrix33 xyzToWorkingSpace;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggest:
// Transform CIE XYZ values, with a D65 white point, to the renderer's linear working color space.

CIE XYZ does not have a fixed white point. In order for this matrix substitution technique to work, the white point on the XYZ side must be specified.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Color-space awareness in thin-film iridescence and blackbody emission

3 participants