Game Programming Patterns: #3

date
Aug 25, 2023
slug
gpp3
status
Published
tags
Program
summary
Design Patterns Revisited: Flyweight
type
Post

引言

本篇文章源于《Game Programming Patterns》第二章第二节(游戏设计模式:享元模式
迷雾散尽,露出了古朴庄严的森林。古老的铁杉,在头顶编成绿色穹顶。 阳光在树叶间破碎成金色顶棚。从树干间远眺,远处的森林渐渐隐去。“
这样一个几句话就能描述的巨大森林,在实时游戏中实现就完全是另外一件事了。当屏幕上需要显示一整个森林时,图形程序员看到的是每秒需要送到GPU六十次的百万多边形。
我们讨论的是成千上万的树,每棵都由上千的多边形组成。 就算有足够的内存描述森林,渲染的过程中,CPU到GPU的部分是非常复杂的。
每棵树都有一系列与之相关的位:
  • 定义树干,树枝和树叶形状的多边形网格。
  • 树皮和树叶的纹理。
  • 在森林中树的位置和朝向。
  • 大小和色彩之类的调节参数,让每棵树都看起来与众不同。
如果用代码表示,那么会得到这样的东西:
这样一大堆数据,描述整个森林的对象在一帧的时间就交给GPU实在是太繁琐了。但仔细观察就能发现,就算森林中有千千万万的书,但是它们大多数其实长得差不多,有着相同的地平线网格和纹理,这意味着这些树的实例的大部分字段是一样的
notion image
这样我们可以把对象化分为两部分来更加明确地模拟。所以,我们可以将树共有的数据分拿出来分离到另一个类中:
因为没有必要在内存中把相同的网格和纹理重复一千遍。 游戏世界中每个树的实例只需有一个对这个共享TreeModel引用Tree剩下的数据则是那些实例相关的数据:
如图:
notion image
把所有的东西都存在主存里没什么问题,但是这对渲染也毫无帮助。 在森林到屏幕上之前,它得先到GPU。我们需要用显卡可以识别的方式共享数据。
为了减少需要推送到GPU的数据量,我们想把共享的数据——TreeModel——只发送一次。 然后,我们分别发送每个树独特的数据——位置,颜色,大小。 最后,我们告诉GPU,“使用同一模型渲染每个实例”。目前主流的图形API和显卡都能实现这样的功能都可以做实例渲染。
我们需要提供两部分数据流。第一部分是一块需要渲染多次的共同数据(这里就是前文说的树的网格模型和纹理)。第二部分是实例的列表以及绘制第一部分时需要使用的参数。然后调用一次渲染,绘制整个森林。

享元模式

前文用一个具体的例子简单描述了享元模式,下面我们来详细了解享元模式。享元模式,就像名字说的一样,以共享的方式高效地支持大量的细粒度的对象。通过复用内存中已存在的对象,降低系统创建对象实例的性能消耗。
当你需要共享类时使用,通常是因为有太多这种类了。实例渲染时,每棵树通过总线送到GPU消耗的更多是时间而非内存,但是基本要点是一样的。
这个模式通过将对象的数据分为两种来解决这个问题:

要点

  • 享元模式中有两种状态。内蕴状态(Internal State)外蕴状态(External State)
    • 内蕴状态,是不会随环境改变而改变的,是存储在享元对象内部的状态信息,因此内蕴状态是可以共享的。对任何一个享元对象而言,内蕴状态的值是完全相同的。在这里的例子中,是树的网格和纹理。
    • 外蕴状态,是会随着环境的改变而改变的。因此是不可共享的状态,对于不同的享元对象而言,它的值可能是不同的。在这个例子中,是每棵树的位置,拉伸和颜色。 就像这里的示例代码块一样,这种模式通过在每个对象出现时共享一份固有状态来节约内存。
  • 享元模式通过共享内蕴状态,区分外蕴状态,有效隔离系统中的变化部分和不变部分。
前面的例子来看,这更像是基础的资源共享而不是某种模式,因为在这个例子中 ,我们可以为共享状态划出一个清晰的身份TreeModel
而当共享对象没有有效定义的实体时,使用这种模式就不那么明显。 在那些情况下,这看上去是一个对象被很巧妙的分配到了各个地方。可以通过下面这个例子来理解。

更大的例子

这些树木的生长之地也需要在游戏场景里展示出来。可能有泥土、草地、丘陵、河谷很多很多不同的地形。
我们基于区块建立地表:世界的表面被划分为由微小区块组成的巨大网格。 每个区块都由一种地形覆盖。
每种地形类型都有一系列特性会影响游戏玩法:
  • 决定了玩家能够多快地穿过它的移动开销。
  • 表明能否用船穿过的水域标识。
  • 用来渲染它的纹理。
因为游戏程序需要追求效率,我们不会在每个区块中保存这些状态, 而是为每种地形使用一个枚举。
然后,世界管理巨大的网格:
为了获得区块的实际有用的数据,可以像下面这样子做:
这样是可行的,但是我们还可以进一步优化:移动开销和水域标识是区块的数据,但在这里它们散布在代码中。 这里简单地形的数据被众多方法拆开了。 如果能够将这些包装起来就好了。我们设计对象的目的就是如此
如果我们设计一个实际的地形就好了,像这样:
但是我们不想为每个区块都保存一个实例。 如果你看看这个类内部,你会发现里面实际上什么也没有, 唯一特别的是区块在哪里。 用享元的术语讲,区块的所有状态都是“固有的”或者说“上下文无关的”。
鉴于此,我们没有必要保存多个同种地形类型。 地面上的草区块两两无异。 我们不用地形区块对象枚举构成世界网格,而是用Terrain对象指针 组成网格:
每个相同地形的区块会指向相同的地形实例。
notion image
由于地形实例在很多地方使用,如果想要动态分配,它们的生命周期会有点复杂。 因此,我们直接在游戏世界中存储它们。
然后我们可以像这样来描绘地面:
现在不需要World中的方法来接触地形属性,我们可以直接暴露出Terrain对象。
用这种方式,World不再与各种地形的细节耦合。 如果你想要某一区块的属性,可直接从那个对象获得:
我们回到了操作实体对象的API,几乎没有额外开销——指针通常不比枚举大。

性能

享元较枚举性能如何?
通过解引用指针获取地形需要一次间接跳转。 为了获得移动开销这样的地形数据,你首先需要跟着网格中的指针找到地形对象, 然后再找到移动开销。跟踪这样的指针会导致缓存不命中,降低运行速度。
优化的前提必须是需求优先,性能只是游戏一个考虑方面,在这篇文章的两个例子里,享元较枚举几乎没有什么性能上的损失。享元较枚举上明显更快,但是这完全取决于内存中的事物是如何排列的。
但是享元对象肯定不会做得特别差,使用它可以获得面向对象设计的优势,同时也没有产生特别多的对象。如果你使用了枚举,又在一个枚举上面做了很多分支跳转,可以考虑一下这个模式。如果担心性能的话,你应该在代码编程到难以维护修改之前就做好性能分析。

使用场合

在以下情况都成立时,适合使用享元模式:
  • 当系统中某个对象类型的实例较多的时候。
  • 由于使用了大量的对象,造成了很大的存储开销。
  • 对象的大多数状态都可变为外蕴状态。
  • 在系统设计中,对象实例进行分类后,发现真正有区别的分类很少的时候。

参见

  • 在区块的例子中,我们只是为每种地形创建一个实例然后存储在World中。 这也许能更好找到和重用这些实例。 但是在多数情况下,你不会在一开始就创建所有享元。 如果你不能预料哪些是实际上需要的,最好在需要时才创建。 为了保持共享的优势,当你需要一个时,首先看看是否已经创建了一个相同的实例。 如果确实如此,那么只需返回那个实例。
  • 为了返回一个已经创建的享元,需要和那些已经实例化的对象建立联系,我们可以配合对象池来进行操作。
  • 当使用状态模式时,很多时候可以配合使用享元模式,在不同的状态机上使用相同的对象实例。
 

© Yuay 2022 - 2024 Next.js & Notion