顶点-片段着色器基础(Vertex-Fragment Shader)

74cc7c37c5c32a47488a1c1dacce2fcb.png
编码工具
推荐使用Vistual Studio

创建和使用Shader
在Unity中,Shader就是包含了CG/HLSL和ShaderLab的文本文件。
实际上,无论是创建 Standard Surface Shader 还是 Image Effect Shader,都可以把它编写成想要的Shader。
不同之处在于:Unity 会在不同类型的Shader里预先插入一部分代码,从而便于最开始的编写。

在Unity里Shader一般有两种用途:

  1. 指定给材质,用于物体渲染,这是最常用的。
  2. 指定给剧本,用于图像处理,例如:后期处理(Post Processing)

Shader 的编写方式
Unity 渲染管线中的Shader可以通过以下的方式编写;

  1. 顶点-片段着色器
  2. 表面着色器
  3. 或者固定函数着色器

固定函数着色器主要用于在老的图形设备上运行,目前以及逐渐被抛弃。

CG 语法基础
在Unity Shader中,ShaderLab语言只是起到组织代码结构的作用,而真正实现渲染效果的部分是用CG语言编写的。

编译指令
CG程序片段通过指令嵌入在Pass中,夹在指令 CGPROGRAM 和 ENDCG 之间,通常看起来是这样的:

Pass
{
    // ...设置渲染状态...

    CGPROGRAM
    //编译指令
    #pragma vertex vert
    #pragma fragment frag

     // CG 代码

     ENDCG
}

在编译程序之前,通常需要先使用#pragma声明编译指令

CG中常用的编译指令

编译指令 作用
#pragma vertex name 定义顶点着色器的名称,通常会使用vert
#pragma fragment name 定义片段着色器的名称,通常会使用frag
#pragma target name 定义 Shader 要编译的目标级别,默认2.5

1.编译目标等级

当编写玩Shader程序之后,其中的Shader代码可以被编译到不同的Shader Models(简称SM)中,为了能够使用更高级的GPU功能,需要对应使用更高级的编译目标。

但是有一点需要注意的是,高等级的编译目标可能会导致Shader无法在旧的GPU上运行,因此编译目标等级并不是越高越好。

声明编译目标的级别可以使用 #pragma target name 指令,或者也可以使用#pragma require feature 指令直接声明某个特定的功能,例如:

#pragma target 3.5                       //目标等级
#pragma require geometry tessellation   //需要几何体细分功能

2.渲染平台

最简单的 Shader

Shader "Custom/Simplest Shader"
{
    SubShader
    {
        Pass
        {
         CGPROGRAM
         #pragma vertex vert
         #pragma fragment frag

          void vert(in float4 vertex : POSITION, out float4 position : SV_POSITION)

          {
              position = UnityObjectToClipPos(vertex);
          }

           void frag(in float4 vertex : SV_POSITION, out float4 color : SV_TARGET)

      {
         color = fixed(1,0,0,1);
      }
           ENDCG
        }
    }
}

1.无返回值的函数
函数分为有返回值和无返回值。
无返回值就是函数不会返回任何变量,而是通过out关键词将变量输出。

无返回值函数的语法结构如下所示:

void name (in 参数, out 参数)
{
    //函数体
}

关键词解释:

  1. void:函数以void开头,表示返回值为空
  2. name:定义函数的名称,后续可以通过这个名称调用函数
  3. in:输入参数,语法为:in+数据类型+名称,一个函数可以有多个输入,关键词in可以省略
  4. out:输出参数,语法为:out+数据类型+名称,一个函数可以有多个输出。

2.有返回值的函数
既然有无返回值的函数,那么反过来肯定会存在有返回值的函数。
有返回值的函数不再使用 out 关键词输出参数,而是会在最后通过return关键词返回一个变量,语法结构如下所示:

type name(in 参数)
{
    //函数体
    return返回值;
}

顶点函数和片段函数中支持的数据类型

数据类型 描述
fixed,fixed2,fixed3,fixed4 低精度浮点值,使用11位精度进行存储,数值区间为[-2.0, 2.0],用于存储颜色、标准化后的向量等。
half,half2,half3,half4 中精度浮点值,使用16位精度进行存储,数值区间为[-60000,60000]
float,float2,float3,float4 高精度浮点值,使用32位精度进行存储,用于存储顶点坐标、未标准化的向量、纹理坐标等
struct 结构体,可以将多个变量整体进行打包

void vert(in float4 vertex : POSITION,out float4 position : SV_POSITION)
{
    position = UnityObjectToClipPos(vertex);
}

等价于

float4 vert(in float4 vertex: POSITION : SV_POSITION)
{
    //返回裁切空间顶点坐标
    return UnityObjectToClipPos(vertex);
}

语义
当使用CG语言编写着色器的时候,函数的输入参数和输出参数都需要填充一个语义(Semantic)来表示它们要传递的数据信息。
语义可以执行大量烦琐的操作,使用户能够避免直接与GPU底层进行交流。参数后被冒号隔开并且全部大写的关键词就是语义。

1.顶点着色器的输入语义

语义 描述
POSITION 顶点的坐标信息,通常为float3 或 float4 类型
NORMAL 顶点的法线信息,通常为float3类型
TEXCOORD0 模型的第一套UV坐标,通常为float2、float3 或 float4 类型, TEXCOORD0到 TEXCOORD3分别对应第一到第四套UV坐标。
TANGENT 顶点的切向量,通常为float4类型
COLOR 顶点的颜色信息,通常为float3类型

当顶点信息包含的元素少于顶点着色器输入所需要的元素时,缺少的部分会被0填充,而w分量会被1填充。
例如:顶点的UV坐标通常是二维向量,只包含x和y元素。
如果输入的语义 TEXCOORD0 被声明为 flaot4 类型,那么顶点着色器最终获得的数据将变为(x,y,0,1)

2.顶点着色器输出和片段着色器输入语义
在整个渲染流水线中,顶点着色器最重要的一项任务就是需要输出顶点在裁切空间中的坐标。
这样GPU就可以知道顶点在屏幕上

顶点着色器产生的输出值将会在三角形遍历阶段经过插值计算,最终作为像素值输入到片段着色器。
换句话说,顶点着色器的输入即为片段着色器的输入。

语义 描述
SV_POSITION 顶点在裁切空间中的坐标,float4类型
TEXCOORD0、TEXCOORD1等 用于声明任意高精度的数据,例如纹理坐标、向量等
COLOR0、COLO1等 用于声明任意低精度的数据,例如顶点颜色、数值区间[0,1]的变量

片段着色器会自动获取顶点着色器输出的裁切空间顶点坐标,所以片段函数输入的SV_POSITION可以省略。这也解释了为什么有些Shader的片段函数中只有输出参数没有输入参数。

需要特别注意的是,与顶点函数的输入语义不同,TEXCOORDn 不再特质模型的UV坐标,COLORn也不再特指顶点颜色。它们的适用范围更广,可以用于声明任何符合要求的数据,所以在使用过程中不要被语义的名称欺骗了。

3.片段着色器输出语义
片段着色器只会输出一个 fixed4 类型的颜色信息,输出的值会存储到渲染目标(Render Target)中,输出参数使用SV_TARGET语义进行填充。

在CG中调用属性变量
在CG代码块中如何调用Properties中开放出来的属性

1.CG中声明属性变量
Shader通过Properties代码块声明开放出来的属性,如果想要在Shader程序中访问这些属性,则需要在CG代码块中再次进行声明,它的语法为:
type name;
type 为变量的类型,name为属性变量的名称。

需要注意的是,必须在函数调用属性之前对其进行声明,否则编译会失败。

Shader "Custom/Properties"
{
    Properties
    {
        _MyFloat("Float Property", Float) = 1            //浮点类型
        _MyRange("Range Property", Range(0,1)) = 0.1     //范围类型
        _MyColor("Color Property", Color) = (1,1,1,1)    //颜色类型
        _MyVector("Vector Property",Vector) = (0,1,0,0)  //向量类型
        _MyTex("Texture Property",2D) = "white" {}       //2D贴图类型
        _MyCube("Cube Property",Cube) = ""{}             //立方体贴图类型
        _My3D("3D Property",3D) = ""{}                   //3D贴图类型
    }
    SubShader
    {
         Pass
         {
             CGPROGRAM
             #pragma vertex vert
             #pragma fragment frag

             //在CG中声明属性变量
             float _MyFloat;      //浮点类型
             float _MyRange;      //范围类型
             float4 _MyColor;     //颜色类型
             float4 _MyVector;    //向量类型
             sampler2D _MyTex;    //2D贴图类型
             samplerCUBE _MyCube; //立方体贴图类型
             sampler3D _My3D;     //3D贴图类型

                        void vert()
                        {

                        }

                        void frag()
                        {

                        }

             ENDCG
       }
    }
    FallBack "Diffuse"
}

edcc48a1b4229eb46eb1d6257d148bcb.pngc0e59dace1c95ce02e3702c9a6b9d111.png

开放属性与CG属性变量的对应关系

开放属性的类型 开放属性与CG属性变量的对应关系
Float,Range 浮点和范围类型的属性,根据精度可以使用float, half 或 fixed 声明
Color, Vector 颜色和向量类的属性,可以使用float4,half4 或 fixed4 声明,其中颜色使用低精度的fixed4声明可以减少性能消耗
2D 2D纹理贴图属性,使用sample2D声明
Cube 立方体贴图属性,使用samplerCube声明
3D 3D纹理贴图属性,使用sampler3D声明

2.在Shader中使用颜色

将Simplest Shader进行修改,以实现随意更改颜色的功能。

Shader "Custom/Simplest Shader"
{
    Properties
    {
    //开放属性颜色
    _MainColor("MainColor",Color) = (1,1,1,1)
    }

    SubShader
    {
        Pass
        {
         CGPROGRAM
         #pragma vertex vert
         #pragma fragment frag

         //声明颜色变量 _MainColor
         fixed4 _MainColor;

          void vert(in float4 vertex : POSITION,
                    out float4 position : SV_POSITION)
          {
              position = UnityObjectToClipPos(vertex);
          }

           void frag(in float4 vertex : SV_POSITION,
                     out float4 color : SV_TARGET)
          {
              //调用颜色变量 _MainColor
              color = _MainColor;
          }
           ENDCG
        }
    }
}

ef30fe45b3e2d03c9402da524b2c6ae5.png

3.在Shader中使用贴图
纹理贴图作为3D制作中最常用的美术资源,在Shader中的使用是相当频繁的。

纹理贴图在Properties代码块中被定义之后,还需要在CG代码块中再次声明,但是与其他属性不同的是,CG还需要声明一个变量用于存储贴图的其他信息。

