US4L7——素描风格渲染

素描风格渲染

素描风格渲染(Hatching Style Rendering),是一种非真实感渲染(NPR),主要目的是使3D模型看起来像手绘素描的视觉效果。
这种风格的渲染常用于游戏、动画和电影中,用来创造一种独特的艺术风格

imageimage

素描风格渲染基本原理

一句话总结素描风格渲染基本原理:
用漫反射系数决定采样权重,在多张具有不同密度和方向的素描纹理中,进行采样,并将采样结果进行叠加得到最终效果

关键点:

  • 多张具有不同密度和方向的素描纹理

    美术需要提供多张素描纹理,我们之后会根据不同位置的光照强度,决定从哪种纹理中进行采样

    image

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    Properties
    {
    _Color("Color", Color) = (1, 1, 1, 1) // 颜色叠加
    _TileFactor("TileFactor", Float) = 1 // 纹理平铺密度
    _OutLineColor("OutLineColor", Color) = (0, 0, 0, 1) // 边缘线颜色
    _OutLineWidth("OutLineWidth", Range(0, 1)) = 0.05 // 边缘线宽度
    _Sketch0("Sketch0", 2D) = {} // 第一张素描纹理
    _Sketch1("Sketch1", 2D) = {} // 第二张素描纹理
    _Sketch2("Sketch2", 2D) = {} // 第三张素描纹理
    _Sketch3("Sketch3", 2D) = {} // 第四张素描纹理
    _Sketch4("Sketch4", 2D) = {} // 第五张素描纹理
    _Sketch5("Sketch5", 2D) = {} // 第六张素描纹理
    }
  • ​​漫反射系数决定采样权重

    我们通过兰伯特光照模型中的:

    max(0,标准化后物体表面法线向量标准化后光源方向向量)×(素描纹理数+1)\max(0,\overrightarrow{标准化后物体表面法线向量} \cdot \overrightarrow{标准化后光源方向向量}) \times (素描纹理数 + 1)

    将漫反射光照强度 0~1 扩充到 0~N,如果是 6 张素描纹理,那么就是 0~7,
    根据不同顶点的不同光照,决定在哪一张纹理中进行采样的权重更大,该权重决定最后的颜色叠加

    • 6~7:不在素描纹理中采样
    • 5~6:第1张素描纹理中采样
    • 3~4:第2、3张纹理中采样
    • 4~5:第1、2素描张纹理中采样
    • 2~3:第3、4张纹理中采样
    • 1~2:第4、5张纹理中采样
    • 0~1:第5、6张纹理中采样
    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
    v2f o;
    o.vertex = UnityObjectToClipPos(v.vertex);
    o.uv = v.texcoord.xy * _TileFactor;
    // 计算漫反射光照强度
    fixed3 worldLightDir = normalize(WorldSpaceLightDir(v.vertex)); // 世界空间下的光照方向
    fixed3 worldNormal = UnityObjectToWorldNormal(v.normal); // 世界空间法线方向转换
    fixed diffuse = max(0, dot(worldLightDir, worldNormal)); // 漫反射光照强度计算
    diffuse = diffuse * 7.0; // 将光照强度从0~1变化到0~7范围

    // 用于记录6张纹理采样的权重,默认都是0,0意味着之后不会使用对应纹理颜色中采样的颜色
    o.sketchWeights0 = fixed3(0, 0, 0);
    o.sketchWeights1 = fixed3(0, 0, 0);
    // 根据光照强度,决定各个图片的采样权重
    if (diffuse > 6.0) {
    // 认为是最亮的部分,不修改任何权重值,意味着不会使用任何素描纹理中采样的颜色
    } else if (diffuse > 5.0) {
    o.sketchWeights0.x = diffuse - 5.0; // 修改第1张素描纹理权重
    } else if (diffuse > 4.0) {
    o.sketchWeights0.x = diffuse - 4.0; // 修改第1张素描纹理权重
    o.sketchWeights0.y = 1.0 - o.sketchWeights0.x; // 修改第2张素描纹理权重
    } else if (diffuse > 3.0) {
    o.sketchWeights0.y = diffuse - 3.0; // 修改第2张素描纹理权重
    o.sketchWeights0.z = 1.0 - o.sketchWeights0.y; // 修改第3张素描纹理权重
    } else if (diffuse > 2.0) {
    o.sketchWeights0.z = diffuse - 2.0; // 修改第3张素描纹理权重
    o.sketchWeights1.x = 1.0 - o.sketchWeights0.z; // 修改第4张素描纹理权重
    } else if (diffuse > 1.0) {
    o.sketchWeights1.x = diffuse - 1.0; // 修改第4张素描纹理权重
    o.sketchWeights1.y = 1.0 - o.sketchWeights1.x; // 修改第5张素描纹理权重
    } else {
    o.sketchWeights1.y = diffuse; // 修改第5张素描纹理权重
    o.sketchWeights1.z = 1.0 - o.sketchWeights1.y; // 修改第6张素描纹理权重
    }
  • 采样结果进行叠加

    根据之前的权重计算,越亮的地方、越趋近于白色,或使用的素描纹理中线条更少更稀疏,而越暗的地方使用的素描纹理中线条更密集。
    因此我们只需要使用之前的权重值和纹理采样结果相乘,最后将纹理颜色进行叠加即可。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    fixed4 frag(v2f i) : SV_Target
    {
    // 如果之前的权重没有设置,默认为0,对应纹理颜色最终就是(0,0,0,0)
    fixed4 hatchTex0 = tex2D(_Hatch0, i.uv) * i.hatchWeights0.x; // 在第1张纹理中采样,并乘以权重1
    fixed4 hatchTex1 = tex2D(_Hatch1, i.uv) * i.hatchWeights0.y; // 在第2张纹理中采样,并乘以权重2
    fixed4 hatchTex2 = tex2D(_Hatch2, i.uv) * i.hatchWeights0.z; // 在第3张纹理中采样,并乘以权重3
    fixed4 hatchTex3 = tex2D(_Hatch3, i.uv) * i.hatchWeights1.x; // 在第4张纹理中采样,并乘以权重4
    fixed4 hatchTex4 = tex2D(_Hatch4, i.uv) * i.hatchWeights1.y; // 在第5张纹理中采样,并乘以权重5
    fixed4 hatchTex5 = tex2D(_Hatch5, i.uv) * i.hatchWeights1.z; // 在第6张纹理中采样,并乘以权重6
    // 6~7区间 所有采样颜色都是(0,0,0,0),代表最亮的地方,计算白色叠加值
    fixed4 whiteColor = fixed4(1, 1, 1, 1) *
    (1 - i.hatchWeight0.x - i.hatchWeight0.y - i.hatchWeight0.z
    - i.hatchWeight1.x - i.hatchWeight1.y - i.hatchWeight1.z);
    // 最终将所有纹理颜色和白色叠加值相加,得到最终颜色
    fixed4 hatchColor = hatchTex0 + hatchTex1 + hatchTex2 + hatchTex3 + hatchTex4 + hatchTex5 + whiteColor;
    return hatchColor;
    }

