Using canvas, how to cut holes with inset shadows into an image?

Viewed 178

Using canvas, I'm trying to cut a series of round holes into an image. Additionally, each hole should have an inset drop shadow that would make it look as if the image is slightly hovering over the background.

Here is what I've managed to do:

  1. Cut holes into an image using globalCompositeOperation = "destination-top"
  2. Cut out one hole from a background shape using "opposite winding" (drawing counter-clockwise, then clockwise)

The second attempt, however, does not seem to be viable for multiple holes on an image for these two reasons:

  • AFAIK, can't draw an image counter-clockwise
  • AFAIK, for opposite drawing technique to work, I can't close the path in between calls to arc

Here is what I currently have:

let cvs = document.querySelector("canvas");
let ctx = cvs.getContext("2d");
let img = new Image();
img.addEventListener("load", function() {
  ctx.drawImage(img, 0, 0, 256, 256);
  ctx.globalCompositeOperation = "destination-out";
  hole(ctx, 64, 64, 32);
  hole(ctx, 192, 64, 32);
  hole(ctx, 64, 192, 32);
  hole(ctx, 192, 192, 32);
});
img.src = "https://placeimg.com/256/256/nature";

function hole(ctx, x, y, r, ccw=false) {
    ctx.beginPath();
    ctx.arc(x, y, r, 0, Math.PI * 2, ccw);
    ctx.closePath();
    ctx.fill();
}
<canvas width="256" height="256"></canvas>

How can I now add inset shadows to the holes?

4 Answers

I suggest you use a second canvas to help create the effect you're after, alongside shadowOffset and shadowColor.

let cvs = document.querySelector("canvas");
let ctx = cvs.getContext("2d");
let img = new Image();

img.addEventListener("load", function() {
  let cvs2 = document.createElement('canvas');
  cvs2.width = 256;
  cvs2.height = 256;
  ctx2 = cvs2.getContext("2d");

  hole(ctx2, 64, 64, 32);
  hole(ctx2, 192, 64, 32);
  hole(ctx2, 64, 192, 32);
  hole(ctx2, 192, 192, 32);

  ctx2.globalCompositeOperation = "source-out";
  ctx2.drawImage(img, 0, 0, 256, 256);

  ctx.shadowOffsetX = 4;
  ctx.shadowOffsetY = 4;
  ctx.shadowBlur = 8;
  ctx.shadowColor = 'black';
  ctx.drawImage(cvs2, 0, 0, 256, 256);
});
img.src = "https://placeimg.com/256/256/nature";

function hole(ctx, x, y, r, ccw = false) {
  ctx.beginPath();
  ctx.arc(x, y, r, 0, Math.PI * 2, ccw);
  ctx.closePath();
  ctx.fill();
}
<canvas width="256" height="256"></canvas>

You could put a div behind each hole and play around with the inset shadows in those to get a suitable effect (though the simple shadow in this snippet makes it look more like a button sticking out rather than a hole in I realize)

let cvs = document.querySelector("canvas");
let ctx = cvs.getContext("2d");
let img = new Image();
img.addEventListener("load", function() {
  ctx.drawImage(img, 0, 0, 256, 256);
  ctx.globalCompositeOperation = "destination-out";
  hole(ctx, 64, 64, 32);
  hole(ctx, 192, 64, 32);
  hole(ctx, 64, 192, 32);
  hole(ctx, 192, 192, 32);
});
img.src = "https://placeimg.com/256/256/nature";

function hole(ctx, x, y, r, ccw=false) {
    ctx.beginPath();
    ctx.arc(x, y, r, 0, Math.PI * 2, ccw);
    ctx.closePath();
    ctx.fill();
}
* {
  padding: 0;
  margin: 0;
  box-sizing: border-box;
}
.shadow {
  position: absolute;
  box-shadow: 0px 20px 10px gray inset;
  width: 70px;
  height: 70px;
  border-style: solid;
  border-radius: 50%;
  border-color: transparent;
  clear: both;
}
div.shadow:nth-of-type(1) {
  top: 29px;
  left: 29px;
}
div.shadow:nth-of-type(2) {
  top: 157px;
  left: 29px;
}
div.shadow:nth-of-type(3) {
  top: 29px;
  left: 157px;
}
div.shadow:nth-of-type(4) {
  top: 157px;
  left: 157px;
}
<div class="shadow"></div>
<div class="shadow"></div>
<div class="shadow"></div>
<div class="shadow"></div>
<canvas width="256" height="256"></canvas>

You can also do it with a single canvas by drawing your canvas over itself, with a huge offset for the shadow.
Having this huge offset allows us to actually draw the canvas outside of its visible rectangle, but to still keep the shadows.

let cvs = document.querySelector("canvas");
let ctx = cvs.getContext("2d");
let img = new Image();
img.addEventListener("load", function() {
  ctx.drawImage(img, 0, 0, 256, 256);
  ctx.globalCompositeOperation = "destination-out";
  hole(ctx, 64, 64, 32);
  hole(ctx, 192, 64, 32);
  hole(ctx, 64, 192, 32);
  hole(ctx, 192, 192, 32);
  ctx.fill(); // single filling is always preferable
  
  // offset by the size of the canvas + the actual offset wanted
  ctx.shadowOffsetX = cvs.width + 4;
  ctx.shadowOffsetY = cvs.height + 4;
  ctx.shadowBlur = 4;
  ctx.shadowColor = "black";
  ctx.globalCompositeOperation = "destination-over";
  // draw at the inverse offset (so the shadow is at the correct position)
  ctx.drawImage( cvs, -cvs.width, -cvs.height );
});
img.src = "https://placeimg.com/256/256/nature";

function hole(ctx, x, y, r, ccw=false) {
    ctx.arc(x, y, r, 0, Math.PI * 2, ccw);
    ctx.closePath();
}
<canvas width="256" height="256"></canvas>

I just found one possible approach myself, but I think it is more complicated than it needs to be:

  1. draw the holes with globalCompositeOperation set to destination-out as I was already doing; this will cut them out of the image, creating literal holes that reveal the background
  2. set appropriate shadow* and lineWidth values; the latter will affect the shadow intensity, where a thicker line makes for a more intense shadow
  3. using stroke(), draw one additional circle per hole with globalCompositeOperation set to destination-over, which will draw behind existing content; to prevent the actual stroke from being visible, add its lineWidth to the radius used for the holes

let cvs = document.querySelector("canvas");
let ctx = cvs.getContext("2d");
let img = new Image();
img.addEventListener("load", function() {
    ctx.drawImage(img, 0, 0, 256, 256);
    ctx.globalCompositeOperation = "destination-out";
    hole(ctx, 64, 64, 32);
    hole(ctx, 192, 64, 32);
    hole(ctx, 64, 192, 32);
    hole(ctx, 192, 192, 32);
});
img.src = "https://placeimg.com/256/256/nature";

function hole(ctx, x, y, r) {
    let lw = r * 0.2;
    ctx.save();
    ctx.beginPath();
    ctx.arc(x, y, r, 0, Math.PI * 2);
    ctx.closePath();
    ctx.fill();
    ctx.globalCompositeOperation = "destination-over";
    ctx.lineWidth = lw;
    ctx.strokeStyle = "black";
    ctx.shadowColor = "black";
    ctx.shadowOffsetX = r * 0.1;
    ctx.shadowOffsetY = r * 0.1;
    ctx.shadowBlur = r * 0.2;
    ctx.beginPath();
    ctx.arc(x, y, r + lw, 0, Math.PI * 2);
    ctx.closePath();
    ctx.stroke();
    ctx.restore();
}
<canvas width="256" height="256"></canvas>

Related