ECS 아키텍처 패턴 (내 게임, Penitence를 때려친 이유)
첫 글을 뭘로 할까 하다가, 내가 최근에 찍먹해본 구조에 대해 말해보고자 한다. 음음.
바로 Entity-Component-System(줄여서, ECS) 이다.
1. 어쩌다...알게 됐는지
올해 초에 Rust 언어를 사용하는 Bevy엔진 소개 영상을 봤다.
Bevy는 특이하게 전형적인 OOP 구조(Game Object, Actor...)를 따르지 않는다.
ECS의 등장이었다.
자, ECS는 어떤 식으로 동작하는가?
우선 이를 알아보기 위해 데이터에 대해 먼저 알아볼 필요가 있다.
2. 데이터 지향 프로그래밍 (Data Oriented Programming)
머릿 속으로 아무 객체(Object)나 떠올려 보라.
OOP의 익숙한 개발자들은 뭐, 고양이, 강아지, 도날드 트럼프 등등 여러 객체를 생각해냈을 것이다.
뭐, 그냥 사람을 객체로 나타낸다 치자.
(이하 모든 예시 코드는 내가 C++ 개발자니 그렇게 됐다 ㅇㅇ)
#include <iostream>
#include <string>
class Person
{
public:
// 여러 생성자들...
// 복사 / 이동 / 할당 연산자...
// 그리고 뭐 또 이상한 게터 세터 어쩌구 저쩌구...
void Walk() // 그냥 걷는 함수
{
std::cout << name << "is walking" << std::endl;
}
protected:
std::string name; // 이름
float height; // 신장
float weight; // 몸무게
};
현재 사람 객체의 멤버는 name, height, weight이다.
각각의 자료형은 8바이트 문자열과 4바이트 부동소수점 소수 두개.
만약에 무슨, 군 장병 관리 벡터를 만들어 순회한다 가정해보자.
std::vector<Person> soldiers;
for(const auto& soldier : soldiers)
soldier.Walk();
그럼 이 벡터는, 구조체의 배열(Array Of Structure)이 된다.
이게 문제다.
읭? 괜찮은거 아닌가요? 뭐가 문제죠? 라고 묻는다면, 물론 개발할땐, 편하겠지. 그것이 OOP의 목적이니까잉.
하지만 전공자라면 컴퓨터구조 시간에 들어봤을법한 녀석이 등장한다면 얘기가 달라진다!
그것은 바로 캐시(Cache)다.
그냥, 쉬운 얘시를 들어보자.
당신은 지금부터 도서관 사서임.
근데, 책들이 주제, 초성, 출간 날짜 상관없이 아무데나 막 꽂혀 있음.
귀찮아 죽고 싶을거 아닌가?
여기서 중요한 건, 감정이 아니라 이유다.
그 귀찮음으로 인한 자가 소멸 욕구가 어디서 기인하는지 알아야 한다.
책이 있는 위치가 지 맴대로라면 왜 힘들어질까.
정답은, 겁나 왔다리갔다리 해야하기 때문이다.
내가 생각하기엔, S책은 저쪽에 있어.
-> 아, 아니었네. 저긴가.
-> 또 아니네. 썅.
이렇게 된다는 거다.
이런 상황을 캐시도 겪는데, 이를 캐시 미스라 한단다.
그리고 조금이라도 지능이 있는 사람이라면 절대 이렇게 관리하지 않을거다.
최소한, 책이 어떤 분야의 서적인지 정도는...코팅해서 앞장에다 불히지 않겠나.
근데, 캐시도 똑같다.
잘 생각해보라.
컴퓨터 하드웨어에서 DATA는 결국 메모리 공간이다.
Person 객체의 메모리는 다음과 같이 모델링 가능하다.
Person | ||
---|---|---|
name(8 byte) | height(4 byte) | weight(4 byte) |
자, 이제 soldiers 벡터를 순회한다 생각해보자.
포인터가 처음에는 8바이트짜리 name을 가리키고 순서대로 height, weight를 가리킬 것이다.
포인터는 어떨때에는 8바이트를 움직여야 하고 어떨 때는 4바이트를 움직여야 한다.
자, 이러한 방식 때문에 메모리 참조에 생각보다 많은 (정말이다.) 리소스가 낭비된다.
그럼 어떻게 해야함?
이러한 문제를 해결할 좋은 방법 중 하나는,
객체 안에 있는 데이터와 로직을 분리하는 것이다.
음...원래 코드를 다음과 같이 만들어 보자.
#include <iostream>
#include <string>
// 클래스 대신 알기 쉽게 네임 스페이스
namespace Person
{
std::string name;
float height;
float weight;
void Walk(const Person::name& _name)
{
std::cout << _name << "is walking" << std::endl;
}
};
std::vector<Person::name> soldiers;
// 순회
for(const auto& name : soldiers)
Person::Walk(name);
"어? 그럼 신장이나 몸무게를 순회하려면 어떻게 하나요?"
그 질문에 대한 답은 다음과 같다.
#include <unordered_map>
std::unordered_map<Person::name, Person::height> soldier_heights;
void PrintHeight(float _h)
{
std::cout << _h << std::endl;
}
// 순회
for(const auto& name : soldiers)
PrintHeight(soldier_heights[name]);
급조한 코드라서 좀...병신처럼 보이지만, 핵심 아이디어는 이거다!
이런식으로 데이터와 로직을 객체라는 감옥해서 해방시키는 것이다!
그리고 이렇게 배열의 구조체(Structure Of Arrays)로 만드는게,
데이터 지향 프로그래밍, 데이터 지향 설계이다.
3. 그래서 ECS가 뭐임
음...우선 위에서 soldiers 벡터를 순회했을때, Person::name으로 순회했던 것을 다음과 같이 바꿔보자.
#include <string>
#include <vector>
#include <unordered_map>
namespace Entity
{
using id = int;
}
namespace Component
{
using name = std::string;
using height = float;
using weight = float;
};
namespace System
{
auto walk = [&](Entity::id _id)
{
std::cout << names[_id] << "is walking\n";
};
std::unordered_map<Entity::id, Component::name> names;
}
int main()
{
std::vector<Entity::id> soldiers;
// 대충 엔티티 초기화 코드 ~~~
for(const int id : soldiers)
System::walk(id);
}
위 코드를 보았을때, Person 객체의 흔적이 보이는가?
우리처럼 Person을 수정했다는 사실을 아는 것이 아니라면,
아마 찾기에 어려울 것이다.
코드에 이미 힌트가 나와있다.
Entity란, 특정 데이터를 식별할 수 있는 최소한의 ID이다.
나는 위 코드에서 int로 id를 표현했다.
슬슬, 엔진부터 시작해 개발 모든 부분을 함께 하려했던 나의 성장 프로젝트,
'Penitence'에 대해 말할 수 있게 된거 같다. (프로젝트 링크)
참고로, Penitence에서는 Entity가 다음과 같이 정의가 되어있다.
#include <cstdint>
namespace MIR // 엔진 이름
{
namespace ECS // ECS 아키텍처와 관련된 그룹
{
struct Entity // 사실 이름공간으로 사용해도 괜찮았을듯...
{
using ID = uint16_t;
};
}
}
Component란, 데이터다.
자, DOP에서는 데이터와 로직을 분리한다고 설명했다.
거기서 말하는 데이터를 ECS에서는 컴포넌트라 한다.
상용엔진 게임 개발자라면 컴포넌트의 개념에 대해서 잘 알고 있을텐데, 그거 맞다.
음...말이 나왔으니 말인데,
ECS를 적용시킨 게임 구조 플로우는 대충 다음과 같이 흘러간다.
게임 초기화 | 게임 업데이트 | 게임 셧다운 |
---|---|---|
엔티티 ID 설정 | 컴포넌트에 시스템 적용 | 동적 할당된 메모리 해제 |
컴포넌트 초기값 설정 | 시스템으로 업데이트된 컴포넌트 저장 | (그 외 이것저것) |
나 같은 경우는, 컴포넌트 구조가 독특하다.
(코드 난해함 주의.)
// 컴포넌트 베이스 클래스
namespace MIR
{
constexpr std::uint8_t MAX_COMPONENTS = 0xFF;
struct Component
{
using Tag = std::uint32_t;
virtual ~Component() = default;
};
}
namespace MIR
{
// 풀 베이스 클래스
class PoolBase
{
public:
virtual ~PoolBase() = default;
};
// 컴포넌트 풀
template <typename T>
class ComponentPool : public PoolBase
{
public:
static constexpr std::size_t MAX_SIZE = 0xFF;
ComponentPool() = default;
template <typename... Args>
inline std::unique_ptr<Component,
std::function<void(Component*)>> Acquire(Args&&... args)
{
T* component;
if (pool.empty())
{
// 풀에 여유 컴포넌트가 없으므로 새로운 컴포넌트 할당
component = new T(std::forward<Args>(args)...);
}
else
{
// 기존 컴포넌트를 재사용
component = pool.top();
pool.pop();
// 새 인자로 재초기화
*component = T(std::forward<Args>(args)...);
}
// 커스텀 해제자 정의: unique_ptr이 해제될 때 호출됨
auto deleter = [this](Component* ptr)
{
this->Release(static_cast<T*>(ptr));
};
return std::unique_ptr<Component,
std::function<void(Component*)>>(component, deleter);
}
private:
std::stack<T*> pool;
inline void Release(T* component)
{
if (component)
{
if (pool.size() < MAX_SIZE)
pool.push(component);
else
delete component;
}
}
};
};
여기까지가 컴포넌트를 관리하는 내 방식임.
Component를 관리할때 편하게 type_info, index를 사용할까 했지만,
엔티티를 ID로 구분했던 것처럼, 비슷한 방식을 채택. (단, ID라는 이름은 Entity::ID와 겹쳐서 헷갈릴 수 있기에, Tag라는 새로운 식별자를 정의함.)
또한, 캐시 친화력을 최대로 끌어오기 위해 스택을 이용해서 컴포넌트 풀을 만듦.
아래는 매니저 클래스. (더 난해함)
namespace MIR
{
namespace ECS
{
using Mask = std::bitset<MAX_COMPONENTS>;
using Data = std::unordered_map<Component::Tag,
std::unique_ptr<Component, std::function<void(Component*)>>>;
using Pool = std::shared_ptr<PoolBase>;
class Manager
{
public:
Manager() = default;
~Manager();
Entity::ID CreateEntity();
void DestoryEntity(Entity::ID id);
template <typename T, typename... Args>
requires std::constructible_from<T, Args...>
inline void AddComponent(Entity::ID id, Args&&... args)
{
if (masks.find(id) == masks.end())
throw std::runtime_error("Invalid Entity ID");
Component::Tag tag = GetTag<T>();
if (masks[id][tag])
throw std::runtime_error("Component already exists for this Entity");
masks[id].set(tag);
if(pools.find(tag) == pools.end())
pools[tag] = std::make_shared<ComponentPool<T>>();
auto pool = std::static_pointer_cast<ComponentPool<T>>(pools[tag]);
auto component = pool->Acquire(std::forward<Args>(args)...);
components[id][tag] = std::move(component);
}
template <typename T>
inline T* GetComponent(Entity::ID id)
{
Component::Tag tag = GetTag<T>();
if(!masks[id][tag])
return nullptr;
auto it = components.find(id);
if(it != components.end() && it->second.find(tag) != it->second.end())
return static_cast<T*>(it->second[tag].get());
return nullptr;
}
template <typename T>
inline bool HasComponent(Entity::ID id)
{
Component::Tag tag = GetTag<T>();
return masks[id][tag];
}
template <typename... Components>
inline std::vector<Entity::ID> Query() const
{
std::vector<Entity::ID> result;
for(const auto& [id, mask] : masks)
if((mask.test(GetTag<Components>()) && ...))
result.emplace_back(id);
return result;
}
private:
/// 엔티티별 컴포넌트 데이터를 관리하는 맵
std::unordered_map<Entity::ID, Data> components;
/// 엔티티별 컴포넌트 보유 마스크를 관리하는 맵
std::unordered_map<Entity::ID, Mask> masks;
/// 컴포넌트 태그별 풀(Pool)을 관리하는 맵
std::unordered_map<Component::Tag, Pool> pools;
/// 재사용 가능한 엔티티 ID를 관리하는 큐
std::queue<Entity::ID> id_queue;
/// 다음에 할당할 엔티티 ID
inline static Entity::ID next_id = 0;
/// 다음에 할당할 컴포넌트 태그
inline static Component::Tag next_tag;
inline Entity::ID GetID()
{
return next_id++;
}
template <typename T>
inline static Component::Tag GetTag()
{
static Component::Tag tag = next_tag++;
return tag;
}
};
}
}
와 지금봐도 너무 소름돋는 코드임.
Mask <<<< 얘가 내 아이디어의 GOAT임.
시스템 업데이트 시, 엔티티들의 상태를 확인하고 순회할때 Tag로 하나 하나 찾아보게 하는게 너무 짜침...
그래서 내가 생각한 것중 하나가 다음과 같다.
'특정 시스템에서 업데이트 될 몇가지 컴포넌트를 엔티티가 '무조건' 가지고 있다 보장시키면, 모든 엔티티를 검사할 필요가 없지 않나?'
그렇게, Mask라는 구조를 만들고, Query()라는 템플릿 함수를 만듦.
Query()의 특징은 파라미터 팩 내부에 들어온 모든 컴포넌트를 가지는 엔티티 벡터 반환임.
그리고 그 연산이, 비트셋으로 빠르게 동작하고, 코드를 읽는 사람도 어떤 역할을 하는 코드인지 쉽게(?)알 수 있다고 '생각함.'
암튼, 이제 다음 토픽.
System이란, 로직이다.
이미 시스템에 대해 컴포넌트에서 반절 이상 설명한 것 같다.
그냥 코드를 보면서 이해해보자.
namespace MIR
{
class System
{
public:
System() = default;
virtual ~System() = default;
virtual void Update(ECS::Manager& manager, const float dt) = 0;
};
}
업데이트에서 엔티티를 가져오기 위해 의존성 주입으로 ECS::Manager를 획득한다.
어떤 식으로 돌아가는지 좀 더 알기 위해, 내가 짠 코드를 또 한번 보자.
const float JUMP_SPEED = 500.f; ///< 점프 시 상승 속도
const float GRAVITY = 980.f; ///< 중력 가속도 (단위: 픽셀/초² 가정)
const float LEVEL = 500.f; ///< 지면 레벨(y좌표)
void Movement::Update(Manager& manager, const float dt)
{
std::vector<Entity::ID> entities = manager.Query<Position, Velocity, PlayerState, Sprite>();
for(const auto& id : entities)
{
Position* pos = manager.GetComponent<Position>(id);
Velocity* vel = manager.GetComponent<Velocity>(id);
PlayerState* state = manager.GetComponent<PlayerState>(id);
Sprite* spr = manager.GetComponent<Sprite>(id);
// 점프 로직: 점프 상태가 아니며 지면에 있을 때 점프 명령 처리
if(state->now_state == PlayerState::Jumping
&& !state->is_jumping && pos->y >= LEVEL)
{
vel->y = -JUMP_SPEED;
state->is_jumping = true;
}
// 상태에 따른 수평 이동 속도 및 스프라이트 스케일 조정
switch(state->now_state)
{
case PlayerState::MovingLeft:
vel->x = -200.f;
spr->sprite.setScale(-0.5f, 0.5f);
break;
case PlayerState::MovingRight:
vel->x = 200.f;
spr->sprite.setScale(0.5f, 0.5f);
break;
case PlayerState::Jumping:
printf("JUMP\n");
// 점프 중 특별한 수평 변화 없음, vel->x는 유지됨(기본적으로 0)
break;
default:
// Idle 상태 등에서는 수평 속도 0
vel->x = 0.f;
break;
}
// 중력 적용: 지면 위가 아닐 경우(점프 중) 중력 가속도 적용
if(pos->y < LEVEL)
vel->y += GRAVITY * dt;
// 지면 도달 처리: 지면을 넘어가지 않도록 위치, 상태 보정
if(pos->y + vel->y * dt >= LEVEL)
{
pos->y = LEVEL;
vel->y = 0.f;
state->is_jumping = false;
// 점프 상태에서 지면 도달 시 Idle 상태로 전환
if(state->now_state == PlayerState::Jumping)
state->now_state = PlayerState::Idle;
}
// 위치 갱신: 속도 * 시간(dt)을 위치에 반영
pos->x += vel->x * dt;
pos->y += vel->y * dt;
}
}
점점 게임의 완성이 아니라, 엔진의 완성을 위한 움직임을 계속이어 나가다 보니...ㅎ
코드가 좀 그렇지만, 핵심 아이디어는 다음과 같다.
- 쿼리로 엔티티를 가져온다.
- 상황에 맞게 컴포넌트를 업데이트한다.
끝임. ㅇㅇ
이런 식으로 ECS를 만들어 본 것이었다란다.
4. 장단점
ECS 구조의 대표적인 장단점을 다음과 같이 정리하겠음.
장단점 | |
---|---|
장점 | 대규모 시뮬레이션에 용이, 병렬 프로그래밍에 용이 |
단점 | 학습 곡선, 초기 설계 잘못하면 ㅈㅈㅈ |
장단점을 따로 설명해야할 필요가 있남.
객체 순회시 사이즈가 일정한 컴포넌트만을 가져오기에 캐시 미스가 덜남.
따라서, 대규모 시뮬레이션에 좋음.
SOA(배열의 구조체들)를 사용하기에 병렬프로그래밍할때 좋음.
단점은 따로 말할 필요가 없이 코드를 보면 답 나오잖슴?
C++이랑 게임 엔진 구조 조금이라도 알아야 이해가 되는 수준의 코드임.
암튼 그렇다 하자.
5. Penitence 왜 포기함? 끈기 ㅉㅉ
원래 Penitence 프로젝트의 의의는, 다음과 같았음.
- ECS 구조 만들어보기
- 2D 게임 만들기
전반적으로, Cpp 코드 작성 레벨업이 나의 목표였다. 이 말임.
근데, 이...MIR엔진을 만들다보니...너무 매력적인거...
Penitence를 만들어야겠다는 열정이 식었음.
근데 결론적으로, 다른 방식으로 만들어질 예정임.
스탠드 얼론 형식의 게임이 아니라,
MIR엔진의 버전이 업그레이드될때 마다 엔진의 신기능을 뽐낼 수 있는 데모 컨셉 게임
이 방식으로 나오지 않을까 싶음.
(사실, Penitence의 컨셉을 버리기에는 너무 아깝기도 해서 ㅇㅇ;;;)
아, 끈기없는 것도 ㅇㅈ....
그에 대해서는 절대 할말이 없다...
6. 마무으리
첫글로 내가 만든 엔진, MIR를 소개했다.
아마, 내 생각에 MIR는 다른 방식으로(현대적인, C++을 버리고 ㅎㅎ) 리뉴얼되지 않을까!
이제 2025인데, 다들 HNY