非真实感渲染

本文最后更新于:2025年4月17日 下午

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

卡通风格的渲染

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

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

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

渲染轮廓线

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

  • 基于观察角度和表面法线的轮廓线渲染。这种方法使用视角方向和表面法线的点乘结果来得到轮廓线的信息。这种方法简单快速,可以在一个 Pass 中就得到渲染结果,但局限性很大,很多模型渲染出来的描边效果都不尽如人意。
  • 过程式几何轮廓线渲染。这种方法的核心是使用两个 Pass 渲染。第一个 Pass 渲染背面的面片,并使用某些技术让它的轮廓可见;第二个 Pass 再正常渲染正面的面片。这种方法的优点在于快速有效,并且适用于绝大多数表面平滑的模型,但它的缺点是不适合类似于立方体这样平整的模型。
  • 基于图像处理的轮廓线渲染。这种方法的优点在于,可以适用于任何种类的模型。但它也有自身的局限所在,一些深度和法线变化很小的轮廓无法被检测出来,例如桌子上的纸张。
  • 基于轮廓边检测的轮廓线渲染。上面提到的各种方法,一个最大的问题是,无法控制轮廓线的风格渲染。对与一些情况,我们希望可以渲染出独特风格的轮廓线,例如水墨风格等。为此,我们希望可以检测出精确的轮廓边,然后直接渲染它们。检测一条边是否是轮廓边的公式很简单,我们只需要检查和这条边相邻的两个三角面片是否满足以下条件:
    $$
    (n_0 \cdot v > 0) \neq (n_1 \cdot v > 0)
    $$
    其中,$n_0$ 和 $n_1$ 分别表示两个相邻三角面片的法向,$v$ 是从视角到该边上任意顶点的方向。上述公式的本质在于检查两个相邻的三角面片是否一个朝正面、一个朝背面。我们可以在几何着色器(Geometry Shader)的帮助下实现上面的检测过程。当然,这种方法也有缺点,除了实现相对复杂外,它还会有动画连贯性的问题。也就是说,由于是逐帧单独提取轮廓,所以在帧与帧之间会出现跳跃性。
  • 最后一个种类就是混合了上述的几种渲染方法,例如,首先找到精确的轮廓边,把模型和轮廓边渲染到纹理中,再使用图像处理的方法识别出轮廓线,并在图像空间下进行风格化渲染。

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

1
viewPos = viewPos + viewNormal * _Outline;

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

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

添加高光

前面提到过,卡通风格中的高光往往是模型上一块块分界明显的纯色区域。为了实现这种效果,我们就不能再使用之前学习的光照模型。回顾一下,在之前实现 Blinn-Phong 模型的过程中,我们使用法线点乘光照方向以及视角方向和的一半,再和另一个参数进行指数操作得到高光反射系数。代码如下:

1
float spec = pow(max(0, dot(normal, halfDir)), _Gloss)

对于卡通渲染需要的高光反射光照模型,我们同样需要计算 normal 和 halfDir 的点乘结果,但不同的是,我们把该值和一个阈值进行比较,如果小于该阈值,则高光反射系数为 0,否则返回 1。

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

在上面的代码中,我们使用 CG 的 step 函数来实现和阈值比较的目的。step 函数接受两个参数,第一个参数是参考值,第二个参数是待比较的数值。如果第二个参数大于等于第一个参数,则返回 1,否则返回 0。

但是,这种粗暴的判断方法会在高光区域的边界造成锯齿,如下图所示。出现这种问题的原因是,高光区域的边缘不是平滑渐变的,而是由 0 突变到 1。要想对其进行抗锯齿处理,我们可以在边界处很小的一块区域内,进行平滑处理。代码如下:

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

抗锯齿

在上面的代码中,我们没有像之前一样直接使用 step 函数返回 0 或 1,而是首先使用了 CG 的 smoothstep 函数。其中,w 是一个很小的值,当 spec - threshold 小于 -w 时,返回 0,大于 w 时,返回 1,否则在 0 到 1 之间进行插值。这样的效果是,我们可以在 [-w, w] 区间内,即高光区域的边界处,得到一个从 0 到 1 平滑变化的 spec 值,从而实现抗锯齿的目的。尽管我们可以把 w 设为一个很小的定值,但在本例中,我们选择使用邻域像素之间的近似导数值,这可以通过 CG 的 fwidth 函数 来得到。

实现

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
// Upgrade NOTE: replaced '_Object2World' with 'unity_ObjectToWorld'
// Upgrade NOTE: replaced '_World2Object' with 'unity_WorldToObject'
// Upgrade NOTE: replaced 'mul(UNITY_MATRIX_MVP,*)' with 'UnityObjectToClipPos(*)'

