找回密码
 立即注册
查看: 1555|回复: 0

剖析UE数字人的渲染技术

[复制链接]
发表于 2022-9-23 09:26 | 显示全部楼层 |阅读模式
注:本篇于几年前首发于博客园。

一、概述

1.1 数字人的概要

数字人类(Digital Human)是利用计算机模拟真实人类的一种综合性的渲染技术。也被称为虚拟人类、超真实人类、照片级人类。
它是一种技术和艺术相结合的综合性模拟渲染,涵盖计算机图形渲染、模型扫描、3D建模、肢体驱动、AI算法等领域。



数字人类概念图
1.1.1 数字人的历史和现状

随着计算机渲染技术的发展,数字人类在电影领域早有应用。在上世纪80年代的《星球大战》系列、《异形》系列等电影,到后来的《终结者》系列、《黑客帝国》系列、《指环王》系列等,再到近期的漫威、DC动画电影,都存在着虚拟角色的身影,他们或是天赋异禀的人类,或是奇形怪状的怪物。



《星球大战I》中的虚拟角色:尤达大师(Master Yoda)



《黑客帝国》的主角很多镜头是采用计算机渲染而成的虚拟数字人



电影《战斗天使》的画面。主角阿丽塔也是虚拟角色。
由于近些年计算机硬件性能和渲染技术的提升,除了在离线渲染领域的电影和动漫的广泛应用之外,实时领域的应用也得到长足的进步。例如,次世代游戏、3A大作、VR游戏以及泛娱乐领域的直播领域。



《孤岛惊魂5》中的虚拟游戏角色



R&D和暴雪在GDC2013展示的次世代虚拟角色



Unreal Engine在GDC2018展示的虚拟角色Siren,可由演员实时驱动动作、表情、肢体等信息。
1.1.2 数字人的制作流程

数字人类的步骤多,工序繁琐。但总结起来,通常有以下几个步骤:

  • 模型扫描。通常借助光学扫描仪或由单反相机组成的360度的摄影包围盒,对扫描对象进行全方位的扫描,从而获得原始的模型数据。




上图展示了模型扫描仪,由很多摄影和灯光设备组成的球形矩阵。

  • 模型调整。由扫描阶段获取的初始模型通常有瑕疵,无法直接投入渲染。需要美术人员利用3D建模工具(如Maya、3DMax等)进行调整、优化、重新拓扑,最终调整成合适的可用模型。




左:扫描的初始模型;中:调整后的中间模型;右:优化了细节的可用模型。

  • 制作贴图。在此阶段,用建模软件或材质制作软件(如Substance)采纳高精度模型烘焙或制作出漫反射、法线、粗糙度、AO、散射、高光等等贴图,为最后的渲染做准备。这些贴图的原始尺寸通常都非常大,4K、8K甚至16K,目的是高精度还原虚拟人类的细节。




漫反射贴图



法线贴图

  • 导入引擎。在此阶段,将之前制作的模型和贴图导入到渲染引擎(如UE4、Unity等),加入光照、材质、场景等元素,结合角色的综合性PBR渲染技术,获得最终成像。




Unreal Engine渲染出的虚拟角色
1.2 Unreal Engine的数字人

1.2.1 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》虚拟角色的细节,与数码相机摄制的照片如出一辙
1.2.2 《Meet Mike》项目

笔者本想以《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个关节,大部分是为了头发的运动和脸部毛发。
  • 脸部模型大约只用了10个关节,分别用在下巴、眼睛和舌头,目的是为了运动更加圆滑。
  • 脸部使用了Technoprop公司先进的配有立体红外摄像头的固定在头部的面部捕捉装置。
  • 综合使用了750个融合变形(blend shapes)。
  • 系统使用了复杂的传统软件和三种深度学习AI引擎。

二、皮肤渲染

皮肤渲染技术经过数十年的发展,由最初的单张贴图+伦勃朗的渲染方式到近期的基于物理的SSSSS(屏幕空间次表面散射)。由此衍生出的皮肤渲染技术层出不穷,其中最最基础也最具代表性的是次表面散射(SSS)。
在虚拟角色渲染中,皮肤的渲染尤为关键。因为皮肤是人们每天亲眼目睹的非常熟悉的东西,如果稍微渲染不好或细节处理不足,便会陷入恐怖谷(Uncanny Valley )理论。至于什么是恐怖谷理论,参看这里。



上图由于皮肤的细节处理不到位,陷入了恐怖谷理论
2.1 皮肤的构成和理论

2.1.1 皮肤构成

人类皮肤的物理构成非常复杂,其表层和内部都由非常复杂的构成物质,剖面图如下:




  • 绒毛(hair shaft)。附着于皮肤表面的细小的毛。
  • 油脂(oil)。皮肤表层有一层薄薄的油脂覆盖,是皮肤高光的主要贡献者。
  • 表皮(epidermis)。油脂层下是表皮覆盖,是造成次表面散射的物质之一。
  • 真皮(dermis)。表皮下面是真正的皮肤组织,也是造成次表面散射的物质之一。
  • 毛囊(hair follicle)。绒毛的皮下组织和根基。
  • 静脉(vein)。呈深蓝色的血管。
  • 动脉(artery)。呈暗红色的血管。
  • 脂肪组织(fatty tissue)。脂肪组织也是造成次表面散射的次要贡献物质。
  • 其它:皮肤表面的纹理、皱纹、毛孔、雀斑、痘痘、黑痣、疤痕、油脂粒等等细节。




真实皮肤包含了非常多的细节:毛孔、绒毛、痘痘、黑痣、油脂......
2.1.2 皮肤建模

皮肤表面油脂层主要贡献了皮肤光照的反射部分(约6%的光线被反射),而油脂层下面的表皮层和真皮层则主要贡献了的次表面散射部分(约94%的光线被散射)。
虽然皮肤构成非常复杂,但图形渲染界的先贤者们利用简化的思维将皮肤建模成若干层。




  • 表面油脂层(Thin Oily Layer):模拟皮肤的高光反射。
  • 表皮层(Epidermis):模拟次表面散射的贡献层。
  • 真皮层(Dermis):模拟次表面散射的贡献层。
以上展示的是BRDF建模方式,只在皮肤表面反射光线,但实际上在三层建模中,还会考虑表皮层和真皮层的次表面散射(BSSRDF),见下图中间部分BSSRDF。



2.1.3 皮肤渲染流程

