-
-
Notifications
You must be signed in to change notification settings - Fork 662
2.5D Games
Part of Working in 3D.
Camera3d isn't only for full 3D arcade games like AfterBurner. A common adjacent use case is 2.5D: a perspective camera for visual depth, but gameplay that lives entirely on a flat XY slice — Paper Mario, Octopath Traveler, parallax-heavy 2D side-scrollers, anything where "depth" is camera atmosphere, not gameplay state.
The Camera3d + Octree + SAT stack handles this cleanly. The recipe:
- Use
cameraClass: Camera3dfor the perspective look. - Render the characters as
Sprite3dbillboards — paper-thin sprites that turn to face the camera (the defining Paper Mario look). See Paper-thin characters below. - Place all gameplay entities (player, enemies, projectiles, pickups) at a shared Z — typically
z = 0. UseaddChild(child, 0)to set Z atomically on insertion. - Place parallax layers (foreground props, background mountains, distant clouds) at distinct Z values: closer-to-camera at
z < 0, farther atz > 0. The perspective projection scales them automatically — no per-frame work to keep parallax in sync. - Use existing 2D SAT collision (
me.collision.check, Body shapes,world.adapter.queryAABB) as-is. SAT runs on Rect/Polygon/Ellipse in the XY plane; it doesn't care which camera class is rendering. Collision stays 2D even though the character renders as aSprite3d— theSprite3dis just the entity's renderable; itsBodykeeps a flat shape.
Why it works:
Each entity's 2D bounds is treated as a point in Z at the entity's pos.z. Two entities sharing the same Z classify into the same Z octant, so the broadphase walk surfaces them as candidates and SAT runs the XY check exactly as it would in a 2D world.
Cost:
Octree carries ~1.3-2× the per-insert overhead of QuadTree and roughly 2× the tree memory vs an equivalent QuadTree at the same entity count. Negligible unless you have thousands of bodies. If you genuinely have hundreds of entities AND a Camera3d AND every entity is on the same Z plane, you can opt back to QuadTree by setting world.sortOn = "z" after stage init.
Mini example:
class PaperMarioStage extends Stage {
onResetEvent(app) {
// Distant background — pushed back so perspective shrinks it
app.world.addChild(new BackdropMountains(), 200);
// Mid parallax — slow-scroll trees
app.world.addChild(new ParallaxTrees(), 50);
// GAMEPLAY plane — all collidable bodies share z = 0.
// player / enemies render as Sprite3d billboards (see below).
app.world.addChild(player, 0);
app.world.addChild(new EnemyManager(), 0);
// Foreground props — closer to camera, in front of player
app.world.addChild(new ForegroundGrass(), -30);
}
}
const app = new Application(canvas, {
cameraClass: Camera3d, // perspective view
});
state.set(state.PLAY, new PaperMarioStage());Keeping parallax out of collision queries:
The Octree partitions on Z, so distant-Z parallax will often land in different octants than gameplay and be pruned by the broadphase walk — but this is best-effort, not a guarantee. Items at the Octree's depth midpoint (z=0 in the default ±10000 root box) stay at the root level and surface in every query, and a 2D Rect query has no z so it descends into all octants from root anyway. For deterministic isolation between gameplay and parallax, give parallax renderables isKinematic = true so the broadphase skips them entirely on insert, or rely on per-pair collisionType / collisionMask filtering on the narrowphase result. Parallax that you don't want hit-tested should be kinematic — this is the same advice that already applies to 2D parallax under Camera2d + QuadTree.
What makes a scene read as Paper Mario (rather than "2D sprites in a 3D-ish scene") is that the characters are flat cards that always turn to face the camera. That's exactly Sprite3d in "cylindrical" billboard mode: it faces the camera but stays upright, so a character never goes edge-on as the camera moves. It animates through the same API as a 2D Sprite, flips to face its travel direction, and cuts its transparent background out cleanly.
import { Sprite3d } from "melonjs";
// a billboarded, animated character (the entity's renderable)
const hero = new Sprite3d(0, 0, {
image: "hero", // a spritesheet with a transparent background
framewidth: 32, frameheight: 48,
width: 64, height: 96, // world size of the card
billboard: "cylindrical", // face the camera, stay upright
alphaCutoff: 0.5, // cut out the transparent background (opaque mesh pass)
});
hero.addAnimation("idle", [0, 1]);
hero.addAnimation("walk", [2, 3, 4, 5]);
hero.setCurrentAnimation("walk");
// later, when moving:
hero.flipX(velocityX < 0); // face the way we're walking
app.world.addChild(hero, 0); // gameplay plane, z = 0Use it as an Entity's renderable (so the Body drives the 2D-SAT collision while the Sprite3d draws), or add it directly with a separate Body for hit-testing.
Two caveats (small, hand-rolled — neither blocks a basic clone):
-
Ground anchoring. A
Sprite3d's quad is centered, so to plant a character's feet on the ground you offset its position by half its height (e.g. set the entity'spos.yso the bottom edge meets the floor). There's no bottom-anchor option yet. -
Shadows. There's no 3D shadow system; fake the classic blob shadow with a flat dark quad under the character — a
Sprite3dwithbillboard: false(a small soft-edged dark texture) laid on the ground plane, or a tintedMeshquad.
The Billboard Sprites example is the live reference — the same animated character shown in all three billboard modes, loaded from a packed (rotated + trimmed) atlas, with transparent cut-out backgrounds.
The same recipe — perspective Camera3d, parallax layers at distinct Z, gameplay on a shared plane — drives the glTF Scene example, which loads a Blender-authored platformer diorama (see Loading glTF / GLB scenes).