在CG中,声明一个纹理变量的 Tiling 和 Offset 的语法结构如下所示:
float {TextureName} _ST;

  1. TextureName: 纹理属性的名称

  2. ST: Scale 和 Transform 的首字母,表示 UV(什么是UV贴图和展开?游戏建模纯干货,UV的详细解释,不懂得赶紧看过来:https://blog.csdn.net/shooopw/article/details/111169660)的缩放和平移。

在CG所声明的变量为 float4 类型, 其中 x 和 y 分量分别为 Tiling 的X值 和 Y 值, z 和 w 分量分别为 Offset 的 X 值和 Y 值。

纹理坐标的计算公式为:
    texcoord = uv · {TextureName}.xy + {TextureName}.zw
在计算纹理的时候,一定要先乘以平铺值再加偏移值 。
先放大、再平移。如果顺序错误,会出现问题。 矩阵一般不能颠倒顺序运算

增加对纹理资源的支持

_MainTex("MainTex",2D) = "white" { }
Properties 代码块中开放了名称为 _MainTex 的纹理属性,默认值为白色。

sampler2D_MainTex;
float4 _MainTex_ST;

在CG中重新声明了_MainTex 属性变量以及它的平铺偏移变量 _MainTex_ST

void vert(in float4 vertex :POSITION, in float2 uv : TEXCOORD0,
          out float4 position : SV_POSITION, out float2 texcoord : TEXCOORD0)
{
    position = UnityObjectToClipPos(vertex);

    //使用公式计算纹理坐标
    texcoord = uv * _MainTex_ST.xy + _MainTex_ST.zw;

}

在顶点函数中,添加了一个名称为 uv 的输入参数用于获取顶点顶点的UV数据之后,又添加了一个名称为texcoord的输出参数用于保存在顶点函数中计算出的纹理坐标。

按照公式,把顶点的UV乘以平铺值然后加上偏移值得到了纹理坐标texcoord,然后输出到片段着色器中。

void frag (in float4 position : SV_POSITION, in float2 texcoord : TEXCOORD0,
           out fixed4 color : SV_TARGET)
{
    color = tex2D(_MainTex,texcoord) * _MainColor;
}

在片段着色器中添加了一个名称为 Texcoord 的输入参数用于接收从顶点着色器传递过来的纹理变量。
然后调用tex2D()函数,使用纹理坐标texcoord对纹理_MainTex进行采样,最后将采样结果乘上 _MainColor 进行输出。

改写之后的代码如下所示:

Shader "Custom/Simplest Shader"
{
    Properties
    {
        _MainColor ("MainColor",Color) = (1,1,1,1)

        //开放纹理属性
        _MainTex("MainTex",2D) = "white" {}
    }

    SubShader
    {
        Pass
        {
         CGPROGRAM
         #pragma vertex vert
         #pragma fragment frag

         fixed4 _MainColor;

         //声明纹理属性变量以及ST变量
          sampler2D _MainTex;
          float4 _MainTex_ST;

          void vert(in float4 vertex : POSITION,
                    in float2 uv : TEXCOORD0,
                    out float4 position : SV_POSITION
                    out float2 texcoord : TEXCOORD0)
          {
              position = UnityObjectToClipPos(vertex);

              //使用公式 计算纹理坐标
              texcoord = uv * _MainTex_ST.xy + _MainTex_ST.zw;
          }

           void frag(in float4 vertex : SV_POSITION,
                     in float2 texcoord : TEXCOORD,
                     out float4 color : SV_TARGET)
          {
                 color = tex2D(_MainTex,texcoord) * _MainColor;
          }
           ENDCG
        }
    }
}

90320c2e7c13885eac7eb9570e464a58.png

通常情况下,纹理资源都需要按照这种流程进行使用,除非能够确定某个纹理资源永远不会用到Tiling 和 Offset , 则可省略对该纹理ST变量的声明,同时不再计算其纹理坐标,于是顶点函数可以做以下删减:

void vert (in float4 vertex : POSITION, int float2 uv : TEXCOORD0,
           out float4 position : SV_POSITION, out float2 texcoord : TEXCOORD0)
{
    position = UnityObjectToClipPos(vertex);
    texcoord = uv;
}

UV 坐标输入到顶点之后无需计算直接输出,片段函数获取到UV之后直接对纹理进行采样。

4.在Shader中使用立方体贴图
Cubemap 是一个六个方向组成的立方体盒子,也可以在Unity中使用全景图(Panorama)转换得到,通常被用来作为环境的反射。

对于立方体贴图采样所使用的函数为:
texCUBE(Cube,r);
函数中的Cube表示立方体贴图,r表示视线方向在物体表面上的反射方向。
Cube可以直接在CG中声明这个属性变量,然后直接获取。r通过以下办法获得:

r = 2[(-v) · n]n + v ,其中, n是单位向量(物体表面的法线向量),v可以通过顶点坐标减去摄像机坐标得到(视线向量),上述所有向量均已标准化处理过。

添加对立方体贴图的支持:

//添加 Cubemap 属性和反射强度
_Cubemap("Cubemap",Cube) = "" { }
_Reflection("Reflection",Range(0,1)) = 0

Properties代码块中新开放了Cubemap属性,名称为_Cubemap, 又开放了控制反射强度的属性,名称为_Reflection。

//声明Cubemap和反射属性变量
samplerCUBE _Cubemap;
fixed _Reflection;

然后在CG中分别使用samplerCUBE 和 fixed 对新增的属性变量再次声明。

void vert(in float4 vertex : POSITION, in float3 normal : NORMAL,
         in float4 uv : TEXCOORD0, out float4 position : SV_POSITION,
         out float4 worldPos : TEXCOORD0, out float3 worldNormal : TEXCOORD1,
         out float2 texcoord : TEXCOORD2)
     {
         position = UnityObjectToClipPos(vertex);
         //将顶点坐标变换到世界空间
         worldPos = mul(unity_ObjectToWorld, vertex);
         //将法向量变换到世界空间
         worldNormal = mul(normal, (float3x3)unity_WorldToObject);
         worldNormal = normalize(worldNormal);
         texcoord = uv * _MainTex_ST.xy + _MainTex_ST.zy;
     }

在顶点函数中,使用Unity提供的变换矩阵 unity_ObjectToWorld将顶点坐标从模型空间变换到世界空间。
为了避免非统一缩放(例如,x,y,z轴的Scale分别为1,2,3)的物体法线方向偏移,使用法线向量右乘逆矩阵的方法对其进行空间变换。

void frag(in float4 position : SV_POSITION, in float4 worldPos : TEXCOORD0,
          in float3 worldNormal : TEXCOORD1, in float2 texcoord : TEXCOORD2,
          out fixed4 color : SV_Target)
{
    fixed4 main = tex2D(_MainTex, texcoord) * _MainColor;

    //计算世界空间中从摄像机指向顶点的方向向量
    float3 viewDir =  worldPos.xyz - _WorldSpaceCameraPos;
    viewDir = normalize(viewDir);

    //套用公式计算反射向量
    float3 refDir = 2 * dot(- viewDir, worldNormal)
                      * worldNormal + viewDir;
        refDir = normalize(refDir);

        //对Cubemap采样
        fixed4 reflection = texCUBE(_Cubemap, refDir);

        //使用_Reflection对颜色和反射进行线性计算
        color = lerp(main,reflection,_Reflection);

}

将世界空间顶点坐标 worldPos 和世界空间法向量 worldNormal 传入到片段函数之后,使用顶点坐标 worldPos.xyz 减去摄像机坐标 _WorldSpaceCameraPos(Unity提供的可以直接使用的变量)得到从摄像机指向顶点的方向向量 viewDir 比规范化。 然后套用反射向量的计算公式计算反射向量 refDir ,再使用 texCUBE( ) 函数对其进行采样,得到反射颜色 reflection。

最后在lerp( ) 函数中使用 _Reflection 属性对颜色和反射进行线性插值,当 _Reflection 为0物体只显示原本颜色,为1则只显示反射颜色,中间值时按照比例进行显示。

修改之后的Shader如下所示:

Shader "Custom/TestShader"
{
    Properties
    {
        _MainColor("MainColor",Color) = (1,1,1,1)
        //开放纹理属性
        _MainTex("MainTex",2D) = "white" { }
        //添加 Cubemap 属性和反射强度
        _Cubemap("Cubemap",Cube) = "" { }
        _Reflection("Reflection",Range(0,1)) = 0
    }
        SubShader
    {
        Pass
        {
         CGPROGRAM
         #pragma vertex vert
         #pragma fragment frag
         fixed4 _MainColor;
    //声明纹理属性变量以及ST变量
     sampler2D _MainTex;
     float4 _MainTex_ST;
     //声明Cubemap和反射属性变量
     samplerCUBE _Cubemap;
     fixed _Reflection;
     void vert(in float4 vertex : POSITION, in float3 normal : NORMAL,
         in float4 uv : TEXCOORD0, out float4 position : SV_POSITION,
         out float4 worldPos : TEXCOORD0, out float3 worldNormal : TEXCOORD1,
         out float2 texcoord : TEXCOORD2)
     {
         position = UnityObjectToClipPos(vertex);
         //将顶点坐标变换到世界空间
         worldPos = mul(unity_ObjectToWorld, vertex);
         //将法向量变换到世界空间
         worldNormal = mul(normal, (float3x3)unity_WorldToObject);
         worldNormal = normalize(worldNormal);
         texcoord = uv * _MainTex_ST.xy + _MainTex_ST.zy;
     }

     void frag(in float4 position : SV_POSITION, in float4 worldPos : TEXCOORD0,

         in float3 worldNormal : TEXCOORD1, in float2 texcoord : TEXCOORD2,
         out fixed4 color : SV_Target)
     {
         fixed4 main = tex2D(_MainTex, texcoord) * _MainColor;
         //计算世界空间中从摄像机指向顶点的方向向量
         float3 viewDir = worldPos.xyz - _WorldSpaceCameraPos;
         viewDir = normalize(viewDir);
         //套用公式计算反射向量
         float3 refDir = 2 * dot(-viewDir, worldNormal)
             * worldNormal + viewDir;
         refDir = normalize(refDir);
         //对Cubemap采样
         fixed4 reflection = texCUBE(_Cubemap, refDir);
         //使用_Reflection对颜色和反射进行线性计算
         color = lerp(main, reflection, _Reflection);
     }
      ENDCG
   }
    }
}

84bd1fd04a61d5cad437c4b600f739ff.png
HDRI,全景图,Cubemap资源下载网站:https://3dmodelhaven.com/
注意,需要将贴图的属性面板中将Texture Shape从2D改为Cube,确认之后即可转换为立方体贴图。

结构体

在实际编写过程中,着色器通常需要输入输出多个参数。例如上一小节中讲解的 Cubemap Property,需要同时将顶点坐标、法线向量和第一套UV传入到顶点着色器。然后再同时输出裁切空间的顶点坐标、世界空间顶点坐标、世界空间法线向量和纹理坐标到片段着色器。

由于函数有多个输入和输出,为了使代码编写更加方便,并且看起来更加美观,本小节引入一个新的数据类型——结构体(Structure)。

1.结构体语法
结构体允许存储多个不同类型的变量,并将多个变量包装成为一个整体进行输入或者输出。
结构体的语法如下:

struct Type
{
   //变量_!;
   //变量_2;
   //变量_3;
   //变量_n;
};
  1. struct: 定义结构体的关键词。

  2. Type: 给当前结构体定义一种类型,着色器函数定义输入和输出数据类型时会用到。结构体内包含的变量仍然需要定义数据类型和名称,然后填充对应的语义。最后通过结构体名称],[变量名称]的语法访问,例如:v.vertex,表示访问名称为v的结构体内的vertex变量。

