尽管游戏渲染一般都是以照相写实主义(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。
但是,这种粗暴的判断方法会在高光区域的边界造成锯齿,如下图。
出现这种问题的原因在于,高光区域的边缘不是平滑渐变的,而是由 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", 2 D) = "white" {} _Ramp ("Ramp Texture", 2 D) = "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 = 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" }
渲染效果如下:
2 素描风格的渲染 另一个非常流行的非真实感渲染是素描风格的渲染。微软研究院的 Praun 等人在 2001 年的 SIGGRAPH 上发表了一篇非常著名的论文。在这篇文章中,他们使用了提前生成的素描纹理来实现实时的素描风格渲染,这些纹理组成了一个色调艺术映射 (Tonal Art Map, TAM), 如下图:
从左到右纹理中的笔触逐渐增多,用于模拟不同光照下的漫反射效果,从上到下则对应了每张纹理的多级渐远纹理 (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 ", 2 D) = "white" {} _Hatch1 ("Hatch 1 ", 2 D) = "white" {} _Hatch2 ("Hatch 2 ", 2 D) = "white" {} _Hatch3 ("Hatch 3 ", 2 D) = "white" {} _Hatch4 ("Hatch 4 ", 2 D) = "white" {} _Hatch5 ("Hatch 5 ", 2 D) = "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); 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 ) { } 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" }
渲染效果如下: