UE Slate渲染流程(上)

UE Slate渲染流程(上)

【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如何完成渲染的。

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


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

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

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