头图

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


一、前言

本文将深入探讨Slate的渲染流程及其相关细节。将详细讲解Slate如何将UI元素渲染到屏幕上,以及它是如何处理各种渲染细节以实现高效、灵活的UI渲染。此外还将讨论Slate的一些缺点,希望能够帮助你更全面地了解这个框架。如有错误还请多多评论指正。

二、渲染数据的准备

首先来看看渲染数据是怎么准备的,流程如下所示:

已知在FSlateApplication::PrivateDrawWindows会对每个SWindow调用FSlateApplication::DrawWindowAndChildren来收集其中的所有控件的图元信息,所以我们就从这里开始!

DrawWindowAndChildren
其实DrawWindowAndChildren内的做的事情很简单,就是去让每一个SWindow的所有子控件完成渲染。具体代码如下:

void FSlateApplication::DrawWindowAndChildren(constTSharedRef<SWindow>&WindowToDraw,FDrawWindowArgs&DrawWindowArgs){
    boolbDrawChildWindows= PLATFORM_MAC;
    if(bRenderOffScreen ||(WindowToDraw->IsVisible()&&(!WindowToDraw->IsWindowMinimized()||FApp::UseVRFocus()))){
        FSlateWindowElementList&WindowElementList=DrawWindowArgs.OutDrawBuffer.AddWindowElementList(WindowToDraw);
        int32MaxLayerId=0;
        ......
        MaxLayerId=WindowToDraw->PaintWindow(GetCurrentTime(),GetDeltaTime(),WindowElementList,......);
        .......
        bDrawChildWindows =true;
    }

    if(bDrawChildWindows){
        constTArray<TSharedRef<SWindow>>WindowChildren=WindowToDraw->GetChildWindows();
        for(int32 ChildIndex=0;ChildIndex<WindowChildren.Num();++ChildIndex){
            DrawWindowAndChildren(WindowChildren[ChildIndex],DrawWindowArgs);
        }
    }
}
  1. 首先判断当前窗口是否可见。如果窗口不可见且没有处于离屏渲染模式,那么跳过这个窗口的渲染。但是在Mac平台上,子窗口始终会被绘制,无论父窗口是否可见。
  2. 假如窗口可见的话,则完成以下操作:

    • 创建一个FSlateWindowElementList对象,用于存储本次渲染所有的图元信息。
    • 调用SWindow::PaintWindow函数来绘制窗口和其所有子控件,并将其添加到FSlateWindowElementList中。并且会返回一个LayerID。
    • 将bDrawChildWindows设置为true,父窗口都渲染了,子窗口当然也得渲染。
  3. 如果bDrawChildWindows为true,则遍历所有子窗口,并调用DrawWindowAndChildren来绘制每个子窗口。

PaintWindow
接下来就是SWindow::PaintWindow,来看看它都做了什么吧!代码如下所示:

int32 SWindow::PaintWindow(doubleCurrentTime,floatDeltaTime,FSlateWindowElementList&OutDrawElements,constFWidgetStyle&InWidgetStyle, bool bParentEnabled ){
    ......
    constboolHittestCleared=HittestGrid->SetHittestArea(GetPositionInScreen(),GetViewportSize());
    FPaintArgsPaintArgs(nullptr, GetHittestGrid(),GetPositionInScreen(),CurrentTime,DeltaTime);
    FSlateInvalidationContextContext(OutDrawElements, InWidgetStyle);
    Context.bParentEnabled = bParentEnabled;
    Context.bAllowFastPathUpdate = bAllowFastUpdate &&GSlateEnableGlobalInvalidation;
    Context.LayoutScaleMultiplier=FSlateApplicationBase::Get().GetApplicationScale()*GetDPIScaleFactor();
    Context.PaintArgs=&PaintArgs;
    Context.IncomingLayerId=0;
    Context.CullingRect=GetClippingRectangleInWindow();
    ......
    FSlateInvalidationResultResult=PaintInvalidationRoot(Context);
    .......
    returnResult.MaxLayerIdPainted;
}
  1. 首先调用SetHittestArea主要用于更新窗口的点击区域。这个区域包括窗口在屏幕中的位置以及窗口的大小。HittestGrid会根据窗口的大小和位置来判断是否需要更新点击区域。
  2. 根据HittestGrid来构建FPaintArgs对象,这是为了在渲染过程中提供有关点击区域的信息,确保控件可以访问到这些信息以正确处理用户输入事件。
  3. 创建一个FSlateInvalidationContext对象,用于存储与渲染相关的上下文信息,如图元列表、父控件的可见性以及上面的FPaintArgs对象。
  4. 调用PaintInvalidationRoot函数并传入FSlateInvalidationContext对象来绘制窗口及其所有子控件。

