Skip to content

Working in 3D

Olivier Biot edited this page Jun 23, 2026 · 15 revisions

Since 19.7, melonJS ships a perspective 3D camera, 3D meshes, 3D spatial queries, and frame-rate-independent smoothing helpers — enough surface to build a full behind-the-plane arcade shooter (AfterBurner showcase) without leaving the engine.

All the 2D conventions you already know — Renderable, Container, addChild, pointer events, audio, particle emitters, tweens — still work. 3D mode just opens up a perspective projection, a depth-sorted scene graph, and 3D spatial queries on top.

In this section

Getting Started

Opt in with one option at Application construction:

import { Application, Camera3d } from "melonjs";

const app = new Application(1024, 768, {
  parent: "screen",
  cameraClass: Camera3d,   // ← opt in
});

That's it. The world's depth sort flips to "depth", the broadphase switches to a 3D index, sprites + meshes are rendered with perspective projection. Existing 2D code keeps working — Camera3d extends Camera2d, so follow, fade, shake, and post-effects all carry over.

Per-stage opt-in works too (useful when the loading screen is 2D but the game stage is 3D):

class GameStage extends Stage {
  constructor() {
    super({
      cameras: [new Camera3d(0, 0, 1024, 768, { fov: Math.PI / 3 })],
    });
  }
}

Conventions

Camera3d follows melonJS's existing 2D conventions, just extended into the third axis:

  • Y-down. A sprite at higher pos.y appears lower on screen — same as Camera2d.
  • +Z forward. A sprite at higher pos.z is farther from the camera (smaller after projection).
  • Rotations are extrinsic XYZ: camera.pitch (look up/down), camera.yaw (look left/right), camera.roll (screen-plane bank).

This is the inverse of OpenGL's Y-up + −Z forward, but it means Camera2d code translates directly: anywhere you used pos.x / pos.y, you can add pos.z and the math still works.

Note: imported glTF assets are authored Y-up / right-handed and are converted on load — see Loading glTF / GLB scenes.

The Camera

Camera3d is the perspective sibling of Camera2d. Fields you'll set:

Field Default What it does
fov Math.PI / 3 (60°) Vertical field of view. Higher = wider lens, more fish-eye.
aspect viewport w / h Aspect ratio. Auto-derives on resize().
near / far 0.1 / 1000 Clip planes. Anything past far projects with bad w-divides — push it out if your scene is deep. Use camera.setClipPlanes(near, far).
pitch / yaw 0 Look-up/down, look-left/right (radians).
pos (0, 0, 0) Camera world position.

Camera2d.follow() works on Camera3d too — pass a 3D target and a follow axis, the camera tracks it. For a third-person "always behind" cam, set pos directly each frame instead of follow:

update(dt) {
  this.camera.pos.set(
    this.player.pos.x * 0.3,                                // loose XY follow
    this.player.pos.y * 0.3 - 80,
    this.player.depth - 350,                                // sit behind the player
  );
  this.camera.pitch = (-this.player.pos.y / PLAY_BOUND_Y) * MAX_PITCH;
  this.camera.yaw   = ( this.player.pos.x / PLAY_BOUND_X) * MAX_YAW;
}

3D Rendering

Meshes

Mesh renders 3D geometry (from an OBJ model or raw vertex data) with an optional material and a single texture binding:

import { Mesh } from "melonjs";

const ship = new Mesh(0, 0, {
  model: "ship",      // preloaded OBJ (see "Loading & supported 3D assets")
  material: "ship",   // preloaded MTL
  texture: "shiptex",
  width: 60, height: 60,
});
app.world.addChild(ship, 200);   // z = 200

Under Camera3d the mesh is projected with the perspective matrix; under Camera2d the mesh renders ortho (no foreshortening) — same code, the camera decides. Multi-material OBJ files are supported: Kd colors from the MTL are baked into a per-vertex color buffer at construction so multiple materials don't cost extra draw calls. A single texture per mesh applies on top.

Pivot — a mesh transforms about its local origin (0, 0, 0)

