Mark Taylor
Mark Taylor

Reputation: 1188

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>

Upvotes: 0

Views: 139

Answers (1)

Kaiido
Kaiido

Reputation: 136678

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>

Upvotes: 1

Related Questions