前言
出于兴趣对游戏《Kena: bridge of spirits》的拆解还在继续,到目前为止已经在Unity的URP中搭建了简略版的UE4延迟渲染管线,虽然管线内部还有许多“填空题”待完成,但是框架算是完整了,作为核心一部分的几个UberPass也完成了反解,植入到了URP自己的 Stencil-Deffered Pass 中。现在要做的就是一块一块把缺失的部分补全。本文属于是对Kena角色皮肤渲染方案(准确说是UE自己的SSSS渲染方案)的一点梳理和总结,在这里分享出来,同时也给自己备忘一下吧。
(1) UE4 次表面材质方案的概述和特点
UE4用来渲染“真实质感皮肤”或者“蜡质表面”的着色模型可以大致整理为如下3种方案:
- Subsurface,
- Preintegrated Skin,
- Subsurface Profile
它们间的效果依次递进,但是性能消耗同样也递进。其中最后一项全名叫做:Subsurface Profile Shading (又称为SSSS渲染),是UE自家电子人项目实际采用的渲染方案,可谓效果非凡。游戏《Kena》中人物皮肤材质的渲染就使用了它。
SSSS相对传统SSS渲染的主要区别在于:
- 要求延迟渲染,不然很多全局参数获取不便
- 是屏幕空间算法
- 要求输入分离的Diffuse和Specular
- 算法核心的卷积(滤波)运算,工作在后处理之后,tonemapping之前
(2) 简单回顾次表面(如何建模皮肤)
我们知道高光反射(Specular)是指光线在物体表面直接弹走的那部分光能,如下图中黄色出射箭头所示,由于未进入物体内部与介质相互作用,所以反射光波一般保持不变(入射光的样子),这就是高光颜色一般为光源颜色的物理学解释。
而漫反射(Diffuse)指的是另一部分(对于像皮肤这样的电介质来说是绝大部分)进入物体内部的光能。它们在物体内与介质相互作用,经过吸收,多次折射和反射后最终回到入射表面形成了漫反射。如下图中蓝色和浅蓝色箭头所示,由于在物体内部传输导致的方向随机性,漫反射一般在出射方面是均匀分布于半球空间的,另一方面由于存在被介质吸收的情况,漫反射光通常也会被染色,对应了不同材质的F0
系数。
所谓次表面散射,其实说的还是漫反射,只不过当我们站在更加微观的尺度去观察漫反射现象时,不能再简单的把入射点等同于出射点了。参考上图绿色圆圈,它们定义了最小的观察点(可类比于像素点),左侧的观察到涵盖了入射和出射光线的绝大部分,所以对于蓝色出射箭头来说它们是漫反射。右侧的观察点则无法包含大部分经过介质传播后出射的蓝色箭头,此时人们定义它们为次表面散射。
皮肤是典型的次表面材质:
简言之,皮肤共可分为三层:
- 表皮油脂 (Thin Oily Layer):模拟皮肤的高光反射。
- 表皮层 (Epidermis):模拟次表面散射的贡献层。
- 真皮层 Dermis):模拟次表面散射的贡献层。
油脂层主要贡献了皮肤光照的反射部分(约6%的光线被反射),而油脂层下面的表皮层和真皮层则主要贡献了的次表面散射部分(约94%的光线被散射)。上图分别展示了双向反射看待问题的方式(左侧BRDF)以及次表面散射处理问题的方式(右侧BSSRDF),可见BRDF只考虑皮肤表面点反射光线的分布,但实际上由于存在表皮层和真皮层的次表面散射(SSS),次表面散射模型才能准确反应皮肤的光照分布。
(3)次表面光照模型(BSSRDF )
首先是皮肤的高光反射,这部分相对来说变化较少,一般直接采用Cook-Torrance的BRDF公式计算,UE是在UberPass的高光部分进行渲染的,公式如下:
对应的光照方程如下:
因为不涉及次表面模型,具体不再赘述,感兴趣的同学可以参考BRDF的渲染流程相关解读。
所谓的BSSRDF,是 Bidirectional Surface Scattering Reflectance Distribution Function 的简称,既双向次表面散射反射分布函数,它描述的是:当一束光以任意角度入射到某个确定的微表面p
上时,有多少辐射率(Radiance)能够从任意一个给定的微表面q
上以某个给定的出射角度反射出去。与之对应的光照方程如下:
这是一个嵌套的二重积分,内层对半球空域积分,积分对象是入射光的角度微元dω
,这部分参考上面的BRDF光照方程,可以认为是对微表面全部入射光通量进行积分求和的过程;而外层积分的是一块区域A
(Area),积分对象dA
代表一份微面元,如果把被积函数S
移到外侧积分中去可以理解为是对处于区域A
内的各个微面元贡献的光照进行权重调节和加总,最终获得等式右侧的辐射率L0
(Radiance),它代表了处于指定位置p0
,朝向指定出射方向ω0
的辐射通量有多少,既摄像机能观察到的光亮度。
于是次表面散射方程S
的通用定义可以写作如下形式:
既方程S
能将位置xi
处以wi
角度入射的光能转化为位置x0
处w0
方向的出射光能,别看它定义非常笼统宽泛,实验科学家可以在定义的基础上开展测试,描绘方程S
在不同维度的物理特性,输出曲线,形成所谓的 Ground-Truth 作为比对和参考!
特别的,当我们抽离出散射方程S
,并稍加推演后可以得到如下经验公式:
其中除法项表明了BSSRDF的本质是:出射辐射率(Radiance)的微分与入射辐射通量(Radiant Flux)的微分之比。
式中Ft
是菲涅尔透射项,用于模拟入射和出射过程的损耗,一般只和材质属性和角度有关。最后是Rd
,既扩散反射函数本体,它的物理含义是光线进入物体内部经过“多次”散射后最终稳定形成的光线分布。实验表明这种稳定后的分布与入射出射点点精确位置无关,与它们之间的距离有关。
完全遵循物理的次表面散射模拟会使得函数S
异常复杂而庞大,对于实时渲染来说要做的是尽可能所见控制变量,同时寻找合适的快速拟合方式去逼近 Ground-Truth。下文介绍的SeparableSSS就是一种效果和性能俱佳的实时渲染解决方案。
(4)聊聊SSSS(SeparableSSS)的原理与模型
其实在学术界,实时渲染状态下的次表面散射模型大多具有以下2点特征:
- 先对每个像素进行一般的漫反射计算。
- 再根据某种特殊的函数
Rd(r)
和上述漫反射结果,加权计算周围若干个像素对当前像素的次表面散射贡献。
换言之就是利用巧妙设计过的卷积核心,或在纹理空间里,或在屏幕空间中,对次表面材质上累计的辐照度进行模糊操作,最终得到近似的次表面散射结果。 进一步讲,Rd(r)
函数与卷积核心是同一个概念,如上文所述,正式学术名叫:扩散反射函数。将函数绘制出来就得到了所谓的扩散剖面(Diffusion Profile)。如下图所示:
对于一块面积无限,介质均匀,厚度无限的理想表面来说,当一速激光照射上去时会引发光线向周围的扩散,形成以照射点为中心的稳定光晕。利用仪器记录这些分布在3D空间中的光强信息,使用图标将数据直观展示出来的话,大概能够得到如下结果:
这里所说的“扩散剖面”本质就是上图投影到x-z或y-z平面上的一块投影(剖面)。它直观的反映了次表面散射模型中散射光强随距离远景的分布趋势,实验表明这种趋势只受到材质属性(波长)以及扩散距离(r
)的影响,与光线的入射/出射位置或者光线的入射/出射方向无关,因而是各向同性(isotropy)的分布函数。
回到SeparableSSS模型,它源自Jimenez和Gutierrez在2015年的论文中提出的实时BSSRDF方案,其主要贡献是摒弃了非常耗时的2D
卷积,将其成功替换为2个相关的1D
卷积,既所谓卷积分离(Separable Convolution)优化方法。
参考上式,A
是前面提及的Rd
扩散反射函数的一种近似(Approximation),x
和y
则对应了纹理空间的uv
展开,Jimenez等人通过拆分近似函数A,获得了2个彼此在不同维度上独立展开的卷积内核a(x)
和b(y)
。其拆分过程的合法性证明大致可归纳如下:
式中的E
代表辐照度(Irradiance),Rd
就是扩散反射函数,x
和y
是出射点位置,x'
和y'
则是积分变量,dx'dy'
代表了积分区域R^2
上的一块微面元。整个第一个行对应了BSSRDF的光照方程。而在第一个约等号后,我们看到Rd
被替换成了一个近似函数A
,它需要具有被降级拆分的能力,该函数原型后文会介绍,此处先按下不表。再下面一行近似函数 A
被展开成多组低维函数之和,每组低维函数是由控制x
通道和控制y
通道的卷积核相乘而得。接下来将求和符号Σ
提到积分外并不重要,事实上我们在计算时会先完成求和再积分。最后一行是重点,由于卷积核a(x)
和a(y)
彼此独立,没有依赖关系,故而我们可以将面积积分R^2
拆分成x
轴向和y
轴向上的2次1D
积分,从而极大提高积分效率,这就是SSSS算法的核心目的了。
为了确定A
,必须先找到靠谱的Rd
,那么到底如何在数学上定义扩散反射函数呢?这就涉及到Jensen在2001年提出的散射模型了[Jenson et al.2001],由于存在大量公式推演和解读,受限于篇幅和精力,我就从简介绍了。Jensen在建模扩散反射函数时引入了偶极子(Dipole)的概念,如下图:
在当前语境下,所谓偶极子指的是一对互为正负的点光源。Jensen将其中的正极(positive real light source)放置在表皮平面的下方Zr
深度处,理想状态下正光源向外均匀辐射光强,同时受到介质内部散射的衰减。另一方面Jensen将负极(negative virtual light source)放置在了表皮平面与正极对立的另一侧,高度为Zv
,这个虚拟的光源负责吸收从表面散射出的光强,从而调节强度分布。在正负极子共同作用下,表面某处(x
)的的光通量可以表示为下方等式:
其中σtr
是透射吸收因子,大D
是散射常数,dr
是点(x
)到正极的直线距离,dv
是点(x
)到负极的直线距离。Jensen通过计算距离和调节系数,模拟出了次表面散射中出Rd
表数学定义:
很显然,这是个复杂的表达式,而且或许对于诸如牛奶或大理石这类材质,一个偶极子剖面足以描述散射分布,但是对于皮肤这样多层结构的材质,一般需要通过布置3个偶极子才能达到理想的效果,这进一步增加了计算公式的复杂度,综合来说不适合直接拿来做实时渲染。
现在是时候回过头看看我们的扩散剖面图:
不难发现,扩散剖面轮廓线类似于高斯函数(Gaussians),SSSS模型正是通过多个不同参数的高斯函数相互叠加来拟合的!实践表明,虽然单个高斯分布不能精确的描述扩散分布,但将多个不同的高斯分布加权在一起是可以对扩散剖面提供极好的近似的。高斯函数表达式具有一些很好的特性:当我们将扩散剖面表示为高斯和(Sum-of-Gaussians Diffusion)时,可以非常有效地求解次表面散射,因为它们同时满足了可分离性和径向对称性,天然满足卷积分离目标需求。因此利用一些数学工具(诸如奇异值分解 Singular Value Decomposition),Jimenez等人分离出了6个高斯函数用来拟合皮肤等带有3层偶极子的剖面(Dipole Prolie),并得到了不错的效果。
其中高斯函数定义如下:
均值为0,方差vi
,权重wi
用于控制参与求和的高斯函数形状,r
是散射距离。通过调节函数的各项特征,经过加权和之后获得拟合结果,这个过程的示意图如下:
SSSS模型在论文中对皮肤的拟合结果给出了如下的最终参数,可见R、G、B通道拟合出的曲线有所不同,而R通道曲线的扩散范围最远,这也是皮肤显示出红色的原因:
需要注意的是,对于每个颜色通道的剖面,高斯项的权重和为1.0
。这是因为处于性能考虑,我们在实际工程应用中,只使用红色通道的拟合值去做高斯模糊,因为即便如此,对于蓝绿色的散射效果也没有明显的瑕疵。
下式是拟合后求取Rd
近似函数值的公式,该函数基于6个高斯函数的加权和,入参r
的单位是mm,代表入射点到出射点之间的直线距离。
额外2点:
(1)关于内核大小:
对于一个简单的屏幕空间模糊操作,通常使用一个固定大小的内核(常量尺寸)就足够了,然而这在SSS的情况下是不可能的,因为内核通常代表一些基于物理的漫反射剖面。因此,有必要视不同的区域属性适时调整内核尺寸。卷积核大小是由三个因素的乘积决定的,即内核缩放系数(Kernal Scale)、SSS宽度 (SSS Width)和SSS强度(SSS Strength)。如果只是简单的使用固定大小内核对次表面像素作卷积,那么得到的就是一个恒定的整体模糊的表现效果,完全没有表面透视投影造成的区域畸变。对于每个屏幕空间表面像素来说,它们与相机的距离可能各不相同,加上透视投影带来的缩放效果,每一个像素所代表的真实次表面面积显然是可能不一样的。因此,有必要针对每个像素进行内核缩放,从而适应表面区域所关联的实际面积。
内核缩放系数(Kernal Scale)-> 定义如下:
其中fy
是摄像机的 field-of-view,既视场角,pd
代表了像素深度,公式含义是,内核尺寸应当随着像素的深度增加而变小,同时随着视场角的变小而增大。
SSS宽度 -> 是用户定义的次表面宽度,用于框定在什么样的尺度上能够看到次表面效果(类似定义了最远传播距离)。
SSS强度 -> 也是用户定义的次表面强度值,它通常以皮肤贴图的形式存在,用来人为规定不同地方的次表面强度变化。
(2)几何过滤:
屏幕空间连续的对次表面材质不一定在几何空间时连续的,因此SSS渲染必须考虑这种几何上的跳变差异,不然就有可能混淆几何上迥异的2块次表面材质,造成过渡不自然。使用的方案是比较当前采样点(in-center sample)和周围采样点(off-center sample)的像素颜色和深度值:
其中ic
是当前像素点颜色,oc
是周边像素颜色,id
是当前点深度,od
是周边像素点深度。
(5) UE的工程化方案
5.1 渲染流程
SSSS后处理流程示意:核心是两次分离,既对Diffuse和Specular的分离,以及对2D
卷积核的分离。
SSSS后处理流程在Renderdoc截帧中展示的顺序:核心是2次应用高斯模糊的Pass。
SSSS完整渲染流程:
- MRT pass输出UE4 Deffered shading GBuffer所需数据
- 在UberPass中(AmbientCubemap,GI,DirLight)计算并分离出次表面材质的Diffuse部分和Specular部分,在Separable Half-Res模式下,这2部分数据被分别编码在了原始分辨率下的不同棋盘格中
- SSSS后处理的第一步是在半分辨率(Half-Res)下解码出次表面材质对应的Diffuse,虽说是Diffuse,实际上是表面辐照度,因此偏亮
- 后面经过2个Pass进行高斯模糊处理,高斯核是预计算的,存放在了名为"ActualSSProfilesTexture"的查找表中
- 最后将模糊和衰减后的次表面散射强度升采样到原始分辨率,与基础颜色混合,同时叠加上Specular部分
5.2 计算分离的Diffue和Specular
(一)实现高光与漫反射分离:
在UberPass中拆分Diffuse和Specular的做法首先起始于如下函数,CheckerFormPixelPos用于确定每一个次表面像素的归属,返回“true”或“false”。
bool CheckerFromPixelPos(uint2 PixelPos)
{
uint TemporalAASampleIndex = View.TemporalAAParams.x;
#if FEATURE_LEVEL >= FEATURE_LEVEL_SM4
return (PixelPos.x + PixelPos.y + TemporalAASampleIndex) % 2;
#else
return (uint)(fmod(PixelPos.x + PixelPos.y + TemporalAASampleIndex, 2)) != 0;
#endif
}
同时为了借助TAA在时间域上的积累,弥补半分辨率的损失,UE在这个函数中还引入了TAASampleIndex
变量,确保前后两帧之间的棋盘格返回值正好是互逆的。
那么UE是如何使用这个棋盘纹理去影响UberPass中正常计算的直接光和间接光的呢?很简单,就在解码GBuffer数据的时候。具体来说会依据当前像素是否是次表面材质来开启如下分支逻辑,所做处理就是依据棋盘状态bChecker
,判断是否将基础色(BaseColor)或高光强度(Specluar)等核心参数设置为0
,从而控制最终写入到colorAttachement上的像素颜色值是否包含有Diffuse或Specular分量的数据。
FGBufferData DecodeGBufferData(..., bool bChecker)
{
...
if (UseSubsurfaceProfile(GBuffer.ShadingModelID)) //对次表面材质来说,会进入此分支
{ //bChecker:对应棋盘状纹理 -> 这里需要开启或屏蔽皮肤像素对应的 BaseCol,SpecCol等原始数据
AdjustBaseColorAndSpecularColorForSubsurfaceProfileLighting(GBuffer.BaseColor, GBuffer.SpecularColor, GBuffer.Specular, bChecker);
}
...
}
(二)SubsurfaceProfileBXDF 基本流程
这一块专职于皮肤表面的高光处理,和前面长篇大论的次表面剖面没关系,而且对于非真实质感的皮肤渲染帮助也不大(Kena的皮肤并非完全的真实质感,有点类似迪士尼卡通风格),但是作为构成UE SSSS 材质完整渲染流程的一个重要组成部分,还是得简要介绍一下的。
UE的皮肤渲染采用双镜叶高光(Dual Lobe Specular),它是由两个独立的高光镜叶组成,各自使用不同的粗糙度,二者的线性和形成最终结果。从真实肤质渲染效果来看,这种组合方式会为皮肤提供非常出色的亚像素微频效果,程序出更加自然的面貌。
UE的默认混合公式是:Lobe_0 * 0.85 + Lobe_1 * 0.15
,但是在工程上UE会将用户的预设值存入查找表中,在运行时实时采样获取:
void GetProfileDualSpecular(FGBufferData GBuffer, out float AverageToRoughness0, out float AverageToRoughness1, out float LobeMix)
{
// 0..255, which SubSurface profile to pick
uint SubsurfaceProfileInt = ExtractSubsurfaceProfileInt(GBuffer); //角色索引
float4 Data = ActualSSProfilesTexture.Load(int3(SSSS_DUAL_SPECULAR_OFFSET, SubsurfaceProfileInt, 0));
AverageToRoughness0 = Data.x * SSSS_MAX_DUAL_SPECULAR_ROUGHNESS;
AverageToRoughness1 = Data.y * SSSS_MAX_DUAL_SPECULAR_ROUGHNESS;
LobeMix = Data.z;
}
上述方法用于采样获取当前像素对应角色的专属皮肤高光参数,如前文所述,一共有3个参数:针对Lobe_0
的粗糙度系数,针对Lobe_1
的粗糙度系数,以及一个用于混合双镜叶的LobeMix
系数。
接下来是高光部分的漫反射Dissue,使用迪士尼Burley在2012年提出的“基于物理渲染”一文中的经典方案:
对此公式此处不再赘述,有兴趣的同学可以翻看迪士尼BRDF相关文章。
高光项由于是双镜叶的关系,要稍微复杂些:
float3 DualSpecularGGX(float AverageRoughness, float Lobe0Roughness, float Lobe1Roughness, float LobeMix, float3 SpecularColor, BxDFContext Context, float NoL, FAreaLight AreaLight)
{
float AverageAlpha2 = Pow4(AverageRoughness);
float Lobe0Alpha2 = Pow4(Lobe0Roughness);
float Lobe1Alpha2 = Pow4(Lobe1Roughness);
float Lobe0Energy = EnergyNormalization(Lobe0Alpha2, Context.VoH, AreaLight);
float Lobe1Energy = EnergyNormalization(Lobe1Alpha2, Context.VoH, AreaLight);
// Generalized microfacet specular
float D = lerp(D_GGX(Lobe0Alpha2, Context.NoH) * Lobe0Energy, D_GGX(Lobe1Alpha2, Context.NoH) * Lobe1Energy, LobeMix);
float Vis = Vis_SmithJointApprox(AverageAlpha2, Context.NoV, NoL); // Average visibility well approximates using two separate ones (one per lobe).
float3 F = F_Schlick(SpecularColor, Context.VoH);
return (D * Vis) * F;
}
还是基于 Cook-Torrance 模型的高光计算,有微表面法线分布D
,几何项G
(这里是Visibility),以及菲尼尔项F相乘构成。由于涉及双高光,最开始计算了不同Lobe的alpha^2
值和归一化能量系数。UE4接下来使用了两次由 Trowbridge-Reitz 提出的各向同性GGX方法:
并通过插值(Lerp)来获得最终的D,注意这里直接使用了从查找表里采样到的 LobeMix
作为比例系数。
几何项Vis使用了对经典的 Joint-Smith项(联合史密斯)的一种近似高效方案:
最后的菲尼尔项没什么特别,使用了那个要对 VdotH
返回值执行5次方的Schlick-Fresnel方案,此处也不赘述了。
UE将上述3项合成并返回,一次性完成2组Lobe高光的计算。
5.3 卷积核的计算和使用
(一)LUT
我们最好先梳理下贯穿了UE4次表面材质渲染始末的这张LUT图,或者叫SSProfilesTexture(次表面剖面纹理)。简单来说,查找表的每一行代表了一种独特的次表面材质,而行中的每一个元素(逐列)存放了不同的预设值或预计算参数,我通过解析源码的方式还原了表中各列的含义,具体参考下图:
上文在介绍双镜叶高光计算时其实已经使用到了它,当时我们通过在这张LUT图X
轴坐标偏移为5
的地方进行采样编码数据Data
,从Data.xy
中获得需要的两层粗糙度,又从Data.z
中获得混合系数。
回到我们现在关心的次表面卷积模糊处理,UE采样的是上图中存放在Kernel_0,Kernel_1或Kernel_2为起始地址的一连串预计算卷积结果。具体而言,3个Kernel偏移对应了3种不同的SSSS质量:
#if SUBSURFACE_QUALITY == SUBSURFACE_QUALITY_LOW // <- Kena 使用此设置
#define SSSS_N_KERNELWEIGHTCOUNT SSSS_KERNEL2_SIZE //6
#define SSSS_N_KERNELWEIGHTOFFSET SSSS_KERNEL2_OFFSET //28
#elif SUBSURFACE_QUALITY == SUBSURFACE_QUALITY_MEDIUM
#define SSSS_N_KERNELWEIGHTCOUNT SSSS_KERNEL1_SIZE //9
#define SSSS_N_KERNELWEIGHTOFFSET SSSS_KERNEL1_OFFSET //19
#else // SUBSURFACE_QUALITY == SUBSURFACE_QUALITY_HIGH
#define SSSS_N_KERNELWEIGHTCOUNT SSSS_KERNEL0_SIZE //13
#define SSSS_N_KERNELWEIGHTOFFSET SSSS_KERNEL0_OFFSET //6
#endif
而Kernel内存放的数据就Rd
(扩散反射函数)的近似计算结果,上文说过了,该函数是基于6
个高斯函数的加权和,入参r的单位是mm
,代表入射点到出射点之间的直线距离。我们来看看UE4是怎么计算它的:
// helper function for ComputeMirroredSSSKernel
// r is in mm
inline FVector SeparableSSS_Profile(float r, FLinearColor FalloffColor)
{
/**
* We used the red channel of the original skin profile defined in
* [d'Eon07] for all three channels. We noticed it can be used for green
* and blue channels (scaled using the falloff parameter) without
* introducing noticeable differences and allowing for total control over
* the profile. For example, it allows to create blue SSS gradients, which
* could be useful in case of rendering blue creatures.
*/
// first parameter is variance in mm^2
return // 0.233f * SeparableSSS_Gaussian(0.0064f, r, FalloffColor) + /* We consider this one to be directly bounced light, accounted by the strength parameter (see @STRENGTH) */
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);
}
除了第一项被排除以外,其他一模一样,权重系数和红色通道的对应方差在数值上一点不差。至于为何去除第一项,UE的说法是需要将其定义为直接反射光的范畴里,但其实是为了给UE自己附加的各种调控系数腾挪空间。
高斯核心(Kernel)具体装填过程如下:
...
// 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;
kernel[i].A = Range * sign * FMath::Abs(FMath::Pow(o, Exponent)) / FMath::Pow(Range, Exponent);
}
// Calculate the weights:
for (int32 i = 0; i < nTotalSamples; i++)
{
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;
FVector t = area * SeparableSSS_Profile(kernel[i].A, FalloffColor);
kernel[i].R = t.X;
kernel[i].G = t.Y;
kernel[i].B = t.Z;
}
...
简单说分两步计算:
依据已有参数计算出步进,得到高斯函数的入参变量
r
,r
对应了散射过程中入射点到出射点之间的直线距离,单位是mm
-> 对应 Kernel.a使用上一步计算出的距离
r
和一个用来控制RGB通道散射强度的 FalloffColor(红光最远,所以红色分量占优),预计算不同距离条件下的光强分布
-> 对应 Kernel.rgb
(二)卷积核采样
SSSSBlurPS是包含了卷积采样主逻辑的函数,2个卷积Pass都调用到了它,但是在不同Pass执行期间,函数入参中的 dir
会有差异,具体而言第一次dir
被设置为 float2(1, 0)
,第二次则是 float2(0, 1)
,代表着分别沿着纹理空间中的u
轴朝向和v
轴朝向做高斯模糊,既所谓的分离的1D
卷积。
函数的核心逻辑有兴趣的同学可以参考如下代码:
// @param dir Direction of the blur: First pass: float2(1.0, 0.0), Second pass: float2(0.0, 1.0)
float4 SSSSBlurPS(float2 BufferUV, float2 dir)
{
...
// 获取艺术家通过贴图传递来的次表面强度值:0..1
float SSSStrength = GetSubsurfaceStrength(BufferUV);
// finalStep用于确定如何步进,以便采样四周辐照度
// 步进反比与“像素深度”,正比于“次表面强度”,而SubsurfaceParams.x存放的是全局缩放系数
float2 finalStep = SubsurfaceParams.x / PixelDepth * dir * SSSStrength;
FGBufferData GBufferData = GetGBufferData(BufferUV);
// 当前像素对应的散射剖面索引:0..255,举例,Kena的皮肤和飞鸟的皮肤分属不同的散射剖面
uint SubsurfaceProfileInt = ExtractSubsurfaceProfileInt(GBufferData);
...
// 对当前卷积窗口的中心点采样,使用第一个Kernel来初始化如下变量
// InvDiv 用于累积误差,确保卷积结果能量守恒,因为在一些情况下卷积窗口可能超出了材质范围
colorInvDiv += GetSubsurfaceProfileKernel(SSSS_N_KERNELWEIGHTOFFSET, SubsurfaceProfileInt).rgb;
// Accum 用于累积散射能量,并最终被InvDiv修正
colorAccum = colorM.rgb * GetSubsurfaceProfileKernel(SSSS_N_KERNELWEIGHTOFFSET, SubsurfaceProfileInt).rgb;
float3 BoundaryColorBleed = GetSubsurfaceProfileBoundaryColorBleed(GBufferData); //处理边界点时所用的替代颜色
//卷积主循环
for (int i = 1; i < SSSS_N_KERNELWEIGHTCOUNT; i++) //COUNT == 6 -> 共需要步进5次,采样对应的Kernel
{
// Kernel.a = 0..SUBSURFACE_KERNEL_SIZE (radius) -> 对应距离r
// Kernel.rgb 对应各颜色通道在给定距离r时的散射比率
half4 Kernel = GetSubsurfaceProfileKernel(SSSS_N_KERNELWEIGHTOFFSET + i, SubsurfaceProfileInt);
float4 LocalAccum = 0;
float2 UVOffset = Kernel.a * finalStep; //这是UV偏移的绝对值
//由于是对称的,一个Kernel需要连续采样正负偏移下的坐标点
for (int Side = -1; Side <= 1; Side += 2)
{
float2 LocalUV = BufferUV + UVOffset * Side;
float4 color = SSSSSampleSceneColor(LocalUV); //采样获得附近点的辐照度(注意a通道存放了像素深度)
// 需要排除的情况(一):附近获取的采样点与当前点不属于同一个散射剖面(或者说不属于同一个角色)
uint LocalSubsurfaceProfileInt = SSSSSampleProfileId(LocalUV);
float3 ColorTint = LocalSubsurfaceProfileInt == SubsurfaceProfileInt ? 1.0f : BoundaryColorBleed;
float LocalDepth = color.a;
color.a = GetMaskFromDepthInAlpha(color.a);
// 需要排除的情况(二):附近获取的采样点深度与当前点深度差距过大(几何差距过大,默认也不属于同一个角色)
float s = saturate(12000.0f / 400000 * SubsurfaceParams.y * abs(PixelDepth - LocalDepth));
color.a *= 1 - s;
color.rgb *= color.a * ColorTint; //这里的ColorTin用于处理边界颜色,非边界状态时恒为1
LocalAccum += color;
}
// 卷积的过程被近似为连续求和的过程
colorAccum += Kernel.rgb * LocalAccum.rgb;
colorInvDiv += Kernel.rgb * LocalAccum.a;
}
// 归一化,返回
float3 OutColor = colorAccum / colorInvDiv;
return float4(OutColor, PixelDepth);
}
代码虽长,但是理解起来并不麻烦,个人认为主要看点是这几个:
- 采样步进 finalStep:该值定义了在
uv
空间内一次采样偏移的单位长度。步进越大,该点的模糊效果(SSS效果)可能越明显。- 它正比于全局缩放因子 SSSScale
- 它正比于由艺术家提供的 SSSStrength
- 它反比于像素的深度
- 它受到卷积朝向的控制,只在
u
或v
轴上展开
- 卷积主循环 :由内外两层循环嵌套而成,形成卷积窗口
- 外层负责采样Kernel(卷积核)
- 内层依据Kernel中的预计算偏移量负责采样正反两个方向的周边区域辐照度
- 累加辐照度与对应次表面散射强度的乘积,获得周边区域对当前点的散射贡献总和
- 受不同质量设置的控制,总循环次数可以在
12
次,18
次到26
次之前切换
5.4 合并 + 升采样
下面的代码用于从棋盘格中重建全分辨率光照,是经过精简的,只保留了采样双邻域的版本,在更高质量档是需要采样上下左右四邻域并加权混合的。从这个方法中我们能看到UE4使用了当前帧的棋盘格状态符 bChecker
来控制插值器在“当前采样值Quant0
”还是“双邻域采样加权值Quant1
”之间切换,从而填充Diffuse和Specular,升采样到全分辨率。
SDiffuseAndSpecular ReconstructLighting(float2 UVSceneColor)
{
bool bChecker = CheckerFromSceneColorUV(UVSceneColor);
float3 Quant0 = LookupSceneColor(UVSceneColor, int2(0, 0));
float3 Quant1 = 0.5f * (
LookupSceneColor(UVSceneColor, int2( 1, 0)) +
LookupSceneColor(UVSceneColor, int2(-1, 0))
);
SDiffuseAndSpecular Ret;
Ret.Diffuse = lerp(Quant1, Quant0, bChecker);
Ret.Specular = lerp(Quant0, Quant1, bChecker);
return Ret;
}
最后,说完升采样我们再来说说合并,UE的合并分为两步:
- 第一步是将卷积后和散射强度与原始Diffuse做合并,使用的方式是线性插值(Lerp),比例系数主要来自计算得出的LerpFactor,UE为了避免在较远距离时次表面材质因为模糊而看不清,通过修正插值系数LerpFactor来达到随着距离拉远对不同尺寸的次表面材质逐渐“去模糊化”的效果。ComputeFullResLerp方法内部使用到了“像素深度”,“全局调节系数”和“预计算的次表面弧度(radius)”等参数,具体实现方式此处不再深入。
- 第二步则是对最终输出颜色的合并,既
OutColor = Diffuse + Specluar
其中Diffuse对应了我们的散射强度,Specular则带有两层镜叶高光。
void SubsurfaceRecombinePS(...)
{
...
float4 SSSColor = Texture2DSample(SubsurfaceInput1_Texture, SharedSubsurfaceSampler1, BufferUV); //符合次表面散射剖面的SSS强度值
// 避免在远距离时次表面材质因为模糊而看不清,这里修正了权重因子,方法内部使用到了“像素深度”,“全局调节系数”和“预计算的次表面弧度(radius)”等参数
LerpFactor = ComputeFullResLerp(ScreenSpaceData, BufferUV, SubsurfaceInput1_ExtentInverse);
...
SDiffuseAndSpecular DiffuseAndSpecular = ReconstructLighting(BufferUV, ReconstructMethod); //重建全分辨率 Diffuse + Specular
float3 ExtractedNonSubsurface = DiffuseAndSpecular.Specular;
float3 SubsurfaceColor = GetSubsurfaceProfileColor(ScreenSpaceData.GBuffer); //查找LUT图,获取 SUBSURFACE_COLOR 对应颜色
float3 FadedSubsurfaceColor = SubsurfaceColor * LerpFactor;
// combine potentially half res with full res
float3 SubsurfaceLighting = lerp(DiffuseAndSpecular.Diffuse, SSSColor, FadedSubsurfaceColor); //注意只涉及 Diffuse 部分
OutColor = float4(SubsurfaceLighting * StoredBaseColor + ExtractedNonSubsurface, 0); //合成 Diffuse 和 Specular
}
5.5 MRT部分
MRT材质有艺术家和TA自定义的成分较大,但是为了正确通过SSSS渲染皮肤,UE会要求如下输入纹理:
下图是一些涉及SSSS的参数:
Demo
Ref
1:docs.unrealengine
2:剖析UE超真实皮肤渲染技术
3:Separable Subsurface Scattering
4:虚幻引擎中的皮肤渲染方案
5:GPU Gems 3