AntV Canvas 局部渲染总结

5,434 阅读9分钟

Canvas 局部渲染优化总结

简介

G2(图表引擎) 4.0 和 G6(图分析引擎) 3.4版本已经替换了 G(2D 渲染引擎)4.0,这个版本最大的改进是支持了局部渲染,在一些场景下例如节点的状态改变、图形的个体动画等方面性能提升巨大。G 4.0 从开始重构到现在稳定经历了半年的不断完善,遇到了各种各样的问题,本文将对 Canvas 的局部渲染做一个总结,给后来者一些帮助。

问题分析

由于 Canvas 的绘制方式是画笔式的,在 Canvas 上绘图时每调用一次 API 就会在画布上进行绘制一次,一旦绘制图形就成为画布的一部分。绘制图形时并没有对象保存下来,一旦图形需要更新,需要清除整个画布重新绘制。

image.png
image.png

为什么要把整个 Canvas 画布都清除,然后整体重绘?我们以上面的两张图为例,以左图为例,1, 2 没有同其他的图形重合,可以清除掉重新绘制,但是 3,4 就无法单独清理掉重绘;右图仅仅在左图的基础上增加一条折线,这时候我们就无法刷新单个图形了。
而在我们要实现局部渲染时,需要考虑的两个因素是:

  • 单次刷新时影响的范围最小
  • 刷新的图形不会影响其他图形的正确绘制

仅仅缩小刷新时的范围从而提升性能并不够,以右图为例,如果我们要刷新图形 2 将图形变成红色。这时候如果仅仅清理掉图形 2 ,重新绘制则:

image.png

折线就会部分消失,这与我们的预期不一致。局部刷新不但要保证刷新的范围足够小,还要保证图形绘制的正确性。

方案

我们来思考 Canvas 局部渲染方案时,需要看 Canvas 的 API 给我们提供了什么样的接口,这里主要用到两个方法:

通过这两个 API 我们可以得到 Canvas 局部刷新的方案:

  1. 清除指定区域的颜色,并设置 clip
  2. 所有同这个区域相交的图形重新绘制

image.png
image.png

image.png
image.png


以上图为例,如果我们想刷新图形 3,使得图形的颜色变成红色

  1. 首先确定图形的矩形包围盒
  2. 清除这个包围盒内的颜色,设置这个区域为 clip 区域
  3. 重新绘制所有跟这个区域相交的图形
  4. 重绘图形 3
  5. 重绘图形 4

遇到的问题

真实的在 G 4.0 中实现局部渲染时遇到的问题比上面的案例复杂的多:

  • G 不仅仅支持图形渲染,也支持分组 Group,一旦分组发生变化也会触发局部刷新
  • 除了图形的属性变化外,图形的顺序调整、添加、移除图形以及显示隐藏等也会导致刷新
  • 图形和分组上会增加各种矩阵,图形的包围盒计算频繁而又复杂

这些问题在 1-2 周内都解决了,但是在接入 G2 和 G6的过程中遇到了一些完全没想过的问题持续了半年的时间,主要体现在两个方面:

  • 包围盒计算不精确,导致的残影问题
  • 局部刷新导致的性能下降

残影的问题

首先我们来看画布上的两条线,同样都是 1 像素颜色 #333 的线,有什么差别?

image.png

很明显上面的一条,两像素宽,同时颜色变淡,两条线的坐标为:

  • 线段1(粗): (10, 100) - (200, 100)
  • 线段2(细):(10, 149.5) - (200, 149.5)

由于屏幕的分辨率只能在整数的点上绘制颜色,线段 1 一半绘制在 (10, 99)-(200, 99) 一半绘制在 (10, 101 )-(200 101)上,所以浏览器会自动的把落到半个像素上的点扩展成一个点,颜色变淡,就变成了下图的示例(示例中画布进行放大,每个单元格代表一像素)。

image.png
image.png

所以在 Canvas 画布上绘制图形时,任意的点如果部分落到一个像素上,都会占满整个像素,这个问题在平时的整体刷新时不明显,一旦我们来实现局部刷新就会出现问题,下面的多个问题都与此相关。

浮点数计算的问题

我们在绘制图形时很多图形属性是自动计算出来的,例如:

  • 直线 (10.2, 44.3) -  (20.1, 10.5)
  • 圆,圆心(10.5, 8.8) 半径 3.4

这时候图形绘制的区域同数学计算出来的并不一致,这就会导致局部刷新时清空的区域不足,会留下一些残影。

image.png

上图中对圆进行几何计算的包围盒和实际的包围盒有了差别,这时候局部渲染就出问题了。解决方案:

  • 将包围盒的 minX, minY 向下取整 (10.2, 10.5) -> (10, 10)
  • 将包围盒的 maxX,maxY 向上取整 (20.1, 44.3) -> (21, 45)

折线夹角的问题

由于 Canvas 在实现折线时,在线段的交接处做了处理,会附加额外的像素,使得折线更美观,我们来看下单独绘制两条线段,和一条折线的差别:

image.png
image.png

红框为我们通过数学计算出来的包围盒,可以看出折线的拐角处明显超出一部分,这时候折线改变时的刷新就不准确。解决这个问题有两个解决方案:

  • canvas 在绘制时提供了一个参数: lineJoin,可以设置 context.lineJoin="bevel|round|miter";详情参看 canvas lineJoin 可以改成 bevel 就不再有尖角,但是同我们的预期不一致。
  • 计算折线包围盒时增加拐角的计算,如果折线线段的夹角小于 90 度,则计算超出的像素数。如果折线的线段比较多,可以仅计算落到折线上下左右四个边上拐点超出的像素数(有一定风险)

shadow 的问题

在 Canvas 上绘制图形时可以指定阴影,有四个参数关系到阴影的设置:

shadowColor 设置或返回用于阴影的颜色
shadowBlur 设置或返回用于阴影的模糊级别
shadowOffsetX 设置或返回阴影距形状的水平距离
shadowOffsetY 设置或返回阴影距形状的垂直距离

下面两个圆,如果不考虑阴影进行局部刷新时会出现下面的情况:

image.png
image.png

所以在局部渲染时,通过判断是否有 shadowColor 来附加额外的包围盒,计算出阴影影响的范围,同原始的包围盒相并即可:

// 如果存在 shadow 则计算 shadow
if (attrs.shadowColor) {
  const { shadowBlur = 0, shadowOffsetX = 0, shadowOffsetY = 0 } = attrs;
  const shadowLeft = minX - shadowBlur + shadowOffsetX;
  const shadowRight = maxX + shadowBlur + shadowOffsetX;
  const shadowTop = minY - shadowBlur + shadowOffsetY;
  const shadowBottom = maxY + shadowBlur + shadowOffsetY;
  minX = Math.min(minX, shadowLeft);
  maxX = Math.max(maxX, shadowRight);
  minY = Math.min(minY, shadowTop);
  maxY = Math.max(maxY, shadowBottom);
}

箭头的问题

在线上增加箭头是个常见需求,但是由于箭头是附加在线上的,计算包围盒未计算在其中,这就导致刷新时箭头未被清除,同时箭头又有多种情况,还要考虑箭头的自定义:

image.png
image.png

因此 G 4.0 将箭头实现成了一个新的 shape,线包围盒计算时同时附加箭头的包围盒,进行相并处理。

文本渲染的问题

你能看清楚下面的文本发生了什么吗?如果仔细观察会发现文本左侧被裁剪掉了一像素,这种情况在多个场景下都存在

image.png

通过一番痛苦的排查发现,在这个 demo 的页面上有一个属性,在不同的字体下导致文字的宽度不一致:
     
image.png

分析一下原因发现,当前文本的宽度计算是通过离屏 Canvas ,也就是创建一个 canvas 标签,但是没有放入 document.body 下,导致离屏 Canvas (1*1 的画布) 上的 font 相关的属性同当前 Canvas 不一致导致的。有两个方案可以解决这个问题:

  • 将离屏的 cavas 添加到当前页面文件流中
  • 在图表中设置所有的 font 属性,覆盖掉 body 上的属性

浏览器缩放的问题

G2 4.0 和 G6 3.4 发布后,有用户反馈在页面上进行操作时,出现一些线的划痕

image.png

一开始是怀疑线宽度计算时的浮点数问题,我们在本地和虚拟机上进行了测试,始终没有定位到问题。用户反馈他们对浏览器进行了缩放,通过检测他们浏览器页面上的 window.devicePixelRatio 是非整数。这个参数是浏览器和屏幕的像素比,一般情况下是 1,高精屏下可能是 2 或者 3,为了让图形的绘制更加清晰我们在 G 上进行处理,屏蔽了这个参数,但是在用户对浏览器进行了缩放后,这个参数会变成 1.2、1.3 、1.5 等非整数值。
在开始讨论遇到的局部渲染问题时,我们介绍了直线在屏幕上的绘制,绘制发生在部分像素时浏览器会将整个像素设置颜色,并且变浅,这就出现了线的浅色的划痕。
解决方案:当 window.devicePixelRatio 是非整数时,给包围盒四个方向各附加 0.5 像素,然后取整即可。

// 附加 0.5 像素,会解决1px 变成 2px 的问题,无论 pixelRatio 的值是多少
// 真实测试的环境下,发现在 1-2 之间时会出现 >2 和 <1 的情况下未出现,但是为了安全,统一附加 0.5
const appendPixel = 0.5;
if (region) {
  region.minX = Math.floor(region.minX - appendPixel);
  region.minY = Math.floor(region.minY - appendPixel);
  region.maxX = Math.ceil(region.maxX + appendPixel);
  region.maxY = Math.ceil(region.maxY + appendPixel);
}

性能问题

大量分散的图形刷新

如果同时有多个图形进行刷新,为了减少包围盒的计算,我们会把所有刷新的图形的包围盒进行合并,但是会出现一些特殊情况,导致局部渲染的性能下降,例如:

image.png

视窗外的图形刷新

image.png

这些局部渲染的性能问题的解决方案牵扯的方面比较多,不在这里展开,可以参考 渲染裁剪优化

总结

经历了半年的改造,G 4.0 的局部渲染方案已经经历了 G2 和 G6 的考验,在局部刷新的场景下在交互和性能等方面提升了 7-10 倍,但是依然存在一些特殊场景上的性能问题,还在持续优化中。渲染的一分提升,上层都会有十分的收获。


AntV 官网:antv.vision/
2D 绘图引擎 G:github.com/antvis/g