LitMotion 은 끝난 모션을 표시만 바꿔 두고 다음 프레임에 지운다
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는 취소를 예외로 알립니다.