게임 개발은 복잡한 객체와 기능을 다루는 것으로 유명합니다. 이에 대한 대응책으로 Unity는 엔티티 컴포넌트 시스템(Entity Component System, ECS)을 도입하였습니다. 이 글에서는 ECS가 게임 개발에 어떤 이점을 제공하며, 실제 코드 예시를 통해 이를 설명하겠습니다. 또한 어떨 때 사용해야 좋은지도 설명하겠습니다.
목차
[ECS는 왜 써야 하는가?]
ECS는 객체 지향 프로그래밍의 한계를 극복하기 위해 등장했습니다. 전통적인 객체 지향 프로그래밍은 복잡한 계층 구조와 관리의 어려움을 야기할 수 있습니다. ECS는 이러한 문제를 해결하고자 객체의 데이터와 동작을 분리하여 보다 모듈화된 코드를 작성할 수 있도록 돕습니다.
[ECS란?]
- 엔티티(Entity): 게임 객체를 나타냅니다. 엔티티는 컴포넌트를 가지고 있습니다.
- 컴포넌트(Component): 엔티티의 구성 요소입니다. 각각의 컴포넌트는 특정 기능을 담당하며, 데이터만을 가지고 있습니다.
- 시스템(System): 컴포넌트를 처리하는 로직을 담당합니다. 시스템은 컴포넌트의 데이터를 읽고 쓰며, 게임 로직을 구현합니다.
ECS는 객체 지향 프로그래밍과는 다르게 상속이나 객체 간의 결합을 최소화하고, 대신 컴포넌트의 조합을 통해 기능을 확장합니다. 이는 더 모듈화된 코드를 작성할 수 있도록 도와줍니다.
더 쉬운 설명을 위해서 플레이어가 총을 발사하고 몬스터를 공격하는 상황을 예시로 들어보겠습니다.
- 엔티티(Entity): 게임 레벨에 있는 모든 물체들은 엔티티입니다. 플레이어, 몬스터, 총알, 아이템 등은 모두 엔티티로 나타낼 수 있습니다.
- 컴포넌트(Component): 엔티티의 각각의 특징을 설명하는 것으로, 총을 발사하는 행동, 이동하는 속도, 체력 등을 나타냅니다. 예를 들어, 총알에는 이동 속도와 데미지 값을 갖는 컴포넌트가 있을 것입니다.
- 시스템(System): 게임 레벨에서 발생하는 특정한 동작이나 작업을 처리합니다. 예를 들어, 총알이 발사되고 난 후에는 총알이 화면을 벗어나면 삭제되어야 합니다. 이 동작은 총알 시스템이라는 시스템에서 처리될 것입니다.
// 엔티티 정의
public struct PlayerEntity : IComponentData { }
public struct MonsterEntity : IComponentData { }
public struct BulletEntity : IComponentData { }
// 컴포넌트 정의
public struct BulletComponent : IComponentData
{
public float Speed;
public int Damage;
}
// 시스템 정의
public class BulletSystem : SystemBase
{
protected override void OnUpdate()
{
float deltaTime = Time.DeltaTime;
Entities.ForEach((ref Translation translation, in BulletComponent bullet) =>
{
translation.Value += deltaTime * bullet.Speed * float3.up;
}).Schedule();
}
}
위 코드에서, PlayerEntity
, MonsterEntity
, BulletEntity
는 각각 플레이어, 몬스터, 총알을 나타내는 엔티티입니다. BulletComponent
는 총알의 속도와 데미지 값을 가지고 있습니다. BulletSystem
은 총알의 이동을 처리합니다.
[ECS를 사용할 때 장점]
- 성능
- ECS는 데이터 지향적으로 설계되어 있어 메모리 액세스를 최적화하고 병렬 처리를 통해 성능을 향상시킵니다. 예를 들어, 총알이나 몬스터와 같은 게임 객체들은 컴포넌트 형태로 메모리에 저장됩니다. 이렇게 연속된 메모리 블록에 컴포넌트를 저장함으로써 CPU 캐시의 효율성을 높이고 메모리 액세스 시간을 최소화합니다.
- 또한, ECS는 시스템을 병렬로 실행하여 CPU의 다중 코어를 최대한 활용합니다. 예를 들어, 여러 개의 총알이 동시에 이동할 때 각 총알의 이동을 병렬로 처리하여 성능을 향상시킵니다.
- 모듈화
- ECS는 엔티티와 컴포넌트의 분리로 인해 코드의 재사용성이 높아지고 유연성이 향상됩니다. 예를 들어, 총알의 속도를 조절하거나 총알에 새로운 기능을 추가할 때 해당 기능을 가진 컴포넌트를 만들어 기존 코드에 추가하기만 하면 됩니다. 이렇게 컴포넌트를 모듈화함으로써 코드의 재사용성이 높아지고 유연성이 증가합니다.
- 간편한 아키텍처
- ECS는 구조적인 실수를 줄이고 개발자가 빠르게 이해하고 개발할 수 있는 간편한 아키텍처를 제공합니다. 예를 들어, ECS에서는 각 객체의 상태와 행동을 명확하게 분리하여 관리합니다. 이렇게 되면 개발자는 객체의 상태와 행동을 동시에 고려하지 않고 각각을 개별적으로 이해하고 수정할 수 있습니다.
- 또한, ECS는 객체 간의 관계를 명확하게 정의하고 복잡성을 줄여줍니다. 예를 들어, 총알이나 몬스터와 같은 게임 객체들은 각각의 엔티티로 나타내어져서 객체 간의 관계를 명확하게 정의할 수 있습니다. 이렇게 하면 개발자는 객체 간의 관계를 이해하고 수정하기 쉽습니다.
[ECS의 단점]
- 학습곡선
- 기존의 객체 지향 프로그래밍과는 다른 접근 방식이기에 학습하는데 오랜시간이 걸릴 수 있습니다.
- 소규모 프로젝트에서의 생산성 저하
- 엔티티와 컴포넌트와 시스템을 분리해야하다 보니 분리하는데 많은 시간을 소요하기에 소규모로 개발을 할 경우 생산성이 크게 저하 될 수 있습니다.
[사용 예시코드 제공]
[ECS로 총알의 이동 구현]
using Unity.Entities;
using Unity.Transforms;
using Unity.Mathematics;
public struct BulletComponent : IComponentData
{
public float Speed;
}
public class BulletSystem : SystemBase
{
protected override void OnUpdate()
{
float deltaTime = Time.DeltaTime;
Entities.ForEach((ref Translation translation, in BulletComponent bullet) =>
{
translation.Value += deltaTime * bullet.Speed * float3.up;
}).Schedule();
}
}
BulletComponent
구조체는 총알을 나타내는 컴포넌트입니다. IComponentData
인터페이스를 구현하여 ECS의 컴포넌트로 사용됩니다.
BulletSystem
클래스는 ECS 시스템을 구현합니다. SystemBase
를 상속받아서 필요한 메서드를 오버라이드합니다.
또한 .Schedule()
을 호출하여 작업을 스케줄링하고 병렬 처리를 가능하게 합니다.
[플레이어와 몬스터의 공격 시스템]
using Unity.Entities;
using Unity.Jobs;
using Unity.Collections;
using Unity.Mathematics;
using UnityEngine;
// 체력을 나타내는 컴포넌트
public struct HealthComponent : IComponentData
{
public int Value; // 체력 값
}
// 공격에 따른 데미지를 나타내는 컴포넌트
public struct DamageComponent : IComponentData
{
public int Value; // 데미지 값
}
// 공격 쿨타임을 나타내는 컴포넌트
public struct AttackCooldownComponent : IComponentData
{
public float Value; // 쿨타임 값
}
// 공격 범위를 나타내는 컴포넌트
public struct AttackRangeComponent : IComponentData
{
public float Value; // 공격 범위 값
}
// 플레이어를 식별하는 태그 컴포넌트
public struct IsPlayerTag : IComponentData { }
// 몬스터를 식별하는 태그 컴포넌트
public struct IsMonsterTag : IComponentData { }
// 공격 시스템
public class AttackSystem : SystemBase
{
protected override void OnUpdate()
{
float deltaTime = Time.DeltaTime; // 프레임 간 시간 간격
// 플레이어 엔티티에 대한 루프
Entities.ForEach((Entity entity, ref Translation playerTranslation, in HealthComponent playerHealth,
in AttackCooldownComponent playerCooldown, in AttackRangeComponent playerAttackRange, in IsPlayerTag playerTag) =>
{
// 몬스터 엔티티에 대한 루프
Entities.ForEach((Entity monsterEntity, ref Translation monsterTranslation, ref HealthComponent monsterHealth,
in AttackRangeComponent monsterAttackRange, in IsMonsterTag monsterTag) =>
{
// 플레이어와 몬스터 간의 거리 계산
float distance = math.distance(playerTranslation.Value, monsterTranslation.Value);
// 플레이어와 몬스터 간의 거리가 공격 범위 내에 있고, 플레이어의 공격 쿨타임이 지났을 때 공격 수행
if (distance <= playerAttackRange.Value && playerCooldown.Value <= 0)
{
// 플레이어가 몬스터를 공격하고 데미지를 입힘
if (playerHealth.Value > 0)
{
monsterHealth.Value -= 5; // 몬스터에게 데미지를 입힘
Debug.Log("Player attacks monster! Monster health: " + monsterHealth.Value); // 로그 출력
}
// 몬스터가 플레이어를 공격하고 데미지를 입힘
if (monsterHealth.Value > 0)
{
playerHealth.Value -= 5; // 플레이어에게 데미지를 입힘
Debug.Log("Monster attacks player! Player health: " + playerHealth.Value); // 로그 출력
}
// 공격 쿨타임 초기화
playerCooldown.Value = 3f;
}
});
}).Schedule(); // 엔티티 루프를 스케줄링하여 병렬 처리
// 공격 쿨타임 업데이트
Entities.ForEach((ref AttackCooldownComponent cooldown) =>
{
cooldown.Value = math.max(0f, cooldown.Value - deltaTime); // 쿨타임 감소
}).Schedule(); // 엔티티 루프를 스케줄링하여 병렬 처리
}
}
위의 코드에서는 AttackSystem
이라는 시스템을 만들어 플레이어와 몬스터 간의 공격 및 체력 변화를 처리합니다. 각 엔티티에는 공격 범위와 체력 정보를 나타내는 컴포넌트가 있으며, AttackSystem
은 이를 이용하여 플레이어와 몬스터 간의 상호작용을 관리합니다.
또한, 플레이어와 몬스터 간의 공격 쿨타임을 관리하기 위해 AttackCooldownComponent
를 사용하고, AttackSystem
은 이 쿨타임을 갱신하여 공격 가능 여부를 관리합니다.
[아이템 강화]
using Unity.Entities;
using Unity.Jobs;
using Unity.Collections;
using Unity.Mathematics;
using UnityEngine;
using Random = UnityEngine.Random;
// 강화 요청을 나타내는 컴포넌트
public struct EnhanceRequestComponent : IComponentData
{
public int GoldCost; // 강화 비용
public float SuccessRate; // 강화 성공 확률
}
// 무기 강화를 나타내는 시스템
public class EnhanceSystem : SystemBase
{
private Random _random;
protected override void OnCreate()
{
_random = new Random((uint)System.DateTime.Now.Ticks); // 랜덤 시드 초기화
}
protected override void OnUpdate()
{
float deltaTime = Time.DeltaTime; // 프레임 간 시간 간격
// 강화 요청을 처리하는 루프
Entities.ForEach((Entity entity, ref EnhanceRequestComponent enhanceRequest) =>
{
// 강화 비용만큼 골드를 소모하고 강화 성공 여부를 판정
if (enhanceRequest.GoldCost <= 1000 && Random.Range(0f, 1f) < enhanceRequest.SuccessRate)
{
// 강화에 성공하면 무기의 데미지를 10% 증가시킴
EntityManager.AddComponent<DamageIncreaseComponent>(entity); // 데미지 증가 컴포넌트 추가
Debug.Log("Enhancement succeeded! Damage increased by 10%."); // 강화 성공 로그 출력
}
else
{
Debug.Log("Enhancement failed!"); // 강화 실패 로그 출력
}
EntityManager.RemoveComponent<EnhanceRequestComponent>(entity); // 강화 요청 컴포넌트 제거
}).WithoutBurst().Run(); // 병렬 처리하지 않음
// 데미지 증가된 무기에 대한 루프
Entities.ForEach((Entity entity, ref DamageComponent damage) =>
{
// 데미지 증가 컴포넌트가 있는 경우 데미지를 10% 증가시킴
if (EntityManager.HasComponent<DamageIncreaseComponent>(entity))
{
damage.Value = (int)math.round(damage.Value * 1.1f); // 데미지 10% 증가
}
}).WithoutBurst().Run(); // 병렬 처리하지 않음
}
}
EnhanceRequestComponent는 강화 요청을 나타내며, 강화 비용과 성공 확률을 저장합니다. EnhanceSystem은 강화 요청을 처리하고 강화 성공 여부를 결정합니다. 성공한 경우 DamageIncreaseComponent를 추가하여 무기의 데미지를 10% 증가시킵니다.
[ECS를 언제사용하면 좋을까?]
ECS는 대규모의 게임이나 성능이 중요한 게임에서 유용합니다. 씬 내에서 10000개의 개체를 운용해야 한다면 유니티에서 최적화하는 것에 많은 어려움이 있습니다. 하지만 ECS를 사용한다면 10000개의 개체를 운용하는 것이 가능합니다. 하지만 소규모 프로젝트에서 최적화에 민감한 프로젝트가 아니라면 오히려 독이 될 수 있다는 것을 기억하는게 좋습니다.
[마무리 말]
ECS는 적재적소에 사용하면 프로젝트의 퀄리티나 재미를 극대화 할 수 있지만, 잘못 사용하면 오히려 생산성만 떨어지고 기존 유니티 시스템으로 만든 것보다 프로젝트의 퀄리티가 현저히 떨어질 수 있습니다. 항상 새로운 기술을 도입하고자 할 때는 충분한 Rnd를 거쳐서 사용하시기 바랍니다.