效果展示:

31516

原模型:

image-20220102233701399

简单分析

卡通渲染又叫非真实渲染(None-Physical Rendering-NPR),一般日漫里的卡通风格有几个特点:

人物有描边

有明显的阴影分界线,没有太平滑的过渡

以下就根据这两点来实现卡渲效果;

描边

法线外扩

实现描边方式多种,比如卷积区分边界;

这里使用更简单的两个Pass,一个只用纯色画背面,利用法线外扩顶点,根据深度的不同这个纯色的背面会被显示出来,同时又不会遮挡正面;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
Pass
{
Tags {"LightMode"="ForwardBase"}
//裁剪正面,只画背面
Cull Front

CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"

half _OutlineWidth;
half4 _OutLineColor;

struct a2v
{
float4 vertex : POSITION;
float3 normal : NORMAL;
float2 uv : TEXCOORD0;
float4 vertColor : COLOR;
float4 tangent : TANGENT;
};

struct v2f
{
float4 vertColor : TEXCOORD0;
float4 pos : SV_POSITION;
};

v2f vert (a2v v)
{
v2f o;
UNITY_INITIALIZE_OUTPUT(v2f, o);

//顶点沿着法线方向外扩
o.pos = UnityObjectToClipPos(float4(v.vertex.xyz + v.normal * _OutlineWidth * 0.1 ,1));

o.vertColor = fixed4(v.vertColor.rgb,1.0);
return o;
}

half4 frag(v2f i) : SV_TARGET
{
return half4(_OutLineColor.rgb * i.vertColor.rgb, 0);
}
ENDCG
}

细节处理(坑)

细节处理前后对比:

42

  • 摄像机远近边缘线粗细不同

    由于世界坐标系下做外扩,摄像机里物体远近会影响法线外扩的多少;

    解决方案,在NDC坐标系下法线外扩;

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    //顶点着色器替换以下代码
    float4 pos = UnityObjectToClipPos(v.vertex);

    //摄像机空间法线
    float3 viewNormal = mul((float3x3)UNITY_MATRIX_IT_MV, v.normal.xyz);

    //将法线变换到NDC空间,投影空间*W分量
    float3 ndcNormal = normalize(TransformViewToProjection(viewNormal.xyz)) * pos.w;

    //xy两方向外扩
    pos.xy += 0.01 * _OutlineWidth * ndcNormal.xy * v.vertColor.a;
    o.pos = pos;
  • 上下和左右边缘线粗细不同

    NDC空间是正方形,而视口宽高比是长方体,导致描边上下和左右的粗细不统一;

    解放方案,根据屏幕宽高比缩放法线再外扩;

    1
    2
    3
    4
    5
    6
    7
    //将近裁剪面右上角位置的顶点变换到观察空间
    //unity_CameraInvProjection摄像机矩阵逆矩阵,UNITY_NEAR_CLIP_VALUE近截面值,DX:0,OpenGL-1.0;_ProjectionParams.y摄像机近截面
    float4 nearUpperRight = mul(unity_CameraInvProjection, float4(1, 1, UNITY_NEAR_CLIP_VALUE, _ProjectionParams.y));

    //求得屏幕宽高比
    float aspect = abs(nearUpperRight.y / nearUpperRight.x);
    ndcNormal.x *= aspect;
  • 顶点重合法线不连续

    模型顶点重合时会出现多条法线,在不同的面上法线不同导致描边不连续;

    解决方案,修改模型顶点数据,同顶点多条法线求平均值;

    需要和美工协商修改模型数据,这里写了脚本临时修改模型数据;

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    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);
    }
    Debug.Log("重合顶点平均法线写入成功");
    }

    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;
    }
    }

ps:利用模型顶点的四个通道RGBA——对描边粗细显影相机距离缩放进行精细控制,需要美工配合;

着色

减少色阶

二分法

将有阴影和没阴影的地方做明显的区分;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
half4 frag(v2f i) : SV_TARGET 
{
half4 col = 1;
half4 mainTex = tex2D(_MainTex, i.uv);
half3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos.xyz);
half3 worldNormal = normalize(i.worldNormal);
half3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);

//半兰伯特光照模型
half halfLambert = dot(worldNormal, worldLightDir) * 0.5 + 0.5;

//_ShadowRange区分阴影范围,_ShadowSmooth控制分界线的柔和程度,求出ramp值(百分比)
half ramp = smoothstep(0, _ShadowSmooth, halfLambert - _ShadowRange);

//根据ramp值插值取样,将阴影和main颜色混合
half3 diffuse = lerp(_ShadowColor, _MainColor, ramp);
diffuse *= mainTex;
col.rgb = _LightColor0 * diffuse;
return col;
}

image-20220103173035747

Ramp贴图

使用明显分界的色阶图来取样,使阴影有明显的分界线;

逻辑和二分一样,只是多加个几个色阶;

33

1
2
//_ShadowRange范围取样Ramp贴图
half ramp = tex2D(_RampTex, float2(saturate(halfLambert - _ShadowRange), 0.5)).r;

image-20220103171837758

高光色阶

卡渲高光和阴影一样,和周围色块有明显的分界线;

1
2
3
4
5
6
7
8
9
10
11
half3 specular = 0;
half3 halfDir = normalize(worldLightDir + viewDir);
half NdotH = max(0, dot(worldNormal, halfDir));
//_SpecularGloss控制高光光泽度
half SpecularSize = pow(NdotH, _SpecularGloss);

//_SpecularRange高光范围,_SpecularMulti强度,在范围内显示高光有明显分界
if (SpecularSize >= 1 - _SpecularRange)
{
specular = _SpecularMulti * _SpecularColor;
}

12

ilmTexture贴图

《GUILTY GEAR Xrd》中使用的方法,又叫Threshold贴图;

贴图的R通道控制漫反射的阴影阈值,G通道控制高光强度,B通道控制高光范围;

需要和美工配合,没贴图就不测了;

总之万物皆可用贴图来传递信息,rgba代表什么意思可以自行做各种trick;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
half4 frag (v2f i) : SV_Target
{
half4 col = 0;
half4 mainTex = tex2D (_MainTex, i.uv);
//取样ilmTexture
half4 ilmTex = tex2D (_IlmTex, i.uv);
half3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos.xyz);
half3 worldNormal = normalize(i.worldNormal);
half3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);

//漫反射+阴影
half3 diffuse = 0;
half halfLambert = dot(worldNormal, worldLightDir) * 0.5 + 0.5;
//g通道控制高光强度
half threshold = (halfLambert + ilmTex.r) * 0.5;
half ramp = saturate(_ShadowRange - threshold);
ramp = smoothstep(0, _ShadowSmooth, ramp);
diffuse = lerp(_MainColor, _ShadowColor, ramp);
diffuse *= mainTex.rgb;

half3 specular = 0;
half3 halfDir = normalize(worldLightDir + viewDir);
half NdotH = max(0, dot(worldNormal, halfDir));
half SpecularSize = pow(NdotH, _SpecularGloss);
//b通道控制高光遮罩
half specularMask = ilmTex.b;
if (SpecularSize >= 1 - specularMask * _SpecularRange)
{
//g控制高光强度
specular = _SpecularMulti * (ilmTex.g) * _SpecularColor;
}

col.rgb = (diffuse + specular) * _LightColor0.rgb;
return col;
}

【翻译】西川善司「实验做出的游戏图形」「GUILTY GEAR Xrd -SIGN-」中实现的「纯卡通动画的实时3D图形」的秘密,前篇(1)

【翻译】西川善司「实验做出的游戏图形」「GUILTY GEAR Xrd -SIGN-」中实现的「纯卡通动画的实时3D图形」的秘密,前篇(2)

【翻译】西川善司的「实验做出的游戏图形」「GUILTY GEAR Xrd -SIGN-」中实现的「纯卡通动画的实时3D图形」的秘密,后篇

边缘泛光

三渲二加点边缘泛光会增加立体感,让画质更真实;效果如下;

img

__RimMin、_RimMax控制边缘泛光范围;

smoothstep使过渡平缓;再乘以RimColor,alpha控制强度;

1
2
3
4
5
half f =  1.0 - saturate(dot(viewDir, worldNormal));
half rim = smoothstep(_RimMin, _RimMax, f);
rim = smoothstep(0, _RimSmooth, rim);
half3 rimColor = rim * _RimColor.rgb * _RimColor.a;
col.rgb = (diffuse + specular + rimColor) * _LightColor0.rgb;

mask遮罩图

用一张贴图来修正边缘泛光的效果;

边缘光的计算使用的是法线点乘视线。在物体的法线和视线垂直的时候,边缘光会很强。在球体上不会有问题,但是在一些有平面的物体,当平面和视线接近垂直的时候,会导致整个平面都有边缘光。这会让一些不该有边缘光的地方出现边缘光。

img

屏幕后效

post-processing官方组件中有bloom效果;

原理:提取图像中较亮区域,存储在纹理中,使用高斯模糊模拟光线扩散效果,将该纹理和原图像混合;过程比较复杂,后面写屏幕后期效果再分析吧;

