Shader

GPU上的渲染程序。

 

渲染管线的流程:

几何阶段:

  • 顶点着色器(Vertex Shader):

主要工作是坐标变换,逐顶点光照和输出后续阶段所需的数据。

所有的几何体,以顶点的形式,先经过顶点着色器,进行顶点的坐标转换,把顶点坐标从模型空间转换到齐次裁剪空间,正向渲染(Forward Render)会对顶点进行光照计算,得到顶点的光照。

VBO(Vertex Buffer Object):在显存中申请一块空间,存储顶点的各类属性信息,在渲染时,直接从显存的VBO中读取顶点的属性信息,不需要从CPU传输数据,执行效率更高。

  • 裁剪处理(Clipping):

完全在视野内的图元会继续传递到下一个阶段,完全在视野外的图元不会继续向下传递,因为他们不需要渲染。而部分在视野内需要进行一个处理,这就是裁剪。例如,一条线段的一个顶点在视野内,另一个顶点在视野外,那么在视野外部的顶点应该使用一个新的顶点来代替,这个新的顶点位于这条线段和视野边界的交点处。

  • 屏幕映射(Screen Mapping):

主要任务是把每个图元的x和y坐标转换到屏幕坐标系。

 

光栅化阶段:

重要的目标就是计算每个片元覆盖了哪些像素,以及为这些像素计算他们的颜色。

  • 三角形设置。

上一阶段输出的是三角形网格的顶点,即我们得到的是三角形每条边的两个端点。但如果要得到整个三角形网格对像素的覆盖情况,就必须计算每条边上的像素坐标。为了能够计算边界像素的坐标信息,就需要得到三角形边界的表示方式。这样一个计算三角形网格表示数据的过程叫三角形设置。

  • 三角形遍历。

根据上一阶段的计算结果来判断一个三角形网格覆盖了哪些像素,被覆盖的像素,就会对应生成一个片元(fragment),并使用三角网格的3个顶点的顶点信息对整个覆盖区域的像素进行插值,得到片元中的状态。这一步输出的就是一个片元序列。需要注意的是,一个片元并不是真正意义上的像素,而是包含了很多状态的集合,这些状态用于计算每个像素的最终颜色。包含了屏幕坐标,深度信息,以及从几何阶段输出的顶点信息,例如法线、纹理坐标等。

  • 片元着色器(Fragment Shader):

为了在片元着色器中,进行纹理采样,通常在顶点着色器阶段输出每个顶点对应的纹理坐标,然后经过光栅化阶段对三角形网格的三个顶点对应的纹理坐标进行插值,得到其覆盖的片元的纹理坐标。

  • 逐片元操作,或是光栅化操作(Per-Fragment Operations):

1. 决定每个片元的可见性。例如模板测试、深度测试等。

2. 如果一个片元通过了所有的测试,就需要把这个片元的颜色值和已经存储在颜色缓冲区中的颜色进行混合。

 

模板测试(Stencil Test):

如果开启了模板测试,GPU会首先读取(使用读取掩码)模板缓冲区中该片元位置的模版缓冲值(Stencil buffer),然后将该值和读取到的参考值(Reference buffer)进行比较,比较函数由开发者指定,例如小于时舍弃该片元等。模板测试通常用于限制渲染的区域。还有些高级用法,渲染阴影、轮廓渲染等。

 

深度测试(Depth Test):

如果开启了深度测试,GPU将会把该片元的深度值与已经存在于深度缓冲区的深度值进行比较。比较函数由开发者指定。通常比较函数是小于等于的关系,才会让它通过测试,即如果这个片元的深度值大于等于当前缓冲区的值,就会舍弃它。因为我们只想显示出离摄像机最近的物体,那些被其他物体遮挡的就不需要出现在屏幕上。如果通过了测试,开发者可以通过开启/关闭深度写入,指定是否要用这个片元的深度值覆盖原有的深度值。

 

混合(Blend):

