التنفيذ الأصلي لمكتبة ECS

صورة

بدأت هذا الأسبوع العمل على محرك Vagabond الخاص بي وبدأت في تنفيذ قالب نظام الكيان المكون .

في هذه المقالة أريد أن أتحدث عن تنفيذي ، والذي يتوفر مجانًا على GitHub . لكن بدلاً من مجرد التعليق على الكود ، أريد أن أوضح كيف تم تصميم هيكلها. لذلك ، سأبدأ بالتنفيذ الأول الذي كتبته ، ونحلل نقاط قوته وضعفه ، ثم أظهر كيف قمت بتحسينه. في النهاية سأذكر قائمة بالجوانب التي يمكن تحسينها أيضًا.

مقدمة


حافز


لن أتحدث عن فوائد ECS على النهج الموجه للكائنات ، لأن الكثير من الناس قبلي فعلوا هذا جيدًا. كان Scott Bilas من أوائل من تحدثوا عن ECS في GDC 2002 . من بين المقدمات الشهيرة الأخرى لهذا الموضوع: مايك ويست ، "إيفوليف يور هيرميري" ، وفصل " المكونات" من كتاب " أنماط برمجة الألعاب" المذهل لروبرت نيستروم.

باختصار ، سأقول إن مهمة ECS هي إنشاء نهج موجه نحو البيانات إلى كيانات اللعبة وفصل مناسب للبيانات والمنطق. الكيانات تتكون من مكونات تحتوي على بيانات. والأنظمة التي تحتوي على منطق عملية هذه المكونات.

إذا ذهبت إلى التفاصيل ، بدلاً من الميراث ، يتم استخدام التكوين في ECS. علاوة على ذلك ، فإن هذا النهج الموجه نحو البيانات يستفيد بشكل أفضل من ذاكرة التخزين المؤقت ، مما يعني أنه يحقق أداءً ممتازًا.

أمثلة


قبل الخوض في الكود ، أود أن أوضح لك ما سنقوم بتصميمه.

تعيين المكونات بسيط للغاية:

struct Position : public Component<Position> { float x; float y; }; struct Velocity : public Component<Velocity> { float x; float y; }; 

كما ترون ، سوف نستخدم قالب CRTP .

بعد ذلك ، ولأسباب فنية ، والتي سأشرحها لاحقًا ، نحتاج إلى إصلاح عدد المكونات وعدد الأنظمة:

 constexpr auto ComponentCount = 32; constexpr auto SystemCount = 8; 

بعد ذلك ، يمكنك تحديد نظام سيأخذ جميع الكيانات التي تحتوي على كل من المكونات وتحديث مواقعها:

 class PhysicsSystem : public System<ComponentCount, SystemCount> { public: PhysicsSystem(EntityManager<ComponentCount, SystemCount>& entityManager) : mEntityManager(entityManager) { setRequirements<Position, Velocity>(); } void update(float dt) { for (const auto& entity : getManagedEntities()) { auto [position, velocity] = mEntityManager.getComponents<Position, Velocity>(entity); position.x += velocity.x * dt; position.y += velocity.y * dt; } } private: EntityManager<ComponentCount, SystemCount>& mEntityManager; }; 

يستخدم النظام ببساطة طريقة setRequirements عن مكونات setRequirements . بعد ذلك ، في طريقة update ، يمكنه استدعاء getManagedEntities كل الكيانات التي تفي بالمتطلبات.

أخيرًا ، لنقم بإنشاء مدير كيان ، وتسجيل المكونات ، وإنشاء نظام والعديد من الكيانات ، ثم تحديث مواضعها باستخدام النظام:

 auto manager = EntityManager<ComponentCount, SystemCount>(); manager.registerComponent<Position>(); manager.registerComponent<Velocity>(); auto system = manager.createSystem<PhysicsSystem>(manager); for (auto i = 0; i < 10; ++i) { auto entity = manager.createEntity(); manager.addComponent<Position>(entity); manager.addComponent<Velocity>(entity); } auto dt = 1.0f / 60.0f; while (true) system->update(dt); 

المعايير


لن أدعي أن إنشاء مكتبة ECS أفضل. أردت فقط أن أكتبها بنفسي. بالإضافة إلى ذلك ، عملت فقط لمدة أسبوع.

ومع ذلك ، هذا ليس سببا لخلق شيء غير فعال تماما. لذلك دعونا تثبيت المعايير:

  • الأول سيخلق كيانات.
  • والثاني سيستخدم النظام لاجتياز الكيانات تكرارا ؛
  • هذا الأخير سوف يخلق ويدمر الكيانات ؛

معلمات كل هذه المعايير هي عدد الكيانات ، وعدد المكونات لكل كيان ، والحد الأقصى لعدد المكونات والحد الأقصى لعدد الأنظمة. وبهذه الطريقة ، يمكننا أن نرى مدى جودة تنفيذنا. على وجه الخصوص ، سأعرض نتائج ثلاثة ملفات تعريف مختلفة:

  • الملف الشخصي A: 32 مكونات و 16 أنظمة ؛
  • ملف تعريف AA: 128 مكونًا و 32 نظامًا ؛
  • ملف تعريف AAA: 512 مكونًا و 64 نظامًا.

على الرغم من أن هذه المعايير ستعطينا فكرة عن جودة التنفيذ ، فهي بسيطة للغاية. على سبيل المثال ، في هذه المعايير ، نستخدم الكيانات المتجانسة فقط ، ومكوناتها صغيرة.

تطبيق


جوهر


في تطبيقي ، الكيان هو مجرد معرف:

 using Entity = uint32_t; 

علاوة على ذلك ، في Entity.h ، سنحدد أيضًا Index الاسم المستعار ، والذي سيكون مفيدًا في وقت لاحق:

 using Index = uint32_t; static constexpr auto InvalidIndex = std::numeric_limits<Index>::max(); 

قررت استخدام uint32_t بدلاً من نوع 64 بت أو std::size_t لتوفير مساحة وتحسين تحسين ذاكرة التخزين المؤقت. لن نخسر الكثير: من غير المرجح أن يكون لدى شخص ما مليارات الكيانات.

عنصر


الآن دعونا نحدد الفئة الأساسية للمكونات:

 template<typename T, auto Type> class Component { public: static constexpr auto type = static_cast<std::size_t>(Type); }; 

فئة القالب بسيطة جدًا ، حيث تقوم فقط بتخزين معرف النوع ، والذي سنستخدمه لاحقًا لفهرسة هياكل البيانات حسب نوع المكونات.

المعلمة القالب الأول هو نوع المكون. والثاني هو القيمة المحولة إلى std::size_t ، والتي ستكون بمثابة معرف نوع المكون.

على سبيل المثال ، يمكننا تعريف مكون Position كما يلي:

 struct Positon : Component<Position, 0> { float x; float y; }; 

ومع ذلك ، قد يكون التعداد أكثر ملاءمة:

 enum class ComponentType { Position }; struct Positon : Component<Position, ComponentType::Position> { float x; float y; }; 

في المثال التمهيدي ، هناك معلمة قالب واحد فقط: لا نحتاج إلى تحديد معرف النوع يدويًا. سنرى لاحقًا كيفية تحسين الهيكل وإنشاء معرفات الأنواع تلقائيًا.

EntityContainer


ستكون فئة EntityContainer مسؤولة عن إدارة الكيانات و std::bitset لكل منها. ستشير مجموعة البتات هذه إلى المكونات التي يمتلكها الكيان.

نظرًا لأننا سنستخدم الكيانات لفهرسة الحاويات ، وخاصة std::vector ، فإننا نحتاج إلى أن يكون المعرف أصغر حجم ممكن ونستهلك ذاكرة أقل. لذلك ، سوف نعيد استخدام هوية الكيانات المدمرة. للقيام بذلك ، سيتم تخزين معرف مجاني في حاوية تسمى mFreeEntities .

هنا هو EntityContainer :

 template<std::size_t ComponentCount, std::size_t SystemCount> class EntityContainer { public: void reserve(std::size_t size); std::vector<std::bitset<ComponentCount>>& getEntityToBitset(); const std::bitset<ComponentCount>& getBitset(Entity entity) const; Entity create(); void remove(Entity entity); private: std::vector<std::bitset<ComponentCount>> mEntityToBitset; std::vector<Entity> mFreeEntities; }; 

دعونا نرى كيف يتم تنفيذ الأساليب.

getEntityToBitset و getBitset هي getBitset الصغيرة المعتادة:

 std::vector<std::bitset<ComponentCount>>& getEntityToBitset() { return mEntityToBitset; } const std::bitset<ComponentCount>& getBitset(Entity entity) const { return mEntityToBitset[entity]; } 

طريقة create أكثر إثارة للاهتمام:

 Entity create() { auto entity = Entity(); if (mFreeEntities.empty()) { entity = static_cast<Entity>(mEntityToBitset.size()); mEntityToBitset.emplace_back(); } else { entity = mFreeEntities.back(); mFreeEntities.pop_back(); mEntityToBitset[entity].reset(); } return entity; } 

إذا كان هناك كيان حر ، فإنه يعيد استخدامه. خلاف ذلك ، تقوم الطريقة بإنشاء كيان جديد.

تقوم طريقة remove ببساطة بإضافة الكيان المراد إزالته في mFreeEntities :

 void remove(Entity entity) { mFreeEntities.push_back(entity); } 

الطريقة الأخيرة هي reserve . وتتمثل مهمتها في حجز الذاكرة لمختلف الحاويات. كما نعلم ، فإن تخصيص الذاكرة هو عملية مكلفة ، لذلك إذا كنا نعرف تقريبًا عدد الكيانات المستقبلية في اللعبة ، فإن الذاكرة الاحتياطية ستسرع العمل:

 void reserve(std::size_t size) { mFreeEntities.resize(size); std::iota(std::begin(mFreeEntities), std::end(mFreeEntities), 0); mEntityToBitset.resize(size); } 

بالإضافة إلى نسخة احتياطية بسيطة للذاكرة ، فإنه يملأ mFreeEntities أيضًا.

ComponentContainer


ستكون فئة ComponentContainer مسؤولة عن تخزين جميع مكونات النوع المحدد.

في بنائي ، يتم تخزين جميع مكونات نوع معين معًا. بمعنى ، يوجد صفيف واحد كبير لكل نوع مكون يسمى mComponents .

بالإضافة إلى ذلك ، لكي نتمكن من إضافة أو استلام أو إزالة مكون من كيان في وقت ثابت ، نحتاج إلى طريقة للانتقال من كيان إلى مكون ومن مكون إلى كيان. للقيام بذلك ، نحتاج إلى mComponentToEntity البيانات تسمى mComponentToEntity و mEntityToComponent .

هنا هو إعلان ComponentContainer :

 template<typename T, std::size_t ComponentCount, std::size_t SystemCount> class ComponentContainer : public BaseComponentContainer { public: ComponentContainer(std::vector<std::bitset<ComponentCount>>& entityToBitset); virtual void reserve(std::size_t size) override; T& get(Entity entity); const T& get(Entity entity) const; template<typename... Args> void add(Entity entity, Args&&... args); void remove(Entity entity); virtual bool tryRemove(Entity entity) override; Entity getOwner(const T& component) const; private: std::vector<T> mComponents; std::vector<Entity> mComponentToEntity; std::unordered_map<Entity, Index> mEntityToComponent; std::vector<std::bitset<ComponentCount>>& mEntityToBitset; }; 

يمكنك أن ترى أنه يرث من BaseComponentContainer ، والذي تم تعيينه مثل هذا:

 class BaseComponentContainer { public: virtual ~BaseComponentContainer() = default; virtual void reserve(std::size_t size) = 0; virtual bool tryRemove(Entity entity) = 0; }; 

الغرض الوحيد من هذه الفئة الأساسية هو أن تكون قادراً على تخزين كافة مثيلات ComponentContainer في حاوية.

دعونا الآن نلقي نظرة على تعريف الأساليب.

أولاً ، فكر في المُنشئ: إنه يحصل على إشارة إلى حاوية تحتوي على مجموعات من وحدات بت الكيان. ستستخدم هذه الفئة للتحقق مما إذا كان الكيان يحتوي على مكون ولتحديث مجموعة البتات للكيان عند إضافة مكون أو إزالته:

 ComponentContainer(std::vector<std::bitset<ComponentCount>>& entityToBitset) : mEntityToBitset(entityToBitset) { } 

طريقة get بسيطة ، نحن فقط نستخدم mEntityToComponent للعثور على فهرس مكون الكيان في mComponents :

 T& get(Entity entity) { return mComponents[mEntityToComponent[entity]]; } 

تستخدم طريقة add الوسيطات الخاصة بها لإدراج مكون جديد في نهاية mComponents ، ثم تُعد الروابط للانتقال من كيان إلى مكون ومن مكون إلى كيان. في النهاية ، يقوم بتعيين بت بت مجموعة entity الذي يطابق المكون بـ true :

 template<typename... Args> void add(Entity entity, Args&&... args) { auto index = static_cast<Index>(mComponents.size()); mComponents.emplace_back(std::forward<Args>(args)...); mComponentToEntity.emplace_back(entity); mEntityToComponent[entity] = index; mEntityToBitset[entity][T::type] = true; } 

