저자: 멍청한 아구몬(傻头傻脑亚古兽)
기술 배경
Virtual Texture(가상 텍스처)는 실시간 렌더링에서 다양한 제약으로 인해 초고해상도 텍스처를 직접 표시할 수 없는 문제를 해결하기 위해 주로 사용됩니다. 메모리 부족일 수도 있고, 플랫폼이나 그래픽 API 등의 제한일 수도 있습니다. 일반적으로 초대형 텍스처는 화면에 한 번에 모두 렌더링되지 않으며, 원경은 대부분 저해상도 Mipmap으로 렌더링됩니다.
VT의 핵심 아이디어는 이 초대형 텍스처를 여러 블록(Page)으로 분할하는 것입니다. 렌더링 시 화면에 필요한 Page만 물리 캐시에 로드하거나 렌더링합니다. 실제 사용할 때는 간접 리스트(PageTable)를 통해 해당 Page를 찾아옵니다.
VT에는 다음과 같은 여러 확장 기술이 있습니다.
Streaming Virtual Texture(SVT)
텍스처가 모델 텍스처 등 기존 에셋인 경우 사용합니다. 오프라인에서 미리 Page를 분할해야 하며, 런타임에는 전체 대형 텍스처를 로드할 필요 없이 필요한 Page만 로드하여 메모리를 절약합니다.

Runtime Virtual Texture(RVT):
텍스처가 실시간으로 생성되는 경우 사용합니다. 런타임에 필요한 Page만 렌더링하며, 전형적인 예가 지형 렌더링입니다. 지형 렌더링은 여러 텍스처 레이어를 혼합해야 해서 비용이 큰데, RVT를 통해 혼합 결과를 캐싱하면 성능을 절약할 수 있습니다.

Adaptive Virtual Texture(AVT):
고정밀 VT를 추구할 때 Indirection Texture가 지나치게 커지는 문제를 해결합니다. 표준 RVT에 Sectors 개념을 도입하여, Virtual Texture를 여러 Sector로 나누고 카메라 거리에 따라 Sector 크기를 동적으로 조절합니다. 각 Sector는 고정 크기의 Indirection Texture를 사용해 물리 페이지를 인덱싱합니다. 즉, 한 단계 더 변환 과정을 거치는 구조입니다.

Clipmap Texture:
화면 리드백 방식이 아니라 카메라 정보를 사용해 렌더링할 Page를 수집합니다. GPU 리드백의 느린 속도와 비동기 리드백으로 인한 지연 문제를 피할 수 있습니다. 특정 영역의 정밀도를 지정하여 Indirection Texture가 너무 커지는 문제도 해결합니다. 저사양 기기에서 사용하기 더 적합하며, 사실상 Cascade Shadowmap과 유사합니다.

이처럼 다양한 VT 기법들은 전체적인 흐름 면에서 대체로 비슷합니다.
지면 관계상 이 글에서는 지형을 렌더링하기 위한 표준 RVT와 Clipmap Texture의 구현을 중심으로 소개합니다.

Virtual Texture Terrain

VT 흐름
VT의 주요 파이프라인은 거의 대동소이합니다.
1. Virtual Texture Page 분할
거대한 가상 텍스처 전체를 각 Mipmap 단계마다 균등하게 여러 Virtual Texture Page로 나눕니다.
상위 Mip일수록 Page 수는 적어지고, 하나의 Page가 대응하는 Virtual Texture의 픽셀 영역은 커집니다.
예를 들어 단일 Page의 해상도가 Mip0은 128 128, Mip1은 256 256인 식입니다.

VT Mipmap Pages
2. 가시 Virtual Texture Page 수집
현재 필요한 VT Page를 수집하는 것이 목적으로, 크게 CPU 방식과 GPU 방식으로 나뉩니다.
CPU 방식: 카메라 위치를 기준으로 필요한 VT Page의 Mipmap을 추정합니다. Clipmap Texture가 사용하는 방식입니다.
장점: 빠르고 지연이 없습니다.
단점: VT Page의 오클루전 컬링(Occlusion Culling)이 불가능하며, 정확한 Mip 정보를 얻기 어렵습니다.

GPU 방식: 화면에 Feedback Pass를 렌더링하여 지정된 버퍼에 Page와 Mip 정보를 출력한 뒤, 비동기 리드백(Async Readback) 방식으로 이를 CPU로 전달해 처리합니다. 보통 리드백 속도를 높이기 위해 버퍼를 다운샘플링합니다.
장점: 시야에 보이는 VT Page를 꽤 정확하게 수집할 수 있으며 오클루전 컬링의 이점을 얻습니다.
단점: 별도의 Feedback Pass를 렌더링해야 하고, 비동기 리드백이 비교적 느려 지연이 발생합니다. 또한 다운샘플링으로 인한 정보 손실 문제도 해결해야 합니다.

Feedback
3. Physical Texture 업데이트
필요한 Virtual Texture Page 정보를 얻고 나면, Physical Texture 내에 한 영역(Physical Page)을 할당받아 그에 해당하는 Virtual Texture Page를 렌더링해 생성할 수 있습니다. 보통 이 물리 페이지들은 LRU(Least Recently Used) 양방향 순환 연결 리스트를 사용해 관리합니다. 자주 보이는 Page는 리스트의 끝에 두고, 새로 할당할 때는 되도록 리스트의 앞쪽에 있는 Physical Page를 가져와 순환하며 재사용합니다.

Physical Page(LRU)
그 후 Physical Texture의 Physical Page 영역에, 다양한 지형 Splat Map을 혼합하여 대응하는 Virtual Page의 Virtual Texture 영역을 렌더링합니다.

4. Indirect Texture(PageTable) 업데이트
물리 페이지를 그리고 나면, 해당 Physical Page가 Physical Texture의 어느 위치에 있는지 Indirection Texture에 기록해야 합니다. Indirection Texture의 각 텍셀은 하나의 Page에 대한 주소 변환 정보를 나타냅니다. 이렇게 하면 실제로 사용할 때 Indirect Texture를 샘플링하여 실제 Physical Page를 찾을 수 있습니다.

RVT 구현
1. 초기화
Indirection Texture:
먼저 VT를 여러 Virtual Page로 나누어야 하며, 각 Page는 Indirection Texture의 한 텍셀에 대응합니다. Mip0의 Page 크기를 128픽셀로 잡고 Indirection Texture 크기를 1024(즉 1024개의 Page)로 가정하면, VT의 전체 해상도는 1024 * 128이 됩니다. 이 데이터를 바탕으로 Indirection Texture를 초기화합니다.

