Canvas 局部渲染优化总结
简介
G2(图表引擎) 4.0 和 G6(图分析引擎) 3.4版本已经替换了 G(2D 渲染引擎)4.0,这个版本最大的改进是支持了局部渲染,在一些场景下例如节点的状态改变、图形的个体动画等方面性能提升巨大。G 4.0 从开始重构到现在稳定经历了半年的不断完善,遇到了各种各样的问题,本文将对 Canvas 的局部渲染做一个总结,给后来者一些帮助。
问题分析
由于 Canvas 的绘制方式是画笔式的,在 Canvas 上绘图时每调用一次 API 就会在画布上进行绘制一次,一旦绘制图形就成为画布的一部分。绘制图形时并没有对象保存下来,一旦图形需要更新,需要清除整个画布重新绘制。
为什么要把整个 Canvas 画布都清除,然后整体重绘?我们以上面的两张图为例,以左图为例,1, 2 没有同其他的图形重合,可以清除掉重新绘制,但是 3,4 就无法单独清理掉重绘;右图仅仅在左图的基础上增加一条折线,这时候我们就无法刷新单个图形了。
而在我们要实现局部渲染时,需要考虑的两个因素是:
- 单次刷新时影响的范围最小
- 刷新的图形不会影响其他图形的正确绘制
仅仅缩小刷新时的范围从而提升性能并不够,以右图为例,如果我们要刷新图形 2 将图形变成红色。这时候如果仅仅清理掉图形 2 ,重新绘制则:
折线就会部分消失,这与我们的预期不一致。局部刷新不但要保证刷新的范围足够小,还要保证图形绘制的正确性。
方案
我们来思考 Canvas 局部渲染方案时,需要看 Canvas 的 API 给我们提供了什么样的接口,这里主要用到两个方法:
- clip() 确定绘制的的裁剪区域,区域之外的图形不能绘制,详情查看 CanvasRenderingContext2D.clip()
- clearRect(x, y, width, height) 擦除指定矩形内的颜色,查看 CanvasRenderingContext2D.clearRect()
通过这两个 API 我们可以得到 Canvas 局部刷新的方案:
- 清除指定区域的颜色,并设置 clip
- 所有同这个区域相交的图形重新绘制
以上图为例,如果我们想刷新图形 3,使得图形的颜色变成红色
- 首先确定图形的矩形包围盒
- 清除这个包围盒内的颜色,设置这个区域为 clip 区域
- 重新绘制所有跟这个区域相交的图形
- 重绘图形 3
- 重绘图形 4
遇到的问题
真实的在 G 4.0 中实现局部渲染时遇到的问题比上面的案例复杂的多:
- G 不仅仅支持图形渲染,也支持分组 Group,一旦分组发生变化也会触发局部刷新
- 除了图形的属性变化外,图形的顺序调整、添加、移除图形以及显示隐藏等也会导致刷新
- 图形和分组上会增加各种矩阵,图形的包围盒计算频繁而又复杂
这些问题在 1-2 周内都解决了,但是在接入 G2 和 G6的过程中遇到了一些完全没想过的问题持续了半年的时间,主要体现在两个方面:
- 包围盒计算不精确,导致的残影问题
- 局部刷新导致的性能下降
残影的问题
首先我们来看画布上的两条线,同样都是 1 像素颜色 #333 的线,有什么差别?
很明显上面的一条,两像素宽,同时颜色变淡,两条线的坐标为:
- 线段1(粗): (10, 100) - (200, 100)
- 线段2(细):(10, 149.5) - (200, 149.5)
由于屏幕的分辨率只能在整数的点上绘制颜色,线段 1 一半绘制在 (10, 99)-(200, 99) 一半绘制在 (10, 101 )-(200 101)上,所以浏览器会自动的把落到半个像素上的点扩展成一个点,颜色变淡,就变成了下图的示例(示例中画布进行放大,每个单元格代表一像素)。
所以在 Canvas 画布上绘制图形时,任意的点如果部分落到一个像素上,都会占满整个像素,这个问题在平时的整体刷新时不明显,一旦我们来实现局部刷新就会出现问题,下面的多个问题都与此相关。
浮点数计算的问题
我们在绘制图形时很多图形属性是自动计算出来的,例如:
- 直线 (10.2, 44.3) - (20.1, 10.5)
- 圆,圆心(10.5, 8.8) 半径 3.4
这时候图形绘制的区域同数学计算出来的并不一致,这就会导致局部刷新时清空的区域不足,会留下一些残影。
上图中对圆进行几何计算的包围盒和实际的包围盒有了差别,这时候局部渲染就出问题了。解决方案:
- 将包围盒的 minX, minY 向下取整 (10.2, 10.5) -> (10, 10)
- 将包围盒的 maxX,maxY 向上取整 (20.1, 44.3) -> (21, 45)
折线夹角的问题
由于 Canvas 在实现折线时,在线段的交接处做了处理,会附加额外的像素,使得折线更美观,我们来看下单独绘制两条线段,和一条折线的差别:
红框为我们通过数学计算出来的包围盒,可以看出折线的拐角处明显超出一部分,这时候折线改变时的刷新就不准确。解决这个问题有两个解决方案:
- canvas 在绘制时提供了一个参数: lineJoin,可以设置 context.lineJoin="bevel|round|miter";详情参看 canvas lineJoin 可以改成 bevel 就不再有尖角,但是同我们的预期不一致。
- 计算折线包围盒时增加拐角的计算,如果折线线段的夹角小于 90 度,则计算超出的像素数。如果折线的线段比较多,可以仅计算落到折线上下左右四个边上拐点超出的像素数(有一定风险)
shadow 的问题
在 Canvas 上绘制图形时可以指定阴影,有四个参数关系到阴影的设置:
shadowColor | 设置或返回用于阴影的颜色 |
---|---|
shadowBlur | 设置或返回用于阴影的模糊级别 |
shadowOffsetX | 设置或返回阴影距形状的水平距离 |
shadowOffsetY | 设置或返回阴影距形状的垂直距离 |
下面两个圆,如果不考虑阴影进行局部刷新时会出现下面的情况:
所以在局部渲染时,通过判断是否有 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);
}
箭头的问题
在线上增加箭头是个常见需求,但是由于箭头是附加在线上的,计算包围盒未计算在其中,这就导致刷新时箭头未被清除,同时箭头又有多种情况,还要考虑箭头的自定义:
因此 G 4.0 将箭头实现成了一个新的 shape,线包围盒计算时同时附加箭头的包围盒,进行相并处理。
文本渲染的问题
你能看清楚下面的文本发生了什么吗?如果仔细观察会发现文本左侧被裁剪掉了一像素,这种情况在多个场景下都存在
通过一番痛苦的排查发现,在这个 demo 的页面上有一个属性,在不同的字体下导致文字的宽度不一致:
分析一下原因发现,当前文本的宽度计算是通过离屏 Canvas ,也就是创建一个 canvas 标签,但是没有放入 document.body 下,导致离屏 Canvas (1*1 的画布) 上的 font 相关的属性同当前 Canvas 不一致导致的。有两个方案可以解决这个问题:
浏览器缩放的问题
G2 4.0 和 G6 3.4 发布后,有用户反馈在页面上进行操作时,出现一些线的划痕
一开始是怀疑线宽度计算时的浮点数问题,我们在本地和虚拟机上进行了测试,始终没有定位到问题。用户反馈他们对浏览器进行了缩放,通过检测他们浏览器页面上的 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);
}
性能问题
大量分散的图形刷新
如果同时有多个图形进行刷新,为了减少包围盒的计算,我们会把所有刷新的图形的包围盒进行合并,但是会出现一些特殊情况,导致局部渲染的性能下降,例如:
视窗外的图形刷新
这些局部渲染的性能问题的解决方案牵扯的方面比较多,不在这里展开,可以参考 渲染裁剪优化
总结
经历了半年的改造,G 4.0 的局部渲染方案已经经历了 G2 和 G6 的考验,在局部刷新的场景下在交互和性能等方面提升了 7-10 倍,但是依然存在一些特殊场景上的性能问题,还在持续优化中。渲染的一分提升,上层都会有十分的收获。
AntV 官网:antv.vision/
2D 绘图引擎 G:github.com/antvis/g