游戏编程模式-序列模式
最近在学
游戏有一个独有的苛刻的要求——有趣,玩家需要的是既新奇又有平衡性的的体验。
所以游戏的迭代更新是很重要的?如果一个游戏要持续吸引玩家,要持久的发展的话。
但是光是更新迭代发布版本,而不对游戏做全面的测试检查的话,是一种很不负责的行为。
双缓冲模式
用序列的操作模拟瞬间或者同时发生的事情。
定义缓冲类封装了缓冲区:一块能被修改的状态区域。
这个缓冲被增量地修改,但我们希望外部的代码对缓冲区的修改都视为单一的原子操作。
为了实现这点,类保存并维护两个缓冲的实例:下一缓冲和当前缓冲。
从缓冲区中读取信息,总是读取当前的缓冲区。 往缓存区写入信息,总是在下一缓冲区上经进行。
当改变完成后,一个交换操作会立刻将当前缓冲区和下一缓冲区交换,旧的缓冲区成为下一个重用的缓冲区。
使用场景
- 我们需要维护一些被增量修改的状态。
- 在修改到一半的时候,状态可能会被外部请求。
- 我们想要防止请求状态的外部代码知道内部的工作方式。
- 我们想要读取状态,而且不想等着修改完成。
使用须知
不像其他较大的架构模式,双缓冲模式位于底层。
它对代码库的其他部分影响较小——大多数游戏甚至不会感到有区别。
- 交换本身需要时间
交换操作必须是原子性的:在交换时,没有代码可以接触到任何一个状态。
通常交换过程跟修改指针一样快,如果交换消耗时间长于修改状态时间那就很坏了。 - 我们得保存两个缓冲区
这个模式的另一个结果是增加了内存的使用,需要你在内存中一直保留两个状态的拷贝。
在内存受限的硬件设备上,是个苛刻的要求,要付出惨痛的代价。
如无法分配出两份内存,就需要保证状态在修改时不会被请求访问。
需要设计决策的地方
- 缓冲区如何被交换?
- 交换缓冲区的指针或者引用:
速度快,但外部代码不能存储对缓存的永久指针,
缓冲区中的数据是两帧之前的数据,而不是上一帧的数据。 - 在缓冲区之间拷贝数据:
交换也许更花时间,
下一帧的数据和之前的数据相差一帧。
- 交换缓冲区的指针或者引用:
- 缓冲的粒度如何?
- 如果缓存是一整块:
交换操作更简单。由于只有一对缓存,一个简单的交换就完成了。
如果可以改变指针来交换,那么不必在意缓冲区大小,只需几步操作就可以交换整个缓冲区。 - 如果很多对象都持有一块数据:
交换操作更慢。 为了交换,需要遍历整个对象集合,通知每个对象交换。
如果不需要缓存的状态,可以使用将“当前”和“下一个”指针概念,将其改为对象相关的相对偏移量。
- 如果缓存是一整块:
游戏循环模式
将游戏的进行和玩家的输入解耦,和处理器速度解耦。
是“游戏编程模式”的精髓。 几乎每个游戏都有,而在非游戏的程序几乎没有。
关键
- 处理用户输入,但是不等待它,游戏循环在运转、渲染
- 让游戏在一个与硬件无关的速度变量下运行
帧率(FPS)
用实际时间来测算游戏循环运行的速度,就是游戏的“帧率”(FPS)。
如果游戏循环的更快,FPS就更高,游戏运行得更流畅更快。
可以理解成是图形处理器每秒更新的次数,例如60FPS,也就是每一秒更新60次,即0.016s更新一次。
决定帧率的两个因素
- 一个是每帧要做多少工作
- 底层平台的速度
定时步长
- 原理:以固定时间间隔(如 60FPS 的 16.6ms)更新游戏逻辑,渲染帧率可以变化
- 优点:
- 物理模拟稳定:适合需要精确物理计算的游戏,避免因帧率波动导致数值不稳定(如穿墙、速度异常)
- 确定性:相同输入下行为一致,便于调试和网络同步(如多人游戏)
- 逻辑与渲染分离:游戏逻辑不受渲染帧率影响
- 缺点:
- 性能浪费或卡顿:若逻辑更新耗时超过步长时间,会导致帧率下降或需要跳帧
- 不匹配高帧率设备:固定步长可能无法充分利用高刷新率显示器
变时步长
- 原理:每帧根据实际耗时
(ΔT)
更新逻辑,渲染和逻辑步长同步变化。 - 优点:
- 流畅渲染:充分利用硬件性能,适应不同帧率设备(如高刷显示器)
- 资源高效:避免不必要的逻辑更新,适合性能波动大的平台(如移动端)
- 缺点:
- 物理不稳定:
ΔT
过大会导致碰撞检测失效或数值爆炸 - 非确定性:相同输入可能因帧率不同产生不同结果,难以调试和同步
- 依赖帧率:逻辑代码处理需要
ΔT
- 物理不稳定:
使用情景
- 库:自己把握游戏循环,并在其中调用库函数
- 游戏引擎:引擎掌握游戏循环并调用你的代码
需要设计决策的地方
如何管理能量消耗?
需要考虑的不仅仅是让游戏看上去很棒,同时尽可能少地使用CPU。
需要设置一个性能的上限:完成一帧之内所需的工作后,让CPU休眠。尽可能快地运行:
游戏循环永远不会显式告诉系统休眠,相反,空闲的循环被划在提升FPS或者图像显示效果上了。
这会给玩家最好的游戏体验,但也会尽可能多地使用电量。固定帧率:
比如移动游戏更加注意游戏的体验质量,而不是最大化图像画质。 很多这种游戏都会设置最大帧率。
如果游戏循环在分配的时间片消耗完之前完成,剩余的时间它会休眠。
这给了玩家“足够好的”游戏体验,也让电池轻松了一点。
如何控制游戏速度?
- 固定时间步长,没有同步:
简单,游戏速度直接受到硬件和游戏复杂度影响。 - 固定时间步长,有同步:
较简单,可能总需要做一些同步,游戏不会运行得太快,但可能运行的太慢。 - 动态时间步长:
不建议,虽然能适应并调整,但是让游戏不确定而且不稳定。 - 固定更新时间步长,动态渲染:
最有适应性,能适应并调整,避免运行得太快或者太慢,也更复杂,需要在实现中写更多东西。
- 固定时间步长,没有同步:
更新方法模式
游戏世界管理对象集合。
每个对象实现一个更新方法模拟对象在一帧内的行为。每一帧,游戏循环更新集合中的每一个对象。
因此每个游戏实体应该封装它自己的行为,使得游戏循环保持整洁,便于增加和移除实体。
适用场景
- 游戏中含有一系列对象或系统需要同步运转
- 各个对象之间的行为基本相互独立
- 对象行为与时间相关
使用须知
- 将代码划分到一帧帧中会让它变得更加复杂
- 需要在每帧结束时存储游戏状态来让下一帧继续
- 所有对象逐帧模拟,但并非真的同步
- 在更新期间修改对象列表必须谨慎
需要设计决策的地方
将更新方法
update()
放在哪个类中?- 实体类
- 组件类:更新方法模式和组件模式享有同样的功能——让实体/组件独立更新自己。
- 委托类:比如状态模式通过改变它委托的对象来改变行为,类型对象模式可以在同类实体间共享行为。
如何处理隐藏的对象?
指的是一些暂时不需要更新的对象,比如被禁用的、在屏幕外的、或者未解锁的对象。
- 使用单个存储所有对象的集合:
处理不活跃对象会浪费时间,要么检查其“是否启用”的标识,或调用空方法。 - 两个集合,一个单独维护活跃对象,另一个维护全部实体:
使用了额外的内存管理第二个集合,当需要所有实体时,这个单独活跃对象的集合是多余的。
但在游戏对速度比内存要求更高时是值得的。 - 两个集合,一个活跃对象集合,另一个只包含不活跃实体:
得保持集合同步。当对象创建或完全销毁时(不是暂时停用),你得修改全部对象集合和活跃对象集合。
方法选择的度量标准是不活跃对象的可能数量。
数量越多就尽可能用分离的集合,避免在核心游戏循环中用到它们。- 使用单个存储所有对象的集合:
一些需要注意的
- 更新方法模式,游戏循环模式和组件模式,是构建游戏引擎核心的三位一体。
- 优先使用“对象组合”,而非“类继承”
- 继承难以维护,没人可以不拆解它们来管理庞杂的对象层次。
- 可以适度使用也不必完全禁止使用,执着于避免使用和执着于使用一样糟糕。