Skip to content

feat(camera): Camera3d + Frustum + Stage/App cameraClass (19.7.0 PR B)#1464

Open
obiot wants to merge 7 commits into
masterfrom
feat/camera3d
Open

feat(camera): Camera3d + Frustum + Stage/App cameraClass (19.7.0 PR B)#1464
obiot wants to merge 7 commits into
masterfrom
feat/camera3d

Conversation

@obiot
Copy link
Copy Markdown
Member

@obiot obiot commented May 24, 2026

Summary

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.

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 with fov, aspect, pitch, yaw, followOffset, lookAhead. 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 — encapsulates 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. 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 explicit cameras. App-level applies to every stage; per-stage overrides the app-level.

Three opt-in paths

// 1. App-wide default (simplest):
new Application(w, h, { cameraClass: Camera3d });

// 2. Per-stage override:
class GameStage extends Stage {
  constructor() { super({ cameraClass: Camera3d }); }
}

// 3. Per-instance with custom opts:
class GameStage extends Stage {
  constructor() {
    super({
      cameras: [new Camera3d(0, 0, w, h, { fov: Math.PI / 4, near: 1, far: 5000 })]
    });
  }
}

Resolution order in Stage.reset()

  1. explicit cameras array on the stage (most-specific)
  2. cameraClass on the stage's settings
  3. cameraClass on the application's settings
  4. fallback to the Camera2d module-level singleton (preserves pre-19.7 path bit-for-bit)

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 identical to pre-19.7.

Preloader protection

DefaultLoadingScreen now constructs with explicit super({ cameraClass: Camera2d }). The loader's 2D progress bar would render incorrectly under perspective; 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). Specific guarantees:

Before After
existing Camera2d code (default near/far ±1e6 from PR A) works works (identical)
app.viewport typed as Camera2d yes yes (Camera3d extends Camera2d)
follow / shake / fadeIn / fadeOut / colorMatrix works works (inherited)
Camera2d singleton path (no cameraClass set anywhere) shared instance across stages shared instance across stages (identical)
DefaultLoadingScreen rendering Camera2d via singleton Camera2d explicitly (immune to app-level cameraClass)
Light2d + Mesh under Camera3d N/A works under Camera2d unchanged; documented as 2D-only under Camera3d (use sprite billboards)

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

Visual 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-level cameraClass: Camera3d opt-in path.

Known limitations (documented in Camera3d JSDoc)

  • Light2d is 2D-coord-only; avoid combining with Camera3d until Light3d ships.
  • Mesh keeps its own self-contained projection; meshes won't track perspective correctly under Camera3d without manual projection syncing. AfterBurner-style showcases use sprite billboards (PR A's vec3 aVertex makes them scale naturally).
  • localToWorld / worldToLocal fall back to ortho-equivalent 2D projection at z=0. Full 3D unproject for arbitrary depth is future work.

Next

  • PR C — After Burner-style behind-the-plane shoot-em-up showcase, in-tree example, headline gameplay demo for the 19.7 release.

Test plan

  • pnpm vitest run — 3604 pass / 12 skipped (was 3558 / 12)
  • pnpm lint — 0 errors
  • pnpm build — succeeds
  • Visual validation in WebGL2 via Playwright — perspective scaling + orbit rotation confirmed
  • Backward compat spot-check across existing examples — sprite, lights, platformer, shaderEffects, mesh3d, plinko-planck all render identically
  • Loader screen renders as Camera2d 2D progress bar regardless of app-level cameraClass

🤖 Generated with Claude Code

….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>
Copilot AI review requested due to automatic review settings May 24, 2026 08:57
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 reusable Frustum projection-parameter container.
  • Adds cameraClass to stage/app settings and updates Stage.reset() to resolve the default camera via stage/app settings before falling back to the Camera2d singleton; pins DefaultLoadingScreen to Camera2d.
  • Adds unit/integration tests plus a new Camera3d example 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 thread packages/melonjs/src/camera/camera3d.ts Outdated
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 thread packages/melonjs/src/camera/camera3d.ts Outdated
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 thread packages/melonjs/src/camera/camera3d.ts Outdated
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 thread packages/melonjs/src/camera/camera3d.ts Outdated
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;
obiot and others added 2 commits May 24, 2026 17:29
…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>
Copilot AI review requested due to automatic review settings May 24, 2026 09:51
…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>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 6 comments.

* @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();
});
obiot and others added 2 commits May 24, 2026 18:01
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>
Copilot AI review requested due to automatic review settings May 24, 2026 10:03
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 6 comments.

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants