import type { ObservableVector3d } from "../math/observableVector3d.ts"; import { Vector3d } from "../math/vector3d.ts"; import type Container from "./../renderable/container.js"; import type Renderable from "./../renderable/renderable.js"; import type Renderer from "./../video/renderer.js"; import Camera2d from "./camera2d.ts"; import Frustum, { type FrustumOptions } from "./frustum.ts"; /** * 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. * * **WebGL required.** Camera3d's perspective projection, depth-buffer * painter sort and mesh draw path all live in the WebGL renderer; the * Canvas backend has none of these and would render a stuck blank scene. * Construct the Application with `renderer: video.WEBGL` to get a hard * throw at construction time if WebGL is unavailable. Pairing * `cameraClass: Camera3d` with `video.AUTO` will emit a `console.warn` * at construction (and silently misrender) when AUTO falls back to * Canvas — see {@link ApplicationSettings.renderer} for the contract. * * 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 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. * Avoid combining with Camera3d for now. * - `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 { /** * Override `Camera2d.defaultSortOn` to declare `"depth"` as this * camera's preferred sort mode. `Application` / `Stage` apply this * to `world.sortOn` at bootstrap, so games opting into Camera3d via * `cameraClass: Camera3d` get camera-distance painter's sort for * free — the only correct sort for alpha-blended sprites under * perspective. */ static defaultSortOn: "x" | "y" | "z" | "depth"; /** * 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; /** * World-space 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. * * Treated as world-space in this release — target-rotation-aware * follow (where the offset rotates with the target's orientation, * Cinemachine / Unreal spring-arm style) is deferred until a * showcase needs it (e.g. AfterBurner's banking jet). * @default (0, 0, 0) */ followOffset: Vector3d; /** * 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; /** * @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); /** * vertical field of view in radians. Setting this rebuilds the * projection matrix. Proxies to `frustum.fov`. */ get fov(): number; set fov(value: number); /** * 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; set aspect(value: number); /** * Update the perspective near/far clip distances and rebuild the * projection matrix in one shot. Anything closer than `near` or * farther than `far` is clipped by the GPU; projection math also * degrades sharply just before `far`, so size the far plane to the * deepest object in your scene with a little headroom. Defaults are * `near = 0.1`, `far = 1000` — typical AfterBurner-class scenes * with enemies spawning at z = 3000+ need to push `far` out. * * **This is the supported way to change near/far at runtime.** The * inherited `Camera2d.near` / `.far` are plain instance fields — * direct assignment (`camera.near = 5`) updates the cached value * but leaves the projection matrix stale until the next * `resize()`. TypeScript's property-vs-accessor rule prevents * shadowing the inherited fields with accessor pairs, so the * convenience method is the public contract instead. * @param near - near clip distance * @param far - far clip distance * @returns this camera (chainable) */ setClipPlanes(near: number, far: number): this; /** * 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 */ _updateProjectionMatrix(): void; /** * Override Camera2d's non-default projection setup so that a Camera3d * used in split-screen / picture-in-picture still renders the world * with perspective instead of falling back to a 2D ortho. The * camera's perspective `projectionMatrix` is mirrored into * `worldProjection`; the surrounding `clipRect` set by the base * `draw()` handles confining the draw region to the camera's * sub-screen rect, and the frustum's `aspect` is already in sync * with the camera's width/height. `screenProjection` is left alone * because it was already set up correctly in * {@link Camera3d#_updateProjectionMatrix}. * @ignore */ _setupNonDefaultProjection(renderer: Renderer): void; /** * Resize the camera viewport and recompute aspect ratio. * @param w - new width * @param h - new height * @returns this camera */ resize(w: number, h: number): 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 */ _applyContainerViewTransform(container: Container, translateX: number, translateY: number): void; /** * Revert {@link Camera3d#_applyContainerViewTransform} in reverse * order to restore the container's `currentTransform` to its * pre-camera state. * @ignore */ _revertContainerViewTransform(container: Container, translateX: number, translateY: number): void; /** * Point the camera at a world-space target by deriving pitch and * yaw from the direction (target − camera.pos). Roll is unaffected. * * Three call shapes: * - `lookAt(x, y, z)` — raw world coordinates * - `lookAt(vector3d)` — a 3D point * - `lookAt(renderable)` — uses `renderable.pos` (matches the * `Renderable.lookAt(target)` signature so Camera3d is a structural * drop-in replacement for Camera2d / Renderable in user code). * * 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 xOrTarget - target world x, or a target with `pos` / `x`,`y`,`z` * @param y - target world y (only when first arg is a number) * @param z - target world z (only when first arg is a number) * @returns this camera */ lookAt(xOrTarget: number | { x: number; y: number; z?: number; pos?: ObservableVector3d; }, y?: number, z?: number): this; /** * Convenience overload of `lookAt` accepting a {@link Vector3d}. * @param target - world-space point to look at * @returns this camera */ setLookAt(target: Vector3d): this; /** * 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; /** * 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`. * * **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 */ updateTarget(dt?: number): void; /** * Visibility check used by `Container.update` (in turn driving * `Container.draw`) to skip rendering off-screen children. * * 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] - test against screen coordinates instead of frustum * @returns true if the renderable's bounds overlap the frustum */ isVisible(obj: Renderable, floating?: boolean): boolean; /** * Bulk frustum cull via the world's {@link Octree}. Returns every * renderable whose octant the current frustum overlaps — * conservative (some renderables may still narrow-cull out * via {@link Camera3d#isVisible}'s per-sphere test) but * O(visible + walk) instead of O(scene). * * Only applicable under `cameraClass: Camera3d` (or any setup * where `world.sortOn === "depth"` and the broadphase is an * Octree). Returns an empty array under a 2D broadphase — call * sites can guard on the array length or branch on * `world.sortOn`. * * For a 1000-renderable scene with ~50 visible, expect a 5-20× * speedup over walking every renderable and per-item * {@link Camera3d#isVisible}. * @param world - the world to cull (its broadphase must be an Octree); typed structurally to sidestep the Camera3d → World import cycle * @param world.broadphase - the world's spatial broadphase * @param world.sortOn - guard: returns empty unless this equals `"depth"` * @param [out] - caller-supplied result array (re-entrancy-safe) * @returns visible-renderable candidates * @example * const visible = camera.queryVisible(app.world); * for (const r of visible) { * // narrow-phase per-renderable visibility (sphere / OBB) if needed * if (camera.isVisible(r)) r.draw(renderer); * } */ queryVisible(world: { broadphase: unknown; sortOn: string; }, out?: Renderable[]): Renderable[]; /** * 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 */ update(dt?: number): boolean; /** * Recompute the frustum's six bounding planes from the current * `projectionMatrix × viewMatrix` (the world → clip matrix). * Called from {@link Camera3d#update} each frame; `isVisible` * then tests against the cached planes. * @ignore */ _rebuildFrustumPlanes(): void; } //# sourceMappingURL=camera3d.d.ts.map