光线步进和有符号距离函数(SDF)(一)

光线步进(Ray Marching)是一种十分有趣的着色技术,用它可以实时创建许多看起来很酷的视觉效果(比如分形),其中使用到了一种算法就叫有符号距离函数(Signed Distanced Functions)。

有符号距离函数

有符号距离函数,简写作SDFs,若传递给函数空间中的某个点的坐标,则返回那个点和某些平面之间的最短距离。返回值的符号表示点在平面的里面或者外面,故而叫做有符号距离函数,让我们来看几个例子。

想象在原点有一个球体。球体里的点和原点有小于半径的距离,球面上的点则有等于半径的距离,而球体外的点则有大于半径的距离。那么,我们的第一个SDF,对于一个在原点,半径为1的球体,函数将是这样:

我们可以代入某些点来试试,如:f(1,0,0)=0,f(0,0,0.5)=-0.5,f(0,3,0)=2。显然,点(1,0,0)是在球体表面的,点(0,0,0.5)是球体内的点且距离球体表面0.5个单位,而(0,3,0)是在球体外,并离球体表面最近的2个单位远。

使用HLSL代码时,最好将公式向量化,用欧几里得范数写作:

翻译成代码,则是这样:

float sphereSDF(float3 p) {

    return length(p) - 1.0;
}

如果想了解更多的SDFs,可以参阅Inigo_Quilez的https://iquilezles.org/www/articles/distfunctions/distfunctions.htm。

光线步进算法

当我们开始用SDF建模时,我们该如何渲染出来呢?这时候,光线步进算法就发挥作用了!

就像光线追踪,给相机定一个位置,然后在前面放一个网格,从相机的位置发射射线,穿过格子打到物体上,所成的像上的每一个像素对应的就是网格上的每一个点。

区别就在于如何定义场景,和光线追踪不同的是,光线步进需要找到视线和场景之间的边界。在光线追踪里,场景通常从显性几何的角度定义,比如:三角形、球体等等。在光线步进,为了找到视线和场景之间的边界,我们做了一系列几何相交测试:射线将相交在三角形的那些地方?如果全部相交了,那么在其他集合体的情况是?

补充:如果你还没有接触过光线追踪,可以参看闫老师的现代图形学入门和我的前几篇光线追踪专栏,如果你对英文阅读没有障碍,也可以参阅https://www.scratchapixel.com/lessons/3d-basic-rendering/introduction-to-ray-tracing/how-does-it-work。

在光线步进中,整个场景都会由一系列SDFs的角度定义。为了找到场景和视线之间的边界,我们从相机的位置开始,沿着视线,一点一点地移动一个点。每一步,都会问:“这个点在不在场景的某个表面内部?”换句话也就是说:“SDF函数在这个点的估值是负数吗?”如果是的,这一步就完成了,光线确实击中了某些东西。如果没有,我们就以某个更大的步长沿着射线移动这个点。

诚然,谨慎起见我们沿着视线一次可以只增加很小的增量,但事实上使用“球体追踪(sphere tracing)”技术可以做得更好(不管是在速度方面还是精度方面)。不必每次一小步一小步地检测,而是选取已知的没有穿过任何表面的最大步长,即SDF已经提供的到表面的最近距离!

在上图中,p0是相机的位置。蓝色的线代表从相机出发沿视线方向到表面结束的射线。我们可以看到,第一步就选的特别大,它恰是这一步到场景的表面的最近距离。因为表面上的点虽然是距离p0的最近距离,但它不沿着视线的方向,所以需要继续检测直到视线交于点p4。

实施在HLSL里,光线步进算法看起来是这样:

    float depth = start;
    for (int i = 0; i < MAX_MARCHING_STEPS; i++) {
        float dst = sceneSDF(eye + depth * viewRayDirection);
        if (dst < epsilon) {
            // We're inside the scene surface!
            return depth;
        }
        // Move along the view ray
        depth += dist;

        if (depth >= end) {
            // Gone too far; give up
            return end;
        }
    }
    return end;