Texture Property Shader 改写为以结构体作为输入和输出的Shader,代码如下所示:

Shader "Custom/In Out Struct"
{
    Properties
    {
        _MainColor("MainColor",Color) = (1,1,1,1)
        _MainTex("MainTex",2D) = "white" { }
    }
        SubShader
    {
       Pass
       {
           CGPROGRAM
           #pragma vertex vert
           #pragma fragment frag

           //定义顶点着色器的输入结构体
            struct appdata
            {
              float4 vertex : POSITION;
              float2 uv : TEXCOORD0;
            };

             //定义顶点着色器的输出结构体
            struct v2f
            {
              float4 position : SV_POSITION;
              float2 texcoord : TEXCOORD0;
            };

            fixed4 _MainColor;
            sampler2D _MainTex;
            float4 _MainTex_ST;
            //使用结构体传入和传出参数

            void  vert(in appdata v, out v2f o)
            {
                o.position = UnityObjectToClipPos(v.vertex);
                o.texcoord = v.uv * _MainTex_ST.xy + _MainTex_ST.zw;
            }
            void frag(in v2f i, out fixed4 color : SV_TARGET)
            {
                color = tex2D(_MainTex, i.texcoord) * _MainColor;
            }
            ENDCG
       }
    }
}

appdata 是 ApplicationData 的缩写,表示从3D应用获取道德数据。然后又定义了一个类型为 v2f 的结构体,并把之前顶点着色器的输出参数写在结构体内。 v2f 是 vertex to fragment 的缩写,表示从顶点着色器传递到片段着色器的数据。

2.返回结构体的函数
函数可以写成有返回和无返回的形式。
函数可以返回结构体。

