특수 용도를 위해 대기열 사이에 대기열을 둘 수 있습니다. 내부적으로 각 대기열은 정수 인덱스로 대표됩니다. 예를 들어,Background는 1000,Geometry는 2000,AlphaTest는 2450,Transparent는 3000,Overlay는 4000입니다. 셰이더가 대기열을 다음과 같이 사용한다면
Tags { "Queue" = "Geometry+1" }
렌더 대기열 인덱스가 2001이 되므로(Geometry+1), 이 오브젝트는 모든 불투명 오브젝트가 렌더링된 후에 렌더링되고 투명 오브젝트보다는 먼저 렌더링됩니다. 이 방법은 일부 오브젝트가 항상 다른 오브젝트 세트 사이에 그려지도록 하고 싶을 때 유용합니다. 예를 들어, 투명한 물은 불투명한 오브젝트보다는 나중에 그려야 하지만 투명한 오브젝트보다 먼저 그려야 합니다.
2500 이하의 대기열(“Geometry+500”)은 “불투명”으로 간주되며 최상의 성능을 위해 오브젝트의 드로우 순서를 최적화합니다. 더 높은 인덱스의 렌더링 대기열은 “투명 오브젝트”로 간주되며 거리에 따라 오브젝트를 정렬합니다. 즉, 가장 먼 오브젝트부터 렌더링하여 가장 가까운 오브젝트를 맨 마지막에 렌더링합니다. 스카이박스는 모든 불투명 오브젝트와 투명 오브젝트 사이에 그려집니다.
C#은 상당히 좋은 언어다. 가장 많이 알려진 C#의 특징 중 하나는 메모리 관리에 부담이 없다는 점이다.
So Cool~ C# 메모리 C/C++를 사용하면서 포인터 때문에 괴로워 해본 적이 있는가? 그렇다면 C#에 관심을 가져보는 것이 좋다. C#은 다음과 같은 특징들을 제공하기 때문이다.
- 메모리 해제에 신경 쓰지 않아도 된다. - 이미 삭제된 메모리에 접근하는 실수를 방지해준다. - 잘못된 캐스팅으로 엉뚱한 메모리에 접근하지 않게 한다. - 배열 크기보다 큰 메모리에 접근하지 못한다. - 메모리 단편화에 대해 신경 쓰지 않아도 된다.
편한 C#, 마구잡이로 사용하면 낭패 골치 아픈 메모리 관리를 신경 쓰지 않아도 된다는 점은 사용자들에게 무척 편리하게 다가온다. 하지만 C#에서도 메모리를 다루기 위해서는 세심한 주의가 필요하다. 마음 놓고 개발하다 당황했던 과거 필자의 경험을 살펴보도록 하자.
개 발 초창기, 게임 플레이 중에 주기적으로 랙이 발생했다. 로직을 확인해 봤지만 특별히 로딩 중이거나 초기화된 부분이 없어 의아했다. 유니티 엔진에서 제공하는 프로파일러로 한 프레임에 걸리는 시간을 측정해봤다. 측정 결과, System.GC.Collect() 호출에 오랜 시간이 걸린다는 점이 발견됐다. 플레이 중에 프레임마다 소모되는 시간을 그래프로 보여주는 <그림 1>을 보면 System.GC.Collect() 호출 시 그래프가 크게 튀어 오른 모습이 확인된다. C#에서 사용하지 않는 메모리를 정리하면서 가비지 컬렉션(Garbage collection) 랙이 발생한 것이다.
<그림 1> 프로파일러 이미지
이때는 가비지 컬렉션이 동작하는 횟수를 줄여서 랙 발생을 줄이면 된다. 가비지 발생을 줄이면 가비지 컬렉션이 호출되는 시간을 늦출 수 있어 동작 횟수가 줄어든다. 가비지란 프로그램이 실행되면서 어디에서든 더 이상 참조되지 않는 메모리를 의미하므로 가능한 한 메모리를 할당했다 금방 버려지는 상황을 만들지 않는 것이 좋다. 몇 가지 사례들을 살펴보자.
‘+’ operator를 통한 문자열 조합 C#은 문자열 조합이 쉽다. <리스트 1>에 보이는 것처럼 ‘+’로 연결하면 간단히 문자열 조합이 이뤄진다. 모든 객체가 ToString()을 지원하기 때문에 문자열끼리만 조합되는 게 아니라 int, float 등의 값도 알아서 문자열로 변환·조합된다.
<리스트 1> ‘+’로 연결한 문자열 조합 class Names { public string[] name = new string[100]; public void Print() { for (int index = 0; index < name.Length; index++) { string output = "[" + index + "]" + name; Console.WriteLine(output); } } }
문제는 <리스트 1>에서 가비지가 많이 발생한다는 점이다. ‘+’ 연산자로 두 값을 연결할 때마다 새로운 string 인스턴스가 생성된다. 연이어 ‘+’ 연산자가 나오기 때문에 다시금 새로운 string 인스턴스가 생성되고, 이전에 만들어진 string 인스턴스는 가비지가 된다. string 조합을 위해 ‘+’ operator 호출이 많아질수록 많은 가비지가 만들어지는 것이다.
그래서 문자열을 조합하는 동안 새로운 객체를 생성하지 않는 System.Text.StringBuilder 객체를 소개한다. ‘+’ operator가 아닌 Append() 메소드를 통해 문자열을 추가하며, string 객체를 만들어내는 게 아니라 이미 잡아놓은 메모리 공간에 문자열만 복사해 뒀다가 한번에 ToString()으로 string 객체를 생성해낸다.
<리스트 2> System.Text.StringBuilder 객체 사용 class NewNames { public string[] name = new string[100]; private StringBuilder sb = new StringBuilder(); public void Print() { sb.Clear(); // sb.Length = 0; for (int index = 0; index < name.Length; index++) { sb.Append("["); sb.Append(index); sb.Append("] "); sb.Append(name); sb.AppendLine(); } Console.WriteLine(sb.ToString()); } }
과다한 Append() 메소드 호출이 필요해 ‘+’ 코드보다 깔끔하지 못하다고 생각된다면 AppendFormat()을 사용하는 것도 좋다.
<리스트 3> AppendFormat() 활용 class NewNames { public string[] name = new string[100]; private StringBuilder sb = new StringBuilder(); public void Print() { sb.Clear(); // sb.Length = 0; for (int index = 0; index < name.Length; index++) { sb.AppendFormat("[{0}] {1}", index, name.ToString()); } Console.WriteLine(sb.ToString()); } }
string처럼 Immutable pattern을 사용한 객체들의 값에 접근할 때는 기존 메모리를 수정하지 않고 새로운 메모리를 만들어 반환하거나 입력받으므로 사용 시 주의가 필요하다.
메소드 안에서 생성한 객체 C#은 C++과 달리 클래스를 인스턴싱하려면 반드시 new를 해줘야 한다. 이때 heap에서 메모리 할당이 일어난다.
<리스트 4>와 같이 메소드 안에서 new로 생성된 인스턴스는 메소드를 빠져나오면 더 이상 사용하지 않게 돼 가비지로 처리된다. 이런 패턴의 메소드가 자주 호출될수록 가비지도 많이 발생한다.
<리스트 4> new로 생성된 인스턴스 public class MyVector { public float x, y; public MyVector(float x, float y) { this.x = x; this.y = y; } public double Length() { return System.Math.Sqrt(x * x + y * y); } } static class TestMyVector { public static void PrintVectorLength(float x, float y) { MyVector v = new MyVector(x, y); Console.WriteLine("Vector=({0},{1}), lenght={2}", x, y, v.Length()); } }
Vector 클래스를 구조체로 바꿔보면, new 연산자로 인스턴스를 만들어도 heap 영역에 메모리가 할당되지 않는다. 구조체 역시 Value type이기 때문에 stack 영역에 메모리가 할당되며, 메소드를 빠져나갈 경우 자동으로 삭제된다. 물론 heap 영역에 생성된 메모리가 아니기 때문에 가비지 컬렉션의 대상이 되지도 않는다.
<리스트 5> Vector 클래스를 구조체로 변환 public struct MyVector { public float x, y; public MyVector(float x, float y) { this.x = x; this.y = y; } public double Length() { return System.Math.Sqrt(x * x + y * y); } } static class TestMyVector { public static void PrintVectorLength(float x, float y) { MyVector v = new MyVector(x, y); Console.WriteLine("Vector=({0},{1}), lenght={2}", x, y, v.Length()); } }
구조체로 바꿀 수 없다면, <리스트 6>처럼 멤버변수 사용을 권장한다.
<리스트 6> 멤버변수 사용 public class MyVector { public float x, y; public MyVector() { x = .0f; y = .0f; } public MyVector(float x, float y) { this.x = x; this.y = y; } public double Length() { return System.Math.Sqrt(x * x + y * y); } } static class TestMyVector { private static MyVector m_cachedVector = new MyVector(); public static void PrintVectorLength(float x, float y) { m_cachedVector.x = x; m_cachedVector.y = y; Console.WriteLine("Vector=({0},{1}), lenght={2}", x, y, m_cachedVector.Length()); } }
속도 저하가 큰 Boxing Boxing이란 Value type 객체를 Reference type 객체로 포장하는 과정을 뜻한다. C#의 모든 객체는 object로부터 상속되는데, 심지어 상속받지 못하는 int, float 등의 Value type 조차도 object로부터 상속된 것처럼 사용할 수 있다. 하지만 가비지 컬렉션에 의한 부하 못지않게 boxing으로 인한 부하도 크다. 무심코 만든 코드에서 boxing 과정이 일어나는 경우가 있으니 잘 이해하고 사용해야 한다.
<리스트 7>을 보면 리스트에 서로 다른 type의 값을 추가했지만, loop 안에서 추가 값을 object type으로 받아 하나의 코드로 처리할 수 있음을 알 수 있다.
<리스트 7> 서로 다른 type 값 추가 class MyClass { public override string ToString() { return "다섯"; } static public void Sample() { ArrayList list = new ArrayList(); list.Add(1); list.Add(1.5f); list.Add(‘3’); list.Add("four"); list.Add(new MyClass()); foreach (object item in list) Console.WriteLine(item.ToString()); } }
<그림 2> <리스트 7>의 실행 결과
매력적인 C#이지만 Value type의 값을 Reference type인 object로 바꿔주는 과정에는 많은 시간이 걸리며 변환 시에는 System.Object 내부에 랩핑하고 관리되는 heap에 저장된다. 즉 새로운 객체가 만들어지는 셈이다. MSDN에서 발췌한 <그림 3>을 참조하길 바란다.
따라서 한 번에 다양한 type을 처리하는 경우가 아니라면 collection에 사용된 값의 type을 명시해주는 Generic collection 사용을 권한다. Generic은 C++의 template와 비슷하다. 그래서 Generic collection들은 C++의 STL container들과 비슷하게 생겼다. <리스트 8>을 참고하자.
<리스트 8> Generic collection class Example { static public void BadCase() { ArrayList list = new ArrayList(); int evenSum = 0; int oddSum = 0; for (int i = 0; i < 1000000; i++) list.Add(i); foreach (object item in list) { if (item is int) { int num = (int)item; if(num % 2 ==0) evenSum += num; else oddSum += num; } }
Console.WriteLine("EvenSum={0}, OddSum={1}", evenSum, oddSum); } static public void GoodCase() { List<int> list = new List<int>(); int evenSum = 0; int oddSum = 0; for (int i = 0; i < 1000000; i++) list.Add(i); foreach (int num in list) { if (num % 2 == 0) evenSum += num; else oddSum += num; }
메모리가 계속 늘어나는 또 다른 문제의 발생! 이 글을 시작하며 C#에서 사용자는 메모리 해제에 신경 쓸 필요가 없다고 했지만 의도하지 않게 메모리가 늘어나기도 한다. C#에는 delete 같은 메모리 해제 명령이 없기에 메모리 릭(memory leak) 현상이 발생하면 당혹스러울 수 있다. 여기서 C# 메모리의 특징을 다시 한 번 떠올려보자.
시스템에서 더 이상 참조가 없는 메모리를 알아서 해제하는 것을 우리는 가비지 컬렉션이라 부른다. 가비지는 더 이상 참조가 없는 메모리다. C# 애플리케이션이 메모리가 해제되지 않고 계속 증가되고 있다면 어디선가 의도하지 않는 참조가 일어나고 있다고 보면 된다. 그렇다면 어디에서 의도하지 않은 참조가 일어나는 것일까? 예를 통해 확인해 보자.
<그림 4> #1 - 케릭터 매니저에서 케릭터를 생성한다
<그림 5> #2 - 누군가 디버깅을 위해 '캐릭터 위치 표시' 객체를 만들고 캐릭터 매니저에 접근해 등록된 캐릭터를 모두 참조한다
<그림 6> #3 - 캐릭터 매니저에서 필요없는 캐릭터를 삭제한다
<그림 7> #4 - 캐릭터 매니저에서 삭제됏지만 '캐릭터 위치 표시' 객체에서는 여전히 참조 중이다. 가비지가 아니기 때문에 메모리에 계속 남아있으며, 구현에 따라서는 의도하지 않게 화면에 남을 수도 있다.
WeakReference로 의도하지 않은 참조를 없애보자 System.WeakReference 는 가비지 컬렉션에 의한 객체 회수를 허용하면서 객체를 참조한다. 인스턴스를 참조하려면 Weak Reference.Target으로 접근해야 하는데 원본 인스턴스가 가비지 컬렉터에 의해 회수되면 WeakReference.Target은 null이 반환된다.
<리스트 9> WeakReference.Target public class Sample { private class Fruit { public Fruit(string name) { this.Name = name; } public string Name { private set; get; } } public static void TestWeakRef() { Fruit apple = new Fruit("Apple"); Fruit orange = new Fruit("Orange");
Fruit fruit1 = apple; // strong reference WeakReference fruit2 = new WeakReference(orange); Fruit target;
target = fruit2.Target as Fruit; Console.WriteLine(" (1) Fruit1 = \"{0}\", Fruit2 = \"{1}\"", fruit1.Name, target == null ? "" : target.Name); apple = null; orange = null; System.GC.Collect(0, GCCollectionMode.Forced); System.GC.WaitForFullGCComplete(); // fruit1과 fruit2의 값을 바꾼 적은 없지만, fruit2의 결과가 달라진다. target = fruit2.Target as Fruit; Console.WriteLine(" (2) Fruit1 = \"{0}\", Fruit2 = \"{1}\"", fruit1==null ? "" : fruit1.Name, target == null ? "" : target.Name); } }
<리스트 9>의 실행으로 <그림 8>을 확인할 수 있다. Fruit2가 참조하고 있던 orange 인스턴스는 가비지 컬렉터에 의해 회수돼 null이 됐다.
<그림 8> <리스트 9>의 실행 결과
‘캐릭터 매니저’처럼 객체의 생성·삭제를 직접 관여하는 모듈이 아닌 곳에서는 가능한 WeakRefernce를 사용하는 것이 좋다. ‘객체 위치 표시 객체’처럼 인스턴스를 참조하는 모듈에서 WeakReference를 사용하면, 의도하지 않은 참조로 메모리가 해제되지 않는 실수를 방지할 수 있다. 주의할 점은 Weak Reference.Target 값을 보관하면 안 된다는 것이다. 만약 그대로 보관하고 있으면 강한 참조(strong reference)가 일어나 이를 인식한 가비지 컬렉터는 회수를 실행하지 않게 된다.
C/C++처럼 원하는 시점에 객체를 삭제하고 싶다면 C#에서 는 할당된 메모리를 임의로 해제할 수 없다. 컬렉션에 보관된 인스턴스를 제거하거나 인스턴스를 담고 있던 변수에 null을 넣어 더 이상 참조하지 않는 방법이 있지만 실제 인스턴스가 삭제되는 시점은 가비지 컬렉션 동작 이후가 되므로, 언제가 될 지 정확히 알 수 없다. 의도한 시점에 맞춰 정확히 삭제할 수 없다는 점이 그렇게 중요하지는 않다. 하지만 캐릭터 매니저에서 캐릭터를 제거했는데도 여전히 캐릭터 인스턴스가 남아서 화면에 한동안 계속 나타나는 경우가 발생할 수 있다.
Dispose pattern 소개C#에서는 관리되지 않는 메모리(리소스)를 해제하는 용도로 System.IDisposable이라는 인터페이스를 제공한다. IDisposable 인터페이스를 상속받은 클래스라면 용도에 맞게 Dispose()를 구현해줘야 하는데 이는 FileStream 관련 객체들에서 많이 볼 수 있다.
리소스를 강제로 해제시키려면 직접 Release(), Delete(), Destroy(), Close() 등의 메소드를 만들어 사용하면 되는데 굳이 IDisposable을 사용할 필요가 있을까? 서로 다른 type의 객체여도 IDisposable 인터페이스를 상속받고 있다면, 하나의 코드로 다양한 type의 메모리를 정리할 수 있기 때문에 IDisposable을 사용할 필요가 있다. 또한 Dispose() 메소드만 보고도 “아, 이 클래스는 사용이 끝나면 Dispose()를 호출해서 메모리를 정리해야 하는구나” 라고 금방 알 수 있다.
캐릭터 객체에서 IDisposable 인터페이스를 구현해보자. 업데이트 목록에서도 제외시키고 렌더링 정보도 다 지우자. 캐릭터의 Dipsose()를 호출한 이후에 캐릭터는 어떠한 동작도 하지 못하게 된다. 물론 Dispose()를 호출한다고 캐릭터가 가비지 컬렉터에 의해 메모리 해제되는 것은 아니다.
WeakReference과 IDisosalbe의 조합원하는 시점에 메모리를 해제하려면 앞서 설명한 Weak Reference와 IDisposable을 개별적으로 사용하는 것으로는 부족하다. 둘을 함께 사용하는 것이 좋다. <리스트 10>을 보자.
<리스트 10> Disposable 인터페이스를 상속받아 구현된 캐릭터 클래스 namespace MyApp { public class SampleChar : IDisposable { private IRenderObject m_Render = Renderer.CreateRenderObject(); public void Dispose() { SampleCharManager.Remove(this); m_Render = null; } public bool isRemoved { get { return m_Render == null; } } public void Render() { if (m_Render == null) return; // 렌더링 } public void Update() { } } }
예제로 만들어 본 캐릭터 클래스는 Disposable 인터페이스를 상속받아 구현된다. Dispose 후에는 더 이상 업데이트가 되지 않도록 SampleCharManager에서 제거되며, 렌더링 객체를 null로 만들어 화면에 그려지지 않도록 했다.
IRenderObject 인터페이스는 <리스트 11>과 같이 구현된다.
<리스트 11> IRenderObject 인터페이스 namespace MyApp { public interface IRenderObject { void Render(); } public static class Renderer { public static IRenderObject CreateRenderObject() { return new DumyRenderObject(); // IRenderObject를 상속받은 더미 객체 } } }
<리스트 12>의 캐릭터 매니저 클래스는 등록된 캐릭터들을 일괄적으로 업데이트시키고 렌더링한다.
<리스트 12> 등록 캐릭터 일괄 업데이트 및 렌더링 namespace MyApp { static class SampleCharManager { private static List<SampleChar> m_list = new List<SampleChar>(); public static void Update() { foreach (SampleChar obj in m_list) obj.Update(); } public static void Render() { foreach (SampleChar obj in m_list) obj.Render(); } public static void Add(SampleChar obj) { m_list.Add(obj); } public static void Remove(SampleChar obj) { m_list.Remove(obj); } } }
<리스트 13>의 디버깅을 위한 ‘캐릭터 위치 표시 객체’는 WeakReference를 통해 SampleChar 객체를 참조하도록 구현돼 있고, SampleCharManager에서 캐릭터를 삭제하더라도 안전하게 가비지가 회수된다. 업데이트 시 DisplayCharInfo는 삭제된 캐릭터를 스스로 판단해 목록에서 제거한다.
<리스트 13> 디버깅을 위한 캐릭터 위치 표시 객체 namespace MyDebug { static class DisplayCharInfo { private static List<WeakReference> m_list = new List<WeakReference>(); private static Queue<WeakReference> m_removeQueue = new Queue<WeakReference>(); public static void Update() { foreach (WeakReference item in m_list) { MyApp.SampleChar obj = (item.Target != null) ? item.Target as MyApp.SampleChar : null; if (obj == null || obj.isRemoved) { m_removeQueue.Enqueue(item); } else { /* 캐릭터 정보 표시 */ } } while(m_removeQueue.Count > 0) { WeakReference item = m_removeQueue.Dequeue(); m_list.Remove(item); } } public static void Add(MyApp.SampleChar obj) { m_list.Add(new WeakReference(obj)); } } }
C#에서 메모리를 관리하는 데 도움되길 바라며, 지금까지 설명한 내용을 요약하면 다음과 같다.
- string 조합이 많다면, StringBuilder 활용 - Immutable 객체의 값 접근 시 매번 메모리가 생성될 수 있으므로 주의 - 매번 호출되는 메소드 안에서 반복해서 일회성 인스턴스가 생성되지 않도록 주의 - Boxing / unboxing이 가능한 일어나지 않도록 주의 - WeakReference를 사용해서 의도하지 않은 참조 줄이기 - IDisposable 인터페이스를 사용해 사용자가 원하는 시점에 객체 삭제하기
Value type과 Reference type 비교
Value type은 stack 영역에 할당되며 값이 통째로 복사된다.
유니티 3D엔진에서의 메모리 관리 유니티 3D엔진으로 개발하면서 주의할 내용을 알아보자. 유니티 3D엔진은 크게 모노 메모리와 엔진에서 관리하는 메모리로 나뉜다. 둘 다 메모리가 부족하면 내부에 관리하는 heap 영역을 늘려 메모리를 할당한다. 이렇게 한 번 늘어난 heap은 줄어들지 않는 특징을 가진다. 물론 늘어난 heap 안에서 메모리가 재사용되므로, 무턱대고 늘어나진 않는다. 하지만 가비지를 너무 많이 생성시키면 GC.Collect()로 인한 성능저하와 더불어 최대 메모리가 늘어날 수도 있으니 주의해야 한다. 가능한 가비지가 덜 생성되도록 코드를 구현하는 게 좋다. 메모리는 한 번에 잡는 것이 좋고, caching이나 memory pool을 사용하는 것도 도움이 된다.
<리스트 14> Value typepublic static class Sample { public static void TestValueType() { int a = 100; int b = a;
a = 200; Console.WriteLine(" a={0}, b={1}", a, b); } } <리스트 14>를 실행하면 <그림 9>와 같은 결과를 확인할 수 있다. a와 b는 서로 다른 메모리 공간을 가지고 있다.
<그림 9> <리스트 14>의 실행 결과 Reference Type은 heap 영역에 할당되며, C/C++의 포인터나 레퍼런스처럼 new로 생성한 인스턴스를 참조한다. <리스트 15> Reference type public class MyInt { public int Value { get; set; } public MyInt(int val) { this.Value = val; } public static void TestReferenceType() { MyInt a = new MyInt(100); MyInt b = a; a.Value = 200; Console.WriteLine(" a={0}, b={1}", a.Value, b.Value); } } <리스트 15>의 실행 결과로 <그림 10>을 확인할 수 있다. a와 b는 같은 메모리를 참조한다.
소스 코드를 완벽하게 파악하고 있는 Accelerate Solutions 팀은 Unity 엔진을 최대한 활용할 수 있도록 수많은 고객을 지원합니다. 팀은 크리에이터 프로젝트를 심도 있게 분석하여 속도, 안정성, 효율성 등을 향상시키기 위해 최적화할 부분을 파악합니다. Unity에서 가장 경력이 많은 소프트웨어 엔지니어로 이루어진 이 팀과 함께 모바일 게임 최적화에 관한 전문적인 지식을 공유하는 자리를 마련했습니다.
모바일 게임 최적화에 관한 인사이트를 공유하기 시작하면서, 원래 계획한 하나의 블로그 포스팅에 담기에는 너무나 방대한 정보가 있다는 사실을 알게 되었습니다. 따라서 이 방대한 지식을 한 권의 전자책(여기에서 다운로드 가능)과 75가지 이상의 실용적인 팁을 담은 블로그 포스팅 시리즈를 통해 제공하기로 했습니다.
이번 시리즈의 첫 게시물에서는 프로파일링, 메모리, 코드 아키텍처를 통해 게임 성능을 개선하는 방법을 자세히 들여다 봅니다. 이후 몇 주 이내에 두 개의 포스팅이 추가로 업로드될 예정입니다. 첫 번째는 UI 물리에 대해, 두 번째는 오디오와 에셋, 프로젝트 설정, 그래픽스에 대해 다룹니다.
모바일 성능 데이터의 프로파일링과 수집 및 활용 과정은 진정한 모바일 성능 최적화가 시작되는 지점입니다.
개발 초기부터 타겟 기기에서 자주 프로파일링 실행하기
Unity 프로파일러는 사용 시 애플리케이션에 대한 필수 성능 정보를 제공합니다. 출시가 멀지 않은 시점이 아닌 개발 초기에 프로젝트를 프로파일링하세요. 여러 오류나 성능 문제를 발생 즉시 조사하세요. 프로젝트의 '성능 시그니처'를 개발하면서 새로운 문제를 보다 쉽게 발견할 수 있습니다.
에디터에서 프로파일링을 수행하면 다양한 시스템의 상대적인 게임 성능에 관한 정보를 얻을 수 있는 반면, 각 기기를 대상으로 프로파일링하면 보다 정확한 인사이트를 확보할 수 있습니다. 가능하면 타겟 기기에서 개발 빌드를 프로파일링하세요. 지원할 기기 중 최고 사양의 기기와 최저 사양의 기기 모두를 프로파일링하고 모두 최적화해야 합니다.
Unity 프로파일러와 더불어 다음과 같은 iOS및 Android의 네이티브 툴을 사용하면 각 엔진에서 추가로 성능 테스트를 수행할 수 있습니다.
게임 성능을 저하시키는 요인을 추정하거나 가정하지 않도록 합니다. Unity 프로파일러 및 플랫폼별 툴을 사용하여 성능 저하의 정확한 원인을 찾으세요.
물론 여기에서 설명하는 최적화가 모두 애플리케이션에 적용되지는 않습니다. 다른 프로젝트에는 적합했던 최적화라도 현재 프로젝트에는 적용되지 않을 수 있습니다. 실제 병목 지점을 파악하고 실질적으로 최적화가 필요한 부분에 집중하세요.
Unity 프로파일러의 작동 방식 이해하기
Unity 프로파일러는 런타임 시 성능 저하 또는 중단의 원인을 감지하고 특정 프레임 또는 시점에 발생하는 상황을 보다 정확하게 이해하는 데 도움이 될 수 있습니다. CPU 및 메모리 트랙을 기본적으로 활성화하세요. 물리를 많이 사용하는 게임이나 음악에 기반한 게임플레이를 개발 중이라면 필요에 따라 렌더러, 오디오, 물리와 같은 보조 프로파일러 모듈을 모니터링할 수 있습니다.
Unity 프로파일러를 사용하여 애플리케이션의 성능 및 리소스 할당 테스트확장
Development Build및Autoconnect Profiler를 선택하여 기기에 애플리케이션을 빌드하거나 수동으로 연결하여 앱 시작 시간을 단축하세요.
확장
프로파일링을 실행할 타겟 플랫폼을 선택하세요.녹화버튼은 애플리케이션 재생을 몇 초 동안 추적합니다(기본 300 프레임). 더 오래 캡처해야 한다면Unity > Preferences > Analysis > Profiler > Frame Count로 이동하여 이 값을 2000까지 늘릴 수 있습니다. 이렇게 하면 Unity 에디터가 더 많은 CPU 작업을 수행하고 더 많은 메모리를 사용하지만, 상황에 따라 유용할 수 있습니다.
이는 계측 기반 프로파일러로ProfileMarkers에 명시적으로 래핑된 코드 타이밍을 프로파일링합니다(예: MonoBehaviour의 Start나 Update 메서드 또는 특정 API 호출). 또한Deep Profiling설정을 사용하면 Unity는 스크립트 코드에 있는 모든 함수 호출의 시작과 끝을 프로파일링하여 정확히 애플리케이션의 어느 부분에서 성능 저하가 발생하는지 제시합니다.
타임라인 뷰를 사용하여 CPU 바운드 또는 GPU 바운드 여부 판단확장
게임을 프로파일링할 때는 성능 불안정과 평균 프레임의 비용을 모두 살펴보는 것이 좋습니다. 각 프레임에서 발생하는 높은 비용의 작업을 파악하고 최적화하면 타겟 프레임 속도 이하에서 실행되는 애플리케이션에 더 유용할 수 있습니다. 스파이크 현상을 살펴볼 때는 물리, AI, 애니메이션 등의 고비용 작업을 먼저 검토한 다음 가비지 컬렉션을 살펴봅니다.
창을 클릭하여 특정 프레임을 분석하세요. 그리고 나서타임라인또는계층 구조뷰를 사용합니다.
타임라인: 특정 프레임의 타이밍을 시각적으로 요약하여 제시합니다. 이를 통해 각 활동이 다양한 스레드 전반에서 서로 어떤 관계를 맺고 있는지 시각화할 수 있습니다. 이 옵션을 사용하여 CPU 바운드 또는 GPU 바운드 여부를 판단하세요.
계층 구조: 그룹화된 ProfileMarkers의 계층 구조를 제시합니다. 이를 통해 밀리초 단위(Time ms및Self ms)의 시간 비용을 기준으로 샘플을 정렬할 수 있고, 함수에 대한호출수와 프레임의 관리되는 힙 메모리(GC Alloc)의 양도 알 수 있습니다.
계층 구조 뷰를 통해 시간 비용에 따라 ProfileMarkers 정렬확장
여기에서 Unity 프로파일러의 전체 개요를 읽어보세요. 프로파일링이 생소하다면Unity 프로파일링 소개영상을 시청해 보세요.
프로젝트에서 최적화를 수행하기 전에 먼저 Profiler .data파일을 저장하세요. 변경 사항을 구현하고 수정이전및이후에 저장된 .data 파일을 비교해 보세요. 반복적인 프로파일링, 최적화, 비교를 통해 성능을 향상할 수 있습니다. 이후에도 이 과정을 반복합니다.
Profile Analyzer 사용하기
이 툴을 사용하면 Profiler 데이터의 여러 프레임을 집계한 다음 관심 있는 프레임을 찾을 수 있습니다. 프로젝트를 변경하면 Profiler에 어떤 영향을 주는지 확인하고 싶으신가요?Compare뷰를 사용하면 두 데이터 세트를 로드해서 차이점을 확인할 수 있으므로 변경 사항을 테스트하고 결과를 개선할 수 있습니다.Profile Analyzer는 Unity의 패키지 관리자를 통해 사용할 수 있습니다.
기존 Profiler를 보완하는 Profile Analyzer로 프레임과 마커 데이터 확인확장
프레임당 정해진 시간 예산으로 작업하기
각 프레임은 목표로 하는 초당 프레임 수(fps)를 기반으로 시간 예산을 가집니다. 30fps로 실행되는 애플리케이션의 경우 프레임당 약 33.33ms(1000ms/30fps)를 허용하는 것이 이상적입니다. 마찬가지로 60fps의 경우 목표 시간 예산은 프레임당 16.66ms입니다.
컷씬이나 로딩 시퀀스 같은 짧은 시간 동안 기기는 예산을 초과할 수도 있지만, 긴 시간 동안 초과할 수는 없습니다.
기기 온도 고려하기
모바일 기기에서는 기기가 과열되거나 OS가 CPU 및 GPU를 서멀 스로틀링할 수 있으므로 최대 시간을 지속적으로 사용하지 않는 것이 좋습니다. 프레임 간 쿨다운을 위해 가용 시간의 약 65%만 사용할 것을 권장합니다. 일반적인 프레임 예산은 30fps에서는 프레임당 약 22ms, 60fps에서는 11ms입니다.
대부분의 모바일 기기에는 데스크톱과 달리 냉각 장치가 없습니다. 물리적인 열기가 성능에 직접적인 영향을 미칠 수 있습니다.
기기가 가열되면 장기적인 문제로 이어지지 않는다 해도 Profiler에서 성능 저하를 인식하고 이를 보고할 수 있습니다. 프로파일링 과열을 방지하기 위해 짧은 간격으로 프로파일링하세요. 이렇게 하면 기기가 냉각되고 실제 사용 조건에서 시뮬레이션됩니다. 일반적으로 다시 프로파일링하기 전에 10~15분 간 기기를 냉각하는 것이 바람직합니다.
GPU 바운드 또는 CPU 바운드 여부 판단하기
Profiler를 통해 CPU가 프레임 예산을 할당된 양보다 오래 사용 중이거나 GPU에 문제가 있는지 파악할 수 있습니다. 다음과 같이 Gfx가 접두어인 마커를 방출하는 방식을 사용합니다.
Gfx.WaitForCommands마커가 표시되면 렌더 스레드가 준비되었으나 메인 스레드에 병목 현상이 일어날 가능성을 의미합니다.
힙의 불필요한 할당은 GC(가비지 컬렉션) 스파이크를 유발할 수 있으므로 유의해야 합니다.
문자열:C#에서 문자열은 값 유형이 아닌 참조 유형입니다. 불필요한 문자열의 생성 또는 조작을 줄이세요. JSON, XML과 같은 문자열 기반 데이터 파일은 구문 분석하지 않는 것이 좋습니다. 데이터를 ScriptableObjects 또는 MessagePack이나 Protobuf 같은 형식으로 대신 저장하세요. 런타임에서 문자열을 빌드해야 하는 경우StringBuilder클래스를 사용합니다.
Unity 함수 호출:일부 함수는 힙 할당을 생성합니다. 참조를 루프 도중에 할당하지 말고 배열에 저장하세요. 아울러 가비지 생성을 방지하는 함수들을 활용하세요. 예를 들어 문자열을GameObject.tag와 직접 비교하는 대신GameObject.CompareTag를 사용하는 방법이 있습니다(새로운 문자열 반환은 가비지를 생성합니다).
박싱:참조 유형 변수 대신 값 유형 변수를 전달하지 않도록 합니다. 이렇게 하면 임시 오브젝트가 생성되며 동반되는 잠재적 가비지가 값 유형을 암묵적으로 타입 오브젝트로 변환합니다(예:int i = 123, object o = i). 대신 전달하려는 값 유형에 구체적인 오버라이드를 제공해 보세요. 이러한 오버라이드에는 제네릭이 사용될 수도 있습니다.
점진적 가비지 컬렉션을 사용하면 프로그램 실행 중에 한 번 길게 중단되는 것이 아니라 훨씬 짧은 중단이 여러 프레임에 걸쳐 여러 번 나타납니다. 가비지 컬렉션이 성능에 영향을 미친다면 이 옵션을 사용하여 GC 스파이크를 줄일 수 있는지 확인해 보세요. Profile Analyzer를 사용하여 이 방식이 애플리케이션에 어떤 이점으로 작용하는지 확인하세요.
점진적 가비지 컬렉터를 사용하여 GC 스파이크 줄이기확장
프로그래밍 및 코드 아키텍처
UnityPlayerLoop에는 게임 엔진의 코어와 상호작용하기 위한 함수가 포함되어 있습니다. 이 구조는 초기화와 프레임별 업데이트를 처리하는 다양한 시스템을 포함합니다. 모든 스크립트가 이 PlayerLoop를 활용하여 게임플레이를 생성하게 됩니다.
프로파일링 시에는 PlayerLoop 아래에 프로젝트의 사용자 코드가 표시됩니다(EditorLoop 아래에는 에디터 컴포넌트).
전체 엔진 실행의 맥락에서 커스텀 스크립트, 설정, 그래픽스를 제시하는 Profiler확장
코드를 반드시 모든 프레임에 실행해야 하는지 확인하세요. 불필요한 로직을Update,LateUpdate,FixedUpdate에서 제외하세요. 이러한 이벤트 함수에는 프레임마다 업데이트해야 하는 코드를 편리하게 배치할 수 있으며, 같은 빈도로 업데이트할 필요가 없는 로직은 추출됩니다. 가능하다면 상황이 바뀌는 경우에만 로직을 실행하세요.
반드시Update를 사용해야 한다면n개 프레임마다 코드를 실행하는 방안을 검토해 보세요. 이는 여러 프레임에 대규모 워크로드를 분산하는 일반적인 기법인 타임 슬라이싱의 적용 방식 중 하나이기도 합니다. 이 예에서는 3개 프레임마다 한 번씩ExampleExpensiveFunction을 실행합니다.
Start/Awake에서 대규모 로직 사용 방지
첫 번째 씬을 로드하면 다음과 같은 함수가 각 오브젝트에 대해 호출됩니다.
Awake
OnEnable
Start
애플리케이션이 첫 번째 프레임을 렌더링하기 전까지는 이러한 함수에서 고비용 로직의 사용을 피하세요. 그렇게 하지 않으면 필요 이상으로 로딩 시간이 길어질 수 있습니다.
GameObject.Find,GameObject.GetComponent,Camera.main(2020.2 이전 버전)은 비용이 많이 들 수 있으므로Update메서드에서는 호출하지 않는 것이 가장 좋습니다. 대신Start에서 호출하고 결과를 캐시하세요.
다음 예는 반복적인GetComponent호출의 비효율성을 보여줍니다.
함수의 결과가 캐시되므로GetComponent는 한 번만 호출하세요.Update에서GetComponent를 추가로 호출하지 않아도 캐시된 결과를 재사용할 수 있습니다.
오브젝트 풀 사용하기
Instantiate 함수및Destroy 함수는 가비지와 가비지 컬렉션 스파이크를 야기하며, 일반적으로 속도가 느린 프로세스입니다. 게임 오브젝트를 계속 인스턴트화하고 삭제하기보다는(예: 총알 발사) 재사용 및 재활용할 수 있는 사전에 할당된 오브젝트풀을 사용해 보세요.
재사용 가능한 PlayerLaser 인스턴스 20개를 생성하는 ObjectPool의 예확장
CPU 스파이크가 적을 때 게임 내 한 지점(예: 메뉴 화면)에서 재사용 가능한 인스턴스를 생성합니다. 컬렉션으로 이러한 오브젝트 '풀'을 추적합니다. 게임플레이 중에는 필요할 때 다음으로 사용 가능한 인스턴스를 사용 설정하고, 오브젝트를 삭제하는 대신 사용 중지한 다음 풀로 돌려 보내면 됩니다.
비활성 및 발사 준비 상태의 PlayerLaser 오브젝트 풀확장
이렇게 하면 프로젝트에서 관리되는 할당의 수가 줄어들기 때문에 가비지 컬렉션 문제를 방지할 수 있습니다.
stackless 코루틴은 stackful 코루틴처럼 사기군같은 컨텍스트 스위칭 없이 자체 상태머신을 이용한 멀쩡해보이는 방법으로 구현한다.
Stackful Coroutines vs Stackless Coroutines
Stackful Coroutines
먼저 스택풀 코루틴(Stackful Coroutine)은 함수 호출시에 사용되는 분리된 스택을 가지고 있다. 코루틴이 어떻게 동작하는지 정확하게 이해하기 위해서 로우레벨 관점에서 간단히 함수 프레임과 함수 호출에 대해서 살펴볼 것이다. 하지만 먼저 스택풀 코루틴의 특성에 대해서 알아보자.
스택풀 코루틴은 자신만의 스택을 가지고 있다.
스택풀 코루틴의 수명은 호출한 코드로부터 독립적이다.
스택이란 것은 메모리 관점에서 볼 때 연속된 메모리 블럭이며, 스택은 지역변수 및 함수 인자등을 저장하기 위해 필요하다.하지만 더 중요한 것은 각 함수 호출 후에 추가적인 정보가 스택에 배치되어 호출된 함수가 피호출자(callee)에게 반환하고 프로세서 레지스터를 복원하는 방법을 알려주게 된다.
몇가지 레지스터들은 특정 목적을 가지고 있으며 함수 호출시 스택에 저장된다. 이러한 레지스터는 다음과 같다.
SP – Stack Pointer
LR – Link Register
PC – Program Counter
스택포인터 (Stack Pointer)는 현재 함수 호출에 속하는 스택의 시작주소를 가지고 있는 레지스터다. 이 값 덕분에 스택에 저장된 인수와 지역 변수를 쉽게 참조할 수 있다.
링크 레지스터(Link Register)는함수 호출에서 매우 중요하다. 현재 함수 실행이 끝난 후 실행할 코드가있는 반환 주소(수신자 주소)를 저장한다. 함수가 호출되면 PC가 LR에 저장되고, 함수가 반환되면 LR을 사용하여 PC가 복원된다.
프로그램 카운터(Program counter)는 현재 실행중인 명령어의 주소다.
함수가 호출 될 때마다 링크 레지스터가 저장되므로 함수가 완료된 후 반환 할 위치를 알 수 있다.
함수의 호출과 반환에서 PC 및 LR 레지스터의 동작
스택풀 코루틴이 실행되면 호출 된 함수는 이전에 할당 된 스택을 사용하여 인수와 지역 변수를 저장한다. 함수 호출은 스택풀 코루틴에 대한 모든 정보를 스택에 저장하기 때문에 코루틴에서 호출되는 모든 함수들의 실행을 중단 할 수 있다.
Stackless Coroutines
스택리스 코루틴(Stackless Coroutine)은 스택풀 코루틴과 비교했을 때 약간의 차이점이 있다. 그러나 주요 특징은 여전히 시작(started)되고, 스스로 중단(suspended) 된 후에 다시 재개(resumed)할 수 있다는 점이다.
스택리스 코루틴의 특징은 다음과 같다.
코루틴은 호출자(caller)와 강하게 연결되어 있다. 코루틴에 대한 호출은 실행을 코루틴으로 전송하고 코루틴에서 양보(yield)하는 것은 호출자에게 돌아온다.
스택풀 코루틴은 스택의 수명만큼 유지 되지만, 스택리스 코루틴은 객체의 수명만큼 유지된다.
그러나 스택리스 코루틴의 경우 전체 스택을 할당할 필요가 없기 때문에 훨씬적은 메모리를 요구하지만, 그 때문에 몇가지 제약사항이 생긴다.
스택이 없다면 메모리에서 스택을 할당하지 못하면 어떻게 동작하는거지? 스택에 저장될 모든 데이터는 어디로 가는걸까?라는 의문이 생길 수 있다. 정답은 바로호출자의 스택을 사용하는 것이다.
스택리스 코루틴은 최상위 레벨(top-level) 함수에서만 스스로를 중단(suspend) 할수 있다.일반적인 함수의 경우, 데이터가 호출 수신자 스택에 할당되기 때문에 코루틴에서 호출 된 모든 함수는 코루틴이 중단되기 전에 완료되어야 한다. 코루틴이 상태를 유지하는 데 필요한 모든 데이터는 Heap 메모리에 동적으로 할당된다. 이것은 일반적으로 미리 할당 된 전체 스택보다 크기가 훨씬 작은 두 개의 지역 변수와 인수를 사용한다.
코루틴 중단 및 재개에서 기억해야 할 데이터가 훨씬 적지만 코루틴은 최상위 레벨 함수에서만 일시 중단하고 자신을 반환 할 수 있다. 모든 함수 및 코루틴 호출은 동일한 방식으로 발생하지만 코루틴의 경우 일부 추가 데이터를 호출에서 보존해야 중단 지점으로 이동하고 지역 변수의 상태를 복원하는 방법을 알 수 있다. 게다가 함수 프레임과 코루틴 프레임 사이에는 차이가 없다.
코루틴은 다른 코루틴을 호출 할 수도 있다. 스택리스 코루틴의 경우, 하나에 대한 각 호출이 새로운 코루틴 데이터를 위한 새로운 공간을 할당하게 된다. (코루틴에 대한 여러 호출은 다양한 동적 메모리 할당을 유발할 수 있음).
코루틴을 사용하기 위해서는 코루틴을 지원하는 언어가 필요한 이유는 컴파일러가 코루틴의 상태를 설명하는 변수를 결정하고 중단 지점으로의 점프를 위한보일러 플레이트코드를 생성해야 하기 때문이다.
금일은 요즘 유니티를 사용하면서 문제점에 부디친 URP 때문에 유니티 쉐이더를 다시 들여다 보려고 합니다.(역시 기본을 아는 것이 가장 중요한 것 같습니다.)
기존의 유니티 쉐이더는 3가지 방식으로 작성될 수 있었습니다.
ShaderLab, Surface Shader 그리고 전통적인 방식인 vertex, fragment 쉐이더 방식으로 작성되어오고 있습니다.
작성법도 편하고 빛계산도 비교적 수월한 Surface 쉐이더를 많이 사용하고 있는 것 같은데요 최근의 URP 파이프라인으로 넘어오면서는 전통적인 HLSL (vertex, fragment )방식으로 쉐이더가 작성되고 있습니다. (물론 쉐이더그래프를 지원하며 더 직관적인것 같긴 하지만…. 그 원리를 모른다면 매번 문제에 직면 하는 것 같습니다.) 그래서 이전의 vertex, fragment 쉐이더방식을 처음부터 차근차근 되짚어 보려고 합니다.
그 중에서 모든 쉐이더 공부의 첫 시작은 빨갱이 쉐이더인 것 같아서 버텍스쉐이더와 프래그먼트쉐이더를 사용해서 빨강 쉐이더를 만들어 보려고 합니다. (URP이전의 쉐이더 입니다.)
정말 간결하고 아름답습니다. 왜 아름다운지 그 구조부터 들여다 보겠습니다. 유니티 쉐이더의 구조는
좌측 그림처럼 Shader 안에 Properties(속성) 안에 SubShader (여러개가 존재할수 있습니다.) 그리고 마지막에 fallback 이라는 구조로 구성되어 있습니다.(참조https://jinhomang.tistory.com/43)
그런데 왜 서브쉐이더라는 것이 존재하며 여러개 존재할 수 있을까요? 바로 하드웨어의 성능 때문이라고 합니다. 하드웨어 마다 다양한 성능을 가지고 있기때문에 거기에 적합한 쉐이더들을 각각의 서브쉐이더에 작성을 해둔다고 합니다.
유니티가 렌더링을 할때 서브쉐이더를 들여다 보면서 가장 위의 서브쉐이더(가장 최상품질)부터 찾아본후 그에 적합한 서브쉐이더를 적용하게 되며 마지막까지 적합한 스펙이 없으때 fallBack 이라는 부분에 적혀 있는 쉐이더를 사용하게 된다고 합니다.
버텍스 인풋구조체 v의 값을 인자로 가집니다. o의 오브젝트 공간의 위치를 v의 버텍스위치에서 클립공간으로 바꾸어줍니다.
오브젝트 공간의 한 점을 동질적인 좌표에 있는 카메라의 클립 공간으로 변환합니다.mul(UNITY_MATRIX_MVP, float4(pos, 1.0))과 같으며, 이를 대신하여 사용해야 합니다. 로컬 공간(오브젝트 공간) 의 버텍스를 클립공간으로 변환합니다. 기본적으로 공간변환은 로컬 — 월드 — 뷰 — 프러스텀인데, 이걸 모델-뷰-프러스텀이라고 부르면서 MVP라고 부릅니다. 그런데 프러스텀은 니어와 파로 잘라지죠? 그러면 절두체 모양이 됩니다. 일종의 찌그러진 큐브죠. 이 큐브 영역을 0~ 1사이로 변환한 것이 클립 포지션이라 합니다. 즉 최종 영역까지 한 번에 변환하는 함수입니다.
간단한 기능인데 : on, off 기능으로 에디터에서 default 값을 어떻게 선택하느냐에 따라 on, off 중 하나만 out 으로 내보낸다
여기서 맨 밑의 Keyword는 약간 다른 종류인데, 이 Keyword 하위에 있는 요소들(Boolean, Enum)은 셰이더의 정적 분기를 위한 노드라고 한다.
경우에 따라서 출력되는 게 다른 상황에서, 셰이더의 Variant를 만들어내기 위해서 나온 기능이라고 하는데,
최적화에는 별로 좋지 않다고 하고..
나뉘는 경우의 수마다 다른 셰이더로 쳐 진다고 한다.
직접 사용해보자. 우선은 Keyword 아래의 Boolean부터 꺼낸다.
이 Boolean은 인스펙터 창에서 체크박스 형태로 나타나게 되는데,
그 체크박스의 체크 여부에 따라서 최종 결과물이 달라지는 형태이다.
즉, 체크박스에 체크가 되어있다면 위에서 On의 결과물이 Out으로 나가게 될 것이고,
반대로 체크가 되어있지 않다면 Off의 결과물이 Out으로 나가게 될 것이다.
간단하게 예제를 만들어보자.
아까 위에서 만들었던 곱하기의 결과물이 On으로, 그리고 곱하기를 하지 않은 Sample Texture 2D 상태가 Off로 들어오도록 노드를 연결해준다.
그런 다음 Boolean을 Base Color에 연결해준다.
그러면 이런 모습이 되고,
직접 씬에서 본다면
이렇게 Boolean의 On/Off 여부에 따라 결과물이 달라지는 것을 볼 수 있다.
Boolean Keywords
Boolean Keywords are either on or off. This results in two shader variants. Shader Graph uses the value in theReferencename field for the on state, and automatically defines the off state as an underscore (_).
To expose a Boolean Keyword in the Material Inspector, itsReferencename must include the_ONsuffix. For example,BOOLEAN_A506A032_ON.
- 사운드를 구현하려면 소리를 내는 파트(Audio Source)와 소리를 듣는 파트(Audio Listener) 그리고 음원 파일(Audio Clip)이 필요하다
- 유니티로 import된 음원 파일을 오디오 클립이라고 부른다
- 오디오는 일반적으로 Singleton 방식을 이용해서 하나의 게임오브젝트가 사운드 출력을 총괄하도록 관리한다
AudioListener 컴포넌트
- 이 컴포넌트는 Main Camera에 기본 컴포넌트로 미리 추가되어 있다. 이 컴포넌트에 대해서는 별도로 속성을 이용해 제어할 수 없다
- 이 컴포넌트는 모든 게임오브젝트에 붙일 수 있지만 일반적으로 게임세계를 바라보는 유저의 눈에 해당하는 카메라 게임오브젝트에 포함되는 게 좋으며 조작하고 있는 캐릭터에 붙이기도 한다
- Audio Listener는 씬 내에 하나만 존재해야 한다(카메라가 여러 대인 경우 Audio Listener가 모든 카메라에서 활성화되어 있지는 않은지 꼭 확인하자). 반대로 Audio Source는 씬 내에 여러 개가 있을 수 있다
Audio Source 컴포넌트 1
Audio Source 컴포넌트 2
Audio Source 컴포넌트
- 씬 내에서 음원을 재생하는 컴포넌트
AudioClip: 재생할 음원 파일
Output: 재생되는 음원의 출력을 특정 Audio Listener 또는 Audio Mixer로 설정(?)
Mute: 음원이 재생되고 있는 상태로 음소거시킨다
Bypass Effects: 모든 Filter effects를 즉시 켜거나 끈다
Bypass Listener Effects: 모든 Listener effects를 즉시 켜거나 끈다
Bypass Reverb Zones: 모든 Reverb Zones를 즉시 켜거나 끈다
Play On Awake: 컴포넌트가 활성화 상태일 때 씬 실행과 동시에 음원을자동재생한다. 체크해제할 경우 스크립트에서 Play 함수로 재생할 수 있다
Loop: 반복재생 여부 설정
Priority: 씬 내의 여러 음원들 중에서 재생 우선순위 설정. default는 128이며 0이 최우선 순위, 256이 마지막 순위이다
Volume: Audio Listener로부터 1유닛(=1미터) 만큼 떨어지 거리에 있을 때를 기준으로 볼륨 설정
Pitch
Stereo Pan
Spatial Blend: 3D엔진이 Audio Source에 영향을 주는 정도를 설정
Reverb Zone Mix
3D Sound Settings: Spatial Blend에 값에 따라 비례적으로 영향을 받는다
Doppler Level: doppler effect의 적용 정도를 설정
Spread: 3D 스테레오나 멀티채널 사운드일 경우 스피커 공간에서의 spread angle을 조절
Volume Rolloff: Audio Source로부터 거리가 멀어질수록 볼륨이 감소하는 세 가지 Preset 옵션을 제공. 하단의 Curved Editor에서 감소 효과를 세부적으로 조절 가능
Min Distance: 볼륨을 100%로 들을 수 있는 범위. 3D공간에서 소리를 '더 크게' 만드려면 값을 높인다. 이 범위를 벗어나면 소리가 점점 줄어든다
Max Distance: 볼륨을 50% 이상으로 들을 수 있는 최대 범위. 즉, Max Distance 를 벗어나면 볼륨이 더 이상 감소하지 않고 50%로 들린다
Audio Clip의 인스펙터 뷰
Audio Clip
Force To Mono: 멀티채널(스테레오 포함) 음원을 싱글채널(모노)로 변환한다(down-mixing). 변환 후에는 기존 음원보다 소리가 작아지므로 데이터를 peak-
normalize 한다
Normalize: 변환 과정에서 normalize 여부를 설정
Load In Background: 체크하면 오디오 클립의 로드를 메인 쓰레드가 아닌 별개의 쓰레드에서 delayed time에 진행하므로 메인 쓰레드를 block하지 않는다. 유니티는 씬이 플레이되기 전에 씬 내에서 사용할 모든 오디오 클립을 로드해놓도록 기본설정되어 있으므로 이 속성은 default로 체크해제 되어 있다. Audio Clip 컴포넌트가 Background에서 음원을 로드하는 중이면 음원 로드를 완료할 때까지 컴포넌트는 작동이 지연된다
Ambisonic: 음원파일에 Ambisonic-encoded audio가 포함된 경우 체크. Listener의 방향orientation에 따라 회전하는 soundfield를 지원하는 포맷으로 음원을 저장
한다. XR이나 360도 영상에서 유용하게 사용된다
Load Type: 유니티가 실시간으로 음원을 불러올 때 사용할 방식을 설정
Decompress On Load: 오디오 파일을 압축되지 않은 상태로 메모리에 올리는 방식. 용량이 큰 오디오 파일은 성능상 오버헤드를 발생시키므로 용량이 작은 오디오 파일에
적합하다. 메모리는 많이 사용하지만, CPU 자원은 덜 소모한다
Compressed In Memory: 오디오 파일을 압축된 상태로 메모리에 올리는 방식. 실행할 때 압축을 해제하기 때문에 약간의 성능상 오버헤드를 발생시킨다. 큰 사이즈의
오디오 파일에 적합하다. 품질을 떨어뜨리면 파일크기를 줄일 수 있으므로 음질손상이 없는 범위 내에서 적절하게 Quality 값 설정
Streaming: Persistent Memory(HDD, Flash Driver)에 저장된 오디오 파일을 스트리밍 방식으로 재생한다. 따라서 오디오 파일을 저장하기 위한 메모리가 필요 없다
Preload Audio Data: 씬이 로드될 때 씬에서 사용할 모든 오디오 클립도 미리 로드하도록 설정. default로 체크되어 있다. 체크해제할 경우 AudioSource.Play/PlayOneShot 함수가 처음 호출되는 시점에 음원이 로드된다. 또는 AudioSource.LoadAudioData 함수로 원하는 시점에 로드하고 AudioSource.UnloadAudioData 함수로 언로드할 수 있다. 오디오 클립을 처음 재생하는 시점에 음원이 로드될 경우 렉이 발생할 수 있다는 점을 유의하자
Compression Format: 압축 포맷은 오디오 파일의 용도에 따라 다르게 설정해야 한다. 유니티에서 지원하는 오디오 압축 포맷의 종류는 다음과 같다
PCM: 음질이 좋은 대신 파일의 크기가 비교적 크기 때문에 효과음처럼 재생시간이 짧은 오디오 파일에 적합하다(비압축 포맷)
ADPCM: 압축률이 PCM 대비 3.5배이기에 파일의 크기가 작아서 메모리는 덜 쓸 수 있는 반면 CPU 자원은 좀 더 사용한다. 노이즈가 발생하기에 노이즈가 약간 있더라도
크게 상관 없는 총 소리나 발걸음 소리 같은 음원에 적합하다
Vorbis / MP3: 중간길이 정도의 효과음 또는 배경음악에 적합하다. PCM에 비해 음질은 떨어지지만 압축률(Quality 설정)을 조절할 수 있다. 압축률이 100%이면 PCM
과 동일해진다. 보통 70% 정도로 설정
*Vorbis의 Quality 값을 낮추게 되면 압축이 일어나므로 값을 다시 올리더라도 원래 상태로 돌아오지 않는다(다시 로드해야함)
HEVAG: PS Vista 고유의 압축 포맷으로 ADPCM과 유사하다
Quality: 압축되는 오디오 클립에 대한 압축률 설정. Vorbis 포맷에만 적용된다
Sample Rate Setting
Preserve Sample Rate: default 설정. 기존 음원파일의 sample rate를 유지
Optimize Sample Rate: 음원파일을 분석해서 가장 높은 주파수에 맞게 sample rate를 최적화
Override Sample Rate: sample rate를 수동으로 override 한다
오디오 설정 팁
- import한 오디오 클립은 2개의 채널로 이뤄져 있다(스테레오 방식. 각 채널은 좌,우 스피커로 구분해서 소리를 따로 보낸다). 그러나 음향 효과를 극대화한 게임이 아니라면 모노(소리의 방향구분 X)로 변환하는 걸 권장한다. 특히 모바일 게임의 경우 스테레오 사운드는 용량 부족과 성능 저하의 원인이 될 수 있다
*유니티는 스테레오, 모노 방식 외에도 최대 8개까지의 멀티채널을 지원한다
- raw 파일 음원은 용량이 크다. wav 파일은 용량이 크지만 바로 출력할 수 있기에 실행속도가 빠르다. mp3 파일은 용량이 작지만 압축 해제한 후 출력되기에 실행속도가 상대적으로 느리다(딜레이 발생) *유니티는 aif, wav, mp3, ogg 포맷의 음원을 지원한다
1. wav 파일: 즉시 출력돼야 하는 효과음(칼이나 총알의 충돌 등)에 사용
2. mp3 파일: 약간의 딜레이가 허용되는 인물의 대사나 배경음악에 사용
- 배경음악이나 환경음은 대부분 용량이 크기에 실행시 미리 압축을 풀어서 메모리에 보관할 경우 가용 메모리가 줄어든다. 따라서 용량이 큰 음원은 다음과 같은 import 설정을 권장한다
1. Streaming+Vorbis
2. Compressed in Memory+Vorbis+Quality(70%)
- 오디오의 발생 빈도나 오디오 파일의 크기에 따라서는 다음과 같은 import 설정을 권장한다
1. 자주 발생하며 파일크기가 작은 경우: Decompress On Load+PCM(or ADPCM)
2. 자주 발생하며 파일크기가 중간인 경우: Compressed in Memory+ADPCM
3. 가끔 발생하며 파일크기가 작은 경우: Compressed in Memory+ADPCM
4. 가끔 발생하며 파일크기가 중간인 경우: Compressed in Memory+Vorbis
- 음원은 import될 때 설정된 플랫폼(build target)에 적합한 타입(Load Type)으로 변환(re-encode)된다(default는 Decompress On Load+Vorbis). Load Type은 원본을 크게 손상시키지 않는 PCM이나 Vorbis/MP3 타입이 주로 쓰인다. PCM은 비압축포맷이기에 메모리에서 바로 읽어들일 수 있으므로 CPU의 부담이 덜하다. Vorbis/MP3 포맷은 불필요한 소리 정보를 잘라내는 방식으로 압축한다. ADPCM은 메모리 성능과 CPU 성능 사이에서 타협한 방식인데, PCM에 비해 CPU를 살짝 더 사용하지만 꽤 많이 압축되기에 메모리 부담을 줄인다.
Scripting API
AudioSource
publicAudioClipclip:재생할 default 오디오 클립. 다음에 재생할 오디오 클립을 clip에 담는 방법은 아래 코드 참고
This static class provides several easy game-oriented ways of generatingpseudorandom numbers.
The generator is anXorshift 128algorithm, based on the paperXorshift RNGsby George Marsaglia. It is statically initialized with a high-entropyseedfrom the operating system, and stored in native memory where it will survive domain reloads. This means that the generator is seeded exactly once on process start, and after that is left entirely under script control.
For more details on the seed, including how to manage it yourself, seeInitState. To learn how to save and restore the state ofRandom, seestate.
Versus System.Random
This class has the same name as the .NET Framework classSystem.Randomand serves a similar purpose, but differs in some key ways:
Static vs instanced UnityEngine.Randomis a static class, and so its state is globally shared. Getting random numbers is easy, because there is no need tonewan instance and manage its storage. However, static state is problematic when working with threads or jobs (the generator will error if used outside the main thread), or if multiple independent random number generators are required. In those cases, managing instances ofSystem.Randomwould be a better option.
Float upper bounds are inclusive All properties and methods inUnityEngine.Randomthat work with or derive work from float-based randomness (for examplevalueorColorHSV) will use aninclusiveupper bound. This means that it is possible, though as rare as any other given value, for the max to be randomly returned. In contrast,System.Random.NextDouble()has anexclusivemaximum, and will never return the maximum value, but only a number slightly below it.
Performance Methods inUnityEngine.Randomhave been measured to be between 20% and 40% faster than their equivalents inSystem.Random.
Name resolution ambiguity Because the classes share the nameRandom, it can be easy to get aCS0104"ambiguous reference" compiler error if theSystemandUnityEnginenamespaces are both brought in viausing. To disambiguate, either use an aliasusing Random = UnityEngine.Random;, fully-qualify the typename e.g.UnityEngine.Random.InitState(123);, or eliminate theusing Systemand fully-qualify or alias types from that namespace instead.
🚧2022년 7월 수정 사항 코루틴은 싱글 스레드로 구현되기 때문에 비동기 방식이 아닙니다. 멀티 스레딩 모델의 비동기 방식은 함수 A의 완료와 함수 B의 실행 시점이 일치하지 않습니다. 왜냐하면 병렬로 처리되기 때문인데 그에 반해 코루틴은 순차적으로 처리합니다. 코루틴의 작업 처리가 늦을 수록 다음 작업에 딜레이가 생기는 이유입니다. 그러므로 코루틴은 함수 실행과 완료 시점이 일치하지 않더라도 동기 방식입니다. MSDN에서는 '순차적으로 작업을 분할해서 처리'하는 것도 비동기로 분류하고 있습니다. 이런 점에서 코루틴도 비동기 방식이라 할 수 있습니다. 하지만MSDN이 설명하는 것은 엄밀히 따지면 싱글스레드-비동기가 아닌 멀티스레드-비동기입니다. 사용자 코드만 놓고 봐서는 싱글스레드이지만 실제로 시스템 스레드풀에 위임해버리기 때문입니다. 즉, 사용자는 싱글스레드를 쥐어주고 시스템에선 작업자 스레드를 운용하는 것입니다. 이런 방식을 싱글스레드-비동기라고 합니다. 반면 코루틴은 사용자는 싱글스레드, 시스템은 싱글스레드 또는 멀티스레드 일 수 있습니다. 그래서 싱글 스레드를yield return하는 경우엔 동기방식이 될테고 멀티 스레드를 운용하는 API를yield return하는 경우 비동기방식이라 할 수 있습니다. 하지만 대부분 싱글 스레드로 운영될 것입니다. 함수를 아주 잘게 테스크로 쪼개어서 여러 프레임에 걸쳐 분산 처리 하는 것입니다.
📖레퍼런스 코루틴은 LateUpdate 주기에 실행된다. In-depth analysis of the Unity Coroutine principle 그 근거는 위 아티클의 테스트 코드를 통해 얻을 수 있다. e.g. 게임오브젝트의 enabled가 false로 바뀌면 실행 중인 코루틴이 모두 중단 됨. e.g. yield return 이후 코드는 LateUpdate 호출 시점에 실행됨.
그 동안 궁금했던 동작원리를 알아보자.
IEnumerator
코루틴의 반환 타입은IEnumerator이다. 왜IEnumerator를 반환해야 할까?
먼저IEnumerator에 대해 알아보자.
IEnumerator는 클래스가IEnumerable이 되려면 반드시 구현해야 할 인터페이스다.
// IEnumerator
class Enumerator : IEnumerator
{
int _idx = 0;
int _current;
int[] _pmArr = new int[] { 2, 3, 5, 7 };
public object Current => _current;
// 다음 데이터 있는지 체크 & current 갱신
public bool MoveNext()
{
if (_pmArr.Length - 1 < _idx)
{
return false;
}
_current = pmArr[_idx];
_idx++;
return true;
}
public void Reset()
{
_idx = 0;
}
}
IEnumerator은 배열의Length를 체크하고다음 데이터가 있는지 여부와Current값을 저장한다.
// IEnumerable
class PrimeNums : IEnumerable
{
// IEnumerator
class Enumerator : IEnumerator
{
// 중략
}
public IEnumerator GetEnumerator()
{
return new Enumerator();
}
}
I use GPGS v0.11.01 and was same errorjava.lang.ClassNotFoundException: com.google.android.gms.games.PlayGames. Android Force Resolve failed with an errorFailed to fetch the following dependencies: com.google.games:gpgs-plugin-support:+. How did I fix it:
open the file Assets/GooglePlayGames/com.google.play.games/Editor/GooglePlayGamesPluginDependencies.xml
Coroutine runningCoroutine = null; //코루틴 변수. 1개의 루틴만 돌리기 위해 저장한다.
//만약 이미 돌고 있는 코루틴이 있다면, 정지시킨 후 코루틴을 실행한다.
if(runningCoroutine != null)
{
StopCoroutine(runningCoroutine);
}
runningCoroutine = StartCoroutine(ScaleUp()); //코루틴을 시작하며, 동시에 저장한다.
정리하자면,
1. String으로 호출한 Coroutine은 String으로 멈출 것.
2. 직접 호출한 코루틴은 반환 값을 저장한 다음에 저장된 반환 값을 사용하여 멈출 것.
# 참고로 코루틴을 여러 개 돌리고 싶다면 변수 방식으로 해야 한다. string방식은 이름이 같은 모든 코루틴을 조작한다.
I am tired of clicking through the hierarchy trying to find where X script is so I can look at it's public variables when testing things out. Especially from scene to scene when placement might be different. How do you stay organized? Find in scene wasn't that useful. Guess I might have to look into editing the inspector. I hope it's easy enough to do. Maybe an asset could help?
As somebody already stated, you can right click on a script in the project tab, click on "Find References in Scene". Depending on the size of the project, this may take a bit and Unity may seem unresponsive until the search is completed. An alternative to this is to search the type you need in Hierarchy search tab with by typing t:TheTypeYou'reLookingFor. The results, if any, appear instantly and you can search for Unity classes too (t:camera - will return all camera scripts in the scene).
An useful tool for hierarchy management isQHierarchy.
유니티는 C# 6.0을 지원한다. 2018 버전부터는 7.0도 지원한다고 알고있다. async, await 키워드는 C# 5.0부터 추가된 기능이므로 유니티에서 사용 가능하다.
굉장히 오버헤드가 큰 작업을 해야하는데 실시간성을 확보하고 싶을때가 있다. 맵을 로딩하는 동안 로딩 아이콘이 끊김 없이 뱅글뱅글 돌게 하고 싶은 경우가 좋은 예시. 보통 이럴 땐 일회용 스레드를 생성하고 그 안에서 작업을 수행한다. 이런 작업을 위해서 있는 편의 기능이 Task인데 aysnc와 await는 이 Task와 함께 사용하는 키워드이다.
async, await의 사용법을 모를 때는 유니티에서 일회용 스레드를 만들고 싶으면 아래와 같이 쓰곤 했었다.
using UnityEngine;
using System.Collections;
using System.Threading;
public class AsyncTest_Coroutine : MonoBehaviour
{
void Start()
{
StartCoroutine(Run(10));
}
IEnumerator Run(int count)
{
int result = 0;
bool isDone = false;
// 이렇게 하면 변수 스코프를 공유할 수 있는 장점이 있다.
// 스레드 내에서 result와 isDone 변수에 접근할 수 있다.
(new Thread(() =>
{
for (int i = 0; i < count; ++i)
{
Debug.Log(i);
result += i;
Thread.Sleep(1000);
}
// 작업이 끝났음을 알린다.
isDone = true;
}))
.Start();
// isDone == true 가 될 때까지 대기한다.
while (!isDone) yield return null;
Debug.Log("Result : " + result);
}
}
프로그램을 block 시키지 않고 10초간 비동기 작업이 실행되는 코드이다.
이렇게 스레드 객체를 만들고 람다식으로 함수를 정의하면 나름 보기 좋은 코드가 나온다. 새로운 스레드를 팠지만 그냥 위에서 아래로 순차적으로 읽으면 동작을 이해할 수 있어서 개인적으로 굉장히 좋은 코드라고 생각한다. 하지만 매번 코루틴을 사용해야 하는 단점이 있고 그마저도 유니티이기 때문에 가능한 것이었다.
그런데 async, await 키워드를 이용하면 더 간단하고 유니티 코루틴의 도움 없이도 동일한 동작을 하는 코드를 작성할 수 있다. 다음은 async, await를 사용하는 예제이다.
using System.Threading.Tasks;
using System.Threading;
using UnityEngine;
public class AsyncTest : MonoBehaviour
{
void Start()
{
Debug.Log("Run() invoked in Start()");
Run(10);
Debug.Log("Run() returns");
}
void Update()
{
Debug.Log("Update()");
}
async void Run(int count)
{
// 새로 만들어진 태스크 스레드에서 CountAsync()를 실행한다.
var task = Task.Run(() => CountAsync(10));
// 함수를 리턴하고 태스크가 종료될 때까지 기다린다.
// 따라서 바로 "Run() returns" 로그가 출력된다.
// 태스크가 끝나면 result 에는 CountAsync() 함수의 리턴값이 저장된다.
int result = await task;
// 태스크가 끝나면 await 바로 다음 줄로 돌아와서 나머지가 실행되고 함수가 종료된다.
Debug.Log("Result : " + result);
}
int CountAsync(int count)
{
int result = 0;
for (int i = 0; i < count; ++i)
{
Debug.Log(i);
result += i;
Thread.Sleep(1000);
}
return result;
}
}
await 키워드가 들어있는 메소드는 반드시 async 키워드를 붙여야 한다. Task 객체 앞에 await를 붙이면 해당 태스크가 종료될 때까지 기다리게 된다. await를 만나면 즉시 함수를 리턴하고 해당 스레드는 다음 작업을 계속 수행할 수 있다. 태스크가 종료되면 다시 await가 있던 곳으로 돌아와 나머지가 실행된다. 물론 함수가 호출됐던 동일한 스레드에서 실행된다.
이제 람다식을 사용해서 첫 번째 예제처럼 수정하면 다음과 같다.
using System.Threading.Tasks;
using System.Threading;
using UnityEngine;
public class AsyncTest_Lambda : MonoBehaviour
{
void Start()
{
Debug.Log("Run() invoked in Start()");
Run(10);
Debug.Log("Run() returns");
}
void Update()
{
Debug.Log("Update()");
}
async void Run(int count)
{
int result = 0;
await Task.Run(() =>
{
for (int i = 0; i < count; ++i)
{
Debug.Log(i);
result += i;
Thread.Sleep(1000);
}
});
Debug.Log("Result : " + result);
}
}
해당 클래스는 int, float, string, bool 타입의 변수를 저장하고 로드하는 기능을 제공한다.
PlayerPrefs 은 (엑셀에서 두 개의 컬럼으로 구성되어 있는 것과 같이) key와 Value 의 쌍으로 묶여진 항목들의 리스트의 룩업 리스트 입니다.
그리고 각 플랫폼별 알아서 저장해줍니다
Description
게임 세션(session)사이에 플레이어 preference를 저장하고, preference에 접근합니다.
Mac OS X 에서 PlayerPrefs는~/Library/Preferences폴더에unity.[company name].[product name].plist의 파일이름으로 저장되며,
On Mac OS X PlayerPrefs are stored in ~/Library/Preferences folder, in a file named unity.[company name].[product name].plist, where company and product names are the names set up in Project Settings. Windows 독립 플레이어에서 PlayerPrefs는 @@HKCU Software [company name] [product name]@@ 키 아래의 레지스트리에 저장되며 standalone players.
웹 플레이어에서 PlayerPrefs는 Mac OS X에서~/Library/Preferences/Unity/WebPlayerPrefs아래에 HKCU\Software\[company name]\[product name] key, where company and product names are 웹 플레이어 URL 당, 한개의 preference 파일이 존재하며, 파일 크기는 1메가 바이트로 제한됩니다. 파일 크기가 이 제한 값을 초과하는 경우,
On Linux, PlayerPrefs can be found in ~/.config/unity3d/[CompanyName]/[ProductName] again using the company and product names specified in the Project Settings.
On Windows Store Apps, Player Prefs can be found in %userprofile%\AppData\Local\Packages\[ProductPackageId]>\LocalState\playerprefs.dat
On Windows Phone 8, Player Prefs can be found in application's local folder, See Also: Windows.Directory.localFolder
On Android data is stored (persisted) on the device. The data is saved in SharerPreferences. C#/JavaScript, Android Java and Native code can all access the PlayerPrefs data. The PlayerPrefs data is physically stored in /data/data/pkg-name/shared_prefs/pkg-name.xml.
WebPlayer
On Web players, PlayerPrefs are stored in binary files in the following locations:
Mac OS X:~/Library/Preferences/Unity/WebPlayerPrefs
Windows:%APPDATA%\Unity\WebPlayerPrefs
웹 플레이어에서 PlayerPrefs는 Mac OS X에서~/Library/Preferences/Unity/WebPlayerPrefs아래에
There is one preference file per Web player URL and the file size is limited to 1 megabyte. If this limit is exceeded, SetInt, SetFloat and SetString will not store the value and throw aPlayerPrefsException.
Go to Player Settings -> Select Android -> Other Settings -> For Target API select API 29 (As of now API 30 is recommended at the time of this response)
ScriptableObject는 클래스 인스턴스와는 별도로 대량의 데이터를 저장하는 데 사용할 수 있는 데이터 컨테이너입니다. ScriptableObject의 주요 사용 사례 중 하나는 값의 사본이 생성되는 것을 방지하여 프로젝트의 메모리 사용을 줄이는 것입니다. 이는 연결된 MonoBehaviour 스크립트에 변경되지 않는 데이터를 저장하는프리팹이 있는 프로젝트의 경우 유용합니다.
이러한 프리팹을 인스턴스화할 때마다 해당 데이터의 자체 사본이 생성됩니다. 이러한 방법을 사용하여 중복 데이터를 저장하는 대신 ScriptableObject를 이용하여 데이터를 저장한 후 모든 프리팹의 레퍼런스를 통해 액세스할 수 있습니다. 즉, 메모리에 데이터 사본을 하나만 저장합니다.
MonoBehaviour와 마찬가지로 ScriptableObject는 기본 Unity 오브젝트에서 파생되나, MonoBehaviour와는 달리게임 오브젝트에 ScriptableObject를 연결할 수 없으며 대신 프로젝트의 에셋으로 저장해야 합니다.
에디터 사용 시, ScriptableObject에 데이터를 저장하는 작업은 편집할 때나 런타임에 가능합니다. 이는 ScriptableObject가 에디터 네임스페이스와 에디터 스크립팅을 사용하기 때문입니다. 배포된 빌드에서는 ScriptableObject를 사용하여 데이터를 저장할 수 없으나, 개발 시 설정한 ScriptableObject 에셋의 저장된 데이터를 사용할 수 있습니다.
에디터 툴에서 에셋 형태로 ScriptableObject에 저장한 데이터는 디스크에 작성되므로 세션 간에도 그대로 유지됩니다.
ScriptableObject 사용하기
ScriptableObject의 주요 사용 사례는 다음과 같습니다.
에디터 세션 동안 데이터 저장 및 보관
데이터를 프로젝트의 에셋으로 저장하여 런타임 시 사용
ScriptableObject를 사용하려면 애플리케이션의Assets폴더에 스크립트를 생성하고ScriptableObject클래스에서 상속하도록 해야 합니다.CreateAssetMenu속성을 사용하면 더욱 간편하게 클래스를 이용하여 커스텀 에셋을 생성할 수 있습니다. 다음 예를 참조하십시오.
바로 메뉴에 보이는게 아니고 SpawnManagerScriptableObject
클래슬르 먼저 만들고 ScriptableObject 를 만들고 menuName 을 따라가보면 생성할수 있는 메뉴가 나온다
using UnityEngine;
[CreateAssetMenu(fileName = "Data", menuName = "ScriptableObjects/SpawnManagerScriptableObject", order = 1)]
public class SpawnManagerScriptableObject : ScriptableObject
{
public string prefabName;
public int numberOfPrefabsToCreate;
public Vector3[] spawnPoints;
}
Assets폴더의 상기 스크립트를 이용하여Assets > Create > ScriptableObjects > SpawnManagerScriptableObject로 이동하여 ScriptableObject의 인스턴스를 생성할 수 있습니다. 새 ScriptableObject 인스턴스에 알아볼 수 있는 이름을 지정하고 값을 변경합니다. 이러한 값을 사용하려면 ScriptableObject를 참조하는 새로운 스크립트를 만들어야 합니다(이 경우SpawnManagerScriptableObject). 다음 예를 참조하십시오.
using UnityEngine;
public class Spawner : MonoBehaviour
{
// The GameObject to instantiate.
public GameObject entityToSpawn;
// An instance of the ScriptableObject defined above.
public SpawnManagerScriptableObject spawnManagerValues;
// This will be appended to the name of the created entities and increment when each is created.
int instanceNumber = 1;
void Start()
{
SpawnEntities();
}
void SpawnEntities()
{
int currentSpawnPointIndex = 0;
for (int i = 0; i < spawnManagerValues.numberOfPrefabsToCreate; i++)
{
// Creates an instance of the prefab at the current spawn point.
GameObject currentEntity = Instantiate(entityToSpawn, spawnManagerValues.spawnPoints[currentSpawnPointIndex], Quaternion.identity);
// Sets the name of the instantiated entity to be the string defined in the ScriptableObject and then appends it with a unique number.
currentEntity.name = spawnManagerValues.prefabName + instanceNumber;
// Moves to the next spawn point index. If it goes out of range, it wraps back to the start.
currentSpawnPointIndex = (currentSpawnPointIndex + 1) % spawnManagerValues.spawnPoints.Length;
instanceNumber++;
}
}
}
위의 스크립트를씬의 게임 오브젝트에 연결하십시오. 그런 다음 인스펙터에서Spawn Manager Values필드를 새로 설정한SpawnManagerScriptableObject로 설정해야 합니다.
Entity To Spawn필드를 Assets 폴더의 아무 프리팹으로 설정하고, 에디터의Play를 클릭하십시오.Spawner에서 참조한 프리팹이SpawnManagerScriptableObject인스턴스에서 설정된 값을 사용하여 인스턴스화됩니다.
인스펙터에서 ScriptableObject 레퍼런스로 작업할 때 레퍼런스 필드를 더블 클릭해서 ScriptableObject의 인스펙터를 열 수 있습니다. 또한 인스펙터가 나타내는 데이터의 관리에 도움이 되도록 모양을 정의해 커스텀 에디터를 만들 수도 있습니다.
강화 학습에는 Agent, Action , State ,Reward , Environment 가 존재한다
Agent 는 알고리즘 이라고 생각하면 되는데
이와 같은것들이 있다
환경을 제작할 수 있는 엔진에는 유니티, 언리얼 등등이 있다
강화 학습에는 Agent, Action , State ,Reward 가 있으며 이것이 Environment에서 돈다
Agent 는 강화학습 딥러닝으로 많이들 한다
이때 딥러닝은 파이썬이나 TensorFlow 등등을 이용함
머신러닝과 가장 큰차이점은딥러닝은 분류에 사용할 데이터를 스스로 학습할 수 있는 반면
머신 러닝은 학습 데이터를 수동으로 제공해야한다는점이딥러닝과머신러닝의 가장 큰차이점입니다.
머신 러닝이란? 인공지능의 하위 집한 개념인 머신러닝은 정확한 결정을 내리기 위해 제공된 데이터를 통하여 스스로 학습할 수 있습니다. 처리될 정보에 대해 더 많이 배울 수 있도록 많은 양의 데이터를 제공해야 합니다.
즉, 빅데이터를 통한 학습 방법으로 머신러닝을 이용할 수 있습니다. 머신 러닝은 기본적으로 알고리즘을 이용해 데이터를 분석하고, 분석을 통해 학습하며, 학습한 내용을 기반으로 판단이나 예측을 합니다. 따라서 궁극적으로는 의사 결정 기준에 대한 구체적인 지침을 소프트웨어에 직접 코딩해 넣는 것이 아닌, 대량의 데이터와 알고리즘을 통해 컴퓨터 그 자체를 ‘학습’시켜 작업 수행 방법을 익히는 것을 목표로 한답니다.
환경은 내가 알아서 만듬(유니티, 언리얼 같은것으로)
Agent 와 환경은 둘이 다른 환경(프로그래밍 언어등)인데 이 둘 사이를 연결해주는것을 보고 유니티에서
ML-Agent 라하며 이것을 ToolKit 이라 볼 수 있다
#1
이 그림에서 보면 Learnig Enviroment 유 유니티 같은 환경에서 보상과 관측된 정보를 Python API 로 넘긴다
Python API 에서 넘어온 정보를 기반으로 강화학습 알고리즘을 만들어(Python Low-Level API) 알고리즘을 통하여 Action 을 다시 환경으로 넘겨주게 할 수 있다
그런데 강화학습 알고리즘을 직접 짜곳 싶지 않다면 유니티에서 제공하는 Python Trainer 를 갖고 학습을 시킨후
액션을 선택하여 환경에 넘겨줘 서로 통신할수 있게 할 수 있다
Trainers 를 통해서 할대는 학습이 완료된 .nn 이라는 파일을 유니티로 내장 후 이것을 그대로 사용 할 수 있다
ML-Agent 1.0 에선 다음과 같은것들이 있었음
Agents 스크립트는
환경을 다 구성해 놓고 어떤 정보와 어떤 액션들어오는 지에 따라서 어떻게 행동 할지를 결정 할수 있는 스크립트
보상과 게임 종료 조건도 결정 할 수 있다
Behavior 스크립트(예전에는 Brain 이였다고함) ( 위 그림에서 Behavior Parameters 스크립트)
1.0 에서
예젠에는 Acamedy 가 있었는데(빌드된 화면의 환경크기, 몇프레임마다 화면 업데이트 할지등등)
지금은 Acamedy 가 사라지고 파이썬으로 옮겨갔음
Agent 하나당 하나의 Behavior 스크립트가 붙어야 하며 이 Behavior 는 Agents 에 대한 구체적인 설정을 하게된다
Vector Observation : x,y,z 위치, 속도 등 관측에 필요한 정보개수들 설정
Stacked Vectors : 이전것 그 전전의 것 그 전전전의 것을 쌓아서 행동을 결정할때 쌓는정도를 말함
Space Type : 액션의 타입인데 연속적인 액션 또는 비연속적인 액션등으로 설정 할 수 있음
학습환경 내에 있는 Agent A1, Agent A2 가 사용하는 Behavior 가 같다면 Behavior 를 하나로 묶어줘도 된다
그다음 이렇게 묶인 것을 Communicator 를 통해서 Python API 와 통신하게 만든다 (#1)
여기서 오른쪽에 Behavior C 는 inference 와 견결 되어 있는데 inference 는 이미 학습된 강화 학습을 통한 결과를 통해 곧바로 행동 할수 있도록 하는것
Behavior D 는 Heuristic 과 연결 되어 있는데 이것은 정해진 규칙에 의해서 움직이게 할 수 있다
사람이 제어하게 할수 있거나, 규칙에 따라 설정 해줄 수가 있다
유니티와 파이썬 사이에 직접적으로 연관 없는데이터를 교환하는 것이 가능하다
ex ) 자동차를 강화 학습 하는데 자기자신이 아닌 앞차나 뒷차의 정보가 앞차와 뒷차의 거리 등등이 궁금할때(log) Side Channel 을 통해 해볼 수 있다
위 처럼 하나를
Agent 를 하나가 아닌 여러개로 구성하여 강화학습을 좀 더 빠르게 하는 방법들이 존재..
Distributed Agents :
이것처럼 여러개로 만들어 좀 더 빠르고 정확한 결과를 볼 수도있음
Multi-agents : 양 한마리에 여러 사자들이 협력하여 양을 잡을 수 있게 할 수있음
1:1로 붙는 agents 학습을 말함 알파고 바둑 같은 경우를 말함 , 흑/백돌
둘이서 Self play 환경 (ai 끼리 대결하면서 하는 학습방식)
이때 학습 해놓은것에서 하나를 빼고 대신 사람이 플레이하게 해서 학습 할 수 도 있게 할수 있따
파란색이 주황색공을 피하게 한다
limitation Learning 은 사람이 하는 행동을 따라하는 것을 말함
우선 사람이 플레이를 한다음 그때의 행동이나 데이터를 기반으로해서 사람이 한것을 따라해보라고 학습을 시키는 것을 말한다
학습이 이상하면 플레이어의 문제 일 수도 있음
중간 사각형의 색상에 따라서 파란색 삭각형이 색상에 일치하는 문으로 가는 Agent 의 예시이다
이것 같은 경우에는 골기퍼들이 하나씩 있고 스트라이커들이 골을 넣는 환경으로
각자의 Agents 의 역할이 다르다 행동들도 다르다, 즉 Multi Agent 이다
강화 핛브에는 오른쪽 그림 처럼 Exploitation 이냐 Exploration 이냐에 따라서 학습 되는것이 다르다
이것은 강화 학습에서 중요한 문제인데, 오른쪽 그림에서 로봇이 아래로만 가도 reward 를 1 을 얻기 때문에
계속 아래로만 갈 수 있다는것 위로가면 더 좋은 보상을 얻을 수 있지만 위로 가기전까진 모름으로
어떻게 에이전트가 다양한 경험을 하면서 학습할지가 성능등 중요한 문제 중 하나
* 학습과 탐험을 적절히 섞어 행동하도록 하는것이 중요
e : 엡실론 (랜덤하게 액션을 선택할 확률)
x 값이 1에 가까워지면 행동 하던대로만 행동하게 되고 0에 가까워지면 여러 탐색을 먼저 시도를 하게 된다
이것이 엡실론 그리디 방식임
그렇지만 목표에 도달하는데까지 상황이 복잡해질 수록 랜덤하게 탐험 하는것인 목표에 도달하는데 거의 불가능에 가까워진다, 보상획득이 어려워져 학습이 불가
이런것은 랜덤 학습으로 키 까지 도달하는데 어려움(거의 불가)
그런데 호기심(curiosity) 탐험 방식이 있는데
다음 상태를 예측하기 어려워서 내부 보상을 부여하여 새로운 경험을 선택하도록함
외부 보상은 일반적으로 게임에 보이는 보상
스태이트와 다음 스태이트가 있을 경우 이것을 동일한 Feature 라는 네트워크를 몇번 통과 시킨다
(상태에서 Feature 를 뽑아냄)
이렇게 뽑아진 Feature 를 Forward Model 이라는 곳에 넣어서 다음 스태이트를 예측해본다 (즉 예측한 다음 상태가 나옴)
현재 상태와 다음 상태를 뽑고
현재 상태에서 내부 보상을 통하여 다음 상태를예측한다음(Forward Model)
현제에서다음을 예측한것과 다음 상태간의 차이를 구한다
즉 이 차이를 이용하여 내부 보상을 만든다
내부 보상이 큰경우 : 현재 상태의 액션을 갖고 다음 상태를 예측 했을때 다음 상태와의 차이가 큰경우를 말함
내부 보상이 작은경우 : 현재 상태의 액션을 갖고 다음 상태를 예측 했을때 다음 상태와의 차이가 작은경우를 말함
inverse Model :
현재 상태의 state 와 다음 상태의 state 를 둘다 넣고 액션을 해봤을 때 추정하는것으로 추정된 액션과 실제 액션으로 잘 추정하도록 만드는것을 말함 , 즉 이것이 잘 되야 현재에서 다음상태로의 유의미한 Feature 를 뽑아낼수 있는것
정리하자면 다음 상태를 잘 모르겠는 상태일때 내부 보상을 크게 줘서 원하는곳으로 가도록 유도하는것
가본적이 많이 없으면 내부 보상이 작아진다
즉 다른곳으로 가게 하려면 내부 보상을 줄이면 된다(새로운 곳으로 가도록 유도함)
처음 가보는 곳은 내부 보상이 커지게 바꾼다
즉 랜덤하게 돌아다니면 경우의 수가 너무 많기 때문에 이런 식으로 다음 탐험에 대한 방향성을 좁힐 수 있다
imitation
사람의 플레이를 통해서 경험을 쌓는 방식
만약 사람이 플레이를이상하게 하면 agent 도 이상하게 행동을 하게 된다
이미테이션 러닝에서 레퍼런스가 되는 애니메이션이 있고 여기에서 목표하는 타겟을 때리는 것을 강화학습으로 애니시키면 그냥 강화학습으로 목표를 때리는것보다는 자연스럽게 처리 할 수 있다
Curriculum Learning : 어려운 문제를 한단계씩 쉬운 난이도 부터 학습해서 조금식 어렵게 바꿔나가는 방식
파란색이 초록색 박스를 밀어서 녹색 플러스에 집어 넣어야 한다, 빨간색 플러스쪽으로 밀어 넣으면 안됨
제대로 밀어 넣었으면 좀 더 어려운 다음 스테이지에서도 밀어 넣을 수 있도록 난이도를 점점 높여간다
엡실론 (랜덤하게 액션을 선택할 확률) 값을 적당히 높여서 다음 난이도에서 학습하도록함
Environment parameter Randomization :
사각형 얼굴이 왔다갔다하면서 위에 공을 떨어뜨리지 않도록 할때 Environment parameter 를 쓸 수 있다
공의 크기 또한 바꿀 수 있도로고 파라미터화를 할 수도 있다 (이런 랜덤한 값이 실행활에 있을수 있으니 그런 경우에 유용하다)
자동차가 여러개 가고있는데 그중 하나가 끝착선에서 다른 끝차선으로 차선 변경할대 Multi-Agent 를 사용 할수 있다
차선을 이동하는 차가 아닌 다른 차들도 협력을해야 하는 상황
빨간색 공이 있는 차가 빨간선 라인으로 차선 변경을 하기 위해서 다른 차들이 비키거나 속력을 내서 공간을 확보한 이후 차선 변경이 되도록함, 하지만 다른 차들의 속도저하는 최소한으로 처리하도록 함
알까기 : 당구와 비슷
알을 까서 이동하는 동안 상대방은 치면 안됨
액션을 결정하는데 조건이 있음 즉 상대방이 친 돌이 이동하고 멈춰야 내 돌이이동 할 수 있음
이때 어떤 조건에서 알을 까도록 decision 을 설정할 수 있는데 이것을 만들어 주면 가능함
ML-Agent 설계 팁
Obervation : 최대한 실제적인 것을 사용 하는것이 중요하다, 처음엔 목표달성에 너무 쉬운 정보들을 줬다가 점점 빼는 형태로 가면 학습 속도를 빨리 할 수 있다
Action : 너무 많을 수록 학습이 잘 안된다, 액션의 조합을 다 합처서 많으면 너무 선택이 많음으로
Reward : 어떻게 움직이고 싶어하는 목표에 맞춰서 만든다
전체적으로 너무 많은 변수들과 액션 리워드를 세팅 하면 처음 학습 자체가안되거나 어디서 문제가 발생이 됐는지 알기 어려움으로 환경을 과 액션 reward 를 최대한 간단하게 만들고 여기서 학습이 되는 것을 확인한 다음에 차츰 어려운 환경을 만들어 나가는 것이 최종 만들어나가는데 빠른길이 될 수 있다