Skip to content

Latest commit

 

History

History
558 lines (386 loc) · 25.4 KB

051.Design-pattern-in-game-develop.md

File metadata and controls

558 lines (386 loc) · 25.4 KB
title date draft isCJKLanguage
《游戏编程模式》
2021-12-07 11:44:19 +0800
false
true

阅读链接: https://gpp.tkchu.me/

没有正确的答案,只有不同的错误

读后感写在前面

这本书是12月7号开始读,到今天1月9号,第一遍读完,就当是看了一个月吧,收获实在是太多了,强烈推荐没看过这本书的都看一遍。

印象比较深的设计模式有:

  • 命令模式:重要的目的是解耦
  • 观察者模式:它并不是那么完美无缺,有很多平时没有注意到的缺点。工程师们也发展出了诸如数据流编程、函数反射编程等新的方式来尝试替代
  • 单例模式:能不用就不要用,对任何可以全局访问的变量、数据都要慎之又慎
  • 状态模式:看完这个章节,然后我就刚好有个机会——用状态模式重构了游戏中的玩家血条显示逻辑,感觉顺畅无比,原来的if-else简直是魔鬼
  • 游戏循环、更新方法:彻底的讲清楚了逻辑与渲染分离,以及渲染插值
  • 事件队列:事件虽好,但也要注意全局的事件会很危险
  • 数据局部模式:CPU缓存对性能的提升比我想象中的还要大,以至于在性能关键的地方,内存拷贝带来的性能损耗要给CPU缓存让路。
  • 空间分区:四叉树也好、方格划分也好,背后其实是有一套更加抽象的理论的。

看得不太懂,或者尽管看了书,但也找不到合适的引用场景的模式:

  • 原型模式:没看太懂
  • 双缓冲模式:出了在渲染方面,还有其他的应用场景么?感觉应用场景比较少
  • 字节码:太复杂了,看到一半没看了
  • 类型对象:没看太懂

重访设计模式

我认为有些模式被过度使用了(单例模式), 而另一些被冷落了(命令模式)。 有些模式在这里是因为我想探索其在游戏上的特殊应用(享元模式观察者模式)。 最后,我认为看看有些模式在更广的编程领域是如何运用的是很有趣的(原型模式状态模式)。

命令模式

Command,将调用与被调用者解耦

  • 修改输入按钮的映射

  • Player与AI的输入方式虽然不同,但底层运作方式一般情况是一样的

    Command执行时可以传入角色,这样Command就可以让玩家操作任意的角色,包括玩家和AI

    Command* command = inputHandler.handleInput();
    if (command)
    {
      command->execute(actor);
    }
  • Command类内部可以自己实现execute与undo两个方法,提供命令的回滚实现

  • Command抽象,可以使得Command的来源可以是网络

在函数式编程中,Command其实是一个有状态的函数:闭包函数

在OOP中,Command则被封装成一个class用于保存状态

function makeMoveUnitCommand(unit, x, y) {
  var xBefore, yBefore;
  return {
    execute: function() {
      xBefore = unit.x();
      yBefore = unit.y();
      unit.moveTo(x, y);
    },
    undo: function() {
      unit.moveTo(xBefore, yBefore);
    }
  };
}

享元模式

重复的对象只用一个对象即可,游戏开发场景常见用于,树的建模数据只有一份,然后会有多分树的高度、样式、颜色的类,这样就可以用最小的内存渲染出一个森林

观察者模式

  • C#语言把观察者模式内置到语言特性上了(eventdelegate关键字)
  • 一般游戏发中的观察者模式是同步执行的,这意味着所有观察者的通知方法返回后,被观察者才会继续自己的工作。观察者会阻塞被观察者。要小心在观察者中混合线程和锁。
  • 可以用事件队列来做异步通知。
  • 观察同一被观察者的两个观察者互相之间不该有任何顺序相关。 如果顺序确实有影响,这意味着这两个观察者有一些微妙的耦合。
  • 如果两个完全不相干的领域(比如物理系统和成就系统)需要互相交流,那么就适合观察者模式,如果对一个需求要同时理解两个模块,那么这两个模块之间就不适用观察者模式。

