【译】Unity3D Shader 新手教程(5/6) —— Bumped Diffuse Shader

浪子不回头ぞ 提交于 2020-02-08 09:38:50

本文为翻译,附上原文链接

转载请注明出处——polobymulberry-博客园

动机


如果你满足以下条件,我建议你阅读这篇教程:

  • 你想学习片段着色器(Fragment Shader)。
  • 你想实现复杂的多通道着色器(multipass),但是对其不是很了解。
  • 你想使用上面提到的两种技术(片段着色器和多Pass)来实现描边效果的Toon shader,你就需要理解这两种技术的概念。

学习资源


引论


在教程的第4部分,我们创建了一个相当好的toon shader,该shader使用了边缘光照(rim lighting)对模型进行描边 — 但是这种方法的问题在于它只能使用在表面光滑的模型上。对于那些平整的,有棱角的表面,我们计算其边缘的法向量时,会发现两个面的边界法向会产生突变(比如正方形的六个面,单个面上的法向都一致,但是两个面之间的法向会发生突变,不像球面上的法向是渐变的),这将产生我们不想要的边界效果。

有一个更好的办法对模型进行描边效果处理 — 先将模型背面稍微扩大一些(边缘延伸一些)然后渲染为全黑,最后正常渲染模型正面即可,这样我们看到的黑色边缘其实是背面呈现的颜色。这就要求在shader中使用两个Pass — 你可能还记得,我们在表面着色器中是无法使用Pass的,表面着色器必须放在SubShader语句块中,而非Pass语句块中,虽然表面着色器会在编译阶段将自身代码编译为multiple passes。

所以如果你想使用这种描边方式,你必须使用(片段着色器)fragment shader — 这并没有什么不好的地方,因为使用该shader可以让我们了解到shader是如何真正工作的,并且使我们能实现在表面着色器中实现不了的效果。

实现片段着色器(fragment shader)的问题在于之前表面着色器自动帮我们完成了很多工作 — 比如光照处理。所以我们必须了解表面着色器自动为我们做了哪些事情,以便使模型渲染出相当好的效果。

这一部分的教程内容讲解的是如何实现一个bumped diffuse shader,并为我们实现更加复杂的描边toon shader打下基础。

顶点着色器&片段着色器


我们将开始实现顶点和片段着色器,这两个着色器不同于之前使用的表面着色器。要实现这两个着色器,我们得在shader内添加两个函数,一个函数用来从模型中获取顶点,然后你可以对这些顶点进行些操作,最后将这些顶点映射到屏幕坐标系上(光栅化,即形成对应像素点),另一个函数用来为每个像素提供颜色值。

Image

从上图看出,相比于表面着色器,我们只需关心两件事,但是需要我们自己手动去实现的内容却变多了。

我们顶点程序(vertex program)主要是根据模型的原始数据(比如模型在局部坐标系下的顶点数据)将模型的每个顶点都转化到屏幕坐标系上。同时该程序也会默认输出顶点的UV坐标以及其他数据,以传递给片段程序(fragment program)使用。

随后我们根据渲染到屏幕上的顶点信息来对我们从顶点程序得到的输出值进行插值(比如我只给了一条线段的两个端点颜色,就需要插值求出这条线段上所有像素点的颜色),接着我们调用片段程序处理这些值。

我们首先从处理数据的单个Pass开始,实现一个简单的diffuse shader和diffuse bumped shader — 等会我们再学习多通道技术(multiple passes)。如果我们使用多通道(multiple passes),并且对于其中的每个Pass,我们都有一个不同的顶点程序和片段程序,那么对不同的Pass我们可以传递不同的数据进行处理。

A Diffuse, Vertex Lit Fragment Shader


现在让我们实现第一个片段着色器(fragment shader)。我们可以在Subshader代码区中使用关键字Pass来定义一个通道(pass)。

可以为每个Pass定义很多的标签,这些标签表明在Pass处理的这个阶段将使用哪些属性进行渲染 — 比如我们可以通过控制是否写入深度缓存(Z buffer)来剔除模型的背面或前面。我们将要实现的这个diffuse shader将使用背面剔除(此处的背面指的是背向照相机的部分)。

记住每次写入像素值时都会向深度缓存(Z-buffer)写入一个深度值,该值表示像素上的内容实际与相机的距离(比如该像素上存的内容是一个球体上的某一点,深度值就是该点离相机的距离)— 如果打开深度缓存,我们就可以根据此深度值决定同样映射到此处的像素值是否可以写入到帧缓存中并绘制到屏幕上(最后产生的效果就是离相机越近的像素只会被写入到帧缓存,并绘制出来)。如果关闭深度缓存,那么像素是否写入帧缓存就不依赖距离相机的远近。

我们通过ZWrite On|Off来打开和关闭写入Z buffer的功能。正常情况下,如果之前已经有其他的shader渲染了一个更近的物体到此像素,那么其他离得远的物体将不会渲染到此像素 — 当然你可以使用ZTest命令来改变深度测试的结果,也就是说不一定离相机近的物体渲染出的像素非得覆盖离相机远的物体渲染出的像素。ZTest Less | Greater | LEqual | NotEqual | Always将决定写入像素的方式。

(注:将3D模型光栅化后(即将3D模型映射到2D的帧缓存上,形象点就是把3D模型拍扁到2D平面上),你可能会想到这样的话,2D帧缓存的某个像素对应到拍扁之前3D模型上的点时,可能对应的点不止1个,那怎么决定帧缓存最终存储到底哪个3D模型点了,这时候就会用到Z buffer,即深度缓存。我们根据对应3D模型点的深度值决定最终显示的值,而决定的方式就靠ZTest参数了,比如如果使用Less,那么比当前存储的像素值的深度值小的话,就覆盖该像素值,再比如Always就是不管深度值多少,都覆盖前面的像素值,此时可以想象最后绘制的像素就是最终显示的像素颜色)。

开始我们Pass的定义:

Pass {
 
    Cull Back
    Lighting On
 
    CGPROGRAM
    #pragma vertex vert
    #pragma fragment frag
 
    #include "UnityCG.cginc"
 
    // More code here
 
}

上面我们定义了Pass,并告诉该pass,我们打开光照效果并且使用背面剔除。然后我们制定了CG程序的起始标志 — CGPROGRAM,同时指定了顶点程序(vertex program)和片段程序(fragment program)的名称分别为vert和frag。最后我们使用包含了Unity shader中常用全局变量和帮助函数的文件 — UnityCG.cginc。

逐顶点光照?


我们有下面的两种方式实现光照shader — 第一种方法:我们只定义一个Pass,每个顶点都使用该Pass来处理所有光源对其的影响。第二种方法:我们可以为场景中的每个光源设置一个Pass,这样就得使用多通道(multipass)。

很明显,前者处理速度更快并且只要一个pass,然而后者计算的结果更正确。

如果我们写了一个逐顶点光照shader,那我们就得考虑所有的光源以及这些光源在每个顶点上产生的效果。如果我们定义了一个multipass shader,那么对于场景中的每个光源,都会调用一次该shader。

Unity有一个特殊的写入函数,在下一节,我们将看到该函数如何帮助我们实现顶点光照。


顶点程序(Vertex Program)

下面我们自定义vertex函数 — 首先我们需要得到模型的信息 — 我们定义了一个结构体来存储这些信息:

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

该结构体依赖语义 — 比如上面代码中的POSITION,NORMAL,TEXCOORD0(第一个纹UV坐标集)。而对应变量的名称却不是很重要(比如vertex, normal, texcoord)。上面结构体中表明我们想要得到模型局部坐标系下的顶点位置(POSITION)以及法向的方向(NORMAL),并且我们将从uv1得到纹理坐标(TEXCOORD0)。该结构体对于我们的shader已经够用了。


在片段着色器中,spaces(空间)的概念很重要。空间的概念其实就是你描述一个模型的坐标时,所使用的坐标系。

  • 模型空间(model space) — 其坐标系和模型本身的坐标系是一致的(模型本身的坐标系就是你导出模型时所使用的坐标系,每个模型都有其自身的坐标系) 。我们的vertex函数需要转换这些坐标到projection space,其实就是视空间(View)经过投影变换(Projection)后的空间。
  • 切线空间(tangent space) — 该坐标系是相对于模型每个面片来说的 — 我们在bump map中将使用切线空间,稍后我们会详细介绍。
  • 世界空间(world space) — 世界坐标系,将所有模型统一到一个坐标系下进行统一的变换。
  • 投影空间(projection space) — 相对于相机的坐标系(即以相机为原点的坐标系)。

