MaterialX base color: procedural and image-backed graphs#3
Merged
Conversation
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>
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
internal/materialxpackage: 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.ziparchives containing both).BaseColorfor untextured faces. Per-voxel sampling for print-grade fidelity; per-face centroid bake for the on-screen preview.|normal|^sharpness, with sign-flipped UVs to avoid mirror seams). Procedural graphs short-circuit to one sample per voxel viaSampler.UsesUV().runStage's double-cache-read pattern (commitbfa7700). Same crash mode independently surfaced when adding a sticker; not specific to MaterialX.Reference matching
fractal3Dno longer normalizes by amplitude sum andperlin3Dappliesmx_gradient_scale3d=0.9820, matching the official MaterialX GLSL reference (libraries/stdlib/genglsl/lib/mx_noise.glsl). Prior implementation made the noise term instandard_surface_marble_solid.mtlx1.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, therunStagecache-eviction race, and updated cache-key tests for the new MaterialX fields.npx svelte-checkclean.standard_surface_marble_solid.mtlxand the realTH_Pink_Cobblestone_Floor.zipagainstglyphid_praetorian.glband a benchy — voxelize completes cleanly, no warnings.BenchmarkSampleMarble: 0 allocs/op, 841 ns/op (single-octave marble graph)..mtlxand a PBR.zip, confirm tile-size + triplanar sharpness reactivity and the missing-file warning when a path no longer exists.🤖 Generated with Claude Code