feat(mesh): multi-material OBJ rendering#1465
Merged
Merged
Conversation
OBJ files with multiple `usemtl` directives (Kenney asset packs,
Blender / Maya exports, any model with painted panels) previously
rendered as a single uniform color in melonJS because `Mesh` bound
one material per mesh and the parser threw away `usemtl` boundaries.
OBJ parser now emits `groups: Array<{materialName, start, count}>`
alongside the existing geometry — each entry is a contiguous slice of
the shared index buffer that draws as one submesh against one
material. Field shape (`start`, `count`, plus a name pointing into a
material table) matches the Three.js / glTF "groups" convention so the
structure is familiar to anyone porting from those engines. Single-
material OBJs still produce a `groups` array of length 1, so
downstream code doesn't need a special case.
`Mesh` detects multi-material OBJs (multiple groups + at least one
named + an MTL bound via `material:`) and builds a per-group descriptor
carrying texture, tint, and opacity resolved from each named MTL
entry. `Mesh.draw()` iterates the descriptors, swapping
`renderer.currentTint` + `mesh.texture` per draw call — one draw per
material region, all sharing the same projected vertex buffer (no
geometry duplication, no per-frame allocation).
`renderer.drawMesh(mesh, group?)` gains an optional group argument
(matches Three.js's `renderer.renderBufferDirect(..., group)`) — when
provided, only the index slice `[group.start, group.start + group.count)`
is pushed to the GPU. WebGL and Canvas renderers updated. Depth clear
moved to "first group only" so per-material draws within one mesh
compose against each other for correct cross-material occlusion.
Kd-only Kenney-style models without `map_Kd` get a shared 1×1 white
texture fallback so the GPU pipeline still has something to sample —
the per-group `tint` does all the visible coloring. Allocated lazily
on first use, shared across every Mesh that needs it.
8 new parser tests cover: single-material default group, multi-usemtl
emission, anonymous null-material chunk for pre-usemtl faces,
triangulated quads inside groups, winding-fix preserves boundaries,
empty OBJ, trailing usemtl with no faces.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Loads four Kenney Space Kit (CC0) spacecraft (craft_speederA / B / racer / miner) and renders them rotating in 3D in a 2x2 grid. Each spacecraft has 3-5 `usemtl` material groups (metal / metalRed / dark / metalDark) — the new groups[] OBJ parser API + Mesh.draw per-group iteration paint each panel with its correct Kd color, instead of collapsing the whole model into one flat tint. Compare with the existing mesh3d example (single texture across the whole mesh): same Mesh class, no extra wiring beyond passing the MTL name through `material:`. The multi-material code path activates automatically when the OBJ has multiple `usemtl` directives + a matching MTL is bound. LICENSE.md updated to credit Kenney (CC0 — attribution not legally required, included as a courtesy, mirroring the pool-matter pool- table assets pattern). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tier 2 of the multi-material work: bake each material's Kd color into a per-vertex `aColor` attribute at construction time, then render the whole mesh in ONE draw call regardless of material count. Replaces the prior per-group draw iteration (one drawElements + texture bind per material) — same correct result, ~N× less GL state churn on WebGL, and Canvas no longer needs a multi-material code path at all. OBJ parser: per-material vertex dedup. Each `usemtl` switch resets the active `vertexMap`, so vertices shared across materials get separate slots in the unified vertex buffer. Required for per-vertex color baking — a vertex shared between two materials needs to carry both colors. Mesh constructor: when isMultiMaterial, allocate `vertexColors` (Uint32Array, one packed ARGB per vertex) and fill it by walking each group's index slice, assigning the group's Kd to every vertex it references. `mesh.groups` stays exposed for inspection; the `.tint` field is now informational (post-construction mutation is a no-op since colors are already baked). Runtime mutation happens through `mesh.tint` instead — multiplies on top of every baked color via the shader's existing `aColor` path. WebGL mesh batcher: new `mulPackedARGB` helper combines each vertex's baked color with the runtime `tint` (matches the ARGB packing from `Color.toUint32`). Single-material meshes (no `vertexColors`) take the unchanged fast path — same `tint` for every vertex push. WebGL renderer: reverted `drawMesh(mesh, group?)` back to single- arg `drawMesh(mesh)`. State setup runs once, batcher handles per- vertex color from the mesh. Canvas renderer: same single-arg signature. When `vertexColors` is present, per-triangle solid-fill reads `vertexColors[v0]` (all 3 vertices of a triangle share a material color by construction) multiplied by `currentTint`. One global painter's sort — no inter- group ordering glitches like the per-group approach had. Per-material textures aren't supported on Canvas (would need per-group passes); use WebGL for that case. Canvas also gains a 1×1 solid-fill fast path for Kd-only single- material meshes (a user could load an OBJ with one material whose MTL sets only `Kd`) — previously the affine drawImage produced sub-pixel artifacts at large triangle scale, now it solid-fills with the tinted color. MTL parser: dropped the obsolete "multiple materials detected — only the first material's texture will be used per mesh" warning (no longer true after tier 2), and switched the remaining MTL warnings from the deprecation helper `warning()` (which formats as "is deprecated since version undefined, please use undefined") to plain `console.warn`. Single-material meshes: zero behavior change, zero perf change. The multi-material path is gated on `isMultiMaterial` so nothing allocates or branches for the common single-material case. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Canvas multi-material rendering is correct but does per-triangle solid-fill in JS — 10-50× slower than the GPU rasterizer for the same scene. Force WebGL at the Application constructor so users opening the example see usable frame rates. Canvas remains supported in the engine for fallback / debug / correctness purposes; just not the default for this example. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Restructure example into named sections (layout / entry point / craft renderable / scene helpers) with extracted constants (CANVAS_W, CANVAS_H, MESH_SIZE, ROW_Y) — same behavior, easier to read at a glance - Trim verbose inline comments to one short sentence each; remove stale references to "per draw call" / tier-1 framing - Restate the file header in tier-2 terms: per-vertex color baking + single GPU draw, with `mesh.tint` as the runtime multiplier - Gallery description shifts from asset-attribution + implementation framing to engine-feature framing: "Rotating 3D models with multiple materials and per-mesh tinting — each material region picks up its diffuse color from the .mtl file, multiplied by a runtime tint." Matches the tone of the neighboring 3D Mesh / 3D Material gallery cards. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add the per-vertex baked color path + single-draw-call multi-material to the 19.7.0 unreleased section. Documents the OBJ parser groups[] emission, Mesh's per-vertex color baking, the renderer single-draw path (WebGL aColor / Canvas solid-fill), and runtime tinting on top. Calls out single-material backward compatibility (unchanged) and the new Multi-material OBJ showcase. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
This PR adds multi-material support for OBJ meshes by extending the OBJ parser to emit groups[] (per-usemtl index ranges) and updating mesh rendering to support per-vertex baked diffuse colors, plus a new example and expanded test coverage.
Changes:
- OBJ parser now emits
groups: Array<{ materialName, start, count }>trackingusemtlboundaries (with an anonymousnullgroup fallback). Meshcan bake per-material diffuse (Kd) + opacity into avertexColors: Uint32Array, and WebGL/Canvas mesh rendering paths use these colors.- Adds a new “Multi-material OBJ” example (plus Kenney CC0 assets) and new parser tests covering group emission edge cases.
Reviewed changes
Copilot reviewed 19 out of 19 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/melonjs/tests/mesh.spec.js | Adds OBJ parser tests for groups[] emission and boundary edge cases. |
| packages/melonjs/src/video/webgl/webgl_renderer.js | Updates drawMesh documentation to describe multi-material behavior. |
| packages/melonjs/src/video/webgl/batchers/mesh_batcher.js | Adds per-vertex color modulation (vertexColors × tint) when present. |
| packages/melonjs/src/video/canvas/canvas_renderer.js | Adds a solid-fill path for meshes with vertexColors (and 1×1 Kd fallback textures). |
| packages/melonjs/src/renderable/mesh.js | Adds groups resolution + baked vertexColors, and a 1×1 white-pixel texture fallback. |
| packages/melonjs/src/loader/parsers/obj.js | Adds usemtl parsing and emits groups[]; resets dedup scope per material. |
| packages/melonjs/src/loader/parsers/mtl.js | Removes obsolete multi-material warning and switches warnings to console.warn. |
| packages/examples/src/main.tsx | Registers the new “Multi-material OBJ” example route/entry. |
| packages/examples/src/examples/multiMaterialMesh/ExampleMultiMaterialMesh.tsx | New example showcasing multi-material OBJ color baking + runtime mesh.tint. |
| packages/examples/public/assets/multiMaterialMesh/craft_speederB.obj | New Kenney CC0 OBJ asset for the example. |
| packages/examples/public/assets/multiMaterialMesh/craft_speederB.mtl | New Kenney CC0 MTL asset for the example. |
| packages/examples/public/assets/multiMaterialMesh/craft_speederA.obj | New Kenney CC0 OBJ asset for the example. |
| packages/examples/public/assets/multiMaterialMesh/craft_speederA.mtl | New Kenney CC0 MTL asset for the example. |
| packages/examples/public/assets/multiMaterialMesh/craft_racer.obj | New Kenney CC0 OBJ asset for the example. |
| packages/examples/public/assets/multiMaterialMesh/craft_racer.mtl | New Kenney CC0 MTL asset for the example. |
| packages/examples/public/assets/multiMaterialMesh/craft_miner.obj | New Kenney CC0 OBJ asset for the example. |
| packages/examples/public/assets/multiMaterialMesh/craft_miner.mtl | New Kenney CC0 MTL asset for the example. |
| packages/examples/LICENSE.md | Adds CC0 credit for the Kenney Space Kit assets used by the new example. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Two outdated MTL parser comments removed: - Function header claimed "Multiple materials per mesh (`usemtl`) are not supported — only the first material is used" — now false since Mesh resolves per-material colors / textures via the OBJ `groups[]` emitted by the parser. - "(was: warn on multi-material — obsolete…)" inline comment near the `case "newmtl":` block — pure archaeology referencing a removed warning; the current code is self-explanatory. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two PR review threads pointed out that the multi-material work added
bare `document.createElement("canvas")` calls in Mesh and the Canvas
renderer — these throw in OffscreenCanvas / worker contexts where
`document` is undefined.
Introduce `Renderer.getWhitePixel()` — a lazily-created, shared 1×1
white canvas using `OffscreenCanvas` where supported and falling back
to `document.createElement` otherwise. Static (not instance-bound) so
it's reachable from `Mesh` construction before any active renderer
exists.
Mesh's `isMultiMaterial` Kd-only path now uses
`Renderer.getWhitePixel()` instead of its own local
`getOrCreateWhitePixel()`; CanvasRenderer's `_meshColorCanvas`
scratch sampler now goes through the same OffscreenCanvas-aware
allocation pattern.
Kept inline (not via `CanvasRenderTarget`) — that abstraction is
sized for full-blown render targets with their own GL context, way
heavier than what a 1x1 fallback needs. Also kept off the `video`
namespace — `video.createCanvas` is on a deprecation path; canvas
allocation is renderer-side concern now.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 20 out of 20 changed files in this pull request and generated 8 comments.
Comments suppressed due to low confidence (1)
packages/melonjs/src/video/canvas/canvas_renderer.js:594
- The
rawDet === 0(degenerate UV) fallback path still allocates_meshColorCanvasviadocument.createElement("canvas"). In worker/OffscreenCanvas contexts (which the earlier solid-fill path now explicitly supports),documentis undefined and this will throw. Consider using the sameglobalThis.OffscreenCanvas-aware allocation approach here as well.
} else if (rawDet === 0) {
// degenerate UV triangle — sample a solid color from the texture
// (common with color-palette models where all 3 UVs map to the same point)
context.closePath();
if (!this._meshColorCanvas) {
this._meshColorCanvas = document.createElement("canvas");
this._meshColorCanvas.width = 1;
this._meshColorCanvas.height = 1;
this._meshColorCtx = this._meshColorCanvas.getContext("2d");
}
Promotes canvas allocation from the `video` namespace (which is on a
deprecation path) to a static `Renderer.createCanvas(width, height,
returnOffscreenCanvas)` — the natural home for canvas-related
utilities that the renderer-side of the engine needs.
`Renderer.getWhitePixel()` now routes through it; CanvasRenderer's
multi-material 1×1 scratch sampler, CanvasRenderTarget, and TMXLayer
all migrated to `Renderer.createCanvas` (no deprecation warnings from
internal code).
`video.createCanvas` lives on as a deprecated forwarder in
`lang/deprecated.js` and is re-exported from `video.js` so existing
`video.createCanvas(...)` call sites keep working with a runtime
deprecation warning until users migrate. Standard
`warning("video.createCanvas", "Renderer.createCanvas", "19.7.0")`
notice, matching the existing CanvasTexture / setLineWidth patterns.
Also addresses several Copilot review findings in passing:
- Multi-material `Mesh`: `this.tint` no longer adopts the first
group's color (avoids double-multiplying the baked vertexColors
against `mesh.tint` at draw time)
- Single-material `Mesh`: now picks the MTL entry named by
`objGroups[0].materialName` when present, falling back to the first
entry only when no `usemtl` is set (fixes wrong-material selection
for OBJs whose `usemtl` targets a non-first MTL entry)
- Doc fixes: `resolveGroupMaterial` JSDoc field rename
(`material` → `materialName`), `Mesh.draw` comment retired the
stale tier-1 "one draw per groups[]" framing, obj.js docstring
consistent `materialName: null`
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previous version routed the deprecation forwarder through `deprecated.js`, which inadvertently exposed `createCanvas` as a top-level melonJS export via the blanket `export * from "./lang/deprecated.js"` re-export — but `createCanvas` was never a top-level export, only `video.createCanvas`. Polluting the top level for a never-top-level symbol breaks the implicit "deprecated symbols stay at the API position they had before deprecation" rule the existing entries (`CanvasTexture`, `Compositor` family) all follow. Move the implementation back to `video.js` (with the same `warning( "video.createCanvas", "Renderer.createCanvas", "19.7.0")` notice and forwarding to `Renderer.createCanvas`). `deprecated.js` no longer exports it — only `video.createCanvas` is exposed, matching the original API surface. Existing top-level deprecated classes (`CanvasTexture`, `Compositor`, `PrimitiveCompositor`, `QuadCompositor`) keep their top-level slots since those genuinely were top-level exports before being deprecated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…, doc accuracy Four findings from the second Copilot review pass: 1. **Single-material texture fallback** — `resolveTextureAtlas(undefined)` threw when a single-material `Mesh` was constructed without a `texture:` AND without a `material:` (or a `material:` whose MTL had no `map_Kd`). The white-pixel fallback was gated on `isMultiMaterial`, missing this case. Drop the gate — any path that ends with `textureSource` unresolved now falls through to `Renderer.getWhitePixel()`, GPU pipeline always has a sampler binding. 2. **Per-material vertex dedup cache** — the OBJ parser's `startGroup()` reset the vertex `Map` on every `usemtl`. For OBJs that interleave materials (`usemtl red ... usemtl blue ... usemtl red`), the second "red" block couldn't reuse its earlier deduplicated vertices, producing unnecessary duplication. Switch to a per-material `Map` keyed by `materialName`; same material reappearing hits its existing cache. Same vertex still gets distinct slots across DIFFERENT materials (the prerequisite for per-vertex color baking). 3. **`mesh_batcher.addMesh` doc wording** — said the shared tint was "multiplied per-vertex in the shader". Wrong — it's CPU-side via `mulPackedARGB` before `pushMesh`. Mesh shader just does `texture * aColor`. Fixed. 4. **CHANGELOG scope claim** — claimed per-material textures (each material with its own `map_Kd`) were supported via the per-vertex `aColor` path. Misleading — `aColor` carries color, not textures. Tier 2 supports per-material COLORS only; the whole mesh shares a single texture binding. Clarified the scope. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1. **canvas_renderer**: skip `cache.tint(image, ...)` when
`vertexColors` is present — the solid-fill path reads color from
the baked buffer per triangle and never samples the image, so
tinting the image is wasted work + allocation per frame.
2. **renderer.createCanvas**: gate the OffscreenCanvas path on
`device.offscreenCanvas` rather than a bare
`typeof globalThis.OffscreenCanvas !== "undefined"`. The device
capability check actually instantiates `new OffscreenCanvas(0,0)`
and verifies `.getContext("2d")` returns non-null inside a
try/catch — covers Safari's historical "OffscreenCanvas exists
but only does WebGL{1,2}" quirk. Without this, `getWhitePixel()`
could end up calling `getContext("2d")` on an OffscreenCanvas that
doesn't support it and crash.
3. **renderer.getWhitePixel**: guard `getContext("2d")` against null
with a descriptive error, so a failed allocation surfaces clearly
instead of a null-deref later.
4. **canvas_renderer solid-fill alpha**: ARGB-extract now reads the
A byte too and emits `rgba(...)` — preserves per-material MTL `d`
opacity baked into `vertexColors`. Previously dropped silently.
5. **canvas_renderer degenerate-UV fallback**: the `rawDet === 0`
branch's `_meshColorCanvas` allocation now routes through
`Renderer.createCanvas(1, 1, true)` like the `solidFillKd` branch
above — was the last `document.createElement("canvas")` left in
the mesh path, would throw in worker / OffscreenCanvas contexts.
6. **Mesh per-group texture storage dropped**: `resolveGroupMaterial`
no longer resolves a per-group `texture` field, since tier 2's
mesh shader has a single `uSampler` binding shared across the
whole mesh — per-material textures aren't switched at draw time.
The shared `this.texture` falls back to the first group's
`map_Kd` if any group has one, otherwise to the 1×1 white pixel.
Removes a misleading field on `mesh.groups[*]` that the render
path never read.
7. **mtl.js header comment**: dropped — explained a non-choice
("we're using console.warn because warning() is for deprecation")
that's self-evident from looking at `lang/console.js`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1. **obj parser tab tolerance** — `mtllib` / `usemtl` detection used
`startsWith("mtllib ")` (single-space prefix), which fails for valid
OBJ files that separate the keyword from its argument with tabs or
multiple spaces. Switched to the same `split(/\s+/)` tokenization
the `v/vt/f` paths use, then check `parts[0]` against the keyword.
2. **Renderer ↔ CanvasRenderTarget circular import resolved** —
`CanvasRenderTarget` imported `Renderer` for `Renderer.createCanvas`,
while `Renderer` already imported `CanvasRenderTarget` (used in its
constructor). The cycle works today via ES module hoisting but is
brittle. Extracted the canvas allocator into a new
`video/canvas_factory.js` helper that both can import without
referencing each other. `Renderer.createCanvas` becomes a thin
re-export of the helper, so the public-facing API is unchanged.
`CanvasRenderTarget` and `TMXLayer` migrated to importing the
helper directly.
3. **"Single draw call" wording corrected in 5 places** — Mesh.draw
doc, vertexColors-field doc, mesh_batcher.addMesh doc,
webgl_renderer.drawMesh doc, and CHANGELOG line 10 all overstated
the guarantee. `MeshBatcher.addMesh` still chunks very large meshes
across multiple flushes to respect vertex/index buffer limits —
what multi-material rendering actually guarantees is **no extra
draw calls per material** vs single-material rendering, not a
blanket "single draw call regardless of size". Rephrased
consistently across all five sites.
README bullet (root + packages/melonjs/) updated to mention
multi-material support in the existing 3D mesh feature line.
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
groups: Array<{materialName, start, count}>matching the Three.js / glTF convention — everyusemtlswitch opens a new group slicing the shared index buffer.Meshconstructor bakes each material'sKddiffuse color into a per-vertex color buffer at construction time. Vertices on material boundaries are split during parsing so every vertex carries exactly one material's color.aColor(WebGL) / per-triangle solid-fill fromvertexColors[v0](Canvas) supplies the right color per face.mesh.tintmultiplies on top at render time, so flash / fade / team-color viasetTintwork exactly as today.Multi-material OBJexample loads four Kenney Space Kit spacecraft (CC0, credited inLICENSE.md), gives each one a team color viamesh.tint, and rotates them in a 2×2 grid.Single-material meshes are unchanged — same code path, zero new allocations, identical behavior.
Why
melonJS's
Meshpreviously bound one material per mesh — multi-material OBJs (Kenney packs, Blender exports, anything with painted panels) collapsed to a single uniform color because the parser threw awayusemtlboundaries. This change adds proper per-material rendering without giving up the engine's single-draw-call goal.Implementation notes
usemtlresets the active vertexMap so shared (v, vt) pairs across materials get distinct unified-vertex slots — required for per-vertex color baking. Pre-usemtlfaces go into an anonymous null-material group so thegroups[]contract is always non-empty for non-empty OBJs.isMultiMaterialgate keeps the existing single-material code path untouched. When triggered, allocates avertexColors: Uint32Arrayand fills it by walking each group's index slice.mesh.groupsstays exposed for inspection; the per-grouptintfield is informational under tier 2 (post-construction mutation is a no-op since colors are baked).mulPackedARGBhelper combines per-vertex baked color with the runtime tint (matchesColor.toUint32packing). Single-material meshes (novertexColors) take the unchanged fast path.vertexColorsis present, per-triangle solid-fill readsvertexColors[v0](all 3 vertices of a triangle share a material color by construction) ×currentTint. Single global painter's sort — no inter-group ordering glitches. Per-material textures aren't supported on Canvas (use WebGL for that case).warning()(which formats as "deprecated since version undefined") to plainconsole.warn.groupsarray so consumers don't need a special case. Existingmesh3d/mesh3dMaterialexamples render identically to before.Test plan
usemtlemission, anonymous null-material chunk for pre-usemtlfaces, triangulated quads inside groups, winding-fix preserving boundaries, empty OBJ, trailingusemtlwith no facesmesh3d+mesh3dMaterialexamples render identically — visually verifiedmulti-material-meshexample renders correctly on both WebGL and Canvas; all 4 ships show distinct per-material colors through the full rotationmesh.tintruntime multiplication works on top of baked colors (team colors in the example)🤖 Generated with Claude Code