requestAnimationFrame JavaScript:恒定帧速率/平滑图形

时间:2017-09-19 12:50:59

标签: javascript game-physics

根据多个开发人员(link1link2),使用requestAnimationFrame获得恒定帧速率的正确方法是调整最后一次渲染"游戏循环中的时间如下:

function gameLoop() {

    requestAnimationFrame(gameLoop);

    now = Date.now();
    delta = now - then;

    if (delta > interval) {
        then = now - (delta % interval); // This weird stuff
        doGameUpdate(delta);
        doGameRender();
    }
}

interval为1000 / fps(即16.667ms)。

以下行对我没有意义:

then = now - (delta % interval);

事实上,如果我尝试它,我根本不会获得平滑的图形,但速度会慢,取决于CPU: https://jsfiddle.net/6u82gpdn/

如果我让then = now(这是有道理的)一切顺利进行: https://jsfiddle.net/4v302mt3/

哪种方式是"正确"?或者我缺少什么权衡?

2 个答案:

答案 0 :(得分:8)

Delta时间是糟糕的动画。

似乎任何人都会在博客上发布有关正确的方法来做这件事,而且完全错了。

两篇文章都存在缺陷,因为他们不了解如何调用requestAnimationFrame以及如何在帧速率和时间方面使用它。

当您使用增量时间通过requestAnimationFrame来修正动画位置时,您已经显示了该帧,但要纠正它已经太晚了。

  • requestAnimationFrame的回调函数传递一个参数,该参数保存精确到微秒(1 / 1,000,000th)秒的高精度时间,以ms(1/1000th)为单位。您应该使用该时间而不是Date对象时间。

  • 在最后一帧呈现给显示器后,尽快调用回调,调用回调之间的间隔不一致。

  • 使用增量时间的方法需要预测何时显示下一帧,以便可以在即将到来的帧的正确位置渲染对象。如果您的帧渲染负载很高且可变,则无法在帧的开头预测何时会显示下一帧。

  • 渲染帧始终在垂直显示刷新期间显示,并始终为1/60秒。帧之间的时间总是整数倍的1/60,只给出帧率为1 / 60,1 / 30,1 / 20,1 / 15等等

  • 当您退出回调函数时,渲染的内容将保留在后备缓冲区中,直到下一个垂直显示刷新。只有这样它才会移动到显示RAM。

  • 帧速率(垂直刷新)与设备硬件相关联,非常完美。

  • 如果您迟到退出回调,以便浏览器没有时间将画布内容移动到显示,则后台缓冲区将保持到下一次垂直刷新。在缓冲区出现之后才会调用下一帧。

  • 慢速渲染不会降低帧速率,会导致帧速率在每秒60/30帧之间振荡。请参阅使用鼠标按钮添加渲染加载的示例代码段并查看丢弃的帧。

使用提供给回调的时间。

您应该只使用一个时间值,这是浏览器传递给requestAnimationFrame回调函数的时间

例如

function mainLoop(time){  // time in ms accurate to 1 micro second 1/1,000,000th second
   requestAnimationFrame(mainLoop);
}
requestAnimationFrame(mainLoop);

帧后校正错误。

除非你必须,否则不要使用基于delta时间的动画。只是让帧丢失或者你会引入动画噪声以试图减少它。

我称之为后帧校正错误(PFCE)。您正试图根据过去的帧时间,在即将到来且不确定的时间内及时纠正一个位置,这可能是错误的。

您正在渲染的每个帧都会在一段时间后出现(希望在下一个1/60秒内)。如果您将位置基于前一个渲染的帧时间并且您丢弃了一个帧并且该帧准时,您将提前一帧渲染下一帧,并且这同样适用于将渲染为帧的前一帧后面作为一个框架被跳过。因此,只有一帧丢失,你会渲染2帧。总共3个坏帧而不是1.

如果您想要更好的增量时间,请通过以下方法计算帧数。

var frameRate = 1000/60;
var lastFrame = 0;
var startTime;
function mainLoop(time){  // time in ms accurate to 1 micro second 1/1,000,000th second
   var deltaTime = 0
   if(startTime === undefined){
       startTime = time;
   }else{
       const currentFrame = Math.round((time - startTime) / frameRate);
       deltaTime = (currentFrame - lastFrame) * frameRate;
   }
    lastFrame = currentFrame;
   requestAnimationFrame(mainLoop);
}
requestAnimationFrame(mainLoop);

如果您将delta时间用作timeNow - lastTime,则不会消除PFCE,但优于不规则的间隔时间。

帧始终以恒定速率呈现,如果帧无法跟上,requestAnimationFrame将丢帧,但它永远不会出现在帧中间。帧速率将是固定间隔的1 / 60,1 / 30,1 / 20或1/15,依此类推。使用与这些费率不匹配的增量时间将错误地定位动画。

动画请求帧的快照

enter image description here

这是一个简单动画函数的requestAnimationframe的时间轴。我已经注释了结果,以显示何时调用回调。在此期间,帧速率恒定为60fps,没有丢帧。

然而,回调之间的时间到处都是。

帧渲染时间

该示例显示帧定时。在SO沙箱中运行并不是理想的解决方案,要获得良好的结果,您应该在专用页面中运行它。

它显示的内容(虽然很难看到小像素)是理想时间的各种时间误差。

  • 红色是回调参数的帧时间错误。它将在距离1/60理想帧时间0ms附近稳定。
  • 黄色是使用performance.now()计算的帧时间误差。它总共变化约2毫秒,偶尔偷看范围之外。
  • Cyan是使用Date.now()计算的帧时间误差。由于日期的ms精度
  • 分辨率较差,您可以清楚地看到混叠
  • 绿点是回调时间参数与performance.now()报告的时间之间的时间差,在我的系统上约为1-2毫秒。
  • 洋红色是现在使用表现计算的最后一帧渲染时间。如果按住鼠标按钮,则可以添加负载并查看此值爬升。
  • 绿色垂直线表示已删除/跳过某个框架
  • 深蓝色和黑色背景标志着秒。

此演示的主要目的是显示在渲染负载增加时如何删除帧。按住鼠标按钮,渲染负载将开始增加。

当帧时间接近16 ms时,您将开始看到帧丢失。在渲染负载达到大约32ms之前,您将获得1/60到1/30之间的帧,首先是1/30到1/30时的每一个帧。

如果您使用增量时间和帧后校正,这是非常有问题的,因为您将不断地修正动画位置。



const ctx = canvas.getContext("2d");
canvas.width = 512;
canvas.height = 380;

const mouse  = {x : 0, y : 0, button : false}
function mouseEvents(e){
	mouse.x = e.pageX;
	mouse.y = e.pageY;
	mouse.button = e.type === "mousedown" ? true : e.type === "mouseup" ? false : mouse.button;
}
["down","up","move"].forEach(name => document.addEventListener("mouse"+name,mouseEvents));

var lastTime;   // callback time
var lastPTime;  // performance time
var lastDTime;  // date time
var lastFrameRenderTime = 0; // Last frames render time
var renderLoadMs = 0;  // When mouse button down this slowly adds a load to the render
var pTimeErrorTotal = 0;
var totalFrameTime = 0;
var totalFrameCount = 0;
var startTime;
var clearToY = 0;
const frameRate = 1000/60;
ctx.font = "14px arial";
var w = canvas.width;
var h = canvas.height;
var cw = w / 2;  // center 
var ch = h / 2;
var globalTime;  // global to this 
ctx.clearRect(0,0,w,h);

const graph = (()=>{
    var posx = 0;
    const legendW = 30;
    const posy = canvas.height - 266;
    const w = canvas.width - legendW;
    const range = 6;
    const gridAt = 1;
    const subGridAt = 0.2;
    const graph = ctx.getImageData(0,0,1,256);
    const graph32 = new Uint32Array(graph.data.buffer);
    const graphClearA = new Uint32Array(ctx.getImageData(0,0,1,256).data.buffer);
    const graphClearB = new Uint32Array(ctx.getImageData(0,0,1,256).data.buffer);
    const graphClearGrid = new Uint32Array(ctx.getImageData(0,0,1,256).data.buffer);
    const graphFrameDropped = ctx.getImageData(0,0,1,256);
    const graphFrameDropped32 = new Uint32Array(graphFrameDropped.data.buffer);
    graphClearA.fill(0xFF000000);
    graphClearB.fill(0xFF440000);
    graphClearGrid.fill(0xFF888888);
    graphFrameDropped32.fill(0xFF008800);
    const gridYCol = 0xFF444444;  // ms marks
    const gridYColMaj = 0xFF888888;  // 4 ms marks
    const centerCol = 0xFF00AAAA;
    ctx.save();
    ctx.fillStyle = "black";
    ctx.textAlign = "right";
    ctx.textBaseline = "middle";
    ctx.font = "10px arial";
    for(var i = -range; i < range; i += subGridAt){
        var p = (i / range) * 128 + 128 | 0;
        i = Number(i.toFixed(1));
        graphFrameDropped32[p] = graphClearB[p] = graphClearA[p] = graphClearGrid[p] = i === 0 ? centerCol : (i % gridAt === 0) ? gridYColMaj : gridYCol;
        if(i % gridAt === 0){
            ctx.fillText(i + "ms",legendW - 2, p + posy);
            ctx.fillText(i + "ms",legendW - 2, p + posy);
        }
    }
    ctx.restore();
    var lastFrame;
    return {
        step(frame){
            if(lastFrame === undefined){
                lastFrame = frame;
            }else{
                
                while(frame - lastFrame > 1){
                    if(frame - lastFrame > w){ lastFrame = frame - w - 1 } 
                    lastFrame ++;
                    ctx.putImageData(graphFrameDropped,legendW + (posx++) % w, posy);
                }
                lastFrame = frame;
                ctx.putImageData(graph,legendW + (posx++) % w, posy);
                ctx.fillStyle = "red";
                ctx.fillRect(legendW + posx % w,posy,1,256);
                if((frame / 60 | 0) % 2){
                    graph32.set(graphClearA)
                }else{
                    graph32.set(graphClearB)
                    
                }
            }
        },
        mark(ms,col){
            const p = (ms / range) * 128 + 128 | 0;
            graph32[p] = col;
            graph32[p+1] = col;
            graph32[p-1] = col;
        }
    }
        
})();


