---
title: "LitMotion 의 어댑터는 Job 본문에 그대로 박히는 구조체다"
description: "Burst Job 안에서는 보간 방법을 델리게이트로 넘길 수 없습니다. LitMotion 은 값 타입마다 상태 없는 어댑터 구조체를 타입 인자로 넣어 default(TAdapter).Evaluate 로 부르고, 그 계산은 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-adapter/
---
```csharp
LMotion.Create(0f, 10f, 1f).Bind(x => Debug.Log(x));                     // float
LMotion.Create(Vector3.zero, Vector3.one, 1f).BindToPosition(transform);  // Vector3
LMotion.Punch.Create(Vector3.zero, Vector3.one, 0.5f).BindToLocalPosition(transform);
```

손으로 쓴 보간이 여러 타입을 다루려면 `Func<T, T, float, T>` 같은 보간 함수를 들고 다니게 됩니다. 그런데 LitMotion 은 값 계산을 Burst Job 안에서 하고, Burst 는 델리게이트도 관리 객체도 다루지 못합니다.

그래서 LitMotion 은 **진행률을 값으로 바꾸는 마지막 한 단계를 상태 없는 구조체(어댑터)로 만들어 타입 인자로 넘깁니다.** 조합마다 전용 코드가 생기고, 계산은 Job 본문에 그대로 박힙니다. 기준 판본은 2.0.2 입니다.

## 인터페이스인데 호출이 남지 않는다

```csharp
// Runtime/Adapters/PrimitiveMotionAdapters.cs
public readonly struct FloatMotionAdapter : IMotionAdapter<float, NoOptions>
{
    public float Evaluate(ref float startValue, ref float endValue, ref NoOptions options, in MotionEvaluationContext context)
        => math.lerp(startValue, endValue, context.Progress);
}

// Runtime/Internal/MotionData.cs
result = default(TAdapter).Evaluate(ref StartValue, ref EndValue, ref Options, new MotionEvaluationContext { Progress = progress, Time = time });
```

어댑터를 필드에 두지 않고 부를 때마다 `default(TAdapter)` 로 빈 구조체를 만들어 부릅니다. `TAdapter : unmanaged` 라서 값 타입만 들어오고, 값 타입 인자로 닫힌 `MotionUpdateJob<float, NoOptions, FloatMotionAdapter>` 는 그 자체로 완성된 타입이라 어느 구현을 부를지가 컴파일할 때 정해집니다.

실제로 호출이 남는지 Burst 기계어에서 셌습니다.

| `MotionUpdateJob` 인스턴스 | 어댑터 `Evaluate` 로 가는 call | 보간 계산 |
| --- | --- | --- |
| `float` | 0개 | `Execute` 본문에 `subss mulss addss` |
| `Vector3` | 0개 | xy 를 묶은 `subps mulps addps` 와 z 의 `subss mulss addss` |
| `FixedString32Bytes` (방법 검증용 대조) | 1개 | 따로 된 함수를 부름 |

빌드에 실리는 `lib_burst_generated.bundle` 을 디스어셈블한 결과입니다. 대조군에서 call 이 잡혔으니 방법은 믿을 만합니다. `float` 와 `Vector3` 의 lerp 는 진행률을 받아 오는 자리 바로 뒤에 산술 세 개로 들어가 있었습니다. 별도 함수로 남은 것은 시간, 루프, 이징을 처리하는 `MotionData.Update` 쪽입니다.

## 조합마다 RegisterGenericJobType 을 적어야 한다

`MotionUpdateJob<TValue, TOptions, TAdapter>` 는 제네릭 러너 안에서 타입 인자가 열린 채로 만들어져서, Burst 가 미리 컴파일할 목록을 모을 때 닫힌 타입을 찾을 자리가 없습니다. 그래서 공식 어댑터 파일마다 맨 위에 자기 조합을 적어 둡니다.

```csharp
// Runtime/Adapters/PrimitiveMotionAdapters.cs
[assembly: RegisterGenericJobType(typeof(MotionUpdateJob<float, NoOptions, FloatMotionAdapter>))]
```

직접 만든 어댑터도 같습니다. 같은 `float3` lerp 어댑터를 특성 있음, 없음 둘로 만들어 모션 1만 개를 돌렸습니다.

| 런타임 | 특성 있음, 모션 하나당 | 특성 없음, 모션 하나당 |
| --- | --- | --- |
| 에디터 | 42.5 ns | 97.7 ns |
| Mono | 42.5 ns | 92.1 ns |
| IL2CPP | 35.4 ns | 49.8 ns |

**특성이 없으면 오류 없이 느려지기만 합니다.** 값은 끝값까지 정확히 갔고, Job 이 관리 코드로 물러나 돌았습니다(`[BurstDiscard]` 카운터로 확인). 에디터 콘솔에 Burst 컴파일 요청 경고 한 줄이 남았을 뿐 플레이어 콘솔은 비어 있었습니다. 직접 만드는 어댑터는 이렇게 씁니다.

```csharp
[assembly: RegisterGenericJobType(typeof(MotionUpdateJob<float3, NoOptions, Float3Adapter>))]

public readonly struct Float3Adapter : IMotionAdapter<float3, NoOptions>
{
    public float3 Evaluate(ref float3 start, ref float3 end, ref NoOptions options, in MotionEvaluationContext context)
        => math.lerp(start, end, context.Progress);   // Progress 는 이미 이징을 거친 값
}
```

## 기본 어댑터는 자르지 않는다

기본 어댑터는 `math.lerp`, `Vector3.LerpUnclamped` 처럼 0~1 밖의 진행률을 자르지 않습니다. 그래서 `Ease.OutBack` 의 튀어나감과 1 을 넘는 Incremental 루프가 값에 그대로 삽니다. 회전은 `Quaternion.LerpUnclamped` 이고 `Slerp` 가 아닙니다. 170도 회전에서 시작 자세와의 각도입니다.

| 진행률 | 0.1 | 0.25 | 0.5 | 0.75 | 0.9 |
| --- | --- | --- | --- | --- | --- |
| LitMotion | 12.5° | 35.8° | 85.0° | 134.2° | 157.5° |
| 등속 (`Quaternion.Slerp`) | 17.0° | 42.5° | 85.0° | 127.5° | 153.0° |

가운데에서만 같고 4분의 1 지점에서 6.7도 뒤처졌다가 4분의 3 지점에서 6.7도 앞섭니다.

## 곡선 이징은 키 둘 이상을 가정한다

`WithEase(AnimationCurve)` 는 곡선이 관리 객체라 Job 에서 못 쓰므로, 모션을 만들 때 키프레임을 네이티브 메모리로 복사하고 유니티의 곡선 평가를 Burst 코드로 다시 짜서 씁니다. 복사 때 `animationCurve.keys` 가 관리 배열을 새로 만들어 모션당 116 B(키 3개)가 할당됩니다. 평가는 구간을 늘 키 두 개로 잡습니다.

```csharp
time = WrapTime(ptr, length, preWrapMode, postWrapMode, time);   // 범위 밖 시각을 WrapMode 로 당긴다
int index = BinarySearch(ptr, length, time);                     // 없으면 들어갈 자리를 돌려준다

// 구간은 늘 키 두 개로 잡는다
if (index == 0)
    index++;
else if (index == length)
    index = length - 1;

keyframe = ptr[index - 1];
nextKeyframe = ptr[index];                                        // 키가 하나면 배열 밖이다

return Evaluate(time, ref keyframe, ref nextKeyframe);            // 두 키 사이의 베지어
```

| 곡선 | 시각 | `index` | 읽는 두 키 | 결과 |
| --- | --- | --- | --- | --- |
| 키 셋 `[0초, 0.5초, 1초]` | 0.25 | 1 | `ptr[0]`, `ptr[1]` | 0초와 0.5초 키 사이의 값 |
| 키 하나 `[0초]` | 0.5 → 0 으로 당겨짐 | 0 → 1 | `ptr[0]`, **`ptr[1]` (배열 밖)** | NaN |

키가 하나면 맨 앞이라 한 칸 뒤로 밀고, 그 칸은 배열 밖입니다. 날 포인터로 읽으니 검사가 끼어들 자리가 없습니다.

키 하나짜리 곡선 `new AnimationCurve(new Keyframe(0f, 0.3f))` 로 1초 모션을 돌리면 만든 순간만 0.3 이고 그 뒤로 완료까지 **전부 NaN** 이었습니다. 예외도 콘솔 메시지도 없었고, Collections 안전 검사를 켠 에디터도 같았습니다. 날 포인터 접근이라 검사가 끼어들 자리가 없습니다.

