各向异性
在此前的文章中谈及的材质都属于各向同性,但本文即将谈及的头发材质却属于各向异性
各向异性顾名思义,在不同的方向有着不同的特性(吸光度、折射率等等)
各向异性和各向同性主要的差异体现在视觉上,即和光反应后的物理视觉效果。比如一枚CD硬盘,放置在阳光,即使观察不变,但360°旋转它,导致观察到的视觉效果差距是很大的
那么导致各向异性的原因是什么呢?各向异性材质在微观上有一些方向性的细丝,这些细丝以宏观角度来看是不易察觉的。这里所指的细丝即下图中黑色箭头的射线
在光照模型中,可以把这些细丝看作直径很小且长度很长的圆柱,在宏观上来看,在上图这跟粗的圆柱的某一点的光照实际为它一圈的多个直径很小且长度很长的圆柱的光照的总和
头发构造
真实世界的毛发主要由纤维构造,也可分成多层结构,有中心的发髓(Medulla)、内部的皮质(Cortex)和表皮的角质层(Cuticle)构成。如下图所示:
- 角质层:包裹在发丝最外层的是一种组织,很薄,形如瓦片般层层堆叠,互相依附从而构成了发丝的外层几何形态
角质层放大后,可见坑坑洼洼的微表面,它是造成高光和反射的介质。此外,光线照射毛发表皮之后,还会发生透射和次反射:
毛发放大数千倍后的微表面存在坑洼,具有较统一的指向性,由根部指向尾部,在图形学可用切线及各向异性属性来衡量这一现象:
方案
Shell Based Fur
即多层渲染,以一根头发举例,不渲染这个头发,而是渲染这跟头发上以根部到顶部的多个面片,只要这些面片距离足够的近,就能产生单根头发的错觉
- 缺点:若头发越长,需要的横截面就越多,越影响性能
Card & Mesh
目前主要体现在写实和卡渲的风格差距上
人体发量大体在10w左右,使用strands渲染对性能影响巨大。因此划分面片或者Mesh 将是一种妥协式的解决方案
卡渲更多使用Mesh,写实用Card
曲面细分
最早的Hairwork用于2013年的《使命召唤:幽灵》上
视察贴图
头发模型
KajiyaKay
Kajiya Kay模型将头发抽象为一种不透明的单圆柱,在垂直于发丝的朝向(fiber direction)上观察高光走向,但不能产生透射和内部反射
KajiyaKay和Blinn-Phong高光一样需要用到normal,由于每根头发一圈360°都存在normal,这样会导致极大的计算量,但头发的副切线(下图中的T,沿着发丝方向)是唯一的,KajiyaKay以该副切线做文章,最终模拟出了各向异性高光
- 光照主要分为Diffuse和Specular
- Diffuse:Lambert
但和Lambert还是有区别。前面提到当计算发丝上一点的光照时,实际是计算发丝这一点周围一圈上所有直径很小但长度很长的细丝的光照总和,即该点切面半圆上的积分
- Specular:Phong
同样的,Phone所有所区别。光照击中头发之后,所求的依旧是切面的所有反射方向,但反射方向是沿着切线而不是法线以镜面反射角度射出的
- 但实际计算中,会用到另一个公式
与前一个公式的不同之处有:
- 考虑毛发的自阴影,防止diffuse项过亮
- 头发的高光项改写成了两层高光,其中一层高光是有颜色的,另外一层高光是没有颜色的,且两层高光的相互错开一点点
- Shift贴图
仅仅使用当前光照算法得到的结果是不够的,该算法得到的天使环会非常规则,并不会出现动漫中高光的些许偏移。为了得到高光的便宜效果,需要对副切线T添加offset,这就需要用到一张贴图,该贴图存储每根头发的高光offset值
计算shit bitTangent公式如下:
float3 KajiyaKayShiftTangent(float3 tangent, float3 normal, float shift) { float3 bitTangent = tangent + normal * shift; return normalize(bitTangent); }
- 缺点
- kajiya是经验模型,并非基于物理。多用于移动端
- 无法复现出发丝的许多其他光学特性
Marschner
Marschner等人在KajiyaKay的基础上,提出了更为全面和准确的物理与数学模型,后面相关的算法都是对Marschner的拟合与改进
该方法研究分析了真实世界的毛发构成及特性,抽象为如下图所示的光照模型:
对应的横截面光照模型图:
该模型将光照在毛发的作用分成3部位:
- 反射(R):光线到达角质层直接被反射,产生主高光,受毛发切线和各向异性影响
-
传输-传输(TT):光线透射角质层进入皮层,角质层内层折射,又透射到空气中。产生透射光
-
传输-反射-传输(TRT):光线透过真皮层,在真皮层内部被角质层折射的部分,产生次高光
理论上光线应该可以在内部继续弹射,但因为能量吸收,导致这些效果是相当微弱的,所以得到的公式如下:
对头发着色时,每个像素P都有R,TT,TRT 三项,可以把每项分解成 M、N、A,其中 M 项是轴向散射函数(Longitudinal scattering),N 项是垂面(方位角)散射函数(Azimuthal scattering),A项是吸收和反射
但事实上,Marschner模型是非常非常复杂的(这里不会提及Marschner模型具体的样子,有兴趣的可以看看这位大佬讲的),根本无法用实时的要求来实现Marschner。后续提出的方案都是基于Marschner来做近似,从而优化性能
在谈论Marschner的优化方案前,还需要先对毛发与光交互的物理过程有一个比较清晰的数学模型,定义其中的重要符号与变量。如下图所示,右边是发丝的模型,中间是发丝的横截面
其中:
- u(轴向矢量):沿着发根朝向发梢的方向。表示横截面所处位置的切线方向
- v-w面(法平面):v、w、u构成右手坐标系,v轴方向为发丝横截面的长轴方向,w轴方向为发丝横截面的短轴方向(发丝横截面为椭圆)
- ω_i:入射光方向
- θ_i(入射角):入射光和法平面的夹角
- \phi_i(入射方位角):法平面v轴转向入射平面和法平面交线为止
- ω_r(散射光方向):需要计算得到的目标向量。可用于表示R、TT、TRT中任意一种出射光向量
- θ_r(散射角):从散射光和法平面交线开始,转到ω_r
- \phi_r(散射方位角):从法平面v轴转到散射平面为止
UE对Marschner的拟合
Diffuse
Diffuse采用Kajiya Kay漫反射结合多重散射近似方案。但需注意,该方案是非物理的
多重散射:发丝与发丝之间的多次弹射最终落入像机的光能。头发并不是一根根孤立的个体,而是一簇簇的发丝聚合,多重散射的本质就是以发丝聚合体为单位所发出的一种漫反射(diffuse),理论上它没有特别的传播方向,理想状况下就如同普通散射一般,多重散射会与发丝簇的宏观法线以及入射光线方向夹角的余弦相关,此外还可以纳入阴影强度 (shadow),并考虑上头发的基础颜色。这里多重散射采用的是双向散射的近似,在节省大量时间的情况下,和非近似的效果相差无几
公式如下:
其中:
- n:在UE中定义为fake normal,可用公式n = normalize(v - N * dot(N, v))得到,N为发丝的宏观法线
- Luma:从alebdo中提取的lum值,可用公式lum = dot(albedo, (0.3, 0.59, 0.11))得到
但UE实际实现中,对\frac{dot(N, L) + 1}{4 \pi}做了另一种近似,额外考虑物体的metallic
Specular
R项
M项
不论是R、TT、TRT,其M项都类似的,使用高斯分布近似
但UE中,对于反射分量M_R,采用效果更好但是更加昂贵的Weta模型,而没用高斯分布,原因是该模型是能量守恒的,对于基于物理的环境光,能给出相比高斯函数更加真实的辐射亮度估计。公式如下:
其中:
- β:为与roughness相关的参数
- θ_i:入射角,定义了入射光与法平面之间的夹角
- $I_0(\frac{cos_{θi} cos{θ_r}}{v})$:第一类修正贝塞尔函数
N项
模拟光线随方位角变化而衰减的物理现象
其中:
- \phi:\phi_r - \phi_i,入射和出射光线在法平面上投影线段的夹角
A项
使用Schlick近似
其中:
- η:材质的折射率
- x:在R项中,x = dot(V, H)
TT项
M项
采用高斯函数近似
其中:
- β:与roughness相关的参数。TT项的β为0.5*roughness^2
- α:轴向的偏移角。可暴露给美术调节
N项
一旦折射光进入发丝内部后,光路解算起来就会变得相对复杂许多,主要复杂在衰减项A、方位角分布函数Dp
其中:
- h:入射光所在直线到圆心的距离
-
u:光线波长
-
衰减项A:可分解为两部分,一部分是折射定律f,通过折射率η和入射角计算光线的折射和反射占比,由Fresnel定义;另一部分是吸收项T,用于估算光的能量在发丝内部传播被吸收的程度
-
方位角分布函数DP:给定h,估算目标光路对方位角\phi_r的贡献度,随后对h积分将所有光路求和
可以认定DP为一个均值为0的高斯分布,当入参为0时, \phi - Φ(p, h) = 0时取到极大值
被减数Φ意为待测方位角的变化量(\phi_r - \phi_i),它通过p确定内部反射次数且用h确定原始入射角度,以得到当前光路的实际出射方向
吸收项T
该项为N项的一部分,用于估算光的能量在发丝内部传播被吸收的程度,主要为头发提供染色(当光线与电介质反应,少部分被直接反射,大部分会折射进内部,其中一部分的光子容易被散布在物体内部的金属离子或其他组织(色素)吸收从而完成染色,最后在若干次反射后再次折射出物体表面,形成散射光被人眼或摄像机捕捉到)
由此可见,T项一定会带有光波长μ参数以便对头发颜色控制,此外T项还需要考虑在物质内传播的路径长度,这和入射角相关,也和反射次数有关
定义如下:
UE对该公式做了优化,它们从Pixar提出的简化版起手:
其中:
- γ_t:折射角
- ζ(C):输入颜色的整体波长
UE将γ_t进一步拆解,并将BaseColor代替ζ(C)(公式中的h会在下方的A项中提到)
方位角分布函数DP
UE通过产生各种高斯函数的逼近提出了自己的分布公式,TT项的DP函数如下:
TT输出曲线如下:
可以看出,函数大约在\phi = π时取到最大值,也就是当入射方位角\phi_i与出射方位角\phi_r相差π时
A项
UE使用的标准模型,f项使用Schlick近似:
这里为了求取f项,先定义以下公式:
这里的η'并不是材质的折射率,而是法平面投影上的折射率
第一项α是给出的一个定义,后续会用
第二个式子η'是由UE4提出的改良后的折射率公式,与材质折射率η、θ_d(V和H)有关
第三个式子为近似
θ_d = \frac{θ_r - θ_i}{2}
UE4依据Weta模型提出了用于计算h_{tt}(f项)的表达式:
再使用二倍角公式得到h_{TT}^2:
但这样的表达式的计算代价是较大的,UE又对其拟合,得到如下近似公式:
TT项的标准模型中,p = 1,因此F = (1 - f)^2,而f的入参为cos_{θ} * sqrt(saturate(1 - Htt * Htt))
TRT项
M项
和TT项一模一样,但TRT项的β为2*roughness^2
N项
T项
TRT项的T项TRT,UE4舍去幂指数分子的部分,改用常数项模拟
DP项
TRT项的DP项依旧采用高斯函数近似,公式如下:
输出图像如下:
可以看出,函数大约在\phi = 0时取到最大值,也就是当入射方位角\phi_i与出射方位角\phi_r相差为0时
A项
TRT项的A项,p = 2,可得F = (1 - f)^2f,但f项的入参除cos_θ外UE仅仅给了个常数0.5f——即cosθ * 0.5f
实现
这里实现两套方案,一套低消耗,一套高消耗
Marschner近似
Diffuse
Kajiya Kay漫反射结合多重散射近似方案,并纳入shadow、alebdo,在节省大量时间的情况下,和非近似的效果相差无几
float3 KajiyaKayDiffuse(float3 albedo, float metallic, float3 lightDir, float3 viewDir, float3 normal, float shadow)
{
float3 fakeNormal = normalize(viewDir - normal * dot(viewDir, normal));
normal = fakeNormal;
float warp = 1;
float NoL = saturate(dot(normal, lightDir) + warp) / pow2(1.f + warp);
float KajiyaDiffuse = 1.f - abs(dot(normal, lightDir));
// 漫反射项的另一种近似,考虑了表面金属度
float diffuseScatter = lerp(NoL, KajiyaDiffuse, 0.33f) * metallic * INV_PI;
float3 luma = Luma(albedo);
float3 albedoOverLuma = abs(albedo / max(luma, 0.0001f));
float3 scatterTint = shadow < 1.f ? pow(albedoOverLuma, 1.f - shadow) : 1.f;
return sqrt(abs(albedo)) * diffuseScatter * scatterTint;
}
metallic = 1时,效果如下:
Specular
R
- M项
M项可以分为两种,一种是用高斯近似,另一种是用效果更好但是更加昂贵的Weta模型
Weta模型:
// 第一类修正贝塞尔函数 I0(x) 的实现 float ModifiedBesselI0(float x) { // 处理特殊情况 if (x == 0.f) { return 1.f; } float sum = 0.f; float term = 1.f; // 初始项 (x/2)^0 / 0!^2 = 1 int k = 0; [unroll(64)] for(; term > FLT_EPS;) { sum += term; k++; term *= (x * x) / (4.0 * k * k); } return sum; } float M = 0.f; float v = pow2(clampedRoughness); M += rcp(v * exp(2.f / v)); M *= exp((1.f - sinThetaL * sinThetaV) / v); M *= ModifiedBesselI0(cosThetaL * cosThetaV / v);
高斯近似:
float Hair_g(float roughness, float Theta) { return exp(-0.5f * pow2(Theta) / pow2(roughness)) / (sqrt(TWO_PI) * roughness); } // Alpha[0] = -0.07 M = Hair_g(pow2(clampedRoughness), sinThetaL + sinThetaV - Alpha[0]);
- N项
float N = 0.25f * cosHalfPhi;
- A项
float Hair_F(float CosTheta) { const float n = 1.55; const float F0 = pow2((1 - n) / (1 + n)); return F0 + (1 - F0) * pow5(1 - CosTheta); } float A = Hair_F(sqrt(saturate(0.5f + 0.5f * VoL))) * lerp(1, 0.5f, saturate(-VoL));
- 合并效果
TT
-
M项
// Alpha[1] = 0.035 M = Hair_g(0.5f * pow2(clampedRoughness), sinThetaL + sinThetaV - Alpha[1]);
- N项
- 吸收项T
float a = rcp(n_prime); float hTT = cosHalfPhi * (1.f + a * (0.6f - 0.8f * cosPhi)); T = pow(brdf_data.albedo, 0.5f * sqrt(1.f - pow2(hTT * a)) * rcp(cosThetaD));
- 分布函数DP
DP = exp(-3.65f * cosPhi - 3.98f);
- A项
float fTT = Hair_F(cosThetaD * sqrt(saturate(1.f - pow2(hTT)))); A = pow2(1.f - fTT);
- 合并效果
TRT
-
M项
// Alpha[2]:0.12 M = Hair_g(2.f * pow2(clampedRoughness), sinThetaL + sinThetaV - Alpha[2]);
- N项
- T项
T = pow(brdf_data.albedo, 0.8f * rcp(cosThetaD));
- DP项
DP = exp(17.f * cosPhi - 16.78f);
- A项
float fTRT = Hair_F(cosThetaD * 0.5f); A = pow2(1.f - fTRT) * fTRT;
- 合并效果
-
添加GI和参数后
KajiyaKay
Diffuse
float3 KajiyaKayShiftTangent(float3 tangent, float3 normal, float shift)
{
float3 bitTangent = tangent + normal * shift;
return normalize(bitTangent);
}
Specular
float3 KajiyaKaySpecular(float width, float intensity, float3 shiftTangent, float3 lightDir, float3 viewDir)
{
float3 o;
float3 H = normalize(lightDir + viewDir);
float ToH = dot(shiftTangent, H);
float sinToH = sqrt(1.f - pow2(ToH));
o = smoothstep(-1, 0, ToH);
o *= pow(sinToH, width * 10.f);
o *= intensity;
return o;
}
两个高光,两个shiftTangent
float3 shiftTangent1 = lerp(lightData.bitTangentWS + _KajiyaKayFirstOffset, KajiyaKayShiftTangent(lightData.tangentWS, lightData.normalWS, brdfData.anisotropy + _KajiyaKayFirstOffset), _AnisotropyIntensity);
float3 shiftTangent2 = lerp(lightData.bitTangentWS + _KajiyaKaySecondOffset, KajiyaKayShiftTangent(lightData.tangentWS, lightData.normalWS, brdfData.anisotropy + _KajiyaKaySecondOffset), _AnisotropyIntensity);
float3 specular = KajiyaKaySpecular(_KajiyaKayFirstWidth, _KajiyaKayFirstIntensity, shiftTangent1, light.direction, lightData.viewDirWS) * _KajiyaKayFirstSpecularColor;
specular += KajiyaKaySpecular(_KajiyaKaySecondWidth, _KajiyaKaySecondIntensity, shiftTangent2, light.direction, lightData.viewDirWS) * _KajiyaKaySecondSpecularColor;
specular *= smoothstep(-1, 1, brdfData.NoL);
如何渲染?
问题来了,头发这种半透明用alpha blend合适吗?可以但有缺点:
- 开销大
- 因为头发是多个面片穿插在一起的,在半透明下会导致渲染顺序错误
因此需要一个方案,既满足开销低、不关心渲染顺序、半透明模样——即AlphaTest、DitherOpacityMask、TAA
最终效果
KajiyaKay效果如下:
Marschner近似效果如下:
Comments | NOTHING