UE4 Runtime Virtual Texture实现机制及源码解析

UE4 Runtime Virtual Texture实现机制及源码解析

一、前言

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也适用。


二、术语

为了便于理解,讲解之前先统一定义文中术语:

  1. Virtual Texture:虚拟纹理,以下简称VT。
  2. Runtime Virtual Texture:UE4运行时虚拟纹理系统,以下简称RVT。
  3. VT feedback:存储当前屏幕像素对应的VT Page信息,用于加载VT数据。
  4. VT Physical Texture:虚拟纹理对应的物理纹理资源。
  5. PageTable:虚拟纹理页表,用来寻址VT Physical Texture Page数据。
  6. PageTable Texture:包含虚拟纹理页表数据的纹理资源,通过此纹理资源可查询Physical Texture Page信息。有些VT系统也叫Indirection Texture,由于本文分析UE4 VT的内容,这里采用UE4术语。
  7. PageTable Buffer:包含虚拟纹理页表数据内容的GPU Buffer资源。

三、VT系统概述

从原理上来说,VT系统主要由2大阶段构成,VT数据准备和VT运行时采样阶段。

  1. VT数据准备阶段:
    a. 生成VT feedback数据。
    b. 生成VT纹理数据,并更新到指定VT Physical Texture对应的Page。
    c. 根据Feedback数据生成并更新PageTable数据。

  2. VT运行时采样阶段:
    a. 根据屏幕空间坐标以及相关信息生成VT Physical Texture UV。
    b. 对VT Physical Texture执行采样。

UE4的RVT基本上也是按照这个原理和流程来实现的,本文就按照这个顺序来详细讲解。在讲解之前,为了便于后续理解,先来了解下UE4 RVT的实现机制。


四、UE4 RVT实现机制概述

IVirtualTexture是UE4 VT最重要的接口,它是如何产生VT数据的接口,主要有两个抽象函数:

  1. RequestPageData,请求页面数据。
  2. 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 种:

  1. PageTable Buffer用于在CPU端只写的PageTable数据。
  2. PageTable Texture用于在采样VT时获取指定VT Physical Texture Page数据,此资源数据不是在CPU端填充,而是由PageTable Buffer通过RHICommandList在GPU上填充。
  3. 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构成:

  1. Draw Pass,通过RTT方式生成VT纹理数据,对应DrawMeshes函数。
  2. Compression Pass,将生成的纹理数据使用CS生成GPU支持的压缩格式,对应AddCompressPass函数。
  3. 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数据,具体有以下几个步骤:

  1. 根据UV寻址模式修正虚拟纹理坐标。
  2. 根据当前VT的Page数量和上一步修正过的虚拟纹理坐标计算出VT坐标对应的Page坐标。
  3. 通过Page坐标加上Page的XY偏移,再根据Mip Level,计算出PageTable Texture的UV坐标,然后使用这个UV坐标和Mip Level采样PageTable Texture得到在Physical Texture上的信息,保存在PageTableValue中,在接下来的流程中使用。
  4. 将第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坐标,步骤如下:

  1. PackedPageTableValue低4bit得到Mip Level,最多16级(0~15)。
  2. 由Mip Level计算UV Scale。
  3. 根据PackedPageTableValue的中\高8位(32bit PageTable Texture)或6位(16bit PageTable Texture)计算出在Physical Texture的Page坐标。
  4. 根据第2步的VU Scale计算出Mip Level对应的UV坐标,取小数部分UV坐标既是在Page内的UV坐标。
  5. 根据第3步的Page坐标乘以每Page像素大小在整个Physical Texture像素大小的比值的倒数,得到Page在Physical Texture的起始UV坐标,加上把第4步的Page内UV缩放到整个Physical Texture UV坐标,再加上Page Border与Physical Texture像素比值的倒数,就得出最终的Physical Texture UV采样的坐标。
  6. 将第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. 基础颜色或者场景高度,返回1层。
  2. 底色、法线、粗糙度、高光度,返回2层。
  3. YCoCg底色、法线、粗糙度、高光度或遮罩,返回3层。

在UE4中层数决定了VT Physical Texture的数量。


九、VT Physical Texture像素格式及存储布局

VT Physical Texture像素格式与RVT的配置有关,当需要2层纹理时(即底色、法线、粗糙度、高光度类型),FVirtualTexturePhysicalSpace会分配2个VT Physical Texture,纹理数据布局如下:

  1. #0 Texture(DXT1格式的RGB通道存储底色。
  2. #0 Texture的A通道存储法线X分量。
  3. #1 Texture(DXT5格式)的A通道存储法线Y分量。
  4. #1 Texture的B通道存储法线Z分量,带符号。
  5. #1 Texture的R通道存储高光度。
  6. #1 Texture的G通道存储粗糙度。

在Shader中调用VirtualTextureUnpackNormalBC3BC3函数Unpack Normal数据。

当需要3层纹理时(即YCoCg底色、法线、粗糙度、高光度(遮罩)),FVirtualTexturePhysicalSpace会分配3个VT Physical Texture,纹理数据布局如下:

  1. #0 Texture(DXT1格式的RGBA通道存储YCoCg空间底色。
  2. #1 Texture(BC5格式)的RG通道存储法线XY分量。
  3. #2 Texture(DXT1格式\遮罩DXT5格式)的B通道存储法线Z分量,带符号。
  4. #2 Texture的A通道存储遮罩值。
  5. #2 Texture的R通道存储高光度。
  6. #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开发者计划,这个舞台有你更精彩!