Painting in Canvas which fades with time | Strange alpha layering behaviour

I'm painting to a canvas which isn't being cleared and making it so that the canvas either fades to a solid colour over time, or fades in alpha revealing the layer behind.

My first instinct was to simply fill a rectangle over the drawing with a low alpha each frame so that the fill colour accumulates gradually fading out the painting.

But I found some strange behaviour (to me at least, I'm sure there's a reason). The fill colour never fully accumulates. And the results change depending on wether paint & fill colours are lighter/darker than each other.

I found this question where someone was doing the same as me: fade out lines after drawing canvas?

The top answer looks good, and it's the same as what I tried. BUT it only works with black on white. Here's another version of the same fiddle with different colours, you'll see the drawing never disappears, it leaves a ghost: http://jsfiddle.net/R4V97/92/

var canvas = document.getElementById("canvas"),
    ctx = canvas.getContext("2d"),
    painting = false,
    lastX = 0,
    lastY = 0;

canvas.width = canvas.height = 600;

canvas.onmousedown = function (e) {
    if (!painting) {
        painting = true;
    } else {
        painting = false;
    }

    lastX = e.pageX - this.offsetLeft;
    lastY = e.pageY - this.offsetTop;
};

canvas.onmousemove = function (e) {
    if (painting) {
        mouseX = e.pageX - this.offsetLeft;
        mouseY = e.pageY - this.offsetTop;

        ctx.strokeStyle = "rgba(255,255,255,1)";
        ctx.beginPath();
        ctx.moveTo(lastX, lastY);
        ctx.lineTo(mouseX, mouseY);
        ctx.stroke();

        lastX = mouseX;
        lastY = mouseY;
    }
}

function fadeOut() {
    ctx.fillStyle = "rgba(60,30,50,0.2)";
    ctx.fillRect(0, 0, canvas.width, canvas.height);
    setTimeout(fadeOut,100);
}

fadeOut();

Also if you change the fill opacity to 0.01, and the timing to something like 20ms, it never even fills the correct colour, leaving it grey.

Other things I've tried all suffer from this same root problem. I've tried bouncing between two canvasses, taking canvas A and drawing it with a reduced alpha to canvas B, before drawing canvas B back to canvas A - same problem, there's a threshold where it doesn't disappear.

As a test I've even tried the super slow thing of getting the image data, looping through all pixels alpha channels and multiplying by 0.95 before putting the data back. It still leaves a ghost, I have to do something like this in the loop (it never even gets below 10 for some reason):

if (alpha<25) {
    alpha = 0;
}

I'm thinking I might be able to divide the canvas into a grid or rows and do the imageData thing one cell per frame, it might not be noticeable with low fade times.

But if anyone knows a better way or what the core thing I'm not getting is I'd be hugely grateful!

  • oh should also note, I'm letting it rasterise on the canvas because I'm painting with particles/algorithms so I'm not looking for solutions that mean I keep refreshing and redrawing the same points. Thanks!
WilliamAmateur Asked at 2017-01-05 11:23:58Z.
4 Answer(s)

RGB and 8bit integer math!

You need to avoid touching the RGB channels because when you do math on 8 bit values the results will have a huge error. Eg (8bit integer math) 14 * 0.1 = 1, 8 * 0.1 = 1 Thus when you draw over the existing pixels you will get a rounding error that will be different for each channel depending on the colour you are drawing on top.

There is not perfect solution but you can avoid the colour channels and fade only the alpha channel by using the global composite operation "destination-out" This will fade out the rendering by reducing the pixels alpha.

Works well for fade rates down to globalAlpha = 0.01 and even a little lower 0.006 but it can be troublesome below that. Then if you need even slower fade just do the fade every 2nd or 3rd frame.

ctx.globalAlpha = 0.01;           // fade rate
ctx.globalCompositeOperation = "destination-out"  // fade out destination pixels
ctx.fillRect(0,0,w,h)
ctx.globalCompositeOperation = "source-over"
ctx.globalAlpha = 1;           // reset alpha

Please note that this fade the canvas to transparent. If you want the fade to progress towards a particular colour you need to keep the fading canvas as a separate offscreen canvas and draw it over a canvas with the desired background to fade to.

Demo coloured particles on coloured background with fade.

var canvas = document.createElement("canvas");
canvas.width = 1024;
canvas.height = 1024;
var ctx = canvas.getContext("2d");
var w = canvas.width;
var h = canvas.height;
document.body.appendChild(canvas);

var fadCan = document.createElement("canvas");
fadCan.width = canvas.width;
fadCan.height = canvas.height;
var fCtx = fadCan.getContext("2d");

var cw = w / 2;  // center 
var ch = h / 2;
var globalTime;

