---
title: "LitMotion 의 시퀀스는 자식의 시각을 밀어 주는 모션 하나다"
description: "코루틴으로 연출을 이으면 yield 로 차례를 잇고 동시에 도는 것은 코루틴을 더 띄웁니다. LitMotion 의 LSequence 는 0 에서 전체 길이까지 가는 double 모션 하나를 만들고, 그 값을 시각으로 삼아 자식마다 SetTime 을 부릅니다."
author: "유니얼"
pubDate: 2026-09-26
updatedDate: 2026-10-18
category: "game-dev/unity/libraries"
tags: ["Unity", "라이브러리", "트윈"]
canonical: https://blog.unialgames.com/posts/unity-litmotion-sequence/
---
```csharp
var handle = LSequence.Create()
    .Append(LMotion.Create(0f, 1f, 1f).BindToPositionX(transform))         // 0초부터
    .Join(LMotion.Create(Color.white, Color.red, 1f).BindToColor(sprite))  // 같이 시작
    .AppendInterval(0.5f)                                                   // 0.5초 쉬고
    .Append(LMotion.Create(1f, 2f, 1f).BindToPositionX(transform))         // 1.5초부터
    .Run();
```

코루틴이라면 `yield return` 으로 차례를 잇고, 동시에 도는 것은 코루틴을 하나 더 띄웁니다. 되감거나 중간으로 건너뛰려면 따로 짜야 합니다.

LitMotion 의 `LSequence` 에는 따로 도는 엔진이 없습니다. `Run()` 은 **0 에서 전체 길이까지 가는 `double` 모션 하나**를 만들고, 그 값이 바뀔 때마다 자식 모션들에게 "너는 지금 몇 초다" 를 `SetTime` 으로 알려 줍니다. 기준 판본은 2.0.2 입니다.

## Append 와 Join 은 tail 두 개로 자리를 정한다

빌더는 다음 `Append` 가 놓일 `tail`, 그 직전의 `tail` 인 `lastTail`, 전체 길이 `duration` 셋으로 자리를 계산합니다. `Append` 는 자기 길이만큼 `AppendInterval` 을 부르고, `Join` 은 `lastTail` 에 `Insert` 합니다.

```csharp
public void Append(MotionHandle handle)
{
    AddItem(new MotionSequenceItem(tail, handle));   // 지금 tail 에 놓는다
    AppendInterval(TotalDuration);                   // 그 길이만큼 tail 을 민다
}

public void AppendInterval(double interval)
{
    lastTail = tail;                                 // 밀기 전 위치를 기억한다
    tail += interval;
    duration = Math.Max(duration, tail);
}

public void Insert(double position, MotionHandle handle)
{
    AddItem(new MotionSequenceItem(position, handle));
    duration = Math.Max(duration, position + TotalDuration);   // tail 은 건드리지 않는다
}

public void Join(MotionHandle handle)
{
    Insert(lastTail, handle);                        // 마지막으로 민 자리의 시작에 붙인다
}
```

| 호출 | 놓이는 위치 | tail | lastTail | duration |
| --- | --- | --- | --- | --- |
| 시작 | | 0 | 0 | 0 |
| `Append(A)`, 1초 | A@0 | 1 | 0 | 1 |
| `Join(B)`, 2초 | B@0 | 1 | 0 | 2 |
| `Append(C)`, 1초 | C@1 | 2 | 1 | 2 |
| `AppendInterval(0.5)` | | 2.5 | 2 | 2.5 |
| `Join(D)`, 1초 | D@2 | 2.5 | 2 | 3 |

C 는 B 가 끝나는 2 가 아니라 tail 인 1 에 놓입니다. Join 은 tail 을 늘리지 않기 때문입니다. D 는 인터벌 뒤 2.5 가 아니라 인터벌 시작 2 에 붙습니다. AppendInterval 도 lastTail 을 옮기기 때문입니다.

- **`Join` 한 모션이 길어도 다음 `Append` 는 밀리지 않습니다.** `Join` 은 전체 길이만 늘리고 `tail` 은 그대로 둡니다.
- **`AppendInterval` 바로 뒤의 `Join` 은 쉬기 전 자리에 붙습니다.** `AppendInterval` 도 `lastTail` 을 옮기기 때문입니다.

`Insert(초, 모션)` 은 둘 다 건드리지 않고 절대 위치에 놓습니다.

## 넣는 순간 자식은 시퀀스 것이 된다

`Append`, `Join`, `Insert` 는 먼저 자식을 검사하고 두 플래그를 찍습니다.

