-
-
Notifications
You must be signed in to change notification settings - Fork 662
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, billboards (
Sprite3d), 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.
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 antiAlias — linear 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 ignorestextureFilterentirely (it has only theantiAlias-driven global smoothing).
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 atomicallySprite3d 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
Sprite3duses an alpha cutout for transparency:alphaCutoffdefaults to0.5, discarding a sprite's transparent background for a clean silhouette with correct depth and no back-to-front sorting. PassalphaCutoff: 0for 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()(andflipX/flipYsettings) 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 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.