2020年2月5日 星期三

今晚來點 Raymarching (3)


  • 前言
這是發生在寫完上一篇 Raymarching 範例程式碼的那一晚,

那一晚我右手端著咖啡,左手敲著鍵盤,沉浸在自己的部落格文章裡,

正當我非常滿意我的文筆時,發現了賣雞排以外的路,喔!不!!

我是說突然想到我是利用一個3D物件(範例使用四邊形),來做為射線終點位置的判斷。

如果我想把射線終點範圍擴大到整個螢幕範圍(就像攝影機濾鏡效果一樣),

是不是也能辦的到?

結果,那一晚 ... 我失眠了。


本篇是補充上一篇從只能對局部範圍,延伸到對整個畫面做 Raymarching 的方法說明,

建議先從上一篇看起會比較好理解。

  • 原因
Unity 攝影機濾鏡是在攝影機下掛一個 script, 

並在這個 script 內的 MonoBehaviour::OnPostRender() 或 MonoBehaviour::OnRenderImage,

呼叫 Graphics.Blit()。

上一篇的 shader 沒有辦法直接使用的問題就在於透過呼叫 Graphics.Blit(),

導致 shader 內的 unity_ObjectToWorld 值永遠是個 identity。


先讓我們隨便在攝影機上掛一個濾鏡,然後開啟 frame debugger 來看看。


可以觀察到 unity_MatrixVP 永遠是個定值。

模型在繪圖時,會依序執行3個步驟

  1. 從模型的區域座標轉換到世界座標(根據 Transform Component)
  2. 轉換到攝影機的相對位置(根據 Camera 的 Transform Component)
  3. 計算投影到螢幕上的座標(根據 Camera Component)

其中,unity_MatrixVP 就是做第2、3步驟。

因此,可以推測出 Graphics.Blit() 只是做一個定點投影到螢幕上的動作,

因此無法用 unity_ObjectToWorld 算出射線終點的世界座標!


  • 解答與實作

該文章是在討論如何在VR環境下,用深度圖算出畫面的世界座標位置。

解決方法是利用深度圖算出畫面每個點與攝影機的相對距離,

再使用攝影機到畫面每個點的射線,算出畫面每個點的位置。


射線的計算方式則是在 script 計算,主要是利用反矩陣求出,

可以試想成從步驟三反向執行到步驟一,求出世界座標在算出射線。


實作程式碼如下:

Script 部分


我使用 CommandBuffer 來做攝影機濾鏡,

這個好處是我可以不必指定將這個 script 掛在攝影機上。

在 LateUpdate() 時,收集攝影機的 view matrix 和 project matrix,反轉和修改後(註1),

指定變數 _ClipToWorld 儲存,給繪圖的材質球使用。


Shader 部分

基本上和之前沒太大的差別,主要是在 vertex shader 部分原本是計算世界座標位置,

修改成直接算出射線向量

v2f vert (appdata v)
{
    v2f o;

    // ... 省略

    float4 clip = float4(o.vertex.xy, 0.0, 1.0);
    o.worldDirection = mul(_ClipToWorld, clip) -_WorldSpaceCameraPos;                
    return o;
}

我修改了 shader reconstructing position from depth in vr through projection matrix 的計算方式,

文章是在 VR 裝置下處理, 而我的範例是在 PC 平台,所以做了一點修正。



  • 結論
其實有想過直接在攝影機前面掛一個很大的四方形(像 nGUI 一樣),

但考慮到以後會挑戰結合 3D物件 和 Raymarching 的混合繪圖

還是硬著頭皮去網路上找答案 ...


Raymarching 基礎就先暫時到這裡,接下來會挑戰應用部分!


註1.

這一部分說實在很想用幾個字草草混過,但發現如果不說明,

在很多情況下,會無法正常顯示!

還是只能打開小畫家來畫數字了 ...

先回頭來看繪圖 步驟3. 計算投影到螢幕上的座標

這個的轉換矩陣就是 Camera.main.projectionMatrix ,

