真实感皮肤渲染综述 — 从 BSSRDF 到屏幕空间可分离 SSS

皮肤是数字人渲染中最具挑战、也是最值得深挖的材质之一。 本文按照「提出问题 → 物理建模 → 实时近似的演进 → 工程落地 → 横向对比」的脉络,系统梳理近 20 年实时皮肤渲染的主流方案,重点解析 Pre-IntegratedSeparable SSS (4S)Spherical Gaussian (SG) 三大里程碑技术。

文章还在施工中


一、为什么皮肤渲染如此困难?

人眼对人脸有近乎本能的识别精度——一点点不自然的光照、一处过硬的高光,都足以让数字人陷入”恐怖谷”。它的难点本质上来自皮肤独特的物理结构:

皮肤不是一个表面,而是一摞有半透明属性的多孔层。

光线打到皮肤上后,约 6% 被表面油脂层直接反射形成镜面高光,剩下 94% 的能量会进入皮肤内部,在多个层之间被多次散射、部分波长被血红蛋白选择性吸收,最后从入射点附近的某处再次出射。

Fig 1.1.

在医学上,仅皮肤表皮(epidermis)即被认为包含五个不同的层[Poirer 2004]。在这种复杂性下对散射进行模拟可能是过度和没有必要的,但是真实的渲染需要在油质层下面建模至少两个不同的层,因为至少有一个层要用于镜面反射(specular)项。并展示了使用三层建模的改进方案。

Fig 1.2.

因为其具有半透明属性光线会在皮肤的表层进行多次散射,散射根据其通过的路径衰减,简单来说就是光线会扩散到周围,这对于表现皮肤的质感起到很大作用。

多次皮肤对光线的散射和吸收

Fig 1.3.

多次皮肤对光线的散射和吸收

物理上,最准确的描述是 BSSRDF(Bidirectional Scattering-Surface Reflectance Distribution Function)—— 一个 8 维函数 ,它显式地刻画了入射点 与出射点 不重合的散射行为:

对比一下普通 PBR 材质所用的 BRDF:

BRDF(同一点入射 / 出射) 入=出 BSSRDF(入射点 ≠ 出射点) 入射点 出射点

核心矛盾:BSSRDF 物理准确,但需要在表面 A 上做积分,对每个出射点都要查询周围所有入射点的贡献——直接评估完全不可能用于实时。

因此所有的实时皮肤技术,本质上都是在回答同一个问题

如何用低成本的近似手段,在不同硬件预算下拟合 BSSRDF 的视觉表现?

把握住这条主线,下文所有方案的取舍就都顺理成章了。


二、物理基础:三个必须先理解的概念

2.1 镜面反射 — 来自表面油脂层的 6%

虽然只占 6%,但镜面项决定了”湿润 / 干燥””油性 / 哑光”等关键观感,是数字人是否”活”的关键。

业界常见的高光模型有:

模型 特点 性能
Cook-Torrance / GGX 工业标准 PBR 微面元模型
Beckmann 早期皮肤渲染常用,搭配 LUT 预积分加速 低(LUT 化后)
Kelemen–Szirmay-Kalos GPU Gems 3 推荐,与 Torrance-Sparrow 视觉接近但 ALU 显著更少

Cook-Torrance BRDF:

为了避免运行时再算法线分布函数 ,常以 为索引把它烘到 2D LUT 里:

1
2
3
4
5
6
// runtime
float2 uv = float2(saturate(NdotH), roughness);
float D = SAMPLE_TEXTURE2D(_BeckmannLUT, sampler_LinearClamp, uv).r;
float3 F = F_Schlick(F0, LdotH);
float G = V_KelemenSzirmayKalos(NdotL, NdotV); // 解析式即可,无需 LUT
float3 spec = D * F * G;

Dual Lobe Specular(双镜叶高光)

UE 的数字人方案不再使用单一粗糙度。皮肤毛孔的亚像素级凹凸会同时产生柔和的大波瓣锐利的小波瓣,UE 用两个独立粗糙度的高光分别采样再混合:

UE 默认 (柔和层占主导)。这种亚像素微频近似让皮肤的”湿润感”显著提升,单层高光始终偏塑料感。

