-
-
Notifications
You must be signed in to change notification settings - Fork 662
3D Loading and Supported Assets
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.
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.
The following applies to all 3D meshes regardless of source format.
| 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 | ❌ |
| 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 globalantiAliasdefault. -
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 exceed1for 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.
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.
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 poseThe same API works on a 2D Sprite, so 2D and 3D animation read identically.
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).