---
title: "LitMotion 은 PlayerLoop 맨 앞에 끼어들어 모션을 돌린다"
description: "코루틴은 MonoBehaviour 가, Update 보간은 컴포넌트가 돌립니다. LitMotion 에는 그런 주인이 없습니다. 씬보다 먼저 PlayerLoop 여덟 단계의 맨 앞에 끼어들고, 타입과 타이밍마다 하나인 러너를 정적 필드로 찾아 돌립니다."
author: "유니얼"
pubDate: 2026-09-26
updatedDate: 2026-10-18
category: "game-dev/unity/libraries"
tags: ["Unity", "라이브러리", "트윈"]
canonical: https://blog.unialgames.com/posts/unity-litmotion-dispatcher/
---
```csharp
void Awake()
{
    LMotion.Create(0f, 1f, 0.5f)
        .WithScheduler(MotionScheduler.PreLateUpdateIgnoreTimeScale)
        .Bind(this, static (x, self) => self.transform.localScale = Vector3.one * x);
}
```

코루틴이라면 `StartCoroutine` 을 부른 `MonoBehaviour` 가, 손으로 쓴 보간이라면 그 컴포넌트의 `Update` 가 값을 밉니다. 위 모션에는 그런 주인이 없고 씬에 숨긴 게임오브젝트도 없습니다.

LitMotion 은 **씬보다 먼저 PlayerLoop 여덟 단계 맨 앞에 자기 시스템을 끼워 넣고**, 그 자리에서 (값 타입 × 타이밍) 마다 하나인 러너를 돌립니다. 기준 판본은 2.0.2 입니다.

## 씬보다 먼저 여덟 단계 맨 앞에 꽂는다

`PlayerLoopHelper` 는 `[RuntimeInitializeOnLoadMethod(AfterAssembliesLoaded)]` 에서, 곧 어떤 `Awake` 보다 먼저 `Initialization` 부터 `TimeUpdate` 까지 최상위 여덟 단계에 시스템을 하나씩 꽂습니다. 꽂는 자리는 늘 0번이고, 같은 이름표가 이미 있으면 걸러 낸 뒤 넣습니다.

```csharp
// 같은 이름표가 이미 있으면 걸러 낸다
var source = subSystemList.Where(x => x.type != loopRunnerType).ToArray();

var dest = new PlayerLoopSystem[source.Length + 1];   // 한 칸 더 큰 배열
Array.Copy(source, 0, dest, 1, source.Length);        // 0번을 비우고 1번부터 복사

dest[0] = new PlayerLoopSystem { type = loopRunnerType, updateDelegate = updateDelegate };   // 맨 앞에 넣는다
return dest;
```

| 언제 | 꽂기 전 Update 하위 | 꽂은 뒤 |
| --- | --- | --- |
| 처음 | `[BehaviourUpdate, DelayedTasks]` | `[LitMotionUpdate, BehaviourUpdate, DelayedTasks]` |
| 한 번 더 | `[LitMotionUpdate, BehaviourUpdate, DelayedTasks]` | 같음. 옛것을 거르고 넣어 한 개 |

여덟 단계 모두 같은 코드로 맨 앞에만 꽂습니다. 단계 끝에 꽂는 경로는 없습니다.

그래서 같은 `Update` 단계에서 LitMotion 은 모든 `MonoBehaviour.Update` 보다 먼저 돌고, 몇 번을 꽂아도 한 벌입니다. 어떤 `Update` 에서 모션을 만들면 그 프레임의 LitMotion 차례는 이미 지났으므로 첫 갱신은 다음 프레임입니다.

"맨 앞" 은 꽂는 순간의 자리입니다. 측정 프로젝트에는 PlayerLoop 에 시스템을 꽂는 다른 패키지가 둘 있었고, 여덟 단계 모두에서 LitMotion 은 3번 자리였습니다. 그래도 `ScriptRunBehaviourUpdate` 보다는 앞이었습니다.

## 러너는 제네릭 정적 필드로 찾는다

