我所理解的RTR4-第3章图形处理单元

这一章中主要讲了三个部分内容:GPU架构以、GPU渲染管线和着色模型(shader model)。

事实上,GPU架构、GPU渲染管线、着色模型(Shader Model),这三个东西紧密相关。在发展的过程中,着色模型的提出让GPU架构与GPU渲染管线需要按照某种规定的方式提供数据,也演化出了相应的硬件结构,最终演化出了相应的渲染管线的阶段。

先来看GPU架构。

GPU架构

GPU拥有大量的处理单元,这些处理单元被称为着色核心(shader core),一个GPU通常拥有上千个着色核心(我的显卡是GTX2060,核心数1920)。所以,GPU适合做大量的,简单的操作(比如挖矿?)。就是那种只要你把数据和指令准备好,传给它算,中途不要有什么改变,也别妄图去控制的这种操作。

接着,拥有这么多着色核心的GPU就遇到了问题,事实上,所有的处理单元都会遇到的问题是:延迟(latency)。不是每个指令执行速度都相同的,碰到慢的指令,傻傻地让处理器等着它执行完再往下执行显然是非常low的选择。

GPU和CPU的目标任务不同,所以不存在什么取代关系,它们要相互依存。因为两者完全不同,一个CPU不需要这么多核心,所以CPU用来降低延迟的方法很难直接套到GPU上。

让处理器保持繁忙,从而在整体上降低延迟的技术就叫做延迟隐藏技术

GPU的延迟隐藏技术

设想这么一个场景,有一个物体已经完成了光栅化阶段,最后确定它占据了2000个像素,也就是说它的像素着色程序需要执行2000次。再假设,我们的GPU非常low,是史上最low的处理器,它只有一个着色核心。这就意味着,这个着色核心需要处理2000次着色程序。如果遇到一个比较慢的指令,比如纹理提取。

纹理提取指令非常慢,几百到上千个时钟周期,跟加减指令这种比起来慢的令人发指!

我们可以让着色核心等着,这样会很傻;我们也可以让其他的像素先执行着色,这样效率会很高。因为各个像素执行的着色程序是一样的,我们完全可以断定,到一定的时候,着色程序依旧会执行到慢指令,继续切换。

按照这个思路往下想,我们又上千个着色核心的情况下,不一定要一个着色程序一个着色程序这样调度,我们可以把调用相同着色程序的像素分批,一批一批地进行调度,这样效率更高。

事实上,N卡和A卡都是这样实现的。

在GPU的眼里,每一次的像素着色器调用都是一个线程(thread),这里的线程和CPU中的线程有区别。最明显的是,GPU眼中的线程都有配套的一小块存储区和寄存器,用于保存线程的数据,方便调用。然后,使用相同着色程序的线程被分为组,这个组叫做wrap。对N卡来说,一个wrap大概32个线程,GPU就是根据wrap来调度的。

分wrap,然后切换wrap执行是主流的延迟隐藏技术。

wrap交换,其中,txr是提取纹理操作,非常耗时

Warp技术的瓶颈

当然,wrap不是完美的解决方案,它也会有瓶颈。下面两种情况会对这种技术有很大的影响。
1、着色程序本身的结构
2、动态分支。

1、着色程序本身的结构

对线程来说,为了切换方便,它需要将自己用到的数据存储在寄存器里。如果它用的寄存器过多,那么耗尽GPU的寄存器也没法装几个线程的数据时,效率就急速下降。这点很容易理解。

2、动态分支

动态分支说的是如果着色器里有if条件语句,或者是循环语句,在运行着色器的时候,很有可能会将if的两个分支都走一遍,注意,是每个着色器运行的时候都会走两个分支,然后抛弃掉其中一个。剩下的那个作为最终的结果。

很难说着色器不会有条件判断或者循环语句,在写shader的时候,肯定是以实现功能优先,所以对GPU消耗的影响也只能是印象中的一种提示。

统一着色模型

统一着色模型,着色管线中的所有阶段都可以读取纹理或者缓存,他们所使用的指令集相同,并且他们可以在各个着色核心上运行。

从这种特意的说明可以推导,过去GPU上的着色核心可能是区分开来的,比如这个顶点着色核心就不能运行像素着色器。

