Shader入门—4.卡通材质(2.正式制作Toon-Shading)

我们来回顾下如何制作卡通材质,我们用两个Pass来实现,一个Pass用来渲染背部来达到描边的效果,一个Pass用来渲染正面达到颜色着色的效果。这其中需要用到一个指令Cull Front与Cull Back,这两个指令可以告诉GPU我渲染的时候不需要渲染那一部分,所以叫做剔除指令。

首先我们来制作Cull Front部分的渲染,在渲染轮廓线的时候,我们可以进行法线描边,但这时候有个问题,模型的法线的深度有区分,如果不对他进行重置,我们得到的轮廓线会有断线的情况。所以在进行描边之前我们需要进行法线的深度统一化,这一步也很简单,直接进行normali.z=-0.5就可以统一描边,为什么是-的呢,这里我们要再次复习下左手坐标系,

左手坐标系

Z轴代表我们的深度坐标,越远z的值越大,而我们的卡通材质需要我们清晰的看到他的描边防止被正面渲染的部分给遮挡住所以进行统一的-0.5的定值。所以我们正式来编写描边的Pass。

Shader"Custom/Toon-Shading"{
    Properties{
        _Outline("Outline Size",Range(0,1))=0.5
        _OutLineColor("OutLine Color",Color)=(1,1,1,1)
        _Color("Color Tint",Color)=(1,1,1,1)
    }
    Subshader{
        Pass{
            NAME "OUTLINE"
            Cull Front
            CGPROGRAM
            
            
            #pragma vertex vert
            #pragma fragmengt frag
            #include "Lighting.cginc"
            #include "UnityCG.cginc"
            struct a2v{
                float4 vertex:POSITION;
                float3 normal:NORMAL;
            };
            struct v2f{
                float4 pos:SV_POSITION;
            };
            v2f vert(a2v v){
                v2f o;
                float4 pos=UnityObjectToClipPos(v.vertex);
                float3 normal=mul((float3x3)UNITY_MATRIX_IT_MV,v.normal);
                normal.z=-0.5;
                pos=pos+float4(normalize(normal),0)*_Outline;
                o.pos=mul(UNITY_MATRIX_P,pos);
                return o;
            }
            fixed4 frag(v2f i):SV_Target{
                    return float4(_OutLineColor.rgb,1);
            }
            ENDCG
        }
    }
    Fallback "Diffuse"
}

上述代码中,我标亮的地方需要特别注意下,首先是Cull Front,前文已经说明了,这部分Pass我们专门给她命名成OUTLINE,以后如果写其他这种需要描边的效果可以直接使用UsePass”Toon-Shading/OUTLINE”,注意下,unity中会把有名称的Pass的名称转成全部大写,所以我们在命名的时候直接命名成大写,这样就不太会忘记这种小细节。

然后float4 这个定义,这里有一个豆知识,明明三个数字就能表达点的坐标,为什么要用float4来定义,一个原因是我们在变换的时候用的矩阵是4X4的矩阵,所以为了达到转换效果西药将他们转换到一个齐次坐标的空间进行转化。这个是转换时的意义,还有就是当(x,y,z,1)时代表一个点,而(x,y,z,0)时代表一个向量。所以我们在将顶点位置向外延申的时候除了要normalize还需要将他补齐为一个4X4的向量。

接下来我们来实现真正关键的光照模型所在Pass。在这我们再次回顾一下Blinn-Phong模型,

Specular=Clight*Mdiffuse*max(0,n·halfDir)^_gloss

写成代码就是

fixed4 specular=_LightColor0.rgb*_Diffuse*pow(max(0,dot(normal,halfDir)),_Gloss);

其中halfDir相信大家还没有忘记,就是viewDir+lightDir也就是视角向量+入射光线向量,其中视角向量可以用_WorldSpaceCameraPos0.xyz-UnityObjectToWorldNormal(normal)来得到。这里稍微复习一下Blinn-Phong的高光模型,虽然我们不会在这个shader中用它,但将他修改下,就能够为我们所用。

我们照样需要normal与halfDir的点积,但我们需要的不是渐变的高光,而是用它的点积跟一个阈值比较。这时候我们要用一个CG函数step(a,x)这是一个比较函数,当x大于a的时,返回1否则返回0,所以这部分我们可以这么写,

