黑洞的动画特效(html版本)

一个单文件 WebGL2 实时黑洞渲染器:每个像素都在史瓦西时空里反向积分一条零测地线,所以画面里的现象几乎都是算出来的,而不是贴图或后期特效。

文件:

• 

index.html --- 单文件、零依赖,双击即可运行(无需联网、无需服务器)

• 

README.md --- 物理模型、数值方法、参数说明

遵循的物理

• 度规取 Schwarzschild(r_s = 1,M = 0.5),零测地线方程 d²x/dλ² = −(3/2)h²x/r⁵

• 吸积盘内缘取 ISCO 3 r_s,温度用 Shakura--Sunyaev 零力矩解(峰温在49/36·r_in),强度 ∝ T⁴,颜色由黑体谱给出

• 频移 g = √(1−3M/r)/(1−v·n̂),v = √(M/(r−2M))(ISCO 处正好 0.5c),强度 ∝ g⁴,观测温度 T_obs = g·T_emit

• 盘面纹理冻结在流体元上随 Ω(r)=√(M/r³)差动转动,自然剪切出螺旋结构;光线多次穿越赤道面时按光学厚度做 alpha 合成,于是高阶像(盘面上下的弧)自动出现

• 星空是程序化生成的,会被引力透镜弯折,因此能看到爱因斯坦环

实测校验(默认相机 D = 28 r_s)

• 阴影角半径:理论 5.23° ≈ 89.6 px(视场 42°),实测 89--90 px,误差约 1%

• 开启多普勒时画面左右半亮度比 0.52,关闭后 0.97(近对称),与相对论增亮预期一致

• 阴影内部采样为纯黑 (0,0,0);关闭透镜后阴影收缩为视界本影、盘面不再弯曲

界面里可以切换吸积盘 / 引力透镜 / 多普勒、调节盘面峰值温度(2200--30000 K)、增亮强度、曝光与时间流速;关闭引力透镜能直观对比弯曲时空与平直时空的差别。渲染质量分四档并带自动性能调节,切标签页或丢帧时会自动升降分辨率。