如果你看一些shader方面的资料,你就会经常看到有关光照在各种坐标系之间的转换。一开始确实很难理解。为了产生最终的光照颜色,你必须在各种坐标系中转换你的光照方向和位置,为这些都是基础的东西。不过在这篇教程的结尾,你将有能力驾驭这些坐标转换。


从模型的网格中获得的位置,法向,uv坐标信息作为你顶点程序的输入(上面的a2v结构体) — 当然,我们还需知道输出什么样的结果。记住我们输出的结果在vertex函数中首先会根据正在渲染的像素进行插值,而这些插值的结果会作为输入传递给fragment shader。

struct v2f
    {
        float4 pos : POSITION;
        float2 uv : TEXCOORD0;
        float3 color : TEXCOORD1;
    };

v2f结构体就是我们在vertex函数中的所要返回的值。此处的语义信息不是很重要 — 我们必须返回一个POSITION的值pos,该值是将顶点从model space转变到projection space的结果。上面v2f结构体输出的值(它们不能使用uniform关键字)首先进行插值,然后才会传入给fragment函数。事实上,在我们的shader中这些变量值是保持不变的 — 就像之前在表面着色器的示例中一样。

我们应该尽可能将计算放在vertex函数中进行计算,因为该函数是是对每个顶点调用一次,而fragment函数是对每个像素调用一次。(这里的意思可能是指此场景的顶点个数比像素个数少,或者是单个像素处理比单个顶点处理更耗时,因为这里有一个空间转换的过程

知道这些已经足够了。下面是vertex函数 — 其作用将输入结构体a2v转化为fragment函数的输入结构体v2f。

v2f vert (a2v v)
{
    v2f o;
    o.pos = mul( UNITY_MATRIX_MVP, v.vertex);
    o.uv = TRANSFORM_TEX (v.texcoord, _MainTex); 
    o.color = ShadeVertexLights(v.vertex, v.normal);
    return o;
}

上述代码中,我们首先定义了一个输出结构体v2f o。然后我们使用Unity预定义(通过包含UnityCg.cginc文件)的一个矩阵乘上模型空间下的顶点v.vertex,将顶点坐标从模型空间转变到投影空间。上面那个矩阵指的就是UNITY_MATRIX_MVP(MVP表示ModelViewProjection,即模型视图投影矩阵)。我们使用mul函数来进行矩阵与点的乘法。

接下来的一行代码就是根据每个顶点的v.texcoord值和指定的纹理资源_MainTex,来获得对应的uv坐标 — 换句话说,计算出的uv其实该模型网格上的点对应到纹理上的坐标值(可能会有人问,直接使用o.uv=v.texcoord.xy不就行了,但是有些情况下,材质的tiling和offset不是默认值(默认值为tiling(1,1)和offset(0,0)),那么就不能使用o.uv=v.texcoord.xy,得使用o.uv = v.texcoord.xy * _MainTex_ST.xy + _MainTex_ST.zw;其中_MainTex_ST.xy为tiling,_MainTex_ST.zw为offset。)我们使用了UnityCG.cginc中的一个内建的宏TRANSFORM_TEX,作用其实就是上述的计算uv的公式。

// Transforms 2D UV by scale/bias property
#define TRANSFORM_TEX(tex,name) (tex.xy * name##_ST.xy + name##_ST.zw)

image

注意要使用TRANSFORM_TEX宏,你需要在你的shader中定义一些额外的变量。这些变量的类型是float4,变量名是_YourTextureName_ST(比如针对上述例子就是_MainTex_ST)。

最后我们为每个像素计算出了一个基本颜色。我们使用了一个内建函数

float3 ShadeVertexLights (float4 vertex, float3 normal)

该函数通过给定的顶点和法向来计算每个像素受到光照的效果,该函数选择了最近的四个光源以及漫射光来进行计算该像素的颜色。

我们打开Unity.cginc文件可以看到ShadeVertexLights函数的具体定义:

// 在Vertex Pass中使用,也就是说每个顶点将调用一次这个函数: 根据lightCount个光源来计算光照颜色. 将spotLight指定为true将会耗费更多计算时间,如果为false,将会当做点光源进行处理。
float3 ShadeVertexLightsFull (float4 vertex, float3 normal, int lightCount, bool spotLight)
{
    float3 viewpos = mul (UNITY_MATRIX_MV, vertex).xyz;
    float3 viewN = normalize (mul ((float3x3)UNITY_MATRIX_IT_MV, normal));

    float3 lightColor = UNITY_LIGHTMODEL_AMBIENT.xyz;
    for (int i = 0; i < lightCount; i++) {
        float3 toLight = unity_LightPosition[i].xyz - viewpos.xyz * unity_LightPosition[i].w;
        float lengthSq = dot(toLight, toLight);
        toLight *= rsqrt(lengthSq);

        float atten = 1.0 / (1.0 + lengthSq * unity_LightAtten[i].z);
        if (spotLight)
        {
            float rho = max (0, dot(toLight, unity_SpotDirection[i].xyz));
            float spotAtt = (rho - unity_LightAtten[i].x) * unity_LightAtten[i].y;
            atten *= saturate(spotAtt);
        }

        float diff = max (0, dot (viewN, toLight));
        lightColor += unity_LightColor[i].rgb * (diff * atten);
    }
    return lightColor;
}
// 该函数仅仅将上面函数的lightCount指定为4
float3 ShadeVertexLights (float4 vertex, float3 normal)
{
    return ShadeVertexLightsFull (vertex, normal, 4, false);
}

最最后,我们返回输出结构体o,并将输出值传给fragment程序做准备。

切记在fragment函数执行之前,系统会对输出的三角形顶点信息进行插值(因为我们得到的只是三角形的三个顶点,所以要通过插值得到这个三角形的三条边的信息以及整个三角形面的信息),注意三角形顶点在vertex函数中是分3次输出出来的。

Fragment程序


我们现在想根据插值后的输入结构体来计算特定像素的颜色值。

float4 frag(v2f i) : COLOR
{
    float4 c = tex2D (_MainTex, i.uv);
    c.rgb = c.rgb  * i.color * 2;
    return c; 
}

我们的fragment程序首先根据uv坐标和对应纹理_MainTex计算出该像素的颜色值,然后将计算出的颜色值(纹理颜色)与之前vertex函数计算出的颜色值(光照颜色)相乘,并将结果再乘上2(我也不知道为啥还要乘上2,但是不乘上2的话,会显得很暗)。

最后我们返回该像素的颜色值。

源代码


总结上面的所有代码,我们整个diffuse vertex lit shader的代码看起来像下面这样(下载):

Shader "Custom/VertexLitDiffuse" {
    Properties {
        _MainTex ("Base (RGB)", 2D) = "white" {}
    }
    SubShader {
        Tags { "RenderType"="Opaque" }
        LOD 200
        
        Pass{
            Tags { "LightMode" = "Vertex" }  
        
            Cull Back
            Lighting On
            
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"
            
            sampler2D _MainTex;
            float4 _MainTex_ST;
            
            struct a2v
            {
                float4 vertex : POSITION;
                float3 normal : NORMAL;
                float4 texcoord : TEXCOORD0;
            };
            
            struct v2f
            {
                float4 pos : POSITION;
                float2 uv : TEXCOORD0;
                float3 color : TEXCOORD1;
            };
            
            v2f vert ( a2v v )
            {
                v2f o;
                o.pos = mul( UNITY_MATRIX_MVP, v.vertex);
                o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);
                o.color = ShadeVertexLights(v.vertex, v.normal);
                return o;
            }    
            float4 frag(v2f i):COLOR
            {
                float4 c = tex2D(_MainTex, i.uv);
                c.rgb = c.rgb * i.color * 2;
                return c;
            }
            ENDCG
        }
    } 
    FallBack "Diffuse"
}

