0%

【Unity Shader】(九)非真实感渲染

尽管游戏渲染一般都是以照相写实主义(photorealism)作为主要目标,但也有许多游戏使用了非真实感渲染(Non-Photorealistic Rendering, NPR)的方法来渲染游戏画面。非真实感渲染的一个主要目标是,使用一些渲染方法使得画面达到和某些特殊的绘画风格相似的效果,例如卡通、水彩风格等。

1 卡通风格的渲染

卡通风格是游戏中常见的一种渲染风格。使用这种风格的游戏画面通常有一些共有的特点,例如物体都被黑色的线条描边,以及分明的明暗变化等。

要实现卡通渲染有很多方法,其中之一就是使用**基于色调的着色技术 (tone-based shading)**。实现中,我们往往会使用漫反射系数对一张一维纹理进行采样,以控制漫反射的色调,我们在之前的渐变纹理中实现过这样的效果。卡通风格的高光效果也和我们之前学习的光照不同。在卡通风格中,模型的高光往往是一块块分界明显的纯色区域。

除了光照模型不同外,卡通风格通常还需要在物体边缘部分绘制轮廓。在前两节我们曾使用屏幕后处理技术对屏幕图像进行描边。在本节,我们将会使用基于模型的描边方法,这种方法的实现更加简单,而且在很多情况下也能得到不错的效果。

1.1 渲染轮廓线

在实时渲染中,轮廓线的渲染是应用非常广泛的一种效果。近 20 年来,有许多绘制模型轮廓线的方法被先后提出来,在《RTR3》中作者将这些方法分为了 5 类:

  • 基于观察角度和表面法线的轮廓线渲染。这种方法使用视角方向和表面法线的点乘结果来得到轮廓线的信息。这种方法简单快速,可以在一个 Pass 中就得到渲染结果,但局限性很大,很多模型渲染出来的描边效果都不尽如人意。
  • 过程式几何轮廓线渲染。这种方法的核心是使用两个 Pass 渲染。第一个 Pass 渲染背面的面片,并使用某些技术让它的轮廓可见;第二个 Pass 再正常渲染正面的面片。这种方法的优点在于快速有效,并且适用于绝大多数表面平滑的模型,但它的缺点是不适合类似于立方体这样的平整模型。
  • 基于图像处理的轮廓线渲染。我们之前使用的边缘检测的方法就属于这个类别。这种方法的优点在于,可以适用于任何种类的模型。但它也有自身的局限所在,一些深度和法线变化很小的轮廓无法被检测出来,例如桌子上的纸张。
  • 基于轮廓边检测的轮廓线渲染。上面提到的各种方法,一个最大的问题是,无法控制轮廓线的风格渲染。对于一些情况,我们希望可以渲染出独特风格的轮廓线,例如水墨风格等。为此,我们希望可以检测出精确的轮廓边,然后直接渲染它们。检测一条边是否是轮廓边的公式很简单,我们只需要检查和这条边相邻的两个三角面片是否满足以下条件:

$$
(\vec n_0·\vec v>0) \neq (\vec n_1·\vec v>0)
$$

其中 $\vec n_0$ 和 $\vec n_1$ 是两个相邻三角面片的法向量,$\vec v$ 是从视角到该边上任意顶点的方向。上述公式本质在于检查两个相邻的三角面片是否一个朝正面、一个朝背面。我们可以在几何着色器 (Geometry Shader) 的帮助下实现上面的检测过程。当然,这种方法也有缺点,除了实现相对复杂外,它还会有动画连贯性的问题。也就是说,由于是逐帧单独提取轮廓,所以在帧与帧之间会出现跳跃性。

  • 最后一个种类就是混合了上述的几种渲染方法。例如,首先找到精确的轮廓边,把模型和轮廓边渲染到纹理中,再使用图像处理的方法识别出轮廓线,并在图像空间下进行风格化渲染。

在本节中,我们将会在 Unity 中使用过程式几何轮廓线渲染的方法来对模型进行轮廓描边。我们将使用两个 Pass 渲染模型:在第一个 Pass 中,我们会使用轮廓线颜色渲染整个背面的面片,并在视角空间下把模型顶点沿着法线方向向外扩张一段距离,以此来让背部轮廓线可见。代码如下:

1
viewPos = viewPos + viewNormal * Outline;

