The first build of Vector Storm was sharp on my monitor and soft on the laptop beside it. Same file, same fonts, same line widths. The monitor reports devicePixelRatio 1 and the laptop reports 1.25, and the canvas was declared 960 by 600 on both. Identical drawing commands, a different number of device pixels to spread them across.
There are two sizes in play. One is the CSS box the canvas occupies in the layout. The other is the bitmap it actually holds, set by the width and height attributes. Nothing forces them to agree, and by default they do not: hand the browser a 960 by 600 bitmap and a box covering more device pixels than that, and it stretches the bitmap to fill, with the same interpolation a photograph gets when it is enlarged. That upscale is the blur.
MDN defines the property as the ratio of the resolution in physical pixels to the resolution in CSS pixels for the current display, so one CSS pixel covers that many device pixels. It is 1 on a classic 96 DPI screen, 2 on a Retina laptop, and often above 2 on a phone. Two assumptions were mine: that it was always 1, and that it was always a whole number.
A soft canvas is a resolution mismatch, not a rendering bug
The tell is that text and hairlines go soft while large rectangles stay solid. A wide rectangle loses a fraction of its edge to the upscale and nobody notices. A one-pixel line loses its whole edge, and a glyph loses the whole stem to interpolation.
The second symptom costs more in play: a collision box that reads as slightly unfair. Coordinates are CSS pixels in the layout and device pixels in the bitmap, and every conversion between them is a chance to be off by half a pixel. Making the two sizes agree removes that category of near-miss entirely.
Rule out the other blur first, though. A canvas scaled by a CSS transform, or one stretched by a flex row, is soft for an unrelated reason and no amount of backing-store sizing will touch it.
Four operations that make the two sizes agree
Read the box with getBoundingClientRect(), multiply both dimensions by the ratio, write the products into the width and height attributes, and set the transform once afterwards. That is the whole fix, and here is how it reads in the games on this site, Brick Buster included: var dpr = Math.min(window.devicePixelRatio || 1, 2); canvas.width = Math.round(W * dpr); canvas.height = Math.round(H * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
Every coordinate in the game still means a CSS pixel, so a fillRect(10, 10, 300, 300) covers the same visible area it covered before, sampled at more points. The Math.min clamp is the interesting part of the helper and it is a budget decision rather than a rendering one, which is what the next section is about.
The order of those two steps is not cosmetic. Assigning to width resets the entire rendering context: the backing buffer is cleared, the current path is dropped, and fillStyle, globalCompositeOperation and the transform all go back to their defaults. The reset happens even when the value written is the one already in place, so a resize handler that writes canvas.width on every frame throws away its own transform and quietly slides back to CSS pixels. Attributes first, transform second.
setTransform rather than scale is deliberate for the same reason. scale multiplies whatever transform is already there, so calling it twice gives a ratio of four and a canvas drawn at double size inside its own box.
What the extra samples cost
A canvas holds four bytes per pixel, one each for red, green, blue and alpha, and the count grows in both directions at once. Hold the CSS box at 800 by 450 and raise only the ratio, and the arithmetic falls out like this.
| devicePixelRatio | Backing store | Bytes held as RGBA | Fill work against ratio 1 |
|---|---|---|---|
| 1 | 800 x 450 | 1,440,000 | 1.00x |
| 1.25 | 1000 x 563 | 2,252,000 | 1.56x |
| 2 | 1600 x 900 | 5,760,000 | 4.00x |
| 3 | 2400 x 1350 | 12,960,000 | 9.00x |
The dimensions are the CSS size times the ratio, rounded to the nearest whole pixel; the bytes are the pixel count times four; the last column is that pixel count divided by the 360,000 at ratio 1. None of it is a benchmark from my machine and none of it needs to be, because the 4 in the ratio 2 row is just 2 times 2.
That last column also explains the cap at 2 in the helper above. A phone reporting 3 asks for nine times the fill work of a ratio 1 screen, and the third multiple of the ratio is the one that buys the least. Spending it inside a 16.6 millisecond frame is a worse trade than spending the second multiple, which is where the clamp comes from.
Fractional ratios put lines between pixels
125 percent and 150 percent display scaling are Windows defaults on a lot of laptops, and that is where ratios of 1.25 and 1.5 come from. Sizing still works, but a second problem arrives: a CSS coordinate no longer lands on a device pixel. A vertical line at x = 10.3 sits at 12.875 device pixels when the ratio is 1.25, and the browser anti-aliases it across two columns. The line changes weight as it moves, which is harder to look at than a uniformly soft line.
MDN's canvas performance notes recommend rounding drawing coordinates instead of passing fractions, and keeping drawImage on whole numbers for the same reason. Rounding is not a correctness fix. It is the difference between one crisp edge and two grey ones, and on a fractional display it is the only way to get either.
The ratio changes while the game is running
Dragging a window from a Retina laptop onto a 1080p monitor changes the ratio mid-session. Zooming the page does as well, and MDN is explicit about it: a zoomed page has bigger CSS pixels, so the ratio climbs. Pinch-zoom is the exception, because it magnifies without resizing the CSS pixel. A canvas fitted once at startup keeps the bitmap it was born with, and after the window moves it is soft in one direction and wasteful in the other.
There is no change event for the ratio. The documented way to notice is a media query on resolution, matchMedia('(resolution: ' + ratio + 'dppx)'), with a change listener that re-fits the canvas and then builds the query again, because the query string encodes the old ratio and a stale query never fires twice. A 120 millisecond debounce in front of that resize path is enough to stop a dragged window from rebuilding the backing store on every event.
Pros and cons
Pros
- One
setTransformkeeps collision boxes, HUD positions and spawn points in the CSS pixel space the layout already uses. - The ratio belongs to the display, so a single file is crisp on a phone and untouched on a 1080p monitor.
- Rounding coordinates to cope with a fractional ratio doubles as an ordinary anti-aliasing fix at every ratio.
- Capping the ratio bounds the memory and fill work on a screen that reports more than 2.
Cons
- Memory and fill work scale with the square of the ratio, so 3 costs nine times 1 for the same visible area.
- Writing
canvas.widthwipes the transform and the whole context state, so the fit path has to reapply both in the right order. - There is no ratio change event, only a resolution media query that has to be rebuilt each time it fires.
- A game that draws nothing but whole-pixel rectangles gains almost nothing and pays the same memory bill.
Sources
- MDN: Window.devicePixelRatio — the definition of the ratio, the values 1 and 2, the page-zoom behaviour, and the scaling example the helper above follows.
- MDN: HTMLCanvasElement.width — that assigning the attribute resets the entire rendering context, transform included.
- MDN: Optimizing canvas — the high-DPI sizing snippet, and the advice to round drawImage coordinates and batch primitives.