游戏中人体皮肤实时渲染第一弹

第一弹主要介绍原理部分,第二弹  是可以在游戏中运用的。 

要渲染皮肤,首先要知道皮肤的整体构成及属于一种什么样的材质,根据现代医学分析,皮肤大致的整体结构分为表皮层,真皮层和皮下组织,并且是轻微半透明的,所以我们知道了,皮肤是一种多层半透明材质。

然后我们分析光射射到皮肤上的变化。

一小部分光在接触到皮肤的时候会直接发生镜面反射,这部分光大约占据入射光光能的百分之六,这主要以为菲涅尔互动和最上层含油的皮肤。下图展示了光在皮肤表面反射示意图。

另一部分光穿过皮肤表层会发生来回的反射,一部分被皮肤吸收,一部分再穿过表层皮肤在某个位置穿出。下图展示了光在皮肤次表面散射的示意图。


皮肤渲染总流程

1.位皮肤实现一个基于物理的镜面反射模型

我们使用BRDF进行渲染。通常BRDF的高光反射项用以下形式来表示。


其中F代表菲涅反射项,G代表阴影遮蔽函数,D代表微面元的法线分布函数,具体BRDF的详细细节可参考:https://boblchen.github.io/images/brdf/brdf.pdf

其中F的实现如下:

其中halfDir代表半角向量,ViewDir代表顶点点到摄像机的方向,F0是入射时的反射值,对于皮肤我们用0.28.

float fresnelReflectance(float3 halfDir, float3 viewDir, float F0)
{
	float base = 1.0 - dot(viewDir, halfDir);
	float exponential = pow(base, 5.0);
	return exponential + F0 * (1.0 - exponential);
}

我们这里对BRDF进行分解以高效的计算,我们这里预计算Beckmann法线分布函数的单个纹理并使用上述的fresnel近似得到足够高效的镜面反射计算式。

			float PHBeckmann(float nDotH, float m)
			{
				float alpha = acos(nDotH);
				float tanAlpha = tan(alpha);
				float value = exp(-(tanAlpha * tanAlpha) / (m * m)) / (m * m * pow(nDotH, 4.0));
				return value;
			}

			float4 frag(v2f i) : COLOR
			{
				float value = 0.5 * pow(PHBeckmann(i.tex.x, i.tex.y), 0.1);//使用纹理uv座标作为ndoth和m
				return float4(value, value, value, 1.0);
			}

使用上述代码生成的2D纹理得到镜面模型

float brdf_KS(float3 normal, float3 lightDir, float3 viewDir, float roughness, float specPower, sampler2D beckmannTex)
{
	float result = 0.0;
	float nDotL = dot(normal, lightDir);
	if (nDotL > 0.0)
	{
		float3 h = viewDir + lightDir;
		float3 halfDir = normalize(h);
		float nDotH = dot(normal, halfDir);
		float PH = pow(2.0 * tex2D(beckmannTex, float2(nDotH, roughness)).r, 10.0);
		const float F0 = 0.028; // Reflectance at normal incidence
		float F = fresnelReflectance(halfDir, viewDir, F0);
		float frSpec = max(PH * F / dot(h, h), 0.0);
		result = frSpec * nDotL * specPower;
	}
	return result;
}


2.次表面散射模型

本文提供的人体皮肤的渲染方法是用高斯函数求个近似半透明材质表面光的漫反射剖面(漫反射剖面表达的是每种颜色在材质剖面的传播及散射的数学分析,函数的参数是距离光源中心的距离及角度,我们使用一条一维曲线来表达,每种颜色有各自的漫反射剖面,如下图),这个高斯函数使用两次一维卷积来实现,通过对辐照度问的的滤波与线性组合最终生成用于实时渲染的人体皮肤纹理。


    对漫反射剖面近似有两种理论,分别是偶极子近似和多极子近似,这两种理论分表描述了光在进入人体皮肤后的传播和反射情况,后者补充了光在穿过在像耳朵这种薄的部位的情况,实际上多极子的方法实际上就是多个偶极子共同作用。注:这两种理论读者无需悉知,因为咱们用不到,有兴趣的读者可以自己研究下。

由于偶极子和多极子近似多层半透明材质漫反射剖面的方法不适于实时渲染,我们这里引入高斯函数求和来对偶极子和多极子进行近似。从而提高算法效率。

使用高斯函数求和主要有三个优点。第一,因为二维高斯函数可以分离成x方向和y方向的两个一维卷积来实现。第二,一个较宽的高斯函数的卷积可以在之前较窄的高斯函数卷积的结果上进行计算。这样计算精度可以得到很好的提高。第三,任意两个高斯函数的二维径向卷积结果为另一个高斯函数。

四个高斯函数可以很好的满足单一薄层的剖面,增加高斯函数的个数可以相应的增加精确度。

针对皮肤三层模型,我们使用以下高斯参数来近似漫反射剖面



介绍完使用高斯函数求和来近似漫反射剖面后,我们开始整个渲染流程,如下图



具体来说有三步:

(1)计算辐照度纹理。在顶点着色器将模型UV座标作为屏幕位置输出,同时输出模型每个顶点的世界座标位置,在像素着色器中对每个像素进行漫反射光照计算(如有阴影则考虑在内),得到辐照度纹理。

(2)对得到的辐照度纹理进行6次卷积操作,卷积核由漫反射剖面来确定,并生成6张卷积后的图像,由于本技术在弯曲表面的时候会有一些问题,所以需要进行UV矫正,需要计算拉伸纹理,来加入到每次高斯模糊中,下面详述。