A Sprite has no intrinsic coordinates, so its anchorPoint recenters a width×height box (0.5, 0.5 = center). A Mesh has real vertex coordinates, so it needs no such box: rotation and scale pivot around the model origin (0, 0, 0) the geometry was authored against — the standard convention for 3D meshes, which don't have an anchor.

  • Put the origin where you want the pivot at export time (e.g. a character's feet, a wheel's hub).
  • To pivot somewhere else, nest the mesh under a parent and transform the parent.

Because of this, a mesh on the Camera3d (world-space) path ignores anchorPoint — it sets applyAnchorTransform = false so the normalized box offset can't leak into placement. The legacy 2D mesh path still honors anchorPoint. You normally don't touch any of this; it just means an imported scene's props sit exactly where the authoring tool put them.

See Loading & supported 3D assets for how to preload geometry, and Loading glTF / GLB scenes to import a whole authored scene.

Texture filtering

WebGL / 3D feature. This is part of the 3D (WebGL) rendering path. The 2D Canvas renderer has no per-texture filtering — it only has a single global image-smoothing toggle driven by antiAlias — so the controls below apply when rendering under WebGL.

How a texture is sampled when scaled — nearest (crisp, pixel-art) vs linear (smooth) — is controlled separately from polygon-edge antialiasing (MSAA). A single antiAlias flag would conflate the two; melonJS keeps them independent so you can mix them freely:

// smooth textures, but NO polygon-edge MSAA
const app = new Application(1024, 768, {
  renderer: video.WEBGL,
  antiAlias: false,        // MSAA off
  textureFilter: "linear", // textures still sampled smooth
});

// crisp pixel-art textures WITH MSAA-smoothed silhouettes
new Application(1024, 768, {
  renderer: video.WEBGL,
  antiAlias: true,          // MSAA on
  textureFilter: "nearest", // textures stay crisp
});

textureFilter is the global default for every texture:

textureFilter effect
"auto" (default) follow antiAliaslinear when true, nearest when false
"nearest" crisp, regardless of antiAlias
"linear" smooth, regardless of antiAlias

A Mesh overrides it per-mesh with its own textureFilter (e.g. a pixel-art model in an otherwise-smooth scene stays crisp):

const sign = new Mesh(0, 0, {
  vertices, uvs, indices, texture: "neon-sign",
  width: 64, normalize: false,
  textureFilter: "nearest", // wins over the global default for this mesh
});

The glTF loader sets this per-mesh automatically from each material's sampler magFilter, so pixel-art-textured imported models render crisp without any extra wiring. You can also change the global default at runtime with app.renderer.setTextureFilter("nearest" | "linear" | "auto").

This differs from the 2D path on purpose: 2D sprites under WebGL still pick up the global textureFilter, but there's no 2D Canvas equivalent of the per-mesh override, and the Canvas renderer ignores textureFilter entirely (it has only the antiAlias-driven global smoothing).

Sprites + Depth

Regular Sprite and Renderable work in 3D unchanged. Their pos.z (also accessible as .depth) feeds the depth-sort painter's algorithm, so a sprite at higher z draws before one at lower z — i.e., farther sprites render first. Use addChild(child, z) to set the depth atomically at insertion:

app.world.addChild(bullet, PLAYER_Z + 40);   // pos.z = 240 atomically

Sprite3d — billboards & animated sprites

Sprite3d is the 3D counterpart of Sprite: a textured quad that lives in the 3D scene and renders under Camera3d. It's the workhorse for the 2.5D look — characters, pickups, foliage, signs, particles — a flat sprite placed in a perspective world. Being a thin Mesh subclass, it rides the same world-space pipeline (depth testing, frustum culling) and the same material flags (lit, emissive, alphaCutoff).

Its headline feature is billboarding — keeping the quad facing the camera as the camera orbits:

billboard behavior use for
false (default) fixed orientation — a flat quad in the XY plane decals, posters, ground markers
true / "cylindrical" faces the camera but stays upright (rotates only around the world up axis) trees, characters, items — the 2.5D default
"spherical" faces the camera on all axes particles, glints, soft sprites
import { Sprite3d } from "melonjs";

// a tree that always faces the camera but stays upright. Its texture has a
// transparent background — the mesh pass is opaque, so the alpha cutout
// (alphaCutoff, default 0.5) discards those texels for a clean silhouette.
const tree = new Sprite3d(0, 0, {
  image: "tree",        // a preloaded image with transparency
  width: 64, height: 96,
  z: -200,              // 3D depth (world z)
  billboard: true,      // = "cylindrical"
  // alphaCutoff: 0.5,  // the default — lower for softer edges, 0 = fully opaque
});

