Search code examples
htmlhtml5-canvas

Display pixel-perfect canvas on all devices


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


Solution

  • 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).

    100x100 image canvas

    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.

    enter image description here

    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

    1. You can't just naively set image-rendering: pixelated; image-rendering: crisp-edges
    2. If you want to know how many pixels are in the canvas you may have to ask and you can only find out on Chrome/Firefox ATM

    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

      1. get the size of the container (the body in this case)
      2. multiply by devicePixelRatio and round down
      3. divide by the devicePixelRatio
      4. Use that value for the canvas CSS width and height and the #2 value for the canvas's width and heigh.

      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.