Skip to content

3D Loading glTF and GLB Scenes

Olivier Biot edited this page Aug 8, 2026 · 11 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). GPU backend required, same as all 3D meshes — WebGL 2 or WebGPU; the default video.AUTO picks the best one (gate on renderer.supportsDepthBuffer).

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.
lights true Instantiate the scene's authored KHR_lights_punctual lights (directional, point, spot — plus a soft ambient fill) as Light3d world children. false to manage lighting yourself.
lightIntensityScale (unset) Multiply each light's authored physical intensity (lux/candela) by this factor instead of normalizing it to 1 — e.g. 0.001 maps a 1000-lux Blender sun to 1 while keeping the authored ratios between lights.

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, sampler-derived textureRepeat / textureFilter, alphaCutoff (alphaMode MASK), emissive, unlit (KHR_materials_unlit), a name, and a doubleSided flag.
  • cameras — parsed camera nodes (world matrix + perspective params).
  • lights — parsed KHR_lights_punctual lights (type, color, intensity, range, spot cone angles, world-space direction/position, name).
  • 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(...): every instantiated light carries its authored name —
// grab the Blender sun directly and animate it
const sun = app.world.getChildByName("Sun")[0];
app.world.addChild(new DayNight(sun));

The same pattern fades lights in/out (light.intensity), recolors them, or moves a point/spot lamp by mutating light.position.

How it fits together:

  • Light3d — a world Renderable light. Four types: "directional" (a sun: only its direction matters — rotating it changes the lighting angle), "ambient" (a flat fill), "point" (a lamp with quadratic falloff over range) and "spot" (a cone with innerConeAngle/outerConeAngle). Add/remove it from the world to enable/disable it — see Working in 3D → Lighting.
  • 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 carries any shading-capable light (directional, point or spot); standalone unlit meshes stay on the lean path and pay nothing for lighting. With no lights active at all, lit meshes render fullbright.
Feature Supported
Directional lights (parsed + shaded), auto-loaded from the scene
Point / spot lights (parsed + shaded, scene-scaled position/range, spot cones) ✅ since 20.0
Ambient fill (Light3d type: "ambient")
Per-vertex normals (read or synthesized) + half-Lambert
Runtime light control (add / remove / animate Light3d in the world)
Authored light names carried onto Light3d (getChildByName("Sun")) ✅ since 20.0
Ground (blob) shadows, scene-wide via castGroundShadow ✅ since 20.0
Real shadow mapping (objects shadowing each other)
Normal / PBR maps

Ground shadows

level.load(...) gives a whole scene blob shadows in one option:

level.load("diorama", {
  scale: 32,
  castGroundShadow: true,   // every prop in the scene casts
  shadowGroundY: 0,         // optional: the floor's world Y
});

Ground shadows are on by default application-wide (the castGroundShadow application setting), so a glTF scene under a Camera3d already has them — this option overrides that setting for one scene, in either direction.

Omit shadowGroundY and each blob sits at its own object's base at full strength, which is right for a diorama whose props rest on platforms at several different heights. Give it, and shadows shrink and fade with height above that floor — what reads as a jump.

As a scene-wide opt-in it skips nodes with no vertical extent: a scene ships its ground as a flat plane, and shadowing that with itself would smear a blob across the whole floor. A per-object mesh.castGroundShadow = true is an explicit instruction and bypasses that. An instanced node (EXT_mesh_gpu_instancing) costs one extra draw for the entire scatter, however many copies it holds.

See Working in 3D → Ground shadows.

Notes: pass { lights: false } to level.load(...) to skip auto-lighting and manage it yourself. glTF intensities are authored in physical units — lux for suns, candela for lamps (often thousands) — not meaningful for stylized diffuse, so they're normalized to a unit intensity by default; tune light.intensity afterwards, or pass lightIntensityScale to level.load(...) to keep the authored ratios at a chosen scale (e.g. 0.001). 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: no PBR maps and no real shadow mapping (ground shadows are supported — see below), alpha cutout (alphaMode: "MASK") is supported but alpha blending ("BLEND") is not, and no skeletal/skinned animation (node animation is supported). Directional, point and spot lights are all parsed and shaded since 20.0.

Clone this wiki locally