一、概述
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(ncdot l)(ncdot 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的方程,它是一次反射光照的计算是在光线交点的法线半球上的球面积分:
对于BSSRDF来说,每一次反射在物体表面上每一个位置都要做一次半球面积分,是一个嵌套积分:
(S(p_o,omega_o,p_i,omega_i))项表明了次表面散射的计算过程,具体公式:
其中:
-
(frac{dL_r(p_o,omega_o)}{dPhi_r(p_i,omega_i)})表明BSSRDF的定义是出射光的辐射度和入射通量的比值。
-
(F_t)是菲涅尔透射效应。
-
(R_d(parallel p_i-p_oparallel))是扩散反射(Diffuse reflectance),与入射点和出射点的距离相关。
[R_d(parallel p_i-p_oparallel) = -Dfrac{(ncdot rianglephi(p_o))}{dPhi_i(p_i)} ]- (D)是漫反射常量:[D=frac{1}{3sigma_t'} ]
- (D)是漫反射常量:
由此可见,(S)项的计算过程比较复杂,对于实时渲染,是几乎不可能完成的。由此可采用近似法求解:
其中:
-
(F_r(cos heta_o))是菲涅尔反射项。
-
(S_p(p_o,p_i))是点(p)处的次表面散射函数。它可以进一步简化:
[S_p(p_o,p_i) approx S_r(parallel p_o - p_iparallel) ]也就是说点(p)处的次表面系数只由入射点(p_i)和出射点(p_o)相关。
(S_r)跟介质的很多属性有关,可用公式表达及简化:
[egin{eqnarray} S_r(eta,g, ho,sigma_t,r) &=& sigma^2_t S_r(eta,g, ho,1,r_{optical}) \ &approx& sigma^2_t S_r( ho,r_{optical}) \ r_{optical} &=& ho_t r end{eqnarray} ]简化后的(S_r)只跟( ho)和(r)有关,每种材料的( ho)和(r)可组成一个BSSRDF表。
上图展示了( ho=0.2)和(r=0.5)的索引表。
通过( ho)和(r)可查询到对应的(S_r),从而化繁为简,实现实时渲染的目标。
-
(S_omega(omega_i))是有缩放因子的菲涅尔项,它的公式:
[S_omega(omega_i) = frac{1-F_r(cos heta_i)}{ccdot pi} ]其中(c)是一个嵌套的半球面积分:
[egin{eqnarray} c &=& int_0^{2pi} int_0^{frac{pi}{2}} frac{1-F_r(eta,cos heta)}{pi}sin heta cos heta d heta dphi \ &=& 1 - 2 int_0^{frac{pi}{2}} F_r(eta,cos heta)sin heta cos heta d heta dphi 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)做了简化:
具体的推导过程请参看:Separable Subsurface Scattering。
该论文还提到,为了给实时渲染加速,还需要预积分分离的卷积核(Pre-integrated Separable Kernel):
利用奇异值分解(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,不同版本可能有所差别):
-
EngineShadersPrivateSeparableSSS.ush:
SSSS的shader主要实现。
-
EngineShadersPrivatePostProcessSubsurface.usf:
后处理阶段为SeparableSSS.ush提供数据和工具接口的实现。
-
EngineShadersPrivateSubsurfaceProfileCommon.ush:
定义了SSSS的常量和配置。
-
EngineSourceRuntimeEnginePrivateRenderingSeparableSSS.cpp:
实现CPU版本的扩散剖面、高斯模糊及透射剖面等逻辑,可用于离线计算。
-
EngineSourceRuntimeEnginePrivateRenderingSubsurfaceProfile.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)
扩散剖面的模拟可由若干个高斯和函数进行模拟,其中高斯函数的公式:
下图是单个高斯和的扩散剖面曲线图:
由此可见R、G、B的扩散距离不一样,并且单个高斯函数无法精确模拟出复杂的人类皮肤扩散剖面。
实践表明多个高斯分布在一起可以对扩散剖面提供极好的近似。并且高斯函数是独特的,因为它们同时是可分离的和径向对称的,并且它们可以相互卷积来产生新的高斯函数。
对于每个扩散分布(R(r)),我们找到具有权重(omega_i)和方差(v_i)的(k)个高斯函数:
并且高斯函数的方差(v)有以下定义:
可以选择常数(frac{1}{2v})使得(G(v, r))在用于径向2D模糊时不会使输入图像变暗或变亮(其具有单位脉冲响应(unit impulse response))。
对于大部分透明物体(牛奶、大理石等)用一个Dipole Profile就够了,但是对于皮肤这种拥有多层结构的材质,用一个Dipole Profile不能达到理想的效果,可以通过3个Dipole接近Jensen论文中的根据测量得出的皮肤Profile数据。
实验发现,3个Dipole曲线可通过以下6个高斯函数拟合得到(具体的拟合推导过程参见:《GPU Gems 3》:真实感皮肤渲染技术总结):
上述公式是红通道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[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、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遮罩与参数相乘,再将结果与其它参数相加,最终输出结果。抽象成公式:
其中(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区域生成遮罩图。
-
本系列文章其它部分
特别说明
- 本系列还有眼球、毛发、其它身体部位的分析,未完待续。
- 感谢参考文献的作者们。
参考文献
- 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