前言:一个有趣的现象

拿一根蜡烛,把手指挡在烛火和你眼睛之间——手指会变成橙红色,你能看见指骨的轮廓。

同样的事情对一块金属做,金属只是变暗,没有颜色变化。

这两种东西的区别是什么?

答案是:次表面散射(Subsurface Scattering,SSS)

  • 金属:光打到表面就反射回来,不进入内部
  • 皮肤/蜡烛/玉石:光穿入材质内部,在里面散射若干次,然后从不同位置的表面射出

今天我们从零实现一个 SSS 渲染器,渲染出下面这张图:

次表面散射效果:蜡烛(橙)、皮肤(红)、玉石(绿)、金属(对比)

左边的橙色球是蜡烛材质,中间偏左是皮肤,小球是玉石,右边银色是金属对比。注意背光(来自后方的蓝光)穿透了蜡烛和皮肤,而金属没有任何穿透效果。


第一步:理解光和材质的关系

在讲代码之前,我们先搞清楚两类材质的根本区别

不透明材质(金属、木头)

1
2
3
光 → 打到表面 → 反射/散射 → 到达眼睛

光不进入内部

光照模型:Phong / PBR,只考虑表面那一层。

半透明材质(皮肤、蜡烛、玉石、牛奶)

1
2
3
光 → 穿入表面 → 在内部散射N次 → 从其他位置的表面射出 → 到达眼睛
↑ ↑
入射点 出射点(不同位置!)

这就是为什么皮肤边缘会发光——背面的光穿过皮肤,从正面的边缘散出来。

这种现象的学名:次表面散射(Subsurface Scattering)


第二步:物理参数定义

要模拟 SSS,需要定义三个物理参数:

参数 符号 含义
散射系数 σ_s 光在材质内被”重新定向”的概率(越大 = 越容易散射)
吸收系数 σ_a 光在材质内被”吸收消灭”的概率(越大 = 颜色越深)
消光系数 σ_t σ_s + σ_a(光减弱的总速率)

不同材质的参数差异决定了 SSS 颜色:

1
2
3
4
蜡烛:σ_s = 9.0,  σ_a = 0.2  → 光容易散射,几乎不被吸收,橙色透射
皮肤:σ_s = 6.0, σ_a = 0.4 → 中等散射,血液色素决定红色透射
玉石:σ_s = 4.0, σ_a = 0.8 → 低散射,绿色矿物质决定颜色
金属:不参与 SSS 计算

在代码里这样定义材质结构体:

1
2
3
4
5
6
7
8
struct Material {
MaterialType type; // DIFFUSE / SSS_WAX / SSS_SKIN / SSS_JADE / METAL
Vec3 albedo; // 表面颜色(外观)
Vec3 sssColor; // SSS 颜色(内部散射的色调)
double scatterCoeff; // σ_s 散射系数
double absorptionCoeff; // σ_a 吸收系数
// ...
};

蜡烛材质的定义:

1
2
3
4
5
6
7
8
Material makeWax() {
return { MAT_SSS_WAX,
Vec3(0.95, 0.85, 0.70), // 表面:奶白色(蜡的颜色)
Vec3(1.0, 0.55, 0.1), // SSS内部色:深橙色(灯光穿透后的颜色)
9.0, // σ_s:高散射(蜡很容易散射光)
0.2, // σ_a:低吸收(蜡不怎么吸收光,所以透光好)
0.3, 0.6, 0.0 };
}

第三步:Beer-Lambert 定律 — 光穿透物体时如何衰减

这是理解 SSS 最关键的公式。

问题:光穿过厚度为 $d$ 的材质后,还剩多少?

答案(Beer-Lambert 定律):

$$T = e^{-\sigma_t \cdot d}$$

  • $T$:透射率(0 = 完全被吸收,1 = 完全透过)
  • $\sigma_t$:消光系数(= σ_s + σ_a)
  • $d$:穿透距离

直观例子

假设 σ_t = 9.2(蜡烛),球半径 = 0.9(直径 1.8):

  • 穿透整个球:$T = e^{-9.2 \times 1.8} \approx e^{-16.6} \approx 0.00006$(几乎全被吸收)

等等——这么小,背光效果怎么还能看见?

