Car Open World 개발 일지 #2: 오픈월드 맵 스트리밍 기법
방대한 오픈월드 맵을 모바일 메모리에 올리기 위한 청크 단위의 맵 동적 로딩 및 언로딩(Streaming) 시스템 구축기.
오픈월드 구현의 핵심: 왜 스트리밍인가?
모바일 기기에서 광활한 오픈월드를 구현할 때 가장 먼저 맞닥뜨리는 벽은 바로 '메모리(RAM)'입니다. PC나 콘솔과 달리 모바일 환경은 가용 메모리가 매우 제한적이며, 운영체제가 백그라운드 앱을 관리하는 방식도 엄격합니다. 4km x 4km 크기의 맵을 한 번에 메모리에 올리려고 시도한다면, 저사양 기기에서는 앱이 실행되자마자 크래시가 발생할 것입니다. 이를 해결하기 위해 필요한 것이 바로 '맵 스트리밍(Map Streaming)' 기법입니다.
맵 스트리밍은 플레이어의 현재 위치를 중심으로 필요한 구역(Chunk)만 실시간으로 로드하고, 멀어진 구역은 메모리에서 해제하는 기술입니다. 단순히 로딩 화면을 보여주는 것이 아니라, 플레이어가 눈치채지 못하게 배경에서 비동기적으로 작업이 이루어져야 한다는 점이 구현의 핵심입니다.
청크(Chunk) 단위의 세계 분할
우선 전체 세계를 일정한 크기의 격자(Grid)로 나눕니다. Car Open World 프로젝트에서는 100m x 100m 단위의 청크를 기본 단위로 설정했습니다. 각 청크는 독립적인 프리팹(Prefab)이나 씬(Scene) 파일로 저장됩니다. 플레이어의 좌표를 청크 좌표로 변환하는 공식은 간단합니다.
// 플레이어의 월드 좌표를 청크 좌표로 변환
int chunkX = Mathf.FloorToInt(playerTransform.position.x / chunkSize);
int chunkZ = Mathf.FloorToInt(playerTransform.position.z / chunkSize);
Vector2Int currentChunkCoord = new Vector2Int(chunkX, chunkZ);
비동기 로딩 시스템 구축
유니티에서는 Addressables 시스템이나 SceneManager.LoadSceneAsync를 사용하여 비동기 로딩을 구현할 수 있습니다. 맵 스트리밍 시스템은 매 프레임 플레이어의 위치를 확인하고, 미리 정의된 '가시 범위(Visible Radius)' 내에 있는 청크 중 아직 로드되지 않은 것들을 큐(Queue)에 담아 순차적으로 로딩합니다.
여기서 주의할 점은 한 프레임에 너무 많은 로딩 요청을 보내면 프레임 드랍(Stuttering)이 발생할 수 있다는 것입니다. 따라서 Coroutine이나 Async/Await을 활용해 프레임당 로딩 개수를 제한해야 합니다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;
public class MapStreamingManager : MonoBehaviour
{
public Transform player;
public float chunkSize = 100f;
public int viewDistance = 2; // 주변 2개 청크까지 로드
private Dictionary loadedChunks = new Dictionary();
private List chunksToRemove = new List();
void Update()
{
UpdateVisibleChunks();
}
void UpdateVisibleChunks()
{
int currentX = Mathf.FloorToInt(player.position.x / chunkSize);
int currentZ = Mathf.FloorToInt(player.position.z / chunkSize);
// 현재 시야 범위 내의 모든 청크 좌표 계산
HashSet visibleCoords = new HashSet();
for (int x = -viewDistance; x <= viewDistance; x++)
{
for (int z = -viewDistance; z <= viewDistance; z++)
{
visibleCoords.Add(new Vector2Int(currentX + x, currentZ + z));
}
}
// 해제해야 할 청크 확인
chunksToRemove.Clear();
foreach (var chunk in loadedChunks)
{
if (!visibleCoords.Contains(chunk.Key))
{
chunksToRemove.Add(chunk.Key);
}
}
// 청크 언로드
foreach (var coord in chunksToRemove)
{
UnloadChunk(coord);
}
// 새로운 청크 로드
foreach (var coord in visibleCoords)
{
if (!loadedChunks.ContainsKey(coord))
{
StartCoroutine(LoadChunkAsync(coord));
}
}
}
IEnumerator LoadChunkAsync(Vector2Int coord)
{
string sceneName = $"Chunk_{coord.x}_{coord.y}";
loadedChunks.Add(coord, new ChunkData { isPending = true });
AsyncOperation op = SceneManager.LoadSceneAsync(sceneName, LoadSceneMode.Additive);
yield return op;
loadedChunks[coord].isPending = false;
}
void UnloadChunk(Vector2Int coord)
{
string sceneName = $"Chunk_{coord.x}_{coord.y}";
SceneManager.UnloadSceneAsync(sceneName);
loadedChunks.Remove(coord);
}
class ChunkData { public bool isPending; }
}
성능 최적화: 가비지 컬렉션과 메모리 파편화
스트리밍 시스템을 돌리다 보면 잦은 로드/언로드로 인해 메모리 파편화가 발생할 수 있습니다. 이를 방지하기 위해 정적인 오브젝트들은 최대한 Object Pooling을 활용하거나, 에셋 번들 관리 시 UnloadUnusedAssets를 적절한 타이밍에 호출해줘야 합니다. 특히 모바일에서는 메모리 압박이 심해지면 OS 단에서 앱을 강제 종료시키므로, 타겟 기기의 사양에 맞춰 시야 거리(View Distance)를 동적으로 조절하는 옵션을 제공하는 것이 좋습니다.
도전 과제: 플로팅 포인트 오차 문제
맵이 너무 넓어지면(예: 수십 킬로미터) 원점에서 멀어질수록 부동 소수점(Floating Point) 오차로 인해 떨림 현상이 발생합니다. 이를 'Floating Origin' 문제라고 하는데, 플레이어가 일정 거리 이상 이동하면 세계의 원점을 플레이어 위치로 재설정(Shift)하는 로직이 필요합니다. Car Open World에서는 이 문제를 해결하기 위해 5km마다 월드를 재정렬하는 시스템을 도입하여 안정적인 물리 연산을 유지했습니다.
마치며
오픈월드 스트리밍은 단순히 기술적인 구현을 넘어, 레벨 디자인과도 밀접하게 연결되어 있습니다. 청크 경계에서 플레이어가 갑자기 나타나는 오브젝트(Pop-in)를 보지 않도록 안개 효과(Fog)나 LOD 시스템을 병행하여 사용하는 것이 중요합니다. 다음 일지에서는 이러한 가시 거리 최적화 기법에 대해 더 자세히 다뤄보겠습니다.