【Shader拓展】Illustrative Rendering in Team Fortress 2

时间:2022-05-13 03:47:23


写在前面


早在使用ramp texture控制diffuse光照一文就提到了这篇著名的论文。Valve公司发表的其他成果可见这里。这是Valve在2007年发表的一篇非常具有影响力的文章,我的导师也提到过这篇,既然这么有名,我就去拜读了下,结果真是读到头大啊啊啊啊!其实半年前就读了这篇文章,差一点读完,后来一比赛一考试一放假就没完成,对自己很愧疚。。。时隔这么久希望能把之前的理解整理出来,顺便看看能不能有更多收获。(又要读一遍E文!【Shader拓展】Illustrative Rendering in Team Fortress 2


这篇博客在翻译这篇论文的基础上,会在Unity Shader里面尝试自己实现一下效果。若有出入,还请指出,不胜感谢。



摘要


在2007年,《要塞军团2》这样画质的游戏应该是比较少见的,这篇论文就说明了这个游戏里面使用的Shader技术。一个好的游戏一定是美术人员和工程师之间的完美合作,因此这篇论文里将会讨论他们是如何用技术支持美术目标和游戏限制的。而且,为了得到引人入胜的效果,他们的shading技术使用了边缘高光技术(rim highlights)以及在亮度和色彩方面的变化来快速传递不同的几何信息。


【Shader拓展】Illustrative Rendering in Team Fortress 2


关键词:非真实感渲染(有点像卡通、插画那样的渲染效果),光照模型,着色器,硬件渲染,电子游戏



1. 介绍


在这之前,大部分非真实感渲染技术都只能在一个特定的模型下工作,而这篇文章介绍了一系列可交互的渲染技术,而且还添加了在光照和色彩方面的变化,使得玩家可以在不同的光照条件下直观地分别不同的场景。


在《要塞军团2》中,他们选择了20世纪中期几名插画师的艺术风格,这种风格的插画角色使用了强烈而且鲜明的轮廓,并且强调衣服褶皱。他们加重了对象的内部形状,使用边缘高光而非黑色的轮廓来强调对象轮廓,如上图图(a)那样。


这篇论文的主要贡献是整理的这种商业插画风格到游戏的转换。技术有,实现了一种diffuse light warping function的插画式渲染函数,rim lighting的公式,以及一个总体说来在真实感和非真实感之间获得平衡的shading技术。



2. 相关工作


非真实感的渲染风格变化多样,但是它们都是来自真实世界的艺术风格,前提是这些技术是由人类发展而来,是具有内在价值的。《要塞军团2》使用的商业插画风格是和1998年的Gooch紧密相关的。在Gooch shading中,传统的Phong模型[Phong 1975]被一种由冷到暖的色彩变化所修改,来暗示在某个光源下的平面方向。因此,非常明亮和黑暗的区域被保留为边缘线条以及高光。相比于传统的光照模型,在一些困难的光照条件下,这种方法得到了更清晰的3D视觉。



3. 商业插画技巧


根据20世纪早期的插画家J. C. Leyendecker, Dean Cornwell , Norman Rockwell 以及他们自己的概念原画,他们得到了下面的一致性来定义《要塞军团2》中的风格:

  • Shading遵循一个由暖到冷的色彩转换。阴影是趋于冷色,而非黑色。
  • 在特定光源的明暗交替处,饱和度增加。交替处通常是偏红色的。
  • 高频度的细节尽可能省略。
  • 对于角色,内部细节,像衣服褶皱等,重复轮廓线条。
  • 在轮廓处使用边缘高光,而非深色的线,进行强调。

基于以上基本理解,他们进行了美术资源和实时着色算法的创建。

下一节将阐述美术资源的创建。在第五节将介绍技术细节。


4. 创建美术资源


本节介绍3D人物和世界模型的建模,以及生成贴图时遵循的规范。


4.1 人物建模


在像《军团要塞2》这样的多人战斗游戏中,我们需要在各种距离和视角上,在视觉上可以快速识别不同的人物,以便评估威胁。在《军团要塞2》中,玩家的类别——Demo, Engineer,Heavy, Medic, Pyro, Spy, Sniper, Soldier or Scout ——尤其重要,因此,这九种类型的轮廓要小心设计成互相差别很大的样子。如下图所示。


【Shader拓展】Illustrative Rendering in Team Fortress 2


由鞋子、帽子和衣服褶皱确定的身体比例、武器以及轮廓线条给予每一个角色独特的轮廓。在角色被遮住的内部区域,衣服褶皱重复了轮廓线条以强调轮廓,正如我们在商业插画里面观察到的一样。在第5节中,用于这些角色的着色算法补足了我们的模型来提高阴影比率。


4.2 环境建模


《军团要塞2》中的环境和两个队伍有关,蓝队和红队。因此为两个队伍定义了对比鲜明的特点。红队基本使用暖色调,天然材质和棱角分明的图形。而蓝队则主要使用冷色调,工业材质以及正交形式的图形。如下图所示。


【Shader拓展】Illustrative Rendering in Team Fortress 2


尽管实际建模有更多的细节,但他们尽可能避免复杂的或者几何失衡的模型,因为那样会增加不必要的视觉噪声,以及会给内存造成负担的顶点。并且,使用重复的结构,例如桥架、电线杆、铁路轨道到最低限度对我们的风格更合适,因为重复的空间感觉比表达每一个细节更重要。


4.3 贴图绘制


在《要塞军团2》中,角色和游戏的色彩跟真实材质比,色彩饱和度和对比度更强。红蓝两队在游戏中都有各自的基调,它们的参考样色样本如下图所示。可以看出,大多使用了柔和的颜色。


【Shader拓展】Illustrative Rendering in Team Fortress 2


除了上述两种主要的红蓝两个色调,还有其他的颜色被用于一些小区域的环境道具,如灭火器、电话等。通常,用于3D环境中的贴图是印象派的,意味着它们是绘画式的,并保持最小化的视觉噪声。这种风格被用于很多动画电影中,特别是宫崎骏的那些作品中,在这些作品里,视角中出现很大的笔触。对于我们的3D游戏,我们使用相同的技术。


大量环境贴图来源于手绘的反射贴图,它们内部具有少量细节,使用较大笔触,来表现特定平面的触感,如下图所示。在早期的开发中,许多这些2D贴图是在画板上使用水彩和扫描完成的。后来,美术们转而使用逼真的参考照片,再使用一系列滤镜和数字笔刷来得到希望的贴图。


【Shader拓展】Illustrative Rendering in Team Fortress 2



5 Interactive Character and Model Shading


本节将会介绍非真实感的阴影算法。对于人物角色和大多数模型,我们结合了一系列依赖视角和非依赖视角的部分。如下图所示。


【Shader拓展】Illustrative Rendering in Team Fortress 2

【Shader拓展】Illustrative Rendering in Team Fortress 2


非依赖视角的部分由一个空间变化的平行环境光部分加上一个修改后的兰伯特光照部分。依赖视角的部分由Phong高光和自定义的边缘光照部分组成。所有这些光照部分是逐像素计算而得,并且大多数材质特性,包括法线、反射率、镜面反射部分以及各种这样的遮罩由贴图采样而得。在下面的两个章节中,我们将会讨论每一个光照部分和传统方法有什么不同,并且是如何影响我们的效果的。


5.1 非依赖视角的光照部分


非依赖视角的部分由一个空间变化的平行环境光部分加上一个修改后的兰伯特光照部分。它可以用下列等式(1)表示:

【Shader拓展】Illustrative Rendering in Team Fortress 2


其中,L是光源数量,i是光源索引,【Shader拓展】Illustrative Rendering in Team Fortress 2是光源i的颜色,【Shader拓展】Illustrative Rendering in Team Fortress 2是由贴图映射得到的对象的反射率(albedo),【Shader拓展】Illustrative Rendering in Team Fortress 2是一个传统的关于光源i的unclamped Lambertian term,常量α、β、γ分别是Lambertian term的放缩、偏移和指数部分,【Shader拓展】Illustrative Rendering in Team Fortress 2是一个为每一个像素的法线n评估平行环境光部分的函数,w()则是一个将0-1范围的标量映射到一个RGB颜色的变形函数。


半兰伯特(Half Lambert) 等式1中第一个不同寻常的部分就是应用到【Shader拓展】Illustrative Rendering in Team Fortress 2的放缩、偏移和指数部分。由于我们的第一个游戏《半条命》,我们使用了0.5倍的放缩、0.5的偏移量以及平方来防止角色丢失在背光面的形状(α = 0.5、β = 0.5、γ = 2)。尽管在现在的游戏中需要更多的真实感,我们仍然采用这些设置,以便让点乘的结果(范围是-1到+1)转换到0到1,并且有一个令人满意的衰减区。由于兰伯特部分的0.5倍放缩和0.5大小的偏移,我们将这种技术称为“半兰伯特”。在《军团要塞2》中,我们让α = 0.5、β = 0.5但γ = 1,因为我们可以通过w()函数得到任何想要的阴影。


漫反射变形函数(Diffuse Warping Function) 等式1中第二个有趣的地方是变形函数w()。这个函数的目标是在引入商业插画中那些漂亮的明暗交接的同时,保持半兰伯特的阴影信息。在《军团要塞2》中,该变形函数使用一个1维贴图进行查找映射,如下图所示。这种方式间接地创建了一个“硬阴影”,我们在保留所有法线对光照的变化的同时,收紧了明暗交界处从亮到暗的跳变。如上图中的6(b)。


【Shader拓展】Illustrative Rendering in Team Fortress 2


注释:由图可以看出,在明暗交替处的变化很快,因此达到了收紧的目的。


除了上述常见的“阴影”作用外,这个一维的变形图片还有一些有趣的特性。首先,最右测的值不是白色,而是只比mid-gray稍稍明亮点的颜色。这是因为在查找完这张贴图后还需要乘以2(这样会变得更明亮了),这样允许美术人员可以更好地控制明暗。还有一点很重要。这张贴图被分为了三个部分:右侧的灰度梯度部分,左侧的冷色梯度部分,以及中间较小的发红的分界线部分。这和我们之前观察的插画阴影的变化是一致的,也就是说是趋于冷色而不是黑色,并且在分解处通常是有一点微红的。正如等式1所示,变形函数作用于半兰伯特因子后,得到一个RGB颜色,然后再和微调过的【Shader拓展】Illustrative Rendering in Team Fortress 2(光源的颜色)相乘,最终得到一个漫反射光照部分,如图6b所示。


平行环境光部分(Directional Ambient Term) 除了简单的变形漫反射光照部分的和以外,我们还应用了一个平行环境光照部分,【Shader拓展】Illustrative Rendering in Team Fortress 2。尽管表示方式不同,但是我们的平行环境光部分和一个环境辐射光照映射(inrradiance environment map,[Ramamoorthi and Hanrahan 2001])是等同的。但是,我们没有使用一个9项的球面调谐公式(a 9 term spherical harmonic basis),而是使用了一个修改后的6项公式,我们称为“环境光盒子”(ambient cube),它使用了沿x、y、z正负方向延伸的余弦平方来实现[McTag- gart 2004] [Mitchell et al. 2006]。我们使用离线的radiosity solver计算这些环境盒子,并把它们存储在一个辐射量(an irradiance volume)中,以便实时访问[Greger et al. 1998]。尽管这个光照部分很简单,如图6c所示,这个环境光盒子部分提供了反射光线的信息,而这是渲染游戏中的人物和其他模型的关键基础。


这些视角无关的光照部分相加起来的最终结果如图6d所示,然后再和模型的颜色反射率(albedo,图6a)相乘,得到的结果如图6e所示。



5.2 依赖视角的光照部分


我们的依赖视角的光照部分由传统的Phony镜面高光模型和一个自定义的边缘高光部分组合而成。等式(2)如下:

【Shader拓展】Illustrative Rendering in Team Fortress 2


其中,L是光源数量,i是光源索引,【Shader拓展】Illustrative Rendering in Team Fortress 2是光源i的颜色,【Shader拓展】Illustrative Rendering in Team Fortress 2是被嵌入到一个纹理通道中的镜面高光遮罩(specular mask),【Shader拓展】Illustrative Rendering in Team Fortress 2是视角向量,【Shader拓展】Illustrative Rendering in Team Fortress 2是一个由美术调整的为镜面高光而设的菲涅耳(Fresnel)因子,【Shader拓展】Illustrative Rendering in Team Fortress 2是光源i的方向向量相对于【Shader拓展】Illustrative Rendering in Team Fortress 2的反射向量,【Shader拓展】Illustrative Rendering in Team Fortress 2是世界坐标系中的方向向上的单位向量(up vector),【Shader拓展】Illustrative Rendering in Team Fortress 2是一个由一张纹理映射而得的镜面反射的指数部分,【Shader拓展】Illustrative Rendering in Team Fortress 2是一个常量指数部分,用于控制边缘高光的宽度(越小越宽),【Shader拓展】Illustrative Rendering in Team Fortress 2是另一个菲涅耳因子,用于修饰边缘高光(通常就是使用【Shader拓展】Illustrative Rendering in Team Fortress 2),【Shader拓展】Illustrative Rendering in Team Fortress 2是一个边缘遮罩纹理(rim mask texture),用于减弱边缘高光对模型上某些部分的影响;最后,【Shader拓展】Illustrative Rendering in Team Fortress 2是一个使用观察方向对环境盒子(ambient cube)的评估(最后这一句我没看懂。。。原文是:is an evaluation of the ambient cube using a ray from the eye through the pixel being rendered)。


多重Phong部分(Multiple Phong Term)         等式(2)的左半部分包含了使用常见的表达式——来计算Phong高光,并且使用了适当的常量以及一个菲涅耳因子对它进行调整。然而,我们在求和的内部还使用了max()函数将Phong高光和额外的Phong lobes(使用了不同的指数、菲涅耳系数以及遮罩)结合在一起。在《要塞军团2》中,对一个模型整体来说是一个常量,并且比小很多,来由光源得到一个边缘高光轮廓,而不受材质属性的影响。我们使用了一个菲涅耳系数对这些边缘高光进行遮罩,以保证它们只在切线角(grazing angles)出现。这种使用Phong高光和边缘高光的组合使得《军团要塞2》得到了非常出色的画面效果。


专用的边缘光照(Dedicated Rim Lighting)        当角色逐渐移动远离光源时,仅仅基于Phong部分的边缘高光可能不会向我们想要的那么明显了。为此,我们还加入了一个专用的边缘高光部分(等式2的右半部分)。这个部分使用了观察方向对环境盒子(ambient cube)进行了求值,并且使用了一个由美术控制的遮罩纹理、菲涅耳因子以及表达式进行调整。这个最后的表达式仅仅是逐像素的法线和空间向上向量(up vector)点乘的结果,再约束到正数范围(clamped to be positive)。这使得这种专用的边缘高光看起来包含了环境中的间接光源,但仅仅适用于朝上的法线。这种方法是一种基于美学和感性的选择,我们希望这样可以让人感觉这些光照好像是从上方照下来的一样。


用于角色和其他模型渲染的完整的pixel shader就是等式(1)和等式(2)的和,以及一些其他类似于可选环境映射的操作。



实践


我在Unity里使用vertex & fragment shader进行了上述过程的尝试。这里仅考虑了单光源、并且没有对非依赖视角的光照中的平行环境光部分以及依赖视角的光照中的环境光盒子进行处理。下面是处理效果(仅供参考):

【Shader拓展】Illustrative Rendering in Team Fortress 2  【Shader拓展】Illustrative Rendering in Team Fortress 2


最后,给出在Unity Shader中使用上述模型的示例代码(仅仅是公式的示例!而且仅处理了单光源!):

Shader "Custom/IllustrativeRenderingNormal" {
	Properties {
		_MainTex ("Base (RGB)", 2D) = "white" {}
		_NormalTex ("Normal Texture", 2D) = "white" {}
		_RampTex ("Ramp Texture", 2D) = "white" {}
		_SpecularMask ("Specular Mask", 2D) = "white" {}
		_Specular ("Speculr Exponent", Range(0.1, 128)) = 128
		_RimMask ("Rim Mask", 2D) = "white" {}
		_Rim ("Rim Exponent", Range(0.1, 8)) = 1
	}
	SubShader {	
		Pass {
			Name "FORWARD"
			Tags { "LightMode" = "ForwardBase" }
		
			CGPROGRAM
			
			#pragma vertex vert
			#pragma fragment frag
			
			#include "UnityCG.cginc"
			#include "Lighting.cginc"
			#include "AutoLight.cginc"

			sampler2D _MainTex;
			sampler2D _NormalTex;
			sampler2D _RampTex;
			sampler2D _SpecularMask;
			float _Specular;
			sampler2D _RimMask;
			float _Rim;
			
			float4 _MainTex_ST;
			float4 _NormalTex_ST;
			float4 _SpecularMask_ST;
			float4 _RimMask_ST;
			
			struct v2f {
				float4 position : SV_POSITION;
  				float2 uv0 : TEXCOORD0;
  				float2 uv1 : TEXCOORD1;
  				float2 uv2 : TEXCOORD2;
  				float2 uv3 : TEXCOORD3;
  				float3 viewDir : TEXCOORD4;
  				float3 lightDir : TEXCOORD5;
  				float3 up : TEXCOORD6;
			};
			
			v2f vert(appdata_full v) {
				v2f o;
  				o.position = mul (UNITY_MATRIX_MVP, v.vertex);
				o.uv0 = TRANSFORM_TEX (v.texcoord, _MainTex); 
				o.uv1 = TRANSFORM_TEX (v.texcoord, _NormalTex); 
				o.uv2 = TRANSFORM_TEX (v.texcoord, _SpecularMask); 
				o.uv3 = TRANSFORM_TEX (v.texcoord, _RimMask); 
				
				TANGENT_SPACE_ROTATION;
 	 			float3 lightDir = mul (rotation, ObjSpaceLightDir(v.vertex));
 	 			o.lightDir = normalize(lightDir);
				
				float3 viewDirForLight = mul (rotation, ObjSpaceViewDir(v.vertex));
  				o.viewDir = normalize(viewDirForLight);
  				
  				o.up = mul(rotation, float3(mul(_World2Object, half4(0, 1, 0, 0))));
				
				// pass lighting information to pixel shader
				TRANSFER_VERTEX_TO_FRAGMENT(o);
				return o;
			}
			
			fixed4 frag (v2f i) : COLOR {
				half3 normal = UnpackNormal(tex2D (_NormalTex, i.uv1)); 
				
				// Compute View Independent Lighting Terms
				half3 k = tex2D( _MainTex, i.uv0).rgb;
				
				half difLight = dot (normal, i.lightDir);
				half halfLambert = pow(0.5 * difLight + 0.5, 1);
				
				half3 ramp = tex2D(_RampTex, float2(halfLambert)).rgb;
				half3 difWarping = ramp * 2; // Or difWarping = ramp * 2;
				
				half3 difLightTerm = _LightColor0.rgb * difWarping; 
				
				half3 dirLightTerm = 0;
				
				half3 viewIndependentLightTerms = k * (dirLightTerm + difLightTerm);
				
				// Compute View Dependent Lighting Terms
				half3 r = reflect(i.lightDir, normal);
				half3 refl = dot(i.viewDir, r);
				half fresnelForSpecular = 1; // Just for example
				half fresnelForRim = pow(1 - dot(normal, i.viewDir), 4);
				
				half3 kS = tex2D( _SpecularMask, i.uv2).rgb;
				half3 multiplePhongTerms =  _LightColor0.rgb * kS * max(fresnelForSpecular * pow(refl, _Specular), fresnelForRim * pow(refl, _Rim));
				
				half3 kR = tex2D( _RimMask, i.uv3).rgb;
				half3 aV = float(1);
				half3 dedicatedRimLighting = dot(normal, i.up) * fresnelForRim * kR * aV;
				half3 viewDependentLightTerms = multiplePhongTerms + dedicatedRimLighting;
	       	  	
	       	  	// Compute the final color
	       	  	float4 col;
	       	  	col.rgb = viewIndependentLightTerms + viewDependentLightTerms;
	       	  	col.a = 1.0;
	       	  	
	       	  	return col;
			}

			ENDCG
		}
	}
	
	FallBack "Diffuse"
}