引用书中一段话,我觉得很关键

设计模式源于1994。 那时候,面向对象语言正是热门的编程范式。 每个程序员都想要“30天学会面向对象编程”, 中层管理员根据程序员创建类的数量为他们支付工资。 工程师通过继承层次的深度评价代码质量。

同一年,Ace of Base的畅销单曲发行了三首而不是一首,这也许能让你了解一些我们那时的品味和洞察力。

观察者模式在那个时代中很流行,所以构建它需要很多类就不奇怪了。 但是现代的主流程序员更加适应函数式语言。 实现一整套接口只是为了接受一个通知不再符合今日的美学了。

它看上去是又沉重又死板。它确实又沉重又死板。 举个例子,在观察者类中,你不能为不同的被观察者调用不同的通知方法。

这就是为什么被观察者经常将自身传给观察者。 观察者只有单一的onNotify()方法, 如果它观察多个被观察者,它需要知道哪个被观察者在调用它的方法。

现代的解决办法是让“观察者”只是对方法或者函数的引用。 在函数作为第一公民的语言中,特别是那些有闭包的, 这种实现观察者的方式更为普遍。

今日,几乎每种语言都有闭包。C++克服了在没有垃圾回收的语言中构建闭包的挑战, 甚至Java都在JDK8中引入了闭包。

举个例子,C#有“事件”嵌在语言中。 通过这样,观察者是一个“委托”, (“委托”是方法的引用在C#中的术语)。 在JavaScript事件系统中,观察者可以是支持了特定EventListener协议的类, 但是它们也可以是函数。 后者是人们常用的方式。

如果设计今日的观察者模式,我会让它基于函数而不是基于类。 哪怕是在C++中,我倾向于让你注册一个成员函数指针作为观察者,而不是Observer接口的实例。

这里的一篇有趣博文以某种方式在C++上实现了这一点。

事件系统和其他类似观察者的模式如今遍地都是。 它们都是成熟的方案。 但是如果你用它们写一个稍微大一些的应用,你会发现一件事情。 在观察者中很多代码最后都长得一样。通常是这样:

1. 获知有状态改变了。
2. 下命令改变一些UI来反映新的状态。

就是这样,“哦,英雄的健康现在是7了?让我们把血条的宽度设为70像素。 过上一段时间,这会变得很沉闷。 计算机科学学术界和软件工程师已经用了很长时间尝试结束这种状况了。 这些方式被赋予了不同的名字:“数据流编程”,“函数反射编程”等等。

即使有所突破,一般也局限在特定的领域中,比如音频处理或芯片设计,我们还没有找到万能钥匙。 与此同时,一个更脚踏实地的方式开始获得成效。那就是现在的很多应用框架使用的“数据绑定”。

不像激进的方式,数据绑定不再指望完全终结命令式代码, 也不尝试基于巨大的声明式数据图表架构整个应用。 它做的只是自动改变UI元素或计算某些数值来反映一些值的变化。

就像其他声明式系统,数据绑定也许太慢,嵌入游戏引擎的核心也太复杂。 但是如果说它不会侵入游戏不那么性能攸关的部分,比如UI,那我会很惊讶。

与此同时,经典观察者模式仍然在那里等着我们。 是的,它不像其他的新热门技术一样在名字中填满了“函数”“反射”, 但是它超简单而且能正常工作。对我而言,这通常是解决方案最重要的条件。

原型模式

单例模式

保证一个类只有一个实例,并且提供了访问该实例的全局访问点。

为什么我们使用它

  • 惰性加载:

    如果没有人用,就不必创建实例;运行时初始化,可以获取运行时的一些环境变量

  • 可继承单例:

为什么我们后悔使用它

  • 它是一个全局变量(全局变量万恶之源)
    • 理解代码更加困难:读取单例里的一个变量时,你要遍历整个代码库去发现这个变量究竟在什么时候读写的。
    • 促进了耦合的发生:因为可以全局调用,那么在一些不该调用的地方,其实是可以调用的,特别是团队来了新人,很容易出现这种耦合的调用。
    • 对并行不友好:全局变量意味着每个线程都能看到并且能访问的内存,却不知道其他线程是否在使用。这容易造成死锁、Race Condition以及其他难以解决的线程同步问题。
  • 它能在你只有一个问题的时候,解决两个

    回顾单例模式的描述:

    保证一个类只有一个实例,并且提供了访问该实例的全局访问点。

    这个【并且】就很奇怪,保证一个类只有一个实例是有用的,提供访问某个实例的全局访问点也是有用的,但这两个并不应该同时声明。

    • 保证单例可以使用assert() 强行保证一个类只会new 一次
    • 全局访问可以把类声明为纯static + static方法
  • 惰性加载可能在性能关键点被调用

那咋办?

  • 没有必要实现不必要的所谓的Manager类
  • 将类限制为单一实例,可以用assert或者只是一个简单的bool值,在构造方法里判断
  • 方便的访问方法
    • 通过构造函数传进来
    • 从基类获得
    • 从已经是全局的东西中获取:比如把Log、FileSystem等都放到一个叫Game或者World的静态类中,这样只有一个是全局可见的。少部分获取不到Game::Log的再考虑直接调用Log
    • 从服务定位器中获得:这个在另一个篇章中单独介绍

状态模式

状态模式,与有限状态机、分层状态机、下推自动机不是一回事儿

有限状态机

Fsm是状态模式的具体实现,但其实有时候声明几个Enum来做Enum之间的跳转声明,也是可以实现状态模式的

Fsm代码:

class Heroine
{
public:
  virtual void handleInput(Input input)
  {
    state_->handleInput(*this, input);
  }

  virtual void update()
  {
    state_->update(*this);
  }

  // 其他方法……
private:
  HeroineState* state_;
};

为了“改变状态”,我们只需要将state_声明指向不同的HeroineState对象。 这就是状态模式的全部了。

❗ 这看上去有些像策略模式和类型对象模式。 在三者中,你都有一个主对象委托给下属。区别在于意图

  • 在策略模式中,目标是解耦主类和它的部分行为。
  • 在类型对象中,目标是通过共享一个对相同类型对象的引用,让一系列对象行为相近。
  • 在状态模式中,目标是让主对象通过改变委托的对象,来改变它的行为。

状态的入口行为和出口行为

在状态的入口行为(Enter())实现改变状态动画,当进入这个状态时,不必关心是从哪个状态转换来的。