这个流程主要处理一些渲染上下文的设置,做好渲染所有控件的前期准备。

PaintInvalidationRoot
接着来到PaintInvalidationRoot,其实这里涉及到Slate中一个重要的优化手段,那就是SInvalidationPanel。现在Slate的逻辑是每帧重新渲染所有的控件,这当然会带来大量的性能浪费,因为某些控件的变化频率并没有那么高,无需每帧更新,SInvalidationPanel则是当控件的内容发生变化时,只需重新渲染发生变化的部分,而不是整个面板。这可以显著减少UI布局侧的计算消耗,从而提高性能。具体代码如下所示:

FSlateInvalidationResult FSlateInvalidationRoot::PaintInvalidationRoot(constFSlateInvalidationContext&Context){
    ......
    FSlateInvalidationResultResult;
    .....
    TSharedRef<SWidget>RootWidget=GetRootWidget();
    ......
    if(!Context.bAllowFastPathUpdate || bNeedsSlowPath ||GSlateIsInInvalidationSlowPath){
        .....
        CachedMaxLayerId=PaintSlowPath(Context);
        Result.bRepaintedWidgets =true;
    }
    elseif(!FastWidgetPathList->IsEmpty()){
        Result.bRepaintedWidgets =PaintFastPath(Context);
    }
    Result.MaxLayerIdPainted=CachedMaxLayerId;
    returnResult;
}

主要分为两个路径,一个是正常路径也就是调用PaintSlowPath来重新渲染所有的控件,其二就是调用PaintFastPath仅仅渲染本次发生变化的控件,这当然对性能更优。但是在本文中还是沿着PaintSlowPath继续往下,可以更加完整地看到Slate是如何渲染的整个流程。

PaintSlowPath
最后终于快到渲染控件的起点了,来看看SWindow::PaintSlowPath都做了什么!代码如下所示:

int32 SWindow::PaintSlowPath(constFSlateInvalidationContext&Context){
    HittestGrid->Clear();
    constFSlateRectWindowCullingBounds=GetClippingRectangleInWindow();
    constint32LayerId=0;
    constFGeometryWindowGeometry=GetWindowGeometryInWindow();
    int32MaxLayerId=0;
    {
        MaxLayerId=Paint(*Context.PaintArgs,WindowGeometry,WindowCullingBounds,*Context.WindowElementList,LayerId,Context.WidgetStyle,Context.bParentEnabled);
    }
    returnMaxLayerId;
}
  1. 调用GetClippingRectangleInWindow获取裁剪矩形,裁剪矩形用于确定在窗口中哪些区域需要绘制。
  2. 调用GetWindowGeometryInWindow获取窗口的几何信息,几何信息包括窗口的位置、大小等。
  3. 初始化LayerId设置为0,因为SWindow是根结点。
  4. 调用SWidget::Paint来渲染窗口以及子控件。

之前的流程主要是设置一些Context信息以及全局的优化手段,到这里终于走到了渲染控件的入口。这里需要注意的是SWindow是继承SCompoundWidget的,所以这里调用的Paint是SWidget内的实现。

Paint
最后绕了这么多层,终于来到了SWidget::Paint,这是递归处理Slate控件树的开始,代码如下所示:

