前言
前文提到了如何实现适合于移动端的预积分次表面散射效果,但这种方案存在一定缺陷 ,对于PC端还有其他效果更好的实时渲染方案,且两大的引擎Unity、UE都用到了此方案——即Screen Space Subsurface Scattering(SSSSS, 5S)。但光是5S需要消耗不少性能,基于此基础上,两大引擎又做了优化,使用高斯混合模型,以及高斯核可分离性,让 SSS 可以以比较低的代价实时的运行起来——即Separable Subsurface Scattering)(4S)
UE的方案
UE对皮肤、玉石等具有次表面散射效应的物体提供了三种渲染方案:
- Subsurface(寒霜引擎分享的快速次表面散射)
- Preintegrated Skin(预积分次表面散射)
- Subsurface Profile(SSSS)
三者性能消耗逐渐递增。其中,Preintegrated Skin在前文已经介绍过。而4s相对传统3s主要区别在于:
- 适用于延迟渲染,否则许多参数获取十分麻烦
- 基于屏幕空间
- 要求输入分离的Diffuse和Specular
- 算法核心的卷积(滤波)运算,工作在后处理之后,tonemapping之前
次表面散射算法
关于次表面散射的成因及皮肤组织建模在上篇已经提过,这里不再提及
高光
高光部分依旧采用Cook-Torrance的BRDF,对应公式如下:


漫反射
BSSRDF
漫反射部分使用到的是BSSRDF,相较于BRDF,BSSRDF每一次反射在物体表面上每一个位置都要做一次半球面积分,是一个嵌套积分:

它的含义是:当一束光以任意角度入射到某个确定的微表面p上时,有多少Radiance能够从任意一个给定的微表面q上以某个给定的出射角度反射出去
对于BSSRDF渲染方程的S函数部分可以笼统的归纳为以下形式:

表示以ω_i角度向x_i位置入射的radiance,在x_o处以ω_o角度出射的irradiance为多少。这部分看似比较简单,但是它是次表面散射中最为重要的一部分,也是最复杂的一部分
对于S函数,有如下经验公式:

其中:
- F_t:Fresnel效应,用于模拟入射和出射过程的消耗,一般只和材质属性和角度有关
- R_d(|p_i - p_o|):散射函数,主要与入射点和出射点的距离有关。它的真身如下:
- D:漫反射常量

- D:漫反射常量
可以看到BSSRDF渲染方程是十分复杂的,想用此做到实时是不可能的。虽然完全物理的做不到,但可以用近似法来逼近以降低消耗

其中:
- 1 - F_r(cos_{θ_o}):描述从正面射入物体的光线,有多少在物体内部不断反射后,又从物体正面射出
-
S_p(p_o, p_i):描述了光线在物体内部,不同传播距离下的能量衰减
-
S_ω(ω_i):描述有多少光线透射进物体内部。公式:

其中c是一个嵌套的半球面积分,代表平均透射率,η代表材质折射率,折射率越小,透射越强:

SeparableSSS
Diffusion Profile
-
什么是Diffusion Profile
- Diffusion Profile即上面的 S_p(p_o, p_i)
-
是一个数学函数,描述了光线在物体内部的能量衰减
-
一个以距离r为横坐标,光照强度为纵坐标的衰减曲线,因为RGB三种颜色的光跑的距离不同,所以一条完整的 Diffusion Profile 实际上是由红、绿、蓝三条不同的衰减曲线组成
-
想象一下,在一个黑暗的房间中,把强光手电筒紧紧贴在手掌心。手电筒的发光口很小,但整只手掌都亮了起来,并且光源周围有一圈明显的红晕。这里描述了两个物理现象
-
光在扩散
- 颜色在分离:扩散出来的光晕不是白色的,而是边缘偏红
Diffusion Profile将上述过程数据化,得到了三个颜色的曲线
-
有什么用
- 决定材质看起来像什么
-
皮肤:设置“红光传得远,绿蓝光传得近”的曲线
- 牛奶:设置“蓝光传得远,红绿光传得近”的曲线
-
玉:RGB 三通道衰减距离差不多,但整体散射半径很大的曲线
-
将3D弹射优化为2D卷积
-
不计算光线如何在内部弹射;而是计算当前pixel和周围pixel的距离r,根据Diffusion Profile曲线,计算周围pixel对当前pixel的贡献
因此,后续不需要计算光线如何弹射,每个pixel执行卷积即可
Separable Convolution
虽然Diffusion Profile将3维计算降维打击到了2d卷积,但由于次表面散射需要大范围的卷积核,对屏幕空间来说2d卷积消耗依然很大,有没有什么办法对2D卷积进行优化呢?还真有,也就是本文着重介绍的SeparableSSS
SeparableSSS是Jimenez和Gutierrez在2015年的论文中提出的,主要贡献是将2D卷积拆分成2个相关的1D卷积,该方法称为卷积分离(Separable Convolution)
$R(r)$的近似函数
R(r)的近似函数数学上定义的衰减函数是十分复杂的,它是由Jensen在2001年提出的散射模型,这个模型假设物体表面平坦且具有各向同性,两点位置关系(S_p(p_o, p_i))退化成了距离d,通常简写为R(r),公式如下:

对于牛奶、大理石这类材质,一个R_d足够了,消耗貌似不大。但对于皮肤这样多层结构的材质,需要使用3个R_d才能达到理想效果,消耗是无法接受的,综合来说不适合用于实时
优化
Diffusion Profile(R(r))的分布和高斯函数类似,如下图所示:

这意味着,可以通过结合不同参数的高斯函数来近似Diffusion Profile。高斯函数还有个优点:可分离性和径向对称性,这使得高斯函数满足卷积分离的需求
使用6个高斯函数即可拟合皮肤等带有3层Dipole的Diffusion Profile


其中:
- w_i:控制参与求和的高斯函数形状
- v_i:方差
- r:散射距离
在论文中对皮肤的拟合结果最终给出如下的参数,可见R、G、B通道拟合出的曲线有所不同,而R通道曲线的扩散范围最远,这也是皮肤显示出红色的原因:


卷积核大小
在实现Blur效果时,所用到的卷积核是固定大小的。但在实现SSSS时,情况不同了,因为这里卷积核代表基于物理的Diffusion Profile,若是固定的,得到的模糊效果整体都是一样的,且因为透视投影的原因,不可能每个pixel所占面积都是相同的,所以需要根据不同的区域属性来调整卷积核大小
决定卷积核大小的因素如下:
- 缩放系数

物理意义是卷积核大小应随pixel深度的增加而减小,且随视场角的变小而变大- f_y:相机的视场角
- p_d:当前pixel的深度(非[0,1])
- SSS宽度:限定在什么样的尺度上能够看到次表面效果
- SSS强度:通常以贴图的形式存在,用以表示不同区域的次表面强度
几何过滤
在屏幕空间几个相邻的点,次表面散射材质是连续的;但在世界空间上不一定连续。为了避免混淆几何上迥异的2块次表面材质,造成过渡不自然。解决方案是比较当前采样点和周围采样点的像素颜色和深度值

其中:
- i_c:当前pixel的颜色
- o_c:周边pixel的颜色
- i_d:当前pixel的深度
- o_d:周边pixel的深度
工业界的流程
大名鼎鼎的UE使用到的流程如下:

大致含义是先对高光和漫反射光分开计算,对漫反射光再次分离,分成水平竖直两次计算,最后叠加
分开是为了防止高频信号的高光被模糊,而漫反射本来就是低频信号,配上卷积效果更好
实现
可能你会疑惑目前只得到了衰减函数,而$1 - F_r(cos_{θo})、Sω(ω_i)$并没有提到,其实这两部分就藏在BRDF中。其中:
- $S_ω(ωi):由于Sω(ω_i)$非常复杂,因此需要对其优化
- $1−Fr(cosθi) $:用NoL来代替,因为随角度变化的细微透射率效果不会特别显著
-
c积分嵌套:观察下图不难发现,唯一变量只有η(材质折射率 IOR), 且这个积分代表平均透射率

