Search code examples
javascriptcanvasalpha

Sequencing of stroke/fill to create a blended overlap


I have a large array of objects to draw on a canvas.

The center of the objects needs to blend with standard alpha.

The border (stroke) of later objects needs to appear to remove any underlying borders while leaving the fill intact for blending.

An example as a code snippet with a couple of failed attempts - please note the 'desired' outcome was produced manually.

The solution needs to scale too as this is for a requestAnimationFrame and there will be thousands of objects to iterated over so performing individual beginPath()/stroke() combinations isn't likely to be viable.

var canvas = document.getElementById('canvas');
canvas.width = 600;
canvas.height = 600;
var ctx = canvas.getContext('2d');

//set up control objects
let objects = [{
    x: 20,
    y: 20,
    w: 60,
    h: 30,
    rgba: "rgba(255, 0,0,.5)"
}, {
    x: 40,
    y: 30,
    w: 60,
    h: 30,
    rgba: "rgba(0,255,0,.5)"
}]

//manually produce desired outcome
ctx.beginPath();
for (let i = 0, l = objects.length; i < l; i++) {
    let myObject = objects[i];
    ctx.fillStyle = myObject.rgba;
    ctx.fillRect(myObject.x, myObject.y, myObject.w, myObject.h);
}
ctx.beginPath();

ctx.moveTo(40, 50);
ctx.lineTo(20, 50);
ctx.lineTo(20, 20);
ctx.lineTo(80, 20);
ctx.lineTo(80, 30);
ctx.rect(40, 30, 60, 30);
ctx.stroke();

ctx.font = "15px Arial"
ctx.fillStyle = "black";
ctx.fillText("Desired outcome - (done manually for example)", 120, 50);

//Attempt one: fill on iterate, stroke on end
ctx.beginPath();
for (let i = 0, l = objects.length; i < l; i++) {
    let myObject = objects[i];
    ctx.rect(myObject.x, myObject.y + 70, myObject.w, myObject.h);
    ctx.fillStyle = myObject.rgba;
    ctx.fillRect(myObject.x, myObject.y + 70, myObject.w, myObject.h);
}
ctx.stroke();

ctx.fillStyle = "black";
ctx.fillText("Attempt #1: inner corner of red box fully visible", 120, 120);

//Attempt two: fill and stroke on iterate

for (let i = 0, l = objects.length; i < l; i++) {
    let myObject = objects[i];
    ctx.beginPath();
    ctx.rect(myObject.x, myObject.y + 140, myObject.w, myObject.h);
    ctx.fillStyle = myObject.rgba;
    ctx.fillRect(myObject.x, myObject.y + 140, myObject.w, myObject.h);
    ctx.stroke();
}
ctx.fillStyle = "black";
ctx.fillText("Attempt #2: inner corner of red box partly visible", 120, 170);
ctx.fillText("(This also scales very badly into thousands of strokes)", 120, 190);
<canvas name="canvas" id="canvas" style="position: absolute; left: 0; top: 0; z-index: 0;"></canvas>