着色模型4.0支持的统一虚拟机架构和寄存器布局

对只在CPU上编程的程序员来说(包括我自己),我开始并不理解它为什么要特意说明统一着色模型这个东西,因为对我来说,核心的指令集就应该是一样的,核心就该是通用的。经过长时间的思考,终于从GPU的角度理解了这一点。GPU来说,它是经过了多少年的发展才走到这一步,这不容易。

统一着色模型指的是Shader Model 4.0之后的版本,这个版本在2006年被提出来,并且由DX10提供支持。

着色管线

渲染管线

下一个单元是渲染管线,然后是可编程着色阶段。说实话,转折挺生硬的,不知道为啥直接讲这个了。这一节的开篇就直接讲了统一着色模型,接着是讲了着色语言HLSL和GLSL它们会被编译成更低层的IL(中间语言),然后才会被翻译成GPU支持的指令集。接着是编写着色器的时候,会有两种类型的输入,一种是uniform,一种是varying。对应的是常量和变量的区别。

顶点着色器

顶点着色器是渲染管线的第一个阶段,其实在它之前还有一个阶段,叫做输入装配Input Assembler(这是DX的叫法)。

输入装配的工作是将几股数据流汇聚到一起,生成新的顶点和图元集合。输入装配的工作还包括实例化。想想也是,对有很多实例的物体来说,不需要把所有的顶点网格数据都保存着,只需要保存在哪个地方有什么物体就可以,要绘制的时候再把这些数据填充进去。

顶点着色器接受的数据包括位置、颜色、纹理坐标法线值等等。特别是法线值需要注意,顶点的法线值表示的是顶点所在的表面的朝向,并不是三角形的朝向。因为我们是用三角形去模拟曲面,所以一个顶点的法线意味着这个曲面在这点的朝向,而不是三角形的朝向。

顶点着色器的工作是处理顶点数据,它可以修改、生成甚至忽略一些数据,并且还会把顶点从模型空间转换到齐次裁剪空间。但是,它不能增加或者减少顶点的数量,只能改变它的位置。基于这个功能,衍生出了顶点着色器可以完成的功能:
(1)将物体变形
(2)角色的身体或脸形的蒙皮与改变
(3)程序变形功能,例如随风飘动的旗子或者衣物
(4)粒子生成,用退化网格的方式来实现,后续步骤可以根据顶点生成面片显示,这里要注意它和几何着色器不同的地方就是它没法生成新顶点。
(5)镜头扭曲,雾效、水波纹等等效果。

很早以前,顶点着色器还做了计算顶点颜色的工作,现在基本上没有了,现在的着色工作都放到PS里,顶点着色器主要就是负责生成相关的数据方便后续计算。

细分阶段

以一种程序化的方式自动生成细致的曲面。这个阶段的工作就是根据表面描述生成一系列的三角形。

它用一种表面描述的形式从CPU传递信息给GPU,而不是直接把顶点全部传给GPU,这大大节省CPU与GPU之间总线带宽。

它包含了三个子阶段,壳着色,细分器,域着色器(DX的说法)。OpenGL的术语是细分控制着色器、图元生成器、细分计算着色器。相比而言,OpenGL的术语虽然长点,但是自解释性更强,我喜欢这种一看名字就知道有什么作用的术语。

细分阶段流程

细分控制着色器:读输入的数据进行处理,根据输入信息生成控制点位置,细分程度等信息。

  • 一般情况下,曲面都会有一些控制点,贝塞尔曲面也好,B样条也好,都会有控制点。
  • type类型,控制图元生成器生成什么类型的图元,三角形、四边形还是别的
  • TL表示tessellation level,细分等级,有两个,外等级和内等级。外等级是外边要被分成几份,内等级是图元内部要分几次。如果外等级设置成0或者NaN,表示丢弃这个patch。

听起来复杂的流程,但是细分控制着色器通常只会进行少量的改动,甚至不改动。

图元生成器:生成了点的位置,然后指定他们组成什么三角形或者直线。

细分计算着色器:利用每个点的重心坐标,配合patch里的计算方程生成新的位置、法线、纹理坐标以及其他的信息。

几何着色器

几何着色器可以将一种图元转换成另一种图元,设计它的目的是为了修改输入数据,或者生成一定数量的副本。

