2

I'm trying to make it appear as though movement on my <canvas> creates motion trails. In order to do this, instead of clearing the canvas between frames I reduce the opacity of the existing content by replacing a clearRect call with something like this:

// Redraw the canvas's contents at lower opacity. The 'copy' blend
// mode keeps only the new content, discarding what was previously
// there. That way we don't have to use a second canvas when copying 
// data
ctx.globalCompositeOperation = 'copy';
ctx.globalAlpha = 0.98;
ctx.drawImage(canvas, 0, 0);
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';

However, since setting globalAlpha multiplies alpha values, the alpha values of the trail can approach zero but will never actually reach it. This means that graphics never quite fade, leaving traces like these on the canvas that do not fade even after thousands of frames have passed over several minutes:

traces

To combat this, I've been subtracting alpha values pixel-by-pixel instead of using globalAlpha. Subtraction guarantees that the pixel opacity will reach zero.

// Reduce opacity of each pixel in canvas
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
// Iterates, hitting only the alpha values of each pixel.
for (let i = 3; i < data.length; i += 4) {
  // Use 0 if the result of subtraction would be less than zero.
  data[i] = Math.max(data[i] - (0.02 * 255), 0);
}
ctx.putImageData(imageData, 0, 0);

This fixes the problem, but it's extremely slow since I'm manually changing each pixel value and then using the expensive putImageData() method.

Is there a more performant way to subtract, rather than multiplying, the opacity of pixels being drawn on the canvas?

Luke Taylor
  • 8,631
  • 8
  • 54
  • 92
  • 1
    if you put 0.98 then you probably can also check if it's approaching 1 or so and set it to 0? – EricG Aug 07 '17 at 14:35
  • @EricG The opacity of different parts of the image varies and the entire thing is drawn at once. The effect is achieved by drawing the entire last frame with lower opacity, meaning that the frame before that is also visible but with further reduced opacity. Nowhere am I checking the opacity of individual pixels; that gets too expensive. – Luke Taylor Aug 07 '17 at 15:09
  • 1
    How about maintain the pixels you drew and only update those? Then you don't have to go to every single pixel, and when there's overlap you replace the pixel/value. Inspired by http://perfectionkills.com/exploring-canvas-drawing-techniques/#colored-pixels – EricG Aug 07 '17 at 15:23
  • Possible duplicate of [Painting in Canvas which fades with time | Strange alpha layering behaviour](https://stackoverflow.com/questions/41483806/painting-in-canvas-which-fades-with-time-strange-alpha-layering-behaviour) – Kaiido Aug 07 '17 at 23:01

1 Answers1

1

Unfortunately there is nothing we can do about it except from manually iterating over the pixels to clear low-value alpha pixels like you do already.

The problem is related to integer math and rounding (more details at this link, from the answer).

There are blending modes such as "luminosity" (and to a certain degree "multiply") which can be used to subtract luma, the problem is it works on the entire surface contrary to composite modes which only works on alpha - there is no equivalent in composite operations. So this won't help here.

There is also a new luma mask via CSS but the problem is that the image source (which in theory could've been manipulated using for example contrast) has to be updated every frame and basically, the performance would be very bad.

Workaround

One workaround is to use "particles". That is, instead of using a feedback-loop instead log and store the path points, then redraw all logged points every frame. Using a max value and reusing that to set alpha can work fine in many cases.

This simple example is just a proof-of-concept and can be implemented in various ways in regards to perhaps pre-populated arrays, order of drawing, alpha value calculations and so forth. But I think you'll get the idea.

var ctx = c.getContext("2d");
var cx = c.width>>1, cy = c.height>>1, r = c.width>>2, o=c.width>>3;
var particles = [], max = 50;

ctx.fillStyle = "#fff";

(function anim(t) {
  var d = t * 0.002, x = cx + r * Math.cos(d), y = cy + r * Math.sin(d);
  
  // store point and trim array when reached max
  particles.push({x: x, y: y});
  if (particles.length > max) particles.shift();
  
  // clear frame as usual
  ctx.clearRect(0,0,c.width,c.height);
  
  // redraw all particles at a log. alpha, except last which is drawn full
  for(var i = 0, p, a; p = particles[i++];) {
    a = i / max * 0.6;
    ctx.globalAlpha = i === max ? 1 : a*a*a;
    ctx.fillRect(p.x-o, p.y-o, r, r);  // or image etc.
  }

  requestAnimationFrame(anim);
})();
body {background:#037}
<canvas id=c width=400 height=400></canvas>
Community
  • 1
  • 1