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} groups - the material groups, mutated in place to carry their resolved `texture` + * @param {object} materials - MTL material table keyed by material name + * @param {TextureAtlas} shared - the mesh-level texture, used by groups with no `map_Kd` of their own + * @param {number} [framewidth] - spritesheet cell width, as passed to the Mesh + * @param {number} [frameheight] - spritesheet cell height, as passed to the Mesh + * @returns {Array<{texture: TextureAtlas, start: number, count: number}>|undefined} the per-texture draw ranges, or `undefined` when one binding covers the whole mesh + * @ignore + */ +function buildTextureGroups( + groups, + materials, + shared, + framewidth, + frameheight, +) { + let distinct = false; + for (const group of groups) { + const mat = group.materialName ? materials[group.materialName] : null; + let texture = shared; + if (mat?.map_Kd) { + try { + texture = resolveTextureAtlas(mat.map_Kd, framewidth, frameheight); + } catch { + console.warn( + `melonJS: Mesh material "${group.materialName}" references the texture "${mat.map_Kd}", which is not loaded — that material falls back to the mesh texture`, + ); + } + } + group.texture = texture; + // only geometry that actually draws can make a split necessary + if (texture !== shared && group.count > 0) { + distinct = true; + } + } + if (distinct === false) { + return undefined; + } + + const slices = []; + for (const group of groups) { + // an empty group (two `usemtl` directives in a row) would emit a + // zero-index draw and, worse, break the adjacency test below + if (group.count === 0) { + continue; + } + const previous = slices[slices.length - 1]; + if ( + previous !== undefined && + previous.texture === group.texture && + previous.start + previous.count === group.start + ) { + previous.count += group.count; + } else { + slices.push({ + texture: group.texture, + start: group.start, + count: group.count, + }); + } + } + // A single surviving range still needs the plan when its texture is not + // the mesh-level one — the whole model draws with that material, and + // returning `undefined` here would hand it back to `mesh.texture`. + // Reachable when an empty group precedes the only drawing one. + if (slices.length > 1) { + return slices; + } + return slices.length === 1 && slices[0].texture !== shared + ? slices + : undefined; +} + /** * A renderable object for displaying textured triangle meshes. * Supports loading from Wavefront OBJ models (via `loader.preload` with type "obj") @@ -155,12 +245,12 @@ export default class Mesh extends Renderable { * @param {number} x - the x screen position of the mesh object * @param {number} y - the y screen position of the mesh object * @param {object} settings - Configuration parameters for the Mesh object - * @param {string} [settings.model] - name of a preloaded OBJ model (via loader.preload with type "obj") + * @param {string} [settings.model] - name of a preloaded OBJ model (via loader.preload with type "obj"). Vertex normals come with it — authored `vn` when the file has them, generated from face geometry when it does not — so an OBJ model can be `lit`. * @param {Float32Array|number[]} [settings.vertices] - vertex positions as x,y,z triplets (alternative to settings.model) * @param {Float32Array|number[]} [settings.uvs] - texture coordinates as u,v pairs (alternative to settings.model) * @param {Uint16Array|number[]} [settings.indices] - triangle vertex indices (alternative to settings.model) - * @param {HTMLImageElement|TextureAtlas|string} [settings.texture] - the texture to apply (image name, HTMLImageElement, or TextureAtlas). If omitted and settings.material is provided, the texture is resolved from the MTL material's map_Kd. - * @param {string} [settings.material] - name of a preloaded MTL material (via loader.preload with type "mtl"). When provided, the diffuse texture (map_Kd), tint color (Kd), and opacity (d) are automatically applied. + * @param {HTMLImageElement|TextureAtlas|string} [settings.texture] - the texture to apply (image name, HTMLImageElement, or TextureAtlas). If omitted and settings.material is provided, the texture is resolved from the MTL material's map_Kd. Passing this pins ONE binding over the whole model, which on a multi-material model suppresses the per-material texture split — see {@link Mesh#textureGroups}. + * @param {string} [settings.material] - name of a preloaded MTL material (via loader.preload with type "mtl"). When provided, the diffuse texture (map_Kd), tint color (Kd), and opacity (d) are automatically applied. On a multi-material model each material's own `map_Kd` is bound for its own slice of the geometry (#1573) and each `Kd` is baked per-vertex, so one `Mesh` renders the whole model. * @param {number} settings.width - display width in pixels. With normalization on (the default) the model is scaled to fit this size; with `normalize: false` this is the uniform pixels-per-unit scale applied to the raw geometry. * @param {number} [settings.height] - display height in pixels (normalized models only; ignored when `normalize: false`) * @param {boolean} [settings.cullBackFaces=true] - enable backface culling @@ -221,6 +311,9 @@ export default class Mesh extends Renderable { // load geometry from OBJ model or raw data let objGroups = null; + // the normal source that wins: an explicit `settings.normals`, else + // whatever the OBJ supplied + let sourceNormals = settings.normals; if (typeof settings.model === "string") { const objData = getOBJ(settings.model); if (!objData) { @@ -238,6 +331,19 @@ export default class Mesh extends Renderable { */ this.uvs = objData.uvs; + // Authored (or generated) vertex normals from the OBJ, so `lit` + // models shade against the surface the artist modelled rather + // than a fallback — the same data a glTF import supplies through + // its NORMAL accessor. An explicit `settings.normals` still wins. + // + // Held locally rather than written back onto `settings`: the + // caller's object is not ours to mutate. A frozen settings + // literal would throw, and one reused for two different `model` + // names would hand the second mesh the first model's normals. + if (sourceNormals === undefined && objData.normals !== undefined) { + sourceNormals = objData.normals; + } + /** * triangle indices * @type {Uint16Array} @@ -311,10 +417,10 @@ export default class Mesh extends Renderable { * @type {Float32Array|undefined} */ this.originalNormals = - settings.normals !== undefined - ? settings.normals instanceof Float32Array - ? settings.normals - : new Float32Array(settings.normals) + sourceNormals !== undefined + ? sourceNormals instanceof Float32Array + ? sourceNormals + : new Float32Array(sourceNormals) : undefined; /** @@ -436,7 +542,8 @@ export default class Mesh extends Renderable { * runtime color multiplication, or rebuild the Mesh with * new material settings. * @type {Array<{materialName: string|null, start: number, - * count: number, tint: Color, opacity: number}>} + * count: number, tint: Color, opacity: number, + * texture: TextureAtlas|undefined}>} */ this.groups = objGroups.map((g) => { return resolveGroupMaterial(g, materials); @@ -447,13 +554,22 @@ export default class Mesh extends Renderable { // multiply it onto every vertex at render time. The // renderer-level `setTint` path on top of the baked colors // is still available for runtime flash / fade / team color. - // Per-material `map_Kd` textures are not switched at draw - // time (mesh shader has a single `uSampler`); pick up the - // first material's `map_Kd` for the shared texture binding - // if any group has one, else fall through to the white- - // pixel fallback further down. + // The first `map_Kd` in the model becomes the mesh-level + // texture — what groups whose material declares none draw + // with, and the single binding a consumer that ignores + // `textureGroups` (the Canvas renderer) sees. Groups with + // their own `map_Kd` get it bound per draw range further + // down (#1573). With no `map_Kd` anywhere this falls + // through to the white-pixel fallback. if (!textureSource) { for (const g of objGroups) { + // a zero-index group (two `usemtl` in a row) draws nothing, + // so letting its material name the mesh-level texture would + // pick a map no geometry uses — and leave `mesh.texture` + // outside the split plan entirely + if (g.count === 0) { + continue; + } const mat = g.materialName ? materials[g.materialName] : null; if (mat && mat.map_Kd) { textureSource = mat.map_Kd; @@ -542,6 +658,34 @@ export default class Mesh extends Renderable { settings.frameheight, ); + /** + * Index ranges that each need their own diffuse texture bound, for a + * multi-material model whose materials carry different `map_Kd` maps + * (#1573) — `undefined` whenever one binding covers the whole mesh, + * which is every single-material model and every `Kd`-only one. + * + * The GPU backends draw one indexed range per entry instead of one + * range for the whole mesh; adjacent materials sharing a texture are + * already merged here, so the list is the minimum number of draws the + * model needs. An explicit `settings.texture` suppresses the split + * entirely — asking for one texture is asking for one texture. + * + * The Canvas renderer ignores this: a multi-material mesh takes its + * per-triangle solid-fill path there and never samples a texture at + * all. + * @type {Array<{texture: TextureAtlas, start: number, count: number}>|undefined} + */ + this.textureGroups = + isMultiMaterial === true && !settings.texture + ? buildTextureGroups( + this.groups, + materials, + this.texture, + settings.framewidth, + settings.frameheight, + ) + : undefined; + /** * Per-mesh texture wrap mode (`"repeat"` / `"repeat-x"` / `"repeat-y"` * / `"no-repeat"`), or `undefined` to sample with the texture's own @@ -578,11 +722,20 @@ export default class Mesh extends Renderable { // wanting different filters is last-writer-wins until then. if (hasRealTexture && typeof settings.textureFilter === "string") { const gl = game.renderer?.gl; - if (gl) { - this.texture.filter = - settings.textureFilter === "nearest" ? gl.NEAREST : gl.LINEAR; - } else { - this.texture.filter = settings.textureFilter; + const filter = gl + ? settings.textureFilter === "nearest" + ? gl.NEAREST + : gl.LINEAR + : settings.textureFilter; + this.texture.filter = filter; + // every texture the model draws with, not just the mesh-level one: + // a per-material split would otherwise leave the other materials on + // the renderer default, rendering one material crisp and the rest + // smooth (#1573) + if (this.textureGroups !== undefined) { + for (const group of this.textureGroups) { + group.texture.filter = filter; + } } } @@ -1010,7 +1163,9 @@ export default class Mesh extends Renderable { * `vertexColors` at construction time and pushed through the renderer's * per-vertex `aColor` (GPU backends) or per-triangle solid-fill (Canvas) * path. The GPU batchers may still chunk very large meshes across multiple - * `drawElements` calls to fit its vertex/index buffer limits. + * `drawElements` calls to fit its vertex/index buffer limits, and split the + * draw once per entry in {@link Mesh#textureGroups} when the materials + * carry different diffuse textures (#1573). * * The active path is picked from the `viewport` passed in by * `Container.draw`, NOT from the activation-time `_useWorldSpace` diff --git a/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js b/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js index 3dff08ade..a423152ec 100644 --- a/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js @@ -446,7 +446,11 @@ export default class MeshBatcher extends MaterialBatcher { * issue one indexed draw, with placement supplied entirely by uniforms. * * Unlike {@link addMesh} this accumulates nothing and never chunks — the - * whole mesh is one draw call regardless of size. + * whole mesh is one draw call regardless of size, except for a + * multi-material model whose materials carry different diffuse textures: + * that draws one indexed range per entry in {@link Mesh#textureGroups} + * (#1573), over the same buffers, with only the sampler moving between + * them. * @param {object} mesh - the mesh to draw * @param {Matrix3d} modelMatrix - where the mesh sits in the world * @param {number} tint - tint colour in UINT32 (argb) format @@ -461,12 +465,40 @@ export default class MeshBatcher extends MaterialBatcher { this.updatePassState(); - this.applyMeshMaterial(mesh); + const slices = mesh.textureGroups; + if (slices === undefined) { + this.applyMeshMaterial(mesh); + } this.setPlacementUniforms(modelMatrix, tint); const geometry = this.retainedGeometryFor(mesh); geometry.bind(); - gl.drawElements(this.mode, geometry.indexCount, geometry.indexType, 0); + if (slices === undefined) { + gl.drawElements(this.mode, geometry.indexCount, geometry.indexType, 0); + } else { + const indexBytes = geometry.indexType === gl.UNSIGNED_INT ? 4 : 2; + for (let i = 0; i < slices.length; i++) { + // Bound one range at a time, immediately before its own draw. + // Resolving every range's texture up front would be faster + // but is wrong: exhausting the unit budget makes the texture + // cache recycle units from unit 0, which silently invalidates + // the units already handed out. Binding per range means a + // later range recycling an earlier one's unit is harmless — + // that range has already drawn. + // + // Safe between `geometry.bind()` and these draws because + // nothing is accumulated on this path: the flush a texture + // upload may trigger returns immediately at zero vertices, + // touching neither the vertex array nor ARRAY_BUFFER. + this.applyMeshMaterial(mesh, slices[i].texture); + gl.drawElements( + this.mode, + slices[i].count, + geometry.indexType, + slices[i].start * indexBytes, + ); + } + } // hand the batcher's own vertex state back, so a subsequent // accumulated draw uploads and draws through its buffers, not these @@ -676,18 +708,38 @@ export default class MeshBatcher extends MaterialBatcher { this.useShader(this.instancedShaderFor(mesh.instanceLayout)); this.updatePassState(); - this.applyMeshMaterial(mesh); + const slices = mesh.textureGroups; + if (slices === undefined) { + this.applyMeshMaterial(mesh); + } this.setPlacementUniforms(modelMatrix, tint); const { geometry, state } = this.instancedStateFor(mesh); state.vertexState.bind(); - gl.drawElementsInstanced( - this.mode, - geometry.indexCount, - geometry.indexType, - 0, - count, - ); + if (slices === undefined) { + gl.drawElementsInstanced( + this.mode, + geometry.indexCount, + geometry.indexType, + 0, + count, + ); + } else { + // a multi-material prototype: every instance draws the same split + // (#1573), so each range is one instanced draw over the whole set + // — bound per range for the reason spelled out in drawRetainedMesh + const indexBytes = geometry.indexType === gl.UNSIGNED_INT ? 4 : 2; + for (let i = 0; i < slices.length; i++) { + this.applyMeshMaterial(mesh, slices[i].texture); + gl.drawElementsInstanced( + this.mode, + slices[i].count, + geometry.indexType, + slices[i].start * indexBytes, + count, + ); + } + } // Hand the default shader and this batcher's own vertex state back. // Both matter: `bind()` only restores the default program when the @@ -812,32 +864,28 @@ export default class MeshBatcher extends MaterialBatcher { * Shared by the accumulated and retained draw paths so material changes * take effect immediately either way, without touching geometry. * @param {object} mesh - the mesh whose material should be applied + * @param {TextureAtlas} [texture] - bind this texture instead of the mesh's + * own — how a multi-material model's per-material `map_Kd` reaches the + * sampler, one draw range at a time (#1573). Everything else here is a + * property of the mesh, not of the material group. + * @returns {number} the texture unit the material landed on * @ignore */ - applyMeshMaterial(mesh) { + applyMeshMaterial(mesh, texture = mesh.texture) { // upload and activate the texture. The mesh's own `textureRepeat` // (when set) is threaded through as a per-use wrap override — sampler // state per mesh, never a mutation of the shared per-image atlas // (#1503). The unit cache keys by `(source, repeat)`, so meshes with // different wraps over one image coexist on distinct GL textures. const unit = this.uploadTexture( - mesh.texture, + texture, undefined, undefined, false, true, mesh.textureRepeat, ); - // guarded like every other per-mesh uniform below: a custom mesh - // shader that never samples the texture (vertex colors only) does - // not declare `uSampler`, and setUniform throws on unknown names - if ( - unit !== this.currentSamplerUnit && - this.currentShader.uniforms?.uSampler !== undefined - ) { - this.currentShader.setUniform("uSampler", unit); - this.currentSamplerUnit = unit; - } + this.bindSamplerUnit(unit); // Mesh textures sample their mip chain: `createTexture2D` already // runs `generateMipmap` for every plain image upload, but the min @@ -848,17 +896,15 @@ export default class MeshBatcher extends MaterialBatcher { // last-writer-wins caveat as the `textureFilter` setting. const gl = this.gl; const glFilter = - typeof mesh.texture.filter !== "undefined" - ? mesh.texture.filter + typeof texture.filter !== "undefined" + ? texture.filter : this.renderer._glTextureFilter(); // the filter can be a GL enum (this backend's Mesh) or the string // form (an atlas first configured under a non-GL renderer) if (glFilter === gl.LINEAR || glFilter === "linear") { const glTexture = this.boundTextures[unit]; const source = - typeof mesh.texture.getTexture === "function" - ? mesh.texture.getTexture() - : null; + typeof texture.getTexture === "function" ? texture.getTexture() : null; // TextureResource-backed sources own their upload and carry no // generated chain — a mipmap min filter over their single level // is mipmap-incomplete under ES3 (samples opaque black), so they @@ -927,23 +973,68 @@ export default class MeshBatcher extends MaterialBatcher { this.currentEmissiveG = eg; this.currentEmissiveB = eb; } + return unit; + } + + /** + * Point `uSampler` at a texture unit, if it moved and if the current + * shader has a sampler at all — a custom mesh shader that never samples + * (vertex colours only) declares none, and `setUniform` throws on an + * unknown name. + * @param {number} unit - the texture unit to sample from + * @ignore + */ + bindSamplerUnit(unit) { + if ( + unit !== this.currentSamplerUnit && + this.currentShader.uniforms?.uSampler !== undefined + ) { + this.currentShader.setUniform("uSampler", unit); + this.currentSamplerUnit = unit; + } } addMesh(mesh, tint) { + this.updatePassState(); + + const slices = mesh.textureGroups; + if (slices === undefined) { + this.applyMeshMaterial(mesh); + // Placement uniforms. The view transform and the tint used to be + // baked into every vertex on the CPU; they are uniforms now, so the + // vertex data depends only on the geometry itself. This path (2D + // camera / pre-projected vertices) supplies an identity model + // matrix — the vertices already sit where they belong. + this.setPlacementUniforms(_IDENTITY_MATRIX, tint); + this.accumulateRange(mesh, 0, mesh.indices.length); + return; + } + // Multi-material with per-material textures (#1573): one accumulation + // pass per range. `applyMeshMaterial` flushes on a texture change, so + // the previous range's vertices land under the texture they were + // accumulated for — and the uniforms are re-armed after that flush, + // never before it. + for (let i = 0; i < slices.length; i++) { + this.applyMeshMaterial(mesh, slices[i].texture); + this.setPlacementUniforms(_IDENTITY_MATRIX, tint); + this.accumulateRange(mesh, slices[i].start, slices[i].count); + } + } + + /** + * Accumulate one index range of a mesh into the batch, chunking triangles + * across flushes as the vertex / index buffers fill. + * @param {object} mesh - a Mesh with vertices, uvs, indices, texture + * @param {number} from - first index to accumulate + * @param {number} length - how many indices to accumulate + * @ignore + */ + accumulateRange(mesh, from, length) { const vertices = mesh.vertices; const uvs = mesh.uvs; const indices = mesh.indices; const vertexColors = mesh.vertexColors; - - this.updatePassState(); - this.applyMeshMaterial(mesh); - - // Placement uniforms. The view transform and the tint used to be baked - // into every vertex on the CPU; they are uniforms now, so the vertex - // data depends only on the geometry itself. This path (2D camera / - // pre-projected vertices) supplies an identity model matrix — the - // vertices already sit where they belong. - this.setPlacementUniforms(_IDENTITY_MATRIX, tint); + const until = from + length; const maxVerts = this.vertexData.maxVertex; const maxIndices = this.indexBuffer.data.length; @@ -953,8 +1044,8 @@ export default class MeshBatcher extends MaterialBatcher { ensureRemapCapacity(mesh.vertexCount); // process triangles in chunks that fit the buffer - let triIdx = 0; - while (triIdx < indices.length) { + let triIdx = from; + while (triIdx < until) { // figure out how many triangles fit in the current batch const vertexData = this.vertexData; const availVerts = maxVerts - vertexData.vertexCount; @@ -970,7 +1061,7 @@ export default class MeshBatcher extends MaterialBatcher { continue; } - const endIdx = Math.min(triIdx + maxTris * 3, indices.length); + const endIdx = Math.min(triIdx + maxTris * 3, until); // build a local vertex remap for this chunk (shared reused // scratch — see gpu/meshchunk.ts). Capture the base offset diff --git a/packages/melonjs/src/video/webgl/shaders/mesh-lit.frag b/packages/melonjs/src/video/webgl/shaders/mesh-lit.frag index eb1c78cd9..7d5c5fb12 100644 --- a/packages/melonjs/src/video/webgl/shaders/mesh-lit.frag +++ b/packages/melonjs/src/video/webgl/shaders/mesh-lit.frag @@ -67,7 +67,23 @@ void main(void) { discard; } - vec3 N = normalize(vNormal); + // A mesh marked `lit` with no usable normals — the 2D-camera path, + // which leaves world normals unwritten, or geometry that supplied none — + // would normalize a zero vector to NaN and render BLACK. Degrade to + // unlit instead: wrong, but recognisably the model rather than a hole. + float nLength = length(vNormal); + if (nLength < 1e-6) { + // the emissive term is built exactly as the lit path below builds + // it, per-instance slot included — degrading to unlit must not also + // drop an instance's glow + vec3 unlitEmissive = uEmissive; +#ifdef INSTANCE_DATA + unlitEmissive += vInstanceData.rgb; +#endif + fragColor = vec4(base.rgb + unlitEmissive, base.a); + return; + } + vec3 N = vNormal / nLength; vec3 lit = uAmbient; // ES 3.00 allows a non-constant loop bound, so this runs exactly as many // iterations as there are live lights — unused capacity costs nothing, diff --git a/packages/melonjs/src/video/webgpu/batchers/mesh_batcher.js b/packages/melonjs/src/video/webgpu/batchers/mesh_batcher.js index 3fa4d9671..a91360dfa 100644 --- a/packages/melonjs/src/video/webgpu/batchers/mesh_batcher.js +++ b/packages/melonjs/src/video/webgpu/batchers/mesh_batcher.js @@ -238,7 +238,13 @@ export default class WebGPUMeshBatcher extends WebGPUBatcher { this.flush(); this.updatePassState(); - this.applyMeshMaterial(mesh); + // on the split path every range resolves its own binding below, so + // resolving the mesh-level one here would reserve a texture unit (and + // possibly run a first-use upload plus mip generation) for a binding + // that is immediately overwritten + if (mesh.textureGroups === undefined) { + this.applyMeshMaterial(mesh); + } this.setPlacementUniforms(modelMatrix, tint, mesh); const renderer = this.renderer; @@ -271,7 +277,6 @@ export default class WebGPUMeshBatcher extends WebGPUBatcher { } const frame = renderer.currentFrameBinding; pass.setBindGroup(0, frame.bindGroup, [frame.dynamicOffset]); - pass.setBindGroup(1, this.currentMaterial); this.bindLights(pass); pass.setBindGroup(3, this.uniformBinding.bindGroup, [ this.uniformBinding.dynamicOffset, @@ -279,7 +284,19 @@ export default class WebGPUMeshBatcher extends WebGPUBatcher { pass.setVertexBuffer(0, geometry.vertexBuffer); pass.setVertexBuffer(1, instances.buffer); pass.setIndexBuffer(geometry.indexBuffer, geometry.indexFormat); - pass.drawIndexed(geometry.indexCount, count); + const slices = mesh.textureGroups; + if (slices === undefined) { + pass.setBindGroup(1, this.currentMaterial); + pass.drawIndexed(geometry.indexCount, count); + } else { + // a multi-material prototype: every instance draws the same split + // (#1573), so each range is one instanced draw over the whole set + for (let i = 0; i < slices.length; i++) { + this.applyMeshMaterial(mesh, slices[i].texture); + pass.setBindGroup(1, this.currentMaterial); + pass.drawIndexed(slices[i].count, count, slices[i].start); + } + } // stamp both halves: an edit later this frame must go to fresh buffers geometry.lastDrawnFrameId = renderer.frameId; @@ -416,15 +433,19 @@ export default class WebGPUMeshBatcher extends WebGPUBatcher { * per-image atlas). A material change with vertices pending flushes * them under the previous binding. * @param {object} mesh - the mesh whose material should be applied + * @param {TextureAtlas} [texture] - bind this texture instead of the mesh's + * own — how a multi-material model's per-material `map_Kd` reaches the + * sampler, one draw range at a time (#1573). The wrap override stays a + * property of the mesh, not of the material group. * @ignore */ - applyMeshMaterial(mesh) { + applyMeshMaterial(mesh, texture = mesh.texture) { const renderer = this.renderer; const filter = - typeof mesh.texture.filter === "string" - ? mesh.texture.filter + typeof texture.filter === "string" + ? texture.filter : renderer.getDefaultTextureFilter(); - const material = renderer.textureStore.getBinding(mesh.texture, { + const material = renderer.textureStore.getBinding(texture, { repeat: mesh.textureRepeat, // mesh textures sample a generated mip chain — trilinear // minification keeps distant geometry from shimmering, while @@ -517,22 +538,49 @@ export default class WebGPUMeshBatcher extends WebGPUBatcher { * @param {number} tint - tint color in UINT32 (argb) format */ addMesh(mesh, tint) { + this.updatePassState(); + + const slices = mesh.textureGroups; + if (slices === undefined) { + this.applyMeshMaterial(mesh); + this.setPlacementUniforms(IDENTITY_MATRIX, tint, mesh); + this.accumulateRange(mesh, 0, mesh.indices.length); + return; + } + // Multi-material with per-material textures (#1573): one accumulation + // pass per range. `applyMeshMaterial` flushes on a material change, so + // the previous range's vertices are recorded under the binding they + // were accumulated for — and the uniforms are re-armed after that + // flush, never before it. + for (let i = 0; i < slices.length; i++) { + this.applyMeshMaterial(mesh, slices[i].texture); + this.setPlacementUniforms(IDENTITY_MATRIX, tint, mesh); + this.accumulateRange(mesh, slices[i].start, slices[i].count); + } + } + + /** + * Accumulate one index range of a mesh into the batch, chunking triangles + * across flushes as the vertex / index staging arrays fill. + * @param {object} mesh - a Mesh with vertices, uvs, indices, texture + * @param {number} from - first index to accumulate + * @param {number} length - how many indices to accumulate + * @ignore + */ + accumulateRange(mesh, from, length) { const vertices = mesh.vertices; const uvs = mesh.uvs; const indices = mesh.indices; const vertexColors = mesh.vertexColors; - - this.updatePassState(); - this.applyMeshMaterial(mesh); - this.setPlacementUniforms(IDENTITY_MATRIX, tint, mesh); + const until = from + length; const maxVerts = this.vertexData.maxVertex; const maxIndices = this.indexData.length; ensureRemapCapacity(mesh.vertexCount); - let triIdx = 0; - while (triIdx < indices.length) { + let triIdx = from; + while (triIdx < until) { const vertexData = this.vertexData; const availVerts = maxVerts - vertexData.vertexCount; const availIndices = maxIndices - this.indexCount; @@ -547,7 +595,7 @@ export default class WebGPUMeshBatcher extends WebGPUBatcher { continue; } - const endIdx = Math.min(triIdx + maxTris * 3, indices.length); + const endIdx = Math.min(triIdx + maxTris * 3, until); const baseOffset = vertexData.vertexCount; const chunkIndices = beginChunk(); @@ -713,7 +761,10 @@ export default class WebGPUMeshBatcher extends WebGPUBatcher { * and record one indexed draw, with placement supplied entirely by the * per-draw uniform snapshot. Unlike {@link WebGPUMeshBatcher#addMesh} * this accumulates nothing and never chunks — the whole mesh is one - * draw regardless of size. + * draw regardless of size, except for a multi-material model whose + * materials carry different diffuse textures: that records one indexed + * range per entry in {@link Mesh#textureGroups} (#1573), over the same + * buffers, with only the group-1 material binding moving between them. * @param {object} mesh - the mesh to draw * @param {Matrix3d} modelMatrix - where the mesh sits in the world * @param {number} tint - tint colour in UINT32 (argb) format @@ -725,7 +776,13 @@ export default class WebGPUMeshBatcher extends WebGPUBatcher { this.flush(); this.updatePassState(); - this.applyMeshMaterial(mesh); + // on the split path every range resolves its own binding below, so + // resolving the mesh-level one here would reserve a texture unit (and + // possibly run a first-use upload plus mip generation) for a binding + // that is immediately overwritten + if (mesh.textureGroups === undefined) { + this.applyMeshMaterial(mesh); + } this.setPlacementUniforms(modelMatrix, tint, mesh); const renderer = this.renderer; @@ -746,14 +803,25 @@ export default class WebGPUMeshBatcher extends WebGPUBatcher { } const frame = renderer.currentFrameBinding; pass.setBindGroup(0, frame.bindGroup, [frame.dynamicOffset]); - pass.setBindGroup(1, this.currentMaterial); this.bindLights(pass); pass.setBindGroup(3, this.uniformBinding.bindGroup, [ this.uniformBinding.dynamicOffset, ]); pass.setVertexBuffer(0, geometry.vertexBuffer); pass.setIndexBuffer(geometry.indexBuffer, geometry.indexFormat); - pass.drawIndexed(geometry.indexCount); + const slices = mesh.textureGroups; + if (slices === undefined) { + pass.setBindGroup(1, this.currentMaterial); + pass.drawIndexed(geometry.indexCount); + } else { + for (let i = 0; i < slices.length; i++) { + // nothing is queued at this point, so the flush this may run is + // a no-op — it is here for the material tracking, not the flush + this.applyMeshMaterial(mesh, slices[i].texture); + pass.setBindGroup(1, this.currentMaterial); + pass.drawIndexed(slices[i].count, 1, slices[i].start); + } + } // stamp: a version bump later this frame must go to fresh buffers geometry.lastDrawnFrameId = renderer.frameId; diff --git a/packages/melonjs/src/video/webgpu/shaders/mesh-lit.wgsl b/packages/melonjs/src/video/webgpu/shaders/mesh-lit.wgsl index 59a76e157..bdb1973c8 100644 --- a/packages/melonjs/src/video/webgpu/shaders/mesh-lit.wgsl +++ b/packages/melonjs/src/video/webgpu/shaders/mesh-lit.wgsl @@ -104,7 +104,13 @@ fn fragment_main(in : VSOut) -> @location(0) vec4f { discard; } - let n = normalize(in.vNormal); + // see mesh-lit.frag: a `lit` mesh with no usable normals must degrade to + // unlit rather than normalize a zero vector to NaN and render black + let nLength = length(in.vNormal); + if (nLength < 1e-6) { + return vec4f(base.rgb + uMesh.emissive.rgb, base.a); + } + let n = in.vNormal / nLength; var lit = uLights.ambient.rgb; // clamped to the array size, not just taken on trust: if the block // ever read as something other than what the writer put there, an diff --git a/packages/melonjs/tests/helpers/webgpu-mock-renderer.js b/packages/melonjs/tests/helpers/webgpu-mock-renderer.js index 51e44defd..793ba171e 100644 --- a/packages/melonjs/tests/helpers/webgpu-mock-renderer.js +++ b/packages/melonjs/tests/helpers/webgpu-mock-renderer.js @@ -13,6 +13,9 @@ export function createMockWebGPURenderer() { // draw / drawIndexed vertex counts, in recording order draws: [], drawIndexed: [], + // the same drawIndexed calls with every argument, for the paths that + // record a sub-range (per-material texture splits, #1573) + drawIndexedArgs: [], // pipeline-cache lookups (full key) and actual setPipeline count pipelineKeys: [], setPipeline: 0, @@ -54,8 +57,9 @@ export function createMockWebGPURenderer() { draw(count) { calls.draws.push(count); }, - drawIndexed(count) { + drawIndexed(count, instanceCount, firstIndex) { calls.drawIndexed.push(count); + calls.drawIndexedArgs.push({ count, instanceCount, firstIndex }); }, }; diff --git a/packages/melonjs/tests/mesh_texture_groups.spec.js b/packages/melonjs/tests/mesh_texture_groups.spec.js new file mode 100644 index 000000000..6a3260744 --- /dev/null +++ b/packages/melonjs/tests/mesh_texture_groups.spec.js @@ -0,0 +1,583 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { Camera3d, InstancedMesh, loader, Mesh } from "../src/index.js"; +import { GPU_TEXTURE_CACHE_RESET, off, on } from "../src/system/event.ts"; +import { + getWebGLRenderer, + releaseWebGLRenderer, + requireWebGL, +} from "./helpers/webgl-context.js"; + +/** + * Per-material diffuse textures on a multi-material mesh (#1573). + * + * A multi-material OBJ used to bind whichever `map_Kd` came first for the + * whole model, so a crate with wood sides and a metal lid rendered entirely + * in wood. Each material's *colour* already composed correctly (it is baked + * per-vertex at construction), which made the asymmetry the confusing part. + * + * What is pinned here is both halves of the fix: the resolution — which + * index ranges end up needing their own texture, and which collapse — and + * the draw, where the ranges must become one indexed draw each over the same + * buffers, and a model that needs no split must still issue exactly the one + * draw call it always did. + * + * The fixture (`tests/public/data/models/multitex.*`) is four single-triangle + * material groups: + * + * alpha → multitex-a.png \ adjacent + same map: must MERGE + * beta → multitex-a.png / + * gamma → multitex-b.png the switch + * plain → (no map_Kd) falls back to the mesh texture (a) + * + * so the expected plan is three ranges: a[0..6), b[6..9), a[9..12). + */ +describe("Mesh per-material textures (#1573)", () => { + let renderer; + let camera; + + beforeAll(async () => { + renderer = await getWebGLRenderer(128, 128); + camera = new Camera3d(0, 0, 128, 128); + await loader.load({ + name: "multitex", + type: "obj", + src: "/data/models/multitex.obj", + }); + await loader.load({ + name: "multitex", + type: "mtl", + src: "/data/models/multitex.mtl", + }); + await loader.load({ + name: "multitex_missing", + type: "mtl", + src: "/data/models/multitex-missing.mtl", + }); + await loader.load({ + name: "multitex_kdonly", + type: "mtl", + src: "/data/models/multitex-kdonly.mtl", + }); + // a single-material OBJ + MTL pair, the no-split control + await loader.load({ + name: "single", + type: "obj", + src: "/data/models/single.obj", + }); + await loader.load({ + name: "single", + type: "mtl", + src: "/data/models/cube.mtl", + }); + await loader.load({ + name: "multitex_empty", + type: "obj", + src: "/data/models/multitex-empty.obj", + }); + }); + + afterAll(() => { + releaseWebGLRenderer(); + }); + + const makeMesh = (settings = {}) => { + return new Mesh(0, 0, { + model: "multitex", + material: "multitex", + width: 32, + ...settings, + }); + }; + + // ── resolution ────────────────────────────────────────────────────── + + describe("resolving the draw plan", () => { + it("splits into one range per distinct texture, merging adjacent sharers", (ctx) => { + requireWebGL(ctx, renderer); + const mesh = makeMesh(); + const groups = mesh.textureGroups; + expect(groups).toBeDefined(); + expect(groups).toHaveLength(3); + + expect(groups[0].start).toBe(0); + // alpha + beta collapsed into one range rather than two draws + expect(groups[0].count).toBe(6); + expect(groups[1].start).toBe(6); + expect(groups[1].count).toBe(3); + expect(groups[2].start).toBe(9); + expect(groups[2].count).toBe(3); + + // distinct maps → distinct atlases; the merged pair and the + // map-less fallback both land on the SAME atlas object, which is + // what makes merging by identity correct + expect(groups[0].texture).not.toBe(groups[1].texture); + expect(groups[2].texture).toBe(groups[0].texture); + mesh.destroy(); + }); + + it("covers every index exactly once, in order", (ctx) => { + requireWebGL(ctx, renderer); + const mesh = makeMesh(); + let cursor = 0; + for (const group of mesh.textureGroups) { + expect(group.start).toBe(cursor); + cursor += group.count; + } + expect(cursor).toBe(mesh.indices.length); + mesh.destroy(); + }); + + it("hangs each material's own texture on its group descriptor", (ctx) => { + requireWebGL(ctx, renderer); + const mesh = makeMesh(); + expect( + mesh.groups.map((g) => { + return g.materialName; + }), + ).toEqual(["alpha", "beta", "gamma", "plain"]); + expect(mesh.groups[0].texture).toBe(mesh.groups[1].texture); + expect(mesh.groups[2].texture).not.toBe(mesh.groups[0].texture); + // no map_Kd of its own → the mesh-level texture + expect(mesh.groups[3].texture).toBe(mesh.texture); + mesh.destroy(); + }); + + it("mesh.texture stays the first map_Kd — what a consumer ignoring the split draws", (ctx) => { + requireWebGL(ctx, renderer); + const mesh = makeMesh(); + expect(mesh.texture).toBe(mesh.textureGroups[0].texture); + mesh.destroy(); + }); + + it("stays undefined for a single-material model", (ctx) => { + requireWebGL(ctx, renderer); + const mesh = new Mesh(0, 0, { + model: "single", + material: "single", + width: 32, + }); + expect(mesh.textureGroups).toBeUndefined(); + mesh.destroy(); + }); + + it("stays undefined for a Kd-only multi-material model", (ctx) => { + requireWebGL(ctx, renderer); + // every group falls back to the same (white-pixel) texture, so + // there is nothing to switch — colour is baked per-vertex + const mesh = new Mesh(0, 0, { + model: "multitex", + material: "multitex_kdonly", + width: 32, + }); + expect(mesh.textureGroups).toBeUndefined(); + mesh.destroy(); + }); + + it("an explicit `texture:` suppresses the split entirely", (ctx) => { + requireWebGL(ctx, renderer); + // asking for one texture is asking for one texture — the MTL's + // per-material maps must not override the caller + const mesh = makeMesh({ texture: "multitex-b.png" }); + expect(mesh.textureGroups).toBeUndefined(); + mesh.destroy(); + }); + + it("warns and falls back when a material's map_Kd never loaded", (ctx) => { + requireWebGL(ctx, renderer); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const mesh = makeMesh({ material: "multitex_missing" }); + // the missing map names itself in the warning rather than + // throwing the whole mesh away + expect(warn).toHaveBeenCalled(); + expect( + warn.mock.calls.some((c) => { + return /multitex-nowhere/.test(c[0]); + }), + ).toBe(true); + // gamma degraded to the mesh texture, so alpha/beta/gamma/plain all + // share one binding and no split is needed at all + expect(mesh.textureGroups).toBeUndefined(); + warn.mockRestore(); + mesh.destroy(); + }); + }); + + // ── the WebGL draw ────────────────────────────────────────────────── + + describe("the retained draw", () => { + const drawOnce = (mesh) => { + mesh.preDraw(renderer); + mesh.draw(renderer, camera); + mesh.postDraw(renderer); + renderer.flush(); + }; + + it("issues one indexed draw per range, at the right byte offsets", (ctx) => { + requireWebGL(ctx, renderer); + const gl = renderer.gl; + const mesh = makeMesh(); + drawOnce(mesh); // first draw uploads the geometry + + const spy = vi.spyOn(gl, "drawElements"); + drawOnce(mesh); + expect(spy).toHaveBeenCalledTimes(3); + // (mode, count, type, byteOffset) — 12 indices as Uint16, so the + // offsets are index × 2. An offset in INDICES rather than bytes + // would draw the wrong triangles without any GL error. + expect(spy.mock.calls[0][1]).toBe(6); + expect(spy.mock.calls[0][3]).toBe(0); + expect(spy.mock.calls[1][1]).toBe(3); + expect(spy.mock.calls[1][3]).toBe(12); + expect(spy.mock.calls[2][1]).toBe(3); + expect(spy.mock.calls[2][3]).toBe(18); + expect(gl.getError()).toBe(gl.NO_ERROR); + spy.mockRestore(); + mesh.destroy(); + }); + + it("REGRESSION: a single-texture mesh still issues exactly ONE draw", (ctx) => { + requireWebGL(ctx, renderer); + const gl = renderer.gl; + const mesh = new Mesh(0, 0, { + model: "single", + material: "single", + width: 32, + }); + drawOnce(mesh); + + const spy = vi.spyOn(gl, "drawElements"); + drawOnce(mesh); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0][3]).toBe(0); + spy.mockRestore(); + mesh.destroy(); + }); + + it("binds each range's own texture, and re-uses the shared one", (ctx) => { + requireWebGL(ctx, renderer); + const mesh = makeMesh(); + drawOnce(mesh); + + const batcher = renderer.currentBatcher; + const bound = []; + const original = batcher.applyMeshMaterial; + batcher.applyMeshMaterial = function (target, texture) { + bound.push(texture); + return original.call(this, target, texture); + }; + drawOnce(mesh); + delete batcher.applyMeshMaterial; + + // the three ranges resolve in order, and the last returns to the + // first's atlas rather than picking up a third binding + expect(bound).toHaveLength(3); + expect(bound[0]).toBe(mesh.textureGroups[0].texture); + expect(bound[1]).toBe(mesh.textureGroups[1].texture); + expect(bound[2]).toBe(bound[0]); + mesh.destroy(); + }); + + it("points the sampler at a different unit for the switching range", (ctx) => { + requireWebGL(ctx, renderer); + const mesh = makeMesh(); + drawOnce(mesh); + + const batcher = renderer.currentBatcher; + const units = []; + const original = batcher.bindSamplerUnit; + batcher.bindSamplerUnit = function (unit) { + units.push(unit); + return original.call(this, unit); + }; + drawOnce(mesh); + delete batcher.bindSamplerUnit; + + // three ranges' worth of sampler binds happen inside the draw loop, + // after the pre-pass that resolved them: the middle one must differ + // from its neighbours, and the third must return to the first's unit + const inLoop = units.slice(-3); + expect(inLoop[0]).not.toBe(inLoop[1]); + expect(inLoop[2]).toBe(inLoop[0]); + mesh.destroy(); + }); + + it("does not leave the split bound for the next mesh", (ctx) => { + requireWebGL(ctx, renderer); + const gl = renderer.gl; + const split = makeMesh(); + const plain = new Mesh(0, 0, { + model: "single", + material: "single", + width: 32, + }); + drawOnce(split); + drawOnce(plain); + + const spy = vi.spyOn(gl, "drawElements"); + drawOnce(split); + drawOnce(plain); + // 3 for the split model, then 1 for the plain one — the plain mesh + // must not inherit the previous mesh's ranges + expect(spy).toHaveBeenCalledTimes(4); + expect(spy.mock.calls[3][1]).toBe(plain.indices.length); + expect(gl.getError()).toBe(gl.NO_ERROR); + spy.mockRestore(); + split.destroy(); + plain.destroy(); + }); + }); + + // ── the WebGL accumulated (Camera2d) draw ─────────────────────────── + + describe("the accumulated draw", () => { + const drawOnce2d = (mesh) => { + mesh.preDraw(renderer); + mesh.draw(renderer); + mesh.postDraw(renderer); + renderer.flush(); + }; + + it("flushes between ranges so each lands under its own texture", (ctx) => { + requireWebGL(ctx, renderer); + const gl = renderer.gl; + const mesh = makeMesh(); + // no viewport and no world-space flag → the 2D accumulated path + expect(mesh._useWorldSpace).not.toBe(true); + drawOnce2d(mesh); + + const spy = vi.spyOn(gl, "drawElements"); + drawOnce2d(mesh); + // one flush per texture change: a[0..6) and b[6..9) and a[9..12) + expect(spy).toHaveBeenCalledTimes(3); + // every range's triangles are accounted for (2 + 1 + 1 triangles) + const drawn = spy.mock.calls.reduce((sum, call) => { + return sum + call[1]; + }, 0); + expect(drawn).toBe(mesh.indices.length); + expect(gl.getError()).toBe(gl.NO_ERROR); + spy.mockRestore(); + mesh.destroy(); + }); + + it("REGRESSION: an unsplit mesh accumulates in ONE draw", (ctx) => { + requireWebGL(ctx, renderer); + const gl = renderer.gl; + const mesh = new Mesh(0, 0, { + model: "single", + material: "single", + width: 32, + }); + drawOnce2d(mesh); + + const spy = vi.spyOn(gl, "drawElements"); + drawOnce2d(mesh); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0][1]).toBe(mesh.indices.length); + spy.mockRestore(); + mesh.destroy(); + }); + }); + // ── review-hardening: cases the first cut got wrong (#1573) ───────── + + describe("hardening", () => { + const drawOnce = (mesh) => { + mesh.preDraw(renderer); + mesh.draw(renderer, camera); + mesh.postDraw(renderer); + renderer.flush(); + }; + + it("a zero-index group never names the mesh-level texture", (ctx) => { + requireWebGL(ctx, renderer); + // `gamma` (multitex-b) is declared and immediately superseded by + // `alpha` (multitex-a), so it draws nothing. Letting it supply + // `mesh.texture` painted the whole model in a map no geometry uses + // — and, with more groups, left `mesh.texture` outside the plan. + const mesh = new Mesh(0, 0, { + model: "multitex_empty", + material: "multitex", + width: 32, + }); + const alpha = new Mesh(0, 0, { + model: "multitex", + material: "multitex", + width: 32, + }).textureGroups[0].texture; + expect(mesh.texture).toBe(alpha); + // one drawing group, one texture — nothing to switch + expect(mesh.textureGroups).toBeUndefined(); + mesh.destroy(); + }); + + it("`textureFilter` reaches EVERY slice texture, not just the first", (ctx) => { + requireWebGL(ctx, renderer); + const gl = renderer.gl; + // the filter used to be written onto `mesh.texture` alone, so a + // pixel-art model rendered one material crisp and the rest through + // the renderer default — and slice B ended up with the incoherent + // pair mag=NEAREST / min=LINEAR_MIPMAP_LINEAR + const mesh = makeMesh({ textureFilter: "nearest" }); + for (const group of mesh.textureGroups) { + expect(group.texture.filter).toBe(gl.NEAREST); + } + drawOnce(mesh); + + // and it survives to the GL texture object each slice binds + for (const group of mesh.textureGroups) { + const unit = renderer.cache.getUnit(group.texture, undefined); + gl.activeTexture(gl.TEXTURE0 + unit); + const glTexture = renderer.currentBatcher.boundTextures[unit]; + gl.bindTexture(gl.TEXTURE_2D, glTexture); + expect(gl.getTexParameter(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER)).toBe( + gl.NEAREST, + ); + } + expect(gl.getError()).toBe(gl.NO_ERROR); + mesh.destroy(); + }); + + it("each range still samples its OWN texture when units are exhausted", (ctx) => { + requireWebGL(ctx, renderer); + const gl = renderer.gl; + const mesh = makeMesh(); + drawOnce(mesh); + + // Resolving every range's unit up front and only then drawing is + // wrong: the texture cache recycles from unit 0 once the budget is + // spent, invalidating units already handed out, and the earlier + // ranges then sample whatever landed last. Standing in for a real + // 16-unit budget already mostly consumed. + const cache = renderer.cache; + const budget = cache.max_size; + const seen = []; + let resets = 0; + const countReset = () => { + resets++; + }; + const batcher = renderer.currentBatcher; + const original = batcher.gl.drawElements; + batcher.gl.drawElements = function (...args) { + gl.activeTexture(gl.TEXTURE0 + batcher.currentSamplerUnit); + seen.push(gl.getParameter(gl.TEXTURE_BINDING_2D)); + return original.apply(this, args); + }; + on(GPU_TEXTURE_CACHE_RESET, countReset); + try { + // drop the standing assignments too, or `getUnit` answers from + // the cache and never reaches the allocator this test is about + cache.units.clear(); + cache.usedUnits.clear(); + cache.max_size = 1; + drawOnce(mesh); + } finally { + off(GPU_TEXTURE_CACHE_RESET, countReset); + batcher.gl.drawElements = original; + cache.max_size = budget; + cache.units.clear(); + cache.usedUnits.clear(); + } + + // the test is only meaningful if the budget actually ran out — + // assert the recycling happened rather than trusting it did + expect(resets).toBeGreaterThan(0); + expect(seen).toHaveLength(mesh.textureGroups.length); + // ranges 0 and 2 share a map, range 1 does not: whatever units the + // recycling handed out, the middle range must sample something + // different from its neighbours + expect(seen[0]).not.toBe(seen[1]); + expect(seen[1]).not.toBe(seen[2]); + expect(gl.getError()).toBe(gl.NO_ERROR); + mesh.destroy(); + }); + + it("an instanced multi-material prototype splits per range too", (ctx) => { + requireWebGL(ctx, renderer); + const gl = renderer.gl; + const mesh = new InstancedMesh(0, 0, { + model: "multitex", + material: "multitex", + width: 32, + instanceCount: 4, + }); + expect(mesh.textureGroups).toHaveLength(3); + drawOnce(mesh); + + const spy = vi.spyOn(gl, "drawElementsInstanced"); + drawOnce(mesh); + expect(spy).toHaveBeenCalledTimes(3); + // (mode, count, type, byteOffset, instanceCount) + expect( + spy.mock.calls.map((c) => { + return [c[1], c[3], c[4]]; + }), + ).toEqual([ + [6, 0, 4], + [3, 12, 4], + [3, 18, 4], + ]); + expect(gl.getError()).toBe(gl.NO_ERROR); + spy.mockRestore(); + mesh.destroy(); + }); + + it("offsets by 4 bytes per index on a WIDE (Uint32) index buffer", (ctx) => { + requireWebGL(ctx, renderer); + // Newly reachable: the OBJ parser widens past 65 536 vertices, so a + // large multi-material model takes this branch. A hard-coded stride + // of 2 would draw the wrong triangles with no GL error. + const gl = renderer.gl; + const mesh = makeMesh(); + const wide = new Mesh(0, 0, { + vertices: mesh.originalVertices, + uvs: mesh.uvs, + indices: new Uint32Array(mesh._indicesOriginal), + texture: "multitex-a.png", + width: 32, + normalize: false, + }); + // borrow the resolved plan from the multi-material mesh + wide.textureGroups = mesh.textureGroups; + drawOnce(wide); + + const spy = vi.spyOn(gl, "drawElements"); + drawOnce(wide); + expect( + spy.mock.calls.map((c) => { + return [c[1], c[2], c[3]]; + }), + ).toEqual([ + [6, gl.UNSIGNED_INT, 0], + [3, gl.UNSIGNED_INT, 24], + [3, gl.UNSIGNED_INT, 36], + ]); + expect(gl.getError()).toBe(gl.NO_ERROR); + spy.mockRestore(); + wide.destroy(); + mesh.destroy(); + }); + + it("does not mutate the settings object it was given", (ctx) => { + requireWebGL(ctx, renderer); + // the OBJ's normals used to be written back onto `settings`, which + // throws on a frozen literal and leaks one model's normals into the + // next mesh built from a reused object + const frozen = Object.freeze({ + model: "multitex", + material: "multitex", + width: 32, + }); + const first = new Mesh(0, 0, frozen); + expect(first.originalNormals).toBeDefined(); + expect(Object.hasOwn(frozen, "normals")).toBe(false); + + const shared = { model: "multitex", material: "multitex", width: 32 }; + const a = new Mesh(0, 0, shared); + const b = new Mesh(0, 0, { ...shared, model: "single" }); + expect(a.originalNormals.length).toBe(a.vertexCount * 3); + expect(b.originalNormals.length).toBe(b.vertexCount * 3); + first.destroy(); + a.destroy(); + b.destroy(); + }); + }); +}); diff --git a/packages/melonjs/tests/obj_normals.spec.js b/packages/melonjs/tests/obj_normals.spec.js new file mode 100644 index 000000000..b7f95f01f --- /dev/null +++ b/packages/melonjs/tests/obj_normals.spec.js @@ -0,0 +1,373 @@ +import { describe, expect, it } from "vitest"; +import { parseOBJ } from "../src/loader/parsers/obj.js"; + +/** + * OBJ vertex normals (#1572). + * + * `vn` used to be parsed and discarded, so an OBJ model could not be lit by + * `Light3d` even though the engine fully supports lit meshes — the same + * model imported from glTF shaded correctly while the OBJ one did not. + * That reads as a bug rather than a missing feature, which is what these + * pin. + */ +describe("OBJ vertex normals", () => { + const parse = (lines) => { + return parseOBJ(lines.join("\n")); + }; + + // a unit quad in the XY plane, wound CCW seen from +Z + const QUAD = [ + "v -1 -1 0", + "v 1 -1 0", + "v 1 1 0", + "v -1 1 0", + "vt 0 0", + "vt 1 0", + "vt 1 1", + "vt 0 1", + ]; + + it("carries authored normals through to the mesh data", () => { + const data = parse([ + ...QUAD, + "vn 0 0 1", + "f 1/1/1 2/2/1 3/3/1", + "f 1/1/1 3/3/1 4/4/1", + ]); + expect(data.normals).toBeInstanceOf(Float32Array); + expect(data.normals.length).toBe(data.vertexCount * 3); + for (let i = 0; i < data.vertexCount; i++) { + expect(Array.from(data.normals.slice(i * 3, i * 3 + 3))).toEqual([ + 0, 0, 1, + ]); + } + }); + + it("reads the `v//vn` form (no texture coordinates)", () => { + const data = parse([ + "v 0 0 0", + "v 1 0 0", + "v 0 1 0", + "vn 0 1 0", + "f 1//1 2//1 3//1", + ]); + expect(Array.from(data.normals.slice(0, 3))).toEqual([0, 1, 0]); + }); + + it("splits a vertex shared between DIFFERENT normals (a hard edge)", () => { + // the same position+uv referenced with two normals must become two + // vertices, or the hard edge smooths itself away + const data = parse([ + "v 0 0 0", + "v 1 0 0", + "v 0 1 0", + "v 1 1 0", + "vn 0 0 1", + "vn 1 0 0", + "f 1//1 2//1 3//1", + "f 1//2 2//2 4//2", + ]); + // vertices 1 and 2 appear under both normals → 6 unified vertices + expect(data.vertexCount).toBe(6); + const seen = new Set(); + for (let i = 0; i < data.vertexCount; i++) { + seen.add(data.normals.slice(i * 3, i * 3 + 3).join(",")); + } + expect(seen.has("0,0,1")).toBe(true); + expect(seen.has("1,0,0")).toBe(true); + }); + + it("does NOT split when the normal is shared (no needless duplication)", () => { + const data = parse([ + ...QUAD, + "vn 0 0 1", + "f 1/1/1 2/2/1 3/3/1", + "f 1/1/1 3/3/1 4/4/1", + ]); + expect(data.vertexCount).toBe(4); + }); + + it("generates unit normals when the file supplies none", () => { + const data = parse([...QUAD, "f 1/1 2/2 3/3", "f 1/1 3/3 4/4"]); + expect(data.normals.length).toBe(data.vertexCount * 3); + for (let i = 0; i < data.vertexCount; i++) { + const n = data.normals.slice(i * 3, i * 3 + 3); + expect(Math.hypot(n[0], n[1], n[2])).toBeCloseTo(1, 5); + } + }); + + it("generated normals follow the CORRECTED winding, not the authored one", () => { + // a closed CW-wound tetrahedron: the parser flips it to CCW, and the + // generated normals have to be computed after that or they point + // inward — the exact case the winding correction exists to fix + const cw = parse([ + "v 0 0 0", + "v 1 0 0", + "v 0 1 0", + "v 0 0 1", + "f 1 3 2", + "f 1 2 4", + "f 1 4 3", + "f 2 3 4", + ]); + // every generated normal must point AWAY from the centroid + let cx = 0; + let cy = 0; + let cz = 0; + for (let i = 0; i < cw.vertexCount; i++) { + cx += cw.vertices[i * 3]; + cy += cw.vertices[i * 3 + 1]; + cz += cw.vertices[i * 3 + 2]; + } + cx /= cw.vertexCount; + cy /= cw.vertexCount; + cz /= cw.vertexCount; + for (let i = 0; i < cw.vertexCount; i++) { + const outward = + (cw.vertices[i * 3] - cx) * cw.normals[i * 3] + + (cw.vertices[i * 3 + 1] - cy) * cw.normals[i * 3 + 1] + + (cw.vertices[i * 3 + 2] - cz) * cw.normals[i * 3 + 2]; + expect(outward, `vertex ${i}`).toBeGreaterThan(0); + } + }); + + it("an unreferenced vertex gets a valid unit normal, never a zero vector", () => { + // a zero normal normalizes to NaN in the shader and the fragment + // turns black — worse than being slightly wrong + const data = parse(["v 0 0 0", "v 1 0 0", "v 0 1 0", "f 1 2 3"]); + for (let i = 0; i < data.vertexCount; i++) { + const n = data.normals.slice(i * 3, i * 3 + 3); + expect(Number.isFinite(Math.hypot(n[0], n[1], n[2]))).toBe(true); + expect(Math.hypot(n[0], n[1], n[2])).toBeGreaterThan(0); + } + }); + + it("normals are stored RAW — the axis bridge is applied at draw", () => { + // flipping here would double the bridge the model matrix already + // applies (`mat3(uModelMatrix) * aNormal`), exactly as for glTF + const data = parse([ + "v 0 0 0", + "v 1 0 0", + "v 0 1 0", + "vn 0 -1 0", + "f 1//1 2//1 3//1", + ]); + expect(Array.from(data.normals.slice(0, 3))).toEqual([0, -1, 0]); + }); + // ── review-hardening: cases the first cut got wrong (#1572) ───────── + + describe("normal resolution is per-vertex, never per-file", () => { + const unitLength = (normals) => { + const lengths = []; + for (let i = 0; i < normals.length; i += 3) { + lengths.push(Math.hypot(normals[i], normals[i + 1], normals[i + 2])); + } + return lengths; + }; + + it("generates for faces that omit `vn` in a file where others carry it", () => { + // mixed authoring: the un-normalled face used to come out (0,0,0) + // because generation was gated on "the file declared any vn" + const data = parse([ + ...QUAD, + "v 3 -1 0", + "v 5 -1 0", + "v 3 1 0", + "vn 0 0 1", + "f 1/1/1 2/2/1 3/3/1", + "f 5 6 7", + ]); + expect( + unitLength(data.normals).every((l) => { + return Math.abs(l - 1) < 1e-5; + }), + ).toBe(true); + }); + + it("generates when `vn` is declared but no face references it", () => { + const data = parse([...QUAD, "vn 0 0 1", "f 1/1 2/2 3/3"]); + expect( + unitLength(data.normals).every((l) => { + return Math.abs(l - 1) < 1e-5; + }), + ).toBe(true); + }); + + it("an out-of-range `vn` index degrades to a generated normal, never NaN", () => { + // `sourceNormals[vn3]` is undefined past the end; writing it + // straight through produced NaN, which no downstream guard catches + const data = parse([...QUAD, "vn 0 0 1", "f 1//9 2//9 3//9"]); + expect(Array.from(data.normals).some(Number.isNaN)).toBe(false); + expect( + unitLength(data.normals).every((l) => { + return Math.abs(l - 1) < 1e-5; + }), + ).toBe(true); + }); + + it("resolves a `vn` declared AFTER the face that references it", () => { + // legal enough to appear in the wild, and single-pass resolution + // read it before it existed + const data = parse([...QUAD, "f 1//1 2//1 3//1", "vn 0 0 1"]); + expect(Array.from(data.normals.slice(0, 3))).toEqual([0, 0, 1]); + }); + + it("an empty normal field (`v/vt/`) is treated as absent", () => { + const data = parse([...QUAD, "vn 0 0 1", "f 1/1/ 2/2/ 3/3/"]); + expect(Array.from(data.normals).some(Number.isNaN)).toBe(false); + expect( + unitLength(data.normals).every((l) => { + return Math.abs(l - 1) < 1e-5; + }), + ).toBe(true); + }); + }); + + describe("generated normals are angle-weighted", () => { + // a unit cube as six QUADS — the shape that exposes fan bias, since + // fan-triangulating a quad hands its pivot corners two triangles' + // worth of one face and the others only one + const CUBE = [ + "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", + "f 1 4 3 2", + "f 5 6 7 8", + "f 1 2 6 5", + "f 2 3 7 6", + "f 3 4 8 7", + "f 4 1 5 8", + ]; + + it("every corner of a quad-built cube points along its own diagonal", () => { + // area weighting gives (1, 0.5, 0.5) at a corner instead of + // (1, 1, 1) — 19.5 degrees off, and asymmetric on a symmetric model + const data = parse(CUBE); + const inv = 1 / Math.sqrt(3); + for (let i = 0; i < data.vertexCount; i++) { + const at = i * 3; + const expected = [ + Math.sign(data.vertices[at]) * inv, + Math.sign(data.vertices[at + 1]) * inv, + Math.sign(data.vertices[at + 2]) * inv, + ]; + const dot = + data.normals[at] * expected[0] + + data.normals[at + 1] * expected[1] + + data.normals[at + 2] * expected[2]; + // within a degree of the exact diagonal + expect(Math.acos(Math.min(1, dot)) * (180 / Math.PI)).toBeLessThan(1); + } + }); + + it("is unaffected by how the polygon was triangulated", () => { + // the same cube authored as triangles must shade identically + const quads = parse(CUBE); + const tris = parse([ + ...CUBE.slice(0, 8), + "f 1 4 3", + "f 1 3 2", + "f 5 6 7", + "f 5 7 8", + "f 1 2 6", + "f 1 6 5", + "f 2 3 7", + "f 2 7 6", + "f 3 4 8", + "f 3 8 7", + "f 4 1 5", + "f 4 5 8", + ]); + for (let i = 0; i < quads.normals.length; i++) { + expect(tris.normals[i]).toBeCloseTo(quads.normals[i], 5); + } + }); + + it("stay smooth across a `usemtl` boundary", () => { + // generation accumulates per SOURCE POSITION: the per-material + // dedup scope splits one corner into several unified vertices, and + // accumulating per unified vertex creased the model at every + // material seam + const data = parse([ + ...CUBE.slice(0, 8), + "usemtl a", + "f 1 4 3 2", + "f 5 6 7 8", + "f 1 2 6 5", + "usemtl b", + "f 2 3 7 6", + "f 3 4 8 7", + "f 4 1 5 8", + ]); + // find the unified vertices sharing one corner position + const seen = new Map(); + for (let i = 0; i < data.vertexCount; i++) { + const at = i * 3; + const key = `${data.vertices[at]}|${data.vertices[at + 1]}|${data.vertices[at + 2]}`; + const previous = seen.get(key); + if (previous !== undefined) { + expect(data.normals[at]).toBeCloseTo(data.normals[previous], 5); + expect(data.normals[at + 1]).toBeCloseTo( + data.normals[previous + 1], + 5, + ); + expect(data.normals[at + 2]).toBeCloseTo( + data.normals[previous + 2], + 5, + ); + } else { + seen.set(key, at); + } + } + // the fixture must actually produce the duplicates it is testing + expect(seen.size).toBeLessThan(data.vertexCount); + }); + }); + + describe("index buffer width", () => { + it("widens to Uint32 when normal splitting pushes past 65 536 vertices", () => { + // REGRESSION: splitting a position per distinct normal can treble + // a flat-shaded model's unified vertex count. A hard-coded Uint16 + // index buffer then wraps mod 65 536 in silence, stitching the tail + // of the model to its head. + const lines = []; + const side = 150; + for (let y = 0; y <= side; y++) { + for (let x = 0; x <= side; x++) { + lines.push(`v ${x} ${y} 0`); + } + } + // one distinct normal per quad, so every corner splits + const stride = side + 1; + const faces = []; + for (let y = 0; y < side; y++) { + for (let x = 0; x < side; x++) { + const n = y * side + x + 1; + lines.push(`vn 0 0 ${1 + n * 1e-6}`); + const a = y * stride + x + 1; + faces.push(`f ${a}//${n} ${a + 1}//${n} ${a + stride}//${n}`); + } + } + const data = parse([...lines, ...faces]); + + expect(data.vertexCount).toBeGreaterThan(65536); + expect(data.indices).toBeInstanceOf(Uint32Array); + let max = 0; + for (let i = 0; i < data.indices.length; i++) { + max = Math.max(max, data.indices[i]); + } + // every index still addresses a real vertex + expect(max).toBe(data.vertexCount - 1); + }); + + it("stays Uint16 for an ordinary model", () => { + const data = parse([...QUAD, "vn 0 0 1", "f 1/1/1 2/2/1 3/3/1"]); + expect(data.indices).toBeInstanceOf(Uint16Array); + }); + }); +}); diff --git a/packages/melonjs/tests/public/data/models/multitex-a.png b/packages/melonjs/tests/public/data/models/multitex-a.png new file mode 100644 index 000000000..9171cc10a Binary files /dev/null and b/packages/melonjs/tests/public/data/models/multitex-a.png differ diff --git a/packages/melonjs/tests/public/data/models/multitex-b.png b/packages/melonjs/tests/public/data/models/multitex-b.png new file mode 100644 index 000000000..2baa86360 Binary files /dev/null and b/packages/melonjs/tests/public/data/models/multitex-b.png differ diff --git a/packages/melonjs/tests/public/data/models/multitex-empty.obj b/packages/melonjs/tests/public/data/models/multitex-empty.obj new file mode 100644 index 000000000..d5883507d --- /dev/null +++ b/packages/melonjs/tests/public/data/models/multitex-empty.obj @@ -0,0 +1,14 @@ +# fixture for the empty-material-group case (#1573) +# `gamma` is declared and then immediately superseded, so it owns zero +# indices — its map_Kd must not be allowed to name the mesh-level texture, +# since nothing it covers is ever drawn +mtllib multitex.mtl +v 0 0 0 +v 1 0 0 +v 0 1 0 +vt 0 0 +vt 1 0 +vt 0 1 +usemtl gamma +usemtl alpha +f 1/1 2/2 3/3 diff --git a/packages/melonjs/tests/public/data/models/multitex-kdonly.mtl b/packages/melonjs/tests/public/data/models/multitex-kdonly.mtl new file mode 100644 index 000000000..9e4fc3fa8 --- /dev/null +++ b/packages/melonjs/tests/public/data/models/multitex-kdonly.mtl @@ -0,0 +1,11 @@ +newmtl alpha +Kd 1.0 0.2 0.2 + +newmtl beta +Kd 0.2 1.0 0.2 + +newmtl gamma +Kd 0.2 0.2 1.0 + +newmtl plain +Kd 0.9 0.9 0.9 diff --git a/packages/melonjs/tests/public/data/models/multitex-missing.mtl b/packages/melonjs/tests/public/data/models/multitex-missing.mtl new file mode 100644 index 000000000..4cd6f8ab4 --- /dev/null +++ b/packages/melonjs/tests/public/data/models/multitex-missing.mtl @@ -0,0 +1,14 @@ +newmtl alpha +Kd 1.0 0.2 0.2 +map_Kd multitex-a.png + +newmtl beta +Kd 0.2 1.0 0.2 +map_Kd multitex-a.png + +newmtl gamma +Kd 0.2 0.2 1.0 +map_Kd multitex-nowhere.png + +newmtl plain +Kd 0.9 0.9 0.9 diff --git a/packages/melonjs/tests/public/data/models/multitex.mtl b/packages/melonjs/tests/public/data/models/multitex.mtl new file mode 100644 index 000000000..7c4d9e1a6 --- /dev/null +++ b/packages/melonjs/tests/public/data/models/multitex.mtl @@ -0,0 +1,14 @@ +newmtl alpha +Kd 1.0 0.2 0.2 +map_Kd multitex-a.png + +newmtl beta +Kd 0.2 1.0 0.2 +map_Kd multitex-a.png + +newmtl gamma +Kd 0.2 0.2 1.0 +map_Kd multitex-b.png + +newmtl plain +Kd 0.9 0.9 0.9 diff --git a/packages/melonjs/tests/public/data/models/multitex.obj b/packages/melonjs/tests/public/data/models/multitex.obj new file mode 100644 index 000000000..7e6115bb1 --- /dev/null +++ b/packages/melonjs/tests/public/data/models/multitex.obj @@ -0,0 +1,28 @@ +# fixture for the per-material texture specs (#1573) +# four single-triangle material groups, laid out so the resolved draw +# ranges exercise merging (alpha + beta share a map), switching (gamma), +# and the mesh-texture fallback (plain declares no map_Kd) +mtllib multitex.mtl +v 0 0 0 +v 1 0 0 +v 0 1 0 +v 2 0 0 +v 3 0 0 +v 2 1 0 +v 4 0 0 +v 5 0 0 +v 4 1 0 +v 6 0 0 +v 7 0 0 +v 6 1 0 +vt 0 0 +vt 1 0 +vt 0 1 +usemtl alpha +f 1/1 2/2 3/3 +usemtl beta +f 4/1 5/2 6/3 +usemtl gamma +f 7/1 8/2 9/3 +usemtl plain +f 10/1 11/2 12/3 diff --git a/packages/melonjs/tests/public/data/models/single.obj b/packages/melonjs/tests/public/data/models/single.obj new file mode 100644 index 000000000..b8d57f2c7 --- /dev/null +++ b/packages/melonjs/tests/public/data/models/single.obj @@ -0,0 +1,13 @@ +# single-material control for the per-material texture specs (#1573) +mtllib cube.mtl +v 0 0 0 +v 1 0 0 +v 0 1 0 +v 1 1 0 +vt 0 0 +vt 1 0 +vt 0 1 +vt 1 1 +usemtl cube +f 1/1 2/2 3/3 +f 2/2 4/4 3/3 diff --git a/packages/melonjs/tests/webgpu_texture_groups.spec.js b/packages/melonjs/tests/webgpu_texture_groups.spec.js new file mode 100644 index 000000000..6fb2ebc78 --- /dev/null +++ b/packages/melonjs/tests/webgpu_texture_groups.spec.js @@ -0,0 +1,163 @@ +import "./helpers/webgpu-globals.js"; +import { beforeEach, describe, expect, it } from "vitest"; +import WebGPUMeshBatcher from "../src/video/webgpu/batchers/mesh_batcher.js"; +import { createMockWebGPURenderer } from "./helpers/webgpu-mock-renderer.js"; + +/** + * Per-material diffuse textures on WebGPU (#1573) — the same contract the + * GL backend holds, expressed in this backend's vocabulary: one + * `drawIndexed` per range with a `firstIndex`, and the group-1 material + * binding moving between them. + * + * The mesh stand-ins carry `textureGroups` exactly as `Mesh` builds it, so + * what is under test is the batcher's half of the split and nothing else. + */ +const WOOD = { id: "wood" }; +const METAL = { id: "metal" }; + +function makeMesh(overrides = {}) { + return { + originalVertices: new Float32Array([ + -0.5, -0.5, 0, 0.5, -0.5, 0, 0.5, 0.5, 0, -0.5, 0.5, 0, + ]), + // the accumulated path reads the CPU-projected pair, the retained one + // the model-space original — a real Mesh carries both + vertices: new Float32Array([ + -0.5, -0.5, 0, 0.5, -0.5, 0, 0.5, 0.5, 0, -0.5, 0.5, 0, + ]), + indices: new Uint16Array([0, 1, 2, 0, 2, 3]), + uvs: new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]), + _indicesOriginal: new Uint16Array([0, 1, 2, 0, 2, 3]), + _geometryVersion: 0, + vertexCount: 4, + texture: WOOD, + textureRepeat: undefined, + vertexColors: undefined, + alphaCutoff: 0, + emissive: undefined, + lit: false, + cullBackFaces: true, + rightHanded: false, + textureGroups: undefined, + ...overrides, + }; +} + +// two ranges over the quad: the first triangle in wood, the second in metal +const SPLIT = [ + { texture: WOOD, start: 0, count: 3 }, + { texture: METAL, start: 3, count: 3 }, +]; + +const MODEL = (() => { + const val = new Float32Array(16); + val[0] = val[5] = val[10] = val[15] = 1; + return { val }; +})(); + +describe("WebGPU per-material textures (#1573)", () => { + let renderer; + let batcher; + + beforeEach(() => { + renderer = createMockWebGPURenderer(); + batcher = new WebGPUMeshBatcher(renderer); + }); + + describe("the retained draw", () => { + it("records one indexed range per texture group", () => { + const mesh = makeMesh({ textureGroups: SPLIT }); + batcher.drawRetainedMesh(mesh, MODEL, 0xffffffff); + + expect(renderer.calls.drawIndexedArgs).toEqual([ + { count: 3, instanceCount: 1, firstIndex: 0 }, + { count: 3, instanceCount: 1, firstIndex: 3 }, + ]); + }); + + it("REGRESSION: an unsplit mesh still records exactly ONE whole-mesh draw", () => { + batcher.drawRetainedMesh(makeMesh(), MODEL, 0xffffffff); + expect(renderer.calls.drawIndexed).toEqual([6]); + // the unsplit path keeps its argument shape too — a `firstIndex` of + // `undefined` is the whole buffer, same call it always made + expect(renderer.calls.drawIndexedArgs).toEqual([ + { count: 6, instanceCount: undefined, firstIndex: undefined }, + ]); + }); + + it("re-binds group 1 for each range, with distinct bindings", () => { + const mesh = makeMesh({ textureGroups: SPLIT }); + batcher.drawRetainedMesh(mesh, MODEL, 0xffffffff); + + const binds = renderer.calls.materialBinds; + expect(binds).toHaveLength(2); + expect(binds[0]).not.toBe(binds[1]); + // each range asked the texture store for its OWN texture + const requested = renderer.calls.textureBindings.map((b) => { + return b.texture; + }); + expect(requested).toContain(WOOD); + expect(requested).toContain(METAL); + }); + + it("uploads the geometry ONCE for a split mesh — the ranges share buffers", () => { + const mesh = makeMesh({ textureGroups: SPLIT }); + batcher.drawRetainedMesh(mesh, MODEL, 0xffffffff); + + const geometry = renderer.calls.writes.filter((w) => { + return ( + w.buffer.label === "melonJS retained mesh vertices" || + w.buffer.label === "melonJS retained mesh indices" + ); + }); + expect(geometry).toHaveLength(2); + // one vertex buffer, one index buffer — bound once, drawn twice + expect(renderer.calls.vertexBufferBinds).toHaveLength(1); + expect(renderer.calls.indexBufferBinds).toHaveLength(1); + }); + + it("keeps the placement uniforms to ONE snapshot across the ranges", () => { + const mesh = makeMesh({ textureGroups: SPLIT }); + batcher.drawRetainedMesh(mesh, MODEL, 0xffffffff); + // the split is a material change, not a placement change: re-arming + // the uniform arena per range would burn a region per material + const uniformWrites = renderer.calls.writes.filter((w) => { + return w.buffer.label === undefined || /uniform/i.test(w.buffer.label); + }); + expect(uniformWrites.length).toBeLessThanOrEqual(1); + }); + }); + + describe("the accumulated draw", () => { + it("flushes between ranges so each records under its own binding", () => { + const mesh = makeMesh({ textureGroups: SPLIT }); + batcher.addMesh(mesh, 0xffffffff); + batcher.flush(); + + // two recorded draws, three vertices each — one per range + expect(renderer.calls.drawIndexed).toEqual([3, 3]); + expect(renderer.calls.materialBinds).toHaveLength(2); + expect(renderer.calls.materialBinds[0]).not.toBe( + renderer.calls.materialBinds[1], + ); + }); + + it("REGRESSION: an unsplit mesh accumulates into ONE draw", () => { + batcher.addMesh(makeMesh(), 0xffffffff); + batcher.flush(); + expect(renderer.calls.drawIndexed).toEqual([6]); + }); + + it("ADVERSARIAL: a range's vertices are not leaked into the next one", () => { + // the versioned remap is reset per chunk; a range that reused the + // previous range's remap would emit indices pointing at vertices + // that were already flushed away + const mesh = makeMesh({ textureGroups: SPLIT }); + batcher.addMesh(mesh, 0xffffffff); + batcher.flush(); + // 3 vertices per range (no sharing across the split), so each + // flushed draw indexes only what it pushed + expect(renderer.calls.drawIndexed).toEqual([3, 3]); + }); + }); +});