Skip to content
cccskills
FreePublished on GitHub

melonjs-3d

Build 3D or 2.5D scenes in melonJS with cameras, meshes, billboards, lights, shadows, and depth sorting. This guide explains the engine’s Y-down and +Z-forward coordinates, camera setup, transparency, and fog, and identifies features that require a GPU renderer instead of the Canvas fallback.

OriginalShow the original description

Use this skill for anything 3D or 2.5D in melonJS — Camera3d, Mesh, InstancedMesh, Sprite3d billboards, Light3d, ground shadows, glTF/GLB scenes, and depth sorting. Covers the Y-down/+Z-forward convention that is the inverse of OpenGL, the cameraClass opt-in, clip planes, and what does not work on the Canvas fallback. Triggers on: Camera3d, Mesh, InstancedMesh, Sprite3d, Trail3d, ribbon, exhaust trail, Light3d, billboard, glTF, glb, 3D, 2.5D, depth, cameraClass, fov, setClipPlanes, setFog, fog, distance fog, height fog, heightFalloff, transparent, transparency, alpha, blendMode, fade, castGroundShadow, lit, setBasis, getBasis, lookAt, up vector, gimbal, slerp, great circle, turn a direction.

melonjs/melonJS6,429 stars(GitHub 6,429 + cccskills 0)

Run score Not tested

See how to install

Good for

  • Setting up a Camera3d for a game stage
  • Placing meshes, billboards, or repeated instances
  • Fixing depth order or transparency in a 3D scene
  • Adding fog or checking Canvas renderer limitations

Files included (1)

  • SKILL.md55.2 KB

SKILL.md (original)

Review the instructions the agent will receive before you install.

3D and 2.5D in melonJS

melonJS is a 2D engine that grew a real 3D tier: perspective cameras, meshes, instancing, glTF scenes and 3D lights. Most of what a model "knows" about 3D graphics comes from the OpenGL convention, and melonJS's is the inverse. Read the conventions section before writing any 3D code.

Coordinate conventions — the inverse of OpenGL

This is the single most important thing on this page.

melonJSOpenGL convention
verticalY-down — higher y is lower on screenY-up
depth+Z forward — higher z is farther away−Z forward

Camera3d exposes three rotation angles: camera.pitch (X, look up/down), camera.yaw (Y, look left/right) and camera.roll (Z, bank the horizon). The view is R(yaw) ∘ R(pitch) ∘ R(roll) inverted, and the frustum planes come off that same matrix — so culling follows a banked view.

The basis those angles produce is readable and writable: getBasis(right, up, forward) reads it, setBasis(right, up, forward) poses the camera from one.