根據  Unity 官方說明 它裡面的內容如下



在 Script 裡面,做了這樣的修改

void LateUpdate()
{
    // ... 省略

    Matrix4x4 proj = GL.GetGPUProjectionMatrix(Camera.main.projectionMatrix, true);
        
    proj[2, 3] = proj[3, 2] = 0.0f;
    proj[3, 3] = 1.0f;

    Matrix4x4 view = Camera.main.worldToCameraMatrix;

    Matrix4x4 clip = Matrix4x4.Inverse(proj * view)
             * Matrix4x4.TRS(new Vector3(0, 0, -proj[2, 2]), Quaternion.identity, Vector3.one);
}

結果,投影矩陣內容變成下圖所示



那他的反矩陣就是


步驟2 計算與攝影機的相對位置使用 Camera.main.worldToCameraMatrix 沒有做任何變更。

將這兩個轉換矩陣相乘後做反轉,然後在對 Z 軸做位移(移動 (far+near) / (far-near))。

這一整個轉換步驟全部記錄在矩陣變數 _ClipToWorld。

接下來把目光放在矩陣變數  _ClipToWorld,可以分解成



接著,來到 shader 裡面來看

v2f vert (appdata v)
{
    v2f o;

    o.vertex = UnityObjectToClipPos(v.vertex);
    float4 clip = float4(o.vertex.xy, 0.0, 1.0);
    o.worldDirection = mul(_ClipToWorld, clip) -_WorldSpaceCameraPos;
    return o;
}

shader 將輸出的畫面螢幕座標從 2維 轉成  4維,並令 z 軸為 0, w 軸為 1。

其中 o.vertex.xy 的範圍會在 (-1, -1) ~ (1, 1)。

然後和矩陣變數做相乘。


到了這裡,我們已經反推求得步驟2 世界座標和攝影機的相對距離,

但要如何證明,這個相對位置,涵蓋整個可視範圍?

在證明之前,先看看 Unity 官方對攝影機可視範圍的定義(Frustum)

其中,

var frustumHeight = 2.0f * distance * Mathf.Tan(camera.fieldOfView * 0.5f * Mathf.Deg2Rad);

var frustumWidth = frustumHeight * camera.aspect;



可以得知,將結果轉為射線,經過光柵化(Rasterizing)後,

射線範圍會涵蓋整個可視範圍(Frustum)。

z 軸為 -1 是因為 OpenGL 和  Unity3D 對 Z 軸方向的定義相反,

因此要為 -1 才能使前後方向正常。


看完之後,可以知道這個方法是在很多前提下才得到的結果。

如果其中一個前提無法滿足,就沒有辦法算出正常結果,

因此需要知道其運作內容,才能根據專案環境,作對應修改。


投影矩陣在繪圖上是一個很重要的因素,

透過修改這個矩陣可以達到許多特殊需求(像水面倒影、光線投影之類)。

若之後沒有什麼新梗,也許可以考慮寫一篇討論投影矩陣的文章。




今晚來點 Raymarching (2)


  • 前言
講了一篇的前言,終於到了 Unity 實作階段,比較起上一篇用快速傅立葉轉換做水波生成,

文章撰寫的速度快很多!

果然少了複雜的數學公式,不論對我的寫作動力還是網路硬碟容量,都是百利無一害!
(都是數學的錯!我要求道歉!下台!! ... 但不解釋又無法說明原理  )

本篇會說明如何在 Unity 上使用 Raymarching,

實作是使用 Shader 來運算,因此需要對 Unity Shader  有一定的理解。

  • Unity 實作
在上一篇談到實作 Raymarching 需要知道

  1. 所有的射線資料
  2. 所有障礙物的資料(包含座標、形狀、顏色等 ...)
先從一個簡單的範例來看


這段 Shader Code 主要是使用 Raymarching 在畫面畫出一個球。




先來看看實作上遇到的第一個問題:如何得到所有的射線資料?

射線需要起點和終點,

根據 Raymarching 原理,

