本系列文章是對 metalkit.org 上面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上.
下次見!