【剖析虚幻渲染体系】01 基础
渲染系统中常见的基础知识
Lambda
C++11 的特性,在渲染系统中,主要是游戏线程给渲染线程发送渲染命令
智能指针
C++11 引入智能指针,UE 也自己实现了一套,除了唯一指针、共享指针、弱指针之外,还实现了一种共享引用
UE 中:TSharedPtr, TUniquePtr, TWeakPtr, TSharedRef
虚幻 Objects 使用更适合游戏代码的单独内存追踪系统,因此这些类无法与 UObject 系统同时使用?
在 UE 中构造智能指针:
- TSharedFromThis::从 this 指针构造,获得对象的 TSharedRef
- MakeShared,MakeShareable(效率较低,但可以对私有构造函数的类使用):利用常规 C++ 指针创建共享指针
- StaticCastSharedRef、StatiCastSharedPtr:静态转换,常用于向下转换到衍生类型
- ConstCastSharedRef、ConstCastSharedPtr:const 转为 mutable
线程安全的访问模式:形式如TSharedPtr<T, ESPMode::ThreadSafe>
依赖于原子引用计数,性能比非线程安全版低,但行为和 C++ 常规指针一致:
- Read 和 Copy 为线程安全
- Write 和 Reset 必须要同步后才安全
Delegate
C++ 标准库并没有实现委托,UE 实现了一套委托机制(三种类型):
- 单点委托
- 组播委托
- 事件
- 动态物体
- UObject
- Serilizable
通过BindXXX
和UnBind
接口来将委托绑定到和解绑已有接口
Coding Standard
- 参考官方的常用编码规范
- STL 白名单
- atomic
- type_traits
- initializer_list
- regex
- limits
- cmath
- 类的声明要站在使用者角度,而非实现者。所以通常先声明共有接口或成员变量,再声明私有的
- 尽量使用 const
- 代码注释。特定的注释格式可提供自动文档系统生成编辑器的 Tooltips
容器
UE 和 STL 实际底层的实现机制可能存在很大的差异
数学库
代码在 Engine\Source\Runtime\Core\Public\Math 目录下
坐标空间
UE 使用左手坐标系,默认关卡(新建的场景)视图下,Z 轴向上,Y 朝左,X 朝视线后方
引擎模块
内存分配
内存分配基础
- FFreeMem:可分配的小块内存信息记录体
- FPoolInfo:内存池
- FPoolTable:双向链表存储了一组内存池,记录可用内存池和不可用内存池
- PoolHashBucket:存放由内存地址哈希出来的键对应的所有内存池
- 内存尺寸:涉及的参数比较多,有内存池大小(PoolSize)、内存页大小(PageSize)和内存块(BlockSize),实际大小与分配器、系统平台、内存对齐方式、调用者都有关系
内存分配器
- FMalloc 是 UE 内存分配器的核心类
内存操作方式
- GMalloc:GMalloc 是全局的内存分配器,在 UE 启动之初就通过 FPlatformMemory 被创建,FPlatformMemory 在不同的操作系统对应不同的类型
- FMemory:FMemory 是 UE 的静态工具类
- new/delete操作符:除了重载了的之外,其他全局的基本都是内部封装了 FMemory::Malloc 和 FMemory::Free
- 特定 API:其他
数情况下使用 new/delete 操作符和 FMemory 方式操作内存,直接申请系统内存的情况并不多见
垃圾回收
UE 的 GC 算法主要是基于 Mark-Sweep(标记 - 清理算法),用于清理 UObject 对象
Mark-Sweep 标记 - 清理算法
- 标记(Mark)阶段:遍历根的活动对象列表,将所有活动对象指向的堆对象标记为 TRUE
- 清理(Sweep)阶段:遍历堆列表,将所有标记为 FALSE 的对象释放到可分配堆,且重置活动对象的标记,以便下次执行标记行为
要防止某个对象(包括属性、静态变量)被 GC 清理,可借助 UObject 的 AddToRoot 接口
UE 的 GC
- 3 个步骤:1、索引可达对象;2、收集待清理对象;3、清理步骤 2 收集到的对象
- 在游戏线程上对 UObject 进行清理
- 线程安全,支持多线程并行 (Parallel) 和群簇 (Cluster) 处理,以提升吞吐率
- 支持全量清理,编辑器模式下强制此模式;也支持增量清理,防止 GC 处理线程卡顿太久
- 可指定某些标记的物体不被清理
内存屏障
内存屏障(Memory Barrier)又被成为 membar, memory fence 或 fence instruction
为了解决内存访问的乱序问题以及 CPU 缓冲数据的不同步问题
- 内存乱序问题可由编译期或运行时产生
- 编译期乱序是由于编译器做了优化导致指令顺序变更
- 运行时乱序常由多处理多线程的无序访问产生
编译期内存屏障
假如有以下代码:
1 | sum = a + b + sum; |
编译为汇编指令后可能变为:
1 | // 指令顺序情况 1 |
这种情况下会导致三种顺序结果都不相同,所以可以利用编译器的内存屏障指令强制指令的执行顺序
1 | sum = a + b; |
C++11 里是利用的:atomic_signal_fence(memory_order_acq_rel);
运行时内存屏障
简化后的屏障:读屏障(Load Barrier)和写屏障(Store Barrier),组合后可以实现四种指令:
- LoadLoad:防止重排序后,在该屏障前后的读取操作的乱序问题
- StoreStore:防止重排序导致的在屏障前后的写入操作的乱序问题
- LoadStore:可以防止屏障前的加载操作和屏障后存储操作的重排序
- StoreLoad:可以防止屏障前的写入操作和屏障后加载操作的重排序
使用例如下:
1 | Value = x; // 写入 Value |
UE 的内存屏障
因为 UE 对系统平台的多态封装,对调用者而言无需关注是哪个系统,无脑调用 FPlatformMisc::MemoryBarrier()
即可在代码中加入跨平台的运行时内存屏障(没有封装编译期内存屏障)
引擎启动流程
Windows 的程序入口是WinMain
,经过内部分支最终进入GuardedMain
,这里的逻辑主要由四个步骤:
- 引擎预初始化(EnginePreInit)
- 引擎初始化(EngineInit)
- 引擎帧更新(EngineTick)
- 引擎退出(EngineExit)
引擎预初始化
主要是和初始化以及基础核心相关的准备
最终当前执行WinMain
的线程会被设置为游戏线程(主线程)并记录线程 ID
引擎初始化
分为编辑器和非编辑器两种模式
- 非编辑器模式执行:FEngineLoop::Init
- 编辑器模式执行:EditorInit + FEngineLoop::Init
引擎帧更新
引擎退出
- 非编辑器模式,直接返回 ErrorLevel 值
- 编辑器模式会执行 EditorExit 逻辑。它的主要工作是保存 Log,关闭和释放各个引擎模块