Skip to content

Fix OpenPBR thin-walled subsurface color scaling - #3066

Open
LouisDeconinck wants to merge 3 commits into
AcademySoftwareFoundation:mainfrom
LouisDeconinck:fix/openpbr-thin-walled-subsurface-color
Open

LouisDeconinck wants to merge 3 commits into
AcademySoftwareFoundation:mainfrom
LouisDeconinck:fix/openpbr-thin-walled-subsurface-color

Conversation

@LouisDeconinck

Copy link
Copy Markdown

Fixes #3027.

The OpenPBR thin-walled subsurface lobes are unit-albedo lobes; subsurface_color should be applied once as each lobe color. The nodegraph also used it in both anisotropy factors, which squared the color contribution. This changes those factors to unit color and adds a focused graph regression test.

Validation:

  • MaterialX XML parse passed
  • git diff --check passed
  • CMake/CTest and generated-header compilation were unavailable in this environment

AI assistance: Codex assisted with implementation and test drafting; the patch was reviewed before submission.

@linux-foundation-easycla

linux-foundation-easycla Bot commented Sep 10, 2026

Copy link
Copy Markdown

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

@jstone-lucasfilm

Copy link
Copy Markdown
Member

Thanks for this proposal, @LouisDeconinck! Can you add a comment on the GitHub Issue that you're tackling, so that we can assign it to you? Additionally, see the instructions at #3066 (comment) on how to resolve the CLA authorization warnings, so that we can begin reviewing this PR.

@LouisDeconinck

Copy link
Copy Markdown
Author

Signed the CLA.

@LouisDeconinck

Copy link
Copy Markdown
Author

Done — Ive commented on #3027 and the issue is now assigned to me. EasyCLA is also passing on this PR now.

Signed-off-by: Louis Deconinck <louis.dck@gmail.com>
Assisted-by: Codex
Signed-off-by: Louis Deconinck <louis.dck@gmail.com>
Assisted-by: Codex
@LouisDeconinck
LouisDeconinck force-pushed the fix/openpbr-thin-walled-subsurface-color branch from 264fd51 to 47b8e60 Compare September 16, 2026 20:50
@jstone-lucasfilm

Copy link
Copy Markdown
Member

Thanks for taking this on, @LouisDeconinck! This looks very promising, but I see one important issue to address before we merge, along with a suggestion on the unit test.

Your diagnosis of the double application is correct, but removing the color from the external factors makes a latent problem in our original graph more visible. In our hardware shading library, mx_multiply_bsdf_color3.glsl and mx_multiply_bsdf_float.glsl both clamp their scaling weight to [0, 1], in keeping with the energy conservation guidance for multiply in the PBR specification. With this change, the transmission factor becomes (1 + g), which exceeds 1.0 for any positive subsurface_scatter_anisotropy. The transmission lobe then stops brightening at g = 0 while the reflection lobe continues to darken. As an example, at the default subsurface_color of 0.8 and an anisotropy of 0.5, the specified total albedo is 0.8, but hardware renders would produce 0.6. Previously the color kept the factor below 1.0 in most configurations, so the clamp was rarely reached.

Ideally I'd like this PR to address the clamping issue along with the double application, and I believe two changes would get us there:

  1. Express the thin-walled subsurface as a single mix of the two lobes. Since 0.5 * (1 - g) and 0.5 * (1 + g) sum to one, the OpenPBR equations are exactly a mix with the transmission lobe as fg, the reflection lobe as bg, and a weight of 0.5 * (1 + g). This keeps every weight within [0, 1] across the full anisotropy range, applies subsurface_color once through the lobe color inputs, and removes both factor nodes and both BSDF multiply nodes. That brings this section of the graph from ten nodes down to six.

  2. Update the functional notation for open_pbr_surface in MaterialX.PBRSpec.md, which carries the same double application, as @niklasharrysson noted in the review that led to Subsurface color is applied twice in the thin-walled path of OpenPBR Surface #3027. With the change above, I'd expect that block to read something like the following:

BSDF ss_reflection = oren_nayar_diffuse_bsdf(color = ss_color, roughness = base_diffuse_roughness, normal = geometry_normal);
BSDF ss_transmission = translucent_bsdf(color = ss_color, normal = geometry_normal);
BSDF subsurface_thin_walled = mix(ss_transmission, ss_reflection, 0.5 * (1.0 + subsurface_scatter_anisotropy));

On the unit test, the new case in GenShader.cpp checks for literal input values in the data library, so it would fail on any future restructuring of the graph, including the one proposed above. I'd suggest replacing it with a material in our render test suite. None of our current examples exercises thin-walled subsurface in OpenPBR, and a small document under resources/Materials/TestSuite/pbrlib/surfaceshader would give us real coverage of this path across all of our shading languages. I'd suggest geometry_thin_walled enabled, a subsurface_weight of 1.0, a tinted subsurface_color, and a nonzero subsurface_scatter_anisotropy.

Overall this looks like a great first contribution, and with the refinements above, I believe it should be ready to merge. Once it lands in MaterialX, we can propagate the same fix to the reference graph in the OpenPBR repository.

The 0.5*(1-g) and 0.5*(1+g) weights sum to one, so the OpenPBR
equations are exactly a mix of the transmission and reflection lobes.
This keeps every weight within [0, 1] across the full anisotropy range,
avoids the clamp in the hardware multiply implementations, applies
subsurface_color once through the lobe color inputs, and reduces this
section of the graph from ten nodes to six.

Also updates the functional notation in the PBR spec and replaces the
literal-value unit test with a render test material exercising
thin-walled subsurface across all shading languages.
@LouisDeconinck

Copy link
Copy Markdown
Author

Thanks for the detailed review — pushed a rework along exactly those lines:

  1. The thin-walled subsurface is now a single mix of the two lobes: translucent_bsdf as fg, oren_nayar_diffuse_bsdf as bg, weight 0.5 * (1 + subsurface_scatter_anisotropy). Since the weights sum to one this is mathematically identical to the OpenPBR equations, keeps every weight within [0, 1] so the hardware multiply clamps are never reached, applies subsurface_color once through the lobe color inputs, and takes the section from ten nodes down to six.
  2. MaterialX.PBRSpec.md functional notation updated to the same mix formulation.
  3. The literal-value check in GenShader.cpp is gone; replaced by resources/Materials/TestSuite/pbrlib/surfaceshader/open_pbr_thin_walled_subsurface.mtlx with geometry_thin_walled, subsurface_weight = 1.0, a tinted subsurface_color, and subsurface_scatter_anisotropy = 0.5.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Subsurface color is applied twice in the thin-walled path of OpenPBR Surface

2 participants