int32 SWidget::Paint(constFPaintArgs&Args,constFGeometry&AllottedGeometry,constFSlateRect&MyCullingRect,FSlateWindowElementList&OutDrawElements, int32 LayerId,constFWidgetStyle&InWidgetStyle, bool bParentEnabled)const{
    TSharedRef<SWidget>MutableThis=ConstCastSharedRef<SWidget>(AsShared());
    bool bClipToBounds, bAlwaysClip, bIntersectClipBounds;
    FSlateRectCullingBounds=CalculateCullingAndClippingRules(AllottedGeometry,MyCullingRect, bClipToBounds, bAlwaysClip, bIntersectClipBounds);
    FWidgetStyleContentWidgetStyle=FWidgetStyle(InWidgetStyle).BlendOpacity(RenderOpacity);
    FGeometryDesktopSpaceGeometry=AllottedGeometry;
    DesktopSpaceGeometry.AppendTransform(FSlateLayoutTransform(Args.GetWindowToDesktopTransform()));
    if(HasAnyUpdateFlags(EWidgetUpdateFlags::NeedsTick)){
        .....
        MutableThis->Tick(DesktopSpaceGeometry,Args.GetCurrentTime(),Args.GetDeltaTime());
    }
    constboolbInheritedHittestability=Args.GetInheritedHittestability();
    SWidget*PaintParentPtr= const_cast<SWidget*>(Args.GetPaintParent());
    .......
    PersistentState.InitialClipState=OutDrawElements.GetClippingState();
    PersistentState.LayerId=LayerId;
    PersistentState.bParentEnabled = bParentEnabled;
    ......
    PersistentState.WidgetStyle=InWidgetStyle;
    PersistentState.CullingBounds=MyCullingRect;
    PersistentState.IncomingUserIndex=Args.GetHittestGrid().GetUserIndex();
    FPaintArgsUpdatedArgs=Args.WithNewParent(this);
    UpdatedArgs.SetInheritedHittestability(bOutgoingHittestability);
    OutDrawElements.PushPaintingWidget(*this,LayerId,PersistentState.CachedElementHandle);
    .......
    int32NewLayerId=OnPaint(UpdatedArgs,AllottedGeometry,CullingBounds,OutDrawElements,LayerId,ContentWidgetStyle, bParentEnabled);
    ........
    returnNewLayerId;
}
  1. 首先处理窗口的裁剪矩形,裁剪矩形用于确定在窗口中哪些区域需要绘制。并且还有透明度以及坐标系转换等操作。
  2. 处理Tick相关逻辑,如果包含EWidgetUpdateFlags::NeedsTick,则调用Tick函数。
  3. 构造FSlateWidgetPersistentState对象,作为一个SWidget的变量,表示Paint时的状态,包含调用Paint所需的信息。
  4. 最后调用子类实现的OnPaint函数,来递归调用整个控件树来完成窗口和子控件的渲染操作。

到了这里可以总结一下Slate是怎么渲染这一颗控件树了,实际上是一个深度遍历的操作。流程如下所示:

  • SWindow调用SWidget::Paint,最后调用到SWindow::OnPaint。
  • 递归调用各自控件Paint函数,并分发给各控件实现的OnPaint。
  • 当前控件如果包含子控件则重复第二步的流程。
  • 直到所有的控件全部被渲染完毕。

使用SWindow来举例,SWindow::OnPaint实现其实是直接调用SCompoundWidget::OnPaint的实现来渲染子控件的。SCompoundWidget::OnPaint实现如下所示:

int32 SCompoundWidget::OnPaint(constFPaintArgs&Args,constFGeometry&AllottedGeometry,constFSlateRect&MyCullingRect,FSlateWindowElementList&OutDrawElements, int32 LayerId,constFWidgetStyle&InWidgetStyle, bool bParentEnabled )const{
    FArrangedChildrenArrangedChildren(EVisibility::Visible);
    {
        this->ArrangeChildren(AllottedGeometry,ArrangedChildren);
    }
    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;
}

因为SCompoundWidget只有一个子控件所以处理很简单(多个子控件也不复杂,无非是变成一个循环),直接调用其子控件的Paint函数去渲染该控件以及它的子控件。直到整个控件树全部都被处理完毕。到这里应该知道Slate是如何去收集到全部控件的信息的,但是到目前为止还不知道怎么设置图元信息的,咱们接着往下走!

各个控件的OnPaint
在Slate中存储单个图元信息的对象是FSlateDrawElement,并且最终都要放入FSlateWindowElementList以供最后的渲染。OnPaint需要做的就是这些。Slate中提供了FSlateDrawElement::MakeXXX各种辅助方法来帮助创建不同类型的图元。

这里还是介绍一下OnPaint这些参数的含义,可以帮助你的理解它们的作用:

int32 OnPaint(const FPaintArgs&Args,
              constFGeometry&AllottedGeometry,
              constFSlateRect&,
              FSlateWindowElementList&OutDrawElements,
              int32 LayerId,
              constFWidgetStyle&InWidgetStyle,
              bool bParentEnabled) const = 0;
  1. FPaintArgs & Args:上面流程中也有提到,包括点击区域以及当前时间和间隔时间以及父控件指针。
  2. FGeometry & AllottedGeometry:分配给该子控件的几何大小(相对于父控件)。
  3. FSlateRect & MyCullingRect:当前控件的剪裁矩形,可用于判断子控件是否在这个矩形内来决定是否渲染。
  4. int32 LayerId:用于标记当前控件的层级,决定其渲染顺序的前后,并且会影响最后的合批操作。
  5. FWidgetStyle & InWidgetStyle:一般最上层父控件(SWindow对象)的传入的样式。
  6. bool bParentEnabled:代表父控件是否已启用。

首先还是通过SImage来举例,这调用FSlateDrawElement::MakeBox来创建一个矩形图元信息,因为是渲染一张图片所以矩形图元来承载足以。代码如下所示:

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

FSlateDrawElement::MakeBox的实现如下所示:

void FSlateDrawElement::MakeBox(FSlateWindowElementList&ElementList, uint32 InLayer,
    constFPaintGeometry&PaintGeometry,
    constFSlateBrush*InBrush,
    ESlateDrawEffectInDrawEffects,
    constFLinearColor&InTint){
    ......
    if(ShouldCull(ElementList,PaintGeometry,InBrush,InTint)){
        return;
    }
    MakeBoxInternal(ElementList,InLayer,PaintGeometry,InBrush,InDrawEffects,InTint);
}

首先调用ShouldCull函数来判定当前控件是否应该被裁剪,这里面其实包含了很多维度的检验,因为如果被裁剪了就没必要放入图元列表,这个检验的维度主要是以下几个方面:

  1. 检查尺寸:如果PaintGeometry的大小(宽度或高度)为0,说明图元没有实际大小,因此应该被剔除。
  2. 检查裁剪状态:检查当前的裁剪状态。如果裁剪状态具有零区域(即裁剪矩形的宽度或高度为0),说明图元被完全裁剪掉,因此应该被剔除。
  3. 检查Brush:如果InBrush的绘制类型为ESlateBrushDrawType::NoDrawType,说明Brush没有实际的绘制内容,因此应该被剔除。
  4. 检查资源:检查Brush的资源是否有效。如果资源处于销毁或不可访问的状态,说明Brush无法正常绘制,因此应该被剔除。
  5. 检查颜色透明度:如果InTint的透明度为0,说明图元是完全透明的,没有必要渲染,因此应该被剔除。
  6. 检查文本:当绘制文本时,如果InText的长度为0,说明没有实际的文本内容,因此应该被剔除。

完成这些检验就代表这是一个真的需要渲染的图元,接着来看FSlateDrawElement::MakeBoxInternal,代码如下所示:

FSlateDrawElement& FSlateDrawElement::MakeBoxInternal(FSlateWindowElementList&ElementList, uint32 InLayer,
    constFPaintGeometry&PaintGeometry,
    constFSlateBrush*InBrush,
    ESlateDrawEffectInDrawEffects,
    constFLinearColor&InTint
){
    EElementTypeElementType=(InBrush->DrawAs==ESlateBrushDrawType::Border)?EElementType::ET_Border :EElementType::ET_Box;
    FSlateDrawElement&Element=ElementList.AddUninitialized();
    FSlateBoxPayload&BoxPayload=ElementList.CreatePayload<FSlateBoxPayload>(Element);
    BoxPayload.SetTint(InTint);
    BoxPayload.SetBrush(InBrush);
    Element.Init(ElementList,ElementType,InLayer,PaintGeometry,InDrawEffects);
    returnElement;
}
  1. 首先确定当前图元类型,只有在Brush的DrawAs为ESlateBrushDrawType::Border的情况下是EElementType::ET_Border,其他的情况下都是EElementType::ET_Box类型。
  2. 调用FSlateWindowElementList::AddUninitialized从中获取一个新的FSlateDrawElement对象。
  3. 为Box图元创建对应FSlateBoxPayload对象,用于存储与绘制Box类型图元相关的数据(包括Tint,Brush)。
  4. 最后调用FSlateDrawElement::Init,完成FSlateDrawElement对象的初始化。

