Unity SDF贴图的应用,手把手教你写出可以正确计算并且渲染出二次元角色面部阴影的着色器(含代码,图片说明和原理)

梦开始的地方

近年来卡通风格渲染的游戏越来越多,三渲二这个的出现频率也越来越高

无论是去年提名TGA年度最佳移动端游戏的《崩坏:星穹铁道》还是风靡全球的《原神》,他们都是卡通渲染风格的游戏,也都运用到了三渲二相关技术

原神

 不知道你是否和我一样跃跃欲试,想写出一个三渲二风格的shader。

说干就干,不知道您是不是这样想的,所谓三渲二,不就是亮面一个颜色,暗面一个颜色,有明显的明暗交界线吗?简单!

计算一个像素的法向量与光的法向量夹角,诺大于90度,则这个像素位于暗面,反之则位于亮面

我们便可以写出一个简单的shader(以unity的CG语言为例)

//这个shader只是个入门级示例shader,游戏公司的shader比这个复杂很多
Shader "CelShader/CelShaderWithTex"
{
    Properties
    {
        _Darklight("Dark Light",Color) = (0.1,0.1,0.1,1)
        _MainTex("Main Tex",2D) = "white"{}
    }
    SubShader
    {
         



        Pass
        {
            Tags{"LightMode" = "ForwardBase"}
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma multi_compile_fog

            #include "UnityCG.cginc"
            #include "Lighting.cginc"

            float3 _Darklight;
            sampler2D _MainTex;
            struct c2v
            {
                float4 vertex:POSITION;
                float3 normal:NORMAL;
                float2 uv:TEXCOORD0;
            };

            struct v2f
            {
                float4 pos:SV_POSITION;
                float3 worldNormal:NORMAL;
                float2 uv:TEXCOORD;
                float3 objViewDir:COLOR1;
                float3 normal:NORMAL2;
            };

            v2f vert(c2v input)
            {
                v2f output;
                output.pos = UnityObjectToClipPos(input.vertex);
                output.worldNormal = normalize( mul((float3x3)unity_ObjectToWorld,input.normal) );
                output.uv = input.uv;


                float3 ObjViewDir = normalize(ObjSpaceViewDir(input.vertex));
                output.objViewDir = ObjViewDir;
                output.normal = normalize(input.normal);

                return output;
            }


            fixed3 frag(in v2f input):SV_TARGET0
            {
                fixed3 diffuseColor = _LightColor0.rgb *  tex2D(_MainTex, input.uv).rgb ;
                fixed3 col = UNITY_LIGHTMODEL_AMBIENT.xyz + diffuseColor;
                
                
                
                fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);


                if(dot(input.worldNormal,worldLightDir)<0)
                {
                    return col * _Darklight;
                }
                return col;
            }

            
            ENDCG
        }
    }
    FallBack "Diffuse"
}


//LZX completed this shader in 2024/04/04

当然了,在shader实战中我们应该尽量避免if(因为显卡不擅长逻辑判断,显卡擅长的是浮点运算)

我们可以将下面的代码

if(dot(input.worldNormal,worldLightDir)<0)
{
    return col * _Darklight;
}
return col;

替换为

float isShadow = step(dot(input.worldNormal,worldLightDir),0);

return lerp(col ,col * _Darklight,isShadow);

 二者效果是相同的

上述代码是用CG语言写的顶点/片段着色器,通过计算两个向量的点乘积来判断其夹角是否大于90度,诺夹角大于90度,则让亮面颜色的RGB值乘以一个小于1的数(_Darklight和col都是三维向量,而_Darklight在xyz轴上的分量值均小于1),这样就可以达到一种比较简单的三渲二效果了,我们把这个材质放到一个物体上试试看!

左默认材质  右简单的三渲二材质

可以看到,确实有内味了,于是我们欣喜诺狂的把这个材质拖到老婆身上

 

效果一

我们发现,似乎头发和衣服效果还行,但是面部的阴影十分诡异

很显然这不是我们想要的阴影效果,下图才是想要达到的阴影效果

想要的阴影效果

为什么角色面部阴影如此奇怪呢?我们不妨用默认的材质看看

原来,这个模型的脸颊是凸起的,而嘴部是凹陷的,这意味着我们不可能通过模型的轮廓来计算出我们想要的阴影,怎么办呢? 

正片开始

我们需要一张阈值图

阴影贴图(存储角色面部阴影信息的阈值图)

这可不是普通的阈值图,其实这张图存储的,是角色面部的阴影信息

我们可以在photoshop中观察

 

这张图导入unity后请将warpmode设置成repeat 

                                                 将warpmode设置成repeat 

那么我们该如何判断一个像素位于亮面还是暗面呢?

我们将角色面部所有像素视为一个整体,共用一个法向量(而且该法向量是单位向量,记该向量为F),而这个法向量的方向与角色脸部面对的方向一致。记该法向量在归一化的光线向量(记该向量为L)上的投影值为x,再记y = 0.5x+0.5(y = 0.5cosθ+0.5  θ是F和L的夹角),然后将y与阴影贴图中每一个像素点R值(或者G值B值)进行比较(此时y的取值范围是[0,1],而unity shaderlab中像素的RGB取值范围也是[0,1]),如果y小于R值,说明该像素位于是亮面,反之则位于暗面

关于向量的草图

对了,我们张SDF贴图存储的是光源在xz平面运动(绕y轴旋转)时在角色面部留下的阴影数据,也就是说我们计算时应该忽略光线向量在y轴的分量(光线的高度角对阴影无影响)

还有一点,相信你也看出来了,这张阈值图代表的是阴影在灯光从正前方移动到左后方的变化,所以说当光线在角色右方时,我们需要180度镜像反转该图

如何判断光源在角色的左边还是右边呢?

还是将角色面部视为一个整体,判断面部左方向向量在光线向量上的投影是否为正数,诺为正数,则说明光源位于角色左侧,反之则在右侧(用右方向向量判断也可以)

那么如何生成这张图呢?

可以参考这两篇教程:如何快速生成混合卡通光照图 - 知乎 (zhihu.com)

卡通渲染之基于SDF生成面部阴影贴图的效果实现(URP) - 知乎 (zhihu.com)

那么代码是什么样的呢?

下面是我写的实例代码,还是以unity的CG语言为例(HLSL语言或者虚幻的蓝图整体思路是一样的)

//这个shader只是展示如何运用阴影贴图是示例shader,游戏公司的shader比这个复杂很多
Shader "CelShader/CelFaceWithTex"
{
    Properties
    {
        _Darklight("Dark Light",Color) = (0.1,0.1,0.1,1)
        _MainTex("Main Tex",2D) = "white"{}     
        _ShadowTex("Shadow Tex",2D) = "white"{}
        _Front("Front",Vector) = (0,0,0)
        _UP("UP",Vector) = (0,0,0)
        _LeftDir("LeftDir",Vector) = (0,0,0)
        
    }
    SubShader
    {
        
        
 
 
 
        Pass
        {
            Tags{"LightMode" = "ForwardBase"}
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma multi_compile_fog
 
            #include "UnityCG.cginc"
            #include "Lighting.cginc"
 
            float3 _Darklight;
            sampler2D _MainTex;
            
            sampler2D _ShadowTex;
            float3 _Front;
            float3 _UP;
            float3 _LeftDir;
 
            struct c2v
            {
                float4 vertex:POSITION;
                float3 normal:NORMAL;
                float2 uv:TEXCOORD0;
            };
 
            struct v2f
            {
                float4 pos:SV_POSITION;
                float3 worldNormal:NORMAL;
                float2 uv:TEXCOORD;
                float3 objViewDir:COLOR1;
                float3 normal:NORMAL2;
            };
 
            v2f vert(c2v input)
            {
                v2f output;
                output.pos = UnityObjectToClipPos(input.vertex);
                output.worldNormal = normalize( mul((float3x3)unity_ObjectToWorld,input.normal) );
                output.uv = input.uv;
 
 
                float3 ObjViewDir = normalize(ObjSpaceViewDir(input.vertex));
                output.objViewDir = ObjViewDir;
                output.normal = normalize(input.normal);
                return output;
            }
 
 
            fixed3 frag(in v2f input):SV_TARGET0
            {              
                fixed3 diffuseColor = _LightColor0.rgb *  tex2D(_MainTex, input.uv).rgb ;
                float isSahdow = 0;
                
                float3 RightDir = -_LeftDir;
                float2 Left = normalize(float2(_LeftDir.x,_LeftDir.z));	//世界空间角色正左侧方向向量
                float2 Front = normalize(float2(_Front.x,_Front.z));	//世界空间角色正前方向向量
                float2 LightDir = normalize(float2(_WorldSpaceLightPos0.x,_WorldSpaceLightPos0.z));//只考虑x,z轴上的分量,忽略y轴分量(光源高度角不影响阴影)
 
                 //float ctrl = 1 - ( dot(Front, LightDir) * 0.5 + 0.5); //第一次阴影反了的原因就是我这里画蛇添足,不知道怎么想的,脑子瓦特了
                float ctrl = dot(Front, LightDir) * 0.5 + 0.5;

                //这张阈值图代表的是阴影在灯光从正前方移动到左后方的变化
                half4 shadowTex = tex2D(_ShadowTex, input.uv);
                //这张阈值图代表的是阴影在灯光从正前方移动到右后方的变化(反转了180度后)
                half4 r_shadowTex = tex2D(_ShadowTex, float2(1 - input.uv.x, input.uv.y));
                float shadow = dot(LightDir, Left) > 0 ? shadowTex.r : r_shadowTex.r;//通过左方向向量在光线向量上的投影的正负,来确定采样的是哪一张贴图(是否反转180度),取的值也不一定是r值,g值b值也可以,毕竟他们三个在这张图里面是相等的
 
                //ctrl大于采样,说明该像素位于暗面
                isSahdow = step(shadow, ctrl);//if(ctrl>=shadow) isSahdow = 1 
 
                fixed3 col = UNITY_LIGHTMODEL_AMBIENT.xyz + diffuseColor;
 
                if ( isSahdow == 1 )
                {
                    return  tex2D(_MainTex, input.uv).rgb * _Darklight;
                }
                return col;
            }
 
            
            ENDCG
        }
        
    }
    FallBack "Diffuse"
}
 