Indirection Texture는 Mipmap을 가지고 있으므로 Mipmap의 Page 데이터도 초기화해야 합니다. 각 Mip 단계마다 대응하는 해상도의 Page가 있으며, 상위 Mip으로 갈수록 Page 수는 줄어들고 단일 Page가 덮는 VT 영역은 넓어집니다.

VT Page의 ID는 Morton 코드(Z-order curve)로 인코딩합니다. 이를 통해 Page를 "Z"자 형태로 정렬할 수 있으며, 이후 다른 Mip에서 대응하는 위치를 빠르게 찾을 때 유용합니다.

Z형 인코딩
public static class MortonCode
{
public static int MortonCode2(int x)
{
x &= 0x0000ffff;
x = (x ^ (x << 8)) & 0x00ff00ff;
x = (x ^ (x << 4)) & 0x0f0f0f0f;
x = (x ^ (x << 2)) & 0x33333333;
x = (x ^ (x << 1)) & 0x55555555;
return x;
}
// Encodes two 16-bit integers into one 32-bit morton code
public static int MortonEncode(int x, int y)
{
int Morton = MortonCode2(x) | (MortonCode2(y) << 1);
return Morton;
}
public static int ReverseMortonCode2(int x)
{
x &= 0x55555555;
x = (x ^ (x >> 1)) & 0x33333333;
x = (x ^ (x >> 2)) & 0x0f0f0f0f;
x = (x ^ (x >> 4)) & 0x00ff00ff;
x = (x ^ (x >> 8)) & 0x0000ffff;
return x;
}
public static void MortonDecode(int Morton, out int x, out int y)
{
x = ReverseMortonCode2(Morton);
y = ReverseMortonCode2(Morton >> 1);
}
}
Physical Texture:
물리 텍스처는 실제 Page 데이터를 저장하는 곳으로 크기는 직접 지정합니다. 일반적으로 지형 렌더링 시 Physical Texture는 4k ~ 8k 정도의 해상도를 가지며 두 장(베이스 컬러, 노멀 맵)을 씁니다. 본 코드에서는 물리 페이지를 Tile로 표현하며, Tile의 갯수는 물리 페이지 해상도 / 단일 페이지 해상도가 됩니다. Tile 역시 위에서 언급한 LRU 방식으로 관리됩니다.
본문의 Physical Texture는 TextureArray(텍스처 배열) 포맷을 사용합니다. 아주 작은 Page 하나를 업데이트할 때 전체 RT가 Load/Store되는 것을 막고, 실시간 압축 효율을 높이기 위함입니다(압축 관련 내용은 후술).
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Experimental.Rendering;
public class VTPhysicalTable
{
public class Tile
{
public Vector4 rect;
public VTPage cachePage;
public byte depth;
public Tile prev;
public Tile next;
public bool SetCachePage(VTPage page, out VTPage oldPage)
{
if (cachePage != null && cachePage.alwayInCache)
{
oldPage = null;
return false;
}
else
{
oldPage = ClearCache();
cachePage = page;
return true;
}
}
public VTPage ClearCache()
{
if (cachePage != null && !cachePage.alwayInCache)
{
var oldPage = cachePage;
cachePage.loadFlag = VTPage.FLAG_NONE;
cachePage.physTile = null;
cachePage = null;
return oldPage;
}
else
{
return null;
}
}
}
public int realWidth;
public int realHeight;
int tileCountX;
int tileCountY;
byte depth;
public RenderTexture[] rts;
public int[] matPass;
public Tile lruFirst;
public Tile lruLast;
void AddLRU(Tile tile)
{
if (lruFirst == null)
{
lruFirst = tile;
lruLast = tile;
}
else
{
var last = lruLast;
tile.prev = last;
last.next = tile;
lruLast = tile;
}
}
void RemoveLRU(Tile tile)
{
if (lruFirst == tile && lruLast == tile)
{
lruFirst = null;
lruLast = null;
}
else if (lruFirst == tile)
{
lruFirst = tile.next;
}
else if (lruLast == tile)
{
lruLast = lruLast.prev;
}
else
{
tile.prev.next = tile.next;
tile.next.prev = tile.prev;
tile.prev = null;
tile.next = null;
}
}
public VTPhysicalTable(Vector2Int size, byte depth, int pageWidth, int rtCount = 1, GraphicsFormat rtFormat = GraphicsFormat.R8G8B8A8_UNorm)
{
tileCountX = Mathf.CeilToInt(size.x / (float)pageWidth);
tileCountY = Mathf.CeilToInt(size.y / (float)pageWidth);
realWidth = tileCountX * pageWidth;
realHeight = tileCountY * pageWidth;
this.depth = depth;
for (int z = 0; z < depth; z++)
{
for (int y = 0; y < tileCountX; y++)
{
for (int x = 0; x < tileCountY; x++)
{
var t = new Tile();
t.rect = new Vector4(x * pageWidth, y * pageWidth, pageWidth, pageWidth);
t.depth = (byte)z;
AddLRU(t);
}
}
}
CreateRenderTexture(rtCount, rtFormat);
}
public virtual void CreateRenderTexture(int rtCount, GraphicsFormat rtFormat)
{
rts = new RenderTexture[rtCount];
matPass = new int[rtCount];
for (int i = 0; i < rtCount; i++)
{
var rt = new RenderTexture(realWidth, realHeight, 0, rtFormat);
rt.dimension = UnityEngine.Rendering.TextureDimension.Tex2DArray;
rt.volumeDepth = depth;
rt.filterMode = FilterMode.Bilinear;
rt.name = "VTPhysicalMap" + i;
rt.Create();
rts[i] = rt;
}
}
/// <summary>
/// 최근 사용 처리
/// </summary>
public void MarkRecently(Tile tile)
{
RemoveLRU(tile);
AddLRU(tile);
}
/// <summary>
/// 타일 할당
/// </summary>
public void AllocAddress(VTPage page, out VTPage oldPage)
{
oldPage = null;
var tile = lruFirst;
while (tile != null)
{
if (tile.SetCachePage(page, out oldPage))
{
page.physTile = tile;
MarkRecently(tile);
return;
}
tile = tile.next;
}
#if UNITY_EDITOR
Debug.LogError("물리 텍스처 공간이 부족하여 렌더링 오류가 발생할 수 있습니다.");
#endif
}
}
2. Feedback
씬에서 사용되는 VT Page를 수집해야 합니다. Feedback 메커니즘은 GPU에서 Page ID와 Mipmap을 계산해 Feedback Buffer에 출력한 후, CPU에서 비동기 리드백을 통해 이 버퍼를 파싱하여 Page 정보를 얻는 것입니다.
Feedback Buffer
버퍼 포맷은 32비트 RGBA8을 사용합니다. 일반적인 VT 분할 Page는 1024나 2048 크기이므로 PageID의 XY 좌표는 256을 초과합니다. 따라서 8비트로는 부족하기에 보통 B 채널의 8비트를 쪼개어 R과 G에 더해 PageID의 XY를 각각 12비트로 표현합니다. 그리고 A 채널 8비트에는 mipLevel을 저장합니다.
| R8 | G8 | B8 | A8 |
| PageID X (하위 8비트) | PageID Y (하위 8비트) | 4비트 PageID X(상위 4비트) 4비트 PageID Y(상위 4비트) | mipLevel |
이러한 Feedback Buffer를 구성한 뒤, 데이터를 버퍼에 출력해야 합니다.
전통적인 방법 중 하나는 Feedback Buffer를 Gbuffer의 렌더 타겟 중 하나로 삼고 MRT(Multi Render Target) 기능을 이용해 출력하는 것입니다.
반면 UE(언리얼 엔진)의 경우 PS에서 직접 UAV Buffer에 출력합니다. 이 버퍼 크기는 화면 해상도보다 작게 축소되어 있는데, 주로 리드백 속도를 높이기 위함입니다. 해상도가 줄어들면 정보 손실이 발생하므로 다중 프레임 간 JitterOffset 랜덤 오프셋 메커니즘을 이용해 손실된 정보를 보완하는 방식을 사용합니다.