关键在于:SSS 颜色系数乘了一个较大的值(2.5),而且光源强度本身很高(3.0)。渲染中的参数往往需要”夸张”处理,才能让效果可见。

代码实现:

1
2
3
4
5
6
// 计算光穿透球体后剩余的透射率
double thickness = computeThickness(hitPoint, lightDir, sphere);
// ↑ 从当前点出发,沿光线方向穿过球体的距离

double sigmaT = mat.scatterCoeff + mat.absorptionCoeff;
double transmit = std::exp(-sigmaT * thickness); // Beer-Lambert

如何计算穿透厚度?

这是个简单的几何问题:从当前点出发,沿光线的反方向射一条光线,找到它离开球的那个点,距离就是厚度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
double computeThickness(const Vec3& hitPoint, const Vec3& lightDir,
const Sphere& sphere) {
// 从 hitPoint 出发,沿光线反方向(光是从后面来的,所以我们往后看)
Ray backRay;
backRay.origin = hitPoint - lightDir * 1e-4; // 稍微偏移避免自交
backRay.dir = -lightDir; // 反方向

// 解方程:找光线离开球体的那个点
Vec3 oc = backRay.origin - sphere.center;
double a = backRay.dir.dot(backRay.dir);
double half_b = oc.dot(backRay.dir);
double c = oc.dot(oc) - sphere.radius * sphere.radius;
double disc = half_b * half_b - a * c;
if (disc < 0) return 2.0 * sphere.radius; // 没交点,用直径估算

double t = (-half_b + std::sqrt(disc)) / a; // 取远的那个交点
return std::max(0.0, t); // 就是厚度
}
1
2
3
4
5
6
               光源(在后方)

观察者 ← [出射点]━━━━━━━━━━[入射点]
← thickness →
hitPoint 在出射点
往 -lightDir 走,到达球的另一侧

第四步:背光透射的核心计算

背光透射效果是 SSS 最明显的特征:当光源在物体背面时,物体边缘会有内部散射的颜色溢出

计算分两部分:

1. 背光散射(主效果)

1
2
3
4
5
6
7
8
9
10
11
// dotBack > 0 表示光源在法线背面(正是我们想要的背光)
// N·L < 0 → 光从背后来 → dotBack 为正
double dotBack = std::max(0.0, -normal.dot(lightDir));

// Beer-Lambert:光穿透整个厚度后的衰减
double transmit = std::exp(-sigmaT * thickness);

// 背光贡献:SSS颜色 × 光色 × 背光角度 × 穿透量 × 增强系数
Vec3 backlight = mat.sssColor * lightColor * dotBack * transmit * 2.5;
// ↑
// 乘以2.5是为了让效果可见(物理上的"艺术夸张")

为什么乘 dotBack

  • 当光正对物体背面(dotBack = 1.0):最强的背光穿透
  • 当光从侧面来(dotBack ≈ 0.5):中等效果
  • 当光从正面来(dotBack = 0):没有背光穿透(正面光不用穿过球)

2. 正面 Wrapped Diffuse(边缘柔化)

普通的 Lambertian 漫反射在明暗交界处很硬,对于皮肤这类材质太假了。Wrapped Diffuse 把明暗边界”软化”:

1
2
3
4
5
6
7
8
9
10
11
12
// 普通 Lambertian:  max(0, N·L)
// 在 N·L = 0(侧面)时直接变0,边界很硬
//
// Wrapped Diffuse: (N·L + w) / (1+w)
// w=0.5 时,侧面也有 0.5/1.5 ≈ 0.33 的光,边界柔和
double wrap = 0.5;
double dotFront = normal.dot(lightDir);
double wrappedDiffuse = std::max(0.0, (dotFront + wrap) / (1.0 + wrap));

// 浅层散射(从正面透进去的光,厚度减半)
double shallowScatter = std::exp(-sigmaEff * thickness * 0.5);
Vec3 frontSSS = mat.sssColor * lightColor * wrappedDiffuse * shallowScatter * 0.8;

3. 组合

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Vec3 computeSSS(/* ... */) {
double sigmaT = mat.scatterCoeff + mat.absorptionCoeff;
double sigmaEff = std::sqrt(3.0 * mat.absorptionCoeff * sigmaT); // Dipole 有效系数
double thickness = computeThickness(hitPoint, lightDir, sphere);

// 背光穿透
double dotBack = std::max(0.0, -normal.dot(lightDir));
double transmit = std::exp(-sigmaT * thickness);
Vec3 backlight = mat.sssColor * lightColor * dotBack * transmit * 2.5;

// 正面柔化散射
double wrap = 0.5;
double wrappedDiffuse = std::max(0.0, (normal.dot(lightDir) + wrap) / (1.0 + wrap));
double shallowScatter = std::exp(-sigmaEff * thickness * 0.5);
Vec3 frontSSS = mat.sssColor * lightColor * wrappedDiffuse * shallowScatter * 0.8;

// 距离衰减
double atten = 1.0 / (1.0 + lightDistance * lightDistance * 0.04);

return clamp((backlight + frontSSS) * atten, 0.0, 3.0);
}

第五步:Dipole 模型简介

上面提到了 sigmaEff = sqrt(3 * σ_a * σ_t),这个公式来自 Dipole 近似模型(Donner & Jensen, 2005)。

Dipole 是目前游戏/电影里最常用的 SSS 近似。它的思路:

把一个在球体表面散射的光源,等效为两个点光源:

  • 一个在表面内部(真实光源的镜像)
  • 一个在表面外部(相反方向)

两个”偶极子”的散射叠加,近似真实的多次散射结果。

有效消光系数的意义:

$$\sigma_{eff} = \sqrt{3 \sigma_a (\sigma_a + \sigma_s)}$$

它表示”散射进去后,平均能走多远才完全衰减”。σ_a 越小(吸收少),$\sigma_{eff}$ 越小,光能走得更远——这正是蜡烛比皮肤透光更好的原因(蜡的 σ_a = 0.2,皮肤的 σ_a = 0.4)。

在我们的简化实现里,sigmaEff 主要用于控制正面浅层散射的深度,没有实现完整的 Dipole 积分——那需要数值积分,超出本文范围。


第六步:光源设置(让背光效果可见的关键)

正确设置光源位置是让 SSS 效果肉眼可见的关键。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void buildScene(/* ... */) {
// 主光源:右上方,暖白光(照亮正面)
Light mainLight;
mainLight.pos = Vec3(4.0, 5.0, 1.0);
mainLight.color = Vec3(1.0, 0.95, 0.85);
mainLight.intensity = 1.2; // 不要太强,避免正面过曝盖住SSS颜色

// ★ 背光1:正后方强蓝光,专门激发SSS透射
// z = -9,比所有球(z ≈ -3.5)更靠后
// 从后面打过来,必须穿透整个球才能到达观察者
Light backLight;
backLight.pos = Vec3(0.0, 0.5, -9.0);
backLight.color = Vec3(0.4, 0.6, 1.0); // 蓝色,和正面暖光形成冷暖对比
backLight.intensity = 3.0; // 强度大,因为要穿过整个球

// ★ 背光2:左后方暖色光,专照蜡烛球
Light backLight2;
backLight2.pos = Vec3(-3.5, 1.0, -7.5);
backLight2.color = Vec3(1.0, 0.7, 0.3); // 橙黄色,配合蜡烛SSS颜色
backLight2.intensity = 2.2;
}

为什么背光强度要比主光源大?

因为背光需要穿透整个球体(经过 Beer-Lambert 大幅衰减后)才有贡献,所以原始强度必须更大才能让最终效果可见。这是现实摄影里也有的概念:给半透明物体打”透射灯”时,灯的瓦数要远大于正面补光灯。


第七步:主着色流程

