UE Slate渲染流程(下)

UE Slate渲染流程(下)

【USparkle专栏】如果你深怀绝技,爱“搞点研究”,乐于分享也博采众长,我们期待你的加入,让智慧的火花碰撞交织,让知识的传递生生不息!


上一篇:UE Slate渲染流程(上)

五、真正的渲染

最开始还是先把图贴上吧,Slate的真正的渲染流程如下所示:

从FSlateRHIRenderer::DrawWindows_Private开始,重要流程代码如下所示:

void FSlateRHIRenderer::DrawWindows_Private(FSlateDrawBuffer&WindowDrawBuffer){
    ......
    FSlateRHIRenderingPolicy*Policy=RenderingPolicy.Get();
    ......
    constTArray<TSharedRef<FSlateWindowElementList>>&WindowElementLists=WindowDrawBuffer.GetWindowElementLists();
    for(int32ListIndex=0;ListIndex<WindowElementLists.Num();++ListIndex){
        FSlateWindowElementList&ElementList=*WindowElementLists[ListIndex];
        SWindow*Window=ElementList.GetRenderWindow();
        if(Window){
            constFVector2DWindowSize=Window->GetViewportSize();
            if(WindowSize.X >0&&WindowSize.Y >0){
                ........
                ElementBatcher->AddElements(ElementList);
                ......
                {
                    FSlateDrawWindowCommandParamsParams;
                    Params.Renderer=this;
                    Params.WindowElementList=&ElementList;
                    Params.Window=Window;
                    .........
                    Params.WorldTimeSeconds=FApp::GetCurrentTime()-GStartTime;
                    Params.DeltaTimeSeconds=FApp::GetDeltaTime();
                    Params.RealTimeSeconds=FPlatformTime::Seconds()-GStartTime;
                    if(GIsClient&&!IsRunningCommandlet()&&!GUsingNullRHI){
                        ENQUEUE_RENDER_COMMAND(SlateDrawWindowsCommand)([Params,ViewInfo](FRHICommandListImmediate&RHICmdList){
                            Params.Renderer->DrawWindow_RenderThread(RHICmdList,*ViewInfo,*Params.WindowElementList,Params);
                        });
                        ........
                    }
                    SlateWindowRendered.Broadcast(*Window,&ViewInfo->ViewportRHI);
                }
            }
        }else{......}
    }
    ........
}

遍历WindowElementLists并逐个处理每个SlateWindowElementList,具体操作如下所示:

  • 获取与SlateWindowElementList关联的SWindow对象。如果窗口存在并且大小有效,则继续执行。

  • 调用FSlateElementBatcher::AddElement完成FSlateDrawElement对象到FSlateBatchData对象转化操作,完成渲染前的数据准备。

  • 构造FSlateDrawWindowCommandParams对象,其中包含渲染所需的参数,如渲染器、窗口元素列表、窗口、是否锁定垂直同步等。

  • 最后调用ENQUEUE_RENDER_COMMAND以在渲染线程执行FSlateRHIRenderer::DrawWindow_RenderThread完成渲染Command的生成并提交给GPU。

真正渲染数据的生成
已经知道FSlateRenderBatch被用于承载渲染所要用到的数据,但是FSlateWindowElementList中现在还都是FSlateDrawElement对象,所以还是需要FSlateElementBatcher做为中枢来完成这一步转换,并放到FSlateBatchData对象中。流程图如下所示:

这一步是通过调用FSlateElementBatcher::AddElements来完成的,具体代码如下所示:

void FSlateElementBatcher::AddElements(FSlateWindowElementList&WindowElementList){
    .....
    BatchData=&WindowElementList.GetBatchData();
    FVector2DViewportSize=WindowElementList.GetPaintWindow()->GetViewportSize();
    .......
    AddElementsInternal(WindowElementList.GetUncachedDrawElements(),ViewportSize);
    ......
    BatchData= nullptr;
    .......
}

其实这里的流程很简单,如下所示:

  1. 从FSlateWindowElementList中获取FSlateBatchData对象并赋值给BatchData,并且从SWindow对象中获取视口信息。

  2. 调用FSlateElementBatcher::AddElementsInternal来完成所有FSlateRenderBatch对象的构造。

  3. 将BatchData置空,以便接着处理下一个Window的FSlateWindowElementList对象。

FSlateElementBatcher::AddElementsInternal的逻辑其实很简单,就是遍历所有FSlateDrawElement对象,并根据每个FSlateDrawElement对象的不同的图元类型来调用不同FSlateElementBatcher::AddXXXXXElement函数,具体代码如下所示:

void FSlateElementBatcher::AddElementsInternal(constFSlateDrawElementArray&DrawElements,constFVector2D&ViewportSize){
    for(constFSlateDrawElement&DrawElement:DrawElements){
    switch(DrawElement.GetElementType())
    {
    caseEElementType::ET_Box:{
        DrawElement.IsPixelSnapped()?AddBoxElement<ESlateVertexRounding::Enabled>(DrawElement):AddBoxElement<ESlateVertexRounding::Disabled>(DrawElement);
    }
        break;
    caseEElementType::ET_Border:{
        DrawElement.IsPixelSnapped()?AddBorderElement<ESlateVertexRounding::Enabled>(DrawElement):AddBorderElement<ESlateVertexRounding::Disabled>(DrawElement);
    }
        break;
    caseEElementType::ET_Text:{
        DrawElement.IsPixelSnapped()?AddTextElement<ESlateVertexRounding::Enabled>(DrawElement):AddTextElement<ESlateVertexRounding::Disabled>(DrawElement);
    }
        break;
    caseEElementType::ET_ShapedText:{
        DrawElement.IsPixelSnapped()?AddShapedTextElement<ESlateVertexRounding::Enabled>(DrawElement):AddShapedTextElement<ESlateVertexRounding::Disabled>(DrawElement);
    }
        break;
        .........
        default:
            checkf(0, TEXT("Invalid element type"));
            break;
        }
    }
}

使用FSlateElementBatcher::AddBoxElement来举例(因为其他图元流程也差不多),都是从FSlateDrawElement从提取出所需数据并构造FSlateRenderBatch并设置好对应顶点和索引数据等。具体代码如下所示:

void FSlateElementBatcher::AddBoxElement(constFSlateDrawElement&DrawElement){
    constFSlateBoxPayload&DrawElementPayload=DrawElement.GetDataPayload<FSlateBoxPayload>();
    FSlateRenderTransformRenderTransform=DrawElement.GetRenderTransform()
    constFColorTint=PackVertexColor(DrawElementPayload.GetTint());
    constFVector2D&LocalSize=DrawElement.GetLocalSize();
    constESlateDrawEffectInDrawEffects=DrawElement.GetDrawEffects();
    constint32Layer=DrawElement.GetLayer();
    constfloatDrawScale=DrawElement.GetScale();
    FVector2DTopLeft(0,0);
    FVector2DBotRight(LocalSize);
    uint32TextureWidth=1;
    uint32TextureHeight=1;
    FVector2DStartUV=FVector2D(0.0f,0.0f);
    FVector2DEndUV=FVector2D(1.0f,1.0f);
    FVector2DSizeUV;
    FVector2DHalfTexel;
    constFSlateShaderResourceProxy*ResourceProxy=DrawElementPayload.GetResourceProxy();
    FSlateShaderResource*Resource= nullptr;
    if(ResourceProxy){
        Resource=ResourceProxy->Resource;
        TextureWidth=ResourceProxy->ActualSize.X !=0?ResourceProxy->ActualSize.X :1;
        TextureHeight=ResourceProxy->ActualSize.Y !=0?ResourceProxy->ActualSize.Y :1;
        // Texel offset
        HalfTexel=FVector2D(PixelCenterOffset/TextureWidth,PixelCenterOffset/TextureHeight);
        constFBox2D&BrushUV=DrawElementPayload.GetBrushUVRegion();
        if(BrushUV.bIsValid){
            SizeUV=BrushUV.GetSize();
            StartUV=BrushUV.Min+HalfTexel;
            EndUV=StartUV+SizeUV;
        }
        else{.......}
    }
    else{......}
    constESlateBrushTileType::TypeTilingRule=DrawElementPayload.GetBrushTiling();
    constboolbTileHorizontal=(TilingRule==ESlateBrushTileType::Both||TilingRule==ESlateBrushTileType::Horizontal);
    constboolbTileVertical=(TilingRule==ESlateBrushTileType::Both||TilingRule==ESlateBrushTileType::Vertical);
    constESlateBrushMirrorType::TypeMirroringRule=DrawElementPayload.GetBrushMirroring();
    constboolbMirrorHorizontal=(MirroringRule==ESlateBrushMirrorType::Both||MirroringRule==ESlateBrushMirrorType::Horizontal);
    constboolbMirrorVertical=(MirroringRule==ESlateBrushMirrorType::Both||MirroringRule==ESlateBrushMirrorType::Vertical);
    ESlateBatchDrawFlagDrawFlags=DrawElement.GetBatchFlags();
    DrawFlags|=(( bTileHorizontal ?ESlateBatchDrawFlag::TileU:ESlateBatchDrawFlag::None)|( bTileVertical ?ESlateBatchDrawFlag::TileV:ESlateBatchDrawFlag::None));
    FSlateRenderBatch&RenderBatch=CreateRenderBatch(Layer,FShaderParams(),Resource,ESlateDrawPrimitive::TriangleList,ESlateShader::Default,InDrawEffects,DrawFlags,DrawElement);
    ....
    // 最后是填充顶点和索引,大致流程如下所示:
    RenderBatch.AddVertex(.......);
    RenderBatch.AddVertex(.......);
    RenderBatch.AddVertex(.......);
    
    RenderBatch.AddIndex(.......);
    RenderBatch.AddIndex(.......);
    RenderBatch.AddIndex(......);
}
  1. 获取DrawElement中的Payload,并从中获取绘制元素所需的所有信息,如颜色、纹理、大小、缩放以及LayerID,DrawEffects以及RenderTransform等信息。

  2. 获取当前是否包含纹理资源,并从中提取出UV,纹理和纹素大小等数据。

  3. 根据数据确定是否需要镜像或者平铺等来计算出纹理坐标和顶点坐标。

  4. 根据计算出的信息调用FSlateElementBatcher::CreateRenderBatch创建RenderBtach。

  5. 最后根据图元类型来创建顶点和索引并将其添加到RenderBatch对象中,这样RenderBatch就已经包含了绘制元素所需的所有顶点、纹理坐标、颜色等信息了。

其实每个不同的图元在创建顶点和索引时都有优化手段,首先来讲讲Box类型的图元:

  • 针对其Brush是非图片资源的情况下,则会采用九宫格的处理方式,也就是说最终会生成九个Quad,也就是18个三角形。这主要是处理UI元素的尺寸变化,这样就可以保证元素的边缘不会被拉伸或压缩,避免了图像失真或像素化。

  • 针对是图片的情况下,则只需要添加一个Quad就可以展示图片,但是在像素对齐(PixelSnapp)关闭的情况下,还会有另外一个优化启动,那就是羽化(Feather)。原理是在添加四个顶点在现有的矩形顶点周围并且带有一定的偏移,这相当于是在四个顶点处添加一个小矩形。也就是八个小三角形。注意这里给这些三角形选择的颜色是(0,0,0,0)。也就是完全透明的处理。因为透明度会在渲染过程中与其他颜色进行混合。这种混合可以使矩形元素的边缘看起来更加平滑,减少锯齿。

这里还提到了像素对齐,它主要用于确保UI元素和其他屏幕空间对象在渲染时保持清晰和锐利。像素对齐的主要目的是避免由于浮点数计算和舍入误差导致的图像模糊和锯齿。像素对齐通过将对象的坐标和尺寸四舍五入到最接近的整数值,来确保对象在屏幕上的像素边界上对齐。更加复杂的是关于线段图元的生成,在这里就不再过多赘述,有兴趣的小伙伴可以再去看看。

到这一步FSlateDrawElement对象就已经完全转化为FSlateRenderBatch,这样所有的渲染数据就准备好并存储到了FSlateBatchData中。接下来进入到渲染线程中!

Slate在渲染线程中都做了什么?
还是来看看FSlateRHIRenderer::DrawWindow_RenderThread都做了什么,由于这个函数内做的事情相当之多,这里只关注一些关键流程。具体流程如下所示:

void FSlateRHIRenderer::DrawWindow_RenderThread(FRHICommandListImmediate&RHICmdList,FViewportInfo&ViewportInfo,FSlateWindowElementList&WindowElementList,conststructFSlateDrawWindowCommandParams&DrawCommandParams){
    ......
    FSlateBatchData&BatchData=WindowElementList.GetBatchData();
    RenderingPolicy->BuildRenderingBuffers(RHICmdList,BatchData);
    .....
    RHICmdList.BeginDrawingViewport(ViewportInfo.ViewportRHI,FTextureRHIRef());
    .......
    boolbHasBatches=BatchData.GetRenderBatches().Num()>0;
    if(bHasBatches || bClear){
        RHICmdList.BeginRenderPass(RPInfo, TEXT("SlateBatches"));
        if(bHasBatches){
            FSlateBackBufferBackBufferTarget(BackBuffer, FIntPoint(ViewportWidth, ViewportHeight));
            FSlateRenderingParamsRenderParams(ViewMatrix * ViewportInfo.ProjectionMatrix, DrawCommandParams.WorldTimeSeconds, DrawCommandParams.DeltaTimeSeconds, DrawCommandParams.RealTimeSeconds);
            RenderParams.bWireFrame =!!SlateWireFrame;
            RenderParams.bIsHDR =ViewportInfo.bHDREnabled;
            FTexture2DRHIRefEmptyTarget;
            RenderingPolicy->DrawElements(
                RHICmdList,
                ......
                BatchData.GetFirstRenderBatchIndex(),
                BatchData.GetRenderBatches(),
                RenderParams
            );
        }
    }
    ........
    RHICmdList.EndDrawingViewport(ViewportInfo.ViewportRHI,true,DrawCommandParams.bLockToVsync);
    ........
}

首先看向FSlateRHIRenderingPolicy::BuildRenderingBuffers,其主要作用是根据给定的RenderBatch对象来构建渲染数据(顶点数据以及索引等等),完成前期数据的准备工作。代码如下所示:

