Game Dev/Unity ECS & DOTS

[ECS&DOTS] NSprite 머리 쪼개기 (2) 우선, 샘플 프로젝트부터 공략하자. (2)

Septentrions 2024. 11. 11. 22:22

Age Of Sprite 샘플 프로젝트 코드 리뷰 2번째 포스팅이다.

이번에는 본격적인 System 쪽을 들어가려고 한다.

 

System 부터는 숨이 턱턱 막힌다.
하나하나 잘 체크하면서 리뷰를 해보자

 

Commpon / Input 부분은 Monobehavior 로 구현 해놓았기 때문에

신경 쓸 필요는 없을 것이다.

 

DrawSquadinSceneViewSystem Editor 상에서만 실행 되며, Toggle 형태로 버튼을 누르면
오브젝트들이 어디로 갈지를 한눈에 볼 수 있다.
FactorySystem Soldier Prefab 을 일정 시간마다 실행
Tent Entity가 가지고 있다.
GenerateMapSystem 17개의 Rock Prefab 을 75000카운트만큼 50 x 50 사이즈만큼 생성해줄 맵 관련 시스템
MovableAnimationControlSystem Animation 관련 시스템으로 2D Sprite ECS를 다룬다면 필수로 리뷰하자
MoveToDestinationSystem .
SoldierDistributionSystem Soldier들을 스쿼드로 뿌려주는 코드인데
상당히 난해한 구현 방식을 보여준다.
SpawnNewSquadsSystem  
SpawnSoldierSystem  
SquadMoveSystem  
SquadSpawnSystem  

 


DrawSquadinSceneViewSystem

 

"더 보기" 를 클릭하면 Script를 볼 수 있다.

 

더보기

 

#if UNITY_EDITOR
using NSprites;
using Unity.Collections;
using Unity.Entities;
using Unity.Jobs;
using Unity.Mathematics;
using Unity.Transforms;
using UnityEditor;
using UnityEngine;

[WorldSystemFilter(WorldSystemFilterFlags.Default | WorldSystemFilterFlags.Editor)]
[UpdateInGroup(typeof(PresentationSystemGroup))]
[UpdateAfter(typeof(SpriteRenderingSystem))]
public partial struct DrawSquadInSceneViewSystem : ISystem
{
    private struct EnableSquadDrawing : IComponentData { }

    private EntityQuery _squadQuery;

#if UNITY_EDITOR
    [MenuItem("NSprites/Toggle draw squads for View window")]
    public static void ToggleFrustumCullingSystem()
    {
        var entityManager = World.DefaultGameObjectInjectionWorld.EntityManager;
        var enableSquadDrawingQuery = entityManager.CreateEntityQuery(typeof(EnableSquadDrawing));
        if (enableSquadDrawingQuery.IsEmpty)
            _ = entityManager.AddComponentData(entityManager.CreateEntity(), new EnableSquadDrawing());
        else
            entityManager.DestroyEntity(enableSquadDrawingQuery);
    }
#endif

    public void OnCreate(ref SystemState state)
    {
        _squadQuery = state.GetEntityQuery
        (
            typeof(SquadSettings),
            typeof(WorldPosition2D)
        );
        state.RequireForUpdate<EnableSquadDrawing>();
    }

    public void OnUpdate(ref SystemState state)
    {
        if (!SystemAPI.TryGetSingleton<SquadDefaultSettings>(out var squadGlobalSettings))
            return;

        var soldierSize = SystemAPI.GetComponent<Scale2D>(squadGlobalSettings.soldierPrefab).value;
        var settings = _squadQuery.ToComponentDataListAsync<SquadSettings>(Allocator.TempJob, out var settings_GatherHandle);
        var positions = _squadQuery.ToComponentDataListAsync<WorldPosition2D>(Allocator.TempJob, out var poisitions_GatherHandle);

        JobHandle.CombineDependencies(settings_GatherHandle, poisitions_GatherHandle).Complete();

        for (int squadIndex = 0; squadIndex < settings.Length; squadIndex++)
        {
            var squadPos = positions[squadIndex].Value;
            var setting = settings[squadIndex];
            var squadSize = SquadDefaultSettings.GetSquadSize(setting.squadResolution, soldierSize, setting.soldierMargin);
            var rect = new float2x2(squadPos, squadPos + squadSize);
            Utils.DrawRect(rect, Color.cyan, 0f, Utils.DrawType.Debug);

            var soldierCount = setting.squadResolution.x * setting.squadResolution.y;
            var perSoldierOffset = (2 * setting.soldierMargin + 1f) * soldierSize;

            for (int soldierIndex = 0; soldierIndex < soldierCount; soldierIndex++)
            {
                var rectSize = soldierSize / 16f;
                var soldierPos = new float2(0f, rectSize.y * 1.5f) + squadPos + (perSoldierOffset * new float2(soldierIndex % setting.squadResolution.x + .5f, soldierIndex / setting.squadResolution.x));
                var soldierRect = new float2x2(soldierPos - rectSize, soldierPos + rectSize);
                Utils.DrawRect(soldierRect, Color.green, 0f, Utils.DrawType.Debug);
            }
        }

        settings.Dispose();
        positions.Dispose();
    }
}
#endif

해당 코드는 Draw Tool 이며 Editor에서만 실행되는 일종의 디버깅 코드이다.

굳이 깊게 볼 필요는 없지만, 라이브러리를 정복하기로 마음 먹은 이상

깊게 살펴보겠다.

 

일단 구조부터 살펴보자

 

첫 줄부터 UNITY_EDITOR 가 박혀있다.

해당 코드는 NSprite 패키지도 이용하고 있다.

 

System 코드의 메인이 되는

DrawSquadInSceneViewSystem : ISystem 위에 Attribute들이 덕지덕지 달려있다.

WorldSystemFilter(Flag.Default | Flag.Editor) Defines where internal Unity systems should be created. The existence of these flags and the specialized Worlds they represent are subject to change.

내부 유니티 시스템으로서 생성되어야 할 위치를 지정 해 줄 수 있다.

Default 자체만 쓰면 의미가 없고, 다른 Flag가 있으면 그 Flag에 속하도록 바뀐다고 한다.
Editor 플래그는 에디터에서 실행될 때 생성되고
UpdateInGroup (typeof(presentationSystemGroup)) 해당 시스템이 돌아갈 그룹을 선택할 수 있다.
PresentationSystemGroup 은 ECS 패키지에서 Rendering 부분을 담당한다.
UpdateInGroup (typeof(SprtieRenderingSystem)) SpriteRenderingSystem 은 NSprite에서 제공하는 UpdateGroup이다.
NSprite가 대강 어떻게 동작하는지 짐작 할 수 있을 것 같다.

 

 

Group을 지정했기 때문에

PreLateUpdate에 해당 시스템이 반영된 것을 알 수 있다. (바로 위에 SprtieRenderingSystem 도 보인다.)

그러면 이 System의 실행 타이밍은 알게 되었다.

바로 다음 구조로 가자

 

EntityQuery _squadQuery ,  EnableSquadDrawing : IComponentData 를 ISystem 내부에서 초기화하고 있고,

UnityEditor에서 Menu를 만들어서 Toggle 버튼을 생성해주고 있다.

ToggleFrustumCullingSystem() 이라고 이름만 봐도 FrustumCulling을 시키려는 코드임을 짐작 할 수 있다.

 

EntiyManager를 불러와서  EnableSquadDrawing 컴포넌트를 해당 시스템에 추가하도록 enableSquadDrawingQuery 쿼리를 생성한다.

 

즉, DrawSquadinSceneViewSystem 시스템은 Menu 생성 및 FrustumCulling을 해주는 시스템이라고 이해하면 된다.

 

헷갈리게.. EnableSquadDrawing 컴포넌트를 외부에다 만들어도 될 껄.. 이렇게 번거롭게 만든 이유는 모르겠지만

 

OnCreate 함수에서 EnableSquadDrawing 컴포넌트가 있어야 동작하도록 해놓았다.

그리고 _squadQuery  는 SquadSettings와 WorldPosition2D 컴포넌트들을 쿼리하도록 초기화 했다.

(무슨 일을 할 지는 좀 뻔하지 않은가?)

 

OnUpdate 로 넘어가자

 

var settings = _squadQuery.ToComponentDataListAsync<SquadSettings>(Allocator.TempJob, out var settings_GatherHandle);

var positions = _squadQuery.ToComponentDataListAsync<WorldPosition2D>(Allocator.TempJob, out var poisitions_GatherHandle);

 

Create에서 만든 EntityQuery를 이용해 두 컴포넌트를 가진 Entity들을 우선 조회한다.

 

var soldierSize = SystemAPI.GetComponent<Scale2D>(squadGlobalSettings.soldierPrefab).value;

해당 코드에서 쓰이는 Scale2D 는 Nsprite 패키지의 Componet 이다.

squadGlobalSettings.soldierPrefab 의 컴포넌트 중 하나가 Scale2D라는 이야기인데?

 

해당 Entity 는 직접 Inspector로 확인 할 수 없다.

Soldier Prefab에는 SpriteAnimatedRendererAuthoring만 있지만 보지 못했던 Components가 있는걸 봐서

NSprite는 2D Conversion에 필요한 컴포넌트를 자동으로 추가해주는 것으로 보인다.

 

 JobHandle.CombineDependencies(settings_GatherHandle, poisitions_GatherHandle).Complete();

바로 다음 줄에서 CombineDependencies를 걸어놓았는데.

 

- settings_GatherHandle

- poisitions_GatherHandle

 

settings  /  positions 를 EntityQuery로 조회하면서 그 결과값을 두 핸들러에 넣을 수 있도록 해놓은 것을 알 수 있다.

세팅 끝날떄까지 진행 하지 마! 라고 이해하면 된다.

이렇게 하나하나 분석하니까,  별거 안한거 같은데, 벌써 해당 System 동작이 끝나려고 한다..!

 

for (int squadIndex = 0; squadIndex < settings.Length; squadIndex++)

 

For문 안에는 2가지의 작업이 이루어진다.

 

(1)

setting는 SquadAuthoring가 만들어낸 Entity들이 가지고 있다. 

총 17개의 Squad (#) 오브젝트들이므로 Length는 총 17이다.



squad 엔티티의 위치값

setting 값 (Soldier Margin, Resoultion)

squadSize값 (= SquadDefaultSettings 컴포넌트에 달려있던 GetSquadSize 함수)

rect (float2x2) 값을 가져와서 

Utills.Draw 해준다. (그냥 좌표 4개 DrawLine 해주는 함수다.)

 

 

(2)

SoldierCount = Resolution.x * Resoultion.y
perSoldierOffset = (2 * soldierMargin + 1f) * soldierSize

여기서 Soldier Margin이 있는 이유는, 직접 Soldier 엔티티들을 선택할 방법이 없어서 어디로 가는지 대강 확인하기 위한
계산값이다.
(실제로 Soldier가 있는 만큼이 아니다, 해당 Squad에 들어올 Soldier들 수이다.)

SoldierPos / SoliderRect를 또 구해서 DrawRect 해주고 있다.

 

 

이 결과는?

 

 

그저 어디에 위치할지를 할당한 걸 보고 싶은 용도라 보면 된다.

마지막으로 ToComponentDataListAsync로 끌고온 Settings / Positions는 Dispose 하고 있다.

 

생각보다 별거 없는 코드였다. 

쉬어가는 타이밍은 지났으니 본론으로 가자!


FactorySystem

 

"더 보기" 를 클릭하면 Script를 볼 수 있다.

더보기
using NSprites;
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Transforms;

[BurstCompile]
public partial struct FactorySystem : ISystem
{
    [BurstCompile]
    private partial struct ProductionJob : IJobEntity
    {
        public float DeltaTime;
        public EntityCommandBuffer.ParallelWriter ECB;

        private void Execute([ChunkIndexInQuery] int chunkIndex, ref FactoryTimer timer, in FactoryData factoryData)
        {
            timer.value -= DeltaTime;

            if (timer.value <= 0)
            {
                timer.value += factoryData.duration;
                var instanceEntities = new NativeArray<Entity>(factoryData.count, Allocator.Temp);
                ECB.Instantiate(chunkIndex, factoryData.prefab, instanceEntities);
                for (int i = 0; i < instanceEntities.Length; i++)
                    ECB.SetComponent(chunkIndex, instanceEntities[i], LocalTransform.FromPosition(factoryData.instantiatePos.ToFloat3()));
            }
        }
    }

    [BurstCompile]
    public void OnCreate(ref SystemState state)
    {
        state.RequireForUpdate<EndSimulationEntityCommandBufferSystem.Singleton>();
    }
    
    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        var productionJob = new ProductionJob
        {
            DeltaTime = SystemAPI.Time.DeltaTime,
            ECB = SystemAPI.GetSingleton<EndSimulationEntityCommandBufferSystem.Singleton>().CreateCommandBuffer(state.WorldUnmanaged).AsParallelWriter()
        };
        state.Dependency = productionJob.ScheduleParallelByRef(state.Dependency);
    }
}

(1)

FactorySystem은 Job 시스템을 이용하기 위해 IJobEntity를 사용하고 있고

EndSimulationEntityCommandBufferSystem.Singleton() 를 요구하여 Update를 실행하도록 하고 있다.

 

전신이 되는 EntityCommandBufferSystem은 Thread-safe, 경쟁 상태를 막기 위한 유니티의 시스템이다.

SystemGroup의 Begin / End에 ECB System이 들어가있는 것을 확인 할 수 있다.

 

 

엔티티의 구조 변경이 이루어질 때, 문제 없이 돌아갈 수 있도록 돕기 때문에

해당 프레임에서 구조 변경이 이루어졌을 때, 기록된 ECB System 을 실행시켜서 구조 변경을 반영한다.

EndSimulationEntityCommandBufferSystem 은 SimulationGroup이 종료 되었을 때, 실행 되는 System이다.

물론, 해당 System이 존재하고 있으니 FactorySystem에서 Update문이 진행된다.

 

(2)

OnUpdate문에서는 EndSimulationEntityCommandBufferSystem 를 선언해서 스케쥴링을 실행한다.

(시뮬레이션 그룹이 끝나느 시점에 호출된다.)

 

IJobEntity로 된 ProductionJob은 [ChunkIndexInQuery] 어트리뷰트를 받고 있는데, 해당 코드안에서 chunkIndex 값을 사용하겠다는 뜻이다.

 

ECB에서 ChunkIndex를 사용하여 Spawn 하는 이 코드는 Docs 공식 예제에서도 최적화된 방식이라고 되어있다.

https://docs.unity3d.com/Packages/com.unity.entities@1.0/manual/ecs-workflow-optimize-systems.html

 

Optimize the system for the spawner example | Entities | 1.0.16

Optimize the system for the spawner example This task shows you how to modify a system so that it uses Burst-compatible jobs that run in parallel on multiple threads. Note Before you modify a system to run in parallel on multiple threads, consider whether

docs.unity3d.com

ChunkIndex 는 Execute 안에 자동으로 등록되는 매개변수이며, 동기화 관리를 위한 Integer 값이다.

설명이 많이 없지만.. 이렇게 동작한다는걸 이해해야 한다..

 

내부 코드는 봐도 무슨 내용인이 이해할 것 같아서 설명은 넘어간다.

그나저나 Spawning 하는 것조차 번거롭다.. 성능을 위해서라면..........

 


 

GenerateMapSystem

 

"더 보기" 를 클릭하면 Script를 볼 수 있다.

더보기
using Unity.Burst;
using Unity.Collections;
using Unity.Collections.LowLevel.Unsafe;
using Unity.Entities;
using Unity.Jobs;
using Unity.Jobs.LowLevel.Unsafe;
using Unity.Mathematics;
using Unity.Transforms;

[BurstCompile]
public partial struct GenerateMapSystem : ISystem
{
    [BurstCompile]
    private struct GenerateMapJob : IJobParallelForBatch
    {
        public EntityCommandBuffer.ParallelWriter ECB;
        public float2x2 MapSize;
        [ReadOnly] public NativeArray<Entity> Rocks;
        [NativeDisableParallelForRestriction] public NativeArray<Random> PosRands;
        [NativeSetThreadIndex] private int _threadIndex;

        public void Execute(int startIndex, int count)
        {
            for (int i = startIndex; i < startIndex + count; i++)
            {
                var rand = PosRands[_threadIndex];
                var rockEntity = ECB.Instantiate(i, Rocks[rand.NextInt(0, Rocks.Length)]);
                ECB.SetComponent(i, rockEntity, LocalTransform.FromPosition(rand.NextFloat2(MapSize.c0, MapSize.c1).ToFloat3()));
                PosRands[_threadIndex] = rand;
            }
        }
    }
    private struct SystemData : IComponentData
    {
        public Random Rand;
    }

    [BurstCompile]
    public void OnCreate(ref SystemState state)
    {
        state.RequireForUpdate<EndSimulationEntityCommandBufferSystem.Singleton>();
        _ = state.EntityManager.AddComponentData(state.SystemHandle, new SystemData { Rand = new Random(1u) });
    }
    
    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        if (!SystemAPI.TryGetSingleton<MapSettings>(out var mapSettings))
            return;

        var systemData = SystemAPI.GetComponent<SystemData>(state.SystemHandle);
        var posRands = new NativeArray<Random>(JobsUtility.MaxJobThreadCount, Allocator.TempJob);
        for (int i = 0; i < posRands.Length; i++)
            posRands[i] = new Random(systemData.Rand.NextUInt());

        var generateMapJob = new GenerateMapJob
        {
            ECB = SystemAPI.GetSingleton<EndSimulationEntityCommandBufferSystem.Singleton>().CreateCommandBuffer(state.WorldUnmanaged).AsParallelWriter(),
            MapSize = mapSettings.size,
            PosRands = posRands,
            Rocks = state.EntityManager.GetBuffer<PrefabLink>(mapSettings.rockCollectionLink).Reinterpret<Entity>().AsNativeArray()
        };
        state.Dependency = generateMapJob.ScheduleBatch(mapSettings.rockCount, 32, state.Dependency);
        _ = posRands.Dispose(state.Dependency);

        SystemAPI.SetComponent(state.SystemHandle, systemData);

        state.Enabled = false;
    }
}

 

이번에는 GenerateMapSystem을 살펴보겠다.

 

샘플을 실행하면 RockPrefab으로 등록된 오브젝트들이 엄청나게 깔리는 걸 볼 수 있는데

이 걸 GenerateMapSystem이 수행한다.

 

이번 System은 IJobParallelForBatch 를 이용한다.

특정 구간마다 대해 동일한 랜덤 반복을 수행하므로 넣은 것 같다.

컴포넌트 값으로는 무려, 75000개를 쏴주는 역할을 한다..

 

(1)

OnCreate에서는 이번에도 EndSimulationEntityCommandBufferSystem 를 요구한다.

또한, 랜덤값 시드용 SystemData : IComponentData 도 해당 System에 추가해주고 있다.

 

(2)

OnUpdate에서는 MapSetting Component를 가져오고

Create단에서 추가했던 SystemData도 가져오고

가용 가능한 Thread를 싹 가져와서 Random Array를 만들어주고 있다.

(모든 스레드를 활용하는 방법이라 참고 할만하다..!)

 

그 다음은 GenerateMapJob (IJobParallelForBatch 로 구현된 Job)을 생성하고,

Job의 일이 끝나면, Dispose 하고,

다신 사용하지 않을 코드이기 때문에 Enabled = false로 적용한다.

 

실제로 한번 실행되고나서 바로 플러그가 뽑힌 걸 볼 수 있다.

(궁금해서 클릭해보면, 한번 더 실행되고 꺼진다.)

 

(3)

        var generateMapJob = new GenerateMapJob
        {
            ECB = SystemAPI.GetSingleton<EndSimulationEntityCommandBufferSystem.Singleton>().CreateCommandBuffer(state.WorldUnmanaged).AsParallelWriter(),
            MapSize = mapSettings.size,
            PosRands = posRands,
            Rocks = state.EntityManager.GetBuffer<PrefabLink>(mapSettings.rockCollectionLink).Reinterpret<Entity>().AsNativeArray()
        };

 

GenerateMapJob을 생성 할 때, 파라미터를 확인해보자.

- ECB

- 사이즈 (-25 ~ 25) 값

- 스레드 수만큼 생성한 NativeArray,  

- Rocks를 파라미터로 넣고 있다.

 

 

Rocks = state.EntityManager.GetBuffer<PrefabLink>(mapSettings.rockCollectionLink)

.Reinterpret<Entity>().AsNativeArray()

 

Rocks는 PrefabLink : IBufferElementData 버퍼를 mapSettings로부터 가져온다. (Prefab 17개)

Reinterpret<Entity> 함수는 DynamicBuffer 에서 사용되는 Method이다.

Entity로 된 형식의 DynaicBuffer를 반환한다.

Rocks 자체가 PreFab이 아니라 새로운 Entity들로써 DynamicBuffer로 부여받는다는 뜻이다.

꽤 복잡하다..

 

    [BurstCompile]
    private struct GenerateMapJob : IJobParallelForBatch
    {
        public EntityCommandBuffer.ParallelWriter ECB;
        public float2x2 MapSize;
        [ReadOnly] public NativeArray<Entity> Rocks;
        [NativeDisableParallelForRestriction] public NativeArray<Random> PosRands;
        [NativeSetThreadIndex] private int _threadIndex;

        public void Execute(int startIndex, int count)
        {
            for (int i = startIndex; i < startIndex + count; i++)
            {
                var rand = PosRands[_threadIndex];
                var rockEntity = ECB.Instantiate(i, Rocks[rand.NextInt(0, Rocks.Length)]);
                ECB.SetComponent(i, rockEntity, LocalTransform.FromPosition(rand.NextFloat2(MapSize.c0, MapSize.c1).ToFloat3()));
                PosRands[_threadIndex] = rand;
            }
        }
    }

 

GenerateMapSystem도 결국은 Spawning 비슷한 작업을 수행한다.

 

[NativeDisableParallelForRestriction]  어트리뷰트는 본래는 동시에 접근하면 위험할 수도 있는 Narive 오브젝트들을 안전하다고 마크해주는 어트리뷰트이다. 이걸 의식하고 코딩을 하라니.. 쉽지 않네..

 

[NativeSetThreadIndex] 어트리뷰트는 유니티 코어 모듈에서 제공한다.

해당 Job을 수행하는 스레드를 int 값으로 부여한다.

이런 어트리뷰트까지 자유자재로 사용하기엔 큰 벽이 느껴지는 것 같다.

 

        state.Dependency = generateMapJob.ScheduleBatch(mapSettings.rockCount, 32, state.Dependency);

 

아래에서 해당 Job을 실행하는 부분을 볼 수 있다.

masettings.rockCount = 75000개

batch 32개씩 넣고 있는데 3번째 argument는 용도를 모르겠다. Docs에도 2개까지만 받게 되어있긴한데..

자기 자신을 넣는걸 보니 out 이랑 비슷한 느낌일까..

 

내부 코드는 매우 쉽다.

앞에서 만들었던 Entity DynamicBuffer (Rocks) 를 이용해서 생성하고, SetComponent 하고 있다.

 

이 정도면 사실 ECS가 어려운게 아니라 Job System을 적재적소에 활용하는게 어려운게 아닐까...



GenerateMapSystem

 

"더 보기" 를 클릭하면 Script를 볼 수 있다.

 

더보기
using NSprites;
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;

[BurstCompile]
[UpdateBefore(typeof(SpriteUVAnimationSystem))]
public partial struct MovableAnimationControlSystem : ISystem
{
    [BurstCompile]
    private partial struct ChangeAnimationJob : IJobEntity
    {
        public AnimationSettings AnimationSettings;
        public double Time;

        private void Execute(AnimatorAspect animator, EnabledRefRO<MovingTag> movingTagEnabled)
        {
            animator.SetAnimation(movingTagEnabled.ValueRO ? AnimationSettings.WalkHash : AnimationSettings.IdleHash, Time);
        }
    }

    private struct SystemData : IComponentData
    {
        public EntityQuery MovableQuery;
    }

    [BurstCompile]
    public void OnCreate(ref SystemState state)
    {
        var systemData = new SystemData();
        var queryBuilder = new EntityQueryBuilder(Allocator.Temp)
            .WithAll<MovingTag>()
            .WithAspect<AnimatorAspect>()
            .WithOptions(EntityQueryOptions.IgnoreComponentEnabledState);
        var movableQuery = state.GetEntityQuery(queryBuilder);
        movableQuery.AddChangedVersionFilter(ComponentType.ReadOnly<MovingTag>());
        systemData.MovableQuery = movableQuery;

        _ = state.EntityManager.AddComponentData(state.SystemHandle, systemData);

        queryBuilder.Dispose();
    }

    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        var systemData = SystemAPI.GetComponent<SystemData>(state.SystemHandle);
        if (!SystemAPI.TryGetSingleton<AnimationSettings>(out var animationSettings))
            return;
        var time = SystemAPI.Time.ElapsedTime;

        var animationSwitchJob = new ChangeAnimationJob
        {
            AnimationSettings = animationSettings,
            Time = time
        };
        state.Dependency = animationSwitchJob.ScheduleParallelByRef(systemData.MovableQuery, state.Dependency);
    }
}

4번째 System 코드 MovableAnimationControlSystem 을 보자.

3번쨰까지도 벌써 몰랐던 방식과 어트리뷰트들이 잔뜩 쏟아져나왔는데.. 갈길이 멀구나.

 

이번 코드는 NSprite 관련 스크립트이니 집중해보자.

 

(1)

[UpdateBefore(typeof(SpriteUVAnimationSystem))] 

UpdateBefore 라는걸 보니 SpriteUVAnimationSystem 의 실행 위치를 보면, 어떤 방식으로 실행되는지 알 수 있다.

System들은 기본적으로 임의로 실행된다고 하는거 같았는데

이런 식으로 System 얼라이먼트를 할 수 있구나 싶었다.

 

Movable Animation Control System은 UpdateBefore 타겟의 앞에 위치한다.

 

 

(2)

OnCreate 부터 복잡한게 느껴진다..

이번에도 SystemData : IComponentData 가 코드 내부에 있는데

해당 컴포넌트를 객체화 시키고 있다.

var systemData = new SystemData();

 

EntityQueryBuilder를 통해 Query를 만들고 있는데

MovingTag, AnimationAspect, EnableState에 상관없이이므로 해당 엔티티를 몽땅 가져온단 뜻으로 이해하면 되겠다.

Authoring 코드 중에 MovingTag를 가지고 있는 건, Soldier Authoring 코드이다.

모든 병사 엔티티들을 대상으로 Animation을 실행하겠구나. 라고 짐작 할 수 있다.

 

Soldier 프리팹 리마인드

 

.WithAspect<AnimatorAspect>()  의 AnimatorAspect 은 NSprite에서 사용되는 struct 이다.

보나마나 Sprite Animated Renderer Authoring 을 컴포넌트로 가지고 있으면 자동으로 생기는 것이겠지?

구현 코드를 들어가보면, 굉장히 복잡하니 일단은 애니메이션 관련으로 사용하는구나 하자..

 

movableQuery 에 위 Query를 담고, AddChangedVersionFilter 를 실행해주는데..

Docs를 여러번 읽어도 의미를 도저히 모르겠다..

 

Filters out entities in chunks for which the specified component has not changed. Additive with other filter functions.

 

컴포넌트가 변환되지 않은 청크들을 필터링한다....?

MovingTag 가 생각해보니 그냥 컴포넌트가 아니라 IEnableableComponent 이다.

이 뜻은 해당 엔티티가 Enabled = false 인 컴포넌트만을 가져온단 뜻이구나 생각이 든다.

 

https://gametorrahod.com/change-version/

 

Chunk's Change Version

DOTS comes with a per-chunk versioning and a related filter feature. This feature can optimize processing the data only when it mattered.

gametorrahod.com

 

해당 글에서 설명한느 걸 보면, 필터링 하는 걸 좀더 최적화 시켜준다고 하는거 같은데.

도착지에 도착한 Soldier들은 Idle 상태이고, Walk 중인 엔티티들을 필터링 할 때 빠르게 찾아 준다는 의미로 받아들이면 되는 것 같다.

 

다음에는 SystemData Struct에 해당 쿼리를 넣어주고 System의 Component로 등록 해주고 있다.

아마도 꺼낼 때마다 필터링 시킬 Query를 실행 시켜주겠다는 의미일 것 같다.

굳이 이렇게 안하고 JobEntity 안에서 직접 필터링 할 수 있을 것 같긴 하지만... 

 

(3)

OnUpdate를 보자.

이럴 꺼면.. 왜 앞에서 뻘짓해서 Query Build 주고 받기를 했는지 모르겠지만, 바로 SystemData를 꺼냈다.

이제는 뻔한 Job 구현부를 지나서

State.Dependency 부분을 보면, 

state.Dependency = animationSwitchJob.ScheduleParallelByRef(systemData.MovableQuery, state.Dependency);

으로 되어 있다.

 

Update 할 때마다, MovableQuery로 엔티티들을 싹 불러와서 청크 단위로 animationSwitchJob을 실행한다는 뜻이다.

 

(4)

    [BurstCompile]
    private partial struct ChangeAnimationJob : IJobEntity
    {
        public AnimationSettings AnimationSettings;
        public double Time;

        private void Execute(AnimatorAspect animator, EnabledRefRO<MovingTag> movingTagEnabled)
        {
            animator.SetAnimation(movingTagEnabled.ValueRO ? AnimationSettings.WalkHash : AnimationSettings.IdleHash, Time);
        }
    }

 

이번 Job은 복잡할 게 없다.

 

AnimationSettings 에는 Idle / Walk Hash가 보관되어 있는 것을 기억할 것이다.

IAspect인 animator와 MovingTag가 Enabled되어 있는 엔티티만을 가져온다고 생각하자.

 

Animation State 처리 방식이 상당히 난해하다.

그래도 이렇게라도 구현 할 수 있다는건 축복 아닐까...

 

MoveableAnimationControlSystem 에서는 MovingTag가 Enabled 상태에 따라서 Animation을 컨트롤 해주는 것을 알 수 있었다.

 

그렇다면, 다른 System에서 해당 Moving Tag를 제어하는 System이 있을 것이다.

 

 


 

MovingTag를 제어하는 System의 정체는 MoveToDestinationSystem이다.

 

MoveToDestinationSystem

 

"더 보기" 를 클릭하면 Script를 볼 수 있다.

더보기
using System.Runtime.CompilerServices;
using Unity.Burst;
using Unity.Burst.Intrinsics;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;

[BurstCompile]
public partial struct MoveToDestinationSystem : ISystem
{
    #region jobs
    [BurstCompile]
    private struct CalculateMoveTimerJob : IJobChunk
    {
        private const float Threshold = .01f;

        [ReadOnly] public EntityTypeHandle EntityTypeHandle;
        public ComponentTypeHandle<MoveTimer> MoveTimer_CTH_RW;
        [ReadOnly] public ComponentTypeHandle<MoveSpeed> MoveSpeed_CTH_RO;
        [ReadOnly] public ComponentTypeHandle<LocalToWorld> LTW_CTH_RO;
        [ReadOnly] public ComponentTypeHandle<Destination> Destination_CTH_RO;
        public ComponentTypeHandle<MovingTag> MovingTag_CTH_RW;
        public uint LastSystemVersion;

        public void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex, bool useEnabledMask, in v128 chunkEnabledMask)
        {
            if (chunk.DidChange(ref Destination_CTH_RO, LastSystemVersion)
                || chunk.DidChange(ref MoveSpeed_CTH_RO, LastSystemVersion))
            {
                var entities = chunk.GetNativeArray(EntityTypeHandle);
                var ltw = chunk.GetNativeArray(ref LTW_CTH_RO);
                var moveSpeeds = chunk.GetNativeArray(ref MoveSpeed_CTH_RO);
                var destinations = chunk.GetNativeArray(ref Destination_CTH_RO);
                var timers = chunk.GetNativeArray(ref MoveTimer_CTH_RW);

                for (int entityIndex = 0; entityIndex < entities.Length; entityIndex++)
                {
                    var distance = math.length(destinations[entityIndex].Value - ltw[entityIndex].Position.xy);
                    if (distance > Threshold)
                    {
                        timers[entityIndex] = new MoveTimer { RemainingTime = GetRemainingTime(distance, moveSpeeds[entityIndex].value) };
                        if (!chunk.IsComponentEnabled(ref MovingTag_CTH_RW, entityIndex))
                            chunk.SetComponentEnabled(ref MovingTag_CTH_RW, entityIndex, true);
                    }
                }
            }
        }
        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        private static float GetRemainingTime(in float2 pos, in float2 dest, float speed)
            => GetRemainingTime(math.length(dest - pos), speed);
        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        private static float GetRemainingTime(in float distance, float speed)
            => distance / speed;
    }
    [BurstCompile]
    [WithAll(typeof(MovingTag))]
    private partial struct MoveJob : IJobEntity
    {
        public float DeltaTime;
        [NativeDisableParallelForRestriction] public ComponentLookup<MovingTag> MovingTag_CL_RW;

        private void Execute(Entity entity, ref LocalTransform transform, ref MoveTimer timer, in Destination destination)
        {
            var remainingDelta = math.max(timer.RemainingTime, DeltaTime - timer.RemainingTime);
            // move pos in a direction of current destination by passed frac of whole remaining move time
            transform.Position += ((destination.Value - transform.Position.xy) * DeltaTime / remainingDelta).ToFloat3();
            timer.RemainingTime = math.max(0, timer.RemainingTime - DeltaTime);

            if (timer.RemainingTime == 0f)
                MovingTag_CL_RW.SetComponentEnabled(entity, false);
        }
    }
    #endregion

    private struct SystemData : IComponentData
    {
        public EntityQuery MovableQuery;
    }

    [BurstCompile]
    public void OnCreate(ref SystemState state)
    {
        var queryBuilder = new EntityQueryBuilder(Allocator.Temp)
            .WithAll<MoveSpeed>()
            .WithAll<LocalToWorld>()
            .WithAll<Destination>();
        var systemData = new SystemData{ MovableQuery = state.GetEntityQuery(queryBuilder) };
        _ = state.EntityManager.AddComponentData(state.SystemHandle, systemData);

        queryBuilder.Dispose();
    }

    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        var systemData = SystemAPI.GetComponent<SystemData>(state.SystemHandle);

        // recalculate MoveTimer if MoveSpeed or Destination was changed
        var calculateMoveTimerJob = new CalculateMoveTimerJob
        {
            EntityTypeHandle = SystemAPI.GetEntityTypeHandle(),
            MoveTimer_CTH_RW = SystemAPI.GetComponentTypeHandle<MoveTimer>(false),
            MoveSpeed_CTH_RO = SystemAPI.GetComponentTypeHandle<MoveSpeed>(true),
            LTW_CTH_RO = SystemAPI.GetComponentTypeHandle<LocalToWorld>(true),
            Destination_CTH_RO = SystemAPI.GetComponentTypeHandle<Destination>(true),
            MovingTag_CTH_RW = SystemAPI.GetComponentTypeHandle<MovingTag>(false),
            LastSystemVersion = state.LastSystemVersion
        };
        state.Dependency = calculateMoveTimerJob.ScheduleParallelByRef(systemData.MovableQuery, state.Dependency);

        var moveJob = new MoveJob
        {
            DeltaTime = SystemAPI.Time.DeltaTime,
            MovingTag_CL_RW = SystemAPI.GetComponentLookup<MovingTag>(false)
        };
        state.Dependency = moveJob.ScheduleParallelByRef(state.Dependency);
    }
}

 

5번째 System 코드. MoveToDestinationSystem 를 알아보자.

본인은 코드 리뷰 하기 전에 구조부터 쓰윽 확인하고 리뷰하는데

JobChunk 부분 보자마자 복잡해보여서 ㅆㅃ;;;; 소리부터 나온다.

정신 차리고 천천히 쪼개보자.

 

(1)

또!! Moving Query를 SystemData Component에 담아주고 있다.

공식 헬로 큐브에서는 매번 QueryBuilder를 Update에서 정의하고 사용하고 있는데

NSprite 개발자는 미리 만들어두는걸 선호하나보다.

(좋은 습관이라 배워둬야겠다.)

 

이번 쿼리는?

- MoveSpeed

- LocalToWorld

- Destination 을 필터링 하는 쿼리이다.

 

마찬가지로 Soldier Authoring 을 타겟으로 한 쿼리이다.

빠르게 Update 부분을 살펴보자.

 

(2)

이번에는 JobSystem을 2개 사용하고 있다.

그룹핑하는 것도 아니고 Dependency 연속으로 쓰고 있으니 순서대로 실행 한다는 뜻이니 두려워하지 말자.

 

CalculateMoveTimerJob   =>  MoveJob 순이다.

코드 구조는 SHIT 소리가 나왔지만, Job 네이밍을 보니 어려울 것 같진 않다. (아마도..)

 

 

(3)

        // recalculate MoveTimer if MoveSpeed or Destination was changed
        var calculateMoveTimerJob = new CalculateMoveTimerJob
        {
            EntityTypeHandle = SystemAPI.GetEntityTypeHandle(),
            MoveTimer_CTH_RW = SystemAPI.GetComponentTypeHandle<MoveTimer>(false),
            MoveSpeed_CTH_RO = SystemAPI.GetComponentTypeHandle<MoveSpeed>(true),
            LTW_CTH_RO = SystemAPI.GetComponentTypeHandle<LocalToWorld>(true),
            Destination_CTH_RO = SystemAPI.GetComponentTypeHandle<Destination>(true),
            MovingTag_CTH_RW = SystemAPI.GetComponentTypeHandle<MovingTag>(false),
            LastSystemVersion = state.LastSystemVersion
        };
        state.Dependency = calculateMoveTimerJob.ScheduleParallelByRef(systemData.MovableQuery, state.Dependency);

 

Job 생성부 부터 보자.

SystemAPI.GetEntityTypeHandle() 현재 함수를 실행중인 엔티티들의 Array를 청크 단위로 가져오는 함수
SystemAPI.GetComponentTypeHandle<T> (bool) bool = ReadOnly, 해당 컴포넌트 데이터의 Array를 청크 단위로 가져온다.

 

Calcuation에 필요한 정보들을 ReadOnly / Readable로 구분해서 가져오고 있다.

state.LastSystemVersion은 그냥 Job에다가 현재 State를 넘겨준다는 의미이다.

 

(4)

[BurstCompile]
private struct CalculateMoveTimerJob : IJobChunk
{
    private const float Threshold = .01f;

    [ReadOnly] public EntityTypeHandle EntityTypeHandle;
    public ComponentTypeHandle<MoveTimer> MoveTimer_CTH_RW;
    [ReadOnly] public ComponentTypeHandle<MoveSpeed> MoveSpeed_CTH_RO;
    [ReadOnly] public ComponentTypeHandle<LocalToWorld> LTW_CTH_RO;
    [ReadOnly] public ComponentTypeHandle<Destination> Destination_CTH_RO;
    public ComponentTypeHandle<MovingTag> MovingTag_CTH_RW;
    public uint LastSystemVersion;

    public void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex, bool useEnabledMask, in v128 chunkEnabledMask)
    {
        if (chunk.DidChange(ref Destination_CTH_RO, LastSystemVersion)
            || chunk.DidChange(ref MoveSpeed_CTH_RO, LastSystemVersion))
        {
            var entities = chunk.GetNativeArray(EntityTypeHandle);
            var ltw = chunk.GetNativeArray(ref LTW_CTH_RO);
            var moveSpeeds = chunk.GetNativeArray(ref MoveSpeed_CTH_RO);
            var destinations = chunk.GetNativeArray(ref Destination_CTH_RO);
            var timers = chunk.GetNativeArray(ref MoveTimer_CTH_RW);

            for (int entityIndex = 0; entityIndex < entities.Length; entityIndex++)
            {
                var distance = math.length(destinations[entityIndex].Value - ltw[entityIndex].Position.xy);
                if (distance > Threshold)
                {
                    timers[entityIndex] = new MoveTimer { RemainingTime = GetRemainingTime(distance, moveSpeeds[entityIndex].value) };
                    if (!chunk.IsComponentEnabled(ref MovingTag_CTH_RW, entityIndex))
                        chunk.SetComponentEnabled(ref MovingTag_CTH_RW, entityIndex, true);
                }
            }
        }
    }
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    private static float GetRemainingTime(in float2 pos, in float2 dest, float speed)
        => GetRemainingTime(math.length(dest - pos), speed);
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    private static float GetRemainingTime(in float distance, float speed)
        => distance / speed;
}

 

이번에는 IJobChunk 형식이다. 

Execute 부분이 IJobChunk 에서 사용하는 방식인데.. 그냥 이렇게 쓰는구나 하자.. 

 

IJobChunk에서 chunk 단위로 관리 되기 때문에 DidChange로 엔티티가 변경 했었는지 체크할 수 있다.\

그래도 의외로 천천히 읽어보면 코드 자체가 어렵진 않다.

threshold 보다 높다면, Chunk의 Timer를 설정해주는 단순한 코드이다.

 

그렇다면? 모든 Chunk에 대해 Distance를 고려해서 모든 엔티티들의 (현재) Timer를 Setup 한다는 뜻!!

Threshold (목적지 도착 지점과의 거리)보다 높다면 Soldier가 움직여야 한다는 뜻이므로

MovingTag를 True로 해서 MovableAnimationControlSystem Walk 상태로 바꿔준다.

복잡하다 복잡해.. 그래도 시뮬레이션 만들때는 도움 엄청 될 것 같은 코드이다..!!

 

(5)

Timer를 설정 했으니, 움직여줘야겠지?

    [BurstCompile]
    [WithAll(typeof(MovingTag))]
    private partial struct MoveJob : IJobEntity
    {
        public float DeltaTime;
        [NativeDisableParallelForRestriction] public ComponentLookup<MovingTag> MovingTag_CL_RW;

        private void Execute(Entity entity, ref LocalTransform transform, ref MoveTimer timer, in Destination destination)
        {
            var remainingDelta = math.max(timer.RemainingTime, DeltaTime - timer.RemainingTime);
            // move pos in a direction of current destination by passed frac of whole remaining move time
            transform.Position += ((destination.Value - transform.Position.xy) * DeltaTime / remainingDelta).ToFloat3();
            timer.RemainingTime = math.max(0, timer.RemainingTime - DeltaTime);

            if (timer.RemainingTime == 0f)
                MovingTag_CL_RW.SetComponentEnabled(entity, false);
        }
    }

 

특이하게 Struct 위에 WithAll 어트리뷰트가 깔려있다.

용도는 뻔해보이지만, 이래뵈도 WithAllAttribute 라는 Constructor 공식 명칭이 존재한다.

MovingTag라는 Component가 있는 엔티티만을 해당 Job에서 사용 할 수 있다고 제약을 걸 수 있다고 한다.

 

-  [NativeDisableParallelForRestriction] public ComponentLookup<MovingTag> MovingTag_CL_RW;

 

NativeDisableParallelForRestriction은 앞서 설명했듯이, Native 타입이지만 편하게 사용하란 뜻

ComponentLookup 은 해당 Component들을 가져온단 뜻이다.

 

- private void Execute(Entity entity, ref LocalTransform transform, ref MoveTimer timer, in Destination destination)

 

이번에는 딱히 Query를 하지 않았는데도 뻔뻔하게 MoveTimer와 Destination를 다이렉트로 가져오고 있다.

var timers = chunk.GetNativeArray(ref MoveTimer_CTH_RW);
var destinations = chunk.GetNativeArray(ref Destination_CTH_RO);

 

 

다음 코드들은 그냥 이동 시키는 코드라 설명하진 않겠다.

 

timers / destinations 부분은 Local Intent 개념이 안통하는건 좀 신기하다..

나중에 알아봐야겠다.

 


 

이제 남은 System들은 Squad 관련 System 들이다.

5개의 코드만을 남겨두고 있다.

해당 코드는 다음 포스팅에서 진행할 예정이다.

무진장 어려워보이는 스크립트가 하나 있기도 하다..