تقوم طريقة remove بتعيين مكون البت المقابل على false ، ثم تحريك آخر مكون من المكونات المكونة mComponents في فهرس المكون الذي نريد إزالته. يقوم بتحديث الروابط للمكون الذي قمنا بنقله للتو ، ويزيل أحد المكونات التي نريد تدميرها:

 void remove(Entity entity) { mEntityToBitset[entity][T::type] = false; auto index = mEntityToComponent[entity]; // Update mComponents mComponents[index] = std::move(mComponents.back()); mComponents.pop_back(); // Update mEntityToComponent mEntityToComponent[mComponentToEntity.back()] = index; mEntityToComponent.erase(entity); // Update mComponentToEntity mComponentToEntity[index] = mComponentToEntity.back(); mComponentToEntity.pop_back(); } 

يمكننا القيام بحركة زمنية ثابتة عن طريق تحريك العنصر الأخير في الفهرس الذي نريد تدميره. وفي الواقع ، نحتاج فقط إلى إزالة المكون الأخير ، والذي يمكن القيام به في std::vector في وقت ثابت.

يتحقق أسلوب tryRemove لمعرفة ما إذا كان أحد العناصر يحتوي على مكون قبل محاولة إزالته:

 virtual bool tryRemove(Entity entity) override { if (mEntityToBitset[entity][T::type]) { remove(entity); return true; } return false; } 

تُرجع طريقة getOwner الكيان الذي يمتلك المكون ، لذلك يستخدم المؤشر الحسابي و mComponentToEntity :

 Entity getOwner(const T& component) const { auto begin = mComponents.data(); auto index = static_cast<std::size_t>(&component - begin); return mComponentToEntity[index]; } 

الطريقة الأخيرة هي reserve ، ولها نفس الغرض من الطريقة المماثلة في EntityContainer :

 virtual void reserve(std::size_t size) override { mComponents.reserve(size); mComponentToEntity.reserve(size); mEntityToComponent.reserve(size); } 

النظام


الآن دعونا نلقي نظرة على فئة System .

يحتوي كل نظام على مجموعة من وحدات بت mRequirements التي تصف المكونات التي يحتاجها. بالإضافة إلى ذلك ، يقوم بتخزين مجموعة من كيانات mManagedEntities التي تفي بهذه المتطلبات. أكرر ، لكي نتمكن من تنفيذ جميع العمليات في وقت ثابت ، نحتاج إلى طريقة للانتقال من كيان إلى فهرسه في mManagedEntities . للقيام بذلك ، سوف نستخدم std::unordered_map تسمى mEntityToManagedEntity .

إليك ما يبدو عليه إعلان System :

 template<std::size_t ComponentCount, std::size_t SystemCount> class System { public: virtual ~System() = default; protected: template<typename ...Ts> void setRequirements(); const std::vector<Entity>& getManagedEntities() const; virtual void onManagedEntityAdded([[maybe_unused]] Entity entity); virtual void onManagedEntityRemoved([[maybe_unused]] Entity entity); private: friend EntityManager<ComponentCount, SystemCount>; std::bitset<ComponentCount> mRequirements; std::size_t mType; std::vector<Entity> mManagedEntities; std::unordered_map<Entity, Index> mEntityToManagedEntity; void setUp(std::size_t type); void onEntityUpdated(Entity entity, const std::bitset<ComponentCount>& components); void onEntityRemoved(Entity entity); void addEntity(Entity entity); void removeEntity(Entity entity); }; 

يستخدم setRequirements تعبير تحويل لضبط قيم البت:

 template<typename ...Ts> void setRequirements() { (mRequirements.set(Ts::type), ...); } 

getManagedEntities عبارة عن getManagedEntities الفئات التي تم إنشاؤها للوصول إلى الكيانات التي تتم معالجتها:

 const std::vector<Entity>& getManagedEntities() const { return mManagedEntities; } 

تقوم بإرجاع مرجع ثابت بحيث لا تحاول الفئات التي تم إنشاؤها تعديل mManagedEntities .

onManagedEntityAdded و onManagedEntityRemoved فارغة. سيتم إعادة تعريفها لاحقًا. سيتم استدعاء هذه الطرق عند إضافة كيان إلى mManagedEntities أو حذفه.

ستكون الطرق التالية خاصة ويمكن الوصول إليها فقط من EntityManager ، والتي تم إعلانها EntityManager .

سيتم استدعاء setUp بواسطة مدير الكيان لتعيين معرف للنظام. ثم يمكن استخدامه لفهرسة المصفوفات:

 void setUp(std::size_t type) { mType = type; } 

يتم استدعاء onEntityUpdated عندما يتغير الكيان ، أي عند إضافة أو إزالة مكون. يتحقق النظام من تلبية المتطلبات وما إذا كان الكيان قد تمت معالجته بالفعل. إذا كان يفي بالمتطلبات ولم تتم معالجته بعد ، فسيضيفه النظام. ومع ذلك ، إذا لم يستوف الكيان المتطلبات وتمت معالجته بالفعل ، فسيحذفه النظام. في جميع الحالات الأخرى ، لا يقوم النظام بأي شيء:

 void onEntityUpdated(Entity entity, const std::bitset<ComponentCount>& components) { auto satisfied = (mRequirements & components) == mRequirements; auto managed = mEntityToManagedEntity.find(entity) != std::end(mEntityToManagedEntity); if (satisfied && !managed) addEntity(entity); else if (!satisfied && managed) removeEntity(entity); } 

يتم استدعاء onEntityRemoved بواسطة مدير الكيان عند حذف الكيان. إذا تمت معالجة الكيان بواسطة النظام ، فسيحذفه:

 void onEntityRemoved(Entity entity) { if (mEntityToManagedEntity.find(entity) != std::end(mEntityToManagedEntity)) removeEntity(entity); } 

addEntity و removeEntity هما فقط طرق مساعد.

addEntity بتعيين الرابط للانتقال من الكيان المُضاف بواسطة فهرسه في mManagedEntities ، ويضيف الكيان ويستدعي onManagedEntityAdded :

 void addEntity(Entity entity) { mEntityToManagedEntity[entity] = static_cast<Index>(mManagedEntities.size()); mManagedEntities.emplace_back(entity); onManagedEntityAdded(entity); } 

removeEntity أولاً يستدعي onManagedEntityRemoved . ثم ينقل آخر كيان معالج في فهرس الكيان الذي يتم حذفه. يقوم بتحديث المرجع إلى الكيان المنقول. في النهاية ، يحذف الكيان المراد حذفه من mManagedEntities و mEntityToManagedEntity :

 void removeEntity(Entity entity) { onManagedEntityRemoved(entity); auto index = mEntityToManagedEntity[entity]; mEntityToManagedEntity[mManagedEntities.back()] = index; mEntityToManagedEntity.erase(entity); mManagedEntities[index] = mManagedEntities.back(); mManagedEntities.pop_back(); } 

EntityManager


كل المنطق المهم هو في الفصول الأخرى. مدير الكيان ببساطة يربط كل شيء معا.

دعنا نلقي نظرة على إعلانه:

 template<std::size_t ComponentCount, std::size_t SystemCount> class EntityManager { public: template<typename T> void registerComponent(); template<typename T, typename ...Args> T* createSystem(Args&& ...args); void reserve(std::size_t size); Entity createEntity(); void removeEntity(Entity entity); template<typename T> bool hasComponent(Entity entity) const; template<typename ...Ts> bool hasComponents(Entity entity) const; template<typename T> T& getComponent(Entity entity); template<typename T> const T& getComponent(Entity entity) const; template<typename ...Ts> std::tuple<Ts&...> getComponents(Entity entity); template<typename ...Ts> std::tuple<const Ts&...> getComponents(Entity entity) const; template<typename T, typename... Args> void addComponent(Entity entity, Args&&... args); template<typename T> void removeComponent(Entity entity); template<typename T> Entity getOwner(const T& component) const; private: std::array<std::unique_ptr<BaseComponentContainer>, ComponentCount> mComponentContainers; EntityContainer<ComponentCount, SystemCount> mEntities; std::vector<std::unique_ptr<System<ComponentCount, SystemCount>>> mSystems; template<typename T> void checkComponentType() const; template<typename ...Ts> void checkComponentTypes() const; template<typename T> auto getComponentContainer(); template<typename T> auto getComponentContainer() const; }; 

EntityManager فئة EntityManager على ثلاثة متغيرات mComponentContainers : mComponentContainers ، الذي يخزن std::unique_ptr إلى BaseComponentContainer ، mEntities ، التي هي مجرد مثيل mSystems ، و mSystems ، التي تخزّن مؤشرات unique_ptr إلى System .

يمتلك الفصل العديد من الطرق ، لكن في الحقيقة كلها بسيطة للغاية.

دعنا أولاً نلقي نظرة على getComponentContainer ، والتي تُرجع مؤشرًا إلى حاوية مكون تعالج مكونات النوع T :

 template<typename T> auto getComponentContainer() { return static_cast<ComponentContainer<T, ComponentCount, SystemCount>*>(mComponentContainers[T::type].get()); } 

وظيفة المساعد الأخرى هي checkComponentType ، والذي يتحقق ببساطة من أن معرف نوع المكون أقل من الحد الأقصى لعدد المكونات:

 template<typename T> void checkComponentType() const { static_assert(T::type < ComponentCount); } 

يستخدم checkComponentTypes لإجراء عدة أنواع من الاختبارات:

 template<typename ...Ts> void checkComponentTypes() const { (checkComponentType<Ts>(), ...); } 

registerComponent ينشئ حاوية جديدة من المكونات من النوع المحدد:

 template<typename T> void registerComponent() { checkComponentType<T>(); mComponentContainers[T::type] = std::make_unique<ComponentContainer<T, ComponentCount, SystemCount>>( mEntities.getEntityToBitset()); } 

createSystem ينشئ نظامًا جديدًا من النوع المحدد ويحدد نوعه:

 template<typename T, typename ...Args> T* createSystem(Args&& ...args) { auto type = mSystems.size(); auto& system = mSystems.emplace_back(std::make_unique<T>(std::forward<Args>(args)...)); system->setUp(type); return static_cast<T*>(system.get()); } 

يستدعي الأسلوب reserve الأساليب reserve EntityContainer ComponentContainer و EntityContainer :

 void reserve(std::size_t size) { for (auto i = std::size_t(0); i < ComponentCount; ++i) { if (mComponentContainers[i]) mComponentContainers[i]->reserve(size); } mEntities.reserve(size); } 

تقوم طريقة createEntity بإرجاع نتيجة طريقة create مدير EntityManager :

 Entity createEntity() { return mEntities.create(); } 

يستخدم hasComponent مجموعة من وحدات بت الكيانات للتحقق بسرعة من أن هذا الكيان يحتوي على مكون من النوع المحدد:

 template<typename T> bool hasComponent(Entity entity) const { checkComponentType<T>(); return mEntities.getBitset(entity)[T::type]; } 

يستخدم hasComponents تعبير تحويل ملفوف لإنشاء مجموعة من البتات تشير إلى المكونات المطلوبة ، ثم يستخدمه مع مجموعة من وحدات بت الكيان للتحقق مما إذا كان الكيان يحتوي على جميع المكونات المطلوبة:

 template<typename ...Ts> bool hasComponents(Entity entity) const { checkComponentTypes<Ts...>(); auto requirements = std::bitset<ComponentCount>(); (requirements.set(Ts::type), ...); return (requirements & mEntities.getBitset(entity)) == requirements; } 

يعيد getComponent توجيه الطلب إلى حاوية المكون المطلوبة:

 template<typename T> T& getComponent(Entity entity) { checkComponentType<T>(); return getComponentContainer<T>()->get(entity); } 

إرجاع getComponents tuple من الارتباطات إلى المكونات المطلوبة. للقيام بذلك ، يستخدم std::tie وتعبير الإلتواء:

 template<typename ...Ts> std::tuple<Ts&...> getComponents(Entity entity) { checkComponentTypes<Ts...>(); return std::tie(getComponentContainer<Ts>()->get(entity)...); } 

addComponent و removeComponent إعادة توجيه الطلب إلى حاوية المكون المطلوب ، ومن ثم استدعاء onEntityUpdated النظام onEntityUpdated :

 template<typename T, typename... Args> void addComponent(Entity entity, Args&&... args) { checkComponentType<T>(); getComponentContainer<T>()->add(entity, std::forward<Args>(args)...); // Send message to systems const auto& bitset = mEntities.getBitset(entity); for (auto& system : mSystems) system->onEntityUpdated(entity, bitset); } template<typename T> void removeComponent(Entity entity) { checkComponentType<T>(); getComponentContainer<T>()->remove(entity); // Send message to systems const auto& bitset = mEntities.getBitset(entity); for (auto& system : mSystems) system->onEntityUpdated(entity, bitset); } 

أخيرًا ، يعيد getOwner توجيه الطلب إلى مكون الحاوية المطلوب:

 template<typename T> Entity getOwner(const T& component) const { checkComponentType<T>(); return getComponentContainer<T>()->getOwner(component); } 

كان هذا أول تطبيق لي. يتكون من 357 سطر فقط من الكود. كل رمز يمكن العثور عليها في هذا الموضوع .

التنميط والمعايير


المعايير


لقد حان الوقت لتقييم أول تطبيق ECS!

وهنا النتائج:




القالب جداول جيدا بما فيه الكفاية! يتشابه عدد المعالجات في الثانية تقريبًا عند زيادة عدد الكيانات وتغيير التوصيفات (A و AA و AAA).

بالإضافة إلى ذلك ، فإنه يتطور بشكل جيد مع زيادة عدد المكونات في الكيانات. عندما نقوم بالتكرار من خلال الكيانات التي تحتوي على ثلاثة مكونات ، تحدث بشكل أبطأ ثلاث مرات من التكرار من خلال الكيانات ذات المكون الواحد. هذا متوقع لأننا بحاجة إلى الحصول على ثلاثة مكونات.

ذاكرة التخزين المؤقت يخطئ


للتحقق من عدد أخطاء ذاكرة التخزين المؤقت ، قمت بتشغيل مثال cachegrind مأخوذ من هنا .

هذه هي النتيجة لـ 10000 كيان:

==1652== D refs: 277,577,353 (254,775,159 rd + 22,802,194 wr)
==1652== D1 misses: 20,814,368 ( 20,759,914 rd + 54,454 wr)
==1652== LLd misses: 43,483 ( 7,847 rd + 35,636 wr)
==1652== D1 miss rate: 7.5% ( 8.1% + 0.2% )
==1652== LLd miss rate: 0.0% ( 0.0% + 0.2% )


هذه هي النتيجة لـ 100000 كيان:

==1738== D refs: 2,762,879,670 (2,539,368,564 rd + 223,511,106 wr)
==1738== D1 misses: 207,415,181 ( 206,902,072 rd + 513,109 wr)
==1738== LLd misses: 207,274,328 ( 206,789,289 rd + 485,039 wr)
==1738== D1 miss rate: 7.5% ( 8.1% + 0.2% )
==1738== LLd miss rate: 7.5% ( 8.1% + 0.2% )


النتائج جيدة جدا. إنه أمر غريب بعض الشيء أن هناك الكثير من LLd يفتقد إلى 100000 كيان.

جانبي


لفهم أجزاء التنفيذ الحالي التي تستغرق وقتًا أطول ، قمت بتوضيح المثال باستخدام gprof .

هذه هي النتيجة:

Flat profile:

Each sample counts as 0.01 seconds.
% cumulative self self total
time seconds seconds calls ms/call ms/call name
57.45 1.16 1.16 200300000 0.00 0.00 std::__detail::_Map_base<unsigned int, std::pair<unsigned int const, unsigned int>, std::allocator<std::pair<unsigned int const, unsigned int> >, std::__detail::_Select1st, std::equal_to<unsigned int>, std::hash<unsigned int>, std::__detail::_Mod_range_hashing, std::__detail::_Default_ranged_hash, std::__detail::_Prime_rehash_policy, std::__detail::_Hashtable_traits<false, false, true>, true>::operator[](unsigned int const&)
19.31 1.55 0.39 main
16.34 1.88 0.33 200500000 0.00 0.00 std::_Hashtable<unsigned int, std::pair<unsigned int const, unsigned int>, std::allocator<std::pair<unsigned int const, unsigned int> >, std::__detail::_Select1st, std::equal_to<unsigned int>, std::hash<unsigned int>, std::__detail::_Mod_range_hashing, std::__detail::_Default_ranged_hash, std::__detail::_Prime_rehash_policy, std::__detail::_Hashtable_traits<false, false, true> >::_M_find_before_node(unsigned long, unsigned int const&, unsigned long) const
3.96 1.96 0.08 300000 0.00 0.00 std::_Hashtable<unsigned int, std::pair<unsigned int const, unsigned int>, std::allocator<std::pair<unsigned int const, unsigned int> >, std::__detail::_Select1st, std::equal_to<unsigned int>, std::hash<unsigned int>, std::__detail::_Mod_range_hashing, std::__detail::_Default_ranged_hash, std::__detail::_Prime_rehash_policy, std::__detail::_Hashtable_traits<false, false, true> >::_M_insert_unique_node(unsigned long, unsigned long, std::__detail::_Hash_node<std::pair<unsigned int const, unsigned int>, false>*)
2.48 2.01 0.05 300000 0.00 0.00 unsigned int& std::vector<unsigned int, std::allocator<unsigned int> >::emplace_back<unsigned int&>(unsigned int&)
0.50 2.02 0.01 3 3.33 3.33 std::_Hashtable<unsigned int, std::pair<unsigned int const, unsigned int>, std::allocator<std::pair<unsigned int const, unsigned int> >, std::__detail::_Select1st, std::equal_to<unsigned int>, std::hash<unsigned int>, std::__detail::_Mod_range_hashing, std::__detail::_Default_ranged_hash, std::__detail::_Prime_rehash_policy, std::__detail::_Hashtable_traits<false, false, true> >::~_Hashtable()
0.00 2.02 0.00 200000 0.00 0.00 std::_Hashtable<unsigned int, std::pair<unsigned int const, unsigned int>, std::allocator<std::pair<unsigned int const, unsigned int> >, std::__detail::_Select1st, std::equal_to<unsigned int>, std::hash<unsigned int>, std::__detail::_Mod_range_hashing, std::__detail::_Default_ranged_hash, std::__detail::_Prime_rehash_policy, std::__detail::_Hashtable_traits<false, false, true> >::find(unsigned int const&)


قد تكون النتائج مشوهة قليلاً لأنني جمعت -O1 gprof شيئًا ذا معنى. يبدو أنه عند زيادة مستوى التحسين ، يبدأ المترجم في تضمين كل شيء بقوة ولا يقول gprof شيئًا تقريبًا.

وفقًا لـ gprof ، فإن عنق الزجاجة الواضح في هذا التطبيق هو std::unordered_map . إذا كنا نريد تحسينه ، فيجب أن نحاول التخلص منه.

مقارنة مع std::map


أصبحت فضوليًا حول الفرق في الأداء بين std::unordered_mapو std::map، لذلك استبدلت كل شيء في الكود std::unordered_mapبـ std::map. هذا التطبيق متاح هنا ،

وهنا النتائج المرجعية:




يمكننا أن نرى أن التنفيذ هذه المرة لا يتطور بشكل جيد مع زيادة عدد الكيانات. وحتى مع وجود 1000 كيان ، يكون التكرار بطيئًا في التكرار مثل الإصدار c std::unordered_map.

استنتاج


لقد أنشأنا مكتبة بسيطة ولكنها عملية بالفعل لقالب نظام الكيان المكون. في المستقبل ، سوف نستخدمها كأساس للتحسينات والتحسينات.

في الجزء التالي ، سنبين كيفية زيادة الإنتاجية عن طريق استبدال std::unordered_mapبـ std::vector. بالإضافة إلى ذلك ، سنبين كيفية تخصيص أنواع المعرفات تلقائيًا للمكونات.

استبدال std :: unordered_map بـ std :: vector


, std::unordered_map . std::unordered_map mEntityToComponent ComponentContainer mEntityToManagedEntity System std::vector .


, .

, vector mEntityToComponent mEntityToManagedEntity . , vector EntityContainer , id . vector .

.

النتائج


, , :




,
.

, ! . . : , .

, .

cachegrind 10 000 :

==1374== D refs: 94,563,949 (72,082,880 rd + 22,481,069 wr)
==1374== D1 misses: 4,813,780 ( 4,417,702 rd + 396,078 wr)
==1374== LLd misses: 378,905 ( 9,626 rd + 369,279 wr)
==1374== D1 miss rate: 5.1% ( 6.1% + 1.8% )
==1374== LLd miss rate: 0.4% ( 0.0% + 1.6% )


100 000 :

==1307== D refs: 938,405,796 (715,424,940 rd + 222,980,856 wr)
==1307== D1 misses: 51,034,738 ( 44,045,090 rd + 6,989,648 wr)
==1307== LLd misses: 5,866,508 ( 1,997,948 rd + 3,868,560 wr)
==1307== D1 miss rate: 5.4% ( 6.2% + 3.1% )
==1307== LLd miss rate: 0.6% ( 0.3% + 1.7% )


, .


, , .


id .

, id , CRTP :

 template<typename T> class Component { public: static const std::size_t type; }; std::size_t generateComponentType() { static auto counter = std::size_t(0); return counter++; } template<typename T> const std::size_t Component<T>::type = generateComponentType(); 

, id , .

.

النتائج


:




للخلق والحذف ، بقيت النتائج كما هي تقريبا. ومع ذلك ، يمكنك أن ترى أن التكرار أصبح أبطأ قليلاً ، حوالي 10 ٪.

يمكن تفسير هذا التباطؤ من خلال حقيقة أن المحول البرمجي يستخدم لمعرفة معرفات الكتابة في وقت الترجمة ، مما يعني أنه يمكن تحسين الكود بشكل أفضل.

التعيين اليدوي لأنواع المعرّف غير مريح إلى حد ما ويمكن أن يؤدي إلى حدوث أخطاء. وبالتالي ، حتى لو قللنا الأداء قليلاً ، فإنه لا يزال تحسينًا في قابلية استخدام مكتبة ECS الخاصة بنا.

أفكار لمزيد من التحسينات


قبل أن أختتم هذه المقالة ، أود أن أشارككم أفكارًا لتحسينات أخرى. لم أقم بتنفيذها حتى الآن ، لكن ربما سأفعلها في المستقبل.

عدد ديناميكي من المكونات والأنظمة


. , std::array EntityManager std::vector .

std::bitset . std::vector<bitset<ComponentCount>> EntityContainer std::vector<char> . BitsetView , , std::bitset .

: mEntityToComponent , .


في الوقت الحالي ، إذا كان النظام يريد التكرار التكراري لمكونات الكيانات التي يعالجها ، فنحن بحاجة إلى القيام بذلك على النحو التالي:

 for (const auto& entity : getManagedEntities()) { auto [position, velocity] = mEntityManager.getComponents<Position, Velocity>(entity); ... } 

سيكون أجمل وأبسط إذا استطعنا فعل شيء مثل هذا:

 for (auto& [position, velocity] : mEntityManager.getComponents<Position, Velocity>(mManagedEntities)) { ... } 

std::view::transform C++20
ranges .

, . range , .

EntityRangeView , , , std::vector . begin , end , . , .


في التطبيق الحالي ، عند إضافة أو إزالة مكون من كيان ما ، فإننا ندعو onEntityUpdatedجميع الأنظمة. هذا غير فعال إلى حد ما لأن العديد من الأنظمة ليست مهتمة بنوع المكون الذي تم تغييره للتو.

لتقليل الضرر ، يمكننا تخزين المؤشرات على الأنظمة المهتمة بنوع المكونات المحدد في بنية البيانات ، على سبيل المثال std::array<std::vector<System<ComponentCount, SystemCount>>, ComponentCount>. ثم ، عند إضافة أو إزالة مكون ، فإننا ببساطة نسمي طريقة onEntityUpdatedالأنظمة المهتمة بهذا المكون.

مجموعات فرعية من الكيانات التي يديرها مدير الكيان بدلاً من الأنظمة


.

, , . , , , .

. . , :

 for (const auto& entity : mEntityManager.getEntitiesWith<Position, Velocity>()) { ... } 

استنتاج


حتى الآن هذه هي نهاية مقال عن تنفيذ نظام الكيان المكون. إذا قمت بإجراء تحسينات أخرى ، فقد أكتب مقالات جديدة في المستقبل.

التنفيذ الموصوف في المقالة بسيط للغاية: يتكون من أقل من 500 سطر من التعليمات البرمجية ، ولديه أداء جيد أيضًا. تتحقق جميع المعاملات لوقت ثابت (مطفأ). علاوة على ذلك ، في الممارسة العملية ، فإنه يستخدم ذاكرة التخزين المؤقت على النحو الأمثل ويستقبل الكيانات ويكررها بسرعة.

آمل أن يكون هذا المقال مثيرًا أو مفيدًا لك.

قراءة إضافية


فيما يلي بعض المصادر المفيدة لإجراء دراسة أكثر تعمقًا لنمط نظام الكيان المكون:

  • كتبت ميشيل كيني ، مؤلفة entt ، سلسلة مثيرة للاهتمام للغاية من المقالات حول نظام مكون الكيان تسمى ECS ذهابًا وإيابًا .
  • يحتوي Entity Systems Wiki على معلومات وروابط مفيدة للغاية.

Source: https://habr.com/ru/post/ar459288/


All Articles