UE4 Runtime Virtual Texture实现机制及源码解析
- 作者:admin
- /
- 时间:2020年07月24日
- /
- 浏览:5911 次
- /
- 分类:厚积薄发
一、前言
Unreal Engine从4.23开始增加了Virtual Texture系统,并提供了2种Virtual Texture机制:Streaming Virtual Texture和Runtime Virtual Texture。经过3个版本的迭代和完善,在最新的4.25版本中提供了更加完善的功能,同时对移动平台有了更好的支持。
本文主要讲解UE 4.25中Runtime Virtual Texture系统实现机制以及源码解析,本文不包括Virtual Texture原理内容,阅读本文请先了解Virtual Texture的相关背景知识,另外本文也不包括UE4中Virtual Texture的使用方面的介绍,所以最好也请先了解UE4的Runtime Virtual Texture的基础概念以及使用方面的知识。这里有一篇比较好的Virtual Texture的原理介绍文章《浅谈Virtual Texture》以及Epic官方的Virtual Texture技术讲解视频,供大家参考。
由于篇幅限制,不可能把UE4 RVT所有代码都列出来讲解,对于关键功能会列出核心代码并加以阐述说明,其它非关键部分只列出相关类名和函数,并辅以文字描述。另外虽然本文讲解的是Runtime Virtual Texture,但UE4的实现中Virtual Texture的基础以及抽象部分是共用的,因此部分内容对Streaming Virtual Texture也适用。
二、术语
为了便于理解,讲解之前先统一定义文中术语:
- Virtual Texture:虚拟纹理,以下简称VT。
- Runtime Virtual Texture:UE4运行时虚拟纹理系统,以下简称RVT。
- VT feedback:存储当前屏幕像素对应的VT Page信息,用于加载VT数据。
- VT Physical Texture:虚拟纹理对应的物理纹理资源。
- PageTable:虚拟纹理页表,用来寻址VT Physical Texture Page数据。
- PageTable Texture:包含虚拟纹理页表数据的纹理资源,通过此纹理资源可查询Physical Texture Page信息。有些VT系统也叫Indirection Texture,由于本文分析UE4 VT的内容,这里采用UE4术语。
- PageTable Buffer:包含虚拟纹理页表数据内容的GPU Buffer资源。
三、VT系统概述
从原理上来说,VT系统主要由2大阶段构成,VT数据准备和VT运行时采样阶段。
VT数据准备阶段:
a. 生成VT feedback数据。
b. 生成VT纹理数据,并更新到指定VT Physical Texture对应的Page。
c. 根据Feedback数据生成并更新PageTable数据。VT运行时采样阶段:
a. 根据屏幕空间坐标以及相关信息生成VT Physical Texture UV。
b. 对VT Physical Texture执行采样。
UE4的RVT基本上也是按照这个原理和流程来实现的,本文就按照这个顺序来详细讲解。在讲解之前,为了便于后续理解,先来了解下UE4 RVT的实现机制。
四、UE4 RVT实现机制概述
IVirtualTexture是UE4 VT最重要的接口,它是如何产生VT数据的接口,主要有两个抽象函数:
- RequestPageData,请求页面数据。
- ProducePageData,产生页面数据。
对于RVT来说,实现此接口的是FRuntimeVirtualTextureProducer,也就是作为运行时产生Page纹理数据的类,对于SVT来说,实现此接口的是FUploadingVirtualTexture,用于从磁盘中流送上传Page纹理数据。
FVirtualTextureSystem是全局单件类,包含了UE4 VT系统中大部分核心逻辑和流程,驱动这个系统工作的是Update函数,分别在PC\Console Pipeline的 FDeferredShadingSceneRenderer::Render和Mobile Pipeline的FMobileSceneRenderer::Render中调用,具体细节会在下文中详细讲解。
在VT中只有Diffuse是远远不够的,在实际的着色过程中经常需要其它的纹理数据来进行光照计算,比如Normal、Roughness、Specular等等,UE4的RVT使用了Layer的概念,每个Layer代表不同的Physical Texture,在UE4中可以支持底色(Diffuse)、法线(Normal)、Roughness(粗糙度)、高光度(Specular)、遮罩(Mask)等不同内容的VT,这些数据以Pack的方式保存在多张Physical Texture的不同通道中,在使用时通过Unpack以及组合的形式解算出来进行光照计算。这些Physical Texture的寻址信息保存在同一个VT上的PageTable Texture的不同颜色通道中,下文会详细描述。
UE4 RVT中所使用的GPU资源有以下 3 种:
- PageTable Buffer用于在CPU端只写的PageTable数据。
- PageTable Texture用于在采样VT时获取指定VT Physical Texture Page数据,此资源数据不是在CPU端填充,而是由PageTable Buffer通过RHICommandList在GPU上填充。
- VT Physical Texture实际存储纹理数据的资源,通过VT feedback从磁盘或运行时动态生成纹理数据,并在VT数据准备阶段中更新到VT Physical Texture中。
其中VT Physical Texture资源包含在FVirtualTexturePhysicalSpace类中,PageTable Buffer\Texture包含在FVirtualTextureSpace类中。
1. VT数据准备阶段
1.1 生成VT Feedback
与一般在GBuffer pass中生成VT Feedback Buffer不同的是,UE4中并没有单独的VT Feedback Pass,而是在材质Shader的最后调用FinalizeVirtualTextureFeedback将当前帧的Request Page信息写入Feedback UAV Buffer,然后在CPU侧每帧的FVirtualTextureSystem::Update中读取这个Buffer,根据Buffer中的Request Page信息读取Page Texture数据。
FinalizeVirtualTextureFeedback函数被不同的Render pipeline调用,比如PC\Console 的BasePassPixelShader和Mobile的MobileBasePassPixelShader。这个函数很简单,主要是把PS生成的VT Request写入到UAV Buffer中,部分代码如下:
void FinalizeVirtualTextureFeedback(in FVirtualTextureFeedbackParams Params, float4 SvPosition, float Opacity, uint FrameNumber, RWBuffer<uint> OutputBuffer)
{
uint2 PixelPos = SvPosition.xy;
uint FeedbackPos = 0;
// This code will only run every few pixels...
[branch] if (((PixelPos.x | PixelPos.y) & (VIRTUAL_TEXTURE_FEEDBACK_FACTOR-1)) == 0)
{
// TODO use append buffer ?
PixelPos /= VIRTUAL_TEXTURE_FEEDBACK_FACTOR;
FeedbackPos = PixelPos.y*View.VirtualTextureFeedbackStride + PixelPos.x;
...
...
OutputBuffer[FeedbackPos] = Params.Request;
}
}
}
出于性能考虑,并不是写入与屏幕分辨率相同大小的Buffer,而是根据项目中的反馈分辨率因子设置来写入,对应Shader中的VIRTUAL_TEXTURE_FEEDBACK_FACTOR,这个值越大性能越好,但粒度越粗,很可能会漏掉VT数据而导致渲染不正确。
1.2 生成 VT 纹理数据
GPU侧生成好Feedback Buffer之后,在CPU侧的VirtualTextureSystem::Update函数通过FSceneRenderTargets单件类的VirtualTextureFeedback成员,回读VT Feedback数据,根据回读到的Request数据,然后将搜集好的数据通过Task Graph系统进行并行分析处理,代码如下:
for (int32 TaskIndex = 0; TaskIndex < LocalFeedbackTaskCount; ++TaskIndex)
{
FFeedbackAnalysisTask::DoTask(FeedbackAnalysisParameters[TaskIndex]);
}
if (WorkerFeedbackTaskCount > 0)
{
SCOPE_CYCLE_COUNTER(STAT_ProcessRequests_WaitTasks);
FTaskGraphInterface::Get().WaitUntilTasksComplete(Tasks, ENamedThreads::GetRenderThread_Local());
}
在FFeedbackAnalysisTask中最终调用FVirtualTextureSystem::FeedbackAnalysisTask处理Request数据,函数代码如下:
void FVirtualTextureSystem::FeedbackAnalysisTask(const FFeedbackAnalysisParameters& Parameters)
{
FUniquePageList* RESTRICT RequestedPageList = Parameters.UniquePageList;
const uint32* RESTRICT Buffer = Parameters.FeedbackBuffer;
const uint32 Width = Parameters.FeedbackWidth;
const uint32 Height = Parameters.FeedbackHeight;
const uint32 Pitch = Parameters.FeedbackPitch;
// Combine simple runs of identical requests
uint32 LastPixel = 0xffffffff;
uint32 LastCount = 0;
for (uint32 y = 0; y < Height; y++)
{
const uint32* RESTRICT BufferRow = Buffer + y * Pitch;
for (uint32 x = 0; x < Width; x++)
{
const uint32 Pixel = BufferRow[x];
if (Pixel == LastPixel)
{
LastCount++;
continue;
}
if (LastPixel != 0xffffffff)
{
RequestedPageList->Add(LastPixel, LastCount);
}
LastPixel = Pixel;
LastCount = 1;
}
}
if (LastPixel != 0xffffffff)
{
RequestedPageList->Add(LastPixel, LastCount);
}
}
可以看出是将GPU产生的Request数据加入到FUniquePageList中,并统计出现的次数。FUniquePageList内部是一个Hash Table,通过Hash Page Request得到Page的索引并进行累加次数。
等待所有分析任务完成之后,再去除重复的Page,生成唯一的Page List:
for (uint32 TaskIndex = 1u; TaskIndex < NumFeedbackTasks; ++TaskIndex)
{
MergedUniquePageList->MergePages(FeedbackAnalysisParameters[TaskIndex].UniquePageList);
}
接下来是生成唯一的请求列表FUniqueRequestList,流程和FUniquePageList类似,根据上一步得到的FUniquePageList再次通过TaskGraph并行处理,在FVirtualTextureSystem::SubmitRequests中提交请求列表,最终调用到FRuntimeVirtualTextureProducer::ProducePageData,产生 FRuntimeVirtualTextureFinalizer对象,在FRuntimeVirtualTextureFinalizer::Finalize中 调用RuntimeVirtualTexture::RenderPages函数渲染到VT Physical Texture上。
FRuntimeVirtualTextureFinalizer::Finalize中主要生成以VT Physical Texture为批次的 FRenderPageBatchDesc数据,遍历需要生成纹理数据的VT Pages,当遇到不同的Physical Texture则打断批次,并执行RenderPages 。在RenderPages中遍历每个Page 调用RenderPage函数执行真正的生成VT纹理数据功能,部分代码如下:
bool bBreakBatchForTextures = false;
for (int LayerIndex = 0; LayerIndex < RuntimeVirtualTexture::MaxTextureLayers; ++LayerIndex)
{
// This should never happen which is why we don't bother sorting to maximize batch size
bBreakBatchForTextures |= (RenderPageBatchDesc.Targets[LayerIndex].Texture != Entry.Targets[LayerIndex].TextureRHI);
}
if (++BatchSize == RuntimeVirtualTexture::EMaxRenderPageBatch || bBreakBatchForTextures)
{
RenderPageBatchDesc.NumPageDescs = BatchSize;
RuntimeVirtualTexture::RenderPages(RHICmdList, RenderPageBatchDesc);
BatchSize = 0;
}
if (bBreakBatchForTextures)
{
for (int LayerIndex = 0; LayerIndex < RuntimeVirtualTexture::MaxTextureLayers; ++LayerIndex)
{
RenderPageBatchDesc.Targets[LayerIndex].Texture = Tiles[0].Targets[LayerIndex].TextureRHI != nullptr ? Tiles[0].Targets[LayerIndex].TextureRHI->GetTexture2D() : nullptr;
RenderPageBatchDesc.Targets[LayerIndex].UAV = Tiles[0].Targets[LayerIndex].UnorderedAccessViewRHI;
}
}
RenderPage使用RenderDependencyGraph协助完成VT纹理数据的生成。这个函数主要有三个Pass构成:
- Draw Pass,通过RTT方式生成VT纹理数据,对应DrawMeshes函数。
- Compression Pass,将生成的纹理数据使用CS生成GPU支持的压缩格式,对应AddCompressPass函数。
- Copy Pass,将纹理数据进一步编码到更少的RT中,减少资源占用,对应AddCopyPass函数。
为了方便说明,这里只列出关键代码段,并在前面标注了序号:
void RenderPage(...)
{
...
// Build graph
FMemMark Mark(FMemStack::Get());
FRDGBuilder GraphBuilder(RHICmdList);
1 FRenderGraphSetup GraphSetup(GraphBuilder, MaterialType, OutputTexture0, TextureSize, bIsThumbnails);
// Draw Pass
if (GraphSetup.bRenderPass)
{
FShader_VirtualTextureMaterialDraw::FParameters* PassParameters = GraphBuilder.AllocParameters<FShader_VirtualTextureMaterialDraw::FParameters>();
...
GraphBuilder.AddPass(
RDG_EVENT_NAME("VirtualTextureDraw"),
PassParameters,
ERDGPassFlags::Raster,
[Scene, View, MaterialType, RuntimeVirtualTextureMask, vLevel, MaxLevel](FRHICommandListImmediate& RHICmdListImmediate)
{
2 DrawMeshes(RHICmdListImmediate, Scene, View, MaterialType, RuntimeVirtualTextureMask, vLevel, MaxLevel);
});
}
// Compression Pass
if (GraphSetup.bCompressPass)
{
FShader_VirtualTextureCompress::FParameters* PassParameters = GraphBuilder.AllocParameters<FShader_VirtualTextureCompress::FParameters>();
...
3 AddCompressPass(GraphBuilder, View->GetFeatureLevel(), PassParameters, TextureSize, MaterialType);
}
// Copy Pass
if (GraphSetup.bCopyPass || GraphSetup.bCopyThumbnailPass)
{
FShader_VirtualTextureCopy::FParameters* PassParameters = GraphBuilder.AllocParameters<FShader_VirtualTextureCopy::FParameters>();
...
if (GraphSetup.bCopyPass)
{
4 AddCopyPass(GraphBuilder, View->GetFeatureLevel(), PassParameters, TextureSize, MaterialType);
}
else
{
AddCopyThumbnailPass(GraphBuilder, View->GetFeatureLevel(), PassParameters, TextureSize, MaterialType);
}
}
....
....
// Execute the graph
GraphBuilder.Execute();
...
// Copy to final destination
if (GraphSetup.OutputAlias0 != nullptr && OutputTexture0 != nullptr)
{
FRHICopyTextureInfo Info;
Info.Size = GraphOutputSize0;
Info.DestPosition = FIntVector(DestBox0.Min.X, DestBox0.Min.Y, 0);
5 RHICmdList.CopyTexture(GraphOutputTexture0->GetRenderTargetItem().ShaderResourceTexture->GetTexture2D(), OutputTexture0->GetTexture2D(), Info);
}
...
}
下面逐条进行解析:
序号1:FRenderGraphSetup根据RVT Asset配置的内容生成RDG资源,比如选择底色、法线、粗糙度、高光度会生成3张B8G8R8A8格式的RenderTarget,用于生成Diffuse、Normal、Specular/Roughness数据。FRenderGraphSetup包含了3个Pass所需要的所有RGD资源,并且为了节省GPU内存,使用了重叠资源。
序号2:DrawMeshes搜集所有在当前RVT Volume范围内的Mesh进行绘制。对应绘制的Shader是VirtualTextureMaterial.usf,在PS中通过MRT输出到对应的RT中,部分代码如下:
void FPixelShaderInOut_MainPS(...)
{
....
// Output is from standard material output attribute node
half3 BaseColor = GetMaterialBaseColor(PixelMaterialInputs);
half Specular = GetMaterialSpecular(PixelMaterialInputs);
half Roughness = GetMaterialRoughness(PixelMaterialInputs);
half3 Normal = MaterialParameters.WorldNormal;
float WorldHeight = MaterialParameters.AbsoluteWorldPosition.z;
float Opacity = GetMaterialOpacity(PixelMaterialInputs);
float Mask = 0.f;
#if defined(OUT_BASECOLOR)
Out.MRT[0] = float4(BaseColor, 1.f) * Opacity;
#elif defined(OUT_BASECOLOR_NORMAL_SPECULAR)
float3 PackedNormal = PackNormal(Normal);
Out.MRT[0] = float4(BaseColor, 1.f) * Opacity;
Out.MRT[1] = float4(PackedNormal.xy, Mask, 1.f) * Opacity;
Out.MRT[2] = float4(Specular, Roughness, PackedNormal.z, 1.f) * Opacity;
#elif defined(OUT_WORLDHEIGHT)
float PackedHeight = PackWorldHeight(WorldHeight, ResolvedView.RuntimeVirtualTexturePackHeight);
Out.MRT[0] = float4(PackedHeight, 0, 0, 1);
#endif
}
由代码可以看出,这里直接调用Mesh材质Shader的各个通道输出作为结果,存储到对应的RT中。
序号3:AddCompressPass通过CS将在第2步生成的B8G8R8A8格式的纹理数据生成GPU压缩格式,这一步都在VirtualTextureCompress.usf Shader中处理,不同的VT配置使用不同的CS函数执行块压缩,包括BC3, BC5, BC1,目前不支持移动平台的ETC压缩。
序号4:AddCopyPass将第 3 步压缩后的RT进行合并,这一步也是在VirtualTextureCompress.usf Shader中完成,以底色、法线、粗糙度、高光度为例,读取3张Texture,Packed并写入到2个RT中,代码如下:
/** Copy path used when we disable texture compression, because we need to keep the same final channel layout. */
void CopyBaseColorNormalSpecularPS(
in float4 InPosition : SV_POSITION,
in noperspective float2 InTexCoord : TEXCOORD0,
out float4 OutColor0 : SV_Target0,
out float4 OutColor1 : SV_Target1)
{
float3 BaseColor = RenderTexture0.SampleLevel(TextureSampler0, InTexCoord, 0).xyz;
float2 NormalXY = RenderTexture1.SampleLevel(TextureSampler1, InTexCoord, 0).xy;
float3 RoughnessSpecularNormalZ = RenderTexture2.SampleLevel(TextureSampler2, InTexCoord, 0).xyz;
RoughnessSpecularNormalZ.z = round(RoughnessSpecularNormalZ.z);
OutColor0 = float4(BaseColor, NormalXY.x);
OutColor1 = float4(RoughnessSpecularNormalZ, NormalXY.y);
}
序号5:这一步很简单,只是调用图形API Copy命令将最终渲染生成的VT纹理数据Copy到VT Physical Texture的对应的Page(区域)上。
至此RVT的纹理数据生成完毕,接下来是更新PageTable数据。
1.3 PageTable Texture的更新
在生成VT纹理数据的最后( FVirtualTextureSystem::SubmitRequests函数的最后),调用FVirtualTextureSpace::ApplyUpdates更新PageTable Buffer并渲染到PageTable Texture中。
在FVirtualTextureSpace::ApplyUpdates中遍历每个PageTable Layer,通过Layer对应的FTexturePageMap生成FPageTableUpdate数据结构,这个结构包含了需要更新的PageTable原数据,为了减少GPU资源开销,UE4创建64bit R16G16B16A16_UINT格式GPU Buffer存储这个数据,这就是PageTable Buffer。然后通过RHI的Lock\Unlock操作将数据更新到这个Buffer中,代码如下:
uint8* Buffer = (uint8*)RHILockVertexBuffer(UpdateBuffer, 0, TotalNumUpdates * sizeof(FPageTableUpdate), RLM_WriteOnly);
for (uint32 LayerIndex = 0u; LayerIndex < Description.NumPageTableLayers; ++LayerIndex)
{
for (uint32 Mip = 0; Mip < NumPageTableLevels; Mip++)
{
const uint32 NumUpdates = ExpandedUpdates[LayerIndex][Mip].Num();
if (NumUpdates)
{
size_t UploadSize = NumUpdates * sizeof(FPageTableUpdate);
FMemory::Memcpy(Buffer, ExpandedUpdates[LayerIndex][Mip].GetData(), UploadSize);
Buffer += UploadSize;
}
}
}
RHIUnlockVertexBuffer(UpdateBuffer);
更新好PageTable Buffer之后,创建RVT内容类型对应的格式的PageTable RenderTarget,遍历PageTable RenderTarget的每个Mip Level,直接调用FRHICommandList执行渲染操作,将PageTable Buffer中数据写入到这个RT中。渲染部分代码如下:
uint32 MipSize = PageTableSize;
for (uint32 Mip = 0; Mip < NumPageTableLevels; Mip++)
{
const uint32 NumUpdates = ExpandedUpdates[LayerIndex][Mip].Num();
if (NumUpdates)
{
FRHIRenderPassInfo RPInfo(PageTableTarget.TargetableTexture, ERenderTargetActions::Load_Store, nullptr, Mip);
RHICmdList.BeginRenderPass(RPInfo, TEXT("PageTableUpdate"));
RHICmdList.SetViewport(0, 0, 0.0f, MipSize, MipSize, 1.0f);
FGraphicsPipelineStateInitializer GraphicsPSOInit;
RHICmdList.ApplyCachedRenderTargets(GraphicsPSOInit);
GraphicsPSOInit.BlendState = BlendStateRHI;
GraphicsPSOInit.RasterizerState = TStaticRasterizerState<>::GetRHI();
GraphicsPSOInit.DepthStencilState = TStaticDepthStencilState<false, CF_Always>::GetRHI();
GraphicsPSOInit.PrimitiveType = PT_TriangleList;
GraphicsPSOInit.BoundShaderState.VertexDeclarationRHI = GEmptyVertexDeclaration.VertexDeclarationRHI;
GraphicsPSOInit.BoundShaderState.VertexShaderRHI = VertexShader.GetVertexShader();
GraphicsPSOInit.BoundShaderState.PixelShaderRHI = PixelShader.GetPixelShader();
SetGraphicsPipelineState(RHICmdList, GraphicsPSOInit);
{
FRHIVertexShader* ShaderRHI = VertexShader.GetVertexShader();
SetShaderValue(RHICmdList, ShaderRHI, VertexShader->PageTableSize, PageTableSize);
SetShaderValue(RHICmdList, ShaderRHI, VertexShader->FirstUpdate, FirstUpdate);
SetShaderValue(RHICmdList, ShaderRHI, VertexShader->NumUpdates, NumUpdates);
SetSRVParameter(RHICmdList, ShaderRHI, VertexShader->UpdateBuffer, UpdateBufferSRV);
}
// needs to be the same on shader side (faster on NVIDIA and AMD)
uint32 QuadsPerInstance = 8;
RHICmdList.SetStreamSource(0, NULL, 0);
RHICmdList.DrawIndexedPrimitive(GQuadIndexBuffer.IndexBufferRHI, 0, 0, 32, 0, 2 * QuadsPerInstance, FMath::DivideAndRoundUp(NumUpdates, QuadsPerInstance));
RHICmdList.EndRenderPass();
ExpandedUpdates[LayerIndex][Mip].Reset();
}
FirstUpdate += NumUpdates;
MipSize >>= 1;
}
值得一提的是,UE4并没有使用Compute Pipeline来更新PageTable Texture,而是通过Graphics Pipeline在VS中生成,在PS中存储到PageTable RenderTarget,并且使用Instancing Rendering的方式,最多一次处理16个PageTable Quad,这样处理与CS基本相同,至于为何不直接用CS,我猜测也许因为前面生成VT纹理数据时已经使用了CS,为了提高GPU的并行性而使用Graphics Pipeline,不过这种优化在PC\Console上是可行的,对移动平台却并不能带来提升,如代码所示,使用Graphics Pipeline就需要对每个Mip Level要执行一次RenderPass,对移动平台上的现代API来说,这样并不是GPU Friendly的,而且在某些GPU比如Mali上,VS和CS共用同一个硬件Shader Core单元,并不能带来期望的CS和VS并行,因此这部分代码可以进一步改进,针对移动平台特性编写单独的代码路径来优化。
UE4将PageTable数据渲染到Texture主要目的是为了在使用VT时能够快速寻址,渲染的Shader代码在PageTableUpdate.usf中,其中PageTableUpdateVS函数将Page的Level、Page XY信息Pack到PageTable Texture中,部分代码如下:
#if USE_16BIT
// We can assume pPage fits in 6 bits and pack the final output to 16 bits
const uint PageCoordinateBitCount = 6;
#else
const uint PageCoordinateBitCount = 8;
#endif
...
uint Page = vLevel;
Page |= pPage.x << 4;
Page |= pPage.y << (4 + PageCoordinateBitCount);
五、RVT的使用
VT的数据准备好之后,就是如何使用了。RVT的使用主要是通过在Material的Shader中,加入RVT相关材质节点来采样RVT来完成的。下面是在Material Editor中使用Runtime Virtual Texutre Sample节点生成的HLSL使用RVT部分的代码:
VTPageTableResult Local1 = TextureLoadVirtualPageTable(VIRTUALTEXTURE_PAGETABLE_0
, VTPageTableUniform_Unpack(Material.VTPackedPageTableUniform[0 * 2], Material.VTPackedPageTableUniform[0 * 2 + 1])
, Parameters.SvPosition.xy
, Parameters.VirtualTextureFeedback
, 0 + LIGHTMAP_VT_ENABLED
, VirtualTextureWorldToUV(GetWorldPosition(Parameters)
, Material.VectorExpressions[4].rgb
, Material.VectorExpressions[3].rgb
, Material.VectorExpressions[2].rgb)
, VTADDRESSMODE_WRAP
, VTADDRESSMODE_WRAP);
MaterialFloat4 Local2 = TextureVirtualSample(Material.VirtualTexturePhysicalTable_0
, GetMaterialSharedSampler(Material.VirtualTexturePhysicalTable_0Sampler, View.SharedBilinearClampedSampler)
, Local1
, 0
, VTUniform_Unpack(Material.VTPackedUniform[0]));
这里主要调用了2个Shader函数:TextureLoadVirtualPageTable和TextureVirtualSample。
TextureLoadVirtualPageTable函数用于生成VTPageTableResult结构,这个结构包含了间接寻址的Page数据;TextureVirtualSample函数中使用这个结构执行真正的纹理采样工作。
六、TextureLoadVirtualPageTable
分析第一个函数TextureLoadVirtualPageTable,代码如下:
VTPageTableResult TextureLoadVirtualPageTable(Texture2D<uint4> PageTable0,
float2 UV, float MipBias,
float2 SvPositionXY,
VTPageTableUniform PageTableUniform,
uint AddressU, uint AddressV)
{
VTPageTableResult Result = (VTPageTableResult)0.0f;
const float2 ScaledUV = UV * PageTableUniform.UVScale;
uint vLevel = 0u;
#if PIXELSHADER
vLevel = TextureComputeVirtualMipLevel(Result, ddx(ScaledUV), ddy(ScaledUV), MipBias, SvPositionXY, PageTableUniform);
#endif // PIXELSHADER
TextureLoadVirtualPageTableInternal(Result, PageTable0, ScaledUV, vLevel, PageTableUniform, AddressU, AddressV);
return Result;
}
其中UV参数是RVT的UV坐标,那么如何获取VT的UV呢?答案在VirtualTextureWorldToUV函数中:
float2 VirtualTextureWorldToUV(in float3 WorldPos, in float3 Origin, in float3 U, in float3 V)
{
float3 P = WorldPos - Origin;
return saturate(float2(dot(P, U), dot(P, V)));
}
从代码可以看出,根据当前像素的世界空间位置以及RVT Volume原点(Volume左下角)、Volume边界大小的UV范围(经过世界旋转变换的XY轴乘以Volume缩放-即Volume大小-的倒数,这些计算在URuntimeVirtualTexture::Initialize中完成),求出当前像素在RVT中的UV坐标。
TextureComputeVirtualMipLevel函数计算RVT的Mip Level,为了实现较好的混合效果,这里根据当前帧ID生成交错的随机Noise扰动Level,代码如下:
uint TextureComputeVirtualMipLevel(inout VTPageTableResult OutResult,
float2 dUVdx, float2 dUVdy, float MipBias,
float2 SvPositionXY,
VTPageTableUniform PageTableUniform)
{
OutResult.dUVdx = dUVdx * PageTableUniform.SizeInPages;
OutResult.dUVdy = dUVdy * PageTableUniform.SizeInPages;
const float Noise = InterleavedGradientNoise(SvPositionXY, View.StateFrameIndexMod8);
const float ComputedLevel = MipLevelAniso2D(OutResult.dUVdx, OutResult.dUVdy, PageTableUniform.MaxAnisoLog2) + MipBias + Noise * 0.5f - 0.25f;
return clamp(int(ComputedLevel) + int(PageTableUniform.vPageTableMipBias), 0, int(PageTableUniform.MaxLevel));
}
TextureLoadVirtualPageTableInternal函数代码如下:
void TextureLoadVirtualPageTableInternal(inout VTPageTableResult OutResult,
Texture2D<uint4> PageTable0,
float2 UV, uint vLevel,
VTPageTableUniform PageTableUniform,
uint AddressU, uint AddressV)
{
UV.x = ApplyAddressMode(UV.x, AddressU);
UV.y = ApplyAddressMode(UV.y, AddressV);
OutResult.UV = UV * PageTableUniform.SizeInPages;
const uint vPageX = (uint(OutResult.UV.x) + PageTableUniform.XOffsetInPages) >> vLevel;
const uint vPageY = (uint(OutResult.UV.y) + PageTableUniform.YOffsetInPages) >> vLevel;
OutResult.PageTableValue[0] = PageTable0.Load(int3(vPageX, vPageY, vLevel));
OutResult.PageTableValue[1] = uint4(0u, 0u, 0u, 0u);
// PageTableID packed in upper 4 bits of 'PackedPageTableUniform', which is the bit position we want it in for PackedRequest as well, just need to mask off extra bits
OutResult.PackedRequest = PageTableUniform.ShiftedPageTableID;
OutResult.PackedRequest |= vPageX;
OutResult.PackedRequest |= vPageY << 12;
OutResult.PackedRequest |= vLevel << 24;
}
这个函数主要2个作用,一是生成用于寻址VT Physical Texture的PageTableValue,另一个是生成Feedback Request数据,具体有以下几个步骤:
- 根据UV寻址模式修正虚拟纹理坐标。
- 根据当前VT的Page数量和上一步修正过的虚拟纹理坐标计算出VT坐标对应的Page坐标。
- 通过Page坐标加上Page的XY偏移,再根据Mip Level,计算出PageTable Texture的UV坐标,然后使用这个UV坐标和Mip Level采样PageTable Texture得到在Physical Texture上的信息,保存在PageTableValue中,在接下来的流程中使用。
- 将第3步计算好的PageTable Texture的Page坐标和Mip Level保存在VTPageTableResult中,最后通过StoreVirtualTextureFeedback函数写入到VT Feedback Buffer中。
七、TextureVirtualSample
采样所需的VTPageTableResult数据准备完毕,在TextureVirtualSample函数中就是执行真正的Physical Texture采样逻辑,代码如下:
MaterialFloat4 TextureVirtualSample(
Texture2D Physical, SamplerState PhysicalSampler,
VTPageTableResult PageTableResult, uint LayerIndex,
VTUniform Uniform)
{
const float2 pUV = VTComputePhysicalUVs(PageTableResult, LayerIndex, Uniform);
return Physical.SampleGrad(PhysicalSampler, pUV, PageTableResult.dUVdx, PageTableResult.dUVdy);
}
这个函数很简单,只有2个函数调用,第一行VTComputePhysicalUVs用于生成Physical Texture UV坐标,第二行用于执行渐变采样,所以这里重点是如何生成Physical Texture UV坐标,VTComputePhysicalUVs函数代码如下:
float2 VTComputePhysicalUVs(in out VTPageTableResult PageTableResult, uint LayerIndex, VTUniform Uniform)
{
const uint PackedPageTableValue = PageTableResult.PageTableValue[LayerIndex / 4u][LayerIndex & 3u];
// See packing in PageTableUpdate.usf
const uint vLevel = PackedPageTableValue & 0xf;
const float UVScale = 1.0f / (float)(1 << vLevel);
const float pPageX = (float)((PackedPageTableValue >> 4) & ((1 << Uniform.PageCoordinateBitCount) - 1));
const float pPageY = (float)(PackedPageTableValue >> (4 + Uniform.PageCoordinateBitCount));
const float2 vPageFrac = frac(PageTableResult.UV * UVScale);
const float2 pUV = float2(pPageX, pPageY) * Uniform.pPageSize + (vPageFrac * Uniform.vPageSize + Uniform.vPageBorderSize);
const float ddxyScale = UVScale * Uniform.vPageSize;
PageTableResult.dUVdx *= ddxyScale;
PageTableResult.dUVdy *= ddxyScale;
return pUV;
}
这个函数通过在TextureLoadVirtualPageTableInternal中采样PageTable Texture得到在Physical Texture上的信息PackedPageTableValue,计算出采样Physical Texture的Mip Level和UV坐标,步骤如下:
- PackedPageTableValue低4bit得到Mip Level,最多16级(0~15)。
- 由Mip Level计算UV Scale。
- 根据PackedPageTableValue的中\高8位(32bit PageTable Texture)或6位(16bit PageTable Texture)计算出在Physical Texture的Page坐标。
- 根据第2步的VU Scale计算出Mip Level对应的UV坐标,取小数部分UV坐标既是在Page内的UV坐标。
- 根据第3步的Page坐标乘以每Page像素大小在整个Physical Texture像素大小的比值的倒数,得到Page在Physical Texture的起始UV坐标,加上把第4步的Page内UV缩放到整个Physical Texture UV坐标,再加上Page Border与Physical Texture像素比值的倒数,就得出最终的Physical Texture UV采样的坐标。
- 将第2步的UV Scale缩放到整个Physical Texture,来计算最终在Physical Texture的XY方向的导数,作为最终采样时使用。
八、UE4 RVT资源格式及布局
RVT的布局配置中选择不同的虚拟纹理内容,将决定VT Physical Texture和PageTable Texture的像素格式和布局。
RVT 的布局配置中虚拟纹理内容选项
在代码中根据内容枚举类型返回Texture层数,如下所示:
int32 URuntimeVirtualTexture::GetLayerCount(ERuntimeVirtualTextureMaterialType InMaterialType)
{
switch (InMaterialType)
{
case ERuntimeVirtualTextureMaterialType::BaseColor:
case ERuntimeVirtualTextureMaterialType::WorldHeight:
return 1;
case ERuntimeVirtualTextureMaterialType::BaseColor_Normal_Specular:
return 2;
case ERuntimeVirtualTextureMaterialType::BaseColor_Normal_Specular_YCoCg:
case ERuntimeVirtualTextureMaterialType::BaseColor_Normal_Specular_Mask_YCoCg:
return 3;
default:
break;
}
// Implement logic for any missing material types
check(false);
return 1;
}
如代码所示,虚拟纹理内容类型和层数之间对应关系如下:
- 基础颜色或者场景高度,返回1层。
- 底色、法线、粗糙度、高光度,返回2层。
- YCoCg底色、法线、粗糙度、高光度或遮罩,返回3层。
在UE4中层数决定了VT Physical Texture的数量。
九、VT Physical Texture像素格式及存储布局
VT Physical Texture像素格式与RVT的配置有关,当需要2层纹理时(即底色、法线、粗糙度、高光度类型),FVirtualTexturePhysicalSpace会分配2个VT Physical Texture,纹理数据布局如下:
- #0 Texture(DXT1格式的RGB通道存储底色。
- #0 Texture的A通道存储法线X分量。
- #1 Texture(DXT5格式)的A通道存储法线Y分量。
- #1 Texture的B通道存储法线Z分量,带符号。
- #1 Texture的R通道存储高光度。
- #1 Texture的G通道存储粗糙度。
在Shader中调用VirtualTextureUnpackNormalBC3BC3函数Unpack Normal数据。
当需要3层纹理时(即YCoCg底色、法线、粗糙度、高光度(遮罩)),FVirtualTexturePhysicalSpace会分配3个VT Physical Texture,纹理数据布局如下:
- #0 Texture(DXT1格式的RGBA通道存储YCoCg空间底色。
- #1 Texture(BC5格式)的RG通道存储法线XY分量。
- #2 Texture(DXT1格式\遮罩DXT5格式)的B通道存储法线Z分量,带符号。
- #2 Texture的A通道存储遮罩值。
- #2 Texture的R通道存储高光度。
- #2 Texture的G通道存储粗糙度。
在Shader中调用VirtualTextureUnpackBaseColorYCoCg函数Unpack Diffuse数据。在Shader中调用VirtualTextureUnpackNormalBC5BC1函数Unpack Normal数据。
在PC或主机平台上VT Physical Texture根据RVT内容配置不同采用不同的GPU压缩格式,比如如果内容只是底色,则使用DXT1格式,如果内容是底色、法线、粗糙度、高光度则使用DXT5,如果是YCoCg空间则是BC5,以满足Unpack法线数据时的精度要求。需要注意的是,目前在移动平台上还不支持GPU压缩格式。
十、PageTable Texture像素格式及布局
当VT Physical Texture中的Page不超过64*64个时,PageTable Texture使用16bit格式,因为只需要记录0~15(4位)Mip Level,以及0~63(6位)个Page的X、Y坐标,总计16bit,否则使用32bit,其中4bit(0~15)Mip Level,以及8bit(0~255)个Page的X、Y坐标。
由于GPU硬件的限制,尤其在移动平台,最大支持4K纹理,如果每个Page是128大小,则一个Physical Texture最多有32个Page,所以一般情况下都是16bit格式。但是PageTable Texture却可以使用4K的大小,也就意味着可以索引4K个Physical Texture Page,因此在UE4中一个PageTable Texture可以索引多张Physical Texture。
出于性能优化的考虑,UE4的RVT将不同Layer的Physical Texture Page数据存储在1个PageTable Texture的各个颜色通道中,当在RVT的配置中选择不同内容布局时,PageTable Texture的像素格式也会随之变化,这样在获取Physical Texture Page数据时,只需要对PageTable Texture采样一次即可获取每个Layer的Physical Texture Page信息。在VirtualTextureSpace中GetFormatForNumLayers函数根据Texture层数和Page格式返回PageTable Texture的像素格式,代码如下所示:
VirtualTextureSpace.cpp
...
static EPixelFormat GetFormatForNumLayers(uint32 NumLayers, EVTPageTableFormat Format)
{
const bool bUse16Bits = (Format == EVTPageTableFormat::UInt16);
switch (NumLayers)
{
case 1u: return bUse16Bits ? PF_R16_UINT : PF_R32_UINT;
case 2u: return bUse16Bits ? PF_R16G16_UINT : PF_R32G32_UINT;
case 3u:
case 4u: return bUse16Bits ? PF_R16G16B16A16_UINT : PF_R32G32B32A32_UINT;
default: checkNoEntry(); return PF_Unknown;
}
}
十一、后记
在VT实现过程中,往往由于工程化程度不够而导致无法实用,UE4的VT使我们看到一个真正意义上工程化且实用的VT是如何实现的,分析它一方面对于更深入的了解UE4 VT有很好的帮助,另一方面对实现自己的VT系统也有很好的工程化参考意义,尤其是一些优化手段。最后需要说明的是,UE4的VT在实现过程中加入了大量的利于工程化的优化机制和手段,因此实际的代码要远比文中列出的庞杂,本文只是对关键部分的代码加以分析和说明,如果要了解更多的实现细节,可以按照文中梳理的脉络来阅读源码,相信会有更多收获。
文末,再次感谢丛越的分享,如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)
作者主页:https://www.zhihu.com/people/li-xiang-da-sheng,作者也是U Sparkle活动参与者,UWA欢迎更多开发朋友加入U Sparkle开发者计划,这个舞台有你更精彩!