ea379116cc
- 新增5个物理仿真实验室:黑洞模拟、日地轨道、3D太阳系、π推导、拉格朗日点 - 新增思想实验室后端路由与鉴权逻辑 - 首页新增思想实验室页签与前端渲染逻辑 - 修复中韩半导体监控标的配置错误 - 新增市场技能推送白名单过滤逻辑与单元测试
530 lines
23 KiB
HTML
530 lines
23 KiB
HTML
<!-- ============================================================
|
||
思想实验室 · labs/blackhole_sim · 黑洞模拟
|
||
广义相对论光线追踪(史瓦西度规零测地线)教学模拟器。
|
||
独立 lab:单文件自包含,不引用其他 lab 的任何资源。
|
||
服务端配置:src/server/thought_lab/labs/blackhole_sim/config.json
|
||
============================================================ -->
|
||
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>黑洞模拟器 - 广义相对论光线追踪 · 教学版</title>
|
||
<style>
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
html, body { height: 100%; background: #000; overflow: hidden;
|
||
font: 13px/1.65 "Microsoft YaHei", "PingFang SC", sans-serif; color: #cfd8ea; }
|
||
#glcanvas { position: fixed; inset: 0; width: 100%; height: 100%; display: block; cursor: grab; }
|
||
#glcanvas:active { cursor: grabbing; }
|
||
|
||
/* 左侧控制/教学面板 */
|
||
#panel {
|
||
position: fixed; top: 12px; left: 12px; z-index: 10; width: 320px;
|
||
max-height: calc(100% - 24px); overflow-y: auto;
|
||
background: rgba(6, 10, 20, 0.82); border: 1px solid rgba(120, 160, 255, 0.3);
|
||
border-radius: 10px; padding: 14px 16px; backdrop-filter: blur(6px);
|
||
}
|
||
#panel::-webkit-scrollbar { width: 6px; }
|
||
#panel::-webkit-scrollbar-thumb { background: #33415e; border-radius: 3px; }
|
||
#panel h1 { font-size: 16px; color: #fff; letter-spacing: 1px; margin-bottom: 2px; }
|
||
#panel h1 small { font-size: 11px; color: #7fa0e0; font-weight: normal; }
|
||
.tabs { display: flex; gap: 6px; margin: 10px 0; }
|
||
.tab-btn { flex: 1; padding: 6px 0; text-align: center; cursor: pointer;
|
||
background: #16203a; border: 1px solid #2c3c60; border-radius: 6px; color: #9db4e0; }
|
||
.tab-btn.active { background: #27407a; color: #fff; border-color: #5a8cff; }
|
||
.sec { margin: 10px 0 4px; color: #ffd479; font-size: 12px; font-weight: bold;
|
||
border-left: 3px solid #ffd479; padding-left: 6px; }
|
||
.row { display: flex; align-items: center; justify-content: space-between; margin: 6px 0; gap: 8px; }
|
||
.row label { color: #9db4e0; white-space: nowrap; }
|
||
input[type=range] { flex: 1; accent-color: #5a8cff; cursor: pointer; }
|
||
select, button.preset { background: #16203a; color: #cfd8ea; border: 1px solid #2c3c60;
|
||
border-radius: 5px; padding: 3px 8px; cursor: pointer; }
|
||
button.preset:hover { border-color: #5a8cff; }
|
||
.val { color: #fff; min-width: 70px; text-align: right; }
|
||
.chk { display: flex; align-items: center; gap: 6px; color: #cfd8ea; cursor: pointer; }
|
||
.chk input { accent-color: #5a8cff; }
|
||
.data-grid { display: grid; grid-template-columns: auto 1fr; gap: 2px 10px; font-size: 12px; }
|
||
.data-grid .k { color: #7d8db0; }
|
||
.data-grid .v { color: #ffe9b0; }
|
||
details { margin: 8px 0; }
|
||
summary { cursor: pointer; color: #8fb0ff; font-weight: bold; }
|
||
details p { margin: 6px 0 6px 4px; color: #aebad2; font-size: 12px; text-align: justify; }
|
||
details p b { color: #ffd479; }
|
||
.hint { color: #66759a; font-size: 11px; margin-top: 4px; }
|
||
|
||
/* 轨道实验室 */
|
||
#orbitlab { display: none; }
|
||
#orbitlab.show { display: block; }
|
||
#view3d.hide { display: none; }
|
||
#orbit-canvas { width: 100%; background: #050810; border: 1px solid #223052; border-radius: 8px; }
|
||
#orbit-info { font-size: 12px; color: #aebad2; margin-top: 6px; }
|
||
#orbit-info b { color: #ffe9b0; }
|
||
|
||
#load-error { position: fixed; inset: 0; z-index: 99; display: none;
|
||
align-items: center; justify-content: center; background: #000;
|
||
color: #ff8080; font-size: 16px; text-align: center; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<canvas id="glcanvas"></canvas>
|
||
|
||
<div id="panel">
|
||
<h1>黑洞模拟器 <small>GR Ray Tracing · 教学版</small></h1>
|
||
|
||
<div class="tabs">
|
||
<div class="tab-btn active" id="tab-view" onclick="switchTab('view')">3D 相对论视图</div>
|
||
<div class="tab-btn" id="tab-orbit" onclick="switchTab('orbit')">轨道实验室</div>
|
||
</div>
|
||
|
||
<!-- ============ 3D 视图控制 ============ -->
|
||
<div id="view3d">
|
||
<div class="sec">黑洞参数</div>
|
||
<div class="row">
|
||
<label>质量</label>
|
||
<input type="range" id="mass" min="0" max="100" step="1" value="36">
|
||
<span class="val" id="mass-val"></span>
|
||
</div>
|
||
<div class="row">
|
||
<label>预设</label>
|
||
<button class="preset" onclick="setMassPreset(10)">恒星级 10M☉</button>
|
||
<button class="preset" onclick="setMassPreset(4.3e6)">Sgr A*</button>
|
||
<button class="preset" onclick="setMassPreset(6.5e9)">M87*</button>
|
||
</div>
|
||
<div class="data-grid" id="derived"></div>
|
||
|
||
<div class="sec">物理效应开关</div>
|
||
<div class="row">
|
||
<label class="chk"><input type="checkbox" id="tg-lens" checked> 引力透镜(光线弯曲)</label>
|
||
<label class="chk"><input type="checkbox" id="tg-doppler" checked> 多普勒束流</label>
|
||
</div>
|
||
<div class="row">
|
||
<label class="chk"><input type="checkbox" id="tg-disk" checked> 吸积盘</label>
|
||
<label class="chk"><input type="checkbox" id="tg-stars" checked> 背景星空</label>
|
||
</div>
|
||
|
||
<div class="sec">相机</div>
|
||
<div class="row">
|
||
<label>距离</label>
|
||
<input type="range" id="cam-dist" min="4" max="60" step="0.5" value="16">
|
||
<span class="val" id="cam-dist-val">16 rs</span>
|
||
</div>
|
||
<div class="row">
|
||
<label>视角</label>
|
||
<button class="preset" onclick="setView(0.09)">侧视(盘边缘)</button>
|
||
<button class="preset" onclick="setView(0.55)">斜 30°</button>
|
||
<button class="preset" onclick="setView(1.35)">俯视</button>
|
||
</div>
|
||
<div class="row">
|
||
<label>画质</label>
|
||
<select id="quality">
|
||
<option value="0.5">低(流畅)</option>
|
||
<option value="0.75" selected>中</option>
|
||
<option value="1">高</option>
|
||
</select>
|
||
</div>
|
||
<div class="hint">鼠标左键拖拽旋转 · 滚轮缩放 · 吸积盘内侧更亮更蓝(温度 T∝r⁻³ᐟ⁴),朝向你运动的一侧因相对论性束流更亮。</div>
|
||
|
||
<div class="sec">结构与真实观测</div>
|
||
<details>
|
||
<summary>事件视界 / 光子球 / ISCO / 阴影</summary>
|
||
<p><b>事件视界 r=rs</b>:光也无法逃逸的边界,rs=2GM/c²。</p>
|
||
<p><b>光子球 r=1.5rs</b>:光子可绕黑洞做(不稳定)圆轨道。画面中细亮的"光子环"即多次绕转的光。</p>
|
||
<p><b>ISCO r=3rs</b>:最内稳定圆轨道,吸积盘内缘在此(物质自此螺旋坠入)。</p>
|
||
<p><b>黑洞阴影</b>:背景光被捕获形成的暗区,直径≈5.2rs,比视界大得多——这正是 EHT 拍到的"环状亮圈包着暗区"。</p>
|
||
</details>
|
||
<details>
|
||
<summary>人类如何真的"看到"黑洞</summary>
|
||
<p><b>事件视界望远镜 EHT</b>:全球毫米波射电望远镜组成地球口径的 VLBI 阵列,2019 年发布 M87*(65亿M☉)首张照片,2022 年发布 Sgr A*。观测波长 1.3mm,亮环即透镜化的吸积流,暗区即阴影。</p>
|
||
<p><b>LIGO/Virgo</b>:2015 年首次直接探测双黑洞并合引力波,验证强场广义相对论。</p>
|
||
<p><b>GRAVITY(VLT)</b>:追踪 S2 恒星绕 Sgr A* 的轨道,测得史瓦西进动,与"轨道实验室"页同一物理。</p>
|
||
</details>
|
||
</div>
|
||
|
||
<!-- ============ 轨道实验室 ============ -->
|
||
<div id="orbitlab">
|
||
<div class="sec">测试粒子轨道(史瓦西度规)</div>
|
||
<div class="row">
|
||
<button class="preset" onclick="orbitPreset('isco')">ISCO 圆轨道 r=3rs</button>
|
||
<button class="preset" onclick="orbitPreset('prec')">进动椭圆</button>
|
||
<button class="preset" onclick="orbitPreset('plunge')">俘获坠入</button>
|
||
</div>
|
||
<canvas id="orbit-canvas" width="560" height="560"></canvas>
|
||
<div id="orbit-info"></div>
|
||
<div class="hint">蓝圈=事件视界(1rs) · 青圈=光子球(1.5rs) · 黄圈=ISCO(3rs)。椭圆轨道近日点每圈前移 Δφ≈6πGM/[c²a(1−e²)],S2 恒星绕银心的进动已被 GRAVITY 实测验证。</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="load-error">WebGL 不可用,无法运行黑洞模拟。</div>
|
||
|
||
<script>
|
||
'use strict';
|
||
/* =====================================================================
|
||
* 黑洞教学模拟器
|
||
* 物理内核:史瓦西度规零测地线(光子)数值积分
|
||
* d²x/dλ² = -(3/2) rs h² x / r⁵ (h 为比角动量,守恒)
|
||
* 该式精确描述史瓦西时空中光的弯曲,自然涌现:
|
||
* 黑洞阴影、光子环、吸积盘上下镜像(透镜化)、爱因斯坦环
|
||
* 吸积盘辐射:T∝r^(-3/4),多普勒束流 δ=1/[γ(1-βcosθ)],
|
||
* 引力红移 g=√(1-rs/r),观测强度 ∝ g³(相对论性束流)
|
||
* ================================================================== */
|
||
|
||
const canvas = document.getElementById('glcanvas');
|
||
const gl = canvas.getContext('webgl', { antialias: false, preserveDrawingBuffer: true }) || canvas.getContext('experimental-webgl');
|
||
if (!gl) { document.getElementById('load-error').style.display = 'flex'; throw new Error('WebGL unavailable'); }
|
||
|
||
/* ---------------- 着色器 ---------------- */
|
||
const VERT = `
|
||
attribute vec2 aPos;
|
||
void main(){ gl_Position = vec4(aPos, 0.0, 1.0); }
|
||
`;
|
||
|
||
const FRAG = `
|
||
#ifdef GL_FRAGMENT_PRECISION_HIGH
|
||
precision highp float;
|
||
#else
|
||
precision mediump float;
|
||
#endif
|
||
uniform vec2 uRes;
|
||
uniform vec3 uCamPos, uCamR, uCamU, uCamF;
|
||
uniform float uTime, uLens, uDoppler, uDisk, uStars, uAspect;
|
||
|
||
const float RS = 1.0; // 史瓦西半径(几何单位)
|
||
const float DISK_IN = 3.0; // ISCO
|
||
const float DISK_OUT = 13.0;
|
||
const int MAX_STEPS = 300;
|
||
|
||
float hash13(vec3 p){ p = fract(p*0.1031); p += dot(p, p.yzx+33.33); return fract((p.x+p.y)*p.z); }
|
||
float hash12(vec2 p){ vec3 p3 = fract(vec3(p.xyx)*0.1031); p3 += dot(p3, p3.yzx+33.33); return fract((p3.x+p3.y)*p3.z); }
|
||
float noise2(vec2 p){
|
||
vec2 i = floor(p), f = fract(p); f = f*f*(3.0-2.0*f);
|
||
return mix(mix(hash12(i), hash12(i+vec2(1,0)), f.x),
|
||
mix(hash12(i+vec2(0,1)), hash12(i+vec2(1,1)), f.x), f.y);
|
||
}
|
||
|
||
// 黑体色温近似色带:冷(橙红) -> 热(蓝白)
|
||
vec3 tempColor(float t){
|
||
t = clamp(t, 0.0, 1.0);
|
||
vec3 c1 = vec3(1.0, 0.25, 0.06);
|
||
vec3 c2 = vec3(1.0, 0.72, 0.30);
|
||
vec3 c3 = vec3(1.0, 0.98, 0.92);
|
||
vec3 c4 = vec3(0.62, 0.76, 1.0);
|
||
if (t < 0.35) return mix(c1, c2, t/0.35);
|
||
if (t < 0.7) return mix(c2, c3, (t-0.35)/0.35);
|
||
return mix(c3, c4, (t-0.7)/0.3);
|
||
}
|
||
|
||
// 背景星空(随光线方向,透镜会自然扭曲它)
|
||
vec3 starfield(vec3 d){
|
||
vec3 col = vec3(0.0);
|
||
if (uStars < 0.5) return col;
|
||
vec3 q = floor(d*230.0);
|
||
float h = hash13(q);
|
||
float star = smoothstep(0.9986, 1.0, h);
|
||
float bri = 0.4 + 0.6*hash13(q+11.7);
|
||
col += star * bri * mix(vec3(0.8,0.9,1.0), vec3(1.0,0.85,0.7), hash13(q+3.1));
|
||
// 微弱银河带
|
||
float band = exp(-abs(d.y)*6.0);
|
||
col += band * 0.05 * noise2(d.xz*4.0 + d.y*2.0) * vec3(0.7,0.8,1.0);
|
||
return col;
|
||
}
|
||
|
||
// 吸积盘局部辐射(含多普勒束流 + 引力红移)
|
||
vec3 diskShade(vec3 pos, vec3 rayDir){
|
||
float r = length(pos);
|
||
// 温度分布 T ∝ r^(-3/4),以 ISCO 为 1 归一
|
||
float tN = pow(DISK_IN / r, 0.75);
|
||
// 开普勒旋转湍流纹理(角速度 ∝ r^-1.5)
|
||
float ang = atan(pos.z, pos.x);
|
||
float om = 1.0 / pow(r, 1.5);
|
||
float n = noise2(vec2(ang*2.0 + uTime*om*3.0, r*1.5));
|
||
n = 0.65 + 0.7*n;
|
||
|
||
float g = sqrt(max(1.0 - RS/r, 0.0)); // 引力红移
|
||
float dop = 1.0;
|
||
if (uDoppler > 0.5) {
|
||
float beta = sqrt(0.5 / max(r - 1.0, 0.05)); // 局部静止观者测得的圆轨道速度/c
|
||
beta = min(beta, 0.94);
|
||
vec3 tangent = normalize(vec3(pos.z, 0.0, -pos.x)); // 顺行方向
|
||
vec3 toCam = normalize(-rayDir);
|
||
float cosT = dot(tangent, toCam);
|
||
float gam = 1.0 / sqrt(1.0 - beta*beta);
|
||
dop = 1.0 / (gam * (1.0 - beta*cosT)); // 多普勒因子
|
||
}
|
||
float gTot = g * dop;
|
||
vec3 col = tempColor(clamp(tN * gTot, 0.0, 1.0));
|
||
float inten = pow(tN, 3.0) * pow(gTot, 3.0) * n * 3.0; // 束流: I ∝ δ³
|
||
return col * inten;
|
||
}
|
||
|
||
void main(){
|
||
vec2 uv = (gl_FragCoord.xy - 0.5*uRes) / uRes.y;
|
||
uv.x *= uAspect;
|
||
// 相机光线(FOV≈60°)
|
||
vec3 dir = normalize(uCamF + 0.577*uv.x*uCamR + 0.577*uv.y*uCamU);
|
||
|
||
vec3 x = uCamPos; // 光子位置
|
||
vec3 v = dir; // 光子方向(切矢)
|
||
vec3 col = vec3(0.0);
|
||
bool done = false;
|
||
|
||
for (int s = 0; s < MAX_STEPS; s++) {
|
||
vec3 px = x; // 上一步位置
|
||
float r = length(x);
|
||
|
||
if (r < RS) { col = vec3(0.0); done = true; break; } // 落入视界 -> 阴影
|
||
if (r > 90.0) { col = starfield(normalize(v)); done = true; break; } // 逃逸 -> 背景星空
|
||
|
||
// 零测地线加速度 a = -(3/2) rs h² x / r⁵ 与自适应步长
|
||
float h2 = dot(cross(x, v), cross(x, v));
|
||
vec3 a = (uLens > 0.5) ? -1.5 * RS * h2 * x / pow(r, 5.0) : vec3(0.0);
|
||
float dt = clamp(0.02 + 0.045*r, 0.02, 0.7); // 近黑洞步长更细
|
||
v += a * dt; // 半隐式欧辛积分
|
||
x += v * dt;
|
||
|
||
// 穿越吸积盘平面 (y=0):在 px 与 x 之间线性插值
|
||
if (uDisk > 0.5 && (px.y * x.y) < 0.0) {
|
||
float fr = px.y / (px.y - x.y);
|
||
vec3 pHit = mix(px, x, fr);
|
||
float rc = length(pHit);
|
||
if (rc > DISK_IN && rc < DISK_OUT) { col += diskShade(pHit, v); done = true; break; }
|
||
}
|
||
}
|
||
if (!done) col = starfield(normalize(v));
|
||
|
||
// 色调映射 + gamma
|
||
col = 1.0 - exp(-col * 1.1);
|
||
col = pow(col, vec3(0.4545));
|
||
gl_FragColor = vec4(col, 1.0);
|
||
}
|
||
`;
|
||
|
||
/* ---------------- WebGL 初始化 ---------------- */
|
||
function compile(type, src) {
|
||
const sh = gl.createShader(type);
|
||
gl.shaderSource(sh, src);
|
||
gl.compileShader(sh);
|
||
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
|
||
console.error(gl.getShaderInfoLog(sh));
|
||
throw new Error('shader compile error');
|
||
}
|
||
return sh;
|
||
}
|
||
const prog = gl.createProgram();
|
||
gl.attachShader(prog, compile(gl.VERTEX_SHADER, VERT));
|
||
gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, FRAG));
|
||
gl.linkProgram(prog);
|
||
gl.useProgram(prog);
|
||
|
||
const quad = gl.createBuffer();
|
||
gl.bindBuffer(gl.ARRAY_BUFFER, quad);
|
||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 3,-1, -1,3]), gl.STATIC_DRAW);
|
||
const aPos = gl.getAttribLocation(prog, 'aPos');
|
||
gl.enableVertexAttribArray(aPos);
|
||
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
|
||
|
||
const U = {};
|
||
['uRes','uCamPos','uCamR','uCamU','uCamF','uTime','uLens','uDoppler','uDisk','uStars','uAspect']
|
||
.forEach(n => U[n] = gl.getUniformLocation(prog, n));
|
||
|
||
/* ---------------- 相机(球面轨道控制) ---------------- */
|
||
const cam = { theta: 0.6, phi: 0.10, dist: 16 };
|
||
function camBasis() {
|
||
const cp = Math.cos(cam.phi), sp = Math.sin(cam.phi);
|
||
const ct = Math.cos(cam.theta), st = Math.sin(cam.theta);
|
||
const pos = [cam.dist*cp*st, cam.dist*sp, cam.dist*cp*ct];
|
||
const f = norm([-pos[0], -pos[1], -pos[2]]);
|
||
const r = norm(cross(f, [0,1,0]));
|
||
const u = cross(r, f);
|
||
return { pos, f, r, u };
|
||
}
|
||
function norm(v){ const l = Math.hypot(...v)||1; return v.map(x=>x/l); }
|
||
function cross(a,b){ return [a[1]*b[2]-a[2]*b[1], a[2]*b[0]-a[0]*b[2], a[0]*b[1]-a[1]*b[0]]; }
|
||
|
||
let dragging = false, lx = 0, ly = 0;
|
||
canvas.addEventListener('mousedown', e => { dragging = true; lx = e.clientX; ly = e.clientY; });
|
||
window.addEventListener('mouseup', () => dragging = false);
|
||
window.addEventListener('mousemove', e => {
|
||
if (!dragging) return;
|
||
cam.theta -= (e.clientX - lx) * 0.005;
|
||
cam.phi = Math.min(1.5, Math.max(-1.5, cam.phi + (e.clientY - ly) * 0.005));
|
||
lx = e.clientX; ly = e.clientY;
|
||
});
|
||
canvas.addEventListener('wheel', e => {
|
||
e.preventDefault();
|
||
cam.dist = Math.min(60, Math.max(4, cam.dist * (1 + e.deltaY * 0.001)));
|
||
document.getElementById('cam-dist').value = cam.dist;
|
||
document.getElementById('cam-dist-val').textContent = cam.dist.toFixed(1) + ' rs';
|
||
}, { passive: false });
|
||
|
||
/* ---------------- 物理量推导(随质量更新) ---------------- */
|
||
const C = 299792.458; // km/s
|
||
const RS_KM_PER_MSUN = 2.9532; // 每太阳质量的 rs (km)
|
||
function currentMass() {
|
||
// 滑块 0..100 对数映射 1 .. 1e10 M☉
|
||
const t = parseFloat(document.getElementById('mass').value);
|
||
return Math.pow(10, t / 10); // 0->1, 100->1e10
|
||
}
|
||
function fmtKm(km) {
|
||
if (km < 1e6) return km.toFixed(1) + ' km';
|
||
if (km < 1.496e8) return (km/1e6).toFixed(2) + ' 百万km';
|
||
return (km/1.496e8).toFixed(2) + ' AU';
|
||
}
|
||
function fmtMass(m) {
|
||
if (m < 1e4) return m.toFixed(0) + ' M☉';
|
||
if (m < 1e8) return (m/1e6).toFixed(1) + '×10⁶ M☉';
|
||
return (m/1e9).toFixed(1) + '×10⁹ M☉';
|
||
}
|
||
function updateDerived() {
|
||
const M = currentMass();
|
||
const rs = RS_KM_PER_MSUN * M;
|
||
const tIsco = 4.55e-4 * M; // 秒 (ISCO 轨道周期近似)
|
||
const tIn = 6.3e7 * Math.pow(M/10, -0.25); // 内盘温度量级 K
|
||
document.getElementById('mass-val').textContent = fmtMass(M);
|
||
document.getElementById('derived').innerHTML =
|
||
`<span class="k">史瓦西半径 rs</span><span class="v">${fmtKm(rs)}</span>` +
|
||
`<span class="k">光子球 1.5rs</span><span class="v">${fmtKm(rs*1.5)}</span>` +
|
||
`<span class="k">ISCO 3rs</span><span class="v">${fmtKm(rs*3)}</span>` +
|
||
`<span class="k">阴影直径 ≈5.2rs</span><span class="v">${fmtKm(rs*5.196)}</span>` +
|
||
`<span class="k">ISCO 周期</span><span class="v">${tIsco<1? (tIsco*1000).toFixed(2)+' ms' : tIsco<3600? tIsco.toFixed(1)+' s' : (tIsco/3600).toFixed(1)+' h'}</span>` +
|
||
`<span class="k">内盘温度(量级)</span><span class="v">${tIn.toExponential(1)} K</span>`;
|
||
}
|
||
function setMassPreset(m) {
|
||
// 反解滑块值 t = 10*log10(m)
|
||
document.getElementById('mass').value = Math.round(10 * Math.log10(m));
|
||
updateDerived();
|
||
}
|
||
|
||
/* ---------------- 开关 / 视角 / 画质 ---------------- */
|
||
const tg = id => document.getElementById(id).checked;
|
||
function setView(phi) { cam.phi = phi; }
|
||
let quality = 0.75;
|
||
document.getElementById('quality').addEventListener('change', e => { quality = parseFloat(e.target.value); resize(); });
|
||
document.getElementById('cam-dist').addEventListener('input', e => {
|
||
cam.dist = parseFloat(e.target.value);
|
||
document.getElementById('cam-dist-val').textContent = cam.dist.toFixed(1) + ' rs';
|
||
});
|
||
document.getElementById('mass').addEventListener('input', updateDerived);
|
||
|
||
/* ---------------- 尺寸 ---------------- */
|
||
function resize() {
|
||
const dpr = Math.min(devicePixelRatio || 1, 2) * quality;
|
||
canvas.width = Math.floor(innerWidth * dpr);
|
||
canvas.height = Math.floor(innerHeight * dpr);
|
||
gl.viewport(0, 0, canvas.width, canvas.height);
|
||
}
|
||
window.addEventListener('resize', resize);
|
||
resize();
|
||
|
||
/* ---------------- 渲染循环 ---------------- */
|
||
function render(t) {
|
||
const b = camBasis();
|
||
gl.uniform2f(U.uRes, canvas.width, canvas.height);
|
||
gl.uniform1f(U.uAspect, canvas.width / canvas.height);
|
||
gl.uniform3fv(U.uCamPos, b.pos);
|
||
gl.uniform3fv(U.uCamR, b.r);
|
||
gl.uniform3fv(U.uCamU, b.u);
|
||
gl.uniform3fv(U.uCamF, b.f);
|
||
gl.uniform1f(U.uTime, t * 0.001);
|
||
gl.uniform1f(U.uLens, tg('tg-lens') ? 1 : 0);
|
||
gl.uniform1f(U.uDoppler, tg('tg-doppler') ? 1 : 0);
|
||
gl.uniform1f(U.uDisk, tg('tg-disk') ? 1 : 0);
|
||
gl.uniform1f(U.uStars, tg('tg-stars') ? 1 : 0);
|
||
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
||
}
|
||
let orbitRAF = null;
|
||
function loop(t) {
|
||
if (!document.getElementById('view3d').classList.contains('hide')) render(t);
|
||
requestAnimationFrame(loop);
|
||
}
|
||
updateDerived();
|
||
requestAnimationFrame(loop);
|
||
|
||
/* ================= 轨道实验室(2D 史瓦西进动) =================
|
||
* 比内方程:d²u/dφ² + u = K + 1.5 rs u², u=1/r, rs=1, K=GM/h²
|
||
* 牛顿项 K 给出椭圆,1.5u² 相对论项导致近日点进动 */
|
||
const oc = document.getElementById('orbit-canvas');
|
||
const octx = oc.getContext('2d');
|
||
let orbit = null;
|
||
function orbitPreset(kind) {
|
||
// 状态 [u, du/dφ]
|
||
if (kind === 'isco') {
|
||
const r = 3, u = 1/r, K = u - 1.5*u*u; // 圆轨道
|
||
orbit = { u, du: 0, K, phi: 0, trail: [], kind };
|
||
} else if (kind === 'prec') {
|
||
const a = 8, e = 0.6, rp = a*(1-e);
|
||
const h2 = 0.5 * a * (1-e*e); // 牛顿 h²=GM a(1-e²), GM=0.5
|
||
orbit = { u: 1/rp, du: 0, K: 0.5/h2, phi: 0, trail: [], kind, a, e };
|
||
} else {
|
||
orbit = { u: 1/4.5, du: 0.16, K: 0.5/2.2, phi: 0, trail: [], kind }; // 坠入
|
||
}
|
||
document.getElementById('orbit-info').innerHTML = orbitInfoText();
|
||
}
|
||
function orbitInfoText() {
|
||
if (!orbit) return '';
|
||
if (orbit.kind === 'prec') {
|
||
const dphi = 3*Math.PI / (orbit.a * (1-orbit.e*orbit.e)); // Δφ=6πGM/(c²a(1-e²)), GM/c²=0.5
|
||
return `椭圆 a=8rs e=0.6 · 理论进动 <b>Δφ≈${(dphi*180/Math.PI).toFixed(1)}°/圈</b>( exaggerated 强场演示;S2 恒星实测 ~12′/圈)`;
|
||
}
|
||
if (orbit.kind === 'isco') return `ISCO r=3rs:最内<b>稳定</b>圆轨道,吸积盘内缘。`;
|
||
return `角动量不足:粒子越过 ISCO 后<b>螺旋坠入视界</b>。`;
|
||
}
|
||
function orbitStep() {
|
||
const dphi = 0.004;
|
||
for (let i = 0; i < 6; i++) {
|
||
// RK4
|
||
const f = (u, du) => [du, -u + orbit.K + 1.5*u*u];
|
||
let [u, du] = [orbit.u, orbit.du];
|
||
const k1 = f(u, du), k2 = f(u+k1[0]*dphi/2, du+k1[1]*dphi/2),
|
||
k3 = f(u+k2[0]*dphi/2, du+k2[1]*dphi/2), k4 = f(u+k3[0]*dphi, du+k3[1]*dphi);
|
||
orbit.u += dphi/6*(k1[0]+2*k2[0]+2*k3[0]+k4[0]);
|
||
orbit.du += dphi/6*(k1[1]+2*k2[1]+2*k3[1]+k4[1]);
|
||
orbit.phi += dphi;
|
||
const r = 1/orbit.u;
|
||
if (r < 1 || r > 40) { orbit.dead = true; }
|
||
orbit.trail.push([r*Math.cos(orbit.phi), r*Math.sin(orbit.phi)]);
|
||
if (orbit.trail.length > 4000) orbit.trail.shift();
|
||
}
|
||
}
|
||
function orbitDraw() {
|
||
const W = oc.width, H = oc.height, cx = W/2, cy = H/2;
|
||
const scale = (W/2 - 20) / 20; // 视场 20rs
|
||
octx.fillStyle = '#050810'; octx.fillRect(0,0,W,H);
|
||
const circle = (r, color) => {
|
||
octx.strokeStyle = color; octx.beginPath();
|
||
octx.arc(cx, cy, r*scale, 0, Math.PI*2); octx.stroke();
|
||
};
|
||
circle(1, '#4a6cff'); circle(1.5, '#39d0d0'); circle(3, '#ffd479');
|
||
if (orbit) {
|
||
octx.strokeStyle = '#7fd47f'; octx.beginPath();
|
||
orbit.trail.forEach((p, i) => {
|
||
const X = cx + p[0]*scale, Y = cy - p[1]*scale;
|
||
i ? octx.lineTo(X, Y) : octx.moveTo(X, Y);
|
||
});
|
||
octx.stroke();
|
||
const p = orbit.trail[orbit.trail.length-1];
|
||
if (p && !orbit.dead) {
|
||
octx.fillStyle = '#fff'; octx.beginPath();
|
||
octx.arc(cx + p[0]*scale, cy - p[1]*scale, 4, 0, Math.PI*2); octx.fill();
|
||
}
|
||
}
|
||
}
|
||
function orbitLoop() {
|
||
if (orbit && !orbit.dead) orbitStep();
|
||
if (document.getElementById('orbitlab').classList.contains('show')) orbitDraw();
|
||
orbitRAF = requestAnimationFrame(orbitLoop);
|
||
}
|
||
orbitPreset('prec');
|
||
orbitLoop();
|
||
|
||
/* ---------------- 页签切换 ---------------- */
|
||
function switchTab(which) {
|
||
const isView = which === 'view';
|
||
document.getElementById('tab-view').classList.toggle('active', isView);
|
||
document.getElementById('tab-orbit').classList.toggle('active', !isView);
|
||
document.getElementById('view3d').classList.toggle('hide', !isView);
|
||
document.getElementById('orbitlab').classList.toggle('show', !isView);
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|