Milo的编程
首发于Milo的编程
用 C 语言画光(二):构造实体几何

用 C 语言画光(二):构造实体几何

在《用 C 语言画光(一):基础》中,我们画了一个发光的圆形,那么,该如何画更多形状呢?

1. 场景函数

重温一下,之前的光线步进实现是这样的:

float trace(float ox, float oy, float dx, float dy) {
    float t = 0.0f;
    for (int i = 0; i < MAX_STEP && t < MAX_DISTANCE; i++) {
        float sd = circleSDF(ox + dx * t, oy + dy * t, /*...*/); // SDF
        if (sd < EPSILON) 
            return 2.0f; // 自发光
        t += sd;
    }
    return 0.0f;
}

在步进的过程中,我们要取得当前采样点与场景形状的带符号距离(signed distance),当少于阈值,就要返回该形状的自发光强度(emissive)。我们首先进行代码重构(code refactoring),把场景采样功能提取成一个函数(extract function),它返回 SDF 和自发光。

typedef struct { float sd, emissive; } Result;

Result scene(float x, float y) {
    Result r = { circleSDF(x, y, 0.5f, 0.5f, 0.1f), 2.0f };
    return r;
}

float trace(float ox, float oy, float dx, float dy) {
    float t = 0.0f;
    for (int i = 0; i < MAX_STEP && t < MAX_DISTANCE; i++) {
        Result r = scene(ox + dx * t, oy + dy * t); // <-
        if (r.sd < EPSILON)                         // <-
            return r.emissive;                      // <-
        t += r.sd;                                  // <-
    }
    return 0.0f;
}

那么,我们之后改动场景时就不用更改 \texttt{trace()} 函数,只需修改 \texttt{scene()}

2. 加入多个形状

我们在上一篇谈到, \phi(\mathbf{x}) 表示坐标 \mathbf{x} 与场景形状的最近距离。如果场景中有两个形状 A 和 B,它们的 SDF 分别为为 \phi_A(\mathbf{x})\phi_B(\mathbf{x}) ,那么坐标 \mathbf{x} 与这两个形状的最近距离,就是两个 SDF 的最小值,即 \min(\phi_A(\mathbf{x}), \phi_B(\mathbf{x})) 。这个操作可以定义为,形状 A 和 B 的并集(union) A \cup B ,其 SDF 为:

\phi_{A \cup B}(\mathbf x) = \min\left(\phi_A(\mathbf x), \phi_B(\mathbf x)\right)

实现也很简单:

Result unionOp(Result a, Result b) {
    return a.sd < b.sd ? a : b;
}

我们加入 3 个圆形试试,每个圆形的自发光值都不同:

Result scene(float x, float y) {
    Result r1 = { circleSDF(x, y, 0.3f, 0.3f, 0.10f), 2.0f };
    Result r2 = { circleSDF(x, y, 0.3f, 0.7f, 0.05f), 0.8f };
    Result r3 = { circleSDF(x, y, 0.7f, 0.5f, 0.10f), 0.0f };
    return unionOp(unionOp(r1, r2), r3);
}

运行结果:

我们发现,此图形增添了一个视觉上的特徵──阴影!这是因为,进行光线步进时,追踪至非常接近形状表面时便会停下来,不会继续穿透形状,所以形状之间是有遮挡关系的,也就形成了阴影。而且因为光源是线条(此例子中是圆孤边界)而不是一点,所以会产生半影(penumbra)的效果。

3. 构造实体几何

采用 SDF 来表示形状,还可以简单地实现构造实体几何(constructive solid geometry),即是以形状点集的布尔操作来表示模型。3 个基本运算为并集(union)、交集(intersection)和相对补集(relative complement,或称为 set difference):

\begin{align} \phi_{A \cup B}(\mathbf x) &= \min\left(\phi_A(\mathbf x), \phi_B(\mathbf x)\right)\\ \phi_{A \cap B}(\mathbf x) &= \max\left(\phi_A(\mathbf x), \phi_B(\mathbf x)\right)\\ \phi_{A \setminus B}(\mathbf x) &= \max\left(\phi_A(\mathbf x), -\phi_B(\mathbf x)\right)\\ \end{align}

注意,相对补集运算为非可交换的(non-commutative)。另外,涉及凹的形状时,得出的 SDF 并不代表最近距离,但它的估值是保守的,我们仍然可以用光线步进方法来找出边界。

实现时,要注意除了计算结果的 SDF,还要考虑采用哪个形状的特性(暂时只有自发光)。对于交集,我们要采用相反的形状;对于相对补集则采用左运算元的形状:

Result unionOp(Result a, Result b) {
    return a.sd < b.sd ? a : b;
}

Result intersectOp(Result a, Result b) {
    Result r = a.sd > b.sd ? b : a;
    r.sd = a.sd > b.sd ? a.sd : b.sd;
    return r;
}

Result subtractOp(Result a, Result b) {
    Result r = a;
    r.sd = (a.sd > -b.sd) ? a.sd : -b.sd;
    return r;
}

做一个测试场景,左方是较亮的圆形,右方是较暗的圆形:

Result scene(float x, float y) {
    Result a = { circleSDF(x, y, 0.4f, 0.5f, 0.20f), 1.0f };
    Result b = { circleSDF(x, y, 0.6f, 0.5f, 0.20f), 0.8f };
    return unionOp(a, b);
    // return intersectOp(a, b);
    // return subtractOp(a, b);
    // return subtractOp(b, a);
}

把 4 个语句逐一取消注释,运行结果:

4. 结语

通过 CSG,可以把各种形状组合成不同的形状,这在运算在工业用的建模工具非常常见。你可以尝试做不同的形状,例如齿轮、花朵等。下一篇会讲述更多形状的 SDF 定义和相关运算。

本文的完整代码位于 csg.c

编辑于 2017-12-06

文章被以下专栏收录