p5.js: How to punch a hole into a canvas shape, without changing the background or using an external canvas?

Viewed 518

I'm trying to figure out a way to punch holes into a thing, but without the hole also going through whatever is in the background already.

  1. the hole is made of a few arbitrary shapes, and is not a simple path I can use to clip.
  2. The hole is only punched through the foreground shapes, and not all the way into the background (the background should stay as-is).

I figured a way to do this with an external context, and then bringing it in. My questions: is there a way to do it on my default canvas, and avoid the complications that might arise from the external context (extra memory, color differences etc)?

Here's a working (p5.js) example, which is using a new context:

function setup() {
   createCanvas(600,600);
   background(255, 0, 0);
   noStroke();
}

function draw() {
  //blue: stuff in the background that should not change
  fill ("blue");
  rect (20,20,500,500);
      
  //draw on external canvas
  pg = createGraphics(600,600);
  //yellow+green foreground shapes
  pg.fill("green");
  pg.rect(100, 100, 200, 200);
  pg.fill("yellow");
  pg.rect(80, 80, 100, 300);
      
  //punch a hole in the shapes
  pg.fill(0, 0, 255);
  pg.blendMode(REMOVE);
  pg.circle(140, 140, 150);
  pg.circle(180, 180, 150);
  //bring in the external canvas with punched shapes
  image(pg, 0, 0);

  noLoop();
}
html,
body {
  margin: 0;
  padding: 0;
}
canvas {
  display: block;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.3.1/p5.js"></script>

2 Answers

There is no easy or built in way to do this without the technique you've already discovered. The only alternative would be to implement boolean geometry operations like subtraction and intersection on arbitrary shapes and splines. That would allow you to make arbitrary bezier splines that represent the composites of multiple complex shapes and then draw those directly. This approach would have different behavior with regards to stroke than the removal approach.

Just FYI, there are also a pair of methods in p5js erase() and noErase() which have a similar behavior to the blendMode(REMOVE) approach. I don't think there's any technical benefit, but it might be more idiomatic to use them rather than blend mode.

I agree, as Paul(+1) mentions as well, using multiple p5.Graphics instances (external contexts as you call them) is the most straight forward/readable method.

You could explicitly uses p5.Image and mask(), however there are few more operations involved and the could would be a little less readable. Here's an example:

function setup() {
   createCanvas(600,600);
   background(255, 0, 0);
   noStroke();
}

function draw() {
  //blue: stuff in the background that should not change
  fill ("blue");
  rect (20,20,500,500);
      
  //draw on external canvas
  pg = createGraphics(600,600);
  //yellow+green foreground shapes
  pg.fill("green");
  pg.rect(100, 100, 200, 200);
  pg.fill("yellow");
  pg.rect(80, 80, 100, 300);
      
  //punch a hole in the shapes
  let msk = createGraphics(600, 600);
  msk.background(0);
  msk.erase();
  msk.noStroke();
  msk.circle(140, 140, 150);
  msk.circle(180, 180, 150);
  let mskImage = msk.get();
  
  pgImage = pg.get();
  pgImage.mask(mskImage);
  
  image(pgImage, 0, 0);

  noLoop();
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.4.0/p5.min.js"></script>

A (very) hacky workaround would be to do the same thing with one canvas. This would leave the areas inside the circles completely transparent so make them appear blue, simply make the background element behind the blue:

function setup() {
   createCanvas(600,600);
   background(255, 0, 0);
   noStroke();
}

function draw() {
  //blue: stuff in the background that should not change
  fill ("blue");
  rect (20,20,500,500);
      
  //draw on external canvas
  // pg = createGraphics(600,600);
  //yellow+green foreground shapes
  fill("green");
  rect(100, 100, 200, 200);
  fill("yellow");
  rect(80, 80, 100, 300);
      
  //punch a hole in the shapes
  fill(0, 0, 255);
  blendMode(REMOVE);
  circle(140, 140, 150);
  circle(180, 180, 150);
  //bring in the external canvas with punched shapes
  // image(pg, 0, 0);

  noLoop();
}
body{
  /* make the HTML background match the canvas blue */
  background-color: #00F;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.4.0/p5.min.js"></script>

This might not be flexible enough though.

Now, assuming your foreground is made of the yellow and green shapes and the background is blue, another option would be manually accessing the pixels[] array and updating pixel values. In your example the masks are circular so you could check if:

  • the distance between the current pixel and the circle's centre is smaller than the circle's radius: this means the pixel is inside the circle
  • also, if the colour inside the circle is a foreground colour (e.g. green or yellow in your case) If both conditions match then you could replace this pixel with a background colour (blue in your case)

Here's an example of that:

function setup() {
   createCanvas(600,600);
   pixelDensity(1);
   background(255, 0, 0);
   noStroke();
}

function draw() {
  //blue: stuff in the background that should not change
  fill ("blue");
  rect (20,20,500,500);
      
  //draw on external canvas
  //yellow+green foreground shapes
  fill("green");
  rect(100, 100, 200, 200);
  fill("yellow");
  rect(80, 80, 100, 300);
      
  //punch a hole in the shapes
  fill(0, 0, 255);
  
  // make pixels available for reading
  loadPixels();
  // apply each circle "mask" / bg color replacement
  //                        yellow       , green       , bg blue to replace fg with
  circleMask(140, 140, 150, [255, 255, 0], [0, 0x80, 0], [0, 0, 255]);
  circleMask(180, 180, 150, [255, 255, 0], [0, 0x80, 0], [0, 0, 255]);
  // once all "masks" are applied, 
  updatePixels();
  
  noLoop();
}

function circleMask(x, y, radius, fg1, fg2, bg){
  // total number of pixels 
  let np = width * height;
  let np4 = np*4;
  //for each pixel (i = canvas pixel index (taking r,g,b,a order into account)
  // id4 is a quarter of "i"
  for(let i = 0, id4 =0 ; i < np4; i+=4, id4++){
      // compute x from pixel index
      let px = id4 % width;
      // compute y from pixel index
      let py = id4 / width;
      // if we're within the circle
      if(dist(px, py, x, y) < radius / 2){
        // if we've found foreground colours to make transparent 
        // ([0][1][2] = r, g, b)
        if((pixels[i]   == fg1[0] || pixels[i]   == fg2[0]) &&
           (pixels[i+1] == fg1[1] || pixels[i+1] == fg2[1]) &&
           (pixels[i+2] == fg1[2] || pixels[i+2] == fg2[2])){
          // "mask" => replace fg colour matching pixel with bg pixel
          pixels[i]   = bg[0];
          pixels[i+1] = bg[1];
          pixels[i+2] = bg[2];
      }
    }
  }
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.4.0/p5.min.js"></script>

There are a few things to notice here. pixels[] is faster than set(x, y, clr) but it means you need to remember a few details:

  • call loadPixels() before accessing pixels[] to read/populate the array
  • make all the pixels changes required (in this case the circle "masks" / pixels inside circle colour replacement)
  • call updatePixels() after pixels[] have been updated

Also notices it takes a bit of time to execute. There could be a few speed improvements such as only iterating over the pixels inside the bounding box of the circle before checking distance and checking squared distance instead of dist(), however this would also make the code less readable.

Related