10

UE 4.24 Slate合批机制剖析

 3 years ago
source link: https://blog.uwa4d.com/archives/USparkle_UESlate.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

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中执行的逻辑分为以下三大块:

1.png

第一块是控件绘制,在主线程中,给每个控件分配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。

    2.png

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

3.png


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

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

DrawElement分为以下几类:

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

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

    4.png
  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不同。
  • 5.png
  • 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和开发者在一起!


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK