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!
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.
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);
Click drag mouse to draw.
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%)";
}
// main update function
function update(timer){
globalTime = timer;
ctx.setTransform(1,0,0,1,0,0); // reset transform
ctx.globalAlpha = 1; // reset alpha
ctx.fillStyle = "hsl("+(Math.floor((timer/150000)*360))+",100%,50%)";
ctx.fillRect(0,0,w,h);
if(mouse.buttonRaw === 1){
fCtx.strokeStyle = "White";
fCtx.lineWidth = 3;
fCtx.lineCap = "round";
fCtx.beginPath();
fCtx.moveTo(mouse.lx,mouse.ly);
fCtx.lineTo(mouse.x,mouse.y);
fCtx.stroke();
}
mouse.lx = mouse.x;
mouse.ly = mouse.y;
fCtx.globalCompositeOperation = "destination-out";
fCtx.fillStyle = "black";
fCtx.globalAlpha = 0.1;
fCtx.fillRect(0,0,w,h);
fCtx.globalAlpha = 1;
fCtx.globalCompositeOperation = "source-over"
ctx.drawImage(fadCan,0,0)
requestAnimationFrame(update);
}
var mouse = (function () {
function preventDefault(e) {
e.preventDefault();
}
var mouse = {
x : 0,
y : 0,
w : 0,
alt : false,
shift : false,
ctrl : false,
buttonRaw : 0,
over : false,
bm : [1, 2, 4, 6, 5, 3],
active : false,
bounds : null,
crashRecover : null,
mouseEvents : "mousemove,mousedown,mouseup,mouseout,mouseover,mousewheel,DOMMouseScroll".split(",")
};
var m = mouse;
function mouseMove(e) {
var t = e.type;
m.bounds = m.element.getBoundingClientRect();
m.x = e.pageX - m.bounds.left + scrollX;
m.y = e.pageY - m.bounds.top + scrollY;
m.alt = e.altKey;
m.shift = e.shiftKey;
m.ctrl = e.ctrlKey;
if (t === "mousedown") {
m.buttonRaw |= m.bm[e.which - 1];
} else if (t === "mouseup") {
m.buttonRaw &= m.bm[e.which + 2];
} else if (t === "mouseout") {
m.buttonRaw = 0;
m.over = false;
} else if (t === "mouseover") {
m.over = true;
} else if (t === "mousewheel") {
m.w = e.wheelDelta;
} else if (t === "DOMMouseScroll") {
m.w = -e.detail;
}
if (m.callbacks) {
m.callbacks.forEach(c => c(e));
}
if ((m.buttonRaw & 2) && m.crashRecover !== null) {
if (typeof m.crashRecover === "function") {
setTimeout(m.crashRecover, 0);
}
}
e.preventDefault();
}
m.addCallback = function (callback) {
if (typeof callback === "function") {
if (m.callbacks === undefined) {
m.callbacks = [callback];
} else {
m.callbacks.push(callback);
}
}
}
m.start = function (element) {
if (m.element !== undefined) {
m.removeMouse();
}
m.element = element === undefined ? document : element;
m.mouseEvents.forEach(n => {
m.element.addEventListener(n, mouseMove);
});
m.element.addEventListener("contextmenu", preventDefault, false);
m.active = true;
}
m.remove = function () {
if (m.element !== undefined) {
m.mouseEvents.forEach(n => {
m.element.removeEventListener(n, mouseMove);
});
m.element.removeEventListener("contextmenu", preventDefault);
m.element = m.callbacks = undefined;
m.active = false;
}
}
return mouse;
})();
mouse.start(canvas);
requestAnimationFrame(update);
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.
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 :
((currentTime - object.calledTime) / duration)
alpha <= 0
, remove the object// 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>
Ps : An alternative solution would be to use a lot of canvases, and draw the first ones on the next ones with less and less opacity.
Here is a proof of concept which has some bugs relative to the duration controls...
var ctx = canvas.getContext('2d');
var objects = [],
w = canvas.width,
h = canvas.height;
function Fader(mainContext) {
var nbOfFrames = 25;
this.distance = 2000;
this.mainContext = mainContext;
this.list = [mainContext];
var ctx;
var alphaStep = 1 - (1 / (nbOfFrames - 1));
for (var i = 0; i < nbOfFrames; i++) {
ctx = mainContext.canvas.cloneNode().getContext('2d');
this.list.push(ctx);
ctx.globalAlpha = 1 - (i / (nbOfFrames + 1));
}
}
Fader.prototype = {
draw: function() {
var main = this.list[0];
if (!this.creationTime) {
this.creationTime = performance.now();
return;
}
// only used at init, to set the distance between each frame,
// but there is something wrong here..
var limit = ~~(((performance.now() - this.creationTime) / this.distance) * this.list.length);
if (!limit) {
return;
} // first frame
var c;
// update the contexts content
for (var i = Math.min(this.list.length - 1, limit); i > 0; i--) {
c = this.list[i];
c.clearRect(0, 0, w, h);
c.drawImage(this.list[i - 1].canvas, 0, 0);
}
// draw them back to the main one
main.globalCompositeOperation = 'destination-over';
this.list.forEach(function(c, i) {
if (!i) return;
main.drawImage(c.canvas, 0, 0);
});
main.globalCompositeOperation = 'source-over';
}
};
var fader = new Fader(ctx);
// taken from http://stackoverflow.com/a/23486828/3702797
for (var i = 0; i < 100; i++) {
objects.push({
angle: Math.random() * 360,
x: 100 + (Math.random() * w / 2),
y: 100 + (Math.random() * h / 2),
radius: 10 + (Math.random() * 40),
speed: 1 + Math.random() * 20
});
}
var stopMoving = false;
document.body.onclick = e => stopMoving = !stopMoving;
ctx.fillStyle = "rgb(60,30,50)";
var draw = function() {
ctx.clearRect(0, 0, w, h);
for (var n = 0; n < 100; n++) {
var entity = objects[n],
velY = stopMoving ? 0 : Math.cos(entity.angle * Math.PI / 180) * entity.speed,
velX = stopMoving ? 0 : Math.sin(entity.angle * Math.PI / 180) * entity.speed;
entity.x += velX;
entity.y -= velY;
ctx.drawImage(img, entity.x, entity.y, entity.radius, entity.radius);
entity.angle++;
}
fader.draw();
ctx.globalCompositeOperation = 'destination-over';
ctx.fillRect(0,0,w, h);
ctx.globalCompositeOperation = 'source-over';
requestAnimationFrame(draw);
}
var img = new Image();
img.onload = draw;
img.crossOrigin = 'anonymous';
img.src = "https://dl.dropboxusercontent.com/s/4e90e48s5vtmfbd/aaa.png";
<canvas id="canvas" width=600 height=600></canvas>
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With