《Unity3D高级编程:主程手记》——引擎技术

《Unity3D高级编程:主程手记》——引擎技术

背景

身为一名游戏开发从业者在过去10多年时间里在很多项目中积累了经验同时身在大厂希望分享自己,为此我在2021年出版了一本书《Unity3D高级编程:主程手记》书中记录了我做过的所有项目各个模块的技术要点包括框架、算法和优化方案等等(博客:)。同时在前面的经历中我也深刻认识到引擎作为游戏研发的基础和依赖的重要性。多年下来对引擎也有了自己的认知,最近几年通过对引擎的解剖和分析深入了解了它的模块结构、流程、技术原理和实现方式。

最近萌生了一个想法希望能系统性的对游戏引擎进行解剖,于是开始了这个行动。我想通过这个解析过程更深入地理解引擎,因此决定写一系列文章来剖析引擎的各个模块,包括结构、流程、技术原理和实现方式,为了能与工作相结合从而发挥最大效果在解析原理的同时也会解析当下热门引擎。

由于过去几年中一直努力致力于性能优化,包括性能工具研发、性能分析和优化以及框架结构改造升级等,在工作中也明白了理解引擎中的架构和各模块的运作原理对于优化工作的重要性。实际上“引擎技术”已经不单单是引擎了为了方便描述我这里统称为“引擎技术”,同时为了能更好的理解引擎我在这个系列的文章中将用图的方式来描述原理、流程和结构,以加速自己和读者们的理解。

我将这个系列命名为《图解游戏引擎》,希望通过‘图’的方式形象化解读技术。写作过程也是我的学习过程,通过学习和回顾更深入地理解引擎的技术。这个系列的文章我希望能系统性的解析游戏引擎技术,涵盖包括引擎历史、架构、模块框架、执行流程、机制算法、硬件差异等,时间允许的话也会通过些自制引擎的案例来完成引擎技术实践。

目录

1、UMG框架结构

1.1 UMG框架

1.2 UMG结构

1.3 UMG动画

2、Slate UI框架结构

2.1 Slate结构

2.2 Slate控件和自定义

3、UI渲染结构和流程

3.1 渲染结构

3.2 渲染流程

3.3 渲染流程细节1

3.4 渲染流程细节2

4、UI合批和图集生成算法

4.1 UI合批算法

4.2 UI图集生成算法

5、UI输入响应机制

摘要:

UI合批算法通过FSlateElementBatcher在主程的渲染管线中收集需要合批的元素,并在渲染线程上进行元素排序、合批计算和网格合并;合批时控件的LayerId是最为关键的属性,每个控件都有一个LayerId通过Paint函数传参带入,每个子控件在调用OnPaint时针对传入的父控件的LayerId根基控件的特点有自己不同的影响;UI图集由FSlateTextureAtlas类来存储和表示,在编译阶段FSlateRHIResourceManager会通过样式资源生成图集,图集空间分为已使用空间和未使用空间,分别用已使用槽位队列和未使用槽位队列表示。在UI输入事件处理机制中游戏引擎教程,Slate主轮训体中检测到输入信息并发起输入事件调用,通过定位要响应的控件最终调用Widget中的虚函数事件将输入事件派发给控件,事件最终在UI蓝图中响应。

关键词:引擎UI,UMG,UI合批算法,图集生成算法,UI输入响应机制

内容

前面讲述了Unreal的UI结构和UI渲染,这篇来讲讲UI合批和图集生成算法

4、UI合批和图集生成算法

UI合批分两部分,合批算法和图集生成算法

4.1 UI合批算法

UI合批计算前需要收集需要合批的元素,用FSlateElementBatcher在主程的渲染管线中收集所有需要合批的元素。

引擎制作游戏_引擎教程游戏视频_游戏引擎教程

(合批前UI元素收集流程图)

收集合批元素发生在主线程上,FSlateElementBatcher通过渲染线程之前的计算处理收集完毕所有合批元素,再通过渲染线程做合批计算和网格合并。

UI合批在渲染线程上进行,包括元素排序、合批计算、网格合并,如下图:

游戏引擎教程_引擎制作游戏_引擎教程游戏视频

(UI合批流程图)

前期在主线程中将渲染处理函数推入到渲染线程中,再由渲染线程调用处理渲染逻辑。

渲染线程中,已经将用于合批计算的元素收集完毕,可以通过FSlateBatchData来做合批计算。FSlateBatchData首先通过StableSort对所有UI合批元素做LayerId的排序从小到大游戏引擎教程,接着根据合批规则筛选出可以合批的元素集合,最后通过CombineBatches对合批元素进行网格合并。

图中合批条件框中列表出的是程序代码中的合批条件,如下:

1、LayerId相同

2、图集相同

3、绘制类型相同

4、Shader相同

5、绘制效果相同

6、Shader参数相同

除以上还有其他9条等等。

其中控件的LayerId最为关键的生成算法为,每个控件都有一个LayerId通过Paint函数传参带入,每个子控件在调用OnPaint时针对传入的父控件的LayerId根基控件的特点有自己不同的影响,如下:

XXX::Paint(..., int32 LayerId, ...) const{    ...    int32 NewLayerId = OnPanit(..., LayerId, ...);    ...    return NewLayerId;}XXX::OnPaint(..., int32 LayerId, ...) const{    int32 MaxLayerId;    for (int32 Idx = 0; Idx < Children.Num(); ++Idx)    {        ...        const int32 CurWidgetsMaxLayerId = Children[Idx]->Paint(..., LayerId, ...);        MaxLayerId = FMath::Max(MaxLayerId, CurWidgetsMaxLayerId);    }    ...    OR    ...    MaxLayerId++;    ...    OR    MaxLayerId = LayerId;    ...    return MaxLayerId;}

