[DOTS] HelloCube Sample

황교선·2023년 8월 9일
0

DOTS

목록 보기
5/5

MainThread Sample

첫번째 헬로큐브 샘플인 메인쓰레드 씬을 플레이해보면 간단하게 큐브들이 회전하는 것을 볼 수 있습니다. 이 두 큐브는 GameObject가 아닌 Entity입니다. 이 두 Entity는 SubScene 베이킹을 통하여 씬에 스폰됩니다. SubScene 안에는 RotatingCube라는 부모 GameObject가 있고, ChildCube라는 자식 GameObject가 RotatingCube 안에 있습니다.

public class RotationSpeedAuthoring : MonoBehaviour
{
    public float DegreesPerSecond = 360.0f;

    // 베이킹 과정에서, 이 베이커는 서브씬 안에 RotationSpeedAuthoring을 갖고 있는 모든 인스턴스를 한 번씩 돌며 실행합니다.
    class Baker : Baker<RotationSpeedAuthoring>
    {
        public override void Bake(RotationSpeedAuthoring authoring)
        {
            var entity = GetEntity(TransformUsageFlags.Dynamic);
            AddComponent(entity, new RotationSpeed
            {
                RadiansPerSecond = math.radians(authoring.DegreesPerSecond)
            });
        }
    }
}

public struct RotationSpeed : IComponentData
{
    public float RadiansPerSecond;
}

RotatingCube 게임오브젝트는 RotationSpeedAuthoring 이라는 컴포넌트가 있습니다. Authoring 컴포넌트는 일반적인 MonoBehaviour 클래스이지만, Baker 클래스가 추가되어 있는 것 뿐입니다. 현재 예제에서 Baker 클래스는 Nasted class로 되어있지만, 이 클래스는 어디에 있든 상관이 없습니다. 또한 Baker의 이름은 상관없고, Baker을 상속하기만 하면 됩니다.

Baker 클래스에서 중요한 것은, Authoring 컴포넌트를 매개변수로 갖는 Bake 메소드를 오버라이드하는 것입니다. 이 메소드 안에서 할 수 있는 것은 Authoring 컴포넌트를 갖고 있는 GameObject에서 만들어지는(Baked)Entity에 컴포넌트를 추가하는 것입니다.

컴포넌트는 원하는만큼 여러가지를 추가할 수 있지만 이 예제에서 넣는 컴포넌트는 하나이며, 코드 아래에서 볼 수 있는 IComponentData를 구현하는 RotationSpeed 구조체입니다. 이 컴포넌트의 멤버로는 float 하나로 RadiansPerSecond라는 이름을 갖고 있습니다. 컴포넌트를 생성하여 추가할 때, Authroing Component의 DegreesPerSecond 값을 라디안으로 바꾼 값을 인자로 넣습니다.

SubScene에 있는 부모 게임오브젝트가 RotationSpeedAuthoring 컴포넌트를 갖고 있기 때문에, Entity Baking Preview 창을 보면 HelloCube.RotationSpeed 컴포넌트가 추가되어 있는 것을 볼 수 있습니다.

public partial struct RotationSystem : ISystem
{
    [BurstCompile]
    public void OnCreate(ref SystemState state)
    {
        state.RequireForUpdate<Execute.MainThread>();
    }

    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        float deltaTime = SystemAPI.Time.DeltaTime;

        foreach (var (transform, speed) in SystemAPI.Query<RefRW<LocalTransform>, RefRO<RotationSpeed>>())
        {
            transform.ValueRW = transform.ValueRO.RotateY(
                speed.ValueRO.RadiansPerSecond * deltaTime);
        }
    }
}

OnCreate

OnCreate는 System이 생성되어 World에 추가됐을 때 딱 한 번만 호출됩니다. 여기에서 가장 중요한 것은 우리의 System은 대부분 Main Scene이 Load되기 이전에 만들어진다는 것을 이해하는 것입니다. 그렇기 때문에 OnCreate가 실행되기 전에 Main Scene이 Load되었다고 가정하면 안 됩니다. 보통은 반대로 실행되기 때문입니다. 따라서 OnCreate에서는 Scene의 어떤 것에도 접근하면 안 됩니다.

현재 예제의 OnCreate에서 하는 것은 매개변수로 들어오는 state를 통해 컴포넌트 타입이 특정지어진 RequireForUpdate를 호출하는 것입니다. 여기에서는 Execute.MainThread 컴포넌트가 되겠습니다. 이 RequireForUpdate 호출을 통하여 컴포넌트가 World 내에 하나라도 존재하기 전까지 OnUpdate하는 것을 막을 수 있습니다.

Query를 넘기는 RequireForUpdate 오버로드 버전도 있습니다. Query의 조건을 만족하는 단 하나의 Entity가 나올 때까지 OnUpdate를 막을 수 있습니다.

또한 단 하나의 System에 우리가 원하는만큼 RequireForUpdate를 할 수 있습니다.

이 예제에서는 다른 프로젝트의 Update를 막으며 하나의 Rotation System만을 OnUpdate 실행하고 싶습니다. 그렇기 때문에 SubScene에 ExecuteAuthoring 컴포넌트를 추가하여, 체크박스를 통해 Rotation System만 체크하여 추가되고 실행하도록 설정하였습니다.

public class ExecuteAuthoring : MonoBehaviour
{
    public bool MainThread;
    public bool IJobEntity;
    public bool Aspects;
    public bool Prefabs;
    public bool IJobChunk;
    public bool Reparenting;
    public bool EnableableComponents;
    public bool GameObjectSync;