void FSlateRHIRenderingPolicy::BuildRenderingBuffers(FRHICommandListImmediate&RHICmdList,FSlateBatchData&InBatchData){
    .........
    InBatchData.MergeRenderBatches();
    constFSlateVertexArray&FinalVertexData=InBatchData.GetFinalVertexData();
    constFSlateIndexArray&FinalIndexData=InBatchData.GetFinalIndexData();
    constint32NumVertices=FinalVertexData.Num();
    constint32NumIndices=FinalIndexData.Num();
    if(InBatchData.GetRenderBatches().Num()>0&&NumVertices>0&&NumIndices>0){
        boolbShouldShrinkResources=false;
        boolbAbsoluteIndices=CVarSlateAbsoluteIndices.GetValueOnRenderThread()!=0;
        MasterVertexBuffer.PreFillBuffer(NumVertices, bShouldShrinkResources);
        MasterIndexBuffer.PreFillBuffer(NumIndices, bShouldShrinkResources);
        RHICmdList.EnqueueLambda([VertexBuffer=MasterVertexBuffer.VertexBufferRHI.GetReference(),IndexBuffer=MasterIndexBuffer.IndexBufferRHI.GetReference(),
            &InBatchData,
            bAbsoluteIndices
        ](FRHICommandListImmediate&InRHICmdList)
        {
            ........
            constFSlateVertexArray&LambdaFinalVertexData=InBatchData.GetFinalVertexData();
            constFSlateIndexArray&LambdaFinalIndexData=InBatchData.GetFinalIndexData();
            constint32NumBatchedVertices=LambdaFinalVertexData.Num();
            constint32NumBatchedIndices=LambdaFinalIndexData.Num();
            uint32RequiredVertexBufferSize=NumBatchedVertices*sizeof(FSlateVertex);
            uint8*VertexBufferData=(uint8*)InRHICmdList.LockVertexBuffer(VertexBuffer,0,RequiredVertexBufferSize, RLM_WriteOnly);
            uint32RequiredIndexBufferSize=NumBatchedIndices*sizeof(SlateIndex);
            uint8*IndexBufferData=(uint8*)InRHICmdList.LockIndexBuffer(IndexBuffer,0,RequiredIndexBufferSize, RLM_WriteOnly);
            FMemory::Memcpy(VertexBufferData,LambdaFinalVertexData.GetData(),RequiredVertexBufferSize);
            FMemory::Memcpy(IndexBufferData,LambdaFinalIndexData.GetData(),RequiredIndexBufferSize);
            InRHICmdList.UnlockVertexBuffer(VertexBuffer);
            InRHICmdList.UnlockIndexBuffer(IndexBuffer);
        });
        RHICmdList.RHIThreadFence(true);
    }
    ........
}
  • 调用FSlateBatchData::MergeRenderBatches来完成RenderBatch的合批,具体做了在后面展开。

  • 从RenderBatch中获取顶点和索引数量,如果有RenderBatchData不为空并且顶点和索引数量大于0,则继续往下操作。

  • 根据顶点和索引数量调用PreFillBuffer预填充来MasterVertexBuffer和MasterIndexBuffer一次性分配好足够的内存空间。

  • 调用FRHICommandListImmediate::EnqueueLambda将一个Lambda表达式添加到RHICmdList中,以便在后续流程中执行,该Lambda完成以下操作:

    • 获取最终的顶点数据和索引数据,并计算最终所需的顶点缓冲和索引缓冲大小。

    • 调用LockXXXXBuffer来对顶点和索引数据上锁,主要是阻止其他线程访问这些数据,这样在修改内容时不会有其他线程同时读取或修改,避免了数据竞争和脏数据。

    • 并从RenderBatch获取顶点数据和索引数据直接拷贝到上述对象中,再将其解锁,可以让其他线程正常访问。

    • 最后调用FRHICommandListBase::RHIThreadFence添加一个Fence,可用于确保渲染线程已经完成了对顶点缓冲和索引缓冲的读写后,其他线程才会继续执行。

注意这里的操作都只是缓存到RHICommandList,实际是并没有马上执行的。接下来就是FRHICommandList::BeginDrawingViewport。代码如下所示:

void FRHICommandList::BeginDrawingViewport(FRHIViewport*Viewport,FRHITexture*RenderTargetRHI){
    ......
    ALLOC_COMMAND(FRHICommandBeginDrawingViewport)(Viewport,RenderTargetRHI);
    if(!IsRunningRHIInSeparateThread()){
        .........
        FRHICommandListExecutor::GetImmediateCommandList().ImmediateFlush(EImmediateFlushType::FlushRHIThread);
    }
}
  1. 使用ALLOC_COMMAND向CommandList添加一个FRHICommandBeginDrawingViewport命令(这个宏会根据当前的RHI实现(如DirectX, OpenGL或Vulkan)来调用相应的RHIBeginDrawingViewport实现),该命令将在稍后执行以开始渲染视口。

  2. 检查是否有RHI线程。如果没有运行RHI线程,那么就没有将视口绘制操作缓存的理由,因为这会使状态管理变得复杂。因此在这种情况下,立即调用FRHICommandListExecutor::GetImmediateCommandList().ImmediateFlush(EImmediateFlushType::FlushRHIThread)来刷新并执行CommandList中的所有命令。

需要注意的是如果是在Window编辑器情况下会走直接Flush的路径,如果有RHI线程都不会走到这个Flush路径。这一块可能在不同环境下会有不同的流程,需要大家注意一下。后面就到FSlateRHIRenderingPolicy::DrawElements操作,这也是一个超大的函数体,还是老规矩挑一些最重要的逻辑来看看,具体代码如下所示:

void FSlateRHIRenderingPolicy::DrawElements(FRHICommandListImmediate&RHICmdList,FSlateBackBuffer&BackBuffer,
    ........
    int32 FirstBatchIndex,
    constTArray<FSlateRenderBatch>&RenderBatches,
    constFSlateRenderingParams&Params){
        .......
        while(NextRenderBatchIndex!= INDEX_NONE){
            VertexBufferPtr=&MasterVertexBuffer;
            IndexBufferPtr=&MasterIndexBuffer;
            .....
            constFSlateRenderBatch&RenderBatch=RenderBatches[NextRenderBatchIndex];
            NextRenderBatchIndex=RenderBatch.NextBatchIndex;
            constFSlateShaderResource*ShaderResource=RenderBatch.ShaderResource;
            constESlateBatchDrawFlagDrawFlags=RenderBatch.DrawFlags;
            constESlateDrawEffectDrawEffects=RenderBatch.DrawEffects;
            constESlateShaderShaderType=RenderBatch.ShaderType;
            constFShaderParams&ShaderParams=RenderBatch.ShaderParams;
            if(EnumHasAllFlags(DrawFlags,ESlateBatchDrawFlag::Wireframe)){
                GraphicsPSOInit.RasterizerState=TStaticRasterizerState<FM_Wireframe, CM_None,false>::GetRHI();
            }
            else{
                GraphicsPSOInit.RasterizerState=TStaticRasterizerState<FM_Solid, CM_None,false>::GetRHI();
            }
            ......
            if(!RenderBatch.CustomDrawer){
                ......
                constuint32PrimitiveCount=RenderBatch.DrawPrimitiveType==ESlateDrawPrimitive::LineList?RenderBatch.NumIndices/2:RenderBatch.NumIndices/3;
                ESlateShaderResource::TypeResourceType=ShaderResource?ShaderResource->GetType():ESlateShaderResource::Invalid;
                if(ResourceType!=ESlateShaderResource::Material&&ShaderType!=ESlateShader::PostProcess){
                    ......
                    TShaderRef<FSlateElementPS>PixelShader;
                    constboolbUseInstancing=RenderBatch.InstanceCount>1&&RenderBatch.InstanceData!= nullptr;
                    .......
                    {
                        PixelShader=GetTexturePixelShader(ShaderMap,ShaderType,DrawEffects);
                    }
                    ........
                    {
                        GraphicsPSOInit.BlendState=EnumHasAllFlags(DrawFlags,ESlateBatchDrawFlag::NoBlending)
                        ?TStaticBlendState<>::GetRHI():(EnumHasAllFlags(DrawFlags,ESlateBatchDrawFlag::PreMultipliedAlpha)
                            ?TStaticBlendState<CW_RGBA, BO_Add, BF_One, BF_InverseSourceAlpha, BO_Add, BF_One, BF_InverseSourceAlpha>::GetRHI()
                            :TStaticBlendState<CW_RGBA, BO_Add, BF_SourceAlpha, BF_InverseSourceAlpha, BO_Add, BF_One, BF_InverseSourceAlpha>::GetRHI())
                        ;
                    }
                    if(EnumHasAllFlags(DrawFlags,ESlateBatchDrawFlag::Wireframe)||Params.bWireFrame){
                        GraphicsPSOInit.RasterizerState=TStaticRasterizerState<FM_Wireframe, CM_None,false>::GetRHI();
                        if(Params.bWireFrame){
                            GraphicsPSOInit.BlendState=TStaticBlendState<>::GetRHI();
                        }
                    }
                    else{
                        GraphicsPSOInit.RasterizerState=TStaticRasterizerState<FM_Solid, CM_None,false>::GetRHI();
                    }
                    GraphicsPSOInit.BoundShaderState.VertexDeclarationRHI=GSlateVertexDeclaration.VertexDeclarationRHI;
                    GraphicsPSOInit.BoundShaderState.VertexShaderRHI=GlobalVertexShader.GetVertexShader();
                    GraphicsPSOInit.BoundShaderState.PixelShaderRHI=PixelShader.GetPixelShader();
                    GraphicsPSOInit.PrimitiveType=GetRHIPrimitiveType(RenderBatch.DrawPrimitiveType);
                    SetGraphicsPipelineState(RHICmdList,GraphicsPSOInit);
                    RHICmdList.SetStencilRef(StencilRef);
                    FRHISamplerState*SamplerState=BilinearClamp;
                    FRHITexture*TextureRHI=GWhiteTexture->TextureRHI;
                    // 渲染纹理资源处理
                    if(ShaderResource){......}
                    // 采样状态处理
                    {.....}
                    {
                        GlobalVertexShader->SetViewProjection(RHICmdList,ViewProjection);
                        GlobalVertexShader->SetVerticalAxisMultiplier(RHICmdList, bSwitchVerticalAxis ?-1.0f:1.0f);
                        PixelShader->SetTexture(RHICmdList,TextureRHI,SamplerState);
                        PixelShader->SetShaderParams(RHICmdList,ShaderParams.PixelParams);
                        constfloatFinalGamma=EnumHasAnyFlags(DrawFlags,ESlateBatchDrawFlag::ReverseGamma)?(1.0f/EngineGamma):EnumHasAnyFlags(DrawFlags,ESlateBatchDrawFlag::NoGamma)?1.0f:DisplayGamma;
                        constfloatFinalContrast=EnumHasAnyFlags(DrawFlags,ESlateBatchDrawFlag::NoGamma)?1:DisplayContrast;
                        PixelShader->SetDisplayGammaAndInvertAlphaAndContrast(RHICmdList,FinalGamma,EnumHasAllFlags(DrawEffects,ESlateDrawEffect::InvertAlpha)?1.0f:0.0f,FinalContrast);
                    }
                    {
                        RHICmdList.SetStreamSource(0,VertexBufferPtr->VertexBufferRHI,RenderBatch.VertexOffset*sizeof(FSlateVertex));
                        RHICmdList.DrawIndexedPrimitive(IndexBufferPtr->IndexBufferRHI,0,0,RenderBatch.NumVertices,RenderBatch.IndexOffset,PrimitiveCount,RenderBatch.InstanceCount);
                    }
                }
                ........
            }else{.....}
        }
        ..........
}

遍历所有的RenderBatch,每个RenderBatch代表一组具有相同状态和属性的绘制操作,一次性渲染可提高性能。并且缓存NextRenderBatchIndex,因为下一个RenderBatch并不是都对应的索引+1,因为渲染顺序或者合批处理可能会跳过多个RenderBatch,针对每个RenderBatch的操作如下:

  1. 从RenderBatch获取相关的信息,如ShaderResource、DrawFlags、DrawEffects、ShaderType和ShaderParams等等。

  2. 设置RasterizerState,如果DrawFlags中包含Wireframe,则使用线框模式。

  3. 根据DrawEffects和ShaderType获取对应的PixelShader。

  4. 设置BlendState,根据DrawFlags来决定是否使用预乘Alpha或AlphaBlend。

  5. 设置PSO对象,其中包括顶点数据声明、顶点着色器、像素着色器和图元类型。

  6. 为顶点着色器设置视线矩阵和投影矩阵,为像素着色器设置纹理资源、采样器以及是否需要Gamma矫正等属性设置。

  7. 调用FRHICommandList::SetStreamSource来设置顶点缓冲以及其偏移,以便从中读取顶点数据。

  8. 调用FRHICommandList::DrawIndexedPrimitive完成渲染操作。

如果是对图形API有所了解的同学,应该很容易就能看出,这就是一次完整渲染流程,包括设置Shader以及PSO对象以及最后的DrawCall调用。当然里面还有不少细节没有写出来,比如设置裁剪矩形、如何选择对应的采样器等。但是大体流程上是这样就不在赘述。DrawCall调用代码如下所示:

void DrawIndexedPrimitive(FRHIIndexBuffer* IndexBuffer, int32 BaseVertexIndex, uint32 FirstInstance, uint32 NumVertices, uint32 StartIndex, uint32 NumPrimitives, uint32 NumInstances) s{
    if(!IndexBuffer){
        UE_LOG(LogRHI,Fatal, TEXT("Tried to call DrawIndexedPrimitive with null IndexBuffer!"));
    }
    .......
    ALLOC_COMMAND(FRHICommandDrawIndexedPrimitive)(IndexBuffer,BaseVertexIndex,FirstInstance,NumVertices,StartIndex,NumPrimitives,NumInstances);
}