皮肤渲染涉及的高级技术有:

  • 线性空间光照工作流。这部分可以参看《Technical Artist 的不归路 —— 线性空间光照》。
  • 基于物理的光照(PBR)。这部分的理论和实践可以参看笔者的另一篇技术文章:《由浅入深学习PBR的原理和实现》
  • 大量后处理。
  • 1~5个实时光照和1个预计算光照探头。
皮肤渲染的过程可以抽象成以下步骤:

  • 皮肤反射。
  • 直接反射部分采用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更详细描述。
2.2 次表面散射

次表面散射(Subsurface scattering)是模拟皮肤、玉石、牛奶等半透光性物质的一种物理渲染技术。



它与普通BRDF的区别在于,同一条入射光进入半透光性物质后,会在内部经过多次散射,最终在入射点附近散射出若干条光线。
由于R、G、B在物质内扩散的曲线不一样,由此产生了与入射光不一样的颜色。



红色光由于穿透力更强,更容易在皮肤组织穿透,形成红色光。
2.2.1 BSSRDF

BSSRDF是基于次表面散射的一种光照模型,充分考虑了入射光在物质内部经过若干次散射后重新反射出来的光。



左:BRDF;右:BSSRDF,考虑了输入光在物质内散射后重新射出的若干条光



上图描述了BRDF、BTDF、BSSRDF之间的关系:

  • BRDF:双向反射分布函数,用于表述在介质入射点的反射光照模型。
  • BTDF:双向透射分布函数,用于描述光线透过介质后的光照模型。
  • BSSRDF:双向次表面反射分布函数,用于描述入射光在介质内部的光照模型。
  • BSDF = BRDF + BTDF。
  • BSSRDF是BSDF的升级版。
下面两图展示了使用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)}
  • $D$是漫反射常量:          D=\frac{1}{3\sigma_t'}
由此可见,$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公式更具体的理论、推导、简化过程可参看下面两篇论文:

  • A Practical Model for Subsurface Light Transport
  • BSSRDF Explorer: A Rendering Framework for the 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)的方法将其分解为一个行向量和一个列向量并且保证了分解后的表示方法基本没有能量损失。下图展示了它的计算过程:



2.3 UE底层实现

本节将从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交互的处理。
2.3.1  SeparableSSS.ush

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是样本数量,与配置相关,分别是6、9、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));
}2.3.2  SeparableSSS.cpp

SeparableSSS.cpp主题提供了扩散剖面、透射剖面、高斯模糊计算以及镜像卷积核的预计算。
为了更好地理解源代码,还是先介绍一些前提知识。
2.3.2.1 高斯和的扩散剖面(Sum-of-Gaussians Diffusion Profile)

扩散剖面的模拟可由若干个高斯和函数进行模拟,其中高斯函数的公式:  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通道曲线的扩散范围最远,这也是皮肤显示出红色的原因。



2.3.2.2 源码分析

首先分析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 = 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.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.A - kernel[i - 1].A) : 0.0f;
            float w1 = i < nTotalSamples - 1 ? FMath::Abs(kernel.A - kernel[i + 1].A) : 0.0f;
            float area = (w0 + w1) / 2.0f;
            // 将模糊后的权重与6个高斯函数的拟合结果相乘,获得RGB的最终权重。
            FVector t = area * SeparableSSS_Profile(kernel.A, FalloffColor);
            kernel.R = t.X;
            kernel.G = t.Y;
            kernel.B = t.Z;
        }

        // 将offset为0.0(即中心采样点)的值移到位置0.
        FLinearColor t = kernel[nTotalSamples / 2];

        for (int i = nTotalSamples / 2; i > 0; i--)
        {
            kernel = kernel[i - 1];
        }
        kernel[0] = t;

        // 规范化权重,使得权重总和为1,保持颜色能量守恒.
        {
            FVector sum = FVector(0, 0, 0);

            for (int i = 0; i < nTotalSamples; i++)
            {
                sum.X += kernel.R;
                sum.Y += kernel.G;
                sum.Z += kernel.B;
            }

            for (int i = 0; i < nTotalSamples; i++)
            {
                kernel.R /= sum.X;
                kernel.G /= sum.Y;
                kernel.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.R *= SubsurfaceColor.R;
            kernel.G *= SubsurfaceColor.G;
            kernel.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 = TransmissionProfile;
        //Use Luminance of scattering as SSSS shadow.
        TargetBuffer.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、1、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);

        // ...(隐藏了卷积后的处理代码)
    }
}
2.3.3  PostProcessSubsurface.ush

此文件为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前,已经由其它代码计算好了漫反射颜色,后续还会进行高光混合。如果在预计算卷积核之前就混合了高光,会得到不好的渲染结果:



2.3.4 UE次表面散射的限制

UE4的次表面散射虽然能提高非常逼真的皮肤渲染,但也存在以下限制(摘自官方文档:次表面轮廓明暗处理模型):

  • 该功能不适用于非延迟(移动)渲染模式。
  • 将大屏幕设置为散射半径,将会在极端照明条件下显示出带状瑕疵。





  • 目前,没有照明反向散射。
  • 目前,当非SSS材质遮挡SSS材质时,会出现灰色轮廓。(经笔者测试,4.22.1并不会出现,见下图)




2.4 皮肤材质解析

本节将开始解析Mike的皮肤材质。皮肤材质主要是M_Head。



皮肤材质节点总览
它的启用了次表面散射的着色模型,此外,还开启了与骨骼动作和静态光一起使用标记,如下:



2.4.1 基础色(Base Color)





对于基础色,是由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的参数值。奏是辣么简单!
2.4.2 高光(Specular)





高光度主要由Mike_head_cavity_map_001的R通道提供,通过Power和Lerp调整强度和范围后,再经过Fresnel菲涅尔节点增强角色边缘的高光反射(下图)。



上述结果经过T_head_sss_ao_mask贴图的Alpha通道控制高光度和BaseSpecularValue调整后,最终输出到Specular引脚。(下图)



其中鼻子区域的高光度通过贴图T_RGB_roughness_02的R通道在原始值和0.8之间做插值。
2.4.3 粗糙度(Roughness)

粗糙度的计算比较复杂,要分几个部分来分析。
2.4.3.1 动作混合的粗糙度





这部分跟基础色类似,通过4张不同动作状态的粗糙度贴图(Toksvig_mesoNormal,Toksvig_mesoNormal1,Toksvig_mesoNormal2,Toksvig_mesoNormal3)混合成初始粗糙度值。
2.4.3.2 基于微表面的粗糙度





