在Unity中渲染一个黑洞

Posted GuyaWeiren

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了在Unity中渲染一个黑洞相关的知识,希望对你有一定的参考价值。

在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中的,因为都特么要收费。放上我使用的链接

后记

有一种丰收的喜悦,做完之后非常开心,浑身充满了力量。

很惭愧,就做了一点微小的工作,谢谢大家。

如何在 webgl 中使用 uv 纹理包裹空间以制作黑洞

【中文标题】如何在 webgl 中使用 uv 纹理包裹空间以制作黑洞【英文标题】:How to wrap space to make a black hole using uv textures in webgl 【发布时间】:2019-08-22 18:22:01 【问题描述】:

我渲染了一个网格纹理。我想在片段着色器中操纵 uv 坐标(vQuadCoord)以产生黑洞效果,即当线条接近中心时,线条之间的间隙会走得更远。也有圆形效果

我认为这是可能的,因为如果我这样做vQuadCoord = vQuadCoord * vQuadCoord,它会达到类似的效果,但在角落里。

const fShaderSource = `#version 300 es

precision mediump float;

out vec4 outColor;

uniform sampler2D u_texture;

in vec2 vQuadCoord;

void main() 
  outColor = texture(u_texture, vQuadCoord);


`;

const vShaderSource = `#version 300 es

precision mediump float;

in vec2 a_position;

out vec2 vQuadCoord;

void main() 
  vQuadCoord = (a_position + 1.0) / 2.0;

  gl_Position = vec4(a_position, 0, 1);

`;

main(document.getElementById('app'));

function main(element) 
  
  const canvas = document.createElement('canvas'),
        gl = canvas.getContext('webgl2');
  element.append(canvas);
  const displayWidth = canvas.clientWidth,
        displayHeight = canvas.clientHeight;
  canvas.width = displayWidth;
  canvas.height = displayHeight;


  let graphics = new Graphics(width: displayWidth, height: displayHeight, gl);
  
  new Loop(() => 
     graphics.render();
  ).start();


function Graphics(state, gl) 

  const  width, height  = state;

  gl.clearColor(0, 0, 0, 0);
 
 
  gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
  //gl.blendFunc(gl.SRC_ALPHA, gl.ONE);
  gl.enable(gl.BLEND);
  gl.disable(gl.DEPTH_TEST);

  
  
  let minibatch = [];
  
  const redText = makeGlQuad(gl, fShaderSource, canvasTexture());

  this.render = () =>   

    minibatch.push(redText);
    
    gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
    gl.clear(gl.COLOR_BUFFER_BIT);

    minibatch.forEach((
      program,
      resUniformLocation,
      vao,
      glTexture
    ) => 

    gl.useProgram(program);

    gl.uniform2f(resUniformLocation, gl.canvas.width, gl.canvas.height);
    
    if (glTexture) 
      gl.activeTexture(gl.TEXTURE0);
      gl.bindTexture(gl.TEXTURE_2D, glTexture);
    
    
    gl.bindVertexArray(vao);
    gl.drawArrays(gl.TRIANGLES, 0, 6);
  );
  minibatch = [];
  ;



function makeGlQuad(gl, fShaderSource, texture) 

  let vShader = createShader(gl, gl.VERTEX_SHADER, vShaderSource);
  let fShader = createShader(gl, gl.FRAGMENT_SHADER, fShaderSource);

  let program = createProgram(gl, vShader, fShader);

  let posAttrLocation = gl.getAttribLocation(program, "a_position");
  let posBuffer = gl.createBuffer();

  gl.bindBuffer(gl.ARRAY_BUFFER, posBuffer);


  let left = -1,
      right = 1,
      down = -1,
      up = 1;

  /*
    (-1, 1).( 1, 1)
        .
    (-1,-1).( 1,-1)
   */
  let positions = [
      left, down,
      left, up,
      right, down,
      left, up,
      right, down,
      right, up

  ];

  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW);


  let vao = gl.createVertexArray();
  gl.bindVertexArray(vao);

  gl.enableVertexAttribArray(posAttrLocation);

  let size = 2,
      type = gl.FLOAT,
      normalize = false,
      stride = 0,
      offset = 0;

  gl.vertexAttribPointer(posAttrLocation,
                         size,
                         type,
                         normalize,
                         stride,
                         offset);

  let glTexture;
  if (texture) 
    glTexture = gl.createTexture();
    gl.bindTexture(gl.TEXTURE_2D, glTexture);
  
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, texture);
    //gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array([0, 0, 255, 255]));


    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
  



  let resUniformLocation = gl.getUniformLocation(program, "u_resolution");
  let texUniformLocation = gl.getUniformLocation(program, "u_texture");
 
 
  return 
   program,
   resUniformLocation,
   vao,
   glTexture
  


function canvasTexture() 
 
 return withCanvasTexture(256, 256, (w, h, canvas, ctx) => 
  const gap = w * 0.07;

  ctx.fillStyle = 'green';
  ctx.fillRect(0, 0, 10, 10);

  ctx.strokeStyle = 'red';
  ctx.lineWidth = 1;
  ctx.beginPath();

  for (let i = 0; i < w; i+= gap) 
    ctx.moveTo(i, 0);
    ctx.lineTo(i, h);
  
  for (let i = 0; i < h; i+= gap) 
    ctx.moveTo(0, i);
    ctx.lineTo(w, i);
  
  ctx.stroke();

  return canvas;

 );
 
