四、毛发渲染
4.1 毛发的构造及渲染技术
毛发渲染一直是实时图形学的难题,因为其光照复杂,数量众多,物理效果不好抽象等。在早期,只能通过若干面片代替,后来随着硬件及渲染技术的提升,慢慢发展出了经验模型的Kajiya-Kay和基于物理的Marschner毛发渲染模型。Mike采用的是Marschner毛发渲染模型。
4.1.1 毛发的构造
真实世界的毛发主要由纤维构造,也可分成多层结构,有中心的发髓(Medulla)、内部的皮质(Cortex)和表皮的角质层(Cuticle)构成。(下图)
毛发剖面图
其中角质层放大后,可见坑坑洼洼的微表面(下图),它是造成高光和反射的介质。此外,光线照射毛发表皮之后,还会发生透射和次反射。
毛发放大数千倍后的微表面
毛发微表面的坑洼具有较统一的指向性,由根部指向尾部,在图形学可用切线及各向异性属性来衡量这一现象。
简化后的毛发模型
4.1.2 Marschner毛发渲染模型
Marschner是基于物理的毛发渲染模型,是Stephen R. Marschner等人共同发表的论文《Light Scattering from Human Hair Fibers》内的方法。
该方法研究分析了真实世界的毛发构成及特性,抽象出如下图所示的光照模型:
毛发对应的横截面光照模型图:
该模型将光照在毛发的作用分成3部位:
- 反射(R):表面的反射,产生主高光,受毛发切线和各向异性影响。
- 传输-传输(TT):传输-传输路线,光线照射并穿透毛囊,然后从另一边照射出去。这是光线在一定发量中的散射过程。
- 传输-反射-传输(TRT):光线进入毛囊,从内表面边界反射出来,然后再照射出来。产生的是次高光。
基于以上光照模型,论文又进一步根据几何光学分析了光线在某一个光路上的行为,并把这个行为具体的分成了两类,即纵向散射(longitudinal scattering)和方位角散射(azimuthal scattering)。
差角度计算如下:
( heta_d = ( heta_r - heta_i) /2)
(phi = (phi_r - phi_i))
半角度计算如下:
( heta_h = ( heta_r - heta_i) /2)
(phi_h = (phi_r - phi_i) /2)
(R),(TT),(TRT)三种散射纵向散射函数(M)都满足( heta_h)符合高斯分布。公式如下:
(M_R = g(eta_R, alpha_R, heta_h))
(M_{TT} = g(eta_{TT}, alpha_{TT}, heta_h))
(M_{TRT} = g(eta_{TRT}, alpha_{TRT}, heta_h))
(R)和(TRT)散射方位角散射函数(N)分别简化为$cos^2 phi (,)TT(散射方位角散射函数)N(满足)phi$ 符合高斯分布。公式如下:
(N_R= cos^2phi)
(N_{TT} = g(gamma_{TT}, 0.0, pi - phi))
(N_{TRT} = cos^2phi)
最终散射公式如下:
(S = S_R + S_{TT} + S_{TRT})
(S_P = M_P cdot N_P, for P = R, TT, TRT)
利用以上渲染技术可以渲染出Mike的直接光照部分:
不同灯光角度下的Mike毛发渲染效果
4.1.3 毛发的间接光照
毛发除了上一小节描述的直接光照外,还需要增加非直接光照,以模拟环境光或漫反射。
出于性能的考虑,UE4默认给头发加了一个类似于diffuse的fake scattering (非物理真实的散射)的散射的间接光照。渲染结果如下图:
增加了非物理真实的间接光照的效果
UE4采用的是Dual Scattering(双向散射)的多散射近似光照模型,论文出处:Dual Scattering Approximation for Fast Multiple Scattering in Hair。和离线光线跟踪毛发间接采样方法相比,双向散射会节省大量时间,质量几乎接近。
双向散射主要用于估计毛发的多散射函数,这个函数有两个部分组成:
- 全局散射函数。全局散射函数用于计算由于光穿过周边的毛发对当前毛发的散射贡献,
- 局部散射函数。局部散射用于计算由于光多次在周边头发折射对当前毛发的散射贡献。
这两种贡献的总和称为双向多散射。这种计算模型不受光源数量和类型的限制。
如上图所示,可获得如下的抽象公式:
毛发光照(包含直接光照和间接光照)实现的伪代码:
更具体的推导和实现过程请参看参考论文,也可参考这篇技术文章:Real-Time Hair Simulation and Rendering。
4.2 毛发的底层实现
UE实现毛发的shader代码主要在:
- EngineShadersPrivateShadingModels.ush。
Light Scattering from Human Hair Fibers论文给出了下面一组测量的标准值,后面的源码中大量涉及这些常量或计算公式:
下面着手分析毛发的光照着色源码:
// Approximation to HairShadingRef using concepts from the following papers:
// [Marschner et al. 2003, "Light Scattering from Human Hair Fibers"]
// [Pekelis et al. 2015, "A Data-Driven Light Scattering Model for Hair"]
float3 HairShading( FGBufferData GBuffer, float3 L, float3 V, half3 N, float Shadow, float Backlit, float Area, uint2 Random )
{
// to prevent NaN with decals
// OR-18489 HERO: IGGY: RMB on E ability causes blinding hair effect
// OR-17578 HERO: HAMMER: E causes blinding light on heroes with hair
float ClampedRoughness = clamp(GBuffer.Roughness, 1/255.0f, 1.0f);
//const float3 DiffuseN = OctahedronToUnitVector( GBuffer.CustomData.xy * 2 - 1 );
//const float Backlit = GBuffer.CustomData.z;
#if HAIR_REFERENCE
// todo: ClampedRoughness is missing for this code path
float3 S = HairShadingRef( GBuffer, L, V, N, Random );
//float3 S = HairShadingMarschner( GBuffer, L, V, N );
#else
// N is the vector parallel to hair pointing toward root
const float VoL = dot(V,L);
const float SinThetaL = dot(N,L);
const float SinThetaV = dot(N,V);
float CosThetaD = cos( 0.5 * abs( asinFast( SinThetaV ) - asinFast( SinThetaL ) ) );
//CosThetaD = abs( CosThetaD ) < 0.01 ? 0.01 : CosThetaD;
const float3 Lp = L - SinThetaL * N;
const float3 Vp = V - SinThetaV * N;
const float CosPhi = dot(Lp,Vp) * rsqrt( dot(Lp,Lp) * dot(Vp,Vp) + 1e-4 );
const float CosHalfPhi = sqrt( saturate( 0.5 + 0.5 * CosPhi ) );
//const float Phi = acosFast( CosPhi );
// 下面很多初始化的值都是基于上面给出的表格获得
float n = 1.55; // 毛发的折射率
//float n_prime = sqrt( n*n - 1 + Pow2( CosThetaD ) ) / CosThetaD;
float n_prime = 1.19 / CosThetaD + 0.36 * CosThetaD;
// 对应R、TT、TRT的longitudinal shift
float Shift = 0.035;
float Alpha[] =
{
-Shift * 2,
Shift,
Shift * 4,
};
// 对应R、TT、TRT的longitudinal width
float B[] =
{
Area + Pow2( ClampedRoughness ),
Area + Pow2( ClampedRoughness ) / 2,
Area + Pow2( ClampedRoughness ) * 2,
};
float3 S = 0;
// 下面各分量中的Mp是纵向散射函数,Np是方位角散射函数,Fp是菲涅尔函数,Tp是吸收函数
// 反射(R)分量
if(1)
{
const float sa = sin( Alpha[0] );
const float ca = cos( Alpha[0] );
float Shift = 2*sa* ( ca * CosHalfPhi * sqrt( 1 - SinThetaV * SinThetaV ) + sa * SinThetaV );
float Mp = Hair_g( B[0] * sqrt(2.0) * CosHalfPhi, SinThetaL + SinThetaV - Shift );
float Np = 0.25 * CosHalfPhi;
float Fp = Hair_F( sqrt( saturate( 0.5 + 0.5 * VoL ) ) );
S += Mp * Np * Fp * ( GBuffer.Specular * 2 ) * lerp( 1, Backlit, saturate(-VoL) );
}
// 透射(TT)分量
if(1)
{
float Mp = Hair_g( B[1], SinThetaL + SinThetaV - Alpha[1] );
float a = 1 / n_prime;
//float h = CosHalfPhi * rsqrt( 1 + a*a - 2*a * sqrt( 0.5 - 0.5 * CosPhi ) );
//float h = CosHalfPhi * ( ( 1 - Pow2( CosHalfPhi ) ) * a + 1 );
float h = CosHalfPhi * ( 1 + a * ( 0.6 - 0.8 * CosPhi ) );
//float h = 0.4;
//float yi = asinFast(h);
//float yt = asinFast(h / n_prime);
float f = Hair_F( CosThetaD * sqrt( saturate( 1 - h*h ) ) );
float Fp = Pow2(1 - f);
//float3 Tp = pow( GBuffer.BaseColor, 0.5 * ( 1 + cos(2*yt) ) / CosThetaD );
//float3 Tp = pow( GBuffer.BaseColor, 0.5 * cos(yt) / CosThetaD );
float3 Tp = pow( GBuffer.BaseColor, 0.5 * sqrt( 1 - Pow2(h * a) ) / CosThetaD );
//float t = asin( 1 / n_prime );
//float d = ( sqrt(2) - t ) / ( 1 - t );
//float s = -0.5 * PI * (1 - 1 / n_prime) * log( 2*d - 1 - 2 * sqrt( d * (d - 1) ) );
//float s = 0.35;
//float Np = exp( (Phi - PI) / s ) / ( s * Pow2( 1 + exp( (Phi - PI) / s ) ) );
//float Np = 0.71 * exp( -1.65 * Pow2(Phi - PI) );
float Np = exp( -3.65 * CosPhi - 3.98 );
// Backlit是背光度,由材质提供。
S += Mp * Np * Fp * Tp * Backlit;
}
// 次反射(TRT)分量
if(1)
{
float Mp = Hair_g( B[2], SinThetaL + SinThetaV - Alpha[2] );
//float h = 0.75;
float f = Hair_F( CosThetaD * 0.5 );
float Fp = Pow2(1 - f) * f;
//float3 Tp = pow( GBuffer.BaseColor, 1.6 / CosThetaD );
float3 Tp = pow( GBuffer.BaseColor, 0.8 / CosThetaD );
//float s = 0.15;
//float Np = 0.75 * exp( Phi / s ) / ( s * Pow2( 1 + exp( Phi / s ) ) );
float Np = exp( 17 * CosPhi - 16.78 );
S += Mp * Np * Fp * Tp;
}
#endif
if(1)
{
// Use soft Kajiya Kay diffuse attenuation
float KajiyaDiffuse = 1 - abs( dot(N,L) );
float3 FakeNormal = normalize( V - N * dot(V,N) );
//N = normalize( DiffuseN + FakeNormal * 2 );
N = FakeNormal;
// Hack approximation for multiple scattering.
float Wrap = 1;
float NoL = saturate( ( dot(N, L) + Wrap ) / Square( 1 + Wrap ) );
float DiffuseScatter = (1 / PI) * lerp( NoL, KajiyaDiffuse, 0.33 ) * GBuffer.Metallic;
float Luma = Luminance( GBuffer.BaseColor );
float3 ScatterTint = pow( GBuffer.BaseColor / Luma, 1 - Shadow );
S += sqrt( GBuffer.BaseColor ) * DiffuseScatter * ScatterTint;
}
S = -min(-S, 0.0);
return S;
}
从上面可知,先算出R、TT、TRT的各个分量的函数系数,将它们的光照贡献量相加,最后采用Kajiya Kay漫反射模型和多散射近似法模拟漫反射部分。
4.3 毛发的材质解析
本节将剖析Mike用到的毛发材质,它们的材质有个共同点:都是用了Hair的着色模型(下图)。
4.3.1 头发(M_Hair)
下图是头发(M_Hair)的总览图。
-
基础色(Base Color)
首先是下图模拟了头发中心偏亮、边缘渐变变暗的效果。(下图)
模拟的头发渐变效果如下图。
下图所示的Scalp Variation部分是提取靠近头皮(即头发根部)的UV纹理,然后去采样噪点纹理,生成一张有随机变化的遮罩图:
Hair Albedo部分主要是模拟了发根到发伟的颜色渐变,其中发根处利用颜色遮罩
hair_color_mask
更好地将发根颜色融入头皮。颜色混合最后阶段,将加入边沿色和环境遮挡色,使得头发颜色最终呈现出逼真的效果。
需要注意的是,头发的顶点色大部分是黄色,小部分是白色(下图)。
-
散射(Scatter)
对于Hair着色模型,才有此属性,以模拟头发的漫反射颜色及强度。实现方法就是将头发边缘色乘以一个缩放因子。(下图)
-
粗糙度(Roughness)
粗糙度的计算也不复杂,将基础色涉及的Scalp Variation部分输出的结果作为线性插值Alpha,在最大和最小值之间过渡,再经过一个缩放因子,即可得到最终结果。
-
切线(tangent)
利用基础色涉及的Scalp Variation部分的结果和采样噪点图,生成纹理V方向上有随机变化纹路的切线数据,以模拟头发的微平面。
-
背光度(Backlit)
背光度主要是控制头发着色过程透射(TT)部分(参见[4.2 毛发的底层实现](#4.2 毛发的底层实现))的缩放。
由UV集合2控制的贴图经由反向和阴影缩放,即可得到数据。
此外,还有顶点坐标偏移、AO等数据,这些将忽略其分析,有兴趣的读者可自行查看材质。
4.3.2 头发模糊(M_HairBlur)
头发模糊材质主要是在头发根部加入模糊效果,并且添加像素深度偏移,使得头发更好地“植入”头皮,过渡更自然。(下图)
其实现的核心是采样像素周边16个场景颜色的点,做平均计算,模拟高斯模糊的结果。(下图)
4.3.3 眉毛和睫毛(M_Lashes、M_Brows)
眉毛和睫毛的材质跟头发的材质非常接近,可参看上一小节。
4.3.4 绒毛(M_Fuzz)
绒毛是很容易被忽略的渲染细节,只有在镜头很近时才能发现。但实际上Mike的整个身体被绒毛所包围,这可以提升人物皮肤的细节和渲染真实度:
黄色区域所示便是绒毛,可见绒毛在Mike身上遍地开花
来一张近处特写:
它的材质采用透明混合、无光照着色模式。
颜色计算跟之前的毛发有点类似,先对周边场景颜色进行模糊,经过明暗度调整、边缘亮度调整,获得最终颜色。此外,也采用了位置偏移。(下图)
五、其它部位
除了皮肤、眼睛、头发等重要部位的渲染,Mike的其它部分的渲染也同样注重细节。
5.1 舌头
舌头也采用了次表面散射着色模型。
对于颜色,在一张漫反射和亮度反射图中做插值,经过饱和度调整和颜色亮度调整,获得最终颜色和自发光颜色。
对于法线,在一张基础贴图之上,混合了微观细节法线。
5.2 牙齿
对于牙齿,为了反映其类似玉石的散射效果(下图),也同样采用了次表面散射着色模型。
它的材质总览图如下:
对于颜色,在牙齿基础色和模糊后的柔色之间插值混合,结果若干次亮度、饱和度及色调(TeethTint)变换,得到中间色,再加入菲涅尔效应的边缘色,获得最终色。
对于高光,利用法线和视线向量求得一个与视角相关的因子,以便调整高光度,使得与反射向量越接近的像素高光越强。
对于粗糙度和次表面散射强度,利用AO遮罩图经过数次调整后获得。
对于法线,跟舌头类似,在一张基础贴图之上,混合了微观细节法线。
5.3 衣服
衣服启用了Masked
混合模式和Cloth
着色模型,采用了多层材质,背景层是衣服本身的材质,第二层是纽扣材质(下图)。
对于衣服本身的材质,颜色利用一张灰度图乘以指定色,再经过一系列调整获得,这种变色也是游戏领域常采用的变色方案。优点是可控制材质的明暗度和颜色,缺点是只能有单一的色相,不能有多种色相。衣服的法线也是采用两层贴图混合而成。此外,还设置了次表面散射颜色(SubsurfaceColor)、清漆(ClearCoat)、AO等属性。
对于纽扣材质,非常简单,此处忽略。
5.4 灯光
首先分析场景的布灯。人物左前方斜45度角是主灯,提供了摄影界常用的伦勃朗式的光照和阴影;角色正前方提供了一个补光灯,降低面部的阴影浓度;角色右边有一个侧灯,提供脸部和身体的侧面轮廓,提高质感;角色后方有两个背景灯,用以照亮背景和头发,使头发更具层次感,也能体现头发和耳朵的次表面散射和透射效果。(下图)
其中,主灯由蓝图动态创建而成,类似若干个聚光灯组成的灯阵,模拟很大的柔光灯,提供角色的主要光源以及眼神光。(下图)
上:由若干盏聚光灯组成的灯阵;下:眼神高光反馈的灯阵形状。
此外,场景提供了体积雾,并且配以一个点光源,模拟自然过渡的背景效果。(下图)
六、总结和展望
6.1 渲染技术总结
本系列文章紧紧围绕着Unreal的官方数字人类《Meet Mike》的角色进行渲染技术的剖析,它们涉及的技术点如下:
-
皮肤
- 基于物理的渲染(PBR)
- 双向反射分布函数(BRDF)
- 次表面散射(SSS)
- 高斯函数
- 偶极子(Dipole)
- 多偶极子(Multi Dipole)
- 多个高斯函数模拟皮肤次表面散射
- 双向次散射反射模型(BSSRDF)
- 可分离的次表面散射(SSSS)
- 奇异值分解(SVD)
- 纹理空间模糊
- 屏幕空间模糊
- 预卷积核权重
-
眼睛
- 基于物理的反射
- 镜面反射
- 折射
- 自反射(预烘焙)
- 参合多介质渲染(participating media rendering)
- 其它细节:
- 湿润度(法线扰动)
- 血色
- 接触阴影
- 泪腺体
- 遮蔽模糊体
- 眼角混合物
- 基于物理的反射
-
头发
- Marschner毛发渲染
- 反射(R)
- 透射(TT)
- 次反射(TRT)
- 双层UV
- 高精度模型
- XGen生成
- Marschner毛发渲染
6.2 能达到实时逼真的原因
能达到如此逼真的渲染效果,总结起来,主要有以下原因:
-
基于物理的光照模型
- PBR
- BSSRDF
- SSSS
-
基于真人扫描的模型
- 超高精度模型(70w顶点,60w三角面)
- 超高分辨率贴图(4K+)
- 功能众多的贴图
- 基础色、高光、粗糙、次表面散射、清漆、法线、AO等贴图
- 扫描直出、转置、二次制作
- 众多细节
- 皮肤细节:毛孔、雀斑、血丝、绒毛、双层高光、皱纹......
- 眼球细节:反射、折射、自阴影、侧面光、材质过渡、法线扰动......
-
基于物理和摄影艺术的场景灯光
- 聚光灯阵
- 补光灯
- 侧灯
- 背面轮廓灯
- 背景过渡灯
-
高度定制的材质
- 皮肤材质
- 眼睛材质
- 毛发材质
- 衣服材质
6.3 不足
就Mike而言,虽然渲染效果已经逼近真实,但也存在一些问题:
-
毛发没有物理效果。
-
材质非所有场景的灯光都能适应。在某些场景,渲染出来的角色效果存在失真现象。
-
SSSS渲染出现的皮肤条纹。
-
驱动效果不够流畅(从发布的视频得出结论)。
当然,在后续的Siren项目中,以上有些问题得到解决或缓解。
相信在强大的UE官方团队面前,虚拟数字人探索的脚步会一直向前迈进,为实时渲染领域拿下一个又一个里程碑。
本系列文章完!
本系列文章其它部分
特别说明
- 感谢参考文献的所有作者们!
- 未经允许,禁止转载!
参考文献
-
Next-Generation-Character-Rendering (ACM Transactions on Graphics, Vol. 29(5), SIGGRAPH Asia 2010)
-
Skin Microstructure Deformation with Displacement Map Convolution
-
Dual Scattering Approximation for Fast Multiple Scattering in Hair
-
GPU Gem 2: Chapter 23. Hair Animation and Rendering in the Nalu Demo