DOTween 의 트윈은 숨은 오브젝트 하나의 Update 에서 돈다
transform.DOMoveX(5f, 1f); // Update 에서 돈다
transform.DOMoveX(5f, 1f).SetUpdate(UpdateType.Late); // LateUpdate 에서 돈다
transform.DOMoveX(5f, 1f).SetUpdate(true); // Time.timeScale 을 무시한다
트윈을 만든 스크립트에는 Update 가 없는데 값이 매 프레임 바뀝니다. 그러면 내 Update 에서 읽은 위치가 이번 프레임 값인지, 일시정지 화면의 SetUpdate(true) 트윈이 왜 느리게 끝나는지가 이 구동 방식에 달려 있습니다.
DOTween 은 첫 호출 때 만든 [DOTween] 오브젝트의 DOTweenComponent 가 Update, LateUpdate, FixedUpdate 에서 활성 트윈 배열 하나를 훑어 모든 트윈을 돌립니다. 기준은 설치본 DOTween 1.3.030(GitHub 커밋 4846808)입니다.
트윈은 다른 스크립트의 Update 사이 어딘가에서 돈다
DOTweenComponent 에는 DefaultExecutionOrder 도, 실행 순서를 정하는 코드도 없습니다. 같은 순서값끼리의 Update 순서는 유니티가 정합니다. 실행 순서를 정하지 않은 스크립트의 Update 에서 DOMoveX 를 만들고 첫 갱신이 언제 오는지 30회씩 봤습니다.
| 만드는 스크립트의 실행 순서 | 첫 갱신 |
|---|---|
정하지 않음 ([DOTween] 이 이미 있거나, 그 호출로 막 생기거나) |
30회 모두 다음 프레임 |
[DefaultExecutionOrder(-100)] |
30회 모두 같은 프레임 |
[DefaultExecutionOrder(100)] |
30회 모두 다음 프레임 |
이 환경에서는 [DOTween] 이 늘 먼저 돌았습니다. 하지만 같은 순서값끼리의 차례는 유니티가 약속한 값이 아니므로, 트윈과의 앞뒤가 중요하면 순서값을 직접 정해야 합니다.
SetUpdate(true) 가 피하는 것은 Time.timeScale 하나다
TweenManager.Update 는 Time.timeScale 이 곱해진 시간과 곱해지지 않은 unscaled 시간을 함께 받고, 트윈마다 isIndependentUpdate 로 하나를 고른 뒤 트윈의 timeScale 을 곱합니다. 일시정지 화면에서 자주 만나는 세 경우입니다.
void Update()
{
if (TweenManager.hasActiveDefaultTweens)
TweenManager.Update(Normal,
Time.deltaTime * DOTween.timeScale, // 스케일 시간
unscaledDelta * DOTween.unscaledTimeScale * DOTween.timeScale); // unscaled 시간에도 DOTween.timeScale 이 곱해진다
}
void FixedUpdate()
{
if (TweenManager.hasActiveFixedTweens && Time.timeScale > 0) // Time.timeScale 이 0 이면 돌지 않는다
TweenManager.Update(Fixed, ...);
}
// TweenManager.Update(t): 트윈 하나
float tDelta = (t.isIndependentUpdate ? independentTime : deltaTime) * t.timeScale; // SetUpdate(true) 면 unscaled
if (tDelta < 0.000001f && tDelta > -0.000001f)
return false; // 사실상 0 이면 건너뛴다
Time.timeScale = 0 인 일시정지 화면, 프레임 16 ms 에서 트윈이 받는 시간입니다.
| 트윈 | DOTween.timeScale |
받는 시간 |
|---|---|---|
SetUpdate(true) |
1 | 16 ms. 움직임 |
SetUpdate(true) |
0 | 0. 멈춤 |
SetUpdate(UpdateType.Fixed, true) |
1 | Fixed 루프가 돌지 않아 멈춤 |
DOTween.timeScale 은 unscaled 시간에도 곱해지고, Fixed 루프는 Time.timeScale 이 0 이면 아예 돌지 않습니다. SetUpdate(true) 는 Time.timeScale 만 피합니다.
루프 길이는 시작할 때 정한다
세 메시지가 부르는 루프는 같은 함수입니다. 배열 하나를 처음부터 훑으며 updateType 이 다른 칸은 건너뜁니다. 그리고 훑을 길이를 시작할 때 한 번 잡습니다.
// Core/TweenManager.cs (줄임)
int len = _maxActiveLookupId + 1; // Stored here so if _maxActiveLookupId changed during update loop (like if new tween is created at onComplete) new tweens are still ignored
for (int i = 0; i < len; ++i) {
Tween t = _activeTweens[i];
if (t == null || t.updateType != updateType) continue;
if (Update(t, deltaTime, independentTime, false)) willKill = true;
}
콜백 안에서 만든 트윈은 배열 끝, len 밖에 붙어 이번 루프에서 돌지 않습니다.
int len = _maxActiveLookupId + 1; // 루프를 시작할 때 한 번만 정한다
for (int i = 0; i < len; ++i)
{
Tween t = _activeTweens[i];
if (t == null || t.updateType != updateType)
continue; // 다른 루프의 트윈은 건너뛴다
if (Update(t, deltaTime, independentTime, false))
willKill = true;
}
활성 배열이 [A(Normal), B(Late), C(Normal)] 이고, A 가 끝나며 OnComplete 에서 새 트윈 D 를 만드는 프레임입니다.
- 루프를 시작할 때
len이 3 으로 정해집니다. - A 가 끝나고
OnComplete안에서 D 가 생겨 배열 끝 3번 칸에 들어갑니다. - B 는 Late 라 건너뛰고, C 는 갱신합니다.
- 3번 칸의 D 는
len밖이라 이번 루프에서 불리지 않습니다. 다음 프레임부터 움직입니다.
len 을 매번 다시 읽었다면 D 가 같은 프레임에 한 걸음 먼저 갔을 것입니다.
OnComplete 가 불린 프레임이 7958, 그 안에서 만든 트윈의 첫 OnUpdate 는 7959 였습니다. 거르는 비용은 작았습니다. 같은 조건에서 Late 1개 + Normal 10,000개인 프레임(1.869 ms)과 Normal 10,000개만인 프레임(1.914 ms)이 구분되지 않았습니다.
1.3.030 의 unscaled 시간은 DOTween 자신의 갱신 시간을 뺀다
1.3.030 은 unscaled 시간을 Time.realtimeSinceStartup 두 번의 차이로 재는데, 기준점을 트윈 갱신이 끝난 뒤에 찍습니다. realtimeSinceStartup 은 부를 때마다 실제 시각이라, 트윈 갱신에 걸린 구간이 어느 프레임에도 들어가지 않습니다. 16 ms 프레임에 갱신이 4 ms 걸린다고 두고 1.3.045(GitHub 커밋 bfb02b0)와 나란히 봅니다.
Time.timeScale = 0 에서 SetUpdate(true) 트윈이 센 시간을 실제 경과와 비교해 빠진 비율을 쟀습니다.
| 부하 트윈 | 상한 없음, Mono | 상한 없음, IL2CPP | 60 fps, Mono | 60 fps, IL2CPP |
|---|---|---|---|---|
| 0 | 0.37 % | 0.35 % | 0.02 % | 0.02 % |
| 1,000 | 29.02 % | 21.25 % | 0.92 % | 0.65 % |
| 5,000 | 65.75 % | 58.15 % | 4.69 % | 2.98 % |
빠지는 양은 DOTween 이 한 프레임에 돈 시간이고 비율은 그것을 프레임 시간으로 나눈 값이라, 트윈이 많고 프레임이 짧을수록 커집니다. 렌더링 없는 에디터 배치모드에서는 트윈 5,001개에서 71.56 % 가 사라졌습니다. 영향을 받는 것은 독립 시간을 쓰는 Normal, Late 트윈입니다.
주의할 점
1. 일시정지 화면의 unscaled 트윈은 1.3.045 이상에서 쓴다
안 지키면 생기는 문제. Time.timeScale = 0 으로 게임을 멈추고 메뉴를 SetUpdate(true) 트윈으로 띄우면, 1.3.030 에서는 트윈이 많은 장면일수록 메뉴 연출이 느리게 끝납니다. 60 fps 로 묶은 플레이어에서도 트윈 5,000개면 3~5 % 가 사라졌습니다.
이렇게 쓴다. 1.3.045 이상으로 올립니다. 올릴 수 없으면 그 트윈을 SetUpdate(UpdateType.Manual, true) 로 두고 내 Update 에서 DOTween.ManualUpdate(Time.deltaTime, Time.unscaledDeltaTime) 를 부릅니다. 같은 플레이어의 60 fps, 부하 5,000개에서 이 경로의 누락은 Mono 0.000 %, IL2CPP 0.001 % 였고, 대조한 SetUpdate(true) 트윈은 4.390 %, 3.302 % 였습니다.
2. timeScale 0 일시정지에 Fixed 트윈과 DOTween.timeScale 을 섞지 않는다
안 지키면 생기는 문제. SetUpdate(UpdateType.Fixed, true) 트윈은 Time.timeScale 이 0 인 동안 FixedUpdate 쪽 조건에 막혀 움직이지 않습니다. 게임을 멈추려고 DOTween.timeScale 을 0 으로 두면 unscaled 시간에도 곱해져 일시정지 메뉴 트윈까지 멈춥니다.
이렇게 쓴다. 일시정지 중에 움직일 트윈은 Normal 이나 Late 에 SetUpdate(true) 로 둡니다. 게임 쪽 트윈만 멈추려면 Time.timeScale 을 쓰거나, 게임 트윈에 id 를 달아 DOTween.Pause(id) 로 멈춥니다.
3. 트윈이 옮기는 값을 Update 에서 읽지 않는다
안 지키면 생기는 문제. [DOTween] 의 Update 가 내 스크립트보다 먼저 돌지는 DOTween 이 정하지 않습니다. 트윈이 옮기는 오브젝트의 위치를 내 Update 에서 읽어 카메라나 UI 를 맞추면, 스크립트에 따라 이번 값과 지난 값을 섞어 읽어 한 프레임 어긋납니다.
이렇게 쓴다. 트윈이 쓴 값을 따라가는 코드는 LateUpdate 에서 읽습니다. 값 자체가 필요하면 값을 쓴 직후에 불리는 OnUpdate 콜백에서 받습니다.
4. 콜백 안에서 만든 트윈은 다음 프레임부터 돈다고 보고 짠다
안 지키면 생기는 문제. OnComplete 에서 다음 트윈을 만들면 한 프레임 동안 아무 값도 쓰지 않아, 이어지는 움직임에 턱이 보일 수 있습니다. 같은 콜백에서 이미 돌던 트윈을 시퀀스에 넣어 앞쪽 칸이 비면, 새 트윈을 넣을 때의 압축이 순회를 흔들어 아직 안 읽은 트윈 하나가 그 프레임을 건너뛰었습니다.
이렇게 쓴다. 끊김 없이 이어질 움직임은 Sequence 에 Append 로 넣습니다. 이미 재생 중인 트윈을 콜백 안에서 시퀀스에 넣지 않습니다.
정리
DOTween 의 트윈은 [DOTween] 오브젝트 하나의 Update, LateUpdate, FixedUpdate 가 배열 하나를 훑어 돌립니다.
- 실행 순서를 정하지 않아 다른 스크립트의
Update와의 앞뒤는 보장되지 않습니다. 순서값을 정하지 않은 스크립트보다 늘 먼저 돌았습니다. SetUpdate(true)는Time.timeScale만 피하고, 루프 길이는 시작할 때 고정돼 콜백에서 만든 트윈은 다음 프레임부터 돕니다.- 1.3.030 은 unscaled 시간에서 자기 갱신 시간을 빼먹습니다. 1.3.045 가
Time.unscaledDeltaTime으로 고쳤고, 1.3.030 에서는Manual갱신이 우회로입니다.