LitMotion 은 끝난 모션을 표시만 바꿔 두고 다음 프레임에 지운다
Esc

    LitMotion 은 끝난 모션을 표시만 바꿔 두고 다음 프레임에 지운다

    2026년 10월 18일 수정–Unity라이브러리트윈
    var handle = LMotion.Create(0f, 5f, 0.5f)
        .WithOnComplete(() => Debug.Log("끝"))
        .WithOnCancel(() => Debug.Log("취소"))
        .BindToPositionX(transform);
    
    handle.Cancel();   // 이 줄 뒤에 handle 은 어떤 상태인가

    코루틴은 StopCoroutine 을 부르면 그 자리에서 사라집니다. LitMotion 의 모션은 Job 이 여러 스레드에서 나눠 계산하고 메인 스레드가 번호 순서로 콜백을 도는 배열의 한 칸이라, 도는 도중에 칸을 지울 수 없습니다.

    그래서 Cancel 과 Complete 는 칸의 상태 값만 바꾸고, 칸을 지우는 일은 다음 러너 갱신의 Job 과 RemoveAll 이 합니다. 그 한 프레임이 핸들 판정, 거절 조건, 기다리는 코드의 동작을 정합니다. 기준 판본은 2.0.2 입니다.

    끝난 모션은 한 프레임 동안 칸에 남는다

    상태는 None, Scheduled, Delayed, Playing, Completed, Canceled, Disposed 일곱 개입니다. Job 은 이미 Completed 나 Canceled 인 칸만 골라 Disposed 로 바꾸고 번호를 모으며, 러너는 콜백 루프가 다 끝난 뒤 그 칸들을 지웁니다.

    // 사용자 코드
    handle.Cancel();   // Status = Canceled. OnCancel 은 여기서 바로 불린다
    
    // UpdateRunner.Update: 프레임마다 러너마다 한 번
    job.Schedule(count, 16).Complete();
    
    // MotionUpdateJob.Execute(i)
    if (Status is Scheduled or Delayed or Playing || (IsPreserved && Status == Completed))
    {
        Update(time);                    // 끝에 닿으면 Status = Completed
    }
    else if ((!IsPreserved && Status == Completed) || Status == Canceled)
    {
        CompletedIndexList.Add(i);       // 지울 목록에 넣는다
        Status = Disposed;
    }
    
    // 콜백 루프 (메인 스레드)
    if (canUpdate)
        managedData.UpdateUnsafe(output[i]);       // Bind. Disposed 는 canUpdate 가 아니다
    if (isCompleted && WasStatusChanged)
        managedData.InvokeOnComplete();
    
    storage.RemoveAll(completedIndexList);         // 칸을 비우고 슬롯 Version++

    끝까지 재생하는 경우입니다. 프레임 N 직전 상태는 Playing, Time 0.48, 길이 0.5 입니다.

    프레임 Job 이 한 일 콜백 루프 상태 IsValid IsActive
    N 0.48 + 0.02 로 끝에 닿음 Bind(5), OnComplete Completed 참 거짓
    N+1 보존하지 않은 Completed 라 지울 목록에 넣음 없음 Disposed → 칸 제거, Version 1 → 2 거짓 거짓

    도중에 Cancel() 을 부르면 이렇게 됩니다.

    때 일 상태 IsValid IsActive
    Cancel() 호출 OnCancel 을 그 자리에서 부름. 값은 되돌리지 않음 Canceled 참 거짓
    다음 러너 갱신 지울 목록에 넣고 칸 제거, Version 1 → 2 칸 없음 거짓 거짓

    끝난 모션은 한 번의 러너 갱신 동안 칸을 차지한 채 남습니다. 그 사이 IsValid 는 참, IsActive 는 거짓입니다.

    그 한 프레임 동안 세 판정이 갈립니다.

    칸의 상태 IsValid IsActive IsPlaying
    Scheduled, Delayed, Playing 참 참 참
    Completed, Canceled (지워지기 전) 참 거짓 거짓
    Completed + Preserve() 참 참 거짓
    지워짐, 또는 default 핸들 거짓 거짓 거짓

    실제로도 끝난 프레임에 IsValid() 참, IsActive() 거짓, 한 프레임 뒤 둘 다 거짓이었습니다. IsValid 는 칸이 아직 있느냐만 묻고, 조작할 일이 남았는지는 IsActive 가 답합니다.

    Cancel 과 Complete 는 받아 주는 조건이 다르다

    모션 Cancel() Complete()
    재생 중 상태만 Canceled, OnCancel. 값은 되돌리지 않음 최종값 Bind 한 번, OnLoopComplete, OnComplete
    끝남 (보존 안 함) Motion has already been canceled or completed. 같은 예외
    끝남 + Preserve() 받아 줌, OnCancel 같은 예외
    무한 루프 받아 줌 ...loops infinitely. If you want to end the motion, call Cancel() instead.

    Preserve() 는 Job 의 제거 조건 하나를 끄는 표시입니다. 보존한 모션은 끝난 뒤에도 칸에 남아 매 프레임 다시 계산되고 Bind 를 받습니다(끝난 시점 47회에서 10프레임 뒤 57회). 상태가 Completed 에서 Completed 로 가므로 OnComplete 는 다시 불리지 않습니다. 슬라이더로 handle.Time 을 긁는 스크러빙을 위한 장치이고, 푸는 방법은 Cancel() 하나입니다.

    TryCancel(), TryComplete() 는 같은 검사를 하고 예외 대신 false 를 돌려줍니다.

    콜백 예외는 기본으로 로그만 남긴다

    Bind 가 예외를 던지면 러너는 전역 핸들러(기본 Debug.LogException)에 넘기고, CancelOnError 가 켜져 있을 때만 모션을 취소합니다. 빌더의 기본값은 false 입니다.

    WithCancelOnError 경로에는 틈이 있습니다. 이때 부르는 OnCancelAction?.Invoke() 는 try 밖이라, OnCancel 이 또 던지면 예외가 콜백 루프를 빠져나갑니다. 앞 칸 모션 A 의 Bind 와 OnCancel 이 모두 던지게 했더니, 뒤 칸 모션 B 의 Bind 가 그 프레임 한 번 빠졌습니다. 같은 프레임에 끝난 칸을 치우는 RemoveAll 도 그 프레임에는 돌지 않습니다.

    전역 핸들러는 등록이 아니라 교체입니다. RegisterUnhandledExceptionHandler(null) 을 넣으면 ?.Invoke 라서 콜백 예외가 소리 없이 사라집니다.

    AddTo 는 게임오브젝트의 이벤트에서 목록을 돈다

    AddTo(gameObject) 는 대상에 MotionHandleLinker 를 한 번 붙이고 핸들을 목록에 넣습니다. 유니티가 OnDestroy(기본 CancelOnDestroy)나 OnDisable(CancelOnDisable, CompleteOnDisable)을 부를 때 목록을 돌며 IsActive() 인 핸들에 Cancel() 이나 Complete() 를 부릅니다. 목록에서 빼는 코드는 없습니다.

    IsActive() 가 참인데 Complete() 가 거절하는 두 경우가 걸립니다. CompleteOnDisable 로 B(재생 중), C(보존한 완료 또는 무한 루프), D(재생 중)를 묶고 SetActive(false) 했더니 B 는 완료, C 에서 예외 한 줄, D 는 5프레임 뒤에도 재생 중이었습니다. 예외가 OnDisable 을 빠져나가 D 는 검사조차 받지 못한 것입니다.

    MonoBehaviour 안에서 AddTo(this) 라고 쓰면 더 구체적인 오버로드가 골라져 링커 대신 destroyCancellationToken 에 클로저를 등록합니다. 호출당 Mono 887 B, IL2CPP 891 B 였고 AddTo(gameObject) 는 28~31 B 였습니다(목록 배열이 커진 몫이 나뉜 값).

    기다리는 길은 넷이고 취소를 알리는 방식이 다르다

    await handle 의 continuation 은 모션의 OnCompleteAction 에 붙어 있어서, 이어지는 코드는 다른 곳에 예약되지 않고 러너의 콜백 루프 안에서 곧바로 돕니다. 스택에 UpdateRunner<...>.Update 가 있었고 frameCount 도 OnComplete 가 불린 프레임과 같았습니다.

    OnCancelAction 에 붙은 같은 continuation 은 떼어지지 않습니다. Preserve() 한 모션을 await handle 로 기다리면 완료 때 한 번, 나중에 Cancel() 할 때 한 번 더, 이미 끝난 메서드의 다음 줄이 조용히 다시 돌았습니다. ToValueTask 는 끝날 때 원래 델리게이트를 되돌려 놓아 한 번이었습니다.

    주의할 점

    1. IsValid 로 재생 중인지 판단하지 않는다

    안 지키면 생기는 문제. 끝나거나 취소된 프레임에도 칸이 남아 있어 IsValid() 가 참입니다. 이를 “재생 중” 으로 읽고 Complete() 를 부르면 Motion has already been canceled or completed. 예외가 나고, 새 모션을 거는 조건이 한 프레임 늦습니다.

    이렇게 쓴다. 조작할 수 있는지는 IsActive(), 진행 중인지는 IsPlaying() 으로 묻습니다. 끝내거나 멈출 때는 TryComplete(), TryCancel() 을 씁니다.

    2. 무한 루프와 보존한 모션은 Cancel 로 끝낸다

    안 지키면 생기는 문제. 두 경우 모두 IsActive() 는 참인데 Complete() 는 거절합니다. CompleteOnDisable 링커나 CompositeMotionHandle.Complete() 는 IsActive() 만 보고 부르므로, 목록 중간에서 예외가 나면 뒤의 모션은 처리되지 않고 남습니다. 보존한 모션은 대상이 파괴된 뒤에도 매 프레임 Bind 를 받습니다.

    이렇게 쓴다. 무한 루프와 보존한 모션은 Cancel() 로 끝내고, 끝값이 필요하면 OnCancel 에서 직접 씁니다. Preserve() 를 쓴 자리에는 .AddTo(gameObject) 를 같이 붙여 파괴에 취소를 맡깁니다.

    3. 대상이 사라질 수 있는 모션에는 WithCancelOnError 를 붙이고, OnCancel 에서 던지지 않는다

    안 지키면 생기는 문제. 기본 설정이면 대상이 파괴된 뒤 남은 시간 동안 매 프레임 같은 예외가 콘솔에 쌓이고, 끝나면 OnComplete 까지 불립니다. WithCancelOnError 를 붙인 모션의 OnCancel 이 던지면 같은 러너의 뒤쪽 모션이 한 프레임 건너뜁니다.

    이렇게 쓴다. 대상이 파괴될 수 있으면 WithCancelOnError() 를 붙입니다. OnCancel 본문은 try/catch 로 감싸고 if (target == null) return; 으로 시작합니다.

    4. 끝났는지 끊겼는지가 중요하면 ToValueTask 로 기다린다

    안 지키면 생기는 문제. await handle 은 취소돼도 예외 없이 다음 줄로 넘어갑니다. 연출이 끊겨도 다음 단계로 가고, 대상이 파괴돼 취소된 경우라면 다음 줄이 파괴된 오브젝트를 만집니다.

    이렇게 쓴다. await handle.ToValueTask(token) 을 쓰고 OperationCanceledException 을 받습니다. 토큰을 취소한 스레드에서 Cancel() 이 돌므로 토큰은 메인 스레드에서 취소합니다. 보존한 모션은 await handle 로 기다리지 않습니다.

    정리

    LitMotion 은 끝난 모션을 표시만 바꿔 두고 다음 프레임에 지웁니다.

    • Cancel, Complete 는 상태만 바꾸고, 칸은 다음 러너 갱신에서 빠집니다. 그 사이 IsValid 는 참, IsActive 는 거짓입니다.
    • Cancel 은 보존한 완료 모션과 무한 루프를 받아 주고, Complete 는 둘 다 거절합니다. 보존한 모션을 푸는 것은 Cancel 뿐입니다.
    • 콜백 예외는 기본으로 로그만 남깁니다. await handle 은 취소를 구분하지 못하고 러너 루프 안에서 이어지며, ToValueTask 는 취소를 예외로 알립니다.