Skip to content

Working in 3D

Olivier Biot edited this page Jun 18, 2026 · 9 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.

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

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