LitMotion 은 Burst Job 으로 나눠 계산하고 그 자리에서 기다린다
Esc

    LitMotion 은 Burst Job 으로 나눠 계산하고 그 자리에서 기다린다

    2026년 10월 18일 수정–Unity라이브러리트윈DOTS
    for (int i = 0; i < 1000; i++)
    {
        LMotion.Create(0f, 10f, 2f)
            .WithEase(Ease.OutQuad)
            .Bind(cubes[i], static (x, t) => t.localPosition = new Vector3(0f, x, 0f));
    }

    손으로 쓴 보간이라면 큐브 천 개가 각자의 Update 에서 경과 시간을 더하고 Mathf.Lerp 를 부릅니다. 전부 메인 스레드에서 하나씩입니다.

    LitMotion 은 시간 전진, 이징, 보간을 Burst 로 컴파일한 Job 에 몰아 여러 코어에 나누고, 예약한 그 줄에서 끝나기를 기다립니다. Transform 에 값을 쓰는 콜백만 메인 스레드에 남습니다. 기준 판본은 2.0.2 입니다.

    러너의 한 프레임은 다섯 단계다

    러너는 (값 타입 × 타이밍) 마다 하나이고, PlayerLoop 가 그 타이밍에 이르면 UpdateRunner.Update 가 불립니다.

    단계 스레드 하는 일
    1. 버퍼 메인 결과 NativeArray<TValue> 와 끝난 칸 목록 NativeList<int> 를 TempJob 으로 잡는다
    2. 델타 메인 Time, UnscaledTime, Realtime 세 시계의 델타를 만든다
    3. Job 워커 여럿과 메인 칸마다 시간을 전진시키고 값을 계산한다
    4. 콜백 메인 Bind 콜백, 루프 완료, 완료 콜백을 부른다
    5. 제거 메인 끝난 모션을 저장소에서 뺀다

    모션 데이터는 NativeArray 로 옮기지 않습니다. 저장소의 C# 배열을 fixed 로 고정해 포인터를 넘기고, Job 안전 검사는 [NativeDisableUnsafePtrRestriction] 으로 끕니다. 복사가 없는 대신 배열이 바뀌는 순간을 조심해야 합니다(마지막 절).

    병렬이지만 그 자리에서 기다린다

    job.Schedule(count, 16).Complete();

    Schedule 은 워커에 16칸씩 맡기고, Complete 는 끝날 때까지 메인 스레드를 붙잡습니다. 계산은 여러 코어에 나누되 결과는 이번 프레임, 이 자리에서 받는 동기 병렬입니다.

    값을 이번 프레임에 받아야 하는 이유는 러너가 도는 자리에 있습니다. LitMotion 은 같은 Update 단계에서 MonoBehaviour.Update 보다 먼저 돕니다. 그 뒤 Update 에서 transform.position 을 읽는 코드는 이번 프레임의 값을 봐야 합니다.

    기다리는 메인 스레드는 놀지 않습니다. 호출 스레드를 세어 보니 묶음이 하나뿐일 때는 메인 스레드가 그 묶음을 직접 처리했습니다.

    모션 수 묶음 수 그중 메인 스레드 몫 (Mono / IL2CPP)
    16 1 100 % / 95 %
    100 7 0 % / 0 %
    1,000 63 0.4 % / 0.5 %
    10,000 625 0.9 % / 0.7 %

    60프레임을 합친 값이고, 모든 호출이 Burst 코드였습니다. 모션이 1,000개를 넘으면 메인과 워커 전부에 나뉘었습니다.

    콜백은 메인 스레드가 고정한 배열을 돌며 부른다

    Job 이 끝나면 메인 스레드가 배열을 돌며 칸마다 바인딩 → 루프 완료 → 완료 순으로 콜백을 부릅니다. 이 루프는 들어가기 전에 잡은 포인터와 span 을 끝까지 씁니다. 그런데 콜백은 사용자 코드라 모션을 만들 수 있습니다. 같은 조합, 같은 타이밍이면 새 모션은 지금 도는 그 저장소에 붙고, 용량을 넘으면 Array.Resize 가 배열을 새로 만듭니다. 루프는 옛 배열에 계속 씁니다.

    fixed (MotionData* dataPtr = 저장소 배열)   // 러너가 지금 배열의 주소를 붙잡는다
    {
        for (int i = 0; i < span.Length; i++)
        {
            try
            {
                managedData.UpdateUnsafe(outputPtr[i]);   // Bind
            }
            catch
            {
                (dataPtr + i)->Status = Canceled;          // 붙잡은 배열에 쓴다
                OnCancelAction?.Invoke();
            }
    
            if (Completed && WasStatusChanged)
                managedData.InvokeOnComplete();            // 여기서 새 모션을 만들 수 있다
        }
    }

    float 저장소가 32칸이고 모션이 31개, K 는 뒤쪽 칸에 있다고 합니다.

    프레임 일 dataPtr 이 가리키는 배열 저장소 배열 K
    N 루프 시작 옛 배열 (32칸) 옛 배열 Playing
    N 맨 앞 모션의 OnComplete 에서 모션 10개 생성 → Array.Resize 옛 배열 새 배열 (64칸) Playing
    N K 의 Bind 가 던진다 → Canceled 를 dataPtr 에 쓴다 옛 배열 새 배열 옛 배열만 Canceled, OnCancel 호출
    N+1 새 배열을 붙잡고 다시 돈다 새 배열 새 배열 Playing, Bind 를 프레임마다 받음

    취소 콜백은 불렸는데 새 배열의 K 는 Playing 그대로라 계속 돕니다. IsValid 와 IsActive 도 참입니다. 새로 만든 10개는 새 배열에서 정상으로 재생됩니다.

    옛 배열은 fixed 로 고정돼 있어 메모리가 깨지지는 않지만, 루프가 그 뒤 옛 배열에 쓴 상태는 새 배열에 옮겨지지 않습니다.

    경우 결과
    위 그림 그대로: 모션 31개인 32칸 저장소에서 맨 앞 모션의 OnComplete 가 10개를 더 만들고, 같은 프레임 뒤 칸 K(WithCancelOnError)의 Bind 가 던짐 K 의 OnCancel 은 한 번 불렸는데 5프레임 내내 IsActive() 가 참이고 Bind 를 계속 받음. 새 10개는 정상 재생
    EnsureStorageCapacity(128) 로 배열이 커지지 않게 한 대조 취소된 모션이 다음 프레임부터 멈춤
    앞 칸 P 의 Bind 에서 뒤 칸 Q 를 Complete() Q 의 OnComplete 가 두 번, 마지막 바인딩 값이 끝값 1 이 아니라 그 프레임 Job 이 계산한 0.03 안팎

    마지막 줄은 Complete() 가 한 번, 루프가 Q 칸에 와서 “이번에 완료로 바뀐 칸” 으로 또 한 번 OnComplete 를 부르고, 그 앞에서 Job 이 계산해 둔 중간값을 다시 쓴 것입니다.

    주의할 점

    1. Job 을 쓰니 메인 스레드가 비어 있다고 보지 않는다

    안 지키면 생기는 문제. 러너는 Job 이 끝날 때까지 메인 스레드를 세워 두고, 그 뒤 콜백 루프도 메인 스레드입니다. 프로파일러에서 LitMotion 몫이 메인 스레드에 그대로 잡히는 것을 보고 헤매게 되고, 콜백이 무거우면 병렬 계산의 이득이 거기서 묻힙니다.

    이렇게 쓴다. 러너 구간 전체를 LitMotion 의 메인 스레드 비용으로 봅니다. Bind 콜백에서는 대입만 하고, 레이아웃 재계산을 부르는 호출이나 문자열 조립 같은 무거운 일을 하지 않습니다.

    2. 콜백 안에서 같은 조합의 모션을 만들 거면 용량을 미리 잡는다

    안 지키면 생기는 문제. OnComplete 에서 다음 모션을 이어 거는 체인이 저장소를 키우는 프레임에는, 같은 프레임의 취소가 옛 배열에만 쓰여 사라집니다. 모션 수가 32, 64, 128 을 처음 넘는 프레임에만 생겨 재현이 드뭅니다.

    이렇게 쓴다. 로딩 때 MotionDispatcher.EnsureStorageCapacity<T, O, A>(n) 으로 동시에 살아 있을 최대치만큼 잡아 둡니다. 한 프레임 늦어도 되면 콜백에서는 플래그만 세우고 다음 Update 에서 모션을 만듭니다.

    3. 콜백 안에서 다른 모션을 Complete 하지 않는다

    안 지키면 생기는 문제. 콜백 루프가 아직 지나지 않은 뒤 칸의 모션을 Complete() 하면, 루프가 그 칸에 와서 OnComplete 를 한 번 더 부르고 Job 이 계산한 중간값을 다시 씁니다. 스킵 버튼이 끝값 대신 중간에 멈춘 것처럼 보입니다.

    이렇게 쓴다. 다른 모션을 끝내는 일은 LitMotion 콜백 밖, 예를 들어 MonoBehaviour.Update 에서 합니다. 콜백 안에서 해야 하면 플래그를 세워 두고 다음 프레임에 처리합니다.

    정리

    LitMotion 은 Burst Job 으로 나눠 계산하고 그 자리에서 기다립니다.

    • 러너의 한 프레임은 버퍼, 델타, Job, 콜백, 제거 다섯 단계이고, 모션 데이터는 C# 배열을 고정한 포인터로 넘어갑니다.
    • Schedule(count, 16).Complete() 는 동기 병렬입니다. 묶음이 하나뿐이면 기다리던 메인 스레드가 직접 처리합니다.
    • 콜백 루프는 루프 전에 고정한 배열을 끝까지 씁니다. 콜백 안에서 배열을 키우거나 다른 모션을 끝내면 상태가 어긋납니다.