博客
关于我
强烈建议你试试无所不能的chatGPT,快点击我
《游戏程序设计模式》 1.2 - 享元模式
阅读量:7006 次
发布时间:2019-06-27

本文共 5533 字,大约阅读时间需要 18 分钟。

hot3.png

    浓雾消散,一片雄伟古老、勃勃生机的森林浮现眼前。不可计数的古铁杉高耸入云形成一座巨大的绿色教堂。阳光透过枝叶构成的彩绘玻璃穹顶分散成金色的迷雾。在巨大的树干中间,你可以看到广阔的森林延伸的远方。

    这就是作为游戏开发者想象出的一种虚拟世界的设定,像这种场景经常会用到一种设计模式,这种设计模式的名字不会更适当:享元模式。

Forest for the Trees

    我可以用几句话就描述了广阔的森林,但是实际上在实时游戏中实现它们就是另外一回事了。当你把整个森林填充到屏幕时,图形程序员看到的是数以万计的多边形,他们会以每秒60次的速度把这些多边形送进GPU。我们正在讨论的数以千计的树木,每一棵都具有包含数千个多边形的几何体。即便你有足够的内存来描述森林,为了渲染它,它的数据不得不占用总线从CPU传到GPU。

    每棵树都有一堆相关的数据:

  • 一个多边形构成的网格,定义树干、树枝和树叶。

  • 树皮和树叶的纹理贴图。

  • 它在森林的位置和朝向。

  • 一些调整参数像尺寸和色调,以让每棵树看起来不一样。

    如果你想用代码把它勾画出来,那么会像这样:

class Tree{private:  Mesh mesh_;  Texture bark_;  Texture leaves_;  Vector position_;  double height_;  double thickness_;  Color barkTint_;  Color leafTint_;};

    这有很多数据,而且网格和纹理数据尤其地大。一个包含这些数据的森林太大了,根本不可能在一帧之内传给GPU。很幸运,有个历史悠久的方法来解决这个问题。

    关键点就是即使有成千上万棵树,但是它们都长得差不多。它们很可能使用相同的网格和纹理。这意味着这些对象实例的大部分字段是相同的。

    31162846_uosL.png

    我们可以这样构建模型,显式地把对象分成两半。首先,我们把所有树的共同部分提取出来,放到一个单独的类中:

class TreeModel{private:  Mesh mesh_;  Texture bark_;  Texture leaves_;};

    游戏只需要单独的一份这种数据,因为没有理由让相同的网格和纹理数据在内存中存储上千份。然后,游戏中的每一棵树都有一个指向共享TreeModel的指针。还留在Tree中的数据就只是那些特定于实例的状态变量:

class Tree{private:  TreeModel* model_;  Vector position_;  double height_;  double thickness_;  Color barkTint_;  Color leafTint_;};

    你可以看到它是这样:

    31162847_FNEe.png

    这是很好的对于在主内存中存储数据,但是对于渲染没有帮助。在森林出现在屏幕上之前,必须先把数据传到GPU。我们需要以GPU能够理解的方式表达资源共享。

A Thousand Instances

    为了把要传给GPU的数据最小化,我们想传递共享数据-TreeModel-只传一次。然后,我们再单独传递每棵树的独有的部分,位置、色调、缩放大小。最后我们告诉GPU,使用同一个TreeModel来渲染所有的树。

    幸运的是,现代的图形api和图形卡完全支持上述操作。具体细节要求比较高,已经超出了本书的讲解范围,Direct3D与OpenGL都可以做这件事称为多实例渲染(instanced rendering)。

    在两种api中,你都是传递两个数据流。第一个是要被渲染很多次的公共数据-上面树木的例子中的网格和纹理数据。第二个就是一串实例独有的参数,那些用来改变第一个公共数据的参数。用一个draw call,整个森林就出现了。

the flyweight pattern

    现在我们已经有了一个具体的例子,那么,我就可以带你走进这个模式了。享元模式,就像他的名字表明的,会用在当你有大量的对象实例,想减少数据量的情况。

    这个模式可以解决这个问题通过把对象分成两部分。第一部分是并不是特定于实例,而是所有实例都具有的相同的数据。“四人帮”称其为“固有的”数据,我更喜欢称其为“上下文无关”的数据。在这里的例子中,就是树的网格和纹理数据。

    另一个部分就是“外在的”,这部分数据时特定于实例的。在这个例子中,就是树的位置,缩放和色调。就像上面的代码,这个模式可以节省大量内存通过使所有的实例共享一份“固有的”数据。

    从目前我们的例子看来,这几乎很难称为一种模式。部分原因是在这个例子中,我们可以为共享的数据定义一个清晰的本体:TreeModel。

    我发现这个模式不是很明显(而且更聪明)当使用在那种你很难为共享数据定义一个清晰的本体的情况。在这种情况下,感觉就像一个对象神奇地在同一时间出现在不同的地方。让我来展示另一个例子。

A place to put down roots

    游戏中的长着树的土地也需要实现。土地上可能会有成块的草地,沙地,山丘,湖泊,河流等所有你能想到的地形。我们会把地面做成分块的:世界的表面就是这些分块组成的大网格。每一个分块覆盖着一种地形。

    每一种地形都一些属性来影响游戏:

  • 移动阻力,决定着玩家能多快地通过这块地形。

  • 一个标志,标明这块地形是否有水能不能让船通过。

  • 一个纹理,用来绘制这块地形。

    由于我们游戏开发者对效率很偏执,所以不可能,我们会为每一块地形都存一份属性数据。一个通用的方法是把地形用枚举分类:

enum Terrain{    TERRAIN_GRASS,    TERRAIN_HILL,    TERRAIN_RIVER    // Other terrains...};

    然后世界定义一个包含地形巨大的网格:

class World{private:  Terrain tiles_[WIDTH][HEIGHT];};

    为了获取一个地形实际的数据,我们会这么做:

int World::getMovementCost(int x, int y){  switch (tiles_[x][y])  {    case TERRAIN_GRASS: return 1;    case TERRAIN_HILL:  return 3;    case TERRAIN_RIVER: return 2;      // Other terrains...  }}bool World::isWater(int x, int y){  switch (tiles_[x][y])  {    case TERRAIN_GRASS: return false;    case TERRAIN_HILL:  return false;    case TERRAIN_RIVER: return true;      // Other terrains...  }}

    你做到了。这个可以工作,但是我觉得他很丑。我认为应该把阻力跟是否有水作为地形的属性数据,但是现在他们被写死到代码里了。更糟糕的是,地形的属性数据被分散到不同的函数中了。把这些属性封装到一起才是更好的做法。毕竟,这是对象设计的目的。

    如果我们有一个实际的terrain类,将是极好的,就像这样:

class Terrain{public:  Terrain(int movementCost,          bool isWater,          Texture texture)  : movementCost_(movementCost),    isWater_(isWater),    texture_(texture)  {}  int getMovementCost() const { return movementCost_; }  bool isWater() const { return isWater_; }  const Texture& getTexture() const { return texture_; }private:  int movementCost_;  bool isWater_;  Texture texture_;};

    但是我们不想为每一块地形都产生一个实例。如果你仔细观察这个类,你就会发现它没有与分块的位置相关的特定的状态。在享元模式的术语中,所有Terrain类的状态都是“固有的”或者“上下文无关的”。

    知道了这个,那么就没必要为每一种地形产生多余一个的实例。地面上每一块草地跟其他草地都是相同的。所以地面的网格类型不是枚举也不是Terrain对象,而是指向Terrain的指针:

class World{private:  Terrain* tiles_[WIDTH][HEIGHT];  // Other stuff...};

    每一块具有相同地形的分块都指向同一个Terrain实例。

    31162849_HHMM.png

    一旦多个分块指向了Terrain实例,如果你使用动态分配的话,他们的生命周期管理起来就会有点麻烦。所以,我们直接把他们定义到World类中:

class World{public:  World()  : grassTerrain_(1, false, GRASS_TEXTURE),    hillTerrain_(3, false, HILL_TEXTURE),    riverTerrain_(2, true, RIVER_TEXTURE)  {}private:  Terrain grassTerrain_;  Terrain hillTerrain_;  Terrain riverTerrain_;  // Other stuff...};

    然后,我们就可以使用他们渲染地形了,就像这样:

void World::generateTerrain(){  // Fill the ground with grass.  for (int x = 0; x < WIDTH; x++)  {    for (int y = 0; y < HEIGHT; y++)    {      // Sprinkle some hills.      if (random(10) == 0)      {        tiles_[x][y] = &hillTerrain_;      }      else      {        tiles_[x][y] = &grassTerrain_;      }    }  }  // Lay a river.  int x = random(WIDTH);  for (int y = 0; y < HEIGHT; y++)   {    tiles_[x][y] = &riverTerrain_;  }}

    现在,相比之前通过函数返回地形的属性,我们可以直接返回Terrain实例了:

const Terrain& World::getTile(int x, int y) const{  return *tiles_[x][y];}

    这样,World不再与Terrain的属性耦合了。如果你想获取某个分块地形的属性,你可以直接从Terrain实例中获取:

int cost = world.getTile(2, 3).getMovementCost();

    我们又回到了利用对象的愉快的api的状态,但是却几乎没有花费多少开销-一个指针一般并不比一个枚举大。

what about performance?

    我刚才说“几乎”是因为性能统计专家理所当然想知道与使用枚举相比性能如何。使用指针就表示有个间接的查找。为了获取一个分块的阻力,你要先跟着指针找到Terrain实例,然后才能找到阻力。追逐一个指针,可能会导致缓存未命中,从而减慢速度。

    通常,优化的黄金法则是框架优先。现代计算机硬件非常复杂已经不再是限制游戏性能的唯一理由。经过我的测试,我用享元模式没有任何性能损失相比使用枚举。享元模式实际上明显很快。但是,完全依赖于其他数据在内存中如何安排。

    我相信的是,享元模式不应该被忽视。它给你带来面向对象方式的好处,还不会造成产生大量实例的开销。如果你发现你使用了枚举并且对其使用了大量的switch语句,考虑使用这个模式吧。如果你担心性能,至少先把框架建好,而不是经常修改代码导致不可维护。

see also

  • 在分块的那个例子中,我们只是急切地创建了每种Terrain的实例并且存在World中。这使得我们很容易找到并复用这些实例。但是,在很多情况下,你不会想预先创建所有类型的Terrain实例。

    如果你不能预料你会使用哪个,那么根据需要创建实例是比较好的。为了利用共享,当你需求一个时,你先看看是否已经创建了实例,如果创建了就直接返回即可。

    这意味着,你需要在先查找已存在实例的接口后面封装一个构造器。把构造器隐藏起来的例子实际上是工厂模式的应用。

  • 为了返回一个已经存在的实例,你不得不遍历已经实例化的对象池。就像名字表明的,一个对象池会是一个好的地方来存放实例后的对象。

  • 当你使用状态模式时,你经常会发现“state”对象没有特定于状态机的属性数据。“state”的本体和函数会非常有用。在这种情况下,你可以使用此模式在不同的状态机中共用“state”,没有任何问题。

转载于:https://my.oschina.net/xunxun/blog/486193

你可能感兴趣的文章
Redis应用场景一
查看>>
webservice 协议
查看>>
SAR-303 xml validator验证框架
查看>>
牛腩学用MUI做手机APP
查看>>
WCF--安全小见解...
查看>>
C# Type.GetConstructor() 根据构造函数参数获取实例对象(一)
查看>>
针对各地项目icomet停止服务的临时处理办法
查看>>
Spring源代码解析
查看>>
搞明白这八个问题,Linux系统就好学多了
查看>>
Android Weekly Notes Issue #222
查看>>
CAD字体显示问号的解决办法
查看>>
微信支付开发(1) JS API支付V3版(转)
查看>>
利用tween,使用原生js实现模块回弹动画效果
查看>>
InfluxDB源码目录结构解析
查看>>
Mysql连接错误:Lost connection to Mysql server at 'waiting for initial communication packet'
查看>>
使用hosts.allow和hosts.deny实现简单的防火墙
查看>>
Javascript将字符串日期格式化为yyyy-mm-dd的方法 js number 类型 没有length 属性 string类型才有...
查看>>
磁波刀和海扶刀的区别
查看>>
MYSQL MVCC实现及其机制
查看>>
mysql 锁的粒度
查看>>