LitMotion 의 빌더는 풀에서 빌린 버퍼를 가리키는 구조체다
Esc

    LitMotion 의 빌더는 풀에서 빌린 버퍼를 가리키는 구조체다

    2026년 10월 18일 수정–Unity라이브러리트윈
    LMotion.Create(0f, 1f, 0.3f)
        .WithEase(Ease.OutQuad)
        .WithDelay(0.1f)
        .Bind(group, static (x, g) => g.alpha = x);

    Create 로 시작해 With... 를 잇고 Bind 로 끝나는 사슬입니다. 빌더를 클래스로 만들면 모션마다 객체가 생기고, 설정을 전부 구조체에 담으면 고리마다 설정 전체가 복사됩니다.

    LitMotion 의 MotionBuilder 는 풀에서 빌린 버퍼 참조 하나와 ushort 버전 하나만 든 구조체입니다. 설정은 버퍼에 쌓이고, Bind 하면 버퍼가 풀로 돌아가며 버전이 오릅니다. 기준 판본은 2.0.2 입니다.

    설정은 풀에서 빌린 버퍼에 쌓인다

    // Runtime/MotionBuilder.cs
    public struct MotionBuilder<TValue, TOptions, TAdapter> : IDisposable
    {
        internal ushort version;
        internal MotionBuilderBuffer<TValue, TOptions> buffer;   // 시작값, 끝값, 이징, 콜백, 스케줄러 전부
    
        public readonly MotionBuilder<TValue, TOptions, TAdapter> WithEase(Ease ease)
        {
            CheckBuffer();
            buffer.Ease = ease;
            return this;   // 복사본이지만 같은 버퍼를 가리킨다
        }
    }

    With... 가 돌려주는 복사본들은 모두 같은 버퍼를 가리키므로 어느 복사본으로 설정해도 한곳에 모입니다. 복사하는 것은 참조와 숫자 하나뿐입니다.

    버퍼 풀에는 컨테이너가 없습니다. 버퍼 클래스 자신의 NextNode 필드로 엮인 스택이고, 정적 필드 PoolRoot 가 머리입니다. 빌더 둘을 겹쳐 쓰다가 차례로 Bind 하고 셋째를 만드는 경우입니다.

    // Rent(): LMotion.Create 가 부른다
    if (PoolRoot == null)
    {
        result = new();                    // 풀이 비었을 때만 new
    }
    else
    {
        result = PoolRoot;                 // 머리를 꺼낸다
        PoolRoot = PoolRoot.NextNode;
        result.NextNode = null;
    }
    
    // Return(buffer): Bind 가 끝나면 부른다
    buffer.Version++;                      // 버전을 올리고 필드를 전부 기본값으로
    
    if (buffer.Version != ushort.MaxValue)
    {
        buffer.NextNode = PoolRoot;        // 머리에 끼운다
        PoolRoot = buffer;
    }

    풀에 버퍼 A 하나가 있을 때 빌더 둘을 겹쳐 쓰다가 차례로 Bind 하고 셋째를 만들면 이렇습니다.

    일 풀 빌더가 받은 버퍼
    첫째 Create 비었음 빌더1 → A (v0)
    둘째 Create. 빌더1 은 아직 Bind 전 비었음 빌더2 → B (v0). 여기서 new
    빌더1 Bind A (v1)
    빌더2 Bind B (v1) → A (v1)
    셋째 Create A (v1) 빌더3 → B (v1)

    new 는 동시에 들고 있는 빌더 수가 풀 크기를 넘을 때만 생깁니다.

    보통은 Create 부터 Bind 까지 한 줄이라 동시에 살아 있는 빌더가 하나이고, 정적 초기화로 만든 버퍼 하나가 계속 돕니다. 풀은 <TValue, TOptions> 마다 따로이고 잠금이 없어 메인 스레드 전용입니다.

    버전이 옛 복사본을 막는다

    모든 With... 와 Bind 는 첫 줄에서 버퍼의 버전과 빌더가 베껴 둔 버전을 비교합니다.

    // Runtime/MotionBuilder.cs
    readonly void CheckBuffer()
    {
        if (buffer == null || buffer.Version != version) throw new InvalidOperationException("MotionBuilder is either not initialized or has already run a Build (or Bind).");
    }

    Bind 는 끝에서 자기 변수의 buffer 를 null 로 만들지만, 미리 떠 둔 복사본은 여전히 버퍼를 가리킵니다. 그 버퍼는 곧바로 다음 빌더에게 나가므로, 옛 복사본이 가리키는 것은 “지나간 버퍼” 가 아니라 “지금 남이 쓰는 버퍼” 입니다.

    Dispose 에는 이 검사가 없습니다. buffer 가 null 이 아니면 그대로 반납합니다. 그래서 Bind 한 빌더의 복사본을 Dispose 하면 같은 버퍼가 풀에 두 번 들어가고(NextNode 가 자기 자신을 가리킴), 다음 Create 두 번이 같은 버퍼와 같은 버전을 받습니다. 둘째 빌더에 적은 설정이 첫째 모션에 섞이고, 첫째가 Bind 한 뒤에야 둘째가 예외를 냅니다.

    state 인자가 클로저를 없앤다

    Bind 는 델리게이트를 object 필드 하나에, state 를 State0 부터 차례로 넣습니다. 매 프레임에는 UnsafeUtility.As 로 Action<TValue, object> 로 다시 읽어 부릅니다. 참조끼리는 넘기는 방식이 같아서 이 재해석이 성립하고, 그래서 state 는 where TState : class 입니다.

    람다가 대상을 붙잡지 않고 인자로 받으면 컴파일러가 델리게이트를 한 번만 만들어 재사용합니다. BindToPosition 같은 확장 메서드도 안에서 Bind(transform, static (x, t) => t.position = x) 를 부릅니다.

    붙잡는 람다만 모션마다 152 B 였고 에디터, Mono, IL2CPP 가 같았습니다. 클로저 객체와 Action<Vector3> 가 새로 생기는 몫이고, LitMotion 쪽은 어느 탭에서도 할당하지 않았습니다(Vector3 위치 모션 100개를 한 번에 만든 순할당을 100 으로 나눔).

    state 가 구조체면 확장 오버로드가 Box<T> 로 감싸서 부를 때마다 상자를 만듭니다(Mono 20 B, IL2CPP 24 B). 미리 만든 상자를 쓰는 것은 int -1~9 와 bool 뿐인데, 2.0.2 의 BoxFalse 는 new(true) 로 만들어져 있어 Bind(false, ...) 의 콜백이 true 를 받습니다.

    Bind 하는 순간 시작값을 한 번 쓴다

    Bind 는 버퍼의 값을 저장소 배열로 옮겨 적고 버퍼를 돌려줍니다. 옮기는 도중 ImmediateBind 가 켜져 있으면 진행도 0 의 값으로 콜백을 그 자리에서 한 번 부릅니다. 모션의 첫 갱신은 다음 PlayerLoop 이라, 이게 없으면 만든 프레임에 대상이 옛 값에 머뭅니다.

    이 블록은 딜레이를 보지 않습니다. 위치 (9, 9, 9) 인 Transform 에 WithDelay(1f).BindToPosition 을 걸면 같은 프레임에 (0, 0, 0) 으로 가서 딜레이 내내 거기 머뭅니다. WithImmediateBind(false) 를 붙이면 (9, 9, 9) 를 지킵니다. 빌더 버퍼의 기본값은 true 이고, 직접 new MotionSettings<...> 로 만든 설정은 false 라 첫 대입이 한 프레임 늦습니다.

    주의할 점

    1. 빌더를 담아 두고 다시 쓰거나 Dispose 하지 않는다

    안 지키면 생기는 문제. Bind 한 빌더나 그 복사본으로 다시 With... 나 Bind 를 부르면 MotionBuilder is either not initialized or has already run a Build (or Bind). 예외입니다. 복사본을 Dispose 하면 예외도 없이 다음 두 빌더가 한 버퍼를 나눠 씁니다.

    이렇게 쓴다. Create 부터 Bind 까지 한 문장으로 끝냅니다. 설정을 재사용하려면 ToMotionSettings() 로 뽑은 MotionSettings 를 보관하고 LMotion.Create(settings) 로 매번 새 빌더를 받습니다. Dispose 는 Bind 하지 않기로 한 빌더 변수 하나에만 부릅니다.

    2. 대상을 붙잡는 람다로 Bind 하지 않는다

    안 지키면 생기는 문제. Bind(x => t.position = x) 는 모션마다 152 B 를 할당합니다. 목록 칸마다 모션을 거는 UI 라면 스크롤할 때마다 GC 거리가 쌓입니다.

    이렇게 쓴다. 확장 메서드가 있으면 그것을, 없으면 Bind(대상, static (x, 대상) => ...) 를 씁니다. state 는 셋까지 넘길 수 있고, static 을 붙이면 실수로 붙잡았을 때 컴파일이 막습니다.

    3. 구조체, 특히 bool 을 state 로 넘기지 않는다

    안 지키면 생기는 문제. Bind(false, (x, flag) => ...) 의 flag 는 true 로 들어옵니다. int -1~9 밖의 구조체 state 는 부를 때마다 상자를 할당합니다.

    이렇게 쓴다. 켜짐, 꺼짐은 static 람다 두 개 중 하나를 고르는 식으로 나누고, 값이 필요하면 그 값을 가진 참조 타입(컴포넌트 자신 같은 것)을 state 로 넘깁니다.

    4. 딜레이 동안 제자리를 지켜야 하면 ImmediateBind 를 끈다

    안 지키면 생기는 문제. WithDelay 를 걸어도 만드는 순간 시작값이 한 번 대입됩니다. 1초 뒤에 날아가야 할 아이콘이 만들자마자 출발점으로 튀어 거기서 기다립니다.

    이렇게 쓴다. 그런 모션에는 WithImmediateBind(false) 를 붙입니다. 반대로 MotionSettings 를 직접 만들 때는 ImmediateBind = true 를 적어 빌더와 기본값을 맞춥니다.

    정리

    LitMotion 의 빌더는 풀에서 빌린 버퍼를 가리키는 작은 구조체입니다.

    • 설정은 버퍼에 쌓이고 복사본은 모두 같은 버퍼를 가리킵니다. 풀은 버퍼 자신의 NextNode 로 엮인 스택입니다.
    • Bind 가 버퍼를 돌려주며 버전을 올려 옛 복사본을 막습니다. Dispose 만 이 검사를 건너뜁니다.
    • 대상을 state 로 넘기고 static 람다를 쓰면 모션당 0 B 입니다. 붙잡는 람다는 152 B 였습니다.