其实这里很简单,就是调用ALLOC_COMMAND来往CommandList中放入FRHICommandDrawIndexedPrimitive命令。这个FRHICommandDrawIndexedPrimitive会根据当前使用的图形API来进行分发操作,比如Vulkan最后会调用到FVulkanCommandListContext::RHIDrawIndexedPrimitive,其他的图形API也是类似情况。

最后就到了FRHICommandList::EndDrawingViewport,代码如下所示:

void FRHICommandList::EndDrawingViewport(FRHIViewport*Viewport, bool bPresent, bool bLockToVsync){
    ......
    ALLOC_COMMAND(FRHICommandEndDrawingViewport)(Viewport, bPresent, bLockToVsync);
    if(IsRunningRHIInSeparateThread()){
        GRHIThreadEndDrawingViewportFences[GRHIThreadEndDrawingViewportFenceIndex]= static_cast<FRHICommandListImmediate*>(this)->RHIThreadFence();
    }
    {
        FRHICommandListExecutor::GetImmediateCommandList().ImmediateFlush(EImmediateFlushType::DispatchToRHIThread);
    }
    if(IsRunningRHIInSeparateThread()){
        uint32PreviousFrameFenceIndex=1-GRHIThreadEndDrawingViewportFenceIndex;
        FGraphEventRef&LastFrameFence=GRHIThreadEndDrawingViewportFences[PreviousFrameFenceIndex];
        FRHICommandListExecutor::WaitOnRHIThreadFence(LastFrameFence);
        GRHIThreadEndDrawingViewportFences[PreviousFrameFenceIndex]= nullptr;
        GRHIThreadEndDrawingViewportFenceIndex=PreviousFrameFenceIndex;
    }
    RHIAdvanceFrameForGetViewportBackBuffer(Viewport);
}
  1. 调用ALLOC_COMMAND(FRHICommandEndDrawingViewport)(……)向CommandList添加一个FRHICommandEndDrawingViewport命令,这个结束对指定视口的渲染,并根据参数决定是否呈现结果以及是否垂直同步(Vsync)。

  2. 检查是否有RHI线程,如果有则创建一个Fence,这个Fence将在RHI线程中触发,以确保渲染线程和RHI线程按照预期顺序执行。将新的Fence赋值给GRHIThreadEndDrawingViewportFences[GRHIThreadEndDrawingViewportFenceIndex],表示将这个Fence插入到渲染线程和RHI线程之间。

  3. 无论是否有RHI线程,都会调用FImmediateFlush(EImmediateFlushType::DispatchToRHIThread)。这个调用的作用是立即刷新并执行当前的Command List。这可以确保视口绘制操作按照预期顺序和时间点执行。

  4. 检查是否有RHI线程,如果有则需要确保渲染线程不会超过RHI线程一帧。为此需要等待上一帧的RHI线程的Fence,以确保渲染线程在RHI线程完成上一帧的所有命令后才会继续执行。随后再更新GRHIThreadEndDrawingViewportFenceIndex,并清空上一帧的Fence,以便在下一帧时使用正确的Fence。

  5. 最后调用RHIAdvanceFrameForGetViewportBackBuffer,让当前视口的BackBuffer做好进入下一帧的准备,它确保在每次调用EndDrawingViewport时都使用到正确的BackBuffer。

到这里Slate的渲染流程就全部结束了,看下来是不是很简单?其实从渲染层面都还是那几步,准备数据、提交渲染Command、GPU完成最后的渲染。其实整个渲染流程还是比较清晰的,就是从RenderBatch中获取所需的渲染数据,再提交渲染数据和调用DrawCall。对于这一层来说应该鲜有优化的空间,如果需要优化Slate,可能需要从更上层开始,比如下面就要开始讲到的合批操作。

六、LayerID和合批操作

接下来看看Slate是怎么做到合批操作的!首先讲讲LayerID,LayerID是Slate框架中的一个重要概念,决定了Element在渲染过程中的绘制顺序。具有较低LayerID的元素将先于具有较高LayerID的元素绘制。这意味着当两个或多个Element在屏幕上的位置发生重叠时,具有较高LayerID的元覆盖在具有较低LayerID之上。这样可以确保Element按照预期的顺序绘制,例如位于顶层的UI元素将覆盖在底层元素之上。并且LayerID能够决定Slate是否能够合批,因为必须保证是正确的渲染顺序,只要具有相同LayerID的元素将被一起合批处理。

在之前的流程中经常看的LayerID的场景是在OnPaint中,因为LayerID是在Slate控件的渲染流程中传递的,不同的控件在OnPaint中针对于LayerID可能有不同的操作。首先在SWindow::PaintSlowPath中LayerID初始化为0,随后就开始递归调用子控件的OnPaint。接下来就来看看不同的控件针对LayerID都会做出什么修改。还是从基础控件开始。

首先就是SCompoundWidget,代码如下所示。如果没有子控件则直接返回传递过来的LayerID,如果有子控件,它会使得子控件的LayerID+1。

int32 SCompoundWidget::OnPaint(constFPaintArgs&Args,constFGeometry&AllottedGeometry,constFSlateRect&MyCullingRect,FSlateWindowElementList&OutDrawElements, int32 LayerId,constFWidgetStyle&InWidgetStyle, bool bParentEnabled)const{
    .......
    if(ArrangedChildren.Num()>0){
        FArrangedWidget&TheChild=ArrangedChildren[0];
        ......
        int32Layer=0;
        Layer=TheChild.Widget->Paint(Args.WithNewParent(this),TheChild.Geometry,MyCullingRect,OutDrawElements,LayerId+1,CompoundedWidgetStyle,ShouldBeEnabled(bParentEnabled));
        returnLayer;
    }
    returnLayerId;
}

接着就是SPanel组件,它会给所有的子控件传递相同的LayerID,并每次调用后将返回的LayerID和与MaxLayerId进行比较,并从中用更大的值来更新MaxLayerId。这样可以确保在遍历所有子控件后,MaxLayerId表示所有子控件中使用的最高层级。这个目的是为了保持正确的渲染顺序。当一个控件需要在其所有子控件之上绘制内容(例如Overlap或Border)时,可以使用MaxLayerId作为初始LayerID,这样可以确保重叠的内容始终在所有子控件之上,并且子控件之间的渲染顺序得以保留。代码如下所示:

int32 SPanel::OnPaint(constFPaintArgs&Args,constFGeometry&AllottedGeometry,constFSlateRect&MyCullingRect,FSlateWindowElementList&OutDrawElements, int32 LayerId,constFWidgetStyle&InWidgetStyle, bool bParentEnabled )const{
    ..........
    returnPaintArrangedChildren(Args,ArrangedChildren,AllottedGeometry,MyCullingRect,OutDrawElements,LayerId,InWidgetStyle, bParentEnabled);
    }
int32 SPanel::PaintArrangedChildren(constFPaintArgs&Args,constFArrangedChildren&ArrangedChildren,constFGeometry&AllottedGeometry,constFSlateRect&MyCullingRect,FSlateWindowElementList&OutDrawElements, int32 LayerId,constFWidgetStyle&InWidgetStyle, bool bParentEnabled  )const{
    int32MaxLayerId=LayerId;
    .........
        for(int32ChildIndex=0;ChildIndex<ArrangedChildren.Num();++ChildIndex){
        constFArrangedWidget&CurWidget=ArrangedChildren[ChildIndex];
        ......
        constint32CurWidgetsMaxLayerId=CurWidget.Widget->Paint(NewArgs,CurWidget.Geometry,MyCullingRect,OutDrawElements,LayerId,InWidgetStyle, bShouldBeEnabled);
        MaxLayerId=FMath::Max(MaxLayerId,CurWidgetsMaxLayerId);
        .......
    }
    returnMaxLayerId;
}