모션을 만들 때 저장소와 러너를 찾는 데 사전 조회가 없습니다. `StorageCache<TValue, TOptions, TAdapter>` 는 제네릭 정적 클래스라 타입 인자마다 따로 존재하고, 타이밍 여덟 개에 필드가 하나씩 있습니다. `float` 모션을 `Update` 에서 처음 만들 때 채워지는 것입니다.

<x-wiring>
<script type="application/json">
{
  "label": "float 모션을 Update 에서 처음 만들 때 채워지는 것",
  "width": 790,
  "height": 380,
  "nodes": [
    { "id": "b", "title": "Bind()", "sub": "빌더가 스케줄을 요청", "x": 0, "y": 24, "w": 170 },
    { "id": "d", "title": "MotionDispatcher", "sub": "Schedule(ref builder, Update)", "x": 300, "y": 24, "w": 190 },
    { "id": "s", "title": "StorageCache<float,…>", "sub": "정적 필드 update", "x": 0, "y": 160, "w": 190 },
    { "id": "r", "title": "RunnerCache<float,…>", "sub": "정적 필드 update", "x": 600, "y": 160, "w": 190 },
    { "id": "m", "title": "MotionManager", "sub": "전역 스토리지 목록", "x": 0, "y": 310, "w": 190 },
    { "id": "c", "title": "MotionStorage", "sub": "Create: 꼬리에 붙인다", "x": 310, "y": 310, "w": 170 },
    { "id": "l", "title": "Update 러너 목록", "sub": "FastListCore<IUpdateRunner>", "x": 600, "y": 310, "w": 190 }
  ],
  "edges": [
    { "from": "b", "to": "d", "label": "스케줄 요청", "bend": 14 },
    { "from": "d", "to": "s", "label": "GetOrCreate(Update)", "bend": 0 },
    { "from": "s", "to": "m", "label": "null 이면 new, Register", "bend": 0 },
    { "from": "d", "to": "r", "label": "GetOrCreate(Update, storage)", "bend": 0 },
    { "from": "r", "to": "l", "label": "null 이면 new, Add", "bend": 10 },
    { "from": "d", "to": "c", "label": "Create(ref builder)", "bend": 0 },
    { "from": "d", "to": "b", "label": "MotionHandle", "bend": 14 }
  ],
  "steps": [
    { "edge": 0, "why": "스케줄러를 지정하지 않았고 기본 스케줄러가 Update 라서, 인터페이스를 거치지 않고 MotionDispatcher.Schedule 을 바로 부릅니다." },
    { "edge": 1, "why": "float 조합의 Update 스토리지 필드를 읽습니다. 처음이라 null 입니다." },
    { "edge": 2, "why": "스토리지를 새로 만들고 전역 목록에 등록합니다. 등록 직전의 목록 길이가 이 스토리지의 Id 입니다." },
    { "edge": 3, "why": "러너 필드도 처음이라 null 입니다. 러너를 만들면서 지금 시각을 직전 시각으로 적어 둡니다." },
    { "edge": 4, "why": "새 러너를 Update 타이밍 러너 목록 끝에 붙입니다. 이 목록에서 빼는 코드는 없습니다." },
    { "edge": 5, "why": "스토리지 꼬리 칸에 모션을 씁니다." },
    { "edge": 6, "why": "핸들을 돌려줍니다. 두 번째 float 모션부터는 두 필드가 이미 차 있어 등록과 러너 생성 없이 곧장 Create 로 갑니다." }
  ],
  "note": "새 조합을 처음 쓰는 순간에만 스토리지, 러너, 네이티브 할당자가 새로 생깁니다."
}
</script>
</x-wiring>

새 조합을 처음 쓰는 순간에만 저장소와 러너가 생기고, 러너 목록에서 빼는 코드는 없습니다. 도는 쪽은 매 프레임 `GetRunnerList(timing).AsSpan()` 으로 **그 순간의 길이로 자른 구간**을 돌아서, 콜백 안에서 새 러너가 생겨도 예외 없이 다음 프레임부터 돕니다.

## 러너는 자기 직전 시각과 빼서 델타를 만든다