vertex&fragment shader

上述代码仅仅是利用vertex&fragment shader实现了表面着色器中的diffuse功能,我们可以从上面图片中看出两者没有什么区别,但是代码实现上,vertex&fragment shader代码是diffuse的两倍。就像蜘蛛侠中说的一样 — 能力越大,责任越大!

总结一下,对于vertex和fragment程序,我们的责任就是:

  • 将顶点从模型空间(model space)转换到投影空间(projection space)。
  • 如果有必要的话,根据模型的uv值计算纹理坐标。
  • 计算所有的光照效果。
  • 将计算的光照颜色应用到最终的像素颜色上。

我猜你们肯定会这样挖苦我 —“真棒,这样法向贴图就没啥事了!”(意思就是这一小节没用上法向贴图)

法向贴图


让我们想想看一个法向贴图是如何存储的 — 在Unity中,对于用于法向贴图的纹理,我们必须特意指定该纹理类型为bump。下面解释了为啥要特意指定纹理类型:

因为一个法向贴图:

  • 存储模型上面片的法向值。换句话说,其实就是存储了切线空间下的法向值。(注意bump map中存的法向值是在切线空间下的)
  • 仅仅存储指向模型外的那个法向,
  • 以图片(纹理)的形式存储。
  • 存储时进行压缩。事实上,因为法向贴图中存储的法向都是单位化后的!(它们的长度都为1 — 换句话就是单位向量),法向贴图其实只要存储向量的两个分量,因为另一个分量可以根据单位长度这个条件计算出来。
  • 每个分量以16bit进行存储(两个分量就是32bit,注意到rgba恰好就是32bit)。

