Shadertoy 教程 Part 6 - 使用光線步算法進創建3D場景

Note: This series blog was translated from Nathan Vaughn's Shaders Language Tutorial and has been authorized by the author. If reprinted or reposted, please be sure to mark the original link and description in the key position of the article after obtaining the author’s consent as well as the translator's. If the article is helpful to you, click this Donation link to buy the author a cup of coffee.
說明:該系列博文翻譯自Nathan Vaughn着色器語言教程。文章已經獲得作者翻譯授權,如有轉載請務必在取得作者譯者同意之後在文章的重點位置標明原文鏈接以及說明。如果你覺得文章對你有幫助,點擊此打賞鏈接請作者喝一杯咖啡。

朋友們,你們好!終於到了我們期待已久的時刻啦!在本篇教程中,你將首次學習到如何使用光線步進(Ray marching)創建3D場景!

什麼是光線?

你有沒有瀏覽過Shadertoy上那些讓人驚歎不已的作品?你一定會感到好奇,它們是如何在不使用3D模型的情況下,僅僅用一個像素着色器就能創造出如此神奇的東西呢?難道他們用了魔法嗎?還是他們個個都是數學或者圖形學設計的博士? 也許其中有些人是吧,但他們中的絕大多數人都是普通人。

Shadertoy上絕大多數3D場景都是用光線追蹤(Ray tracing)或者光線步進(Ray marching)算法模擬出來的。這些算法經常在計算機圖形學領域被使用。瞭解如何創建一個3D場景的第一步就是去理解光線/射線(Rays)。

仔細瞧!下面的光線!🙌

上面的就是一條光線!看起來像是從一個點加一個帶方向的線條。黑色的點表示光源(Ray origin),紅色的表示光線的方向。你會在創建3D場景時經常用到它,所以最好還是要理解它們是如何工作的。

一束光線是由光源和方向組成的,然後呢?

光源簡單來說指的就是光線開始的地方。在2D場景中,我們可以用GLSL創建一個變量來表示:

  vec2 rayOrigin = vec2(0, 0);

如果你上過一些線性代數或者微積分的課程,你可能會對此感到困惑不解。爲什麼我們要將一個點用一個向量表示?難道所有的向量都有方向嗎?是的,數學地講,向量都有一個長度和一個方向,但是現在,我們只把向量當作是一種數據類型來討論。

在着色器語言例如(GLSL)中,我們可以用一個vec2類型的變量來儲存任何我們想存儲的值。在vec3類型的變量中,我們可以存儲三個值。這些值可以代表各種意義:例如顏色、座標、圓的半徑、等等。我們可以選擇XY座標,例如(0, 0)來存儲光源。

光線方向表示一個歸一化的向量,它的大小是1。 在2D場景中我們用GLSL創建一個變量來表示方向:

  vec2 rayDirection = vec2(1, 0);

設置光線的方向爲vec2(1, 0),表示的就是這束光線單位是1,指向座標的右邊。

一個2D向量擁有x和y元素。下面的例子展示了一束方向爲vec2(2, 2)的光線,我們把它用黑色標明出來。它與成水平45度,指向斜對角。紅色的水平線表示光線的x元素,綠色那條則表示y元素。我在Desmos中創建了一個示例,你可以任意的修改裏面的值來查看效果。

這束光線沒有被歸一化,假設計算它的方向大小,我們會發現它並不等於1。光線方向的大小值可以用下面方式計算出來。

讓我們來計算這束光線(vec2(2, 2))的大小(長度)吧。

  length(vec2(2,2)) = sqrt(x^2 + y^2) = sqrt(2^2 + 2^2) = sqrt(4 + 4) = sqrt(8)

結果等於8的平方根, 這個值並不是1,所以我們需要將其歸一化處理。在GLSL中,我們使用normalize函數歸一化一個向量:

  vec2 normalizedRayDirection = normalize(vec2(2, 2));

normalize函數背後實現的原理就是:給向量中的每個元素除以一個數值,這個數值就是向量的大小(長度):

  Given vec2(2,2):
  x = 2
  y = 2

  length(vec2(2,2)) = sqrt(8)

  x / length(x) = 2 / sqrt(8) = 1 / sqrt(2) = 0.7071 (approximately)
  y / length(y) = 2 / sqrt(8) = 1 / sqrt(2) = 0.7071 (approximately)

  normalize(vec2(2,2)) = vec2(0.7071, 0.7071)

