Search code examples
javascripthtmlcanvas

In html canvas how to convert black and white mask to black and transparent mask?


Is it possible in canvas to convert black/white mask to black/transparent mask? I searched everywhere and the only way is to use pixel manipulation which is too slow.

Basically I want this transformation:

var ac = a.getContext('2d');
var g = ac.createLinearGradient(25, 0, 75, 0);
g.addColorStop(0, 'black');
g.addColorStop(1, 'white');
ac.fillStyle = 'white';
ac.fillRect(0, 0, 100, 100);
ac.fillStyle = g;
ac.fillRect(25, 25, 50, 50);

var bc = b.getContext('2d');
var g = bc.createLinearGradient(25, 0, 75, 0);
g.addColorStop(0, 'black');
g.addColorStop(1, '#00000000');
bc.fillStyle = g;
bc.fillRect(25, 25, 50, 50);
body {
  background-color: pink;
}

canvas {
  outline: 1px solid cyan;
}
Convert this:<canvas id="a" width="100" height="100"></canvas>
to this:<canvas id="b" width="100" height="100"></canvas>

The closest I got is using multiply:

var a = document.getElementById('a'),
    m = document.getElementById('m'),
    c = document.getElementById('c');

// green image
a.context = a.getContext('2d');
a.context.fillStyle = 'green';
a.context.fillRect(0, 0, 100, 100);

// black and white mask
m.context = m.getContext('2d');
m.context.fillStyle = 'white';
m.context.fillRect(0, 0, 100, 100);

var g = m.context.createLinearGradient(25, 0, 75, 0);
g.addColorStop(0, 'black');
g.addColorStop(1, 'white');

m.context.fillStyle = g;
m.context.fillRect(25, 25, 50, 50);

// result
c.context = c.getContext('2d');
c.context.drawImage(a, 0, 0);
c.context.globalCompositeOperation = "multiply";
c.context.drawImage(m, 0, 0);
body {
  background-color: cyan;
}
Green image:
<canvas id="a" width=100 height=100 style="outline: 1px solid cyan;"></canvas>
Black and white mask:
<canvas id="m" width=100 height=100 style="outline: 1px solid cyan;"></canvas>
Result:
<canvas id="c" width=100 height=100 style="outline: 1px solid cyan;"></canvas>

But the problem with this is that there is no intermediary step where I have access to black/transparent canvas.


Solution

  • The definitive best is to prepare your assets the way you need to consume them.
    So if it's at all possible, just don't generate or store the opaque gradient at all.


    Now, I have no doubt it may not be possible in every scenario...
    There is a luminanceToAlpha effect on the SVG <feColorMatrix>, which you can also use in 2D canvas. It will convert all the pixels with low luminance to transparent pixels, and the ones with high luminance to opaque black, which is the inverse of what you want since on your gradient that would make the white pixels black and the black ones transparent. So we need to first invert the gradient and then apply the luminanceToAlpha:

    var m = document.getElementById('m'),
        c = document.getElementById('c');
    
    // black and white mask
    m.context = m.getContext('2d');
    m.context.fillStyle = 'white';
    m.context.fillRect(0, 0, 100, 100);
    
    var g = m.context.createLinearGradient(25, 0, 75, 0);
    g.addColorStop(0, 'black');
    g.addColorStop(1, 'white');
    
    m.context.fillStyle = g;
    m.context.fillRect(25, 25, 50, 50);
    
    // result
    c.context = c.getContext('2d');
    c.context.filter = "invert(1) url(#lumToAlpha)";
    c.context.drawImage(m, 0, 0);
    body {
      background-color: cyan;
    }
    canvas { border: 1px solid }
    Black and white mask:
    <canvas id="m" width=100 height=100 style="outline: 1px solid cyan;"></canvas>
    Result:
    <canvas id="c" width=100 height=100 style="outline: 1px solid cyan;"></canvas>
    <svg width="0" height="0" style="position:absolute">
      <filter id="lumToAlpha"><feColorMatrix type="luminanceToAlpha"/></filter>
    </svg>

    Note that if you don't want to append the <svg> in the document you can very well just use a data: URL, but you'll need to wait at least a task after setting the filter property before being able to use it on the canvas:

    (async () => {
    var m = document.getElementById('m'),
        c = document.getElementById('c');
    
    // black and white mask
    m.context = m.getContext('2d');
    m.context.fillStyle = 'white';
    m.context.fillRect(0, 0, 100, 100);
    
    var g = m.context.createLinearGradient(25, 0, 75, 0);
    g.addColorStop(0, 'black');
    g.addColorStop(1, 'white');
    
    m.context.fillStyle = g;
    m.context.fillRect(25, 25, 50, 50);
    
    // result
    c.context = c.getContext('2d');
    c.context.filter = `invert(1) url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg"><filter id="f"><feColorMatrix type="luminanceToAlpha"/></filter></svg>#f')`;
    // We need to wait for the filter to be "loaded",
    // When using an URL that's async, even for data: URLs (at least in Firefox)
    await new Promise(res => setTimeout(res, 0));
    c.context.drawImage(m, 0, 0);
    })();
    body {
      background-color: cyan;
    }
    canvas { border: 1px solid }
    Black and white mask:
    <canvas id="m" width=100 height=100 style="outline: 1px solid cyan;"></canvas>
    Result:
    <canvas id="c" width=100 height=100 style="outline: 1px solid cyan;"></canvas>

    There is one quite big drawback with this method though: Safari doesn't support 2D canvas's filter quite yet. They're finally working on it, it's even testable in the latest Technology Preview after switching some flags, but it's not yet in stable, and thus for most iPhone users, you'd need to fallback to ImageData manipulation.
    To test for support you can check that your 2D context's filter returns "none" when you don't set it.

    const supportsCanvasFilters = new OffscreenCanvas(1,1).getContext("2d").filter==="none";
    

    Another drawback is that this won't work in a Worker. We used to have a proposal for CanvasFilter objects which should have been able to fill that gap, but it got retracted and might come back in the future, when Safari finally supports filter.