Solution

  • You can achieve this by drawing in two passes:

    First you will composite your strokes, by iteratively

    When this is done, your canvas will only have the final strokes remaining.

    Now you have to draw the fills, but since we want the strokes to be in front of the fills, we have to use an other compositing mode: "destination-over" and to iterate our rects in reversed order:

    (async () => {
    var canvas = document.getElementById('canvas');
    canvas.width = 600;
    canvas.height = 600;
    
    var ctx = canvas.getContext('2d');
    ctx.scale(2,2)
    
    //set up control objects
    let objects = [{
        x: 20,
        y: 20,
        w: 60,
        h: 30,
        rgba: "rgba(255, 0,0,.5)"
    }, {
        x: 40,
        y: 30,
        w: 60,
        h: 30,
        rgba: "rgba(0,255,0,.5)"
    },
    {
        x: 10,
        y: 5,
        w: 60,
        h: 30,
        rgba: "rgba(0,0,255,.5)"
    }]
    
    
    // first pass, composite the strokes
    for (let i = 0, l = objects.length; i < l; i++) {
        let myObject = objects[i];
        ctx.beginPath();
        ctx.rect(myObject.x, myObject.y, myObject.w, myObject.h);
        // erase the previous strokes where our fill will be
        ctx.globalCompositeOperation = "destination-out";
        ctx.fillStyle = "#000"; // must be opaque
        ctx.fill();
        // draw our stroke
        ctx.globalCompositeOperation = "source-over";
        ctx.stroke();
    }
    
    await wait(1000);
    
    // second pass, draw the colored fills
    // we will draw from behind to keep the stroke at frontmost
    // so we need to iterate our objects in reverse order
    for (let i = objects.length- 1; i >= 0; i--) {
        let myObject = objects[i];
        // draw behind
        ctx.globalCompositeOperation = "destination-over";
        ctx.fillStyle = myObject.rgba;
        ctx.fillRect(myObject.x, myObject.y, myObject.w, myObject.h);
    }
    
    })();
    function wait(ms){
      return new Promise(res => setTimeout(res, ms));
    }
    <canvas name="canvas" id="canvas" style="position: absolute; left: 0; top: 0; z-index: 0;"></canvas>

    And of course, this can be used in an animation too:

    var canvas = document.getElementById('canvas');
    canvas.width = 100;
    canvas.height = 100;
    
    var ctx = canvas.getContext('2d');
    
    //set up control objects
    let objects = [{
        x: 20,
        y: 20,
        w: 60,
        h: 30,
        rgba: "rgba(255, 0,0,.5)"
    }, {
        x: 40,
        y: 30,
        w: 60,
        h: 30,
        rgba: "rgba(0,255,0,.5)"
    },
    {
        x: 10,
        y: 5,
        w: 60,
        h: 30,
        rgba: "rgba(0,0,255,.5)"
    }]
    objects.forEach( rect => {
      rect.speedX = Math.random() * 2 - 1;
      rect.speedY = Math.random() * 2 - 1;
    });
    
    requestAnimationFrame(anim);
    onclick = anim
    function anim() {
      update();
      draw();
      requestAnimationFrame( anim );
    }
    function update() {
      objects.forEach( rect => {
        rect.x = rect.x + rect.speedX;
        rect.y = rect.y + rect.speedY;
        if(
          rect.x + rect.w > canvas.width ||
          rect.x < 0
        ) {
          rect.speedX *= -1;
        }
        if(
          rect.y + rect.h > canvas.height ||
          rect.y < 0
        ) {
          rect.speedY *= -1;
        }
      });
    }
    function draw() {
    
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      
      // first pass, composite the strokes
      for (let i = 0, l = objects.length; i < l; i++) {
          let myObject = objects[i];
          ctx.beginPath();
          ctx.rect(myObject.x, myObject.y, myObject.w, myObject.h);
          // erase the previous strokes where our fill will be
          ctx.globalCompositeOperation = "destination-out";
          ctx.fillStyle = "#000"; // must be opaque
          ctx.fill();
          // draw our stroke
          ctx.globalCompositeOperation = "source-over";
          ctx.stroke();
      }
    
      // second pass, draw the colored fills
      // we will draw from behind to keep the stroke at frontmost
      // so we need to iterate our objects in reverse order
      for (let i = objects.length- 1; i >= 0; i--) {
          let myObject = objects[i];
          // draw behind
          ctx.globalCompositeOperation = "destination-over";
          ctx.fillStyle = myObject.rgba;
          ctx.fillRect(myObject.x, myObject.y, myObject.w, myObject.h);
      }
      ctx.globalCompositeOperation = "source-over";
    }
    <canvas name="canvas" id="canvas" style="position: absolute; left: 0; top: 0; z-index: 0;"></canvas>