UE Feedback
본문에서는 코드 침투성을 줄이고 시연을 용이하게 하기 위해, UE나 MRT 방식과 달리 지형에 대한 Feedback Pass를 한 번 더 렌더링하여 Feed Buffer에 담았습니다. 실제 프로젝트에서는 이렇게 처리하는 것을 권장하지 않습니다. 지형을 추가로 렌더링해야 할 뿐만 아니라 씬 객체에 의한 오클루전 정보가 없어 지형 자체의 오클루전만 반영되므로 불필요한 Page가 수집될 수 있기 때문입니다.
Feedback Pass
본문의 Feedback Pass 셰이더 코드는 다음과 같습니다. UV * PageCount로 해당 PageID를 구하고, DDX와 DDY를 통해 MipLevel을 계산합니다.

Page 정보 출력

MipLevel 계산

좌: Feedback Buffer
Feedback 다운샘플링
주의할 점은 Feedback Pass를 원본 화면 해상도로 렌더링해야 한다는 것입니다. MipLevel이 DDX, DDY로 계산되기 때문에 저해상도 버퍼로 바로 렌더링하면 해상도가 달라져 Mip 계산이 틀려집니다.
UE처럼 원본 해상도로 렌더링한 뒤 저해상도 UAV Buffer에 출력한다면 이 문제는 발생하지 않지만, 여기서는 그렇지 않으므로 저해상도 RenderTarget으로 다운샘플링을 한 번 수행해야 합니다.
여기서 사용한 다운샘플링 방법은 원본 Feedback Buffer에서 원반(Disk) 형태로 랜덤 샘플링을 하여 데이터 손실을 막는 것입니다. 매 프레임 난수를 변경하여 여러 프레임이 누적되면 원본 데이터에 근접하도록 합니다.


다운샘플링 후
3. 가시 Page 수집
Feedback Buffer가 준비되었으므로 비동기 리드백 방식으로 데이터를 가져옵니다. Unity에서는 CommandBuffer.RequestAsyncReadback API를 사용합니다. (여기서는 FrameDebugger로 디버깅하기 편하도록 동기 리드백 스위치를 추가했습니다.)

