MTL material fidelity: specular, alpha maps, Pr/Pm — #1575 items 1, 2 (scalars) and 3 - #1578
Merged
Conversation
The MTL parser recognised around twenty properties and consumed five, so an authored highlight was read and thrown away and an alpha map was rejected outright. Both destinations already existed. Specular (Ks + Ns) gives the lit mesh path a Blinn-Phong term where it was half-Lambert diffuse plus an ambient floor, so every material read as chalk. Two decisions worth recording: - Gated on the EXPONENT, not the colour. `Ns` of 0 is the format's "no highlight" and exporters routinely write a bright `Ks` beside it, so reading the colour alone would put a full-strength highlight on every matte material in the wild. - Masked by the UNWRAPPED Lambert term. Half-Lambert deliberately lifts the shadowed side, and reusing it would light a highlight on a surface facing away from the light. The eye position the half-vector needs is derived from the view matrix as -RT*t rather than plumbed from the camera, so it stays correct for any caller that sets a view directly. `map_d` drives alphaCutoff per texel instead of per material — the shape of a leaf rather than one threshold across a whole surface. It multiplies alpha BEFORE the cutout; the other order cuts nothing out. Both backends sample unconditionally and weight by a flag rather than branching: one backend branching and the other not is exactly how the emissive early-return diverged in #1572, and weighting also keeps the WGSL sample in uniform control flow. WebGPU needed the second texture. Group 1 for the mesh family grows from two bindings to four and MeshUniforms from 176 to 208 bytes. A mesh with no map binds its own diffuse texture as filler, so there is no extra unit and no extra upload, and the bind-group cache is keyed on the alpha record OBJECT — one diffuse shared by two meshes with different masks must not hand the second mesh the first's cut-outs. Widening the layout does not break the documented custom-WGSL mesh contract: a module may declare a subset of its layout's bindings. The glTF loader now maps metallic/roughness onto the same terms, which is where the factors every asset already carries can finally land. It is an approximation onto a stylized shading model, not a PBR implementation: roughness -> exponent through the usual GGX bridge, with the glTF DEFAULT roughness of 1 landing on exactly 0 so a scene that declares nothing is untouched; metallic -> tint between the dielectric F0 and the base colour. Note this changes no shipped example — every glTF asset in the repo is authored fully rough — so it is unblocking imported assets, not improving existing ones. Also folded in, unrelated to the above: - packages/examples/LICENSE.md had the multiMaterialMesh attribution cut mid-sentence by the Water Overworld paragraph. Pre-existing. - The #1573 unit-exhaustion spec cleared the SHARED session renderer's unit assignments without announcing it, leaving every batcher's boundTextures claiming units the cache no longer considered assigned. The example is reworked into a Camera3d scene showing all of it: the crate for #1573, a chrome ball for the highlight (smooth because its normals are generated, #1572), a perforated panel for the cutout. Textures regenerated at 256x256 with antiAlias on — at 64x64 they were magnified into blocks. Tests: +31, adversarial where the trap is silent — a bright Ks with Ns 0, an Ns with no Ks, the sample-before-cutout ordering pinned across all four shader sources, the specular/emissive uniform offsets, one diffuse with two masks, and the glTF defaults. That last one caught a real bug: a malformed roughnessFactor made the exponent NaN, failed the `> 0` test and fell into the mirror-smooth branch, turning a broken file into chrome. The four new uniforms were also being set unconditionally per draw while every other uniform in that method is change-guarded, which timed out a fuzz spec; all four are guarded now. WGSL verified against a real device (both tiers plus all four derived instanced variants, zero messages) since the in-tree validation spec skips without one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
…, scalars) The scalar half of item 2. I had deferred it as a PBR design question, then wrote exactly that mapping for the glTF loader an hour later — which made the MTL side nearly free and the deferral hard to justify. The mapping moves out of `gltf.js` into `loader/parsers/pbr.ts` and both loaders call it. That is the point of the change as much as the feature is: MTL's `Pr`/`Pm` and glTF's `pbrMetallicRoughness` describe one material concept, and approximating it in two places is how the two drift apart. - `Pr` / `Pm` are parsed, defaulting to `null` rather than a number: 0 is meaningful for both (mirror-smooth, non-metal), so "declared nothing" must stay distinguishable from "declared a mirror". - An explicit `Ks`/`Ns` WINS over the derived terms. Blender writes both blocks, so this precedence decides most real files; the explicit specular states what the artist wanted, the extension only implies it. - A fully-rough material derives nothing, which is what leaves existing scenes untouched. Still an approximation onto a stylized half-Lambert model, not a PBR shading model, and `map_Pr` / `map_Pm` are not consumed — those need the second-texture plumbing alongside #1574. Separately: `webgl_vao_adversarial`'s fuzz test gets an explicit 60s timeout. It runs in well under a second on its own but times out at 15s in a full run — the shared browser session slows as specs accumulate and a software rasterizer under load stretches several hundred GL ops by more than an order of magnitude. Same rationale as the config's 90s hookTimeout. It flaked before this branch too; cutting the iteration count to fit would have traded real fuzz coverage for a round number. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
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.
Items 1 and 3 of #1575. The MTL parser recognised around twenty properties and consumed five, so an authored highlight was read and thrown away and an alpha map was rejected outright — while both destinations already existed in the engine.
Specular —
Ks+NsThe lit mesh path was half-Lambert diffuse plus an ambient floor, so every material read as chalk whatever the
.mtlsaid. It now carries a Blinn-Phong term, exposed asmesh.specular/mesh.shininess.Two decisions worth calling out, because both are places the obvious implementation is wrong:
Nsof 0 is the format's "no highlight", and exporters routinely write a brightKsbeside it. Reading the colour alone would put a full-strength highlight on every matte material in the wild.The eye position the half-vector needs is derived from the view matrix as
-Rᵀ·trather than plumbed from the camera, so it stays correct for any caller that sets a view directly.Per-texel opacity —
map_dalphaCutoffcould only threshold uniformly across a material;map_dcuts to the shape of a leaf, a fence, a perforation. It ridesmesh.alphaMap, is fetched automatically by the MTL loader alongsidemap_Kd, and multiplies alpha before the cutout — the other order cuts nothing out, because a fragment that survived the test keeps its alpha whatever the map says.Both backends sample unconditionally and weight by a flag rather than branching. One backend branching and the other not is exactly how the emissive early-return diverged in #1572, and weighting also keeps the WGSL sample in uniform control flow.
WebGPU needed the second texture: group 1 for the mesh family grows from two bindings to four, and
MeshUniformsfrom 176 to 208 bytes (specular= rgb + exponent,eye= camera world position). A mesh with no map binds its own diffuse texture as filler — no extra unit, no extra upload — and the bind-group cache is keyed on the alpha record object, so one diffuse shared by two meshes with different masks cannot hand the second mesh the first's cut-outs.Widening the layout does not break the documented custom-WGSL mesh contract from #1564: a module may declare a subset of its layout's bindings, so an existing custom mesh shader declaring only texture+sampler still validates.
glTF metallic/roughness → the same terms
The loader read
baseColorTexture,baseColorFactorand the sampler wrap, and stopped — it never touchedmetallicFactororroughnessFactor, though every glTF asset carries them. They now map onto the terms this PR adds: roughness → exponent through the usual GGX bridge, metallic → tint between the dielectric F0 and the base colour.This is an approximation onto a stylized shading model, not a PBR implementation, and it is deliberately inert by default: the glTF default roughness of 1 lands on exactly 0, so a material that declares nothing shades exactly as before.
It changes no existing example. Every glTF asset in this repo —
platformer-diorama.glb(44 materials),character.glb,forest.glb— is authored fully rough, so nothing in the gallery moves a pixel. This unblocks assets users bring in from Blender; it does not improve what ships.Also folded in
packages/examples/LICENSE.mdhad themultiMaterialMeshattribution cut mid-sentence by the Water Overworld paragraph (pre-existing).GPU_TEXTURE_CACHE_RESET, leaving every batcher'sboundTexturesclaiming units the cache no longer considered assigned — the nextgetUnitcould hand one to a texture that never got bound.Example
The Per-material Textures example is reworked into a
Camera3dscene showing every piece at once: the crate for #1573, a chrome ball for the highlight (smooth because its normals are generated — #1572), and a perforated panel for the cutout. Textures regenerated at 256×256 withantiAliason; at 64×64 they were magnified into blocks. Verified rendering identically on WebGL and WebGPU.Verification
KswithNs 0, anNswith noKs, the sample-before-cutout ordering pinned across all four shader sources, the specular/emissive uniform offsets, one diffuse with two masks, and the glTF defaults. That last one caught a real bug: a malformedroughnessFactormade the exponent NaN, failed the> 0test, and fell into the mirror-smooth branch — turning a broken file into chrome.tscclean.Not in scope
#1575 items 2 (
Pr/Pmas a real PBR model) and 4 (Ka/illum) remain open, as does #1574 (normal maps) — which will reuse the second-texture plumbing this PR introduces.🤖 Generated with Claude Code
https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi