DOTween 의 시퀀스는 넣은 트윈을 빼내 시간표대로 돌린다
Sequence s = DOTween.Sequence();
s.Append(panel.DOScale(1f, 0.3f))
.Join(group.DOFade(1f, 0.3f))
.AppendInterval(0.5f)
.Append(title.DOMoveY(0f, 0.4f));
코루틴이었다면 yield return 을 사이사이 끼우고, Animator 였다면 타임라인에 키를 찍었을 연출입니다. 시퀀스는 이것을 트윈 하나로 만들어, 멈추면 전부 멈추고 되감으면 전부 되감깁니다. 그런데 그 뒤로 transform.DOKill() 이 자식을 못 찾고, 자식에게 Kill() 을 불러도 아무 일도 일어나지 않습니다.
DOTween 의 시퀀스는 넣은 트윈을 매니저의 활성 목록에서 빼내 자기 시간표에 적고, 매 프레임 자식마다 로컬 시각을 새로 정해 Goto 합니다. 기준은 DOTween 1.3.030(GitHub 커밋 4846808)입니다.
시퀀스는 자리를 적은 시간표다
시퀀스의 항목마다 시간축 위의 구간 [sequencedPosition, sequencedEndPosition] 이 있고, 시퀀스 길이는 가장 늦게 끝나는 자리입니다. 만드는 API 는 전부 “어디에 적을까” 를 정합니다. Append 는 지금 길이에, Join 은 직전에 넣은 자리에, Insert 는 지정한 시각에 적고, AppendInterval 은 적지 않고 길이만 늘립니다.
var s = DOTween.Sequence();
s.Append(a); // a: 1초. 지금 길이 0 에 적는다 → a [0 ~ 1]
s.Join(b); // b: 0.5초. 직전에 넣은 자리 0 에 겹친다 → b [0 ~ 0.5]
s.AppendInterval(0.5f); // 적지 않고 길이만 1 → 1.5 로 늘린다. 직전 자리는 인터벌 시작 1 로
s.Join(c); // c: 1초. 직전 자리 1 에 겹친다 → c [1 ~ 2]
s.Append(d); // d: 1초, SetDelay(0.5f). 지금 길이 2 에 딜레이만큼 밀어 → d [2.5 ~ 3.5]
| 항목 | 시간표의 자리 | 그 뒤 시퀀스 길이 |
|---|---|---|
| a | 0 ~ 1 | 1 |
| b | 0 ~ 0.5 | 1 |
| 인터벌 | 적지 않음 | 1.5 |
| c | 1 ~ 2 | 2 |
| d | 2.5 ~ 3.5 | 3.5 |
- 시퀀스의 길이는 따로 정하지 않습니다. 가장 늦게 끝나는 항목이 곧 길이입니다.
- 인터벌 뒤의
Join은 인터벌이 끝난 1.5 가 아니라 시작한 1 에 붙습니다. 소스 주석이 밝혀 둔 동작입니다. - d 의 딜레이 0.5 는 자리를 미는 데 쓰이고, d 자신의 딜레이는 0 이 됩니다.
항목은 시퀀스가 시작할 때 안정 정렬로 한 번 정렬돼서, 같은 시각의 항목은 넣은 순서대로 처리됩니다. 인터벌 뒤의 Join 은 인터벌이 끝난 자리가 아니라 시작한 자리에 붙습니다.
넣는 순간 자식은 매니저 손을 떠난다
DoInsert 의 첫 줄 AddActiveTweenToSequence 는 이름과 달리 RemoveActiveTween 한 줄입니다. 자식은 활성 배열에서 지워지고, 그 뒤로 자식을 부르는 길은 부모 시퀀스뿐입니다. 함께 딜레이는 자리로 바뀌고(delay = 0), creationLocked 와 autoKill = false 가 걸리고, 무한 루프는 int.MaxValue 로 바뀝니다.
DOMove 를 Append 한 뒤 transform.DOKill() 의 반환값은 0 이었고 자식은 계속 활성이었습니다. 자식에게 직접 Kill(), Complete(), Pause() 를 불러도 첫머리의 isSequenced 검사에서 돌아가고, 무시했다는 경고는 로그 수준이 Verbose 일 때만 찍힙니다. 넣기 전에 걸어 둔 SetLink 도 이때 지워집니다.
이미 재생 중인 트윈도 경고 없이 들어가고, 처음부터 다시 재생됩니다. 0.5초 돌아 x = 5 에 온 DOMoveX(10, 1) 을 새 시퀀스에 넣자 시퀀스의 첫 갱신에서 0 근처로 돌아가 1초 동안 다시 10 으로 갔습니다.
매 프레임 자식마다 로컬 시각으로 Goto 한다
시퀀스의 갱신은 “시퀀스 시각 from 에서 to 로” 한 가지이고, 시간표를 훑어 구간이 걸친 자식마다 TweenManager.Goto(자식, to - 자식 시작) 을 부릅니다. 지난 프레임에서 흐른 시간을 더해 주는 것이 아니라 “너는 지금 몇 초 지점이다” 를 매번 알려 줍니다. 0.8초에서 1.2초로 넘어가는 한 프레임입니다.
for (int i = 0; i < len; ++i)
{
var o = _sequencedObjs[i];
// 이번 구간 (fromPos, toPos] 에 걸치지 않는 항목은 건너뛴다
if (o.sequencedPosition > toPos || o.sequencedEndPosition <= fromPos)
continue;
// 콜백은 구간 안에 들었으면 한 번 부른다
if (o is SequenceCallback)
{
OnTweenCallback(o.onStart, s);
continue;
}
float gotoPos = toPos - o.sequencedPosition; // "너는 지금 몇 초 지점이다"
if (toPos >= o.sequencedEndPosition)
gotoPos = o.fullDuration; // 끝을 넘었으면 끝으로 맞춘다
TweenManager.Goto(o, gotoPos);
}
시간표 [a 0~1, b 0~0.5, 콜백 @1, c 1~2] 에서 0.8초에서 1.2초로 넘어가는 프레임입니다.
| 항목 | 판정 | 하는 일 |
|---|---|---|
| a | 걸침. 1.2 가 끝 1 을 넘음 | 끝(1초)에 맞춰 놓음. a 의 OnComplete 가 여기서 불림 |
| b | 0.5 에 이미 끝남 | 건너뜀 |
| 콜백 @1 | 0.8 < 1 ≤ 1.2 | 실행 |
| c | 걸침 | 로컬 0.2초 지점에 놓음. c 의 첫 갱신이라 시작값도 여기서 읽힘 |
지난 프레임에서 흐른 시간을 더해 주는 것이 아니라 매번 “지금 몇 초 지점이다” 를 알려 줍니다. 그래서 끝을 지난 자식도 끝에 정확히 놓입니다.
끝을 지난 자식은 끝으로 맞춰 OnComplete 를 빠뜨리지 않고, 시퀀스를 어디로 점프시켜도 자식 상태가 어긋나지 않습니다. 대신 이미 끝난 항목도 매 프레임 한 번씩 검사합니다. 시퀀스 100개에서 마지막 항목 하나만 진행 중일 때의 프레임 증가분입니다.
| 시퀀스 100개 × 항목 M | Mono | IL2CPP |
|---|---|---|
| M = 10 | +0.010 ms | +0.058 ms |
| M = 100 | +0.116 ms | +0.103 ms |
| M = 1000 | +2.486 ms | +1.805 ms |
움직이는 트윈은 셋 다 100개인데 비용은 항목 수를 따라 늘었습니다. 끝난 항목 하나가 Mono 에서 매 프레임 10~25 ns 입니다.
되감을 때는 뒤에서부터 돈다
to < from 이면 같은 시간표를 끝에서 앞으로 훑습니다. 같은 대상을 이어서 옮기는 자식들이 그 시각의 올바른 값을 남기려면, 앞으로 갈 때는 뒤 항목이, 되감을 때는 앞 항목이 마지막에 써야 하기 때문입니다.
되감을 때는 한 번도 시작하지 않은 자식을 건너뜁니다. 콜백도 방향을 탑니다. 0.5초와 1초에 콜백을 둔 시퀀스를 끝까지 재생한 뒤 PlayBackwards() 하면 1초 것, 0.5초 것 순으로 불렸고, Rewind() 로 한 번에 돌리면 하나도 불리지 않았습니다.
주의할 점
1. 시퀀스에 넣은 트윈은 시퀀스로 다룬다
안 지키면 생기는 문제. 자식 참조로 Kill(), Complete(), Pause() 를 부르거나 OnDestroy 에서 transform.DOKill() 로 정리해도, 기본 로그 설정에서는 흔적 없이 무시됩니다. 오브젝트가 사라진 뒤에도 시퀀스가 돌며 SafeMode 경고를 남기거나, 풀링된 오브젝트라면 다음 사용 때 옛 움직임이 섞입니다.
이렇게 쓴다. 제어는 부모 Sequence 에 합니다. DOTween.Sequence(transform) 이나 SetTarget(transform) 으로 시퀀스에 대상을 걸면 transform.DOKill() 이 시퀀스를 잡고 자식도 함께 정리됩니다. 오브젝트 수명에 묶으려면 시퀀스에 SetLink(gameObject) 를 겁니다.
2. 무한 루프는 시퀀스에 건다
안 지키면 생기는 문제. SetLoops(-1) 트윈을 Append 하면 int.MaxValue 루프가 되어 시퀀스 길이가 2,147,484,000초가 됩니다. 그 뒤에 붙인 트윈과 시퀀스의 OnComplete 는 오지 않습니다. XML 주석은 “1 로 바뀐다” 고 적어서 더 헷갈립니다.
이렇게 쓴다. 반복할 구간만 따로 시퀀스로 만들어 그 시퀀스에 SetLoops(-1) 을 겁니다.
3. 시퀀스는 만든 프레임 안에서 다 채운다
안 지키면 생기는 문제. 시퀀스는 첫 갱신에서 잠기고, 그 뒤의 Append, Join, Insert 는 무시됩니다. 비동기 로드가 끝나면 트윈을 덧붙이는 코드는 로드가 한 프레임만 늦어도 조용히 빠집니다. 자식도 넣는 순간 잠겨서 Append 뒤에 부른 SetLoops, SetDelay 는 먹지 않습니다.
이렇게 쓴다. 자식 설정은 Append 전에 끝내고 시퀀스 구성은 한 메서드 안에서 끝냅니다. 나중에 붙일 것은 자리만 AppendInterval 로 잡고 AppendCallback 에서 따로 시작합니다.
4. 시퀀스의 SetEase 는 자식 이징 위에 겹친다
안 지키면 생기는 문제. 시퀀스에 이징을 걸면 from 과 to 가 먼저 휘고, 자식은 그 휜 시각 위에 자기 이징(기본 OutQuad)을 또 겁니다. 끝부분이 예상보다 훨씬 느려지고 콜백 시점도 휜 시간축을 따라 움직입니다.
이렇게 쓴다. 시퀀스 이징은 기본값 Linear 로 두고 자식마다 이징을 정합니다. 전체를 한 곡선으로 휘고 싶을 때만 시퀀스에 걸고 자식을 Ease.Linear 로 둡니다.
정리
DOTween 의 시퀀스는 넣은 트윈을 활성 목록에서 빼내 시간표에 적고, 매 프레임 자식마다 로컬 시각으로 Goto 합니다.
- 넣는 순간 자식은 매니저 손을 떠나, 자식 직접 제어와
DOKill(target)이 조용히 빗나갑니다. - 끝을 지난 자식은 끝으로 맞추고, 끝난 항목도 매 프레임 검사해 비용이 항목 수에 비례합니다.
- 되감을 때는 뒤에서부터 돌아 그 시각의 올바른 값을 남기고, 시작하지 않은 자식은 건너뜁니다.