白云哥

身披半件长工衣,怀揣一颗地主心

 

Structuring the Main Loop

    看到CppBlog上翻译的一篇游戏主循环,想起之前也看到过一篇类似的文章,因为笔记本上第一篇记录就是这个主循环的简短翻译,对比了一下,发现这篇文章对实现细节的描述更多一些,也发上来与大家共享。

    这篇文章最早是出现在flipcode的论坛上,地址在这里,后来有人整理了一下,并添加了更多的描述,也就是下面的内容。原贴地址在这里

    文章中关于网络的描述是指局域网环境下的情况,另外这个主循环也没有考虑到windows环境下与Windows Message Loop的结合,如果是应用在windows环境下,可以再参考下这里,把两者结合基本上就差不多了。

    翻译并未严格遵照原文,为了读起来流畅,很多句子都是按照我个人的理解来描述。

 

This article is about a way of structuring a game's main loop. It includes techniques for handling view drawing with interpolation for smooth animation matched to the frame-rate with fixed-step game logic updating for deterministic game logic. A lot of this is still pretty much a work-in-progress as I muddle my way through, learning better ways of doing things or new tricks to add to my bag, so please bear with me.

这篇文章描述的是如何组织游戏主循环的一种方法。内容包括了如何处理平滑的动画绘制,并且能够根据当前帧率做正确的动画插值,另外还要保证固定的游戏逻辑更新帧率,以确保游戏逻辑的计算结果是确定的。

这些内容的实现有很多都还在进行中,我也在不断地学习更好的方法,以及将一些新的技巧添加进来,所以,希望能够给我一些耐心与宽容。

The heart of a game, any game, is the game loop. This is where the action takes place, where the guns fire and the fireball spells fly. In some games, the concept of the game loop may be diffused among different components or game states, which implement their own version of the game loop to be exectued at the proper time, but the idea is still there.

任何游戏的核心都是游戏主循环。游戏的动作执行、子弹的射击以及火球魔法的飞行等等都是在这里实现。

在一些游戏中,你可能会找不到一个唯一的游戏主循环,取而代之的是,你会在一些组件及状态机中找到各个不同版本的主循环。

其实这个原理也是一样的,只不过是把原来的一个主循环拆分成了多个,这样可以控制游戏在不同的时间及状态下执行不同的循环过程。

 

The game loop is just that: a loop. It is a repeating sequence of steps or actions which are executed in a timely and (hopefully) efficient manner, parceling out CPU time to all of the myriad tasks the game engine is required to perform: logic, physics, animation, rendering, handling of input. It must be constructed in a deterministic, predictable fashion so as to give expected behavior on a wide array of hardware configurations. Classic failures in this regard include old pre-Pentium DOS-based games that were synchronized to run well on old hardware, but which did not have the controls in place to control the speed on newer hardware, and consequently ran so rapidly that they became unplayable on new hardware. With such a broad diversity of hardware as now exists, there must be tighter controls on the game loop to keep it running at a consistent speed, while still taking advantage of more powerful hardware to render smoother animation at higher framerates.

简单来说,游戏主循环就是一个循环过程。

在这个不断重复执行的过程中,我们需要把CPU时间按照一定的规则分配到不同的任务上,这些任务包括:逻辑、物理、动画、渲染、输入处理等等。

游戏的主循环必须保证是以一种确定的、可预测的方式来执行,这样才能在大量的不同硬件配置环境下都得到我们所期望的相同行为。

以前的DOS游戏曾经出现过这样的问题,它们在旧的硬件上运行的很好,但是放到新的硬件上以后就失去控制了,游戏的运行速度变的如此之快,以至于根本无法去玩它。

现在市场上存在这么多的硬件种类,所以就必须要紧紧地控制住游戏的主循环,保证他们以一个固定的速度运行,但同时又能获得这些强大的硬件所带来的好处:以尽可能高的帧率来渲染出更平滑的动画。

 

Older games frequently tied the rendering of the view very closely to the game loop, drawing the view exactly once per logic update and waiting for a signal from the display system indicating a vertical retrace period, when the electron gun in the CRT monitor was resetting after drawing the screen. This synchronized loops to a predictable rate based on the refresh rate of the monitor, but with the advent of customizable refresh settings this leads again to unpredictable loop behavior. Retrace synchronization is still useful, especially to avoid visual artifacts when rendering the view, but is less useful as a means for synchronizing the game logic updating, which may require finer control.