camera.rotate() is NOT the way to bank a 3D camera. It writes the inherited currentTransform, which a 3D view never reads, so the call is silently inert. Set roll. (On a Camera2d it is the other way round: roll IS that transform's rotation, which is why worldToLocal / localToWorld compensate for it.)

The payoff is that 2D code translates directly — anywhere you used pos.x / pos.y, add pos.z and the maths still holds. The cost is that every OpenGL-shaped instinct is backwards.

Consequence that bites immediately: "behind everything" is the largest depth when the camera looks along +Z. Put a backdrop on the wrong side and it either paints over the whole scene (it is nearer than everything else) or disappears without trace (it falls in front of the near plane and the GPU clips it). Which sign means "far" depends on which side the camera sits, so check the camera before picking a backdrop depth.

glTF assets are authored Y-up right-handed. level.load converts them by rotation (rightHanded: true is its default), so you do not apply a flip yourself. The Mesh constructor defaults rightHanded to false, which bridges by mirroring instead — geometry you hand to a Mesh directly comes in flipped left/right unless you pass rightHanded: true. See melonjs-3d-assets.

Opting in

3D starts with the camera class, and nothing else has to change. Set it once on the Application:

import { Application, Camera3d } from "melonjs";

const app = new Application(1024, 768, {
    parent: "screen",
    cameraClass: Camera3d,      // ← the opt-in
});
await app.init();

That flips the world's sort to "depth", switches the broadphase to a 3D index, and renders sprites and meshes with perspective projection. Camera3d extends Camera2d, so follow, fade, shake and post effects all still work.

Keep the camera, do not fish it back out

app.viewport is the active stage's default camera, so it is typed Camera2d: a stage overrides the application's cameraClass, and the built-in loading screen does exactly that. Reading your Camera3d back out of it therefore needs a cast, and a cast that is wrong while the loader is up:

const camera = app.viewport as Camera3d;   // works, until it does not

Own it instead. Pass the camera in and keep the typed reference, and nothing downstream ever narrows:

class GameStage extends Stage {
    readonly camera: Camera3d;

    constructor(app) {
        const camera = new Camera3d(0, 0, app.renderer.width, app.renderer.height);
        super({ cameras: [camera] });
        this.camera = camera;
    }
}

A freshly built camera is named "default", and a stage registers its cameras by name, so this IS the stage's default camera: app.viewport returns the same instance.

The two patterns differ in LIFETIME, and that decides which you want

This is the part that bites. cameraClass builds a fresh camera for each stage, which the engine does deliberately so "Camera3d state shouldn't bleed across stages". An owned camera passed through super({ cameras }) is built once and lives as long as the stage object, which for a stage you register with state.set() means as long as the application.

So anything you attach to the camera now outlives the visit:

// in onResetEvent — runs EVERY time the stage is entered
app.viewport.addPostEffect(bloom);

With cameraClass that lands on a fresh camera each time and is harmless. With an owned camera it stacks: a second bloom on the second visit, a third on the third. The symptom is text and edges appearing to double or smear, because the scene is being post-processed once per accumulated effect.

Note that removePostEffect DESTROYS the effect rather than detaching it, so "just remove it on the way out" is not a free undo.

Choose on lifetime, not on typing:

your effects / camera state areuse
set up per stage visit, in onResetEventcameraClass — fresh camera per stage
set up once, in the stage constructoran owned camera in super({ cameras })

If you want the typed reference but your setup is per-visit, keep cameraClass and narrow once on reset:

override onResetEvent(app) {
    const viewport = app.viewport;
    if (!(viewport instanceof Camera3d)) {
        throw new Error("this stage needs a Camera3d");
    }
    this.camera = viewport;   // typed, checked, and still fresh per visit
}

That is what the Earth Defender example does, and why: it attaches post effects on every reset and never removes them.

Narrowing beats asserting wherever you read the shared slot: the language verifies instanceof, and fails loudly if the application was never given a cameraClass, where a cast fails silently at the first 3D call.

Per-stage opt-in also works, which is useful when the loading screen is 2D:

class GameStage extends Stage {
    constructor() {
        super({ cameras: [new Camera3d(0, 0, 1024, 768, { fov: Math.PI / 3 })] });
    }
}

Camera traps

  • There is no sky. The frame clears to renderer.backgroundColor (opaque black unless the renderer was created transparent), and world.backgroundColor clears over it when you set one — both are flat colours. Anything more is a backdrop renderable, screen-fixed or placed at a far depth.
  • Set the clip planes for your scene scale. Defaults are near 0.1 / far 1000. Objects beyond the far plane clip or project with bad w-divides; a near that is too small wastes depth precision and distant props z-fight. Use camera.setClipPlanes(near, far).
  • camera.pos.set(x, y) is 2-argument and zeroes z. Use camera.setPosition(x, y, z), which leaves the depth untouched when z is omitted; camera.depth is the z accessor if you only want that.
  • lookAt(target) solves pitch and yaw only, so on its own it cannot express "up is this direction" and it leaves roll alone. Pass an up to bank the horizon too: camera.lookAt(target, up), where up is what should appear UP ON SCREEN. This engine renders Y-down, so a level horizon is (0, -1, 0). There is deliberately no default, because giving one would silently level every camera that had banked its own horizon.
  • When the game already has all three axes, hand them over: camera.setBasis(right, up, forward), the writable counterpart of getBasis. The case that needs it is a view over a curved surface, where up is the surface normal and differs at every point, so no pitch and yaw pair expresses it and integrating towards one drifts and gimbals at the poles. setBasis absorbs a triple that has drifted off orthonormal rather than leaving the camera holding something that is not a rotation, and it does not care what the axes are scaled to: a direction is a direction, so anything from a denormal to 1e300 resolves the same way. Note its up is the camera's local +Y column, which under Y-down points DOWN the screen: the opposite sense to lookAt's, and one negation apart.
  • worldToLocal does not project. It is a 2D camera's offset subtraction. To pin a label or marker to a point in the scene use camera.worldToScreen(x, y, z), which applies the projection and returns null behind the camera. See melonjs-camera-and-drawing.

Where an up vector earns its keep

Three cases. In the first two, lookAt(target) on its own is already right and an up would be noise. The third is the one it exists for.

// 1. ordinary third-person: world up never changes, so say nothing
camera.setPosition(x, y, z).lookAt(player);

// 2. a deliberate dutch angle: this is a ROLL, not an up vector
camera.lookAt(player);
camera.roll = Math.PI / 32;

// 3. a view over a curved surface: up is the surface normal, and it is
//    different at every point on the globe. No pitch and yaw pair expresses
//    it, and integrating towards it drifts and gimbals over the poles.
const up = player.pos.clone().sub(planetCentre).normalize();  // surface normal
camera.setPosition(eye.x, eye.y, eye.z).lookAt(player, up.negate());

Note the negate() in case 3. The surface normal points away from the planet, which is the direction the player's head points, and up wants what should appear UP ON SCREEN. This engine renders Y-down, so (0, -1, 0) is a level horizon and a world-space "up" direction has to be handed over in that sense. Get it backwards and the view is upside down, which is the single most likely mistake with this argument.

If the game already computes all three axes, skip the solve entirely:

// a surface frame the game is already maintaining
camera.setBasis(right, up, forward);

setBasis takes the camera's OWN columns, where up is local +Y and so points down the screen: the opposite sense to lookAt's, one negation apart. It also absorbs a frame that has drifted off orthonormal, which one integrated over thousands of steps will have.

A camera posed this way and a world rotated into a fixed camera's frame are the same picture. If a game already transforms its world into view space by hand, there is nothing to gain by switching; the value of setBasis is not having to write that transform in the first place.

Turning a direction: slerp, not lerp

lerp walks the straight line between the two tips, which passes INSIDE the sphere. The result points the right way at both ends, lingers near them, rushes the middle, and comes out shorter than either input as it crosses the midpoint. On a camera swing that reads as a lurch; on a body walking over a globe it dips into the surface.

Vector3d#slerp turns along the great circle instead, at a constant angular rate, and carries the length across linearly, so two unit vectors give back a unit vector and nothing needs renormalizing.

// a turret, a camera, a ship: a facing chasing another facing
facing.slerp(toTarget, 0.25);

// frame-rate independent, the same shape as math.damp
facing.slerp(toTarget, 1 - Math.exp((-6 * dt) / 1000));

ObservableVector3d has it too and fires its callback ONCE, so renderable.pos.slerp(...) is one notification rather than three.

Vector2d has the same method, and it is not a cut-down copy: in 2D the whole turn is one signed angle, so there is no sine to divide by and no degenerate near-parallel case at all. What it buys a 2D game is the wrap: a heading damped as a NUMBER from 170 degrees to -170 degrees sweeps the long 340 instead of the short 20, and an arc between directions cannot do that.

Two degenerate pairs are handled inside rather than left to the caller, and both turn up in ordinary game code:

  • the same direction, where every path between them is the same path.
  • opposite directions, where infinitely many great circles pass through both and none is the shorter way round. The textbook formula divides by a vanishing sine here and hands back NaN, which propagates into a position or a matrix and the object disappears. slerp picks a stable perpendicular and turns through half a circle instead.

That second case is why a hand-rolled version is a liability rather than a shortcut: anything orbiting a planet reaches the antipode of where it started, every run.

math.damp for a scalar, slerp for a direction. Same job, different space; what damp cannot do is keep a vector on the sphere.

Depth sorting

Under Camera3d the world sorts on "depth" every frame (recursively), so unlike 2D you can assign depth after addChild and it will reorder. That is the one place where the usual z-ordering rule is relaxed — see melonjs-renderables for the 2D rules, which still apply everywhere else.

You will often have to. A Container has autoDepth: true by default, so addChild(child) with no explicit z overwrites pos.z with the child's index — a real world depth in a 3D scene, and never the one you wanted. Pass it (world.addChild(mesh, z)), or set mesh.depth afterwards. The glTF importer turns autoDepth off on the container it loads into for this reason.

floating does not opt out of this sort. It skips the camera transform, not the depth order. A floating child is ordered by |pos.z| alone — its pos.x/y are screen pixels, and the camera does not move relative to it — so the magnitude is the distance and the sign is ignored:

world.addChild(hud, -150);       // small -> nearer than anything -> on top
world.addChild(skybox, -10000);  // large -> farther -> behind everything
world.addChild(skybox, 100000);  // equally far: sign does not matter

Both hold at any camera position. A HUD given the huge z that would put it on top in 2D lands at the far end of the level instead, with the scenery drawing over it.

Transparency

A mesh fades by setting its opacity — there is nothing else to switch on:

ghost.setOpacity(0.4);

Meshes render in two phases. The opaque pass writes depth in sort order; the transparent pass replays afterwards, back-to-front, blending and writing no depth. A draw lands in the second whenever its alpha is fractional.

That default matters because the opaque path writes premultiplied colour with blending off, so before this a faded mesh came out darkened toward black rather than see-through — the background contributed nothing.

transparent: true when the transparency is in the TEXTURE rather than the opacity — a soft-edged glow, smoke, a glTF material with alphaMode: "BLEND". The automatic check reads the draw's alpha and cannot see into a texture. Watch alphaCutoff here: it discards texels before blending sees them, so a soft edge needs a low cutoff (Sprite3d drops its own default to 1/255 when you set transparent: true). The cutoff thresholds the MATERIAL's alpha, not the drawn alpha, so a fading cutout mesh keeps its shape instead of disappearing at its own threshold.

The glTF loader does not set this for you: one loaded mesh can merge several materials and the flag routes the whole mesh, so a "BLEND" material sharing geometry with an opaque one would drag the opaque half into the transparent pass.

transparent: false pins a mesh to the opaque pass however it is faded.

depthTest: false for something with no surface to be occluded on

transparent decides the PASS, and the transparent pass turns depth writing off on its own so overlapping blended draws blend instead of fighting. Whether geometry in front of a mesh hides it is a separate question, and depthTest is where you answer it. The default, true, is what you want for anything genuinely in the scene — a muzzle flash behind a wall should still be behind the wall, and this is not a synonym for "additive".

Set it false for a mesh standing in for a screen-space effect: an additive glow carrying a world position only so it can sort and move with the thing it belongs to. A world-space caption billboarded over a prop wants it for the same reason, since it has no surface for the prop's own foliage to be in front of. Left depth tested, a flat billboard is sliced along a hard straight line the moment any geometry is nearer at some pixel. That shows up worst around something round — a glow on a rock near a planet's limb gets cut, and no offset escapes it, because the near surface bulges further toward the camera than any offset you would dare apply.

const halo = new Sprite3d(0, 0, {
    image: glowTexture,
    width: rock.radius * 3, height: rock.radius * 3,
    billboard: "spherical",
    blendMode: "additive",
    transparent: true,     // the blended pass
    alphaCutoff: 0,        // do not cut the soft falloff
    depthTest: false,      // and do not let the world slice it
});

Honoured in the transparent pass on both GPU backends; the Canvas renderer has no depth buffer and ignores it.

Blending uses the renderable's existing blendMode, so a glow is one property. The advanced modes ("overlay", "difference", and the rest that need a compositing pass) fall back to "normal" here, on both backends:

const glow = new Mesh(0, 0, {
    ...quad, texture: glowTexture,
    transparent: true, blendMode: "additive", alphaCutoff: 0,
});
sortingper object, by distance from the camera
intersecting transparent meshesmay pop as the camera moves — split them, or accept it
InstancedMeshsorts as one object; instances draw in buffer order
needsa GPU backend and a Camera3d

Ground shadows ride the same pass — a blob is a decal, and decals are its first client rather than a feature of their own.

Trail3d is the transparent pass's other obvious client: a camera-facing ribbon for an exhaust streak, a speed trail or a tyre mark. It is a Mesh, so it sorts and fogs with everything else here, and like the rest of this tier it needs a Camera3d. Trail is the 2D one and stays flat under a perspective camera. See melonjs-particles-and-trails.

Distance fog

Off until you ask for it, and one call on the camera:

camera.setFog({ near: 2000, far: 7000 });   // linear: name the two distances
camera.setFog({ mode: "exp2", density: 4e-4 }); // or one density
camera.setFog(null);                         // off

It is the cheapest thing that stops a 3D scene reading as flat cut-outs, and it lets props arrive at the far plane without a visible edge.

Every parameter is optional, and the omitted ones track live. Distances default to the camera's own clip planes, so fog cannot silently disagree with them after a later setClipPlanes. The colour defaults to renderer.backgroundColor and follows it, so geometry dissolves into whatever sky you already set — including through a day/night fade. Pass color only when the fog should deliberately differ from the backdrop:

camera.setFog({ far: 5000, color: "#8899aa" });

A Color is held by reference, so mutating it animates the fog.

Height falloff makes mist pool in low ground instead of hanging at every altitude equally — the difference between fog reading as weather and as a global desaturation:

camera.setFog({ near: 1200, far: 7000, fogHeight: 0, heightFalloff: 0.0015 });

heightFalloff defaults to 0, which is uniform fog — not a special case, the same integral with the dial at zero, so leaving it out changes nothing. Render space is Y-down, so fogHeight is the floor and density rises below it; every published form of this formula assumes Y-up and has the opposite sign.

Fog is measured radially from the camera and applied per fragment, so it does not slide as the camera turns and does not band across large triangles. It lives on the camera, so a split-screen or minimap view fogs independently — and a Camera2d never fogs at all.

Per object: fog: false exempts a mesh however far away it is — for a waypoint or objective marker that has to stay readable. It exempts the mesh and not the ground shadow it casts: a blob is a mark on the floor and fogs with the floor. Emissive surfaces fog like everything else (light travelling through fog is attenuated too), so a neon sign that should punch through wants fog: false, not a brighter emissive.

Only meshes fog. 2D content, HUDs and floating renderables never reach the mesh shaders, so a screen-space overlay stays clean with no work.

A custom mesh shader is not fogged unless it asks to be. Fog is compiled into the engine's own mesh programs — #define FOG on WebGL, an enable_fog overridable constant on WebGPU — and a shader you supply is yours: the engine binds it as written and never substitutes a fogged variant. So a mesh given its own GLShader keeps full contrast while the scene around it recedes. It is safe — nothing throws, and the camera's fog is simply not applied — but it is usually surprising.

A hosted ShaderEffect is the other case and needs nothing: its body is spliced into the engine's own mesh program as a colour hook, and fog is applied to the hook's result, so such a mesh fogs like any other.

To opt in, declare the same uniforms and the engine will feed them, because the fog values are pushed to any mesh program that declares them rather than only to the built-in ones:

uniform vec3 uFogColor;   // straight (unpremultiplied) fog colour
uniform vec4 uFogParams;  // x = mode (0 off / 1 linear / 2 exp2),
                          // y = near, z = 1/(far - near), w = density

Your vertex stage computes the distance itself — length((uViewMatrix * uModelMatrix * vec4(aVertex, 1.0)).xyz), radially so it does not swim as the camera turns — and the blend must scale the fog colour by the fragment's own alpha, mix(uFogColor * a, rgb, f), because vColor arrives premultiplied. Mixing toward the unscaled colour haloes every alpha-cutout edge.

The flip side is the reason fog costs nothing when unused: with no camera fog, the mesh programs are compiled without any of it, on both backends. It is not a branch that is skipped at runtime — the code is not there.

Meshes

const cube = new Mesh(x, y, {
    model: "cube",          // a preloaded OBJ name — no built-in primitives
    texture: atlas,         // TextureAtlas, image, or a preloaded image name
    width: 64, height: 64,
    cullBackFaces: true,    // the default
    lit: true,
});

A mesh pivots about its model origin (0, 0, 0), not an anchor point. Under Camera3d it opts out of the anchor offset entirely, so anchorPoint is inert there — place the origin where you want the pivot at authoring time, or nest the mesh under a transformed parent. The anchor is only honoured on the legacy 2D-camera path.

InstancedMesh, and when it is the wrong tool

InstancedMesh draws one mesh many times in a single draw call — the difference between a hundred trees and a hundred thousand. glTF scenes using EXT_mesh_gpu_instancing load as one automatically; by hand it is a Mesh with a count:

const trees = new InstancedMesh(0, 0, { ...treeGeometry, instanceCount: 400 });
const at = new Matrix3d();               // one scratch, reused
for (let i = 0; i < trees.instanceCount; i++) {
    at.identity().translate(x, y, z);
    trees.setInstance(i, at);
}
world.addChild(trees, 0);

trees.visibleInstanceCount = 120;        // draw fewer, without re-uploading

It is not a free upgrade. One InstancedMesh is one geometry and one material, and four things move from per-object to per-group:

with Meshwith InstancedMesh
depth sorteach object sorts on its own posthe whole set has one sort key
ground shadowone blob per objectone instanced draw for the set
removalremoveChild, indices unaffectedremoveInstance(i) swaps the last instance into the hole, so any index you were holding is now wrong
colourtint per objectneeds instanceColors: true and setInstanceColor(i, …)

So the question is not "how many are there" but "does the game address them individually":

  • Scenery — instance it. Trees, rocks, grass, debris: the game never asks about one of them.
  • Collision-tested props — still fine. You test against positions you already own; instancing only changes how they are drawn.
  • Collectibles and enemies — usually not. Anything removed one at a time makes removeInstance's swap your problem: you have to keep an index↔object map and repair it on every removal. At small counts a pooled Mesh each is less code and no slower.

Under a few hundred objects the draw-call saving is not what limits you anyway — reach for it when the count is in the thousands, or when the objects are pure scenery and it costs nothing to.

Normals are generated for you

A lit mesh needs per-vertex normals for the shader to light with. Supply them if you have them; omit them and the engine computes them from the geometry:

const mesh = new Mesh(x, y, { vertices, uvs, indices, lit: true });
// normals derived from the triangles — nothing else to do

You do not choose flat or smooth, because the geometry already decides. Face normals accumulate into their vertices weighted by area, so where faces share a vertex they average and the surface shades smoothly, and where every triangle carries its own three vertices — a triangle soup, which is how most hand-built geometry comes out — each vertex belongs to one face and the result is that face's normal, so it shades flat. Want faceted edges: duplicate the vertices. Want smooth: share them.

An explicit settings.normals always wins, and an unlit mesh gets none — there would be nothing to read them.

A lit mesh with no normals used to render fullbright, which looks like the lighting is broken rather than absent. If an older scene suddenly picks up shading, that is why.

Colouring a mesh

There are four levels, and picking the wrong one is the usual reason a colour "does not apply". They all multiply together.

levelhowuse for
whole objectmesh.setTint("#ff8800"), or mesh.tint.setColor(r, g, b)flash on hit, team colour, fading one object
per vertexsettings.vertexColors, or mesh.setVertexColor(i, color)a gradient within one mesh — distance haze, a darker crease
per materialtextureGroups, from a multi-material OBJ + MTLa model whose parts differ, in one draw call
per instancenew InstancedMesh(…, { instanceColors: true }) then setInstanceColor(i, color)a thousand copies that differ

tint is per object. That is the trap: build a terrain as one big mesh and you can tint the whole valley or none of it. Anything that varies across a single mesh is per-vertex.

// fade a procedural terrain toward the sky the further out it goes
const ground = new Color(217, 230, 244);
const sky = new Color(207, 230, 247);
const haze = new Color();
for (let i = 0; i < mesh.vertexCount; i++) {
    const t = Math.min(1, mesh.originalVertices[i * 3 + 2] / 6000);
    mesh.setVertexColor(i, haze.copy(ground).lerp(sky, t));
}

Supply the whole array at construction when you already have it — vertexColors takes a packed Uint32Array (the form the batchers read, so no conversion) or one Color per vertex. A length that does not match vertexCount throws; it is not padded, because a short array would leave the tail of the mesh mis-coloured and that reads as a lighting bug.

Mutating the array directly is fine, but say so afterwards:

mesh.vertexColors[i] = color.toUint32(color.alpha);
mesh.needsUpdate = true;   // the retained Camera3d path uploads once

setVertexColor does that for you. Skip it and the colour applies under a 2D camera and silently does not under Camera3d.

On a lit mesh the colour multiplies the lit result, so it behaves as albedo rather than as an emissive override — a vertex colour will not make an unlit face bright.

alpha hides a mesh as it hides anything else: at 0 the draw is skipped. There is no partial mesh transparency though — the mesh path renders opaque, so a mesh at alpha = 0.5 draws fully opaque rather than half see-through. Fade a mesh out and it will stay solid until it vanishes.

Moving a mesh in 3D

Mesh and Sprite3d inherit the ordinary Renderable transform helpers, and both are already 3D:

mesh.rotate(Math.PI / 2, new Vector3d(1, 0, 0));   // axis overload
mesh.scale(2, 1, 0.5);                             // z is a real argument

rotate(angle) alone is the 2D case (about Z); pass a Vector3d and it rotates about that axis. Both write to currentTransform, a Matrix3d, which Mesh folds into its model matrix — so orienting a mesh costs a matrix update, not new geometry.

This matters most for the case it is least obvious for: a horizontal plane. Sprite3d with billboard: false is a quad in the sprite plane — upright — and the way to lay one flat (water, a road, a shadow decal) is to rotate it a quarter turn about X, not to hand-build vertices. The one reason to build geometry instead is tiling: a Sprite3d bakes its UVs 0..1 from its atlas frame, so a surface that needs textureRepeat across many world units still wants an explicit vertices/uvs mesh.

The mesh pass is opaque

Worth stating plainly, because the symptom does not look like a blending problem: the mesh pass disables blending. A soft-edged texture — a glow, a sun, a halo, anything with a gradient alpha — composites as a hard-edged disc with a grey rim, which reads as a broken texture rather than as a missing feature.

transparent: true routes it through the transparent pass, and blendMode: "additive" on top of that makes a light source add to the sky instead of sitting on it. This is the same feature a translucent water surface needs.

Textures under a perspective camera

A tiling texture on a large ground plane is seen at a grazing angle, and that changes which detail survives:

  • Nothing straight and parallel to the direction of travel. Every such run converges on the vanishing point, so a few current bands on a river become a fan of rays radiating out of the horizon. Narrowing them and tiling more often only multiplies the rays. Short, scattered, direction-free detail minifies into an even shimmer instead.
  • textureFilter: "linear" for a floor — "nearest" opts out of mipmaps and anisotropy, and detail aliases into a wash exactly where the camera looks most.

Ground must outlast the props standing on it

Scenery scattered up a slope is placed by its own rule, and terrain is built by another. When the planting reaches further than the mesh does, a low camera sees trees standing over open sky at the corners of the frame. Build the ground wider than the widest thing planted on it — past the profile's clamp it is a flat plateau, so the extra columns cost two triangles and no silhouette.

Recycling has the same trap in Z: an endless runner that recycles tiles a fixed distance behind the player forgets that the camera trails further back still, and the bottom of the frame falls off the world.

Sprite3d and billboards

Sprite3d is the 2.5D workhorse: a flat sprite living at a real depth, with billboard controlling how it faces the camera.

billboardbehaviour
false (default) / "none"no rotation — a flat plane in the world
true / "cylindrical"yaws to face the camera, stays upright (characters, trees)
"spherical"always fully faces the camera (particles, impostors)

"cylindrical" is what you want for paper-thin characters in a 2.5D game. Billboarding needs a Camera3d drawing the frame; under a 2D camera the quad renders fixed-orientation. Any other string falls through to the spherical branch, so a guessed value like "upright" silently gives you spherical.

A billboard takes its orientation from the camera, so rotate() has no effect on one: a card already turned to face the camera has no free rotation left to give. Everything that is not an orientation still applies, scale() and meshScale included, so animating the size of a shockwave or a pickup is the ordinary call and the cull bounds grow with it:

const wave = new Sprite3d(x, y, { image: "ring", width: 64, height: 64,
                                  billboard: "spherical", transparent: true });
wave.scale(1 + 3 * t);          // grows; `scale` accumulates, so drive it
                                 // from the authored size, not per frame

For an in-plane rotation, a bolt streak or an exhaust, use billboard: false and orient the quad yourself.

Lighting

Light3d takes one argument — the options object (it has no x, y, z pair; a light carries its own position). Types are "directional" (the default), "ambient", "point" and "spot".

world.addChild(new Light3d({ type: "directional", direction: [0.3, 1, 0.2] }));
world.addChild(new Light3d({ type: "ambient", intensity: 0.3 }));
// a Vector3d works too, wherever the game already has one
world.addChild(new Light3d({ type: "spot", position: torch.pos, range: 400 }));

direction and position take an [x, y, z] array or a Vector3d, and color takes a Color, a CSS string or an [r, g, b] array — so a value the game already holds can go straight in. Each is read, not retained: move your vector afterwards and the light stays where it was.

Two things still bite, both silently:

  • new Light3d(0, 0, {…}) — the constructor takes options alone. JavaScript drops the extra arguments, so options becomes the number 0 and every setting in your literal is discarded. The light still appears, on pure defaults: a type: "ambient" written this way is a second DIRECTIONAL light, and the scene looks plausible enough that nobody checks. TypeScript stops checking a literal once the argument count is wrong, so nothing inside it is verified either.

  • direction is where the light GOES, and this is a Y-down space — so a sun overhead travels downward and its Y is positive. Get the sign wrong and the scene is lit from underneath: faces that should be in shade are bright, the ground glows and the sky-facing surfaces go dark.

    direction: [-0.35, 0.8, 0.45]   // sun overhead, late afternoon
    direction: [-0.35, -0.8, 0.45]  // lit from below — almost never what you want
    

    position follows the same convention: a lamp above the floor has a smaller y than the floor.

Use both halves: with a key light but no ambient, the shadow side of a mesh goes black. With no Light3d in the world at all, a lit: true mesh falls back to a white ambient and renders fullbright — indistinguishable from unlit, which is why "I added lit: true and nothing changed" is the usual first report.

Other defaults worth knowing: intensity 1, color white, range 1000 (point and spot, a stylised quadratic falloff, not inverse-square), innerConeAngle 0 and outerConeAngle π/4 (spot).

Light2d is 2D-only and produces visible artifacts under perspective projection. Do not combine it with Camera3d.

lit: true only does anything under a Camera3d. Lighting a mesh needs world-space normals and a world-space fragment position, and the 2D-camera path has neither: its vertices are already the projected output, so there is no world space left to light in. Such a mesh degrades to unlit and warns once, naming the fix. If you want lit 3D props in an otherwise 2D game, set cameraClass: Camera3d on the application. (For 2D sprites, lighting is a separate system that works under a 2D camera as normal, see the lighting skill.)

A lit mesh takes a tangent-space normal map, so relief comes from the material rather than from geometry — settings.normalMap (a loader key or any image-like source), or MTL map_bump / bump / norm, which the MTL loader fetches and applies per material. The tangent frame is derived per fragment, so the model needs no tangent attribute, and an unmapped mesh is unaffected. This is the 3D counterpart of the Sprite normal maps in the lighting skill; they are separate systems and a Mesh uses this one.

What the ground-shadow tier can and cannot do

Check this table before claiming a setting is unsupported. Every one of these is read every draw, so all of them can be animated.

settingMesh / Sprite3d / GLTFModel partInstancedMesh
castGroundShadowyesyes
shadowGroundYyes, the caster's own flooryes, one plane through the SET's origin
shadowGroundPointyes on a Mesh or Sprite3d; on a GLTFModel part only by setting it after the load, since it is neither a model nor a level.load optionno, warns and ignores
shadowGroundNormalyesyes, one plane for the set
shadowOpacityyesyes
shadowScaleyesyes
shadowOffsetyesyes, one throw for the set
shadowStretchyesyes, one stretch for the set
shadowDirectionX / Zyesyes
shadowLightyesyes
height fade as the caster risesyesno
a different value per instancen/ano, and there is no plan for one

The last two "no"s are the same limitation seen twice: a set gets ONE shadow plane and ONE shared quad, so the instance's own height is dropped and nothing can vary between instances. Everything else is one value for the whole set, which is what a set means. A scatter over curved ground is therefore one set per flat facet, and a flying scatter wanting a height fade has to be per-object meshes.

shadowGroundPoint (20.9) is the one genuinely per-object setting, and the reason is structural rather than missing work: the instanced tier builds its plane THROUGH the set's own origin so the translation's x and z drop out of the intercept, which is then groundY + lift / nY. An arbitrary point does not drop out, and that same nY divisor is why the 75 degree tilt ceiling cannot be lifted there either. InstancedMesh warns and ignores rather than storing it, so a game cannot think it took.

Apart from that one, if a claim that a setting "does not work on an InstancedMesh" is in your head, it is out of date: shadowOffset and shadowStretch both reached the instanced tier in 20.8.

Ground shadows are on by default (the castGroundShadow application setting), and need a GPU backend and a Camera3d. As a blanket default they skip geometry with no vertical extent — a ground plane. Per object, castGroundShadow: true/false overrides the app setting and is obeyed as given, safeguard included; shadowGroundY names the floor the blob lands on, shadowOpacity how dark it is and shadowScale how big it is.

On ground that is not flat, name the floor with shadowGroundPoint instead: a point and shadowGroundNormal are a plane, and the blob leaves the caster's vertical column to land on it. shadowGroundY cannot express that, because the blob is always directly below the caster in world Y, which is only "below" on level ground. It is also what lifts the tilt ceiling, since that ceiling measures tilt from world up and so assumes a camera looking down the Y axis.

The height fade follows whichever floor you named. With a point and a normal it is measured along that normal; with a point alone it is the straight-line distance to the point. So on a sphere the blob fades with ALTITUDE rather than with the vertical drop, which is the whole reason the point exists. Two consequences worth knowing, because they fail in opposite directions: a shadowGroundNormal facing inward makes the height negative, so the fade clamps to full strength and the blob never fades at all; and a point on the wrong side of the caster reads as a large height, so the blob disappears outright rather than thinning to a ring the way a backwards shadowGroundY does. It is also the one shadow setting that THROWS rather than clamping or hiding: a non-finite component is rejected on the spot.

The blob is an ellipse sized to the caster's own footprint and placed at the caster's x/z, or at shadowGroundPoint when one is set. So a tall or narrow object (a character, a tree, a pickup) shows its shadow clearly, while a wide, flat-bottomed one resting on the floor covers its own completely from a camera looking down at it. That is the shadow behaving correctly, not a bug.

Do not chase it by raising shadowGroundY. Lifting the plane does not slide the blob out from under the object, it floats the blob up — and past a few units it projects over the top of the caster as a dark halo ringing it.

The simplest fix is a bigger blob, so its edge shows past the object:

mesh.shadowScale = 1.5;        // 1 is the default; 0 hides the shadow

shadowScale works everywhere, InstancedMesh included. Changing it on a set rewrites the shared quad's four corners in place rather than rebuilding it, as does a shadowLight that moves, so an animated sun costs no allocation.

Or, for the look of a sun that is not overhead, use the shape controls:

mesh.shadowOffset = 1.5;       // slide it out, in blob radii
mesh.shadowDirectionX = 1;     // the ground direction it is cast along
mesh.shadowDirectionZ = 0;
mesh.shadowStretch = 2;        // and lengthen it along the same line

shadowOffset is measured in multiples of the blob's own radius, not in world units, so one value serves a boulder and a pebble and every part of a glTF model. About stretch - 1 is what keeps the shadow attached to its caster. It is honoured only when the floor has been named, by shadowGroundY or by shadowGroundPoint, because the blob is a flat quad on one named plane: sliding it across a plane the game has not named puts it where there may be no floor. shadowStretch is clamped to 3 and the blob fades as it pulls, so an extreme value degrades to nothing rather than to a smear.

An InstancedMesh honours both, as one offset and one stretch for the whole set, which is all a shared set can carry. Both ride the quad the blobs share rather than the matrix that also places them: a stretch in the matrix would scale the instance positions too and smear the scatter instead of lengthening its blobs. So a scatter can sit next to loose props under the same sun without looking lit from somewhere else.

shadowLight takes the direction from a Light3d you name, re-read every draw, so a moving sun carries the shadows with it. Nothing is inferred: the engine never picks a dominant light, because a scene with several has no non-arbitrary answer and one with none has no answer at all.

A floor that is not level

shadowGroundY says where the floor is; shadowGroundNormal says which way it faces:

prop.shadowGroundY = floorY;
prop.shadowGroundNormal = [Math.sin(0.35), -Math.cos(0.35), 0];  // a 20 deg slope

World up is (0, -1, 0), because render space is Y-down, and that is the default. The blob is rotated onto the plane rather than projected, so it keeps its size; it sits directly under the caster, since the normal turns it rather than moving it, unless shadowGroundPoint says where instead. It needs the floor named, by shadowGroundY or shadowGroundPoint, for the same reason the offset does: tilting the bounds fallback has no anchor to turn about.

The tilt is clamped at 75 degrees alongside shadowGroundY, past which an edge-on blob has nothing left to show. That ceiling measures tilt from WORLD up, which stands in for "tilted away from the viewer" only while the camera looks down the Y axis, so it is lifted alongside shadowGroundPoint, where a point and a normal state the plane outright.

On an InstancedMesh one plane serves the whole set: it passes through the set's origin at shadowGroundY, faces the normal, and every blob is projected onto it whatever height its own instance sits at, exactly as the level case puts every blob at one height. A scatter over curved ground is therefore one set per flat facet, each with the facet's own plane, not one set with an averaged normal.

All three compose on a set: the stretch turns the shared quad, the offset slides the whole set along the light, and both are carried onto the tilted plane rather than applied flat, so a bank's planting lies along its slope instead of hovering across it.

Give blobs to the plants that stand apart. Dense low ground cover is better left without: a thousand blobs each as wide as its own fern sum to a darkened hillside, not to a thousand shadows, and ground cover is the ground.

These are art direction, not a projection. A stretched ellipse is not a silhouette and has no contact with terrain, so on ground that is not the plane you named it will not lie on it. Direction-correct shadows want a shadow map, which this tier does not do.

Get the sign right: the floor is a GREATER y

Render space is Y-down, so the floor an object stands on is a larger y than the object itself. Nudging a shadow plane below a surface is therefore a plus:

mesh.shadowGroundY = WATER_LEVEL + 8;   // just under the surface — correct
mesh.shadowGroundY = mesh.pos.y - 8;    // eight units ABOVE its own base — wrong

Get it backwards and the blob becomes a horizontal quad slicing through the caster's own body. It is still drawn at full strength — the height fade sees a negative height and clamps to 1, so nothing warns you — but the depth test hides every part of it that falls inside the caster's silhouette. What survives is a thin ring, only as wide as the blob overhangs the object.

The symptom is distinctive and easy to misread: shadows appear to switch on as casters approach the camera. That ring is a few pixels on the nearest object and sub-pixel further out, so a scene reads as "only nearby things have shadows" — which looks like culling, an LOD stage or a fog problem, none of which it is. Before chasing any of those, dump shadowGroundY against the caster's own y and check which one is larger.

Collision in 3D

Use a body with a depth shape, not a hand-rolled distance check. Two shapes carry depth — Box3d and Sphere — and any pair of them is resolved in three dimensions; the response carries overlapNZ / overlapZ for the depth axis.

Pick by whether the thing turns. Box3d is world-axis-aligned and cannot be oriented, so it fits walls, floors and crates and misfits anything that tumbles. Sphere has no orientation at all, which makes it the shape for spinning debris, projectiles, and anything spread over a curved surface: on a globe each object's own "up" points a different way, so one axis-aligned box cannot fit any of them.

A hand-rolled check is the usual mistake here and it costs twice — it throws away the penetration vector the response already carries, and it is invisible to the debug plugin's hitbox overlay, which draws from body.shapes. If the red boxes never appear, the reason is usually that there is no body to draw.

Against a planar shape, either 3D shape degrades to its XY footprint with the planar side read as unbounded along Z, so a sphere or box body still collides with an ordinary collision layer, just without a depth result. And mind the normal: a box pair separates along one world axis, a sphere along the line between two centres, so a sphere contact fills the 2D and depth halves of the response at the same time. See the physics skill for the full contract.

glTF / GLB scenes

Loaded through the same level director as everything else:

await loader.preload([{ name: "diorama", type: "glb", src: "data/diorama.glb" }]);
level.load("diorama", { scale, castGroundShadow, shadowGroundY, onLoaded });

// every ground-shadow setting travels except shadowGroundPoint, which is
// per object and dropped by a scene load: a loaded scene can
// be told where its sun is, and `shadowLight` is re-read every draw
level.load("diorama", {
    scale, castGroundShadow: true, shadowGroundY: 0,
    shadowLight: sun, shadowOffset: 0.6, shadowStretch: 1.8, async: true,
});

.obj / .mtl are also supported loader types.

An animated asset instantiates as a GLTFModel, which is a game object as much as a scene: construct it straight from the parsed descriptor, and place it with pos / depth / rotate / scale like any other renderable — the placement drives the whole rig and composes with the playing clip.

const model = new GLTFModel(loader.getGLTF("boat"), { scale: 40, lit: false });
model.setCurrentAnimation("paddle", { loop: true });
model.pos.set(x, y);
model.depth = z;

To scatter an authored mesh yourself, read the geometry off the descriptor and hand it to an InstancedMesh — loader.getGLTF(name).nodes[0] carries vertices / uvs / normals / indices. That wants a single merged primitive exported at the origin; see melonjs-3d-assets.

melonjs-3d-assets covers the rest: every level.load option, which material features are imported, imported lights and their intensity units, node-TRS animation (and the skinning that is out of scope), and OBJ/MTL.

Requires a GPU backend

The whole 3D tier needs WebGPU or WebGL 2. The Canvas renderer has no depth buffer, no perspective path and no drawMesh, so a Camera3d scene does not render correctly there — usually a black canvas. Light3d and shader effects are inert too. A Mesh under a 2D camera still renders, CPU-projected by painter's algorithm and unlit.

Application does warn: constructing with a cameraClass whose defaultSortOn is "depth" on a renderer with no depth buffer logs a console.warn. But video.AUTO falls back silently to Canvas, so the strong gate is to ask for a GPU backend by name — renderer: video.WEBGL (or video.WEBGPU) makes await app.init() reject instead of misrendering:

const app = new Application(1024, 768, {
    parent: "screen",
    renderer: video.WEBGL,      // init() rejects if WebGL 2 is unavailable
    cameraClass: Camera3d,
});
await app.init();

To branch rather than fail, read app.renderer.supportsDepthBuffer after init().

Symptom → cause

symptomcause
scene lit from underneathdirection Y sign — this is Y-down, so a sun overhead is +Y
a lit mesh renders fullbrightit had no normals — supply them, or let the engine generate them
a gradient across one mesh is impossibletint is per object — use vertexColors / setVertexColor
a mesh stays solid as you fade it outmeshes render opaque; only alpha 0 (hidden) and 1 differ
a glow is cut along a hard straight line, worst near a round object's limba flat billboard being depth tested against the geometry behind it — depthTest: false
vertex colour applies under a 2D camera but not Camera3dwrote the array directly without setting needsUpdate
Mesh: vertexColors has N entries, expected Mone colour per vertex, not per triangle or per index
nothing renders, or a backdrop covers everythingwrong depth sign — "far" is larger z when looking along +Z
distant objects vanish or warpscene exceeds the default far plane; setClipPlanes
distant surfaces z-fightnear too small for the scene scale
black canvas under Camera3dCanvas renderer (no depth buffer) — check the console.warn
everything flat and unlitlit: true with no Light3d in the world (falls back to fullbright), or a mesh under a 2D camera
a faded mesh goes dark instead of see-throughtransparent: false on it, or a 2D camera — the transparent pass needs a Camera3d
a soft-edged glow has hard edgesalphaCutoff discarded the soft texels; lower it
a glTF alphaMode: "BLEND" material draws opaquethe loader does not set transparent — one mesh can merge several materials, so set it yourself
two transparent objects pop as the camera movesper-object sorting cannot order intersecting geometry
a floating HUD draws behind the scenerya large |z| is far under Camera3d — use a small depth
fog hangs in the sky as thickly as in the valleyuniform fog — add heightFalloff so it pools low
mist sits on the ridges instead of the valley floorthe fogHeight sign — Y is DOWN here, density rises below it
distant geometry pops in against the skyno fog — camera.setFog({}) picks up the clip planes and background colour
fog does not match the sky after a background fadean explicit color was passed; omit it to track renderer.backgroundColor
geometry clips before it has finished fadingfog far beyond the clip far — omit the distances and they default to the clip planes
one marker must stay readable in fogfog: false on that mesh
an object casts no visible shadowwide and flat-bottomed — its own blob is underneath it; raise shadowScale (or slide it out with shadowOffset), not shadowGroundY, which haloes it instead of revealing it
a dark ring around the top of an objectshadowGroundY lifted too far, floating the blob up into the caster
a tilted blob never fades as its caster risesshadowGroundNormal faces inward, so the height came out negative and clamped to full strength
a blob vanishes completely after setting shadowGroundPointthe point is on the wrong side of the caster; the fade reads that as height and hides it
shadowGroundPoint throwsit is the one shadow setting that rejects a bad value instead of clamping: all three components must be finite
a scatter ignores shadowGroundPointInstancedMesh shares one plane across the set and warns; use one set per facet, or per-object meshes
a mesh sits at the wrong depth after being addedautoDepth overwrote pos.z with the child index — pass addChild(mesh, z)
a mesh sits half its size offanchorPoint — only on the 2D-camera path; a Camera3d mesh pivots on its model origin
a billboard tips over when the camera looks down"spherical", or a mistyped mode string falling through to it — use true / "cylindrical"
rotate() does nothing on a billboardthe camera owns its orientation; billboard: false and orient the quad yourself
the camera will not tilt its horizon to an arbitrary uplookAt(target) solves pitch and yaw only; pass the up as the next argument, or hand all three axes to setBasis
the view is upside down after lookAt(target, up)up is the direction that should appear UP ON SCREEN, and this is Y-down, so a level horizon is (0, -1, 0)
setBasis throws "forward must have a non-zero length"forward is all zeros, the one case that names no direction; a short one is fine, so a surface frame that shrank as it drifted still works
roll is ignored when the target is straight up or downno rotation about the view axis is determined there, so lookAt keeps the roll it had and setBasis falls back to the right axis
an object jumps to the camera planeVector3d.set(x, y) / camera.pos.set(x, y) zeroed its z
lighting looks wrong under a 3D cameraLight2d used instead of Light3d

Related skills

  • melonjs-renderables — the 2D z-ordering rules, anchors, custom draw
  • melonjs-getting-started — Application settings and the renderer fallback

Reviews

No reviews yet. Tell others what you thought.

Comments

No login needed. Share how it worked for you, ask a question, or note where it did not run.

No comments yet. Be the first to write one.

Comments are published right away. Do not include personal information such as email addresses or phone numbers. Up to 2 URLs.

0 / 1,000 characters

This form is protected by reCAPTCHA, and the Google Privacy Policy and Terms of Service apply.

More skills from this repository

melonjs

FreeSummary

Use this entry point to find the melonJS guide for a game development task, from application setup and scenes to sprites, input, physics, audio, and 3D. It also points to companion packages and the API index when the guides do not cover a need.

  • Choose a guide for a melonJS game task
  • Find the right setup steps for a new game
  • Locate guidance for a 3D scene or imported model
melonjs/melonJS6.4KUpdated Oct 29, 2026

Update older melonJS code for version 20.x. Review the asynchronous Application setup, renderer changes, post-effect API, and deprecated exports so you can identify outdated patterns when migrating or debugging expected APIs.

  • Migrating a game from melonJS 19.x
  • Checking why an older API is missing
  • Replacing deprecated shader assignments
melonjs/melonJS6.4KUpdated Oct 29, 2026

Load glTF, GLB, or OBJ/MTL assets into melonJS and understand how their materials, lights, animations, shadows, and instances are handled. The guide explains loader options and common format limitations so you can configure scenes and diagnose missing or incorrectly rendered asset features.

  • Loading a glTF or GLB scene into a melonJS level
  • Adding a static OBJ model with its MTL material
  • Checking why imported materials or lights look different
melonjs/melonJS6.4KUpdated Oct 29, 2026

melonjs-audio

FreeSummary

Manage sound in melonJS games: load audio, play effects and looping background tracks, control volume, and place sounds in the game world. It also covers generated tones and noise, whole-mix effects, and common causes of silent playback.

  • Loading sound files and choosing supported audio formats
  • Playing sound effects and looping background music
  • Positioning sounds around a player or game object
melonjs/melonJS6.4KUpdated Oct 29, 2026

Use this skill to control melonJS cameras and draw shapes, lines, and gradients. It covers target following, camera bounds and effects, coordinate conversion, clipping, rendering into a canvas target, and reading rendered pixels for images or data.

  • Making a camera follow a player within world bounds
  • Converting pointer or 3D positions to screen coordinates
  • Drawing shapes or gradients in a custom draw method
melonjs/melonJS6.4KUpdated Oct 29, 2026

Prepare a melonJS game for web builds, native app packaging, or mini-game platforms. The guide covers project scaffolding, browser syntax support, transpiling, asset-loading constraints, and practical bundle-size considerations.

  • Scaffolding a new melonJS project
  • Preparing a game for iOS or Android packaging
  • Checking support for older browsers
melonjs/melonJS6.4KUpdated Oct 29, 2026

See all skills by melonjs

Report a problem with this skill