[번역] 유니티 버추얼 텍스처를 구현합니다.
저자
기술적 배경
가상 텍스처는 주로 여러 가지 이유로 인해 고해상도 텍스처를 실시간 렌더링에 직접 표시할 수 없는 문제를 해결하기 위해 사용됩니다. 메모리 제한, 플랫폼 제한 또는 그래픽 API 제한이 있을 수 있습니다. 일반적으로 크기가 큰 텍스처는 화면에 완전히 렌더링되지 않으며, 파스케이프는 저해상도 밉맵을 렌더링할 가능성이 더 높습니다.
VT의 아이디어는 이 매우 큰 텍스처를 여러 블록(페이지)으로 분할하는 것입니다. 렌더링할 때 화면 그리기의 필요에 따라 해당 페이지를 물리적 캐시에 로드하거나 그립니다. 이를 사용할 때 PageTable을 사용하여 페이지를 찾을 수 있습니다.
VT에는 다양한 기술 확장 기능이 있습니다:
스트리밍 가상 텍스처(SVT)
텍스처는 모델 맵 등과 같이 오프라인에서 잘라내야 하는 기존 에셋입니다. 런타임은 큰 텍스처 전체를 로드하지 않고 필요에 따라 페이지를 로드하므로 메모리를 절약할 수 있습니다.
Runtime Virtual Texture(RVT):
텍스처는 실시간으로 생성되고 페이지는 런타임에 필요에 따라 렌더링됩니다. 대표적인 사례는 오버헤드가 높은 여러 레이어의 텍스처 맵을 블렌딩해야 하는 지상형 렌더링이며, RVT는 블렌딩 결과를 캐시하여 성능을 절약할 수 있습니다.
Adaptive Virtual Texture(AVT):
고정밀 VT를 추구할 때 방향 텍스처가 너무 크다는 문제를 해결합니다. 표준 RVT에 섹터 개념을 도입하여 버츄어 텍스처를 여러 섹터로 나누고 카메라 거리에 따라 섹터 크기를 동적으로 조정하며 각 섹터는 고정된 크기의 방향 텍스처에 대응하여 실제 페이지를 인덱싱합니다. 이는 추가적인 변환 레이어에 해당합니다.
Clipmap Texture:
화면 리드백 대신 카메라 정보를 사용하여 렌더링할 페이지를 수집합니다. 비동기식 읽기백으로 인한 느린 GPU 읽기백 문제와 지연 시간을 방지합니다. 특정 영역의 정밀도를 지정하여 방향 텍스처가 너무 커지는 문제를 해결합니다. 저사양 디바이스에 더 적합합니다. 실제로 캐스케이드 섀도맵과 유사합니다.
프로세스의 여러 VT 부분은 거의 동일합니다.
이 글에서는 주로 지면 유형을 렌더링하는 표준 RVT와 클립맵 텍스처를 소개하고 구현하는 방법을 소개합니다.
Virtual Texture Terrain
Virtual Texture Terrain
VT워크플로
VT의 주요 프로세스는 사실 거의 동일합니다.
1.가상 텍스처 페이지 분할
거대한 가상 텍스처 전체, 각 레벨의 밉맵을 여러 가상 텍스처 페이지로 균등하게 나눕니다.
밉 레벨이 높을수록 페이지 수가 줄어들고 가상 텍스처에 해당하는 픽셀 영역이 커집니다.
단일 페이지 Mip0: 128*128, Mip1: 256*256。
VT Mipmap Pages
2.가상 텍스처 페이지 보기
목적은 현재 필요한 VTPage를 수집하는 것으로, 주로 CPU와 GPU의 두 가지 장르로 나뉩니다.
CPU 측면: 카메라 위치에 따라 필요한 VTPage를 추정하기 위한 밉맵으로, ClipmapTexture가 사용되는 방식입니다.
장점: 빠르고, 대기 시간이 없습니다. 단점: VTPage에는 마스크 컬링이 없고 정확한 밉 정보가 없습니다.
GPU 측면: 화면의 피드백 패스(출력 페이지 및 밉 정보)를 지정된 버퍼에 렌더링한 다음 비동기식 리드백으로 처리하기 위해 VTPage와 밉을 CPU로 전달합니다.일반적으로 리드백 속도를 높이기 위해 버퍼를 추가로 다운샘플링합니다.
장점: 표시되는 VT페이지를 더 정확하게 파악하고 마스킹 컬링을 즐길 수 있습니다.
단점: 추가 렌더링 피드백 패스가 필요하고, 비동기식 피드백이 느리고 약간의 지연 시간이 있으며, 다운샘플링으로 인한 정보 손실 문제를 해결해야 합니다.
Feedback
2.물리적 텍스처 업데이트
필요한 가상 텍스처 페이지 정보를 얻은 후, 피지컬 텍스처에서 해당 가상 텍스처 페이지를 렌더링하고 생성할 영역(피지컬 페이지)을 신청할 수 있으며, 일반적으로 LRU(최소 최근 사용) 양방향 원형 체인 테이블을 사용하여 유지 관리합니다.이러한 물리 페이지는 종종 체인의 끝에 있는 페이지를 보고 할당할 때 체인의 맨 앞에 있는 물리 페이지를 가져가려고 하는데, 이러한 물리 페이지는 주기적으로 재사용됩니다.
Physical Page(LRU)
그런 다음 다양한 지면 유형 스플랫 맵을 혼합하여 가상 페이지에 해당하는 가상 텍스처 영역을 물리적 텍스처의 물리적 페이지 영역에 렌더링합니다.
3.간접 텍스처 갱신(페이지 테이블)
물리 페이지를 그린 후에는 물리 텍스처에 있는 물리 페이지의 위치 정보를 해당 방향 텍스처에 써야 하며, 방향 텍스처의 각 픽셀은 페이지의 주소 전환을 나타냅니다.인디렉션 텍스처의 각 픽셀은 페이지 주소 변환을 나타내므로 실제로 사용할 때 인디렉션 텍스처를 샘플링하여 해당 피지컬 페이지를 찾을 수 있습니다.
RVT구현
1.초기화
Indirection Texture:
먼저 VT를 여러 개의 가상 페이지로 나누어 각각 하나의 방향 텍스처 스트라이프에 대해 시작해야 합니다.페이지 크기가 128 픽셀인 mip0, 1024 크기(즉, 1024 페이지)의 방향 텍스처, 해상도가 1024 * 128인 VT를 공식화할 수 있습니다. 이 데이터로 방향 텍스처를 초기화할 수 있습니다.
인디렉션 텍스처는 밉맵을 사용하므로 밉맵의 페이지 데이터도 초기화해야 합니다. 밉맵의 각 레벨에는 해당 해상도의 페이지가 있으며, 고급 밉일수록 페이지 수가 적고, VT의 면적이 클수록 하나의 페이지에 해당합니다.
VTPage의 ID는 "Z" 문자에 따라 페이지를 정렬할 수 있는 모튼 코드를 사용하여 인코딩되며, 다른 밉에 해당하는 페이지의 해당 위치를 빠르게 확인하는 데 사용됩니다.
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:
물리적 텍스처는 실제 페이지 데이터가 저장되는 곳이며 크기는 우리가 지정하고 일반적인 유형의 렌더링 단어 물리적 텍스처는 약 4k - 8k 해상도이며 2 개 (기본 색상 맵, 노멀 맵)가 있으며 여기 코드에서 물리적 페이지 나는 타일의 수를 나타내는 데 타일을 사용합니다 : 물리적 페이지 해상도 / 단일 페이지 해상도, 타일은 위의 사용입니다.Tile은 위에서 언급한 대로 LRU를 사용하여 관리합니다.
이 글에서 피지컬 텍스처는 주로 작은 페이지 조각을 업데이트하여 전체 RT를 로드/저장하고 싶지 않고, 실시간 압축의 효율성을 개선하기 위해(나중에 언급할 것입니다) TextureArray 텍스처 배열 형식을 사용하고 있습니다.
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>
/// 分配Tile
/// </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 페이지를 수집하고, GPU에서 페이지 ID와 밉맵을 계산하여 피드백 버퍼에 출력한 다음, CPU 비동기 읽기 피드백으로 버퍼를 파싱하여 페이지 정보를 가져오는 것입니다.
Feedback Buffer
버퍼 포맷은 32비트 RGBA8입니다. 일반적으로 VT는 페이지를 1024 또는 2048 정도로 나누기 때문에 PageID의 xy 좌표가 256보다 크면 8비트로는 충분하지 않으며, 일반적으로 8비트 B 채널은 RG, 즉 PageID의 xy를 12비트로 분할하여 표현합니다.그런 다음 A 채널 8비트로 mipLevel을 저장합니다.
IR8G8B8A8
PageID X (低8位) | PageID Y (낮은 8비트) | 4位 PageID X(높은4비트) 4位 PageID Y(높은4비트) |
mipLevel |
이 피드백 버퍼를 사용하면 버퍼에 데이터를 출력할 필요도 있습니다. 구식 접근 방식은 MRT 기능을 사용하여 피드백 버퍼를 G버퍼의 렌더링 타깃 중 하나로 출력하는 것입니다. 반면 UE는 주로 리드백 속도를 높이기 위해 화면 해상도에 따라 크기가 축소된 PS에서 정렬되지 않은 액세스 UAV 버퍼에 직접 출력합니다.해상도를 축소하면 정보가 손실되므로, 손실된 정보를 다시 추가하기 위해 여러 프레임을 사용하여 JitterOffset의 랜덤 오프셋 메커니즘을 사용하는 코드를 참조하세요.
UE Feedback
UE 및 MRT와 달리 이 백서에서는 코드의 침입을 줄이고 프레젠테이션을 용이하게 하기 위해 피드 버퍼에 접지형 피드백 패스를 추가로 끌어옵니다.실제 프로젝트에서는 그라운드 타입을 추가로 그려야 하고, 동시에 씬의 오클루전 정보 없이 그라운드 타입만 자체적으로 가려져 페이지가 중복으로 표시되므로 권장하지 않습니다.
Feedback Pass
이 글에서 피드백 패스의 셰이더 코드는 다음과 같습니다. UV * PageCount를 사용하여 PageID를 가져온 다음 DDX,DDY를 사용하여 밉레벨을 계산합니다.
출력 페이지 정보
밉레벨 계산
좌:Feedback Buffer
피드백 다운샘플링
피드백 패스는 원래 화면 해상도로 렌더링해야 하며, 저해상도 버퍼를 사용하여 직접 렌더링할 수 없는데, 그 이유는 밉 레벨이 DDX,DDY로 계산되고 해상도가 변경되면 이 밉이 부정확해지기 때문입니다. UE와 같은 전체 해상도 렌더링을 사용하고 저해상도 UAV 버퍼로 출력하면 이 문제가 발생하지 않습니다.하지만 우리는 그렇지 않으므로 저해상도 렌더타깃으로 추가 다운샘플링을 수행해야 합니다. 여기서 다운샘플링하는 방법은 데이터 손실을 방지하기 위해 원본 피드백 버퍼에서 무작위 샘플을 반올림하는 것으로, 매 프레임마다 난수가 변경되므로 여러 프레임이 지나면 원본 데이터에 가까워질 수 있습니다.
다운샘플링
다운샘플링 후
3.표시되는 페이지 모음
피드백 버퍼를 사용하면 비동기식 읽기 피드백을 사용할 수 있으며, Unity에서는 CommandBuffer.RequestAsyncReadback API를 사용하여 프레임 디버거 디버깅을 용이하게 하기 위해 동기식 읽기 피드백 스위치를 추가했습니다.
읽기 피드백이 성공하면 이 피드백 버퍼의 각 픽셀을 반복하고 페이지와 밉레벨을 파싱한 다음 처리 목록에 추가합니다.처리 목록은 밉과 발생 횟수별로 정렬되며, 프로젝트의 필요에 따라 우선 처리 전략을 조정할 수 있습니다.
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);//우선순위 높음
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.실제 페이지 업데이트
시각적 페이지가 생성되면 시각적 페이지를 그릴 수 있는데, 한 프레임에 너무 많은 페이지가 생성되는 것을 방지하기 위해 한 프레임에 그릴 수 있는 최대 개수를 제한해야 합니다. 물리 텍스처의 타일을 적용하기 위해 VTPage를 통해 타일에 이미 저장된 콘텐츠가 있다면 이론적으로는 이미 저장된 콘텐츠가 필요하지 않지만 위의 콘텐츠를 함부로 덮어쓸 수는 없습니다.피드백은 비동기적이고 지연되기 때문에 현재 프레임이 제거된 위치에 다시 렌더링될 경우 렌더링 오류가 발생합니다.이를 방지하려면 이전 방향 텍스처 페이지를 다른 밉에 매핑해야 실제 페이지의 콘텐츠를 덮어쓸 수 있습니다.
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;
// 일괄 페이지 생성
{
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();
}
실제 물리 페이지의 업데이트는 인스턴스를 사용하여 렌더링 직교 시트를 인스턴스화하여 물리 텍스처에 해당 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();
}
실제 페이지가 업데이트된 후에는 페이지를 방향 텍스처 업데이트 목록에 추가해야 합니다.
페이지의 해당 밉의 픽셀을 업데이트하는 것 외에도 그 하위 수준의 밉을 매핑해야 하며, 그렇지 않으면 피드백을 읽는 동안 매핑되지 않은 페이지(아래 그림에서 파란색 줄이 그어진 페이지)로 렌더링하면 렌더링 오류가 발생할 수 있습니다.이 문서의 해결책은 아래 그림과 같이 매핑되지 않은 페이지를 물리적 맵에 이미 존재하는 상위 페이지의 물리적 위치에 매핑하는 것입니다:
여기서는 위에서 설명한 모튼 코드를 사용하여 하위 밉의 페이지를 빠르게 찾은 다음 각 밉 레벨의 페이지를 반복할 수 있습니다.이 오버헤드는 실제로 비교적 크며, 이 문제를 처리하는 더 나은 알고리즘이 있을 수도 있습니다.트래버스 속도를 높이고 간접 텍스처 업데이트의 효율성을 개선하기 위해 이 논문에서는 사각형이 전체 밉의 영역을 덮고, 하위 레벨 밉이 물리적 매핑에 저장된 경우 페이지 픽셀을 업데이트하기 위해 다시 업데이트하는 오버랩 드로잉 방법을 사용합니다. 위 그림과 같이 3개의 녹색 픽셀은 3개의 사각형으로 업데이트되지 않고 하나의 녹색 사각형으로 업데이트된 후 빨간색 사각형이 다시 덮어씌워집니다.
피지컬 페이지는 다음과 같이 셰이더를 그립니다.
Physcial Texture
5.간접 테이블 업데이트
인디렉션 텍스처 형식은 채널당 8비트 텍스처가 포함된 RGBA32입니다.채널 정보는 다음과 같습니다:
RGBA
Physical TileID X | Physical TileID Y | MipLevel | TextureArray Index |
위에서 텍스처 배열을 사용하여 물리적 텍스처 배열을 구현하는 방법에 대해 언급했는데, 1024 * 1024 * 8 크기이고 각 페이지가 128 * 128, TileID XY = 1024 /128 = 8, 즉 타일ID가 [0,7] 간격이라고 가정하면 8비트면 충분합니다.
위와 비슷한 방식으로 방향 텍스처를 업데이트합니다.
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) //处理回退的page,整个区域都映射到该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를 사용하는 것은 간단하며, 주의해야 할 것은 물리적 UV 계산뿐입니다.
Physical UV = ( VTPageUV * TileSize+ PhysicalTileIndex * TileSize) / PhyscialTextureSize
밉레벨과 VTPage의 크기를 사용하여 VTPageUV를 계산할 수 있습니다. 이렇게 하면 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: 1.0/PhyscialTextureSize
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을 직접 혼합하는 것과 동일합니다.
VT를 켭니다.
SceneView에서 고정밀 위치가 카메라에 가깝고 뷰콘 내에 있는 것을 확인할 수 있습니다.
SceneView 시점
Clipmap 구현
클립맵과 RVT의 차이점은 주로 페이지를 수집하는 단계에 있습니다. 인디렉션 텍스처는 각 밉 레벨이 동일한 수의 페이지 표현을 사용한다는 점, 즉 인디렉션 텍스처는 더 이상 밉맵을 사용하지 않고 TextureArray를 사용하여 각 밉 레벨의 정보를 저장하고 각 밉 레벨의 페이지 수가 동일하다는 점에서도 차이가 있습니다. 클립맵은 카메라 주변이나 주인공 주변에 위치하거나 원래 대칭이 아닌 카메라를 향해 앞으로 향할 수도 있습니다.
左图mip2 右图mip1
1.데이터 준비
여기서는 레이어를 사용하여 밉의 각 레벨을 표현합니다. 레이어는 보통 6-8레벨이면 충분하며 각 레벨에는 8 * 8 또는 16 * 16 페이지가 있습니다. 결국 페이지 수가 적기 때문에 방향 텍스처는 CPU를 사용하여 업데이트하므로 매핑 색상 데이터를 저장할 색상 배열이 있습니다.
2.롤링 업데이트는 페이지에서 확인할 수 있습니다.
클립맵의 핵심 아이디어는 유한한 영역을 표현하기 위해 유한한 수의 그리드(페이지)를 사용하는 것입니다. 그리드는 고정된 수의 그리드이므로 이동할 때 일부 그리드는 동일하므로 업데이트할 필요가 없고 일부는 새 위치로 업데이트해야 그리드를 재활용할 수 있습니다(다음 그림 참조).
이것은 주기적인 주소 지정 프로세스이며, 미러 중앙의 버려진 페이지를 새 위치로 이동하고 앵커 포인트를 다시 조정하기 위해 이동하며 이러한 프로세스가 반복되었습니다 (위 그림 참조).아이디어는 간단하지만 코드는 결국 번거롭습니다.
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; //new global 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.업데이트된 간접 매핑
위의 단계를 통해 업데이트된 페이지를 파악한 후 작업은 직접 간접 매핑을 업데이트하도록 예약됩니다.
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 업데이트에서 동시에 피지컬 텍스처 업데이트를 시작하고, 피지컬 텍스처 업데이트 단계와 RVT 부분을 병행할 수 있으며, 더 이상 세부 사항은 없습니다.피지컬 텍스처 업데이트가 완료되면 잡의 데이터를 동기화하여 TextureArray를 채울 수 있습니다.
Job 예약하기
更新Indirection Texture
4.클립맵 텍스처 렌더링
샘플링 방법은 간단합니다. 먼저 픽셀이 어느 레이어에 있는지 확인한 다음 해당 레이어의 인디렉션 텍스처 인덱스를 샘플링합니다.
#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;//padding
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;
}
이제 클립맵 텍스처가 어느 정도 완성되었습니다.
클립맵 텍스처 유형
텍스처 필터링
1.선형 보간
물리 텍스처는 여러 페이지가 공존하기 때문에 패딩 경계가 없는 경우 경계를 샘플링할 때 다음 페이지로 잘못 샘플링되어 오류가 발생합니다. 해결 방법은 경계를 추가하는 것입니다. 즉, 페이지가 128인 경우 4개의 경계를 추가하면 132가 됩니다. 이 문서에서 샘플링하는 또 다른 방법은 ProbeBaseGI 문서 이전에 사용되었던 UV 스케일링을 푸는 것입니다.
//페이지 생성 시 축소
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.트라이리니어 보간
삼선형성은 밉맵 간의 보간을 의미합니다. 한 가지 옵션은 디더 노이즈 샘플링을 사용하는데, 성능은 향상되지만 노이즈가 발생하며 TAA를 사용하면 노이즈를 제거할 수 있습니다.
여기에서는 효과를 더 쉽게 확인할 수 있도록 밉맵의 정밀도 차이를 확대했습니다.
왼쪽: 클립맵 필터 영역 오른쪽: 노이즈 슈도코드 오버랩
다른 하나는 정직하게 두 번(밉과 밉+1) 샘플링하고 VT가 보간하는 것입니다:
3.各项异性过滤
TODO
VT압축
렌더링 효율을 향상시키기 위해 물리 텍스처를 해당 플랫폼의 포맷으로 압축할 수 있으므로 물리 텍스처를 TextureArray로 설정하면 압축 효율도 높아지고 업데이트된 단일 레이어에 대응하기만 하면 압축을 수행할 수 있습니다.
이 글에서는 UE 소스 코드에서 ETCCompressionCommon.ush와 BCCompressionCommon.ush를 사용한 다음 PS를 사용하여 압축합니다.형식은 Uint여야 한다는 점에 유의해야 합니다.
또한 샘플 중심을 원점에 두고 4x4 압축으로 PS를 사용하여 압축하는 경우 시작점까지 -1.5가 필요합니다.
압축 후 Renderdoc 및 FramgeDebuger를 통해 매핑이 이제 BC7임을 확인할 수 있습니다.
요약
SVT는 메모리에 대한 텍스처 압박을 해결할 수 있고, RVT는 프로시저럴 텍스처를 위한 캐시를 생성할 수 있습니다. 전체 세트에 관련된 엔지니어링 세부 사항이 너무 많다고 말해야 할 것 같습니다. 이 논문에서는 RVT의 구현만 살펴보고, 과거 SVT도 LoadObject("page_"+N) 방식을 통해 이미지를 에셋 번들로 잘라 관리할 수 있는 것처럼 보입니다.
参考
GPU Pro1 :Virtual Texture Mapping
GPU Pro7 :Adaptive Virtual Textures
Terrain Rendering in 'Far Cry 5'
Chen Ka AdaptiveVirtualTexture
원문
https://zhuanlan.zhihu.com/p/11611037946?utm_psn=1851655037236953089