【转载】Shader学习 (12)使用 Unity 和 UE4 实现三个经典光照模型

763 阅读12分钟

原文链接 作者:九猫

文中内容主要参考书籍《unity shader入门精要》,作者为冯乐乐。

本文中将会实现三个经典着色模型:LambertPhongBlinn。这些经典的光照模型在大部分早期建模软件中都出现过,其中有一些软件中至今还存在,现在一看到这些光照模型的材质球效果就可以想起那些古旧的电脑界面和 Low 爆的画面效果以及风扇嘶吼着的破电脑。这些光照模型效果简陋,但是写 Shader 最应该先理解清楚它们的原理,这些思路在以后的工作中经常需要用到,而且以后的基于物理的看起来高大上的光照模型也都有这些经典光照模型的原理来支持。

需要注意的是,这里的光照模型都是经验模型,在物理上并不一定是正确的。

之前写过,我认为学习一个知识,如果能把这个知识用结构图的方式画出来,会更深刻的理解它的原理和各知识点互相之间的关系。UE4 的可视化节点材质编辑器,简称连连看,给展示材质的结构图提供了一个非常好用的工具,所以在本文中我就分别使用 UnityShaderUE4 材质连连看实现同一个效果。

image.png

我学习材质的时候发现 UE4 的连连看是真的好用,非常直观且浅显易懂,可以实时地查看效果,比敲代码爽很多。尤其是想要换一个光照模式贼几儿方便,不需要修改很多代码,只需要切换几个选项。可以节省你很多敲键盘的时间,用来思考实现效果的方法。

一、一些基础的概念和定义

光源:平行光、环境光、点光源、聚光灯以及其他一些体积光照

最为广泛使用的就是前四者

  • 平行光:模拟从无限远处发出的光线,这个光源投射在一个平面上的各点的强度一定是相同的。这种光源一般用来模拟阳光。
  • 环境光:在空间中均匀散布的无方向的或者说所有方向的光线,在所有方向上和所有物体上投射的环境光的强度都是统一的恒定值。
  • 点光源:由一个点在空间中均匀的向各个方向散发光线,就像是一个灯泡。
  • 聚光灯:就是用来模拟聚光灯,像是手电筒或者舞台的射灯。

物体表面属性:漫反射、高光反射

  • 漫反射:完全粗糙的表面向着各个方向均匀的反射光线,可以参考石膏体的效果。
  • 高光反射:一个光滑的物体被光照射的时候,会在某个方向上反射出很强的反射光,形成高光点。

二、lambert 光照模型

如果一个物体的表面可以产生 光的漫反射 现象,那就称它为兰伯特反射体,即它的表面在所有方向上都具有等量的反射光线。

虽然它向所有方向反射等量光线,但是入射光线的强度决定了它的表面可以反射多少光线出来,物体表面与入射光线方向越垂直,则接受到的光线越多,表面光强也就越强。这种现象可以由 lambert 定律进行模拟。

Lambert 定律:当方向光照射到物体上时,漫反射光强与 入射光的方向入射点表面法线向量夹角的余弦 成正比。

上图中 n 为法线向量,L 为光照方向的反方向,随着夹角 θ 越来越大,平面 dA 上接受到的光线会越来越少。也可以理解为归一化的光线向量投影在归一化法线向量上的长度越来越小。

在 shader 的计算中,兰伯特的计算如下:f(θ) = max(cosθ,0) = max(L•n,0)

再加上物体本身颜色和光线强度的影响,就有了 f(θ) =(C_light * M_diffuse)max(L•n,0)

C_light 代表光线的强度和颜色,M_diffuse 代表物体表面的颜色。

使用 UnityShader 实现 lambert 光照模型

Unity Shader 可以把主要的运算过程放在顶点着色器内,也可以放在片元着色器内。逐片元计算的光照模型看起来过渡更加丝滑,但是运行效率也更低。这里可以分别列举出来。

逐顶点光照模型

