canvas2d绘图中的9个小技巧

今天总结一下canvas2D绘图中的几个小技巧,希望对大家有所帮助。这里说的2d绘图是指CanvasRenderingContext2D接口的使用,并不包含webgl的2d绘图方面。

1、使用Path2D管理路径

在绘制的图形较多时,单纯使用context绘制的方式去重绘图形、做选中检测、管理图形等都比较麻烦。

你可以使用Path2D创建好每个要绘制的图形路径,然后传到fill(),stroke()方法中绘制。将每个创建的Path2D对象保存起来,等重绘、选中检测管理操作时再取出来直接使用,非常方便。

以下是一个简单的使用示例,其更多api可查看MDN WEB文档。

var ctx = canvas.getContext("2d");
// 第一个路径
let path0 = new Path2D();
path0.rect(20, 130, 50, 50);
// 绘制该路径
ctx.fill(path0);

// 多个子路径的情况
let path1 = new Path2D();
path1.rect(10, 10, 100, 100);

let path2 = new Path2D(path1);
path2.moveTo(220, 60);
path2.arc(170, 60, 50, 0, 2 * Math.PI);

ctx.stroke(path2);

canvas.addEventListener('mousemove', (event) => {
  // 判断选中检测时直接传入 path
  const isPointInPath = ctx.isPointInPath(path0, event.offsetX, event.offsetY);
  const isPointInStroke = ctx.isPointInStroke(
    path0,
    event.offsetX,
    event.offsetY
  );

  const isPointInPath2 = ctx.isPointInPath(path2, event.offsetX, event.offsetY);
  const isPointInStroke2 = ctx.isPointInStroke(
    path2,
    event.offsetX,
    event.offsetY
  );

  if (isPointInPath || isPointInStroke) console.info('已选中图形1');
  if (isPointInPath2 || isPointInStroke2) console.info('已选中图形2');
});

2、使用保存与恢复

canvas2d绘图时需要频繁设置一些全局状态属性来绘制每个图形。而使用save,restore方法可以减少一些赋值操作。

save()方法是将当前全局状态压入栈中,restore()则是移除当前栈顶的状态数据,恢复为上一次保存的全局状态信息。

就这两个方法的使用而言,你可以:

  • 绘制时尽量先把有同状态的图形绘制完成再使用restore切换到之前的状态进行绘制。
  • 优先save频率高的状态组,这方便restore切回去使用。
const ctx = canvas.getContext('2d');
ctx.fillStyle = 'green';
ctx.beginPath();
...
ctx.fill();
// 连续绘制一些同状态属性的图形
...
ctx.save(); // 保存 fillStyle状态
// 绘制一个特殊颜色的图形
ctx.fillStyle = 'red';
ctx.beginPath();
...
ctx.fill();
ctx.restore(); // 绘制之前的状态,继续绘制

可以保存的状态属性

  • 当前的变换矩阵。
  • 当前的剪切区域。
  • 当前的虚线列表。
  • 以下属性当前的值:strokeStyle, fillStyle, globalAlpha, lineWidth, lineCap, lineJoin, miterLimit, linDashOffset, shadowOffsetX, globalCompositeOperation,font, textAlign, textBaseline, direction, imageSmoothingEnabled

3、使用嵌套canvas

嵌套canvas并不是指canvas标签嵌套,而是绘图时可以直接将另一个canvas的内容绘制进来。这相当于一种分块的操作,当场景复杂时你可以使用多个canvas分别绘制不同的部分,代码上管理起来比较方便。

const ctx1 = canvas1.getContext('2d');
const ctx2 = canvas2.getContext('2d');
// 第2个canvas上绘制一些图形
ctx2.beginPath();
...
// 将第2个canvas中的图像绘制进来
ctx1.drawImage(canvas2,100,100);

4、使用离屏canvas

就是指不显示出来的canvas元素(style设置display:none),由于不显示出来所以不会有渲染消耗,你可以使用它的绘图api预先进行绘制,需要用到时再将其结果渲染出来。比如:

  • 先预先进行绘制,进入要显示的页面时将其导成图片,或使用另一个canvas绘制显示。
  • 制作动画时可以使用多个离屏canvas预先绘制好后面几帧。
<canvas id="mycanvas" width="500" height="500" style="display:none;">您的浏览器不支持canvas</canvas>
<script>
const ctx = mycanvas.getContext('2d');
</script>

OffscreenCanvas:一个创建离屏canvas的api,还可以在Web Worker中使用,在worker中绘制好图形传到主线程使用。

const offscreenCanvas = new OffscreenCanvas(width, height);
const ctx = offscreenCanvas.getContext('2d');
// 在这里绘制图形  
ctx.fillStyle = 'blue';  
ctx.fillRect(0, 0, width, height);  
// 将 OffscreenCanvas 内容转换为 ImageBitmap  
const imageBitmap = await offscreenCanvas.transferToImageBitmap();
// 另一个context使用
ctx2.drawImage(imageBitmap, 0, 0, 100, 100);

5、建立层级

canvas上绘制的图形在叠加时默认都是显示在之前图形的上方,不像普通dom那样简单调整z-index层级就可实现上下关系的变化。

多数canvas绘图库都有层级的概念,这种层级一般都是使用多个canvas元素(1个canvas为1层)上下覆盖实现的。