    class Baker : Baker<ExecuteAuthoring>
    {
        public override void Bake(ExecuteAuthoring authoring)
        {
            var entity = GetEntity(TransformUsageFlags.None);

            if (authoring.MainThread) AddComponent<MainThread>(entity);
            if (authoring.IJobEntity) AddComponent<IJobEntity>(entity);
            if (authoring.Aspects) AddComponent<Aspects>(entity);
            if (authoring.Prefabs) AddComponent<Prefabs>(entity);
            if (authoring.IJobChunk) AddComponent<IJobChunk>(entity);
            if (authoring.GameObjectSync) AddComponent<GameObjectSync>(entity);
            if (authoring.Reparenting) AddComponent<Reparenting>(entity);
            if (authoring.EnableableComponents) AddComponent<EnableableComponents>(entity);
        }
    }
}

public struct MainThread : IComponentData
{
}

public struct IJobEntity : IComponentData
{
}

public struct Aspects : IComponentData
{
}

public struct Prefabs : IComponentData
{
}

public struct IJobChunk : IComponentData
{
}

public struct GameObjectSync : IComponentData
{
}

public struct Reparenting : IComponentData
{
}

public struct EnableableComponents : IComponentData
{
}

ExecuteAuthoring 스크립트는 프로젝트의 샘플만큼의 체크박스를 갖고 있습니다. 또한 프로젝트의 샘플만큼의 빈 IComponentData 구조체가 있습니다. 각 체크박스에 체크할 때마다 각 샘플에 대응되는 IComponentData를 엔티티의 컴포넌트로 추가합니다. 체크되어 있는만큼 각 컴포넌트들이 엔티티에 추가되므로 체크되어 있는 샘플들은 서로 동시에 작용할 수 있게됩니다.

Automatic Bootstrap

프로젝트에 있는 모든 System은 기본적으로 Play mode 시작 시 default world에 자동으로 추가되기 때문에 자동으로 실행되는 System의 Update를 막기 위와 같은 ExecuteAuthoring을 만들었습니다.

OnUpdate

전체적인 구조

실제로 큐브를 회전시키기 위해서 RotationSystem이 필요합니다. 자세히 설명하기 전에 전체적으로 구조를 보면 OnUpdate는 프레임 당 한 번씩 실행되며, OnUpdate 안에서는 우선 DeltaTime 값을 갖습니다. 이후 Query를 통하여 LocalTransform, RotationSpeed 두 컴포넌트를 갖고 있는 엔티티들을 찾고 반복문을 돌립니다. 반복문 안에서는 Y축을 중심으로 회전된 값을 찾은 각 엔티티의 Transform에 넣습니다.

System.Time.DeltaTime

유니티의 타임 클래스의 deltaTime을 사용하지 않고 왜 System의 시간을 사용하는지 궁금할 것입니다. 몇 가지의 이유가 있겠지만 대표적으로 netcode를 사용할 때 유용합니다. world는 각자의 시간 값을 통해서 시스템을 관리하기 때문입니다. 이 예제에서는 netcode를 사용하지 않기 때문에 유니티의 타임 클래스를 써도 상관 없습니다만 다른 여러 시나리오에서 System의 시간을 써야하기 때문에 항상 현재 방식을 사용하는 것을 추천합니다.

SystemAPI

이 예제에서는 SystemAPI라는 간편한 API를 통해 Query를 합니다. SystemAPI는 static class이며, 다양한 static method와 static property를 갖고 있습니다. System 안에서는 이런 API 멤버들을 사용하게 되면 이런 호출들은 SourceGen을 통해서 대체됩니다. 이 예제의 뒷편으로는 SourceGen이 만드는 마법이 들어있습니다. 그리고 중요한 점은 이 SystemAPI.Query를 사용하기 위해서는 foreach 내에서만 호출하는 것입니다. 이곳에서만 사용할 수 있습니다. 이렇게 만들어지는 코드는 Query에 일치하는 엔티티의 컴포넌트들의 iterator를 반환합니다. 이 예제에서는 Query가 하나가 아닌 두 컴포넌트를 찾기 때문에 iterator는 튜플 값을 다룹니다. 하나는 RefRW LocalTransform이고 다른 하나는 RefRO RotationSpeed인 튜플입니다. RefRW, RefRO는 단순한 래퍼 타입이고 실제 컴포넌트의 값을 갖고 있는 참조값입니다. 이들을 사용할 때는 safety check가 필요합니다. 둘의 차이점은 RW는 Read Write, RO는 Read Only입니다. 이 예제에서는 Entity를 회전시켜야하기 때문에 LocalTransform은 RW로 설정하였고, 회전하는 속도는 직접 바꿀 필요가 없으니 RO로 설정하였습니다. 이 쿼리의 Iterator 튜플의 순서에 대응하여 반복문 지역 변수에 대입되며 지역 변수 튜플의 첫 번째 값으로 transform에는 RefRW LocalTrasform 값이 대입되며, 두 번째 값으로는 RefRO RotationSpeed 값이 대입됩니다.

foreach

ValueRW 프로퍼티를 통해 컴포넌트의 실제 값을 읽고 쓸 수 있습니다. 우리는 transform의 값을 바꿔야하기 때문에 write까지 가능한 RW를 사용하는 것입니다. 이에 적용하려는 값은 현재 transform 값에 추가로 회전한 값을 적용한 값입니다. 오른쪽 식에서의 transform 값을 접근할 때는 ValueRO를 사용하였지만, RW를 사용해도 기능적으로 동일합니다. 그렇지만 오직 읽는 문장이기 때문에 RO를 사용하는 것이 바람직하다고 생각합니다.

마무리하며

