Canvas 性能优化有哪些办法?分层、离屏 Canvas、脏矩形重绘分别解决什么问题?
Canvas 每一帧都要靠 JavaScript 重画,优化的思路是少画和画得便宜:分层让不变的内容不重画,离屏 Canvas 缓存重复图形,脏矩形只重画变化的区域,批量绘制和减少状态切换降低每次调用的开销,坐标取整避免额外抗锯齿。高清屏按 devicePixelRatio 放大位图解决模糊,绘制太重时再考虑 OffscreenCanvas 放进 Worker。
简答
用 Canvas 做一个有几千个点在动的图表或者小游戏,浏览器不会替你记住画面里有哪些东西,每一帧都要由 JavaScript 擦掉再重画。所以 Canvas 卡不卡,取决于每一帧画了多少、每次画得贵不贵。
第一类办法是少画。把很少变化的背景、经常变化的主体、只在操作时变化的界面分到叠在一起的几个
<canvas>上,每层只在自己变化时重画;重复出现的复杂图形先画到一张不显示的离屏 Canvas 上,之后每帧直接drawImage贴过去;只有一小块变化时,算出变化的矩形,只擦这一块、只重画这一块,这叫脏矩形重绘。第二类办法是画得便宜。能合成一条路径的线就一起画,减少调用次数;少切换颜色、线宽这些绘图状态;坐标取整,避免浏览器做亚像素抗锯齿;不需要透明背景就用
alpha: false创建上下文;动画用requestAnimationFrame驱动。还要注意两件事。高清屏上 Canvas 会模糊,要把位图尺寸乘以
devicePixelRatio,再用scale把坐标换回 CSS 像素,但位图越大每帧要处理的像素也越多。如果绘制本身就很重、和主线程的其他工作抢时间,可以用OffscreenCanvas把绘制放进 Worker,但它不会让绘制本身变快。
为什么要分层
假设一个小游戏里有一张复杂的地图背景、几十个在动的角色,还有一个分数面板。只用一个 Canvas 的话,哪怕只有角色在动,每一帧也得把背景和面板跟着重画,因为擦掉画面时它们也一起被擦掉了。
MDN 的 Optimizing canvas 建议,场景里有的东西经常变、有的基本不变时,把它们分到叠在一起的多个 <canvas> 上:
<div id="stage">
<canvas id="ui-layer" width="480" height="320"></canvas>
<canvas id="game-layer" width="480" height="320"></canvas>
<canvas id="background-layer" width="480" height="320"></canvas>
</div>
#stage {
position: relative;
width: 480px;
height: 320px;
}
canvas {
position: absolute;
}
#ui-layer {
z-index: 3;
}
#game-layer {
z-index: 2;
}
#background-layer {
z-index: 1;
}
三个 Canvas 绝对定位叠在一起,背景层只在初始化时画一次,角色层每帧重画,界面层只在分数变化时重画。如果背景只是一张静态大图,MDN 还建议干脆别画进 Canvas,直接用 CSS 的 background 放在底下的元素上。
离屏 Canvas 缓存重复图形
另一种浪费是同一个复杂图形每帧都从头画一遍,比如带渐变和阴影的按钮、由几十段路径组成的图标。MDN 的建议是先把它画到一个不显示在页面上的 Canvas 里,之后只用 drawImage 把这张“预渲染好的图”贴到主画布上:
const sprite = document.createElement('canvas');
sprite.width = 64;
sprite.height = 64;
drawComplexIcon(sprite.getContext('2d'));
function frame(ctx, items) {
for (const item of items) {
ctx.drawImage(sprite, Math.floor(item.x), Math.floor(item.y));
}
}
这里的“离屏 Canvas”只是一个没有挂到页面上的普通 <canvas> 元素,仍然在主线程上画,作用是把重复计算变成一次贴图。它和 OffscreenCanvas 这个 API 名字很像,但不是一回事,后者在后面单独讲。同理,MDN 也建议图片要缩放时,加载后先在离屏 Canvas 上缩放好缓存起来,不要每次 drawImage 都带缩放参数。
脏矩形:只重画变化的区域
MDN 在“更多建议”里提到,只渲染画面的差异,而不是整个新状态。常见的实现叫脏矩形:记下这一帧哪块区域变了,只擦这一块、只重画落在这块里的图形。
一个方块从旧位置移到新位置,要重画的区域是旧位置和新位置合起来的外接矩形,因为旧位置要擦掉,新位置要画上。下面先算出这个矩形,并把边界扩到整数像素:
function union(a, b) {
const left = Math.min(a.x, b.x);
const top = Math.min(a.y, b.y);
const right = Math.max(a.x + a.w, b.x + b.w);
const bottom = Math.max(a.y + a.h, b.y + b.h);
return { x: left, y: top, w: right - left, h: bottom - top };
}
function snap(rect) {
const x = Math.floor(rect.x);
const y = Math.floor(rect.y);
return {
x,
y,
w: Math.ceil(rect.x + rect.w) - x,
h: Math.ceil(rect.y + rect.h) - y,
};
}
const before = { x: 10.4, y: 20.6, w: 30, h: 30 };
const after = { x: 18.4, y: 20.6, w: 30, h: 30 };
const dirty = snap(union(before, after));
console.log(dirty.x, dirty.y, dirty.w, dirty.h); // 10 20 39 31
console.log(dirty.w * dirty.h); // 1209
console.log(800 * 600); // 480000
为什么要向外取整?方块的边落在小数坐标上时,边缘那一列像素只被盖住一部分,按小数坐标擦会留下残影。左上角向下取整、右下角向上取整,擦的范围就把这些像素整个包进去了。
算出来的区域是 39 × 31,共 1209 个像素,而整张 800 × 600 的画布有 48 万个像素。拿到这个矩形以后,先擦掉它,再用 clip() 把后续绘制限制在这块区域里,把和它相交的图形重画一遍:
function redraw(ctx, dirty, shapes) {
ctx.save();
ctx.beginPath();
ctx.rect(dirty.x, dirty.y, dirty.w, dirty.h);
ctx.clip();
ctx.clearRect(dirty.x, dirty.y, dirty.w, dirty.h);
for (const shape of shapes) {
if (intersects(shape.bounds, dirty)) shape.draw(ctx);
}
ctx.restore();
}
clip() 会把当前路径变成裁剪区域,之后画的东西超出这块就不会落到画布上,所以和脏矩形只相交一部分的图形,也不会把外面画坏。save() 和 restore() 用来在画完后撤掉裁剪。
脏矩形也有代价,要维护每个图形的包围盒、做相交判断。如果每帧大部分画面都在变,比如整屏粒子动画,算脏区域反而白费功夫,直接全量重画更简单。
怎样让每次绘制更便宜
先说调用次数。MDN 建议把绘制调用合并起来,比如画一条折线,而不是画很多条独立的线段。下面两种写法画出的折线一样,第二种只描一次边:
for (let i = 1; i < points.length; i += 1) {
ctx.beginPath();
ctx.moveTo(points[i - 1].x, points[i - 1].y);
ctx.lineTo(points[i].x, points[i].y);
ctx.stroke();
}
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i += 1) {
ctx.lineTo(points[i].x, points[i].y);
}
ctx.stroke();
同样的道理,MDN 也建议避免不必要的绘图状态切换。比如散点图里红点和蓝点交错,与其每画一个点都改一次 fillStyle,不如先按颜色分组,同色的点一起画。
再说坐标。MDN 指出,在非整数坐标上绘制会触发亚像素渲染,浏览器要多做抗锯齿计算,所以传给 drawImage() 的坐标要先用 Math.floor() 之类的办法取整,上面贴图的例子就是这样做的。
还有几条容易做到的。不需要透明背景时,用 canvas.getContext('2d', { alpha: false }) 创建上下文,浏览器可以据此在内部优化渲染。shadowBlur 和文字绘制都比较贵,MDN 建议能不用就不用。动画用 requestAnimationFrame() 而不是 setInterval() 驱动,它会在浏览器下一次重绘之前调用回调。
高清屏为什么会模糊,放大又有什么代价
Canvas 有两个尺寸:width、height 属性决定位图有多少个像素,CSS 的宽高决定它在页面上显示多大。devicePixelRatio 是物理像素和 CSS 像素的比值,在 2 倍屏上,一个 CSS 像素对应 2 × 2 个物理像素。如果位图只有 CSS 尺寸那么大,浏览器就要把它放大两倍显示,看起来就糊了。MDN 给出的做法是:
const dpr = window.devicePixelRatio;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
canvas.style.width = `${rect.width}px`;
canvas.style.height = `${rect.height}px`;
位图尺寸乘以 dpr,再用 ctx.scale(dpr, dpr) 让后面的绘制代码还能按 CSS 像素写坐标,显示尺寸保持不变。这样一来,2 倍屏上位图的像素数是原来的 4 倍,每帧要清除和填充的像素也跟着变多。所以在性能吃紧的场景里,要在清晰度和速度之间权衡。为什么 Canvas 在高清屏上模糊,也可以看《Canvas 和 SVG 怎样选择?》。
什么时候用 OffscreenCanvas
前面这些办法减少的是绘制本身的工作量。还有一种情况是绘制已经很省了,但主线程同时在跑大量业务逻辑,两边互相抢时间,页面一卡动画就跟着卡。这时可以用 OffscreenCanvas:主线程调用 canvas.transferControlToOffscreen(),把画布的控制权转给 Worker,Worker 里画的内容会直接显示在页面的 <canvas> 上。MDN 的说明是,渲染操作放进 Worker,可以在单独的线程里做这些工作,避免主线程上的繁重工作。
它解决的是“和谁抢时间”,不是“画得慢”。每一帧画什么、画多少,还是取决于上面那些优化。具体怎样把绘制交给 Worker、哪些情况下用了也没用,可以看《OffscreenCanvas 什么时候能减少主线程绘制压力?》。
常见追问
分层以后点击事件怎么处理?
分层只改变了画在哪里,图形的数据还在 JavaScript 里。可以在外面的容器上统一监听指针事件,把坐标换算好,再按从上到下的顺序判断点中了哪一层的哪个图形,详见《Canvas 图形命中测试应该怎样实现?》。
清空画布用什么方法?
最常用的是
clearRect(),它把指定矩形里的像素变成透明黑色。MDN 也提到可以试试fillRect()或者重新设置画布尺寸来清空,哪种快要在目标设备上实测。