文档章节

《游戏程序设计模式》 1.2 - 享元模式

yintao
 yintao
发布于 2015/07/31 16:26
字数 2958
阅读 37
收藏 0

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

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

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。很幸运,有个历史悠久的方法来解决这个问题。

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

    

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

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_;
};

    你可以看到它是这样:

    

    这是很好的对于在主内存中存储数据,但是对于渲染没有帮助。在森林出现在屏幕上之前,必须先把数据传到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实例。

    

    一旦多个分块指向了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”,没有任何问题。


© 著作权归作者所有

yintao
粉丝 7
博文 63
码字总数 45783
作品 0
大连
程序员
私信 提问
面向对象编程设计模式------享元模式

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。 https://blog.csdn.net/zengxiantao1994/article/details/90020596   所谓享元模式就是运行...

知行流浪
05/09
0
0
Java设计模式系列十四(享元模式)

前言 秋雨绵绵,周末午后,小区凉亭。 李大爷:"你来了。" 我:"我来了。" 李大爷:"我知道你会来的!" 我:"我当然会来,你当然知道,否则一天前你又怎会让我走?" 我目光重落,再次凝视着他...

Mooree
09/04
78
0
设计模式知识梳理(6) - 结构型 - 享元模式

一、基本概念 1.1 定义 使用享元对象可有效地支持大量的细粒度的对象,达到对象共享、避免创建过多对象的效果。 享元对象内部分为两种状态: 内部状态:可以共享,不会随着环境变化。 外部状...

泽毛
2018/11/27
0
0
享元(Flyweight Pattern)模式

Flyweight在拳击比赛中指最轻量级,选择使用“享元模式”的意译,是因为这样更能反映模式的用意。 享元模式的用意 享元模式对象的结构模式。享元模式以共享的方式高效地支持大量的细粒度对象...

叶知秋
2013/07/01
186
0
设计模式-实现对象的复用——享元模式

享元模式概述 当一个系统中运行时产生的对象数量太多, 将导致运行代价过高, 带来系统性能下降的问题. 享元模式: 运用共享技术有效的支持大量细粒度对象的复用. 系统只使用少量的对象, 而这些...

hell03W
2016/12/09
23
0

没有更多内容

加载失败,请刷新页面

加载更多

java通过ServerSocket与Socket实现通信

首先说一下ServerSocket与Socket. 1.ServerSocket ServerSocket是用来监听客户端Socket连接的类,如果没有连接会一直处于等待状态. ServetSocket有三个构造方法: (1) ServerSocket(int port);...

Blueeeeeee
今天
6
0
用 Sphinx 搭建博客时,如何自定义插件?

之前有不少同学看过我的个人博客(http://python-online.cn),也根据我写的教程完成了自己个人站点的搭建。 点此:使用 Python 30分钟 教你快速搭建一个博客 为防有的同学不清楚 Sphinx ,这...

王炳明
昨天
5
0
黑客之道-40本书籍助你快速入门黑客技术免费下载

场景 黑客是一个中文词语,皆源自英文hacker,随着灰鸽子的出现,灰鸽子成为了很多假借黑客名义控制他人电脑的黑客技术,于是出现了“骇客”与"黑客"分家。2012年电影频道节目中心出品的电影...

badaoliumang
昨天
16
0
很遗憾,没有一篇文章能讲清楚线程的生命周期!

(手机横屏看源码更方便) 注:java源码分析部分如无特殊说明均基于 java8 版本。 简介 大家都知道线程是有生命周期,但是彤哥可以认真负责地告诉你网上几乎没有一篇文章讲得是完全正确的。 ...

彤哥读源码
昨天
18
0
jquery--DOM操作基础

本文转载于:专业的前端网站➭jquery--DOM操作基础 元素的访问 元素属性操作 获取:attr(name);$("#my").attr("src"); 设置:attr(name,value);$("#myImg").attr("src","images/1.jpg"); ......

前端老手
昨天
7
0

没有更多内容

加载失败,请刷新页面

加载更多

返回顶部
顶部