owzim
owzim

Reputation: 925

rgba fillStyle with alpha does not get fully opaque if applied multiple times

I stubled upon a weird problem. The following code results in making the image fade away because it's overdrawn by a semi-opaque rect over and over again.

But at least at the 10th iteration of draw(); the image should be completely overdrawn, because the rect should be fully opaque by then, right? But it actually never disappears completely.

This effect is worse on Chrome than it is on Firefox. But beware: bad screens may hide this faulty behaviour =)

I also made a demo on jsFiddle.

$(function () {
var canvas = $("#mycanvas"),
    ctx = canvas[0].getContext("2d"),
    imgUrl = "http://it-runde.de/dateien/2009/august/14/25.png";


var image = new Image();  
image.src = imgUrl ;  
$(image).load(function() {
    ctx.drawImage(image, 0, 0, canvas.width(), canvas.height());
    draw();
});

function draw() {        
    ctx.fillStyle = "rgba(255, 255, 255, 0.1)";
    ctx.fillRect(0, 0, canvas.width(), canvas.height());
    setTimeout(draw, 100);
    
}    
});

The effect one may want to achieve is that, say an object is moving all over the canvas, and the already drawn positions get overdrawn only slightly so after-glow of after-fade effect. But this result is just fugly.

So is there any solution to this?

Upvotes: 8

Views: 15274

Answers (4)

George
George

Reputation: 2422

The solution is to manipulate the pixel data with ctx.getImageData and ctx.putImageData.

Instead of using ctx.fillRect with a translucent fillStyle, set each pixel slightly to your background colour each frame. In my case it is black, which makes things simpler.

With this solution, your trails can be as long as you want, if float precision is taken into account.

function postProcess(){
  const fadeAmount = 1-1/256;
  const imageData = ctx.getImageData(0, 0, w, h);
  for (let x = 0; x < w; x++) {
    for (let y = 0; y < h; y++) {
      const i = (x + y * w) * 4;
      imageData.data[i] = Math.floor(imageData.data[i]*fadeAmount);
      imageData.data[i + 1] = Math.floor(imageData.data[i + 1]*fadeAmount);
      imageData.data[i + 2] = Math.floor(imageData.data[i + 2]*fadeAmount);
      imageData.data[i + 3] = 255;
    }
  }
  ctx.putImageData(imageData, 0, 0);
}

const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
const w = window.innerWidth;
const h = window.innerHeight;
canvas.width = w;
canvas.height = h;
const cs = createCs(50);
let frame = 0;
function init(){
  ctx.strokeStyle = '#FFFFFF';
  ctx.fillStyle = '#000000';
  ctx.fillRect(0, 0, w, h)
  loop();
}
function createCs(n){
  const cs = [];
  for(let i = 0; i < n; i++){
    cs.push({
      x: Math.random()*w,
      y: Math.random()*h,
      r: Math.random()*5+1
    });
  }
  return cs;
}
function draw(frame){
  //no longer need these:
  //ctx.fillStyle = 'rgba(0,0,0,0.02)'
  //ctx.fillRect(0, 0, w, h)
  ctx.beginPath();
  cs.forEach(({x,y,r}, i) => {
    cs[i].x += 0.5;
    if(cs[i].x > w) cs[i].x = -r;
    ctx.moveTo(x+r+Math.cos((frame+i*4)/30)*r, y+Math.sin((frame+i*4)/30)*r);
    ctx.arc(x+Math.cos((frame+i*4)/30)*r,y+Math.sin((frame+i*4)/30)*r,r,0,Math.PI*2);
  });
  ctx.closePath();
  ctx.stroke();
  //only fade every 4 frames
  if(frame % 4 === 0) postProcess(0,0,w,h*0.5);
  //fade every frame
  postProcess(0,h*0.5,w,h*0.5);
  
}
//fades canvas to black
function postProcess(sx,sy,dw,dh){
  sx = Math.round(sx);
  sy = Math.round(sy);
  dw = Math.round(dw);
  dh = Math.round(dh);
  const fadeAmount = 1-4/256;
  const imageData = ctx.getImageData(sx, sy, dw, dh);
  for (let x = 0; x < w; x++) {
    for (let y = 0; y < h; y++) {
      const i = (x + y * w) * 4;
      imageData.data[i] = Math.floor(imageData.data[i]*fadeAmount);
      imageData.data[i + 1] = Math.floor(imageData.data[i + 1]*fadeAmount);
      imageData.data[i + 2] = Math.floor(imageData.data[i + 2]*fadeAmount);
      imageData.data[i + 3] = 255;
    }
  }
  ctx.putImageData(imageData, sx, sy);
}
function loop(){
  draw(frame);
  frame ++;
  requestAnimationFrame(loop);
}
init();
canvas {
width: 100%;
height: 100%;
}
<canvas id="canvas"/>

Upvotes: 0

arc
arc

Reputation: 4691

The reason was perfectly stated before. It is not possible to get rid of it without clearing it and redrawing it like @Sam already said.

What you can you do to compensate it a bit is to set globalCompositeOperation.

There are various operations that help. From my tests I can say that hard-light works best for dark backgrounds and lighter work best for bright backgrounds. But this very depends on your scene.

An example making trails on "near" black

ctx.globalCompositeOperation = 'hard-light'
ctx.fillStyle = 'rgba(20,20,20,0.2)' // The closer to black the better
ctx.fillRect(0, 0, width, height)

ctx.globalCompositeOperation = 'source-over' // reset to default value

Upvotes: 0

Sam
Sam

Reputation: 3480

I know this is old but I don't think the previously accepted answer is correct. I think this is happening as a result of pixel values being truncated from float to byte. In Windows 7 running Chrome version 39.0.2171.95m, after running your fiddle for a while, the image is still visible but only lightly, and doesn't appear to be changing any more. If I take a screenshot I see the following pixel values on the image:

(246, 246, 246)

When you draw a rectangle over it with rgba of:

(255, 255, 255, 0.1)

and apply alpha blending using the default compositing mode of source-over, before converting to a byte you get:

(255 * 0.1 + 246 * 0.9) = 246.9

So you can see that, assuming the browser simply truncates the floating point value to a byte, it will write out a value of 246, and every time you repeat the drawing operation you'll always end up with the same value.

There is a big discussion on the issue at this blog post here.

As a workaround you could continually clear the canvas and redraw the image with a decreasing globalAlpha value. For example:

    // Clear the canvas
    ctx.globalAlpha = 1.0;
    ctx.fillStyle = "rgb(255, 255, 255)";
    ctx.fillRect(0,0,canvas.width(),canvas.height());

    // Decrement the alpha and draw the image
    alpha -= 0.1;
    if (alpha < 0) alpha = 0;
    ctx.globalAlpha = alpha;
    console.log(alpha);
    ctx.drawImage(image, 0, 0, 256, 256);
    setTimeout(draw, 100);

Fiddle is here.

Upvotes: 9

Neil
Neil

Reputation: 55392

Since the rectangle is only 10% opaque, the result of drawing it over the image is a composite of 90% of the image and 10% white. Each time you draw it you lose 10% of the previous iteration of the image; the rectangle itself does not become more opaque. (To get that effect, you would need to position another object over the image and animate its opacity.) So after 10 iterations you still have (0.9^10) or about 35% of the original image. Note that rounding errors will probably set in after about 30 iterations.

Upvotes: 5

Related Questions