| 검사, 표시 | 결과 |
| --- | --- |
| 상태가 `Scheduled` 가 아니면 예외 | 모션은 첫 갱신에서 `Scheduled` 를 벗어나므로 **만든 프레임 안에서** 넣어야 합니다 |
| 길이가 무한이면 예외 | `WithLoops(-1)` 은 시간표에 놓을 수 없습니다 |
| `IsInSequence = true` | 자기 Job 과 콜백 루프에서 빠지고, 사용자가 `Cancel`, `Complete`, `Time` 을 부르면 `Cannot access the motion in sequence.` |
| `IsPreserved = true` | 스스로 끝나도 저장소에서 빠지지 않습니다. 시퀀스가 되감을 수 있어야 해서입니다 |

## 시퀀스는 double 모션 하나다

```csharp
// Runtime/MotionSequenceBuilder.cs (줄임)
var builder = LMotion.Create(0.0, duration, (float)duration)
    .WithOnComplete(source.OnCompleteDelegate)
    .WithOnCancel(source.OnCancelDelegate);
configuration?.Invoke(builder);
var handle = builder.Bind(source, (x, source) => source.Time = x);
```

기본 이징이 선형이라 값이 곧 경과 시각입니다. 돌려받는 핸들도 이 모션의 핸들이라 `Cancel`, `Complete`, `Preserve`, `Time`, `PlaybackSpeed` 가 평범한 모션처럼 동작합니다. 한 프레임의 흐름입니다.

<x-wiring>
<script type="application/json">
{
  "label": "시퀀스 한 프레임: double 모션의 값이 자식들의 시각이 되는 길",
  "width": 640,
  "height": 520,
  "nodes": [
    { "id": "job", "title": "MotionUpdateJob<double, …>", "sub": "시퀀스 모션 시각 += dt", "x": 10, "y": 8, "w": 210, "h": 54 },
    { "id": "run", "title": "UpdateRunner<double, …>", "sub": "Bind 콜백 호출", "x": 10, "y": 120, "w": 210, "h": 54 },
    { "id": "src", "title": "MotionSequenceSource", "sub": "Time = x", "x": 10, "y": 232, "w": 210, "h": 54 },
    { "id": "cj", "title": "자식의 MotionUpdateJob", "sub": "IsInSequence 면 return", "x": 10, "y": 344, "w": 210, "h": 54, "aside": true },
    { "id": "st", "title": "자식 MotionStorage", "sub": "Update<TAdapter> 직접", "x": 420, "y": 344, "w": 210, "h": 54 },
    { "id": "mm", "title": "MotionManager.SetTime", "sub": "StorageId 로 저장소", "x": 420, "y": 232, "w": 210, "h": 54 },
    { "id": "cb", "title": "자식 Bind 콜백", "sub": "transform.position 등", "x": 420, "y": 456, "w": 210, "h": 54, "aside": true }
  ],
  "edges": [
    { "from": "job", "to": "run", "label": "x = 경과 시각", "bend": 0 },
    { "from": "run", "to": "src", "label": "source.Time = x", "bend": 0 },
    { "from": "src", "to": "mm", "label": "자식마다 x - Position", "bend": 0 },
    { "from": "mm", "to": "st", "label": "SetTime", "bend": 0 },
    { "from": "st", "to": "cb", "label": "값 쓰기, 콜백", "bend": 0 }
  ],
  "steps": [
    { "node": "cj", "why": "자식들의 Job 도 이번 프레임에 돌지만, IsInSequence 를 보고 아무것도 하지 않습니다. 시각은 여기서 흐르지 않습니다." },
    { "edge": 0, "why": "시퀀스 자신은 평범한 double 모션입니다. Burst Job 이 시각을 더하고 0 에서 duration 사이의 값을 냅니다. 이징이 선형이면 그 값이 곧 경과 시각입니다." },
    { "edge": 1, "why": "러너가 메인 스레드에서 바인딩 콜백을 부르고, 콜백이 그 값을 source.Time 에 넣습니다." },
    { "edge": 2, "why": "Time setter 가 자식마다 자기 위치를 뺀 시각을 들고 SetTime 을 부릅니다. 어떤 순서로 부르는지는 아래 절에서 봅니다." },
    { "edge": 3, "why": "핸들의 StorageId 로 자식의 저장소를 찾아 인터페이스로 부릅니다." },
    { "edge": 4, "why": "저장소가 그 자리에서 Update<TAdapter> 를 불러 값을 계산하고 자식의 바인딩, 루프 완료, 완료 콜백까지 바로 부릅니다. 전부 메인 스레드입니다." }
  ],
  "note": "자식의 값 계산은 Burst Job 이 아니라 메인 스레드의 SetTime 안에서 일어납니다."
}
</script>
</x-wiring>

