Shadertoy学习 vol. 5 Ray Marching 基本原理与实现

result

从今天,我们的Shadertoy之旅才真正开始。这是因为之前的Path Tracing虽然效果很好,但是他的运行实在是太慢了,根本不可能在现在的计算机上实现实时的渲染。光为了一张图,我们就得花上几秒,甚至数十秒,要想做出动画的效果,我们得想想别的渲染方法。

为此,我们采用了一种新的叫做Ray Marching的渲染方法。

Ray Marching基本介绍

Ray Marching,中文叫做光线步进,正如他的名字一样,也是基于光线的渲染。他和Ray Tracing或者Path Tracing最大的区别在于求交的方法。在之前的Path Tracing中,我们自己写了求解光线是否与矩形或者球形相交的函数,如果我们用三角形来进行建模则需要大量求解光线是否与三角形相交。然而在光线步进当中,我们不再直接求解光线是否与物体相交,我们现在会使用SDF函数来求光线当前位置与物体的最小距离。如果距离为正,则未发生碰撞,如果距离为负,则光线在物体内部,如果距离为0,则恰好发生碰撞。Ray Marching最大的优势便在于渲染的高效和使用SDF来建模的方便程度。

Sphere Tracing

试想我们要开始写一个光线步进的算法,一开始我们可能会想成这样:

Method 1

但是现在会有一个问题,当我们的物体过小或者步长过大时,光线会直接穿过去:

Problem

这样渲染的时候物体就根本不会被显示。如果我们为了不错过物体而把步长设置的过小,计算量又会太大。

为此,我们需要一种能够实时调整步长的算法,既能让我们不会错过物体,又不会让我们的计算开销太大。而Sphere Traching就是这样的一种算法。

Sphere Tracing

我们在起点使用SDF检测最近距离,然后将当前的最近距离设为步长。接下来一直重复这两步,直到最小距离等于0或小于我们设置的一个Threshold。

基础知识都知道了,让我们开始编程实现吧。

SDF获得最近距离

我们需要一个函数,输入一个点的坐标,返回离这个点最近的物体的距离。

设点的坐标\(P\left ( P_{x}, P_{y}, P_{z} \right )\),球体的圆心为\(S\left ( S_{x}, S_{y}, S_{z} \right )\),半径为\(R\)。易证:
$$d_{\text {Sphere}}=\operatorname{length}(P-S)-r$$

除了球体,我们还设置了一个平面,SDF公式非常简单:
$$d_{\text {Plane}}=P_{y}$$

代码为这样:

1
2
3
4
5
6
7
8
9
10
11
12
float GetDist(vec3 p)
{
vec4 s = vec4(-1.0, 1, 6, 1.3);
float sphereDist = length(p - s.xyz) - s.w;
float planeDist = p.y;

vec4 s1 = vec4(1.0, 0.5, 6, 0.9);
float sphereDist2 = length(p - s1.xyz) - s1.w;

float d = min(sphereDist, min(planeDist, sphereDist2));
return d;
}

法线

这一部分用到了梯度求解法线的方法,设求最近距离$d=f(x, y, z)$,其中$(x, y, z)$为世界坐标,$d$为距离,$f$是我们的GetDist函数。
当点$P$位于任意物体表面时,我们可以靠计算梯度得到表面向量$N$:
$$N=f_{x}\left(x_{p}, y_{p}, z_{p}\right) i+f_{y}\left(x_{p}, y_{p}, z_{p}\right) j+f_{z}\left(x_{p}, y_{p}, z_{p}\right) k$$
其中$i, j, k$为$x, y, z$方向的单位向量。

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
vec3 GetNormal(vec3 p)
{
float d = GetDist(p);
vec2 e = vec2(0.01f, 0.0f);

vec3 n = d - vec3
(
GetDist(p - e.xyy),
GetDist(p - e.yxy),
GetDist(p - e.yyx)
);
return normalize(n);
}

计算光线

这里用的是最简单的Diffuse Lighting, 也叫兰伯特光照模型:

$$\boldsymbol{c}_{\text {diffuse}}=\left(\boldsymbol{c}_{\text {light}} \cdot \boldsymbol{m}_{\text {diffuse}}\right) \max (0, \hat{\boldsymbol{n}} \cdot \hat{\boldsymbol{l}})$$
这里的$N$就是我们之前靠梯度算出来的法线向量。
1
2
3
4
5
6
7
8
9
10
11
12
13
float GetLight(vec3 p)
{
vec3 lightPos = vec3(0, 5, 6);
lightPos.xz += vec2(sin(iTime),cos(iTime))*2.0;
vec3 l = normalize(lightPos - p);
vec3 n = GetNormal(p);

float dif = clamp(dot(n, l), 0.0f, 1.0f);

float d = RayMarch(p + n * SURF_DIST * 2.0f, l);
if (d < length(lightPos - p)) dif*=0.1;
return dif;
}

10到11行里阴影的实现方法非常有趣,当物体上任意一点$P$向光源RayMarch时得到的距离小于该点到光源的直线距离时,说明该点和光源之间一定存在物体遮挡住了光线。所以我们将这点的颜色值乘以0.1,以表示阴影。

Raymarch部分

1
2
3
4
5
6
7
8
9
10
11
12
13
float RayMarch(vec3 ro, vec3 rd)
{
float d0 = 0.0f;
for (int i = 0; i < MAX_STEPS; i++)
{
vec3 p = ro + rd * d0;
float ds = GetDist(p);
d0+=ds;

if (d0 > MAX_DIST || ds < SURF_DIST) break;
}
return d0;
}

主函数部分

这里没有什么难的部分,我们就是定义了相机和初始光线的方向,然后走了一遍我们的渲染管线,最后给像素颜色附了值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void main()
{
vec2 uv = (gl_FragCoord.xy - 0.5f * iResolution.xy) / iResolution.y;
vec3 color = vec3(0);

vec3 ro = vec3(0, 1, 0);
vec3 rd = normalize(vec3(uv.x, uv.y, 1.0f));

float d = RayMarch(ro, rd);

vec3 p = ro + rd * d;
float dif = GetLight(p);

color = vec3(dif);
gl_FragColor = vec4(color, 1.0);
}

结尾

关于Ray Marching的基础介绍就到这里了,下一篇会介绍更多关于SDF函数和过程化建模的知识。再见!

Comments