// 这个 Shader 的名字为: Article12/LembertVertexShader
Shader "Article12/LembertVertexShader"
{
    // 首先在 Properties语 义当中声明一个属性 Diffuse
    Properties{
        _Diffuse("Diffuse", Color) = (1, 1, 1, 1)}
    // 在 SubShader当中定义一个 Pass语 义块,因为顶点、片元着色器需要写在 Pass 语义块当中。
    SubShader
    {
        Pass
        {
            // 下面一行代码指明了该 Pass 的光照模式。
            Tags{"LightMode" = "ForwardBase"}
            // 开始定义
            CGPROGRAM

            // 首先我们定义了一个名为 vert 的顶点着色器,然后是一个名为 frag 的片元着色器
            #pragma vertex vert
            #pragma fragment frag
            // 为了使用 Unity 的一些内置变量,需要包含进 Unity 的内置文件 “Lighting.cginc”
            #include "Lighting.cginc"
            // 为了使用在 Properties 语义中定义的属性,我们需要定义一个和该属性类型相匹配的变量。
            fixed4 _Diffuse;

            //定义顶点着色器的输入和输出结构
            struct a2v
            {
                float4 vertex : POSITION;
                float3 normal : NORMAL;
            };

            struct v2f
            {
                float4 pos : SV_POSITION;
                fixed3 color : COLOR;
            };

            // 顶点着色器代码,实现一个逐顶点的漫反射模型。
            v2f vert(a2v v)
            {
                v2f o;
                o.pos = UnityObjectToClipPos(v.vertex);

                fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
                fixed3 worldNormal = normalize(mul(v.normal, (float3x3)unity_WorldToObject));
                fixed3 worldLight = normalize(_WorldSpaceLightPos0.xyz);
                fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * max(dot(worldNormal, worldLight), 0);

                o.color = ambient + diffuse;
                return o;
            }

            //定义片元着色器的输出结构
            fixed4 frag(v2f i) : SV_Target
            {
                return fixed4(i.color.rgb, 1.0);
            }

            ENDCG
        }
    }
    Fallback "Diffuse"
}

逐像素光照模型

//定义 Shader 的名字
Shader "Unity Shaders Book/Chapter 6/DiffusePixelLevel"
{
    // 首先声明一个 color 类型的属性
    Properties{
        _Diffuse("Diffuse", Color) = (1, 1, 1, 1)}
    // 定义一个 Pass 语义块,顶点和片元着色器的代码需要写在 Pass 语义块当中
    SubShader
    {
        Pass
        {
            Tags{"LightMode" = "ForwardBase"}
            // 然后开始 Cg
            CGPROGRAM

            #pragma vertex vert
            #pragma fragment frag
            // 引用内置变量
            #include "Lighting.cginc"
            // 定义一个和 Properties 语义中声明的属性相匹配的变量
            fixed4 _Diffuse;

            // 接下来定义顶点着色器的输入和输出结构
            struct a2v
            {
                float4 vertex : POSITION;
                float3 normal : NORMAL;
            };

            struct v2f
            {
                float4 pos : SV_POSITION;
                fixed3 worldNormal : TEXCOORD0;
            };
            // 接下来是顶点着色器,所有漫反射部分都在这里进行
            v2f vert(a2v v)
            {
                //首先定义返回值o
                v2f o;
                // 转换顶点信息,从模型空间到投影空间
                o.pos = UnityObjectToClipPos(v.vertex);
                // 把法线信息从模型空间转换到世界空间
                o.worldNormal = mul(v.normal, (float3x3)unity_WorldToObject);
                return o;
            }
            // 定义片元着色器,在片元着色器当中需要计算漫反射光照
            fixed4 frag(v2f i) : SV_Target
            {
                // 首先获取环境光
                fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
                // 获取世界空间法线信息
                fixed3 worldNormal = normalize(i.worldNormal);
                // 获取世界空间下的光照方向
                fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);
                // 计算光照信息
                fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * saturate(dot(worldNormal, worldLightDir));
                fixed3 color = ambient + diffuse;
                return fixed4(color, 1.0);
            }
            ENDCG
        }
    }
    Fallback "Diffuse"
}

可以看到,逐像素计算的结果丝滑就主要丝滑在明暗交界线的地方。

使用 UE4 实现 lambert 光照模型

在 UE4 的蓝图中实现这个 Shader 的时候和在 Unity 是一个思路,只是实现方式换成了节点连线而已。预设材质的着色模型是无光材质。

