2020年2月5日 星期三

今晚來點 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的時機了。


沒有留言:

張貼留言