HTML5画布游戏循环时间增量计算

10

我是一名新手游戏开发者。目前我正在为js13kgames比赛制作游戏,因此游戏应该很小,这就是为什么我不使用任何现代流行的框架。

在开发我的无限游戏循环时,我发现了几篇文章和建议来实现它。现在它看起来像这样:

self.gameLoop = function () {
        self.dt = 0;

        var now;
        var lastTime = timestamp();
        var fpsmeter = new FPSMeter({decimals: 0, graph: true, theme: 'dark', left: '5px'});

        function frame () {
            fpsmeter.tickStart();
            now = window.performance.now();

            // first variant - delta is increasing..
            self.dt = self.dt + Math.min(1, (now-lastTime)/1000);

            // second variant - delta is stable.. 
            self.dt = (now - lastTime)/16;
            self.dt = (self.dt > 10) ? 10 : self.dt;

            self.clearRect();

            self.createWeapons();
            self.createTargets();

            self.update('weapons');
            self.render('weapons');

            self.update('targets');
            self.render('targets');

            self.ticks++;

            lastTime = now;
            fpsmeter.tick();
            requestAnimationFrame(frame);
        }

        requestAnimationFrame(frame);
};

所以问题出在self.dt上。我最终发现第一种变量不适合我的游戏,因为它会无限增加,武器速度也会随之增加(例如:this.position.x += (Math.cos(this.angle) * this.speed) * self.dt;)。第二种变量看起来更合适,但它是否符合这种循环的要求(http://codeincomplete.com/posts/2013/12/4/javascript_game_foundations_the_game_loop/)呢?
6个回答

9
以下是一个使用固定时间步长和可变渲染时间的HTML5渲染系统的实现: http://jsbin.com/ditad/10/edit?js,output 该实现基于此文章: http://gameprogrammingpatterns.com/game-loop.html 以下是游戏循环:
    //Set the frame rate
var fps = 60,
    //Get the start time
    start = Date.now(),
    //Set the frame duration in milliseconds
    frameDuration = 1000 / fps,
    //Initialize the lag offset
    lag = 0;

//Start the game loop
gameLoop();

function gameLoop() {
  requestAnimationFrame(gameLoop, canvas);

  //Calcuate the time that has elapsed since the last frame
  var current = Date.now(),
      elapsed = current - start;
  start = current;
  //Add the elapsed time to the lag counter
  lag += elapsed;

  //Update the frame if the lag counter is greater than or
  //equal to the frame duration
  while (lag >= frameDuration){  
    //Update the logic
    update();
    //Reduce the lag counter by the frame duration
    lag -= frameDuration;
  }
  //Calculate the lag offset and use it to render the sprites
  var lagOffset = lag / frameDuration;
  render(lagOffset);
}

render函数会调用每个精灵的render方法,并且传入一个指向lagOffset的引用。

function render(lagOffset) {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  sprites.forEach(function(sprite){
    ctx.save();
    //Call the sprite's `render` method and feed it the
    //canvas context and lagOffset
    sprite.render(ctx, lagOffset);
    ctx.restore();
  });
}

这里是精灵的渲染方法,使用滞后偏移来插值精灵在画布上的渲染位置。
o.render = function(ctx, lagOffset) {
    //Use the `lagOffset` and previous x/y positions to
    //calculate the render positions
    o.renderX = (o.x - o.oldX) * lagOffset + o.oldX;
    o.renderY = (o.y - o.oldY) * lagOffset + o.oldY;

    //Render the sprite
    ctx.strokeStyle = o.strokeStyle;
    ctx.lineWidth = o.lineWidth;
    ctx.fillStyle = o.fillStyle;
    ctx.translate(
      o.renderX + (o.width / 2),
      o.renderY + (o.height / 2)
     );
    ctx.beginPath();
    ctx.rect(-o.width / 2, -o.height / 2, o.width, o.height);
    ctx.stroke();
    ctx.fill();

    //Capture the sprite's current positions to use as 
    //the previous position on the next frame
    o.oldX = o.x;
    o.oldY = o.y;
  };

重要的是这段代码,它使用lagOffset和精灵在帧之间渲染位置的差异来计算其新的当前画布位置:
o.renderX = (o.x - o.oldX) * lagOffset + o.oldX;
o.renderY = (o.y - o.oldY) * lagOffset + o.oldY;

请注意,在该方法的末尾,oldXoldY的值会在每一帧重新计算,以便在下一帧中使用它们来帮助计算差异。
o.oldX = o.x;
o.oldY = o.y;

我其实不确定这个插值是否完全正确,或者这是否是最好的方法。如果有任何人读到这篇文章并知道它是错误的,请告诉我们 :)


