渲染管线都是由多个Pass(微软翻译为传递)组成的,而一个Pass中包含了很多Drawcall,也就是渲染命令。Pipeline 包含 Pass,Pass包含Drawcall。简单来说,UE中内置的Pass基本都是写死的,很难通过除了直接修改引擎之外的方式去调整这些Pass。因此,我们这篇文章关注的就是如何在不修改引擎的前提下,将我们自己的数据传递给UE的渲染模块。
基本概念
GPU资源的抽象
我们知道,数据是需要从CPU的内存上,上载到GPU的显存中才能被GPU使用的(这里说的是普通PC的架构)。那么从工程的角度来说,CPU如果要与GPU交互,CPU就需要向GPU发送请求,然后GPU响应成功后返回一个代表了GPU内资源的句柄(Handle),CPU以后就可以通过这个句柄来给GPU发送操作这个资源的指令了。
这就涉及到了一个问题,CPU如何给GPU发送请求?最底层是PCIe总线的串行数据传输,但我们现在只需要关注在应用层面对于传输的封装就够用了。
- 在系统内核层面,运行着显卡驱动,显卡驱动负责的就是封装底层的通讯协议,转发应用的请求。
- 在应用层面(Windows),可以通过I/O Request Package来与驱动进行通讯,其实就是应用让系统帮忙往一个Buffer里写东西,驱动可以读这个Buffer。这里就涉及到了费时间的系统调用,这涉及到一个可能的优化点。
在用户层面,为了方便应用调用,大伙搞了一堆所谓的“图形API”(Graphic API),像是Vulkan、OpenGL、DirectX 3D啥的,那些主机平台也有自己的用户层图形接口。这些图形API的功能无非就是对GPU功能的一些抽象,提供点更友好的调用GPU的方式。但是,由于图形API是对硬件的封装,这就需要针对不同的硬件设计合适的驱动,所以不同平台可能对图形API的支持不同。像是MacOS,它只有一个叫做Metal的图形API是原生的,也就是说底下是真的对应了一个驱动。Vulkan在MacOS上的实现,其实就是把调用转发到Metal去。所以,为了保证跨平台的兼容性(其实还是依托✳✳,)大伙通常还会在图形API上封装一层抽象,在UE里这层抽象叫做Rendering Hardware Interface,RHI。RHI就像MacOS上的Vulkan一样,把对RHI的调用转发到当前平台的合适的图形API上。
所以,UE里带RHI后缀的非空对象,一般都是持有了某个图形API资源的句柄,比如说一块显卡上的Buffer。这个所谓的句柄,大多数情况下也就是个唯一的整数,跟真正的资源类似这样的关系:Map<Handle, VideoResource>
。
现在我们知道了GPU在用户层是如何被抽象出来的。希望深入了解这块架构的话,可以去看看麦老师他们搞的LuisaCompute,这里面解决了目前对图形API封装的架构的一些痛点。
图元数据
以前在LearnOpenGL刚接触到图元这么一个概念的时候,我其实还是有点懵的,因为不知道为什么要有这么一个玩意。但是我们通常所说的图元(Primitive),其实就是一堆顶点(Vertex)与边(Edge)构成的图形的关系。
所谓图形,可以是线(Line),三角形(Triangle),也可以是四边形(Quadrilateral,一般简称Quad,)还可以是任意的多边形(Polygon),但是这些东西本质上都是描述了顶点是如何构造边并构成一个闭合图形的。
不过虽然上面提到了好几种图元的组装(Assemble,术语,等价于上面的“构成”一词)关系,但是实时渲染中最常用的还是三角面,当然四边形也会偶尔用到,因为它的某些性质会让一些计算变得容易。
表示方式
最简单的表示三角面的方式,就是用三个顶点表示一个三角面(伪代码):
// 伪代码会默认使用UE的逆天坐标系,见下图
// 最后一位为齐次坐标,不用管,默认为1就行
type VertexPosition = Vector4f
// 这里在X-Y平面构造了一个三角面
let TriangleFace: Array<VertexPosition> = {
{0.0, 0.0, 0.0, 1.0}, // 原点
{1.0, 1.0, 0.0, 1.0}, // 右上方
{1.0, 0.0, 0.0, 1.0}, // 右侧
}