可能有人要问 UE4 有那么完善的预设材质我为啥要连这么个玩意,有时候还真有用,比如做一些特效使用的特殊材质,又或者在一些特殊场景中需要用到风格化的渲染效果,或者可以单独控制材质每个灰度部分混合自定义颜色,这些都可以用到上面的连线。

比如可以实现一个下面这样的小效果。

UE14.gif

三、Phong 光照模型

Phong 光照模型就是指带有 高光反射(或称为镜面反射)的光照模型,又到了美术同学最喜欢的点高光环节。

高光反射:当一个表面光滑的物体被光线照射时,可以在物体的某个区域看到很强的反射光,一般这个区域是在靠近光源的地方。这是因为这个区域最接近镜面反射角,这里会反射入射光的绝大部分光强。

在 Shader 的计算中,高光反射的计算公式如下:

specular= pow (max (0, (dot (reflectDir , viewDir))), _Gloss)

计算公式中的 reflectDir 指的是反射方向,viewDir 指的是视角方向,对二者进行点积运算可以得到高光反射的基础强度,为了防止结果为负数还需要和 0 进行 Max 操作,取更大值。而不同的表面光滑度也是不同的,当一个物体表面越光滑的时候,反射光线越集中,且亮度更高,物体表面越粗糙的时候,反射光线越分散,亮度更低。因此需要使用 _Gloss 控制物体表面的光滑值。

事实上,兰伯特光照模型表现的就是一个绝对粗糙的物体收光的效果, Phong 光照模型基于兰伯特模型,给兰伯特模型点了个高光

上面式子中提到的 reflectDir 反射方向是由以下公式计算得出的:

reflectDir = lightDir - 2 * dot (normal, lightDir) * normal

计算 viewDir 视角方向只需要把摄像机的位置减去物体表面一点的位置,就得到了视角向量。

使用 UnityShader 实现 Phong 光照模型

Shader "Unity Shaders Book/Chapter 6/Specular Pixel Level"
{
    // 首先声明属性
    Properties{
        _Diffuse("Diffuse", Color) = (1, 1, 1, 1)
        _Specular("Specular", Color) = (1, 1, 1, 1)
        _Gloss("Gloss", Range(8.0, 256)) = 20}

    SubShader
    {
        Pass
        {
            // 设置渲染模式为前向渲染
            Tags{"LightMode" = "ForwardBase"}

            //开始CG
            CGPROGRAM

            // 定义顶点着色器和片元着色器的名字
            #pragma vertex vert
            #pragma fragment frag

            // 包含进内置变量
            #include "Lighting.cginc"

            // 定义和属性类型相匹配的变量
            fixed4 _Diffuse;
            fixed4 _Specular;
            float _Gloss;

            // 定义顶点着色器的输入输出结构体
            struct a2v
            {
                float4 vertex : POSITION;
                float3 normal : NORMAL;
            };

            struct v2f
            {
                float4 pos : SV_POSITION;
                float3 worldNormal : TEXCOORD0;
                float3 worldPos : TEXCOORD1;
            };

            // 顶点着色器当中的计算

            v2f vert(a2v v)
            {
                v2f o;
                // 转换顶点空间:模型=>投影
                o.pos = UnityObjectToClipPos(v.vertex);

                // 转换法线空间:模型=>世界
                o.worldNormal = mul(v.normal, (float3x3)unity_WorldToObject);

                // 转换顶点空间:模型=>世界
                o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;

                return o;
            }

            // 片元着色器中的计算
            fixed4 frag(v2f i) : SV_Target
            {
                // 获取环境光
                fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;

                // 获取世界空间法线
                fixed3 worldNormal = normalize(i.worldNormal);
                // 获取世界空间光照方向
                fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);

                // 计算漫反射
                fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * saturate(dot(worldNormal, worldLightDir));

                // 获取世界空间反射方向
                fixed3 reflectDir = normalize(reflect(-worldLightDir, worldNormal));
                //"reflect()" 函数是 Unity 内置的函数,可以用来计算反射方向,如果不使用这个函数则上一句代码可以改成下面这样:
                //fixed3 reflectDir = normalize(-worldLightDir - 2 * dot(-worldNormal, worldLightDir) * worldNormal);

                //获取世界空间中的视角方向
                fixed3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos.xyz);
                //计算反射信息
                fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(saturate(dot(reflectDir, viewDir)), _Gloss);

                return fixed4(ambient + diffuse + specular, 1.0);
            }

            ENDCG
        }
    }

    FallBack "Specular"
}

