Game Loop的几种实现方式
http://www.bennychen.cn/2011/06/game-loop-model/
——————————————————————————————
写这篇博客的目的是为了对game loop(游戏主循环)做一个全面的总结和介绍,包括它的定义,与之相关的专业术语(terminology),以及最重要的,对它的几种实现方式,从代码层次做一些介绍以及优缺点分析。
1.什么是Game Loop
对任何一个游戏开发者来说,game loop都应该不是一个陌生的概念。任何一款游戏都会有一个自己的game loop,它是整个游戏的核心,是游戏的心脏。具体什么是game loop,这是来自于Game Engine Architecture[2]上对于game loop的定义:game loop是对于一个游戏(或者一个游戏引擎)的所有子系统(subsystem)的周期性的更新。一个游戏会包含各种不同的子系统(比如渲染,物理,动 画,AI等等),这些不同的子系统负责处理不同的游戏任务,game loop的实现方式就决定了这些子系统的任务执行的组织方式。而这些就决定了整个游戏的基础架构,同时也将决定game在不同的机器上将如何运行,在下面 介绍game loop的不同实现方式时会对此有详细介绍。
game loop中所处理的逻辑和操作包罗万象,但一般可抽象为以下这几种模块:
- 处理输入:这些输入包括,来自于交互设备(键盘,鼠标,游戏控制器等等)的输入,来自于网络的输入;
- 系统更新(update):(根据输入或者自发的)对各个子系统进行更新,以决定当前的游戏状态;
- 渲染(render):对整个游戏场景进行渲染。
所以一个最最简单的game loop可以抽象为如下的这些伪代码:
while ( game is running )
{
processInput();
update();
render();
}
2.相关术语
常常和渲染紧密相连的一个参数是FPS(frame per second),它指的是每秒显示设备在屏幕上进行绘制的频率。对于游戏来说,一般50-60的FPS是最优,最低也不要低于16帧[1]。游戏中还有一 个容易被忽略的重要参数可以被称为游戏速度(game speed)[3],它指的是游戏状态每秒被更新的次数,容易与FPS混淆。一个通俗的理解和区别它们的方式可以是,FPS是render函数被调用的频 率,而game speed则是update函数被调用的频率。
游戏是对实时性(real-time)要求很高的系统,所以在game loop中,time是尤其重要的一个因素。这里需要说的是,游戏中会包含好几种不同的时间,这些时间有着各自的时间线(timeline),并且在游戏中发挥着各自不同的重要作用[2]。
- 系统的真实时间(real time)。真实时间一般在操作系统被定义为从过去某个固定时间点(比如常见的一个时间点被称之为Epoch, 是1970年1月1日的0:00:00, UTC时间)到当前点总共逝去的时间。
- 游戏时间(game time)。一般情况下,游戏时间等于系统时间。但在某些特殊情况下,游戏时间可以发挥不同的作用:比如在游戏暂停的时候,需要停止更新游戏时间;在游戏 需要以慢速运行时,可以用比真实时间低的频率来更新游戏时间,这在我们需要进行一些游戏调试的时候,尤其有用。下面会介绍到,一个好的game loop结构,可以有助于游戏调试,这对开发人员来说是非常重要的。
- 局部时间线(local timeline)。不管是一个动画,还是一个音频或者视频片段,都会有一个局部时间线。通过将此局部时间线以不同的方式映射到全局的时间线,可以灵活的控制片段以特定的方式进行播放。[2]
- 帧时间(frame time)。两次帧循环之间所间隔的时间,具体可看本文章的3.2.1节。
3.Game Loop的实现方式
OK,介绍了这么多跟game loop相关的概念,接下来进入正题,game loop的各种实现方式大盘点。
有一点需要强调的是,game loop在单线程的游戏系统中是一种情况,而在多线程的系统中又是另一个不同的故事。多线程对于系统会引入异常的复杂性,关于这个,可以看我之前的一篇关于游戏引擎多线程的文章:http://www.bennychen.cn/2011/01/关于游戏引擎多线程的一些整理和思考/。相比来讲,单线程要简单很多,这篇文章里以下的一些game loop的实现方式也主要是针对在单线程环境中的。
在单线程环境中,game loop按照实现方式可以分为以下这几种类型:
- 基于帧的(frame-based)game loop
- 基于时间的(time-based)game loop
- 可变频率(variable-step)game loop
- 固定频率(fixed-step)game loop
3.1 基于帧的(frame-based)game loop
这是一种最简单的game loop,就如上面文章刚开始的伪代码所描述的一样,在这种结构下,游戏只是重复的不间隔的进行processInput,update,render操 作。可以看到,这种game loop的实现异常简单,但缺点也是异常明显的。因为它缺少了一个很重要的控制因素——时间,所以在不同配置的机器环境下,游戏将以不同的速度运行。比如 说,游戏中的一个物体,在每次的update操作中将它的位移增加5(单位可以是米,英尺,像素,或者其它某种单位,每个游戏都使用一个特定的距离单位)
void update()
{
object.position += 5.0f;
}
在一个较快速的机器上,update被执行的次数会更多,所以物体移动的速度就会更快,这就导致了游戏在不同机器运行速度的不一致,这是不能接受的。
优点:简单;
缺点:在不同机器上,游戏的运行速度不一致。
3.2 基于时间的(time-based)game loop
为了让不同机器的游戏运行速度一致,就需要引入时间,这就带来了基于时间的game
loop实现方式。这种方式下,又可以分为两种,可变频率(variable step)的和固定频率的(fixed
step)。在[5]中,这两种方式则分别被称为可变间隔的(variable interval)和固定间隔的(fixed interval)。
3.2.1 可变频率的game loop
这种方式的实现只需要为update函数引入一个时间参数elapsedTime即可,elapsedTime指的是从上一次loop的执行到当前
loop所过去的时间,通常称之为帧时间(frame time),或者time
delta。它的单位一般使用毫秒,这是在游戏中进行时间相关操作所使用的标准单位[2],当然其它比如在做profiling时为了计算某个函数的执行
时间,则用的是机器周期(machine cycle)这样更精确的时间机制。
这是这种game loop实现的伪代码,也非常简单:
lastFrameTime = getCurrentTime();
while ( game is running )
{
processInput(); currentFrameTime = getCurrentTime();
elapsedTime = currentFrameTime - lastFrameTime;
update( elapsedTime );
lastFrameTime = currentFrameTime; render();
}
这时,再看前面的那个例子,对游戏中的一个物体,在每次的update操作中不是将它的位移绝对增加5,而是要乘以时间这个因子,这样5实际代表的是物体的速度。速度乘以时间,就得到了物体在这段时间内的位移:
void update( float elapsedTime )
{
object.position += 5.0f * elapsedTime;
}
这样就解决了在不同配置的机器上运行速度不一致的问题。因为在更快的机器上,update执行的频率高,但frame time的间隔时间较短,所以物体位置更新的频率高,但每次更新的位移幅度小。反之,在较慢的机器上,update函数的执行频率较低,但frame time间隔时间较长,则每次物体将以较大幅度的更新位移。正是因为引入了时间因素,所以在不同配置的机器上,游戏的运行速度将会看起来一致。
不过这种game loop在实现时需要解决一个长时间暂停的问题,所以我们在暂停时,要同时停止更新游戏时间,以免在暂停后恢复时,将得到一个超级大的elapsedTime值。
但随之而来,这种game loop实现方式的缺点也暴露出来了。比如考虑这样一种场景,物体绕着一个弧形的轨迹进行移动。在正常的速率下,物体的运行轨迹几乎是弧形的。如下图,图片来自于[5]。
但是在较慢的机器上,虽然物体的移动位置点能保持同步,因为更新的频率较低,物体的移动轨迹就变得非常离散,以至于不是按照一个弧形在移动,如下图。
其它的效果也有类似的问题,比如说动画,虽然说动画的播放速率是一致的,但是在较慢的机器上,会出现比较严重的掉帧现象,这就是我们俗称的“卡了”。再比如说物理,在一个正常的机器上,一个障碍物能够完美的被避开,但在一个较慢的机器上,这就不好说了。
对于这种game loop,就算是较快的机器上,也是有问题的,虽然好像update被执行的越快越多,游戏运行的就越流畅,用户的体验应该越好才对。但其实不然,两点原 因,首先就算在较快的机器上,也可能会遭遇到运算的高峰期,这时由于对比明显,游戏性能的下降会非常明显,游戏用户就很容易察觉到这种性能降级 (performance degradation),这并不是好的体验;再一点,对于手机等移动设备上的游戏,update速率执行过快不是好事,这对电池是一种消耗。事实是,游 戏只需要一定范围内的update频率就可以达到流畅而令用户接受的效果。
优点:简单且不同机器上的游戏运行速度是一致的;
缺点:在较慢的机器上,物体的更新频率慢会导致各种效果失真(distortion);在较快的机器上,更新太快的话会更容易让用户察觉到性能降级,且对于移动设备,更新太快会降低电池的使用时间。
3.2.2 固定频率的game loop
由此,为了解决上面的问题,就有了这种固定频率的game loop,让游戏的更新速度保持在一个特定的恒定值。比如下面的这段伪代码,让游戏恒定运行在FPS(或者game speed,此时FPS等于game speed)为25的速度下。代码参考自[3]。
#define FRAME_RATE 25
#define FRAME_TIME ( 1000 / FRAME_RATE ) nextFrameTime = getCurrentTime();
while ( game is running )
{
processInput();
update( FRAME_TIME );
render(); nextFrameTime += FRAME_TIME;
currentFrameTime = getCurrentTime();
if ( nextFrameTime >= currentFrameTime )
{
sleep( nextFrameTime - currentFrameTime );
}
else
{
// 我们已经跟不上帧速率了
}
}
可以看到,如果一次loop执行完的持续时间小于固定帧时间,则直接让主线程sleep即可。但是如果在较慢的机器上(或者是设定的固定帧速率过 高),执行完一帧的时间会超过固定帧时间,导致无法达到所目标的帧速率,则只能忍受这种情况了。最差情况下,如果在某一时期内游戏遭遇到了非常巨大的运算 压力,则游戏将会变得异常缓慢到无法忍受的地步。
保持固定的更新频率有一个非常重要的优点,因为它带来了游戏执行的确定性(game execution determinism)[1],所以以这种机制所实行的game loop可以被称为确定性的game loop(deterministic game loop)。反之,可变频率的game loop则是非确定性的(non-deterministic),因为它依赖于系统每一帧运行的时间,这在游戏每次运行时是变化不定的,这就导致游戏的行 为也是不定的。
确定性机制能够为系统带来一个非常重要的特点——录制和回放功能(record and replay)[2]。所谓录制回放功能,就是能够将玩家在进行游戏的时候的各种操作记录下来,以便在下一次运行时,就能够通过回放将游戏以同样的方式执 行。这会成为一个很好的debugging工具,因为通过回放功能,会让一些难以发现的bug得以轻而易举的复现,这是非常珍贵的。甚至我们还可以支持单 步调试(single-stepping)功能[2],单步调试指的是当游戏处于暂停状态时,可以通过某个按键,让游戏一次执行一个frame time,这在调试游戏时都是非常有用的。
这种game loop还是有一个问题,它紧耦合了update和render的执行频率,update的频率(即game speed)保持在25基本能满足游戏运行的流畅需求,但让render的更新频率(即FPS)也保持在25,对于配置好的机器实在是有些浪费,我们可以 让渲染的更快,以获得更好的画面效果。
优点:在不同机器上游戏效果一致,同时为游戏带来确定性。
缺点:一个更好的机器并不能带来更好的游戏画面,扩展性(scalability)差。
3.2.3 获得最大FPS的固定频率的game loop
解决上一个game loop缺点的办法就是解耦update和render,让它们以各自不同的频率运行。这就带来了这个固定频率的game
loop的变种,称之为“获得最大FPS的固定频率的game loop”(Constant Game Speed with Maximum
FPS)[3]。
同时这种方法还处理另一个问题,当update处理时间过久时,这种game
loop会暂时不进行render而再次update。换言之,当update执行时间长于所期望的帧时间时,游戏会丢弃绘制帧并调用额外多次
update函数,以让游戏从一个慢速(slowdown)状态中追赶上并恢复过来[4]。
这是该game loop的代码:
#define MAXIMUM_FRAME_RATE 45;
#define MINIMUM_FRAME_RATE = 15;
#define UPDATE_INTERVAL ( 1000 / MAXIMUM_FRAME_RATE )
#define MAX_CYCLES_PER_FRAME ( MAXIMUM_FRAME_RATE / MINIMUM_FRAME_RATE ) nextFrameTime = getCurrentTime();
while ( game is running )
{
loops = 0;
while( getCurrentTime() > nextFrameTime
&& loops < MAX_CYCLES_PER_FRAME )
{
update( UPDATE_INTERVAL );
nextFrameTime += UPDATE_INTERVAL;
loops++;
} render();
}
从上面的代码中看出,当出现update处理时间过久时,game loop并不是一直重复执行update而不渲染,update频率被控制在15-45之间,所以在一次while循环中,最多只会执行3次 update(MAX_CYCLES_PER_FRAME=MAXIMUM_FRAME_RATE / MINIMUM_FRAME_RATE=45/15)。
当然FPS也不总是可以为任意值,有时为了解决显示设备上的一种叫做tearing[2](屏幕的上半部分显示的是上一帧的画面,而下半部分是当前 帧的画面)的问题,需要将FPS设置为显示设备刷新频率的倍数。在手机等这种移动设备上,同样为了省电,也需要将FPS固定在一个恒定值。但不管怎 样,update和render仍然是以各自不同的频率运行。最近在读的一本关于iOS游戏开发的书上[6]的game loop,作者使用的就是这种game loop,它的game speed被设置在15-45,而FPS则采用的是iOS上默认的60(iOS上使用CADisplayLink的frameInterval属性来设置 绘制帧率,frameInterval默认是1,表示显示1秒钟会被刷新60次)。
优点:拥有固定频率的game loop的优点,同时解耦update和render,并且当帧速率降低时,可以通过丢弃绘制帧来保持游戏的速度;
缺点:在高配置的机器上依然有些浪费资源。
3.3 其它方式
game
loop还可以有更多的变化,比如在[1]中提到,对于游戏中的不同子系统,update的频率也是不一致的。比如,为了获得很好的动画效果,需要一个较
高的频率来更新动画,而对于AI系统,用同等高频率的速度来更新就是浪费计算资源了。所以这篇文章提出了一种更好的update机制,将update分隔
为两部分,一部分以最快的速度运行,而另一部分以某种预设的固定频率运行。
在[3]中提出,为了获得一种更平滑的画面效果,可以对frame-time进行插值(interpolate),并且为update函数提供预测函数(prediction function)。
4.总结
一个看似简单的game loop,也可以有这么多的变数。这篇文章主要基于时间因素列出了4种实现方式:第1种是基于帧的game
loop,一般而言要避免采用这种方式,而应该选用后面3种基于时间的game loop。基于时间的可变频率的game
loop是一种常见的实现方式,不过为了获得稳定的画面效果和游戏运行的确定性,可以使用固定频率的game
loop。最后还可以将update和render解耦以各自频率运行,以获得最优的组织结构运行游戏。
OK,这里就是我有史以来最长的一篇博客的结尾。
5.参考
[1]LUIS VALENTE, Real Time Game Loop Models for Single-Player Computer Games
[2]Jason Gregory, Game Engine Architecture
[3]http://www.koonsolo.com/news/dewitters-gameloop/
[4]http://msdn.microsoft.com/en-us/library/bb203873.aspx
[5]http://sacredsoftware.net/tutorials/Animation/TimeBasedAnimation.xhtml
[6]Michael Daley, Learning iOS Game Programming - A Hands-On Guide to Building Your First iPhone Game
Game Loop的几种实现方式的更多相关文章
- oracle Hash Join及三种连接方式
在Oracle中,确定连接操作类型是执行计划生成的重要方面.各种连接操作类型代表着不同的连接操作算法,不同的连接操作类型也适应于不同的数据量和数据分布情况. 无论是Nest Loop Join(嵌套循 ...
- PostgreSQL EXPLAIN执行计划学习--多表连接几种Join方式比较
转了一部分.稍后再修改. 三种多表Join的算法: 一. NESTED LOOP: 对于被连接的数据子集较小的情况,嵌套循环连接是个较好的选择.在嵌套循环中,内表被外表驱动,外表返回的每一行都要在内表 ...
- Unity---动画系统学习(2)---模型3种导入方式、人形动画介绍、切割动画
1. 介绍 Unity中导入的模型主要是由3DMAX.Maya等建模软件制作的,后缀为.fbx的文件. 博主在Unity Asset Store里面下载了一套官方免费的模型和动画. 和一套地图,分享给 ...
- SQL Server中的三种Join方式
1.测试数据准备 参考:Sql Server中的表访问方式Table Scan, Index Scan, Index Seek 这篇博客中的实验数据准备.这两篇博客使用了相同的实验数据. 2.SQ ...
- python 多线程两种实现方式,Python多线程下的_strptime问题,
python 多线程两种实现方式 原创 Linux操作系统 作者:杨奇龙 时间:2014-06-08 20:24:26 44021 0 目前python 提供了几种多线程实现方式 thread,t ...
- Web APi之认证(Authentication)两种实现方式【二】(十三)
前言 上一节我们详细讲解了认证及其基本信息,这一节我们通过两种不同方式来实现认证,并且分析如何合理的利用这两种方式,文中涉及到的基础知识,请参看上一篇文中,就不再叙述废话. 序言 对于所谓的认证说到底 ...
- CSS垂直居中的11种实现方式
今天是邓呆呆球衣退役的日子,在这个颇具纪念意义的日子里我写下自己的第一篇博客,还望前辈们多多提携,多多指教! 接下来,就进入正文,来说说关于垂直居中的事.(以下这11种垂直居中的实现方式均为笔者在日常 ...
- Android中BroadcastReceiver的两种注册方式(静态和动态)详解
今天我们一起来探讨下安卓中BroadcastReceiver组件以及详细分析下它的两种注册方式. BroadcastReceiver也就是"广播接收者"的意思,顾名思义,它就是用来 ...
- Android中Fragment与Activity之间的交互(两种实现方式)
(未给Fragment的布局设置BackGound) 之前关于Android中Fragment的概念以及创建方式,我专门写了一篇博文<Android中Fragment的两种创建方式>,就如 ...
随机推荐
- C# 默认参数/可选参数需要注意
在使用C#的默认参数/可选参数的时候,需要注意,参数的默认值是在编译的时候,自动加入调用方的. 如我有这样一个方法: public class Name { public void Test(Bool ...
- Python学习笔记015——文件file的常规操作之三(标准输入输出文件)
1 标准输入输出文件 在Python中,模块sys中含有标准的输入输出文件 sys.stdin 标准输入方法(一般是键盘) sys.stdout 标准输出方法(到显示器的缓冲输出) sys ...
- Google Guice之作用域
默认情况下,Guice获取一个实例时.每次都会返回一个新的对象. 这个行为能够通过scopes进行配置.Scopes同意你复用实例: 应用整个生命周期(@Singleton),会话(@Session) ...
- Python 字典 pop() 方法
描述 Python 字典 pop() 方法删除给定键所对应的键/值对,并返回被删除的值.给定键如果不在字典中,则必须设置一个default值,否则会报错,此时返回的就是default值. 语法 pop ...
- Python count() 方法
描述 count() 方法用于统计字符串中某个子字符串出现的次数,可选参数为开始搜索与结束搜索的位置索引. 语法 count() 方法语法: S.count(sub[,start=0[,end=len ...
- stm32 spi1 bug
stm32 spi1调试NRF24L01时该模块作为接收机时,能收到数据,作为发送机时,发不出数据(虽然读NRF的寄存器显示数据已经发出,但实际并发不出),换到SPI2问题解决
- SpringSecurityFilter 链
1. HttpSessionContextIntegrationFilter 位于过滤器顶端,第一个起作用的过滤器. 用途一,在执行其他过滤器之前,率先判断用户的session中是否已经存在一个Sec ...
- vim:过一个字符
vim由插入模式进入编辑模式,会向前移动一个字符. 利用这个特性,我们可以用他来过一个字符. 什么情况需要过字符?比如:右括号.右引号等等,在括号中间,直接跳出,而不用方向键移动. 向前过一个字符,应 ...
- DPDK
Intel DPDK 全面解读 高性能网络技术 随着云计算产业的异军突起,网络技术的不断创新,越来越多的网络设备基础架构逐步向基于通用处理器平台的架构方向融合,从传统的物理网络到虚拟网络,从扁平化 ...
- [self.view addSubview:vc2.view]程序崩溃的解决办法
- (void)viewDidLoad { [super viewDidLoad]; // Do any additional setup after loading the view. UIButt ...