0%

SlateApplication输入管理与Slate控件运行原理

SlateApplication输入管理与Slate控件运行原理

SlateApplication输入管理与Slate控件运行原理

UE4编辑器扩展与slate基础 | Coding中。。。中稍微研究了点Slate的一些基础概念,在UE多线程与windows多线程输入 | Coding中。。。中涉及到了输入相关,也和SlateApplication这个类有一些关系,所以现在来具体看看这几个东西是怎么一回事。

Slate在官方文档上的描述:

  • 易于访问模型的代码和数据。
  • 支持程序化 UI 生成。
  • UI 描述不易出错。
  • 支持动画和设计。

 

Widget控件结构

SWidget是所有控件的父类,和一般的控件一样,UWidget持有SWidget的引用,UWidget只处理一些数据相关的内容,而SWidget则去管理一些渲染和点击相关的内容。

按照结构区分,控件大致可分为三种类型:

  • 叶控件 - 不带子槽的控件。如显示一块文本的 STextBlock。其原生便了解如何绘制文本。
  • 面板 - 子槽数量为动态的控件。如垂直排列任意数量子项,形成一些布局规则的 SVerticalBox
  • 合成控件 - 子槽显式命名、数量固定的控件。如拥有一个名为 Content 的槽(包含按钮中所有控件)的 SButton

如果是SPanel(面板)或者SCompoundWidget(合成控件),那么他们是可以实现父子关系以实现复杂布局的,由Slot来实现。

举个例子SVerticalBox,Slot在代码里的体现是这样的:

class SVerticalBox : public SBoxPanel
{
    SLATE_DECLARE_WIDGET_API(SVerticalBox, SBoxPanel, SLATECORE_API)
public:
    class FSlot : public SBoxPanel::TSlot<FSlot>
    {
    public:
        //。。。
    }
}

用了一个很罕见的嵌套类的方式来定义FSlot,基类为TSlot,这种方式本质目的为实现一个垂直插槽特有的Slot类型,例如实现一些布局相关的属性方法,设置内边距、水平垂直对齐方式等。

Slot有一些很经典的使用方式:

// 使用 SNew 创建 VerticalBox,然后使用 + 操作符添加插槽
TSharedRef<SVerticalBox> VerticalBox = SNew(SVerticalBox)

// 添加第一个槽位,设置自动高度和居中对齐

  • SVerticalBox::Slot()
    .AutoHeight() // 插槽高度由内容决定
    .HAlign(HAlign_Center) // 水平居中对齐
    [
    SNew(SButton) // 在这个插槽中放置一个按钮
    .Text(FText::FromString("Button 1"))
    ]

这个内容在UE4编辑器扩展与slate基础 | Coding中。。。里面见过一点,其实只是个语法糖,重载了+和[]等一些符号,值得一提的是这种方式添加控件其实就是和在UMG里面操作UI蓝图的底层实现,当UMG中的控件 AddToViewport时,会触发UWidget的RebuildWidget与TakeWidget等逻辑,然后UMG上面的控件树就会转化成Slate的控件树了,也就和代码所示的内容类似,这个地方也蛮重要,等到渲染流程时再研究。

在SVerticalBox对应的UVerticalBox中,有一些和Slot相关的虚函数:

pZCvvMq.png

这个OnSlotAdded,在UPanelWidget的AddChild内被调用,意思就是这样的布局控件添加了新子控件后就会调用这样的函数来添加插槽Slot。

OnSlotAdded在UPanelWidget里面没有逻辑,而是在UVerticalBox中重写:

pZCxii4.png

BuildSlot的内容:

pZCxFJJ.png

AddSlot,将slot用FScopedWidgetSlotArguments包了一下,暂时不知道干啥的,总之最后还是添加到了BoxPanel是Slot插槽中:pZCxAzR.png

image-20251113234601704

交互

前面提到具体的点击和渲染的管理由SWidget负责,而面向用户的数据由UWidget负责。

几个UWidget的方法:

public:
  virtual void SynchronizeProperties() override;
