Shader学习的前置知识
以下内容总结自unity shader入门精要第2章节
2 渲染流水线
渲染流水线与shader关系紧密。染流水线的最终目的在于生成或者说是渲染一张二维纹理,即我们在电脑屏幕上看到的所 有效果。它的输入是一个虚拟摄像机、一些光源、一些Shader以及纹理等。
2.1 综述
2.1.1 什么是流水线
设想一个这样的情景:你身处一个生产车间,在加工一种罐头。罐头的加工工序为填充、密封、包装、装箱,分别由四种机器来完成,这写工序的顺序不能乱。原来你是一个一个罐头加工,即对一个罐头完成四种工序后再开始下一个罐头的加工,但你发现这样效率很低,因为最多同时有一台机器在有效运作。
为改善这种环境,你引入了流水线,即当一个罐头的某个工序A完成移交到下一个工序后,立马对下一个罐头执行该工序A。如此一来,你最多可以同时加工4个罐头,效率翻了4倍。
流水线提高了你的单位时间产量。
2.1.2 什么是渲染流水线
上面的关于流水线的概念同样适用于计算机的图像渲染中。渲染流水线的工作任务在于由一 个三维场景出发、生成(或者说渲染)一张二维图像。换句话说,计算机需要从一系列的顶点数 据、纹理等信息出发,把这些信息最终转换成一张人眼可以看到的图像。而这个工作通常是由CPU 和GPU共同完成的。
可以将渲染流程分为3个阶段:
- 应用阶段(Application Stage)
- 几何阶段(Geometry Stage)
- 光栅化阶段(Rasterizer Stage)
注意,这里仅仅是概念性阶段,每个阶段本身通常也是一个流水线系统,即包含了子流水线 阶段。
应用阶段
从名字我们可以看出,这个阶段是由我们的应用主导的,因此通常由CPU负责实现。换句话 说,我们这些开发者具有这个阶段的绝对控制权。
在这一阶段中,开发者有3个主要任务:
- 准备场景数据。e. 摄像机位置、视锥体、场景中的模型、光源
- 粗粒度剔除(culling,提高渲染性能)。提出不可见物体。
- 设置每个模型的渲染状态。渲染状态包括但不限 于它使用的材质(漫反射颜色、高光反射颜色)、使用的纹理、使用的Shader等。这一阶段最重 要的输出是渲染所需的几何信息,即渲染图元 (rendering primitives)。通俗来讲,渲染图元可以 是点、线、三角面等。这些渲染图元将会被传递给下一个阶段——几何阶段。
几何阶段
几何阶段用于处理所有和我们要绘制的几何相关的事情。例如,决定需要绘制的图元是什么, 怎样绘制它们,在哪里绘制它们。这一阶段通常在GPU中进行。
通过对输入的渲染图元进行多步处理后,这一阶段将会输 出屏幕空间的二维顶点坐标、每个顶点对应的深度值、着色等相关信息,并传递给下一个阶段。
光栅化阶段
这一阶段将会使用上个阶段传递的数据来产生屏幕上的像素,并渲染出最终的图像。这一阶 段也是在GPU上运行。光栅化的任务主要是决定每个渲染图元中的哪些像素应该被绘制在屏幕 上。它需要对上一个阶段得到的逐顶点数据(例如纹理坐标、顶点颜色等)进行插值,然后再进 行逐像素处理。
2.2 CPU和GPU之间的通信
渲染流水线的起点是CPU,即应用阶段。应用阶段大致可分为下面3个阶段:
- 把数据加载到显存中
- 设置渲染状态
- 调用Draw Call
2.2.1 把数据加载到显存中
渲染所需数据先加载到内存中。显卡对显存的访问速度快于直接访问内存且大多数显卡没有直接访问RAM的权限,所以一些网格和纹理数据(顶点的位置信息、法线方向、顶点颜色、纹理坐标等)又会被加载到显存中。
之后,还需要通过CPU来设置渲染状态,指导GPU进行渲染。
2.2.2 设置渲染状态
渲染状态可以理解为,定义了场景中网格是怎样被渲染的状态(e. 顶点着色器vertexShader、片元着色器fragmentShader、光源属性、材质)。如果我们没有更改渲染状态,那么所有的网格都将使用同一种渲染状态。
在准备好上述所有工作后,CPU调用一个渲染命令Draw Call来告诉GPU开始渲染。
2.2.3 调用Draw Call
Draw Call就是一个命令,它的发起方是CPU,接收方是GPU。这个命令仅仅会指向一个需要被渲染的图元(primitives)列表。
当给定了一个Draw Call时,GPU就会根据渲染状态(例如材质、纹理、着色器等)和所有 输入的顶点数据来进行计算,最终输出成屏幕上显示的那些漂亮的像素。
2.3 GPU流水线
当GPU从CPU那里得到渲染命令后,就会进行一系列流水线操作,最终把图元渲染到屏幕上。
前面提到的几何阶段、光栅化阶段发生在GPU流水线中。
顶点着色器 (Vertex Shader) 是完全可编程的,它通常用于实现顶点的空间变换、顶点着色 等功能。曲面细分着色器 (Tessellation Shader) 是一个可选的着色器,它用于细分图元。几何着 色器 (Geometry Shader) 同样是一个可选的着色器,它可以被用于执行逐图元(Per-Primitive) 的着色操作,或者被用于产生更多的图元。下一个流水线阶段是裁剪 (Clipping), 这一阶段的目 的是将那些不在摄像机视野内的顶点裁剪掉,并剔除某些三角图元的面片。何概念阶段的最后一个流水线阶段是屏幕映射 (Screen Mapping) 。这一阶段是不 可配置和编程的,它负责把每个图元的坐标转换到屏幕坐标系中。
光栅化概念阶段中的三角形设置 (Triangle Setup) 和三角形遍历 (Triangle Traversal) 阶段 也都是固定函数(Fixed-Function)的阶段。接下来的片元着色器 (Fragment Shader) ,则是完全 可编程的,它用于实现逐片元(Per-Fragment)的着色操作。最后,逐片元操作 (Per-Fragment Operations) 阶段负责执行很多重要的操作,例如修改颜色、深度缓冲、进行混合等,它不是可 编程的,但具有很高的可配置性。
2.3.2 顶点着色器
顶点着色器 (Vertex Shader) 是流水线的第一个阶段,它的输入来自于CPU。顶点着色器的 处理单位是顶点,也就是说,输入进来的每个顶点都会调用一次顶点着色器。顶点着色器本身不 可以创建或者销毁任何顶点,而且无法得到顶点与顶点之间的关系。
顶点着色器需要完成的工作主要有:坐标变换和逐顶点光照。当然,除了这两个主要任务外, 顶点着色器还可以输出后续阶段所需的数据。
2.3.3 裁剪
裁剪的作用是剔除不在摄像机视野范围内的物体。
一个图元和摄像机视野的关系有3种:完全在视野内、部分在视野内、完全在视野外。完全 在视野内的图元就继续传递给下一个流水线阶段,完全在视野外的图元不会继续向下传递,因为 它们不需要被渲染。而那些部分在视野内的图元需要进行一个处理,这就是裁剪。例如,一条线 段的一个顶点在视野内,而另一个顶点不在视野内,那么在视野外部的顶点应该使用一个新的顶 点来代替,这个新的顶点位于这条线段和视野边界的交点处。
和顶点着色器不同,这一步是不可编程的,即我们无法通过编程来控制裁剪的过程,而是硬 件上的固定操作,但我们可以自定义一个裁剪操作来对这一步进行配置。
2.3.4 屏幕映射
这一步输入的坐标仍然是三维坐标系下的坐标。屏幕映射(Screen Mapping)的任务是把每个图元的x和y坐标转换到屏幕坐标系(Screen Coordinates)下。屏幕坐标系是一个二维坐标系,它和我们用于显示画面的分辨率有很大关系。
假设,我们需要把场景渲染到一个窗口上,窗口的范围是从最小的窗口坐标(x1, y1)到最大的窗口坐标(x2, y2),其中x1 < x2 且 y1 < y2。由于输入的坐标范围在-1到1,因此可以想象到,这个过程实际上是一个缩放的过程。
屏幕映射不会对输入的z坐标做任何处理。实际上,屏幕坐标系和z坐标一起构成了一个坐标系,叫做窗口坐标系(Window Coordinates)。这些值会一起被传递到光栅化阶段。
屏幕映射得到的屏幕坐标决定了这个顶点对应屏幕上哪个像素以及距离这个像素有多远。
注意:屏幕坐标系在OpenGL和DirectX之间存在差异:
这一阶段输出的主要信息是屏幕坐标系下的顶点位置以 及和它们相关的额外信息,如深度值(Z坐标)、法线方向、视角方向等。
2.3.5 三角形设置
由这一步开始就进入了光栅化阶段。光栅化阶段有两个最重要的目标:
- 计算每个图元覆盖了哪些像素
- 计算每个像素的颜色
光栅化的第一个流水线阶段是三角形设置 (Triangle Setup) 。这个阶段会计算光栅化一个三 角网格所需的信息。具体来说,上一个阶段输出的都是三角网格的顶点,即我们得到的是三角网 格每条边的两个端点。但如果要得到整个三角网格对像素的覆盖情况,我们就必须计算每条边上 的像素坐标。为了能够计算边界像素的坐标信息,我们就需要得到三角形边界的表示方式。这样一个计算三角网格表示数据的过程就叫做三角形设置。它的输出是为了给下一个阶段做准备。
2.3.6 三角形遍历
三角形遍历 (Triangle Traversal) 阶段将会检查每个像素是否被一个三角网格所覆盖。如果 被覆盖的话,就会生成一个片元 (fragment) 。而这样一个找到哪些像素被三角网格覆盖的过程就是三角形遍历,这个阶段也被称为扫描变换 (Scan Conversion) 。
三角形遍历阶段会根据上一阶段的计算结果来判断一个三角网格覆盖了哪些像素,并使用网格3个顶点的顶点信息对整个覆盖区域的像素进行插值。
这一步的输出就是得到一个片元序列。需要注意的是,一个片元并不是真正意义上的像素, 而是包含了很多状态的集合,这些状态用于计算每个像素的最终颜色。这些状态包括了(但不限 于)它的屏幕坐标、深度信息,以及其他从几何阶段输出的顶点信息,例如法线、纹理坐标等。
2.3.7 片元着色器
前面的光栅化阶段实际上并不会影响屏幕上每个像素的颜色值,而是会产生一系列的数据信 息,用来表述一个三角网格是怎样覆盖每个像素的。而每个片元就负责存储这样一系列数据。真正会对像素产生影响的阶段是下一个流水线阶段——逐片元操作(Per-Fragment Operations)。
片元着色器的输入是上一个阶段对顶点信息插值得到的结果,更具体来说,是根据那些从顶点着 色器中输出的数据插值得到的。而它的输出是一个或者多个颜色值。
这一阶段可以完成很多重要的渲染技术,其中最重要的技术之一就是纹理釆样。为了在片元 着色器中进行纹理采样,我们通常会在顶点着色器阶段输出每个顶点对应的纹理坐标,然后经过光栅化阶段对三角网格的3个顶点对应的纹理坐标进行插值后,就可以得到其覆盖的片元的纹理坐标了。
虽然片元着色器可以完成很多重要效果,但它的局限在于,它仅可以影响单个片元。
2.3.8 逐片元操作
逐片元操作 (Per-Fragment Operations) 是OpenGL中的 说法,在DirectX中,这一阶段被称为输岀合并阶段 (Output-Merger) 。可以把这个阶段的工作简单理解为合并(Merger)。这一阶段的主要任务:
- 决定每个片元的可见性。涉及到很多测试工作,e. 深度测试、模板测试
- 如果一个片元通过了所有测试,就需要把这个片元的颜色值和已经存储在颜色缓冲区中的颜色进行合并(混合)
逐片元操作是高度可配置的
模板测试
如果开启了模板测试,GPU会首先 读取(使用读取掩码)模板缓冲区中该片元位置的模板值,然后将该值和读取(使用读取掩码) 到的参考值(reference value)进行比较,这个比较函数可以是由开发者指定的,例如小于时舍弃 该片元,或者大于等于时舍弃该片元。如果这个片元没有通过这个测试,该片元就会被舍弃。模板测试还有一些其它用法:渲染阴影、轮廓渲染。
深度测试
如果开启了深度测试,GPU 会把该片元的深度值和已经存在于深度缓冲区中的深度值进行比较。这个比较函数也是可由开发 者设置的,例如小于时舍弃该片元,或者大于等于时舍弃该片元。通常这个比较函数是小于等于 的关系。
合并
合并解决的主要问题:当我们得到这次的渲染结果时,我们仍然保有上次的渲染结果(要真正绘制后,上次的渲染结果才会消失)。我们应该如何处理这两个结果。
具体的操作和开发者的配置有关(e. webgl里可以设置gl.BLEND实现透明效果)。
上面给出的测试顺序并不是唯一的,而且虽然从逻辑上来说这些测试是在片元着色器之后进 行的,但对于大多数GPU来说,它们会尽可能在执行片元着色器之前就进行这些测试。这可以避免计算测试未通过的片元(本应该在逐片元操作中被舍弃的片元),从而减少计算量。
Early-Z是一种提前深度测试的技术,被unity采用
提前进行测试有可能会造成片元着色器中的冲突,现代的GPU会判断片元着色器中的操作是否和提前测试发生冲突,如果有冲突,就会禁用 提前测试。但是,这样也会造成性能上的下降,因为有更多片元需要被处理了。这也是透明度测 试会导致性能下降的原因。
当模型的图元经过了上面层层计算和测试后,就会显示到我们的屏幕上。我们的屏幕显示的 就是颜色缓冲区中的颜色值。但是,为了避免我们看到那些正在进行光栅化的图元,GPU会使用 双重缓冲 (Double Buffering) 的策略。这意味着,对场景的渲染是在幕后发生的,即在后置缓冲 (Back Buffer) 中。一旦场景已经被渲染到了后置缓冲中,GPU就会交换后置缓冲区和前置缓冲 (Front Buffer) 中的内容,而前置缓冲区是之前显示在屏幕上的图像。由此,保证了我们看到的 图像总是连续的。
2.4 补充
2.4.1 OpenGL和DirectX
OpenGL和DirectX是图像应用编程接口,这些接口用于渲染二维或三维图形。图像编程接口在各种寄存器、显存的基础上实现了一层抽象,让我们方便实现GPU编程。
2.4.2 HLSL、GLSL、CG
这三者都是着色器语言。这些语言会被编译成与机器无关的汇编语言,也被称为中间语言(Intermediate Language, IL)。这些中间语言再交给显卡驱动来翻译成真正的机器语言,即GPU可以理解的语言。
HLSL - DirectX。支持HLSL的平台相对比较有限,几乎完全是微软 自已的产品。
GLSL - OpenGL。可以在Windows. Linux、Mac甚至移动平台等多种平台 上工作,但这种跨平台性是由于OpenGL没有提供着色器编译器,而是由显卡驱动来完成着色器 的编译工作。也就是说,只要显卡驱动支持对GLSL的编译它就可以运行。
CG - NVIDIA。是真正意义上的跨平台。它会根据平台的不同,编译成相应的中间语言。
2.4.3 Draw Call
Draw Call本身的含义很简单,就是CPU 调用图像编程接口。
Draw Call中可能出现性能问题,而问题的原因很大可能在于CPU。
命令缓冲区
如果没有流水线化,那么CPU需要等到GPU完成上一个渲染任务才能再次发送渲染命令。 但这种方法显然会造成效率低下。我们 需要让CPU和GPU可以并行工作。而解决方法就是使用一个命令缓冲区 (Command Buffer) 。
命令缓冲区包含了一个命令队列,由CPU向其中添加命令,而由GPU从中读取命令,添加 和读取的过程是互相独立的。命令缓冲区使得CPU和GPU可以相互独立工作。当CPU需要渲染 一些对象时,它可以向命令缓冲区中添加命令,而当GPU完成了上一次的渲染任务后,它就可以 从命令队列中再取岀一个命令并执行它。
命令缓冲区中的命令有很多种类,而Draw Call是其中一种,其他命令还有改变渲染状态等。
Draw Call多了影响帧率的原因
总计同等大小,我们拷贝多个小文件所花费的时间远多于拷贝一个大文件。这是因为一个复制动作需要很多额外的操作,例如分配内存、创建各种元数据等。
同理,在每次调用Draw Call之前,CPU需要向GPU发送很多内容,包括数据、状态和命令等。在这一阶段,CPU需要 完成很多工作,例如检査渲染状态等。而一旦CPU完成了这些准备工作,GPU就可以开始本次 的渲染。但GPU强大的渲染能力往往使得GPU读取命令的速度大于CPU写入命令的速度。这就导致性能的瓶颈决定于Draw Call的次数。
减少Draw Call
可以使用批处理(Batching)。从文件拷贝的例子不难想到,一个很显然的优化想法就是把很多小的Draw Call合并成一个大 的Draw Call,这就是批处理的思想。
注意:由于我们需要在CPU的内存中合并网格,而合并的过程是需要消耗时间的。 因此,批处理技术更加适合于那些静态的物体,例如不会移动的大地、石头等
注意:利用批处理,CPU在RAM把多个网格合并成一个更大的网格,再发送给GPU,然后在Draw Call中渲染它们。 但要注意的是,使用批处理合并的网格将会使用同一种渲染状态。也就是说,如果网格之间需要使用不同的渲染状态,那 么就无法使用批处理技术
2.4.4 固定管线渲染
固定函数的流水线 (Fixed.Function Pipeline) ,也简称为固定管线,通常是指在较旧的GPU 上实现的渲染流水线。这种流水线只给开发者提供一些配置操作,但开发者没有对流水线阶段的 完全控制权。
2.5 Shader的定义
shader就是:
- GPU流水线上一些可高度编程的阶段,而由着色器编译出来的最终代码是会在GPU上运 行的;
- 有一些特定类型的着色器,如顶点着色器、片元着色器等;
- 依靠着色器我们可以控制流水线中的渲染细节,例如用顶点着色器来进行顶点变换以及传 递数据,用片元着色器来进行逐像素的渲染。