canvas
February 3, 2020
A lot of the things I am interested in involve drawing, so one of the first things
I looked into was how this is done in the browser world. Naturally, this led to
the canvas
element, but it doesn't work very well, at least for what I
have in mind. The major culprit
seems to be the inability to turn off anti-aliasing. Also, you may want to
read about how touch events are handled for
a canvas. Overall, I think SVG
looks better, but it has problems too.
Below is a small canvas with a square drawn inside. Click on the square with the mouse to move the square around within the canvas. Pretty exciting, I know.
The problem is that the square is blurry, and there's no reasonable way to fix it. If your eyes aren't sharp enough to see what I mean, zoom in on the canvas with Ctrl-+ or by "pinching," or whatever you need to do to enlarge the page on your browser. The frame enclosing the canvas is nice and crisp, so browsers can render crisply; the problem is with how the square inside is drawn.
The reason it's blurry is due to anti-aliasing. Back when monitors had a resolution of 72ppi, at best, the "jaggies" you'd see when drawing lines were considered to be a problem in some situations, and one solution was to anti-alias the lines. If memory serves, anti-aliasing also got a big boost in the late 80s or early 90s when Microsoft used it to smooth the way fonts are rendered so that they didn't look blocky when blown up – this was when most fonts were bit-mapped. "Anti-alias" is just jargon for "make the edges blurry" (in a clever way). It's not necessarily a bad thing, but sometimes you don't want it. Also, some people, like me, find that looking at these blurred renderings causes low-grade nausea; it's like our eyes refuse to focus.
A quick search shows that lots of people are frustrated by this problem. One partial solution that's often mentioned is to round all pixel coordinates to the nearest 0.5. In theory, if all coordinates are rounded to the nearest 0.5, then the coordinates are smack in center of a pixel, and there should be no "bleed" into adjacent pixels.
For instance, when a line is drawn, it's perfectly acceptable to say something like this.
ctx = theCanvas.getContext("2d"); ctx.beginPath(); ctx.moveTo(11.478,19.431); ctx.lineTo(17.178,27.870); ctx.stroke();
The line was specified with floating-point numbers, and the graphics context represents the line internally with floats until it's drawn. It's when the line is drawn that the problem arises. The browser has to decide which pixels are shaded, and that is inherently an integer (not floating-point) problem.
If we try rounding the coordinates of the figure to the nearest 0.5, then the canvas behaves like this.
The two legs of the triangle are fairly crisp. They're still not absolutely crisp at all levels of zoom, but they're a lot better – at least on my machine. Even so, the hypoteneuse is still blurry. That's to be expected since the coordinates of the points of the hypoteneuse vary in a way that can't be restricted to any particular form; those points are determined by the choice of end-points.
The ultimate cause of the problem is the way browsers render their contents. JavaScript tells the browser what we would like to be drawn, but our code isn't doing the actual drawing; the browser does it, and it's done however the browser wants. That's part of what makes the browser framework attractive for many things. Instead of handling screen refresh events in our own code, the browser handles it. To accomplish this, the browser keeps an off-screen bitmap where the contents of the canvas are held.
I haven't examined any browser source code, so I don't know that browsers handle canvases with an off-screen bitmap, but it's logical that they do. One can draw images to a canvas, or any number of arbitrary things, and it's hard to see how else browsers could deal with this efficiently. It also explains why anti-aliasing is used. There's no one-size-fits-all solution to the problem of scaling an arbitrary bitmap, but anti-aliasing is a reasonable compromise. Even so, it would be nice if there were a way to turn it off.
Another purported solution involves using
window.devicePixelRatio
to scale the canvas. This sounds promising
since the problem arises from the mismatch between actual pixels on the
screen, "pixel" as understood by the offscreen bitmap, and the pixel concept used within HTML.
Unfortunately, the way devicePixelRatio
is defined seems to rule
this out as a complete solution. According to
MDN web docs,
the devicePixelRatio
is "the ratio of the resolution in physical
pixels to the resolution in CSS pixels for the current display device." Ok, so what
is a "CSS pixel?" Going to
MDN web docs
again, it seems to be fixed at 96 pixels, since 96ppi is judged to be what the
human eye can distinguish "comfortably...at arm's length".
So, devicePixelRatio
tells us the ratio of the current device's
pixel density to 96, but it doesn't normalize for the level of browser zoom.
The more the user zooms in, the larger this ratio will be.
It doesn't say anything about the ratio of pixels in the
off-screen bitmap to pixels on the screen, and that is the source of blurriness.
Even if window.devicePixelRatio
doesn't completely solve the problem,
it's still a handy thing to be aware of since there are devices out there with
densities of more than 800ppi. One way to modify the size of
the canvas to reflect the pixel density is to do it when the canvas is allocated.
Here's the relevant snippet to adjust the size.
var dpiRatio = window.devicePixelRatio; // '+' casts to an integer, and slice() strips off the 'px'. sheight = +getComputedStyle(theCanvas).getPropertyValue('height').slice(0,-2); swidth = +getComputedStyle(theCanvas).getPropertyValue('width').slice(0,-2); theCanvas.setAttribute('height',sheight*dpiRatio); theCanvas.setAttribute('width',swidth*dpiRatio);
Various other ideas of how to make a canvas more crisp are sometimes given.
context.scale()
. The idea here is to enlarge or
reduce the size of the image to "squeeze out" any blurriness. Under some circumstances,
this might reduce blurriness, but it won't eliminate it entirely.
ctx.imageSmoothingEnabled
to false
. This might
help when displaying images, but not with lines.
image-rendering
style to
pixelated
or crisp-edges
. This can also be helpful
with images, but it doesn't do anything for anti-aliased lines.
The bottom line is that none of these approaches solve the problem, and the way
canvas
is implemented, there will always be blurriness under some
circumstances. It is impossible to draw a line that is thin and crisp.
One solution that might work is to draw everything to an off-screen bitmap, and do all of the drawing on a pixel-by-pixel basis. I haven't investigated exactly how this would be done – assuming that it can be done – partly because it would be slow. In any case, it's no longer 1990, and I have no desire to implement Bresenham's algorithm for the upteenth time, along with implementing every other graphics primitive I might care about.
Another genuine solution is to use WebGL, but wow, talk about a gold-plated hammer! WebGL is intended for 3D drawing, and it is possible to use it to render lines in 2D – and they can be made crisp – but it's a laughably complicated solution for such a simple problem. And, again, WebGL doesn't have many 2D graphics primitives built in.