-
-
Notifications
You must be signed in to change notification settings - Fork 663
3D Loading glTF and GLB Scenes
Part of Working in 3D.
glTF 2.0 is the open, web-native scene format — the "JPEG of 3D". Author a scene in Blender (or any DCC tool: Maya, 3ds Max, Cinema 4D, …), export .glb, and load it like a TMX map. Where an OBJ model is a single mesh, a glTF scene carries a node hierarchy, multiple meshes, materials, and a camera.
Requires melonJS 19.8+ (glTF/GLB scene loading via the level director). WebGL required, same as all 3D meshes (
renderer: video.WEBGL).
A glTF/GLB asset auto-registers with the level director on preload, so it loads into the world in one call — exactly like a Tiled map:
loader.preload([
{ name: "level", type: "glb", src: "models/level.glb" },
], () => {
level.load("level", {
scale: 32, // pixels per glTF unit (uniform scene scale)
// rightHanded: true // default for glTF — see "Coordinate system" below
onLoaded: () => {
// scene is now in game.world — frame the Camera3d, wire controls, …
},
});
});level.load(name, options) instantiates every mesh node as a Mesh in the target container (game.world by default), each carrying its own world transform, so the scene's layout and relative scale are preserved.
load option |
Default | Effect (glTF/GLB) |
|---|---|---|
scale |
1 |
Pixels per glTF unit, applied uniformly to the whole scene. |
rightHanded |
true |
Convert the Y-up right-handed source to the engine's Y-down by a rotation (not a mirror) — see Coordinate system. |
container |
game.world |
Where to instantiate the meshes. |
onLoaded |
game.onLevelLoaded |
Called once the scene is in the container. |
Each node's translation is carried by the mesh's pos/depth and its rotation/scale by currentTransform, so Camera3d frustum culling and depth-sorting see each mesh at its true world location. Per-node depth re-assignment is disabled (container.autoDepth = false) — the GPU depth test resolves occlusion between meshes. Double-sided glTF materials are honored (their meshes aren't back-face culled), and each mesh keeps its authored node.name for lookup.
View the scene under a Camera3d for a coherent perspective. See the glTF Scene example for a complete, orbit-controllable reference (Kenney Platformer Kit, CC0).
For camera framing or custom instantiation, loader.getGLTF(name) returns the parsed descriptor { nodes, cameras, lights, bounds, graph, animations } (or null if the asset failed to load) without touching the world:
-
nodes— one entry per mesh node: world transform (world, column-major 4×4), geometry (vertices/normals/uvs/indices/vertexCount), decoded baseColorimage,baseColorFactor, optional per-vertexcolors, texture wrap (textureRepeat), aname, and adoubleSidedflag. -
cameras— parsed camera nodes (world matrix + perspective params). -
lights— parsedKHR_lights_punctuallights (type,color,intensity, world-spacedirection/position). -
bounds—{ min, max }of the whole scene, handy for framing the camera before/afterlevel.load. -
graph— the full node hierarchy ({ roots, nodes }, keyed by glTF node index) used by the animated path. -
animations— parsed node-animation clips ({ name, duration, channels }); empty for a static scene.
const { bounds } = loader.getGLTF("level"); // frame the Camera3d to the sceneA mesh's color comes from up to three sources, which compose (factor × vertexColor × texel):
-
baseColorTexture— the material's texture (sampled per fragment). -
baseColorFactor— a solid color/tint on the material. An untextured material with just a factor renders that color (it's applied as the meshtint) instead of falling back to white. -
Vertex colors (
COLOR_0) — per-vertex colors (float or normalized byte/short, RGB or RGBA). Untextured vertex-colored meshes — MagicaVoxel exports, vertex-painted models — render correctly.
All three work under lighting. Only the RGB of baseColorFactor is used today.
The loader also honors these material flags automatically — author them in Blender and they carry over on level.load(...):
-
KHR_materials_unlit— the material bakes its own lighting and renders fullbright (not re-shaded), even in a lit scene. Avoids double-lighting for stylized / baked-lighting workflows. -
Alpha cutout (
alphaMode: "MASK") — fragments below the material'salphaCutoff(default0.5) are discarded, for crisp foliage / fences / chain-link / decals. No blending or sorting. Alpha blending (alphaMode: "BLEND") is not yet supported — those meshes render opaque. -
Emissive (
emissiveFactor, scaled byKHR_materials_emissive_strength) — a self-illumination color added on top, so neon / lava / screens glow regardless of scene lights. -
Texture filter — the sampler's
magFilterselects nearest (crisp pixel-art) vs linear (smooth) magnification.
Each is also a plain Mesh setting (alphaCutoff, emissive, textureFilter) you can set on a hand-built mesh — see Materials & textures for the full table and a snippet.
glTF is Y-up, right-handed; melonJS is Y-down. Pass rightHanded: true so the conversion is a rotation (negate Y and Z, determinant +1), which preserves orientation. Without it, the default Y-only flip is a reflection that mirrors the scene left ↔ right. Triangle winding is handled automatically either way.
The glTF camera node is parsed into scene.cameras (world matrix + yfov / aspect / near / far). You can derive a Camera3d pose — position, orientation, and FOV — from it to reproduce the framing the author set up.
| Feature | Supported |
|---|---|
Perspective camera data parsed (scene.cameras: world matrix + yfov/aspect/near/far) |
✅ |
Derive a Camera3d pose from the embedded camera |
✅ — you read scene.cameras and apply it |
| Orthographic cameras | ❌ |
Auto-instantiation / auto-wiring to the active Camera3d
|
❌ — you frame the camera yourself |
Honoring the authored FOV is optional and scene-dependent: a narrow authored FOV framed for a small set may not fit a much wider level within the far plane — use the camera's
yfovonly when it suits the scene, otherwise frame to the scenebounds.
If the scene carries KHR_lights_punctual directional lights (e.g. a Blender Sun), level.load(...) adds them to the world automatically — so the meshes are lit by the same sun the artist set up, no code required. Lit meshes use half-Lambert diffuse + a soft ambient fill for a stylized look.
level.load("level", { scale: 32 }); // authored Sun lights the sceneA Light3d is a world Renderable, managed exactly like a Light2d: add it to the world and it lights the scene; remove it to turn it off. There's no separate "environment" object.
import { Light3d } from "melonjs";
// add your own sun + a soft ambient fill (direction is the way light travels)
const sun = new Light3d({ direction: [0.4, 1, 0.3], color: "#fff", intensity: 1 });
app.world.addChild(sun);
app.world.addChild(new Light3d({ type: "ambient", color: "#404858", intensity: 1 }));Because a Light3d's fields are mutable and the lit shader re-reads the active lights every frame, animating one is just mutating it — e.g. a day/night cycle that swings the sun across the sky and shifts its color. Drive it from any update(dt) (here a tiny controller renderable added to the world):
import { Light3d, Renderable, Color, math } from "melonjs";
class DayNight extends Renderable {
constructor(sun) {
super(0, 0, 0, 0);
this.sun = sun;
this.t = 0;
this.alwaysUpdate = true; // keep ticking even when off-screen
this.noon = new Color(255, 244, 214); // warm midday
this.dusk = new Color(255, 120, 60); // orange low sun
}
update(dt) {
this.t += dt / 1000;
const a = this.t * 0.3; // ~20s per full sweep
// swing the sun from horizon to overhead to the far horizon
this.sun.direction.set(Math.cos(a), Math.max(0.15, Math.sin(a)), 0.3).normalize();
// redden it near the horizon (low sun), warm-white at noon
const k = Math.max(0, Math.sin(a)); // 0 at horizon → 1 at noon
this.sun.color.setColor(
math.lerp(this.dusk.r, this.noon.r, k),
math.lerp(this.dusk.g, this.noon.g, k),
math.lerp(this.dusk.b, this.noon.b, k),
);
this.sun.intensity = 0.4 + 0.6 * k; // dimmer at dawn/dusk
return true;
}
}
// after level.load(...): grab the auto-loaded sun (or add your own) and animate it
const sun = app.world.getChildByType(Light3d).find((l) => l.type === "directional");
app.world.addChild(new DayNight(sun));The same pattern fades lights in/out (light.intensity), recolors them, or moves a (future) point light by mutating light.pos.
How it fits together:
-
Light3d— a worldRenderablelight."directional"(a sun: only itsdirectionmatters — rotating it changes the lighting angle) and"ambient"(a flat fill,directionignored). Add/remove it from the world to enable/disable it. -
mesh.lit— only meshes flaggedlitgo through the lighting path (a dedicated lit batcher). The loader sets it on scene meshes when the scene has lights; standalone unlit meshes stay on the lean path and pay nothing for lighting. With no directional lights active, everything renders fullbright.
| Feature | Supported |
|---|---|
| Directional lights (parsed + shaded), auto-loaded from the scene | ✅ |
Ambient fill (Light3d type: "ambient") |
✅ |
| Per-vertex normals (read or synthesized) + half-Lambert | ✅ |
Runtime light control (add / remove / animate Light3d in the world) |
✅ |
| Point / spot light shading | ❌ — parsed into scene.lights, not shaded |
| Shadows | ❌ |
| Normal / PBR maps | ❌ |
Notes: pass
{ lights: false }tolevel.load(...)to skip auto-lighting and manage it yourself. glTF directional intensity is authored in lux (often thousands) — not meaningful for stylized diffuse, so it's normalized to a unit intensity; tunelight.intensityafterwards.KHR_lights_punctualhas no ambient light type, so the loader adds a soft ambientLight3ditself; add/adjust your ownnew Light3d({ type: "ambient", ... })to taste.
If the asset defines animation channels, level.load(...) loads it as a single GLTFModel instead of flat meshes — a container that keeps the node hierarchy intact so an animated parent carries its children (rotate a character's torso and its arm/head follow). Retrieve it from the world by the asset name and play clips with the same API as a 2D Sprite:
level.load("hero", { scale: 200 });
const hero = game.world.getChildByName("hero")[0]; // the GLTFModel
hero.getAnimationNames(); // ["idle", "walk", "sprint", ...]
hero.play("walk"); // switch to + play (shorthand for setCurrentAnimation)
hero.setCurrentAnimation("die", { loop: false }); // play once, hold the last pose
hero.setCurrentAnimation("jump", { next: "idle" }); // jump, then idle
hero.animationspeed = 1.5; // playback multiplier
hero.pause(); // freeze · hero.play() resumes
hero.stop(); // reset to the bind poseThis is node/TRS animation (rigid body parts, no vertex skinning): walking modular characters, spinning pickups, doors, lifts. LERP / SLERP / STEP interpolation; CUBICSPLINE uses keyframe values. See Loading & supported 3D assets → Animation for the full support matrix. Skeletal/skinned animation is not supported yet.
| Feature | Supported |
|---|---|
.glb (binary, self-contained) |
✅ |
.gltf with embedded / data-URI buffers & images |
✅ |
External .bin + image files referenced by relative uri (resolved against the asset) |
✅ |
| Draco / Meshopt compression | ❌ |
For what's supported in terms of geometry and materials once loaded, see Loading & supported 3D assets (those limits apply to all 3D meshes, glTF included). Current glTF limits: directional lights only (point/spot are parsed but not shaded), no PBR maps or shadows, alpha cutout (alphaMode: "MASK") is supported but alpha blending ("BLEND") is not, and no skeletal/skinned animation (node animation is supported).