Multiple Pass Fair Rendering

多层Pass毛发渲染

Posted by Yoko on September 8, 2020

毛绒材质在生活中出现的频率非常高,但是在各种游戏中,我们却很少看到这种材质效果的良好表现,原因在于它们的制作与渲染成本都太高了。所以, 实时毛发渲染是业内最为期待的次世代特效之一。

我们一般游戏的毛发渲染,都是将毛发纹理放到模型面片上面,用AlphaBlend或者AlphaTest剔除镂空区。但这两者也都无法尽善尽美:

  • AlphaBlend没有深度会有模型穿插问题。

  • AlphaTest有顶点深度但边缘锯齿感严重,需要在SSAA下面才不会有明显的锯齿。

  • 两者结合使用效果比较好,但会带来更高的性能消耗。

所以至今为止,游戏上面都很难做到满意的毛发表现,尤其是手游,往往我们只能从设计层面去规避这些问题

多pass渲染的方案

这套方案在美术资源制作上,基本上和普通角色资源没有差别,唯一的限制就是skin多边形数量上的限制。而且这套方案在PC端游上已经比较成熟,比如《剑灵》。多pass的方法局限性也很大,不过我们可以拆分开来研究和优化。

layer实现方式

根据模型使用层 (layer) 来渲染毛发长度,在 Unity Shader 中,每一个 Pass 即表示一层。当渲染每一层时,使用法线将顶点位置挤出模型表面。Pass及使用的层数越多渲染效果越好,当然开销也越大。这种做法如果想要很好的表现,就需要大量的pass。如何用少量的pass实现更好的效果,后面我们再一步步解决。

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
Shader "Custom/FurShader"
{
	Properties
	{
		_MainTex ("Texture", 2D) = "white" {}
		_FurTex ("Fur pattern", 2D) = "white" {}
		_Diffuse ("Diffuse value", Range(0, 1)) = 1

		_FurLength ("Fur length", Range(0.0, 1)) = 0.5
		_CutOff ("Alpha cutoff", Range(0, 1)) = 0.5
		_Blur ("Blur", Range(0, 1)) = 0.5
		_Thickness ("Thickness", Range(0, 0.5)) = 0
	}

	CGINCLUDE

		fixed _Diffuse;

		inline fixed4 LambertDiffuse(float3 worldNormal)
		{
			float3 lightDir = normalize(_WorldSpaceLightPos0.xyz);
			float NdotL = max(0, dot(worldNormal, lightDir));
			return NdotL * _Diffuse;
		}

	ENDCG

	SubShader
	{
		Tags { "RenderType"="Transparent" "IgnoreProjector"="True" "Queue"="Transparent"}
		ZWrite Off
		Blend SrcAlpha OneMinusSrcAlpha
		Pass
		{
			CGPROGRAM
			#pragma vertex vert
			#pragma fragment frag
			#pragma target 3.0

			#include "UnityCG.cginc"

			struct v2f
			{
				float4 pos : SV_POSITION;
				float2 uv : TEXCOORD0;
				fixed4 dif : COLOR;
			};

			sampler2D _MainTex;
			float4 _MainTex_ST;

			v2f vert (appdata_base v)
			{
				v2f o;
				o.pos = UnityObjectToClipPos(v.vertex);
				o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);
				o.dif = LambertDiffuse(v.normal);
				return o;
			}

			fixed4 frag (v2f i) : SV_Target
			{
				fixed4 col = tex2D(_MainTex, i.uv);
				col.rgb *= i.dif;
				return col;
			}
			ENDCG
		}

		Pass
		{
			CGPROGRAM
			#pragma vertex vert
			#pragma fragment frag
			#define FURSTEP 0.05
			#include "FurHelper.cginc"
			ENDCG
		}
	}
}

FURSTEP每个Pass增加0.05可以增加至0.95

命名为FurHelper.cginc的库

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
#pragma target 3.0
#include "UnityCG.cginc"

struct v2f {
	float4 pos : SV_POSITION;
	half2 uv : TEXCOORD0;
	half2 uv1 : TEXCOORD1;
	fixed4 diff : COLOR;
};

float _FurLength;
sampler2D _MainTex;
float4 _MainTex_ST;
sampler2D _FurTex;
float4 _FurTex_ST;
float _Blur;