但是,如果图形再稍微复杂那么一点点,比如说我们要表示一个正方形。我们都知道正方形总共就四个顶点,但是如果按上面的表示方法,我们使用两个三角形来表示它所需的顶点数就是六个,这很不符合我们勤俭节约的价值观(雾。要知道,早期的显卡的显存可没现在这么大。
所以,我们就可以先把所有要用的顶点存下来,总共四个:
let VertexBuffer: Array<VertexPosition> = {
{0.0, 0.0, 0.0, 1.0}, // 原点
{0.0, 1.0, 0.0, 1.0}, // 上方
{1.0, 1.0, 0.0, 1.0}, // 右上方
{1.0, 0.0, 0.0, 1.0}, // 右方
}
然后再有一个索引,三个三个一组来取顶点位置就好(用指令数量换空间):
type FaceWithIndex = Vector3i
let IndexBuffer: Array<FaceWithIndex> = {
{0, 1, 2},
{0, 2, 3},
}
这样我们就画出来了一个正方形。这是所谓顶点缓冲(Vertex Buffer)与索引缓冲(Index Buffer)的由来,我们会经常用到这玩意。
虚幻引擎渲染数据获取流程
我们在本篇文章中,暂时只会讨论虚幻引擎中部分可编程的渲染流程。
渲染数据
这里之所以要强调所谓“渲染数据”,是因为这些数据是仅供渲染时使用的,更深入点的话,这些数据就是供“渲染线程”使用的。这是一个通用的做法,很多其它游戏引擎(如Bevy)也都会采用这个逻辑。
这里并不打算深入讨论为何渲染要使用单独的线程这个问题。我们只要了解下面几点就好了。
首先,我们要知道,因为游戏线程(Game Thread)和渲染线程(Render Thread)可能是同时并行运行的,所以为了让渲染线程与游戏线程同步,我们需要一个同步时机。至于为什么要同步,咱就是说,就算是所谓静态的StaticMesh,它的渲染信息也并非是一直不变的。比如说我们在编辑器中切了个线框(Wireframe)模式,那么当前视口(Viewport)的状态就要被设成线框,然后全部在关卡中的UPrimitiveComponent的子类(为什么是这个后面会讲),就要响应这个状态,把自己变成虚幻引擎内置的线框材质。
再者,渲染线程是一种受限于旧时总线通讯延迟而产生的东西,当然以前的驱动也要背一波锅。在以前,即时向GPU提交命令是一种非常低效费时的操作,具体原因可以看看这篇文章(点击后会跳到对应节,没有的话自己手动跳去调度机制小节看看)。大家都通过渲染线程与指令缓冲(Command Buffer,也有叫指令录制/指令编码的)机制来减少这个延迟。但是看看现代图形API(Vulkan、Direct3D12)吧,它们都不用自己实现了,直接用就完事了!
最后,为什么不能直接用游戏线程的数据,要拷一份出来浪费内存呢?因为这是俩可以一起跑的线程,万一这边还在用某个FDummyData*
,游戏线程那边因为玩家点了个啥东西把这玩意释放掉了,那不就寄了。相信这个大伙都知道,就不细讲了。
所以,我们需要一份数据给渲染线程用。我们知道虚幻引擎大部分代码是基于面向对象编程(OOP)模型的,渲染也不例外。我们会使用一个派生自FPrimitiveSceneProxy
的子类来保存渲染数据,比如说前面提到的顶点缓冲、索引缓冲以及各种其它RHI资源。下面会给出一段伪代码以方便大家理解虚幻引擎是如何获取这个保存渲染数据的SceneProxy
类的。
# 当某个图元组件渲染状态变化时
# 可能是刚刚被加入关卡中,
# 也可能是被调用了UPrimitiveComponent::MarkRenderStateDirty()函数
# 在编辑器中编辑属性(包括Transform)也会触发,至于为什么可以去看看它的生命周期
# 这也反映出MarkRenderStateDirty()是个费时的操作,
# 因为会将Primitive移除渲染场景并重新添加
OnPrimitiveRenderStateDirty = (InPrimitiveThatChanged) => {
# 遍历所有的渲染场景
# 渲染场景可以理解成关卡在渲染器中的数据类
for (All Scene) as Scene do
# 移除并释放渲染场景中原有的该Primitive的数据
Remove (InPrimitiveThatChanged) from Scene if exist
# 调用UPrimitiveComponent::CreateSceneProxy()成员函数
NewSceneProxy = Create FPrimitiveSceneProxy from (InPrimitiveThatChanged)
Set SceneProxy field in (InPrimitiveThatChanged) to (NewSceneProxy)
# 创建新的FPrimitiveSceneInfo类并塞进NewSceneProxy里
PrimitiveSceneInfo = Create a new FPrimitiveSceneInfo and set fields
Set PrimitiveSceneInfo field in (NewSceneProxy) to (PrimitiveSceneInfo)
# 在渲染线程初始化NewSceneProxy并添加进场景
RUN_IN_RENDERING_THREAD( () => {
# 调用 FPrimitiveSceneProxy::InitRenderResource()
Init NewSceneProxy
# 添加进场景
Add PrimitiveSceneInfo to (Scene) if not null
} )
done
}
# 渲染某个Scene时获取要渲染的东西的操作
OnCollectRendererElement = (InSceneToRender) => {
# 创建个收集器
# 方便统一分配资源
Collector = Make a meshbatch collector
for (All FSceneProxy in Scene) as SceneProxy do
# 调用FPrimitiveSceneProxy::GetDynamicMeshElements(
# const TArray< const FSceneView*...,
# const FSceneViewFamily& ViewFamily,
# uint32 VisibilityMap,
# FMeshElementCollector& Collector
# )
Collect TArray<FMeshBatch> from (SceneProxy) to (Collector)
done
}
上面看到了FMeshBatch
这玩意,它其实就是一组同一批被发送到GPU的数据,把一些东西一起合进一个批次可以减少RHI开销。