当我们应用一个法向贴图,我们需要将法向贴图上的对应像素点的法向值映射到模型对应的三角形面片上,以计算出该面片的法向值。一 开始想到这一点并不是很容易。

虽然所有的法向都已经单位化了,但是为了得到最终的世界空间下的法向量,我们不能简单的将两个法向量(一个是模型面片的法向量,一个是法向贴图上对应像素存储的法向值)相加。因为仅仅相加两个单位向量,得到的只是它们的中分线。

我们可以把在单位圆中很好的看出个结果。

image

所以如果我们想使用bump map。我们应该在切线空间上进行光照处理。换句话说,我们应该将所有与计算光照相关的信息转换到切线空间,然后在进行处理。事实上,我们仍像第一个例子中那样,但是我们将在fragment函数中计算每个像素的光照而非在vertex函数中计算每个顶点的的光照。

我们之所以将光照处理放在切线空间,是因为这样处理简单。我们仅需要将光照信息转化到切线空间,并且进行插值。另一种做法不用将光照转化到切线空间,而是将模型法向转化到世界空间。在世界空间中处理光照,这意味着我们要将每个像素的法向转换到世界空间中,然而现在我们从bump map中读出的是切线空间的法向,这种空间转变将耗费更多时间。

幸运的是转换到切线空间相当简单 — 但是我担心我们可能不使用ShadeVertexLights函数了,必须自己手动实现光照计算函数了(因为我们下面不使用逐顶点光照了)。

给我模型加些光照


好的,我们已经避开了自己手动计算光照 — 通过使用表面着色器或者在逐顶点光照中调用ShadeVertexLights就可以做到,但是我们将使用使用的其实是Forward Lighting(其实就是将光照计算从vertex函数移到fragment函数),虽然看上去很麻烦,实际上很容易的!之前在toon surface shader中我们实现过类似的光照模型。这里我们还是使用Lambert光照,它其实就是该点法向与光照方向进行点乘,然后乘上光强衰减因子,最后乘上一个2。

只要我们可以将所有信息转换到同一坐标系下,那么处理起来就简单了,下图描述这种光照模型的计算方式。

Image

上图的意思大概是说,首先插值得到对应顶点的法向,然后乘上光照方向得带夹角的余弦值。余弦值等于1表示光直射在模型表面,等于0说明法向与光照方向垂直,小于0说明该面为背面,照不到光。

但是这些光源在哪?


Unity按照对我们模型影响程度(比如距离模型的远近,光源的亮度)提供对应光源的位置,颜色和光强衰减因子。

逐顶点光照被定义为3个数组:unity_LightPosition,unity_LightAtten和unity_LightColor。这些数组中以[0]为下标的表示最重要的光源(比如距离模型越近越重要,或者按照亮度来排序)。

当我们写一个multi-pass光照模型(就像接下来我们将要做的)我们每次仅仅处理一个光源 — 考虑到这种情况,Unity定义了一个_WorldSpaceLightPosition0值,我们可以使用该值计算出光源的位置,这里还有一个很有用的函数ObjSpaceLightDir函数,该函数用来计算光源方向。为了得到光源的颜色,我们准备利用在shader中定义一个变量_LightColor0或者在我们的程序中包含”Lighting.cginc”。

uniform float4 _LightColor0; //定义了当前光源的颜色

Diffuse Normal Map Shader – Forward Lighting(非逐顶点光照)


我们准备从shader中移出逐顶点光照,并且为每个光源定义multiple pass。

好的-让我们开始吧。我们为我们的法向贴图添加一个属性值和两个变量(牢记我们需要一个对应的sample2D _XXXX变量 和float4 _XXXX_ST变量)。

现在我们需要为我们的vertex和fragment程序定义一个数据结构

struct a2v
{
    float4 vertex : POSITION;
    float3 normal : NORMAL;
    float4 texcoord : TEXCOORD0;
    float4 tangent : TANGENT;
 
};

