UE 4.24 Slate合批机制剖析

UE 4.24 Slate合批机制剖析

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

作者主页:https://www.zhihu.com/people/inkiu0,作者也是U Sparkle活动参与者,UWA欢迎更多开发朋友加入U Sparkle开发者计划,这个舞台有你更精彩!


本篇文章会和大家分享笔者这段时间从Unreal Engine 4.24源码中了解到的Slate合批机制。

由于Unreal Engine引擎更新迭代非常快,请注意这篇文章是针对Unreal Engine 4.24版本的。比如《Unreal Open Day 2017 Optimize in Mobile UI》的分享中提到一个优化点:Canvas Panel 不支持批次合并,建议不要使用Canvas Panel。

但更重要的是,这句话有一个前提:

在Unreal Engine 4.15之前的引擎版本,Canvas Panel不支持批次合并。

最后希望大家能从文章里有所收获,在阅读源码之前,对Unreal Engine的Slate有个概念。

一、Slate和UMG概述

Unreal的UI系统由Slate和UMG两部分组成,它们的关系就像下面代码里展示的SWidget和UWidget一样。

UCLASS(Abstract, BlueprintType, Blueprintable)
class UMG_API UWidget : public UVisual
{
    GENERATED_UCLASS_BODY()

    // ......

protected:
    TWeakPtr<Swidget> MyWidget;

    // ......
};

class SLATECORE_API SWidget
{
    // ......

public:
    int32 Paint(...);

    // ......

private:
    int32 virtual OnPaint(...) const = 0;

    // ......

};

SWidget在SlateCore模块中,控件的绘制、点击以及大部分控件逻辑都集中在这里面。

UWidget在UMG模块中,UWidget持有着SWidget,UWidget是对SWidget的一个包装。UWidget本身是基于UObject的,不包括太多控件逻辑。主要作用是加入了UObject的GC系统,支持反射和蓝图功能。

所以我们对Unreal Engine 4引擎的合批机制的剖析,主要会集中在Slate中。


二、UI绘制流程

了解完了Slate和UMG的关系,我们再来了解一下Slate具体的流程。Slate在CPU中执行的逻辑分为以下三大块:

第一块是控件绘制,在主线程中,给每个控件分配LayerId,并从控件抽象出FSlateDrawElement。

第二块是绘制指令生成渲染指令,也是在主线程中,把FSlateDrawElement包装成FSlateRenderBatch,并根据控件的信息生成VertexBuffer。

第三块是合批并执行渲染,在渲染线程,把之前生成的FSlateRenderBatch按照LayerId从小到大排序,并尝试合批。最后把UI渲染到BackBuffer。


三、控件绘制

3.1 控件的绘制流程
当我们在谈论Unreal的合批机制的时候,首先要关注第一块的代码,也就是控件绘制过程。但Unreal Engine的代码量非常的大,光SWidget就有近1800行。我们在看源码的时候要有所选择,所以我们聚焦在Paint和OnPaint两个函数上。

class SLATECORE_API SWidget : public FSlateControlledContruction, public TSharedFromThis<SWidget>
{
    // ......

public:
    int32 Paint(const FPaintArgs& Args, const FGeometry& AllottedGeometry, const FSlateRect& MyCullingRect, FSlateWindowElementList& OutDrawElements, int32 LayerId, const FWidgetStyle& InWidgetStyle, bool bParentEnabled) const;

    // ......

private:
    virtual int32 OnPaint(const FPaintArgs& Args, const FGeometry& AllottedGeometry, const FSlateRect& MyCullingRect, FSlateWindowElementList& OutDrawElements, int32 LayerId, const FWidgetStyle& InWidgetStyle, bool bParentEnabled) const = 0;

    // ......
};

Unreal的控件绘制是从FSlateApplication::DrawWindows开始,从SWindow::Paint开始派发,LayerId初始为0。

