Skip to content

feat(gltf): material features Tier 2 + mesh-batcher perf + texture-filter decoupling#1506

Merged
obiot merged 10 commits into
masterfrom
feat/gltf-materials
Jun 22, 2026
Merged

feat(gltf): material features Tier 2 + mesh-batcher perf + texture-filter decoupling#1506
obiot merged 10 commits into
masterfrom
feat/gltf-materials

Conversation

@obiot

@obiot obiot commented Jun 20, 2026

Copy link
Copy Markdown
Member

glTF / OBJ material features (Tier 2) + 3D rendering improvements

Builds on the Tier-1 glTF/GLB loader (#1504). Started as the four day-1 material features; grew to include a mesh-batcher perf win, a texture-filtering API improvement, and a showcase example.

Material features (the original scope)

Each is a generic Mesh setting the loaders drive — usable from raw-geometry meshes too:

  • Mesh.textureFilter + glTF sampler filter"nearest" / "linear" magnification from magFilter; pixel-art models render crisp.
  • KHR_materials_unlit — baked-lighting materials render fullbright, not re-shaded.
  • Mesh.alphaCutoff + glTF alpha cutoutalphaMode: "MASK" → hard-edged discard (foliage / fences / decals), no blending or sorting.
  • Mesh.emissive + glTF/OBJ emissive — self-illumination from emissiveFactorKHR_materials_emissive_strength) and MTL Ke; glows independent of scene lights.

Performance

  • Allocation-free mesh batcherMeshBatcher.addMesh dedup'd vertices with a per-chunk Map that clear()ed each chunk; V8 drops the backing table on clear(), so re-filling reallocated → garbage ∝ vertex count. Replaced with a versioned typed-array remap (O(1) invalidation, arrays reused). ~7× less GC garbage and ~30% faster draw on a dense (~158k-vertex) scene; benefits all 3D mesh rendering.