html 复制代码
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<title>史瓦西黑洞 · 实时引力透镜渲染</title>
<style>
  :root{
    --fg:#e9eefb; --dim:#8e9dbd; --line:rgba(126,166,255,.16);
    --panel:rgba(8,11,19,.74); --accent:#7db0ff;
  }
  *{box-sizing:border-box}
  html,body{margin:0;height:100%;background:#000;overflow:hidden;color:var(--fg);
    font:13px/1.55 "Segoe UI",system-ui,-apple-system,"Microsoft YaHei",sans-serif;
    -webkit-font-smoothing:antialiased}
  #gl{position:fixed;inset:0;width:100%;height:100%;display:block;touch-action:none;cursor:grab}
  #gl.dragging{cursor:grabbing}

  .panel{position:fixed;z-index:10;background:var(--panel);border:1px solid var(--line);
    border-radius:14px;backdrop-filter:blur(16px) saturate(1.3);-webkit-backdrop-filter:blur(16px) saturate(1.3);
    box-shadow:0 18px 50px rgba(0,0,0,.55)}
  #ctrl{top:14px;left:14px;width:312px;max-height:calc(100vh - 28px);overflow:auto}
  #ctrl h1{display:flex;align-items:center;gap:8px;margin:0;padding:12px 13px 10px;
    font-size:13.5px;font-weight:500;letter-spacing:.2px;cursor:pointer;user-select:none}
  #ctrl h1 .dot{width:8px;height:8px;border-radius:50%;flex:none;
    background:radial-gradient(circle at 30% 30%,#ffd9a0,#ff8a2b 55%,#7a2d00);
    box-shadow:0 0 12px rgba(255,142,60,.85)}
  #ctrl h1 .chev{margin-left:auto;color:var(--dim);transition:transform .18s}
  #ctrl.collapsed .chev{transform:rotate(-90deg)}
  #ctrl.collapsed .body{display:none}
  .body{padding:0 10px 10px;display:flex;flex-direction:column;gap:6px}
  .row{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:4px 3px}
  .slider{display:flex;flex-direction:column;gap:2px;padding:3px}
  .slider .lab{display:flex;justify-content:space-between;gap:8px;color:var(--dim);font-size:11.5px}
  .slider .lab b{color:var(--fg);font-weight:500;font-variant-numeric:tabular-nums}
  input[type=range]{-webkit-appearance:none;appearance:none;width:100%;height:18px;background:transparent;cursor:pointer}
  input[type=range]::-webkit-slider-runnable-track{height:3px;border-radius:3px;
    background:linear-gradient(90deg,rgba(125,176,255,.85),rgba(125,176,255,.18))}
  input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:12px;height:12px;margin-top:-4.5px;
    border-radius:50%;background:#dfe9ff;box-shadow:0 0 0 1px rgba(0,0,0,.5),0 0 10px rgba(125,176,255,.7)}
  input[type=range]::-moz-range-track{height:3px;border-radius:3px;background:rgba(125,176,255,.35)}
  input[type=range]::-moz-range-thumb{width:12px;height:12px;border:none;border-radius:50%;background:#dfe9ff}
  input[type=checkbox]{-webkit-appearance:none;appearance:none;width:34px;height:19px;border-radius:19px;flex:none;
    background:rgba(140,160,200,.22);border:1px solid var(--line);position:relative;cursor:pointer;transition:background .18s}
  input[type=checkbox]::after{content:"";position:absolute;top:2px;left:2px;width:13px;height:13px;border-radius:50%;
    background:#c9d6f0;transition:transform .18s}
  input[type=checkbox]:checked{background:rgba(125,176,255,.55)}
  input[type=checkbox]:checked::after{transform:translateX(15px);background:#fff}
  select{background:rgba(140,160,200,.14);color:var(--fg);border:1px solid var(--line);
    border-radius:8px;padding:4px 8px;font:inherit;font-size:12px;outline:none}
  .btns{display:flex;gap:7px;padding:3px 0 0}
  button{flex:1;background:rgba(125,176,255,.13);color:var(--fg);border:1px solid var(--line);
    border-radius:9px;padding:7px 6px;font:inherit;font-size:12px;cursor:pointer;transition:background .15s}
  button:hover{background:rgba(125,176,255,.26)}
  button:active{transform:translateY(1px)}
  details{border-top:1px solid var(--line);margin-top:4px;padding-top:8px;font-size:11.5px;color:var(--dim)}
  details summary{cursor:pointer;color:var(--fg);font-size:12px;outline:none}
  details p{margin:7px 0 0}
  details code{color:#bcd2ff;font-family:ui-monospace,Consolas,monospace;font-size:11px}

  #hud{position:fixed;left:16px;bottom:14px;z-index:10;pointer-events:none;
    font-size:11.5px;color:var(--dim);text-shadow:0 1px 8px rgba(0,0,0,.9);line-height:1.75}
  #hud b{color:var(--fg);font-weight:500;font-variant-numeric:tabular-nums}
  #hud .u{color:#7d8aa8}
  .hint{position:fixed;right:16px;bottom:14px;z-index:10;color:var(--dim);font-size:11.5px;
    text-shadow:0 1px 8px rgba(0,0,0,.9);pointer-events:none}
  #boot{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;z-index:20;
    background:#000;color:var(--dim);font-size:13px;letter-spacing:.5px;transition:opacity .6s}
  #boot.done{opacity:0;pointer-events:none}
  #err{position:fixed;inset:0;display:none;z-index:30;background:#080a10;color:#ffb3b3;
    padding:28px;font:12px/1.6 ui-monospace,Consolas,monospace;white-space:pre-wrap;overflow:auto}
  @media (max-width:600px){
    #ctrl{width:calc(100vw - 28px)}
    .hint{display:none}
  }
</style>
</head>
<body>
<canvas id="gl"></canvas>

<div class="panel" id="ctrl">
  <h1 id="ctrlHead"><i class="dot"></i>史瓦西黑洞 · 实时引力透镜<span class="chev">▾</span></h1>
  <div class="body">
    <label class="row"><span>吸积盘</span><input type="checkbox" id="uiDisk" checked></label>
    <label class="row"><span>引力透镜(时空弯曲)</span><input type="checkbox" id="uiLens" checked></label>
    <label class="row"><span>多普勒 / 引力红移</span><input type="checkbox" id="uiDop" checked></label>
    <label class="row"><span>自动环绕</span><input type="checkbox" id="uiAuto" checked></label>

    <label class="slider">
      <span class="lab">盘面峰值温度 <b id="vT">8200 K</b></span>
      <input type="range" id="uiT" min="2200" max="30000" step="100" value="8200">
    </label>
    <label class="slider">
      <span class="lab">相对论增亮强度 <b id="vB">100%</b></span>
      <input type="range" id="uiB" min="0" max="1" step="0.05" value="1">
    </label>
    <label class="slider">
      <span class="lab">曝光 <b id="vE">1.00</b></span>
      <input type="range" id="uiE" min="0.1" max="3" step="0.05" value="1">
    </label>
    <label class="slider">
      <span class="lab">时间流速 <b id="vS">1.0×</b></span>
      <input type="range" id="uiS" min="0" max="3" step="0.05" value="1">
    </label>
    <label class="slider">
      <span class="lab">星空亮度 <b id="vSt">1.0</b></span>
      <input type="range" id="uiSt" min="0" max="2.5" step="0.05" value="1">
    </label>

    <label class="row"><span>渲染质量</span>
      <select id="uiQ">
        <option value="low">低</option>
        <option value="med" selected>中</option>
        <option value="high">高</option>
        <option value="ultra">极致</option>
      </select>
    </label>

    <div class="btns">
      <button id="btnReset">重置视角</button>
      <button id="btnPause">暂停</button>
      <button id="btnSave">保存图像</button>
    </div>

    <details>
      <summary>物理模型与近似</summary>
      <p>度规:<code>Schwarzschild</code>(静态、无自转)。长度单位取视界半径 <code>r_s = 1</code>,
      故 <code>M = 0.5</code>。逐像素反向积分零测地线
      <code>d²x/dλ² = −(3/2)·h²·x / r⁵</code>,其中 <code>h = |x × dx/dλ|</code>。
      黑洞阴影由临界碰撞参数 <code>b = 3√3·M ≈ 2.6 r_s</code> 自动涌现。</p>
      <p>吸积盘:赤道面薄盘,内缘取最内稳定圆轨道 <code>r_isco = 6M = 3 r_s</code>;
      Shakura--Sunyaev 零力矩温度剖面 <code>T ∝ (r_in/r)^{3/4}·(1−√(r_in/r))^{1/4}</code>,
      热辐射流量 ∝ <code>T⁴</code>;颜色取黑体谱(Tanner Helland 拟合)。</p>
      <p>频移:<code>g = √(1−3M/r) / (1 − v·n̂)</code>;局域轨道速度
      <code>v = √(M/(r−2M))</code>(ISCO 处为 0.5c);观测强度 ∝ <code>g⁴</code>,
      观测温度 <code>T_obs = g·T_emit</code>。</p>
      <p>已省略:Kerr 自转与参考系拖曳、盘的三维辐射转移与自照射、喷流、磁场。</p>
    </details>
  </div>
</div>

<div id="hud">
  <div id="hud1"></div>
  <div>视界 <b>1</b> <span class="u">r<sub>s</sub></span> ・ 光子球 <b>1.5</b> <span class="u">r<sub>s</sub></span> ・ ISCO <b>3</b> <span class="u">r<sub>s</sub></span></div>
  <div id="hud3"></div>
</div>
<div class="hint">拖拽旋转 ・ 滚轮缩放 ・ 空格暂停</div>

<div id="boot">正在编译引力透镜着色器...</div>
<div id="err"></div>

<script>
"use strict";
/* ==========================================================================
   史瓦西黑洞实时渲染器
   几何单位:G = c = 1,视界半径 r_s = 1(故 M = 0.5)
   ========================================================================== */

const canvas = document.getElementById('gl');
const gl = canvas.getContext('webgl2', {
  alpha:false, antialias:false, depth:false, stencil:false,
  preserveDrawingBuffer:false, powerPreference:'high-performance'
});

const errBox = document.getElementById('err');
function fatal(msg){
  errBox.style.display = 'block';
  errBox.textContent = String(msg);
  document.getElementById('boot').classList.add('done');
}
if(!gl){
  fatal('无法创建 WebGL2 上下文。\n请使用较新的 Chrome / Edge / Firefox,并确认已启用硬件加速。');
  throw new Error('WebGL2 unavailable');
}

/* ------------------------------- 着色器源码 ------------------------------- */

const VS = `#version 300 es
in vec2 aPos;
void main(){ gl_Position = vec4(aPos, 0.0, 1.0); }
`;

const FS_SCENE = `#version 300 es
precision highp float;

uniform vec2  uRes;
uniform float uTime;
uniform vec3  uCamPos;
uniform vec3  uCamRight;
uniform vec3  uCamUp;
uniform vec3  uCamFwd;
uniform float uTanHalf;
uniform float uEscape;

uniform float uDiskIn;      // 3.0 r_s  (ISCO)
uniform float uDiskOut;
uniform float uTpeak;       // 峰值温度 [K]
uniform float uBright;
uniform float uBeam;
uniform float uOpacity;
uniform float uStarBright;
uniform float uExposure;
uniform float uLensing;
uniform float uDoppler;
uniform float uDiskOn;
uniform float uEncode;
uniform int   uSteps;

#define MAX_STEPS 600
#define RS 1.0

out vec4 o;

/* ------------------------------ 哈希与噪声 ------------------------------ */

float hash13(vec3 p3){
  p3 = fract(p3 * 0.1031);
  p3 += dot(p3, p3.zyx + 31.32);
  return fract((p3.x + p3.y) * p3.z);
}

float vnoise(vec3 x){
  vec3 i = floor(x);
  vec3 f = fract(x);
  f = f * f * (3.0 - 2.0 * f);
  float n000 = hash13(i + vec3(0.0, 0.0, 0.0));
  float n100 = hash13(i + vec3(1.0, 0.0, 0.0));
  float n010 = hash13(i + vec3(0.0, 1.0, 0.0));
  float n110 = hash13(i + vec3(1.0, 1.0, 0.0));
  float n001 = hash13(i + vec3(0.0, 0.0, 1.0));
  float n101 = hash13(i + vec3(1.0, 0.0, 1.0));
  float n011 = hash13(i + vec3(0.0, 1.0, 1.0));
  float n111 = hash13(i + vec3(1.0, 1.0, 1.0));
  return mix(mix(mix(n000, n100, f.x), mix(n010, n110, f.x), f.y),
             mix(mix(n001, n101, f.x), mix(n011, n111, f.x), f.y), f.z);
}

float fbm3(vec3 p){
  float s = 0.0, a = 0.5;
  for(int i = 0; i < 3; i++){
    s += a * vnoise(p);
    p *= 2.03;
    a *= 0.5;
  }
  return s * 1.1428;
}

/* ------------------------------- 黑体辐射色 ------------------------------- */

vec3 blackbodySRGB(float T){
  T = clamp(T, 1000.0, 40000.0);
  float t = T * 0.01;
  float r, g, b;
  if(t <= 66.0){ r = 255.0; } else { r = 329.698727446 * pow(t - 60.0, -0.1332047592); }
  if(t <= 66.0){ g = 99.4708025861 * log(t) - 161.1195681661; }
  else { g = 288.1221695283 * pow(t - 60.0, -0.0755148492); }
  if(t >= 66.0){ b = 255.0; }
  else if(t <= 19.0){ b = 0.0; }
  else { b = 138.5177312231 * log(t - 10.0) - 305.0447927307; }
  return clamp(vec3(r, g, b) / 255.0, 0.0, 1.0);
}

vec3 blackbodyLinear(float T){
  return pow(blackbodySRGB(T), vec3(2.2));
}

/* --------------------------------- 星空 --------------------------------- */

vec3 starLayer(vec3 d, float scale, float bright, float seed){
  vec3 ad = abs(d);
  vec2 uv; float face;
  if(ad.x >= ad.y && ad.x >= ad.z){ uv = d.yz / ad.x; face = d.x > 0.0 ? 0.0 : 1.0; }
  else if(ad.y >= ad.z){ uv = d.xz / ad.y; face = d.y > 0.0 ? 2.0 : 3.0; }
  else { uv = d.xy / ad.z; face = d.z > 0.0 ? 4.0 : 5.0; }

  vec2 g = uv * scale;
  vec2 cell = floor(g);
  vec2 fr = fract(g);

  float h1 = hash13(vec3(cell, face * 17.0 + seed));
  // 按立方体面元立体角拒绝采样,保证天球上星密度均匀
  float jac = pow(1.0 + dot(uv, uv), -1.5);
  if(h1 > jac * 1.7) return vec3(0.0);

  float h2 = hash13(vec3(cell, face * 17.0 + seed + 3.7));
  float h3 = hash13(vec3(cell, face * 17.0 + seed + 7.9));
  float h4 = hash13(vec3(cell, face * 17.0 + seed + 13.3));

  vec2 sp = vec2(h2, h3);
  float dd = length(fr - sp);
  float core = exp(-dd * dd * 640.0);
  float halo = exp(-dd * dd * 90.0) * 0.10;

  float mag = 0.05 + 2.1 * pow(h4, 7.0);
  float T = 2300.0 + 9500.0 * pow(h4, 0.55);
  vec3 tint = blackbodyLinear(T);
  tint /= max(max(tint.r, tint.g), tint.b);

  return tint * (core + halo) * mag * bright;
}

vec3 skyColor(vec3 d){
  vec3 col = starLayer(d, 42.0, 1.0, 0.0);
  col += starLayer(d, 104.0, 0.62, 23.0);

  vec3 bn = normalize(vec3(0.42, 0.78, 0.46));
  float t = dot(d, bn);
  float band = exp(-t * t * 7.0);
  float n = fbm3(d * 3.2) * 0.65 + fbm3(d * 7.5 + 4.0) * 0.35;
  col += band * (0.007 + 0.021 * n) * vec3(0.46, 0.56, 0.88);
  col += vec3(0.30, 0.36, 0.52) * (0.0009 + 0.0022 * n);
  return col * uStarBright;
}

/* ------------------------------- 吸积盘发光 ------------------------------- */

void diskEmission(vec3 pc, vec3 tangent, out vec3 col, out float alpha){
  float r = length(pc.xz);
  float u = clamp(uDiskIn / r, 0.0, 1.0);
  float sq = sqrt(u);

  // Shakura--Sunyaev 零力矩温度剖面,峰值归一化到 1(温度峰值位于 r = 49/36 r_in)
  float h = clamp(pow(u, 0.75) * pow(max(1.0 - sq, 1e-4), 0.25) / 0.48786, 0.0, 1.0);
  float edge = smoothstep(0.1875, 0.28, u);

  float rSafe = max(r, 1.6);
  float v = sqrt(0.5 / (rSafe - 1.0));               // 局域轨道速度,M = 0.5

  // 光子射向观测者的方向(追踪方向取反),换算到静态局部正交系
  vec3 e_r = normalize(pc);
  vec3 ndir = -normalize(tangent);
  float fr = max(1.0 - 1.0 / rSafe, 1e-3);           // 1 - r_s/r
  float nr = dot(ndir, e_r) / sqrt(fr);
  vec3 nt = ndir - dot(ndir, e_r) * e_r;
  float nrm = max(sqrt(nr * nr + dot(nt, nt)), 1e-5);
  vec3 vorb = normalize(cross(e_r, vec3(0.0, 1.0, 0.0)));
  float nphi = dot(nt, vorb) / nrm;

  float g = sqrt(max(1.0 - 1.5 / rSafe, 0.0)) / max(1.0 - v * nphi, 0.05);
  g = mix(1.0, g, uDoppler);

  // 图案冻结在流体元上,随差动转动 Ω(r) 剪切
  float phi = atan(pc.z, pc.x);
  float om = 0.70710678 * pow(r, -1.5);              // Ω = sqrt(M/r³)
  float ph = phi - om * uTime;
  vec3 q = vec3(cos(ph), sin(ph), 0.0);
  float n1 = fbm3(vec3(q.xy * 2.1, r * 0.45));
  float n2 = fbm3(vec3(q.xy * 4.7 + 9.0, r * 1.15));
  float tex = max(1.0 + (n1 - 0.5) * 1.1 + (n2 - 0.5) * 0.55, 0.12);

  // 斜穿薄盘的光程 ∝ 1/|cos i|
  float vy = max(abs(normalize(tangent).y), 0.045);
  float L = clamp(0.11 / vy, 0.55, 2.4);

  float Tob = clamp(uTpeak * h * max(g, 0.02), 1000.0, 40000.0);
  vec3 chroma = blackbodyLinear(Tob);
  chroma /= max(dot(chroma, vec3(0.2126, 0.7152, 0.0722)), 1e-4);

  float flux = pow(h, 4.0) * pow(g, 4.0 * uBeam) * uBright;
  col = chroma * flux * edge * tex * L;
  alpha = 1.0 - exp(-uOpacity * tex * L);
}

/* --------------------------------- 主循环 --------------------------------- */

void main(){
  vec2 p = (2.0 * gl_FragCoord.xy - uRes) / uRes.y;
  vec3 ray = normalize(uCamFwd + uCamRight * (p.x * uTanHalf) + uCamUp * (p.y * uTanHalf));

  vec3 pos = uCamPos;
  vec3 vel = ray;
  vec3 hv = cross(pos, vel);
  float h2 = dot(hv, hv) * step(0.5, uLensing);      // 关闭透镜 → 直线传播

  vec3 accum = vec3(0.0);
  float trans = 1.0;
  bool captured = false;

  for(int i = 0; i < MAX_STEPS; i++){
    if(i >= uSteps) break;

    float r2 = dot(pos, pos);
    float r = sqrt(r2);
    if(r < RS){ captured = true; break; }                     // 落入视界
    if(r > uEscape && dot(pos, vel) > 0.0) break;             // 逃逸

    float sp = length(vel);
    float r4 = r2 * r2;
    vec3 a = -1.5 * h2 * pos / (r4 * r);                      // 零测地线加速度
    float am = length(a);

    // 自适应步长:同时限制单步位移与单步转角
    float dt = min(min(0.14 * r, 1.1) / max(sp, 1e-4), 0.085 * sp / max(am, 1e-9));

    vec3 pn = pos + vel * dt + 0.5 * a * dt * dt;             // 速度 Verlet
    float rn2 = dot(pn, pn);
    float rn = sqrt(rn2);
    vec3 an = -1.5 * h2 * pn / (rn2 * rn2 * rn);
    vec3 vn = vel + 0.5 * (a + an) * dt;

    // 与赤道薄盘求交;多次穿越即产生高阶像
    if(uDiskOn > 0.5 && trans > 0.02 && pos.y * pn.y < 0.0){
      float tt = pos.y / (pos.y - pn.y);
      vec3 pc = mix(pos, pn, tt);
      float rc = length(pc.xz);
      if(rc > uDiskIn * 0.97 && rc < uDiskOut * 1.03){
        vec3 col; float alpha;
        diskEmission(pc, normalize(mix(vel, vn, tt)), col, alpha);
        accum += trans * col * alpha;
        trans *= (1.0 - alpha);
      }
    }

    pos = pn;
    vel = vn;
  }

  vec3 col = accum;
  if(!captured) col += trans * skyColor(normalize(vel));
  col *= uExposure;
  if(uEncode > 0.5) col = col / (1.0 + col);          // 8bit 目标时保留高光动态范围
  o = vec4(col, 1.0);
}
`;

const FS_BRIGHT = `#version 300 es
precision highp float;
uniform sampler2D uTex;
uniform vec2 uSrcTexel;
uniform vec2 uInvSize;
uniform float uThresh;
out vec4 o;
void main(){
  vec2 uv = gl_FragCoord.xy * uInvSize;
  vec3 c = texture(uTex, uv + vec2(-0.5, -0.5) * uSrcTexel).rgb
         + texture(uTex, uv + vec2( 0.5, -0.5) * uSrcTexel).rgb
         + texture(uTex, uv + vec2(-0.5,  0.5) * uSrcTexel).rgb
         + texture(uTex, uv + vec2( 0.5,  0.5) * uSrcTexel).rgb;
  c *= 0.25;
  float l = max(max(c.r, c.g), c.b);
  float w = clamp((l - uThresh) / max(l, 1e-4), 0.0, 1.0);
  o = vec4(c * w, 1.0);
}
`;

const FS_BLUR = `#version 300 es
precision highp float;
uniform sampler2D uTex;
uniform vec2 uDir;
uniform vec2 uInvSize;
out vec4 o;
void main(){
  vec2 uv = gl_FragCoord.xy * uInvSize;
  vec3 c = texture(uTex, uv).rgb * 0.2270270270;
  c += (texture(uTex, uv + uDir).rgb + texture(uTex, uv - uDir).rgb) * 0.1945945946;
  c += (texture(uTex, uv + uDir * 2.0).rgb + texture(uTex, uv - uDir * 2.0).rgb) * 0.1216216216;
  c += (texture(uTex, uv + uDir * 3.0).rgb + texture(uTex, uv - uDir * 3.0).rgb) * 0.0540540541;
  c += (texture(uTex, uv + uDir * 4.0).rgb + texture(uTex, uv - uDir * 4.0).rgb) * 0.0162162162;
  o = vec4(c, 1.0);
}
`;

const FS_COMP = `#version 300 es
precision highp float;
uniform sampler2D uScene;
uniform sampler2D uBloom;
uniform vec2 uInvSize;
uniform float uBloomAmt;
uniform float uDecode;
out vec4 o;

vec3 aces(vec3 x){
  const float a = 2.51, b = 0.03, c = 2.43, d = 0.59, e = 0.14;
  return clamp((x * (a * x + b)) / (x * (c * x + d) + e), 0.0, 1.0);
}

void main(){
  vec2 uv = gl_FragCoord.xy * uInvSize;
  vec3 s = texture(uScene, uv).rgb;
  if(uDecode > 0.5) s = s / max(1.0 - s, 1e-3);
  vec3 bl = texture(uBloom, uv).rgb;
  vec3 c = aces(s + bl * uBloomAmt);
  c = pow(max(c, 0.0), vec3(1.0 / 2.2));
  vec2 q = uv * 2.0 - 1.0;
  c *= 1.0 - 0.15 * dot(q, q);
  float dth = fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233))) * 43758.5453) - 0.5;
  o = vec4(c + dth / 255.0, 1.0);
}
`;

/* ----------------------------- WebGL 基础设施 ----------------------------- */

function compile(type, src){
  const s = gl.createShader(type);
  gl.shaderSource(s, src);
  gl.compileShader(s);
  if(!gl.getShaderParameter(s, gl.COMPILE_STATUS)){
    throw new Error('着色器编译失败:\n' + gl.getShaderInfoLog(s));
  }
  return s;
}

function makeProgram(vsSrc, fsSrc){
  const p = gl.createProgram();
  gl.attachShader(p, compile(gl.VERTEX_SHADER, vsSrc));
  gl.attachShader(p, compile(gl.FRAGMENT_SHADER, fsSrc));
  gl.bindAttribLocation(p, 0, 'aPos');
  gl.linkProgram(p);
  if(!gl.getProgramParameter(p, gl.LINK_STATUS)){
    throw new Error('程序链接失败:\n' + gl.getProgramInfoLog(p));
  }
  const cache = new Map();
  return {
    p,
    use(){ gl.useProgram(p); },
    u(n){ if(!cache.has(n)) cache.set(n, gl.getUniformLocation(p, n)); return cache.get(n); }
  };
}

let progScene, progBright, progBlur, progComp;
try{
  progScene  = makeProgram(VS, FS_SCENE);
  progBright = makeProgram(VS, FS_BRIGHT);
  progBlur   = makeProgram(VS, FS_BLUR);
  progComp   = makeProgram(VS, FS_COMP);
}catch(e){
  fatal(e.message || e);
  throw e;
}

const vao = gl.createVertexArray();
gl.bindVertexArray(vao);
const vbo = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);

// 优先使用半浮点渲染目标;不可用时退回 RGBA8 + Reinhard 编码
let HDR = !!gl.getExtension('EXT_color_buffer_float') || !!gl.getExtension('EXT_color_buffer_half_float');
function formats(){
  return HDR ? [gl.RGBA16F, gl.HALF_FLOAT] : [gl.RGBA8, gl.UNSIGNED_BYTE];
}

function makeTarget(w, h){
  const [internal, type] = formats();
  const tex = gl.createTexture();
  gl.bindTexture(gl.TEXTURE_2D, tex);
  gl.texImage2D(gl.TEXTURE_2D, 0, internal, w, h, 0, gl.RGBA, type, null);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
  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);
  const fb = gl.createFramebuffer();
  gl.bindFramebuffer(gl.FRAMEBUFFER, fb);
  gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
  const ok = gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE;
  gl.bindFramebuffer(gl.FRAMEBUFFER, null);
  if(!ok){
    gl.deleteTexture(tex); gl.deleteFramebuffer(fb);
    HDR = false;
    throw new Error('无法创建该格式的渲染目标');
  }
  return { tex, fb, w, h };
}

