---
title: "LitMotion 은 트윈 하나를 구조체 배열의 한 칸으로 만든다"
description: "코루틴은 부를 때마다 객체를 만들고, Update 보간은 값마다 필드가 늘고, Animator 는 실행 중에 정해지는 값을 못 다룹니다. LitMotion 은 트윈 하나를 구조체 배열의 한 칸에 두고 Burst Job 으로 계산합니다."
author: "유니얼"
pubDate: 2026-09-26
updatedDate: 2026-10-18
category: "game-dev/unity/libraries"
tags: ["Unity", "라이브러리", "트윈"]
canonical: https://blog.unialgames.com/posts/unity-litmotion-why/
---
```csharp
IEnumerator MoveCo(Vector3 from, Vector3 to, float duration)
{
    for (float e = 0; e < duration; e += Time.deltaTime)
    {
        transform.position = Vector3.Lerp(from, to, e / duration);
        yield return null;
    }
    transform.position = to;
}
```

버튼 자리에서 인벤토리 칸으로 아이콘을 날리는 일을 코루틴으로 쓰면 부를 때마다 객체가 생기고, 멈추려면 `Coroutine` 참조를 따로 들고 있어야 합니다. `Update` 에서 손으로 보간하면 할당은 없지만 움직일 값마다 필드가 늘어납니다.

LitMotion 은 **트윈 하나를 객체가 아니라 구조체 배열의 한 칸으로 만들고**, 그 배열을 Burst Job 으로 한꺼번에 계산합니다. 사용자는 칸을 가리키는 정수 핸들만 받습니다. 기준 판본은 2.0.2 입니다.

## 기존 세 방법은 각자 빈자리가 있다

같은 일, "0.5초 동안 위로 1만큼 올린다" 를 세 방법과 LitMotion 으로 나란히 놓습니다.

<x-compare>
<script type="application/json">
{
  "label": "0.5초 동안 위로 1만큼 올리기",
  "tabs": [
    {
      "label": "코루틴",
      "code": "IEnumerator MoveCo(Vector3 from, Vector3 to, float duration)\n{\n    for (float e = 0; e < duration; e += Time.deltaTime)\n    {\n        float t = e / duration;\n        transform.position = Vector3.Lerp(from, to, 1 - (1 - t) * (1 - t));\n        yield return null;\n    }\n    transform.position = to;\n}\n\nvar co = StartCoroutine(MoveCo(p, p + Vector3.up, 0.5f));",
      "stats": [["이징", "식을 직접 쓴다"], ["중간에 멈추기", "Coroutine 참조를 들고 StopCoroutine"], ["누가 돌리나", "이 MonoBehaviour. 꺼지면 같이 멈춘다"], ["부를 때마다", "약 100 B (Mono 95.9 B, IL2CPP 103.9 B)"]],
      "tone": "bad"
    },
    {
      "label": "Update 보간",
      "code": "Vector3 from, to;\nfloat elapsed, duration;\nbool playing;\n\nvoid Update()\n{\n    if (!playing) return;\n    elapsed += Time.deltaTime;\n    float t = Mathf.Clamp01(elapsed / duration);\n    transform.position = Vector3.Lerp(from, to, 1 - (1 - t) * (1 - t));\n    if (t >= 1) playing = false;\n}",
      "stats": [["이징", "식을 직접 쓴다"], ["상태", "움직일 값마다 필드 네다섯 개"], ["여러 개를 돌리면", "컴포넌트마다 Update 가 불린다"], ["부를 때마다", "할당 없음"]],
      "tone": "bad"
    },
    {
      "label": "Animator",
      "text": "클립에 키프레임을 미리 찍어 두고 상태 머신으로 재생합니다. 캐릭터 동작처럼 모양이 정해진 연출에는 이것이 맞습니다. 대신 시작값과 끝값이 실행 중에 정해지는 이동, 예를 들어 누른 버튼 자리에서 인벤토리 칸으로 날아가는 아이콘은 클립으로 미리 만들 수 없습니다.",
      "stats": [["값이 정해지는 때", "편집할 때"], ["맞는 자리", "모양이 고정된 연출"], ["맞지 않는 자리", "실행 중에 끝값이 정해지는 이동"]]
    },
    {
      "label": "LitMotion",
      "code": "var handle = LMotion.Create(p, p + Vector3.up, 0.5f)\n    .WithEase(Ease.OutQuad)\n    .BindToPosition(transform);\n\n// 멈추려면\nhandle.Cancel();",
      "stats": [["이징", "Ease 열거형 하나"], ["중간에 멈추기", "받은 핸들로 Cancel"], ["누가 돌리나", "PlayerLoop 에 꽂힌 LitMotion 자신"], ["부를 때마다", "0 B"]],
      "tone": "good"
    }
  ]
}
</script>
</x-compare>

