안녕하세요!
오늘은 저희 게임의 세이브 로드 시스템을 개선해온 과정에 대해 소개하려고 합니다.
플레이어는 게임을 진행하며, 씬에 배치된 오브젝트를 부수거나 상태를 변화시킬 수 있습니다.
여러 맵을 거치며 게임 중반부까지 진행시 대략 8000개 정도의 오브젝트를 저장해야 합니다. (중간중간 생기는 파편들 및 이것저것)
이것을 전부 저장할 경우 끊김 현상이 있을 수 있기 때문에 여러 개선 작업을 했습니다.
1. 맵 단위로 분할
게임을 진행하며 방문한 맵이 많아질수록 저장할 정보량이 늘어나게 됩니다.
그래서 세이브 데이터의 정보들을 맵 단위로 나누고, 맵을 나갈 때 해당 맵의 정보만 업데이트합니다.
2. 멀티스레딩
게임 월드로부터 데이터를 수집하는 것은 메인 쓰레드에서 수행하지만, 이후 직렬화 과정부터는 자식 쓰레드에서 처리합니다.
메인 쓰레드에서의 동작을 최소한으로 줄여서 게임 루프의 부하를 줄입니다.
다만, 이 경우 자식 쓰레드가 데이터를 처리중일 때 다음 세이브 요청이 온다면 문제가 생길 수 있습니다.
그래서 자식 쓰레드가 데이터를 처리중일 땐 세이브 데이터에 락을 걸어 대기시킵니다.
(이 경우 메인 쓰레드가 정지하게 되지만, 정상적인 경우엔 이런 일이 일어나지 않기 때문에 만약을 대비한 처리입니다)
3. 프레임 분할
게임 월드에서 데이터를 수집하는 과정을 여러 프레임으로 분할합니다.
위 gif에서 보라색으로 깜빡인 오브젝트들이 해당 프레임에서 수집 대상이 된 오브젝트들입니다.
맵에 물체가 많은 경우, 맵 단위로 분할을 하더라도 데이터 수집에 시간이 오래 걸리는 경우가 생겨 도입하였습니다.
다만 이렇게 하면 세이브 도중 물체의 상태가 바뀔 수 있습니다.
문제 발생을 막기 위해 중요 오브젝트들은 한 프레임에 함께 저장하도록 했고,
저장 도중에 새로운 오브젝트가 생성되거나 삭제된 경우 저장 루틴을 처음부터 다시 시작합니다.
그리고 혹여나 저장 순간 플레이어가 허공 등 위험한 위치에 있다면 가장 최근의 '안전한 위치'에 저장합니다.
4. 데이터 바이너리화
게임 오브젝트들은 여러 타입의 저장 필드를 가지는데, 초기에는 저장 타입으로 int, float, bool, string 정도만 지원하고 있었습니다.
그래서 Struct의 경우 여러 값으로 분리하여 저장했었습니다.
Color타입의 colorTint 라는 값을 예시로 들면, "colorTint.r", "colorTint.g", "colorTint.b", "colorTint.a"라는 4개의 float으로 분리됩니다.
그런데 이 경우, 실제 저장해야 하는 값은 float 4개일 뿐인데 각종 라벨들이 붙어 쓸 데 없이 거대해집니다.
어느 날 이런 쓸 데 없는 값들이 세이브 파일 용량의 80% 이상을 차지한다는 걸 발견하게 되어 바이너리 형태로 개선하게 되었습니다.
| 예전방식 | 바이너리기반 | |
| 읽기 (데이터: int 100000개 +string 20000개) | 8070 ms | 53 ms |
| 쓰기 (데이터: int 10000개 + string 2000개) | 640 ms | 8 ms |
바이너리 기반 방식으로 교체하고 읽고 쓰는 속도도 빨라졌습니다. (용량 감소 / 텍스트 파싱 생략)
다만 세이브파일을 사람이 읽을 수 없게 되어 디버깅에 어려움이 생겼기 때문에 에디터를 만들어 보완하였습니다.
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 라이브러리를 도입하기도 하고 헤더만 읽는 기능을 추가하기도 하고 다양한 방법으로 최적화를 진행했습니다.
덕분에 이제는 파편과 핏방울을 마음껏 뿌리고 죄다 저장해도 렉이 걸리지 않습니다.
감사합니다.
왕고수ㄷㄷ
미친 갓겜 개발자잖아? 데모 재밌게 햇음
이 댓글은 게시물 작성자가 삭제하였습니다.
광고라서 지웁니다