前言:一个有趣的现象
拿一根蜡烛,把手指挡在烛火和你眼睛之间——手指会变成橙红色,你能看见指骨的轮廓。
同样的事情对一块金属做,金属只是变暗,没有颜色变化。
这两种东西的区别是什么?
答案是:次表面散射(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; Vec3 albedo; Vec3 sssColor; double scatterCoeff; double absorptionCoeff; };
|
蜡烛材质的定义:
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), 9.0, 0.2, 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);
|
如何计算穿透厚度?
这是个简单的几何问题:从当前点出发,沿光线的反方向射一条光线,找到它离开球的那个点,距离就是厚度。
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) { 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
|
double dotBack = std::max(0.0, -normal.dot(lightDir));
double transmit = std::exp(-sigmaT * thickness);
Vec3 backlight = mat.sssColor * lightColor * dotBack * transmit * 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
|
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); 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;
Light backLight; backLight.pos = Vec3(0.0, 0.5, -9.0); backLight.color = Vec3(0.4, 0.6, 1.0); backLight.intensity = 3.0;
Light backLight2; backLight2.pos = Vec3(-3.5, 1.0, -7.5); backLight2.color = Vec3(1.0, 0.7, 0.3); 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, ) { 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) { 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) { color = ambientLight(rec.normal, mat.albedo); for (auto& light : lights) color += phongShading(rec, ray, light, scene); } else { color = ambientLight(rec.normal, mat.albedo);
for (auto& light : lights) { Vec3 N = rec.normal.normalized(); Vec3 L = (light.pos - rec.point).normalized();
double diff = max(0, N.dot(L)); bool inShadow = checkShadow(rec.point, light, scene); Vec3 directDiff = mat.albedo * light.color * diff * !inShadow;
Vec3 spec = phongSpecular(rec, ray, light);
Vec3 sss = computeSSS(rec.point, N, L, light.color, *hitSphere, mat, dist); sss *= light.intensity;
color += directDiff + spec + sss; }
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) { 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; color = acesTonemap(color); 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
|
总结
次表面散射让我们理解了半透明材质的物理本质:
- 光不只在表面反射——它会进入材质内部
- Beer-Lambert 定律:穿透距离越长,剩余的光越少(指数衰减)
- 背光是关键:背面的强光穿过球体后,以材质的 SSS 颜色从正面边缘散出
- Wrapped Diffuse:让明暗边界柔和,避免”塑料感”
这种技术广泛用于游戏(角色皮肤渲染)、电影(蜡烛、宝石、人体皮肤特效)。完整的实现比这里复杂得多——BRDF 积分、多散射层、随机游走等——但核心思路都是这篇文章里的东西。
参考资料