我们在结构体中添加了一个:TANGENT。我们将使用它来将光照方向转化到切线空间中。

切线空间的转换


为了将一个顶点从模型空间转换到切线空间,我们需要为该顶点额外定义两个向量。正常情况下,一个顶点有它的位置和法向信息 — 并且切向与法向是互相垂直的,然后根据右手原则(直接用叉乘)定义出它的binormal(即除了法向和切向以外的另一个坐标轴)。

UnityCG.cginc文件中定义了一个TANGENT_SPACE_ROTATION宏,该宏为我们提供了一个矩阵rotation来将模型空间转化到切线空间。

从vertex程序中输出结果到fragment程序中


为了对我们的模型使用光照,我们在vertex函数中计算光源在模型每个顶点的切线空间下的方向。

struct v2f
    {
        float4 pos : POSITION;
        float2 uv : TEXCOORD0;
        float2 uv2 : TEXCOORD1;
        float3 lightDirection : TEXCOORD2; 
    };

lightDirection是插值过后的光照方向。uv2表示bump map的纹理坐标。

该shader仅仅对于平行光源和点光源很有用。我们此处不考虑聚光灯光源。

Vertex函数


v2f vert (a2v v)
    {
        v2f o;
        TANGENT_SPACE_ROTATION;
 
        o.lightDirection = mul(rotation, ObjSpaceLightDir(v.vertex));
        o.pos = mul( UNITY_MATRIX_MVP, v.vertex);
        o.uv = TRANSFORM_TEX (v.texcoord, _MainTex); 
        o.uv2 = TRANSFORM_TEX (v.texcoord, _Bump);
        return o;
    }

在顶点程序中。我们使用TANGENT_SPACE_ROTATION宏去创建我们的矩阵rotation,来进行从模型空间到切线空间的转化。

对于TANGENT_SPACE_ROTATION这个宏,传入的输入结构体变量名称必须为v,并且必须包含一个名称为normal的法向和一个名称为tangent的切向。

我们使用ObjSpaceLightDir(v.vertex)函数来在模型空间中计算光照的方向(此时使用最重要的那个光源),我们之所以计算光照在模型空间的方向,是因为我们有一个从模型空间到切向空间的转换(就是rotation这个矩阵) — 这样我们直接将模型空间的方向与rotation相乘就可以得到切线空间的方向。

最后我们计算出顶点在投影空间的位置(记住这是必须的一步,并且我们将计算出的位置存在输出结构体中的pos : POSITION变量)和我们纹理的uv坐标。

平行光源和点光源


Unity用一个float4类型的变量来存储光源位置 — 换句话说光源位置是由四个值来表示的。可能有人会觉得光源位置用3个值表示xyz不就行了。确实,前3个值表示的是xyz坐标,剩下那个w值的作用是为了区分平行光源和点光源,如果w为1则表示此为点光源,如果w为0则表示平行光源。你可能回想为啥这样设置?

ObjSpaceLightDir函数所做就是将光源位置减去顶点位置,这样就得到光照方向了 — 但是我们应该首先将顶点位置乘上光源的w分量,如果是平行光,w分量为0,得到的结果为0,说明顶点位置全变为(0.0, 0.0, 0.0)了,这样光照方向就是光源本身的位置(return objSpaceLightPos.xyz)。对于点光源,w为1,所以乘上顶点位置后,顶点位置无变化,这样计算的光照方向就是(return objSpaceLightPos.xyz – v.xyz)。

// Computes object space light direction
inline float3 ObjSpaceLightDir( in float4 v )
{
    float3 objSpaceLightPos = mul(_World2Object, _WorldSpaceLightPos0).xyz;
    #ifndef USING_LIGHT_MULTI_COMPILE
        return objSpaceLightPos.xyz - v.xyz * _WorldSpaceLightPos0.w;
    #else
        #ifndef USING_DIRECTIONAL_LIGHT
        return objSpaceLightPos.xyz - v.xyz;
        #else
        return objSpaceLightPos.xyz;
        #endif
    #endif
}

Fragment函数


在fragment函数中,我们从bump纹理中根据uv坐标解压出法向信息,并将这些法向信息作为我们Lambert函数(一种光照模型)中的法向。之所以可以直接用解压出的法向值是因为解压出的法向值就是对应面片在切线空间的法向量,与转换后的光照方向正好都在对应面片的切线空间上,可以直接相乘得到入射角。

