在Unity中渲染一個黑洞

GuyaWeiren發表於2021-10-07

在Unity中渲染一個黑洞

前言

N年前觀看《星際穿越》時,被其中的“卡岡圖雅”黑洞所震撼。製作團隊表示這是一個最貼近實際的黑洞效果,因為它是通過各種科學理論實現的。當時就想自己也做一個差不多的出來,無奈技術太菜。現在以掉了一堆頭髮為代價,終於實現出來了,分享給大家。這是最終效果:

本專案使用Unity 2018.4.23f1製作,完整專案請移步GitHub:https://github.com/RenChiyu/UnityBlackHole

轉載請註明出處:https://www.cnblogs.com/GuyaWeiren/p/15376286.html

基礎概念

從某度查詢資料得知,目前理論上將黑洞分為如下四種型別:

  1. 史瓦西黑洞(沒有電荷,不旋轉)
  2. R-N黑洞(有電荷,不旋轉)
  3. 克爾黑洞(沒有電荷,旋轉)
  4. 克爾-紐曼黑洞(有電荷,旋轉)

這裡我們以史瓦西黑洞為目的進行實現。因為它沒有自旋且不帶電荷,所以實現起來(比如套公式時)會比較方便。

一個黑洞如圖所示,可以簡單地視作三個部分:

1. 奇點

奇點是視覺上黑洞的中心部分,它是一個質量非常大,而密度趨近無限大的結構。

2. 事件視界

事件視界點簡單理解就是,以黑洞的奇點為中心,第二宇宙速度小於光速的區域。從外部來看,事件視界內部的物體因為逃逸速度大於光速,導致光無法從該區域射出,因此在視界外的觀測者眼中呈現一片黑色。這個區域可以視作一個黑色的球。

3. 吸積盤

吸積盤是物體向奇點跌落的過程中,物體由於奇點的強大引力造成的摩擦和壓縮所釋放出的電磁波輻射。吸積盤中的物質通常是高溫氣體,圍繞著黑洞做高速旋轉。它看起來像一個會發出明亮光線的盤。

除開以上三個,根據廣義相對論,質量會使空間發生扭曲。光線經過這個扭曲空間時發生的偏移現象稱之為引力透鏡現象。質量越大,扭曲越嚴重,黑洞的質量必然會使空間發生明顯的扭曲,這也就是為什麼“卡岡圖雅”看上去有一個兩個星環(一個水平,一個垂直)的原因,其中垂直的星環就是水平星環被透鏡扭曲形成的虛像。引力透鏡可以讓觀測者看到被大質量天體遮擋的光源,從下圖可以大概看出引力透鏡的作用:

實現思路

在Unity中,光是沿直線傳播的,沒有辦法轉彎。《星際穿越》的特效團隊為此特意打造了一套渲染引擎來實現它。對我們來說,如此高成本的活當然是duck不必的,需要採用另一種思路:光線步進法。

和引擎的渲染不同,光線步進的原理是反向操作致敬韋神:從攝像機經過每一個畫素往外發射一個點,不斷延長直到接觸到的東西,再將碰撞處的顏色顯示在對應畫素上。這個過程是可以被我們的程式碼控制的,因此我們可以通過控制步進的總長度和每次步進的方向來反向實現扭曲的光。螢幕就像畫布,而每一個檢測點就是畫筆。

因此,我們需要知道光線是怎麼扭曲的。

公式推導

由於光的路徑不是因重力而扭曲,這裡不能簡單用牛頓第二定律描述,而應當使用愛因斯坦引力場方程

\[G_{\mu v}=R_{\mu v}-\frac{1}{2}g_{\mu v}R=\frac{8\pi G}{c^4}T_{\mu v} \]

這是一個二階非線性偏微分方程,直接求解非常困難。我們模擬史瓦西黑洞,可以使用方程的一個特殊解:史瓦西度規。它表示扭曲只取決於質量,忽略自旋和電荷:

\[\mathrm{d}s^2=c^2\left(1-{\frac{2GM}{c^2r}}\right)\mathrm{d}t^2-\left(1-{\frac{2GM}{c^2r}}\right)^{-1}\mathrm{d}r^2-r^2\mathrm{d}\Omega^2 \]

\(c=1\),設史瓦西半徑(即黑洞的事件視界半徑)\(r_s=\frac{2GM}{c^2}=1\),再引入球極座標,即\(\mathrm{d}\Omega^2=\mathrm{d}\theta^2+\sin^2\theta\mathrm{d}\varphi^2\)。由於史瓦西黑洞附近的空間是球對稱的,還可以令\(\theta=\frac{\pi}{2}\)。於是有:

\[\mathrm{d}s^2=\left(1-\frac{1}{r}\right)\mathrm{d}t^2-\left(1-\frac{1}{r}\right)^{-1}\mathrm{d}r^2-r^2\mathrm{d}\varphi^2 \]

其中,\(r\)\(t\)\(\varphi\)都是史瓦西座標系下的引數。

現在有了描述扭曲空間的方程,還需要一個方程用於描述光子在其中的運動軌跡。得到軌跡就能微分得到用於計算光線步進的方向方程。測地線方程用於描述在空間中兩點之間的最短路徑,完全符合需求,因此我們要將史瓦西度規套入測地線方程中。

測地線方程一般形式為:

\[\frac{\mathrm{d}U^\mu}{\mathrm{d}\lambda}+\Gamma_{\alpha\beta}^{\mu}U^\alpha U^\beta=0 \]

然後提取史瓦西度規中的兩個守恆量:

  1. \(L=r^2\frac{\mathrm{d}\varphi}{\mathrm{d}\lambda}\)
  2. \(E=\left(1 -\frac{1}{r}\right)\frac{\mathrm{d}t}{\mathrm{d}\lambda}\)

對於測地線方程,\(L\)為角動量,\(E\)為系統能量。

光子的運動是類光世界線,有\(g_{\mu\nu}U^\mu U^\nu=0\),於是有:

\[\left(\frac{\mathrm{d}r}{\mathrm{d}\lambda}\right)^2=E^2-\frac{L^2}{r^2}\left(1-\frac{1}{r}\right) \]

這樣可以用\(E\)消去等式中的仿射參量\(\lambda\)。同時令\(u=\frac{1}{r}\),能得到:

\[\left(\frac{\mathrm{d}u}{\mathrm{d}\varphi}\right)^2=\frac{E^2}{L^2}-u^2(1-u) \]

由於\(E\)\(L\)都是常量,於是兩邊對\(\varphi\)求導,能得到:

\[\frac{\mathrm{d}^2u}{\mathrm{d}\varphi^2}=u+\frac{3}{2}u^3 \]

注意到上式和比耐公式非常相似。比耐公式又叫軌道微分方程:

\[\frac{\mathrm{d}^2u}{\mathrm{d}\varphi^2}+u=-\frac{\mathbf{F}(u)}{m h^2u^2} \]

上式中的\(\mathbf{F}\)是粒子受到的向心力,也就是偏轉方向。這是我們需要的結果。\(m\)是粒子的質量,令\(m=1\),最終可以得到:

\[\mathbf{F}(r)=-\frac{3}{2}h^2\frac{r}{r^5} \]

渲染實現

得到了最關鍵的公式,接下來就是奧利給幹啦兄弟們!

SDF簡介

在開始敲程式碼前,先介紹一下後面會用到的SDF。它的全稱是Signed Distance Field,中文名為有向距離場。SDF函式描述了一個圖形的區域,我們習慣性地設定它的規則是點在圖形內部則返回負值,點在圖形外部返回正值。在光線步進法中,利用各種SDF函式可以繪製出不同的圖形。如下是一個以原點為中心點,半徑為1的球體的SDF函式:

// @param pPosition 需要判定的點
fixed sdfSphere(fixed3 pPosition)
{
    return length(pPosition) - 1;
}

在這裡可以找到更多圖形的SDF函式:https://iquilezles.org/www/articles/distfunctions/distfunctions.htm

準備資源

準備一個天空盒的Cubemap,建立C#指令碼Shader材質球

  • 指令碼需要掛在Camera上做後處理

正式開衝

我們在畫素著色器中對每一個畫素往外發射一道光線,最終碰撞到天空盒上:

struct appdata
{
    fixed4 vertex : POSITION;
    fixed2 uv     : TEXCOORD0;
};

struct v2f
{
    fixed4 vertex : SV_POSITION;
    fixed3 rayDir : TEXCOORD0;
};

v2f vert (appdata i)
{
    v2f o;
    o.vertex = UnityObjectToClipPos(i.vertex);
    // 變換得到螢幕四個角向外的射線
    fixed3 dir = mul(unity_CameraInvProjection, fixed4(i.uv * 2.0f - 1.0f, 0.0f, -1.0f));
    o.rayDir = normalize(mul(unity_CameraToWorld, fixed4(dir, 0.0f)));
    return o;
}

