Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 186 additions & 0 deletions packages/examples/src/examples/camera3d/ExampleCamera3d.tsx
Original file line number Diff line number Diff line change
@@ -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();
});
Comment on lines +96 to +116

// 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);
Comment on lines +122 to +135
};
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);
Comment on lines +177 to +181
}
});
};

export const ExampleCamera3d = createExampleComponent(createGame);
13 changes: 13 additions & 0 deletions packages/examples/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -218,6 +223,14 @@ const examples: {
description:
"Visual comparison of all supported blend modes (normal, multiply, screen, overlay, darken, lighten, etc.).",
},
{
component: <ExampleCamera3d />,
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: <ExampleClipping />,
label: "Clipping",
Expand Down
5 changes: 5 additions & 0 deletions packages/melonjs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,18 @@
**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.

### Changed
- **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.
Expand Down
18 changes: 18 additions & 0 deletions packages/melonjs/src/application/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
} & (
| {
/**
Expand Down
38 changes: 35 additions & 3 deletions packages/melonjs/src/camera/camera2d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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);
}

Expand Down
Loading
Loading