let tgScene = null, tgA = null, tgB = null;
function freeTargets(){
  for(const t of [tgScene, tgA, tgB]){
    if(t){ gl.deleteTexture(t.tex); gl.deleteFramebuffer(t.fb); }
  }
  tgScene = tgA = tgB = null;
}
function allocTargets(w, h){
  if(tgScene && tgScene.w === w && tgScene.h === h) return;
  freeTargets();
  const bw = Math.max(2, w >> 2), bh = Math.max(2, h >> 2);
  for(let attempt = 0; attempt < 2; attempt++){
    try{
      tgScene = makeTarget(w, h);
      tgA = makeTarget(bw, bh);
      tgB = makeTarget(bw, bh);
      return;
    }catch(e){
      freeTargets();
      HDR = false;
      if(attempt === 1) fatal('渲染目标分配失败:' + e.message);
    }
  }
}

function drawQuad(){
  gl.bindVertexArray(vao);
  gl.drawArrays(gl.TRIANGLES, 0, 3);
}

/* ---------------------------------- 状态 ---------------------------------- */

const DEFAULTS = { az: 0.62, el: 0.128, dist: 28.0, fov: 42.0 };
const cam = Object.assign({}, DEFAULTS);

const PRESETS = {
  low:   { steps: 130, scale: 0.62 },
  med:   { steps: 250, scale: 0.82 },
  high:  { steps: 380, scale: 1.00 },
  ultra: { steps: 600, scale: 1.15 }
};