(控件在OnPaint绘制时对LayerId影响的示例代码)

大部分的控件都是使用参数中的LayerId不会改变LayerId,同时继承自SCompoundWidget的控件由于包含一个子控件会使子控件的LayerId + 1,其他还有一些特殊的控件会影响LayerId,比如:

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。

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。这个控件会让子控件的LayerId快速增长,阻碍合批。

(控件逻辑说明清单,来源知乎作者-舒航)

控件自身的LayerId由自身的OnPaint绘制时决定,不同的控件对LayerId有不同的影响,因此在合批时就会出现各种断批的现象,同时LayerId不仅影响合批还影响UI点击碰撞测试。在开发时想要从合批角度做性能优化就要注意各个控件的合批因素,包括控件的LayerId、图集、绘制效果等,在条件符合的情况下才能有合批的情况。同时我们可以利用失效组件做性能优化,将不需要更新或不频繁更新的控件用失效区域的方式用RenderTexture去提交绘制。

4.2 UI图集生成

引擎制作游戏_游戏引擎教程_引擎教程游戏视频

(Slate图集结构图)

图集由FSlateTextureAtlas类来存储和表示,在编译阶段FSlateRHIResourceManager会通过样式资源生成图集,图集空间分为已使用空间和未使用空间,分别用已使用槽位队列和未使用槽位队列表示,FAtlasedTextureSlot就是表示图元槽位的类。

引擎制作游戏_游戏引擎教程_引擎教程游戏视频

(编译时构建图集的代码清单)

UMG在编译时或运行前会自动构建UI元素中的图集以避免运行时构建,构建函数如上图,在FSlateRHIResourceManager::CreateTextures中会获取所有贴图资源信息,载入并合并为图集。

游戏引擎教程_引擎制作游戏_引擎教程游戏视频

(UE图集的示例图)

构建后的图集如上图所示,在UE编辑器中可通过图集查看器查看到所生成的所有图集。

图集的生成算法如下图:

引擎制作游戏_游戏引擎教程_引擎教程游戏视频

(图集空间拆分后的数据结构)

图集在构建时通过二叉树的空间分割方式进行构建,容器上改用链表方式进行存储,链表组成的二叉树深度搜索会更快些。当未使用空间被占用而分割时分为两种情况,一种是常用的左右分割,另一种当高大于宽时将空间做上下分割,分割后放入空槽位的链表中整理成为二叉树结构,同时已使用的槽位信息放入到已使用槽位链表中不做任何排序。通过槽位信息的排序和存储为图集添加和删除提供了更好的空间利用率和生成算法。

5、UI输入事件响应机制

当鼠标或触屏板收到点击事件事,Slate的UI框架是如何接收输入事件并做出相应的响应的呢?下面来了解下Slate中的UI的输入机制。

游戏引擎教程_引擎制作游戏_引擎教程游戏视频

(输入事件响应流程图)

屏幕点击事件在各个操作系统中有所不同,Windows操作系统中使用事件的方式响应鼠标和输入事件,而在安卓和IOS上则使用轮训的方式检查触屏信息。操作系统的输入事件最终都会在引擎主线程中做逻辑处理进入到主逻辑轮训,在Slate中主轮训为应用类FSlateApplication。

首先,Slate主轮训体中检测到输入信息并发起输入事件调用,例如OnMouseDown、OnTouchStarted等事件调用。

接着,调用LocateWidgetInWindow开始定位要响应的控件。在窗体中使用FHittestGrid结构存储了很多Cell格子,每个Cell格子代表屏幕中的一个小方块,每个Widget元素都会加入到以Cell为单元的格子中。

然后,通过FHittestGrid::GetBubblePath调用计算出响应的UI控件。在FHittestGrid中有存储一个Cell数组,这个数组存储了屏幕中所有的Cell,每个Cell中存储有覆盖它的Widget控件,因此通过坐标就能定位到Cell再从Cell中取到所有覆盖它的控件。

最后,当定位到控件后立刻调用Widget中的虚函数事件将输入事件派发给控件,Widget中使用FEventRouter::Route派发事件最终事件在蓝图中响应。

Slate的窗体中Cell与空间关联如下图:

引擎教程游戏视频_游戏引擎教程_引擎制作游戏

UI界面中的Cell格子的分布图)

如上图所示,Cell在Widget加入到SWindow窗体时在相应的区域上被构建起来,同时当多个Widget覆盖同一个Cell时,Cell中存储着所有覆盖它的Widget(包括不完全覆盖的Widget)。

映射到UMG的UI画面中贴图笔刷,如下所示:

引擎教程游戏视频_引擎制作游戏_游戏引擎教程

(UE中的UI视图)

上图中,碰撞区域有三块,每个区域在碰撞范围内都有Cell覆盖,当鼠标或触屏点击时通过将点击的坐标信息转换成Cell在屏幕上的索引坐标,再从Cell中取出Widget,通过计算筛选出最接近输入坐标点并且LayerId层级最小的Widget有效控件。

本篇完

文章来源:http://mp.weixin.qq.com/s?src=11×tamp=1689837177&ver=4661&signature=-JYFgYhawok7Xg9H4r8ZsZ9eQowLsEATjqzh99rO2JD91TVytiDln3kSeO9NNMXsPGE4eOHVjHIyK81CryWY7ulcLA9KvLBAE5AYUjNnHmf5tuv8PjMzgFF0zLu9WTDn&new=1