-
-
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.
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.
- General concepts (this page) — opt-in, conventions, the camera, meshes, sprites + depth, smooth follow
- Loading & supported 3D assets — the asset pipeline, OBJ/MTL, and what's supported vs. not (materials, animation, lighting)
- Loading glTF / GLB scenes — import a whole Blender-authored scene
- 3D spatial queries — sphere / ray / frustum queries against the 3D broadphase
- 2.5D games (Paper Mario-style) — perspective look, flat gameplay plane
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.
Note: imported glTF assets are authored Y-up / right-handed and are converted on load — see Loading glTF / GLB scenes.
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 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 = 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.
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.
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. (3D meshes are unlit — see supported assets.)
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.
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.