javascripthtml5-canvas

How to better smooth the fading edges of an arc


I am creating a frame plugin. The arc seems to be drawn okay as shown here: enter image description here

But if you look carefully, you will notice the wild edges compared to this idle frame: enter image description here

So, how can I smooth the edges to match the second image, given this code piece:

function drawFancyArcFrame(mainCtx, opts) {
        const {
          cx, cy, radius, width, startDeg, sweepDeg,
          color1, color2, capStyle = 'feather',
          capLengthPx = 24, pixelSize = 4
        } = opts;

        if (sweepDeg <= 0 || width <= 0) return;

        const startRad = (startDeg * Math.PI) / 180;
        const endRad   = ((startDeg + sweepDeg) * Math.PI) / 180;

        const W = mainCtx.canvas.width;
        const H = mainCtx.canvas.height;

        const off = document.createElement('canvas');
        off.width = W; off.height = H;
        const ctx = off.getContext('2d');

        const grad = ctx.createLinearGradient(0, 0, W, H);
        grad.addColorStop(0, color1);
        grad.addColorStop(1, color2);

        // draw base arc with flat ends so we can sculpt
        ctx.save();
        ctx.strokeStyle = grad;
        ctx.lineWidth = width;
        ctx.lineCap = 'butt';
        ctx.globalAlpha = 0.85;
        ctx.beginPath();
        ctx.arc(cx, cy, radius, startRad, endRad, false);
        ctx.stroke();
        ctx.restore();

        // endpoints + tangents
        const sx = cx + radius * Math.cos(startRad);
        const sy = cy + radius * Math.sin(startRad);
        const ex = cx + radius * Math.cos(endRad);
        const ey = cy + radius * Math.sin(endRad);
        const tStart = startRad + Math.PI / 2;
        const tEnd   = endRad   + Math.PI / 2;

        // erasers (destination-out)
        const eraseFeather = (x, y, ang) => {
          ctx.save();
          ctx.translate(x, y);
          ctx.rotate(ang);
          ctx.globalCompositeOperation = 'destination-out';
          const g = ctx.createLinearGradient(0, 0, capLengthPx, 0);
          g.addColorStop(0, 'rgba(0,0,0,1)');
          g.addColorStop(1, 'rgba(0,0,0,0)');
          ctx.fillStyle = g;
          ctx.fillRect(0, -width/2, capLengthPx, width);
          ctx.restore();
        };

        const eraseChop = (x, y, ang) => {
          ctx.save();
          ctx.translate(x, y);
          ctx.rotate(ang);
          ctx.globalCompositeOperation = 'destination-out';
          ctx.beginPath();
          ctx.moveTo(0, -width/2);
          ctx.lineTo(capLengthPx, 0);
          ctx.lineTo(0, width/2);
          ctx.closePath();
          ctx.fillStyle = 'rgba(0,0,0,1)';
          ctx.fill();
          ctx.restore();
        };

        const erasePixel = (x, y, ang) => {
          ctx.save();
          ctx.translate(x, y);
          ctx.rotate(ang);
          ctx.globalCompositeOperation = 'destination-out';
          const cols = Math.ceil(capLengthPx / pixelSize);
          const rows = Math.ceil(width / pixelSize);
          const y0 = -width / 2;
          for (let c = 0; c < cols; c++) {
            const frac = 1 - (c / cols);
            const activeRows = Math.max(1, Math.floor(rows * frac));
            const x0 = c * pixelSize;
            for (let r = 0; r < activeRows; r++) {
              if (Math.random() < 0.85) {
                const yCell = y0 + r * pixelSize;
                ctx.fillStyle = 'rgba(0,0,0,1)';
                ctx.fillRect(x0, yCell, pixelSize, pixelSize);
              }
            }
          }
          ctx.restore();
        };

        switch (capStyle) {
          case 'feather':
            eraseFeather(sx, sy, tStart);
            eraseFeather(ex, ey, tEnd + Math.PI);
            break;
          case 'chop':
            eraseChop(sx, sy, tStart);
            eraseChop(ex, ey, tEnd + Math.PI);
            break;
          case 'pixel':
            erasePixel(sx, sy, tStart);
            erasePixel(ex, ey, tEnd + Math.PI);
            break;
        }

        mainCtx.drawImage(off, 0, 0);
      }

I want to remove the straight-edge seams to match the second image's smooth fading. enter image description here


Solution

  • You can use a conic gradient for that:

    const canvas = document.querySelector("canvas");
    canvas.width = 300 * devicePixelRatio;
    canvas.height = 300 * devicePixelRatio;
    const ctx = canvas.getContext("2d");
    
    // control the size of the arcs
    const cx = canvas.width / 2;
    const cy = canvas.height / 2;
    const maxRad = canvas.width * 0.5;
    const minRad = canvas.width * 0.35;
    
    const grad = ctx.createConicGradient(-Math.PI / 3, cx, cy);
    grad.addColorStop(0.25, "#0000FF00"); // transparent blue
    grad.addColorStop(0.30, "blue");
    grad.addColorStop(0.70, "blue");
    grad.addColorStop(0.75, "#0000FF00");  // transparent blue
    ctx.fillStyle = grad;
    
    // draw the "donut" by filling 2 circles with an evenodd rule
    ctx.arc(cx, cy, maxRad, Math.PI*2, 0);
    ctx.moveTo(cx + minRad, cy);
    ctx.arc(cx, cy, minRad, Math.PI*2, 0);
    ctx.fill("evenodd");
    canvas {
      width: 300px;
      height: 300px;
      background: repeating-conic-gradient(rgba(0,0,0,0.1) 0deg 25%, white 0deg 50%);
      background-size: 2em 2em;
    }
    <canvas></canvas>