歸一化後,我們就得到一個新的向量:vec2(0.7071, 0.7071)。計算這個向量的大小(長度),會發現它會爲1.

後面我們將約定俗成:使用一個歸一化後的向量代表光線方向。還有一些我們後面將會用到的算法,這些算法只關心光線的方向而不是大小(長度)。

如果你學習過一些線性代數的課程,那麼你就知道可以用一個基礎的向量的線性結合組成一個新的向量。同樣,通過給一個歸一化的光線乘以一個值,我們就可以讓它變長,並且保持原來的方向。

3D 歐式空間

我們在2D場景討論過的所有東西都可以適用於3D。3D中的光線的大小(長度)由以下的方程式定義:

在3D歐式(Euclidean)空間中(我們在學校中學到的3D空間)的向量也是一些基礎向量的線性集合。我們可以用一個向量的集合或者歸一化一個向量來表示一個新的向量:

上面的圖片,有三個軸:藍色的X軸,紅色的Y周以及綠色的Z軸。向量IJK代表可以被合併、縮放或者拉伸的基礎向量,它們用來創建一個新的a向量,它包含XYZ三個元素。

需要注意的是上面的圖片只是我們在3D座標空間中的展示方式。我們可以任意旋轉座標系統,只要保持三個軸互相垂直,我就可以運用同一套計算法方式進行計算。

在Shadertoy中,人們經常使用的是這樣一個座標系統:x軸沿着是畫布的水平方向,y軸沿着畫布的垂直方向而z軸則是沿着垂直屏幕的方向。

上圖中我刻意地使用紅色表示X軸,綠色表示Y軸,藍色表示Z軸。在第一篇教程中,每個軸在向量中的位置同時也是向量中表示顏色(RGB)的位置。

vec3 someVariable = vec3(1, 2, 3);

someVariable.r == someVariable.x
someVariable.g == someVariable.y
someVariable.b == someVariable.z

在上圖中,Z軸的正方向是朝我們的,負方向則是遠離我們的。這就是我們約定俗成的右手座標系。使用右手,將你的大拇指朝向右邊,食指朝上,終止朝着自己,它們代表了座標系中互相垂直的三個軸。手指指向的方向就是正方向。

其他的在線教程或者別人寫的代碼中也會看到一種z軸方向相反的座標規範。這種座標規範的Z軸方向與我們之前提到的相反,但是X和Y軸的方向不變。這種座標體系也稱之爲左手座標系。

光線算法

我們接下來談談一些光線算法————光線步進以及光線追蹤。光線步進是在Shadertoy中構建3D場的最常用的方法。當然也有其他方式例如:光線追蹤以及路徑追蹤。

但不管是光線步進還是光線追蹤,他們都是利用光線在2D的屏幕上繪製3D場景的。在現實的生活當中,像太陽這種光源通常以光子形式向四面八方投射光線。當光子碰撞到了一個物體時,能量就會被物體的原子的晶格吸收,從而釋放另外一個光子。這個被釋放的光子會根據物體原子的晶格結構和材質,沿着隨機的方向被反射出去(漫反射),或者都以同樣的一個角度被反射出去(鏡面反射)。

我可以把物理知識說上一整天,但是我們這裏的重點是談論的是光線步進和光線追蹤。如果我們嘗試爲3D場景建模,從光源位置開始,追蹤到照相機結束,那麼我們會陷入無休止的計算當中去。這種模擬前進的方式會導致我們陷入無休止的射線回照,永遠無法到觀察點。

我們經常看到的另外一種方向追蹤的模擬方式,光線從相機中被射出而不是從光源。我們知道一束光線通常都有一個光源例如太陽,它在物體之間反射跳躍,最終到達相機。現在假設我們的光線是從相機開始,向所有的方向發射,這些光線會在各個物體之間跳躍反彈,其中一些光線會到達表面例如地板,另外也有一些也會到達光源。如果一束光線從表面上被反彈然後碰到了一個物體而非光源,那麼我們就稱之爲“陰影射線”,此時我們需要在這些地方繪製一個黑色的像素標識陰影。

在上面這張圖片當中,照相機向不同的方向發射了很多光線,有多少條呢?我們畫布上有多少像素就有多少條光線!我們使用Shadertoy上的像素來產生一條光線。是不是很有意思?每個像素都有在x軸和y軸上都有一個座標值,爲什麼不用他們創建一條含有z元素的光線呢?

