feat(camera): Camera3d + Frustum + Stage/App cameraClass (19.7.0 PR B)#1464
Open
obiot wants to merge 7 commits into
Open
feat(camera): Camera3d + Frustum + Stage/App cameraClass (19.7.0 PR B)#1464obiot wants to merge 7 commits into
obiot wants to merge 7 commits into
Conversation
….0 PR B)
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) <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
Adds a new perspective-camera path to melonJS by introducing Camera3d (as a Camera2d subclass) plus a standalone Frustum math container, and wires an opt-in cameraClass mechanism through Application/Stage to select the default camera per stage while preserving the legacy Camera2d singleton fallback.
Changes:
- Introduces
Camera3d(perspective projection, pitch/yaw view transform) and a reusableFrustumprojection-parameter container. - Adds
cameraClassto stage/app settings and updatesStage.reset()to resolve the default camera via stage/app settings before falling back to theCamera2dsingleton; pinsDefaultLoadingScreentoCamera2d. - Adds unit/integration tests plus a new
Camera3dexample entry in the examples app.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/melonjs/src/camera/frustum.ts | New Frustum class encapsulating perspective params and projection matrix (Y-down, +Z forward). |
| packages/melonjs/src/camera/camera3d.ts | New Camera3d extending Camera2d, adding frustum-backed perspective projection and 3D view transform hooks. |
| packages/melonjs/src/camera/camera2d.ts | Factors view transform into overridable hooks to support Camera3d without duplicating draw logic. |
| packages/melonjs/src/state/stage.ts | Adds StageSettings.cameraClass and updates default-camera resolution order in Stage.reset(). |
| packages/melonjs/src/application/settings.ts | Adds ApplicationSettings.cameraClass type entry for app-wide default camera selection. |
| packages/melonjs/src/loader/loadingscreen.js | Pins DefaultLoadingScreen to Camera2d via super({ cameraClass: Camera2d }). |
| packages/melonjs/src/index.ts | Exports Camera3d and Frustum from the public entrypoint. |
| packages/melonjs/tests/frustum.spec.js | New unit tests for Frustum defaults, updates, conventions, and aspect behavior. |
| packages/melonjs/tests/camera3d.spec.js | New unit tests for Camera3d construction, frustum integration, lookAt, and followOffset. |
| packages/melonjs/tests/camera3d_integration.spec.js | Integration tests for Stage.reset() camera resolution order and loader protection behavior. |
| packages/melonjs/CHANGELOG.md | Documents the new camera/frustum features and the new stage/app cameraClass settings. |
| packages/examples/src/main.tsx | Registers the new Camera3d example route/entry. |
| packages/examples/src/examples/camera3d/ExampleCamera3d.tsx | New example demonstrating Camera3d orbit + perspective sprite billboard grid. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+336
to
+340
| 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, |
Comment on lines
+190
to
+201
| 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 | ||
| }); |
Comment on lines
+27
to
+31
| * - **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`. |
Comment on lines
+99
to
+100
| * Target-local point the camera looks at when following. Combined | ||
| * with the followed target's position to compute the look direction. |
Comment on lines
+230
to
+233
| // 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) |
Comment on lines
+319
to
+343
| /** | ||
| * 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; |
…B follow-up) `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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…ng sprites mid-orbit 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) <noreply@anthropic.com>
| * @import Renderer from "./../video/renderer.js"; | ||
| */ | ||
|
|
||
| import Camera2d from "../camera/camera2d"; |
Comment on lines
+190
to
+195
|
|
||
| /** | ||
| * 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 |
Comment on lines
+95
to
+102
| * 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.) |
Comment on lines
+140
to
+153
| 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 |
Comment on lines
+119
to
+132
| const panel = document.createElement("div"); | ||
| panel.style.cssText = | ||
| "position:absolute;top:60px;left:16px;display:grid;" + | ||
| "grid-template-columns:repeat(3,40px);grid-template-rows:repeat(4,40px);" + | ||
| "gap:4px;z-index:1000;font-family:sans-serif;"; | ||
| const mkButton = (label: string, gridArea: string, handler: () => void) => { | ||
| const b = document.createElement("button"); | ||
| b.textContent = label; | ||
| b.style.cssText = | ||
| "background:#1a1a1a;color:#e0e0e0;border:1px solid #444;" + | ||
| "border-radius:4px;cursor:pointer;font-size:18px;" + | ||
| `grid-area:${gridArea};`; | ||
| b.addEventListener("click", handler); | ||
| panel.appendChild(b); |
Comment on lines
+93
to
+113
| 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(); | ||
| }); |
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Comment on lines
+42
to
+45
| * 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. |
Comment on lines
+162
to
+166
| * 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. | ||
| * |
Comment on lines
+54
to
+59
| * - `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. |
Comment on lines
+318
to
+321
| // 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"). |
Comment on lines
+469
to
+473
| // 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); |
Comment on lines
+177
to
+181
| const parent = app.renderer.getCanvas().parentElement; | ||
| if (parent) { | ||
| parent.style.position = "relative"; | ||
| parent.appendChild(panel); | ||
| parent.appendChild(hint); |
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) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a perspective
Camera3dextendingCamera2d, with three opt-in paths and a freshFrustumclass 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.This is the headline engine feature for 19.7, building on PR A's vertex depth plumbing (#1463). Together they unblock PR C (After Burner-style behind-the-plane shoot-em-up showcase).
What's added
Camera3d— perspective camera withfov,aspect,pitch,yaw,followOffset,lookAhead. Drop-in replacement forCamera2d(slots intoStage.cameras, inherits post-effect FBO bracket, color matrix, fade/shake/follow, lighting overlay). Conventions: Y-down + +Z forward (matchesCamera2dso 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 asR⁻¹ ∘ T(-pos)via new overridable hooks onCamera2d(_applyContainerViewTransform/_revertContainerViewTransform) thatCamera2d's default impl uses as drop-in for its previouscontainer.translate(-tx, -ty)lines — zero behavior change on the Camera2d path.Frustum— encapsulatesfov/aspect/near/far+ matchingprojectionMatrix. Bakes Y-down + +Z forward via post-multipliedscale(1, -1, -1)so standardgl-matrix-styleperspective()output aligns with melonJS conventions.set(fov, aspect, near, far)atomically rebuilds the matrix in one call. Plane-based culling intentionally deferred (YAGNI for PR B; AfterBurner in PR C will drive the API shape).ApplicationSettings.cameraClass+StageSettings.cameraClass— declare the default camera constructor for any stage that doesn't provide explicitcameras. App-level applies to every stage; per-stage overrides the app-level.Three opt-in paths
Resolution order in
Stage.reset()camerasarray on the stage (most-specific)cameraClasson the stage's settingscameraClasson the application's settingsCamera2dmodule-level singleton (preserves pre-19.7 path bit-for-bit)Steps 2 and 3 produce a fresh instance per stage —
Camera3dholds 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 tocameraClasskeepsCamera2dsingleton-sharing identical to pre-19.7.Preloader protection
DefaultLoadingScreennow constructs with explicitsuper({ cameraClass: Camera2d }). The loader's 2D progress bar would render incorrectly under perspective; pinning it toCamera2dmakes 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-widecameraClass: Camera3djust doessuper({ cameraClass: Camera2d }).Backward compatibility
✅ Full. Confirmed by 3604 tests pass + the existing example gallery rendering identically (visually spot-checked on lights, platformer, shaderEffects, mesh3d). Specific guarantees:
Camera2dcode (default near/far ±1e6 from PR A)app.viewporttyped asCamera2dCamera3d extends Camera2d)follow/shake/fadeIn/fadeOut/colorMatrixCamera2dsingleton path (nocameraClassset anywhere)DefaultLoadingScreenrenderingcameraClass)Light2d+Meshunder Camera3dTests
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 behaviorcamera3d.spec.js— 25 tests covering constructor defaults, Frustum integration, fov/aspect setters, resize, lookAt math, followOffset, target follow logic, Camera2d API inheritancecamera3d_integration.spec.js— 10 tests covering the 4-stepStage.resetresolution order, no-state-bleed across stages, singleton preservation for the Camera2d default path,DefaultLoadingScreenalways-Camera2d protection,Applicationsettings propagation, fullApplicationconstruction smokeVisual validation
Validated end-to-end in headless Chromium + WebGL2 + ANGLE / SwiftShader via Playwright. A grid of sprite billboards renders with clear perspective scaling (closer tiles larger, farther tiles smaller), and a simulated mouse drag rotates the camera, producing a dramatically different view.
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-levelcameraClass: Camera3dopt-in path.Known limitations (documented in
Camera3dJSDoc)Light2dis 2D-coord-only; avoid combining withCamera3duntil Light3d ships.Meshkeeps its own self-contained projection; meshes won't track perspective correctly underCamera3dwithout manual projection syncing. AfterBurner-style showcases use sprite billboards (PR A'svec3 aVertexmakes them scale naturally).localToWorld/worldToLocalfall back to ortho-equivalent 2D projection at z=0. Full 3D unproject for arbitrary depth is future work.Next
Test plan
pnpm vitest run— 3604 pass / 12 skipped (was 3558 / 12)pnpm lint— 0 errorspnpm build— succeedscameraClass🤖 Generated with Claude Code