LitMotion 의 핸들은 슬롯의 버전으로 끝난 모션을 걸러 낸다
MotionHandle h = LMotion.Create(0f, 1f, 0.2f)
.Bind(group, static (x, g) => g.alpha = x);
// 한참 뒤, 그 자리는 이미 다른 모션이 쓰고 있다
h.Cancel();
LitMotion 은 끝난 모션의 자리를 곧바로 다음 모션에 내줍니다. 사용자가 들고 있던 참조가 그 자리를 그대로 가리킨다면, 옛 참조로 부른 Cancel() 이 남의 모션을 멈춥니다.
LitMotion 의 MotionHandle 은 슬롯 번호와 그 슬롯의 버전을 들고 있고, 슬롯을 비울 때마다 버전을 올려 옛 핸들을 걸러 냅니다. 위 h.Cancel() 은 새 모션을 건드리지 않고 예외를 던집니다. 기준 판본은 2.0.2 입니다.
핸들은 움직이지 않는 슬롯을 가리킨다
// Runtime/MotionHandle.cs
public struct MotionHandle
{
public int StorageId; // 어느 저장소인가 (전역 목록의 칸 번호)
public int Index; // 저장소 안의 슬롯 번호
public int Version; // 핸들을 만들 때 그 슬롯의 세대
}
모션 데이터는 빈칸 없는 밀집 배열에 살고, 모션이 빠지면 맨 뒤 칸이 그 자리로 옮겨 옵니다. 핸들이 배열 칸 번호를 들고 있으면 옮겨진 모션의 핸들이 깨집니다. 그래서 핸들과 배열 사이에 움직이지 않는 슬롯 표를 두고, 슬롯이 지금의 배열 칸 번호를 적어 둡니다. 모션 셋 A, B, C 중 A 가 빠지는 모습입니다.
tail--;
data[dense] = data[tail]; // 빈칸을 맨 뒤 모션으로 메운다
data[tail] = default;
var prev = lookup[dense]; // 지울 모션의 슬롯
var cur = lookup[dense] = lookup[tail]; // 옮겨 온 모션의 슬롯
lookup[tail] = default;
slots[cur.Index].DenseIndex = dense; // 옮겨 온 모션의 슬롯이 새 칸을 가리키게 고친다
Free(prev); // 지운 모션의 슬롯을 비우고 버전을 올린다
모션 A, B, C 중 A 가 빠질 때입니다.
| 배열 | 슬롯 0 | 슬롯 1 | 슬롯 2 | |
|---|---|---|---|---|
| 전 | [A, B, C] |
칸 0 (v1) | 칸 1 | 칸 2 |
| 후 | [C, B, _] |
비었음 (v2) | 칸 1 | 칸 0 |
C 의 핸들은 (슬롯 2, v1) 그대로입니다. 슬롯 2 가 칸 0 을 가리키도록 고쳐져서, 핸들은 여전히 C 를 찾습니다.
C 는 배열에서 자리를 옮겼지만 핸들의 슬롯 번호 2 는 그대로이고, 슬롯이 대신 따라갑니다. 핸들은 12바이트 값이라 필드에 담고 복사하고 비교하는 데 할당이 없습니다. 대신 핸들로 무엇을 하든 전역 목록 인덱싱, 인터페이스 호출 한 번, 슬롯 검사를 거칩니다.
슬롯을 비울 때 버전이 오른다
빈 슬롯들은 각자의 Next 필드로 사슬을 이루는 free list 입니다. 버전은 슬롯을 비울 때 오르고, 비운 슬롯은 사슬 머리로 가서 다음 모션에게 제일 먼저 나갑니다. 슬롯 넷으로 줄여 A, B 를 만들고 A 가 끝난 뒤 C 를 만듭니다.
// Alloc(dense): 모션을 만들 때
if (freeSlot == -1)
EnsureCapacity(slots.Length * 2); // 빈 슬롯이 없을 때만 키운다
var i = freeSlot; // 사슬 머리의 슬롯을 꺼낸다
freeSlot = slots[i].Next;
slots[i].Next = -1;
slots[i].DenseIndex = dense; // 이 슬롯이 가리킬 밀집 칸
return new SparseIndex(i, slots[i].Version); // 버전은 그대로 적는다
// Free(index): 모션이 빠질 때
slots[i].Next = freeSlot; // 사슬 머리로 되돌린다
slots[i].Version++; // 세대를 올린다
freeSlot = i;
처음 상태는 freeSlot = 0, 사슬 0→1→2→3, 버전 [1, 1, 1, 1] 입니다.
| 단계 | 일 | freeSlot · 사슬 | 버전 | 결과 |
|---|---|---|---|---|
| 1 | A 를 만든다 | 1 · 1→2→3 | [1, 1, 1, 1] | A = (0, v1) |
| 2 | B 를 만든다 | 2 · 2→3 | [1, 1, 1, 1] | B = (1, v1) |
| 3 | A 가 끝나 빠진다 | 0 · 0→2→3 | [2, 1, 1, 1] | 슬롯 0 반납 |
| 4 | C 를 만든다 | 2 · 2→3 | [2, 1, 1, 1] | C = (0, v2) |
방금 비운 슬롯 0 이 가장 먼저 다시 나옵니다. A 와 C 는 같은 슬롯 0 을 썼고, 둘을 가르는 것은 버전 1 과 2 뿐입니다.
A 와 C 는 같은 슬롯 0 을 썼고, 둘을 가르는 것은 버전 1 과 2 뿐입니다. 버튼을 누를 때마다 짧은 모션을 하나 만들고 끝내는 코드라면 매번 같은 슬롯에 버전만 오른 핸들이 나옵니다. 새 슬롯은 버전 1 로 시작해서, default 핸들의 버전 0 은 어느 슬롯과도 맞지 않습니다.
Cancel 은 범위, 버전, 상태 순으로 거른다
같은 핸들 (0, v1) 로 네 가지 때에 Cancel() 을 부릅니다.
ref var slot = ref slots[handle.Index];
// 1. 범위: 슬롯이 가리키는 칸이 지금 배열 안에 있는가
if (slot.DenseIndex < 0 || slot.DenseIndex >= tail)
return 1; // "Motion has been destroyed or no longer exists."
// 2. 버전: 슬롯이 그사이 다른 모션에게 넘어가지 않았는가
if (slot.Version <= 0 || slot.Version != handle.Version)
return 1;
// 3. 상태: 취소할 것이 남았는가
ref var state = ref data[slot.DenseIndex].Core.State;
if (state.Status is None or Canceled || (state.Status is Completed && !state.IsPreserved))
return 2; // "Motion has already been canceled or completed."
if (checkIsInSequence && state.IsInSequence)
return 3; // 시퀀스 안의 자식은 직접 취소할 수 없다
state.Status = Canceled; // 상태만 바꾼다. 배열에서 빼는 것은 다음 러너 갱신의 Job
InvokeOnCancel();
return 0;
| 언제 | 슬롯 0 의 상태 | 걸리는 검사 | 결과 |
|---|---|---|---|
| 재생 중 | 칸 0, v1, Playing | 모두 통과 | 취소됨, OnCancel 호출 |
| 끝난 그 프레임 | 칸 0, v1, Completed (아직 배열에 있음) | 3. 상태 | Motion has already been canceled or completed. |
| 빠지고 슬롯이 빈 뒤 | 옛 DenseIndex 0, v2, tail = 0 | 1. 범위 (0 >= 0) | Motion has been destroyed or no longer exists. |
| 슬롯을 새 모션 C 가 쓰는 중 | 칸 0, v2, C 가 Playing | 2. 버전 (2 != 1) | Motion has been destroyed or no longer exists. |
빈 슬롯은 DenseIndex 를 지우지 않아 옛 값이 남지만, 배열이 줄어 범위 검사에 걸립니다. 슬롯이 재사용됐으면 버전 검사에서 막혀 C 는 건드려지지 않습니다. 범위는 빈 슬롯을, 버전은 재사용된 슬롯을, 상태는 끝났지만 아직 배열에 있는 모션을 걸러 냅니다.
- 슬롯이 비었으면 범위 검사에서
Motion has been destroyed or no longer exists. - 슬롯을 새 모션이 쓰고 있으면 버전 검사에서 같은 예외. 이 검사가 없으면 옛 핸들이 C 를 취소합니다.
- 끝났지만 아직 배열에 있으면 상태 검사에서
Motion has already been canceled or completed.
TryCancel() 은 같은 자리에서 예외 대신 false 를 돌려줍니다. 한 번도 대입하지 않은 default 핸들은 저장소가 아직 없으면 ArgumentException("Invalid type id.") 입니다.
Clear 는 모든 버전을 1 로 되감는다
세대 번호가 지켜지려면 버전이 줄지 않아야 합니다. 그런데 MotionDispatcher.Clear() 가 부르는 SparseSetCore.Reset() 은 모든 슬롯의 버전을 1 로 되돌립니다. 그러면 Clear() 전에 받은 핸들과 뒤에 만든 모션의 핸들이 세 필드 모두 같아질 수 있습니다.
Clear() 전 핸들의 슬롯 |
새 모션과 번호 | 옛 핸들로 Cancel() |
|---|---|---|
| 한 번도 비워진 적 없음 (버전 1) | (0, 1) 로 같음, == 가 참 |
예외 없이 새 모션이 취소됨 |
| 한 번 비워졌다 다시 채워짐 (버전 2) | 새 모션은 버전 1 | has been destroyed or no longer exists. |
Clear() 는 사용자만 부르지 않습니다. MotionDispatcher 가 BeforeSceneLoad 에서 플레이를 시작할 때마다 부릅니다. 도메인 리로드를 끈 에디터에서 지난 플레이의 핸들을 정적 필드에 남겨 두었더니, 두 번째 플레이의 첫 모션과 둘 다 (Index 0, Version 1) 이었고 saved == fresh 와 saved.IsActive() 가 모두 참이었습니다.
주의할 점
1. 끝났을 수 있는 핸들은 TryCancel 로 끊는다
안 지키면 생기는 문제. 짧은 모션의 핸들을 들고 있다가 버튼을 다시 눌렀을 때 Cancel() 을 부르면, 모션이 끝난 시점에 따라 두 가지 예외 중 하나가 납니다. 연타 속도에 따라 가끔만 나서 원인을 찾기 어렵습니다.
이렇게 쓴다. 새 모션을 걸기 전에 옛 모션을 이렇게 끊습니다.
fade.TryCancel();
fade = LMotion.Create(0f, 1f, 0.2f).Bind(group, static (x, g) => g.alpha = x);
2. MotionDispatcher.Clear() 를 부른 뒤에는 옛 핸들을 버린다
안 지키면 생기는 문제. 버전이 1 로 되감겨, 옛 핸들의 Cancel() 이 뒤에 만든 모션을 예외 없이 끊을 수 있습니다. 호출한 쪽에는 아무 신호도 없습니다.
이렇게 쓴다. Clear() 를 부르는 자리에서 핸들을 들고 있는 필드를 모두 MotionHandle.None 으로 비웁니다. 전체 정리가 목적이면 Clear() 대신 모션마다 AddTo(gameObject) 로 수명을 묶습니다.
3. 도메인 리로드를 끈 에디터에서 핸들을 정적 필드에 두지 않는다
안 지키면 생기는 문제. 지난 플레이의 핸들이 새 플레이의 첫 모션과 같은 번호가 됩니다. 에디터에서만, 두 번째 플레이부터만 나는 증상이라 빌드에서는 재현되지 않습니다.
이렇게 쓴다. 핸들은 인스턴스 필드에 둡니다. 정적 필드에 둬야 하면 [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] 초기화 메서드에서 default 로 비웁니다.
정리
LitMotion 의 핸들은 움직이지 않는 슬롯 번호와 그 슬롯의 버전으로 끝난 모션을 걸러 냅니다.
- 모션이 배열에서 자리를 옮겨도 슬롯이 따라가므로 핸들은 그대로 씁니다.
- 슬롯을 비울 때 버전이 올라, 같은 슬롯을 다시 쓴 새 모션과 옛 핸들이 갈립니다.
Clear()는 버전을 1 로 되감아 이 보호를 풉니다. 그 뒤로는 옛 핸들을 쓰지 않습니다.