2
votes

The issue I have is very straightforward. This is a variation of the "How can I draw a hole in a shape?" question, to which the classic answer is "Simply draw both shapes in the same path, but draw the solid clockwise and the "hole" counterclockwise." That's great but the "hole" I need is often a compound shape, consisting of multiple circles.

Visual description: http://i.imgur.com/9SuMSWT.png.

jsfiddle: http://jsfiddle.net/d_panayotov/44d7qekw/1/

context = document.getElementsByTagName('canvas')[0].getContext('2d');
// green background
context.fillStyle = "#00FF00";
context.fillRect(0,0,context.canvas.width, context.canvas.height);
context.fillStyle = "#000000";
context.globalAlpha = 0.5;
//rectangle
context.beginPath();
context.moveTo(0, 0);
context.lineTo(context.canvas.width, 0);
context.lineTo(context.canvas.width, context.canvas.height);
context.lineTo(0, context.canvas.height);
//first circle
context.moveTo(context.canvas.width / 2 + 20, context.canvas.height / 2);
context.arc(context.canvas.width / 2 + 20, context.canvas.height / 2, 50, 0, Math.PI*2, true);
//second circle
context.moveTo(context.canvas.width / 2 - 20, context.canvas.height / 2);
context.arc(context.canvas.width / 2 - 20, context.canvas.height / 2, 50, 0, Math.PI*2, true);
context.closePath();
context.fill();

EDIT:

Multiple solutions have been proposed and I feel that my question has been misleading. So here's more info: I need the rectangle area to act as a shade. Here's a screenshot from the game I'm making (hope this is not against the rules): http://i.imgur.com/tJRjMXC.png.

  • the rectangle should be able to have alpha less than 1.0.
  • the contents, displayed in the "holes" are whatever is drawn on the canvas before applying the shade.

@markE:

  • Alternatively...to "knockout" (erase) the double-circles... - "destination-out" replaces the canvas content with the set background. http://jsfiddle.net/d_panayotov/ab21yfgd/ - The holes are blue instead of green.
  • On the other hand... - "source-atop" requires content to be drawn after defining the clipping mask. This in my case would be inefficient (Light is drawn as concentric circles, shaded area still visible).

@hobberwickey: That's a static background, not actual canvas content. I can however use clip() the same way I would use "source-atop" but that would be inefficient.

The solution that I have implemented right now: http://jsfiddle.net/d_panayotov/ewdyfnj5/. I'm simply drawing the clipped rectangle (in an in-memory canvas) over the main canvas content. Is there a faster/better solution?

3
Updated my question with a simple example.Dean Panayotov
I think you’ll have to make a shape that contains two arks in the same path…Sebastian Simon
The complexity of finding circle-circle intersections and calculating a compound perimeter exceeds the complexity of the whole project. I wouldn't want to reinvent the wheel until I'm certain that's the only option.Dean Panayotov
I've added some thoughts to my answer given your addition to your question. Good luck with your project! :-)markE

3 Answers

4
votes

I almost dread posting the first part of this answer because of its simplicity, but why not just fill 2 circles on a solid background?

enter image description here

var canvas=document.getElementById("canvas");
var ctx=canvas.getContext("2d");
var cw=canvas.width;
var ch=canvas.height;

var r=50;

ctx.fillStyle='rgb(0,174,239)';
ctx.fillRect(0,0,cw,ch);

ctx.fillStyle='white'
ctx.beginPath();
ctx.arc(cw/2-r/2,ch/2,r,0,Math.PI*2);
ctx.closePath();
ctx.fill();
ctx.beginPath();
ctx.arc(cw/2+r/2,ch/2,r,0,Math.PI*2);
ctx.closePath();
ctx.fill();
body{ background-color: ivory; }
#canvas{border:1px solid red;}
<canvas id="canvas" width=400 height=168></canvas>

Alternatively...to "knockout" (erase) the double-circles...

If you want the 2 circles to "knockout" the blue pixels down so the double-circles are transparent & reveal the webpage background underneath, then you can use compositing to "knockout" the circles: context.globalCompositeOperation='destination-out

enter image description here

var canvas=document.getElementById("canvas");
var ctx=canvas.getContext("2d");
var cw=canvas.width;
var ch=canvas.height;

var r=50;


// draw the blue background
// The background will be visible only outside the double-circles
ctx.fillStyle='rgb(0,174,239)';
ctx.fillRect(0,0,cw,ch);


// use destination-out compositing to "knockout" 
// the double-circles and thereby revealing the
// ivory webpage background below
ctx.globalCompositeOperation='destination-out';

// draw the double-circles
// and effectively "erase" the blue background
ctx.fillStyle='white'
ctx.beginPath();
ctx.arc(cw/2-r/2,ch/2,r,0,Math.PI*2);
ctx.closePath();
ctx.fill();
ctx.beginPath();
ctx.arc(cw/2+r/2,ch/2,r,0,Math.PI*2);
ctx.closePath();
ctx.fill();

// always clean up! Set compositing back to its default
ctx.globalCompositeOperation='source-over';
body{ background-color: ivory; }
#canvas{border:1px solid red;}
<canvas id="canvas" width=400 height=168></canvas>

On the other hand...

If you need to isolate those double-circle pixels as a containing path, then you can use compositing to draw into the double-circles without drawing into the blue background.

