渲染系统中常见的基础知识

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

通过BindXXXUnBind接口来将委托绑定到和解绑已有接口

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
2
sum = a + b + sum; 
print(sum);

编译为汇编指令后可能变为:

1
2
3
4
5
6
7
8
9
10
11
// 指令顺序情况 1
sum = a + b;
sum = sum + sum;

// 指令顺序情况 2
sum = b + sum;
sum = a + sum;

// 指令顺序情况 3
sum = a + sum;
sum = sum + b;

这种情况下会导致三种顺序结果都不相同,所以可以利用编译器的内存屏障指令强制指令的执行顺序

1
2
3
sum = a + b;
__COMPILE_MEMORY_BARRIER__; // 不同编译器有不同实现
sum = sum + c;

C++11 里是利用的:atomic_signal_fence(memory_order_acq_rel);

运行时内存屏障

简化后的屏障:读屏障(Load Barrier)和写屏障(Store Barrier),组合后可以实现四种指令:

  • LoadLoad:防止重排序后,在该屏障前后读取操作的乱序问题
  • StoreStore:防止重排序导致的在屏障前后写入操作的乱序问题
  • LoadStore:可以防止屏障前的加载操作屏障后存储操作的重排序
  • StoreLoad:可以防止屏障前的写入操作屏障后加载操作的重排序

使用例如下:

1
2
3
4
5
6
Value = x;          // 写入 Value
STORELOAD_FENCE(); // 在 IsValid 及后续所有读取操作执行前,保证 Value 的写入对所有处理器可见。
if (IsValid) // 加载并检测 IsValid
{
return 1;
}

UE 的内存屏障

因为 UE 对系统平台的多态封装,对调用者而言无需关注是哪个系统,无脑调用 FPlatformMisc::MemoryBarrier() 即可在代码中加入跨平台的运行时内存屏障(没有封装编译期内存屏障)

引擎启动流程

Windows 的程序入口是WinMain,经过内部分支最终进入GuardedMain,这里的逻辑主要由四个步骤:

  • 引擎预初始化(EnginePreInit)
  • 引擎初始化(EngineInit)
  • 引擎帧更新(EngineTick)
  • 引擎退出(EngineExit)

引擎预初始化

主要是和初始化以及基础核心相关的准备

最终当前执行WinMain的线程会被设置为游戏线程(主线程)并记录线程 ID

引擎初始化

分为编辑器和非编辑器两种模式

  • 非编辑器模式执行:FEngineLoop::Init
  • 编辑器模式执行:EditorInit + FEngineLoop::Init

引擎帧更新

引擎退出

  • 非编辑器模式,直接返回 ErrorLevel 值
  • 编辑器模式会执行 EditorExit 逻辑。它的主要工作是保存 Log,关闭和释放各个引擎模块

参考链接