디스패처는 러너에게 델타가 아니라 절대 시각 셋(`Time.timeAsDouble`, `unscaledTimeAsDouble`, `realtimeSinceStartupAsDouble`)을 넘기고, 러너가 자기 필드의 직전 시각과 빼서 델타를 만듭니다. 러너는 태어날 때 **지금 시각**을 적는데, `MotionDispatcher.Clear()` 가 부르는 `Reset()` 은 **0** 을 적습니다. 게임 600초에 두 경우의 첫 갱신입니다.

```csharp
// 러너를 처음 만들 때 (RunnerCache)
prevTime = Time.timeAsDouble;      // 지금 시각을 적는다

// MotionDispatcher.Clear() → Reset()
prevTime = 0;                      // 0 을 적는다

// 매 갱신 (UpdateRunner.Update)
var deltaTime = time - prevTime;
prevTime = time;

// Job: state.Time += deltaTime * PlaybackSpeed
```

게임 600초에 1초짜리 모션을 만든 두 경우의 첫 갱신입니다.

| 경우 | `prevTime` | 다음 프레임(600.02초)의 델타 | 결과 |
| --- | --- | --- | --- |
| 그 타입을 처음 씀 | 600.00 | 0.02 | 진행률 2% |
| `Clear()` 하고 바로 만듦 | 0 | 600.02 | 첫 갱신에 끝남 |

러너를 만들 때는 지금 시각으로, `Reset` 할 때는 0 으로 적는 비대칭이 차이를 만듭니다.

`Clear()` 직후 10초짜리 모션을 만들었더니 첫 프레임 진행률이 0.1233, 곧 1.2332초가 한 번에 흘렀습니다. 정상 프레임은 0.0018초였고 1.23초는 그 플레이가 시작된 뒤 흐른 시간입니다. 게임을 한참 한 뒤라면 짧은 모션은 만든 다음 프레임에 끝납니다.

## 스케줄러는 타이밍과 시계를 고른다

`WithScheduler` 로 넘기는 내장 스케줄러는 **타이밍 하나와 시계 종류 하나**를 든 객체입니다. 시계 종류(`Time`, `UnscaledTime`, `Realtime`)는 러너가 아니라 모션 데이터에 적히고, Job 이 모션마다 세 델타 중 하나를 고릅니다. 그래서 `timeScale` 을 따르는 모션과 무시하는 모션이 한 러너에서 같이 돕니다. `FixedUpdate` 타이밍만 `Time` 시계 하나입니다.

`MotionScheduler.DefaultScheduler` 는 공개 설정값이라 바꾸면 스케줄러를 지정하지 않은 모든 모션이 영향을 받고, 정적 값이라 씬을 넘어 남습니다.

## 시간을 직접 미는 ManualMotionDispatcher

테스트나 컷신 스크러빙처럼 PlayerLoop 대신 내가 시간을 밀 때 쓰는 디스패처입니다. `Update(deltaTime)` 이 자기 시계를 더하고, 러너 사전을 `foreach` 로 돌며 세 시계에 같은 값을 넘깁니다. 러너가 `Dictionary` 라서, 갱신 중인 콜백에서 **같은 디스패처로 처음 보는 타입**의 모션을 만들면 열거 도중 사전이 바뀝니다.

```csharp
d.Update(1.0);
    foreach (var runner in runners)            // 러너 사전(Dictionary)을 돈다
        runner.Update(...);                    // float 모션이 끝나 OnComplete 가 여기서 불린다
            // 콜백 안: 같은 디스패처로 처음 보는 Vector3 모션을 만든다
            LMotion.Create(Vector3.zero, Vector3.one, 1f).WithScheduler(d)...
                runners.Add(새 Vector3 러너);  // 열거 도중 사전이 바뀐다
    // 다음 MoveNext 에서 InvalidOperationException

d.Update(0.1);                                 // 새 러너가 이미 들어가 있어 예외 없음
```

모션 생성 자체는 성공하고, 예외는 그 `d.Update(1.0)` 이 다음 러너로 넘어가려는 순간 납니다. 같은 타입의 모션은 그 뒤로 몇 번을 만들어도 사전이 바뀌지 않아 예외가 나지 않습니다. 전역 디스패처는 길이를 잘라 둔 구간을 돌아 이 문제를 피합니다.

