Shader中的光照模型

Lambert光照模型
在现实生活中,一个物体的颜色是由射进眼睛里的光线决定的。
当光线射到物体表面时,一部分被物体表面吸收,另一部分被反射。
对于透明物体而言,还有一部分光会穿过透明体,产生透射光。
被物体吸收的光会产生热量,只有反射光和透射光才能进入眼睛。
从而产生视觉效果(物体呈现出亮度和颜色)。
所以,物体表面的光照颜色是由入射光、物体材质、以及材质和光的交互规律共同决定。

最常用的是Lambert光照模型
因为它能比较真实地还原粗糙物体表面与光地交互行为,并且计算效率非常高,因此即便是现在也依然被广泛应用与游戏渲染。

Lambert 光照渲染模型
当光线照射到表面粗糙的物体,例如:石灰墙壁、纸张、布料等,光线会向各个方向等强度的反射,这种现象称为光的漫反射现象。

漫反射满足 Lambert 定律(Lambert’s Law):反射光线强度与表面法线和光源方向之间的夹角成正比。

产生漫反射的物体被称为理想漫反射物体,这种光照模型称为Lambert光照模型。(**基本光照模型系列——1.Lambert光照模型:https://zhuanlan.zhihu.com/p/144681176**)

Cdiffuse = (Clight · Mdiffuse)saturate(n·l)

其中,Cdiffuse是物体的漫反射颜色;Clight为入射光的颜色;Mdiffuse为物体的漫反射颜色;n为物体表面的法线;l为物体的灯光方向

为了避免负值的出现,因此使用CG数学函数 saturate( )将点积结果截取到[0,1]的区间范围内

在Shader中获取灯光变量
灯光参数如何传递给Shader取决于当前Unity项目使用的渲染路径(Rendering Path),以及Shader中用于声明灯光模式的Pass Tag。

查看当前项目的渲染路径:
Edit>Project Settings>Graphics

在Low,Medium,High 这三个等级中,可以发现Unity已经默认将Rendering Path 设置成了 Forward(向前渲染)。

变量 类型 说明
_LightColor0 fixed4 灯光的颜色乘上亮度,在UnityLightingCommon.cginc中被声明
_WorldSpaceLightPos0 float4 平行光属性:float4(世界空间灯光方向,0)
其他灯光属性:float4(世界空间灯光位置,1)
_LightMatrix0 float4x4 世界到灯光的变换矩阵,用于采样灯光 cookie 和衰减贴图,在AutoLight.cginc中被声明
unity_4LightPosX0,
unity_4LightPosY0,
unity_4LightPosZ0
float4 前四个非重要带点光在世界空间的位置,只能用于ForwardBass pass
unity_4LightAtten0 float4 前四个非重要点光的衰减系数,只能用于ForwardBass pass
untiy_LightColor half4[4] 前四个非重要点光的颜色,只能用于ForwardBass pass
unity_WorldToShadow float4x4[4] 世界到阴影的变换矩阵,一个用于聚光灯矩阵,最多4个用于串联平行光的矩阵。

基于 Lambert 光照模型的 Shader
使用一个平行光进行照射,此处代码运行在URP渲染管线下。

Shader "Custom/Lambert"
{
    Properties
    {
        _MainColor("Main Color",Color) = (1,1,1,1)
    }
    SubShader
    {
        Pass
        {
            CGPROGRAM
            # pragma vertex vert
            # pragma fragment frag
            # include "UnityCG.cginc"
            //声明包含灯光变量的文件
            # include "UnityLightingCommon.cginc"

            struct v2f
            {
                float4 pos : SV_POSITION;
                fixed4 dif : COLOR0;
            };
             fixed4 _MainColor;
             fixed3 l;
             v2f o;
             v2f vert(appdata_base v)
             {
                 o.pos = UnityObjectToClipPos(v.vertex);
                 //法线向量
                 float3 n = UnityObjectToWorldNormal(v.normal);
                 n = normalize(n);
                 //灯光方向向量
                 l = normalize(_WorldSpaceLightPos0.xyz);
                 //按照公式计算漫反射
                 fixed ndotl = dot(n, l);
                 o.dif = _LightColor0 * _MainColor * saturate(ndotl);
                 return o;
             }
             fixed4 frag(v2f i) : SV_Target
             {
                 return i.dif;
             }
             ENDCG
        }

    }

}

注意,标红部分与《Unity ShaderLab 新手宝典》上的代码有出入,原书代码为:

Shader "Custom/Lambert"
{
    Properties
    {
        _MainColor("Main Color",Color) = (1,1,1,1)
    }
    SubShader
    {
        Pass
        {
            CGPROGRAM
            # pragma vertex vert
            # pragma fragment frag
            # include "UnityCG.cginc"
            //声明包含灯光变量的文件
            # include "UnityLightingCommon.cginc"

            struct v2f
            {
                float4 pos : SV_POSITION;
                fixed4 dif : COLOR0;
            };

             fixed4 _MainColor;

             v2f vert(appdata_base v)
             {
                 v2f o;
                 o.pos = UnityObjectToClipPos(v.vertex);

                 //法线向量
                 float3 n = UnityObjectToWorldNormal(v.normal);
                 n = normalize(n);

                 //灯光方向向量

                 fixed3 l = normalize(_WorldSpaceLightPos0.xyz);

                 //按照公式计算漫反射
                 fixed ndotl = dot(n, l);
                 o.dif = _LightColor0 * _MainColor * saturate(ndotl);
                 return o;
             }
             fixed4 frag(v2f i) : SV_Target
             {
                 return i.dif;
             }
             ENDCG
        }

    }

}

fe5bd2e763c71fc77830474b208a8ba0.png

CG标准库函数
一些现成的数学函数

函数 说明
abs(x) 返回x的绝对值
ceil(x) 返回大于x的最小整数
clamp(x,a,b) 将x限制在[min,max]范围内
cos(x) 返回x的余弦值
cross(A,B) 返回A和B的叉积,A,B必须为三维向量
degrees(x) 将弧度转变为角度
dot(A,B) 返回A和B的点积
floor(x) 返回小于x的最大整数
fmod(x,y) 返回x/y的浮点型余数
frac(x) 返回x的小数部分
lerp(a,b,f) 线性插值,返回(1-f) * a + b*f
max(a,b) 返回 a、b中的最大值
min(a,b) 返回a、b中的最小值
mul(M,V) 使用矩阵M对向量V进行变换
pow(x,y) 返回x的y次幂
radians(x) 将度数转变为弧度
round(x) 返回最接近x的整数,相当于对x的四舍五入
saturate(x) 将x范围截取到[0,1]
sign(x) 如果 x >0,返回1;如果 x <0,返回-1;否则返回0
sin(x) 返回x的正弦值
step(a,x) 如果 x < a,返回0;如果 x ≥ a,返回1
sqrt(x) 返回x的平方根
distance(pt1,pt2) 返回pt1与pt2之间的距离
length(v) 返回向量v的长度
normalize(x) 返回方向相同,长度为1的向量
reflect(I,N) 输入入射方向 I 和表面法线 N,返回反射向量,只能用于三维向量。
reflect(I,N,eta) 输入入射方向 I 和表面法线 和折射率eta,返回折射向量,只能用于三维向量。

CG标准库中的函数是针对于硬件设备底层实现的算法,因此可以非常快速地执行。
虽然对于一些简单的算法也可以自己输入公式计算,但是使用CG标准库里的函数更节省性能,
因此建议大家尽量使用CG标准库中的函数进行计算。

如果想要了解更多CG标准库的函数,可以阅读Nvidia的CG用户手册。

Half-Lambert 光照模型
使用Lambert光照模型有一个明显的缺点,那就是物体背光完全是黑的,看不到表面的任何细节,以至于只能再添加一盏辅助光照亮物体表面,这非常不利于性能的优化。

计算公式为:

Cdiffuse = (Clight · Mdiffuse)[0.5(n·l)+ 0.5]

其中,Cdiffuse是物体的漫反射颜色;Clight为入射光的颜色;Mdiffuse为物体的漫反射颜色;n为物体表面的法线;l为物体的灯光方向

与Lambert光照模型不同的是,表面法线与光照方向点积之后并不是直接截取到区间[0,1],而是先乘以0.5将数值区间缩小到[-0.5,0.5],
然后加上0.5,将区间移动到[0.1]。
如此一来,物体的光照强度会从最亮的迎光面逐渐过度到最暗的被光面。

将Lamber光照Shader改写为Hafl-Lambert

v2f vert(appdata_base v)
             {
                 v2f o;
                 o.pos = UnityObjectToClipPos(v.vertex);

                 //法线向量
                 float3 n = UnityObjectToWorldNormal(v.normal);
                 n = normalize(n),

                 //灯光方向向量
                 fixed3 l = normalize(_WorldSpaceLightPos0.xyz);

                 //按照公式计算漫反射
                 fixed ndotl = dot(n, l);
                 o.dif = _LightColor0 * _MainColor * (0.5 * ndotl + 0.5);
                 return o;
             }

完整Shader代码:

Shader "Custom/TestShader"
{

        Properties
        {
            _MainColor("Main Color",Color) = (1,1,1,1)
        }
            SubShader
        {
            Pass
            {
                CGPROGRAM
                # pragma vertex vert
                # pragma fragment frag
                # include "UnityCG.cginc"
                //声明包含灯光变量的文件
                # include "UnityLightingCommon.cginc"
                struct v2f
                {
                    float4 pos : SV_POSITION;
                    fixed4 dif : COLOR0;
                };
                 fixed4 _MainColor;
                 fixed3 l;
                 v2f o;
                 v2f vert(appdata_base v)
                 {

                     o.pos = UnityObjectToClipPos(v.vertex);
                     //法线向量
                     float3 n = UnityObjectToWorldNormal(v.normal);
                     n = normalize(n),
                         //灯光方向向量
                          l = normalize(_WorldSpaceLightPos0.xyz);
                     //按照公式计算漫反射
                     fixed ndotl = dot(n, l);
                     o.dif = _LightColor0 * _MainColor * (0.5 * ndotl + 0.5);
                     return o;
                 }
                 fixed4 frag(v2f i) : SV_Target
                 {
                     return i.dif;
                 }
                 ENDCG
            }
        }
}

问题同上,效果:
球体的背光部分多了很多细节,不再是全黑的了
0d4f61b2e7c02aa9d97717fc58bce28f.png

Phong 光照模型

Lambert模型能够较好地模拟出粗糙物体表面的光照效果,但是在真实环境中还存在很多表面光滑的物体,例如金属、陶瓷、塑料等,而Lambert光照模型却无法对此进行很好的表现,因此引入表面镜面反射的光照模型 —— Phong 光照模型

Phong 光照模型理论
Bui Tuong Phong提出一种局部光照的经验模型,他认为物体表面反射光线由三部分组成:

SurfaceColor = CAmbient +  CDiffuse + CSpecular

其中,CAmbient为环境光,CDiffuse为漫反射,CSpecular为镜面反射。
物体不仅会受到灯光的直接照射,还会受周围环境光(Ambient)的影响。
274c7a52e0d094238b732891330f705d.png
如图所示,当光线照射到一个表面光滑的物体时,除了有漫反射光光(Diffuse)之外, 从某个角度还可以看到很强的反射光。
这是因为在接近镜面反射角的一个固定区域内,大部分入射光会被反射,这种现象称为镜面反射(Specular)。

镜面反射的公式为:

CSpecular = (Clight · Mspecular)saturate(v·r)Mshininess

其中,Clight为灯光强度,Mspecular为物体材质的镜面反射颜色,v为视角方向(由顶点指向摄像机),r为光线的反射方向, Mshininess为物体材质的反光度。

由于这种光照模型在当时影响力相当广泛,即便是现在很多游戏引擎依然还在使用。
为了表现 Bui Tuong Phong 对图形渲染领域做出的突出贡献,后人就以他的名字命名这种光照模型——Phong模型

在Shader中获取环境光变量

Phong光照模型中会用到环境变量,一些Unity提供的可以直接使用的环境光变量如下表给出,表中的变量在UnityShaderVariables.cginc中给出。

变量 类型 说明
unity_AmbientSky fixed4 Gradient类型环境中的Sky Color
unity_AmbientEuator fixed4 Gradient类型环境中的Equator Color
unity_AmbientGround fixed4 Gradient类型环境中的Ground Color
UNITY_LIGHTMODEL_AMBIENT fixed4 Gradient类型环境中的Sky Color,将被 unity_AmbientSky 取代

查看当前场景中的环境光:
依次点击菜单
Window > Rendering > Lighting
a31464ec3e81577883472f2f277456c0.png

需要注意的是,要把默认的Skybox类型改为Gradient类型,才能使用上述的变量。

基于 Phong 光照的 Shader

Properties
{
    _MainColor("Main Color",Color) = (1,1,1,1)
    _SpecularColor("Specular Color",Color) = (0,0,0,0)
    _Shininess("Shininess",Range(1,100)) = 1
}

在Properties代码块增加了控制高光颜色的 _SpecularColor 属性和控制光泽度的 _Shininess 属性。

float3 n = UnityObjectToWorldNormal(v.normal);
n = normalize(n);
fixed3 l = normalize(_WorldSpaceLightPos0.xyz);
fixed3 view = normalize(WorldSpaceViewDir(v.vertex));

在顶点着色器中,计算出标准化的世界空间法线向量n、世界空间灯光方向l和时间空间视角方向 view,为后续计算光照效果做好准备。
漫反射光照部分还是继续沿用 Lambert 光照模型计算方法。

float3 ref = reflect(-1,n);
ref = normalize(ref);
fixed rdotv = saturate(dot(ref,view));
fixed4 spec = _LightColor0 * _SpecularColor * pos(rdotv,_Shininess);

在计算镜面反射的过程中,通过使用 CG 函数 reflect() 得到光线的反射向量ref,由于函数需要传入的是灯光指向顶点的方向,而使用WorldSpaceViewDir()函数得到的是顶点指向灯光的方向,所以需要将向量l乘以-1进行反向。

将标准化之后的ref与view点乘得到 rdotv,然后按照镜面反射的计算公式得到镜面反射部分光照。

o.color = unity_AmbientSky + dif + spec
最后把环境光、漫反射和镜面反射相加,得到最终的光照。
片段着色器的计算跟之前一样保持不变。

完整的 Shader 代码如下所示:

Shader "Custom/TestShader"
{

    Properties
    {
        _MainColor("Main Color",Color) = (1,1,1,1)
        _SpecularColor("Specular Color",Color) = (0,0,0,0)
        _Shininess("Shininess",Range(1,100)) = 1
    }
            SubShader
        {
            Pass
            {
                CGPROGRAM
                # pragma vertex vert
                # pragma fragment frag
                # include "UnityCG.cginc"
                //声明包含灯光变量的文件
                # include "Lighting.cginc"
                struct v2f
                {
                    float4 pos : SV_POSITION;
                    fixed4 color : COLOR0;
                };
                 fixed4 _MainColor;
                 fixed4 _SpecularColor;
                 half _Shininess;
                 v2f vert(appdata_base v)
                 {
                     v2f o;
                     o.pos = UnityObjectToClipPos(v.vertex);
                     //计算公式中各个变量
                     float3 n = UnityObjectToWorldNormal(v.normal);
                     n = normalize(n);
                     fixed3 l = normalize(_WorldSpaceLightPos0.xyz);
                     fixed3 view = normalize(WorldSpaceViewDir(v.vertex));
                     //漫反射部分
                     fixed ndotl = saturate(dot(n, l));
                     fixed dif = _LightColor0 * _MainColor * ndotl;
                     //镜面反射部分
                     float3 ref = reflect(-1, n);
                     ref = normalize(ref);
                     fixed rdotv = saturate(dot(ref, view));

                     fixed4 spec = _LightColor0 * _SpecularColor * pow(rdotv,  _Shininess);

                     //环境光+满发射+镜面反射
                     o.color = unity_AmbientSky + dif + spec;
                     return o;
                 }
                 fixed4 frag(v2f i) : SV_Target
                 {
                     return i.color;
                 }
                 ENDCG
            }
        }
}

ac99e53530b50df237209d7e731034d8.png

将改写好的Shader指定给之前的材质,然后将环境光设置为 Gradien 类型。
如图所示,可以看到不同部分的灯光效果以及最终完整的光照效果。
其中环境光效果与方向无关,物体任意部位的强度都一样;漫反射效果与Lambert一样,强度随着物体表面方向的不同而改变;
镜面反射效果比较聚集吗,但强度很大。

环境光 + 漫反射 + 镜面反射 = 完整光照效果。

逐像素光照
使用上述Phong光照Shader之后,不难发现这样一个现象:
高光部分与平时看到的不太一样。
印象中的高光点应该是边缘很圆滑的,而现在却不清晰。

出现这样的现象是因为光照模型的计算一直是逐顶点的(Per-Vertex)光照,而不是逐像素(Per-Pixel)光照。

逐顶点光照是在顶点着色器中计算光照颜色。
计算过程中,GPU将为多边形的每个顶点执行一遍光照计算,得到顶点颜色,然后顶点在多边形上所占的范围对像素颜色进行线性插值。
对于细分较高的模型,由于每个多边形所占范围很小,因此插值之后每个像素的误差很小,所以逐顶点光照基本上已经可以满足需求了。

逐像素光照其实就是在像素着色器(也就是Unity中的片段着色器)中计算光照颜色。
在像素着色器中,颜色的计算就不再是基于顶点了,而是基于像素的计算,所以最终屏幕的分辨率越高计算量越大。

如果想要解决高光不清晰的问题,可以使用一个细分程度更高的多边形,或者将逐顶点光照改逐像素光照。

在Phong Shader的基础上,使用改为逐像素光照的方法:

struct v2f
{
    float4 pos : SV_POSITION;
    float3 normal : TEXCOORD0;
    float4 vertex : TEXCOORD1;
};

由于不是在顶点着色器中计算光照颜色,而是将所有需要的数据都传到片段着色器中进行计算,因此在v2f结构体中不再需要保存顶点色的变量,而是添加了normal 和 vertex 两个变量,分别用来保存世界空间法线向量和顶点坐标。

v2f vert(appdata_base v)
{
    v2f o;
    o.pos = UnityObjectToClipPos(v.vertex);
    o.normal = v.normal;
    o.vertex = v.vertex;

    return o;
}

在顶点着色器中,只将世界空间法线向量和顶点坐标传递给片段着色器,除此之外不做其他计算。

fixed4 frag(v2f i) : SV_Target
{
    //计算公式中的所有变量
    float3 n = UnityObjectToWorldNormal(i.normal)
    n = normalize(n);
    fixed3 l = normalize(_WorldSpaceLightPos0,xyz);
    fixed3 view = normalize(WorldSpaceViewDir(i.vertex));

    //漫反射部分
    fixed ndotl = saturate(dot(n,l));
    fixed4 dif = _LightColor0 * _MainColor * ndotl;

    //镜面反射部分
    float3 ref = reflect(-1.n);
    ref = normalize(ref);
    fixed rdotv = saturate(dot(ref,view));
    fixed4 spec = _LightColor0 * _SpecularColor * pow(rdotv,_Shininess);

    //环境光 + 漫反射 + 镜面反射
    return unity_AmbientSky + dif  + spec;
}

片段着色器的计算方法和在顶点着色器中的计算是一样的。

完整的Shader代码如下所示:

Shader "Custom/per-pixel Phong"
{

    Properties
    {
        _MainColor("Main Color",Color) = (1,1,1,1)
        _SpecularColor("Specular Color",Color) = (0,0,0,0)
        _Shininess("Shininess",Range(1,100)) = 1
    }
            SubShader
        {
            Pass
            {
                CGPROGRAM
                # pragma vertex vert
                # pragma fragment frag
                # include "UnityCG.cginc"
                //声明包含灯光变量的文件
                # include "Lighting.cginc"
                struct v2f
                {
                   float4 pos : SV_POSITION;
                   float3 normal : TEXCOORD0;
                   float4 vertex : TEXCOORD1;
                };
                 fixed4 _MainColor;
                 fixed4 _SpecularColor;
                 half _Shininess;
                 v2f vert(appdata_base v)
                 {
                     v2f o;
                     o.pos = UnityObjectToClipPos(v.vertex);
                     o.normal = v.normal;
                     o.vertex = v.vertex;
                     return o;
                 }
                 fixed4 frag(v2f i) : SV_Target
                 {
                     //计算公式中的所有变量
                     float3 n = UnityObjectToWorldNormal(i.normal);
                     n = normalize(n);
                     fixed3 l = normalize(_WorldSpaceLightPos0.xyz);
                     fixed3 view = normalize(WorldSpaceViewDir(i.vertex));
                     //漫反射部分
                     fixed ndotl = saturate(dot(n,l));
                     fixed4 dif = _LightColor0 * _MainColor * ndotl;
                     //镜面反射部分
                     float3 ref = reflect(-1,n);
                     ref = normalize(ref);
                     fixed rdotv = saturate(dot(ref,view));

                     fixed4 spec = _LightColor0 * _SpecularColor *  pow(rdotv,_Shininess);

                     //环境光 + 漫反射 + 镜面反射
                     return unity_AmbientSky + dif + spec;
                 }
                 ENDCG
            }
        }
}

fc4b997a99bfbd780845f1b7a902c15a.png
逐像素光照(上图)比逐顶点光照更加细腻。

使用逐像素计算不仅可以提升光照效果的精确度,还可以在渲染时添加多边形原本并不存在的表面细节。
例如使用 Normal Map 可以在像素级别上使原本平坦的表面表现出凹凸效果。

逐像素提供了更好的表现效果。
但是由于逐像素的计算量比逐顶点要大,性能要求更高。
在具体实际使用中究竟做作何选择,需要开发者考虑最终运行的终端设备。

Blinn-Phong 光照模型

Blinn-Phong 光照模型不再使用反射向量r计算镜面反射,而是使用半角向量 h 代替 rh为表视角方向 v 和灯光方向 l 的角平分线。

4da25ae9f824a8a0bb6e1cca38ba940b.png

Blinn-Phong shading using WebGL:https://www.geertarien.com/blog/2017/08/30/blinn-phong-shading-using-webgl

半角向量的计算公式为:

h = normalize( v + l )

Blinn-Phong 镜面反射的计算公式为:

CSpecular = (Clight · Mspecular)saturate(*n *· h)Mshininess

Blinn-Phong光照模型的Shader
改写的片段着色器代码:

fixed4 frag(v2f i) : SV_Target
                 {
                     //计算公式中的各个变量
                     float3 n = UnityObjectToWorldNormal(i.normal);
                     n = normalize(n);
                     fixed3 l = normalize(_WorldSpaceLightPos0.xyz);
                     fixed3 view = normalize(WorldSpaceViewDir(i.vertex));

                     //漫反射部分
                     fixed ndotl = saturate(dot(n,l));
                     fixed4 dif = _LightColor0 * _MainColor * ndotl;

                     //镜面反射部分
                     float3 h = normalize(l + view);
                     fixed rdoth = saturate(dot(n,h));

                     fixed4 spec = _LightColor0 * _SpecularColor *  pow(rdoth,_Shininess);

                     //环境光 + 漫反射 + 镜面反射
                     return unity_AmbientSky + dif + spec;
                 }

完整的Shader代码:

hader "Custom/TestShader"
{
    Properties
    {
        _MainColor("Main Color",Color) = (1,1,1,1)
        _SpecularColor("Specular Color",Color) = (0,0,0,0)
        _Shininess("Shininess",Range(1,100)) = 1
    }
        SubShader
    {
        Pass
        {
            CGPROGRAM
            # pragma vertex vert
            # pragma fragment frag
            # include "UnityCG.cginc"
            //声明包含灯光变量的文件
            # include "Lighting.cginc"
            struct v2f
            {
               float4 pos : SV_POSITION;
               float3 normal : TEXCOORD0;
               float4 vertex : TEXCOORD1;
            };
             fixed4 _MainColor;
             fixed4 _SpecularColor;
             half _Shininess;
             v2f vert(appdata_base v)
             {
                 v2f o;
                 o.pos = UnityObjectToClipPos(v.vertex);
                 o.normal = v.normal;
                 o.vertex = v.vertex;
                 return o;
             }
             fixed4 frag(v2f i) : SV_Target
             {
                 //计算公式中的各个变量
                 float3 n = UnityObjectToWorldNormal(i.normal);
                 n = normalize(n);
                 fixed3 l = normalize(_WorldSpaceLightPos0.xyz);
                 fixed3 view = normalize(WorldSpaceViewDir(i.vertex));
                 //漫反射部分
                 fixed ndotl = saturate(dot(n,l));
                 fixed4 dif = _LightColor0 * _MainColor * ndotl;
                 //镜面反射部分
                 float3 h = normalize(l + view);
                 fixed rdoth = saturate(dot(n,h));

                 fixed4 spec = _LightColor0 * _SpecularColor *  pow(rdoth,_Shininess);

                 //环境光 + 漫反射 + 镜面反射
                 return unity_AmbientSky + dif + spec;
             }
             ENDCG
        }
    }
}

d43e34d7781cf4942de3a85e5ccb97f4.png

Blinn-Phong 效果与 Phong 效果在视觉上的差距并不是很大。

在性能方面,当观察者和灯光离被照射物体非常远时,Blinn-Phong 的计算效率要高于Phong。
因为 h 是取决于视角方向以及灯光方向的,两者都很远时 h 可以被认为是常量,跟位置以及表面曲率没有关系,因此可以大大减少计算量。
而 Phong 却要根据表面曲率去逐顶点或逐像素计算反射向量 r ,相对而言计算量比较大。

灯光阴影
“平行光和环境光对物体照明”的效果在上述Shader中已实现。
但是目前的Shader在实际游戏中应用是不太完美的。
例如:开启灯光的投射阴影选项,物体上没有任何阴影,也不会接受其他物体投射的阴影;
当灯光类型切换为聚光灯或者点光源的时候,光照效果依然和平行光一样,只会受到方向的影响,更改灯光位置不会有任何光照变化。

渲染路径
Unity支持不同的渲染路径(Rendering Path),不同的渲染路径在光照和阴影方面会有不同的功能特点。
当显卡不能使用选定的渲染路径进行渲染时,Unity会自动选择一个较低精度的渲染路径。
例如,当设置的延迟着色(Deferred Shading)不能被执行,前向渲染(Forward Rendering)将会被采用。

1.延迟着色渲染路径

延迟着色顾名思义是将着色步骤延迟处理的渲染方式,是实现最逼真光影效果的渲染路径,即使场景中有成千上万个实时灯光,依然可以保持比较流畅的渲染帧率,但是它需要硬件达到一定的级别才能使用。

当使用延迟着色时,灯光 Pass 基于G-Buffer(Geometry Buffer,屏幕空间缓存)和深度信息计算光照,光照在屏幕空间进行计算,因此计算量与场景的复杂程度无关,如此一来就可以避免计算因未通过深度测试而被丢弃的片段,从而减少性能浪费。并且所有灯光都可以逐像素渲染,这意味着所有灯光都可以与法线贴图等产生正确的交互,并且所有的灯光都可以使用阴影和 cookies。

遗憾的是,延迟着色不支持真正的抗锯齿,也不能处理半透明物体,这些会自动使用前向渲染处理。
使用延迟着色的模型不支持接受阴影选项,Culling Mask 只能在限定条件下使用,且最多只能使用4个。

在性能方面,光照对于性能的损耗不再受灯光数量或受光物体数量影响,而是与受灯光影响的像素数量或者灯光的照射范围有关,所以物体受灯光影响的数量不会再有限制。
投射阴影的灯光依然比无阴影的灯光更耗费性能,投射阴影的物体任然需要为阴影投射灯光渲染多次。但是可以通过减少照射范围来降低性能消耗。

延迟着色只能在有多重渲染目标(Multiple Render Targets, MRT)、Shader Model3.0或以上,并且支持深度渲染贴图的显卡上运行。

移动端上,可以在OpenGL ES 3.0 及以上的设备上运行。

延迟着色不支持正交投影,当摄像机使用正交投影模式的时候,摄像机会自动向前渲染。

2.前向渲染路径
前向渲染路径是传统的渲染路径,它支持所有的Unity图形功能,例如法线贴图、逐像素光照、阴影等。
前向渲染路径使用一个或多个Pass渲染每一个物体,这取决于影响到物体的灯光数量。
并且灯光也会因为自身设置和强度不同而被区别对待。

在前向渲染中,一部分最亮的灯光以完全逐像素照明的方式渲染,然后4个点光源以逐顶点的方式渲染,其余灯光以更快速的SH(Spherical Harmonics,球谐:球谐光照——球谐函数https://zhuanlan.zhihu.com/p/153352797 ;球谐光照——简单理解和实现 https://blog.csdn.net/qq_33999892/article/details/83862583**),**解决环境光对物体的影响光照渲染。

SH 光照可以被非常快速地渲染,他只能消耗很少地CPU性能,几乎不消耗GPU性能。
并且增加SH灯光的数量不会影响性能的消耗。

一个灯光是逐像素光照还是其他方式渲染取决于以下几点:

  1. 渲染模式设置为 Not Important 的灯光总是以逐顶点或者SH的方式渲染。
  2. 渲染模式设置为 Important 的灯光总是逐像素渲染。
  3. 最亮的平行光总是逐像素渲染。
  4. 如果逐像素光照的灯光数量少于项目质量设置中 Pixel Light 的数量,那么其余比较亮的灯光将会被逐像素渲染。

灯光的渲染模式(Render Mode)可以在每个灯光的属性面板里进行设置,默认为 Auto,Unity 会根据灯光的亮度以及与物体的距离自动判断该灯光是否重要。

如果想要设置当前 Unity 项目中逐像素灯光的限制数量,依次点击
Edit > Project Settings > Quality > Pixel Light Count
Unity 默认限制为4个逐像素灯光。

943a17129b71d94cd3833b40a1eaf423.png531851a2f79612c865d397b14852efab.png

基础Pass 包含一个逐像素的平行光和所有逐顶点或 SH 的灯光,并且也会包含所有来自于 Shader 的光照贴图、环境光和自发光。
平行光能够投射阴影,但是灯光贴图不能接受 SH 灯光的照明。

其他逐像素的灯光会在额外的 Pass 中渲染,每一个灯光会产生一个额外的 Pass 。
在额外 Pass 中的灯光默认不会投射阴影。
这意味着在默认情况下,前向渲染只支持一个投射阴影的平行光。

如果希望更多的灯光能够产生投影,就需要添加内置的 multi-compile_fwdadd_fullshadows 编译指令编译出不同的 Shader 变体(Variant)。

3.两种渲染路径的特性对比

特性 前向渲染 延迟着色
功能 逐像素光照(法线贴图、灯光 cookie) 支持 支持
实时阴影 需要满足某些条件 支持
反射探针 支持 支持
深度和法线缓存 需要添加额外的Pass 支持
软粒子 不支持 支持
半透明物体 支持 不支持
抗锯齿 支持 不支持
灯光剔除蒙版 支持 部分共嗯
光照的细节程度 部分灯光逐像素渲染 所有灯光逐像素渲染
单个逐像素照明所耗性能 取决于像素数量乘以灯光照亮的物体数量 取决于照亮的像素数量
物体被正常渲染需要的次数 取决于逐像素光照的灯光数量 1次
对于简单场景的开销
PC(Windows/Mac) 支持 Shader Model3.0 以上
移动(iOS/Android) 支持 OpenGL,ES3.0,MRT
Metal(A8以上设备)
Consoles 支持 XB1,PS4

前向渲染相对于延迟着色的兼容性更高,并且前向渲染也是Unity默认的渲染路径。

Pass 标签
SubShader 中有 Tag(标签) 的概念,其实 Pass 中也有标签功能,弱国想要实现正常的光照效果,需要在 Pass 中使用对应的标签。
Pass 标签的使用语法和 SubShader 是一样的,也是键值对的形式,并且没有数量的限制,语法结构如下所示:
Tags{"TagName1" = "Value1" "TagName2" = "Value2"}

1.LightMode 标签
LightMode 可以设置的标签值

标签值 作用
Always 除了主要平行光,其他灯光不会产生任何光照
ForwardBase 用于计算主要平行光、逐顶点或者SH灯光、环境光和光照贴图,只能在前向渲染中使用
ForwardAdd 为每一个逐像素灯光生成一个Pass进行光照计算,只能在前向渲染中使用。
Deferred 用于渲染 G-Buffer, 只能在延迟着色中使用
ShadowCaster 将物体的深度渲染到阴影贴图或者深度贴图中

例如,代码中可能会这样使用:
Tags {"LightMode" = "ForwardBase"}

2.PassFlags 标签
PassFlags 标签用于更改渲染流水线传递数据给 Pass 的方式。
目前仅可使用的值为 OnlyDirectional。
当使用前向渲染的时候,这个标签使得只有主要平行光、环境光或灯光探针、光照贴图的数据才能传递到 Shader,SH和逐顶点灯光不能传递数据。

内置的 multi_compile
前向渲染只支持一个投射阴影的平行光,如果想要修改默认状态,就需要添加多重编译指令。
Unity 提供了一系列多重编译指令以编译出不同的 Shader 变体,这些编译指令主要用于处理不同类型的灯光、阴影和灯光贴图,可以使用的编译指令如下:

(1) multi_compile_fwdbase:编译 ForwardBase Pass 中的所有变体,用于处理不同类型的光照贴图,并为主要平行光开启或者关闭阴影。

(2) multi_compile_fwdadd:编译 ForwardAdd Pass 中的所有变体,用于处理平行光、聚光灯和点光源,以及它们的 cookie 纹理。

(3) multi_compile_fwdadd_fullshadow:与 multi_compile_fwdadd 类似,但是增加了灯光投射实时阴影的效果。

实现阴影效果
渲染路径、设置 Pass 标签、添加多重编译指令等是和灯光进行完美的交互所需要的前提准备工作。

1.编写 Shader
案例采用 Lambert 光照模型,不在计算镜面反射效果。
(1) 第一个部分为基础 Pass,用于渲染主要平行光和逐顶点或 SH 的灯光,并为主要平行光产生阴影投射。
(2) 第二个部分为额外 Pass,用于渲染其他逐像素灯光,并且在这个 Pass 中也为其他逐像素的灯光产生了阴影投射。

代码详解:

Tags{"LightMode" = "ForwardBase"}

CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma multi_compile_fwdbase
#include "UnityCG.cginc"
#include "Lighting.cginc"
#include "AutoLight.cginc"

在第一个 Pass 中,首先添加标签 “LightMode” = “ForwardBase” 将 Pass 的光照模式设置为前向渲染基础 Pass 。然后调用 multi_compile_fwdbase 多重编译指令为在当前 Pass 中渲染每个灯光编译出不同的 Shader 变体。

由于 Shader 中会用到很多内置变量和预定义函数,所以把相关的文件都包含进来。

struct v2f
{
    float4 pos : SV_POSITION;
    float3 normal :TEXCOORD0;
    float4 vertex : TEXCOORD1;
    SHADOW_COORDS(2) //使用预定义宏保存阴影坐标
};

在v2f结构体中,除了要保存裁切空间顶点坐标,模型空间法线向量和模型空间顶点坐标之外,还引进了一个新的变量,使用宏 UNITY_SHADOW_COORDS(idxl)保存阴影贴图的坐标。

括号内的数字表示 TEXCOORD 语义后的序号,由于0和1已经被法线向量和顶点坐标占用,所以此处使用序号2。
这个宏在包含文件 AutoLight.cginc 中被定义。

v2f vert (appdata_base v)
{
    v2f o;
    o.pos = UnityObjectToClipPos(v.vertex);
    o.normal = v.normal;
    o.vertex = v.vertex;
    TRANSFER_SHADOW(O)    //使用预定义宏变换阴影坐标

    return o;
}

将内置的 appdata_base 结构体传入顶点着色器之后,在顶点着色器中计算出裁切空间顶点坐标,并直接输出传入的法线向量和顶点坐标。

在这里需要引进一个新的宏定义”RANSFER_SHADOW(a)”,这个宏在包含文件 AutoLight.cginc 中被定义,它的作用是变换阴影贴图的纹理坐标并存入结构体中。

宏括号内的 a 表示要存入的结构体名称,由于顶点函数中声明的结构体名称为 o,因此括号内需要填写 o 。

fixed4 frag(v2f i) : SV_Target
{
    //准备变量
    float3 n = UnityObjectToWorldNormal(i.normal);
    n = normalize(n);
    float3 l = WorldSpaceLightDir(i.vertex);
    l = normalize(l);
    float4 worldPos = mul(unity_ObjectToWorld, i.vertex);

    // Lambert 光照
    fixed ndotl = saturate(dot(n,l));
    fixed4 color = _LightColor0 * _MainColor * ndotl;

    // 加上4个点光源光照
    color.rgb  += Shade4PointLights(
    unity_4LightPosX0, unity_4LightPosY0, unity_4LightPosZ0,
    unity_LightColor[0].rgb, unity_LightColor[1].rgb,
    unity_LightColor[2].rgb, unity_LightColor[3].rgb,
    unity_4LightAtten0, worldPos.rgb, n) * _MainColor;

    //加上环境光照
    color += unity_AmbientSky;

    //使用预定义宏计算阴影系数
    UNITY_LIGHT_ATTENUATION(shadowmask, i, worldPos.rgb)

     //阴影合成
     color.rgb *= shadowmask;

    return color;
    }

在片段着色器中,通过计算得到标准化的世界空间法线和世界空间灯光方向,由于使用了 WorldSpaceLightPos( ) 变量,导致聚光灯和点光源的位置对于光照效果是起不了任何作用。

为了解决这个问题,在本Shader中使用WorldSpaceLightDir()函数计算灯光方向,它会根据不同类型的灯光分别计算灯光方向,该函数在包含文件 UnityCG.cginc中被定义。

等到光照效果之后,还需要计算阴影结果。

首先使用宏 UNITY_LIGHT_ATTENUATION(destName,input,worldPos)计算出阴影遮罩,这个宏文件在包含文件 AutoLight.cginc 中被定义,

需要用到的变量如下:
(1)destName:表示阴影系数名称,后续可以直接使用这个名称调用
(2)input:表示传入片段着色器的结构体名称
(3)worldPos:表示世界空间顶点坐标

计算出投影遮罩之后,将其与光照效果相乘,就可以得到有投影的光照效果了。

编写完第一部分的 Shader 之后,还需要为除主光外的其他逐像素灯光产生投影:

1
2
3
4
5
6
7
8
9
10
11
12
13
Tags{"LightMode" = "ForwardAdd"}

//使用相加混合,使绘制的图像与上一个 Pass 完全吻合=
Blend One One

CGPROGRAM

# pragma vertex vert

# pragma fragment frag

# pragma multi_compile_fwdadd_fullshadows

在第二个 Pass 中,首先使用标签”LightMode” = “ForwarAdd”将光照模式设置为ForwardAdd,然后添加多重编译指令 multi_compile_fwdadd_fullshadows 为当前 Pass 中渲染的灯光编译出不同的 Shader 变体,并产生阴影投射。

为了避免当前 Pass 绘制出的图像完全覆盖掉上一个 Pass,需要使用混合指令 Blend 将两个Pass绘制出的图像进行混合。
本Shader中使用 “Blend One One” 混合指令使两个Pass 绘制的图像完全相加。

因为在第一个

顶点-片段着色器基础(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中引入了结构体之后,代码更加简洁了。

ShaderLab语法基础

ShaderLab 语法不区分大小写。真正意义上的Shader代码则是在CGPROGRAM代码块中编写的

Shader 的组织结构
通常情况下,Shader 的大致结构如下:

Shader "Name"
{
    Properties
    {
        //开放到材质面板的属性
    }

    SubShader
    {
        //顶点-片段着色器
        //或者表面着色器
        //或者固定函数着色器
    }

    SubShader
    {
        //更加精简的版本
        //为了在旧的设备上运行
    }

    ...
    Fallback"Name"
}

Shader中可以 编写多个子着色器(SubShader),但至少需要一个。

如果编写的是顶点-片段着色器(Vertex-Fragment Shader),每个着色器中还会包含一个甚至多个Pass。在运行的过程中,如果某个子着色器能够在当前GPU上运行,那么该子着色器内的所有Pass会依次执行,每个Pass输出的结果会以指定的方式与上一步的结果进行混合,最终输出。

表面着色器不会再嵌套Pass。系统在编译表面着色器的时候,会自动生成多个对应的Pass,最终编译出来的Shader本质上就是顶点-片段着色器。

Shader 的名称
Shader 程序的第一行代码用来声明该Shader的名称以及所在路径。
名称就是指该Shader在选择使用的时候所显示的名称,而路径则是指Shader在材质面板上Shader下拉列表里的保存路径。
例如:
Shader "Custom/Simple Shader"
这一行代码的意思是:这个Shader位于Custom路径里,名称为Simple Shader。

Properties
开放的属性 不同类型的变量或贴图等资源
Unity Shader 的属性主要分为三大类:数值、颜色和向量、纹理贴图,每一条属性都是按照以下语法进行定义的:
_Name("Display Name",type) = defaultValue[{options}]

  1. _Name: 属性的名字,为了方便获取,通常在名字的最前面加一个下划线,后续在整个Shader中都将使用这个名称来获取该属性。
  2. Display Name:在材质面板中显示出来的名称。
  3. type:属性的类型。
  4. defaultValue:将Shader指定给材质的时候初始化的默认值。

数值类属性
Unity Shader 的数值类属性基本都是浮点型(Float)数据,虽然Unity提供了整数(Int)数据,但是在编译的时候最终都会转化为浮点型数据。
数值类型的数据有以下两种:

{
    name("display name", Color) = (number,number.number,number)
    name("display name", Vector) = (number1,number2,number3,number4)
}

Color 是颜色类型的数据,由R、G、B、A四个分量定义,在材质面板上显示为取色器(Color Picker)。

需要注意的是:Ps处理图片一般会使用8位深度图,每个通道的亮度最大值为2的8次方=256,由于从0开始计算,因此数值范围是[0,255]。而在Shader中,每个分量的数值范围是[0,1],于是它们之间需要按照对应关系进行线性映射,当数值为0,颜色为黑色,当数值为1,中间部分以线性关系对应。

Vector 是向量类的属性,是一个四维数组,在材质面板上作显示为4个连续的数值输入框,分别为X、Y、Z、W。

颜色和向量类型属性的默认值都是由括号括住的4个浮点数组成,其中颜色属性每个分量的数值区间为[0,1],例如中度灰:(0.5,0.5,0.5,1),而向量属性没有范围限制。

纹理贴图类型

{
    name("display name",2D) = "defaulttexture"{}
    name("display name",Cube) = "defaulttexture"{}
    name("display name",3D) = "defaulttexture"{}
}
  1. 2D属性是纹理类属性中最常使用的,漫反射贴图、法线贴图等都属于2D类型。

  2. Cube全称Cube map texture(立方体纹理),是由前、后、左、右、上、下6张有联系的2D贴图拼成的立方体,主要用作反射,例如Skybox和Reflection Prob。

  3. 3D纹理只能被脚本创建,在实际使用中很少。

2D类型的属性,默认值可以可以为空字符串,也可以是内置的表示颜色的字符串:“white”(RGBA:1,1,1,1),”black”(RGBA:0,0,0,0),”gray”(RGBA:0.5,0.5,0.5,0.5),”bump”(RGBA:0.5,0.5,1,0.5)和“red”(RGBA:1,0,0,0)。其中“bump”通常用于法线体贴图的默认值。

至于非2D类型的属性(Cube,3D,2DArray),默认值为空字符串。当材质没有指定Cubemap或者3D或者2DArray纹理的时候,会默认使用(RGBA:0.5,0.5,0.5,0.5)

所有纹理贴图类的属性最后都有一对空的花括号(是Unity 5.0之前的功能)。

所有类型属性汇总

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
         {
            //pass中的代码
         }
    }
    FallBack "Diffuse"
}

Properties代码在Shader中并不是必须的。如果在实际编写过程中没有开放参数的必要,完全可以在Shader中省略Properties这一部分的代码。

SubShader

在Unity中,每一个Shader都会至少包含一个SubShader。当Unity想要显示一个物体时,它就会去检测这些SubShader,然后选择第一个能够在当前显卡运行的SubShader。

通常情况下,SubShader的大致结构如下所示:

SubShader
{
    //标签
    Tags{"TagName1" = "Value1" "TagName2" = "Value2" ...}

    //渲染状态
    Cull Back
    ...

    Pass
    {
       //第一个Pass
    }
    Pass
    {
       //第二个Pass
    }
    ...
}

每个SubShader都可以设置一个或者多个标签(Tags)和渲染状态(States),然后定义至少一个Pass。在SubShader中设置的渲染状态会影响到该SubShader中所有Pass,如果想要某些状态下不影响其他Pass,可以针对某个Pass单独设置渲染状态。但是需要主要的是,部分渲染状态在Pass中并不支持。

当Unity选择了某个SubShader来渲染某个物体的时候,SubShader中每定义一个Pass都会使这个物体执行一次渲染,当物体受到灯光影响的时候,渲染次数还会增加。所以考虑这方面的影响,应该尽可能地减少Pass的数量。当然,如果某些效果无法通过单个Pass来实现,那么只能使用多个Pass,但是这种情况一定要少出现。

                          Tags(标签)
                          States(状态)
SubShader         Pass - 1
                           Pass - 2
                          …. ….

SubShader的标签
**Tags{"TagNamel" = "Value1""TagName2" = "Value2"}**
标签通过键值对的形式进行声明,并且没有使用数量的限制。如果有需要,可以使用任意数量多个标签。

1.渲染队列
在SubShader中可以用Queue(队列)标签确定物体的渲染顺序,Unity预先定义了五种渲染队列

可以使用的渲染队列:

队列名称 描述 队列号
Background 最先执行渲染,一般用来渲染天空盒(Skybox)或者背景 1000
Geometry 非透明的几何体通常使用这个队列,当没有声明渲染队列的时候,Unity会默认使用这个队列。 2000
AlphaTest Alpha测试的几何体会使用这个队列,之所以从Geometry队列单独拆分出来,是因为当所有实体都绘制完之后再绘制Alpha测试会更高效 2450
Transparent 在这个队列的几何体按由远及近的顺序进行绘制,所有进行Alpha混合的几何体都应该使用这个队列,例如玻璃材质,粒子特效等 3000
Overlay 用来叠加渲染的效果,例如镜头光晕等,放在最后渲染 4000

除了使用Unity预定义的渲染队列, 使用者也可以自己指定一个队列,例如:
Tags{"Queue" = "Geometry + 1"}

这个队列的队列号其实就是2001,表示在所有非透明几何体绘制完成之后再进行绘制。

使用自定义的渲染队列在某些情况下非常有用,例如:透明的水应该在所有不透明几何体之后,透明几何体之前被绘制,所以透明水的渲染队列一般会使用”Queue” = “Transparent -1 “‘

2.渲染类型
RenderType(渲染类型)标签可以将Shader划分为不同的类别,用于后期进行Shader替换或者产生摄像机的深度纹理。

可以设置的渲染类型

类型名称 描述
Opaque 用于普通Shader,例如:不透明、自发光、反射、地形Shader
Transparent 用于半透明Shader,例如:透明、粒子
TransparentCutout 用于透明测试Shader,例如:植物叶子
Background 用于Skybox Shader
Overlay 用于GUI纹理、Halo、Flare Shader
TreeOpaque 用于地形系统中的树干
TreeTransparentCutout 用于地形系统中的树叶
TreeBillboard 用于地形系统中的Billboarded树
Grass 用于地形系统中的草
GrassBillboard 用于地形系统中的Billboarded草

3.禁用批处理

当使用批处理(Batching)的时候,几何体会被变换到世界空间,模型空间会被丢弃。这会导致某些使用模型空间的顶点数据的Shader最终无法实现所希望的效果。而开启DisableBatching(禁用批处理)可以解决这个问题。

禁用批处理有三个数值可以使用

  1. “DisableBatching” = “True”: 总是禁用批处理。
  2. “DisableBatching” = “False”: 不禁用批处理,这是默认数值。
  3. “DisableBatching” = “LODFading”: 当LOD效果激活的时候才会禁用批处理,主要用于地形上的树。

4.禁止阴影投射

在游戏中,有很多特效类的物体并不需要对其他物体产生投影,这个时候可以使用”ForceNoShadowCasting”(禁止阴影照射)标签来达到需要实现的效果。只要将这个标签的数值设为true,那么使用这个Shader的物体就不会对其他物体产生投射阴影了。

5.忽略Projector
如果不希望物体受到Projector(投影机)的投射,可以在Shader中添加IgnoreProjector标签。
它有两个数值可以使用:”True”和”False”,分别为忽略投射机和不忽略投射机。一般半透明的Shader都会开启这个标签。

6.其他标签
除了以上标签之外,Unity还提供了很多不常用的标签,例如CanUseSpriteAtlas、PreviewType。

Pass的渲染状态
如果想某些Pass的渲染状态不影响到其他的Pass,可以在该Pass中单独设置渲染状态。
在SubShader中使用会影响到该SubShader中的所有Pass。

可以设置的渲染状态(这些渲染状态在SubShader中同样被允许使用)

渲染状态 数值 作用
Cull Cull Back | Front | Off 设置多边形的剔除方式,有背面剔除、正面剔除、不剔除,默认为Back
ZTest ZTest(Less | Greater | LEqual | GEqual | Equal | NotEqual | Always) 设置深度测试的对比方式,默认为LEqual
ZWrite ZWrite On | Off 设置是否写入深度缓存,默认为On
Blend Blend sourceBlendMode destBlendMode 设置渲染图像的混合方式
ColorMask ColorMask RGB | A | 0 | 或者R、G、B、A的任意组合 设置颜色通道的写入蒙版,默认蒙版为RGBA,当设置为0时,则无法写入任何颜色。

FallBack
FallBack在所有SubShader之后进行定义。
当所有的SubShader都不能在当前显卡上运行的时候,就会运行Fallback定义的Shader。
语法如下:
Fallback "name"

最常用于Fallback的Shader为Unity为Unity内置的Diffuse。

如果觉得某些Shader肯定可以在目标显卡上运行,没有指定Fallback的必要,可以使用Fallback Off关闭Fall back 功能,或者直接什么都不写。

渲染流水线与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

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