梦开始的地方
近年来卡通风格渲染的游戏越来越多,三渲二这个的出现频率也越来越高
无论是去年提名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
将脚本挂载到角色身上,如何将我们的材质球托给这个脚本就可以了
这样我们的阴影就可以变化了
阴影变化效果
当然啦,三渲二仅仅是计算出了正确的阴影是远远不够的,还有描边,边缘光,明暗过度等等,如果大家有兴趣,以后我可以再发一篇教程讲这些
加了描边,边缘光,平滑过度的效果
希望我的教程对你有帮助