import type { ObservableVector3d } from "../math/observableVector3d.ts"; import { Vector2d } from "../math/vector2d.ts"; import { Vector3d } from "../math/vector3d.ts"; import type Renderable from "./../renderable/renderable.js"; import Camera2d from "./camera2d.ts"; import type { FogOptions } from "./fog.ts"; import Frustum, { type FrustumOptions } from "./frustum.ts"; export type { Fog3dState, FogMode, FogOptions } from "./fog.ts"; /** * A perspective camera that extends {@link Camera2d} with a view * {@link Frustum} (fov / aspect / near / far) and orientation * (pitch / yaw). 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. * * **GPU backend required.** Camera3d's perspective projection, * depth-buffer painter sort and retained mesh draw path need a renderer * with a depth buffer (`renderer.supportsDepthBuffer` — WebGL 2 or * WebGPU); the Canvas backend has none of these and would render a * stuck blank scene. Construct the Application with * `renderer: video.WEBGL` or `video.WEBGPU` to make `app.init()` reject * when that backend is unavailable. Pairing `cameraClass: Camera3d` * with `video.AUTO` will emit a `console.warn` (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 XY.** `pitch` (X axis, look up/down) and * `yaw` (Y axis, look left/right) and `roll` (Z axis, bank the horizon). * The view is `R(yaw) ∘ R(pitch) ∘ R(roll)` inverted; the frustum planes are * extracted from that same matrix, so culling follows a banked view. The * inherited `currentTransform` is still NOT read — `camera.rotate()` on a * 3D camera does nothing, so set `roll` rather than rotating the camera. * - **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 (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; /** * Z-axis rotation in radians (bank the horizon). Positive values * roll the camera clockwise, so the world tilts anticlockwise — * the view from a cockpit banking right. * * Completes the `pitch` / `yaw` / `roll` trio. Note this is NOT the * inherited {@link Renderable#rotation}: a 3D camera builds its view * from these three angles and never reads `currentTransform`, which * is why `camera.rotate()` on a `Camera3d` is silently inert. A * `Camera2d` is the other way round — it has no `roll` because a * screen-plane rotation IS its only rotation, and `rotate()` already * does it through `currentTransform`. * @default 0 * @example * // bank with the player's steering, as a flight game would * camera.roll = (player.pos.x / PLAY_BOUND_X) * MAX_BANK; */ get roll(): number; set roll(value: 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, * 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; /** * Enable, reconfigure, or switch off distance fog for this camera. * * Fog fades mesh geometry toward a colour with distance, which is what * stops a 3D scene reading as flat cut-outs and lets props appear at the * far plane without a visible edge. It is **off until you call this**, and * a scene that never does renders exactly as it did before. * * Two curves, chosen with `mode`: * * | mode | parameters | character | * | --- | --- | --- | * | `"linear"` (default) | `near`, `far` | you name the two distances | * | `"exp2"` | `density` | clear up close, closes fast at range | * * Every parameter is optional, and an omitted one is **resolved live each * frame** rather than captured here: distances track the camera's own clip * planes and the colour tracks `renderer.backgroundColor`. That is * deliberate — fog distances that silently disagreed with the clip planes * after a later {@link Camera3d#setClipPlanes} call would clip geometry * before it finished fading, and a fog colour that did not follow a * day/night background fade would leave a band at the horizon. * * Fog is per camera, so a split-screen or minimap view fogs independently * — and a `Camera2d` never fogs at all. * * `heightFalloff` adds a second falloff with altitude, so mist pools in low * ground instead of hanging at every height equally. It defaults to 0, * which is uniform fog — not a special case, the same integral with the * dial at zero. * @param options - fog settings, or `null` to switch fog off * @returns this camera (chainable) * @throws {Error} on an unknown `mode`, a non-finite or negative distance, * `far` at or below `near`, or a density at or below zero * @example * // A typical outdoor scene: set the sky, size the frustum to the level, * // then let fog take its distances and its colour from both. * class GameStage extends Stage { * onResetEvent(app) { * app.renderer.backgroundColor.parseCSS("#cfe6f7"); * * const camera = app.viewport; // a Camera3d * camera.setClipPlanes(1, 9000); * // no colour passed: it tracks `backgroundColor`, so the terrain * // dissolves into the sky and props arrive without a hard edge * camera.setFog({ near: 1200, far: 7000 }); * } * } * @example * // A single density instead of two distances. Omit it and it resolves to * // `2 / far`, which reads the same at any world scale. * camera.setFog({ mode: "exp2", density: 0.0004 }); * @example * // Fog that is deliberately NOT the sky — a green murk under a blue sky. * // Passing a `Color` keeps it by reference, so this fog can be animated * // by mutating the colour, without calling `setFog` again. * const murk = new Color(90, 120, 80); * camera.setFog({ far: 5000, color: murk }); * murk.setColor(60, 90, 55); // thickens over the next frame * @example * // Mist pooling in a valley: dense along the floor, thinning up the * // walls so the tree line stays crisp. Render space is Y-down, so * // `fogHeight` is the floor and density rises BELOW it. * camera.setFog({ * near: 1200, * far: 7000, * fogHeight: 0, * heightFalloff: 0.0015, * }); * @example * // Everything is optional: with nothing at all, fog spans the camera's * // own clip planes in the backdrop's colour. * camera.setFog({}); * camera.setFog(null); // and off again * @see Camera3d#setClipPlanes * @see Mesh#fog */ setFog(options: FogOptions | null): this; /** * The fog settings as given to {@link Camera3d#setFog}, or `null` when fog * is off. The omitted fields are not filled in here — they are resolved * per frame against the clip planes and the renderer's background colour. */ get fog(): FogOptions | null; /** * Write the camera's world-space orientation basis into the given vectors: * `right` (camera local +X), `up` (+Y), and `forward` (+Z — the direction the * camera looks). Derived from `yaw` / `pitch` (the inverse of the view * rotation), so they update as the camera turns. Handy for orienting * camera-facing geometry — e.g. {@link Sprite3d} billboards. * @param right - receives the right axis (unit) * @param up - receives the up axis (unit) * @param forward - receives the forward / look axis (unit) * @returns this camera, for chaining */ getBasis(right: Vector3d, up: Vector3d, forward: Vector3d): this; /** * The camera's world-space right axis (unit). See {@link Camera3d#getBasis}. * @param out - vector to write into (returned) * @returns `out` */ getRight(out: Vector3d): Vector3d; /** * The camera's world-space up axis (unit). See {@link Camera3d#getBasis}. * @param out - vector to write into (returned) * @returns `out` */ getUp(out: Vector3d): Vector3d; /** * The camera's world-space forward / look axis (unit). See * {@link Camera3d#getBasis}. * @param out - vector to write into (returned) * @returns `out` */ getForward(out: Vector3d): Vector3d; /** * 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; /** * 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; /** * 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[]; /** * Project a world-space point to 2D screen (canvas pixel) coordinates * through this camera's view + perspective projection (perspective divide * included). The origin is top-left with **y down**, matching where * geometry at `world` rasterizes and the engine's 2D draw space — so the * result can be fed straight to the 2D draw API (HUD pinned to a 3D object, * picking, debug overlays such as the 3D bounding-box wireframe). * * **Returns `null` when the point is at or behind the camera** (clip * `w ≤ 0`) — projecting it would yield a mirrored/degenerate pixel, so * callers (e.g. a debug wireframe) can skip it cleanly instead of drawing * garbage. Otherwise returns the screen-space pixel coordinates. * @param world - the world-space point to project * @param [out] - optional Vector2d to receive the result (allocated if omitted) * @returns the screen-space pixel coordinates, or `null` if behind the camera */ worldToScreen(world: Vector3d, out?: Vector2d): Vector2d | null; } //# sourceMappingURL=camera3d.d.ts.map