如何在 C++ 中使用 Slot Map / Object Pool 模式管理数百万个游戏对象?

Posted

技术标签:

【中文标题】如何在 C++ 中使用 Slot Map / Object Pool 模式管理数百万个游戏对象?【英文标题】:How to manage millions of game objects with a Slot Map / Object Pool pattern in C++? 【发布时间】:2016-11-16 07:34:15 【问题描述】:

我正在为一款名为 Tibia 的视频游戏开发游戏服务器。

基本上,可以有多达数百万个对象,在玩家与游戏世界互动时,其中可以有多达数千个删除和重新创建。

问题是,最初的创建者使用了一个插槽映射/对象池,当一个对象被删除时,指针会被重新使用。这是一个巨大的性能提升,因为除非需要,否则无需进行大量内存重新分配。

当然,我正在尝试自己完成这项工作,但我的槽位图遇到了一个大问题:

根据我在网上找到的来源,这里只是对 Slot Map 工作原理的一些解释:

Object 类是每个游戏对象的基类,我的 Slot Map / object Pool 正在使用这个 Object 类来保存每个分配的对象。

例子:

struct TObjectBlock

     Object Object[36768];
;

插槽映射的工作方式是,服务器首先在 TObjectBlock 列表中分配 36768 个对象,并为每个 ObjectID 分配一个唯一 ID ObjectID strong>对象,当服务器需要创建新对象时,可以在空闲对象列表中重复使用。

例子:

对象 1 (ID: 555) 被删除,它的 ID 555 被放入一个空闲的对象 ID 列表,请求创建项目,ID 555 被重复使用,因为它已打开 空闲对象列表,无需重新分配另一个 数组中的 TObjectBlock 用于更多对象。

我的问题:如何使用“Player”“Creature”“Item”“Tile”来支持这个 Slot Map?我似乎没有想出解决这个逻辑问题的方法。

我正在使用虚拟类来管理所有对象:

struct Object

    uint32_t ObjectID;
    int32_t posx;
    int32_t posy;
    int32_t posz;
;

然后,我会自己创建对象:

struct Creature : Object

     char Name[31];
;

struct Player : Creature


;

struct Item : Object

     uint16_t Attack;
;

struct Tile : Object


;

但现在如果我要使用槽图,我必须这样做:

Object allocatedObject;
allocatedObject.ObjectID = CreateObject(); // Get a free object ID to use
if (allocatedObject.ObjectID != INVALIDOBJECT.ObjectID)

      Creature* monster = new Creature();
      // This doesn't make much sense, since I'd have this creature pointer floating around!
      monster.ObjectID = allocatedObject.ObjectID;

将一个全新的对象指针设置为已分配的对象唯一 ID 几乎没有多大意义。

我对这个逻辑有什么选择?

【问题讨论】:

谷歌“分配器”。一旦您完全了解它们是什么以及为什么存在不同的分配器,您将不再需要这个问题的答案。 【参考方案1】:

我相信你这里有很多纠结的概念,你需要解开它们才能使这个工作。

首先,你实际上违背了这个模型的主要目的。你所展示的东西闻起来很糟糕。货物崇拜编程。你不应该是newing 对象,至少在没有重载的情况下,如果你是认真的。您应该为给定的对象类型分配一个大块内存,并从“分配”中提取 - 无论是从重载的new 还是通过内存管理器类创建。这意味着您需要为每种对象类型提供单独的内存块,而不是单个“对象”块。

整个想法是,如果你想避免实际内存的分配释放,你需要重用内存。要构造一个对象,您需要足够的内存来容纳它,并且您的类型长度不​​同。只有您的示例中的TileObject 的大小相同,因此只有 可以 共享相同的内存(但不应该)。其他类型都不能放在对象内存中,因为它们更长。每种类型都需要单独的池。

其次,对象 ID 不应该与事物的存储方式有关。一旦考虑到第一点,就不可能共享 ID 而不是共享内存。但必须明确指出——内存块中的位置在很大程度上是任意的,而 ID 则不是。

为什么?假设您获取对象 40,“删除”它,然后创建一个新对象 40。现在假设程序的一些错误部分引用了原始 ID 40。它会寻找原始的 40,应该 错误,而是找到新的 40。您刚刚创建了一个完全无法跟踪的错误。虽然这可能发生在指针上,但更可能发生在 ID 上,因为很少有系统会对 ID 的使用进行检查。使用 ID 进行间接访问的一个主要原因是为了让访问更安全,因为它可以很容易地发现不良使用情况,因此通过使 ID 可重用,您会使它们与存储指针一样不安全。

处理此问题的实际模型应该类似于操作系统执行类似操作的方式(请参阅下面的除法以了解更多信息……)。也就是说,遵循这样的模型:

    创建您要存储的类型的某种数组(如向量) - 实际类型,而不是指向它的指针。不是Object,它是一个通用的基础,而是类似于Player。 将其调整为您期望需要的大小。 创建一个 size_t 堆栈(用于索引)并将数组中的每个索引都压入其中。如果您创建了 10 个对象,则推送 0 1 2 3 4 5 6 7 8 9。 每次您需要一个对象时,从堆栈中弹出一个索引并使用该数组单元格中的内存。 如果索引用完,请增加向量的大小并推送新创建的索引。 当您使用对象时,通过弹出的索引间接使用。

本质上,你需要一个类来管理内存。

另一种模型是将指针直接推送到具有匹配指针类型的堆栈中。这样做有好处,但也更难调试。该系统的主要好处是它可以很容易地集成到现有系统中;但是,大多数编译器已经在做类似的事情了……


也就是说,我建议不要这样做。这在纸面上似乎是一个好主意,而且在非常有限的系统上也是如此,但现代操作系统并不是按照这个定义的“有限系统”。虚拟内存已经解决了这样做的最大原因,内存碎片(你没有提到)。许多编译器分配器将尝试通过从内存池中提取来或多或少地在标准库容器中执行您在此处尝试执行的操作,并且这些分配器更易于使用。

我曾经实现过一个这样的系统,但出于许多充分的理由,我放弃了它,转而使用一组无序的指针映射。如果我发现与此模型相关的性能或内存问题,我计划更换分配器。这让我可以消除在测试/优化之前管理内存的担忧,并且不需要在每个级别都进行古怪的系统设计来处理抽象。

当我说“古怪”时,相信我说间接池堆栈设计的烦恼比我列出的要多得多。

【讨论】:

以上是关于如何在 C++ 中使用 Slot Map / Object Pool 模式管理数百万个游戏对象?的主要内容,如果未能解决你的问题,请参考以下文章

在 c++ 中使用 unordered_set/map 时如何制作封装良好的类?

C++ 如何清空unordered_map

pyQT slot decorator 啥是 C++ 等价物

c++ bool型函数的调用

如何在 C++ 中单独锁定 unordered_map 元素

运算符重载 c++ (x==y==z)