이렇게 부모 큐브가 회전할 수 있도록하는 것을 알아보았습니다. 부모 큐브만 RotationSpeedComponent를 갖고 있고 자식 큐브는 이 컴포넌트가 없다는 것을 생각해야합니다. 부모가 회전할 때 자식도 같이 회전되는 이유는, 엔티티 패키지에서 제공되는 Standard Transform System이 Child Entity의 Rendering Transform을 부모와의 관계를 계산한 Local Transform으로부터 매 프레임마다 계산하기 때문입니다.

IJobEntity Sample

이 샘플은 첫 번째 샘플과 똑같지만 Main Thread가 아닌 Job에서 Entity를 수정합니다. SubScene도 정확히 똑같지만 ExecuteAuthoring 컴포넌트에서 체크박스가 MainThread → IJobEntity로 옮겨진 것을 볼 수 있습니다.

public partial struct RotationSystem : ISystem
{
    [BurstCompile]
    public void OnCreate(ref SystemState state)
    {
        // RequireForUpdate가 Execute.MainThread가 아닌 Execute.IJobEntity
        state.RequireForUpdate<Execute.IJobEntity>();
    }

    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        var job = new RotateJob { deltaTime = SystemAPI.Time.DeltaTime };
        job.Schedule();
    }
}
[BurstCompile]
partial struct RotateJob : IJobEntity
{
    public float deltaTime;

    // In source generation, a query is created from the parameters of Execute().
    // Here, the query will match all entities having a LocalTransform and RotationSpeed component.
    void Execute(ref LocalTransform transform, in RotationSpeed speed)
    {
        transform = transform.RotateY(speed.RadiansPerSecond * deltaTime);
    }
}

OnUpate가 MainThread에서 동작하는 것이 아닌 Job에 Scheduling을 시키는 것을 볼 수 있습니다. RotationJob을 생성하고 Schedule합니다.

Job은 struct로 정의되어 있고 IJobEntity를 구현합니다. 일반적으로 우리가 우선적으로 하는 것은 Query되는 엔티티를 처리하는 것입니다. IJobEntity 이외의 선택지는 low-level 옵션인 IJobChunk를 선택하는 것입니다. IJobEntity 또한 SourceGeneration을 통하여 IJobChunk로 바뀝니다. 그렇기 때문에 이 예제에서 또한 사실 IJobChunk라고 볼 수 있겠습니다. 어쨌든 IJobEntity를 선택하는 것이 훨씬 편하기 때문에 일반적으로 이를 사용할 것입니다. 해야할 것은 Execute 메소드를 정의하는 것과 쿼리하고 싶은 컴포넌트들을 매개변수로 넣는 것입니다. 이 케이스에서는 LocalTransform와 RotateSpeed를 갖고 있는 Entity를 처리하게 됩니다. ref와 in C# 키워드를 통해 Read와 Write 접근을 분류합니다. Read와 Write 모두 필요하다면 ref 키워드를, Read만 필요하다면 in 키워드를 씁니다. C# ref를 사용하기 때문에 RefRW, RefRO 프로퍼티를 신경쓰지 않아도 되어 변수를 바로 사용하면 됩니다.

Job에서 Entity가 아닌 다른 데이터에 접근하기 위해서는 public field로 넘겨주어야합니다. 이 예제에서는 Rotate Job은 deltaTime이라는 필드가 있으며 Job을 생성할 때 SystemAPI.Time.DeltaTime을 넘겨주는 것을 볼 수 있습니다.

Query를 직접 정의하지 않아도 IJobEntity의 SourceGen이 대신 처리해준다는 것을 명심하세요.

// job.Schedule(); // 아래와 동일함
state.Dependency = job.Schedule(state.Dependency); // 윗줄로부터 SourceGen에서 생성되는 코드

여기에서 SourceGen의 또 한가지 중요한 점은 SystemState의 Dependency Property는 Schedule된 JobHandle로 업데이트된다는 것입니다. 다른 System에서 동일한 컴포넌트에 접근할 수 있기 때문에 이를 예방하기 위해서 Dependency JobHandle을 업데이트합니다.

Aspects Sample

이 예제는 회전하면서 위아래로 움직이는 것까지 추가됐기 때문에 이전 예제들보다 살짝 더 흥미롭습니다. SubScene은 이전과 같이 ExecuteAuthoring 체크박스 외에는 바뀌지 않습니다.

public partial struct RotationSystem : ISystem
{
    [BurstCompile]
    public void OnCreate(ref SystemState state)
    {
        state.RequireForUpdate<Execute.Aspects>();
    }

    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        float deltaTime = SystemAPI.Time.DeltaTime;
        double elapsedTime = SystemAPI.Time.ElapsedTime;

        foreach (var (transform, speed) in SystemAPI.Query<RefRW<LocalTransform>, RefRO<RotationSpeed>>())
        {
            transform.ValueRW = transform.ValueRO.RotateY(speed.ValueRO.RadiansPerSecond * deltaTime);
        }

        foreach (var movement in SystemAPI.Query<VerticalMovementAspect>())
        {
            movement.Move(elapsedTime);
        }
    }
}

이 시스템에서 또한 큐브를 이전 샘플과 같이 회전시키지만 두 번째 foreach는 VerticalMovementAspect를 Query하여 위아래로 움직이게 만듭니다. 하지만 첫 번째 foreach의 쿼리와 동일한 값을 반환하는 쿼리입니다. LocalTransform과 RotationSpeed를 갖고 있는 엔티티인거죠. Aspect의 프로퍼티에 RefRW로 래핑된 LocalTransform은 읽고 쓸 수 있고, RefRO로 래핑된 RotationSpeed는 읽을 수만 있습니다.

readonly partial struct VerticalMovementAspect : IAspect
{
    readonly RefRW<LocalTransform> m_Transform;
    readonly RefRO<RotationSpeed> m_Speed;

    public void Move(double elapsedTime)
    {
        m_Transform.ValueRW.Position.y = (float)math.sin(elapsedTime * m_Speed.ValueRO.RadiansPerSecond);
    }
}

Aspect는 우리가 원하는 컴포넌트 세트를 편하게 사용하게 도와줍니다. 이 케이스에서는 elapsedTime 값을 매개변수로 받는 Move 메소드 하나만을 사용합니다. Move 메소드에서는 현재 transform의 포지션 y 값을 사인함수에 적용하여 위아래로 움직이게합니다.

이렇게 동작하는 Move 메소드는 위 코드의 두 번째 foreach의 Aspect 지역 변수인 movement에서 사용할 수 있습니다.

Aspect에 대해 몇 가지 알아야할 점이 있습니다.

  1. IAspect는 인터페이스이지만 강제로 구현해야할 메소드가 없습니다.
  2. SourceGen으로 인해 Aspect는 partial 키워드를 삽입해야합니다.
  3. Data access safety로 인해 Aspect struct는 readonly 키워드를 삽입해야합니다.
  4. Data access safety로 인해 모든 Ref 프로퍼티는 readonly 키워드를 삽입해야합니다.

Apsect는 단지 편하기 때문에 사용하는 것입니다. 이 예제에서는 큰 이점은 없지만 Aspect의 컨셉에 대해서 보여주기 위하여 사용했습니다. 현재 예제보다 더많은 컴포넌트 세트가 있는 경우 Aspect의 사용이 훨씬 이점을 가져올 것입니다. 예를들면 몬스터 엔티티가 있다면 수십 개의 컴포넌트가 있을 것입니다. 그런 예에서는 몬스터 Aspect를 통하여 다루는 것이 편리할 것입니다.

Prefabs Sample

이 예제에서는 하나의 큐브만 소환하는 것이 아닌, 다수를 랜덤 위치에 소환합니다. 소환한 후 아래로 이동시키며 Y 값이 0보다 작아질 때 파괴합니다. 모든 큐브가 파괴되면 처음부터 다시 재생합니다.

SubScene에 들어있던 큐브 게임오브젝트는 이제는 없고 Spawner가 그 위치를 대체합니다. Spawner는 SpawnerAuthoring 컴포넌트가 있으며, 이는 RotatingCube Asset을 참조하고 있습니다. 이 RotatingCube는 부모와 자식 큐브가 존재하고, 이전과 같이 이 RotatingCube는 부모에게만 RotationSpeedAuthoring 컴포넌트가 있습니다.

public class SpawnerAuthoring : MonoBehaviour
{
    public GameObject Prefab;

    class Baker : Baker<SpawnerAuthoring>
    {
        public override void Bake(SpawnerAuthoring authoring)
        {
            var entity = GetEntity(TransformUsageFlags.None);
            AddComponent(entity, new Spawner
            {
                Prefab = GetEntity(authoring.Prefab, TransformUsageFlags.Dynamic)
            });
        }
    }
}

struct Spawner : IComponentData
{
    public Entity Prefab;
}

SpawnerAuthoring 컴포넌트가 Bake될 때 Entity에 Spawner라는 IComponentData로 넣습니다. Spawner는 Prefab이라는 Entity를 참조하는 프로퍼티 하나만을 가지며, 이 프로퍼티에는 GameObject의 Entity 버전을 넣을 것입니다. 그렇기 때문에 Baker 내에서 추가하는 새 컴포넌트의 인자로 새 Entity를 만드는데 만들 때 GameObject Prefab을 집어넣습니다. 다시말해 Baker에 추가되는 새 컴포넌트의 인자로 들어가는 값은 GameObject Prefab의 Entity 버전이 됩니다.

public partial struct SpawnSystem : ISystem
{
    uint updateCounter;

    [BurstCompile]
    public void OnCreate(ref SystemState state)
    {
        state.RequireForUpdate<Spawner>();
        state.RequireForUpdate<Execute.Prefabs>();
    }

    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        // Create a query that matches all entities having a RotationSpeed component.
        // (The query is cached in source generation, so this does not incur a cost of recreating it every update.)
        var spinningCubesQuery = SystemAPI.QueryBuilder().WithAll<RotationSpeed>().Build();

        if (spinningCubesQuery.IsEmpty)
        {
            var prefab = SystemAPI.GetSingleton<Spawner>().Prefab;

            // Instantiating an entity creates copy entities with the same component types and values.
            var instances = state.EntityManager.Instantiate(prefab, 500, Allocator.Temp);

            // Unlike new Random(), CreateFromIndex() hashes the random seed
            // so that similar seeds don't produce similar results.
            var random = Random.CreateFromIndex(updateCounter++);

            foreach (var entity in instances)
            {
                // Update the entity's LocalTransform component with the new position.
                var transform = SystemAPI.GetComponentRW<LocalTransform>(entity);
                transform.ValueRW.Position = (random.NextFloat3() - new float3(0.5f, 0, 0.5f)) * 20;
            }
        }
    }
}

박스의 스폰은 SpawnSystem을 통하여 합니다.

시스템의 업데이트는 우선 RotationSpeed 컴포넌트를 쿼리합니다.

만약 쿼리가 비어있다면, 월드에서 Spawner 컴포넌트를 찾아 그 안의 Prefab Entity를 복사 모든 큐브를 스폰합니다. Spawner 컴포넌트는 월드에 단 하나만 존재하기 때문에 GetSingleton으로 편하게 가져올 수 있습니다. 만약 World에 Spawner 컴포넌트를 가진 엔티티가 하나도 없거나 두 개 이상이라면 예외를 던집니다. 그렇게 때문에 OnCreate에서 Spawner가 월드에 나올 때까지 기다리는 이유입니다. 메인 씬이 로드되기 전부터 System이 업데이트될 수도 있기 때문입니다.

어쨌든 Prefab Entity를 찾았으니 state.EntityManager.Instantiate를 통해 생성할 수 있습니다. Instantiate은 Nativa Array에 담긴 Entity IDs를 반환하기 때문에 Allocator를 지정해주어야합니다. 여기에서는 이 메소드에서 잠깐 사용하기 때문에 Allocator.Temp를 사용합니다. 생성된 인스턴스들을 랜덤 위치에 지정해주기 위해 랜덤넘버생성기가 필요합니다. 랜덤 씨드는 업데이트할 때마다 늘어나는 카운터를 사용하였습니다.

반복문에서는 Entity의 포지션을 변경하기 위해서 쿼리를 사용해도 되지만, API의 사용법을 설명하기 위해서 Entity ID를 돌며 실행하였습니다. SystemAPI.GetComponentRW의 인자로 entity를 넘김으로서 ReadWrite 가능한 localTransform을 반환 받았습니다. 다음줄에서는 랜덤넘버생성기를 통해 나온 값을 localTransform.Position에 적용하였습니다. 이렇게 하나 하나 원하는 Entity를 찾는 방법은 기존의 Query를 통해 접근하는 방식보다 상당히 비쌉니다. Query는 메모리에 나열되어 있는 상태 자체를 접근하기 때문에 기본적으로 훨씬 더 효율적입니다. 그렇기 때문에 하나씩 찾으며 대입하는 방식은 선호되는 방식이 아닙니다.

IJobChunk Sample

이 예제는 IJobEntity 샘플과 유사하지만 IJobChunk를 사용하여 큐브를 돌립니다.

[BurstCompile]
struct RotationJob : IJobChunk
{
    public ComponentTypeHandle<LocalTransform> TransformTypeHandle;
    [ReadOnly] public ComponentTypeHandle<RotationSpeed> RotationSpeedTypeHandle;
    public float DeltaTime;

    public void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex, bool useEnabledMask,
        in v128 chunkEnabledMask)
    {
        // The useEnableMask parameter is true when one or more entities in
        // the chunk have components of the query that are disabled.
        // If none of the query component types implement IEnableableComponent,
        // we can assume that useEnabledMask will always be false.
        // However, it's good practice to add this guard check just in case
        // someone later changes the query or component types.
        Assert.IsFalse(useEnabledMask);

        var transforms = chunk.GetNativeArray(ref TransformTypeHandle);
        var rotationSpeeds = chunk.GetNativeArray(ref RotationSpeedTypeHandle);
        for (int i = 0, chunkEntityCount = chunk.Count; i < chunkEntityCount; i++)
        {
            transforms[i] = transforms[i].RotateY(rotationSpeeds[i].RadiansPerSecond * DeltaTime);
        }
    }
}

IJobEntity Execute는 쿼리와 매칭되는 Entity 하나를 한 번 실행하지만, IJobChunk Execute는 쿼리에 매칭되는 Chunk 자체를 한 번 실행합니다. Chunk에서는 컴포넌트 값이 든 NativeArray에 접근합니다. 그리고 청크의 모든 엔티티를 한 바퀴 돕니다. 이 배열은 Chunk에 들어있는 실제 배열이기 때문에 수정하면 엔티티의 컴포넌트 값을 바로 바꾸는 것입니다. 이런 NativeArray를 사용하기 위해서는 컴포넌트 핸들을 넘겨야합니다. 그래서 Job을 생성할 때, 그런 컴포넌트 핸들을 넘기는 것을 볼 수 있습니다.

public partial struct RotationSystem : ISystem
{
    [BurstCompile]
    public void OnCreate(ref SystemState state)
    {
        state.RequireForUpdate<Execute.IJobChunk>();
    }

    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        var spinningCubesQuery = SystemAPI.QueryBuilder().WithAll<RotationSpeed, LocalTransform>().Build();

        var job = new RotationJob
        {
            TransformTypeHandle = SystemAPI.GetComponentTypeHandle<LocalTransform>(),
            RotationSpeedTypeHandle = SystemAPI.GetComponentTypeHandle<RotationSpeed>(true),
            DeltaTime = SystemAPI.Time.DeltaTime
        };

        // Unlike an IJobEntity, an IJobChunk must be manually passed a query.
        // Furthermore, IJobChunk does not pass and assign the state.Dependency JobHandle implicitly.
        // (This pattern of passing and assigning state.Dependency ensures that the entity jobs scheduled
        // in different systems will depend upon each other as needed.)
        state.Dependency = job.Schedule(spinningCubesQuery, state.Dependency);
    }
}

또한 IJobEntity에서와는 다르게 Job을 Schedule할 때 Query 자체를 생성하여 넘겨주어야합니다.

만약 먼저 Schedule되어 있는 다른 Job 중에서 현재 Job에서 사용할 컴포넌트를 이미 사용하고 있는 경우 Safety Check Execption이 발생합니다. Job Safety의 기본적인 아이디어는 여러 잡이 같은 데이터에 접근하는 것을 원치 않습니다. 따라서 여러 잡이 같은 컴포넌트 값에 접근하는 것을 원치 않습니다. 특별한 예외로 만약 여러 잡들이 같은 데이터에 Read Only로 데이터에 접근하는 것입니다. 그렇기 때문에 Read Only로 표시할 수 있을 때는 항상 Read Only로 지정하는 것이 좋습니다. 예제를 보면 RotationSpeed 핸들을 가져올 때 ReadOnly 인자로 true를 넘겨주는 것을 볼 수 있습니다. 그리고 IJobChunk의 필드 또한 ReadOnly 어트리뷰트로 마크했습니다.

