数字人类(Digital Human)是利用计算机模拟真实人类的一种综合性的渲染技术。也被称为虚拟人类、超真实人类、照片级人类。git
它是一种技术和艺术相结合的综合性模拟渲染,涵盖计算机图形渲染、模型扫描、3D建模、肢体驱动、AI算法等领域。算法
数字人类概念图api
随着计算机渲染技术的发展,数字人类在电影领域早有应用。在上世纪80年代的《星球大战》系列、《异形》系列等电影,到后来的《终结者》系列、《黑客帝国》系列、《指环王》系列等,再到近期的漫威、DC动画电影,都存在着虚拟角色的身影,他们或是天赋异禀的人类,或是奇形怪状的怪物。session
《星球大战I》中的虚拟角色:尤达大师(Master Yoda)app
《黑客帝国》的主角不少镜头是采用计算机渲染而成的虚拟数字人less
电影《战斗天使》的画面。主角阿丽塔也是虚拟角色。编辑器
因为近些年计算机硬件性能和渲染技术的提高,除了在离线渲染领域的电影和动漫的普遍应用以外,实时领域的应用也获得长足的进步。例如,次世代游戏、3A大做、VR游戏以及泛娱乐领域的直播领域。ide
《孤岛惊魂5》中的虚拟游戏角色函数
R&D和暴雪在GDC2013展现的次世代虚拟角色
Unreal Engine在GDC2018展现的虚拟角色Siren,可由演员实时驱动动做、表情、肢体等信息。
数字人类的步骤多,工序繁琐。但总结起来,一般有如下几个步骤:
模型扫描。一般借助光学扫描仪或由单反相机组成的360度的摄影包围盒,对扫描对象进行全方位的扫描,从而得到原始的模型数据。
上图展现了模型扫描仪,由不少摄影和灯光设备组成的球形矩阵。
模型调整。由扫描阶段获取的初始模型一般有瑕疵,没法直接投入渲染。须要美术人员利用3D建模工具(如Maya、3DMax等)进行调整、优化、从新拓扑,最终调整成合适的可用模型。
左:扫描的初始模型;中:调整后的中间模型;右:优化了细节的可用模型。
制做贴图。在此阶段,用建模软件或材质制做软件(如Substance)采纳高精度模型烘焙或制做出漫反射、法线、粗糙度、AO、散射、高光等等贴图,为最后的渲染作准备。这些贴图的原始尺寸一般都很是大,4K、8K甚至16K,目的是高精度还原虚拟人类的细节。
漫反射贴图
法线贴图
导入引擎。在此阶段,将以前制做的模型和贴图导入到渲染引擎(如UE四、Unity等),加入光照、材质、场景等元素,结合角色的综合性PBR渲染技术,得到最终成像。
Unreal Engine渲染出的虚拟角色
Unreal Engine做为商业渲染引擎的巨头,在实时领域渲染数字人类作了不少尝试,关键节点有:
2015年:《A Boy and His Kite》。展现了当时的开放世界概念和天然的角色动画风格与凭借第一人称射击游戏成名的Epic之前作过的任何项目都大不相同。
《A Boy and His Kite》的画面
2016年:《地狱之刃:塞娜的献祭》。这是Unreal将数字人引入实时游戏的一次尝试,从画质表现上,已经达到了异常逼真的程度。
《地狱之刃:塞娜的献祭》中的游戏角色画面
2017年:《Meet Mike》。在Siggraph 2017中,Epic Game凭借此项目为世人展现了数字人科技的最新研究:利用最早进的画面捕捉技术、体感控制技术以及画面渲染技术在计算机中塑造人类的化身。其中数字人Mike是著名电影特效大师以及Fx Guide网站创始人Mike Seymour的化身。
Unreal Engine官方团队制做的Mike虚拟角色
2018年:《Siren》。Siren是Epic Game、3Lateral、Cubic Motion、Vicon以及腾讯的NEXT工做室等多家跨国公司倾力合做,花费半年多打造的顶级实时渲染的虚拟角色。从画质效果上看,已经与数码照片无异。
《Siren》虚拟角色的细节,与数码相机摄制的照片一模一样
笔者本想以《Siren》的虚拟角色为依托进行研究,奈何官方并未将此项目开源。
因此本文只能用《Meet Mike》项目的角色做为研究对象。
《Meet Mike》项目的资源和源码能够从Unreal Engine的Epic Games Launcher中下载得到。
《Meet Mike》资源和源码下载具体步骤
若成功下载了Mike工程,打开项目的DigitalHuman.uproject文件,能够看到下面的画面:
点击右上角World Outliner面板的”final_mike“,能够查看Mike模型及其全部材质的细节。
若是要研究某个部分的材质(好比皮肤),双击对应的材质,便可打开材质节点。下图是双击M_Head皮肤材质后的界面:
打材质编辑器后,即可以进行后续的研究。后面章节将着重研究数字人的皮肤、眼球、毛发以及身体其它部位的渲染技术。
Mike的一些数据:
57万个三角形,69万个顶点。其中大量三角形集中在脸部,特别是头发,约占75%。
每根头发都是单独三角形,大约有2万多根头发。
脸部骨骼绑定使用了大约80个关节,大部分是为了头发的运动和脸部毛发。
脸部使用了Technoprop公司先进的配有立体红外摄像头的固定在头部的面部捕捉装置。
综合使用了750个融合变形(blend shapes)。
系统使用了复杂的传统软件和三种深度学习AI引擎。
皮肤渲染技术通过数十年的发展,由最初的单张贴图+伦勃朗的渲染方式到近期的基于物理的SSSSS(屏幕空间次表面散射)。由此衍生出的皮肤渲染技术层出不穷,其中最最基础也最具表明性的是次表面散射(SSS)。
在虚拟角色渲染中,皮肤的渲染尤其关键。由于皮肤是人们天天亲眼目击的很是熟悉的东西,若是稍微渲染很差或细节处理不足,便会陷入恐怖谷(Uncanny Valley )理论。至于什么是恐怖谷理论,参看这里。
上图因为皮肤的细节处理不到位,陷入了恐怖谷理论
人类皮肤的物理构成很是复杂,其表层和内部都由很是复杂的构成物质,剖面图以下:
绒毛(hair shaft)。附着于皮肤表面的细小的毛。
油脂(oil)。皮肤表层有一层薄薄的油脂覆盖,是皮肤高光的主要贡献者。
表皮(epidermis)。油脂层下是表皮覆盖,是形成次表面散射的物质之一。
真皮(dermis)。表皮下面是真正的皮肤组织,也是形成次表面散射的物质之一。
毛囊(hair follicle)。绒毛的皮下组织和根基。
静脉(vein)。呈深蓝色的血管。
动脉(artery)。呈暗红色的血管。
脂肪组织(fatty tissue)。脂肪组织也是形成次表面散射的次要贡献物质。
其它:皮肤表面的纹理、皱纹、毛孔、雀斑、痘痘、黑痣、疤痕、油脂粒等等细节。
真实皮肤包含了很是多的细节:毛孔、绒毛、痘痘、黑痣、油脂......
皮肤表面油脂层主要贡献了皮肤光照的反射部分(约6%的光线被反射),而油脂层下面的表皮层和真皮层则主要贡献了的次表面散射部分(约94%的光线被散射)。
虽然皮肤构成很是复杂,但图形渲染界的先贤者们利用简化的思惟将皮肤建模成若干层。
以上展现的是BRDF建模方式,只在皮肤表面反射光线,但实际上在三层建模中,还会考虑表皮层和真皮层的次表面散射(BSSRDF),见下图中间部分BSSRDF。
皮肤渲染涉及的高级技术有:
皮肤渲染的过程能够抽象成如下步骤:
皮肤反射。
直接反射部分采用Cook-Torrance的BRDF,公式:
\[ f_{cook-torrance} = \frac {D(h)F(l,h)G(l,v,h)}{4(n\cdot l)(n\cdot v)} \]
具体解析和实现请参看《由浅入深学习PBR的原理和实现》的章节3.1.3 反射方程。
UE的皮肤渲染采用双镜叶高光(Dual Lobe Specular)。双镜叶高光度为两个独立的高光镜叶提供粗糙度值,两者组合后造成最终结果。当两者组合后,会为皮肤提供很是出色的亚像素微频效果,呈现出一种天然面貌。
其中UE默认的混合公式是:
\[ Lobe1 \cdot 0.85 \ + \ Lobe2 \cdot 0.15 \]
下图显示了UE4混合的过程和最终成像。
左:较柔和的高光层Lobe1; 中:较强烈的高光层Lobe2; 右:最终混合成像
非直接反射部分采用预卷积的cube map。
具体解析和实现请参看《由浅入深学习PBR的原理和实现》的章节3.3.2 镜面的IBL(Specular IBL)。
皮肤毛孔。
皮肤毛孔内部构造很是复杂,会形成反射(高光和漫反射)、阴影、遮挡、次表面散射等效应。
人类毛孔放大图,内部构造异常复杂,由此产生很是复杂的光照信息
在渲染毛孔细节时,需注意不少细节,不然会渲染结果陷入恐怖谷理论。
理论上,接近物理真实的渲染,毛孔的渲染公式以下:
\[ cavity \cdot Specular(gloss) \cdot Fresnel(reflectance) \]
其中:
\(cavity\)是凹陷度。可从cavity map(下图)中采样得到。
\(Specular(gloss)\)代表高光项。
\(Fresnel(reflectance)\)是与视觉角度相关的反射。
然而,这种物理真实,使得凹陷太明显,视觉不美观,有点让人不适:
尝试微调高光和cavity的位置,可得到下面的渲染结果:
上图能够看出,高光太强,凹陷细节不足,也是不够真实的皮肤渲染结果。
实际上,可摒弃彻底物理真实的原理,采用近似法:
\[ Specular(gloss) \cdot Fresnel(cavity \cdot reflectance) \]
最终可渲染出真实和美观相平衡的画面:
UE4采用漫反射+粗糙度+高光度+散射+法线等贴图结合的方式,以高精度还原皮肤细节。
从左到右:漫反射、粗糙度、高光度、散射、法线贴图
具体光照过程跟Cook-Torrance的BRDF大体同样,这里不详述。
全局光照。
皮肤的全局光照是基于图像的光照(IBL)+改进的AO结合的结果。
其中IBL技术请参看3.3 基于图像的光照(Image Based Lighting,IBL)。
上图:叠加了全局光照,但无AO的画面
AO部分是屏幕空间环境光遮蔽(SSAO),其中AO贴图混合了Bleed Color(皮肤一般取红色)。
增长了红色Bleed Color的AO,使得皮肤渲染更加贴切,皮肤暗处的亮度和颜色更真实美观。
次表面散射(BSSRDF)。
这部份内容将在2.2更详细描述。
次表面散射(Subsurface scattering)是模拟皮肤、玉石、牛奶等半透光性物质的一种物理渲染技术。
它与普通BRDF的区别在于,同一条入射光进入半透光性物质后,会在内部通过屡次散射,最终在入射点附近散射出若干条光线。
因为R、G、B在物质内扩散的曲线不同,由此产生了与入射光不同的颜色。
红色光因为穿透力更强,更容易在皮肤组织穿透,造成红色光。
BSSRDF是基于次表面散射的一种光照模型,充分考虑了入射光在物质内部通过若干次散射后从新反射出来的光。
左:BRDF;右:BSSRDF,考虑了输入光在物质内散射后从新射出的若干条光
上图描述了BRDF、BTDF、BSSRDF之间的关系:
下面两图展现了使用BRDF和BSSRDF的皮肤渲染结果:
BRDF光照模型渲染的皮肤
BSSRDF光照模型渲染的皮肤
可见BSSRDF渲染的皮肤效果更真实,更美观,防止陷入恐怖谷效应。
回顾一下BRDF的方程,它是一次反射光照的计算是在光线交点的法线半球上的球面积分:
\[ L_o(p,\omega_o) = \int\limits_{\Omega} f_r(p,\omega_i,\omega_o) L_i(p,\omega_i) n \cdot \omega_i d\omega_i \]
对于BSSRDF来讲,每一次反射在物体表面上每个位置都要作一次半球面积分,是一个嵌套积分:
\[ L_o(p_o,\omega_o) = \int\limits_{A} \int\limits_{\Omega} S(p_o,\omega_o,p_i,\omega_i) L_i(p_i,\omega_i) n \cdot \omega_i d\omega_i dA \]
\(S(p_o,\omega_o,p_i,\omega_i)\)项代表了次表面散射的计算过程,具体公式:
\[ \begin{eqnarray} S(p_o,\omega_o,p_i,\omega_i) &\stackrel {def}{=}& \frac{dL_r(p_o,\omega_o)}{d\Phi_r(p_i,\omega_i)} \\ &=& \frac{1}{\pi}F_t(p_o,\omega_o)R_d(\parallel p_i-p_o\parallel)F_t(p_i,\omega_i) \\ \end{eqnarray} \]
其中:
\(\frac{dL_r(p_o,\omega_o)}{d\Phi_r(p_i,\omega_i)}\)代表BSSRDF的定义是出射光的辐射度和入射通量的比值。
\(F_t\)是菲涅尔透射效应。
\(R_d(\parallel p_i-p_o\parallel)\)是扩散反射(Diffuse reflectance),与入射点和出射点的距离相关。
\[ R_d(\parallel p_i-p_o\parallel) = -D\frac{(n\cdot \triangle\phi(p_o))}{d\Phi_i(p_i)} \]
因而可知,\(S\)项的计算过程比较复杂,对于实时渲染,是几乎不可能完成的。由此可采用近似法求解:
\[ S(p_o,\omega_o,p_i,\omega_i) \approx (1-F_r(\cos\theta_o))S_p(p_o,p_i)S_\omega(\omega_i) \]
其中:
\(F_r(\cos\theta_o)\)是菲涅尔反射项。
\(S_p(p_o,p_i)\)是点\(p\)处的次表面散射函数。它能够进一步简化:
\[ S_p(p_o,p_i) \approx S_r(\parallel p_o - p_i\parallel) \]
也就是说点\(p\)处的次表面系数只由入射点\(p_i\)和出射点\(p_o\)相关。
\(S_r\)跟介质的不少属性有关,可用公式表达及简化:
\[ \begin{eqnarray} S_r(\eta,g,\rho,\sigma_t,r) &=& \sigma^2_t S_r(\eta,g,\rho,1,r_{optical}) \\ &\approx& \sigma^2_t S_r(\rho,r_{optical}) \\ r_{optical} &=& \rho_t r \end{eqnarray} \]
简化后的\(S_r\)只跟\(\rho\)和\(r\)有关,每种材料的\(\rho\)和\(r\)可组成一个BSSRDF表。
上图展现了\(\rho=0.2\)和\(r=0.5\)的索引表。
经过\(\rho\)和\(r\)可查询到对应的\(S_r\),从而化繁为简,实现实时渲染的目标。
\(S_\omega(\omega_i)\)是有缩放因子的菲涅尔项,它的公式:
\[ S_\omega(\omega_i) = \frac{1-F_r(\cos\theta_i)}{c\cdot \pi} \]
其中\(c\)是一个嵌套的半球面积分:
\[ \begin{eqnarray} c &=& \int_0^{2\pi} \int_0^{\frac{\pi}{2}} \frac{1-F_r(\eta,\cos\theta)}{\pi}\sin\theta \ \cos\theta \ d\theta \ d\phi \\ &=& 1 - 2 \int_0^{\frac{\pi}{2}} F_r(\eta,\cos\theta)\sin\theta \ \cos\theta \ d\theta \ d\phi \end{eqnarray} \]
BSSRDF公式更具体的理论、推导、简化过程可参看下面两篇论文:
2.2.2 次表面散射的空间模糊
次表面散射本质上是采样周边像素进行加权计算,相似特殊的高斯模糊。也就是说,次表面散射的计算能够分为两个部分:
(1)先对每一个像素进行通常的漫反射计算。
(2)再根据某种特殊的函数\(R(r)\)和(1)中的漫反射结果,加权计算周围若干个像素对当前像素的次表面散射贡献。
上述(2)中提到的\(R(r)\)就是次表面散射的扩散剖面(Diffusion Profile)。它是一个次表面散射的光线密度分布,是各向同性的函数,也就是说一个像素受周边像素的光照影响的比例只和两个像素间的距离有关。
实际上全部材质都存在次表面散射现象,区别只在于其密度分布函数\(R(r)\)的集中程度,若是该函数的绝大部分能量都集中在入射点附近(r=0),就表示附近像素对当前像素的光照贡献不明显,能够忽略,则在渲染时咱们就用漫反射代替,若是该函数分布比较均匀,附近像素对当前像素的光照贡献明显,则须要单独计算次表面散射。
利用扩散剖面技术模拟的次表面散射,为了获得更柔和的皮肤质感,须要对画面进行若干次不一样参数的高斯模糊。从模糊空间划分,有两种方法:
纹理空间模糊(Texture Space Blur)。利用皮肤中散射的局部特性,经过使用纹理坐标做为渲染坐标展开3D网格,在2D纹理中有效地对其进行模拟。
屏幕空间模糊(Screen Space Blur)。跟纹理空间不一样的是,它在屏幕空间进行模糊,也被称为屏幕空间次表面散射(Screen Space SubSurface Scattering,SSSSS)。
纹理空间和屏幕空间进行0, 3, 5次高斯模糊的结果
上图:屏幕空间的次表面散射渲染过程
2.2.3 可分离的次表面散射(Separable Subsurface Scattering)
次表面散射的模糊存在卷积分离(Separable Convolution)的优化方法,具体是将横向坐标U和纵向坐标V分开卷积,再作合成:
由此产生了可分离的次表面散射(Separable Subsurface Scattering,也叫SSSS或4S),这也是UE目前采用的人类皮肤渲染方法。它将\(R_d\)作了简化:
\[ R_d(x,y) \approx A_g(x,y) = \sum_{i=1}^N \omega_i G(x,y,\sigma_i) \]
具体的推导过程请参看:Separable Subsurface Scattering。
该论文还提到,为了给实时渲染加速,还须要预积分分离的卷积核(Pre-integrated Separable Kernel):
\[ A_p(x,y) = \frac{1}{\parallel R_d \parallel_1} a_p(x)a_p(y) \]
利用奇异值分解(Singular Value Decomposition,SVD)的方法将其分解为一个行向量和一个列向量,而且保证了分解后的表示方法基本没有能量损失。下图展现了它的计算过程:
本节将从UE的C++和shader源码分析皮肤渲染的实现。UE源码下载的具体步骤请看官方文档:下载虚幻引擎源代码。
再次给拥有充分共享精神的Epic Game点个赞!UE的开源使咱们能够一窥引擎内部的实现,再也不是黑盒操做,也使咱们有机会学习图形渲染的知识,对我的、项目和公司都大有裨益。
皮肤渲染的方法不少,UE使用的是可分离的次表面散射(Separable Subsurface Scattering,也叫SSSS或4S)。最早由暴雪的Jorge等人,在GDC2013的演讲《Next-Generation Character Rendering》中首次展现了SSSS的渲染图,并在2015年经过论文正式提出了Separable Subsurface Scattering。其经过水平和垂直卷积2个Pass来近似,效率更进一步提高,这是目前游戏里采用的主流技术。
UE源码中,与SSSS相关的主要文件(笔者使用的是UE 4.22,不一样版本可能有所差异):
\Engine\Shaders\Private\SeparableSSS.ush:
SSSS的shader主要实现。
\Engine\Shaders\Private\PostProcessSubsurface.usf:
后处理阶段为SeparableSSS.ush提供数据和工具接口的实现。
\Engine\Shaders\Private\SubsurfaceProfileCommon.ush:
定义了SSSS的常量和配置。
\Engine\Source\Runtime\Engine\Private\Rendering\SeparableSSS.cpp:
实现CPU版本的扩散剖面、高斯模糊及透射剖面等逻辑,可用于离线计算。
\Engine\Source\Runtime\Engine\Private\Rendering\SubsurfaceProfile.cpp:
SSS Profile的管理,纹理的建立,及与SSSS交互的处理。
SeparableSSS.ush是实现SSSS的主要shader文件,先分析像素着色器代码。(下面有些接口是在其它文件定义的,经过名字就能够知道大体的意思,无需关心其内部实现细节也不妨碍分析核心渲染算法。)
// BufferUV: 纹理坐标,会从GBuffer中取数据; // dir: 模糊方向。第一个pass取值float2(1.0, 0.0),表示横向模糊;第二个pass取值float2(0.0, 1.0),表示纵向模糊。这就是“可分离”的优化。 // initStencil:是否初始化模板缓冲。第一个pass须要设为true,以便在第二个pass得到优化。 float4 SSSSBlurPS(float2 BufferUV, float2 dir, bool initStencil) { // Fetch color of current pixel: // SSSSSampleSceneColorPoint和SSSSSampleSceneColor就是获取2.2.2步骤(1)中提到的已经计算好的漫反射颜色 float4 colorM = SSSSSampleSceneColorPoint(BufferUV); // we store the depth in alpha float OutDepth = colorM.a; colorM.a = ComputeMaskFromDepthInAlpha(colorM.a); // 根据掩码值决定是否直接返回,而不作后面的次表面散射计算。 BRANCH if(!colorM.a) { // todo: need to check for proper clear // discard; return 0.0f; } // 0..1 float SSSStrength = GetSubsurfaceStrength(BufferUV); // Initialize the stencil buffer in case it was not already available: if (initStencil) // (Checked in compile time, it's optimized away) if (SSSStrength < 1 / 256.0f) discard; float SSSScaleX = SSSParams.x; float scale = SSSScaleX / OutDepth; // 计算采样周边像素的最终步进 float2 finalStep = scale * dir; // ideally this comes from a half res buffer as well - there are some minor artifacts finalStep *= SSSStrength; // Modulate it using the opacity (0..1 range) FGBufferData GBufferData = GetGBufferData(BufferUV); // 0..255, which SubSurface profile to pick // ideally this comes from a half res buffer as well - there are some minor artifacts uint SubsurfaceProfileInt = ExtractSubsurfaceProfileInt(GBufferData); // Accumulate the center sample: float3 colorAccum = 0; // 初始化为非零值,是为了防止后面除零异常。 float3 colorInvDiv = 0.00001f; // 中心点采样 colorInvDiv += GetKernel(SSSS_N_KERNELWEIGHTOFFSET, SubsurfaceProfileInt).rgb; colorAccum = colorM.rgb * GetKernel(SSSS_N_KERNELWEIGHTOFFSET, SubsurfaceProfileInt).rgb; // 边界溢色。 float3 BoundaryColorBleed = GetProfileBoundaryColorBleed(GBufferData); // 叠加周边像素的采样,即次表面散射的计算,也可看作是与距离相关的特殊的模糊 // SSSS_N_KERNELWEIGHTCOUNT是样本数量,与配置相关,分别是六、九、13。可由控制台命令r.SSS.SampleSet设置。 SSSS_UNROLL for (int i = 1; i < SSSS_N_KERNELWEIGHTCOUNT; i++) { // Kernel是卷积核,卷积核的权重由扩散剖面(Diffusion Profile)肯定,而卷积核的大小则须要根据当前像素的深度(d(x,y))及其导数(dFdx(d(x,y))和dFdy(d(x,y)))来肯定。而且它是根据Subsurface Profile参数预计算的。 // Kernel.rgb是颜色通道的权重;Kernel.a是采样位置,取值范围是0~SUBSURFACE_KERNEL_SIZE(即次表面散射影响的半径) half4 Kernel = GetKernel(SSSS_N_KERNELWEIGHTOFFSET + i, SubsurfaceProfileInt); float4 LocalAccum = 0; float2 UVOffset = Kernel.a * finalStep; // 因为卷积核是各向同性的,因此能够简单地取采样中心对称的点的颜色进行计算。可将GetKernel调用下降至一半,权重计算消耗降至一半。 SSSS_UNROLL // Side的值是-1和1,经过BufferUV + UVOffset * Side,便可得到采样中心点对称的两点作处理。 for (int Side = -1; Side <= 1; Side += 2) { // Fetch color and depth for current sample: float2 LocalUV = BufferUV + UVOffset * Side; float4 color = SSSSSampleSceneColor(LocalUV); uint LocalSubsurfaceProfileInt = SSSSSampleProfileId(LocalUV); float3 ColorTint = LocalSubsurfaceProfileInt == SubsurfaceProfileInt ? 1.0f : BoundaryColorBleed; float LocalDepth = color.a; color.a = ComputeMaskFromDepthInAlpha(color.a); #if SSSS_FOLLOW_SURFACE == 1 // 根据OutDepth和LocalDepth的深度差校订次表面散射效果,若是它们相差太大,几乎无次表面散射效果。 float s = saturate(12000.0f / 400000 * SSSParams.y * // float s = saturate(300.0f/400000 * SSSParams.y * abs(OutDepth - LocalDepth)); color.a *= 1 - s; #endif // approximation, ideally we would reconstruct the mask with ComputeMaskFromDepthInAlpha() and do manual bilinear filter // needed? color.rgb *= color.a * ColorTint; // Accumulate left and right LocalAccum += color; } // 因为中心采样点两端的权重是对称的,colorAccum和colorInvDiv原本都须要*2,但它们最终colorAccum / colorInvDiv,因此*2能够消除掉。 colorAccum += Kernel.rgb * LocalAccum.rgb; colorInvDiv += Kernel.rgb * LocalAccum.a; } // 最终将颜色权重和深度权重相除,以规范化,保持光能量守恒,防止颜色过曝。(对于没有深度信息或者没有SSS效果的材质,采样可能失效!) float3 OutColor = colorAccum / colorInvDiv; // alpha stored the SceneDepth (0 if there is no subsurface scattering) return float4(OutColor, OutDepth); }
此文件还有SSSSTransmittance
,但笔者搜索了整个UE的源代码工程,彷佛没有被用到,因此暂时不分析。下面只贴出其源码:
//----------------------------------------------------------------------------- // Separable SSS Transmittance Function // @param translucency This parameter allows to control the transmittance effect. Its range should be 0..1. Higher values translate to a stronger effect. // @param sssWidth this parameter should be the same as the 'SSSSBlurPS' one. See below for more details. // @param worldPosition Position in world space. // @param worldNormal Normal in world space. // @param light Light vector: lightWorldPosition - worldPosition. // @param lightViewProjection Regular world to light space matrix. // @param lightFarPlane Far plane distance used in the light projection matrix. float3 SSSSTransmittance(float translucency, float sssWidth, float3 worldPosition, float3 worldNormal, float3 light, float4x4 lightViewProjection, float lightFarPlane) { /** * Calculate the scale of the effect. */ float scale = 8.25 * (1.0 - translucency) / sssWidth; /** * First we shrink the position inwards the surface to avoid artifacts: * (Note that this can be done once for all the lights) */ float4 shrinkedPos = float4(worldPosition - 0.005 * worldNormal, 1.0); /** * Now we calculate the thickness from the light point of view: */ float4 shadowPosition = SSSSMul(shrinkedPos, lightViewProjection); float d1 = SSSSSampleShadowmap(shadowPosition.xy / shadowPosition.w).r; // 'd1' has a range of 0..1 float d2 = shadowPosition.z; // 'd2' has a range of 0..'lightFarPlane' d1 *= lightFarPlane; // So we scale 'd1' accordingly: float d = scale * abs(d1 - d2); /** * Armed with the thickness, we can now calculate the color by means of the * precalculated transmittance profile. * (It can be precomputed into a texture, for maximum performance): */ float dd = -d * d; float3 profile = float3(0.233, 0.455, 0.649) * exp(dd / 0.0064) + float3(0.1, 0.336, 0.344) * exp(dd / 0.0484) + float3(0.118, 0.198, 0.0) * exp(dd / 0.187) + float3(0.113, 0.007, 0.007) * exp(dd / 0.567) + float3(0.358, 0.004, 0.0) * exp(dd / 1.99) + float3(0.078, 0.0, 0.0) * exp(dd / 7.41); /** * Using the profile, we finally approximate the transmitted lighting from * the back of the object: */ return profile * saturate(0.3 + dot(light, -worldNormal)); }
SeparableSSS.cpp主题提供了扩散剖面、透射剖面、高斯模糊计算以及镜像卷积核的预计算。
为了更好地理解源代码,仍是先介绍一些前提知识。
扩散剖面的模拟可由若干个高斯和函数进行模拟,其中高斯函数的公式:
\[ f_{gaussian} = e^{-r^2} \]
下图是单个高斯和的扩散剖面曲线图:
因而可知R、G、B的扩散距离不同,而且单个高斯函数没法精确模拟出复杂的人类皮肤扩散剖面。
实践代表多个高斯分布在一块儿能够对扩散剖面提供极好的近似。而且高斯函数是独特的,由于它们同时是可分离的和径向对称的,而且它们能够相互卷积来产生新的高斯函数。
对于每一个扩散分布\(R(r)\),咱们找到具备权重\(\omega_i\)和方差\(v_i\)的\(k\)个高斯函数:
\[ R(r) \approx \sum_{i=1}^k\omega_iG(v_i,r) \]
而且高斯函数的方差\(v\)有如下定义:
\[ G(v, r) := \frac{1}{2\pi v} e^{\frac{-r^2}{2v}} \]
能够选择常数\(\frac{1}{2v}\)使得\(G(v, r)\)在用于径向2D模糊时不会使输入图像变暗或变亮(其具备单位脉冲响应(unit impulse response))。
对于大部分透明物体(牛奶、大理石等)用一个Dipole Profile就够了,可是对于皮肤这种拥有多层结构的材质,用一个Dipole Profile不能达到理想的效果,能够经过3个Dipole接近Jensen论文中的根据测量得出的皮肤Profile数据。
实验发现,3个Dipole曲线可经过如下6个高斯函数拟合获得(具体的拟合推导过程参见:《GPU Gems 3》:真实感皮肤渲染技术总结):
\[ \begin{eqnarray} R(r) &=& 0.233\cdot G(0.0064,r) + 0.1\cdot G(0.0484,r) + 0.118\cdot G(0.187,r) \\ &+& 0.113\cdot G(0.567,r) + 0.358\cdot G(1.99,r) + 0.078\cdot G(7.41,r) \end{eqnarray} \]
上述公式是红通道Red的模拟,绿通道Green和蓝通道Blue的参数不同,见下表:
R、G、B通道拟合出的曲线有所不一样(下图),可见R通道曲线的扩散范围最远,这也是皮肤显示出红色的缘由。
首先分析SeparableSSS_Gaussian
:
// 这个就是上一小节提到的G(v,r)的高斯函数,增长了FalloffColor颜色,对应不一样颜色通道的值。 inline FVector SeparableSSS_Gaussian(float variance, float r, FLinearColor FalloffColor) { FVector Ret; // 对每一个颜色通道作一次高斯函数技术 for (int i = 0; i < 3; i++) { float rr = r / (0.001f + FalloffColor.Component(i)); Ret[i] = exp((-(rr * rr)) / (2.0f * variance)) / (2.0f * 3.14f * variance); } return Ret; }
再分析SeparableSSS_Profile
:
// 天啦噜,这不正是上一小节提到的经过6个高斯函数拟合获得3个dipole曲线的公式么?参数一毛同样有木有? // 其中r是次表面散射的最大影响距离,单位是mm,可由UE编辑器的Subsurface Profile界面设置。 inline FVector SeparableSSS_Profile(float r, FLinearColor FalloffColor) { // 须要注意的是,UE4将R、G、B通道的参数都统一使用了R通道的参数,它给出的理由是FalloffColor已经包含了不一样的值,而且方便模拟出不一样肤色的材质。 return // 0.233f * SeparableSSS_Gaussian(0.0064f, r, FalloffColor) + // UE4屏蔽掉了第一个高斯函数,理由是这个是直接反射光,而且考虑了strength参数。(We consider this one to be directly bounced light, accounted by the strength parameter) 0.100f * SeparableSSS_Gaussian(0.0484f, r, FalloffColor) + 0.118f * SeparableSSS_Gaussian(0.187f, r, FalloffColor) + 0.113f * SeparableSSS_Gaussian(0.567f, r, FalloffColor) + 0.358f * SeparableSSS_Gaussian(1.99f, r, FalloffColor) + 0.078f * SeparableSSS_Gaussian(7.41f, r, FalloffColor); }
接着分析如何利用上面的接口进行离线计算Kernel的权重:
// 因为高斯函数具体各向同性、中心对称性,因此横向卷积和纵向卷积同样,经过镜像的数据减小一半计算量。 void ComputeMirroredSSSKernel(FLinearColor* TargetBuffer, uint32 TargetBufferSize, FLinearColor SubsurfaceColor, FLinearColor FalloffColor) { check(TargetBuffer); check(TargetBufferSize > 0); uint32 nNonMirroredSamples = TargetBufferSize; int32 nTotalSamples = nNonMirroredSamples * 2 - 1; // we could generate Out directly but the original code form SeparableSSS wasn't done like that so we convert it later // .A is in mm check(nTotalSamples < 64); FLinearColor kernel[64]; { // 卷积核时先给定一个默认的半径范围,不能太大也不能过小,根据nTotalSamples数量调整Range是必要的。(单位是毫米mm) const float Range = nTotalSamples > 20 ? 3.0f : 2.0f; // tweak constant const float Exponent = 2.0f; // Calculate the offsets: float step = 2.0f * Range / (nTotalSamples - 1); for (int i = 0; i < nTotalSamples; i++) { float o = -Range + float(i) * step; float sign = o < 0.0f ? -1.0f : 1.0f; // 将当前的range和最大的Range的比值存入alpha通道,以便在shader中快速应用。 kernel[i].A = Range * sign * FMath::Abs(FMath::Pow(o, Exponent)) / FMath::Pow(Range, Exponent); } // 计算Kernel权重 for (int32 i = 0; i < nTotalSamples; i++) { // 分别取得i两边的.A值作模糊,存入area float w0 = i > 0 ? FMath::Abs(kernel[i].A - kernel[i - 1].A) : 0.0f; float w1 = i < nTotalSamples - 1 ? FMath::Abs(kernel[i].A - kernel[i + 1].A) : 0.0f; float area = (w0 + w1) / 2.0f; // 将模糊后的权重与6个高斯函数的拟合结果相乘,得到RGB的最终权重。 FVector t = area * SeparableSSS_Profile(kernel[i].A, FalloffColor); kernel[i].R = t.X; kernel[i].G = t.Y; kernel[i].B = t.Z; } // 将offset为0.0(即中心采样点)的值移到位置0. FLinearColor t = kernel[nTotalSamples / 2]; for (int i = nTotalSamples / 2; i > 0; i--) { kernel[i] = kernel[i - 1]; } kernel[0] = t; // 规范化权重,使得权重总和为1,保持颜色能量守恒. { FVector sum = FVector(0, 0, 0); for (int i = 0; i < nTotalSamples; i++) { sum.X += kernel[i].R; sum.Y += kernel[i].G; sum.Z += kernel[i].B; } for (int i = 0; i < nTotalSamples; i++) { kernel[i].R /= sum.X; kernel[i].G /= sum.Y; kernel[i].B /= sum.Z; } } /* we do that in the shader for better quality with half res // Tweak them using the desired strength. The first one is: // lerp(1.0, kernel[0].rgb, strength) kernel[0].R = FMath::Lerp(1.0f, kernel[0].R, SubsurfaceColor.R); kernel[0].G = FMath::Lerp(1.0f, kernel[0].G, SubsurfaceColor.G); kernel[0].B = FMath::Lerp(1.0f, kernel[0].B, SubsurfaceColor.B); for (int i = 1; i < nTotalSamples; i++) { kernel[i].R *= SubsurfaceColor.R; kernel[i].G *= SubsurfaceColor.G; kernel[i].B *= SubsurfaceColor.B; }*/ } // 将正向权重结果输出到TargetBuffer,删除负向结果。 { check(kernel[0].A == 0.0f); // center sample TargetBuffer[0] = kernel[0]; // all positive samples for (uint32 i = 0; i < nNonMirroredSamples - 1; i++) { TargetBuffer[i + 1] = kernel[nNonMirroredSamples + i]; } } }
此文件还实现了ComputeTransmissionProfile
:
void ComputeTransmissionProfile(FLinearColor* TargetBuffer, uint32 TargetBufferSize, FLinearColor SubsurfaceColor, FLinearColor FalloffColor, float ExtinctionScale) { check(TargetBuffer); check(TargetBufferSize > 0); static float MaxTransmissionProfileDistance = 5.0f; // See MAX_TRANSMISSION_PROFILE_DISTANCE in TransmissionCommon.ush for (uint32 i = 0; i < TargetBufferSize; ++i) { //10 mm const float InvSize = 1.0f / TargetBufferSize; float Distance = i * InvSize * MaxTransmissionProfileDistance; FVector TransmissionProfile = SeparableSSS_Profile(Distance, FalloffColor); TargetBuffer[i] = TransmissionProfile; //Use Luminance of scattering as SSSS shadow. TargetBuffer[i].A = exp(-Distance * ExtinctionScale); } // Do this is because 5mm is not enough cool down the scattering to zero, although which is small number but after tone mapping still noticeable // so just Let last pixel be 0 which make sure thickness great than MaxRadius have no scattering static bool bMakeLastPixelBlack = true; if (bMakeLastPixelBlack) { TargetBuffer[TargetBufferSize - 1] = FLinearColor::Black; } }
ComputeMirroredSSSKernel
和ComputeTransmissionProfile
的触发是在FSubsurfaceProfileTexture::CreateTexture
内,然后者又是在关卡加载时或者编辑器操做时触发调用(也就是说预计算的,非运行时计算):
void FSubsurfaceProfileTexture::CreateTexture(FRHICommandListImmediate& RHICmdList) { // ... (隐藏了卷积前的处理代码) for (uint32 y = 0; y < Height; ++y) { // ... (隐藏了卷积前的处理代码) // 根据r.SSS.SampleSet的数值(0、一、2),卷积3个不一样尺寸的权重。 ComputeMirroredSSSKernel(&TextureRow[SSSS_KERNEL0_OFFSET], SSSS_KERNEL0_SIZE, Data.SubsurfaceColor, Data.FalloffColor); ComputeMirroredSSSKernel(&TextureRow[SSSS_KERNEL1_OFFSET], SSSS_KERNEL1_SIZE, Data.SubsurfaceColor, Data.FalloffColor); ComputeMirroredSSSKernel(&TextureRow[SSSS_KERNEL2_OFFSET], SSSS_KERNEL2_SIZE, Data.SubsurfaceColor, Data.FalloffColor); // 计算透射剖面。 ComputeTransmissionProfile(&TextureRow[SSSS_TRANSMISSION_PROFILE_OFFSET], SSSS_TRANSMISSION_PROFILE_SIZE, Data.SubsurfaceColor, Data.FalloffColor, Data.ExtinctionScale); // ...(隐藏了卷积后的处理代码) } }
此文件为SeparableSSS.ush
定义了大量接口和变量,而且是调用SeparableSSS
的使用者:
// .... (隐藏其它代码) #include "SeparableSSS.ush" // .... (隐藏其它代码) // input0 is created by the SetupPS shader void MainPS(noperspective float4 UVAndScreenPos : TEXCOORD0, out float4 OutColor : SV_Target0) { float2 BufferUV = UVAndScreenPos.xy; #if SSS_DIRECTION == 0 // horizontal float2 ViewportDirectionUV = float2(1, 0) * SUBSURFACE_RADIUS_SCALE; #else // vertical float2 ViewportDirectionUV = float2(0, 1) * SUBSURFACE_RADIUS_SCALE * (View.ViewSizeAndInvSize.x * View.ViewSizeAndInvSize.w); #endif #if MANUALLY_CLAMP_UV ViewportDirectionUV *= (View.ViewSizeAndInvSize.x * View.BufferSizeAndInvSize.z); #endif // 得到次表面散射颜色 OutColor = SSSSBlurPS(BufferUV, ViewportDirectionUV, false); #if SSS_DIRECTION == 1 // second pass prepares the setup from the recombine pass which doesn't need depth but wants to reconstruct the color OutColor.a = ComputeMaskFromDepthInAlpha(OutColor.a); #endif }
而且在调用MainPS
前,已经由其它代码计算好了漫反射颜色,后续还会进行高光混合。若是在预计算卷积核以前就混合了高光,会获得很差的渲染结果:
UE4的次表面散射虽然能提升很是逼真的皮肤渲染,但也存在如下限制(摘自官方文档:次表面轮廓明暗处理模型):
该功能不适用于非延迟(移动)渲染模式。
将大屏幕设置为散射半径,将会在极端照明条件下显示出带状瑕疵。
目前,没有照明反向散射。
目前,当非SSS材质遮挡SSS材质时,会出现灰色轮廓。(经笔者测试,4.22.1并不会出现,见下图)
本节将开始解析Mike的皮肤材质。皮肤材质主要是M_Head。
皮肤材质节点总览
它的启用了次表面散射的着色模型,此外,还开启了与骨骼动做和静态光一块儿使用标记,以下:
对于基础色,是由4张漫反射贴图(下图)做为输入,经过MF_AnimatedMapsMike输出混合的结果,再除以由一张次表面散射遮罩图(T_head_sss_ao_mask)控制的系数,最终输入到Base Color引脚。
4张漫反射贴图,每张都表明着不一样动做状态下的贴图。
其中MF_AnimatedMapsMike是一个通用的材质函数,内部控制着不一样动做下的贴图混合权重,而混合不一样动做参数的是m_headMask_01
、m_headMask_02
、m_headMask_03
三个材质函数:
而m_headMask_01
、m_headMask_02
、m_headMask_03
三个材质函数又分别控制了一组面部Blend Shape动做,其中以m_headMask_01
为研究对象:
由上图可见,m_headMask_01
有5张贴图(head_wm1_msk_01 ~ head_wm1_msk_04,head_wm13_msk_03),利用它们的共19个通道(head_wm1_msk_04的alpha通道没用上)提供了19组blend shape遮罩,而后它们与对应的参数相做用。
此外,m_headMask_02
有3张贴图控制了10个Blend Shape动做;m_headMask_03
有3张贴图控制了12个Blend Shape动做。
至于遮罩数据和blend shape参数如何计算,还得进入fn_maskDelta_xx
一探究竟,下面以fn_maskDelta_01
为例:
不要被众多的材质节点搞迷糊了,其实就是将每一个Blend Shape遮罩与参数相乘,再将结果与其它参数相加,最终输出结果。抽象成公式:
\[ f = \sum_{i=1}^N m_i \cdot p_i \]
其中\(m_i\)表示第\(i\)个Blend Shape的遮罩值,\(p_i\)表示第\(i\)个Blend Shape的参数值。奏是辣么简单!
高光度主要由Mike_head_cavity_map_001的R通道提供,经过Power
和Lerp
调整强度和范围后,再通过Fresnel
菲涅尔节点加强角色边缘的高光反射(下图)。
上述结果通过T_head_sss_ao_mask
贴图的Alpha通道控制高光度和BaseSpecularValue
调整后,最终输出到Specular
引脚。(下图)
其中鼻子区域的高光度经过贴图T_RGB_roughness_02
的R通道在原始值和0.8
之间作插值。
粗糙度的计算比较复杂,要分几个部分来分析。
这部分跟基础色相似,经过4张不一样动做状态的粗糙度贴图(Toksvig_mesoNormal,Toksvig_mesoNormal1,Toksvig_mesoNormal2,Toksvig_mesoNormal3)混合成初始粗糙度值。
如上图,由Toksvig_mesoNormal
的G通道加上基础粗糙度BaseRoughness
,再进入材质函数MF_RoughnessRegionMult
处理后输出结果。
其中,MF_RoughnessRegionMult
的内部计算以下:
简而言之,就是经过3张mask贴图(head_skin_mask4,T_siren_head_roughmask_02,T_siren_head_roughmask_01)的10个通道分别控制10个部位的粗糙度,而且每一个部位的粗糙度提供了参数调节,使得每一个部位在\([1.0, mask]\)之间插值。
上图所示,RoughnessVariation
经过Mike_T_specular_neutral
的R通道,在Rough0
和Rough1
之间作插值;EdgeRoughness
则经过Fresnel
节点增强了角色视角边缘的粗糙度;而后将它们和前俩小节的结果分别作相乘和相加。
如上图,将纹理坐标作偏移后,采用微表面细节贴图skin_h
,接着增强对比度,并将值控制在\([0.85, 1.0]\)之间,最后与上一小节的结果相乘,输出到粗糙度引脚。
其中微表面细节贴图skin_h
见下:
首先须要说明,当材质着色模型是Subsurface Profile时,材质引脚Opacity的做用再也不是控制物体的透明度,而变成了控制次表面散射的系数。
由贴图T_head_sss_ao_mask
的G通道(下图)提供主要的次表面散射数据,将它们限定在[ThinScatter
,ThickScatter
]之间。
次表面散射遮罩图。可见耳朵、鼻子最强,鼻子、嘴巴次之。
另外,经过贴图T_RGB_roughness_02
的B、A通道分别控制上眼睑(UpperLidScatter)和眼皮(LidScatter)部位的次表面散射系数。
与漫反射、粗糙度相似,法线的主要提供者也是由4张图控制。
此外,还提供了微观法线,以增长镜头很近时的皮肤细节。
主法线和微观法线分别通过NormalStrength
和MicroNormalStrength
缩放后(注意,法线的z通道数据不变),再经过材质节点BlendAngleCorrectedNormals
将它们叠加起来,最后规范化输入到法线引脚。(见下图)
不妨进入材质节点BlendAngleCorrectedNormals
分析法线的混合过程:
从材质节点上看,计算过程并不算复杂,将它转成函数:
Vector3 BlendAngleCorrectedNormals(Vector3 BaseNormal, Vector3 AdditionalNormal) { BaseNormal.b += 1.0; AdditionalNormal.rg *= -1.0; float dot = Dot(BaseNormal, AdditionalNormal); Vector3 result = BaseNormal * dot - AdditionalNormal * BaseNormal.b; return result; }
另外,Normal Map Blending in Unreal Engine 4一文提出了一种更简单的混合方法:
将两个法线的XY相加、Z相乘即获得混合的结果。
AO控制很是简单,直接用贴图T_head_sss_ao_mask
的R通道输入到AO引脚。其中T_head_sss_ao_mask
的R通道以下:
可见,五官内部、下颚、脖子、头发都屏蔽了较多的环境光。
前面能够看到,皮肤渲染涉及的贴图很是多,多达几十张。
它们的制做来源一般有如下几种:
扫描出的超高清贴图。例如漫反射、高光、SSS、粗糙度、法线等等。
转置贴图。好比粗糙度、副法线、微观法线等。
粗糙度贴图由法线贴图转置而成。
遮罩图。这类图很是多,标识了身体的各个区域,以便精准控制它们的各种属性。来源有:
PS等软件制做。此法最传统,也最容易理解。
插件生成。利用Blend Shape、骨骼等的权重信息,自动生成遮罩图。
Blend Shape记录了顶点的权重,能够将它们对应的UV区域生成遮罩图。