float4 frag(v2f i) : COLOR 
    {
        float4 c = tex2D (_MainTex, i.uv); 
        float3 n =  UnpackNormal(tex2D (_Bump, i.uv2));
 
        float3 lightColor = UNITY_LIGHTMODEL_AMBIENT.xyz;
 
        float lengthSq = dot(i.lightDirection, i.lightDirection);
        float atten = 1.0 / (1.0 + lengthSq * unity_LightAtten[0].z);
        //光源的入射夹角的余弦值
        float diff = saturate (dot (n, normalize(i.lightDirection)));  
        lightColor += _LightColor0.rgb * (diff * atten);
        c.rgb = lightColor * c.rgb * 2;
        return c; 
    }

我们首先获得漫射光的颜色(UNITY_LIGHTMODEL_AMBIENT.xyz)。

接下来我们计算真实的光源离我们多远(dot(v,v)的结果其实是v长度大小的平方)。如果光源是平行光,并且光源位置已经单位化,那么计算出来的距离是1(当然这也没啥影响)。最后我们使用光源距离的平方乘上光强来计算光的衰减(光源的光强用unity_LightAtten表示)。

对于平行光,我们计算出的lengthSq是1,所以我们的atten = 1/(1+1*attenuation) — 换句话说,我们将最后的颜色除以了(1+attenuation),这就是为什么我们要将最后的颜色值乘上2的原因,不然颜色会太暗。

注意:此处加上attenuation后貌似不起作用,当点光源拉远后,并没有出现光强变暗的效果,只有点光源拉出作用范围后,会突然出现变暗的效果。所以我建议大家看看这位女神的博客。我的工程中有一个OthersForwarding_It's Right的Shader就是抄袭这个美女的。

上面有一个ShadeVertexLightsFull函数的源码,我们可以看到原文作者计算光照衰减的思路和Unity内置的函数是一致的,仔细观察我们就会发现其实是我们lengthSq乘上的是unity_LightAtten[0].z而不是像ShadeVertexLightsFull函数一样乘上unity_LightAtten[i].x(i表示对应光源)。我猜测此处的点光源不一定是就是第一个光源(下标为0)。

image

image

然后我们使用dot函数将单位化后的光照方向normalize(i.lightDirection)和我们从bump map中得到的法向值n进行点乘得到入射夹角余弦值。将光源的颜色_LightColor0.rgb乘上该余弦值,再乘上atten,将得到光照颜色。(记住我们已经转化光照方向到每个面片的切线空间,也就是和面片法向值在同一切线空间中)。

为了得到光源颜色,我们使用了_LightColor0 — 这需要在我们的shader程序中提前声明(或者包含”Lighting.cginc”文件)。在此shader中我们选择在包含UnityGC.cginc文件后直接定义该变量。

uniform float4 _LightColor0;

完整的源码在这里

在Forward模式下处理多光源


我们已经完成了单个光源的处理 — 但是仅仅只有一个。为了处理更多的光源,我们准备再写一个pass并且开始添加一些标签来告诉系统我们对每个的光源进行什么样的操作。

我们需要2个pass来处理一下情况:

  • 一个pass用来处理我们上面介绍的那个最重要光源。
  • 一个pass用来处理接下来的的光源,并将效果叠加在之前效果之上。

在我们现在这个shader中的Pass添加

Tags { "LightMode" = "ForwardBase" }

该标签是为了告诉系统对最重要的那个光源应该做哪些处理。

然后我们拷贝和粘贴我们之前写的pass作为第二个pass,不过我们将第二个pass的Tags修改为

Tags { "LightMode" = "ForwardAdd" }

并且添加一个命令来告诉shader如何混合颜色

Blend One One

换句话说,就是最终颜色值=1*当前颜色值+1*新颜色值。然后在第二个Fragment shader的第二个Pass中我们移出UNITY_AMBIENT_LIGHT,因为我们已经在第一个pass中处理过了。我们最终的shader拥有两个pass。

  • ForwardBase、ForwardAdd的LightMode只能运行在Camera为Forward、DeferredLighting的渲染模式下
  • ScreenClip
  • ForwardAdd这个Pass需要和ForwardBase一起使用,否则会被Unity忽视掉
  • ForwardBase只对1个有效灯光执行一次
  • ForwardAdd对除了ForwardBase用的那个灯光外的所有有效灯光都执行1次,所以会被执行多次