리드백에 성공하면 Feedback Buffer의 모든 픽셀을 순회하며 Page와 MipLevel을 해석해 처리 목록에 추가합니다. 처리 목록은 Mip 단계와 출현 횟수를 기준으로 정렬하며 프로젝트 니즈에 따라 우선 처리 전략을 조정할 수 있습니다.
Dictionary<VTPage, int> pageReadPixelCounter = new Dictionary<VTPage, int>();
private void OnReadback(AsyncGPUReadbackRequest obj)
{
if (!obj.hasError && readyReadback)
{
ClearWaitList();
var colors = obj.GetData<Color32>();
for (int i = 0; i < colors.Length; i++)
{
var color = colors[i];
UnpackPage(color.r, color.g, color.b, out int pageIndexX, out int pageIndexY);
int pageMip = (int)color.a;
// 무효 픽셀 건너뛰기
if (pageMip < 0 || pageIndexX < 0 || pageIndexY < 0 ||
pageIndexX >= indirectTable.texWidth || pageIndexY >= indirectTable.texHeight ||
pageMip > maxMip)
continue;
var page = indirectTable.GetPage(pageIndexX, pageIndexY, pageMip);
if (pageReadPixelCounter.TryGetValue(page, out var count)) // Page 출현 횟수 기록
pageReadPixelCounter[page]++;
else
pageReadPixelCounter[page] = 0;
if (page.loadFlag == VTPage.FLAG_LOADED)
{
physicalTable.MarkRecently(page.physTile); // 이미 로드되었으면 LRU 순서 갱신
}
else if (page.loadFlag == VTPage.FLAG_NONE)
{
page.loadFlag = VTPage.FLAG_LOADING;
waitLoadPageList.Add(page);
}
}
waitLoadPageList.Sort(ReadbackSortComparer);
pageReadPixelCounter.Clear();
}
}
private int ReadbackSortComparer(VTPage x, VTPage y)
{
if (y.mip != x.mip)
return y.mip.CompareTo(x.mip); // 높은 Mip 우선
return pageReadPixelCounter[y].CompareTo(pageReadPixelCounter[x]); // 픽셀이 많은 순
}
/// <summary>
/// Page Buffer Color 언팩
/// </summary>
void UnpackPage(byte x, byte y, byte z, out int X, out int Y)
{
uint ix = x;
uint iy = y;
uint iz = z;
// 8 bit in lo, 4 bit in hi
uint hi = iz >> 4;
uint lo = iz & 15;
X = (int)(ix | (lo << 8));
Y = (int)(iy | (hi << 8));
}
4. 물리 페이지 업데이트
가시 Page를 확보했으므로 해당 Page들을 그려야 합니다. 단일 프레임에 생성되는 Page가 너무 많아지는 것을 방지하기 위해 프레임당 최대 렌더링 수를 제한합니다.
VTPage를 사용해 Physical Texture의 Tile을 요청할 때, 해당 Tile에 이미 내용이 저장되어 있다면 이론적으로 기존 내용은 더 이상 필요하지 않은 것입니다. 하지만 무작정 덮어써서는 안 됩니다. Feedback은 비동기적이며 지연되기 때문에, 현재 프레임에서 지워진 위치를 여전히 렌더링하고 있을 수 있어 렌더링 오류가 발생할 수 있기 때문입니다. 이를 방지하려면 Indirection Texture의 기존 Page를 다른 Mip에 매핑한 뒤에 물리 페이지 내용을 덮어써야 합니다.
private List<VTPage> remapPages = new List<VTPage>();
void CollectLoadPage(bool limit = true)
{
for (int i = 0; i < physicalUpdateList.Length; i++)
physicalUpdateList[i].Clear();
int curRenderSize = 0;
// Page 분할 생성
{
for (int i = 0; i < waitLoadPageList.Count; i++)
{
var page = waitLoadPageList[i];
curRenderSize += pageWidth;
if (curRenderSize > maxRenderSize && !forceUpdate && limit) // 성능 제한에 따른 분할 처리
{
break;
}
else
{
physicalTable.AllocAddress(page, out var oldPage);
page.loadFlag = VTPage.FLAG_LOADED;
physicalUpdateList[page.physTile.depth].Add(page);
if (oldPage != null) // 예전 Page 대체
{
remapPages.Add(oldPage);
}
}
}
}
for (int i = 0; i < remapPages.Count; i++)
{
RemapSubPageIndirect(remapPages[i]);
}
remapPages.Clear();
}
실제로 물리 페이지를 업데이트할 때는 Instance(인스턴싱) 렌더링을 활용해 정면 쿼드(Quad)를 그려 Physical Texture의 해당 VT 영역을 렌더링합니다.
void UpdatePhysicalTexture()
{
if (compress)
InitCompress();
// 데이터 렌더링
int renderCount = 0;
if (renderPageTBS == null)
{
renderPageTBS = new Matrix4x4[MAX_RENDER_BATCH];
for (int i = 0; i < renderPageTBS.Length; i++)
renderPageTBS[i] = Matrix4x4.identity;
renderPageData = new Vector4[MAX_RENDER_BATCH];
}
float padding = border;
float scalePadding = (pageWidth + padding * 2f) / pageWidth;
float offsetPadding = padding / (pageWidth + padding * 2f);
// 물리 텍스처에 그리기
cmd.BeginSample(profilerTag);
var rtArray = physicalTable.rts;
float pScaleX = pageWidth / (float)physicalTable.realWidth;
float pScaleY = pageWidth / (float)physicalTable.realHeight;
cmd.SetViewProjectionMatrices(Matrix4x4.identity, Matrix4x4.identity);
for (int r = 0; r < rtArray.Length; r++) // rt (baseColor, normal)
{
var rt = physicalTable.rts[r];
int pass = physicalTable.matPass[r];
for (int t = 0; t < physicalUpdateList.Length; t++) // Texture array index
{
var list = physicalUpdateList[t];
if (list.Count == 0)
continue;
RenderTargetIdentifier depthIdentifier = new RenderTargetIdentifier(rt, 0, CubemapFace.Unknown, t);
cmd.SetRenderTarget(depthIdentifier, RenderBufferLoadAction.Load, RenderBufferStoreAction.Store, RenderBufferLoadAction.DontCare, RenderBufferStoreAction.DontCare);
for (int i = 0, len = list.Count, lastIndex = list.Count - 1; i < len; i++) // pages per texture array slice
{
var page = list[i];
var mipmap = indirectTable.mips[page.mip];
float scaleX = (mipmap.virtualWidth) / (float)virtualTextureSize.x;
float scaleY = (mipmap.virtualWidth) / (float)virtualTextureSize.y;
AddIndirectUpatePage(page, page.mip);
UpdateSubPageIndirect(page); // 서브 페이지 업데이트
/* Page UV를 VT UV로 변환
(uv * pageMipSize + pageStart) / virtualTextureSize
pageStart = pageMipIndex * pageMipSize
*/
MortonCode.MortonDecode(page.mortonID, out var localIndexX, out var localIndexY);
float biasX = localIndexX * scaleX;
float baseY = localIndexY * scaleY;
// (uv - offsetPadding) * scalePadding * S + B
// uv * scalePadding * S - offsetPadding * scalePadding * S + B
float vScaleX = scaleX * scalePadding;
float vScaleY = scaleY * scalePadding;
float vPosX = -offsetPadding * scalePadding * scaleX + biasX;
float vPosY = -offsetPadding * scalePadding * scaleY + baseY;
renderPageData[renderCount] = new Vector4(vScaleX, vScaleY, vPosX, vPosY);
// 물리 페이지 행렬
float pPosX = page.physTile.rect.x / (float)physicalTable.realWidth + pScaleX * 0.5f;
float pPosY = page.physTile.rect.y / (float)physicalTable.realWidth + pScaleY * 0.5f;
pPosX = pPosX * 2f - 1f; // [0,1] => [-1,1]
pPosY = pPosY * 2f - 1f;
ref var tbs = ref renderPageTBS[renderCount];
tbs.m03 = pPosX;
tbs.m13 = pPosY;
tbs.m00 = pScaleX;
tbs.m11 = pScaleY;
renderCount++;
if ((renderCount == MAX_RENDER_BATCH || i == lastIndex) && renderCount > 0)
{
block.SetVectorArray(_VirtualTextureUVTransform, renderPageData);
cmd.DrawMeshInstanced(RenderingUtils.fullscreenMesh, 0, renderPageMat, pass, renderPageTBS, renderCount, block);
renderCount = 0;
}
}
if (compress)
gpuCompress.Compress4x4(cmd, rt, r == 0 ? pBaseColorCompreeTex : pNormalCompreeTex, true, t);
}
}
cmd.EndSample(profilerTag);
Graphics.ExecuteCommandBuffer(cmd);
cmd.Clear();
for (int t = 0; t < physicalUpdateList.Length; t++)
{
var list = physicalUpdateList[t];
for (int i = 0; i < list.Count; i++)
tempRemoveList.Add(list[i]);
list.Clear();
}
for (int i = 0; i < tempRemoveList.Count; i++)
{
var p = tempRemoveList[i];
waitLoadPageList.Remove(p);
}
tempRemoveList.Clear();
}
물리 페이지 업데이트 후에는 해당 Page를 Indirection Texture 업데이트 목록에 추가해야 합니다.

