대규모 월드 미니맵 타일 스트리밍 구현: 줌 레벨별 캐시와 좌표 오차 누적 방지법
Unity C# 기준으로 대규모 월드 미니맵의 타일 스트리밍 구조를 설계하고 줌 레벨별 LRU 캐시·비동기 로딩·double 기반 좌표 계산으로 끊김과 오차 누적을 줄이는 방법을 설명합니다.

핵심 요약
대규모 월드 미니맵은 화면에 필요한 타일만 비동기로 요청하고 타일 키를 zoom/x/y로 분리해 줌 레벨별 LRU 캐시에 저장하는 방식으로 구성합니다. 월드 좌표는 누적 덧셈 대신 원본 월드 좌표에서 매번 타일 인덱스를 double로 계산하고 렌더링 직전에 타일 원점 기준의 작은 로컬 좌표로 변환해야 부동소수점 오차가 커지지 않습니다.
대규모 미니맵에서 타일 스트리밍이 필요한 이유
플레이어가 이동할 때 미니맵 전체 텍스처를 다시 만들면 월드 크기에 비례해 메모리와 업로드 비용이 증가합니다. 예를 들어 4096×4096 픽셀 텍스처 한 장은 RGBA32 기준 약 64MiB이며 여러 줌 레벨을 함께 보관하면 필요한 데이터가 빠르게 늘어납니다.
타일 스트리밍은 월드를 일정한 격자로 나누고 현재 뷰포트 주변의 타일만 로드합니다. 화면에 보이는 타일이 5×5라면 전체 월드 크기와 관계없이 기본적으로 25개 안팎의 타일만 관리하면 됩니다. 다만 실제 요청 수는 다음 요소의 영향을 받습니다.
- 현재 줌 레벨과 프리패치용 인접 줌 레벨
- 타일 해상도와 화면의 미니맵 크기
- 카메라 회전 여부와 뷰포트 여백
- 네트워크 또는 디스크 로딩 지연
- 디코딩된 CPU 이미지와 GPU 텍스처의 보관 정책
타일 하나를 너무 크게 잡으면 요청 수는 줄지만 작은 이동에도 큰 텍스처를 업로드해야 합니다. 반대로 128×128처럼 지나치게 작게 나누면 타일 경계와 비동기 작업 관리 비용이 커집니다. 256×256 또는 512×512부터 시작해 실제 기기에서 업로드 시간과 메모리를 측정하는 편이 낫습니다.
타일 피라미드와 줌 레벨은 어떻게 설계할까?
일반적인 구조는 쿼드트리 또는 XYZ 타일 피라미드입니다. 줌 레벨 z에서 한 축의 타일 개수는 다음과 같습니다.
월드가 정사각형이고 타일 한 변이 tileSize라면 해당 줌 레벨의 논리적 픽셀 폭은 다음과 같이 계산할 수 있습니다.
게임 월드 좌표를 정규화된 u, v 좌표로 바꾼 뒤 타일 인덱스를 구합니다.
여기서 v를 위에서 아래로 증가시키는 이미지 좌표계에 맞추려면 tileY를 반전해야 합니다. 즉, 사용하는 타일 포맷이 XYZ인지 TMS인지 먼저 고정해야 합니다. 이 규칙이 섞이면 타일이 상하로 뒤집히거나 줌 전환 시 위치가 어긋납니다.
| 항목 | 권장 기준 | 주의점 |
|---|---|---|
| 타일 키 | zoom/x/y/revision | 파일명만 키로 사용하지 않기 |
| 타일 크기 | 256 또는 512 픽셀 | 업로드 비용과 요청 수를 함께 측정 |
| 줌 전환 | 현재 줌 우선, 인접 줌 프리패치 | 먼 줌을 먼저 로드하면 체감 지연 증가 |
| 좌표 타입 | 타일 인덱스는 int, 월드 변환은 double | 최종 셰이더 입력은 작은 로컬 좌표 사용 |
| 경계 처리 | 1~2픽셀 gutter 또는 UV clamp | 타일 사이 검은 선 방지 |
줌 레벨마다 별도의 캐시를 두면 메모리 상한을 제어하기 쉽습니다. 예를 들어 현재 줌 캐시에는 64개, 한 단계 낮은 줌에는 32개, 한 단계 높은 줌에는 16개를 배정할 수 있습니다. 모든 줌에 동일한 개수의 슬롯을 주는 방식은 사용 빈도가 낮은 원거리 타일이 메모리를 오래 점유할 수 있습니다.
좌표 오차 누적은 왜 발생하는가?
다음과 같은 방식은 작은 월드에서는 문제가 잘 드러나지 않지만 대규모 월드에서 오차가 누적되기 쉽습니다.
// 피해야 할 예시
screenOffset += playerDelta * pixelsPerWorldUnit;
tileOffset += screenOffset;
매 프레임 float 델타를 누적하면 큰 기준값에 작은 변화량을 계속 더하게 됩니다. IEEE 754 단정밀도 부동소수점에서는 값이 커질수록 표현 가능한 최소 간격도 커집니다. 월드 좌표가 수십만 단위인데 이동량이 0.01이라면 어느 순간 이동량이 반올림되어 사라질 수 있습니다.
타일을 옮기는 과정에서 이미 계산된 화면 위치에 다시 보정값을 더하는 것도 같은 문제를 만듭니다. 줌 변경, 카메라 이동, 타일 교체가 여러 프레임에 걸쳐 일어나면 이전 프레임의 반올림 결과가 다음 계산의 입력이 됩니다.
기준 좌표에서 매번 다시 계산하기
오차 누적을 피하는 규칙은 간단합니다.
- 플레이어와 카메라의 원본 월드 좌표를 보관합니다.
- 원본 좌표에서 현재 줌의 타일 인덱스를 매번 계산합니다.
- 타일 위치는 타일 인덱스와 월드 경계로부터 다시 계산합니다.
- 렌더링 위치는 타일 원점에서의 작은 차이만 사용합니다.
Unity C#에서는 장거리 월드 좌표 계산에 double을 사용하고 타일 키에는 정수만 저장하는 형태가 실용적입니다.
using System;
using UnityEngine;
public readonly struct TileKey : IEquatable<TileKey>
{
public readonly int Zoom;
public readonly int X;
public readonly int Y;
public readonly int Revision;
public TileKey(int zoom, int x, int y, int revision = 0)
{
Zoom = zoom;
X = x;
Y = y;
Revision = revision;
}
public bool Equals(TileKey other) =>
Zoom == other.Zoom && X == other.X && Y == other.Y && Revision == other.Revision;
public override bool Equals(object obj) => obj is TileKey other && Equals(other);
public override int GetHashCode() => HashCode.Combine(Zoom, X, Y, Revision);
}
public static class MinimapProjection
{
public static TileKey WorldToTile(
double worldX,
double worldZ,
double minX,
double minZ,
double worldWidth,
double worldHeight,
int zoom,
int revision = 0)
{
int count = 1 << zoom;
double u = (worldX - minX) / worldWidth;
double v = (worldZ - minZ) / worldHeight;
int x = Mathf.Clamp((int)Math.Floor(u * count), 0, count - 1);
int y = Mathf.Clamp((int)Math.Floor(v * count), 0, count - 1);
return new TileKey(zoom, x, y, revision);
}
public static Vector2 WorldToTileLocal(
double worldX,
double worldZ,
double minX,
double minZ,
double worldWidth,
double worldHeight,
int zoom,
int tileSize)
{
int count = 1 << zoom;
double pixelsPerWorldX = (count * tileSize) / worldWidth;
double pixelsPerWorldZ = (count * tileSize) / worldHeight;
double pixelX = (worldX - minX) * pixelsPerWorldX;
double pixelZ = (worldZ - minZ) * pixelsPerWorldZ;
int tileX = Mathf.Clamp((int)Math.Floor(pixelX / tileSize), 0, count - 1);
int tileY = Mathf.Clamp((int)Math.Floor(pixelZ / tileSize), 0, count - 1);
float localX = (float)Math.Max(0.0, Math.Min(tileSize, pixelX - tileX * tileSize));
float localY = (float)Math.Max(0.0, Math.Min(tileSize, pixelZ - tileY * tileSize));
return new Vector2(localX, localY);
}
}
이 코드에서 pixelX 전체 값을 float으로 변환하지 않는 점이 중요합니다. 먼저 double로 타일 인덱스와 타일 내부 오프셋을 구한 뒤, 0부터 tileSize 사이의 작은 로컬 값만 float으로 바꿉니다. 이렇게 하면 큰 월드 원점이 렌더링 좌표의 정밀도를 잠식하지 않습니다.
화면에 필요한 타일은 어떻게 계산할까?
플레이어 위치 하나만 기준으로 타일을 요청하면 미니맵 확대, 회전, 화면 비율 변경에서 모서리가 비어 보일 수 있습니다. 미니맵 뷰포트의 네 모서리를 월드 좌표로 역변환하고 네 점을 포함하는 타일 사각형을 구하는 방식이 안전합니다.
계산 결과에는 최소 한 타일 이상의 여백을 추가합니다. 카메라가 빠르게 이동하거나 비동기 로딩이 늦을 때를 대비한 프리패치 영역입니다.
public static void AddVisibleKeys(
Bounds worldBounds,
int zoom,
int padding,
int revision,
HashSet<TileKey> destination)
{
TileKey min = MinimapProjection.WorldToTile(
worldBounds.min.x,
worldBounds.min.z,
0.0,
0.0,
1_000_000.0,
1_000_000.0,
zoom,
revision);
TileKey max = MinimapProjection.WorldToTile(
worldBounds.max.x,
worldBounds.max.z,
0.0,
0.0,
1_000_000.0,
1_000_000.0,
zoom,
revision);
int count = 1 << zoom;
int minX = Mathf.Clamp(min.X - padding, 0, count - 1);
int maxX = Mathf.Clamp(max.X + padding, 0, count - 1);
int minY = Mathf.Clamp(min.Y - padding, 0, count - 1);
int maxY = Mathf.Clamp(max.Y + padding, 0, count - 1);
for (int y = minY; y <= maxY; y++)
{
for (int x = minX; x <= maxX; x++)
destination.Add(new TileKey(zoom, x, y, revision));
}
}
실제 구현에서는 Bounds 대신 미니맵 카메라의 네 코너를 투영하는 편이 좋습니다. 지형이 회전된 직사각형으로 보이면 축 정렬된 월드 Bounds가 필요 이상으로 많은 타일을 포함할 수 있기 때문입니다. 타일 요청 수가 많지 않은 모바일 UI라면 단순한 사각형 범위부터 시작하고 프로파일러에서 요청 수가 문제가 될 때 코너 기반 계산으로 바꾸어도 됩니다.