也可以扩展并支持出口行为(Exit()

Fsm优缺点

  • 状态机通过使用有约束的结构来清理杂乱的代码。你只需要一个固定状态的集合,单一的当前状态,和一些硬编码的转换。
  • Fsm不是图灵完全的。自动理论用一些抽象模型描述计算,每种都比之前的复杂。图灵机是其中最具有表现力的模型之一。
  • 如果你需要为更复杂的系统使用状态机,比如游戏AI,你会撞到这个模型的限制上。

并发状态机

英雄在拿枪的基础上,可以跑、跳、跳斩等,但在做这些的同时也要能开火。

  • 将英雄【做的】和【携带的】分成两个状态机。

  • 有时候需要让状态机在读取到输入后销毁输入,这样其他状态机就不会收到了。这样能阻止两个状态机同时响应。

  • 状态有时候需要交互,比如希望跳跃时不能开火,或者持枪时不能跳斩攻击等。可以加一些 if-else,这不是最优雅的解决方案,但可以搞定工作。

分层状态机

充实一下英雄的行为,在站立、奔跑和滑铲状态中,按B跳、按下蹲。这种【按B跳、按下蹲】被称作父状态(OnGround),站立、奔跑、滑铲算作子状态

  • 状态可以有父状态。当一个事件进来,如果子状态没有处理,它就会交给链上的父状态。
  • 可以用OOP的重载与继承来实现分层状态,顶一个一个父状态,再让各个子状态继承它。
  • 也可以用状态栈的形式来表示当前状态的父状态链。栈顶的状态是当前状态,在它下面是它的直接父状态,然后是父状态的父状态,以此类推。

下推自动机

  • 记录有限状态机的历史概念。比如设计结束后回到之前的状态。
  • 用栈来记录状态的历史,这种数据结构被成为下推自动机。
  • 将新状态压入栈中,当前的状态总是在栈顶,所以你能转到新状态。但之前的状态待在栈中而不是销毁
  • 可以弹出最上面的状态。这个状态会被销毁,它下面的状态成为新状态

Q:跳跃开火结束时,人物已经站在地面上了,这种时候人物应该回到地面状态

A:这里混淆了并发状态机与下推自动机,应当是并发状态机,各自有各自的下推自动机。

何时使用Fsm

即使状态机有以上那么多种类的扩展,它们还是很受限制。这让今日游戏AI转向了行为树和规划系统(GOAP)

有限状态机在一下情况有用:

  • 你有个实体,它的行为基于一些内在状态
  • 状态可以被严格的分割为相对较少的不相干项目
  • 实体响应一系列输入或事件

状态机不仅可以用户玩家状态,也可以用于处理玩家输入、导航菜单界面、文字分析、网络协议以及其他异步行为。

序列模式

双缓冲模式

意图:用序列的操作模拟瞬间或者同时发生的事情。

定义

以计算机渲染帧为例,系统拥有两个帧缓冲,其中一个代表现在的帧,也就是显卡读取的那一个,GPU想什么时候读取就什么时候读取,不会使画面出现撕裂。

同时我们的渲染代码正在写入另一个帧缓冲。当渲染代码完成了场景的绘制,它将通过交换缓存来切换,告诉图形硬件从第二块缓存中读取而不是第一块。

何时使用

这是那种你需要它时自然会想起的模式:

  • 我们需要维护一些被增量修改的状态
  • 修改到一半时,状态可能会被外部请求
  • 我们想要防止请求状态的外部代码知道内部的工作方式
  • 我们想要读取状态,而且不想等着修改完成

缺陷

  • 交换缓存需要时间,而且必须是原子的。通常就是交换一下指针。
  • 保存两个缓冲区增加了内存的使用

应用

  • 用双缓存帧保证图形渲染时不会撕裂
  • 负责修改的代码试图访问正在修改的状态。特别是实体的物理部分和AI部分,实体在相互交互。双缓冲在那里也十分有用。

比如实体的更新顺序是1、2、3,但3在当前帧的最后修改了1的内容,如果我们会在每一帧的最后清除实体的所有状态,那么3对1的修改则永远不会触发1的响应(因为1的状态被重置了)。这种就可以使用双缓冲,3对1的修改是写到下一帧的缓冲区,然后下一帧的开始交换缓冲区,这样,不论1、2、3的更新顺序是怎么样的,都不会影响互相之间的交互。

游戏循环

一个游戏循环,需要无阻塞的处理玩家输入更新游戏状态渲染游戏,它追踪时间的消耗并控制游戏的速度

我们这里谈到的循环是游戏代码中最重要的部分。 有人说程序会花费90%的时间在10%的代码上。 游戏循环代码肯定在这10%中。 你必须小心谨慎,时时注意效率。

游戏循环主要分四种:

固定时间步长,没有同步

while (true)
{
  processInput();
  update();
  render();
}

指定每一次循环的deltaTime,但是并不与真实时间同步,意味着机器性能越好,循环执行得越快,机器性能越差,循环执行得越差,但它在逻辑上是指定每一次循环的deltaTime的。

  • 简单,几乎是它唯一的优点
  • 游戏速度直接受到硬件和游戏复杂度影响。

固定时间步长,有同步

while (true)
{
  double start = getCurrentTime();
  processInput();
  update();
  render();

  sleep(start + MS_PER_FRAME - getCurrentTime());
}

加一个与真实时间的同步

  • 简单
  • 电量友好。在移动游戏开发中,不必要消耗的电量,通过简单的休眠几个毫秒,就节省了电量
  • 游戏不会运行得太快
  • 游戏可能运行得太慢。如果一次循环花了太多时间更新或者渲染,那么就会变慢

动态时间步长

double lastTime = getCurrentTime();
while (true)
{
  double current = getCurrentTime();
  double elapsed = current - lastTime;
  processInput();
  update(elapsed);
  render();
  lastTime = current;
}

其实就是动态的把elapsed传到每一个Update中,让代码中可以写这样的实现

var deltaMove = Speed * elapsed;

大多数(作者认识的)游戏开发者反对这个解决方案,不过记住为什么反对它是有价值的:

  • 能适应并调整,避免运行得太快或者太慢。如果游戏不能追上真实的时间,这个方法可以用越来越长的时间步长更新,直到追上。
  • 让游戏不确定而且不稳定。这是真正的问题。在物理和网络部分使用动态时间步长则会遇见更多的困难。

固定时间步长,动态渲染

double previous = getCurrentTime();
double lag = 0.0;
while (true)
{
  double current = getCurrentTime();
  double elapsed = current - previous;
  previous = current;
  lag += elapsed;

  processInput();

  while (lag >= MS_PER_UPDATE)
  {
    update();
    lag -= MS_PER_UPDATE;
  }

  render();
  // render(lag / MS_PER_UPDATE); // 这种做法可以让渲染更流畅
}
  • 能适应并调整,避免运行得太快或太慢。只要能实时更新,游戏状态就不会落后于真实事件。如果玩家用高端机器,它会回以更平滑得游戏体验。
  • 更复杂。主要负面问题是需要在实现中写更多东西。你需要将更新的时间步长调整得尽可能小来适应高端机,同时不至于在低端机上太慢。

更新方法——Update()方法

通过每次处理一帧的行为模拟一系列独立对象。

每个游戏实体应该封装它自己的行为(update()行为)

游戏世界管理对象集合。每个对象实现一个update()方法模拟对象在一帧内的行为。每一帧,游戏循环更新集合中的每一个对象。

多用“对象组合”,而非“类继承”。

行为模式

字节码

子类沙箱

用一系列由基类提供的操作定义子类中的行为

  • 用一个基类耦合的调用其他子系统,而每一个子类只依赖这个基类,以达到子类对其他子系统的解耦
    • 基类提供子类需要的所有操作
    • 最小化子类和程序的其他部分的耦合
  • 这种情况继承链不深,但是会有很多类,这种继承树是比较好处理的
  • 需要权衡那些放基类哪些放子类
  • 把大多数行为都在基类中实现,如果发现基类的代码正在变成屎山,可以考虑分离(@see:组件模式)

💡类似于UCC插件中,Ability的基类里实现了很多方法,然后Ability的实现只需要发送事件或者调用那些方法即可,不需要去真正的关注他们的实现。

类型对象

创造一个类A来允许灵活的创造新“类型”,类A的每个实例都代表了不同的对象类型

  • 如果每个怪物都是一个类,会导致随着怪物的种类增加而增加代码量,修改设计者(策划)修改起来也很麻烦

  • 把【怪物】和【怪物种类】分成两个类

    我理解是怪物的行为和怪物的属性分成两个类,然后怪物的属性可以通过数据定义,设计者也可以很方便的调整

也有缺点

  • 对象的实际类型是以数据形式表示的,需要手动去追踪它
  • 很难定义复杂的类型的行为,或者要为每个数据写逻辑来判定,比起重载+ 写代码显得麻烦(可以用解释器和字节码模式来真正的数据中定义行为)

解耦模式

当我们说“解耦”时,是指修改一块代码一般不会需要修改另外一块代码。

组件模式

允许单一的实体跨越多个领域而不会导致这些领域彼此耦合。

  • 我对这个模式的理解就是把各个功能拆解成不同的类(称之为组件),用依赖注入的形式初始化和调用,而且最好是以interface的形式做依赖注入
  • 一般而言,引擎越大越复杂,你就越想将组件划分得更细
  • 对象如何获取组件:可以内部初始化,也可以依赖注入(推荐后者)
  • 组件之间如何通信?
    • 完美解耦的组件不需要考虑这个问题,但在真正的实践中行不通。
    • 通过修改容器对象的状态(保持了组件解耦,但同时需要将组件分享的任何数据存储在容器类中,并且让组件的通信基于组件运行顺序)
    • 通过组件之间的相互引用(简单快捷,但有耦合,简单用一用可以)
    • 通过发送消息
    • 有些不同领域仍然紧密相关。比如动画和渲染,输入和AI,或物理和例子,这些组件之间直接互相引用也许会更加容易。

💡Unity核心架构中GameObject类完全根据这样的原则设计Components

事件队列

解耦发出消息或事件的时间和处理它的时间

  • 其实就是消息队列
  • 用于GUI时间循环,对GUI的输入存放到一个队列里,然后应用程序从队列里取数据运行
  • 用于中心事件总线(需要注意的是,事件队列不需要在整个游戏引擎中沟通。在一个类或者领域中沟通就足够有用了。)

服务定位器

提供服务的全局接入点,避免使用者和实现服务的具体类耦合

平平无奇的IoC模式

优化模式

介绍优化性能的姿势

数据局部性

合理组织数据,充分使用CPU的缓存来加速内存读取

  • 平平无奇的CPU缓存友好的设计模式

    // 更新物理
    for (int i = 0; i < numEntities; i++)
    {
    	physicsComponents[i].update();
    }
    
  • 连续数组(略)

  • 打包数据:

    并不是所有Entity都需要更新(以粒子系统为例,不活跃的粒子不执行Update)

    for (int i = 0; i < numParticles_; i++)
    {
      if (particles_[i].isActive())
      {
        particles_[i].update();
      }
    }
    

    但是这样并不是百分百CPU缓存友好,可以将粒子数据排序,将活跃的粒子排在前面,不活跃的排在后面

    但这不意味着每一帧都执行排序,而只需要激活粒子时,将这个粒子与第一个不活跃的粒子交换内存,反激活粒子只需要把这个粒子与最后一个激活的粒子交换内存

    💡交换内存的消耗并没有想象中的那么大,比起解析指针带来的消耗+CPU缓存不命中带来的消耗,交换内存的性能消耗更小

  • 冷/热分割

    常用的数据直接引用并在Update中执行,不常用的数据用指针来引用,以达到冷热分割,但其实也可以把不常用的数据分割到另一个Component中

脏标识模式

平平无奇的脏标

对象池模式

平平无奇的对象池

空间分区

将对象根据它们的位置存储在数据结构中,来高效地定位对象。

对于一系列对象,每个对象都有空间上的位置。 将它们存储在根据位置组织对象的空间数据结构中,让你有效查询在某处或者某处附近的对象。 当对象的位置改变时,更新空间数据结构,这样它可以继续找到对象。

  • 我理解是空间数据结构,比如四叉树等
  • 空间分区是为了将O(n)或者O(n²)的操作降到更加可控的数量级,但如果n足够小,也许不需要担心这个
  • 空间划分的数据结构有三种:
    • 划分与对象无关的,也即划分的格子是一开始就固定好的,比如梭哈的对空间做网格的划分。优点是快,缺点是可能划分不均匀。
    • 划分适应对象集合,也即每个空间包含数量相近的对象,比如k-d树。优点是划分均匀,缺点是自适应会稍慢。
    • 划分层次与对象相关,比如四叉树,算是上述两者优点的结合,比较平衡的一种划分,缺点是按层次的划分会多一些内存