Part VII · The canvas 14 / 25

14 The loop 14 The loop 14 The loop 14 The loop

A canvas is a flipbook: clear, draw, repeat. Move things by the clock, never by the frame.

So far every animation in this course has been handed to the browser: a transition, keyframes, a spring, a scroll timeline. You said where things should end up, and it ran the clock. From here to chapter 19 you write the clock yourself, on a <canvas>. Chapter 19 weighs when that is worth it; first you need to know how it works.

The DOM animates properties. You tell an element where to be, and the browser moves it and paints it for you. A canvas has only pixels. There is nothing on it to move, only a picture, and the picture stays exactly as you left it until you paint over it. You paint with a 2D context, canvas.getContext('2d'), and its calls: arc and fill for a circle, fillRect for a box, clearRect to wipe.

So motion on a canvas works like a flipbook. Wipe the page, draw everything where it is now, then do it again for the next page, many times a second. Nothing moves between the pages. Your eye does the moving.

Who turns the pages? You never draw on your own schedule. You give the browser a function with requestAnimationFrame(frame), and it calls frame once, just before it next paints the screen. The call carries one argument, a timestamp: milliseconds on the browser’s clock. Subtract the previous call’s timestamp and you have dt: how long it has been since the last drawing. Anything you move has to be moved by that much time, not by one fixed step. The next sections show why, and figure 14.1 shows it happening.

One motion, three screens

Screens redraw at different rates. Here are three. The top one draws half as often as the middle one, and the bottom one twice as often. In each row, two dots run the same motion, written two ways. The blue dot takes a fixed step each time its screen draws. The red dot moves by dt, how much time has passed since its screen last drew. The ticks under the red dot mark each drawing.

000/180 fr
Open in Canvas sandbox
Fig. 14.1 — The same motion at three refresh rates

Watch the dots in each column as they cross the track. Which dots agree? The red ones stay in a column. On every screen they are in the same place at the same moment; the slow screen just shows fewer drawings, so its ticks sit further apart. The blue dots split up. On the slow screen blue crawls, and on the fast one it races to the end. The code is the same in every row. Only the screen changed.

More drawings, or more speed?

Feel

Three dots cross in the same time. Which one is drawn most often? Watch how smooth each one is, not how fast.

Clear, draw, repeat

Every canvas animation is one loop, run once per frame:

  1. Clear. Erase the last picture.
  2. Update. Move each thing by the time that has passed.
  3. Draw. Paint each thing where it is now.
  4. Repeat. Ask for the next frame.

The clear matters because a canvas keeps every pixel you paint. It has no idea there was a ball; it only has the colours the ball left behind. Skip the clear and the old pictures stay.

0.12
000/360 fr 0ms
Open in Canvas sandbox
Fig. 14.2 — Old frames, partly erased

This program erases only part of the last picture before it draws the next one. Drag Clear opacity to 1: each frame starts on a blank page, and there are only three dots. Drag it down toward 0.02 and the old pictures fade slowly instead of vanishing. Nobody drew those trails. They are what was never erased. Chapter 17 uses this on purpose.

The browser sets the beat

You don’t pick the frame rate. The browser calls your frame function once per paint, so you get the screen’s own rate: every 16.7ms on a 60Hz display, every 8.3ms at 120Hz, every 6.9ms at 144Hz. To keep going, ask again from inside frame. While the tab is hidden, most browsers stop calling you at all.

Those figures are averages. Frames are not evenly timed: a frame that takes too long to build (a slow script, a garbage collection, a busy tab) makes the next one late. The timestamp is the moment the frame began, on the same clock as performance.now(). Three example frames:

FrameTimestampdtWhy
11000.0msnone yetthe first call has no previous frame
21016.7ms16.7mson time at 60Hz
31066.7ms50msa stall: two or three frames were missed

dt is that gap. It comes out in milliseconds; divide by 1000 and it is in seconds, the unit the rest of the course uses. A frame-based x += 3 moves 3 pixels on frame 3 as on frame 2, so the dot hitches: 50ms went by and it moved as if 16.7 had. x += 180 * dt moves 9 pixels on frame 3 and about 3 on frame 2. The motion stays on the clock however the frames fall.

Frames or seconds

There are two ways to write “move right”.

  • Frame-based: x += 3. Three pixels per frame. That is 180px a second at 60Hz, 360 at 120Hz, and 90 at 30Hz. The screen picks the speed.
  • Time-based: x += 180 * dt, with dt in seconds. That is 180px a second on every screen. A slow screen takes bigger steps and a fast one smaller steps, and they all arrive together.

Those are the blue and red dots of figure 14.1. Look at its middle row: at 60Hz the two agree exactly. That is why frame-based code looks right on the machine it was written on, and wrong on the next one.

Everything that changes over time needs dt: position from speed (x += vx * dt), speed from gravity (vy += gravity * dt), an angle, a fade. If a line in your update changes a value and has no dt in it, ask why. One form hides the problem well, x += (target - x) * 0.1, and chapter 15 takes it apart.

When a frame comes late

dt tells the truth, and sometimes that is a problem. Switch to another tab for ten seconds and come back. The browser stopped calling you while you were away, so the next dt is ten seconds long, and everything jumps to where it would have been by now. A ball can jump clean through a thin wall in a single step. The same thing happens on a smaller scale whenever one frame stalls.

