🔥关于 canvas 模糊的问题(高清图解)

10,869 阅读6分钟

缘起

模糊在 canvas 中应该算是个经典问题了,相信大家也曾经看过很多相关文章,但总是记不住,因为概念很多,描述的也不够明确,所以我就自己总结了一篇,刨去了复杂概念,顺带画了几张高清图,以此加深理解(我觉得画的贼好😄,记不住就来打我)。

模糊的原因

总的来说模糊的原因大致可分为以下三点:

1、canvas 的大小和 css 的大小不一致

首先让我们先抛弃 css 大小的概念,请记住我们所有的绘图操作都是在 canvas 这个画布大小上进行的;canvas 绘制完成后最终在页面渲染的时候才和 css 大小有关。这时候把 canvas 想象成一张大小固定的照片,把 css 想象成一个容器,不管 css 尺寸如何,这张照片都会铺满整个容器(机制就是这样,没有为啥😒)。所以如果长宽比例相同就会等比缩放;如果长宽比例不同就会拉伸变形;如果大小一样就刚刚好。就像下面这样: image.png 所以一般情况下我们需要把 canvas 和 css 设置成一样大:

<canvas width="600" height="600" style="width: 600px; height: 600px;"></canvas>

2、当绘制的东西不足 1px,会自动补足 1px

大家应该也有听说个这个说法,说在 (100, 0) 处画一条 1px 的竖线,画出来的线会感觉很模糊。因为这涉及到 canvas 绘制线条的方式,(100, 0)是线的中心点,绘制的时候会从这个点向两边扩散 1px/2=0.5px,而不是单边扩散,但是我们无法绘制出半个像素,最小也得是 1px,所以就会自动补足成 1px(而且是纯色),这样一来两边各扩散 1px 总共就变成了 2px。但仅仅这样线条也不应该模糊,顶多变粗了,再复杂的点的图形也只是会表现出有锯齿感而已。其实模糊的主要原因在于自动补足的这一部分的颜色已经不是之前那个颜色了,而是根据某种算法(比如取周围的近似色)生成的。那为什么要做颜色改变呢?主要就是为了解决刚才说的锯齿问题而做的一种平滑处理的手段。没看懂😂?那就看图,大概是下面这个样子👇🏻: image.png 当然也不是说你画 1px 就模糊,如果你在(100.5, 0)画条 1px 的竖线,是不会变粗也不会模糊的,应该说是出现半个像素的时候或多或少会模糊些,包括 translate(0.5, 0.5) 也会造成模糊。所以在使用 canvas 画图时,应注意减少值出现半个像素的情况,或者绘制的时候多加半个像素,这也是种优化(包括计算取整也是)。可以看看下面的对比图感受一下👇🏻: image.png

3、受到高清屏的影响

事实上你会发现即便 canvas 和 css 大小一样可能还是会模糊,这是受到了 dpr(设备像素比,其他概念就别去记啦🐶)的影响,如下图所示: image.png 图没看懂😂?那就来看看文字解说:假设我们现在 canvas 和 css 的大小都是 10 * 10,那么 canvas 画完的照片中就会有 100 个(像素)点,也就是只有 100 个点的信息;但是到了高清屏中(如 dpr = 2),我们需要 400 个点的信息,原来的点不够用怎么办?于是就会有一套算法来自动生成这些点的信息,从而造成了模糊。那应该怎么办呢🤔?我们需要更多的点,所以可以这样子搞,把画布放大 dpr 倍,也就是把 canvas 变大 dpr 倍,而 css 的大小保持不变,虽然 canvas 变大了,但是最终在页面上绘制的时候放到 css 中又会因为等比缩放(上面第一点说到的原因)变回来了原来大小,这样一折腾,点就变多了。但是要注意什么呢,画布变大了,相应的绘制操作(画圆、画矩形等)也需要相应放大,一般有两种方法,很多文章都只告诉你放大,但是却没告诉你放大之后怎么做,所以看看下面的代码应该会帮助你更好的理解👇🏻:

// 方法一:放大画布之后,直接用 scale 放大整个坐标系
// * 但是你要知道我们一直是在放大的坐标系上绘制的,可能不知道什么时候(比如重新设置画布宽高),scale 可能就会被重置成 1 了,从而造成画面错乱
adaptDPR() { // 在初始化 canvas 的时候就要调用该方法
    const dpr = window.devicePixelRatio;
    const { width, height } = this.canvas;
    // 重新设置 canvas 自身宽高大小和 css 大小。放大 canvas;css 保持不变,因为我们需要那么多的点
    this.canvas.width = Math.round(width * dpr);
    this.canvas.height = Math.round(height * dpr);
    this.canvas.style.width = width + 'px';
    this.canvas.style.height = height + 'px';
    // 直接用 scale 放大整个坐标系,相对来说就是放大了每个绘制操作
    this.ctx2d.scale(dpr, dpr);
    // 接下来的绘制操作和往常一样,比如画个矩形 ctx2d.strokeRect(x, y, w, h);原来该怎么算就怎么算,该怎么调用还是怎么调用
}
// 方法二:放大画布之后,需要把每一个绘制的 api 都乘以 dpr
// * 这样一来使用的时候就会很麻烦,所以我们需要把所有的绘制操作进行统一封装
// 可以参考这个库:https://github.com/jondavidjohn/hidpi-canvas-polyfill,不过这个库也不是所有 api 都覆盖
adaptDPR() { // 在初始化 canvas 的时候就要调用该方法
    const dpr = window.devicePixelRatio;
    const { width, height } = this.canvas;
    // 重新设置 canvas 自身宽高大小和 css 大小。放大 canvas;css 保持不变,因为我们需要那么多的点
    this.canvas.width = Math.round(width * dpr);
    this.canvas.height = Math.round(height * dpr);
    this.canvas.style.width = width + 'px';
    this.canvas.style.height = height + 'px';
    // 注意这里没有用 scale
}
// 接下来在每个涉及绘制的 api 时都乘以 dpr,比如绘制矩形的时候
strokeRect(x: number, y: number, w: number, h: number) {
    const { ctx2d, dpr } = this;
    if (!ctx2d) return;
    x = x * dpr;
    y = y * dpr;
    w = w * dpr;
    h = h * dpr;
    ctx2d.strokeRect(x, y, w, h);
}

两种方法都极力推荐大家试一试,感受一下差别,真的要试一试,不然记不住的,文章这东西过眼云烟☁️。还有一个可能遇到的坑就是用户如果缩放页面(ctrl+),页面的 devicePixelRatio 是会变的😂,遇到了你就懂了。

这里补充一个小知识点:scalesetTransform 这两个 canvas api 都可以进行缩放,不同的是 scale 会在当前的变换效果上进行叠加缩放,而 setTransform 则会覆盖当前的变换效果。一个是叠加,一个是覆盖,记得区分开。

结语

可能有的同学还是会感到迷糊,所以感兴趣的同学可以看看这个github,看看具体是怎样在项目中应用的,当然也有配套的文章说明(🔥函数曲线的绘制,纵享丝滑)。好啦,有什么问题,欢迎点赞评论留言,下期回见👊🏻。