還記得有多少個方向嗎?一個像素即一條光線,這就是爲什麼知道光線工作很重要了。

從照相機中發射出來的光線的源頭就是照相機所在的位置。每條光線都會有一個包含xyz三個元素的方向軸。請注意陰影射線,它的來源就是相機射線擊中表面的那個點。每當射線擊中一個表面,我們就能夠模擬反彈或者反射,從而在這個點產生出一條新的射線!請牢記這一點因爲我們後面會談到照明和陰影。

光線算法中的差異

現在我們來談論擊中不同的類型的光線算法:它們分別是:光線投射(ray casting),光線追蹤(ray tracing),光線步進(ray marching)和路徑追蹤(path tracing)。

光線投射(Ray Casting)

光線投射是光線追蹤的一種簡化形式,在德軍總部3D毀滅戰士 這樣的遊戲中被使用到。它指的是發射一條射線,然後當它碰撞到一個物體的時候就停止。

光線步進(Ray Marching)

光線步進是光線投射的一種方法,使用距離符號場函數和一個常用的球形追蹤算法,讓射線逐步地遞進,直到碰撞到最近的物體。

關係追蹤(Ray Tracing)

光線追蹤是是光線投射的一個升級版本,它需要計算光線表面的交互,然後遞歸地在每個物體反射情況之下創造出新的光線。

路徑追蹤(Path Tracing)

路徑追蹤是光線追蹤算法的其中一種類型,它爲一個像素髮射成千上百條射線而不是一條射線,使用Monte Carlo method方法隨機想各個方向發射,最終像素的顏色取決於哪一束來自光源的光線。

如果你曾經聽過Monte Carlo,這會讓你立即聯想到概率學和統計學。

你或許聽過光線步進也會被稱爲“球形追蹤”。在計算機圖形學Stack Exchange論壇上,關於光線步進和球體追蹤之間差異性討論非常熱鬧。基本上,球體追蹤就是光線步進的一種實現。絕大多數Shadertoy上使用的光線步進就是使用球體追蹤的方式的,它也是光線步進的一種算法。

爲了是你對拼寫不產生疑惑,我經常看見大家使用光線步進或者光線追蹤爲一個單詞。當你需要在搜索這些主題資料的時候,你需要時刻記住關於我上面提到的一些說法。

光線步進

本篇教程剩下的篇幅,我將用來討論如何在Shadertoy中使用光線步進的算法。網絡上有很多出色的關於光線步進的算法的文章,例如Jamie Wong的這篇文字。Shadertoy上的這篇教程,你可以幫助你更好的理解可視化光線步進算法以及爲何它又會被稱爲球形追蹤。

我會一步一步地分析光線步進的過程,所以你別擔心你不懂或者只會一點點計算機圖形學的知識。

我們首先創建一個簡單的相機,這樣我們就能夠在Shadertoy畫布上模擬出3D 場景。讓我們先想像一個我們的場景會是什麼樣子。我們以最基礎的物體球體開始吧:

上圖所示,我們在Shadertoy中創建的一個3D場景的一面。x軸沒有出現在圖片中因爲它指向的是觀察者。我們的照相機被放置在左邊爲(0,0, 5)的位置上,意味着它離畫布的z軸有5個單位。想前一篇文字一樣,我們將UV的原點座標調整到了整個畫布的中心。

上圖表示的是從我們的視角看去的畫布,畫布有紅色的x軸,和綠色y軸。我們是處在相機的視角來觀察整個場景的。從照相機射出的射線直接指向畫布的中心點的位置直接撞擊到球體。如果它還擊中了地板,那麼從照相機射出來的線會以一定的角度照射在地板上。如果射線沒有碰觸到任何東西,那麼我們直接渲染出一個黑色的背景色即可。

我們現在知道了我們需要構建什麼東西,那就開始編碼吧!在Shaderto中新建一個着色器,將裏面的內容替換成下面的內容:

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
  vec2 uv = fragCoord/iResolution.xy; // <0, 1>
  uv -= 0.5; // <-0.5,0.5>
  uv.x *= iResolution.x/iResolution.y; // fix aspect ratio

  vec3 col = vec3(0);

  // Output to screen
  fragColor = vec4(col,1.0);
}

