Pages

September 13, 2013

[Unity] 유니티3D 코루틴의 매커니즘

유니티는 기본적으로 싱글 스레드로 동작한다. 따라서 긴시간이 걸리는 작업을 (Especially I/O Operation) 로직내에서 수행할 경우 프레임이 떨어지는 문제를 겪게 된다.

만약 긴 시간이 걸리는 작업을 멀티스레드를 사용해서 골치아프게 동기화등을 고려하지 않고, 여러 프레임이 지나가는 동안 나누어서 처리할 수 있다면 깔끔하게 문제를 해결할 수 있지 않을까?

이 상황을 해결하는 솔루션으로 유니티는 코루틴(coroutine)이라는 것을 제공한다.

사실 유니티 코루틴은 긴 시간이 걸리는 작업을 분할하여 처리하는데에도 쓰이지만 잠깐 로직이 일정 시간 멈출 필요가 있을때에도 사용된다.

아래는 유니티 C# 스크립트로 만든 코루틴의 예제이다.
IEnumerator HeavyLogic()
{
    while(someCondition)
    {
        /* 일정 부분의 작업 수행 */
 
        // 이 로직은 여기서 멈추고 다음 프레임을 진행한다.
        yield return null;

        /* 다음 프레임 이후에 처리될 로직 */
    }
}

그렇다면 이 코루틴이라는 것은 어떻게 돌아가는 것인가? 사실 내가 글을 쓰는 목적인 코루틴의 활용 보다는 그것이 어떻게 유니티내에서 돌아가는 것인가 하는 매커니즘 파악에 촛점이 맞추어져 있다.

이것을 이해하는 키는 해당 코루틴 로직이 IEnumerator형을 리턴하는 함수라는 것과 yield라는 키워드를 사용하는 점에서 유추해볼 수 있다.

IEnumerator 타입은 일종의 시퀀스에 대한 커서처럼 작용한다. IEnumberator 는 Current라는 멤버변수와 MoveNext()라는 멤버함수를 가진다.

Current는 현재 시퀀스의 커서에 대한 요소를 가진 속성이고, MoveNext() 는 현재 시퀀스에서 다음 시퀀스로 진행하도록 하는 함수이다.

IEnumerator는 단지 Interface이므로 이런 멤버들이 어떻게 Implementation되었는지에 대해서 정의하지는 않는다.

MoveNext() 함수의 구현은 Current에 1을 더하는 로직이 될 수도 있고 인터넷 상에서 이미지를 다운 받아 그것의 해쉬값을 Current 변수에 저장하는 로직이 될 수도 있는 것이다. 또한 이 함수는 처음에는 로직 시퀀스상의 어떤 연산을 하다가 그 다음 호출시에는 처음과는 완전히 다른 로직을 수행할 수 도 있다.

MoveNext()가 호출이 되면 해당하는 로직을 수행후 결과는 Current멤버 변수에 저장이 된다. 그리고 더이상 진행할 것이 없다면 false를 리턴한다.

자 따라서 원래대로라면 IEnumerator를 상속받은 Class를 구현을 해야 한다. 골치가 살살 아파 올것이다 그러나 상심은 아직 금물 C#에서는 몇가지 Rule만 따른다면 컴파일러가 자동으로 IEnumerator 구현체를 컴파일 타임에 자동으로 생성하여 준다.

이렇게 Rule을 따라 정의한 함수를 C#에서는 Iterator block이라고 부르는 것 같다.

그렇다면 Iterator block이란 무엇인가?

Iterator block이란 일반 함수와 다르지 않지만 a) IEnumuerator를 리턴할 것, b) yield 키워드를 사용할 것 이 두가지를 갖추면 Iterator block이 된다. (위의 예제 코드 참고)

그렇다면 yield 키워드는 과연 무엇을 하는 것인가.

yield 키워드는 로직 시퀀스에서 다음에 나올 값이 무엇인지 혹은 값이 더이상 없는지 를 가르쳐주는 키워드이다. 따라서 코드 진행중 yield return X 나 yield break를 마주친 지점이 IEnumerator.MoveNext() 함수가 중단되는 지점이다.

yield return X 는 MoveNext()는 true를 리턴하고 Current 는 X로 할당되도록 한다. 반면에 yield break는 MoveNext()가 false를 반환하도록 한다.

자 여기서 발생할 수 잇는 트릭은 시퀀스가 어떤 값을 실제로 반환하는지는 상관이 없다는 것이다. MoveNext()를 반복해서 호출하고 Current값을 무시할 수 있다. 그러면 계산은 계속해서 진행될 것이며 매번 MoveNext()가 호출 될 때 마다 iterator block은 실제로 어떤 식을 yield 하느냐에 상관 없이 다음 yield 키워드까지 진행되게 된다.

그래서 다음과 같은 Iterator block도 생각해볼 수 있다.

IEnumerator TellMeASecret()
{
   PlayAnimation("LeanInConspiratorially");
   while(playingAnimation)
     yield return null;
 
   Say("I stole the cookie from the cookie jar!");
   while(speaking)
     yield return null;
 
   PlayAnimation("LeanOutRelieved");
   while(playingAnimation)
     yield return null;
}

이 Iterator block은 긴 연속적인 null 값을 생성하게 된다. 그러나 여기서 중요한 것은 이 로직을 실행하기 위해 발생한 사이드 이펙트이다.

아마도 아래와 같은 간단한 루프 내에서 위의 iterator block의 코루틴을 실행할 수 있다.

IEnumerator e = TellMeASecret();
while(e.MoveNext()) { }

좀 더 실용성있는 예문은 다음과 같이 쓸 수 있을것 같다.

IEnumerator e = TellMeASecret();
while(e.MoveNext()) 
{ 
  // 'Escape'키를 누를 경우 컷씬을 건너뛴다.
  if(Input.GetKeyDown(KeyCode.Escape)) { break; }
}

마지막의 예문으로 위의 코루틴이 어떻게 실행될지를 예측해보면,

먼저 PlayAnimation("LeanInConspiratorially"); 함수내에서 애니메이션이 구동되고 playingAnimation 변수는 true로 세팅이 된다.
while(playingAnimation) 에 의해 playingAnimation이 true 인 동안은 while 루프를 반복하게 되는데 루프안에 있는 첫번째 명령은 yield return null; 이다 따라서 여기서 MoveNext의 진행은 멈추고 return 값으로 null을 Current에 할당한다.

원래 유니티에서 Coroutine의 yield값이 null인경우 이번 프레임을 쭉 실행하고 다음 프레임에 다시 해당 코루틴을 중단 지점에서 다시 진행하도록 되어 있으나 이 예제는 Current 멤버 변수를 무시한다고 가정하고 있으므로 그냥 while 루프를 실행하는 MoveNext가 yield에서 잠시 멈추고 애니메이션을 진행하는 작업을 조금 하다가 다음번에 다시 while문으로 돌아 오게 되어 playingAnimation이 값을 다시 평가하고 같은 작업을 반복하게 된다.

중요한것은 MoveNext함수가 yield 키워드를 만나면 그 곳에서 함수의 진행을 멈추고 다른 작업에 제어권을 넘겨주는 점이다.

자 이제 yield로 넘겨주는 값이 Current 멤버 변수에 할당이 되는 점을 고려하여 유니티에서 해당 사항을 어떻게 처리하는지 알아 보자.

위의 예제에서 null을 yield하는 경우는 유니티에서 한프레임을 넘기게 된다. 그러나 만약 애니메이션을 보여주는 데 일정 시간이 필요하다거나 아니면 일정 시간이 지난 후에 무엇인가를 해야 한다거나 하면 위와 같이 yield return null;을 통해 프레임을 넘기고 매번 어떤 상황을 매 프레임 체크하는 것은 꽤나 비효율적이지 않은가?

그래서 유니티는 yield를 통해 Current에 할당된 값에 따른 몇가지 다른 종류의 기다림 로직을 YieldInstruction 타입으로 정의 해 두었다.

WaitForSeconds값을 만났을 경우는 Coroutine을 일정 시간이 지난 후 다시 재개 되도록 하고, WaitForEndOfFrame의 경우는 코루틴을 프레임이 렌더링 된 이후에 재개 되도록 하였다. 그리고 코루틴 자체를 Current 값으로도 받을 수 있는데 예를 들어 Coroutine A가 Couroutine B를 yield 하도록 한 경우는 Coroutine B가 끝나야 Coroutine B가 재개된다.

이러한 유니티의 로직을 코드로 표현해보면,

List<IEnumerator> unblockedCoroutines;
List<IEnumerator> shouldRunNextFrame;
List<IEnumerator> shouldRunAtEndOfFrame;
SortedList<float, IEnumerator> shouldRunAfterTimes;
 
foreach(IEnumerator coroutine in unblockedCoroutines)
{
    if(!coroutine.MoveNext())
        // This coroutine has finished
        continue;
 
    if(!coroutine.Current is YieldInstruction)
    {
        // This coroutine yielded null, or some other value we don't understand; run it next frame.
        shouldRunNextFrame.Add(coroutine);
        continue;
    }
 
    if(coroutine.Current is WaitForSeconds)
    {
        WaitForSeconds wait = (WaitForSeconds)coroutine.Current;
        shouldRunAfterTimes.Add(Time.time + wait.duration, coroutine);
    }
    else if(coroutine.Current is WaitForEndOfFrame)
    {
        shouldRunAtEndOfFrame.Add(coroutine);
    }
    else /* similar stuff for other YieldInstruction subtypes */
}
 
unblockedCoroutines = shouldRunNextFrame;

더 많은 YieldInstruction 타입의 로직이 어떻게 추가로 구현 되어 각기 다른 상황을 처리 할 것인지는 어렵지 않을 것이다.

한가지 팁은 yield return은 하나의 표현일 뿐이다. 따라서 다음과 같은 응용법도 얼마든지 가능하다.

YieldInstruction y;
 
if(something)
 y = null;
else if(somethingElse)
 y = new WaitForEndOfFrame();
else
 y = new WaitForSeconds(1.0f);
 
yield return y;

위와 같이 something, somethingElse 에 따라 코루틴이 언제 재개 되는지를 다르게 정의할 수 있다.

또한 유니티의 코루틴은 일반적인 C#의 iterator block이므로 해당 코루틴을 iterate 하는 로직을 스스로 만들 수도 있다.

IEnumerator DoSomething()
{
  /* ... */
}
 
IEnumerator DoSomethingUnlessInterrupted()
{
  IEnumerator e = DoSomething();
  bool interrupted = false;
  while(!interrupted)
  {
    e.MoveNext();
    yield return e.Current;
    interrupted = HasBeenInterrupted();
  }
}

위의 예제는 코루틴 내에서 iterator block을 직접 iterate 하는 로직을 구현하고 있다.

이상의 내용들이 유니티에서 코루틴을 사용할 때 무슨 일들이 일어나는지 이해하는데 있어 도움이 될 수 있었으면 좋겠다.

참고: http://www.altdevblogaday.com/2011/07/07/unity3d-coroutines-in-detail/






No comments:

Post a Comment