对于不透明物体,关闭混合操作,这样片元着色器计算得到的颜色值就可以直接覆盖掉已经存在于颜色缓冲区的颜色值。对于半透明物体,需要使用混合操作,让物体看起来是透明的。开启混合功能,GPU会把片元着色器计算得到的颜色值和已经存在于颜色缓冲区的颜色值,进行混合。

默认情况下OpenGL使用帧缓冲区作为渲染的目的地。帧缓冲区(FrameBuffer),包括深度缓冲区(depth),模板缓冲区(stencil)和颜色缓冲区(color buffers)。颜色缓冲区是必不可少的,其他缓冲区可以存在可以不存在。

最后,为了避免我们看到正在进行光栅化的图元,GPU会使用双重缓冲的策略。对场景的渲染是放在幕后的,当场景被渲染到后置缓冲中,GPU就会交换后置缓冲区和前置缓冲区中的内容,而前置缓冲区是之前显示在屏幕上的图像。由此,保证我们看到的图像总是连续的。

 

透明度测试(Alpha Test ):

只要一个片元的透明度不满足条件(小于某一个阈值),那么这个片元就会被舍弃。

为什么在移动平台上,透明度测试会影响游戏性能?

为了减少OverDraw(一个像素被渲染多次),PowerVR(ios)使用基于瓦片的延迟渲染(Tiled-based Deferred Rendering, TBDR)架构,把所有的渲染图像装入一个个瓦片(tile)中,再由硬件找到可见的片元,而只有这些可见的片元才会执行片元着色器。另一些基于瓦片的GPU架构,如Adreno(高通)和Mali(ARM)会使用Early-Z或相似的技术进行一个低精度的深度测试,来剔除那些不需要渲染的片元。

由于在clip函数中使用了discard,改变了片元是否会被渲染的结果。因此,只有执行完了所有的片元着色器后,GPU才能知道哪些片元会被真正渲染到屏幕上。这样原先可以减少OverDraw的优化就都无效了。这种时候,使用透明度混合的性能往往比使用透明度测试更好。

//透明度测试使用AlphaTest队列,在SubShader中设置
Tags{ "Queue" = "AlphaTest" "IgnoreProjector" = "True" "RenderType" = "TransparentCutout"}

clip(texColor.a - _CutOff);
//内部实现相当于
//if((texColor.a - _CutOff) < 0)
//{
// discard;
//}

clip函数:

Cg提供了clip函数,如果给定的参数的任何一个分量为负数,就会舍弃当前像素的输出颜色。

如果传入alpha分量小于_CutOff,就会舍弃当前像素的输出颜色,用来进行透明度测试。

 

透明度混合(Alpha Blending):

使用当前片元的透明度作为混合因子,与已经存储在颜色缓冲中的颜色值进行混合,得到新的颜色。透明度混合要非常注意物体的渲染顺序,需要关闭深度写入

 

为什么透明度混合需要关闭深度写入?

如果不关闭深度写入,一个半透明物体后的物体本来是可以被我们看见的,但由于深度测试时,判断该半透明物体具体摄像机更近,导致后面的物体将被剔除,我们也就无法透过半透明物体看到后面的物体了。

 

可编程Shader:Vertex Shader

传入的是顶点在模型空间的位置、光照、颜色等信息,传出的是顶点在剪裁空间的位置和颜色等信息。

可编程Shader:Fragment Shader

在像素处理器中,传入的是已经光栅化的像素在剪裁空间的位置,颜色等信息,传出的是像素被着色后的顶点颜色。

 

Shader与Material的关系

Material是对Shader的包装,也可以说是Shader的预制。

 

OpenGL或DirectX

图像应用编程接口,这些接口用于渲染二维或三维的图形。这些接口架起了上层应用程序和GPU的沟通桥梁。我们的程序运行在CPU上,应用程序通过调用OpenGL或DirectX的图形接口,将需要渲染的数据,例如顶点数据、纹理数据、材质参数等存储在显存中的特定区域。随后,可以通过调用图形编程接口发出渲染命令(DrawCall),命令将会被显卡驱动翻译成GPU可以运行的代码,进行真正的绘制。

 

物理渲染 & Standard Shader