float spec=dot(worldNormal,halfDir);
spec=step(a,spec);

但我们使用这个进行着色的时候,会发现表面锯齿有点大,所以我们引入smoothstep(-w,w,spec-a) smoothstep(a,b,c)的数学意义是,当c小于a的时候返回0,大于b的时候返回1在这之间的时候进行插值,我们结合这两个函数看,其实smoothstep是step的进化版,step是直接拿spec-a,当其>0的时候返回1,<0的时候返回0,所以spec-a的时候无外乎有三种情况,<-w,>w,与在-w,w之间,而这个w有我们来定义,在本例中我们会用到fwidth函数,来得到临近像素之间的近似导数。

所以我们来写这部分的shader。首先我们在达成这个效果的时候需要什么?毋庸置疑的表面颜色_Color,_Specular,_SpecularScale,这三个是高光模型中必备的,然后包含我们上面一个pass所需要的_Outline与_OutlineColor,

Shader "Custom/Toon-ShadingShader2"
{
    Properties
    {
        _Color ("Color", Color) = (1,1,1,1)
        _Specular("Specular Color",Color)=(1,1,1,1)
        _SpecularScale("Specular Scale",Range(0,0.1))=0.1
        _Outline("Outline Size",Range(0,1))=0.5
        _OutlineColor("Outline Color",Color)=(1,1,1,1)
        _MainTex("Main Tex",2D)="white"{}
    }
    SubShader
    {
        Tags{"RenderType"="Opaque"  "Queue"="Geometry"}
        
        Pass{
            
            
            NAME "OUTLINE"
            Cull Front
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag 
            #include "Lighting.cginc"
            #include "UnityCG.cginc"

            float _Outline;
            fixed4 _OutlineColor;

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

            };

            struct v2f{
                float4 pos:SV_POSITION;
                
            };

            v2f vert(a2v v){
                v2f o;
                float4 pos=UnityObjectToClipPos(v.vertex);
                float3 normal=mul((float3x3)UNITY_MATRIX_IT_MV,v.normal);
                normal.z=-0.5;
                pos=pos+float4(normalize(normal),0)*_Outline;
                o.pos=mul(UNITY_MATRIX_P,pos);
                return o;
                
            }

            fixed4 frag(v2f i):SV_TARGET{
                return float4(_OutlineColor.rgb,1.0);
            }
            
            ENDCG
        }

        Pass{
            Tags{"LightMode"="ForwardBase"}

            Cull Back

            CGPROGRAM
                ...
            ENDCG
        }

    }
    FallBack "Diffuse"
}

这个就是我们的大体框架,然后我们开始编写代码的初始工作,首先我们的#pragma vertex与#pragma fragment不用谈都要的,然后我们这里要用到#pragma multi_compile_fwdbase这个组包含了所有向前渲染的关键字,但是这个只能处理平行光。如果缺少这段代码的话,unity会默认编译

DIRECTIONAL LIGHTMAP_ON DIRLIGHTMAP_ON SHADOWS_OFF

接下来是头文件,unityCG.cginc与Lighting.cginc必不可少。由于我们需要计算阴影,所以还需要AutoLight.cginc,这里要额外添加一个UnityShaderVariables.cginc,具体的含义可以在他的头文件中获取。

                        #pragma vertex vert 
            #pragma fragment frag 
            #pragma multi_compile_fwdbase

            #include "UnityCg.cginc"
            #include "Lighting.cginc"
            #include "AutoLight.cginc"
            #include "UnityShaderVariables.cginc"

接下来就是定义我们需要的参数了。

            fixed4 _Color;
            sampler2D _MainTex;
            float4 _MainTex_ST;
            fixed4 _Specular;
            fixed _SpecularScale;

这些基本定义就不再赘述,接着写我们的顶点数据到顶点着色器的结构体

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

我们需要从顶点数据那边拿到的首先是就是顶点坐标与顶点法线,还有uv坐标,由于我们输出到片元着色器中也需要uv所以们在a2v中用texcoord作为顶点UV的参数,并附上第一套坐标值。

            v2f vert(a2v v){
                v2f o;
                o.pos=UnityObjectToClipPos(v.vertex);
                o.uv=TRANSFORM_TEX(v.texcoord,_MainTex);
                o.worldNormal=UnityObjectToWorldNormal(v.normal);
                o.worldPos=mul(unity_ObjectToWorld,v.vertex).xyz;

                TRANSFER_SHADOW(o);

                return o;
            }

