在之前的文章中写的Shader,呈现出来的物体样子是一个平面2D的状态,即使物体是3D的,那是因为,我们还没有将灯光加入到Shader的运算中。现在,我们将介绍灯光相关的东西,最后呈现出和 Unity Diffuse Shader 一样的效果。

什么是光照模型

光照模型,简单理解就是一种运算,或者说一个公式,计算的结果,决定了一个点受到光照时,所表现出来的效果。例如,光照在木板上,和照在一面镜子上,我们所看到的效果是不一样的,照在镜子上,很大一部分光会被镜子反射,而木板,却不会反射那么多光。

进入摄相机的光线分类

在游戏中,我们可以将进入摄相机的光分为 高光反射漫反射自发光等。像上面说的镜子反射了大部分光,就是高光反射,现实中比较光滑的表面,受到光照时,都会产生这种效果,很亮。而光线照在木头上,就是漫反射,其实是木头先吸收了光,然后向周围散射出去,这个就不会很亮。而自发光,就是字面意思,自身是一个发光体。这里大概知道这些词是什么就可以,不必深究里面的原理。

这一篇博客,接下来我们将在Shader中实现一下漫反射。实现漫反射,可以在顶点函数中,这叫做逐顶点光照。也可以在片元函数中实现,这叫做逐片元光照。在顶点函数中实现,也就是对每一个顶点都进行一次光照的计算,而在片元函数中也就是对每一像素执行光照计算,所以,在片元函数中实现相对来说要更耗费一点性能。

在顶点函数中实现漫反射

漫反射的计算公式是 最终颜色=直射光颜色 * max(0, dot(光线,法线)),也就是使用 Directional Light 的颜色 乘 光线发射方向 与顶点法线方向的夹角,dot函数就是点乘,结果就是夹角。有一点要注意的是,dot中的 光线法线 都是单位向量,也就是我们要对其进行标准化。max函数是取最大值,也就是说,如果dot计算出来的结果小于0,那就取0。

看下面的代码,注意看注释,从上往下每一个注释都要看

Shader "iMoeGirl/04-DiffuseVertex" {
    SubShader {
        Pass {
            // 要使用光照,首先要定义一下LightMode,这里我们使用ForwardBase,
            // 这里先不用管意思,只要照着写上就行
            Tags {
                "LightMode" = "ForwardBase"
            }


            CGPROGRAM
                // 这里我们将 Unity 一些预定义的Shader代码包含进来,
                // 里面有我们需要的东西,场景中第一个Directional Light的信息(后面用来做计算)
                #include "Lighting.cginc"
                #pragma vertex vert
                #pragma fragment frag

                // 根据共识,要计算最终顶点的颜色,需要法线数据,所以这里将法线从Application传到顶点处理函数中
                struct a2v {
                    float4 vertex: POSITION;
                    float3 normal: NORMAL;          // NORMAL就是法线语义,之前的文章说过
                };

                struct v2f {
                    float4 position: SV_POSITION;
                    fixed3 color : COLOR;           // 这个颜色就是在顶点函数中计算完的顶点的漫反射颜色,传到片元函数中
                };

                // 把光照的计算放在顶点函数中,所以叫做顶点光照
                v2f vert(a2v v) {
                    // 定义一个数据传送结构体(传送到片元函数中)
                    v2f f;  

                    f.position = UnityObjectToClipPos(v.vertex);

                    // 使用Unity内置变量 _WorldSpaceLightPos0 取到第0个直射光的方向,用于在cos中做计算
                    // 对于平行光来说,光的位置就是光的方向(在世界空间)
                    fixed3 lightDir = normalize(_WorldSpaceLightPos0.xyz);

                    // 将法线转换到世界空间,然后标准化
                    // 因为灯光是在世界空间,所以后面的dot需要在一个空间中做计算
                    // 也可以将灯光转换到模型空间,只要保证在一个空间中即可
                    fixed3 normalInWorld = normalize(UnityObjectToWorldNormal(v.normal));

                    // 漫反射 = 真射光颜色 * max(0, dot(光的方向,法线))
                    // 使用Unity内置变量 _LightColor0.rgb 取到直射光的颜色
                    float3 diffuse = _LightColor0.rgb * max(0, dot(lightDir, normalInWorld));

                    // 将计算好的颜色传到片元函数中
                    f.color = diffuse;
                    return f;
                }

                float4 frag(v2f f) : SV_TARGET {
                    // 使用顶点函数中计算出来的颜色值的 rgb 作为最终颜色的返回值,alpha通道取1。
                    return fixed4(f.color, 1);
                }

            ENDCG
        }
    }

    Fallback "VertexLit"
}