해당 Mip의 픽셀을 업데이트하는 것뿐만 아니라, 하위 Mip에 대한 매핑도 함께 처리해야 합니다. 그렇지 않으면 Feedback 리드백 기간 동안 매핑되지 않은 Page(아래 파란 선의 Page)를 렌더링할 때 에러가 발생합니다. 이 문제를 해결하기 위해 렌더링되지 않은 Page를 물리 텍스처에 존재하는 더 상위의 Page 위치에 매핑하는 방식을 사용합니다(아래 그림 참고).

위에서 설명한 모튼 코드를 사용하면 하위 Mip의 Page를 빠르게 찾아 각 Mip의 Page를 순회할 수 있습니다. 이 과정의 오버헤드가 크기 때문에 더 나은 알고리즘이 존재할 수 있습니다.
탐색 속도와 Indirect Texture 업데이트 효율을 높이기 위해, 겹쳐 그리는 방식(Overdraw)을 사용했습니다. 큰 사각형으로 Mip 전체 영역을 덮고, 하위 Mip이 이미 물리 텍스처에 존재한다면 해당 Page 픽셀을 다시 업데이트합니다.
위 그림처럼 3개의 녹색 픽셀을 따로 그리지 않고 하나의 큰 녹색 사각형을 그린 뒤, 그 위에 붉은 사각형을 덮어씌워 최적화합니다.

Physical Page를 그리는 셰이더 코드는 다음과 같습니다.


Physical Texture
5. 간접 테이블 업데이트
Indirection Texture의 포맷은 RGBA32이며 각 채널은 8비트를 가집니다. 구성 정보는 다음과 같습니다.
| R | G | B | A |
| Physical TileID X | Physical TileID Y | MipLevel | TextureArray Index |
위에서 Physical Texture Array를 구현할 때 텍스처 배열을 사용한다고 언급했습니다. 해상도가 1024 1024 8이고 각 Page가 128 * 128이라고 가정하면, TileID XY는 1024 / 128 = 8이 되어 TileID는 [0, 7] 구간에 들어가므로 8비트면 충분합니다.
Indirection Texture를 업데이트하는 과정도 위와 비슷합니다.
void UpdateIndirectTexture()
{
// 간접 테이블 정보 렌더링
int renderCount = 0;
cmd.BeginSample(profilerTag);
cmd.SetViewProjectionMatrices(Matrix4x4.identity, Matrix4x4.identity);
for (int m = 0; m < indirectUpdateList.Length; m++)
{
var list = indirectUpdateList[m];
if (list.Count == 0)
continue;
list.Sort(PageMipComparison); // 상위 Mip을 먼저 그리고, 하위 Mip을 덮어씀
var mipmap = indirectTable.mips[m];
cmd.SetRenderTarget(indirectTable.rt, mipmap.mipLevel);
uint bitMask = ~(1u << m);
for (int i = 0, len = list.Count, lastIndex = list.Count - 1; i < len; i++)
{
var page = list[i];
page.mipMask &= bitMask; // 그려야 할 mip 플래그 제거
{
float phyBlockIndexX = (int)(page.physTile.rect.x / pageWidth) / 255f; // 8bit
float phyBlockIndexY = (int)(page.physTile.rect.y / pageWidth) / 255f; // 8bit
float mip = page.mip / 255f; // 8bit
float phyTexArrayIndex = page.physTile.depth / 255f; // 텍스처 배열 Index
ref var data = ref renderPageData[renderCount];
data.x = phyBlockIndexX;
data.y = phyBlockIndexY;
data.z = mip;
data.w = phyTexArrayIndex;
MortonCode.MortonDecode(page.mortonID, out var localIndexX, out var localIndexY);
float vScaleX, vScaleY, vPosX, vPosY;
if (page.mip != (byte)m) // 상위 Mip으로 덮이는 경우의 처리: 전체 영역을 해당 Mip으로 매핑
{
var pageMip = indirectTable.mips[page.mip];
float w = pageMip.virtualWidth;
vScaleX = w / virtualTextureSize.x;
vScaleY = w / virtualTextureSize.y;
vPosX = (w * localIndexX) / virtualTextureSize.x;
vPosY = (w * localIndexY) / virtualTextureSize.y;
}
else
{
vScaleX = 1f / mipmap.size;
vScaleY = 1f / mipmap.size;
vPosX = localIndexX * vScaleX;
vPosY = localIndexY * vScaleY;
}
float vHalfScaleX = vScaleX * 0.5f;
float vHalfScaleY = vScaleY * 0.5f;
vPosX += vHalfScaleX;
vPosY += vHalfScaleY;
vPosX = vPosX * 2f - 1f; // [0,1] => [-1,1]
vPosY = vPosY * 2f - 1f;
ref var tbs = ref renderPageTBS[renderCount];
tbs.m03 = vPosX;
tbs.m13 = vPosY;
tbs.m00 = vScaleX;
tbs.m11 = vScaleY;
renderCount++;
}
if (renderCount == MAX_RENDER_BATCH || i == lastIndex)
{
block.SetVectorArray(_VirtualTextureUVTransform, renderPageData);
cmd.DrawMeshInstanced(RenderingUtils.fullscreenMesh, 0, renderPageMat, 1, renderPageTBS, renderCount, block);
renderCount = 0;
}
}
list.Clear();
}
cmd.EndSample(profilerTag);
Graphics.ExecuteCommandBuffer(cmd);
cmd.Clear();
}
셰이더는 매우 단순하게 주소 스케일과 매핑 결과를 바로 출력하면 됩니다.