2.2 次表面散射 — 来自表皮 / 真皮的 94%

这是皮肤”通透感”的全部来源。在统计意义下,光线从入射点沿表面”扩散”出去的距离衰减规律,可以用一个一维函数表达:

Diffusion Profile(扩散剖面):当一束极细的光打到皮肤上时,从距入射点 处出射的能量比例

不同波长的光对应不同的 Profile —— 红光散射距离最远(这就是手指捂着手电筒会变红的原因),绿光次之,蓝光衰减最快。这正是皮肤背光处那一抹温暖的红色调的来源。

距入射点距离 r (mm) R(r) 0 1 2 3 4 R 通道 σ_r ≈ 0.65 mm G 通道 σ_g ≈ 0.25 mm B 通道 σ_b ≈ 0.10 mm RGB Diffusion Profile(典型皮肤数值)

Diffusion Profile 是所有 SSS 算法的共同基础:之后的纹理空间模糊、屏幕空间模糊、4S、Pre-Integrated、SG,本质都是用不同手段去**”应用”这条曲线**。

2.3 交互:感受 R/G/B 散射差异

下面这个小工具可以拖动 RGB 散射半径,直观看出 Profile 形态变化与卷积出来的”晕开”效果:

Diffusion Profile 形态
应用到点光源的"晕开"效果
R σ (mm) 0.65 G σ (mm) 0.25 B σ (mm) 0.10
使用单一高斯近似 R(r) = exp(-r²/2σ²)。三套数值越接近 → 散射颜色越中性;R 越大于 G/B → 越红越"血肉感"

三、次表面散射的实时近似演进

3.1 时间线:性能预算如何驱动技术演化

year 2001 SSLT Dipole 2003 Texture Space Blur 2005 Multipole 2009 Screen Space Blur (SSSSS) 2011 Pre-Integrated [Penner] 2015 Separable SSS [Jimenez] 2018+ Burley + SG [MJP] 驱动力: • 移动端带宽受限 → Pre-Integrated(1 pass,零额外 RT) • PC/主机算力充足 → 屏幕空间方法精度更高,4S 进一步把 N×N 拆为两次 1D blur • 追求能量守恒 + 灵活性 → SG 用解析式拟合 Profile

下面我们首先提一下Wrap方案,再逐个拆解三个里程碑级方案。


3.2 Light Warp 经验模型

alt text
观察.经验模型
GPUGems
GPUGems
Chapter 16. Real-Time Approximations to Subsurface Scattering

3.3 方案 A:Pre-Integrated Skin Shading(移动端首选)

核心思想(一句话):既然每帧都把 Diffusion Profile 在球面 + 曲率上积一遍太贵,那就离线积一次,把结果存成 2D LUT,运行时只查表。

Pre-Integrated Skin Shading是一个从结果反推实现的方案。我们在观察次表面散射效果可以发现:

  1. 「Surface Curvature」次表面散射的效果主要发生在曲率较大的位置(或者说光照情况变化陡峭的位置),而在比较平坦的位置则不容易显现出次表面散射的效果(比如鼻梁处的次表面散射就比额头处的次表面散射效果要强)
  2. 「Small Surface Bumps」在有凹凸细节的部位也容易出现次表面散射,这一点其实和(1)说的是一回事,只是(1)中的较大曲率是由几何形状产生的,而(2)中的凹凸细节则一般是通过法线贴图来补充。
  3. 「Shadows」when shadows fall on the surface, light will scatter into the shadows. 。当阴影落到表面上时,灯光将会散射到阴影中。

数学推导

结合以上 3 个观察,预积分的思路是把次表面散射的效果预计算成一张二维查找表,查找表的参数分别是**dot(N, L)和曲率「curvature (1/radius)」**,因为这两者结合就能够反映出光照随着曲率的变化。
对凸出表面,靠近终止线()的位置散射最明显。Penner 把”光在球面上沿 Profile 散射后呈现的颜色”近似为关于 (NdotL, 曲率) 两个参数的函数:

知乎答疑中更推荐的理解形式:

注意积分域是 , 就是余弦项 ,为曲率, 是局部曲率半径, 是 RGB 三通道的 Diffusion Profile。把这个二维积分预先算出来,得到的就是经典的 SSS LUT:

运行时 1 行采样:
float2 uv = float2(NdotL*0.5+0.5,
    curvature);
float3 sssDiff = tex2D(_SSSLUT, uv);
优势 • 1 个 Pass,0 个额外 RT • 移动端带宽友好 • 适合 forward 路径 局限 • 凹面 / 自遮挡处理不佳 • 曲率需额外算(mesh 烘焙或导数) • Profile 改动需重新烘 LUT

alt text

图中的 就是曲率,计算方法如下:

核心实现(URP / HLSL)

具体包括 4 个实现要点:

  • 曲率散射
  • 法线过滤
  • 阴影散射
  • Filmic Tone-mapping「HDR,高动态范围应用」
    阴影实现可以参考 GPU-Pro-2

\phi +x  与 \phi -x

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 1. 曲率:可由模型空间法线对屏幕空间的导数估算
float curvature = saturate(length(fwidth(normalWS)) / length(fwidth(positionWS)) * _CurvatureScale);

// 2. 采样 SSS LUT(核心)
float2 lutUV = float2(NdotL * 0.5 + 0.5, curvature);
float3 sssDiffuse = SAMPLE_TEXTURE2D(_SkinLUT, sampler_LinearClamp, lutUV).rgb;

// 3. 镜面反射 LUT(Beckmann)
float specD = SAMPLE_TEXTURE2D(_BeckmannLUT, sampler_LinearClamp,
float2(NdotH, 1 - roughness)).r;
float3 F = F_Schlick(F0, LdotH);
float G = V_KelemenSzirmayKalos(NdotL, NdotV);
float3 specular = specD * F * G;

// 4. 合成
half3 color = (sssDiffuse * baseColor + specular) * lightColor * lightAttenuation;

典型踩坑:曲率计算稳定性。直接 fwidth 在低多边形或 MSAA 边缘会产生噪点;常见做法是把曲率烘到顶点色或单独贴图里,运行时插值即可。


3.4 方案 B:Separable Subsurface Scattering(4S,PC/主机首选)

演进起点:屏幕空间模糊(SSSSS)

屏幕空间方法的思路非常直接:

  1. 像普通光照那样把皮肤的 Lambert irradiance 输出到一张 RT;
  2. Stencil Buffer 标记皮肤像素;
  3. 对该 RT 做 2D 卷积,卷积核形态由 Diffusion Profile 决定,核半径由像素深度推断(远的物体 SSS 范围视觉上更小)。
屏幕空间模糊(Screen Space Blur)思路概览
Screen Space Blur
Diffuse Pass 输出 irradiance Stencil Mask 仅皮肤像素 = 1 Horizontal Blur 1D, N taps Vertical Blur 1D, N taps Composite + specular Final Frame to backbuffer Separable SSS 渲染管线

4S 的关键洞察:N×N → 1×N + N×1

朴素 2D 卷积的复杂度是 ,对 25×25 卷积核需要 625 次纹理采样。Jimenez 的关键观察:Diffusion Profile 卷积核可以做秩 1 近似(SVD 分解),于是:

25×25 → 2×25 = 50 次采样,性能提升 12 倍,视觉损失却几乎不可察觉。

C
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// SeparableSSS.hlsl 简化版(参考 Jimenez 原始实现)
// 25 个高斯权重,已沿 1D 离散化;红色权重最宽以匹配真实皮肤的 R 散射
static const float4 kernel[25] = {
float4(0.530605, 0.613514, 0.739601, 0),
float4(0.000973794, 1.11862e-005, 9.43437e-007, -3.0),
// ... 中间项略
float4(0.000973794, 1.11862e-005, 9.43437e-007, 3.0),
};

