반갑습니다.
지난 번 포스팅 이후로 텀이 좀 있었군요.
그동안 밀렸던 프로젝트 개발이랑 GPU 인스턴싱과 렌더링에 깊게 빠져 있었네요.
ECS 정복한다더니 갑자기 딴 (더욱 깊고 어두운) 길로 샜다가
ECS가 필요해질만 하니 다시 돌아온?
이번 포스팅은
Unity 공식 깃허브에서 제공하는 Entity Component System 프로젝트의 샘플
Hello Cube 프로젝트에서 6번 / 8번 / 12번을 알아보겠습니다.
6번 : Reparenting
8번 : GameObjectSycn
12번 : FixedTimestep
6번 프로젝트 : Reparenting
Scene을 열게 되면, 다음과 같이
부모 큐브와 자식 큐브들로 이루어진 오브젝트가 놓여있습니다.
무작정 실행해보면, 자식과 함께 회전하다가 부모만 회전 하는 걸 볼 수 있습니다.
자식-부모 계층을 끊어주는, Transform 을 변경하는 프로젝트군요.
코드는 2개지만,
RotationSystem 코드는 프로젝트 내내 지겹게 있는 단순한 시스템 코드라 넘어가겠습니다.
ReparentingSystem.cs
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Transforms;
using UnityEngine;
namespace HelloCube.Reparenting
{
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<ExecuteReparenting>();
state.RequireForUpdate<RotationSpeed>();
}
[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);
}
// Alternative solution instead of the above loop:
// A single call that removes the Parent component from all entities in the array.
// Because the method expects a NativeArray<Entity>, we create a NativeArray<Entity> alias of the DynamicBuffer.
/*
ecb.RemoveComponent<Parent>(children.AsNativeArray().Reinterpret<Entity>());
*/
}
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 });
}
// Alternative solution instead of the above loop:
// Add a Parent value to all entities matching a query.
/*
var query = SystemAPI.QueryBuilder().WithAll<LocalTransform>().WithNone<RotationSpeed>().Build();
ecb.AddComponent(query, new Parent { Value = rotatorEntity });
*/
}
ecb.Playback(state.EntityManager);
attached = !attached;
}
}
}
bool
timer
interval를 가지고 있는 시스템 코드입니다.
순서대로 코드 구조를 분석해보겠습니다.
(1) OnCreate
초기화 코드라 그런지 별건 없습니다.
timer는 앞으로 interval 만큼 초기화 되면서 진행 되겠다는 뜻이겠군요.
요구하는 컴포넌트는 2개지만, 크게 중요친 않습니다.
(2) OnUpdate
예상대로 타이머를 이용하고 있습니다.
타이머가 지나면, 해당 시스템이 동작하도록 하고 있습니다.
var rotatorEntity = SystemAPI.GetSingletonEntity<RotationSpeed>();
RotationSpeed를 가진 엔티티인데, 싱글톤인 엔티티를 쿼리해줍니다.
참고로 싱글톤이라고 하면, 일반적인 MonoBehaviour에서는 인스턴스를 하나만 두도록 처리하는데
ECS에서는 엔티티가 하나면 자동으로 싱글톤으로 취급 해줍니다.
물론, 엔티티가 2개 이상이면 싱글톤이 되지 않겠죠?
var ecb = new EntityCommandBuffer(Allocator.Temp);
다음 줄에서는
Reparenting 같은 경우, 엔티티의 구조를 바꾸겠다는 소리이니
당연하게도 ECB를 생성합니다.
그럼 Create에서 True로 설정했던 Attached (bool) 를 분기로 작업이 진행되는군요.
코드를 볼까요?
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);
}
// 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); 자식들로부터 Parent 컴포넌트를 제거함으로써, 회전자로부터 오든 자식들을 분리한다. (다음 TransformSystemGroup 단이 업데이트 될때, Child 버퍼와 Transforms들을 적절하게 업데이트 한다.) |
우린 그냥 계층 변경을 할려고 했을 뿐인데, 주석에서는 복잡한 표현을 사용 했습니다.
실제로, Child에 해당하는 엔티티들을 Parent 컴포넌트를 제거해야 하는 작업까지 ECB에 넣어야 합니다.
ECS에서는 Entity화 시킨 오브젝트가 자식을 가지고 있을 경우,
Child 라는 Dynamic Buffer 를 가진 채로 엔티티화 됩니다.
Dynamic Buffer는 List 처럼 동적으로 컴포넌트를 추가해주는 버퍼입니다.
// 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. |
주석에서 설명한 것처럼 자기 자식을 매번 EntityMaanger로 직접 Parent를 제거하려고 하면 (SystemAPI.Query) 매번, 부모의 Dynamic Buffer를 검색해야 하니, ECB를 사용하라고 합니다.
그러면 Attached가 False일 땐?
이미 자식과 부모 관계가 끊어져 있다면
자식 엔티티는 어떤 상태일까요? 저희가 Authoring 코드로 직접 엔티티화 시켜준게 아니기 때문에
RotationSpeed, ExecuteReparenting 컴포넌트들을 가지고 있을리가 없습니다.
샘플에서는 RotationSpeed의 유무를 통해서 자식 엔티티들을 쿼리 합니다.
foreach (var (transform, entity) in
SystemAPI.Query<RefRO<LocalTransform>>()
.WithNone<RotationSpeed>()
.WithEntityAccess())
{
ecb.AddComponent(entity, new Parent { Value = rotatorEntity });
WithNone 함수를 통해서 RotationSpeed가 없지만, LocalTransform을 가지고 있는 엔티티들을 찾아줘! 이왕이면 엔티티들도 같이 넘겨줄래? (WithEntityAccess) 라고 쿼리문을 작성 해놓은 것을 볼 수 있습니다.
AddComponent로 Parent를 다시 넘겨주는군요.
그리고 마지막으로 ECB.Playback을 함으로 써,
엔티티 구조가 변경되는 작업들의 명령을 수행하도록 합니다.
이번 샘플 프로젝트는 참 쉽죠?
하지만 설명하고 넘어가지 않은 부분이 있습니다.
(1) Dynamic Buffer
다이나믹 버퍼!
아무것도 모르고 이것저것 만지면 되겠지 하고 공부 없이 접근한 저를 크게 좌절 시켰던 버퍼입니다.
NativeArray 같은 Unmanaged 한 자료형만 받는 컴포넌트들은 List나 Queue를 사용 할 수 없습니다.
해당 버퍼의 존재는 이를 대체하도록 도와주는 "동적"인 컴포넌트 버퍼입니다.
* Managed 컴포넌트들은 동적인 자료형을 사용 할 수 있습니다. (Job / Burst를 쓸 수 없지만)
Chunk 단위로 연산해야 ECS를 제대로 활용 할 수 있기 때문에
Dynamic Buffer는 Chunk 를 고려한 설계로 Capacity라고 각 버퍼의 용량을 지정해줘야 합니다.
Capacity (용량) 이라는 특이한 특징을 갖고 있는데요.
처음에는 Dynamic Buffer 용량이 충분하기 때문에 포인터는 null로 표시되다가
용량을 넘어서면, Chunk 외부에 배열로 저장되며 포인터가 변경됩니다.
하지만, 나중에 버퍼가 줄어들었다 하더라도 외부에 있는 데이터는 계속 남아있기 때문에
나중에라도 반드시 할당 해제를 해줘야 합니다.
너무 많은 Capacity 설정은 Chunk 낭비로 이어지니 주의해서 개발해야 하는 부분입니다.
그리고, Entity의 호출 순서나 병렬, 비동기에 민감하기 때문에
구조적 변화가 있다면, 다이나믹 버퍼를 재 초기화 해줘야 합니다.
안그래도 동적이라 불안정한데.. 데이터 변경에 의한 유효성 체크까지 하려고하니
엄격하게 막는 것 같습니다.
public void DynamicBufferExample(Entity e)
{
// Acquires a dynamic buffer of type MyElement.
DynamicBuffer<MyElement> myBuff = EntityManager.GetBuffer<MyElement>(e);
// This structural change invalidates the previously acquired DynamicBuffer.
EntityManager.CreateEntity();
// A safety check will throw an exception on any read or write actions on the buffer.
var x = myBuff[0];
// Reacquires the dynamic buffer after the above structural changes.
myBuff = EntityManager.GetBuffer<MyElement>(e);
var y = myBuff[0];
}
https://docs.unity3d.com/Packages/com.unity.entities@1.0/manual/components-buffer.html
Dynamic buffer components | Entities | 1.0.16
Dynamic buffer components A dynamic buffer component is a component that acts as a resizable array.
docs.unity3d.com
다이나믹 버퍼는 해당 Docs를 한번 정독 해보는 걸 추천드립니다.
알고나니 뭐야 별거 아니네~ 했는데.. 모르고 쓰면.. 진짜 백지밖에 안됩니다.
(2) 샘플이 제안한 다른 방식 (Reinterpret)
샘플에서는 For문을 사용하지 않고 대체 할 수 있는 방식이라고 소개 하는군요.
// Detach 할 때,
ecb.RemoveComponent<Parent>(children.AsNativeArray().Reinterpret<Entity>());
// Attach 할 때,
var query = SystemAPI.QueryBuilder().WithAll<LocalTransform>().WithNone<RotationSpeed>().Build();
ecb.AddComponent(query, new Parent { Value = rotatorEntity });
둘 다 비슷하게 ECB에 Parent를 없애거나 부착하는 용도로 명령어를 예약하는 코드입니다.
아래 정확히 설명해주는 문서가 Docs에 있습니다.
https://docs.unity3d.com/Packages/com.unity.entities@1.0/manual/components-buffer-reinterpret.html
Reinterpret a dynamic buffer | Entities | 1.0.16
Reinterpret a dynamic buffer You can reinterpret a DynamicBuffer to get another DynamicBuffer , where T and U have the same size. This can be useful if you want to reinterpret a dynamic buffer of components as a dynamic buffer of the entities that the comp
docs.unity3d.com
You can reinterpret a DynamicBuffer<T> to get another DynamicBuffer<U>, where T and U have the same size. This can be useful if you want to reinterpret a dynamic buffer of components as a dynamic buffer of the entities that the components are attached to. This reinterpretation aliases the same memory, so changing the value at index i of one changes the value at index i of the other. T라는 컴포넌트의 DynamicBuffer는 같은 사이즈지만, 다른 컴포넌트의 DynamicBuffer로 재해석 할 수 있다. 해당 컴포넌트들이 부착된 엔티티들의 DynamicBuffer로서 가져올 때 유용하다. (특정 컴포넌트의 DynamicBuffer를 갖고 있는 엔티티들을 찾기 용이하다.) 이 "재해석 방식"은 같은 메모리를 점유하기 때문에, 해당 버퍼의 인덱스를 바꾸면 다른 쪽도 바뀌게 된다. |
굳이 for문으로 쓰지 않아도 된다는 뜻으로 이해하면 될 것 같군요.
8번 GameObjectSync
이번에는 다른 엔티티가 아니라 GameObject 를 활용하는 방법에 대한 샘플입니다.
샘플 동작은 간단합니다.
토글을 On 하면 회전, Off 하면 멈추게 되어 있습니다.
Directory.cs
namespace HelloCube.GameObjectSync
{
// The "Directory" acts as a central place to reference GameObject prefabs and managed objects.
// Systems can then get references to managed objects all from one place.
// (In a large project, you may want more than one "directory" if dumping
// all the managed objects in one place gets too unwieldy.)
public class Directory : MonoBehaviour
{
public GameObject RotatorPrefab;
public Toggle RotationToggle;
}
}
Directory 스크립트는 참조할 게임오브젝트 프리팹 혹은 Managed 오브젝트들을 관리하는 매니저 역할입니다.
예제에서는 GameObject와 Toggle을 관리하는 역할로 두었군요.
DirectoryInitSystem.cs
using System;
using Unity.Entities;
using UnityEngine;
using UnityEngine.UI;
using Unity.Burst;
namespace HelloCube.GameObjectSync
{
#if !UNITY_DISABLE_MANAGED_COMPONENTS
public partial struct DirectoryInitSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
// We need to wait for the scene to load before Updating, so we must RequireForUpdate at
// least one component type loaded from the scene.
state.RequireForUpdate<ExecuteGameObjectSync>();
}
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()
{
}
}
#endif
}
코드 분석을 합시다.
UNITY_DISABLE_MANAGED_COMPONENTS
어트리뷰트는 플레이어 세팅에서 Managed Component를 사용하지 않겠다라고 설정하면 사용 할 수 없다는 뜻 입니다.
이전 샘플에서도 말 했듯이 Job이나 Burst 같은 혜택을 받을 수 없기 때문입니다.
누가 이런 설정을 건들까 싶지만은..
(0) publc class DirectoryManaged : IComponentData
관리되는 오브젝트들을 담을 컴포넌트 입니다.
참고로 Struct가 아니라 Class 입니다!!
class로 선언해주면 리스트/큐 같은 동적 자료형은 물론, 오브젝트까지 받아올 수 있습니다.
https://docs.unity3d.com/Packages/com.unity.entities@1.0/manual/components-managed.html
Managed components | Entities | 1.0.16
Managed components Unlike unmanaged components, managed components can store properties of any type. However, they're more resource intensive to store and access, and have the following restrictions: You can't access them in jobs. You can't use them in Bur
docs.unity3d.com
(1) OnCreate
에서는 주석문이 경고하듯
적어도 Scene이 로드 될떄까지는 기다려야 한다고 경고를 해놓았군요.
(2) OnUpdate
state.Enabled = false;
var go = GameObject.Find("Directory");
시작하자마자 state를 꺼버리는걸 보니
프로젝트에서 단 한번만 동작 시키겠다는 뜻입니다.
게임 개발 할 때, 도움 될만한 팁이군요.
퍼블릭으로 인스턴스를 가져올 수 있는게 아니니
Find를 통해서 Directory (싱크할 오브젝트 매니저) 를 찾습니다.
var directory = go.GetComponent<Directory>();
var directoryManaged = new DirectoryManaged();
directoryManaged.RotatorPrefab = directory.RotatorPrefab;
directoryManaged.RotationToggle = directory.RotationToggle;
Managed 컴포넌트 클래스를 인스턴스화 시키고,
컴포넌트의 변수에 인스턴스가 가진 오브젝트들을 참조 시킵니다.
var entity = state.EntityManager.CreateEntity();
state.EntityManager.AddComponentData(entity, directoryManaged);
참조까지 마친 컴포넌트 인스턴스는 기존의 컴포넌트처럼
엔티티 AddComponentData로 추가 할 수 있습니다.
RotationInitSystem.cs
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using UnityEngine;
namespace HelloCube.GameObjectSync
{
#if !UNITY_DISABLE_MANAGED_COMPONENTS
[UpdateInGroup(typeof(InitializationSystemGroup))]
public partial struct RotatorInitSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<DirectoryManaged>();
state.RequireForUpdate<ExecuteGameObjectSync>();
}
// 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()
{
}
}
#endif
}
(0) Attribute
이번 시스템은 아무래도 오브젝트를 생성해야 되니까
InitializationSystemGroup에서 실행되는 System으로 어트리뷰트가 잡혀있습니다.
(1) RotatorGO : IComponentData
public class RotatorGO : IComponentData
{
public GameObject Value;
public RotatorGO(GameObject value)
{
Value = value;
}
// Every IComponentData class must have a no-arg constructor.
public RotatorGO()
{
}
}
이번에도 클래스입니다.
게임 오브젝트를 가지고 있으니 Managed 된 컴포넌트구요.
(2) OnCreate
역시 별거 없습니다.
DirectoryManaged / ExecuteGameObjectSync 컴포넌트를 요구합니다.
(3) OnUpdate
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);
아무래도 엔티티를 생성해서 오브젝트와 싱크를 하는 거라서 ECB를 생성해주고 있군요.
쿼리는 RotationSpeed를 가지고, RotatorGO를 가지고 있지 않은 엔티티를 받아오고 있습니다.
MainScene의 Directory가 갖고 있는 프리팹을 생성하고 (go)
이 go를 갖고 생성되는 RotatorGO 컴포넌트를 엔티티에게 부착 시켜줍니다.
단순한 싱크도 이런 번거로움이 필요하군요.....
RotationSystem.cs
using Unity.Burst;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;
namespace HelloCube.GameObjectSync
{
#if !UNITY_DISABLE_MANAGED_COMPONENTS
public partial struct RotationSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<DirectoryManaged>();
state.RequireForUpdate<ExecuteGameObjectSync>();
}
// 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;
}
}
}
#endif
}
싱크가 끝나고나면, 사용법은 간단합니다.
DirectoryManaged를 갖고 있는 엔티티를 싱글톤으로 가져와서
토글이 켜져있는지 체크하고
큐브를 돌립니다.
이상 GameObjectSync 였습니다.
좀 헷갈리게 하는 구석이 있네요.
GameObject랑 연동된다는 점에서는 좋지만
Job / Burst 를 사용할 수 없으므로, 잘 고민해봐야 할 것 같습니다.
참조를 여러번 물려주는 방식이라...
12번 FixedTimeStep
8번 샘플의 경우는 미리 만들어둔 GameObject를 Entity와 싱크해서 만들었습니다.
이번에는 GameObject 에서 Entity로 접근하는 방식인데요.
해당 방식은 ECS 패턴을 망치는 것이기 때문에 사용하지 말 것을 권장하고 있습니다.
Slider를 조종하여 물결 생성 시간을 조절
SliderHandler.cs
using Unity.Entities;
using UnityEngine;
using UnityEngine.UI;
namespace HelloCube.FixedTimestep
{
public class SliderHandler : MonoBehaviour
{
public Text sliderValueText;
public void OnSliderChange()
{
float fixedFps = GetComponent<Slider>().value;
// WARNING: accessing World.DefaultGameObjectInjectionWorld is a broken pattern in non-trivial projects.
// GameObject interaction with ECS should generally go in the other direction: rather than having
// GameObjects access ECS data and code, ECS systems should access GameObjects.
var fixedSimulationGroup = World.DefaultGameObjectInjectionWorld
?.GetExistingSystemManaged<FixedStepSimulationSystemGroup>();
if (fixedSimulationGroup != null)
{
// The group timestep can be set at runtime:
fixedSimulationGroup.Timestep = 1.0f / fixedFps;
// The current timestep can also be retrieved:
sliderValueText.text = $"{(int)(1.0f / fixedSimulationGroup.Timestep)} updates/sec";
}
}
}
}
ECS에 직접 접근하는 방식의 코드입니다.
fixedFps 슬라이더의 값을 넣어주는데요.
여기서 World라는 용어가 나옵니다.
Docs를 읊어볼까요?
Encapsulates a set of entities, component data, and systems. Remarks Multiple Worlds may exist concurrently in the same application, but they are isolated from each other; an EntityQuery will only match entities in the World that created the query, a World's EntityManager can only process entities from the same World, etc.
엔티티들, 컴포넌트 데이터, 시스템들의 집합을 캡슐화 한다. 여러 월드들은 동시에 존재할 수 있다. 하지만, 서로 고립되어 있다. 엔티티 쿼리는 오직 해당 쿼리가 생성된 월드에서만 매칭하여 엔티티를 찾는다. 월드의 엔티티 매니저는 같은 월드로부터의 엔티티들만 처리한다. |
모든 예제가 World가 디폴트로 되어 있어 활용처는 잘 모르겠네요.
스테이지 별 월드를 만든다던가?
그런 용도로 쓰일 것 같습니다.
어쩄거나 World의 다음 프로퍼티들을 사용하고 있죠.
- DefaultGameObjectInjectionWorld
|
뭐.. Docs만 순서대로 읽어보면 무슨 코드인지 이해가 가실겁니다.
FixedTime을 다루는 SystemGroup 단에서 TimeStep을 기존의 1/60초에서 Slider의 값으로 샘플링하도록 합니다.
코드를 실행해보면 웨이브가 생성되는데
그 생성 주기를 조절해주죠.
그게 전부입니다.
다만, 이번 프로젝트는 코드가 좀 많습니다..
이제는 모든 코드가 쉽지만.. 그래도 하나씩 봐야겠죠.
생성된 웨이브부터 볼까요?
ProjectileAuthoring 입니다.
ProjectileAutohring.cs
using Unity.Entities;
using Unity.Mathematics;
using UnityEngine;
namespace HelloCube.FixedTimestep
{
public class ProjectileAuthoring : MonoBehaviour
{
class Baker : Baker<ProjectileAuthoring>
{
public override void Bake(ProjectileAuthoring authoring)
{
var entity = GetEntity(TransformUsageFlags.Dynamic);
AddComponent<Projectile>(entity);
}
}
}
public struct Projectile : IComponentData
{
public float SpawnTime;
public float3 SpawnPos;
}
}
이번 포스팅에서 참 오랜만에 보는 Authoring 코드입니다.
별거 없습니다.
SpawnTime과 SpawnPos를 결정하는 Projectile 컴포넌트를 가진채로 엔티티화 시켜주는 코드입니다.
다만, 이 Authoring 스크립트는 웨이브 역할을 하는 Projectile 프리팹이 가지고 있습니다.
스포너들이 생성을 해야 해당 엔티티가 동작을 하겠죠?
MoveProjectileSystem.cs
using Unity.Burst;
using Unity.Entities;
using Unity.Transforms;
namespace HelloCube.FixedTimestep
{
public partial struct MoveProjectilesSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<ExecuteFixedTimestep>();
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var ecbSingleton = SystemAPI.GetSingleton<EndSimulationEntityCommandBufferSystem.Singleton>();
new MoveJob
{
TimeSinceLoad = (float)SystemAPI.Time.ElapsedTime,
ProjectileSpeed = 5.0f,
ECBWriter = ecbSingleton.CreateCommandBuffer(state.WorldUnmanaged).AsParallelWriter()
}.ScheduleParallel();
}
}
[BurstCompile]
public partial struct MoveJob : IJobEntity
{
public float TimeSinceLoad;
public float ProjectileSpeed;
public EntityCommandBuffer.ParallelWriter ECBWriter;
void Execute(Entity projectileEntity, [ChunkIndexInQuery] int chunkIndex, ref LocalTransform transform,
in Projectile projectile)
{
float aliveTime = TimeSinceLoad - projectile.SpawnTime;
if (aliveTime > 5.0f)
{
ECBWriter.DestroyEntity(chunkIndex, projectileEntity);
}
transform.Position.x = projectile.SpawnPos.x + aliveTime * ProjectileSpeed;
}
}
}
Projectile 엔티티들이 생성되면 동작하게 될 스크립트입니다.
이번에는 IJobEntity를 이용하고 있네요.
순서대로 구조를 리뷰해봅시다.
1. OnCreate
ExecutedFixedTimestep 컴포넌트가 있을 때만, 업데이트를 진행한다.
이제는 타자치기도 지겨운 Require문이군요.
2. OnUpdate
var ecbSingleton = SystemAPI.GetSingleton<EndSimulationEntityCommandBufferSystem.Singleton>();
투사체가 삭제될 시점 때문에 ECB를 사용하는거라 ECB 시점을 EndSimulation 단계로 정해주고 버퍼를 생성한 것 같습니다.
3. MoveJob
[BurstCompile]
public partial struct MoveJob : IJobEntity
{
public float TimeSinceLoad;
public float ProjectileSpeed;
public EntityCommandBuffer.ParallelWriter ECBWriter;
void Execute(Entity projectileEntity, [ChunkIndexInQuery] int chunkIndex, ref LocalTransform transform,
in Projectile projectile)
{
float aliveTime = TimeSinceLoad - projectile.SpawnTime;
if (aliveTime > 5.0f)
{
ECBWriter.DestroyEntity(chunkIndex, projectileEntity);
}
transform.Position.x = projectile.SpawnPos.x + aliveTime * ProjectileSpeed;
}
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var ecbSingleton = SystemAPI.GetSingleton<EndSimulationEntityCommandBufferSystem.Singleton>();
new MoveJob
{
TimeSinceLoad = (float)SystemAPI.Time.ElapsedTime,
ProjectileSpeed = 5.0f,
ECBWriter = ecbSingleton.CreateCommandBuffer(state.WorldUnmanaged).AsParallelWriter()
}.ScheduleParallel();
}
Job 코드 자체는 매우 쉽습니다.
TimeSinceLoad = 프로그램 실행 시간
ProjectileSpeed = 날라가는 속도
ECBWriter = EndSimulation 시점에 예약된 커맨드 버퍼 (업데이트 문의 마지막에 실행 하겠다는 뜻)
실제 Execute 문에서는
계속 X 축으로 이동 하면서
aliveTime = 프로그램 실행 시간 - 생성 된 시간을 보고 ECB 보고 삭제하라고 하는 간단한 코드입니다.
다음은 DefaultRateSpawner 와 FixedRateSpawner 들을 볼 차례지요?
둘의 차이점은 SliderHandler 에서 정한 값을 생성 주기로 사용하느냐의 차이입니다.
DefaultRateSpawner 는 ElapsedTime을 이용하고 FixedRateSpawner는 Fixed Time을 사용합니다.
DefaultRateSpawnerAuthoring.cs
using Unity.Entities;
using Unity.Mathematics;
using UnityEngine;
namespace HelloCube.FixedTimestep
{
public class DefaultRateSpawnerAuthoring : MonoBehaviour
{
public GameObject projectilePrefab;
class Baker : Baker<DefaultRateSpawnerAuthoring>
{
public override void Bake(DefaultRateSpawnerAuthoring authoring)
{
var entity = GetEntity(TransformUsageFlags.None);
var spawnerData = new DefaultRateSpawner
{
Prefab = GetEntity(authoring.projectilePrefab, TransformUsageFlags.Dynamic),
SpawnPos = GetComponent<Transform>().position,
};
AddComponent(entity, spawnerData);
}
}
}
public struct DefaultRateSpawner : IComponentData
{
public Entity Prefab;
public float3 SpawnPos;
}
}
별거 없는 베이킹 코드입니다.
Prefab은 앞서 말씀 드린 Projectile 프리팹이구요.
DefaultRateSpawnerSystem.cs
using Unity.Burst;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;
namespace HelloCube.FixedTimestep
{
public partial struct DefaultRateSpawnerSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<ExecuteFixedTimestep>();
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
float spawnTime = (float)SystemAPI.Time.ElapsedTime;
foreach (var spawner in
SystemAPI.Query<RefRW<DefaultRateSpawner>>())
{
var projectileEntity = state.EntityManager.Instantiate(spawner.ValueRO.Prefab);
var spawnPos = spawner.ValueRO.SpawnPos;
spawnPos.y += 0.3f * math.sin(5.0f * spawnTime);
SystemAPI.SetComponent(projectileEntity, LocalTransform.FromPosition(spawnPos));
SystemAPI.SetComponent(projectileEntity, new Projectile
{
SpawnTime = spawnTime,
SpawnPos = spawnPos,
});
}
}
}
}
코드가 매우 간단합니다.
Update 쪽만 보겠습니다.
spawnTime을 ElapsedTime을 사용하고,
싱글톤이나 마찬가지인 것 같지만 for문에서 DefaultRateSpawner 컴포넌트로 쿼리하고 있습니다.
내용은 당연히 엔티티를 생성하는 코드겠죠?
매 프레임마다 생성하면서, 단순히 생성한 Projectile 엔티티의 SpawnTime 값에 ElapsedTime 값을 넣어줄 뿐입니다.
FixedRateSpawnerAuthoring.cs
using Unity.Entities;
using Unity.Mathematics;
using UnityEngine;
namespace HelloCube.FixedTimestep
{
public class FixedRateSpawnerAuthoring : MonoBehaviour
{
public GameObject projectilePrefab;
class Baker : Baker<FixedRateSpawnerAuthoring>
{
public override void Bake(FixedRateSpawnerAuthoring authoring)
{
var entity = GetEntity(TransformUsageFlags.None);
var spawnerData = new FixedRateSpawner
{
Prefab = GetEntity(authoring.projectilePrefab, TransformUsageFlags.Dynamic),
SpawnPos = GetComponent<Transform>().position,
};
AddComponent(entity, spawnerData);
}
}
}
public struct FixedRateSpawner : IComponentData
{
public Entity Prefab;
public float3 SpawnPos;
}
}
Fixed 스포너도 Default 스포너와 정확히 동일합니다.
대신에 컴포넌트는 이름만 다른 FixedRateSpawner 를 가지고 있군요.
FixedRateSpawnerSystem.cs
using Unity.Burst;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;
namespace HelloCube.FixedTimestep
{
// This system is virtually identical to DefaultRateSpawner; the key difference is that it updates in the
// FixedStepSimulationSystemGroup instead of the default SimulationSystemGroup.
[UpdateInGroup(typeof(FixedStepSimulationSystemGroup))]
public partial struct FixedRateSpawnerSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<ExecuteFixedTimestep>();
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
float spawnTime = (float)SystemAPI.Time.ElapsedTime;
foreach (var spawner in
SystemAPI.Query<RefRO<FixedRateSpawner>>())
{
var projectileEntity = state.EntityManager.Instantiate(spawner.ValueRO.Prefab);
var spawnPos = spawner.ValueRO.SpawnPos;
spawnPos.y += 0.3f * math.sin(5.0f * spawnTime);
SystemAPI.SetComponent(projectileEntity, LocalTransform.FromPosition(spawnPos));
SystemAPI.SetComponent(projectileEntity, new Projectile
{
SpawnTime = spawnTime,
SpawnPos = spawnPos,
});
}
}
}
}
이 시스템 코드의 차이점은 딱 하나! Attribute 입니다.
해당 시스템이 동작하는 시점을 FixedStepSimulationSystemGroup 으로 하겠다. 이죠
그러면 SliderHandler에서 설정했던 값의 주기마다 프로그램이 실행된 시간 Elapased Time을 캡쳐해서
Projectile을 생성하겠다. 란 시스템 코드입니다.
너무 쉽다!!!
해당 프로젝트에서 눈여겨 볼껀,
GameObject에서 직접적으로 World에 접근하는게 가능은 하다.
하지만 권하지 않는다.
(Entity 세계는 일사분란하게 System에 맞게 동작하는데.. 저희가 막 접근해서 쓰는건 좋은 행위가 아니라고 보는거죠.)
SystemUpdateGroup을 잘 생각해서 설계해야 할 것으로 보입니다.
그러면 이번 포스팅은 여기까지 하도록 하겠습니다.
원래 Input 관련으로 쓸려고 했는데.. 좀 예시 자료 만들기 귀찮아욧..
이번 포스팅에서의 핵심 주제는
ECS가 기존 시스템과 비슷해보이는 기능들이라도, 잘 알아보고 쓰자 입니다.
Dynamic Buffer 는 요긴하게 쓰일테니 Docs를 정독 하시는 걸 추천 드립니다.
감사합니다. 좋은 하루 되세요.