然后就是SImage,它的处理很简单就是直接返回传递的LayerID。因为SImage是在乎渲染图片本身,本身没有复杂的层级结构,所以无需增加LayerID。

int32 SImage::OnPaint(constFPaintArgs&Args,constFGeometry&AllottedGeometry,constFSlateRect&MyCullingRect,FSlateWindowElementList&OutDrawElements, int32 LayerId,constFWidgetStyle&InWidgetStyle, bool bParentEnabled )const{
    ......
    FSlateDrawElement::MakeBox(OutDrawElements,LayerId,.......);
    ......
    returnLayerId;
}

再来看看STextBlock,它的流程会更加复杂一点,分为两种情况:

  • 如果是开了SimpleTextMode的,就在不开文字阴影的情况下LayerID不增加并直接返回,如果开了阴影则会LayerID+1并返回。

  • 如果未开启SimpleTextMode的情况下,文字每多一行则LayerID+1。

所以一般建议STextBlock都勾选SimpleTextMode上,能够尽可能避免LayerID不同带来的合批困难。具体代码如下所示:

int32 STextBlock::OnPaint(constFPaintArgs&Args,constFGeometry&AllottedGeometry,constFSlateRect&MyCullingRect,FSlateWindowElementList&OutDrawElements, int32 LayerId,constFWidgetStyle&InWidgetStyle, bool bParentEnabled )const{
    .......
    if(bSimpleTextMode){
        if(ShouldDropShadow){
            ......
            FSlateDrawElement::MakeText(OutDrawElements,LayerId,......);
            ++LayerId;
        }
        FSlateDrawElement::MakeText(OutDrawElements,LayerId,....);
    }else{
        LayerId=TextLayoutCache->OnPaint(Args,AllottedGeometry,MyCullingRect,OutDrawElements,LayerId,InWidgetStyle,ShouldBeEnabled(bParentEnabled));
    }
    .......
    returnLayerId;
}

int32 FSlateTextLayout::OnPaint(constFPaintArgs&Args,constFGeometry&AllottedGeometry,constFSlateRect&MyCullingRect,FSlateWindowElementList&OutDrawElements, int32 LayerId,constFWidgetStyle&InWidgetStyle, bool bParentEnabled )const{
    ......
    int32HighestLayerId=LayerId;
    for(constFTextLayout::FLineView&LineView:LineViews){
        ......
        HighestRunLayerId=RunRenderer->OnPaint(Args,LineView,Run,Block,DefaultTextStyle,AllottedGeometry,MyCullingRect,OutDrawElements,TextLayer,InWidgetStyle, bParentEnabled );
        .......
        HighestLayerId=FMath::Max(HighestLayerId,HighestRunLayerId);
    }
    returnHighestLayerId;
}

int32 FSlateTextRun::OnPaint(constFPaintArgs&Args,constFTextLayout::FLineView&Line,constTSharedRef<ILayoutBlock>&Block,constFTextBlockStyle&DefaultStyle,constFGeometry&AllottedGeometry,constFSlateRect&MyCullingRect,FSlateWindowElementList&OutDrawElements, int32 LayerId,constFWidgetStyle&InWidgetStyle, bool bParentEnabled )const{
    ......
    FSlateDrawElement::MakeShapedText(OutDrawElements,++LayerId,......);
    returnLayerId;
}

在Slate中还有一个重要的概念是ZOrder(在某些控件上才有),ZOrder指定了子控件在渲染过程中的顺序。具有较小ZOrder的控件将首先被绘制,而具有较大ZOrder的控件将在较低ZOrder值的控件上绘制。这可以确保在控件重叠时,正确的控件显示在顶部。其实ZOrder就是通过最终影响LayerID的大小来完成渲染顺序的控制,接下来看看它是怎么做到的。使用SConstraintCanvas来举例。代码如下所示:

void SConstraintCanvas::ArrangeLayeredChildren(constFGeometry&AllottedGeometry,FArrangedChildren&ArrangedChildren,FArrangedChildLayers&ArrangedChildLayers)const{
    ......
    TArray<FChildZOrder,TInlineAllocator<64>>SlotOrder;
    SlotOrder.Reserve(Children.Num());
    for(int32ChildIndex=0;ChildIndex<Children.Num();++ChildIndex){
        constSConstraintCanvas::FSlot&CurChild=Children[ChildIndex];
        FChildZOrderOrder;
        Order.ChildIndex=ChildIndex;
        Order.ZOrder=CurChild.ZOrderAttr.Get();
        SlotOrder.Add(Order);
    }
    SlotOrder.Sort(FSortSlotsByZOrder());
    floatLastZOrder=-FLT_MAX;
    for(int32ChildIndex=0;ChildIndex<Children.Num();++ChildIndex){
        constFChildZOrder&CurSlot=SlotOrder[ChildIndex];
        constSConstraintCanvas::FSlot&CurChild=Children[CurSlot.ChildIndex];
        constTSharedRef<SWidget>&CurWidget=CurChild.GetWidget();
        ........
            boolbNewLayer=true;
            if(bExplicitChildZOrder){
                bNewLayer =false;
                if(CurSlot.ZOrder>LastZOrder+ DELTA){
                    if(ArrangedChildLayers.Num()>0){
                        bNewLayer =true;
                    }
                    LastZOrder=CurSlot.ZOrder;
                }
            }
            ArrangedChildLayers.Add(bNewLayer);
        }
    }
}

int32 SConstraintCanvas::OnPaint(constFPaintArgs&Args,constFGeometry&AllottedGeometry,constFSlateRect&MyCullingRect,FSlateWindowElementList&OutDrawElements, int32 LayerId,constFWidgetStyle&InWidgetStyle, bool bParentEnabled )const{
    FArrangedChildrenArrangedChildren(EVisibility::Visible);
    FArrangedChildLayersChildLayers;
    ArrangeLayeredChildren(AllottedGeometry,ArrangedChildren,ChildLayers);
    .....
    int32MaxLayerId=LayerId;
    int32ChildLayerId=LayerId;
    for(int32ChildIndex=0;ChildIndex<ArrangedChildren.Num();++ChildIndex){
        FArrangedWidget&CurWidget=ArrangedChildren[ChildIndex];
        if(!IsChildWidgetCulled(MyCullingRect,CurWidget)){
            if(ChildLayers[ChildIndex]){
                ChildLayerId=MaxLayerId+1;
            }
            constint32CurWidgetsMaxLayerId=CurWidget.Widget->Paint(NewArgs,CurWidget.Geometry,MyCullingRect,OutDrawElements,ChildLayerId,InWidgetStyle, bForwardedEnabled);
            MaxLayerId=FMath::Max(MaxLayerId,CurWidgetsMaxLayerId);
        }
    }
    returnMaxLayerId;
}

在ArrangeLayeredChildren中会遍历所有子控件并将它们的索引和ZOrder添加到SlotOrder数组中。接着对SlotOrder数组排序,以便按照ZOrder对子控件进行排序。最后按照ZOrder从小到大的顺序来遍历每个子控件。针对每个子控件完成以下操作:

  • 未开启ExplicitChildZOrder,则每个子控件就算单独拥有一个Layer,设置bNewLayer为true并放入数组。

  • 开启ExplicitChildZOrder,只有当子控件的ZOrder大于上一个子控件的ZOrder时,才会创建新的Layer。

最后就会得到一个ArrangedChildLayers数组,回到OnPaint中的逻辑来,它遍历ArrangedChildren列表中的所有子控件,并完成以下操作:

  • 检查它们是否在MyCullingRect之外,如果是则被剔除。对于未被剔除的子控件,根据ChildLayers中的信息来确定子控件的LayerId。如果ChildLayers[ChildIndex]为true,则在当前最大MaxLayerID的基础上递增,并提供传入子控件的OnPaint逻辑中,否则子控件将使用相同的LayerId。

  • 在绘制每个子控件时,将返回CurWidgetsMaxLayerId与MaxLayerId进行比较,并将更大的值来更新MaxLayerId。这样可以确保下一个子控件始终在当前最大MaxLayerId之上绘制。

所以从这里可以看出ZOrder对于LayerID的影响,如果没有开ExplicitChildZOrder,每多一个子控件将递增LayerID,这对于Slate的合批操作肯定是不利的,所以随意使用ZOrder会对整体UI性能有所影响。并且ZOrder将影响最后的控件的显示效果,渲染结果可能会将不符合父子控件的层级结构。所以这也会造成一部分的理解成本,所以有些项目内可能会在编辑器内关闭ZOrder相关属性的可编辑性,完成由代码控制,避免美术同学不规范的操作。

到这里了你应该对于LayerID计算流程有一定的了解,下面用一些更加实际的内容帮助你理解。下图是一个比较简单的控件树,左侧是输入该控件的LayerID,右侧是该控件最后返回的LayerID,如下图所示:

下面还有一个更加复杂的场景,也就是SConstraintCanvas并且包含ZOrder的情况下LayerID的构成,如下图所示:

相信理解了这些内容,应该对LayerID这个概念有了清楚的认知以及它在Slate框架中发挥的作用,接下来看看Slate的合批具体是怎么操作的。

七、Slate的合批操作

Slate合批操作代码如下所示:

void FSlateBatchData::MergeRenderBatches(){
        .......
        TArray<TPair<int32, int32>,TInlineAllocator<100,TMemStackAllocator<>>>BatchIndices;
        {
            BatchIndices.AddUninitialized(RenderBatches.Num());
            for(int32Index=0;Index<RenderBatches.Num();++Index){
                BatchIndices[Index].Key=Index;
                BatchIndices[Index].Value=RenderBatches[Index].GetLayer();
            }
            BatchIndices.StableSort([](constTPair<int32, int32>& A,constTPair<int32, int32>& B){
                return A.Value< B.Value;
            });
        }
        ......
        FirstRenderBatchIndex=BatchIndices[0].Key;
        FSlateRenderBatch*PrevBatch= nullptr;
        for(int32BatchIndex=0;BatchIndex<BatchIndices.Num();++BatchIndex){
            constTPair<int32, int32>&BatchIndexPair=BatchIndices[BatchIndex];
            FSlateRenderBatch&CurBatch=RenderBatches[BatchIndexPair.Key];
            if(CurBatch.bIsMerged ||!CurBatch.IsValidForRendering()){
                continue;
            }
            .......
            if(PrevBatch!= nullptr){
                PrevBatch->NextBatchIndex=BatchIndexPair.Key;
            }
            FillBuffersFromNewBatch(CurBatch,FinalVertexData,FinalIndexData);
            .....
            if(CurBatch.bIsMergable){
                for(int32TestIndex=BatchIndex+1;TestIndex<BatchIndices.Num();++TestIndex){
                    constTPair<int32, int32>&NextBatchIndexPair=BatchIndices[TestIndex];
                    FSlateRenderBatch&TestBatch=RenderBatches[NextBatchIndexPair.Key];
                    if(TestBatch.GetLayer()!=CurBatch.GetLayer()){
                        break;
                    }
                    elseif(!TestBatch.bIsMerged &&CurBatch.IsBatchableWith(TestBatch)){
                        CombineBatches(CurBatch,TestBatch,FinalVertexData,FinalIndexData);
                        check(TestBatch.NextBatchIndex== INDEX_NONE);
                    }
                }
            }
            PrevBatch=&CurBatch;
        }
    }
}
  1. 创建名为BatchIndices的数组,并遍历所有RenderBatch并将它们的索引和Layer添加到BatchIndices中。接着对BatchIndices进行稳定排序以便按Layer对RenderBatch进行排序。

  2. 初始化PrevBatch为空。遍历排序后的BatchIndices。对于每个RenderBatch,执行以下操作:

  • 检查RenderBatch是否已合并或是否需要渲染。如果已合批或无需渲染,则跳过该RenderBatch的处理。

  • PrevBatch不为空的情况下,将当前RenderBatch的索引赋值给PrevBatch的NextBatchIndex。

  • 调用FSlateBatchData::FillBuffersFromNewBatch,将当前RenderBatch的顶点数据和索引数据添加到FinalVertexData和FinalIndexData中。

  • 如果当前RenderBatch可以合并(bIsMergable为true),则尝试将其与后续RenderBatch合并。遍历后续RenderBatch,检查它们是否与当前RenderBatch兼容。如果兼容则调用CombineBatches函数将两个RenderBatch合并,并将结果存储在当前的RenderBatch中。

  • 更新PrevBatch指针以指向当前RenderBatch。

其实整体的流程都很简单,就是遍历排序后的RenderBatch,尽可能合并所有的RenderBtach。还需要关注的是合批条件,可以看到在合批中首先判断的就是LayerID是否相同,如果不相同就直接结束这个循环。从这里可以看出LayderID的重要性,LayerID不同将无法合批,当然还有其他的条件需要判断,看看IsBatchableWith的实现,代码如下所示:

bool IsBatchableWith(const FSlateRenderBatch& Other)const{
    returnShaderResource==Other.ShaderResource
        &&DrawFlags==Other.DrawFlags
        &&ShaderType==Other.ShaderType
        &&DrawPrimitiveType==Other.DrawPrimitiveType
        &&DrawEffects==Other.DrawEffects
        &&ShaderParams==Other.ShaderParams
        &&InstanceData==Other.InstanceData
        &&InstanceCount==Other.InstanceCount
        &&InstanceOffset==Other.InstanceOffset
        &&DynamicOffset==Other.DynamicOffset
        &&CustomDrawer==Other.CustomDrawer
        &&SceneIndex==Other.SceneIndex
        &&ClippingState==Other.ClippingState;
}

