写一个简单的 Ray Tracer

首先我们需要了解人眼看到颜色的原理。想象一束混合了红色绿色蓝色光子(photon)的白光,照射到了红色表面,其中绿色蓝色光子都被吸收了,只有红色光子被反射了,在众多反射方向中,有那么一束进入到了我们的眼睛,这就是我们看到物体表面是红色的原因。根据物体表面材质的不同,光子被吸收反射的比例也不同。

基于这一点,光线追踪这种绘制图像的算法出现了。按照上述的思路从光源到物体表面再到人眼叫做 forward ray-tracing。这种思路的缺陷是很明显的,光子在物体表面向很多方向反射,只有极小一部分进入人眼,大部分计算都被浪费了。因此,与之相对的另一种思路成为了更好的选择。

Backward Ray-tracing

让我们将整条路径反转,现在我们的视线,称作 primary-ray 到达了物体表面(如果没有被其他物体遮挡),在交点处向着光源发射另一条 shadow-ray,如果没有被其他物体遮挡,交点处的颜色就是物体表面颜色,反之则是阴影。

Ray Tracer 1.0

在我们的基础版本中唯一比较困难的就是视线和物体表面交点的计算。 我们在场景中选用球体(大多数 DEMO 也是这么做的),计算交点和法线相对方便。让我们来复习一下几何和线性代数的知识。

Sphere intersection

关于射线和球面计算焦点的问题,IQ 大神在博客上吐槽过很多开发者只会复制粘贴,并不了解其中原理,导致复制的代码中包含 2 4 这样的 Magic Number,也不知道精简一下。

下面来自 scratch a pixel 的图能很清晰的说明:

我们的视点在 ,视线方向单位向量是 ,视线这条射线可以表示为 ,而射线和圆的两个交点 可以表示为: \begin{array}{l} P = {O+t_{0}D}\\P’ = {O+t_{1}D} \end{array}

因此,只要得到 : \begin{array}{l} t_{0}=t_{ca}-t_{hc}\\t_{1}=t_{ca}+t_{hc} \end{array}

其中 是很容易获得的。还记得向量点乘的含义吗, 就是 上的投影: \begin{array}{l} L=C-O\\t_{ca}=L \bullet D \end{array}

有了 利用勾股定理,很容易得到 \begin{array}{l} d^2+t_{ca}^2=L^2\\d=\sqrt{L^2-t_{ca}^2}=\sqrt{L \bullet L - t_{ca} \bullet t_{ca} }\\t_{hc}=\sqrt{radius^2-d^2} \end{array}

除了使用几何知识,使用线性代数同样可以得到,IQ 大神吐槽的被广泛复制粘贴的方法实际上使用的是这种分析方式。

了解了算法思想,用 glsl 实现就很容易了。注意 out 修饰符的用法,类似传入引用的地址将方法的多个返回值传递出来:

bool intersect(in vec3 rayorig, in vec3 raydir,
                in vec3 center, in float radius,
                out float t0, out float t1) {
    vec3 l = center - rayorig;
    float tca = dot(l, raydir);
    if (tca < 0.0) return false;
    float d2 = dot(l, l) - tca * tca;
    if (d2 > radius * radius) return false;
    float thc = sqrt(radius * radius - d2);
    t0 = tca - thc;
    t1 = tca + thc;

    return true;
}

构建场景

我们的场景中包含若干球体和光源。 在 fragment shader 中定义如下 uniform,包括球体的结构体。 在 JS 中向结构体数组传值还是比较麻烦的,并没有便捷的方式,需要依次获取数组中每个结构体的每个属性地址,然后传值:

#define SPHERE_NUM 2
uniform vec3 u_EyePosition;
uniform vec3 u_LightPosition;
uniform vec3 u_LightColor;
struct Sphere {
    vec3 center;
    float radius;
    vec3 surfaceColor;
};
uniform Sphere u_Spheres[SPHERE_NUM];

我们的第一版光线追踪实现如下。根据传入的 primary ray 的位置和方向,与场景中所有的球体进行交点检测。 如果没有和任何球体相交,就返回背景颜色。这里有一点需要注意,在依次检测和各个球体的交点时,我们需要知道最近的一个。

void main() {
    // 视点到当前 fragment 的方向
    vec3 eyeDirection = normalize(v_Position - u_EyePosition);
    // 追踪 primary ray
    gl_FragColor = vec4(trace(u_EyePosition, eyeDirection, 1), 1.0);
}
vec3 trace(in vec3 rayorig, in vec3 raydir) {
    vec3 color = vec3(0.0); // 最终返回
    Sphere intersectedSphere;
    bool intersected = false;
    float tnear = 10000.0;
    for (int i = 0; i < SPHERE_NUM; i++) {
        float t0 = 10000.0;
        float t1 = 10000.0;
        if (intersect(rayorig, raydir, u_Spheres[i].center, u_Spheres[i].radius, t0, t1)) {
            if (t0 < 0.0) t0 = t1;
            if (t0 < tnear) {
                // 保存最近的交点
                tnear = t0;
                intersectedSphere = u_Spheres[i];
                intersected = true;
            }
        }
    }
    // 没有看到任何球体,返回背景颜色
    if (!intersected) return color;
    // shadow ray 部分

接下来进行 shadow ray 的部分,此时射线起点变成了 primary ray 和球体的交点,射线方向朝向光源。 继续进行场景中的球体交点检测,如果碰到了球体,说明阻挡了光源,返回背景颜色即可。如果没有被阻挡,返回球体表面颜色。

// shadow-ray
    vec3 hitPoint = rayorig + raydir * tnear;
    vec3 lightDirection = normalize(u_LightPosition - hitPoint);
    for (int j = 0; j < SPHERE_NUM; j++) {
        float t0, t1;
        if (intersect(hitPoint, lightDirection, u_Spheres[j].center, u_Spheres[j].radius, t0, t1)) {
            return color;
        }
    }
    color += intersectedSphere.surfaceColor;
    return color;
}

第一版效果如下,有点奇怪是吧,不用担心我们会利用之前学到的「光照基础」继续改善效果:

漫反射 & 环境反射

在之前我们通过「光照基础」学习了物体表面漫反射和环境反射的计算方法,正好在此应用一下。

首先是最简单的环境反射:

float ambient = 0.1;
color += ambient * intersectedSphere.surfaceColor;

然后是稍微复杂一点的漫反射,这里需要使用 primary ray 和球面交点处的法线向量(知道为啥使用球面了吧):

vec3 hitNormal = normalize(hitPoint - intersectedSphere.center);
float diffuse = clamp(dot(hitNormal, lightDirection), 0.0, 1.0);
color += (diffuse + ambient) * intersectedSphere.surfaceColor;

这里有一点需要注意,在第一版中 shadow ray 求交失败后,我们直接返回了背景颜色也就是黑色,这里可以稍稍改进一下:

for (int j = 0; j < SPHERE_NUM; j++) {
    float t0, t1;
    if (intersect(hitPoint, lightDirection, u_Spheres[j].center, u_Spheres[j].radius, t0, t1)) {
        diffuse *= 0.2;
        break;
    }
}

改进后的效果如下:

后续改进

我们在「光照基础」中还学到了镜面反射,不禁让我们继续思考 shadow ray 的旅程其实还没有结束。当遇到镜面材质时会发生反射,当进入半透明物体时会发生折射,通过对这些情况的模拟,我们能实现更加逼真的渲染效果。这些就留到下一 Part 介绍吧。

参考资料

文中的数学公式使用 Mathjax 编辑。