但是,如果直接使用顶点法线进行扩展,对于一些内凹的模型,就可能发生背面面片遮挡正面面片的情况。为了尽可能防止出现这样的情况,在扩张背面顶点之前,我们首先对顶点法线的 z 分量进行处理,使它们等于一个定值,然后把法线归一化后再对顶点进行扩张。这样的好处在于,扩展后的背面更加扁平化,从而降低了遮挡正面面片的可能性。代码如下:

1
2
3
viewNormal.z = -0.5;
viewNormal = normalize(viewNormal);
viewPos = viewPos + viewNormal * Outline;

1.2 添加高光

前面提到过,卡通风格中的高光往往是模型上一块块分界明显的纯色区域。为了实现这种效果,我们就不能再使用之前学习的光照模型。类似于 Blinn-Phong 光照的高光计算,我们先计算法线和半程向量的点积,但不进行指数计算,而是将点积结果和一个阈值对比,如果小于该阈值,则高光系数为 0,否则高光系数为 1。

1
2
float spec = dot(worldNormal, worldHalfDir);
spec = step(threshold, spec);

step 函数是 CG 的内置函数,它比较参考值和给定值的大小,如果给定值大于参考值返回 1,否则返回 0。

但是,这种粗暴的判断方法会在高光区域的边界造成锯齿,如下图。

image-20220409173101155

出现这种问题的原因在于,高光区域的边缘不是平滑渐变的,而是由 0 突变到 1 。要想对其进行抗锯齿处理,我们可以在边界处很小一块区域内进行平滑处理:

1
2
float spec = dot(worldNormal, worldHalfDir);
spec = lerp(0, 1, smoothstep(-w, w, spec - threshold));

smoothstep 函数是 CG 的内置函数,其中 w 是一个很小的值,当 spec - threshold 小于 -w 时返回 0,当 spec - threshold 大于 w 时返回 1,在 [-w, w] 之间时,在 0 到 1 之间插值。这样的效果是,我们可以在 [-w, w] 区间内,即高光区域的边界附近,得到一个从 0 到 1 平滑变化的 spec 值,从而实现抗锯齿的目的。尽管我们可以把 w 设为一个很小的定值,但在下面的实现中,我们选择使用邻域像素之间的近似导数值,这可以通过 CG 的 fwidth 函数来得到。

1.3 实现