素描风格渲染具体实现

纹理资源导入、相关属性添加

  1. 使用如下的素描纹理资源

  2. 新建 Shader 命名为 Sketch​,删除无用代码

  3. 添加属性

    • 颜色属性 Color​ —— 用于颜色叠加
    • 平铺系数 TileFactor​ —— 用于平铺纹理,让采样细节更多
    • 素描纹理 Sketch1~6​ —— 用于采样模拟素描表现效果
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    Properties
    {
    _Color("Color", Color) = (1, 1, 1, 1) // 整体颜色叠加
    _TileFactor("TileFactor", Float) = 1 // 纹理平铺密度
    _Sketch0("Sketch0", 2D) = {} // 第1张素描纹理
    _Sketch1("Sketch1", 2D) = {} // 第2张素描纹理
    _Sketch2("Sketch2", 2D) = {} // 第3张素描纹理
    _Sketch3("Sketch3", 2D) = {} // 第4张素描纹理
    _Sketch4("Sketch4", 2D) = {} // 第5张素描纹理
    _Sketch5("Sketch5", 2D) = {} // 第6张素描纹理
    }
  4. 进行属性映射

权重计算(顶点着色器函数逻辑)

  1. v2f​ 结构体声明

    顶点位置、UV、用两个 fixed3​ 记录素描纹理权重

    1
    2
    3
    4
    5
    6
    7
    struct v2f
    {
    float4 vertex : SV_POSITION;
    float2 uv : TEXCOORD0;
    fixed3 sketchWeights0 : TEXCOORD1; // x,y,z 分别代表1,2,3张素描的权重
    fixed3 sketchWeights1 : TEXCOORD2; // x,y,z 分别代表4,5,6张素描的权重
    };
  2. 顶点着色器函数实现

    1. 顶点坐标转换
    2. UV 坐标平铺缩放 让纹理*平铺系数
    3. 世界空间光照方向、世界空间法线转换
    4. 兰伯特漫反射光照系数计算
    5. 将光照系数 从 0~1 扩充到 0~7
    6. 根据系数决定素描纹理权重
    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
    v2f vert (appdata_base v)
    {
    v2f o;
    o.vertex = UnityObjectToClipPos(v.vertex);
    o.uv = v.texcoord.xy * _TileFactor;
    // 计算漫反射光照强度
    fixed3 worldLightDir = normalize(WorldSpaceLightDir(v.vertex)); // 世界空间下的光照方向
    fixed3 worldNormal = UnityObjectToWorldNormal(v.normal); // 世界空间法线方向转换
    fixed diffuse = max(0, dot(worldLightDir, worldNormal)); // 漫反射光照强度计算
    diffuse = diffuse * 7.0; // 将光照强度从0~1变化到0~7范围

    // 用于记录6张纹理采样的权重,默认都是0,0意味着之后不会使用对应纹理颜色中采样的颜色
    o.sketchWeights0 = fixed3(0, 0, 0);
    o.sketchWeights1 = fixed3(0, 0, 0);
    // 根据光照强度,决定各个图片的采样权重
    if (diffuse > 6.0)
    {
    // 认为是最亮的部分,不修改任何权重值,意味着不会使用任何素描纹理中采样的颜色
    }
    else if (diffuse > 5.0)
    {
    o.sketchWeights0.x = diffuse - 5.0; // 修改第1张素描纹理权重
    }
    else if (diffuse > 4.0)
    {
    o.sketchWeights0.x = diffuse - 4.0; // 修改第1张素描纹理权重
    o.sketchWeights0.y = 1.0 - o.sketchWeights0.x; // 修改第2张素描纹理权重
    }
    else if (diffuse > 3.0)
    {
    o.sketchWeights0.y = diffuse - 3.0; // 修改第2张素描纹理权重
    o.sketchWeights0.z = 1.0 - o.sketchWeights0.y; // 修改第3张素描纹理权重
    }
    else if (diffuse > 2.0)
    {
    o.sketchWeights0.z = diffuse - 2.0; // 修改第3张素描纹理权重
    o.sketchWeights1.x = 1.0 - o.sketchWeights0.z; // 修改第4张素描纹理权重
    }
    else if (diffuse > 1.0)
    {
    o.sketchWeights1.x = diffuse - 1.0; // 修改第4张素描纹理权重
    o.sketchWeights1.y = 1.0 - o.sketchWeights1.x; // 修改第5张素描纹理权重
    }
    else
    {
    o.sketchWeights1.y = diffuse; // 修改第5张素描纹理权重
    o.sketchWeights1.z = 1.0 - o.sketchWeights1.y; // 修改第6张素描纹理权重
    }

    return o;
    }