function withCanvasTexture(width, height, f) 
  var canvas = document.createElement('canvas');
  canvas.width = width;
  canvas.height = height;
  f(width, height, canvas, canvas.getContext('2d'));
  const texture = canvas;
  document.body.append(canvas);
  return texture;



function createShader(gl, type, source) 
  let shader = gl.createShader(type);
  gl.shaderSource(shader, source);
  gl.compileShader(shader);
  let success = gl.getShaderParameter(shader, gl.COMPILE_STATUS);

  if (success) 
    return shader;
  

  console.error(gl.getShaderInfoLog(shader));
  gl.deleteShader(shader);
  return null;
;

function createProgram(gl, vShader, fShader) 
  let program = gl.createProgram();
  gl.attachShader(program, vShader);
  gl.attachShader(program, fShader);
  gl.linkProgram(program);
  let success = gl.getProgramParameter(program, gl.LINK_STATUS);
  if (success) 
    return program;
  

  console.error(gl.getProgramInfoLog(program));
  gl.deleteProgram(program);
  return null;



// Loop Library
function Loop(fn) 

const perf = window.performance !== undefined ? window.performance : Date;

const now = () => perf.now();

const raf = window.requestAnimationFrame;

  let running = false,
      lastUpdate = now(),
      frame = 0;

  this.start = () => 
    if (running) 
      return this;
    

    running = true;
    lastUpdate = now();
    frame = raf(tick);
    return this;
  ;

  this.stop = () => 
    running = false;

    if (frame != 0) 
      raf.cancel(frame);
    

    frame = 0;
    return this;
  ;

  const tick = () => 
    frame = raf(tick);
    const time = now();
    const dt = time - lastUpdate;
    fn(dt);
    lastUpdate = time;
  ;
#app canvas 
  background: #ccc;
  position: fixed;
  top: 50%;
  bottom: 0;
  left: 50%;
  right: 0;

  width: 100vmin;
  height: 70vmin;

  transform: translate(-50%, -25%);

  image-rendering: optimizeSpeed;
  cursor: none;
  margin: auto;
<div id="app">
</div>

【问题讨论】:

还有为什么纹理会翻转? 不确定是否有帮助我前段时间创建了一个着色器,我把它留在这里shadertoy.com/view/MlByzd 【参考方案1】:

[...] 在片段着色器中制作黑洞效果,即当线条接近中心时,线条之间的间隙会变得更远。

您必须执行(1.0 - (1.0 - abs(x)) * (1.0 - abs(x))) 之类的操作。 x 是一个坐标,其中 (0,0) 位于纹理的中心。

将纹理坐标从 [0, 1] 范围转换为 [-1, 1] 范围:

vec2 p  = vQuadCoord * 2.0 - 1.0;

计算“黑洞效应”坐标:

p = sign(p) * (1.0 - (1.0 - abs(p)) * (1.0 - abs(p)));

从范围 [-1, 1] 转换回 [0, 1]:

vec2 uv = p * 0.5 + 0.5;

对于圆形效果,您必须将归一化方向矢量乘以一个因子,该因子取决于到中心的平方距离或到边界的距离:

p =  normalize(p) * length(p) * length(p);

p = normalize(p) * (1.0 - (1.0 - length(p)) * (1.0 - length(p))) 

片段着色器:

precision mediump float;

out vec4 outColor;

uniform sampler2D u_texture;

in vec2 vQuadCoord;

void main() 
    vec2 p  = vQuadCoord * 2.0 - 1.0;

    //p = sign(p) * (1.0 - (1.0 - abs(p)) * (1.0 - abs(p)));
    //p = normalize(p) * (1.0 - (1.0 - length(p)) * (1.0 - length(p)));
    p =  normalize(p) * length(p) * length(p);

    vec2 uv = p * 0.5 + 0.5;
    outColor = texture(u_texture, uv);


要对纹理进行 y 翻转,您可以设置 UNPACK_FLIP_Y_WEBGL 标志。见WebGL 2.0, 5.14.8 Texture objects:

gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true); 
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, texture);

【讨论】:

实际上我希望线条循环。也就是说,当线条到中心的距离相等时,间隙应该相同。这样做的问题是,角落与边缘的间隙相同,但我认为角落离中心更远, 您能否分享您的信息来源,例如您如何知道导致这种情况的原因。 @eguneys 对不起,但来源是我的大脑。那只是数学。我知道这样的事情,因为坐标转换和 glsl 是我日常工作的一部分。 @eguneys 您可能对Issue getting gradient square in glsl es 2.0, Gamemaker Studio 2.0 或Coloring rectangle in function of distance to nearest edge produces weird result in diagonals 的答案感兴趣。 你将如何控制失真的影响,就像我将影响设置为零将没有效果,如果我将其设置为一,效果将是可见的,介于两者之间的一切都会被插值

以上是关于在Unity中渲染一个黑洞的主要内容,如果未能解决你的问题,请参考以下文章

[Unity Shader] 渲染管线流程

Unity中Shader的三种基本类型

Unity Shader入门精要学习笔记 - 第5章 开始 Unity Shader 学习之旅

maya动画导入unity怎么播放

Unity5 GI与PBS渲染从用法到着色代码

解读Unity中的CG编写Shader系列3——表面剔除与剪裁模式