这里我们解释两个宏,一个是TRANSFORM_TEX(a,b),关于这个解释Unity的官方给出的解释如下vertex program uses the TRANSFORM_TEX macro from UnityCG.cginc to make sure texture scale and offset is applied correctly, and fragment program just samples the texture and multiplies by the color property.翻译一下就是顶点着色器使用这个宏可以确保应用纹理的比例与偏移,但片元着色器中只需要通过与颜色的相乘就能达到采样的结果。就是说当对顶点进行纹理着色的时候,使用这个宏可以正确的进行着色不至于使他的坐标进行偏移。

接下来是TRANSFER_SHADOW(o);这个宏其实是对应下一个v2f的结构体中的SHADOW_COORDS()来使用的,他将SHADOW_COORDS()声明的阴影坐标转换成片元着色器中所需要的阴影坐标,供其着色。所以下面我们来进行struct v2f的编写

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

这部分代码中的关键在前文已经解释过了,SHADOW_COORDS()仅起到一个声明阴影坐标的作用。接下来是我们最重要的片元着色器。首先我们再来回顾下我们需要什么,Blinn-Phong模型中我们需要表面法线点乘halfDir,所以我们需要世界坐标下的法线,视角向量,入射光向量。与之相对的halfDir。所以我们继续初始的声明计算

            fixed4 frag(v2f i):SV_TARGET{
                float3 worldNormal=normalize(i.worldNormal);
                float3 worldLightDir=normalize(UnityWorldSpaceLightDir(i.worldPos));
                float3 worldViewDir=normalize(UnityWorldSpaceViewDir(i.worldPos));
                float3 halfDir=normalize(worldLightDir+worldViewDir);

                
            }

虽然本案例不用进行贴图,但以防万一,我们仍在偏远着色其中进行贴图着色,按照之前的贴图纹理,我们了解了一个函数tex2D(tex,uv)并乘上我们的_Color进行一个正片叠加的效果,得到我们的反射率效果

fixed3 tex=tex2d(_MainTex,i.uv);
fixed3 albedo=tex.rgb*_Color;

接着我们获取环境光通过UNITY_LIGHTMODEL_AMBIEN.xyz乘上albedo

fixed3 ambient=UNITY_LIGHTMODEL_AMBIENT.xyz*albedo;

然后我们进行漫反射的计算,在这里为了使正面颜色更加突出,我们可以用一个新的模型半兰伯特漫反射模型(half-Lambert diffuse model),这是一个效果模型,跟现实中的反射模型没有一点关系,我们回忆一下我么的兰伯特模型的计算公式diffuse=clight*mdiffuse*max(0,n*l) 其中max(0,n*l)我们再代码中是这样体现的

saturate(0,dot(worldNormal,worldLightDir));

而我们的半兰伯特模型,则是将其乘以0.5并加上0.5的偏移这其中的代码我们可以这么写

diff=dot(worldNormal,worldLightDir);
diff=0.5*diff+0.5;

如果这时候我们直接乘上_LightColor0.rgb与_Diffuse就可以直接获得我们的半兰伯特漫反射模型。但因为这个案例中我们需要进行阴影的计算,我们来引入一个新的宏UNITY_LIGHT_ATTENUATION(atten,i,i.worldPos)这个宏可以帮我们计算阴影的衰减,atten不用声明,这个宏会自动声明,第二个参数是前面结构体所取得的阴影坐标,用来计算阴影值,后面的是在世界空间坐标系啊的坐标,用来计算光源下的坐标。

UNITY_LIGHT_ATTENUATION(atten,i,i.worldPos);

我们再将其得到的atten与半兰伯特公式相乘,得到其阴影部分的阴影衰减。

diff=(0.5*diff+0.5)*atten;

在我们得到这个阴影衰减后可以用一张渐变图对其采样,这样我们在效果上可以实现渐变阴影的衰减,这时候我们在头部属性,增加_Ramp(“Ramp Texture”,2D)=”whiter”{},并定义与之相对应的变量sampler2D _Ramp;float _Ramp_ST;

