LitMotion 은 모션을 빈칸 없는 배열에 모으고 맨 끝 원소로 구멍을 메운다
Esc

    LitMotion 은 모션을 빈칸 없는 배열에 모으고 맨 끝 원소로 구멍을 메운다

    2026년 10월 18일 수정–Unity라이브러리트윈
    var a = LMotion.Create(0f, 1f, 1f).Bind(static x => Debug.Log(x));
    var b = LMotion.Create(0f, 5f, 2f).BindToPositionX(transform);
    var c = LMotion.Create(Vector3.zero, Vector3.one, 1f).BindToPosition(transform);

    a 와 b 는 같은 배열의 이웃 칸에, c 는 다른 배열에 들어갑니다. 무엇을 움직이는지가 아니라 값의 타입이 자리를 가릅니다. 매 프레임 Job 이 이 배열을 0번부터 훑으려면 중간에 빈칸이 없어야 합니다.

    LitMotion 의 MotionStorage 는 모션을 빈칸 없는 밀집 배열에 두고, 새 모션은 꼬리에 붙이고, 빠질 때는 맨 끝 원소로 구멍을 메웁니다(swap-remove). 기준 판본은 2.0.2 입니다.

    저장소는 타입 조합과 타이밍마다 하나다

    저장소는 <TValue, TOptions, TAdapter> 조합 × PlayerLoop 타이밍마다 하나씩, 처음 쓰일 때 생깁니다. 같은 float 모션이라도 Update 와 PostLateUpdate 는 다른 저장소입니다. 모션 하나는 두 배열의 같은 칸에 나뉘어 삽니다.

    배열 담는 것 누가 만지나
    unmanagedDataArray 상태, 시간, 길이, 이징, 루프, 시작값, 끝값, 옵션 Burst Job 과 메인 스레드
    managedDataArray Bind 델리게이트, state, 완료·취소 콜백 메인 스레드

    가르는 기준은 Burst 가 읽을 수 있느냐입니다. 둘 다 NativeArray 가 아니라 평범한 C# 배열이고, Job 에 넘길 때만 fixed 로 고정해 포인터를 줍니다. 필드마다 배열을 나누는 모양이 아니라 모션 하나가 구조체 하나로 한 칸에 들어갑니다.

    새 모션은 꼬리에 붙고 배열은 두 배로 자란다

    EnsureCapacity(tail + 1);                       // 모자라면 네 배열을 모두 두 배로
    
    ref var data = ref unmanagedDataArray[tail];    // 꼬리 칸을 잡는다
    ref var managed = ref managedDataArray[tail];   // 관리 배열도 같은 번호 칸
    
    data.Status = Scheduled;                        // 시간, 길이, 시작값, 끝값 복사
    data.Time = 0;
    managed.UpdateAction = buffer.UpdateAction;     // 델리게이트와 state
    
    if (buffer.ImmediateBind && buffer.UpdateAction != null)
        managed.UpdateUnsafe(시작값);               // tail 을 올리기 전에 시작값을 한 번 쓴다
    
    var sparseIndex = sparseSetCore.Alloc(tail);    // 빈 슬롯을 꺼내 칸 번호를 적는다
    sparseIndexLookup[tail] = sparseIndex;          // 칸 → 슬롯도 거꾸로 적는다
    tail++;
    
    return new MotionHandle { Index = sparseIndex.Index, Version = sparseIndex.Version, StorageId = Id };

    자리가 있을 때입니다. 밀집 배열 [A, B, _, _], tail 2, 빈 슬롯 목록 [1(v2), 3(v1)] 에서 C 를 넣습니다.

    단계 밀집 배열 tail 슬롯
    용량 확인 4 ≥ 3, 그대로 2
    칸 2 에 값 복사 [A, B, C, _] 2
    Alloc(2) [A, B, C, _] 2 슬롯 1 → 칸 2 (v2), 빈 목록 [3(v1)]
    tail++ [A, B, C, _] 3

    돌려주는 핸들은 { Index = 1, Version = 2 } 입니다. 슬롯 1 은 예전에 한 번 쓰고 돌려받아 버전이 2 입니다.

    꽉 찼을 때는 첫 줄이 다릅니다. [A, B, C, D], 용량 4 에서 5칸이 필요하니 네 배열을 모두 8칸으로 새로 만들어 복사합니다(Array.Resize). 옛 배열은 버려지고, E 는 칸 4 에 들어가 tail 이 5 가 됩니다.

    표는 용량을 4 로 줄였습니다. 실제 초기 용량은 32 이고 두 배씩 늘어나며, 줄어드는 코드는 없습니다.

    중간에 빈칸이 없으니 새 모션은 언제나 tail 칸입니다. 핸들에는 밀집 칸 번호가 아니라 슬롯 번호가 들어갑니다. 배열이 차면 네 배열을 모두 두 배짜리로 새로 만들어 복사하고, 줄이는 코드는 없습니다. 초기 용량은 32 입니다.

    빠질 때는 맨 끝 원소가 구멍을 메운다

    지운 칸을 맨 끝 원소로 채우면 몇 개가 있든 비용이 같습니다. 옮겨 온 원소의 슬롯은 새 칸 번호로 고치고, 지운 모션의 슬롯은 버전을 올려 돌려받습니다. 네 모션 A, B, C, D 에서 B 를 뺍니다.

    tail--;                                         // 맨 끝 원소를 지울 칸으로 옮긴다
    data[dense] = data[tail];
    data[tail] = default;
    managed[dense] = managed[tail];
    managed[tail] = default;                        // 참조를 끊어 GC 가 거둘 수 있게
    
    var prev = lookup[dense];                       // 지워지는 원소의 슬롯
    var cur = lookup[dense] = lookup[tail];         // 옮겨 온 원소의 슬롯
    lookup[tail] = default;
    
    if (cur.Version != 0)
        slots[cur.Index].DenseIndex = dense;        // 옮겨 온 원소의 슬롯에 새 칸 번호
    if (prev.Version != 0)
        sparseSetCore.Free(prev);                   // 빈 슬롯 목록에 넣고 Version++

    밀집 [A, B, C, D], lookup [s0, s3, s1, s2], tail 4 에서 시작합니다.

    지우는 것 밀집 배열 lookup 슬롯 변화
    가운데 B (칸 1) [A, D, C, _] [s0, s2, s1, _] 슬롯 2 → 칸 1, 슬롯 3 v1 → v2
    맨 끝 D (칸 3) [A, B, C, _] [s0, s3, s1, _] 슬롯 2 → 칸 3 (tail 밖), 슬롯 2 v1 → v2

    B 를 지우면 D 가 B 자리로 옵니다. D 의 핸들은 슬롯 2 를 통해 그대로 쓰이고, B 의 옛 핸들 (Index 3, Version 1) 은 버전이 안 맞습니다. 맨 끝을 지울 때는 자기 자신을 자기 자리에 복사하고 지우는 셈이라, 옛 핸들은 버전으로도 칸 범위로도 걸립니다. B 가 빠진 자리에 D 가 들어오면서 배열 안 순서가 A, D, C 로 바뀐다는 점도 기억해 둡니다.

    B 가 빠진 자리에 D 가 들어오면서 배열 순서가 A, D, C 로 바뀌었습니다. 러너는 이 배열을 0번부터 돌며 콜백을 부르므로, 같은 저장소 모션들의 콜백 순서는 만든 순서를 따르지 않습니다.

    여러 개를 지울 때는 슬롯 번호로 바꿔 둔다

    매 프레임 Job 은 끝난 모션의 밀집 칸 번호를 모아 넘깁니다. RemoveAll 은 그 번호로 바로 지우지 않고 먼저 슬롯 번호로 바꿔 둡니다. 첫 제거가 맨 끝 원소를 옮기면 뒤의 칸 번호가 낡기 때문입니다.

    바꿔 둔 목록은 Allocator.Temp 에서 잡으므로 GC 할당이 아닙니다.

    Reset 은 내용만 비우고 크기는 남긴다

    MotionDispatcher.Clear() 는 저장소마다 Reset() 을 부릅니다. 배열을 0 으로 지우고 tail 을 0 으로 돌릴 뿐 배열 길이는 그대로입니다. 러너의 콜백 루프는 모션 수가 아니라 배열 길이만큼 돌기 때문에, 남은 길이가 매 프레임 비용으로 남습니다.

    커스텀 AnimationCurve 이징의 곡선 메모리도 같습니다. 곡선은 저장소마다 하나인 RewindableAllocator 에 복사되는데, 모션이 빠질 때 돌려주는 코드가 없고 되감는 곳은 Reset() 하나입니다. 키 3개 곡선 모션 6만 개를 끝까지 재생하며 재 보았습니다.

    시점 할당자 블록 블록 크기 합
    시작 1개 128 KB
    6만 개를 모두 끝낸 뒤 7개 16,256 KB
    곡선 모션 1만 개를 더 만들고 끝낸 뒤 7개 16,256 KB
    MotionDispatcher.Clear() 뒤 7개 16,256 KB

    RewindableAllocator.Rewind() 는 이번 주기에 쓴 블록을 “처음부터 다시 쓸 수 있게” 만들 뿐 풀지 않습니다. 한 번 커진 16 MB 는 프로세스가 계속 쥡니다.

    주의할 점

    1. 같은 타입 모션들의 콜백 순서에 기대지 않는다

    안 지키면 생기는 문제. 가운데 모션 하나만 끝나도 맨 끝 모션이 그 자리로 옵니다. A, B, C 순서로 만든 모션의 Bind 와 OnComplete 가 A, C, B 순서로 불릴 수 있고, 한 콜백이 다른 모션이 이번 프레임에 쓴 값을 전제로 하면 어느 날부터 가끔 틀립니다.

    이렇게 쓴다. 순서가 필요한 값은 모션 하나에 묶어 한 콜백 안에서 씁니다. 여러 모션의 결과를 합칠 때는 각 콜백이 자기 필드에만 쓰고, 합치는 일은 LateUpdate 처럼 모든 갱신이 끝난 뒤에 합니다.

    2. EnsureStorageCapacity 는 실제 최대치만큼만 잡는다

    안 지키면 생기는 문제. 로딩 때 배열을 미리 키우면 실행 중 Array.Resize 는 사라지지만, 키운 길이는 줄지 않고 매 프레임 훑입니다. 모션 0개에 용량 16,384 인 저장소 하나가 프레임당 0.174 ms 를 더했고 Clear() 로도 돌아오지 않았습니다. 이 API 는 쓰지 않을 타이밍의 저장소까지 여덟 개를 모두 만듭니다.

    이렇게 쓴다. 그 타입으로 동시에 도는 모션의 최대치를 재고 그보다 조금 큰 2의 거듭제곱으로 잡습니다. 한 장면에서만 몰리는 대량 연출은 평소 쓰지 않는 타이밍으로 떼어 평소 저장소가 커지지 않게 합니다.

    3. 커스텀 곡선 모션을 끝없이 새로 만들지 않는다

    안 지키면 생기는 문제. 곡선 메모리는 모션 단위로 돌아오지 않아, 곡선 모션 6만 개에 할당자가 128 KB 에서 16 MB 로 커졌고 Clear() 뒤에도 그대로였습니다. GC 쪽 수치에는 보이지 않습니다.

    이렇게 쓴다. 꼭 필요한 연출이 아니면 Ease 열거형의 내장 이징을 씁니다. 곡선 모션을 많이 만드는 연출은 프로파일러의 Memory 모듈에서 네이티브 메모리가 계단처럼 오르는지 보고, Clear() 로 줄어들 거라고 기대하지 않습니다.

    정리

    LitMotion 은 모션을 빈칸 없는 배열에 모으고 맨 끝 원소로 구멍을 메웁니다.

    • 저장소는 타입 조합 × 타이밍마다 하나이고, 모션 하나는 Burst 용 구조체 배열과 콜백 배열의 같은 칸에 삽니다.
    • 새 모션은 꼬리에 붙고, 빠질 때는 맨 끝이 구멍을 메워 배열 순서가 바뀝니다. 여러 개를 지울 때는 슬롯 번호로 바꿔 두고 지웁니다.
    • 배열 길이와 곡선용 할당자는 한 번 커지면 Clear() 로도 줄지 않습니다.