그래서 `Run(b => b.WithEase(Ease.InOutQuad))` 의 이징은 자식이 아니라 **시계**에 걸리고, `WithLoops(2, LoopType.Yoyo)` 면 시퀀스 전체가 되감깁니다. 그리고 자식의 값 계산은 Burst Job 이 아니라 메인 스레드의 `SetTime` 안에서 하나씩 일어납니다. 10초 `float` 모션 1,000개를 따로 돌린 것과 한 시퀀스에 `Join` 한 것의 LitMotion 갱신 단계 증가분입니다.

| 모션 1,000개 | Mono | IL2CPP |
| --- | --- | --- |
| 따로 돌림 | +0.140 ms | +0.156 ms |
| 한 시퀀스에 Join | +0.362 ms | +0.242 ms |

200프레임을 잰 값입니다. 조립도 공짜가 아닙니다. 자식 셋짜리 시퀀스를 한 프레임에 100개 만들었더니 시퀀스당 1,264 B(IL2CPP 1,272 B) 였습니다. 소스 객체, 델리게이트 둘, `ArrayPool` 이 새로 만든 32칸 항목 배열 몫이고, 풀로 돌아가는 것은 시퀀스가 끝나거나 취소될 때라 동시에 살아 있는 시퀀스 수만큼 쌓입니다.

## 시각은 뒤에서 한 번, 앞에서 한 번 쓴다

`Time` 이 들어오면 위치 순으로 정렬한 자식 목록을 두 번 훑습니다. 아직 시작 전인 자식(`Position >= time`)을 **뒤에서 앞으로**, 이미 시작한 자식을 **앞에서 뒤로** 씁니다. 같은 `x` 를 A(0→1)와 B(1→2)가 이어서 움직이는 경우입니다.

<x-compare>
<script type="application/json">
{
  "label": "같은 x 를 A(0→1), B(1→2) 가 이어서 움직일 때 시각을 쓰는 순서",
  "tabs": [
    {
      "label": "LitMotion, time 0.5",
      "code": "time = value;\nvar index = span.Length - 1;\n\n// 뒤에서부터: 아직 시작 전인 자식\nwhile (index >= 0 && span[index].Position >= time)\n{\n    SetTime(span[index].Handle, time - span[index].Position);   // 음수 시간, 시작값을 쓴다\n    index--;\n}\n\n// 앞에서부터: 이미 시작한 자식\nforeach (var item in span[..(index + 1)])\n    SetTime(item.Handle, time - item.Position);",
      "text": "B(위치 1) 는 아직 시작 전이라 뒤쪽 반복에서 -0.5초를 받고 자기 시작값 1 을 씁니다. 그다음 앞쪽 반복에서 A 가 0.5초를 받아 0.5 를 씁니다. 마지막에 쓴 A 가 이겨 x = 0.5 입니다.",
      "tone": "good"
    },
    {
      "label": "LitMotion, time 1.5",
      "code": "time = value;\nvar index = span.Length - 1;\n\n// 뒤에서부터: 아직 시작 전인 자식\nwhile (index >= 0 && span[index].Position >= time)\n{\n    SetTime(span[index].Handle, time - span[index].Position);   // 음수 시간, 시작값을 쓴다\n    index--;\n}\n\n// 앞에서부터: 이미 시작한 자식\nforeach (var item in span[..(index + 1)])\n    SetTime(item.Handle, time - item.Position);",
      "text": "둘 다 시작해서 뒤쪽 반복은 할 일이 없습니다. 앞에서부터 A 가 1.5초를 받아 끝값 1, B 가 0.5초를 받아 1.5 를 씁니다. 가장 늦게 시작한 B 가 이겨 x = 1.5 입니다.",
      "tone": "good"
    },
    {
      "label": "앞에서 한 번만 (가정)",
      "code": "time = value;\nforeach (var item in span)\n    SetTime(item.Handle, time - item.Position);",
      "text": "time 0.5 에서 A 가 0.5 를 쓴 뒤 B 가 -0.5초로 자기 시작값 1 을 씁니다. A 의 진행이 덮여 x = 1 이라는 틀린 값이 남습니다.",
      "tone": "bad"
    }
  ]
}
</script>
</x-compare>

시작 전 자식은 가장 가까운 것이, 시작한 자식은 가장 늦게 시작한 것이 마지막에 씁니다. 그래서 되감기와 점프에서도 한 속성이 지금 맡은 자식의 값으로 모입니다.

이 장치는 시작 전 자식이 시작값을 쓴다는 데 기댑니다. 그런데 `WithDelay(d)` 의 세 번째 인자 `skipValuesDuringDelay` 는 기본이 `true` 라, 딜레이를 건 자식은 시작 전에 값을 쓰지 않습니다. A 다음에 0.5초 딜레이를 건 B(5→6)를 붙인 보존 시퀀스를 끝까지 재생하고 `h.Time = 0.5` 로 되감았더니, A 는 0.5 로 돌아왔는데 B 가 맡은 값은 끝값 6 에 남았습니다. 세 번째 인자를 `false` 로 주면 5 로 돌아갔습니다.