Texture filtering decoupled from antiAlias

  • New textureFilter setting ("auto" | "nearest" | "linear", default "auto") separates texture sampling smoothness from polygon-edge MSAA — so you can have smooth textures with no MSAA, or crisp textures with MSAA edges (combinations the single antiAlias boolean couldn't express). "auto" = unchanged behavior; Mesh.textureFilter overrides per-mesh. Backend-neutral resolver on the base Renderer (future-proof for a WebGPU backend); Canvas has no per-texture filtering and ignores it.

Types

Example

  • Night City Flythrough — a big procedural downtown built entirely from raw mesh data (no asset): individually-glowing emissive windows + pole-mounted streetlights, dark ground + street grid, a seamless looping Camera3d flythrough, and a vignette post-effect. Showcases Mesh.emissive and the antiAlias/textureFilter combo. Also: gltf-scene example enables antiAlias.

Tests / docs

  • Adversarial specs for each feature (parser + WebGL pixel tests) and the texture-filter resolver/precedence; full suite 4276 passed / 15 skipped / 0 failed.
  • JSDoc (with examples), CHANGELOG, README perf line, and wiki updates (3D loading + Working-in-3D texture-filtering section).

Follow-up tickets filed

#1507 (static VBO cache), #1508 (instancing), #1509 (WebGL2-only + VAOs), #1510 (ObservableVector3d↔2d assignability).

🤖 Generated with Claude Code

obiot and others added 8 commits June 20, 2026 11:23
Materials flagged KHR_materials_unlit bake their own lighting and must not
be shaded again. The parser detects the extension per material; GLTFScene /
GLTFModel set `lit = sceneLit && !unlit` so an unlit prim renders fullbright
even in a lit scene (no double-lighting).

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

New `Mesh` `textureFilter` setting ("nearest" / "linear", WebGL) applied to the
resolved texture. The glTF loader reads each material's sampler `magFilter`, so
pixel-art-textured 3D models render crisp instead of blurred by the global
antiAlias default. Same image-global caveat as `textureRepeat` (#1503).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Hard alpha cutout for the WebGL mesh path: fragments whose final alpha
falls below a per-mesh threshold are discarded in the mesh / lit-mesh
shaders, for crisp foliage / fences / chain-link / decals with no
blending or back-to-front sorting.

- mesh.frag / mesh-lit.frag: new uAlphaCutoff uniform + discard guard
- MeshBatcher.addMesh: set the uniform (flush-free, per-mesh) when it
  changes, guarded behind the shader actually declaring it so custom
  mesh shaders are untouched
- Mesh.alphaCutoff setting (default 0 = disabled)
- glTF parser materialAlphaCutoff: alphaMode MASK -> alphaCutoff (def
  0.5); OPAQUE/BLEND -> 0. Wired through GLTFScene + GLTFModel
- tests: parser (6) + Mesh consumer (1) + WebGL end-to-end pixel (3,
  proving both shaders compile with the uniform and the discard works)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Self-illumination for the WebGL mesh path: a color added on top of the
lit/unlit result so a surface glows regardless of scene lights (neon,
lava, screens, glowing eyes).

- mesh.frag / mesh-lit.frag: new uEmissive vec3 uniform, added to the
  final color (after lighting in the lit path so it glows full strength)
- MeshBatcher.addMesh: set uEmissive flush-free per mesh with a per-
  channel redundant-set guard, behind the shader declaring the uniform;
  shared zero vector when a mesh has no emission
- Mesh.emissive (Float32Array(3) or undefined) + toEmissive() helper
  (all-zero collapses to undefined → lean path)
- glTF parser materialEmissive: emissiveFactor x
  KHR_materials_emissive_strength; wired through GLTFScene + GLTFModel
- MTL parser: Ke (Blender emission export); wired in Mesh single-
  material path
- tests: glTF parser (5) + MTL Ke (1) + WebGL end-to-end pixel (2,
  incl. uniform-reset-doesn't-leak)

Emissive textures (emissiveTexture / map_Ke) remain out of scope.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…emissive) to JSDoc

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
MeshBatcher.addMesh dedup'd vertices per chunk with a Map that was
clear()ed every chunk; V8 drops a Map's backing table on clear(), so
re-filling it reallocated as it grew — garbage proportional to vertex
count (MBs/sec of GC churn on a dense scene). Replaced with a versioned
typed-array remap: a per-chunk stamp invalidates every entry in O(1),
arrays grow once and are reused, so a re-drawn static mesh allocates
nothing per frame.

Measured on a ~158k-vertex scene: ~7x less GC garbage and ~30% faster
draw (11ms -> 7.6ms, Chrome/ANGLE). Benefits all 3D mesh rendering.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ata camera typing

textureFilter setting ("auto" | "nearest" | "linear", default "auto")
separates texture sampling smoothness from the polygon-edge MSAA that
antiAlias controls — so you can mix them (smooth textures + no MSAA, or
crisp pixel-art textures + MSAA edges), combinations the single boolean
couldn't express. "auto" follows antiAlias (byte-identical to before);
Mesh.textureFilter still overrides per-mesh.

- backend-neutral resolver Renderer.getDefaultTextureFilter() ("linear"/
  "nearest") on the base renderer so a future WebGPU backend reuses it;
  WebGL maps it to a GL enum via _glTextureFilter()
- base Renderer.setTextureFilter() records the setting (Canvas no-op);
  WebGL overrides to re-filter live textures (shared _reapplyTextureFilter)
- 2D Canvas has no per-texture filtering and ignores it
- adversarial tests (11): resolver truth table, explicit filter not
  clobbered by antiAlias toggle, per-mesh override precedence

Also tightens GLTFData.cameras from object[] to a typed shape with
perspective/yfov so scene.cameras[0].perspective?.yfov type-checks.

README/CHANGELOG: textureFilter + the allocation-free batcher perf note.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A big procedural downtown built entirely from raw mesh data (no asset) —
every lit window and street-lamp head is its own emissive geometry, so
they glow individually at night while the moonlit shells stay dark.
Pole-mounted streetlights, dark ground + street grid, a seamless looping
Camera3d flythrough, and a vignette post-effect. Showcases Mesh.emissive
and the decoupled antiAlias/textureFilter combo (MSAA edges + crisp).

Also: gltf-scene example uses antiAlias:true (looks better on the
low-poly diorama), shorter example descriptions, and the gltf-scene
camera uses pos.set(x,y) + depth=z (the inherited pos.set is typed
2-arg; see #1510). LICENSE credits the Kenney glTF kits; night-city
uses no external assets.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@obiot obiot changed the title feat(gltf): material features Tier 2 — texture filter, unlit, alpha cutout, emissive feat(gltf): material features Tier 2 + mesh-batcher perf + texture-filter decoupling Jun 22, 2026
obiot and others added 2 commits June 22, 2026 12:38
… parse

Code-review follow-ups (PR #1506):
- MeshBatcher.useShader override invalidates the per-mesh uAlphaCutoff /
  uEmissive caches when the bound GL program changes (mirrors the base
  batcher's currentSamplerUnit reset). Without it, a custom mesh shader
  declaring those uniforms, interleaved with the built-in shader on the
  same batcher, could be skipped via a stale cache and silently not apply
  the cutoff/emissive.
- materialEmissive: guard a malformed (short) emissiveFactor with || 0 so
  a missing component can't become NaN and reach the uEmissive uniform
  (NaN !== 0 would dodge the all-zero collapse). +1 adversarial test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CI lint (eslint src tests) enforces arrow-body-style: always; the new
malformed-emissiveFactor test used a concise arrow body. No logic change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@obiot obiot merged commit 7a42727 into master Jun 22, 2026
6 checks passed
@obiot obiot deleted the feat/gltf-materials branch June 22, 2026 06:07
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