Milo的编程
首发于Milo的编程
用C语言画光(七):比尔-朗伯定律

用C语言画光(七):比尔-朗伯定律

(五)折射(六)菲涅耳方程 里,我们谈及光怎样穿过表面,透射至物体内部。我们假设了光在物体中传播时不会衰减。然而,除了真空,光线通过不同物质都会被散射和吸收,例如我们看到天空是蓝色的,也是因为不同波长的光被空气粒子散射程度不一样所致;如果阳光没有被空气粒子散射,天空应该是透明的。

本文描述一种简单方法去模拟光被材质吸收,也会首次尝试加入色彩。

1. 比尔-朗伯定律

比尔-朗伯定律(Beer-Lambert law)描述电磁波(如可见光)通过物体时,物体吸收部分电磁波,而吸收率与物体的厚度(光程距离)、物质的吸光系数及其浓度相关。[1] P.393 给出的比尔-朗伯定律形式为:

T = e^{-\alpha'cd}\tag{1}

当中, T\in[0,1] 为透射率, \alpha' \in [0, \infty) 为物质的吸光系数, c \in [0, \infty) 为浓度, d \in [0, \infty) 为光程距离。

若物体是均质的,那么 \alpha'c 为常量,可以看到(1)是服从指数衰减的。例如,若光通过距离 \Delta d 会衰减成原来的 50%,那么通过 2\Delta d 的话就会衰减成原来的 25%。

在这里应用时,由于 \alpha'c 为物理上的单位,而它们又是常数,我们可用单个参数 a = \alpha'c 去表示材质对吸收的光收特性:

T = e^{-ad}\tag{2}

下图展示 a 分别为 1, 2, 3, 4, 5 时,距离 d 和透射率 T 的关系:

图 1:指数衰减

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); // <- 只改这一行
        }
        // ...
    }
    // ...
}

我们把一个长方形设置 a=4 ,和之前的结果比较:

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:开启比尔-朗伯定律的前后比较

可以清楚看到,图2(b)中光线通过物体表面后,距离越远就变得越暗。

不过,单色的效果不太好看,如果物质对不同波长的吸收率不一样,效果就会更明显了。

3. 色彩

为了简单起见,之前我们一直只是用单色光,生成灰阶图像。我们使用 RGB 色彩模型,把色彩定义为三维矢量 \mathbb{R}^3 ,并需要三个运算:加法、乘法(哈达马积/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;
}

为了方便,上面还定义了一个 \texttt{BLACK} 的宣代表黑色 \texttt{Color} 的初始值。

然后,我们把想要支持色彩的场景定义参数(如自发光和吸收率)的类型,从 \texttt{float} 改为 \texttt{Color}

typedef struct {
    float sd, reflectivity, eta;
    Color emissive, absorption;
} Result;

实际上, \texttt{reflectivity}\texttt{eta} 都可以支持色彩,不过暂时本文不作这支持。

然后, \texttt{beerLambert()}\texttt{trace()}\texttt{sample()} 函数都改为返回 \texttt{Color} 类型。我们甚至可以通过编译的错误信息,来找到需要修改的代码,以下展示了这些改动:

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.cbeerlambert_color.cheart.c

参考

[1] Akenine-Möller, Tomas, Eric Haines, and Naty Hoffman. Real-time rendering, Third Edition. CRC Press, 2008.

编辑于 2017-12-11

文章被以下专栏收录