half4 SSSBlurPS(Varyings i, half2 dir) {
half4 colorM = SAMPLE(_MainTex, i.uv);
half depthM = SAMPLE(_CameraDepthTexture, i.uv).r;

// 屏幕空间步长 ∝ FOV / 深度
half2 scale = _SSSWidth / depthM * dir;
half3 colorBlurred = colorM.rgb * kernel[0].rgb;

[unroll]
for (int k = 1; k < 25; k++) {
half2 offset = i.uv + kernel[k].a * scale;
half3 c = SAMPLE(_MainTex, offset).rgb;

// 深度修正:相邻像素深度差太大时降低权重,避免漏光
half depth = SAMPLE(_CameraDepthTexture, offset).r;
half s = saturate(300 * abs(depthM - depth));
c = lerp(c, colorM.rgb, s);

colorBlurred += kernel[k].rgb * c;
}
return half4(colorBlurred, colorM.a);
}

Burley’s Normalized Diffusion(2018+)

迪士尼提出 Burley Normalized Diffusion 取代纯高斯,单一参数 (mean free path)即可控制整条曲线,能量精确守恒:

UE5、Unity HDRP 当前的 SSS 都是基于这个 Profile 来采样的。
Zero-Radiance 还证明了它的 CDF 可解析求逆——这意味着可以做重要性采样,进一步减少 noise。


3.5 方案 C:Spherical Gaussian(数学解析派)

SG 用三个解析的球面高斯函数(分别对应 RGB)去直接卷积光源,绕开 LUT 和屏幕空间 blur 这两条路:

其中 是波瓣中心(直接对齐光源方向), 是锐度(散射半径越大锐度越小)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
float3 diffuse = nDotL;
if (EnableSSS) {
// 用 ScatterAmt.xyz 控制 RGB 三通道的散射半径
SG redKernel = MakeNormalizedSG(lightDir, 1.0 / max(ScatterAmt.x, 1e-4));
SG greenKernel = MakeNormalizedSG(lightDir, 1.0 / max(ScatterAmt.y, 1e-4));
SG blueKernel = MakeNormalizedSG(lightDir, 1.0 / max(ScatterAmt.z, 1e-4));

// 球面高斯卷积法线 → 解析得到散射后的 irradiance
diffuse = float3(
SGIrradianceFitted(redKernel, normal).x,
SGIrradianceFitted(greenKernel, normal).x,
SGIrradianceFitted(blueKernel, normal).x
);
}
float3 lighting = diffuse * LightIntensity * DiffuseAlbedo / PI;

为什么 SG 在数学上很美

优势 说明
任意方向 直接对齐 punctual light 方向,无需重新参数化
任意锐度 单参数 平滑控制散射强度,无需分档烘 LUT
严格能量守恒 球面归一化积分 = 1, 不会出现”越调越亮”的物理失真
与 IBL 兼容 球面环境光也可用 SG 拟合,统一框架

代价:单点光源效果好,但拟合 Burley Profile 需要至少 3~5 个 SG 叠加,且 SG 不能直接处理屏幕空间的”邻接像素散射”——它只能近似 punctual light 自身的散射,对透射、跨表面散射都没有解。所以更准确的定位是 “高质量 punctual light SSS 的解析方案”,而非屏幕空间 SSS 的替代。


四、工程实践:把方案塞进现代渲染管线

4.1 URP 中的 4S Pass 组织

Unity URP 的 ScriptableRendererFeature + ScriptableRenderPass 是注入屏幕空间后处理的标准方式:

SeparableSSSFeature.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
public class SeparableSSSFeature : ScriptableRendererFeature {
[SerializeField] Shader sssShader;
[SerializeField] float sssWidth = 0.012f; // 物理单位(米),约皮肤 1cm
[SerializeField] LayerMask skinLayers;

SeparableSSSPass pass;

public override void Create() {
pass = new SeparableSSSPass(sssShader) {
renderPassEvent = RenderPassEvent.AfterRenderingSkybox // 透明物之前
};
}
public override void AddRenderPasses(ScriptableRenderer r, ref RenderingData d) {
pass.Setup(sssWidth);
r.EnqueuePass(pass);
}
}

class SeparableSSSPass : ScriptableRenderPass {
static readonly int TempA = Shader.PropertyToID("_SSS_TempA");
static readonly int TempB = Shader.PropertyToID("_SSS_TempB");
Material mat;
float width;

public override void Execute(ScriptableRenderContext ctx, ref RenderingData d) {
var cmd = CommandBufferPool.Get("Separable SSS");
var src = d.cameraData.renderer.cameraColorTargetHandle;
var desc = d.cameraData.cameraTargetDescriptor;
desc.depthBufferBits = 0;

cmd.GetTemporaryRT(TempA, desc); cmd.GetTemporaryRT(TempB, desc);

// 关键:仅对 stencil = SkinBit 的像素做 blur
mat.SetFloat("_SSSWidth", width);
mat.SetVector("_BlurDir", new Vector2(1, 0));
cmd.Blit(src, TempA, mat, 0); // pass 0: H blur w/ stencil test
mat.SetVector("_BlurDir", new Vector2(0, 1));
cmd.Blit(TempA, TempB, mat, 0); // pass 0: V blur
cmd.Blit(TempB, src);

ctx.ExecuteCommandBuffer(cmd);
cmd.ReleaseTemporaryRT(TempA); cmd.ReleaseTemporaryRT(TempB);
CommandBufferPool.Release(cmd);
}
}

Unity URP Blit 默认行为会丢弃stencil,需要替代 Blit 的写法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
//以下两行代码就可,如果Shader 使用了MVP变换就就需要第二个
cmd.SetRenderTarget(tempTarget, depth);
// 绘制全屏Quad或Mask Mesh
cmd.DrawMesh(RenderingUtils.fullscreenMesh, Matrix4x4.identity, stencilMat, 0, 0);


//以下要设置MVP矩阵是因为Shader中写了
// o.positionHCS = TransformObjectToHClip(v.positionOS.xyz);
//
//o.positionHCS = float4(v.positionOS.xyz, 1.0);
// === 1. 保存当前摄像机的矩阵 ===
var cam = renderingData.cameraData.camera;
Matrix4x4 view = cam.worldToCameraMatrix;
Matrix4x4 proj = cam.projectionMatrix;

//替代了cmd.blit
//需要设置屏幕视图投影矩阵,这样才是完整的屏幕坐标
// === 2. 设置为单位矩阵,用于全屏绘制 ===
cmd.SetViewProjectionMatrices(Matrix4x4.identity, Matrix4x4.identity);
//tempRT 为 Mask RT
cmd.SetRenderTarget(tempTarget, depth);
// cmd.SetRenderTarget(tempTarget, RenderBufferLoadAction.Load, RenderBufferStoreAction.Store, depth, RenderBufferLoadAction.Load, RenderBufferStoreAction.Store);

// 绘制全屏Quad或Mask Mesh
cmd.DrawMesh(RenderingUtils.fullscreenMesh, Matrix4x4.identity, stencilMat, 0, 0);

// === 3. 恢复摄像机矩阵 ===
cmd.SetViewProjectionMatrices(view, proj);

4.2 Stencil 剔除的具体做法

为了避免对天空 / 衣服 / 地形等 94% 不需要 SSS 的像素也跑全屏 blur,必须用 Stencil 收窄运算范围

  1. 皮肤材质的前向 / G-Buffer Pass 中,写入 stencil 的某一位(如 0x40);
  2. SSS Blur Pass 在 RenderState 里配置 Stencil { Ref 0x40 ReadMask 0x40 Comp Equal },GPU 的 Early-Z/Stencil Reject 会自动剔除非皮肤像素;
  3. 实测在 1080p 下能省约 40%~70% 的 blur 开销(取决于皮肤像素占比)。
1
2
3
4
5
6
7
8
9
10
11
12
Pass {
Name "SSS_Horizontal_Blur"
Stencil {
Ref 64 // 0x40
ReadMask 64
Comp Equal
Pass Keep
}
HLSLPROGRAM
// ... blur code
ENDHLSL
}

4.3 平台权衡:选型决策树

