Search code examples
javascriptcanvasopacityalphaoverlapping

HtmlCanvas + globalAlpha + overlap = incorrect output color


I want to draw a stack of almost transparent white rectangles that overlap each other.

Each rectangle has an opacity of 0.01

I have 100 rectangles overlaping, I expect the output result to be the sum of all the opacity. In other term, I expect the result to be white without any opacity.

But it's not the case.

  1. Why ?
  2. How to get the result I want ?

Here is a minimalist code to illustrate the problem

let canvas = document.createElement("canvas");
canvas.width = canvas.height = 512;

let ctx = canvas.getContext("2d");
ctx.fillStyle = "#000000";
ctx.fillRect(0,0,512,512);

ctx.fillStyle = "#ffffff";
ctx.globalAlpha = 0.01;

for(let i=0;i<100;i++){
   let n = i*3;
   ctx.fillRect(n,n,512-n,512-n);
}

document.body.appendChild(canvas); 

Here is the result

result

And here is the jsfiddle https://jsfiddle.net/hsqpno04/

Any help is very welcome !


Solution

  • To do alpha blending of two RGBA colors (assuming normal blending and composite modes), the general formula is

    out = alpha * new + (1 - alpha) * old
    

    But out must be an integer (in the range 0~255), so we have to apply a rounding on top of that (I guess the rounding could depend on implementations and how they store color values).

    If we take a less dramatic alpha of 0.1, and keep white so new is 255 (white is 255, 255, 255), and old is 0 (transparent),
    at first step we'll have

    out = round( 0.1 * 255 + (1 - 0.1) * 0 );
    // => 26
    

    following steps we'll have

    out = round( 0.1 * 255 + (1 - 0.1) * 26 );
    // => 49
    out = round( 0.1 * 255 + (1 - 0.1) * 49 );
    // => 70
    out = round( 0.1 * 255 + (1 - 0.1) * 70 );
    // => 89
    [...] // a few iterations later
    // => 250
    out = round( 0.1 * 255 + (1 - 0.1) * 250 );
    // => 251
    out = round( 0.1 * 255 + (1 - 0.1) * 251 );
    // => 251
    out = round( 0.1 * 255 + (1 - 0.1) * 251 );
    // => 251
    

    Once you reach this 251, 251, 251 color value, no matter the number of new layer you'll add, it won't change the color value anymore, and thus you won't ever be able to reach a full opaque color by layering semi-opaque versions of that color if their opacity is less than 0.5.

    Your 0.01 value will take more iterations to reach this stabilized position (153 vs 38), but it will achieve it at a lower value (206).

    round( 0.01 * 255 + (1 - 0.01) * 206 )
    // => 206
    

    Note that colors are generally stored premultiplied by their alpha, which may add some rounding errors in these numbers.