IndirectionTexture Mipmap
6. VT 사용
VT의 사용 방법 자체는 간단하지만, Physical UV 계산 시에는 주의가 필요합니다.
Physical UV = ( VTPageUV TileSize + PhysicalTileIndex TileSize ) / PhysicalTextureSize
VTPageUV는 MipLevel과 VTPage 크기만 있으면 도출할 수 있습니다. 이렇게 하면 기본적으로 RVT가 완성됩니다.
float2 ComputePhysicalUV(float4 physicalData, float2 px)
{
int2 phyBlockInexXY = physicalData.xy;
int pageMip = physicalData.z;
int pageWidth = _VTPhysTexParma.x;
int vtBlockWidth = pageWidth << pageMip;
int2 vtBlockIndex = px / vtBlockWidth;
float2 tileUV = (px - vtBlockIndex * vtBlockWidth) / vtBlockWidth;
tileUV = tileUV * _VTPaddingParam.xy + _VTPaddingParam.zw; // 경계 처리
float2 physicalUV = (tileUV * pageWidth + phyBlockInexXY * pageWidth) * _VTPhysTexParma.y; // _VTPhysTexParma.y: 1.0/PhysicalTextureSize
return physicalUV;
}
void SampleDiffuseVT(float2 positionSS, float2 uv, inout half3 diffuse, inout half3 normal)
{
float2 px = uv * _VTParam.y;
float2 dx = ddx(px);
float2 dy = ddy(px);
float mip, nextMip, mipFrac;
ComputeVTMipData(dx, dy, mip, nextMip, mipFrac);
float4 physicalData = SAMPLE_TEXTURE2D_LOD(_VTIndirectTex, sampler_VTIndirectTex, uv, mip) * 255.5;
float2 physicalUV = ComputePhysicalUV(physicalData, px);
float4 color = SAMPLE_TEXTURE2D_ARRAY_LOD(_VTDiffuse, sampler_VTDiffuse, physicalUV, physicalData.w, 0);
#ifdef _NORMALMAP
normal = SAMPLE_TEXTURE2D_ARRAY_LOD(_VTNormal, sampler_VTNormal, physicalUV, physicalData.w, 0).xyz * 2.0 - 1.0;
#endif
diffuse = color.xyz;
}
결과를 보면 스플랫 맵(SplatMap)을 직접 혼합한 것과 사실상 차이가 없습니다.
IndirectionTexture Mipmap

VT 활성화
SceneView에서 확인해 보면 정밀도가 높은 곳은 카메라와 가깝고 시야 범위(Frustum) 내에 존재하는 부분임을 알 수 있습니다.

SceneView 시점
Clipmap 구현
Clipmap과 RVT의 가장 큰 차이점은 Page를 수집하는 단계입니다.
Indirection Texture 역시 조금 다른데, Mipmap을 사용하지 않고 각 Mip이 동일한 수의 Page로 구성됩니다. 즉, Indirection Texture는 각 Mip 정보를 TextureArray로 저장하며, 각 Mip 단계의 Page 수는 동일합니다.
Clipmap은 카메라 중심일 수도, 주인공 중심일 수도 있고, 원점 대칭이 아니라 카메라 방향으로 쏠린 형태일 수도 있어 무척 유연합니다.

좌: mip2, 우: mip1
1. 데이터 준비
여기서는 Mip 단계를 Layer로 표현했습니다. 보통 6~8개의 Layer면 충분하며, 각 Layer는 8 8 또는 16 16 개의 page를 가집니다.
page 개수가 적어 Indirection Texture는 CPU에서 업데이트하므로 텍스처 컬러 데이터를 담을 Colors 배열을 따로 유지합니다.


2. 가시 Page 롤링 업데이트
Clipmap의 핵심 아이디어 중 하나는 제한된 영역을 표현하기 위해 제한된 수의 그리드(page)를 사용하는 것입니다. 그리드의 수는 고정되어 있으므로 카메라가 이동할 때 위치를 유지하는 그리드도 있고, 새로운 위치로 갱신되어야 하는 그리드도 있습니다. 즉, 그리드를 순환하며 재사용할 수 있습니다.