protected:
  UMG_API virtual TSharedRef<SWidget> RebuildWidget();

举个例子UButton的RebuildWidget:

TSharedRef<SWidget> UButton::RebuildWidget()
{
PRAGMA_DISABLE_DEPRECATION_WARNINGS
  MyButton = SNew(SButton)
      .OnClicked(BIND_UOBJECT_DELEGATE(FOnClicked, SlateHandleClicked))
      .OnPressed(BIND_UOBJECT_DELEGATE(FSimpleDelegate, SlateHandlePressed))
      .OnReleased(BIND_UOBJECT_DELEGATE(FSimpleDelegate, SlateHandleReleased))
      .OnHovered_UObject( this, &ThisClass::SlateHandleHovered )
      .OnUnhovered_UObject( this, &ThisClass::SlateHandleUnhovered )
      .ButtonStyle(&WidgetStyle)
      .ClickMethod(ClickMethod)
      .TouchMethod(TouchMethod)
      .PressMethod(PressMethod)
      .IsFocusable(IsFocusable)
      ;

PRAGMA_ENABLE_DEPRECATION_WARNINGS
if ( GetChildrenCount() > 0 )
{
Cast<UButtonSlot>(GetContentSlot())->BuildSlot(MyButton.ToSharedRef());
}

return MyButton.ToSharedRef();

}

每次Rebuild,SWidget都在UWidget里面重建并重新绑定各种事件。RebuildWidget这个函数,只有在Slate初始化和认为需要彻底重建的时候才会触发,这里需要区分两个点:彻底重建属性同步

如果在UMG中只是修改一些控件属性,例如文本颜色,控件大小等,那么就会触发SynchronizeProperties进行属性同步,将控件状态进行标记,然后由每一帧的OnPaint进行重绘。

只有当涉及到结构性的修改时才会触发RebuildWidget,例如将控件删除替换等。

void UButton::SynchronizeProperties()
{
    Super::SynchronizeProperties();

if (!MyButton.IsValid())
{
    return;
}

PRAGMA_DISABLE_DEPRECATION_WARNINGS
MyButton->SetButtonStyle(&WidgetStyle);
MyButton->SetColorAndOpacity( ColorAndOpacity );
MyButton->SetBorderBackgroundColor( BackgroundColor );
MyButton->SetClickMethod(ClickMethod);
MyButton->SetTouchMethod(TouchMethod);
MyButton->SetPressMethod(PressMethod);
PRAGMA_ENABLE_DEPRECATION_WARNINGS
}

如果有自定义的控件且有额外的自定义属性,记得重写这个函数,不然在UMG编辑器内每次修改属性都需要重新编译蓝图(触发RebuildWidget)才会触发重绘。

还有一些别的比如TArrtibute绑定的方法,总之思路就是需要同步属性并进行重绘就行

 

UI点击流程

UE多线程与windows多线程输入 | Coding中。。。内聊了Windows系统的点击事件怎么传到UE里的,到了SlateApplication停止了,那么这篇就从这里开始研究,这个地方的代码也是很经典的。

以鼠标点下事件为例,从文章中,消息已经通过消息循环走到了SlateApplication的OnMouseDown内:pZCx8SA.png

这里可以看出还在做转换,将WindowsOS的消息包成PointerEvent,内含点击的屏幕坐标等等。

ProcessMouseButtonDownEvent 对应的还有很多

ProcessMouseButtonUpEvent 抬起

ProcessKeyDownEvent 键盘按钮相关

ProcessTouchStartedEvent 模拟动端触屏

等等等等,就不一一列举了,这些事件的路由都是相似的,涉及到的函数名也是这种什么MouseDown什么KeyDown,也不一一列举了。

经过一系列判断捕获的代码之后:

FReply FSlateApplication::RoutePointerDownEvent(const FWidgetPath& WidgetsUnderPointer, const FPointerEvent& PointerEvent)
{
    ...
    //slateUser,包含了一些什么鼠标的位置,捕获的对象等等,举个例子就是多人游戏本地连两个手柄的情况,内涵索引index来区分
    TSharedRef<FSlateUser> SlateUser = GetOrCreateUser(PointerEvent);
    SlateUser->UpdatePointerPosition(PointerEvent);
    //先回走一遍OnPreviewMouseButtonDown的流程,从根节点往下遍历
    FReply Reply = FEventRouter::Route<FReply>( this, FEventRouter::FTunnelPolicy( WidgetsUnderPointer ), TransformedPointerEvent, []( const FArrangedWidget TargetWidget, const FPointerEvent& Event )
    {
        const FReply TempReply = TargetWidget.Widget->OnPreviewMouseButtonDown(TargetWidget.Geometry, Event);
        return TempReply;
    }, ESlateDebuggingInputEvent::PreviewMouseButtonDown);
    
if( !Reply.IsEventHandled() )
{
    //然后从叶子节点往上遍历
    Reply = FEventRouter::Route&lt;FReply&gt;( this, FEventRouter::FBubblePolicy( WidgetsUnderPointer ), TransformedPointerEvent, [this]( const FArrangedWidget TargetWidget, const FPointerEvent&amp; Event )
    {
        FReply TempReply = FReply::Unhandled();
            if( !TempReply.IsEventHandled() )
        {
                        //如果是模拟点击
            if( Event.IsTouchEvent() )
            {
                TempReply = TargetWidget.Widget-&gt;OnTouchStarted( TargetWidget.Geometry, Event );
                //如果是不是模拟点击
            if( !Event.IsTouchEvent() || ( !TempReply.IsEventHandled() &amp;&amp; this-&gt;bTouchFallbackToMouse ) )
            {
                TempReply = TargetWidget.Widget-&gt;OnMouseButtonDown( TargetWidget.Geometry, Event );
            }
}
    return TempReply;

}, ESlateDebuggingInputEvent::MouseButtonDown);

Route函数算是最精髓的部分了,但这里先不看这个,先看下最后OnMouseButtonDown到哪了,把流程走完。

直接看Visual的调用栈发现直接走到了SWidget的OnMouseButtonDown里面:

pZCxfkF.png

SWidget的OnMouseButtonDown本质是将这个事件传给鼠标事件处理器去了,很明显这是一个虚函数,要通过子类重写,然后来函数体内触发一些事件委托等等,值得一提的是这个函数的返回值FReply是何意味

来了解这个之前,先了解几个点击的基本概念。

捕获(Capture)、焦点(Focus)和控件树

捕获的概念从控件角度理解可能要好点,意思就是某个控件捕获到了鼠标的输入,然后后续的事件会最优先作用于这个控件,例如鼠标拖动某控件(哪怕鼠标已经移出窗口了,回来也会接着被捕获)。

焦点是一个状态,通常意义为某某控件被“聚焦”了,被聚焦的控件会直接收到键盘、手柄等的输入。

这两个概念都被上面提到的FSlateUser控制,里面有一些比如设置Focus、设置Capture等的方法。

从源代码上看,一次鼠标事件处理,要经过信息转换->事件本身是否捕获->哪个控件被聚焦(如果不是捕获)->具体控件处理事件几个流程,所以可以将“捕获”理解成一次特定的,向特殊控件传输专门信息的方案,但这块接触的应用不多,还不是很能理解。

bool FSlateApplication::ProcessMouseButtonDownEvent( const TSharedPtr< FGenericWindow >& PlatformWindow, const FPointerEvent& MouseEvent ){
    ...
    if (!SlateUser->IsDragDropping())//还顺便处理的拖动的情况,这里也的理解也不是很深
    {
        FReply Reply = FReply::Unhandled();
        if (SlateUser->HasCapture(MouseEvent.GetPointerIndex()))//是否被捕获
        {    
            Reply = FEventRouter::Route<FReply>(...);
            ...
        }
        else{
            ...
            Reply = RoutePointerDownEvent(WidgetsUnderCursor, MouseEvent);//不是就检查聚焦
            ...
        }
}

控件树在上面的SWidget结构中已经提到过了,这里指的最终形成的在UMG控件反射器上表现的UI树形结构。

UI控件树的单位:FArrangedWidget,内含一个SWidget,这个单位也作为Route函数中lambda表达式的参数类型之一

Route

Route直译过来就是路线,结合源代码中的作用,理解成“利用UI控件树的结构路径,通过某种算法,找到这个事件应该由哪个控件接手”。

	template< typename ReplyType, typename RoutingPolicyType, typename EventType, typename FuncType >
    static ReplyType Route( FSlateApplication* ThisApplication, RoutingPolicyType RoutingPolicy, EventType EventCopy, const FuncType& Lambda, ESlateDebuggingInputEvent DebuggingInputEvent)
    {
        ReplyType Reply = ReplyType::Unhandled();
        const FWidgetPath& RoutingPath = RoutingPolicy.GetRoutingPath();
        const FWidgetPath* WidgetsUnderCursor = RoutingPolicy.GetWidgetsUnderCursor();
        

#if WITH_SLATE_DEBUGGING
FSlateDebugging::FScopeRouteInputEvent Scope(DebuggingInputEvent, RoutingPolicyType::Name);
#endif

    EventCopy.SetEventPath( RoutingPath );

    for (; !Reply.IsEventHandled() &amp;&amp; RoutingPolicy.ShouldKeepGoing(); RoutingPolicy.Next())
    {
        const FWidgetAndPointer&amp; ArrangedWidget = RoutingPolicy.GetWidget();

        if constexpr (Translate&lt;EventType&gt;::TranslationNeeded())
        {
            const EventType TranslatedEvent = Translate&lt;EventType&gt;::PointerEvent(ArrangedWidget, EventCopy);
            Reply = Lambda(ArrangedWidget, TranslatedEvent).SetHandler(ArrangedWidget.Widget);
            ProcessReply(ThisApplication, RoutingPath, Reply, WidgetsUnderCursor, &amp;TranslatedEvent);
        }
        else
        {
            Reply = Lambda(ArrangedWidget, EventCopy).SetHandler(ArrangedWidget.Widget);
            ProcessReply(ThisApplication, RoutingPath, Reply, WidgetsUnderCursor, &amp;EventCopy);
        }
    }

    return Reply;
}

其实我也不是很想直接贴源码,太难看了,但是还是嫌麻烦,所以不想看的话就直接看我总结:

  • Route通过某种策略RoutingPolicy来获取一系列要处理的ArrangedWidget(前面提到的控件树结点)
  • 所谓的处理也就是调用传入Route的Lambda函数,看前面的代码可以知道这个Lambda函数其实就是在调用具体控件里的类似OnMouseButtonDown方法,这些方法还会返回FReply回复
  • 这里的RoutingPolicy涉及到Next()等方法,感兴趣的可以进去看看并学习下算法
  • 核心是一个for循环,意思就是遍历通过这些“策略”获取到的SWidget,调用他们的OnMouseButtonDown(或者其他事件)方法,然后这些方法还会返回Reply,如果Reply是Handled了,或者策略不是ShouldKeepGoing了,就停止循环。
  • 那么SWidget的遍历顺序就很重要了,因为如果遍历的过程中被某个Widget返回了Handled,那么循环就停止了,相当于事件被“截胡”了

所谓的Handled、Unhandled,没有具体的定义必须什么时候要返回被处理,其实理解成设计思想就行,Handled也就意味着这次点击起到了实质性作用了就返回处理,反之则不处理,体现在源码中举个例子就是Button控件的事件通常会返回Handled,而像是VerticalBox这种布局控件通常就会返回Unhandled,当然这些都是可以自行定制的,没有绝对的限制。

那么具体有哪些策略呢?直接偷一张别人的图

img

  • FDirectPolicy只查找根节点,用于拖拽
  • FToLeafmostPolicy只查找最后的叶子节点,用于Capture
  • FTunnelPolicy从根节点往下遍历,用于PreviewMouseButton
  • FBubblePolicy从叶子节点往上遍历,用于上面提到的以外的情况

 

特殊:鼠标事件的双层路由机制

对于MouseButtonDown,也就是上文提到过的源代码的内容,会发现它的处理方式是先从父节点遍历到子节点,触发OnPreviewMouseButtonDown,然后再从叶子节点往上遍历(如果没被Handled),触发OnMouseButtonDown

这里的设计叫做“双层路由机制”,一是在父节点上预留一个接口,用于处理比如“点击统计”或者阻断式的“弹窗”等,从上往下遍历;二是为了保证点击事件能更直观,比如按钮下层有另一个按钮,那么肯定是直接触发这个子按钮,所以要从下往上遍历

 

HitTestGrid

时间紧任务重,不想具体研究这个了,以后再说吧,实在不行参考别人写的:【UE·底层篇】Slate源码分析——点击事件的触发流程梳理_ue slate-CSDN博客

 

UI渲染流程

大致可分为以下几个流程:

  • 触发渲染指令(在tick中)——标志函数:FSlateApplication::DrawWindows()
  • 测量布局,控件树执行中序遍历,自下而上询问每一个控件的DesiredSize——标志函数:FSlateApplication::DrawPrepass()
  • 排列绘制,自上而下计算控件要显示的真正大小——标志函数:FSlateApplication::DrawWindowAndChildren()
  • 生成绘制指令与渲染批次,然后在渲染线程中进行合批,最后传入RHI线程内执行GPU绘制

准备(GameThread)

DesiredSize顾名思义就是“期待大小”,由每个控件本身决定,每个控件都实现ComputeDesiredSize()——SWidget内,比如图片可以设置为图片原大小,也可以设置字体为原大小等。对于不含子项的控件,按照自己的属性和缓存去决定DesiredSize,对于含子项的控件需要依据子项大小和本身的计算逻辑去算自己的DesiredSize。

在绘制过程中,因为已经知道了子控件的DesiredSize,所以自顶向下排列(排列的函数ArrangeChildren()也需要SWidget中的每个控件去实现)并绘制,比如某父项在OnPaint时,会优先给子项预留他的DesiredSize的空间,然后递归调用子项的OnPaint来绘制。

 

开始(RenderThread)

每个OnPaint最终都会将要绘制的图元信息传入一个Slate的绘制元素列表中,这个列表每一帧都会更新,渲染线程会不断消耗这个列表。

渲染线程,在UE中封装了一个类来处理Slate的渲染,那就是FSlateRenderer,这个类会根据图形API的不同被派生成不同的类,例如FSlateRHIRenderer、FSlateOpenGLRenderer等等。

int32 SImage::OnPaint( const FPaintArgs& Args, const FGeometry& AllottedGeometry, const FSlateRect& MyCullingRect, FSlateWindowElementList& OutDrawElements, int32 LayerId, const FWidgetStyle& InWidgetStyle, bool bParentEnabled ) const {
    ....
    FSlateDrawElement::MakeBox(OutDrawElements, LayerId, AllottedGeometry.ToPaintGeometry(), ImageBrush, DrawEffects, FinalColorAndOpacity);
    .....
    return LayerId;
}

由上面的代码中可见,生成了一个FSlateDrawElement,这个元素会被进一步包装成FSlateRenderBatch,然后再传给渲染线程进行合批操作。

合批

OnPaint自己返回了一个LayerId。

SWindow::Paint返回的LayerId为0,依据传递链通过OnPaint依次将这个参数传递给控件并返回新值,大部分控件不会改变这个值,有子项的可能会改变,(部分)规矩为:

  • SCompoundWidget 包含一个子控件,它会使子控件的LayerId + 1
  • SPanel包含多个子控件,不改变LayerId,所有子控件都继承父控件的LayerId

渲染线程中的FSlateRenderBatch会根据LayerId从小到大排序,LayerId相同的可以合批,还有一些其他规则,具体参考:(99+ 封私信 / 54 条消息) 南山搬砖道人 - 知乎

总结的一些针对合批的优化:

img

合批完成后,生成RHICommand提交给RHIThread完成真正的Slate渲染。