mystreie
mystreie

Reputation: 153

cancel canvas animation by adding eventListener

I am trying to reset my rectangle animation from the starting point when I resize the browser, the code is like this:

/*from basic setup*/
canvas = document.getElementById("myCanvas");
canvas.height = window.innerHeight;
canvas.width = window.innerWidth;
ctx = canvas.getContext("2d");

/*the "resize" event and the reset function should be triggered */
window.addEventListener("resize",function(){
     canvas.height = window.innerHeight;
     canvas.width = window.innerWidth;
     //cancelAnimationFrame(timeID)
     //theRect();
});

/*drawing the rectangle*/
var x = 0,y = canvas.height - 100,w = 100, h = 100,dx=1;

function theRect(){
 ctx.fillStyle = "lightblue";
 ctx.fillRect(x,y,w,h)
 x+=dx;
 if(x+w>canvas.width/2 || x<0){
    dx=-dx;
 }
}

/*start animation*/

function animate(){
    ctx.clearRect(0,0,canvas.width,canvas.height);
    theRect();
    requestAnimationFrame(animate);

}
//var timeID = requestAnimationFrame(animate)

animate();

I was thinking if each time the browser resized, the rectangle has to be reset to the starting point asctx.fillRect(0,canvas.height-100,100,100)and continue its animation.

So my original approach to solve this is using cancelAnimationFrame()this method to kill the animation first and calling this function again within my eventlistener so each time the event was fired, the animation will stop and run again.

but cancellAnimationFrame()function requires the target animation timeID, and there is no way to track this timeID inside my animate()function since if I am calling var timeID = requestionAnimationFrame(animate)it will come to huge lag spikes on my browser

just the more I want to modify to more I become to loss with this, so my questions are:

1, is this wise to use cancelAnimationFrame here as the first step to reset my animation?

2, is there any other good way to achieve this "reset" purpose?

Could someone please help me with this? thanks in advance.

Upvotes: 0

Views: 1475

Answers (3)

Blindman67
Blindman67

Reputation: 54059

Animation

Animation is best handled with the system time or page time with a single time variable holding the start time of the animation, and the current animation position being found by subtracting the start time from the current time.

Iterative animation

This will mean that the way you animate has to change as iterative animations do not lend themselves to conditional branching as you have done with...

// Code from OP's question
function theRect() {
   ctx.fillStyle = "lightblue";
   ctx.fillRect(x, y, w, h);
   x += dx;
   if (x + w > canvas.width / 2 || x < 0) {
      dx = -dx;
   }
}

The above works well if timing is not important, but you can not find the position at any particular point in time as you need to iterate the positions in between. Also any frames dropped will change the speed of the animated object and over time the position becomes indeterminate in respect to time.

Time and controllers.

The alternative is to use controllers to animate. Controllers are just functions the produce shaped output depending on a input. This is how most CSS animation is done, with controllers such as easeIn, easeOut, and dozens more. Animation controls can be fixed time (only play once over a fixed amount of time), they have a start time and an end time, or they can be cyclic having a fixed period. Controllers can also easily integrate keyframed animation.

Controllers are very powerful and should be in every game/animation coders knowledge set.

Cyclic triangle waveform

For this animation a cyclic triangle controller will suit best to mimic the animation you have.Example of cyclic triangle wave The above image is from Desmos calculator and shows the triangle function over time (x axis is time). All controllers have a frequency of 1 and an amplitude of 1.

The controller as a Javascript function

// waveforms are cyclic to the second and return a normalised range 0-1
const waveforms = {
    triangle (time) { return Math.abs(2 * (time - Math.floor(time + 0.5))) },
}

Now you can get the position of the rectangle as a function of time. All you need is the the start time, the current time, the speed in pixels of the object, and the distance it travels.

I have created an object to define the rectangle and animation parameters

