엔티티는 번호표이고 데이터는 청크에 줄지어 있다
ECS 는 게임 오브젝트를 이루던 것을 셋으로 쪼갭니다. 그런데 “쪼갠다” 는 말만으로는 왜 빨라지는지가 안 나옵니다. 빨라지는 이유는 쪼갠 뒤에 어떻게 다시 모아 두느냐에 있습니다.
셋의 역할
| 무엇 | 갖지 않는 것 | |
|---|---|---|
| 엔티티 | 번호표. Index 와 Version 두 정수뿐입니다 |
데이터도 로직도 없습니다 |
| 컴포넌트 | 순수한 데이터. IComponentData 를 구현한 struct |
메서드가 없습니다 |
| 시스템 | 로직. 조건에 맞는 엔티티를 훑으며 컴포넌트를 고칩니다 | 상태를 안 갖습니다 |
MonoBehaviour 는 셋이 한 덩어리입니다.
Enemy 클래스 하나가 식별자이면서 데이터이면서 로직입니다.
ECS 는 그걸 갈라 놓고 데이터만 따로 모읍니다.
public struct Enemy : IComponentData { } // 태그. 필드가 없어도 됩니다
public struct Health : IComponentData { public int Value; }
public struct MoveStats : IComponentData { public float Speed, StopDistance; }
엔티티는 이 컴포넌트들을 가리키지 않습니다. 참조로 들고 있으면 결국 힙 여기저기를 가리키게 되니까요. 반대입니다. 데이터가 모여 있는 자리에 엔티티의 번호가 적혀 있습니다.
아키타입
컴포넌트 구성이 정확히 같은 엔티티들을 한 묶음으로 봅니다. 그 구성이 아키타입입니다.
아키타입 A : Enemy + Health + MoveStats + LocalTransform
아키타입 B : Enemy + Health + MoveStats + LocalTransform + DeadTag
아키타입 C : Player + Health + LocalTransform
A 와 B 는 DeadTag 하나 차이지만 다른 아키타입 입니다.
컴포넌트를 하나 붙이거나 떼면 그 엔티티는 다른 아키타입으로 옮겨 갑니다.
이걸 구조 변경이라 부릅니다.
잠깐, 여기서 사고를 한 번 바꿔야 합니다
객체지향에 익숙하면 여기서 막힙니다. 용어가 어려워서가 아니라 질문이 달라서입니다.
객체지향에서 우리는 이렇게 묻습니다.
이 객체는 무엇인가. 그리고 그 객체는 무엇을 할 수 있는가.
Enemy 는 체력을 갖고 움직일 수 있는 것입니다.
그래서 Enemy 클래스를 만들고, 그 안에 Hp 를 넣고, Move() 를 넣습니다.
하나의 대상이 자기 데이터와 자기 행동을 다 들고 있습니다.
데이터 지향에서는 이렇게 묻습니다.
이 데이터는 어떤 모양인가. 그리고 그 모양에 무슨 일을 해야 하는가.
Enemy 라는 것은 없습니다.
Health 와 MoveStats 와 LocalTransform 이 같이 붙어 있는 모양이 있고,
그 모양에 대해 “위치를 갱신하라” 는 일이 있을 뿐입니다.
| 객체지향 | 데이터 지향 |
|---|---|
| 클래스 = 무엇인가 | 아키타입 = 어떤 모양인가 |
| 인스턴스 = 그 무엇 하나 | 엔티티 = 그 모양 안의 몇 번째 자리 |
| 필드 = 그 객체의 속성 | 컴포넌트 = 모양을 이루는 조각 |
| 메서드 = 그 객체가 하는 일 | 시스템 = 그 모양에 가하는 일 |
| 상속 = 무엇의 한 종류 | 컴포넌트 추가 = 다른 모양 |
마지막 줄이 제일 크게 바뀝니다.
FlyingEnemy is a Enemy 가 아니라 Enemy 컴포넌트에 Flying 이 하나 더 붙은 모양입니다.
상속 계층이 없고, 대신 조합이 있습니다.
엔티티는 물건이 아니라 자리입니다.
Entity구조체 안에는Index와Version두 정수뿐입니다. “몇 번 청크의 몇 번째 칸” 을 가리키는 번호표이고, 데이터는 거기 줄 서 있습니다.
청크
아키타입마다 고정 크기 메모리 블록을 나눠 씁니다. 기본이 16KB 입니다. 그리고 청크 안에서는 컴포넌트가 종류별로 배열처럼 줄지어 있습니다.
여기서 두 가지가 나옵니다.
하나. 안 쓰는 데이터는 캐시에 안 올라옵니다.
MonoBehaviour 는 객체 하나를 만지면 그 객체의 모든 필드가 같이 딸려 옵니다.
청크는 컴포넌트별로 줄이 나뉘어 있어서 이동 시스템이 체력을 안 건드립니다.
둘. 컴포넌트를 크게 만들면 손해입니다. 청크 크기가 고정이라, 컴포넌트가 커지면 청크 하나에 들어가는 엔티티 수가 줄어듭니다. 같은 수를 처리하는 데 청크를 더 많이 오가야 합니다. “일단 다 넣어 두자” 가 여기서는 비용입니다.
쿼리는 개체가 아니라 모양을 고른다
이 전환이 실제로 무슨 차이를 만드는지가 쿼리에서 드러납니다.
“Health 와 MoveStats 를 둘 다 가진 것 전부” 를 찾는다고 해 봅시다.
객체지향의 습관대로면 1만 개를 하나씩 꺼내서 조건을 확인합니다. 그것 말고 방법이 없습니다. 어떤 객체가 무엇을 갖고 있는지는 그 객체에게 물어봐야 아니까요.
ECS 는 그럴 필요가 없습니다. 같은 모양끼리 이미 모여 있으므로, 모양에게 한 번만 물어보면 됩니다.
같은 일을 코드로 놓고 보면 이렇습니다.
계산은 양쪽 다 9,800번 합니다. 줄어든 것은 그게 아닙니다. 줄어든 것은 누구에게 시킬지 고르는 비용이고, 10,000번에서 3번이 됐습니다.
세 단계로 뜯어보기
쿼리가 도는 과정을 단위로 나누면 이렇습니다.
여기서 두 가지가 따라 나옵니다.
하나. 조건을 늘려도 거의 안 비쌉니다.
Health 와 MoveStats 에 Enemy 와 LocalTransform 을 더 걸어도
1단계에서 비교할 것이 조금 늘 뿐입니다. 엔티티 수와는 무관합니다.
객체지향이었다면 조건 하나가 늘 때마다 1만 번의 확인이 더 붙습니다.
둘. 1단계와 2단계 결과는 캐시됩니다. 아키타입 목록은 엔티티를 만들거나 컴포넌트를 붙이지 않는 한 안 바뀝니다. 그래서 쿼리는 대개 한 번 만들어 두고 매 프레임 재사용합니다. 매 프레임 새로 거르는 것이 아닙니다.
그래서 쿼리에 안 맞는 조건이 있다
이 구조를 알면 왜 어떤 것은 쿼리로 못 거르는지도 같이 보입니다.
// 됩니다. 모양으로 가릅니다
SystemAPI.Query<RefRW<LocalTransform>, RefRO<MoveStats>>()
// 안 됩니다. 값으로 가르는 것은 모양이 아닙니다
// "체력이 50 이하인 적" 같은 조건
체력이 50 이하냐는 아키타입에 안 적혀 있습니다. 값은 청크 안에 들어 있으니까요.
그래서 이런 조건은 3단계에서, 즉 순회하면서 if 로 걸러야 합니다.
값으로 자주 가르는 조건이 있다면 그 상태를 컴포넌트로 만들어 모양에 넣는 것이
ECS 다운 답입니다. LowHealth 태그를 붙였다 뗐다 하면 쿼리가 걸러 줍니다.
대신 붙였다 떼는 것이 곧 구조 변경이라 공짜가 아니고, 그 비용은 따로 재 봐야 합니다.
그래서 무엇이 달라지나
| MonoBehaviour | ECS | |
|---|---|---|
| 데이터 위치 | 힙 여기저기 | 청크 안에 연속 |
| 순회 | 객체마다 가상 호출 | 배열 순회 |
| 조건 검사 | 객체마다 | 아키타입마다 |
| 안 쓰는 필드 | 같이 캐시에 올라옴 | 안 올라옴 |
| 병렬화 | 어렵습니다. 무엇을 참조할지 모릅니다 | 청크 단위로 쪼갤 수 있습니다 |
마지막 줄이 중요합니다. 데이터를 이렇게 둔 결과로 병렬화가 가능해집니다. 청크 A 와 청크 B 가 서로를 안 건드린다는 것이 구조로 보장되기 때문입니다.
안 맞는 자리
ECS 가 항상 답은 아닙니다.
- 개수가 적으면 얻는 게 없습니다. 하나짜리를 청크에 넣어 봐야 캐시 이득이 없습니다
- 컴포넌트 구성이 자주 바뀌면 구조 변경 비용이 계속 듭니다. 상태가 붙었다 떨어졌다 하는 대상은 아키타입을 계속 갈아탑니다
- 이터레이션이 잦으면 개발 속도가 느려집니다. 값 하나 노출하는 데 authoring 과 baker 를 거쳐야 하고, 로그 하나 찍기가 번거롭습니다
개수가 많고, 구성이 안정적이고, 매 프레임 전부 도는 것. 여기에 씁니다.