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 dc3dc2fea..50e1fa3ed 100644
--- a/packages/examples/src/main.tsx
+++ b/packages/examples/src/main.tsx
@@ -33,6 +33,11 @@ const ExampleBlendModes = lazy(() =>
default: m.ExampleBlendModes,
})),
);
+const ExampleCamera3d = lazy(() =>
+ import("./examples/camera3d/ExampleCamera3d").then((m) => ({
+ default: m.ExampleCamera3d,
+ })),
+);
const ExampleClipping = lazy(() =>
import("./examples/clipping/ExampleClipping").then((m) => ({
default: m.ExampleClipping,
@@ -218,6 +223,14 @@ const examples: {
description:
"Visual comparison of all supported blend modes (normal, multiply, screen, overlay, darken, lighten, etc.).",
},
+ {
+ 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 dbe7d8b7f..73900d217 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.
@@ -12,6 +15,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/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..a0292c193 100644
--- a/packages/melonjs/src/camera/camera2d.ts
+++ b/packages/melonjs/src/camera/camera2d.ts
@@ -900,8 +900,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 +1010,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..d956408b7
--- /dev/null
+++ b/packages/melonjs/src/camera/camera3d.ts
@@ -0,0 +1,477 @@
+import { Matrix3d } from "../math/matrix3d.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";
+
+// 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.
+ * - `isVisible` (visibility culling) currently returns `true` for
+ * every non-floating renderable — Camera2d's 2D rect-overlap test
+ * doesn't translate to perspective. The GPU still clips fragments
+ * that fall outside the frustum, so this is visually correct but
+ * defeats the CPU-side early-out. Proper plane-based frustum
+ * culling on `Frustum` is a follow-up.
+ * @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 {
+ /**
+ * 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, -this.pos.z);
+ }
+
+ /**
+ * 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, this.pos.z);
+ 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.
+ *
+ * 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 x - target world x
+ * @param y - target world y
+ * @param z - target world z
+ * @returns this camera
+ */
+ lookAt(x: number, y: number, z: number): this {
+ const dx = x - this.pos.x;
+ const dy = y - this.pos.y;
+ const dz = z - this.pos.z;
+
+ // 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.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);
+ }
+ // bounding sphere around the renderable's 2D bounds; the z
+ // component is the renderable's depth (its world-space z).
+ // Sprite billboards face the camera so a sphere bounded by
+ // max(width, height) is the right conservative envelope.
+ 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, -this.pos.z);
+
+ // 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/state/stage.ts b/packages/melonjs/src/state/stage.ts
index 6cff9dfb9..f0bf4b28d 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,14 +145,32 @@ 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);
}
}
diff --git a/packages/melonjs/tests/camera3d.spec.js b/packages/melonjs/tests/camera3d.spec.js
new file mode 100644
index 000000000..107a01c96
--- /dev/null
+++ b/packages/melonjs/tests/camera3d.spec.js
@@ -0,0 +1,525 @@
+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);
+ });
+ });
+});
diff --git a/packages/melonjs/tests/camera3d_integration.spec.js b/packages/melonjs/tests/camera3d_integration.spec.js
new file mode 100644
index 000000000..8478925c6
--- /dev/null
+++ b/packages/melonjs/tests/camera3d_integration.spec.js
@@ -0,0 +1,216 @@
+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();
+ });
+ });
+});
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);
+ });
+ });
+});