LVDM
LVDM

Reputation: 454

Canvas animation "slowing down" when scrolling up & down rapidly

I'm currently working on a website when you scroll down a line will animate into a square/rectangle, works perfectly but there's one thing that's bugging me a bit might be a hard one to solve but here it is:

When the animation is playing and you scroll up and down rapidly (with the canvas in the viewport), you can see it slowing down a little bit. No idea what's causing this, i'm sending only 1 call to animate function each time the element reaches a certain point in the Window. Any ideas?

Edit: Updated the link, just scroll down and it'll be more clear by what i mean by slowing down.

See working example in this fiddle or below.

function createBorderAnimation(elm) {

  console.log(elm);

  var canvas = elm.get(0),
    ctx = canvas.getContext('2d');

  canvas.width = $(window).width() / 4 * 3;
  canvas.height = $(window).height() / 2;

  var Point = function(x, y) {
    this.x = x;
    this.y = y;
  };
  var points = [
    new Point(0, 0),
    new Point(canvas.width, 0),
    new Point(canvas.width, canvas.height),
    new Point(0, canvas.height),
    new Point(0, -10),
  ];

  function calcWaypoints(vertices) {
    var waypoints = [];
    for (var i = 1; i < vertices.length; i++) {
      var pt0 = vertices[i - 1];
      var pt1 = vertices[i];
      var dx = pt1.x - pt0.x;
      var dy = pt1.y - pt0.y;
      for (var j = 0; j < 50; j++) {
        var x = pt0.x + dx * j / 50;
        var y = pt0.y + dy * j / 50;
        waypoints.push({
          x: x,
          y: y
        });
      }
    }
    return (waypoints);
  }

  var wayPoints = calcWaypoints(points);

  ctx.strokeStyle = "rgba(0,0,0,0.5)";
  ctx.lineWidth = 5;
  ctx.moveTo(points[0].x, points[0].y);
  ctx.globalCompositeOperation = 'destination-atop';

  var counter = 1,
    inter = setInterval(function() {
      var point = wayPoints[counter++];
      ctx.lineTo(point.x, point.y);
      ctx.stroke();

      if (counter >= wayPoints.length) {
        clearInterval(inter);
        $(canvas).parent().addClass('active');
      }
    }, 1);
  ctx.stroke();
}
$(window).scroll(function() {
  var st = $(window).scrollTop();
  $('canvas').each(function(key, elm) {
    var _this = $(elm);
    if (st > _this.offset().top - $(window).height()) {
      if (!_this.hasClass('animating')) {
        _this.addClass('animating');
        createBorderAnimation(_this);
      }
      TweenLite.set(_this, {
        y: -(st - _this.offset().top) / (1.5 * 10)
      });
    }
  });
});
canvas {
   width: 35vw;
   height: 50vh;
   display: inline-block;
   vertical-align: top;
}

canvas:first-child {
  margin: 0 5vw 0 0;
}
div {
  width: 75vw;
  height: 50vh;
  margin: 100px auto;
  font-size: 0;
}
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/latest/TweenMax.min.js"></script>
<div>
  <canvas></canvas>
  <canvas></canvas>
</div>
<div>
  <canvas></canvas>
  <canvas></canvas>
</div>
<div>
   <canvas></canvas>
   <canvas></canvas>
</div>
<div>
  <canvas></canvas>
  <canvas></canvas>
</div>
<div>
  <canvas></canvas>
  <canvas></canvas>
</div>

Upvotes: 0

Views: 1276

Answers (1)

Kaiido
Kaiido

Reputation: 136608

You got some good advises from @Blindman67 and @markE (his answer is now deleted), about

  • don't use setInterval for an animation
  • throttle your scroll event

I would also add that you should avoid initializing things as much as possible, (initialize once, then reuse), and be careful with jQuery.

About setInterval, it's just bad, imprecise, and may lead to browser crash when call on too small intervals (it's just a reminder to the browser that it has to do something when the interval has elapsed, so if something takes longer than the interval, it will just keep adding things to do, and will try to execute them all, blocking all other resources and finally just crashes...)

It should be noted that the scroll event may fire really fast depending on the device used, and usually, just faster than you screen refresh rate, so it's one of the events that needs the most optimization.

Here you are doing some extensive use of jQuery's methods, inside the scroll event. The $(selector) method is a more complicated way to call document.querySelectorAll(), which in itself is already kind of a loud operation.
Moreover, for all these canvases, you are again calling jQuery's methods to get their size and position. To understand why it's bad at high rate, simply keep in mind that .height is calling window.getComputedStyle(elem) every time, which does return every styles assigned to your element, it may be fine occasionally, but not in your case (> 300 calls per seconds).

A simple workaround : call all of these once and store what won't change somewhere.

You can also use requestAnimationFrame to throttle it, here is a simple example using a flag :

