Command Palette

Search for a command to run...

Hectal
PHASE 11Intermediate ~22 min· topic 4 of 7

Topic 11.4

Sharp Canvas on High-DPI Screens

In one line

On a phone or a retina laptop one CSS pixel is two or three device pixels, so a canvas sized in CSS pixels is stretched and blurry. The fix is a bigger pixel grid in the same box, with the context scaled once.

Think of it like this

A photo printed on a postcard. If the photo has only 300 dots across and you print it on a poster, it looks fuzzy: the dots are stretched. A photo with 900 dots across, on the same postcard, looks sharp. The postcard did not change size; it got more dots.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

CSS pixel
The unit of CSS lengths. It is not always one dot of the screen.
device pixel
One real dot of the screen.
devicePixelRatio
Device pixels per CSS pixel: 1, 2, 3 or a fraction such as 1.25.
backing store
The canvas's pixel grid. Make it cssSize × ratio for a sharp picture.
high-DPI
A screen with more device pixels than CSS pixels: a ratio above 1.

Step by step

01Size the grid and the box separately

setupCanvas(canvas, cssWidth, cssHeight, ratio) sets canvas.width = cssWidth × ratio and canvas.style.width = cssWidth + "px". The first example logs the grid it gets for five ratios: 101 × 40 CSS pixels becomes 101 × 40, 202 × 80, 303 × 120, 152 × 60 and 126 × 50 device pixels.

02Scale the context once

After sizing, ctx.setTransform(ratio, 0, 0, ratio, 0, 0) makes one drawing unit equal one CSS pixel. All the code that draws can keep using CSS pixels: fillRect(10, 10, 100, 50) is the same on every screen.

03See why half pixels matter

At ratio 1 the example draws three 1-unit lines and prints the alpha of the rows around each: a crisp row 0, 255, 0, a blurred pair 127, 128, 0, and a crisp row from a line at 50.5. The numbers are real pixels read back from the canvas.

04Resize correctly

The second example changes only canvas.width and shows everything vanish and the scale fall to 1. Then it runs the setup and the draw again and the bar returns. Then it shows scale inside the draw function multiplying 2, 4, 8, and setTransform once keeping it at 2.

Try it yourself

  1. 1

    Skip the redraw

    In the second example, delete the draw(ctx, 320, 100); after the second setup. The last log line prints 0.

  2. 2

    Make it sharper

    In the first example change setupCanvas(canvas, 160, 60, 1) to ratio 2. The grid becomes 320 × 120 and the three alpha lines print 0, 0, 0: the drawing moved to other device rows. Read getImageData(160, 2 * y, 1, 1) instead and the line at y = 30, now two whole device rows, is crisp.

Code & diagrams

The grid for every ratio, and half-pixel lines New tab

The ratio is a parameter, because the checker's screen always has ratio 1. On your device, pass `window.devicePixelRatio`.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

ratio 1: CSS box 101 x 40 -> backing store 101 x 40
ratio 2: CSS box 101 x 40 -> backing store 202 x 80
ratio 3: CSS box 101 x 40 -> backing store 303 x 120
ratio 1.5: CSS box 101 x 40 -> backing store 152 x 60
ratio 1.25: CSS box 101 x 40 -> backing store 126 x 50
fillRect(10, 10, 140, 1), rows 9, 10, 11 -> alpha 0, 255, 0
stroke at y = 30, rows 29, 30, 31 -> alpha 127, 128, 0
stroke at y = 50.5, rows 49, 50, 51 -> alpha 0, 255, 0
Resizing erases the canvas, and scale() adds up New tab

Edge cases: a resize that erases, a scale that multiplies, and a fractional ratio.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

after setup at ratio 2: scale 2 | pixel inside a bar: 255
after canvas.width = 640: scale 1 | pixel inside a bar: 0
after setup + draw at 320 x 100: pixel inside a bar: 255
scale() in the draw function, 3 redraws: 2, 4, 8
setTransform once, 3 redraws: 2, 2, 2
ratio 1.25 on 101 px: backing store 126 -> real scale 1.2475

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Set only the grid size, not the box

In the first example's setupCanvas, delete the two canvas.style lines and call setupCanvas(canvas, 160, 60, 2) for the visible canvas.

