Skip to content

[MAX-MAT-003] Stop leaking Max wireframe color into primvars:displayColor#38

Open
doug-smith-miris wants to merge 3 commits into
Autodesk:devfrom
doug-smith-miris:miris/max-mat-003-fix-displaycolor-leak
Open

[MAX-MAT-003] Stop leaking Max wireframe color into primvars:displayColor#38
doug-smith-miris wants to merge 3 commits into
Autodesk:devfrom
doug-smith-miris:miris/max-mat-003-fix-displaycolor-leak

Conversation

@doug-smith-miris

Copy link
Copy Markdown

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::ConvertToUSDMesh unconditionally authored every mesh's primvars:displayColor from 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, defines primvars:displayColor as the surface color a consumer should use when the bound UsdShadeMaterial cannot be evaluated (the "fallback" color).

On the diagnostic corpus, 6/6 meshes carry a displayColor that disagrees with their bound material:

Mesh Bound material displayColor (buggy) displayColor (fixed) = material diffuse
Ground Floor (0.53, 0.84, 0.40) (0.78, 0.78, 0.80)
Teapot Gold (0.85, 0.89, 0.68) (pale green) (0.92, 0.71, 0.24) (gold)
Sphere RedPlastic (0.67, 0.38, 0.35) (muddy pink) (0.78, 0.16, 0.16) (red)
Box BrushedSteel (0.77, 0.88, 0.64) (0.71, 0.71, 0.74) (steel)
Cylinder BlueCeramic (0.33, 0.50, 0.08) (olive) (0.16, 0.35, 0.78) (blue)
Torus Copper (0.22, 0.49, 0.44) (0.78, 0.43, 0.27) (copper)

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:

  • Minimal Hydra delegates and scene-graph viewers without MaterialX.
  • ARKit Quick Look paths / iOS USDZ thumbnailers without MaterialX.
  • The usdview "displayColor" overlay used to QA primvars.
  • USDZ packagers that fall back to displayColor when shaders can't be inlined for distribution.

What changed

  • src/MaxUsd/MeshConversion/MeshConverter.cpp: in the post-conversion fallback block, derive displayColor from node->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 what LastResortUSDPreviewSurfaceWriter already uses to author the bound material's inputs:diffuseColor, so the two values are round-trip-identical modulo float32 quantization. For a MultiMtl, 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: adds test_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) and test_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 existing test_display_color_override continues 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 existing test_io_display_color continues 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.usda from #37): 6/6 meshes affected. The USDA diff is exactly 6 deletions + 6 additions (one primvars:displayColor line per mesh), every new value matches the bound material's UsdPreviewSurface inputs:diffuseColor verbatim. 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:binding stripped from every mesh in both copies) ARE visually different (compare_side_by_side.png):

  • Prefix: pale-green Teapot, muddy-pink Sphere, olive Cylinder, lime Box, light-green Floor — the wireframe-derived palette.
  • Postfix: gold Teapot, red Sphere, blue Cylinder, grey Box, near-white Floor — the material-derived palette.

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 of render_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

  • Reviewer confirms the C++ change is a minimal, conservative addition to the existing MeshConverter fallback (one condition added, the two pre-existing branches preserved).
  • On a Windows 3ds Max install, run the geometry / color integration suite (io_color_n_visibility_test.ms) and confirm both new tests pass alongside the pre-existing test_io_display_color and test_display_color_override.
  • On a Windows 3ds Max install, export a fresh scene with materials via the plugin and confirm primvars:displayColor on each mesh matches the bound material's diffuse color (and falls back to the wireframe color only on unbound meshes).

🤖 Generated with Claude Code

Doug Smith and others added 3 commits June 20, 2026 17:43
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>
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.

1 participant