마지막으로 IJobChunk의 Execute 메소드 매개변수 중 chunk 뒤로 있는 매개변수들은 Enable 컴포넌트가 disabled된 엔티티를 필터링할 때 사용합니다. 예제에서는 두 컴포넌트 다 Enable 컴포넌트가 아니기에 무시하여도 좋습니다. 그렇지만 Assert을 추가함으로써 좋은 연습이 될 것입니다.

Reparenting

작은 큐브들이 타이머에 의해 큰 큐브로 Parenting, Unparenting을 반복하는 예제입니다. RotationSystem은 기존 예제와 같이 동일합니다.

public partial struct ReparentingSystem : ISystem
{
    bool attached;
    float timer;
    const float interval = 0.7f;

    [BurstCompile]
    public void OnCreate(ref SystemState state)
    {
        timer = interval;
        attached = true;
        state.RequireForUpdate<Execute.Reparenting>();
    }

    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        timer -= SystemAPI.Time.DeltaTime;
        if (timer > 0)
        {
            return;
        }
        timer = interval;

        var rotatorEntity = SystemAPI.GetSingletonEntity<RotationSpeed>();
        var ecb = new EntityCommandBuffer(Allocator.Temp);

        if (attached)
        {
            // Detach all children from the rotator by removing the Parent component from the children.
            // (The next time TransformSystemGroup updates, it will update the Child buffer and transforms accordingly.)

            DynamicBuffer<Child> children = SystemAPI.GetBuffer<Child>(rotatorEntity);
            for (int i = 0; i < children.Length; i++)
            {
                // Using an ECB is the best option here because calling EntityManager.RemoveComponent()
                // instead would invalidate the DynamicBuffer, meaning we'd have to re-retrieve
                // the DynamicBuffer after every EntityManager.RemoveComponent() call.
                ecb.RemoveComponent<Parent>(children[i].Value);
            }
        }
        else
        {
            // Attach all the small cubes to the rotator by adding a Parent component to the cubes.
            // (The next time TransformSystemGroup updates, it will update the Child buffer and transforms accordingly.)

            foreach (var (transform, entity) in
                        SystemAPI.Query<RefRO<LocalTransform>>()
                            .WithNone<RotationSpeed>()
                            .WithEntityAccess())
            {
                ecb.AddComponent(entity, new Parent { Value = rotatorEntity });
            }
        }

        ecb.Playback(state.EntityManager);

        attached = !attached;
    }
}

작은 큐브들의 Parenting, Unparenting은 ReparentingSystem에서 기능하고, OnCreate에서 초기화되는 timer 값은 OnUpdate에서 deltaTime으로 갱신하며 0보다 클 때는 일찍 반환합니다. 만약 0보다 작아질 때 Children을 토글시키는 동작을 합니다. 이 예제에서는 Parent만이 RotationSpeed 컴포넌트를 갖고 있는 엔티티이기 때문에 GetSingletonEntity를 통하여 엔티티를 가져올 수 있습니다.

그다음으로 필요한 것은 EntityCommandBuffer(ECB)입니다. EntityCommandBuffer의 아이디어는 엔티티를 수정하는 커맨드를 저장(Record, 녹화)해놓을 수 있으며 나중에 한 번에 실행(Playback, 재생)하는 것입니다. 그런 커맨드에는 컴포넌트의 값을 설정하는 것과 컴포넌트를 추가, 삭제하는 것뿐만 아니라 Entity의 생성 및 파괴를 포함합니다. 다양한 시나리오에서 이런 일들을 바로 처리하지 않고 다르게 하고 싶을 때 ECB가 유용합니다.

첫 번째 케이스로 모든 자식들이 부모에 붙어있는 경우, 이들을 부모에게서 떼어내기 위해서 우선 부모 엔티티로부터 Child Dynamic Buffer를 받아옵니다. Buffer는 모든 자식들의 Entity ID를 포함하고 있습니다. 이후 버퍼를 돌며, Parent 컴포넌트를 제거하는 커맨드를 ECB에 녹화합니다. 루프가 끝나면 ECB를 재생시켜 녹화된 모든 커맨드를 실행하도록 합니다. 마지막으로 attach 불값을 토글시킵니다.

다음 케이스로 attach가 false이므로 else 블록으로 진입합니다. else 블록에서는 모든 Child 엔티티에 컴포넌트를 추가하는 커맨드를 ECB에 녹화시킵니다. 모든 Children을 찾기 위해서 Foreach Query를 사용하였고, LocalTransform이 있으며, RotationSpeed가 없는 엔티티들을 쿼리하였습니다. 추가적으로 WithEntityAccess를 이어붙여 Entity에 접근할 수 있도록해 튜플의 마지막에는 엔티티가 추가됩니다.

이 예제에서 문뜩 궁금할 수 있는 점은 컴포넌트를 추가 및 제거할 때 왜 EntityManager를 직접 사용하지 않고 ECB를 통해 적용하냐는 것일 겁니다. 그것은 컴포넌트 추가 및 삭제 연산이 structural change에 해당하기 때문입니다. structural change는 연산이 적용되는 엔티티를 다른 chunk로 옮기는 것이기에, 리스트 iteration과 foreach query에서는 그러한 연산은 사용하면 안 되기 때문입니다.

