Skip to content

Camera and Viewport

A game places its actors in world units. The camera projects a region of the world into the logical design size handed to createEngine, and the viewport maps that logical field onto the canvas’s backing store. The pipeline composes the two into the transform each render component draws through.

SpaceUnitSet by
WorldWorld unitsThe game, on every transform
LogicalThe design size handed to createEngineThe camera
DeviceDevice pixelsThe viewport

The camera carries the first mapping and the viewport the second, with width and height the logical design size:

logicalX = width / 2 + (worldX - camera.x) * camera.zoom;
logicalY = height / 2 + (worldY - camera.y) * camera.zoom;
deviceX = offsetX + logicalX * scale;
deviceY = offsetY + logicalY * scale;

The world-to-logical pair holds with rotation at 0. The device pair inverts as (deviceX - offsetX) / scale, so a pixel read off the backing store maps back to a logical point and then through the camera to a world one.

A render component in screen space skips the first map: its composed transform is logical coordinates, and the viewport alone carries it onto the canvas. See rendering.

interface Vec2 {
x: number;
y: number;
}
interface Rect {
x: number;
y: number;
width: number;
height: number;
}

Vec2 is a point or a direction. Rect is an axis-aligned rectangle, placed by x and y and sized by width and height. Both carry the units of whatever names them.

interface CameraSnapshot {
x: number;
y: number;
zoom: number;
rotation: number;
}

The camera’s projection at one moment, as a plain value the caller owns. camera.snapshot() returns one, and a DrawComponent reads one from api.camera(). A held snapshot keeps the values of the frame it was read in.

interface Camera {
x: number;
y: number;
zoom: number;
rotation: number;
bounds: Rect | null;
readonly target: Actor | null;
follow(actor: Actor | null): void;
snapshot(): CameraSnapshot;
worldToLogical(point: Vec2): Vec2;
logicalToWorld(point: Vec2): Vec2;
}
FieldDefaultMeaning
xwidth / 2The world x the center of the logical field shows.
yheight / 2The world y the center of the logical field shows.
zoom1Logical units per world unit.
rotation0Radians, turning the projected region about the camera’s position.
boundsnullA rectangle in world units the visible region is kept inside.
targetnullThe actor the camera follows.
MethodResult
followSets target. null clears it and returns the projection to the game.
snapshotThe projection as a value the caller owns.
worldToLogicalA world point in logical coordinates, through position, zoom, and rotation.
logicalToWorldThe inverse of worldToLogical.

A world’s camera starts at x = width / 2, y = height / 2, zoom = 1, rotation = 0, and bounds = null, so world coordinates and logical coordinates coincide until the game moves it. zoom is logical units per world unit: a zoom of 2 draws a world unit two logical units wide and halves the visible extent.

The camera is reached as world.camera and is part of the world, so a level transition builds a new one at those defaults.

An actor is a view target by carrying a CameraComponent. follow(actor) sets target. Each frame, before the pipeline draws, a camera with a target takes the first enabled CameraComponent the target holds and adopts that component’s world transform and zoom, then clamps the result to bounds.

follow(null) clears the target, and the game writes x, y, zoom, and rotation itself.

bounds is a rectangle in world units the camera’s visible region is kept inside. With rotation at 0 the visible extent is the logical field divided by the zoom, width / zoom by height / zoom. Each axis is clamped on its own, and an axis whose visible extent exceeds the bounds on that axis centers on it.

interface Viewport {
readonly width: number;
readonly height: number;
scale: number;
offsetX: number;
offsetY: number;
}
FieldMeaning
widthThe logical design width.
heightThe logical design height.
scaleDevice pixels per logical unit, with the device pixel ratio folded in.
offsetXThe left letterbox bar, in device pixels.
offsetYThe top letterbox bar, in device pixels.

scale and both offsets are device pixels. The CSS-pixel figure is scale divided by the device pixel ratio.

function fitViewport(
logicalWidth: number,
logicalHeight: number,
cssWidth: number,
cssHeight: number,
dpr: number,
): Viewport;

The scale is uniform: the smaller of cssWidth / logicalWidth and cssHeight / logicalHeight, multiplied by dpr. One scale preserves the aspect ratio and keeps the whole logical field visible.

The leftover space on the long axis is split into two equal letterbox bars, which are the offsets. They are computed against the rounded device size the backing store is written at, so the two bars sum to the drawable area exactly. The bars hold background alone: the pipeline clips every component to the logical field, as rendering states.

A degenerate input yields a scale of 0: a surface measuring zero on either axis, or a logical size that is not finite and positive. A ratio that is not finite and positive is read as 1. A zero scale draws nothing for the frame it applies to, and the fit recovers on its own once the element has a size.

function applyViewport(ctx: CanvasRenderingContext2D, viewport: Viewport): void;

Sets the context transform to the viewport, as setTransform(scale, 0, 0, scale, offsetX, offsetY). The transform is replaced rather than composed, so every frame starts from the viewport whatever state the previous frame’s render left the context in. The pipeline then composes the camera onto it, which is what lets a render component draw in world units.

function syncCanvas(
canvas: HTMLCanvasElement,
logicalWidth: number,
logicalHeight: number,
surface: SurfaceMetrics,
): Viewport;

Brings the canvas’s backing store in line with the size and ratio the surface reports, and returns the fit. The backing store is round(cssWidth * dpr) by round(cssHeight * dpr), written only when it differs from the current values, since assigning canvas.width reallocates and clears the canvas.

A surface reporting zero on either axis leaves the backing store as it stands, which keeps the last frame drawn on screen, and returns a viewport whose scale is zero.

The engine recomputes the fit at the top of every frame, from what the surface reports at that moment, and once when the engine is created so the first read of engine.viewport() reports a real fit. A window resize, a device pixel ratio change from a display switch, and a layout change that fires no event at all each correct themselves within one frame.

The engine pins a pixel CSS size onto the canvas only while the reported size still matches the backing-store attributes.

MeasurementThe engine writes
The reported CSS size equals the size the backing-store attributes implystyle.width and style.height in pixels, equal to the measurement, alongside the backing store
The page sized the element, inline or through a stylesheetThe backing store alone

An element the page has not sized takes its CSS size from the width and height attributes, which are exactly what the backing store writes. Writing the backing store would then feed into the next measurement, and the element would grow by the device pixel ratio on every frame. Pinning the measured size breaks that loop.

engine.viewport(), world.viewport(), InitApi.viewport(), and a DrawComponent’s api.viewport() all return the current fit. Each call returns a snapshot the caller owns, so a held viewport keeps the values of the frame it was read in.

Vec2, Rect, CameraSnapshot, Camera, and Viewport are exported as types, and fitViewport, applyViewport, and syncCanvas as functions, from @clockwyrks/structured-2d.