非零与奇偶环绕规则

1,270 阅读4分钟

非零环绕规则和奇偶环绕规则在Canvas和SVG中有很重要的用途,经常用它们制作有趣的剪纸效果。两种规则存在矛盾,所以开发者需要根据需求指定使用哪一种规则。一图胜千言,我画了两张涂鸦,分别解释这两种算法的原理。如何使用这两个规则的废话也不多说,代码也能胜千言,我会在下方拿出代码片段。

非零环绕规则

ctx.beginPath();
// 逆时针画个小圆弧
ctx.arc(0, 0, 40, 0, Math.PI * 2, true);
// 顺时针画个大圆弧
ctx.arc(0, 0, 60, 0, Math.PI * 2, false);
ctx.closePath();
// 当不给 fill() 方法传入参数时,默认参数是'nonzero',即使用非零环绕规则来填充路径
ctx.fill();

已知以上canvas代码片段,如果按照非零环绕规则的算法,渲染引擎会如何知道哪里填色?哪里不填色呢?

我们分别分析下图5个点是否落在了需要填充的区域内。假设各点都有一个计数器,初始值为0。从各点向任意方向引一条足够长的射线,每条穿过圆弧路径的射线都是有效的,每条射线都在与路径的交叉点上将圆弧路径分为了两侧。我们既可以说射线穿过可圆弧,由于圆弧路径也有绘制方向(顺时针和逆时针),所以也可以说圆弧路径穿过了射线。我们规定,其中一侧穿过射线,则计数器+1,从另一侧穿过射线则-1。最后,如果该点计数器不为0(即非零),则该点所在的区域需要填充颜色:

  • A点
    引出的射线:它依次穿过小圆弧和大圆弧。小圆弧从左向右穿过射线,假设凡从这一侧穿过的则计数器+1;后来大圆弧从右向左穿过射线,所以-1。最终结果为0。A点所在区域不填充颜色。
  • B点
    引出的射线:它只穿过了大圆弧,所以必然计数器不为0。B点所在区域填充颜色。
  • C点
    引出的射线:它先穿过大圆弧->穿过小圆弧->再次穿过小圆弧->最后穿过大圆弧。规定当圆弧路径从射线上方穿过射线时,计数器+1,反之从射线下方穿过射线时-1。所以依次0+1-1+1-1,结果为0。所以C点所在区域不填充颜色。
  • D点
    引出的射线:它只穿过了大圆弧,仍然规定当路径从射线下方穿过时-1,反之+1,结果是-1。所以D点填充颜色。
  • E点
    引出的射线:它穿过了两次大圆弧,但是第一次路径从下方穿过射线,第二次路径从上方穿过射线,两次抵消,所以不填充。

奇偶环绕规则

ctx.beginPath();
// 逆时针画个小圆弧
ctx.arc(0, 0, 40, 0, Math.PI * 2, true);
// 逆时针再画个大圆弧
ctx.arc(0, 0, 60, 0, Math.PI * 2, true);
ctx.closePath();
// 当不给 fill() 方法传入参数时,默认参数是'nonzero',即使用非零环绕规则来填充路径
ctx.fill();

和非零环绕规则不同,奇偶环绕规则的算法不会“计较”路径的矢量方向,所以在上面代码中,我虽然依然绘制了一大一小的两个圆弧,但它们最后一个参数相同。奇偶环绕规则比非零环绕规则更好理解,虽然 context.fill() 方法的默认参数是 nonzero (非零环绕规则)而不是 evenodd (奇偶环绕规则)。依然任选5个点,向任意方向画射线,记录射线穿过路径而产生的交叉点的个数,或者说穿过路径的次数,如果结果为奇数,则填充,反之为偶数则不填充。

  • A点
    引出的射线:先穿过小圆弧,记1次,又穿过大圆环,再记1次,共两次,偶数——所以A点所在区域不填充。
  • B点
    引出的射线只穿过1次大圆弧,奇数——此区域填充。
  • C点
    引出的射线共穿了4词,偶数——不填充此区域。
  • D点
    引出的射线还是只穿过1次大圆弧,奇数——填充此区域。
  • E点
    引出的射线穿过两次大圆弧,偶数——不填充此区域。

矛盾的两个规则

ctx.translate(canvas.width / 2, canvas.height / 2);
ctx.beginPath();
ctx.moveTo(-20, 20);
ctx.lineTo(20, -20);
ctx.lineTo(10, 50);
ctx.lineTo(-20, -30);
ctx.lineTo(40, 30);
ctx.closePath();
ctx.fill('evenodd');
// ctx.fill('nonzero');
ctx.strokeStyle = '#f00';
ctx.lineWidth = 4;
ctx.stroke();

同样的路径绘制步骤,但是只改变 context.fill() 方法——传入代表不同算法规则的参数,最终的填充结果就不一样。

代码片段

ctx.save();
ctx.translate(canvas.width / 2, canvas.height / 2);
ctx.beginPath();
ctx.arc(-40, 0, 60, 0, Math.PI * 2, true);
ctx.arc(0, 0, 60, 0, Math.PI * 2, false);
ctx.arc(40, 0, 60, 0, Math.PI * 2, true);
// 默认使用nonzero,即非零环绕规则填充路径
ctx.fill();
ctx.restore();


ctx.save();
ctx.translate(canvas.width / 2, canvas.height / 2);
ctx.beginPath();
// 第一个圆弧按逆时针旋转一周
ctx.arc(0, 0, 50, 0, Math.PI * 2, true);
// 第二个圆弧按顺时针旋转一周
ctx.arc(0, 0, 60, 0, Math.PI * 2, false);
// 默认使用nonzero,即非零环绕规则填充路径
ctx.fill();
ctx.restore();


ctx.save();
ctx.translate(canvas.width / 2, canvas.height / 2);
ctx.beginPath();
// 两个弧形都按逆时针绘制
ctx.arc(0, 0, 50, 0, Math.PI /180 * 180, true);
ctx.arc(0, 0, 60, 0, Math.PI * 2, true);
// 使用奇偶环绕规则
ctx.fill('evenodd');
ctx.restore();