整个控件的绘制过程,实际上是一个递归调用,所以Unreal的UI绘制是一个深度优先的遍历。递归过程如下:

  1. SWindow调用SWidget::Paint。
  2. 执行SWidget::Paint,并调用纯虚函数OnPaint,分发给各控件实现的OnPaint。
  3. 执行各控件的OnPaint,完成绘制,如果包含子控件则调用子控件的Paint,回到第二步。
  4. 直到所有控件绘制完。

3.2. LayerId的传递
随着控件绘制,LayerId也随之更新,初始为0。LayerId的传递过程伪代码如下:

Paint(..., int32 LayerId, ...) const
{
    int32 NewLayerId = OnPanit(..., LayerId, ...);
return NewLayerId;
}

OnPaint(..., int32 LayerId, ...) const
{
    int32 MaxLayerId = LayerId;
    for (int32 Idx = 0; Idx < Children.Num(); ++Idx)
    {
        if (false)  // 只有CanvasPanel和Overlay有可能继承兄弟Widget的MaxLayerId
        {
            LayerId = MaxLayerId + 1;
        }
        const int32 CurWidgetsMaxLayerId = Children[Idx]->Paint(..., LayerId, ...);
        MaxLayerId = FMath::Max(MaxLayerId, CurWidgetsMaxLayerId);
    }
    return LayerId/*or return MaxLayerId*/;
}

在控件绘制过程中,各控件实现的OnPaint对LayerId的影响各不相同。

大部分控件使用参数中的LayerId,也就是使所有子节点都继承自己的LayerId,最后把LayerId返回。

少部分控件改变LayerId,并作为返回值传递给父节点(CurWidgetsMaxLayerId)。

极少数控件会使用子控件中最大的LayerId(MaxLayerId),使各子控件的LayerId递增。

3.3. 控件对LayerId的影响
大部分的控件都是使用参数中的LayerId,不会改变LayerId。一个控件对LayerId的影响,可以分为自身逻辑和基类影响两部分。

3.3.1 控件基类
Slate控件的基类主要是下面这四类:

  1. SWidget所有控件的基类,不改变LayerId。
  2. SLeafWidget不包含子控件,不改变LayerId。
  3. SCompoundWidget包含一个子控件,它会使子控件的LayerId + 1。
  4. SPanel包含多个子控件,不改变LayerId,所有子控件都继承父控件的LayerId。

3.3.2 特殊控件
下面列举的控件都对LayerId有特殊影响,没有列出来的控件对LayerId的影响,参照下图中该控件继承的基类。

  1. CanvasPanel,只要开启了Canvas合批优化,并CanvasPanel下的子控件ZOrder相等,LayerId就不变。
  • Canvas合批优化的开关默认是打开的,在“Engine->Slate Settings->Constraint Canvas->Explicit Canvas Child ZOrder”中。
  1. SProgressBar自身逻辑会分别另开一层Layer绘制BackgroundImage和FillImage,LayerId+2。但return LayerId+1。

  2. SSlider自身逻辑会另开一层Layer绘制的BarImage和ThumbImage,LayerId+1。

  3. SScrollBox本身不特殊,但是它包含的那一个子控件很特殊。根据滚动方向,SScrollBox的子控件为HorizonalBox或VerticalBox。其中又有一个SOverlay作为父控件,包含了ScrollBox的所有Item(而SOverlay非常特殊,后面会讲到)。

  4. SCheckBox自身逻辑会绘制一个SBorder,LayerId+1。

  5. SBackgroundBlur会另起一层绘制后处理效果,LayerId+1。

  6. SExpandableArea分别使用一个SBorder包裹Header和Body,LayerId+1。

  7. SGridPanel如果子控件的Layer和前一个不同,则LayerId+1。子控件绘制完成后,将返回的LayerId与当前MaxLayerId取Max,最后将所有子控件中最大的MaxLayerId返回给父控件。

  8. SOverlay每绘制一个子控件LayerId+1,当完成了一个子控件的绘制后,会将子控件返回的LayerId和当前LayerId取Max,然后下一个子控件再LayerId+1。