실측은 소스대로였습니다.

| 본 것 | 결과 |
| --- | --- |
| 콜백 안에서 새 타입 모션 생성 | `Collection was modified; enumeration operation may not execute.` |
| 시계를 100초까지 민 뒤 처음 쓰는 타입의 10초 모션에 `Update(0.1)` | `handle.Time` 이 0.1 이 아니라 100.1, 그 한 번에 끝남 |
| `new ManualMotionDispatcher()` 에 모션 하나씩 100번 | 전역 저장소 목록 +100, GC 뒤에도 그대로. 인스턴스 하나에 140 KB 남짓 |

새 러너는 디스패처의 지금 시각이 아니라 0 을 직전 시각으로 적고, 인스턴스마다 만든 저장소는 전역 목록에서 빠지지 않습니다.

## 주의할 점

### 1. 게임 도중 MotionDispatcher.Clear() 를 부르지 않는다

**안 지키면 생기는 문제.** `Clear()` 는 러너의 직전 시각을 0 으로 되돌리는데 `Time.time` 은 계속 흐릅니다. 직후에 만든 모션은 첫 갱신에서 게임 시작부터 지금까지를 한 번에 받아, 짧은 모션은 만든 다음 프레임에 끝납니다.

**이렇게 쓴다.** 모션을 한꺼번에 끝내려면 핸들을 `TryCancel()` 로 하나씩 끊거나 `AddTo(gameObject)` 로 수명을 묶습니다. 꼭 `Clear()` 를 써야 하면 모든 타이밍이 한 번씩 돈 다음 프레임부터 새 모션을 만듭니다. 한 번 돈 러너는 직전 시각이 다시 지금 시각이 됩니다.

### 2. PlayerLoop 를 바꾸는 코드는 지금 루프를 읽어서 더한다

**안 지키면 생기는 문제.** `PlayerLoop.SetPlayerLoop()` 는 루프를 통째로 바꿉니다. LitMotion 이 꽂기 전에 읽어 둔 루프를 나중에 넣으면 LitMotion 의 여덟 시스템이 빠지고, 모션은 만들어지는데 움직이지 않습니다. 최상위 단계를 지운 루프를 넘기면 LitMotion 초기화가 `Target PlayerLoopSystem does not found` 로 멈춥니다.

**이렇게 쓴다.** `SetPlayerLoop` 직전에 `GetCurrentPlayerLoop()` 로 지금 루프를 읽어 거기에 더합니다. 초기화 시점은 `BeforeSceneLoad` 이후로 두고, 최상위 여덟 단계는 지우지 않습니다.

### 3. ManualMotionDispatcher 는 한 번 만든 인스턴스를 계속 쓴다

**안 지키면 생기는 문제.** 인스턴스마다 전역에 저장소가 쌓이고 빠지지 않습니다. 콜백 안에서 새 타입 모션을 만들면 예외가 나고, 시계가 흐른 뒤 처음 쓰는 타입은 첫 갱신에 시계 전체를 델타로 받습니다.

**이렇게 쓴다.** `ManualMotionDispatcher.Default` 나 용도별로 한 번 만든 인스턴스를 쓰고 끝나면 `Reset()` 합니다. 쓸 타입은 시계를 밀기 전에 `dispatcher.EnsureStorageCapacity<T, O, A>(n)` 으로 러너를 미리 만들어 둡니다.

## 정리

LitMotion 은 **PlayerLoop 맨 앞에 끼어들어** 주인 없이 모션을 돌립니다.

- `AfterAssembliesLoaded` 에 여덟 단계 맨 앞에 꽂아, 같은 단계의 `MonoBehaviour` 갱신보다 먼저 돕니다.
- 저장소와 러너는 제네릭 정적 필드라 조회가 없고, 한 번 생긴 러너는 빠지지 않습니다.
- 러너는 절대 시각에서 자기 직전 시각을 빼 델타를 만듭니다. `Clear()` 는 그 직전 시각을 0 으로 되돌립니다.