目标平台 / 预算? core question 移动端 / Forward Pre-Integrated 1 Pass · 0 RT · LUT 预烘 PC / 主机 Separable SSS (4S) 2 个 1D blur · 屏幕空间 通用材质 / IBL Spherical Gaussian 解析式 · 适配玉石/大理石 次要决策点 是否需要透射(耳朵/手指捂光)? 4S + Translucent Shadow Map / Pre-Int 加 BackLight 项 数字人 / Hero 角色? 4S + Dual Lobe Specular + 高频 Detail Normal + 微表情 大量 NPC? Pre-Integrated + 共用 LUT,省带宽 VR / 高帧率? 优先 Pre-Integrated;4S 的全屏 blur 会显著拉高 GPU 时延

五、横向对比

1.Original     2.Wrap     3.Pre-Integrated     4.Screen Space

Fig 5.1.

1.Original     2.Wrap     3.Pre-Integrated     4.Screen Space

💡ss和sg可调整内容会更多,能方便的应用于其他SSS的物体上,lut调整起来就比较麻烦

5.1 多维对比表

维度 Pre-Integrated Separable SSS (4S) Spherical Gaussian
核心思想 离线把 NdotL × 曲率 积分进 LUT 屏幕空间 N×N 卷积 → SVD 拆分两次 1D 解析球面高斯函数卷积光源
性能(采样数) 1 次 LUT + 1 次 Beckmann LUT 2 × N 次纹理(典型 N=12~25) ~5 次 ALU(无纹理)
额外 RT 0 2~3 张全屏 RT 0
管线侵入 仅材质 Pass 需要 SRP / Render Feature 仅材质 Pass
凹面处理 较弱(曲率定义在表面外) 良好 中等
能量守恒 取决于 LUT 烘焙 取决于 Profile 归一化 严格守恒
美术工作流 烘 LUT,Profile 改动需重烘 只需调 Width 和 Profile 系数 改 ScatterAmt 即时预览
扩展到玉石/蜡 需为每种材质做一组 LUT 改 Profile 即可 改 ScatterAmt 即可
移动端可行性 ⭐⭐⭐⭐⭐ ⭐⭐ ⭐⭐⭐⭐
3A 数字人画质 ⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐
代表项目 大量国产二次元手游 / Unity Demo UE / Unity HDRP / Frostbite MJP 个人研究 / 部分实验性引擎

5.2 雷达图(交互)

把鼠标悬停在三角形上可以高亮该方案:

5 = 最佳;维度按顺时针:性能 / 画质 / 能量守恒 / 美术友好 / 移动端友好

5.3 选型建议

  • 手游 / 中端机移动端 / 大量 NPC 共享材质Pre-Integrated,权衡最优。
  • 3A 数字人 / 影视级 cinematic4S + Burley Profile + Dual Lobe Specular + Translucent Shadow Map
  • 次表面通用材质(玉、蜡、果冻、奶)/ 研究项目SG,参数即时可调,能量守恒是其杀手锏。
  • VR 应用:优先 Pre-Integrated,慎用屏幕空间方法(带宽 + 时延双杀)。

六、工程化清单

把要落地的工作拆成一个 checklist:

[ ] 资产:Albedo / Normal(含 Detail Normal)/ Roughness / Cavity / Curvature / Thickness
[ ] LUT:Beckmann LUT、SSS Diffuse LUT(如选 Pre-Integrated)
[ ] 管线:Stencil 写入位规划,避免与其他后处理冲突
[ ] 后处理顺序:SSS Blur 在透明物之前,Bloom 之前,TAA 之后(避免 ghost)
[ ] Detail:Cavity Map 抑制毛孔附近高光,Specular Occlusion 处理凹陷
[ ] 透射:Thickness Map + Translucent Shadow Map,背光从耳朵 / 鼻翼 / 手指透出
[ ] 眼角 / 嘴唇:单独材质,更湿润高光、更弱 SSS
[ ] TAA / MSAA:4S 在锯齿边缘容易出现摩尔纹,需要前置 TAA



写在最后:皮肤渲染的故事远没有结束。当下的研究热点正从”如何近似 BSSRDF”转向”如何让 SSS 与全局光照、半透阴影、Path-Traced Reference 端到端一致”——尤其是 Lumen / Restir-DI 等实时 GI 方案普及之后,屏幕空间 SSS 与间接光的耦合是新的工程难点。建议持续关注 SIGGRAPH / GDC 每年的 Advances in Real-Time Rendering 课程。