如果在Unity的compute shader里实现,我们可以定义好相机的起点和方向,把代码做出一点点的调整,取表面以内为黄色:

[numthreads(8,8,1)]
void CSMain (uint3 id : SV_DispatchThreadID) {
    uint width, height;
    Destination.GetDimensions(width, height);
    Destination[id.xy] = Source[id.xy];
    float2 uv = float2( id.xy / float2(width, height) * 2.0f - 1.0f);
    Ray ray = CreateCameraRay(uv);
    float dst = shortestDistanceToSurface(ray.origin, ray.direction, minDst, maxDst);
    if (dst > maxDst - epsilon) {
        Destination[id.xy] = float4(0, 0, 0, 0);
        return;
    }
    Destination[id.xy] = float4(1, 1, 0, 0);
}

则会得到这样的图像:

表面法线和光照

在计算机图形中大多数的光照模型都用表面法线的概念计算在表面的某个给定点的材质应该是什么颜色。在表面被例如多边形的显性几何定义时,法线通常可由每个顶点指定,因而在一个面的给定点的法线可以通过插值周围顶点的法线获得。

那么我们又如何在由有符号距离函数定义的场景中找到表面法线呢?答案是使用梯度(gradient)。从概念上讲,函数f在点(x,y,z)的梯度表示了该点的函数变化趋势最大的方向,对于梯度的理解可以参看(https://www.zhihu.com/question/36301367)。这将作为平面的法线。

而直观的思路是:对于在物体表面上的点,函数f(SDF)的值取0,在表面以内的点,函数值为负数,在表面以外的点函数值取正数。所以让函数值从负到正的变化最快的方向应该和物体的表面正交。

函数f(x,y,z)的梯度记作▽f,你可以这样计算它:

但是我们不必真的去求微积分,而是对物体表面的点的周围点进行采样得到一个近似的解:

/**
 * Using the gradient of the SDF, estimate the normal on the surface at point p.
 */

float3 estimateNormal(float3 p) {

    return normalize(float3(
        sceneSDF(float3(p.x + EPSILON, p.y, p.z)) - sceneSDF(float3(p.x - EPSILON, p.y, p.z)),
        sceneSDF(float3(p.x, p.y + EPSILON, p.z)) - sceneSDF(float3(p.x, p.y - EPSILON, p.z)),
        sceneSDF(float3(p.x, p.y, p.z + EPSILON)) - sceneSDF(float3(p.x, p.y, p.z - EPSILON))
    ));
}

有了这些知识,我们就可以计算出表面上任何点的法线,然后应用到Phong反射模型,我们可以得到:

yi

移动相机

这部分不会讲太多,因为实现方法并不是光线步进独有的。就像在光线跟踪里,先定义出射线的原点和方向,然后定义初始化相机的射线,并对相机作矩阵变换:

Ray CreateRay(float3 origin, float3 direction)
{
    Ray ray;
    ray.origin = origin;
    ray.direction = direction;
    return ray;
}

Ray CreateCameraRay(float2 uv)
{
    float3 origin = mul(CameraToWorld, float4(0.0f, 0.0f, 0.0f, 1.0f)).xyz;
    float3 direction = mul(CameraInverseProjection, float4(uv, 0.0f, 1.0f)).xyz;
    direction = mul(CameraToWorld, float4(direction, 0.0f)).xyz;
    direction = normalize(direction);
    return CreateRay(origin, direction);
}

最后别忘了在CSMain里初始化:

    Ray ray = CreateCameraRay(uv);

于是我们就可以在场景里自由移动相机了。

构造实体几何

构造实体几何(Constructive Solid Geometry)简称CSG,是一种构建复杂的几何形状的方法。它通过对一些简单图形之间的集合关系(交、并、差等等)组合出复杂的形状。从下面的图我们可以理解到这种技术的思路:

并且,事实上当组合为二时,这些集合关系在SDFs中的表达相当简洁:

float intersectSDF(float dstA, float dstB) {
    return max(dstA, dstB);
}


float unionSDF(float dstA, float dstB) {
    return min(dstA, dstB);
}

float differenceSDF(float dstA, float dstB) {
    return max(dstA, -dstB);
}
此时,如果你将场景设置为这样:

float sceneSDF(float3 v) {
    float sphereDst = sphereSDF(v/1.2)*1.2;
    float cubeDst = cubeSDF(v);
    return intersectSDF(sphereDst,cubeDst);
}

则会看到(后面会解释为什么代码中出现了乘法和除法):

不妨思考一下这些由位运算组成的SDF函数是如何起作用的:

要明确的是在SDF函数中结果为负数的区域代表了在物体表面的内部,那么从交集运算可以知道,只有cube(p)和sphere(p)两者都为负数时,sceneSDF(p)才可能为负,也就意味着,只有某个点既在立方体内也在球体内的情况下我们才会认为这个点是在场景中的物体表面的内部。这也恰好和CSG中相交的定义一致。

同样的思路也适用于并集,如果任意一个函数为负,sceneSDF的结果也必定为负,因此那些点都会认为是物体表面的内部。

然而,求差操作是最棘手的:

SDF的负数意味着什么?

如果你继续考虑SDF的正负区间的含义是什么,你会发现一个SDF的负数变成了在物体表面内外的倒置。所以我们认为在表面以内的点现在变成了表面以外,反之也亦然。

这就意味着你可以认为求差等同于求第一个SDF和第一个SDF的倒置的交集。所以sceneSDF的结果在第一个SDF为负数而第二个SDF为正数的时候,才为负数。从几何的角度来看,这也就相当于只有在第一个物体内部且在第二个物体外部的区域属于场景中物体表面的内部,也恰和CSG的定义相同。

模型的变换

相机的移动给我们带来了许多便利,那如果场景中的物体也能按照我们自己的意愿作位移旋转缩放等运动就更棒了。让我们来探讨探讨如何做到这一点。

旋转和平移

为了移动或者旋转由SDF所定义的模型,你可以通过在解析函数之前作逆变换。就像你平常对不同的mesh作各种不同的变换一样,你可以对SDF的某个部分作各种变换,只需要把你想改变的数据传给你希望改变的SDF函数。比如,把之前例子中的立方体向上移动某个长度,不管球体,并使它们仍有交集,你可以这样做:

float sceneSDF(float3 v) {
    float sphereDst = sphereSDF(v/1.2)*1.2;
    float cubeDst = cubeSDF(v + float3(0, -1, 0));
    return intersectSDF(sphereDst,cubeDst);
}

于是会有一个疑问,当作了变换之后,结果函数仍然在有符号距离场之内吗?对于旋转和平移来说,是的,因为它们都是刚体变换,意味着保持了点之间原有的距离。通常,我们可以通过将采样点乘以变换矩阵的逆,实现任何刚体变换。比如,我想应用一个旋转矩阵,我可以这样做:

float4x4 rotateY(float theta) {
    float c = cos(theta);
    float s = sin(theta);

    return float4x4(
        float4(c, 0, s, 0),
        float4(0, 1, 0, 0),
        float4(-s, 0, c, 0),
        float4(0, 0, 0, 1)
        );
}

由于在HLSL里没有内置矩阵的逆,所以我们只用执行相反的转换达到等效的功能:

float sceneSDF(float3 v){
    float sphereDst = sphereSDF(v/1.2)*1.2;
    float3 cubePoint = mul(rotateY(-90),float4(v, 1.0)).xyz;
    float cubeDst = cubeSDF(cubePoint);
    return intersectSDF(sphereDst,cubeDst);
}

如果想了解更多的变换矩阵,可以去看看图形学教科书或者这个关于3D仿射变换的网站:http://web.cs.wpi.edu/~emmanuel/courses/cs543/slides/lecture04_p1.pdf

均匀缩放

好吧,现在回到之前故意没提到的缩放的小技巧:

    float sphereDst = sphereSDF(v/1.2)*1.2;

除以1.2是为了将球体放大1.2倍(牢记我们对物体的变换反映在SDF上则是逆变换),但为什么在后面还要乘以1.2呢?为了简单起见,不妨先检查放大两倍的情况:

    float sphereDst = sphereSDF(v/2)*2;

缩放不是刚体变换,不能保持点之间的原有距离。如果我们把点(0,0,1)和点(0,0,2)都除以2(相当于模型整体放大),那么这两个点之间的距离从1变成了0.5,这相当于一种走样。

所以在采样sphereSDF上的点的时候,我们最终会得到转换后的球体表面的点的距离的一半,在最后的乘法就是为了补偿这种走样。

有趣的是,如果在shader里尝试这么做,你会发现,即使不对缩放做补偿,得到的结果也不会受到影响,为什么?

//All of the following result in a similar image

    float sphereDst = sphereSDF(v/2)*2;

    float sphereDst = sphereSDF(v/2);

    float sphereDst = sphereSDF(v/2)*0.2;

请注意不管我们如何缩放SDF,距离的符号部分都是不变的。有符号距离场的符号部分仍然可行,但距离部分确实撒了谎。

为了找到问题的原因,我们需要重新检查光线步进算法是如何工作的:

回忆光线步进算法的每一步,我们想沿着射线的方向移动距离表面的最短距离,我们通过SDF预测了最短距离,为了算法能更快,我们又希望每一步都尽可能大,然而,若有一步过冲(undershoot),算法仍然能起效果,只不过需要更多的迭代。但是如果我们高估了距离,就真的会出问题了,比如在缩小模型的情况下,像这样:

    float sphereDst = sphereSDF(v/0.5);

你会发现球体完全消失了,因为在高估距离的时候,光线步进算法可能已经经过了表面,却没有发现它。

所以对于任何SDF,可以这样合理的缩放:

    float dst = someSDF(samplePoint / scalingFactor) * scalingFactor;

非均匀缩放及其他

如果想对一个模型做不均匀缩放,我们该如何安全地避免距离的高估问题呢?和均匀缩放不同的是,我们不能确切地对变换过程中距离的走样做补偿,这均匀缩放中这么做可行的原因是所有被缩放的维度都是等同的,因此不管最接近采样点的位置在哪里,缩放的补偿都是一样的。

但是对于非均匀缩放,我们需要知道哪里是离表面最近的点从而知道要校正多少距离。为了能清楚这么做是为什么,不妨想象有一个SDF定义的均匀球体,使其沿X轴缩小一半,其他的轴保持不变,则:

尝试向SDF代入点(0,2,0),返回单位1的距离,结果正确,点(0,1,0)确实是离球体表面最近的点;但是如果代入点(2,0,0),返回单位3的距离,结果错误,点(0.5,0,0)才是离球体表面最近距离的点,实际的距离是1.5个单位。

所以,就像在均匀缩放中我们需要防止高估来确定返回的距离的正确性,但是要多少?此因数的不同取决于点和表面的在哪。由于通常来说低估是没有影响的,所以我们可以只乘以最小的因数,像这样:

    float dst = someSDF(samplePoint / float3(s_x, s_y ,s_z)) * min(s_x, min(s_y, s_z));

对于其他非刚体变换的道理也是一样,只要符号在变换中保留了,就只需要弄清楚一些补偿的因数以确保高估现象不会触及到平面就可以了。

最后,在下一期我将希望可以脱离compute shader里的硬编码,将代码与C#脚本结合起来,实现场景的可拓展,并使物体之间的结合更加平滑等。

本文禁止转载或摘编

--
--
  • 投诉或建议
评论
赛事库 课堂 2021拜年纪