Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/melonjs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
9 changes: 8 additions & 1 deletion packages/melonjs/src/level/gltf/GLTFModel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down
14 changes: 12 additions & 2 deletions packages/melonjs/src/level/gltf/GLTFScene.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
92 changes: 92 additions & 0 deletions packages/melonjs/src/loader/parsers/gltf.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand All @@ -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),
};
};

Expand Down
19 changes: 17 additions & 2 deletions packages/melonjs/src/loader/parsers/mtl.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { preloadImage } from "./image.js";
const SUPPORTED_PROPS = new Set([
"newmtl",
"Kd",
"Ke",
"d",
"Tr",
"map_Kd",
Expand All @@ -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",
Expand All @@ -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
Expand Down Expand Up @@ -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,
};
Expand All @@ -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]);
Expand Down
79 changes: 79 additions & 0 deletions packages/melonjs/src/renderable/mesh.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -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
*
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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;
}
}
}

Expand Down Expand Up @@ -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
Expand Down
Loading
Loading