function randColour(){
    return "hsl("+(Math.floor(Math.random()*360))+",100%,50%)";
}
var pps = [];
for(var i = 0; i < 100; i ++){
    pps.push({
        x : Math.random() * canvas.width,
        y : Math.random() * canvas.height,
        d : Math.random() * Math.PI * 2,
        sp : Math.random() * 2 + 0.41,
        col : randColour(),
        s : Math.random() * 5 + 2,
        t : (Math.random() * 6 -3)/10,
        
    });
}
function doDots(){
    for(var i = 0; i < 100; i ++){
        var d = pps[i];
        d.d += d.t * Math.sin(globalTime / (d.t+d.sp+d.s)*1000);
        d.x += Math.cos(d.d) * d.sp;
        d.y += Math.sin(d.d) * d.sp;
        d.x = (d.x + w)%w;
        d.y = (d.y + w)%w;
        fCtx.fillStyle = d.col;
        fCtx.beginPath();
        fCtx.arc(d.x,d.y,d.s,0,Math.PI * 2);
        fCtx.fill();
        
    }
}


var frameCount = 0;
// main update function
function update(timer){
    globalTime = timer;
    frameCount += 1;
    ctx.setTransform(1,0,0,1,0,0); // reset transform
    ctx.globalAlpha = 1;           // reset alpha
    ctx.fillStyle = "hsl("+(Math.floor((timer/50000)*360))+",100%,50%)";
    ctx.fillRect(0,0,w,h);
    doDots();
    if(frameCount%2){
        fCtx.globalCompositeOperation = "destination-out";
        fCtx.fillStyle = "black";
        var r = Math.random() * 0.04
        fCtx.globalAlpha = (frameCount & 2 ? 0.16:0.08)+r;
        fCtx.fillRect(0,0,w,h);
        fCtx.globalAlpha = 1;
        fCtx.globalCompositeOperation = "source-over"
    }
    ctx.drawImage(fadCan,0,0)
    requestAnimationFrame(update);
}
requestAnimationFrame(update);
Answered at 2017-01-05 16:17:27Z.

Blindman67's answer probably does give a correct core reason to why this is happening. But unfortunately, I think his solution won't work either.

Actually, the only real solution I can think of is one that you didn't wanted :
Record all the points of your paths and draw it one by one...

So even if you said you didn't want this solution, I'll post it here in case it can help someone else than OP.

this example does save paths, but you could save any object that needs to be faded over time with just the same basic steps :

  • record object's called time
  • get the alpha with ((currentTime - object.calledTime) / duration)
  • if alpha <= 0, remove the object
  • else set the alpha and redraw

// Some constructors

// The main Object that will handle all our paths + drawing logics
//  Expects a main (e.g visible) context as only argument
function PathFader(mainContext) {
  this.mainContext = mainContext;
  // create a copy of the main canvas
  this.ctx = mainContext.canvas.cloneNode().getContext('2d');
  this.list = [];
  // here are some settings you can change
  this.duration = 4000; // the time it takes to fade out a single path
  this.ctx.strokeStyle = 'white'; // the color of our paths
};
PathFader.prototype = Object.create({
  add: function(lx, ly, nx, ny) {
    this.list.push(new Path(lx, ly, nx, ny));
  },
  remove: function(path) {
    var index = this.list.indexOf(path);
    this.list.splice(index, 1);
  },
  draw: function(time) {
    // first set the currentTime to the one passed by rAF
    this.currentTime = time;
    // clear the curretn state
    this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height);
    // redraw all our pathes
    this.list.forEach(this.drawPathes, this);
    // draw our path context to the main one
    this.mainContext.drawImage(this.ctx.canvas, 0, 0);
  },
  drawPathes: function(path, i, list) {
    // calculate the path alpha at this time
    var a = 1 - ((this.currentTime - path.time) / this.duration);
    // if we're transparent
    if (a < 0) {
      this.remove(path);
      return;
    }
    // otherwise set the alpha
    this.ctx.globalAlpha = a;
    // draw the path
    this.ctx.beginPath();
    this.ctx.moveTo(path.lastX, path.lastY);
    this.ctx.lineTo(path.nextX, path.nextY);
    this.ctx.stroke();
  },
  resize: function() {
    var strokeStyle = this.ctx.strokeStyle,
      lineWidth = this.ctx.lineWidth;
    this.ctx.canvas.width = this.mainContext.canvas.width;
    this.ctx.canvas.height = this.mainContext.canvas.height;
    this.ctx.strokeStyle = strokeStyle;
    this.ctx.lineWidth = lineWidth;
  }
});

function Path(lastX, lastY, nextX, nextY) {
  this.time = performance.now();
  this.lastX = lastX;
  this.lastY = lastY;
  this.nextX = nextX;
  this.nextY = nextY;
}

var canvas = document.getElementById("canvas"),
  ctx = canvas.getContext("2d");
