LitMotion 은 모션마다 드는 할당을 없앤 대신 러너마다 고정 비용을 낸다
Esc

    LitMotion 은 모션마다 드는 할당을 없앤 대신 러너마다 고정 비용을 낸다

    2026년 10월 18일 수정–Unity라이브러리트윈GC
    "description": "Lightning-fast and Zero Allocation Tween Library for Unity."

    코루틴 연출은 부를 때마다 GC 할당을 남깁니다. LitMotion 은 패키지 설명에 “Zero Allocation” 을 걸었고, 모션을 올바른 경로로 만들면 실제로 0 이 나옵니다. 그런데 프로파일러에는 모션이 몇 개 없어도 LitMotion 몫이 보입니다.

    LitMotion 은 모션마다 드는 할당을 없앤 대신, (값 타입 × 타이밍) 마다 하나인 러너가 매 프레임 고정 비용을 냅니다. 할당은 처음 쓰는 순간과 배열이 커지는 순간에 몰리고, 고정 비용은 모션이 많을수록 잘게 나뉩니다. 기준 판본은 2.0.2 입니다.

    정상 상태의 모션은 할당이 없다

    Bind 에 static 람다와 state 를 넘기면 모션을 만들 때 0 B 이고, 모션 1만 개가 도는 동안의 프레임 할당도 빈 프레임과 같았습니다. 할당이 남는 것은 다른 기능을 쓸 때입니다.

    자리 언제 크기
    대상을 붙잡는 람다 모션마다 152 B
    구조체 state (int -1~9, bool 밖) 모션마다 Mono 20 B, IL2CPP 24 B
    WithOnComplete 등 콜백 호출마다 넘긴 델리게이트, 두 번째부터 결합 델리게이트
    커스텀 AnimationCurve 모션마다 keys 복사본, 키 3개 116 B
    MonoBehaviour 안의 AddTo(this) 호출마다 약 890 B
    새 (값 타입 × 타이밍) 조합 그 조합의 첫 모션 약 7.4~7.8 KB
    저장소 배열 성장 동시 모션 수가 용량을 넘을 때 float 32 → 64칸에서 약 11 KB

    할당은 첫 사용과 배열 성장에 몰린다

    저장소는 그 조합의 첫 모션 때 생기고, 칸이 모자라면 배열 넷을 두 배로 새로 만듭니다. 32칸 저장소에 32번째와 33번째 모션이 들어올 때입니다.

    public MotionHandle Create(ref MotionBuilder builder)
    {
        EnsureCapacity(tail + 1);      // 슬롯, 역색인, 데이터, 관리 데이터 배열 넷 모두 확인한다
    
        // ArrayHelper.EnsureCapacity(ref array, minimumCapacity)
        if (array.Length >= minimumCapacity)
            return;                    // 칸이 충분하면 아무것도 하지 않는다
    
        while (l < minimumCapacity)
            l *= 2;                    // 두 배씩 키운다
        Array.Resize(ref array, l);    // 새 배열을 만들어 옮긴다. 옛 배열은 쓰레기
    
        unmanagedDataArray[tail] = ...;
        managedDataArray[tail] = ...;
        tail++;
    }
    모션 필요한 칸 용량 할당
    32번째 32 32 없음
    33번째 33 32 → 64 64칸 배열 넷 (float 모션에서 약 11 KB)

    용량은 32, 64, 128 처럼 두 배로만 자라고 줄어드는 코드는 없습니다.

    모션 하나를 만드는 프레임의 순할당을 쟀습니다.

    경우 Mono IL2CPP
    32번째 (성장 없음) 440 B 442 B
    33번째 (배열 넷 32 → 64) 11,576 B 11,578 B
    EnsureStorageCapacity(64) 뒤 33번째 457 B 442 B
    처음 쓰는 Color 의 첫 모션 7,768 B, 호출 5.41 ms 7,440 B, 호출 0.88 ms
    EnsureStorageCapacity(64) 뒤 처음 쓰는 Vector4 의 첫 모션 854 B, 호출 4.06 ms 962 B, 호출 0.81 ms

    EnsureStorageCapacity 는 배열 성장을 없애고 첫 사용의 할당도 대부분 로딩으로 옮기지만, 첫 모션의 시간은 조금밖에 줄이지 못했습니다. 이 API 는 러너를 만들지 않아서, 첫 모션이 러너를 만들고 그 조합의 생성 경로를 처음 밟는 일이 그대로 남기 때문입니다.

    러너는 모션이 없어도 매 프레임 일한다

    러너는 자기 타이밍마다 TempJob 네이티브 배열 둘을 잡고, Job 을 예약해 기다리고, 콜백 루프를 배열 길이만큼 돕니다. 모션이 0개여도 같습니다.

    var count = storage.Count;
    
    // 타이밍마다 네이티브 배열 둘을 잡는다. 모션이 0개여도 잡는다
    using var output = new NativeArray<TValue>(count, Allocator.TempJob);
    using var completed = new NativeList<int>(count, Allocator.TempJob);
    
    job.Schedule(count, 16).Complete();       // 할 일이 없어도 예약하고 기다린다
    
    var managedDataSpan = storage.GetManagedDataSpan();   // 모션 수가 아니라 배열 길이(용량)
    
    for (int i = 0; i < managedDataSpan.Length; i++)
    {
        if (canUpdate)
            managedData.UpdateUnsafe(output[i]);          // Bind. 빈 칸은 상태가 None 이라 넘어간다
    }
    
    storage.RemoveAll(completed);

    용량 8 인 저장소로 줄여 보면 이렇습니다.

    모션 수 네이티브 배열 Job 예약 콜백 루프가 보는 칸
    2 둘 잡음 함 8칸 (Bind 2번, 빈 칸 6)
    0 둘 잡음 함 8칸 (모두 빈 칸)

    러너 하나의 고정 비용은 모션 수와 상관없이 나가고, 콜백 루프는 용량만큼 돕니다.

    그래서 모션이 적을수록 하나당 비용이 커집니다. Vector3 위치 모션 N개를 돌린 값입니다.

    동시 모션 에디터 프레임 증가 Mono 단계 IL2CPP 단계
    100 +0.074 ms (740 ns) 0.245 ms (2,431 ns) 0.176 ms (1,747 ns)
    1,000 +0.169 ms (169 ns) 0.326 ms (324 ns) 0.267 ms (265 ns)
    10,000 +1.169 ms (117 ns) 0.968 ms (97 ns) 0.759 ms (76 ns)
    0 (모두 취소, 러너는 남음) 0.161 ms 0.091 ms

    괄호 안은 모션 하나당 값이고, 에디터의 기준 프레임은 0.130 ms 입니다. 플레이어 값은 Vector3 저장소를 16,384칸으로 미리 키운 뒤 LitMotion Update 단계 앞뒤를 200프레임 잰 중앙값입니다.

    마지막 줄이 표를 읽는 열쇠입니다. 모션이 없어도 16,384칸 배열을 도는 콜백 루프가 남아, 모션 100개일 때 Mono 단계 시간의 3분의 2 가 모션과 무관한 몫이었습니다. 32칸짜리 빈 러너 하나는 Mono 4.3 µs, IL2CPP 1.6 µs, 에디터 약 4.4 µs 였습니다. 러너는 한 번 생기면 목록에서 빠지지 않으므로, 한 번이라도 쓴 조합은 그 뒤로 매 프레임 이 값을 냅니다. 모션을 하나 더할 때의 비용(1,000개와 10,000개 행의 차이)은 에디터 111 ns, Mono 71 ns, IL2CPP 55 ns 였습니다.

    MotionDebugger 는 끝난 모션을 놓지 않는다

    2.0.2 에는 옛 MotionTracker 대신 MotionDebugger 와 Window/LitMotion Debugger 창이 있습니다. Enabled 는 실행 중에 읽는 bool 이고, 켜져 있으면 모션을 만들 때마다 추적 객체를 목록에 넣고 끝날 때 뺍니다. 빼는 조건이 문제입니다.

    Enabled 를 켜고 0.05초 모션 1,000개를 끝까지 재생하자 MotionDebugger.Items.Count 가 1,000 이었고, Cancel() 로 끝낸 1,000개는 0 이었습니다. 켜 두면 생성마다 Mono 544.7 B, IL2CPP 552.5 B 가 들었습니다. 창은 IsActive() 인 항목만 그리므로 쌓이는 것이 화면에 보이지 않고, 켬 상태는 EditorPrefs 에 남아 다음에 창이 열릴 때 되살아납니다.

    WithDebugName(string) 의 이름 필드는 릴리스 빌드에서 사라지지만, $"enemy {id}" 같은 보간 문자열은 호출하기 전에 만들어지므로 릴리스에서도 할당됩니다.

    주의할 점

    1. 모션이 적은 곳에서는 러너 수를 줄인다

    안 지키면 생기는 문제. 화면 연출 몇 개에 float, Vector2, Vector3, Color 를 섞고 타이밍도 나누면, 모션이 다 끝난 뒤에도 러너 여럿이 매 프레임 TempJob 할당 둘과 Job 대기를 합니다. 모션 100개일 때 하나당 비용이 만 개일 때의 여섯 배(에디터)에서 스물다섯 배(Mono)였던 것이 이 고정 비용입니다.

    이렇게 쓴다. 소수의 UI 연출에서는 LitMotion 을 “할당이 없고 API 가 짧다” 는 이유로 쓰고 속도 이득은 기대하지 않습니다. 값 타입과 타이밍 가짓수를 줄입니다. 위치 X 만 움직이면 float 에 BindToPositionX, x·y·z 를 같이 움직이면 Vector3 하나로 묶고, 스케줄러는 기본 Update 에 둡니다.

    2. 처음 쓰는 조합은 로딩 중에 한 번 만들고 취소해 둔다

    안 지키면 생기는 문제. 게임 도중 처음 쓰는 값 타입의 첫 모션에서 저장소와 러너가 생기며 할당과 시간이 한 프레임에 몰립니다. Mono 플레이어에서 첫 Color 모션 호출이 5.41 ms, 그 프레임 간격이 2.07 ms 에서 6.16 ms 로 튀었습니다.

    이렇게 쓴다. 로딩 중에 쓸 조합마다 LMotion.Create(...).RunWithoutBinding().Cancel() 로 모션 하나를 만들고 바로 취소합니다. 러너는 그때 생겨 사라지지 않습니다. 동시 최대치를 알면 EnsureStorageCapacity 로 배열 성장도 로딩으로 옮깁니다.

    3. MotionDebugger 는 볼 때만 켜고 Clear 로 비운다

    안 지키면 생기는 문제. 켜 두면 모션마다 550 B 안팎이 할당되고, 끝까지 재생된 모션의 추적 객체가 목록에 계속 쌓입니다. 켠 채로 프로파일링하면 이 몫이 측정에 섞입니다.

    이렇게 쓴다. 모션을 찾아야 할 때만 창에서 Enable 을 켜고 다 보면 끕니다. 긴 세션에서는 창의 Clear 나 MotionDebugger.Clear() 로 목록을 비웁니다.

    4. 디버그 이름에 보간 문자열을 넘기지 않는다

    안 지키면 생기는 문제. 릴리스 빌드에서 WithDebugName 의 본문이 비어도 넘길 문자열은 호출하는 쪽에서 만들어져, 모션마다 문자열 하나가 할당됩니다.

    이렇게 쓴다. 디버그 이름은 상수 문자열로 주거나, 호출하는 쪽을 #if DEVELOPMENT_BUILD || UNITY_EDITOR 로 감쌉니다.

    정리

    LitMotion 은 모션마다 드는 할당을 없앤 대신 러너마다 고정 비용을 냅니다.

    • static 람다와 state 로 만든 모션은 만들 때도 도는 동안에도 0 B 입니다. 할당은 새 조합의 첫 사용과 배열 성장에 몰립니다.
    • 러너는 모션이 없어도 매 프레임 네이티브 할당 둘, Job 대기, 배열 길이만큼의 콜백 루프를 돕니다. 모션 100개에서 하나당 740 ns, 만 개에서 117 ns 였습니다(에디터).
    • MotionDebugger 를 켜면 모션마다 할당이 생기고 끝난 모션이 목록에 남습니다.