在(五)折射 和(六)菲涅耳方程 里,我们谈及光怎样穿过表面,透射至物体内部。我们假设了光在物体中传播时不会衰减。然而,除了真空,光线通过不同物质都会被散射和吸收,例如我们看到天空是蓝色的,也是因为不同波长的光被空气粒子散射程度不一样所致;如果阳光没有被空气粒子散射,天空应该是透明的。
本文描述一种简单方法去模拟光被材质吸收,也会首次尝试加入色彩。
1. 比尔-朗伯定律
比尔-朗伯定律(Beer-Lambert law)描述电磁波(如可见光)通过物体时,物体吸收部分电磁波,而吸收率与物体的厚度(光程距离)、物质的吸光系数及其浓度相关。[1] P.393 给出的比尔-朗伯定律形式为:
当中, 为透射率,
为物质的吸光系数,
为浓度,
为光程距离。
若物体是均质的,那么 和
为常量,可以看到(1)是服从指数衰减的。例如,若光通过距离
会衰减成原来的 50%,那么通过
的话就会衰减成原来的 25%。
在这里应用时,由于 和
为物理上的单位,而它们又是常数,我们可用单个参数
去表示材质对吸收的光收特性:
下图展示 分别为 1, 2, 3, 4, 5 时,距离
和透射率
的关系:

2. 实现
实现只需三步。第一步,简单把 (2) 写成一行 C 函数:
float beerLambert(float a, float d) {
return expf(-a * d);
}
第二步,在场景定义中加入吸收率:
typedef struct { float sd, emissive, reflectivity, eta, absorption; } Result;
第三步,追踪到表面时,依追踪距离计算吸收率,乘以从那个方向得到的光强总量:
float trace(float ox, float oy, float dx, float dy, int depth) {
// ...
for (int i = 0; i < MAX_STEP && t < MAX_DISTANCE; i++) {
// ...
if (r.sd * sign < EPSILON) {
float sum = r.emissive;
// 计算反射和折射
return sum * beerLambert(r.absorption, t); // <- 只改这一行
}
// ...
}
// ...
}
我们把一个长方形设置 ,和之前的结果比较:
Result scene(float x, float y) {
Result a = { circleSDF(x, y, -0.2f, -0.2f, 0.1f), 10.0f, 0.0f, 0.0f, 0.0f };
Result b = { boxSDF(x, y, 0.5f, 0.5f, 0.0f, 0.3, 0.2f), 0.0f, 0.2f, 1.5f, 4.0f };
return unionOp(a, b);
}

可以清楚看到,图2(b)中光线通过物体表面后,距离越远就变得越暗。
不过,单色的效果不太好看,如果物质对不同波长的吸收率不一样,效果就会更明显了。
3. 色彩
为了简单起见,之前我们一直只是用单色光,生成灰阶图像。我们使用 RGB 色彩模型,把色彩定义为三维矢量 ,并需要三个运算:加法、乘法(哈达马积/Hadamard product)及缩放(乘以纯量),实现如下:
#define BLACK { 0.0f, 0.0f, 0.0f }
typedef struct { float r, g, b; } Color;
Color colorAdd(Color a, Color b) {
Color c = { a.r + b.r, a.g + b.g, a.b + b.b };
return c;
}
Color colorMultiply(Color a, Color b) {
Color c = { a.r * b.r, a.g * b.g, a.b * b.b };
return c;
}
Color colorScale(Color a, float s) {
Color c = { a.r * s, a.g * s, a.b * s };
return c;
}
为了方便,上面还定义了一个 的宣代表黑色
的初始值。
然后,我们把想要支持色彩的场景定义参数(如自发光和吸收率)的类型,从 改为
:
typedef struct {
float sd, reflectivity, eta;
Color emissive, absorption;
} Result;
实际上, 和
都可以支持色彩,不过暂时本文不作这支持。
然后, 、
和
函数都改为返回
类型。我们甚至可以通过编译的错误信息,来找到需要修改的代码,以下展示了这些改动:
Color beerLambert(Color a, float d) {
Color c = { expf(-a.r * d), expf(-a.g * d), expf(-a.b * d) };
return c;
}
Color trace(float ox, float oy, float dx, float dy, int depth) {
// ...
for (int i = 0; i < MAX_STEP && t < MAX_DISTANCE; i++) {
// ...
if (r.sd * sign < EPSILON) {
Color sum = r.emissive;
if (depth < MAX_DEPTH && r.eta > 0.0f) {
// ...
if (r.eta > 0.0f) {
if (refract(/* ... */) {
// ...
sum = colorAdd(sum, colorScale(trace(x - nx * BIAS, y - ny * BIAS, rx, ry, depth + 1), 1.0f - refl));
}
// ...
}
if (refl > 0.0f) {
// ...
sum = colorAdd(sum, colorScale(trace(x + nx * BIAS, y + ny * BIAS, rx, ry, depth + 1), refl));
}
}
return colorMultiply(sum, beerLambert(r.absorption, t));
}
// ...
}
Color black = BLACK;
return black;
}
Color sample(float x, float y) {
Color sum = BLACK;
for (int i = 0; i < N; i++) {
float a = TWO_PI * (i + (float)rand() / RAND_MAX) / N;
sum = colorAdd(sum, trace(x, y, cosf(a), sinf(a), 0));
}
return colorScale(sum, 1.0f / N);
}
最后,在输出每个像素时,分别顺序写入R、G 和 B 通道,就能生成彩色图像:
int main() {
unsigned char* p = img;
for (int y = 0; y < H; y++)
for (int x = 0; x < W; x++, p += 3) {
Color c = sample((float)x / W, (float)y / H);
p[0] = (int)(fminf(c.r * 255.0f, 255.0f));
p[1] = (int)(fminf(c.g * 255.0f, 255.0f));
p[2] = (int)(fminf(c.b * 255.0f, 255.0f));
}
// ...
}
我们加入一个新的正多边形 SDF(本文暂不阐述),并让它吸收更多的绿色和红色:
Result scene(float x, float y) {
Result a = { circleSDF(x, y, 0.5f, -0.2f, 0.1f), 0.0f, 0.0f, { 10.0f, 10.0f, 10.0f }, BLACK };
Result b = { ngonSDF(x, y, 0.5f, 0.5f, 0.25f, 5.0f), 0.0f, 1.5f, BLACK, { 4.0f, 4.0f, 1.0f} };
return unionOp(a, b);
}
渲染结果:

4. 结语
本文用了比尔-朗伯定律模拟了光线被物体吸收,可以模拟一些透明(有色)物体。而真实世界中,一些粒子除了吸收光,也会散射至其他方向,其模拟会复杂很多。
另外,本文也讲解如何把单色渲染改为彩色渲染,作为练习,读者也可把反射及折射率加入彩色的处理,不过折射率的改动会多一些,留给读者思考。
我们一连三篇模拟了三个物理定律(斯涅尔、菲涅耳、比尔-朗伯),下一篇我们换一个话题。
本文的源代码位于 beerlambert.c、beerlambert_color.c 及 heart.c。
参考
[1] Akenine-Möller, Tomas, Eric Haines, and Naty Hoffman. Real-time rendering, Third Edition. CRC Press, 2008.