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 绘制的图像完全相加。

因为在第一个

Author

TsingLoo

Posted on

2021-08-24

Updated on

2022-09-12

Licensed under

Comments