使用 UE4 实现 Phong 光照模型

image.png

四、Blinn-Phong 光照模型

Blinn 光照模型的渲染效果比 Phong 模型的 高光更加柔和,更加平滑,而且它的渲染速度足够快,因此很多的建模软件中都会使用该模型作为默认光照模型。

在 Blinn 光照模型中没有使用反射方向,而是使用了一个新的矢量 half,它的计算方式是:

half = normalize(worldLightDir + viewDir)

新的反射计算公式是:

specular =pow(max(0, dot(worldNormal, halfDir)), Gloss)

Blinn 光照模型甚至比 Phong 光照模型都更加的 “不物理”,早期的很多渲染软件使用它的原因之一是,它比 Phong 少一个点积运算,而且看上去还不错。其实图形学最重要的定律之一就是:只要它看上去是对的,它就是对的。

使用 UnityShader 实现 Blinn-Phong 光照模型

Shader "Unity Shaders Book/Chapter 6/Specular BlinnPhong Level"
{
    // 首先要声明三个属性
    Properties{
        _Diffuse("Diffuse", Color) = (1, 1, 1, 1)
        _Specular("Specular", Color) = (1, 1, 1, 1)
        _Gloss("Gloss", Range(8.0, 256)) = 20}

    //定义一个Pass语义块
    SubShader
    {
        Pass
        {
            // 声明该 Pass 的光照模式
            Tags{"LightMode" = "ForwardBase"}

            // 开始 CG 代码
            CGPROGRAM

            #pragma vertex vert
            #pragma fragment frag

            //包含进 Unity 内置变量
            #include "Lighting.cginc"

            //定义和声明的属性类型相匹配的变量
            fixed4 _Diffuse;
            fixed4 _Specular;
            float _Gloss;

            // 定义顶点着色器的输入输出结构体
            struct a2v
            {
                float4 vertex : POSITION;
                float3 normal : NORMAL;
                float4 texcoord : TEXCOORD0;
            };

            struct v2f
            {
                float4 pos : SV_POSITION;
                float3 worldNormal : TEXCOORD0;
                float3 worldPos : TEXCOORD1;
                float2 uv : TEXCOORD2;
            };

            // 顶点着色器当中的计算
            v2f vert(a2v v)
            {
                v2f o;
                // 转换顶点空间:模型=>世界
                o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
                // 转换顶点空间:模型=>裁剪
                o.pos = UnityObjectToClipPos(v.vertex);

                // 转换法线空间:模型=>世界
                o.worldNormal = mul(v.normal, (float3x3)unity_WorldToObject);

                return o;
            }

            // 片元着色器中的计算
            fixed4 frag(v2f i) : SV_Target
            {
                // 获取环境光
                fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;

                // 获取法线信息
                fixed3 worldNormal = normalize(i.worldNormal);

                // 获取光线方向
                fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);

                // 计算漫反射信息
                fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * saturate(dot(worldNormal, worldLightDir));

                // 获取世界空间视角方向
                fixed3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos.xyz);

                // 计算 Half 方向
                fixed3 halfDir = normalize(worldLightDir + viewDir);

                // 计算高光信息
                fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0, dot(worldNormal, halfDir)), _Gloss);

                return fixed4(ambient + diffuse + specular, 1.0);
            }

            ENDCG
        }
    }
    FallBack "Specular"
}

使用 UE4 实现 Blinn 光照模型

image.png

我个人认为做一个学习笔记的目的并不在于笔记本身,而是在于去拔树寻根,把知识化成体系在脑中永久保存。因此若是不能把文字揉碎了再组装起来,就无法知道哪些东西是必须记住的,哪些是必须掌握的,而哪些是无关紧要的

我学软件的时候喜欢找的教程不是那些做一个看起来非常高大上的效果的教程,而是喜欢找那些冗长但是不厌其烦的讲每一个按钮的作用和性质的教程,虽然枯燥,但是看完之后再看那些高大上的效果就可以一眼明白它是怎么做出来的,用到了哪些东西。我喜欢把每个概念,理解过程,甚至计算过程都列出来,所以文章会有些枯燥,但自己记得会更深入一点。

希望各位大佬多多指教。