0

I'm making a menu for my Sudoku game to choose numbers for selected cells.
I managed to do a circular animated menu with X canvas elements each representing one number (+ one is empty). Then I rotate all these elements with CSS transform to create a perfect circle. And here the problem appears - there are ugly transparent strokes between each element that I can't manage to remove.

enter image description here

This is how I draw it:

enter image description here

My app is a bit complicated but I managed to create a demo:

let parent = document.getElementsByClassName('menu')[0];
let elSize = parent.getBoundingClientRect().width;
let upscale = 2;

let total = 10;
let length = elSize / 2;

for (let i = 0; i < total; i++) {
  // create new canvas
  let val = document.createElement('canvas');
  val.classList.add("value");

  let deg = 360 / total;

  //set sizes and rotation
  val.height = length * upscale;
  val.width = elSize * upscale;
  val.style.width = elSize + "px";
  val.style.height = length + "px";
  val.style.setProperty("--rotation", (i / total * 360) + "deg");

  // get context
  let ctx = val.getContext("2d");
  ctx.fillStyle = "blue";
  ctx.imageSmoothingEnabled = true;

  // full circle center
  let center = {
    x: length * upscale,
    y: length * upscale
  }

  //function to fill the circle part (step 1 and 2 on the image)
  const fillWedge = (cx, cy, radius, startAngle, endAngle, fillcolor, stroke = false) => {
    ctx.beginPath();
    ctx.moveTo(cx, cy);
    ctx.arc(cx, cy, radius, startAngle, endAngle);
    ctx.closePath();
    if (stroke) {
      ctx.lineWidth = 1;
      ctx.strokeStyle = fillcolor;
      ctx.stroke();
    } else {
      ctx.fillStyle = fillcolor;
      ctx.fill();
    }
  }

  const degToAngle = (deg) => {
    let start = -Math.PI / 2;
    let fullCircle = Math.PI * 2;
    return (start + fullCircle * (deg / 360));
  }

  ctx.save();
  ctx.imageSmoothingEnabled = false;
  ctx.globalCompositeOperation = "source-out"; {
    //smaller circle
    let cx = center.x;
    let cy = center.y;
    let radius = length * upscale / 3;
    let startAngle = -((deg + 1) / 2) % 360;
    let endAngle = ((deg + 1) / 2) % 360;

    fillWedge(cx, cy, radius, degToAngle(startAngle), degToAngle(endAngle), ctx.fillStyle);
  }
  //make it semi-transparent
  ctx.globalAlpha = 0.8; {
    //bigger circle
    let cx = center.x;
    let cy = center.y;
    let radius = length * upscale;
    let startAngle = -(deg / 2) % 360;
    let endAngle = (deg / 2) % 360;

    fillWedge(cx, cy, radius, degToAngle(startAngle), degToAngle(endAngle), ctx.fillStyle);
  }
  ctx.restore();

  //draw text
  if (i !== 0) {
    ctx.save();
    ctx.translate(length * upscale, length / 3 * upscale);
    ctx.rotate(-(i / total * 360) / 180 * Math.PI);
    ctx.font = "600 " + (18 * upscale) + 'px Consolas';
    ctx.textAlign = "center";
    ctx.fillStyle = "white";
    ctx.fillText((i) + "", 0, 5 * upscale);
    ctx.restore()
  }

  //add element to menu
  parent.appendChild(val);
}
html {
  background: url(https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Fcdn.cienradios.com%2Fwp-content%2Fuploads%2Fsites%2F13%2F2020%2F06%2FShrek-portada.jpg&f=1&nofb=1) no-repeat center;
  display: flex;
  justify-content: center;
  align-content: center;
}

.menu {
  width: 400px;
  aspect-ratio: 1;
  position: relative;
  border-radius: 50%;
}

.menu .value {
  --rotation: 0deg;
  position: absolute;
  top: 0;
  bottom: 50%;
  left: 50%;
  transform-origin: bottom;
  transform: translate(-50%, 0) rotate(var(--rotation));
}

p {
  color: white;
}
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <title>Sudoku Test</title>
</head>

<body>
  <div class="menu">

  </div>
  <p>
    See, it's semi-transparent and you can clearly see lines between elements

    <br>
    <b>How to fix it?!</b>
  </p>
</body>

</html>

Any way to remove those spaces? I know it's a bit too much detailed question, but I really can't manage to fix it. Thanks.

matez
  • 142
  • 1
  • 11
  • That's antialiasing, not much you can do with this code design. Any reasons you use many canvases instead of a single one? By using a single canvas you could make all your path a single subpath and avoid this problem: https://jsfiddle.net/503ruyea/ – Kaiido Mar 21 '22 at 07:08
  • @Kaiido Because I animate each cell with CSS. So when showing menu, the popup animation plays. Doing it with a single canvas would be pretty hard. Any way I can remove antialiasing? – matez Mar 21 '22 at 09:15

1 Answers1

1

These are caused by antialiasing. Since you do draw on diagonals, the shape doesn't fall on full pixel boundaries. So to smooth the lines, the browser will make those pixels that should have been painted only partially, more transparent. When stacked these transparent pixels won't add up exactly to full opacity (0.5 opacity + 0.5 opacity = 0.75 opacity, not 1). So you'll see these lines.

Removing the smoothing here won't help, because the alternative would be to fill in a marching-square fashion, but that would result in either complete holes in some places, either in overlapping pixels, which would be visible since your shapes aren't fully opaque.

Usually the cheap trick for that issue is to stroke a couple of pixels around the shape in the same color as it's filled. But once again since your shapes are filled with semi-transparent colors this trick won't do it.

You could hack something around by drawing all your shapes at full opacity, and applying the transparency on a common container. But this means that your texts would need their own canvas, and their own container (otherwise they'd be transparent too).

let parent = document.getElementsByClassName('menu')[0];
const textsParent = document.getElementsByClassName('texts')[0];
let elSize = parent.getBoundingClientRect().width;
let upscale = 2;

let total = 10;
let length = elSize / 2;

for (let i = 0; i < total; i++) {
  // create new canvas
  let val = document.createElement('canvas');
  val.classList.add("value");

  let deg = 360 / total;

  //set sizes and rotation
  val.height = length * upscale;
  val.width = elSize * upscale;
  val.style.width = elSize + "px";
  val.style.height = length + "px";
  val.style.setProperty("--rotation", (i / total * 360) + "deg");

  // get context
  let ctx = val.getContext("2d");
  ctx.fillStyle = "blue";

  // full circle center
  let center = {
    x: length * upscale,
    y: length * upscale
  }

  //function to fill the circle part (step 1 and 2 on the image)
  const fillWedge = (cx, cy, radius, startAngle, endAngle, fillcolor, stroke = false) => {
    ctx.beginPath();
    ctx.moveTo(cx, cy);
    ctx.arc(cx, cy, radius, startAngle, endAngle);
    ctx.closePath();
    ctx.lineWidth = 2;
    ctx.strokeStyle = fillcolor;
    ctx.stroke();
    ctx.fillStyle = fillcolor;
    ctx.fill();
  }

  const degToAngle = (deg) => {
    let start = -Math.PI / 2;
    let fullCircle = Math.PI * 2;
    return (start + fullCircle * (deg / 360));
  }

  ctx.save();
  ctx.imageSmoothingEnabled = false;
  {
    //smaller circle
    let cx = center.x;
    let cy = center.y;
    let radius = length * upscale / 3;
    let startAngle = -((deg + 1) / 2) % 360;
    let endAngle = ((deg + 1) / 2) % 360;

    fillWedge(cx, cy, radius, degToAngle(startAngle), degToAngle(endAngle), ctx.fillStyle);
  }
  
  {
    //bigger circle
    let cx = center.x;
    let cy = center.y;
    let radius = length * upscale;
    let startAngle = -(deg / 2) % 360;
    let endAngle = (deg / 2) % 360;

    fillWedge(cx, cy, radius, degToAngle(startAngle), degToAngle(endAngle), ctx.fillStyle);
  }
  ctx.restore();

  //draw text
  if (i !== 0) {
    // we need a new canvas just for the text
    const val2 = val.cloneNode();
    const ctx = val2.getContext("2d");
    ctx.save();
    ctx.translate(length * upscale, length / 3 * upscale);
    ctx.rotate(-(i / total * 360) / 180 * Math.PI);
    ctx.font = "600 " + (18 * upscale) + 'px Consolas';
    ctx.textAlign = "center";
    ctx.fillStyle = "white";
    ctx.fillText((i) + "", 0, 5 * upscale);
    ctx.restore()
    textsParent.appendChild(val2);
  }

  //add element to menu
  parent.appendChild(val);
}
html {
  background: url(https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Fcdn.cienradios.com%2Fwp-content%2Fuploads%2Fsites%2F13%2F2020%2F06%2FShrek-portada.jpg&f=1&nofb=1) no-repeat center;
  display: flex;
  justify-content: center;
  align-content: center;
}

.menu, .texts {
  width: 400px;
  aspect-ratio: 1;
  position: relative;
  border-radius: 50%;
}

.menu .value, .texts canvas {
  --rotation: 0deg;
  position: absolute;
  top: 0;
  bottom: 50%;
  left: 50%;
  transform-origin: bottom;
  transform: translate(-50%, 0) rotate(var(--rotation));
}
.menu { opacity: 0.8 }
.text-container {
  position: absolute;
  top: 0;
  left: 0;
  width: 100vw;
  height: 100vh; 
  display: flex;
  justify-content: center;
  align-content: center;
}  
<div class="menu">

</div>
<div class="text-container">
  <div class="texts">
  </div>
</div>

So instead the best is probably to refactor your code entirely to use a single canvas instead. If you tell the browser to draw all your shapes in a single subpath, it will be able to place the tracing perfectly where it should be and will be able to draw all this without any line in between:

const canvas = document.querySelector("canvas");
canvas.width = canvas.height = 500;
const ctx = canvas.getContext("2d");
const parts = 10;
const theta = Math.PI / (parts / 2);

const trace = (cx, cy, r1, r2, t) => {
  ctx.moveTo(cx + r2, cy);
  ctx.lineTo(cx + r1, cy);
  ctx.arc(cx, cy, r1, 0, t);
  ctx.arc(cx, cy, r2, t, 0, true);
};

ctx.translate(250, 250);
ctx.rotate(-Math.PI / 2 - theta);
// draw all but the last part
for (let i = 0; i < parts - 1; i++) {
  ctx.rotate(theta);
  trace(0, 0, 50, 200, theta);
}
ctx.globalAlpha = 0.8;
ctx.fillStyle = "blue";
ctx.fill(); // in a single pass
// draw the last part in red
ctx.fillStyle = "red";
ctx.rotate(theta);
ctx.beginPath();
trace(0, 0, 50, 200, theta);
ctx.fill();
canvas {
  /* checkered effect from https://stackoverflow.com/a/51054396/3702797 */
  --tint:rgba(255,255,255,0.9);background-image:linear-gradient(to right,var(--tint),var(--tint)),linear-gradient(to right,black 50%,white 50%),linear-gradient(to bottom,black 50%,white 50%);background-blend-mode:normal,difference,normal;background-size:2em 2em;
}
<canvas></canvas>
Kaiido
  • 123,334
  • 13
  • 219
  • 285
  • Thanks! Could you describe what you're doing in the `trace` method to make a smaller circle disappear without globalCompositeOperation? I'd like to use your first method but I can't manage to make fill + stroke with correct appearance, so I've checked the second method and you do that differently. – matez Mar 21 '22 at 18:28
  • Well it quite literally moves to the point at r2 distance from the center, trace a line down to r1 then the inner arc and finally the outer one, in anticlockwise order. But you should avoid the first method (based on yours), you'll face many issues going down this route. If they don't imply 3D transforms, then your CSS animations can be done on the 2D context quite easily. – Kaiido Mar 22 '22 at 00:43