完整Shader:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
Shader "Unlit/CelRenderFull"
{
Properties
{
_MainTex ("MainTex", 2D) = "white" {}
_IlmTex ("IlmTex", 2D) = "white" {}

[Space(20)]
_MainColor("Main Color", Color) = (1,1,1)
_ShadowColor ("Shadow Color", Color) = (0.7, 0.7, 0.7)
_ShadowSmooth("Shadow Smooth", Range(0, 0.03)) = 0.002
_ShadowRange ("Shadow Range", Range(0, 1)) = 0.6

[Space(20)]
_SpecularColor("Specular Color", Color) = (1,1,1)
_SpecularRange ("Specular Range", Range(0, 1)) = 0.9
_SpecularMulti ("Specular Multi", Range(0, 1)) = 0.4
_SpecularGloss("Sprecular Gloss", Range(0.001, 8)) = 4

[Space(20)]
_OutlineWidth ("Outline Width", Range(0, 1)) = 0.24
_OutLineColor ("OutLine Color", Color) = (0.5,0.5,0.5,1)

[Space(20)]
_RimMin ("imMin",float) = 1.0
_RimMax ("RimMax",float) = 2.0
_RimSmooth("RimSmooth",Range(0.0,1))=0.5
_RimColor("RimColor",Color) = (1,1,1,1)
}

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

CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma multi_compile_fwdbase

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

sampler2D _MainTex;
float4 _MainTex_ST;
sampler2D _IlmTex;
float4 _IlmTex_ST;

half3 _MainColor;
half3 _ShadowColor;
half _ShadowSmooth;
half _ShadowRange;

half3 _SpecularColor;
half _SpecularRange;
half _SpecularMulti;
half _SpecularGloss;

half _RimMin;
half _RimMax;
half _RimSmooth;
fixed4 _RimColor;

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

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

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

half4 frag (v2f i) : SV_Target
{
half4 col = 0;
half4 mainTex = tex2D (_MainTex, i.uv);
half4 ilmTex = tex2D (_IlmTex, i.uv);
half3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos.xyz);
half3 worldNormal = normalize(i.worldNormal);
half3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);

half3 diffuse = 0;
half halfLambert = dot(worldNormal, worldLightDir) * 0.5 + 0.5;
half threshold = (halfLambert + ilmTex.g) * 0.5;
half ramp = saturate(_ShadowRange - threshold);
ramp = smoothstep(0, _ShadowSmooth, ramp);
diffuse = lerp(_MainColor, _ShadowColor, ramp);
diffuse *= mainTex.rgb;

half3 specular = 0;
half3 halfDir = normalize(worldLightDir + viewDir);
half NdotH = max(0, dot(worldNormal, halfDir));
half SpecularSize = pow(NdotH, _SpecularGloss);
half specularMask = ilmTex.b;
if (SpecularSize >= 1 - specularMask * _SpecularRange)
{
specular = _SpecularMulti * (ilmTex.r) * _SpecularColor;
}

half f = 1.0 - saturate(dot(viewDir, worldNormal));
half rim = smoothstep(_RimMin, _RimMax, f);
rim = smoothstep(0, _RimSmooth, rim);
half3 rimColor = rim * _RimColor.rgb * _RimColor.a;
col.rgb = (diffuse + specular + rimColor) * _LightColor0.rgb;

return col;
}
ENDCG
}

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

Cull Front

CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"

half _OutlineWidth;
half4 _OutLineColor;

struct a2v
{
float4 vertex : POSITION;
float3 normal : NORMAL;
float2 uv : TEXCOORD0;
float4 vertColor : COLOR;
float4 tangent : TANGENT;
};

struct v2f

{
float4 vertColor : TEXCOORD0;
float4 pos : SV_POSITION;
};


v2f vert (a2v v)
{
v2f o;
UNITY_INITIALIZE_OUTPUT(v2f, o);

float4 pos = UnityObjectToClipPos(v.vertex);
float3 viewNormal = mul((float3x3)UNITY_MATRIX_IT_MV, v.tangent.xyz);
float3 ndcNormal = normalize(TransformViewToProjection(viewNormal.xyz)) * pos.w;


float4 nearUpperRight = mul(unity_CameraInvProjection, float4(1, 1, UNITY_NEAR_CLIP_VALUE, _ProjectionParams.y));
float aspect = abs(nearUpperRight.y / nearUpperRight.x);
ndcNormal.x *= aspect;

pos.xy += 0.01 * _OutlineWidth * ndcNormal.xy * v.vertColor.a;
o.pos = pos;
o.vertColor = fixed4(v.vertColor.rgb,1.0);
return o;
}

half4 frag(v2f i) : SV_TARGET
{
return half4(_OutLineColor.rgb * i.vertColor.rgb, 0);
}
ENDCG
}
}
FallBack Off
}