因此可以将这部分作为一个可控参数Tint开放给美术,让美术调,美术绝对透光度不高,Tint调高即可- 分母Π:直接优化掉,因为入射项和出射项都有分母Π、Diffuse Lambert也会除以Π,会导致整体颜色变暗
-
优化后的公式:S_ω(ω_i) = Diffuse_Lambert(DiffuseColor)
tint融入衰减函数(在c++端计算),因为在GPU端适配不同漫反射表现,需要写if分支,会降低active thread
-
$1 - F_r(cos_{θ_o})$
- 按照正常的物理现象,当光线射出皮肤时,如果L与N的夹角接近90度,光线会发生全反射——被重新弹回皮肤,导致全黑
对于渲染来说,但现实中当逆光或者侧光看人的脸颊边缘时,那里不仅不死黑,反而亮得刺眼
这是因为皮肤有一层油脂层。在掠射角下,菲涅尔高光(Specular)会呈现爆炸式的指数增长,从而导致极其耀眼的高光
因此完全可以舍弃这个公式,在高光项下功夫——双镜叶高光
高斯函数

/// <summary>
/// 高斯函数. 增加了FalloffColor颜色,对应不同颜色通道的值
/// </summary>
/// <param name="variance"> 方差 </param>
/// <param name="radius"> 次表面散射的最大影响距离。单位mm </param>
/// <param name="falloffColor"> 光线随距离增加而衰减的程度, 数值越小表示对应方向上光线衰减得越快,数值越大表示衰减得越慢 </param>
/// <returns></returns>
static Vector3 SeparableSSS_Gaussian(float variance, float radius, Vector3 falloffColor)
{
Vector3 result = Vector3.zero;
for (int i = 0; i < 3; ++i)
{
float rr = radius / (0.001f + falloffColor[i]);
result[i] = Mathf.Exp(-(rr * rr) / (2f * variance)) / (2f * Mathf.PI * variance);
}
return result;
}
这里新添了一个falloffColor(tint),因为不同颜色的光RGB三个分量的曲线是不一样的,如果不加falloffColor,三个分量的距离都是相同的,总的来说它控制光的衰减强度
近似Diffusion Profile

/// <summary>
/// 6个高斯函数拟合3个dipole曲线
/// </summary>
/// <param name="radius"> 次表面散射的最大影响距离。单位mm </param>
/// <param name="falloffColor"> 光线随距离增加而衰减的程度, 数值越小表示对应方向上光线衰减得越快,数值越大表示衰减得越慢 </param>
/// <returns></returns>
static Vector3 SeparableSSS_Profile(float radius, Vector3 falloffColor)
{
// 去掉0.233f * SeparableSSS_Gaussian(0.0064f, radius, falloffColor)。理由是这个高斯曲线极其尖锐,影响范围小
return 0.1f * SeparableSSS_Gaussian(0.0484f, radius, falloffColor) +
0.118f * SeparableSSS_Gaussian(0.187f, radius, falloffColor) +
0.113f * SeparableSSS_Gaussian(0.567f, radius, falloffColor) +
0.358f * SeparableSSS_Gaussian(1.99f, radius, falloffColor) +
0.078f * SeparableSSS_Gaussian(7.41f, radius, falloffColor);
}
计算卷积核数据
- 确定卷积范围:kernelList[i].w
// 卷积核先给定一个默认的半径范围,不能太大也不能太小,根据nTotalSamples调整Range(单位是毫米mm) // 得到一个前半为负,后半为正的步长数组,并通过一个步长函数来控制步长值 float range = nTotalSamples > 20.0f ? 3.0f : 2.0f; const float exponent = 2.0f; // 指定步长函数的形状.高斯分布的简化版,距离原点越远的样本步长越大 float step = 2.0f * range / (nTotalSamples - 1); // 每次卷积的偏移值 for (int i = 0; i < nTotalSamples; ++i) { float o = -range + (float)i * step; // 第i步卷积的总偏移值 float sign = o < 0.0f ? -1.0f : 1.0f; // 将当前的range和最大的Range的比值存入alpha通道 kernelList.Add(new Vector4(0.0f, 0.0f, 0.0f, range * sign * Mathf.Abs(Mathf.Pow(o, exponent)))); }采用Mathf.Pow(o, exponent),正是利用到高分分布的特性,中心周围点的贡献度最大,越往两边贡献度越低(类似于重要性采样)
-
计算权重:kernelList[i].xyz
计算每卷积范围后,需要知道卷积范围内,每个点的比重
// 计算Kernel权重 for (int i = 0; i < nTotalSamples; ++i) { float w0 = i > 0 ? Mathf.Abs(kernelList[i].w - kernelList[i - 1].w) : 0.0f; //左邻居的步长差 float w1 = i < nTotalSamples - 1 ? Mathf.Abs(kernelList[i].w - kernelList[i + 1].w) : 0.0f; //右邻居的步长差 float area = (w0 + w1) / 2.0f; // 该采样点的所占长度 Vector3 t = area * SeparableSSS_Profile(kernelList[i].w, falloffColor); // 用卷积步长计算得到颜色 kernelList[i] = new Vector4(t.x, t.y, t.z, kernelList[i].w); } - 将最中间的元素移到数组的第一位,在后续计算Blur时,优化性能
// 使用nSamples / 2表示中间的位置,再从中间元素的位置开始,往前遍历半个数组,将所有元素都向后移动一个位置 // 确保kernel数组的中间元素处于最容易访问的位置,后续对kernel数组的操作更高效 Vector4 centerKernel = kernelList[nTotalSamples / 2]; for (int i = nTotalSamples / 2; i > 0; --i) { kernelList[i] = kernelList[i - 1]; } kernelList[0] = centerKernel; - 将权重归一化,即所有点的权重和为1
// kernelList[i].xyz = subsurfaceColor * kernelList[i].xyz / ∑kernelList[i].xyz Vector4 sum = Vector4.zero; for (int i = 0; i < nTotalSamples; ++i) { sum.x += kernelList[i].x; sum.y += kernelList[i].y; sum.z += kernelList[i].z; } for (int i = 0; i < nTotalSamples; ++i) { kernelList[i] = new Vector4(kernelList[i].x / sum.x, kernelList[i].y / sum.y, kernelList[i].z / sum.z, kernelList[i].w); } - 将权重比和一个可开放且可修改的参数subsurfaceColor lerp,从而手动控制漫反射效果
var tempKernel = kernelList[0]; tempKernel.x = Mathf.Lerp(1.0f, kernelList[0].x, subsurfaceColor.x); tempKernel.y = Mathf.Lerp(1.0f, kernelList[0].y, subsurfaceColor.y); tempKernel.z = Mathf.Lerp(1.0f, kernelList[0].z, subsurfaceColor.z); kernelList[0] = tempKernel; for (int i = 1; i < nTotalSamples; ++i) { tempKernel = kernelList[i]; tempKernel.x *= subsurfaceColor.x; tempKernel.y *= subsurfaceColor.y; tempKernel.z *= subsurfaceColor.z; kernelList[i] = tempKernel; }subsurfaceColor与falloffColor不同,它表示散射颜色
分离的高斯模糊
分为水平和竖直的模糊
缩放系数:

_SSSSMaterial.SetFloat("_DistanceToProjectionWindow", 1.0f / (0.5f * Mathf.Tan(math.radians(renderingData.cameraData.camera.fieldOfView))));
float SSSSIntensity = _SSSSScale * _CameraDepthTexture_TexelSize.x; // 水平方向为_CameraDepthTexture_TexelSize.x,竖直方向为_CameraDepthTexture_TexelSize.y
float4 sceneColor = _MainTex.SampleLevel(Smp_ClampU_ClampV_Linear, uv, 0);
float3 SSSSBlur(float3 sceneColor, float2 uv, float2 SSSSIntensity)
{
float eyeDepth = GetLinearEyeDepth(GetDeviceDepth(uv));
float blurLength = _DistanceToProjectionWindow / eyeDepth; // 缩放系数
float2 uvOffset = blurLength * SSSSIntensity;
float3 blurColor = sceneColor * _KernelArray[0].rgb;
[unroll(64)]
for(int i = 1; i < _Sample_Nums; ++i)
{
float2 SSSSUV = uv + uvOffset * _KernelArray[i].a;
float3 SSSSSceneColor = _MainTex.SampleLevel(Smp_ClampU_ClampV_Linear, SSSSUV, 0).rgb;
float SSSSEyeDepth = _CameraDepthTexture.SampleLevel(Smp_ClampU_ClampV_Linear, SSSSUV, 0);
float DPTimes300 = _DistanceToProjectionWindow * 300; // 经验参数
float SSSSSCale = saturate(DPTimes300 * SSSSIntensity * abs(eyeDepth - SSSSEyeDepth));
SSSSSceneColor = lerp(SSSSSceneColor, sceneColor, SSSSSCale);
blurColor += _KernelArray[i].rgb * SSSSSceneColor;
}
return blurColor;
}
Diffuse光照选择
UE选择的是Disney提出的Burley模型
// [Burley 2012, "Physically-Based Shading at Disney"]
// Lambert漫反射模型在边缘上通常太暗,而通过尝试添加菲涅尔因子以使其在物理上更合理,但会导致其更暗
// 根据对Merl 100材质库的观察,Disney开发了一种用于漫反射的新的经验模型,以在光滑表面的漫反射菲涅尔阴影和粗糙表面之间进行平滑过渡
// Disney使用了Schlick Fresnel近似,并修改掠射逆反射(grazing retroreflection response)以达到其特定值由粗糙度值确定,而不是简单为0
float3 Diffuse_Burley( float3 DiffuseColor, float Roughness, float NoV, float NoL, float VoH )
{
float FD90 = 0.5 + 2 * VoH * VoH * Roughness;
float FdV = 1 + (FD90 - 1) * pow5( 1 - NoV );
float FdL = 1 + (FD90 - 1) * pow5( 1 - NoL );
return DiffuseColor * ( (1 / PI) * FdV * FdL );
}
效果
仅漫反射无高光,再SSSS效果如下:

高光
高光部分有些繁琐,不能走正常的延迟渲染流程,需要修改延迟光照算法,先计算diffuse,然后SSSS,最后再进行延迟光照来计算specular。重点在于specular算法如何抉择
NDF
UE的皮肤渲染采用双镜叶高光(Dual Lobe Specular),由两个独立的高光镜叶组成,各自使用不同的roughness——即使用两个独立的roughness,计算两个相同的NDF,再对两个roughness混合,UE的混合比例是固定的:主roughness为0.85,次roughness为0.15
half _LodeA;
half _LodeB;
// GGX / Trowbridge-Reitz
// [Walter et al. 2007, "Microfacet models for refraction through rough surfaces"]
// 在流行的模型中,GGX拥有最长的尾部。而GGX其实与Blinn (1977)推崇的Trowbridge-Reitz(TR)(1975)分布等同。然而,对于许多材质而言,即便是GGX分布,仍然没有足够长的尾部
float NDF_GGX( float roughness2, float NoH )
{
const float a2 = pow2(roughness2);
const float NoH2 = pow2(NoH);
const float d = PI * pow2(NoH2 * (a2 - 1.f) + 1.f);
if(d < FLT_EPS) return 1.f;
return a2 / d;
}
float lobeARoughness = brdfData.roughness * brdfData.LobeA;
lobeARoughness = lerp(1.f, lobeARoughness, saturate(brdfData.opacity * 10.0f));
float lobeBRoughness = brdfData.roughness * brdfData.LobeB;
lobeBRoughness = lerp(1.f, lobeBRoughness, saturate(brdfData.opacity * 10.0f));
float lobeMix = 0.15f;
// AverageRoughness: roughness
float AverageAlpha2 = Pow4(AverageRoughness);
float Lobe0Alpha2 = Pow4(Lobe0Roughness);
float Lobe1Alpha2 = Pow4(Lobe1Roughness);
float D = lerp(NDF_GGX(Lobe0Alpha2, brdfData.NoH), NDF_GGX(Lobe1Alpha2, brdfData.NoH), LobeMix);

几何项
使用 Joint-Smith项(联合史密斯)的一种近似高效方案:

float Vis_SmithJointApprox( float a2, float NoV, float NoL )
{
float a = sqrt(a2);
float Vis_SmithV = NoL * ( NoV * ( 1 - a ) + a );
float Vis_SmithL = NoV * ( NoL * ( 1 - a ) + a );
return 0.5 * rcp( Vis_SmithV + Vis_SmithL );
}
float G = Vis_SmithJointApprox(AverageAlpha2, brdfData.NoV, brdfData.NoL);
Fresnel
用普通的Schlick-Fresnel方案即可:
float3 SchlickFresnel(float HdotV, float3 F0)
{
float m = clamp(1-HdotV, 0, 1);
float m2 = m * m;
float m5 = m2 * m2 * m; // pow(m,5)
return F0 + (1.0 - F0) * m5;
}
float3 F = SchlickFresnel(brdfData.HoV, brdfData.F0);
效果
得到的效果如下:

优化
SSSS并非是完美的,存在如下几点问题
- 遮挡断层
- 问题:假设角色的鼻子高高挺起,遮住后面的侧脸。如果直接在屏幕上做卷积,鼻尖上极其明亮的光照,会因为卷积贡献到本该处于阴影的侧脸
-
如何解决:深度感知的卷积
如果pixel间深度差很大,不卷积
- 新的的问题:边缘过渡十分生硬
-
如何解决:在采样卷积核时加入jitter,将生硬的边缘变成噪点,再通过TAA抹平噪点
float jitter = InterleavedGradientNoise(vpos.xy, FrameCount); float offset = kernelList[i].w + (jitter - 0.5f) * stepSize; float2 sampleUV = centerUV + direction * offset;
- 无法处理“薄壁透射”
- 问题:不管是SSS,还是SSSSS,积分模型都是假定物体是特别厚的,光线进入物体内,在物体内不断反射,最后又从正面输出,因此都没有透射效果
-
如何解决:厚度图配合快速次表面散射
厚度图有三种方案:
- 美术预烘焙
-
根据sample pos与shadow pos计算深度差,作为厚度
-
将“入射角、厚度、观察角”预积分到一张 2D 贴图里
因为皮肤这种材质不是各向同性的,需要相位函数计算光线向各个方向散射的概率分布,而相位函数ALU成本比较高,寒霜的方案是将其烘焙
- 透射公式:$LightColorP(V,L,g)exp(-kThickness)(-L·N)_{clamped}$
- 相位函数:\frac{1}{4Π} \frac{1 - g^2}{(1 + g^2 - 2g cosθ)^{\frac{3}{2}}}
- 存在的问题:不能动态修改g
- LOD
- 问题:因为散射半径是映射到屏幕像素的,当物体离屏幕近,模糊半径可能高达几十上百个像素,会导致很低的L2 命中率;当物体离屏幕远,在屏幕上只有 10 个像素,模糊核可能连 1 个像素都不到,导致采样权重剧烈抖动(画面闪烁)
-
如何解决
-
Down Sample + Depth-Aware Upsampling
上采样时,计算低分辨率像素与全分辨率像素的深度差,如果过大说明采样到了背景,则以全分辨率光照为准
滤波核半径也需根据渲染分辨率动态缩放
-
DeinterLeave
解决L2命中率的神器
-
TAA
当卷积次数有限时,可以通过jitter 采样核uv,配合TAA分摊到多帧
-
离屏幕远
- 取消SSSSS,使用default lit
- 降低滤波核半径
- 离屏幕近
- 如果计算出的屏幕空间半径 rpixel 小于一个阈值(如 0.5 像素),直接跳过SSSSS,输出default lit
- 与AO的冲突
- 问题:一般AO会在light pass与AO混合,SSSSS会模糊AO,导致高频的AO被模糊为低频的
- 如何解决:将取消SSSSS light pass与AO混合的操作,等SSSSS的卷积结束后再将AO与卷积混合
- 物体边缘的Halo
- 问题:若在物体边缘卷积,会采样到背景,直接舍弃会导致边缘生硬;不舍弃会出现halo
- 如何解决:根据depth diff,混合背景与漫反射
- 支持多材质
- 问题:通过为每个不同的次表面散射物体执行不同的衰减函数计算的脚本,效率是非常低的
- 如何解决:预生成LUT。将所有次表面散射物体的卷积核同一计算出来,存储在一张2d tex,u为卷积核 index offset,v为material id
总结
SSSS积分方程的S部分拆分为 $S_ω(ωi)、$S_p(p_o, p_i)、$1 - F_r(cos{θ_o}),优化掉了随角度变化的细微透射率效果不会特别显著的1−Fr(cosθi) 、分母Π,用参数代替c积分嵌套 ,S_p(p_o, p_i)$衰减函数用多跟高斯函数拟合,通过falloff color控制光线的衰减强度、subsurfacecolor控制光线散射强度,通过c++端计算滤波核,SSSS时通过可分离的卷积配合计算的滤波核,计算SSSS散射效果,并乘上base color,防止漫反射细节模糊;最后通过双镜叶高光弥补全反射的高光效果
Reference
剖析Unreal Engine超真实人类的渲染技术Part 1 - 概述和皮肤渲染







Comments | NOTHING