const state = {
  disk: true, lens: true, doppler: true, autoOrbit: true,
  tpeak: 8200, beam: 1.0, exposure: 1.0, speed: 1.0, stars: 1.0,
  quality: 'med', paused: false,
  simTime: 0,          // 几何单位时间(r_s / c)
  autoScale: 1.0
};

const RATE = 8.0;            // 1.0× 时每秒推进 8 个几何时间单位
const PIXEL_BUDGET = 1150000;
const $ = id => document.getElementById(id);

let W = 0, H = 0;
function resize(force){
  const dpr = Math.min(window.devicePixelRatio || 1, 2);
  const cw = window.innerWidth, ch = window.innerHeight;
  const preset = PRESETS[state.quality];
  let s = preset.scale * state.autoScale;
  const full = cw * dpr * ch * dpr;
  if(full * s * s > PIXEL_BUDGET) s *= Math.sqrt(PIXEL_BUDGET / (full * s * s));
  const w = Math.max(200, Math.round(cw * dpr * s));
  const h = Math.max(150, Math.round(ch * dpr * s));
  if(!force && w === W && h === H) return;
  W = w; H = h;
  canvas.width = W; canvas.height = H;
  allocTargets(W, H);
}

function camBasis(){
  const ce = Math.cos(cam.el), se = Math.sin(cam.el);
  const pos = [
    Math.cos(cam.az) * ce * cam.dist,
    se * cam.dist,
    Math.sin(cam.az) * ce * cam.dist
  ];
  const inv = 1 / Math.hypot(pos[0], pos[1], pos[2]);
  const fwd = [-pos[0] * inv, -pos[1] * inv, -pos[2] * inv];
  let right = [-fwd[2], 0, fwd[0]];                       // normalize(cross(fwd, up))
  const rn = Math.hypot(right[0], right[1], right[2]) || 1;
  right = [right[0] / rn, right[1] / rn, right[2] / rn];
  const up = [
    right[1] * fwd[2] - right[2] * fwd[1],
    right[2] * fwd[0] - right[0] * fwd[2],
    right[0] * fwd[1] - right[1] * fwd[0]
  ];
  return { pos, fwd, right, up };
}