以上就是在顶点函数中计算最终的颜色,只要知道了光照模型,也就是知道了计算公式,就可以实现出来。简单来理解一下,就是当光照在一个点上时,这个点应该是什么颜色,要计算这个最终的颜色,有一个公式,只要把公式需要的参数都套进去,那最终的颜色就出来了。

在片元函数中实现漫反射

我们在上面的顶点函数中实现了漫反射的计算,因为顶点的数量远远小于像素的数据,所以在顶点中计算光照的颜色,会有一个问题,就是明暗交接出很不平滑,所以接下来我们将漫反射的计算放到片元函数中,然后再对比一下结果。

Shader "iMoeGirl/06-DiffuseFragment" {

    SubShader {
        Pass {

            Tags {
                "LightMode" = "ForwardBase"
            }


            CGPROGRAM
                #include "Lighting.cginc"  // 取得第一个直射光的颜色 _LightColor0(Unity内置变量)
                #pragma vertex vert
                #pragma fragment frag

                struct a2v {
                    float4 vertex: POSITION;
                    float3 normal: NORMAL;
                };

                struct v2f {
                    float4 position: SV_POSITION;

                    // 因为我们需要在片元函数中计算漫反射,所以需要法线的值,
                    // 也就是需要从法线值顶点函数中传到片元函数中,这里使用COLOR语义描述
                    fixed3 worldNormalDir : COLOR;
                };

                // 把光照的计算放在顶点函数中,所以叫做顶点光照
                v2f vert(a2v v) {
                    v2f f;
                    f.position = UnityObjectToClipPos(v.vertex);

                    // 将法线转换到世界空间,然后标准化
                    fixed3 normalInWorld = normalize(UnityObjectToWorldNormal(v.normal));
                    
                    // 将标准化后的法线值传到片元函数中
                    f.worldNormalDir = normalInWorld;
                    
                    return f;
                }

                float4 frag(v2f f) : SV_TARGET {
                    // 对于平行光来说,光的位置就是光的方向(在世界空间)
                    fixed3 lightDir = normalize(_WorldSpaceLightPos0.xyz);

                    // 取到标准化后的法线值
                    fixed3 normalInWorld = f.worldNormalDir;

                    // 漫反射 = 真射光颜色 * max(0, dot(光和法线的夹角))
                    float3 diffuse = _LightColor0.rgb * max(0, dot(lightDir, normalInWorld));

                    fixed3 color = diffuse;

                    return fixed4(color, 1);
                }

            ENDCG
        }
    }

    Fallback "VertexLit"
}

好了,上面就是在片元函数中做漫反射的计算,我们来看一下 逐顶点光照逐片元光照 的区别

p002801_vertex-fragment-light

左边是在顶点函数中计算漫反射,右边是在片元函数中计算漫反射,可以清楚地看出,在顶点函数中计算时,明暗交界处很不平滑,而在片元函数中计算时,明暗交界就很平滑。

因为最终呈现在屏幕上的颜色,是使用插值来填充的,也就是如果左边一个顶点是红色,右边一个顶点是黄色,那两个顶点中间的颜色就是红和黄的融合。顶点数量比像素数量少很多,所以插值的结果也就没有在片元函数中计算的结果平滑。

下一接我们将继续写与灯光相关的 Shader。