一、技术方案选型
在 PC 和 Console 平台上,基于屏幕空间模糊的散射方案因效果显著成为主流。其核心思想是开辟独立的 Post Pass,对屏幕空间 Scene Color 中标记为 Subsurface 的区域执行卷积模糊。这一方案的主要代价是多次全屏 Color Buffer 读取,对带宽和计算量(大散射半径时尤为明显)要求较高。
具体来说,屏幕空间方案(如 Separable SSS / Jorge Jimenez 2015)将散射 Kernel 拆分为水平和垂直两个 Pass,每个 Pass 采样 1125 个 Tap。在 1080p 分辨率下,每帧需要读取约 2 × 1920 × 1080 × 25 × 8Byte ≈ 830MB 的带宽(RGBA16F 格式)。PC 端显卡的带宽预算(300900 GB/s)可以轻松承受,但 Mobile GPU 的带宽(30~60 GB/s)直接被击穿。加上 Tile-Based Renderer 在全屏 Post Pass 时需要将 Tile Memory 内容回写主存再重新加载,实际代价远超理论计算值。
移动端在带宽和计算能力上存在明确约束,屏幕空间方案并不适用。UE5 已开放了屏幕空间次表面散射对 Mobile 的支持:如果目标设备性能足够强劲(如旗舰 SoC),可以直接尝试这一方案。但对于中低端机型覆盖的需求,仍需要更轻量的替代方案。通常有两种:
方案一:Pre-Integrated LUT
预先将散射结果烘焙到 LUT 中,运行时仅需一次纹理采样。代价是增加两张贴图的带宽开销,且由于预烘焙的特性,不同 SSS Color 需要对应不同的 LUT。目前多数 Mobile 甚至 Console 游戏采用此方案,如《教团 1886》和《对马岛之魂》。
Pre-Integrated LUT 的生成方式:将 Diffusion Profile(散射核函数)沿表面曲率和 NdotL 两个维度积分,结果存入一张 2D LUT。横轴为 NdotL(范围 [-1, 1]),纵轴为 1/radius(曲率的倒数)。运行时以当前像素的 NdotL 和曲率作为 UV 采样 LUT,得到散射后的 Diffuse 响应值。这张 LUT 的分辨率通常为 256×256 或 512×512,占用 256KB~1MB。
LUT 方案的局限:
- 一张 LUT 对应一组固定的散射参数(ScatterColor + ScatterWidth)。如果场景中有不同肤色/材质的角色,需要多张 LUT 或引入额外参数化
- 无法处理阴影边缘散射:阴影值不在 LUT 的参数空间中
- 曲率估算精度有限时(如 Screen Space Curvature),LUT 采样的质量直接受影响
方案二:Spherical Gaussian 解析近似
使用球面高斯函数对散射轮廓做解析拟合,省去 LUT 采样以节省带宽,且因为非预计算可以支持任意散射颜色。代价是占用额外的 ALU。目前业界对该方案在产品中的应用案例较少。
这里选择方案二来做实现和优化。
1.1 方案对比
| 维度 | Pre-Integrated LUT | Spherical Gaussian |
|---|---|---|
| 运行时计算 | 1次纹理采样 + 简单乘法 | 多次 exp() + 加法(纯 ALU) |
| 带宽开销 | +1~2 次纹理 Fetch(LUT + Curvature Map) | 无额外纹理访问 |
| 参数灵活度 | 固定(烘焙时确定) | 完全动态(运行时任意修改) |
| 多肤色支持 | 需多张 LUT 或参数化 LUT | 修改 ScatterColor 参数即可 |
| Shadow Scatter | 需额外机制 | 自然支持(Shadow 值代入 SG) |
| Bump Scatter | 需额外机制 | 自然支持(多法线代入 SG) |
| 视觉质量 | 受 LUT 分辨率和曲率精度限制 | 受 Lobe 数量和拟合精度限制 |
| ALU 消耗 | 低 | 中偏高(3~4 次 exp 指令) |
| Mobile GPU 适配 | 带宽敏感时不利 | ALU 充足时有利 |
现代 Mobile GPU(Adreno 6xx+、Mali G7x+)的 ALU 性能增长快于带宽增长。在 ALU 充足但带宽受限的架构上,SG 方案的"用 ALU 换带宽"策略越来越有利。
二、SSS 的三个视觉要素
对于皮肤散射效果,需要同时满足三个视觉条件才能产生可信的 Skin 质感:
- Bump Scatter — 表面凹凸细节在散射范围内被平均化
- Curvature Scatter — 表面曲率较大的区域(如鼻翼、耳轮廓)呈现更强的散射
- Shadow Scatter — 阴影边缘区域的散射透红
多数公开实现通常只覆盖其中一到两个要素。这次的目标是在移动端把三个效果都做完整。
三者的物理基础统一:光进入皮肤表面后在内部发生多次散射,最终从不同位置射出。散射距离取决于组织的吸收系数和散射系数。对于人类皮肤:
- 红色光散射距离最远(~3.67mm),因为血红蛋白对红光吸收最弱
- 绿色光中等(~1.21mm)
- 蓝色光最短(~0.30mm)
这个 per-channel 不同散射距离的特性直接决定了实现中需要对 RGB 三通道使用不同的散射参数。
三、工程实现
3.1 框架集成
由于追求对任意 SSS Color 的支持,放弃引擎内置的 LUT 版本,选择 Spherical Gaussian 方案。
在 Unreal Engine 中的集成采用非侵入式方案:Shader 层面继承原有的 Pre-Integrated Skin Shading Model 并修改其 HLSL 代码;上层参数通过 Plugin 提供的 Custom Output 节点传递 SG 散射所需的参数(如 Scatter Width、Scatter Color 等)。
具体的修改点集中在三处:MobileBasePass.usf 的 Skin ShadingModel 分支中把 LUT 采样替换为 SG 计算,ShadingModelsMobile.ush 中新增 SG SSS 的评估函数,Material Graph 通过 Custom Output 暴露 ScatterWidth、ScatterColor 参数。
3.2 Spherical Gaussian 拟合散射 Kernel 的数学推导
Spherical Gaussian(SG)函数的基本形式:
其中 是 Lobe 方向, 是 Sharpness(控制宽度), 是 Amplitude。
对于 SSS 场景,将问题简化为 1D:以 NdotL(或其替代量)作为输入变量,散射 Kernel 退化为 1D 的 Gaussian-like 函数。目标是用 SG 拟合 Diffusion Profile 积分后的 NdotL→Scatter 响应曲线。
Diffusion Profile 的经典形式(Christensen-Burley 2015):
其中 是散射距离, 是 mean free path 的缩放因子。将此 Profile 对半球面积分(考虑表面曲率),得到 NdotL→Scatter 的 1D 响应曲线。该曲线可以用若干 SG Lobe 的叠加来拟合:
其中 是变换后的 NdotL 值(考虑曲率偏移),、、 是第 个 Lobe 的权重、中心和宽度。
实际实现中使用单 Lobe 近似(N=1)以控制 ALU 开销:
// 单 Lobe SG 散射近似
// x: 输入值(NdotL 或其变换),scatterWidth: 散射宽度参数
half SGScatter(half x, half scatterWidth)
{
// scatterWidth 越大,exp 衰减越慢,散射范围越广
half sigma2 = scatterWidth * scatterWidth;
return exp(-x * x / (2.0 * sigma2));
}
单 Lobe 在散射宽度较小时(scatterWidth < 0.3)拟合精度足够。宽度更大时,单 Lobe 无法准确模拟 Diffusion Profile 的长尾特征,此时可以增加第二个 Lobe(宽且弱)模拟长距离散射:
half3 EvaluateSGSSS(half NdotL, half3 scatterColor, half scatterWidth)
{
// Lobe 1: 窄、强 — 模拟短距离散射
half scatter1 = SGScatter(NdotL, scatterWidth);
// Lobe 2: 宽、弱 — 模拟长距离散射(权重 0.3,宽度 ×3)
half scatter2 = SGScatter(NdotL, scatterWidth * 3.0) * 0.3;
half scatter = scatter1 + scatter2;
return scatterColor * scatter;
}
双 Lobe 的参数(权重比 1:0.3,宽度比 1:3)来自对 Christensen-Burley Diffusion Profile 的最小二乘拟合。在 Mobile 上,单 Lobe 版本的 ALU 增加约 2~3 条指令(一次 mul + 一次 exp + 一次 mad),双 Lobe 翻倍。考虑到 exp 指令在多数 Mobile GPU 上是 special function unit 处理(不占 ALU pipeline),实际开销有限。
3.3 Curvature Scatter
在 MobileBasePass.usf 中,通过自定义 Output 函数获取参数后代入 SG 算法。曲率计算不仅考虑表面几何曲率,还需根据 Light Direction 加权一个方向性曲率分量。这样可以使与光线方向夹角更小的区域散射更弱,符合物理预期。具体算法参考《对马岛之魂》的 SIGGRAPH 分享。
3.3.1 Curvature 计算:fwidth 方法
fwidth 方法利用屏幕空间导数近似局部曲率:
// 基础曲率:世界空间法线的散度近似
half curvature = length(fwidth(worldNormal)) * _CurvatureScale;
// 方向性曲率:在光照方向上加权
half NdotL = dot(worldNormal, lightDir);
half directionalCurvature = curvature * saturate(1.0 - NdotL);
fwidth(v) = abs(ddx(v)) + abs(ddy(v)),其物理含义是法线在屏幕空间相邻像素间的变化量。变化越大,表示几何曲率越高。
fwidth 方法的优缺点:
- 优点:零额外内存开销、不依赖预处理、适用于任意 Mesh
- 缺点:精度与分辨率耦合,分辨率降低时相邻像素覆盖更大的世界空间面积,曲率估计偏大;在 Triangle Edge 处可能产生不连续
3.3.2 Curvature 计算:Mesh 预计算方法
另一种方法是在离线阶段预计算 Mesh 的逐顶点曲率,存储在 Vertex Color 或额外的 UV Channel 中:
// 预计算方法:从顶点数据读取曲率
half curvature = input.vertexColor.r * _CurvatureScale;
half directionalCurvature = curvature * saturate(1.0 - NdotL);
预计算曲率可以使用 Principal Curvature(最大/最小曲率)或 Mean Curvature。计算方式是对 Mesh 的每个顶点,基于其 1-ring 邻域拟合二次曲面,提取曲率张量的特征值。工具链上可以在 Houdini、Maya 或自定义 Baker 中完成。
| 维度 | fwidth 方法 | Mesh 预计算 |
|---|---|---|
| 内存开销 | 0 | +1 Vertex Channel(~4Byte/vertex) |
| 精度 | 依赖分辨率 | 固定精度,与分辨率无关 |
| 动态 Mesh | 自动适应(骨骼动画、Morph) | 需要实时更新或预计算所有 Blend Shape |
| Edge 连续性 | 可能在三角形边界跳变 | 连续(顶点插值) |
| LOD 兼容性 | 自动适应不同 LOD | 每级 LOD 需独立预计算 |
| 适用场景 | 快速原型、动态物体多 | 产品级品质、静态角色 |
这里选择 fwidth 方法,理由是:(a) 目标平台为 Mobile,额外 Vertex 数据增加内存和带宽;(b) 角色有大量 Blend Shape 动画,预计算方案需要为每个 Shape 烘焙曲率,工程成本高。
方向性曲率的意义:与光线方向正对的区域(NdotL 高)散射范围应更小(光直接穿透的路径短),而侧面区域(NdotL 低)光路更长、散射更明显。fwidth 利用屏幕空间导数近似局部曲率,代价是依赖分辨率,低分辨率下曲率估计会偏大。
完成基于双曲率的散射后,面部会呈现曲率驱动的透红效果。但此时整体仍有明显的塑料感——这是因为缺少 Bump 层面的散射处理。
3.4 Bump Scatter
根据散射现象的物理机制:表面凹凸细节会在散射范围内被空间平均。对于 RGB 三通道具有不同散射宽度的皮肤(红色通道散射最远),R 通道对应的法线应更为平滑(模糊),而 G/B 通道的法线应保持较锐利的细节。
3.4.1 四张法线的完整 Interpolation 流程
实现方式:以 Detail Normal 和 Geometry Normal 作为两个极端,利用 Scatter Color 的三个通道作为 Alpha 插值权重,生成四张不同细节程度的法线(原始 + R/G/B 三通道对应)。
// 输入:两张极端法线
half3 geometryN = normalize(input.worldNormal); // 最平滑:仅几何法线
half3 detailN = BlendNormal(geometryN, normalMapSample); // 最锐利:叠加 Normal Map
// Scatter Color 归一化为插值系数
// scatterColor.r 最大(红光散射最远)→ normalR 最接近 geometryN
// scatterColor.b 最小(蓝光散射最短)→ normalB 最接近 detailN
half3 scatterColor = _ScatterColor.rgb;
// 按 RGB 通道的散射强度生成三张插值法线
half3 normalR = normalize(lerp(detailN, geometryN, scatterColor.r));
half3 normalG = normalize(lerp(detailN, geometryN, scatterColor.g));
half3 normalB = normalize(lerp(detailN, geometryN, scatterColor.b));
// 第四张:原始 detailN,用于非散射区域的 Specular 和常规 Diffuse
half3 normalFull = detailN;
插值逻辑的关键:scatterColor.r 通常最大(如 (0.8, 0.4, 0.2) 代表皮肤的红色散射偏强),所以 normalR 最接近 geometryN(最平滑),normalB 最接近 detailN(最锐利)。这精确地模拟了"红光在更大范围内被平均化,蓝光保持局部细节"的物理行为。
3.4.2 Per-Channel NdotL 计算与 SG 评估
使用三张法线分别计算 NdotL,代入 SG 得到三个通道的散射响应:
// Per-channel NdotL
half NdotL_R = dot(normalR, lightDir);
half NdotL_G = dot(normalG, lightDir);
half NdotL_B = dot(normalB, lightDir);
// Per-channel SG SSS 评估
// 每个通道使用自己的 NdotL 和对应的 scatterWidth
half sssR = SGScatter(NdotL_R - directionalCurvature, _ScatterWidth * scatterColor.r);
half sssG = SGScatter(NdotL_G - directionalCurvature, _ScatterWidth * scatterColor.g);
half sssB = SGScatter(NdotL_B - directionalCurvature, _ScatterWidth * scatterColor.b);
// 最终 Bump Scatter 贡献
half3 bumpSSS = half3(sssR, sssG, sssB) * scatterColor;
// 非散射区域使用原始法线的 Diffuse
half NdotL_Full = saturate(dot(normalFull, lightDir));
half3 directDiffuse = NdotL_Full * albedo;
// 最终合成
half3 diffuseResult = directDiffuse + bumpSSS * albedo * _ScatterIntensity;
SGScatter 的输入是 NdotL - directionalCurvature:曲率偏移把 SG 的响应中心从 NdotL=0(明暗交界线)推向暗面一侧,模拟光在弯曲表面的更长散射路径。scatterWidth 也按通道缩放,红色通道乘以更大的系数,SG Lobe 随之更宽。
这样散射宽度越大的通道法线越平滑,最终效果上 Skin 的层下质感就出来了。
3.5 Shadow Scatter
Shadow 散射的核心思想是将阴影值作为 NdotL 的替代输入,在阴影区域再计算一次 SG SSS。由于阴影值范围为 [0, 1],需要重映射至 [-1, 1](即 shadow * 2 - 1)以匹配 SG 函数的输入域。
3.5.1 Remap 函数设计与边界行为分析
half3 ShadowScatter(half shadow, half3 scatterColor, half scatterWidth)
{
// 将 shadow [0,1] 重映射为 [-1,1] 以匹配 SG 输入域
half remapped = shadow * 2.0 - 1.0;
// 对三通道分别评估
half ssR = SGScatter(remapped, scatterWidth * scatterColor.r);
half ssG = SGScatter(remapped, scatterWidth * scatterColor.g);
half ssB = SGScatter(remapped, scatterWidth * scatterColor.b);
return half3(ssR, ssG, ssB) * scatterColor;
}
边界行为分析:
| shadow 值 | remapped 值 | SGScatter 响应 | 物理含义 |
|---|---|---|---|
| 0.0(全阴影) | -1.0 | exp(-1/(2σ²)) ≈ 0(σ<0.5 时) | 深阴影内部无可见散射 |
| 0.5(半影中心) | 0.0 | exp(0) = 1.0(最大值) | 阴影边缘散射最强 |
| 1.0(全光照) | 1.0 | exp(-1/(2σ²)) ≈ 0(σ<0.5 时) | 完全光照区域无额外散射 |
SG 函数在 remapped=0(即 shadow=0.5,半影区域)处取得最大值,这精确地对应了阴影散射发生在明暗过渡区域的物理预期。当 scatterWidth 较大时,SG 的 Lobe 展宽,散射效果向全光照和全阴影两侧扩展——但衰减是连续且平滑的,不会产生硬边界。
边界情况:
- 如果 Shadow Map 使用硬阴影(无 PCF),shadow 值非 0 即 1,remapped 为 ±1,SG 响应几乎为零,Shadow Scatter 失效。因此此方案要求 Shadow Map 具备软边缘(PCF / PCSS / Contact Shadow)
- scatterWidth 过大(>1.0)会导致即使在 remapped=±1 时 SG 响应仍然显著,视觉上表现为全阴影/全光照区域出现不自然的红晕。实测中 scatterWidth 在 [0.2, 0.6] 范围内效果最佳
// 最终散射合成
half3 totalSSS = bumpSSS + shadowScatter;
// 注意:curvature 已经融合在 bumpSSS 的 NdotL 偏移中,不再单独加
// 应用到最终着色
half3 finalColor = (directDiffuse + totalSSS * _ScatterIntensity) * lightColor * shadow;
加入 Shadow Scatter 后,阴影边缘区域呈现出红润的散射过渡,进一步强化了皮肤的次表面感知。
Shadow 散射只在阴影过渡区域(shadow ∈ (0,1))产生可见效果。完全在光照中(shadow=1 → remapped=1)或完全在阴影中(shadow=0 → remapped=-1)时,SG 的 Lobe 衰减使得贡献接近零,符合预期。
四、Transmission 透射效果延伸
耳部、鼻翼等薄组织区域在强逆光下会出现光透射现象:光从一侧穿透薄层组织后从另一侧射出,呈现深红色或橘红色。这是 SSS 的另一个重要视觉特征,与前述的散射(入射/出射在同侧)互为补充。
4.1 实现思路
Transmission 的核心是估算光从物体背面穿透到正面的路径长度:
half3 TransmissionSSS(half3 worldPos, half3 worldNormal, half3 lightDir,
half3 transColor, half thickness)
{
// 反转法线方向,模拟光从背面入射
half3 backLightDir = lightDir + worldNormal * _Distortion;
half VdotBackL = saturate(dot(viewDir, -normalize(backLightDir)));
// 透射强度随厚度衰减
half transmission = pow(VdotBackL, _TransPower) * exp(-thickness / _TransScale);
return transColor * transmission;
}
关键参数:
| 参数 | 含义 |
|---|---|
thickness | 组织厚度,通常存储在 Mesh 的 Vertex Color 或单独的 Thickness Map 中,可通过 AO Baker 的反向(从内表面向外采样遮蔽率)烘焙获得 |
_Distortion | 法线偏移量,控制透射方向的扩散程度,值越大透射效果越 Diffuse |
_TransPower | 控制透射的集中度(类似 Specular Power) |
transColor | 透射颜色,通常为深红/橘红色(模拟血液对红光的选择性透过) |
Transmission 未纳入当前实现的原因:需要额外的 Thickness 数据(增加美术制作成本和内存),且其视觉贡献主要集中在耳部和手指等极薄区域,对整体效果的影响相对 Bump/Curvature/Shadow 三要素更为次要。后续可作为优化方向,优先在关键角色的耳部区域局部启用。
五、完整参数表
| 参数名 | 类型 | 范围 | 默认值 | 说明 |
|---|---|---|---|---|
| _ScatterColor | half3 | [0,1] per channel | (0.8, 0.3, 0.15) | RGB 散射颜色,R>G>B 模拟皮肤 |
| _ScatterWidth | half | [0.05, 1.0] | 0.35 | 散射宽度,控制 SG Lobe 展宽 |
| _ScatterIntensity | half | [0, 2.0] | 1.0 | 散射强度乘数 |
| _CurvatureScale | half | [0.1, 10.0] | 1.0 | fwidth 曲率缩放因子 |
| _ShadowScatterWidth | half | [0.1, 0.8] | 0.4 | Shadow Scatter 专用宽度 |
| _BumpScatterStrength | half | [0, 1.0] | 0.7 | Bump Scatter 强度 |
| _TransColor | half3 | [0,1] per channel | (0.9, 0.2, 0.05) | 透射颜色(预留) |
| _Thickness | half | [0, 1.0] | 0.5 | 组织厚度(预留) |
六、性能数据
测试平台:Adreno 660(Snapdragon 888)、1080×2400 分辨率、单方向光
| 方案 | Fragment Shader ALU | 纹理采样次数 | 额外带宽 | GPU 耗时(全屏 Skin) |
|---|---|---|---|---|
| 无 SSS(Standard Lit) | 基准 | 基准 | 0 | 基准 |
| Pre-Integrated LUT | +8 ALU | +2(LUT + Curvature) | +0.5 MB/帧 | +0.12 ms |
| SG 单 Lobe | +18 ALU | +0 | 0 | +0.15 ms |
| SG 双 Lobe + Shadow | +32 ALU | +0 | 0 | +0.24 ms |
| 屏幕空间 Separable SSS | +6 ALU(模糊 Pass) | +22(11-tap × 2 Pass) | +16 MB/帧 | +1.8 ms |
SG 单 Lobe 方案在耗时上与 LUT 方案接近(+0.03ms),但省去了 LUT 纹理的内存和带宽开销,且支持完整的三要素散射。双 Lobe + Shadow 的额外 0.09ms 开销换来明显更好的阴影边缘质量。屏幕空间方案在 Mobile 上的 1.8ms 开销使其基本不可用。
七、结果与对比
完成 Curvature + Bump + Shadow 三层散射的完整实现后,相比 Unreal Engine 内置的 Pre-Integrated Skin,皮肤质感有明显提升,表面呈现出更强的层下感和通透性,不再是单纯的表面着色。
各要素的独立贡献:
- 仅 Curvature Scatter:鼻翼、眉弓等高曲率区域出现红色散射,但平坦区域(如脸颊)缺乏层次
- +Bump Scatter:平坦区域的皮肤纹理被柔化,R 通道的细节法线平滑产生温暖的底色透出感
- +Shadow Scatter:阴影边缘出现连续的红色过渡带,消除了硬阴影的"贴片感"
三者叠加后,面部在各种光照角度下都能呈现出可信的皮肤质感。
耳部 Transmission 透射和环境光散射尚未实现,对整体效果的影响相对次要,可作为后续优化方向。
Reference
- https://therealmjp.github.io/posts/sss-sg/
- AlexTsui:UE4 中用球面高斯函数实现移动端 SSS 效果
- https://blog.selfshadow.com/publications/s2020-shading-course/patry/slides/index.html#/104/0/0
- Christensen & Burley, "Approximate Reflectance Profiles for Efficient Subsurface Scattering", SIGGRAPH 2015
- Penner, "Pre-Integrated Skin Shading", GPU Pro 2, 2011
- Patry & Abram, "A Scalable Real-Time Many-Shadowed-Light Rendering System", SIGGRAPH 2020















