前言
本文代码所在的仓库(还请各位帮忙给个star)
在线体验地址
什么是局部重绘
在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方法。但是这种处理方式还存在一些问题,例如以下场景:
当上图中矩形A的样式发生改变时,按照我们的实现方案,会对其进行重新绘制,重新绘制以后会导致原本被矩形B遮挡的A却来到了矩形B的上方,这是因为我们只重绘了图形A没有重绘图形B导致的。
所以在计算脏矩形时,不光要考虑属性发生变化的元素,我们还要不断遍历场景中所有的元素,判断其它元素的renderRect是否与脏矩形相接,一旦相接时,需要将其也加入到重绘队列中去。
移动图形
我们再考虑下图的场景:
假如我们将矩形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…
结束语
谢谢观看,如果本文对您有什么帮助,请帮忙点个赞。文中有哪些错误也欢迎在评论区指出,笔者一定虚心改正。