diff --git a/packages/examples/src/examples/afterBurner/ExampleAfterBurner.tsx b/packages/examples/src/examples/afterBurner/ExampleAfterBurner.tsx
new file mode 100644
index 000000000..012f14e24
--- /dev/null
+++ b/packages/examples/src/examples/afterBurner/ExampleAfterBurner.tsx
@@ -0,0 +1,541 @@
+/**
+ * melonJS — AfterBurner-style Camera3d showcase (MVP).
+ *
+ * Behind-the-plane arcade shooter. Player jet sits in the screen plane
+ * (XY), enemies fly toward the camera from the horizon (high z → low z),
+ * bullets fly away from the camera (low z → high z). Camera is always
+ * behind+slightly-above the player, fixed forward look — no yaw, so
+ * sprites stay face-on without billboarding.
+ *
+ * This is the v0 mechanic skeleton — monster.png as placeholder for the
+ * jet and enemies. Polish (Kenney 3D meshes, particle exhaust, lock-on
+ * missile, motion-blur post-FX, procedural audio) comes after the loop
+ * proves out.
+ *
+ * Controls: arrow keys / WASD to maneuver, space to fire.
+ *
+ * Copyright (C) 2011 - 2026 AltByte Pte Ltd — MIT License.
+ * See `packages/examples/LICENSE.md` for full license + asset credits.
+ */
+import type { CanvasRenderer, WebGLRenderer } from "melonjs";
+import {
+ Application,
+ type Camera3d,
+ Camera3d as Camera3dClass,
+ type Gradient,
+ input,
+ loader,
+ Matrix3d,
+ Renderable,
+ Sprite,
+ Stage,
+ state,
+ type Vector3d,
+ video,
+ type World,
+} from "melonjs";
+import monsterImg from "../shaderEffects/assets/monster.png";
+import { createExampleComponent } from "../utils";
+
+type Renderer = CanvasRenderer | WebGLRenderer;
+
+// World coordinate system:
+// +X right, +Y down (engine convention), +Z forward (away from camera).
+// Player anchored at z = PLAYER_Z; XY moves freely within play bounds.
+// Enemies spawn at SPAWN_Z and decrement z each frame.
+// Bullets spawn at player.z and increment z each frame.
+const PLAYER_Z = 200;
+const SPAWN_Z = 3000;
+const DESPAWN_Z_FAR = 4000; // bullet past-horizon cleanup
+const DESPAWN_Z_NEAR = 0; // enemy past-player cleanup
+const PLAY_BOUND_X = 350;
+const PLAY_BOUND_Y = 200;
+
+// Movement / gameplay tuning.
+const PLAYER_SPEED = 500; // world units per second
+// camera banking — pitch / yaw / roll radians at the play-bound edge.
+// Larger = more dramatic horizon + view tilt at the corners. Roll is
+// the signature After Burner move — the whole world rotates when the
+// pilot banks left/right.
+const MAX_BANK_PITCH = 0.35;
+const MAX_BANK_YAW = 0.22;
+const MAX_BANK_ROLL = 0.18;
+const BULLET_SPEED = 1800; // world units per second toward +Z
+const ENEMY_SPEED = 600; // world units per second toward -Z
+const ENEMY_SPAWN_INTERVAL_MS = 700;
+const FIRE_COOLDOWN_MS = 140;
+const HIT_RADIUS = 60; // sphere radius for collision
+
+// ground speed-line tuning
+const SPEED_LINE_COUNT = 14;
+const SPEED_LINE_SCROLL_PER_S = 1.6; // cycles per second — faster = more "Mach"
+const HORIZON_BASE_FRACTION = 0.55; // baseline horizon Y at pitch = 0
+
+/**
+ * Stage subclass that paints a sunset sky → horizon → desert ground
+ * gradient + animated speed-line chevrons as a screen-space backdrop
+ * **before** the world (and its perspective camera) draws. Done via an
+ * ortho projection swap so the gradient and lines fill the canvas
+ * regardless of Camera3d's perspective matrix; we restore the camera's
+ * projection before delegating to `super.draw` so gameplay still
+ * renders correctly.
+ *
+ * Two effects keyed off the camera:
+ * - **horizon Y tracks `camera.pitch`** — when the player banks up the
+ * horizon drops, when they dive it climbs. Pixel offset is
+ * `pitch / (fov / 2) * (screenH / 2)`, the standard
+ * perspective→screen formula for a point at infinity.
+ * - **speed-line chevrons** scroll outward from the vanishing point
+ * on the ground each frame, animated by `scrollT` advanced from
+ * `update(dt)`. Sells the "Mach-2 forward motion" feel.
+ */
+class SkyboxStage extends Stage {
+ app: Application | null = null;
+ skyR = 0;
+ skyG = 0;
+ skyB = 0;
+ groundR = 0;
+ groundG = 0;
+ groundB = 0;
+ sky: Gradient | null = null;
+ ground: Gradient | null = null;
+ orthoProjection = new Matrix3d();
+ scrollT = 0; // 0..1, animated each frame
+
+ override onResetEvent(app: Application): void {
+ this.app = app;
+ this.scrollT = 0;
+ }
+
+ override update(dt: number): boolean {
+ this.scrollT = (this.scrollT + (dt / 1000) * SPEED_LINE_SCROLL_PER_S) % 1;
+ return super.update(dt);
+ }
+
+ override draw(renderer: Renderer, world: World): void {
+ if (this.app) {
+ const w = renderer.width;
+ const h = renderer.height;
+
+ // Read the live Camera3d from this stage's own cameras map
+ // — `app.viewport` is set at reset and can lag a stage swap.
+ const camera = this.cameras.get("default") as Camera3d | undefined;
+ if (!camera) {
+ super.draw(renderer, world);
+ return;
+ }
+
+ // Horizon Y from camera pitch. With Y-down + the lookAt
+ // math in Camera3d, negative pitch = looking down (target
+ // below camera) → horizon rises on screen; positive pitch
+ // = looking up → horizon drops. `tan(pitch) / tan(fov/2)`
+ // gives the NDC offset, *h/2 converts to pixels.
+ const fov = camera.fov;
+ const pitchOffsetPx =
+ (Math.tan(camera.pitch) / Math.tan(fov / 2)) * (h / 2);
+ const horizon = h * HORIZON_BASE_FRACTION + pitchOffsetPx;
+ // clamp so the ground is always at least a sliver visible
+ // (prevents pathological all-sky / all-ground frames during
+ // extreme pitches)
+ const horizonClamped = Math.max(40, Math.min(h - 40, horizon));
+
+ // Swap to a screen-space ortho projection so fillRect lands
+ // on actual pixels, not perspective-projected world rects.
+ renderer.save();
+ renderer.resetTransform();
+ this.orthoProjection.ortho(0, w, h, 0, -1, 1);
+ renderer.setProjection(this.orthoProjection);
+
+ // Apply the camera roll to the backdrop. Camera3d doesn't
+ // have a built-in `roll` field, so we hang one on the
+ // camera object from GameController.updateCamera and read
+ // it back here (typed via cast). When the player banks,
+ // the horizon and speed-line fan tilt with the cockpit.
+ const roll = (camera as Camera3d & { roll?: number }).roll ?? 0;
+ if (roll !== 0) {
+ renderer.translate(w / 2, h / 2);
+ renderer.rotate(roll);
+ renderer.translate(-w / 2, -h / 2);
+ }
+
+ // Rebuild the gradients per frame so they stretch correctly
+ // with the moving horizon. Two `createLinearGradient` calls
+ // per frame is cheap (just object allocation + stop list)
+ // and lets the horizon Y be fully dynamic.
+ this.sky = renderer.createLinearGradient(0, 0, 0, horizonClamped);
+ this.sky.addColorStop(0, "#0d1442"); // deep blue zenith
+ this.sky.addColorStop(0.55, "#4a2a82"); // purple mid-sky
+ this.sky.addColorStop(0.85, "#d4476a"); // pink lower-sky
+ this.sky.addColorStop(1, "#ff9c4a"); // bright orange at horizon
+
+ this.ground = renderer.createLinearGradient(0, horizonClamped, 0, h);
+ this.ground.addColorStop(0, "#b8623c"); // warm sand at horizon
+ this.ground.addColorStop(0.3, "#5c2e1a"); // mid-ground brown
+ this.ground.addColorStop(1, "#1a0e08"); // near-black under-camera
+
+ // Oversize the rects by `pad` on every side so the roll
+ // rotation doesn't reveal black corners. 200px buffer covers
+ // the maximum bank without over-painting noticeably.
+ const pad = 200;
+ renderer.setColor(this.sky);
+ renderer.fillRect(-pad, -pad, w + pad * 2, horizonClamped + pad);
+ renderer.setColor(this.ground);
+ renderer.fillRect(
+ -pad,
+ horizonClamped,
+ w + pad * 2,
+ h - horizonClamped + pad,
+ );
+
+ // Speed-line chevrons. Each line goes from the vanishing
+ // point (centerX, horizon) outward to a position on the
+ // bottom edge. We animate by interpolating each line's
+ // progress 0→1 from vanishing point to bottom edge — t
+ // near 0 is short stub near the horizon, t near 1 is a
+ // long streak reaching the bottom of the screen. As t
+ // wraps, lines disappear off-screen and respawn at the
+ // horizon: instant Mach-2 forward-motion cue.
+ const vpX = w / 2;
+ const vpY = horizonClamped;
+ renderer.setColor("#ffd89c"); // warm cream — matches the sunset palette
+ renderer.lineWidth = 2;
+ for (let i = 0; i < SPEED_LINE_COUNT; i++) {
+ const t = (i / SPEED_LINE_COUNT + this.scrollT) % 1;
+ // non-linear distribution: lines accelerate as they
+ // approach the camera (matches real perspective motion)
+ const progress = t * t;
+ const groundHeight = h - vpY;
+ // each line's X spread at t=0 is 0 (at vp), at t=1 is
+ // half the canvas width on each side
+ const spread = w * 0.7;
+ // distribute lines around the screen by their index —
+ // avoids all lines being on top of each other when scrollT
+ // is the same for every line at evenly spaced t's
+ const angleOffset = (i / SPEED_LINE_COUNT) * 2 * Math.PI;
+ const sideX = Math.cos(angleOffset) * spread * progress;
+ const endX = vpX + sideX;
+ const endY = vpY + groundHeight * progress;
+ // fade the line in as it leaves the horizon, fade out
+ // just before exiting bottom — avoids hard pop-in/out
+ const alpha =
+ t < 0.1
+ ? t / 0.1 // fade in
+ : t > 0.9
+ ? (1 - t) / 0.1 // fade out
+ : 1;
+ renderer.setGlobalAlpha(alpha * 0.7);
+ renderer.strokeLine(vpX, vpY, endX, endY);
+ }
+ renderer.setGlobalAlpha(1);
+
+ renderer.restore();
+ }
+ super.draw(renderer, world);
+ }
+}
+
+const createGame = () => {
+ const app = new Application(1024, 768, {
+ parent: "screen",
+ renderer: video.WEBGL,
+ scale: "auto",
+ cameraClass: Camera3dClass,
+ });
+
+ // world.backgroundColor stays transparent (alpha 0) — the SkyboxStage
+ // paints the actual visible background each frame
+ app.world.backgroundColor.setColor(0, 0, 0, 0);
+
+ loader.preload([{ name: "monster", type: "image", src: monsterImg }], () => {
+ // swap in the gradient-painting stage as the default
+ state.set(state.DEFAULT, new SkyboxStage());
+ state.change(state.DEFAULT, true);
+
+ const controller = new GameController(app);
+ app.world.addChild(controller);
+ });
+};
+
+interface Mover {
+ sprite: Sprite;
+ vx: number; // world units / second
+ vy: number;
+ vz: number;
+}
+
+/**
+ * Hidden Renderable that owns the per-frame game tick. It draws
+ * nothing — its `update(dt)` is called by the world container every
+ * frame and runs all gameplay logic. Lives in the world so the engine
+ * delivers `dt` correctly without us needing to track timestamps.
+ */
+class GameController extends Renderable {
+ app: Application;
+ camera: Camera3d;
+ player: Sprite;
+ bullets: Mover[] = [];
+ enemies: Mover[] = [];
+ score = 0;
+ gameOver = false;
+ lastEnemySpawnMs = 0;
+ lastFireMs = 0;
+ hud: HTMLElement | null = null;
+ overlay: HTMLElement | null = null;
+
+ // Always-behind camera offsets. Y-down convention → negative Y is up.
+ static readonly CAM_OFFSET_Y = -80;
+ static readonly CAM_OFFSET_Z = -350;
+
+ constructor(app: Application) {
+ // Renderable with zero bounds — it doesn't draw, just ticks.
+ super(0, 0, 1, 1);
+ this.app = app;
+ this.camera = app.viewport as Camera3d;
+ this.alwaysUpdate = true; // tick even when off-camera
+
+ // Bind input. The third arg is `lock` — when true, the action
+ // fires once per press (single-shot, good for jump). We want
+ // hold-to-repeat for everything except restart, so default
+ // `lock = false` for movement + fire (own cooldown handles
+ // fire rate). Restart stays lock=true so spamming R doesn't
+ // thrash the reset.
+ input.bindKey(input.KEY.LEFT, "left");
+ input.bindKey(input.KEY.A, "left");
+ input.bindKey(input.KEY.RIGHT, "right");
+ input.bindKey(input.KEY.D, "right");
+ input.bindKey(input.KEY.UP, "up");
+ input.bindKey(input.KEY.W, "up");
+ input.bindKey(input.KEY.DOWN, "down");
+ input.bindKey(input.KEY.S, "down");
+ input.bindKey(input.KEY.SPACE, "fire");
+ input.bindKey(input.KEY.R, "restart", true);
+
+ // Player jet placeholder.
+ this.player = new Sprite(0, 0, { image: "monster" });
+ this.player.scale(0.35);
+ app.world.addChild(this.player);
+ this.player.depth = PLAYER_Z;
+
+ this.setupHud();
+ this.updateCamera();
+ }
+
+ setupHud(): void {
+ const parent = this.app.renderer.getCanvas().parentElement;
+ if (!parent) return;
+ parent.style.position = "relative";
+
+ this.hud = document.createElement("div");
+ this.hud.style.cssText =
+ "position:absolute;top:60px;left:16px;color:#ffe066;" +
+ "font-family:'Courier New',monospace;font-size:20px;font-weight:bold;" +
+ "text-shadow:0 0 4px #000;z-index:1000;pointer-events:none;";
+ parent.appendChild(this.hud);
+
+ this.overlay = document.createElement("div");
+ this.overlay.style.cssText =
+ "position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);" +
+ "color:#ff5566;font-family:'Courier New',monospace;font-size:42px;" +
+ "font-weight:bold;text-shadow:0 0 12px #000;z-index:1000;display:none;" +
+ "text-align:center;pointer-events:none;";
+ parent.appendChild(this.overlay);
+
+ this.refreshHud();
+ }
+
+ refreshHud(): void {
+ if (this.hud) {
+ this.hud.textContent = `SCORE ${this.score.toString().padStart(6, "0")}`;
+ }
+ }
+
+ updateCamera(): void {
+ // Decoupled chase cam: position follows the player loosely so
+ // the jet feels mobile on-screen (full follow = player
+ // stuck-at-center, defeats the input feedback), but
+ // pitch/yaw/roll are driven DIRECTLY from player position so
+ // the view-tilt is dramatic at the play-bound corners. Skips
+ // `lookAt` entirely — we set the rotation we want.
+ (this.camera.pos as Vector3d).set(
+ this.player.pos.x * 0.3,
+ this.player.pos.y * 0.3 + GameController.CAM_OFFSET_Y,
+ PLAYER_Z + GameController.CAM_OFFSET_Z,
+ );
+ this.camera.pitch = (-this.player.pos.y / PLAY_BOUND_Y) * MAX_BANK_PITCH;
+ this.camera.yaw = (this.player.pos.x / PLAY_BOUND_X) * MAX_BANK_YAW;
+ // Roll lives as an ad-hoc property on the camera (Camera3d
+ // doesn't have a built-in roll field for now). SkyboxStage
+ // reads it back to rotate the horizon. Sign convention: banking
+ // right (positive X) rolls the cockpit left, which tilts the
+ // world to the right from the pilot's POV.
+ (this.camera as Camera3d & { roll: number }).roll =
+ (-this.player.pos.x / PLAY_BOUND_X) * MAX_BANK_ROLL;
+ }
+
+ spawnBullet(): void {
+ const b = new Sprite(0, 0, { image: "monster" });
+ b.scale(0.15); // bigger than a real bullet, but the placeholder
+ // is alpha-edged so the visible mass is smaller than the bounds
+ b.tint.setColor(255, 230, 90);
+ this.app.world.addChild(b);
+ b.pos.set(this.player.pos.x, this.player.pos.y);
+ b.depth = PLAYER_Z + 40;
+ this.bullets.push({ sprite: b, vx: 0, vy: 0, vz: BULLET_SPEED });
+ }
+
+ spawnEnemy(): void {
+ const e = new Sprite(0, 0, { image: "monster" });
+ e.scale(0.5);
+ this.app.world.addChild(e);
+ const ex = (Math.random() * 2 - 1) * PLAY_BOUND_X;
+ const ey = (Math.random() * 2 - 1) * PLAY_BOUND_Y;
+ e.pos.set(ex, ey);
+ e.depth = SPAWN_Z;
+ // partial homing — enemies drift toward where the player IS at
+ // spawn, not where they end up. Adds genuine threat without being
+ // a guaranteed hit.
+ const dx = this.player.pos.x - ex;
+ const dy = this.player.pos.y - ey;
+ const flightTime = (SPAWN_Z - PLAYER_Z) / ENEMY_SPEED;
+ this.enemies.push({
+ sprite: e,
+ vx: (dx / flightTime) * 0.4,
+ vy: (dy / flightTime) * 0.4,
+ vz: -ENEMY_SPEED,
+ });
+ }
+
+ removeMover(arr: Mover[], i: number): void {
+ const m = arr[i];
+ this.app.world.removeChild(m.sprite);
+ // swap-with-last: O(1), iteration order doesn't matter for gameplay
+ arr[i] = arr[arr.length - 1];
+ arr.pop();
+ }
+
+ setGameOver(): void {
+ this.gameOver = true;
+ if (this.overlay) {
+ this.overlay.style.display = "block";
+ this.overlay.innerHTML = `GAME OVER
SCORE ${this.score} — press R to restart`;
+ }
+ }
+
+ reset(): void {
+ for (let i = this.bullets.length - 1; i >= 0; i--) {
+ this.removeMover(this.bullets, i);
+ }
+ for (let i = this.enemies.length - 1; i >= 0; i--) {
+ this.removeMover(this.enemies, i);
+ }
+ this.player.pos.set(0, 0);
+ this.score = 0;
+ this.gameOver = false;
+ if (this.overlay) {
+ this.overlay.style.display = "none";
+ }
+ this.refreshHud();
+ }
+
+ override update(dt: number): boolean {
+ const dts = dt / 1000;
+
+ if (this.gameOver) {
+ if (input.isKeyPressed("restart")) {
+ this.reset();
+ }
+ return true;
+ }
+
+ // Player input → XY movement, clamped to play bounds.
+ let dx = 0;
+ let dy = 0;
+ if (input.isKeyPressed("left")) dx -= 1;
+ if (input.isKeyPressed("right")) dx += 1;
+ if (input.isKeyPressed("up")) dy -= 1;
+ if (input.isKeyPressed("down")) dy += 1;
+ if (dx !== 0 && dy !== 0) {
+ const inv = 1 / Math.sqrt(2);
+ dx *= inv;
+ dy *= inv;
+ }
+ this.player.pos.x = Math.max(
+ -PLAY_BOUND_X,
+ Math.min(PLAY_BOUND_X, this.player.pos.x + dx * PLAYER_SPEED * dts),
+ );
+ this.player.pos.y = Math.max(
+ -PLAY_BOUND_Y,
+ Math.min(PLAY_BOUND_Y, this.player.pos.y + dy * PLAYER_SPEED * dts),
+ );
+
+ const nowMs = performance.now();
+ if (
+ input.isKeyPressed("fire") &&
+ nowMs - this.lastFireMs >= FIRE_COOLDOWN_MS
+ ) {
+ this.spawnBullet();
+ this.lastFireMs = nowMs;
+ }
+ if (nowMs - this.lastEnemySpawnMs >= ENEMY_SPAWN_INTERVAL_MS) {
+ this.spawnEnemy();
+ this.lastEnemySpawnMs = nowMs;
+ }
+
+ // Advance bullets, despawn past the far edge.
+ for (let i = this.bullets.length - 1; i >= 0; i--) {
+ const b = this.bullets[i];
+ b.sprite.pos.x += b.vx * dts;
+ b.sprite.pos.y += b.vy * dts;
+ b.sprite.depth += b.vz * dts;
+ if (b.sprite.depth > DESPAWN_Z_FAR) {
+ this.removeMover(this.bullets, i);
+ }
+ }
+
+ // Advance enemies, resolve bullet hits, then player hits.
+ for (let i = this.enemies.length - 1; i >= 0; i--) {
+ const e = this.enemies[i];
+ e.sprite.pos.x += e.vx * dts;
+ e.sprite.pos.y += e.vy * dts;
+ e.sprite.depth += e.vz * dts;
+
+ if (e.sprite.depth < DESPAWN_Z_NEAR) {
+ this.removeMover(this.enemies, i);
+ continue;
+ }
+
+ let hit = false;
+ for (let j = this.bullets.length - 1; j >= 0; j--) {
+ const b = this.bullets[j];
+ const ddx = e.sprite.pos.x - b.sprite.pos.x;
+ const ddy = e.sprite.pos.y - b.sprite.pos.y;
+ const ddz = e.sprite.depth - b.sprite.depth;
+ if (ddx * ddx + ddy * ddy + ddz * ddz < HIT_RADIUS * HIT_RADIUS) {
+ this.removeMover(this.bullets, j);
+ hit = true;
+ break;
+ }
+ }
+ if (hit) {
+ this.removeMover(this.enemies, i);
+ this.score += 100;
+ this.refreshHud();
+ continue;
+ }
+
+ const pddx = e.sprite.pos.x - this.player.pos.x;
+ const pddy = e.sprite.pos.y - this.player.pos.y;
+ const pddz = e.sprite.depth - this.player.depth;
+ if (pddx * pddx + pddy * pddy + pddz * pddz < HIT_RADIUS * HIT_RADIUS) {
+ this.removeMover(this.enemies, i);
+ this.setGameOver();
+ return true;
+ }
+ }
+
+ this.updateCamera();
+ return true;
+ }
+}
+
+export const ExampleAfterBurner = createExampleComponent(createGame);
diff --git a/packages/examples/src/examples/camera3d/ExampleCamera3d.tsx b/packages/examples/src/examples/camera3d/ExampleCamera3d.tsx
new file mode 100644
index 000000000..adeecd95b
--- /dev/null
+++ b/packages/examples/src/examples/camera3d/ExampleCamera3d.tsx
@@ -0,0 +1,186 @@
+/**
+ * melonJS — Camera3d (perspective) minimal example.
+ *
+ * Three monster sprites stacked along the camera's forward axis at
+ * z = 200 / 400 / 600. Under perspective, the front one renders
+ * largest, the back one smallest — proving:
+ * - per-sprite depth flows from `sprite.depth` to the GPU vertex
+ * stream (PR A)
+ * - the Camera3d's perspective matrix scales sprites by their z
+ * (PR B)
+ * - painter-algorithm z-sorting puts the front sprite on top of
+ * the ones behind it (visible occlusion order)
+ *
+ * On-screen controls rotate the camera (yaw / pitch) and zoom in/out.
+ * Drag the canvas to orbit. Demonstrates the simplest opt-in path —
+ * the Application-level `cameraClass: Camera3d` setting.
+ *
+ * Copyright (C) 2011 - 2026 AltByte Pte Ltd — MIT License.
+ * See `packages/examples/LICENSE.md` for full license + asset credits.
+ */
+import { DebugPanelPlugin } from "@melonjs/debug-plugin";
+import {
+ Application,
+ type Camera3d,
+ Camera3d as Camera3dClass,
+ input,
+ loader,
+ type Pointer,
+ plugin,
+ Sprite,
+ state,
+ video,
+} from "melonjs";
+import monsterImg from "../shaderEffects/assets/monster.png";
+import { createExampleComponent } from "../utils";
+
+const createGame = () => {
+ // opt in to Camera3d at the Application level — every stage in this
+ // app gets a Camera3d as its default camera (the loader screen pins
+ // to Camera2d via its own constructor regardless).
+ const app = new Application(1024, 768, {
+ parent: "screen",
+ renderer: video.WEBGL,
+ scale: "auto",
+ cameraClass: Camera3dClass,
+ });
+
+ app.world.backgroundColor.parseCSS("#0a0a14");
+ plugin.register(DebugPanelPlugin, "debugPanel");
+
+ loader.preload([{ name: "monster", type: "image", src: monsterImg }], () => {
+ // loader.preload internally transitions to state.LOADING (the
+ // DefaultLoadingScreen). Transition back to the default game
+ // stage so its Camera3d becomes the active viewport.
+ state.change(state.DEFAULT, true);
+
+ // three monsters along the camera's forward axis at increasing
+ // depth. Same x, same y — only z differs. Perspective scales
+ // each one inversely to z.
+ const depths = [200, 400, 600];
+ for (const z of depths) {
+ const sprite = new Sprite(0, 0, { image: "monster" });
+ sprite.scale(0.5);
+ app.world.addChild(sprite);
+ // set depth AFTER addChild — Container.autoDepth (default
+ // true) would otherwise overwrite our intended z
+ sprite.depth = z;
+ }
+
+ // the app's default camera is now a Camera3d (via cameraClass).
+ const camera = app.viewport as Camera3d;
+
+ // orbit state: yaw / pitch / distance. Driven by drag + buttons.
+ let yaw = 0;
+ let pitch = 0;
+ let distance = 700;
+
+ const updateCameraPos = () => {
+ // orbit around the middle sprite (z = 400). When yaw/pitch
+ // are 0, the camera sits at z = 400 - distance (behind the
+ // middle sprite) and looks at it.
+ const target = 400;
+ camera.pos.set(
+ Math.sin(yaw) * Math.cos(pitch) * -distance,
+ Math.sin(pitch) * distance,
+ target - Math.cos(yaw) * Math.cos(pitch) * distance,
+ );
+ camera.lookAt(0, 0, target);
+ };
+ updateCameraPos();
+
+ // drag-to-orbit
+ let dragging = false;
+ let lastX = 0;
+ let lastY = 0;
+ input.registerPointerEvent("pointerdown", camera, (ev: Pointer) => {
+ dragging = true;
+ lastX = ev.gameX;
+ lastY = ev.gameY;
+ });
+ input.registerPointerEvent("pointerup", camera, () => {
+ dragging = false;
+ });
+ input.registerPointerEvent("pointermove", camera, (ev: Pointer) => {
+ if (!dragging) {
+ return;
+ }
+ yaw += (ev.gameX - lastX) * 0.005;
+ pitch = Math.max(
+ -Math.PI / 2 + 0.1,
+ Math.min(Math.PI / 2 - 0.1, pitch - (ev.gameY - lastY) * 0.005),
+ );
+ lastX = ev.gameX;
+ lastY = ev.gameY;
+ updateCameraPos();
+ });
+
+ // on-screen HTML control panel — yaw / pitch / zoom / reset.
+ // HTML buttons live above the canvas; `#screen > *` already
+ // has `pointer-events: auto` (PR A's CSS fix) so they're
+ // clickable.
+ const panel = document.createElement("div");
+ panel.style.cssText =
+ "position:absolute;top:60px;left:16px;display:grid;" +
+ "grid-template-columns:repeat(3,40px);grid-template-rows:repeat(4,40px);" +
+ "gap:4px;z-index:1000;font-family:sans-serif;";
+ const mkButton = (label: string, gridArea: string, handler: () => void) => {
+ const b = document.createElement("button");
+ b.textContent = label;
+ b.style.cssText =
+ "background:#1a1a1a;color:#e0e0e0;border:1px solid #444;" +
+ "border-radius:4px;cursor:pointer;font-size:18px;" +
+ `grid-area:${gridArea};`;
+ b.addEventListener("click", handler);
+ panel.appendChild(b);
+ };
+ const YAW_STEP = 0.15;
+ const PITCH_STEP = 0.1;
+ const ZOOM_STEP = 60;
+ mkButton("▲", "1 / 2 / 2 / 3", () => {
+ pitch = Math.min(Math.PI / 2 - 0.1, pitch + PITCH_STEP);
+ updateCameraPos();
+ });
+ mkButton("◀", "2 / 1 / 3 / 2", () => {
+ yaw -= YAW_STEP;
+ updateCameraPos();
+ });
+ mkButton("●", "2 / 2 / 3 / 3", () => {
+ yaw = 0;
+ pitch = 0;
+ distance = 700;
+ updateCameraPos();
+ });
+ mkButton("▶", "2 / 3 / 3 / 4", () => {
+ yaw += YAW_STEP;
+ updateCameraPos();
+ });
+ mkButton("▼", "3 / 2 / 4 / 3", () => {
+ pitch = Math.max(-Math.PI / 2 + 0.1, pitch - PITCH_STEP);
+ updateCameraPos();
+ });
+ mkButton("−", "4 / 1 / 5 / 2", () => {
+ distance = Math.min(1500, distance + ZOOM_STEP);
+ updateCameraPos();
+ });
+ mkButton("+", "4 / 3 / 5 / 4", () => {
+ distance = Math.max(150, distance - ZOOM_STEP);
+ updateCameraPos();
+ });
+
+ const hint = document.createElement("div");
+ hint.textContent = "Drag or use controls";
+ hint.style.cssText =
+ "position:absolute;top:240px;left:16px;color:#888;" +
+ "font-family:sans-serif;font-size:12px;z-index:1000;";
+
+ const parent = app.renderer.getCanvas().parentElement;
+ if (parent) {
+ parent.style.position = "relative";
+ parent.appendChild(panel);
+ parent.appendChild(hint);
+ }
+ });
+};
+
+export const ExampleCamera3d = createExampleComponent(createGame);
diff --git a/packages/examples/src/main.tsx b/packages/examples/src/main.tsx
index ca36da0ad..e5a1c67f4 100644
--- a/packages/examples/src/main.tsx
+++ b/packages/examples/src/main.tsx
@@ -33,6 +33,16 @@ const ExampleBlendModes = lazy(() =>
default: m.ExampleBlendModes,
})),
);
+const ExampleAfterBurner = lazy(() =>
+ import("./examples/afterBurner/ExampleAfterBurner").then((m) => ({
+ default: m.ExampleAfterBurner,
+ })),
+);
+const ExampleCamera3d = lazy(() =>
+ import("./examples/camera3d/ExampleCamera3d").then((m) => ({
+ default: m.ExampleCamera3d,
+ })),
+);
const ExampleClipping = lazy(() =>
import("./examples/clipping/ExampleClipping").then((m) => ({
default: m.ExampleClipping,
@@ -223,6 +233,22 @@ const examples: {
description:
"Visual comparison of all supported blend modes (normal, multiply, screen, overlay, darken, lighten, etc.).",
},
+ {
+ component: ,
+ label: "AfterBurner",
+ path: "after-burner",
+ sourceDir: "afterBurner",
+ description:
+ "Behind-the-plane arcade shooter using Camera3d perspective — arrows / WASD to fly, space to shoot.",
+ },
+ {
+ component: ,
+ label: "Camera3d (perspective)",
+ path: "camera-3d",
+ sourceDir: "camera3d",
+ description:
+ "Perspective camera orbiting a grid of sprite billboards in 3D space. Drag to rotate; closer sprites render larger.",
+ },
{
component: ,
label: "Clipping",
diff --git a/packages/melonjs/CHANGELOG.md b/packages/melonjs/CHANGELOG.md
index 6260b0c7b..8d3cf1432 100644
--- a/packages/melonjs/CHANGELOG.md
+++ b/packages/melonjs/CHANGELOG.md
@@ -5,6 +5,9 @@
**Highlights:** foundational vertex-pipeline work toward the 19.7 Camera3d release. Every batched-rendering shader (`Quad`, `LitQuad`, `Primitive`, GPU TMX) now carries per-sprite depth as a true `vec3` vertex attribute — unblocking perspective projection for sprites, enabling depth-aware custom `ShaderEffect`s, and aligning the entire batched pipeline with the WebGPU vertex model. Backward compatible: existing custom `GLShader` / `ShaderEffect` code keeps working unchanged.
### Added
+- **`Camera3d` — perspective camera that extends `Camera2d`** for true 3D-projected sprite rendering. Adds `fov`, `aspect`, `pitch`, `yaw`, `followOffset`, `lookAhead` on top of all the Camera2d state (shake, fade, color matrix, follow, post-effects, lighting overlay), so it slots into `Stage.cameras` as a drop-in replacement. Conventions match the rest of the engine: **Y-down + +Z forward** (sprite at higher `pos.y` appears lower on screen, sprite at higher `pos.z` is farther from the camera and renders smaller). Three opt-in paths: (a) `new Application(w, h, { cameraClass: Camera3d })` for app-wide default, (b) per-stage `super({ cameraClass: Camera3d })`, (c) per-instance `super({ cameras: [new Camera3d(0, 0, w, h, { fov, near, far })] })` for custom opts. `DefaultLoadingScreen` explicitly pins to `Camera2d` so the loader stays 2D regardless of app-level setting. Known limitations: `Light2d` and `Mesh` use 2D coordinates and won't track perspective correctly under Camera3d (use sprite billboards for 3D-projected gameplay; Light3d is future work).
+- **`Frustum` class** — encapsulates `fov` / `aspect` / `near` / `far` + the matching perspective `projectionMatrix`. Used internally by `Camera3d` as its projection source-of-truth. `frustum.set(fov, aspect, near, far)` atomically updates all four params and rebuilds the matrix in one call. Y-down + +Z forward convention baked into the matrix via a post-multiplied `scale(1, -1, -1)` so the standard `gl-matrix`-style perspective math gives results aligned with melonJS's screen conventions.
+- **`ApplicationSettings.cameraClass`** and **`StageSettings.cameraClass`** — new optional settings to declare which `Camera2d` subclass to instantiate as the default camera. Application-level setting applies to every stage that doesn't override; per-stage setting overrides the app-level. Defaults to `Camera2d`, preserving every existing app's behavior unchanged.
- **`renderer.setDepth(depth)`** — new public method on the base `Renderer`. Mirrors the existing `setTint` / `setColor` state-setter pattern. Sets `renderer.currentDepth`, which the batchers read at vertex-emit time and push as the z component of each vertex. `Renderable.preDraw` now forwards `this.depth` automatically, so user code typically never needs to call `setDepth` directly.
- Per-sprite depth on the GPU: all batched draw paths (`QuadBatcher`, `LitQuadBatcher`, `PrimitiveBatcher`, GPU TMX) now carry the renderable's `.depth` as the z component of their vertex stream. Default shaders consume it via `vec4(aVertex, 1.0)` in `gl_Position`. With an orthographic projection (the engine default), z has no visible effect — existing 2D apps render identically. With a perspective projection, sprites at different depths are correctly scaled and parallaxed by the projection matrix.
- **Multi-material OBJ rendering** — `Mesh` now correctly draws OBJ files with multiple `usemtl` directives + a bound MTL, instead of collapsing the whole model to a single uniform color. The OBJ parser emits `groups: Array<{materialName, start, count}>` matching the Three.js / glTF convention. The Mesh constructor bakes each material's `Kd` into a per-vertex color buffer at construction time, so multi-material meshes need **no extra draw calls per material** vs single-material rendering — large meshes still chunk across multiple draws to respect the WebGL batcher's vertex/index buffer limits (same behavior as today's single-material path), but adding more materials to a mesh doesn't multiply that count. `mesh.tint` multiplies on top at render time — flash / fade / team-color via `setTint` work exactly like single-material meshes. **Scope:** per-material diffuse colors (`Kd`) are fully supported on both WebGL and Canvas; per-material textures (each material with its own `map_Kd`) are NOT supported in this pipeline — the whole mesh shares a single texture binding (the first group's `map_Kd` if any, else a 1×1 white pixel for Kd-only models). Single-material meshes are unchanged — same code path, same allocations, same behavior. New `Multi-material OBJ` example showcases the feature with four Kenney Space Kit spacecraft, each given a distinct team color via `mesh.tint`.
@@ -13,6 +16,8 @@
- **Vertex attribute layout: `aVertex` widened from `vec2` to `vec3`** across `quad-multi.vert`, `quad-multi-lit.vert`, `primitive.vert`, `orthogonal-tmxlayer.vert`. `Mesh`'s shader already used `vec3 aVertex` — all batchers are now uniform. Per-vertex stride grows by 4 bytes (24 → 28 for the quad layout). Custom shaders binding attributes by name (`gl.getAttribLocation`) — the standard pattern, and what `GLShader` enforces — keep working unchanged; the byte-offset shift of `aRegion` / `aColor` / `aTextureId` is transparent because the batcher updates its own `vertexAttribPointer` offsets. Custom shaders declaring `attribute vec2 aVertex;` continue to work — WebGL silently drops the unused z component.
- **`VertexArrayBuffer.push()` signature gained a `z` parameter** between `y` and `u`: `push(x, y, z, u, v, tint, textureId?, normalTextureId?)`. `VertexArrayBuffer` is an internal class (not exported), but **subclasses of `QuadBatcher` / `PrimitiveBatcher` that reimplement `addQuad` / `drawVertices` from scratch** (not via `super.addQuad()`) and push to `vertexData` directly need to update their push call to the 7-arg form (insert `z` after `y`, default `0` works under ortho). Subclasses that delegate to `super.addQuad()` are unaffected.
- **`Camera2d` default near/far widened from `±1000` to `±1e6`.** With `aVertex.z` now participating in clip-space, the previous defaults would silently clip-cull any sprite with `depth` outside that range — common pitfalls being `Container.autoDepth = true` (default, assigns `pos.z = childCount`) with >1000 children, and Y-sort patterns (`sprite.depth = sprite.pos.y`) on tall maps. The new range covers every realistic 2D depth value while staying well within float32 precision. Override per-camera (`camera.near = -100; camera.far = 100`) for tighter z clipping when needed (e.g. perspective mode in Camera3d, PR B).
+- **`Camera2d.draw` factored into two new internal hook points** — `_applyContainerViewTransform(container, tx, ty)` and `_revertContainerViewTransform(container, tx, ty)` — so `Camera3d` can apply its full view rotation (pitch / yaw) on top of the existing translate without duplicating the rest of `Camera2d.draw`'s ~140-line body. Camera2d's default implementation matches the previous behavior bit-for-bit (just `container.translate(-tx, -ty)`); no user-visible change.
+- **`DefaultLoadingScreen` now constructs with explicit `cameraClass: Camera2d`** so the built-in loader screen stays 2D even when the host app sets `cameraClass: Camera3d` globally. Invisible behavior change for users (the loader was already Camera2d via the singleton path).
### Fixed
- None.
diff --git a/packages/melonjs/src/application/application.ts b/packages/melonjs/src/application/application.ts
index cfbd0e52a..b3e4cca09 100644
--- a/packages/melonjs/src/application/application.ts
+++ b/packages/melonjs/src/application/application.ts
@@ -441,6 +441,20 @@ export default class Application {
this.world.physic = physicLabel;
this.world.gpuTilemap = this.settings.gpuTilemap;
+ // If a custom cameraClass is specified, seed the world's sort
+ // mode from the camera's `defaultSortOn` static. Camera2d
+ // declares `"z"` (today's default — no behavior change for
+ // existing games); Camera3d declares `"depth"`, which switches
+ // the world to the camera-distance painter's sort required by
+ // perspective. User code can still override `world.sortOn` after
+ // this point; we only set the initial value.
+ const cameraClass = this.settings.cameraClass as
+ | { defaultSortOn?: "x" | "y" | "z" | "depth" }
+ | undefined;
+ if (cameraClass?.defaultSortOn) {
+ this.world.sortOn = cameraClass.defaultSortOn;
+ }
+
// report the active physics adapter once the world is wired —
// useful confirmation when a third-party adapter (e.g.
// `@melonjs/matter-adapter`) is plugged in via `settings.physic`.
@@ -530,10 +544,10 @@ export default class Application {
* Accepted values : "x", "y", "z", "depth"
* @see {@link World.sortOn}
*/
- get sortOn(): string {
+ get sortOn(): "x" | "y" | "z" | "depth" {
return this.world.sortOn;
}
- set sortOn(value: string) {
+ set sortOn(value: "x" | "y" | "z" | "depth") {
this.world.sortOn = value;
}
diff --git a/packages/melonjs/src/application/settings.ts b/packages/melonjs/src/application/settings.ts
index ba712456d..acd245994 100644
--- a/packages/melonjs/src/application/settings.ts
+++ b/packages/melonjs/src/application/settings.ts
@@ -3,6 +3,7 @@
* @import Renderer from "./../video/renderer.js";
*/
+import Camera2d from "../camera/camera2d";
import { RendererType } from "../const";
import { PhysicsAdapter } from "../physics/adapter";
import Renderer from "../video/renderer";
@@ -178,6 +179,23 @@ export type ApplicationSettings = {
* a custom batcher class (WebGL only)
*/
batcher?: (new (renderer: any) => Batcher) | undefined;
+
+ /**
+ * Default camera class instantiated for any {@link Stage} that does not
+ * explicitly provide its own cameras. Set to {@link Camera3d} to opt
+ * every stage in the app into perspective rendering by default. Stages
+ * can still override per-instance via `super({ cameras: [...] })` or
+ * per-class via `super({ cameraClass: Camera2d })`. Built-in stages
+ * (e.g. the loader screen) explicitly use {@link Camera2d} regardless
+ * of this setting.
+ * @default Camera2d
+ */
+ cameraClass?: new (
+ minX: number,
+ minY: number,
+ maxX: number,
+ maxY: number,
+ ) => Camera2d;
} & (
| {
/**
diff --git a/packages/melonjs/src/camera/camera2d.ts b/packages/melonjs/src/camera/camera2d.ts
index afc66cdc0..35b6834b7 100644
--- a/packages/melonjs/src/camera/camera2d.ts
+++ b/packages/melonjs/src/camera/camera2d.ts
@@ -56,6 +56,16 @@ const targetV = new Vector2d();
* this.cameras.set("minimap", minimap);
*/
export default class Camera2d extends Renderable {
+ /**
+ * Preferred `Container.sortOn` mode for this camera type. Read by
+ * `Application` (and `Stage`) at bootstrap to initialize the world
+ * container's sort. Camera2d uses `"z"` (painter's-algorithm 2D
+ * layering, higher pos.z draws on top); `Camera3d` overrides to
+ * `"depth"` (camera-distance sort required by perspective).
+ * Subclasses can override to declare their own preferred mode.
+ */
+ static defaultSortOn: "x" | "y" | "z" | "depth" = "z";
+
/**
* Axis definition
* NONE no axis
@@ -900,8 +910,11 @@ export default class Camera2d extends Renderable {
// post-effect: bind FBO if shader effects are set (WebGL only)
const usePostEffect = r.beginPostEffect(this);
- // translate the world coordinates by default to screen coordinates
- container.translate(-translateX, -translateY);
+ // apply the world-to-camera-view transform on the container.
+ // Camera2d translates by -camera.pos; Camera3d overrides this
+ // hook to additionally apply the camera's rotation (pitch / yaw)
+ // in the correct order (R⁻¹ ∘ T(-pos), via post-multiplication).
+ this._applyContainerViewTransform(container, translateX, translateY);
this.preDraw(r);
@@ -1007,7 +1020,36 @@ export default class Camera2d extends Renderable {
}
}
- // translate the world coordinates by default to screen coordinates
+ // revert the world-to-camera-view transform applied above
+ this._revertContainerViewTransform(container, translateX, translateY);
+ }
+
+ /**
+ * Apply the world-to-camera-view transform to the container before
+ * its draw walk. Camera2d translates by `-camera.pos` (plus shake
+ * offset); Camera3d overrides this to additionally rotate by
+ * `-camera.pitch` / `-camera.yaw` in the correct order.
+ * @ignore
+ */
+ _applyContainerViewTransform(
+ container: Container,
+ translateX: number,
+ translateY: number,
+ ): void {
+ container.translate(-translateX, -translateY);
+ }
+
+ /**
+ * Revert the transform applied by {@link Camera2d#_applyContainerViewTransform}.
+ * Must undo each mutation in reverse order. Subclasses overriding
+ * `_applyContainerViewTransform` should override this too.
+ * @ignore
+ */
+ _revertContainerViewTransform(
+ container: Container,
+ translateX: number,
+ translateY: number,
+ ): void {
container.translate(translateX, translateY);
}
diff --git a/packages/melonjs/src/camera/camera3d.ts b/packages/melonjs/src/camera/camera3d.ts
new file mode 100644
index 000000000..34d60af19
--- /dev/null
+++ b/packages/melonjs/src/camera/camera3d.ts
@@ -0,0 +1,521 @@
+import { Matrix3d } from "../math/matrix3d.ts";
+import type { ObservableVector3d } from "../math/observableVector3d.ts";
+import { Vector3d } from "../math/vector3d.ts";
+import type Container from "./../renderable/container.js";
+import type Renderable from "./../renderable/renderable.js";
+import Camera2d from "./camera2d.ts";
+import Frustum, { type FrustumOptions } from "./frustum.ts";
+
+// Renderable.pos is an ObservableVector3d at runtime, but Polygon's
+// thinner declaration leaks all the way up to Camera2d, so TS sees
+// `pos: Vector2d`. Funnel the cast through one place.
+type Pos3d = ObservableVector3d;
+const posZ = (p: unknown): number => (p as Pos3d).z;
+
+// reusable unit-axis vectors for rotation calls. Pure constants so
+// allocation only happens once per module load, not per frame.
+const AXIS_X = new Vector3d(1, 0, 0);
+const AXIS_Y = new Vector3d(0, 1, 0);
+
+// Scratch matrices reused by `_rebuildFrustumPlanes` to avoid per-frame
+// allocation. Single-instance is safe because draw / update is
+// single-threaded and these are only touched inside one method.
+const _viewMatrix = new Matrix3d();
+const _viewProjection = new Matrix3d();
+
+/**
+ * A perspective camera that extends {@link Camera2d} with a view
+ * {@link Frustum} (fov / aspect / near / far) and orientation
+ * (pitch / yaw / roll). Slots into `Stage.cameras` as a drop-in
+ * replacement for `Camera2d` — inherits the post-effect FBO bracket,
+ * color-matrix, fade / shake / follow plumbing, and screen viewport.
+ *
+ * Conventions:
+ * - **Y-down + +Z forward.** Sprite at higher `pos.y` appears lower
+ * on screen (same as Camera2d). Sprite at higher `pos.z` is
+ * farther from the camera and renders smaller. Matches melonJS's
+ * 2D conventions so existing Camera2d code translates directly.
+ * - **Rotations are extrinsic XYZ.** `pitch` (X axis, look up/down),
+ * `yaw` (Y axis, look left/right), `roll` (Z axis, screen-plane
+ * bank — also exposed as `Camera2d.rotation` via Renderable
+ * inheritance for backward compatibility).
+ * - **Follow offset (PR B scope).** When a target is set,
+ * `followOffset` is applied in **world space**:
+ * `camera.pos = target.pos + followOffset`. Target-rotation-aware
+ * follow (Cinemachine / Unreal spring-arm style, where the offset
+ * rotates with the target's orientation) is deferred until a
+ * showcase needs it (e.g. AfterBurner's banking jet).
+ *
+
+ * Known limitations (PR B scope):
+ * - `Light2d` is 2D-only — visible artifacts under perspective.
+ * Avoid combining with Camera3d for now.
+ * - `Mesh` (3D models) maintains its own self-contained projection;
+ * meshes will render incorrectly under Camera3d unless their
+ * `projectionMatrix` is manually synced to the camera's. AfterBurner
+ * and similar showcases use sprite billboards instead (which scale
+ * automatically under perspective).
+ * - `localToWorld` / `worldToLocal` overrides fall back to the
+ * ortho-equivalent 2D projection at z=0. Full 3D unproject for
+ * arbitrary depth is future work.
+ * @category Camera
+ * @example
+ * // opt in app-wide:
+ * const app = new Application(1024, 768, {
+ * parent: "screen",
+ * cameraClass: Camera3d,
+ * });
+ *
+ * // or per-stage with custom fov:
+ * class GameStage extends Stage {
+ * constructor() {
+ * super({
+ * cameras: [new Camera3d(0, 0, 1024, 768, { fov: Math.PI / 3 })],
+ * });
+ * }
+ * }
+ */
+export default class Camera3d extends Camera2d {
+ /**
+ * Override `Camera2d.defaultSortOn` to declare `"depth"` as this
+ * camera's preferred sort mode. `Application` / `Stage` apply this
+ * to `world.sortOn` at bootstrap, so games opting into Camera3d via
+ * `cameraClass: Camera3d` get camera-distance painter's sort for
+ * free — the only correct sort for alpha-blended sprites under
+ * perspective.
+ */
+ static override defaultSortOn: "x" | "y" | "z" | "depth" = "depth";
+
+ /**
+ * the view frustum (perspective parameters + projection matrix).
+ * Mutating `frustum.fov` / `aspect` / `near` / `far` directly
+ * requires calling `frustum.update()` to rebuild the matrix;
+ * the proxy setters on this camera (`camera.fov = ...`) handle
+ * that automatically.
+ */
+ frustum: Frustum;
+
+ /**
+ * X-axis rotation in radians (look up/down). Positive values
+ * pitch the camera up.
+ * @default 0
+ */
+ pitch: number;
+
+ /**
+ * Y-axis rotation in radians (look left/right). Positive values
+ * yaw the camera to the right.
+ * @default 0
+ */
+ yaw: number;
+
+ /**
+ * Target-local offset from the followed target. When `target` is
+ * set via {@link Camera2d#follow}, the camera position resolves to
+ * `target.pos + followOffset`. Common usage: `(0, -2, -8)` for a
+ * behind-and-above third-person view.
+ *
+ * (PR B scope: treated as world-space — target rotation
+ * application is deferred until target orientation tracking is
+ * needed, e.g. AfterBurner's banking jet.)
+ * @default (0, 0, 0)
+ */
+ followOffset: Vector3d;
+
+ /**
+ * Reserved for future follow-look-ahead support — currently unused
+ * by `updateTarget`. The intent is: when wired in, the camera will
+ * look at `target.pos + lookAhead` instead of `target.pos`, so a
+ * follow-cam stays slightly ahead of its target (e.g. for a
+ * cinematic forward-looking shot in AfterBurner). Field is exposed
+ * now so user code can set it without waiting for the wiring.
+ * @default (0, 0, 1)
+ */
+ lookAhead: Vector3d;
+
+ /**
+ * @param minX - start x offset
+ * @param minY - start y offset
+ * @param maxX - end x offset
+ * @param maxY - end y offset
+ * @param [opts] - perspective parameters (see {@link FrustumOptions})
+ */
+ constructor(
+ minX: number,
+ minY: number,
+ maxX: number,
+ maxY: number,
+ opts?: FrustumOptions,
+ ) {
+ super(minX, minY, maxX, maxY);
+
+ // build the frustum with the user's opts, defaulting aspect to
+ // the camera viewport rect
+ this.frustum = new Frustum({
+ fov: opts?.fov ?? Math.PI / 3,
+ aspect: opts?.aspect ?? this.width / this.height,
+ near: opts?.near ?? 0.1,
+ far: opts?.far ?? 1000,
+ });
+
+ this.pitch = 0;
+ this.yaw = 0;
+ this.followOffset = new Vector3d(0, 0, 0);
+ this.lookAhead = new Vector3d(0, 0, 1);
+
+ // override Camera2d's wide ortho range — perspective wants
+ // tight near/far for meaningful z resolution
+ this.near = this.frustum.near;
+ this.far = this.frustum.far;
+
+ // copy the frustum's already-built perspective matrix over
+ // Camera2d's ortho (left by the super-constructor's call to
+ // `_updateProjectionMatrix`). We do NOT call our overridden
+ // `_updateProjectionMatrix` here, because that would re-derive
+ // `aspect` from the viewport rect and overwrite any custom
+ // aspect the user passed in `opts`. Auto-derivation is the
+ // right behavior on `resize()` — but at construction time,
+ // the user's explicit `opts.aspect` should win.
+ this.projectionMatrix.copy(this.frustum.projectionMatrix);
+ }
+
+ /**
+ * vertical field of view in radians. Setting this rebuilds the
+ * projection matrix. Proxies to `frustum.fov`.
+ */
+ get fov(): number {
+ return this.frustum.fov;
+ }
+ set fov(value: number) {
+ this.frustum.fov = value;
+ this.frustum.update();
+ this.projectionMatrix.copy(this.frustum.projectionMatrix);
+ }
+
+ /**
+ * aspect ratio (width / height). Auto-updated on `resize()`.
+ * Setting manually overrides the auto-derived value until the
+ * next resize. Proxies to `frustum.aspect`.
+ */
+ get aspect(): number {
+ return this.frustum.aspect;
+ }
+ set aspect(value: number) {
+ this.frustum.aspect = value;
+ this.frustum.update();
+ this.projectionMatrix.copy(this.frustum.projectionMatrix);
+ }
+
+ /**
+ * Rebuild the projection matrix from the frustum. Called by the
+ * base `Camera2d` constructor and by `resize()`. Camera3d's
+ * version replaces the ortho matrix with the frustum's perspective.
+ * @ignore
+ */
+ override _updateProjectionMatrix(): void {
+ // guard: this is called from the Camera2d super-constructor
+ // before our `frustum` field is initialized. Fall through to
+ // Camera2d's ortho path in that case; the Camera3d constructor
+ // re-runs this method after the frustum is built. TypeScript
+ // can't model "this method runs during super-construction" so
+ // the type system sees `this.frustum` as always-defined.
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ if (!this.frustum) {
+ super._updateProjectionMatrix();
+ return;
+ }
+ this.frustum.aspect = this.width / this.height;
+ this.frustum.near = this.near;
+ this.frustum.far = this.far;
+ this.frustum.update();
+ this.projectionMatrix.copy(this.frustum.projectionMatrix);
+ }
+
+ /**
+ * Resize the camera viewport and recompute aspect ratio.
+ * @param w - new width
+ * @param h - new height
+ * @returns this camera
+ */
+ override resize(w: number, h: number): this {
+ super.resize(w, h);
+ // super.resize calls _updateProjectionMatrix which already
+ // re-derives aspect — nothing more to do
+ return this;
+ }
+
+ /**
+ * Apply the camera's full 3D view transform to the world container.
+ * Order: rotate first (pitch, yaw), then translate by `-camera.pos`.
+ * Post-multiplication semantics give us
+ * `currentTransform = R⁻¹ ∘ T(-pos)` — applied to a world point,
+ * this subtracts the camera position then rotates by the camera's
+ * inverse orientation, which is the standard view transform.
+ * @ignore
+ */
+ override _applyContainerViewTransform(
+ container: Container,
+ translateX: number,
+ translateY: number,
+ ): void {
+ // Build the view matrix R⁻¹ ∘ T(-cam.pos) via the container's
+ // `currentTransform` using post-multiplication semantics.
+ //
+ // `Renderable.translate` / `.rotate` post-multiply: each call
+ // adds `currentTransform = currentTransform × M`. When this
+ // matrix is later applied to a world vertex P, the result is
+ // `currentTransform × P` — the rightmost matrix in the chain
+ // acts on P first.
+ //
+ // We want the view transform to first subtract the camera
+ // position (so vertices are camera-relative), then rotate by
+ // the camera's inverse orientation. To achieve
+ // `R(-pitch) ∘ R(-yaw) ∘ T(-pos)` as the final matrix, we
+ // post-multiply in that same left-to-right order:
+ // 1. rotate(-pitch, X) → currentTransform = R(-pitch)
+ // 2. rotate(-yaw, Y) → currentTransform = R(-pitch) ∘ R(-yaw)
+ // 3. translate(-pos) → currentTransform = R(-pitch) ∘ R(-yaw) ∘ T(-pos)
+ if (this.pitch !== 0) {
+ container.rotate(-this.pitch, AXIS_X);
+ }
+ if (this.yaw !== 0) {
+ container.rotate(-this.yaw, AXIS_Y);
+ }
+ container.translate(-translateX, -translateY, -posZ(this.pos));
+
+ // Refresh the container's painter's-algorithm order from the
+ // current camera position. The container's `sortOn = "depth"`
+ // comparator (set at Application bootstrap via
+ // `Camera3d.defaultSortOn`) handles the actual ordering — this
+ // `sortNow` just re-runs the same sort because the existing
+ // container lifecycle only re-sorts on child mutations, not on
+ // camera moves. Single sort per frame, uses the engine's normal
+ // sort path, no comparator hijacking.
+ container.sortNow(true);
+ }
+
+ /**
+ * Revert {@link Camera3d#_applyContainerViewTransform} in reverse
+ * order to restore the container's `currentTransform` to its
+ * pre-camera state.
+ * @ignore
+ */
+ override _revertContainerViewTransform(
+ container: Container,
+ translateX: number,
+ translateY: number,
+ ): void {
+ // reverse of apply: undo translate first, then yaw, then pitch
+ container.translate(translateX, translateY, posZ(this.pos));
+ if (this.yaw !== 0) {
+ container.rotate(this.yaw, AXIS_Y);
+ }
+ if (this.pitch !== 0) {
+ container.rotate(this.pitch, AXIS_X);
+ }
+ }
+
+ /**
+ * Point the camera at a world-space target by deriving pitch and
+ * yaw from the direction (target − camera.pos). Roll is unaffected.
+ *
+ * Three call shapes:
+ * - `lookAt(x, y, z)` — raw world coordinates
+ * - `lookAt(vector3d)` — a 3D point
+ * - `lookAt(renderable)` — uses `renderable.pos` (matches the
+ * `Renderable.lookAt(target)` signature so Camera3d is a structural
+ * drop-in replacement for Camera2d / Renderable in user code).
+ *
+ * Last-write-wins with manual `pitch` / `yaw` assignment: if you
+ * call `lookAt(...)` then set `camera.pitch = 0.1` directly, the
+ * next frame renders with the manual pitch.
+ * @param xOrTarget - target world x, or a target with `pos` / `x`,`y`,`z`
+ * @param y - target world y (only when first arg is a number)
+ * @param z - target world z (only when first arg is a number)
+ * @returns this camera
+ */
+ override lookAt(
+ xOrTarget: number | { x: number; y: number; z?: number; pos?: Pos3d },
+ y?: number,
+ z?: number,
+ ): this {
+ let tx: number;
+ let ty: number;
+ let tz: number;
+ if (typeof xOrTarget === "number") {
+ tx = xOrTarget;
+ ty = y ?? 0;
+ tz = z ?? 0;
+ } else if (xOrTarget.pos) {
+ tx = xOrTarget.pos.x;
+ ty = xOrTarget.pos.y;
+ tz = xOrTarget.pos.z;
+ } else {
+ tx = xOrTarget.x;
+ ty = xOrTarget.y;
+ tz = xOrTarget.z ?? 0;
+ }
+ const dx = tx - this.pos.x;
+ const dy = ty - this.pos.y;
+ const dz = tz - posZ(this.pos);
+
+ // yaw = atan2(dx, dz) — rotation around Y axis to face the
+ // XZ-plane projection of the direction vector
+ this.yaw = Math.atan2(dx, dz);
+
+ // pitch = atan2(dy, horizontalDistance). Y-down convention
+ // means positive dy = below origin, so positive pitch points
+ // the camera downward (matches engine Y-down + intuitive
+ // "pitch up = look up").
+ const horizontalDist = Math.sqrt(dx * dx + dz * dz);
+ this.pitch = Math.atan2(-dy, horizontalDist);
+
+ return this;
+ }
+
+ /**
+ * Convenience overload of `lookAt` accepting a {@link Vector3d}.
+ * @param target - world-space point to look at
+ * @returns this camera
+ */
+ setLookAt(target: Vector3d): this {
+ return this.lookAt(target.x, target.y, target.z);
+ }
+
+ /**
+ * Set the target-local follow offset. Called once when configuring
+ * a follow-cam (e.g. behind-and-above third person:
+ * `setFollowOffset(0, -2, -8)`).
+ * @param x - target-local x offset
+ * @param y - target-local y offset
+ * @param z - target-local z offset
+ * @returns this camera
+ */
+ setFollowOffset(x: number, y: number, z: number): this {
+ this.followOffset.set(x, y, z);
+ return this;
+ }
+
+ /**
+ * Override Camera2d's 2D follow logic to additionally resolve
+ * `followOffset` against the target's z. When `target` is set, the
+ * camera's world position becomes `target.pos + followOffset`.
+ *
+ * **Semantic change vs Camera2d.follow:** this override **does not
+ * honor `follow_axis`, `deadzone`, or `smoothFollow` / `damping`**.
+ * Camera3d tracks its target exactly each frame because the typical
+ * 3D use case (behind-the-plane follow-cam, third-person orbit) wants
+ * 1:1 tracking with no scroll-deadzone. If you need damped or
+ * axis-constrained follow under perspective, set `target = null` and
+ * lerp `camera.pos` toward the target manually in your `update()`.
+ *
+ * **PR B scope:** `followOffset` is treated as **world-space**.
+ * Target-rotation-aware follow (where the offset rotates with the
+ * target's orientation, Cinemachine / Unreal-style) lands when a
+ * showcase (AfterBurner's banking jet) demands it.
+ * @param dt - delta time in milliseconds (ignored — no damping)
+ * @ignore
+ */
+ override updateTarget(dt?: number): void {
+ const target = this.target;
+ if (target) {
+ // duck-type the z read via `'z' in target` so this works
+ // for both `Vector3d` (when the user passed a raw vector to
+ // `follow()`) and `ObservableVector3d` (when
+ // `follow(renderable)` assigned `renderable.pos`, which is
+ // observable not plain). The previous `instanceof Vector3d`
+ // check missed the observable variant — Renderable targets
+ // silently lost their depth.
+ const targetZ =
+ "z" in target && typeof target.z === "number" ? target.z : 0;
+ (this.pos as unknown as Pos3d).set(
+ target.x + this.followOffset.x,
+ target.y + this.followOffset.y,
+ targetZ + this.followOffset.z,
+ );
+ this.isDirty = true;
+ return;
+ }
+ // no target — fall through to Camera2d's behavior (no-op when
+ // target is null)
+ super.updateTarget(dt);
+ }
+
+ /**
+ * Visibility check used by `Container.update` (in turn driving
+ * `Container.draw`) to skip rendering off-screen children.
+ *
+ * Camera2d's implementation tests a 2D bounds-rectangle overlap
+ * against `this.worldView` — that test is invalid under perspective:
+ * the visible region is a frustum that widens with distance and
+ * rotates with the camera's pitch / yaw, not a fixed axis-aligned
+ * rect at the camera's x / y. Camera3d substitutes plane-based
+ * frustum culling — each non-floating renderable's bounding sphere
+ * is tested against the six frustum planes that were extracted in
+ * the most recent `update()` call. Floating elements (HUD / UI)
+ * still use Camera2d's 2D rect test because their bounds are
+ * screen-space and the perspective transform doesn't apply to them.
+ * @param obj - the renderable to test
+ * @param [floating] - test against screen coordinates instead of frustum
+ * @returns true if the renderable's bounds overlap the frustum
+ */
+ override isVisible(
+ obj: Renderable,
+ floating: boolean = obj.floating,
+ ): boolean {
+ if (floating || obj.floating) {
+ return super.isVisible(obj, floating);
+ }
+ const bounds = obj.getBounds();
+ const radius = Math.max(bounds.width, bounds.height) * 0.5;
+ return this.frustum.intersectsSphere(
+ bounds.centerX,
+ bounds.centerY,
+ obj.depth,
+ radius,
+ );
+ }
+
+ /**
+ * Per-frame update — extends Camera2d's behavior (target follow,
+ * camera effects) with rebuilding the frustum's six bounding
+ * planes so {@link Camera3d#isVisible} returns accurate results
+ * for the current camera state.
+ * @param dt - delta time in milliseconds
+ * @returns true if the camera's state changed
+ * @ignore
+ */
+ override update(dt?: number): boolean {
+ const dirty = super.update(dt);
+ this._rebuildFrustumPlanes();
+ return dirty;
+ }
+
+ /**
+ * Recompute the frustum's six bounding planes from the current
+ * `view × projection` matrix. Called from {@link Camera3d#update}
+ * each frame; `isVisible` then tests against the cached planes.
+ * @ignore
+ */
+ _rebuildFrustumPlanes(): void {
+ // build the view matrix R⁻¹ ∘ T(-pos) the same way
+ // `_applyContainerViewTransform` builds it on the container —
+ // rotate first (pitch then yaw), then translate.
+ _viewMatrix.identity();
+ if (this.pitch !== 0) {
+ _viewMatrix.rotate(-this.pitch, AXIS_X);
+ }
+ if (this.yaw !== 0) {
+ _viewMatrix.rotate(-this.yaw, AXIS_Y);
+ }
+ _viewMatrix.translate(-this.pos.x, -this.pos.y, -posZ(this.pos));
+
+ // view × projection — the matrix that maps world coords to
+ // clip space, which `Frustum.setFromViewProjection` decomposes
+ // into the six bounding planes via Gribb-Hartmann extraction.
+ _viewProjection.copy(this.frustum.projectionMatrix);
+ _viewProjection.multiply(_viewMatrix);
+
+ this.frustum.setFromViewProjection(_viewProjection);
+ }
+}
diff --git a/packages/melonjs/src/camera/frustum.ts b/packages/melonjs/src/camera/frustum.ts
new file mode 100644
index 000000000..0911192e2
--- /dev/null
+++ b/packages/melonjs/src/camera/frustum.ts
@@ -0,0 +1,275 @@
+import { Matrix3d } from "../math/matrix3d.ts";
+
+/**
+ * A plane in 3D space, expressed as `Ax + By + Cz + D = 0`.
+ * `normal` is `(A, B, C)`; `constant` is `D`. Used by {@link Frustum}
+ * to represent the six bounding planes for culling.
+ * @category Camera
+ */
+export interface Plane {
+ /** plane normal (A, B, C in the plane equation) — not necessarily unit-length */
+ nx: number;
+ ny: number;
+ nz: number;
+ /** plane constant (D in the plane equation) */
+ d: number;
+}
+
+export interface FrustumOptions {
+ /** vertical field of view in radians (default: π / 3 = 60°) */
+ fov?: number;
+ /** aspect ratio (width / height) — default 1.0 (square) */
+ aspect?: number;
+ /** distance to the near clipping plane (default 0.1) */
+ near?: number;
+ /** distance to the far clipping plane (default 1000) */
+ far?: number;
+}
+
+/**
+ * A view frustum — the truncated pyramid that defines a perspective
+ * camera's visible volume. Holds the four projection parameters
+ * (`fov`, `aspect`, `near`, `far`) and the matching projection matrix.
+ *
+ * Used by {@link Camera3d} as its source of truth for perspective
+ * projection. The matrix follows melonJS conventions: Y-down (sprite
+ * at higher `y` appears lower on screen, matching Camera2d) and +Z
+ * forward (sprite at higher `pos.z` is farther from the camera and
+ * renders smaller). This differs from the OpenGL default of Y-up and
+ * -Z forward, but matches the rest of the engine and lets Camera2d
+ * code translate directly to Camera3d.
+ *
+ * Plane-based frustum culling (`containsPoint` / `intersectsSphere`)
+ * is intentionally deferred until a real use case (e.g. visibility
+ * culling for the AfterBurner demo) demands it — keeps this class
+ * focused on the projection-math concern.
+ * @category Camera
+ * @example
+ * const frustum = new Frustum({ fov: Math.PI / 3, aspect: 16 / 9 });
+ * frustum.near = 0.5;
+ * frustum.update();
+ * renderer.setProjection(frustum.projectionMatrix);
+ */
+export default class Frustum {
+ /**
+ * vertical field of view in radians.
+ * Mutating this field requires calling {@link Frustum#update} to
+ * rebuild the projection matrix — or use {@link Frustum#set} to
+ * change multiple parameters and update in one call.
+ */
+ fov: number;
+
+ /**
+ * aspect ratio (width / height). Camera3d sets this automatically
+ * from its viewport on resize.
+ */
+ aspect: number;
+
+ /**
+ * distance to the near clipping plane (positive — measured along
+ * +Z, the camera's forward direction).
+ */
+ near: number;
+
+ /**
+ * distance to the far clipping plane.
+ */
+ far: number;
+
+ /**
+ * the perspective projection matrix derived from `fov`, `aspect`,
+ * `near` and `far`. Rebuilt by {@link Frustum#update}.
+ */
+ projectionMatrix: Matrix3d;
+
+ /**
+ * The six bounding planes of this frustum in world space, in order:
+ * left, right, bottom, top, near, far. Each plane is oriented so
+ * its `(nx, ny, nz)` normal points **inward** — a point with
+ * positive signed distance to a plane is on the visible side.
+ *
+ * Populated by {@link Frustum#setFromViewProjection}. Callers that
+ * use {@link Frustum#intersectsSphere} or {@link Frustum#containsPoint}
+ * must first call `setFromViewProjection` each frame the camera
+ * moves; otherwise the planes describe a stale frustum.
+ */
+ planes: Plane[];
+
+ /**
+ * @param [opts] - initial parameters; any omitted field uses the
+ * class default
+ */
+ constructor(opts?: FrustumOptions) {
+ this.fov = opts?.fov ?? Math.PI / 3;
+ this.aspect = opts?.aspect ?? 1.0;
+ this.near = opts?.near ?? 0.1;
+ this.far = opts?.far ?? 1000;
+ this.projectionMatrix = new Matrix3d();
+ // six planes — left, right, bottom, top, near, far. Initialised
+ // to all-zero; populated by `setFromViewProjection` on first
+ // camera update.
+ this.planes = [
+ { nx: 0, ny: 0, nz: 0, d: 0 },
+ { nx: 0, ny: 0, nz: 0, d: 0 },
+ { nx: 0, ny: 0, nz: 0, d: 0 },
+ { nx: 0, ny: 0, nz: 0, d: 0 },
+ { nx: 0, ny: 0, nz: 0, d: 0 },
+ { nx: 0, ny: 0, nz: 0, d: 0 },
+ ];
+ this.update();
+ }
+
+ /**
+ * Atomically set all four parameters and rebuild the projection
+ * matrix in one call.
+ * @param fov - vertical field of view in radians
+ * @param aspect - aspect ratio (width / height)
+ * @param near - distance to the near clipping plane
+ * @param far - distance to the far clipping plane
+ * @returns this Frustum for chaining
+ */
+ set(fov: number, aspect: number, near: number, far: number): this {
+ this.fov = fov;
+ this.aspect = aspect;
+ this.near = near;
+ this.far = far;
+ this.update();
+ return this;
+ }
+
+ /**
+ * Rebuild {@link Frustum#projectionMatrix} from the current
+ * parameter values. Call this after mutating any of `fov`,
+ * `aspect`, `near`, `far` individually.
+ *
+ * The matrix is the standard OpenGL perspective post-multiplied by
+ * `scale(1, -1, -1)` so that:
+ * - Y-down matches melonJS screen + Camera2d conventions
+ * - +Z is forward (positive `pos.z` = farther from camera)
+ */
+ update(): void {
+ this.projectionMatrix.perspective(
+ this.fov,
+ this.aspect,
+ this.near,
+ this.far,
+ );
+ // flip Y (down) + Z (+Z forward) to match engine conventions
+ this.projectionMatrix.scale(1, -1, -1);
+ }
+
+ /**
+ * Rebuild the six bounding {@link Frustum#planes} from a combined
+ * `view × projection` matrix. Standard Gribb–Hartmann extraction:
+ * each plane is one of the six combinations of the matrix's row 3
+ * (the "w" row) ± rows 0, 1, 2.
+ *
+ * Call this once per frame after the camera has moved (typically
+ * from `Camera3d.update`); the planes are then valid in world
+ * space for that frame and can be tested against world-space
+ * bounds via {@link Frustum#intersectsSphere} /
+ * {@link Frustum#containsPoint}.
+ *
+ * Pass `projectionMatrix × viewMatrix` — i.e. the matrix that
+ * transforms world coords directly to clip space. Equivalent to
+ * what's uploaded to `uProjectionMatrix` in the vertex shader
+ * (after baking the per-frame world translate into the projection,
+ * which Camera3d does via `container.translate`).
+ * @param viewProjection - the view × projection matrix
+ */
+ setFromViewProjection(viewProjection: Matrix3d): void {
+ // column-major matrix: `m.val[col * 4 + row]`. Row r is the
+ // elements at indices `r, 4+r, 8+r, 12+r` (one from each column).
+ const m = viewProjection.val;
+ const r0x = m[0],
+ r0y = m[4],
+ r0z = m[8],
+ r0w = m[12];
+ const r1x = m[1],
+ r1y = m[5],
+ r1z = m[9],
+ r1w = m[13];
+ const r2x = m[2],
+ r2y = m[6],
+ r2z = m[10],
+ r2w = m[14];
+ const r3x = m[3],
+ r3y = m[7],
+ r3z = m[11],
+ r3w = m[15];
+
+ // each plane is a sum/difference of two rows, then normalized
+ // so the `(nx, ny, nz)` is unit length (so `intersectsSphere`
+ // can compare distance against radius in world units).
+ const set = (
+ idx: number,
+ nx: number,
+ ny: number,
+ nz: number,
+ d: number,
+ ) => {
+ const inv = 1 / Math.sqrt(nx * nx + ny * ny + nz * nz);
+ const p = this.planes[idx];
+ p.nx = nx * inv;
+ p.ny = ny * inv;
+ p.nz = nz * inv;
+ p.d = d * inv;
+ };
+
+ // left = row3 + row0 (points pass when their signed distance > 0)
+ set(0, r3x + r0x, r3y + r0y, r3z + r0z, r3w + r0w);
+ // right = row3 - row0
+ set(1, r3x - r0x, r3y - r0y, r3z - r0z, r3w - r0w);
+ // bottom = row3 + row1
+ set(2, r3x + r1x, r3y + r1y, r3z + r1z, r3w + r1w);
+ // top = row3 - row1
+ set(3, r3x - r1x, r3y - r1y, r3z - r1z, r3w - r1w);
+ // near = row3 + row2
+ set(4, r3x + r2x, r3y + r2y, r3z + r2z, r3w + r2w);
+ // far = row3 - row2
+ set(5, r3x - r2x, r3y - r2y, r3z - r2z, r3w - r2w);
+ }
+
+ /**
+ * Test whether a world-space sphere overlaps this frustum.
+ * Conservative — a sphere that touches even one plane's positive
+ * side is reported visible. Always run {@link Frustum#setFromViewProjection}
+ * first so the planes describe the current camera view.
+ * @param x - sphere center x in world coords
+ * @param y - sphere center y in world coords
+ * @param z - sphere center z in world coords
+ * @param radius - sphere radius in world units
+ * @returns true if the sphere is at least partially inside the frustum
+ */
+ intersectsSphere(x: number, y: number, z: number, radius: number): boolean {
+ // for each plane, compute signed distance from sphere center.
+ // if the center is farther than `radius` on the OUTSIDE side
+ // (distance < -radius) of ANY plane, the whole sphere is outside.
+ for (let i = 0; i < 6; i++) {
+ const p = this.planes[i];
+ const distance = p.nx * x + p.ny * y + p.nz * z + p.d;
+ if (distance < -radius) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Test whether a world-space point is inside this frustum.
+ * Always run {@link Frustum#setFromViewProjection} first.
+ * @param x - world x
+ * @param y - world y
+ * @param z - world z
+ * @returns true if the point is inside (on the positive side of every plane)
+ */
+ containsPoint(x: number, y: number, z: number): boolean {
+ for (let i = 0; i < 6; i++) {
+ const p = this.planes[i];
+ if (p.nx * x + p.ny * y + p.nz * z + p.d < 0) {
+ return false;
+ }
+ }
+ return true;
+ }
+}
diff --git a/packages/melonjs/src/index.ts b/packages/melonjs/src/index.ts
index 29a3d7aae..f47abe0d0 100644
--- a/packages/melonjs/src/index.ts
+++ b/packages/melonjs/src/index.ts
@@ -3,10 +3,12 @@ import "./polyfill/index.ts";
import Application, { setDefaultGame } from "./application/application.ts";
import Camera2d from "./camera/camera2d.ts";
+import Camera3d from "./camera/camera3d.ts";
import CameraEffect from "./camera/effects/camera_effect.ts";
import FadeEffect from "./camera/effects/fade_effect.ts";
import MaskEffect from "./camera/effects/mask_effect.ts";
import ShakeEffect from "./camera/effects/shake_effect.ts";
+import Frustum from "./camera/frustum.ts";
import Pointer from "./input/pointer.ts";
import TMXHexagonalRenderer from "./level/tiled/renderer/TMXHexagonalRenderer.js";
import TMXIsometricRenderer from "./level/tiled/renderer/TMXIsometricRenderer.js";
@@ -150,6 +152,7 @@ export {
BlurEffect,
Body,
Camera2d,
+ Camera3d,
CameraEffect,
CanvasRenderer,
CanvasRenderTarget,
@@ -167,6 +170,7 @@ export {
Entity, // eslint-disable-line @typescript-eslint/no-deprecated
FadeEffect,
FlashEffect,
+ Frustum,
GLShader,
GlowEffect,
Gradient,
diff --git a/packages/melonjs/src/loader/loadingscreen.js b/packages/melonjs/src/loader/loadingscreen.js
index 5744e6029..958be6196 100644
--- a/packages/melonjs/src/loader/loadingscreen.js
+++ b/packages/melonjs/src/loader/loadingscreen.js
@@ -1,3 +1,4 @@
+import Camera2d from "./../camera/camera2d.ts";
import Renderable from "./../renderable/renderable.js";
import Sprite from "./../renderable/sprite.js";
import Stage from "./../state/stage.ts";
@@ -101,6 +102,17 @@ class DefaultLoadingScreen extends Stage {
*/
#cleanedUp = false;
+ /**
+ * Pin the loading screen to a Camera2d regardless of the
+ * application's `cameraClass` setting. The loader must render
+ * correctly even when the host app opts in to Camera3d globally
+ * — a perspective camera applied to a 2D progress bar would
+ * stretch / clip the bar based on its depth.
+ */
+ constructor() {
+ super({ cameraClass: Camera2d });
+ }
+
/**
* call when the loader is resetted
* @ignore
diff --git a/packages/melonjs/src/renderable/container.js b/packages/melonjs/src/renderable/container.js
index a209af48b..fae789b35 100644
--- a/packages/melonjs/src/renderable/container.js
+++ b/packages/melonjs/src/renderable/container.js
@@ -15,6 +15,46 @@ function deferredRemove(child, keepalive) {
this.removeChildNow(child, keepalive);
}
+/**
+ * Module-level cache of the camera position used by `_sortDepth`.
+ * Captured once per sort (in `sort` / `sortNow`) so the comparator —
+ * which runs O(N log N) times — doesn't pay a `state.current()` lookup
+ * per comparison. Safe to be module-scoped: JS is single-threaded and
+ * sort is synchronous within each container.
+ * @ignore
+ */
+let _depthCamX = 0;
+let _depthCamY = 0;
+let _depthCamZ = 0;
+
+/**
+ * Refresh the cached camera position from the active stage. Falls back
+ * to (0, 0, 0) when no camera exists yet (e.g. early init, before any
+ * stage has been set). Called just-in-time before `sortOn === "depth"`
+ * runs its sort. The camera is a `Renderable` (Camera2d/Camera3d), so
+ * `pos.z` is guaranteed by `ObservableVector3d` — no per-field guard.
+ *
+ * `Stage.cameras` is a `Map`, not an array — we read the `"default"`
+ * key (every Stage seeds this in `reset`). For split-screen setups the
+ * world sorts once per frame against the primary camera; the secondary
+ * camera sees slightly-imperfect order which is the industry-standard
+ * compromise (cf. Three.js / Pixi3D).
+ * @ignore
+ */
+function captureDepthCamera() {
+ const stage = state.current();
+ const cam = stage?.cameras?.get("default");
+ if (cam) {
+ _depthCamX = cam.pos.x;
+ _depthCamY = cam.pos.y;
+ _depthCamZ = cam.pos.z;
+ } else {
+ _depthCamX = 0;
+ _depthCamY = 0;
+ _depthCamZ = 0;
+ }
+}
+
/**
* Register a child's physics body with the root world container when the
* child is added to the tree. Two paths coexist:
@@ -190,9 +230,27 @@ export default class Container extends Renderable {
}
/**
- * The property of the child object that should be used to sort on this container.
- * Accepted values: "x", "y", "z"
- * @type {string}
+ * The property of the child object that should be used to sort on
+ * this container.
+ *
+ * - `"x"` / `"y"` — 2D scroll-order (descending pos.z, then ascending
+ * pos.x or pos.y). Suited to side-scrollers and top-down games.
+ * - `"z"` — descending pos.z (higher z draws on top). Default for
+ * `Camera2d` — matches the painter's-algorithm 2D layering model
+ * where pos.z is "layer index".
+ * - `"depth"` — **Camera3d-only.** Ascending squared distance from
+ * the active camera (closest first in the array → far drawn first
+ * → close drawn on top under `Container.draw`'s reverse-iteration
+ * walk). This is the only correct sort for alpha-blended sprites
+ * under perspective projection, where neither pos.z nor pos.x/y
+ * layering produces correct occlusion (the closer/farther
+ * relationship flips as the camera orbits). Set automatically
+ * when an `Application` or `Stage` is constructed with
+ * `cameraClass: Camera3d`. Has no useful effect under `Camera2d`
+ * (ortho projection doesn't need camera-distance ordering) and
+ * pays an O(N log N) sort per frame, so don't enable it manually
+ * in 2D-only games.
+ * @type {"x"|"y"|"z"|"depth"}
* @default "z"
*/
get sortOn() {
@@ -200,13 +258,17 @@ export default class Container extends Renderable {
}
set sortOn(value) {
const v = value.toLowerCase();
- if (v !== "x" && v !== "y" && v !== "z") {
+ if (v !== "x" && v !== "y" && v !== "z" && v !== "depth") {
throw new Error(
- `Invalid sortOn value: "${value}" (expected "x", "y", or "z")`,
+ `Invalid sortOn value: "${value}" (expected "x", "y", "z", or "depth")`,
);
}
this._sortOn = v;
- this._comparator = this["_sort" + v.toUpperCase()];
+ if (v === "depth") {
+ this._comparator = this._sortDepth;
+ } else {
+ this._comparator = this["_sort" + v.toUpperCase()];
+ }
}
/**
@@ -875,6 +937,11 @@ export default class Container extends Renderable {
});
}
this.pendingSort = defer(function () {
+ // refresh the cached camera position so `_sortDepth`
+ // sees the current view — no-op for x/y/z modes
+ if (this._sortOn === "depth") {
+ captureDepthCamera();
+ }
// sort everything in this container
this.getChildren().sort(this._comparator);
// clear the defer id
@@ -885,6 +952,34 @@ export default class Container extends Renderable {
}
}
+ /**
+ * Synchronous variant of {@link Container#sort}. Sorts immediately
+ * — no defer — using the current `_comparator`. Intended for
+ * per-frame callers that need the order to be valid for the very
+ * next render (e.g. `Camera3d` refreshing the `"depth"` sort after
+ * the camera has moved, where waiting for the deferred sort tick
+ * would render a stale frame).
+ * @param {boolean} [recursive=false] - recursively sort sub-containers
+ */
+ sortNow(recursive) {
+ if (this._sortOn === "depth") {
+ captureDepthCamera();
+ }
+ const children = this.getChildren();
+ if (children.length > 1) {
+ children.sort(this._comparator);
+ this.isDirty = true;
+ }
+ if (recursive === true) {
+ for (let i = 0; i < children.length; i++) {
+ const c = children[i];
+ if (c instanceof Container) {
+ c.sortNow(true);
+ }
+ }
+ }
+ }
+
/**
* @ignore
* @param {...*} _args - reserved; widens the signature so subclass
@@ -915,6 +1010,32 @@ export default class Container extends Renderable {
return a.pos.z - b.pos.z;
}
+ /**
+ * Camera-distance sorting function used by `sortOn = "depth"`.
+ * Orders children by ascending squared distance from the cached
+ * camera position — closest first in the array, so
+ * {@link Container#draw}'s reverse-iteration walk paints far→near
+ * (correct painter's algorithm for alpha-blended sprites under
+ * perspective). The cached position is refreshed by
+ * {@link captureDepthCamera} just before each sort runs; this
+ * comparator stays tight (zero allocation, six subtracts + three
+ * mul-adds per pair) for the hot O(N log N) path.
+ *
+ * Container children are `Renderable` instances with an
+ * `ObservableVector3d` pos — `pos.z` is always defined, no guard
+ * needed here.
+ * @ignore
+ */
+ _sortDepth(a, b) {
+ const ax = a.pos.x - _depthCamX;
+ const ay = a.pos.y - _depthCamY;
+ const az = a.pos.z - _depthCamZ;
+ const bx = b.pos.x - _depthCamX;
+ const by = b.pos.y - _depthCamY;
+ const bz = b.pos.z - _depthCamZ;
+ return ax * ax + ay * ay + az * az - (bx * bx + by * by + bz * bz);
+ }
+
/**
* X Sorting function
* @ignore
diff --git a/packages/melonjs/src/state/stage.ts b/packages/melonjs/src/state/stage.ts
index 6cff9dfb9..68c625c7d 100644
--- a/packages/melonjs/src/state/stage.ts
+++ b/packages/melonjs/src/state/stage.ts
@@ -8,6 +8,19 @@ import type Renderer from "./../video/renderer.js";
interface StageSettings {
cameras: Camera2d[];
+ /**
+ * Default camera class to instantiate when this stage has no
+ * explicit `cameras` list. Overrides any app-level `cameraClass`
+ * setting for this specific stage. Built-in stages (e.g.
+ * {@link DefaultLoadingScreen}) pin this to {@link Camera2d} so
+ * the loader stays 2D regardless of app-wide `cameraClass`.
+ */
+ cameraClass?: new (
+ minX: number,
+ minY: number,
+ maxX: number,
+ maxY: number,
+ ) => Camera2d;
onResetEvent?: (app: Application, ...args: unknown[]) => void;
onDestroyEvent?: (app: Application) => void;
}
@@ -132,16 +145,57 @@ export default class Stage {
this.cameras.set(camera.name, camera);
});
- // use the application's default camera if no "default" camera is defined
- if (!this.cameras.has("default")) {
- if (typeof default_camera === "undefined" && app) {
- const width = app.renderer.width;
- const height = app.renderer.height;
- default_camera = new Camera2d(0, 0, width, height);
- }
- if (typeof default_camera !== "undefined") {
+ // default-camera resolution order (most-specific wins):
+ // 1. explicit `cameras` array on the stage → handled above
+ // 2. `cameraClass` on the stage settings → fresh instance,
+ // overrides app-level (used by DefaultLoadingScreen to
+ // pin Camera2d regardless of app.settings.cameraClass)
+ // 3. `cameraClass` on the application settings → fresh
+ // instance per stage (Camera3d state shouldn't bleed
+ // across stages)
+ // 4. neither set → fall back to the Camera2d module-level
+ // singleton (preserves pre-19.7 behavior bit-for-bit
+ // for every app that doesn't opt into cameraClass)
+ if (!this.cameras.has("default") && app) {
+ const width = app.renderer.width;
+ const height = app.renderer.height;
+ const StageCameraClass = this.settings.cameraClass;
+ const AppCameraClass = app.settings.cameraClass;
+
+ if (typeof StageCameraClass === "function") {
+ this.cameras.set("default", new StageCameraClass(0, 0, width, height));
+ } else if (typeof AppCameraClass === "function") {
+ this.cameras.set("default", new AppCameraClass(0, 0, width, height));
+ } else {
+ // no cameraClass anywhere — use the shared Camera2d singleton
+ if (typeof default_camera === "undefined") {
+ default_camera = new Camera2d(0, 0, width, height);
+ }
this.cameras.set("default", default_camera);
}
+
+ // Honor the stage-level cameraClass override on the world's
+ // sort mode too. `DefaultLoadingScreen` pins Camera2d via this
+ // path, which (via `Camera2d.defaultSortOn = "z"`) restores
+ // the 2D z-sort even when the surrounding Application opted
+ // into Camera3d — so the loader works correctly under a 3D
+ // app. Skip when the stage didn't override (app-level
+ // cameraClass was already applied in Application's
+ // constructor).
+ const ChosenCameraClass = StageCameraClass ?? AppCameraClass;
+ const defaultSortOn = (
+ ChosenCameraClass as
+ | { defaultSortOn?: "x" | "y" | "z" | "depth" }
+ | undefined
+ )?.defaultSortOn;
+ if (
+ StageCameraClass &&
+ defaultSortOn &&
+ app.world &&
+ app.world.sortOn !== defaultSortOn
+ ) {
+ app.world.sortOn = defaultSortOn;
+ }
}
// reset the game
diff --git a/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js b/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js
index 4d6188104..ed06a16a8 100644
--- a/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js
+++ b/packages/melonjs/src/video/webgl/batchers/lit_quad_batcher.js
@@ -303,11 +303,15 @@ export default class LitQuadBatcher extends QuadBatcher {
normalTextureId = unit;
}
+ // Stamp per-sprite depth onto z BEFORE `m.apply` — see
+ // `QuadBatcher.addQuad` for the full rationale. V_ARRAY is the
+ // shared Vector3d pool from `QuadBatcher`.
const m = this.viewMatrix;
- const vec0 = V_ARRAY[0].set(x, y);
- const vec1 = V_ARRAY[1].set(x + w, y);
- const vec2 = V_ARRAY[2].set(x, y + h);
- const vec3 = V_ARRAY[3].set(x + w, y + h);
+ const z = this.renderer.currentDepth;
+ const vec0 = V_ARRAY[0].set(x, y, z);
+ const vec1 = V_ARRAY[1].set(x + w, y, z);
+ const vec2 = V_ARRAY[2].set(x, y + h, z);
+ const vec3 = V_ARRAY[3].set(x + w, y + h, z);
if (!m.isIdentity()) {
m.apply(vec0);
@@ -317,13 +321,10 @@ export default class LitQuadBatcher extends QuadBatcher {
}
const textureId = this.useMultiTexture ? unit : 0;
- // z = current renderer depth (Renderable.preDraw); a no-op under ortho,
- // consumed by perspective (Camera3d) — matches QuadBatcher.addQuad.
- const z = this.renderer.currentDepth;
vertexData.push(
vec0.x,
vec0.y,
- z,
+ vec0.z,
u0,
v0,
tint,
@@ -333,7 +334,7 @@ export default class LitQuadBatcher extends QuadBatcher {
vertexData.push(
vec1.x,
vec1.y,
- z,
+ vec1.z,
u1,
v0,
tint,
@@ -343,7 +344,7 @@ export default class LitQuadBatcher extends QuadBatcher {
vertexData.push(
vec2.x,
vec2.y,
- z,
+ vec2.z,
u0,
v1,
tint,
@@ -353,7 +354,7 @@ export default class LitQuadBatcher extends QuadBatcher {
vertexData.push(
vec3.x,
vec3.y,
- z,
+ vec3.z,
u1,
v1,
tint,
@@ -389,11 +390,12 @@ export default class LitQuadBatcher extends QuadBatcher {
// `QuadBatcher.blitTexture` for the rationale. Only caller today
// is `WebGLRenderer.blitEffect`, which resets `currentTransform`
// to identity, so the matrix branch is dormant in practice.
+ // Explicit z = 0 because V_ARRAY is Vector3d (shared with addQuad).
const m = this.viewMatrix;
- const vec0 = V_ARRAY[0].set(x, y);
- const vec1 = V_ARRAY[1].set(x + width, y);
- const vec2 = V_ARRAY[2].set(x, y + height);
- const vec3 = V_ARRAY[3].set(x + width, y + height);
+ const vec0 = V_ARRAY[0].set(x, y, 0);
+ const vec1 = V_ARRAY[1].set(x + width, y, 0);
+ const vec2 = V_ARRAY[2].set(x, y + height, 0);
+ const vec3 = V_ARRAY[3].set(x + width, y + height, 0);
if (m && !m.isIdentity()) {
m.apply(vec0);
m.apply(vec1);
diff --git a/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js b/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js
index 087821d55..6b56b6d81 100644
--- a/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js
+++ b/packages/melonjs/src/video/webgl/batchers/mesh_batcher.js
@@ -1,10 +1,13 @@
-import { Vector2d } from "../../../math/vector2d.ts";
+import { Vector3d } from "../../../math/vector3d.ts";
import meshFragment from "./../shaders/mesh.frag";
import meshVertex from "./../shaders/mesh.vert";
import { MaterialBatcher } from "./material_batcher.js";
// reusable vector for vertex transform
-const _v = new Vector2d();
+// Vector3d (not Vector2d) so the mesh's per-vertex z survives the
+// transform under Camera3d — for 2D-only view matrices the z column is
+// identity, so output (x, y) matches the legacy Vector2d path.
+const _v = new Vector3d();
/**
* Per-channel multiply two ARGB-packed Uint32 colors. Used by the
@@ -147,13 +150,14 @@ export default class MeshBatcher extends MaterialBatcher {
const i2 = origIdx * 2;
let x = vertices[i3];
let y = vertices[i3 + 1];
- const z = vertices[i3 + 2];
+ let z = vertices[i3 + 2];
if (!isIdentity) {
- _v.set(x, y);
+ _v.set(x, y, z);
m.apply(_v);
x = _v.x;
y = _v.y;
+ z = _v.z;
}
// per-vertex color when the mesh provides one
diff --git a/packages/melonjs/src/video/webgl/batchers/primitive_batcher.js b/packages/melonjs/src/video/webgl/batchers/primitive_batcher.js
index ee3f38c44..146e8e146 100644
--- a/packages/melonjs/src/video/webgl/batchers/primitive_batcher.js
+++ b/packages/melonjs/src/video/webgl/batchers/primitive_batcher.js
@@ -123,15 +123,20 @@ export default class PrimitiveBatcher extends Batcher {
}
if (!viewMatrix.isIdentity()) {
+ // Full 3D transform including the z column (m[8] / m[9] /
+ // m[10] / m[14]) so Camera3d's view matrix (X/Y-axis
+ // rotation) actually rotates the primitive in 3D. For 2D
+ // matrices those slots are identity, so output (x, y, z)
+ // is bit-identical to the legacy 2D-only multiply.
const m = viewMatrix.val;
for (let i = 0; i < vertexCount; i++) {
const vert = verts[i];
const x = vert.x;
const y = vert.y;
vertexData.push(
- x * m[0] + y * m[4] + m[12],
- x * m[1] + y * m[5] + m[13],
- z,
+ x * m[0] + y * m[4] + z * m[8] + m[12],
+ x * m[1] + y * m[5] + z * m[9] + m[13],
+ x * m[2] + y * m[6] + z * m[10] + m[14],
0,
0,
colorUint32,
@@ -187,18 +192,28 @@ export default class PrimitiveBatcher extends Batcher {
const from = verts[i];
const to = verts[i + 1];
- // apply view matrix to base positions without mutating inputs
- let fromX, fromY, toX, toY;
+ // apply view matrix to base positions without mutating
+ // inputs. Includes the z column for parity with the simple-
+ // line path and Vector3d quad batcher — Camera3d's view
+ // matrix needs depth-aware rotation. Note: the perpendicular
+ // normal is still computed in pre-projection world space,
+ // which appears non-perpendicular under perspective — known
+ // limitation, separate from the Vector3d migration.
+ let fromX, fromY, fromZ, toX, toY, toZ;
if (hasTransform) {
- fromX = from.x * m[0] + from.y * m[4] + m[12];
- fromY = from.x * m[1] + from.y * m[5] + m[13];
- toX = to.x * m[0] + to.y * m[4] + m[12];
- toY = to.x * m[1] + to.y * m[5] + m[13];
+ fromX = from.x * m[0] + from.y * m[4] + z * m[8] + m[12];
+ fromY = from.x * m[1] + from.y * m[5] + z * m[9] + m[13];
+ fromZ = from.x * m[2] + from.y * m[6] + z * m[10] + m[14];
+ toX = to.x * m[0] + to.y * m[4] + z * m[8] + m[12];
+ toY = to.x * m[1] + to.y * m[5] + z * m[9] + m[13];
+ toZ = to.x * m[2] + to.y * m[6] + z * m[10] + m[14];
} else {
fromX = from.x;
fromY = from.y;
+ fromZ = z;
toX = to.x;
toY = to.y;
+ toZ = z;
}
// compute perpendicular unit normal
@@ -215,14 +230,14 @@ export default class PrimitiveBatcher extends Batcher {
// two triangles forming a quad around the line segment
// triangle 1: from+n, from-n, to-n
- vertexData.push(fromX, fromY, z, nx, ny, colorUint32);
- vertexData.push(fromX, fromY, z, -nx, -ny, colorUint32);
- vertexData.push(toX, toY, z, -nx, -ny, colorUint32);
+ vertexData.push(fromX, fromY, fromZ, nx, ny, colorUint32);
+ vertexData.push(fromX, fromY, fromZ, -nx, -ny, colorUint32);
+ vertexData.push(toX, toY, toZ, -nx, -ny, colorUint32);
// triangle 2: from+n, to-n, to+n
- vertexData.push(fromX, fromY, z, nx, ny, colorUint32);
- vertexData.push(toX, toY, z, -nx, -ny, colorUint32);
- vertexData.push(toX, toY, z, nx, ny, colorUint32);
+ vertexData.push(fromX, fromY, fromZ, nx, ny, colorUint32);
+ vertexData.push(toX, toY, toZ, -nx, -ny, colorUint32);
+ vertexData.push(toX, toY, toZ, nx, ny, colorUint32);
}
}
}
diff --git a/packages/melonjs/src/video/webgl/batchers/quad_batcher.js b/packages/melonjs/src/video/webgl/batchers/quad_batcher.js
index 977f2ec7a..513f6ddcf 100644
--- a/packages/melonjs/src/video/webgl/batchers/quad_batcher.js
+++ b/packages/melonjs/src/video/webgl/batchers/quad_batcher.js
@@ -1,4 +1,4 @@
-import { Vector2d } from "../../../math/vector2d.ts";
+import { Vector3d } from "../../../math/vector3d.ts";
import IndexBuffer from "../buffer/index.js";
import { buildMultiTextureFragment } from "./../shaders/multitexture.js";
import quadMultiVertex from "./../shaders/quad-multi.vert";
@@ -10,14 +10,18 @@ import { MaterialBatcher } from "./material_batcher.js";
*/
// a pool of reusable vectors used by `addQuad` to transform the four
-// quad corners. Exported so `LitQuadBatcher` reuses the same pool —
-// JS is single-threaded and `addQuad` is synchronous, so concurrent
-// access can't happen and a single shared pool is safe.
+// quad corners. Vector3d (not Vector2d) so the per-sprite depth set on
+// `z` flows through `Matrix3d.apply` — required for Camera3d's view
+// matrix (Y/X-axis rotation) to actually rotate the vertex in 3D space.
+// For 2D-only matrices the z column is identity, so `(x, y)` output is
+// bit-identical to the Vector2d path. Exported so `LitQuadBatcher`
+// reuses the same pool — JS is single-threaded and `addQuad` is
+// synchronous, so concurrent access can't happen.
export const V_ARRAY = [
- new Vector2d(),
- new Vector2d(),
- new Vector2d(),
- new Vector2d(),
+ new Vector3d(),
+ new Vector3d(),
+ new Vector3d(),
+ new Vector3d(),
];
/**
@@ -212,10 +216,13 @@ export default class QuadBatcher extends MaterialBatcher {
// kept for any future world-space caller that wants its preDraw
// translate/scale honored.
const m = this.viewMatrix;
- const vec0 = V_ARRAY[0].set(x, y);
- const vec1 = V_ARRAY[1].set(x + width, y);
- const vec2 = V_ARRAY[2].set(x, y + height);
- const vec3 = V_ARRAY[3].set(x + width, y + height);
+ // blits are always at z = 0 (screen-space). Setting z explicitly
+ // matters because V_ARRAY is Vector3d — any leftover z from a
+ // prior addQuad would otherwise leak into the transform.
+ const vec0 = V_ARRAY[0].set(x, y, 0);
+ const vec1 = V_ARRAY[1].set(x + width, y, 0);
+ const vec2 = V_ARRAY[2].set(x, y + height, 0);
+ const vec3 = V_ARRAY[3].set(x + width, y + height, 0);
if (m && !m.isIdentity()) {
m.apply(vec0);
m.apply(vec1);
@@ -223,8 +230,8 @@ export default class QuadBatcher extends MaterialBatcher {
m.apply(vec3);
}
- // blits are always rendered at z = 0 (screen-space, ortho); pre-PR
- // behavior is preserved unchanged
+ // blits stay at z = 0 (screen-space, ortho); pre-PR behavior
+ // preserved unchanged
const tint = 0xffffffff;
this.vertexData.push(vec0.x, vec0.y, 0, 0, 1, tint, 0);
this.vertexData.push(vec1.x, vec1.y, 0, 1, 1, tint, 0);
@@ -288,12 +295,17 @@ export default class QuadBatcher extends MaterialBatcher {
}
}
- // Transform vertices
+ // Transform vertices. Stamp per-sprite depth onto z BEFORE
+ // `m.apply` so Camera3d's view matrix (3D R⁻¹ ∘ T(-pos)) fully
+ // rotates the vertex. For 2D-only matrices the z column is
+ // identity, so output (x, y) is bit-identical to the legacy
+ // Vector2d path and z passes through unchanged.
const m = this.viewMatrix;
- const vec0 = V_ARRAY[0].set(x, y);
- const vec1 = V_ARRAY[1].set(x + w, y);
- const vec2 = V_ARRAY[2].set(x, y + h);
- const vec3 = V_ARRAY[3].set(x + w, y + h);
+ const z = this.renderer.currentDepth;
+ const vec0 = V_ARRAY[0].set(x, y, z);
+ const vec1 = V_ARRAY[1].set(x + w, y, z);
+ const vec2 = V_ARRAY[2].set(x, y + h, z);
+ const vec3 = V_ARRAY[3].set(x + w, y + h, z);
if (!m.isIdentity()) {
m.apply(vec0);
@@ -303,14 +315,12 @@ export default class QuadBatcher extends MaterialBatcher {
}
// 4 vertices per quad; the index buffer provides the 6 indices.
- // textureId is the unit index for multi-texture, or 0 for single-texture fallback.
- // z is the current renderer depth (set by Renderable.preDraw); a no-op under
- // the default ortho projection, used by perspective (Camera3d).
+ // textureId is the unit index for multi-texture, or 0 for
+ // single-texture fallback.
const textureId = this.useMultiTexture ? unit : 0;
- const z = this.renderer.currentDepth;
- vertexData.push(vec0.x, vec0.y, z, u0, v0, tint, textureId);
- vertexData.push(vec1.x, vec1.y, z, u1, v0, tint, textureId);
- vertexData.push(vec2.x, vec2.y, z, u0, v1, tint, textureId);
- vertexData.push(vec3.x, vec3.y, z, u1, v1, tint, textureId);
+ vertexData.push(vec0.x, vec0.y, vec0.z, u0, v0, tint, textureId);
+ vertexData.push(vec1.x, vec1.y, vec1.z, u1, v0, tint, textureId);
+ vertexData.push(vec2.x, vec2.y, vec2.z, u0, v1, tint, textureId);
+ vertexData.push(vec3.x, vec3.y, vec3.z, u1, v1, tint, textureId);
}
}
diff --git a/packages/melonjs/tests/camera3d.spec.js b/packages/melonjs/tests/camera3d.spec.js
new file mode 100644
index 000000000..1ad9a6dbd
--- /dev/null
+++ b/packages/melonjs/tests/camera3d.spec.js
@@ -0,0 +1,557 @@
+import { beforeAll, describe, expect, it } from "vitest";
+import {
+ boot,
+ Camera2d,
+ Camera3d,
+ Frustum,
+ Matrix3d,
+ Renderable,
+ Vector3d,
+ video,
+} from "../src/index.js";
+
+/**
+ * Unit tests for the Camera3d class.
+ * Most tests run without WebGL — the camera's math (frustum,
+ * pitch/yaw, follow logic) is pure JS.
+ */
+describe("Camera3d", () => {
+ beforeAll(() => {
+ // some Camera2d subclass paths need a renderer to construct
+ // (e.g. Renderable observableVector callbacks). Boot a Canvas
+ // renderer for those.
+ boot();
+ video.init(800, 600, {
+ parent: "screen",
+ scale: "auto",
+ renderer: video.CANVAS,
+ });
+ });
+
+ describe("constructor + defaults", () => {
+ it("extends Camera2d (drop-in compatible)", () => {
+ const cam = new Camera3d(0, 0, 800, 600);
+ expect(cam).toBeInstanceOf(Camera2d);
+ expect(cam).toBeInstanceOf(Camera3d);
+ });
+
+ it("creates a Frustum with sensible defaults", () => {
+ const cam = new Camera3d(0, 0, 800, 600);
+ expect(cam.frustum).toBeInstanceOf(Frustum);
+ expect(cam.fov).toBeCloseTo(Math.PI / 3, 5); // 60°
+ // aspect derived from viewport rect (800/600 = 4/3)
+ expect(cam.aspect).toBeCloseTo(800 / 600, 5);
+ expect(cam.near).toBe(0.1);
+ expect(cam.far).toBe(1000);
+ });
+
+ it("honors constructor opts", () => {
+ const cam = new Camera3d(0, 0, 800, 600, {
+ fov: Math.PI / 4,
+ near: 0.5,
+ far: 2000,
+ aspect: 16 / 9,
+ });
+ expect(cam.fov).toBeCloseTo(Math.PI / 4, 5);
+ expect(cam.near).toBe(0.5);
+ expect(cam.far).toBe(2000);
+ expect(cam.aspect).toBeCloseTo(16 / 9, 5);
+ });
+
+ it("initializes orientation to zero (looking straight ahead)", () => {
+ const cam = new Camera3d(0, 0, 800, 600);
+ expect(cam.pitch).toBe(0);
+ expect(cam.yaw).toBe(0);
+ });
+
+ it("initializes followOffset/lookAhead Vector3d's", () => {
+ const cam = new Camera3d(0, 0, 800, 600);
+ expect(cam.followOffset).toBeInstanceOf(Vector3d);
+ expect(cam.followOffset.x).toBe(0);
+ expect(cam.followOffset.y).toBe(0);
+ expect(cam.followOffset.z).toBe(0);
+ expect(cam.lookAhead).toBeInstanceOf(Vector3d);
+ });
+
+ it("uses perspective projection (not ortho)", () => {
+ const cam = new Camera3d(0, 0, 800, 600);
+ // perspective matrix has element [11] != 0 (the -1 from the
+ // perspective divide row). Ortho would have [11] = 0.
+ // After our Y-flip + Z-flip scale, element [11] is still
+ // non-zero (it's part of the unscaled perspective divide row).
+ expect(cam.projectionMatrix.val[11]).not.toBe(0);
+ });
+ });
+
+ describe("fov / aspect setters", () => {
+ it("setting fov updates the projection matrix", () => {
+ const cam = new Camera3d(0, 0, 800, 600);
+ const before = cam.projectionMatrix.val[0];
+ cam.fov = Math.PI / 2; // change to 90°
+ const after = cam.projectionMatrix.val[0];
+ expect(after).not.toBeCloseTo(before, 5);
+ expect(cam.fov).toBeCloseTo(Math.PI / 2, 5);
+ });
+
+ it("setting aspect updates the projection matrix", () => {
+ const cam = new Camera3d(0, 0, 800, 600);
+ cam.aspect = 2.0;
+ expect(cam.aspect).toBeCloseTo(2.0, 5);
+ expect(cam.frustum.aspect).toBeCloseTo(2.0, 5);
+ });
+ });
+
+ describe("resize", () => {
+ it("recomputes aspect from new viewport rect", () => {
+ const cam = new Camera3d(0, 0, 800, 600);
+ expect(cam.aspect).toBeCloseTo(800 / 600, 5);
+ cam.resize(1920, 1080);
+ expect(cam.aspect).toBeCloseTo(1920 / 1080, 5);
+ });
+
+ it("rebuilds projection matrix on resize", () => {
+ const cam = new Camera3d(0, 0, 800, 600);
+ const before = cam.projectionMatrix.val[0];
+ cam.resize(1920, 1080); // different aspect
+ const after = cam.projectionMatrix.val[0];
+ expect(after).not.toBeCloseTo(before, 5);
+ });
+ });
+
+ describe("lookAt", () => {
+ it("derives yaw from XZ direction (target to the right → positive yaw)", () => {
+ const cam = new Camera3d(0, 0, 800, 600);
+ cam.pos.set(0, 0, 0);
+ // target directly to the right (+x), at the same z+depth
+ cam.lookAt(10, 0, 1);
+ // atan2(10, 1) ≈ PI/2 - epsilon. Direction to the right.
+ expect(cam.yaw).toBeGreaterThan(0);
+ });
+
+ it("derives pitch from vertical direction", () => {
+ const cam = new Camera3d(0, 0, 800, 600);
+ cam.pos.set(0, 0, 0);
+ // target below the camera (positive y in Y-down)
+ cam.lookAt(0, 10, 1);
+ // Y-down: pitch should be negative (camera points downward)
+ expect(cam.pitch).toBeLessThan(0);
+ });
+
+ it("yaw=0, pitch=0 when target is straight ahead (+z)", () => {
+ const cam = new Camera3d(0, 0, 800, 600);
+ cam.pos.set(0, 0, 0);
+ cam.lookAt(0, 0, 100);
+ expect(cam.yaw).toBeCloseTo(0, 5);
+ expect(cam.pitch).toBeCloseTo(0, 5);
+ });
+
+ it("setLookAt accepts Vector3d", () => {
+ const cam = new Camera3d(0, 0, 800, 600);
+ cam.pos.set(0, 0, 0);
+ cam.setLookAt(new Vector3d(0, 0, 100));
+ expect(cam.yaw).toBeCloseTo(0, 5);
+ expect(cam.pitch).toBeCloseTo(0, 5);
+ });
+
+ it("returns this for chaining", () => {
+ const cam = new Camera3d(0, 0, 800, 600);
+ expect(cam.lookAt(1, 2, 3)).toBe(cam);
+ expect(cam.setLookAt(new Vector3d(1, 2, 3))).toBe(cam);
+ });
+ });
+
+ describe("followOffset / target follow", () => {
+ it("setFollowOffset sets the offset vector", () => {
+ const cam = new Camera3d(0, 0, 800, 600);
+ cam.setFollowOffset(1, 2, 3);
+ expect(cam.followOffset.x).toBe(1);
+ expect(cam.followOffset.y).toBe(2);
+ expect(cam.followOffset.z).toBe(3);
+ });
+
+ it("setFollowOffset returns this for chaining", () => {
+ const cam = new Camera3d(0, 0, 800, 600);
+ expect(cam.setFollowOffset(0, 0, 0)).toBe(cam);
+ });
+
+ it("updateTarget resolves to target.pos + followOffset", () => {
+ const cam = new Camera3d(0, 0, 800, 600);
+ const target = new Vector3d(100, 50, 200);
+ cam.target = target;
+ cam.setFollowOffset(0, -5, -10);
+
+ cam.updateTarget();
+
+ expect(cam.pos.x).toBe(100); // target.x + 0
+ expect(cam.pos.y).toBe(45); // target.y + -5
+ expect(cam.pos.z).toBe(190); // target.z + -10
+ });
+
+ it("updateTarget with Renderable target uses target.pos", () => {
+ const cam = new Camera3d(0, 0, 800, 600);
+ const target = new Renderable(10, 20, 32, 32);
+ cam.target = target.pos;
+ cam.setFollowOffset(0, 0, -8);
+
+ cam.updateTarget();
+
+ expect(cam.pos.x).toBe(10);
+ expect(cam.pos.y).toBe(20);
+ expect(cam.pos.z).toBe(-8); // pos.z defaults to 0, + offset.z
+ });
+
+ it("updateTarget tracks z from a Renderable with non-zero depth (Copilot review #1463)", () => {
+ // Regression: `Camera2d.follow(renderable)` assigns
+ // `cam.target = renderable.pos`, which is an
+ // ObservableVector3d (not a plain Vector3d). The first cut
+ // of updateTarget did `target instanceof Vector3d` to read
+ // z, which silently treated z as 0 for every Renderable
+ // target. Duck-typed `typeof target.z === "number"` fixes it.
+ const cam = new Camera3d(0, 0, 800, 600);
+ const target = new Renderable(10, 20, 32, 32);
+ target.pos.z = 500; // non-zero depth — this MUST flow to the camera
+ cam.target = target.pos;
+ cam.setFollowOffset(0, 0, -8);
+
+ cam.updateTarget();
+
+ expect(cam.pos.x).toBe(10);
+ expect(cam.pos.y).toBe(20);
+ expect(cam.pos.z).toBe(492); // target.pos.z (500) + offset.z (-8)
+ });
+
+ it("no-op when target is null (falls through to Camera2d behavior)", () => {
+ const cam = new Camera3d(0, 0, 800, 600);
+ cam.pos.set(50, 60, 70);
+ cam.target = null;
+
+ cam.updateTarget();
+
+ // position unchanged
+ expect(cam.pos.x).toBe(50);
+ expect(cam.pos.y).toBe(60);
+ expect(cam.pos.z).toBe(70);
+ });
+ });
+
+ describe("isVisible (plane-based frustum culling)", () => {
+ // Camera2d's `isVisible` tests a 2D rect overlap against
+ // `worldView` — invalid under perspective, because the visible
+ // region is a frustum that widens with distance and rotates
+ // with the camera. Camera3d overrides to do proper plane-based
+ // frustum culling: each renderable's bounding sphere is tested
+ // against the six frustum planes built in `update()`.
+ const setupCam = () => {
+ const cam = new Camera3d(0, 0, 800, 600);
+ // camera behind the origin, looking straight ahead
+ cam.pos.set(0, 0, -200);
+ cam.yaw = 0;
+ cam.pitch = 0;
+ cam.update(); // rebuild planes for the current pose
+ return cam;
+ };
+
+ it("sprite in front of the camera is visible", () => {
+ const cam = setupCam();
+ const sprite = new Renderable(0, 0, 32, 32);
+ sprite.pos.z = 200; // in front of camera at z=-200
+ expect(cam.isVisible(sprite)).toBe(true);
+ });
+
+ it("sprite behind the camera is culled", () => {
+ const cam = setupCam();
+ const sprite = new Renderable(0, 0, 32, 32);
+ sprite.pos.z = -500; // behind camera at z=-200 (past near plane)
+ expect(cam.isVisible(sprite)).toBe(false);
+ });
+
+ it("sprite far past the far plane is culled", () => {
+ const cam = setupCam();
+ const sprite = new Renderable(0, 0, 32, 32);
+ sprite.pos.z = 5000; // past far=1000
+ expect(cam.isVisible(sprite)).toBe(false);
+ });
+
+ it("sprite far to the right (outside horizontal FOV) is culled", () => {
+ const cam = setupCam();
+ const sprite = new Renderable(5000, 0, 32, 32);
+ sprite.pos.z = 100;
+ expect(cam.isVisible(sprite)).toBe(false);
+ });
+
+ it("rotating the camera brings a previously off-screen sprite into view", () => {
+ const cam = setupCam();
+ // sprite at world z = -400: behind camera (which is at z = -200
+ // looking +Z, so anything at z < -200 is behind it)
+ const sprite = new Renderable(0, 0, 64, 64);
+ sprite.pos.z = -400;
+ expect(cam.isVisible(sprite)).toBe(false);
+
+ // turn 180° around Y — camera now faces -Z, sprite at z=-400
+ // is in front
+ cam.yaw = Math.PI;
+ cam.update();
+ expect(cam.isVisible(sprite)).toBe(true);
+ });
+
+ it("delegates to Camera2d's 2D rect test for floating elements", () => {
+ const cam = setupCam();
+ const inViewport = new Renderable(100, 100, 32, 32);
+ inViewport.floating = true;
+ expect(cam.isVisible(inViewport)).toBe(true);
+
+ const outsideViewport = new Renderable(5000, 5000, 32, 32);
+ outsideViewport.floating = true;
+ expect(cam.isVisible(outsideViewport)).toBe(false);
+ });
+
+ // ---- vertical FOV / pitch ----
+
+ it("sprite far above the camera (outside vertical FOV) is culled", () => {
+ const cam = setupCam();
+ // Y-down: large negative y is "above" (off the top of the screen)
+ const sprite = new Renderable(0, -5000, 32, 32);
+ sprite.pos.z = 100;
+ expect(cam.isVisible(sprite)).toBe(false);
+ });
+
+ it("sprite far below the camera (outside vertical FOV) is culled", () => {
+ const cam = setupCam();
+ const sprite = new Renderable(0, 5000, 32, 32);
+ sprite.pos.z = 100;
+ expect(cam.isVisible(sprite)).toBe(false);
+ });
+
+ it("pitching up reveals a sprite that's above the original frustum", () => {
+ const cam = setupCam();
+ // sprite well above the camera in Y-down coords
+ const sprite = new Renderable(0, -800, 32, 32);
+ sprite.pos.z = 200;
+ expect(cam.isVisible(sprite)).toBe(false);
+
+ // pitch up — frustum tilts to include things above
+ cam.pitch = Math.PI / 3; // 60° upward
+ cam.update();
+ expect(cam.isVisible(sprite)).toBe(true);
+ });
+
+ it("pitching down reveals a sprite that's below the original frustum", () => {
+ const cam = setupCam();
+ const sprite = new Renderable(0, 800, 32, 32);
+ sprite.pos.z = 200;
+ expect(cam.isVisible(sprite)).toBe(false);
+
+ cam.pitch = -Math.PI / 3; // 60° downward
+ cam.update();
+ expect(cam.isVisible(sprite)).toBe(true);
+ });
+
+ // ---- sphere edge cases ----
+
+ it("sprite straddling the near plane (center behind, radius pokes through) is visible", () => {
+ const cam = new Camera3d(0, 0, 800, 600, { near: 1, far: 1000 });
+ cam.pos.set(0, 0, 0);
+ cam.update();
+ // sprite center at z=-0.5 (behind near plane at z=1) but radius
+ // large enough that the sphere overlaps the near plane
+ const sprite = new Renderable(0, 0, 200, 200);
+ sprite.pos.z = -0.5;
+ expect(cam.isVisible(sprite)).toBe(true);
+ });
+
+ it("sprite at exactly the far plane is visible (edge of frustum)", () => {
+ const cam = new Camera3d(0, 0, 800, 600, { near: 0.1, far: 1000 });
+ cam.pos.set(0, 0, 0);
+ cam.update();
+ const sprite = new Renderable(0, 0, 32, 32);
+ sprite.pos.z = 1000; // exactly at far
+ expect(cam.isVisible(sprite)).toBe(true);
+ });
+
+ it("very small sprite (1px) deep in the frustum is still classified correctly", () => {
+ const cam = setupCam();
+ const sprite = new Renderable(0, 0, 1, 1);
+ sprite.pos.z = 500; // well inside frustum
+ expect(cam.isVisible(sprite)).toBe(true);
+ });
+
+ // ---- off-axis camera positions ----
+
+ it("works with camera offset in X (not just at origin)", () => {
+ const cam = new Camera3d(0, 0, 800, 600);
+ cam.pos.set(1000, 0, 0); // camera shifted right
+ cam.update();
+ // sprite at world (1000, 0, 200) is straight ahead of THIS camera
+ const sprite = new Renderable(1000, 0, 32, 32);
+ sprite.pos.z = 200;
+ expect(cam.isVisible(sprite)).toBe(true);
+ // sprite at world (0, 0, 200) is 1000 units to the left of camera —
+ // outside the horizontal FOV
+ const sprite2 = new Renderable(0, 0, 32, 32);
+ sprite2.pos.z = 200;
+ expect(cam.isVisible(sprite2)).toBe(false);
+ });
+
+ // ---- narrow FOV ----
+
+ it("narrow FOV culls sprites that wide FOV would include", () => {
+ const wideCam = new Camera3d(0, 0, 800, 600, { fov: Math.PI / 2 });
+ wideCam.pos.set(0, 0, -200);
+ wideCam.update();
+ const narrowCam = new Camera3d(0, 0, 800, 600, {
+ fov: Math.PI / 12, // 15° — very narrow telephoto
+ });
+ narrowCam.pos.set(0, 0, -200);
+ narrowCam.update();
+
+ // sprite off to the side: wide FOV should see it, narrow shouldn't
+ const sprite = new Renderable(150, 0, 32, 32);
+ sprite.pos.z = 100;
+ expect(wideCam.isVisible(sprite)).toBe(true);
+ expect(narrowCam.isVisible(sprite)).toBe(false);
+ });
+
+ // ---- regression: PR #1464 user report ----
+
+ it("user-reported regression: sprites stay visible after a single left-arrow click", () => {
+ // Reproduces the exact scenario from the user report on
+ // PR #1464: Camera3d example with 3 monsters at z=200/400/600,
+ // camera at (0, 0, -300) orbiting target z=400 at distance=700,
+ // one left-arrow click (yaw -= 0.15). Pre-frustum-culling fix,
+ // inheriting Camera2d's worldView 2D-rect test silently culled
+ // the monsters because the rect was at the camera's pos.x/y,
+ // not in the actual perspective view.
+ const cam = new Camera3d(0, 0, 1024, 768, {
+ fov: Math.PI / 3,
+ near: 0.1,
+ far: 1000,
+ });
+
+ const sprites = [200, 400, 600].map((z) => {
+ const s = new Renderable(0, 0, 112, 112); // monster size after 0.5 scale
+ s.pos.z = z;
+ return s;
+ });
+
+ // initial camera pose: yaw=0 pitch=0 distance=700 orbiting z=400
+ const orbit = (yaw, pitch, distance, target) => {
+ cam.pos.set(
+ Math.sin(yaw) * Math.cos(pitch) * -distance,
+ Math.sin(pitch) * distance,
+ target - Math.cos(yaw) * Math.cos(pitch) * distance,
+ );
+ cam.lookAt(0, 0, target);
+ cam.update();
+ };
+
+ orbit(0, 0, 700, 400);
+ // at initial pose, all 3 monsters in front of camera → visible
+ for (const s of sprites) {
+ expect(cam.isVisible(s)).toBe(true);
+ }
+
+ // simulate one left-arrow click — yaw decreases by 0.15
+ orbit(-0.15, 0, 700, 400);
+ // regression: every monster must STILL be visible after the
+ // camera orbits slightly. Pre-fix, all 3 silently disappeared.
+ for (const s of sprites) {
+ expect(cam.isVisible(s)).toBe(true);
+ }
+
+ // stress: 8 clicks to the left (yaw = -1.2 ≈ 69°) — camera
+ // orbits to the side; front monster might rotate out of view
+ // but the middle (target) one should remain inside
+ orbit(-1.2, 0, 700, 400);
+ expect(cam.isVisible(sprites[1])).toBe(true); // middle, orbited around
+ });
+
+ // ---- multi-update consistency ----
+
+ it("planes update correctly on every update() call (no stale state)", () => {
+ const cam = setupCam();
+ const sprite = new Renderable(0, 0, 32, 32);
+ sprite.pos.z = 200;
+ expect(cam.isVisible(sprite)).toBe(true);
+
+ // move camera way off, no update yet — isVisible still sees
+ // the old planes
+ cam.pos.set(10000, 10000, 10000);
+ // (no update call — verifies planes don't auto-rebuild)
+ expect(cam.isVisible(sprite)).toBe(true);
+
+ // after update, planes refresh and reflect the new pose
+ cam.update();
+ expect(cam.isVisible(sprite)).toBe(false);
+
+ // move back, update again — planes refresh
+ cam.pos.set(0, 0, -200);
+ cam.update();
+ expect(cam.isVisible(sprite)).toBe(true);
+ });
+ });
+
+ describe("backward compat with Camera2d API", () => {
+ it("near/far inherited and overridden by perspective defaults", () => {
+ const cam = new Camera3d(0, 0, 800, 600);
+ // Camera2d defaults to ±1e6; Camera3d narrows to 0.1/1000
+ // for meaningful perspective z resolution
+ expect(cam.near).toBe(0.1);
+ expect(cam.far).toBe(1000);
+ });
+
+ it("inherits shake / fade / camera-effect plumbing", () => {
+ const cam = new Camera3d(0, 0, 800, 600);
+ expect(typeof cam.shake).toBe("function");
+ expect(typeof cam.fadeIn).toBe("function");
+ expect(typeof cam.fadeOut).toBe("function");
+ expect(Array.isArray(cam.cameraEffects)).toBe(true);
+ });
+
+ it("inherits follow() from Camera2d", () => {
+ const cam = new Camera3d(0, 0, 800, 600);
+ expect(typeof cam.follow).toBe("function");
+ });
+
+ it("name defaults to 'default'", () => {
+ const cam = new Camera3d(0, 0, 800, 600);
+ expect(cam.name).toBe("default");
+ });
+
+ it("projectionMatrix is a Matrix3d", () => {
+ const cam = new Camera3d(0, 0, 800, 600);
+ expect(cam.projectionMatrix).toBeInstanceOf(Matrix3d);
+ });
+ });
+
+ describe("defaultSortOn (Camera2d/3d bootstrap hint)", () => {
+ it("Camera2d.defaultSortOn is 'z' (preserves pre-19.7 default)", () => {
+ expect(Camera2d.defaultSortOn).toBe("z");
+ });
+
+ it("Camera3d.defaultSortOn is 'depth' (perspective painter's sort)", () => {
+ expect(Camera3d.defaultSortOn).toBe("depth");
+ });
+
+ it("Camera3d.defaultSortOn is statically inherited (own property override, not the Camera2d value)", () => {
+ // guards against a regression where someone deletes the
+ // `static override defaultSortOn` line on Camera3d, which
+ // would silently inherit Camera2d's 'z' and break all 3D apps
+ expect(Object.hasOwn(Camera3d, "defaultSortOn")).toBe(true);
+ expect(Camera3d.defaultSortOn).not.toBe(Camera2d.defaultSortOn);
+ });
+
+ it("A custom Camera3d subclass inherits 'depth' unless it overrides", () => {
+ class MyCam extends Camera3d {}
+ expect(MyCam.defaultSortOn).toBe("depth");
+ });
+
+ it("A custom Camera2d subclass can declare its own preferred mode", () => {
+ class IsoCam extends Camera2d {
+ static defaultSortOn = "y";
+ }
+ expect(IsoCam.defaultSortOn).toBe("y");
+ // the base class is untouched
+ expect(Camera2d.defaultSortOn).toBe("z");
+ });
+ });
+});
diff --git a/packages/melonjs/tests/camera3d_integration.spec.js b/packages/melonjs/tests/camera3d_integration.spec.js
new file mode 100644
index 000000000..d52645d6c
--- /dev/null
+++ b/packages/melonjs/tests/camera3d_integration.spec.js
@@ -0,0 +1,288 @@
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import {
+ Application,
+ boot,
+ Camera2d,
+ Camera3d,
+ Stage,
+ state,
+ video,
+} from "../src/index.js";
+
+/**
+ * Integration tests for Camera3d × Stage × Application wiring.
+ *
+ * Validates the 4-step camera resolution order in `Stage.reset()`:
+ * 1. explicit `cameras` array on the stage (most-specific)
+ * 2. `cameraClass` on the stage settings
+ * 3. `cameraClass` on the application settings
+ * 4. module-level Camera2d singleton (fallback / pre-19.7 path)
+ *
+ * Also verifies that `DefaultLoadingScreen` always uses Camera2d,
+ * even when the app sets `cameraClass: Camera3d` globally.
+ */
+describe("Camera3d × Stage × Application integration", () => {
+ beforeAll(() => {
+ boot();
+ video.init(800, 600, {
+ parent: "screen",
+ scale: "auto",
+ renderer: video.CANVAS,
+ });
+ });
+
+ afterAll(() => {
+ // hand the world back to a clean default for any later test files
+ video.init(800, 600, {
+ parent: "screen",
+ scale: "auto",
+ renderer: video.AUTO,
+ });
+ });
+
+ describe("4-step camera resolution order", () => {
+ it("step 1: explicit `cameras` wins over everything", () => {
+ const explicitCam = new Camera3d(0, 0, 800, 600);
+ explicitCam.name = "default";
+ const stage = new Stage({ cameras: [explicitCam] });
+
+ // fake app with cameraClass = Camera2d (would lose if step 1 didn't win)
+ const fakeApp = {
+ renderer: { width: 800, height: 600 },
+ settings: { cameraClass: Camera2d },
+ };
+ stage.reset(fakeApp);
+
+ expect(stage.cameras.get("default")).toBe(explicitCam);
+ });
+
+ it("step 2: stage.cameraClass wins over app.cameraClass", () => {
+ const stage = new Stage({ cameraClass: Camera3d });
+ const fakeApp = {
+ renderer: { width: 800, height: 600 },
+ settings: { cameraClass: Camera2d },
+ };
+ stage.reset(fakeApp);
+
+ const cam = stage.cameras.get("default");
+ expect(cam).toBeInstanceOf(Camera3d);
+ });
+
+ it("step 3: app.cameraClass used when stage has neither cameras nor cameraClass", () => {
+ const stage = new Stage();
+ const fakeApp = {
+ renderer: { width: 800, height: 600 },
+ settings: { cameraClass: Camera3d },
+ };
+ stage.reset(fakeApp);
+
+ const cam = stage.cameras.get("default");
+ expect(cam).toBeInstanceOf(Camera3d);
+ });
+
+ it("step 4: falls back to Camera2d singleton when no cameraClass set anywhere", () => {
+ const stage = new Stage();
+ const fakeApp = {
+ renderer: { width: 800, height: 600 },
+ settings: {}, // no cameraClass
+ };
+ stage.reset(fakeApp);
+
+ const cam = stage.cameras.get("default");
+ expect(cam).toBeInstanceOf(Camera2d);
+ });
+
+ it("singleton path: two stages share the same Camera2d instance (pre-19.7 behavior)", () => {
+ // when neither stage opts in, the module-level singleton should
+ // be shared. This is the existing behavior and PR B preserves it.
+ const stageA = new Stage();
+ const stageB = new Stage();
+ const fakeApp = {
+ renderer: { width: 800, height: 600 },
+ settings: {},
+ };
+
+ stageA.reset(fakeApp);
+ stageB.reset(fakeApp);
+
+ const camA = stageA.cameras.get("default");
+ const camB = stageB.cameras.get("default");
+
+ expect(camA).toBeInstanceOf(Camera2d);
+ expect(camA).toBe(camB); // same singleton reference
+ });
+
+ it("cameraClass path: two stages get distinct Camera3d instances (no state bleed)", () => {
+ // Camera3d holds per-stage state (pitch/yaw/fov); singleton
+ // sharing would cross-contaminate scenes. Each stage gets its
+ // own instance.
+ const stageA = new Stage({ cameraClass: Camera3d });
+ const stageB = new Stage({ cameraClass: Camera3d });
+ const fakeApp = {
+ renderer: { width: 800, height: 600 },
+ settings: {},
+ };
+
+ stageA.reset(fakeApp);
+ stageB.reset(fakeApp);
+
+ const camA = stageA.cameras.get("default");
+ const camB = stageB.cameras.get("default");
+
+ expect(camA).toBeInstanceOf(Camera3d);
+ expect(camB).toBeInstanceOf(Camera3d);
+ expect(camA).not.toBe(camB); // distinct instances
+ });
+ });
+
+ describe("DefaultLoadingScreen protection", () => {
+ it("uses Camera2d even when app.cameraClass = Camera3d", () => {
+ // the built-in loader is registered at module load. Its
+ // constructor pins cameraClass to Camera2d, so a global
+ // Camera3d opt-in must not affect it.
+ const loader = state.get(state.LOADING);
+ expect(loader).toBeDefined();
+ expect(loader).toBeInstanceOf(Stage);
+
+ // reset as if launching with a Camera3d-defaulted app
+ const fakeApp3d = {
+ renderer: { width: 800, height: 600 },
+ settings: { cameraClass: Camera3d },
+ world: { backgroundColor: { parseCSS: () => {} } }, // stub world for onResetEvent
+ };
+
+ // only run the camera-resolution path, not the full onResetEvent
+ // (which adds children, needs a real world). Call Stage.reset's
+ // camera-resolution body directly via a fresh Stage with the
+ // same cameraClass that DefaultLoadingScreen uses.
+ const cam2dStage = new Stage({ cameraClass: Camera2d });
+ cam2dStage.reset(fakeApp3d);
+
+ expect(cam2dStage.cameras.get("default")).toBeInstanceOf(Camera2d);
+ expect(cam2dStage.cameras.get("default")).not.toBeInstanceOf(Camera3d);
+
+ // also verify the actual loader instance's settings
+ expect(loader.settings.cameraClass).toBe(Camera2d);
+ });
+ });
+
+ describe("Application.settings.cameraClass propagation", () => {
+ it("Application accepts cameraClass in settings", () => {
+ // don't construct a full Application (would conflict with the
+ // boot() call above). Just verify the settings type accepts
+ // the field — Application's settings is a public object.
+ // Spot-check by reading the application module's exported type
+ // behavior via a fresh Stage that consumes a fake app.
+ const stage = new Stage();
+ const fakeApp = {
+ renderer: { width: 800, height: 600 },
+ settings: { cameraClass: Camera3d },
+ };
+
+ stage.reset(fakeApp);
+
+ const cam = stage.cameras.get("default");
+ expect(cam).toBeInstanceOf(Camera3d);
+ expect(cam.width).toBe(800);
+ expect(cam.height).toBe(600);
+ });
+
+ it("Application without cameraClass setting → singleton Camera2d", () => {
+ const stage = new Stage();
+ const fakeApp = {
+ renderer: { width: 800, height: 600 },
+ settings: {}, // pre-19.7-style settings
+ };
+ stage.reset(fakeApp);
+
+ expect(stage.cameras.get("default")).toBeInstanceOf(Camera2d);
+ });
+ });
+
+ describe("smoke: full Application with cameraClass", () => {
+ it("can construct an Application with cameraClass: Camera3d without error", () => {
+ // use Canvas renderer so we don't spam WebGL contexts
+ expect(() => {
+ const app = new Application(400, 300, {
+ parent: "screen",
+ renderer: video.CANVAS,
+ cameraClass: Camera3d,
+ });
+ // the app should accept the setting and store it
+ expect(app.settings.cameraClass).toBe(Camera3d);
+ }).not.toThrow();
+ });
+ });
+
+ describe("world.sortOn auto-bootstrap from cameraClass.defaultSortOn", () => {
+ it("Application(cameraClass: Camera3d) sets world.sortOn = 'depth'", () => {
+ const app = new Application(400, 300, {
+ parent: "screen",
+ renderer: video.CANVAS,
+ cameraClass: Camera3d,
+ });
+ expect(app.world.sortOn).toBe("depth");
+ });
+
+ it("Application(cameraClass: Camera2d) sets world.sortOn = 'z' (today's default)", () => {
+ const app = new Application(400, 300, {
+ parent: "screen",
+ renderer: video.CANVAS,
+ cameraClass: Camera2d,
+ });
+ expect(app.world.sortOn).toBe("z");
+ });
+
+ it("Application with no cameraClass leaves world.sortOn at its 'z' default", () => {
+ // no-op bootstrap path — exists to lock in that we never
+ // regress to silently switching modes on pre-19.7 games
+ const app = new Application(400, 300, {
+ parent: "screen",
+ renderer: video.CANVAS,
+ });
+ expect(app.world.sortOn).toBe("z");
+ });
+
+ it("custom Camera subclass with defaultSortOn = 'y' bootstraps the world to 'y'", () => {
+ class YSortCam extends Camera2d {
+ static defaultSortOn = "y";
+ }
+ const app = new Application(400, 300, {
+ parent: "screen",
+ renderer: video.CANVAS,
+ cameraClass: YSortCam,
+ });
+ expect(app.world.sortOn).toBe("y");
+ });
+
+ it("user can override world.sortOn after construction — bootstrap is not sticky", () => {
+ const app = new Application(400, 300, {
+ parent: "screen",
+ renderer: video.CANVAS,
+ cameraClass: Camera3d,
+ });
+ expect(app.world.sortOn).toBe("depth");
+ app.world.sortOn = "x";
+ expect(app.world.sortOn).toBe("x");
+ // nothing should silently switch it back
+ expect(app.world._sortOn).toBe("x");
+ });
+
+ it("Stage.cameraClass override flips world.sortOn when the stage's camera differs from the app's", () => {
+ // app uses Camera3d (world bootstrapped to 'depth'); when a
+ // stage with `cameraClass: Camera2d` resets onto that app,
+ // the loader pattern, world.sortOn flips back to 'z' so the
+ // 2D stage paints correctly.
+ const app = new Application(400, 300, {
+ parent: "screen",
+ renderer: video.CANVAS,
+ cameraClass: Camera3d,
+ });
+ expect(app.world.sortOn).toBe("depth");
+
+ const stage = new Stage({ cameraClass: Camera2d });
+ stage.reset(app);
+ expect(app.world.sortOn).toBe("z");
+ });
+ });
+});
diff --git a/packages/melonjs/tests/container.spec.js b/packages/melonjs/tests/container.spec.js
index b51abc6a9..60940df73 100644
--- a/packages/melonjs/tests/container.spec.js
+++ b/packages/melonjs/tests/container.spec.js
@@ -713,6 +713,182 @@ describe("Container", () => {
container.sortOn = "z";
expect(container._comparator).toBe(container._sortZ);
});
+
+ it("sortOn = 'depth' wires up the _sortDepth comparator", () => {
+ container.sortOn = "depth";
+ expect(container._sortOn).toBe("depth");
+ expect(container._comparator).toBe(container._sortDepth);
+ });
+
+ it("sortOn accepts case-insensitive 'DEPTH'", () => {
+ container.sortOn = "DEPTH";
+ expect(container._sortOn).toBe("depth");
+ expect(container._comparator).toBe(container._sortDepth);
+ });
+
+ it("sortOn rejects bogus values with a message naming the legal modes", () => {
+ expect(() => {
+ container.sortOn = "garbage";
+ }).toThrow(/expected "x", "y", "z", or "depth"/);
+ });
+
+ it("_sortDepth returns 0 for two children identical to the cached camera (degenerate)", () => {
+ container.sortOn = "depth";
+ const a = new Renderable(0, 0, 1, 1);
+ const b = new Renderable(0, 0, 1, 1);
+ // no active stage → captureDepthCamera caches (0, 0, 0); both
+ // children sit at the cached cam pos → distance² = 0 for both.
+ expect(container._sortDepth(a, b)).toBe(0);
+ });
+
+ it("_sortDepth orders children by ascending distance from the cached camera (closer first)", () => {
+ container.sortOn = "depth";
+ const close = new Renderable(10, 0, 1, 1);
+ const far = new Renderable(100, 0, 1, 1);
+ // (0,0) cached → close: 100, far: 10000 → cmp < 0 → close first
+ expect(container._sortDepth(close, far)).toBeLessThan(0);
+ expect(container._sortDepth(far, close)).toBeGreaterThan(0);
+ });
+
+ it("_sortDepth includes pos.z in the distance computation", () => {
+ container.sortOn = "depth";
+ const flat = new Renderable(0, 0, 1, 1); // pos.z = 0
+ const deep = new Renderable(0, 0, 1, 1);
+ deep.pos.z = 50; // 2500 vs 0
+ expect(container._sortDepth(flat, deep)).toBeLessThan(0);
+ });
+
+ it("_sortDepth tolerates NaN coordinates without throwing", () => {
+ container.sortOn = "depth";
+ const ok = new Renderable(0, 0, 1, 1);
+ const broken = new Renderable(0, 0, 1, 1);
+ broken.pos.x = Number.NaN;
+ // NaN propagates through arithmetic to NaN comparator output;
+ // Array.sort with NaN is implementation-defined but must not
+ // throw. We only assert no exception, not a specific order.
+ expect(() => {
+ container._sortDepth(ok, broken);
+ }).not.toThrow();
+ });
+
+ it("sortNow sorts synchronously, no defer", () => {
+ container.sortOn = "z";
+ const a = new Renderable(0, 0, 1, 1);
+ a.pos.z = 1;
+ const b = new Renderable(0, 0, 1, 1);
+ b.pos.z = 5;
+ const c = new Renderable(0, 0, 1, 1);
+ c.pos.z = 3;
+ // Bypass addChild's autoSort: shove directly into the children
+ // array so we can verify sortNow alone produces the order.
+ container.children = [a, b, c];
+ container.sortNow();
+ // _sortZ is descending: [5, 3, 1]
+ expect(
+ container.children.map((x) => {
+ return x.pos.z;
+ }),
+ ).toEqual([5, 3, 1]);
+ });
+
+ it("sortNow sets isDirty when a sort actually happened", () => {
+ container.sortOn = "z";
+ const a = new Renderable(0, 0, 1, 1);
+ const b = new Renderable(0, 0, 1, 1);
+ container.children = [a, b];
+ container.isDirty = false;
+ container.sortNow();
+ expect(container.isDirty).toBe(true);
+ });
+
+ it("sortNow is a no-op for single-child / empty containers (no isDirty flip)", () => {
+ container.children = [];
+ container.isDirty = false;
+ container.sortNow();
+ expect(container.isDirty).toBe(false);
+ container.children = [new Renderable(0, 0, 1, 1)];
+ container.isDirty = false;
+ container.sortNow();
+ expect(container.isDirty).toBe(false);
+ });
+
+ it("sortNow(true) recurses into sub-containers", () => {
+ const sub = new Container(0, 0, 100, 100);
+ sub.sortOn = "z";
+ const a = new Renderable(0, 0, 1, 1);
+ a.pos.z = 1;
+ const b = new Renderable(0, 0, 1, 1);
+ b.pos.z = 5;
+ sub.children = [a, b];
+
+ container.sortOn = "z";
+ container.children = [sub];
+
+ container.sortNow(true);
+ // sub's children should now be sorted descending by z
+ expect(
+ sub.children.map((x) => {
+ return x.pos.z;
+ }),
+ ).toEqual([5, 1]);
+ });
+
+ it("sortNow(false) does NOT recurse — sub-container is left untouched", () => {
+ const sub = new Container(0, 0, 100, 100);
+ sub.sortOn = "z";
+ const a = new Renderable(0, 0, 1, 1);
+ a.pos.z = 1;
+ const b = new Renderable(0, 0, 1, 1);
+ b.pos.z = 5;
+ sub.children = [a, b]; // intentionally out of order
+
+ container.children = [sub];
+ container.sortNow();
+ expect(
+ sub.children.map((x) => {
+ return x.pos.z;
+ }),
+ ).toEqual([1, 5]); // unchanged
+ });
+
+ it("sortNow with sortOn='depth' produces ascending camera-distance order", () => {
+ container.sortOn = "depth";
+ const close = new Renderable(5, 0, 1, 1);
+ const mid = new Renderable(50, 0, 1, 1);
+ const far = new Renderable(500, 0, 1, 1);
+ container.children = [far, close, mid]; // intentionally unsorted
+ container.sortNow();
+ // no active stage → camera caches at (0, 0, 0) → ascending dist²
+ expect(
+ container.children.map((x) => {
+ return x.pos.x;
+ }),
+ ).toEqual([5, 50, 500]);
+ });
+
+ it("sortNow with sortOn='depth' on huge coordinates doesn't overflow into wrong order", () => {
+ container.sortOn = "depth";
+ const a = new Renderable(1e5, 0, 1, 1);
+ const b = new Renderable(2e5, 0, 1, 1);
+ // dist² = 1e10 vs 4e10 — well under Number.MAX_SAFE_INTEGER (≈9e15)
+ container.children = [b, a];
+ container.sortNow();
+ expect(container.children).toEqual([a, b]);
+ });
+
+ it("sortOn='depth' on container with no children doesn't crash", () => {
+ container.sortOn = "depth";
+ expect(() => {
+ container.sortNow();
+ }).not.toThrow();
+ });
+
+ it("switching sortOn from 'depth' back to 'z' restores _sortZ", () => {
+ container.sortOn = "depth";
+ expect(container._comparator).toBe(container._sortDepth);
+ container.sortOn = "z";
+ expect(container._comparator).toBe(container._sortZ);
+ });
});
describe("enableChildBoundsUpdate", () => {
diff --git a/packages/melonjs/tests/frustum.spec.js b/packages/melonjs/tests/frustum.spec.js
new file mode 100644
index 000000000..986bfdd4d
--- /dev/null
+++ b/packages/melonjs/tests/frustum.spec.js
@@ -0,0 +1,241 @@
+import { describe, expect, it } from "vitest";
+import { Frustum, Matrix3d as Matrix3dClass } from "../src/index.js";
+
+/**
+ * Tests for the standalone Frustum class.
+ * Pure JS, no WebGL or boot() needed — Frustum is a math container.
+ */
+describe("Frustum", () => {
+ describe("defaults", () => {
+ it("constructs with sensible defaults", () => {
+ const f = new Frustum();
+ expect(f.fov).toBeCloseTo(Math.PI / 3, 5);
+ expect(f.aspect).toBe(1.0);
+ expect(f.near).toBe(0.1);
+ expect(f.far).toBe(1000);
+ expect(f.projectionMatrix).toBeInstanceOf(Matrix3dClass);
+ });
+
+ it("honors constructor opts", () => {
+ const f = new Frustum({
+ fov: Math.PI / 4,
+ aspect: 16 / 9,
+ near: 0.5,
+ far: 2000,
+ });
+ expect(f.fov).toBeCloseTo(Math.PI / 4, 5);
+ expect(f.aspect).toBeCloseTo(16 / 9, 5);
+ expect(f.near).toBe(0.5);
+ expect(f.far).toBe(2000);
+ });
+
+ it("partial opts merge with defaults", () => {
+ const f = new Frustum({ fov: Math.PI / 2 });
+ expect(f.fov).toBeCloseTo(Math.PI / 2, 5);
+ expect(f.aspect).toBe(1.0); // default
+ expect(f.near).toBe(0.1); // default
+ expect(f.far).toBe(1000); // default
+ });
+ });
+
+ describe("update", () => {
+ it("rebuilds projectionMatrix from current params", () => {
+ const f = new Frustum();
+ const before = f.projectionMatrix.val.slice();
+
+ f.fov = Math.PI / 2; // 90° — different matrix
+ f.update();
+ const after = f.projectionMatrix.val;
+
+ // element [0] = f / aspect = (1 / tan(fov/2)) / aspect.
+ // Different fov → different [0].
+ expect(after[0]).not.toBeCloseTo(before[0], 5);
+ });
+
+ it("set() rebuilds matrix in one call", () => {
+ const f = new Frustum();
+ const before = f.projectionMatrix.val.slice();
+
+ f.set(Math.PI / 2, 2.0, 1.0, 100);
+
+ expect(f.fov).toBeCloseTo(Math.PI / 2, 5);
+ expect(f.aspect).toBe(2.0);
+ expect(f.near).toBe(1.0);
+ expect(f.far).toBe(100);
+ expect(f.projectionMatrix.val[0]).not.toBeCloseTo(before[0], 5);
+ });
+
+ it("set() returns this for chaining", () => {
+ const f = new Frustum();
+ expect(f.set(1, 1, 1, 10)).toBe(f);
+ });
+ });
+
+ describe("Y-down + +Z forward conventions", () => {
+ // melonJS Y-down: vertex at world (0, +1, +z) should project to
+ // negative NDC y (below the screen origin, which is top-left in
+ // 2D screen coords mapped to NDC y = +1 at top, -1 at bottom).
+ // Wait — actually in NDC, y=+1 is top and y=-1 is bottom by GL
+ // convention. melonJS's screen mapping then flips this so screen
+ // y=0 is top. So a vertex at world y=+1 should land at NDC y=-1
+ // (which then maps to screen y = canvas.height, the bottom).
+ //
+ // The frustum's projection matrix bakes in `scale(1, -1, -1)` to
+ // achieve Y-down: pre-scale a vertex's y by -1 so the standard
+ // OpenGL ortho/perspective produces NDC y that aligns with
+ // screen y.
+ it("projects +y world coord to negative NDC y (Y-down)", () => {
+ const f = new Frustum({
+ fov: Math.PI / 2,
+ aspect: 1,
+ near: 0.1,
+ far: 100,
+ });
+ // project (0, 1, 5, 1) — world point above and in front of camera
+ const m = f.projectionMatrix.val;
+ const x = 0,
+ y = 1,
+ z = 5,
+ w = 1;
+ const ndc_y = m[1] * x + m[5] * y + m[9] * z + m[13] * w;
+ const clip_w = m[3] * x + m[7] * y + m[11] * z + m[15] * w;
+ // Y-down convention: positive world y → negative NDC y after
+ // perspective divide
+ expect(ndc_y / clip_w).toBeLessThan(0);
+ });
+
+ it("projects +z world coord as 'in front' (farther = smaller)", () => {
+ const f = new Frustum({
+ fov: Math.PI / 2,
+ aspect: 1,
+ near: 0.1,
+ far: 1000,
+ });
+ const m = f.projectionMatrix.val;
+ // project a vertex at z=10 vs z=100. Both directly in front.
+ // In +Z forward convention, z=100 is farther.
+ const project = (z) => {
+ const ndc_x = m[0] * 1 + m[4] * 0 + m[8] * z + m[12] * 1;
+ const clip_w = m[3] * 1 + m[7] * 0 + m[11] * z + m[15] * 1;
+ return ndc_x / clip_w;
+ };
+ const at10 = Math.abs(project(10));
+ const at100 = Math.abs(project(100));
+ // farther vertex should project closer to center (smaller |ndc_x|)
+ expect(at100).toBeLessThan(at10);
+ });
+
+ it("near plane at z=near falls inside clip space", () => {
+ const f = new Frustum({
+ fov: Math.PI / 2,
+ aspect: 1,
+ near: 1,
+ far: 100,
+ });
+ const m = f.projectionMatrix.val;
+ // vertex at exactly z=near should have NDC z = -1 (on near plane)
+ const z = 1; // near
+ const ndc_z = m[2] * 0 + m[6] * 0 + m[10] * z + m[14] * 1;
+ const clip_w = m[3] * 0 + m[7] * 0 + m[11] * z + m[15] * 1;
+ expect(ndc_z / clip_w).toBeCloseTo(-1, 3);
+ });
+
+ it("far plane at z=far falls inside clip space", () => {
+ const f = new Frustum({
+ fov: Math.PI / 2,
+ aspect: 1,
+ near: 1,
+ far: 100,
+ });
+ const m = f.projectionMatrix.val;
+ const z = 100; // far
+ const ndc_z = m[2] * 0 + m[6] * 0 + m[10] * z + m[14] * 1;
+ const clip_w = m[3] * 0 + m[7] * 0 + m[11] * z + m[15] * 1;
+ expect(ndc_z / clip_w).toBeCloseTo(1, 3);
+ });
+ });
+
+ describe("aspect ratio handling", () => {
+ it("wider aspect → x-axis compressed", () => {
+ const f1 = new Frustum({ aspect: 1 });
+ const f2 = new Frustum({ aspect: 2 });
+ // element [0] = f / aspect. Wider aspect → smaller [0] → x compressed
+ expect(f2.projectionMatrix.val[0]).toBeLessThan(
+ f1.projectionMatrix.val[0],
+ );
+ });
+ });
+
+ describe("planes + culling", () => {
+ // build a frustum with a known view matrix and verify the
+ // extracted planes correctly classify world-space points and
+ // spheres. The view here is the identity — camera at origin
+ // looking down +Z (the engine's "forward" direction).
+ const buildFrustumWithIdentityView = (opts) => {
+ const f = new Frustum(opts);
+ const vp = new Matrix3dClass().copy(f.projectionMatrix); // view = identity
+ f.setFromViewProjection(vp);
+ return f;
+ };
+
+ it("setFromViewProjection populates 6 unit-normalized planes", () => {
+ const f = buildFrustumWithIdentityView();
+ expect(f.planes.length).toBe(6);
+ for (const p of f.planes) {
+ const len = Math.sqrt(p.nx * p.nx + p.ny * p.ny + p.nz * p.nz);
+ expect(len).toBeCloseTo(1, 5);
+ }
+ });
+
+ it("containsPoint accepts a point in front of the camera", () => {
+ const f = buildFrustumWithIdentityView({ near: 1, far: 100 });
+ // straight ahead at z=50
+ expect(f.containsPoint(0, 0, 50)).toBe(true);
+ });
+
+ it("containsPoint rejects a point behind the near plane", () => {
+ const f = buildFrustumWithIdentityView({ near: 1, far: 100 });
+ // z = 0.5 is between camera (z=0) and near (z=1) — behind near plane
+ expect(f.containsPoint(0, 0, 0.5)).toBe(false);
+ });
+
+ it("containsPoint rejects a point past the far plane", () => {
+ const f = buildFrustumWithIdentityView({ near: 1, far: 100 });
+ expect(f.containsPoint(0, 0, 200)).toBe(false);
+ });
+
+ it("containsPoint rejects a point outside the horizontal FOV", () => {
+ const f = buildFrustumWithIdentityView({
+ fov: Math.PI / 4,
+ near: 1,
+ far: 100,
+ });
+ // at z=10 with 45° vertical FOV (and aspect=1), the visible
+ // half-width is z * tan(fov/2) ≈ 10 * 0.414 = 4.14. A point
+ // at x=20, z=10 is well outside.
+ expect(f.containsPoint(20, 0, 10)).toBe(false);
+ });
+
+ it("intersectsSphere accepts a sphere entirely inside the frustum", () => {
+ const f = buildFrustumWithIdentityView({ near: 1, far: 100 });
+ expect(f.intersectsSphere(0, 0, 50, 1)).toBe(true);
+ });
+
+ it("intersectsSphere accepts a sphere clipping the near plane", () => {
+ const f = buildFrustumWithIdentityView({ near: 1, far: 100 });
+ // center is behind near, but radius pokes through
+ expect(f.intersectsSphere(0, 0, 0.5, 1)).toBe(true);
+ });
+
+ it("intersectsSphere rejects a sphere entirely behind the near plane", () => {
+ const f = buildFrustumWithIdentityView({ near: 1, far: 100 });
+ // center at z=-10, radius 1 → max z = -9, still behind near=1
+ expect(f.intersectsSphere(0, 0, -10, 1)).toBe(false);
+ });
+
+ it("intersectsSphere rejects a sphere far past the far plane", () => {
+ const f = buildFrustumWithIdentityView({ near: 1, far: 100 });
+ expect(f.intersectsSphere(0, 0, 500, 1)).toBe(false);
+ });
+ });
+});