WebGL Tutorial
and more

Fill Rule

撰写时间:2024-08-04

修订时间:2024-08-04

填充的真谛

当我们要填充一个闭合图形时,我们当然是希望在该图形的内部区域进行填充。

ctx.fillStyle = 'hsl(180, 50%, 30%)'; ctx.strokeStyle = '#CCC'; ctx.beginPath(); ctx.moveTo(100, 30); ctx.lineTo(30, 150); ctx.lineTo(170, 150); ctx.closePath(); ctx.fill(); ctx.stroke();
ctx.shadowColor = '#000'; ctx.shadowOffsetX = 12; ctx.shadowOffsetY = 12; ctx.shadowBlur = 15;

上面代码使用3条边绘制了一个闭合的三角形,之后,调用fill方法时,则对这个三角形的内部区域进行填充。

注意,在设置阴影后,如果不描边,则图形内部不会产生阴影效果;但如果描边,则边框线的内部也会按阴影方向产生阴影效果。

下面对两个闭合图形进行填充。

ctx.strokeStyle = '#CCC'; ctx.beginPath(); ctx.moveTo(100, 30); ctx.lineTo(30, 150); ctx.lineTo(170, 150); ctx.closePath(); ctx.fillStyle = 'hsl(180, 50%, 30%)'; ctx.fill(); ctx.stroke(); ctx.beginPath(); ctx.arc(100, 105, 25, 0, Math.PI * 2); ctx.fillStyle = 'hsl(80, 50%, 30%)'; ctx.fill(); ctx.stroke();
ctx.shadowColor = '#000'; ctx.shadowOffsetX = 12; ctx.shadowOffsetY = 12; ctx.shadowBlur = 15;

填充了两个闭合图形,其中圆位于三角形的内部。两种填充完全独立进行。这是因为这两个图形分属于两个独立的路径(调用了两次ctx.beginPath())。

下面只调用一次ctx.beginPath(),以将这两个图形合并进一个路径中。