/* --------------------------------- 交互 ---------------------------------- */

let dragging = false, lastX = 0, lastY = 0, ptrId = null;
const pointers = new Map();
let pinchDist = 0;

canvas.addEventListener('pointerdown', e => {
  canvas.setPointerCapture(e.pointerId);
  pointers.set(e.pointerId, [e.clientX, e.clientY]);
  if(pointers.size === 1){
    dragging = true; ptrId = e.pointerId;
    lastX = e.clientX; lastY = e.clientY;
    canvas.classList.add('dragging');
  }else if(pointers.size === 2){
    const v = [...pointers.values()];
    pinchDist = Math.hypot(v[0][0] - v[1][0], v[0][1] - v[1][1]);
  }
});

canvas.addEventListener('pointermove', e => {
  if(!pointers.has(e.pointerId)) return;
  pointers.set(e.pointerId, [e.clientX, e.clientY]);
  if(pointers.size >= 2){
    const v = [...pointers.values()];
    const d = Math.hypot(v[0][0] - v[1][0], v[0][1] - v[1][1]);
    if(pinchDist > 0) cam.dist = Math.min(140, Math.max(2.6, cam.dist * (pinchDist / Math.max(d, 1))));
    pinchDist = d;
    return;
  }
  if(!dragging || e.pointerId !== ptrId) return;
  const dx = e.clientX - lastX, dy = e.clientY - lastY;
  lastX = e.clientX; lastY = e.clientY;
  cam.az -= dx * 0.0052;
  cam.el = Math.max(-1.45, Math.min(1.45, cam.el + dy * 0.0045));
  state.autoOrbit = false; $('uiAuto').checked = false;
});