物理渲染是遵循光传播的物理学模拟,更真实,尤其适合自然界中的材质模拟。

原理:基于物体表面的微表面理论,物体表面有很多微表面组成,反射光照由与视线一致的微表面的直接反射(高光)和其他微表面互相之间的漫反射组成。

 

 

材质和Unity Shader的桥梁:Properties

Properties {

_MyColor ("Some Color", Color) = (1,1,1,1)

_MyVector ("Some Vector", Vector) = (0,0,0,0)

_MyNum("Num", Int) = 1

_MyRange("Range", Range(1, 11)) = 5

_MyFloat ("My float", Float) = 0.5

_MyTexture ("Texture", 2D) = "white" {}

_MyCubemap ("Cubemap", Cube) = "white" {}

_My3D("3D", 3D) = "black"{}

}

以_MyColor("Some Color", Color) = (1,1,1,1)为例,_MyColor为Shader中变量名,"Some Color"为编辑器中显示的属性名,最后一个Color为类型,(1,1,1,1)为Color的值。

 

SubShader

SubShader{

// 可选的标签

Tags { “RenderType”= “Opaque” }

// 可选的状态

Cull Off

Pass{

}

// Other Pass

}
  • 可以有多个SubShader,但至少有一个。Unity会扫描所有的SubShader,然后选择第一个能够在目标平台上运行的SubShader。如果都不支持,调用FallBack指定的Unity Shader。
  • SubShader中定义了一系列的Pass以及可选的状态([RenderSetup])和标签([Tags])设置。每个Pass定义了一次完整的渲染流程,但如果Pass的数目过多,就会造成渲染性能下降。至少有一个Pass。

 

状态设置

Cull Back | Front | Off 设置剔除模式:剔除背面 | 正面 | 关闭剔除

ZTest Less | Greater | LEqual | GEqual | Equal | NotEqual | Always 开启深度测试,设置深度测试的比较函数

ZWrite On | Off 开启 | 关闭深度写入

Blend SrcFactor DstFactor 开启并设置混合模式,生成的颜色乘以SrcFactor。屏幕上已有的颜色乘以DstFactor,两者相加。

在SubShader中,设置渲染状态,将会应用到所有的Pass。如果不想这样,可以在Pass语义块中单独进行状态设置。

参数对应声明及类型说明

 

Unity中的属性

_MyColor ("Some Color", Color) = (1,1,1,1)

_MyVector ("Some Vector", Vector) = (0,0,0,0)

_MyNum("Num", Int) = 1

_MyRange("Range", Range(1, 11)) = 5

_MyFloat ("My float", Float) = 0.5

_MyTexture ("Texture", 2D) = "white" {}

_MyCubemap ("Cubemap", Cube) = "white" {}

_My3D("3D", 3D) = "black"{}

那么在cg程序里面应该再次声明这些参数,才能在cg程序里面使用

fixed4 _MyColor;

float4 _MyVector;

float _MyNum;

float _MyRange;

float _MyFloat;

sampler2D _MyTexture;

float4 _MyTexture_ST;

float4 _MyTexture_TexelSize;

samplerCUBE _MyCubemap;

sampler3D _My3D;

 

float 32位存储

half 16位存储,-6万 ~ +6万

fixed 11位存储,-2 ~ +2

所以一般情况下,颜色的值在[0, 1]之间,用fixed4来存储。

float3x3 3*3的矩阵,类型中是字母x

 

与其他属性不同的是,我们需要为纹理类型的属性声明一个float4类型的变量_MainTexture_ST。在Unity中,我们需要使用纹理名_ST的方式来声明某个纹理的属性。ST是缩放(Scale)和平移(Translation)的缩写。_MainTexture_ST.xy存储的是缩放值,而_MainTexture_ST.zw存储的是偏移值,分别对应着材质面板中,Tiling的x和y值,Offset的x和y值。

 

_MyTexture_TexelSize为纹理的每个纹素的大小,可以用来计算各个相邻区域内的纹理进行采样。比如纹理大小为(512*512),纹素大小为 (1 / 512)。

 

慎用分支和循环语句