Shader"Custom/Toon-Shading"{
Properties{
...
_Ramp("Ramp Texture",2D)="white"{}
...
}
Subshader{
...
sampler2D _Ramp;
float _Ramp_ST;
...

fixed4 diffuse=_LightColor0.rgb*albedo*tex2D(_Ramp,float2(diff,diff)).rgb;

这样我们的漫反射部分就已经结束了。

接着我们进行高光部分的编写,我们来回顾一下,Blinn-Phong的公式

specular=Clight*Mdiffuse*max(0,n*halfDir)^Gloss 然后我们改进后的一部分公式smoothstep(-w,w,spec-a),其中w是我们通过fwidth函数实现的,而fwidth这个函数在CG语言中的含义如下:fwidth — return the sum of the absolute value of derivatives in x and y 意思是返回x,y中导数绝对值的和,但我们只需要临近像素之间的近似导数值,而高光部分我们只需要如此计算:fixed w=fwidth(dot(worldNormal,halfDir))*2则可

所以这部分代码如下

fixed w=fwidth(dot(worldNormal.halfDir))*2.0;
fixed3 specular=_Specular.rgb*lerp(0,1,smoothstep(-w,w,spec+_SpecularScale-1));

这部分为什么没有乘以_LightColor0.rgb呢,因为在卡通渲染的过程,我们希望高光部分不被环境光打扰,但如果你有这部分需求也能乘,但我试过,效果不是那么好,高光部分容易被干扰,从而达不到应有的效果。但如果你这时候你运行代码的时候,将_SpecularScale拉到0你会发现还会有高光反射的光照,这时候该怎么办呢,其实很简单直,运用我们的step函数将_SpecularScale与一个极小的值进行比较,当_SpecularScale等于0的时候返回0,修改过后的高光部分的代码如下

fixed3 specular=_Specular.rgb*lerp(0,1,smoothstep(-w,w,spec+_SpecualrScale-1))*step(0.00001,_SpecularScale);

如此我们边完成了高光部分的代码工作,最后我们将环境光、漫反射、高光相加就可以了,如果你要实现自发光的效果,可以直接定义一个_Emission的Color,然后与结果的和相乘便能达到效果。

Shader "Custom/Toon-ShadingShader2"
{
    Properties
    {
        _Color ("Color", Color) = (1,1,1,1)
        _Specular("Specular Color",Color)=(1,1,1,1)
        _SpecularScale("Specular Scale",Range(0,0.1))=0.1
        _Outline("Outline Size",Range(0,1))=0.5
        _OutlineColor("Outline Color",Color)=(1,1,1,1)
        _MainTex("Main Tex",2D)="white"{}
        _Ramp("Ramp Texture",2D)="white"{}
    }
    SubShader
    {
        Tags{"RenderType"="Opaque"  "Queue"="Geometry"}
        
        Pass{
            
            
            NAME "OUTLINE"
            Cull Front
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag 
            #include "Lighting.cginc"
            #include "UnityCG.cginc"
            float _Outline;
            fixed4 _OutlineColor;
            struct a2v{
               float4 vertex:POSITION;
               float3 normal:NORMAL;
            };
            struct v2f{
                float4 pos:SV_POSITION;
                
            };
            v2f vert(a2v v){
                v2f o;
                float4 pos=UnityObjectToClipPos(v.vertex);
                float3 normal=mul((float3x3)UNITY_MATRIX_IT_MV,v.normal);
                normal.z=-0.5;
                pos=pos+float4(normalize(normal),0)*_Outline;
                o.pos=mul(UNITY_MATRIX_P,pos);
                return o;
                
            }
            fixed4 frag(v2f i):SV_TARGET{
                return float4(_OutlineColor.rgb,1.0);
            }
            
            ENDCG
        }
        Pass{
            Tags{"LightMode"="ForwardBase"}
            Cull Back
            CGPROGRAM
            #pragma vertex vert 
            #pragma fragment frag 
            #pragma multi_compile_fwdbase
            #include "UnityCg.cginc"
            #include "Lighting.cginc"
            #include "AutoLight.cginc"
            #include "UnityShaderVariables.cginc"
            fixed4 _Color;
            sampler2D _MainTex;
            float4 _MainTex_ST;
            fixed4 _Specular;
            fixed _SpecularScale;
            sampler2D _Ramp;
            float _Ramp_ST;
            
            struct a2v{
                float4 vertex:POSITION;
                float3 normal:NORMAL;
                float4 texcoord:TEXCOORD0;
                
            };
            struct v2f {
                float4 pos:SV_POSITION;
                float2 uv:TEXCOORD0;
                float3 worldNormal:TEXCOORD1;
                float3 worldPos:TEXCOORD2;
                SHADOW_COORDS(3)
           };
           
            v2f vert(a2v v){
                v2f o;
                o.pos=UnityObjectToClipPos(v.vertex);
                o.uv=TRANSFORM_TEX(v.texcoord,_MainTex);
                o.worldNormal=UnityObjectToWorldNormal(v.normal);
                o.worldPos=mul(unity_ObjectToWorld,v.vertex).xyz;
                TRANSFER_SHADOW(o);
                return o;
            }
           
            
            fixed4 frag(v2f i):SV_TARGET{
                fixed3 worldNormal=normalize(i.worldNormal);
                fixed3 worldLightDir=normalize(UnityWorldSpaceLightDir(i.worldPos));
                fixed3 worldViewDir=normalize(UnityWorldSpaceViewDir(i.worldPos));
                fixed3 halfDir=normalize(worldLightDir+worldViewDir);
                
                fixed3 tex=tex2D(_MainTex,i.uv);
                fixed3 albedo=tex.rgb*_Color.rgb;
                fixed3 ambient=UNITY_LIGHTMODEL_AMBIENT.xyz*albedo;
                fixed diff=dot(worldNormal,worldLightDir);
                UNITY_LIGHT_ATTENUATION(atten,i,i.worldPos);
                diff=(diff*0.5+0.5)*atten;
                fixed3 diffuse=_LightColor0.rgb*albedo*tex2D(_Ramp,float2(diff,diff)).rgb;
                fixed w=fwidth(dot(worldNormal,halfDir))*2.0;
                fixed3 specular=_Specular*lerp(0,1,smoothstep(-w,w,dot(worldNormal,halfDir)+_SpecularScale-1))*step(0.0001,_SpecularScale);
                return fixed4 (ambient+diffuse+specular,1.0);
            }
            ENDCG
        }
    }
    FallBack "Diffuse"
}

以上就是我们的完整代码了。这部分如何再shadergraph中实现,我明天摸鱼的时候再弄吧。昨天测试了下代码,发现轮廓线有问题,我怀疑是描边pass 的法线的锅,果不出其然,在debug的过程中,发现是unityObjectToClipPos这个内置函数的锅将其换成原先老版的矩阵变换mul((float3x3)UNITY_MARITX_MV,v.vertex)这个是老版的矩阵转换,作用是将顶点坐标转换至裁剪空间内,更新后这个内置矩阵被UnityObjectToClipPos(v.vertex)给取代。具体为何会出现这个情况,有待进一步的分享。

在制作shadergraph的时候再将我们的custom function的代码优化一下。

void LWRPLightingFunction_float (float3 ObjPos, out float3 Direction, out float3 Color, out float ShadowAttenuation )
{
   #ifdef LIGHTWEIGHT_LIGHTING_INCLUDED
   #define LIGHTWEIGHT_LIGHT_INCLUDE
      //Actual light data from the pipeline
      Light light = GetMainLight(GetShadowCoord(GetVertexPositionInputs(ObjPos)));
      Direction = light.direction;
      Color = light.color;
      ShadowAttenuation = light.shadowAttenuation;
      
   #else
   
      //Hardcoded data, used for the preview shader inside the graph
      //where light functions are not available
      Direction = float3(-0.5, 0.5, -0.5);
      Color = float3(1, 1, 1);
      ShadowAttenuation = 0.4;
      
   #endif
}

之前的CG代码与HLSL很类似,所以相当于平铺直叙。将此代码直接复制到txt中然后改文件后缀为hlsl,添加到场景资源中。

创建我们的custom function,这里要注意两个点,首先是Name这里要与代码中的函数名一样,然后输出的三个参数的上下顺序应该与函数中的三个顺序相同,且命名相同。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注