US4L6——卡通风格渲染

卡通风格渲染

卡通风格渲染(Cartoon Shading),也称为非真实感渲染(NPR)或卡通渲染(Toon Shading)
主要目的是使 3D 模型看起来更像手绘的二维卡通或漫画风格,而不是逼真写实的 3D 渲染效果。
这种风格的渲染常用于游戏、动画和电影中,用来创造一种独特的艺术风格

imageimage

卡通风格渲染基本原理

一句话总结卡通风格渲染基本原理:让光的过渡效果变硬并且实现轮廓描边!

关键点:

  • 如何让光的过渡效果变硬

    影响对象光照效果的部分主要是:漫反射的计算 + 高光反射的计算
    因此,想要光的过渡效果变硬,只需要从这两方面去考虑即可

    这里可以从 Blinn Phong 光照模型公式下手:详见:US3S1L7——Blinn-Phong光照模型

    物体表面光照颜色=环境光颜色+兰伯特光照模型所得颜色+BlinnPhong式高光反射光照模型所得颜色物体表面光照颜色 = 环境光颜色 + 兰伯特光照模型所得颜色 + BlinnPhong式高光反射光照模型所得颜色

    其中:

    • 环境光颜色 = UNITY_LIGHTMODEL_AMBIENT​ (unity_AmbientSky​、unity_AmbientEquator​、unity_AmbientGround​)

      关于上边四个变量,详见:US3S1L5——Phong光照模型

    • 漫反射光颜色 = 兰伯特光照模型 或 半兰伯特光照模型 计算得到的颜色

    • 高光反射光颜色 = Blinn Phong 式高光反射光照模型 计算得到的颜色

    漫反射部分的变硬我们需要回顾之前学习的渐变纹理知识,详见:US3S2L8——渐变纹理基本概念

    渐变纹理的基本原理就是在计算漫反射时利用 半兰伯特光照模型 公式中后半部分

    Color漫反射光照=Color光源×Color材质的漫反射×((标准化后物体表面法线向量标准化后光源方向向量)×0.5+0.5)Color_{漫反射光照} = Color_{光源} \times Color_{材质的漫反射} \times ((\overrightarrow{标准化后物体表面法线向量} \cdot \overrightarrow{标准化后光源方向向量}) \times 0.5 + 0.5)

    半兰伯特光照模型后半部分会得到一个 0~1 区间的值,将这个值作为 uv 坐标中的 uv 值,从渐变纹理中取出颜色
    与公式中前面部分进行颜色叠加,最终得到漫反射光照颜色。

    也就是说,决定漫反射明暗的不再是单由 0~1 这个值决定,而是通过 0~1 这个值从渐变纹理中取出的颜色进行叠加达到最终效果
    这意味着,原本由光照带来的明暗变化会变成通过从渐变纹理内映射颜色来叠加颜色,这样可以改变一个模型光照的明暗表现

    image

    高光反射部分的变硬,我们只需要基于它的公式修改计算规则即可:

    公式:

    高光反射光照颜色=光源的颜色×材质高光反射颜色×max(0, 标准化后顶点法线方向标准化后半角向量方向)光泽度高光反射光照颜色 = 光源的颜色 \times 材质高光反射颜色 \times \max(0,\ \overrightarrow{标准化后顶点法线方向} \cdot \overrightarrow{标准化后半角向量方向})^{光泽度}

    1. 标准化后顶点法线方向标准化后半角向量方向\overrightarrow{标准化后顶点法线方向} \cdot \overrightarrow{标准化后半角向量方向} 得到的结果就是 cosθ\cos\theta
    2. 半角向量方向=视角单位向量+入射光单位向量\overrightarrow{半角向量方向} = \overrightarrow{视角单位向量} + \overrightarrow{入射光单位向量}
    3. 光泽度是幂运算,假设光泽度为 nn ,相当于:(max(0,cosθ))n(max(0,\cos\theta))^n

    我们把 max(0, 标准化后顶点法线方向标准化后半角向量方向)光泽度\max(0,\ \overrightarrow{标准化后顶点法线方向} \cdot \overrightarrow{标准化后半角向量方向})^{光泽度} 这部分直接进行简化

    1
    2
    3
    fixed spec = dot(worldNormal, worldHalfDir);    // 直接利用法线和半角向量的点乘结果进行计算
    spec = step(_SpecularScale, spec); // 用一个阈值来进行比较,如果小于这个阈值,系数为0,大于阈值则系数为1
    fixed3 specular = _Specular.rgb * spec; // 直接用0或者1乘以高光颜色,进行叠加即可

    相当于之前平滑的值变化变得只有 1 和 0 两种情况,要不有,要不没有

  • 如何实现轮廓描边

    我们之前其实已经学习过模型描边效果的实现,它的基本原理是:

    使用两个 Pass​ 渲染对象:

    • 一个 Pass​ 用于渲染沿法线方向放大的模型
    • 一个 Pass​ 用于正常渲染正常模型

    相当于先用纯色渲染一次放大后的模型,再用模型本来的颜色覆盖重合部分。

    详见:US4L2——模型描边效果

    但是我们在实现卡通风格渲染时不会使用这种方式,我们将采用一种新的方式来制作轮廓描边。

    新的方法同样两个 Pass​ 渲染对象:

    • 一个 Pass 渲染背面将模型背面顶点沿法线方向偏移扩大
    • 一个 Pass 渲染正面正常渲染

    这样实现的效果会让模型上有重叠的结构出现描边效果

    imageimage

    注意:

    • 模型背面就是法线方向和摄像机面朝向呈锐角的部分
    • 模型正面就是法线方向和摄像机面朝向呈钝角的部分

