Skip to content

3D Loading and Supported Assets

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

Part of Working in 3D.

3D geometry loads through the standard asset pipeline (loader), the same as images, audio, or Tiled maps. Two formats are supported:

Format Asset type Best for
Wavefront OBJ/MTL "obj" + "mtl" a single static model (prop, character billboard)
glTF 2.0 / GLB "gltf" / "glb" a whole authored scene — node hierarchy, multiple meshes, a camera (see Loading glTF / GLB scenes)

WebGL required. 3D meshes render only under the WebGL renderer (renderer: video.WEBGL). The Canvas2D renderer has no mesh path.

Loading an OBJ model

loader.preload([
  { name: "fox",    type: "obj",   src: "models/fox.obj" },
  { name: "fox",    type: "mtl",   src: "models/fox.mtl" },
  { name: "foxtex", type: "image", src: "models/fox.png" },
], () => {
  const mesh = new Mesh(400, 300, {
    model: "fox", material: "fox", texture: "foxtex",
    width: 200, height: 200,
  });
  game.world.addChild(mesh);
});

loader.getOBJ(name) / getMTL(name) return the raw parsed data if you need it directly. Multi-material OBJ files (multiple usemtl groups) are supported — each group's diffuse color is baked into a per-vertex color buffer, so multiple materials don't cost extra draw calls.

For importing a full scene (many meshes + hierarchy + camera) rather than a single model, see Loading glTF / GLB scenes.

Current support & limitations

The following applies to all 3D meshes regardless of source format.

Geometry

Feature Supported
Positions, UVs (TEXCOORD_0), indexed triangles
Per-vertex normals (NORMAL, or synthesized when absent)
Node hierarchy + per-node TRS (glTF) → Matrix3d
Per-vertex colors (COLOR_0, float / normalized byte/short)
Uint32 index buffers (meshes > 65 535 vertices)
Multi-material submeshes (OBJ usemtl groups)
Vertex tangents (TANGENT) ❌ — attribute ignored
Multiple UV sets (TEXCOORD_1+)
Sparse accessors
Draco / Meshopt compression

Materials & textures

Feature Supported
One diffuse / baseColor texture per material
Diffuse color (Kd / baseColorFactor RGB) as tint
Texture wrap from the sampler (wrapS / wrapT) ✅ — default REPEAT
Texture magnification filter from the sampler (magFilter → nearest / linear) ✅ — pixel-art renders crisp
External textures & buffers (image / .bin files referenced by relative uri)
Per-vertex tint multiply ✅ — WebGL (Canvas uses a solid per-triangle fill)
Material opacity (OBJ d)
Unlit materials (KHR_materials_unlit) ✅ — rendered fullbright, not re-shaded
Alpha cutout (glTF alphaMode: "MASK" + alphaCutoff) ✅ — hard-edged, no sorting
Emissive color (glTF emissiveFactor × KHR_materials_emissive_strength / OBJ Ke) ✅ — self-illumination
Alpha blending (glTF alphaMode: "BLEND", baseColorFactor.a) ❌ — meshes render opaque
Metallic-roughness / normal / emissive / occlusion (AO) textures ❌ — emissive/AO etc. as maps
Full PBR shading
Mirrored-repeat wrap ❌ — falls back to repeat

These material settings are read from the glTF/MTL material automatically when you level.load(...) a scene — author them in Blender and they "just work". They're also plain Mesh settings, so you can set them yourself on a hand-built mesh:

const sign = new Mesh(0, 0, {
  vertices, uvs, indices, texture: "neon-sign",
  width: 64, normalize: false,

  textureFilter: "nearest",   // crisp pixel-art upscaling (vs "linear")
  alphaCutoff: 0.5,           // discard texels below 0.5 alpha (cutout)
  emissive: [0.9, 0.2, 0.6],  // glows magenta regardless of scene lights
});
  • textureFilter"nearest" for pixel-art, "linear" for smooth. Omit to keep the renderer's global antiAlias default.
  • alphaCutoff — fragments below this alpha are discarded (hard cutout for foliage / fences / chain-link / decals). 0 (default) disables it. No blending or back-to-front sorting needed.
  • emissive — an [r, g, b] self-illumination color added on top of the lit/unlit result (neon, lava, screens, glowing eyes). May exceed 1 for HDR glow. Omit / all-zero for none.

All three are WebGL-only and unlit materials skip lighting via the engine's lit/unlit batcher split — standalone unlit meshes pay nothing for lighting.

Animation

glTF node animation is supported: keyframed translation / rotation / scale, sampled and re-posed every frame over the node hierarchy (a parent transform carries its children). This drives rigid rigs — walking characters made of separate body parts, spinning pickups, opening doors, moving platforms. It plays through the same API as a 2D Sprite (see below).

Feature Supported
glTF node animation (keyframed TRS channels) ✅ — LERP / SLERP / STEP
Hierarchical playback (an animated parent moves its children)
CUBICSPLINE channels ✅ — keyframe values (tangents approximated)
Transform animation you drive yourself (mesh.rotate() / translate(), tweens)
Skeletal animation / skinning (vertex-deforming bones) ❌ — imports at rest pose
Morph targets (blend shapes)

Rigid (modular) characters like Kenney's Blocky Characters animate this way — each limb is a separate mesh node rotated about its joint, no skinning. Rigged/skinned characters (a single mesh deformed by an armature) import at rest pose for now. For 2D skeletal animation, use the Spine plugin.

Playing an animation

When a glTF asset defines animation channels, it loads as a single GLTFModel — a container that keeps the node hierarchy intact and drives playback. Retrieve it from the world by the asset name, then use the Sprite-style API:

loader.preload(
  [{ name: "hero", type: "glb", src: "models/hero.glb" }],
  () => {
    level.load("hero", { scale: 200 });

    // the animated asset loads as one GLTFModel, named after the asset
    const hero = game.world.getChildByName("hero")[0];

    hero.getAnimationNames();          // ["idle", "walk", "sprint", ...]
    hero.setCurrentAnimation("walk");  // loop forever
    hero.play("walk");                 // ...or the shorthand
  },
);

The second argument matches Sprite.setCurrentAnimation — an options object, a clip name to chain to, or a completion callback:

hero.setCurrentAnimation("die", { loop: false });          // play once, hold the last pose
hero.setCurrentAnimation("jump", { next: "idle" });        // jump, then return to idle
hero.setCurrentAnimation("walk", { speed: 2 });            // twice as fast
hero.setCurrentAnimation("pickup", { onComplete: grab });  // callback each cycle

hero.animationspeed = 0.5;  // playback multiplier (1 = authored speed)
hero.pause();               // freeze on the current pose
hero.play();                // resume
hero.stop();                // stop and reset to the bind pose

The same API works on a 2D Sprite, so 2D and 3D animation read identically.

Lighting & shadows

Directional + ambient lighting is supported for 3D meshes via Light3d — a world Renderable managed exactly like Light2d: app.world.addChild(new Light3d({ direction, color, intensity })). Half-Lambert diffuse + a soft ambient fill (new Light3d({ type: "ambient", ... })). A glTF scene's authored KHR_lights_punctual directional lights (plus an ambient fill) are added automatically. Lights are mutable, so they can be animated in-game (day/night). See Lighting (with an in-game day/night example) and Working in 3D → Meshes. With no directional lights active, meshes render fullbright (texture × tint), so existing scenes are unaffected.

Not yet: point/spot light shading (parsed, not shaded), shadows, alpha blending (only the hard MASK cutout is supported), and texture maps beyond baseColor (normal / emissive / metallic-roughness / AO).

Clone this wiki locally