深入理解对象池技术

笔者介绍:姜雪伟,IT公司技术合伙人,IT高级讲师,CSDN社区专家,特邀编辑,畅销书作者,已出版书籍:《手把手教你架构3D游戏引擎》电子工业出版社和《Unity3D实战核心技术详解》电子工业出版社等。

CSDN视频网址:http://edu.csdn.net/lecturer/144

对象池在游戏开发中使用的非常广泛,尤其对于内存的管理方面,很多人知道对象池但是不理解,本篇博客使用通俗的语言给读者讲解。。。。。。

游戏中通过重新使用固定池中的对象来提高性能和内存使用,而不是单独分配和释放对象。  举例,游戏的视觉效果, 当英雄施放一个咒语时,我们想要一阵闪闪发光的闪光, 这就需要一个粒子系统,粒子系统是引擎必须要支持的。
粒子播放时,可能导致数百颗粒子产生,系统需要能够很快地创建它们, 更重要的是,我们需要确保创建和销毁这些粒子不会导致内存碎片。

移动设备的编程在许多方面比传统的PC编程更接近嵌入式编程 内存稀缺,用户期望游戏稳定,高效的压缩内存管理器很少可用。 在这种环境中,内存碎片是致命的。
碎片意味着我们堆中的可用空间被分解成较小的内存块,而不是一个大的开放块。 可用的总内存可能很大,但最大的连续区域可能会很小。 假设我们已经有十四个字节可用,但是它们被分成两个七字节的片段,它们之间带有大量的使用内存。 如果我们尝试分配一个十二字节的对象,我们将失败。
如下所示:

由于碎片,因为分配可能很慢,游戏对于管理内存的时间和方式非常谨慎。 一个简单的解决方案通常是最好的 - 在游戏开始时抓住大块内存,并且不要释放它直到游戏结束。 但是,对于在游戏运行期间需要创建和销毁东西的系统来说,这是一个痛苦。
对象池给了我们两个世界最好的
对于内存管理员,我们只是在前面分配一大块内存,而不是在游戏中释放它。 对于池的用户,我们可以自由地分配和释放对象到我们心脏的内容。

定义一个维护一个可重用对象集合的池类。 每个对象都支持一个“在使用中”查询来判断当前是否“活着”。 当池初始化时,它将在前面创建整个对象集合(通常在单个连续分配中),并将它们全部初始化为“未使用”状态。
当你想要一个新对象时,
查询一个池 它找到一个可用对象,将其初始化为“正在使用”,并返回。 当对象不再需要时,它被设置回到“不使用”状态。 这样,可以自由地创建和销毁对象,而不需要分配内存或其他资源。

  这种模式在游戏中广泛应用于诸如游戏实体和视觉效果之类的明显的东西,但它也用于不太可见的数据结构,例如当前播放的声音。 使用对象池时:
一、您需要频繁地创建和销毁对象。
二、对象的大小相似。
三、堆上分配对象很慢或可能导致内存碎片化。
四、每个对象都封装了一个资源,例如数据库或网络连接,这些资源很昂贵,可以被重用。

你通常依靠一个垃圾收集器或者新的和删除来处理你的内存管理 通过使用对象池,您说的是“我更好地了解这些字节应该如何处理”。这意味着你可以处理这种模式的局限性。

需要根据游戏的需要调整对象池的大小 调整时,当池太小时,通常很明显(没有什么像碰撞引起你的注意) 还要注意池不算太大 一个较小的池释放可用于其他东西的存放

大多数池实现将对象存储在一个对象数组中 如果所有的对象都是相同的对象,那就行了。 但是,如果要在池中存储不同类型的对象或可能添加字段的子类的实例,则需要确保池中的每个插槽具有足够的可用对象的内存。 否则,一个意想不到的大对象会踩下下一个对象并将其记录在内。
同时,当您的对象尺寸不同时, 每个插槽需要足够大以容纳最大的对象
如果对象很少,那么每次在该插槽中放一个较小的对象时,都会丢弃内存。 这就像通过机场安全,并为您的钥匙和钱包使用一个巨大的携带大小的行李托盘。
当你发现自己
使用了很多内存的时候,可以考虑将对象池分成不同大小的物体 - 不同大小的物品 - 行李箱的大托盘,小容器的小托盘。

大多数内存管理器都有一个调试功能,可以将新分配或释放的内存清除为一些明显的值,如0xdeadbeef。 这可以帮助您找到由未初始化的变量引起的错误,或者在释放后使用内存。
由于我们的对象池在重用对象时不再经历内存管理器,所以我们失去了这个安全网
更糟糕的是,用于“新”对象的内存以前拥有完全相同类型的对象。 这使得几乎不可能告诉您是否在创建新对象时忘记初始化某些内容:存储对象的内存可能已经包含了几乎正确的数据。
因此,请特别注意,初始化池中新对象的代码完全初始化对象
甚至可能花费一点时间添加一个调试功能,以便在对象被回收时清除对象插槽的内存。

对象池在支持垃圾收集的系统中不常见,因为内存管理器通常会为您处理碎片。 但是池仍然有用,以避免分配和释放的成本,特别是在CPU速度较慢的移动设备和更简单的垃圾收集器上。
如果您使用对象池与垃圾收集器协同工作,请注意潜在的冲突。 由于池不再使用对象时不会重新分配对象,所以它们保留在内存中。 如果它们包含对其他对象的引用,它将阻止收集器再次收回它们。 为避免这种情况,当一个池化对象不再使用时,请清除其它对象所引用的任何引用。

现实世界的粒子系统通常会应用重力,风力,摩擦力等物理效应。 我们简单得多的样品只能将粒子沿直线移动到一定数量的帧,然后杀死粒子。
我们将从最简单的实现开始。 首先是小粒子类:

class Particle
{
public:
  Particle()
  : framesLeft_(0)
  {}

  void init(double x, double y,
            double xVel, double yVel, int lifetime)
  {
    x_ = x; y_ = y;
    xVel_ = xVel; yVel_ = yVel;
    framesLeft_ = lifetime;
  }

  void animate()
  {
    if (!inUse()) return;

    framesLeft_--;
    x_ += xVel_;
    y_ += yVel_;
  }

  bool inUse() const { return framesLeft_ > 0; }

private:
  int framesLeft_;
  double x_, y_;
  double xVel_, yVel_;
};
默认构造函数将粒子初始化为“未使用”。 稍后调用init()会将粒子初始化为活动状态。 粒子随着时间的推移使用命名animate()函数,每帧应该调用一次。
池需要知道哪些粒子可以重用, 它从粒子的inUse()函数中获取。 此功能利用了粒子的使用寿命有限的事实,并使用_framesLeft变量来发现哪些粒子正在使用,而不必存储单独的标志。

池类也很简单:

class ParticlePool
{
public:
  void create(double x, double y,
              double xVel, double yVel, int lifetime);

  void animate()
  {
    for (int i = 0; i < POOL_SIZE; i++)
    {
      particles_[i].animate();
    }
  }

private:
  static const int POOL_SIZE = 100;
  Particle particles_[POOL_SIZE];
};
create()函数允许外部代码创建新的粒子。 游戏每帧调用animate()一次,这反过来动画化游戏中的每个粒子。
此animate()方法是Update Method模式的示例。
粒子本身简单地存储在类中的固定大小的数组中
在此示例实现中,池大小在类声明中是硬编码的,但是可以通过使用给定大小的动态数组或使用值模板参数在外部定义池大小。
创建一个新的粒子是直接的:

void ParticlePool::create(double x, double y,
                          double xVel, double yVel,
                          int lifetime)
{
  // Find an available particle.
  for (int i = 0; i < POOL_SIZE; i++)
  {
    if (!particles_[i].inUse())
    {
      particles_[i].init(x, y, xVel, yVel, lifetime);
      return;
    }
  }
}
我们遍历池,寻找第一个可用的粒子。 当我们找到它,我们初始化它,我们完成了。 请注意,在这个实现中,如果没有任何可用的粒子,我们根本不会创建一个新的粒子。
这就是一个简单的粒子系统,除了渲染粒子,当然, 我们现在可以创建一个池并使用它创建一些粒子 当粒子的寿命过期时,粒子会自动停用。
可能已经注意到,创建新的粒子需要迭代(潜在)整个集合,直到我们找到一个可用的 。 如果对象 池很大,大部分都是满的,那可以慢一些 让我们看看我们如何能够改善这一点。

如果我们不想浪费时间找到自由粒子,明显的答案是不要失去追踪。我们可以存储一个单独的指针列表到每个未使用的粒子。然后,当我们需要创建一个粒子时,我们从列表中删除第一个指针并重新使用它指向的粒子。
不幸的是,这将需要我们维护整个单独的数组,它们与池中的对象一样多的指针。毕竟,当我们首先创建池时,所有的粒子都是未使用的,所以列表最初会有一个指向池中每个对象的指针。
在不牺牲任何内存的情况下修复我们的性能问题将是很好的。
当粒子不使用时,其大部分状态是无关紧要的
它的位置和速度没有被使用。它需要的唯一的状态是需要告诉它是否死亡的东西。在我们的示例中,这是_framesLeft成员所有其他位都可以重复使用。这是一个修改后的粒子:

class Particle
{
public:
  // ...

  Particle* getNext() const { return state_.next; }
  void setNext(Particle* next) { state_.next = next; }

private:
  int framesLeft_;

  union
  {
    // State when it's in use.
    struct
    {
      double x, y;
      double xVel, yVel;
    } live;

    // State when it's available.
    Particle* next;
  } state_;
};
我们已将除FrameLeft_之外的所有成员变量移动到state_ union内的结构中。这个结构体在动画时保持粒子的状态。当未使用粒子时,使用其他情况的联合,即下一个成员。在此之后,它会保留下一个可用粒子的指针。我们可以使用这些指针构建链接在池中的每个未使用的粒子的链表我们有我们需要的可用粒子的列表,但是我们不需要使用任何额外的内存。相反,我们将自己存储的死粒子的记忆消耗掉以存储列表。

为了使其正常工作,我们需要确保指针被初始化正确,并在创建和销毁粒子时进行维护。

class ParticlePool
{
  // ...
private:
  Particle* firstAvailable_;
};
当一个池首次创建时,所有的粒子都可用,所以我们的空闲列表应该遍历整个池, 池构造函数设置:

ParticlePool::ParticlePool()
{
  // The first one is available.
  firstAvailable_ = &particles_[0];

  // Each particle points to the next.
  for (int i = 0; i < POOL_SIZE - 1; i++)
  {
    particles_[i].setNext(&particles_[i + 1]);
  }

  // The last one terminates the list.
  particles_[POOL_SIZE - 1].setNext(NULL);
}

现在要创建一个新的粒子,我们直接跳到第一个可用的粒子:

void ParticlePool::create(double x, double y,
                          double xVel, double yVel,
                          int lifetime)
{
  // Make sure the pool isn't full.
  assert(firstAvailable_ != NULL);

  // Remove it from the available list.
  Particle* newParticle = firstAvailable_;
  firstAvailable_ = newParticle->getNext();

  newParticle->init(x, y, xVel, yVel, lifetime);
}
我们需要知道一个粒子何时死亡,所以我们可以将它添加到自由列表中,所以如果先前的活粒子放弃了该帧中的ghost,我们将更改animate()返回true:

bool Particle::animate()
{
  if (!inUse()) return false;

  framesLeft_--;
  x_ += xVel_;
  y_ += yVel_;

  return framesLeft_ == 0;
}
当这种情况发生时,我们只需将其重新列入列表:

void ParticlePool::animate()
{
  for (int i = 0; i < POOL_SIZE; i++)
  {
    if (particles_[i].animate())
    {
      // Add this particle to the front of the list.
      particles_[i].setNext(firstAvailable_);
      firstAvailable_ = &particles_[i];
    }
  }
}
当这种情况发生时,我们只需将其重新列入列表:

如您所见,最简单的对象池实现几乎是微不足道的:创建一个对象数组,并根据需要重新初始化它们 代码很少是最小的。 有几种方法来扩展,使对象池更通用,更安全使用或更易于维护。 当您在游戏中实施对象池时,您需要回答以下问题:

编写对象池时遇到的第一个问题是对象本身是否知道它们在池中 大多数时候他们会这样做,但是在编写可以容纳任意对象的通用池类时,你不会有这样的奢侈。

一、实现更简单。 你可以简单地在你的pooled对象中放置一个“使用中”的标志或函数,并用它完成。您可以确保只能由池创建对象 在C ++中,一个简单的方法是使池类成为对象类的朋友,然后使对象的构造函数为私有的。

class Particle
{
  friend class ParticlePool;

private:
  Particle()
  : inUse_(false)
  {}

  bool inUse_;
};

class ParticlePool
{
  Particle pool_[100];
};
您可能无法存储明确的“使用中”标志。 许多对象已经保留了一些可以用来判断它是否存在的状态。 例如,如果粒子的当前位置在屏幕外,则其可以被重用。 如果对象类知道它可以在池中使用,它可以提供一个inUse()方法来查询该状态。 这样可以节省存储一大堆“使用中”标志的额外内存。

三、可以汇集任何类型的对象。 这是最大的优势。 通过从池中分离对象,您可能可以实现一个通用的可重用池类。
必须在对象外追踪“使用中”状态。 最简单的方法是创建一个单独的位字段:

template <class TObject>
class GenericPool
{
private:
  static const int POOL_SIZE = 100;

  TObject pool_[POOL_SIZE];
  bool    inUse_[POOL_SIZE];
};
为了重用一个现有的对象,它必须用新的状态初始化。 这里的一个关键问题是是否在池类或外部初始化对象。

对象池可以完全封装其对象。 根据您的对象需要的其他功能,您可能会将其完全保留在池内部。 这确保其他代码不会保留对可能意外重复使用的对象的引用。池被绑定到如何初始化对象, 池初始化的对象可以提供多种初始化它的功能。 如果池管理初始化,它的接口需要支持所有这些,并将它们转发到对象。

 

class Particle
{
  // Multiple ways to initialize.
  void init(double x, double y);
  void init(double x, double y, double angle);
  void init(double x, double y, double xVel, double yVel);
};

class ParticlePool
{
public:
  void create(double x, double y)
  {
    // Forward to Particle...
  }

  void create(double x, double y, double angle)
  {
    // Forward to Particle...
  }

  void create(double x, double y, double xVel, double yVel)
  {
    // Forward to Particle...
  }
};
对象 池的界面可以更简单 而不是提供多种功能来覆盖每个方法可以初始化对象,池可以简单地返回对新对象的引用:

class Particle
{
public:
  // Multiple ways to initialize.
  void init(double x, double y);
  void init(double x, double y, double angle);
  void init(double x, double y, double xVel, double yVel);
};

class ParticlePool
{
public:
  Particle* create()
  {
    // Return reference to available particle...
  }
private:
  Particle pool_[100];
};
调用者可以通过调用对象公开的任何方法来初始化对象:

ParticlePool pool;

pool.create()->init(1, 2);
pool.create()->init(1, 2, 0.3);
pool.create()->init(1, 2, 3.3, 4.4);
外部代码可能需要处理创建新对象的失败。 前面的例子假设create()将始终成功地返回一个指向一个对象的指针。 如果池已满,则可能返回NULL。 为了安全起见,您需要在尝试初始化对象之前检查它:

Particle* particle = pool.create();
if (particle != NULL) particle->init(1, 2);

对象在游戏开发中使用的非常广泛,希望对读者有所帮助。。。。。。。。。




已标记关键词 清除标记
©️2020 CSDN 皮肤主题: 大白 设计师:CSDN官方博客 返回首页