如上图,由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]$之间插值。
2.4.3.3 粗糙度调整和边缘粗糙度





上图所示,RoughnessVariation通过Mike_T_specular_neutral的R通道,在Rough0和Rough1之间做插值;EdgeRoughness则通过Fresnel节点加强了角色视角边缘的粗糙度;然后将它们和前俩小节的结果分别做相乘和相加。
2.4.3.4 微表面细节加强





如上图,将纹理坐标做偏移后,采用微表面细节贴图skin_h,接着加强对比度,并将值控制在$[0.85, 1.0]$之间,最后与上一小节的结果相乘,输出到粗糙度引脚。
其中微表面细节贴图skin_h见下:



2.4.4 次表面散射(Opacity)

首先需要说明,当材质着色模型是Subsurface Profile时,材质引脚Opacity的作用不再是控制物体的透明度,而变成了控制次表面散射的系数。



由贴图T_head_sss_ao_mask的G通道(下图)提供主要的次表面散射数据,将它们限定在[ThinScatter,ThickScatter]之间。



次表面散射遮罩图。可见耳朵、鼻子最强,鼻子、嘴巴次之。
另外,通过贴图T_RGB_roughness_02的B、A通道分别控制上眼睑(UpperLidScatter)和眼皮(LidScatter)部位的次表面散射系数。
2.4.5 法线(Normal)





与漫反射、粗糙度类似,法线的主要提供者也是由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相乘即得到混合的结果。
2.4.6 环境光遮蔽(Ambient Occlusion)





AO控制非常简单,直接用贴图T_head_sss_ao_mask的R通道输入到AO引脚。其中T_head_sss_ao_mask的R通道如下:



可见,五官内部、下颚、脖子、头发都屏蔽了较多的环境光。
2.5 皮肤贴图制作

前面可以看到,皮肤渲染涉及的贴图非常多,多达几十张。



它们的制作来源通常有以下几种:

  • 扫描出的超高清贴图。例如漫反射、高光、SSS、粗糙度、法线等等。
  • 转置贴图。比如粗糙度、副法线、微观法线等。




粗糙度贴图由法线贴图转置而成。

  • 遮罩图。这类图非常多,标识了身体的各个区域,以便精准控制它们的各类属性。来源有:
  • PS等软件制作。此法最传统,也最容易理解。
  • 插件生成。利用Blend Shape、骨骼等的权重信息,自动生成遮罩图。



Blend Shape记录了顶点的权重,可以将它们对应的UV区域生成遮罩图。


三、眼球渲染

都说眼睛是人类心灵的窗户,若是眼睛渲染得逼真,将给虚拟角色点睛之笔,给予其栩栩如生的灵魂。



Mike那深邃的眼眸,唏嘘的胡渣子,神乎其神的眼神。。。应该征服了不少迷妹
再来一张超近距离的特写:



超近距离的眼睛特写,细节刻画得无与伦比,足以以假乱真。
然而,要渲染出如此逼真有神的眼睛,可不是那么简单,需要经过多道工序,运用许多渲染技法,刻画很多细节。
3.1 眼球的构造及理论

3.1.1 眼球的构造

生物学的眼球解剖图非常复杂,涉及的部位数十种。(下图)



人类眼球的生物学剖面图,涉及部位多达数十种。
在图形渲染领域,当然不可能关注这么多细节,可以将眼球构造做简化,只关注其中的几个部位:



上图所示的序号代表的部位:

  • 1 - 巩膜(sclera):也称为“眼白”,通常非常湿润,包含少量的触感纹理、血丝等细节。
  • 2 - 角膜缘(limbus):角膜缘存在于虹膜和巩膜之间的深色环形。有些眼睛中的角膜缘更为明显,从侧面看时往往会消退。
  • 3 - 虹膜(iris):虹膜是围绕在眼睛中心周围的一圈色环。如果某个人有“绿”眼睛,就是因为虹膜主要是绿色的。在真实的眼睛中,虹膜是类似肌肉的纤维结构,有扩张和收缩功能,以让更多光线进入瞳孔或者不让光线进入瞳孔。还需要注意的是,在真实世界中,虹膜实际上更像是圆盘或锥形,不会向眼部其余部分突出。
  • 4 - 瞳孔(pupil):瞳孔是眼睛中心的黑点。这是一个孔,光线穿过这个孔后才会被视网膜的视杆和视锥捕捉到。
  • 5 - 角膜(cornea):角膜是位于虹膜表面上的一层透明的、充满液体的圆顶结构。
3.1.2 眼球的渲染理论

由于眼球充满了液体,因此会折射照射进来的任何光线。在真实世界中从多个角度观察眼球时就会看到这种效果。虹膜和瞳孔会因为折射而变形,因为它们是透过角膜观看的。
游戏和电影中用来解决这个问题的传统方法是创建两层独立的眼睛表面,一层提供巩膜、虹膜和瞳孔,另一层位于顶部,提供角膜和眼睛的总体湿润度。这样底层表面透过湿润层观看时就会产生折射。



《A Boy and His Kite》中的男孩眼睛中采用的就是两层表面的渲染模型
根据上面的分析,以及简化后的眼球解剖结构,就可以得出结论,要渲染好眼睛,需要着重实现的效果包括:

  • 角膜的半透和光泽反射效果。
  • 瞳孔的次表面散射。
  • 瞳孔的缩放。最好根据整个场景的光照强度动态调整缩放大小。
  • 虹膜的颜色变化。
  • 其它眼球细节。
下节将详细探讨。
3.2 眼球的渲染技术

本节主要参考来源:

  • 角色渲染技术——毛发及其他。
  • Next-Generation-Character-Rendering。
3.2.1 角膜的半透和光泽反射

角膜的半透射和反射效果最能体现眼球渲染的效果。
简单的做法就是直接把角膜看做一个半透明光泽球体的反射,正常的做法是用PBR流程计算其镜面反射和IBL反射,然后给眼球一张虹膜和眼白的贴图,这张贴图作为角膜下面的折射效果,最后给角膜设定一个混合系数,把光泽球体反射效果和虹膜及眼白贴图上的颜色进行混合。



角膜的镜面反射和环境反射丰富了眼球的细节,增加了真实可信度
3.2.2 瞳孔的次表面散射

