《军团要塞2》卡通渲染算法实现

Posted by Richbabe on December 12, 2018

中山大学 数据科学与计算机学院 软件工程数字媒体 洪鹏圳


前言

在上一篇博客:Illustrative Rendering in Team Fortress 2中我们介绍了Value公司在《军团要塞2》中的卡通着色算法。这篇博客我将用Unity简单实现该论文中角色的卡通渲染。

实现

我们按照论文中的步骤来逐步实现: image image

Albedo

首先渲染出Figure6(a)的Albedo贴图,即人物模型本身自带的纹理。着色器代码如下:

Shader "NPR_Lab/TeamFotress2" {
	Properties {
		_Color("Color", Color) = (1,1,1,1)
		_MainTex ("Albedo (RGB)", 2D) = "white" {}
	}
	SubShader {
		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"

			//定义Properties变量
			fixed4 _Color;
			sampler2D _MainTex;
			float4 _MainTex_ST;

			//定义顶点着色器输入
			struct a2v {
				float4 vertex : POSITION;
				float3 normal : NORMAL;
				float2 texcoord : TEXCOORD0;
				float4 tangent : TANGENT;
			};

			//定义顶点着色器输出
			struct v2f {
				float4 pos : SV_POSITION;
				float3 worldNormal : TEXCOORD0;
				float3 worldPos : TEXCOORD1;
				float2 uv : TEXCOORD2;
			};

			//顶点着色器
			v2f vert(a2v v) {
				v2f o;

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

				return o;
			}

			//片段着色器
			fixed4 frag(v2f i) : SV_Target{
				//计算所需的方向向量
				fixed3 worldNormal = normalize(i.worldNormal);
				fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));

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

				return c;
			}
			ENDCG
		}
	}
	FallBack "Diffuse"
}

效果如下,可以看出纹理中基本都是简约的纯色色块:

image

Wraped diffuse

首先来回顾下论文里计算非依赖视角的光照部分的公式(1):

image

可见,在非依赖视角的光照部分,《军团要塞2》中除了考虑了一般的漫反射外,还加入了基于模型法线方向的环境光分量(ambient cube),此外,通常的漫反射分量改为了wrapped diffuse(通过一张漫反射变形贴图)。

其中,Wraped diffuse就是中括号里加号右边那部分。在这里我只考虑一个光源,并让α = 0.5,β = 0.5,γ = 1,使用的漫反射变形贴图如下:

image

片段着色器代码如下:

//计算wraped diffuse term
half difLight = dot(worldNormal, worldLightDir);//n·l
half halfLambert = pow(0.5 * difLight + 0.5, 1.0);//半兰伯特因子
half3 ramp = tex2D(_RampTex, float2(halfLambert, halfLambert)).rgb;//漫反射变形
half3 difWarping = ramp * 2;//乘2使得模型更加明亮
half3 wrapDiffuseTerm = _LightColor0.rgb * difWarping;

可以看到我在计算漫反射变形函数w()时,按照论文中的做法在对漫反射变形贴图进行纹理采样后乘上了个2,所以最终结果是偏亮了,在调节了一下光源颜色后,Wraped diffuse的效果图如下:

image

效果和论文中Figure6(b)很接近了~

Ambient cube

Ambient cube即是公式(1)中括号加号左边那个a(n),我的做法是先通过amd的cubemap gen生成一个法线贴图:

image

然后通过Diffuse Cube map得到Ambient Cube,效果如下:

image 可见和Figure6(c)很接近。

计算非依赖视角的光照部分

要计算非依赖视角的光照部分,其实就是计算公式(1)。我们只需要把上面的wraped diffuse 和 Ambient cube加起来再乘一个漫反射系数kd即可。片段着色器代码如下:

//计算View Independent Lighting Terms
half3 viewIndependentLightTerms = kd * (ambientCubeTerm + wrapDiffuseTerm);