7
现代版本的requestAnimationFrame现在会发送一个时间戳,你可以用它来计算经过的时间。当你期望的时间间隔已经过去时,你可以进行更新、创建和渲染任务。
以下是示例代码:
var lastTime;
var requiredElapsed = 1000 / 10; // desired interval is 10fps

requestAnimationFrame(loop);

function loop(now) {
    requestAnimationFrame(loop);
    
    if (!lastTime) { lastTime = now; }
    var elapsed = now - lastTime;

    if (elapsed > requiredElapsed) {
        // do stuff
        lastTime = now;
    }
    
}

7
代码没问题!requestAnimationFrame只是安排一个新的循环,它不会立即触发该循环。其余代码确实得到执行。;-) - markE
1
假设代码继续执行。如果您在上一帧完成之前调用下一帧,则会破坏您的 60fps。应该在当前帧处理完后调用 requestAnimationFrame(),以保持稳定的 60fps。 - Patrick W. McMahon
1
你会注意到,requestAnimationFrame的所有示例都是在循环中执行的最后一件事,这是有原因的。当你调用requestAnimationFrame时,你将函数传递给操作系统处理。你希望在执行结束时传递它,以便系统可以正确计算出正确的增量时间来执行。当在结尾运行时,系统将知道时间间隔,并为您提供约60fps,而如果在开头运行,则不会获得60fps。 - Patrick W. McMahon
2
requestAnimationFrame 只是给浏览器一个运行循环内代码的最佳时间的许可。循环代码在浏览器方便的时候运行,而不是在代码中使用 requestAnimationFrame 的那个点。它在循环中的位置是任意的,markE 将其放置在循环的第一行是完全可以的。事实上,这可能是最好的位置,因为它清楚地指示了循环函数的目的和工作方式。 - d13
1
现在始终大于上次时间,因此经过的时间始终为负数,并且经过的时间大于所需时间从不成立。 - Anthony Johnston
显示剩余13条评论

1

我没有检查你代码中的数学逻辑.. 但是这是对我有效的:

GameBox = function()
{
    this.lastFrameTime = Date.now();
    this.currentFrameTime = Date.now();
    this.timeElapsed = 0;
    this.updateInterval = 2000;       //in ms
}

GameBox.prototype.gameLoop = function()
{
   window.requestAnimationFrame(this.gameLoop.bind(this));
   this.lastFrameTime = this.currentFrameTime;
   this.currentFrameTime = Date.now();
   this.timeElapsed +=  this.currentFrameTime - this.lastFrameTime ;
   if(this.timeElapsed >= this.updateInterval)
   {
      this.timeElapsed = 0;
      this.update(); //modify data which is used to render
   }
   this.render();
}

这个实现与 CPU 速度(时钟周期)无关。希望你能好好利用它!


1
window.requestAnimationFrame(this.gameLoop()); - That won't work. You want to pass a reference to this.gameLoop, not call it immediately and pass its return value to requestAnimationFrame(), plus you need to be sure this is correct once the function is called, so it should be: window.requestAnimationFrame(this.gameLoop.bind(this)); - nnnnnn
谢谢您的修正,可能是我在帖子中打错了字,因为我将其从我正在运行的游戏中精简并复制过来。 - Alex

