为什么 Canvas API 会用错误的颜色填充循环中的部分路径?

Why does the Canvas API fill parts of these paths within a loop with the wrong color?

我创建了一个 JSFiddle,所有代码都处于活动状态并且 运行。

相关JS在这里:

const canvas = document.getElementById("base");
const ctx = canvas.getContext("2d");
const cWidth = canvas.width;
const cHeight = canvas.height;
const padding = 4;
const portSetCount = 96;
const portSetRows = 6;
const portsPerRow = portSetCount / portSetRows;
const drawablePortWidth = cWidth - ((portsPerRow + 1) * padding);
const portWidth = drawablePortWidth / portsPerRow;
const portSixth = portWidth / 6;
let x = padding;
let y = padding;

for (let row = 1; row <= portSetRows; row++) {
  x = padding;

  for (let col = 1; col <= portSetCount / portSetRows; col++) {
    ctx.fillStyle = "green";
    ctx.rect(x, y, portWidth, portWidth);
    ctx.fill();

    ctx.fillStyle = "black";
    ctx.beginPath();
    ctx.moveTo(x + portSixth, y + (2 * portSixth));
    ctx.lineTo(x + portSixth, y + (5 * portSixth)); // Left.
    ctx.lineTo(x + (5 * portSixth), y + (5 * portSixth)); // Bottom.
    ctx.lineTo(x + (5 * portSixth), y + (2 * portSixth)); // Right.
    ctx.lineTo(x + (4 * portSixth), y + (2 * portSixth));
    ctx.lineTo(x + (4 * portSixth), y + (1 * portSixth));
    ctx.lineTo(x + (2 * portSixth), y + (1 * portSixth));
    ctx.lineTo(x + (2 * portSixth), y + (2 * portSixth));
    // ctx.lineTo(x + (1 * portSixth), y + (2 * portSixth));
    ctx.closePath();
    ctx.fill();

    x += portWidth + padding;
  }

  y += portWidth + padding;
}
#stage {
  width: 1000;
  height: 500px;
  position: relative;
  border: 2px solid black;
}

canvas {
  position: absolute;
}

#overlay {
  z-index: 2;
}

#base {
  z-index: 1;
}
<div id="stage">
  <canvas id="base" width="1000" height="500"></canvas>
  <canvas id="overlay" width="1000" height="500"></canvas>
</div>

出于某种原因,它最终呈现成这样,其中大多数内部形状实际上并没有填充,甚至它们的边框似乎也缺少一些线条。

但是,如果我注释掉绿色背景,它确实会像这样工作,其中所有形状都按照您的预期填充。

提出问题后,我能够通过调整相关代码段来“解决”问题,如下所示:

ctx.beginPath(); // Added this line.

ctx.fillStyle = "black";
ctx.rect(x, y, portWidth, portWidth);
ctx.fill();

ctx.fillStyle = "green";

基本上,我最初的尝试是“概念上”尝试创建一个绿色正方形,然后在其上覆盖一个黑色形状。事情变得很奇怪所以我创建了一个连续的 thing (路径?形状?)由两个嵌套的 things (我不知道正确的 canvas 术语)和两个单独的填充,然后以某种方式工作。

我不会把它作为答案,因为它不是,但这是我目前达到预期效果的方式。

此问题的简单解决方法是替换 rect by fillRect 并删除后续的 fillrect 当前子路径上添加一个矩形 ,而 fillRect 是一个自包含的操作,它创建自己的路径并填充它。

这在HTML5 Canvas - fillRect() vs rect()中也有解释。

现在,为什么您的端口看起来是这样的? 代码实际上总是保留正确填充的最后一个端口 —— 这总是正确的,即使在程序执行的中间,在任何迭代结束时也是如此。 但是一旦下一个绿色矩形被填满,前一个端口也会被填满绿色。 原因是:他们是同一条路!

以下 table 和图形是了解 Canvas API.

中路径创建的一些先决条件

您可以将您使用的一些方法分为两类:属于整个当前 路径 的方法,以及属于当前 子路径的方法路径可以由多个子路径组成。 让我们看看它们之间的关系:

Method Pertains to Links WHATWG specification MDN documentation
beginPath Path WHATWG, MDN “[…] empty the list of subpaths in [the] current default path so that it once again has zero subpaths.” “[…] starts a new path by emptying the list of sub-paths. Call this method when you want to create a new path.”
moveTo Subpath WHATWG, MDN “Create a new subpath with the specified point as its first (and only) point.” “[…] begins a new sub-path at the point specified by the given (x, y) coordinates.”
lineTo Subpath WHATWG, MDN “If the […] path has no subpaths, then ensure1 there is a subpath for (x, y). Otherwise, connect the last point in the subpath to the given point (x, y) using a straight line, and then add the given point (x, y) to the subpath.” “[…] adds a straight line to the current sub-path by connecting the sub-path’s last point to the specified (x, y) coordinates.”
closePath Subpath WHATWG, MDN “[…] must do nothing if the […] path has no subpaths. Otherwise, it must mark the last subpath as closed, create a new subpath whose first point is the same as the previous subpath’s first point, and finally add this new subpath to the path. If the last subpath had more than one point in its list of points, then this is equivalent to adding a straight line connecting the last point back to the first point of the last subpath, thus ‘closing’ the subpath.” “[…] attempts to add a straight line from the current point to the start of the current sub-path. If the shape has already been closed or has only one point, this function does nothing.”
fill Path WHATWG, MDN “Subpaths with only one point are ignored when painting the path.”; “[…] fill all the subpaths of the […] path […]. Open subpaths must be implicitly closed when being filled (without affecting the actual subpaths).” “[…] fills the current or given2 path with the current fillStyle.”

此图应总结所有这些操作:

rect,和它的相关方法一样,添加到当前的子路径.

(整个)路径子路径之间的区别很重要,但很容易被忽略:beginPath创建一个全新的路径,moveTo 创建子路径,但 fill 填充整个路径。 此外,一旦填充,就会保持填充状态。 这就像油漆:当你油漆 canvas 一次,并被告知要填充重叠区域时,你将不得不在旧油漆上油漆。

这有助于解释为什么存在这些黑色轮廓:您的端口形状填充为黑色,但相同的形状 - 加一个矩形 - 被填充为绿色;绿色覆盖黑色。 请注意,除以 6 并不总是得到整数,因此每次填充都对边缘具有一定的透明度。 这就是为什么绿色没有完全覆盖边缘的黑色。

重要的是要注意 closePath 不是 beginPath 的“对立面”。 closePath 只关闭和打开子路径;它不会像 beginPath 那样改变当前的 path。 由于 beginPathclosePath 有时是可选的,这变得更加复杂:例如,moveTo 只应该创建一个子路径,但如果它在一开始就被调用,当不存在 path,也会自动创建一个路径,使 moveTo 之前的 beginPath 变得多余。

让我们通过考虑一个接一个的两个迭代来一步一步地完成脚本:

  1. 第一次迭代
    1. ctx.fillStyle = "green"; 设置下一次 fill 调用 "green" 的填充颜色。
    2. ctx.rect(x, y, portWidth, portWidth); 需要存在子路径。由于开始时不存在 paths,因此会自动创建一个新路径,并创建一个子路径作为其中的一部分。然后,在当前子路径中添加一个矩形。
    3. ctx.fill(); 将整个当前路径(即其所有子路径:矩形)的内部填充为绿色。
    4. ctx.fillStyle = "black"; 设置下一次 fill 调用 "black" 的填充颜色。
    5. ctx.beginPath(); 丢弃以前的路径并创建一个新路径。这是现在的当前路径,之前的矩形路径已经不可访问了。
    6. ctx.moveTo(x + portSixth, y + 2 * portSixth); 创建一个以指定位置作为第一个顶点的新子路径。
    7. 七个ctx.lineTo();调用将顶点添加到当前子路径。
    8. ctx.closePath(); 就像一个 lineTo 回到当前 subpath 的开始;关闭子路径并创建新的子路径。
    9. ctx.fill(); 将整个当前路径(即其所有子路径:端口形状)的内部填充为黑色。
  2. 第二次迭代(步骤 1 和 4 与第一次迭代没有区别)
    1. (ctx.fillStyle = "green";)
    2. ctx.rect(x, y, portWidth, portWidth);当前子路径添加一个矩形,该子路径已经存在:它从上一次迭代的端口形状的末尾开始.
    3. ctx.fill();填充整个当前路径的内部(即每个子路径:矩形加上一次迭代的端口形状)绿色。
    4. (ctx.fillStyle = "black";)
    5. ctx.beginPath(); 丢弃以前的路径并创建一个新路径。 现在矩形加端口形状不再可用。
    6. 等等

第二次迭代是错误发生的地方。 在其第三步中,fill 使用 beginPath.

填充在 previous 迭代中打开的路径

如您所见,您 可以 修复它而无需 fillRect,只需将 ctx.beginPath(); 放在 ctx.rect(); — 或者,等价地,在迭代开始时。 现在你应该明白为什么了。


1:短语“ensure there is a subpath”有自己的算法步骤:“当用户代理'确保有子路径'时路径上的坐标 (x, y),用户代理必须检查路径是否设置了“需要新子路径”标志。如果是,则用户代理必须创建一个新的子路径,并将点 (x, y) 作为其第一个(也是唯一的)点,就好像 moveTo 方法已被调用一样,然后必须取消设置路径的 '需要新的子路径标志。”.

2fill 确实接受 specific Path2D 作为参数。如果没有参数,它会填充提供的呈现上下文的当前路径。