function endPointer(e){
  pointers.delete(e.pointerId);
  if(pointers.size === 0){ dragging = false; canvas.classList.remove('dragging'); }
  else {
    const v = [...pointers.values()];
    pinchDist = Math.hypot(v[0][0] - v[1][0], v[0][1] - v[1][1]);
  }
}
canvas.addEventListener('pointerup', endPointer);
canvas.addEventListener('pointercancel', endPointer);

canvas.addEventListener('wheel', e => {
  e.preventDefault();
  cam.dist = Math.min(140, Math.max(2.6, cam.dist * Math.exp(e.deltaY * 0.0011)));
}, { passive:false });

function resetCam(){
  Object.assign(cam, DEFAULTS);
  state.autoOrbit = true; $('uiAuto').checked = true;
}

function togglePause(){
  state.paused = !state.paused;
  $('btnPause').textContent = state.paused ? '继续' : '暂停';
}

window.addEventListener('keydown', e => {
  if(e.code === 'Space'){ e.preventDefault(); togglePause(); }
  if(e.key === 'r' || e.key === 'R'){ resetCam(); }
});

/* ---------------------------------- UI ----------------------------------- */

$('ctrlHead').addEventListener('click', () => $('ctrl').classList.toggle('collapsed'));
$('uiDisk').addEventListener('change', e => state.disk = e.target.checked);
$('uiLens').addEventListener('change', e => state.lens = e.target.checked);
$('uiDop').addEventListener('change', e => state.doppler = e.target.checked);
$('uiAuto').addEventListener('change', e => state.autoOrbit = e.target.checked);
$('uiT').addEventListener('input', e => { state.tpeak = +e.target.value; $('vT').textContent = state.tpeak + ' K'; });
$('uiB').addEventListener('input', e => { state.beam = +e.target.value; $('vB').textContent = Math.round(state.beam * 100) + '%'; });
$('uiE').addEventListener('input', e => { state.exposure = +e.target.value; $('vE').textContent = state.exposure.toFixed(2); });
$('uiS').addEventListener('input', e => { state.speed = +e.target.value; $('vS').textContent = state.speed.toFixed(1) + '×'; });
$('uiSt').addEventListener('input', e => { state.stars = +e.target.value; $('vSt').textContent = state.stars.toFixed(1); });
$('uiQ').addEventListener('change', e => {
  state.quality = e.target.value;
  state.autoScale = 1.0;
  resize(true);
});
$('btnReset').addEventListener('click', resetCam);
$('btnPause').addEventListener('click', togglePause);
let saveNext = false;
$('btnSave').addEventListener('click', () => { saveNext = true; });