根据以上实现,再来看看影响合批的因素,如下所示:

  • ShaderResource:使用的资源不一致不能合批,比如不同的SImage如果使用的图片或者图集不一样,就不能合批。

  • DrawFlags:绝大部分情况下都是None,但是BoxElement假如是Tiling模式下会设置为ESlateBatchDrawFlag::TileU或者ESlateBatchDrawFlag::TileV,BorderElement则是这两者皆有。还有一个特殊情况是QuadElement,它被设置为ESlateBatchDrawFlag::Wireframe | ESlateBatchDrawFlag::NoBlending。但是它一般用于Debug,所以可以忽略掉。

  • DrawPrimitiveType:图元类型不一致不能合批,但是基本所有的都是ESlateDrawPrimitive::TriangleList,只有渲染线并且厚度为1的情况下才会是ESlateDrawPrimitive::LineList,这种情况很少,一般可以忽略。

  • ShaderType:大部分默认都是ESlateShader::Deafult,STextBlock如果是普通文字的,使用的是ESlateShader::GrayscaleFont,彩色文字则使用的是ESlateShader::ColorFont,还有一个BorderElement使用的是ESlateShader::Border。

  • DrawEffects:一般情况下都是None,如果子控件或者父控件没有勾选IsEnable则会为是ESlateDrawEffect::DisabledEffect,还有在选择关闭像素对齐时会是NoPixelSnapping。还有SRetainerWidget这个控件是ESlateDrawEffect::PreMultipliedAlpha | ESlateDrawEffect::NoGamma。

  • ShaderParams:是传递给像素着色器的一些参数,大部分情况下都是默认值,只有BorderElement、SplineElement、LineElement这三个控件有时会有所不同。

  • InstanceData,InstanceCount,InstanceOffset:如果没有使用Instance,一般默认都是默认值,可忽略。

  • DynamicOffset:一般都是默认值,不会修改该值,可忽略。

  • CustomDrawer:默认都是空指针,只会在CustomElement中设置该值。

  • SceneIndex:在FSlateDrawElement初始化时设置,一般情况每个RenderBatch下都是相同的,可忽略。

  • ClippingState:一般情况下默认都是空指针,可忽略。

从上面的内容可以看出RenderBatch合批其实是一个比较困难的事情,从上面影响合批的因素,就可以总结出影响合批一些操作,如下所示:

  • 不同的SImage使用的图片或者图集不同,导致无法合批。

  • DrawAs选择Border则无法和选择了Box的控件合批。

  • 设置了Tilling的控件无法和其他控件合批。

当然还很有很多其他的因素,但是这些大部分可以通过一些UI制作规范来规避掉。但是其实说到底还是让LayerID相等的条件是最难的,因为影响LayerID的因素太多,并且不完全取决于控件本身的属性设置,而是来自于其父控件和子控件的各种层级排布。而且不同的控件针对LayerID的操作也不相同,比如上面提到的ZOrder对LayerID的影响。SConstraintCanvas还有ExplicitChildZOrder优化,但是SOverlay控件就是每个子控件都会递增LayerID,所以在平常开发中这是要尽量避免的。

而且LayerID本身是一个Slate独有的概念,重点是它的不直观,LayerID是完全通过Slate框架计算出来的,UI美术同事很难观察到LayerID到底是多少。并且它和它子控件的层级结构是很难对应上的,这很反直觉,比如上面提到的ZOrder,你排布好的控件层级结构,其实在设置了ZOrder可能完全超出你的预期。所以其实有不少项目会选择屏蔽ZOrder在编辑器内给美术同学控制。这种情况下连开发同学都很难来做排布这个控件的层级结构来完成合批,对于UI美术同学更是一个噩梦。更加重要的是可能程序同学精心排布好了一个复杂UI的层级,能够尽可能的合批,但是美术同学随便加了一个控件就可以摧毁之前的所有努力,这种挫败感是难以磨灭的。不过这固然是Slate的缺陷所在,但是考虑一下UE是做FPS游戏起家的,FPS本来就是UI比较轻度的游戏,换在MMO这种UI比较重度的游戏来说,这确实是一个需要解决的点。

不过也不用过于担心,既然合批很难,那就不做。首先是Unreal的适用场景一般都是主机场景,一般来说主机场景多一些DrawCall也是无伤大雅,去精心地排布UI层级来达到一个脆弱的合批,不如去优化别的模块,说不定收益还会更大一点。但是在移动端还是需要尽量的减少DrawCall的,可以减少性能损耗降低手机发热等等。

一些优化手段
当然上面描述了Slate合批的困难所在,但是还是可以做一些优化来弥补的,接下来就谈谈一些可行的优化手段。

比如LayerID的相等判断就很难通过。那就不判断LayerID。当然这是需要额外条件来做判断的。比如在合批的时候判断当前两个RenderBatch对应的控件是否有重叠的情况发生,这是一个很重要的判断,因为LayerID本身还能够处理UI渲染顺序的问题。所以当两个UI没有相互重叠的时候,那就可以忽略针对LayerID需要相等的判断,只需要保证满足IsBatchableWith的条件就可以直接合并RenderBatch,这样也不会有任何问题。

还有一个问题,当前Slate的渲染流程实际是在场景渲染之后的,所以这存在一定的浪费,因为场景渲染完之后,某些像素却会被Slate渲染出的UI再覆盖掉,这是一个可以优化的地方,可以考虑提前在这些被UI遮盖的地方写入深度,就可避免这个额外开销。当然这个具体实现还得思考,是把整个Slate渲染流程放在场景渲染之前,还是回读上一帧的结果,这个实现方式还需要思考下。

还有一个优化就是需要针对业务层,在游戏中当然会有一些全屏不透明的UI的,但是按照现在的渲染流程中,场景渲染和LayerID小于这个全屏UI的所有控件都会渲染一遍,但是实际上这都是多余的渲染,因为都会被这个全屏不透明UI给遮挡住,都是无意义的渲染。所以这里还可以做一层优化,就是我们需要给这些全屏不透明的UI打一个Tag,方便后续流程中能够识别出来。

当然Slate本身还推出了一些优化的方案,那就是RetainerBox与InvalidationBox,这两个优化手段其实原理都很简单,InvalidationBox是只更新发生过变化的UI,减少CPU侧的各种UI布局的计算等等,但是DrawCall还是没省下来,本质上还是使用那些RenderBatch去完成渲染。RetainerBox则是更加直接,直接将当前区域的UI渲染到一张RenderTarget上,然后再渲染到屏幕上,如果UI没有变化,就可以完全复用以减少DrawCall。

八、总结

本文深入探讨了Slate UI框架的渲染流程,结合关于Slate基础知识的文章,希望能帮助你对Slate框架的运行原理有一个完整且清晰的认识。尽管Slate在设计上显得简洁,但其实它的运行机制相当复杂。但它并非完美无缺。例如,Slate的LayerID设计使得整个框架的合批变得异常困难。这对于那些在追求渲染效率的开发者来说,可能会感到有些头痛。此外,Slate的链式调用也是一个挑战,它可能会对代码的调试和可读性产生负面影响。虽然Slate有其缺点,但它的优点仍然使其成为一个值得学习和使用的UI框架。


这是侑虎科技第1703篇文章,感谢作者不知名书杯供稿。欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)

作者主页:https://www.zhihu.com/people/lllzwj

再次感谢不知名书杯的分享,如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)