1
你的游戏引擎的一个好的解决方案是考虑对象和实体。你可以将你世界中的一切都视为对象和实体。然后你需要制作一个游戏对象管理器,它将拥有所有游戏对象的列表。接下来,你需要在引擎中制作一个通用的通信方法,以便游戏对象可以触发事件。例如,在你的游戏中的实体(如玩家)不需要从任何东西继承以获得渲染到屏幕或具有碰撞检测的能力,你只需在实体中制作引擎正在寻找的通用方法。然后让游戏引擎按照自己的方式处理实体。你的游戏中的实体可以在游戏中的任何时候创建或销毁,因此你不应该在游戏循环中硬编码任何实体。
你希望你的游戏引擎中的其他对象对引擎收到的事件触发做出响应。这可以通过实体中的方法来完成,游戏引擎将检查这些方法是否可用,如果可用,则会将事件传递给实体。不要将任何游戏逻辑硬编码到引擎中,这会影响可移植性并限制你以后扩展游戏的能力。
你的代码问题首先在于你没有按正确顺序调用不同对象的渲染和更新。你需要按照 所有更新所有渲染 的顺序来调用它们。另一个问题是你硬编码对象到循环中会给你带来很多问题,当你想要将其中一个对象从游戏中删除或者后期想要添加更多对象时。
你的游戏对象将拥有一个update()和一个render()函数,你的游戏引擎会在每一帧中查找该对象/实体中的这些函数并调用它们。你可以让引擎检查游戏对象/实体是否具有这些函数,以达到更高级的效果。例如,你可能想要一个具有update()但从不渲染任何内容到屏幕上的对象。你可以通过让引擎在调用函数之前进行检查来使游戏对象函数变为可选项。对于所有游戏对象,拥有一个init()函数也是很好的做法。当游戏引擎启动场景并创建对象时,它会首先调用游戏对象的init()函数来创建对象,然后每帧调用update()函数,这样你就可以拥有一个仅在创建时运行一次的函数和另一个每帧运行一次的函数。
由于window.requestAnimationFrame(frame);将提供大约60fps,所以不真正需要delta时间。因此,如果你正在跟踪帧数,就可以知道经过了多长时间。然后,你的游戏中的不同对象可以根据游戏中的某个设定点和帧数来确定它已经做了多长时间的事情。
window.requestAnimationFrame = window.requestAnimationFrame || function(callback){window.setTimeout(callback,16)};
gameEngine = function () {
        this.frameCount=0;
        self=this;

        this.update = function(){
           //loop over your objects and run each objects update function
        }

        this.render = function(){
          //loop over your objects and run each objects render function
        }

        this.frame = function() {
            self.update();
            self.render();
            self.frameCount++;
            window.requestAnimationFrame(frame);
        }
        this.frame();
};

我创建了一个完整的游戏引擎,位于 https://github.com/Patrick-W-McMahon/Jinx-Engine/ ,如果您查看 https://github.com/Patrick-W-McMahon/Jinx-Engine/blob/master/JinxEngine.js 上的代码,您将看到一个完全使用 JavaScript 构建的功能齐全的游戏引擎。它包括事件处理程序,并允许在传递给引擎的对象之间进行操作调用。请查看一些示例 https://github.com/Patrick-W-McMahon/Jinx-Engine/tree/master/examples ,您将看到它如何工作。该引擎可以在每帧渲染和执行大约 100,000 个对象,以 60fps 的速率运行。这是在核心 i5 上测试的。不同硬件可能会有所不同。鼠标和键盘事件已内置于引擎中。传入引擎的对象只需监听引擎传递的事件即可。目前正在构建场景管理和多场景支持,以供更复杂的游戏使用。该引擎还支持高像素密度屏幕。

检查我的源代码可以帮助您构建更完整的游戏引擎。

