---
title: "LitMotion 은 절대 시각 하나에서 루프와 진행률을 계산한다"
description: "손으로 쓴 보간은 델타를 쌓고 길이를 넘을 때마다 빼며 루프를 셉니다. LitMotion 은 누적 시각 하나를 길이로 나눠 floor 한 번으로 루프 수와 진행률을 뽑습니다. 그래서 점프가 공짜지만 경계의 끝값과 콜백 수는 보장하지 않습니다."
author: "유니얼"
pubDate: 2026-09-26
updatedDate: 2026-10-18
category: "game-dev/unity/libraries"
tags: ["Unity", "라이브러리", "트윈"]
canonical: https://blog.unialgames.com/posts/unity-litmotion-update/
---
```csharp
elapsed += Time.deltaTime;
while (elapsed >= duration) { elapsed -= duration; loops++; OnLoop(); }
value = Mathf.Lerp(from, to, elapsed / duration);
```

손으로 쓴 `Update` 보간은 이렇게 델타를 쌓고, 길이를 넘을 때마다 빼면서 루프를 셉니다. 한 프레임이 길면 `while` 이 여러 번 돌고, 임의의 시각으로 건너뛰려면 따로 코드를 써야 합니다.

LitMotion 은 **모션이 시작된 뒤의 절대 시각 하나를 받아, 나눗셈과 `floor` 로 루프 수와 진행률을 처음부터 다시 계산합니다.** `Runtime/Internal/MotionData.cs` 의 `MotionData.Update(double time, out float progress)` 한 함수입니다. 기준 판본은 2.0.2 입니다.

## 시각은 한 줄에서 쌓이고 나머지는 나눗셈이다

시각이 쌓이는 곳은 Job 의 한 줄뿐입니다.

```csharp
// Runtime/MotionUpdateJob.cs (줄임)
var time = state.Time + deltaTime * state.PlaybackSpeed;
ptr->Update<TAdapter>(time, out var result);
```

그다음부터는 더하기가 없습니다. 한 프레임에 2.7초가 흘렀을 때 두 방식입니다.

<x-compare>
<script type="application/json">
{
  "label": "한 프레임에 2.7초가 흘렀을 때 루프를 세는 방법",
  "tabs": [
    {
      "label": "손으로 쓴 Update",
      "code": "elapsed += Time.deltaTime;          // 0.5 + 2.7 = 3.2\n\nwhile (elapsed >= duration)         // 넘긴 루프 수만큼 돈다\n{\n    elapsed -= duration;\n    loops++;                        // 루프 완료 처리\n}\n\nvalue = Mathf.Lerp(from, to, elapsed / duration);",
      "text": "duration 1, elapsed 0.5 에서 2.7초가 흐르면 while 이 세 번 돌아 loops = 3, 진행률 0.2 가 됩니다. 넘긴 루프가 많을수록 반복문이 더 돕니다."
    },
    {
      "label": "LitMotion",
      "code": "var time = state.Time + deltaTime * state.PlaybackSpeed;   // 0.5 + 2.7 = 3.2\n\ncompletedLoops = (int)math.floor(TimeSinceStart / Duration);   // floor(3.2) = 3\nclamped = GetClampedCompletedLoops(completedLoops);\nisCompleted = Loops >= 0 && clamped > Loops - 1;              // 3 > 4 거짓\n\nvar currentLoopTime = TimeSinceStart - Duration * clamped;   // 0.2\nt = math.clamp(currentLoopTime / Duration, 0f, 1f);",
      "text": "같은 조건(Loops 5)에서 나눗셈 한 번으로 CompletedLoops = 3, t = 0.2 가 나옵니다. 반복문이 없으니 한 프레임이 길어도 계산량은 같습니다.",
      "tone": "good"
    }
  ]
}
</script>
</x-compare>

결과는 둘 다 "루프 3, 진행률 0.2" 입니다. LitMotion 은 넘긴 루프가 몇 개든 나눗셈 한 번이고, 핸들의 `Time` 에 값을 넣는 점프도 같은 함수를 한 번 부를 뿐입니다.

대신 콜백 수는 맞추지 않습니다. `OnLoopComplete` 는 `CompletedLoops > PrevCompletedLoops` 비교 하나로 불리므로, 한 번에 루프 셋을 넘으면 인자 3 으로 **한 번** 불립니다. `WithLoops(5)` 모션에 `h.Time = 3.2` 를 넣었더니 기록이 `[3]` 한 건이었습니다.

## 루프 경계에서 진행률은 0 이다

기본 딜레이 방식인 `FirstLoop` 는 딜레이를 시각에서 한 번 빼고 길이로 나눕니다. `Duration = 1`, `Delay = 0.5`, `Loops = 3` 모션을 세 시각에서 멈춰 봅니다.

