From a2b09cbccdedc4a992574aae8ffe84e50c8e5d1f Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sat, 20 Jun 2026 10:26:59 +0800 Subject: [PATCH 01/10] =?UTF-8?q?feat(gltf):=20KHR=5Fmaterials=5Funlit=20?= =?UTF-8?q?=E2=80=94=20per-primitive=20unlit=20materials?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Materials flagged KHR_materials_unlit bake their own lighting and must not be shaded again. The parser detects the extension per material; GLTFScene / GLTFModel set `lit = sceneLit && !unlit` so an unlit prim renders fullbright even in a lit scene (no double-lighting). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/melonjs/CHANGELOG.md | 1 + packages/melonjs/src/level/gltf/GLTFModel.js | 3 +- packages/melonjs/src/level/gltf/GLTFScene.js | 6 +- packages/melonjs/src/loader/parsers/gltf.js | 14 +++ packages/melonjs/tests/gltf.spec.js | 107 +++++++++++++++++++ 5 files changed, 128 insertions(+), 3 deletions(-) diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 912fb9476..f242d1dfe 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -13,6 +13,7 @@ - **`Mesh` `textureRepeat` setting** — texture wrap mode (`"repeat"` / `"repeat-x"` / `"repeat-y"` / `"no-repeat"`) applied to the resolved texture, for geometry whose UVs fall outside `[0, 1]` and rely on the texture tiling. The glTF loader sets it from each material's sampler `wrapS` / `wrapT` (defaulting to REPEAT, the glTF spec default) — without it such assets sampled flat edge texels and looked untextured. Never applied to the shared white-pixel fallback. - **glTF material color** — `baseColorFactor` is applied as the mesh tint, so a solid-colored *untextured* material renders its color (previously it fell back to white). **Vertex colors** (`COLOR_0`, float or normalized byte/short, VEC3/VEC4) are read into per-vertex colors — untextured vertex-colored meshes (MagicaVoxel exports, vertex-painted models) render correctly. Factor, vertex color, and texture compose (`factor × vertexColor × texel`), and work under lighting. - **3D mesh lighting** — `Light3d`, a manipulable light managed exactly like `Light2d`: it's a world `Renderable`, so `app.world.addChild(new Light3d({ direction, color, intensity }))` adds it and the active stage auto-tracks it (remove it from the world to turn it off — no global, no separate lighting object). Types: `"directional"` (a sun, half-Lambert diffuse) and `"ambient"` (a flat fill). Loading a glTF scene adds its authored `KHR_lights_punctual` directional lights (plus a soft ambient fill) automatically, so meshes are lit by the same sun set up in the authoring tool. Fields are mutable, so a light can be animated at runtime (e.g. a day/night cycle rotating `direction`). Meshes opt in via `mesh.lit` and render through a dedicated `LitMeshBatcher`; standalone unlit meshes keep the lean path and pay nothing for lighting. Directional + ambient this release (point/spot are parsed but not yet shaded). +- **glTF `KHR_materials_unlit`** — materials flagged with the extension bake their own lighting and are rendered fullbright (not shaded again), even in a lit scene. A very common stylized workflow (baked lighting in the texture); honoring it avoids double-lighting. Applied per primitive. - **`Mesh.getBounds3d()`** — the mesh's world-space `AABB3d` (the 3D analog of `getBounds()`, which only describes a flat 2D box). Powers the debug-plugin's new 3D bounding-box wireframe overlay. - **`Camera3d.worldToScreen(world, out?)`** — project a world point to screen-pixel coordinates (perspective divide included); returns `null` for points behind the camera. Useful for HUD elements pinned to 3D objects, picking, and debug overlays. - **`AABB3d`** now exported, with `AABB3d.fromVertices(src, count, matrix?)` to build a box from a flat vertex buffer (delegates to the new `transformedBounds`). diff --git a/packages/melonjs/src/level/gltf/GLTFModel.js b/packages/melonjs/src/level/gltf/GLTFModel.js index 2254d7418..97922d3ac 100644 --- a/packages/melonjs/src/level/gltf/GLTFModel.js +++ b/packages/melonjs/src/level/gltf/GLTFModel.js @@ -133,7 +133,8 @@ export default class GLTFModel extends Container { scale: this.scale, normalize: false, rightHanded, - lit, + // KHR_materials_unlit materials skip the lit path even in a lit scene + lit: lit && prim.unlit !== true, // honor the glTF sampler wrap (default REPEAT) — many exporters // author UVs outside [0,1] that tile; clamping flattens them textureRepeat: prim.textureRepeat, diff --git a/packages/melonjs/src/level/gltf/GLTFScene.js b/packages/melonjs/src/level/gltf/GLTFScene.js index 97682b290..7d838a339 100644 --- a/packages/melonjs/src/level/gltf/GLTFScene.js +++ b/packages/melonjs/src/level/gltf/GLTFScene.js @@ -144,8 +144,10 @@ export default class GLTFScene { // honor the glTF sampler wrap (default REPEAT) so tiling UVs // (UVs outside [0,1]) sample correctly instead of clamping flat textureRepeat: node.textureRepeat, - // light this mesh (via the lit batcher) when the scene has lights - lit, + // 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) + lit: lit && node.unlit !== true, // honor the glTF material's double-sided flag: thin/flat props // (coins, fences, foliage) are double-sided and must NOT be // back-face culled, or half their faces vanish diff --git a/packages/melonjs/src/loader/parsers/gltf.js b/packages/melonjs/src/loader/parsers/gltf.js index d46c0d541..1970069c9 100644 --- a/packages/melonjs/src/loader/parsers/gltf.js +++ b/packages/melonjs/src/loader/parsers/gltf.js @@ -529,6 +529,17 @@ export async function parseGLTF(arrayBuffer, baseURI, settings) { return "no-repeat"; }; + // resolve material index -> whether it opts out of lighting via + // `KHR_materials_unlit` (a common stylized workflow: lighting is baked into + // the texture, so the engine must NOT shade it again or it double-lights). + const materialUnlit = (materialIndex) => { + return ( + materialIndex !== undefined && + json.materials?.[materialIndex]?.extensions?.KHR_materials_unlit !== + undefined + ); + }; + // walk the active scene's node graph, accumulating world matrices. // A malformed asset is not allowed to crash the loader: a missing scene // or scene-node list degrades to an empty (but valid) descriptor rather @@ -601,6 +612,9 @@ export async function parseGLTF(arrayBuffer, baseURI, settings) { doubleSided: prim.material !== undefined && json.materials?.[prim.material]?.doubleSided === true, + // KHR_materials_unlit — the material bakes its own lighting and must + // not be shaded again (skips the lit path even in a lit scene) + unlit: materialUnlit(prim.material), }; }; diff --git a/packages/melonjs/tests/gltf.spec.js b/packages/melonjs/tests/gltf.spec.js index 298da4363..826607d2f 100644 --- a/packages/melonjs/tests/gltf.spec.js +++ b/packages/melonjs/tests/gltf.spec.js @@ -1053,6 +1053,59 @@ describe("GLTFScene → lighting (KHR_lights_punctual)", () => { expect(lightsOf(container)).toHaveLength(0); expect(container.kids[0].lit).toBe(false); }); + + it("KHR_materials_unlit: an unlit-material mesh stays unlit even in a lit scene", async () => { + // a lit scene (directional light) whose single mesh uses an unlit material + const UNLIT = "__gltf_unlit_in_lit"; + const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]); + const normals = new Float32Array([0, 0, 1, 0, 0, 1, 0, 0, 1]); + const indices = new Uint16Array([0, 1, 2]); + const { bin, offsets } = packParts([positions, normals, indices]); + const json = { + asset: { version: "2.0" }, + scene: 0, + scenes: [{ nodes: [0, 1] }], + extensionsUsed: ["KHR_lights_punctual", "KHR_materials_unlit"], + extensions: { + KHR_lights_punctual: { + lights: [{ type: "directional", color: [1, 1, 1], intensity: 1000 }], + }, + }, + nodes: [ + { mesh: 0 }, + { extensions: { KHR_lights_punctual: { light: 0 } } }, + ], + materials: [{ extensions: { KHR_materials_unlit: {} } }], + meshes: [ + { + primitives: [ + { attributes: { POSITION: 0, NORMAL: 1 }, indices: 2, material: 0 }, + ], + }, + ], + accessors: [ + { bufferView: 0, componentType: 5126, count: 3, type: "VEC3" }, + { bufferView: 1, componentType: 5126, count: 3, type: "VEC3" }, + { bufferView: 2, componentType: 5123, count: 3, type: "SCALAR" }, + ], + bufferViews: [ + { buffer: 0, byteOffset: offsets[0], byteLength: positions.byteLength }, + { buffer: 0, byteOffset: offsets[1], byteLength: normals.byteLength }, + { buffer: 0, byteOffset: offsets[2], byteLength: indices.byteLength }, + ], + buffers: [{ byteLength: bin.length }], + }; + gltfList[UNLIT] = await parseGLTF(packGLB(json, bin)); + + const container = fakeContainer(); + new GLTFScene(UNLIT).addTo(container, { scale: 10 }); + // the scene IS lit (directional light added)… + expect(lightsOf(container).length).toBeGreaterThan(0); + // …but the unlit material opts this mesh out of the lit path + expect(container.kids[0].lit).toBe(false); + + delete gltfList[UNLIT]; + }); }); // ── ADVERSARIAL: matrix multiply (node hierarchy) ──────────────────────────── @@ -1575,3 +1628,57 @@ describe("parseGLTF() — texture wrap mode", () => { expect(scene.nodes[0].textureRepeat).toBe("repeat"); }); }); + +// ── material flags: KHR_materials_unlit ────────────────────────────────────── + +// single textured-less triangle whose material carries the given extensions +function buildMaterialGLB(material) { + const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]); + const indices = new Uint16Array([0, 1, 2]); + const { bin, offsets } = packParts([positions, indices]); + const json = { + asset: { version: "2.0" }, + scene: 0, + scenes: [{ nodes: [0] }], + nodes: [{ mesh: 0 }], + materials: [material], + meshes: [ + { + primitives: [{ attributes: { POSITION: 0 }, indices: 1, material: 0 }], + }, + ], + accessors: [ + { bufferView: 0, componentType: 5126, count: 3, type: "VEC3" }, + { bufferView: 1, componentType: 5123, count: 3, type: "SCALAR" }, + ], + bufferViews: [ + { buffer: 0, byteOffset: offsets[0], byteLength: positions.byteLength }, + { buffer: 0, byteOffset: offsets[1], byteLength: indices.byteLength }, + ], + buffers: [{ byteLength: bin.length }], + }; + return packGLB(json, bin); +} + +describe("parseGLTF() — KHR_materials_unlit", () => { + it("flags a material with the extension as unlit", async () => { + const scene = await parseGLTF( + buildMaterialGLB({ extensions: { KHR_materials_unlit: {} } }), + ); + expect(scene.nodes[0].unlit).toBe(true); + }); + + it("a material without the extension is not unlit", async () => { + const scene = await parseGLTF( + buildMaterialGLB({ + pbrMetallicRoughness: { baseColorFactor: [1, 0, 0, 1] }, + }), + ); + expect(scene.nodes[0].unlit).toBe(false); + }); + + it("ADVERSARIAL: a primitive with no material is not unlit", async () => { + const scene = await parseGLTF(buildSceneGLB()); + expect(scene.nodes[0].unlit).toBe(false); + }); +}); From d5049cbcc5878f33336fa36e084b1aeaf764243f Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sat, 20 Jun 2026 11:29:49 +0800 Subject: [PATCH 02/10] feat(gltf): texture magnification filter from the sampler (pixel-art support) New `Mesh` `textureFilter` setting ("nearest" / "linear", WebGL) applied to the resolved texture. The glTF loader reads each material's sampler `magFilter`, so pixel-art-textured 3D models render crisp instead of blurred by the global antiAlias default. Same image-global caveat as `textureRepeat` (#1503). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/melonjs/CHANGELOG.md | 1 + packages/melonjs/src/level/gltf/GLTFModel.js | 2 ++ packages/melonjs/src/level/gltf/GLTFScene.js | 2 ++ packages/melonjs/src/loader/parsers/gltf.js | 34 ++++++++++++++++++++ packages/melonjs/src/renderable/mesh.js | 15 +++++++++ packages/melonjs/tests/gltf.spec.js | 30 +++++++++++++++++ 6 files changed, 84 insertions(+) diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index f242d1dfe..43f397c19 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -11,6 +11,7 @@ - **External glTF resources** — the loader resolves external `.bin` buffers and image `uri`s relative to the asset URL (via `fetchData`, honoring the loader's crossOrigin / nocache settings), so a `.glb`/`.gltf` that references a separate texture file (e.g. Kenney's `Textures/foo.png`) loads as-shipped without repackaging. Self-contained GLBs (embedded buffers + data-URI / bufferView images) are unaffected. - **OBJ/MTL textures auto-load** (#1505) — `preloadMTL` now loads each material's `map_Kd` texture automatically, resolved relative to the `.mtl` file, so an OBJ model's textures "come for free" like a glTF scene's. Preloading the model + material is enough — no separate per-texture preload entry needed (the explicit `texture:` still wins, and the legacy preload-it-yourself flow keeps working). A missing texture is warned and skipped (the mesh falls back to the white pixel) rather than aborting the load. - **`Mesh` `textureRepeat` setting** — texture wrap mode (`"repeat"` / `"repeat-x"` / `"repeat-y"` / `"no-repeat"`) applied to the resolved texture, for geometry whose UVs fall outside `[0, 1]` and rely on the texture tiling. The glTF loader sets it from each material's sampler `wrapS` / `wrapT` (defaulting to REPEAT, the glTF spec default) — without it such assets sampled flat edge texels and looked untextured. Never applied to the shared white-pixel fallback. +- **`Mesh` `textureFilter` setting + glTF sampler filtering** — texture magnification filter (`"nearest"` / `"linear"`) applied to the resolved texture (WebGL). The glTF loader reads it from each material's sampler `magFilter`, so **pixel-art-textured 3D models render crisp** instead of blurred by the global antiAlias default. Omitted → keeps the engine default. - **glTF material color** — `baseColorFactor` is applied as the mesh tint, so a solid-colored *untextured* material renders its color (previously it fell back to white). **Vertex colors** (`COLOR_0`, float or normalized byte/short, VEC3/VEC4) are read into per-vertex colors — untextured vertex-colored meshes (MagicaVoxel exports, vertex-painted models) render correctly. Factor, vertex color, and texture compose (`factor × vertexColor × texel`), and work under lighting. - **3D mesh lighting** — `Light3d`, a manipulable light managed exactly like `Light2d`: it's a world `Renderable`, so `app.world.addChild(new Light3d({ direction, color, intensity }))` adds it and the active stage auto-tracks it (remove it from the world to turn it off — no global, no separate lighting object). Types: `"directional"` (a sun, half-Lambert diffuse) and `"ambient"` (a flat fill). Loading a glTF scene adds its authored `KHR_lights_punctual` directional lights (plus a soft ambient fill) automatically, so meshes are lit by the same sun set up in the authoring tool. Fields are mutable, so a light can be animated at runtime (e.g. a day/night cycle rotating `direction`). Meshes opt in via `mesh.lit` and render through a dedicated `LitMeshBatcher`; standalone unlit meshes keep the lean path and pay nothing for lighting. Directional + ambient this release (point/spot are parsed but not yet shaded). - **glTF `KHR_materials_unlit`** — materials flagged with the extension bake their own lighting and are rendered fullbright (not shaded again), even in a lit scene. A very common stylized workflow (baked lighting in the texture); honoring it avoids double-lighting. Applied per primitive. diff --git a/packages/melonjs/src/level/gltf/GLTFModel.js b/packages/melonjs/src/level/gltf/GLTFModel.js index 97922d3ac..97fa4674e 100644 --- a/packages/melonjs/src/level/gltf/GLTFModel.js +++ b/packages/melonjs/src/level/gltf/GLTFModel.js @@ -138,6 +138,8 @@ export default class GLTFModel extends Container { // honor the glTF sampler wrap (default REPEAT) — many exporters // author UVs outside [0,1] that tile; clamping flattens them textureRepeat: prim.textureRepeat, + // honor the glTF sampler magnification filter (nearest = pixel-art) + textureFilter: prim.textureFilter, // 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 7d838a339..c4b9f1bf1 100644 --- a/packages/melonjs/src/level/gltf/GLTFScene.js +++ b/packages/melonjs/src/level/gltf/GLTFScene.js @@ -144,6 +144,8 @@ export default class GLTFScene { // honor the glTF sampler wrap (default REPEAT) so tiling UVs // (UVs outside [0,1]) sample correctly instead of clamping flat textureRepeat: node.textureRepeat, + // honor the glTF sampler magnification filter (nearest for pixel-art) + textureFilter: node.textureFilter, // 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 1970069c9..08f1b9de9 100644 --- a/packages/melonjs/src/loader/parsers/gltf.js +++ b/packages/melonjs/src/loader/parsers/gltf.js @@ -540,6 +540,36 @@ export async function parseGLTF(arrayBuffer, baseURI, settings) { ); }; + // resolve material index -> texture magnification filter from the glTF + // sampler: `"nearest"` (9728, crisp pixel-art upscaling) or `"linear"` + // (9729, smooth). `undefined` when the sampler doesn't specify one, so the + // engine keeps its global antiAlias default — only an explicit NEAREST/LINEAR + // overrides it. + const NEAREST = 9728; + const LINEAR = 9729; + const materialTextureFilter = (materialIndex) => { + const tex = + materialIndex !== undefined + ? json.materials?.[materialIndex]?.pbrMetallicRoughness + ?.baseColorTexture + : undefined; + if (!tex) { + return undefined; + } + const samplerIndex = json.textures?.[tex.index]?.sampler; + const magFilter = + samplerIndex !== undefined + ? json.samplers?.[samplerIndex]?.magFilter + : undefined; + if (magFilter === NEAREST) { + return "nearest"; + } + if (magFilter === LINEAR) { + return "linear"; + } + return undefined; + }; + // walk the active scene's node graph, accumulating world matrices. // A malformed asset is not allowed to crash the loader: a missing scene // or scene-node list degrades to an empty (but valid) descriptor rather @@ -601,6 +631,10 @@ export async function parseGLTF(arrayBuffer, baseURI, settings) { // materialTextureRepeat; carried so the Mesh samples tiling UVs // correctly instead of clamping to flat edge texels textureRepeat: materialTextureRepeat(prim.material), + // texture magnification filter from the glTF sampler ("nearest" for + // crisp pixel-art, "linear" for smooth) — undefined keeps the engine + // default + textureFilter: materialTextureFilter(prim.material), // baseColorFactor [r,g,b,a] — applied as the mesh tint so a // solid-colored (untextured) material renders its color baseColorFactor: materialBaseColor(prim.material), diff --git a/packages/melonjs/src/renderable/mesh.js b/packages/melonjs/src/renderable/mesh.js index cff15b13a..bf843cdf9 100644 --- a/packages/melonjs/src/renderable/mesh.js +++ b/packages/melonjs/src/renderable/mesh.js @@ -117,6 +117,7 @@ export default class Mesh extends Renderable { * @param {number} [settings.scale] - world-space scale (pixels per source unit) for the Camera3d path; defaults to `width`. Set this when `width`/`height` describe the renderable's world bounds (frustum culling) rather than the geometry scale — see {@link Mesh#meshScale}. * @param {boolean} [settings.rightHanded=false] - treat the source as right-handed (Y-up, e.g. glTF) under the `Camera3d` world path. The default Y-up→Y-down bridge negates Y only (a reflection, which mirrors the scene left/right); `true` negates Y **and** Z (a rotation) so chirality is preserved and the result matches the authoring tool. See {@link Mesh#rightHanded}. * @param {string} [settings.textureRepeat] - texture wrap mode (`"repeat"` / `"repeat-x"` / `"repeat-y"` / `"no-repeat"`) applied to the resolved texture. Use `"repeat"` when the geometry's UVs fall outside the `[0, 1]` range and rely on the texture tiling (e.g. glTF assets, whose default sampler wrap is REPEAT) — otherwise the texture clamps to its edge texels and looks flat. Ignored for the white-pixel fallback. Note: REPEAT on a non-power-of-two texture requires WebGL 2. + * @param {string} [settings.textureFilter] - texture magnification filter (`"nearest"` for crisp pixel-art upscaling, `"linear"` for smooth) applied to the resolved texture. Omit to keep the renderer's global `antiAlias` default. WebGL only (ignored by the Canvas renderer). * @example * // create from OBJ + MTL (texture auto-resolved from material) * let mesh = new me.Mesh(0, 0, { @@ -445,6 +446,20 @@ export default class Mesh extends Renderable { this.texture.repeat = settings.textureRepeat; } + // Optional texture magnification filter (`"nearest"` for crisp pixel-art + // upscaling, `"linear"` for smooth). When omitted the texture keeps the + // renderer's global `antiAlias` default. WebGL only — the Canvas renderer + // ignores it. Same image-global caveat as `textureRepeat` above (#1503). + if ( + hasRealTexture && + typeof settings.textureFilter === "string" && + game.renderer.gl + ) { + const gl = game.renderer.gl; + this.texture.filter = + settings.textureFilter === "nearest" ? gl.NEAREST : gl.LINEAR; + } + /** * Projection matrix applied automatically before the model transform in draw(). * Defaults to a perspective projection (45° FOV, camera at z=-2.5) suitable for diff --git a/packages/melonjs/tests/gltf.spec.js b/packages/melonjs/tests/gltf.spec.js index 826607d2f..d4072a8df 100644 --- a/packages/melonjs/tests/gltf.spec.js +++ b/packages/melonjs/tests/gltf.spec.js @@ -1629,6 +1629,36 @@ describe("parseGLTF() — texture wrap mode", () => { }); }); +describe("parseGLTF() — texture magnification filter", () => { + const NEAREST = 9728; + const LINEAR = 9729; + + it("magFilter NEAREST → 'nearest' (crisp pixel-art)", async () => { + const scene = await parseGLTF(buildWrapGLB({ magFilter: NEAREST })); + expect(scene.nodes[0].textureFilter).toBe("nearest"); + }); + + it("magFilter LINEAR → 'linear'", async () => { + const scene = await parseGLTF(buildWrapGLB({ magFilter: LINEAR })); + expect(scene.nodes[0].textureFilter).toBe("linear"); + }); + + it("no sampler → undefined (keeps the engine's antiAlias default)", async () => { + const scene = await parseGLTF(buildWrapGLB(undefined)); + expect(scene.nodes[0].textureFilter).toBeUndefined(); + }); + + it("ADVERSARIAL: a sampler without magFilter → undefined (no override)", async () => { + const scene = await parseGLTF(buildWrapGLB({ wrapS: 10497 })); + expect(scene.nodes[0].textureFilter).toBeUndefined(); + }); + + it("ADVERSARIAL: an untextured material → undefined", async () => { + const scene = await parseGLTF(buildSceneGLB()); + expect(scene.nodes[0].textureFilter).toBeUndefined(); + }); +}); + // ── material flags: KHR_materials_unlit ────────────────────────────────────── // single textured-less triangle whose material carries the given extensions From dd46473090ee7c2b5d9444677cf6ed90c70535df Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sat, 20 Jun 2026 12:15:29 +0800 Subject: [PATCH 03/10] feat(gltf): alpha cutout (alphaMode MASK) via Mesh.alphaCutoff Hard alpha cutout for the WebGL mesh path: fragments whose final alpha falls below a per-mesh threshold are discarded in the mesh / lit-mesh shaders, for crisp foliage / fences / chain-link / decals with no blending or back-to-front sorting. - mesh.frag / mesh-lit.frag: new uAlphaCutoff uniform + discard guard - MeshBatcher.addMesh: set the uniform (flush-free, per-mesh) when it changes, guarded behind the shader actually declaring it so custom mesh shaders are untouched - Mesh.alphaCutoff setting (default 0 = disabled) - glTF parser materialAlphaCutoff: alphaMode MASK -> alphaCutoff (def 0.5); OPAQUE/BLEND -> 0. Wired through GLTFScene + GLTFModel - tests: parser (6) + Mesh consumer (1) + WebGL end-to-end pixel (3, proving both shaders compile with the uniform and the discard works) Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/melonjs/CHANGELOG.md | 1 + packages/melonjs/src/level/gltf/GLTFModel.js | 2 + packages/melonjs/src/level/gltf/GLTFScene.js | 3 + packages/melonjs/src/loader/parsers/gltf.js | 17 ++++ packages/melonjs/src/renderable/mesh.js | 14 ++++ .../src/video/webgl/batchers/mesh_batcher.js | 19 +++++ .../src/video/webgl/shaders/mesh-lit.frag | 7 ++ .../melonjs/src/video/webgl/shaders/mesh.frag | 9 ++- packages/melonjs/tests/gltf.spec.js | 56 +++++++++++++ .../melonjs/tests/webgl_mesh_depth.spec.js | 81 +++++++++++++++++++ 10 files changed, 208 insertions(+), 1 deletion(-) diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 43f397c19..19d723523 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -15,6 +15,7 @@ - **glTF material color** — `baseColorFactor` is applied as the mesh tint, so a solid-colored *untextured* material renders its color (previously it fell back to white). **Vertex colors** (`COLOR_0`, float or normalized byte/short, VEC3/VEC4) are read into per-vertex colors — untextured vertex-colored meshes (MagicaVoxel exports, vertex-painted models) render correctly. Factor, vertex color, and texture compose (`factor × vertexColor × texel`), and work under lighting. - **3D mesh lighting** — `Light3d`, a manipulable light managed exactly like `Light2d`: it's a world `Renderable`, so `app.world.addChild(new Light3d({ direction, color, intensity }))` adds it and the active stage auto-tracks it (remove it from the world to turn it off — no global, no separate lighting object). Types: `"directional"` (a sun, half-Lambert diffuse) and `"ambient"` (a flat fill). Loading a glTF scene adds its authored `KHR_lights_punctual` directional lights (plus a soft ambient fill) automatically, so meshes are lit by the same sun set up in the authoring tool. Fields are mutable, so a light can be animated at runtime (e.g. a day/night cycle rotating `direction`). Meshes opt in via `mesh.lit` and render through a dedicated `LitMeshBatcher`; standalone unlit meshes keep the lean path and pay nothing for lighting. Directional + ambient this release (point/spot are parsed but not yet shaded). - **glTF `KHR_materials_unlit`** — materials flagged with the extension bake their own lighting and are rendered fullbright (not shaded again), even in a lit scene. A very common stylized workflow (baked lighting in the texture); honoring it avoids double-lighting. Applied per primitive. +- **`Mesh` `alphaCutoff` setting + glTF alpha cutout** — a hard alpha cutout: fragments whose final alpha falls below the threshold are `discard`ed in the mesh shader, for crisp foliage / fences / chain-link / decals with no blending or back-to-front sorting. The glTF loader sets it from a material's `alphaMode: "MASK"` (using `alphaCutoff`, default `0.5`); `OPAQUE` / `BLEND` materials are unaffected. `0` (the default) disables the cutout. WebGL mesh path only. - **`Mesh.getBounds3d()`** — the mesh's world-space `AABB3d` (the 3D analog of `getBounds()`, which only describes a flat 2D box). Powers the debug-plugin's new 3D bounding-box wireframe overlay. - **`Camera3d.worldToScreen(world, out?)`** — project a world point to screen-pixel coordinates (perspective divide included); returns `null` for points behind the camera. Useful for HUD elements pinned to 3D objects, picking, and debug overlays. - **`AABB3d`** now exported, with `AABB3d.fromVertices(src, count, matrix?)` to build a box from a flat vertex buffer (delegates to the new `transformedBounds`). diff --git a/packages/melonjs/src/level/gltf/GLTFModel.js b/packages/melonjs/src/level/gltf/GLTFModel.js index 97fa4674e..5bb97a97c 100644 --- a/packages/melonjs/src/level/gltf/GLTFModel.js +++ b/packages/melonjs/src/level/gltf/GLTFModel.js @@ -140,6 +140,8 @@ export default class GLTFModel extends Container { textureRepeat: prim.textureRepeat, // honor the glTF sampler magnification filter (nearest = pixel-art) textureFilter: prim.textureFilter, + // alpha cutout threshold (glTF alphaMode MASK) + alphaCutoff: prim.alphaCutoff, // 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 c4b9f1bf1..7b506a099 100644 --- a/packages/melonjs/src/level/gltf/GLTFScene.js +++ b/packages/melonjs/src/level/gltf/GLTFScene.js @@ -146,6 +146,9 @@ export default class GLTFScene { textureRepeat: node.textureRepeat, // honor the glTF sampler magnification filter (nearest for pixel-art) textureFilter: node.textureFilter, + // alpha cutout threshold (glTF alphaMode MASK) — discard fully + // transparent texels so cutout props (foliage, fences) read crisp + alphaCutoff: node.alphaCutoff, // 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 08f1b9de9..ce8624245 100644 --- a/packages/melonjs/src/loader/parsers/gltf.js +++ b/packages/melonjs/src/loader/parsers/gltf.js @@ -570,6 +570,21 @@ export async function parseGLTF(arrayBuffer, baseURI, settings) { return undefined; }; + // 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, + // chain-link, decals — crisp edges, no blending or back-to-front sorting). + // The spec default `alphaCutoff` is 0.5. Any other mode (OPAQUE / BLEND) → + // 0, i.e. no discard (the mesh path renders opaque; BLEND is out of scope). + const materialAlphaCutoff = (materialIndex) => { + const mat = + materialIndex !== undefined ? json.materials?.[materialIndex] : undefined; + if (mat?.alphaMode === "MASK") { + return mat.alphaCutoff ?? 0.5; + } + return 0; + }; + // walk the active scene's node graph, accumulating world matrices. // A malformed asset is not allowed to crash the loader: a missing scene // or scene-node list degrades to an empty (but valid) descriptor rather @@ -649,6 +664,8 @@ export async function parseGLTF(arrayBuffer, baseURI, settings) { // KHR_materials_unlit — the material bakes its own lighting and must // not be shaded again (skips the lit path even in a lit scene) unlit: materialUnlit(prim.material), + // alpha cutout threshold (glTF alphaMode MASK); 0 = no discard + alphaCutoff: materialAlphaCutoff(prim.material), }; }; diff --git a/packages/melonjs/src/renderable/mesh.js b/packages/melonjs/src/renderable/mesh.js index bf843cdf9..123e828ab 100644 --- a/packages/melonjs/src/renderable/mesh.js +++ b/packages/melonjs/src/renderable/mesh.js @@ -118,6 +118,7 @@ export default class Mesh extends Renderable { * @param {boolean} [settings.rightHanded=false] - treat the source as right-handed (Y-up, e.g. glTF) under the `Camera3d` world path. The default Y-up→Y-down bridge negates Y only (a reflection, which mirrors the scene left/right); `true` negates Y **and** Z (a rotation) so chirality is preserved and the result matches the authoring tool. See {@link Mesh#rightHanded}. * @param {string} [settings.textureRepeat] - texture wrap mode (`"repeat"` / `"repeat-x"` / `"repeat-y"` / `"no-repeat"`) applied to the resolved texture. Use `"repeat"` when the geometry's UVs fall outside the `[0, 1]` range and rely on the texture tiling (e.g. glTF assets, whose default sampler wrap is REPEAT) — otherwise the texture clamps to its edge texels and looks flat. Ignored for the white-pixel fallback. Note: REPEAT on a non-power-of-two texture requires WebGL 2. * @param {string} [settings.textureFilter] - texture magnification filter (`"nearest"` for crisp pixel-art upscaling, `"linear"` for smooth) applied to the resolved texture. Omit to keep the renderer's global `antiAlias` default. WebGL only (ignored by the Canvas renderer). + * @param {number} [settings.alphaCutoff=0] - alpha cutout threshold. Fragments whose final alpha is below this value are discarded (hard-edged cutout — foliage, fences, decals — with no blending or sorting). `0` disables the cutout. Set automatically by the glTF loader from a material's `alphaMode: "MASK"`. WebGL mesh path only. * @example * // create from OBJ + MTL (texture auto-resolved from material) * let mesh = new me.Mesh(0, 0, { @@ -262,6 +263,19 @@ export default class Mesh extends Renderable { */ this.lit = settings.lit === true; + /** + * Alpha cutout threshold. A fragment whose final alpha is below this + * value is discarded — a hard-edged cutout (foliage, fences, chain-link, + * decals) that needs no blending or back-to-front sorting. `0` (the + * default) disables the cutout and the mesh renders fully opaque. Set by + * the glTF loader from a material's `alphaMode: "MASK"` / `alphaCutoff`. + * WebGL mesh path only (the Canvas renderer ignores it). + * @type {number} + * @default 0 + */ + this.alphaCutoff = + typeof settings.alphaCutoff === "number" ? settings.alphaCutoff : 0; + /** * whether to cull back-facing triangles * @type {boolean} diff --git a/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js b/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js index e5cb404e3..17597af85 100644 --- a/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js @@ -90,6 +90,11 @@ export default class MeshBatcher extends MaterialBatcher { indexed: true, }); + // last `uAlphaCutoff` value pushed to the current shader, so consecutive + // meshes sharing a cutoff don't re-issue the uniform. -1 is an impossible + // cutoff (valid range 0..1), forcing the first mesh of a pass to set it. + this.currentAlphaCutoff = -1; + // Subscribe to the renderer's target-changed broadcast so we re-arm the // shared lazy depth clear (`_meshDepthDirty`) whenever the active // framebuffer's attachments change identity (FBO bind/unbind for @@ -256,6 +261,20 @@ export default class MeshBatcher extends MaterialBatcher { this.currentSamplerUnit = unit; } + // 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. + // Each mesh is flushed on its own (see WebGLRenderer.drawMesh), so setting + // the uniform before the vertices are pushed is enough — no extra flush. + const cutoff = mesh.alphaCutoff || 0; + if ( + cutoff !== this.currentAlphaCutoff && + this.currentShader.uniforms.uAlphaCutoff !== undefined + ) { + this.currentShader.setUniform("uAlphaCutoff", cutoff); + this.currentAlphaCutoff = cutoff; + } + const m = this.viewMatrix; const isIdentity = m.isIdentity(); const maxVerts = this.vertexData.maxVertex; diff --git a/packages/melonjs/src/video/webgl/shaders/mesh-lit.frag b/packages/melonjs/src/video/webgl/shaders/mesh-lit.frag index 543629d36..fd65f89aa 100644 --- a/packages/melonjs/src/video/webgl/shaders/mesh-lit.frag +++ b/packages/melonjs/src/video/webgl/shaders/mesh-lit.frag @@ -8,6 +8,7 @@ // preprocessor regardless of how it handles macros.) uniform sampler2D uSampler; +uniform float uAlphaCutoff; // alpha cutout threshold (0 = disabled) uniform int uLightCount; uniform vec3 uLightDir[__MAX_LIGHTS__]; // surface→light, normalized (world space) @@ -21,6 +22,12 @@ varying vec3 vNormal; void main(void) { vec4 base = texture2D(uSampler, vRegion) * vColor; + // hard alpha cutout (glTF alphaMode MASK) — discard before any shading + // so cut-away texels cost nothing and never write depth. + if (base.a < uAlphaCutoff) { + discard; + } + vec3 N = normalize(vNormal); vec3 lit = uAmbient; for (int i = 0; i < __MAX_LIGHTS__; i++) { diff --git a/packages/melonjs/src/video/webgl/shaders/mesh.frag b/packages/melonjs/src/video/webgl/shaders/mesh.frag index 767b89a70..9253e461a 100644 --- a/packages/melonjs/src/video/webgl/shaders/mesh.frag +++ b/packages/melonjs/src/video/webgl/shaders/mesh.frag @@ -1,7 +1,14 @@ uniform sampler2D uSampler; +uniform float uAlphaCutoff; // alpha cutout threshold (0 = disabled) varying vec4 vColor; varying vec2 vRegion; void main(void) { - gl_FragColor = texture2D(uSampler, vRegion) * vColor; + vec4 color = texture2D(uSampler, vRegion) * vColor; + // hard alpha cutout (glTF alphaMode MASK): drop fully-transparent texels + // so foliage / fences / decals read crisp without blending or sorting. + if (color.a < uAlphaCutoff) { + discard; + } + gl_FragColor = color; } diff --git a/packages/melonjs/tests/gltf.spec.js b/packages/melonjs/tests/gltf.spec.js index d4072a8df..e725ce163 100644 --- a/packages/melonjs/tests/gltf.spec.js +++ b/packages/melonjs/tests/gltf.spec.js @@ -899,6 +899,25 @@ describe("GLTFScene → Mesh instantiation", () => { expect(a.getBounds().width).toBeGreaterThan(0); }); + it("propagates a MASK material's alpha cutout to the instantiated Mesh", async () => { + const CUTOUT = "__gltf_cutout_scene"; + gltfList[CUTOUT] = await parseGLTF( + buildMaterialGLB({ alphaMode: "MASK", alphaCutoff: 0.3 }), + ); + const scene = new GLTFScene(CUTOUT); + const container = { + autoDepth: true, + kids: [], + addChild(c) { + this.kids.push(c); + }, + }; + scene.addTo(container); + // the cutout threshold rides from material → parser → Mesh + expect(container.kids[0].alphaCutoff).toBe(0.3); + delete gltfList[CUTOUT]; + }); + it("keeps raw geometry untouched when normalize is disabled", () => { // glTF nodes share one coordinate space, so addTo passes // normalize:false — the raw vertices must survive verbatim @@ -1712,3 +1731,40 @@ describe("parseGLTF() — KHR_materials_unlit", () => { expect(scene.nodes[0].unlit).toBe(false); }); }); + +// ── material flags: alpha cutout (alphaMode MASK) ──────────────────────────── + +describe("parseGLTF() — alpha cutout (alphaMode MASK)", () => { + it("MASK with an explicit alphaCutoff uses that threshold", async () => { + const scene = await parseGLTF( + buildMaterialGLB({ alphaMode: "MASK", alphaCutoff: 0.25 }), + ); + expect(scene.nodes[0].alphaCutoff).toBe(0.25); + }); + + it("MASK without an alphaCutoff defaults to the spec 0.5", async () => { + const scene = await parseGLTF(buildMaterialGLB({ alphaMode: "MASK" })); + expect(scene.nodes[0].alphaCutoff).toBe(0.5); + }); + + it("OPAQUE (default) yields no cutout (0)", async () => { + const scene = await parseGLTF( + buildMaterialGLB({ + pbrMetallicRoughness: { baseColorFactor: [1, 1, 1, 1] }, + }), + ); + expect(scene.nodes[0].alphaCutoff).toBe(0); + }); + + it("ADVERSARIAL: BLEND mode is not a cutout (0 — alphaCutoff ignored)", async () => { + const scene = await parseGLTF( + buildMaterialGLB({ alphaMode: "BLEND", alphaCutoff: 0.9 }), + ); + expect(scene.nodes[0].alphaCutoff).toBe(0); + }); + + it("ADVERSARIAL: a primitive with no material has no cutout (0)", async () => { + const scene = await parseGLTF(buildSceneGLB()); + expect(scene.nodes[0].alphaCutoff).toBe(0); + }); +}); diff --git a/packages/melonjs/tests/webgl_mesh_depth.spec.js b/packages/melonjs/tests/webgl_mesh_depth.spec.js index 6deb5b0b5..c49667f90 100644 --- a/packages/melonjs/tests/webgl_mesh_depth.spec.js +++ b/packages/melonjs/tests/webgl_mesh_depth.spec.js @@ -144,6 +144,8 @@ describe("Mesh depth handling (issue #1468)", () => { // correctness here, only depth resolution. cullBackFaces: false, tintRGBA, + // 0 = no cutout (default). The alpha-cutout tests below set this. + alphaCutoff: 0, }; }; @@ -431,4 +433,83 @@ describe("Mesh depth handling (issue #1468)", () => { expect(px[1]).toBeLessThan(80); }); }); + + // ────────────────────────────────────────────────────────────────────── + // Layer 2 — alpha cutout (glTF alphaMode MASK) + // ────────────────────────────────────────────────────────────────────── + // + // The mesh shaders `discard` a fragment whose final alpha is below + // `uAlphaCutoff`. With no blending (mesh mode disables BLEND), a discarded + // fragment leaves the background untouched. These drive the fragment alpha + // via the global alpha (which becomes `vColor.a` through the batcher) and + // read back the centre pixel: below the cutoff → background survives; at / + // above → the mesh paints. Doubles as a smoke test that both shaders still + // COMPILE with the new uniform and the batcher's `setUniform` path runs. + + describe("alpha cutout (Layer 2)", () => { + const readCenter = () => { + const gl = renderer.gl; + const px = new Uint8Array(4); + gl.finish(); + gl.readPixels(64, 64, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, px); + return px; + }; + + const setupOrtho = () => { + const proj = new Matrix3d(); + proj.ortho(0, 128, 128, 0, -1000, 1000); + renderer.setProjection(proj); + }; + + const freshFrame = () => { + renderer.backgroundColor.setColor(0, 0, 0, 255); + renderer.clear(); + renderer.setColor("#000000"); + renderer.fillRect(0, 0, 1, 1); // force a non-mesh batcher state + }; + + const drawCutoutMesh = (alpha) => { + const mesh = makeQuadMesh(64, 64, 0, [220, 20, 20, 255]); + mesh.alphaCutoff = 0.5; + renderer.currentTint.setColor(...mesh.tintRGBA); + renderer.setGlobalAlpha(alpha); // becomes vColor.a in the shader + renderer.drawMesh(mesh); + renderer.setGlobalAlpha(1); // restore for sibling tests + }; + + it("discards fragments whose alpha is below the cutoff (background survives)", (ctx) => { + requireWebGL2(ctx); + setupOrtho(); + freshFrame(); + drawCutoutMesh(0.3); // 0.3 < 0.5 → discard every fragment + const px = readCenter(); + expect(px[0]).toBeLessThan(60); // red dropped → black background + }); + + it("keeps fragments at or above the cutoff", (ctx) => { + requireWebGL2(ctx); + setupOrtho(); + freshFrame(); + drawCutoutMesh(0.9); // 0.9 >= 0.5 → fragment kept + const px = readCenter(); + expect(px[0]).toBeGreaterThan(150); // red paints through + }); + + it("a zero cutoff (default) keeps a fragment a non-zero cutoff would drop", (ctx) => { + requireWebGL2(ctx); + setupOrtho(); + freshFrame(); + // At alpha 0.45 the cutout=0.5 case (test above) discards to black. + // With alphaCutoff at its 0 default, `a < 0` is never true → the same + // fragment survives. Output RGB is premultiplied (≈ 220·0.45 ≈ 99), + // so a kept fragment reads clearly above the discarded-to-black floor. + const mesh = makeQuadMesh(64, 64, 0, [220, 20, 20, 255]); + renderer.currentTint.setColor(...mesh.tintRGBA); + renderer.setGlobalAlpha(0.45); + renderer.drawMesh(mesh); + renderer.setGlobalAlpha(1); + const px = readCenter(); + expect(px[0]).toBeGreaterThan(50); // kept (≈99), not discarded (≈0) + }); + }); }); From 211ab7a024fba0b5ef7f70d05409c123a28c81c2 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sat, 20 Jun 2026 12:21:08 +0800 Subject: [PATCH 04/10] feat(gltf): emissive (emissiveFactor / MTL Ke) via Mesh.emissive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-illumination for the WebGL mesh path: a color added on top of the lit/unlit result so a surface glows regardless of scene lights (neon, lava, screens, glowing eyes). - mesh.frag / mesh-lit.frag: new uEmissive vec3 uniform, added to the final color (after lighting in the lit path so it glows full strength) - MeshBatcher.addMesh: set uEmissive flush-free per mesh with a per- channel redundant-set guard, behind the shader declaring the uniform; shared zero vector when a mesh has no emission - Mesh.emissive (Float32Array(3) or undefined) + toEmissive() helper (all-zero collapses to undefined → lean path) - glTF parser materialEmissive: emissiveFactor x KHR_materials_emissive_strength; wired through GLTFScene + GLTFModel - MTL parser: Ke (Blender emission export); wired in Mesh single- material path - tests: glTF parser (5) + MTL Ke (1) + WebGL end-to-end pixel (2, incl. uniform-reset-doesn't-leak) Emissive textures (emissiveTexture / map_Ke) remain out of scope. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/melonjs/CHANGELOG.md | 1 + packages/melonjs/src/level/gltf/GLTFModel.js | 2 + packages/melonjs/src/level/gltf/GLTFScene.js | 3 + packages/melonjs/src/loader/parsers/gltf.js | 27 ++++++++ packages/melonjs/src/loader/parsers/mtl.js | 19 +++++- packages/melonjs/src/renderable/mesh.js | 40 ++++++++++++ .../src/video/webgl/batchers/mesh_batcher.js | 30 +++++++++ .../src/video/webgl/shaders/mesh-lit.frag | 5 +- .../melonjs/src/video/webgl/shaders/mesh.frag | 5 +- packages/melonjs/tests/gltf.spec.js | 44 +++++++++++++ packages/melonjs/tests/loader.spec.js | 17 ++++++ .../melonjs/tests/public/data/models/cube.mtl | 1 + .../melonjs/tests/webgl_mesh_depth.spec.js | 61 +++++++++++++++++++ 13 files changed, 251 insertions(+), 4 deletions(-) diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 19d723523..88056d2bc 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -16,6 +16,7 @@ - **3D mesh lighting** — `Light3d`, a manipulable light managed exactly like `Light2d`: it's a world `Renderable`, so `app.world.addChild(new Light3d({ direction, color, intensity }))` adds it and the active stage auto-tracks it (remove it from the world to turn it off — no global, no separate lighting object). Types: `"directional"` (a sun, half-Lambert diffuse) and `"ambient"` (a flat fill). Loading a glTF scene adds its authored `KHR_lights_punctual` directional lights (plus a soft ambient fill) automatically, so meshes are lit by the same sun set up in the authoring tool. Fields are mutable, so a light can be animated at runtime (e.g. a day/night cycle rotating `direction`). Meshes opt in via `mesh.lit` and render through a dedicated `LitMeshBatcher`; standalone unlit meshes keep the lean path and pay nothing for lighting. Directional + ambient this release (point/spot are parsed but not yet shaded). - **glTF `KHR_materials_unlit`** — materials flagged with the extension bake their own lighting and are rendered fullbright (not shaded again), even in a lit scene. A very common stylized workflow (baked lighting in the texture); honoring it avoids double-lighting. Applied per primitive. - **`Mesh` `alphaCutoff` setting + glTF alpha cutout** — a hard alpha cutout: fragments whose final alpha falls below the threshold are `discard`ed in the mesh shader, for crisp foliage / fences / chain-link / decals with no blending or back-to-front sorting. The glTF loader sets it from a material's `alphaMode: "MASK"` (using `alphaCutoff`, default `0.5`); `OPAQUE` / `BLEND` materials are unaffected. `0` (the default) disables the cutout. WebGL mesh path only. +- **`Mesh` `emissive` setting + glTF/OBJ emissive** — a self-illumination color added on top of the lit/unlit result so a surface glows regardless of scene lights (neon, lava, screens, glowing eyes). The glTF loader sets it from a material's `emissiveFactor` (scaled by `KHR_materials_emissive_strength` for HDR glow); the OBJ loader from an MTL's `Ke`. Composes with diffuse, so a black-diffuse + emissive material glows on a dark mesh. Omitted / all-zero → no emission (the lean path). WebGL mesh path only; emissive *textures* (`emissiveTexture` / `map_Ke`) are not yet supported. - **`Mesh.getBounds3d()`** — the mesh's world-space `AABB3d` (the 3D analog of `getBounds()`, which only describes a flat 2D box). Powers the debug-plugin's new 3D bounding-box wireframe overlay. - **`Camera3d.worldToScreen(world, out?)`** — project a world point to screen-pixel coordinates (perspective divide included); returns `null` for points behind the camera. Useful for HUD elements pinned to 3D objects, picking, and debug overlays. - **`AABB3d`** now exported, with `AABB3d.fromVertices(src, count, matrix?)` to build a box from a flat vertex buffer (delegates to the new `transformedBounds`). diff --git a/packages/melonjs/src/level/gltf/GLTFModel.js b/packages/melonjs/src/level/gltf/GLTFModel.js index 5bb97a97c..c27da66b9 100644 --- a/packages/melonjs/src/level/gltf/GLTFModel.js +++ b/packages/melonjs/src/level/gltf/GLTFModel.js @@ -142,6 +142,8 @@ export default class GLTFModel extends Container { textureFilter: prim.textureFilter, // alpha cutout threshold (glTF alphaMode MASK) alphaCutoff: prim.alphaCutoff, + // emissive color (glTF emissiveFactor) — self-illumination + emissive: prim.emissive, // 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 7b506a099..ecfaad8b9 100644 --- a/packages/melonjs/src/level/gltf/GLTFScene.js +++ b/packages/melonjs/src/level/gltf/GLTFScene.js @@ -149,6 +149,9 @@ export default class GLTFScene { // alpha cutout threshold (glTF alphaMode MASK) — discard fully // transparent texels so cutout props (foliage, fences) read crisp alphaCutoff: node.alphaCutoff, + // emissive color (glTF emissiveFactor) — self-illumination so neon / + // lava / screens glow regardless of scene lighting + emissive: node.emissive, // 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 ce8624245..0b6483279 100644 --- a/packages/melonjs/src/loader/parsers/gltf.js +++ b/packages/melonjs/src/loader/parsers/gltf.js @@ -585,6 +585,30 @@ export async function parseGLTF(arrayBuffer, baseURI, settings) { return 0; }; + // resolve material index -> emissive color [r,g,b] (0..1, possibly HDR), or + // undefined when the material has no (non-zero) emission. glTF `emissiveFactor` + // self-illuminates a surface (neon, lava, screens, glowing eyes) independently + // of scene lights; `KHR_materials_emissive_strength` scales it past 1 for a + // brighter glow (default strength 1). A zero factor → undefined so the Mesh + // stays on the lean no-emissive path. + const materialEmissive = (materialIndex) => { + const mat = + materialIndex !== undefined ? json.materials?.[materialIndex] : undefined; + const f = mat?.emissiveFactor; + if (!f) { + return undefined; + } + const strength = + mat.extensions?.KHR_materials_emissive_strength?.emissiveStrength ?? 1; + const r = f[0] * strength; + const g = f[1] * strength; + const b = f[2] * strength; + if (r === 0 && g === 0 && b === 0) { + return undefined; + } + return [r, g, b]; + }; + // walk the active scene's node graph, accumulating world matrices. // A malformed asset is not allowed to crash the loader: a missing scene // or scene-node list degrades to an empty (but valid) descriptor rather @@ -666,6 +690,9 @@ export async function parseGLTF(arrayBuffer, baseURI, settings) { unlit: materialUnlit(prim.material), // alpha cutout threshold (glTF alphaMode MASK); 0 = no discard alphaCutoff: materialAlphaCutoff(prim.material), + // emissive color [r,g,b] (glTF emissiveFactor × emissive_strength), or + // undefined when the material doesn't self-illuminate + emissive: materialEmissive(prim.material), }; }; diff --git a/packages/melonjs/src/loader/parsers/mtl.js b/packages/melonjs/src/loader/parsers/mtl.js index 47e6b5e65..966d473eb 100644 --- a/packages/melonjs/src/loader/parsers/mtl.js +++ b/packages/melonjs/src/loader/parsers/mtl.js @@ -7,6 +7,7 @@ import { preloadImage } from "./image.js"; const SUPPORTED_PROPS = new Set([ "newmtl", "Kd", + "Ke", "d", "Tr", "map_Kd", @@ -20,6 +21,7 @@ const SUPPORTED_PROPS = new Set([ // unsupported texture maps (would need multi-texture or shader changes) const UNSUPPORTED_MAPS = new Set([ "map_Ka", + "map_Ke", "map_Ks", "map_Ns", "map_d", @@ -32,8 +34,8 @@ const UNSUPPORTED_MAPS = new Set([ /** * Parse a Wavefront MTL file into material data. - * Supports: `newmtl`, `Kd` (diffuse color), `map_Kd` (diffuse texture), - * `d`/`Tr` (opacity/transparency). + * Supports: `newmtl`, `Kd` (diffuse color), `Ke` (emissive color), `map_Kd` + * (diffuse texture), `d`/`Tr` (opacity/transparency). * * Limitations: * - Only one `map_Kd` texture per material is supported @@ -86,6 +88,7 @@ function parseMTL(text, basePath) { current = { name: parts[1], Kd: [1, 1, 1], + Ke: [0, 0, 0], d: 1.0, map_Kd: null, }; @@ -102,6 +105,18 @@ function parseMTL(text, basePath) { } break; + case "Ke": + // emissive color (self-illumination — Blender's OBJ exporter + // writes it for materials with an emission shader) + if (current) { + current.Ke = [ + parseFloat(parts[1]), + parseFloat(parts[2]), + parseFloat(parts[3]), + ]; + } + break; + case "d": if (current) { current.d = parseFloat(parts[1]); diff --git a/packages/melonjs/src/renderable/mesh.js b/packages/melonjs/src/renderable/mesh.js index 123e828ab..d8e9cb894 100644 --- a/packages/melonjs/src/renderable/mesh.js +++ b/packages/melonjs/src/renderable/mesh.js @@ -41,6 +41,27 @@ function resolveTextureAtlas(src) { }); } +/** + * Normalize an emissive color input (`[r, g, b]` array / Float32Array, or + * nullish) into a `Float32Array(3)`, or `undefined` when there's no emission + * (nullish or all-zero) so the Mesh stays on the lean no-emissive path. + * @param {number[]|Float32Array|undefined|null} src + * @returns {Float32Array|undefined} + * @ignore + */ +function toEmissive(src) { + if (src === undefined || src === null) { + return undefined; + } + const r = src[0] || 0; + const g = src[1] || 0; + const b = src[2] || 0; + if (r === 0 && g === 0 && b === 0) { + return undefined; + } + return new Float32Array([r, g, b]); +} + /** * Resolve an OBJ material group into a draw descriptor. Builds the * group's tint from the MTL's `Kd` (defaults to white if missing) and @@ -119,6 +140,7 @@ export default class Mesh extends Renderable { * @param {string} [settings.textureRepeat] - texture wrap mode (`"repeat"` / `"repeat-x"` / `"repeat-y"` / `"no-repeat"`) applied to the resolved texture. Use `"repeat"` when the geometry's UVs fall outside the `[0, 1]` range and rely on the texture tiling (e.g. glTF assets, whose default sampler wrap is REPEAT) — otherwise the texture clamps to its edge texels and looks flat. Ignored for the white-pixel fallback. Note: REPEAT on a non-power-of-two texture requires WebGL 2. * @param {string} [settings.textureFilter] - texture magnification filter (`"nearest"` for crisp pixel-art upscaling, `"linear"` for smooth) applied to the resolved texture. Omit to keep the renderer's global `antiAlias` default. WebGL only (ignored by the Canvas renderer). * @param {number} [settings.alphaCutoff=0] - alpha cutout threshold. Fragments whose final alpha is below this value are discarded (hard-edged cutout — foliage, fences, decals — with no blending or sorting). `0` disables the cutout. Set automatically by the glTF loader from a material's `alphaMode: "MASK"`. WebGL mesh path only. + * @param {number[]|Float32Array} [settings.emissive] - emissive (self-illumination) color `[r, g, b]` (0..1, may exceed 1 for HDR glow) added on top of the lit/unlit color so the surface glows regardless of scene lights (neon, lava, screens). Omit / all-zero for no emission. Set automatically by the glTF loader (`emissiveFactor`) and OBJ loader (MTL `Ke`). WebGL mesh path only. * @example * // create from OBJ + MTL (texture auto-resolved from material) * let mesh = new me.Mesh(0, 0, { @@ -276,6 +298,18 @@ export default class Mesh extends Renderable { this.alphaCutoff = typeof settings.alphaCutoff === "number" ? settings.alphaCutoff : 0; + /** + * Emissive (self-illumination) color as an `[r, g, b]` `Float32Array` + * (0..1, may exceed 1 for HDR glow), added on top of the lit/unlit color + * so the surface glows independently of the scene lights (neon, lava, + * screens, glowing eyes). `undefined` (the default) means no emission and + * keeps the mesh on the lean path. Set by the glTF loader from a material's + * `emissiveFactor` (× `KHR_materials_emissive_strength`) and by the OBJ + * loader from an MTL's `Ke`. WebGL mesh path only. + * @type {Float32Array|undefined} + */ + this.emissive = toEmissive(settings.emissive); + /** * whether to cull back-facing triangles * @type {boolean} @@ -427,6 +461,12 @@ export default class Mesh extends Renderable { if (mat.d < 1.0) { this.setOpacity(mat.d); } + // MTL emissive (Ke) — self-illumination, kept separate from the + // diffuse tint so it glows regardless of scene lighting + const ke = toEmissive(mat.Ke); + if (ke !== undefined) { + this.emissive = ke; + } } } diff --git a/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js b/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js index 17597af85..36e79b83f 100644 --- a/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js @@ -26,6 +26,10 @@ const _chunkIndices = []; // `bind()` of either clears + marks clean; `RENDER_TARGET_CHANGED` re-arms it. let _meshDepthDirty = true; +// shared zero emissive, passed to the shader when a mesh has no emission so the +// `uEmissive` add is a no-op. Never mutated. +const _ZERO_EMISSIVE = new Float32Array(3); + /** * Per-channel multiply two ARGB-packed Uint32 colors. Used by the * multi-material mesh path to combine a vertex's baked material color @@ -95,6 +99,12 @@ export default class MeshBatcher extends MaterialBatcher { // cutoff (valid range 0..1), forcing the first mesh of a pass to set it. this.currentAlphaCutoff = -1; + // last `uEmissive` value pushed (per channel), same redundant-set guard. + // -1 is an impossible emissive (valid range 0..∞), forcing the first set. + this.currentEmissiveR = -1; + this.currentEmissiveG = -1; + this.currentEmissiveB = -1; + // Subscribe to the renderer's target-changed broadcast so we re-arm the // shared lazy depth clear (`_meshDepthDirty`) whenever the active // framebuffer's attachments change identity (FBO bind/unbind for @@ -275,6 +285,26 @@ export default class MeshBatcher extends MaterialBatcher { this.currentAlphaCutoff = cutoff; } + // emissive (glTF emissiveFactor / MTL Ke): a self-illumination color + // added to the final fragment, unaffected by lighting. Same per-mesh, + // flush-free, guarded-by-uniform-presence pattern as the cutoff above. + // `undefined` (no emission) → the shared zero vector, a no-op add. + const em = mesh.emissive; + const er = em ? em[0] : 0; + const eg = em ? em[1] : 0; + const eb = em ? em[2] : 0; + if ( + (er !== this.currentEmissiveR || + eg !== this.currentEmissiveG || + eb !== this.currentEmissiveB) && + this.currentShader.uniforms.uEmissive !== undefined + ) { + this.currentShader.setUniform("uEmissive", em ?? _ZERO_EMISSIVE); + this.currentEmissiveR = er; + this.currentEmissiveG = eg; + this.currentEmissiveB = eb; + } + const m = this.viewMatrix; const isIdentity = m.isIdentity(); const maxVerts = this.vertexData.maxVertex; diff --git a/packages/melonjs/src/video/webgl/shaders/mesh-lit.frag b/packages/melonjs/src/video/webgl/shaders/mesh-lit.frag index fd65f89aa..5bbe19810 100644 --- a/packages/melonjs/src/video/webgl/shaders/mesh-lit.frag +++ b/packages/melonjs/src/video/webgl/shaders/mesh-lit.frag @@ -9,6 +9,7 @@ uniform sampler2D uSampler; uniform float uAlphaCutoff; // alpha cutout threshold (0 = disabled) +uniform vec3 uEmissive; // self-illumination color (0 = none) uniform int uLightCount; uniform vec3 uLightDir[__MAX_LIGHTS__]; // surface→light, normalized (world space) @@ -39,5 +40,7 @@ void main(void) { lit += uLightColor[i] * (ndl * ndl); } - gl_FragColor = vec4(base.rgb * lit, base.a); + // emissive self-illuminates: added AFTER lighting so it glows at full + // strength regardless of the scene lights (neon, lava, glowing eyes). + gl_FragColor = vec4(base.rgb * lit + uEmissive, base.a); } diff --git a/packages/melonjs/src/video/webgl/shaders/mesh.frag b/packages/melonjs/src/video/webgl/shaders/mesh.frag index 9253e461a..47902557f 100644 --- a/packages/melonjs/src/video/webgl/shaders/mesh.frag +++ b/packages/melonjs/src/video/webgl/shaders/mesh.frag @@ -1,5 +1,6 @@ uniform sampler2D uSampler; uniform float uAlphaCutoff; // alpha cutout threshold (0 = disabled) +uniform vec3 uEmissive; // self-illumination color added on top (0 = none) varying vec4 vColor; varying vec2 vRegion; @@ -10,5 +11,7 @@ void main(void) { if (color.a < uAlphaCutoff) { discard; } - gl_FragColor = color; + // emissive adds a self-lit color on top (neon, lava, screens); the unlit + // path has no lighting, so it's simply added to the base color. + gl_FragColor = vec4(color.rgb + uEmissive, color.a); } diff --git a/packages/melonjs/tests/gltf.spec.js b/packages/melonjs/tests/gltf.spec.js index e725ce163..a1e9fb2aa 100644 --- a/packages/melonjs/tests/gltf.spec.js +++ b/packages/melonjs/tests/gltf.spec.js @@ -1768,3 +1768,47 @@ describe("parseGLTF() — alpha cutout (alphaMode MASK)", () => { expect(scene.nodes[0].alphaCutoff).toBe(0); }); }); + +// ── material flags: emissive (emissiveFactor) ──────────────────────────────── + +describe("parseGLTF() — emissive", () => { + it("reads emissiveFactor into the emissive color", async () => { + const scene = await parseGLTF( + buildMaterialGLB({ emissiveFactor: [1, 0.5, 0] }), + ); + expect(Array.from(scene.nodes[0].emissive)).toEqual([1, 0.5, 0]); + }); + + it("KHR_materials_emissive_strength scales the factor (HDR glow)", async () => { + const scene = await parseGLTF( + buildMaterialGLB({ + emissiveFactor: [1, 0.5, 0], + extensions: { + KHR_materials_emissive_strength: { emissiveStrength: 3 }, + }, + }), + ); + expect(Array.from(scene.nodes[0].emissive)).toEqual([3, 1.5, 0]); + }); + + it("a material with no emissiveFactor has no emissive (undefined)", async () => { + const scene = await parseGLTF( + buildMaterialGLB({ + pbrMetallicRoughness: { baseColorFactor: [1, 1, 1, 1] }, + }), + ); + expect(scene.nodes[0].emissive).toBeUndefined(); + }); + + it("ADVERSARIAL: an all-zero emissiveFactor collapses to no emissive (undefined)", async () => { + const scene = await parseGLTF( + buildMaterialGLB({ emissiveFactor: [0, 0, 0] }), + ); + expect(scene.nodes[0].emissive).toBeUndefined(); + }); + + it("ADVERSARIAL: a primitive with no material has no emissive (undefined)", async () => { + const scene = await parseGLTF(buildSceneGLB()); + expect(scene.nodes[0].emissive).toBeUndefined(); + }); +}); diff --git a/packages/melonjs/tests/loader.spec.js b/packages/melonjs/tests/loader.spec.js index 68151bcd2..2894f13c1 100644 --- a/packages/melonjs/tests/loader.spec.js +++ b/packages/melonjs/tests/loader.spec.js @@ -501,6 +501,23 @@ describe("loader", () => { ).resolves.toBe(true); }); + it("parses a material's Ke (emissive) color", async () => { + await expect( + new Promise((resolve, reject) => { + loader.load( + { name: "cubemtl_ke", type: "mtl", src: "/data/models/cube.mtl" }, + () => { + const mats = loader.getMTL("cubemtl_ke"); + resolve(JSON.stringify(mats?.cube?.Ke)); + }, + () => { + reject(new Error("failed to load cube.mtl")); + }, + ); + }), + ).resolves.toBe(JSON.stringify([0, 1, 0.5])); + }); + it("does not abort the load when a map_Kd texture is missing (mesh falls back to white)", async () => { // the .mtl parses fine; only the (absent) texture fails → onload still fires await expect( diff --git a/packages/melonjs/tests/public/data/models/cube.mtl b/packages/melonjs/tests/public/data/models/cube.mtl index f25912fc3..4da3b5987 100644 --- a/packages/melonjs/tests/public/data/models/cube.mtl +++ b/packages/melonjs/tests/public/data/models/cube.mtl @@ -1,3 +1,4 @@ newmtl cube Kd 1.0 1.0 1.0 +Ke 0.0 1.0 0.5 map_Kd cube.png diff --git a/packages/melonjs/tests/webgl_mesh_depth.spec.js b/packages/melonjs/tests/webgl_mesh_depth.spec.js index c49667f90..5fa691790 100644 --- a/packages/melonjs/tests/webgl_mesh_depth.spec.js +++ b/packages/melonjs/tests/webgl_mesh_depth.spec.js @@ -512,4 +512,65 @@ describe("Mesh depth handling (issue #1468)", () => { expect(px[0]).toBeGreaterThan(50); // kept (≈99), not discarded (≈0) }); }); + + // ────────────────────────────────────────────────────────────────────── + // Layer 2 — emissive (glTF emissiveFactor / MTL Ke) + // ────────────────────────────────────────────────────────────────────── + // + // The mesh shaders ADD `uEmissive` to the final color, so a surface glows + // regardless of lighting. These draw a BLACK-tinted mesh (no diffuse + // contribution) so any color in the readback comes purely from the emissive + // add — proving the uniform path runs and both shaders still compile. + + describe("emissive (Layer 2)", () => { + const readCenter = () => { + const gl = renderer.gl; + const px = new Uint8Array(4); + gl.finish(); + gl.readPixels(64, 64, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, px); + return px; + }; + + const setupOrtho = () => { + const proj = new Matrix3d(); + proj.ortho(0, 128, 128, 0, -1000, 1000); + renderer.setProjection(proj); + }; + + const freshFrame = () => { + renderer.backgroundColor.setColor(0, 0, 0, 255); + renderer.clear(); + renderer.setColor("#000000"); + renderer.fillRect(0, 0, 1, 1); // force a non-mesh batcher state + }; + + const drawEmissiveMesh = (emissive) => { + const mesh = makeQuadMesh(64, 64, 0, [0, 0, 0, 255]); // black diffuse + mesh.emissive = emissive; // Float32Array(3) or undefined + renderer.currentTint.setColor(0, 0, 0, 255); + renderer.drawMesh(mesh); + }; + + it("a black mesh with green emissive glows green", (ctx) => { + requireWebGL2(ctx); + setupOrtho(); + freshFrame(); + drawEmissiveMesh(new Float32Array([0, 1, 0])); + const px = readCenter(); + expect(px[0]).toBeLessThan(60); // no red + expect(px[1]).toBeGreaterThan(180); // green from emissive add + }); + + it("a black mesh with no emissive stays black (uniform resets between meshes)", (ctx) => { + requireWebGL2(ctx); + setupOrtho(); + freshFrame(); + // draw an emissive mesh first, then a non-emissive one over it: the + // batcher must reset uEmissive back to zero or the glow would leak. + drawEmissiveMesh(new Float32Array([0, 1, 0])); + drawEmissiveMesh(undefined); + const px = readCenter(); + expect(px[1]).toBeLessThan(60); // green did NOT leak into mesh 2 + }); + }); }); From 8178af4fe72e9fe3bda15fb9fe877eebc09aedc1 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sat, 20 Jun 2026 12:26:54 +0800 Subject: [PATCH 05/10] docs(mesh): add material-settings example (textureFilter/alphaCutoff/emissive) to JSDoc Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/melonjs/src/renderable/mesh.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/melonjs/src/renderable/mesh.js b/packages/melonjs/src/renderable/mesh.js index d8e9cb894..cae317ea5 100644 --- a/packages/melonjs/src/renderable/mesh.js +++ b/packages/melonjs/src/renderable/mesh.js @@ -170,6 +170,16 @@ export default class Mesh extends Renderable { * rightHanded: true, * }); * + * // material settings (WebGL) — usually set for you by the glTF/OBJ loader, + * // but available directly on a hand-built mesh too + * let sign = new me.Mesh(0, 0, { + * vertices, uvs, indices, texture: "neon-sign", + * width: 64, normalize: false, + * textureFilter: "nearest", // crisp pixel-art upscaling + * alphaCutoff: 0.5, // discard texels below 0.5 alpha (cutout) + * emissive: [0.9, 0.2, 0.6], // glow, independent of scene lights + * }); + * * // 3D rotation using the standard rotate() API * mesh.rotate(Math.PI / 4, new me.Vector3d(0, 1, 0)); // rotate around Y axis * From 3b713aa283a246d7fd40e1e555fd8af19389e513 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Mon, 22 Jun 2026 11:20:14 +0800 Subject: [PATCH 06/10] perf(webgl): allocation-free mesh batcher (versioned remap) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MeshBatcher.addMesh dedup'd vertices per chunk with a Map that was clear()ed every chunk; V8 drops a Map's backing table on clear(), so re-filling it reallocated as it grew — garbage proportional to vertex count (MBs/sec of GC churn on a dense scene). Replaced with a versioned typed-array remap: a per-chunk stamp invalidates every entry in O(1), arrays grow once and are reused, so a re-drawn static mesh allocates nothing per frame. Measured on a ~158k-vertex scene: ~7x less GC garbage and ~30% faster draw (11ms -> 7.6ms, Chrome/ANGLE). Benefits all 3D mesh rendering. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/video/webgl/batchers/mesh_batcher.js | 65 +++++++++++++++---- 1 file changed, 53 insertions(+), 12 deletions(-) diff --git a/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js b/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js index 36e79b83f..91d34e6b6 100644 --- a/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js @@ -10,14 +10,41 @@ import { MaterialBatcher } from "./material_batcher.js"; // identity, so output (x, y) matches the legacy Vector2d path. const _v = new Vector3d(); -// Reused scratch for addMesh's per-chunk vertex dedup (`_remap`) and absolute -// index list (`_chunkIndices`), so a chunk doesn't allocate a fresh Map + -// array per mesh per frame (GC pressure on the draw path). Safe because -// addMesh runs synchronously and never re-enters (flush() only draws). Shared -// by MeshBatcher and LitMeshBatcher — only one addMesh runs at a time. -const _remap = new Map(); +// Reused scratch for addMesh's per-chunk vertex dedup and absolute index list +// (`_chunkIndices`), so a chunk allocates nothing per mesh per frame (GC +// pressure on the draw path). Safe because addMesh runs synchronously and never +// re-enters (flush() only draws). Shared by MeshBatcher and LitMeshBatcher — +// only one addMesh runs at a time. +// +// Dedup uses a "versioned" typed-array remap rather than a `Map`: a `Map` here +// churned the GC badly, because V8's `Map.clear()` drops the backing table, so +// re-filling it each chunk reallocated as it grew — and the cost scaled with +// vertex count (a dense mesh = MBs/sec of garbage). Instead, `_remapSlot[orig]` +// holds the local index assigned to original-vertex `orig` THIS chunk, valid +// only when `_remapStamp[orig] === _stamp`. Bumping `_stamp` per chunk +// invalidates every entry in O(1) — no clearing, no allocation. The arrays grow +// lazily (to a power of two ≥ the largest mesh's vertex count) and are reused. +let _remapSlot = new Int32Array(0); +let _remapStamp = new Int32Array(0); +let _stamp = 0; const _chunkIndices = []; +/** + * Ensure the versioned-remap scratch arrays can index every vertex of a mesh + * with `vertexCount` vertices. Grows to the next power of two and reuses + * thereafter (one-time cost when a larger mesh first appears). + * @ignore + */ +function ensureRemapCapacity(vertexCount) { + if (_remapSlot.length >= vertexCount) { + return; + } + // next power of two ≥ vertexCount (Math.clz32 → leading-zero count) + const cap = vertexCount <= 1 ? 1 : 1 << (32 - Math.clz32(vertexCount - 1)); + _remapSlot = new Int32Array(cap); + _remapStamp = new Int32Array(cap); // zero-filled; _stamp is always ≥ 1 in use +} + // Shared lazy-depth-clear state for the mesh-mode pass. Module-level (not // per-instance) so the unlit `MeshBatcher` and the `LitMeshBatcher` — which // extends it and inherits `bind()` — coordinate on a SINGLE depth clear per @@ -310,6 +337,10 @@ export default class MeshBatcher extends MaterialBatcher { const maxVerts = this.vertexData.maxVertex; const maxIndices = this.indexBuffer.data.length; + // size the versioned-remap scratch for this mesh's vertex range (one-time + // growth; reused across frames thereafter) + ensureRemapCapacity(mesh.vertexCount); + // process triangles in chunks that fit the buffer let triIdx = 0; while (triIdx < indices.length) { @@ -330,19 +361,29 @@ export default class MeshBatcher extends MaterialBatcher { const endIdx = Math.min(triIdx + maxTris * 3, indices.length); - // build a local vertex remap for this chunk (reused scratch) - // capture base offset before pushing any vertices + // build a local vertex remap for this chunk (reused scratch). + // capture base offset before pushing any vertices. Bump the stamp to + // invalidate the whole remap in O(1) (resetting before int32 overflow, + // ~weeks of continuous rendering away, keeps the stored stamps valid). const baseOffset = vertexData.vertexCount; - _remap.clear(); + if (_stamp >= 0x7fffffff) { + _remapStamp.fill(0); + _stamp = 0; + } + _stamp++; _chunkIndices.length = 0; let localCount = 0; for (let j = triIdx; j < endIdx; j++) { const origIdx = indices[j]; - let localIdx = _remap.get(origIdx); - if (localIdx === undefined) { + let localIdx; + if (_remapStamp[origIdx] === _stamp) { + // already emitted this chunk — reuse its local index + localIdx = _remapSlot[origIdx]; + } else { localIdx = localCount++; - _remap.set(origIdx, localIdx); + _remapStamp[origIdx] = _stamp; + _remapSlot[origIdx] = localIdx; const i3 = origIdx * 3; const i2 = origIdx * 2; From be6a58e05c43f545efa452b23a5bdf275c8abfad Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Mon, 22 Jun 2026 11:20:31 +0800 Subject: [PATCH 07/10] feat(webgl): decouple texture filtering from antiAlias; tighten GLTFData camera typing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit textureFilter setting ("auto" | "nearest" | "linear", default "auto") separates texture sampling smoothness from the polygon-edge MSAA that antiAlias controls — so you can mix them (smooth textures + no MSAA, or crisp pixel-art textures + MSAA edges), combinations the single boolean couldn't express. "auto" follows antiAlias (byte-identical to before); Mesh.textureFilter still overrides per-mesh. - backend-neutral resolver Renderer.getDefaultTextureFilter() ("linear"/ "nearest") on the base renderer so a future WebGPU backend reuses it; WebGL maps it to a GL enum via _glTextureFilter() - base Renderer.setTextureFilter() records the setting (Canvas no-op); WebGL overrides to re-filter live textures (shared _reapplyTextureFilter) - 2D Canvas has no per-texture filtering and ignores it - adversarial tests (11): resolver truth table, explicit filter not clobbered by antiAlias toggle, per-mesh override precedence Also tightens GLTFData.cameras from object[] to a typed shape with perspective/yfov so scene.cameras[0].perspective?.yfov type-checks. README/CHANGELOG: textureFilter + the allocation-free batcher perf note. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 2 +- packages/melonjs/CHANGELOG.md | 2 + .../application/defaultApplicationSettings.ts | 1 + packages/melonjs/src/application/settings.ts | 35 ++++ packages/melonjs/src/loader/loader.js | 2 +- packages/melonjs/src/video/renderer.js | 32 +++ .../video/webgl/batchers/lit_quad_batcher.js | 3 +- .../video/webgl/batchers/material_batcher.js | 10 +- .../melonjs/src/video/webgl/webgl_renderer.js | 75 +++++-- packages/melonjs/tests/texture-filter.spec.js | 188 ++++++++++++++++++ 10 files changed, 323 insertions(+), 27 deletions(-) create mode 100644 packages/melonjs/tests/texture-filter.spec.js diff --git a/README.md b/README.md index fa04cfe00..26719fc3f 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ Graphics - Built-in effects such as tinting, masking, and CSS-style blend modes (normal, additive, multiply, screen, darken, lighten) - Standard spritesheet, single and multiple Packed Textures support - Compressed texture support (DDS, KTX, KTX2, PVR, PKM) with automatic format detection and fallback -- 3D mesh rendering with OBJ/MTL model loading, multi-material support, hardware depth testing, and perspective projection via `Camera3d` +- 3D mesh rendering with OBJ/MTL model loading, multi-material support, hardware depth testing, and perspective projection via `Camera3d` — ~30% faster mesh rendering with near-zero per-frame allocation (a re-drawn static mesh produces no GC garbage) - Lighting, in 2D and 3D: - **2D** — `Light2d` as a first-class `Renderable` (multiple dynamic lights, radial-gradient falloff, illumination-only mode, procedural rendering via `drawLight`), plus optional per-pixel normal-map shading on sprites for 3D-looking dynamic lights - **3D** — `Light3d` directional + ambient lights, added to the world like `Light2d` (half-Lambert diffuse + ambient fill, runtime-manipulable for day/night), auto-loaded from a glTF scene's authored sun diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 88056d2bc..166088827 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -20,6 +20,7 @@ - **`Mesh.getBounds3d()`** — the mesh's world-space `AABB3d` (the 3D analog of `getBounds()`, which only describes a flat 2D box). Powers the debug-plugin's new 3D bounding-box wireframe overlay. - **`Camera3d.worldToScreen(world, out?)`** — project a world point to screen-pixel coordinates (perspective divide included); returns `null` for points behind the camera. Useful for HUD elements pinned to 3D objects, picking, and debug overlays. - **`AABB3d`** now exported, with `AABB3d.fromVertices(src, count, matrix?)` to build a box from a flat vertex buffer (delegates to the new `transformedBounds`). +- **`textureFilter` application setting — texture filtering decoupled from `antiAlias`** (WebGL). `antiAlias` conflated two things: polygon-edge MSAA *and* texture sampling smoothness. The new `textureFilter` setting (`"auto"` / `"nearest"` / `"linear"`, default `"auto"`) separates the texture half, so you can pick them independently — smooth textures with no MSAA, or crisp pixel-art textures *with* MSAA edges. `"auto"` follows `antiAlias` (unchanged behavior); a `Mesh`'s own `textureFilter` still overrides per-mesh. Runtime `renderer.setTextureFilter(mode)` + backend-neutral `renderer.getDefaultTextureFilter()` (the 2D Canvas renderer has no per-texture filtering and ignores it). Default `"auto"` → byte-identical to previous behavior. - **Mesh `lit` / `normals` settings** and per-vertex world-space normal projection for the Camera3d lighting path. - **`Renderable.applyAnchorTransform`** (default `true`) — new flag controlling whether `preDraw` applies the `anchorPoint` offset to the renderer transform. Defaults to the existing behavior; `Mesh` sets it `false` on the `Camera3d` world-space path (a 3D mesh is positioned by its transform and has no anchor box, so the normalized offset must not leak into the shared mesh view matrix). - **`Mesh` supports meshes with more than 65,535 vertices** — a `Uint32Array` index buffer is preserved as-is instead of being coerced to `Uint16Array`, so high-poly meshes (e.g. large glTF nodes) no longer have their indices silently truncated. @@ -30,6 +31,7 @@ ### Performance - **Allocation-free glTF animation pose path** — sampling and re-posing an animated `GLTFModel` each frame allocates nothing: matrix composition and multiplication write in place (`composeTRSInto` / `multiplyMatrixInto`) into preallocated per-node world buffers, and the keyframe sampler reuses scratch vectors. No per-frame GC churn even for dense, many-node rigs. +- **Allocation-free mesh batching** — `MeshBatcher.addMesh` dedup'd vertices per chunk with a `Map` that was `clear()`ed every chunk; V8 drops a `Map`'s backing table on `clear()`, so re-filling it reallocated as it grew — producing garbage proportional to vertex count (megabytes/sec of GC churn on a dense scene). Replaced with a "versioned" typed-array remap (a per-chunk stamp invalidates all entries in O(1), arrays grow once and are reused), so a re-drawn static mesh now allocates nothing per frame. Measured ~30% performance improvement and ~7× less garbage on a dense (~158k-vertex) scene. Benefits all 3D mesh rendering. ## [19.7.1] (melonJS 2) - _2026-06-14_ diff --git a/packages/melonjs/src/application/defaultApplicationSettings.ts b/packages/melonjs/src/application/defaultApplicationSettings.ts index b50cd3247..11fa68e07 100644 --- a/packages/melonjs/src/application/defaultApplicationSettings.ts +++ b/packages/melonjs/src/application/defaultApplicationSettings.ts @@ -10,6 +10,7 @@ export const defaultApplicationSettings = { powerPreference: "default", transparent: false, antiAlias: false, + textureFilter: "auto", consoleHeader: true, blendMode: "normal", physic: "builtin", diff --git a/packages/melonjs/src/application/settings.ts b/packages/melonjs/src/application/settings.ts index a71c2bc2b..6a17a13db 100644 --- a/packages/melonjs/src/application/settings.ts +++ b/packages/melonjs/src/application/settings.ts @@ -97,6 +97,41 @@ export type ApplicationSettings = { */ antiAlias: boolean; + /** + * Default texture magnification/minification filter, **decoupled from + * `antiAlias`** (WebGL only — the 2D Canvas renderer has no per-texture + * filtering and ignores this). + * + * `antiAlias` conflates two separate concerns: polygon-edge antialiasing + * (MSAA) *and* texture sampling smoothness. This setting separates the + * texture half out, so you can choose them independently — e.g. smooth + * textures with no MSAA, or crisp pixel-art textures *with* MSAA edges. + * + * - `"auto"` (default) — follow `antiAlias` (`linear` when `true`, `nearest` + * when `false`): unchanged behavior. + * - `"nearest"` — crisp/pixelated upscaling, regardless of `antiAlias`. + * - `"linear"` — smooth, regardless of `antiAlias`. + * + * This is the **default** for every texture; a {@link Mesh} can still override + * it per-mesh via its own `textureFilter` setting (which wins). + * @default "auto" + * @example + * // smooth textures but NO polygon-edge MSAA + * const app = new Application(1024, 768, { + * renderer: video.WEBGL, + * antiAlias: false, // MSAA off + * textureFilter: "linear", // textures still filtered smooth + * }); + * + * // crisp pixel-art textures WITH MSAA-smoothed edges + * new Application(1024, 768, { + * renderer: video.WEBGL, + * antiAlias: true, // MSAA on + * textureFilter: "nearest", // textures stay crisp + * }); + */ + textureFilter: "auto" | "nearest" | "linear"; + /** * whether to display melonJS version and basic device information in the console * @default true diff --git a/packages/melonjs/src/loader/loader.js b/packages/melonjs/src/loader/loader.js index 3a65274e8..1ee873af3 100644 --- a/packages/melonjs/src/loader/loader.js +++ b/packages/melonjs/src/loader/loader.js @@ -885,7 +885,7 @@ export function getOBJ(elt) { * a parsed glTF/GLB scene descriptor, as returned by {@link loader.getGLTF} * @typedef {object} GLTFData * @property {object[]} nodes - one entry per mesh primitive (accumulated `world` transform, `vertices`, `normals`, `uvs`, `indices`, `vertexCount`, decoded baseColor `image`, `doubleSided`) - * @property {object[]} cameras - glTF cameras, each with its `world` transform + perspective parameters + * @property {Array<{world: number[], type?: string, perspective?: {yfov?: number, aspectRatio?: number, znear?: number, zfar?: number}, orthographic?: object}>} cameras - glTF cameras, each with its `world` transform + the glTF camera parameters (`perspective` for perspective cameras, `orthographic` otherwise) * @property {object[]} lights - parsed `KHR_lights_punctual` lights (`type`, `color`, `intensity`, `range`, world-space `direction`/`position`, `name`) * @property {{min: number[], max: number[]}} bounds - world-space scene bounds in glTF units */ diff --git a/packages/melonjs/src/video/renderer.js b/packages/melonjs/src/video/renderer.js index 7a3057a6d..5d80e86fd 100644 --- a/packages/melonjs/src/video/renderer.js +++ b/packages/melonjs/src/video/renderer.js @@ -725,6 +725,38 @@ export default class Renderer { } } + /** + * Resolve the default texture filter **mode** for this renderer, decoupled + * from {@link Renderer#setAntiAlias} (which controls polygon-edge MSAA on GPU + * backends). Honors the `textureFilter` setting (`"nearest"` / `"linear"`), + * falling back to the `antiAlias` setting when it's `"auto"` (the default). + * + * Backend-neutral on purpose: it returns the mode as a string so each GPU + * backend maps it to its own enum (WebGL `gl.LINEAR` / `gl.NEAREST`, a future + * WebGPU renderer `GPUFilterMode`). The Canvas renderer has no per-texture + * filtering, so this is informational there. + * @returns {"linear"|"nearest"} the resolved default filter mode + */ + getDefaultTextureFilter() { + const mode = this.settings.textureFilter; + if (mode === "linear" || mode === "nearest") { + return mode; + } + // "auto" (or unset) → follow the antiAlias setting + return this.settings.antiAlias ? "linear" : "nearest"; + } + + /** + * Set the default texture magnification/minification filter at runtime, + * decoupled from {@link Renderer#setAntiAlias}. The base implementation just + * records the setting (the Canvas renderer has no per-texture filtering); + * GPU backends override this to re-apply the filter to live textures. + * @param {"auto"|"nearest"|"linear"} [mode="auto"] - `"auto"` follows `antiAlias` + */ + setTextureFilter(mode = "auto") { + this.settings.textureFilter = mode; + } + /** * set/change the current projection matrix (GPU renderers only — * the Canvas renderer applies projection via the 2D context's transform stack). diff --git a/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js b/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js index ed06a16a8..26e04256c 100644 --- a/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js @@ -218,11 +218,10 @@ export default class LitQuadBatcher extends QuadBatcher { * @param {number} unit - GL texture unit (already offset by `maxBatchTextures`) */ uploadNormalMap(image, unit) { - const gl = this.gl; this.createTexture2D( unit, image, - this.renderer.settings.antiAlias ? gl.LINEAR : gl.NEAREST, + this.renderer._glTextureFilter(), "no-repeat", image.width, image.height, diff --git a/packages/melonjs/src/video/webgl/batchers/material_batcher.js b/packages/melonjs/src/video/webgl/batchers/material_batcher.js index 587165b8f..dd6fd6f6a 100644 --- a/packages/melonjs/src/video/webgl/batchers/material_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/material_batcher.js @@ -364,14 +364,14 @@ export class MaterialBatcher extends Batcher { if (typeof texture2D === "undefined" || force) { // honor a resource-specified filter (e.g. tilemap index textures - // need NEAREST regardless of the global antiAlias setting), - // otherwise fall back to the renderer-wide preference + // need NEAREST regardless of the global setting, or a Mesh's own + // `textureFilter`), otherwise fall back to the renderer-wide default + // (the `textureFilter` setting, decoupled from MSAA — see + // WebGLRenderer#getDefaultTextureFilter) const filter = typeof texture.filter !== "undefined" ? texture.filter - : this.renderer.settings.antiAlias - ? this.gl.LINEAR - : this.gl.NEAREST; + : this.renderer._glTextureFilter(); // `w`/`h` historically came from callers (e.g. `addQuad`) that // passed the DESTINATION quad size, not the texture size. That // broke the downstream POT check — a 480×1216 atlas drawn into diff --git a/packages/melonjs/src/video/webgl/webgl_renderer.js b/packages/melonjs/src/video/webgl/webgl_renderer.js index 10a91a39a..744291b4d 100644 --- a/packages/melonjs/src/video/webgl/webgl_renderer.js +++ b/packages/melonjs/src/video/webgl/webgl_renderer.js @@ -1671,6 +1671,46 @@ export default class WebGLRenderer extends Renderer { this.currentTransform.scale(x, y, 1); } + /** + * Map the backend-neutral default filter mode + * ({@link Renderer#getDefaultTextureFilter}) to the GL enum + * (`gl.LINEAR` / `gl.NEAREST`) used for textures that don't carry their own + * `texture.filter`. The neutral resolver lives on the base renderer so a + * future WebGPU backend reuses it and maps to `GPUFilterMode` instead. + * @returns {number} `gl.LINEAR` or `gl.NEAREST` + * @ignore + */ + _glTextureFilter() { + return this.getDefaultTextureFilter() === "linear" + ? this.gl.LINEAR + : this.gl.NEAREST; + } + + /** + * Re-apply a min/mag filter to every currently-bound texture across all + * batchers (see https://github.com/melonjs/melonJS/issues/1279). Shared by + * {@link WebGLRenderer#setAntiAlias} and {@link WebGLRenderer#setTextureFilter}. + * @param {number} filter - `gl.LINEAR` or `gl.NEAREST` + * @ignore + */ + _reapplyTextureFilter(filter) { + const gl = this.gl; + this.batchers.forEach((batcher) => { + if (batcher.boundTextures) { + for (let i = 0; i < batcher.boundTextures.length; i++) { + if (typeof batcher.boundTextures[i] !== "undefined") { + gl.activeTexture(gl.TEXTURE0 + i); + gl.bindTexture(gl.TEXTURE_2D, batcher.boundTextures[i]); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter); + } + } + // reset so next bindTexture2D re-selects the correct unit + batcher.currentTextureUnit = -1; + } + }); + } + /** * enable/disable image smoothing (scaling interpolation) * @param {boolean} [enable=false] @@ -1678,27 +1718,26 @@ export default class WebGLRenderer extends Renderer { setAntiAlias(enable = false) { if (this.settings.antiAlias !== enable) { super.setAntiAlias(enable); - // update the GL texture filtering on all bound textures - // see https://github.com/melonjs/melonJS/issues/1279 - const gl = this.gl; - const filter = enable ? gl.LINEAR : gl.NEAREST; - this.batchers.forEach((batcher) => { - if (batcher.boundTextures) { - for (let i = 0; i < batcher.boundTextures.length; i++) { - if (typeof batcher.boundTextures[i] !== "undefined") { - gl.activeTexture(gl.TEXTURE0 + i); - gl.bindTexture(gl.TEXTURE_2D, batcher.boundTextures[i]); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter); - } - } - // reset so next bindTexture2D re-selects the correct unit - batcher.currentTextureUnit = -1; - } - }); + // re-filter bound textures to the resolved default — honors an explicit + // `textureFilter` (so toggling antiAlias won't override a "nearest" / + // "linear" choice), and tracks antiAlias when `textureFilter` is "auto". + this._reapplyTextureFilter(this._glTextureFilter()); } } + /** + * Set the default texture magnification/minification filter at runtime, + * decoupled from {@link WebGLRenderer#setAntiAlias} (which controls MSAA). + * Records the setting (via the base) then re-filters every currently-bound + * texture. WebGL only. + * @param {"auto"|"nearest"|"linear"} [mode="auto"] - `"auto"` follows `antiAlias` + * @see Renderer#getDefaultTextureFilter + */ + setTextureFilter(mode = "auto") { + super.setTextureFilter(mode); + this._reapplyTextureFilter(this._glTextureFilter()); + } + /** * Set the global alpha * @param {number} alpha - 0.0 to 1.0 values accepted. diff --git a/packages/melonjs/tests/texture-filter.spec.js b/packages/melonjs/tests/texture-filter.spec.js new file mode 100644 index 000000000..1d22278a3 --- /dev/null +++ b/packages/melonjs/tests/texture-filter.spec.js @@ -0,0 +1,188 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { boot, Mesh, video, WebGLRenderer } from "../src/index.js"; + +/** + * Tests for the decoupled default texture filter (`textureFilter` setting), + * which separates texture sampling smoothness from the polygon-edge MSAA that + * `antiAlias` controls. + * + * The resolver lives on the base `Renderer` (backend-neutral, returns + * `"linear"` / `"nearest"`); the WebGL renderer maps it to a GL enum via + * `_glTextureFilter()`. The per-mesh `Mesh.textureFilter` override still wins. + * + * Adversarial cases assert the precedence and that an explicit filter is NOT + * clobbered by toggling `antiAlias` (the bug the decoupling exists to prevent). + */ +describe("default texture filter (decoupled from antiAlias)", () => { + let renderer; + + beforeAll(async () => { + await boot(); + try { + video.init(64, 64, { + parent: "screen", + renderer: video.WEBGL, + // software GL in headless chromium trips the "major performance + // caveat" flag — opt out so the WebGL renderer is actually created + failIfMajorPerformanceCaveat: false, + }); + } catch { + // genuine WebGL absence — tests skip via requireWebGL below + } + if (video.renderer instanceof WebGLRenderer) { + renderer = video.renderer; + } + }); + + afterAll(() => { + try { + video.init(64, 64, { parent: "screen", renderer: video.AUTO }); + } catch { + // ignore + } + }); + + const requireWebGL = (ctx) => { + if (renderer === undefined) { + ctx.skip("WebGL renderer not available in this environment"); + } + }; + + // restore a known baseline before each test (other specs share `video`) + beforeEach(() => { + if (renderer) { + renderer.settings.textureFilter = "auto"; + renderer.settings.antiAlias = false; + } + }); + + // ── resolver truth table (backend-neutral mode) ──────────────────────── + describe("getDefaultTextureFilter() resolution", () => { + it("auto + antiAlias:false → nearest", (ctx) => { + requireWebGL(ctx); + renderer.settings.textureFilter = "auto"; + renderer.settings.antiAlias = false; + expect(renderer.getDefaultTextureFilter()).toBe("nearest"); + }); + + it("auto + antiAlias:true → linear", (ctx) => { + requireWebGL(ctx); + renderer.settings.textureFilter = "auto"; + renderer.settings.antiAlias = true; + expect(renderer.getDefaultTextureFilter()).toBe("linear"); + }); + + it("explicit 'linear' overrides antiAlias:false", (ctx) => { + requireWebGL(ctx); + renderer.settings.textureFilter = "linear"; + renderer.settings.antiAlias = false; + expect(renderer.getDefaultTextureFilter()).toBe("linear"); + }); + + it("explicit 'nearest' overrides antiAlias:true", (ctx) => { + requireWebGL(ctx); + renderer.settings.textureFilter = "nearest"; + renderer.settings.antiAlias = true; + expect(renderer.getDefaultTextureFilter()).toBe("nearest"); + }); + + it("ADVERSARIAL: an unknown/unset mode falls back to the antiAlias path", (ctx) => { + requireWebGL(ctx); + // @ts-expect-error — exercising the defensive fallback + renderer.settings.textureFilter = undefined; + renderer.settings.antiAlias = true; + expect(renderer.getDefaultTextureFilter()).toBe("linear"); + renderer.settings.antiAlias = false; + expect(renderer.getDefaultTextureFilter()).toBe("nearest"); + }); + }); + + // ── GL enum mapping ──────────────────────────────────────────────────── + describe("_glTextureFilter() maps the mode to a GL enum", () => { + it("maps linear → gl.LINEAR and nearest → gl.NEAREST", (ctx) => { + requireWebGL(ctx); + const gl = renderer.gl; + renderer.settings.textureFilter = "linear"; + expect(renderer._glTextureFilter()).toBe(gl.LINEAR); + renderer.settings.textureFilter = "nearest"; + expect(renderer._glTextureFilter()).toBe(gl.NEAREST); + }); + }); + + // ── runtime setter, decoupled from setAntiAlias ──────────────────────── + describe("setTextureFilter() / setAntiAlias() independence", () => { + it("setTextureFilter records the mode and resolves accordingly", (ctx) => { + requireWebGL(ctx); + renderer.setTextureFilter("linear"); + expect(renderer.settings.textureFilter).toBe("linear"); + expect(renderer.getDefaultTextureFilter()).toBe("linear"); + }); + + it("ADVERSARIAL: an explicit filter is NOT clobbered by toggling antiAlias", (ctx) => { + requireWebGL(ctx); + // pin a crisp filter, then turn MSAA on/off — the texture filter must + // stay 'nearest' (the whole point of decoupling) + renderer.setTextureFilter("nearest"); + renderer.setAntiAlias(true); + expect(renderer.getDefaultTextureFilter()).toBe("nearest"); + renderer.setAntiAlias(false); + expect(renderer.getDefaultTextureFilter()).toBe("nearest"); + }); + + it("ADVERSARIAL: with 'auto', toggling antiAlias DOES move the filter", (ctx) => { + requireWebGL(ctx); + renderer.setTextureFilter("auto"); + renderer.setAntiAlias(true); + expect(renderer.getDefaultTextureFilter()).toBe("linear"); + renderer.setAntiAlias(false); + expect(renderer.getDefaultTextureFilter()).toBe("nearest"); + }); + }); + + // ── per-mesh override precedence ─────────────────────────────────────── + describe("Mesh.textureFilter overrides the global default", () => { + // a real (non-white-pixel) texture — the per-mesh override only applies to + // a real texture, never the shared white-pixel fallback + const makeTex = () => { + const c = document.createElement("canvas"); + c.width = 2; + c.height = 2; + c.getContext("2d").fillRect(0, 0, 2, 2); + return c; + }; + const tri = { + vertices: new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]), + uvs: new Float32Array([0, 0, 0, 0, 0, 0]), + indices: new Uint16Array([0, 1, 2]), + }; + + it("ADVERSARIAL: a 'nearest' mesh stays nearest even when the global is 'linear'", (ctx) => { + requireWebGL(ctx); + renderer.setTextureFilter("linear"); // global says smooth + const mesh = new Mesh(0, 0, { + ...tri, + texture: makeTex(), + width: 10, + normalize: false, + textureFilter: "nearest", // ...but this mesh wants crisp + }); + // the per-mesh override is baked onto the resolved texture, winning + // over the global default used by uploadTexture + expect(mesh.texture.filter).toBe(renderer.gl.NEAREST); + }); + + it("a mesh with no textureFilter leaves the texture on the global default", (ctx) => { + requireWebGL(ctx); + renderer.setTextureFilter("linear"); + const mesh = new Mesh(0, 0, { + ...tri, + texture: makeTex(), + width: 10, + normalize: false, + // no per-mesh textureFilter → texture.filter stays undefined, so + // uploadTexture applies the global default (_glTextureFilter) + }); + expect(mesh.texture.filter).toBeUndefined(); + }); + }); +}); From fbdee72e9d37c6393c2d428fc6fe1da35b70837e Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Mon, 22 Jun 2026 11:20:53 +0800 Subject: [PATCH 08/10] example: procedural night-city flythrough (emissive showcase) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A big procedural downtown built entirely from raw mesh data (no asset) — every lit window and street-lamp head is its own emissive geometry, so they glow individually at night while the moonlit shells stay dark. Pole-mounted streetlights, dark ground + street grid, a seamless looping Camera3d flythrough, and a vignette post-effect. Showcases Mesh.emissive and the decoupled antiAlias/textureFilter combo (MSAA edges + crisp). Also: gltf-scene example uses antiAlias:true (looks better on the low-poly diorama), shorter example descriptions, and the gltf-scene camera uses pos.set(x,y) + depth=z (the inherited pos.set is typed 2-arg; see #1510). LICENSE credits the Kenney glTF kits; night-city uses no external assets. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/examples/LICENSE.md | 16 + .../src/examples/gltf/ExampleGltf.tsx | 10 +- .../examples/nightcity/ExampleNightCity.tsx | 320 +++++++++++++++ .../examples/src/examples/nightcity/city.ts | 383 ++++++++++++++++++ packages/examples/src/main.tsx | 17 +- 5 files changed, 741 insertions(+), 5 deletions(-) create mode 100644 packages/examples/src/examples/nightcity/ExampleNightCity.tsx create mode 100644 packages/examples/src/examples/nightcity/city.ts diff --git a/packages/examples/LICENSE.md b/packages/examples/LICENSE.md index e55b48f18..45115b083 100644 --- a/packages/examples/LICENSE.md +++ b/packages/examples/LICENSE.md @@ -48,3 +48,19 @@ Spacecraft 3D models (`craft_speederA`, `craft_speederB`, `craft_racer`, Released under **CC0 1.0 Universal (Public Domain Dedication)** — no attribution legally required, credited here as a courtesy. + +### `gltf` examples + +The 3D scenes in `public/assets/gltf/` are authored/assembled from CC0 kits +published by Kenney: + +- `platformer-diorama.glb` (**glTF Scene**) — **"Platformer Kit"** + () +- `character.glb` (**glTF Animated Model**) — **"Blocky Characters"** + () + +All released under **CC0 1.0 Universal (Public Domain Dedication)** — no +attribution legally required, credited here as a courtesy. + +The **Night City Flythrough** example uses no external assets — its city is +generated procedurally from raw mesh data at runtime. diff --git a/packages/examples/src/examples/gltf/ExampleGltf.tsx b/packages/examples/src/examples/gltf/ExampleGltf.tsx index 956f879bc..156059974 100644 --- a/packages/examples/src/examples/gltf/ExampleGltf.tsx +++ b/packages/examples/src/examples/gltf/ExampleGltf.tsx @@ -82,6 +82,7 @@ const createGame = () => { renderer: video.WEBGL, // Mesh rendering requires WebGL scale: "auto", cameraClass: Camera3dClass, + antiAlias: true, }); } catch (err) { const reason = err instanceof Error ? err.message : String(err); @@ -133,8 +134,9 @@ const createGame = () => { // here). A wide lens exaggerates near-field perspective so the rounded // grass front-lip looms over and visually swallows props behind it; // matching the glTF `yfov` reproduces the Blender look. - if (scene.cameras.length > 0 && scene.cameras[0].perspective?.yfov) { - camera.fov = scene.cameras[0].perspective.yfov; + const yfov = scene.cameras[0]?.perspective?.yfov; + if (yfov) { + camera.fov = yfov; } // Showcase framing: a Blender-style 3/4 bird's-eye. The embedded glTF @@ -156,11 +158,13 @@ const createGame = () => { const updateCam = () => { distance = clamp(distance, 120, 3000); + // x/y via pos, z via `depth` (Camera3d's documented z accessor — the + // inherited `pos.set` is typed 2-arg; `depth` proxies to `pos.z`) camera.pos.set( cx + Math.sin(yaw) * Math.cos(pitch) * -distance, cy + Math.sin(pitch) * distance, // up = -Y - cz - Math.cos(yaw) * Math.cos(pitch) * distance, ); + camera.depth = cz - Math.cos(yaw) * Math.cos(pitch) * distance; camera.lookAt(cx, cy, cz); }; updateCam(); diff --git a/packages/examples/src/examples/nightcity/ExampleNightCity.tsx b/packages/examples/src/examples/nightcity/ExampleNightCity.tsx new file mode 100644 index 000000000..e57f2bae4 --- /dev/null +++ b/packages/examples/src/examples/nightcity/ExampleNightCity.tsx @@ -0,0 +1,320 @@ +/** + * melonJS — procedural night-city flythrough (emissive material showcase). + * + * A downtown generated entirely from raw mesh data (no asset file): dark + * moonlit building shells plus thousands of individual emissive window panes. + * Because each lit window is real geometry carrying a `Mesh.emissive` color, + * windows glow individually at night — a few flicker — while the walls stay + * dark. A looping camera flythrough sweeps over and around the skyline, and a + * vignette post-effect frames it cinematically. + * + * Showcases the 19.8 `Mesh.emissive` material feature (self-illumination, + * independent of scene lighting) together with `Camera3d`. + * Copyright (C) 2011 - 2026 AltByte Pte Ltd — MIT License. + * See `packages/examples/LICENSE.md` for full license + asset credits. + */ +import { DebugPanelPlugin } from "@melonjs/debug-plugin"; +import { + Application, + Camera3d as Camera3dClass, + type CanvasRenderer, + Color, + Light3d, + Mesh, + plugin, + Renderable, + VignetteEffect, + video, + type WebGLRenderer, +} from "melonjs"; +import { createExampleComponent } from "../utils"; +import { generateCity } from "./city"; + +// pixels per authored unit — scales the whole city up to screen size. +const SCALE = 18; + +// authored (Y-up) → melonJS render space (Y-down, rightHanded): negate Y and Z, +// then ×SCALE. Applied inline in the camera path (kept allocation-free — no +// intermediate arrays per frame). + +/** A night sky: gradient + stars + a soft moon, drawn screen-fixed. */ +function bakeNightSky() { + const c = document.createElement("canvas"); + c.width = 256; + c.height = 256; + const ctx = c.getContext("2d"); + if (ctx) { + const g = ctx.createLinearGradient(0, 0, 0, 256); + g.addColorStop(0, "#04060f"); + g.addColorStop(0.65, "#0a1230"); + g.addColorStop(1, "#1d2c52"); // light-polluted horizon + ctx.fillStyle = g; + ctx.fillRect(0, 0, 256, 256); + // stars, denser toward the top + for (let i = 0; i < 260; i++) { + const y = Math.random() ** 1.8 * 256; + const x = Math.random() * 256; + ctx.fillStyle = `rgba(255,255,255,${0.35 + Math.random() * 0.6})`; + ctx.fillRect(x, y, Math.random() < 0.12 ? 2 : 1, 1); + } + // soft moon + const mg = ctx.createRadialGradient(198, 46, 4, 198, 46, 30); + mg.addColorStop(0, "rgba(245,247,255,0.95)"); + mg.addColorStop(0.4, "rgba(210,222,255,0.45)"); + mg.addColorStop(1, "rgba(210,222,255,0)"); + ctx.fillStyle = mg; + ctx.fillRect(168, 16, 60, 60); + } + return c; +} + +class SkyBackdrop extends Renderable { + private sky = bakeNightSky(); + + constructor() { + super(0, 0, 1, 1); + this.floating = true; // screen-space — ignore the perspective camera + this.anchorPoint.set(0, 0); + } + + override draw(renderer: CanvasRenderer | WebGLRenderer) { + renderer.drawImage( + this.sky, + 0, + 0, + 256, + 256, + 0, + 0, + renderer.width, + renderer.height, + ); + } +} + +const createGame = () => { + let app: Application; + try { + app = new Application(1024, 768, { + parent: "screen", + renderer: video.WEBGL, // Mesh rendering requires WebGL + scale: "auto", + cameraClass: Camera3dClass, + // Showcase the decoupled antiAlias / textureFilter combo: + // - antiAlias: true → MSAA smooths the building silhouettes and + // calms the shimmer of the hundreds of tiny + // bright emissive window quads in motion. + // - textureFilter: "nearest" → textures stay crisp (independent of + // MSAA). A combination a single `antiAlias` + // boolean couldn't express. (The buildings are + // untextured here, so this is mainly to model + // the API; on a textured pixel-art scene it + // keeps the texels sharp while MSAA still + // smooths the polygon edges.) + antiAlias: true, + textureFilter: "nearest", + }); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + globalThis.alert( + "This example couldn't start: WebGL isn't available.\n\n" + + "3D mesh rendering requires a WebGL-capable browser/GPU.\n\n" + + `Details: ${reason}`, + ); + throw err; + } + + plugin.register(DebugPanelPlugin, "debugPanel"); + + let domCleanup: (() => void) | null = null; + + // ── build the city geometry → meshes ─────────────────────────────────── + // a big downtown (26×26 plots) — the merged meshes are drawn in full every + // frame (no frustum culling), so this pushes a lot of geometry through the + // batcher's chunked draw path: a light stress test you can watch on the + // debug panel's FPS / draw-call counters. + const city = generateCity(26, 7); + const cityBox = (city.radius + 4) * 2 * SCALE; // generous bounds (no cull-to-point) + + const makeMesh = ( + geo: { + vertices: number[]; + normals: number[]; + indices: number[]; + colors?: number[]; + }, + opts: { lit: boolean; tint?: Color; emissive?: [number, number, number] }, + ) => { + const m = new Mesh(0, 0, { + vertices: new Float32Array(geo.vertices), + normals: new Float32Array(geo.normals), + uvs: new Float32Array((geo.vertices.length / 3) * 2), // white-pixel: UVs unused + indices: Uint32Array.from(geo.indices), + width: cityBox, + height: cityBox, + scale: SCALE, + normalize: false, + rightHanded: true, + cullBackFaces: false, + lit: opts.lit, + emissive: opts.emissive, + }); + if (opts.tint) { + m.tint.copy(opts.tint); + } + // per-vertex colors (ground: asphalt vs road) — multiplied by tint + if (geo.colors) { + m.vertexColors = Uint32Array.from(geo.colors); + } + return m; + }; + + // ground plane + street grid (per-vertex colored), shaded by the moon + app.world.addChild(makeMesh(city.ground, { lit: true })); + // dark concrete shells, shaded by the moon (lit) + app.world.addChild( + makeMesh(city.walls, { lit: true, tint: new Color(34, 40, 58) }), + ); + // lit windows: one emissive mesh per glow color. Near-black tint so the + // (untextured) pane shows ONLY its emissive color — a glowing window. + for (const grp of city.glow) { + if (grp.indices.length === 0) { + continue; + } + const m = makeMesh(grp, { + lit: false, + tint: new Color(0, 0, 0), + emissive: grp.color, + }); + app.world.addChild(m); + } + + // ── lighting: a dim cool moon + low blue ambient so the shells read as + // shadowed shapes while the emissive windows do the talking ───────────── + app.world.addChild( + new Light3d({ + type: "directional", + direction: [0.5, 1, 0.3], + color: "#9fb4ff", + intensity: 0.22, + }), + ); + app.world.addChild( + new Light3d({ type: "ambient", color: "#1a2444", intensity: 0.6 }), + ); + + // sky behind everything (Camera3d doesn't clear to backgroundColor) + app.world.addChild(new SkyBackdrop(), -10000); + + // cinematic vignette (graceful no-op on Canvas) + app.viewport.addPostEffect(new VignetteEffect(app.renderer as WebGLRenderer)); + + // ── camera + looping flythrough ───────────────────────────────────────── + const camera = app.viewport as InstanceType; + camera.fov = (55 * Math.PI) / 180; + camera.setClipPlanes(SCALE * 0.4, SCALE * 240); + + // the flythrough is a SEAMLESS loop: a breathing orbit whose radius and + // height oscillate, so the camera swoops from a high establishing shot down + // to a low pass skimming the skyline and back. Every term is a sin/cos of an + // INTEGER multiple of the loop angle `a` (which wraps every PERIOD seconds), + // so position and aim are continuous across the t: 1 → 0 wrap — no snap. + const PERIOD = 26; // seconds per loop + let paused = false; + let t = 0.15; // start mid-swoop for a nice first frame + + // capture just the two scalars the camera path needs, so the (large) `city` + // geometry object — including its source `number[]` arrays — can be GC'd + // after setup instead of being pinned by the per-frame closure. + const cityRadius = city.radius; + const cityMaxH = city.maxHeight; + + const placeCamera = () => { + const a = t * Math.PI * 2; + // keep the orbit radius just OUTSIDE the city footprint (min ≈ 1.15·radius) + // so the low passes skim the skyline edge instead of plunging through walls + const r = cityRadius * (1.65 + 0.5 * Math.sin(a)); + const y = Math.max( + cityMaxH * 0.55 + cityMaxH * 0.5 * Math.sin(a * 2 + 1.2), + 2.5, + ); + // authored (Y-up) → render (Y-down): negate Y and Z, ×SCALE. Written + // straight into pos/depth as scalars — zero allocations per frame. (x/y + // via pos, z via `depth` — `pos.set` is typed 2-arg; `depth` is pos.z.) + camera.pos.set(r * Math.cos(a) * SCALE, -y * SCALE); + camera.depth = -r * Math.sin(a) * SCALE; + // aim drifts gently around the center at mid-height; frequency 1 (not 0.5) + // so the aim returns to its start as the loop wraps (seamless). + camera.lookAt( + cityRadius * 0.16 * Math.sin(a) * SCALE, + -cityMaxH * 0.42 * SCALE, + -cityRadius * 0.16 * Math.cos(a) * SCALE, + ); + }; + placeCamera(); + + // a tiny driver renderable: advances the looping flythrough each frame. + // Draws nothing. + class FlyDriver extends Renderable { + constructor() { + super(0, 0, 1, 1); + this.alwaysUpdate = true; + } + override update(dt: number) { + if (!paused) { + t = (t + dt / 1000 / PERIOD) % 1; + placeCamera(); + } + return true; + } + override draw() {} + } + app.world.addChild(new FlyDriver()); + + // ── on-screen controls ────────────────────────────────────────────────── + const parent = app.renderer.getCanvas().parentElement; + const btn = document.createElement("button"); + btn.textContent = "⏸ Pause flythrough"; + btn.style.cssText = + "position:absolute;top:16px;left:16px;z-index:1000;padding:8px 14px;" + + "background:#11131c;color:#cfe0ff;border:1px solid #38406a;border-radius:6px;" + + "cursor:pointer;font-family:sans-serif;font-size:14px;font-weight:600;"; + btn.addEventListener("click", () => { + paused = !paused; + btn.textContent = paused ? "▶ Resume flythrough" : "⏸ Pause flythrough"; + }); + + // concrete scene size for the perf story (20 verts/building, 4/window pane) + const buildingCount = Math.round(city.walls.vertices.length / 3 / 20); + const litWindowCount = city.glow.reduce( + (n, g) => n + g.vertices.length / 3 / 4, + 0, + ); + + const hint = document.createElement("div"); + hint.textContent = + `${buildingCount} buildings · ${litWindowCount.toLocaleString()} emissive ` + + "windows · looping flythrough · press S for stats"; + hint.style.cssText = + "position:absolute;top:58px;left:16px;color:#9fb6e8;" + + "font-family:sans-serif;font-size:12px;z-index:1000;" + + "text-shadow:0 1px 2px rgba(0,0,0,0.7);"; + + if (parent) { + parent.style.position = "relative"; + parent.appendChild(btn); + parent.appendChild(hint); + } + domCleanup = () => { + btn.remove(); + hint.remove(); + }; + + return () => { + if (domCleanup) { + domCleanup(); + } + }; +}; + +export const ExampleNightCity = createExampleComponent(createGame); diff --git a/packages/examples/src/examples/nightcity/city.ts b/packages/examples/src/examples/nightcity/city.ts new file mode 100644 index 000000000..57c84b979 --- /dev/null +++ b/packages/examples/src/examples/nightcity/city.ts @@ -0,0 +1,383 @@ +/** + * melonJS — procedural night-city geometry generator. + * + * Builds a small downtown of box "buildings" plus thousands of individual + * window quads, entirely from raw vertex data — no asset file. The walls are + * one lit mesh (shaded by the moon); the lit windows are split into a handful + * of emissive meshes (one per glow color) so each group can carry its own + * `Mesh.emissive` — that's what makes individual windows glow at night while + * the wall around them stays dark. "Off" windows simply aren't emitted, so the + * dark wall shows through and the skyline reads as a real some-windows-on city. + * + * Authored in a Y-up right-handed space (Y = height) so the meshes can use + * `rightHanded: true` — matching the glTF / Camera3d convention the rest of the + * 3D examples use. + * Copyright (C) 2011 - 2026 AltByte Pte Ltd — MIT License. + */ + +/** one emissive window-glow color (added on top of a near-black quad). */ +export interface GlowGroup { + /** emissive `[r, g, b]` (may exceed 1 for a hot glow). */ + color: [number, number, number]; + vertices: number[]; + normals: number[]; + indices: number[]; +} + +export interface CityGeometry { + /** merged building shells — one lit mesh, dark concrete. */ + walls: { vertices: number[]; normals: number[]; indices: number[] }; + /** + * the ground plane + street grid — one lit mesh. Carries per-vertex colors + * (`colors`, packed ARGB) so the dark asphalt and the lighter road strips + * are distinguished within a single mesh / draw. + */ + ground: { + vertices: number[]; + normals: number[]; + indices: number[]; + colors: number[]; + }; + /** lit windows, grouped by glow color — each an emissive mesh. */ + glow: GlowGroup[]; + /** authored half-extent of the city footprint (for camera framing). */ + radius: number; + /** authored height of the tallest tower. */ + maxHeight: number; +} + +/** pack an 8-bit RGB triple into the ARGB Uint32 `Mesh.vertexColors` wants. */ +function packRGB(r: number, g: number, b: number): number { + return ((255 << 24) | (r << 16) | (g << 8) | b) >>> 0; +} + +// the window-glow palette: mostly warm interior light, a few cool +// fluorescent-office and white penthouse accents. The last entry is the +// (hotter, >1 for an HDR pop) street-lamp color — used only by the streetlights +// below, never picked by a window. +const GLOW_COLORS: Array<[number, number, number]> = [ + [1.0, 0.74, 0.36], // 0 warm + [1.0, 0.58, 0.22], // 1 amber + [0.62, 0.4, 0.18], // 2 dim warm + [0.55, 0.8, 1.0], // 3 cool office + [0.3, 0.45, 0.62], // 4 dim cool + [0.95, 0.97, 1.0], // 5 white penthouse + [1.5, 1.02, 0.46], // 6 street lamp (hot sodium-warm) +]; +const STREETLAMP_GROUP = 6; + +// deterministic PRNG (mulberry32) so the city looks identical every run — +// no Math.random, so re-runs and screenshots are reproducible. +function rng(seed: number) { + let a = seed >>> 0; + return () => { + a |= 0; + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** + * Push one axis-aligned quad (4 verts, 2 tris) into a target buffer set. + * `o` is a corner; `du` / `dv` are the two edge vectors from it; `n` is the + * outward normal. Winding is left for `cullBackFaces: false` (interior never + * shown), so order doesn't matter here. + */ +function quad( + t: { vertices: number[]; normals: number[]; indices: number[] }, + ox: number, + oy: number, + oz: number, + dux: number, + duy: number, + duz: number, + dvx: number, + dvy: number, + dvz: number, + nx: number, + ny: number, + nz: number, +) { + const base = t.vertices.length / 3; + // p0 = o, p1 = o+du, p2 = o+du+dv, p3 = o+dv + t.vertices.push( + ox, + oy, + oz, + ox + dux, + oy + duy, + oz + duz, + ox + dux + dvx, + oy + duy + dvy, + oz + duz + dvz, + ox + dvx, + oy + dvy, + oz + dvz, + ); + for (let i = 0; i < 4; i++) { + t.normals.push(nx, ny, nz); + } + t.indices.push(base, base + 1, base + 2, base, base + 2, base + 3); +} + +/** + * Push an axis-aligned box centered at `(cx, ·, cz)`, spanning `y0..y1`, with + * footprint `sx × sz`. `top` / `bottom` toggle the cap faces (a thin pole skips + * both; a lamp head keeps them). Reuses {@link quad} per face. + */ +function box( + t: { vertices: number[]; normals: number[]; indices: number[] }, + cx: number, + cz: number, + y0: number, + y1: number, + sx: number, + sz: number, + top = false, + bottom = false, +) { + const x0 = cx - sx / 2; + const x1 = cx + sx / 2; + const z0 = cz - sz / 2; + const z1 = cz + sz / 2; + const h = y1 - y0; + quad(t, x0, y0, z1, sx, 0, 0, 0, h, 0, 0, 0, 1); // +Z + quad(t, x1, y0, z0, -sx, 0, 0, 0, h, 0, 0, 0, -1); // -Z + quad(t, x1, y0, z1, 0, 0, -sz, 0, h, 0, 1, 0, 0); // +X + quad(t, x0, y0, z0, 0, 0, sz, 0, h, 0, -1, 0, 0); // -X + if (top) { + quad(t, x0, y1, z0, sx, 0, 0, 0, 0, sz, 0, 1, 0); + } + if (bottom) { + quad(t, x0, y0, z0, sx, 0, 0, 0, 0, sz, 0, -1, 0); + } +} + +/** + * Generate the city geometry. + * @param grid - buildings per side (grid × grid plots, minus a few gaps) + * @param seed - PRNG seed (same seed → same city) + */ +export function generateCity(grid = 10, seed = 1337): CityGeometry { + const rand = rng(seed); + const CELL = 5; // plot pitch (building + street) + const half = ((grid - 1) * CELL) / 2; + + const walls = { + vertices: [] as number[], + normals: [] as number[], + indices: [] as number[], + }; + const ground = { + vertices: [] as number[], + normals: [] as number[], + indices: [] as number[], + colors: [] as number[], + }; + const glow: GlowGroup[] = GLOW_COLORS.map((color) => ({ + color, + vertices: [], + normals: [], + indices: [], + })); + + // a flat, upward-facing quad with a single vertex color — for the ground + // plane and road strips (which all live in the y=0 plane). + const flatQuad = ( + x0: number, + z0: number, + x1: number, + z1: number, + y: number, + color: number, + ) => { + const base = ground.vertices.length / 3; + ground.vertices.push(x0, y, z0, x1, y, z0, x1, y, z1, x0, y, z1); + for (let i = 0; i < 4; i++) { + ground.normals.push(0, 1, 0); + ground.colors.push(color); + } + ground.indices.push(base, base + 1, base + 2, base, base + 2, base + 3); + }; + + let maxHeight = 0; + + for (let gx = 0; gx < grid; gx++) { + for (let gz = 0; gz < grid; gz++) { + // leave occasional plots empty (plazas / parks) + if (rand() < 0.12) { + continue; + } + const cx = -half + gx * CELL; + const cz = -half + gz * CELL; + // footprint, with a little jitter + const sx = 2.6 + rand() * 1.0; + const sz = 2.6 + rand() * 1.0; + // downtown bias: taller toward the center. `dist` can exceed 1 at the + // corners (diagonal > per-axis half), so clamp before the fractional + // power — a negative base raised to 1.6 is NaN and would poison the + // height, the camera framing, and the whole view matrix. + const dist = Math.hypot(cx, cz) / half; // 0 center … >1 corners + const tall = Math.max(0, 1 - dist) ** 1.6; + const sy = 3 + rand() * 4 + tall * 16; + maxHeight = Math.max(maxHeight, sy); + + const x0 = cx - sx / 2; + const x1 = cx + sx / 2; + const z0 = cz - sz / 2; + const z1 = cz + sz / 2; + + // ── walls (4 sides + flat roof) ──────────────────────────────────── + quad(walls, x0, 0, z1, sx, 0, 0, 0, sy, 0, 0, 0, 1); // +Z + quad(walls, x1, 0, z0, -sx, 0, 0, 0, sy, 0, 0, 0, -1); // -Z + quad(walls, x1, 0, z1, 0, 0, -sz, 0, sy, 0, 1, 0, 0); // +X + quad(walls, x0, 0, z0, 0, 0, sz, 0, sy, 0, -1, 0, 0); // -X + quad(walls, x0, sy, z0, sx, 0, 0, 0, 0, sz, 0, 1, 0); // roof + + // ── windows on each of the 4 side faces ──────────────────────────── + // a faint per-building tint bias so a tower trends warm OR cool + // rather than random confetti + const coolBias = rand() < 0.35; + const faces: Array<{ + ox: number; + oz: number; + ux: number; + uz: number; + nx: number; + nz: number; + width: number; + }> = [ + { ox: x0, oz: z1, ux: 1, uz: 0, nx: 0, nz: 1, width: sx }, // +Z + { ox: x1, oz: z0, ux: -1, uz: 0, nx: 0, nz: -1, width: sx }, // -Z + { ox: x1, oz: z1, ux: 0, uz: -1, nx: 1, nz: 0, width: sz }, // +X + { ox: x0, oz: z0, ux: 0, uz: 1, nx: -1, nz: 0, width: sz }, // -X + ]; + const PITCH = 0.85; // window cell size + const WIN = 0.5; // lit pane size within the cell + const margin = (PITCH - WIN) / 2; + const eps = 0.02; // outset so the pane sits proud of the wall + for (const f of faces) { + const cols = Math.max(1, Math.floor(f.width / PITCH)); + const rows = Math.max(1, Math.floor((sy - 1) / PITCH)); + // center the window band horizontally on the face + const used = cols * PITCH; + const pad = (f.width - used) / 2; + for (let r = 0; r < rows; r++) { + // whole floors sometimes dark + if (rand() < 0.28) { + continue; + } + for (let c = 0; c < cols; c++) { + if (rand() < 0.45) { + continue; // this pane is unlit → dark wall shows + } + // pick a glow group (warm-biased, or cool-biased tower) + let gi: number; + const rr = rand(); + if (coolBias) { + gi = rr < 0.6 ? 3 : rr < 0.85 ? 4 : 5; + } else { + gi = rr < 0.45 ? 0 : rr < 0.72 ? 1 : rr < 0.9 ? 2 : 5; + } + const u = pad + c * PITCH + margin; + const v = 0.6 + r * PITCH + margin; + // pane origin in world, outset along the face normal + const ox = f.ox + f.ux * u + f.nx * eps; + const oz = f.oz + f.uz * u + f.nz * eps; + quad( + glow[gi], + ox, + v, + oz, + f.ux * WIN, + 0, + f.uz * WIN, // du (horizontal) + 0, + WIN, + 0, // dv (up) + f.nx, + 0, + f.nz, + ); + } + } + } + } + } + + // ── ground: asphalt plane + street grid + streetlights ───────────────── + const ext = half + CELL * 1.4; // plane reaches a bit past the outer plots + const ASPHALT = packRGB(16, 18, 26); // near-black ground + const ROAD = packRGB(40, 43, 54); // lighter road strips + const ROAD_W = 1.7; // street width (fits the gap between footprints) + + // base plane, just below the building bases so there's no z-fight at y=0 + flatQuad(-ext, -ext, ext, ext, -0.03, ASPHALT); + + // road grid: one strip on each line BETWEEN plot rows (and the outer ring). + // Plot centers sit at -half + k·CELL, so the streets run at the half-steps. + for (let k = 0; k <= grid; k++) { + const line = -half - CELL / 2 + k * CELL; + // road running along Z (constant x = line) + flatQuad(line - ROAD_W / 2, -ext, line + ROAD_W / 2, ext, 0.0, ROAD); + // road running along X (constant z = line) + flatQuad(-ext, line - ROAD_W / 2, ext, line + ROAD_W / 2, 0.01, ROAD); + } + + // streetlights lining every road, one per block (mid-block, between + // intersections). Each is a real (if low-poly) lamp: a thin dark POLE (added + // to the lit walls mesh so the moon catches it) topped by a small glowing + // HEAD (its own hot-warm emissive group, so the lamps pop and trace the + // streets from the air), plus a faint cast-light POOL on the asphalt below. + const lamp = glow[STREETLAMP_GROUP]; + const pool = glow[2]; // dim-warm group → subtle light cast on the road + const POLE_H = 2.6; // pole height (authored units) + const POLE_W = 0.12; // pole thickness + const HEAD = 0.34; // glowing lamp-head size + const POOL = 0.9; // cast-light pool diameter + const dropLamp = (x: number, z: number) => { + // pole (dark, lit) — no caps, it's thin + box(walls, x, z, 0, POLE_H, POLE_W, POLE_W); + // glowing head (emissive cube at the top, all faces so it reads from the + // air and the street) + box( + lamp, + x, + z, + POLE_H - HEAD * 0.5, + POLE_H + HEAD * 0.5, + HEAD, + HEAD, + true, + true, + ); + // faint pool of cast light on the road + quad( + pool, + x - POOL / 2, + 0.03, + z - POOL / 2, + POOL, + 0, + 0, + 0, + 0, + POOL, + 0, + 1, + 0, + ); + }; + for (let k = 0; k <= grid; k++) { + const line = -half - CELL / 2 + k * CELL; + for (let j = 0; j < grid; j++) { + const along = -half + j * CELL; // mid-block points + dropLamp(line, along); // along the Z-running road at x = line + dropLamp(along, line); // along the X-running road at z = line + } + } + + return { walls, ground, glow, radius: half + CELL, maxHeight }; +} diff --git a/packages/examples/src/main.tsx b/packages/examples/src/main.tsx index 176caa7d8..39569d89d 100644 --- a/packages/examples/src/main.tsx +++ b/packages/examples/src/main.tsx @@ -118,6 +118,11 @@ const ExampleGltfCharacter = lazy(() => default: m.ExampleGltfCharacter, })), ); +const ExampleNightCity = lazy(() => + import("./examples/nightcity/ExampleNightCity").then((m) => ({ + default: m.ExampleNightCity, + })), +); const ExampleMesh3d = lazy(() => import("./examples/mesh3d/ExampleMesh3d").then((m) => ({ default: m.ExampleMesh3d, @@ -376,7 +381,7 @@ const examples: { path: "gltf", sourceDir: "gltf", description: - "A Blender-authored scene (Kenney Platformer Kit, CC0) exported to GLB and loaded via the glTF Tier-1 importer — each node instantiated as a Mesh under a Camera3d.", + "A Blender-authored scene (Kenney Platformer Kit, CC0) exported to GLB.", }, { component: , @@ -384,7 +389,15 @@ const examples: { path: "gltf-character", sourceDir: "gltf", description: - "A rigged blocky character (Kenney Blocky Characters, CC0) loaded from GLB — node-TRS animation over a rigid hierarchy (walk, idle, sprint, …) driven through the Sprite-aligned setCurrentAnimation / play / pause / stop API.", + "A rigged blocky character (Kenney, CC0) loaded from GLB, using node-TRS animation over a rigid hierarchy.", + }, + { + component: , + label: "Night City Flythrough", + path: "night-city", + sourceDir: "nightcity", + description: + "A low-poly procedural downtown using emissive geometry to build a night-city view, with a looping Camera3d flythrough.", }, { component: , From fe7e217157bea409e68da3c7405b4a06451ef804 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Mon, 22 Jun 2026 12:38:29 +0800 Subject: [PATCH 09/10] fix(webgl): reset mesh uniform caches on shader swap; harden emissive parse Code-review follow-ups (PR #1506): - MeshBatcher.useShader override invalidates the per-mesh uAlphaCutoff / uEmissive caches when the bound GL program changes (mirrors the base batcher's currentSamplerUnit reset). Without it, a custom mesh shader declaring those uniforms, interleaved with the built-in shader on the same batcher, could be skipped via a stale cache and silently not apply the cutoff/emissive. - materialEmissive: guard a malformed (short) emissiveFactor with || 0 so a missing component can't become NaN and reach the uEmissive uniform (NaN !== 0 would dodge the all-zero collapse). +1 adversarial test. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/melonjs/src/loader/parsers/gltf.js | 10 +++++--- .../src/video/webgl/batchers/mesh_batcher.js | 23 +++++++++++++++++++ packages/melonjs/tests/gltf.spec.js | 9 ++++++++ 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/packages/melonjs/src/loader/parsers/gltf.js b/packages/melonjs/src/loader/parsers/gltf.js index 0b6483279..3d8bdea6a 100644 --- a/packages/melonjs/src/loader/parsers/gltf.js +++ b/packages/melonjs/src/loader/parsers/gltf.js @@ -600,9 +600,13 @@ export async function parseGLTF(arrayBuffer, baseURI, settings) { } const strength = mat.extensions?.KHR_materials_emissive_strength?.emissiveStrength ?? 1; - const r = f[0] * strength; - const g = f[1] * strength; - const b = f[2] * strength; + // `|| 0` guards a malformed (short) emissiveFactor: a missing component + // would otherwise be `undefined * strength = NaN`, and NaN reaches the + // `uEmissive` uniform (NaN !== 0, so the all-zero collapse below misses + // it) → NaN fragments. Valid glTF always has 3 components. + const r = (f[0] || 0) * strength; + const g = (f[1] || 0) * strength; + const b = (f[2] || 0) * strength; if (r === 0 && g === 0 && b === 0) { return undefined; } diff --git a/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js b/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js index 91d34e6b6..f74189a2a 100644 --- a/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js +++ b/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js @@ -195,6 +195,29 @@ export default class MeshBatcher extends MaterialBatcher { return { vertex: meshVertex, fragment: meshFragment }; } + /** + * Invalidate the per-mesh `uAlphaCutoff` / `uEmissive` caches when the bound + * shader (GL program) changes — the new program's uniforms are at their own + * defaults, so the next mesh must re-issue them rather than trust the cache. + * Mirrors the base batcher's `currentSamplerUnit` reset (same condition), and + * matters when a custom mesh shader declaring those uniforms is interleaved + * with the built-in one on this batcher (e.g. `drawMesh` swapping a custom + * shader in and back out). + * @ignore + */ + useShader(shader) { + if ( + this.currentShader !== shader || + this.renderer.currentProgram !== shader.program + ) { + this.currentAlphaCutoff = -1; + this.currentEmissiveR = -1; + this.currentEmissiveG = -1; + this.currentEmissiveB = -1; + } + super.useShader(shader); + } + /** * Unsubscribe the `RENDER_TARGET_CHANGED` listener so a discarded * batcher doesn't keep getting notified (relevant on context loss / diff --git a/packages/melonjs/tests/gltf.spec.js b/packages/melonjs/tests/gltf.spec.js index a1e9fb2aa..99bbaf085 100644 --- a/packages/melonjs/tests/gltf.spec.js +++ b/packages/melonjs/tests/gltf.spec.js @@ -1811,4 +1811,13 @@ describe("parseGLTF() — emissive", () => { const scene = await parseGLTF(buildSceneGLB()); expect(scene.nodes[0].emissive).toBeUndefined(); }); + + it("ADVERSARIAL: a malformed (short) emissiveFactor never yields NaN", async () => { + // a non-spec asset writing only 1 component must not produce a NaN that + // would reach the uEmissive uniform (NaN !== 0 dodges the zero-collapse) + const scene = await parseGLTF(buildMaterialGLB({ emissiveFactor: [1] })); + const e = scene.nodes[0].emissive; + expect(e).toEqual([1, 0, 0]); + expect(e.some((c) => Number.isNaN(c))).toBe(false); + }); }); From adf6d8d8cd34c40a191412015ad4bea330869c4b Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Mon, 22 Jun 2026 14:02:08 +0800 Subject: [PATCH 10/10] test: block-body arrow in gltf emissive test (eslint arrow-body-style) CI lint (eslint src tests) enforces arrow-body-style: always; the new malformed-emissiveFactor test used a concise arrow body. No logic change. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/melonjs/tests/gltf.spec.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/melonjs/tests/gltf.spec.js b/packages/melonjs/tests/gltf.spec.js index 99bbaf085..be9bc268e 100644 --- a/packages/melonjs/tests/gltf.spec.js +++ b/packages/melonjs/tests/gltf.spec.js @@ -1818,6 +1818,10 @@ describe("parseGLTF() — emissive", () => { const scene = await parseGLTF(buildMaterialGLB({ emissiveFactor: [1] })); const e = scene.nodes[0].emissive; expect(e).toEqual([1, 0, 0]); - expect(e.some((c) => Number.isNaN(c))).toBe(false); + expect( + e.some((c) => { + return Number.isNaN(c); + }), + ).toBe(false); }); });