ates.dev

Responsive Canvas Rendering

Posted on Dec 08, 2024

In web development, 'responsive' typically refers to layout. Adjusting the layout of a page based on the dimensions of the renderable area. A more formal definition from Wikipedia:

Responsive web design (RWD) or responsive design is an approach to web design that aims to make web pages render well on a variety of devices and window or screen sizes from minimum to maximum display size to ensure usability and satisfaction.

Here, I will share a few key techniques I always apply when rendering pixels on a <canvas> element, while ensuring the rendering properly responds to canvas size and Device Pixel Ratio (DPR).

This is not a comprehensive tutorial that aims to teach on canvas rendering from scratch. Code excerpts are partial, only highlighting key changes from the previous example. Knowledge of JavaScript and some algebra is assumed.

Let's start with a default canvas:

<canvas></canvas>

By default, the canvas is a transparent rectangle measuring 300x150 pixels (spec). I have added a border around it to clearly define its size and position on the page.

Now, let's fill it black and then render a 100x100 white square in its middle:

<canvas id="canvas"></canvas>
const canvas = document.querySelector("#canvas");
const ctx = canvas.getContext("2d");

ctx.fillRect(0, 0, canvas.width, canvas.height);

const SIZE = 100;

ctx.fillStyle = "white";
ctx.fillRect(
  canvas.width / 2 - SIZE / 2,
  canvas.height / 2 - SIZE / 2,
  SIZE,
  SIZE
);

Now let's stretch the canvas to 100% of the width of the container:

canvas {
  width: 100%;
  height: 150px;
}

Why is our square no longer square? Because we resized the canvas with CSS and didn't attach width and height attributes to the <canvas> tag, the intrinsic dimensions of the canvas are still the defaults, 300x150.

We can fix this by setting the width and height properties of the canvas element to its pixel dimensions:

const {width, height} = canvas.getBoundingClientRect();

canvas.width = width;
canvas.height = height;

Next, let's switch to rendering a circle to achieve anti-aliased edges. And let's also print "1.0", and render a reference 20x20 square, both of which will become significant soon.

const RADIUS = 50;

ctx.beginPath();
ctx.arc(canvas.width / 2, canvas.height / 2, RADIUS, 0, Math.PI * 2);
ctx.fill();

ctx.font = '2em monospace';
ctx.fillText('1.0', 10, 35);

// 20x20 pixel reference square
ctx.fillRect(10, 50, 20, 20);

The CSS dimensions we set on the canvas are the logical pixel dimensions. Your browser's DPR (Device Pixel Ratio) is a multiplier that determines the physical pixel density of your screen. For example, if your DPR is 2.0, then for every logical pixel, there are 4 physical pixels (2x2). To render this circle in the crispiest way possible we apply the DPR as a multiplier to the canvas dimensions. We'll also print your actual DPR instead of the "1.0" we hard-coded earlier:

const dpr = window.devicePixelRatio;
const {width, height} = canvas.getBoundingClientRect();

canvas.width = width * dpr;
canvas.height = height * dpr;

But then, we also have to start factoring in DPR in all size units when using the drawing primitives:

// Adjust for DPR
const radius = RADIUS * dpr;

ctx.beginPath();
ctx.arc(
  canvas.width / 2,
  canvas.height / 2,
  radius,
  0,
  Math.PI * 2
);
ctx.fill();

// Display DPR
ctx.font = '2em monospace';
ctx.fillText(dpr.toFixed(2), 10, 35);

If your DPR is greater than 1.0, you should see the 20x20 pixel reference square rendered as something smaller than 20x20 while our circle remains the same size. And the anti-aliased edges of the circle should now look as crisp as possible on your screen (without getting into subpixel rendering).

In case your DPR is 1.0, and you don't see a difference above, here's what the comparison would have approximately looked like against a DPR of 2.0:

In the examples so far we've used hard-coded dimensions for the elements because we knew the canvas height was always 150px. A square 100px across or a circle 50px in radius would nicely fit.

Defining the scene in proportions, such as a fraction of the canvas height, simplifies dynamic layouts. So, let's switch to rendering our circle with a radius of 1/3 of the canvas height:

const radius = canvas.height / 3;

ctx.fillStyle = 'white';
ctx.beginPath();
ctx.arc(
  canvas.width / 2,
  canvas.height / 2,
  radius,
  0,
  Math.PI * 2
);
ctx.fill();