terminal
$ In the picture
── what you'll see ──
No error. In a run, `canvas.clientWidth` and `clientHeight` read 320 and 120 (the grid size is used as the box size): the canvas is twice as big on the page, drawn pixel for pixel, and no sharper.

Myth vs fact

Myth

A bigger ratio is always better.

Fact

Memory grows with the square of the ratio: 9 times the pixels at ratio 3. Cap it.

Myth

devicePixelRatio is a constant of the device.

Fact

It changes with browser zoom and when the window moves to another screen. Listen for the change (matchMedia) or redraw on resize.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    To react to ratio changes, matchMedia("(resolution: " + dpr + "dppx)").addEventListener("change", …) fires when the ratio changes, and you re-register for the new value. Where the browser supports it, a ResizeObserver with devicePixelContentBoxSize even gives the exact device pixel size of the box.

  • ▸

    A large size times a large ratio runs into the grid limits of Topic 11.1: a 4,000 × 1,000 CSS-pixel canvas at ratio 3 is 12,000 × 3,000 device pixels, 36 million pixels and 144 MB. Some phone browsers cap the total area far below what desktop Chromium allows, so cap the ratio and the size.

Remember this

  1. 1

    The problem this solves: a canvas with width="300" is 300 grid pixels. On a screen where one CSS pixel is two device pixels, the browser stretches those 300 pixels over 600. Text and thin lines look soft. SVG is immune, because it is vectors drawn at the real resolution. A canvas must ask for the extra pixels itself.

  2. 2

    **devicePixelRatio.** window.devicePixelRatio is how many device pixels one CSS pixel covers: 1 on a plain screen, 2 on many laptops and phones, 3 on some phones, and fractional values such as 1.25 or 1.5 on Windows displays and when the user zooms the page. It can change while the page is open (zoom, or dragging the window to another screen).

  3. 3

    Two sizes, again (Topic 11.1). Make the grid cssWidth × ratio pixels wide, and keep the box at cssWidth with CSS. Then call ctx.setTransform(ratio, 0, 0, ratio, 0, 0) once, so you keep drawing in CSS pixels. The first example is a setupCanvas function that takes the ratio as a parameter and prints the size of the grid for 1, 2, 3, 1.5 and 1.25. (The checker's screen has ratio 1, so the example passes the ratio in: the real code passes window.devicePixelRatio.)

  4. 4

    Fractional ratios do not divide evenly. At 1.25, a 101-pixel box needs 126.25 pixels, and the grid is a whole number, 126. So the real scale is 126 / 101, 1.2475, not 1.25. Using canvas.width / cssWidth for the scale (as the build does) keeps the picture filling the box exactly.

  5. 5

    Half-pixel lines. A 1-unit line at y = 30 is centred on the border between two pixel rows, so it covers half of each (alpha 127 and 128): a grey blur. At y = 50.5 it covers one whole row at alpha 255: crisp. fillRect(10, 10, 140, 1) starts on a pixel edge and is crisp too. At ratio 2 whole numbers are crisp: know your ratio before you add 0.5.

  6. 6

    Resizing erases and resets. Setting canvas.width clears the picture and resets the transform (the second example shows the scale falling from 2 to 1 and the bar's pixel going from alpha 255 to 0). So a resize is always: set up again, then draw again. And never call scale inside the draw function: it multiplies on every redraw (2, 4, 8). Set the scale in setup with setTransform.

Explain it without notes

01

Why is a canvas blurry on a phone, and an SVG not?

02

What are the three steps of a sharp canvas setup?

03

Why does a resize need a redraw?

Practice

01

Write fitCanvas(canvas, ratio) that reads the box size from canvas.getBoundingClientRect() and sets up the grid for the given ratio. What must the caller do after it?

02

Explain why a 3-pixel line at y = 30 is crisp at ratio 1 only if you draw it at y = 30.5.

Trade-offs

  • ↔

    A higher ratio gives sharper drawing but costs memory and paint time with the square of the ratio. A cap (2 or 3) is a good default.

Done when you can

  • I can explain the difference between the grid size and the box size.

  • I can write setupCanvas with a ratio, and I scale the context once.

  • I know a resize erases and resets the canvas and that scale inside draw adds up.

  • I can say why fractional ratios and half-pixel lines matter.