完成这些后一份完整的Box图元信息就已经全部准备好,并放入FSlateWindowElementList对象中,之后就可以交给Slate的渲染线程去操作。其他图元的创建方式也是大同小异,比如STextBlock控件生成的图元,它是通过FSlateDrawElement::MakeText来创建的,代码如下所示:

void FSlateDrawElement::MakeText(FSlateWindowElementList&ElementList, uint32 InLayer,constFPaintGeometry&PaintGeometry,constFString&InText,constFSlateFontInfo&InFontInfo,ESlateDrawEffectInDrawEffects,constFLinearColor&InTint){
    PaintGeometry.CommitTransformsIfUsingLegacyConstructor();
    ......
    if(ShouldCull(ElementList,PaintGeometry,InTint,InText)){
        return;
    }
    if(InTint.A ==0&&!InFontInfo.OutlineSettings.IsVisible()){
        return;
    }
    FSlateDrawElement&Element=ElementList.AddUninitialized();
    FSlateTextPayload&DataPayload=ElementList.CreatePayload<FSlateTextPayload>(Element);
    DataPayload.SetTint(InTint);
    DataPayload.SetText(InText,InFontInfo);
    Element.Init(ElementList,EElementType::ET_Text,InLayer,PaintGeometry,InDrawEffects);
}

其实这个流程和创建Box类型的图元类似,其他的图元创建流程上都是创建FSlateDrawElement并设置对应类型的DataPayload并完成初始化操作。

三、Geometry

在生成图元信息的流程中看到使用FGeometry对象,它主要用于描述和管理控件的布局和几何信息。FGeometry包含了控件的位置、大小、缩放等等各种信息,它在控件布局、事件处理、坐标变换、子控件传递以及支持复杂变换等方面发挥重要作用。

在正式开始介绍之前,首先来了解一些前置概念,比如还有一个本地空间和屏幕空间的概念:

  1. 本地空间(Local Space):本地空间是指控件自身的坐标系。在本地空间中,控件的原点(0,0)通常位于其左上角,x轴从左到右增加,y轴从上到下增加。本地空间主要用于描述控件的内部布局和尺寸。例如,当设置一个控件的宽度和高度时,这些值是相对于控件自身的本地空间的。
  2. 屏幕空间(Screen Space):屏幕空间是指整个屏幕或应用程序窗口的坐标系。在屏幕空间中,原点(0,0)通常位于屏幕或窗口的左上角,x轴从左到右增加,y轴从上到下增加。屏幕空间主要用于描述控件在屏幕或窗口上的位置。例如,当设置一个控件的位置时,这个位置是相对于屏幕或窗口的坐标系的。

在Slate框架中,通常需要在本地空间和屏幕空间之间进行坐标转换。例如在处理控件的布局和点击事件时,需要将本地空间中的坐标转换为屏幕空间中的坐标,以便确定控件在屏幕上的实际位置。

接着来介绍一下Slate的FGeometry对象,重要函数和属性如下所示:

USTRUCT(BlueprintType)
struct SLATECORE_API FGeometry
{
    GENERATED_USTRUCT_BODY()
public:
......
    FORCEINLINE constFVector2D&GetLocalSize()const{returnSize;}
    FORCEINLINE constFSlateRenderTransform&GetAccumulatedRenderTransform()const{returnAccumulatedRenderTransform;}
    FORCEINLINE FSlateLayoutTransformGetAccumulatedLayoutTransform()const{returnFSlateLayoutTransform(Scale,AbsolutePosition);}
    FORCEINLINE FVector2DGetAbsolutePosition()const{returnAccumulatedRenderTransform.TransformPoint(FVector2D::ZeroVector);}
    FORCEINLINE FVector2DGetAbsoluteSize()const{returnAccumulatedRenderTransform.TransformVector(GetLocalSize());}
    constFVector2D/*Local*/Size;
    constfloat/*Absolute*/Scale;
    constFVector2DAbsolutePosition;
    constFVector2D/*Local*/Position;
private:
    FSlateRenderTransformAccumulatedRenderTransform;
    const uint8 bHasRenderTransform :1;
};
  1. Size:表示控件在本地空间(Local Space)中的大小,即控件自身坐标系中的宽度和高度。这个字段主要用于描述控件的内部布局和尺寸。
  2. Scale:表示控件的缩放因子。这个字段用于描述控件在屏幕空间(Screen Space)中的缩放程度。这个值是累积的,包括了控件本身以及其所有父控件的缩放。
  3. AbsolutePosition:表示控件在屏幕空间中的位置,即控件相对于屏幕或应用程序窗口的左上角的坐标。这个字段主要用于描述控件在屏幕上的位置。
  4. Position:表示控件在本地空间中的位置。这个字段主要用于描述当前控件相对于其父控件的位置。
  5. AccumulatedRenderTransform:表示从控件的本地空间到屏幕空间的累积渲染变换。这个变换包括了控件本身以及其所有父控件的渲染变换。这个字段主要用于在渲染过程中对控件应用复杂的变换,例如旋转、缩放和平移等。
  6. bHasRenderTransform:一个布尔值,表示控件是否具有渲染变换。这个字段用于在需要时快速检查控件是否具有渲染变换,以便在渲染过程中进行相应的处理。