/* --------------------------------- 渲染 ---------------------------------- */

function bindTex(unit, tex, loc){
  gl.activeTexture(gl.TEXTURE0 + unit);
  gl.bindTexture(gl.TEXTURE_2D, tex);
  gl.uniform1i(loc, unit);
}

let lastT = performance.now(), lastFrameAt = lastT;
let fpsEMA = 60, frameEMA = 16.7, perfTick = 0, lastDown = 0;

function renderFrame(now){
  const dtReal = Math.min((now - lastT) / 1000, 0.05);
  lastT = now;

  if(!state.paused && !document.hidden){
    state.simTime += dtReal * state.speed * RATE;
    if(state.simTime > 1e6) state.simTime -= 1e6;
  }
  if(state.autoOrbit && !state.paused) cam.az += dtReal * 0.055;

  // 用帧间隔(含 GPU 与垂直同步)估计实际性能
  const frameMs = Math.min(Math.max(now - lastFrameAt, 0.5), 200);
  const steady = !document.hidden && frameMs < 90;      // 忽略切标签页造成的长间隔
  if(steady){
    frameEMA += (frameMs - frameEMA) * 0.08;
    fpsEMA += (1000 / Math.max(frameEMA, 0.5) - fpsEMA) * 0.08;
  }

  // 自适应分辨率:超过 ~45fps 阈值则降档,长时间富余则缓慢升档
  if(steady && ++perfTick % 40 === 0){
    if(frameEMA > 22 && state.autoScale > 0.5){
      state.autoScale = Math.max(0.5, state.autoScale * 0.9);
      lastDown = now;
      resize(true);
    }else if(frameEMA < 17.5 && state.autoScale < 1.0 && now - lastDown > 3000){
      state.autoScale = Math.min(1.0, state.autoScale * 1.05);
      resize(true);
    }
  }

  const cb = camBasis();
  const preset = PRESETS[state.quality];

  // 1) 测地线追踪
  gl.bindFramebuffer(gl.FRAMEBUFFER, tgScene.fb);
  gl.viewport(0, 0, W, H);
  progScene.use();
  gl.uniform2f(progScene.u('uRes'), W, H);
  gl.uniform1f(progScene.u('uTime'), state.simTime);
  gl.uniform3fv(progScene.u('uCamPos'), cb.pos);
  gl.uniform3fv(progScene.u('uCamRight'), cb.right);
  gl.uniform3fv(progScene.u('uCamUp'), cb.up);
  gl.uniform3fv(progScene.u('uCamFwd'), cb.fwd);
  gl.uniform1f(progScene.u('uTanHalf'), Math.tan(cam.fov * Math.PI / 360));
  gl.uniform1f(progScene.u('uEscape'), Math.max(55.0, cam.dist * 1.6));
  gl.uniform1f(progScene.u('uDiskIn'), 3.0);
  gl.uniform1f(progScene.u('uDiskOut'), 16.0);
  gl.uniform1f(progScene.u('uTpeak'), state.tpeak);
  gl.uniform1f(progScene.u('uBright'), 1.0);
  gl.uniform1f(progScene.u('uBeam'), state.beam);
  gl.uniform1f(progScene.u('uOpacity'), 0.85);
  gl.uniform1f(progScene.u('uStarBright'), state.stars);
  gl.uniform1f(progScene.u('uExposure'), state.exposure);
  gl.uniform1f(progScene.u('uLensing'), state.lens ? 1.0 : 0.0);
  gl.uniform1f(progScene.u('uDoppler'), state.doppler ? 1.0 : 0.0);
  gl.uniform1f(progScene.u('uDiskOn'), state.disk ? 1.0 : 0.0);
  gl.uniform1f(progScene.u('uEncode'), HDR ? 0.0 : 1.0);
  gl.uniform1i(progScene.u('uSteps'), preset.steps);
  drawQuad();

  // 2) 亮部提取(1/4 分辨率)
  gl.bindFramebuffer(gl.FRAMEBUFFER, tgA.fb);
  gl.viewport(0, 0, tgA.w, tgA.h);
  progBright.use();
  bindTex(0, tgScene.tex, progBright.u('uTex'));
  gl.uniform2f(progBright.u('uSrcTexel'), 1 / W, 1 / H);
  gl.uniform2f(progBright.u('uInvSize'), 1 / tgA.w, 1 / tgA.h);
  gl.uniform1f(progBright.u('uThresh'), HDR ? 0.75 : 0.35);
  drawQuad();

  // 3) 分离高斯模糊
  progBlur.use();
  gl.bindFramebuffer(gl.FRAMEBUFFER, tgB.fb);
  gl.viewport(0, 0, tgB.w, tgB.h);
  bindTex(0, tgA.tex, progBlur.u('uTex'));
  gl.uniform2f(progBlur.u('uDir'), 1.35 / tgA.w, 0.0);
  gl.uniform2f(progBlur.u('uInvSize'), 1 / tgB.w, 1 / tgB.h);
  drawQuad();

  gl.bindFramebuffer(gl.FRAMEBUFFER, tgA.fb);
  gl.viewport(0, 0, tgA.w, tgA.h);
  bindTex(0, tgB.tex, progBlur.u('uTex'));
  gl.uniform2f(progBlur.u('uDir'), 0.0, 1.35 / tgA.h);
  gl.uniform2f(progBlur.u('uInvSize'), 1 / tgA.w, 1 / tgA.h);
  drawQuad();

  // 4) 合成 + ACES 色调映射
  gl.bindFramebuffer(gl.FRAMEBUFFER, null);
  gl.viewport(0, 0, W, H);
  progComp.use();
  bindTex(0, tgScene.tex, progComp.u('uScene'));
  bindTex(1, tgA.tex, progComp.u('uBloom'));
  gl.uniform2f(progComp.u('uInvSize'), 1 / W, 1 / H);
  gl.uniform1f(progComp.u('uBloomAmt'), 0.8);
  gl.uniform1f(progComp.u('uDecode'), HDR ? 0.0 : 1.0);
  drawQuad();

  lastFrameAt = now;

  if(saveNext){
    saveNext = false;
    canvas.toBlob(b => {
      if(!b) return;
      const a = document.createElement('a');
      const url = URL.createObjectURL(b);
      a.href = url;
      a.download = 'blackhole_' + Date.now() + '.png';
      a.click();
      setTimeout(() => URL.revokeObjectURL(url), 5000);
    }, 'image/png');
  }

  updateHud();
  requestAnimationFrame(renderFrame);
}