```csharp
completedLoops = (int)math.floor(TimeSinceStart / Duration);   // TimeSinceStart = Time - Delay
clamped = GetClampedCompletedLoops(completedLoops);            // 0 아래는 0 으로 자른다
isCompleted = Loops >= 0 && clamped > Loops - 1;               // 마지막 경계에서만 완료
isDelayed = TimeSinceStart < 0f;

if (isCompleted)
    t = 1f;                                                    // 완료일 때만 1 로 고정
else
    t = math.clamp((TimeSinceStart - Duration * clamped) / Duration, 0f, 1f);
```

Duration 1, Delay 0.5, Loops 3 인 모션입니다.

| Time | TimeSinceStart | completedLoops → clamped | 완료 | 결과 |
| --- | --- | --- | --- | --- |
| 0.2 | -0.3 | -1 → 0 | 0 > 2 거짓 | Delayed, t = 0 |
| 1.5 | 1.0 | 1 → 1 | 1 > 2 거짓 | Playing, t = 0, OnLoopComplete(1) |
| 3.5 | 3.0 | 3 → 3 | 3 > 2 참 | Completed, t = 1 |

Time 1.5 는 정확히 루프 경계라 `(1.0 - 1) / 1 = 0` 이 됩니다. 루프 경계에서 t 는 끝값 1 이 아니라 다음 루프의 0 이고, 1 이 보장되는 것은 마지막 경계뿐입니다.

**루프 경계에서 `t` 는 1 이 아니라 0 입니다.** 몫이 올라가는 순간 나머지는 0 이기 때문입니다. 직전 프레임이 0.98 에서 끝났고 이번 프레임이 경계를 조금 넘었다면 그 루프의 끝값은 한 번도 출력되지 않습니다. 1 이 보장되는 것은 마지막 경계, 곧 `Delay + Duration × Loops` 에서 완료로 판정될 때뿐입니다.

`DelayType.EveryLoop` 는 딜레이 + 재생을 한 주기로 보고 `fmod` 로 주기 안의 위치를 구합니다. 음수인 동안이 그 주기의 딜레이 구간입니다.

## LoopType 네 가지는 식 하나씩이다

`t` 에 이징을 씌워 진행률로 바꾸는 식만 다릅니다. `OutQuad`(`E(x) = 1 - (1 - x)²`) 로 둘째 루프의 4분의 1 지점을 계산합니다.

```csharp
switch (Parameters.LoopType)
{
    case LoopType.Restart:       // 루프마다 처음부터 같은 곡선
        progress = E(t);
        break;
    case LoopType.Flip:          // 홀수 루프면 값을 위아래로 뒤집는다
        progress = E(t);
        if ((clamped + (int)t) % 2 == 1)
            progress = 1f - progress;
        break;
    case LoopType.Incremental:   // 끝난 루프만큼 한 칸씩 쌓는다
        progress = E(1f) * clamped + E(t % 1);
        break;
    case LoopType.Yoyo:          // 홀수 루프면 시간을 뒤집어 거꾸로 달린다
        progress = (clamped + (int)t) % 2 == 1 ? E(1f - t) : E(t);
        break;
}
```

OutQuad 이징, `clamped = 1` (두 번째 루프), `t = 0.25` 인 같은 순간입니다.

| LoopType | 계산 | progress |
| --- | --- | --- |
| Restart | E(0.25) | 0.4375 |
| Flip | 1 - E(0.25) | 0.5625 |
| Yoyo | E(1 - 0.25) = E(0.75) | 0.9375 |
| Incremental | 1 × 1 + E(0.25) | 1.4375 |

Flip 은 값을, Yoyo 는 시간을 뒤집으니 이징이 곡선이면 결과가 다릅니다. 둘이 같은 값이 되는 것은 선형 이징뿐입니다.

| LoopType | 홀수 루프의 식 | 돌아오는 길 (OutQuad) |
| --- | --- | --- |
| Restart | `E(t)` | 시작값으로 튀어 다시 출발 |
| Flip | `1 - E(t)` | 값 축을 뒤집어, 돌아올 때도 빠르게 출발해 천천히 도착 |
| Yoyo | `E(1 - t)` | 시간 축을 뒤집어, 가던 길을 되감듯 천천히 출발해 빠르게 도착 |
| Incremental | `E(1)·n + E(t)` | 돌아오지 않고 루프마다 한 칸 더 |

Incremental 은 진행률이 1 을 넘고, 기본 어댑터가 `LerpUnclamped` 처럼 자르지 않는 보간이라 그대로 값이 됩니다.

## Complete() 는 다른 식을 쓴다

`handle.Complete()` 는 `Update` 를 부르지 않고 따로 짠 식으로 최종 진행률을 냅니다. Restart, Flip, Yoyo 는 끝까지 재생한 값과 같지만 Incremental 은 `E(1) × Loops` 가 아니라 `E(Loops)` 를 씁니다. 이징 함수는 입력을 0~1 로 자르지 않습니다.