实现代码如下:

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
Shader "Unity Shaders Book/Chapter 14/Toon Shading" {
Properties {
_Color ("Color Tint", Color) = (1, 1, 1, 1)
_MainTex ("Main Tex", 2D) = "white" {}
//用于控制漫反射色调的渐变纹理
_Ramp ("Ramp Texture", 2D) = "white" {}
//用于控制轮廓线宽度,
_Outline ("Outline", Range(0, 1)) = 0.1
_OutlineColor ("Outline Color", Color) = (0, 0, 0, 1)
_Specular ("Specular", Color) = (1, 1, 1, 1)
//计算高光反射时的阈值
_SpecularScale ("Specular Scale", Range(0, 0.1)) = 0.01
}
SubShader {
Tags { "RenderType"="Opaque" "Queue"="Geometry"}

//渲染轮廓线的Pass
Pass {
//描边在非真实感渲染中很常用,方便后面调用
NAME "OUTLINE"
//剔除掉正面的部分,只渲染背面
Cull Front

CGPROGRAM

#pragma vertex vert
#pragma fragment frag

#include "UnityCG.cginc"

float _Outline;
fixed4 _OutlineColor;

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

struct v2f {
float4 pos : SV_POSITION;
};

v2f vert (a2v v) {
v2f o;
//模型空间下的顶点和法线变换到视角空间下,为了让描边再观察空间达到最好的效果
float4 pos = mul(UNITY_MATRIX_MV, v.vertex);
float3 normal = mul((float3x3)UNITY_MATRIX_IT_MV, v.normal);
//扩张顶点位置
normal.z = -0.5;
pos = pos + float4(normalize(normal), 0) * _Outline;
//变换到裁剪空间下
o.pos = mul(UNITY_MATRIX_P, pos);

return o;
}

float4 frag(v2f i) : SV_Target {
//用轮廓线颜色渲染整个背面
return float4(_OutlineColor.rgb, 1);
}

ENDCG
}

//计算光照的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"
#include "UnityShaderVariables.cginc"

fixed4 _Color;
sampler2D _MainTex;
float4 _MainTex_ST;
sampler2D _Ramp;
fixed4 _Specular;
fixed _SpecularScale;

struct a2v {
float4 vertex : POSITION;
float3 normal : NORMAL;
float4 texcoord : TEXCOORD0;
float4 tangent : TANGENT;
};

struct v2f {
float4 pos : POSITION;
float2 uv : TEXCOORD0;
float3 worldNormal : TEXCOORD1;
float3 worldPos : TEXCOORD2;
SHADOW_COORDS(3)
};

v2f vert (a2v v) {
v2f o;

o.pos = UnityObjectToClipPos( v.vertex);
o.uv = TRANSFORM_TEX (v.texcoord, _MainTex);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;

TRANSFER_SHADOW(o);

return o;
}

float4 frag(v2f i) : SV_Target {
fixed3 worldNormal = normalize(i.worldNormal);
fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
fixed3 worldViewDir = normalize(UnityWorldSpaceViewDir(i.worldPos));
fixed3 worldHalfDir = normalize(worldLightDir + worldViewDir);

fixed4 c = tex2D (_MainTex, i.uv);
fixed3 albedo = c.rgb * _Color.rgb;

fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;
//阴影和衰减
UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos);

fixed diff = dot(worldNormal, worldLightDir);
//半兰伯特漫反射系数乘以阴影值得到最终的漫反射系数
diff = (diff * 0.5 + 0.5) * atten;
//利用漫反射系数对渐变纹理采样并计算漫反射颜色
fixed3 diffuse = _LightColor0.rgb * albedo * tex2D(_Ramp, float2(diff, diff)).rgb;
//按照公式计算高光
fixed spec = dot(worldNormal, worldHalfDir);
fixed w = fwidth(spec) * 2.0;
fixed3 specular = _Specular.rgb * lerp(0, 1, smoothstep(-w, w, spec + _SpecularScale - 1)) * step(0.0001, _SpecularScale);

return fixed4(ambient + diffuse + specular, 1.0);
}

ENDCG
}
}
FallBack "Diffuse"
}

渲染效果如下:

image-20220409180228375

2 素描风格的渲染

另一个非常流行的非真实感渲染是素描风格的渲染。微软研究院的 Praun 等人在 2001 年的 SIGGRAPH 上发表了一篇非常著名的论文。在这篇文章中,他们使用了提前生成的素描纹理来实现实时的素描风格渲染,这些纹理组成了一个色调艺术映射 (Tonal Art Map, TAM), 如下图:

image-20220409191551680

从左到右纹理中的笔触逐渐增多,用于模拟不同光照下的漫反射效果,从上到下则对应了每张纹理的多级渐远纹理 (MipMaps) 。这些多级渐远纹理的生成并不是简单的对上一层纹理进行降采样,而是需要保持笔触之间的间隔,以便更真实地模拟素描效果。

