[MAX-MAT-003] Stop leaking Max wireframe color into primvars:displayColor#38
Open
doug-smith-miris wants to merge 3 commits into
Open
Conversation
3ds Max's MtlxIOUtil bridge hardcodes specular_rotation=0.25 on every ND_standard_surface_surfaceshader, regardless of source PhysicalMaterial anisotropy. The MaterialX nodedef default is 0.0; the value has no visual effect when specular_anisotropy is zero (the BSDF multiplies them), but it silently introduces a 90deg highlight rotation if a downstream layer turns anisotropy on. Add a post-parse normalization pass in MtlxShaderWriter::Write that walks the parsed MaterialX document and drops specular_rotation from standard_surface nodes when: * the value is statically 0.25 (the bug pattern), AND * specular_anisotropy is absent or statically 0.0. Connected anisotropy, any non-0.25 rotation value, and connected rotation are conservatively left untouched. Validated against the diagnostic corpus (samples/complex_export.usda): 6/6 affected materials cleaned, Karma renders byte-identical pre/post (SHA-256 match — semantic-only fix). Synthetic negative test (BrushedSteel with specular_anisotropy=0.4) confirms the fix is surgical and preserves intentional rotation. Also adds doc/translation-mapping.md to track 3ds Max -> MaterialX property mappings going forward, and a MaxScript regression test in mtlxShaderWriter_test.ms. Build/run validation deferred: 3ds Max is Windows-only and this repo builds via Visual Studio (configure-vsdevcmd.bat). The Python validator in the arch-build dir mirrors the C++ logic exactly at the USD layer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3ds Max's MtlxIOUtil bridge hardcodes emission=1.0 paired with
emission_color=(0,0,0) on every ND_standard_surface_surfaceshader,
regardless of the source PhysicalMaterial. The MaterialX nodedef defaults
are emission=0.0 and emission_color=(1,1,1); the buggy pair multiplies to
(0,0,0) so it has no visual effect, but it is semantically wrong --
any downstream layer that bumps emission_color away from black would
unexpectedly turn emission on at full strength, and any round-trip
importer reads (0,0,0) as "explicit black emission" rather than "no
emission authored".
Add a second post-parse normalization pass in MtlxShaderWriter::Write
(immediately after MAX-MAT-001's pass) that walks the parsed MaterialX
document and strips BOTH inputs from standard_surface nodes when:
* emission is statically 1.0 (no connection, tolerant of float noise),
* emission_color is statically (0,0,0) (no connection, same tolerance),
* AND both inputs are present (the bug is the pair; treating either
half in isolation could destroy a real authored value).
Any deviation -- non-1.0 emission, non-black color, connected input,
missing half -- leaves both inputs untouched.
Adds a small _TryGetStaticColor3 helper that parses MaterialX color3
value strings ("r, g, b" or "r g b") via istringstream with separator
normalization; rejects strings with the wrong number of components.
Validated against the diagnostic corpus (samples/complex_export.usda)
via a Python validator that mirrors the C++ logic 1:1 at the USD layer:
6/6 affected materials cleaned, exactly 12 deletions in the USDA diff
(6 emission + 6 emission_color), zero additions, zero structural
changes. Karma renders byte-identical pre/post (SHA-256 match -- the
buggy pair multiplies to zero emission, the nodedef defaults also
multiply to zero emission, both produce the same BSDF lighting result).
Synthetic negative-test fixture (BrushedSteel with intentional
emission_color=(0.8, 0.4, 0.1); Copper with intentional emission=0.5)
confirms the fix is surgical: 4/6 stripped, both intentional materials
preserved.
Also updates doc/translation-mapping.md and adds a MaxScript
regression test in mtlxShaderWriter_test.ms.
Build/run validation deferred: 3ds Max is Windows-only and this repo
builds via Visual Studio. The Python validator mirrors the C++ logic
exactly at the USD layer.
Stacked on top of Autodesk#36 (MAX-MAT-001).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…olor MaxUsd::MeshConverter::ConvertToUSDMesh unconditionally authored every mesh's primvars:displayColor from the source Max node's viewport wireframe color whenever no explicit displayColor primvar had already been written. In 3ds Max the wireframe color is a scene-graph organizational tag (a hue assigned to a node so it stands out in the viewport) -- it has no relationship to the surface's actual color. USD, in contrast, defines primvars:displayColor as the surface color a consumer should display when the bound UsdShadeMaterial cannot be evaluated (the "fallback" color). Result on the diagnostic corpus (6/6 meshes): every mesh carries a displayColor that disagrees with its bound material. Gold Teapot is (0.85, 0.89, 0.68) -- pale green-cream -- bound to a Gold material with base_color (0.92, 0.71, 0.24). Same divergence on Sphere/RedPlastic, Cylinder/BlueCeramic, etc. The bug is invisible to PBR-capable renderers (Hydra Storm, Karma, RenderMan) because they evaluate the bound MaterialX/UsdPreviewSurface and never read displayColor. It surfaces in fallback consumers: - Minimal Hydra delegates and scene-graph viewers without MaterialX - ARKit Quick Look paths / iOS USDZ thumbnailers without MaterialX - The usdview "displayColor" overlay - USDZ packagers that fall back to displayColor when shaders can't be inlined for distribution In all of those, the rendered surface color is the wireframe-derived hue rather than the material's color. Fix: change the single fallback line in MeshConverter to derive displayColor from the bound material when one is present. The accessor is Mtl::GetDiffuse(int mtlNum = 0, BOOL backFace = FALSE) -- the universal Max SDK accessor for a material's main diffuse, and the same accessor the LastResortUSDPreviewSurfaceWriter uses to author the bound material's inputs:diffuseColor on the USD side. The two values are therefore round-trip-identical modulo float32 quantization. Bounds (where the fix conservatively does nothing): - usdMesh.GetDisplayColorAttr().IsAuthored() is already true -- the vertex-color -> displayColor channel mapping (the explicit user opt-in via SetChannelPrimvarMapping 0 "displayColor") ran first and wrote the artist-authored value. We never overwrite it. - node->GetMtl() == nullptr -- no material bound. The wireframe color is the best representational color we have, so the previous behavior is preserved (existing test_io_display_color regression check continues to pass). Validated against the diagnostic corpus (post-MAX-MAT-002 complex_export_postfix.usda) via a Python validator that mirrors the C++ logic at the USD layer: 6/6 affected meshes rewritten, exactly 6 deletions + 6 additions in the USDA diff (one primvars:displayColor line per mesh), every new value matches the bound material's UsdPreviewSurface diffuseColor verbatim. PBR-path Karma renders pre- and post-fix are byte-identical (SHA-256 match) -- expected, because PBR renderers don't read displayColor when a material is bound, so the fix is a no-op for the common case. displayColor-fallback Karma renders (material:binding stripped from every mesh in both copies) ARE visually different -- the wireframe-derived palette is replaced by the material-derived palette, which is the observable behavior change for fallback consumers. Also updates doc/translation-mapping.md and adds two MaxScript regression tests in io_color_n_visibility_test.ms: test_display_color_uses_material_diffuse_when_bound (the positive fix), and test_display_color_still_uses_wire_color_when_no_material (the negative case, guaranteeing the unbound-mesh fallback is preserved). Build/run validation deferred: 3ds Max is Windows-only and this repo builds via Visual Studio. The Python validator mirrors the C++ logic exactly at the USD layer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Stacked on top of #36 (MAX-MAT-001) and #37 (MAX-MAT-002). Until those PRs merge, this PR's diff includes their commits too — once they merge into
dev, this PR's diff will collapse to just the MAX-MAT-003 changes (1 commit, 4 files).MaxUsd::MeshConverter::ConvertToUSDMeshunconditionally authored every mesh'sprimvars:displayColorfrom the source 3ds Max node's viewport wireframe color whenever no explicit displayColor primvar had already been written. The wireframe color is a scene-graph organizational tag — a hue assigned to a node so it stands out in the viewport — and has no relationship to the surface's actual color. USD, in contrast, definesprimvars:displayColoras the surface color a consumer should use when the boundUsdShadeMaterialcannot be evaluated (the "fallback" color).On the diagnostic corpus, 6/6 meshes carry a
displayColorthat disagrees with their bound material:The bug is invisible to PBR-capable USD renderers (Hydra Storm, Karma, RenderMan) — they evaluate the bound MaterialX/UsdPreviewSurface and never read displayColor. It surfaces in fallback consumers:
usdview"displayColor" overlay used to QA primvars.What changed
src/MaxUsd/MeshConversion/MeshConverter.cpp: in the post-conversion fallback block, derive displayColor fromnode->GetMtl()->GetDiffuse()when a material is bound; fall back to the wireframe color only when none is.Mtl::GetDiffuse(int mtlNum = 0, BOOL backFace = FALSE)is the universal Max SDK accessor for a material's "main" diffuse and is whatLastResortUSDPreviewSurfaceWriteralready uses to author the bound material'sinputs:diffuseColor, so the two values are round-trip-identical modulo float32 quantization. For aMultiMtl,GetDiffuse(0)returns the first sub-material's diffuse — still materially closer to the artist's intent than the wireframe color.doc/translation-mapping.md: adds a new Status row + Notes section + change-log entry under MAX-MAT-003.doc/changelog.md: adds an Unreleased fix entry.src/Tests/Integration/io_color_n_visibility_test.ms: addstest_display_color_uses_material_diffuse_when_bound(positive fix — a box with a green-diffuse Standard material and a red wireframe must export displayColor = green) andtest_display_color_still_uses_wire_color_when_no_material(negative case — same box without a material must still export displayColor = wireframe red).Bounds (where the fix conservatively does nothing)
usdMesh.GetDisplayColorAttr().IsAuthored()is already true — the vertex-color → displayColor channel mapping (SetChannelPrimvarMapping 0 "displayColor") ran first and wrote the artist-authored value. We never overwrite it. The existingtest_display_color_overridecontinues to pass.node->GetMtl() == nullptr— no material bound. The wireframe color is the best representational color we have, so the previous behavior is preserved. The existingtest_io_display_colorcontinues to pass.Validation
3ds Max is Windows-only and this repo builds via Visual Studio; build/run was deferred (same constraint as #36/#37). A Python validator that mirrors the C++ logic at the USD layer was run on the post-MAX-MAT-002 corpus.
Diagnostic corpus (
complex_export_postfix.usdafrom #37): 6/6 meshes affected. The USDA diff is exactly 6 deletions + 6 additions (oneprimvars:displayColorline per mesh), every new value matches the bound material'sUsdPreviewSurface inputs:diffuseColorverbatim. No structural changes.PBR-path Karma renders of pre-fix and post-fix corpora are byte-identical (SHA-256
eaf38328…). This is the expected outcome and the success signal — PBR renderers don't read displayColor when a material is bound, so the fix is a no-op for the common case.displayColor-fallback Karma renders (
material:bindingstripped from every mesh in both copies) ARE visually different (compare_side_by_side.png):This is the observable behavior change for fallback consumers, and the visual proof that the fix is meaningful.
Reference files (validator + corpus)
Under
agent/arch-builds/44966a53-f273-422c-9ce9-c1f2d7e477c1/(outside the repo):normalize_display_color.py— USD-layer mirror of the C++ logic.strip_material_bindings.py— produces the fallback-render fixtures.complex_export_prefix.usda— input (post-MAX-MAT-002, pre-MAX-MAT-003).complex_export_postfix.usda— output (MAX-MAT-001+002+003-clean).complex_export_prefix_stripped.usda,complex_export_postfix_stripped.usda— material-binding-stripped fallback-render fixtures.render_karma_prefix.png,render_karma_postfix.png(byte-identical, PBR path).render_karma_prefix_stripped.png,render_karma_postfix_stripped.png(visually different, fallback path).render_unreal_reference.png— copy ofrender_karma_postfix_stripped.png(this fork targets 3ds Max, not Unreal).compare_side_by_side.png— labeled side-by-side fallback comparison.intended_example.md— detailed observable / fixture description for the auditor.Test plan
io_color_n_visibility_test.ms) and confirm both new tests pass alongside the pre-existingtest_io_display_colorandtest_display_color_override.primvars:displayColoron each mesh matches the bound material's diffuse color (and falls back to the wireframe color only on unbound meshes).🤖 Generated with Claude Code