Render EquationForward RenderingDeferred Rendering 延迟渲染Tile-based RenderingForward+(Tile-based Forward) RenderingCluster-based RenderingVisibility BufferReal Rendering PipelineUE4 实时渲染流程Frame Graph(Render Graph)
Render Equation
从 JT Kajiya 老先生的 render equation 开始。
第一节课教我们用 Mesh、材质、Shader,做 Culling。
第二节课:光和世界如何变化,材质系统。
第三节课:世界难以表达的巨大的地形、天空和云,用各种积分算法来近似 Kajiya 老先生的需求,把世界做的很大,有无限细节。
这节课教的都是细节,有 AO 才能感觉世界凹凸有致;有 Fog 才有层次感;世界东西太多,分辨率不够,就需要 AA 将其看起来更平滑;最后我们还需要美颜。
有了这些东西,我们还需要一个重要的绘制顺序。
这么多重要的算法,需要一套规则告诉我们谁先做,谁后做,这样才能保证这些是有序的。
游戏引擎的渲染和图形学的渲染最本质的区别:我们没有办法抽出一个简单干净的算法,实际上游戏画面里,我们有几十种上百种效果同时在运作,还要保证其不出问题。
我们需要有一个 Pipeline,这么多算法一次算出来。
最简单的 Pipeline (Forward Rendering):先算 Shadow map 算出光影明暗,然后把物体放进去,进行光照做 Shading,最后把结果放到后处理。
Forward Rendering
对每一个 Mesh 对每个光都渲染一遍。
天空要最后绘制。
透明物体也要最后绘制,需要做 Transparent Sorting。假如有不透明挡住透明物体,那透明物体就不需要绘制了。
如果有多个透明物,Transparent Sorting 算法就将透明物由远处到近绘制,首先绘制远的透明物,再依次绘制离相机距离近的。上图左下角就是一个例子,绿色和红色透明图的不同绘制顺序结果是不一样的。
Transparent Sorting 会导致很多 bug。这个算法很早期,有问题,上图左上角三个棍子三个透明度,不知道谁前谁后。游戏引擎中一般不管,由透明物体中心点进行排序。
扔手雷出的烟,就是一个个透明 particle,烟的排序就让游戏引擎难了很多年,后面粒子系统会讲遇到的困难。
Forward Rendering 一定把透明物体放在不透明物体之后绘制。
这个技术十几年前就被大家认为有问题,因为现代游戏场景中,光是非常复杂的,吊灯、火把、照明弹,光十分丰富,因此我们引入了 延迟渲染。
Deferred Rendering 延迟渲染
我们先把所有物体渲染一遍,先不算和光的关系,先把 Albedo、Specular、Normals、Depth 扔到巨大的 G-Buffer 里面。有了这些信息,把一块一块的光应用到屏幕上后就可以只算光的部分了,Shading 运算只会算最后一次。
潜在的好处:由于现在材质越来越统一(PBR),人们逐渐喜欢把材质数据拍到 G-Buffer 里面,这样计算高度一致。
这样能绘制很多光源的结果。
对光的处理可以非常方便,例如做 Screen space light,屏幕上加很多点光的时候,只需要在点光覆盖的地方画一个小的区域,这样可以画很多个点光源。
渲染容易 debug。
但是 G-Buffer 非常费,硬件上读写这些数据效率其实是非常低的。
Tile-based Rendering
移动端有个问题:对发热非常敏感,而存储芯片最容易发热。
所以手机做了这么一个结构:主板上有 DRAM(动态随机存取存储器),速度比较慢,读写消耗很多能量。chip 里面还做了 SRAM(静态随机存取存储器),频率特别高但尺寸特别小。因此绘制画面的时候在 Java 层面把画面切成一小块一小块,只渲染一小块的几何,渲染好了把那一小块的东西放到 Frame Buffer 里面,而不用存到巨大的 G-Buffer 里面。
现在手机主流还是用 Forward Rendering 也是这个道理。
如何减少对 Frame Buffer 的读写压力,可以把画面拆成块,这样效率更好。
这样拆额外的好处:光可以被 culling 到一个个 Tile 里面。屏幕上每个小 Tile,我们是知道它被几个光照着的。我们只要做简单的 View Frustum 的处理,就知道 Tile 里面到底有几个光可见,这个叫 Light List。
我们还可以再走一步。
实际上在渲染这个世界的时候,Shading 会生成 Z-Buffer 的深度。对每一个 Tile,我实际上可以知道最远和最近的 Tile 在哪里,例如上图右边摩托车,back face 是不会被 Shading 的,front face 才会形成一个个小的区域。假如有个点光源,点光源在空间是一个球,我们就能知道哪些 Tile 会被照到,哪些 Tile 虽然在空间中,但不会被照到。
上图中就是点光源只覆盖几个 Tile 的例子,这就是空间划分之后带来的很大的优势。
Tile-based Rendering 和 Tile-based Deferred Rendering 是现代手机很多主流的绘制方案,就是因为对光的处理非常高效。
下图中,五年前很多大作基本都是这种渲染。
Forward+(Tile-based Forward) Rendering
Forward+ 实际上就是 Forward Rendering + 一个 Tile 一个 Tile 绘制。
PC 游戏也有人按照这种方法去做。
前面我们算了 z-min z-max,那么如果我们直接对空间做切分呢?
Cluster-based Rendering
把空间分成一个个四棱锥的体柱——Cluster。上图中有上千个光源,能高效地做 Rendering。
Cluster-based Rendering 也在逐渐变成主流游戏用的方法。
实际上就是不用算 z-min、z-max 了,而是一个 Tile 一个 Tile 去算对光的 Visibility。
Visibility Buffer
现在正在蓬勃发展的新的 Render Pipeline 技术——Visibility Buffer。
前面提到所有的渲染大部分人用的都是 Deferred Rendering。本质上是把材质信息写到巨大的 G-Buffer 里面。
随着现代硬件的发展,人们发现其实可以把几何信息和材质信息剥离开来。
Frame Buffer 里面写这个像素属于哪个几何体(Primitive ID),属于哪个 Mesh Lists,Triangle ID 是什么,在三角形的重心坐标是什么。
这样就可以反向的查这个物体这个三角形用的是什么材质。应该是什么 Normal。
为什么剥离的这件事这么重要?过去的计算机里面,我们认为材质的渲染是比较复杂的,几何是比较简单的(只是一个个面片),但现代引擎,几何密度非常高,很多时候几何会超过像素。传统的写 G-Buffer 的方法,会浪费掉大量的几何的 Overdraw。然后要取大量的 Texture Fetching 带来的运算。
大家发现我们把几何写上就可以了。这也是现代引擎逐渐在变化的非常重要的发展方向。
传统的顶点,Vertex buffer 传进去,光栅化 Pixel Shading 的效率其实没有我们自己直接写光栅化的效率高。
做 Shading Texture Sampling 的时候,我们需要不断地查 G-Buffer,效率是非常低的。现在在 Visibility Buffer,做 Shading 的时候,取值之后直接查 VB、IB 的效率其实是非常高的。
Visibility Buffer 是现代 Pipeline 很重要的发展前沿方向。现在还是一种增强型的 Pipeline,未来可能变成一个主 Pipeline。
Visibility Buffer 还把没有用的材质信息都剔除掉了,只需要用自己要用的材质,而且它支持的材质类型会更加复杂丰富。而对 G-Buffer 来说,是假设材质是一致的。
Real Rendering Pipeline
上图已经是大量简化的 Pipeline 图。
UE4 实时渲染流程
这么多算法放一块怎么 Work?实际上是非常麻烦的。
- 每个游戏的渲染需求是不一样的,积木怎么搭起来?
- 计算之外,还占用存储资源(buffer)。可能一个buffer 计算用完之后,另一个计算就不需要了。如果不管理,很多 buffer texture 显存就浪费掉了。
- 简单的 Pipeline 能管理好,但是复杂的时候几乎没人能管理
- DirectX12、Vulkan 设计理念:把硬件复杂度暴露出来,如何管理内存,可读还是可写,内存还需要加锁,没加对就会导致整个游戏直接 crash,或者导致死锁。
Frame Graph(Render Graph)
例如 Unity 的 SRP。
把 Pipeline 里的东西变成 module、脚本,或者变成一个可拖拽的图。用有向无环图 DAG (Directed Acyclic Graph)把依赖表示出来,系统自动检查资源依赖性,自动检查资源可重用部分(例如一个 Buffer 后面用不到了就释放掉,或者在后面重用,这个叫 buffer aliasing),这样能避免很多错误。
大部分时间应该考虑一个工具怎么让算法不打架。
Frame Graph 是未来非常重要的发展方向。
Unity HDRP、URP 都是通过 SRP 定义的,这就封装的非常好。
Frame Graph 现在还不太成熟,还在探索接口怎么封装。思想值得了解。
渲染变得足够复杂的时候,我们定义了一个图形化的语言,把渲染本身的依赖关系表达出来了。这样才能把复杂的 3A 游戏开发有序地管理起来。