本系列文章是对 http://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,你应该会看到类似的图像:
现在我们创建一个函数命名为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
函数,我们重复空间填满整个屏幕,实际上创建了一个无限数量的球体,每一个都带着自己的(重复的)射线.当然,我们将只看被屏幕的x
和y
坐标之内的那些,然而,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,你应该会看到类似的图像:
下一步,我们让场景动起来!我们在part 12中已经看到如何发送uniforms变量比如time
到GPU
,所以我们就不再重复了.
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
到所有三个坐标,但我们只让x
和y
起伏变化而z
保持直线.1.
部分只是为了阻止摄像机撞到最近的球体上.要看这份代码的动画效果,我在下面使用一个Shadertoy
嵌入式播放器.只要把鼠标悬浮在上面,并单击播放按钮就能看到动画:<译者注:不支持嵌入播放器,我用gif代替https://www.shadertoy.com/embed/XtcSDf>
感谢 Chris的协助.
源代码source code已发布在Github上.
下次见!