上图是从程序角度列举的所有Slate控件的继承关系图,对美术不是很友好。下图列举出常用控件的简化导图。


四、绘制指令生成渲染指令

在每个控件OnPain的最后,如果有需要绘制的内容。会调用FSlateDrawElement::MakeXXX,把控件的绘制抽象成一个FSlateDrawElement。这里设置的EElementType,以及从控件上保存下来的属性,在后续合批的过程中会使用到。

DrawElement分为以下几类:

  1. EElementType::ET_Box,绝大多数控件都是生成BoxElement。

  2. EElementType::ET_Border,只有InBrush->DrawAs指定是Border才会生成BorderElement。

  3. EElementType::ET_PostProcessPass, 只有SBackgroundBlur才会生成PostProcessElement。

  4. EElementType::ET_Text,只有STextBlock才会生成TextElement。

  5. EElementType::ET_ShapedText,只有SRichTextBlock才会生成ShapedTextElement。

  6. EElementType::ET_Line,只有Debug的时候才会生成LinesElement。

在所有的控件都生成了DrawElement以后,会将每一个FSlateDrawElement包装成FSlateRenderBatch。这其中最重要的工作就是,根据FSlateDrawElement生成了对应的Vertex Buffer。


五、MergeRenderBatches

在UI绘制的最后,会进入到渲染线程,进行渲染指令的合批和渲染。在合批的一开始,就会对所有的FSlateRenderBatch根据LayerId,从小到大排序。

LayerId不同时,不能合批。

LayerId相同时,判断IsBatchableWith是否为true。IsBatchableWith代码如下,看完代码以后我们再对每一个影响合批的变量进行分析。

bool IsBatchableWith(const FSlateRenderBatch& Other) const
{
return
    ShaderResource == 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;
}
  1. ShaderResource, Image是指AltasTexture或者自身的Texture,Text是指FontTexture,其他都是nullptr。

  2. DrawFlags,绝大部分都是None。

  • BorderElement和BoxElement如果选择了不同Tiling,DrawFlags不同。
  • QuadElement的DrawFlags是Wireframe | NoBlending,只能和自己合批。但QuadElement只有Debug的时候才会生成,所以忽略。
  1. ShaderType,BorderElement是ESlateShader::Border,文本普通字体是ESlateShader::GrayscaleFont,彩色字体是ESlateShader::ColorFont,其余全是ESlateShader::Default。

  2. DrawPrimitiveType,都是ESlateDrawPrimitive::TriangleList

  • 除了厚度是1的LineElement是ESlateDrawPrimitive::LineList,但LineElement只有Debug的时候才会生成,所以忽略。
  1. DrawEffects,都是ESlateDrawEffect::None,如果自己或父节点没勾上Is Enable选项是ESlateDrawEffect::DisabledEffect。
  • SRetainerWidget是ESlateDrawEffect::PreMultipliedAlpha | 。ESlateDrawEffect::NoGamma,但SRetainerWidget本来就不能和其他控件一起合批。
  1. ShaderParams,其实是2个FVector4,也就是8个float参数,默认都是8个0。
  • BorderElement塞了Left/Right/Top/BottomMargin四个参数。
  • SplineElement塞了厚度和缩放2个参数,PostProcessElement塞了7个参数,这两种都用得特别少,所以忽略。
  1. InstanceData、InstanceCount、InstanceOffset,默认都是nullptr、0、0。
  • 除了CostumVertsElement的InstanceData可以自定义。
  1. DynamicOffset,都是FVector2D(0, 0)。

  2. CustomDrawer,都是nullptr。

  • CustomElement会把Drawer传进来。
  1. SceneIndex,当前场景索引,一般都是相等的。

  2. ClippingState,都是nullptr。


