R3 는 씬에 오브젝트를 만들지 않는다
UniRx 로 게임을 켜면 하이어라키에 MainThreadDispatcher 가 생기고, 그 안의 코루틴이 프레임 연산을 돌립니다. 씬 정리 코드가 이 오브젝트를 지우거나 프리팹에 하나가 더 딸려 오면 프레임 연산이 흔들립니다.
R3 는 씬에 아무것도 만들지 않고 PlayerLoop 에 러너를 직접 꽂습니다. 그 덕에 끼울 자리를 고를 수 있고, 매 프레임 경로에서 락이 빠졌습니다.
러너는 PlayerLoop 단계의 맨 앞에 들어간다
EveryUpdate 구독자의 스택 트레이스가 차이를 그대로 보여 줍니다.
UniRx
UnityEngine.SetupCoroutine:InvokeMoveNext
MainThreadDispatcher/<RunUpdateMicroCoroutine>d__32:MoveNext()
InternalUtil.MicroCoroutine:Run()
R3
R3.UnityFrameProvider:Run()
Internal.CancellableFrameRunnerWorkItemBase:MoveNext(long)
R3 쪽에는 코루틴 프레임이 없습니다. PlayerLoopHelper 가 유니티의 실행 루프를 배열로 받아 각 단계에 UnityFrameProvider.Run 을 끼워 넣습니다.
| 규칙 | 이유 |
|---|---|
| 같은 타입이 있으면 빼고 넣습니다 | 재초기화할 때 러너가 중복되지 않습니다 |
| 기본은 그 단계의 맨 앞 | 유니티 자체 시스템보다 먼저 돌아 내 로직이 엔진 처리에 반영됩니다 |
PostFixedUpdate 만 FixedUpdate 단계의 맨 뒤 |
물리 스텝이 끝난 뒤에 돌아야 뜻이 있습니다 |
PostFixedUpdate 는 별도 단계가 아니라 FixedUpdate 단계 안의 마지막 자리입니다. 에디터에서 Domain Reload 를 끄면 플레이 모드에 들어갈 때마다 러너를 다시 초기화해서 이전 세션의 워크아이템이 새지 않게 합니다.
타이밍은 아홉 자리에서 고른다
UniRx 의 프레임 연산은 디스패처의 코루틴 셋(Update · FixedUpdate · EndOfFrame) 위에서 돕니다. R3 는 UnityFrameProvider 의 아홉 자리 중에서 고릅니다.
| 타이밍 | 쓰임새 |
|---|---|
TimeUpdate |
프레임 맨 앞. Initialization 보다도 앞 |
Initialization |
입력 폴링 전 |
EarlyUpdate |
입력 처리 직후 |
FixedUpdate |
물리 스텝 전 |
PostFixedUpdate |
물리 스텝 후. 물리 결과 보정 |
PreUpdate |
Update 직전 |
Update |
일반 게임 로직 (기본값) |
PreLateUpdate |
LateUpdate 직전. 카메라 추적 |
PostLateUpdate |
렌더 직전. 최종 보정 |
(TimeUpdate 위치는 PlayerLoop 를 덤프해 확인했습니다. R3 러너가 유니티의 WaitForLastPresentationAndUpdateTime 보다 앞에 있었습니다.)
// 물리 결과가 확정된 뒤에 보정한다
Observable.EveryUpdate(UnityFrameProvider.PostFixedUpdate)
.Subscribe(_ => CorrectRigidbodyDrift());
숫자보다 표현할 수 있는 요구가 늘어난 것이 중요합니다.
매 프레임 경로에 락이 없다
러너가 목록을 읽는 FreeListCore.AsSpan() 은 Volatile.Read 로 배열과 마지막 인덱스를 읽을 뿐이고, 락은 Add 와 Remove 에만 있습니다. 워크아이템 하나가 예외를 던지면 그 항목만 목록에서 빼고 전역 미처리 핸들러에 넘긴 뒤 루프를 계속합니다.
EveryUpdate 구독 N개를 유지한 채 PlayerLoopType.Update 노드만 잰 값입니다.
| N | UniRx | R3 |
|---|---|---|
| 1,000 | 0.0207 ms | 0.0096 ms |
| 5,000 | 0.1329 ms | 0.0418 ms |
| 10,000 | 0.1961 ms | 0.1300 ms |
(IL2CPP 플레이어, 프레임당 할당은 양쪽 0 B. 세 번 재서 N=10,000 에서 1.51배 · 2.74배 · 2.95배로 같은 방향이었고, N=1,000 은 한 번의 값입니다.) 차이가 락, 이터레이터, 순회 방식 중 어디서 났는지는 이 측정으로 가를 수 없습니다. 구독이 수천 개 붙는 프레임 루프에서 R3 가 유리하게 나왔다는 것까지입니다.
시간과 프레임을 따로 추상화했다
UniRx 의 IScheduler 는 “언제” 를 TimeSpan 으로만 표현해서 “60프레임 뒤” 를 담을 자리가 없었습니다. R3 소스에는 IScheduler 라는 문자열이 아예 없습니다. 대신 둘로 갈랐습니다.
IScheduler → TimeProvider 시계 (.NET 8 BCL 표준)
FrameProvider 프레임 (R3 자체)
TimeProvider 는 타이머 객체를 하나 만들어 ITimer.Change() 로 다음 발동 시각만 바꿉니다. 한 번 실행마다 예약 객체를 만들던 모양과 반대입니다. 동기 실행은 추상화하지 않아서 Range 에는 스케줄러 오버로드가 없습니다. 그리고 시간축 연산자마다 프레임판이 생겼습니다.
| 시간축 | 프레임축 |
|---|---|
Interval(TimeSpan) |
IntervalFrame(int) |
Timer(TimeSpan) |
TimerFrame(int) |
Debounce(TimeSpan) |
DebounceFrame(int) |
Chunk(TimeSpan) |
ChunkFrame(int) |
ThrottleLast(TimeSpan) |
ThrottleLastFrame(int) |
UniRx 에도 TimerFrame 같은 프레임 연산자는 있었지만 FrameCountType 열거형으로 부분 지원한 것이었습니다. 게임에서는 “0.2초 뒤” 보다 “12프레임 뒤” 가 정확한 요구일 때가 많고, 프레임 수 기준은 프레임 드랍이 있어도 결정론적입니다.
주의할 점
1. PostFixedUpdate 를 별도 단계로 여기지 않는다
안 지키면 생기는 문제. 이름만 보면 FixedUpdate 다음의 새 단계 같지만 실제로는 FixedUpdate 단계 안의 맨 뒤 자리입니다. 별도 단계로 알고 짜면 다른 단계와의 순서를 잘못 예상합니다.
이렇게 쓴다. “이번 고정 스텝의 물리 처리가 끝난 뒤” 에 해야 하는 보정에만 UnityFrameProvider.PostFixedUpdate 를 씁니다. 렌더 전에 한 번 하면 되는 일은 PreLateUpdate 나 PostLateUpdate 에 겁니다.
2. Domain Reload 를 끄면 내 static 도 직접 비운다
안 지키면 생기는 문제. Domain Reload 를 끄면 static 필드가 플레이 세션을 넘어 남습니다. R3 러너는 이 경우를 알아서 재초기화하지만, 내 코드의 static Subject 나 static 구독은 이전 세션의 구독자를 그대로 들고 있습니다.
이렇게 쓴다. static 으로 들고 있는 스트림과 구독은 플레이 모드에 들어갈 때 비우는 초기화 코드를 둡니다.
3. 프레임 구독이 조용히 멈췄으면 콘솔의 예외부터 본다
안 지키면 생기는 문제. 러너는 예외를 던진 워크아이템만 빼고 계속 돕니다. 나머지는 멀쩡하니 한 구독만 소리 없이 멈춘 것처럼 보이고, 빠진 항목은 다시 붙지 않습니다. 흔적은 콘솔 한 줄뿐입니다.
이렇게 쓴다. 매 프레임 도는 구독이 어느 순간부터 안 불리면 그 프레임의 예외 로그를 먼저 찾습니다. 계속 돌아야 하는 일은 구독 안에서 예외를 직접 처리합니다.
정리
R3 는 PlayerLoop 에 러너를 직접 꽂아 프레임을 돌리고, 씬에는 아무것도 만들지 않습니다.
- 러너는 각 단계의 맨 앞에 들어가고,
PostFixedUpdate만FixedUpdate단계의 맨 뒤입니다. 타이밍은 아홉 자리에서 고릅니다. - 매 프레임 읽는 경로에 락이 없고, IL2CPP 에서 구독 수천 개 기준 1.5~3.2배 빨랐습니다.
IScheduler대신TimeProvider와FrameProvider로 갈라, 시간축 연산자마다 프레임판이 있습니다.