v2f vert(appdata_base v) {
	v2f o;
	v.vertex.xyz += v.normal * _FurLength * FURSTEP;
	o.pos = UnityObjectToClipPos(v.vertex);
	o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);
	o.uv1 = TRANSFORM_TEX(v.texcoord, _FurTex);
	float3 worldNormal = normalize(mul(v.normal, (float3x3) unity_WorldToObject));
	o.diff = LambertDiffuse(worldNormal);
	o.diff.a = 1 - (FURSTEP * FURSTEP);
	float4 worldPos = mul(unity_WorldToObject, v.vertex);
	o.diff.a += dot(normalize(_WorldSpaceCameraPos.xyz - worldPos), worldNormal) - _Blur;
	return o;
}

float _CutOff;
float _Thickness;

fixed4 frag(v2f i) : SV_Target {
	fixed4 col = tex2D(_MainTex, i.uv);
	fixed alpha = tex2D(_FurTex, i.uv1).r;
	col *= i.diff;
	col.a *= step(lerp(_CutOff, _CutOff + _Thickness, FURSTEP), alpha);
	return col;
}

根据模型使用层 (layer) 来渲染毛发长度,在 Unity Shader 中,每一个 Pass 即表示一层。当渲染每一层时,使用法线将顶点位置挤出模型表面 。Pass及使用的层数越多渲染效果越好,当然开销也越大。这种做法如果想要很好的表现,就需要大量的pass。如何用少量的pass实现更好的效果,后面我们再一步步解决。

1
2
3
4
float3 aNormal = (v.normal.xyz);
aNormal.xyz += FUR_OFFSET;
float3 n = aNormal * FUR_OFFSET * (FUR_OFFSET * saturate( v.color.a )); 
//顶点色alpha通道控制毛发扩展范围

然后将Noise贴图根据layer做衰减,来当做alpha值。

1
_UVoffset ("UV偏移:XY=UV偏移;ZW=UV扰动", Vector) = (0, 0, 0.2, 0.2)

一定加入基于layer层高度的UV偏移。

  • 没有UV偏移效果的毛怎么看都会像刺猬。

  • 记得对毛发做UV偏移的时候,

Diffuse贴图的UV也要跟着一起计算哦。

1
2
3
4
5
float2 uvoffset= _UVoffset.xy  * FUR_OFFSET;
uvoffset *=  0.1 ; //尺寸太大不好调整 缩小精度。
float2 uv1= TRANSFORM_TEX(v.texcoord.xy, _MainTex ) + uvoffset * (float2(1,1)/_SubTexUV.xy);
float2 uv2= TRANSFORM_TEX(v.texcoord.xy, _MainTex )*_SubTexUV.xy   + uvoffset;
o.uv = float4(uv1,uv2);
1
2
3
4
5
half3 NoiseTex = tex2D(_SubTex, i.uv.zw).rgb;
half Noise = NoiseTex.r;
color.rgb = lerp(_Color,_BaseColor,FUR_OFFSET) ;
color.a = saturate(Noise-FUR_OFFSET) ;
return color;

也可以加入风力、重力等顶点控制项。根据不同性能的机器来选择是否开启。

默认:顶点重力:UV偏移

Noise混合

也可以将多层Noise混合到一起来做一些不同的毛发,将他们分别放到R、G、B不同的通道里,可以减少贴图量。

缺点

  • 这样大家能看出来,多pass的制作方式,无法制作头发这样的长毛,只能制作较短的毛发。不过我们这次的目标也是制作短毛,所以长毛与头发可以先抛开。

  • 要有比较好的效果,就需要非常多的pass来进行计算,这也是我们不希望的。因为移动平台对大量Overdraw这样的像素级处理是非常大的一笔开销。

layer30 : layer10

layer的层数是越少效率越高的,在低layer上得到更好效果是我们的目标。我将控制半透明毛发的曲线做了一些优化,勉强可以在低layer上面达到多layer的效果。

对Alpha的衰减曲线做调整

layer30 : layer10 : 拟合曲线后的 layer10

优化Layer数量

一般的做法:

1
alpha = Noise -FUR_OFFSET;

优化后:同时加入了可控的变量。

1
alpha = (Noise*2-(FUR_OFFSET *FUR_OFFSET +(FUR_OFFSET*FurMask*5)))*_tming ;

灯光

到现在为止,在外形上基本接近了我们想要的毛发效果。我们还需要将毛发的渲染特征也加上去。这里用3个部分来实现:环境光、轮廓光、太阳光

