Chapter 6: Cameras and viewports
How to extend your game world beyond the bounds of the canvas.
Screen space and world space
So far whenever we've called a function like fillRect(x, y, width, height), those x/y coordinates have been in screen space, where x starts at 0 from the left and y starts at 0 from the top of the canvas.
Wait, shouldn't that be ‘canvas space’ instead of ‘screen space’?
Arguably, yes! But in keeping with how it's often used in game engine parlance, "screen space" usually does not mean the whole monitor. Instead it commonly refers to the coordinates of the render target, which might be the whole monitor for a fullscreen game, or in our case, just the DPI-scaled canvas element. So wherever we say "screen space" in this chapter, know that we're really working in "canvas space".
Working directly in screen-space coordinates means that depending on your canvas size, more or less of the game world will be visible at any moment. This creates issues with fairness and consistency, and can be totally game-breaking. Imagine not being able to see half your board because you're on a small screen!
These demos are resizable! Drag the resize handles to see how they compare.
Using screen-space pixel coordinates
for (let = 0; < 8; ++) {
for (let = 0; < 8; ++) {
const = 40; // every tile is 40px x 40px
const = * ; // and every coordinate needs to know this size
const = * ;
. = ( + ) % 2 === 0 ? "white" : "black";
.(, , , );
}
}To make our chess game work consistently on all canvas sizes, we'll make a couple of changes:
- Define an 8x8 grid for our world space.
- Scale the canvas so that the grid fills either the full vertical or horizontal space available, whichever threshold is met first.
How? Like this:
Using world-space coordinates
function (
: CanvasRenderingContext2D,
: DOMRect, // canvas' boundingClientRect
: () => void,
) {
const = .(., .); // the board is square, so constrain it by the shorter side of the canvas
const = / 8; // we'll scale up by this factor since it's an 8x8 board
.();
.(, );
(); // anything in this callback is now scaled to "chessboard units"
.();
}(, .(), () => {
// in here, x=4 y=2 means "file 4, rank 2" in chessboard world space.
// we're no longer thinking in pixels!
for (let = 0; < 8; ++) {
for (let = 0; < 8; ++) {
. = ( + ) % 2 === 0 ? "white" : "black";
.(, , 1, 1);
}
}
});Now we have two coordinate systems to draw into, and it's trivial to swap between them. Anything outside the inChessWorldSpace callback is in screen space and anything inside of the callback is in world space, a new coordinate system we've created specifically for our game to make it easy to lay things out.
Let's also center the board and add some margins.
function inChessWorldSpace(
ctx: CanvasRenderingContext2D,
width: number,
height: number,
draw: () => void,
) {
const margin = 16;
const size = Math.min(width, height) - margin * 2;
const scale = size / 8;
ctx.save();
ctx.translate((width - size) / 2, (height - size) / 2);
ctx.scale(scale, scale);
draw();
ctx.restore();
}A square 8 x 8 world is an extremely simple example, so let's apply this concept to another game, this time using a more standard 16:9 aspect ratio. We'll build pong within a 64 x 36 unit game world, giving our paddles and ball a width of 1, but that choice of world size is totally arbitrary and we could have just as easily used 1920 x 1080 or any other 16:9 area.
Letterboxing a 16:9 game with optional margins is common enough to justify making a small helper function. This is the same math we used in inChessWorldSpace but applied now to two dimensions since our game world is no longer a square.
function (
: { : number; : number },
: { : number; : number },
= 0,
) {
const = .(
(. - * 2) / .,
(. - * 2) / .,
);
return {
,
: (. - . * ) / 2,
: (. - . * ) / 2,
};
}function (
: CanvasRenderingContext2D,
: { : number; : number },
: { : number; : number },
: () => void,
) {
const = 16;
const { , , } = (, , );
.();
.(, );
.(, );
();
.();
}All our game logic and draw code can now operate within a 64 x 36 world space, no matter what size the canvas element actually is.
const = .();
const = { : 64, : 36 };
const = {
: { : 20, : 14 },
: { : 10 },
: { : 20 },
};
function () {
.(0, 0, ., .);
(, , , () => {
. = "#13131e";
.(0, 0, ., .);
const = 4;
const = 1;
(, {
: ,
: ..,
});
(, {
: . - - ,
: ..,
});
(, .);
});
}Screen shake
With this world space coordinate conversion in place, look how easily we can add support for screen shake to our pong game. (We'll do a deep dive on this in Chapter 10 →, this is just a sneak peek.)
function (
: CanvasRenderingContext2D,
: { : number; : number },
: { : number; : number },
: { : number; : number },
: () => void,
) {
const = 16;
const { , , } = (, , );
.();
.(, );
.(, );
.(., .);
();
.();
}In-game cameras
Pong and chess both display the entire game world on screen at once, but what do we do when the game world is much larger than the portion we want to display? For that, we need an in-game camera.
Click to focus. WASD / arrows to move. Space to toggle camera.
Pixel art car created by @MinZiin_
To start we'll create a state object for our camera.
const state = {
camera: {
x: 0,
y: 0,
zoom: 1,
},
};Then to use it...
function (
: CanvasRenderingContext2D,
: { : number; : number },
: { : number; : number; : number },
: () => void,
) {
.();
// start at the center
.(. / 2, . / 2);
// scale to the camera's zoom level
.(., .);
// translate to the camera's position
.(-., -.);
();
.();
}function () {
(, , , () => {
(, , ., () => {
();
(, .);
});
(, ..);
});
}Now we have two primitives to work with. inWorldSpace puts everything into game world units allowing us to draw in world space. inCamera then pans and zooms within our world space keeping our view within the camera. And anything drawn outside of those two functions will remain in screen space.
Right now there is a potential issue with our camera for some games. You can currently see more or less of the game world at different aspect ratios. (Try resizing the previous demo!) If you want to prevent players from seeing anything outside of the 16:9 letterboxed camera, using .clip() makes that possible.
Click to focus. WASD / arrows to move. Space to toggle camera.
function (
: CanvasRenderingContext2D,
: { : number; : number },
: { : number; : number; : number },
: () => void,
) {
.();
.();
.(0, 0, ., .);
.();
.(. / 2, . / 2);
.(., .);
.(-., -.);
();
.();
}Screen, world, and camera
The main takeaway I'd like to leave you with is that the camera is really just a set of transforms. First, we can make our lives easier by setting our game units to something that makes sense for our specific game world (world space). And then we can pan and zoom around that world with basic transforms in our camera. Everything outside of these callbacks will continue to be in screen space, which is made up of the DPI-scaled pixels within our <canvas>.
There are many ways to take this from here. A few ideas to get you thinking...
- Instead of always snapping the camera right to the player, try using an exponential follow (see Chapter 4) so that the camera lags slightly behind the player's position.
- Offset the camera's
xandywhenever there's an impact in your game to add screen shake. - Don't render things that are a certain distance outside of the camera.
- Add rotations to the set of transforms done in-camera.
- Think about how you'd convert a mouse event's
x/y(window-space) coordinates into world-space or in-camera coordinates so you can let players click/hover/drag in-game items. (We'll answer this in the next chapter →.)
And just to recap, our final car/racetrack code now looks something like this:
function () {
// 1. in screen space (raw canvas pixels)
.(0, 0, ., .);
(, , , () => {
(, , ., () => {
// 2. the camera-viewed world
();
(, .);
});
// 3. in world-space units w/ letterboxing, but floating outside of the camera
(, ..);
});
// 4. back in screen space (usually FPS/debug ui)
if (import.meta..) {
(, );
}
}Up next
Our camera demo had the player moving around inside of the game world using key presses. We'll cover how to react to these inputs in Chapter 7 → where we wire browser events into the game loop.