Why does repeatedly drawing translucent fills on HTML5 Canvas not cover it completely?

Viewed 95

What the heck is going on here?

Why does the blending abruptly stop at 255,214,214,255? Is this expected behavior?

I thought it would eventually hit 255,255,255,255, but it doesn't. It doesn't even come close. The red rectangle from the beginning never vanishes into white, it remains at color 255,214,214,255 forever.

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

// draw "red"
ctx.fillStyle = 'red';
ctx.fillRect(0, 0, 300, 200);

function loop() {
    
    // log new colors
    if (getCurrentColor() !== loop.lastColor) {
        console.log(loop.lastColor = getCurrentColor());
    }
    
    // draw "transparent white" repeatedly
    ctx.fillStyle = 'rgba(255,255,255,0.01)';
    ctx.fillRect(0, 0, 300, 200);
    
    // schedule next iteration
    requestAnimationFrame(loop);
}
loop(); // start loop

function getCurrentColor() {
    return ctx.getImageData(0, 0, 1, 1).data.join(',');
}
<!DOCTYPE html>
<html>
    <head></head>
    <body>
        <canvas width="300" height="200"></canvas>
    </body>
</html>

Both explanations and solutions are highly appreciated! :P

2 Answers

The underlying graphic buffers for HTML/JS Canvases are Bitmaps, which only allow for Integer color values from 0 to 255. This means that the output of every render operation has to be rounded!

Say you start at color value 214. If you then mix 214 * 0.99 + 255 * 0.01, you'd theoretically get 214.41. But this is then rounded back to 214 to be stored in the canvas bitmap!!
So, essentially, you're stuck on 214 (or any other value for that matter, as long as your steps are smaller than 0.5) xD

Unfortunately, there is no way to circumvent this with plain JavaScript, as you can't change the graphic buffer architecture or the blending/compositing operations.
But I'm sure you could hack the hell out of it if you really wanted to. By implementing a Float32 graphic buffer via the WebGL API, or something like that.

The reason why this is not completely white at the end is simple: You are adding a non totally transparent overlay on top of something. Regardless of how often you will do that, you will never reach the 100% as it is a asymptote behavior. Just image the following

I have a 100% red paper. And I will cover it with a 50% transparent white glas. Then you will see 50% of the 100% => 50%. If I do that again, your logic would say then I have 50% + 50% = 100% but thats not how it works. You have the 50% red & 50% white glas and overlay it with another 50% white glas. That makes the glas appears 25% red and 75% white.

If you repeat that for several times, the amount of red color goes down but never reaches 0%.

The solution is that you paint the red rect again but with a higher amount of opacity. If you then reach ctx.fillStyle = 'rgba(255,255,255,1.00)'; it will fully cover the red.

Here is the example code:

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

// draw "red"
ctx.fillStyle = 'red';
ctx.fillRect(0, 0, 300, 200);

// draw "transparent white" repeatedly
let lastColor;
let percentDone=0;
render();

function render() {
    if (getCurrentColor() !== lastColor)
        console.log(lastColor = getCurrentColor());

    percentDone += .005;
    if (percentDone == 1) {percentDone = 1;}
    ctx.fillStyle = 'red';
    ctx.fillRect(0, 0, 300, 200);

    ctx.fillStyle = 'rgba(255,255,255,'+percentDone+')';
    ctx.fillRect(0, 0, 300, 200);
    requestAnimationFrame(render);
}

function getCurrentColor() {
    return ctx.getImageData(0, 0, 1, 1).data.join(',');
}
<canvas width="300" height="200"></canvas>

External: https://jsfiddle.net/Holger50/51h0yLm6/3/

€dit 1:

The color with transparency you want to add and the color on that position are compared and only the color difference is taken in account for calculation and is based on a integer division. If the calculated value is lower than 1, the color will not change and that is what happens at color strength 214 in your example.

I have tried several things out to find out why this stops on a weird step. Maybe the following helps understanding the problem: If you set the initial fill color to ctx.fillStyle = 'black'; and issue the following ctx.fillStyle = 'rgba(255,100,150,0.01)'; the stopping output is 207,43,129,255.

It seems that the new color with tranparency is calculated somehow based on the already existing value. If the difference is to low, the colors do not get added. As example: If you use ctx.fillStyle = 'rgba(10,100,150,0.01), the red part will never grow cause internally 10*0.01 is less than 1 and therefore not taken in account (it is rounded to the next integer). You can also see that in the color values. Red is raised by 100. The console output raises by 1 each step. Blue is raised by 150 and the console raises by 2 UNTIL it reaches value 44. After that, it will only grow with step 1 until it completely stops at 129.

I have attached a second fiddle where you can see the effect.

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

// draw "red"
ctx.fillStyle = 'black';
ctx.fillRect(0, 0, 300, 200);

// draw "transparent white" repeatedly
let lastColor;
render();
function render() {
    if (getCurrentColor() !== lastColor)
    console.log(lastColor = getCurrentColor());
    ctx.fillStyle = 'rgba(10,100,150,0.01)';
    ctx.fillRect(0, 0, 300, 200);
    requestAnimationFrame(render);
}

function getCurrentColor() {
    return ctx.getImageData(0, 0, 1, 1).data.join(',');
}
<canvas width="300" height="200"></canvas>

Related