Skip to content

MTL material fidelity: specular, alpha maps, Pr/Pm — #1575 items 1, 2 (scalars) and 3 - #1578

Merged
obiot merged 2 commits into
masterfrom
mtl-specular-alpha-1575
Aug 7, 2026
Merged

MTL material fidelity: specular, alpha maps, Pr/Pm — #1575 items 1, 2 (scalars) and 3#1578
obiot merged 2 commits into
masterfrom
mtl-specular-alpha-1575

Conversation

@obiot

@obiot obiot commented Aug 7, 2026

Copy link
Copy Markdown
Member

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 + Ns

The lit mesh path was half-Lambert diffuse plus an ambient floor, so every material read as chalk whatever the .mtl said. It now carries a Blinn-Phong term, exposed as mesh.specular / mesh.shininess.

Two decisions worth calling out, because both are places the obvious implementation is wrong:

  • 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. 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; reusing it for specular 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 -Rᵀ·t rather than plumbed from the camera, so it stays correct for any caller that sets a view directly.

Per-texel opacity — map_d

alphaCutoff could only threshold uniformly across a material; map_d cuts to the shape of a leaf, a fence, a perforation. It rides mesh.alphaMap, is fetched automatically by the MTL loader alongside map_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 MeshUniforms from 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, baseColorFactor and the sampler wrap, and stopped — it never touched metallicFactor or roughnessFactor, 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.md had the multiMaterialMesh attribution cut mid-sentence by the Water Overworld paragraph (pre-existing).
  • The Mesh: bind per-material textures at draw for multi-material models #1573 unit-exhaustion spec cleared the shared session renderer's unit assignments without emitting GPU_TEXTURE_CACHE_RESET, leaving every batcher's boundTextures claiming units the cache no longer considered assigned — the next getUnit could hand one to a texture that never got bound.

Example

The Per-material Textures example is reworked into a Camera3d scene 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 with antiAlias on; at 64×64 they were magnified into blocks. Verified rendering identically on WebGL and WebGPU.

Verification

  • +31 tests, 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.
  • A performance regression caught and fixed: the four new uniforms were 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 validated on a real device — both tiers plus all four derived instanced variants, zero compilation messages. The in-tree validation spec skips without a device, so this was done out of band.
  • Full suite 232 files / 5772 passed, eslint 0 errors, biome clean, build clean, examples tsc clean.

Not in scope

#1575 items 2 (Pr/Pm as 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

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
Copilot AI lite review requested due to automatic review settings August 7, 2026 10:56

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

…, 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
Copilot AI review requested due to automatic review settings August 7, 2026 11:16

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@obiot obiot changed the title MTL specular (Ks/Ns) and per-texel opacity (map_d) — #1575 items 1 and 3 MTL material fidelity: specular, alpha maps, Pr/Pm — #1575 items 1, 2 (scalars) and 3 Aug 7, 2026
@obiot
obiot merged commit dadcdb6 into master Aug 7, 2026
6 checks passed
@obiot
obiot deleted the mtl-specular-alpha-1575 branch August 7, 2026 11:50
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.

2 participants