使用层级管理之后我们可以将那些静态的内容都绘制到同一层中,有交互要求的图形再单独绘制到一个canvas上,这样每次都只用在这个需要变化的图形所在的canvas上进行操作即可。如此可方便管理和避免一些重绘

<canvas id="c1" width="1000" height="1000" style="z-index:1;"></canvas>
<canvas id="c1" width="1000" height="1000" style="z-index:2;"></canvas>
<style>
canvas{
    position:absolute;
    top:0;
    left:0;
}
</style>

6、图片绘制

绘制矢量图
在绘制得图片使用矢量图,绘制后依然具有矢量的特性,即使你在绘制时改变宽高,使用缩放。当然,倘若你对canvas元素进行缩放的话是没有矢量效果的。矢量图中包含动画,绘制之后也不会生效。

如果你选用canvas绘图又想要高保真效果时就可以将图片尽量换成矢量的,另外canvas外层容器大小变化时考虑设置canvas的全局缩放属性,而非对元素进行缩放,这样可以尽量保持你的绘图不失真。

const str = `<svg width="70px" height="70px xmlns...</svg>`;// svg内容
const img = new Image();

img.onload = function() {
    ctx.drawImage(img,0,0,200,200);
}
img.src = "data:image/svg+xml," + encodeURIComponent(str); // svg矢量图

几个api性能对比

  • drawImage()方法比putImageData()方法性能更好;
  • drawImage(canvas)drawImage(img)的速度要稍快;
  • getImageData方法较耗时,尽量少使用;

7、动画函数的选用

虽然requestAnimationFrame函数更推荐拿来制作动画,但不代表完全放弃setInterval,先看两者的特点。

requestAnimationFrame

  • 回调次数通常与浏览器屏幕刷新次数相匹配。
  • 回调与刷新同步:回调函数会在浏览器下一次重绘之前执行,即每帧动画函数的调用与dom刷新是同步的,不会出现当前帧运行完,上一帧对应的dom内容却还没刷新的情况。
  • 精确的时间控制:每次回调间隔的时间一般都是相差不大的,比较稳定。
  • 节能:离开当前页面后浏览器会为了节约资源而减少对动画函数的回调次数,甚至完全停止。

setInterval

  • 定时执行:即使离开当前页面也会定时的执行回调。
  • 精确度稍差:虽然是定时执行但它每次的回调间隔波动比起requestAnimationFrame稍大。

所以如果你的动画有包含:不要求刷新频率太高离开页面时也要执行,就可以考虑使用setInterval,反之更推荐使用requestAnimationFrame

8、使用时间动画

requestAnimationFrame函数的回调频次是浏览器根据当前状态来决定的,虽然它多数时候都比较稳定,但存在多个动画、用户设备配置稍差的情况时也难免出现回调频次变化。

如果你使用每帧移动固定像素值的方式实现动画,在掉帧后就会导致你动画速率的变化。使用时间动画可以改善,让其更加平稳。

以下是一个只指定物体移动速度的时间动画示例(如果是指定了持续时间、移动距离的情况可以先计算平均速度再按下面方式实现)。

// v为平均速度 30px/s, x为移动距离
const rectObj = { v: 30, x: 0 };
const maxDistance = 300;
let timer = null;

function draw(x){
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.fillRect(x, 0, 20, 20);
  ctx.fill();
}
// time: requestAnimationFrame传递进来的 距开始时经过时间,单位毫秒
function animation(time) {
  rectObj.x = rectObj.v * time/1000;
  // 大于指定值后不再运行
  if (rectObj.x < maxDistance) {
    draw(rectObj.x);
    timer = window.requestAnimationFrame(animation);
  } else {
    // 用户中途离开页面又再次返回的情况处理
    rectObj.x = maxDistance;
    draw(rectObj.x);
    window.cancelAnimationFrame(timer);
  }
}

timer = window.requestAnimationFrame(animation);

以上是匀速动画的例子,对于变速动画大致思想也相同,比如贝塞尔速度曲线,上面匀速变化的x可以改为贝塞尔曲线的插值参数t,然后带入之前定义好的贝塞尔函数计算出当前y值,再与上一次y值对比计算当前帧速率、最终移动的位置。

9、处理过度绘制问题

过渡绘制是指绘制了一些最终并不被呈现出来的图形。比如在可视区域外绘制图形、先绘制的图形被后绘制的图形覆盖住(不含透明)的情况,这些都是用户看不见的,无需绘制的图形。过度绘制对性能的影响是比较大的,尤其在3d绘图中。可以尝试使用以下思路进行一些处理:

  1. 判断所绘图形位置是否在可视区域内,超出可视区域外的图形不再绘制。
  2. 有缩放操作的场景,某些图形缩放得太小也可以考虑不再绘制。
  3. 计算当前图形是否有被其它图形完全覆盖住,若有则不再绘制(可用包围盒、点在几何中的判断等方法组合计算)

最理想的情况是只绘制最终能被用户看到的部分,但对于只被覆盖了一部分的这类图形比较难处理(暂未研究过)

参考
《HTML5 Canvas核心技术》,《canvas游戏开发》

原文链接:https://juejin.cn/post/7343807967329746981 作者:web像素之境

(0)
上一篇 2024年3月9日 下午4:32
下一篇 2024年3月9日 下午4:42

相关推荐

发表回复

登录后才能评论