颜色采样(片元着色器函数逻辑)

在片元着色器中实现:

  1. 对 16 张纹理进行采样 并乘以权重 得到各纹理采样颜色
  2. 根据 16 张纹理权重计算出白色高光部分颜色
  3. 将 1~6 张纹理采样颜色 和 白色部分 相加得到最终叠加颜色
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
fixed4 frag (v2f i) : SV_Target
{
// 如果之前的权重没有设置,默认为0,对应纹理颜色最终就是(0,0,0,0)
fixed4 sketchTex0 = tex2D(_Sketch0, i.uv) * i.sketchWeights0.x; // 在第1张纹理中采样,并乘以权重1
fixed4 sketchTex1 = tex2D(_Sketch1, i.uv) * i.sketchWeights0.y; // 在第2张纹理中采样,并乘以权重2
fixed4 sketchTex2 = tex2D(_Sketch2, i.uv) * i.sketchWeights0.z; // 在第3张纹理中采样,并乘以权重3
fixed4 sketchTex3 = tex2D(_Sketch3, i.uv) * i.sketchWeights1.x; // 在第4张纹理中采样,并乘以权重4
fixed4 sketchTex4 = tex2D(_Sketch4, i.uv) * i.sketchWeights1.y; // 在第5张纹理中采样,并乘以权重5
fixed4 sketchTex5 = tex2D(_Sketch5, i.uv) * i.sketchWeights1.z; // 在第6张纹理中采样,并乘以权重6
// 6~7区间 所有采样颜色都是(0,0,0,0),代表最亮的地方,计算白色叠加值
fixed4 whiteColor = fixed4(1, 1, 1, 1) *
(1 - i.sketchWeights0.x - i.sketchWeights0.y - i.sketchWeights0.z
- i.sketchWeights1.x - i.sketchWeights1.y - i.sketchWeights1.z);
// 最终将所有纹理颜色和白色叠加值相加,得到最终颜色
fixed4 sketchColor = sketchTex0 + sketchTex1 + sketchTex2 + sketchTex3 + sketchTex4 + sketchTex5 + whiteColor;
return sketchColor;
}