Shader "Custom/Return Struct"
{
    Properties
    {
        _MainColor("MainColor",Color) = (1,1,1,1)
        _MainTex("MainTex",2D) = "white" { }
    }
        SubShader
    {
       Pass
       {
           CGPROGRAM
           #pragma vertex vert
           #pragma fragment frag

           //定义顶点着色器的输入结构体
            struct appdata
            {
              float4 vertex : POSITION;
              float2 uv : TEXCOORD0;
            };

             //定义顶点着色器的输出结构体
            struct v2f
            {
              float4 position : SV_POSITION;
              float2 texcoord : TEXCOORD0;
            };

            fixed4 _MainColor;
            sampler2D _MainTex;
            float4 _MainTex_ST;

            //使用结构体传入和传出参数
            v2f vert(appdata v)
            {
                //声明结构体名称
                v2f o;
                o.position = UnityObjectToClipPos(v.vertex);
                o.texcoord = v.uv * _MainTex_ST.xy + _MainTex_ST.zw;
                return o;
            }

            fixed4 frag (v2f i) : SV_TARGET
            {
                return tex2D(_MainTex, i.texcoord) * _MainColor;
            }
            ENDCG
       }
    }
}

上述Shader中定义了appdata 和 v2f这两个结构体

在Shader中引入了结构体之后,代码更加简洁了。

渲染流水线与Shader概念

渲染流水线基础概念

输入是一个虚拟摄像机、一些光源、一些Shader以及纹理等等。最终目的在于生成或者说是渲染一张二维纹理。

应用阶段(通常由CPU负责实现)、几何阶段、光栅化阶段

渲染流水线中的3个概念阶段

应用阶段

在这一阶段中, 开发者有3个主要任务: 首先, 我们需要准备好场景数据, 例如摄像机的位 置、 视锥体、 场景中包含了哪些模型、 使用了哪些光源等等;其次, 为了提高渲染性能, 我们往 往需要做一个粗粒度剔除(culling)工作, 以把那些不可见的物体剔除出去, 这样就不需要再移 交给几何阶段进行处理;最后, 我们需要设置好每个模型的渲染状态。这些渲染状态包括但不限于它使用的材质(漫反射颜色,高光反射颜色)、使用的纹理、使用的Shader等等。这一阶段最重要的输出是渲染所需要的几何信息,即渲染图元(rendering primitives)。通俗来讲,渲染图元可以是点、线、三角面等。这些渲染图元将会被传递给下一个阶段——几何阶段

  1. 把数据加载到显存中

  2. 设置渲染状态

  3. 调用 Draw Call

    在同一状态下渲染3个网格

设置渲染状态

这些状态定义了场景中的网格是怎样被渲染的

例如, 使用哪个顶点着色器 (Vertex Shader) I片元着色器 (Fragment Shader)、 光源属性、材质等。 如果我们没有更改渲染状态, 那么所有的网格都将使用同一种渲染状态。 下图显示了当使用同 一种渲染状态时, 渲染 3 个不同网格的结果。在准备好上述所有工作后, CPU 就需要调用一个渲染命令来告诉GPU: “嘿!老兄, 我都帮 你把数据准备好啦, 你可以按照我的设置来开始渲染啦! ” 而这个渲染命令就是 Draw Call。

调用 Draw Call

相信接触过渲染优化的读者应该都听说过 Draw Call。 实际上, Draw Call 就是一个命令, 它 的发起方是 CPU, 接收方是GPU 。这个命令仅仅会指向一个需要被渲染的图元 (primitives) 列表, 而不会再包含任何材质信息-这是因为我们已经在上一个阶段中(设置渲染状态)完成了

当给定了一个 Draw Call 时, GPU 就会根据渲染状态(例如材质、 纹理、 若色器等)和所有 输入的顶点数据来进行计算, 最终输出成屏器上显示的那些漂亮的像素。 而这个计算过程, 就是 我们下一节要讲的GPU 流水线

几何阶段

几何阶段用于处理所有和我们要绘制的几何相关的事情。

例如,决定需要绘制的图元是什么, 怎样绘制它们,在哪里绘制它们。这一阶段通常在 GPU 上进行

几何阶段负责和每个渲染图元打交道,进行逐顶点、逐多边形的操作。这个阶段可以进一步 分成更小的流水线阶段 。几何阶段的一个重要任务就是把顶点坐标变换到屏幕空间中 ,再交给光栅器进行处理。通过对输入的渲染图元进行多步处理后,这一阶段将会输出屏幕空间的二维顶点坐标、每个顶点对应的深度值、着色等相关信息,并传递给下一个阶段。

光栅化阶段

此阶段将会使用上个阶段传递的数据来产生屏幕上的像素,并渲染出最终的图像。这 段也是在 GPU 上运行 。光栅化的任务主要是决定每个渲染图元中 的哪些像素应该被绘制在屏幕上。它需要对上一个阶段得到的逐顶点数据 (例如纹理坐标 、顶点颜色等)进行插值,然后再进行逐像素处理。

GPU 流水线

GPU的渲染流水线实现

颜色表示了不同阶段的可配置性或可编程性:绿色表示该流水线阶段是完全可编程控制的,黄色表示该流水线阶段可以配置但不是可编程的,蓝色表示该流水线阶段是由 GPU 固定实现的,开发者没有任何控制权。实线表示该 Shader 必须由开发者编程实现,虚线表示该 Shader 是可选的

GPU 的渲染流水线接收顶点数据作为输入。

几何阶段

顶点着色器 (Vertex Shader) 是完全可编程的,它通常用千实现顶点的空间变换、顶点着色等功能。

曲面细分着色器 (Tessellation Shader) 是一个可选的着色器,它用于细分图元。

几何着 色器 (Geometry Shader) 同样是一个可选的着色器,它可以被用于执行逐图元(Per-Primitive) 的着色操作,或者被用于产生更多的图元。

下一个流水线阶段是裁剪 (Clipping), 这一阶段的目的是将那些不在摄像机视野内的顶点裁剪掉,并剔除某些三角图元的面片。这个阶段是可配置的。 例如,我们可以使用自定义的裁剪平面来配置裁剪区域,也可以通过指令控制裁剪三角图元的正 面还是背面。

几何概念阶段的最后一个流水线阶段是屏幕映射 (Screen Mapping) 。此阶段是不可配置和编程的,它负责把每个图元的坐标转换到屏幕坐标系中。

顶点着色器 Vertex Shader

顶点着色器的处理单位是顶点,也就是说,输入进来的每个顶点都会调用一次顶点着色器。

