Skip to content

MaterialX base color: procedural and image-backed graphs#3

Merged
rtwfroody merged 13 commits into
mainfrom
materialx-support
May 3, 2026
Merged

MaterialX base color: procedural and image-backed graphs#3
rtwfroody merged 13 commits into
mainfrom
materialx-support

Conversation

@rtwfroody
Copy link
Copy Markdown
Owner

Summary

  • Adds a new internal/materialx package: pure-Go parser + zero-alloc closure-tree evaluator for the subset of MaterialX needed to compute base colors at 3D points. Supports both procedural graphs (marble, brick, checkerboard) and image-backed PBR packs (Quixel/AmbientCG-style .mtlx + adjacent textures, or .zip archives containing both).
  • Wires it into the pipeline as an alternative to the legacy hex BaseColor for untextured faces. Per-voxel sampling for print-grade fidelity; per-face centroid bake for the on-screen preview.
  • Image-backed graphs project onto untextured faces via triplanar (3× sampling weighted by |normal|^sharpness, with sign-flipped UVs to avoid mirror seams). Procedural graphs short-circuit to one sample per voxel via Sampler.UsesUV().
  • Frontend: dedicated row with a Solid / Texture toggle, plus tile-size and triplanar-sharpness inputs that appear when a texture is loaded. Settings round-trip the path; missing-file warning surfaces immediately on load.
  • Drive-by: fix a SIGSEGV from a disk-cache sweep race in runStage's double-cache-read pattern (commit bfa7700). Same crash mode independently surfaced when adding a sticker; not specific to MaterialX.

Reference matching

fractal3D no longer normalizes by amplitude sum and perlin3D applies mx_gradient_scale3d=0.9820, matching the official MaterialX GLSL reference (libraries/stdlib/genglsl/lib/mx_noise.glsl). Prior implementation made the noise term in standard_surface_marble_solid.mtlx 1.75× too small, leaving arrow-straight diagonal banding on a benchy. Hash function still differs (Ken Perlin permutation table vs. Bob Jenkins lookup3) so individual sample values won't bit-match, but pattern statistics and amplitude do.

Test plan

  • go test ./... green — new package tests cover image sampling, addressing modes (periodic/clamp/mirror), zip-with-subdirectory loading, sampler memoization, triplanar plane-picking + UV sign flipping + degenerate-normal averaging, the runStage cache-eviction race, and updated cache-key tests for the new MaterialX fields.
  • npx svelte-check clean.
  • Smoke-tested end-to-end with the official standard_surface_marble_solid.mtlx and the real TH_Pink_Cobblestone_Floor .zip against glyphid_praetorian.glb and a benchy — voxelize completes cleanly, no warnings.
  • BenchmarkSampleMarble: 0 allocs/op, 841 ns/op (single-octave marble graph).
  • Manual: launch GUI, toggle Solid ↔ Texture, load both a procedural .mtlx and a PBR .zip, confirm tile-size + triplanar sharpness reactivity and the missing-file warning when a path no longer exists.

🤖 Generated with Claude Code

rtwfroody and others added 13 commits May 2, 2026 14:17
Subset of MaterialX needed to evaluate solid procedural shader graphs
(marble, brick, checkerboard) at 3D positions. Image, BSDF, and
codegen nodes intentionally out of scope; the consumer is the voxel
color pipeline, which only needs RGB at a point.

Graphs are compiled to a closure tree at sampler construction so
Sample is reentrant and zero-alloc on the hot path (892 ns/op,
0 allocs/op for the official standard_surface_marble_solid.mtlx
fixture). Validation is exhaustive at construction — unsupported
nodes and missing wires fail there, not silently at sample time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A new BaseColorMaterialX option (file content) plus a tile-size knob
take precedence over the existing hex BaseColor when set. The
voxelizer samples per-voxel via a voxel.BaseColorOverride interface
fed by an adapter over materialx.Sampler; the per-face centroid is
also baked into FaceBaseColor so the on-screen 3D preview matches.
Sampling happens in the original-mesh frame via the existing
Transform.ApplyInverse, so the marble pattern is continuous across
split halves with no extra plumbing.

Both new fields are hashed into voxelizeSettings *and* stickerSettings
since runSticker deep-clones ColorModel; cache contracts are exercised
by new regression tests alongside the existing BaseColor ones. Load
and decimate caches survive procedural changes.

Frontend gains a "Load .mtlx" button + tile-size input in the Base
color section, mutually exclusive with the hex picker; the .mtlx
content is round-tripped through the Settings JSON so projects
rebuild identically without re-reading the file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two delta versus the official MaterialX GLSL implementation
(libraries/stdlib/genglsl/lib/mx_noise.glsl):

* fractal3D no longer normalizes by sum-of-amplitudes. With
  octaves=3/diminish=0.5 our output was 1.75x too small, which made
  the noise term in standard_surface_marble_solid (3 * fractal3d)
  unable to overpower the linear (x+y+z) carrier — visible as
  arrow-straight diagonal bands instead of organic veins.
* perlin3D now multiplies by mx_gradient_scale3d=0.9820, the
  empirical scale factor MaterialX uses to compensate for non-unit
  gradient vectors. Brings single-octave Perlin output into the
  reference's numeric range.

Hash function still differs (we use Ken Perlin's permutation table;
the reference uses Bob Jenkins lookup3) so individual sample values
won't bit-match, but pattern statistics and amplitude do.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the parser-and-evaluator half of image-backed MaterialX support
(Quixel/AmbientCG-style PBR packs). Self-contained — no pipeline
plumbing changes yet; the existing per-voxel hot path is unaffected.

* SampleContext (Pos/UV/Normal) replaces the legacy pos-only entry
  point on Sampler. Sample(pos) is preserved as a thin wrapper.
* Per-call context+scratch are pooled together so SampleContext
  doesn't escape when its address flows into closures (zero allocs
  per Sample retained).
* New nodes: image (PNG/JPEG via stdlib, periodic/clamp/mirror
  addressing, nearest/linear filtering, sRGB pixels kept un-linearized
  to match downstream sRGB quantization), texcoord (reads UV from
  context), extract (one component of a vector). multiply already
  generalized to vec2 in a prior commit just by widening arity.
* ResourceResolver interface + dirResolver, zipResolver, mapResolver.
  ParsePackage detects .mtlx vs .zip; ParseFile auto-installs a dir
  resolver rooted at the file's directory; ParseBytes leaves the
  resolver nil (image graphs error at sampler construction time).
* Parser tolerates unknown-typed inputs by stashing the raw string —
  surface shaders carry many we don't consume (boolean thin_walled,
  matrix44 etc.); failing the whole parse on those was wrong.

Verified end-to-end against the real cobblestone .zip
(TH_Pink_Cobblestone_Floor_2k_8b_3h0n66y.zip) — loads, samples,
returns plausible per-UV colors. In-package tests cover image
sampling, addressing modes, UV scaling, zip loading, and the no-
resolver error path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…cache key

Plumbs the closest-face surface normal into the BaseColorOverride
contract so the pipeline adapter can drive triplanar projection on
image-backed MaterialX graphs (PBR packs whose .mtlx reads texcoord
to sample a 2D texture). For purely position-driven graphs (marble,
brick) the new sampler.UsesUV() flag short-circuits the triplanar
machinery and falls back to a single SampleAt — no per-voxel cost
increase for the existing procedural path.

Specifically:

* voxel.BaseColorOverride.SampleBaseColor now takes a BaseColorContext
  bundling pos and the unit-length face normal (zero on degenerate
  faces). Both call sites — voxel.SampleNearestColorWithSticker and
  pipeline.bakeMaterialXBaseColor — compute the normal from the
  triangle's three vertices.
* materialx.Sampler grows UsesUV() so the adapter can detect graphs
  that consume texcoord. The compiler flags it on when texcoord or
  image nodes are encountered.