## Shake 의 난수는 시드와 시각의 해시다

Punch 와 Shake 어댑터는 시작값을 기준 위치로, 끝값을 흔들림 세기로 읽습니다. Shake 는 병렬 Job 안에서 전역 난수를 쓸 수 없어 **시드와 현재 시각을 해시해 매번 새 난수기를 만듭니다.** 상태가 없으니 병렬도 되감기도 재현도 되지만, `ShakeOptions.Default` 는 시드를 채우지 않아 모든 Shake 의 시드가 0 입니다.

같은 프레임에 시드 없이 만든 Shake 둘은 모든 프레임에서 같은 값이었습니다(에디터 103/103, Mono 588/588, IL2CPP 782/782 프레임). `WithRandomSeed(7)` 을 붙인 쪽이 같았던 것은 즉시 바인딩된 0 을 포함한 두 프레임뿐입니다. `Time.timeScale = 0` 이면 시각이 멈춰 같은 난수가 나오고 봉투도 멈춰, 흔들리던 자리에 고정됩니다.

## 주의할 점

### 1. 커스텀 어댑터에 필드를 두지 않는다

**안 지키면 생기는 문제.** 어댑터는 늘 `default(TAdapter)` 로 불립니다. 필드에 배율이나 축을 넣어 두어도 호출할 때는 전부 0 이고, 컴파일 오류도 경고도 없습니다.

**이렇게 쓴다.** 어댑터는 `readonly struct` 로 선언해 필드를 막습니다. 모션마다 다른 값은 `IMotionOptions` 를 구현한 옵션 구조체에 담아 `WithOptions` 로 넘깁니다. 어댑터 안에서 이징을 다시 걸지 않습니다. `context.Progress` 는 이미 이징을 거친 값입니다.

### 2. 커스텀 어댑터에는 RegisterGenericJobType 을 붙인다

**안 지키면 생기는 문제.** 값은 맞게 나오면서 Job 이 관리 코드로 돌아 모션당 비용이 Mono 2.2배, IL2CPP 1.4배가 됩니다. 플레이어에서는 아무 메시지도 없습니다.

**이렇게 쓴다.** 어댑터를 정의한 파일 맨 위에 `[assembly: RegisterGenericJobType(typeof(MotionUpdateJob<값, 옵션, 어댑터>))]` 를 적고, 새 조합을 만들 때마다 한 줄씩 늘립니다.

### 3. 곡선은 키 둘 이상으로 만든다

**안 지키면 생기는 문제.** 키가 하나면 배열 밖을 읽어 값이 NaN 이 되고, 키가 0개면 복사하는 순간 예외입니다. 첫 프레임 뒤로 값이 전부 NaN 인데 원인을 가리키는 로그가 없습니다.

**이렇게 쓴다.** 곡선을 코드나 데이터로 받으면 `curve.length >= 2` 를 먼저 봅니다. 자주 만드는 모션에는 `Ease` 열거형을 써서 모션마다 `keys` 복사도 피합니다.

### 4. 동시에 흔드는 Shake 는 시드를 나눈다

**안 지키면 생기는 문제.** 기본 시드가 0 이라 같은 프레임에 맞은 적 여러 마리가 한 몸처럼 흔들립니다. `timeScale` 을 0 으로 두는 일시정지 중에는 흔들리던 자리에서 멈춥니다.

**이렇게 쓴다.** `WithRandomSeed` 에 인스턴스 ID 같은 값을 넣습니다. 일시정지 중에도 흔들려야 하면 `WithScheduler(MotionScheduler.UpdateIgnoreTimeScale)` 로 시계를 바꿉니다.

## 정리

LitMotion 의 어댑터는 **Job 본문에 그대로 박히는 상태 없는 구조체**입니다.

- `default(TAdapter).Evaluate` 와 `unmanaged` 제약으로 조합마다 전용 코드가 생기고, `float`, `Vector3` 의 보간은 call 없이 인라인됩니다.
- Job 이 제네릭 러너 안에서 만들어지므로 조합마다 `RegisterGenericJobType` 이 필요하고, 빠뜨리면 조용히 느려집니다.
- 기본 어댑터는 자르지 않고, 회전은 `Slerp` 가 아니며, 곡선은 키 둘 이상을, Shake 는 시드를 따로 줘야 합니다.