엔티티는 번호표이고 데이터는 청크에 줄지어 있다
Esc

    엔티티는 번호표이고 데이터는 청크에 줄지어 있다

    –UnityDOTS메모리

    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 입니다. 그리고 청크 안에서는 컴포넌트가 종류별로 배열처럼 줄지어 있습니다.

    청크 하나 (16KB) · 아키타입 A Entity LocalTransform MoveStats Health 이동 시스템은 칠해진 두 줄만 앞에서 뒤로 훑습니다 Health 는 안 읽으니 캐시에 안 올라옵니다 한 청크에 몇 개가 들어가는지는 컴포넌트 크기가 정합니다 쓰지도 않는 필드를 컴포넌트에 끼워 넣으면 청크당 개수가 줄어듭니다
    구조체의 배열이 아니라 배열의 구조체입니다. 필요한 줄만 읽으면 됩니다.

    여기서 두 가지가 나옵니다.

    하나. 안 쓰는 데이터는 캐시에 안 올라옵니다. MonoBehaviour 는 객체 하나를 만지면 그 객체의 모든 필드가 같이 딸려 옵니다. 청크는 컴포넌트별로 줄이 나뉘어 있어서 이동 시스템이 체력을 안 건드립니다.

    둘. 컴포넌트를 크게 만들면 손해입니다. 청크 크기가 고정이라, 컴포넌트가 커지면 청크 하나에 들어가는 엔티티 수가 줄어듭니다. 같은 수를 처리하는 데 청크를 더 많이 오가야 합니다. “일단 다 넣어 두자” 가 여기서는 비용입니다.

    쿼리는 개체가 아니라 모양을 고른다

    이 전환이 실제로 무슨 차이를 만드는지가 쿼리에서 드러납니다.

    “Health 와 MoveStats 를 둘 다 가진 것 전부” 를 찾는다고 해 봅시다.

    객체지향의 습관대로면 1만 개를 하나씩 꺼내서 조건을 확인합니다. 그것 말고 방법이 없습니다. 어떤 객체가 무엇을 갖고 있는지는 그 객체에게 물어봐야 아니까요.

    ECS 는 그럴 필요가 없습니다. 같은 모양끼리 이미 모여 있으므로, 모양에게 한 번만 물어보면 됩니다.

    객체를 하나씩 본다 ... 칸 하나마다 "조건에 맞나" 를 묻습니다. 10,000번. 조건 검사 10,000회 엔티티가 늘면 같이 늘어납니다 모양을 하나씩 본다 A · 위치 이동 체력 맞음 B · A + 사망 맞음 C · 위치 체력 이동이 없음. 건너뜀 A 와 B 의 청크를 통째로 집습니다 (안에 9,800개) 안을 열어 하나씩 확인하지 않습니다. 같은 모양인 것이 이미 보장이니까요. 조건 검사 3회 엔티티가 100만이어도 3회입니다 양쪽 다 실제 계산은 9,800번으로 같습니다. 줄어든 것은 대상을 고르는 비용입니다.
    위는 칸을 하나씩 세고, 아래는 상자를 셋만 셉니다. 상자 안은 세지 않습니다.

    같은 일을 코드로 놓고 보면 이렇습니다.

    계산은 양쪽 다 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 를 거쳐야 하고, 로그 하나 찍기가 번거롭습니다

    개수가 많고, 구성이 안정적이고, 매 프레임 전부 도는 것. 여기에 씁니다.