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
541 changes: 541 additions & 0 deletions packages/examples/src/examples/afterBurner/ExampleAfterBurner.tsx

Large diffs are not rendered by default.

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);
26 changes: 26 additions & 0 deletions packages/examples/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,16 @@ const ExampleBlendModes = lazy(() =>
default: m.ExampleBlendModes,
})),
);
const ExampleAfterBurner = lazy(() =>
import("./examples/afterBurner/ExampleAfterBurner").then((m) => ({
default: m.ExampleAfterBurner,
})),
);
const ExampleCamera3d = lazy(() =>
import("./examples/camera3d/ExampleCamera3d").then((m) => ({
default: m.ExampleCamera3d,
})),
);
const ExampleClipping = lazy(() =>
import("./examples/clipping/ExampleClipping").then((m) => ({
default: m.ExampleClipping,
Expand Down Expand Up @@ -223,6 +233,22 @@ const examples: {
description:
"Visual comparison of all supported blend modes (normal, multiply, screen, overlay, darken, lighten, etc.).",
},
{
component: <ExampleAfterBurner />,
label: "AfterBurner",
path: "after-burner",
sourceDir: "afterBurner",
description:
"Behind-the-plane arcade shooter using Camera3d perspective — arrows / WASD to fly, space to shoot.",
},
{
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.",
},
Comment on lines +236 to +251
{
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,6 +5,9 @@
**Highlights:** foundational vertex-pipeline work toward the 19.7 Camera3d release. Every batched-rendering shader (`Quad`, `LitQuad`, `Primitive`, GPU TMX) now carries per-sprite depth as a true `vec3` vertex attribute — unblocking perspective projection for sprites, enabling depth-aware custom `ShaderEffect`s, and aligning the entire batched pipeline with the WebGPU vertex model. Backward compatible: existing custom `GLShader` / `ShaderEffect` code keeps working unchanged.

### Added
- **`Camera3d` — perspective camera that extends `Camera2d`** for true 3D-projected sprite rendering. Adds `fov`, `aspect`, `pitch`, `yaw`, `followOffset`, `lookAhead` on top of all the Camera2d state (shake, fade, color matrix, follow, post-effects, lighting overlay), so it slots into `Stage.cameras` as a drop-in replacement. Conventions match the rest of the engine: **Y-down + +Z forward** (sprite at higher `pos.y` appears lower on screen, sprite at higher `pos.z` is farther from the camera and renders smaller). Three opt-in paths: (a) `new Application(w, h, { cameraClass: Camera3d })` for app-wide default, (b) per-stage `super({ cameraClass: Camera3d })`, (c) per-instance `super({ cameras: [new Camera3d(0, 0, w, h, { fov, near, far })] })` for custom opts. `DefaultLoadingScreen` explicitly pins to `Camera2d` so the loader stays 2D regardless of app-level setting. Known limitations: `Light2d` and `Mesh` use 2D coordinates and won't track perspective correctly under Camera3d (use sprite billboards for 3D-projected gameplay; Light3d is future work).
- **`Frustum` class** — encapsulates `fov` / `aspect` / `near` / `far` + the matching perspective `projectionMatrix`. Used internally by `Camera3d` as its projection source-of-truth. `frustum.set(fov, aspect, near, far)` atomically updates all four params and rebuilds the matrix in one call. Y-down + +Z forward convention baked into the matrix via a post-multiplied `scale(1, -1, -1)` so the standard `gl-matrix`-style perspective math gives results aligned with melonJS's screen conventions.
- **`ApplicationSettings.cameraClass`** and **`StageSettings.cameraClass`** — new optional settings to declare which `Camera2d` subclass to instantiate as the default camera. Application-level setting applies to every stage that doesn't override; per-stage setting overrides the app-level. Defaults to `Camera2d`, preserving every existing app's behavior unchanged.
- **`renderer.setDepth(depth)`** — new public method on the base `Renderer`. Mirrors the existing `setTint` / `setColor` state-setter pattern. Sets `renderer.currentDepth`, which the batchers read at vertex-emit time and push as the z component of each vertex. `Renderable.preDraw` now forwards `this.depth` automatically, so user code typically never needs to call `setDepth` directly.
- Per-sprite depth on the GPU: all batched draw paths (`QuadBatcher`, `LitQuadBatcher`, `PrimitiveBatcher`, GPU TMX) now carry the renderable's `.depth` as the z component of their vertex stream. Default shaders consume it via `vec4(aVertex, 1.0)` in `gl_Position`. With an orthographic projection (the engine default), z has no visible effect — existing 2D apps render identically. With a perspective projection, sprites at different depths are correctly scaled and parallaxed by the projection matrix.
- **Multi-material OBJ rendering** — `Mesh` now correctly draws OBJ files with multiple `usemtl` directives + a bound MTL, instead of collapsing the whole model to a single uniform color. The OBJ parser emits `groups: Array<{materialName, start, count}>` matching the Three.js / glTF convention. The Mesh constructor bakes each material's `Kd` into a per-vertex color buffer at construction time, so multi-material meshes need **no extra draw calls per material** vs single-material rendering — large meshes still chunk across multiple draws to respect the WebGL batcher's vertex/index buffer limits (same behavior as today's single-material path), but adding more materials to a mesh doesn't multiply that count. `mesh.tint` multiplies on top at render time — flash / fade / team-color via `setTint` work exactly like single-material meshes. **Scope:** per-material diffuse colors (`Kd`) are fully supported on both WebGL and Canvas; per-material textures (each material with its own `map_Kd`) are NOT supported in this pipeline — the whole mesh shares a single texture binding (the first group's `map_Kd` if any, else a 1×1 white pixel for Kd-only models). Single-material meshes are unchanged — same code path, same allocations, same behavior. New `Multi-material OBJ` example showcases the feature with four Kenney Space Kit spacecraft, each given a distinct team color via `mesh.tint`.
Expand All @@ -13,6 +16,8 @@
- **Vertex attribute layout: `aVertex` widened from `vec2` to `vec3`** across `quad-multi.vert`, `quad-multi-lit.vert`, `primitive.vert`, `orthogonal-tmxlayer.vert`. `Mesh`'s shader already used `vec3 aVertex` — all batchers are now uniform. Per-vertex stride grows by 4 bytes (24 → 28 for the quad layout). Custom shaders binding attributes by name (`gl.getAttribLocation`) — the standard pattern, and what `GLShader` enforces — keep working unchanged; the byte-offset shift of `aRegion` / `aColor` / `aTextureId` is transparent because the batcher updates its own `vertexAttribPointer` offsets. Custom shaders declaring `attribute vec2 aVertex;` continue to work — WebGL silently drops the unused z component.
- **`VertexArrayBuffer.push()` signature gained a `z` parameter** between `y` and `u`: `push(x, y, z, u, v, tint, textureId?, normalTextureId?)`. `VertexArrayBuffer` is an internal class (not exported), but **subclasses of `QuadBatcher` / `PrimitiveBatcher` that reimplement `addQuad` / `drawVertices` from scratch** (not via `super.addQuad()`) and push to `vertexData` directly need to update their push call to the 7-arg form (insert `z` after `y`, default `0` works under ortho). Subclasses that delegate to `super.addQuad()` are unaffected.
- **`Camera2d` default near/far widened from `±1000` to `±1e6`.** With `aVertex.z` now participating in clip-space, the previous defaults would silently clip-cull any sprite with `depth` outside that range — common pitfalls being `Container.autoDepth = true` (default, assigns `pos.z = childCount`) with >1000 children, and Y-sort patterns (`sprite.depth = sprite.pos.y`) on tall maps. The new range covers every realistic 2D depth value while staying well within float32 precision. Override per-camera (`camera.near = -100; camera.far = 100`) for tighter z clipping when needed (e.g. perspective mode in Camera3d, PR B).
- **`Camera2d.draw` factored into two new internal hook points** — `_applyContainerViewTransform(container, tx, ty)` and `_revertContainerViewTransform(container, tx, ty)` — so `Camera3d` can apply its full view rotation (pitch / yaw) on top of the existing translate without duplicating the rest of `Camera2d.draw`'s ~140-line body. Camera2d's default implementation matches the previous behavior bit-for-bit (just `container.translate(-tx, -ty)`); no user-visible change.
- **`DefaultLoadingScreen` now constructs with explicit `cameraClass: Camera2d`** so the built-in loader screen stays 2D even when the host app sets `cameraClass: Camera3d` globally. Invisible behavior change for users (the loader was already Camera2d via the singleton path).

### Fixed
- None.
Expand Down
18 changes: 16 additions & 2 deletions packages/melonjs/src/application/application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@
if (physic === "none") {
return { adapter: undefined, physicLabel: "none" };
}
if (physic === undefined || physic === "builtin") {

Check warning on line 73 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Unnecessary conditional, the types have no overlap

Check warning on line 73 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Unnecessary conditional, the types have no overlap

Check warning on line 73 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / test

Unnecessary conditional, the types have no overlap
return { adapter: undefined, physicLabel: "builtin" };
}
// instance or { adapter } object — extract and pass through. The
Expand All @@ -80,7 +80,7 @@
// predating the `physicLabel` field.
const adapter =
typeof physic === "object" && "adapter" in physic ? physic.adapter : physic;
return { adapter, physicLabel: adapter?.physicLabel ?? "builtin" };

Check warning on line 83 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Unnecessary optional chain on a non-nullish value

Check warning on line 83 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Unnecessary optional chain on a non-nullish value

Check warning on line 83 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / test

Unnecessary optional chain on a non-nullish value
}

/**
Expand Down Expand Up @@ -315,7 +315,7 @@
this.settings = settings;

// identify parent element and/or the html target for resizing
this.parentElement = device.getElement(this.settings.parent!);

Check warning on line 318 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Forbidden non-null assertion

Check warning on line 318 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Forbidden non-null assertion

Check warning on line 318 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / test

Forbidden non-null assertion
if (typeof this.settings.scaleTarget !== "undefined") {
this.settings.scaleTarget = device.getElement(this.settings.scaleTarget);
}
Expand Down Expand Up @@ -441,6 +441,20 @@
this.world.physic = physicLabel;
this.world.gpuTilemap = this.settings.gpuTilemap;

// If a custom cameraClass is specified, seed the world's sort
// mode from the camera's `defaultSortOn` static. Camera2d
// declares `"z"` (today's default — no behavior change for
// existing games); Camera3d declares `"depth"`, which switches
// the world to the camera-distance painter's sort required by
// perspective. User code can still override `world.sortOn` after
// this point; we only set the initial value.
const cameraClass = this.settings.cameraClass as
| { defaultSortOn?: "x" | "y" | "z" | "depth" }
| undefined;
if (cameraClass?.defaultSortOn) {
this.world.sortOn = cameraClass.defaultSortOn;
}

// report the active physics adapter once the world is wired —
// useful confirmation when a third-party adapter (e.g.
// `@melonjs/matter-adapter`) is plugged in via `settings.physic`.
Expand All @@ -450,7 +464,7 @@
if (this.settings.consoleHeader) {
if (this.world.physic === "none") {
console.log("physics: disabled");
} else if (this.world.adapter) {

Check warning on line 467 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Unnecessary conditional, value is always truthy

Check warning on line 467 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Unnecessary conditional, value is always truthy

Check warning on line 467 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / test

Unnecessary conditional, value is always truthy
const a = this.world.adapter as {
constructor: { name: string };
name?: string;
Expand Down Expand Up @@ -515,7 +529,7 @@
// point to the current active stage "default" camera
const current = state.get();
if (typeof current !== "undefined") {
this.viewport = current.cameras.get("default")!;

Check warning on line 532 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Forbidden non-null assertion

Check warning on line 532 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Forbidden non-null assertion

Check warning on line 532 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / test

Forbidden non-null assertion
}

// publish reset notification
Expand All @@ -530,10 +544,10 @@
* Accepted values : "x", "y", "z", "depth"
* @see {@link World.sortOn}
*/
get sortOn(): string {
get sortOn(): "x" | "y" | "z" | "depth" {
return this.world.sortOn;
}
set sortOn(value: string) {
set sortOn(value: "x" | "y" | "z" | "depth") {
this.world.sortOn = value;
}

Expand Down Expand Up @@ -622,21 +636,21 @@
globalThis.removeEventListener("resize", this._onResize);
globalThis.removeEventListener(
"orientationchange",
this._onOrientationChange!,

Check warning on line 639 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Forbidden non-null assertion

Check warning on line 639 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Forbidden non-null assertion

Check warning on line 639 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / test

Forbidden non-null assertion
);
globalThis.removeEventListener("scroll", this._onScroll!);

Check warning on line 641 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Forbidden non-null assertion

Check warning on line 641 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Forbidden non-null assertion

Check warning on line 641 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / test

Forbidden non-null assertion
if (device.screenOrientation) {
globalThis.screen.orientation.onchange = null;
}
}

// destroy the world and all its children
if (this.world) {

Check warning on line 648 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Unnecessary conditional, value is always truthy

Check warning on line 648 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Unnecessary conditional, value is always truthy

Check warning on line 648 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / test

Unnecessary conditional, value is always truthy
this.world.destroy();
}

// remove the canvas from the DOM
if (removeCanvas && this.renderer) {

Check warning on line 653 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Unnecessary conditional, value is always truthy

Check warning on line 653 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Unnecessary conditional, value is always truthy

Check warning on line 653 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / test

Unnecessary conditional, value is always truthy
const canvas = this.renderer.getCanvas();
if (canvas.parentElement) {
canvas.parentElement.removeChild(canvas);
Expand Down Expand Up @@ -755,7 +769,7 @@
// update all objects (and pass the elapsed time since last frame)
this.isDirty = this.world.update(this.updateDelta);
this.isDirty =
state.current()!.update(this.updateDelta) || this.isDirty;

Check warning on line 772 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Forbidden non-null assertion

Check warning on line 772 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / lint

Forbidden non-null assertion

Check warning on line 772 in packages/melonjs/src/application/application.ts

View workflow job for this annotation

GitHub Actions / test

Forbidden non-null assertion

this.lastUpdate = globalThis.performance.now();
this.updateAverageDelta = this.lastUpdate - this.lastUpdateStart;
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
48 changes: 45 additions & 3 deletions packages/melonjs/src/camera/camera2d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,16 @@ const targetV = new Vector2d();
* this.cameras.set("minimap", minimap);
*/
export default class Camera2d extends Renderable {
/**
* Preferred `Container.sortOn` mode for this camera type. Read by
* `Application` (and `Stage`) at bootstrap to initialize the world
* container's sort. Camera2d uses `"z"` (painter's-algorithm 2D
* layering, higher pos.z draws on top); `Camera3d` overrides to
* `"depth"` (camera-distance sort required by perspective).
* Subclasses can override to declare their own preferred mode.
*/
static defaultSortOn: "x" | "y" | "z" | "depth" = "z";

/**
* Axis definition
* NONE no axis
Expand Down Expand Up @@ -900,8 +910,11 @@ export default class Camera2d extends Renderable {
// post-effect: bind FBO if shader effects are set (WebGL only)
const usePostEffect = r.beginPostEffect(this);

// translate the world coordinates by default to screen coordinates
container.translate(-translateX, -translateY);
// apply the world-to-camera-view transform on the container.
// Camera2d translates by -camera.pos; Camera3d overrides this
// hook to additionally apply the camera's rotation (pitch / yaw)
// in the correct order (R⁻¹ ∘ T(-pos), via post-multiplication).
this._applyContainerViewTransform(container, translateX, translateY);

this.preDraw(r);

Expand Down Expand Up @@ -1007,7 +1020,36 @@ export default class Camera2d extends Renderable {
}
}

// translate the world coordinates by default to screen coordinates
// revert the world-to-camera-view transform applied above
this._revertContainerViewTransform(container, translateX, translateY);
}

/**
* Apply the world-to-camera-view transform to the container before
* its draw walk. Camera2d translates by `-camera.pos` (plus shake
* offset); Camera3d overrides this to additionally rotate by
* `-camera.pitch` / `-camera.yaw` in the correct order.
* @ignore
*/
_applyContainerViewTransform(
container: Container,
translateX: number,
translateY: number,
): void {
container.translate(-translateX, -translateY);
}

/**
* Revert the transform applied by {@link Camera2d#_applyContainerViewTransform}.
* Must undo each mutation in reverse order. Subclasses overriding
* `_applyContainerViewTransform` should override this too.
* @ignore
*/
_revertContainerViewTransform(
container: Container,
translateX: number,
translateY: number,
): void {
container.translate(translateX, translateY);
}

Expand Down
Loading