Camera2D
<Camera2D> is a 2D game’s camera, Godot’s name for it. It follows the player’s character in the scene’s plane, and no drag, wheel or touch moves it. By default it looks straight down and is pixel-perfect: it draws the scene into a small target and shows that target upscaled by a whole number, so no pixel of the art ever stretches. Unity’s Pixel Perfect Camera and Godot’s viewport stretch mode draw the same way.
It comes from the 2D layer’s own entry, which a 3D game never downloads:
import { Camera2D } from "@spawnite/engine/2d";Add it to a scene in place of Camera. It aims itself, so a 2D game lists no camera plugin. The sample’s PointerMarker reads the point under the mouse, and its PointerAim faces the player’s character to it, as the pointer’s section says:
import { useRef } from "react";import type { Mesh } from "three";import { useFrame } from "@react-three/fiber";import { Plane2D, setPlayerAim, useViewWriter, Views } from "@spawnite/engine";import { Camera2D, usePointerPlanePoint } from "@spawnite/engine/2d";
// A top-down game's camera: pixel-perfect and straight down by default,// with a 2 m by 1 m dead zone and a short look-ahead.export function TopDownCamera() { return ( <Camera2D plane={Plane2D.Ground} pixelsPerMetre={16} referenceHeight={360} deadZone={{ width: 2, height: 1 }} lookAheadSeconds={0.2} /> );}
// A marker on the ground under the mouse, hidden where the pointer reads// no point, such as off the canvas. Its hooks need the canvas, so they// live in a view, which a room skips.export function PointerMarker() { return ( <Views> <PointerMarkerOnPage /> </Views> );}
function PointerMarkerOnPage() { const point = usePointerPlanePoint(); const marker = useRef<Mesh>(null); useFrame(() => { if (!marker.current) return; marker.current.visible = point.current !== null; if (point.current) marker.current.position.copy(point.current); }); return ( <mesh ref={marker} rotation-x={-Math.PI / 2}> <circleGeometry args={[0.25, 16]} /> <meshBasicMaterial color="#feae34" /> </mesh> );}
// The player's character faces the ground under the mouse, alone and in// a room: the page asks with setPlayerAim each frame, null included, so// the character stops aiming as the pointer leaves the page.export function PointerAim() { return ( <Views> <PointerAimOnPage /> </Views> );}
function PointerAimOnPage() { const point = usePointerPlanePoint(); const writer = useViewWriter(); useFrame(() => setPlayerAim(writer, point.current)); return null;}The camera holds the scene’s 2D frame, its plane and pixels per metre, on the world while it is mounted, in a room as well as on the page. Sprites and tile layers check their art against it. With two Camera2Ds mounted, the last one mounted draws, and the other takes over when it goes.
Pick a look
Section titled “Pick a look”A game picks its look from a few props, and the defaults are the straight-down pixel look:
| Look | Props | Games |
|---|---|---|
| Pixel top-down | the defaults | Vampire Survivors, Stardew Valley |
| Smooth top-down | pixelPerfect={false} |
Brotato, Hotline Miami |
| Tilted 2.5D | pitch={50}, and projection={CameraProjection.Perspective} for depth |
Don’t Starve, Octopath Traveler |
| Side-scroller | plane={Plane2D.Side} |
Celeste, Hollow Knight |
| Pixel diorama | a perspective lens with orbit and a zoom range, as below |
Octopath Traveler, Luminati |
pitch is the degrees the camera looks down from level, as Camera’s is: 90 looks straight down at the ground, and 0 looks level at the side plane. Away from square on, the view tilts, and it draws smooth, since a tilt squashes square texels. pixelPerfect is on by default where it can hold, square on with an orthographic lens. Set it with a tilt or a perspective lens, and a development build throws.
How the pixel-perfect frame draws
Section titled “How the pixel-perfect frame draws”The scene draws into a target referenceHeight texels tall, 360 by default, Godot’s baseline. The canvas shows it scaled by the largest whole number that fits:
- A 1080-pixel screen draws each texel 3 by 3 device pixels; a 720-pixel one, 2 by 2.
- A screen between two scales shows a little more world: 1600 by 900 shows 450 texels high.
- A phone held upright keeps
referenceHeightacross its short side, so it shows a taller slice of the world. zoommultiplies the scale by a whole number, for a level-up’s close-in.
The scale is read from the canvas’s drawing buffer, and while the camera is pixel-perfect the canvas draws at the screen’s own pixel ratio. Each texel then lands on whole device pixels at a ratio of 1.5 and under a quality level’s pixel ratio cap. The quality level’s 3D resolution, dynamic resolution and anti-aliasing do not apply to the target, which is small enough for a phone as it is, so the Graphics panel hides those rows. The target takes no samples and no screen-space filter, since either would smooth the steps the art drew.
A shot from a camera of its own, such as screenshot with a view or a camera, draws at the same texel while the camera is pixel-perfect. It covers the whole canvas with no bars and no shift below a texel, so the art keeps the steps the player sees.
The target costs less than drawing the whole canvas. On a desktop GPU at 1920 by 1080 and a ratio of 1.5, the camera’s story drew in a median of 0.9 ms of GPU time pixel-perfect and 2.3 ms smooth, over four interleaved runs each. The CPU took 1.1 to 1.2 ms either way.
The camera follows the character smoothly while every texel stays whole. The target is one texel larger than the frame, and the canvas shifts it by the leftover part of a texel in whole device pixels. The world moves in device-pixel steps, and a rigid follow holds the character still on the screen. The look’s exposure, its curve and the conversion to sRGB apply once, with or without a post-processing stack.
Show every player the same world
Section titled “Show every player the same world”fit={Camera2DFit.Crop} shows exactly the reference frame, referenceWidth by referenceHeight texels, centred in black bars, as Unity’s Crop Frame and Godot’s keep do. A screen between two scales then shows the same world as any other, which matters where waves spawn just off the screen. referenceWidth is the height at 16 by 9 unless set, 640 for 360. On an upright screen the frame turns upright, 360 by 640. A smooth crop draws the frame at the canvas’s own resolution, in the same bars.
Follow the character
Section titled “Follow the character”The camera follows the player’s view target, the character the player sees through, and these props shape the follow:
deadZone: the metres the character walks across the frame’s middle before the camera moves, its full width and height, as Cinemachine’s dead zone. None unless set.offset: metres the frame sits off the character,[right, up]on the screen, such as[0, 2]for a platformer’s character in the lower third.dampingSeconds: the seconds the frame takes to close 95% of the way on the character, Godot’s position smoothing. Rigid unless set.lookAheadSeconds: the seconds of the character’s velocity the frame moves ahead of it, eased overlookAheadDampingSeconds, Cinemachine’s look-ahead.bounds: the plane rectangle the follow never takes the frame out of, Phaser’s camera bounds, such as{ min: [-32, -18], max: [32, 18] }in metres right and up, or a Tiled map’sbounds. The mounted map’s edge unless set, andnullis unbounded. Bounds smaller than the frame centre it.
follow={null} stops the follow: the frame holds where it was last placed, and the game aims it from its own plugin, as the next section says.
A teleport, and a new view target, snap the frame. While pixel-perfect, damping and an easing look-ahead give the character up to a texel of back-and-forth against the screen, as Unity’s Pixel Perfect Camera under Cinemachine damping does. A rigid follow and the dead zone hold it still.
readCamera2DView(world) reads the plane rectangle the frame shows, such as to spawn a wave just past its edge, as Phaser’s camera.worldView does.
Turn and zoom a diorama
Section titled “Turn and zoom a diorama”A pixel diorama, such as Octopath Traveler’s towns, looks down into a miniature that the player turns and zooms. This camera is Luminati’s rig: a 28-degree lens 32 degrees down, its eye 30 m off, zooming from 18 to 42 m, turning 60 degrees either way:
import { CameraProjection, controls } from "@spawnite/engine";import { Camera2D } from "@spawnite/engine/2d";
export function DioramaCamera() { return ( <Camera2D projection={CameraProjection.Perspective} fov={28} pitch={32} referenceHeight={239} minZoom={0.71} maxZoom={1.67} orbit={{ yawLimitRadians: Math.PI / 3 }} highGround={{ height: 3.2, pitch: 39 }} followHeight aimHeight={1} dampingSeconds={0.2} /> );}
// Luminati turns on Q and E. E is the interact's, so the game moves it.export const dioramaKeys = controls({ keys: { orbitLeft: ["KeyQ"], orbitRight: ["KeyE"], interact: ["KeyF"] },});A perspective lens stands its eye where the frame shows referenceHeight texels top to bottom at the focus, so 239 texels at 16 a metre is 30 m off. The eye stands at that distance divided by the zoom. A room behind a door, Luminati’s interior, frames closer and steeper at 20 m and 38 degrees: zoom={1.5} and pitch={38}. A change of zoom or pitch turns the view to the new values, and the player’s zoom starts again from zoom.
Under a Landscape, a perspective Camera2D takes its map’s level settings, the diorama block the map builder writes, over its props, field by field. The block’s camera sets fov, pitch, minZoom and maxZoom. Its interior: true holds the eye 20 m off at 38 degrees, with no zoom range and no high ground, where camera sets none. An interior’s scene clears to backdrop, #0d0b12 when left out, with no sky.
The props work as follows:
orbitturns the view about the character. Comma and Period turn it by default, Roblox’s keys, atturnRateradians a second, and a two-finger twist turns it on a phone.yawRadiansis the rest view the limit centres on, andyawLimitRadiansofInfinityturns it all the way round, for an island.minZoomandmaxZoomlet the player zoom with the wheel, a pinch, and thezoomInandzoomOutactions, which have no keys until a game binds them.zoomis where the zoom starts.highGroundsteepens the pitch while the character stands aboveheightmetres, so the cliff before it hides less, and eases back 0.25 m lower.followHeightfollows the height the character last stood at, so a terrace stays in the frame’s middle, and a jump does not move the frame.aimHeightaims that many metres above the ground, the character’s chest.
The character’s movement turns with the view, so the stick’s up walks away from the camera at any yaw. A CharacterSprite standing upright picks its row from the way it moves on the screen, so a character walking toward the camera shows its front whatever the yaw.
bounds is the rectangle the frame never shows past: the map with its outer scenery. The focus stops where the view at rest meets an edge, so a wider zoom stops it further in, and the zoom-out stops where the view fills the rectangle. Near an edge the orbit turns less, and the view turns back as the character walks toward it. A map smaller than the frame still turns, but never shows more past its edge than the view at rest does.
setCamera2DYaw(writer, yawRadians) and setCamera2DZoom(writer, zoom) turn and zoom the view from code, such as for a cutscene, inside the same limits. A replay’s or a save’s frameCamera sets the yaw too.
Move the frame yourself
Section titled “Move the frame yourself”A game’s own plugin moves the frame in its onCameraUpdate, which runs after the camera has aimed itself, for a cutscene’s pan, a boss’s intro or a room scroll. This one pans to a boss the first time the character reaches its arena, holds, and pans back:
import { Vector2 } from "three";import { definePlugin } from "@spawnite/engine";import { Camera2DTrait, placeCamera2D } from "@spawnite/engine/2d";
// A boss's intro: the first time the follow reaches the arena, 16 m east,// the frame pans to the boss over two seconds, holds one, pans back over// two to wherever the character now stands, and hands the frame back.
// The boss at (20, 0, -12): on the ground plane, x and -z.const boss = new Vector2(20, 12);const arenaEdge = 16;const panSeconds = 2;const holdSeconds = 1;
// Written in place once a frame.const focus = new Vector2();
const ease = (share: number) => share * share * (3 - 2 * share);
export const bossIntro = definePlugin(() => { // Camera hooks run only on a page, so the plugin keeps the intro's // clock itself: the seconds since it began, null before and after. let seconds: number | null = null; let played = false; return { name: "boss-intro", onCameraUpdate({ writer, camera, deltaSeconds }) { // Where the Camera2D's own aim framed the character this frame. const follow = camera.get(Camera2DTrait)?.focus; if (!follow) return; if (!played && follow.x >= arenaEdge) { played = true; seconds = 0; } if (seconds === null) return; seconds += deltaSeconds; const back = seconds - panSeconds - holdSeconds; if (back >= panSeconds) { // Done: no write, so the follow frames the character. seconds = null; return; } if (seconds < panSeconds) focus.lerpVectors(follow, boss, ease(seconds / panSeconds)); else if (back < 0) focus.copy(boss); else focus.lerpVectors(boss, follow, ease(back / panSeconds)); placeCamera2D(writer, camera, focus); }, };});
// The game's plugins. The Camera2D aims before every hook, so the intro// moves the frame after the follow has aimed it.export const plugins = [bossIntro()];The hook runs on each frame the page draws, after the camera has aimed, and never in a room, so the plugin keeps its clock itself. The plugin hooks say when it runs and what deltaSeconds counts. Its context’s camera is the camera’s entity, not the three.js camera:
camera.get(Camera2DTrait).focusis where the follow framed the character this frame, on the plane.placeCamera2D(writer, camera, focus)framesfocusinstead, aVector2of metres right and up on the plane: x and -z on the ground, x and y on the side plane.projectOntoPlane2Dturns a world point into one. Itsheightoption aims that many metres above the ground plane, where the follow’s height would.- A frame the hook does not place stays where the follow put it, so stopping the calls hands the frame back.
- With
follow={null},aimCamera2D(context)runs the camera’s own follow where the hook wants it, such as after its own target pick.
The frame stays on the pixel grid wherever it is placed. bounds hold the follow only, so a pan keeps to its own. Move the frame only through placeCamera2D: the camera keeps its turn, zoom and frustum, and a development build throws where a hook turns a pixel-perfect lens off its plane.
Shake the screen
Section titled “Shake the screen”shakeCamera shakes a Camera2D too. The frame moves in its plane by up to shakeTexels, 4 by default, and never turns, since a turned pixel grid is the one thing a pixel game cannot show. While pixel-perfect it moves by whole texels.
Read the point under the pointer
Section titled “Read the point under the pointer”usePointerPlanePoint() returns a ref to the point on the plane under the mouse, which the frame writes after the camera moves. Read it in a view, as PointerMarker in the sample above does.
The point is null before the pointer moves, off the page, over a cropped frame’s bars, and while a devtools camera holds the view. It reads the camera as the screen shows it, shake and all, as Weapon aims. A touch reads a point only while the finger that went down on the canvas stays down, so a finger on a touch control, such as the move stick, moves none.
The player’s character faces the point when a view hands it to setPlayerAim each frame, as PointerAim in the sample does. The aim reaches the room as the character’s input, alone and in a room, as the player’s aim says. Hand it the ref’s value even when it is null, so the character stops aiming as the pointer leaves the page. For how this facing reaches a CharacterSprite, see Sprites.
Devtools
Section titled “Devtools”The devtools’ free and map cameras take the view over and give it back, and the frame snaps to the character on its return. Under the orthographic lens their wheel, pinch and forward key zoom it. The camera’s Settings leaf shows its dead zone, damping, look-ahead, zoom, shake, yaw, turn rate and aim height, to tune live.
Limits
Section titled “Limits”- Height fog over a straight-down camera hazes the whole frame evenly, since every point of the ground is one distance from the eye. The
pixellook turns fog off. - On
Plane2D.Side, a character keeps to the side plane only where its controller’splanelocks it, as Side-scrollers says. - The orbit, the high ground and the height follow work on the ground plane with a smooth lens alone, and a development build throws otherwise.
- A corner of the frame that sees past the horizon narrows no turn, so a lens that sees the sky shows past
boundsas it turns.