- **코루틴.** 이터레이터 메서드는 컴파일러가 클래스로 만들어서 부를 때마다 객체가 생깁니다. 이징 식도 매번 손으로 씁니다.
- **`Update` 보간.** 할당은 없지만 값 하나에 필드 네다섯 개가 붙고, 같은 코드를 컴포넌트마다 복사합니다.
- **`Animator`.** 끝값을 편집할 때 찍어 두는 방식이라 실행 중에 정해지는 이동에는 맞지 않습니다.

LitMotion 은 "무엇을, 어디서 어디까지, 몇 초 동안, 어떤 곡선으로" 를 한 줄로 받고, 부를 때 할당하지 않습니다.

## 모션은 객체가 아니라 배열의 한 칸이다

"할당 없음" 을 지키려면 트윈을 클래스로 만들 수 없습니다. 그래서 모션 하나가 `unmanaged` 구조체 하나입니다.

```csharp
// Runtime/Internal/MotionData.cs
internal struct MotionData<TValue, TOptions>
    where TValue : unmanaged
    where TOptions : unmanaged, IMotionOptions
{
    public MotionData Core;     // 상태, 시간, 길이, 이징, 루프
    public TValue StartValue;
    public TValue EndValue;
    public TOptions Options;
}
```

같은 값 타입의 모션들은 이 구조체가 빈틈없이 늘어선 평범한 C# 배열 하나에 모입니다. 콜백처럼 참조 타입이어야 하는 것은 옆 배열의 **같은 번호** 칸에 둡니다. 사용자가 받는 `MotionHandle` 은 그 칸을 가리키는 정수 셋(`StorageId`, `Index`, `Version`)이라 필드에 담고 복사해도 할당이 없습니다.

## 계산은 Burst Job 이, 대입은 메인 스레드가 한다

LitMotion 은 ECS 를 쓰지 않고 DOTS 의 아래쪽 조각인 Burst, Job System, Collections, Mathematics 만 가져왔습니다. 시간 전진과 이징, 보간은 배열을 통째로 Job 에 넘겨 여러 코어로 나눠 계산하고, `transform.position = x` 같은 대입은 Job 이 끝난 뒤 메인 스레드에서 합니다. `Update` 타이밍의 모션 한 무리가 한 프레임에 거치는 길입니다.

<x-wiring>
<script type="application/json">
{
  "label": "Update 타이밍의 float 모션들이 한 프레임에 거치는 길",
  "width": 820,
  "height": 330,
  "nodes": [
    { "id": "loop", "title": "PlayerLoop · Update", "sub": "맨 앞의 LitMotionUpdate", "x": 0, "y": 8, "w": 180, "h": 70 },
    { "id": "runner", "title": "UpdateRunner", "sub": "타입 × 타이밍마다 하나", "x": 260, "y": 8, "w": 180, "h": 70 },
    { "id": "data", "title": "MotionData[]", "sub": "구조체가 늘어선 C# 배열", "x": 260, "y": 262, "w": 180, "h": 54 },
    { "id": "job", "title": "MotionUpdateJob", "sub": "Burst · 워커 스레드들", "x": 640, "y": 8, "w": 180, "h": 70 },
    { "id": "cb", "title": "Bind 콜백", "sub": "메인 스레드", "x": 0, "y": 262, "w": 180, "h": 54 }
  ],
  "edges": [
    { "from": "loop", "to": "runner", "label": "Update(time)", "bend": 0 },
    { "from": "runner", "to": "data", "label": "fixed 로 고정", "bend": -30 },
    { "from": "runner", "to": "job", "label": "포인터 + Schedule(count, 16)", "bend": -20 },
    { "from": "job", "to": "data", "label": "시간 전진, 상태 기록", "bend": 0 },
    { "from": "job", "to": "runner", "label": "Complete() 가 돌아온다", "bend": -20 },
    { "from": "runner", "to": "cb", "label": "Output[i] 를 넘긴다", "bend": 10 },
    { "from": "runner", "to": "data", "label": "끝난 칸을 뺀다", "bend": 30 }
  ],
  "steps": [
    { "edge": 0, "why": "LitMotion 은 부팅 때 PlayerLoop 의 Update 단계 맨 앞에 자기 시스템을 끼워 넣었습니다. 그래서 MonoBehaviour.Update 보다 먼저 여기로 들어옵니다." },
    { "edge": 1, "why": "러너는 (float, NoOptions, FloatMotionAdapter) 모션만 담은 배열을 fixed 로 고정해 포인터를 꺼냅니다. 배열은 NativeArray 가 아니라 평범한 C# 배열입니다." },
    { "edge": 2, "why": "포인터와 이번 프레임의 deltaTime 을 Job 에 넣고, 16칸씩 나눠 워커에 뿌립니다." },
    { "edge": 3, "why": "Burst 로 컴파일된 코드가 칸마다 경과 시간을 더하고, 이징과 보간으로 값을 계산해 출력 배열에 적습니다. 끝난 모션은 상태만 바꿉니다." },
    { "edge": 4, "why": "메인 스레드는 Schedule 직후 Complete() 로 Job 이 끝날 때까지 기다립니다. 병렬이지만 비동기는 아닙니다." },
    { "edge": 5, "why": "메인 스레드가 배열을 다시 돌며 출력값을 Bind 콜백에 넘깁니다. transform.position = x 같은 대입은 여기서 일어납니다." },
    { "edge": 6, "why": "이번 Job 에서 정리 대상으로 표시된 칸을 배열에서 뺍니다. 빈칸 없이 뒤의 것으로 메웁니다." }
  ],
  "note": "계산은 Burst Job 이, 대입은 메인 스레드가 합니다. 둘 사이를 잇는 것은 같은 배열을 가리키는 포인터 하나입니다."
}
</script>
</x-wiring>

