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 才能使前後方向正常。


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

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

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


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

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

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




沒有留言:

張貼留言