R3 는 씬에 오브젝트를 만들지 않는다
Esc

    R3 는 씬에 오브젝트를 만들지 않는다

    2026년 9월 25일 수정–UnityRx라이브러리

    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 로 갈라, 시간축 연산자마다 프레임판이 있습니다.