如何用Unity简单渲染一个黑洞
黑洞的基本构成
黑洞是恒星的一种,它的质量和引力巨大以致于连光都不能从内部逃脱。在黑洞周围,由于强引力的作用会引发时空非常大扭曲。这样,即使是被黑洞挡着的恒星发出的光,虽然有一部分会落入黑洞中消失,可另一部分也会通过弯曲的空间中绕过黑洞往前传播,这就是引力透镜效应。
黑洞有4种:
- 史瓦西黑洞(没有电荷,不旋转)
- R-N黑洞(有电荷,不旋转)
- 克尔黑洞(没有电荷,旋转)
- 克尔-纽曼黑洞(有电荷,旋转)
这里我们只讨论史瓦西黑洞(实际上克尔黑洞更常见)。
可以通过https://www.bilibili.com/video/BV11b411T74K来了解
事件视界
平常我们看到的黑洞中的黑的部分就是事件视界(不完全是)
上图中黄色的线就是光线,绿色圈内就是事件视界,光是不能从这里逃脱,所以看到的就是黑色。
但是由于引力透镜,实际上看到的时间视界大小是实际上的2.59倍。
引力透镜
还是上面那个图,可以看到靠近黑洞的这个光线都被扭曲了,这使得我们可以看到被黑洞遮挡的物体
爱因斯坦环(恒星外的一圈)就是由于引力透镜效应造成的。
视频参考:https://www.bilibili.com/video/BV1eW411C7Dk
吸积盘
事件视界之外的气体、星尘在黑洞强大的引力作用下,会朝向黑洞下落。这个过程被称作“黑洞吸积”。由于气体具有一定的角动量,这些气体在下落过程中会形成一个围绕黑洞高速旋转的盘状结构,如同太阳系的各大行星轨道平面一样,这就是黑洞吸积盘。
白白一圈一圈的就是吸积盘了。
喷流
吸积盘上的气体、星尘有部分会跨越事件视界落入黑洞,从而产生粒子,能量等从黑洞的两极接近光速喷射而出,形成相对论喷流。
那条一束就是喷流。
具体操作
光线步进 Ray Marching
为了渲染出真实的画面我们就需要使用光线追踪。
光线追踪的原理就跟它的名字一样,我们去模拟物理中的光线,光线是怎么走的,我们就跟着它走。具体来讲,一条光线从光源处出发,途中条光线可能会击中某个物体,然后就会发生折射或反射,并且会损失一部分光能。那些最终能射入我们的眼睛(或照相机)的光线,就会在我们的眼睛(或照相机)内留下影像,我们就看到了光来源位置的物体。而我们的渲染器就是去模拟一个这样的过程。
但是!!!如果我们只是这样模拟,会出现一个很大的问题:我们的照相机模型只是一个点,而我们的光线想要击中这样一个点的概率为0!那有没有办法解决这样一个问题呢。答案显然是肯定的。我们在娘胎就学过光路可逆原理,所以如果我们将所有光线反向,是不会影响正确性的,这样就变成了光从照像机发出射向光源,(实际上这就是以前人们认为我们是怎么看世界的)概率比之前大多了。因此,我们的算法变成了:从眼睛中投射出一条射线->这条击中物体后进行折射或反射->这条光线击中光源->获取光源的信息(颜色与亮度)->回溯以计算亮度的损失->最终在光屏上着色。
而光线步进法就是实现光线追踪的一种方法,它从屏幕的像素点出发,每次就走一小步,然后判断是否和物体相撞,如果相撞了我们就看到了这个物体。
具体步骤动画:Raymarching in Raymarching (shadertoy.com)
如何判断是否与物体相撞
我们可以使用SDF函数,它将返回某点(对于光线步进,我们每次检测的都是某一个位置)是否在某物体上,我们习惯性地设置它的规则是点在图形内部则返回负值,点在图形外部返回正值。比如对于一个在原点上的圆,来说有:
float sdSphere( vec3 p, float r )
{
return length(p)-r;
}
如何在Unity中使用光线步进
因为Unity的渲染中,默认光是直线传播的,所以我们不能直接使用unity来渲染,我们可以获取unity渲染好的画面(也就是原来空白的场景的图片)然后对其进行操作,具体操作如下。
/// <summary>
/// 在每一帧渲染完成后自动调用,简单来说就是对渲染图像进行处理
/// </summary>
/// <param name="source">unity渲染后的结果,如果不对它处理就是最终展现的画面</param>
/// <param name="destination">表示处理后的图像,也就是最终呈现给玩家的图像</param>
private void OnRenderImage(RenderTexture source, RenderTexture destination)
{
//声明一个和source大小相同的纹理变量
var tempTex = RenderTexture.GetTemporary(source.width, source.height);
/*
* "source":表示要拷贝的纹理。
* "dest":表示要拷贝到的渲染纹理。
* "mat":表示要使用的材质。
* "pass":表示要使用的材质的渲染通道。
*/
Graphics.Blit(source, tempTex, m_Mat, 0);//对原来的这个纹理进行处理,然后放到临时纹理上去
Graphics.Blit(tempTex, destination);
//释放内存
RenderTexture.ReleaseTemporary(tempTex);
}
以上函数可以写在任意一个脚本上,但是需要挂载到摄像机物体上才行。
总的来说以上操作就是获取一张和展示框(屏幕)大小相同的画布,然后我们写写画画(具体怎么画的方法就是m_Mat材质规定的)就是一个将我们的处理的图像传递给unity来呈现,和本身场景有什么无关系(被覆盖掉了)。
如何绘制
和正常的自定义shader一样,我们创建一个材质和一个shader,然后为材质选择我们创建的shader,将材质提供给前文的脚本使用(就是m_Mat),可以拖拽赋值或者用m_Mat = Resources.Load
实现光线步进
首先我们要知道这个屏幕上像素点相对于摄像机的方向(因为我们的光就是从摄像机向屏幕像素点上打过去。所以对于每一个像素点我们应该知道的是位置,和其相对于摄像机的方向。
struct appdata
{
fixed4 vertex : POSITION;
fixed2 uv : TEXCOORD0;//这个就是像素点的位置(0,1)
};
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));
// fixed4(i.uv * 2.0f - 1.0f, 0.0f, -1.0f) 就是将屏幕坐标(0,1)映射到(-1,1)
//unity_CameraInvProjection摄像机的逆投影矩阵。逆投影矩阵是投影矩阵的逆矩阵,用于将屏幕空间坐标变换到摄像机的世界坐标系中。
//简单来说这一行的代码的意思是获取屏幕上的某个点相对于相机的方向(在屏幕坐标系/投影空间下) 下面一行就是再变换到世界坐标系下
o.rayDir = normalize(mul(unity_CameraToWorld, fixed4(dir, 0.0f)));
return o;
}
对于片元着色器我们有
fixed4 frag (v2f i) : SV_Target
{
const fixed step = 0.12; // 步进长度,就是光每一步走多少
fixed3 lightPos = _WorldSpaceCameraPos;//将感知光子的坐标初始化为相机位置
fixed3 dir = i.rayDir * step;//步进向量
fixed3 color = fixed3(0,0,0)
int stepSum= 350;//步进次数,也就是你能看多远。
UNITY_LOOP
for (int i = 0; i < stepSum; i++)
{
fixed3 color += deal(lightPos);//对感知光子进行各种处理,看看有没有碰到什么,叠加感知到的颜色
lightPos+= dir;// 光子前进
}
return fixed4(color, 1);
}
绘制事件视界
也就是绘制黑洞,我们假定黑洞是在原点放着,事件视界半径是1。
fixed3 checkEventHorizon(fixed3 p){//检测p点是否在事件视界中
return length(p) - 1;
}
//在步进循环中有
UNITY_LOOP
for (int i = 0; i < stepSum; i++)
{
if(checkEventHorizon(lightPos)<0){
return fixed4(color, 1);//因为感知光子被吸走了不能再往后面看了,所以直接返回前面看到的
}
checkPos += dir;// 光子前进
}
return fixed4(color, 1);
}
实现引力透镜
引力透镜的本质就是光被扭曲了,所以我们只要模拟光在每一次步进时的扭曲就可以实现引力透镜
具体光是怎么被扭曲的公式推导可以看:Raytracing a Black Hole (rantonels.github.io)
结果大概是:
这里直接采用大佬给出的偏差值计算方法:
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(lightPos, dir);
fixed h2 = dot(h, h);
// ...
for (int i = 0; i < stepSum; i++)
{
// ...
// 引力透镜
fixed3 offset = gravitationalLensing(h2, lightPos);
dir += offset;
lightPos+= dir;
}
绘制吸积盘
我们可以将吸积盘视作为一个圆环,或者是圆柱之类的,然后越靠近黑洞的转速越快越亮(越热)
首先要判断是否在吸积盘上,不在则返回黑色,具体判断在于不在取决于使用什么模型来实现吸积盘。比如圆环可以分别判断高度y是否符合,水平距离xz是否符合。
if (length(position / fixed3(accretionDiskWidth,accretionDiskHeight, accretionDiskWidth)) > 1)
{
return fixed3(0, 0, 0);
}
//注意,由于引力透镜效应,看到的事件视界是实际上的2.6倍,所以长度小于2.6的点也应该返回黑色
其次实现越靠近越亮的效果:
fixed fade = smoothstep(accretionDiskWidth-2.6,2.6,r);//这个2.6就是由于引力透镜效应,看到的事件视界是实际上的2.6倍
对于在吸积盘上的点,我们进行采样,使用的噪音图是
具体使用何种噪声图看个人选择,主要要能实现类似云雾的效果
可以参考:图形噪声 (huailiang.github.io) 我的采样方法如下:
fixed t = atan2(position.z, position.x);
fixed p = asin(position.y / r)/3.14;
fixed ti = _Time.x * 2;
fixed n = tex2D(accretionDiskTex, fixed2(smoothstep(-3.14,3.14,t)+ti,
smoothstep(2.6,accretionDiskWidth,length(position.xz)+p)+ti)).r;
n+=tex2D(accretionDiskWidth, fixed2(smoothstep(-3.14,3.14,t)+ti/2,
smoothstep(2.6,accretionDiskWidth,length(position.xz)+p)+ti/2)).r;
n+=tex2D(accretionDiskWidth, fixed2(smoothstep(-3.14,3.14,t)+ti/4,
smoothstep(2.6,accretionDiskWidth,length(position.xz)+p)+ti/4)).r;
简单来说就是用方位角和天顶角作为uv进行采样,多次采样是因为噪音图不够密集,需要多次采样使得吸积盘没有那么空。
搭配时间则可以做到旋转效果。
而且采样我只取了rgba中的r分量,因为我只采样强度。
最后用n乘以设定的颜色乘以设定的强度乘以fade则可以返回
return n*c * luminance*fade;
喷流
喷流也类似于吸积盘,而且靠近黑洞侧是蓝色,远离黑洞侧是红色,所以需要进行颜色混合
fixed3 sport(fixed3 position){
fixed sportRadius = 0.2;
int sportHeight = 15;
fixed sjr = 2.05;
if(dot(position.xz,position.xz)<sportRadius*sportRadius&&abs(position.y)<sportHeight&&abs(position.y)>sjr){
fixed w = smoothstep(sjr,sportHeight,position.y);
fixed3 c = w*fixed3(0.3,0.3,0.6)+(1-w)*fixed3(0.6,0.3,0.3);
c = (1-smoothstep(0,sportRadius*sportRadius,dot(position.xz,position.xz)))*c;
c = (1-smoothstep(sjr,sportHeight,abs(position.y)))*c;
return c;
}
else{
return fixed3(0,0,0);
}
}
背景
可以直接对天空盒采样然后加到颜色上(注意要在循环外)
fixed4 skyBox = texCUBE(skyBoxTex, dir);//dir向远处射去
color += DecodeHDR(skyBox, skyBoxTex_HDR).rgb*1.75;//1.75是提高天空盒亮度。
注意 图像 Texture Shape要设置为cube
改进处理
bloom
使用bloom来制作光晕。
ToneMapping
改善光线步进叠加颜色导致的过曝。
效果展示
参考
在Unity中渲染一个黑洞 - GuyaWeiren - 博客园 (cnblogs.com)
[图形学] 实时体积云(Horizon: Zero Dawn)_ZJU_fish1996的博客-CSDN博客_体积云
Raytracing a Black Hole (rantonels.github.io)
Ray Tracing a Black Hole in C# - CodeProject