javascriptanimationcanvashtml5-canvas

Why is requestAnimationFrame better than setInterval or setTimeout


Why should I use requestAnimationFrame rather than setTimeout or setInterval?

This self-answered question is a documentation example.


Solution

  • Update

    This post is very old and there have been many changes since I first posted the question.

    requestAnimationFrame is still best

    requestAnimationFrame is still the best option rather than setTimeout and setInterval.

    The main reason is that requestAnimationFrame is synced to the display refresh rate.

    Specifically the vertical blank, vertical sync, or vSync (analogous to old CRT displays). This is a time in the display process that pixels on the display are not being updated and changes can be made to VRAM while not affecting the displayed pixels.

    requestAnimationFrame will call your render function well before the next vSync and after the previous vSync. When your render function returns requestAnimationFrame will wait till after the next vSync before calling your render function again (Be sure to return in time).

    The following image shows a frame (example rate 60 frames per second). The requestAnimationFrame callback will be called before the next vSync. It will be called as early as possible. You should ensure you return from it before the next vSync

    Vsync and rAF callback time

    setTimeout and setInterval are not aware of the frame rate or when the vSynce starts and ends.

    With well written listener you can match the animation quality you get from CSS animations across devices.

    The DOM uses the vSync time to update the GPU RAM with all the changes made during the previous frame (including any changes made by any timer and requestAnimationFrame listeners)

    High resolution time

    requestAnimationFrame also passes a high resolution time to the callback. This time is the same time you get from the performance API. eg performance.now()

    You can use the time argument to get the frame rate and determine a delta time for your animations

    Example getting delta time using requestAnimationFrame

    requestAnimationFrame(mainRenderLoop);
    var prevFrameTime, deltaTime;
    function mainRenderLoop(time) {
        if (prevFrameTime === undefined) {
            deltaTime = 0;
        } else {
            deltaTime = time - prevFrameTime;
        }   
        prevFrameTime = time;
    
        //
        // rendering code here
        //
    
        requestAnimationFrame(mainRenderLoop);
    }
    

    Problems with requestAnimationFrame

    Unknown frame rate

    Because the callback is synced to the display refresh rate the frame rate will vary from device to device. You should use the time argument to determine the rate and adjust your animation as needed.

    Forced Pause

    Like other timers requestAnimationFrame will stop calling the callback when the page is hidden. Eg client switches Tabs, another window hides the browser window.

    Unsynced

    It is possible for the client to setup the GPU drivers and browser to ignore the display refresh rate. You can not detect this directly.

    Render time

    requestAnimationFrame assumes the time the callback returns is the frame that is being animated. It will not call next frame until after the next vSync

    Taking too long to render a frame will cause requestAnimationFrame to skip frames resulting in Jank.


    Original answer from 2016

    High quality animation.

    requestAnimationFrame produces higher quality animation completely eliminating flicker and shear that can happen when using setTimeout or setInterval, and reduce or completely remove frame skips.

    Shear

    is when a new canvas buffer is presented to the display buffer midway through the display scan resulting in a shear line caused by the mismatched animation positions.

    Flicker

    is caused when the canvas buffer is presented to the display buffer before the canvas has been fully rendered.

    Frame skip

    is caused when the time between rendering frames is not in precise sync with the display hardware. Every so many frames a frame will be skipped producing inconsistent animation. (There are method to reduce this but personally I think these methods produce worse overall results) As most devices use 60 frames per second (or multiple of) resulting in a new frame every 16.666...ms and the timers setTimeout and setInterval use integers values they can never perfectly match the framerate (rounding up to 17ms if you have interval = 1000/60)


    A demo is worth a thousand words.

    Update The answer to the question requestAnimationFrame loop not correct fps shows how setTimeout's frame time is inconsistent and compares it to requestAnimationFrame.

    The demo shows a simple animation (stripes moving across the screen) clicking the mouse button will switch between the rendering update methods used.

    There are several update methods used. It will depend on the hardware setup you are running as to what the exact appearance of the animation artifacts will be. You will be looking for little twitches in the movement of the stripes

    Note. You may have display sync turned off, or hardware acceleration off which will affect the quality of all the timing methods. Low end devices may also have trouble with the animation

    /** SimpleFullCanvasMouse.js begin **/
    
    var backBuff;
    var bctx;
    const STRIPE_WIDTH = 250;
    var textWidth;
    const helpText = "Click mouse to change render update method.";
    var onResize = function(){
        if(backBuff === undefined){
            backBuff = document.createElement("canvas")    ;
            bctx = backBuff.getContext("2d");
            
        }
        
        backBuff.width = canvas.width;
        backBuff.height = canvas.height;
        bctx.fillStyle = "White"
        bctx.fillRect(0,0,w,h);
        bctx.fillStyle = "Black";
        for(var i = 0;  i < w; i += STRIPE_WIDTH){
            bctx.fillRect(i,0,STRIPE_WIDTH/2,h)   ;
            
        }
        ctx.font = "20px arial";
        ctx.textAlign = "center";
        ctx.font = "20px arial";
        textWidth = ctx.measureText(helpText).width;
        
    };
    var tick = 0;
    var displayMethod = 0;
    var methods = "Timer,RAF Best Quality,Dual Timers,RAF with timed animation,Timer with timed animation".split(",");
    var dualTimersActive = false;
    var hdl1, hdl2
    
    function display(timeAdvance){  // put code in here
    
        tick += timeAdvance;
        tick %= w;
    
    
        ctx.drawImage(backBuff,tick-w,0);
        ctx.drawImage(backBuff,tick,0);
        if(textWidth !== undefined){
            ctx.fillStyle = "rgba(255,255,255,0.7)";
            ctx.fillRect(w /2 - textWidth/2, 0,textWidth,40);
            ctx.fillStyle = "black";
            ctx.fillText(helpText,w/2, 14);
            ctx.fillText("Display method : " + methods[displayMethod],w/2, 34);
        }
        if(mouse.buttonRaw&1){
            displayMethod += 1;
            displayMethod %= methods.length;
            mouse.buttonRaw = 0;
            lastTime = null;
            tick = 0;
            if(dualTimersActive) {
                 dualTimersActive = false;
                 clearInterval(hdl1);
                 clearInterval(hdl2);
                 updateMethods[displayMethod]()             
            }
        }
    }
    
    
    
    
    
    
    
    
    //==================================================================================================
    // The following code is support code that provides me with a standard interface to various forums.
    // It provides a mouse interface, a full screen canvas, and some global often used variable 
    // like canvas, ctx, mouse, w, h (width and height), globalTime
    // This code is not intended to be part of the answer unless specified and has been formated to reduce
    // display size. It should not be used as an example of how to write a canvas interface.
    // By Blindman67
    const U = undefined;const RESIZE_DEBOUNCE_TIME = 100;
    var w,h,cw,ch,canvas,ctx,mouse,createCanvas,resizeCanvas,setGlobals,globalTime=0,resizeCount = 0; 
    var L = typeof log === "function" ? log : function(d){ console.log(d); }
    createCanvas = function () { var c,cs; cs = (c = document.createElement("canvas")).style; cs.position = "absolute"; cs.top = cs.left = "0px"; cs.zIndex = 1000; document.body.appendChild(c); return c;}
    resizeCanvas = function () {
        if (canvas === U) { canvas = createCanvas(); } canvas.width = window.innerWidth; canvas.height = window.innerHeight; ctx = canvas.getContext("2d"); 
        if (typeof setGlobals === "function") { setGlobals(); } if (typeof onResize === "function"){ resizeCount += 1; setTimeout(debounceResize,RESIZE_DEBOUNCE_TIME);}
    }
    function debounceResize(){ resizeCount -= 1; if(resizeCount <= 0){ onResize();}}
    setGlobals = function(){ cw = (w = canvas.width) / 2; ch = (h = canvas.height) / 2; mouse.updateBounds(); }
    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.x = e.clientX - m.bounds.left; m.y = e.clientY - m.bounds.top;
            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.updateBounds = function(){
            if(m.active){
                m.bounds = m.element.getBoundingClientRect();
            }
            
        }
        m.addCallback = function (callback) {
            if (typeof callback === "function") {
                if (m.callbacks === U) { m.callbacks = [callback]; }
                else { m.callbacks.push(callback); }
            } else { throw new TypeError("mouse.addCallback argument must be a function"); }
        }
        m.start = function (element, blockContextMenu) {
            if (m.element !== U) { m.removeMouse(); }        
            m.element = element === U ? document : element;
            m.blockContextMenu = blockContextMenu === U ? false : blockContextMenu;
            m.mouseEvents.forEach( n => { m.element.addEventListener(n, mouseMove); } );
            if (m.blockContextMenu === true) { m.element.addEventListener("contextmenu", preventDefault, false); }
            m.active = true;
            m.updateBounds();
        }
        m.remove = function () {
            if (m.element !== U) {
                m.mouseEvents.forEach(n => { m.element.removeEventListener(n, mouseMove); } );
                if (m.contextMenuBlocked === true) { m.element.removeEventListener("contextmenu", preventDefault);}
                m.element = m.callbacks = m.contextMenuBlocked = U;
                m.active = false;
            }
        }
        return mouse;
    })();
    
    
    resizeCanvas(); 
    mouse.start(canvas,true); 
    onResize()
    var lastTime = null;
    window.addEventListener("resize",resizeCanvas); 
    function clearCTX(){
        ctx.setTransform(1,0,0,1,0,0); // reset transform
        ctx.globalAlpha = 1;           // reset alpha
        ctx.clearRect(0,0,w,h); // though not needed this is here to be fair across methods and demonstrat flicker
    }
    
    
    
    function dualUpdate(){
        if(!dualTimersActive) {
            dualTimersActive = true;
            hdl1 = setInterval( clearCTX, 1000/60);
            hdl2 = setInterval(() => display(10), 1000/60);
        }
    }
    function timerUpdate(){
        timer = performance.now();
        if(!lastTime){
            lastTime = timer;
        }
        var time = (timer-lastTime) / (1000/60);
        lastTime = timer;    
        setTimeout(updateMethods[displayMethod],1000/60);
        clearCTX();
        display(10*time);
    }
    function updateRAF(){ 
        clearCTX();
        requestAnimationFrame(updateMethods[displayMethod]);
        display(10);  
    }
    function updateRAFTimer(timer){ // Main update loop
    
        clearCTX();
        requestAnimationFrame(updateMethods[displayMethod]);
        if(!timer){
            timer = 0;
        }
        if(!lastTime){
            lastTime = timer;
        }
        var time = (timer-lastTime) / (1000/60);
        display(10 * time);  
        lastTime = timer;
    }
    
    displayMethod = 1;
    var updateMethods = [timerUpdate,updateRAF,dualUpdate,updateRAFTimer,timerUpdate]
    updateMethods[displayMethod]();
    
    /** SimpleFullCanvasMouse.js end **/