以前的游戏经常将渲染过程与游戏循环紧密地绑在一起,首先执行一次逻辑更新,然后绘制画面,接着等待显示系统触发一个垂直同步信号,之后就是下一轮循环周期:逻辑更新、渲染、等待……周而复始。

这种同步的循环方法在显示器的刷新率可预测的情况下是有效的,但是当可以自定义刷新率以后,这种行为又变得不可预测了。

垂直同步仍然是有用的,尤其是在避免画面的渲染出现撕裂的情况下,但是用来同步游戏的逻辑更新就没多大用了,这时可能需要更好的控制方法。

 

The trick, then, is to separate game logic from rendering, and perform them in two separate sub-systems only marginally tied to each other. The game logic updates at it's own pace, and the rendering code draws the screen as fast as it possibly with the most accurate, up-to-date data the logic component can provide.

这种方法就是将游戏逻辑更新与屏幕渲染过程分离开,将他们放到两个分离的子系统中去处理,只是在需要的时候才与另一个打交道。

游戏的逻辑更新严格按照计划来执行,但是屏幕渲染则以它所能达到的最大速率来进行。

 

The system I am accustomed to using is based on a Tip of the Day ( http://www.flipcode.com/cgi-bin/msg.cgi?showThread=Tip-MainLoopTimeSteps&forum=totd&id=-1 ) posted to http://www.flipcode.com by Javier Arevalo. It implements a loop wherein the game logic is set to update a fixed number of times per second, while the rendering code is allowed to draw as rapidly as possible, using interpolation to smooth the transition from one visual frame to the next.

这里描述的方法基于Javier Arevalo发表在flipcode Tip of the Day 上的代码来实现。

在这个游戏主循环里,游戏逻辑更新被设置为每秒执行固定次数,但同时渲染代码被允许执行尽可能多次,并且还使用了插值来使得两个渲染帧之间的动画变化尽可能的平滑。

 

Briefly, here is the code. I will then attempt in my own crude fashion to explain the workings, though I suggest you check out the original tip at the above link to read Javier's explanation, as well as the forum posts accompanying it which offer up insights and suggestions on how the performance of the loop may be improved.

闲话少说,下面首先是代码,然后我会简单的按我的方式描述一下代码的工作原理,同时我建议你阅读一下上面链接地址所给出的内容,其中有Javier的解释,并且论坛上的回贴也有一些不错的内容,包括别人的评论和一些关于如何提高效率的建议。

(注:flipcode论坛早就已经关闭,上面的链接地址已经失效,flipcode上只保留有一些优秀内容的archives在这里能找到这篇原文,其中包括Javier的解释)

 

time0 = getTickCount();
do
{
  time1 = getTickCount();
  frameTime = 0;
  int numLoops = 0;

  while ((time1 - time0) > TICK_TIME && numLoops < MAX_LOOPS)
  {
    GameTickRun();
    time0 += TICK_TIME;
    frameTime += TICK_TIME;
    numLoops++;
  }
  IndependentTickRun(frameTime);

  // If playing solo and game logic takes way too long, discard pending time.
  if (!bNetworkGame && (time1 - time0) > TICK_TIME)
    time0 = time1 - TICK_TIME;

  if (canRender)
  {
    // Account for numLoops overflow causing percent > 1.
    float percentWithinTick = Min(1.f, float(time1 - time0)/TICK_TIME);
    GameDrawWithInterpolation(percentWithinTick);
  }
}
while (!bGameDone);
 

Structurally, the loop is very simple. The above snippet of code can encapsulate the entire workings of your game.

从结构上来说,这个主循环非常简单。上面的代码片段基本上能够囊括你的游戏的整个工作过程。

 

First of all, the main loop portion is embodied as the do{} while(!bGameDone); block. This causes the loop to run endlessly, until some game condition indicates that it is finished and it is time to exit the program, at which point the loop ends and the game can be properly shut down. Each time through the loop, we perform game logic updates, input updating and handling, and rendering. Now, for a breakdown of the sections of the loop.

首先,主循环的执行过程被包在do…while循环块中,这使得游戏主循环将永不结束地运行,直到游戏明确地被告知需要结束并且退出程序时为止。

在每次进入循环的时候,我们会执行游戏逻辑的更新,输入更新与处理,还有渲染。接下来,把循环分为几个片段来描述。

 

time1 = getTickCount();
frameTime = 0;
int numLoops = 0;

while ((time1 - time0) > TICK_TIME && numLoops < MAX_LOOPS)
{
    GameTickRun();
    time0 += TICK_TIME;
    frameTime += TICK_TIME;
    numLoops++;
}
 

This portion is the game logic update sequence that forces the game logic (physics updates, object motion, animation cycling, etc...) to update a set number of times per second. This rate is controlled by the TICK_TIME constant, which specifies the number of milliseconds the logic update is supposed to represent in real time. It probably won't take that long to perform, in which case the update won't be performed again until enough time has passed. For example, with TICK_TIME=40, each logic represents 40 milliseconds, thus forcing the code to update game objects at a rate of 25 times per second.

这部分代码处理游戏逻辑的更新,并且强制要求游戏逻辑每秒执行固定次数。

游戏的逻辑处理包括物理更新、对象行为、动画循环等等。更新速率通过TICK_TIME常量指定,其含义为两次逻辑更新的时间间隔,即经过TICK_TIME后应该再次进行更新,而不是说一次逻辑更新要持续这么长时间。

例如,TICK_TIME = 40,其含义为每次游戏逻辑更新后表现40毫秒,这将使得每秒游戏对象会被更新25次。

 

The logic is encapsulated in it's own while loop. It is possible during a given frame that game logic will take too long. If a logic update cycle goes overtime, we can delay rendering for a bit to give ourselves a little extra time to catch up. The while loop will continue to process game logic updates until we are no longer overtime, at which point we can go ahead and continue on with drawing the view. This is good for handling the occasional burp in logic updating, smoothing out the updates and keeping them consistent and deterministic; but in the case that the logic repeatedly goes overtime, it is possible to accumulate more time-debt than the loop can handle. Thus, the MAX_LOOPS constant is in place to dictate a maximum number of times the loop can repeat to try to catch up. It will force the loop to dump out periodically to handle other tasks such as input handling and rendering. A loop that is constantly running at the MAX_LOOPS limit runs like hell and is about as responsive as a tractor with four flat tires, but at least it keeps the loop operating, allowing the user to pass input to terminate the program. Without the MAX_LOOPS failsafe, it would be entirely possible for a slow computer to lock up with no way to exit as it chokes on the loop, trying to catch up but getting further and further behind.

逻辑更新的代码被包在他自己的while循环中。

可能在某一帧里游戏逻辑的处理时间会非常长,甚至会超时,这时我们可以稍稍暂停一下屏幕的渲染,使得游戏逻辑更新能够在这段时间里赶上来。

这个while循环就是用来让游戏逻辑继续更新,直到我们不再超时,之后我们继续游戏的主循环并且进行屏幕渲染。这可以很好地处理突发的逻辑更新超时,使得我们的更新更加平滑,并保证逻辑的一致性和可预测性。

但是如果游戏逻辑处理持续地超时,甚至使得我们的主循环无法处理过来,这时MAX_LOOPS将会起到作用,他将使游戏控制权从逻辑更新中跳出来,去执行如输入处理和屏幕渲染等其他任务。

MAX_LOOP常量限制了这里的while循环用来追赶逻辑处理时间时最多能重复的次数。这样当游戏真的出现完全无法处理完逻辑更新的时候,用户也有机会结束程序。

如果没有MAX_LOOP的检测,很有可能会出现一台很慢的电脑试图追上逻辑处理时间,但是却越追越远,而又没有机会退出这个过程,最后陷在了这个死循环中……

 

IndependentTickRun(frameTime);
 

This section is where input is gathered, events are pumped from the event queue, interface elements such as life bars are updated, and so forth. Javier allows for passing how much time the logic updates took, which can be used for updating on-screen clock or timer displays and the like if necessary. I've never found occasion to use it, but I regularly pass it anyway on the off-chance I'll need it someday. frameTime basically gives the amount of time that was spent in the logic loop performing updates.

这部分代码用来处理用户输入的捕获,事件会从事件队列中被取出来处理,界面元素,如血条等,会在这里被更新,还有其他类似的处理。

Javier在这里留了一个frameTime参数,用来指明逻辑更新所花的时间。可以用这个值来更新游戏屏幕上的时钟或定时器等类似信息。

虽然我目前还没有找到机会用它,但我还是习惯性地把它带上了,也许某天我会需要。

 

In order for the game loop to run predictably, you should not modify anything in this step that will affect the game logic. This portion of the loop does not run at a predictable rate, so changes made here can throw off the timing. Only things that are not time-critical should be updated here.

为了使游戏循环的运行是可预测的,你不应该在这里修改任何可能影响游戏逻辑的东西,因为此部分在游戏主循环中的执行次数是不可预测的,所以在这里只能做一些时间无关的更新。

 

// If playing solo and game logic takes way too long, discard pending time.
if (!bNetworkGame && (time1 - time0) > TICK_TIME) time0 = time1 - TICK_TIME;
 

This is where we can shave off our time debt if MAX_LOOPS causes us to dump out of the logic loop, and get things square again--as long as we are not running in a network game. If it is a single-player game, the occasional error in the timing of the game is not that big of a deal, so sometimes it might be simpler when the game logic overruns it's alloted time to just discard the extra time debt and start fresh. Technically, this makes the game "fall behind" where it should be in real time, but in a single player game this has no real effect. In a network game, however, all computers must be kept in synchronization, so we can't just cavalierly discard the pending time. Instead, it sticks around until the next time we enter the logic update loop, where the loop has to repeat itself that many more times to try to catch up. If the time burp is an isolated instance, this is no big deal, as with one or two cycle overruns the loop can catch up. But, again, if the logic is consistently running overtime, the performance of the game will be poor and will lag farther and farther behind. In a networked game, you might want to check for repeated bad performance and logic loop overruns here, to pinpoint slow computers that may be bogging the game down and possibly kick them from the game.

当由于满足了MAX_LOOP条件而跳出逻辑循环时,可以在这里减掉多加的上TICK_TIME时间,并且只在单机游戏时才这样做。

如果是在单机游戏中,偶尔出现时间上的错误是不会有什么大问题的,所以当游戏逻辑执行超时后也没有多大关系,我们简单的把多花的时间减掉,然后重新开始。从技术上来说,这会使得游戏有一点点时间上的落后,但在单机游戏里这不会有什么实际的影响。

但是在网络游戏中这样做却不行,所有连网的电脑都必须要保持时间上的同步。

在网络游戏中,如果某台电脑落后了,他应该保持其落后的状态,然后在下一次进入逻辑更新循环时,自己让自己多重复几次,以便追赶上来。

如果时间延迟只是个孤立事件,这将不会有多大问题,经过一两次超速就会追赶上来,但是如果游戏逻辑更新总是超时,游戏的表现将会非常糟糕,并且最终将会越来越滞后。

在网络游戏中,你可能需要检查出这些持续超时,表现总是很差的电脑,并将这些可能拖慢整个游戏环境的电脑踢出去。

 

// Account for numLoops overflow causing percent > 1.
float percentWithinTick = Min(1.f, float(time1 - time0)/TICK_TIME);
GameDrawWithInterpolation(percentWithinTick);
 

This is where the real magic happens, in my opinion. This section performs the rendering of the view. In the case of a loop where TICK_TIME=40, the logic is updating at 25 FPS. However, most video cards today are capable of far greater framerates, so it makes no sense to cripple the visual framerate by locking it to 25 FPS. Instead, we can structure our code so that we can smoothly interpolate from one logic state to the next. percentWithinTick is calculated as a floating point value in the range of [0,1], representing how far conceptually we are into the next game logic tick.

在这里我们将进行屏幕绘制。

当TICK_TIME = 40时,逻辑更新帧率为25FPS,但是现在大多数显卡都能支持更高的渲染帧率,所以我们没必要反而锁定渲染帧率为25FPS。

我们可以组织我们的代码,让我们能够平滑的从一个逻辑状态到下一个状态做插值。percentWithinTick为一个0到1之间的浮点数,表示我们应该向下一个逻辑帧走多远。

 

Every object in the game has certain state regarding LastTick and NextTick. Each object that can move will have a LastPosition and a NextPosition. When the logic section updates the object, then the objects LastPosition is set to it's currentNextPostion and a new NextPosition is calculated based on how far it can move in one logic frame. Then, when the rendering portion executes, percentWithinTick is used to interpolate between these Last and Next positions:

每个可移动的对象都有LastPosition和NextPosition。逻辑更新部分的代码在执行时会将对象的LastPosition设置为当前的NextPosition,再根据它每一帧所能移动的距离来计算出NextPosition。

然后,在渲染对象的时候,可以再用percentWithTick来在Last和Next位置之间进行插值。

译注:

time0为最后一个逻辑帧所在的时间点,time1为当前实际时间,(time1 – time0) / TICK_TIME表示当前时间比最后一个逻辑帧所在时间超出了多少百分比,用这个百分比来向下一逻辑帧做插值。

如果机器比较快,在两个逻辑更新时间点之间执行了多次屏幕渲染,这样插值就有效了。此时time0不变,time1一直在增长,可以根据增长的百分比来计算动画应该从最后一次执行逻辑更新的位置向下一次逻辑更新所在的位置走多远。

也就是上文所说的,在Last和Next位置之间进行插值。插值后的实际位置,即DrawPosition的计算方法如下:

动画播放的插值也是类似。

 

DrawPosition = LastPosition + percentWithinTick * (NextPosition - LastPosition);
 

The main loop executes over and over as fast as the computer is able to run it, and for a lot of the time we will not be performing logic updates. During these loop cycles when no logic is performed, we can still draw, and as the loop progresses closer to the time of our next logic update, percentWithinTick will increase toward 1. The closer percentWithinTick gets to 1, the closer the a ctual DrawPosition of the object will get to NextPosition. The effect is that the object smoothly moves from Last to Next on the screen, the animation as smooth as the hardware framerate will allow. Without this smooth interpolation, the object would move in a jerk from LastPosition to NextPosition, each time the logic updates at 25FPS. So a lot of drawing cycles would be wasted repeatedly drawing the same exact image over and over, and the animation would be locked to a 25FPS rate that would look bad.

游戏的主循环以电脑所能够达到的最大速度不停地运行,在大多数时间里,我们将不需要执行逻辑更新。

但是在这些不执行逻辑更新的周期里,我们仍然可以执行屏幕渲染。当循环的进程越接近我们的下一次逻辑更新时间,percentWithinTick就接近1;percentWithinTick越接近1,DrawPosition的位置就越接近NextPosition。

最后的效果就是游戏对象在屏幕上慢慢的、平滑地从Last位置移动到Next位置,动作将以硬件帧率所能允许的程度尽可能的平滑。

如果没有这个平滑插值过程,对象位置将会从上一帧所在的LastPosition直接跳到下一帧所在的位置NextPosition。大量的渲染周期都被浪费在把对象反复渲染到相同的位置上,并且动画也只能被锁定在25FPS,使得看起来效果非常差。

 

With this technique, logic and drawing are separated. It is possible to perform updates as infrequently as 14 or 15 times per second, far below the threshold necessary for smooth, decent looking visual framerate, yet still maintain the smooth framerate due to interpolation. Low logic update rates such as this are common in games such as real-time strategy games, where logic can eat up a lot of time in pathfinding and AI calculations that would choke a higher rate. Yet, the game will still animate smoothly from logic state to logic state, without the annoying visual hitches of a 15FPS visual framerate. Pretty danged nifty, I must say.

使用了这项技术之后,逻辑更新与屏幕渲染被分离开了。

这将允许我们把逻辑更新帧率降低到14或15FPS,这远远低于平滑的动画渲染所需要的帧率,但是在使用动画插值之后却仍然能维持平滑的渲染帧率。

在实时策略类游戏中可能会使用这样低的逻辑更新帧率,这里逻辑更新会因寻路和AI计算而占用大量的时间,在这种情况下使用高的逻辑更新帧率显然不行。

但是,游戏却仍然能够在不同的逻辑状态之间做平滑的动画过渡,不会因为只有15FPS的渲染帧率而出现那些令人生厌的动画跳跃现象。

非常的漂亮,我不得不说。

 

I've created a simple program in C to demonstrate this loop structure. It requires SDL ( http://www.libsdl.org ) and OpenGL. It's a basic bouncy ball program. The controls are simple: Press q to exit the program or press SPACE to toggle interpolation on and off. With interpolation on, the loop executes exactly as described to smooth out the movement from one logic update to the next. With interpolation off, the interpolation factor percentWithinTick is always set to 1 to simulate drawing without interpolation, in essence locking the visual framerate to the 25FPS of the logic update section. In both cases, the ball moves at exactly the same speed (16 units per update, 25 updates per second), but with interpolation the motion is much smoother and easier on the eyes. Compile and link with SDL and OpenGL to see it in action: http://legion.gibbering.net/golem/files/interp_demo.c

posted on 2009-09-14 20:29 白云哥 阅读(2676) 评论(2)  编辑 收藏 引用 所属分类: GameDev

评论

# re: Structuring the Main Loop 2009-09-14 23:32 post

Practical UML Statecharts in C/C++, Second Edition:Event-Driven Programming for Embedded Systems  回复  更多评论   

# re: Structuring the Main Loop 2009-09-16 15:28 喜乐递

上岛咖啡书店艰苦奋斗是  回复  更多评论   


只有注册用户登录后才能发表评论。
网站导航: 博客园   IT新闻   BlogJava   知识库   博问   管理


导航

统计

常用链接

留言簿(4)

随笔分类

随笔档案

相册

我的链接

搜索

最新评论

阅读排行榜

评论排行榜