<x-compare>
<script type="application/json">
{
  "label": "0 에서 1 로, Loops 3, Incremental, OutQuad",
  "tabs": [
    { "label": "끝까지 재생", "code": "// Update 가 완료 프레임에 계산\nprogress = E(1f) * 3 + E(0f)\n         = 1 * 3 + 0", "stats": [["최종값", "3.0000"], ["걸린 프레임", "359"]], "tone": "good" },
    { "label": "Complete()", "code": "// Complete 가 바로 계산\nprogress = E(3f)\n         = 1 - (1 - 3) * (1 - 3)", "stats": [["최종값", "-3.0000"]], "tone": "bad" }
  ]
}
</script>
</x-compare>

실측 값이 식과 소수점까지 같았습니다. 무한 루프는 최종값이 없어서 `Complete()` 가 `InvalidOperationException` 을 던집니다.

## 0 과 끝자락에서 생기는 일

| 경우 | 동작 |
| --- | --- |
| `WithLoops(0)` | 완료 판정 `clamped > -1` 이 늘 참. `WithDelay(1f)` 를 걸어도 생성 다음 프레임에 끝값 1 로 완료 |
| 길이 0, `Time.timeScale = 0` | 완료 조건이 `TimeSinceStart > 0` 이라 30프레임 동안 완료되지 않음. `timeScale` 을 1 로 되돌린 다음 프레임에 완료 |
| 재생 중 `PlaybackSpeed = -1` (보존 안 함) | 시작점을 지나 `Delayed` 에 머묾. 2초 뒤 `handle.Time` -1.50, 끝나지도 사라지지도 않고 매 프레임 시작값을 씀 |
| 0.01초 무한 루프가 65,536번째 경계를 넘음 | `CompletedLoops` 가 `ushort` 라 0 으로 돌아가고, 그 사이 넘은 두 루프의 `OnLoopComplete` 가 한 번도 불리지 않음 |

## 주의할 점

### 1. 루프 경계에서 끝값이 찍힌다고 기대하지 않는다

**안 지키면 생기는 문제.** 루프가 넘어가는 프레임의 `t` 는 다음 루프의 위치입니다. `OnLoopComplete` 에서 "지금 값이 끝값" 이라고 가정하고 판정하면, Restart 라면 시작값 근처가 들어옵니다.

**이렇게 쓴다.** 루프 끝의 값이 필요하면 지금 값이 아니라 끝값 파라미터를 씁니다. `OnLoopComplete` 에서는 몇 번째 루프인지만 받습니다.

### 2. Incremental 에 비선형 이징을 줬으면 Complete() 로 끝내지 않는다

**안 지키면 생기는 문제.** `OutQuad`, `Loops = 3` 이면 끝까지 재생한 3 대신 -3 에서 멈춥니다. 스킵 버튼이 `Complete()` 를 부르는 연출에서만 오브젝트가 엉뚱한 곳으로 튑니다.

**이렇게 쓴다.** Incremental 에는 `Ease.Linear` 를 씁니다. 비선형 이징이 꼭 필요하면 스킵할 때 `Cancel()` 하고 최종값을 직접 넣습니다.

### 3. WithLoops(0) 을 재생하지 않음으로 쓰지 않는다

**안 지키면 생기는 문제.** 루프 0 은 "끝값을 한 번 쓰고 끝난다" 입니다. 딜레이도 기다리지 않습니다. 설정값으로 루프 수를 받는 코드에 0 이 들어오면 연출 없이 오브젝트가 끝 위치로 순간이동합니다.

**이렇게 쓴다.** 재생하지 않을 거면 모션을 만들지 않습니다. 루프 수를 외부 값으로 받으면 1 이상인지 먼저 봅니다.

### 4. OnLoopComplete 호출 수를 루프 수로 세지 않는다

**안 지키면 생기는 문제.** 한 프레임에 여러 루프를 넘으면 콜백은 한 번이고, 무한 루프는 65,536번째에서 카운터가 0 으로 돌며 콜백이 빠집니다. 콜백 수로 점수나 박자를 맞추는 코드는 히치 한 번, 긴 플레이 한 번에 어긋납니다.

**이렇게 쓴다.** 루프 수가 로직에 쓰이면 경과 시간을 루프 길이로 나눠 직접 계산합니다. `OnLoopComplete` 는 연출 신호로만 씁니다.

## 정리

LitMotion 은 **절대 시각 하나에서 `floor` 로 루프와 진행률을 계산합니다.**

- 시각이 쌓이는 곳은 Job 의 한 줄이고, 루프 수와 위치는 나눗셈과 나머지입니다. 긴 프레임과 점프가 같은 비용입니다.
- 루프 경계의 진행률은 0 이고 1 은 마지막 경계에서만 나옵니다. 여러 루프를 넘어도 `OnLoopComplete` 는 한 번입니다.
- LoopType 은 진행률 식만 다르고, `Complete()` 는 따로 짠 식이라 Incremental 과 비선형 이징에서 값이 갈립니다.