环境光

环境光可以是一个单色,也可以是一个微弱的顶底渐变,或者球协光照(提取Hdir贴图低频数据)。

这里以简单的顶底颜色为例:

1
2
float3 normal = normalize(mul(UNITY_MATRIX_MV, float4(v.normal,0)).xyz);
half3 SH = saturate(normal.y *0.25+0.35) ;

前面讲了毛发的特点之一就是环境光遮蔽与自阴影,缺少了环境光遮蔽的效果只能打20分。环境光遮蔽形成的散射是带有颜色的,会根据物体的颜色不同产生不同颜色。我很懒,就没将颜色与环境光遮蔽之间的关系公式写进去,直接开放一个颜色手动设置反弹的颜色。(也能节约一些计算不是~)

1
2
3
half Occlusion =FUR_OFFSET*FUR_OFFSET; //伽马转线性最精简版
Occlusion +=0.04 ;
half3 SHL = lerp (_OcclusionColor*SH,SH,Occlusion) ;

无环境光遮 vs 加上环境光遮蔽

但要记住固有色越浅,反弹光就越强,Visibility的影响就越弱;反之颜色越深,反弹光就越弱,Visibility的影响就越强;简单的说就是:物体的颜色越浅,AO颜色越浅;反之颜色越深,AO颜色越深。

轮廓光

轮廓光其实也是环境光的一部分。这里单独给轮廓光计算,也只是弥补环境反光的不足,同时加一些可控项,也可以调出一些特殊的不一样的效果。

毛发轮廓光

同时也和上面的一样,物体的颜色越浅,轮廓光穿透率越强;反之颜色越深,轮廓光穿透率越弱。

这里也一定要加入环境光遮蔽的遮挡,因为毛发的透光性,边缘稀疏的部分光线穿透率更高。同时模拟了在环境光下次表面散射效果。

Fresnel :Fresnel+Visilibity

差异性在低多边形下会更加明显。

1
2
3
4
5
6
7
half Occlusion =FUR_OFFSET*FUR_OFFSET; //伽马转线性最精简版
Occlusion +=0.04 ;
half Fresnel = 1-max(0,dot(N,V));//pow (1-max(0,dot(N,V)),2.2);
half RimLight =Fresnel * Occlusion; //AO的深度剔除 很重要
RimLight *=RimLight; //fresnel~pow简化版
RimLight *=_FresnelLV *SH; //加上环境光因数
SHL +=RimLight//与环境光结合

模型线框:Occlusion平方:Occlusion4四次方

将Occlusion的计算放到RimLight平方的前面,是因为模型的多边形数量低,轮廓会比较明显。将Occlusion4次方,能更好的减弱低多边形的影响。因为我们用于毛发材质的模型面数,是非常非常低的,如果模型面数相对高一些的模型,可以放到后面。

太阳光

我们平常说的太阳光,其实可以把它看成是平行光。太阳其实是个点光源,不过因为它过于庞大,光线到地球上面的夹角非常非常小,再到我们的可视物体的时候,就完全可以忽略掉了。

我们就取最简单的公式。

1
2
3
half3 lightDir = -_SGameShadowParams.xyz; //外部传入的灯光方向
half NoL =dot(lightDir,normal);
half DirLight =NoL * _FurDirLightExposure*_DirLightColor;

正测光:背光

普通光照模型这样计算没有问题,但完全没有毛发的特性:缺少太阳光在边缘的穿透性,也没有逆光下的毛发次表面散射效果。缺少每根毛产生的复杂的阴影表现。

阴影与光线边缘的穿透

用一个最简单的拟合就可以得到这个效果。主要利用了NdotL(-1~1)的特性。

1
2
3
4
5
_LightFilter("平行光毛发穿透",  Range(-0.5,0.5)) = 0.0
half3 lightDir = -_SGameShadowParams.xyz;
half NoL =dot(lightDir,normal);
half DirLight= saturate (NoL+_LightFilter+ FUR_OFFSET );
DirLight *=_FurDirLightExposure*_DirLightColor;

正测光:背光

最后将所有光照合并到一起:

佩奇陪你过大年

  • 为了节省性能,所有灯光与颜色计算全部在顶点空间完成;

  • 像素空间只用来计算贴图采样;

  • 很多需要贴图一起计算颜色值的地方都优化省略掉了,比较遗憾。

  • 所有颜色计算都是在线性空间进行的,所以最后要转换到伽马空间,这一步也可以去掉,也可以再加上简单的tommping,让画面颜色变得更好。

高光 - Anisotropic(各项异性)

前面讲毛发特性的时候,对各项异性高光做了个简单的介绍。

Anisotropic(各项异性)

学术上Anisotropic(各项异性)的解释:

  • 某些材质上有一些微观上有方向的细丝,这些细丝在宏观角度来看是不易察觉的,典型的有光盘的背面或者是头发。

  • strand based anisotropy是对上述光照情形的一种建模。(http://www.bluevoid.com/opengl/sig00/advanced00/notes/node159.html)

一些Anisotropic表现的例子:

《崩坏3》

《爱丽丝惊魂记:疯狂再临 (Alice: Madness Returns)》

各向异性制作的各种具体实现方式我就不一一细说了:

沿着法线方向去偏移切线

1
2
3
4
float3 TShift(float3 tangent,float3 normal,float shift)
           {
               return normalize(tangent + shift * normal);
           }

Anisotropic高光

1
2
3
4
5
6
7
8
float StrandSpecular(fixed3 T,fixed3 V,fixed3 L,fixed exponent)
   {
       float3 H = normalize(L+V);
       float dotTH = dot(T,H);
       float sinTH = sqrt(1- dotTH * dotTH);
       float dirAtten = smoothstep(-1,0,dotTH);    
       return dirAtten*pow(sinTH,exponent);
   }

用抖动贴图来弥补头发细节。

抖动贴图制作头发高光细节

头发比较特殊观察头发的高光会发现,其中一层高光是有颜色的,另外一层高光是没有颜色的,且两层高光的相互错开一点点。

观察头发高光

头发双层高光公式

我们根据公式在VS里计算出高光效果。这时候会发现高光效果会很粗糙,简直有点不堪入目。

顶点 VS 逐像素

一步一步来优化。

• 依然使用上面已经使用老套的方法,用FUR_OFFSET做高光的遮蔽后,因为VS渲染精度方面过低的问题缓解了,但效果方面依然不是太好。

1
fixed SPec1 =StrandSpecular (T1,V,L,_specExp.x)*FUR_OFFSET;

高光加入FUR_OFFSET对比

因为毛束是一个个细小的单个圆柱体,它的高光也并不是一个平面连续的表现。我想了很多方法来弥补毛发体积与细节。最终下面的结果相对来说,得出的结果是目前得出的最好的。大家猜一下——下面这张图是怎么得到的?

color = Alpha平方

其实这就是我们目前的alpha当做色彩输出的结果。

  • 越靠近透明的区域越黑,中间区域偏亮。

  • 它刚好能达到我们高光缺少的细节部分的要求。我就用它来当做单根毛发边缘体积的遮挡。得到的结果很不错。同时这个也替代了抖动贴图。

1
color.rgb +=i.Specular  * (Noise*Noise);

加入Noise遮罩,代替抖动贴图方案

加入低频与高频2层高光后的效果更加自然。

双层高光效果

1
2
3
4
5
fixed3 T1 = normalize(_specExp.z*normalWorld+binormalWorld);
fixed3 T2 = normalize(_specExp.w*normalWorld+binormalWorld);
fixed SPec1 =StrandSpecular (T1,V,L,_specExp.x) *FUR_OFFSET;
fixed SPec2 =StrandSpecular (T2,V,L,_specExp.y) *FUR_OFFSET;
o.Specular  = SPec1*_SPColor1  + SPec2*_SPColor2;

高光部分我测试了很多方案,目前效果只能说还能凑合着用,因为都是在VS进行的计算,效率上面应该还行。高光参考资料: https://developer.amd.com/wordpress/media/2012/10/Scheuermann_HairRendering.pdf

一些个人遇到的问题

  • 毛发UV控制部分我加入了flowmap,但是FlowTex的绘制太困难,而且也不直观。可以做个工具实时直观看到flowmap在毛发上面的绘制效果。甚至可以直接在unity绘制到模型顶点色XY2个值上面控制毛发方向。

  • 多模型重叠的时候还是有深度穿插。分享一个16年的国外视频,用了另一种方案来控制毛发长度与方向。https://www.youtube.com/watch?v=bl61E2j_q-U

References