ctx.fillStyle = 'hsl(180, 50%, 30%)'; ctx.strokeStyle = '#CCC'; ctx.beginPath(); ctx.moveTo(100, 30); ctx.lineTo(30, 150); ctx.lineTo(170, 150); ctx.lineTo(100, 30); ctx.arc(100, 105, 25, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
ctx.shadowColor = '#000'; ctx.shadowOffsetX = 12; ctx.shadowOffsetY = 12; ctx.shadowBlur = 15;

两个问题。一是多出一条杂线。二是中间的圆被挖掉了。

先解决多出一条杂线的问题。造成上面问题的原因是:当绘制完三角形后,当前点位于三角形的最上角,然后因为需要从内圆的最右侧开始绘制内圆,则自动从当前点的位置添加一条直线至内圆的最右侧,然后再开始绘制内圆。若想消除此直线,只需在绘制内圆的语句之前添加一条moveTo语句即可:

ctx.fillStyle = 'hsl(180, 50%, 30%)'; ctx.strokeStyle = '#CCC'; ctx.beginPath(); ctx.moveTo(100, 30); ctx.lineTo(30, 150); ctx.lineTo(170, 150); ctx.lineTo(100, 30); ctx.moveTo(100 + 25, 105); ctx.arc(100, 105, 25, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
ctx.shadowColor = '#000'; ctx.shadowOffsetX = 12; ctx.shadowOffsetY = 12; ctx.shadowBlur = 15;

来看第二个问题,即为何内圆部分被挖掉了。当三角形与圆共同组成一个图形时,其内部区域实际上可分为两种情况。上面是第一种情况。下面我们保留上面的效果,并手工绘制出第二种情况。

ctx.fillStyle = 'hsl(180, 50%, 30%)'; ctx.strokeStyle = '#CCC'; ctx.beginPath(); ctx.moveTo(100, 30); ctx.lineTo(30, 150); ctx.lineTo(170, 150); ctx.lineTo(100, 30); ctx.moveTo(100 + 25, 105); ctx.arc(100, 105, 25, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); ctx.translate(200, 0); ctx.beginPath(); ctx.moveTo(100, 30); ctx.lineTo(30, 150); ctx.lineTo(170, 150); ctx.lineTo(100, 30); ctx.stroke(); ctx.beginPath(); ctx.arc(100, 105, 25, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
ctx.shadowColor = '#000'; ctx.shadowOffsetX = 12; ctx.shadowOffsetY = 12; ctx.shadowBlur = 15;

也即,所谓内部区域,即可以是三角形与内圆之间的部分,也可以仅是内圆区域。只有内部区域才被填充,不是内部区域不被填充。现在的问题是,我们如何规范哪个区域才是内部区域?

Canvas 2D通过设置填充规则的方式,用以解决此问题。

填充规则

fill方法有一个可选参数fillRule,用以指定填充规则,实际就是确定内部区域的方式。其值只有两个,一个是nonzero(默认值),另一个是evenodd

nonzero

nonzero意为非0值。也即,将通过一定的方法来计算出一个数值,如果该数值不等于0,则该区域被认定为内部区域而应填充;如果该数值等于0,则该区域被认定为外部区域而不予填充。

HTML 5规范有下列定义:

The value "nonzero" value indicates the nonzero winding rule, wherein a point is considered to be outside a shape if the number of times a half-infinite straight line drawn from that point crosses the shape's path going in one direction is equal to the number of times it crosses the path going in the other direction.

一句话概括,即任意一个点所引出的射线与图形不同方向的边的相交次数是否相等。如果相等,则属外部区域;如果不等,则属内部区域。

具体来说:

  1. 取任意一个点
  2. 从该点引出任意一条射线
  3. 检查其与图形不同方向的路径相交的情况
  4. 如果该射线与不同方向绘制的路径相交的次数相等,则该点属于图形路径的外部区域;如果不等,则该点为图形的内部区域。

看下面图例:

ctx.beginPath(); ctx.moveTo(100, 30); ctx.lineTo(30, 150); ctx.lineTo(170, 150); ctx.lineTo(100, 30); ctx.fillStyle = 'hsl(230, 30%, 40%)'; ctx.strokeStyle = '#CCC'; ctx.fill(); ctx.stroke(); drawRadialLine(100, 100, 200, 80, 'hsl(120, 50%, 50%)'); drawRadialLine(80, 170, 50, 40, 'hsl(0, 70%, 60%)'); function drawRadialLine(fromX, fromY, toX, toY, fillColor) { ctx.beginPath(); ctx.moveTo(fromX, fromY); ctx.lineTo(toX, toY); ctx.strokeStyle = 'white'; ctx.stroke(); ctx.beginPath(); ctx.arc(fromX, fromY, 5, 0, Math.PI * 2); ctx.fillStyle = fillColor; ctx.fill(); }
ctx.shadowColor = '#000'; ctx.shadowOffsetX = 12; ctx.shadowOffsetY = 12; ctx.shadowBlur = 15;

三角形是按逆时针方向来绘制的。任取两点,分别为绿点与红点。

其中,绿点所在射线与图形的边只相交1次,无与相反方向的边相交,因此,绿点位于图形内部区域。

红点所在射线先与底边相交1次,该边的方向是从左到右,再与左斜边相交1次,该边的方向从右到左,方向相反,因此,两种情况的相交的次数正好相等,因此,红点位于图形外部区域。

为何取名为nonzero?在计算相交次数时,凡遇与某一方向的边相交一次,则计数加1;凡遇与相反方向的边相交一次,则计数减1。结果如果不为0,则应填充。计数方式不同而已。

再看之前被挖掉的圆洞的情况。

ctx.beginPath(); ctx.moveTo(100, 30); ctx.lineTo(30, 150); ctx.lineTo(170, 150); ctx.lineTo(100, 30); ctx.moveTo(100 + 25, 105); ctx.arc(100, 105, 25, 0, Math.PI * 2); ctx.fillStyle = 'hsl(230, 30%, 40%)'; ctx.strokeStyle = '#CCC'; ctx.fill(); ctx.stroke(); drawRadialLine(100, 100, 200, 80, 'hsl(120, 50%, 50%)'); drawRadialLine(140, 135, 30, 90, 'hsl(0, 70%, 60%)'); function drawRadialLine(fromX, fromY, toX, toY, fillColor) { ctx.beginPath(); ctx.moveTo(fromX, fromY); ctx.lineTo(toX, toY); ctx.strokeStyle = 'white'; ctx.stroke(); ctx.beginPath(); ctx.arc(fromX, fromY, 5, 0, Math.PI * 2); ctx.fillStyle = fillColor; ctx.fill(); }
ctx.shadowColor = '#000'; ctx.shadowOffsetX = 12; ctx.shadowOffsetY = 12; ctx.shadowBlur = 15;

arc的最后一个参数是counterclockwise,即是否按逆时针方向绘制,默认值为false。因此,默认情况下,或当我们显式地将其值设置为false时,则按顺时针方向绘制圆。当该参数为true时,则按逆时针方向绘制圆。因此,上面的圆是按顺时针来绘制的。

此时,绿点所在射线先与圆弧相交,相交时圆弧的方向是从上到下,再与三角形的右边相交,相交时该边的方向是从下到上。两者方向相反。因此,绿点射线与不同方向的边所相交的次数相等,故绿点所在的位置被认定为图形的外部区域而不予填充。

红点所在射线与圆弧两次相交时,圆弧方向相反,计数为0;再与三角形左边相交,计数为1。结果不为0,因此被认定为图形内部区域而被填充。

根据此规则,我们很容易绘制出被圆环的效果:

let orgX = 150; let orgY = 100; let outerR = 80; let innerR = 50; ctx.fillStyle = 'hsl(180, 50%, 30%)'; ctx.strokeStyle = '#CCC'; ctx.beginPath(); ctx.arc(orgX, orgY, outerR, 0, Math.PI * 2, false); // Outer: CW ctx.moveTo(orgX + innerR, orgY); ctx.arc(orgX, orgY, innerR, 0, Math.PI * 2, true); // Inner: CCW ctx.fill(); ctx.stroke();
ctx.shadowColor = '#000'; ctx.shadowOffsetX = 12; ctx.shadowOffsetY = 12; ctx.shadowBlur = 15;

evenodd

HTML 5规范定义:

The "evenodd" value indicates the even-odd rule, wherein a point is considered to be outside a shape if the number of times a half-infinite straight line drawn from that point crosses the shape's path is even.

evenodd的填充方式更容易理解了,它不管相交时图形的边的方向,只计射线与边相交的次数。如果相交的次数为奇数,则认为位于图形内部区域;如果相交的次数为偶数,则认为位于图形外部区域。

ctx.fillStyle = 'hsl(180, 50%, 30%)'; ctx.strokeStyle = '#CCC'; let pathData = 'M 20 20 h 80 v 120 h 80 v -80 h 20 v 50 z'; let pathObj = new Path2D(pathData); ctx.fill(pathObj, 'evenodd'); ctx.stroke(pathObj); drawRadialLine(190, 120, 50, 10, 'red'); function drawRadialLine(fromX, fromY, toX, toY, fillColor) { ctx.beginPath(); ctx.moveTo(fromX, fromY); ctx.lineTo(toX, toY); ctx.strokeStyle = 'white'; ctx.stroke(); ctx.beginPath(); ctx.arc(fromX, fromY, 5, 0, Math.PI * 2); ctx.fillStyle = fillColor; ctx.fill(); }
ctx.shadowColor = '#000'; ctx.shadowOffsetX = 12; ctx.shadowOffsetY = 12; ctx.shadowBlur = 15;

虽然红点位于整个图形的总体边框范围内,但从红点引出的任意射线与边相交的次数为偶数,故红点位于图形外部区域。

利用填充规则实现布尔运算

利用填充规则,可以很方便地实现异或的布尔运算。

ctx.fillStyle = 'hsl(180, 50%, 30%)'; ctx.strokeStyle = '#CCC'; ctx.beginPath(); ctx.arc(100, 100, 50, 0, Math.PI * 2, false); ctx.moveTo(100, 100); ctx.lineTo(100, 180); ctx.lineTo(180, 180); ctx.lineTo(100, 100); ctx.fill(); ctx.stroke();
ctx.shadowColor = '#000'; ctx.shadowOffsetX = 12; ctx.shadowOffsetY = 12; ctx.shadowBlur = 15;

但如果是布尔相加,则效果不理想。

ctx.fillStyle = 'hsl(180, 50%, 30%)'; ctx.strokeStyle = '#CCC'; ctx.beginPath(); ctx.arc(100, 100, 50, 0, Math.PI * 2, true); ctx.moveTo(100, 100); ctx.lineTo(100, 180); ctx.lineTo(180, 180); ctx.lineTo(100, 100); ctx.fill(); ctx.stroke();
ctx.shadowColor = '#000'; ctx.shadowOffsetX = 12; ctx.shadowOffsetY = 12; ctx.shadowBlur = 15;

相加后,内部的边框线也依旧保留了下来,穿帮了。

下面试用裁剪路径来解决问题。

ctx.fillStyle = 'hsl(180, 50%, 30%)'; ctx.strokeStyle = '#CCC'; ctx.beginPath(); ctx.arc(100, 100, 50, 0, Math.PI * 2, true); ctx.clip(); ctx.beginPath(); ctx.moveTo(100, 100); ctx.lineTo(100, 180); ctx.lineTo(180, 180); ctx.lineTo(100, 100); ctx.fill(); ctx.stroke();
ctx.shadowColor = '#000'; ctx.shadowOffsetX = 12; ctx.shadowOffsetY = 12; ctx.shadowBlur = 15;

裁剪路径的原理是,如果裁剪路径内绘制有图形,则这些有图形的区域将成为被保留的有效范围。以后的图形绘制只在这个有效区域内绘制。

上面形成了布尔相交运算的效果。

但如果通过多个图形的组合设置为一个裁剪区域,则原有图形内部的边框线将不起作用:

let region = new Path2D(); region.arc(100, 100, 50, 0, Math.PI * 2, true); region.moveTo(100, 100); region.lineTo(100, 180); region.lineTo(180, 180); region.lineTo(100, 100); ctx.strokeStyle = 'white'; ctx.stroke(region); ctx.clip(region, "nonzero"); ctx.fillStyle = 'hsl(180, 50%, 30%)'; ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.shadowColor = '#000'; ctx.shadowOffsetX = 12; ctx.shadowOffsetY = 12; ctx.shadowBlur = 15;

arccounterclockwise参数改为true,或将clip的参数fillRule的值改为evenodd,都能出现挖空的效果。

下面实现布尔相减的效果:

ctx.fillStyle = 'hsl(180, 50%, 30%)'; ctx.beginPath(); ctx.arc(100, 100, 50, 0, Math.PI * 2, false); ctx.strokeStyle = 'rgba(0, 0, 0, 0)'; ctx.stroke(); ctx.clip('nonzero'); ctx.moveTo(100, 100); ctx.lineTo(100, 180); ctx.lineTo(180, 180); ctx.lineTo(100, 100); ctx.fill(); ctx.strokeStyle = 'white'; ctx.stroke();
ctx.shadowColor = '#000'; ctx.shadowOffsetX = 12; ctx.shadowOffsetY = 12; ctx.shadowBlur = 15;

参考资源

  1. HTML5 Canvas Element
  2. Apple: Paths in Cocoa Drawing Guide