爲了使我們的代碼看起來更加簡潔,我們把映射UV座標的代碼縮減到了一行:

  void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
  vec2 uv = (fragCoord - .5 * iResolution.xy) / iResolution.y; // Condense 3 lines down to a single line!

  vec3 col = vec3(0);

  // Output to screen
  fragColor = vec4(col,1.0);
}

射線源頭,ro表示我們相機所在的位置。我們將其設置爲畫布5個單位的距離:

  vec3 ro = vec3(0, 0, 5);

接下來我們添加射線的方向,rd,它根據座標上的每個像素會變化。我們將其z軸位置設置爲-1,這樣射線所朝向的方向就是我們的場景了。然後我們歸一化整個向量:

  vec3 rd = normalize(vec3(uv, -1));

然後我們設置一個變量,這個變量用於接收光線步進算法返回的距離:

  float d = rayMarch(ro, rd, 0., 100.);

我們創建一個rayMarch函數,用來實現我們的光線步進算法:

  float rayMarch(vec3 ro, vec3 rd, float start, float end) {
  float depth = start;
  
  for (int i = 0; i < 255; i++) {
    vec3 p = ro + depth * rd;
    float d = sdSphere(p, 1.);
    depth += d;
    if (d < 0.001 || depth > end) break;
  }
  
  return depth;
}

我們現在來更詳盡地來解釋這個光線步進算法。我們從深度0的位置出發,然後逐步地增加深度的距離。我們測試的點就是射線的原點(相機的位置)加上沿着射線行進的距離。需要注意,光線步進算法會爲每個像素進行計算,每個像素會決定不同的光線方向。

我們採用測試點p,然後將起傳遞給sdSphere函數,該函數定義如下:

  float sdSphere(vec3 p, float r)
{
  return length(p) - r; //p點就是測試點,r是球體的半徑
}

然後我們會逐步的增加的深度值,增加的速率就是sdSphere函數返回的距離。如果返回距離圓的位置小於0.0001,那麼我們就認爲已經足夠接近這個球了。這只是一個近似的值,值約小,計算就越準確。

如果距離大於一個設定的閾值,例如我們在這裏設置的100. 則我們可以認爲射線已經走得太遠了,並沒有碰撞到物體,可以停止這個循環了。我們之所以要停止射線的繼續前進,是因爲如果射線沒有碰撞到物體他會一直走下去,這樣會造成一個無線的計算輪訓,消耗電腦的計算資源。

最終,我們根據射線是否碰撞到了東西來爲其添加一個顏色:

  if (d > 100.0) {
    col = vec3(0.6); // ray didn't hit anything
  } else {
    col = vec3(0, 0, 1); // ray hit something
  }

我們最終的代碼如下:

  float sdSphere(vec3 p, float r )
{
  return length(p) - r;
}

float rayMarch(vec3 ro, vec3 rd, float start, float end) {
  float depth = start;

  for (int i = 0; i < 255; i++) {
    vec3 p = ro + depth * rd;
    float d = sdSphere(p, 1.);
    depth += d;
    if (d < 0.001 || depth > end) break;
  }

  return depth;
}

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
  vec2 uv = (fragCoord-.5*iResolution.xy)/iResolution.y;

  vec3 col = vec3(0);
  vec3 ro = vec3(0, 0, 5); // ray origin that represents camera position
  vec3 rd = normalize(vec3(uv, -1)); // ray direction

  float d = rayMarch(ro, rd, 0., 100.); // distance to sphere

  if (d > 100.0) {
    col = vec3(0.6); // ray didn't hit anything
  } else {
    col = vec3(0, 0, 1); // ray hit something
  }

  // Output to screen
  fragColor = vec4(col, 1.0);
}

看起來重複使用了一些數字,我們可以把它們定義爲一些全局的常量。在GLSL中,我們用const關鍵字告訴編譯器我們不打算去更改這些變量:

  const int MAX_MARCHING_STEPS = 255;
  const float MIN_DIST = 0.0;
  const float MAX_DIST = 100.0;
  const float PRECISION = 0.001;

我們也可用用預編譯指令實現同樣的效果。當需要定義一個常量的時候,可以經常看到一些預編譯指令,例如#define。利用#define的一個優勢就是你可以使用ifdef來檢查一個變量是否定義在你的代碼之後。#defineconst之間還是有一些區別的,所以選擇哪種方式是需要你的實際應用場景來決定的。

我們用#define預編譯指令,那麼我們就得到以下的代碼:

  #define MAX_MARCHING_STEPS 255
  #define MIN_DIST 0.0
  #define MAX_DIST 100.0
  #define PRECISION 0.001

