개요

14편에서 얻은 결론은 비용을 결정하는 건 좀비 마리 수가 아니라 얼마나 몰려 있느냐였다. 682마리에서 753마리 사이에 프레임 시간이 다섯 배로 뛰었다.

그러면 답은 둘 중 하나다. 몰리지 않게 하거나, 애초에 그만큼 안 만들거나.

앞의 것부터 검토했고, 보고 나서 접었다. 뒤의 것을 골라 상한을 넣었다. 그리고 마지막에 실제로 플레이해봤더니 그 상한이 걸릴 일이 없었다.

이번 글이 답할 질문. 최악의 경우를 막는 장치는 그 경우가 오지 않으면 무의미한가?


1. 먼저 나온 제안을 기각했다

멘토 역할로 쓰는 AI가 먼저 낸 안은 거리 기반 회수였다.

화면 밖으로 멀어진 좀비를 걷어들이면 마리 수와 밀집도가 같이 내려간다. 걷어들인 걸 다시 쓰면 오브젝트를 돌려쓰는 근거도 새로 생긴다.

말이 됐다. 그런데 실제 분포를 눈으로 보고 나서 접었다.

작업 화면에서 본 분포. 화면 밖으로 흩어진 게 아니라 플레이어 아래쪽에 통째로 정체해 있다

작업 화면에서 좀비들이 어디에 있는지를 봤더니, 멀리 흩어진 게 아니었다. 플레이어 쪽을 향해 반원 모양으로 정체해 있었다. 당연했다. 좀비가 태어나는 자리는 플레이어를 중심으로 한 원 둘레고, 태어나자마자 전부 플레이어를 향해 온다. 출발점이 원이고 목적지가 한 점이면 결국 한 점으로 모인다.

걷어들일 만큼 멀어진 개체가 없다. 회수 조건이 성립하지 않으니 제안은 기각이다.

분포를 안 보고 코드부터 짰으면, 한 번도 안 걸리는 회수 로직을 만들고 뿌듯해했을 것이다.

여기서 스폰 위치를 흩어놓는 방법도 생각해봤는데, 어떻게 흩어놓든 목적지가 플레이어 한 점이라 결국 같은 곳에서 뭉친다. 그래서 위치가 아니라 동시에 존재하는 수를 건드리기로 했다.

보면서 같이 알게 된 것

  • 앞줄이 벽처럼 막고 있어서 뒷줄 수백 마리는 플레이어에게 닿지도 못한다. 그런데 물리 계산은 매번 다 낸다. 비용은 전부 내면서 게임에는 아무 기여도 안 하는 상태
  • 좀비끼리 밀다가 벽을 뚫고 나가는 현상이 있었다. 빠른 물체가 벽을 지나쳐버리는 그 문제가 아니라, 뒤에서 밀어대는 압력에 밀려 스며 나가는 쪽이다. 한 프레임에 풀 수 있는 겹침의 양을 넘어서면 이렇게 된다

두 번째는 마리 수가 줄면 같이 사라질 것으로 보고 일단 보류했다. 원인이 겹침의 양이면, 겹침이 줄면 없어진다.


2. 상한 600 — break를 어디에 두느냐

동시에 존재할 수 있는 좀비 수에 뚜껑을 씌우기로 했다. 코드는 짧다.

for (int i = 0; i < _countPerSpawn; ++i)
{
    if (AliveCount >= _limitAliveCount) break;
    GameObject obj = Spawn();
    if (obj == null) continue;
    ++AliveCount;
    OnSpawn?.Invoke();
}
yield return _wait;

여기서 중요한 건 break가 어디까지 빠져나가느냐다.

break는 for만 빠져나가야 한다

스폰은 두 겹으로 돈다. 바깥은 스포너가 사는 동안 계속 도는 반복문이고, 안쪽은 이번에 몇 마리 낼지만큼 도는 반복문이다.

break자기를 감싼 가장 가까운 반복문 하나만 빠져나온다. 그래서 안쪽만 끊기고 바깥은 계속 돈다.

만약 바깥까지 끊겼다면 실제 동작
600에 닿는 순간 스폰이 영영 멈춘다 600에 닿으면 이번 회차만 건너뛴다
좀비가 죽어 자리가 나도 안 채워진다 죽어서 599가 되면 다음 회차에 다시 채운다

막는 것과 끝내는 것은 다르다. 상한은 잠그는 게 아니라 자리가 없을 때만 기다리는 것이어야 한다.

한 마리씩 검사하는 이유

검사를 반복문 에 뒀다. 밖에 두면 “이번에 6마리”인 상황에서 599마리일 때 6마리가 통째로 들어와 605가 된다.

안에서 한 마리마다 검사하면 599에서 한 마리만 채우고 끊긴다. 정확히 600이다.

왜 하필 600인가

14편에서 잰 숫자에서 뽑았다.

좀비 수 프레임 시간
682 2.32ms
753 11.41ms

