-
-
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. Since 20.0 the whole 3D tier — including lighting — runs identically on both GPU backends (WebGL 2 and WebGPU; the default video.AUTO picks the best one), gated by the renderer.supportsDepthBuffer capability flag. The Canvas renderer has no depth path.
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 (retained rendering), lighting (
Light3d), ground shadows, 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
});
await app.init();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. |
Multiple Camera3d instances work like multiple 2D cameras — give each its own screen rectangle for split-screen or picture-in-picture views (see the Camera3d example).
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 camera's perspective matrix; under Camera2d it self-projects on the CPU through its own built-in perspective projectionMatrix (45° FOV — set it to identity for a true orthographic look) — same code, the camera decides the path. 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.
Under a Camera3d on a GPU backend, meshes use retained rendering (renderer.supportsRetainedMesh): the model-space geometry is uploaded to GPU buffers once, and every frame just draws it by reference — placement, camera, tint, alpha and emissive all ride per-draw uniforms. Moving, rotating, scaling or re-tinting a mesh therefore never re-uploads its geometry, and a re-drawn static mesh produces no per-frame garbage. (This is the classic retained-mode / immediate-mode split: the rest of the melonJS drawing API is immediate-style, the Camera3d mesh path is the retained exception. Under a 2D camera — or on Canvas — the mesh instead re-projects its vertices on the CPU each frame.)
Editing geometry in place is still possible — signal it and the GPU copy refreshes on the next draw:
// deform the mesh, then tell it the shape changed
mesh.originalVertices[1] += 10;
mesh.needsUpdate = true; // placement/tint changes never need thisSprite3d does this automatically per animation frame, and renderer.deleteMeshGeometry(mesh) (called for you on destroy/removal) frees the retained buffers.
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.
GPU / 3D feature. This is part of the GPU rendering path (WebGL 2 and WebGPU alike). 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 on a GPU backend.
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
const app = new Application(1024, 768, {
renderer: video.WEBGL,
antiAlias: true, // MSAA on
textureFilter: "nearest", // textures stay crisp
});
await app.init();Since 20.0 the MSAA half of antiAlias also survives post-processing: the
offscreen target a post-effect chain rasterizes the scene into is multisampled
to match, so adding a camera vignette or a blur no longer silently flattens
your polygon edges. It is not free — a 4× capture target costs roughly 30
extra bytes of GPU memory per pixel (~55 MB at 1080p) plus one resolve per
effect bracket per frame — and with antiAlias: false none of it is allocated.
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").
Mipmaps + anisotropy (mesh path). On both GPU backends, a linear-filtered mesh texture also samples a generated mip chain with trilinear minification and 4× anisotropic filtering, so distant and grazing-angle geometry (ground planes, walls) stops shimmering as the camera moves. Compressed assets (DDS/KTX/PVR/PKM) shipping an authored multi-level chain use it as-is. textureFilter: "nearest" opts a mesh out — crisp pixel-art models keep hard level-0 sampling — and 2D sprites sharing the same image are unaffected (they stay clamped to the base level).
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).
Meshes are lit by Light3d — a first-class world Renderable, managed exactly like Light2d: add it to the world and the active stage tracks it automatically, remove it to turn it off. Four types:
| Type | What it does | Key fields |
|---|---|---|
"directional" |
a sun — parallel rays, half-Lambert diffuse |
direction, color, intensity
|
"ambient" |
a flat fill so shadow sides aren't pure black |
color, intensity
|
"point" |
a lamp radiating from a position, quadratic falloff over range
|
position, range, color, intensity
|
"spot" |
a point light constrained to a cone with a smooth edge |
position, direction, range, innerConeAngle, outerConeAngle
|
import { Light3d } from "melonjs";
// a warm sun + soft fill
app.world.addChild(new Light3d({ type: "directional", direction: [0.4, 0.8, 0.45], color: [1, 0.95, 0.85] }));
app.world.addChild(new Light3d({ type: "ambient", color: "#ffffff", intensity: 0.3 }));
// a cyan street lamp with a tight cone
const lamp = new Light3d({
type: "spot",
position: [200, -300, 400],
direction: [0, 1, 0], // pointing down (Y-down world)
range: 600,
innerConeAngle: 0.2,
outerConeAngle: 0.5,
color: [0.4, 0.9, 1.0],
});
app.world.addChild(lamp);
// every field is mutable at runtime — day/night, flicker, swinging lamps
lamp.intensity = 0.8 + 0.2 * Math.sin(t);Meshes opt in with lit: true (the glTF loader sets it automatically when the scene carries authored lights); unlit meshes pay nothing for lighting. Up to 32 lights are shaded per scene. Falloff is a stylized quadratic-over-range (tuned for pixel-unit worlds), not physical inverse-square. Loading a glTF/GLB scene instantiates its authored suns and lamps for you — see Loading glTF / GLB scenes.
3D objects cast a soft "blob" shadow on the ground — on by default since 20.0. It is deliberately not a simulated shadow: for the paper-thin billboards a 2.5D game is made of, a shadow map costs far more than this engine wants to spend and looks worse, because a flat silhouette has to be special-cased to cast anything sensible. What the player needs from a shadow here is contact — where the object is standing, and how far off the ground it is mid-jump — and a blob says exactly that.
const crate = new Mesh(x, y, {
model: "crate", material: "crate", width: 150,
shadowGroundY: 0, // the floor's world Y
shadowOpacity: 0.45, // the default
});| property | default | meaning |
|---|---|---|
castGroundShadow |
unset → follows the application setting | the opt-in |
shadowGroundY |
undefined |
world Y of the floor; unset means "standing on the ground" — blob at the object's own base, full strength, no falloff |
shadowOpacity |
0.45 |
darkest value, directly beneath |
Set shadowGroundY and the blob shrinks and fades as the object rises
above that floor. The shape follows the caster: it is an ellipse sized from the
object's own footprint and turned by its rotation, so a thin upright panel gets
a thin shadow lying along the panel rather than a disc, and it spreads slightly
past the footprint the way a real contact shadow does.
Where the opt-in comes from, most specific first:
-
mesh.castGroundShadow— always obeyed -
level.load(name, { castGroundShadow })— one glTF scene - the
castGroundShadowapplication setting — shipstrue
// opt one 3D game out wholesale (its lighting is already baked, say)
const app = new Application(1024, 768, {
cameraClass: Camera3d,
castGroundShadow: false,
});The two blanket forms carry one safeguard the per-object form does not: they skip meshes with no vertical extent, because a flat plane lying on the floor is the floor and shadowing it with itself smears the whole ground.
Cost is one extra draw per shadowed object, and for an InstancedMesh one
extra draw for the whole scatter regardless of instance count — the blobs are
read from the same instance buffer the meshes draw from. Requires a GPU backend
and a Camera3d: the Canvas renderer has no depth buffer, and the 2D-camera
path draws none, so a 2D game is untouched whatever the setting says.
Live in the Per-material Textures, Billboard Sprites and Instanced Forest examples.
Light2d is 2D-only and produces visible artifacts under perspective projection. Avoid combining it with Camera3d — use Light3d (above) for 3D scenes.
Many 3D scenes are the same mesh repeated — a forest, a crowd, a city of
identical buildings, a field of asteroids. Done with one Mesh per repeat,
the cost scales with instances × vertices: every copy is its own geometry
on the GPU even though they are identical.
An InstancedMesh uploads the geometry once and stamps out the copies
from a small per-instance record, in a single draw call:
const forest = new me.InstancedMesh(0, 0, {
model: "tree", material: "tree", width: 64, lit: true,
instanceCount: 5000, // pre-allocate
instanceColors: true, // opt in: per-instance colour (+16 B)
instanceData: true, // opt in: per-instance vec4 (+16 B)
});
const placement = new me.Matrix3d(); // one scratch — zero allocation
for (let i = 0; i < forest.instanceCount; i++) {
placement.identity().translate(x, y, z).rotate(angle, UP).scale(s);
forest.setInstance(i, placement);
forest.setInstanceColor(i, autumnTint);
forest.setInstanceData(i, windPhase, seed, 0, 0);
}
app.world.addChild(forest, 10);It extends Mesh, so every mesh setting works unchanged — model +
material, raw geometry, lit, cullBackFaces, rightHanded, tint,
textureRepeat, even a custom shader.
The mesh's own position, rotation and scale act as the group transform, and placement rides uniforms exactly as it does for a retained mesh:
| Change | Cost |
|---|---|
| Moving / rotating / re-tinting the whole group | one uniform — no upload |
| Moving one instance | one record (48–80 bytes) |
| Editing a contiguous run | one upload spanning it |
visibleInstanceCount |
nothing — the draw is just shorter |
visibleInstanceCount is the cheap level-of-detail knob: sort your
instances by distance once, then draw fewer of them by moving one integer.
getBounds3d() covers every instance, so the group frustum-culls as a
single object.
Every instance carries a transform. Two further slots are opt-in, so nothing pays for what it does not declare:
-
instanceColors— a colour multiplied into the mesh tint. -
instanceData— an opaquevec4. The built-in shading reads itsrgbas emissive (per-tree glow, per-window light); a custom mesh shader is free to read the same slot as a wind phase, an atlas offset or a random seed.
Authored instancing needs no code at all. A glTF node may carry
per-instance TRANSLATION / ROTATION / SCALE accessors — what
exporters write for linked duplicates — and level.load() turns such a
node into an InstancedMesh automatically, leaving ordinary nodes as
ordinary meshes:
me.loader.preload([{ name: "forest", type: "glb", src: "forest.glb" }], () => {
me.level.load("forest", { scale: 26 }); // that is the whole of it
});See the Instanced Forest example: 100 000 trees from one 52-vertex geometry in a single draw call, scattered entirely by the asset.
Requires a GPU backend.
renderer.supportsInstancingreports it (trueon WebGL 2 and WebGPU). Under the Canvas renderer anInstancedMeshstill renders, by drawing each instance individually — correct, but without any of the benefit.
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 a full reference scene exercising much of the 3D tier: 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.