Unity Shader入门精要 第2章 读书笔记
注意:图片的来源基本来自作者冯乐乐的GitHub,感谢作者分享
https://github.com/candycat1992/Unity_Shaders_Book
第2章 渲染流水线
什么是Shader(着色器):
1、GPU流水线上一些可高度编程的阶段,由着色器编译出来的最终代码是会在GPU上运行的(对于固定管线的渲染来说,着色器有时等同于一些特定的渲染设置)
2、有一些特定类型的着色器,如顶点着色器、片元着色器等等
3、依靠着色器可以控制GPU流水线中的渲染细节,例如用顶点着色器来进行顶点变换以及传递数据,用片元着色器来进行逐像素的渲染。
如果没有了解过渲染流水线的工作流程,就永远无法说自己对Shader已经入门
渲染流水线的最终目的:渲染一张二维纹理
输入:虚拟摄像机、光源、Shader 以及 纹理 等等。
流水线:
流水线之前:同一个人完成多个工序
流水线之后:步骤由专人完成,并行进行
流水线的好处在于可以提高单位时间的生产量。
每一个生产单位只专注处理某一个片段的工作,以提高工作效率及产量
原本每四个小时一个成品,现在每一个小时就可以生产出一个成品。
比如:
流水线之前,小明和其他三个人一共四个人,从工序1到工序4每个工序花费1个小时,4个小时一共生产出了4个布娃娃,但是工厂每次收到最终成品都是隔着4个小时。收到4个成品后,4个人又是重新开始生产。在领导来视察收获的时候,原本定额是第7个小时有7个布娃娃收获,但是这时候发现第7个小时仍然只有4个布娃娃,很不满意。
流水线之后,小明和其他三个人一共四个人,每个人专门负责一道工序,完成后交给下个负责人。4个小时前面虽然会有等待,但是流水线打通后,1个小时就能获得1个布娃娃成品。领导视察后发现。第7个小时能拿到7个布娃娃,很满意。
流水线存在的问题:决定最后生产速度的是最慢的工序所需的时间。
比如,第2个工序因为生产工具坏掉了,使用了低级的工具使用难度高需要花费4个小时,其他工序仅仅需要1个小时,那么原本是每1个小时收获1个布娃娃,但是这时候因为工序2拖住了流水线,变成了生产时间最大决定因素,变成了每4个小时才能生产出1个布娃娃。工序2是性能的瓶颈。
理想情况下,如果把一个非流水系统分成n个流水线阶段,且每个阶段消耗时间相同的话,会使整个系统得到n倍的速度的提升。
流水线与 Profiler 和预加载 的关联思考:
假如将前面的流水线的领导视察看成玩家,流水线看成游戏的显示,会发现最佳情况下,比如每一帧玩家应该固定看到一帧的变化,玩家会感觉顺畅无阻。但是假如在流水线卡住,则这时候玩家会看到界面卡住不动,过了几百毫秒后才显示到下一帧变化。使用 Profiler 观察性能数据的时候,为什么在优化后的水平线往往是持平或者平缓的,其实就是表示流水线的每个过程都很平衡,也就是说明了玩家体验不到明显的卡顿。这也是为什么优化的点往往就是在水平线越大抖动的地方。
另外对前面的打通流水线与资源预加载进行关联思考。假如流水线还没开始运行,这个过程如果第一道工序花费时间越长,那么后面其他几个工序都需要等待很长的时间,这时候工厂知道第二天领导会来视察,就提前一天开了一条专线让小明去做小明跑到另外一条流水线上提前一天把第一道工序做了,把一堆的东西放到了原来流水线的地方,第二天领导来了后就会发现流水线顺畅进行,所有人都在工作(当然小明不在这条流水线上),手上都在组装布娃娃。预加载同理,我们假定资源资源加载就是第一道工序,其他工序其实消耗的时间短。提前将资源加载消耗比较大的部分提前到开始游戏做专门的加载,当游戏使用资源到一些大点的资源的时候会发现已经可以直接使用,也不会有任何阻碍流水线的下一个工序,Profiler也会平滑。当然如果游戏的所有资源全部预加载也会对玩家不友好,比如十几个小明拿着一堆原材料 和 几百个拿着简单材料不需要提前加工的小红,全部都提前一天跑到另外一条流水线上会发现流水线都快被撑爆了,玩家等了可能都没等资源加载完就先把游戏关了。
什么是渲染流水线
工作任务:由一个三维场景出发、渲染一张二维图像。
计算机 CPU 和 GPU 共同完成,从一系列的顶点数据、纹理等信息触发,把这些信息最终转换成一张人眼可以看到的图像。
概念流水线:应用阶段、几何阶段、光栅化阶段
GPU流水线才是硬件真正用于实现概念流水线的流水线
应用阶段:(4)
CPU负责,应用主导,开发者绝对控制权
1、准备好场景数据(例如摄像机的位置、视锥体、场景中包含了哪些模型、使用了哪些光源等等)
2、粗粒度剔除(Culling 提高渲染性能,把不可见的物体剔除出去)
3、设置模型渲染状态(材质漫反射颜色、材质高光反射颜色、使用的纹理、使用的Shader等)
4、最终输出渲染图元(渲染所需要的几何信息,点、线、三角面等等),渲染图元传递给下一个的几何阶段
应用阶段的流水线是由开发者决定的
几何阶段:(3)
处理所有和要绘制的几何有关的事情。
在GPU上进行
1、和每个渲染图元打交道,进行逐顶点、逐多边形的操作。决定需要绘制的图元是什么,怎样绘制这些图元,在哪里绘制这些图元。
2、把顶点坐标变换到屏幕空间中,再交给光栅器进行处理。
3、输出屏幕空间的二维顶点坐标、每个顶点对应的深度值、着色等相关信息,并传递给下一个光栅化阶段
光栅化阶段:(2)
使用几何阶段传递的数据产生屏幕上的像素,并且渲染出最终的图形
在GPU上进行
决定每个渲染图元中的哪些像素应该被绘制在屏幕上
1、对几何阶段得到的逐顶点数据(纹理坐标、顶点颜色等)进行插值
2、逐像素处理
一、CPU和GPU之间的通信:(DrawCall 的产生流程,概念流水线中的应用阶段)
渲染流水线的起点是CPU,即应用阶段的3个阶段:
1、把所有渲染所需要的数据从硬盘中加载到系统内存中,然后其中的网格和纹理数据(还有顶点的位置信息、法线方向、顶点颜色、纹理坐标等)又被加载到显存中(显卡对于显存的访问速度更快,并且大多数显卡对系统内存没有直接访问权力)
渲染所需要的数据的加载流程:HDD->RAM->VRAM(最后成为GPU渲染流水线所需要的顶点数据)
数据从硬盘加载到RAM的过程十分消耗,数据加载到显存后,RAM中的数据就可以进行移除,但是如果CPU需要访问RAM中的数据(如访问网格数据来进行碰撞检测),就需要将RAM的这些数据保留下来。
2、通过CPU设置渲染状态,从而指导GPU进行渲染工作
渲染状态:定义了场景中的网格如何被渲染。
例如:使用哪个顶点着色器(Vertex Shader)/片元着色器(Fragment Shader)、光源属性、材质等。
如果没有更改渲染状态,那么所有的网格都将使用同一种渲染状态。(在同一状态下渲染三个网格。由于没有更改渲染状态,因此三个网格的外观看起来像是同一种材质的物体)
通过更改渲染状态,完成材质信息的设置。
3、设置渲染状态后,调用渲染命令(DrawCall)
Draw Call(OpenGL 的 glDrawEelements 和 DirectX 的 DrawIndexedPrimitive)
发起方:CPU
接收方:GPU
一个Draw Call 仅仅会指向一个本次调用需要被渲染的图元(primitives)列表,而不会包含任何材质信息。
图元就是组成图像的基本单元,比如三维模型中的点、线、面等等,注意 图元 与 片元 的区别,片元就是以后的像素点,它比像素多一些位置、法向量等属性。
当给定了一个DrawCall后,就会执行GPU流水线。GPU就会根据渲染状态(例如材质、纹理、着色器等)和所有输入的顶点数据来进行计算,最终输出屏幕上显示的那些漂亮的像素。
DrawCall中造成性能的元凶其实是CPU
命令缓冲区(Command Buffer):
CPU向命令缓冲区添加命令,GPU从命令缓冲区读取命令(DrawCall只是命令的其中一种,还有改变渲染状态等),CPU和GPU相互独立工作。
改变渲染状态:改变使用的着色器,使用不同的纹理。相比 DrawCall 命令,改变渲染状态更加耗时。
在每次调用 DrawCall之前,CPU需要向GPU发送很多内容,包括数据、状态和命令等。在这一阶段,CPU需要完成很多工作,例如检查渲染状态等。而一旦CPU完成这些准备工作,GPU就可以开始本次的渲染,渲染200个还是2000个三角网格对于GPU而言没有什么区别,因此GPU的渲染速度往往快于CPU提交命令的速度。如果DrawCall数量太多,CPU就会把大量时间花费在提交DrawCall上,造成CPU的过载。命令缓冲区已经没有可以执行的命令,GPU处于空闲的状态,而CPU还没有准备好下一个渲染命令。
减少DrawCall的方法:
1、批处理(Batching):把很多小的DrawCall合并成一个大的DrawCall
避免使用的大量很小的网格,当不可避免地需要使用很小的网格结构时,考虑是否可以合并这些网格然后使用批处理。
在CPU的内存中合并网格的过程是需要消耗时间的,所以批处理更加适合于静态的物体,只需要合并一次即可。利用批处理,CPU在RAM把多个网格合并成一个更大的网格,再发送给GPU,然后在一个Draw Call中渲染它们。但要注意的是,使用批处理合并的网格将会使用同一种渲染状态。也就是说,如果网格之间需要使用不同的渲染状态,那么就无法使用批处理技术。
2、避免使用过多的材质。尽量在不同的网格之间共用同一个材质。方便使用批处理。
二、GPU流水线:(概念流水线中的几何阶段和光栅化阶段)
最终把图元渲染到屏幕上
开发者虽然无法拥有绝对的控制器,但是每个阶段GPU提供了不同的可配置性或可编程性,实现的载体是GPU。
GPU通过流水线化,加快了渲染速度。
绿色表示该流水线阶段是完全可编程控制的,黄色表示该流水线阶段可以配置但不是可编程的,蓝色表示该流水线阶段是由GPU固定实现的,开发者没有任何控制权。实线表示该shader必须由开发者编程实现,虚线表示该Shader是可选的
输入:应用阶段加载到显存中,然后再由DrawCall指定的顶点数据
顶点数据传递给顶点着色器
顶点着色器(Vertex Shader):
必需着色器,完全可编程,用于实现顶点的空间变换、顶点着色等功能。
曲面细分着色器(Tessellation Shader):
可选着色器,细分图元
几何着色器(Geometry Shader):
可选着色器,执行逐图元(Per-Primitive)着色,或者被用于产生更多的图元。
裁剪(Clipping):
可配置,将不再摄像机视野内的顶点裁剪掉,并且剔除某些三角图元的面片。
使用自定义的裁剪平面来配置裁剪区域,也可以通过指令控制裁剪三角图元的正面还是背面。
屏幕映射(Screen Mapping):
不可配置和不可编程。把每个图元的坐标转换到屏幕坐标系中。
三角形设置(Triangle Setup)和三角形遍历(Triangle Traversal):
不可配置和不可编程。固定函数(Fixed-Function)的阶段。
片元着色器(Fragment Shader):
非必需着色器,完全可编程,实现逐片元(Per-Fragment)的着色操作
逐片元操作(Per-Fragment Operations):
可配置,执行很多重要操作,例如修改颜色、深度缓冲、进行混合等。
顶点着色器(Vertex Shader) 详解:
流水线的第一个阶段,输入来自于CPU。
处理单位:顶点(输入进来的每个顶点都会调用一次顶点着色器)
顶点着色器本身不可以创建或者销毁任何顶点,
顶点具有相互独立性,无法得到顶点与顶点之间的关系(两个顶点是否属于同一个三角网格),GPU可以并行化处理每一个顶点,处理速度快。
完成的主要工作:
1、顶点的坐标变换
对顶点的坐标(即位置)进行某种变换。顶点动画模拟水面、布料等等。
注意:无论在顶点着色器中怎样改变顶点的位置,一个最基本的顶点着色器必须完成的一个工作是:把顶点从模型空间转换到齐次裁剪空间。
o.pos = mul(UNITY_MVP, v.position);
完成坐标转换后,再由硬件做透视除法,最终得到归一化的设备坐标(NDC)
注意:
OpenGL NDC = Unity NDC = z分量的范围在 [ -1, 1]之间
DirectX NDC = z分量的范围在 [ 0, 1]之间
2、计算和输出顶点的颜色:逐顶点光照
3、输出后续阶段需要的数据
经过光栅化后交给片元着色器进行处理
裁剪(Clipping) 详解:
不可编程,硬件的固定操作,但是可以自定义一个裁剪操作来对这一步进行配置
不在摄像机视野范围内的物体不需要被处理
一个图元和摄像机视野的3种关系:
1、完全在视野内(继续传递给下一个流水线阶段)
2、部分在视野内(裁剪)
3、完全在视野外(因为不需要被渲染,所以不会继续向下传递)
将图元裁剪到单位立方体内(NDC)
和单位立方体相交的图元(黄色三角形)会被裁剪,新的顶点会被生成,原来在外部的顶点会被舍弃
屏幕映射(Screen Mapping) 详解:
输入:在单位立方体内的三维坐标系下的坐标
任务是把每个图元的 x 和 y 坐标转换到屏幕坐标系(Screen Coordinates)下。
屏幕坐标系是一个二维坐标系,与显示画面的分辨率有很大关系
缩放的过程
屏幕映射不会对输入的z坐标做任何处理,但是屏幕坐标系和z坐标一起构成了窗口坐标系(Window Coordinates)。最后这些值会一起被传递到光栅化阶段。
屏幕映射得到的屏幕坐标决定了这个顶点对应屏幕上的哪个像素以及距离这个像素有多远
注意:屏幕坐标系的差异(如果发现得到的图象是倒转的,可能就是这个原因造成的)
OpenGL:屏幕左下角 是 (0,0)
DirectX:屏幕左上角 是 (0,0)——> 微软的窗口坐标系统 与 一般阅读方式 一致,从左到右从上到下。
输出:屏幕坐标系下的顶点位置以及和这些顶点相关的额外信息(深度值z坐标、法线方向、视角方向等等)——三角网格的顶点(即三角网格每条边的两个端点)
光栅化的两个重要任务:
1、计算每个图元覆盖了哪些像素
2、为这些像素计算它们的颜色
三角形设置(Triangle Setup):光栅化的第一个流水线阶段 详解
开始进入光栅化
开发者没有任何权限
计算光栅化一个三角网格所需的信息(计算三角网格表示数据的过程)
通过得到三角形边界的表示方式,从而去计算每条边上的像素坐标,最后再得到整个三角网格对像素的覆盖情况。
三角形遍历(Triangle Traversal):片元的生成流程 详解
开发者没有任何权限
检查每个像素是否被一个三角网格所覆盖,这个过程也被称为 扫描变换(Scan Conversion)
如果被覆盖的话,就会生成一个片元(fragment)
三角形遍历阶段会根据上一个阶段(三角形设置阶段)的计算结果来判断一个三角网格覆盖了哪些像素,并使用三角网格的3个顶点的顶点信息对整个覆盖区域的像素进行插值。
最后输出得到一个片元序列。注意:一个片元并不是像素,只是包含了很多状态(片元的屏幕坐标、深度信息,以及从几何阶段输出的顶点信息例如法线、纹理坐标等)的集合,这些状态用于计算每个像素的最终颜色。
三角形设置 和 三角形遍历 并不会影响屏幕上的每个像素的颜色值,而是会产生一系列的数据信息,用来表述一个三角网格是怎样覆盖每个像素的。每个片元就负责存储这样一系列的数据。
片元着色器(Fragment Shader) 详解:
非常重要的可编程着色器
在DirectX中被成为像素着色器(Pixel Shader)
输入:三角形遍历阶段对顶点信息插值得到的结果
输出:一个或者多个颜色值
在片元着色器阶段可以完成很多重要的渲染技术(如纹理采样)
为了在片元着色器中进行纹理采样,会在顶点着色器阶段输出每个顶点对应的纹理坐标,然后经过光栅化阶段对三角网格的3个顶点对应的纹理坐标进行插值后,就可以得到其覆盖的片元的纹理坐标。
片元着色器仅仅可以影响单个片元(相对独立性)。片元着色器不可以将自己的任何结果直接发送给附近的其他片元着色器,但是片元着色器可以访问到导数信息(gradient)。
逐片元操作(Per-Fragment Operations) 详解:
渲染流水线的最后一步。
高度可配置 不可编程
可以设置每一步的操作细节
DirectX中被称为 输出合并阶段(Output-Merger)
对每一个片元进行操作
1、决定每个片元的可见性(模板测试、深度测试 等等其他的测试工作)
模板测试(Stencil Test) 和 深度测试 (Depth Test)
事关是否可以理解 渲染队列,处理透明效果
模板测试 -> 模板缓冲(Stencil Buffer)
比较函数可以由开发者指定,片元没有通过测试就会被舍弃
修改模板缓冲区也可以由开发者指定,可以根据模板测试和深度测试的结果进行修改,设置不同结果下的修改操作(例如在失败时模板缓冲区保持不变,通过时将模板缓冲区中对应位置的值加1等)
模板测试可用于限制渲染的区域,也可以渲染阴影,轮廓渲染等。
关于 读取掩码 和 子网掩码 的关联思考:
掩码是一串二进制代码对目标字段进行位与运算,屏蔽当前的输入位。
子网掩码的应用:
子网掩码是用来判断任意两台计算机的IP地址是否属于同一子网络的根据。
最为简单的理解就是两台计算机各自的IP地址与子网掩码进行AND运算后,如果得出的结果是相同的,则说明这两台计算机是处于同一个子网络上的,可以进行直接的通讯。
读取掩码的应用:readMask 不会对&操作的数产生效果,按位&的结果还是原来本身
深度测试
高度可配置
开启了深度测试后,GPU会把片元的深度值和已经存在于深度缓冲区中的深度值进行比较,不通过测试的片元会被舍弃。
比较函数可以由开发者指定
比如只显示出离摄像机最近的物体,被其他物体遮挡的不需要出现在屏幕上
深度测试 与 模板测试 的区别:(深度写入的开启和关闭)
透明效果和深度测试以及深度写入的关系非常密切
2、如果一个片元通过了所有的测试,就需要把 这个片元的颜色值 和 已经存储在颜色缓冲区中的颜色 进行混合( Blend )操作,最后再写入颜色缓冲区中
颜色缓冲区:每个像素的颜色信息的存储地方
当执行此次渲染时,颜色缓冲中往往有上次渲染之后的颜色效果。
合并操作:决定了使用这次渲染得到的颜色完全覆盖掉之前的结果,还是进行其他处理
混合操作:高度可配置
混合 类似Photoshop中对图层的操作
开启/关闭混合功能:
没有开启混合功能的话,将无法得到透明效果。
1、关闭混合(Blend)操作:片元着色器计算得到的颜色值直接覆盖掉颜色缓冲区中的像素值。
如 不透明物体
2、开启混合(Blend)操作:透明效果。GPU取出源颜色(片元着色器的颜色值)和目标颜色(已经存在于颜色缓冲区中的颜色值),使用 混合函数 将两种颜色进行混合。
如 半透明物体,让物体看起来是透明的
OpenGL 逐片元 的 测试操作:
片元数据 -> 像素所有权测试 -> 裁剪测试 -> 模板测试 -> 深度测试 -> 混合 -> 抖动 -> 写入到帧缓冲
对于大多数GPU来说,尽可能在执行片元着色器之前就进行这些测试。因为当GPU在片元着色器阶段花了很大力气终于计算出片元的颜色后,却发现片元没有通过检验被舍弃掉,那之前花费的计算成本全部浪费。
在 Unity中为了避免这个开销上的浪费,可以将 深度测试 在 片元着色器之前就进行(借用了 GPU的特性 Early-Z 技术)。Early-Z是由GPU硬件(NVIDA & AMD)实现的,通过 Early-Z 可以提前知道深度信息,避免不必要的片元着色器的计算,从而提高性能。
透明度测试会导致性能下降的原因:
在开启 Early-z 技术后,如果将深度测试提前,带来的检验结果会与片元着色器中的一些操作冲突:在片元着色器中进行透明度测试,片元没有通过透明度测试,于是在着色器中调用 clip函数 手动将片元舍弃掉。因为片元被舍弃了,于是GPU会判断片元着色器中的操作是否和 Early-z 提前测试发生了冲突,如果有冲突就会禁用提前测试,这样也会造成性能上的下降,因为有更多的片元需要被处理了。
最终屏幕显示的就是颜色缓冲区中的颜色值,但是为了避免看到的是正在进行光栅化的图元,GPU会使用双重缓冲(Double Buffering)的策略。对场景的渲染是在 后置缓冲(Back Buffer)中 发生的。一旦场景已经被渲染到了后置缓冲中,GPU就会交换后置缓冲和前置缓冲(Front Buffer)的内容,而前置缓冲区是之前显示在屏幕上的图形。因此保证了看到的图象是连续的。
OpenGL 和 DirectX:图象应用编程接口,用于渲染二维或者三维图象。架起了上层应用程序和底层GPU的沟通桥梁。应用程序运行在CPU上,通过调用OpenGL 或者 DirectX 的图形接口将渲染所需的数据,如顶点数据、纹理数据、材质参数等数据存储在显存中的特定区域。随后,开发者可以通过图象编程接口发出渲染命令(DrawCall),渲染命令被显卡驱动翻译成GPU能够理解的代码,从而进行真正的绘制。
显卡通常由总线接口、PCB板、显示芯片(GPU)、显存(VRAM)、RAMDAC、VGA BIOS、VGA功能插针、D-sub插座及其他外围组件构成,现在的显卡大多还具有VGA、DVI显示器接口或者HDMI接口及S-Video端子和Display Port接口。
CPU、OpenGL/DirectX、显卡驱动和GPU之间的关系 :显卡制作商为了让显卡可以同时和OpenGL 和 DirectX合作,就必须提供支持 OpenGL 和 DirectX 接口的显卡驱动。
着色器语言:会被编译成语机器无关的汇编语言(中间语言 IL),然后中间语言再交给显卡驱动翻译成真正的机器语言。
DirectX:HLSL
平台受限,在微软的平台。不同的硬件的编译结果一致。
OpenGL:GLSL
跨平台,显卡驱动完成着色器的编译,依赖硬件,非操作系统级别。GLSL的编译结果取决于硬件提供商。不同硬件的编译结果可能不一致。
NVIDIA:CG
真正的跨平台(根据平台的不同编译成相应的中间语言)。与微软的合作。与HLSL的语法相似,可移植成HLSL代码。确定是可能无法发挥出OpenGL的最新特性。
固定管线渲染(Fixed-Function Pipeline):
也称 固定管线,在旧GPU上实现的渲染流水线。
只可配置
OpenGL 3.0是最后即可支持可编程管线又完全支持固定管线编程接口的版本
OpenGL 3.2中,Core Profile 就完全移除了固定管线概念