Unity DOTS/Dots Custom Manual

UNITY DOTS - Entity Command Buffers 에 대해 알아보자.

개양반 2022. 7. 30.
728x90

무엇에 쓰는 물건일까?

멀티스레드 환경에서는 쓰기 경쟁 문제가 발생할 수 있습니다. 여럿 스레드에서 동시에 하나의 데이터에 쓰기 작업을 하려고 하면 발생하는 문제입니다. 그래서 기본적으로 Unity DOTS(또는 ECS)에서는 여럿 안전장치를 만들어 그러한 경쟁 문제가 발생하지 않도록 조치를 취했습니다. 예로 Entities.ForEach 에서는 쓰기로 명시된 데이터에만 쓰기가 가능하다던가 하는 안전장치가 존재합니다. 그래서 멀티스레드 환경에서도 원활하게 쓰기 작업을 할 수 있도록 만든 것이 Entity Command Buffer 입니다. 

Entity Command Buffer는 쓰기 작업(+구조변경)이 기록되고 해당 프레임이 완료되면 Entity Command Buffer에 기록된 작업들이 처리되어 경쟁 문제없이 쓰기 작업이 가능합니다. 


단일 스레드 작업에서 ECB 사용

Entities.ForEach 에서는 구조 변경을 할 수 없습니다. 그러므로, EntityCommandBuffer에 변경 내역을 저장해서 다음 프레임에서 기록된 명령을 처리합니다.

EntityCommandBuffer ecb = new EntityCommandBuffer(Allocator.TempJob);

Entities
    .ForEach((Entity e, in FooComp foo) =>
    {
        if (foo.Value > 0)
        {
            // Entity에 BarComp 를 추가하는 명령을 기록한다. 
            ecb.AddComponent<BarComp>(e);
        }
     }).Schedule();

// 위의 작업이 완료될때까지 기다린다. 
this.Dependency.Complete();

// Playback은 메인스레드에서만 호출할 수 있다.
// 작업이 완료되면 위에서 기록한 변경 사항을 적용할 수 있다.
ecb.Playback(this.EntityManager);

// Entity Command Buffer에 할당한 메모리를 해제한다.
ecb.Dispose();

 


Parallel Job 에서 ECB 사용

병렬 작업에서 ECB에 기록하려면 병렬 작업으로부터 안전한 동시 기록을 허용하는 EntityCommandBuffer.ParallelWriter 가 필요합니다. (자주 사용합니다) 다만 기록만 멀티스레드의 병렬작업에서 동작하고 기록된 프레임이 종료된 뒤 처리는 단일 스레드에서만 이뤄집니다. 

EntityCommandBuffer ecb = new EntityCommandBuffer(Allocator.TempJob);

EntityCommandBuffer.ParallelWriter parallelEcb = ecb.AsParallelWriter();

멀티스레드의 병렬 작업에서 기록된 명령의 순서는 우연에 의해 결정됩니다. 그래서 int entityInQueryIndex를 이용해서 기록을 정렬하여 처리합니다.  entityInQueryIndex는 해당 작업쿼리에서 해당 Entity와 고정되고 고유한 연결이 있는 숫자입니다. (예: 첫번째 Entity는 entityInQueryIndex가 0 이다. 그 다음은 1)

EntityCommandBuffer ecb = new EntityCommandBuffer(Allocator.TempJob);

// 병렬 처리를 할 것이므로 AsParallelWriter를 설정한다.
EntityCommandBuffer.ParallelWriter ecbParallel = ecb.AsParallelWriter();


// int entityInQueryIndex를 ForEach의 람다에 선언하여 정렬키를 얻을 수 있다.
Entities
    .ForEach((Entity e, int entityInQueryIndex, in FooComp foo) => {
        if (foo.Value > 0)
        {
            // 명령을 기록할때 entityInQueryIndex를 함께 전달하여 명령을 정렬시킨다.
            ecbParallel.AddComponent<BarComp>(entityInQueryIndex, e);
        }
    }).Schedule();

// 위의 작업이 완료될때까지 대기
this.Dependency.Complete();

// 명령 실행
ecb.Playback(this.EntityManager);

// 메모리 해제
ecb.Dispose();

주의사항

단일 스레드에서 처리되는 명령의 경우 작업이 겹쳐지지 않는 한 여러 작업에서 동일한 ECB를 제공하는 것이 좋으나, 병렬에서 처리되는 경우는  각 작업에 사용된 정렬 키가 다른 범위에 속하지 않는 한 재생 중인 명령의 예기치 않은(및 잠재적으로 바람직하지 않은) 정렬 순서가 발생할 수 있습니다. 여러 ECB에 걸쳐 명령 세트를 기록하는 것은 단일 ECB에 동일한 명령 세트를 기록하는 것에 비해 오버헤드가 매우 적기 때문에 일반적으로 특히 병렬 작업의 경우 각 작업에 고유한 ECB를 제공하는 것이 가장 좋습니다. (ScheduleParellel로 예약하는 경우는 각 예약당 하나의 고유한 ECB를 제공하는 것이 좋다는 말이다.)


ECB 명령 연속처리

PlaybackPolicy.MultiPlayback 을 사용하지 않고 Playback을 두번 이상 사용하면 에러가 발생합니다. 

EntityCommandBuffer ecb =
        new EntityCommandBuffer(Allocator.TempJob, PlaybackPolicy.MultiPlayback);

ecb.Playback(this.EntityManager);

// 2번 이상 기록된 명령을 처리하라고 작성하면 에러 발생
ecb.Playback(this.EntityManager);

ecb.Dispose();

 


메인스레드에서 ECB 사용

메인스레드에서는 EntityManager를 통해 구조적 변경을 하지만 아래의 이유로 ECB를 사용하고 싶을때가 있습니다.

  •  변경을 지연하고 싶다.
  •  일련의 변경사항을 여러 번 재생하고 싶다. 
  •  변경 사항을 분산시키는 것보다 많은 변경 사항을 통합된 한 곳에서 재생하는 것이 더 효율적이다.

ECB를 사용하여 구조적 변경 사항을 통합하면 프레임에 동기화 지점이 더 적어져 성능상 이점을 얻을 수 있다.

 


EntityCommandBufferSystem

지금까지 명령버퍼를 수동으로 만들고 폐기했는데 EntityCommandBufferSystem이 다음 단계를 수행하여 해당 작업을 수행하도록 할 수 있습니다.

  1. 재생하려면 ECB 시스템의 인스턴스를 가져옵니다.
  2. 시스템을 통해 ECB를 생성합니다.
  3. ECB에 명령을 쓸 작업을 예약합니다.
  4. 시스템에서 완료할 예약된 작업을 등록합니다.
  5.  

EntityCommandBufferSystem으로 생성한 ECB를 수동으로 재생 및 폐기하면 안됩니다. ECB시스템은 이 두가지를 알아서 수행합니다. 아래는 예제 코드입니다.

// EntityCommandBuffersystem에 Fooecbsystem 이라는 이름이 있다고 가정합니다.
EntityCommandBufferSystem sys =
        this.World.GetExistingSystem<FooECBSystem>();

// 위에서 만든 EntityCommandBufferSystem에서 다음 프레임에서 실행할 CommandBuffer를 만듭니다.
EntityCommandBuffer ecb = sys.CreateCommandBuffer();


// ForEach가 완료되면 자동으로 Dependency가 반환됩니다. (내부적으로 처리함)
Entities
    .ForEach((Entity e, in FooComp foo) => {
        // ... record to the ECB
    }).Schedule();

// ECB에 기록된 명령을 처리하도록 예약합니다.
sys.AddJobHandleForProducer(this.Dependency);

 

위의 예약에서는 FooECBSystem 이라는 가상의 ECBSystem을 만들었지만 저는 보통 아래의 두개를 많이 씁니다. 시뮬레이션 시스템 그룹이 시작할때 호출되는 Begin Simulation Entity Command Buffer System 또는 종료될때 호출되는 End Simulation Entity Command Buffer System. 둘중 어떤 것을 호출할지는 프로젝트에 따라 달라집니다. 이것도 공부하다보면 감이 옵니다. 

 

아래는 표준 ECB System 입니다. System Window에서 아래의 명령버퍼시스템이 어디에 존재하는지 확인해보세요.

BeginInitializationEntityCommandBufferSystem 초기화 작업이 시작될때 호출
EndInitializationEntityCommandBufferSystem 초기화 작업이 완료될때 호출
BeginSimulationEntityCommandBufferSystem 시뮬레이션이 돌아갈때 호출
EndSimulationEntityCommandBufferSystem 시뮬레이션이 완료될때 호출
BeginPresentationEntityCommandBufferSystem 랜더링 데이터가 랜더러에 전달될때 호출된다. 전달이 완료된 후에는 구조적 변경을 할 수 없으므로 EndPresentationEntityCommandBufferSystemsm는 없다.

 

위의 5가지 표준 명령 버퍼 시스템으로 대부분의 목적을 수행할 수 있지만 필요한 경우 고유한 ECB시스템을 만들 수 있습니다.

// 커스텀 ECBSystem이 어느 프레임에서 동작할지 명확히해야 합니다.
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateAfter(typeof(FooSystem))]
public class MyECBSystem : EntityCommandBufferSystem {
    //이 클래스는 의도적으로 비어 있습니다. 
    // 일반적으로 EntityCommandBuffersystem에 코드를 넣을 이유가 없습니다.
}

 


지연된 Entites

ECB 메서드 CreateEntity 및 Instantiate 레코드 명령은 엔터티를 생성합니다. 이러한 메서드는 엔터티를 즉시 생성하는 대신 명령을 기록하기 때문에 아직 존재하지 않는 자리 표시자 엔터티를 나타내는 음수 인덱스가 있는 엔터티 값을 반환합니다. 이러한 자리 표시자 엔터티 값은 동일한 ECB의 기록된 명령에서만 의미가 있습니다.

EntityCommandBuffer ecb = new EntityCommandBuffer(Allocator.TempJob);

Entity placeholderEntity = ecb.CreateEntity();

// 동일한 ECB의 이후 명령에서 자리 표시자를 사용하는 데 유효합니다.
ecb.AddComponent<FooComp>(placeholderEntity);

// 실제 엔티티가 생성되고 FooComp가 실제 엔티티에 추가됩니다.
ecb.Playback(this.EntityManager);

// Ecb에서 생성한 placeholderEntity는 ECB 외부에서 사용하면 에러를 발생한다.
// 따라서 아래의 코드는 ECB외부에서 실행했을 뿐만 아니라 Playback이후에도 사용했으므로
// 에러를 발생합니다. 
this.EntityManager.AddComponent<BarComp>(placeholderEntity);

ecb.Dispose();


AddComponent, SetComponent 또는 SetBuffer 명령에 기록된 값에는 Entity 필드가 있을 수 있습니다. 재생 시 이러한 구성 요소 또는 버퍼의 모든 자리 표시자 엔터티 값은 해당하는 실제 엔터티에 다시 매핑됩니다.

EntityCommandBuffer ecb = new EntityCommandBuffer(Allocator.TempJob);

Entities
    .WithAll<FooComp>()
    .ForEach((Entity e) =>
    {
        // In playback, an actual entity will be created
        // that corresponds to this placeholder entity.
        Entity placeholderEntity = ecb.CreateEntity();

        // (Barcomp에 Targetent라는 엔티티 필드가 있다고 가정합니다.)
        BarComp bar = new BarComp { TargetEnt = placeholderEntity };

        // In playback, TargetEnt will be assigned the
        // actual Entity that corresponds to placeholderEntity.
        ecb.AddComponent(e, bar);
    }).Run();

// 재생 후, Foocomp의 각 엔티티에는 
// 이제 Targetentent가 새로운 엔티티를 참조하는 Barcomp 구성 요소가 있습니다.
ecb.Playback(this.EntityManager);

ecb.Dispose();

댓글

💲 추천 글