---
title: "LitMotion 은 Burst Job 으로 나눠 계산하고 그 자리에서 기다린다"
description: "큐브 천 개를 손으로 보간하면 메인 스레드가 하나씩 계산합니다. LitMotion 은 계산을 Burst Job 으로 여러 코어에 나누되 예약한 줄에서 곧바로 기다립니다. 값은 이번 프레임에 들어가고, 콜백은 메인 스레드에 남습니다."
author: "유니얼"
pubDate: 2026-09-26
updatedDate: 2026-10-18
category: "game-dev/unity/libraries"
tags: ["Unity", "라이브러리", "트윈", "DOTS"]
canonical: https://blog.unialgames.com/posts/unity-litmotion-job/
---
```csharp
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]` 으로 끕니다. 복사가 없는 대신 배열이 바뀌는 순간을 조심해야 합니다(마지막 절).

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

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

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

<x-compare>
<script type="application/json">
{
  "label": "Job 을 맡기고 결과를 받는 세 가지 방법",
  "tabs": [
    { "label": "다음 프레임까지 두기", "code": "handle = job.Schedule(count, 16);\n// ... 다음 프레임에\nhandle.Complete();", "text": "메인 스레드가 그동안 다른 일을 할 수 있다. 대신 값이 한 프레임 늦게 들어가고, 그 사이에 모션을 만들거나 취소하면 Job 이 쓰는 배열을 메인 스레드가 동시에 건드린다.", "tone": "bad" },
    { "label": "메인 스레드 for 문", "code": "for (int i = 0; i < count; i++)\n    Update(ref data[i]);", "text": "가장 단순하다. 코어를 하나만 쓰므로 모션이 수천 개일 때 계산이 한 줄로 늘어선다.", "tone": "bad" },
    { "label": "Schedule + Complete (LitMotion)", "code": "job.Schedule(count, 16).Complete();", "text": "계산은 여러 코어에 나누고, 값은 이번 프레임에 반영된다. Job 이 도는 동안 배열을 만질 수 있는 다른 코드가 돌지 않으니 경합이 없다. 메인 스레드는 그동안 기다리며, 아직 아무도 가져가지 않은 묶음이 있으면 직접 처리한다.", "tone": "good" }
  ]
}
</script>
</x-compare>

값을 이번 프레임에 받아야 하는 이유는 러너가 도는 자리에 있습니다. 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` 가 배열을 새로 만듭니다. 루프는 옛 배열에 계속 씁니다.

```csharp
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()` 는 동기 병렬입니다. 묶음이 하나뿐이면 기다리던 메인 스레드가 직접 처리합니다.
- 콜백 루프는 루프 전에 고정한 배열을 끝까지 씁니다. 콜백 안에서 배열을 키우거나 다른 모션을 끝내면 상태가 어긋납니다.