ECS – EntityQuery 정리

EntityQuery 정리

ECS(Entity Component System)에서 특정 조건을 만족하는 Entity들을 효율적으로 검색하는 도구

Entity가 가진 컴포넌트 조합을 기준으로 검색하며, 이를 통해 메모리 캐시 효율을 극대화

1. EntityQuery의 기본 개념

  • Entity 필터링: 특정한 컴포넌트를 포함하거나 제외하는 기준을 정할 수 있다.
  • Chunk 단위 검색: EntityQuery는 Entity 개별 조회가 아닌 Chunk 단위(Archetype Chunk)로 데이터를 가져옴
  • 즉시 검색 or Job 시스템과 병렬 처리 가능

– 특정 컴포넌트를 가진 Entity 찾기

public partial struct FindEntitiesSystem : ISystem
{
    public void OnUpdate(ref SystemState state)
    {
        // PositionComponent와 VelocityComponent를 가진 Entity 찾기
        EntityQuery query = SystemAPI.QueryBuilder()
            .WithAll<PositionComponent, VelocityComponent>()
            .Build();

        foreach (var (position, velocity) in SystemAPI.Query<RefRW<PositionComponent>, RefRO<VelocityComponent>>())
        {
            position.ValueRW.Value += velocity.ValueRO.Value * SystemAPI.Time.DeltaTime;
        }
    }
}
  • WithAll() → 모든 엔티티 중 PositionComponent와 VelocityComponent를 가진 것만 검색
  • RefRW → 읽기/쓰기 가능
  • RefRO → 읽기 전용

2. EntityQuery의 주요 조건

조건설명
WithAll<T>()해당 컴포넌트를 모두 가진 Entity만 검색
WithAny<T>()지정한 컴포넌트 중 하나라도 가진 Entity 검색
WithNone<T>()특정한 컴포넌트를 가지지 않은 Entity 검색
WithDisabled<T>()비활성화된 컴포넌트가 있는 Entity 검색
WithAbsent<T>()특정한 컴포넌트가 없는 Entity 검색

특정 조건의 엔티티 찾기

EntityQuery query = SystemAPI.QueryBuilder()
    .WithAll<PositionComponent>()   // PositionComponent가 있어야 함
    .WithAny<VelocityComponent, AccelerationComponent>()  // 둘 중 하나만 있으면 됨
    .WithNone<DestroyedTag>()        // DestroyedTag가 있으면 제외
    .Build();
  • WithAll() → 반드시 포함
  • WithAny() → 하나라도 포함
  • WithNone() → 포함하면 안 됨

3. EntityQuery를 사용한 데이터 접근 방법

(1) SystemAPI.Query() 사용

  • EntityQuery를 생성하고 foreach 루프에서 엔티티 데이터를 순회 가능
foreach (var (position, velocity) in SystemAPI.Query<RefRW<PositionComponent>, RefRO<VelocityComponent>>())
{
    position.ValueRW.Value += velocity.ValueRO.Value * SystemAPI.Time.DeltaTime;
}

(2) ToEntityArray()로 Entity 리스트 가져오기

  • ToEntityArray()를 사용하면 해당 EntityQuery에 해당하는 Entity 리스트를 가져옴
var entities = query.ToEntityArray(Allocator.Temp);
foreach (var entity in entities)
{
    Debug.Log($"찾은 Entity: {entity}");
}
entities.Dispose();
  • ToEntityArray(Allocator.Temp)NativeArray로 Entity를 가져옴
  • Dispose() 필수! 메모리 해제 안 하면 메모리 누수 발생

(3) ToComponentDataArray()로 특정 컴포넌트 리스트 가져오기

  • 특정 컴포넌트의 값만 가져올 수도 있음.
var positions = query.ToComponentDataArray<PositionComponent>(Allocator.Temp);
foreach (var position in positions)
{
    Debug.Log($"Position: {position.Value}");
}
positions.Dispose();
  • 데이터만 필요한 경우 성능 최적화 가능

4. IJobChunk과 함께 사용하기

  • IJobChunk를 사용하면 EntityQuery를 멀티스레드로 병렬 처리 가능
  • ArchetypeChunk을 사용하여 Chunk 단위 데이터 접근 가능
[BurstCompile]
public struct MoveJob : IJobChunk
{
    public ComponentTypeHandle<PositionComponent> PositionHandle;
    [ReadOnly] public ComponentTypeHandle<VelocityComponent> VelocityHandle;
    public float DeltaTime;

    public void Execute(ArchetypeChunk chunk, int chunkIndex, int firstEntityIndex)
    {
        var positions = chunk.GetNativeArray(PositionHandle);
        var velocities = chunk.GetNativeArray(VelocityHandle);

        for (int i = 0; i < chunk.Count; i++)
        {
            positions[i] = new PositionComponent
            {
                Value = positions[i].Value + velocities[i].Value * DeltaTime
            };
        }
    }
}

public partial struct MoveSystem : ISystem
{
    public void OnUpdate(ref SystemState state)
    {
        var query = SystemAPI.QueryBuilder()
            .WithAll<PositionComponent, VelocityComponent>()
            .Build();

        var positionHandle = SystemAPI.GetComponentTypeHandle<PositionComponent>();
        var velocityHandle = SystemAPI.GetComponentTypeHandle<VelocityComponent>(isReadOnly: true);

        var job = new MoveJob
        {
            PositionHandle = positionHandle,
            VelocityHandle = velocityHandle,
            DeltaTime = SystemAPI.Time.DeltaTime
        };
        // query를 기반으로 Schedule()을 호출
        state.Dependency = job.Schedule(query, state.Dependency);
    }
}
  • GetComponentTypeHandle<T>() 사용 → 컴포넌트 데이터를 가져올 때 캐시 최적화
    GetComponentTypeHandle<T>()ECS(Worlds)의 청크 단위 데이터 접근을 최적화하기 위한 API
  • IJobChunk으로 멀티스레드 병렬 처리 가능

job.Schedule(query, state.Dependency)의 실행 과정

query를 사용하여 IJobChunk가 처리할 청크들을 찾음

  • queryPositionComponentVelocityComponent를 포함하는 엔티티들의 청크 목록을 가져옴.
  • IJobChunk는 이 청크 목록을 기반으로 실행됨.

job.Schedule()을 호출하여 MoveJob을 실행

  • query에서 반환된 청크를 MoveJob에서 병렬적으로 처리.
  • 내부적으로 각 청크별로 MoveJob.Execute()가 실행됨.

state.Dependency를 업데이트하여 잡 실행을 관리

  • state.Dependency는 현재 시스템의 의존성 체인을 관리.
  • 이전 프레임에서 실행된 다른 잡들과의 의존 관계를 고려하여 동기화함.
  • 병렬 처리를 안전하게 실행하기 위해 필요함.
매개변수타입설명
chunkArchetypeChunk현재 처리 중인 Chunk 객체.
해당 Chunk 내의 Entity 및 컴포넌트에 접근할 수 있음.
chunkIndexint현재 Chunk 의 인덱스 (0부터 시작).
병렬 작업을 할 때 Chunk 가 몇 번째인지 확인 가능.
firstEntityIndexint현재 Chunk 에서 첫 번째 Entity 의 전역 인덱스.
(전체 Entity 중 몇 번째인지 나타냄)

(Q) ComponentTypeHandle을 사용하여 NativeArray를 생성하는 것과 미리 NativeArray를 넘겨주는 것의 차이점

(1) ComponentTypeHandle<T>을 사용하는 이유

GetComponentTypeHandle<T>()을 사용하여 IJobChunk 내부에서 NativeArray를 가져오는 이유는 Chunk 단위 접근 최적화 때문이다.

  • ComponentTypeHandle<T>을 사용하면 각 Chunk에 맞는 NativeArray를 개별적으로 가져올 수 있음
  • 만약 NativeArray를 미리 만들어서 넘겨준다면, Chunk별로 나눠진 데이터가 하나의 배열에 병합되어야 하므로 메모리 효율이 떨어지고 캐시 활용도가 낮아짐
(2) ComponentTypeHandle<T>NativeArray<T> 차이점 비교
방식설명장점단점
ComponentTypeHandle<T> 사용
(IJobChunk 내부에서 GetNativeArray())
각 청크에서 해당 컴포넌트의 데이터 배열을 가져옴– 청크별로 최적화된 데이터 접근 가능
– 캐시 친화적이며 성능 최적화 가능
– Burst 및 멀티스레딩에 적합
IJobChunk을 사용해야 함
NativeArray<T>를 미리 만들어 Job에 넘김전체 데이터를 하나의 NativeArray로 만들고 Job에서 사용– 간단한 구조, 직관적
– 작은 데이터셋에서는 부담이 적음
– 전체 데이터를 하나의 배열로 만들기 때문에 캐시 미스 발생 가능성 증가
– Chunk별 최적화가 불가능하여 성능 저하 가능성 있음
(3) ComponentTypeHandle<T> 방식

1> SystemAPI.GetComponentTypeHandle<T>() 실행

var positionHandle = SystemAPI.GetComponentTypeHandle<PositionComponent>();
var velocityHandle = SystemAPI.GetComponentTypeHandle<VelocityComponent>(isReadOnly: true);
  • ComponentTypeHandle<T>을 가져옴
  • Chunk별로 데이터가 분산되어 있기 때문에 NativeArray<T>로 직접 변환하지 않음

2> MoveJob.Schedule() 실행

var job = new MoveJob
{
    PositionHandle = positionHandle,
    VelocityHandle = velocityHandle,
    DeltaTime = SystemAPI.Time.DeltaTime
};

state.Dependency = job.Schedule(query, state.Dependency);
  • MoveJob을 스케줄할 때 ComponentTypeHandle<T>을 넘김

3> MoveJob.Execute() 내부에서 GetNativeArray() 실행

var positions = chunk.GetNativeArray(PositionHandle);
var velocities = chunk.GetNativeArray(VelocityHandle);
  • 각 Chunk에서 NativeArray<T>를 가져와서 사용함.
  • 이렇게 하면 각 Chunk가 독립적으로 자신의 데이터에 접근할 수 있으며, 캐시 최적화 및 멀티스레딩 효율 증가
(4) NativeArray Job 방식

만약 NativeArray<T>를 미리 만들어서 Job에 넘기면, 멀티스레딩 최적화가 어렵고 성능 저하 가능성 증가.

전체 데이터를 하나의 NativeArray로 병합하는 과정이 필요, Chunk별 데이터 레이아웃이 깨질 수 있음 → CPU 캐시 효율 저하.

NativeArray<PositionComponent> positions = new NativeArray<PositionComponent>(entityCount, Allocator.TempJob);
NativeArray<VelocityComponent> velocities = new NativeArray<VelocityComponent>(entityCount, Allocator.TempJob);

// Job에 NativeArray를 직접 넘김
var job = new MoveJob
{
    Positions = positions,
    Velocities = velocities,
    DeltaTime = SystemAPI.Time.DeltaTime
};

state.Dependency = job.Schedule(state.Dependency);

이 경우, positionsvelocities를 Chunk별로 나누지 않고 전체 데이터를 하나의 배열로 만듦.

Chunk별 데이터가 섞일 가능성이 높아져 캐시 미스(Cache Miss)가 증가할 수 있음


5. EntityQuery의 추가 기능

(1) CalculateEntityCount()로 개수 확인

int entityCount = query.CalculateEntityCount();
Debug.Log($"현재 쿼리 결과 개수: {entityCount}");

(2) SetChangedVersionFilter()로 변경된 컴포넌트만 검색

query.SetChangedVersionFilter<PositionComponent>();

최근 변경된 PositionComponent를 가진 엔티티만 검색

(3) Enable/Disable로 쿼리 활성화/비활성화

query.SetEnabled(false);  // 쿼리 비활성화
query.SetEnabled(true);   // 다시 활성화

쿼리를 특정 조건에서만 사용하고 싶을 때 활용

댓글 달기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

위로 스크롤