慎用if-else、for和while语句,GPU在最坏的情况下,花在一个分支语句的时间相当于运行了所有的分支语句的时间。尽量避免使用流程控制语句,因为它们会降低GPU的并行处理操作。

不要除以0,在不同平台,结果往往不可预测。解决方法是,对于除数可能为0的情况,使用if语句判断除数是否为0。

如果不可避免的使用分支语句来进行运算,一些建议:

  • 分支判断语句中使用的条件变量最好是常数
  • 每个分支中包含的操作指令数尽可能的少
  • 分支的嵌套层数尽可能的少

尽量把放在片元着色器中的计算放到顶点着色器中

 

光照模型

光照模型就是一个公式,使用这个公式来计算在某个点的光照效果

 

标准光照模型

在标准光照模型中,我们把进入摄像机的光分为四个部分:

  • 自发光(Emissive)
  • 高光发射(Specular)
  • 漫反射(Diffuse,兰伯特光照模型)
  • 环境光(Ambient,间接光照,光线通常会在多个物体之间反射)

 

兰伯特光照(Lambert)模型

在平面某点漫反射光的光照强度,与反射光的法线向量和入射光线的夹角余弦值成正比。

Diffuse = 入射光线的颜色和强度 * 反射系数 * max(0, cos夹角(入射光和法线的夹角))。

Cg提供了saturate函数,可以截取[0 , 1]之间的值。normal和lightDirection需要进行归一化normalize处理,方便使用点积的方式获得cos值。

Diffuse = 入射光线的颜色和强度 * 反射系数 * saturate( dot(normal,lightDirection))。

 

半兰伯特模型

在光照无法到达的区域,模型外观通常是全黑的,没有任何明暗变化。Value在开发《半条命》时,提出了一种改善技术,叫做半兰伯特模型。

Diffuse = 入射光线的颜色和强度 * 反射系数 * (dot(normal,lightDirection)* 0.5 + 0.5)。

 

Phong高光反射模型

Specular = 入射光线的颜色和强度 * 高光反射系数 * max(0, 视角方向点积反射方向)^_Gloss(高光区域的大小)

Cg提供了reflect函数,计算反射方向。reflect(i , n) i,入射方向;n,法线方向

reflectDir反射方向,viewDir视角方向,需要进行归一化normalize处理。

fixed3 reflectDir = normalize(reflect(-worldLightDirection, worldNormal));

Specular = 入射光线的颜色和强度 * 高光反射系数 * pow(saturate(dot(reflectDir, viewDir)), _Gloss(高光区域的大小))

 

BlinnPhong高光反射模型

BlinnPhong光照模型没有使用反射方向,引入一个新的矢量,通过对视角方向和光照方向相加后再归一化得到的

fixed3 worldLightDirection = normalize(UnityWorldSpaceLightDir(i.worldPos));

fixed3 viewDir = normalize(UnityWorldSpaceViewDir(i.worldPos));

fixed3 halfDir = normalize(worldLightDirection + viewDir);

Specular = 入射光线的颜色和强度 * 高光反射系数 * pow(saturate(dot(worldNormal, halfDir)), _Gloss);

 

 

性能优化

尽量采用Mobile文件夹下的Shader。基于物理渲染的Standard Shader,在移动平台性能开销太大。

CPU优化

使用批处理技术减少DrawCall数目。批处理技术原理是减少每帧需要的DrawCall数目,即每次调用DrawCall时尽可能的处理多个物体。

动态批处理

每一帧把可以进行批处理的模型网格进行合批,再把合并好的模型数据传递给GPU,然后使用同一种材质对其渲染。经过动态批处理的物体仍然可以移动,这是因为每帧Unity都会重新合并一次网格。

动态批处理条件限制:

(1)进行批处理的网格顶点属性规模要小于900,如果Shader有三个属性,那么顶点数目不能超过300个。

(2)多Pass的Shader会中断批处理。在前向渲染中,我们有时需要使用额外的Pass来为模型添加更多的光照效果,这样一来,模型就不会被动态批处理了。

静态批处理:

在运行开始的阶段,把需要进行静态批处理的模型合并到一个新的网格结构中,这意味着模型不能再运行时被移动。往往需要占用更多的内存来存储合并后的网格结构。

无论是动态批处理还是静态批处理,都要求模型之间需要共享同一个材质。如果两个材质之间只是使用的纹理不同,可以把这些纹理合并到一张更大的纹理中,这张更大的纹理叫做图集(atlas)。

 

GPU优化

减少需要的顶点数目

(1)优化模型,尽可能的减少三角形的面数,移除不必要的硬边及纹理衔接,避免边界平滑和纹理分离。

边界平滑(smoothing splits,一个顶点可能会对应多个法线信息或切线信息,在Unity导入模型时,有一个Smoothing Angles(光滑组)的设置,当Smoothing Angles的值为0时,就没有共用的顶点,拆分出更多新的顶点,可以展示更多细节。当这个值越来越大,共用顶点越多,细节就更少一些。)

纹理分离(uv splits,一个顶点可能有多个纹理坐标。面与面之间使用的一些相同顶点,在不同面上,同一个顶点的纹理坐标可能并不相同 ,GPU会把这个顶点拆分成多个具有不同纹理坐标的顶点)。

(2)使用模型的LOD技术

LOD允许当对象逐渐远离摄像机时,减少模型上的面片数量,从而提高性能。

(3)使用遮挡剔除技术

消除在其他物体后面看不到的物体,也就不会渲染这个看不到的顶点,从而提高性能。注意:在移动平台,遮挡剔除开销太大,不建议使用。

(4)Camera.layerCullDistances

相机跟每一层的剔除距离。比如,在视野中有很多npc,可以把npc设置到npc层,并在代码中为npc层设置较小的layerCullDistances剔除距离,这样就可以只渲染npc层剔除距离内的npc,减少性能开销。

(5)注意摄像机的FOV(Field of View),影响可视物体的数量

(6)Camera视锥体越小越好,注意远裁减面的距离

 

减少需要处理的片元数目

(1)控制绘制顺序,由于深度测试的存在,如果我们可以保证物体都是从前往后绘制,那么就可以很大程度上减少OverDraw,这是因为在后面绘制的物体由于无法通过深度测试,就不会在进行后面的渲染处理。

(2)在移动平台,渲染透明物体,Alpha混合性能比Alpha测试更好

(3)慎用实时光照

使用光照烘焙技术,把光照提前烘焙到一张光照纹理中(lightmap),在运行时根据纹理坐标得到光照结果。

 

减少计算复杂度

(1)使用Shader的LOD技术

Shader的LOD技术可以控制使用的Shader等级。原理是只有Shader的LOD值小于某个设定值,这个Shader才会被使用。

在某些情况下,我们可能需要去掉一些使用复杂计算的Shader渲染。这时,我们可以使用Shader.maximumLOD或Shader.globalMaximumLOD来设置允许的最大LOD值。

(2)代码方面的优化

  • 尽可能使用低精度的浮点值进行计算。
  • 使用插值寄存器把数据从顶点着色器传递给下一个阶段时,应该使用尽可能少的插值变量。
  • 尽量不要使用全屏的屏幕后处理效果,如果真的需要使用,尽量使用低精度计算,高精度计算可以使用查找表(LUT)或者转移到顶点着色器中进行处理。把多个特效合并到一个shader中。使用缩放思想,在高性能的平台,使用更高的分辨率,开启屏幕后处理效果,在低性能平台保证游戏正常运行即可。
  • 尽可能不要使用分支或循环语句。
  • 尽可能避免使用类似sin、tan、pow、log等较为复杂的数学计算,请考虑使用查找纹理(lookup texture, LUT)作为复杂数学计算的替代方法。

 

节省内存带宽

  • 减少纹理大小,针对不同平台,采用压缩纹理来减少纹理大小,可以加快加载速度,减少内存占用,显著提高渲染性能。
  • 利用mipmap,始终为3D场景中使用的纹理启用mipmap,但此规则例外的是:UI元素或2D游戏中
  • 利用分辨率缩放,Screen.SetResolution