本节将会实现简化版的论文中提出的算法,我们不考虑多级渐远纹理的生成,而直接使用 6 张素描纹理进行渲染。在渲染阶段,我们首先在顶点着色阶段计算逐顶点的光照,根据光照结果来决定 6 张纹理的混合权重,并传递给片元着色器。然后,在片元着色器中根据这些权重来混合 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
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
Shader "Unity Shaders Book/Chapter 14/Hatching" {
Properties {
_Color ("Color Tint", Color) = (1, 1, 1, 1)
// 纹理的平铺系数,值越大模型上素描线条越密
_TileFactor ("Tile Factor", Float) = 1
_Outline ("Outline", Range(0, 1)) = 0.1
_Hatch0 ("Hatch 0", 2D) = "white" {}
_Hatch1 ("Hatch 1", 2D) = "white" {}
_Hatch2 ("Hatch 2", 2D) = "white" {}
_Hatch3 ("Hatch 3", 2D) = "white" {}
_Hatch4 ("Hatch 4", 2D) = "white" {}
_Hatch5 ("Hatch 5", 2D) = "white" {}
}

SubShader {
Tags { "RenderType"="Opaque" "Queue"="Geometry"}

//渲染轮廓线
UsePass "Unity Shaders Book/Chapter 14/Toon Shading/OUTLINE"
//渲染素描效果
Pass {
Tags { "LightMode"="ForwardBase" }

CGPROGRAM

#pragma vertex vert
#pragma fragment frag

#pragma multi_compile_fwdbase

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

fixed4 _Color;
float _TileFactor;
sampler2D _Hatch0;
sampler2D _Hatch1;
sampler2D _Hatch2;
sampler2D _Hatch3;
sampler2D _Hatch4;
sampler2D _Hatch5;

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

struct v2f {
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
//存储混合六张纹理的权重
fixed3 hatchWeights0 : TEXCOORD1;
fixed3 hatchWeights1 : TEXCOORD2;
float3 worldPos : TEXCOORD3;
SHADOW_COORDS(4)
};

v2f vert(a2v v) {
v2f o;

o.pos = UnityObjectToClipPos(v.vertex);
//根据_TileFactor得到纹理坐标
o.uv = v.texcoord.xy * _TileFactor;
//计算逐顶点光照,使用漫反射系数计算混合不同纹理的权重
fixed3 worldLightDir = normalize(WorldSpaceLightDir(v.vertex));
fixed3 worldNormal = UnityObjectToWorldNormal(v.normal);
fixed diff = max(0, dot(worldLightDir, worldNormal));
//权重初始化为0
o.hatchWeights0 = fixed3(0, 0, 0);
o.hatchWeights1 = fixed3(0, 0, 0);
//把diff缩放到[0,7]
float hatchFactor = diff * 7.0;
//把[0,7]均匀的分成七个子区间,判断hatchFactor所处的区间,以决定混合权重
if (hatchFactor > 6.0) {
// Pure white, do nothing
} else if (hatchFactor > 5.0) {
o.hatchWeights0.x = hatchFactor - 5.0;
} else if (hatchFactor > 4.0) {
o.hatchWeights0.x = hatchFactor - 4.0;
o.hatchWeights0.y = 1.0 - o.hatchWeights0.x;
} else if (hatchFactor > 3.0) {
o.hatchWeights0.y = hatchFactor - 3.0;
o.hatchWeights0.z = 1.0 - o.hatchWeights0.y;
} else if (hatchFactor > 2.0) {
o.hatchWeights0.z = hatchFactor - 2.0;
o.hatchWeights1.x = 1.0 - o.hatchWeights0.z;
} else if (hatchFactor > 1.0) {
o.hatchWeights1.x = hatchFactor - 1.0;
o.hatchWeights1.y = 1.0 - o.hatchWeights1.x;
} else {
o.hatchWeights1.y = hatchFactor;
o.hatchWeights1.z = 1.0 - o.hatchWeights1.y;
}

o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;

TRANSFER_SHADOW(o);

return o;
}

fixed4 frag(v2f i) : SV_Target {
fixed4 hatchTex0 = tex2D(_Hatch0, i.uv) * i.hatchWeights0.x;
fixed4 hatchTex1 = tex2D(_Hatch1, i.uv) * i.hatchWeights0.y;
fixed4 hatchTex2 = tex2D(_Hatch2, i.uv) * i.hatchWeights0.z;
fixed4 hatchTex3 = tex2D(_Hatch3, i.uv) * i.hatchWeights1.x;
fixed4 hatchTex4 = tex2D(_Hatch4, i.uv) * i.hatchWeights1.y;
fixed4 hatchTex5 = tex2D(_Hatch5, i.uv) * i.hatchWeights1.z;
//留白
fixed4 whiteColor = fixed4(1, 1, 1, 1) * (1 - i.hatchWeights0.x - i.hatchWeights0.y - i.hatchWeights0.z - i.hatchWeights1.x - i.hatchWeights1.y - i.hatchWeights1.z);

fixed4 hatchColor = hatchTex0 + hatchTex1 + hatchTex2 + hatchTex3 + hatchTex4 + hatchTex5 + whiteColor;

UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos);

return fixed4(hatchColor.rgb * _Color.rgb * atten, 1.0);
}

ENDCG
}
}
FallBack "Diffuse"
}

渲染效果如下:

image-20220409192908015

---- 本文结束 知识又增加了亿点点!----

文章版权声明 1、博客名称:LycTechStack
2、博客网址:https://lz328.github.io/LycTechStack.github.io/
3、本博客的文章部分内容可能来源于网络,仅供大家学习与参考,如有侵权,请联系博主进行删除处理。
4、本博客所有文章版权归博主所有,如需转载请标明出处。