안녕하세요!


오늘은 저희 게임의 세이브 로드 시스템을 개선해온 과정에 대해 소개하려고 합니다.



viewimage.php?id=2abcdd23dad63db0&no=24b0d769e1d32ca73ce885fa1bd6253138ca63577cd68a52ea2273d44e232614e0e77fe246ee33a6ae1f8d77e5108b15ada1e1011a90aa5245c84c74c7815d313f3d6b6a63

 

viewimage.php?id=2abcdd23dad63db0&no=24b0d769e1d32ca73ce885fa1bd6253138ca63577cd68a52ea2273d44e232614e0e77fe246ee33a6ae1f8d77e5108b15ada1e1011a90aa5245904c2e9d8e5865f4d563bbd3


플레이어는 게임을 진행하며, 씬에 배치된 오브젝트를 부수거나 상태를 변화시킬 수 있습니다.


여러 맵을 거치며 게임 중반부까지 진행시 대략 8000개 정도의 오브젝트를 저장해야 합니다. (중간중간 생기는 파편들 및 이것저것)


이것을 전부 저장할 경우 끊김 현상이 있을 수 있기 때문에 여러 개선 작업을 했습니다.






1. 맵 단위로 분할


viewimage.php?id=2abcdd23dad63db0&no=24b0d769e1d32ca73ce885fa1bd6253138ca63577cd68a52ea2273d44e232614e0e77fe246ee33a6ae1f8d77e5108b15ada1e1011a90aa5245901f7897d9056014f730c068


게임을 진행하며 방문한 맵이 많아질수록 저장할 정보량이 늘어나게 됩니다.


그래서 세이브 데이터의 정보들을 맵 단위로 나누고, 맵을 나갈 때 해당 맵의 정보만 업데이트합니다.




2. 멀티스레딩


viewimage.php?id=2abcdd23dad63db0&no=24b0d769e1d32ca73ce885fa1bd6253138ca63577cd68a52ea2273d44e232614e0e77fe246ee33a6ae1f8d77887d88147cc945c42859051178e1c746b1e8d3f770512028


게임 월드로부터 데이터를 수집하는 것은 메인 쓰레드에서 수행하지만, 이후 직렬화 과정부터는 자식 쓰레드에서 처리합니다.


메인 쓰레드에서의 동작을 최소한으로 줄여서 게임 루프의 부하를 줄입니다.



다만, 이 경우 자식 쓰레드가 데이터를 처리중일 때 다음 세이브 요청이 온다면 문제가 생길 수 있습니다.


그래서 자식 쓰레드가 데이터를 처리중일 땐 세이브 데이터에 락을 걸어 대기시킵니다.

(이 경우 메인 쓰레드가 정지하게 되지만, 정상적인 경우엔 이런 일이 일어나지 않기 때문에 만약을 대비한 처리입니다)




3. 프레임 분할


viewimage.php?id=2abcdd23dad63db0&no=24b0d769e1d32ca73ce885fa1bd6253138ca63577cd68a52ea2273d44e232614e0e77fe246ee33a6ae5ad925e2798b1131901659f8088a8f998f56700c99d67b


게임 월드에서 데이터를 수집하는 과정을 여러 프레임으로 분할합니다.


위 gif에서 보라색으로 깜빡인 오브젝트들이 해당 프레임에서 수집 대상이 된 오브젝트들입니다.


맵에 물체가 많은 경우, 맵 단위로 분할을 하더라도 데이터 수집에 시간이 오래 걸리는 경우가 생겨 도입하였습니다.



다만 이렇게 하면 세이브 도중 물체의 상태가 바뀔 수 있습니다.


문제 발생을 막기 위해 중요 오브젝트들은 한 프레임에 함께 저장하도록 했고,


저장 도중에 새로운 오브젝트가 생성되거나 삭제된 경우 저장 루틴을 처음부터 다시 시작합니다.


그리고 혹여나 저장 순간 플레이어가 허공 등 위험한 위치에 있다면 가장 최근의 '안전한 위치'에 저장합니다.




4. 데이터 바이너리화


게임 오브젝트들은 여러 타입의 저장 필드를 가지는데, 초기에는 저장 타입으로 int, float, bool, string 정도만 지원하고 있었습니다.