实现漫反射变硬和外轮廓效果

  1. 新建 Shader ,将渐变纹理的综合实践相关代码直接复制过来

    代码详见:US3S2L10——渐变纹理综合实现

  2. 将复制过来的代码的 Pass​ 改为只渲染背面

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    // Base Pass 基础渲染通道
    Pass
    {
    Tags { "LightMode" = "ForwardBase" }
    Cull Back

    CGPROGRAM
    // ...
    ENDCG
    }
  3. 加入阴影接收相关内容

    1. 编译指令 #pragma multi_compile_fwdbase

    2. 内置文件 #include "AutoLight.cginc"

    3. v2f​ 结构体中加入 float3 worldPos​ 和 SHADOW_COORDS(4)

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      #pragma vertex vert
      #pragma fragment frag
      #pragma multi_compile_fwdbase

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

      struct v2f
      {
      float4 pos: SV_POSITION;
      float4 uv: TEXCOORD0; //可以使用一个float4来同时存储主要纹理的uv(xy存储)和法线纹理的uv(zw存储)
      float3 lightDir: TEXCOORD1; //相对于切线空间下的光的方向
      float3 viewDir: TEXCOORD2; //相对于切线空间下的视角方向
      float3 worldPos: TEXCOORD3; //世界空间下顶点坐标
      SHADOW_COORDS(4) //阴影坐标宏
      };
    4. 顶点着色器计算中加入

      世界空间顶点坐标计算:TRANSFER_SHADOW(o);

      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
      v2f vert (appdata_full v)
      {
      v2f data;

      data.pos = UnityObjectToClipPos(v.vertex); //计算裁剪空间下顶点坐标
      // 分别计算主纹理和法线纹理的缩放平移
      data.uv.xy = v.texcoord.xy * _MainTex_ST.xy + _MainTex_ST.zw;
      data.uv.zw = v.texcoord.xy * _BumpMap_ST.xy + _BumpMap_ST.zw;

      // 计算副切线
      float3 binormal = cross(normalize(v.tangent), normalize(v.normal)) * v.tangent.w;
      // 得到模型空间到切线空间的转换矩阵
      float3x3 rotation = float3x3(
      v.tangent.xyz, //切线
      binormal, //副切线
      v.normal //法线
      );

      data.lightDir = mul(rotation, ObjSpaceLightDir(v.vertex)); // 切线空间下的光的方向
      data.viewDir = mul(rotation, ObjSpaceViewDir(v.vertex)); // 切线空间下的视角方向
      data.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz; // 世界空间下的顶点坐标
      TRANSFER_SHADOW(data); // 阴影坐标转换宏

      return data;
      }
    5. 光照衰减相关计算

      UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos);​ 用来和 halfLambertNum​ 进行乘法运算

      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
      fixed4 frag (v2f i) : SV_Target
      {
      float4 packedNormal = tex2D(_BumpMap, i.uv.zw); // 获取法线纹理的颜色数据
      float3 tangentNormal = UnpackNormal(packedNormal); // 将颜色数据逆运算并解压缩,得到切线空间下法线数据
      // 将法线数据的xy乘以凹凸系数,根据xy修正z,避免凹凸系数影响光照亮度
      tangentNormal.xy *= _BumpScale;
      tangentNormal.z = sqrt(1.0 - saturate(dot(tangentNormal.xy, tangentNormal.xy)));

      // 计算带纹理颜色的BlinnPhong光照计算,这里使用已经计算好的切线数据
      fixed3 albedo = tex2D(_MainTex, i.uv.xy).rgb * _MainColor.rgb; // 反射率

      // 漫反射光照计算:使用已经计算完毕的切线数据和光照方向,结合渐变纹理,先计算渐变纹理的uv坐标,再计算漫反射颜色
      fixed halfLambertNum = dot(tangentNormal, normalize(i.lightDir)) * 0.5 + 0.5;

      UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos); // 使用UNITY_LIGHT_ATTENUATION计算光照衰减
      halfLambertNum *= atten;

      fixed3 diffuseColor = _LightColor0.rgb * albedo.rgb * tex2D(_RampTex, fixed2(halfLambertNum, halfLambertNum)).rgb;
      // 高光反射光照计算:这里需要使用已经计算完毕的切线数据和光照方向
      float3 halfA = normalize(normalize(i.viewDir) + normalize(i.lightDir)); // 半角向量
      fixed3 specularColor = _LightColor0.rgb * _SpecularColor.rgb * pow(max(0, dot(tangentNormal, halfA)), _SpecularNum);
      // 最终颜色计算
      fixed3 color = UNITY_LIGHTMODEL_AMBIENT.rgb * albedo + diffuseColor + specularColor;

      return fixed4(color.rgb, 1);
      }
    6. FallBack "Diffuse"

  4. 处理渲染外轮廓(描边)

    1. 加入边缘线颜色和宽度属性 _OutLineColor​、_OutLineWidth

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      Properties
      {
      _MainColor("MainColor", Color) = (1, 1, 1, 1)
      _MainTex("MainTex", 2D) = ""{}
      _BumpMap("BumpMap", 2D) = ""{}
      _BumpScale("BumpScale", Range(0, 1)) = 1
      _RampTex("_RampTex", 2D) = ""{}
      _SpecularColor("SpecularColor", Color) = (1, 1, 1, 1)
      _SpecularNum("SpecularNum", Range(0, 20)) = 18
      _OutLineColor("OutLineColor", Color) = (0, 0, 0, 0)
      _OutLineWidth("OutLineWidth", Float) = 0.005
      }
    2. 加入背面渲染的 Pass​,用于处理轮廓描边

      使用 Cull Front​ 剔除正面,让背面的顶点沿其法线方向拓展
      这里使用 Name​ 为 Pass​ 命名,方便后续 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
      // OutLine Pass 描边渲染通道
      Pass
      {
      Name "OutLinePass"
      Cull Front

      CGPROGRAM
      #pragma vertex vert
      #pragma fragment frag

      #include "UnityCG.cginc"

      struct v2f
      {
      float4 pos: SV_POSITION;
      };

      fixed4 _OutLineColor; // 边缘线颜色
      float _OutLineWidth; // 边缘线宽度

      v2f vert(appdata_base v)
      {
      v2f o;
      // 把背面看不到的顶点朝法线方向往外扩展
      v.vertex.xyz += normalize(v.normal) * _OutLineWidth;
      o.pos = UnityObjectToClipPos(v.vertex);

      return o;
      }

      fixed4 frag(v2f i) : SV_Target
      {
      // 直接返回边缘线颜色,相当于背面是纯色
      return _OutLineColor;
      }

      ENDCG
      }

