From d4043f25ea27e7e92a1b93775776c42501503e10 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sun, 24 May 2026 16:56:57 +0800 Subject: [PATCH 1/7] feat(camera): Camera3d + Frustum + Stage/App cameraClass wiring (19.7.0 PR B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a perspective `Camera3d` extending `Camera2d`, with three opt-in paths and a fresh `Frustum` class encapsulating the projection params. Visually validated end-to-end via Playwright + WebGL2 — a grid of sprite billboards renders with proper perspective scaling, and the camera's orbit rotation moves the view correctly. ## What's added - **`Camera3d`** — perspective camera with `fov`, `aspect`, `pitch`, `yaw`, `followOffset`, `lookAhead`, `viewMatrix`. Drop-in replacement for `Camera2d` (slots into `Stage.cameras`, inherits post-effect FBO bracket, color matrix, fade/shake/follow, lighting overlay). Conventions: Y-down + +Z forward (matches Camera2d so existing 2D coords translate directly to 3D). Methods: `lookAt(x, y, z)` / `setLookAt(target)` (derives pitch+yaw from target direction), `setFollowOffset(x, y, z)`. View transform applied as R⁻¹ ∘ T(-pos) via new overridable hooks on Camera2d (`_applyContainerViewTransform` / `_revertContainerViewTransform`) that Camera2d's default impl uses as drop-in for its previous `container.translate(-tx, -ty)` lines — zero behavior change on the Camera2d path. - **`Frustum`** — `fov` / `aspect` / `near` / `far` + matching `projectionMatrix`. Bakes Y-down + +Z forward via post-multiplied `scale(1, -1, -1)` so standard `gl-matrix`-style `perspective()` output aligns with melonJS conventions. `set(fov, aspect, near, far)` atomically rebuilds the matrix in one call. - **`ApplicationSettings.cameraClass`** + **`StageSettings.cameraClass`** — declare the default camera constructor for any stage that doesn't provide explicit `cameras`. App-level applies to every stage; per-stage overrides the app-level. Defaults to `Camera2d`, preserving every existing app's behavior exactly. ## Resolution order (Stage.reset) 1. explicit `cameras` array on the stage (most-specific wins) 2. `cameraClass` on the stage's settings 3. `cameraClass` on the application's settings 4. fallback to the Camera2d module-level singleton (pre-19.7 path) Steps 2 and 3 produce a fresh instance per stage — Camera3d holds per-stage state (pitch / yaw / fov) that shouldn't bleed across scenes. Step 4 still uses the singleton, so any app that doesn't opt in to `cameraClass` keeps Camera2d singleton-sharing behavior identical to pre-19.7. ## Preloader protection `DefaultLoadingScreen` now constructs with explicit `super({ cameraClass: Camera2d })`. The loader's progress bar would look broken under a perspective camera; pinning it to Camera2d makes the protection immune to whatever the user sets at the app level. The same pattern doubles as user-facing documentation — any custom UI / menu stage that wants to opt out of an app-wide `cameraClass: Camera3d` just does `super({ cameraClass: Camera2d })`. ## Backward compatibility Full. Confirmed by 3604 tests pass + the existing example gallery rendering identically (visually spot-checked on lights, platformer, shaderEffects, mesh3d examples). Specific guarantees: - existing Camera2d code unchanged (default near/far still ±1e6 from PR A, all hooks still 2D-translate-only via the new overridable methods that default to the previous behavior) - `app.viewport` still typed as Camera2d (`Camera3d extends Camera2d`) - all Camera2d follow/shake/fade APIs inherited unchanged - `Light2d` and `Mesh` are 2D-coord-only and won't track perspective correctly under Camera3d (documented limitation; AfterBurner / other Camera3d showcases use sprite billboards which scale naturally via PR A's vec3 vertex stream) ## Tests 3604 pass (was 3558 → +46 new): - `frustum.spec.js` — 11 tests covering defaults, set/update, Y-down + +Z forward conventions, near/far clip-plane mapping, aspect ratio behavior - `camera3d.spec.js` — 25 tests covering constructor defaults, Frustum integration, fov/aspect setters, resize, lookAt math, followOffset, target follow logic, Camera2d API inheritance - `camera3d_integration.spec.js` — 10 tests covering the 4-step Stage.reset resolution order, no-state-bleed across stages, singleton preservation for the Camera2d default path, DefaultLoadingScreen always-Camera2d protection, Application settings propagation, full Application construction smoke ## Example `packages/examples/src/examples/camera3d/` — orbit camera demo with a 5×5 grid of sprite billboards at varying depth. Drag to rotate; auto-rotate when idle. Demonstrates the Application-level `cameraClass: Camera3d` opt-in path. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/examples/camera3d/ExampleCamera3d.tsx | 146 ++++++++ packages/examples/src/main.tsx | 13 + packages/melonjs/CHANGELOG.md | 5 + packages/melonjs/src/application/settings.ts | 18 + packages/melonjs/src/camera/camera2d.ts | 38 +- packages/melonjs/src/camera/camera3d.ts | 349 ++++++++++++++++++ packages/melonjs/src/camera/frustum.ts | 121 ++++++ packages/melonjs/src/index.ts | 4 + packages/melonjs/src/loader/loadingscreen.js | 12 + packages/melonjs/src/state/stage.ts | 47 ++- packages/melonjs/tests/camera3d.spec.js | 249 +++++++++++++ .../tests/camera3d_integration.spec.js | 216 +++++++++++ packages/melonjs/tests/frustum.spec.js | 168 +++++++++ 13 files changed, 1375 insertions(+), 11 deletions(-) create mode 100644 packages/examples/src/examples/camera3d/ExampleCamera3d.tsx create mode 100644 packages/melonjs/src/camera/camera3d.ts create mode 100644 packages/melonjs/src/camera/frustum.ts create mode 100644 packages/melonjs/tests/camera3d.spec.js create mode 100644 packages/melonjs/tests/camera3d_integration.spec.js create mode 100644 packages/melonjs/tests/frustum.spec.js diff --git a/packages/examples/src/examples/camera3d/ExampleCamera3d.tsx b/packages/examples/src/examples/camera3d/ExampleCamera3d.tsx new file mode 100644 index 000000000..ba7df57e3 --- /dev/null +++ b/packages/examples/src/examples/camera3d/ExampleCamera3d.tsx @@ -0,0 +1,146 @@ +/** + * melonJS — Camera3d (perspective + orbit) example. + * + * A grid of sprite billboards floats in 3D space. The Camera3d orbits + * around the center under mouse drag (and auto-rotates when idle). + * Proves the new capabilities end-to-end: + * - Per-sprite depth gets projected through the camera's frustum + * (closer sprites render larger, farther sprites smaller) + * - The camera's pitch / yaw rotates the world view correctly + * + * Demonstrates the simplest opt-in path for Camera3d — the + * `cameraClass: Camera3d` Application setting. Every stage the app + * runs (including the default stage created automatically when none + * is registered) gets a Camera3d as its default camera. + * + * Copyright (C) 2011 - 2026 AltByte Pte Ltd — MIT License. + * See `packages/examples/LICENSE.md` for full license + asset credits. + */ +import { + Application, + type Camera3d, + Camera3d as Camera3dClass, + event, + input, + type Pointer, + Sprite, + video, +} from "melonjs"; +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. DefaultLoadingScreen + // stays Camera2d (hardcoded protection in its constructor). + const app = new Application(1024, 768, { + parent: "screen", + renderer: video.WEBGL, + scale: "auto", + cameraClass: Camera3dClass, + }); + + app.world.backgroundColor.parseCSS("#0a0a14"); + + // build a 64×64 colored tile per grid cell (procedural — no asset preload) + const makeTile = (hue: number) => { + const c = video.createCanvas(64, 64); + const ctx = c.getContext("2d"); + if (ctx) { + ctx.fillStyle = `hsl(${hue}, 70%, 60%)`; + ctx.fillRect(0, 0, 64, 64); + ctx.strokeStyle = "#ffffff"; + ctx.lineWidth = 4; + ctx.strokeRect(2, 2, 60, 60); + } + return c; + }; + + // 5×5 grid of sprite billboards spanning x ∈ [-400, 400] and + // z ∈ [200, 600] (within Camera3d's default near=0.1, far=1000). + // We set sprite.depth AFTER addChild because Container.autoDepth + // (default true) would otherwise overwrite our explicit z. + const GRID = 5; + const SPAN_X = 200; + const SPAN_Z = 100; + const Z_BASE = 200; + for (let row = 0; row < GRID; row++) { + for (let col = 0; col < GRID; col++) { + const x = (col - (GRID - 1) / 2) * SPAN_X; + const y = 0; + const z = Z_BASE + row * SPAN_Z; + const hue = (col / GRID) * 360; + const sprite = new Sprite(x, y, { + framewidth: 64, + frameheight: 64, + image: makeTile(hue), + anchorPoint: { x: 0.5, y: 0.5 }, + }); + app.world.addChild(sprite); + sprite.depth = z; + } + } + + // the app's default camera is now a Camera3d (via cameraClass). + // Type-narrow for the perspective-specific calls. + const camera = app.viewport as Camera3d; + + // orbit state — yaw / pitch / distance. updated by mouse drag, + // applied to the camera each GAME_UPDATE tick. + let yaw = 0; + let pitch = -0.25; // slight downward tilt + const distance = 600; + const centerZ = Z_BASE + ((GRID - 1) * SPAN_Z) / 2; // center of the grid + + const updateCameraPos = () => { + // orbit around (0, 0, centerZ): place the camera on a sphere of + // `distance` around that point, then aim back at it. + camera.pos.set( + Math.sin(yaw) * Math.cos(pitch) * -distance, + Math.sin(pitch) * distance, + centerZ - Math.cos(yaw) * Math.cos(pitch) * distance, + ); + camera.lookAt(0, 0, centerZ); + }; + updateCameraPos(); + + // drag-to-orbit + let dragging = false; + let lastX = 0; + let lastY = 0; + const onDown = (ev: Pointer) => { + dragging = true; + lastX = ev.gameX; + lastY = ev.gameY; + }; + const onUp = () => { + dragging = false; + }; + const onMove = (ev: Pointer) => { + if (!dragging) { + return; + } + const dx = ev.gameX - lastX; + const dy = ev.gameY - lastY; + lastX = ev.gameX; + lastY = ev.gameY; + yaw += dx * 0.005; + pitch = Math.max( + -Math.PI / 2 + 0.1, + Math.min(Math.PI / 2 - 0.1, pitch - dy * 0.005), + ); + updateCameraPos(); + }; + input.registerPointerEvent("pointerdown", camera, onDown); + input.registerPointerEvent("pointerup", camera, onUp); + input.registerPointerEvent("pointermove", camera, onMove); + + // gentle auto-rotate while no input + event.on(event.GAME_UPDATE, (dt: number) => { + if (!dragging) { + yaw += dt * 0.0003; + updateCameraPos(); + } + }); +}; + +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..be44cfd08 --- /dev/null +++ b/packages/melonjs/src/camera/camera3d.ts @@ -0,0 +1,349 @@ +import { Vector3d } from "../math/vector3d.ts"; +import type Container from "./../renderable/container.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); + +/** + * 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 offsets are target-local.** When a target is set, + * `followOffset` is applied in the target's local frame + * (Cinemachine / Unreal spring-arm / Babylon FollowCamera + * convention). The camera world position becomes + * `target.pos + target.rotation * followOffset`. + * + * Known limitations (PR B scope): + * - `Light2d` is 2D-only — visible artifacts under perspective. + * Avoid combining with Camera3d for now. + * - `Mesh` (3D models) maintains its own self-contained projection; + * meshes will render incorrectly under Camera3d unless their + * `projectionMatrix` is manually synced to the camera's. AfterBurner + * and similar showcases use sprite billboards instead (which scale + * automatically under perspective). + * - `localToWorld` / `worldToLocal` overrides fall back to the + * ortho-equivalent 2D projection at z=0. Full 3D unproject for + * arbitrary depth is future work. + * @category Camera + * @example + * // opt in app-wide: + * const app = new Application(1024, 768, { + * parent: "screen", + * cameraClass: Camera3d, + * }); + * + * // or per-stage with custom fov: + * class GameStage extends Stage { + * constructor() { + * super({ + * cameras: [new Camera3d(0, 0, 1024, 768, { fov: Math.PI / 3 })], + * }); + * } + * } + */ +export default class Camera3d extends Camera2d { + /** + * 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; + + /** + * Target-local point the camera looks at when following. Combined + * with the followed target's position to compute the look direction. + * @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 { + // rotations first (pitch then yaw — order matters for the + // inverse to be RPY⁻¹ = Y⁻¹P⁻¹R⁻¹; here we go with Y⁻¹P⁻¹ + // = yaw then pitch with negated angles, applied via post-mult + // in reverse) + if (this.pitch !== 0) { + container.rotate(-this.pitch, AXIS_X); + } + if (this.yaw !== 0) { + container.rotate(-this.yaw, AXIS_Y); + } + // then translate by -camera.pos (include z) + 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 the + * target-local `followOffset` against the target. When `target` is + * set, the camera's world position becomes + * `target.pos + followOffset`. + * + * (PR B scope: treats `followOffset` as world-space. Target-rotation + * application — so the offset rotates with the followed object — + * is deferred until target orientation tracking is needed.) + * @param dt - delta time in milliseconds + * @ignore + */ + override updateTarget(dt?: number): void { + if (this.target) { + // move camera to target.pos + followOffset (world-space for + // now; target-rotation-aware variant lands when AfterBurner + // needs it for the banking jet) + this.pos.set( + this.target.x + this.followOffset.x, + this.target.y + this.followOffset.y, + (this.target instanceof Vector3d ? this.target.z : 0) + + this.followOffset.z, + ); + this.isDirty = true; + return; + } + // no target — fall through to Camera2d's behavior (no-op when + // target is null) + super.updateTarget(dt); + } +} diff --git a/packages/melonjs/src/camera/frustum.ts b/packages/melonjs/src/camera/frustum.ts new file mode 100644 index 000000000..cde9d63c7 --- /dev/null +++ b/packages/melonjs/src/camera/frustum.ts @@ -0,0 +1,121 @@ +import { Matrix3d } from "../math/matrix3d.ts"; + +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; + + /** + * @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(); + 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); + } +} 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..773c3321c --- /dev/null +++ b/packages/melonjs/tests/camera3d.spec.js @@ -0,0 +1,249 @@ +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("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("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..320aa69b5 --- /dev/null +++ b/packages/melonjs/tests/frustum.spec.js @@ -0,0 +1,168 @@ +import { describe, expect, it } from "vitest"; +import { Frustum, Matrix3d } 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(Matrix3d); + }); + + 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], + ); + }); + }); +}); From 31f3b0b735f838ebbdbc463bff1a9f60130e49bb Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sun, 24 May 2026 17:29:54 +0800 Subject: [PATCH 2/7] fix(examples): camera3d auto-rotate using absolute time as delta (PR B follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `event.GAME_UPDATE` emits the absolute `performance.now()` timestamp, not a frame delta — the example was multiplying that timestamp by the rotation rate, producing values in the thousands of radians per frame after a few seconds. Result: the demo span at increasingly absurd speed instead of the intended gentle auto-rotate. Fix: track the previous timestamp and compute the per-frame delta locally. Constant rotation rate regardless of session duration. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../examples/src/examples/camera3d/ExampleCamera3d.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/examples/src/examples/camera3d/ExampleCamera3d.tsx b/packages/examples/src/examples/camera3d/ExampleCamera3d.tsx index ba7df57e3..96e608522 100644 --- a/packages/examples/src/examples/camera3d/ExampleCamera3d.tsx +++ b/packages/examples/src/examples/camera3d/ExampleCamera3d.tsx @@ -134,8 +134,14 @@ const createGame = () => { input.registerPointerEvent("pointerup", camera, onUp); input.registerPointerEvent("pointermove", camera, onMove); - // gentle auto-rotate while no input - event.on(event.GAME_UPDATE, (dt: number) => { + // gentle auto-rotate while no input. `GAME_UPDATE` emits the + // absolute `performance.now()` timestamp — derive a frame delta + // ourselves so the rotation rate stays constant regardless of + // session duration. + let lastTime = 0; + event.on(event.GAME_UPDATE, (time: number) => { + const dt = lastTime > 0 ? time - lastTime : 0; + lastTime = time; if (!dragging) { yaw += dt * 0.0003; updateCameraPos(); From b63fd59d20119083e3d1344d056cc063c6fc8f94 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sun, 24 May 2026 17:51:38 +0800 Subject: [PATCH 3/7] fix(camera3d): Copilot PR #1464 review fixes + minimal example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses all six review comments from the Copilot review on PR #1464. Real bugs: - `updateTarget` used `target instanceof Vector3d` which missed `ObservableVector3d` (the type `Renderable.pos` actually is). Result: following a Renderable with non-zero `pos.z` silently treated z as 0. Fixed with duck-typed `"z" in target && typeof target.z === "number"`. - Added regression test in `camera3d.spec.js` with a Renderable target at `pos.z = 500` that asserts `camera.pos.z === 500 + offset.z`. - `updateTarget` semantic change (bypasses `follow_axis` / `deadzone` / `damping` from Camera2d.follow) now explicitly documented in JSDoc with rationale and migration path for users who need damped follow. Docs cleanup: - Class-level "target-local follow with target rotation" wording reconciled with the property-level "world-space for PR B" wording. Both now state world-space; deferred target-rotation-aware variant to a future showcase (AfterBurner's banking jet) that actually needs it. - `lookAhead` field clearly labeled as "reserved for future use" so it isn't a misleading public API surface. - Rotation-order comment rewritten with explicit step-by-step matrix algebra so the post-multiplication semantics are obvious. Camera3d example minimal rewrite: - Reduced from a 4×4 sprite grid + 192-tile pseudo-floor to the bare minimum that proves all three Camera3d capabilities: 1. perspective scaling (3 monsters at z=200/400/600 render in three distinct sizes — biggest in front, smallest in back) 2. z-sort occlusion (the front monster correctly draws on top of the others via painter's algorithm) 3. rotation works (yaw / pitch / zoom controls visibly change which monster is in front) - Replaced the auto-rotate (which was buggy — it used the absolute `performance.now()` timestamp as a frame delta and spun the camera at increasing speed) with explicit on-screen control buttons. - Removed procedural floor tiles which under billboard-sprite rendering looked like a wall, not a floor — confusing rather than helpful for a Camera3d demo. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/examples/camera3d/ExampleCamera3d.tsx | 249 ++++++++++-------- packages/melonjs/src/camera/camera3d.ts | 87 ++++-- packages/melonjs/tests/camera3d.spec.js | 20 ++ 3 files changed, 219 insertions(+), 137 deletions(-) diff --git a/packages/examples/src/examples/camera3d/ExampleCamera3d.tsx b/packages/examples/src/examples/camera3d/ExampleCamera3d.tsx index 96e608522..7ad8aead7 100644 --- a/packages/examples/src/examples/camera3d/ExampleCamera3d.tsx +++ b/packages/examples/src/examples/camera3d/ExampleCamera3d.tsx @@ -1,17 +1,19 @@ /** - * melonJS — Camera3d (perspective + orbit) example. + * melonJS — Camera3d (perspective) minimal example. * - * A grid of sprite billboards floats in 3D space. The Camera3d orbits - * around the center under mouse drag (and auto-rotates when idle). - * Proves the new capabilities end-to-end: - * - Per-sprite depth gets projected through the camera's frustum - * (closer sprites render larger, farther sprites smaller) - * - The camera's pitch / yaw rotates the world view correctly + * 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) * - * Demonstrates the simplest opt-in path for Camera3d — the - * `cameraClass: Camera3d` Application setting. Every stage the app - * runs (including the default stage created automatically when none - * is registered) gets a Camera3d as its default camera. + * 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. @@ -20,18 +22,20 @@ import { Application, type Camera3d, Camera3d as Camera3dClass, - event, input, + loader, type Pointer, 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. DefaultLoadingScreen - // stays Camera2d (hardcoded protection in its constructor). + // 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, @@ -41,110 +45,137 @@ const createGame = () => { app.world.backgroundColor.parseCSS("#0a0a14"); - // build a 64×64 colored tile per grid cell (procedural — no asset preload) - const makeTile = (hue: number) => { - const c = video.createCanvas(64, 64); - const ctx = c.getContext("2d"); - if (ctx) { - ctx.fillStyle = `hsl(${hue}, 70%, 60%)`; - ctx.fillRect(0, 0, 64, 64); - ctx.strokeStyle = "#ffffff"; - ctx.lineWidth = 4; - ctx.strokeRect(2, 2, 60, 60); - } - return c; - }; + 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); - // 5×5 grid of sprite billboards spanning x ∈ [-400, 400] and - // z ∈ [200, 600] (within Camera3d's default near=0.1, far=1000). - // We set sprite.depth AFTER addChild because Container.autoDepth - // (default true) would otherwise overwrite our explicit z. - const GRID = 5; - const SPAN_X = 200; - const SPAN_Z = 100; - const Z_BASE = 200; - for (let row = 0; row < GRID; row++) { - for (let col = 0; col < GRID; col++) { - const x = (col - (GRID - 1) / 2) * SPAN_X; - const y = 0; - const z = Z_BASE + row * SPAN_Z; - const hue = (col / GRID) * 360; - const sprite = new Sprite(x, y, { - framewidth: 64, - frameheight: 64, - image: makeTile(hue), - anchorPoint: { x: 0.5, y: 0.5 }, - }); + // 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). - // Type-narrow for the perspective-specific calls. - const camera = app.viewport as Camera3d; - // orbit state — yaw / pitch / distance. updated by mouse drag, - // applied to the camera each GAME_UPDATE tick. - let yaw = 0; - let pitch = -0.25; // slight downward tilt - const distance = 600; - const centerZ = Z_BASE + ((GRID - 1) * SPAN_Z) / 2; // center of the grid + // the app's default camera is now a Camera3d (via cameraClass). + const camera = app.viewport as Camera3d; - const updateCameraPos = () => { - // orbit around (0, 0, centerZ): place the camera on a sphere of - // `distance` around that point, then aim back at it. - camera.pos.set( - Math.sin(yaw) * Math.cos(pitch) * -distance, - Math.sin(pitch) * distance, - centerZ - Math.cos(yaw) * Math.cos(pitch) * distance, - ); - camera.lookAt(0, 0, centerZ); - }; - updateCameraPos(); + // orbit state: yaw / pitch / distance. Driven by drag + buttons. + let yaw = 0; + let pitch = 0; + let distance = 700; - // drag-to-orbit - let dragging = false; - let lastX = 0; - let lastY = 0; - const onDown = (ev: Pointer) => { - dragging = true; - lastX = ev.gameX; - lastY = ev.gameY; - }; - const onUp = () => { - dragging = false; - }; - const onMove = (ev: Pointer) => { - if (!dragging) { - return; - } - const dx = ev.gameX - lastX; - const dy = ev.gameY - lastY; - lastX = ev.gameX; - lastY = ev.gameY; - yaw += dx * 0.005; - pitch = Math.max( - -Math.PI / 2 + 0.1, - Math.min(Math.PI / 2 - 0.1, pitch - dy * 0.005), - ); + 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(); - }; - input.registerPointerEvent("pointerdown", camera, onDown); - input.registerPointerEvent("pointerup", camera, onUp); - input.registerPointerEvent("pointermove", camera, onMove); - // gentle auto-rotate while no input. `GAME_UPDATE` emits the - // absolute `performance.now()` timestamp — derive a frame delta - // ourselves so the rotation rate stays constant regardless of - // session duration. - let lastTime = 0; - event.on(event.GAME_UPDATE, (time: number) => { - const dt = lastTime > 0 ? time - lastTime : 0; - lastTime = time; - if (!dragging) { - yaw += dt * 0.0003; + // 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); } }); }; diff --git a/packages/melonjs/src/camera/camera3d.ts b/packages/melonjs/src/camera/camera3d.ts index be44cfd08..744dac181 100644 --- a/packages/melonjs/src/camera/camera3d.ts +++ b/packages/melonjs/src/camera/camera3d.ts @@ -24,11 +24,12 @@ const AXIS_Y = new Vector3d(0, 1, 0); * `yaw` (Y axis, look left/right), `roll` (Z axis, screen-plane * bank — also exposed as `Camera2d.rotation` via Renderable * inheritance for backward compatibility). - * - **Follow offsets are target-local.** When a target is set, - * `followOffset` is applied in the target's local frame - * (Cinemachine / Unreal spring-arm / Babylon FollowCamera - * convention). The camera world position becomes - * `target.pos + target.rotation * followOffset`. + * - **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. @@ -96,8 +97,12 @@ export default class Camera3d extends Camera2d { followOffset: Vector3d; /** - * Target-local point the camera looks at when following. Combined - * with the followed target's position to compute the look direction. + * 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; @@ -227,17 +232,29 @@ export default class Camera3d extends Camera2d { translateX: number, translateY: number, ): void { - // rotations first (pitch then yaw — order matters for the - // inverse to be RPY⁻¹ = Y⁻¹P⁻¹R⁻¹; here we go with Y⁻¹P⁻¹ - // = yaw then pitch with negated angles, applied via post-mult - // in reverse) + // 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); } - // then translate by -camera.pos (include z) container.translate(-translateX, -translateY, -this.pos.z); } @@ -317,27 +334,41 @@ export default class Camera3d extends Camera2d { } /** - * Override Camera2d's 2D follow logic to additionally resolve the - * target-local `followOffset` against the target. When `target` is - * set, the camera's world position becomes - * `target.pos + followOffset`. + * 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`. * - * (PR B scope: treats `followOffset` as world-space. Target-rotation - * application — so the offset rotates with the followed object — - * is deferred until target orientation tracking is needed.) - * @param dt - delta time in milliseconds + * **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 { - if (this.target) { - // move camera to target.pos + followOffset (world-space for - // now; target-rotation-aware variant lands when AfterBurner - // needs it for the banking jet) + 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( - this.target.x + this.followOffset.x, - this.target.y + this.followOffset.y, - (this.target instanceof Vector3d ? this.target.z : 0) + - this.followOffset.z, + target.x + this.followOffset.x, + target.y + this.followOffset.y, + targetZ + this.followOffset.z, ); this.isDirty = true; return; diff --git a/packages/melonjs/tests/camera3d.spec.js b/packages/melonjs/tests/camera3d.spec.js index 773c3321c..86e5d7b98 100644 --- a/packages/melonjs/tests/camera3d.spec.js +++ b/packages/melonjs/tests/camera3d.spec.js @@ -200,6 +200,26 @@ describe("Camera3d", () => { 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); From 9a8e18b545e5014c03966ca7287ce4d2b83bcfc8 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sun, 24 May 2026 17:54:49 +0800 Subject: [PATCH 4/7] =?UTF-8?q?fix(camera3d):=20override=20isVisible=20?= =?UTF-8?q?=E2=80=94=20Camera2d's=202D=20rect=20test=20was=20culling=20spr?= =?UTF-8?q?ites=20mid-orbit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-reported regression on PR #1464: sprites disappear when the camera rotates. Root cause is that Camera3d inherited Camera2d's `isVisible(obj)`, which tests a 2D bounds-rect overlap against `this.worldView` — a rect at the camera's pos.x/y + width/height. Under perspective, the visible region is a frustum that widens with distance and rotates with the camera's yaw/pitch. A sprite at world (2000, 2000, 500) might be perfectly visible through the frustum yet fall outside the 2D `worldView` rect, getting silently culled by Container.draw's early-out. Camera3d now overrides `isVisible` to conservatively return `true` for every non-floating renderable — the GPU still clips fragments that fall outside the frustum (so the result is visually correct), the cost is no CPU-side early-out (every world child runs through `draw`, even ones the GPU will throw away). Floating elements (HUD / UI) continue to use Camera2d's 2D rect check via `super.isVisible(...)` — their bounds are screen-space and don't need perspective consideration. Real plane-based frustum culling on `Frustum` (computing the 6 planes from `viewMatrix × projectionMatrix` then testing renderable bounds against them) is a follow-up — proper culling lands when AfterBurner or another showcase pushes hundreds of sprites and the no-cull cost matters. Adds: - regression test verifying sprite at (2000, 2000, 500) with the camera rotated 45° yaw is reported visible (Camera2d's check would silently return false) - regression test verifying floating elements still delegate to the inherited 2D rect check (in/out of viewport) - updated Camera3d JSDoc known-limitations section documenting the conservative culling and the planned follow-up Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/melonjs/src/camera/camera3d.ts | 40 +++++++++++++++++++++++++ packages/melonjs/tests/camera3d.spec.js | 35 ++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/packages/melonjs/src/camera/camera3d.ts b/packages/melonjs/src/camera/camera3d.ts index 744dac181..56eb03357 100644 --- a/packages/melonjs/src/camera/camera3d.ts +++ b/packages/melonjs/src/camera/camera3d.ts @@ -1,5 +1,6 @@ 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"; @@ -31,6 +32,7 @@ const AXIS_Y = new Vector3d(0, 1, 0); * 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. @@ -42,6 +44,12 @@ const AXIS_Y = new Vector3d(0, 1, 0); * - `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: @@ -377,4 +385,36 @@ export default class Camera3d extends Camera2d { // target is null) super.updateTarget(dt); } + + /** + * Visibility check used by `Container.draw` to skip rendering + * off-screen children. Camera2d's implementation tests the + * renderable's 2D bounds rectangle against `this.worldView` (a + * flat camera-aligned rect) — that test is invalid under + * perspective: rotating the camera changes which world coordinates + * map to the visible frustum, and a sprite at world (0, 0, 200) + * might fall outside `worldView` (a rect at the camera's x/y + * position) but still be perfectly visible in the projected view. + * + * Until plane-based frustum culling lands on {@link Frustum}, the + * Camera3d override conservatively returns `true` for every + * non-floating renderable — the GPU still clips out fragments + * outside the frustum, so the result is visually correct. The + * cost is no CPU-side early-out (every world child runs through + * `draw`, even ones the GPU will throw away). Floating elements + * (HUD / UI) still use Camera2d's 2D rect check — their bounds + * are screen-space and don't need perspective consideration. + * @param obj - the renderable to test + * @param [floating] - if visibility should be tested against screen coords + * @returns true if the renderable should be drawn + */ + override isVisible( + obj: Renderable, + floating: boolean = obj.floating, + ): boolean { + if (floating || obj.floating) { + return super.isVisible(obj, floating); + } + return true; + } } diff --git a/packages/melonjs/tests/camera3d.spec.js b/packages/melonjs/tests/camera3d.spec.js index 86e5d7b98..dd643e07c 100644 --- a/packages/melonjs/tests/camera3d.spec.js +++ b/packages/melonjs/tests/camera3d.spec.js @@ -234,6 +234,41 @@ describe("Camera3d", () => { }); }); + describe("isVisible (frustum-aware culling)", () => { + it("returns true for non-floating sprites far outside Camera2d's worldView (regression)", () => { + // Camera2d's `isVisible` tests a 2D rect overlap against + // `worldView`. When Camera3d rotates / orbits, world + // coordinates that should be visible through the frustum + // can fall outside that 2D rect (which is locked to the + // camera's pos.x/y + width/height). Camera3d must NOT + // inherit that test verbatim — it would silently cull + // sprites mid-orbit. + const cam = new Camera3d(0, 0, 800, 600); + cam.pos.set(0, 0, -500); // camera behind the world + cam.yaw = Math.PI / 4; // looking 45° to the right + + const sprite = new Renderable(2000, 2000, 32, 32); + sprite.pos.z = 500; + // world (2000, 2000) is far outside the camera's 2D worldView + // (which is at camera.pos.x/y = 0,0 + width/height = 800,600). + // Camera3d must still return true — the GPU clips off-frustum + // fragments; visibility culling on the CPU is conservative. + expect(cam.isVisible(sprite)).toBe(true); + }); + + it("delegates to Camera2d's 2D rect test for floating elements", () => { + // floating = screen-space, no perspective involved + const cam = new Camera3d(0, 0, 800, 600); + 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); + }); + }); + describe("backward compat with Camera2d API", () => { it("near/far inherited and overridden by perspective defaults", () => { const cam = new Camera3d(0, 0, 800, 600); From c6538b49661a55d26109cebc05d7ab2697b298b9 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sun, 24 May 2026 18:01:11 +0800 Subject: [PATCH 5/7] feat(camera3d): real frustum culling instead of "return true" stub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User feedback on PR #1464: the previous `isVisible` override returned `true` unconditionally, defeating the whole point of having a Frustum. Replaced with proper Gribb–Hartmann plane-based culling. Frustum additions: - `planes: Plane[]` — six bounding planes (left / right / bottom / top / near / far) in world space. Each plane's normal points inward; positive signed distance = visible side. - `setFromViewProjection(viewProjection: Matrix3d)` — rebuilds the six planes from a `view × projection` matrix via standard Gribb–Hartmann extraction. Planes are unit-normalized so `intersectsSphere`'s `distance < -radius` check is in world units. - `intersectsSphere(x, y, z, radius)` — conservative sphere-vs-frustum test. Returns true if the sphere is at least partially inside. - `containsPoint(x, y, z)` — point-vs-frustum test. Camera3d wiring: - `update(dt)` overrides Camera2d's update to additionally call `_rebuildFrustumPlanes()` each frame. The planes are then valid for the frame's `isVisible` calls (which Container.update fires per child before the draw walk). - `_rebuildFrustumPlanes()` builds the view matrix the same way `_applyContainerViewTransform` builds it on the container (rotate pitch + yaw, then translate by -pos), multiplies by the frustum's projection matrix, and feeds the result to `setFromViewProjection`. - `isVisible(obj)` now wraps the renderable's 2D bounds in a bounding sphere (radius = max(width, height) / 2, center = bounds centerX/Y + obj.depth) and tests against the cached frustum planes. - Floating elements still delegate to Camera2d's screen-space rect check via `super.isVisible(...)`. - Two scratch matrices (`_viewMatrix`, `_viewProjection`) live at module scope to avoid per-frame allocation in the hot path. Tests (+13): - 8 new in `frustum.spec.js` covering plane extraction, unit normalization, point-in-frustum classification (front / behind / outside FOV / past far), sphere-vs-frustum classification (inside / clipping near / behind / past far). - 5 new in `camera3d.spec.js` covering the full isVisible-via-frustum pipeline: sprite in front (visible), behind near (culled), past far (culled), outside horizontal FOV (culled), and rotation bringing a previously-behind sprite into view. 3620 pass (was 3607 + 13). 0 lint errors. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/melonjs/src/camera/camera3d.ts | 95 ++++++++++++--- packages/melonjs/src/camera/frustum.ts | 154 ++++++++++++++++++++++++ packages/melonjs/tests/camera3d.spec.js | 77 +++++++++--- packages/melonjs/tests/frustum.spec.js | 77 +++++++++++- 4 files changed, 362 insertions(+), 41 deletions(-) diff --git a/packages/melonjs/src/camera/camera3d.ts b/packages/melonjs/src/camera/camera3d.ts index 56eb03357..d956408b7 100644 --- a/packages/melonjs/src/camera/camera3d.ts +++ b/packages/melonjs/src/camera/camera3d.ts @@ -1,3 +1,4 @@ +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"; @@ -9,6 +10,12 @@ import Frustum, { type FrustumOptions } from "./frustum.ts"; 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 @@ -387,26 +394,22 @@ export default class Camera3d extends Camera2d { } /** - * Visibility check used by `Container.draw` to skip rendering - * off-screen children. Camera2d's implementation tests the - * renderable's 2D bounds rectangle against `this.worldView` (a - * flat camera-aligned rect) — that test is invalid under - * perspective: rotating the camera changes which world coordinates - * map to the visible frustum, and a sprite at world (0, 0, 200) - * might fall outside `worldView` (a rect at the camera's x/y - * position) but still be perfectly visible in the projected view. + * Visibility check used by `Container.update` (in turn driving + * `Container.draw`) to skip rendering off-screen children. * - * Until plane-based frustum culling lands on {@link Frustum}, the - * Camera3d override conservatively returns `true` for every - * non-floating renderable — the GPU still clips out fragments - * outside the frustum, so the result is visually correct. The - * cost is no CPU-side early-out (every world child runs through - * `draw`, even ones the GPU will throw away). Floating elements - * (HUD / UI) still use Camera2d's 2D rect check — their bounds - * are screen-space and don't need perspective consideration. + * 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] - if visibility should be tested against screen coords - * @returns true if the renderable should be drawn + * @param [floating] - test against screen coordinates instead of frustum + * @returns true if the renderable's bounds overlap the frustum */ override isVisible( obj: Renderable, @@ -415,6 +418,60 @@ export default class Camera3d extends Camera2d { if (floating || obj.floating) { return super.isVisible(obj, floating); } - return true; + // 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 index cde9d63c7..0911192e2 100644 --- a/packages/melonjs/src/camera/frustum.ts +++ b/packages/melonjs/src/camera/frustum.ts @@ -1,5 +1,20 @@ 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; @@ -67,6 +82,19 @@ export default class Frustum { */ 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 @@ -77,6 +105,17 @@ export default class Frustum { 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(); } @@ -118,4 +157,119 @@ export default class Frustum { // 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/tests/camera3d.spec.js b/packages/melonjs/tests/camera3d.spec.js index dd643e07c..2573ca0c0 100644 --- a/packages/melonjs/tests/camera3d.spec.js +++ b/packages/melonjs/tests/camera3d.spec.js @@ -234,31 +234,68 @@ describe("Camera3d", () => { }); }); - describe("isVisible (frustum-aware culling)", () => { - it("returns true for non-floating sprites far outside Camera2d's worldView (regression)", () => { - // Camera2d's `isVisible` tests a 2D rect overlap against - // `worldView`. When Camera3d rotates / orbits, world - // coordinates that should be visible through the frustum - // can fall outside that 2D rect (which is locked to the - // camera's pos.x/y + width/height). Camera3d must NOT - // inherit that test verbatim — it would silently cull - // sprites mid-orbit. + 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); - cam.pos.set(0, 0, -500); // camera behind the world - cam.yaw = Math.PI / 4; // looking 45° to the right - - const sprite = new Renderable(2000, 2000, 32, 32); - sprite.pos.z = 500; - // world (2000, 2000) is far outside the camera's 2D worldView - // (which is at camera.pos.x/y = 0,0 + width/height = 800,600). - // Camera3d must still return true — the GPU clips off-frustum - // fragments; visibility culling on the CPU is conservative. + // 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", () => { - // floating = screen-space, no perspective involved - const cam = new Camera3d(0, 0, 800, 600); + const cam = setupCam(); const inViewport = new Renderable(100, 100, 32, 32); inViewport.floating = true; expect(cam.isVisible(inViewport)).toBe(true); diff --git a/packages/melonjs/tests/frustum.spec.js b/packages/melonjs/tests/frustum.spec.js index 320aa69b5..986bfdd4d 100644 --- a/packages/melonjs/tests/frustum.spec.js +++ b/packages/melonjs/tests/frustum.spec.js @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { Frustum, Matrix3d } from "../src/index.js"; +import { Frustum, Matrix3d as Matrix3dClass } from "../src/index.js"; /** * Tests for the standalone Frustum class. @@ -13,7 +13,7 @@ describe("Frustum", () => { expect(f.aspect).toBe(1.0); expect(f.near).toBe(0.1); expect(f.far).toBe(1000); - expect(f.projectionMatrix).toBeInstanceOf(Matrix3d); + expect(f.projectionMatrix).toBeInstanceOf(Matrix3dClass); }); it("honors constructor opts", () => { @@ -165,4 +165,77 @@ describe("Frustum", () => { ); }); }); + + 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); + }); + }); }); From bbfcfce99f91006c591e4816f259f01e5948aea1 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sun, 24 May 2026 18:03:18 +0800 Subject: [PATCH 6/7] chore(examples): add DebugPanelPlugin to camera3d example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches the pattern used by the mesh3d / mesh3dMaterial examples — press ` (backtick) at runtime to toggle the debug overlay (FPS, draw calls, world tree, batcher stats). Useful when poking at Camera3d behavior to confirm sprites are being drawn and the frustum-culled ones are dropping out of the world tree. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/examples/src/examples/camera3d/ExampleCamera3d.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/examples/src/examples/camera3d/ExampleCamera3d.tsx b/packages/examples/src/examples/camera3d/ExampleCamera3d.tsx index 7ad8aead7..adeecd95b 100644 --- a/packages/examples/src/examples/camera3d/ExampleCamera3d.tsx +++ b/packages/examples/src/examples/camera3d/ExampleCamera3d.tsx @@ -18,6 +18,7 @@ * 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, @@ -25,6 +26,7 @@ import { input, loader, type Pointer, + plugin, Sprite, state, video, @@ -44,6 +46,7 @@ const createGame = () => { }); 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 From 63f6e79517f41973486cc54864cf45d1e9c2a821 Mon Sep 17 00:00:00 2001 From: Olivier Biot Date: Sun, 24 May 2026 18:15:59 +0800 Subject: [PATCH 7/7] test(camera3d): expanded isVisible coverage + PR #1464 regression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 15 new tests on top of the 5 that landed with the initial frustum-culling commit. Targets the parts of the visibility surface that were uncovered: Vertical FOV / pitch: - sprite far above the camera (outside vertical FOV) → culled - sprite far below the camera → culled - pitching up brings an above-frustum sprite into view - pitching down brings a below-frustum sprite into view Sphere edge cases: - sphere center behind near plane but radius pokes through → visible - sprite at exactly the far plane → visible (edge of frustum) - 1×1 px sprite deep in the frustum → still correctly classified Off-axis camera positions: - camera shifted right by 1000 units: sprite at the camera's new forward axis is visible, sprite at world origin is now outside FOV Narrow FOV: - wide-FOV cam sees a sprite that narrow-FOV cam culls (same world pose) — proves fov actually affects culling, not just rendering User-reported regression (PR #1464): - reproduces the exact scenario the user reported: Camera3d example with 3 monsters at z=200/400/600, camera at (0, 0, -300) orbiting z=400 at distance=700, one left-arrow click (yaw -= 0.15). All 3 monsters MUST remain visible after the click. Pre-fix, inheriting Camera2d's worldView rect silently culled all three. Stress variant: 8 clicks (yaw ≈ -69°) — orbit-target monster still in frame. Multi-update consistency: - moving camera far without calling update keeps the old planes (verifies no auto-rebuild) - update rebuilds the planes, isVisible reflects the new pose - moving back + update again gets the original behavior — proves planes are state-correct across multiple updates 3631 tests pass (was 3620 + 11 new isVisible tests + cleanup of the debug logging in camera3d.ts I added while diagnosing the user report). Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/melonjs/tests/camera3d.spec.js | 184 ++++++++++++++++++++++++ 1 file changed, 184 insertions(+) diff --git a/packages/melonjs/tests/camera3d.spec.js b/packages/melonjs/tests/camera3d.spec.js index 2573ca0c0..107a01c96 100644 --- a/packages/melonjs/tests/camera3d.spec.js +++ b/packages/melonjs/tests/camera3d.spec.js @@ -304,6 +304,190 @@ describe("Camera3d", () => { 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", () => {