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

Posted

技术标签:

【中文标题】requestAnimationFrame JavaScript:恒定帧速率/平滑图形【英文标题】:requestAnimationFrame JavaScript: Constant Frame Rate / Smooth Graphics 【发布时间】:2017-09-19 12:50:58 【问题描述】:

根据几位开发人员(link1、link2)的说法,让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/

哪种方式是“正确的”?或者我缺少哪些权衡?

【问题讨论】:

【参考方案1】:

Delta 时间是糟糕的动画。

似乎几乎任何人都会在博客上发布关于如何做这做那的正确方法,而这完全是错误的。

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

当您通过requestAnimationFrame 使用增量时间来纠正动画位置时,您已经呈现了帧,现在纠正它为时已晚。

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

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

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

在垂直显示刷新期间始终呈现渲染帧,并且始终处于 1/60 秒的时间。帧之间的时间将始终是 1/60 的整数倍,仅给出 1/60、1/30、1/20、1/15 等帧速率

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

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

如果你延迟退出回调,使得浏览器没有时间将画布内容移动到显示器,后台缓冲区会一直保持到下一次垂直刷新。在提供缓冲区之前,不会调用您的下一帧。

慢速渲染不会降低帧速率,它们会导致帧速率在每秒 60/30 帧之间波动。查看示例 sn-p 使用鼠标按钮添加渲染负载并查看丢帧。

使用提供给回调的时间。

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

例如

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

requestAnimationFrame(mainLoop);

后帧校正错误。

除非必须,否则不要使用基于时间增量的动画。只需让帧下降,否则您将引入动画噪声以尝试减少它。

我将此称为后帧校正错误 (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);

这不会消除 PFCE,但如果您将增量时间用作 timeNow - lastTime,则比不规则间隔时间要好。

帧总是以恒定速率呈现,requestAnimationFrame 如果跟不上会丢帧,但绝不会呈现中间帧。帧速率将以 1/60、1/30、1/20 或 1/15 等固定间隔显示。使用与这些速率不匹配的增量时间会错误地定位您的动画。

动画请求帧的快照

这是一个简单动画功能的 requestAnimationframe 时间线。我已经注释了结果以显示何时调用回调。在此期间,帧速率始终保持在完美的 60 fps,没有丢帧。

然而,回调之间的时间无处不在。

帧渲染时间

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

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

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

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

当帧时间接近 16 毫秒时,您将开始看到丢帧。在渲染负载达到大约 32 毫秒之前,您将获得介于 1/60 和 1/30 之间的帧,首先是在 1/60 帧,每帧在 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);
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>

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

获得平滑动画的最佳方法是将渲染时间减少到 16 毫秒以下,如果您无法做到这一点,则使用 deltat 时间不设置动画帧,而是选择性地丢帧并保持每秒 30 帧的速率.

【讨论】:

很好的答案,但是屏幕刷新率不是 60 Hz 怎么办?甚至我现在的桌面显示器也不是完美的 60 Hz,而是 59.997 Hz,那怎么样,有什么想法吗? @HankMoody 硬件下移到 59.997Hz 与支持 NTSC 编码的视频同步硬件相关。我不确定浏览器如何处理丢失的帧(每约 5 分钟),但为了防止剪切,它们将在垂直同步期间刷新,这是一个硬件中断,因此与显示完美同步。使用浏览器 API 没有可靠的方法来确定帧是否始终延迟约 0.05 毫秒,计算丢帧将不起作用,因为浏览器丢帧的原因有很多,每约 20,000 帧 1 帧。没有机会。 感谢您的更多评论,但是刷新率完全不同的屏幕(例如 144 Hz)呢?在您的回答中,您使用的是硬编码的frameRate = 1000/60,所以我认为它只能在 60 Hz 下顺利运行。 @HankMoody 如果刷新率超过 60Hz,浏览器将忽略硬件并每 60 秒显示一次内容。浏览器使用设备刷新的主要原因是与 VSync 同步,这会停止剪切(大多数人没有注意到)和闪烁(可以使用后台缓冲区来防止)。 1000/60 只是一个参考,浏览器上只有一个 60Hz 的速度..根据机器的不同,浏览器会轻松或难以跟上。如果你想要超级流畅的 uHD HDI 240Hz 最好不要使用浏览器和 JS 好的,再次感谢您的回答。您是否有任何链接或来源可以更多地说明浏览器在不同的屏幕刷新率下坚持 60 Hz?【参考方案2】:

增量时间的意义在于通过补偿计算所花费的时间来保持帧速率稳定。

想想这段代码:

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 毫秒,然后在绘制下一帧之前等待帧速率。这将不可避免地导致帧速率低于预期。

在示例 2 中,该函数将立即启动下一次迭代的计时器,然后计算 30 毫秒。这将导致在前一帧完成计算之前绘制下一帧,从而限制您的应用程序。

使用 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,我们知道在下一帧需要绘制之前我们还剩下多少时间。

我们没有示例 1 中的滑动性能,也没有像示例 2 中那样尝试同时绘制的一堆帧。

【讨论】:

是的,就是这样。我想补充一点,我们游戏的update() 函数仍然需要知道调用之间的实际 delta time,而这个delta 确实是错误的,如上面应用的那样。我通过跟踪 2 个不同的增量来解决它:一个用于渲染,一个用于更新。 这不考虑显示速率或后备缓冲区呈现时间。它与requestAnimationFrame 也没有任何关系,为什么我必须给出 -1 是因为示例 3 在延迟阻塞浏览器时调用自身并导致最后渲染的帧永远不会呈现给显示硬件。 @Blindman67 我也从未调用过exampleThree,因为它只是伪代码。我添加了requestAnimationFrame 电话。至于显示速率和后缓冲,问题更多是关于链接教程中的增量时间意味着什么,所以这就是我关注的内容。

以上是关于requestAnimationFrame JavaScript:恒定帧速率/平滑图形的主要内容,如果未能解决你的问题,请参考以下文章

requestAnimationFrame.js后续学习

requestAnimationFrame兼容性写法

requestAnimationFrame (待整理)

requestAnimationFrame 方法你真的用对了吗?

详解Web API requestAnimationFrame

requestAnimationFrame