起點位置就是攝影機位置,終點位置就是可視範圍(Frustum)的所有點。

因此,只要在 vertex shader 紀錄節點的世界座標,經過 Rasterizing (光柵化) 後,

在 fragment shader 內就可以取的可視範圍內所有點的世界座標。

struct appdata
{
    float4 vertex : POSITION;
    float2 uv : TEXCOORD0;
};

struct v2f
{
    float2 uv : TEXCOORD0;
    float3 center : TEXCOORD1;
    float3 posWorld : TEXCOORD2;
    float4 vertex : SV_POSITION;
};

v2f vert (appdata v)
{
    v2f o;
    o.vertex = UnityObjectToClipPos(v.vertex);
    o.center = mul(unity_ObjectToWorld, float4(0,0,0,1)).xyz;
    o.posWorld = mul(unity_ObjectToWorld, v.vertex).xyz;
    o.uv = v.uv;
    return o;
}

fixed4 frag (v2f i) : SV_Target
{
    // ... 省略

    float3 cameraPos = _WorldSpaceCameraPos.xyz;
    float3 worldPos = i.posWorld.xyz;
    float3 ray = normalize(worldPos - cameraPos);

    // ... 省略
}

得到了射線資料後,接下來要知道障礙物資料。

這一段記錄著障礙物資料

/*
* pos    = 目前 Raymarching 的起點位置
* center = 球的圓心座標
* size   = 球的半徑
*/
float dist_func(float3 pos, float3 center, float size)
{
    return length(pos - center) - size;
}

仔細一看,這不就是計算任何一點到圓的最短距離的演算法!

如果,我修改這個演算法,改成計算任何一點到環面(Torus)的最短距離

/*
* pos    = 目前 Raymarching 的起點位置
* center = 環面的圓心座標
* size   = 環面的半徑(外側,內側)
*/
float dist_func(float3 pos, float3 center, float2 size)
{
    float3 p = pos - center;
    float2 q = float2(length(p.xy)-size.x,p.z);
    return length(q)-size.y;
}

結果得到



從以上說明,可以知道:

  • 障礙物的形狀,可以透過計算任何一點到障礙物的最短距離求得
  • 計算最短距離的函式,通常稱做 distance function

在這裡,我推薦 iquilezles.org 這個網頁,

裡面記載了許多障礙物形狀的 distance function,並說明如何顯示兩種以上的障礙物,

和交集、聯集、邊緣平滑化等 ... 方法。


最後,需要額外提及這一段程式碼

for(int i = 0; i < 16; ++i)
{
    float D = dist_func(cur, centerPos, sphereSize);
    if(D < 0.0001)
    {
        float3 normalDirection = getNormal(cur, centerPos, sphereSize);
        float NdotL = dot(normalDirection, lightDirection);
        col.rgb = NdotL + 0.1;
        col.a = 1;
        break;
    }
    cur += ray * D;
}

這一段就是根據 Raymarching 起點位置到障礙物的最短距離,

判斷是否小於一個門檻值(範例使用 0.001),

當大於門檻值,Raymarching 起點位置往射線方向移動
當小於門檻值,回傳顏色資料並跳出迴圈

這裡的 Raymarching 移動,我設定做最大16次的計算,

這個數值越大,則計算的形狀越準確,但會增加效能負擔。

可以根據需求作修改。

在計算法線,是使用這個演算法

/*
* pos    = 目前 Raymarching 的起點位置
* center = 環面的圓心座標
* size   = 環面的半徑(外側,內側)
*/
float3 getNormal(float3 pos, float3 center, float2 size)
{
    float ep = 0.0001;

    float D = dist_func(pos, center, size);

    return normalize(
        float3 (
            D - dist_func(pos - float3(ep,0,0), center, size),
            D - dist_func(pos - float3(0,ep,0), center, size),
            D - dist_func(pos - float3(0,0,ep), center, size)
        )
    );
}

這一段是對射線和障礙物發生衝突的位置,對X、Y、Z軸作微分,計算出三軸方向的斜率。