// our flag
var scrolling;
window.onscroll=function(){
    // only if we haven't already stacked our function
    if(!scrolling){
        // raise our flag
        scrolling = true
        requestAnimationFrame(function(){
            // add the callback function
            doSomething();
            // release the flag for next frame
            scrolling = false;
            })
        }
    }

So here is an annotated cleanup of your code, using requestAnimationFrame, and avoiding too much calls to jQuery's method. It can certainly be optimized and cleaned further, but I hope you'll get the main idea.

var BorderAnimation = function() {
  // a global array for all our animations
  var list = [];

  // init one animation per canvas
  var initAnim = function() {
    // our canvas
    var c = this;
    // our animation object
    var a = {};
    a.ctx = c.getContext('2d');
    // a function to check if our canvas is visible in the screen
    a.isVisible = function(min, max) {
      return min < c.offsetTop + a.height && max > c.offsetTop;
    };
    // a trigger
    a.play = function() {
      // fire only if we're not already animating, and if we're not already finished
      if (!a.playing && !a.ended) {
        a.playing = true;
        loop();
      }
    };
    // reverse trigger
    a.pause = function() {
        a.playing = false;
      }
      // our looping function
    var loop = function() {
      // perform our drawing operations
      a.draw(a.ctx);
      // and only if we should still be playing...
      if (a.playing) {
        //...loop
        requestAnimationFrame(loop);
      }
    };
    // init our canvas' size and store it in our anim object
    a.width = c.width = $(window).width() / 4 * 3;
    a.height = c.height = $(window).height() / 2;
    // initialize the drawings for this animation
    initDrawing(a);
    // push our animation in the global array
    list.push(a);
  };

  // this does need to be made outside, but I feel it's cleaner to separate it, 
  //	and if I'm not wrong, it should avoid declaring these functions in the loop.
  var initDrawing = function(anim) {

    var ctx = anim.ctx;

    var Point = function(x, y) {
      this.x = x;
      this.y = y;
    };
    var points = [
      new Point(0, 0),
      new Point(anim.width, 0),
      new Point(anim.width, anim.height),
      new Point(0, anim.height),
      new Point(0, -10),
    ];

    function calcWaypoints(vertices) {
      var waypointsList = [];
      for (var i = 1; i < vertices.length; i++) {
        var pt0 = vertices[i - 1];
        var pt1 = vertices[i];
        var dx = pt1.x - pt0.x;
        var dy = pt1.y - pt0.y;
        for (var j = 0; j < 50; j++) {
          var x = pt0.x + dx * j / 50;
          var y = pt0.y + dy * j / 50;
          waypointsList.push({
            x: x,
            y: y
          });
        }
      }
      return (waypointsList);
    }

    var wayPoints = calcWaypoints(points);

    ctx.strokeStyle = "rgba(0,0,0,0.5)";
    ctx.lineWidth = 5;
    ctx.globalCompositeOperation = 'destination-atop';
    // always better to add this, e.g if you plan to make a `reset` function
    anim.ctx.beginPath();
    anim.ctx.moveTo(points[0].x, points[0].y);

    var counter = 1;
    // store into our drawing object the drawing operations
    anim.draw = function() {
      // we reached the end
      if (counter >= wayPoints.length) {
        anim.playing = false;
        anim.ended = true;
        return;
      }

      var point = wayPoints[counter++];

      ctx.lineTo(point.x, point.y);
      //  ctx.clearRect(0,0,anim.width, anim.height); // don't you need it ???
      ctx.stroke();
    }
  };
  // a single call to the DOM
  $('canvas').each(initAnim);

  var scrolling;
  var checkPos = function() {
    // release the flag
    scrolling = false;
    var st = pageYOffset; // it's just the same as $(window).scrollTop();
    // loop over our list of animations
    list.forEach(function(a) {
      // the canvas is in the screen
      if (a.isVisible(st, st + window.innerHeight)) {
        // it will be blocked if already playing
        a.play();
      } else {
        // stop the loop
        a.pause();
      }
    });
  };
  $(window).scroll(function() {
    if (!scrolling) {
      // set the flag
      scrolling = true;
      requestAnimationFrame(checkPos);
    }
  });
  // if you want to do something with these animations, e.g debugging
  return list;
}();
canvas {
  width: 35vw;
  height: 50vh;
  display: inline-block;
  vertical-align: top;
}
canvas:first-child {
  margin: 0 5vw 0 0;
}
div {
  width: 75vw;
  height: 50vh;
  margin: 100px auto;
  font-size: 0;
}
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<div>
  <canvas></canvas>
  <canvas></canvas>
</div>
<div>
  <canvas></canvas>
  <canvas></canvas>
</div>
<div>
  <canvas></canvas>
  <canvas></canvas>
</div>
<div>
  <canvas></canvas>
  <canvas></canvas>
</div>
<div>
  <canvas></canvas>
  <canvas></canvas>
</div>

Upvotes: 1

Related Questions