LitMotion 의 어댑터는 Job 본문에 그대로 박히는 구조체다
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 입니다.
인터페이스인데 호출이 남지 않는다
// 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 가 미리 컴파일할 목록을 모을 때 닫힌 타입을 찾을 자리가 없습니다. 그래서 공식 어댑터 파일마다 맨 위에 자기 조합을 적어 둡니다.
// 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 컴파일 요청 경고 한 줄이 남았을 뿐 플레이어 콘솔은 비어 있었습니다. 직접 만드는 어댑터는 이렇게 씁니다.
[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개)가 할당됩니다. 평가는 구간을 늘 키 두 개로 잡습니다.
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 는 시드를 따로 줘야 합니다.