來求得該點的法線向量近似值。



  • 總結

由範例可以得知,在做 Raymarching 就是把 shader 當 script 來寫,

需要提供圓心座標、障礙物半徑(可以寫死,或透過其他方式傳入)。

用 for 迴圈計算,必要時用到 if - else 判斷,

這些對於從以前就開始寫 shader 的人,是很難去接受的觀念。

但GPU的效能已經不同以往了,不再是那麼的經不起摧殘。

目前有些遊戲都將複雜的浮點數運算都交由GPU處理(上一篇的快速傅立葉轉換也是)。

所以該是放棄成見,擺脫舊觀念,放手去操GPU的時機了。


2020年2月4日 星期二

今晚來點 Raymarching (1)

  • 前言

幾個月前,聽到公司同事談論到在2020年 mobile 的效能將強大到能夠支援 Raymarching,

這句話讓一個走向墮落的工程師,明明一把年紀了,卻又不自覺地燃燒起來,

決定要先一步走在時代的前端,即使那時候已經在賣雞排了。 HA!HA!!HA!!!

(´;ω;`)


回歸主題,這一次的內容是討論如何在 Unity 上,使用 Raymarching 的技術來做出特殊效果,

環境限制在PC平台上,不做效能優化,只作為學習的一個環節。

本篇先簡單介紹什麼是 Raymarching ,Unity 上的實作和應用會在其他篇作闡述。

  • Raymarching ?

什麼是 Raymarching ? 在講 Raymarching 之前必須要先談到  Ray tracing,

Ray tracing 顧名思義就是射線追蹤,在講明白一點,就是跟著射線走,

把射線走過的軌跡記錄下來,作為繪圖的依據。

(在這裡,射線可以有很多種解釋,太陽的光線和眼睛的視線都算是射線)

Ray tracing 就是根據這個原則所發展的技術總稱。

Raymarching 則是 Ray tracing 原則下的一個分支技術。


Raymarching 可以想像成從攝影機,在其可視範圍內,發射出無數條像雷射般的射線。

當這條射線撞到障礙物時,就把障礙物的資料回傳到螢幕對應的位置上。

感覺上可以用下圖解釋


紅色的射線有撞到藍色的物體,會停止前進,並把藍色畫到螢幕上。

反之,若沒有撞到任何物體,則不會在螢幕上出現任何顏色。


大原則就講到這裡,將下來開始去思考如何實現。

在實現 Raymarching 上,首先要考慮如何判斷射線衝突,

在本文章是使用 Sphere Tracing 的方式去實現 Raymarching (註1)。


  •  Sphere Tracing

Sphere Tracing 的示意圖,可以用下圖來解釋


首先,要注意的是紅色的箭頭,紅色箭頭代表著離起點最近障礙物的最短距離。

射線的起點在攝影機,離起點最近的障礙物是綠色球

因此,先求出起點和綠色球的最短距離(也就是紅色箭頭),

之後讓起點朝射線方向前進,前進的距離就是起點和綠色球的最短距離。
(射線方向並不會受到任何影響而轉向)


之後,重複相同步驟,當移動距離小於一個定值,就判定發生衝突,
並回傳衝突障礙物資料作為繪圖來源。

如上圖,起點位置最後會非常接近藍色球,所以結果會在螢幕對應位置上畫出藍色

因為射線移動軌跡上是呈現很多個半徑不同同心圓,所以又稱 Sphere Tracing。


到了這裡,可以整理出要實現 Sphere Tracing 必須要有:
  1. 所有的射線資料
  2. 所有障礙物的資料(包含位置、形狀、顏色等...)
詳細的作法將於下一篇說明。


  • 註解
  1. Sphere Tracing 是實現 Raymarching 的一個手段, 而 Raymarching 是 Ray tracing 原則下的一個技術。看起來很複雜,其實只要想成 Ray tracing(=概念),Raymarching(=技術),Sphere Tracing(=方法)