Search code examples
javascripthtmlhtml5-canvas

HTML canvas rectangle aliasing with whole pixel values on hi-res monitor


I have an HTML canvas that I am drawing stock ticker candles on. When displayed with a window.devicePixelRatio of 1, everything is fine; I make sure to render on whole pixel values so that all the rectangles, and lines are drawn cleanly:

enter image description here

When looking at this when the window.devicePixelRatio is not 1, the method of just using Math.floor for the pixel value no longer works, as the window.devicePixelRatio can be a factional value.

For example, when looking at this on a 4k monitor, with a window.devicePixelRatio of 1.5, the candles render as such:

enter image description here

As you can see, those same candles now have aliasing around their edges which I would like to get rid of. I have narrowed it down (I think) that in order to render an edge without aliasing, the pixel value provided multiplied by the pixel ratio needs to be a whole number: ie. providing an x pixel of 1 would not work, because, 1 * 1.5 = 1.5, but a value of 2 would, because, 2 * 1.5 = 3 which is a whole value.

In the picture below, the left candle has an x coordinate of 2, whereas the right candle has an x coordinate of 9. 2 * 1.5 = 3, but 9 * 1.5 = 13.5 so it gets aliased.

enter image description here

Is there a simple general mathematical solution to be able to compute what pixel value I should provide when I call fillRect, stroke, etc so that I won't get this aliasing? Ie. a function where if I passed in 9, I would get back 10 if I had a window.devicePixelRatio of 1.5; basically rounding up to nearest x pixel value that wouldn't give me aliasing.

Edit code reference for how I am scaling the canvas:

this._canvas.nativeElement.style.width = `${this._width}px`;
this._canvas.nativeElement.style.height = `${this._height}px`;

const dpr = window.devicePixelRatio;

this._canvas.nativeElement.width = Math.floor(this._width * dpr);
this._canvas.nativeElement.height = Math.floor(this._height * dpr);

this._context.scale(dpr, dpr);

Solution

  • The issue is that you are scaling the whole context through ctx.scale(). This is indeed the easiest way generally, but this also means that with a non-integer pixel ratio the canvas context will itself convert your integer coordinates to floating ones.

    Instead you will want to perform the scaling yourself and round where needed.

    const dPR = devicePixelRatio;
    const canvas = document.querySelector("canvas");
    canvas.width *= dPR;
    canvas.height *= dPR;
    const ctx = canvas.getContext("2d");
    /**
     * Scale the coordinate to the current pixel ratio
     * Does not round. To be used with methods that don't require
     * to fall on integer coords (e.g non straight poly-lines)
     */
    const s = (val) => val * devicePixelRatio;
    /**
     * Scale the coordinate to the current pixel ratio, rounded to the nearest integer.
     */
    const r = (val) => Math.round(s(val));
    // Draw a rect using rounded coords
    ctx.fillRect(r(50), r(50), r(1), r(75));
    canvas { width: 300px; height: 150px; }
    <canvas></canvas>