Typically, a canvas will be set up to be dynamically resized to fit its container. After rendering the circle once, let's start changing the width of the canvas container:

As the container width changes, the circle stretches into an ellipse because the canvas pixels are resampled to fit the new dimensions.

One way to address this is to set up a rendering loop to keep adjusting the intrinsic dimensions of the canvas and rendering the scene every frame. This way, the entire scene will always be rendered at the correct proportions. We'll use a requestAnimationFrame (RAF) loop for this:

function draw() {
  // Request the next frame before we draw this one
  requestAnimationFrame(draw);

  const dpr = window.devicePixelRatio;
  const {width, height} = canvas.getBoundingClientRect();

  canvas.width = width * dpr;
  canvas.height = height * dpr;

  ctx.fillStyle = 'black';
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  const radius = canvas.height / 6;

  ctx.fillStyle = 'white';
  ctx.beginPath();
  ctx.arc(
    canvas.width / 2,
    canvas.height / 2,
    radius,
    0,
    Math.PI * 2
  );
  ctx.fill();
}

// Kick off the loop
requestAnimationFrame(draw);

The circle remains a circle now.

Instead of rendering a static scene every frame, another option is to preserve the aspect ratio of the canvas so that the scene doesn't stretch or squash. We can render the canvas once and let CSS do its thing:

<div class="container">
  <canvas></canvas>
</div>
/* How this container itself fits into the layout is up to you */
.container {
  aspect-ratio: 300 / 150;
}

canvas {
  /* Alternatively, use Flexbox */
  width: 100%;
  height: 100%;
}

The circle remains a circle.

However, the ugly truth is that you will lose all the crispy rendering you got at the start by updating the intrinsic dimensions of the canvas with DPR in mind. Even when initially rendering big and then scaling down, resampling can cause undesirable artifacts. And initially rendering small and then scaling up will create blurry results:

We can watch for the resizing of the canvas and redraw the scene on a debounce. This approach allows resampling during resizing but ensures accurate proportions once resizing stops.

Let's do this efficiently. To watch for resizing, we'll use the ResizeObserver browser API:

// One could always use Lodash's debounce
function tailDebounce(fn, delay) {
  let timer;

  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), delay);
  };
}

let contentRect = null;

const draw = () => {
  // Always draw when the time is right
  requestAnimationFrame(() => {
    const dpr = window.devicePixelRatio;
    const rect = contentRect || canvas.getBoundingClientRect();
    const {width, height} = rect;

    canvas.width = width * dpr;
    canvas.height = height * dpr;

    ctx.fillStyle = 'black';
    ctx.fillRect(0, 0, canvas.width, canvas.height);

    const radius = canvas.height / 3;

    ctx.fillStyle = 'white';
    ctx.beginPath();
    ctx.arc(
      canvas.width / 2,
      canvas.height / 2,
      radius,
      0,
      Math.PI * 2
    );
    ctx.fill();
  });
};

const debouncedDraw = tailDebounce(draw, 100);

const resizeObserver = new ResizeObserver((entries) => {
  for (const entry of entries) {
    if (entry.target === canvas && entry.contentRect) {
      contentRect = entry.contentRect;
      debouncedDraw();
    }
  }
});

draw();
resizeObserver.observe(canvas);

If you look closely, especially when scaling up, you can see the scene being resampled, creating a blurry edge around the circle. When the resizing stops for at least 100ms, the scene is redrawn and the crispiness is restored.

Here's a hollow circle with a 3px border, with the text "circle" in the middle:

ctx.lineWidth = 3;

ctx.strokeStyle = 'white';
ctx.beginPath();
ctx.arc(
  canvas.width / 2,
  canvas.height / 2,
  radius,
  0,
  Math.PI * 2
);
ctx.stroke();

const fontSize = 1 * dpr;
const fontFace = 'Varela Round';

ctx.fillStyle = 'white';
ctx.font = `${fontSize}em ${fontFace}`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('circle', canvas.width / 2, canvas.height / 2);

The stroke width is not affected by the intrinsic resolution of the canvas. When you set the stroke width to 3, it will always be 3 logical pixels wide. Font size, however, needs to account for DPR adjustments.

Here's the same scene without the DPR adjustment:

If your DPR is greater than 1, the border should still appear more or less the same thickness as the previous one, but blurrier.

Solid shapes, images, and text should consider DPR for clarity. Strokes not so.

Discourse

Comments, questions, corrections? Please drop them on Bluesky!