diff --git a/packages/examples/public/assets/materialTextures/crate-label.png b/packages/examples/public/assets/materialTextures/crate-label.png
new file mode 100644
index 000000000..845ff3f38
Binary files /dev/null and b/packages/examples/public/assets/materialTextures/crate-label.png differ
diff --git a/packages/examples/public/assets/materialTextures/crate-metal.png b/packages/examples/public/assets/materialTextures/crate-metal.png
new file mode 100644
index 000000000..f0e4a6932
Binary files /dev/null and b/packages/examples/public/assets/materialTextures/crate-metal.png differ
diff --git a/packages/examples/public/assets/materialTextures/crate-wood.png b/packages/examples/public/assets/materialTextures/crate-wood.png
new file mode 100644
index 000000000..3f35e9d4d
Binary files /dev/null and b/packages/examples/public/assets/materialTextures/crate-wood.png differ
diff --git a/packages/examples/public/assets/materialTextures/crate.mtl b/packages/examples/public/assets/materialTextures/crate.mtl
new file mode 100644
index 000000000..dc7643fe9
--- /dev/null
+++ b/packages/examples/public/assets/materialTextures/crate.mtl
@@ -0,0 +1,14 @@
+# Three materials, three diffuse maps. `Kd` stays near-white on each so the
+# textures speak for themselves — the per-material tint path is already shown
+# by the multiMaterialMesh example.
+newmtl wood
+Kd 1.0 0.95 0.9
+map_Kd crate-wood.png
+
+newmtl metal
+Kd 0.95 0.97 1.0
+map_Kd crate-metal.png
+
+newmtl label
+Kd 1.0 1.0 1.0
+map_Kd crate-label.png
diff --git a/packages/examples/public/assets/materialTextures/crate.obj b/packages/examples/public/assets/materialTextures/crate.obj
new file mode 100644
index 000000000..5903d0cbd
--- /dev/null
+++ b/packages/examples/public/assets/materialTextures/crate.obj
@@ -0,0 +1,53 @@
+# melonJS example asset — a supply crate with three textured materials.
+#
+# Authored for the per-material texture example (#1573): the four sides are
+# wooden boards, the lid and floor are steel plate, and a shipping label sits
+# just proud of the front face. Three `usemtl` groups, three different
+# `map_Kd` maps — which is exactly what a single shared texture binding
+# cannot render.
+mtllib crate.mtl
+
+v -1 -1 1
+v 1 -1 1
+v 1 1 1
+v -1 1 1
+v -1 -1 -1
+v 1 -1 -1
+v 1 1 -1
+v -1 1 -1
+v -0.62 -0.62 1.02
+v 0.62 -0.62 1.02
+v 0.62 0.62 1.02
+v -0.62 0.62 1.02
+
+vt 0 0
+vt 1 0
+vt 1 1
+vt 0 1
+
+vn 0 0 1
+vn 0 0 -1
+vn -1 0 0
+vn 1 0 0
+vn 0 1 0
+vn 0 -1 0
+
+usemtl wood
+f 1/1/1 2/2/1 3/3/1
+f 1/1/1 3/3/1 4/4/1
+f 2/1/4 6/2/4 7/3/4
+f 2/1/4 7/3/4 3/4/4
+f 6/1/2 5/2/2 8/3/2
+f 6/1/2 8/3/2 7/4/2
+f 5/1/3 1/2/3 4/3/3
+f 5/1/3 4/3/3 8/4/3
+
+usemtl metal
+f 4/1/5 3/2/5 7/3/5
+f 4/1/5 7/3/5 8/4/5
+f 5/1/6 6/2/6 2/3/6
+f 5/1/6 2/3/6 1/4/6
+
+usemtl label
+f 9/1/1 10/2/1 11/3/1
+f 9/1/1 11/3/1 12/4/1
diff --git a/packages/examples/src/examples/afterBurner/ExampleAfterBurner.tsx b/packages/examples/src/examples/afterBurner/ExampleAfterBurner.tsx
index 2004dd939..83fdfe34b 100644
--- a/packages/examples/src/examples/afterBurner/ExampleAfterBurner.tsx
+++ b/packages/examples/src/examples/afterBurner/ExampleAfterBurner.tsx
@@ -26,10 +26,13 @@ import {
Application,
audio,
Camera3d,
+ Color,
event,
input,
+ Light3d,
loader,
state,
+ Vector3d,
video,
} from "melonjs";
import { createExampleComponent } from "../utils";
@@ -131,6 +134,27 @@ const createGame = async () => {
const controller = new GameController(app);
app.world.addChild(controller);
+ // Lights for the OBJ craft. They shade against the vertex normals
+ // the OBJ loader now supplies (#1572) — without them a `lit` mesh
+ // has no surface to shade and reads flat whatever the lighting.
+ // A key light plus an ambient fill so the shadowed side stays
+ // readable rather than black.
+ app.world.addChild(
+ new Light3d(0, 0, 0, {
+ type: "directional",
+ direction: new Vector3d(-0.35, -0.7, -0.6),
+ color: new Color(255, 244, 226),
+ intensity: 1.2,
+ }),
+ );
+ app.world.addChild(
+ new Light3d(0, 0, 0, {
+ type: "ambient",
+ color: new Color(128, 146, 178),
+ intensity: 0.6,
+ }),
+ );
+
// Start the music loop via `playTrack` (not `play`) — that
// registers BGM_NAME as the engine's currentTrack, which
// in turn hooks the engine's built-in pause-on-blur
diff --git a/packages/examples/src/examples/afterBurner/Plane.ts b/packages/examples/src/examples/afterBurner/Plane.ts
index de72d65a4..a7cb9db94 100644
--- a/packages/examples/src/examples/afterBurner/Plane.ts
+++ b/packages/examples/src/examples/afterBurner/Plane.ts
@@ -34,6 +34,11 @@ export class Plane extends Mesh {
width: settings.size,
height: settings.size,
cullBackFaces: true,
+ // OBJ models carry vertex normals since #1572 — authored `vn`
+ // where the file has them, generated from face geometry where it
+ // does not — so they shade against the modelled surface instead
+ // of reading flat under the scene lights.
+ lit: true,
});
// Mesh defaults anchor to (0.5, 0.5), but Renderable.preDraw
// applies `translate(-anchorPoint * width)` on top of the
diff --git a/packages/examples/src/examples/materialTextures/ExampleMaterialTextures.tsx b/packages/examples/src/examples/materialTextures/ExampleMaterialTextures.tsx
new file mode 100644
index 000000000..0abafc156
--- /dev/null
+++ b/packages/examples/src/examples/materialTextures/ExampleMaterialTextures.tsx
@@ -0,0 +1,158 @@
+/**
+ * melonJS — Per-material diffuse textures on a multi-material OBJ.
+ *
+ * One supply crate, built from a `crate.obj` + `crate.mtl` pair whose three
+ * materials each declare their own `map_Kd`: wooden boards on the sides,
+ * steel plate on the lid and floor, a shipping label on the front. All the
+ * example does is preload the pair and construct a `Mesh` — the `Mesh`
+ * resolves each material's own texture and reduces them to the shortest list
+ * of index ranges that need switching (`mesh.textureGroups`), which the GPU
+ * batchers draw as one indexed range each over the same buffers. Adjacent
+ * materials sharing a map are merged, so this crate costs three draws rather
+ * than one per material.
+ *
+ * Companion to the `multiMaterialMesh` example, which shows the other half of
+ * the multi-material path: per-material diffuse *colour* (`Kd`), baked into a
+ * per-vertex colour buffer at construction and multiplied by a runtime tint.
+ *
+ * Copyright (C) 2011 - 2026 AltByte Pte Ltd — MIT License.
+ */
+import { DebugPanelPlugin } from "@melonjs/debug-plugin";
+import type { CanvasRenderer, WebGLRenderer } from "melonjs";
+import {
+ Application,
+ loader,
+ Mesh,
+ plugin,
+ Renderable,
+ Vector3d,
+ video,
+} from "melonjs";
+import { createExampleComponent } from "../utils";
+
+// ─── layout & content ─────────────────────────────────────────────
+
+const CANVAS_W = 1024;
+const CANVAS_H = 768;
+const CRATE_SIZE = 250;
+const CRATE_Y = 400;
+// caption sits in the empty band above the crate
+const CAPTION_Y_PCT = 13;
+
+const ASSET_BASE = `${import.meta.env.BASE_URL}assets/materialTextures/`;
+
+// ─── entry point ──────────────────────────────────────────────────
+
+const createGame = async () => {
+ // per-material texture switching is a GPU-backend feature: the Canvas
+ // renderer solid-fills multi-material meshes per triangle and never
+ // samples a texture at all, so there would be nothing to see.
+ let app: Application;
+ try {
+ app = new Application(CANVAS_W, CANVAS_H, {
+ parent: "screen",
+ renderer: video.AUTO,
+ scale: "auto",
+ });
+ await app.init();
+ if (!app.renderer.supportsDepthBuffer) {
+ throw new Error("no GPU backend available (Canvas fallback)");
+ }
+ } catch (err) {
+ const reason = err instanceof Error ? err.message : String(err);
+ globalThis.alert(
+ "This example couldn't start: no GPU rendering is available in this browser.\n\n" +
+ "Per-material mesh textures need a WebGPU- or WebGL-capable " +
+ "browser/GPU. Try enabling hardware acceleration in your browser " +
+ "settings, or open this example in a different browser.\n\n" +
+ `Details: ${reason}`,
+ );
+ throw err;
+ }
+
+ app.world.backgroundColor.parseCSS("#12161d");
+ plugin.register(DebugPanelPlugin, "debugPanel");
+
+ // only the .obj and .mtl are listed: the MTL loader fetches the three
+ // `map_Kd` images itself, relative to the .mtl
+ loader.preload(
+ [
+ { name: "crate", type: "obj", src: `${ASSET_BASE}crate.obj` },
+ { name: "crate", type: "mtl", src: `${ASSET_BASE}crate.mtl` },
+ ],
+ () => {
+ app.world.addChild(new SpinningCrate(CANVAS_W / 2, CRATE_Y));
+ spawnCaption(app);
+ },
+ );
+};
+
+// ─── per-crate renderable ─────────────────────────────────────────
+
+const AXIS_Y = new Vector3d(0, 1, 0);
+const AXIS_X = new Vector3d(1, 0, 0);
+
+/**
+ * The slowly turning crate. No texture wiring at all: `material:` names the
+ * preloaded MTL, and each material's `map_Kd` follows from it. Passing an
+ * explicit `texture:` here would pin one binding over the whole model
+ * instead — that is how a caller opts out of the split.
+ */
+class SpinningCrate extends Renderable {
+ mesh: Mesh;
+
+ constructor(x: number, y: number) {
+ super(0, 0, CANVAS_W, CANVAS_H);
+ this.anchorPoint.set(0, 0);
+ this.mesh = new Mesh(x, y, {
+ model: "crate",
+ material: "crate",
+ width: CRATE_SIZE,
+ height: CRATE_SIZE,
+ cullBackFaces: true,
+ });
+ // tilted forward so the steel lid is in view alongside the boards and
+ // the label — all three materials on screen from the first frame
+ this.mesh.rotate(0.5, AXIS_X);
+ }
+
+ override update(dt: number): boolean {
+ this.mesh.rotate(dt * 0.0005, AXIS_Y);
+ return true;
+ }
+
+ override draw(renderer: WebGLRenderer | CanvasRenderer): void {
+ this.mesh.preDraw(renderer);
+ this.mesh.draw(renderer);
+ this.mesh.postDraw(renderer);
+ }
+}
+
+// ─── caption ──────────────────────────────────────────────────────
+
+/**
+ * An HTML caption over the canvas, naming the three materials in view. The
+ * canvas is scaled by `scale: "auto"`, so the position is a percentage of
+ * the wrapper element and stays put at any display size.
+ */
+function spawnCaption(app: Application) {
+ const parent = app.renderer.getCanvas().parentElement;
+ if (!parent) {
+ return;
+ }
+ parent.style.position = "relative";
+
+ const el = document.createElement("div");
+ el.innerHTML =
+ '
three materials, three diffuse maps
' +
+ '
' +
+ "wood boards · steel plate · shipping label — one .obj, one .mtl, three draw ranges" +
+ "
";
+ el.style.cssText =
+ "position:absolute;color:#e6e9ef;font-family:'Courier New',monospace;" +
+ "text-align:center;text-shadow:0 0 5px #000;z-index:1000;pointer-events:none;" +
+ `transform:translate(-50%,-50%);left:50%;top:${CAPTION_Y_PCT}%;`;
+ parent.appendChild(el);
+}
+
+export const ExampleMaterialTextures = createExampleComponent(createGame);
diff --git a/packages/examples/src/main.tsx b/packages/examples/src/main.tsx
index a40412819..8ca1222b1 100644
--- a/packages/examples/src/main.tsx
+++ b/packages/examples/src/main.tsx
@@ -163,6 +163,11 @@ const ExampleMultiMaterialMesh = lazy(() =>
default: m.ExampleMultiMaterialMesh,
})),
);
+const ExampleMaterialTextures = lazy(() =>
+ import("./examples/materialTextures/ExampleMaterialTextures").then((m) => ({
+ default: m.ExampleMaterialTextures,
+ })),
+);
const ExamplePlatformer = lazy(() =>
import("./examples/platformer/ExamplePlatformer").then((m) => ({
default: m.ExamplePlatformer,
@@ -485,6 +490,14 @@ const examples: {
description:
"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.",
},
+ {
+ component: ,
+ label: "Per-material Textures",
+ path: "material-textures",
+ sourceDir: "materialTextures",
+ description:
+ "A crate whose wood, steel and label materials each carry their own diffuse map from the .mtl — resolved into one indexed draw range per texture.",
+ },
{
component: ,
label: "Platformer",
diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md
index c4fce71ab..8c1d14b8c 100644
--- a/packages/melonjs/CHANGELOG.md
+++ b/packages/melonjs/CHANGELOG.md
@@ -12,6 +12,8 @@
- **Backend-neutral vertex formats and draw topologies** ([#1551](https://github.com/melonjs/melonJS/issues/1551)) — a vertex attribute can now be declared with a single `format` token (`"float32x3"`, `"unorm8x4"`) instead of a `size` + `type` + `normalized` triple, and a draw mode with a topology name (`"triangle-list"`, `"line-list"`). `Batcher.addAttribute` accepts three forms — a descriptor object, `(name, format, offset)`, and the existing `(name, size, glType, normalized, offset)` — and `Batcher.mode` accepts either vocabulary while still reading back as the GL enum. `Batcher.topology` is the new portable spelling. **The GL-enum form is supported indefinitely**, so custom batchers need no changes. Groundwork for [#1184](https://github.com/melonjs/melonJS/issues/1184): a format-declared layout needs no live rendering context, and describes itself to any backend. `VertexFormat` / `Topology` types and the `isVertexFormat` / `isTopology` / `resolveVertexFormat` / `PORTABLE_TOPOLOGIES` helpers are exported
- **A `"none"` blend mode on both GPU backends** — `setBlendMode("none")` disables blending outright (the source replaces the destination, alpha included). It was born as a WebGPU pipeline blend state; the WebGL renderer now honors it identically instead of silently falling back to `"normal"`. The related `setBlendEnabled`, `enableScissor` and `clearRenderTarget` renderer methods — WebGL-only before — are implemented on the WebGPU renderer as well, along with custom batcher overrides (`settings.batcher`/`settings.compositor`), the `settings.blendMode` startup value, `GPUVendor` (from the adapter info), and `failIfMajorPerformanceCaveat` (rejects a software fallback adapter, falling through to WebGL under AUTO)
- **Gradient and Text textures stopped power-of-two rounding** ([#1554](https://github.com/melonjs/melonJS/issues/1554)) — two allocation-stability schemes replace it. Gradients now rasterize into a **fixed 256×256 shared bake target** regardless of on-screen size and are stretched by the destination quad (visually equivalent: linear stop interpolation × linear texture filtering — verified pixel-identical on all three backends): the shared canvas is allocated once and never resized, every re-bake is a same-size texture update, and gradient memory is capped at 256 KB instead of growing with the largest gradient drawn. Text canvases now round to **32-pixel buckets** (grow-only, as before) instead of the next power of two: a ticking counter still re-bakes into identical dimensions (the cheap same-size upload path on every backend), while worst-case memory waste drops from up to 2× per axis to at most 31 px per axis
+- **OBJ models carry vertex normals, so they can be lit** ([#1572](https://github.com/melonjs/melonJS/issues/1572)) — the OBJ parser read `vn` and discarded it, so `lit: true` on an OBJ shaded against a fallback while the same model imported from glTF lit correctly. Authored normals (`v//vn` and `v/vt/vn`) now reach the mesh, and a vertex shared between *different* normals is split so hard edges stay hard. A file supplying no normals gets them **generated** from face geometry — area-weighted, accumulated and normalized, i.e. smooth — computed after the parser's winding correction so they follow the final triangle orientation rather than the authored one. Normals are stored raw: the Y/Z axis bridge is applied at draw through the model matrix, exactly as it is for glTF. Smoothing groups (`s`) are still ignored, so a model relying on them for hard edges reads softer than authored; supply `vn` to control that precisely
+- **Per-material diffuse textures on a multi-material model** ([#1573](https://github.com/melonjs/melonJS/issues/1573)) — a multi-material OBJ bound whichever material's `map_Kd` came first for the *whole* model, so a crate with wood sides and a steel lid rendered entirely in wood. Each material's diffuse **colour** (`Kd`) already composed correctly — it is baked into a per-vertex colour buffer at construction — which made the asymmetry the confusing part. The `Mesh` now resolves each material's own texture and reduces the result to the shortest list of index ranges that actually need switching, exposed as `mesh.textureGroups`; both GPU backends draw one indexed range per entry over the same buffers (`drawElements` at a byte offset on WebGL, `drawIndexed` with a `firstIndex` on WebGPU), instanced meshes included. Adjacent materials sharing a map are merged, a material with no `map_Kd` of its own keeps the mesh-level texture, and a model that needs no split — every single-material one, and every `Kd`-only multi-material one — issues **exactly the one draw call it always did**. An explicit `texture:` still pins one binding over the whole model, and a per-material `map_Kd` naming an image that never loaded warns and falls back to the mesh-level texture — the mesh-level one itself still throws when it cannot be resolved, as it always has. The Canvas renderer is unaffected: it solid-fills multi-material meshes per triangle and never samples a texture. See the new **Per-material Textures** example
- **Mesh instancing** ([#1508](https://github.com/melonjs/melonJS/issues/1508)) — the new `InstancedMesh` draws one geometry many times in a **single call**, so cost scales with the number of instances rather than with `instances × vertices`. A forest of 100 000 trees is one 52-vertex geometry on the GPU plus a compact per-instance record each, instead of 100 000 copies of identical geometry — see the new **Instanced Forest** example, which renders exactly that at 60 fps on both GPU backends. `InstancedMesh` extends `Mesh`, so every existing setting works unchanged (`model` + `material` from an OBJ, raw geometry, `lit`, `cullBackFaces`, `rightHanded`, `tint`, `textureRepeat`, a custom `shader`); what it adds is the instance buffer. A record always carries a transform — packed as a **3×4 affine** rather than a full `mat4`, since the bottom row of an affine matrix is always `(0,0,0,1)` — plus two **opt-in** slots: `instanceColors` gives each instance a colour multiplied into the mesh tint, and `instanceData` gives it an opaque `vec4` that the built-in shading reads as emissive and a custom mesh shader may read as anything at all (a wind phase, an atlas offset, a random seed). Nobody pays for a slot they did not declare: the shader variants are compiled per declared combination, on first use. Placement is uniform-driven exactly as it is for a retained mesh, so **moving the whole group re-uploads nothing** and moving one instance re-uploads only that record; `visibleInstanceCount` draws the first N without touching the buffer at all, which is a distance-LOD knob costing one integer. `getBounds3d()` covers every instance so the group frustum-culls as one object. Requires a GPU backend (`renderer.supportsInstancing`, the new capability flag); the Canvas renderer falls back to drawing each instance individually — correct, and as slow as the scene it replaces
- **glTF `EXT_mesh_gpu_instancing`** ([#1508](https://github.com/melonjs/melonJS/issues/1508)) — authored instancing loads with no user code. A glTF node may carry per-instance `TRANSLATION` / `ROTATION` / `SCALE` accessors instead of being duplicated N times, which is what exporters write for linked duplicates; `level.load()` now turns such a node into an `InstancedMesh` while ordinary nodes stay ordinary meshes. `ROTATION` is accepted as float or as normalized `BYTE`/`SHORT` (the encoding exporters use to shrink large scatters), and any of the three attributes may be absent, taking its glTF default
- **`Mesh.needsUpdate`** ([#1507](https://github.com/melonjs/melonJS/issues/1507)) — signal that a mesh's geometry was edited in place (`originalVertices`, `uvs`, `indices`, normals or per-vertex colours), so the GPU copy is refreshed on the next draw. Moving, rotating, scaling, re-tinting or fading a mesh needs no signal — those are applied when drawing, not stored in the geometry
@@ -61,6 +63,7 @@ The old path scales linearly with vertex count; the new one is flat, because no
- **the cost of `antiAlias: true` under post effects, quantified** ([#1556](https://github.com/melonjs/melonJS/issues/1556)) — MSAA composing through effect chains (see *Added*) is paid for in memory and bandwidth, and the price is worth knowing. Arithmetic, not measurement: a 4× capture target keeps 4 color + 4 depth-stencil samples per pixel next to its 1× resolve texture — roughly **28 extra bytes per pixel on WebGL (~55 MB of GPU memory at 1080p)** and **~16 bytes per pixel on WebGPU (~32 MB at 1080p)**, where the multisampled depth attachment is shared with the canvas rather than per-target; both scale linearly with resolution. Per frame it adds one resolve blit per effect bracket, and draws inside the bracket write up to 4 samples per covered pixel — bandwidth, not shading cost, since fragment shaders still run once per pixel under MSAA. Only scene **capture** targets pay any of this (ping-pong intermediates stay 1×), and with `antiAlias: false` — the default — no multisampled storage exists at all, so nothing changes
### Fixed
+- **a mesh marked `lit` with no usable normals rendered solid black** — normalizing a zero-length normal yields NaN, which the shader turned into black fragments rather than something recognisable. That happens whenever `lit: true` meets geometry with no normals, and on the 2D-camera path generally, where world normals are never written. Such a mesh now degrades to **unlit** on both GPU backends: wrong, but recognisably the model instead of a hole in the scene. Note this makes the failure legible, it does not make a `Camera2d` mesh light — populating world normals on that path is tracked separately as [#1576](https://github.com/melonjs/melonJS/issues/1576) and remains open
- **`DropShadowEffect` rendered its shadow vertically mirrored (up instead of down) when chained with other effects on WebGL** — the pooled multi-effect path composites through capture FBOs, which are bottom-up under GL, so the y component of any directional UV arithmetic inside an effect body ran inverted relative to the single-effect fast path (and to the WebGPU backend, whose captures are top-down on both paths). Found by cross-backend comparison — earlier pixel-count probes were direction-blind. Effect bodies can now declare a `uUVYDir` uniform that the renderer feeds per draw path (+1 where `uv.y` grows downward, −1 on the GL pooled path); DropShadow uses it, so a positive `offsetY` means *down* on every path of both backends; `ShineEffect` adopts it too, so an angled sweep travels the documented direction (π/2 = top→bottom) on the pooled path as well
- **a scene containing only meshes stopped clearing its depth buffer after the first frame, and its geometry disappeared** — a regression from the 19.7 mesh state-ownership work ([#1468](https://github.com/melonjs/melonJS/issues/1468)), found while working on [#1552](https://github.com/melonjs/melonJS/issues/1552). The depth clear and the lit-mesh light upload both ran from `MeshBatcher.bind()`, which is a per-*transition* hook, not a per-frame one: `setBatcher` returns early when the requested batcher is already current. A scene with nothing else to draw — no sprites, no UI, no unlit mesh beside a lit one — therefore bound once and never again, leaving the depth attachment on the first frame's values, so anything receding from the camera failed the depth test and was not drawn at all. The same silence froze `Light3d` lighting at its first-frame values on such a scene. Both now refresh on the draw path, at no measurable cost (one boolean test per draw for the depth clear; the light upload is skipped outright when the lights have not changed)
diff --git a/packages/melonjs/src/loader/parsers/obj.js b/packages/melonjs/src/loader/parsers/obj.js
index b6a5bd84f..f406f2c28 100644
--- a/packages/melonjs/src/loader/parsers/obj.js
+++ b/packages/melonjs/src/loader/parsers/obj.js
@@ -18,10 +18,55 @@ const UV_STRIDE = 2;
// sentinel for missing UV index
const NO_UV = -1;
+// OBJ vertex-normal line prefix
+const NORMAL_PREFIX = "vn";
// OBJ indices are 1-based
const OBJ_INDEX_OFFSET = 1;
+// largest unified vertex count a Uint16 index buffer can address
+const UINT16_VERTEX_LIMIT = 65536;
+
+/**
+ * The unsigned angle between two vectors, in radians. `0` when either is
+ * degenerate, so a zero-length edge contributes no weight instead of NaN.
+ * @param {number} x1
+ * @param {number} y1
+ * @param {number} z1
+ * @param {number} x2
+ * @param {number} y2
+ * @param {number} z2
+ * @returns {number} the angle in radians
+ * @ignore
+ */
+function angleBetween(x1, y1, z1, x2, y2, z2) {
+ const l1 = Math.hypot(x1, y1, z1);
+ const l2 = Math.hypot(x2, y2, z2);
+ if (l1 === 0 || l2 === 0) {
+ return 0;
+ }
+ // clamped: rounding can push the quotient a hair outside [-1, 1], where
+ // acos is NaN
+ const cosine = (x1 * x2 + y1 * y2 + z1 * z2) / (l1 * l2);
+ return Math.acos(cosine < -1 ? -1 : cosine > 1 ? 1 : cosine);
+}
+
+/**
+ * Add `weight × (nx, ny, nz)` into an accumulator at a float offset.
+ * @param {Float64Array} target - the accumulator
+ * @param {number} at - float offset of the entry
+ * @param {number} nx
+ * @param {number} ny
+ * @param {number} nz
+ * @param {number} weight
+ * @ignore
+ */
+function addWeighted(target, at, nx, ny, nz, weight) {
+ target[at] += nx * weight;
+ target[at + 1] += ny * weight;
+ target[at + 2] += nz * weight;
+}
+
/**
* Parse a Wavefront OBJ file into geometry data.
* Supports: `v` (vertex positions), `vt` (texture coordinates),
@@ -40,12 +85,27 @@ const OBJ_INDEX_OFFSET = 1;
* touching the geometry. A model with no `usemtl` directives produces
* a single group with `materialName: null`.
*
- * Parsed but ignored: `vn` (normals), `g` (groups), `s` (smooth shading),
+ * `vn` (vertex normals) are consumed: a face referencing them
+ * (`v//vn` or `v/vt/vn`) produces unified vertices carrying the authored
+ * normal, so an OBJ model lights under `Light3d` exactly as the same model
+ * imported from glTF does. Every vertex WITHOUT a usable authored normal
+ * gets one GENERATED from face geometry — angle-weighted, accumulated per
+ * source position and normalized, i.e. smooth across UV and material seams
+ * — after the winding correction below, so the result follows the final
+ * triangle orientation rather than the authored one. That rule is
+ * per-vertex, so a file mixing normalled and un-normalled faces, or one
+ * carrying an out-of-range `vn`, still comes out fully normalled.
+ *
+ * Parsed but ignored: `g` (groups), `s` (smooth shading — generated normals
+ * are smooth across the whole mesh, so a model relying on hard edges from
+ * smoothing groups will read softer than authored),
* `o` (object name).
*
* @param {string} text - raw OBJ file contents
* @returns {object} parsed geometry with `vertices` (Float32Array),
- * `uvs` (Float32Array), `indices` (Uint16Array), `vertexCount` (number),
+ * `uvs` (Float32Array), `normals` (Float32Array), `indices`
+ * (Uint16Array, widening to Uint32Array past 65 536 vertices),
+ * `vertexCount` (number),
* `mtllib` (string|null), and `groups`
* (Array<{materialName: string|null, start: number, count: number}>).
* `groups` follows the glTF convention — each entry is a
@@ -55,9 +115,10 @@ const OBJ_INDEX_OFFSET = 1;
* special case.
* @ignore
*/
-function parseOBJ(text) {
+export function parseOBJ(text) {
const positions = [];
const texcoords = [];
+ const sourceNormals = [];
// unified output arrays (built in a single pass)
const vertices = [];
@@ -65,6 +126,12 @@ function parseOBJ(text) {
const indices = [];
let vertexCount = 0;
+ // per-unified-vertex provenance, consumed by the normal resolution pass
+ // once the whole file has been read: the `vn` this vertex asked for (or
+ // NO_NORMAL), and the source position index it was built from
+ const vertexNormalIndex = [];
+ const vertexPosition = [];
+
// Per-material vertex dedup: each material name owns its own
// `vertexMap`, so the same (v, vt) reused across different
// materials produces SEPARATE unified vertices (needed for
@@ -78,8 +145,17 @@ function parseOBJ(text) {
// helper: look up or create a unified vertex for a v/vt pair in the
// current material's dedup scope
- function addVertex(v, vt) {
- const key = v * VT_KEY_MULTIPLIER + (vt + OBJ_INDEX_OFFSET);
+ function addVertex(v, vt, vn) {
+ // The dedup key gains the normal index only when the file supplies
+ // normals: two faces sharing a position/UV but referencing DIFFERENT
+ // normals (a hard edge) must become separate vertices, or the edge
+ // smooths itself away. A string key is used in that case rather than
+ // packing a third component into the numeric one — `v * M²` overflows
+ // Number.MAX_SAFE_INTEGER well before the position count does.
+ const key =
+ vn >= 0
+ ? `${v}|${vt}|${vn}`
+ : v * VT_KEY_MULTIPLIER + (vt + OBJ_INDEX_OFFSET);
let index = vertexMap.get(key);
if (index === undefined) {
index = vertexCount++;
@@ -92,6 +168,15 @@ function parseOBJ(text) {
} else {
uvs.push(0, 0);
}
+ // Normal VALUES are resolved after the whole file is parsed (see
+ // the resolution pass below), not here: `vn` may legally be
+ // declared after the face referencing it, and an out-of-range
+ // index must degrade to "no authored normal" rather than reading
+ // past `sourceNormals` and writing NaN. Only the provenance is
+ // recorded now — which normal this vertex asked for, and which
+ // source POSITION it came from.
+ vertexNormalIndex.push(vn);
+ vertexPosition.push(v);
}
return index;
}
@@ -103,6 +188,24 @@ function parseOBJ(text) {
* @returns {number} UV index (0-based) or NO_UV
* @ignore
*/
+ function parseNormalIndex(part, slashIdx) {
+ if (slashIdx === -1) {
+ return NO_UV;
+ }
+ const second = part.indexOf(SLASH_CHAR, slashIdx + 1);
+ if (second === -1) {
+ return NO_UV;
+ }
+ return parseInt(part.substring(second + 1), 10) - OBJ_INDEX_OFFSET;
+ }
+
+ /**
+ * parse a face vertex component and return the UV index, or NO_UV
+ * @param {string} part - face vertex string
+ * @param {number} slashIdx - index of the first slash
+ * @returns {number} UV index (0-based) or NO_UV
+ * @ignore
+ */
function parseUVIndex(part, slashIdx) {
if (slashIdx !== -1 && part[slashIdx + 1] !== SLASH_CHAR) {
return parseInt(part.substring(slashIdx + 1), 10) - OBJ_INDEX_OFFSET;
@@ -184,6 +287,15 @@ function parseOBJ(text) {
);
} else if (parts[0] === TEXCOORD_PREFIX) {
texcoords.push(parseFloat(parts[1]), 1.0 - parseFloat(parts[2]));
+ } else if (parts[0] === NORMAL_PREFIX) {
+ // stored raw: the Y/Z axis bridge is applied at draw through
+ // the model matrix (`mat3(uModelMatrix) * aNormal`), exactly
+ // as it is for glTF normals — flipping here would double it
+ sourceNormals.push(
+ parseFloat(parts[1]),
+ parseFloat(parts[2]),
+ parseFloat(parts[3]),
+ );
}
} else if (first === FACE_PREFIX) {
const parts = line.split(/\s+/);
@@ -192,14 +304,16 @@ function parseOBJ(text) {
let slashIdx = parts[1].indexOf(SLASH_CHAR);
const v0 = parseInt(parts[1], 10) - OBJ_INDEX_OFFSET;
const vt0 = parseUVIndex(parts[1], slashIdx);
- const idx0 = addVertex(v0, vt0);
+ const vn0 = parseNormalIndex(parts[1], slashIdx);
+ const idx0 = addVertex(v0, vt0, vn0);
let prevIdx = -1;
for (let j = 2; j < parts.length; j++) {
slashIdx = parts[j].indexOf(SLASH_CHAR);
const v = parseInt(parts[j], 10) - OBJ_INDEX_OFFSET;
const vt = parseUVIndex(parts[j], slashIdx);
- const idx = addVertex(v, vt);
+ const vn = parseNormalIndex(parts[j], slashIdx);
+ const idx = addVertex(v, vt, vn);
if (prevIdx !== -1) {
indices.push(idx0, prevIdx, idx);
@@ -236,6 +350,131 @@ function parseOBJ(text) {
}
}
+ // ── normal resolution ────────────────────────────────────────────────
+ //
+ // Authored `vn` wins wherever one is available; every vertex left over
+ // is GENERATED from face geometry. Running per-vertex rather than
+ // per-file is what makes a partially-normalled model coherent: a file
+ // mixing `f v//vn` and `f v` faces, one that declares `vn` its faces
+ // never reference, or one carrying an out-of-range index all end up
+ // fully normalled instead of half zero-filled.
+ //
+ // Deliberately AFTER the winding correction above: generated normals
+ // follow triangle orientation, so generating first would point them
+ // inward on a CW-wound model — the exact case that correction exists
+ // to fix.
+ const normals = new Array(vertexCount * POS_STRIDE).fill(0);
+ const authored = new Array(vertexCount).fill(false);
+ let needsGenerated = false;
+ for (let i = 0; i < vertexCount; i++) {
+ const vn = vertexNormalIndex[i];
+ const vn3 = vn * POS_STRIDE;
+ // an index past the end is a malformed file, or a `vn` line that
+ // never arrived — treated as "no authored normal" so the generation
+ // pass below covers it
+ if (vn >= 0 && vn3 + POS_STRIDE <= sourceNormals.length) {
+ const at = i * POS_STRIDE;
+ normals[at] = sourceNormals[vn3];
+ normals[at + 1] = sourceNormals[vn3 + 1];
+ normals[at + 2] = sourceNormals[vn3 + 2];
+ authored[i] = true;
+ } else {
+ needsGenerated = true;
+ }
+ }
+
+ if (needsGenerated === true && vertexCount > 0) {
+ // Accumulated per SOURCE POSITION rather than per unified vertex. A
+ // position split by a UV seam or a `usemtl` boundary is still one
+ // point on the surface, and a generated normal is smooth by
+ // definition — accumulating per unified vertex would crease the
+ // model along every seam, most visibly at material boundaries.
+ //
+ // Weighted by the INTERIOR ANGLE at each corner. Area weighting is
+ // tempting because the unnormalized cross product already carries
+ // twice the area, but it is wrong here: an n-gon arrives
+ // fan-triangulated, which hands the fan's pivot corners two
+ // triangles' worth of one face and the others only one. A cube built
+ // from quads then accumulates (1, 0.5, 0.5) at a corner instead of
+ // (1, 1, 1) — 19.5° off, and asymmetric on a symmetric model. Angle
+ // weighting is independent of how the polygon happened to be
+ // triangulated.
+ const accumulated = new Float64Array(positions.length);
+ for (let i = 0; i < indices.length; i += 3) {
+ const a = vertexPosition[indices[i]] * POS_STRIDE;
+ const b = vertexPosition[indices[i + 1]] * POS_STRIDE;
+ const c = vertexPosition[indices[i + 2]] * POS_STRIDE;
+ const abx = positions[b] - positions[a];
+ const aby = positions[b + 1] - positions[a + 1];
+ const abz = positions[b + 2] - positions[a + 2];
+ const acx = positions[c] - positions[a];
+ const acy = positions[c + 1] - positions[a + 1];
+ const acz = positions[c + 2] - positions[a + 2];
+ let nx = aby * acz - abz * acy;
+ let ny = abz * acx - abx * acz;
+ let nz = abx * acy - aby * acx;
+ const faceLength = Math.hypot(nx, ny, nz);
+ if (faceLength === 0) {
+ // degenerate (zero-area or collinear) — contributes no
+ // direction, and normalizing it would be a division by zero
+ continue;
+ }
+ nx /= faceLength;
+ ny /= faceLength;
+ nz /= faceLength;
+ const bcx = positions[c] - positions[b];
+ const bcy = positions[c + 1] - positions[b + 1];
+ const bcz = positions[c + 2] - positions[b + 2];
+ addWeighted(
+ accumulated,
+ a,
+ nx,
+ ny,
+ nz,
+ angleBetween(abx, aby, abz, acx, acy, acz),
+ );
+ addWeighted(
+ accumulated,
+ b,
+ nx,
+ ny,
+ nz,
+ angleBetween(-abx, -aby, -abz, bcx, bcy, bcz),
+ );
+ addWeighted(
+ accumulated,
+ c,
+ nx,
+ ny,
+ nz,
+ angleBetween(-acx, -acy, -acz, -bcx, -bcy, -bcz),
+ );
+ }
+ for (let i = 0; i < vertexCount; i++) {
+ if (authored[i] === true) {
+ continue;
+ }
+ const from = vertexPosition[i] * POS_STRIDE;
+ const at = i * POS_STRIDE;
+ const length = Math.hypot(
+ accumulated[from],
+ accumulated[from + 1],
+ accumulated[from + 2],
+ );
+ if (length > 0) {
+ normals[at] = accumulated[from] / length;
+ normals[at + 1] = accumulated[from + 1] / length;
+ normals[at + 2] = accumulated[from + 2] / length;
+ } else {
+ // a position referenced by no triangle, or by ones whose
+ // contributions cancel exactly: leave a valid unit normal
+ // rather than a zero vector, which would be NaN once
+ // normalized
+ normals[at + 1] = 1;
+ }
+ }
+ }
+
// finalize the last open group (or, if no `usemtl` was ever seen,
// emit a single material-less group covering all indices so the
// `groups[]` contract is always non-empty for non-empty OBJs)
@@ -251,7 +490,16 @@ function parseOBJ(text) {
return {
vertices: new Float32Array(vertices),
uvs: new Float32Array(uvs),
- indices: new Uint16Array(indices),
+ normals: new Float32Array(normals),
+ // Uint16 holds indices up to 65 535, so it covers a vertex count of
+ // 65 536 exactly. Past that the array must widen or every index
+ // wraps mod 65 536 in silence — reachable on ordinary models now
+ // that a position referencing several normals is split per normal,
+ // which can treble the unified vertex count of a flat-shaded mesh.
+ indices:
+ vertexCount > UINT16_VERTEX_LIMIT
+ ? new Uint32Array(indices)
+ : new Uint16Array(indices),
vertexCount,
mtllib,
groups,
diff --git a/packages/melonjs/src/renderable/mesh.js b/packages/melonjs/src/renderable/mesh.js
index 0531c214a..fda7c860e 100644
--- a/packages/melonjs/src/renderable/mesh.js
+++ b/packages/melonjs/src/renderable/mesh.js
@@ -78,11 +78,10 @@ function toEmissive(src) {
/**
* Resolve an OBJ material group into a draw descriptor. Builds the
* group's tint from the MTL's `Kd` (defaults to white if missing) and
- * its opacity from `d`. Returns a self-contained record carrying just
- * the index slice + color state — per-material textures (`map_Kd`)
- * are NOT modeled because the mesh shader uses a single `uSampler`
- * binding shared across the whole mesh; only colors are baked
- * per-vertex.
+ * its opacity from `d`. Returns a self-contained record carrying the
+ * index slice + color state; the group's own diffuse texture is filled
+ * in afterwards by {@link buildTextureGroups}, once the mesh-level
+ * fallback texture is known.
* @param {{materialName: string|null, start: number, count: number}} group
* @param {object} materials - MTL material table keyed by material name
* @returns {{materialName: string|null, start: number, count: number, tint: Color, opacity: number}} draw descriptor for this group
@@ -110,9 +109,100 @@ function resolveGroupMaterial(group, materials) {
count: group.count,
tint,
opacity,
+ texture: undefined,
};
}
+/**
+ * Resolve each material group's own diffuse texture (`map_Kd`) and reduce
+ * the result to the shortest list of index ranges that have to be drawn
+ * with distinct textures (#1573).
+ *
+ * A group whose material declares no `map_Kd` keeps the mesh-level texture,
+ * so a model mixing textured and `Kd`-only materials still draws in one
+ * binding wherever it can. Adjacent ranges resolving to the same
+ * `TextureAtlas` are merged — the atlas cache is keyed by image, so two
+ * materials pointing at one file coalesce by identity rather than by
+ * comparing names — and a model that ends up with a single texture returns
+ * `undefined`, which keeps the common single-material case on exactly the
+ * draw-call count it had before.
+ *
+ * A `map_Kd` naming an image that was never preloaded warns and falls back
+ * to the mesh texture rather than throwing: only the first material's
+ * `map_Kd` used to be resolved at all, so a partially-preloaded model that
+ * rendered before must keep rendering.
+ * @param {Array