瞳孔本身实际上也是一个高低不平有纵深感的结构,它与角膜存在一定距离。这使得瞳孔会发生折射,并且,当光线到达瞳孔表面的时候,还会进一步在瞳孔结构内部发生次表面散射。



把眼球看成了一个双层结构,外面一层是角膜,里面一层是瞳孔的表面,而角膜和瞳孔之间我们可以认为是充斥了某种透明液体。
光线在进入瞳孔组织的内部前,首先会在角膜的表面发生一次折射,然后进入瞳孔组织的内部,产生散射,最后从瞳孔表面的另一个点散射出来。这里就涉及到了两个问题:
(1)一束射到角膜表面的光线在经过折射后,如何计算最终入射到瞳孔表面的位置;
(2)光线进入角膜内部后,如何计算其散射效果。
为了解决以上两个问题,可使用次表面纹理映射(Subsurface texture mapping),这个方法旨在解决多层厚度不均匀的材质的次表面散射效果的计算。



如上图,每一层材质都有一个单独的深度图,保存在一个通道里,然后每一层单独的材质被认为是均匀的,拥有相同的散射、吸收系数以及相应的相位函数(散射相关的参数)。然后,以视线和第一层材质的交点为起点,沿着视线方向对多层材质进行ray-marching,每行进一步就根据位置和深度图计算当前点位于材质的哪一层,对应什么散射参数,再根据上一步的位置以及光照方向计算散射和吸收,直到ray-marching结束。具体到眼球的散射计算,实际上只有一层散射材质,即瞳孔材质。因此我们只需要提供瞳孔表面的深度图,并设定好瞳孔材质的相关散射参数,再结合次表面纹理映射的方法计算即可。
这部分主要涉及的渲染技术:

  • 视差贴图(parallax mapping,也叫relief mapping)。可以通过ray marching的方法结合一张深度图在相对平坦的几何表面上实现视觉正确的高低起伏效果,法线效果虽然也能在平面上产生凹凸起伏,但在比较斜的视角下平面还是平面,视差贴图则不会这样。




左:normal mapping效果;右:parallax mapping效果。可见在倾斜视角下,后者效果要好很多。

  • 基于物理的折射(Physically based Refraction)。与视差贴图的欺骗式计算不同,基于物理的折射是根据真实的折射模型进行模拟,效果更真实。




hlsl   float cosAlpha = dot(frontNormalW, -refractedW);   float dist = height / cosAlpha;   float3 offsetW = dist * refractedW;   float2 offsetL = mul(offsetW, (float3x2) worldInverse);   texcoord += float2(mask, -mask) * offsetL;



左:视差贴图效果;右:基于物理的折射效果。
当光线从侧面射进眼球时,经过折射和透射后,会在另一侧发生较强烈的透射光环:



这种跟光线角度相关的折射,可以通过预计算的方式解决:




  • 参合多介质渲染(participating media rendering)。它在近年来广泛地被应用在体积光、云彩和天空相关的渲染技术中。更多内容请参看:Rendering participating media。




利用participating media rendering技术渲染的体积雾。
3.2.3 瞳孔的缩放

瞳孔的放大和缩小实现非常简单,通过控制采样瞳孔贴图的UV即可。



UE4的眼球模型的UV布局



Mike的眼球材质提供了缩放参数,以便调节瞳孔大小。
3.2.4 虹膜的颜色

虹膜的颜色可以首先给定一个虹膜纹理的灰度图,然后用给定虹膜颜色乘以灰度颜色,即可得到最终虹膜的颜色,这样可以通过一套资源来实现不同颜色的眼球的渲染。



Mike的眼球材质提供了更改瞳孔、虹膜等颜色的参数。
3.2.5 其它眼球细节

眼球的细节刻画可以增加其真实度,使画面更上一个台阶。

  • 不平坦反射。真实的眼白不是完全镜面平坦的,有一定程度的凹凸不平,可以通过类Sine函数扰动其法线贴图达到模拟效果。





  • 湿润度。大多数人的眼睛都带有不同程度的泪水,具有不同的湿润度。可通过建立一层透明网格来模拟此效果。




不同湿润度的网格模型
模拟出来的效果如下:



眼球的湿润度从左到右:低、中、高。
此外,可以模糊湿润网格,以便更好地将眼睛边缘做融合:




  • 眼睛自反射。由于眼球具体较强的反射,而已睫毛、眼皮会反射在上面,如果这部分被忽略,将会有点怪。




左:没有自反射;右:有睫毛、眼皮等的自反射。
然而要实时地计算自反射会消耗较多的性能,可预先烘焙环境遮蔽图,渲染时直接采样:




  • 瞳孔、虹膜、巩膜等部位之间的过渡。由于它们分属不同的材质,有着各自的属性,如果它们的交界处不进行插值过渡,将会出现恐怖的效果(下图右)。




左:采用了过渡;右:未采用过渡。
过渡曲线可采用类似Sine函数的变种:




  • 血色和血丝。血丝可在眼白的纹理添加血管纹理细节,而血色可在计算时乘以由一张遮罩纹理控制的红色来模拟。




带血丝细节的眼球纹理。

  • 接触阴影(Contact Shadow)。半透明材质可以启用接触阴影。此功能使用类似于光源接触阴影的功能,但不会链接到光源接触阴影参数。这是屏幕空间效果,可以作为几何体的补充,也可以取代几何体,让眼睛看起来牢牢地长在眼眶中,提高可信度。




左:未开启接触阴影;右:开启接触阴影,开启后,反射光变弱了。
3.3 眼球的底层实现

本节将深入源码层剖析UE的眼睛渲染细节。需要注意的是,要将眼睛材质的Shading Model选择为Eye(下图),并且眼睛着色模式启用了次表面散射,即眼睛着色模式是一种特殊化的次表面剖面(Subsurface Profile)着色模式。



在shader层,Eye的渲染模型跟普通的PBR流程和逻辑区别甚微,跟它相关的代码文件:

  • G:\UnrealEngine\Engine\Shaders\Private\DeferredLightingCommon.ush。
  • G:\UnrealEngine\Engine\Shaders\Private\BasePassPixelShader.usf。
  • G:\UnrealEngine\Engine\Shaders\Private\ShadowProjectionPixelShader.usf。
  • G:\UnrealEngine\Engine\Shaders\Private\ShadingModelsMaterial.ush。
