Skip to content

Different results when applying feColorMatrix SVG filter in CSS or in javascript

Let’s say we want to apply a SVG filter on a canvas element. According to this we can apply a SVG filter to the CanvasRenderingContext2D in javascript like this, the filter will only affect shapes drawn after that call:

ctx.filter = "url(#bar)";

We can also just apply the filter in CSS on the whole canvas:

#canvas {
  filter: url(#bar);
}

I need to apply the filter in javascript since I only want part of my canvas to be filtered. When applying a feColorMatrix to some or all of the shapes the results differ depending on the filter having been applied on the 2D Context in JS or on the whole canvas element in CSS.

const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
ctx.filter = "url(#bar)";
ctx.fillRect(10,10,100,100);
ctx.fillRect(10,120,100,100);
ctx.fillRect(120,10,100,100);
ctx.fillStyle = 'red';
ctx.beginPath();
ctx.ellipse(170, 170, 50, 50, Math.PI / 4, 0, 2 * Math.PI);
ctx.fill();
#canvas {
  /* remove comment to see difference */
  /* filter: url(#bar); */
}
<svg xmlns="http://www.w3.org/2000/svg" version="1.1">
  <defs>
    <filter id="bar">
      <fegaussianblur in="SourceGraphic" stdDeviation="10" result="blur"></fegaussianblur>
      <fecolormatrix in="blur" mode="matrix" values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 18 -7"></fecolormatrix>
    </filter>
  </defs>
</svg>
<canvas id="canvas" width="400" height="400"></canvas>

When you remove the comment that applies the SVG filter on the whole canvas it gives this great gooey effect, I can’t seem to achieve that effect only with JS. What am I missing here, shouldn’t the two methods give the same result?

Answer

The CSS filter applies on the canvas image as a whole. This is fundamentally different than in your JS code where you apply the filter on each rectangle separately.

Take for instance this code where I draw rectangles with some transparency. Each rectangle on the left side is drawn one by one, while the ones on the right side are all drawn in a single draw operation. You can see how the globalAlpha didn’t produce the same result at all.

const ctx = document.querySelector("canvas").getContext("2d");

ctx.globalAlpha = 0.25;
for(let y = 0; y<150; y+=10) {
  // draws each rectangle one after the other
  ctx.fillRect(0, 0, 50, y);
}
for(let y = 0; y<150; y+=10) {
  ctx.rect(100, 0, 50, y);
}
// draws all the right-side rectangles in one go
ctx.fill();
<canvas></canvas>

Well, exactly the same thing happens with the filter.
To get the same effect, draw all your rectangles once and then redraw the canvas over itself with the filter so that it applies to the whole image.

const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');

ctx.fillRect(10,10,100,100);
ctx.fillRect(10,120,100,100);
ctx.fillRect(120,10,100,100);
ctx.fillStyle = 'red';
ctx.beginPath();
ctx.ellipse(170, 170, 50, 50, Math.PI / 4, 0, 2 * Math.PI);
ctx.fill();
ctx.filter = "url(#bar)";
// clears what was there, alternatively we could have used a second canvas
ctx.globalCompositeOperation = "copy";
ctx.drawImage(canvas, 0, 0);
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" style="position:absolute;z-index:-1">
  <defs>
    <filter id="bar">
      <fegaussianblur in="SourceGraphic" stdDeviation="10" result="blur"></fegaussianblur>
      <fecolormatrix in="blur" mode="matrix" values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 18 -7"></fecolormatrix>
    </filter>
  </defs>
</svg>
<canvas id="canvas" width="400" height="400"></canvas>