UniTask 의 대기는 무엇을 세느냐만 다르다
spriteRenderer.enabled = false;
await UniTask.Yield(); // yield return null 대신? 아니면 NextFrame()?
spriteRenderer.enabled = true;
yield return null 을 옮기면 Yield() 와 NextFrame() 이 후보로 나옵니다. 이름은 비슷한데 하나는 같은 프레임에 끝날 수 있습니다. Delay 도 일시정지 메뉴에서 멈추고, 1초를 기다렸는데 조금 일찍 끝납니다.
UniTask 의 대기는 모두 매 프레임 한 번 “아직인가” 를 묻고, 무엇을 세는지만 다릅니다. API 는 이름이 아니라 세야 하는 것으로 고릅니다.
대기 객체의 MoveNext 는 뼈대가 같고 조건 한 줄만 다르다
Yield() 를 뺀 대기는 모두 풀에서 꺼낸 대기 객체가 러너에 올라가고, 러너가 프레임마다 MoveNext() 를 한 번 부릅니다. true 를 돌려주면 다음 프레임에 다시 불리고, false 를 돌려주면 러너에서 빠집니다. 여섯 대기 객체의 MoveNext() 는 뼈대가 같습니다.
public bool MoveNext()
{
// 1. 취소됐으면 취소로 끝낸다 (모두 같다)
if (cancellationToken.IsCancellationRequested)
{
core.TrySetCanceled(cancellationToken);
return false;
}
// 2. 아직이면 true 를 돌려준다 (이 조건만 서로 다르다)
if (/* 아직인가 */)
return true;
// 3. 완료하고 러너에서 빠진다 (모두 같다)
core.TrySetResult(...);
return false;
}
다른 것은 2번 조건뿐입니다. 소스에서 그 줄만 옮기면 이렇습니다.
// NextFrame(): 프레임 번호가 그대로면 아직이다
if (frameCount == Time.frameCount)
return true;
// DelayFrame(n): 지나간 프레임 수가 n 에 못 미치면 아직이다
if (++currentFrameCount < delayFrameCount)
return true;
// Delay(t): 더한 deltaTime 이 t 에 못 미치면 아직이다
elapsed += Time.deltaTime;
if (elapsed < delayTimeSpan)
return true;
// Delay(t, DelayType.UnscaledDeltaTime): 더하는 값만 다르다
elapsed += Time.unscaledDeltaTime;
if (elapsed < delayTimeSpan)
return true;
// Delay(t, DelayType.Realtime): 스톱워치가 t 에 못 미치면 아직이다
if (stopwatch.ElapsedTicks < delayTimeSpanTicks)
return true;
// WaitUntil(f): f() 가 거짓이면 아직이다
if (!predicate())
return true;
DelayFrame 과 Delay 는 소스에서 조건이 뒤집혀 있어(>= 이면 완료) 같은 모양으로 맞춰 옮겼습니다. 각 대기가 세는 것을 정리하면 이렇습니다.
| API | 프레임마다 보는 것 |
|---|---|
Yield() |
없음. 그 자리가 오면 한 번 불림 |
NextFrame() |
프레임 번호가 바뀌었나 |
DelayFrame(n) |
지나간 프레임 수 |
Delay(t) |
deltaTime 누적 |
Delay(t, UnscaledDeltaTime) |
unscaledDeltaTime 누적 |
Delay(t, Realtime) |
스톱워치 경과 시간 |
WaitUntil(f) |
f() 의 값 |
Yield() 만 대기 객체 없이 큐에 델리게이트를 넣습니다.
아래 절들은 이 조건 한 줄에서 따라 나오는 동작입니다.
Yield 는 세지 않아서 같은 프레임에 끝날 수 있다
Yield() 는 “다음에 그 자리가 오면” 깨어날 뿐입니다. 그 자리가 이번 프레임에 아직 남아 있으면 이번 프레임에 깨어납니다. NextFrame() 은 만든 순간의 frameCount 를 적어 두고 번호가 바뀐 뒤에만 끝납니다.
한 프레임은 Update 단계, PostLateUpdate 단계 순서로 돌고, UniTask 의 큐와 러너는 각 단계의 앞머리에서 돕니다. MonoBehaviour.Update() 는 Update 단계의 큐가 돈 뒤에 불립니다. 프레임 10 의 Update() 에서 세 가지를 await 하면 이렇게 깨어납니다.
await 한 것 |
호출한 곳 | 깨어난 곳 |
|---|---|---|
Yield() |
프레임 10, Update | 프레임 11, Update |
Yield(PostLateUpdate) |
프레임 10, Update | 프레임 10, PostLateUpdate |
NextFrame(PostLateUpdate) |
프레임 10, Update | 프레임 11, PostLateUpdate |
Yield()는 Update 큐에 들어갑니다. 프레임 10 의 Update 큐는 이미 돌고 지나갔으니 프레임 11 에 불립니다.Yield(PostLateUpdate)는 PostLateUpdate 큐에 들어갑니다. 그 자리가 프레임 10 에 아직 오지 않았으니 같은 프레임에 불립니다.NextFrame(PostLateUpdate)는 프레임 번호 10 을 적어 둡니다. 프레임 10 의 PostLateUpdate 에서도 한 번 불리지만 번호가 같아서 넘어가고, 프레임 11 에 끝납니다.
Update() 에서 기본 인자로 부르면 둘 다 다음 프레임입니다. 코루틴에서 옮긴 코드가 대부분 문제없이 도는 이유입니다. 갈리는 것은 기다릴 자리가 이번 프레임에 남아 있을 때입니다. FixedUpdate() 안에서 기다려 봤습니다.
FixedUpdate() 안에서 |
재개 |
|---|---|
await UniTask.Yield() |
5회 모두 같은 프레임의 Update 타이밍 |
await UniTask.NextFrame() |
5회 모두 다음 프레임 |
대신 Yield() 는 대기 객체를 쓰지 않아 쌉니다. 매 프레임 await 하는 루프 하나에 Mono 0.047 µs, IL2CPP 0.085 µs 로 NextFrame() 보다 약 4배, 2배 쌌습니다. 토큰을 받을 자리는 없어서, Yield(ct) 는 대기 객체를 꺼내 러너로 갑니다.
프레임이 바뀌어야 하면 NextFrame(), 그 자리까지 비켜 주면 되면 Yield() 입니다.
Delay 는 시간을 더해서, 일시정지에 멈추고 조금 일찍 끝난다
세는 방법
Delay 는 러너가 부를 때마다 이번 프레임의 deltaTime 을 elapsed 에 더하고, 목표 시간을 넘으면 끝납니다. 만든 프레임은 세지 않습니다.
public bool MoveNext()
{
if (cancellationToken.IsCancellationRequested) { ... return false; }
// 아직 아무것도 안 셌을 때만 프레임 번호를 본다
if (elapsed == 0.0f)
{
if (initialFrame == Time.frameCount)
return true; // 만든 프레임은 세지 않고 넘어간다
}
elapsed += Time.deltaTime; // 이번 프레임의 시간을 더한다
if (elapsed >= delayTimeSpan) // 목표 시간을 넘었나
{
core.TrySetResult(null); // 완료
return false; // 러너에서 빠진다
}
return true; // 다음 프레임에 다시 불린다
}
Delay(100) 을 프레임 10 에 만들었고 한 프레임이 0.04초라면 이렇게 셉니다.
| 프레임 | 하는 일 | elapsed |
|---|---|---|
| 10 | 만든 프레임이라 세지 않음 | 0 |
| 11 | 0.04 를 더함 | 0.04 |
| 12 | 0.04 를 더함 | 0.08 |
| 13 | 0.04 를 더함. 0.1 을 넘어 완료 | 0.12 |
이 세는 방법에서 두 가지 동작이 따라 나옵니다.
일시정지에 멈춘다
Time.timeScale 이 0 이면 Time.deltaTime 도 0 입니다. 매 프레임 0 을 더하니 elapsed 가 움직이지 않고, timeScale 이 돌아올 때까지 끝나지 않습니다.
일시정지에도 흘러야 하면 DelayType 을 넘깁니다.
DelayType.UnscaledDeltaTime은 더하는 값만Time.unscaledDeltaTime으로 바뀝니다.timeScale과 상관없이 흐릅니다.DelayType.Realtime은 더하지 않고 스톱워치를 읽습니다. 이것도timeScale과 상관없이 흐릅니다.
Delay(ms, ignoreTimeScale: true) 는 UnscaledDeltaTime 입니다. Realtime 이 아닙니다.
조금 일찍 끝난다
처음 더하는 deltaTime 은 만든 프레임이 시작한 시각부터 잰 간격입니다. 그래서 프레임이 시작하고 Delay 를 만들기까지 흐른 시간이 elapsed 에 함께 들어가고, 그만큼 일찍 끝납니다.
프레임 앞부분에서 5 ms 를 쓴 뒤에 Delay 를 만들어 쟀습니다. deltaTime 의 합이 벽시계보다 5.0~5.1 ms 앞섰고, 작업이 없을 때는 0.1 ms 안쪽이었습니다. 쌓이는 오차가 아니라 시작할 때 한 번 생기는 차이입니다. float 로 더해서 생기는 오차는 0.000 ms 였습니다.
Realtime 은 최대 한 프레임 늦게 끝난다
Realtime 은 스톱워치로 재서 위의 차이가 없습니다. 대신 확인이 프레임마다 한 번이라, 목표 시간이 지난 뒤 처음 오는 프레임에 끝납니다.
1초 Delay 가 끝나는 시각을 벽시계로 쟀습니다(창 모드 60 fps).
| Mono | IL2CPP | |
|---|---|---|
DeltaTime |
2.23 ms 일찍 | 0.83 ms 일찍 |
Realtime |
14.65 ms 늦게 | 15.87 ms 늦게 |
DelayType 고르기
DelayType |
일시정지 중 | 끝나는 시각 |
|---|---|---|
DeltaTime (기본) |
멈춘다 | 조금 일찍 |
UnscaledDeltaTime |
흐른다 | 조금 일찍 |
Realtime |
흐른다 | 최대 한 프레임 늦게 |
에디터에서 플레이 모드가 아닐 때는 어떤 DelayType 을 넘겨도 Realtime 으로 돕니다.
DelayFrame 은 만든 프레임을 건너뛰고, WaitUntil 은 건너뛰지 않는다
DelayFrame
DelayFrame(n) 은 Delay 처럼 만든 프레임을 세지 않고, 그다음 프레임부터 한 프레임에 하나씩 셉니다. 프레임 10 에 DelayFrame(3) 을 만들면 이렇습니다.
| 프레임 | 센 수 |
|---|---|
| 10 | 만든 프레임이라 세지 않음 |
| 11 | 1 |
| 12 | 2 |
| 13 | 3. 완료 |
DelayFrame(0) 은 러너가 처음 부를 때 바로 끝납니다. Yield() 와 같은 때 깨어납니다.
WaitUntil
WaitUntil(f) 는 러너가 부를 때마다 f() 를 부르고, 참이면 끝납니다. 만든 프레임을 건너뛰지 않습니다.
- 조건이 이미 참이면 러너가 처음 부를 때 끝납니다. 그 러너의 자리가 이번 프레임에 남아 있으면 같은 프레임에 끝납니다.
f()가 예외를 던지면 그 예외로 끝납니다.
주의할 점
1. 프레임을 세야 하는 곳에 Yield 를 쓰지 않는다
안 지키면 생기는 문제. Yield(PlayerLoopTiming.PostLateUpdate) 를 Update() 에서, 또는 Yield() 를 FixedUpdate() 에서 기다리면 같은 프레임에 깨어납니다. “한 프레임 뒤에 다시 읽는다” 고 짠 코드가 아직 갱신되지 않은 값을 읽습니다.
이렇게 쓴다. 프레임이 바뀌어야 하는 곳은 NextFrame() 이나 DelayFrame(n) 을 씁니다.
2. 일시정지 중에도 흘러야 하는 대기에는 DelayType 을 넘긴다
안 지키면 생기는 문제. 기본 DeltaTime 은 timeScale = 0 에서 끝나지 않습니다. 일시정지 메뉴의 페이드나 팝업 타이머가 멈춥니다.
이렇게 쓴다. 일시정지 UI 는 DelayType.UnscaledDeltaTime, 네트워크 타임아웃처럼 벽시계가 기준인 것은 DelayType.Realtime 을 넘깁니다.
3. Delay 를 미리 만들어 두지 않는다
안 지키면 생기는 문제. 대기 객체는 만드는 순간 러너에 올라가 세기 시작합니다.
var delay = UniTask.Delay(1000); // 이 순간 세기 시작한다
await LoadAsync(); // 0.6초가 걸렸다면
await delay; // 남은 0.4초만 기다린다
이렇게 쓴다. 기다릴 자리에서 await UniTask.Delay(...) 로 만들고 바로 기다립니다.
정리
UniTask 의 대기는 매 프레임 한 번 묻고 무엇을 세는지만 다르므로, 세야 하는 것으로 고릅니다.
Yield()는 아무것도 세지 않아 같은 프레임에 끝날 수 있고,NextFrame()은frameCount가 바뀐 뒤에만 끝납니다.Delay의 기본DeltaTime은 일시정지에 멈추고 조금 일찍 끝나며,Realtime은 최대 한 프레임 늦게 끝납니다.DelayFrame은 만든 프레임을 건너뛰고 세며,WaitUntil은 건너뛰지 않고 조건식을 부릅니다.