DOTween 은 매 프레임 위치와 루프 수를 새로 정해 그 자리로 간다
elapsed += Time.deltaTime;
if (elapsed >= duration) { elapsed -= duration; loop++; forward = !forward; } // 한 프레임에 루프 둘을 넘으면?
float p = elapsed / duration;
손으로 쓴 Update 보간은 시간을 쌓고, 루프나 Yoyo, 딜레이가 붙을 때마다 분기를 더합니다. 로딩 직후처럼 한 프레임이 길어 루프를 여러 번 넘으면 넘친 시간을 버리거나 루프 하나만 넘긴 것으로 치기 쉽습니다.
DOTween 은 시간을 쌓지 않고, 매 프레임 “몇 번째 루프의 몇 초 지점” 을 새로 계산해 DoGoto 로 트윈을 그 자리에 놓습니다. 정상 재생도 Goto 도 Complete 도 같은 DoGoto 를 거칩니다. 기준은 설치본 DOTween 1.3.030(GitHub 커밋 4846808)입니다.
넘긴 루프는 while 로 하나씩 센다
트윈의 시간은 두 칸입니다. 루프 안의 시간 position(0 ~ duration)과 끝낸 루프 수 completedLoops 입니다. 매 프레임 매니저는 position 에 dt 를 더하고, duration 을 넘는 동안 한 루프씩 빼며 셉니다.
float toPosition = t.position; // 지금 루프 안의 시간
bool wasEndPosition = toPosition >= t.duration;
int toCompletedLoops = t.completedLoops; // 지금까지 끝낸 루프 수
toPosition += tDeltaTime; // 이번 프레임에 흐른 시간을 더한다
// duration 을 넘는 동안 한 루프씩 빼며 센다
while (toPosition >= t.duration && (t.loops == -1 || toCompletedLoops < t.loops))
{
toPosition -= t.duration;
toCompletedLoops++;
}
if (wasEndPosition)
toCompletedLoops--;
// 유한 루프를 다 썼으면 끝에 고정한다
if (t.loops != -1 && toCompletedLoops >= t.loops)
toPosition = t.duration;
return Tween.DoGoto(t, toPosition, toCompletedLoops, UpdateMode.Update);
길이 1초, loops = 3 인 트윈이 0.5초 지점에 있을 때 프레임이 크게 늦어 2.7초가 한꺼번에 들어오면 이렇게 셉니다.
| 단계 | toPosition |
toCompletedLoops |
|---|---|---|
| 시간을 더함 | 3.2 | 0 |
| 첫 루프를 뺌 | 2.2 | 1 |
| 둘째 루프를 뺌 | 1.2 | 2 |
| 셋째 루프를 뺌 | 0.2 | 3 |
| 루프를 다 써서 끝에 고정 | 1.0 | 3 |
DoGoto(1.0, 3) 이 불립니다. 프레임이 크게 늦어도 넘긴 루프 수가 정확히 남습니다.
넘긴 루프 수가 그대로 DoGoto 에 가고, DoGoto 는 그 차이만큼 OnStepComplete 를 부릅니다. DOTween.To(...).SetLoops(3) 을 v = 0.75 까지 보낸 뒤 DOTween.ManualUpdate(2.7f, 2.7f) 를 한 번 부르자 그 호출 안에서 OnStepComplete 가 세 번, OnComplete 가 한 번 불렸습니다.
딜레이의 남는 시간은 버리지 않는다
목표를 계산하기 전에 딜레이가 먼저 시간을 가져갑니다.
// Tweener.cs (DoUpdateDelay 줄임)
if (elapsed > tweenDelay) {
t.elapsedDelay = tweenDelay;
t.delayComplete = true;
return elapsed - tweenDelay; // 남는 시간을 이번 프레임 진행에 넘긴다
}
t.elapsedDelay = elapsed;
return 0;
딜레이 0.5 에 0.45 를 기다린 상태에서 0.1 이 들어오면, 딜레이를 끝내고 남는 0.05 를 같은 프레임의 진행에 씁니다. 딜레이와 본 재생을 합친 길이가 프레임 경계와 무관하게 맞습니다. 트위너의 딜레이는 처음 한 번만 기다립니다. 루프마다 다시 기다리지 않고, 다시 기다리게 하는 것은 Restart() 입니다. 시퀀스에 건 SetDelay 는 맨 앞에 빈 구간을 끼우는 방식이라 루프마다 반복됩니다.
Yoyo 는 시간만 뒤집는다
Yoyo 는 홀수 번째 루프 동안 duration - position 을 플러그인에 넘깁니다. 시작값과 끝값은 그대로이고 시간만 거꾸로 흐릅니다.
t.position = toPosition;
t.completedLoops = toCompletedLoops;
// 루프를 끝낸 직후 0 에 있으면 끝 위치로 본다
if (t.position <= 0 && (t.completedLoops > 0 || t.isComplete))
t.position = t.duration;
// Yoyo 의 홀수 번째 루프면 시간을 뒤집는다
bool useInverse = t.loopType == LoopType.Yoyo
&& (t.position < t.duration ? t.completedLoops % 2 != 0 : t.completedLoops % 2 == 0);
float updatePosition = useInverse ? t.duration - t.position : t.position;
plugin.EvaluateAndApply(..., updatePosition, ...); // x = 0 + 10 * updatePosition
DOMoveX(10, 1).SetLoops(2, LoopType.Yoyo) 를 x = 0 에서 Linear 로 돌리면 이렇습니다.
| 누적 시간 | position |
completedLoops |
뒤집나 | 넘기는 시간 | x |
|---|---|---|---|---|---|
| 0.5 | 0.5 | 0 | 아니오 | 0.5 | 5 |
| 1.2 | 0.2 | 1 | 예 | 0.8 | 8 |
| 2.0 | 1.0 | 2 | 예 | 0 | 0 |
마지막 줄은 위치가 끝(1.0)이라 오른쪽 식 2 % 2 == 0 을 씁니다. 왼쪽 식을 썼다면 뒤집지 않고 x = 10 에서 끝났을 것입니다.
ManualUpdate 로 0.5, 0.7, 0.8 초를 밀며 적은 x 는 5, 8, 0 이었습니다. 시간만 뒤집으므로 이징도 거울상이 됩니다. 같은 트윈을 OutQuad 로 바꿔 0.1초씩 밀자 가는 루프의 변화량은 1.9, 1.7 … 0.1 로 줄고, 돌아오는 루프는 -0.1, -0.3 … -1.9 로 커졌습니다. 돌아오는 길은 가는 길을 거꾸로 튼 모양이라 천천히 출발해 빠르게 도착합니다.
콜백은 값을 쓴 뒤 정해진 순서로 불린다
DoGoto 는 값을 적용한 다음 콜백을 부릅니다. loops = 3 트윈의 첫 갱신과, 한 프레임에 끝까지 간 경우입니다.
// 처음 갱신되는 순간에만
if (!t.playedOnce)
{
t.playedOnce = true;
onStart?.Invoke();
onPlay?.Invoke();
}
t.completedLoops = toCompletedLoops;
t.isComplete = t.completedLoops == t.loops;
int newCompletedSteps = t.completedLoops - prevCompletedLoops; // 이번 프레임에 넘긴 루프 수
t.ApplyTween(...); // setter 로 값을 쓴다
onUpdate?.Invoke(); // 값을 쓴 다음에 부른다
if (t.position <= 0 && t.completedLoops <= 0 && !wasRewinded)
onRewind?.Invoke();
// 넘긴 루프 수만큼 부른다. 도중에 죽으면 멈춘다
for (int i = 0; i < newCompletedSteps; i++)
{
onStepComplete();
if (!t.active) break;
}
if (t.isComplete && !wasComplete)
onComplete?.Invoke();
// autoKill 로 끝나는 트윈에는 OnPause 를 부르지 않는다
if (!t.isPlaying && wasPlaying && (!t.isComplete || !t.autoKill))
onPause?.Invoke();
return t.autoKill && t.isComplete; // true 면 루프 끝에서 OnKill
loops = 3 인 트윈에서 불리는 콜백입니다.
| 프레임 | 불리는 콜백 (순서대로) |
|---|---|
| 첫 갱신 (0 → 0.3초) | OnStart, OnPlay, OnUpdate |
| 한 프레임에 끝까지 (루프 0 → 3) | OnUpdate, OnStepComplete × 3, OnComplete, 루프 끝에서 OnKill |
순서는 OnStart → OnPlay → 값 적용 → OnUpdate → OnRewind → OnStepComplete × n → OnComplete → OnPause 이고 OnKill 은 루프 끝입니다. 같은 프레임에 A, B 가 함께 끝나면 기록은 A.OnComplete, B.OnComplete, B.OnKill, A.OnKill 이었습니다. OnComplete 는 배열 순서, OnKill 은 그 반대입니다.
사용자가 위치를 옮기는 API 도 같은 DoGoto 를 부르되 UpdateMode 로 콜백을 뺍니다. Goto, Rewind, Complete 는 UpdateMode.Goto 라서 OnStart, OnPlay, OnStepComplete 가 빠지고, 건너뛴 루프의 OnStepComplete 가 필요하면 GotoWithCallbacks 를 씁니다.
주의할 점
1. 한 프레임에 한 루프만 넘어간다고 가정하지 않는다
안 지키면 생기는 문제. 프레임이 크게 늦으면 짧은 루프 트윈은 한 프레임에 루프 여러 개를 넘기고, OnStepComplete 가 그 수만큼 연달아 불립니다. 그 안에서 사운드를 틀거나 이펙트를 만드는 코드가 같은 프레임에 여러 번 실행됩니다.
이렇게 쓴다. OnStepComplete 에서 하는 일은 같은 프레임에 여러 번 불려도 괜찮게 만들거나 Time.frameCount 로 프레임당 한 번만 하게 거릅니다. 루프 수로 상태를 셀 때는 콜백 횟수보다 CompletedLoops() 를 읽습니다.
2. 딜레이가 루프마다 반복된다고 기대하지 않는다
안 지키면 생기는 문제. 트위너에 SetDelay(0.5f).SetLoops(3) 을 걸면 처음 한 번만 기다리고 세 루프는 쉬지 않고 이어집니다. 루프마다 쉬는 깜빡임을 기대한 연출이 한 번만 쉽니다.
이렇게 쓴다. 시퀀스에 AppendInterval 과 트윈을 넣고 시퀀스에 SetLoops 를 겁니다.
3. Yoyo 로 돌아오는 길의 이징을 따로 기대하지 않는다
안 지키면 생기는 문제. OutQuad 로 부드럽게 도착하는 트윈을 Yoyo 로 되돌리면, 돌아올 때는 처음에 느리고 도착할 때 빨라져서 출발점에서 툭 끊깁니다.
이렇게 쓴다. InOutQuad 처럼 양끝이 대칭인 이징을 쓰거나, 가는 트윈과 오는 트윈을 시퀀스로 따로 만들어 각자 이징을 줍니다.
4. 콜백 안에서 Kill 해도 같은 호출의 남은 콜백은 불린다
안 지키면 생기는 문제. OnStart, OnPlay, OnStepComplete 뒤에는 트윈이 죽었는지 보는 검사가 있지만 OnUpdate, OnRewind, OnComplete 뒤에는 없습니다. 마지막 프레임의 OnUpdate 에서 Kill() 을 불러도 이어서 OnComplete 가 한 번 불렸습니다. “죽였으니 완료 처리는 없다” 고 가정한 코드가 완료 처리를 합니다.
이렇게 쓴다. 끝났을 때 할 일은 OnComplete 한 곳에 모으고 OnUpdate 에서는 값을 읽기만 합니다. OnUpdate 에서 멈춰야 하면 내 쪽 플래그를 두고 OnComplete 에서 봅니다.
정리
DOTween 은 매 프레임 목표 위치와 루프 수를 새로 계산해 DoGoto 로 그 자리에 놓습니다.
- 넘긴 루프는
while로 하나씩 세어OnStepComplete를 빠짐없이 부릅니다. - 딜레이가 끝나는 프레임의 남는 시간은 진행에 넘기고, 트위너의 딜레이는 처음 한 번뿐입니다.
- Yoyo 는 시간만 뒤집어 이징도 거울상이 되고, 콜백은 값을 쓴 뒤 정해진 순서로 불립니다.