이 예제에서는 DynamicBuffer 내에 있는 엔티티를 하나씩 순회하며 structural change가 이루어지기 때문에 위험할 수도 있습니다. 이 예제의 코드는 잘 작동하겠지만, API는 이러한 사실을 모릅니다. DynamicBuffer의 structural change가 이루어질 때 현재 접근하고 있던 것이 무효화되어 그 이후로 이루어지는 동작들이 허가되지 않을 것입니다. 한 번 structural change가 이루어지고 나면 DynamicBuffer를 다시 생성하여 처리할 수 있지만 이는 비효율적이므로 ECB를 사용하는 것이 훨씬 좋은 대안입니다.

Enableable Sample

이 예제에서는 타이머와 Enableable 컴포넌트를 사용하여 큐브의 회전과 정지를 반복합니다. 큐브의 시작이 회전일지 정지일지는 RotatingCube 게임오브젝트의 RotationSpeedAuthoring 컴포넌트의 Start Enabled 체크박스를 통하여 정해집니다.

public class RotationSpeedAuthoring : MonoBehaviour
{
    public bool StartEnabled;
    public float DegreesPerSecond = 360.0f;

    public class Baker : Baker<RotationSpeedAuthoring>
    {
        public override void Bake(RotationSpeedAuthoring authoring)
        {
            var entity = GetEntity(TransformUsageFlags.Dynamic);

            AddComponent(entity, new RotationSpeed { RadiansPerSecond = math.radians(authoring.DegreesPerSecond) });
            SetComponentEnabled<RotationSpeed>(entity, authoring.StartEnabled);
        }
    }
}

struct RotationSpeed : IComponentData, IEnableableComponent
{
    public float RadiansPerSecond;
}

IEnableableComponent가 추가된 RotationSpeed 컴포넌트가 베이킹을 통하여 엔티티에 추가됩니다. 이 인터페이스의 특징은 컴포넌트가 disabled될 수 있다는 점이고 이가 뜻하는 것은 Query가 기본적으로 disabled된 컴포넌트는 존재하지 않는 것처럼 고려할 것입니다.

따라서 RotationSpeed 컴포넌트를 갖고 있는 엔티티를 필터링할 때 disabled되어 있는 것들은 쿼리에 의해 필터링됩니다.

Baker에서 RotationSpeed 컴포넌트를 추가하고 SetComponentEnabled를 통해 초기 상태를 Enabled할 지, Disabled할 지 결정합니다.

public partial struct RotationSystem : ISystem
{
    float timer;
    const float interval = 1.3f;

    [BurstCompile]
    public void OnCreate(ref SystemState state)
    {
        timer = interval;
        state.RequireForUpdate<Execute.EnableableComponents>();
    }

    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        float deltaTime = SystemAPI.Time.DeltaTime;
        timer -= deltaTime;

        // Toggle the enabled state of every RotationSpeed
        if (timer < 0)
        {
            foreach (var rotationSpeedEnabled in
                        SystemAPI.Query<EnabledRefRW<RotationSpeed>>()
                            .WithOptions(EntityQueryOptions.IgnoreComponentEnabledState))
            {
                rotationSpeedEnabled.ValueRW = !rotationSpeedEnabled.ValueRO;
            }

            timer = interval;
        }

        // The query only matches entities whose RotationSpeed is enabled.
        foreach (var (transform, speed) in
                    SystemAPI.Query<RefRW<LocalTransform>, RefRO<RotationSpeed>>())
        {
            transform.ValueRW = transform.ValueRO.RotateY(
                speed.ValueRO.RadiansPerSecond * deltaTime);
        }
    }
}

RotationSystem에서는 Query를 통해 큐브를 회전시키는 것은 이전에 본 것과 동일하지만, Query는 RotationSpeed 컴포넌트가 Disabled된 엔티티를 필터링할 것입니다. 따라서 매 프레임 Enabled된 엔티티만 회전시킵니다.

Enabled State를 토글링하는 것은 타이머가 끝날 때, RotationSpeed를 갖고 있는 엔티티를 쿼리하지만, EnabledRefRW에 대한 접근을 갖게 됩니다. EnabledRefRW는 그 컴포넌트의 EnabledComponent에 대한 래퍼입니다. 이 예제에서는 래퍼를 통해 Enabled State를 토글시킵니다. 중요한 것은 쿼리에서 Enabled State의 상태를 무시하고 RotationSpeed 컴포넌트를 갖고 있는 모든 엔티티를 쿼리하게 하는 옵션을 부여했다는 것입니다. 이 옵션이 없다면 Disabled된 엔티티는 제외합니다.

GameObjectSync Sample

마지막 샘플은 UI element가 화면에 있고, 체크박스를 눌러 큐브를 회전시킬지 말지 결정할 수 있습니다. UI는 유니티에서 사용하던 기존 UI이며, 게임오브젝트로 구성되어 있습니다. 따라서 이 회전하는 큐브는 게임오브젝트인 체크박스의 상태를 확인할 필요가 있습니다. 화면에 그려지는 큐브 또한 엔티티가 아닌 게임오브젝트입니다. System을 통해 실제로 회전하는 엔티티가 존재하고 이 엔티티의 transform을 화면에 그려지는 게임오브젝트 큐브에 동기화시킵니다. 렌더링을 간접적으로 동작할 필요는 없지만 transform을 게임오브젝트와 동기화하는 것이 애니메이션이 들어가는 캐릭터에게 쓸만한 패턴이기 때문에 적용하였습니다. 현재로써는 엔티티 애니메이션 패키지가 존재하지 않기에 애니메이션이 들어가는 캐릭터를 원한다면 솔루션을 직접 구축하거나 캐릭터 엔티티를 실제로 렌더링되는 애니메이션이 있는 게임오브젝트와 동기화하는 것이 필요합니다. 후자가 일반적인 해결책입니다.

