【canvas学习笔记】3 - 图形编辑器局部重绘的实现方式

784 阅读5分钟

前言

本文代码所在的仓库(还请各位帮忙给个star)

github.com/kkagura/sto…

在线体验地址

kkagura.github.io/

什么是局部重绘

在canvas绘图过程中,当有属性变更需要重新渲染的时候,最常见的做法是利用clearRect方法清空整个画布,然后重新执行一遍paint方法,这样就达到了重绘的目的。

然而,如果画布上的元素很多,这样一次性绘制整个画布的方式可能会比较耗时,那么这一帧的渲染时间就会过长,导致视觉上看起来会有卡顿,所以这时就需要考虑,能否在每一帧只重新绘制发生了改变的图形,而不是绘制整个画布,这就是局部重绘。

脏矩形检测

在上文中提到了,局部重绘的关键就是要找到需要重新绘制的区域,这个区域叫做脏区域,也可以称为脏矩形。

在我所做的这个图形编辑器项目中,每一个图形都有一个包含尺寸与位置信息的属性叫做rect。类型定义如下:

export interface IRect {
  x: number;
  y: number;
  width: number;
  height: number;
}

所以很直接就可以想到,当某个图形元素属性发生改变需要重绘的时候,那么脏矩形就是这个图形的rect。所以重绘的时候就只需要先用调用clearRect方法清理干净这块区域,然后再重新调用这个图形的paint方法,一个最基础的局部重绘就实现了。

rect与renderRect

在图形的实际绘制过程,图形的实际大小与它的渲染之后所占用的大小是有可能不一样的,例如当图形带有边框的时候,那么图形实际渲染所需要的带下就是在原始rect基础上向外扩展边框大小的一半。还有类似于图形带有阴影等场景,都会导致渲染尺寸与原始尺寸不符的问题。

由于这种情况的出现,我们如果还是用原始rect去做局部重绘的话,就会导致画布清理不干净,残留了上一帧渲染的图形(因为边框、阴影等超出rect了范围,无法被清理掉)。对此我们需要给每一个图形元素都加一个renderRect的属性,用来储存元素真实绘制时所占用的位置。

多个图形叠加

按照上文思路,当图形的样式改变了,就清除图形的renderRect然后重新执行图形的paint方法。但是这种处理方式还存在一些问题,例如以下场景:

image.png

当上图中矩形A的样式发生改变时,按照我们的实现方案,会对其进行重新绘制,重新绘制以后会导致原本被矩形B遮挡的A却来到了矩形B的上方,这是因为我们只重绘了图形A没有重绘图形B导致的。

所以在计算脏矩形时,不光要考虑属性发生变化的元素,我们还要不断遍历场景中所有的元素,判断其它元素的renderRect是否与脏矩形相接,一旦相接时,需要将其也加入到重绘队列中去。

移动图形

我们再考虑下图的场景:

image.png 假如我们将矩形A的位置向右平移200px时,会发生什么情况?

按照上述的实现方式,会导致画布上出现两个矩形A,原因在于,矩形A的坐标发生改变导致重绘时,其位置信息已经更新了,那么我们clearRect清除的就是最新的renderRect,而旧的绘制区域没有被清除,所以就导致了画布上出现了两个矩形。

解决这个问题的方式也很简单,在每次图形绘制的时候,都缓存它的位置信息,当某个图形需要重绘时,计算脏矩形需要将它当前帧的renderRect与缓存的上一帧的renderRect合并成一个新的rect做计算。

代码实现

至此,我们的局部重绘方案已经基本成形,下面看关键代码实现:

class Editor {

   // 计算脏矩形
  findDirtyRect(): {
    rect: IRect;
    models: Set<Model>;
  } {
    // 判断当前是否有需要重绘的元素
    if (this.dirtyList.size === 0)
      return {
        rect: { x: 0, y: 0, width: 0, height: 0 },
        models: new Set()
      };
    // 获取整个视图窗口大小
    const viewRect = this.viewportManager.getViewRect();
    const modelSet = new Set<Model>(this.dirtyList);
    const modelList = this.box.getModelList();
    // 获取所有的renderRect,包含当前帧与上一帧
    const renderRects = [...modelSet].reduce((acc, model) => {
      acc.push(model.getRenderRect());
      const lastRenderRect = this.frameRects.get(model.id);
      if (lastRenderRect) {
        acc.push(lastRenderRect);
      }
      return acc;
    }, [] as IRect[]);
    // 合并所有的renderRect作为初始的矩形
    let dirtyRect = mergeRects(...renderRects);
    for (let i = 0; i < modelList.length; i++) {
      const m = modelList[i];
      // 已经加入队列的元素不参与比较
      if (modelSet.has(m)) continue;
      const renderRect = m.getRenderRect();
      // 不在视图窗口内的元素不考虑
      if (!isRectIntersect(renderRect, viewRect)) continue;
      // 如果与当前帧相交,则需要重绘,将图形加入到重绘队列中
      if (isRectIntersect(renderRect, dirtyRect)) {
        modelSet.add(m);
        dirtyRect = mergeRects(renderRect, dirtyRect);
        // 一旦脏矩形的大小改变了,则重新开始循环
        i = -1;
      }
    }
    return {
      rect: dirtyRect,
      models: modelSet
    };
  }

  // 局部重绘
  partRepaint() {
    // 获取需要重绘的区域大小,与所有需要重绘的元素
    const { rect, models } = this.findDirtyRect();
    if (!models.size) return;

    const ctx = this.mainCtx;
    ctx.save();
    ctx.setTransform(1, 0, 0, 1, 0, 0);
    const viewport = this.viewportManager.getViewport();

    const dpr = getDevicePixelRatio();
    const zoom = this.viewportManager.getZoom();
    const dx = -viewport.x;
    const dy = -viewport.y;
    // 高清屏处理
    ctx.scale(dpr * zoom, dpr * zoom);
    ctx.translate(dx, dy);

    // 清除重绘区域
    ctx.clearRect(rect.x, rect.y, rect.width, rect.height);

    this.box.each(m => {
      if (models.has(m)) {
        this.paintModel(m, ctx);
      }
    });

    ctx.restore();
  }

}

完整代码参见 github.com/kkagura/sto…

结束语

谢谢观看,如果本文对您有什么帮助,请帮忙点个赞。文中有哪些错误也欢迎在评论区指出,笔者一定虚心改正。