var painting = false,
  lastX = 0,
  lastY = 0,
  nextX, nextY,
  pathFader = new PathFader(ctx);

canvas.width = canvas.height = 600;
// since we do set the width and height of the mainCanvas after,
// we have to resize the Pathes canvas too
pathFader.resize();


canvas.onmousedown = function(e) {
  painting = !painting;
  lastX = e.pageX - this.offsetLeft;
  lastY = e.pageY - this.offsetTop;
};

// Since this is more performance consumptive than the original code,
//  we'll throttle the mousemove event

var moving = false;
canvas.onmousemove = function throttleMouseMove(e) {
  if (!moving) {
    nextX = e.pageX - this.offsetLeft;
    nextY = e.pageY - this.offsetTop;
    requestAnimationFrame(handleMouseMove);
    moving = true;
  }
};

function handleMouseMove() {
  moving = false;
  if (painting) {
    // add a new path, don't draw anything yet
    pathFader.add(lastX, lastY, nextX, nextY);

    lastX = nextX;
    lastY = nextY;
  }
}

ctx.fillStyle = "rgb(60,30,50)";

function anim(time) {
  // draw our background
  ctx.fillRect(0, 0, canvas.width, canvas.height);
  // draw the pathes (remember to pass rAF time param !)
  pathFader.draw(time);
  // do it again at next screen refresh
  requestAnimationFrame(anim);
}

anim();
<canvas id="canvas"></canvas>
Answered at 2017-01-06 02:54:31Z.

Answering my own question with what I ended up going with - thanks to the responses, after learning that the core problem is a rounding issue I figured adding some random noise to the fade amount could help make sure it's not always rounding to the same number, kinda like giving it a shake when it's stuck.

Here's that same jsfiddle modified: http://jsfiddle.net/R4V97/97/

var canvas = document.getElementById("canvas"),
    ctx = canvas.getContext("2d"),
    painting = false,
    lastX = 0,
    lastY = 0;

canvas.width = canvas.height = 600;

canvas.onmousedown = function (e) {
    if (!painting) {
        painting = true;
    } else {
        painting = false;
    }

    lastX = e.pageX - this.offsetLeft;
    lastY = e.pageY - this.offsetTop;
};

canvas.onmousemove = function (e) {
    if (painting) {
        mouseX = e.pageX - this.offsetLeft;
        mouseY = e.pageY - this.offsetTop;

        ctx.strokeStyle = "rgba(255,255,255,1)";
        ctx.beginPath();
        ctx.moveTo(lastX, lastY);
        ctx.lineTo(mouseX, mouseY);
        ctx.stroke();

        lastX = mouseX;
        lastY = mouseY;
    }
}

function fadeOut() {
    var r = 0.3 + (Math.random()*0.1);
    ctx.fillStyle = "rgba(60,30,50,"+r+")";
    ctx.fillRect(0, 0, canvas.width, canvas.height);
    setTimeout(fadeOut,100);
}

fadeOut();

This slightly compromises the smoothness of the fade, but it's a lot less noticeable/intrusive than the ghost trails.

Answered at 2017-01-16 10:56:26Z.

The answers here really helped me to understand the problem. I tried it @Blindman67's way but had issues with the globalCompositeOperation method as others mentioned.

What I ended up doing is push() mouse coordinates into an array, and then shift() the array when the line gets as long as I want the trail to be.

Then, each renderAnimationFrame I am drawing the set of segments in ascending transparency.

var canvas = document.getElementById('noGhost'),
ctx = canvas.getContext('2d'),
time = 0,
segments = [],
maxLength = 20,
lineColor = {
  r: 255,
  g: 0,
  b: 0
};
//really nice options for hex to rgb here: https://stackoverflow.com/questions/5623838/rgb-to-hex-and-hex-to-rgb


document.addEventListener('mousemove', function(evt){
  segments.push({
  x: evt.pageX,
  y: evt.pageY,
  });
  
  if(segments.length > maxLength) {
    segments.shift();
  }
}, false);


function render() {
  //reset canvas
  canvas.width = canvas.width;
  
  if(segments.length > 2) {
    for(var i = 1; i < segments.length; i++) {
      ctx.beginPath();
      ctx.strokeStyle = "rgba(" + lineColor.r + "," + lineColor.g + "," + lineColor.b + "," + (i / segments.length) + ")"
      ctx.moveTo(segments[i-1].x, segments[i-1].y);
      ctx.lineTo(segments[i].x, segments[i].y);
      ctx.stroke();
    }
    
    
  }
  //as time goes, shorten the length of the line
  time++;
  if(time % 2 == 0) {
  segments.shift();
  }
  requestAnimationFrame(render);
};
requestAnimationFrame(render);
#noGhost {
  background: silver;
}
<canvas height=200 width=400 id="noGhost">
</canvas>
Answered at 2017-11-20 00:37:00Z.