顶点着色器本身不可以创建或者销毁任何顶点,而且无法得到顶点与顶点之间的关系。例如,我们无法得知两个顶 点是否属千同一个三角网格。但正是因为这样的相互独立性,GPU 可以利用本身的特性并行化处理每一个顶点,这意味着这一阶段的处理速度会很快。

顶点着色器需要完成的工作主要有:坐标变换和逐顶点光照。当然,除了这两个主要任务外, 顶点着色器还可以输出后续阶段所需的数据。下图展示了在顶点着色器中对顶点位置进行坐标变换并计算顶点颜色的过程。

GPU在每个输入的网格顶点上都会调用顶点着色器

坐标变换。顾名思义,就是对顶点的坐标(即位置)进行某种变换。顶点着色器可以在这 一步中改变顶点的位置,这在顶点动画中是非常有用的。例如,我们可以通过改变顶点位置来模拟水面、布料等。但需要注意的是无论我们在顶点着色器中怎样改变顶点的位置,一个最基本的顶点着色器必须完成的一个工作是,把顶点坐标从模型空间转换到齐次裁剪空间 。想想看,我们在顶点着色器中是不是会看到类似下面的代码

1
o.pos = mul(UNITY_MVP, v.position); 

类似上面这句代码的功能,就是把顶点坐标转换到齐次裁剪坐标系下,接着通常再由硬件做透视除法后,最终得到归一化的设备坐标 (Normalized Device Coordinates , NDC)

顶点着色器会将模型顶点的位置变换到齐次裁剪坐标空间下,进行输出后再由硬件做透视除法得到 NDC 下的坐标

需要注意的是,上图给出的坐标范围是 OpenGL 同时也是 Unity 使用的 NDC, 它的 ${z}$ 分量 范围在[-1,1] 之间,而在 DirectX 中, NDC 分量范围是 [0, 1] 。顶点着色器可 以有不同的输出方式。最常见的输出路径是经光栅化后交给片元着色器进行处理。而在现代的 Shader Model 它还可以把数据发送给曲面细分着色器或几何着色器。

裁剪 Clipping

由于我们的场景可能会很大 ,而摄像机的视野范围很有可能不会覆盖所有的场景物体, 一个很自然的想法就是,那些不在摄像机视野范围的物体不需要被处理 。而裁剪 (Clipping) 就是为 了完成这个目的而被提出来的。

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

只有在单位立方体的图元才需要被继续处理

和顶点着色器不同,这一步是不可编程的,即我们无法通过编程来控制裁剪的过程,而是硬件上的固定操作,但我们可以自定义一个裁剪操作来对这一步进行配置。

屏幕映射 Screen Mapping

这一步输入的坐标仍然是三维坐标系下的坐标(范围在单位立方体内)。屏幕映射 (Screen Mapping) 的任务是把每个图元的坐标转换到 屏幕坐标系 (Screen Coordinates) 下。屏幕坐标系是一个二维坐标系,它和我们用于显示画面的分辨率有很大关系。

假设,我们需要把场景渲染到一个窗口上,窗口的范围是从最小的窗口坐标${(x_1,y_1)}$到最大的窗口坐标${(x_2,y_2)}$, 其中${x_1<x_2}$且${y_1 < y_2}$。由于我们输入的坐标范围在-1 到1, 因此可以想象到,这个过程实际是一个缩放的过程,如下图所示。你可能会问,那么输入的 ${z}$ 坐标会怎么样呢?屏幕映射不会对输入的 ${z}$ 坐标做任何处理。

实际上,屏幕坐标系和${z}$坐标一起构成了一个坐标系,叫窗口坐标系 (Window Coordinates) 。这些值会一起被传递到光栅化阶段

image-20230130162910716

屏幕映射得到的屏幕坐标决定了这个顶点对应屏幕上哪个像素以及距离这个像素有多远。

OpenGL和DirectX的屏幕坐标系差异。对干一张 512\*512 大小的图像,在 OpenGL 中其 (0, 0) 点在左下角,而 DirectX 中其(0, O) 点在左上角

光栅化阶段

光栅化概念阶段中的三角形设置 (Triangle Setup)三角形遍历 (Triangle Traversal) 阶段也都是固定函数 (Fixed-Function) 的阶段。接下来的片元着色器 (Fragment Shader), 则是完全可编程的,它用于实现逐片元 Per-Fragment 的着色操作。最后,逐片元操作 (Per-Fragment Operations) 阶段负责执行很多重要的操作,例如修改颜色、深度缓冲、进行混合等,它不是可编程的,但具有很高的可配置性。

三角形设置 Triangle Setup

由这一步开始就进入了光栅化阶段。从上一个阶段输出的信息是屏幕坐标系下的顶点位置以及和它相关的额外信息,如深度值 (z 坐标)、法线方向、视角方向等。光栅化阶段有两个最重要的目标:计算每个图元覆盖了哪些像素,以及为这些像素计算它们的颜色。

光栅化的第一个流水线阶段是三角形设置 (Triangle Setup) 。这个阶段会计算光栅化一个三角网格所需的信息。具体来说,上一个阶段输出的都是三角网格的顶点,即我们得到的是三角网格每条边的两个端点。但如果要得到整个三角网格对像素的覆盖情况我们就必须计算每条边上 的像素坐标。为了能够计算边界像素的坐标信息,我们就需要得到三角形边界的表示方式。这样一个计算三角网格表示数据的过程就叫做三角形设置。它的输出是为了给下一个阶段做准备。

三角形遍历 Triangle Traversal

三角形遍历 (Triangle Traversal) 阶段将会检查每个像素是否被一个三角网格所覆盖。如果被覆盖的话,就会生成一个片元 (fragment) 。而这样一个找到哪些像素被三角网格覆盖的过程就是三角形遍历,这个阶段也被称为扫描变换 (Scan Conversion)

三角形遍历阶段会根据上一个阶段的计算结果来判断一个三角网格覆盖了哪些像素,并使用三角网格3个顶点的顶点信息对整个覆盖区域的像素进行插值。下图展示了三角形遍历阶段的简化计算过程。

三角形遍历的过程

这一步的输出就是得到一个片元序列。需要注意的是,一个片元并不是真正意义上的像素,而是包含了很多状态的集合,这些状态用于计算每个像素的最终颜色。这些状态包括了(但不限于)它的屏幕坐标深度信息,以及其他从几何阶段输出的顶点信息,例如法线、纹理坐标等。