首先分析ShadingModelsMaterial.ush在眼睛着色模式下GBuffer数据初始化相关的代码:
void SetGBufferForShadingModel(
    in out FGBufferData GBuffer,
    in const FMaterialPixelParameters MaterialParameters,
    const float Opacity,
    const half3 BaseColor,
    const half  Metallic,
    const half  Specular,
    const float Roughness,
    const float3 SubsurfaceColor,
    const float SubsurfaceProfile,
    const float dither)
{

    // ... (省略部分代码)

#elif MATERIAL_SHADINGMODEL_EYE
    GBuffer.ShadingModelID = SHADINGMODELID_EYE;
    GBuffer.CustomData.x = EncodeSubsurfaceProfile(SubsurfaceProfile).x;
    GBuffer.CustomData.w = 1.0f - saturate(GetMaterialCustomData0(MaterialParameters)); // Opacity = 1.0 - Iris Mask
    GBuffer.Metallic = saturate(GetMaterialCustomData1(MaterialParameters));            // Iris Distance

// 如果定义了虹膜法线,进入了一段较复杂的数据处理。可见开启虹膜法线需要消耗较多性能。
#if IRIS_NORMAL
    float IrisMask      = saturate( GetMaterialCustomData0(MaterialParameters) );
    float IrisDistance  = saturate( GetMaterialCustomData1(MaterialParameters) );

    GBuffer.CustomData.x = EncodeSubsurfaceProfile(SubsurfaceProfile).x;
    GBuffer.CustomData.w = 1.0 - IrisMask;  // Opacity

    float2 WorldNormalOct = UnitVectorToOctahedron( GBuffer.WorldNormal );

    // CausticNormal stored as octahedron
    #if NUM_MATERIAL_OUTPUTS_GETTANGENTOUTPUT > 0
        // 通过法线的变换,创建一些凹陷度。
        // Blend in the negative intersection normal to create some concavity
        // Not great as it ties the concavity to the convexity of the cornea surface
        // No good justification for that. On the other hand, if we're just looking to
        // introduce some concavity, this does the job.
        float3 PlaneNormal = normalize( GetTangentOutput0(MaterialParameters) );
        float3 CausticNormal = normalize( lerp( PlaneNormal, -GBuffer.WorldNormal, IrisMask*IrisDistance ) );
        float2 CausticNormalOct  = UnitVectorToOctahedron( CausticNormal );
        float2 CausticNormalDelta = ( CausticNormalOct - WorldNormalOct ) * 0.5 + (128.0/255.0);
        GBuffer.Metallic = CausticNormalDelta.x;
        GBuffer.Specular = CausticNormalDelta.y;
    #else
        float3 PlaneNormal = GBuffer.WorldNormal;
        GBuffer.Metallic = 128.0/255.0;
        GBuffer.Specular = 128.0/255.0;
    #endif

    // IrisNormal CustomData.yz
    #if NUM_MATERIAL_OUTPUTS_CLEARCOATBOTTOMNORMAL > 0
        float3 IrisNormal = normalize( ClearCoatBottomNormal0(MaterialParameters) );
        #if MATERIAL_TANGENTSPACENORMAL
            IrisNormal = normalize( TransformTangentVectorToWorld( MaterialParameters.TangentToWorld, IrisNormal ) );
        #endif
    #else
        float3 IrisNormal = PlaneNormal;
    #endif

    float2 IrisNormalOct  = UnitVectorToOctahedron( IrisNormal );
    float2 IrisNormalDelta = ( IrisNormalOct - WorldNormalOct ) * 0.5 + (128.0/255.0);
    GBuffer.CustomData.yz = IrisNormalDelta;
#else
    GBuffer.Metallic = saturate(GetMaterialCustomData1(MaterialParameters));            // Iris Distance

    #if NUM_MATERIAL_OUTPUTS_GETTANGENTOUTPUT > 0
        float3 Tangent = GetTangentOutput0(MaterialParameters);
        GBuffer.CustomData.yz = UnitVectorToOctahedron( normalize(Tangent) ) * 0.5 + 0.5;
    #endif
#endif

    // ... (省略部分代码)

}接着分析接触阴影相关的代码,在DeferredLightingCommon.ush内:
void GetShadowTerms(FGBufferData GBuffer, FDeferredLightData LightData, float3 WorldPosition, float3 L, float4 LightAttenuation, float Dither, inout FShadowTerms Shadow)
{
    // 默认接触阴影强度是0。
    float ContactShadowLength = 0.0f;
    // 接触阴影长度屏幕空间缩放
    const float ContactShadowLengthScreenScale = View.ClipToView[1][1] * GBuffer.Depth;

    BRANCH
    if (LightData.ShadowedBits)
    {

        // ... (省略部分代码)

        // 根据缩放因子计算接触阴影长度。
        FLATTEN
        if (LightData.ShadowedBits > 1 && LightData.ContactShadowLength > 0)
        {
            ContactShadowLength = LightData.ContactShadowLength * (LightData.ContactShadowLengthInWS ? 1.0f : ContactShadowLengthScreenScale);
        }
    }

#if SUPPORT_CONTACT_SHADOWS
    // 如果是头发或者眼睛着色模式,接触阴影长度强制缩放到0.2倍(这个值应该是测量过的值)。
    if ((LightData.ShadowedBits < 2 && (GBuffer.ShadingModelID == SHADINGMODELID_HAIR))
        || GBuffer.ShadingModelID == SHADINGMODELID_EYE)
    {
        ContactShadowLength = 0.2 * ContactShadowLengthScreenScale;
    }

    #if MATERIAL_CONTACT_SHADOWS
        ContactShadowLength = 0.2 * ContactShadowLengthScreenScale;
    #endif

    BRANCH
    if (ContactShadowLength > 0.0)
    {
        float StepOffset = Dither - 0.5;
        // 计算接触阴影
        float ContactShadow = ShadowRayCast( WorldPosition + View.PreViewTranslation, L, ContactShadowLength, 8, StepOffset );

        Shadow.SurfaceShadow *= ContactShadow;

        // 计算透射阴影
        FLATTEN
        if( GBuffer.ShadingModelID == SHADINGMODELID_HAIR )
            Shadow.TransmissionShadow *= ContactShadow;
        // 如果是眼睛渲染模式,则不加深阴影强度,否正加深。
        else if( GBuffer.ShadingModelID != SHADINGMODELID_EYE )
            Shadow.TransmissionShadow *= ContactShadow * 0.5 + 0.5;
    }
#endif
}还有小部分逻辑在ShadowProjectionPixelShader.ush,关于阴影计算的:
void Main(
    in float4 SVPos : SV_POSITION,
    out float4 OutColor : SV_Target0
    )
{
    // ... (省略部分代码)

        if (IsSubsurfaceModel(GBufferData.ShadingModelID))
        {
            float Opacity = GBufferData.CustomData.a;
            // Derive density from a heuristic using opacity, tweaked for useful falloff ranges and to give a linear depth falloff with opacity
            float Density = -.05f * log(1 - min(Opacity, .999f));
            // 如果是头发或眼睛渲染模式,不透明度和密度强制设为1。
            if( GBufferData.ShadingModelID == SHADINGMODELID_HAIR || GBufferData.ShadingModelID == SHADINGMODELID_EYE )
            {
                Opacity = 1;
                Density = 1;
            }

    // ... (省略部分代码)
}从上面分析可知,眼睛着色模式与次表面剖面着色模式基本一致,只是在GBuffer数据初始化、阴影计算上有所差别。
3.4 眼球的材质

本节将分析Mike的眼球主材质和附属物材质。
3.4.1 眼球主材质

眼球主材质是M_EyeRefractive,下图是眼球主材质的总览图,节点排布有点乱(UE材质编辑器并没有提供自动排布功能)。下面将分小节重点分析眼球材质的重要或主要算法过程,其它的小细节将被忽略。



3.4.1.1 眼球的折射





如上图所示,眼球的折射主要通过材质函数ML_EyeRefraction实现,下面将对它的输入参数和输出参数进行分析。
材质函数ML_EyeRefraction的输入参数:

  • InternalIoR:眼球内部折射,用于模拟光线进入虹膜后的折射率,数值通常在$[1.0,1.4]$之间,越大折射效果越明显。直接由变量IoR提供。
  • ScaleByCenter:眼球(包含眼白、瞳孔、虹膜等)的缩放大小。直接由变量ScaleByCenter提供。
  • LimbusUVWidth:角膜缘的UV宽度,由LimbusUVWidthColor和LimbusUVWidthShading组成的2D向量提供。
  • DepthScale:虹膜的深度缩放。数值越大,折射效果越明显。由变量DepthScale提供。
  • DepthPlaneOffset:深度平面偏移。决定瞳孔的大小和深度。由变量Iris UV Radius和ScaleByCenter共同算出UV,然后采样贴图T_EyeMidPlaneDisplacement的R通道提供数据。
  • MidPlaneDisplacement:中平面偏移,决定角膜平面到瞳孔平面的深度偏移,瞳孔周边的偏移会较小。直接采样贴图T_EyeMidPlaneDisplacement获得。T_EyeMidPlaneDisplacement如下:





  • EyeDirectionWorld:眼球模型的世界空间的法线。由UseEyeBuldge控制的两张法线贴图T_Eye_N和T_Eye_Sphere_N采样后,由切线空间变换到世界空间获得。其中T_Eye_N是中间有凸出的眼球结构(下图左),而T_Eye_Sphere_N则没有(下图右):





  • IrisUVRadius:虹膜UV半径,直接由变量Iris UV Radius提供。
材质函数ML_EyeRefraction的输出参数:

  • RefractedUV:折射后的UV,经过材质函数内部计算后,输出的UV结果,后面可以用于采样漫反射、其它遮罩贴图。
  • Transparency:虹膜颜色透明度。
  • IrisMask:标识虹膜UV区域的遮罩。后续用于虹膜区域的相关着色处理。
上面只是分析了ML_EyeRefraction的输入、输出参数,下面将进入其内部计算过程:



首先分析折射向量(Refraction Direction)的计算:
float airIoR = 1.00029;

// 空气对眼球内部的折射率比。
float n = airIoR / internalIoR;
// 法线和摄像机向量的夹角相关的缩放因子
float facing = dot(normalW, cameraW);
// 视角缩放后的折射率比。
float w = n * facing;
// 根据n和w计算中间因子。
float k = sqrt(1+(w-n)*(w+n));

// 根据n、w和k算出最终的折射向量。
float3 t;
t = (w - k)*normalW - n*cameraW;
t = normalize(t);
return -t;再分析折射纹理偏移(Refracted UV Offset)的计算:



由上图可见,要算出右边红色方框标识的折射纹理偏移,需要用到众多输入参数,以及经过多次坐标运算和角度计算。虽然过程比较复杂,但原理跟3.2.2 瞳孔的次表面散射的基于物理的折射一致。
有了折射向量和折射纹理偏移,就可以通过数次基本运算调整,算出最终的输出参数RefractedUV。
对于输出参数IrisMask的计算,由以下shader代码完成:
// 计算Iris遮罩(R通道)和角膜缘过渡区域(G通道)
UV = UV - float2(0.5f, 0.5f);

float2 m, r;
r = (length(UV) - (IrisUVRadius - LimbusUVWidth)) / LimbusUVWidth;
m = saturate(1 - r);
// 通过类sine函数变种,输出柔和的混合因子,使得角膜缘过渡自然、柔和。
m = smoothstep(0, 1, m);
return m;3.4.1.2 瞳孔的缩放





由上图可以看出,如果开启了折射(Refraction On/Off为true),则会使用上一小节计算的折射后的UV坐标,经过坐标换算和中心缩放,成为Custom shader节点的输入参数,它的输入还有PupilScale,用于决定瞳孔的大小。Custom shader节点的代码如下:
// 主要是将UV坐标绕着纹理中心进行PupilScale缩放

// float2 UV, float PupilScale

float2 UVcentered = UV - float2(0.5f, 0.5f);
float UVlength = length(UVcentered);
// UV on circle at distance 0.5 from the center, in direction of original UV
float2 UVmax = normalize(UVcentered)*0.5f;

float2 UVscaled = lerp(UVmax, float2(0.f, 0.f), saturate((1.f - UVlength*2.f)*PupilScale));
return UVscaled + float2(0.5f, 0.5f);3.4.1.3 眼球颜色的混合





眼球的颜色主要有两种颜色提供:

  • 眼白颜色(Sclera Color):由T_EyeScleraBaseColor采样获得,并且经过变量ScleraBrightness缩放。其中采样的UV没有折射,只经过中心点缩放。
  • 虹膜颜色(Iris Color):颜色采样T_EyeIrisBaseColor获得,并且纹理UV经过3.4.1.1 眼球的折射的折射计算,以及3.4.1.2 瞳孔的缩放的中心点缩放。采样得到的颜色经过IrisBRightness和角膜缘(Limbus)相关的参数缩放。
以上两种颜色经过ML_EyeRefraction输出的IrisMask进行插值,添加虹膜颜色(CloudyIris)后,最终输出到Base Color引脚。
3.4.1.4 眼球的法线





眼球法线的UV经过中心点缩放,接着去采用法线贴图T_Eye_Wet_N,得出的法线经过材质函数FlattenNormal和缩放因子调整法线强度,最终输出到法线引脚。
其中FlattenNormal的强度由ML_EyeRefraction输出的IrisMask指示的虹膜区域在$[FlattenNormal, 1.0]$进行插值。如果是虹膜区域,则不受法线影响,即完全光滑的。
3.4.1.5 虹膜的遮罩和深度

虹膜的遮罩直接由ML_EyeRefraction输出的IrisMask获得。
虹膜的深度由折射后的纹理UV计算出距离圆心(0.5,0.5)的长度,获得与Iris UV Radius的比值,再经过Iris Concavity Scale缩放和Power调整后,得到最终结果。(下图)



3.4.1.6 清漆底部法线(ClearCoatBottomNormal)





如上图,Custom节点与3.4.1.2 瞳孔的缩放中的一样,计算了UV沿着中心点缩放,接着去采样瞳孔法线纹理iris08_leftEye_nml,获得的结果经过IrisDispStrength控制的因子缩放,最后通过节点BlendAngleCorrectedNormals与眼球表面法线混合,输出结果到Output节点ClearCoatBottomNormal。
3.4.1.7 眼球的其它部分

眼球的其它属性,如镜面度、粗糙度、切线等,都比较简单,直接看材质即可明白其计算过程,故这里不做分析。
3.4.2 眼球附属物材质

上小节分析了眼球的主材质,然而,眼睛的渲染还包含了很多附加物体,它们各自有着独立的材质属性(下图)。



3.4.2.1 泪腺液体





泪腺几何体是一个包围着眼皮周围的网格体(上图),提供了眼皮处的高光反射(下图),用于模拟光线照射到泪腺后的镜面反射。



左:无泪腺几何体;右:有泪腺几何体
它的材质如下图,采用了透明混合模式:



它的颜色、金属度默认都是1,可见用高反射率和高金属度来获得极强的镜面反射效果。
它的粗糙度计算较复杂,如下图:



纹理坐标经过变量DetailScale_1缩放后,去采样细节纹理skin_h,获得的结果再依次经过DetailAmount缩放、固定常量0.1和Roughness调整后,进入自定义shader节点CurveToRoughness计算,最终得到结果。其中CurveToRoughness的shader代码如下:
// Specular antialiasing using derivatives and normal variance

float3 N = WorldNormal;
float3 dN = fwidth( N );
float Curvature = sqrt( 1 - dot( normalize( N + dN ), N ) );

// TODO find an approximation that more directly uses Roughness
float Power = 2 / pow( Roughness, 4 ) - 2;
float Angle = 4.11893 / sqrt( Power ) + Curvature;
Power = 16.9656 / ( Angle * Angle );
Roughness = sqrt( sqrt( 2 / (Power + 2) ) );

return Roughness;上面涉及的粗糙度算法在Rock-Solid Shading: Image Stability Without Sacrificing Detail有详细描述。
它的法线计算比较简单,采样法线贴图skin_n后经过变量DetailAmount调整,就得到最终结果。
此外,它还增加了世界坐标偏移,由变量'DepthOffset'控制偏移量,经过材质函数CameraOffset得到相机空间的偏移。
3.4.2.2 遮蔽模糊体





遮蔽模糊体跟泪腺液体类似,环绕于眼角周边,用于遮挡部分光照并模糊,使得周边混合更真实(下图)。



左:无遮蔽模糊体;右:有遮蔽模糊体
它的材质采用透明混合模式,并且光照模型是Unlit。它的总览图如下:



可分下面几个部分进行分析:

  • 不透明度(Opacity):
这部分主要是生成需要遮蔽和模糊的区域掩码。过程大致是通过采样初始掩码图,加上纹理线性过渡、反向、加上Power运算调整,以及若干变量控制的因子进行基本运算,获得眼部周边掩码(下图)。




  • 模糊(Blur):
在当前UV周边采样16个Scene Color求得平均值。此处的Scene Color一定是已经渲染眼球后的颜色,因为眼球是非透明物体,可保证在透明的遮蔽模糊体之前先绘制。

  • 颜色(Color):
原始颜色的输出很简单,利用上面计算的遮罩,在白色和Blur Color之间插值,然后与上面模糊后的场景颜色相乘。

  • 阴影(Shadow):




如上图,通过UV的上下左右线性渐变及调整后获得4个不同的值,进行相乘,获得周边黑色,最后通过变量在1.0之间插值,获得顶部为深色的阴影图。

  • 综合计算:
在此阶段,利用上面的几个计算结果,颜色和阴影相乘,并预乘了Alpha,获得最终颜色和不透明度。
此外,还有位置偏移的计算,这里将忽略。
3.4.2.3 眼角混合体

眼角混合体为眼角增加血色及血丝细节,并调整亮度,使得眼白过渡更自然(下图)。



左:无眼角混合体;右:有眼角混合体
它的材质启用了次表面散射,并且混合模式是裁剪(Masked),材质总览图如下:



下面将其拆分成若干部分进行分析:

  • 渐变掩码:




利用UV的横坐标获得线性过渡,用Power调整强度,然后用SmoothStep获得平滑过渡的掩码图。

  • 颜色:




通过几个变量将UV坐标进行拉伸,去采样眼睛贴图eye_sclera_right_clr,获得拉伸后的颜色,经过眼白亮度调整和由上节计算出的掩码决定的眼白到血色的调整,获得最终颜色。其中眼角偏红,呈现出更多血色,而靠近瞳孔的区域受影响程度较低。

  • 法线:
法线的获得,主要由上面计算出的掩码,在向量[0, 0, 1]和[-1, 0, 0]插值获得。
3.4.2.4 睫毛和眉毛

由于睫毛和眉毛的材质属于Hair着色模式,虽然是眼睛的组成部分,但其实是毛发渲染的范畴,后续章节将会详细阐述。
3.5 眼球渲染总结

由上面可知,虽然眼睛的渲染技术不如皮肤渲染来得更高深、更系统,但由于其涉及的部位和细节多,环环相扣,各个材质之间相辅相成,形成了一套完整而逼真的眼睛渲染体系。
本章结尾,引用官方文档的建议:
在开发数字人类角色时,我们在模型中使用了一些不同方法和材质提升了角色眼部的逼真度。如上所述,许多眼部设置与材质设置和采集的参考资料之间存在着相互依赖的关系。我们强烈建议使用我们的眼部设置作为您的起点。
可见,要完全从零开始制作一个成像逼真的眼睛的资源(模型、贴图、材质等),还是有相当的难度。幸好慷慨的虚幻引擎官方已经给出了足够多的示例及资源,以供个人及团队研究和研发,大大缩短了学习、开发的周期。

四、毛发渲染

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)
差角度计算如下:
$\theta_d = (\theta_r - \theta_i) /2$
$\phi = (\phi_r - \phi_i)$
半角度计算如下:
$\theta_h = (\theta_r - \theta_i) /2$
$\phi_h = (\phi_r - \phi_i) /2$
$R$,$TT$,$TRT$三种散射纵向散射函数$M$都满足$\theta_h$符合高斯分布。公式如下:
$M_R = g(\beta_R, \alpha_R, \theta_h)$
$M_{TT} = g(\beta_{TT}, \alpha_{TT}, \theta_h)$
$M_{TRT} = g(\beta_{TRT}, \alpha_{TRT}, \theta_h)$
$R$和$TRT$散射方位角散射函数$N$分别简化为$\cos^2 \phi $,$TT$散射方位角散射函数$N$满足$\phi$ 符合高斯分布。公式如下:
$N_R= \cos^2\phi$
$N_{TT} = g(\gamma_{TT}, 0.0, \pi - \phi)$
$N_{TRT} = \cos^2\phi$
最终散射公式如下:
$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。和离线光线跟踪毛发间接采样方法相比,双向散射会节省大量时间,质量几乎接近。
双向散射主要用于估计毛发的多散射函数,这个函数有两个部分组成:

  • 全局散射函数。全局散射函数用于计算由于光穿过周边的毛发对当前毛发的散射贡献,
  • 局部散射函数。局部散射用于计算由于光多次在周边头发折射对当前毛发的散射贡献。