fixed4 frag (v2f i) : SV_Target
{
    const fixed step = 0.1; // 步進長度,太大會有橫紋

    fixed3 pos = _WorldSpaceCameraPos;
    fixed3 dir = i.rayDir * step;

    fixed4 color = fixed4(0, 0, 0, 1);

    UNITY_LOOP
    for (int i = 0; i < 300; i++)
    {
        // 步進
        pos += dir;
    }

    // 天空盒
    fixed4 skyBox = texCUBE(_SkyBoxTex, dir);
    color.rgb += DecodeHDR(skyBox, _SkyBoxTex_HDR).rgb;

    return color;
}

如果沒有問題,在執行起來後能看到天空盒。

繪製事件視界

這個非常簡單,直接使用球的SDF:

// 事件視界
if (eventHorizon(pos)) < 0)
{
    return fixed4(color, 1);
}

由於靠近觀察者的吸積盤顏色需要蓋在事件視界上,所以不能直接返回黑色。

繪製吸積盤

吸積盤也沒什麼別的,大概三個要素:

  1. 一個旋轉的圓形
  2. 越靠近奇點吸積盤的溫度越高,也就是更加明亮
  3. 雲狀紋理

雲狀噪聲圖很適合作為吸積盤紋理。在Photoshop中使用分層雲彩可以快速製作出一個噪聲圖。

於是可以編寫吸積盤的繪製程式碼:

fixed3 accretionDisk(fixed3 pPosition)
{
    const fixed MIN_WIDTH = 2.6; // 由於引力透鏡,事件視界看起來是沒有引力透鏡的2.6倍

    fixed r = length(pPosition);

    fixed3 disk = fixed3(_AccretionDiskWidth, 0.1, _AccretionDiskWidth); // 視作一個壓扁的球
    if (length(pPosition / disk) > 1)
    {
        return fixed3(0, 0, 0);
    }
    fixed temperature = max(0, 1 - length(pPosition / disk));
    temperature *= (r - MIN_WIDTH) / (_AccretionDiskWidth - MIN_WIDTH);
    // 座標轉換為球極座標系
    fixed t = atan2(pPosition.z, pPosition.x); // θ
    fixed p = asin(pPosition.y / r); // φ
    fixed3 sphericalCoord = fixed3(r, t, p);
    fixed noise = 0;
    // 使用三層噪聲疊加出雲的紋理
    UNITY_LOOP
    for (int i = 1; i < 4; i++)
    {
        fixed2 noiseUV;
        fixed speedFactor;
        if(i % 2 == 0) // 雲和環狀效果
        {
            noiseUV = sphericalCoord.xy;
            speedFactor = 1;
        }
        else
        {
            noiseUV = sphericalCoord.xz;
            speedFactor = -1;
        }
        noise += tex2D(_AccretionDiskTex, noiseUV * pow(i, 3)).r;
        sphericalCoord.y += _AccretionDiskSpeed * _Time.x * speedFactor;
    }
    // 橙紅色作為吸積盤顏色
    fixed3 color = fixed3(1, 0.5, 0.4);
    return temperature * noise * color * _AccretionDiskBright;
}

繪製引力透鏡效果

根據上文推算出的公式,直接計算出步進方向偏移量疊加上去:

fixed3 gravitationalLensing(fixed pH2, fixed3 pPosition)
{
    fixed r2 = dot(pPosition, pPosition);
    fixed r5 = pow(r2, 2.5);
    return -1.5 * pH2 * pPosition / r5;
}

fixed3 h = cross(pos, dir);
fixed h2 = dot(h, h);
// ...
for (int i = 0; i < 300; i++)
{
    // ...
    // 引力透鏡
    fixed3 offset = gravitationalLensing(h2, pos);
    dir += offset;

    pos += dir;
}

這樣就完成了黑洞和吸積盤的渲染。執行起來,調整一下攝像機角度,可以看到:

後續處理

加上抗鋸齒柔化硬邊,再加上Bloom讓明亮處更加柔和,調整一下攝像機的位置和角度就OJBK了。也可以根據喜好加上其他的後處理調色。這是我調出的最終效果:

Bloom沒有使用AssetStore中的,因為都特麼要收費。放上我使用的連結

後記

有一種豐收的喜悅,做完之後非常開心,渾身充滿了力量。

很慚愧,就做了一點微小的工作,謝謝大家。

相關文章