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 × ratiofor 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
Skip the redraw
In the second example, delete the
draw(ctx, 320, 100);after the secondsetup. The last log line prints0. - 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 print0, 0, 0: the drawing moved to other device rows. ReadgetImageData(160, 2 * y, 1, 1)instead and the line aty = 30, now two whole device rows, is crisp.
Code & diagrams
The ratio is a parameter, because the checker's screen always has ratio 1. On your device, pass `window.devicePixelRatio`.
Preview
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, 0Edge cases: a resize that erases, a scale that multiplies, and a fractional ratio.
Preview
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.2475Break 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.
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, aResizeObserverwithdevicePixelContentBoxSizeeven 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
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
**
devicePixelRatio.**window.devicePixelRatiois 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
Two sizes, again (Topic 11.1). Make the grid
cssWidth × ratiopixels wide, and keep the box atcssWidthwith CSS. Then callctx.setTransform(ratio, 0, 0, ratio, 0, 0)once, so you keep drawing in CSS pixels. The first example is asetupCanvasfunction 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 passeswindow.devicePixelRatio.) - 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. Usingcanvas.width / cssWidthfor the scale (as the build does) keeps the picture filling the box exactly. - 5
Half-pixel lines. A 1-unit line at
y = 30is centred on the border between two pixel rows, so it covers half of each (alpha 127 and 128): a grey blur. Aty = 50.5it 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
Resizing erases and resets. Setting
canvas.widthclears 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 callscaleinside the draw function: it multiplies on every redraw (2, 4, 8). Set the scale in setup withsetTransform.
Explain it without notes
Why is a canvas blurry on a phone, and an SVG not?
What are the three steps of a sharp canvas setup?
Why does a resize need a redraw?
Practice
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?
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
setupCanvaswith a ratio, and I scale the context once.I know a resize erases and resets the canvas and that
scaleinside draw adds up.I can say why fractional ratios and half-pixel lines matter.