LitMotion 은 PlayerLoop 맨 앞에 끼어들어 모션을 돌린다
void Awake()
{
LMotion.Create(0f, 1f, 0.5f)
.WithScheduler(MotionScheduler.PreLateUpdateIgnoreTimeScale)
.Bind(this, static (x, self) => self.transform.localScale = Vector3.one * x);
}
코루틴이라면 StartCoroutine 을 부른 MonoBehaviour 가, 손으로 쓴 보간이라면 그 컴포넌트의 Update 가 값을 밉니다. 위 모션에는 그런 주인이 없고 씬에 숨긴 게임오브젝트도 없습니다.
LitMotion 은 씬보다 먼저 PlayerLoop 여덟 단계 맨 앞에 자기 시스템을 끼워 넣고, 그 자리에서 (값 타입 × 타이밍) 마다 하나인 러너를 돌립니다. 기준 판본은 2.0.2 입니다.
씬보다 먼저 여덟 단계 맨 앞에 꽂는다
PlayerLoopHelper 는 [RuntimeInitializeOnLoadMethod(AfterAssembliesLoaded)] 에서, 곧 어떤 Awake 보다 먼저 Initialization 부터 TimeUpdate 까지 최상위 여덟 단계에 시스템을 하나씩 꽂습니다. 꽂는 자리는 늘 0번이고, 같은 이름표가 이미 있으면 걸러 낸 뒤 넣습니다.
// 같은 이름표가 이미 있으면 걸러 낸다
var source = subSystemList.Where(x => x.type != loopRunnerType).ToArray();
var dest = new PlayerLoopSystem[source.Length + 1]; // 한 칸 더 큰 배열
Array.Copy(source, 0, dest, 1, source.Length); // 0번을 비우고 1번부터 복사
dest[0] = new PlayerLoopSystem { type = loopRunnerType, updateDelegate = updateDelegate }; // 맨 앞에 넣는다
return dest;
| 언제 | 꽂기 전 Update 하위 | 꽂은 뒤 |
|---|---|---|
| 처음 | [BehaviourUpdate, DelayedTasks] |
[LitMotionUpdate, BehaviourUpdate, DelayedTasks] |
| 한 번 더 | [LitMotionUpdate, BehaviourUpdate, DelayedTasks] |
같음. 옛것을 거르고 넣어 한 개 |
여덟 단계 모두 같은 코드로 맨 앞에만 꽂습니다. 단계 끝에 꽂는 경로는 없습니다.
그래서 같은 Update 단계에서 LitMotion 은 모든 MonoBehaviour.Update 보다 먼저 돌고, 몇 번을 꽂아도 한 벌입니다. 어떤 Update 에서 모션을 만들면 그 프레임의 LitMotion 차례는 이미 지났으므로 첫 갱신은 다음 프레임입니다.
“맨 앞” 은 꽂는 순간의 자리입니다. 측정 프로젝트에는 PlayerLoop 에 시스템을 꽂는 다른 패키지가 둘 있었고, 여덟 단계 모두에서 LitMotion 은 3번 자리였습니다. 그래도 ScriptRunBehaviourUpdate 보다는 앞이었습니다.
러너는 제네릭 정적 필드로 찾는다
모션을 만들 때 저장소와 러너를 찾는 데 사전 조회가 없습니다. StorageCache<TValue, TOptions, TAdapter> 는 제네릭 정적 클래스라 타입 인자마다 따로 존재하고, 타이밍 여덟 개에 필드가 하나씩 있습니다. float 모션을 Update 에서 처음 만들 때 채워지는 것입니다.
새 조합을 처음 쓰는 순간에만 저장소와 러너가 생기고, 러너 목록에서 빼는 코드는 없습니다. 도는 쪽은 매 프레임 GetRunnerList(timing).AsSpan() 으로 그 순간의 길이로 자른 구간을 돌아서, 콜백 안에서 새 러너가 생겨도 예외 없이 다음 프레임부터 돕니다.
러너는 자기 직전 시각과 빼서 델타를 만든다
디스패처는 러너에게 델타가 아니라 절대 시각 셋(Time.timeAsDouble, unscaledTimeAsDouble, realtimeSinceStartupAsDouble)을 넘기고, 러너가 자기 필드의 직전 시각과 빼서 델타를 만듭니다. 러너는 태어날 때 지금 시각을 적는데, MotionDispatcher.Clear() 가 부르는 Reset() 은 0 을 적습니다. 게임 600초에 두 경우의 첫 갱신입니다.
// 러너를 처음 만들 때 (RunnerCache)
prevTime = Time.timeAsDouble; // 지금 시각을 적는다
// MotionDispatcher.Clear() → Reset()
prevTime = 0; // 0 을 적는다
// 매 갱신 (UpdateRunner.Update)
var deltaTime = time - prevTime;
prevTime = time;
// Job: state.Time += deltaTime * PlaybackSpeed
게임 600초에 1초짜리 모션을 만든 두 경우의 첫 갱신입니다.
| 경우 | prevTime |
다음 프레임(600.02초)의 델타 | 결과 |
|---|---|---|---|
| 그 타입을 처음 씀 | 600.00 | 0.02 | 진행률 2% |
Clear() 하고 바로 만듦 |
0 | 600.02 | 첫 갱신에 끝남 |
러너를 만들 때는 지금 시각으로, Reset 할 때는 0 으로 적는 비대칭이 차이를 만듭니다.
Clear() 직후 10초짜리 모션을 만들었더니 첫 프레임 진행률이 0.1233, 곧 1.2332초가 한 번에 흘렀습니다. 정상 프레임은 0.0018초였고 1.23초는 그 플레이가 시작된 뒤 흐른 시간입니다. 게임을 한참 한 뒤라면 짧은 모션은 만든 다음 프레임에 끝납니다.
스케줄러는 타이밍과 시계를 고른다
WithScheduler 로 넘기는 내장 스케줄러는 타이밍 하나와 시계 종류 하나를 든 객체입니다. 시계 종류(Time, UnscaledTime, Realtime)는 러너가 아니라 모션 데이터에 적히고, Job 이 모션마다 세 델타 중 하나를 고릅니다. 그래서 timeScale 을 따르는 모션과 무시하는 모션이 한 러너에서 같이 돕니다. FixedUpdate 타이밍만 Time 시계 하나입니다.
MotionScheduler.DefaultScheduler 는 공개 설정값이라 바꾸면 스케줄러를 지정하지 않은 모든 모션이 영향을 받고, 정적 값이라 씬을 넘어 남습니다.
시간을 직접 미는 ManualMotionDispatcher
테스트나 컷신 스크러빙처럼 PlayerLoop 대신 내가 시간을 밀 때 쓰는 디스패처입니다. Update(deltaTime) 이 자기 시계를 더하고, 러너 사전을 foreach 로 돌며 세 시계에 같은 값을 넘깁니다. 러너가 Dictionary 라서, 갱신 중인 콜백에서 같은 디스패처로 처음 보는 타입의 모션을 만들면 열거 도중 사전이 바뀝니다.
d.Update(1.0);
foreach (var runner in runners) // 러너 사전(Dictionary)을 돈다
runner.Update(...); // float 모션이 끝나 OnComplete 가 여기서 불린다
// 콜백 안: 같은 디스패처로 처음 보는 Vector3 모션을 만든다
LMotion.Create(Vector3.zero, Vector3.one, 1f).WithScheduler(d)...
runners.Add(새 Vector3 러너); // 열거 도중 사전이 바뀐다
// 다음 MoveNext 에서 InvalidOperationException
d.Update(0.1); // 새 러너가 이미 들어가 있어 예외 없음
모션 생성 자체는 성공하고, 예외는 그 d.Update(1.0) 이 다음 러너로 넘어가려는 순간 납니다. 같은 타입의 모션은 그 뒤로 몇 번을 만들어도 사전이 바뀌지 않아 예외가 나지 않습니다. 전역 디스패처는 길이를 잘라 둔 구간을 돌아 이 문제를 피합니다.
실측은 소스대로였습니다.
| 본 것 | 결과 |
|---|---|
| 콜백 안에서 새 타입 모션 생성 | Collection was modified; enumeration operation may not execute. |
시계를 100초까지 민 뒤 처음 쓰는 타입의 10초 모션에 Update(0.1) |
handle.Time 이 0.1 이 아니라 100.1, 그 한 번에 끝남 |
new ManualMotionDispatcher() 에 모션 하나씩 100번 |
전역 저장소 목록 +100, GC 뒤에도 그대로. 인스턴스 하나에 140 KB 남짓 |
새 러너는 디스패처의 지금 시각이 아니라 0 을 직전 시각으로 적고, 인스턴스마다 만든 저장소는 전역 목록에서 빠지지 않습니다.
주의할 점
1. 게임 도중 MotionDispatcher.Clear() 를 부르지 않는다
안 지키면 생기는 문제. Clear() 는 러너의 직전 시각을 0 으로 되돌리는데 Time.time 은 계속 흐릅니다. 직후에 만든 모션은 첫 갱신에서 게임 시작부터 지금까지를 한 번에 받아, 짧은 모션은 만든 다음 프레임에 끝납니다.
이렇게 쓴다. 모션을 한꺼번에 끝내려면 핸들을 TryCancel() 로 하나씩 끊거나 AddTo(gameObject) 로 수명을 묶습니다. 꼭 Clear() 를 써야 하면 모든 타이밍이 한 번씩 돈 다음 프레임부터 새 모션을 만듭니다. 한 번 돈 러너는 직전 시각이 다시 지금 시각이 됩니다.
2. PlayerLoop 를 바꾸는 코드는 지금 루프를 읽어서 더한다
안 지키면 생기는 문제. PlayerLoop.SetPlayerLoop() 는 루프를 통째로 바꿉니다. LitMotion 이 꽂기 전에 읽어 둔 루프를 나중에 넣으면 LitMotion 의 여덟 시스템이 빠지고, 모션은 만들어지는데 움직이지 않습니다. 최상위 단계를 지운 루프를 넘기면 LitMotion 초기화가 Target PlayerLoopSystem does not found 로 멈춥니다.
이렇게 쓴다. SetPlayerLoop 직전에 GetCurrentPlayerLoop() 로 지금 루프를 읽어 거기에 더합니다. 초기화 시점은 BeforeSceneLoad 이후로 두고, 최상위 여덟 단계는 지우지 않습니다.
3. ManualMotionDispatcher 는 한 번 만든 인스턴스를 계속 쓴다
안 지키면 생기는 문제. 인스턴스마다 전역에 저장소가 쌓이고 빠지지 않습니다. 콜백 안에서 새 타입 모션을 만들면 예외가 나고, 시계가 흐른 뒤 처음 쓰는 타입은 첫 갱신에 시계 전체를 델타로 받습니다.
이렇게 쓴다. ManualMotionDispatcher.Default 나 용도별로 한 번 만든 인스턴스를 쓰고 끝나면 Reset() 합니다. 쓸 타입은 시계를 밀기 전에 dispatcher.EnsureStorageCapacity<T, O, A>(n) 으로 러너를 미리 만들어 둡니다.
정리
LitMotion 은 PlayerLoop 맨 앞에 끼어들어 주인 없이 모션을 돌립니다.
AfterAssembliesLoaded에 여덟 단계 맨 앞에 꽂아, 같은 단계의MonoBehaviour갱신보다 먼저 돕니다.- 저장소와 러너는 제네릭 정적 필드라 조회가 없고, 한 번 생긴 러너는 빠지지 않습니다.
- 러너는 절대 시각에서 자기 직전 시각을 빼 델타를 만듭니다.
Clear()는 그 직전 시각을 0 으로 되돌립니다.