請注意,在使用預編譯指令時,我們沒有用到等號,並且在行尾沒有以分號結束。

#define關鍵字允許我們定義變量和函數,但我跟傾向使用const,因爲它更加安全。

使用全局定義的常量,我們就得到了以下的代碼:

  const int MAX_MARCHING_STEPS = 255;
const float MIN_DIST = 0.0;
const float MAX_DIST = 100.0;
const float PRECISION = 0.001;

float sdSphere(vec3 p, float r )
{
  return length(p) - r;
}

float rayMarch(vec3 ro, vec3 rd, float start, float end) {
  float depth = start;

  for (int i = 0; i < MAX_MARCHING_STEPS; i++) {
    vec3 p = ro + depth * rd;
    float d = sdSphere(p, 1.);
    depth += d;
    if (d < PRECISION || depth > end) break;
  }

  return depth;
}

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
  vec2 uv = (fragCoord-.5*iResolution.xy)/iResolution.y;

  vec3 col = vec3(0);
  vec3 ro = vec3(0, 0, 5); // ray origin that represents camera position
  vec3 rd = normalize(vec3(uv, -1)); // ray direction

  float d = rayMarch(ro, rd, MIN_DIST, MAX_DIST); // distance to sphere

  if (d > MAX_DIST) {
    col = vec3(0.6); // ray didn't hit anything
  } else {
    col = vec3(0, 0, 1); // ray hit something
  }

  // Output to screen
  fragColor = vec4(col, 1.0);
}

當我們運行以上代碼,我就可以看到一幅球形的圖片。看起來是個圓,但它確實是個球!

通過設置相機的位置,我們可以放大或者縮小來證明我們看到的就是一個3D的物體。將相機的距離和垂直距離增加至到5,就能讓圓看起來稍微大一些了:

  vec3 ro = vec3(0, 0, 3); // 光源

還有一個問題,目前我們的球形的中心位置在座標(0,0,0)上,和我們之前展示的圖片稍微有些不同。我們場景距離相機的位置非常接近。

我們給球添加一個偏移值,模擬我們在第二篇教程的做法。

  float sdSphere(vec3 p, float r )
{
  vec3 offset = vec3(0, 0, -2);
  return length(p - offset) - r;
}

這樣做就使得我們的球體會沿着z軸向後移動兩個單位。同時也會讓球看起來顯得更小,因爲它距離我們的相機越來越遠了。

光照

爲了讓我們的圖形更加真實,我們需要添加光照效果。在現實的世界中,光線是按照隨機方向散佈在物體之上的。

物體的呈現形式會根據他們與光源(例如太陽)的距離的遠近程度而各有不同:

上圖中黑色的箭頭表示的是球體表面的一些法線。如果球體某個表面的法線方指向了光源的位置,那麼球體的這個點看起來就會比其他的點要亮。如果球體上的某個點的法線完全與光源的方向相反,那麼這部分的球表面會看起來更暗一些。

有很多種類型的光照模型用來模擬真實的世界。我們來看看Lambert lighting 是如何模擬漫反射的。這種方式通常是求光線方向與法線方向之間的點積來獲得的。

  vec3 diffuseReflection = dot(normal, lightDirection);

表面法線通常都是一個被歸一化之後的向量,因爲我們只需要知道其方向即可。爲了找到方向,我們需要使用到gradient。表面的法線等於一個表的傾斜度。

計算傾斜度就是計算一條線的斜率。你在學校裏面可能要被下面這句話:“rise over run”。在3D座標當中,我們利用傾斜度找出一個點的方向是否指向了該點。

如果你上過微積分課程,那麼你就知道一條線的傾斜度實際上只是一條線上兩個點之間的微小差異。

我們通過rise over run 來找到傾斜度吧:

    Point 1 = (1, 1)
    Point 2 = (1.2, 1.2)

    Rise / Run = (y2 - y1) / (x2 - x1) = (1.2 - 1) / (1.2 - 1) = 0.2 / 0.2 = 1

    因此,傾斜度就是1

要找到一個表面的傾斜率,我們需要兩個點。首先在球的表面上取一個點,然後讓它減去一個很小的值得到第二個點。我們就是利用小技巧來找到斜率的。我們就可以用這個傾斜率來表示法線。

給定一個表面,f(x,y,z),表面的傾斜率是以下公式:

上圖中那個完全的字母看起來“e”,其實是希臘字母,epsilon。它表示的是距離我們球體表面一個點的很小的值。

在GLSL中,我們創建一個calcNormal的函數,取從rayMarch函數返回的一個點上取一個點的值:

  vec3 calcNormal(vec3 p) {
    float e = 0.0005; // epsilon
    float r = 1.; // radius of sphere
    return normalize(vec3(
      sdSphere(vec3(p.x + e, p.y, p.z), r) - sdSphere(vec3(p.x - e, p.y, p.z), r),
      sdSphere(vec3(p.x, p.y + e, p.z), r) - sdSphere(vec3(p.x, p.y - e, p.z), r),
      sdSphere(vec3(p.x, p.y, p.z  + e), r) - sdSphere(vec3(p.x, p.y, p.z - e), r)
    ));
  }

我們可以使用Swizzling和向量計算讓我們的代碼看起來更加清楚一些:

  vec3 calcNormal(vec3 p) {
  vec2 e = vec2(1.0, -1.0) * 0.0005; // epsilon
  float r = 1.; // radius of sphere
  return normalize(
    e.xyy * sdSphere(p + e.xyy, r) +
    e.yyx * sdSphere(p + e.yyx, r) +
    e.yxy * sdSphere(p + e.yxy, r) +
    e.xxx * sdSphere(p + e.xxx, r));
  }

我們需要弄明白一件事情:即函數法返回的射線方向就是球體上的一個點的對面方向。接下來,我們需要設置一個光源。把它當做3D空間中的一個微小的點:

  vec3 lightPosition = vec3(2, 2, 4);

現在,我們得到了一個光源,一直對着我們的球。我們的光線的射線方向就是光源和我們從光線步進中得到點的差值:

   vec3 lightDirection = normalize(lightPosition - p);

爲了找到我們球接受到的光線總量,我們需要計算dot product。在GLSL中,我們使用dot函數來計算這個值:

  float dif = dot(normal, lightDirection); // dif = diffuse reflection

當我們計算法線與光線方向兩個向量之間的點積時,我們有可能得到一個負值,爲了保證值能保持在0到1之間,我們需要對求出來的值做一個限定, 使用clamp函數即可。、

   float dif = clamp(dot(normal, lightDirection), 0., 1.); 

將所有的東西結合在一起之後,我們就能得到下面的代碼:


 const int MAX_MARCHING_STEPS = 255;
const float MIN_DIST = 0.0;
const float MAX_DIST = 100.0;
const float PRECISION = 0.001;

float sdSphere(vec3 p, float r )
{
  vec3 offset = vec3(0, 0, -2);
  return length(p - offset) - r;
}

float rayMarch(vec3 ro, vec3 rd, float start, float end) {
  float depth = start;

  for (int i = 0; i < MAX_MARCHING_STEPS; i++) {
    vec3 p = ro + depth * rd;
    float d = sdSphere(p, 1.);
    depth += d;
    if (d < PRECISION || depth > end) break;
  }

  return depth;
}

vec3 calcNormal(vec3 p) {
    vec2 e = vec2(1.0, -1.0) * 0.0005; // epsilon
    float r = 1.; // radius of sphere
    return normalize(
      e.xyy * sdSphere(p + e.xyy, r) +
      e.yyx * sdSphere(p + e.yyx, r) +
      e.yxy * sdSphere(p + e.yxy, r) +
      e.xxx * sdSphere(p + e.xxx, r));
}

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
  vec2 uv = (fragCoord-.5*iResolution.xy)/iResolution.y;

  vec3 col = vec3(0);
  vec3 ro = vec3(0, 0, 3); // ray origin that represents camera position
  vec3 rd = normalize(vec3(uv, -1)); // ray direction

  float d = rayMarch(ro, rd, MIN_DIST, MAX_DIST); // distance to sphere

  if (d > MAX_DIST) {
    col = vec3(0.6); // ray didn't hit anything
  } else {
    vec3 p = ro + rd * d; // point on sphere we discovered from ray marching
    vec3 normal = calcNormal(p);
    vec3 lightPosition = vec3(2, 2, 4);
    vec3 lightDirection = normalize(lightPosition - p);

    // Calculate diffuse reflection by taking the dot product of 
    // the normal and the light direction.
    float dif = clamp(dot(normal, lightDirection), 0., 1.);

    col = vec3(dif);
  }

  // Output to screen
  fragColor = vec4(col, 1.0);
}

運行以上的代碼,你就能看到一個球形了!現在,你不會覺得我是在騙你吧,這完全是一個球形!😁

如果你改動 lightPosition 變量,你就可以沿着3D世界的座標隨意地移動光源了。移動光源會影響着色器對球體的影響。如果你將光源移動到相機之後,你就可以看到球中心的位置會更加地明亮:

  vec3 lightPosition = vec3(2, 2, 7);

你同時也可以通過改變漫反射的值和顏色值從而改變球的顏色:

  col = vec3(dif) * vec3(1, 0.58, 0.29);

如果你想爲其增加一點環境光線,你只需要調整限定值的範圍即可,這樣我們的球看起來就不會是完全的黑色了:

   float dif = clamp(dot(normal, lightDirection), 0.3, 1.); 

你同時也可以改變背景顏色,是不是看起來像我們在教程前面展示的圖片呢?😎

下面就是我創建的上面這張圖片展示的球的所有完整代碼:

  const int MAX_MARCHING_STEPS = 255;
const float MIN_DIST = 0.0;
const float MAX_DIST = 100.0;
const float PRECISION = 0.001;

float sdSphere(vec3 p, float r )
{
  vec3 offset = vec3(0, 0, -2);
  return length(p - offset) - r;
}

float rayMarch(vec3 ro, vec3 rd, float start, float end) {
  float depth = start;

  for (int i = 0; i < MAX_MARCHING_STEPS; i++) {
    vec3 p = ro + depth * rd;
    float d = sdSphere(p, 1.);
    depth += d;
    if (d < PRECISION || depth > end) break;
  }

  return depth;
}

vec3 calcNormal(vec3 p) {
    vec2 e = vec2(1.0, -1.0) * 0.0005; // epsilon
    float r = 1.; // radius of sphere
    return normalize(
      e.xyy * sdSphere(p + e.xyy, r) +
      e.yyx * sdSphere(p + e.yyx, r) +
      e.yxy * sdSphere(p + e.yxy, r) +
      e.xxx * sdSphere(p + e.xxx, r));
}

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
  vec2 uv = (fragCoord-.5*iResolution.xy)/iResolution.y;
  vec3 backgroundColor = vec3(0.835, 1, 1);

  vec3 col = vec3(0);
  vec3 ro = vec3(0, 0, 3); // 射線原點,即相機所在的位置
  vec3 rd = normalize(vec3(uv, -1)); // ray direction

  float d = rayMarch(ro, rd, MIN_DIST, MAX_DIST); // 距離圓的位置

  if (d > MAX_DIST) {
    col = backgroundColor; // 射線沒有擊中任何物體 
  } else {
    vec3 p = ro + rd * d; // 從光線步進中計算出的球上的點
    vec3 normal = calcNormal(p);
    vec3 lightPosition = vec3(2, 2, 7);
    vec3 lightDirection = normalize(lightPosition - p);

    // 通過法線和光照方向的點積計算漫反射
    float dif = clamp(dot(normal, lightDirection), 0.3, 1.);
    // 給球上的漫反射顏色乘以橙色然後加一點背景色使其更加融入背景。
    col = dif * vec3(1, 0.58, 0.29) + backgroundColor * .2;
  }

  // Output to screen
  fragColor = vec4(col, 1.0);
}

總結

唷!這篇文章花費了我一個週末的時間編寫和校驗,希望你在學習的過程中感到快樂!如果你覺得這篇文章或者之前的文章對你有幫助,請點擊捐贈鏈接打賞鼓勵一下吧。僅僅利用屏幕上的的像素以及一種聰明的算法,我們朝着繪製3D物體的目標走出了第一步。那麼我們下次再見吧!祝大家編碼愉快!

引用資源

Difference between Ray Algorithms
Ray Marching Tutorial by Jamie Wong
Wikipedia: Ray Tracing
Wikipedia: Lambertian Reflectance
Wikipedia: Surface Normals
Wolfram MathWorld: Gradient
Wolfram MathWorld: Dot Product
Shadertoy: Visual Ray Marching Tutorial
Shadertoy: Super Simple Ray Marching Tutorial
Shadertoy: Box and Balloon
Shadertoy: Let's Make a Ray Marcher
Shadertoy: Ray Marching Primitives
Shadertoy: Ray Marching Sample Code
Shadertoy: Ray Marching Template

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章