콘텐츠로 이동

ECS (Entity-Component-System) 아키텍처 패턴

ECS(Entity-Component-System)는 **데이터 지향 설계(Data-Oriented Design)**를 기반으로 한 아키텍처 패턴입니다.

전통적 OOP: ECS:
GameObject Entity (ID만 보유)
├── Health Components (순수 데이터)
├── Transform Systems (순수 로직)
└── Renderer

핵심 원칙: 데이터와 로직을 분리하고, 같은 종류의 데이터를 연속 메모리에 배치해 캐시 효율을 극대화합니다.

using EntityId = uint32_t;
struct Entity {
EntityId id;
uint32_t version; // 재사용 시 구분
bool operator==(const Entity& o) const {
return id == o.id && version == o.version;
}
};
class EntityManager {
uint32_t nextId = 0;
queue<EntityId> freeIds;
vector<uint32_t> versions;
public:
Entity create() {
if (!freeIds.empty()) {
EntityId id = freeIds.front(); freeIds.pop();
return {id, versions[id]};
}
versions.push_back(0);
return {nextId++, 0};
}
void destroy(Entity e) {
versions[e.id]++; // 버전 증가로 기존 핸들 무효화
freeIds.push(e.id);
}
bool isValid(Entity e) const {
return e.id < versions.size() && versions[e.id] == e.version;
}
};
struct Transform {
float x, y, z;
float rotX, rotY, rotZ;
float scaleX = 1, scaleY = 1, scaleZ = 1;
};
struct Velocity {
float vx, vy, vz;
};
struct Health {
float current;
float max;
};
struct Renderable {
uint32_t meshId;
uint32_t materialId;
bool visible = true;
};

SoA (Structure of Arrays) — 캐시 최적화

섹션 제목: “SoA (Structure of Arrays) — 캐시 최적화”
template<typename T>
class ComponentArray {
vector<T> data_;
vector<EntityId> entityToIndex_; // entity → 배열 인덱스
vector<EntityId> indexToEntity_; // 배열 인덱스 → entity
public:
void insert(EntityId entity, T component) {
size_t idx = data_.size();
data_.push_back(component);
entityToIndex_[entity] = idx;
indexToEntity_.push_back(entity);
}
void remove(EntityId entity) {
size_t removeIdx = entityToIndex_[entity];
size_t lastIdx = data_.size() - 1;
// 마지막 원소와 교환해 swap-remove
data_[removeIdx] = data_[lastIdx];
EntityId lastEntity = indexToEntity_[lastIdx];
entityToIndex_[lastEntity] = removeIdx;
indexToEntity_[removeIdx] = lastEntity;
data_.pop_back();
indexToEntity_.pop_back();
entityToIndex_.erase(entity);
}
T& get(EntityId entity) {
return data_[entityToIndex_[entity]];
}
// 시스템 순회: 연속 메모리 → 캐시 히트
span<T> all() { return data_; }
};

Unity DOTS / Flecs 스타일. 같은 컴포넌트 조합을 가진 Entity를 하나의 청크에 저장.

using ComponentMask = bitset<64>;
struct Archetype {
ComponentMask mask;
vector<uint8_t> chunkData; // 연속 메모리 청크
size_t entityCount = 0;
size_t chunkCapacity;
// 각 컴포넌트의 청크 내 오프셋
map<size_t, size_t> componentOffsets;
size_t entitySize = 0; // 엔티티당 바이트
template<typename... Ts>
static Archetype create(size_t capacity = 128) {
Archetype arch;
arch.chunkCapacity = capacity;
size_t offset = 0;
// 컴포넌트별 오프셋 계산
([&]{
arch.mask.set(ComponentTypeId<Ts>::id);
arch.componentOffsets[ComponentTypeId<Ts>::id] = offset;
offset += sizeof(Ts) * capacity;
arch.entitySize += sizeof(Ts);
}(), ...);
arch.chunkData.resize(offset);
return arch;
}
template<typename T>
T* getArray() {
size_t typeId = ComponentTypeId<T>::id;
return reinterpret_cast<T*>(
chunkData.data() + componentOffsets[typeId]);
}
};
class MovementSystem {
public:
void update(ComponentArray<Transform>& transforms,
ComponentArray<Velocity>& velocities,
float dt) {
// 연속 메모리 순회 — SIMD 벡터화 가능
auto& velData = velocities.all();
for (size_t i = 0; i < velData.size(); i++) {
EntityId entity = velocities.entityAt(i);
if (!transforms.has(entity)) continue;
Transform& t = transforms.get(entity);
Velocity& v = velData[i];
t.x += v.vx * dt;
t.y += v.vy * dt;
t.z += v.vz * dt;
}
}
};
class RenderSystem {
public:
void update(ComponentArray<Transform>& transforms,
ComponentArray<Renderable>& renderables) {
for (auto& r : renderables.all()) {
if (!r.visible) continue;
EntityId e = renderables.entityOf(r);
Transform& t = transforms.get(e);
submitDrawCall(r.meshId, r.materialId, t);
}
}
};
class World {
EntityManager entityMgr_;
map<size_t, any> componentArrays_;
template<typename T>
ComponentArray<T>& getArray() {
size_t id = ComponentTypeId<T>::id;
if (componentArrays_.find(id) == componentArrays_.end())
componentArrays_[id] = ComponentArray<T>{};
return any_cast<ComponentArray<T>&>(componentArrays_[id]);
}
public:
Entity createEntity() { return entityMgr_.create(); }
void destroyEntity(Entity e) { entityMgr_.destroy(e); }
template<typename T>
void addComponent(Entity e, T component) {
getArray<T>().insert(e.id, component);
}
template<typename T>
T& getComponent(Entity e) {
return getArray<T>().get(e.id);
}
template<typename T>
void removeComponent(Entity e) {
getArray<T>().remove(e.id);
}
};
World world;
// 엔티티 생성 + 컴포넌트 추가
Entity player = world.createEntity();
world.addComponent(player, Transform{0, 0, 0});
world.addComponent(player, Velocity{1, 0, 0});
world.addComponent(player, Health{100, 100});
world.addComponent(player, Renderable{meshId, matId});
// 적 100마리
for (int i = 0; i < 100; i++) {
Entity enemy = world.createEntity();
world.addComponent(enemy, Transform{(float)i * 2, 0, 0});
world.addComponent(enemy, Velocity{-0.5f, 0, 0});
world.addComponent(enemy, Health{50, 50});
}
// 시스템 업데이트 (매 프레임)
MovementSystem moveSys;
RenderSystem renderSys;
float dt = 0.016f;
moveSys.update(world.getArrayRef<Transform>(),
world.getArrayRef<Velocity>(), dt);
renderSys.update(world.getArrayRef<Transform>(),
world.getArrayRef<Renderable>());
// OOP: 캐시 미스 다발 (가상 함수, 포인터 추적)
class GameObject {
virtual void update(float dt) = 0; // 가상 함수 → vtable 조회
};
vector<unique_ptr<GameObject>> objects; // 힙 포인터 → 캐시 미스
// ECS: 연속 메모리 → 캐시 라인 최적
// Transform 배열: [T0][T1][T2]...[Tn] — 모두 L1 캐시
// 10,000 엔티티 기준: OOP ~16ms vs ECS ~2ms
// Unity DOTS ECS (Burst Compiler + Jobs)
[BurstCompile]
public partial struct MovementJob : IJobEntity {
public float DeltaTime;
void Execute(ref LocalTransform transform, in Velocity velocity) {
transform.Position += velocity.Value * DeltaTime;
}
}
// 자동으로 아키타입 쿼리 → 연속 메모리 처리
  • Entity: 고유 ID만 보유, 컴포넌트와 직접 연결 없음
  • Component: 순수 데이터, 로직 없음 (SoA로 연속 메모리 보장)
  • System: 특정 컴포넌트 조합을 가진 Entity를 일괄 처리
  • 아키타입: 동일 컴포넌트 조합을 청크 메모리에 밀집 배치
  • OOP 대비 캐시 히트율 향상으로 대량 엔티티 처리에 강점
  • Unity DOTS, Flecs, EnTT 등이 이 패턴을 채택