皮肤渲染的方法很多,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.ushSeparableSSS.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); }