这里还有一个需要注意的GetAccumulatedLayoutTransform方法,这里返回的是控件的累积布局变换。这个变换表示了从控件的本地空间到父控件空间的变换,包括所有父控件的变换。它和AccumulatedRenderTransform的差别如下:

  • AccumulatedLayoutTransform:这个变量表示从控件的本地空间到父控件空间的累积布局变换。这个变换包括了控件本身以及其所有父控件的布局变换。主要用于处理控件的布局计算,例如在确定控件在屏幕上的位置时。可以确保控件在屏幕上的正确布局,同时支持复杂的布局变换,例如缩放和平移等。
  • AccumulatedRenderTransform:这个变量表示从控件的本地空间到屏幕空间的累积渲染变换。这个变换包括了控件本身以及其所有父控件的渲染变换。主要用于在渲染过程中对控件应用复杂的变换,例如旋转、缩放和平移等。可以确保控件在渲染过程中的正确显示,同时支持复杂的渲染变换。

FGeometry可以在控件的层次结构中传递,这可以在OnPaint的实现中看到传递其FGeometry对象,尤其是针对多个子控件的控件会完成其子控件约束后得到正确的FGeometry对象再被传递,这样可以使得子控件可以根据其父控件的FGeometry信息确定自己的布局。这有助于维护控件层次结构的正确布局。

最后再来谈谈生成图元信息时使用到的FPaintGeometry,其实它和FGeometry的差别并不大,并且一般都是由FGeometry转化而来,它们只是应用场景不同,FGeometry主要关注控件的布局和几何信息,而FPaintGeometry主要关注控件的绘制信息和渲染变换。所以在生成图元流程中都会将FGeometry转化为FPaintGeometry再执行后续的流程。

四、渲染线程需要干什么

现在到了如何提交给GPU去完成渲染的时候了,当然UE也为Slate框架封装了一个类来专门处理Slate的渲染,那就是FSlateRenderer。FSlateRenderer是一个抽象基类,用于定义Slate框架的通用渲染接口。为了实现跨平台渲染,UE提供了针对不同图形API和平台的FSlateRenderer的派生类,如FSlateRHIRenderer(它就是Slate的渲染器),当然UE也封装了FSlateD3DRenderer和FSlateOpenGLRenderer,只有特殊情况会启用其它两个。当然还有一个特殊的FSlateNullRenderer,它是一个空渲染器,它不执行任何实际的渲染操作。主要用于不需要图形输出的场景,例如服务器或者某些命令行工具。FSlateRenderer的初始化是在FEngineLoop::PreInitPostStartupScreen中完成的,代码如下所示:

if (!IsRunningDedicatedServer()&&!IsRunningCommandlet()){
    TSharedPtr<FSlateRenderer>SlateRenderer=GUsingNullRHI?
        FModuleManager::Get().LoadModuleChecked<ISlateNullRendererModule>("SlateNullRenderer").CreateSlateNullRenderer():
        FModuleManager::Get().GetModuleChecked<ISlateRHIRendererModule>("SlateRHIRenderer").CreateSlateRHIRenderer();
    TSharedRef<FSlateRenderer>SlateRendererSharedRef=SlateRenderer.ToSharedRef();
    {
        SCOPED_BOOT_TIMING("CurrentSlateApp.InitializeRenderer");
        FSlateApplication&CurrentSlateApp=FSlateApplication::Get();
        CurrentSlateApp.InitializeRenderer(SlateRendererSharedRef);
    }
    .......
}

如果是DS或者命令行工具的环境下是不会创建FSlateRenderer的,并且根据GUsingNullRHI是否为true来创建SlateRHIRenderer还是SlateNullRenderer。当然正常情况下都是SlateRHIRenderer。随后调用FSlateApplication::InitializeRenderer完成其初始化,归FSlateApplication对象持有。

bool FSlateApplication::InitializeRenderer(TSharedRef<FSlateRenderer>InRenderer, bool bQuietMode ){
    Renderer=InRenderer;
    boolbResult=Renderer->Initialize();
    ......
    return bResult;
}

几个重要的类
首先就是FSlateRHIRenderer,一些重要的函数和属性如下所示:

class FSlateRHIRenderer:publicFSlateRenderer{
    .......
    voidDrawWindow_RenderThread(FRHICommandListImmediate& RHICmdList, FViewportInfo& ViewportInfo, FSlateWindowElementList& WindowElementList, const struct FSlateDrawWindowCommandParams& DrawParams);
    voidDrawWindows_Private( FSlateDrawBuffer& InWindowDrawBuffer );
    virtual FSlateDrawBuffer&GetDrawBuffer() override;
    FSlateDrawBufferDrawBuffers[NumDrawBuffers];
    uint8 FreeBufferIndex;
    TUniquePtr<FSlateElementBatcher>ElementBatcher;
    TSharedPtr<FSlateRHIRenderingPolicy>RenderingPolicy;
    ......
}
  1. FreeBufferIndex和DrawBuffers:还是经典的多缓冲,因为Slate是多线程渲染的,所以当GPU正在渲染时,为了让CPU侧能够继续工作,就可拿一个新的Buffer来交替使用,即避免了数据竞争问题,又尽可能地榨干CPU侧的性能,体现在Slate的每帧Tick最开始时会调用GetDrawBuffer,获取最新的FSlateDrawBuffer用于后续流程中存储渲染数据。
  2. TSharedPtr RenderingPolicy:它主要封装Slate的RHI渲染逻辑,包括渲染状态的设置、资源管理和DrawCall执行等等。
  3. TUniquePtr ElementBatcher:它是完成Slate合批操作的中枢,它负责将Slate控件转化为RenderBatch中,后面会详细讲到。

FSlateElementBatcher,FSlateBatchData,FSlateRenderBatch
接着来看FSlateElementBatcher,如果需要深入了解得结合FSlateBatchData和FSlateRenderBatch一起来看,它们的三者的关系如下所示:

有了这一张图应该更能帮助你理解:

FSlateRenderBatch类起到了将各类Element组织成Render Batch并提交给GPU进行渲染的关键作用。它存储了Batch中元素的渲染状态、顶点数据、索引数据等,并支持自定义绘制和实例化渲染,以实现高效且灵活的Slate UI元素渲染。FSlateRenderBatch的一些重要属性和方法如下所示是:

class FSlateRenderBatch{
public:
    ........
public:
    .......
    FShaderParamsShaderParams;
    constFSlateClippingState*ClippingState;
    constFSlateShaderResource*ShaderResource;
    ICustomSlateElement*CustomDrawer;
    int32 LayerId;
    int8 SceneIndex;
    ESlateBatchDrawFlagDrawFlags;
    ESlateShaderShaderType;
    **ESlateDrawPrimitiveDrawPrimitiveType;
    ESlateDrawEffectDrawEffects;
    uint8 bIsMergable :1;
    uint8 bIsMerged :1;
    .......
};
  1. ShaderParams:FShaderParams结构体定义了一组着色器参数。这些参数在渲染过程中将传递给像素着色器。
  2. ClippingState:指向FSlateClippingState的指针,表示与RenderBatch关联的裁剪状态。主要用于限制Slate控件在屏幕上的可见区域。
  3. ShaderResource:指向FSlateShaderResource的指针,表示与RenderBatch关联的着色器资源。这通常是一个纹理或其他渲染资源。RenderBatch中的所有元素将使用相同的着色器资源进行渲染。
  4. CustomDrawer:ICustomSlateElement类型的指针,表示与Batch关联的自定义渲染逻辑。这用于实现特定于的渲染逻辑。
  5. LayerId:表示Batch所在的Layer。并且LayerId用于对Batch进行排序,以确保正确的绘制顺序。
  6. SceneIndex:表示Batch所在的场景索引。
  7. DrawFlags:表示Batch的绘制标志。这用于控制Batch的渲染行为,例如混合模式、剔除模式等。
  8. ShaderType:表示Batch使用的着色器类型。
  9. DrawPrimitiveType:表示Batch的所渲染的图元类型,图元类型决定了Batch中元素的几何形状,例如三角形列表、线段列表等。

