一种消除 Canvas 次像素渲染 bug 的办法

339 阅读3分钟

问题

最近使用 canvas 的时候遇到了一个令人苦恼的问题, 缩放后, 同样大小的 rect 却不能覆盖原有的 rect.

image.png

上面的图有一圈很淡的红色细线组成的矩形, 但是我是先后绘制两个一样大小的 rect, 并且这两个 rect 都是 xy 和 wh 都是整数.

    const x = 50;
    const y = 50;
    const w = 1900;
    const h = 50;
    ctx.scale(2.2, 2.2);
    ctx.translate(-51, 51)
    // 绘制一个矩形,填充为红色
    ctx.fillStyle = 'red';
    ctx.fillRect(x, y, w, h);

    // 在红色矩形上绘制一个白色矩形
    ctx.fillStyle = 'white';
    ctx.fillRect(x, y, w, h);

原因

由于这里设置了缩放 1.2, 我的屏幕 dpr = 2, 最终得到的 transform a 和 d 都是 2.4, 这不是整数. 且设置 scale 为 1.5, 问题将消失. 此刻 transform 的 a 和 d 都是 3, 恰好都是整数. 因为设置了缩放, 实际渲染的位置就是小数, 为了渲染, 实际物理像素将以灰阶的方式呈现, 因此部分像素是半透明效果.

但是缩放级别是用户决定的, 如何解决这个瑕疵呢?

尝试

问了下 GPT, 给出了 Math.round( val * zoom ) / zoom 这样的方式, 尝试看看 这个式子很好理解, 当 zoom 是整数的时候, 我们常用它来保留小数点后有效位.

但是此刻的情况 zoom 是小数, 如何理解呢? 目前看起来很迷惑. 假设 val = 51  并且 zoom = 2.4

  • 计算 val * zoom 得到 51 * 2.4 = 122.4
  • 使用 Math.round() 对 122.4 进行四舍五入,得到 122
  • 再将结果除以 zoom 得到 122 / 2.4 ≈ 50.83

    ctx.fillRectWithZoom = function (x, y, width, height) {
      const m = this.getTransform();
      const scaleX = m.a;
      const scaleY = m.d;

      x = Math.round(x * scaleX) / scaleX;
      y = Math.round(y * scaleY) / scaleY;
      width = Math.round(width * scaleX) / scaleX;
      height = Math.round(height * scaleY) / scaleY;
      this.fillRect(x, y, width, height);
    }

    ctx.translateWithZoom = function (x, y) {
      const m = this.getTransform();
      const scaleX = m.a;
      const scaleY = m.d;
      x = Math.round(x * scaleX) / scaleX;
      y = Math.round(y * scaleY) / scaleY;
      this.translate(x, y);
    }
    
    
    // problem
    {
      ctx.save();
      // ctx.scale(1, 1);
      ctx.scale(1.3, 1.3);
      ctx.translate(51, 51) // bug
      // 绘制一个矩形,填充为红色
      ctx.fillStyle = 'red';
      ctx.fillRect(x, y, w, h);

      // 在红色矩形上绘制一个白色矩形
      ctx.fillStyle = 'white';
      ctx.fillRect(x, y, w, h);
      ctx.restore();

    }    
    
    // fix0 ok!
    {
      ctx.save();
      ctx.scale(2.4, 2.4);
      ctx.translateWithZoom(-50, 50)
      ctx.fillStyle = 'red';
      ctx.beginPath();
      ctx.fillRectWithZoom(x, y, w, h);
      ctx.fillStyle = 'white';
      ctx.fillRectWithZoom(x, y, w, h);
      ctx.restore();

      x = 50.1;
      y = 51.4;
      w = 190.2;
      h = 50.3;
      ctx.save();
      ctx.scale(1.32, 1.31);
      ctx.translateWithZoom(249.1, 11.1);
      ctx.fillStyle = 'green';
      ctx.beginPath();
      ctx.fillRectWithZoom(x, y, w, h);
      ctx.fillStyle = 'white';
      ctx.fillRectWithZoom(x, y, w, h);
      ctx.restore();
    }

一顿操作下来, 竟然修复了,而且对于非整数矩形也能正确覆盖. 实测 translate 和 fillRect 都需处理 zoom.

尝试之2

查资料还看到另一种解法, 使用 setTransform 而不是 scale, 不过实测并不能适用所有的情形.

当 transform 是一位小数时, 大部分情况 OK. 但是两位小数就不行了, 而且 rect 的位置和大小是小数也不行.

    {
      x = 50;
      y = 190;
      w = 200;
      h = 50;
      ctx.setTransform(1.37, 0, 0, 1.37, 0, 0);
      ctx.fillStyle = 'blue';
      ctx.fillRect(x, y, w, h);

      ctx.fillStyle = 'white';
      ctx.fillRect(x, y, w, h);
    }

尝试3

另一种尝试, 不太建议, 还原度不足而且需要重置 transform. 此方法是在绘制 rect 之前, 先将缩放回 1x, 然后再绘制, 不过 translate 和 rect 位置大小都需要取整, 否则还是无法完全覆盖.

    ctx.fillRectWithZoom2 = function (x, y, width, height) {
      const m = this.getTransform();
      const scaleX = m.a;
      const scaleY = m.d;
      ctx.save();
      ctx.setTransform(1, 0, 0, 1, Math.round(m.e * scaleX), Math.round(m.f * scaleY));
      x = Math.round(x * scaleX);
      y = Math.round(y * scaleY);
      const w = Math.round(width * scaleX);
      const h = Math.round(height * scaleY);
      this.fillRect(x, y, w, h);
      ctx.restore();
    }
    
    
    // test 3
    {
      x = 150.5;
      y = 290.3;
      w = 200;
      h = 50;
      ctx.setTransform(1.3, 0, 0, 1.3, 451, 151);
      ctx.fillStyle = 'black';
      ctx.fillRectWithZoom2(x, y, w, h);

      ctx.fillStyle = 'white';
      ctx.fillRectWithZoom2(x, y, w, h);

    }    

Demo