function loop(time){
    var pTime = performance.now();
    var dTime = Date.now();
    var frameTime = 0;
    var framePTime = 0;
    var frameDTime = 0;
    if(lastTime !== undefined){
        frameTime = time - lastTime;
        framePTime = pTime - lastPTime;
        frameDTime = dTime - lastDTime;
        graph.mark(frameRate - framePTime,0xFF00FFFF);
        graph.mark(frameRate - frameDTime,0xFFFFFF00);
        graph.mark(frameRate - frameTime,0xFF0000FF);
        graph.mark(time-pTime,0xFF00FF00);
        graph.mark(lastFrameRenderTime,0xFFFF00FF);
        
        pTimeErrorTotal += Math.abs(frameTime - framePTime);
        totalFrameTime += frameTime;
        totalFrameCount ++;
    }else{
        startTime = time;
    }
    
    lastPTime = pTime;
    lastDTime = dTime;
    lastTime = globalTime = time;
    var atFrame = Math.round((time -startTime) /  frameRate);
    ctx.setTransform(1,0,0,1,0,0); // reset transform
    ctx.clearRect(0,0,w,clearToY);
    ctx.fillStyle = "black";
    var y = 0;
    var step = 16;
    ctx.fillText("Frame time : " + frameTime.toFixed(3)+"ms",10,y += step);
    ctx.fillText("Rendered frames : " + totalFrameCount,10,y += step);
    ctx.fillText("Mean frame time : " + (totalFrameTime / totalFrameCount).toFixed(3)+"ms",10,y += step);
    ctx.fillText("Frames dropped : " + Math.round(((time -startTime)- (totalFrameCount * frameRate)) / frameRate),10,y += step);
    ctx.fillText("RenderLoad : " + lastFrameRenderTime.toFixed(3)+"ms Hold mouse into increase",10,y += step);
    clearToY = y;
    graph.step(atFrame);

    requestAnimationFrame(loop);

    if(mouse.button ){
        renderLoadMs += 0.1;
        var pt = performance.now();
        while(performance.now() - pt < renderLoadMs);
    }else{
        renderLoadMs = 0;
    }
    
    lastFrameRenderTime = performance.now() - pTime;
}
requestAnimationFrame(loop);
&#13;
canvas { border : 2px solid black; }
body { font-family : arial; font-size : 12px;}
&#13;
<canvas id="canvas"></canvas>
<ul>
<li><span style="color:red">Red</span> is frame time error from the callback argument.</li>
<li><span style="color:yellow">Yellow</span> is the frame time error calculated using performance.now().</li>
<li><span style="color:cyan">Cyan</span> is the frame time error calculated using Date.now().</li>
<li><span style="color:#0F0">Green</span> dots are the difference in time between the callback time argument and the time reported by performance.now()</li>
<li><span style="color:magenta">Magenta</span> is the last frame's render time calculated using performance.now().</li>
<li><span style="color:green">Green</span> vertical lines indicate that a frame has been dropped / skipped</li>
<li>The dark blue and black background marks seconds.</li>
</ul>
&#13;
&#13;
&#13;

对我来说,我从不使用增量时间来制作动画,我接受一些帧会丢失。但总体而言,使用固定间隔获得的平滑动画比尝试更正渲染后的时间要快。

获得平滑动画的最佳方法是将渲染时间缩短到16毫秒以下,如果你不能得到它,那么使用deltat时间不设置动画帧,而是选择性地丢帧并保持30帧的速率每秒。

答案 1 :(得分:0)

增量时间点是通过补偿计算所花费的时间来保持帧速率稳定。

想想这段代码:

var framerate = 1000 / 60;
var exampleOne = function () {
    /* computation that takes 10 ms */
    setTimeout(exampleOne, framerate);
}
var exampleTwo = function () {
    setTimeout(exampleTwo, framerate);
    /* computation that takes 30 ms */
}

在示例一中,函数将计算10 ms,然后在绘制下一帧之前等待帧速率。这将不可避免地导致帧率低于预期。

在示例2中,该函数将立即启动计时器以进行下一次迭代,然后计算30 ms。这将导致下一帧在上一次完成计算之前被绘制,瓶颈缩小你的应用程序。

使用delta-time,您将获得两全其美:

var framerate = 1000 / 60;
var exampleThree = function () {
    var delta = Date.now();
    /* computation that takes 10 to 30 ms */
    var deltaTime = Date.now() - delta;
    if (deltaTime >= framerate) {
        requestAnimationFrame(exampleThree);
    }
    else {
        setTimeout(function () { requestAnimationFrame(exampleThree); }, framerate - deltaTime);
    }
};

使用代表计算时间的delta-time,我们知道在下一帧需要绘画之前我们还剩多少时间。

我们没有来自示例一的滑动性能,并且我们没有尝试与示例二同时绘制的一堆帧。