// add it to the game world, exactly like any other Renderable
app.world.addChild(tree);

Billboarding only applies under a Camera3d — under a 2D Camera2d there's no camera orientation to face, so use a regular Sprite for 2D. The quad's "up" follows the same convention as a loaded glTF scene, so billboards and imported meshes compose without flipping.

Frame animation works through the exact same API as Sprite — pass framewidth/frameheight for a spritesheet (or a packed TextureAtlas), then drive it with addAnimation / setCurrentAnimation / play / pause / stop. The current frame is mapped onto the quad's texture coordinates each step:

const coin = new Sprite3d(0, 0, {
  image: "coins",
  framewidth: 32, frameheight: 32,
  width: 48, height: 48,
  billboard: "spherical",
  alphaCutoff: 0.5,     // cut out the transparent frame background (the default)
});
coin.addAnimation("spin", [0, 1, 2, 3, 4, 5]);
coin.setCurrentAnimation("spin");
coin.flipX();           // mirror it (e.g. to face the other way)
app.world.addChild(coin);

Both Sprite and Sprite3d share one frame-animation engine (FrameAnimation), so timing, looping ({ loop: false }), chaining ({ next: "idle" }), completion callbacks ({ onComplete }) and the { speed } multiplier behave identically in 2D and 3D. Packed TextureAtlas regions — including rotated and trimmed frames — are mapped onto the quad at full parity with the 2D Sprite.

Transparency. The 3D mesh pass is opaque (no alpha blending), so Sprite3d uses an alpha cutout for transparency: alphaCutoff defaults to 0.5, discarding a sprite's transparent background for a clean silhouette with correct depth and no back-to-front sorting. Pass alphaCutoff: 0 for a fully-opaque quad, or tune the threshold. (Smooth alpha blending — e.g. soft particles — isn't part of the opaque mesh pass.)

Flipping. flipX() / flipY() (and flipX / flipY settings) mirror the sprite — e.g. to face a character left or right. The flip mirrors the quad's local geometry, so it works in every billboard mode and for rotated/trimmed atlas frames alike.

The Billboard Sprites example shows all three modes side by side under an orbiting/pitching/dollying camera (no external assets — textures are baked at runtime).

Light2d caveat

Light2d is 2D-only and produces visible artifacts under perspective projection. Avoid combining it with Camera3d for now. (3D meshes are unlit — see supported assets.)

Smooth Follow + Damping

math.damp is the frame-rate-independent way to smoothly chase a target value. Use it for camera follow, mouse smoothing, value tracking — anywhere you've reached for lerp(x, target, alpha) per frame and noticed the feel changes between 60 and 30 fps.

import { math } from "melonjs";

const dts = dt / 1000;   // engine `dt` is ms; damp wants seconds
this.playerRoll  = math.damp(this.playerRoll,  targetRoll,  decay, dts);
this.playerPitch = math.damp(this.playerPitch, targetPitch, decay, dts);

Vector overrides for camera position smoothing:

camera.pos.damp(target.pos, 5, dt / 1000);

damp(current, target, lambda, dt) produces the same convergence after the same elapsed time regardless of how dt was split across frames. lambda is the decay rate in 1/seconds — 5 gets to ≈99% of the target in one second, 10 in ≈0.5s.

math.lerp(a, b, t) is also exposed as the scalar primitive (vectors already had it). Use plain lerp only when you want a parametric "X% of the way from A to B" — not for per-frame smooth follow, since the feel changes with frame rate.

Example

The AfterBurner showcase is the full reference scene exercising every 3D feature: Camera3d behind-the-player chase, multi-material OBJ jets, Sprite bullets with depth, broadphase-driven bullet × enemy collision via world.adapter.querySphere, frame-rate-independent bank/pitch smoothing via math.damp, procedural audio, particles, and Tween-driven enemy barrel rolls. Source is under packages/examples/src/examples/afterBurner/ in the monorepo.

The glTF Scene example demonstrates importing a Blender-authored scene (Kenney Platformer Kit, CC0) — see Loading glTF / GLB scenes.

Clone this wiki locally