이는 순환 주소 할당 과정으로, 쓸모없어진 Page를 중심 기준으로 맞은편 새 위치에 반영하고, 이동 후 다시 앵커(Anchor)를 조절하는 작업을 반복합니다(위 그림 참조). 아이디어는 간단하지만, 코드는 다소 복잡합니다.
void CollectDirtyPages()
{
var terrainBounds = terrain.terrainData.bounds;
var postion = Camera.main.transform.position;
var terrainMin = terrain.transform.position; // terrainBounds.min;
var terrainSize = terrainBounds.size;
Vector2 terrainUV = new Vector2(postion.x - terrainMin.x, postion.z - terrainMin.z);
terrainUV.x /= terrainSize.x;
terrainUV.y /= terrainSize.z;
terrainUV.x = Mathf.Clamp01(terrainUV.x);
terrainUV.y = Mathf.Clamp01(terrainUV.y);
bool anyLayerUpdate = false;
for (int i = 0; i < layers.Length; i++)
{
var layer = layers[i];
int blockCount = Mathf.CeilToInt(virtualTextureSize / (float)layer.pixelSize);
var curPos = new Vector2Int((int)(terrainUV.x * blockCount), (int)(terrainUV.y * blockCount));
var layerMinX = Mathf.Clamp(curPos.x - pagePerLayer / 2, 0, blockCount - pagePerLayer);
var layerMinY = Mathf.Clamp(curPos.y - pagePerLayer / 2, 0, blockCount - pagePerLayer);
var preRect = layer.prevRectXY;
if (layerMinX != preRect.x || layerMinY != preRect.y || forceUpdate || !isInit)
{
var pages = layer.pages;
var layerMaxX = layerMinX + pagePerLayer - 1;
var layerMaxY = layerMinY + pagePerLayer - 1;
int offsetX = layerMinX - preRect.x;
int offsetY = layerMinY - preRect.y;
if (forceUpdate || !isInit)
{
offsetX = pagePerLayer;
offsetY = pagePerLayer;
}
offsetX = Mathf.Clamp(offsetX, -pagePerLayer, pagePerLayer);
offsetY = Mathf.Clamp(offsetY, -pagePerLayer, pagePerLayer);
if (offsetX >= 0)
{
for (int x = 0; x < offsetX; x++)
{
for (int y = 0; y < pagePerLayer; y++)
{
int localX = (x + layer.rectOffset.x) % pagePerLayer;
int localY = (y + layer.rectOffset.y) % pagePerLayer;
int pageIndex = localX + localY * pagePerLayer;
var p = pages[pageIndex];
int newLocalX = pagePerLayer - offsetX + x;
p.gx = layerMinX + newLocalX; // 새로운 글로벌 X 좌표
AddLoadPadge(p);
}
}
}
else // offSetX <= 0
{
for (int x = pagePerLayer + offsetX; x < pagePerLayer; x++)
{
for (int y = 0; y < pagePerLayer; y++)
{
int localX = (x + layer.rectOffset.x) % pagePerLayer;
int localY = (y + layer.rectOffset.y) % pagePerLayer;
int pageIndex = localX + localY * pagePerLayer;
var p = pages[pageIndex];
int newLocalX = x - (pagePerLayer + offsetX);
p.gx = layerMinX + newLocalX;
AddLoadPadge(p);
}
}
}
if (offsetY >= 0)
{
for (int y = 0; y < offsetY; y++)
{
for (int x = 0; x < pagePerLayer; x++)
{
int localX = (x + layer.rectOffset.x) % pagePerLayer;
int localY = (y + layer.rectOffset.y) % pagePerLayer;
int pageIndex = localX + localY * pagePerLayer;
var p = pages[pageIndex];
int newLocalY = pagePerLayer - offsetY + y;
p.gy = layerMinY + newLocalY;
AddLoadPadge(p);
}
}
}
else // offSetY <= 0
{
for (int y = pagePerLayer + offsetY; y < pagePerLayer; y++)
{
for (int x = 0; x < pagePerLayer; x++)
{
int localX = (x + layer.rectOffset.x) % pagePerLayer;
int localY = (y + layer.rectOffset.y) % pagePerLayer;
int pageIndex = localX + localY * pagePerLayer;
var p = pages[pageIndex];
int newLocalY = y - (pagePerLayer + offsetY);
p.gy = layerMinY + newLocalY;
AddLoadPadge(p);
}
}
}
if (!isInit)
{
layer.rectOffset.x = 0;
layer.rectOffset.y = 0;
}
else
{
layer.rectOffset.x += offsetX;
layer.rectOffset.y += offsetY;
}
if (layer.rectOffset.x < 0)
layer.rectOffset.x = layer.rectOffset.x % pagePerLayer + pagePerLayer;
if (layer.rectOffset.y < 0)
layer.rectOffset.y = layer.rectOffset.y % pagePerLayer + pagePerLayer;
layer.prevRectXY = new RectInt(layerMinX, layerMinY, layerMaxX, layerMaxY);
float pageMinPX = layerMinX * layer.pixelSize;
float pageMinPY = layerMinY * layer.pixelSize;
float pageMaxPX = (layerMaxX + 1) * layer.pixelSize;
float pageMaxPY = (layerMaxY + 1) * layer.pixelSize;
clipmapLayerRectArray[i] = new Vector4(pageMinPX, pageMinPY, pageMaxPX, pageMaxPY);
float clipXS = 1f / (layer.pixelSize * pagePerLayer);
float clipYS = 1f / (layer.pixelSize);
float clipTX = -layerMinX / (float)pagePerLayer;
float clipTY = -layerMinY / (float)pagePerLayer;
clipmapLayerUVSTArray[i] = new Vector4(clipXS, clipYS, clipTX, clipTY);
if (!anyLayerUpdate)
anyLayerUpdate = true;
}
}
if (anyLayerUpdate || true)
{
var mat = terrain.materialTemplate;
Shader.SetGlobalVectorArray(_CLIPMAP_LAYER_RECT_ARRAY, clipmapLayerRectArray);
Shader.SetGlobalVectorArray(_CLIPMAP_LAYER_UVST_ARRAY, clipmapLayerUVSTArray);
}
if (!isInit)
isInit = true;
}
3. 간접 텍스처 업데이트
위 단계를 통해 업데이트할 Page를 확인한 후, Job을 스케줄링하여 간접 텍스처를 업데이트합니다.
void ScheduleJob()
{
for (int i = 0; i < layers.Length; i++)
{
var layer = layers[i];
if (layer.needUpdate == false)
continue;
var job = new IndirectTexColorJob()
{
colors = layer.colors,
layerRect = layer.prevRectXY,
pagesDatas = layer.pageJobDatas,
pageWidth = pageWidth,
pagePerLayer = pagePerLayer
};
layer.colorJobHandle = job.Schedule(layer.pages.Length, layer.pages.Length);
}
}
struct IndirectTexColorJob : IJobParallelFor
{
[WriteOnly]
public NativeArray<Color32> colors;
[ReadOnly]
public NativeArray<ClipmapPageData> pagesDatas;
public RectInt layerRect;
public int pagePerLayer;
public int pageWidth;
public void Execute(int index)
{
var pageData = pagesDatas[index];
int x = pageData.virturalPageIndex.x - layerRect.x;
int y = pageData.virturalPageIndex.y - layerRect.y;
int pageIndex = y * pagePerLayer + x;
byte phyBlockIndexX = (byte)(pageData.physicalRect.x / pageWidth); // 8bit
byte phyBlockIndexY = (byte)(pageData.physicalRect.y / pageWidth); // 8bit
byte phyTexArrayIndex = pageData.physicalIndex; // 텍스처 배열 Index
colors[pageIndex] = new Color(phyBlockIndexX / 255f, phyBlockIndexY / 255f, 1f, phyTexArrayIndex / 255f);
}
}
Job이 업데이트되는 동안 병렬로 Physical Texture 업데이트를 시작할 수 있습니다. 물리 텍스처 업데이트 과정은 RVT 부분과 거의 같으므로 생략합니다. Physical Texture 업데이트가 완료되면 Job의 데이터를 동기화하여 TextureArray에 채워 넣습니다.

Job 스케줄링