* materialxOverride.SampleBaseColor either single-samples (no UV) or
  triplanar-blends three SampleAt calls weighted by
  |normal.axis|^TriplanarSharpness, with TriplanarSharpness exposed as
  a new pipeline.Options field (default 4 in CLI; UI slider in the
  next commit).
* Options.BaseColorMaterialX semantics: was inline file content,
  now path. Cache invalidation hashes path + mtime + size — both
  voxelize and sticker stages. Existing inline-content settings break
  (intentionally, per the project's no-cache-compat policy).

Verified end-to-end against the real cobblestone .zip — voxelize
completes cleanly, no warnings. New pipeline-internal tests cover
triplanar plane-picking, diagonal-normal blending, the non-UV
fast-path, file-stamp cache invalidation, and the new sharpness
field.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The MaterialX file picker now accepts both .mtlx files (with adjacent
texture directories) and .zip archives containing a .mtlx + textures.
The pipeline reads from the path at run time, so settings only
persist the path — drops the now-redundant baseMaterialXContent that
was a workaround for the previous inline-content cache key.

A new "Triplanar" input lets the user trade soft cosine blending for
sharp box-mapping when an image-backed graph gets projected onto
untextured faces. Defaults to 4 (a reasonable middle ground); the
existing tile-size knob's helper text is updated to mention image
packs alongside procedurals.

Wails bindings (models.ts, App.d.ts, App.js, app.go MaterialXOpenResult)
are updated to drop the content field and add the triplanar sharpness
to both Settings and pipeline.Options.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rnings

Folds in the review punch list from the prior review pass:

* Compiled materialx.Sampler is now cached on StageCache by (path,
  mtime, size). applyBaseColor (preview bake) and the voxelize stage
  share one parse + image decode per pipeline run, vs. two before.
  Errors are cached too so a malformed .mtlx isn't re-attempted on
  every consumer call.
* Triplanar UV signs now flip with sign(normal.axis) so a directional
  texture (text, arrows) reads consistently across opposite-facing
  parallel faces. For the cobblestone fixture the change is invisible;
  for any image-with-text it would have shown mirrored on -X faces.
* Degenerate-normal triplanar fallback now averages all three planes
  equally rather than silently picking XY — degenerate faces blend
  smoothly with neighbors instead of popping.
* dirResolver path-traversal check splits on filepath.Separator and
  rejects ".." segments. The old HasPrefix(clean, "..") was a no-op
  on Windows for "..\foo" after FromSlash didn't normalize backslashes.
* Tracker grows a Warn(message) method so MaterialX errors surface in
  the GUI status banner (existing pipeline-warning event listener) and
  the CLI stderr — not just buried in log.Printf.
* Frontend stats the .mtlx path on settings load (new MaterialXPathOK
  Wails method) and warns immediately if the file is missing — useful
  when a project file moves between machines.

Drive-by polish:
* voxel.faceNormal is now exported as voxel.FaceNormal; pipeline.go's
  bakeMaterialXBaseColor reuses it instead of duplicating the cross-
  product math.
* decodeImage takes io.Reader directly; the anonymous interface
  wrapper and readerFunc adapter are deleted.
* Resolver's hand-rolled byteReader replaced with bytes.NewReader.
* image.go's unused defaultFn capture removed (the real default-color
  story for missing files is "fail at compile time", documented).
* floatToByte's ±1 round-trip from FP blend documented inline.

New tests cover: Sampler cache memoization (via observed color
change), missing-path file-stamp returns (0, 0), triplanar UV sign
flip on opposite-facing normals, degenerate-normal three-way average,
ParsePackage on a zip with .mtlx in a subdirectory.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Model section previously crammed Size/Scale and Base color into a
single 2-column row, with the right column branching between three
incompatible widgets (color swatch / mtlx-loaded label / "Default")
plus a separate MaterialX picker squeezed into a second row. The
behavior was right but the layout was hostile.

Now:
* Size/Scale gets its own row (label + input).
* Base color row: an explicit "Solid / Texture" radio toggle next to
  the label, and a single picker on the right for whichever mode is
  active — color swatch when Solid, file-name display + Clear when
  Texture (or "Load .mtlx / .zip" if no path picked yet).