So clamp it: dt = Math.min(dt, 1 / 15). This site’s own loop does exactly that: no step is longer than 1/15 of a second, about 67ms. After a pause, things carry on from where they left off. After a stall, the motion runs a little behind the clock. Nobody notices a lost fraction of a second. Everybody notices a teleport.

Two sizes

A canvas has two sizes. Its CSS size is how big it looks on the page, set in CSS like any element’s. Its backing store is how many pixels it actually holds, set by its width and height attributes: 300 × 150 unless you say otherwise. When the two don’t match, the browser stretches the picture to fit, and a stretched picture is blurred.

On a dense screen one CSS pixel covers several device pixels. A canvas 400 CSS pixels wide on a 2× screen needs 800 pixels of backing store to look sharp. The extra pixels are free detail, if you use them. So, whenever its size changes:

  1. Read the CSS size: canvas.clientWidth and canvas.clientHeight.
  2. Size the backing store to match, times the ratio: canvas.width = Math.floor(width * dpr), and the same for the height.
  3. Scale the drawing to fit: ctx.setTransform(dpr, 0, 0, dpr, 0, 0). Now you draw in CSS pixels, and each one lands on the right number of device pixels.

Setting width or height wipes the canvas and resets its context, transform included, so step 3 always comes after step 2. And the CSS size changes whenever the layout does, not only when the window resizes, so watch the canvas itself with a ResizeObserver.

Give the canvas a CSS size, too. Without one, a canvas is as many CSS pixels wide as its backing store has pixels. On a 2× screen, step 2 would double it; the observer would see it grow and run step 2 again, and it would double again, and again. This site’s figures also cap the ratio at 2, to limit how many pixels a wide canvas has to fill.

Change the screen

One track each. Blue moves 4 pixels a frame; red moves 240 pixels a second. The sandbox starts at a simulated 30Hz, and blue falls behind. Switch Refresh rate to 60Hz, where 4 × 60 = 240 and the two agree. Then 120Hz, where blue laps red. Then Display: your own screen. The fps readout says how often your browser is drawing, and blue’s speed now depends on it.

Last, fix blue: change s.blue += 4 to s.blue += 240 * dt. Hot reload keeps the state, so blue keeps its lead, but now it holds the same speed at every rate. Reset state lines the two up again.

L7 Canvas sandbox
Open in Lab
Start from
Refresh rate
dt 0.0ms fps 0 frames 0
00/60 fr 0ms
Per frame and per second · JS edits apply live · state kept

The notation

The whole loop, for a page of your own. The CSS sets how big the canvas looks. The script matches the backing store to it, then moves the ball by the clock.

14 · The loop: a CSS size for the canvas Open in Lab
/* The CSS size: how big the canvas looks on the page.
   The script reads it and sizes the backing store to match. */
canvas {
  display: block;   /* not inline, so no gap below it */
  width: 100%;
  height: 300px;
}

/* Without a CSS size, a canvas is as many CSS pixels wide as its
   backing store. Sizing the store for a 2× screen would double it. */
14 · The loop: a loop that survives any screen Open in Lab
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');
let width = 0;   // the CSS size, in CSS pixels
let height = 0;

// Backing store = CSS size × devicePixelRatio. Then draw in CSS pixels.
function resize() {
  const dpr = window.devicePixelRatio || 1;
  width = canvas.clientWidth;
  height = canvas.clientHeight;
  canvas.width = Math.floor(width * dpr);   // whole pixels only; resets the context...
  canvas.height = Math.floor(height * dpr);
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);   // ...so scale after it
}
resize();
new ResizeObserver(resize).observe(canvas);

const ball = { x: 0, speed: 240 };          // speed: px per second

function update(dt) {
  ball.x += ball.speed * dt;                // the same on every screen
  if (ball.x > width + 12) ball.x = -12;    // wrap once fully off (radius 12)
}

function draw() {
  ctx.clearRect(0, 0, width, height);
  ctx.fillStyle = '#ff3b1f';
  ctx.beginPath();
  ctx.arc(ball.x, height / 2, 12, 0, Math.PI * 2);
  ctx.fill();
}

let last;                                   // previous frame's time, ms

function frame(now) {                       // now: this frame's timestamp, ms
  // the first frame has no previous one, so its dt is 0
  const elapsed = last === undefined ? 0 : (now - last) / 1000;
  const dt = Math.min(elapsed, 1 / 15);     // seconds, clamped (see above)
  last = now;
  update(dt);
  draw();
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

Nothing in the loop knows the refresh rate. The ball’s speed lives in one place, in pixels per second, and dt turns it into this frame’s step. For readers who ask for reduced motion, check matchMedia('(prefers-reduced-motion: reduce)').matches: instead of running the loop, draw one still frame, and draw it again after every resize.

The loop moves things by a speed you chose. Next comes moving them toward a place: chapter 15 writes easing and smoothing as code, and finds the one line that quietly ignores dt.

Guess the duration

Time-based code thinks in seconds, not frames. Train your eye to do the same. Watch a move and guess how long it took.

Eye trainer · estimate Guess the duration

Watch the move. How long did it take?Timing: seeing 200ms as 200ms.

5 trials. Judge with your eyes first; the numbers come after. Three right in a row makes trials harder, a miss eases them.

Full rounds and your calibration in the Eye trainer