-
-
Notifications
You must be signed in to change notification settings - Fork 663
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. - 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.
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
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.
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).