줌 레벨별 캐시는 어떻게 구성할까?
타일 캐시는 최소한 다음 상태를 구분해야 합니다.
| 상태 | 의미 | 다음 처리 |
|---|---|---|
| Missing | 캐시에 없음 | 로딩 큐에 추가 |
| Loading | 파일 읽기 또는 디코딩 중 | 중복 요청 병합 |
| ReadyCPU | 이미지 데이터만 준비됨 | GPU 텍스처 생성 |
| ReadyGPU | 화면에 사용할 수 있음 | LRU 갱신 |
| Failed | 로드 실패 | 재시도 정책 또는 오류 타일 표시 |
| Evicted | 메모리에서 제거됨 | 다시 필요하면 재요청 |
캐시 키는 zoom/x/y만으로 충분하지 않을 수 있습니다. 지도 데이터가 패치되거나 바이오ーム 버전이 바뀌면 이전 타일이 새 타일로 교체되어야 하므로 revision 또는 콘텐츠 버전을 키에 포함하는 것이 좋습니다.
LRU 캐시는 최근 사용 시각만 보는 단순 구조로도 시작할 수 있습니다. 다만 화면에 현재 사용 중인 타일을 즉시 제거하면 프레임 중간에 텍스처가 사라질 수 있으므로 visible, loading, pinned 상태의 타일은 eviction 대상에서 제외해야 합니다.
public sealed class TileCache
{
private readonly Dictionary<TileKey, Texture2D> items = new();
private readonly Dictionary<TileKey, int> lastUsedFrame = new();
public bool TryGet(TileKey key, out Texture2D texture)
{
if (items.TryGetValue(key, out texture))
{
lastUsedFrame[key] = Time.frameCount;
return true;
}
return false;
}
public void Put(TileKey key, Texture2D texture)
{
items[key] = texture;
lastUsedFrame[key] = Time.frameCount;
}
public void Trim(int capacity, ISet<TileKey> pinned)
{
while (items.Count > capacity)
{
TileKey candidate = default;
int oldestFrame = int.MaxValue;
bool found = false;
foreach (var pair in items)
{
if (pinned.Contains(pair.Key))
continue;
if (lastUsedFrame[pair.Key] < oldestFrame)
{
candidate = pair.Key;
oldestFrame = lastUsedFrame[pair.Key];
found = true;
}
}
if (!found)
break;
UnityEngine.Object.Destroy(items[candidate]);
items.Remove(candidate);
lastUsedFrame.Remove(candidate);
}
}
}
프로덕션에서는 Dictionary를 순회하는 대신 LinkedList와 노드 맵을 조합한 O(1) LRU 구조를 고려할 수 있습니다. 또한 캐시 용량을 타일 개수만으로 제한하지 말고 다음처럼 바이트 예산으로 관리하는 편이 정확합니다.
- CPU 이미지 버퍼 예산
- GPU 텍스처 예산
- 압축 텍스처와 비압축 텍스처의 구분
- 현재 줌과 인접 줌의 최소 보장 용량
예를 들어 512×512 RGBA32 텍스처는 약 1MiB이므로 GPU 캐시 64MiB라면 밉맵과 여분 버퍼를 고려해 실제 용량을 50개 이하로 잡을 수 있습니다. 플랫폼별 그래픽 메모리 상황이 다르므로 고정된 타일 개수보다 예산 기반 정책이 이식성이 높습니다.
비동기 로딩과 요청 우선순위
타일 스트리밍에서 병목은 디스크 읽기보다 이미지 디코딩, 텍스처 생성, GPU 업로드일 때가 많습니다. Task로 파일을 읽더라도 Texture2D.LoadImage와 같은 작업이 메인 스레드를 오래 점유할 수 있으므로 단계별 비용을 측정해야 합니다.
요청 큐에는 다음 우선순위를 적용할 수 있습니다.
- 현재 줌에서 화면 중심에 가까운 타일
- 현재 줌에서 화면 안에 있지만 중심에서 먼 타일
- 현재 줌의 프리패치 타일
- 한 단계 낮은 줌의 대체 타일
- 한 단계 높은 줌의 선명도 보강 타일
줌을 빠르게 변경하면 이전 요청이 화면에 늦게 도착할 수 있습니다. 요청마다 세대 번호를 부여하고 완료 시점에 최신 세대인지 확인하면 오래된 결과가 새 화면을 덮어쓰는 문제를 막을 수 있습니다.
public sealed class TileRequestGate
{
private int generation;
private readonly Dictionary<TileKey, int> requested = new();
public int BeginViewportUpdate()
{
requested.Clear();
return ++generation;
}
public bool ShouldApply(TileKey key, int requestGeneration)
{
return requested.TryGetValue(key, out int current) &&
current == requestGeneration &&
requestGeneration == generation;
}
public void MarkRequested(TileKey key, int requestGeneration)
{
requested[key] = requestGeneration;
}
}
여기서 모든 오래된 작업을 강제로 취소할 필요는 없습니다. 취소 비용이 크거나 파일 읽기 API가 취소를 제대로 지원하지 않는 경우에는 작업을 끝까지 실행하되 결과 적용만 무시하는 방식도 가능합니다. 단, 디코딩된 이미지가 큰 경우에는 취소와 임시 버퍼 해제를 함께 설계해야 메모리 피크를 줄일 수 있습니다.
타일 경계의 검은 선과 샘플링 문제
인접 타일을 정확히 붙였는데도 경계에 한 줄의 검은색 또는 다른 색이 보이는 경우가 있습니다. 원인은 UV가 타일 끝을 넘어가면서 텍스처의 가장자리 바깥을 샘플링하거나 밉맵 생성 과정에서 다른 타일의 색이 섞이는 데 있습니다.
대표적인 대응책은 세 가지입니다.
- 타일 가장자리에 1~2픽셀의 gutter를 넣습니다.
- UV를 타일 내부 영역으로 아주 조금 안쪽으로 clamp합니다.
- 아틀라스에 합칠 때 타일의 가장자리 픽셀을 확장 복제합니다.
Point 필터링은 경계를 선명하게 유지하지만 확대 시 계단 현상이 커집니다. Bilinear 필터링은 자연스럽지만 gutter가 없으면 경계 색이 섞입니다. 미니맵이 축소 중심이라면 밉맵 품질과 가장자리 패딩을 함께 검증해야 합니다.
Unity와 Unreal에서의 구현 차이
Unity에서는 Texture2D, RawImage, RenderTexture, Addressables 또는 자체 파일 캐시를 조합하는 방식이 일반적입니다. UI에 타일을 개별 RawImage로 배치하면 관리가 쉽지만 타일 수가 많아질수록 Canvas rebuild와 드로우콜을 확인해야 합니다. 타일을 하나의 아틀라스 또는 커스텀 셰이더로 그리면 렌더링 비용을 줄일 여지가 있습니다.
Unreal Engine에서는 UTexture2D, UTexture2DDynamic, 비동기 로딩 시스템, Slate 또는 UMG 위젯을 조합할 수 있습니다. 월드 좌표가 FVector로 관리되더라도 타일 인덱스는 FIntPoint 또는 명시적인 정수 구조체로 분리하는 편이 좋습니다. 매우 큰 월드에서는 World Partition의 셀 스트리밍과 미니맵 타일 스트리밍을 같은 시스템으로 취급하지 않아야 합니다. 월드 셀은 게임플레이 오브젝트의 수명과 관련되고 미니맵 타일은 화면 표현 데이터의 수명과 관련되기 때문입니다.
공통으로 유지해야 할 인터페이스는 다음처럼 작게 만들 수 있습니다.
WorldPosition -> TileKey
TileKey -> Cache/Load
TileImage -> RenderSlot
ViewportChanged -> DesiredTileSet 재계산
렌더러와 파일 포맷을 분리하면 Unity의 Texture2D와 Unreal의 UTexture2D 차이가 스트리밍 정책까지 번지는 것을 막을 수 있습니다.
구현 절차: 1단계부터 검증하기
1단계. 좌표 규칙과 타일 키를 먼저 고정한다
월드 원점, minX/minZ, 축 방향, Y 반전 여부, 타일 포맷, 줌 범위를 문서와 테스트 코드로 고정합니다. 특히 월드 경계의 네 모서리와 중심점이 예상한 타일 키로 변환되는지 검증해야 합니다.
테스트할 경계값은 다음과 같습니다.
u = 0,u = 1인 월드 경계- 음수 월드 좌표
- 타일 경계 바로 앞과 바로 뒤의 좌표
- 최대 줌의 마지막 타일
worldWidth와worldHeight가 다른 직사각형 월드
2단계. 고정된 뷰포트에서 요청 집합을 검증한다
플레이어를 움직이지 않고 줌만 바꾸면서 필요한 타일 집합을 출력합니다. 현재 줌이 바뀔 때 이전 줌의 타일이 즉시 사라지지 않고 대체 타일로 남아 있는지도 확인합니다.
프리패치 여백은 이동 속도와 로딩 시간으로 결정할 수 있습니다. 예를 들어 플레이어가 초당 20m를 이동하고 타일 로딩에 최악의 경우 0.5초가 걸린다면 최소 10m 이상에 해당하는 여백이 필요합니다. 실제로는 디코딩과 GPU 업로드 시간을 포함해 여유를 둬야 합니다.
3단계. 이동·줌·회전·해상도 변경을 함께 부하 테스트한다
다음 조합에서 프레임 타임, GC 할당, GPU 메모리, 요청 완료 지연을 기록합니다.
- 월드 경계를 따라 빠르게 이동
- 최대 줌과 최소 줌을 반복 전환
- 미니맵을 회전시키며 이동
- 네트워크 지연 또는 디스크 지연을 인위적으로 추가
- 캐시 용량을 낮춰 eviction을 자주 발생
좌표 오차는 화면 스크린샷만으로 놓치기 쉽습니다. 같은 월드 위치를 이동 전후에 다시 투영하고 tileKey와 타일 내부 오프셋을 비교하는 자동 테스트를 추가하는 편이 좋습니다. 이동을 여러 번 반복한 뒤 오프셋이 계속 증가한다면 누적 상태를 어딘가에서 다시 사용하고 있다는 신호입니다.
자주 묻는 질문 (FAQ)
미니맵 좌표를 처음부터 float으로 계산하면 안 되나요?
작은 월드에서는 가능합니다. 하지만 월드 좌표가 커질수록 작은 이동량이 반올림될 수 있으므로 월드에서 타일로 변환하는 구간은 double, 타일 인덱스는 int, 타일 내부 렌더링 좌표는 작은 범위의 float으로 나누는 구성이 안전합니다.
모든 줌 레벨의 타일을 한 번에 캐시해야 하나요?
그럴 필요는 없습니다. 현재 줌을 우선 보관하고 한 단계 낮은 줌을 대체 화면용으로 유지한 뒤, 인접한 높은 줌은 프리패치 용량으로 제한하는 방식이 일반적입니다. 메모리 부족이 발생하면 높은 줌의 오래된 타일부터 제거할 수 있습니다.
줌 전환 순간 타일이 비어 보이는 현상은 어떻게 줄이나요?
새 줌의 타일이 준비될 때까지 이전 줌의 타일을 저해상도 대체 화면으로 유지합니다. 이후 새 타일이 준비되면 페이드 또는 한 프레임 단위 교체로 전환합니다. 대체 화면을 별도 타일로 준비하지 않고 현재 텍스처를 확대하면 선명도는 낮아지지만 요청 수와 메모리는 줄어듭니다.
마무리
구현을 시작할 때는 타일 이미지 포맷보다 좌표 기준과 캐시 키를 먼저 확정하는 편이 좋습니다. double 기반 원본 좌표에서 매번 zoom/x/y를 재계산하고 화면에는 타일 내부의 작은 로컬 좌표만 전달하세요. 그 위에 현재 줌 우선순위, 인접 줌 대체 화면, 바이트 예산 LRU, 요청 세대 검사를 추가하면 대규모 월드에서도 스트리밍 품질을 단계적으로 조정할 수 있습니다.



