LitMotion 의 어댑터는 Job 본문에 그대로 박히는 구조체다
Esc

    LitMotion 의 어댑터는 Job 본문에 그대로 박히는 구조체다

    2026년 10월 18일 수정–Unity라이브러리트윈
    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 는 시드를 따로 줘야 합니다.