같은 위치에 놓인 자식끼리의 순서는 위치 하나만 보는 `Array.Sort` 가 정하고, 같은 키의 순서를 지킨다는 약속이 없습니다. 이 환경들에서는 자식 40개까지 마지막에 `Join` 한 자식의 값이 남았지만 API 가 보장하는 결과는 아닙니다.

## 끝나면 자식을 Cancel 로 치운다

자식은 보존 상태라 스스로 빠지지 않습니다. 시퀀스의 완료 콜백과 취소 콜백이 자식 전원을 `Cancel` 합니다. 그런데 보존된 완료 모션은 취소가 허용되고, 그때 `OnCancel` 이 불립니다. 0.2초 자식 하나를 넣은 시퀀스를 1초 기다린 뒤 자식 콜백 기록입니다.

| 시퀀스 | 자식이 받은 콜백 |
| --- | --- |
| 그냥 `Run()` | 완료, **취소** |
| `Run().Preserve()` | 완료 |
| 보존한 시퀀스를 그 뒤 `Cancel()` | 완료, 취소 |

정상으로 끝난 시퀀스에서도 자식은 완료와 취소를 둘 다 받습니다.

## 주의할 점

### 1. 자식 모션은 만든 그 자리에서 넣는다

**안 지키면 생기는 문제.** 한 프레임이라도 지난 모션을 넣으면 `ArgumentException("Cannot add a running motion to a sequence.")` 입니다. 모션을 필드에 만들어 두었다가 버튼을 누를 때 조립하는 코드가 여기 걸립니다.

**이렇게 쓴다.** 조립하는 메서드 안에서 자식을 만들고 바로 넣습니다. 미리 준비하려면 모션이 아니라 모션을 만드는 메서드를 들고 있습니다. 반복은 무한 루프 자식 대신 `Run(b => b.WithLoops(-1))` 로 겁니다.

### 2. AppendInterval 바로 뒤에는 Append 로 시작한다

**안 지키면 생기는 문제.** `AppendInterval(0.5f).Join(x)` 는 쉬기 전 자리에서 시작해 쉬는 시간이 사라집니다.

**이렇게 쓴다.** 쉬고 나서 여럿을 같이 돌릴 때는 `AppendInterval(0.5f).Append(a).Join(b)` 로 씁니다. 위치가 복잡해지면 `Insert(초, 모션)` 로 절대 위치를 적습니다.

### 3. 자식의 OnCancel 에 되돌리기를 두지 않는다

**안 지키면 생기는 문제.** 시퀀스가 정상으로 끝나도 자식의 `OnCancel` 이 불립니다. 취소 콜백에서 위치나 색을 원래대로 돌리면 끝까지 재생한 연출이 마지막 순간 되돌아갑니다.

**이렇게 쓴다.** 취소 처리는 `Run(b => b.WithOnCancel(Restore))` 처럼 시퀀스 쪽에 둡니다. 자식에는 바인딩과 `OnComplete` 만 둡니다. 자식 하나를 따로 멈춰야 한다면 그 모션은 시퀀스에 넣지 않습니다.

### 4. 되감을 시퀀스의 자식에는 기본 딜레이를 쓰지 않는다

**안 지키면 생기는 문제.** 딜레이를 건 자식은 시작 전에 값을 쓰지 않아, 되감으면 그 자식이 맡은 속성이 끝값에 남습니다.

**이렇게 쓴다.** 자리를 띄울 때는 `AppendInterval` 이나 `Insert` 를 씁니다. 자식에 딜레이가 꼭 필요하면 `WithDelay(d, DelayType.FirstLoop, false)` 로 세 번째 인자를 `false` 로 줍니다. 같은 위치의 자식 둘에게 한 속성을 맡기지도 않습니다. 같은 위치끼리의 순서는 API 가 약속하지 않습니다.

## 정리

LitMotion 의 시퀀스는 **자식의 시각을 밀어 주는 `double` 모션 하나**입니다.

- 빌더는 `tail` 과 `lastTail` 로 자리를 정하고, 자식은 만든 프레임 안에서 넣어야 하며 넣는 순간 사용자 접근이 막힙니다.
- 자식의 값은 메인 스레드의 `SetTime` 안에서 계산되고, `Run` 에 준 이징과 루프는 시계에 걸립니다.
- 시각을 뒤에서 한 번, 앞에서 한 번 써서 되감기가 맞습니다. 딜레이를 건 자식은 예외이고, 정상 완료에서도 자식의 `OnCancel` 이 불립니다.