实现高光变硬效果

主要修改高光反射颜色计算相关内容

  1. 计算出半角向量

  2. 用法线和半角向量进行点乘

  3. 用点乘的结果和一个阈值进行比较 如果小于阈值,取0、大于阈值,取1

    对原来的 _SpecularNum​ 属性修改,让其变为光照的阈值

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    Properties
    {
    _MainColor("MainColor", Color) = (1, 1, 1, 1)
    _MainTex("MainTex", 2D) = ""{}
    _BumpMap("BumpMap", 2D) = ""{}
    _BumpScale("BumpScale", Range(0, 1)) = 1
    _RampTex("_RampTex", 2D) = ""{}
    _SpecularColor("SpecularColor", Color) = (1, 1, 1, 1)
    _SpecularNum("SpecularNum", Range(0, 1)) = 0.5 // 高光阈值
    _OutLineColor("OutLineColor", Color) = (0, 0, 0, 0)
    _OutLineWidth("OutLineWidth", Float) = 0.005
    }
  4. 利用这个结果和高光颜色进行叠加

  5. 最后参与到布林方颜色公式计算中

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
fixed4 frag (v2f i) : SV_Target
{
float4 packedNormal = tex2D(_BumpMap, i.uv.zw); // 获取法线纹理的颜色数据
float3 tangentNormal = UnpackNormal(packedNormal); // 将颜色数据逆运算并解压缩,得到切线空间下法线数据
// 将法线数据的xy乘以凹凸系数,根据xy修正z,避免凹凸系数影响光照亮度
tangentNormal.xy *= _BumpScale;
tangentNormal.z = sqrt(1.0 - saturate(dot(tangentNormal.xy, tangentNormal.xy)));

// 计算带纹理颜色的BlinnPhong光照计算,这里使用已经计算好的切线数据
fixed3 albedo = tex2D(_MainTex, i.uv.xy).rgb * _MainColor.rgb; // 反射率

// 漫反射光照计算:使用已经计算完毕的切线数据和光照方向,结合渐变纹理,先计算渐变纹理的uv坐标,再计算漫反射颜色
fixed halfLambertNum = dot(tangentNormal, normalize(i.lightDir)) * 0.5 + 0.5;

UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos); // 使用UNITY_LIGHT_ATTENUATION计算光照衰减
halfLambertNum *= atten;

