-
-
Notifications
You must be signed in to change notification settings - Fork 663
Working in 3D
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.
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 })],
});
}
}Camera3d follows melonJS's existing 2D conventions, just extended into the third axis:
-
Y-down. A sprite at higher
pos.yappears lower on screen — same as Camera2d. -
+Z forward. A sprite at higher
pos.zis 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.
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;
}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 = 200Under 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.
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 atomicallyLight2d is 2D-only and produces visible artifacts under perspective projection. Avoid combining it with Camera3d for now.
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) ?? [];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)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:
SpritedefaultsisKinematic = true, which means the broadphase skips it. Flip tofalseat spawn for any entity you want findable viaquerySphere:bullet.isKinematic = false;
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).
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.
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, …). TheOctree's bounding primitive; pass one toworld.adapter.queryAABBfor a 3D region query. -
Frustum— the perspective view volume (fov/aspect/near/far+ projection matrix) thatCamera3dbuilds internally andqueryVisibletests octants against. Exposed on the camera ascamera.frustum.
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.
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:
- Use
cameraClass: Camera3dfor the perspective look. - Place all gameplay entities (player, enemies, projectiles, pickups) at a shared Z — typically
z = 0. UseaddChild(child, 0)to set Z atomically on insertion. - Place parallax layers (foreground props, background mountains, distant clouds) at distinct Z values: closer-to-camera at
z < 0, farther atz > 0. The perspective projection scales them automatically — no per-frame work to keep parallax in sync. - 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.
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.