我还想指出,在准备好重新绘制而不是之前(即在游戏循环结束时)应调用requestAnimationFrame()。一个很好的例子表明为什么不应该在循环开始时调用requestAnimationFrame(),如果您正在使用画布缓冲区。如果您在开头调用requestAnimationFrame(),然后开始绘制到画布缓冲区,您可能会发现它将半个新帧以旧帧为另一半进行绘制。这将在每个帧上发生,具体取决于完成缓冲区与重绘周期(60fps)的时间。但与此同时,您会重叠每个帧,因此缓冲区将在循环中变得越来越糟糕。这就是为什么只有在缓冲区完全准备好绘制到画布上时才应该调用requestAnimationFrame()。通过将requestAnimationFrame()放在结尾,如果缓冲区没有准备好绘制,它可以跳过重绘,因此每次重绘都按预期绘制。在游戏循环中的requestAnimationFrame()位置非常重要。


4
“delta time不是必需的,因为window.requestAnimationFrame(frame)会给你一个约为60fps的帧率。因此,如果你跟踪帧计数,就可以知道经过了多长时间。”这种说法有误导性,你肯定应该保留deltaTime,因为它在某些计算中是必需的。你不能指望它始终保持在60fps。 - cbethax
2
要补充cbethax的评论,MDN特别指出:“回调次数通常为每秒60次,但按照W3C建议,大多数Web浏览器的显示刷新率会匹配此数字”。因此,随着具有更高刷新率的监视器变得越来越普遍,您真的不能依赖requestAnimationFrame来为您提供60fps。 - Stefnotch

1
在某个时候,你可能需要考虑将物理与渲染分离。否则,你的玩家可能会遇到不一致的物理现象。例如,一台性能强劲的电脑可以达到300帧每秒,而另一台只能维持30帧每秒。这会导致前者在像马里奥那样的滚动游戏中以超快速度行驶,而后者以半速爬行(如果你所有的测试都在60帧每秒下进行)。解决这个问题的方法是引入时间步长。其思想是找到每帧之间的时间,并将其作为物理计算的一部分。它使得游戏玩法在任何帧率下都保持一致。这是一个好的文章,可以让你开始了解:http://gafferongames.com/game-physics/fix-your-timestep/ requestAnimationFrame无法解决这种不一致性,但有时仍然是一个好的选择,因为它具有节省电池的优势。这是更多信息的来源:http://www.chandlerprall.com/2012/06/requestanimationframe-is-not-your-logics-friend/

1

这并不是一个关于你问题的真正答案,而且如果我不了解具体的游戏,我不能确定它是否能帮到你,但是你真的需要知道dt(和FPS)吗?

在我的有限的JS游戏开发中,我发现通常你不需要计算任何类型的dt,因为你可以根据预期的帧率得出一个合理的默认值,并使任何基于时间的事物(例如武器重新装填)仅基于tick数工作(即弓可能需要60个tick来重新装填(~1秒@ ~60FPS))。

我通常使用window.setTimeout()而不是window.requestAnimationFrame(),我发现它通常提供更稳定的帧率,这将允许您定义一个合理的默认值来代替dt。缺点是游戏会占用更多资源,在较慢的机器上性能较差(或者用户运行了很多其他东西),但是根据您的用例,这些可能不是真正的问题。

现在这只是一些轶事式的建议,所以你应该带着谨慎的心态看待它,但在过去它为我服务得很好。这一切取决于你是否介意游戏在旧的/不那么强大的机器上运行得更慢,以及你的游戏循环效率如何。如果只是一些简单的东西,不需要实时显示,你也许可以完全放弃 dt


你好!很感谢您提供透视角度,我们采纳了您的观点。这个主题讨论了使用setTimeout和setInterval的危险,以及为什么通常使用requestAnimationFrame更好:http://goo.gl/PdxHLO。然而,我认为对于大多数游戏来说,固定帧率的逻辑/渲染循环(就像您建议的那样)而不计算滞后和进行渲染位置插值是可以的。 - d13

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