682와 753 사이 어딘가에서 갈린다. 어디가 정확한 경계인지는 모른다. 그래서 경계보다 넉넉히 아래인 600으로 잡았다.

경계를 모를 땐 경계에 붙이지 않는다. 정확한 지점을 찾겠다고 여러 번 더 재는 것보다, 여유를 두고 한 번에 넘어가는 게 싸다.


3. 상한에 닿은 상태를 쟀다

상한이 실제로 걸리는 상황, 그러니까 이 게임이 낼 수 있는 최악의 상태를 만들어놓고 쟀다.

상한에 닿아 좀비 수가 600에서 더 안 올라간다

항목
좀비 수 600 (상한 도달)
한 프레임 시간 8.10ms
그중 2D 물리 4.01ms (49.6%)
쓰레기 메모리 0 B

상한 도달 상태의 프로파일러. CPU 8.10ms, 물리 49.6%, GC Alloc 전 행 0 B

60fps 예산은 한 프레임 16.6ms다. 최악의 상태에서 8.10ms니까 절반쯤 쓴다. 상한 없이 같은 구간까지 쌓았을 때가 16.01ms였으니 대략 절반으로 줄었다.

물리 비중도 66.2%에서 49.6%로 내려왔다. 밀집이 줄었으니 밀어내기 연쇄도 줄었다.

여기까지는 계획대로였다. 최악의 경우를 정의하고, 그 상태를 만들고, 예산 안에 들어오는 걸 확인했다.


4. 반전 — 정상 플레이에서는 안 걸린다

측정용으로 바꿔둔 것들을 전부 원복했다. 체력을 원래대로 되돌리고, 직접 플레이했다.

조건 결과 그때 좀비 수
밸런스 조정 전 40초 만에 죽음 65
조정 후 끝까지 버팀 최대 30

측정을 마치고 직접 해보니 40초 만에 이 화면을 봤다

65마리. 임계라고 잡았던 682의 10분의 1도 안 된다.

600짜리 뚜껑은 한 번도 안 열린다. 실제 플레이에서 그 근처까지 갈 일이 없기 때문이다.

성능을 재다가 게임이 어렵다는 걸 알았다

40초에 죽은 건 성능 문제가 아니다. 그냥 게임이 너무 어려웠다. 레벨업이 느려서 강해지기 전에 좀비에 깔린다.

경험치 요구량이 레벨마다 불어나는 배수를 2.0에서 1.2로 낮췄다. 그랬더니 이번엔 끝까지 안 죽고 좀비가 30마리에서 평형이 됐다. 너무 쉬워진 것이다.

한쪽 끝에서 반대쪽 끝으로 갔다. 가운데를 찾는 건 다음 일로 미뤘다.

성능 문제를 재려다 게임이 너무 어렵다는 걸 알게 됐다. 무적으로 재는 동안엔 절대 안 보이는 것이었다.

그럼 상한은 헛수고였나

아니라고 정리했다. 이유는 두 가지다.

  • 밸런스는 앞으로 계속 바뀐다. 스폰을 늘리는 조정을 하다가 실수하면 그때 좀비가 폭주한다. 그때 뚜껑이 있다
  • 최악의 경우가 예산 안이라는 걸 숫자로 확인했다. 이건 상한을 만들었기 때문에 잴 수 있었던 값이다

안전장치가 한 번도 안 걸린다는 건, 막으려던 상황이 애초에 오지 않는다는 뜻이다. 그게 나쁜 소식은 아니다.


정리

  • 코드를 짜기 전에 분포를 눈으로 본다. 반원형으로 정체해 있어서, 거리로 걷어들이는 안은 한 번도 안 걸릴 코드였다.
  • 출발점이 원이고 목적지가 한 점이면 결국 한 점에 모인다. 스폰 위치를 흩어도 소용없다.
  • 뒷줄 수백 마리는 비용만 내고 게임에는 기여하지 않는다.
  • break는 자기를 감싼 가장 가까운 반복문 하나만 빠져나온다. 바깥까지 끊기면 상한이 아니라 영구 정지가 된다.
  • 막는 것과 끝내는 것은 다르다. 자리가 나면 다시 채워야 상한이다.
  • 검사는 반복문 안에서 한 마리마다. 밖에서 하면 한 번에 여러 마리가 넘어 들어온다.
  • 경계를 모를 땐 경계에 붙이지 않는다. 682와 753 사이가 경계라 600으로 잡았다.
  • 최악의 상태에서 8.10ms. 60fps 예산의 절반이고, 상한 없을 때의 절반이다.
  • 실제 부하는 임계의 10분의 1도 안 됐다. 상한은 최악을 막는 장치로만 남는다.
  • 성능을 재려다 게임이 너무 어렵다는 걸 알았다. 그리고 고치고 나니 이번엔 너무 쉬워졌다.

참고 자료

한줄 평

  • 최악의 경우를 막는 뚜껑을 잘 만들어놓고 정작 실제로 해보니 40초 만에 죽었는데, 이게 오늘 제일 웃픈 순간이었다..