/* ---------------------------------- HUD ---------------------------------- */

let hudTick = 0;
function updateHud(){
  if(++hudTick % 12) return;

  const bcrit = 1.5 * Math.sqrt(3.0);                   // 3√3·M,M = 0.5
  const sinA = Math.min(1, bcrit * Math.sqrt(Math.max(0, 1 - 1 / cam.dist)) / cam.dist);
  const alphaDeg = Math.asin(sinA) * 180 / Math.PI;

  $('hud1').innerHTML =
    '距离 <b>' + cam.dist.toFixed(1) + '</b> <span class="u">r<sub>s</sub></span>' +
    ' ・ 倾角 <b>' + (cam.el * 180 / Math.PI).toFixed(1) + '°</b>' +
    ' ・ 阴影角半径 <b>' + alphaDeg.toFixed(2) + '°</b>' +
    ' ・ 视场 <b>' + cam.fov.toFixed(0) + '°</b>';

  const realMs = state.speed * RATE * 98.7e-3;          // 10 M☉:r_s/c ≈ 98.7 µs
  $('hud3').innerHTML =
    '时间流速 <b>' + state.speed.toFixed(1) + '×</b> ' +
    '<span class="u">(10 M<sub>☉</sub> 换算 ≈ ' + realMs.toFixed(2) + ' ms/s,慢放 ' +
    Math.round(1000 / Math.max(realMs, 1e-3)) + '×)</span>' +
    ' ・ <b>' + fpsEMA.toFixed(0) + '</b> fps ・ ' + W + '×' + H +
    (state.paused ? ' ・ <b>已暂停</b>' : '');
}

/* --------------------------------- 启动 ---------------------------------- */

window.addEventListener('resize', () => resize(false));
document.addEventListener('visibilitychange', () => {
  lastT = performance.now();
  lastFrameAt = lastT;
});

resize(true);
updateHud();
requestAnimationFrame(t => {
  lastT = t;
  renderFrame(t);
  $('boot').classList.add('done');
});
</script>
</body>
</html>
相关推荐
铸人1 天前
再论勾股定理成立的条件-28
数学·几何学·复数·物理
熊猫_豆豆1 个月前
黑洞的粒子产生(霍金1975年论文)第二部分
人工智能·数学·算法·机器学习·量子力学·大学物理·黑洞
谙弆悕博士2 个月前
系统集成项目管理工程师教程(第3版)笔记——第4章:信息系统架构
笔记·系统架构·项目管理·创业创新·学习方法·业界资讯·物理
国际学术会议-杨老师2 个月前
2026年等离子体物理与聚变能源国际会议 (PPFEN 2026)
能源·等离子体·物理
逻辑君4 个月前
Foresight研究报告【20260006】
人工智能·物理
逻辑君4 个月前
物理生物学研究报告【20260016】
人工智能·算法·物理
逻辑君4 个月前
物理生物学研究报告【20260015】
人工智能·物理
逻辑君4 个月前
物理生物学研究报告【20260014】
物理