效果如下:

image

可以看到手臂上的效果和Figure6(e)中的差不多了,那么接下来我们就来添加依赖视角的光照部分(即Phong镜面高光加上自定义的边缘高光)。

计算依赖视角的光照部分

先回顾一下论文中计算依赖视角的光照部分的公式(2):

image

可见,在依赖视角的光照部分中,《军团要塞2》除了考虑一般的镜面反射(Pong高光)外,还基于菲涅尔现象实现了类似边缘光的效果。

其中,Specular就是式子中的左半部分,Rim lighting就是式子中的右半部分。

实现公式(2)的片段着色器代码如下:

//***计算视角相关部分***
//计算多重Phong高光部分
half3 r = reflect(worldLightDir, worldNormal);//反射向量
half3 refl = dot(r, worldViewDir);//r·v
half fresnelForSpecular = 1;//fs,这里随便取了个值
half fresnelForRim = pow(1 - dot(worldNormal, worldViewDir), 4);//fr = (1-(n·v))^4
half3 ks = tex2D(_SpecularMask, i.specularMask_uv).rgb;//高光遮罩系数,如果为1应用高光,如果为0不应用
half3 kr = tex2D(_RimMask, i.rimMask_uv).rgb;//边缘遮罩纹理值,用于减弱边缘高光对模型上某些部分的影响
half3 multiplePhongTerms = _LightColor0.rgb * ks * max(fresnelForSpecular * pow(refl, _Specular), fresnelForRim * pow(refl, _Rim));
//计算Rim Lighting部分
half av = float(1);//对环境盒子的取值函数,这里省略直接取1
half3 dedicatedRimLighting = dot(worldNormal, worldUp) * fresnelForRim * kr * av;

half3 viewDependentLightTerms = multiplePhongTerms + dedicatedRimLighting;

//最终输出颜色
fixed3 finalCol = viewIndependentLightTerms + viewDependentLightTerms;

但是因为我没有找到模型对应的高光遮罩和边缘遮罩纹理,所以最后获得的结果效果很差。然后我参考了Shader - Specular Term - Fresnel[2],换了种算法,不使用遮罩并且只使用一个高光菲涅尔因子,同时将Phong高光模型换成Blinn-Phong高光模型加快计算效率。

计算Blinn-Phong高光部分的代码如下:

float3 halfVector = normalize(worldLightDir + worldViewDir);//使用Blinn-Phong
float3 specBase = pow(saturate(dot(halfVector, worldNormal)), _SpecularPower);
float fresnel = 1.0 - dot(worldViewDir, halfVector);
fresnel = pow(fresnel, 5.0);
fresnel += _SpecularFresnel * (1.0 - fresnel);
half3 multiplePhongTerms = specBase * fresnel * _LightColor0.rgb;

得到的结果为: image 与Figure6(f)效果相近。

计算Rim lighting部分的代码如下:

half rim = 1.0 - saturate(dot(worldViewDir, worldNormal));
half3 dedicatedRimLighting = _RimColor.rgb * pow(rim, _RimPower);

得到的结果为: image 与Figure6(g)效果相近。

将Blinn-Phong高光和Rim lighting相加得到依赖视角的光照部分,片段着色器代码如下:

half3 viewDependentLightTerms = multiplePhongTerms + dedicatedRimLighting;

得到的结果为: image 效果和Figure6(h)相近。

最终结果

将依赖视角的光照部分和非依赖视角的光照部分相加得到最终结果:

//最终输出颜色
fixed3 finalCol = viewIndependentLightTerms + viewDependentLightTerms;

return fixed4(finalCol, 1.0);

运行效果为: image 可以看到我们的最终结果和Figure6(j)中一样,在明暗交界处有明显的泛红(wrapped diffuse的效果),同时模型边缘有高光效果!

Reference

结语

本博客的代码和资源均可在我的github上下载,别忘了点颗Star哟!