一个单文件 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>