了解FSlateRenderBatch之后,再来看看作为FSlateRenderBatch容器的FSlateBatchData,FSlateBatchData类的主要作用是承载RenderBatch。这些数据在渲染过程中将提交给GPU进行渲染。此外FSlateBatchData还提供了一些方法来完成RenderBatch的合批操作。重要的方法和属性如下所示:

class FSlateBatchData{
public:
    .......
    SLATECORE_API voidMergeRenderBatches();
    FSlateRenderBatch&AddRenderBatch(.......);
private:
    voidCombineBatches(FSlateRenderBatch& FirstBatch, FSlateRenderBatch& SecondBatch, FSlateVertexArray& FinalVertices, FSlateIndexArray& FinalIndices);
private:
    ......
    TArray<FSlateRenderBatch>RenderBatches;
    FSlateVertexArrayFinalVertexData;
    FSlateIndexArrayFinalIndexData;
    int32 NumBatches;
};
  1. RenderBatches:是一个用于存储RenderBatches的数组。
  2. FinalVertexData和FinalIndexData:分别表示最终的顶点数据和索引数据。这些数据在渲染过程中将提交给GPU。
  3. NumBatches:表示最终RenderBatch的数量。这与RenderBatches的数组长度并不同,因为它表示经过合批操作后的RenderBatch数量。
  4. AddRenderBatch(...):用于添加一个新的FSlateRenderBatch对象,并且返回该FSlateRenderBatch引用。
  5. MergeRenderBatches():用于合并具有相似渲染状态的RenderBatch,通过合并RenderBatch可以减少渲染时的DrawCall数量,从而提高性能。
  6. CombineBatches(…..):在MergeRenderBatches中被调用,来完成RenderBatch的合并操作(顶点数据的合并操作等)。

最后再来看看FSlateElementBatcher,代码如下所示:

class FSlateElementBatcher{
public:
    SLATECORE_API voidAddElements( FSlateWindowElementList& ElementList );
private:
    voidAddElementsInternal(const FSlateDrawElementArray& DrawElements, const FVector2D& ViewportSize);
    voidAddCachedElements(FSlateCachedElementData& CachedElementData, const FVector2D& ViewportSize);
    template<ESlateVertexRoundingRounding>
    voidAddQuadElement( const FSlateDrawElement& DrawElement );
    template<ESlateVertexRoundingRounding>
    voidAddBoxElement( const FSlateDrawElement& DrawElement );
    template<ESlateVertexRoundingRounding>
    voidAddTextElement( const FSlateDrawElement& DrawElement )
    template<ESlateVertexRoundingRounding>
    voidAddShapedTextElement( const FSlateDrawElement& DrawElement );
    ..........
    FSlateRenderBatch&CreateRenderBatch(..........);
private:
    ......
    FSlateBatchData*BatchData;
    FSlateRenderingPolicy*RenderingPolicy;
    ......
};

FSlateElementBatcher中包含了FSlateRenderingPolicy指针以及FSlateBatchData指针,并且FSlateBatchData指针是从FSlateWindowElementList中获取的,每个FSlateWindowElementList都包含一个FSlateBatchData对象,并且最后渲染时都会从中获取数据,所以FSlateElementBatcher最重要的还是起到构造FSlateRenderBatch的作用。

后续流程中通过AddElements来生成FSlateRenderBatch对象的,根据图元类型的不同会分发到不同的AddXXXElement,和之前生成图元MakeXXX类似。最后都会调用CreateRenderBatch来构造FSlateRenderBatch对象。关于最后渲染数据是怎么生成的以及谁负责都已经知道,接下来看看具体的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)


侑虎科技
62 声望21 粉丝

UWA官网:[链接]