// The 2d camera vocabulary, pure: the one camera type both layers share // and its world <-> screen mapping as plain functions - no GPU or GUI // imports, so its test (tests/camera.test.ts) exercises this // module headless on the flux binary. Three consumers implement or undo // exactly this mapping and must agree with it: the vertex stages // (glsl.ts, uCamera + uCameraRot), the tile layer's composite // transform (the props in components/tile-layer.tsx) and the pointer inverse // (spriteDispatch in layer.ts, which calls unprojectCamera directly). The // check keeps the spellings from drifting. /** * A camera over a 2d world - the sprite layer's `setCamera`/`camera` prop * and the tile layer's `TileCamera` alike. The world point (`x`, `y`) is * shown at the viewport point (`pivotX`, `pivotY`), the world scaled by * `zoom` and rotated by `rotation` ABOUT that pivot. Rotation turns the * world clockwise (y-down) about the pivot; to render a heading `h` * upward use `rotation = -h - Math.PI / 2`. The pivot defaults to (0, 0), * which makes `{ x, y, zoom }` mean "world x/y at the viewport top-left" - * the plain scrolling camera. * * As a `setCamera` update, absent keys keep their values (the params * rule); as an argument to projectCamera/unprojectCamera, absent keys * read as the defaults (x/y/rotation 0, zoom 1, pivot (0, 0)). */ export type CameraUpdate = { /** World pixel shown at the pivot (the viewport top-left by default). */ x?: number y?: number /** World-to-screen scale; 1 is pixel-for-pixel. */ zoom?: number /** Rotation, radians, clockwise (y-down), about the pivot. */ rotation?: number /** Viewport point the camera's world point pins to; default (0, 0). */ pivotX?: number pivotY?: number } /** A camera snapshot (a layer's `camera()`): CameraUpdate's fields, all * present - the argument for projectCamera/unprojectCamera. */ export type CameraState = Required const CAMERA_FIELDS = ["x", "y", "zoom", "rotation", "pivotX", "pivotY"] as const /** * Validate a setCamera update (throws - the dev validation policy): every * present field must be finite, zoom additionally positive. A NaN rotation * or pivot would otherwise poison the uniforms and blank the layer with no * error. Internal - both layer kinds call it at the top of setCamera. */ export function checkCamera(update: CameraUpdate): void { for (let key of CAMERA_FIELDS) { let value = update[key] if (value !== undefined && !Number.isFinite(value)) { throw new Error(`setCamera: ${key} must be a finite number, got ${value}`) } } if (update.zoom !== undefined && update.zoom <= 0) { throw new Error(`setCamera: zoom must be positive, got ${update.zoom}`) } } /** * World -> screen under a camera: * `pivot + R(rotation) * zoom * (world - cam)`. Screen means the layer's * viewport pixels (the sprite layer's output, the tile world's container). */ export function projectCamera(camera: CameraUpdate, worldX: number, worldY: number): [number, number] { let zoom = camera.zoom ?? 1 let rotation = camera.rotation ?? 0 let c = Math.cos(rotation) let s = Math.sin(rotation) let dx = (worldX - (camera.x ?? 0)) * zoom let dy = (worldY - (camera.y ?? 0)) * zoom return [(camera.pivotX ?? 0) + dx * c - dy * s, (camera.pivotY ?? 0) + dx * s + dy * c] } /** Screen -> world: the exact inverse of projectCamera. */ export function unprojectCamera(camera: CameraUpdate, screenX: number, screenY: number): [number, number] { let zoom = camera.zoom ?? 1 let rotation = camera.rotation ?? 0 let c = Math.cos(rotation) let s = Math.sin(rotation) let dx = screenX - (camera.pivotX ?? 0) let dy = screenY - (camera.pivotY ?? 0) return [(camera.x ?? 0) + (dx * c + dy * s) / zoom, (camera.y ?? 0) + (dy * c - dx * s) / zoom] } /** A camera at the defaults: what a layer or view starts with. */ export function defaultCamera(): CameraState { return { x: 0, y: 0, zoom: 1, rotation: 0, pivotX: 0, pivotY: 0 } } /** Apply a setCamera update in place: absent keys keep their values (the * params rule). Validate with checkCamera first. */ export function applyCamera(camera: CameraState, update: CameraUpdate): void { if (update.x !== undefined) camera.x = update.x if (update.y !== undefined) camera.y = update.y if (update.zoom !== undefined) camera.zoom = update.zoom if (update.rotation !== undefined) camera.rotation = update.rotation if (update.pivotX !== undefined) camera.pivotX = update.pivotX if (update.pivotY !== undefined) camera.pivotY = update.pivotY } /** The shared params the vertex stage (glsl.ts) reads for a camera: * uCamera [x, y, zoom, zoom] and uCameraRot [cos, sin, pivotX, pivotY]. * The camera point goes in relative to `origin`, the layer's render * origin (views.ts): the pose records are relative to the same point * (flux:spatial setInstanceOrigin), so the stage's `iPos - uCamera.xy` * subtracts two small numbers however far out the world lies. */ export function cameraParams(camera: CameraState, origin: readonly [number, number]): { uCamera: [number, number, number, number]; uCameraRot: [number, number, number, number] } { return { uCamera: [camera.x - origin[0], camera.y - origin[1], camera.zoom, camera.zoom], uCameraRot: [Math.cos(camera.rotation), Math.sin(camera.rotation), camera.pivotX, camera.pivotY], } } /** * The orthographic view a view pushes to the residency gate (flux:spatial * setView, the viewer every layer kind's view is): world pixels to clip * through the camera mapping (projectCamera) with the viewport's center * at the view origin, so the gate's eye (the point enters are paced by) * is the viewport center, and clip x/y spanning the viewport. Written * into `view` and `proj` (16 floats each, column-major, the core's * matrix layout); the plane is z = 0 and the view direction -z, so a * planar grid (`cell: [w, h, 0]`) is tested by its x/y extent alone. * Internal - views.ts pushes it on every camera, size or oversample * write. */ export function viewMatrices(camera: CameraState, width: number, height: number, view: Float64Array, proj: Float64Array): void { let zoom = camera.zoom let c = Math.cos(camera.rotation) let s = Math.sin(camera.rotation) view.fill(0) view[0] = zoom * c view[1] = zoom * s view[4] = -zoom * s view[5] = zoom * c view[10] = 1 view[12] = camera.pivotX - width / 2 - zoom * (c * camera.x - s * camera.y) view[13] = camera.pivotY - height / 2 - zoom * (s * camera.x + c * camera.y) view[15] = 1 proj.fill(0) proj[0] = 2 / width proj[5] = -2 / height proj[10] = -1 proj[15] = 1 }