外轮廓、阴影相关添加

  1. 外轮廓

    直接复用卡通风格渲染 Shader 的外轮廓 Pass​ 代码,详见:US4L6——卡通风格渲染

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    SubShader
    {
    Tags { "RenderType"="Opaque" }

    // OutLine Pass 描边渲染通道
    UsePass "TeachShader/ToonShader/OutLinePass"

    // Bass Pass 基础渲染通道
    Pass {/*...*/}
    }
  2. 阴影相关添加

    • 加入 SHADOW_COORDS​、TRANSFER_SHADOW​、UNITY_LIGHT_ATTENUATION
    • 加入 FallBack "Diffuse"

完整 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
Shader "TeachShader/Sketch"
{
Properties
{
_Color("Color", Color) = (1, 1, 1, 1) // 整体颜色叠加
_TileFactor("TileFactor", Float) = 1 // 纹理平铺密度
_OutLineColor("OutLineColor", Color) = (0, 0, 0, 1) // 边缘线颜色
_OutLineWidth("OutLineWidth", Range(0, 1)) = 0.005 // 边缘线宽度
_Sketch0("Sketch0", 2D) = ""{} // 第1张素描纹理
_Sketch1("Sketch1", 2D) = ""{} // 第2张素描纹理
_Sketch2("Sketch2", 2D) = ""{} // 第3张素描纹理
_Sketch3("Sketch3", 2D) = ""{} // 第4张素描纹理
_Sketch4("Sketch4", 2D) = ""{} // 第5张素描纹理
_Sketch5("Sketch5", 2D) = ""{} // 第6张素描纹理
}
SubShader
{
Tags { "RenderType"="Opaque" }

// OutLine Pass 描边渲染通道
UsePass "TeachShader/ToonShader/OutLinePass"

// Bass Pass 基础渲染通道
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma multi_compile_fwdbase

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

fixed4 _Color;
fixed _TileFactor;
sampler2D _Sketch0;
sampler2D _Sketch1;
sampler2D _Sketch2;
sampler2D _Sketch3;
sampler2D _Sketch4;
sampler2D _Sketch5;

struct v2f
{
float4 vertex : SV_POSITION;
float2 uv : TEXCOORD0;
fixed3 sketchWeights0 : TEXCOORD1; // x,y,z 分别代表1,2,3张素描的权重
fixed3 sketchWeights1 : TEXCOORD2; // x,y,z 分别代表4,5,6张素描的权重
float3 worldPos: TEXCOORD3; //世界空间下顶点坐标
SHADOW_COORDS(4) //阴影坐标宏
};

v2f vert (appdata_base v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = v.texcoord.xy * _TileFactor;
// 计算漫反射光照强度
fixed3 worldLightDir = normalize(WorldSpaceLightDir(v.vertex)); // 世界空间下的光照方向
fixed3 worldNormal = UnityObjectToWorldNormal(v.normal); // 世界空间法线方向转换
fixed diffuse = max(0, dot(worldLightDir, worldNormal)); // 漫反射光照强度计算
diffuse = diffuse * 7.0; // 将光照强度从0~1变化到0~7范围

// 用于记录6张纹理采样的权重,默认都是0,0意味着之后不会使用对应纹理颜色中采样的颜色
o.sketchWeights0 = fixed3(0, 0, 0);
o.sketchWeights1 = fixed3(0, 0, 0);
// 根据光照强度,决定各个图片的采样权重
if (diffuse > 6.0)
{
// 认为是最亮的部分,不修改任何权重值,意味着不会使用任何素描纹理中采样的颜色
}
else if (diffuse > 5.0)
{
o.sketchWeights0.x = diffuse - 5.0; // 修改第1张素描纹理权重
}
else if (diffuse > 4.0)
{
o.sketchWeights0.x = diffuse - 4.0; // 修改第1张素描纹理权重
o.sketchWeights0.y = 1.0 - o.sketchWeights0.x; // 修改第2张素描纹理权重
}
else if (diffuse > 3.0)
{
o.sketchWeights0.y = diffuse - 3.0; // 修改第2张素描纹理权重
o.sketchWeights0.z = 1.0 - o.sketchWeights0.y; // 修改第3张素描纹理权重
}
else if (diffuse > 2.0)
{
o.sketchWeights0.z = diffuse - 2.0; // 修改第3张素描纹理权重
o.sketchWeights1.x = 1.0 - o.sketchWeights0.z; // 修改第4张素描纹理权重
}
else if (diffuse > 1.0)
{
o.sketchWeights1.x = diffuse - 1.0; // 修改第4张素描纹理权重
o.sketchWeights1.y = 1.0 - o.sketchWeights1.x; // 修改第5张素描纹理权重
}
else
{
o.sketchWeights1.y = diffuse; // 修改第5张素描纹理权重
o.sketchWeights1.z = 1.0 - o.sketchWeights1.y; // 修改第6张素描纹理权重
}

o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz; // 世界空间下的顶点坐标
TRANSFER_SHADOW(o); // 阴影坐标转换宏

return o;
}

fixed4 frag (v2f i) : SV_Target
{
// 如果之前的权重没有设置,默认为0,对应纹理颜色最终就是(0,0,0,0)
fixed4 sketchTex0 = tex2D(_Sketch0, i.uv) * i.sketchWeights0.x; // 在第1张纹理中采样,并乘以权重1
fixed4 sketchTex1 = tex2D(_Sketch1, i.uv) * i.sketchWeights0.y; // 在第2张纹理中采样,并乘以权重2
fixed4 sketchTex2 = tex2D(_Sketch2, i.uv) * i.sketchWeights0.z; // 在第3张纹理中采样,并乘以权重3
fixed4 sketchTex3 = tex2D(_Sketch3, i.uv) * i.sketchWeights1.x; // 在第4张纹理中采样,并乘以权重4
fixed4 sketchTex4 = tex2D(_Sketch4, i.uv) * i.sketchWeights1.y; // 在第5张纹理中采样,并乘以权重5
fixed4 sketchTex5 = tex2D(_Sketch5, i.uv) * i.sketchWeights1.z; // 在第6张纹理中采样,并乘以权重6
// 6~7区间 所有采样颜色都是(0,0,0,0),代表最亮的地方,计算白色叠加值
fixed4 whiteColor = fixed4(1, 1, 1, 1) *
(1 - i.sketchWeights0.x - i.sketchWeights0.y - i.sketchWeights0.z
- i.sketchWeights1.x - i.sketchWeights1.y - i.sketchWeights1.z);
// 最终将所有纹理颜色和白色叠加值相加,得到最终颜色
fixed4 sketchColor = sketchTex0 + sketchTex1 + sketchTex2 + sketchTex3 + sketchTex4 + sketchTex5 + whiteColor;

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

return fixed4(sketchColor.rgb * atten * _Color.rgb, 1);
}
ENDCG
}
}

Fallback "Diffuse"
}

显示效果

设置纹理平铺密度为8,边缘线宽度为0.005

image

可见,物体的光照效果由素描贴图来呈现,越暗的地方笔触密度越高