const rect = {
    startTime : -1, // start time if -1 means start time is unknown
    speed : 60, // in pixels per second
    travelDist : canvas.width / 2 - 20,
    colour : "#8F8",
    w : 20,
    h : 20,
    y : 100,
    setStart(time){
        this.startTime = time / 1000;  // convert to seconds
        this.travelDist = (canvas.width / 2) - this.w;
    },
    draw(time){
        var pos = ((time / 1000) - this.startTime); // get animation position in seconds
        pos = (pos * this.speed) / (this.travelDist * 2)
        var x = waveforms.triangle(pos) * this.travelDist;
        ctx.fillStyle = this.colour;
        ctx.fillRect(x,this.y,this.w,this.h);
    }
}   

So once the start time is set you can find the position of the rectangle at any time by just providing the current time

This is a simple animation and the position of the rectangle can also be calculated as a function of time.

 rect.draw(performance.now());  // assumes start time has been set

Resizing

Resize events can be problematic as they will fire as often as the mouse sample rate which can be many more times than the frame rate. The result is code being run many more times than is necessary, increasing GC hits (due to canvas resizing), reduced mouse capture rates (mouse events are dropped with subsequent events providing the missing data) making the window resize seem sluggish.

Debounced events

The common way to battle this problem has been to use a debounced resize event handler. This is simply means that the actual resize event is delayed by a small amount of time. If in the debounce period another resize event is fired the current scheduled resize is canceled and another is scheduled.