六、优化建议

总结一下上面剖析的UE4.24合批机制,影响合批的主要是以下几个因素:

  1. LayerId。
  2. ShaderResource,图片或图集不同的Image控件不能合批。
  3. Tiling,设置了Tiling的控件不能和普通控件合批。
  4. ShaderType,DrawAs选了Border的和文本控件,不能和普通控件合批。
  5. DrawEffects,自己和父控件不能去掉IsEnable。
  6. ShaderParams,DrawAs选了Border的控件,不能和普通控件合批,同2。

这其中,2/3/4/5/6都很容易做到,最主要的是1,也就是LayerId。所以根据这么多的分析,我给出优化建议是——不用合批

是的,在Unreal Engine 4.24中不用太在意UI控件的合批。原因有这几个:

  1. 合批很难。
  2. 合批提升不大。
  3. DrawCall不再是瓶颈。

6.1 合批很难
虽然经过我们的剖析发现Unreal Engine 4.24中,只要LayerId相同,合批就非常有可能。但是,恰恰是LayerId最难相等。

6.1.1 影响因子多
对一个控件的LayerId有影响的不仅仅有父控件,还有自己的兄弟控件,甚至还有SOverlay这样使用MaxLayerId的控件。想要得到一个相等的LayerId,需要经过精心的排布。而万一来一个动态增删,又要重排。

6.1.2 反直觉不直观
LayerId非常的不直观,首先LayerId是一个抽象概念,并没有一个地方能观察和指定LayerId。当然,这个我们可以改源码。最重要的是LayerId是反直觉的,它和控件在控件树上的层级没有关系。甚至控件的渲染顺序,和控件树的层级都没有关系,毕竟最后渲染指令会根据LayerId重排的。这就导致了,做UI的美术根本理解不了LayerId,更别说排布出一个完美的LayerId,甚至程序员也不能。

6.2 合批提升不大
与其说合批提升不大,与控制合批的难度相比,不如说合批提升不够大。我们知道UI合批主要是避免重复提交,重复调用GPU接口。但随着CPU的发展,往往较低Draw Call并不能带来FPS的提高。虽然可以带来一定量的性能提升,但是与之付出的代价并不相符。

6.3 Draw Call不再是瓶颈
随着CPU发展,Draw Call不再是性能瓶颈。但UI越做越复杂,越做越精美。Unreal Engine的UI性能瓶颈已经发生了转移,OverDraw为重中之重。在《Unreal Open Day 2017 Optimize in Mobile UI》中,也重点提到了利用InvalidationBox减少UI Tick,以提高CPU性能。并使用RetainerBox来实现动静分离,降低OverDraw。


七、总结

目前Unreal Engine 4.24~4.26中不用太关注DrawCall,还是参考《Unreal Engine 4 中的UI优化技巧》,多关注动静分离。

虽然我给出的优化建议是——不用合批,但是归根到底还是因为UE中的LayerId设计不合理。这种不合理不仅会造成合批困难,还会带来控件渲染穿插的Bug,还有HittestGrid生成错误点击穿透的Bug。因为这个LayerId不是面向开发人员的,它是面向代码的一个抽象概念,甚至LayerId不仅管控件的渲染,还管控件的点击层级。

更重要的是在游戏开发中:

  1. 美术会为所有的控件指定好层级,这个层级就是美术最终想要的效果。
  2. UE也会为所有的控件指定好层级,生成一个LayerId,重新排布所有控件的层级。

不管生成和排布LayerId的规则是什么,肯定都会和美术指定的层级有冲突,也就是产生穿插。这个重排非常不合理,个人认为UE根本就不应该重新排布控件层级,这个层级应该完全听使用者的。

欢迎大家一起反馈讨论。


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

也欢迎大家来积极参与U Sparkle开发者计划,简称“US”,代表你和我,代表UWA和开发者在一起!