LitMotion 은 절대 시각 하나에서 루프와 진행률을 계산한다
Esc

    LitMotion 은 절대 시각 하나에서 루프와 진행률을 계산한다

    2026년 10월 18일 수정–Unity라이브러리트윈
    elapsed += Time.deltaTime;
    while (elapsed >= duration) { elapsed -= duration; loops++; OnLoop(); }
    value = Mathf.Lerp(from, to, elapsed / duration);

    손으로 쓴 Update 보간은 이렇게 델타를 쌓고, 길이를 넘을 때마다 빼면서 루프를 셉니다. 한 프레임이 길면 while 이 여러 번 돌고, 임의의 시각으로 건너뛰려면 따로 코드를 써야 합니다.

    LitMotion 은 모션이 시작된 뒤의 절대 시각 하나를 받아, 나눗셈과 floor 로 루프 수와 진행률을 처음부터 다시 계산합니다. Runtime/Internal/MotionData.cs 의 MotionData.Update(double time, out float progress) 한 함수입니다. 기준 판본은 2.0.2 입니다.

    시각은 한 줄에서 쌓이고 나머지는 나눗셈이다

    시각이 쌓이는 곳은 Job 의 한 줄뿐입니다.

    // Runtime/MotionUpdateJob.cs (줄임)
    var time = state.Time + deltaTime * state.PlaybackSpeed;
    ptr->Update<TAdapter>(time, out var result);

    그다음부터는 더하기가 없습니다. 한 프레임에 2.7초가 흘렀을 때 두 방식입니다.

    결과는 둘 다 “루프 3, 진행률 0.2” 입니다. LitMotion 은 넘긴 루프가 몇 개든 나눗셈 한 번이고, 핸들의 Time 에 값을 넣는 점프도 같은 함수를 한 번 부를 뿐입니다.

    대신 콜백 수는 맞추지 않습니다. OnLoopComplete 는 CompletedLoops > PrevCompletedLoops 비교 하나로 불리므로, 한 번에 루프 셋을 넘으면 인자 3 으로 한 번 불립니다. WithLoops(5) 모션에 h.Time = 3.2 를 넣었더니 기록이 [3] 한 건이었습니다.

    루프 경계에서 진행률은 0 이다

    기본 딜레이 방식인 FirstLoop 는 딜레이를 시각에서 한 번 빼고 길이로 나눕니다. Duration = 1, Delay = 0.5, Loops = 3 모션을 세 시각에서 멈춰 봅니다.

    completedLoops = (int)math.floor(TimeSinceStart / Duration);   // TimeSinceStart = Time - Delay
    clamped = GetClampedCompletedLoops(completedLoops);            // 0 아래는 0 으로 자른다
    isCompleted = Loops >= 0 && clamped > Loops - 1;               // 마지막 경계에서만 완료
    isDelayed = TimeSinceStart < 0f;
    
    if (isCompleted)
        t = 1f;                                                    // 완료일 때만 1 로 고정
    else
        t = math.clamp((TimeSinceStart - Duration * clamped) / Duration, 0f, 1f);

    Duration 1, Delay 0.5, Loops 3 인 모션입니다.

    Time TimeSinceStart completedLoops → clamped 완료 결과
    0.2 -0.3 -1 → 0 0 > 2 거짓 Delayed, t = 0
    1.5 1.0 1 → 1 1 > 2 거짓 Playing, t = 0, OnLoopComplete(1)
    3.5 3.0 3 → 3 3 > 2 참 Completed, t = 1

    Time 1.5 는 정확히 루프 경계라 (1.0 - 1) / 1 = 0 이 됩니다. 루프 경계에서 t 는 끝값 1 이 아니라 다음 루프의 0 이고, 1 이 보장되는 것은 마지막 경계뿐입니다.

    루프 경계에서 t 는 1 이 아니라 0 입니다. 몫이 올라가는 순간 나머지는 0 이기 때문입니다. 직전 프레임이 0.98 에서 끝났고 이번 프레임이 경계를 조금 넘었다면 그 루프의 끝값은 한 번도 출력되지 않습니다. 1 이 보장되는 것은 마지막 경계, 곧 Delay + Duration × Loops 에서 완료로 판정될 때뿐입니다.

    DelayType.EveryLoop 는 딜레이 + 재생을 한 주기로 보고 fmod 로 주기 안의 위치를 구합니다. 음수인 동안이 그 주기의 딜레이 구간입니다.

    LoopType 네 가지는 식 하나씩이다

    t 에 이징을 씌워 진행률로 바꾸는 식만 다릅니다. OutQuad(E(x) = 1 - (1 - x)²) 로 둘째 루프의 4분의 1 지점을 계산합니다.

    switch (Parameters.LoopType)
    {
        case LoopType.Restart:       // 루프마다 처음부터 같은 곡선
            progress = E(t);
            break;
        case LoopType.Flip:          // 홀수 루프면 값을 위아래로 뒤집는다
            progress = E(t);
            if ((clamped + (int)t) % 2 == 1)
                progress = 1f - progress;
            break;
        case LoopType.Incremental:   // 끝난 루프만큼 한 칸씩 쌓는다
            progress = E(1f) * clamped + E(t % 1);
            break;
        case LoopType.Yoyo:          // 홀수 루프면 시간을 뒤집어 거꾸로 달린다
            progress = (clamped + (int)t) % 2 == 1 ? E(1f - t) : E(t);
            break;
    }

    OutQuad 이징, clamped = 1 (두 번째 루프), t = 0.25 인 같은 순간입니다.

    LoopType 계산 progress
    Restart E(0.25) 0.4375
    Flip 1 - E(0.25) 0.5625
    Yoyo E(1 - 0.25) = E(0.75) 0.9375
    Incremental 1 × 1 + E(0.25) 1.4375

    Flip 은 값을, Yoyo 는 시간을 뒤집으니 이징이 곡선이면 결과가 다릅니다. 둘이 같은 값이 되는 것은 선형 이징뿐입니다.

    LoopType 홀수 루프의 식 돌아오는 길 (OutQuad)
    Restart E(t) 시작값으로 튀어 다시 출발
    Flip 1 - E(t) 값 축을 뒤집어, 돌아올 때도 빠르게 출발해 천천히 도착
    Yoyo E(1 - t) 시간 축을 뒤집어, 가던 길을 되감듯 천천히 출발해 빠르게 도착
    Incremental E(1)·n + E(t) 돌아오지 않고 루프마다 한 칸 더

    Incremental 은 진행률이 1 을 넘고, 기본 어댑터가 LerpUnclamped 처럼 자르지 않는 보간이라 그대로 값이 됩니다.

    Complete() 는 다른 식을 쓴다

    handle.Complete() 는 Update 를 부르지 않고 따로 짠 식으로 최종 진행률을 냅니다. Restart, Flip, Yoyo 는 끝까지 재생한 값과 같지만 Incremental 은 E(1) × Loops 가 아니라 E(Loops) 를 씁니다. 이징 함수는 입력을 0~1 로 자르지 않습니다.

    실측 값이 식과 소수점까지 같았습니다. 무한 루프는 최종값이 없어서 Complete() 가 InvalidOperationException 을 던집니다.

    0 과 끝자락에서 생기는 일

    경우 동작
    WithLoops(0) 완료 판정 clamped > -1 이 늘 참. WithDelay(1f) 를 걸어도 생성 다음 프레임에 끝값 1 로 완료
    길이 0, Time.timeScale = 0 완료 조건이 TimeSinceStart > 0 이라 30프레임 동안 완료되지 않음. timeScale 을 1 로 되돌린 다음 프레임에 완료
    재생 중 PlaybackSpeed = -1 (보존 안 함) 시작점을 지나 Delayed 에 머묾. 2초 뒤 handle.Time -1.50, 끝나지도 사라지지도 않고 매 프레임 시작값을 씀
    0.01초 무한 루프가 65,536번째 경계를 넘음 CompletedLoops 가 ushort 라 0 으로 돌아가고, 그 사이 넘은 두 루프의 OnLoopComplete 가 한 번도 불리지 않음

    주의할 점

    1. 루프 경계에서 끝값이 찍힌다고 기대하지 않는다

    안 지키면 생기는 문제. 루프가 넘어가는 프레임의 t 는 다음 루프의 위치입니다. OnLoopComplete 에서 “지금 값이 끝값” 이라고 가정하고 판정하면, Restart 라면 시작값 근처가 들어옵니다.

    이렇게 쓴다. 루프 끝의 값이 필요하면 지금 값이 아니라 끝값 파라미터를 씁니다. OnLoopComplete 에서는 몇 번째 루프인지만 받습니다.

    2. Incremental 에 비선형 이징을 줬으면 Complete() 로 끝내지 않는다

    안 지키면 생기는 문제. OutQuad, Loops = 3 이면 끝까지 재생한 3 대신 -3 에서 멈춥니다. 스킵 버튼이 Complete() 를 부르는 연출에서만 오브젝트가 엉뚱한 곳으로 튑니다.

    이렇게 쓴다. Incremental 에는 Ease.Linear 를 씁니다. 비선형 이징이 꼭 필요하면 스킵할 때 Cancel() 하고 최종값을 직접 넣습니다.

    3. WithLoops(0) 을 재생하지 않음으로 쓰지 않는다

    안 지키면 생기는 문제. 루프 0 은 “끝값을 한 번 쓰고 끝난다” 입니다. 딜레이도 기다리지 않습니다. 설정값으로 루프 수를 받는 코드에 0 이 들어오면 연출 없이 오브젝트가 끝 위치로 순간이동합니다.

    이렇게 쓴다. 재생하지 않을 거면 모션을 만들지 않습니다. 루프 수를 외부 값으로 받으면 1 이상인지 먼저 봅니다.

    4. OnLoopComplete 호출 수를 루프 수로 세지 않는다

    안 지키면 생기는 문제. 한 프레임에 여러 루프를 넘으면 콜백은 한 번이고, 무한 루프는 65,536번째에서 카운터가 0 으로 돌며 콜백이 빠집니다. 콜백 수로 점수나 박자를 맞추는 코드는 히치 한 번, 긴 플레이 한 번에 어긋납니다.

    이렇게 쓴다. 루프 수가 로직에 쓰이면 경과 시간을 루프 길이로 나눠 직접 계산합니다. OnLoopComplete 는 연출 신호로만 씁니다.

    정리

    LitMotion 은 절대 시각 하나에서 floor 로 루프와 진행률을 계산합니다.

    • 시각이 쌓이는 곳은 Job 의 한 줄이고, 루프 수와 위치는 나눗셈과 나머지입니다. 긴 프레임과 점프가 같은 비용입니다.
    • 루프 경계의 진행률은 0 이고 1 은 마지막 경계에서만 나옵니다. 여러 루프를 넘어도 OnLoopComplete 는 한 번입니다.
    • LoopType 은 진행률 식만 다르고, Complete() 는 따로 짠 식이라 Incremental 과 비선형 이징에서 값이 갈립니다.