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

4

7

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!
Beadroll answered 5/1, 2017 at 11:23 Comment(1)
Weird... I suspect a rounding issue when the browsers calculate the low alphas... What if I tell you that I've got a workaround which works only on chrome, and only on small canvases ? I may spend some more time in next few days if no one have come yet, but I'm afraid this all ends up in just bug reports...Drat
S
9

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);

Demo drawing on coloured background with fade.

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);
Suellen answered 5/1, 2017 at 16:17 Comment(6)
I think you've got the core problem, but the destination-out solution won't work either... Whatever the gCO, low alpha values are just ignored. Also I'm struggling to get why chrome doesn't have this rounding issue on smaller canvases...Drat
Sorry but the ghost is still there... You can check the imageData yourself, or flip your screen a bit to see it.Drat
@Drat I am aware there are trails, that is why I change the background colour because you can not see them otherwise. The point is that the colours are not being aliased, each pixel's RGB value remains unchanged, only the alpha value changes. The trails are easily removed by increasing the alpha value when masking.Suellen
Not sure to follow, and I think OP actually wants to get this trails removed over time. What's the result benefit of using destination-out over OP's method if this leaves the same trails ? (I know performance wise it's better, and I think copy + drawImage may even be better, but that's not the point here.)Drat
@Drat Using dest-out does not mangle the colours (due to rounding errors affecting each channel slightly differently thus shifting the hue) I have changed the first demo to not leave any trails.Suellen
It still leaves trails for me... I upvoted for the first part, but I still maintain that the solution provided doesn't work.Drat
B
6

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.

Beadroll answered 16/1, 2017 at 10:56 Comment(1)
This is so stupid... this is so brilliant! I love it! Thank you for saving me after multiple hours of attempts. While it's not perfect it works much better than without the jitter.Pammie
D
2

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>

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 https://mcmap.net/q/628477/-draw-10-000-objects-on-canvas-javascript
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>
Drat answered 6/1, 2017 at 2:54 Comment(0)
P
0

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://mcmap.net/q/9259/-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>
Plow answered 20/11, 2017 at 0:37 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.