//LZX completed this shader in 2024/05/06

当然了,上述代码只是示例,在shader实战中,我们应该尽量避免if判断(因为显卡不擅长逻辑判断)

我们可以将最后下面的代码

if ( isSahdow == 1)
{
    return  tex2D(_MainTex, input.uv).rgb * _Darklight;
}
return col;

替换为

fixed3 darkCol = tex2D(_MainTex, input.uv).rgb * _Darklight;

return lerp(col,darkCol,isSahdow);

然后我们可以通过光源在角色左侧还是右侧直接决定uv坐标x的正负,这样就可以少一次采样了

将下面的代码

//这张阈值图代表的是阴影在灯光从正前方移动到左后方的变化
half4 shadowTex = tex2D(_ShadowTex, input.uv);
//这张阈值图代表的是阴影在灯光从正前方移动到右后方的变化(反转了180度后)
half4 r_shadowTex = tex2D(_ShadowTex, float2(1 - input.uv.x, input.uv.y));
float shadow = dot(LightDir, Left) > 0 ? shadowTex.r : r_shadowTex.r;//通过左方向向量在光线向量上的投影的正负,来确定采样的是哪一张贴图(是否反转180度),取的值也不一定是r值,g值b值也可以,毕竟他们三个在这张图里面是相等的

替换为

float flag = step(0,dot(LightDir, Left)) * 2 + -1;//这行代码效果等同于 float flag = dot(LightDir, Left) > 0 ? 1 : -1;  但是可以减少一次判断
half4 shadowTex = tex2D(_ShadowTex, float2( flag * input.uv.x, input.uv.y));//因为已经将warpmode设置成repeat了,所以-input.uv.x等价于1-input.uv.x
float shadow = shadowTex.r;

 二者的效果是相同的

然后我们在unity里面创建一个材质,让这个材质使用这个shader

然后在把面部的贴图和阴影贴图拖进材质的对应部分,设置好参数

完成后我们试试看效果

阴影看起来是正常了,但是有一个问题,现在的阴影不会改变,这是因为我们的shader无法获得角色面部的法向量和左方向向量的方向.

那么我们如何让shader获得角色面部的法向量和左方向向量的方向呢?

别忘了,shader不是一个人在战斗,我们说,材质球是CPU和GPU传递信息的桥梁,我们不妨写一个CS脚本,获取角色法向量后通过材质球传递给我们的shader

下面代码给各位提供一些思路(只是提供一个思路,实战的时候大概率不会这么写)

//这个代码主要是想告诉各位,我们可以利用C#脚本来获取数据
//大部分这类脚本都是用两个for循环遍历得到所有材质球,如何再统一改变其内部的数值
using UnityEngine;

[ExecuteInEditMode]
public class GetVector : MonoBehaviour
{
    public Material material;
    // Start is called before the first frame update
    void Start()
    {

    }

    // Update is called once per frame
    void Update()
    {
        material.SetVector("_Front", gameObject.transform.forward);
      
        material.SetVector("_LeftDir", -gameObject.transform.right);

    }
}


//LZX completed this script in 2024/05/06
//LZX-TC-VS-2024-05-05-001

将脚本挂载到角色身上,如何将我们的材质球托给这个脚本就可以了 

这样我们的阴影就可以变化了

阴影变化效果

当然啦,三渲二仅仅是计算出了正确的阴影是远远不够的,还有描边,边缘光,明暗过度等等,如果大家有兴趣,以后我可以再发一篇教程讲这些

加了描边,边缘光,平滑过度的效果

希望我的教程对你有帮助 

  • 16
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

想换电脑的LaggingWarrior

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值