遊戲中人體皮膚實時渲染第一彈

第一彈主要介紹原理部分,第二彈  是可以在遊戲中運用的。 

要渲染皮膚,首先要知道皮膚的整體構成及屬於一種什麼樣的材質,根據現代醫學分析,皮膚大致的整體結構分爲表皮層,真皮層和皮下組織,並且是輕微半透明的,所以我們知道了,皮膚是一種多層半透明材質。

然後我們分析光射射到皮膚上的變化。

一小部分光在接觸到皮膚的時候會直接發生鏡面反射,這部分光大約佔據入射光光能的百分之六,這主要以爲菲涅爾互動和最上層含油的皮膚。下圖展示了光在皮膚表面反射示意圖。

另一部分光穿過皮膚表層會發生來回的反射,一部分被皮膚吸收,一部分再穿過表層皮膚在某個位置穿出。下圖展示了光在皮膚次表面散射的示意圖。


皮膚渲染總流程

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







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