requestAnimationFrame JavaScript:恒定帧率/流畅图形

7
根据几位开发者的说法(链接1链接2),使用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.667毫秒)。

下面这行话对我来说没有意义:

then = now - (delta % interval);

如果我尝试它,我根本无法得到流畅的图形,但速度快慢取决于 CPU:https://jsfiddle.net/6u82gpdn/

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

哪种方式是“正确”的?或者说我错过了什么权衡?

2个回答

29

Delta时间对于动画效果不利。

似乎任何人都会发表一篇关于如何正确做这个和那个的博客,而且完全是错误的。

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

当您使用 delta 时间通过 requestAnimationFrame 来修正动画位置时,您已经呈现了这一帧,太迟了,无法进行更正。

  • requestAnimationFrame 的回调函数会传递一个参数,其中包含以微秒(1/1,000,000 秒)精确到毫秒(1/1000 秒)的高精度时间。您应该使用该时间,而不是 Date 对象的时间。

  • 回调函数在上一帧呈现到显示器后尽快调用,调用回调之间的时间间隔没有一致性。

  • 使用 delta 时间的方法需要预测下一帧何时呈现,以便对象可以在即将到来的帧的正确位置呈现。如果您的帧渲染负载高且变化不定,则无法在帧开始时预测下一帧何时呈现。

  • 呈现的帧始终在垂直显示刷新期间呈现,并且始终在 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);

帖子框架修正错误。

除非必须,否则不要使用基于时间增量的动画。让帧率下降或会引入动画噪点,试图减少它也无济于事。

我称之为“帖后框架纠正错误”(PFCE)。你正试图在未来和不确定的时间内根据过去的帧时间修正位置,但过去的帧时间可能是错误的。

你正在渲染的每一帧将在一段时间后出现(希望在接下来的1/60秒内)。如果你基于先前渲染的帧时间计算位置,并且跳过了一帧,而此帧按时出现,你将提前渲染下一帧一个帧,同样适用于上一帧,由于跳帧,上一帧将被渲染在落后一个帧的位置。因此,仅跳过一帧就会始终渲染错误,而不是只有一帧。

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

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);

这并不能完全消除PFCE,但如果使用时间差(timeNow - lastTime)作为参数,比不规则间隔时间更好。

帧始终以恒定速率显示,requestAnimationFrame将在无法跟上时丢弃帧,但永远不会在渲染中途停止。帧速率将以1/60、1/30、1/20或1/15等固定时间间隔呈现。如果使用与这些速率不匹配的时间差,则会错误地定位您的动画。

动画请求帧快照

enter image description here

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

然而,回调之间的时间却时好时坏。

帧渲染时间

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

它显示的是与理想时间之间的各种时间误差(虽然对于小像素来说很难看到)。

  • 红色是从回调参数计算的帧时间误差。它将在1/60秒的理想帧时间附近稳定在0ms。
  • 黄色是使用performance.now()计算的帧时间误差。它总共变化约为2毫秒,偶尔会超出此范围。
  • 青色是使用Date.now()计算的帧时间误差。您可以清晰地看到由于日期毫秒精度不足的混叠效应。
  • 绿色点是回调时间参数和performance.now()报告的时间之间的时间差异,在我的系统上大约相差1-2ms。
  • 洋红色是使用性能现在计算的最后一帧的渲染时间。如果按住鼠标按钮,您就可以添加负载并看到此值上升。
  • 绿色垂直线表示已跳过/丢弃了一帧。
  • 深蓝色和黑色背景标记秒数。

此演示的主要目的是展示随着渲染负载的增加,帧将被丢弃的情况。按住鼠标按钮,渲染负载将开始增加。

当帧时间接近16毫秒时,您将开始看到丢失的帧。在渲染负载达到约32ms之前,您将获得介于1/60和1/30之间的帧,首先更多地在1/60th处,每个1/30th一次。

