朝花夕拾

轻飘飘的旧时光就这么溜走 转头回去看看时已匆匆数年

用 raymarching 技术生成精美的 3D 分形图像

2019-02-21


shadertoy 是一个非常神奇的网站,上面有不少大神级的用户和许多令人拍案叫绝的 shader 程序。我认为这个网站的一大贡献就是极大的推广和科普了 raymarching 的技术,使得更多的人可以掌握如何用 shader 程序生成精美的艺术图片。

在潜水一段时间以后,我用在 shadertoy 上学到的知识写了一个 python 版本的 shader 小程序,可以生成非常精美的分形图片,以下是几个例子:

这些分形都是一种叫做 "pseudo Kleinian" 分形的变体,其原理是一个简单的折叠函数的反复迭代,其中加入了各种变化。程序的代码在 pywonderland 项目里,其中一幅图的 shadertoy 版本在这里

本文接下来的部分就是聊聊这个 shader 程序是怎么制作出来的。由于即便是最简单的 ranymarching 程序,其涉及到的基础知识也是非常多的,这里不可能面面俱到,所以只是聊个大概。

首先 shader 程序是一段运行在 GPU 上的代码,用 GLSL 语言写成 (OpenGL shading language 的简写)。如果我们要让写好的 shader 代码运行起来的话,就必须先把它编译然后发送到 GPU 去。这个步骤如果仅用原生的 OpenGL 底层函数来做的话相当繁琐,shadertoy 这个网站实际上帮你做了这一步,省去了你自己编译的功夫。几乎所有编程语言都有对应的 OpenGL 的封装库来帮你做这件事情,python 也有 (并不好用),但是我觉得调用别人封装的前提是要知道背后发生了什么,所以我自己写了一个 shader.py 放在 /glslhelpers/ 目录下来编译项目用到的 glsl 代码。

其次写一个 raymarching 程序的前提是知道 raymarching 这个技术的基本原理。这方面本文不打算展开多写,你可以参考这篇不错的入门文章,简单地说,raymarching 技术的基础是一个函数 DE,其作用是计算空间中每一点 \(p\) 到场景中的距离 (DE 是 distance estimation 的简写),这样我们就得到了一个距离场 (disance field),其在空间中每一点的值是该点到场景的距离。举个例子:假设原点处有一个半径为 \(r\) 的球,则空间中任何一点 \(p\) 到这个球的距离为 \(|p| - r\),于是这个 DE 函数就是

float DE(vec3 p)
{
    return length(p) - r;
}

那如果是一个位于一点 \(a\) 处的半径为 \(r\) 的球呢?很简单,将返回值改成

return length(p - a) - r;

就行。

那如果场景中再加入一个 \(xy\) 平面呢?也很简单,我们知道空间中一点 \(p\)\(xy\) 平面的距离是

abs(p.z)

\(p\) 到场景的距离是其到场景中各物体距离的最小者,于是这时的 DE 函数为

float DE(vec3 p)
{
    return min(abs(p.z), length(p) - r);
}

有了 DE 这个函数,我们所有的渲染信息都可以从这个距离场求出来。比如说一点 \(p\) 处的单位法线可以直接通过对距离场求梯度差分得到:

vec3 calcNormal(vec3 p)
{
    const vec2 e = vec2(0.001, 0.0);
    return normalize(vec3(
              DE(p + e.xyy) - DE(p - e.xyy),
              DE(p + e.yxy) - DE(p - e.yxy),
              DE(p + e.yyx) - DE(p - e.yyx)));
}

这在计算光照时要用到。

另外还需要实现一个 trace 函数,用于计算从相机出发的一条视线到场景的距离,根据这个距离可以确定这条视线与场景的交点是在哪个物体上,从而由此确定如何渲染这个点:

float trace(vec3 ro, vec3 rd)
{
    float t = MIN_TRACE_DIST;
    float h;
    for (int i = 0; i < MAX_TRACE_STEPS; i++)
    {
        h = DE(ro + rd * t);
        if (h < PRECISION * t || t > MAX_TRACE_DIST)
        return t;
        t += h;
    }
    return -1.0;
}

这里如果在超过一定的循环次数后仍然未命中场景中的物体则返回 -1,即看到的是某种背景。

此外是一些渲染的技巧,比如光照模型,柔和阴影 (soft shadow),环境光遮蔽 (ambient occlusion),雾化 (fog),焦点模糊 (focal blur) 等等等等。掌握这些技巧对于增强场景的真实感都是必须的。

最后是你要知道如何放置相机和屏幕。这个是比较简单的线性代数的知识,一般来说常用的方法是这样的:指定三个向量 camera, lookatup,建立视角坐标系,然后将屏幕放置在相机前一定的距离 (相当于指定 fov),然后将每一条视线转换为场景坐标系中的向量。

这个过程大致如下:

  1. 将屏幕上的像素点对应的坐标变换到 \([-1, 1]\) 范围内,并调整宽高比,使得宽度范围为 \([-1, 1]\)。这里 gl_FragCoord 是内置的常量,返回的是像素点的坐标,iResolution 是屏幕的大小。

    vec2 uv = gl_FragCoord.xy / iResolution.xy;
    uv = 2.0 * uv - 1.0;
    uv.x *= iResolution.x / iResolution.y;
  2. 对给定的 camera, lookat, up 向量,建立从视角坐标系到场景坐标系的变换矩阵:

mat3 viewMatrix(vec3 camera, vec3 lookat, vec3 up)
{
    vec3 f = normalize(lookat - camera);
    vec3 r = normalize(cross(f, up));
    vec3 u = normalize(cross(r, f));
    return mat3(r, u, -f);
}

这里屏幕右方为 \(x\) 轴,上方为 \(y\) 轴,屏幕向外为 \(z\) 轴。

  1. 最后将屏幕放置在相机前一定距离的位置处,比如距离为 2 的地方:
vec3 rd = M * normalize(vec3(uv, -2.0));

这里 rd 代表 ray direction,M 是第二步建立的变换矩阵,由于屏幕宽度范围为 \([-1, 1]\),所以屏幕放在相机前距离为 2 的地方代表 fov 角为 45 度,这也是大多数程序的默认值。

以上就是对一个 raymarching 程序的非常粗略的概扩,但是我想对读者理解前面分形图的代码还是有一些帮助的。