把上面所有模块串起来:

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
46
47
48
49
50
51
52
53
54
55
56
57
Vec3 shade(const Ray& ray, /* 场景、光源 */) {
// 1. 光线与场景求交
HitRecord rec;
if (!scene.hit(ray, 1e-4, 1e9, rec)) {
return skyColor(ray.dir); // 没打到任何东西,返回天空色
}

const Material& mat = *rec.mat;
Vec3 color(0);

if (mat.type == MAT_METAL) {
// 金属:直接光照 + 环境反射(没有SSS)
color = metalShading(rec, ray, lights, scene);
// 反射递归
Vec3 reflDir = reflect(ray.dir, rec.normal);
Ray reflRay = {rec.point + rec.normal * 1e-4, reflDir};
color = lerp(color, shade(reflRay, ...) * mat.albedo, 0.6);
}
else if (mat.type == MAT_DIFFUSE) {
// 普通漫反射:环境光 + Phong
color = ambientLight(rec.normal, mat.albedo);
for (auto& light : lights)
color += phongShading(rec, ray, light, scene);
}
else {
// ★ SSS材质(Wax/Skin/Jade)核心流程:
color = ambientLight(rec.normal, mat.albedo); // 微弱环境光底色

for (auto& light : lights) {
Vec3 N = rec.normal.normalized();
Vec3 L = (light.pos - rec.point).normalized();

// a. 直接漫反射(表面正面的光照)
double diff = max(0, N.dot(L));
bool inShadow = checkShadow(rec.point, light, scene);
Vec3 directDiff = mat.albedo * light.color * diff * !inShadow;

// b. 高光
Vec3 spec = phongSpecular(rec, ray, light);

// c. ★ 次表面散射(背光穿透 + 正面柔化)
Vec3 sss = computeSSS(rec.point, N, L, light.color, *hitSphere, mat, dist);
sss *= light.intensity;

color += directDiff + spec + sss;
}

// d. 表面菲涅尔反射(让表面有一点光泽)
double fresnel = fresnelSchlick(rec.normal, -ray.dir);
if (fresnel > 0.05) {
Vec3 reflColor = shade(reflRay, ...);
color = lerp(color, reflColor, fresnel * 0.3);
}
}

return color;
}

关键点:SSS 材质同时有直接光照(表面正面)和次表面散射(背光穿透)两部分。两者叠加,才是最终的颜色。


第八步:色调映射和曝光

渲染出的 HDR 颜色需要压缩到 [0, 255] 才能显示。直接截断会丢失高光细节,我们用 ACES 色调映射:

1
2
3
4
5
6
7
8
9
10
11
12
13
Vec3 acesTonemap(Vec3 x) {
// ACES 曲线:把 [0, ∞) 映射到 [0, 1)
// 高光区域压缩,暗部基本不变
const double a = 2.51, b = 0.03, c = 2.43, d = 0.59, e = 0.14;
return clamp((x * (a*x + b)) / (x * (c*x + d) + e), 0.0, 1.0);
}

Vec3 gammaCorrect(Vec3 color) {
color = color * 0.4; // 曝光:整体压暗,让SSS颜色更可见
color = acesTonemap(color); // 色调映射
// sRGB Gamma校正(显示器的亮度不是线性的)
return { pow(color.x, 1/2.2), pow(color.y, 1/2.2), pow(color.z, 1/2.2) };
}

为什么曝光设成 0.4(偏暗)?

因为 SSS 材质的特点是颜色鲜艳但亮度适中——背光橙色、透射红色这些效果在高亮区域会被 ACES 压平(变成白色)。降低曝光可以把这些颜色保留在可见范围内。


效果对比:有无背光

材质 没有背光 有背光(SSS效果)
蜡烛 奶白色正面 边缘橙色光晕,背面透橙
皮肤 米色正面 边缘红色,模拟血液散射
玉石 绿色正面 透射绿色,有温润感
金属 反光面 无变化(金属不透光)

完整项目

1
2
3
03-06-SubsurfaceScattering/
├── main_v2.cpp ← 核心渲染代码(~550行)
└── sss_output.png ← 渲染输出

编译运行

1
2
3
g++ -O3 -std=c++17 main_v2.cpp -o sss_v2
./sss_v2
# 约 1-2 秒完成(800×600,8spp)

总结

次表面散射让我们理解了半透明材质的物理本质:

  1. 光不只在表面反射——它会进入材质内部
  2. Beer-Lambert 定律:穿透距离越长,剩余的光越少(指数衰减)
  3. 背光是关键:背面的强光穿过球体后,以材质的 SSS 颜色从正面边缘散出
  4. Wrapped Diffuse:让明暗边界柔和,避免”塑料感”

这种技术广泛用于游戏(角色皮肤渲染)、电影(蜡烛、宝石、人体皮肤特效)。完整的实现比这里复杂得多——BRDF 积分、多散射层、随机游走等——但核心思路都是这篇文章里的东西。


参考资料