如果使用时间差和后续帧校正,则这将非常有问题,因为您将不断地过度和不足地纠正动画位置。

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);
canvas { border : 2px solid black; }
body { font-family : arial; font-size : 12px;}
<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>

对我来说,在动画中从不使用delta时间,而且我接受会有一些帧丢失的情况。但总的来说,相对于尝试在渲染后调整时间,使用固定间隔可以获得更平滑的动画。

获得流畅动画的最佳方法是将渲染时间减少到16ms以下,如果无法达到这个值,则使用delta时间来选择性地丢弃帧并保持每秒30帧的速率,而不是设置动画帧。


很好的回答,但是对于刷新率不同于60 Hz的屏幕应该怎么办呢?即使我的当前桌面显示器也不是完美的60 Hz,而是59.997 Hz,那怎么办呢?有什么想法吗? - HankMoody
@HankMoody 硬件下降至59.997Hz与支持NTSC编码的视频同步硬件相关。我不确定浏览器如何处理缺失的帧(每5分钟一次),但为了防止剪切,它们将在VSync期间进行刷新,这是硬件中断,因此与显示器完美同步。使用浏览器API,没有可靠的方法来确定帧是否始终晚约0.05毫秒,计算丢帧将无法工作,因为浏览器会因许多原因而丢帧,大约每20,000帧掉1帧,几乎不可能。 - Blindman67
谢谢您的评论,但是对于完全不同刷新率(例如144 Hz)的屏幕怎么处理呢?在您的回答中,您使用了硬编码的 frameRate = 1000/60,因此我认为它只能在60 Hz下平稳运行。 - HankMoody
@HankMoody 如果刷新率远离60Hz,浏览器将忽略硬件并每60秒呈现一次内容。浏览器使用设备刷新的主要原因是与VSync同步,这可以防止撕裂(大多数人不会注意到)和闪烁(可以使用后备缓冲区来防止)。1000/60只是一个参考值,浏览器上只有一个速度60Hz..根据机器的不同,浏览器将轻松巡航或努力跟上。如果您想要超级流畅的uHD HDI 240Hz,则最好不要使用浏览器和JS。 - Blindman67
抱歉,我应该明确说明。这句话“回调次数通常是每秒60次,但在大多数Web浏览器中,它将与显示刷新率匹配,根据W3C的建议。” 暗示了高于60Hz的帧速率。我不知道哪个是正确的,但是所暗示的与您之前评论的最高为60Hz有所不同。 - Axel F
显示剩余3条评论

1
一个delta时间的目的是通过补偿计算所需的时间来保持帧率稳定。
想象一下这段代码:
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毫秒,然后等待帧速率才能绘制下一帧。这不可避免地会导致帧速率低于预期。
在示例二中,函数将立即启动下一次迭代的计时器,然后计算30毫秒。这将导致在上一个计算完成之前绘制下一帧,限制了你的应用程序。
使用增量时间,您可以同时获得两全其美的效果:
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,我们知道在下一帧需要绘制之前还剩多少时间。
与示例一不同的是,我们没有滑动的性能问题;与示例二不同的是,我们没有一堆帧尝试同时绘制的问题。

就是这样。我想补充说明的是,我们游戏的update()函数仍然需要知道调用之间的实际时间差,而上述应用的这个delta肯定是错误的。我通过跟踪两个不同的delta来解决了这个问题:一个用于渲染,一个用于更新。 - Marc
这并不考虑显示速率或后备缓冲区呈现时间。它也与requestAnimationFrame无关。我之所以要给出-1是因为示例3在延迟时调用自身会阻塞浏览器,并导致最后呈现的帧永远不会呈现到显示硬件上。 - Blindman67
@Blindman67 我并没有调用 exampleThree,因为它只是伪代码。我已经添加了 requestAnimationFrame 的调用。至于显示速率和后备缓冲区,问题更多的是关于链接教程中 delta-time 的含义,所以我重点关注这个问题。 - Emil S. Jørgensen

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接