[MetalKit]30-Raymarching-in-Metal射線行進

蘋果API搬運工發表於2017-12-14

本系列文章是對 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上.

下次見!

相關文章