var debounceTimer;  // handle to timeout
const debounceTime = 50; // in ms set to a few frames
addEventListener("resize",() => {
    clearTimeout(debounceTimer); 
    debounceTimer = setTimeout(resizeCanvas,debounceTime);
}
function resizeCanvas(){
    canvas.width = innerWidth;
    canvas.height = innerHeight;
}

This ensures that the canvas resizes waits before resizing give subsequent resize event a chance to happen before resizing.

Though this is better than directly handling the resize event it is still a little problematic as the delay is noticeable with the canvas not resizing until after the next frame or two.

Frame synced resize

The best solution I have found is to sync the resize to the animation frames. You can just have the resize event set a flag to indicate that the window has resized. Then in the animation main loop you monitor the flag, if true then resize. This means that the resize is synced with the display rate, and extra resize events are ignored. It also sets a better start time for animations (synced to the frame time)

var windowResized = true; // flag a resize at start so that its starts
                          // all the stuff needed at startup
addEventListener("resize",() => {windowResized = true});  // needs the {}
function resizeWindow(time){
    windowResized = false; // clear the flag
    canvas.width = innerWidth;
    canvas.height = innerHeight;
    rect.setStart(time); // reset the animation to start at current time;
}

Then in the main loop you just check for the windowResized flag and if true call resizeWindow

Animation time

When you use the requestAnimationFrame(callback) function the callback is called with the first argument as time. Time is in ms 1/1000th of a second and accurate to microseconds 1/1,000,000th second. Eg (time > 1000.010) is time since load over 1.00001 seconds.

function mainLoop(time){ // time in ms since page load.
    requestAnimationFrame(mainLoop);
}
requestAnimationFrame(mainLoop);

Example

Thus with all that let's rewrite the animation using controllers and synced resize events.

To highlight the usefulness of timed animations and controllers I have added two extra waveforms sin and sinTri

Also I have the original animation that will show how it slowly goes out of sync. All for animation should once every cycle be at the left edge of the canvas. Animation speed is set as a movement speed in pixels per second to match the original approximation of 1 pixel per frame the speeds are set to 60 pixels per second. Though for the sin, and sinTri animation that speed is the average speed, not the instantaneous speed.

requestAnimationFrame(animate); //Starts when ready
var ctx = canvas.getContext("2d"); // get canvas 2D API

// original animation by OP
var x = 0;
var y = 20;
var h = 20;
var w = 20;
var dx = 1;
// Code from OP's question
function theRect() {
   ctx.fillStyle = "lightblue";
   ctx.fillRect(x, y, w, h);
   x += dx;
   if (x + w > canvas.width / 2 || x < 0) {
      dx = -dx;
   }
}

// Alternative solution

// wave forms are cyclic to the second and return a normalised range 0-1
const waveforms = {
    triangle (time) { return Math.abs(2 * (time - Math.floor(time + 0.5))) },
    sin(time) { return Math.cos((time + 0.5) * Math.PI * 2)*0.5 + 0.5 },
    // just for fun the following is a composite of triangle and sin
    // Keeping the output range to 0-1 means that multiplying any controller
    // with another will always keep the value in the range 0-1
    triSin(time) { return waveforms.triangle(time) * waveforms.sin(time) } 
    
}

// object to animate is easier to manage and replicate
const rect1 = {
    controller : waveforms.triangle,
    startTime : -1, // start time if -1 means start time is unknown
    speed : 60, // in pixels per second
    travelDist : canvas.width / 2 - 20,
    colour : "#8F8",
    w : 20,
    h : 20,
    y : 60,
    setStart(time){
        this.startTime = time / 1000;  // convert to seconds
        this.travelDist = (canvas.width / 2) - this.w;
    },
    draw(time){
        var pos = ((time / 1000) - this.startTime); // get animation position in seconds
        pos = (pos * this.speed) / (this.travelDist * 2)
        var x = this.controller(pos) * this.travelDist;
        ctx.fillStyle = this.colour;
        ctx.fillRect(x,this.y,this.w,this.h);
    }
}    
// create a second object that uses a sin wave controller

const rect2 = Object.assign({},rect1,{
    colour : "#0C0",
    controller : waveforms.sin,
    y : 100,
})
const rect3 = Object.assign({},rect1,{
    colour : "#F80",
    controller : waveforms.triSin,
    y : 140,
})

// very simple window resize event, just sets flag.
addEventListener("resize",() => { windowResized = true });

var windowResized = true; // will resize canvas on first frame
function resizeCanvas(time){
    canvas.width = innerWidth;  // set canvas size to window inner size
    canvas.height = innerHeight;
    rect1.setStart(time); // restart animation
    rect2.setStart(time); // restart animation
    rect3.setStart(time); // restart animation
    x = 0; // restart the stepped animation

    // fix for stack overflow title bar getting in the way
    y = canvas.height - 4 * 40;
    rect1.y = canvas.height - 3 * 40;
    rect2.y = canvas.height - 2 * 40;
    rect3.y = canvas.height - 1 * 40;

    windowResized = false; // clear the flag
    // resizing the canvas will reset the 2d context so
    // need to setup state
    ctx.font = "16px arial";    
}
function drawText(text,y){
    ctx.fillStyle = "black";
    ctx.fillText(text,10,y);
}
function animate(time){ // high resolution time since  page load in millisecond with presision to microseconds
    if(windowResized){  // has the window been resize
        resizeCanvas(time);  // yes than resize for next frame
    }
    ctx.clearRect(0,0,canvas.width,canvas.height);
    drawText("Original animation.",y-5);
    theRect(); // render original animation
    // three example animation using different waveforms
    drawText("Timed triangle wave.",rect1.y-5);
    rect1.draw(time); // render using timed animation
    drawText("Timed sin wave.",rect2.y-5);
    rect2.draw(time); // render using timed animation
    drawText("Timed sin*triangle wave.",rect3.y-5);
    rect3.draw(time); // render using timed animation
    ctx.lineWidth = 2;
    ctx.strokeRect(1,1,canvas.width - 2,canvas.height - 2);
    requestAnimationFrame(animate);
}
canvas {
  position : absolute;
  top : 0px;
  left : 0px;
}
<canvas id="canvas"></canvas>

Upvotes: 1

ɢʀᴜɴᴛ
ɢʀᴜɴᴛ

Reputation: 32879

There is no need to use cancelAnimationFrame() function at all in this case scenario.

You could just simply change / reset the value (position) of x and y variable accordingly on window resize and that will reset the rectangle­'s starting position / point.

Since, the animate function is running in a recursive manner hence, changes to any variable­'s (used inside animate function) value will take effect immediately.

/*from basic setup*/
canvas = document.getElementById("myCanvas");
canvas.height = window.innerHeight;
canvas.width = window.innerWidth;
ctx = canvas.getContext("2d");

/*drawing the rectangle*/
var x = 0,
   y = canvas.height - 100,
   w = 100,
   h = 100,
   dx = 1;

/*the "resize" event and the reset function should be triggered */
window.addEventListener("resize", function() {
   canvas.height = window.innerHeight;
   canvas.width = window.innerWidth;
   x = 0; //reset starting point (x-axis)
   y = canvas.height - 100; //reset starting point (y-axis)
});

function theRect() {
   ctx.fillStyle = "lightblue";
   ctx.fillRect(x, y, w, h);
   x += dx;
   if (x + w > canvas.width / 2 || x < 0) {
      dx = -dx;
   }
}

/*start animation*/
function animate() {
   ctx.clearRect(0, 0, canvas.width, canvas.height);
   theRect();
   requestAnimationFrame(animate);
}
animate();
body{margin:0;overflow:hidden}
<canvas id="myCanvas"></canvas>

also, see a demo on JSBin

Upvotes: 2

traktor
traktor

Reputation: 19301

The code posted does seem to get confused and cause the rectangle to hang and quiver in place for reasons I never fully understood. Addressing two issues largely resolved the problem.

  1. Queuing lengthy calculations from a resize handler can reduce system overhead. A simple timer (say 100ms) can be used to delay starting the reaction required.

  2. All parameters for the animation need to be recalculated after window resize.

Note that using window.innerHeight and window.innerWidth for the canvas size causes display of scroll bars. One cause is that the effect of body element padding and margins puts the animation outside the view port. I didn't take this further and subtracted 20px from the values for demonstration purposes only.

This working example is shown as HTML source because it uses the whole viewport:

<!DOCTYPE html><html><head><meta charset="utf-8">
    <title>animation question</title>
</head>
<body>
    <canvas id="myCanvas"></canvas>
<script>

/*from basic setup*/
var canvas = document.getElementById("myCanvas");
var ctx = canvas.getContext("2d");
var delayTimer = 0;
var animateTimer = 0;

function sizeCanvas() { // demonstration
    canvas.height = window.innerHeight -20;
    canvas.width = window.innerWidth -20;
}
sizeCanvas();  // initialize


/* monitor "resize" event */

window.addEventListener("resize",function(){
     clearTimeout( delayTimer);
     if( animateTimer) {
         cancelAnimationFrame( animateTimer);
         animateTimer = 0;
     }
     delayTimer = setTimeout( resizeCanvas, 100);
});

function resizeCanvas() {
    delayTimer = 0;
    sizeCanvas();
    x = 0; y = canvas.height - 100;
    animate(); // restart
}

/*drawing the rectangle*/
var x = 0,y = canvas.height - 100,w = 100, h = 100,dx=2;

function theRect(){
 ctx.fillStyle = "lightblue";
 ctx.fillRect(x,y,w,h)
 x+=dx;
 if(x+w>canvas.width/2 || x<0){
    dx=-dx;
 }
}

/*start animation*/

function animate(){
    ctx.clearRect(0,0,canvas.width,canvas.height);
    theRect();
    animateTimer = requestAnimationFrame(animate);
}
animate();

</script>
</body>
</html>

So in answer to your questions:

  1. using cancelAnimationFrame is a good way of cleanly stopping the current animation, as long as it is started again.

  2. It is not absolutely required as part of the solution if you are willing to leave the animation running. A solution taking this approach may still need to halt animation by testing if the resize timer is running inside animate and didn't seem to be a any clearer so I haven't posted the code. (I encountered no noticeable overhead from recording the requestId returned from requestAnimationFrame.)

Upvotes: 0

Related Questions