ARTICLES

VIDEOS

BOOKS

FORUM

Ensuring our Canvas Looks Good on Retina/High-DPI Screens

by kirupa   |   filed under Working with the Canvas

If you thought that dealing with blurry visuals related to high-DPI screens don't apply to the canvas, you are in for a treat. The pixels we draw using the canvas have a lot of things in common with our traditional raster image formats like PNGs and JPEGs. These common things also include any default allergic reactions to looking crisp and sharp on high-DPI screens:

In this short article, we'll look at how to ensure the pixels we draw using the canvas look their best on both old-school screens with low-DPIs and awesome newfangled screens with a boatload of DPI.

Onwards!

Downsampling to the Rescue!

The problem is this. When we draw using the canvas, what we draw is usually created with a default DPI of 72. That is great for a lot of screens out there. When viewed on a high-DPI screen like those found in our phones or Retina displays or 4k/5k/8k screens, displaying our 72DPI visual at the intended size requires more pixels than what the original image is made up of. To make up for these missing pixels, our browsers tend to automatically fix things by doing the equivalent of scaling the image up:

As we know from scaling up raster images like PNGs and JPEGs, doing this will result in our image appearing blurry. This is true with visuals generated using the canvas as well, since (to our browsers at least) what we generate on the canvas is the same as taking an image and plopping it on the screen. How do we solve this? By relying on the classic tried-and-true approach called downsampling. We will generate our canvas visuals at a larger size (one that matches the expected higher DPI) and then scaling our canvas down to ensure they appear physically at the size we intend for it to.

Downsampling on the Canvas

The way we downsample on the canvas and ensure crisp visuals on high-DPI screens is by doing three things:

  1. Figure out the amount we need to scale our visuals by to match the intended device's DPI setting
  2. Physically increase the size of our canvas by the scale amount and ensure all drawing operations happen at this larger size
  3. Use CSS to scale/squish down the size of our larger sized canvas back to its original, smaller size

When we look at three steps in code, things will make more sense. To start things off, take a look at the following example:

<!DOCTYPE html>
<html>

<head>
  <title>Circle Time</title>
  <style>
    canvas {
      border: #333 10px solid;
    }

    body {
      padding: 50px;
    }
  </style>
</head>

<body>
  <canvas id="myCanvas" width="550px" height="350px"></canvas>

  <script>
    let canvas = document.querySelector("#myCanvas");
    let context = canvas.getContext("2d");

    function draw() {
      // draw the colored region
      context.beginPath();
      context.arc(200, 200, 93, 0, 2 * Math.PI, true);
      context.fillStyle = "#E2FFC6";
      context.fill();

      // draw the stroke
      context.lineWidth = 20;
      context.strokeStyle = "#66CC01";
      context.stroke();
    }
    draw();
  </script>

</body>

</html>

If you wish to actively follow along, create a new HTML document and copy/paste everything you see above into it. When we preview this page in our browser, what we'll see will be something as follows:

A green circle should appear. Currently, what we are drawing isn't high-DPI friendly and will look blurry on a high-DPI device. We are going to fix that. Add (or just take a look if you are passively following along) the following highlighted lines to our current canvas code:

let canvas = document.querySelector("#myCanvas");
let context = canvas.getContext("2d");

// get current size of the canvas
let rect = canvas.getBoundingClientRect();

// increase the actual size of our canvas
canvas.width = rect.width * devicePixelRatio;
canvas.height = rect.height * devicePixelRatio;

// ensure all drawing operations are scaled
context.scale(devicePixelRatio, devicePixelRatio);

// scale everything down using CSS
canvas.style.width = rect.width + 'px';
canvas.style.height = rect.height + 'px';

function draw() {
  // draw the colored region
  context.beginPath();
  context.arc(200, 200, 93, 0, 2 * Math.PI, true);
  context.fillStyle = "#E2FFC6";
  context.fill();

  // draw the stroke
  context.lineWidth = 20;
  context.strokeStyle = "#66CC01";
  context.stroke();
}
draw();

When this code gets added, what we will see on our screen will look as follows:

Yeah...it looks similar to what we had earlier, right? The biggest difference is that what we see on screen now is DPI-aware and looks sharp with no blurriness.

The Code Explained

The way our code accomplishes the downsampling is by relying on a few handy techniques.

First, we use getBoundingClientRect to get us the rendered dimensions of our canvas element:

let rect = canvas.getBoundingClientRect();

Next, we use the global devicePixelRatio property to have the browser tell us the factor to scale everything by. With this factor, we resize our current canvas width and height accordingly:

canvas.width = rect.width * devicePixelRatio;
canvas.height = rect.height * devicePixelRatio;

On a regular-DPI device, the value returned by devicePixelRatio will be 1 and this code will not cause any resizing. On high-DPI devices, the value returned by devicePixelRatio will be greater than 1. When that happens, our canvas will physically be drawn larger:

When we increase the size of our canvas in the way we did, our canvas's internal drawing operations still assume that everything is the original size that we started off with. To ensure our drawing operations take into account the new size, we use the canvas's scale method to set things right:

context.scale(devicePixelRatio, devicePixelRatio);

At this point, on a high-DPI device, our canvas is physically larger. All of the canvas drawing operations have been scaled to thrive in this physically larger world. We don't want a larger canvas...at least not when we view it on our screen. We want our canvas to take up the same amount of space that we started off with. The way we do that is not by resizing our canvas back down. Instead, we perform a visual trick by using CSS to shrink our canvas down to the size we intend for it to be:

canvas.style.width = rect.width + 'px';
canvas.style.height = rect.height + 'px';

In case this seems a bit confusing, the thing to remember is that there are two size changes going on:

  1. One size change occurs when we alter our canvas element's width and height.
  2. The other change is where we set the CSS width and height of our canvas element.

These two sets of widths and heights are visually similar but functionally different. When they combine forces, they help create the downsampling effect we need to ensure the visuals generated by our canvas are high-DPI aware.

Conclusion

It used to be that we could just make things simple and hard code a scaling factor of 2 to perform the downsampling. The challenge is that the amount of pixels being thrown on newer screens is so large, that scaling our canvas by even 2 is no longer enough. Sometimes we have to scale by 3 or 4, and by the time you are reading this, it could even be higher. You shouldn't hard code a higher scaling value just for the sake of futureproofing either. With the added pixels on high-DPI devices, the amount of CPU and memory demands our canvas will make will also go up. That is why relying on devicePixelRatio and letting the code handle the scaling correctly is the right way to go. The code we saw here will ensure our visuals are scaled up by the exact amount needed to look crisp and sharp - nothing more, nothing less.

Got a question or just want to chat? Comment below or drop by our forums (they are actually the same thing!) where a bunch of the friendliest people you'll ever run into will be happy to help you out!

When Kirupa isn’t busy writing about himself in 3rd person, he is practicing social distancing…even on his Twitter, Facebook, and LinkedIn profiles.

Hit Subscribe to get cool tips, tricks, selfies, and more personally hand-delivered to your inbox.

COMMENTS

Serving you freshly baked content since 1998!
Killer hosting by (mt) mediatemple

Twitter Youtube Facebook Pinterest Instagram Github