片元着色器 Fragment Shader

片元着色器 (Fragment Shader) 是另一个非常重要的可编程着色器阶段。在 DirectX 中,片元着色器被称为像素着色器 (Pixel Shader), 但片元着色器是一个更合适的名字,因为此时的片元并不是一个真正意义上的像素。

前面的光栅化阶段实际上并不会影响屏幕上每个像素的颜色值,而是会产生一系列的数据信息,用来表述一个三角网格是怎样覆盖每个像素的。而每个片元就负责存储这样一系列数据。真正会对像素产生影响的阶段是下一个流水线阶段——逐片元操作 (Per-Fragment Operations) 我们随后就会讲到。、

片元着色器的输入是上一个阶段对顶点信息插值得到的结果。更具体来说,是根据那些从顶点着色器中输出的数据插值得到的。而它的输出是一个或者多个颜色值。

这一阶段可以完成很多重要的渲染技术,其中最重要的技术之一就是纹理采样。为了在片元着色器中进行纹理采样,我们通常会在顶点着色器阶段输出每个顶点对应的纹理坐标,然后经过光栅化阶段对三角网格的3个顶点对应的纹理坐标进行插值后,就可以得到其覆盖的片元的纹理

根据上一步插值后的片元信息,片元着色器计算该片元的输出颜色

虽然片元着色器可以完成很多重要效果 但它的局限在于,它仅可以影响单个片元。也就是说,当执行片元着色器时,它不可以将自己的任何结果直接发送给它的邻居们。有一个情况例外, 就是片元着色器可以访问到导数信息 (gradient 或者说是 derivative) 。

逐片元操作 Per Fragment Operations

逐片元操作 (Per Fragment Operations) OpenGL 中的说法,在DirectX中,这一阶段被称为输出合井阶段 (Output-Merger) Merger 这个词可能更容易让读者明白这一步骤的目的:合并。而 OpenGL 中的名字可以让读者明白这个阶段的操作单位, 即是对每一个片元进行一些操作。那么问题来了,要合并哪些数据?又要进行哪些操作呢?

这一阶段有几个主要任务

  1. 决定每个片元的可见性。这涉及了很多测试工作,例如深度测试、模板测试等。
  2. 如果一个片元通过了所有的测试,就需要把这个片元的颜色值和已经存储在颜色缓冲区中的颜色进行合并,或者说是混合。

需要指明的是,逐片元操作阶段是高度可配置性的,即我们可以设置每一步的操作细节 。

这个阶段首先需要解决每个片元的可见性问题。这需要进行一系列测试。这就好比考试, 一个片元只有通过了所有的考试,才能最终获得和 GPU 谈判的资格,这个资格指的是它可以和颜色缓冲区进行合并。如果它没有通过其中的某一个测试,那么对不起,之前为了产生这个片元所做的所有工作都是白费的,因为这个片元会被舍弃掉。 Poor fragment! 下图给出了简化后的逐片元操作所做的操作。

逐片元操作阶段所做的操作

作为一个想充分提高性能的 GPU, 它会希望尽可能早地知道哪些片元是会被舍弃的,对于这些片元就不需要再使用片元着色器来计算它们的颜色。 在 Unity 给出的渲染流水线中, 我们也可以发现它给出的深度测试是在片元着色器之前。这种将深度测试提前执行的技术通常也被称为Early-Z技术。

但是,如果将这些测试提前的话, 其检验结果可能会与片元着色器中的一些操作冲突。 例如,如果我们在片元着色器进行了透明度测试,而这个片元没有通过透明度测试, 我们会在着色器中调用 APJ C例如 clip 函数)来手动将其舍弃掉。 这就导致 GPU 无法提前执行各种测试。 因此,现代的 GPU 会判断片元着色器中的操作是否和提前测试发生冲突,如果有冲突,就会禁用提前测试。但是, 这样也会造成性能上的下降, 因为有更多片元需要被处理了。 这也是透明度测试会导致性能下降的原因。

现在大多数 GPU 都支持一种称为提前深度测试(Early depth testing)的硬件功能。提前深度测试允许深度测试在片段着色器之前运行。明确一个片段永远不会可见的 (它是其它物体的后面) 我们可以更早地放弃该片段。

片段着色器通常是相当费时的所以我们应该尽量避免运行它们。对片段着色器提前深度测试一个限制是,你不应该写入片段的深度值。如果片段着色器将写入其深度值,提前深度测试是不可能的,OpenGL不能事先知道深度值。

当模型的图元经过了上而层层计算和测试后, 就会显示到我们的屏幕上。 我们的屏幕显示的 就是颜色缓冲区中的颜色值。 但是,为了避免我们看到那些正在进行光栅化的图元,GPU 会使用双重缓冲 (Double Buffering) 的策略。这意味着,对场景的渲染是在幕后发生的, 即在后置缓冲 (Back Buffer) 中。 一旦场景已经被渲染到了后置缓冲中, GPU 就会交换后置缓冲区和前置缓冲 (Front Buffer) 中的内容, 而前置缓冲区是之前显示在屏幕上的图像。 由此, 保证了我们看到的图像总是连续的。

总结与常识

概括来说,我们的应用程序运行在 CPU 上。应用程序可以通过调用 OpenGL DirectX 的图形接口将渲染所需的数据,如顶点数据、纹理数据、材质参数等数据存储在显存中的特定区域 随后,开发者可以通过图像编程接口发出渲染命令,这些渲染命令也被称为 Draw Call, 它们将会 被显卡驱动翻译成 GPU 能够理解的代码,进行真正的绘制。

下图可以看出, 一个显卡除了有图像处理单元 GPU 外,还拥有自己的内存,这个内存通常被称为显存 (Video Random Access Memory, VRAM) GPU 可以在显存中存储任何数据, 但对于渲染来说一些数据类型是必需的,例如用于屏幕显示的图像缓冲、深度缓冲等。

image-20230130224205472

投影

不管是透视投影(Perspective Projection)摄像机还是正交投影(Orthographic Projection)摄像机,视锥体都是由Top、Bottom、Right、Left以及Near和Far共六个平面组成。

利用投影的方法将顶点坐标从3D转变为2D:

首先将顶点的x、y、z坐标分别除以w分量(写给大家看的“透视除法” —— 齐次坐标和投影https://www.jianshu.com/p/7e701d7bfd79),得到标准化的设备坐标(Normalized Device Coordinate。NDC)。

空间变换流程图与变换矩阵

模型空间(本地空间) —模型变换(利用模型变换矩阵)—>世界空间 —视变换(利用视变换举证)—> 摄像机空间(观察空间)—投影变换(利用裁切矩阵)—>裁切空间 —屏幕投影(利用投影矩阵)—> 屏幕空间(得到屏幕像素坐标)

Shader和材质的关系与区别

Shader 实际上就是一段程序,它负责把输入的顶点数据按照代码里指定的方式进行处理,并对输入的颜色或者贴图等进行计算,然后输出数据。图像绘制单元获取到输出的数据便可将图像绘制出来,最终呈现在屏幕上。

Shader 程序代码,再加上开放的参数设置以及关联的贴图等等,为实现某种效果而打包存储在一起,最终得到的就是材质(Material)。
材质是Shader的实例化资源,一个Shader可以实例化为多个材质,并且调节为不同的材质效果。最后把材质指定给某个模型就可以渲染出对应的效果了。

Draw Call

Draw Call 本身的含义很简单,就是CPU 调用图像编程接口,如 OpenGL 中的 glDrawElements 命令或者 DirectX 中的 DrawlndexedPrimitive ,以命令 GPU 进行渲染的操作。

为什么 Draw Call 多了会影响帧率?

在每次调用 Draw Call 之前, CPU 需要向 GPU 发送很多内容,包括数据、状态和命令等。在这一阶段, CPU 需要完成很多工作,例如检查渲染状态等。而一旦 CPU 完成了这些准备工作, GPU 就可以开始本次的渲染。 GPU 的渲染能力是很强的,渲染 200 个还是 2 000个三角网格通常没有什么区别,因此渲染速度往往快于 CPU 提交命令的速度。如果 Draw Call 的数量太多, CPU 就会把大量时间花费在提交 Draw Call 上,造成 CPU 的过载。下图显示了这样一个例子:

命令缓冲区中的虚线方框表示 GPU 已经完成的命令

尽管减少 Draw Call 的方法有很多,但我们这里仅讨论使用批处理 (Batching) 的方法。 我们讲过,提交大量很小的 Draw Call 会造成 CPU 的性能瓶颈,即 CPU 把时间都花费在准 Draw Call 的工作上了 。那么,一个很显然的优化想法就是把很多小的 DrawCall 合并成一个大 Draw Call, 这就是批处理的思想。图 2.21 显示了批处理所做的工作。

如何减少 Draw Call?

尽管减少 Draw Call 的方法有很多,但我们这里仅讨论使用批处理 (Batching) 的方法。我们讲过,提交大量很小的 Draw Call 会造成 CPU 的性能瓶颈,即 CPU 把时间都花费在准备 Draw Call 的工作上了。那么,一个很显然的优化想法就是把很多小的 DrawCall 合并成一个大的 Draw Call, 这就是批处理的思想。

需要注意的是,由于我们需要在 CPU 的内存中合并网格,而合并的过程是需要消耗时间的。 因此,批处理技术更加适合于那些静态的物体,例如不会移动的大地、石头等,对于这些静态物体我们只需要合并一次即可。当然,我们也可以对动态物体进行批处理。但是,由于这些物体是不断运动的,因此每一帧都需要重新进行合并然后再发送给 GPU, 这对空间和时间都会造成一定的影响。

使用批处理合并的网格将会使用同一种渲染状态

在游戏开发过程中,为了减少 Draw Call 的开销,有两点需要注意。

  1. 避免使用大蜇很小的网格。当不可避免地需要使用很小的网格结构时,考虑是否可以合并它们
  2. 避免使用过多的材质。尽量在不同的网格之间共用同一个材质。

固定管线渲染

固定函数的流水线 (Fixed-Function Pipeline), 也简称为固定管线,通常是指在较旧的 GPU 上实现的渲染流水线。这种流水线只给开发者提供一些配置操作,但开发者没有对流水线阶段的完全控制权。

固定管线通常提供了一系列接口,这些接口包含了一个函数入口点 (Function Entry Points) 集合,这些函数入口点会匹配 GPU 上的一个特定的逻辑功能。开发者们通过这些接口来控制渲染流水线。换句话说,固定渲染管线是只可配置的管线。一个形象的比喻是,我们在使用固定管线进行渲染时,就好像在控制电路上的多个开关,我们可以选择打开或者关闭一个开关,但永远无法控制整个电路的排布。

简化的渲染完整流水线

建立场景

  • 在真正开始渲染之前,需要对整个场景进行预先设置,例如:摄像机视角、灯光设置以及物化设置等等。

可见性检测

  • 有了摄像机,就可以基于摄像机视角检测场景中所有物体的可见性。这一步在实时渲染中极为重要,因为可以避免把时间和性能浪费在渲染一些视角之外的物体上。

设置渲染状态

  • 一旦检测到某个物体是可见的,接下来就需要把它绘制出来了。但是由于不同物体的渲染状态可能不同(如何进行深度测试、如何与背景图像进行混合等),因此在开始渲染该物体之前首先需要设置该物体的渲染状态。

几何体生成与提交

  • 接着,就需要向渲染API提交几何体数据了,一般所提交的数据为三角形的顶点数据。例如:顶点坐标、法线向量、UV等。

变换与光照

  • 当渲染API获取到三角形顶点数据之后,就需要将顶点坐标从模型空间变换到摄像机空间,并且同时进行顶点光照计算。

背面剔除与裁切

  • 变换到摄像机空间之后,那些背对着摄像机的三角形会被剔除,然后再被变换到裁切空间中,将视锥体之外的部分裁切掉。

投影到屏幕空间

  • 在裁切空间中经历裁切之后的多边形会通过投影从三维变为平面,输出到屏幕空间中。

光栅化

  • 屏幕空间中的几何体还需要经过光栅化处理才能转变为2D的像素信息。

像素着色 nm

  • 最后,计算每个像素的颜色,并把这些颜色信息输出到屏幕上。