# 卡通渲染学习记录
本文记述三渲二学习历程,打算分别采用 Unity 和 Unreal 进行实现。
# 卡通渲染原理
本文依据参考 知乎
# Unity 实现篇
# 描线
# 法线外扩
Unity 中实现法线外扩非常简单,保留模型的初次 Pass 正常渲染,在第二次 Pass 中进行模型沿着法线外扩,并开启 Cull Front (根据三角形朝向裁剪,这里裁剪了正面,只渲染背面)
Shader 代码如下
Shader "OutLine"
{
Properties
{
_OutLineWidth ("OutLine Width", Range(0,1)) = 0.5
_OutLineColor ("OutLine Color", Color) = (0.9,0.4,0.4,1)
}
SubShader
{
Tags { "RenderType"="Opaque" }
LOD 100
Pass
{
// 物体渲染逻辑
}
// 第二pass - 画轮廓
Pass
{
Cull Front
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
half4 _OutLineColor;
half _OutLineWidth;
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
float3 normal : NORMAL;
float3 tangent : TANGENT;
};
struct v2f
{
float2 uv : TEXCOORD0;
float4 vertex : SV_POSITION;
};
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(float4(v.vertex.xyz + v.normal*_OutLineWidth*0.1,1.0f)); //模型在Object空间进行扩张
return o;
}
fixed4 frag (v2f i) : SV_Target
{
return _OutLineColor;
}
ENDCG
}
}
}
效果如下
可以很明显的看到,随着摄像机的移动,描边的粗细也会随之变化。这是因为我们是在 Object 空间进行缩放的。渲染管线为 Object -> Clip(-w,w) -> NDC(-1,1) -> Screen(NDC * [0,Width][0,Height])
,我们想要粗细在 Screen 空间下相等,那么就必须在 NDC 下相等。NDC 为 Clip 空间除以 w 而来,不同的 w 就是引起 Object 中相同距离到 NDC 距离不同的罪魁祸首。因此我们对于每一个着色点扩张的距离乘上其对应的 w 即可。
其中,Object 空间变换到 Clip 空间会经历以下过程 Object (*world)->World (*view)-> View (*projection)-> Clip
,是最为熟知的 MVP 变换。我们要模拟 Clip 空间中的扩张。而在投影变换之后,z,也就是深度信息已经对于画面偏移没有作用,我们只需要在 x,y 上扩张即可。
代码如下
// 第二pass - 画轮廓
Pass
{
Cull Front
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
half4 _OutLineColor;
half _OutLineWidth;
float _AspectRatio;
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
float3 normal : NORMAL;
float3 tangent : TANGENT;
};
struct v2f
{
float2 uv : TEXCOORD0;
float4 pos : SV_POSITION;
};
v2f vert (appdata v)
{
v2f o;
o.pos = UnityObjectToClipPos(float4(v.vertex.xyz,1.0f)); // 先进行常规MVP变换
// 接下来 找到Clip空间下的法线方向
float3 norm = mul((float3x3)UNITY_MATRIX_IT_MV, v.normal); // 从物体空间到视图空间 - 从这儿开始z是深度,对于扩张就无用了
float2 extendDir = normalize(TransformViewToProjection(norm.xy)); // 从视图空间到裁剪空间
float2 extendVec = extendDir * (o.pos.w *_OutLineWidth * 0.1);
o.pos.xy += extendVec;
return o;
}
fixed4 frag (v2f i) : SV_Target
{
return _OutLineColor;
}
ENDCG
}
此时可以发现摄像机的移动不会再影响粗细。但是,在长宽比偏离 1 的画面中会导致粗细不一,这是因为 NDC 最后转到屏幕空间还需要乘上 width 和 height。
那么我们只需在此提前将长宽分别单位化即可。
v2f vert (appdata v)
{
v2f o;
o.pos = UnityObjectToClipPos(float4(v.vertex.xyz,1.0f)); // 先进行常规MVP变换
// 接下来 找到Clip空间下的法线方向
float3 norm = mul((float3x3)UNITY_MATRIX_IT_MV, v.normal); // 从物体空间到视图空间 - 从这儿开始z是深度,对于扩张就无用了
float2 extendDir = normalize(TransformViewToProjection(norm.xy)); // 从视图空间到裁剪空间
float2 extendVec = extendDir * (o.pos.w *_OutLineWidth * 0.1);
// 长宽单位化
float2 screenSize = float2(_ScreenParams.x, _ScreenParams.y);
extendVec.x *= 1000/ _ScreenParams.x;
extendVec.y *= 1000/_ScreenParams.y;
o.pos.xy += extendVec;
return o;
}
我们来看一下效果
现在还有最后一个显著的问题,就是以上 shader 只对法线连续的有效。
我们可以自己撰写脚本
public class PlugTangentTools
{
[MenuItem("Tools/模型平均法线写入切线数据")]
public static void WirteAverageNormalToTangentToos()
{
MeshFilter[] meshFilters = Selection.activeGameObject.GetComponentsInChildren<MeshFilter>();
foreach (var meshFilter in meshFilters)
{
Mesh mesh = meshFilter.sharedMesh;
WirteAverageNormalToTangent(mesh);
}
SkinnedMeshRenderer[] skinMeshRenders = Selection.activeGameObject.GetComponentsInChildren<SkinnedMeshRenderer>();
foreach (var skinMeshRender in skinMeshRenders)
{
Mesh mesh = skinMeshRender.sharedMesh;
WirteAverageNormalToTangent(mesh);
}
}
private static void WirteAverageNormalToTangent(Mesh mesh)
{
var averageNormalHash = new Dictionary<Vector3, Vector3>();
for (var j = 0; j < mesh.vertexCount; j++)
{
if (!averageNormalHash.ContainsKey(mesh.vertices[j]))
{
averageNormalHash.Add(mesh.vertices[j], mesh.normals[j]);
}
else
{
averageNormalHash[mesh.vertices[j]] =
(averageNormalHash[mesh.vertices[j]] + mesh.normals[j]).normalized;
}
}
var averageNormals = new Vector3[mesh.vertexCount];
for (var j = 0; j < mesh.vertexCount; j++)
{
averageNormals[j] = averageNormalHash[mesh.vertices[j]];
}
var tangents = new Vector4[mesh.vertexCount];
for (var j = 0; j < mesh.vertexCount; j++)
{
tangents[j] = new Vector4(averageNormals[j].x, averageNormals[j].y, averageNormals[j].z, 0);
}
mesh.tangents = tangents;
}
}
在 Unity 中新建 C# 脚本,导入上面的代码并关闭编辑器。等待编译后点击 Unity 上方菜单的工具栏(要先选中你要进行平均法线的对象)。随后更改 OutLine.shader 即可。
在先前的人物模型上尝试如下
这个差别不是特别大,主要原因是第一我调整了线比较细,先前即使有裂缝也比较难看出;第二是本身的模型法线就比较连续。想看差异化的效果可以参考文前所给的知乎链接中的模型。
# 着色
# 准备
根据各个贴图的定义,先把各个数据采样出来
// Compute Lighting
half3 normal = normalize(i.normal_world);
half3 ViewDir = normalize(_WorldSpaceCameraPos.xyz - i.pos_world);
half3 LightDir = normalize(_WorldSpaceLightPos0.xyz);
half3 H = normalize(LightDir + ViewDir);
half3 VdotL = dot(ViewDir,LightDir);
half3 NdotH = dot(normal,H);
half3 NdotV = dot(normal,ViewDir);
half3 NdotL = dot(normal,LightDir);
// sample the texture
// Base Map
half4 baseMap = tex2D(_BaseMap, uv0);
half3 baseColor = baseMap.rgb;
// SSS Map
half4 sssMap = tex2D(_SSSMap, uv0);
half3 sssColor = sssMap.rgb;
// ILM Map
half4 ilmMap = tex2D(_ILMMap, uv0);
half specular_intensity = ilmMap.r;
float diffuse_control = ilmMap.g;
half specular_range = ilmMap.b;
half inner_line = ilmMap.a;
// ao
half ao = i.vertex_color.r;
# 风格化 Diffuse
风格化 diffuse 的基本原理就是色阶化,根据漫反射 NdotL 的值来色阶化。
// Toon Diffuse
half3 halfLambert = 0.5 * (NdotL + 1);
//half threshold = (halfLambert + diffuse_control) * 0.5;
half threshold2 = halfLambert > _ToonThreshold ? 1 : 0;
half3 ToonDiffuse = lerp(sssColor,baseColor,threshold2);
然后我们要加上 ilm.g 通道对于阴影的控制。根据对于贴图的查询,我们可以发现这里的 diffuse_control 是非常小的值,基本在 0~0.2 之间。这里的 0.3 也可以提升为参数。
// Toon Diffuse
half3 halfLambert = 0.5 * (NdotL + 1);
half threshold = halfLambert + _ShadowPYControl*(diffuse_control - 0.3) > _ToonThreshold ? 1 : 0;
half3 ToonDiffuse = lerp(sssColor,baseColor,saturate(threshold));
可以看到头发末梢更容易进入阴影。
最后加上 ao 项
half threshold = halfLambert*ao + _ShadowPYControl*(diffuse_control - 0.3) > _ToonThreshold ? 1 : 0;
可以看到裙底,头发等处的阴影更加稳定。
# 风格化高光
风格化高光也是在原有的 Phong 氏高光上进行色阶化。
// Toon Specular
half3 ToonSpecular=0;
NdotH = max(0,NdotH*ao);
half SpecularSize = pow(NdotH, _SpecularGloss);
if(SpecularSize >= 1-specular_range*_specularThreshold){
ToonSpecular = specular_intensity * 3;
}
// col = inner_line * half4(saturate(ToonSpecular)*baseColor* specular_intensity + ToonDiffuse,1.0f);
col = half4(ToonSpecular + ToonDiffuse,1.0f);
return col;
最后记得乘上本衬线。\
# 卡通头发
使用 Kajiya-Kay
// Toon Hair Specular
half3 ToonSpecular=0;
float3 shiftedTangent = normalize(bitangent + normal * _Tshift);
float TdotH = dot(shiftedTangent,H);
float sinTH = sqrt(1.0-TdotH*TdotH);
float dirAtten = smoothstep(-1.0f,0.0,TdotH);
ToonSpecular = dirAtten * pow(sinTH,_SpecularGloss) * _HairSpecular;
模拟天使环如下
这里我丢失了一张纹理,区分头发和面部的一个 MASK,所以给皮肤也上了,大家将就看。
# 边缘光
边缘光是用来模拟边缘发光。边缘可以根据法线的朝向来估计。当 N 与 V 大致垂直时一般就是在边缘。
// Edge Light
half3 EdgeLight = 0.0f;
half f = 1.0f - saturate(NdotV);
col = half4(half3(f,f,f),1.0f);
材质球示例如下
可以看到边缘为白。我们引入三个可调节参数,分别调节边缘光的强度,颜色和范围。
// Edge Light
half3 EdgeLight = 0.0f;
half priEdgeLight = 1.0f - saturate(NdotV);
half powEdgeLight = pow(priEdgeLight,_EdgeLightGloss);
half f = saturate(step(_EdgeLightThreshold,powEdgeLight));
// col = half4(inner_line*(ToonSpecular + ToonDiffuse),1.0f) ;
col = half4(half3(f,f,f),1.0f);
可以看到收获了较为硬的边缘光。
这里的边缘光四周都有,但是一般来说我们只想着光照亮部拥有边缘光。因此我们需要做一个 Mask。那么如何才能获取到那边是光照的方向呢?答案是通过 Diffuse 来获取,通过一个阈值来截断。
// Edge Light
half3 EdgeLight = 0.0f;
half priEdgeLight = 1.0f - saturate(NdotV);
half powEdgeLight = pow(priEdgeLight,_EdgeLightGloss);
half f = saturate(step(_EdgeLightThreshold,powEdgeLight));
half EdgeLightMask = halfLambert > _EdgeLightRange ? 1 : 0;
EdgeLight = f * EdgeLightMask * _EdgeLightColor;
// col = half4(inner_line*(ToonSpecular + ToonDiffuse),1.0f) ;
col = half4(EdgeLight,1.0f);
与原始图案相加,这里将头发的边缘光特地放大了。有点内味了。
Unity 部分到此结束。