fixed3 diffuseColor = _LightColor0.rgb * albedo.rgb * tex2D(_RampTex, fixed2(halfLambertNum, halfLambertNum)).rgb;
// 高光反射光照计算:这里需要使用已经计算完毕的切线数据和光照方向
float3 halfA = normalize(normalize(i.viewDir) + normalize(i.lightDir)); // 半角向量
fixed spec = dot(normalize(tangentNormal), normalize(halfA));
spec = step(_SpecularNum, spec); // 用高光阈值与计算结果比较,小于阈值取0,大于阈值取1
fixed3 specularColor = _LightColor0.rgb * _SpecularColor.rgb * spec;

//fixed3 specularColor = _LightColor0.rgb * _SpecularColor.rgb * pow(max(0, dot(tangentNormal, halfA)), _SpecularNum);
// 最终颜色计算
fixed3 color = UNITY_LIGHTMODEL_AMBIENT.rgb * albedo + diffuseColor + specularColor;

return fixed4(color.rgb, 1);
}

显示效果

完整 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
Shader "TeachShader/ToonShader"
{
Properties
{
_MainColor("MainColor", Color) = (1, 1, 1, 1)
_MainTex("MainTex", 2D) = ""{}
_BumpMap("BumpMap", 2D) = ""{}
_BumpScale("BumpScale", Range(0, 1)) = 1
_RampTex("_RampTex", 2D) = ""{}
_SpecularColor("SpecularColor", Color) = (1, 1, 1, 1)
_SpecularNum("SpecularNum", Range(0, 1)) = 0.5 // 高光阈值
_OutLineColor("OutLineColor", Color) = (0, 0, 0, 0)
_OutLineWidth("OutLineWidth", Float) = 0.005
}
SubShader
{
// OutLine Pass 描边渲染通道
Pass
{
Name "OutLinePass"
Cull Front

CGPROGRAM
#pragma vertex vert
#pragma fragment frag

#include "UnityCG.cginc"

struct v2f
{
float4 pos: SV_POSITION;
};

fixed4 _OutLineColor; // 边缘线颜色
float _OutLineWidth; // 边缘线宽度

v2f vert(appdata_base v)
{
v2f o;
// 把背面看不到的顶点朝法线方向往外扩展
v.vertex.xyz += normalize(v.normal) * _OutLineWidth;
o.pos = UnityObjectToClipPos(v.vertex);

return o;
}

fixed4 frag(v2f i) : SV_Target
{
// 直接返回边缘线颜色,相当于背面是纯色
return _OutLineColor;
}

ENDCG
}

// Base Pass 基础渲染通道
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"

struct v2f
{
float4 pos: SV_POSITION;
float4 uv: TEXCOORD0; //可以使用一个float4来同时存储主要纹理的uv(xy存储)和法线纹理的uv(zw存储)
float3 lightDir: TEXCOORD1; //相对于切线空间下的光的方向
float3 viewDir: TEXCOORD2; //相对于切线空间下的视角方向
float3 worldPos: TEXCOORD3; //世界空间下顶点坐标
SHADOW_COORDS(4) //阴影坐标宏
};

float4 _MainColor; //漫反射颜色
sampler2D _MainTex; //颜色纹理
float4 _MainTex_ST; //颜色纹理的缩放和平移
sampler2D _BumpMap; //法线纹理
float4 _BumpMap_ST; //法线纹理的缩放和平移
sampler2D _RampTex; //渐变纹理
float4 _RampTex_ST; //渐变纹理的缩放和平移(很少使用)
float _BumpScale; //凹凸程度
float4 _SpecularColor; //高光颜色
fixed _SpecularNum; //高光阈值

v2f vert (appdata_full v)
{
v2f data;

data.pos = UnityObjectToClipPos(v.vertex); //计算裁剪空间下顶点坐标
// 分别计算主纹理和法线纹理的缩放平移
data.uv.xy = v.texcoord.xy * _MainTex_ST.xy + _MainTex_ST.zw;
data.uv.zw = v.texcoord.xy * _BumpMap_ST.xy + _BumpMap_ST.zw;

// 计算副切线
float3 binormal = cross(normalize(v.tangent), normalize(v.normal)) * v.tangent.w;
// 得到模型空间到切线空间的转换矩阵
float3x3 rotation = float3x3(
v.tangent.xyz, //切线
binormal, //副切线
v.normal //法线
);

data.lightDir = mul(rotation, ObjSpaceLightDir(v.vertex)); // 切线空间下的光的方向
data.viewDir = mul(rotation, ObjSpaceViewDir(v.vertex)); // 切线空间下的视角方向
data.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz; // 世界空间下的顶点坐标
TRANSFER_SHADOW(data); // 阴影坐标转换宏

return data;
}

fixed4 frag (v2f i) : SV_Target
{
float4 packedNormal = tex2D(_BumpMap, i.uv.zw); // 获取法线纹理的颜色数据
float3 tangentNormal = UnpackNormal(packedNormal); // 将颜色数据逆运算并解压缩,得到切线空间下法线数据
// 将法线数据的xy乘以凹凸系数,根据xy修正z,避免凹凸系数影响光照亮度
tangentNormal.xy *= _BumpScale;
tangentNormal.z = sqrt(1.0 - saturate(dot(tangentNormal.xy, tangentNormal.xy)));

// 计算带纹理颜色的BlinnPhong光照计算,这里使用已经计算好的切线数据
fixed3 albedo = tex2D(_MainTex, i.uv.xy).rgb * _MainColor.rgb; // 反射率

// 漫反射光照计算:使用已经计算完毕的切线数据和光照方向,结合渐变纹理,先计算渐变纹理的uv坐标,再计算漫反射颜色
fixed halfLambertNum = dot(tangentNormal, normalize(i.lightDir)) * 0.5 + 0.5;

UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos); // 使用UNITY_LIGHT_ATTENUATION计算光照衰减
halfLambertNum *= atten;

fixed3 diffuseColor = _LightColor0.rgb * albedo.rgb * tex2D(_RampTex, fixed2(halfLambertNum, halfLambertNum)).rgb;
// 高光反射光照计算:这里需要使用已经计算完毕的切线数据和光照方向
float3 halfA = normalize(normalize(i.viewDir) + normalize(i.lightDir)); // 半角向量
fixed spec = dot(normalize(tangentNormal), normalize(halfA));
spec = step(_SpecularNum, spec); // 用高光阈值与计算结果比较,小于阈值取0,大于阈值取1
fixed3 specularColor = _LightColor0.rgb * _SpecularColor.rgb * spec;

//fixed3 specularColor = _LightColor0.rgb * _SpecularColor.rgb * pow(max(0, dot(tangentNormal, halfA)), _SpecularNum);
// 最终颜色计算
fixed3 color = UNITY_LIGHTMODEL_AMBIENT.rgb * albedo + diffuseColor + specularColor;

return fixed4(color.rgb, 1);
}
ENDCG
}
}
Fallback "Diffuse"
}

将高光阈值设置为0.95,边缘线宽度设置为0.005

image

可见,图内的机器人出现了的阴影和描边效果,光照颜色和高光颜色的过渡也变得更硬了