Canvas - Scaling

Adapting a canvas application to display on multiple devices with different resolutions and aspect ratios is a gnarly but necessary task. Before diving further into creating canvas art, this post designs a framework that handles scaling issues and can be found on codepen as a template.

Presenting canvas art on multiple devices usually involves one of two strategies:

  • Scale the real dimensions of the canvas (may be hard, depending on the design.) The physical dimensions of the canvas change; the position and size of the canvas elements must adapt programmatically based on the dynamic resolution and aspect ratio of the device.
  • Scale the Canvas element (challenging, depending on the range of devices.) This approach starts with a fixed canvas size; the position and size of canvas elements target this size and the canvas dimensions do no appear to change to the program, however, the canvas element itself is visually scaled used CSS to match the device resolution and aspect ratio.

Approach #1: Code for Resolution Independence

Generally this is much trickier to code for. Starting with a simple example:

var canvas = document.getElementById("canvas");
var ctx = canvas.getContext("2d");
var img = new Image();

img.src = "200ax200a.png";
img.onload = draw;

function draw() {
ctx.fillStyle = "#48160D";
ctx.fillRect(0, 0, 300, 300);

ctx.fillStyle = "#FEBF28";
ctx.fillRect(0, 0, 50, 50); // top left corner
ctx.fillRect(0, 250, 50, 50); // bottom left
ctx.fillRect(250, 0, 50, 50); // top right
ctx.fillRect(250, 250, 50, 50); // bottom left

// put a 200x200 image in the middle
ctx.drawImage(img, 50, 50);
}

on a device with screen dimensions of 1024 x 768 this code will result in this:

2015-09-26_192120

Note the page background color is a lighter red than the canvas background to demarcate what is happening. The canvas can be repositioned with CSS but here it defaults to the standard flow in HTML where elements are written from the top, left corner to the right until they wrap or extend off-screen.

To own the space on the page with a resolution independent approach every element drawn on the canvas needs to adapt:

function draw() {
ctx.fillStyle = "#48160D";
ctx.fillRect(0, 0, canvas.width, canvas.height);

ctx.fillStyle = "#FEBF28";
var newRW = 0.166 * canvas.width;
var newRH = 0.166 * canvas.height;
var newIW = 0.666 * canvas.width;
var newIH = 0.666 * canvas.height;

ctx.fillRect(0, 0, newRW, newRH); // top left corner
ctx.fillRect(0, canvas.height - newRH, newRW, newRH); // bottom left
ctx.fillRect(canvas.width - newRW, 0, newRW, newRH); // top right
ctx.fillRect(canvas.width - newRH, canvas.height - newRH, newRW, newRH); // bottom left

// put a 200x200 image in the middle and scale up
ctx.drawImage(
img,
canvas.width / 2 - newIW / 2,
canvas.height / 2 - newIH / 2,
newIW,
newIH
);
}

Now on the 1024 x 768 layout it looks like this:

2015-09-26_195214

It’s elements are skewed because it ignores the aspect ratio of the original (300x300 or 1:1)  It just took whatever ratio was needed to fill the display area using the following for its adaption:

function resizeCanvas() {
var viewport = {
// Get the dimensions of the viewport
width: window.innerWidth,
height: window.innerHeight,
};
// adjust canvas
canvas.width = viewport.width;
canvas.height = viewport.height;
draw();
}

For a best fit, preserving the original aspect ratio but filling as much of the viewport as possible a different approach is required:

function resizeCanvasAspect() {
var viewport = {
// Get the dimensions of the viewport
width: window.innerWidth,
height: window.innerHeight,
};

if (canvas.height / canvas.width > viewport.height / viewport.width) {
newHeight = viewport.height;
newWidth = (newHeight * canvas.width) / canvas.height;
} else {
newWidth = viewport.width;
newHeight = (newWidth * canvas.height) / canvas.width;
}

// adjust canvas
canvas.width = newWidth;
canvas.height = newHeight;
draw();
}

Now it stays proportional and fills the max available space with those proportions:

2015-09-26_201556

adding a little tweak to center the canvas on the page:

// center
var newX = (viewport.width - newWidth) / 2;
var newY = (viewport.height - newHeight) / 2;

// Center by setting the padding of the game
document.getElementById("canvas").style.padding = newY + "px " + newX + "px";

2015-09-26_202339

Note the CSS background color now brackets each side of the centered canvas. This is called a “letter-box” Some ways of mitigating this padding are discussed a bit later.

Approach #2: Scale the Canvas Element

