I have some canvases that I want to be displayed pixel-perfect in every (modern) browser. By default, devices with high-DPI screens are scaling my page so that everything looks the right size, but it's breaking* the appearance of my canvases.
How can I ensure that one pixel in my canvas = one pixel on the screen? Preferably this wouldn't affect other elements on the page, since I still want e.g. text to be scaled appropriately for the device.
I already tried styling the canvas dimensions based on window.devicePixelRatio
. That makes the canvases the right size but the contents look even worse. I'm guessing that just scales them down after they're already scaled up incorrectly.
*If you care, because the canvases use dithering and the browsers are doing some kind of lerp rather than nearest-neighbor
The currently marked answer is wrong
There is no way to get a pixel perfect canvas on all devices across all browsers in 2022.
You might think it's working using
canvas {
image-rendering: pixelated;
image-rendering: crisp-edges;
}
But all you have to do to see it fail is press Zoom-In/Zoom-Out. Zooming is not the only place it will fail. Many devices and many OSes have fractional devicePixelRatio.
In other words. Let's say you make a 100x100 canvas (enlarged).
The user's devicePixelRatio is 1.25 (which is what my Desktop PC is). The canvas will then display at 125x125 pixels and your 100x100 pixel canvas will get scaled to 125x125 with nearest neighbor filtering (image-rendering: pixelated) and look really crappy as some pixels are 1x1 pixel big and others 2x2 pixels big.
So no, using
image-rendering: pixelated;
image-rendering: crisp-edges;
by itself is not a solution.
Worse. The browser works in fractional sizes but the device does not. Example:
let totalWidth = 0;
let totalDeviceWidth = 0;
document.querySelectorAll(".split>*").forEach(elem => {
const rect = elem.getBoundingClientRect();
const width = rect.width;
const deviceWidth = width * devicePixelRatio;
totalWidth += width;
totalDeviceWidth += deviceWidth;
log(`${elem.className.padEnd(6)}: width = ${width}, deviceWidth: ${deviceWidth}`);
});
const elem = document.querySelector('.split');
const rect = elem.getBoundingClientRect();
const width = rect.width;
const deviceWidth = width * devicePixelRatio;
log(`
totalWidth : ${totalWidth}
totalDeviceWidth: ${totalDeviceWidth}
elemWidth : ${width}
elemDeviceWidth : ${deviceWidth}`);
function log(...args) {
const elem = document.createElement('pre');
elem.textContent = args.join(' ');
document.body.appendChild(elem);
}
.split {
width: 299px;
display: flex;
}
.split>* {
flex: 1 1 auto;
height: 50px;
}
.left {
background: pink;
}
.middle {
background: lightgreen;
}
.right {
background: lightblue;
}
pre { margin: 0; }
<div class="split">
<div class="left"></div>
<div class="middle"></div>
<div class="right"></div>
</div>
The sample above there is 299 CSS pixel wide div and inside there are 3 child divs each taking 1/3 of their parent. Asking for sizes by calling getBoundingClientRect
on my MacBook Pro in Chrome 102 I get
left : width = 99.6640625, deviceWidth: 199.328125
middle: width = 99.6640625, deviceWidth: 199.328125
right : width = 99.6640625, deviceWidth: 199.328125
totalWidth : 298.9921875
totalDeviceWidth: 597.984375
elemWidth : 299
elemDeviceWidth : 598
Add them up and you might see a problem. According to getBoundingClientRect
each one is about 1/3 width in device pixels (199.328125). You can't actually have 0.328125 device pixels so they'll need to be converted to integers. Let's use Math.round
so they all become 199.
199 + 199 + 199 = 597
But according to the browser the size of the parent is 598 device pixels big. Where is the missing pixel?
Let's ask
const observer = new ResizeObserver(entries => {
for (const entry of entries) {
let good = false;
if (entry.devicePixelContentBoxSize) {
// NOTE: Only this path gives the correct answer
// The other paths are imperfect fallbacks
// for browsers that don't provide anyway to do this
width = entry.devicePixelContentBoxSize[0].inlineSize;
height = entry.devicePixelContentBoxSize[0].blockSize;
good = true;
} else {
if (entry.contentBoxSize) {
if (entry.contentBoxSize[0]) {
width = entry.contentBoxSize[0].inlineSize;
height = entry.contentBoxSize[0].blockSize;
} else {
width = entry.contentBoxSize.inlineSize;
height = entry.contentBoxSize.blockSize;
}
} else {
width = entry.contentRect.width;
height = entry.contentRect.height;
}
width *= devicePixelRatio;
height *= devicePixelRatio;
}
log(`${entry.target.className.padEnd(6)}: width = ${width} measure = ${good ? "good" : "bad (not accurate)"}`);
}
});
document.querySelectorAll('.split>*').forEach(elem => {
observer.observe(elem);
});
function log(...args) {
const elem = document.createElement('pre');
elem.textContent = args.join(' ');
document.body.appendChild(elem);
}
.split {
width: 299px;
display: flex;
}
.split>* {
flex: 1 1 auto;
height: 50px;
}
.left {
background: pink;
}
.middle {
background: lightgreen;
}
.right {
background: lightblue;
}
pre { margin: 0; }
<div class="split">
<div class="left"></div>
<div class="middle"></div>
<div class="right"></div>
</div>
The code above asks using ResizeObserver
and devicePixelContentBoxSize
which is only supported on Chromium browsers and Firefox at the moment. For me, the middle div got the extra pixel
left : width = 199
middle: width = 200
right : width = 199
The point of all of that is
image-rendering: pixelated; image-rendering: crisp-edges
Solutions: TL;DR THERE IS NO CROSS BROWSER SOLUTION IN 2022
In Chrome/Firefox you can use the ResizeObserver solution above.
In Chrome and Firefox you can also compute a fractional sized canvas
In other words. A typical "fill the page" canvas like
<style>
html, body, canvas { margin: 0; width: 100%; height: 100%; display: block; }
<style>
<body>
<canvas>
</canvas>
<body>
will not work. See reasons above. But you can do this
This will end up leaving a trailing gap on the right and bottom edges of 1 to 3 device pixels but it should give you a canvas that is 1x1 pixels.
const px = v => `${v}px`;
const canvas = document.querySelector('canvas');
resizeCanvas(canvas);
draw(canvas);
window.addEventListener('resize', () => {
resizeCanvas(canvas);
draw(canvas);
});
function resizeCanvas(canvas) {
// how many devicePixels per pixel in the canvas we want
// you can set this to 1 if you always want 1 device pixel to 1 canvas pixel
const pixelSize = Math.max(1, devicePixelRatio) | 0;
const rect = canvas.parentElement.getBoundingClientRect();
const deviceWidth = rect.width * devicePixelRatio | 0;
const deviceHeight = rect.height * devicePixelRatio | 0;
const pixelsAcross = deviceWidth / pixelSize | 0;
const pixelsDown = deviceHeight / pixelSize | 0;
const devicePixelsAcross = pixelsAcross * pixelSize;
const devicePixelsDown = pixelsDown * pixelSize;
canvas.style.width = px(devicePixelsAcross / devicePixelRatio);
canvas.style.height = px(devicePixelsDown / devicePixelRatio);
canvas.width = pixelsAcross;
canvas.height = pixelsDown;
}
function draw(canvas) {
const ctx = canvas.getContext('2d');
for (let x = 0; x < canvas.width; ++x) {
let h = x % (canvas.height * 2);
if (h > canvas.height) {
h = 2 * canvas.height - h;
}
ctx.fillStyle = 'lightblue';
ctx.fillRect(x, 0, 1, h);
ctx.fillStyle = 'black';
ctx.fillRect(x, h, 1, 1);
ctx.fillStyle = 'pink';
ctx.fillRect(x, h + 1, 1, canvas.height - h);
}
}
html, body {
margin: 0;
width: 100%;
height: 100%;
/* set to red we can see the gap */
/* set to some other color to hide the gap */
background: red;
}
canvas {
display: block;
image-rendering: pixelated;
image-rendering: crisp-edges;
}
<canvas></canvas>
In Safari there are no solutions: Safari provides neither support for devicePixelContentBoxSize
nor does it adjust the devicePixelRatio
in response to zooming. On the plus side, Safari never returns a fractional devicePixelRatio
, at least as of 2022 but you won't get 1x1 device pixels on Safari if the user zooms.
There's a proposal for a CSS attribute image-resolution
, which if set to snap
would help with this issue in CSS. Unfortunately, as for May 2023 it is not implemented in any browser.