【canvas】ctx.fill(fillRule) 的那些坑

需求背景

工作项目中,需要展示这样一个场景:给一张图片,一组坐标点(可围成闭合区域),展示出来区域高亮,其余部分透明或减弱展示。

image.png

实现思路

在 canvas 画布上,用 ctx.drawImage() 把图片放到 canvas 中,接着,通过 ctx.moveTo()ctx.lineTo() 等画出‘圆环’路径,然后 ctx.fill() 一个半透明的白色。

代码放到码上掘金了

关键代码

// 这里要注意图片的加载是异步,所以需要放到 load 事件中执行画图操作;也可以在画区域遮罩之后设置一下 ctx.globalCompositeOperationdestination-over 改一下叠加图层的规则,保持在上面,这样异步图片也不会挡住先画的图形了。

const canvas = document.querySelector('.canvas');
const ctx = canvas.getContext('2d');
canvas.width = 400;
canvas.height = 250;

// 通过坐标点画遮罩
function drawMarkerByPoints (points, fillColor = 'rgba(255, 255, 255, 0.7)') {}

// 坐标点
const points = []

const img = new Image();
img.src = 'https://imageUrl';
img.onload = () => {
  ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
  drawMarkerByPoints(points);
}

我们看一下画区域遮罩的代码:先围着整个画布,走出来外圈路径,然后再根据坐标点,走出来内圈路径,之后就storke()fill()

function drawMarkerByPoints (points, fillColor = 'rgba(255, 255, 255, 0.7)') {
  if (!points.length) return;
  const { width, height } = canvas;
  ctx.beginPath();
  // 顺时针走满整个画布
  ctx.moveTo(0, 0);
  ctx.lineTo(width, 0);
  ctx.lineTo(width, height);
  ctx.lineTo(0, height);
  ctx.lineTo(0, 0);

  // points 是二维数组, 如:[[{x: 0.1, y: 0.1}, {x: 0.1, y: 0.7}, {x: 0.7, y: 0.7}, {x: 0.7, 0.1}]]
  points.forEach((pointsItem, index) => {
    // 画出多边形
    ctx.moveTo(pointsItem[0].x * width, pointsItem[0].y * height);
    for (let i = 1; i < pointsItem.length; i++) {
      ctx.lineTo(pointsItem[i].x * width, pointsItem[i].y * height);
    }
    ctx.lineTo(pointsItem[0].x * width, pointsItem[0].y * height);
  });

  ctx.closePath();
  ctx.strokeStyle = 'rgba(0, 0, 0, 1)';
  ctx.lineWidth = 1;
  ctx.stroke();
  ctx.fillStyle = fillColor;
  ctx.fill();
}

理想与现实总有那么点差距

上面的思路没问题,跑demo也正常展示,但是到实际项目中用的时候,这个遮罩高亮区域总是‘随机’出现,有时出来有时不出来,这可就令人费解了。有规律还好,就怕随机的。如下图,高亮区域没出来,整个canvas 都被fill了。

image.png

但 debug 久了,无意中发现了“规律”:一旦 points 连成区域是顺时针的,高亮遮罩区域就没有出来,反之 points坐标点连成区域顺序是逆时针,就可以展示出来高亮的遮罩区域了!

可以在画内圈路径的时候用这个验证一下:

pointsItem = [...pointsItem].reverse();

在边挠头边查资料之后,bug 定位到了 fill 方法上:

fill 可以指定两种填充算法规则:’nonzero’, ‘evenodd’,默认是 ‘nonzero’

本来看到这种,都是跳过,懒得去理解非零绕组原则、奇偶绕组原则,因为一眼没看懂。

于是找来相关文章了解一下 fill的这两个规则,参考这篇文章

在了解了这两个规则之后,我终于知道为什么会随机出现了,咱这个需求的点的来源,是用户点击绘制的,用户可能顺时针也可以逆时针绘制区域,也就是点的顺逆时针是随机的,所以也就导致了上面代码,在绘制的时候,走外圈路径永远是顺时针,而内圈路径可能是顺时针也可能是逆时针,根据非零原则,都是顺时针的时候,中间区域也会被 fill了,逆时针就不会。

解决方案有二

1. 内圈顺时针判断

既然知道了非零原则下的表现,那么我们只要保证内圈路径在画的时候,时针顺序跟外圈路径是相反的就行,外圈我们固定顺时针,那画内圈路径就逆时针。

...
points.forEach((pointsItem, index) => {
    // 判断是否顺时针
    if (isClockwise(pointsItem)) {
      pointsItem = [...pointsItem].reverse();
    }
    // 画出多边形
    ...
});
// 已知多个点,判断顺时针方向
function isClockwise (points) {
  let sum = 0;
  for (let i = 0; i < points.length; i++) {
    const p1 = points[i];
    const p2 = points[(i + 1) % points.length];
    sum += (p2.x - p1.x) * (p2.y + p1.y);
  }
  return sum <= 0;
}

顺时针的判断,涉及到一些数学知识,Github Copilot 生成的(真香),没太理解,不过,确实解决了我的问题。

2. 奇偶绕组原则

第二个原则,跟画圈路径的顺逆时针没关系,只跟区域内射出线相交的路径数量有关,奇数就fill, 偶数就不fill,那在上述需求场景中,中间高亮区域射出线,跟路径相交的数量永远是2,为偶数,不填充(既高亮),那么指定奇偶绕组原则来解决问题更简单。

...
ctx.fill('evenodd');
...

总结

  1. 实践是检验真理的唯一标准,没有踩坑,就不会去了解这些填充规则。
  2. 为什么会有两个方案,是因为我好学喜欢研究么,不是,而是我急着解决问题,只了解了非零原则就跑去debug了,如果我耐心点,就会发现奇偶原则解决起来更简单。
© 版权声明
THE END
喜欢就支持一下吧
点赞5 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容