这两种贡献的总和称为双向多散射。这种计算模型不受光源数量和类型的限制。



如上图所示,可获得如下的抽象公式:  \Psi(x,\omega_d,\omega_i) = \Psi^G(x,\omega_d,\omega_i)(1 + \Psi^L(x,\omega_d,\omega_i))  毛发光照(包含直接光照和间接光照)实现的伪代码:



更具体的推导和实现过程请参看参考论文,也可参考这篇技术文章:Real-Time Hair Simulation and Rendering。
4.2 毛发的底层实现

UE实现毛发的shader代码主要在:

  • \Engine\Shaders\Private\ShadingModels.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 毛发的底层实现)的缩放。
由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生成

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)
  • Separable Subsurface Scattering
  • Real-Time Realistic Skin Translucency
  • 《GPU Gems 3》:真实感皮肤渲染技术总结
  • 角色渲染技术——皮肤
  • 细致到毛孔 ! 深度揭秘超真实皮肤的实时渲染技术(上篇)
  • 细致到毛孔 ! 深度揭秘超真实皮肤的实时渲染技术(下篇)
  • 《由浅入深学习PBR的原理和实现》
  • Fast subsurface scattering
  • BRDF representation and acquisition
  • A BSSRDF Model for Efficient Rendering of Fur with Global Illumination
  • Parameter Estimation of BSSRDF for Heterogeneous Translucent Materials
  • NVIDIA官方展示HairWorks“海飞丝”(1.1 by Tarkan Sarim)
  • Q132:PBRT-V3,BSSRDF(双向散射表面反射分布函数)(5.6.2章节、11.4章节)
  • BSSRDF Explorer: A Rendering Framework for the BSSRDF
  • BSSRDF Importance Sampling
  • A Practical Model for Subsurface Light Transport
  • Digital Mike头发制作及渲染的深度揭秘
  • SIGGRAPH 2017|迄今为止最高品质实时数字人
  • NEXT Story S02.03 - 虚拟人(1)
  • 虚幻引擎在GDC教你做人
  • 数字人类
  • 照片级角色
  • 次表面轮廓明暗处理模型
  • Gaussian Models
  • 《Technical Artist 的不归路 —— 线性空间光照》
  • Kim Libreri畅谈虚拟制片、数字化人物和Epic Games的后续发展
  • Siren亮相FMX 2018:实时穿越《恐怖谷》
  • Star Wars
  • Facial Action Coding System
  • 探究《地狱之刃:塞娜的献祭》(Hellblade: Senua's Sacrifice)背后的理念
  • EPIC win: previs to final in five minutes
  • 恐怖谷理论
  • Normal Map Blending in Unreal Engine 4
  • Separable Subsurface Scattering
  • Real-Time Realistic Skin Translucency
  • Cry Engine Doc: Eye Shader
  • A Survey on Participating Media Rendering Techniques
  • Rendering participating media
  • Modeling and real-time rendering of participating media using the GPU
  • Deferred_POM-DirectX Renderer
  • Subsurface Texture Mapping
  • Rock-Solid Shading: Image Stability Without Sacrificing Detail
  • The Process of Creating Volumetric-based Materials in Uncharted 4
  • 眼睛建模逐步剖析教学-超详细
  • Skin Microstructure Deformation with Displacement Map Convolution
  • 角色渲染技术——毛发及其他
  • Light Scattering from Human Hair Fibers
  • Dual Scattering Approximation for Fast Multiple Scattering in Hair
  • A Data-Driven Light Scattering Model for Hair
  • GPU Gem 2: Chapter 23. Hair Animation and Rendering in the Nalu Demo
  • Real-Time Hair Simulation and Rendering
  • Digital Mike头发制作及渲染的深度揭秘
  • NVIDIA官方展示HairWorks“海飞丝”(1.1 by Tarkan Sarim)

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

×
懒得打字嘛,点击右侧快捷回复 【右侧内容,后台自定义】
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|手机版|Unity开发者联盟 ( 粤ICP备20003399号 )

GMT+8, 2024-11-23 12:24 , Processed in 0.103120 second(s), 26 queries .

Powered by Discuz! X3.5 Licensed

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表