Indirection Texture 업데이트
4. Clipmap Texture 렌더링
샘플링 방법은 매우 단순합니다. 먼저 픽셀이 어떤 Layer에 있는지 확인한 다음, 해당 Layer의 Indirection Texture Index를 샘플링합니다.
#define MAX_LAYER_COUNT 6
float4 _CLIPMAP_LAYER_RECT_ARRAY[MAX_LAYER_COUNT];
float4 _CLIPMAP_LAYER_UVST_ARRAY[MAX_LAYER_COUNT];
void SampleClipmapVT(float2 positionSS, float2 uv, inout half3 diffuse, inout half3 normal)
{
float2 px = uv * _VTParam.y;
int layer = MAX_LAYER_COUNT - 1;
float4 rect;
// 소속 Layer 판단
for (int i = 0; i < MAX_LAYER_COUNT; i++)
{
rect = _CLIPMAP_LAYER_RECT_ARRAY[i];
if (all(px >= rect.xy && px <= rect.zw))
{
layer = i;
break;
}
}
layer = min(layer, _ClipmapParm.y); // _ClipmapParm.y는 실제 Layer 수, 배열 범위 초과 방지
float4 indirectUVST = _CLIPMAP_LAYER_UVST_ARRAY[layer];
float2 indirectUV = px * indirectUVST.xx + indirectUVST.zw;
float4 physicalData = SAMPLE_TEXTURE2D_ARRAY_LOD(_VTIndirectTexArray, sampler_VTIndirectTexArray, indirectUV, layer, 0) * 255.5;
int2 phyBlockInexXY = physicalData.xy;
int pageWidth = _VTPhysTexParma.x;
float2 tileUV = px * indirectUVST.y;
tileUV = tileUV - floor(tileUV); // 소수점 부분(나머지) 구하기
tileUV = tileUV * _VTPaddingParam.xy + _VTPaddingParam.zw; // 패딩 적용
float2 physicalUV = (tileUV * pageWidth + phyBlockInexXY * pageWidth) * _VTPhysTexParma.y;
float4 color = SAMPLE_TEXTURE2D_ARRAY_LOD(_VTDiffuse, sampler_VTDiffuse, physicalUV, physicalData.w, 0);
#ifdef _NORMALMAP
normal = SAMPLE_TEXTURE2D_ARRAY_LOD(_VTNormal, sampler_VTNormal, physicalUV, physicalData.w, 0).xyz * 2.0 - 1.0;
#endif
diffuse = color.xyz;
}
이렇게 하면 Clipmap Texture도 대부분 구현이 마무리됩니다.

Clipmap Texture 지형
텍스처 필터링
1. 이중 선형 보간(Bilinear Interpolation)
Physical Texture에는 여러 Page가 병존하므로 패딩(Padding)이 없는 경계를 샘플링할 때 인접한 Page를 잘못 샘플링하는 문제가 생길 수 있습니다.
해결 방법 중 하나는 Page 외곽에 경계를 추가하는 것입니다(예: 128 크기 Page에 4픽셀 경계를 더해 132로 만듦).
본문에서는 UV 축소/확대를 통한 스케일링 방식을 채택했습니다. (ProbeBaseGI 기법에서도 쓰였던 방식입니다.)
// Page 생성 시 축소
float2 scalePadding = ((_BlitPaddingSize.xy + float(_BlitPaddingSize.z)) / _BlitPaddingSize.xy);
float2 offsetPadding = (float(_BlitPaddingSize.z) / 2.0) / (_BlitPaddingSize.xy + _BlitPaddingSize.z);
uv = (uv - offsetPadding) * scalePadding;
// 샘플링 시 복원(확대)
float2 scalePadding = ((_BlitPaddingSize.xy + float(_BlitPaddingSize.z)) / _BlitPaddingSize.xy);
float2 offsetPadding = (float(_BlitPaddingSize.z) / 2.0) / (_BlitPaddingSize.xy + _BlitPaddingSize.z);
uv = uv / scalePadding + offsetPadding;
2. 삼중 선형 보간(Trilinear Interpolation)
Mipmap 간의 보간을 의미합니다.
Dither 노이즈 샘플링을 이용하면 성능을 절약하면서도 TAA와 결합해 노이즈를 상쇄할 수 있습니다.

효과를 관찰하기 쉽도록 Mipmap 정밀도 차이를 극대화한 모습입니다.

좌: Clipmap 필터 영역 / 우: 노이즈를 활용한 가짜 페이드 효과
혹은 정직하게 두 번 샘플링(mip과 mip+1)하여 VT 보간을 할 수도 있습니다.

3. 비등방성 필터링(Anisotropic Filtering)
TODO
VT 압축
렌더링 효율을 높이기 위해 Physical Texture를 각 플랫폼에 맞는 포맷으로 압축할 수 있습니다. Physical Texture를 TextureArray로 세팅해두면 압축 효율도 더 높고, 업데이트된 해당 레이어만 골라서 압축할 수도 있습니다.
본문에서는 UE 소스에 있는 ETCCompressionCommon.ush와 BCCompressionCommon.ush를 가져와 PS 단에서 압축을 수행했습니다. 이때 포맷이 Uint 형태여야 한다는 점을 주의해야 합니다.



PS에서 압축할 경우 샘플링 중심이 원점이므로, 4x4 압축 시 1.5만큼 빼서 시작점으로 돌아가야 합니다.

압축 후 Renderdoc과 FrameDebugger를 통해 텍스처 포맷이 BC7으로 바뀐 것을 확인할 수 있습니다.


요약

SVT는 거대 텍스처의 메모리 압박 문제를 풀어주고,
RVT는 프로시저럴 생성 텍스처에 캐싱을 제공해줍니다.
전체 시스템을 파이프라인으로 구성하려면 고려해야 할 엔지니어링 세부 사항이 정말로 많습니다.
본 글은 RVT 위주로 구현했습니다. 생각해보면 SVT 역시 이미지를 분할한 뒤 AssetBundle로 묶고, LoadObject("page_"+N) 방식으로 불러오며 관리하는 형태로 구현할 수 있을 것 같습니다.
참고
GPU Pro1: Virtual Texture Mapping
GPU Pro7: Adaptive Virtual Textures
Terrain Rendering in 'Far Cry 5'
Chen Ka AdaptiveVirtualTexture
'TECH.ART.FLOW.IO' 카테고리의 다른 글
| [번역] Unity로 Nanite 구현하기 (0) | 2026.06.11 |
|---|---|
| SDF Tool 1차 릴리스 했습니다. (0) | 2026.06.08 |
| [번역][기술 분석] 대규모 오픈 월드 게임 제작 프로세스 및 기술 탐구 - 대규모 환경 제작 기술 개요 (0) | 2026.06.07 |
| GODOT HDDAGI 가 뭔가요? 읽어보기. (0) | 2026.06.06 |
| NVIDIA DDGI for Unreal Engine 5.7 (0) | 2026.06.04 |