org-page

static site generator

Game Programming Patterns Reading Note

Table of Contents

其实这是篇读书笔记(捂脸…

对这个话题感兴趣又愿意花时间了解的同学直接移步这里吧 http://gameprogrammingpatterns.com/contents.html

作者文笔风骚,干货满满,绝对五星好评.

1 概览: 结构,性能以及游戏.

"每当提到所谓 优雅 的方案,我们想要的往往是用尽量少的代码驱动尽量多的用例."
顾名思义,这篇文章将要介绍一些在游戏设计中常用的模式.
在展开之前先给大家几个tip:

  • 如果不是确定非常需要,不轻易进行模块化和解耦,毕竟都是有代价的嘛..
  • 可以糙快猛的响应策划需求,但需时常注意代码结构,积累太多垃圾代码就不好玩了.
  • 随时考虑代码结构的性能,而尽量把底层优化推后.
  • 重构不必过于追求完美 (处女座退散

2 一些通用设计模式

下面这几个模式大家都比较熟,也并不仅被应用于游戏开发中,在这里就不展开介绍了.

  • 命令模式(Command)
  • 享元模式(Flyweight)
  • 观察者模式(Observer)
  • 原型(Prototype)
  • 单例(Singleton)
  • 状态(State)

3 用于时序的模式 (Sequencing Patterns)

(乍一看标题可能不太好理解, 撸主当初还以为是Sequence Queue之类的东西, 真是too young..)
简而言之, 所有需要one by one处理一坨事务的行为模式都可以称为sequencing patterns. 这里介绍三种.

3.1 双缓冲 Double buffer

3.1.1 目的

使得一系列有序操作得以迅速且 平滑 的进行.

3.1.2 模式简介

了解引擎的同学应该对这个概念比较熟悉. 双缓冲应用比较广的一个场景是在Rendering阶段:

渲染好的像素首先输出到frame buffer, 然后buffer中的内容会被输出到显示设备. 如果只有一个buffer, 会出现边更新边输出的状况, 显示设备上画面可能会出现撕裂. 双缓冲和三缓冲等技术是为了避免这个问题: 使用两个buffer来储存渲染画面. 分别为 back buffer 和 front buffer. 当front buffer被输出时, 下一帧的数据被放在back buffer中. front输出完毕后交换两个buffer.

3.1.3 适用场景

  • 需要频繁的改变一些状态
  • 需要在改变时访问状态
  • 状态改变在进行时禁止被代码访问
  • 而代码不希望等到改变结束才能访问

3.1.4 安全食用指南

  • 交换操作必须是原子的
  • 它会增加内存占用

3.2 游戏循环 Game loop

3.2.1 目的

将游戏速度与用户输入及处理器速度解耦,并且使我们能够操控游戏速度.

3.2.2 模式简介

这货对很多人来说太熟悉了,就是常用的Update方法嘛.

游戏循环在游戏运行过程中以一定速率(比如60fps)持续运行. 在每一次循环中, 程序需要响应用户输入, 更新游戏状态, 以及进行图形渲染 (为了同时保证游戏的流畅性和时效性, 这些的操作一般不会互相阻塞, 而且运行频率也不同). 通过游戏循环, 我们可以方便的控制游戏的进行速度.

奉上unity的game loop作为参考,内有漂亮的图: http://docs.unity3d.com/Manual/ExecutionOrder.html

3.2.3 适用场景

一般不需要自己造轮子, 它是如此重要, 以至于游戏引擎都把它视为不可分割的一部分.

3.2.4 安全食用指南

  • 性能至关重要. 如果说一个程序花费90%的时间在10%的代码上,那Game Loop无疑就是这10%部分,所以需要额外关注它的性能.
  • 在有些平台上, GameLoop可能需要与平台内嵌的UI/Event Loop和谐相处,这听起来就很蛋疼. 好在最近流行的平台上不容易碰到这个问题,或者已经被引擎处理好了.

3.3 更新 Update

3.3.1 目的

每帧逐一通知自己所管理的物件前进一帧, 以此驱动这些物件的行为.

3.3.2 模式简介

维护一个对象集合.每个对象实现一个更新的方法,模拟一帧对象的行为,每帧游戏逐一调用每一个对象的更新方法.

3.3.3 适用场景

  • 有GameLoop的地方就可以用, 尤其当需要同步的物件特别多时.
  • 每个物件的行为模式相对独立.
  • 物件的行为依赖于时间.

3.3.4 安全食用指南

  • 把逻辑分散到众多对象中后, 代码结构会变得更离散,更复杂.
  • 处理游戏暂停变的更难了, 返回游戏时需要恢复update之前的运行状态, 比如继续之前未完的遍历.
  • 写下茫茫多的update调用之后, 往往会发现这些语句之间有时序依赖, 需要格外当心.

4 用于实现行为的模式

游戏中总是有许多特定的'行为', 比如怪物的AI, 技能, 对话等等.
读书的时候时曾经为此写过一个几百行的switch-case, 用某个小伙伴的话讲就是"你会发现滚动条越来越小最后接近一个像素", 这显然不是正确的姿势(:з」∠)_
下面三种模式可以帮助你从switch-case中解脱出来.

4.1 数据驱动 Bytecode

4.1.1 目的

使用运行在'虚拟机'上,依靠解释执行的指令实现行为, 以提供最大的灵活性.

4.1.2 模式简介

上面那句话有些formal, 在实践中应该每个项目多少都用到这个模式, 比如用lua写一部分游戏逻辑(甚至所有逻辑..
Lua是使用最广泛的register-based bytecode VM. 它除了能帮助我们将行为逻辑设计与需要编译的代码解耦, 还可以用于进行手机平台的热更新(苹果爸爸暂且认为lua属于"resource"), 所以lua几乎是现在手游的标配了.

Bytecode当然不仅限于脚本语言.推广一些,只要定义一套命令并在游戏中实现解析逻辑,能够在运行时解释执行就可以. 比如用xml定义一些简单的配置,用于制作新手引导,剧情对白等需要策划大量介入的环节.

4.1.3 适用场景

这个模式比较重度, 一个语言解释器自身的体积,和它对运行效率的影响都难以忽略. 所以仅当你有许多'行为'需要定义和维护的时候再考虑使用它.

4.1.4 安全食用指南

  • 现在是个好时代,就别自己去造轮子实现新的语言了,这可能对接盘侠们造成成吨的伤害.
  • 解释执行使得运行效率打折扣.比如众所周知在unity中使用lua会带来一些性能损失,虽然这点损失远远比不上它带来的好处.
  • 难以调试. 撸主曾经在写了一段时间lua后练就了不依赖debugger的习惯.
  • 难以信任. 保证脚本质量和追踪脚本错误都比平时麻烦一些.

4.2 子类沙箱 Subclass Sandbox

4.2.1 目的

使基类成为沙箱, 在子类中仅使用基类提供的方法定义行为.

4.2.2 模式简介

当有许多同类行为(例如许多技能)需要实现时,首先被想到的方法可能是定义一大堆类.
随之而来的问题则是大量重复冗余的代码,大量对外界api(音频,动画)的调用,难以定义统一的行为.

Subclass Sandbox模式解决这个问题的思路是:
使基类成为一个沙箱,封装一些通用的操作以及对子系统外部的调用在其中, 子类仅使用基类提供的接口来实现一系列相似的行为.

4.2.3 适用场景

这是一个非常轻量的模式,想用就用,尤其当遇到以下情景时:

  • 当你发现需要定义一大坨子类,并且基类可以提供子类所需的所有操作,且子类之间需要共享一些操作.
  • 希望把子系统与外界隔离时.(尼玛引擎升级以后XX函数被deprecated了啊! 有几百个地方要改呢要死了!

4.2.4 安全食用指南

  • 首先,这个基类沙盒看起来就很长,它很好的诠释了解耦和代码量的关系…
  • 难以改动基类, 详情见此Fragile base class
  • 如果基类实在是太庞大,可以考虑使用Component模式,把一些相对独立的操作抽离.

4.3 类型对象 Type Object

4.3.1 目的

定义一个类,这个类的每个实例可以代表一种不同类型的物体.

4.3.2 模式简介

有天策划想要实现一只怪, 程序猿马上实现了Monster.
第二天策划说"一种怪太单调了,来七八种吧", 程序猿咬咬牙, 从Monster继承了8个XXXMonster.
第三天策划又想出了80种怪, 第四天策划要把所有怪的数值调一遍…

如果程序猿还坚持用标准的oop解法肯定是要死人的, Type Object模式就应运而生了:
定义一个[容器]类, 再定义一个[类型]类. [容器]类中实现操作, [类型]类包含描述和数据. 使[容器]类拥有一个[类型]类的引用, 在需要向[类型]类查询数据. is-a变成has-a.
再进一步,[类型]类可以优化成数据驱动的, 策划就可以随心所欲的折腾了.

以上面的场景举栗, 把Monster作为'容器'类:

class Monster
{
public:
  Monster(Breed& breed)
  : health_(breed.getHealth()),
    breed_(breed)
  {}
  const char* getAttack()
  {
    return breed_.getAttack();
  }
private:
  int    health_;
  Breed& breed_;
};

另外定义'Breed'作为'类型'类:

class Breed
{
public:
  Breed(int health, const char* attack)
  : health_(health),
    attack_(attack)
  {}
  int getHealth() { return health_; }
  const char* getAttack() { return attack_; }
private:
  int health_;
  const char* attack_;
};

4.3.3 适用场景

这个模式略显厚重,毕竟多了一层'类'. 出现以下状况时可以考虑使用它:

  • 不知道策划在梦里还会开什么脑洞.
  • 希望不重新编译代码就能进行改动.

4.3.4 安全食用指南

使用这个模式后往往会把一些内容放在配置中,虽然增加了灵活度,但会失去一些操控性.

"哎呀这个怪的冒字动画能不能稍微特别一点, 要不再给那个怪额外加两个音效"
"(╯‵□′)╯︵┻━┻"

5 用于解耦的模式

码代码容易, 应付日新月异的需求难, 解耦可以让生活轻松一点点.

5.1 组件 Component

5.1.1 目的

允许一个实体应用在多种互相无耦合的域中.

5.1.2 模式简介

熟悉Unity的同学有木有亲切..没错,Unity框架的核心GameObject就是根据这个思路设计的, 可以说此Component即彼Component.

这个模式即: 一个Component可以被应用在多个实体中。为了使实体保持无耦合, 逻辑代码分散到各自的Component中, 实体蜕化为一个存放Component的集合.

5.1.3 适用场景

  • 当你希望你的类包含许多不同种类且互相独立的功能时.
  • 如果你的类开始变得非常庞大繁杂,这个模式可能能够帮到忙.
  • 当你想定义许多不同的类,他们共享一些操作, 但是仅仅靠继承满足不了需求.

5.1.4 安全食用指南

  • 复杂, 还是复杂. 使用这个模式不仅仅意味着添加许多Component类, 更要关心它们怎样聚合在一起. 现实总是残酷地, 这些Component往往不可能做到完全的独立, 总是藕断丝连的共享一些状态. 有时需要把状态放在容器中, 有时需要Component互相了解对方的细节, 需要自行权衡.
  • 使用不便. unity程序猿们一定对没完没了的GetComponent怨念颇深吧-v-

5.2 消息队列 Event Queue

5.2.1 目的

将消息/事件的发送者和接收者解耦

5.2.2 模式简介

队列按照先进先出顺序存储一系列消息,并发送通知,处理者收到通知后在队列中取出感兴趣的消息,并把它路由到特定的地方.这样可以将消息的发送者和接收者解耦.

它有许多别名,诸如“message queue”,“event loop”, “message pump”, 不陌生吧.

5.2.3 适用场景

如果仅仅是为了解耦消息发送者和接收者, 观察者模式和命令模式都可以满足需求, 而且代价更小. 但上述两种模式都是实时的, 消息队列则允许使用者挑选一个方便的时间处理事务.

5.2.4 安全食用指南

  • 一旦在使用了这个模式,它的影响面会非常大, 游戏中消息满天飞. 设计时要格外谨慎.
  • event queue通常是 全局
  • 当心无限循环. 比如:出现消息A, 函数B响应了它, 做了一些事情之后函数B又抛出了一个消息A…

5.3 服务定位 Service Locator

5.3.1 目的

提供无耦合的全局的服务.

5.3.2 模式简介

游戏引擎中有些基础功能需要随时随地被访问, 比如音频播放, 动效播放, 配置读取, 等等. 我们一般程序猿常用的手段是用单例来实现某个"Center","Manager"或者"System", 然后在游戏里随意的使用.这存在一些问题:

  • 没有很好的解耦.
  • 这些Center,Manager,System难以被整体的替换.

Service Locator模式提供了一个灵活一些的思路: 一个服务类定义了一个抽象接口的一组操作。一个具体的服务提供者实现这个接口。服务定位器(Service Locator)根据调用者需要的类型来提供对服务的访问.

此处需要祭出一些代码:

class ConsoleAudio : public Audio
{
public:
  virtual void playSound(int soundID)
  {
    // Play sound using console audio api...
  }
  virtual void stopSound(int soundID)
  {
    // Stop sound using console audio api...
  }
};

class Locator
{
public:
  static Audio* getAudio() { return service_; }

  static void provide(Audio* service)
  {
    service_ = service;
  }

private:
  static Audio* service_;
};

在给整个游戏使用之前, 首先需要通过外界来'注册'一个服务, 这个服务可以随时更换, 只需实现接口即可 (所以完全可以实现一个什么也不干的null service

ConsoleAudio *audio = new ConsoleAudio();
Locator::provide(audio);

// 使用者只需知道协议的接口
Audio *audio = Locator::getAudio();
audio->playSound(VERY_LOUD_BANG);

另外一个更加喜闻乐见的例子: Unity的GetComponent<>()方法

5.3.3 适用场景

慎用. 许多子系统几乎不需要"灵活的替换", 那么一个单例就足够了, 无须把它们隐藏在层层叠叠的代码之后. 反之, 当一个系统需要频繁替换或拆卸时, 不妨试试这个模式…比如打Log.

5.3.4 安全食用指南

  • 灵活性和复杂度之间的权衡是永恒的话题, 有时单例足够满足需求.
  • 需要确保服务在合适的地方被初始化和配置.
  • 服务必须足够健壮, 要有在任何环境下, 被任何人调用的觉悟.

6 用于优化性能的模式

性能'优化'是个永恒的课题,我们最终的目的是让玩家的体验更加流畅,下面介绍四个以空间换时间的方法.

6.1 局部数据 Data Locality

6.1.1 目的

根据CPU缓存的特性有序的存放数据, 以达到加快内存访问的目的.

6.1.2 模式简介

现在CPU都带有缓存,CPU缓存一次性把内存中临近的一整块数据读取进来.如果我们利用这个特性,在连续的内存中存放待处理的数据,将有助于提升处理速度.

6.1.3 适用场景

  • 当你真正遇到性能瓶颈的时候再考虑使用优化手段,尤其慎用在一些调用频率不高的代码身上.毕竟对项目整体来说,做这样的优化可能性价比非常低.
  • 先确定病因,先确定病因,先确定病因. 使用Data Locality之前务必确定导致你性能瓶颈的原因是缓存命中率低,否则就是在瞎折腾.
  • 我们在整个编码过程中都应当考量缓存友好性,等到问题累积到一定程度而爆发出来后就比较难办了.

6.1.4 安全食用指南

每当我们传递一个指针的时候就意味着跨越内存, 也就意味着cache miss…..(想掀桌吗
缓存友好和我们平时惯用的一些方便的语言特性是杠杆的两端,没有什么捷径. 所以这个模式性价比真的难说…

6.2 脏标志 Dirty Flag

6.2.1 目的

通过延后一些开销较大的计算/同步操作,避免资源浪费.

6.2.2 模式简介

假如有一批持续更新的数据源,另有一些开销比较大的处理过程与数据源同步更新.最直接的做法当然是每一帧都为全部数据源执行处理过程,但对于数据源变化不频繁的场景来说这样做比较浪费. 那么我们可以为产生变动的一部分数据源设置[脏标志],外部的处理过程看到脏标志时才执行,否则依旧使用缓存的结果.

一个典型的例子就是游戏UI更新.大部分情况下UI是静止的,利用脏标志模式来保证每帧只更新产生变动的UI元素无疑是更好的做法.

6.2.3 适用场景

  • 需要根据数据源进行一些比较耗时的计算/同步操作时.
  • 数据源的更新频率远超所需时

6.2.4 安全食用指南

在四个优化模式中,脏标志算是影响面比较小的一个了.使用时应注意以下问题:

  • 它可能没法根治卡顿现象.虽然我们可以避免一部分冗余的操作,但无法提高计算/同步的效率.所以当真正需要同步时,卡顿依旧可能发生.
  • 你得保证设置flag的逻辑正确.(闭上眼睛随便感受一下
  • 这是以时间换空间的解决方案,那些无需更新的内容需要存放在内存中.

6.3 对象池 Object Pool

6.3.1 目的

从一个固定的池中获取可重用的对象而不是单独分配和释放它们,以提高性能.

6.3.2 模式简介

这个模式大家应该都不陌生,简直是居家旅行必备良药:
定义一个[对象池]以存放一系列可以重用的对象.这些对象上带有状态,以标识"使用中"或"空闲". 当你需要使用对象时,从对象池中取出,并把对象标志位设置成"使用中".使用结束后则把标志位设置成"空闲"并放回对象池. 对象池负责初始化和销毁这些可重用的对象.

6.3.3 适用场景

  • 当你需要频繁的创建和摧毁一类对象时
  • 在堆上创建这类对象比较慢,或者容易导致内存碎片.
  • 尤其当对象中包含诸如纹理,网络连接或数据库等昂贵但可重用的成员时.

6.3.4 安全食用指南

  • 池中的对象数量是恒定的,占用内存也是恒定的.这带来的好处是控制了内存使用规模,缺点是池中的对象可能不够用.
    遇到对象池用尽时,有以下处理方案,酌情使用:
    1. 调整对象池初始大小
    2. 什么都不做,用完就用完了
    3. 强制回收一些正在使用的对象
    4. 动态增加对象池大小
  • 每当回收对象时,程序猿要手动重置对象的状态,这一步通常比较繁琐.
  • 空闲的对象一直活在内存中(空间换时间嘛

6.4 空间索引 Spatial Partition

6.4.1 目的

通过位置信息来放置和索引对象,以提升访问速度.

6.4.2 模式简介

把一组拥有坐标信息的对象存储在一个根据空间坐标组织而成的数据结构中. 这个数据结构可以快速的查询与目标点临近的对象。当一个物体的位置发生变化时,更新这个数据结构,以维持快速查找能力.

此模式可以把O(n)或O(n2)的查询操作复杂度降低到更低,以此提升游戏性能.

6.4.3 适用场景

Spatial Partition的顾忌比较少, 只要你需要经常查询一些拥有空间坐标的物体时即可使用. 比如地图上行走的怪物,静态的道具等等.
然而当待查询的物体很少时,使用这个模式可能得不偿失,毕竟跟踪位置变化所需的计算量可能比粗暴的查询更大.

6.4.4 安全食用指南

  • 注意衡量更新数据结构位置信息所需的计算量对你来说是否合算.毕竟更新操作往往是逐帧的,而查询操作的频率有时低一些.
  • 数据结构需要一些额外的内存来储存位置信息.

===================_(:з」∠)_===================

(写到最后感到我简直是把原书的目录给翻译了一遍, 忧桑…)
撸主修为尚浅,写这个话题真是鸭梨山大,况且本文里提到的一些模式自己从来没用过(惭愧. 如有同学发现问题恳请尽快指正(ง •̀_•́)ง

虽然如此, 还是希望这篇文章能够在半小时内给低年级同学一个关于游戏模式的大概印象, 或者帮助中高年级同学整理一下知识结构.

最后说句自己的感受:设计模式打嘴炮容易,然而应用到日常搬砖工作里比较难.这需要在平时繁忙之余,以一定的意志力保持思考,克服惯性,克服懒,共勉.

Comments

comments powered by Disqus