그래서 Struct의 경우 여러 값으로 분리하여 저장했었습니다.


Color타입의 colorTint 라는 값을 예시로 들면, "colorTint.r", "colorTint.g", "colorTint.b", "colorTint.a"라는 4개의 float으로 분리됩니다.


24b0d121e09c28a8699fe8b115ef046c61f62a4a


그런데 이 경우, 실제 저장해야 하는 값은 float 4개일 뿐인데 각종 라벨들이 붙어 쓸 데 없이 거대해집니다.


어느 날 이런 쓸 데 없는 값들이 세이브 파일 용량의 80% 이상을 차지한다는 걸 발견하게 되어 바이너리 형태로 개선하게 되었습니다.




예전방식바이너리기반
읽기 (데이터: int 100000개 +string 20000개)
8070 ms53 ms
쓰기 (데이터: int 10000개 + string 2000개)640 ms8 ms



바이너리 기반 방식으로 교체하고 읽고 쓰는 속도도 빨라졌습니다. (용량 감소 / 텍스트 파싱 생략)




24b0d121e09c28a8699fe8b115ef046a7d64efc9


다만 세이브파일을 사람이 읽을 수 없게 되어 디버깅에 어려움이 생겼기 때문에 에디터를 만들어 보완하였습니다.





5. 로딩 알고리즘 개선



게임을 불러오는 과정은 맵에 배치된 오브젝트들의 상태를 세이브된 데이터를 참고하여 바꾸는 과정입니다.


즉, '맵에 배치된 오브젝트 리스트' 와 '세이브 데이터의 오브젝트 상태 리스트' 라는 두 리스트를 순회해야 합니다.


맵에 배치된 초기 상태로부터 아무런 상태도 변화하지 않은 오브젝트의 경우 저장을 생략하며,


새로 생성된 오브젝트도 존재하기 때문에 두 리스트는 서로 길이도 다르고, 담긴 오브젝트도 다릅니다.


그래서 옛날엔 단순한 방식으로 로드를 진행했었습니다.





1) 초기 버전 : 모두 순회하기

foreach (var obj in map.objects)
{
    foreach (var data in mapData.objectDatas)
    {
        if (data.id == obj.id)
        {
            obj.Load(data);
            break;
        }
    }
}


단순하면서 버그가 발생할 일도 없지만 느립니다.


옛날엔 오브젝트 수가 별로 많지 않았기 때문에 별 생각이 없었습니다.




2) 두 번째 버전 : 이진 탐색

// mapData.objectDatas는 미리 정렬해둔다
foreach (var obj in map.objects)
{
    var data = BinarySearch(mapData.objectDatas, obj.id);
    if (data != null)
        obj.Load(data);
}


데이터를 미리 정렬해두면 이진 탐색이 가능합니다.


합리적인 수준으로 퍼포먼스가 나오며, 최근까지 썼던 방식입니다. 


평균 측정 : 148ms




3) 세 번째 버전 : 투 포인터 알고리즘

// map.objects와 mapData.objectDatas는 정렬된 상태로 만들어둔다
int pointerA = 0;
int pointerB = 0;
while (pointerA < map.objects.Count && pointerB < mapData.objectDatas.Count)
{
    if (map.objects[pointerA].id == mapData.objectDatas[pointerB].id)
    {
        map.objects[pointerA].Load(mapData.objectDatas[pointerB]);
    }
    else if (map.objects[pointerA].id < mapData.objectDatas[pointerB].localId)
    {
        pointerA++;
    }
    else
    {
        pointerB++;
    }
}


두 리스트를 정렬해두고 리스트마다 각각 인덱스를 증가시키며 비교하는 알고리즘입니다.


평균 측정 : 6ms







이밖에도 오픈소스인 MemoryPack 라이브러리를 도입하기도 하고 헤더만 읽는 기능을 추가하기도 하고 다양한 방법으로 최적화를 진행했습니다. 


덕분에 이제는 파편과 핏방울을 마음껏 뿌리고 죄다 저장해도 렉이 걸리지 않습니다.



감사합니다.



[개발 일지 링크 모음]


[스팀 페이지]