diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md index 912fb9476..88056d2bc 100644 --- a/packages/melonjs/CHANGELOG.md +++ b/packages/melonjs/CHANGELOG.md @@ -11,8 +11,12 @@ - **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. +- **`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 2254d7418..c27da66b9 100644 --- a/packages/melonjs/src/level/gltf/GLTFModel.js +++ b/packages/melonjs/src/level/gltf/GLTFModel.js @@ -133,10 +133,17 @@ 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, + // honor the glTF sampler magnification filter (nearest = pixel-art) + 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 97682b290..ecfaad8b9 100644 --- a/packages/melonjs/src/level/gltf/GLTFScene.js +++ b/packages/melonjs/src/level/gltf/GLTFScene.js @@ -144,8 +144,18 @@ 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, + // 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, + // 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) + 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..0b6483279 100644 --- a/packages/melonjs/src/loader/parsers/gltf.js +++ b/packages/melonjs/src/loader/parsers/gltf.js @@ -529,6 +529,86 @@ 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 + ); + }; + + // 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; + }; + + // 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; + }; + + // 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 @@ -590,6 +670,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), @@ -601,6 +685,14 @@ 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), + // 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 cff15b13a..cae317ea5 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 @@ -117,6 +138,9 @@ 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). + * @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, { @@ -146,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 * @@ -261,6 +295,31 @@ 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; + + /** + * 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} @@ -412,6 +471,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; + } } } @@ -445,6 +510,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/src/video/webgl/batchers/mesh_batcher.js b/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js index e5cb404e3..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 @@ -90,6 +94,17 @@ 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; + + // 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 @@ -256,6 +271,40 @@ 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; + } + + // 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 543629d36..5bbe19810 100644 --- a/packages/melonjs/src/video/webgl/shaders/mesh-lit.frag +++ b/packages/melonjs/src/video/webgl/shaders/mesh-lit.frag @@ -8,6 +8,8 @@ // preprocessor regardless of how it handles macros.) 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) @@ -21,6 +23,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++) { @@ -32,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 767b89a70..47902557f 100644 --- a/packages/melonjs/src/video/webgl/shaders/mesh.frag +++ b/packages/melonjs/src/video/webgl/shaders/mesh.frag @@ -1,7 +1,17 @@ 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; 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; + } + // 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 298da4363..a1e9fb2aa 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 @@ -1053,6 +1072,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 +1647,168 @@ describe("parseGLTF() — texture wrap mode", () => { expect(scene.nodes[0].textureRepeat).toBe("repeat"); }); }); + +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 +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); + }); +}); + +// ── 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); + }); +}); + +// ── 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 6deb5b0b5..5fa691790 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,144 @@ 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) + }); + }); + + // ────────────────────────────────────────────────────────────────────── + // 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 + }); + }); });