没有说如何实现的只是说了几个它的用处。包括:
(1)生成级联阴影贴图(CSM,cascaded shadow maps)
(2)生成新的例子
(3)毛发生成
(4)实例化(DX11引入)
(5)可以生成至多4个流

流输出是几何着色之后的一个可选阶段,它可以将数据输出,不往后面的渲染管线走,输出的数据可以用来做别的事。

像素着色

像素着色,也就是最后的阶段,就计算每一个像素的颜色,有可能直接输出,也有可能还需要混合。
像素着色处理的是一个个像素,并不只是它输出的是像素值,而且是它本身的输入也是一个个像素值,因为光栅化之后得到的就是一张2D图,或者说是一个2D的Buffer。

但是,像素着色器又需要计算很多东西,比如说反射模型,它的计算完全是依靠输入的数据,每一个像素都会有法线、深度这种信息,并不只有这么多,至少数据要多到可以进行说着色计算。

开始的时候,像素着色器计算之后的颜色值直接输出显示,但是随着发展,这样的做法就逐渐被淘汰了。现在的PS不仅可以输出计算后的数据进行保存,还可以输出多个buffer进行保存。这些缓存就叫做多渲染目标(Multiple Render Targets, MRT)。

因为历史的原因,PS的输出叫做render target,其实它就是一个buffer,直接把它当成buffer就行。

由于MRT的出现,新的渲染管线也就出现了,它被称为延迟渲染(Deferred Rendering)。与之相对的是正向渲染(Forward Rendering),正向渲染非常简单,PS计算的值就直接输出显示就行了。延迟渲染则不然,它需要PS把一些数据输出到Buffer里(也就是MRT),然后运用这些数据,一次性计算最终的画面值。这样做的好处就是,当场景中的光源多的时候,延迟渲染的效率比正向渲染高无数倍。

既然PS处理的是纹理图,那么它的一个重要责任自然就是纹理滤波(texture filtering),所谓纹理滤波,个人理解就是从纹理中找到这个像素覆盖纹理区域,并且通过某种算法,确定这个像素的颜色值。(你没法让一个像素显示两种颜色)

纹理滤波的方法很多,我也只是知道个皮毛,就不强行解释了。

不管怎么说,通过各种方法,不管是用什么管线,最终都能生成一张图,场景的2D图,输出到显示器上。

从各种角度来看,像素着色更像是加个滤镜,或者是修图。光栅化阶段生成东西,本质上就已经是张图了。只是它处理的数据多了点。如果有数据的话,使用PhotoShop都可以合成最终效果图。用数学或者物理的原理去处理图片。

合并阶段

合并阶段用来处理一些之前的阶段难处理的东西,比如说透明效果,反射效果等。

之前就说过,像素中会有z-depth信息,这些信息用来确定像素遮挡情况。如果一个像素档在另一个像素之前,那么后面的像素就会被抛弃。但是这有个问题,那就是如果我的像素被抛弃了,那么我之前做的计算不都做了无用功了吗?我能不能在计算之前就可以判断这个像素是不是被遮挡了,如果被遮挡了,那我就不算了。这就促成了一个新的阶段early-z。这个阶段通常在像素着色器之前进行,用于判断像素是否可见,不可见的像素不执行PS,省资源。

UE4就是这样做的,如果像素不可见,就不计算。

于是,合并阶段的一个重要工作就是透明效果的融合,以及一些倒影或者反射的效果。

计算着色器像是一个外挂,它并不在渲染管线中,但它和其他着色器运行在相同的着色核心上,可以访问缓存,也可以将数据输出到缓存上。可以用它做渲染的事情,也可以用来做单纯的计算(称为GPU计算),比如神经网络深度学习就用它(再比如,挖矿???)。CUDA和OpenCL就是将GPU作为一个并行计算器的平台。

总结

GPU是一个和着色模型,甚至是相关的着色语言共同发展的硬件,也许不只是GPU,CPU也是如此。一个硬件如何使用,会反过来塑造硬件的设计。就像一个人说什么话,会反过来塑造这个人。

好吧,我也有点乱,这章里讲的东西太乱了。

相关资料:
深入GPU硬件架构及运行机制:对这篇文章,我只想说一个字,牛逼!