(3)将(2)得到的多张图像根据高斯权重线性组合得到最终的次表面散射结果,加上镜面反射的结果,就是最终结果。


再计算辐照度文理的时候,要考虑到光能守恒,我们只需要考虑穿入皮肤表层以下的光量,所以要就算高光反射出去的光量,可以根据以下方案来计算高光反射的光量并存入纹理中。

 			float4 frag(v2f i) : COLOR
 			{
 				float cosTheta = i.tex.x; // N dot L or N dot V
 				float m = i.tex.y; // Roughness
 				float sum = 0.0;
 				float3 N = float3(0.0, 0.0, 1.0);
 				float3 V = float3(0.0, sqrt(1.0 - cosTheta * cosTheta), cosTheta);
 				for (int j = 0; j < NUM_TERMS; ++j)
 				{
 					float phip = (float(j) / float(NUM_TERMS - 1)) * (2.0 * PI);
 					float localSum = 0.0;
 					float cosp = cos(phip);
 					float sinp = sin(phip);
 					for (int k = 0; k < NUM_TERMS; ++k)
 					{
 						float thetap = (float(k) / float(NUM_TERMS - 1)) * (PI / 2.0);
 						float sint = sin(thetap);
 						float cost = cos(thetap);
 						float3 L = float3(sinp * sint, cosp * sint, cost);
 						localSum += brdf_KS(N, L, V, m, 1.0, _BeckmannTex) * sint;
 					}
 					sum += localSum * (PI / 2.0) / float(NUM_TERMS);
 				}

 				float value = sum * (2.0 * PI)/ float(NUM_TERMS);
 				return float4(value, value, value, 1.0);
 			}

然后再计算辐照度纹理,由于考虑到光在耳朵等薄的部位会发生透射,所以我们要计算渲染点处的皮肤厚度,并存入辐照度纹理a通道中,计算厚度在之前的文中有介绍,下面是辐照度的计算。

float3 diffuse = max(0.0, nDotL) * _LightColor0.rgb;
float reflectedEnergy = _SpecPower * tex2D(_AttenuationTex, float2(nDotL, _Roughness)).r;
float3 lighting = (1 - reflectedEnergy) * diffuse;

得到辐照度纹理后,我们进行六次矫正的高斯模糊,矫正纹理的计算如下:

float3 deriv_u = ddx(i.posWorld);
float3 deriv_v = ddy(i.posWorld);
float stretch_u = (1.0 / length(deriv_u)) * _StretchScale;
float stretch_v = (1.0 / length(deriv_v)) * _StretchScale;
return float4(stretch_u, stretch_v, 0.0, 1.0);

然后进行高斯模糊的U方向的代码如下,V方向同理:

	float stretch = tex2D(_StretchTex, i.tex).r;
	float scale = (1.0 / _TextureSize) * stretch * _GaussianWidth / _BlurStepScale;

	float curve[7] = {0.006, 0.061, 0.242, 0.383, 0.242, 0.061, 0.006};
	float2 coords = i.tex - float2(scale * 3.0, 0.0);
	float4 sum = 0.0;
	for (int j = 0; j < 7; ++j)
	{
		float4 tap = tex2D(_MainTex, coords);
		sum += curve[j] * tap;
		coords += float2(scale, 0.0);
	}

	return sum;


6次高斯模糊之后我们得到6张模糊后的纹理,然后在最终shader中进行线性组合,

				float4 tap1 = tex2D(_IrradianceTex, i.tex);
				float4 tap2 = tex2D(_Blur2Tex, i.tex);
				float4 tap3 = tex2D(_Blur3Tex, i.tex);
				float4 tap4 = tex2D(_Blur4Tex, i.tex);
				float4 tap5 = tex2D(_Blur5Tex, i.tex);
				float4 tap6 = tex2D(_Blur6Tex, i.tex);
				float3 totalWeight = _Blur1WV.xyz + _Blur2WV.xyz + _Blur3WV.xyz + _Blur4WV.xyz + _Blur5WV.xyz + _Blur6WV.xyz;
				float3 diffuse = float3(0.0, 0.0, 0.0);
				diffuse += _Blur1WV.xyz * tap1.rgb;
				diffuse += _Blur2WV.xyz * tap2.rgb;
				diffuse += _Blur3WV.xyz * tap3.rgb;
				diffuse += _Blur4WV.xyz * tap4.rgb;
				diffuse += _Blur5WV.xyz * tap5.rgb;
				diffuse += _Blur6WV.xyz * tap6.rgb;
				diffuse /= totalWeight;

组合之后的结果加上在薄的部位的透射光(之前玉石渲染里有介绍),得到散射的结果。


然后再计算出高光部分结果,相加就是最终效果。

当然,还有接缝问题的处理,预先散射变形,后置散射变形等。

接缝可以根据,6次拉伸纹理的a通道去计算边界,然后和关闭散射使用传统漫反射的结果做一个lerp。

预先散射变形,后置散射变形是在计算辐照度的时候引入纹理颜色还是在最后引入纹理颜色。

解决办法可以在计算辐照度的时候乘上pow(albedo,_Mix),在最后散射记过再乘上pow(albedo,1-_Mix)。来解决。


最终的渲染结果如下:




参考:

https://developer.nvidia.com/gpugems/GPUGems3/gpugems3_ch14.html







發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章