在Unity中渲染一个黑洞
前言
N年前观看《星际穿越》时,被其中的“卡冈图雅”黑洞所震撼。制作团队表示这是一个最贴近实际的黑洞效果,因为它是通过各种科学理论实现的。当时就想自己也做一个差不多的出来,无奈技术太菜。现在以掉了一堆头发为代价,终于实现出来了,分享给大家。这是最终效果:
本项目使用Unity 2018.4.23f1制作,完整项目请移步GitHub:https://github.com/RenChiyu/UnityBlackHole
转载请注明出处:https://www.cnblogs.com/GuyaWeiren/p/15376286.html
基础概念
从某度查询资料得知,目前理论上将黑洞分为如下四种类型:
- 史瓦西黑洞(没有电荷,不旋转)
- R-N黑洞(有电荷,不旋转)
- 克尔黑洞(没有电荷,旋转)
- 克尔-纽曼黑洞(有电荷,旋转)
这里我们以史瓦西黑洞为目的进行实现。因为它没有自旋且不带电荷,所以实现起来(比如套公式时)会比较方便。
一个黑洞如图所示,可以简单地视作三个部分:
1. 奇点
奇点是视觉上黑洞的中心部分,它是一个质量非常大,而密度趋近无限大的结构。
2. 事件视界
事件视界点简单理解就是,以黑洞的奇点为中心,第二宇宙速度小于光速的区域。从外部来看,事件视界内部的物体因为逃逸速度大于光速,导致光无法从该区域射出,因此在视界外的观测者眼中呈现一片黑色。这个区域可以视作一个黑色的球。
3. 吸积盘
吸积盘是物体向奇点跌落的过程中,物体由于奇点的强大引力造成的摩擦和压缩所释放出的电磁波辐射。吸积盘中的物质通常是高温气体,围绕着黑洞做高速旋转。它看起来像一个会发出明亮光线的盘。
除开以上三个,根据广义相对论,质量会使空间发生扭曲。光线经过这个扭曲空间时发生的偏移现象称之为引力透镜现象。质量越大,扭曲越严重,黑洞的质量必然会使空间发生明显的扭曲,这也就是为什么“卡冈图雅”看上去有一个两个星环(一个水平,一个垂直)的原因,其中垂直的星环就是水平星环被透镜扭曲形成的虚像。引力透镜可以让观测者看到被大质量天体遮挡的光源,从下图可以大概看出引力透镜的作用:
实现思路
在Unity中,光是沿直线传播的,没有办法转弯。《星际穿越》的特效团队为此特意打造了一套渲染引擎来实现它。对我们来说,如此高成本的活当然是duck不必的,需要采用另一种思路:光线步进法。
和引擎的渲染不同,光线步进的原理是反向操作致敬韦神:从摄像机经过每一个像素往外发射一个点,不断延长直到接触到的东西,再将碰撞处的颜色显示在对应像素上。这个过程是可以被我们的代码控制的,因此我们可以通过控制步进的总长度和每次步进的方向来反向实现扭曲的光。屏幕就像画布,而每一个检测点就是画笔。
因此,我们需要知道光线是怎么扭曲的。
公式推导
由于光的路径不是因重力而扭曲,这里不能简单用牛顿第二定律描述,而应当使用爱因斯坦引力场方程:
\]
这是一个二阶非线性偏微分方程,直接求解非常困难。我们模拟史瓦西黑洞,可以使用方程的一个特殊解:史瓦西度规。它表示扭曲只取决于质量,忽略自旋和电荷:
\]
令\(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}\)。于是有:
\]
其中,\(r\)、\(t\)和\(\varphi\)都是史瓦西坐标系下的参数。
现在有了描述扭曲空间的方程,还需要一个方程用于描述光子在其中的运动轨迹。得到轨迹就能微分得到用于计算光线步进的方向方程。测地线方程用于描述在空间中两点之间的最短路径,完全符合需求,因此我们要将史瓦西度规套入测地线方程中。
测地线方程一般形式为:
\]
然后提取史瓦西度规中的两个守恒量:
- \(L=r^2\frac{d\varphi}{d\lambda}\)
- \(E=\left(1 -\frac{1}{r}\right)\frac{dt}{d\lambda}\)
对于测地线方程,\(L\)为角动量,\(E\)为系统能量。
光子的运动是类光世界线,有\(g_{\mu\nu}U^\mu U^\nu=0\),于是有:
\]
这样可以用\(E\)消去等式中的仿射参量\(\lambda\)。同时令\(u=\frac{1}{r}\),能得到:
\]
由于\(E\)和\(L\)都是常量,于是两边对\(\varphi\)求导,能得到:
\]
注意到上式和比耐公式非常相似:
\]
上式中的\(\mathbf{F}\)是粒子受到的向心力,就是我们需要的结果。\(m\)是粒子的质量,令\(m=1\),最终可以得到:
\]
这个公式表示,奇点坐标为\((0, 0, 0)\)时,坐标在\(r(x, y, z)\)所受到的加速度。其中,\(h=r^2\frac{\mathrm{d}\theta}{\mathrm{d}t}\)是粒子的角动量。
渲染实现
得到了最关键的公式,接下来就是奥利给干啦兄弟们!
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
上做后处理 - 第二个脚本用于鼠标控制
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);
}
由于靠近观察者的吸积盘颜色需要盖在事件视界上,所以不能直接返回黑色。
绘制吸积盘
吸积盘也没什么别的,大概三个要素:
- 一个旋转的圆形
- 越靠近奇点吸积盘的温度越高,也就是更加明亮
- 云状纹理
如果说还有一点那就是吸积盘的纹理。没有纹理,吸积盘光溜溜,一点也不真实。云状噪声图很适合作为吸积盘纹理。在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中的,因为都特么要收费。放上我使用的链接
后记
有一种丰收的喜悦,做完之后非常开心,浑身充满了力量。
很惭愧,就做了一点微小的工作,谢谢大家。