Shader "Custom/Chapter14/Chapter14-ToonShading"
{
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 {
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 {
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 = mul(v.normal, (float3x3)unity_WorldToObject);
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"
}

_Ramp 是用于控制漫反射色调的渐变纹理,_Outline 用于控制轮廓线宽度,_OutlineColor 对应了轮廓线颜色,_Specular 是高光反射颜色,_SpecularScale 用于控制计算高光反射时使用的阈值。

在第一个 Pass 中渲染轮廓线。这个 Pass 只渲染背面的三角面片,因此我们使用 Cull 指令把正面的三角面片剔除。我们还使用 NAME 命令为该 Pass 定义了名称。这是因为,描边在非真实感渲染中是非常常见的效果,为该 Pass 定义名称可以让我们在后面的使用中不需要再重复编写此 Pass,而只需要调用它的名字即可。在顶点着色器中我们首先把顶点和法线变换到视角空间下,这是为了让描边可以在观察空间达到最好的效果。随后,我们设置法线的 z 分量,对其归一化后再将顶点沿其方向扩张,得到扩张后的顶点坐标。对法线的处理是为了尽可能避免背面扩张后的顶点挡住正面的面片。最后,我们把顶点从视角空间变换到裁剪空间。片元着色器代码非常简单,我们只需要用轮廓线颜色渲染整个背面即可。

在第二个 Pass 中定义光照模型,以渲染模型的正面。首先,我们计算了光照模型中需要的各个方向矢量,并对它们进行了归一化处理。然后,我们计算了材质的反射率 albedo 和环境光照 ambient。接着,我们使用内置的 UNITY_LIGHT_ATTENUATION 宏来计算当前世界坐标下的阴影值。随后,我们计算了半兰伯特漫反射系数,并和阴影值相乘得到最终的漫反射系数。我们使用这个漫反射系数对渐变纹理 _Ramp 进行采样,并将结果和材质的反射率、光照颜色相乘,作为最后的漫反射光照。高光反射的计算和上面介绍的方法一致,我们使用 fwidth 对高光区域的边界进行抗锯齿处理,并将计算而得的高光反射系数和高光反射颜色相乘,得到高光反射的光照部分。值得注意的是,我们在最后还使用了step(0.0001, _SpecularScale),这是为了在 _SpecularScale 为 0 时,可以完全消除高光反射的光照。最后,返回环境光照、漫反射光照和高光反射光照叠加的结果。

卡通风格的渲染效果

素描风格的渲染

另一个非常流行的非真实感渲染是素描风格的渲染。我们可以使用提前生成的素描纹理来实现实时的素描风格渲染,这些纹理组成了一个**色调艺术映射(Tonal Art Map,TAM),如下图所示。在图中,从左到右纹理中的笔触逐渐增多,用于模拟不同光照下的漫反射效果,从上到下则对应了每张纹理的多级渐远纹理(mipmaps)。这些多级渐远纹理的生成并不是简单的对上一层纹理进行降采样,而是需要保持笔触之间的间隔,以便更真实地模拟素描效果。

一个TAM的例子

本节将会实现简化版的算法,不考虑多级渐远纹理的生成,而是直接使用 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
// Upgrade NOTE: replaced '_Object2World' with 'unity_ObjectToWorld'
// Upgrade NOTE: replaced 'mul(UNITY_MATRIX_MVP,*)' with 'UnityObjectToClipPos(*)'

Shader "Custom/Chapter14/Chapter14-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 "Custom/Chapter14/Chapter14-ToonShading/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);
o.uv = v.texcoord.xy * _TileFactor;
fixed3 worldLightDir = normalize(WorldSpaceLightDir(v.vertex));
fixed3 worldNormal = UnityObjectToWorldNormal(v.normal);
fixed diff = max(0, dot(worldLightDir, worldNormal));

o.hatchWeights0 = fixed3(0, 0, 0);
o.hatchWeights1 = fixed3(0, 0, 0);

float hatchFactor = diff * 7.0;

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

其中,_Color 是用于控制模型颜色的属性。_TileFactor 是纹理的平铺系数,_TileFactor 越大,模型上的素描线条越密。_Hatch0 至 _Hatch5 对应了渲染时使用的 6 张素描纹理,它们的线条密度依次增大。由于一共声明了 6 张纹理,这意味着需要 6 个混合权重,我们把它们存储在两个 fixed3 类型的变量(hatchWeights0 和 hatchWeights1)中。为了添加阴影效果,我们还声明了 worldPos 变量,并使用 SHADOW_COORDS 宏声明了阴影纹理的采样坐标。

在顶点着色器中,我们首先对顶点进行了基本的坐标变换。然后,使用 _TileFactor 得到了纹理采样坐标。在计算 6 张纹理的混合权重之前,我们首先需要计算逐顶点光照。因此,我们使用世界空间下的光照方向和法线方向得到漫反射系数 diff。之后,我们把权重值初始化为 0,并把 diff 缩放到[0, 7]范围,得到 hatchFactor。我们把[0, 7]的区间均匀划分为 7 个子区间,通过判断 hatchFactor 所处的子区间来计算对应的纹理混合权重。最后,我们计算了顶点的世界坐标,并使用 TRANSFER_SHADOW 宏来计算阴影纹理的采样坐标。

在片元着色器中,当得到了 6 张纹理的混合权重后,我们对每张纹理进行采样并和它们对应的权重值相乘得到每张纹理的采样颜色。我们还计算了纯白在渲染中的贡献度,这是通过从 1 中减去所有 6 张纹理的权重来得到的。这是因为素描中往往有留白的部分,因此我们希望在最后的渲染中光照最亮的部分是纯白色的。最后,我们混合了各个颜色值,并和阴影值 atten、模型颜色 _Color 相乘后返回最终的渲染结果。

素描风格的渲染效果


非真实感渲染
http://example.com/posts/非真实感渲染/
作者
祭零小白
发布于
2022年4月16日
许可协议