* Texture-only options (Tile size, Triplanar) drop down on a row
  below, only visible when Texture mode + a path are both set.

Mode is persisted in the settings JSON as `baseColorMode` so loaded
projects open in the picker the user expects. Older settings files
that lack the field fall back to inferring from baseMaterialXPath
(set → texture, else solid).

Backend wiring: pipeline.Options.BaseColorMaterialX is now suppressed
when mode === 'solid', so the unselected mode's stored value
(retained client-side so the user can toggle without losing it) never
reaches the cache key or the voxelizer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Matches the Printer section's pattern where each control's title spans
both columns, then the controls themselves sit on the row below. The
Solid/Texture radio toggle stays on the left of the second row, with
the active picker on the right.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
runStageCached's cache-hit path decoded the blob, logged "cache hit",
then threw the value away and returned nil to indicate success.
runStage then saw *slot == nil and called cache.get a second time to
populate the slot — a second decode of the same blob. If the
background disk-cache sweep (kicked at the end of every pipeline run
in app.kickDiskCacheSweep) deleted the file between those two reads,
the second cache.get returned nil. runStage stored the nil into the
slot and returned (nil, nil).

The caller (e.g. runLoad → applyBaseColor) then dereferenced the nil
loadOutput. The Go runtime's SIGSEGV handler should have produced a
clean panic, but on Linux Wails ships a CGO/GTK signal handler that
clobbers SA_ONSTACK, so the runtime's diagnostic was the cryptic
"non-Go code set up signal handler without SA_ONSTACK flag" instead
of the underlying nil dereference.

Fix: thread the decoded value from runStageCached's first read out to
runStage so there's only ever one cache.get per stage. Plus a
defensive guard at the end of runStage that returns an explicit
error if neither the cache hit nor the body populated the slot —
should be unreachable, but better than silently returning nil.

Tests cover both the cache-hit invariant (runStage returns non-nil
without invoking the body) and the eviction-race scenario (cache
file gone before the call returns non-nil rather than (nil, nil)).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Minor bump for the new MaterialX base-color feature (procedural .mtlx
graphs, image-backed PBR packs via .zip, triplanar projection,
sign-flipped UVs, sampler caching, and the runStage cache-race fix).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Updates the "Base color" section to cover the new Solid/Texture toggle,
and adds the three --base-materialx* CLI flags to the options table.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five small cleanups from the holistic review:

* Delete TestRunStageHandlesMidRunCacheEviction. The named regression
  did not exercise — it deleted the cache file before runStage ran,
  so the first cache.get inside runStageCached missed (file gone),
  the body ran, and *slot was populated. The old buggy code passes
  the same path. The actual race (delete BETWEEN the wrapper's get
  and the caller's second get) is now structurally impossible
  because the second get is gone; TestRunStageCacheHitReturnsValue
  covers what's still meaningful to assert.
* Add BaseColorMaterialXTriplanarSharpness coverage to the Sticker
  cache-key test — it was missing while the Voxelize equivalent
  already had it.
* Stale comment refs from the pre-refactor architecture: runSticker /
  runVoxelize / runLoad / cache.setLoad / buildBaseColorOverride no
  longer exist as named functions. Renamed to "the {Sticker,Voxelize,
  Load} stage body" and "cache.baseColorOverride" respectively.
* Hoist the duplicate "ignoring MaterialX base color" warning into
  cache.baseColorOverride with per-session, per-(path,mtime,size)
  dedup. Previously the user saw the same toast twice per run on a
  malformed .mtlx (once from applyBaseColor, once from Voxelize).
* gofmt over cmd/ditherforge/main.go to retire the column-shift drift
  introduced when the new BaseMaterialX* fields landed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@rtwfroody rtwfroody merged commit d191bb8 into main May 3, 2026
4 checks passed
@rtwfroody rtwfroody deleted the materialx-support branch May 3, 2026 05:47
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