```csharp
// Runtime/Internal/UpdateRunner.cs
job.Schedule(count, 16).Complete();
```

Job 을 예약한 그 줄에서 끝나기를 기다립니다. 여러 코어로 나눠 도는 `for` 문에 가깝고, 값은 이번 프레임 안에 대상에 들어갑니다.

## 가장 짧은 예제

```csharp
using LitMotion;
using LitMotion.Extensions;
using UnityEngine;

public class PopUp : MonoBehaviour
{
    [SerializeField] CanvasGroup group;

    void Start()
    {
        var p = transform.position;

        LMotion.Create(p, p + Vector3.up, 0.5f)   // 설정을 모으는 빌더
            .WithEase(Ease.OutQuad)
            .BindToPosition(transform)            // 여기서 모션이 만들어지고 핸들이 돌아온다
            .AddTo(gameObject);                   // 이 오브젝트가 파괴되면 취소

        LMotion.Create(0f, 1f, 0.3f)
            .Bind(group, static (x, g) => g.alpha = x)   // 확장 메서드가 없는 값은 state 로
            .AddTo(gameObject);
    }
}
```

두 모션은 기본 스케줄러를 따라 `Update` 타이밍에 `Time.timeScale` 을 따르는 시간으로 돕니다. 바꾸려면 `WithScheduler(MotionScheduler.UpdateIgnoreTimeScale)` 처럼 지정합니다.

## 주의할 점

### 1. 편집할 때 모양이 정해지는 연출은 Animator 에 둔다

**안 지키면 생기는 문제.** 캐릭터 동작처럼 키프레임이 정해진 연출을 LitMotion 모션 여러 개로 옮기면, 클립 하나로 끝날 일을 코드로 시간표를 짜서 유지하게 됩니다.

**이렇게 쓴다.** 시작값과 끝값이 실행 중에 정해지는 이동, 크기, 색 변화에 LitMotion 을 씁니다. 모양이 고정된 연출은 `Animator` 에 둡니다.

### 2. 모션은 게임오브젝트가 꺼져도 멈추지 않는다

**안 지키면 생기는 문제.** 코루틴은 그것을 돌리던 게임오브젝트가 꺼지면 같이 멈춥니다. LitMotion 모션은 PlayerLoop 에 꽂힌 LitMotion 이 돌리므로 주인이 없습니다. 대상이 파괴된 뒤에도 모션은 남아서 파괴된 오브젝트에 값을 쓰려 합니다.

**이렇게 쓴다.** 모션을 만들 때 `AddTo(gameObject)` 로 수명을 게임오브젝트에 묶습니다. 따로 멈춰야 하면 받은 핸들을 필드에 두고 `TryCancel()` 합니다.

## 정리

LitMotion 은 **트윈 하나를 구조체 배열의 한 칸으로 만들고, 그 배열을 Burst Job 으로 계산하는** 트윈 라이브러리입니다.

- 코루틴은 호출마다 객체를 만들고, `Update` 보간은 상태를 손으로 들고, `Animator` 는 실행 중에 정해지는 값을 못 다룹니다.
- 모션은 `unmanaged` 구조체 한 칸이고 사용자는 정수 셋짜리 핸들만 받아 할당이 없습니다.
- 계산은 Job 이 여러 코어로 나눠 하고 그 자리에서 기다리며, 대입은 메인 스레드가 합니다.