[MetalKit]30-Raymarching-in-Metal射线行进

648 阅读6分钟

本系列文章是对 metalkit.org 上面MetalKit内容的全面翻译和学习.

MetalKit系统文章目录


Raymarching射线步进 是一种用在实时图形的快速渲染方法.几何体通常不是传递到渲染器的,而是在着色器中用Signed Distance Fields (SDF) 函数来创建的,这个函数用来描述场景中一个点到物体的一个面之间的最短距离.当点在物体内部时SDF函数返回一个负数.SDFs非常有用,因为它让我们减少了Ray Tracing射线追踪的采样数.类似于Ray Tracing射线追踪,在Raymarching中我们也有从观察平面的每个像素发出的射线,每条射线被用来确定是否和某个物体相交.

这两种技术的不同在于,在射线追踪中是用严格的方程组来确定相交的,而在Raymarching中相交是估算的.用SDFs我们可以沿着射线步进直到我们离某个物体过近.这种方法相比准确确定相交来说花费的计算不算多,当场景有很多物体并且光照很复杂时,准确确定相交代价很大.Raymarching另一大应用场景是体积渲染(雾,水,云),这些用Ray Tracing射线追踪不好做因为确定和这些的相交非常困难.

我们可以用 Using MetalKit part 10中的playground来继续下去,下面会解释这些明显的改动.让我们从两个基本构建块开始,这是我们在内核用到的最小单元:一个射线和一个物体(球体).

struct Ray {
    float3 origin;
    float3 direction;
    Ray(float3 o, float3 d) {
        origin = o;
        direction = d;
    }
};

struct Sphere {
    float3 center;
    float radius;
    Sphere(float3 c, float r) {
        center = c;
        radius = r;
    }
};

因为我们是从第10部分开始写的,那我们还要写一个SDF来计算从一个给定的点到球体的距离.与原有函数不同之处在于,我们现在的点是沿着射线marching步进的,所以我们用射线位置来代替:

float distToSphere(Ray ray, Sphere s) {
    return length(ray.origin - s.center) - s.radius;
}

我们需要做的是计算从一个给定点到一个圆(不是球体因为我们还没有3D化)的距离,像这样:

float dist(float2 point, float2 center, float radius) {
    return length(point - center) - radius;
}

...
float distToCircle = dist(uv, float2(0.), 0.5);
bool inside = distToCircle < 0.;
output.write(inside ? float4(1.) : float4(0.), gid);
...

我们现在需要有一个射线,并沿着它步进穿过场景,所以用下面几行替换内核中的最后三行:

Sphere s = Sphere(float3(0.), 1.);
Ray ray = Ray(float3(0., 0., -3.), normalize(float3(uv, 1.0)));
float3 col = float3(0.);
for (int i=0.; i<100.; i++) {
    float dist = distToSphere(ray, s);
    if (dist < 0.001) {
        col = float3(1.);
        break;
    }
    ray.origin += ray.direction * dist;
}
output.write(float4(col, 1.), gid);

让我们一行一行来看这些代码.我们首先创建了一个球体和一个射线.注意射线的z值接近于0时,球体看起来更大因为射线离场景更近,相反,当它远离0,球体看上去更小了,原因很明显-我们用射线作为了隐性摄像机.下面我们定义颜色来初始化一个纯黑色.现在raymarching最精华的地方来了!我们循环一定次数(步数)来确保我们行进足够细腻.我们在这里用100,但你可以尝试一个更大数值的步数,来观察渲染图像的质量的改善,当然也会消耗更多的计算资源.在循环里,我们计算当前位置沿射线到场景的距离,同时也检查我们是否接触到了场景中的物体,如果接触到了就将其着色为白色并跳出循环,否则就更新射线位置向场景前进一些.

注意我们规范化了射线方向来覆盖边缘情况,例如向量(1,1,1)(屏幕边角)的长度会是sqrt(1 * 1 + 1 * 1 + 1 * 1)即大约1.732.这意味着我们需要向前移动射线位置大约1.73*dist,也就是大约我们需要前进距离的两倍,这可能会让我们因为超过射线交点而错过/穿过物体.为此,我们规范化了方向,来确保它的长度始终是1.最后,我们将颜色写入到输出纹理中.如果你现在运行playground,你应该会看到类似的图像:

raymarching1.png

现在我们创建一个函数命名为distToScene,它接收一个射线作为参数,因为我们现在卷尺的是找到包含多个物体的复杂场景中的最短距离.下一步,我们移动球体相关的代码到新函数内,只返回到球体的距离(暂时).然后,我们改变球体位置到(1,1,1),半径0.5,这意味着球体现在在0.5 ... 1.5范围内.这里有个巧妙的花招来做例子:如果我们在0.0 ... 2.0内重复空间,则球体总是处于内部.下一步,我们做个射线的本地副本,并对原始值取模.然后我们用重复的射线代入distToSphere()函数.

float distToScene(Ray r) {
    Sphere s = Sphere(float3(1.), 0.5);
    Ray repeatRay = r;
    repeatRay.origin = fmod(r.origin, 2.0);
    return distToSphere(repeatRay, s);
}

通过使用fmod函数,我们重复空间填满整个屏幕,实际上创建了一个无限数量的球体,每一个都带着自己的(重复的)射线.当然,我们将只看被屏幕的xy坐标之内的那些,然而,z坐标将让我们看到球体是如何进到无限深度的.在内核中,移除球体代码,将射线移到很远的位置,修改dist来给我们留出到场景的距离,最后修改最后一行来显示更好看的颜色:

Ray ray = Ray(float3(1000.), normalize(float3(uv, 1.0)));
...
float dist = distToScene(ray);
...
output.write(float4(col * abs((ray.origin - 1000.) / 10.0), 1.), gid);

我们将颜色与射线位置相乘.除以10.0因为场景相当大,射线位置在大部分地方会大于1.0,这会让我们看到纯白色.我们用abs()因为屏幕左边的x小于0,它会让我们看到纯黑色,所以我们只需镜像上/下和左/右的颜色.最后,我们偏移射线位置100,以匹配射线起点(摄像机).如果你现在运行playground,你应该会看到类似的图像:

raymarching2.png

下一步,我们让场景动起来!我们在part 12中已经看到如何发送uniforms变量比如timeGPU,所以我们就不再重复了.

float3 camPos = float3(1000. + sin(time) + 1., 1000. + cos(time) + 1., time);
Ray ray = Ray(camPos, normalize(float3(uv, 1.)));
...
float3 posRelativeToCamera = ray.origin - camPos;
output.write(float4(col * abs((posRelativeToCamera) / 10.0), 1.), gid);

我们添加time到所有三个坐标,但我们只让xy起伏变化而z保持直线.1.部分只是为了阻止摄像机撞到最近的球体上.要看这份代码的动画效果,我在下面使用一个Shadertoy嵌入式播放器.只要把鼠标悬浮在上面,并单击播放按钮就能看到动画:<译者注:这里不支持嵌入播放器,我用gif代替https://www.shadertoy.com/embed/XtcSDf>

raymarching.mov.gif

感谢 Chris的协助. 源代码source code已发布在Github上.

下次见!