Skip to content

Camera binding recipe

Two recipes for the common “camera drives both the canvas and some HTML” setup: a one-call canvas↔camera↔renderer wire-up, and projecting a DOM element onto the world using the renderer’s live view matrix.

The pan-zoom example is a live demo of the pan/zoom camera behavior both recipes on this page drive (it wires the underlying createViewport/bindViewport pair by hand — see “Without bindCamera” below — but the resulting camera behavior is exactly what bindCamera gives you in one call).

bindCamera — canvas → viewport → renderer in one call

Section titled “bindCamera — canvas → viewport → renderer in one call”

bindCamera(renderer, canvas, opts) creates a CameraViewport sized to the canvas, binds pan/zoom/pinch input to it via bindViewport, and subscribes the viewport’s onChange to push the resolved camera into renderer.setCamera. It also attaches a ResizeObserver/DPR-change hook that re-clamps zoom and resizes the viewport frame, so a monitor swap or window drag between displays doesn’t leave a stale zoom clamp.

import { createRenderer, initGPU } from "insomni";
import { bindCamera } from "insomni";
const gpu = await initGPU();
const renderer = createRenderer(gpu, canvas);
const { viewport, binding, destroy } = bindCamera(renderer, canvas, {
minZoom: 0.25,
maxZoom: 20,
});
function frame(now: number) {
binding.update(dt); // step damping/fling — same contract as bindViewport
renderer.render(layers);
requestAnimationFrame(frame);
}
// teardown
destroy(); // unwires the ResizeObserver + the underlying bindViewport binding

bindCamera returns { viewport, binding, destroy }:

  • viewport — the CameraViewport the renderer’s camera is now driven from (same object you’d get from createViewport).
  • binding — the CameraViewportBinding from the underlying bindViewport call — update(dt), setTarget, jumpTo, screenToWorld/worldToScreen, etc. all work as documented on Cameras & viewport.
  • destroy() — tears down both the resize/DPR hook and the input binding.

opts is BindViewportOptions (see the field table), plus minZoom / maxZoom may be a function (() => number) instead of a fixed number — useful when the sensible zoom floor depends on live content bounds (e.g. “never zoom out past fit-to-content”). The resolved bound is re-read on every clamp, including the DPR-change re-clamp bindCamera wires up automatically.

The same wiring, spelled out, if you need to customize a step bindCamera doesn’t expose yet:

import { createViewport, viewportFrame } from "insomni/viewport";
import { bindViewport } from "insomni";
const viewport = createViewport({
frame: viewportFrame(canvas.width, canvas.height),
});
const binding = bindViewport(viewport, canvas, { minZoom: 0.25, maxZoom: 20 });
viewport.onChange(() => renderer.setCamera(viewport.camera));

DOM overlay via the world→CSS view matrix

Section titled “DOM overlay via the world→CSS view matrix”

To pin an HTML element (a tooltip, a label, a selection handle) to a world position as the camera pans/zooms, project the point through the renderer’s live view matrix rather than re-deriving camera math in the overlay code.

renderer.getViewMatrix() returns a Mat3: the world→CSS-pixel view matrix for the renderer’s current camera (CSS px, not device px — already DPR-normalized). Apply it with transformPoint and place the overlay with a CSS transform:

import { transformPoint } from "insomni";
function syncOverlay(worldX: number, worldY: number, el: HTMLElement) {
const view = renderer.getViewMatrix();
const { x, y } = transformPoint(view, worldX, worldY);
el.style.transform = `translate(${x}px, ${y}px)`;
}
function frame() {
renderer.render(layers);
syncOverlay(marker.x, marker.y, markerEl);
requestAnimationFrame(frame);
}

Position markerEl with position: absolute; left: 0; top: 0; inside a container that shares the canvas’s CSS box, then let the transform do the placement — translate() avoids layout thrash on every frame. If you already have a CameraViewportBinding from bindCamera/bindViewport, its worldToScreen(wx, wy) does the same projection using the binding’s interpolated camera (mid-smoothing), which is preferable while the camera is still damping toward a target — getViewMatrix() reflects whatever camera state was last pushed to the renderer, so use whichever one matches what’s actually on screen for your setup.

  • Cameras & viewportcreateViewport, bindViewport, frames, pan bounds.
  • Spaces & cameras — world / ui / device coordinate spaces.
  • plans/game-sim-wishlist/phase-3-camera-unification.md — the frozen bindCamera design this page documents.