Skip to content

3D Loading glTF and GLB Scenes

Olivier Biot edited this page Jun 20, 2026 · 9 revisions

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).

Loading a scene

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).

Inspecting a scene with getGLTF

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 baseColor image, baseColorFactor, optional per-vertex colors, texture wrap (textureRepeat), a name, and a doubleSided flag.
  • cameras — parsed camera nodes (world matrix + perspective params).
  • lights — parsed KHR_lights_punctual lights (type, color, intensity, world-space direction/position).
  • bounds{ min, max } of the whole scene, handy for framing the camera before/after level.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 scene

Materials & colors

A 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 mesh tint) 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's alphaCutoff (default 0.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 by KHR_materials_emissive_strength) — a self-illumination color added on top, so neon / lava / screens glow regardless of scene lights.
  • Texture filter — the sampler's magFilter selects 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.

Coordinate system (rightHanded)

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.

Camera

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 yfov only when it suits the scene, otherwise frame to the scene bounds.

Lighting

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 scene

A 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 }));

Manipulating a light in-game

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 world Renderable light. "directional" (a sun: only its direction matters — rotating it changes the lighting angle) and "ambient" (a flat fill, direction ignored). Add/remove it from the world to enable/disable it.
  • mesh.lit — only meshes flagged lit go 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 } to level.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; tune light.intensity afterwards. KHR_lights_punctual has no ambient light type, so the loader adds a soft ambient Light3d itself; add/adjust your own new Light3d({ type: "ambient", ... }) to taste.

Animation

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 pose

This 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.

Files & packaging

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).

Clone this wiki locally