런타임 중 우리의 System은 게임오브젝트나 모노비헤이비어에 접근해야하는 상황이 나올 것입니다. managed object를 bake해서 SubScene에 넣을 수는 있지만, 다른 씬의 오브젝트는 현재 SubScene에 bake할 수 없습니다. 유니티는 여러 씬의 레퍼런스 공유를 허용하지 않기 때문입니다.

public class Directory : MonoBehaviour
{
    public GameObject RotatorPrefab;
    public Toggle RotationToggle;
}

Directory 게임 오브젝트에 Directory 컴포넌트에 현재 우리가 필요한 모든 오브젝트들을 참조시켜놓았습니다.

public partial struct DirectoryInitSystem : ISystem
{
    [BurstCompile]
    public void OnCreate(ref SystemState state)
    {
        state.RequireForUpdate<Execute.GameObjectSync>();
    }

    public void OnUpdate(ref SystemState state)
    {
        state.Enabled = false;

        var go = GameObject.Find("Directory");
        if (go == null)
        {
            throw new Exception("GameObject 'Directory' not found.");
        }

        var directory = go.GetComponent<Directory>();

        var directoryManaged = new DirectoryManaged();
        directoryManaged.RotatorPrefab = directory.RotatorPrefab;
        directoryManaged.RotationToggle = directory.RotationToggle;

        var entity = state.EntityManager.CreateEntity();
        state.EntityManager.AddComponentData(entity, directoryManaged);
    }
}

public class DirectoryManaged : IComponentData
{
    public GameObject RotatorPrefab;
    public Toggle RotationToggle;

    // Every IComponentData class must have a no-arg constructor.
    public DirectoryManaged()
    {
    }
}

우선 state를 false로 만듬으로써 한 번만 실행하도록 만듭니다.

이후 Directory GameObject를 찾아 go에 담습니다. go의 Directory 컴포넌트를 새로운 변수에 담고, 엔티티에 집어넣기 위해 새로 만드는 DirectoryManaged 컴포넌트에 집어넣습니다. 이렇게 우리가 필요한 모든 오브젝트를 담고 있는 Managed Singleton Object를 효과적으로 만들었습니다.

IComponentData가 구조체가 아닌 클래스로 만들어져있다는 것을 인지하시기 바랍니다. 이는 managed 컴포넌트 타입이 됩니다. 또한 Managed Component Type은 기본 생성자가 있어야 합니다.

[UpdateInGroup(typeof(InitializationSystemGroup))]
public partial struct RotatorInitSystem : ISystem
{
    [BurstCompile]
    public void OnCreate(ref SystemState state)
    {
        state.RequireForUpdate<DirectoryManaged>();
        state.RequireForUpdate<Execute.GameObjectSync>();
    }

    // This OnUpdate accesses managed objects, so it cannot be burst compiled.
    public void OnUpdate(ref SystemState state)
    {
        var directory = SystemAPI.ManagedAPI.GetSingleton<DirectoryManaged>();
        var ecb = new EntityCommandBuffer(Allocator.Temp);

        // Instantiate the associated GameObject from the prefab.
        foreach (var (goPrefab, entity) in
                    SystemAPI.Query<RotationSpeed>()
                        .WithNone<RotatorGO>()
                        .WithEntityAccess())
        {
            var go = GameObject.Instantiate(directory.RotatorPrefab);

            // We can't add components to entities as we iterate over them, so we defer the change with an ECB.
            ecb.AddComponent(entity, new RotatorGO(go));
        }

        ecb.Playback(state.EntityManager);
    }
}

public class RotatorGO : IComponentData
{
    public GameObject Value;

    public RotatorGO(GameObject value)
    {
        Value = value;
    }

    // Every IComponentData class must have a no-arg constructor.
    public RotatorGO()
    {
    }
}

RotatorInitSystem에서는 모든 Rotator 엔티티에 실제로 상호동작하는 게임오브젝트의 Managed Component의 참조를 등록합니다.

이하 생략

public partial struct RotationSystem : ISystem
{
    [BurstCompile]
    public void OnCreate(ref SystemState state)
    {
        state.RequireForUpdate<DirectoryManaged>();
        state.RequireForUpdate<Execute.GameObjectSync>();
    }

    // This OnUpdate accesses managed objects, so it cannot be burst compiled.
    public void OnUpdate(ref SystemState state)
    {
        var directory = SystemAPI.ManagedAPI.GetSingleton<DirectoryManaged>();
        if (!directory.RotationToggle.isOn)
        {
            return;
        }

        float deltaTime = SystemAPI.Time.DeltaTime;

        foreach (var (transform, speed, go) in
                    SystemAPI.Query<RefRW<LocalTransform>, RefRO<RotationSpeed>, RotatorGO>())
        {
            transform.ValueRW = transform.ValueRO.RotateY(
                speed.ValueRO.RadiansPerSecond * deltaTime);

            // Update the associated GameObject's transform to match.
            go.Value.transform.rotation = transform.ValueRO.Rotation;
        }
    }
}

마지막으로 RotationSystem에서는 Directory Singleton을 받아오고 토글이 켜져있지 않다면 바로 메소드를 반환하며, 만약 켜져있다면 쿼리를 통해 각 엔티티를 회전시키며, 게임오브젝트의 값을 현재 엔티티의 값으로 업데이트합니다. Managed Component Type은 RefRO, RefRW 래퍼를 사용하지 않고 사용합니다. Managed Object는 항상 참조 형식으로 전달받기 때문입니다.

Managed Object를 다루기 때문에 현재 OnUpdate 메소드는 BurstCompile 어트리뷰트를 사용할 수 없다는 것을 알아두시기 바랍니다.

profile
성장과 성공, 그 사이 어딘가

0개의 댓글