From 7e1f8e0f3fd4b746133c58cc0911670ec7aed9b3 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Fri, 7 Aug 2026 18:55:45 +0800 Subject: [PATCH 1/2] MTL specular and per-texel opacity (#1575 items 1 and 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MTL parser recognised around twenty properties and consumed five, so an authored highlight was read and thrown away and an alpha map was rejected outright. Both destinations already existed. Specular (Ks + Ns) gives the lit mesh path a Blinn-Phong term where it was half-Lambert diffuse plus an ambient floor, so every material read as chalk. Two decisions worth recording: - Gated on the EXPONENT, not the colour. `Ns` of 0 is the format's "no highlight" and exporters routinely write a bright `Ks` beside it, so reading the colour alone would put a full-strength highlight on every matte material in the wild. - Masked by the UNWRAPPED Lambert term. Half-Lambert deliberately lifts the shadowed side, and reusing it would light a highlight on a surface facing away from the light. The eye position the half-vector needs is derived from the view matrix as -RT*t rather than plumbed from the camera, so it stays correct for any caller that sets a view directly. `map_d` drives alphaCutoff per texel instead of per material — the shape of a leaf rather than one threshold across a whole surface. It multiplies alpha BEFORE the cutout; the other order cuts nothing out. Both backends sample unconditionally and weight by a flag rather than branching: one backend branching and the other not is exactly how the emissive early-return diverged in #1572, and weighting also keeps the WGSL sample in uniform control flow. WebGPU needed the second texture. Group 1 for the mesh family grows from two bindings to four and MeshUniforms from 176 to 208 bytes. A mesh with no map binds its own diffuse texture as filler, so there is no extra unit and no extra upload, and the bind-group cache is keyed on the alpha record OBJECT — one diffuse shared by two meshes with different masks must not hand the second mesh the first's cut-outs. Widening the layout does not break the documented custom-WGSL mesh contract: a module may declare a subset of its layout's bindings. The glTF loader now maps metallic/roughness onto the same terms, which is where the factors every asset already carries can finally land. It is an approximation onto a stylized shading model, not a PBR implementation: roughness -> exponent through the usual GGX bridge, with the glTF DEFAULT roughness of 1 landing on exactly 0 so a scene that declares nothing is untouched; metallic -> tint between the dielectric F0 and the base colour. Note this changes no shipped example — every glTF asset in the repo is authored fully rough — so it is unblocking imported assets, not improving existing ones. Also folded in, unrelated to the above: - packages/examples/LICENSE.md had the multiMaterialMesh attribution cut mid-sentence by the Water Overworld paragraph. Pre-existing. - The #1573 unit-exhaustion spec cleared the SHARED session renderer's unit assignments without announcing it, leaving every batcher's boundTextures claiming units the cache no longer considered assigned. The example is reworked into a Camera3d scene showing all of it: the crate for #1573, a chrome ball for the highlight (smooth because its normals are generated, #1572), a perforated panel for the cutout. Textures regenerated at 256x256 with antiAlias on — at 64x64 they were magnified into blocks. Tests: +31, adversarial where the trap is silent — a bright Ks with Ns 0, an Ns with no Ks, the sample-before-cutout ordering pinned across all four shader sources, the specular/emissive uniform offsets, one diffuse with two masks, and the glTF defaults. That last one caught a real bug: a malformed roughnessFactor made the exponent NaN, failed the `> 0` test and fell into the mirror-smooth branch, turning a broken file into chrome. The four new uniforms were also being set unconditionally per draw while every other uniform in that method is change-guarded, which timed out a fuzz spec; all four are guarded now. WGSL verified against a real device (both tiers plus all four derived instanced variants, zero messages) since the in-tree validation spec skips without one. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi --- packages/examples/LICENSE.md | 20 +- .../assets/materialTextures/crate-label.png | Bin 200 -> 1055 bytes .../assets/materialTextures/crate-metal.png | Bin 2055 -> 26437 bytes .../assets/materialTextures/crate-wood.png | Bin 3454 -> 34279 bytes .../public/assets/materialTextures/panel.obj | 23 ++ .../public/assets/materialTextures/props.mtl | 20 ++ .../assets/materialTextures/vent-mask.png | Bin 0 -> 3730 bytes .../ExampleMaterialTextures.tsx | 205 +++++++++++----- packages/examples/src/main.tsx | 2 +- packages/melonjs/CHANGELOG.md | 1 + packages/melonjs/src/level/gltf/GLTFModel.js | 4 + packages/melonjs/src/level/gltf/GLTFScene.js | 4 + packages/melonjs/src/loader/parsers/gltf.js | 53 ++++ packages/melonjs/src/loader/parsers/mtl.js | 61 +++-- packages/melonjs/src/renderable/mesh.js | 73 ++++++ .../src/video/webgl/batchers/mesh_batcher.js | 111 +++++++++ .../src/video/webgl/shaders/mesh-lit.frag | 34 ++- .../melonjs/src/video/webgl/shaders/mesh.frag | 11 + .../video/webgpu/batchers/lit_mesh_batcher.js | 2 +- .../src/video/webgpu/batchers/mesh_batcher.js | 52 +++- .../src/video/webgpu/pipeline/cache.js | 20 ++ .../src/video/webgpu/shaders/mesh-lit.wgsl | 46 +++- .../src/video/webgpu/shaders/mesh.wgsl | 21 +- .../melonjs/src/video/webgpu/texture/store.js | 125 ++++++++-- packages/melonjs/tests/gltf.spec.js | 80 +++++- .../tests/helpers/webgpu-mock-renderer.js | 17 ++ .../melonjs/tests/mesh_texture_groups.spec.js | 36 ++- packages/melonjs/tests/mtl_material.spec.js | 229 ++++++++++++++++++ .../tests/public/data/models/mat-alpha.obj | 10 + .../tests/public/data/models/mat-beta.obj | 10 + .../tests/public/data/models/mat-gamma.obj | 10 + .../tests/public/data/models/mat-plain.obj | 10 + .../public/data/models/multitex-alpha.png | Bin 0 -> 75 bytes .../public/data/models/multitex-material.mtl | 23 ++ .../melonjs/tests/webgpu_mesh_batcher.spec.js | 5 +- .../melonjs/tests/webgpu_mtl_material.spec.js | 207 ++++++++++++++++ 36 files changed, 1387 insertions(+), 138 deletions(-) create mode 100644 packages/examples/public/assets/materialTextures/panel.obj create mode 100644 packages/examples/public/assets/materialTextures/props.mtl create mode 100644 packages/examples/public/assets/materialTextures/vent-mask.png create mode 100644 packages/melonjs/tests/mtl_material.spec.js create mode 100644 packages/melonjs/tests/public/data/models/mat-alpha.obj create mode 100644 packages/melonjs/tests/public/data/models/mat-beta.obj create mode 100644 packages/melonjs/tests/public/data/models/mat-gamma.obj create mode 100644 packages/melonjs/tests/public/data/models/mat-plain.obj create mode 100644 packages/melonjs/tests/public/data/models/multitex-alpha.png create mode 100644 packages/melonjs/tests/public/data/models/multitex-material.mtl create mode 100644 packages/melonjs/tests/webgpu_mtl_material.spec.js diff --git a/packages/examples/LICENSE.md b/packages/examples/LICENSE.md index 45d13c0f4..0074c3e29 100644 --- a/packages/examples/LICENSE.md +++ b/packages/examples/LICENSE.md @@ -51,18 +51,28 @@ courtesy to the original creator. Spacecraft 3D models (`craft_speederA`, `craft_speederB`, `craft_racer`, `craft_miner`) in `public/assets/multiMaterialMesh/` are taken from +**"Space Kit (2.0)"** published by Kenney: + + + +Released under **CC0 1.0 Universal (Public Domain Dedication)** — no +attribution legally required, credited here as a courtesy. + +### `waterOverworld` example + The Water Overworld example (`waterOverworld/`) uses the **"Free Pixel Art Side Scroller Asset Pack (32x32) Overworld"** published by **GandalfHardcore**, free to use: -**"Space Kit (2.0)"** published by Kenney: - - +### `materialTextures` example -Released under **CC0 1.0 Universal (Public Domain Dedication)** — no -attribution legally required, credited here as a courtesy. +The models (`crate.obj`, `panel.obj`), their material files and every +texture in `public/assets/materialTextures/` were authored for this +repository — hand-written geometry and procedurally generated maps. No +third-party assets, no attribution required. The ball re-uses the shared +`assets/mesh3d/sphere.obj` primitive from the mesh3d example. ### `gltf` examples diff --git a/packages/examples/public/assets/materialTextures/crate-label.png b/packages/examples/public/assets/materialTextures/crate-label.png index 845ff3f38475999597b3fff58f0877bfeb594c36..57bff9d87671d4edd6af2388704746821995b5e5 100644 GIT binary patch literal 1055 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5893O0R7}x|GzJFdXPz#OAr-gYURlW7;2_|7 zP@B0?L8L&)@&Cb#th2-xFrR6CbEvnXZ1c~gQ&G>ogXW7WdoWC3VerI8#4>Eq4Gmhv zpa8T+h=F8Eh0#HXfi!}F!GVc^L4|b6gQ-D<0UxpC48xq`dYXX@6M$jG$v`qSi9vyr zfh2;A5Q7FWG|V0>T~?T(^{ejNqVL`J{=fbYitJG^P$6)^kYUn1<^~o91`h@XQYlzopr0NQzPa{vGU literal 200 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 CQ4muA literal 2055 zcmV+i2>ADjP)W7+ znLhj@|MYY>rBDC@Gfl*R%xTZN;{)> zdcJNY=}l*zR%{;Ydee0}Tzp8&H$$y$+SpmSt!vZP@t*7S?r+IQ59`poPd_?tx^rEz z0XgWpZYOE2GfOXP1C0%4@3Xm2wubS}_?_%w{^a&AU3uQsU*ez9@=-!wUo_Gme|eoN z0LVv=j5fXd^ydk{uKp4Z@bISEOJDt7Nx%Ey*IWTWK6=n6C->;j6M$X)CH@>hgu(BB z{>>a+<_bW2^j3P%hu&TNyw4MW!EEzPe~JGS{8@m^MXfI*S&T9S;y6Yjbh7$n26v>S zW&q$ANDk*8w5Hx&XuO|12msbLMVW*~+h69DEj7d`9@4jvN# zbPnh53jhiLs?AB}Fv>BuQXlCV2gl2ga++t1L6AtzlyoQeQrOxeDZX0V0f1mVaxnsc zSbt#-pa-x4hQjUT~G_GubU;u(@?U@`U@L>$1f*>70oW?nvy=qMKjQn;C zs30JW!LdMuGhLInpF>1=-IHFNMJ95P2cbFfL1w-V$E(e0B;c7GV?H_*nArwn_r-Nz zn}qW?&ZZ%+0H6Ti)B)guU;;A4X$(Uc3%ZlFf)IilgaVEWA*jJX3=bfN%*6qec)*0i zNen`8gmuVa9`8ID@p(8TA~qW|&o)H~4b#geU>Emnh7Mo7;56&86JiEX#&Mp24xJtV zi7ah=Wb;^4sgDZ)3II+X02caz#fb^xfHWW{4{};cGOYBD#f+DwV3iK$VGhK{8r}mY zN=2}?M!^t?6`Kpmq7zc%@WOn871l$>Bq}fznam@2LJzhHSOcXJ6Su5k?lzHf1q{Z+ zW^srL5LjfIFyJB;8M z!3qi?Hx)ii*1oy`hnJAZoJ$A93^-%Rmn4miVas{7t9vC-;=xS`k9YuM98$$L0OO#= zJu;khv%0`;W#$+Fyi^%F1^~}0L;GrTc>-`qZ7@#&zNiiEtIp;Lz#-M)JOTKkI=rtw zk8`a^VhHpEDMI;w$|oyQP=M4S=OHUfkd(w>6;VwQ27-8NczaNPz;r8+$__c^G!81^ zV(*mnAh?PF8@foAVjJL$S04jus#v;_Zq^*sACM;ihx`TP3BW=90eJ#&2nXZ|z&HB? z_}=(B1%XUPxfdlo5$XgZ zB8mlux{8k@Io^zgMx69!3l%PtjphbYY^UAvy>w9I=)}e>f!7Hn?8nutQA5d%&}gZ~ zs7TBdQ2C$(pn2zo5T23Y>m`q7EZYPcwXlgms;t9RQUFi@aPj~^XbeI)o`B#bWa5pD zo>+hsort_)$rF_xfIu#u5b)2w<=eTS%~! zBSA7RBu;u+0Ot*r*h(V>B$+jS#o!4a{;*9D2^Ihp0Gv1g7)eso$pqs?DmED~P!JAF zt5A%Lgy&6+I08@&HCqp|hGi!=4ow2q#zrbugdb#xEKP?uU!#|U7Qj!{AiWl8`GKq+ z4=&HjKq0XU>Km?r=S l^#|k$z#-M)JOS9%|35!;^HxY#Lw^7O002ovPDHLkV1j~Ec=!MS diff --git a/packages/examples/public/assets/materialTextures/crate-wood.png b/packages/examples/public/assets/materialTextures/crate-wood.png index 3f35e9d4dfd86ba0f30199fbde6796e294754639..07600095bca6de5f5cd3eba1cd67fd1440e5b17d 100644 GIT binary patch literal 34279 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 M07*qoM6N<$g2-lV@c;k- literal 3454 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 MF*G+QKJ0e&zi2y4DF6Tf literal 0 HcmV?d00001 diff --git a/packages/examples/src/examples/materialTextures/ExampleMaterialTextures.tsx b/packages/examples/src/examples/materialTextures/ExampleMaterialTextures.tsx index 0abafc156..8b8224876 100644 --- a/packages/examples/src/examples/materialTextures/ExampleMaterialTextures.tsx +++ b/packages/examples/src/examples/materialTextures/ExampleMaterialTextures.tsx @@ -1,30 +1,37 @@ /** - * melonJS — Per-material diffuse textures on a multi-material OBJ. + * melonJS — What an MTL can carry: three props, three material features. * - * 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. + * All three are plain OBJ + MTL pairs under a `Camera3d` with one directional + * `Light3d` and an ambient fill. Nothing is wired by hand — every material + * property below comes out of the `.mtl`. * - * 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. + * - **Crate** — three materials, three different `map_Kd` maps (wood boards, + * steel plate, a shipping label). The `Mesh` reduces them to the shortest + * list of index ranges that need a different texture and the GPU backends + * draw one range each, so this costs three draws rather than one per + * material (#1573). + * - **Ball** — `Ks` + `Ns`, a specular highlight on the lit mesh path, which + * was previously half-Lambert diffuse plus an ambient floor and read as + * chalk whatever the material said (#1575). Its normals are *generated*: + * the sphere ships without `vn`, and the parser accumulates them + * angle-weighted per source position, which is what makes it smooth rather + * than faceted (#1572). + * - **Panel** — `map_d`, per-texel opacity. `alphaCutoff` on its own can only + * threshold uniformly across a material; the map cuts to the shape of the + * perforations (#1575). * * Copyright (C) 2011 - 2026 AltByte Pte Ltd — MIT License. */ import { DebugPanelPlugin } from "@melonjs/debug-plugin"; -import type { CanvasRenderer, WebGLRenderer } from "melonjs"; import { Application, + Camera3d, + Light3d, loader, Mesh, plugin, Renderable, + state, Vector3d, video, } from "melonjs"; @@ -34,25 +41,33 @@ import { createExampleComponent } from "../utils"; 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/`; +const MESH_BASE = `${import.meta.env.BASE_URL}assets/mesh3d/`; + +// the three props, left to right along X at a common depth +const PROP_Y = 0; +const PROP_Z = 520; +const SPACING = 210; + +const AXIS_Y = new Vector3d(0, 1, 0); // ─── 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. + // Lit meshes need a GPU backend AND a Camera3d: world normals are only + // written on the 3D path, so a lit mesh under a Camera2d degrades to + // unlit (melonJS/melonJS#1576) and there would be no highlight to see. let app: Application; try { app = new Application(CANVAS_W, CANVAS_H, { parent: "screen", renderer: video.AUTO, scale: "auto", + cameraClass: Camera3d, + // smooth magnification (these maps are stretched across a face + // several times their own size) plus MSAA on the silhouettes — + // the cut-out panel in particular has a lot of edge to alias + antiAlias: true, }); await app.init(); if (!app.renderer.supportsDepthBuffer) { @@ -62,7 +77,7 @@ const createGame = async () => { 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 " + + "Lit meshes and per-material 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}`, @@ -70,71 +85,129 @@ const createGame = async () => { throw err; } - app.world.backgroundColor.parseCSS("#12161d"); + app.world.backgroundColor.parseCSS("#0e1117"); 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 + // only the models and their .mtl are listed: the MTL loader fetches every + // map_Kd AND map_d it references, relative to the .mtl loader.preload( [ { name: "crate", type: "obj", src: `${ASSET_BASE}crate.obj` }, { name: "crate", type: "mtl", src: `${ASSET_BASE}crate.mtl` }, + { name: "panel", type: "obj", src: `${ASSET_BASE}panel.obj` }, + { name: "props", type: "mtl", src: `${ASSET_BASE}props.mtl` }, + // the sphere is shared with the mesh3d example; it carries no `vn`, + // so its normals are generated at parse + { name: "ball", type: "obj", src: `${MESH_BASE}sphere.obj` }, ], () => { - app.world.addChild(new SpinningCrate(CANVAS_W / 2, CRATE_Y)); + // preload transitions to the loading screen, which pins itself to a + // Camera2d — come back to the default stage so the app's Camera3d + // becomes the active viewport again + state.change(state.DEFAULT, true); + buildScene(app); spawnCaption(app); }, ); }; -// ─── per-crate renderable ───────────────────────────────────────── +// ─── the scene ──────────────────────────────────────────────────── + +function buildScene(app: Application) { + const world = app.world; + + // One directional key light plus a dim ambient fill. The key is what the + // specular highlight rides: a highlight needs a direction to reflect, so + // an ambient-only scene would show none however glossy the material. + const key = new Light3d(0, 0, { + type: "directional", + direction: new Vector3d(-0.45, -0.75, 0.5), + color: "#fff6e8", + intensity: 1.15, + }); + key.name = "key"; + world.addChild(key); + world.addChild(new Light3d(0, 0, { type: "ambient", color: "#3b4870" })); + + const props: Mesh[] = []; + + // ── crate: three diffuse maps, one per material ────────────── + const crate = new Mesh(-SPACING, PROP_Y, { + model: "crate", + material: "crate", + width: 150, + lit: true, + }); + crate.depth = PROP_Z; + props.push(crate); + + // ── ball: Ks + Ns, and generated normals ───────────────────── + const ball = new Mesh(0, PROP_Y, { + model: "ball", + // no `usemtl` in sphere.obj, so the first MTL entry wins — `chrome` + material: "props", + width: 170, + lit: true, + }); + ball.depth = PROP_Z; + props.push(ball); + + // ── panel: per-texel cutout ────────────────────────────────── + const panel = new Mesh(SPACING, PROP_Y, { + model: "panel", + material: "props", + width: 150, + lit: true, + // `map_d` scales alpha; this is the threshold it is scaled against. + // Without a cutoff there is nothing to discard and the map does + // nothing — the two are a pair. + alphaCutoff: 0.5, + cullBackFaces: false, + }); + panel.depth = PROP_Z; + props.push(panel); + + for (const prop of props) { + world.addChild(prop); + // depth is assigned before addChild above; Container.autoDepth would + // overwrite it otherwise + prop.depth = PROP_Z; + } -const AXIS_Y = new Vector3d(0, 1, 0); -const AXIS_X = new Vector3d(1, 0, 0); + // slow turn so the highlight sweeps across the ball and every face of the + // crate comes round — a static shot would hide both + world.addChild(new Spinner(props)); + + // looking slightly down, so the crate's steel lid is in view alongside + // its wooden sides — the split is the point, and a level shot hides half + const camera = app.viewport as Camera3d; + camera.pos.set(0, -170, 0); + camera.lookAt(0, 10, PROP_Z); +} /** - * 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. + * Turns the props. A Renderable that draws nothing — the meshes are children + * of the world in their own right, so this only advances them. */ -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); +class Spinner extends Renderable { + props: Mesh[]; + + constructor(props: Mesh[]) { + super(0, 0, 1, 1); + this.props = props; + this.alwaysUpdate = true; } override update(dt: number): boolean { - this.mesh.rotate(dt * 0.0005, AXIS_Y); + for (const prop of this.props) { + prop.rotate(dt * 0.0004, 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) { @@ -144,14 +217,14 @@ function spawnCaption(app: Application) { 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" + + '
everything below comes out of the .mtl
' + + '
' + + "map_Kd per material · Ks + Ns specular · map_d per-texel cutout" + "
"; 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}%;`; + "transform:translate(-50%,-50%);left:50%;top:11%;"; parent.appendChild(el); } diff --git a/packages/examples/src/main.tsx b/packages/examples/src/main.tsx index 8ca1222b1..7449f5f0f 100644 --- a/packages/examples/src/main.tsx +++ b/packages/examples/src/main.tsx @@ -496,7 +496,7 @@ const examples: { 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.", + "Three props, three MTL material features: per-material diffuse maps on a crate, a Ks/Ns specular highlight on a chrome ball, and a map_d per-texel cutout on a perforated panel.", }, { component: , diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 8c1d14b8c..0f26a6bb2 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -13,6 +13,7 @@ - **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 +- **MTL specular and per-texel opacity** ([#1575](https://github.com/melonjs/melonJS/issues/1575)) — the MTL parser recognised around twenty properties and consumed five, so an authored highlight was read and thrown away and an alpha map was rejected outright. Two of the remaining ones now land, both on foundations that already existed. **Specular** (`Ks` + `Ns`) gives the lit mesh path a Blinn-Phong highlight where it was previously half-Lambert diffuse plus an ambient floor — every material read as chalk. It is exposed as `mesh.specular` / `mesh.shininess` and gated on the **exponent**, not the colour: `Ns` of 0 is the format's "no highlight", and exporters routinely write a bright `Ks` beside it, so a material declaring one without the other stays matte and pays nothing. The highlight is masked by the *unwrapped* Lambert term — half-Lambert deliberately lifts the shadowed side, and a highlight on a surface facing away from the light reads as a rendering error. **`map_d`** drives `alphaCutoff` per *texel* rather than per material, which is what foliage, fences and decals actually need; it rides `mesh.alphaMap`, is fetched automatically by the MTL loader alongside `map_Kd`, and multiplies alpha **before** the cutout so the threshold sees the map's value. Both backends run the identical expression — the map is sampled unconditionally and weighted rather than branched around, which also keeps the WGSL sample in uniform control flow. A material with neither renders byte-identically to before. WebGPU note for custom mesh shaders: the mesh family's group 1 grows from two bindings to four (diffuse pair + opacity pair) and `MeshUniforms` from 176 to 208 bytes (`specular` = rgb + exponent, `eye` = camera world position); a custom WGSL module declaring only the diffuse pair is unaffected, since a module may declare a subset of its layout. Both are shown in the reworked **Per-material Textures** example — a chrome ball for the highlight, a perforated panel for the cutout - **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 diff --git a/packages/melonjs/src/level/gltf/GLTFModel.js b/packages/melonjs/src/level/gltf/GLTFModel.js index 6cef3f10e..571e06fd2 100644 --- a/packages/melonjs/src/level/gltf/GLTFModel.js +++ b/packages/melonjs/src/level/gltf/GLTFModel.js @@ -150,6 +150,10 @@ export default class GLTFModel extends Container { alphaCutoff: prim.alphaCutoff, // emissive color (glTF emissiveFactor) — self-illumination emissive: prim.emissive, + // specular highlight approximated from the material's + // metallic/roughness factors (#1575) + specular: prim.specular, + shininess: prim.shininess, // thin/flat double-sided parts must not be back-face culled cullBackFaces: prim.doubleSided !== true, }); diff --git a/packages/melonjs/src/level/gltf/GLTFScene.js b/packages/melonjs/src/level/gltf/GLTFScene.js index f4e4f6201..ea4100cf8 100644 --- a/packages/melonjs/src/level/gltf/GLTFScene.js +++ b/packages/melonjs/src/level/gltf/GLTFScene.js @@ -178,6 +178,10 @@ export default class GLTFScene { // emissive color (glTF emissiveFactor) — self-illumination so neon / // lava / screens glow regardless of scene lighting emissive: node.emissive, + // specular highlight approximated from the material's + // metallic/roughness factors (#1575) + specular: node.specular, + shininess: node.shininess, // light this mesh (via the lit batcher) when the scene has lights — // unless the material is KHR_materials_unlit (baked lighting, must // not be shaded again) diff --git a/packages/melonjs/src/loader/parsers/gltf.js b/packages/melonjs/src/loader/parsers/gltf.js index 814ee9579..dcab9f2fd 100644 --- a/packages/melonjs/src/loader/parsers/gltf.js +++ b/packages/melonjs/src/loader/parsers/gltf.js @@ -703,6 +703,56 @@ export async function parseGLTF(arrayBuffer, baseURI, settings) { return undefined; }; + // Resolve material index -> `{ specular, shininess }`, approximating glTF's + // metallic/roughness onto the lit mesh path's Blinn-Phong term (#1575). + // + // This is a deliberate APPROXIMATION, not a PBR implementation: the lit + // path is stylized half-Lambert diffuse, not a microfacet BRDF, so the + // point is to recover the "is this surface glossy, and what colour does it + // glint" information every glTF asset already carries — Blender writes + // both factors by default — rather than to be energy-correct. + // + // - roughness -> exponent through the usual Blinn-Phong/GGX bridge, + // `2 / a^4 - 2` for `a = roughness^2`. The glTF DEFAULT roughness of 1 + // lands on exactly 0, which is the "no highlight" gate: a material that + // declares nothing, or declares itself fully rough, shades exactly as it + // did before this existed. Clamped at the top because the exponent runs + // away as roughness approaches 0, and a highlight narrower than a pixel + // only aliases. + // - metallic -> tint. A dielectric reflects white-ish at 4% (the standard + // F0), a metal reflects its own base colour: `mix(0.04, baseColor, + // metallic)`. So chrome glints in its own hue while plaster gets a faint + // sheen, which is the visible half of the distinction. + const MAX_SHININESS = 256; + const DIELECTRIC_F0 = 0.04; + const materialSpecular = (materialIndex) => { + const pbr = + materialIndex !== undefined + ? json.materials?.[materialIndex]?.pbrMetallicRoughness + : undefined; + // Both factors default to 1 per the spec — fully rough, which yields + // no highlight, so an asset that declares neither is untouched. A + // malformed factor takes the same default rather than propagating: + // `NaN` would otherwise fail the `a > 0` test and land in the + // mirror-smooth branch below, turning a broken file into a chrome one. + const declared = (value) => { + return typeof value === "number" && Number.isFinite(value) ? value : 1; + }; + const roughness = declared(pbr?.roughnessFactor); + const metallic = declared(pbr?.metallicFactor); + const a = roughness * roughness; + const shininess = + a > 0 ? Math.min(MAX_SHININESS, 2 / (a * a) - 2) : MAX_SHININESS; + if (!(shininess > 0)) { + return { specular: undefined, shininess: 0 }; + } + const base = pbr?.baseColorFactor ?? [1, 1, 1, 1]; + const mix = (channel) => { + return DIELECTRIC_F0 + ((base[channel] || 0) - DIELECTRIC_F0) * metallic; + }; + return { specular: [mix(0), mix(1), mix(2)], shininess }; + }; + // resolve material index -> alpha cutout threshold. glTF `alphaMode: // "MASK"` is a hard cutout: a fragment is fully opaque where its alpha is // >= `alphaCutoff` and fully discarded below it (foliage, fences, @@ -830,6 +880,9 @@ export async function parseGLTF(arrayBuffer, baseURI, settings) { // emissive color [r,g,b] (glTF emissiveFactor × emissive_strength), or // undefined when the material doesn't self-illuminate emissive: materialEmissive(prim.material), + // specular highlight approximated from metallic/roughness (#1575); + // a fully-rough material yields shininess 0 and no highlight + ...materialSpecular(prim.material), }; }; diff --git a/packages/melonjs/src/loader/parsers/mtl.js b/packages/melonjs/src/loader/parsers/mtl.js index 966d473eb..614a10dd3 100644 --- a/packages/melonjs/src/loader/parsers/mtl.js +++ b/packages/melonjs/src/loader/parsers/mtl.js @@ -11,9 +11,11 @@ const SUPPORTED_PROPS = new Set([ "d", "Tr", "map_Kd", + "Ks", + "Ns", + "map_d", // ignored but harmless "Ka", - "Ns", "Ni", "illum", ]); @@ -24,7 +26,6 @@ const UNSUPPORTED_MAPS = new Set([ "map_Ke", "map_Ks", "map_Ns", - "map_d", "map_bump", "bump", "map_refl", @@ -34,12 +35,12 @@ const UNSUPPORTED_MAPS = new Set([ /** * Parse a Wavefront MTL file into material data. - * Supports: `newmtl`, `Kd` (diffuse color), `Ke` (emissive color), `map_Kd` - * (diffuse texture), `d`/`Tr` (opacity/transparency). + * Supports: `newmtl`, `Kd` (diffuse color), `Ke` (emissive color), `Ks`/`Ns` + * (specular color and exponent), `map_Kd` (diffuse texture), `map_d` (alpha + * map), `d`/`Tr` (opacity/transparency). * * Limitations: - * - Only one `map_Kd` texture per material is supported - * - Specular (`Ks`, `Ns`), ambient (`Ka`), and illumination model (`illum`) are parsed but ignored + * - Ambient (`Ka`), optical density (`Ni`) and illumination model (`illum`) are parsed but ignored * - Normal maps (`map_bump`, `bump`), specular maps (`map_Ks`), and other texture maps are not supported * * @param {string} text - raw MTL file contents @@ -47,7 +48,7 @@ const UNSUPPORTED_MAPS = new Set([ * @returns {object} map of material names to their properties * @ignore */ -function parseMTL(text, basePath) { +export function parseMTL(text, basePath) { const materials = {}; let current = null; @@ -70,11 +71,7 @@ function parseMTL(text, basePath) { } // warn on completely unknown properties - if ( - !SUPPORTED_PROPS.has(keyword) && - !UNSUPPORTED_MAPS.has(keyword) && - keyword !== "Ks" - ) { + if (!SUPPORTED_PROPS.has(keyword) && !UNSUPPORTED_MAPS.has(keyword)) { console.warn("MTL: unknown property '" + keyword + "' will be ignored"); continue; } @@ -89,8 +86,13 @@ function parseMTL(text, basePath) { name: parts[1], Kd: [1, 1, 1], Ke: [0, 0, 0], + // no specular by default: an MTL that declares none must + // shade exactly as it did before specular existed + Ks: [0, 0, 0], + Ns: 0, d: 1.0, map_Kd: null, + map_d: null, }; materials[parts[1]] = current; break; @@ -117,6 +119,26 @@ function parseMTL(text, basePath) { } break; + case "Ks": + // specular color — the highlight's tint and strength + if (current) { + current.Ks = [ + parseFloat(parts[1]), + parseFloat(parts[2]), + parseFloat(parts[3]), + ]; + } + break; + + case "Ns": + // specular exponent (0..1000 in the format): how tight the + // highlight is. 0 means none, which is why Ks alone is not + // enough to turn specular on + if (current) { + current.Ns = parseFloat(parts[1]); + } + break; + case "d": if (current) { current.d = parseFloat(parts[1]); @@ -136,6 +158,14 @@ function parseMTL(text, basePath) { current.map_Kd = basePath + parts.slice(1).join(" "); } break; + + case "map_d": + // per-texel opacity, driving the mesh alpha cutout per pixel + // rather than per material + if (current) { + current.map_d = basePath + parts.slice(1).join(" "); + } + break; } } @@ -170,12 +200,13 @@ export function preloadMTL(data, onload, onerror, settings) { // each texture separately (parity with the glTF loader, which fetches // a scene's external textures automatically). A texture that fails to // load is warned and skipped (the mesh falls back to the white pixel), - // so one missing map_Kd doesn't abort the whole load. + // so one missing map_Kd doesn't abort the whole load. `map_d` + // (per-texel opacity) rides the same fetch for the same reason. const texturePaths = [ ...new Set( Object.values(materials) - .map((material) => { - return material.map_Kd; + .flatMap((material) => { + return [material.map_Kd, material.map_d]; }) .filter(Boolean), ), diff --git a/packages/melonjs/src/renderable/mesh.js b/packages/melonjs/src/renderable/mesh.js index fda7c860e..35d2528f4 100644 --- a/packages/melonjs/src/renderable/mesh.js +++ b/packages/melonjs/src/renderable/mesh.js @@ -472,6 +472,46 @@ export default class Mesh extends Renderable { */ this.emissive = toEmissive(settings.emissive); + /** + * Specular (highlight) color as an `[r, g, b]` `Float32Array`, or + * `undefined` for a purely diffuse surface — which is the default, and + * what every mesh rendered as before this existed. + * + * Drives a Blinn-Phong highlight on the **lit** mesh path, so it needs + * `lit: true`, a `Light3d`, and normals. Set by the OBJ loader from an + * MTL's `Ks`; paired with {@link Mesh#shininess}, which decides how + * tight the highlight is. A `Ks` with no `Ns` produces nothing — the + * exponent is what turns the term on. + * @type {Float32Array|undefined} + * @see Mesh#shininess + */ + this.specular = toEmissive(settings.specular); + + /** + * Specular exponent — how tight the highlight is. The MTL `Ns` range + * is 0..1000; higher is a smaller, harder highlight (polished metal), + * lower is a broad sheen (satin). `0` (the default) disables the + * specular term outright however bright {@link Mesh#specular} is, which + * is what keeps a material declaring neither on the diffuse-only path. + * @type {number} + * @default 0 + */ + this.shininess = + typeof settings.shininess === "number" ? settings.shininess : 0; + + /** + * Per-texel opacity map (MTL `map_d`), or `undefined`. Its red channel + * multiplies the fragment's alpha before {@link Mesh#alphaCutoff} is + * applied, so a single material can cut out per pixel — the shape of + * a leaf, the holes in a chain-link fence — where `alphaCutoff` alone + * can only threshold uniformly across the whole material. + * + * Only meaningful alongside a non-zero `alphaCutoff`: without one there + * is nothing to discard against. GPU mesh path only. + * @type {TextureAtlas|undefined} + */ + this.alphaMap = undefined; + /** * whether to cull back-facing triangles * @type {boolean} @@ -515,6 +555,8 @@ export default class Mesh extends Renderable { // entry. `draw()` later iterates the groups and swaps state // per draw. let textureSource = settings.texture; + // per-texel opacity map, resolved after the diffuse texture below + let alphaMapSource = settings.alphaMap; const materials = typeof settings.material === "string" ? getMTL(settings.material) : null; const isMultiMaterial = @@ -639,6 +681,19 @@ export default class Mesh extends Renderable { if (ke !== undefined) { this.emissive = ke; } + // MTL specular (Ks + Ns). Both are needed: `Ns` of 0 is "no + // highlight" however bright `Ks` is, and exporters write a + // black `Ks` for matte materials, so either one alone leaves + // the mesh on the diffuse-only path. + const ks = toEmissive(mat.Ks); + if (ks !== undefined && mat.Ns > 0) { + this.specular = ks; + this.shininess = mat.Ns; + } + // MTL alpha map (map_d) — per-texel opacity + if (mat.map_d) { + alphaMapSource = mat.map_d; + } } } @@ -658,6 +713,24 @@ export default class Mesh extends Renderable { settings.frameheight, ); + // Per-texel opacity map (MTL `map_d`). A map that failed to preload + // warns and leaves the mesh on the uniform cutout rather than throwing + // — the model still renders, just without its cut-outs, which is far + // easier to diagnose than a mesh that never appeared. + if (alphaMapSource) { + try { + this.alphaMap = resolveTextureAtlas( + alphaMapSource, + settings.framewidth, + settings.frameheight, + ); + } catch { + console.warn( + `melonJS: Mesh alpha map "${alphaMapSource}" is not loaded — the mesh keeps its uniform alpha cutout`, + ); + } + } + /** * Index ranges that each need their own diffuse texture bound, for a * multi-material model whose materials carry different `map_Kd` maps diff --git a/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js b/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js index a423152ec..092bc0425 100644 --- a/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js @@ -47,6 +47,9 @@ const _TINT_RGBA = new Float32Array(4); // `uEmissive` add is a no-op. Never mutated. const _ZERO_EMISSIVE = new Float32Array(3); +// scratch for the camera's world position, recomputed per draw that needs it +const _EYE_POSITION = new Float32Array(3); + /** * A WebGL Batcher for rendering textured triangle meshes. * Uses indexed drawing to efficiently render arbitrary triangle geometry. @@ -100,6 +103,24 @@ export default class MeshBatcher extends MaterialBatcher { this.currentEmissiveG = -1; this.currentEmissiveB = -1; + // last `uShininess` / `uSpecular` pushed — -1 is impossible (valid + // range 0..inf), forcing the first set of a pass + this.currentShininess = -1; + this.currentSpecularR = -1; + this.currentSpecularG = -1; + this.currentSpecularB = -1; + + // last `uAlphaMap` unit and `uHasAlphaMap` flag pushed; -1 is not a + // valid unit or flag, so the first mesh of a pass always sets both + this.currentAlphaMapUnit = -1; + this.currentHasAlphaMap = -1; + + // last `uEyePosition` pushed. NaN never equals itself, so the first + // camera position of a pass always sets it whatever it is + this.currentEyeX = Number.NaN; + this.currentEyeY = Number.NaN; + this.currentEyeZ = Number.NaN; + // Retained geometry per mesh (model-space buffers uploaded once). A // re-init means a new GL context or a fresh batcher life, so anything // held is stale — release it rather than leak it. @@ -843,6 +864,33 @@ export default class MeshBatcher extends MaterialBatcher { if (uniforms.uViewMatrix !== undefined) { shader.setUniform("uViewMatrix", this.viewMatrix); } + if (uniforms.uEyePosition !== undefined) { + // The camera's world position, which the specular half-vector + // needs and nothing else in this shader does. Derived from the + // view matrix rather than plumbed from the camera so it stays + // correct for any caller that sets a view directly: a view is + // rigid, so its inverse translation is -Rᵀ·t — twelve + // multiply-adds against a full 4×4 inversion. + const v = this.viewMatrix.val; + const ex = -(v[0] * v[12] + v[1] * v[13] + v[2] * v[14]); + const ey = -(v[4] * v[12] + v[5] * v[13] + v[6] * v[14]); + const ez = -(v[8] * v[12] + v[9] * v[13] + v[10] * v[14]); + // the camera moves once a frame at most, while this runs once per + // lit mesh — compare before paying for the GL call + if ( + ex !== this.currentEyeX || + ey !== this.currentEyeY || + ez !== this.currentEyeZ + ) { + _EYE_POSITION[0] = ex; + _EYE_POSITION[1] = ey; + _EYE_POSITION[2] = ez; + shader.setUniform("uEyePosition", _EYE_POSITION); + this.currentEyeX = ex; + this.currentEyeY = ey; + this.currentEyeZ = ez; + } + } if (uniforms.uModelMatrix !== undefined) { shader.setUniform("uModelMatrix", modelMatrix); } @@ -940,6 +988,69 @@ export default class MeshBatcher extends MaterialBatcher { } } + // Per-texel opacity map (MTL `map_d`), on its own texture unit. Bound + // before the cutout uniform below because it feeds it: the map scales + // alpha, the cutout thresholds the result. + if (this.currentShader.uniforms.uHasAlphaMap !== undefined) { + const alphaMap = mesh.alphaMap; + // with no map the sampler points at the diffuse unit as filler and + // the weight is 0, so nothing samples an unbound texture unit + const alphaUnit = + alphaMap !== undefined + ? this.uploadTexture( + alphaMap, + undefined, + undefined, + false, + true, + mesh.textureRepeat, + ) + : unit; + // change-guarded like every other per-mesh uniform here: an + // unguarded pair costs two GL uniform calls on EVERY mesh draw, + // and the overwhelmingly common case is "no alpha map, again" + const hasAlphaMap = alphaMap !== undefined ? 1 : 0; + if ( + alphaUnit !== this.currentAlphaMapUnit || + hasAlphaMap !== this.currentHasAlphaMap + ) { + this.currentShader.setUniform("uAlphaMap", alphaUnit); + this.currentShader.setUniform("uHasAlphaMap", hasAlphaMap); + this.currentAlphaMapUnit = alphaUnit; + this.currentHasAlphaMap = hasAlphaMap; + } + if (alphaMap !== undefined) { + // uploading the alpha map moved the active sampler — put the + // diffuse binding back before the draw reads it. Skipped when + // there is no map: nothing moved, and re-setting `uSampler` + // would double the sampler traffic of every ordinary mesh. + this.bindSamplerUnit(unit); + } + } + + // Specular (MTL Ks + Ns). Guarded by the exponent, not the colour: + // `Ns` of 0 is the format's "no highlight" however bright `Ks` is. + if (this.currentShader.uniforms.uShininess !== undefined) { + const shininess = mesh.shininess || 0; + if (shininess !== this.currentShininess) { + this.currentShader.setUniform("uShininess", shininess); + this.currentShininess = shininess; + } + if (shininess > 0) { + const ks = mesh.specular ?? _ZERO_EMISSIVE; + if ( + ks[0] !== this.currentSpecularR || + ks[1] !== this.currentSpecularG || + ks[2] !== this.currentSpecularB + ) { + this.currentShader.setUniform("uSpecular", ks); + this.currentSpecularR = ks[0]; + this.currentSpecularG = ks[1]; + this.currentSpecularB = ks[2]; + } + } + } + // alpha cutout (glTF alphaMode MASK): discard fragments whose final alpha // is below the mesh's threshold (0 = disabled). The built-in mesh shaders // declare `uAlphaCutoff`; a custom shader without it is left untouched. diff --git a/packages/melonjs/src/video/webgl/shaders/mesh-lit.frag b/packages/melonjs/src/video/webgl/shaders/mesh-lit.frag index 7d5c5fb12..d1e69c473 100644 --- a/packages/melonjs/src/video/webgl/shaders/mesh-lit.frag +++ b/packages/melonjs/src/video/webgl/shaders/mesh-lit.frag @@ -24,6 +24,11 @@ uniform sampler2D uSampler; uniform float uAlphaCutoff; // alpha cutout threshold (0 = disabled) uniform vec3 uEmissive; // self-illumination color (0 = none) +uniform vec3 uSpecular; // specular color (0 = purely diffuse) +uniform float uShininess; // specular exponent (0 = no highlight) +uniform vec3 uEyePosition; // camera position, world space +uniform sampler2D uAlphaMap; // per-texel opacity (MTL map_d) +uniform float uHasAlphaMap; // 0 = uAlphaMap is filler, ignore it // One light, type inferred from sentinels (see std140.ts): // posRange.w < 0 -> directional (dirCone.xyz = surface->light, normalized); @@ -61,6 +66,16 @@ out vec4 fragColor; void main(void) { vec4 base = texture(uSampler, vRegion) * vColor; + // per-texel opacity (MTL map_d) multiplies in BEFORE the cutout, so one + // material can cut out to the shape of a leaf rather than at a single + // threshold across the whole surface. Red channel: the format stores a + // greyscale map, and every channel carries the same value. + // Sampled unconditionally and WEIGHTED rather than branched: when no map + // is bound the second sampler is filler (the diffuse texture), so the + // value is thrown away — and both backends run the identical expression + // instead of one branching and the other not. + base.a *= mix(1.0, texture(uAlphaMap, vRegion).r, uHasAlphaMap); + // hard alpha cutout (glTF alphaMode MASK) — discard before any shading // so cut-away texels cost nothing and never write depth. if (base.a < uAlphaCutoff) { @@ -85,6 +100,14 @@ void main(void) { } vec3 N = vNormal / nLength; vec3 lit = uAmbient; + // Blinn-Phong specular, accumulated alongside the diffuse term. Gated on + // the exponent rather than the colour: `Ns` of 0 is the format's "no + // highlight", and exporters happily write a non-black `Ks` next to it. + // The half-vector form (rather than reflect()) is the cheaper of the two + // and is what the fixed-function pipeline this format predates used. + vec3 specular = vec3(0.0); + bool hasSpecular = uShininess > 0.0; + vec3 V = hasSpecular ? normalize(uEyePosition - vWorldPos) : vec3(0.0); // 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, // which is what makes a larger cap safe. @@ -121,6 +144,15 @@ void main(void) { // diffuse look than hard Lambert (which reads as harsh noon). float ndl = dot(N, L) * 0.5 + 0.5; lit += uLights[i].colorInner.rgb * (ndl * ndl * atten); + if (hasSpecular) { + // masked by the UNWRAPPED Lambert term: half-Lambert lifts the + // shadowed side, and a highlight on a surface facing away from + // the light reads as a rendering error + float facing = max(dot(N, L), 0.0); + vec3 H = normalize(L + V); + float spec = pow(max(dot(N, H), 0.0), uShininess); + specular += uLights[i].colorInner.rgb * (spec * facing * atten); + } } // emissive self-illuminates: added AFTER lighting so it glows at full @@ -129,5 +161,5 @@ void main(void) { #ifdef INSTANCE_DATA emissive += vInstanceData.rgb; #endif - fragColor = vec4(base.rgb * lit + emissive, base.a); + fragColor = vec4(base.rgb * lit + specular * uSpecular + emissive, base.a); } diff --git a/packages/melonjs/src/video/webgl/shaders/mesh.frag b/packages/melonjs/src/video/webgl/shaders/mesh.frag index 605632ca3..7d94a1438 100644 --- a/packages/melonjs/src/video/webgl/shaders/mesh.frag +++ b/packages/melonjs/src/video/webgl/shaders/mesh.frag @@ -1,6 +1,8 @@ uniform sampler2D uSampler; uniform float uAlphaCutoff; // alpha cutout threshold (0 = disabled) uniform vec3 uEmissive; // self-illumination color added on top (0 = none) +uniform sampler2D uAlphaMap; // per-texel opacity (MTL map_d) +uniform float uHasAlphaMap; // 0 = uAlphaMap is filler, ignore it varying vec4 vColor; varying vec2 vRegion; #ifdef INSTANCE_DATA @@ -12,6 +14,15 @@ varying vec4 vInstanceData; void main(void) { vec4 color = texture2D(uSampler, vRegion) * vColor; + // per-texel opacity (MTL map_d) multiplies in BEFORE the cutout, so one + // material can cut out to the shape of a leaf rather than at a single + // threshold across the whole surface. Red channel: the format stores a + // greyscale map, and every channel carries the same value. + // Sampled unconditionally and WEIGHTED rather than branched: when no map + // is bound the second sampler is filler (the diffuse texture), so the + // value is thrown away — and both backends run the identical expression + // instead of one branching and the other not. + color.a *= mix(1.0, texture2D(uAlphaMap, vRegion).r, uHasAlphaMap); // hard alpha cutout (glTF alphaMode MASK): drop fully-transparent texels // so foliage / fences / decals read crisp without blending or sorting. if (color.a < uAlphaCutoff) { diff --git a/packages/melonjs/src/video/webgpu/batchers/lit_mesh_batcher.js b/packages/melonjs/src/video/webgpu/batchers/lit_mesh_batcher.js index 17aee9f95..eb550532f 100644 --- a/packages/melonjs/src/video/webgpu/batchers/lit_mesh_batcher.js +++ b/packages/melonjs/src/video/webgpu/batchers/lit_mesh_batcher.js @@ -133,7 +133,7 @@ export default class WebGPULitMeshBatcher extends WebGPUMeshBatcher { bindGroupLayoutList(cache) { return [ cache.frameLayout, - cache.materialLayout, + cache.meshMaterialLayout, this.ensureLightsLayout(), this.meshLayout, ]; diff --git a/packages/melonjs/src/video/webgpu/batchers/mesh_batcher.js b/packages/melonjs/src/video/webgpu/batchers/mesh_batcher.js index a91360dfa..35689a3ce 100644 --- a/packages/melonjs/src/video/webgpu/batchers/mesh_batcher.js +++ b/packages/melonjs/src/video/webgpu/batchers/mesh_batcher.js @@ -19,10 +19,12 @@ import WebGPUBatcher from "./webgpu_batcher.js"; /** * Byte size of the per-draw mesh uniform block: * mat4x4 model (64) + mat4x4 view (64) + vec4 tint (16) + vec4 params - * (alphaCutoff, reserved ×3) (16) + vec4 emissive (16) → 176. + * (alphaCutoff, hasAlphaMap, reserved ×2) (16) + vec4 emissive (16) + + * vec4 specular (rgb + shininess) (16) + vec4 eye (camera world position; + * w reserved) (16) → 208. * @ignore */ -export const MESH_UNIFORM_SIZE = 176; +export const MESH_UNIFORM_SIZE = 208; // Shared identity model matrix for draws whose vertices are already placed // (the 2D-camera path pre-projects them on the CPU). Never mutated. @@ -420,7 +422,7 @@ export default class WebGPUMeshBatcher extends WebGPUBatcher { bindGroupLayoutList(cache) { return [ cache.frameLayout, - cache.materialLayout, + cache.meshMaterialLayout, cache.emptyLayout, this.meshLayout, ]; @@ -445,15 +447,25 @@ export default class WebGPUMeshBatcher extends WebGPUBatcher { typeof texture.filter === "string" ? texture.filter : renderer.getDefaultTextureFilter(); - const material = renderer.textureStore.getBinding(texture, { - repeat: mesh.textureRepeat, - // mesh textures sample a generated mip chain — trilinear - // minification keeps distant geometry from shimmering, while - // "nearest" opts out (crisp pixel-art models) and 2D consumers - // of the same image stay lod-clamped to level 0 - mipmaps: filter === "linear", - }); - if (material !== this.currentMaterial) { + // Four bindings always. A mesh with no `map_d` binds its own diffuse + // texture into the second pair as filler: the shader weights that + // sample by `hasAlphaMap`, so the value is discarded, and reusing a + // texture already resident costs no extra unit and no extra upload. + const material = renderer.textureStore.getMeshBinding( + texture, + mesh.alphaMap ?? texture, + { + repeat: mesh.textureRepeat, + // mesh textures sample a generated mip chain — trilinear + // minification keeps distant geometry from shimmering, while + // "nearest" opts out (crisp pixel-art models) and 2D consumers + // of the same image stay lod-clamped to level 0 + mipmaps: filter === "linear", + }, + ); + // a source that never became resident returns null — keep the previous + // binding rather than recording a draw against nothing + if (material !== null && material !== this.currentMaterial) { this.flush(); this.currentMaterial = material; } @@ -484,11 +496,27 @@ export default class WebGPUMeshBatcher extends WebGPUBatcher { scratch[37] = 0; scratch[38] = 0; scratch[39] = 0; + scratch[37] = mesh.alphaMap !== undefined ? 1 : 0; const em = mesh.emissive; scratch[40] = em ? em[0] : 0; scratch[41] = em ? em[1] : 0; scratch[42] = em ? em[2] : 0; scratch[43] = 0; + // specular: rgb = Ks, w = Ns. Gated on the exponent, not the colour — + // `Ns` of 0 is the format's "no highlight" however bright `Ks` is. + const shininess = mesh.shininess || 0; + const ks = shininess > 0 ? mesh.specular : undefined; + scratch[44] = ks ? ks[0] : 0; + scratch[45] = ks ? ks[1] : 0; + scratch[46] = ks ? ks[2] : 0; + scratch[47] = shininess; + // the camera's world position, which the specular half-vector needs. + // A view matrix is rigid, so its inverse translation is -Rᵀ·t. + const v = renderer.currentTransform.val; + scratch[48] = -(v[0] * v[12] + v[1] * v[13] + v[2] * v[14]); + scratch[49] = -(v[4] * v[12] + v[5] * v[13] + v[6] * v[14]); + scratch[50] = -(v[8] * v[12] + v[9] * v[13] + v[10] * v[14]); + scratch[51] = 0; const region = renderer.effectUniformArena.alloc( MESH_UNIFORM_SIZE, diff --git a/packages/melonjs/src/video/webgpu/pipeline/cache.js b/packages/melonjs/src/video/webgpu/pipeline/cache.js index adaa57159..3028e3574 100644 --- a/packages/melonjs/src/video/webgpu/pipeline/cache.js +++ b/packages/melonjs/src/video/webgpu/pipeline/cache.js @@ -239,6 +239,26 @@ export default class WebGPUPipelineCache { { binding: 1, visibility: GPUShaderStage.FRAGMENT, sampler: {} }, ], }); + // The MESH family's group 1: the diffuse pair plus a second pair for + // the per-texel opacity map (MTL `map_d`, #1575). Always four + // bindings, even for a mesh with no alpha map — the batcher points + // the second pair at the shared white pixel then, which costs one + // extra bind-group entry and keeps every mesh pipeline on one + // layout instead of splitting the family in two. + // + // Widening the layout does not break the documented custom-WGSL mesh + // contract: a module may declare a SUBSET of its layout's bindings, + // so a custom mesh shader declaring only texture+sampler still + // validates against this. + this.meshMaterialLayout = device.createBindGroupLayout({ + label: "melonJS mesh material layout", + entries: [ + { binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: {} }, + { binding: 1, visibility: GPUShaderStage.FRAGMENT, sampler: {} }, + { binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: {} }, + { binding: 3, visibility: GPUShaderStage.FRAGMENT, sampler: {} }, + ], + }); // the quad family's group 1: eight texture slots (bindings 0-7) and // their samplers (8-15) — one draw segment spans up to eight // distinct textures, selected per quad by aTextureId diff --git a/packages/melonjs/src/video/webgpu/shaders/mesh-lit.wgsl b/packages/melonjs/src/video/webgpu/shaders/mesh-lit.wgsl index bdb1973c8..ab98bd9c0 100644 --- a/packages/melonjs/src/video/webgpu/shaders/mesh-lit.wgsl +++ b/packages/melonjs/src/video/webgpu/shaders/mesh-lit.wgsl @@ -25,10 +25,16 @@ struct MeshUniforms { model : mat4x4, view : mat4x4, tint : vec4f, - // x = alpha cutout threshold (0 = disabled); y, z, w reserved + // x = alpha cutout threshold (0 = disabled), y = 1 when an opacity map + // is bound (0 = the second sampler is filler); z, w reserved params : vec4f, // self-illumination added AFTER lighting (r, g, b; w reserved) emissive : vec4f, + // specular color (rgb) and exponent (w); w = 0 means no highlight + specular : vec4f, + // the camera's world position (xyz; w reserved), for the specular + // half-vector — nothing else in this shader needs it + eye : vec4f, }; // One light, type inferred from sentinels (see std140.ts): @@ -54,6 +60,11 @@ struct Light3dBlock { @group(0) @binding(0) var uFrame : FrameUniforms; @group(1) @binding(0) var uTexture : texture_2d; @group(1) @binding(1) var uSampler : sampler; +// per-texel opacity (MTL map_d). When no map is bound this pair is filler — +// the diffuse texture again — and `params.y` is 0, so its sample is weighted +// away rather than branched around. +@group(1) @binding(2) var uAlphaMap : texture_2d; +@group(1) @binding(3) var uAlphaSampler : sampler; @group(2) @binding(0) var uLights : Light3dBlock; @group(3) @binding(0) var uMesh : MeshUniforms; @@ -97,7 +108,13 @@ fn vertex_main( @fragment fn fragment_main(in : VSOut) -> @location(0) vec4f { // sampled unconditionally, before the discard (uniform control flow) - let base = textureSample(uTexture, uSampler, in.vRegion) * in.vColor; + var base = textureSample(uTexture, uSampler, in.vRegion) * in.vColor; + // Per-texel opacity (MTL map_d), applied BEFORE the cutout so one + // material can cut out to the shape of a leaf rather than at a single + // threshold across the whole surface. Sampled unconditionally and + // WEIGHTED: with no map bound the second pair is filler, and this keeps + // the sample in uniform control flow (and identical to the GLSL twin). + base.a = base.a * mix(1.0, textureSample(uAlphaMap, uAlphaSampler, in.vRegion).r, uMesh.params.y); // hard alpha cutout (glTF alphaMode MASK) — discard before any shading // so cut-away texels cost nothing and never write depth if (base.a < uMesh.params.x) { @@ -112,6 +129,16 @@ fn fragment_main(in : VSOut) -> @location(0) vec4f { } let n = in.vNormal / nLength; var lit = uLights.ambient.rgb; + // Blinn-Phong specular, accumulated alongside the diffuse term. Gated on + // the exponent rather than the colour: `Ns` of 0 is the MTL format's "no + // highlight", and exporters happily write a non-black `Ks` next to it. + var specular = vec3f(0.0); + let shininess = uMesh.specular.w; + let hasSpecular = shininess > 0.0; + var viewDir = vec3f(0.0); + if (hasSpecular) { + viewDir = normalize(uMesh.eye.xyz - in.vWorldPos); + } // 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 // unbounded trip count would also index out of range @@ -145,9 +172,22 @@ fn fragment_main(in : VSOut) -> @location(0) vec4f { // Lambert, which reads as harsh noon. let ndl = dot(n, lightDir) * 0.5 + 0.5; lit = lit + uLights.lights[i].colorInner.rgb * (ndl * ndl * atten); + if (hasSpecular) { + // masked by the UNWRAPPED Lambert term: half-Lambert lifts the + // shadowed side, and a highlight on a surface facing away from + // the light reads as a rendering error + let facing = max(dot(n, lightDir), 0.0); + let h = normalize(lightDir + viewDir); + let spec = pow(max(dot(n, h), 0.0), shininess); + specular = specular + + uLights.lights[i].colorInner.rgb * (spec * facing * atten); + } } // emissive self-illuminates: added AFTER lighting so it glows at full // strength regardless of the scene lights (neon, lava, glowing eyes) - return vec4f(base.rgb * lit + uMesh.emissive.rgb, base.a); + return vec4f( + base.rgb * lit + specular * uMesh.specular.rgb + uMesh.emissive.rgb, + base.a + ); } diff --git a/packages/melonjs/src/video/webgpu/shaders/mesh.wgsl b/packages/melonjs/src/video/webgpu/shaders/mesh.wgsl index e7c77f07e..746986eb0 100644 --- a/packages/melonjs/src/video/webgpu/shaders/mesh.wgsl +++ b/packages/melonjs/src/video/webgpu/shaders/mesh.wgsl @@ -31,15 +31,26 @@ struct MeshUniforms { // per-draw tint × global alpha (r, g, b, a) — kept out of the vertex // data so re-tinting never invalidates retained geometry tint : vec4f, - // x = alpha cutout threshold (0 = disabled); y, z, w reserved + // x = alpha cutout threshold (0 = disabled), y = 1 when an opacity map + // is bound (0 = the second sampler is filler); z, w reserved params : vec4f, // self-illumination added on top (r, g, b; w reserved) emissive : vec4f, + // specular color (rgb) and exponent (w); w = 0 means no highlight. + // Unused by the unlit tier, present so both tiers share one block size. + specular : vec4f, + // the camera's world position (xyz; w reserved) + eye : vec4f, }; @group(0) @binding(0) var uFrame : FrameUniforms; @group(1) @binding(0) var uTexture : texture_2d; @group(1) @binding(1) var uSampler : sampler; +// per-texel opacity (MTL map_d). When no map is bound this pair is filler — +// the diffuse texture again — and `params.y` is 0, so its sample is weighted +// away rather than branched around. +@group(1) @binding(2) var uAlphaMap : texture_2d; +@group(1) @binding(3) var uAlphaSampler : sampler; @group(3) @binding(0) var uMesh : MeshUniforms; struct VSOut { @@ -69,7 +80,13 @@ fn vertex_main( @fragment fn fragment_main(in : VSOut) -> @location(0) vec4f { // sampled unconditionally, before the discard (uniform control flow) - let color = textureSample(uTexture, uSampler, in.vRegion) * in.vColor; + var color = textureSample(uTexture, uSampler, in.vRegion) * in.vColor; + // Per-texel opacity (MTL map_d), applied BEFORE the cutout so one + // material can cut out to the shape of a leaf rather than at a single + // threshold across the whole surface. Sampled unconditionally and + // WEIGHTED: with no map bound the second pair is filler, and this keeps + // the sample in uniform control flow (and identical to the GLSL twin). + color.a = color.a * mix(1.0, textureSample(uAlphaMap, uAlphaSampler, in.vRegion).r, uMesh.params.y); // hard alpha cutout (glTF alphaMode MASK): drop cut texels so foliage / // fences / decals read crisp without blending or sorting if (color.a < uMesh.params.x) { diff --git a/packages/melonjs/src/video/webgpu/texture/store.js b/packages/melonjs/src/video/webgpu/texture/store.js index ad8e0c895..3a5b9c2ab 100644 --- a/packages/melonjs/src/video/webgpu/texture/store.js +++ b/packages/melonjs/src/video/webgpu/texture/store.js @@ -358,11 +358,13 @@ export default class WebGPUTextureStore { } /** - * the per-sampler material bind group for a resident record (shared by - * the image and compressed upload paths) + * Resolve the view + sampler a resident record should be bound with, + * plus the cache key identifying that pair. Shared by the one-texture + * material path and the mesh path, so the two cannot drift on which + * view a mip-wanting consumer gets. * @ignore */ - bindGroupFor(record, texture, wrap, wantMips = false) { + viewAndSampler(record, texture, wrap, wantMips) { const filter = typeof texture.filter === "string" ? texture.filter @@ -374,26 +376,117 @@ export default class WebGPUTextureStore { wantMips === true && (record.mipLevelCount ?? 1) > 1 && filter === "linear"; - const samplerKey = `${filter}|${wrap}|${mip ? "mip" : "flat"}`; - let bindGroup = record.bindGroupBySampler.get(samplerKey); + return { + // compressed records keep a level-0 `view` for 2D consumers and a + // `fullView` over the authored chain for mip sampling; + // generated-chain records have one full view serving both (the + // sampler's lod clamp does the 2D restriction there) + view: mip ? (record.fullView ?? record.view) : record.view, + sampler: this.getSampler(filter, wrap, mip), + key: `${filter}|${wrap}|${mip ? "mip" : "flat"}`, + }; + } + + /** + * The group-1 material for a MESH: the diffuse texture/sampler pair plus + * the per-texel opacity pair (MTL `map_d`, #1575). + * + * Always four bindings. A mesh with no alpha map passes the shared white + * pixel, whose red channel is 1 — so the shader's multiply is a no-op and + * the same pipeline layout serves both cases rather than splitting the + * mesh family in two. + * @param {TextureAtlas} texture - the diffuse texture + * @param {TextureAtlas} alphaTexture - the opacity map, or the white pixel + * @param {object} [options] - wrap / mipmap options, as `getBinding` takes + * @returns {GPUBindGroup} the group-1 bind group + * @ignore + */ + getMeshBinding(texture, alphaTexture, options = {}) { + // residency first: this is the call that uploads either source, so it + // must happen before the records are read back + this.getBinding(texture, options); + this.getBinding(alphaTexture, options); + + const wrapFor = (t) => { + return typeof options.repeat === "string" + ? options.repeat + : (t.repeat ?? "no-repeat"); + }; + const wrap = wrapFor(texture); + const alphaWrap = wrapFor(alphaTexture); + const record = this.records.get(this.renderer.cache.getUnit(texture, wrap)); + const alphaRecord = this.records.get( + this.renderer.cache.getUnit(alphaTexture, alphaWrap), + ); + if (record === undefined || alphaRecord === undefined) { + // a source that failed to become resident — the caller keeps its + // previous binding rather than recording a draw against nothing + return null; + } + + const diffuse = this.viewAndSampler( + record, + texture, + wrap, + options.mipmaps === true, + ); + const alpha = this.viewAndSampler( + alphaRecord, + alphaTexture, + alphaWrap, + false, + ); + // Cached on the diffuse record, keyed FIRST by the alpha record object + // and then by the two sampler keys. One diffuse texture is often drawn + // by several meshes with different opacity maps, and keying on the + // diffuse sampler alone would hand the second mesh the first mesh's + // cut-outs. Keying on the record OBJECT (not its unit) also retires + // the entry naturally: a re-upload builds a new record, which simply + // misses rather than serving a bind group over a destroyed view. + if (record.meshBindGroups === undefined) { + record.meshBindGroups = new Map(); + } + let byAlpha = record.meshBindGroups.get(alphaRecord); + if (byAlpha === undefined) { + byAlpha = new Map(); + record.meshBindGroups.set(alphaRecord, byAlpha); + } + const key = `${diffuse.key}|${alpha.key}`; + let bindGroup = byAlpha.get(key); + if (bindGroup === undefined) { + bindGroup = this.device.createBindGroup({ + label: "melonJS mesh material", + layout: this.renderer.pipelineCache.meshMaterialLayout, + entries: [ + { binding: 0, resource: diffuse.view }, + { binding: 1, resource: diffuse.sampler }, + { binding: 2, resource: alpha.view }, + { binding: 3, resource: alpha.sampler }, + ], + }); + byAlpha.set(key, bindGroup); + } + return bindGroup; + } + + /** + * the per-sampler material bind group for a resident record (shared by + * the image and compressed upload paths) + * @ignore + */ + bindGroupFor(record, texture, wrap, wantMips = false) { + const resolved = this.viewAndSampler(record, texture, wrap, wantMips); + let bindGroup = record.bindGroupBySampler.get(resolved.key); if (typeof bindGroup === "undefined") { bindGroup = this.device.createBindGroup({ label: "melonJS material", layout: this.renderer.pipelineCache.materialLayout, entries: [ - { - binding: 0, - // compressed records keep a level-0 `view` for 2D - // consumers and a `fullView` over the authored chain - // for mip sampling; generated-chain records have one - // full view serving both (the sampler's lod clamp - // does the 2D restriction there) - resource: mip ? (record.fullView ?? record.view) : record.view, - }, - { binding: 1, resource: this.getSampler(filter, wrap, mip) }, + { binding: 0, resource: resolved.view }, + { binding: 1, resource: resolved.sampler }, ], }); - record.bindGroupBySampler.set(samplerKey, bindGroup); + record.bindGroupBySampler.set(resolved.key, bindGroup); } return bindGroup; } diff --git a/packages/melonjs/tests/gltf.spec.js b/packages/melonjs/tests/gltf.spec.js index c787912c2..dfe5529f5 100644 --- a/packages/melonjs/tests/gltf.spec.js +++ b/packages/melonjs/tests/gltf.spec.js @@ -1732,7 +1732,7 @@ const PNG_1x1 = // Build a single textured triangle whose material's sampler uses the given // wrap modes. `sampler` may be omitted entirely to exercise the glTF default. -function buildWrapGLB(sampler) { +function buildWrapGLB(sampler, pbr) { const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]); const uvs = new Float32Array([0, 0, 1, 0, 0, 1]); const indices = new Uint16Array([0, 1, 2]); @@ -1753,7 +1753,16 @@ function buildWrapGLB(sampler) { ], }, ], - materials: [{ pbrMetallicRoughness: { baseColorTexture: { index: 0 } } }], + materials: [ + pbr === null + ? {} + : { + pbrMetallicRoughness: { + baseColorTexture: { index: 0 }, + ...pbr, + }, + }, + ], textures: [ sampler === undefined ? { source: 0 } : { source: 0, sampler: 0 }, ], @@ -2003,3 +2012,70 @@ describe("parseGLTF() — emissive", () => { ).toBe(false); }); }); + +describe("parseGLTF() — metallic/roughness → specular (#1575)", () => { + // the same minimal scene as the wrap tests, with the PBR block swapped + const build = (pbr) => { + return buildWrapGLB(undefined, pbr); + }; + + it("ADVERSARIAL: the glTF DEFAULTS produce no highlight at all", async () => { + // metallicFactor and roughnessFactor both default to 1 — fully rough. + // Every existing scene relies on that: if the defaults produced a + // highlight, this change would visibly alter every shipped asset. + const scene = await parseGLTF(build({})); + expect(scene.nodes[0].shininess).toBe(0); + expect(scene.nodes[0].specular).toBeUndefined(); + }); + + it("ADVERSARIAL: a material with no pbrMetallicRoughness block is untouched", async () => { + const scene = await parseGLTF(build(null)); + expect(scene.nodes[0].shininess).toBe(0); + expect(scene.nodes[0].specular).toBeUndefined(); + }); + + it("roughness below 1 produces an exponent that rises as it falls", async () => { + const rough = await parseGLTF(build({ roughnessFactor: 0.8 })); + const glossy = await parseGLTF(build({ roughnessFactor: 0.3 })); + expect(rough.nodes[0].shininess).toBeGreaterThan(0); + expect(glossy.nodes[0].shininess).toBeGreaterThan(rough.nodes[0].shininess); + }); + + it("ADVERSARIAL: a mirror-smooth material is clamped, not infinite", async () => { + // the exponent runs away as roughness approaches 0, and a highlight + // narrower than a pixel only aliases + const scene = await parseGLTF(build({ roughnessFactor: 0 })); + expect(Number.isFinite(scene.nodes[0].shininess)).toBe(true); + expect(scene.nodes[0].shininess).toBeLessThanOrEqual(256); + }); + + it("a metal glints in its own base colour; a dielectric stays neutral", async () => { + const metal = await parseGLTF( + build({ + roughnessFactor: 0.2, + metallicFactor: 1, + baseColorFactor: [0.9, 0.6, 0.2, 1], + }), + ); + expect(metal.nodes[0].specular[0]).toBeCloseTo(0.9, 5); + expect(metal.nodes[0].specular[2]).toBeCloseTo(0.2, 5); + + const plaster = await parseGLTF( + build({ + roughnessFactor: 0.2, + metallicFactor: 0, + baseColorFactor: [0.9, 0.6, 0.2, 1], + }), + ); + // the standard dielectric F0, independent of the base colour + for (const channel of plaster.nodes[0].specular) { + expect(channel).toBeCloseTo(0.04, 5); + } + }); + + it("ADVERSARIAL: a malformed roughness does not reach the shader as NaN", async () => { + const scene = await parseGLTF(build({ roughnessFactor: "shiny" })); + expect(scene.nodes[0].shininess).toBe(0); + expect(scene.nodes[0].specular).toBeUndefined(); + }); +}); diff --git a/packages/melonjs/tests/helpers/webgpu-mock-renderer.js b/packages/melonjs/tests/helpers/webgpu-mock-renderer.js index 793ba171e..bc94bfa72 100644 --- a/packages/melonjs/tests/helpers/webgpu-mock-renderer.js +++ b/packages/melonjs/tests/helpers/webgpu-mock-renderer.js @@ -65,6 +65,7 @@ export function createMockWebGPURenderer() { const pipelines = new Map(); const materialBindings = new Map(); + const meshBindings = new Map(); const renderer = { calls, @@ -153,6 +154,7 @@ export function createMockWebGPURenderer() { }, frameLayout: {}, materialLayout: {}, + meshMaterialLayout: {}, multiMaterialLayout: {}, emptyLayout: {}, get( @@ -238,6 +240,21 @@ export function createMockWebGPURenderer() { } return materialBindings.get(texture); }, + // the mesh family's four-binding group: one stable token per + // (diffuse, alpha) PAIR, so a test can tell "same diffuse, new + // opacity map" from "same material" the way the real store does + getMeshBinding(texture, alphaTexture, options) { + calls.textureBindings.push({ texture, alphaTexture, options }); + let byAlpha = meshBindings.get(texture); + if (byAlpha === undefined) { + byAlpha = new Map(); + meshBindings.set(texture, byAlpha); + } + if (!byAlpha.has(alphaTexture)) { + byAlpha.set(alphaTexture, { texture, alphaTexture }); + } + return byAlpha.get(alphaTexture); + }, // one stable record per atlas object (the lit batcher composes // combined bind groups from the raw view) records: new Map(), diff --git a/packages/melonjs/tests/mesh_texture_groups.spec.js b/packages/melonjs/tests/mesh_texture_groups.spec.js index 6a3260744..e8a6f93ef 100644 --- a/packages/melonjs/tests/mesh_texture_groups.spec.js +++ b/packages/melonjs/tests/mesh_texture_groups.spec.js @@ -1,6 +1,6 @@ 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 { emit, GPU_TEXTURE_CACHE_RESET, off, on } from "../src/system/event.ts"; import { getWebGLRenderer, releaseWebGLRenderer, @@ -282,21 +282,26 @@ describe("Mesh per-material textures (#1573)", () => { const mesh = makeMesh(); drawOnce(mesh); + // Recorded at the DRAW, not at the bind: how many times the sampler + // uniform is touched per range is an implementation detail, but + // what it points at when each range is issued is the contract. const batcher = renderer.currentBatcher; - const units = []; - const original = batcher.bindSamplerUnit; - batcher.bindSamplerUnit = function (unit) { - units.push(unit); - return original.call(this, unit); + const gl = renderer.gl; + const inLoop = []; + const original = gl.drawElements; + gl.drawElements = function (...args) { + inLoop.push(batcher.currentSamplerUnit); + return original.apply(this, args); }; - drawOnce(mesh); - delete batcher.bindSamplerUnit; + try { + drawOnce(mesh); + } finally { + gl.drawElements = original; + } - // 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).toHaveLength(3); expect(inLoop[0]).not.toBe(inLoop[1]); + // the third range shares the first's map, so it returns to its unit expect(inLoop[2]).toBe(inLoop[0]); mesh.destroy(); }); @@ -473,8 +478,15 @@ describe("Mesh per-material textures (#1573)", () => { off(GPU_TEXTURE_CACHE_RESET, countReset); batcher.gl.drawElements = original; cache.max_size = budget; + // This renderer is shared with every other spec in the session, + // so put its unit bookkeeping back the way the engine does: + // clearing the assignments without announcing it leaves each + // batcher's `boundTextures` claiming units the cache no longer + // considers assigned, and the next `getUnit` then hands one of + // them out to a different texture that never gets bound. cache.units.clear(); cache.usedUnits.clear(); + emit(GPU_TEXTURE_CACHE_RESET); } // the test is only meaningful if the budget actually ran out — diff --git a/packages/melonjs/tests/mtl_material.spec.js b/packages/melonjs/tests/mtl_material.spec.js new file mode 100644 index 000000000..1c4c3f783 --- /dev/null +++ b/packages/melonjs/tests/mtl_material.spec.js @@ -0,0 +1,229 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { loader, Mesh } from "../src/index.js"; +import { parseMTL } from "../src/loader/parsers/mtl.js"; +import { + getWebGLRenderer, + releaseWebGLRenderer, + requireWebGL, +} from "./helpers/webgl-context.js"; + +/** + * MTL material fidelity (#1575) — specular (`Ks`/`Ns`) and per-texel opacity + * (`map_d`). + * + * The parser recognised ~20 properties and consumed five, so an authored + * highlight was read and thrown away and an alpha map was rejected outright. + * Both destinations already existed: the lit mesh path shades, and + * `alphaCutoff` already discards. + * + * The traps these pin are mostly about what must NOT switch on. `Ns` of 0 is + * the format's "no highlight", and exporters cheerfully write a bright `Ks` + * beside it — so a matte material that gained a highlight would be a + * regression visible on every model in the wild. + */ +describe("MTL specular and alpha maps (#1575)", () => { + let renderer; + + beforeAll(async () => { + renderer = await getWebGLRenderer(128, 128); + await loader.load({ + name: "mtl_material", + type: "mtl", + src: "/data/models/multitex-material.mtl", + }); + for (const name of ["alpha", "beta", "gamma", "plain"]) { + await loader.load({ + name: `mat_${name}`, + type: "obj", + src: `/data/models/mat-${name}.obj`, + }); + } + }); + + afterAll(() => { + releaseWebGLRenderer(); + }); + + const makeMesh = (material) => { + return new Mesh(0, 0, { + model: `mat_${material}`, + material: "mtl_material", + width: 32, + }); + }; + + // ── the parser ────────────────────────────────────────────────────── + + describe("parsing", () => { + it("reads Ks, Ns and map_d instead of ignoring them", () => { + const materials = parseMTL( + ["newmtl m", "Ks 0.25 0.5 0.75", "Ns 128", "map_d mask.png"].join("\n"), + "assets/", + ); + expect(materials.m.Ks).toEqual([0.25, 0.5, 0.75]); + expect(materials.m.Ns).toBe(128); + // resolved relative to the .mtl, exactly like map_Kd + expect(materials.m.map_d).toBe("assets/mask.png"); + }); + + it("defaults to no specular and no alpha map", () => { + const materials = parseMTL("newmtl m\nKd 1 1 1", ""); + expect(materials.m.Ks).toEqual([0, 0, 0]); + expect(materials.m.Ns).toBe(0); + expect(materials.m.map_d).toBe(null); + }); + + it("no longer warns that Ks / map_d are unsupported", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + parseMTL("newmtl m\nKs 1 1 1\nNs 10\nmap_d m.png", ""); + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + + it("still warns for maps that genuinely are not consumed", () => { + // the set shrank; it must not have emptied + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + parseMTL("newmtl m\nmap_bump b.png", ""); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); + }); + + // ── specular ──────────────────────────────────────────────────────── + + describe("specular", () => { + it("carries Ks and Ns onto the mesh", (ctx) => { + requireWebGL(ctx, renderer); + const mesh = makeMesh("alpha"); + // Float32Array — compare per component rather than deep-equalling + // against doubles + expect(mesh.specular[0]).toBeCloseTo(0.8, 6); + expect(mesh.specular[1]).toBeCloseTo(0.9, 6); + expect(mesh.specular[2]).toBeCloseTo(1, 6); + expect(mesh.shininess).toBe(64); + mesh.destroy(); + }); + + it("ADVERSARIAL: a bright Ks with Ns 0 stays matte", (ctx) => { + requireWebGL(ctx, renderer); + // what exporters write for a non-glossy material. Reading Ks alone + // would put a full-strength white highlight on every such surface + const mesh = makeMesh("beta"); + expect(mesh.shininess).toBe(0); + expect(mesh.specular).toBeUndefined(); + mesh.destroy(); + }); + + it("ADVERSARIAL: an Ns with no Ks stays matte", (ctx) => { + requireWebGL(ctx, renderer); + // a black specular colour contributes nothing, so switching the + // term on for it would only cost the pow() per light per pixel + const mesh = makeMesh("gamma"); + expect(mesh.specular).toBeUndefined(); + expect(mesh.shininess).toBe(0); + mesh.destroy(); + }); + + it("a material with neither leaves the mesh purely diffuse", (ctx) => { + requireWebGL(ctx, renderer); + const mesh = makeMesh("plain"); + expect(mesh.specular).toBeUndefined(); + expect(mesh.shininess).toBe(0); + mesh.destroy(); + }); + + it("the shader masks the highlight by the UNWRAPPED Lambert term", async () => { + // Half-Lambert lifts the shadowed side to 0.25 at 90 degrees, so + // reusing it for specular would light a highlight on a surface + // facing away from the light. Pinned on the source: there is no + // pixel-level harness for a lit mesh here, and this is the one + // place the two terms must NOT share a factor. + const [glsl, wgsl] = await Promise.all([ + import("../src/video/webgl/shaders/mesh-lit.frag?raw"), + import("../src/video/webgpu/shaders/mesh-lit.wgsl?raw"), + ]); + expect(glsl.default).toContain("max(dot(N, L), 0.0)"); + expect(wgsl.default).toContain("max(dot(n, lightDir), 0.0)"); + // and both gate on the exponent, not the colour + expect(glsl.default).toContain("uShininess > 0.0"); + expect(wgsl.default).toContain("shininess > 0.0"); + }); + }); + + // ── alpha maps ────────────────────────────────────────────────────── + + describe("alpha maps", () => { + it("resolves map_d onto the mesh, distinct from the diffuse texture", (ctx) => { + requireWebGL(ctx, renderer); + const mesh = makeMesh("alpha"); + expect(mesh.alphaMap).toBeDefined(); + expect(mesh.alphaMap).not.toBe(mesh.texture); + mesh.destroy(); + }); + + it("is undefined for a material without one", (ctx) => { + requireWebGL(ctx, renderer); + expect(makeMesh("plain").alphaMap).toBeUndefined(); + }); + + it("the MTL loader fetches map_d as well as map_Kd", () => { + // both maps must be resident without the caller preloading either + expect(loader.getImage("/data/models/multitex-alpha.png")).not.toBe(null); + expect(loader.getImage("/data/models/multitex-a.png")).not.toBe(null); + }); + + it("ADVERSARIAL: a missing map_d warns and keeps the mesh renderable", (ctx) => { + requireWebGL(ctx, renderer); + // the mesh loses its cut-outs, which is far easier to diagnose + // than a model that never appeared + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const mesh = new Mesh(0, 0, { + model: "mat_plain", + material: "mtl_material", + width: 32, + alphaMap: "not-a-real-map.png", + }); + expect(warn).toHaveBeenCalled(); + expect(mesh.alphaMap).toBeUndefined(); + warn.mockRestore(); + mesh.destroy(); + }); + + it("ADVERSARIAL: the map multiplies alpha BEFORE the cutout", async () => { + // the other order — threshold, then scale — cuts nothing out, + // because a fragment that survived the test keeps its alpha + // whatever the map says. Pinned on both shader sources, in both + // tiers, since the ordering is invisible to a call-count test. + const sources = await Promise.all([ + import("../src/video/webgl/shaders/mesh.frag?raw"), + import("../src/video/webgl/shaders/mesh-lit.frag?raw"), + import("../src/video/webgpu/shaders/mesh.wgsl?raw"), + import("../src/video/webgpu/shaders/mesh-lit.wgsl?raw"), + ]); + for (const source of sources) { + const text = source.default; + const sampled = text.indexOf("uAlphaMap"); + const cutoff = Math.max( + text.indexOf("uAlphaCutoff);"), + text.indexOf("< uAlphaCutoff"), + text.indexOf("uMesh.params.x"), + ); + expect(sampled).toBeGreaterThan(-1); + expect(cutoff).toBeGreaterThan(sampled); + } + }); + + it("ADVERSARIAL: both backends weight the sample rather than branch", async () => { + // One backend branching and the other multiplying is exactly how + // the emissive early-return diverged in #1572. Weighting also + // keeps the WGSL sample in uniform control flow. + const [glsl, wgsl] = await Promise.all([ + import("../src/video/webgl/shaders/mesh-lit.frag?raw"), + import("../src/video/webgpu/shaders/mesh-lit.wgsl?raw"), + ]); + expect(glsl.default).toContain("mix(1.0,"); + expect(wgsl.default).toContain("mix(1.0,"); + expect(glsl.default).not.toContain("if (uHasAlphaMap)"); + }); + }); +}); diff --git a/packages/melonjs/tests/public/data/models/mat-alpha.obj b/packages/melonjs/tests/public/data/models/mat-alpha.obj new file mode 100644 index 000000000..fc3f57760 --- /dev/null +++ b/packages/melonjs/tests/public/data/models/mat-alpha.obj @@ -0,0 +1,10 @@ +# single-material fixture for the MTL material-fidelity specs (#1575) +mtllib multitex-material.mtl +v 0 0 0 +v 1 0 0 +v 0 1 0 +vt 0 0 +vt 1 0 +vt 0 1 +usemtl alpha +f 1/1 2/2 3/3 diff --git a/packages/melonjs/tests/public/data/models/mat-beta.obj b/packages/melonjs/tests/public/data/models/mat-beta.obj new file mode 100644 index 000000000..ad2a3468b --- /dev/null +++ b/packages/melonjs/tests/public/data/models/mat-beta.obj @@ -0,0 +1,10 @@ +# single-material fixture for the MTL material-fidelity specs (#1575) +mtllib multitex-material.mtl +v 0 0 0 +v 1 0 0 +v 0 1 0 +vt 0 0 +vt 1 0 +vt 0 1 +usemtl beta +f 1/1 2/2 3/3 diff --git a/packages/melonjs/tests/public/data/models/mat-gamma.obj b/packages/melonjs/tests/public/data/models/mat-gamma.obj new file mode 100644 index 000000000..4f9aa9eed --- /dev/null +++ b/packages/melonjs/tests/public/data/models/mat-gamma.obj @@ -0,0 +1,10 @@ +# single-material fixture for the MTL material-fidelity specs (#1575) +mtllib multitex-material.mtl +v 0 0 0 +v 1 0 0 +v 0 1 0 +vt 0 0 +vt 1 0 +vt 0 1 +usemtl gamma +f 1/1 2/2 3/3 diff --git a/packages/melonjs/tests/public/data/models/mat-plain.obj b/packages/melonjs/tests/public/data/models/mat-plain.obj new file mode 100644 index 000000000..cd70c5c79 --- /dev/null +++ b/packages/melonjs/tests/public/data/models/mat-plain.obj @@ -0,0 +1,10 @@ +# single-material fixture for the MTL material-fidelity specs (#1575) +mtllib multitex-material.mtl +v 0 0 0 +v 1 0 0 +v 0 1 0 +vt 0 0 +vt 1 0 +vt 0 1 +usemtl plain +f 1/1 2/2 3/3 diff --git a/packages/melonjs/tests/public/data/models/multitex-alpha.png b/packages/melonjs/tests/public/data/models/multitex-alpha.png new file mode 100644 index 0000000000000000000000000000000000000000..500cc5f53cc367bc8891421d64cc60b9b314822a GIT binary patch literal 75 zcmeAS@N?(olHy`uVBq!ia0vp^EFjFm1|(O0oL2{=ggjjwLn>}1|KMlk;o%YR*~P#x X@3s2P$#s(DKv@P)S3j3^P6 { // dedup by module text: a second batcher re-registers the same key const again = new WebGPUMeshBatcher(renderer); expect(again.shaderKey).toBe(batcher.shaderKey); - expect(renderer.pipelineCache.effectLayouts.has("mesh:u176")).toBe(true); - expect(MESH_UNIFORM_SIZE).toBe(176); + expect(renderer.pipelineCache.effectLayouts.has("mesh:u208")).toBe(true); + // 176 before #1575 — grown by the specular vec4 and the eye position + expect(MESH_UNIFORM_SIZE).toBe(208); }); it("addMesh dedups indexed vertices: 6 indices land as 4 vertices + drawIndexed(6)", () => { diff --git a/packages/melonjs/tests/webgpu_mtl_material.spec.js b/packages/melonjs/tests/webgpu_mtl_material.spec.js new file mode 100644 index 000000000..62fb1c306 --- /dev/null +++ b/packages/melonjs/tests/webgpu_mtl_material.spec.js @@ -0,0 +1,207 @@ +import "./helpers/webgpu-globals.js"; +import { beforeEach, describe, expect, it } from "vitest"; +import WebGPUMeshBatcher, { + MESH_UNIFORM_SIZE, +} from "../src/video/webgpu/batchers/mesh_batcher.js"; +import { createMockWebGPURenderer } from "./helpers/webgpu-mock-renderer.js"; + +/** + * MTL specular and alpha maps on WebGPU (#1575). + * + * Two things this backend does that the GL one does not: the material + * scalars ride a packed uniform block, so an offset that drifts silently + * shades the wrong thing; and the opacity map needs a SECOND texture in + * group 1, which is the first time the mesh family's bind group differs + * from the shared 2D one. + */ +const DIFFUSE = { id: "diffuse" }; +const MASK_A = { id: "mask-a" }; +const MASK_B = { id: "mask-b" }; + +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, + ]), + 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: DIFFUSE, + textureRepeat: undefined, + vertexColors: undefined, + alphaCutoff: 0, + emissive: undefined, + specular: undefined, + shininess: 0, + alphaMap: undefined, + lit: false, + cullBackFaces: true, + rightHanded: false, + textureGroups: undefined, + ...overrides, + }; +} + +const MODEL = (() => { + const val = new Float32Array(16); + val[0] = val[5] = val[10] = val[15] = 1; + return { val }; +})(); + +describe("WebGPU MTL specular and alpha maps (#1575)", () => { + let renderer; + let batcher; + + beforeEach(() => { + renderer = createMockWebGPURenderer(); + batcher = new WebGPUMeshBatcher(renderer); + }); + + // the one uniform snapshot a single draw writes + const snapshot = () => { + const write = renderer.calls.writes.find((w) => { + return w.size === MESH_UNIFORM_SIZE; + }); + expect(write).toBeDefined(); + return write.floats; + }; + + describe("the uniform block", () => { + it("grew to 208 bytes: specular at float 44, eye at 48", () => { + expect(MESH_UNIFORM_SIZE).toBe(208); + const mesh = makeMesh({ + specular: new Float32Array([0.25, 0.5, 0.75]), + shininess: 64, + }); + batcher.drawRetainedMesh(mesh, MODEL, 0xffffffff); + const floats = snapshot(); + // model 0-15, view 16-31, tint 32-35, params 36-39, emissive 40-43 + expect(Array.from(floats.slice(44, 48))).toEqual([0.25, 0.5, 0.75, 64]); + }); + + it("ADVERSARIAL: specular does not overlap emissive", () => { + // one float of drift and a mesh's glow becomes its highlight + const mesh = makeMesh({ + emissive: new Float32Array([1, 0, 0]), + specular: new Float32Array([0, 0, 1]), + shininess: 8, + }); + batcher.drawRetainedMesh(mesh, MODEL, 0xffffffff); + const floats = snapshot(); + expect(Array.from(floats.slice(40, 44))).toEqual([1, 0, 0, 0]); + expect(Array.from(floats.slice(44, 48))).toEqual([0, 0, 1, 8]); + }); + + it("ADVERSARIAL: a Ks with shininess 0 writes ZERO specular", () => { + // the gate lives on the CPU as well as in the shader — writing the + // colour and relying on the exponent alone would light a highlight + // for any custom shader reading the block directly + const mesh = makeMesh({ + specular: new Float32Array([1, 1, 1]), + shininess: 0, + }); + batcher.drawRetainedMesh(mesh, MODEL, 0xffffffff); + expect(Array.from(snapshot().slice(44, 48))).toEqual([0, 0, 0, 0]); + }); + + it("carries the alpha-map flag in params.y, beside the cutout", () => { + batcher.drawRetainedMesh( + makeMesh({ alphaCutoff: 0.5, alphaMap: MASK_A }), + MODEL, + 0xffffffff, + ); + const floats = snapshot(); + expect(floats[36]).toBe(0.5); + expect(floats[37]).toBe(1); + + renderer = createMockWebGPURenderer(); + batcher = new WebGPUMeshBatcher(renderer); + batcher.drawRetainedMesh( + makeMesh({ alphaCutoff: 0.5 }), + MODEL, + 0xffffffff, + ); + expect(snapshot()[37]).toBe(0); + }); + + it("writes the camera position as the view's inverse translation", () => { + // a rigid view's inverse translation is -Rᵀ·t; with an identity + // basis that is simply the negated translation + const view = renderer.currentTransform.val; + view[12] = 10; + view[13] = -20; + view[14] = 30; + batcher.drawRetainedMesh( + makeMesh({ specular: new Float32Array([1, 1, 1]), shininess: 4 }), + MODEL, + 0xffffffff, + ); + expect(Array.from(snapshot().slice(48, 51))).toEqual([-10, 20, -30]); + }); + }); + + describe("the group-1 material", () => { + it("binds the alpha map as the second texture pair", () => { + batcher.drawRetainedMesh( + makeMesh({ alphaMap: MASK_A }), + MODEL, + 0xffffffff, + ); + const bound = renderer.calls.textureBindings.at(-1); + expect(bound.texture).toBe(DIFFUSE); + expect(bound.alphaTexture).toBe(MASK_A); + }); + + it("passes the diffuse texture as filler when there is no map", () => { + // reusing a resident texture costs no extra unit and no upload; the + // shader weights the sample away via params.y + batcher.drawRetainedMesh(makeMesh(), MODEL, 0xffffffff); + const bound = renderer.calls.textureBindings.at(-1); + expect(bound.texture).toBe(DIFFUSE); + expect(bound.alphaTexture).toBe(DIFFUSE); + }); + + it("ADVERSARIAL: one diffuse with two different masks gets two bind groups", () => { + // the trap the mesh bind-group cache is keyed against: caching on + // the diffuse record alone would hand the second mesh the first + // mesh's cut-outs + batcher.drawRetainedMesh( + makeMesh({ alphaMap: MASK_A }), + MODEL, + 0xffffffff, + ); + const first = renderer.calls.materialBinds.at(-1); + batcher.drawRetainedMesh( + makeMesh({ alphaMap: MASK_B }), + MODEL, + 0xffffffff, + ); + const second = renderer.calls.materialBinds.at(-1); + expect(first).not.toBe(second); + }); + + it("ADVERSARIAL: the same pair re-uses one bind group", () => { + // the converse — a fresh bind group per draw would defeat the cache + // and churn a GPU object every frame + const mesh = makeMesh({ alphaMap: MASK_A }); + batcher.drawRetainedMesh(mesh, MODEL, 0xffffffff); + const first = renderer.calls.materialBinds.at(-1); + renderer.frameId++; + batcher.drawRetainedMesh(mesh, MODEL, 0xffffffff); + expect(renderer.calls.materialBinds.at(-1)).toBe(first); + }); + + it("registers the mesh family against the four-binding layout", () => { + // group 1 is the mesh layout, not the shared 2D material layout — + // the pipeline would fail validation against the wrong one + expect(batcher.bindGroupLayoutList(renderer.pipelineCache)[1]).toBe( + renderer.pipelineCache.meshMaterialLayout, + ); + }); + }); +}); From 38fd78aa46e93f0635d0568bbc88b57d855aff55 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Fri, 7 Aug 2026 19:15:58 +0800 Subject: [PATCH 2/2] MTL Pr/Pm through the shared metallic-roughness mapping (#1575 item 2, scalars) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scalar half of item 2. I had deferred it as a PBR design question, then wrote exactly that mapping for the glTF loader an hour later — which made the MTL side nearly free and the deferral hard to justify. The mapping moves out of `gltf.js` into `loader/parsers/pbr.ts` and both loaders call it. That is the point of the change as much as the feature is: MTL's `Pr`/`Pm` and glTF's `pbrMetallicRoughness` describe one material concept, and approximating it in two places is how the two drift apart. - `Pr` / `Pm` are parsed, defaulting to `null` rather than a number: 0 is meaningful for both (mirror-smooth, non-metal), so "declared nothing" must stay distinguishable from "declared a mirror". - An explicit `Ks`/`Ns` WINS over the derived terms. Blender writes both blocks, so this precedence decides most real files; the explicit specular states what the artist wanted, the extension only implies it. - A fully-rough material derives nothing, which is what leaves existing scenes untouched. Still an approximation onto a stylized half-Lambert model, not a PBR shading model, and `map_Pr` / `map_Pm` are not consumed — those need the second-texture plumbing alongside #1574. Separately: `webgl_vao_adversarial`'s fuzz test gets an explicit 60s timeout. It runs in well under a second on its own but times out at 15s in a full run — the shared browser session slows as specs accumulate and a software rasterizer under load stretches several hundred GL ops by more than an order of magnitude. Same rationale as the config's 90s hookTimeout. It flaked before this branch too; cutting the iteration count to fit would have traded real fuzz coverage for a round number. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi --- packages/melonjs/CHANGELOG.md | 2 +- packages/melonjs/src/loader/parsers/gltf.js | 55 +++-------- packages/melonjs/src/loader/parsers/mtl.js | 29 +++++- packages/melonjs/src/loader/parsers/pbr.ts | 97 +++++++++++++++++++ packages/melonjs/src/renderable/mesh.js | 19 ++++ packages/melonjs/tests/mtl_material.spec.js | 58 ++++++++++- .../tests/public/data/models/mat-both.obj | 10 ++ .../tests/public/data/models/mat-pbr.obj | 10 ++ .../public/data/models/multitex-material.mtl | 16 +++ .../tests/webgl_vao_adversarial.spec.js | 9 +- 10 files changed, 257 insertions(+), 48 deletions(-) create mode 100644 packages/melonjs/src/loader/parsers/pbr.ts create mode 100644 packages/melonjs/tests/public/data/models/mat-both.obj create mode 100644 packages/melonjs/tests/public/data/models/mat-pbr.obj diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 0f26a6bb2..461d9c813 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -13,7 +13,7 @@ - **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 -- **MTL specular and per-texel opacity** ([#1575](https://github.com/melonjs/melonJS/issues/1575)) — the MTL parser recognised around twenty properties and consumed five, so an authored highlight was read and thrown away and an alpha map was rejected outright. Two of the remaining ones now land, both on foundations that already existed. **Specular** (`Ks` + `Ns`) gives the lit mesh path a Blinn-Phong highlight where it was previously half-Lambert diffuse plus an ambient floor — every material read as chalk. It is exposed as `mesh.specular` / `mesh.shininess` and gated on the **exponent**, not the colour: `Ns` of 0 is the format's "no highlight", and exporters routinely write a bright `Ks` beside it, so a material declaring one without the other stays matte and pays nothing. The highlight is masked by the *unwrapped* Lambert term — half-Lambert deliberately lifts the shadowed side, and a highlight on a surface facing away from the light reads as a rendering error. **`map_d`** drives `alphaCutoff` per *texel* rather than per material, which is what foliage, fences and decals actually need; it rides `mesh.alphaMap`, is fetched automatically by the MTL loader alongside `map_Kd`, and multiplies alpha **before** the cutout so the threshold sees the map's value. Both backends run the identical expression — the map is sampled unconditionally and weighted rather than branched around, which also keeps the WGSL sample in uniform control flow. A material with neither renders byte-identically to before. WebGPU note for custom mesh shaders: the mesh family's group 1 grows from two bindings to four (diffuse pair + opacity pair) and `MeshUniforms` from 176 to 208 bytes (`specular` = rgb + exponent, `eye` = camera world position); a custom WGSL module declaring only the diffuse pair is unaffected, since a module may declare a subset of its layout. Both are shown in the reworked **Per-material Textures** example — a chrome ball for the highlight, a perforated panel for the cutout +- **MTL specular and per-texel opacity** ([#1575](https://github.com/melonjs/melonJS/issues/1575)) — the MTL parser recognised around twenty properties and consumed five, so an authored highlight was read and thrown away and an alpha map was rejected outright. Two of the remaining ones now land, both on foundations that already existed. **Specular** (`Ks` + `Ns`) gives the lit mesh path a Blinn-Phong highlight where it was previously half-Lambert diffuse plus an ambient floor — every material read as chalk. It is exposed as `mesh.specular` / `mesh.shininess` and gated on the **exponent**, not the colour: `Ns` of 0 is the format's "no highlight", and exporters routinely write a bright `Ks` beside it, so a material declaring one without the other stays matte and pays nothing. The highlight is masked by the *unwrapped* Lambert term — half-Lambert deliberately lifts the shadowed side, and a highlight on a surface facing away from the light reads as a rendering error. **`map_d`** drives `alphaCutoff` per *texel* rather than per material, which is what foliage, fences and decals actually need; it rides `mesh.alphaMap`, is fetched automatically by the MTL loader alongside `map_Kd`, and multiplies alpha **before** the cutout so the threshold sees the map's value. Both backends run the identical expression — the map is sampled unconditionally and weighted rather than branched around, which also keeps the WGSL sample in uniform control flow. A material with neither renders byte-identically to before. WebGPU note for custom mesh shaders: the mesh family's group 1 grows from two bindings to four (diffuse pair + opacity pair) and `MeshUniforms` from 176 to 208 bytes (`specular` = rgb + exponent, `eye` = camera world position); a custom WGSL module declaring only the diffuse pair is unaffected, since a module may declare a subset of its layout. Both are shown in the reworked **Per-material Textures** example — a chrome ball for the highlight, a perforated panel for the cutout. MTL's **`Pr` / `Pm`** roughness-metalness extension (which Blender's OBJ exporter writes by default) and glTF's `pbrMetallicRoughness` factors are both approximated onto the same terms through one shared mapping — roughness → exponent, metalness → tint between the dielectric baseline and the base colour — so a material described in either format shades identically. An explicitly authored `Ks`/`Ns` always wins over the derived approximation, and a fully-rough material (glTF's default) derives nothing, which is what leaves existing scenes untouched. Note this is an approximation onto a stylized half-Lambert model, **not** a PBR shading model; the `map_Pr` / `map_Pm` texture maps are not consumed - **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 diff --git a/packages/melonjs/src/loader/parsers/gltf.js b/packages/melonjs/src/loader/parsers/gltf.js index dcab9f2fd..eaa2f5228 100644 --- a/packages/melonjs/src/loader/parsers/gltf.js +++ b/packages/melonjs/src/loader/parsers/gltf.js @@ -2,6 +2,7 @@ import { level } from "../../level/level.js"; import { transformedBounds } from "../../math/vertex.ts"; import { gltfList } from "../cache.js"; import { fetchData } from "./fetchdata.js"; +import { specularFromMetallicRoughness } from "./pbr.ts"; /** * glTF 2.0 (.gltf / .glb) scene loader — Tier 1. @@ -703,54 +704,22 @@ export async function parseGLTF(arrayBuffer, baseURI, settings) { return undefined; }; - // Resolve material index -> `{ specular, shininess }`, approximating glTF's - // metallic/roughness onto the lit mesh path's Blinn-Phong term (#1575). - // - // This is a deliberate APPROXIMATION, not a PBR implementation: the lit - // path is stylized half-Lambert diffuse, not a microfacet BRDF, so the - // point is to recover the "is this surface glossy, and what colour does it - // glint" information every glTF asset already carries — Blender writes - // both factors by default — rather than to be energy-correct. - // - // - roughness -> exponent through the usual Blinn-Phong/GGX bridge, - // `2 / a^4 - 2` for `a = roughness^2`. The glTF DEFAULT roughness of 1 - // lands on exactly 0, which is the "no highlight" gate: a material that - // declares nothing, or declares itself fully rough, shades exactly as it - // did before this existed. Clamped at the top because the exponent runs - // away as roughness approaches 0, and a highlight narrower than a pixel - // only aliases. - // - metallic -> tint. A dielectric reflects white-ish at 4% (the standard - // F0), a metal reflects its own base colour: `mix(0.04, baseColor, - // metallic)`. So chrome glints in its own hue while plaster gets a faint - // sheen, which is the visible half of the distinction. - const MAX_SHININESS = 256; - const DIELECTRIC_F0 = 0.04; + // Resolve material index -> `{ specular, shininess }` from the material's + // metallic/roughness factors (#1575). The mapping itself lives in + // `pbr.ts` because MTL's `Pr`/`Pm` extension describes the same concept + // and must not approximate it differently. const materialSpecular = (materialIndex) => { const pbr = materialIndex !== undefined ? json.materials?.[materialIndex]?.pbrMetallicRoughness : undefined; - // Both factors default to 1 per the spec — fully rough, which yields - // no highlight, so an asset that declares neither is untouched. A - // malformed factor takes the same default rather than propagating: - // `NaN` would otherwise fail the `a > 0` test and land in the - // mirror-smooth branch below, turning a broken file into a chrome one. - const declared = (value) => { - return typeof value === "number" && Number.isFinite(value) ? value : 1; - }; - const roughness = declared(pbr?.roughnessFactor); - const metallic = declared(pbr?.metallicFactor); - const a = roughness * roughness; - const shininess = - a > 0 ? Math.min(MAX_SHININESS, 2 / (a * a) - 2) : MAX_SHININESS; - if (!(shininess > 0)) { - return { specular: undefined, shininess: 0 }; - } - const base = pbr?.baseColorFactor ?? [1, 1, 1, 1]; - const mix = (channel) => { - return DIELECTRIC_F0 + ((base[channel] || 0) - DIELECTRIC_F0) * metallic; - }; - return { specular: [mix(0), mix(1), mix(2)], shininess }; + // both factors default to 1 per the glTF spec — fully rough, which + // yields no highlight, so an asset declaring neither is untouched + return specularFromMetallicRoughness( + pbr?.roughnessFactor, + pbr?.metallicFactor, + pbr?.baseColorFactor ?? [1, 1, 1, 1], + ); }; // resolve material index -> alpha cutout threshold. glTF `alphaMode: diff --git a/packages/melonjs/src/loader/parsers/mtl.js b/packages/melonjs/src/loader/parsers/mtl.js index 614a10dd3..f64cfcdcd 100644 --- a/packages/melonjs/src/loader/parsers/mtl.js +++ b/packages/melonjs/src/loader/parsers/mtl.js @@ -14,6 +14,8 @@ const SUPPORTED_PROPS = new Set([ "Ks", "Ns", "map_d", + "Pr", + "Pm", // ignored but harmless "Ka", "Ni", @@ -36,8 +38,10 @@ const UNSUPPORTED_MAPS = new Set([ /** * Parse a Wavefront MTL file into material data. * Supports: `newmtl`, `Kd` (diffuse color), `Ke` (emissive color), `Ks`/`Ns` - * (specular color and exponent), `map_Kd` (diffuse texture), `map_d` (alpha - * map), `d`/`Tr` (opacity/transparency). + * (specular color and exponent), `Pr`/`Pm` (the PBR roughness/metalness + * extension, approximated onto the specular terms when `Ks`/`Ns` are absent), + * `map_Kd` (diffuse texture), `map_d` (alpha map), `d`/`Tr` + * (opacity/transparency). * * Limitations: * - Ambient (`Ka`), optical density (`Ni`) and illumination model (`illum`) are parsed but ignored @@ -90,6 +94,11 @@ export function parseMTL(text, basePath) { // shade exactly as it did before specular existed Ks: [0, 0, 0], Ns: 0, + // the PBR extension. `null` rather than a default value: + // absent must be distinguishable from an authored 0, which + // means "mirror-smooth" for Pr and "non-metal" for Pm + Pr: null, + Pm: null, d: 1.0, map_Kd: null, map_d: null, @@ -139,6 +148,22 @@ export function parseMTL(text, basePath) { } break; + case "Pr": + // PBR extension: roughness. Blender's OBJ exporter writes it + // (and `Pm`) for every material, so these are already in the + // assets people bring to the engine + if (current) { + current.Pr = parseFloat(parts[1]); + } + break; + + case "Pm": + // PBR extension: metalness + if (current) { + current.Pm = parseFloat(parts[1]); + } + break; + case "d": if (current) { current.d = parseFloat(parts[1]); diff --git a/packages/melonjs/src/loader/parsers/pbr.ts b/packages/melonjs/src/loader/parsers/pbr.ts new file mode 100644 index 000000000..5fb3f1a34 --- /dev/null +++ b/packages/melonjs/src/loader/parsers/pbr.ts @@ -0,0 +1,97 @@ +/** + * Mapping authored metallic/roughness onto the lit mesh path's Blinn-Phong + * terms (#1575). + * + * Two formats describe the same material concept — glTF's + * `pbrMetallicRoughness` and MTL's `Pr` / `Pm` extension, both of which + * Blender writes by default — and both land here so the two cannot drift + * apart. What they land ON is a stylized half-Lambert shading model, not a + * microfacet BRDF, so this is deliberately an APPROXIMATION: the point is to + * recover the "is this surface glossy, and what colour does it glint" + * information the asset already carries, rather than to be energy-correct. + * + * An explicitly authored specular (`Ks` + `Ns`) always wins over this — it + * says exactly what the artist wanted, where metallic/roughness only implies + * it. + * @ignore + */ + +/** + * The exponent a mirror-smooth surface is clamped to. The Blinn-Phong + * exponent runs away as roughness approaches 0, and a highlight narrower + * than a pixel only aliases. + * @ignore + */ +export const MAX_SHININESS = 256; + +/** + * The specular reflectance of a non-metal, the standard value used by every + * metallic/roughness workflow. Metals reflect their own base colour instead. + * @ignore + */ +export const DIELECTRIC_F0 = 0.04; + +/** + * The result of the mapping: a specular colour and exponent ready to hand to + * a {@link Mesh}, or `undefined` / `0` when the material implies no + * highlight at all. + * @ignore + */ +export interface SpecularTerms { + /** specular colour `[r, g, b]`, or `undefined` for none */ + specular: number[] | undefined; + /** specular exponent; `0` disables the term outright */ + shininess: number; +} + +/** + * Read a factor that should default to 1 when absent or malformed. + * + * Defaulting rather than propagating matters: `NaN` would fail the `> 0` + * test below and fall into the mirror-smooth branch, turning a broken file + * into a chrome one. + * @param value - the authored factor + * @returns the factor, or 1 + * @ignore + */ +function factor(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) ? value : 1; +} + +/** + * Map a metallic/roughness pair onto a Blinn-Phong specular colour and + * exponent. + * + * - **roughness → exponent** through the usual Blinn-Phong/GGX bridge, + * `2 / a^4 - 2` for `a = roughness^2`. A roughness of 1 — which is the + * glTF default, and what a matte material declares — lands on exactly 0, + * so a material that says nothing gets no highlight and shades exactly as + * it did before this existed. + * - **metallic → tint**, interpolating from the dielectric reflectance to + * the material's own base colour. Chrome glints in its own hue while + * plaster gets a faint neutral sheen, which is the visible half of the + * metal/non-metal distinction. + * @param roughness - authored roughness (0..1); absent or malformed reads as 1 + * @param metallic - authored metalness (0..1); absent or malformed reads as 1 + * @param baseColor - the material's base colour, `[r, g, b, a?]` + * @returns the specular terms, inert when the material implies no highlight + * @ignore + */ +export function specularFromMetallicRoughness( + roughness: unknown, + metallic: unknown, + baseColor: number[] = [1, 1, 1, 1], +): SpecularTerms { + const r = factor(roughness); + const m = factor(metallic); + const a = r * r; + const shininess = + a > 0 ? Math.min(MAX_SHININESS, 2 / (a * a) - 2) : MAX_SHININESS; + if (!(shininess > 0)) { + return { specular: undefined, shininess: 0 }; + } + const channel = (index: number) => { + return DIELECTRIC_F0 + ((baseColor[index] || 0) - DIELECTRIC_F0) * m; + }; + return { specular: [channel(0), channel(1), channel(2)], shininess }; +} diff --git a/packages/melonjs/src/renderable/mesh.js b/packages/melonjs/src/renderable/mesh.js index 35d2528f4..53fd2b3f2 100644 --- a/packages/melonjs/src/renderable/mesh.js +++ b/packages/melonjs/src/renderable/mesh.js @@ -2,6 +2,7 @@ import { game } from "../application/application.ts"; import Camera3d from "../camera/camera3d.ts"; import { Polygon } from "../geometries/polygon.ts"; import { getImage, getMTL, getOBJ } from "./../loader/loader.js"; +import { specularFromMetallicRoughness } from "./../loader/parsers/pbr.ts"; import { Color } from "../math/color.ts"; import { Matrix3d } from "../math/matrix3d.ts"; import { Vector2d } from "../math/vector2d.ts"; @@ -689,6 +690,24 @@ export default class Mesh extends Renderable { if (ks !== undefined && mat.Ns > 0) { this.specular = ks; this.shininess = mat.Ns; + } else if (mat.Pr !== null && mat.Pr !== undefined) { + // The PBR extension (`Pr` / `Pm`), approximated onto the + // same terms — the shared mapping the glTF loader uses, so + // one material described in two formats shades the same. + // + // Only when `Ks`/`Ns` said nothing: an explicit specular + // states exactly what the artist wanted, where + // roughness/metalness merely implies it. Blender writes + // both blocks, so this precedence decides most real files. + const derived = specularFromMetallicRoughness( + mat.Pr, + mat.Pm ?? 0, + mat.Kd, + ); + if (derived.specular !== undefined) { + this.specular = new Float32Array(derived.specular); + this.shininess = derived.shininess; + } } // MTL alpha map (map_d) — per-texel opacity if (mat.map_d) { diff --git a/packages/melonjs/tests/mtl_material.spec.js b/packages/melonjs/tests/mtl_material.spec.js index 1c4c3f783..021772e08 100644 --- a/packages/melonjs/tests/mtl_material.spec.js +++ b/packages/melonjs/tests/mtl_material.spec.js @@ -1,6 +1,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { loader, Mesh } from "../src/index.js"; import { parseMTL } from "../src/loader/parsers/mtl.js"; +import { specularFromMetallicRoughness } from "../src/loader/parsers/pbr.ts"; import { getWebGLRenderer, releaseWebGLRenderer, @@ -31,7 +32,7 @@ describe("MTL specular and alpha maps (#1575)", () => { type: "mtl", src: "/data/models/multitex-material.mtl", }); - for (const name of ["alpha", "beta", "gamma", "plain"]) { + for (const name of ["alpha", "beta", "gamma", "plain", "pbr", "both"]) { await loader.load({ name: `mat_${name}`, type: "obj", @@ -226,4 +227,59 @@ describe("MTL specular and alpha maps (#1575)", () => { expect(glsl.default).not.toContain("if (uHasAlphaMap)"); }); }); + // ── the PBR extension (Pr / Pm) ───────────────────────────────────── + + describe("Pr / Pm", () => { + it("parses the roughness/metalness extension", () => { + const materials = parseMTL("newmtl m\nPr 0.3\nPm 0.8", ""); + expect(materials.m.Pr).toBe(0.3); + expect(materials.m.Pm).toBe(0.8); + }); + + it("ADVERSARIAL: absent reads as null, not as 0", () => { + // 0 is meaningful for both — mirror-smooth, and non-metal — so + // defaulting them to a number would make "declared nothing" + // indistinguishable from "declared a mirror" + const materials = parseMTL("newmtl m\nKd 1 1 1", ""); + expect(materials.m.Pr).toBe(null); + expect(materials.m.Pm).toBe(null); + }); + + it("derives a highlight when Ks/Ns are absent", (ctx) => { + requireWebGL(ctx, renderer); + const mesh = makeMesh("pbr"); + expect(mesh.shininess).toBeGreaterThan(0); + // fully metallic, so it glints in its own base colour + expect(mesh.specular[0]).toBeCloseTo(0.85, 5); + expect(mesh.specular[1]).toBeCloseTo(0.55, 5); + mesh.destroy(); + }); + + it("ADVERSARIAL: an explicit Ks/Ns WINS over the extension", (ctx) => { + requireWebGL(ctx, renderer); + // Blender writes both blocks, so this precedence decides most real + // files. The explicit specular states what the artist wanted; the + // extension only implies it. + const mesh = makeMesh("both"); + expect(mesh.shininess).toBe(12); + expect(mesh.specular[1]).toBeCloseTo(0.9, 5); + mesh.destroy(); + }); + + it("ADVERSARIAL: a fully-rough Pr yields no highlight", () => { + // the same inert gate the glTF default relies on + const terms = specularFromMetallicRoughness(1, 1, [1, 1, 1, 1]); + expect(terms.shininess).toBe(0); + expect(terms.specular).toBeUndefined(); + }); + + it("ADVERSARIAL: MTL and glTF derive the SAME terms from one pair", () => { + // the two formats describe one concept; approximating it twice is + // how they drift. Both call this helper — pinning it here means a + // second copy appearing anywhere would have to disagree with it. + const a = specularFromMetallicRoughness(0.25, 1, [0.85, 0.55, 0.25]); + const b = specularFromMetallicRoughness(0.25, 1, [0.85, 0.55, 0.25, 1]); + expect(a).toEqual(b); + }); + }); }); diff --git a/packages/melonjs/tests/public/data/models/mat-both.obj b/packages/melonjs/tests/public/data/models/mat-both.obj new file mode 100644 index 000000000..ff7355db2 --- /dev/null +++ b/packages/melonjs/tests/public/data/models/mat-both.obj @@ -0,0 +1,10 @@ +# single-material fixture for the MTL material-fidelity specs (#1575) +mtllib multitex-material.mtl +v 0 0 0 +v 1 0 0 +v 0 1 0 +vt 0 0 +vt 1 0 +vt 0 1 +usemtl both +f 1/1 2/2 3/3 diff --git a/packages/melonjs/tests/public/data/models/mat-pbr.obj b/packages/melonjs/tests/public/data/models/mat-pbr.obj new file mode 100644 index 000000000..5b3c2c513 --- /dev/null +++ b/packages/melonjs/tests/public/data/models/mat-pbr.obj @@ -0,0 +1,10 @@ +# single-material fixture for the MTL material-fidelity specs (#1575) +mtllib multitex-material.mtl +v 0 0 0 +v 1 0 0 +v 0 1 0 +vt 0 0 +vt 1 0 +vt 0 1 +usemtl pbr +f 1/1 2/2 3/3 diff --git a/packages/melonjs/tests/public/data/models/multitex-material.mtl b/packages/melonjs/tests/public/data/models/multitex-material.mtl index 4634828eb..98fafbc71 100644 --- a/packages/melonjs/tests/public/data/models/multitex-material.mtl +++ b/packages/melonjs/tests/public/data/models/multitex-material.mtl @@ -21,3 +21,19 @@ Ns 200 newmtl plain Kd 0.9 0.9 0.9 + +# `pbr` declares only the roughness/metalness extension — no Ks, no Ns — so +# the specular terms have to be derived from it +newmtl pbr +Kd 0.85 0.55 0.25 +Pr 0.25 +Pm 1.0 + +# `both` declares an explicit specular AND the extension. The explicit one +# must win: it says what the artist wanted, the extension only implies it. +newmtl both +Kd 0.2 0.2 0.2 +Ks 0.1 0.9 0.1 +Ns 12 +Pr 0.1 +Pm 1.0 diff --git a/packages/melonjs/tests/webgl_vao_adversarial.spec.js b/packages/melonjs/tests/webgl_vao_adversarial.spec.js index 4f43ea628..285403877 100644 --- a/packages/melonjs/tests/webgl_vao_adversarial.spec.js +++ b/packages/melonjs/tests/webgl_vao_adversarial.spec.js @@ -144,6 +144,13 @@ describe("WebGL VAO adversarial", () => { expect(gl.getError()).toBe(gl.NO_ERROR); } + // An explicit timeout, for the same reason the config raises + // `hookTimeout`: this file runs in well under a second on its own, but + // the shared browser session gets slower as specs accumulate, and a + // software rasterizer under load stretches several hundred GL ops by more + // than an order of magnitude. It is a slow test, not a hanging one — and + // cutting the iteration count to fit would trade real fuzz coverage for a + // round number. it("fuzz: several hundred random ops leave every frozen layout byte-identical", (ctx) => { requireWebGL(ctx); const before = snapshotAll(); @@ -218,7 +225,7 @@ describe("WebGL VAO adversarial", () => { expect(firstError).toBe("none"); expect(snapshotAll()).toEqual(before); drawTruthSceneAndAssert(); - }); + }, 60000); it("enabled-but-unconsumed attribute: 3-of-4 shader roundtrip stays pixel-correct", (ctx) => { requireWebGL(ctx);