How many pixels are there For real on a canvas?
Since Chrome 84, ResizeObserver supports a new box measure called device-pixel-content-box
, which measures the dimension of the element in physical pixels. This enables pixel perfect graphics to be rendered, especially in the context of high-density displays.
Background: CSS pixels, canvas pixels, and physical pixels
While we often work with abstract units of length like em
, %
or vh
, it all comes down to pixels. Whenever we specify the size or position of an element in CSS, the browser's layout engine will eventually convert that value to pixels (px
). These are "CSS Pixels", which have a long history and only have a loose relationship to the pixels you have on your screen.
For a long time, it was quite reasonable to estimate the pixel density of anyone's screen at 96 dpi ("dots per inch"), which means that any monitor would be roughly 38 pixels per cm. Over time, the monitors grew and / or shrunk or began to have more pixels on the same surface. Combine that with the fact that a lot of content on the web defines its dimensions, including font sizes, in px
, and we end up with garbled text on these high-density displays ("HiDPI"). As a countermeasure, browsers hide the actual pixel density of the monitor and instead pretend that the user has a 96 DPI screen. the px
unit in CSS represents the size of a pixel in this virtual 96 DPI display, hence the name "CSS Pixel". This unit is only used for measurement and positioning. Before any actual rendering occurs, a conversion to physical pixels occurs.
How do we go from this virtual screen to the user's real screen? Get in devicePixelRatio
. This global value tells you how many physical pixels you need to form a single CSS pixel. Yes devicePixelRatio
(dPR) is 1
, you are working on a monitor with approximately 96 dpi. If you have a retina display, your dPR is probably 2
. On phones, it is not uncommon to find higher (and weirder) dPR values like 2
, 3
or even 2.65
. It is essential to take into account that this value is exactly, but does not allow you to bypass the monitor real DPI value. A dPR of 2
means 1 CSS pixel will be mapped to exactly 2 physical pixels.
My monitor has a dPR of 1
according to Chrome ...
1
according to Chrome ... It is 3440 pixels wide and the display area is 79 cm wide. That leads to a resolution of 110 DPI. Close to 96, but not quite. That is also the reason why <div style="width: 1cm; height: 1cm">
It will not be exactly 1cm in size on most screens.
Finally, dPR can also be affected by the zoom function of your browser. If you zoom in, the browser increases the reported dPR, making everything look bigger. If you see devicePixelRatio
In a DevTools Console while zooming, you can see fractional values appear.
Let's add the element to the mix. You can specify how many pixels you want the canvas to be using the
width
and height
attributes. Then it would be a 40 by 30 pixel canvas. However, this does not mean that it will be unfolded at 40 by 30 pixels. By default, the canvas will use the
width
and height
attribute to define its intrinsic size, but you can arbitrarily resize the canvas using all the CSS properties you know and love. With everything we've learned so far, it may occur to you that this won't be ideal in all scenarios. One pixel on the canvas can end up covering several physical pixels or just a fraction of a physical pixel. This can lead to unpleasant visual artifacts.
To summarize: Elements on the canvas are sized to define the area in which you can draw. The number of pixels on the canvas is completely independent of the display size of the canvas, specified in CSS pixels. The number of CSS pixels is not the same as the number of physical pixels.
Pixel perfection
In some scenarios, it is desirable to have an exact mapping of the canvas pixels to the physical pixels. If this mapping is achieved, it is called "pixel perfect." Pixel-perfect rendering is crucial for legible rendering of text, especially when using sub-pixel representation or when displaying graphics with very aligned lines of alternating brightness.
To achieve something as close to a pixel perfect canvas as possible on the web, this has pretty much been the approach to take:
<style>
</style>
<canvas go="myCanvas"></canvas>
<script>
const cvs = document.querySelector('#myCanvas');
const rectangle = cvs.getBoundingClientRect();
cvs.width = rectangle.width * devicePixelRatio;
cvs.height = rectangle.height * devicePixelRatio;
</script>
The astute reader might wonder what happens when dPR is not an integer value. That's a good question and exactly where the crux of this whole problem lies. Also, if you specify the position or size of an item using percentages, vh
or other indirect values, they may be resolved to fractional CSS pixel values. An element with margin-left: 33%
you can end up with a rectangle like this:
CSS pixels are purely virtual, so having fractions of a pixel is fine in theory, but how does the browser calculate the allocation to physical pixels? Because fractional physical pixels are not a thing.
Pixel Adjustment
The part of the unit conversion process that takes care of aligning items to physical pixels is called “pixel snapping,” and it does what it says on the tin: it snaps fractional pixel values to whole physical pixel values. How exactly this happens is different from browser to browser. If we have an element with a width of 791.984px
on a screen where dPR is 1, a browser can render the element in 792px
physical pixels, while another browser may render it in 791px
. That's just one pixel less, but a single pixel can be detrimental to renderings that need to be pixel perfect. This can cause blurriness or even more visible artifacts such as Moiré effect.
devicePixelContentBox
devicePixelContentBox
gives you the content box of an item in units of device pixels (that is, physical pixels). It is part of ResizeObserver
. While ResizeObserver now supports all major browsers since Safari 13.1, the devicePixelContentBox
The property is only on Chrome 84+ for now.
As mentioned in ResizeObserver
: it is like document.onresize
for elements, the callback function of a ResizeObserver
it will be called before painting and after design. That means the entries
The callback parameter will contain the sizes of all the observed elements just before they are painted. In the context of our canvas problem described above, we can take this opportunity to adjust the number of pixels on our canvas, ensuring that we end up with an exact one-to-one mapping between the canvas pixels and the physical pixels.
const observer = new ResizeObserver((entries) => {
const entry = entries.find((entry) => entry.target === canvas);
canvas.width = entry.devicePixelContentBoxSize[0].inlineSize;
canvas.height = entry.devicePixelContentBoxSize[0].blockSize;
});
observer.observe(canvas, {box: ['device-pixel-content-box']});
the box
property on the options object for observe.observe ()
lets you define what sizes you want watch. So while each ResizeObserverEntry
will always provide borderBoxSize
, contentBoxSize
and devicePixelContentBoxSize
(as long as the browser supports it), the callback will only be invoked if any of the observed cash metrics change.
All cash metrics are matrices to allow ResizeObserver
to handle fragmentation in the future. At the time of writing this article, the array always has a length of 1.
With this new property, we can even animate the size and position of our canvas (effectively guaranteeing fractional pixel values) and not see any Moiré effects in the rendering. If you want to see the Moiré effect in focus using getBoundingClientRect ()
and like the new ResizeObserver
property allows you to avoid it, take a look at the manifestation on Chrome 84 or later.
Feature detection
To check if a user's browser is compatible with devicePixelContentBox
, we can observe any element and check if the property is present in the ResizeObserverEntry
:
function hasDevicePixelContentBox() {
return new Promise((resolve) => {
const ro = new ResizeObserver((entries) => {
resolve(entries.every((entry) => 'devicePixelContentBoxSize' in entry));
ro.disconnect();
});
ro.observe(document.body, {box: ['device-pixel-content-box']});
}).catch(() => false);
}if (!(await hasDevicePixelContentBox())) {
}
conclusion
Pixels are a surprisingly complex subject on the web and until now there was no way to know the exact number of physical pixels an item occupies on the user's screen. The new devicePixelContentBox
property in a ResizeObserverEntry
gives you that information and allows you to render pixel perfect renderings with .
devicePixelContentBox
it is compatible with Chrome 84+.