The second technique, scaling the canvas, uses CSS to resize the canvas element and doesn’t require adapting any of the canvas code or recalculating coordinates. As far as the code is concerned it’s still operating in a 300x300 pixel world:

var canvas = document.getElementById("canvas");
var ctx = canvas.getContext("2d");
var img = new Image();

img.src = "200ax200a.png";
img.onload = resizeCanvas();

function draw() {
ctx.fillStyle = "#48160D";
ctx.fillRect(0, 0, 300, 300);

ctx.fillStyle = "#FEBF28";
ctx.fillRect(0, 0, 50, 50); // top left corner
ctx.fillRect(0, 250, 50, 50); // bottom left
ctx.fillRect(250, 0, 50, 50); // top right
ctx.fillRect(250, 250, 50, 50); // bottom left

// put a 200x200 image in the middle
ctx.drawImage(img, 50, 50);
}

function resizeCanvas() {
// Get the dimensions of the viewport
var viewport = {
width: window.innerWidth,
height: window.innerHeight,
};

// Determine canvas size
if (canvas.height / canvas.width > viewport.height / viewport.width) {
newHeight = viewport.height;
newWidth = (newHeight * canvas.width) / canvas.height;
} else {
newWidth = viewport.width;
newHeight = (newWidth * canvas.height) / canvas.width;
}

// Resize canvas
document.getElementById("canvas").style.width = newWidth + "px";
document.getElementById("canvas").style.height = newHeight + "px";

// center
newX = (viewport.width - newWidth) / 2;
newY = (viewport.height - newHeight) / 2;

// Center by setting the padding of the game
document.getElementById("canvas").style.padding = newY + "px " + newX + "px";

draw();
}

window.addEventListener("resize", resizeCanvas);

A CSS Transform could be use in place of modifying the style width and height above, and may prove faster as transforms are often optimized in the GPU of modern devices:

function resizeCanvas() {
var scaleX = window.innerWidth / canvas.width;
var scaleY = window.innerHeight / canvas.height;

var scaleToFit = Math.min(scaleX, scaleY);

//var scaleToCover = Math.max(scaleX, scaleY);
document.getElementById("canvas").style.transformOrigin = "0 0"; //scale from top left
document.getElementById("canvas").style.transform =
"scale(" + scaleToFit + ")";

// Center by setting the padding of the game
var newX = Math.floor(
(window.innerWidth - scaleToFit * canvas.width) / 2 / scaleToFit
);
var newY = Math.floor(
(window.innerHeight - scaleToFit * canvas.height) / 2 / scaleToFit
);
document.getElementById("canvas").style.padding = newY + "px " + newX + "px";

draw();
}

Some notes on managing letter-boxing:

Choosing a good aspect ratio helps mask the letter-box effect. Unfortunately there is no universal ratio that works best, it all depends on your target audience/devices. Popular ratios for HTML5 game development are:

  • 800x500 (1.6)
  • 960x540 (1.77…)
  • 960/640 (1.5)

Viewports

On desktop browsers it’s pretty simple, the size of the viewport is the size of the browser window. On mobiles it gets squirrelly.

A mobile device, with physical screen of 480 x 640 css pixels, for example, runs into a typical website, not optimized for mobile or responsive, which generally has widths between 800 and 1024. In order to try to display this site without zooming into a corner, the mobile will claim it has a default width of 980 (usually, though it varies by mobile device) This is the layout viewport, used to accommodate sites designed for desktops.

A second viewport, the visual viewport is different and defines the area of the layout currently visible. This viewport zooms in and out on the content without affecting the layout viewport. A third viewport, the ideal viewport, is the dimensions that the mobile device reports as optimal for a display area that doesn’t need to zoom. This viewport is defined by the mobile’s browser (i.e. different vendor’s browsers on the same mobile may have different ideal viewports) and usually matches, more or less, the physical CSS pixels (not the device pixels) that the mobile is capable of.

When a meta tag in the HTML states:

<meta name="viewport" content="width=device-width" />

it tells the mobile to use the ideal viewport size instead of the default layout size and basically means the site is mobile ready.

This will be used for the canvas html as well, but it needs some additional tweaks:

<meta
name="viewport"
content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height, target-densitydpi=device-dpi"
/>

While heretical to responsive design, where disabling user zoom is regarded as evil, it is essential for our canvas art where a double touch can result in zooming parts of the canvas off the screen.

Those are the hacks to-date, with more experiments this article may undergo further revision.

There is also a set of strategies for optimizing for high pixel densities, where 1 CSS pixel may map to 2 or more actual device pixels. Topic for a future article though.

Some additional reading and sources:

Previous Canvas - Drawing on Images Next Canvas - Transforms