代码如下:

Shader "Custom/OutlineToonShader" {
    Properties {
        _MainTex ("Base (RGB)", 2D) = "white" {}
        _Bump ("Bump", 2D) = "bump" {}
    }
    SubShader {
        Tags { "RenderType"="Opaque" }
        LOD 200
 
        Pass {
 
            Tags { "LightMode"="ForwardBase" }
            Cull Back
            Lighting On
 
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
 
            #include "UnityCG.cginc"
            uniform float4 _LightColor0;
 
            sampler2D _MainTex;
            sampler2D _Bump;
            float4 _MainTex_ST;
            float4 _Bump_ST;
 
            struct a2v
            {
                float4 vertex : POSITION;
                float3 normal : NORMAL;
                float4 texcoord : TEXCOORD0;
                float4 tangent : TANGENT;
 
            };
 
            struct v2f
            {
                float4 pos : POSITION;
                float2 uv : TEXCOORD0;
                float2 uv2 : TEXCOORD1;
                float3 lightDirection : TEXCOORD2;
 
            };
 
            v2f vert (a2v v)
            {
                v2f o;
                TANGENT_SPACE_ROTATION;
 
                o.lightDirection = mul(rotation, ObjSpaceLightDir(v.vertex));
                o.pos = mul( UNITY_MATRIX_MVP, v.vertex);
                o.uv = TRANSFORM_TEX (v.texcoord, _MainTex); 
                o.uv2 = TRANSFORM_TEX (v.texcoord, _Bump);
                return o;
            }
 
            float4 frag(v2f i) : COLOR 
            {
                float4 c = tex2D (_MainTex, i.uv); 
                float3 n =  UnpackNormal(tex2D (_Bump, i.uv2));
 
                float3 lightColor = UNITY_LIGHTMODEL_AMBIENT.xyz;
 
                float lengthSq = dot(i.lightDirection, i.lightDirection);
                float atten = 1.0 / (1.0 + lengthSq);
                //光源的入射角 
                float diff = saturate (dot (n, normalize(i.lightDirection)));  
                lightColor += _LightColor0.rgb * (diff * atten);
                c.rgb = lightColor * c.rgb * 2;
                return c;
 
            }
 
            ENDCG
        }
 
        Pass {
 
            Cull Back
            Lighting On
            Tags { "LightMode"="ForwardAdd" }
            Blend One One
 
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
 
            #include "UnityCG.cginc"
            uniform float4 _LightColor0;
 
            sampler2D _MainTex;
            sampler2D _Bump;
            float4 _MainTex_ST;
            float4 _Bump_ST;
 
            struct a2v
            {
                float4 vertex : POSITION;
                float3 normal : NORMAL;
                float4 texcoord : TEXCOORD0;
                float4 tangent : TANGENT;
 
            };
 
            struct v2f
            {
                float4 pos : POSITION;
                float2 uv : TEXCOORD0;
                float2 uv2 : TEXCOORD1;
                float3 lightDirection : TEXCOORD2;
            };
 
            v2f vert (a2v v)
            {
                v2f o;
                TANGENT_SPACE_ROTATION;
 
                o.lightDirection = mul(rotation, ObjSpaceLightDir(v.vertex));
                o.pos = mul( UNITY_MATRIX_MVP, v.vertex);
                o.uv = TRANSFORM_TEX (v.texcoord, _MainTex); 
                o.uv2 = TRANSFORM_TEX (v.texcoord, _Bump);
                return o;
            }
 
            float4 frag(v2f i) : COLOR 
            {
                float4 c = tex2D (_MainTex, i.uv); 
                float3 n =  UnpackNormal(tex2D (_Bump, i.uv2));
 
                float3 lightColor = float3(0.0, 0.0, 0.0);
 
                float lengthSq = dot(i.lightDirection, i.lightDirection);
                float atten = 1.0 / (1.0 + lengthSq * unity_LightAtten[0].z);
                //光源的入射角
                   float diff = saturate (dot (n, normalize(i.lightDirection)));  
                lightColor += _LightColor0.rgb * (diff * atten);
                c.rgb = lightColor * c.rgb * 2;
                return c;
 
            }
 
            ENDCG
        }
    }
    FallBack "Diffuse"
}

image

总结


这篇文章只是对vertex和fragment shader的简单介绍。下一部分我们将以此为基础,写一个更好的描边toon shader。

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!