Skip to content

Working in 3D

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

This page covers what you need to opt in and what's available. 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.

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.

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 an OBJ file with per-vertex normals, optional MTL material, and a single texture binding:

import { Mesh } from "melonjs";

const ship = new Mesh(0, 0, {
  obj: "ship.obj",   // preloaded via loader
  mtl: "ship.mtl",
  size: 60,          // bounding-box scale; mesh fits in a 60-unit cube
});
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.

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 Spatial Queries

For "what entities are near me / in front of me / on the camera frustum", melonJS exposes a 3D spatial-query surface through the physics adapter:

const candidates = world.adapter.querySphere?.(sphere) ?? [];

Sphere

new Sphere(x, y, z, radius) is the geometry primitive for sphere queries and arcade 3D collision:

import { Sphere } from "melonjs";

const probe = new Sphere(0, 0, 0, 30);
probe.contains(p);              // point-in-sphere
probe.overlaps(other);          // sphere-vs-sphere
probe.overlapsAABB(aabb);       // sphere-vs-AABB
probe.setShape(x, y, z, r);     // reposition + resize in place (no alloc)

world.adapter.querySphere(...)

Returns every renderable within the given sphere. Two call shapes — pick whichever reads better at the call site:

// Packaged form — sphere maintained alongside the entity
this.collider.pos.set(this.pos.x, this.pos.y, this.depth);
const nearby = world.adapter.querySphere?.(this.collider) ?? [];

// Loose form — one-off query
const nearby = world.adapter.querySphere?.(centerVec3, HIT_RADIUS) ?? [];

Use it as a broadphase. The result is every renderable whose centre falls inside the sphere — your code does the per-entity narrow phase (kind filter, exact bounding-sphere test, etc.). On 2D adapters (matter, planck) the optional method is absent, so the ?? [] fallback runs.

Gotcha: Sprite defaults isKinematic = true, which means the broadphase skips it. Flip to false at spawn for any entity you want findable via querySphere:

bullet.isKinematic = false;

world.adapter.raycast3d?(from, to)

3D ray cast. Returns the nearest RaycastHit3d (with renderable, point: Vector3d, normal: Vector3d, fraction) or null. Each renderable is treated as a bounding sphere using the circumradius of its 2D bounds.

const hit = world.adapter.raycast3d?.(eye.pos, target.pos);
if (hit) {
  console.log("hit", hit.renderable, "at fraction", hit.fraction);
}

Capability-gated by adapter.capabilities.raycasts3d. Builtin adapter supports it under Camera3d; matter/planck don't (raycasts3d: false).

Camera3d.queryVisible(world)

Bulk frustum cull — returns every renderable whose octant overlaps the current view frustum. Use as a broadphase pass before per-renderable narrow culling on dense 3D scenes (hundreds+ of items):

const visible = camera.queryVisible(app.world);
for (const r of visible) {
  if (camera.isVisible(r)) {
    // draw / process
  }
}

Returns [] under a 2D camera — safe to call unconditionally.

Supporting primitives

Two lower-level types back the queries above; you rarely construct them directly, but they're exported for code that needs to reason about 3D regions:

  • AABB3d — minimal 3D axis-aligned bounding box (min, max, contains, overlaps, overlapsSphere, isFinite, …). The Octree's bounding primitive; pass one to world.adapter.queryAABB for a 3D region query.
  • Frustum — the perspective view volume (fov / aspect / near / far + projection matrix) that Camera3d builds internally and queryVisible tests octants against. Exposed on the camera as camera.frustum.

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.

2.5D Games (Paper Mario-style)

Camera3d isn't only for full 3D arcade games like AfterBurner. A common adjacent use case is 2.5D: a perspective camera for visual depth, but gameplay that lives entirely on a flat XY slice — Paper Mario, Octopath Traveler, parallax-heavy 2D side-scrollers, anything where "depth" is camera atmosphere, not gameplay state.

The Camera3d + Octree + SAT stack handles this cleanly. The recipe:

  1. Use cameraClass: Camera3d for the perspective look.
  2. Place all gameplay entities (player, enemies, projectiles, pickups) at a shared Z — typically z = 0. Use addChild(child, 0) to set Z atomically on insertion.
  3. Place parallax layers (foreground props, background mountains, distant clouds) at distinct Z values: closer-to-camera at z < 0, farther at z > 0. The perspective projection scales them automatically — no per-frame work to keep parallax in sync.
  4. Use existing 2D SAT collision (me.collision.check, Body shapes, world.adapter.queryAABB) as-is. SAT runs on Rect/Polygon/Ellipse in the XY plane; it doesn't care which camera class is rendering.

Why it works:

Each entity's 2D bounds is treated as a point in Z at the entity's pos.z. Two entities sharing the same Z classify into the same Z octant, so the broadphase walk surfaces them as candidates and SAT runs the XY check exactly as it would in a 2D world.

Cost:

Octree carries ~1.3-2× the per-insert overhead of QuadTree and roughly 2× the tree memory vs an equivalent QuadTree at the same entity count. Negligible unless you have thousands of bodies. If you genuinely have hundreds of entities AND a Camera3d AND every entity is on the same Z plane, you can opt back to QuadTree by setting world.sortOn = "z" after stage init.

Mini example:

class PaperMarioStage extends Stage {
    onResetEvent(app) {
        // Distant background — pushed back so perspective shrinks it
        app.world.addChild(new BackdropMountains(), 200);
        // Mid parallax — slow-scroll trees
        app.world.addChild(new ParallaxTrees(), 50);
        // GAMEPLAY plane — all collidable bodies share z = 0
        app.world.addChild(player, 0);
        app.world.addChild(new EnemyManager(), 0);
        // Foreground props — closer to camera, in front of player
        app.world.addChild(new ForegroundGrass(), -30);
    }
}

const app = new Application(canvas, {
    cameraClass: Camera3d,    // perspective view
});
state.set(state.PLAY, new PaperMarioStage());

Keeping parallax out of collision queries:

The Octree partitions on Z, so distant-Z parallax will often land in different octants than gameplay and be pruned by the broadphase walk — but this is best-effort, not a guarantee. Items at the Octree's depth midpoint (z=0 in the default ±10000 root box) stay at the root level and surface in every query, and a 2D Rect query has no z so it descends into all octants from root anyway. For deterministic isolation between gameplay and parallax, give parallax renderables isKinematic = true so the broadphase skips them entirely on insert, or rely on per-pair collisionType / collisionMask filtering on the narrowphase result. Parallax that you don't want hit-tested should be kinematic — this is the same advice that already applies to 2D parallax under Camera2d + QuadTree.

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.

Clone this wiki locally