Here's another example:

enter image description here

var canvas=document.getElementById("canvas");
var ctx=canvas.getContext("2d");
var cw=canvas.width;
var ch=canvas.height;

var r=50;

var img=new Image();
img.onload=start;
img.src="https://dl.dropboxusercontent.com/u/139992952/multple/mm.jpg";
function start(){

  // fill the double-circles with any color
  ctx.fillStyle='white'
  ctx.beginPath();
  ctx.arc(cw/2-r/2,ch/2,r,0,Math.PI*2);
  ctx.closePath();
  ctx.fill();
  ctx.beginPath();
  ctx.arc(cw/2+r/2,ch/2,r,0,Math.PI*2);
  ctx.closePath();
  ctx.fill();

  // set compositing to source-atop
  // New drawings are only drawn where they
  //    overlap existing (non-transparent) pixels
  ctx.globalCompositeOperation='source-atop';


  // draw your new content
  // The new content will be visible only inside the double-circles
  ctx.drawImage(img,0,0);

  // set compositing to destination-over
  // New drawings will be drawn "behind" 
  //    existing (non-transparent) pixels
  ctx.globalCompositeOperation='destination-over';

  // draw the blue background
  // The background will be visible only outside the double-circles
  ctx.fillStyle='rgb(0,174,239)';
  ctx.fillRect(0,0,cw,ch);

  // always clean up! Set compositing back to its default
  ctx.globalCompositeOperation='source-over';

}
body{ background-color: ivory; }
#canvas{border:1px solid red;}
<canvas id="canvas" width=400 height=168></canvas>

{ Additional thoughts given addition to answer }

A technical point: xor compositing works by flipping just the alpha values on pixels but does not also zero-out the r,g,b portion of the pixel. In some cases, the alphas of the xored pixels will be un-zeroed and the rgb will again display. It's better to use 'destination-out' compositing where all parts of the pixel value (r,g,b,a) are zeroed out so they don't accidentally return to haunt you.

Be sure... Even though it's not critical in your example, you should always begin your path drawing commands with maskCtx.beginPath(). This signals the end of any previous drawing and the beginning of a new path.

One option: I see you're using concentric circles to cause greater "reveal" at the center of your circles. If you want a more gradual reveal, then you could knockout your in-memory circles with a clipped-shadow (or radial gradient) instead of concentric circles.

Other than that, you solution of overlaying an in-memory canvas should work well (at the cost of the memory used for the in-memory canvas).

Good luck with your game!

0
votes

Even easier is just to use clipping and complete circles. Unless there's some reason you NEED to do this with a single path.

var cutoutCircle = function(x, y, r, ctx){
  ctx.save()
  ctx.arc(x, y, r, 0, Math.PI * 2, false) 
  ctx.clip()
  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height)
  ctx.restore();
}

var myCircles = [{x: 75, y: 100, r: 50}, {x: 125, y: 100, r: 50}], 
    ctx = document.getElementById("canvas").getContext('2d');

ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
for (var i=0; i<myCircles.length; i++){
  cutoutCircle(myCircles[i].x, myCircles[i].y, myCircles[i].r, ctx)
}

EDIT: added background to example for better a better demonstration

http://jsfiddle.net/v9qven9w/1/

0
votes

If I understand you correctly: you want to have the appearance of a mask on top of the game, so that the two intersecting circles highlights, while everything else is dimmed?

I would suggest keep it simple - create an off-screen canvas with the circles knocked out on a transparent black background.

Then just draw that off-screen canvas in on top of your game when you need it. This is far more performant than to re-composite for each frame - do it one time and reuse.

Demo

The mask is shown in the demo window below (scroll it or use full page to see all). Normally you would create an off-screen canvas instead and use that.

// create mask

// for off-screen, use createElement("canvas")
var mask = document.getElementById("mask"),
    ctxm = mask.getContext("2d"),
    w = mask.width, h = mask.height, x, y, radius = 80;

ctxm.fillStyle = "rgba(0,0,0,0.5)";
ctxm.fillRect(0, 0, w, h);                          // fill mask with 50% transp. black
ctxm.globalCompositeOperation = "destination-out";  // knocks out background
ctxm.fillStyle = "#000";                            // some solid color

x = w / 2 - radius/1.67;
y = h / 2;
ctxm.moveTo(x, y);                                  // circle 1
ctxm.arc(x, y, radius, 0, Math.PI*2);
x = w / 2 + radius/1.67;
ctxm.moveTo(x, y);                                  // circle 2
ctxm.arc(x, y, radius, 0, Math.PI*2);
ctxm.fill();                                        // knock em' out, DONE!

// ----- Use mask for the game, pseudo action below ------
var canvas = document.getElementById("game"), ctx = canvas.getContext("2d");

(function loop() {
  ctx.fillStyle = "#742";
  ctx.fillRect(0, 0, w, h);                        // clear background
  ctx.fillStyle = "#960";
  for(x = 0; x < w; x += 8)                        // some random action
    ctx.fillRect(x, h * Math.random(), 8, 8);

  ctx.drawImage(mask, 0, 0);                       // use MASK on top
  
  requestAnimationFrame(loop)
})();
<canvas id="mask" width=500 height=220></canvas>
<canvas id="game" width=500 height=220></canvas>