ブラックホールに近づいた星は、なぜ回り続けられないのか

太陽のまわりなら、どれほど近くても円を描いて回れる。ところがブラックホールのまわりでは、ある半径より内側で円を描き続けられず、わずかなずれで天体は地平線へ落ちていく。

記事

ブラックホールに近づいた星は、なぜ回り続けられないのか

太陽のまわりなら、どれほど近くても円を描いて回れる。ところがブラックホールのまわりでは、ある半径より内側で円を描き続けられず、わずかなずれで天体は地平線へ落ちていく。

作成日 2026年11月3日・更新日 2026年11月3日・執筆・監修 佐々木 航

現象

銀河の中心で、星の軌道がずれていく

天の川銀河の中心には、太陽の約400万倍の質量をもつブラックホール、いて座A*(エースター)がある。そのまわりを16年かけて1周する恒星S2は、いちばん近づくときにはブラックホールから200億 km足らずまで迫り、光速の3%近くで駆け抜ける1。ヨーロッパ南天天文台(ESO)の望遠鏡で27年にわたって追い続けた結果、S2の軌道は閉じた楕円ではなく、1周ごとに向きが少しずつずれていく「バラの花の形」だと分かった。

画面の真ん中に広がるのは、S2より何百倍もブラックホールに近い世界。黒い円は、地平線そのものではない。光がまっすぐ進むとしたときの地平線の見かけの大きさの、約2.6倍に広がった「影」だ。白い帯はブラックホールを回るガスの円盤(降着円盤)で、影の上にかかる弓は、ブラックホールの奥にある円盤から出た光が、上を回り込んで届いた姿である。

太陽のまわりでは、ちょうどよい速ささえあれば、惑星はどれほど近くでも円を描いて回れる。しかし、ブラックホールのまわりは違う。中心からある距離より内側では、円を描いていた天体が、少し押されただけで落ちていく。

右下の小さな図は、真上から見た軌道である。そこをドラッグすると、好きな場所から好きな速さで天体を投げ入れられる。

仕組み

遠心力に勝つ、もう1つの引力

ブラックホールのそばでは、重力半径を長さの単位にすると見通しがよくなる。は万有引力定数、は光速、はブラックホールの質量だ。重力半径は、太陽の質量なら1.48 km、いて座A*なら約590万 km。ここからは、この単位の長さをと書き、とする。回転しないブラックホールなら、地平線の半径はだ。

中心のまわりを回る天体は、中心からの距離だけに注目すると、でこぼこの坂を転がる玉と同じように動く。この坂の形が、有効ポテンシャルである。天体は、自分のエネルギーより坂が高い所へは行けない。だから谷にいる天体は、谷の中を、両側の壁ではね返されながら行ったり来たりする。ニュートン力学では、これが楕円軌道になる。谷のちょうど底にとどまれば、円軌道になる。

一般相対性理論では、質量1あたりの角運動量を、天体自身の時計で測る時間をとすると、坂の形は次の式で書ける。

は天体のエネルギーで決まる定数である。の1項目はニュートンの重力による坂、2項目は遠心力にあたる坂で、ここまではニュートン力学と変わらない。違うのは3項目ので、近づくほど急に強くなる引力を表している。つまり、ニュートンの重力に、もう1つの引力が加わったのだ。

遠くでは3項目が小さいので、坂はニュートンのときと同じ谷の形のままだ。ところが近づくと、で効く3項目がで効く遠心力の項に追いつき、谷の内側に山ができる。山の向こうは、地平線まで続く崖だ。を小さくしていくと、谷は浅くなりながら内側へ寄り、山は外へ寄ってくる。そしてで谷と山がぶつかり、谷が消える。そのときの半径が、最内安定円軌道(ISCO)だ。これより内側にも円軌道はあるが、山の頂上に乗った玉と同じで、わずかなずれで転がり落ちる。

有効ポテンシャルV(r)(質量1あたり、G = c = 1) 0 2M 6M 10M 20M 30M 中心からの距離r 山(不安定な円軌道) 谷の底(安定な円軌道) L = √12 Mで山と谷が重なる(r = 6M、ISCO) L = 4.3M L = 3.8M L = √12 M L = 3.2M ニュートン(L = 3.8M)
角運動量Lごとの有効ポテンシャル。Lが小さくなると谷と山が近づき、L = √12 M、r = 6Mで重なって消える

3項目が変えるのは、円軌道があるかないかだけではない。近点(いちばん近づく点)から次の近点まで戻るあいだに、谷の中を行き来する天体は、360°より少し多く回ってしまう。近点の向きが1周ごとに先へずれ、軌道がバラの花の形になるのはこのためだ。重力が弱い所なら、1周でずれる角は次の式で見積もれる。

は楕円の長半径、は離心率で、の単位はラジアンである。分母のが小さい軌道、つまりブラックホールに近づく軌道ほど、ずれは大きい。

仕組み

光が回り続ける半径と、影の大きさ

光も重力で曲がる。と置くと、回転しないブラックホールのそばを進む光の道筋は次の式に従う。

は中心のまわりの角度である。右辺が0なら、答えはまっすぐな線。右辺のは、天体の式の3項目と同じく、一般相対性理論で加わった部分だ。この式には、がずっとのまま、つまり半径で円を描く答えがある。光が円を描けるこの半径が、光子球である。ただしこの円も山の頂上と同じで、少しでもずれた光は、内側へ落ちるか外へ出ていく。

遠くから平行にやってくる光は、中心からどれだけ離れて通るかで行き先が決まる。この離れ具合が、衝突パラメータだ。が(約)より小さい光は地平線に落ち、大きい光は曲がって出ていく。ちょうどの光は、光子球のまわりを回り続けて出てこない。下の図で四角い取っ手を上下に動かすと、がに近いほど、光がブラックホールのまわりを大きく回り込み、1周以上してから出ていくのが分かる。

遠くから見ると、ブラックホールの後ろから来る光のうち、がより小さいものは届かない。だから影の半径は地平線のではなくになる。地平線より約2.6倍も大きく見えるのだ。影の縁のすぐ外には、光子球のまわりを半周、1周と回ってから届いた光が重なって、細い輪(光子リング)ができる。

中心からの距離(単位M = GM/c²) 中心 地平線(2M) 光も出てこられない 光子球(3M) 光が円を描ける 影の半径(√27 M ≈ 5.2M) 遠くから見た影の大きさ ISCO(6M) 安定な円軌道の内側の限界 影の半径は空間の中の場所ではなく、遠くから来る光の通り道の高さ(衝突パラメータb)で測った大きさ
地平線・光子球・影・ISCOの4つの大きさ。影の半径だけは場所ではなく、光の通り道の高さで測る

主のシミュレーションは、画面の1画素ごとに、この式で光を目から逆向きにたどっている。地平線に落ちれば黒、円盤を横切ればそこにある円盤の明るさ、遠くまで届けばその方向にある星の明るさ。計算機でブラックホールの見え方を描く試みは、1970年代から続いてきた2。

では、円盤はどう光って見えるのか。シミュレーションの円盤には、2つの効果が入っている。円盤のガスは内側の縁で光速の半分ほどの速さで回っていて、こちらへ近づく側から来る光は振動数が上がり、明るく見える(ドップラー効果)。一方、地平線に近い所から出た光は、重力に逆らって昇るあいだに振動数が下がる。2つを合わせた振動数の比をとし、円盤の明るさにはを掛けた。

試す

6Mの内側をのぞく

  1. 半径7M・10M・14Mの円軌道の天体と、少しずらした天体4を回す。左上のグラフで、選んだ天体2がいるのは谷の底だ。ずらした天体4は谷の中を行き来するので、真上から見た図では幅のある輪を描く。
  2. 近点8M・遠点24Mの天体を回す。1周ごとに、真上から見た図に付く近点の印が約150°先へ進み、一点鎖線の楕円(同じ近点と遠点をもつニュートンの軌道)から離れていく。近点から近点まで回る角が、360°より大きいからだ。
  3. 角運動量がより小さい天体2は、グラフに谷がなく、1周しないうちに地平線へ落ちる。半径5Mの円軌道に置いた天体3も、内向きにわずかな速さを与えただけで、1周半ほどで落ちる。一方、6.5Mの天体1は回り続ける。
  4. 円盤を消して、天球の方眼だけを見る。破線の円が影の縁、内側の点線の円は、光がまっすぐ進むとした場合の地平線の大きさだ。影の縁のすぐ外では、方眼が何重にも細く押しつぶされている。光子球のそばを回ってから届いた光の像である。
  5. 円盤を真横から見る。影の上の弓は、奥の円盤の上の面から出た光が上を回り込んだ像で、下の弓は、同じ円盤の下の面から出た光が下を回り込んだ像だ。左が明るいのは、ガスがこちらへ近づく側だからである。
現実では

銀河の中心で測られた回り込みと影

S2の軌道のずれは、2020年にGRAVITY共同研究チームが発表した3。近点の向きは1周(約16年)で12分角、つまり0.2°進んでいた。一般相対性理論の予言を1としたときの比は1.10 ± 0.19で、予言どおりである。ただ、シミュレーションの約150°と比べると、ずっと小さい。S2がいちばん近づいても、重力半径の約3,000倍離れているからだ。上の式で分母にあたるは、シミュレーションで回した軌道の数百倍にもなる。

ISCOのすぐ外を回るガスも見つかっている。同じチームは2018年、いて座Aのすぐそばで赤外線が急に明るくなる現象(フレア)が起きたとき、光る点が約45分の周期で円を描くように動くのをとらえた4。速さは光の約30%、半径は重力半径の6〜10倍と見積もられている。シミュレーションでは、リセットした最初の画面で、真上から見た図の天体1(半径8Mの円軌道)をたたいて選ぶ。質量が「いて座A」なら、1周は約47分。

ブラックホールの影そのものは、イベント・ホライズン・テレスコープ(EHT)が撮影した。2019年4月に公開された楕円銀河M87の中心にあるブラックホールは、太陽の約65億倍の質量をもち、地球から約5,500万光年の距離にある5。明るいリングの直径は42マイクロ秒角で、国立天文台は「月面に置いた野球のボールを地球から見た時の大きさ」にたとえている。リングの片側が明るいのは、光速に近い速さで回るガスのうち、こちらへ近づく側が明るく見えるためと説明された。

2022年5月には、いて座Aの画像も公開された。リングの直径は51.8マイクロ秒角である6。いて座Aの質量は、S2などの恒星の動きから太陽の約400万倍と分かっていて、距離は約2万7,000光年。影の直径をこの距離で割ると、約50マイクロ秒角になる。恒星の軌道から求めた質量で予言した影と、電波で撮ったリングは、ほぼ同じ大きさだった。

省いていること

このシミュレーションが省いていること

  • ブラックホールの回転を省いた。回転するブラックホール(カー・ブラックホール)では、回転と同じ向きに回る天体のISCOが6Mより内側に寄り、影の形や大きさも少し変わる。EHTの比較では、いて座A*は回転していないモデルより、回転しているモデルのほうがよく合うとされている6
  • 円盤は厚さのない板とし、明るさはニュートン力学の薄い円盤の式に、重力とドップラー効果による変化()を掛けただけである。実際のいて座A*やM87のまわりにあるのは厚く希薄な高温のガスで、EHTが見ているのはそこから出る電波だ。円盤の模様は、半径ごとに回る速さが違うことを見せるための作りもので、細い輪ごとに同じ速さで回している
  • 光が届くまでの時間の差を省いた。奥の円盤から来る光は手前の円盤からの光より長い道のりを通るが、画面はすべての光が同時に届いたものとして描いている
  • 天体は大きさのない点として動かし、天体どうしの重力、潮汐力で引き伸ばされること、重力波でエネルギーを失うことは入れていない。画面の天体は見やすいように実物よりずっと大きく描き、中心から照らされているように陰影を付けた
  • 1画素あたりの計算の回数に上限があり、光子球のまわりを何周も回る光は途中で打ち切って黒にしている。そのため、光子リングのうち、いちばん内側の細い輪は描かれない
参考文献

参考文献

  1. European Southern Observatory「ESO Telescope Sees Star Dance Around Supermassive Black Hole, Proves Einstein Right」eso2006, 2020. https://www.eso.org/public/news/eso2006/
  2. GRAVITY Collaboration「Detection of the Schwarzschild precession in the orbit of the star S2 near the Galactic centre massive black hole」『Astronomy & Astrophysics』636, L5, 2020. https://arxiv.org/abs/2004.07187
  3. GRAVITY Collaboration「Detection of orbital motions near the last stable circular orbit of the massive black hole SgrA*」『Astronomy & Astrophysics』618, L10, 2018. https://arxiv.org/abs/1810.12641
  4. 国立天文台「史上初、ブラックホールの撮影に成功」2019. https://www.nao.ac.jp/news/science/2019/20190410-eht.html
  5. Event Horizon Telescope Collaboration「First M87 Event Horizon Telescope Results. I. The Shadow of the Supermassive Black Hole」『The Astrophysical Journal Letters』875(1), L1, 2019. https://arxiv.org/abs/1906.11238
  6. 国立天文台「天の川銀河中心のブラックホールの撮影に初めて成功」2022. https://www.nao.ac.jp/news/science/2022/20220512-eht.html
  7. Event Horizon Telescope Collaboration「First Sagittarius A* Event Horizon Telescope Results. I. The Shadow of the Supermassive Black Hole in the Center of the Milky Way」『The Astrophysical Journal Letters』930(2), L12, 2022. https://arxiv.org/abs/2311.08680
  8. Luminet, J.-P.「An Illustrated History of Black Hole Imaging: Personal Recollections (1972-2002)」arXiv:1902.11196, 2019. https://arxiv.org/abs/1902.11196

注

  1. 参考文献1。 ↩

  2. 参考文献8。 ↩

  3. 参考文献2。近点の移動の大きさを、一般相対性理論の予言を1とする係数で表した値が1.10 ± 0.19。 ↩

  4. 参考文献3。 ↩

  5. 参考文献4・5。質量はリングの大きさを数値シミュレーションと比べて求めた値。 ↩

  6. 参考文献6・7。 ↩ ↩2

プログラム
プログラム

このシミュレーションのプログラム

このシミュレーションは、2種類の計算を組み合わせている。まわりを回る天体は、1コマごとにCPUで運動の式を細かく積分して動かす。画面の絵はGPUが画素ごとに光の道筋を逆向きにたどって作り、天体の位置はそのつどGPUに渡している。

長さも時間もMで測る

万有引力定数と光速を1とし、ブラックホールの質量も1とする。すると長さの単位は重力半径に、時間の単位は光がその距離を進む時間になる。地平線は、光子球は、ISCOはと、式の中の数字がそのまま本文の半径になる。

定数と単位(src/sims/black-hole.ts の 20〜79 行)
 // 円盤の内側と外側の半径(M)・天体の数の上限・軌跡の点の数・時間の速さ 1 倍のとき 1 秒に進む時間(M)const RIN = 6, ROUT = 16, MAXB = 12, TRAIL = 1600, GUIDE = 1400, RATE = 40;const KM = 1.4766, SEC = 4.9255e-6; // 太陽の質量のときの GM/c²(km)と GM/c³(秒)const MASS: Record<string, { m: number; label: string }> = {  stellar: { m: 10, label: "太陽の 10 倍" },  sgr: { m: 4.0e6, label: "いて座 A*" },  m87: { m: 6.5e9, label: "M87*" },};// 画質ごとの光の積分: 角の刻み h(ラジアン)と、1 画素で進める回数の上限 nconst QUAL = [{ h: 0.1, n: 120 }, { h: 0.07, n: 200 }, { h: 0.05, n: 320 }]; type Vec = [number, number, number];const cross = (a: Vec, b: Vec): Vec => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];const dot = (a: Vec, b: Vec) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; interface Orb { r: number; pr: number; phi: number; E: number; L: number; tau: number; t: number }interface Info { kind: "circle" | "bound" | "plunge" | "escape"; rp: number; ra: number; prec: number; wall: boolean }interface Body extends Orb {  id: number; tx: Float32Array; ty: Float32Array; tn: number; ti: number; lx: number; ly: number;  rmin: number; rmax: number; lastPeri: number; meas: number; peri: number[];  info: Info | null; guide: Float32Array; gn: number; guideAt: number;}interface Box { x: number; y: number; w: number; h: number }interface Drag { mode: "cam" | "throw"; lx: number; ly: number; moved: boolean; px: number; py: number; vr: number; vt: number; v: number; snap: boolean; path: Float32Array; pn: number; info: Info | null }interface S {  gl: WebGL2RenderingContext | null; prog: Prog | null; quad: ReturnType<typeof fullscreen> | null; locB: WebGLUniformLocation | null;  bodies: Body[]; nextId: number; sel: Body | null; time: number; camPhi: number;  step: (dt: number, f: (h: number) => void) => number;  bArr: Float32Array; drag: Drag | null; mapR: number; map: Box | null; graph: Box | null;  mt: { cx: number; cy: number; k: number; cp: number; sp: number } | null;} // ---- 数字の表し方const num = (x: number, d: number) => {  if (!Number.isFinite(x)) return "-";  const s = Math.abs(x).toLocaleString("en-US", { minimumFractionDigits: d, maximumFractionDigits: d });  return (x < 0 && Number(s.replace(/,/g, "")) !== 0 ? "−" : "") + s;};const dg = (x: number) => (Math.abs(x) < 10 ? 1 : 0);function realTime(sec: number) {  const a = Math.abs(sec);  if (!Number.isFinite(a)) return "-";  if (a < 1e-3) return `${num(sec * 1e6, dg(sec * 1e6))} マイクロ秒`;  if (a < 1) return `${num(sec * 1e3, dg(sec * 1e3))} ミリ秒`;  if (a < 120) return `${num(sec, dg(sec))} 秒`;  if (a < 7200) return `${num(sec / 60, dg(sec / 60))} 分`;  if (a < 172800) return `${num(sec / 3600, dg(sec / 3600))} 時間`;  if (a < 6.3e7) return `${num(sec / 86400, dg(sec / 86400))} 日`;  return `${num(sec / 3.156e7, 1)} 年`;}function realLen(km: number) {  if (!Number.isFinite(km)) return "-";  if (km < 1e4) return `${num(km, dg(km))} km`;  if (km < 1e8) return `${num(km / 1e4, dg(km / 1e4))} 万 km`;  if (km < 1e12) return `${num(km / 1e8, dg(km / 1e8))} 億 km`;  return `${num(km / 1e12, 1)} 兆 km`;}// 文字の大きさ: 画面の幅に比例させ、大きな画面でも小さな画面でも読める範囲に収めるconst fs = (api: Api, n: number) => clamp(api.u(n), n * 0.95, n * 1.3);

KMとSECは、太陽の質量のときの重力半径(km)と時間(秒)である。これにブラックホールの質量(太陽の何倍か)を掛けると、本物の距離と時間に戻せる。QUALは画質ごとの光の計算の細かさで、角の刻みと、1画素で進める回数の上限を決めている。

天体を動かす

本文の有効ポテンシャルをで微分して符号を変えると、動径の向きの加速度になる。

accRがこの式で、3つの項が本文の3つの項に対応する。向きはで進む。天体自身の時計で積分するのは、地平線に近づいても式が発散しないからだ。

天体の運動(src/sims/black-hole.ts の 81〜133 行)
// 天体の動きは、エネルギー E と角運動量 L(どちらも質量 1 あたり)が保存されることから決まる// E² の形の有効ポテンシャル: (dr/dτ)² = E² − V²(r) なので、E² が V² より小さい所には行けないconst V2 = (r: number, L: number) => (1 - 2 / r) * (1 + (L * L) / (r * r));// 動径の向きの加速度 d²r/dτ²。1 項目がニュートンの重力、2 項目が遠心力の項、3 項目が相対論で加わる引力(近いほど急に強くなる)const accR = (r: number, L2: number) => { const r2 = r * r; return -1 / r2 + L2 / (r2 * r) - (3 * L2) / (r2 * r2); };// 遠くの時計の進み dt/dτ = E/(1 − 2/r)。地平線に近づくほど大きくなる(遠くから見ると、天体の時計はゆっくり進む)const lapseInv = (E: number, r: number) => E / Math.max(1e-4, 1 - 2 / r);/** 4 次のルンゲ・クッタ法で、固有時を h だけ進める(r・dr/dτ・φ・t を同時に) */function rk4(o: Orb, h: number) {  const L = o.L, L2 = L * L, r = o.r, p = o.pr;  const a1 = accR(r, L2), f1 = L / (r * r);  const r2 = r + 0.5 * h * p, p2 = p + 0.5 * h * a1, a2 = accR(r2, L2), f2 = L / (r2 * r2);  const r3 = r + 0.5 * h * p2, p3 = p + 0.5 * h * a2, a3 = accR(r3, L2), f3 = L / (r3 * r3);  const r4 = r + h * p3, p4 = p + h * a3, a4 = accR(r4, L2), f4 = L / (r4 * r4);  o.t += (h / 6) * (lapseInv(o.E, r) + 2 * lapseInv(o.E, r2) + 2 * lapseInv(o.E, r3) + lapseInv(o.E, r4));  o.r = r + (h / 6) * (p + 2 * p2 + 2 * p3 + p4);  o.pr = p + (h / 6) * (a1 + 2 * a2 + 2 * a3 + a4);  o.phi += (h / 6) * (f1 + 2 * f2 + 2 * f3 + f4);  o.tau += h;}// 固有時の刻み: 円軌道の 1 周(2π r^1.5)のおよそ 500 分の 1。中心に近いほど細かくなるconst hOf = (r: number) => clamp(0.012 * r * Math.sqrt(r), 0.002, 0.8);/** 真上から見た図の軌跡に点を足す(前の点から少し離れたときだけ) */ function trail(b: Body) {  const x = b.r * Math.cos(b.phi), y = b.r * Math.sin(b.phi);  if (b.tn && Math.hypot(x - b.lx, y - b.ly) < 0.12 + 0.012 * b.r) return;  b.tx[b.ti] = x; b.ty[b.ti] = y; b.ti = (b.ti + 1) % TRAIL; b.tn = Math.min(TRAIL, b.tn + 1); b.lx = x; b.ly = y;}/** 遠くの時計で T まで進める。0 = 回っている・1 = 地平線へ落ちた・2 = 遠くへ飛び去った */function advance(b: Body, T: number) {  for (let n = 0; n < 4000 && T - b.t > 1e-9; n++) {    let h = hOf(b.r);    const rate = lapseInv(b.E, b.r);    if (h * rate > T - b.t) h = (T - b.t) / rate;    const prOld = b.pr, phiOld = b.phi;    rk4(b, h);    if (!(b.r > 2.03) || !Number.isFinite(b.r)) return 1;    if (b.r > 400) return 2;    b.rmin = Math.min(b.rmin, b.r); b.rmax = Math.max(b.rmax, b.r);    if (prOld < 0 && b.pr >= 0) {      // 近点を通った: 前の近点からの角 − 360° が近点の移動      const ph = phiOld + (b.phi - phiOld) * (-prOld / (b.pr - prOld));      if (b.rmax - b.rmin > 0.02 * b.r) {        if (Number.isFinite(b.lastPeri)) b.meas = ((Math.abs(ph - b.lastPeri) - TAU) * 180) / Math.PI;        b.lastPeri = ph; b.peri.push(ph); if (b.peri.length > 12) b.peri.shift();      }      b.rmin = b.r; b.rmax = b.r;    }    trail(b);  }  return 0;}

画面に並ぶ天体は、遠くの時計でそろえて進める。advanceは、天体ごとにを足しながら、が目標の時刻に届くまで刻みを重ねる。地平線のそばではが大きくなるので、遠くの時計で見ると天体はほとんど進まなくなる。刻みは円軌道の1周の約500分の1にした。近点8M・遠点24Mの軌道を30周させても、近点の移動は式から求めた151.8°と0.01°も違わない。

軌道の形を式で求める

近点と遠点は、をの3次式に直し、その解を二分法で探して求める。近点の移動は、近点から遠点まで回る角を積分して出す。端で分母が0になる積分だが、変数をに置きかえると発散が消え、シンプソンの公式で計算できる。

軌道の形(src/sims/black-hole.ts の 135〜175 行)
/** 軌道の形を、E と L だけから式で求める。E² = V²(r) を u = 1/r で書くと 2L²u³ − L²u² + 2u − (1 − E²) = 0 の 3 次式になり、 *  その解が遠点・近点・山の内側の点。L² < 12(L < √12 M)だと V² に山も谷も無く、天体は地平線へ落ちる */function classify(E: number, L: number, r: number, pr: number): Info {  const L2 = L * L, E2 = E * E;  const f = (u: number) => E2 - (1 - 2 * u) * (1 + L2 * u * u);  const root = (a: number, b: number) => { let fa = f(a); for (let k = 0; k < 60; k++) { const m = 0.5 * (a + b), fm = f(m); if (fm > 0 === fa > 0) { a = m; fa = fm; } else b = m; } return 0.5 * (a + b); };  const out: Info = { kind: "plunge", rp: NaN, ra: NaN, prec: NaN, wall: false };  const peak = L2 > 12, um = peak ? (1 + Math.sqrt(1 - 12 / L2)) / 6 : 0.5;  out.wall = peak && f(um) < 0; // ポテンシャルの山が、天体のエネルギーより高い  const u = 1 / r;  if (u >= um || !out.wall) { if (pr >= 0 && E >= 1 && !(out.wall && u >= um)) out.kind = "escape"; return out; }  if (E >= 1) { out.kind = "escape"; out.rp = 1 / root(Math.min(u, um * 0.999), um); return out; }  const ul = (1 - Math.sqrt(1 - 12 / L2)) / 6; // 谷の底(安定な円軌道の u)。山の頂上は um(不安定な円軌道)  if (f(ul) <= 1e-12) { out.kind = "circle"; out.rp = out.ra = 1 / ul; out.prec = (TAU / Math.sqrt(1 - 6 * ul) - TAU) * 180 / Math.PI; return out; }  const ua = root(0, ul), up = root(ul, um);  out.rp = 1 / up; out.ra = 1 / ua;  out.kind = (out.ra - out.rp) / (out.ra + out.rp) < 0.004 ? "circle" : "bound";  // 近点から遠点まで回る角: dφ/du = 1/√(2(u − ua)(up − u)(u3 − u))。u = ua + (up − ua)(1 − cosθ)/2 と置くと端の発散が消え、  // θ で 0〜π を積分するだけになる(シンプソンの公式・48 区間)。往復で 2 倍し、360° を引いたものが 1 周の近点の移動  const u3 = 0.5 - ua - up, N = 48;  let sum = 0;  for (let k = 0; k <= N; k++) { const th = (Math.PI * k) / N, uu = ua + ((up - ua) * (1 - Math.cos(th))) / 2; sum += (k === 0 || k === N ? 1 : k % 2 ? 4 : 2) / Math.sqrt(2 * (u3 - uu)); }  out.prec = (((2 * sum * Math.PI) / (3 * N) - TAU) * 180) / Math.PI;  return out;}/** これからの道筋(固有時で進める)。maxAng は回る角の上限 */function predict(E: number, L: number, r: number, pr: number, phi: number, out: Float32Array, rLim: number, maxAng: number) {  const o: Orb = { r, pr, phi, E, L, tau: 0, t: 0 };  let n = 0, lx = r * Math.cos(phi), ly = r * Math.sin(phi);  out[0] = lx; out[1] = ly; n = 1;  const cap = out.length >> 1;  for (let k = 0; k < 5000 && n < cap; k++) {    rk4(o, hOf(o.r) * 1.25);    const end = !(o.r > 2.02) || o.r > rLim || Math.abs(o.phi - phi) > maxAng || o.t > 6000;    const rr = Math.max(o.r, 2), x = rr * Math.cos(o.phi), y = rr * Math.sin(o.phi);    if (end || Math.hypot(x - lx, y - ly) > 0.2 + 0.012 * rr) { out[2 * n] = x; out[2 * n + 1] = y; n++; lx = x; ly = y; }    if (end) break;  }  return n;}

光を画素ごとにさかのぼる

光の道筋は、カメラ・ブラックホール・視線の3つを含む平面から出ない。そこで画素ごとにその平面を作り、平面の中で光子の軌道の式を4次のルンゲ・クッタ法で解く。始めの傾きは、カメラが止まった観測者として見る向きから決める。

光の追跡(src/sims/black-hole.ts の 241〜395 行)
// ---- カメラ(止まった観測者)。i = 傾き(0 で真上・90° で真横)、camPhi = 赤道面の中のカメラの向き// 座標: 赤道面が x-z 面、円盤の軸が y。C = カメラの位置、F = 見る向き(ブラックホールの中心へ)、U = 画面の上、R = 画面の右function cam(api: Api, s: S) {  const i = (clamp(api.num("incl"), 0.5, 89.5) * Math.PI) / 180, D = api.num("dist"), f = s.camPhi;  const si = Math.sin(i), ci = Math.cos(i), cf = Math.cos(f), sf = Math.sin(f);  const C: Vec = [D * si * cf, D * ci, -D * si * sf], F: Vec = [-si * cf, -ci, si * sf], U: Vec = [-ci * cf, si, ci * sf];  return { C, F, U, R: cross(F, U), D };}/** 画角: 画面の横幅の半分に当たる tan。横長なら縦の半分を 0.4(約 22°)、縦長なら横の半分を 0.5 前後にして、どの形の画面でも真ん中に収める */function tanOf(api: Api) {  const a = api.w / Math.max(1, api.h);  return a >= 1 ? 0.4 * a : 0.4 + 0.1 * clamp((1 - a) / 0.4, 0, 1);}// 天体の見た目の半径(M)。本物の星よりずっと大きく描く(遠くから見ても見えるように距離で少し大きくする)const bodyR = (api: Api) => 0.45 + 0.008 * api.num("dist"); const FS = `#version 300 esprecision highp float;in vec2 vUv;out vec4 outColor;${DITHER}uniform vec2 uRes;uniform vec3 uCam;uniform vec3 uF;uniform vec3 uR;uniform vec3 uU;uniform float uTan, uTime, uDisk, uGrid, uH, uMax, uNB, uBR, uBRmax, uPix, uPixCss;uniform vec4 uB[12];const float RIN = 6.0;const float ROUT = 16.0;const float PI = 3.14159265; // 光子の軌道の式 u'' = 3u² − u(M = 1)void rk4(inout float u, inout float w, float h) {  float k1u = w, k1w = 3.0 * u * u - u;  float u2 = u + 0.5 * h * k1u, w2 = w + 0.5 * h * k1w;  float k2u = w2, k2w = 3.0 * u2 * u2 - u2;  float u3 = u + 0.5 * h * k2u, w3 = w + 0.5 * h * k2w;  float k3u = w3, k3w = 3.0 * u3 * u3 - u3;  float u4 = u + h * k3u, w4 = w + h * k3w;  float k4u = w4, k4w = 3.0 * u4 * u4 - u4;  u += h / 6.0 * (k1u + 2.0 * k2u + 2.0 * k3u + k4u);  w += h / 6.0 * (k1w + 2.0 * k2w + 2.0 * k3w + k4w);}// 刻みの両端の値と傾きから、途中(割合 f)の値を 3 次式で補間するfloat herm(float u0, float w0, float u1, float w1, float h, float f) {  float f2 = f * f, f3 = f2 * f;  return (2.0 * f3 - 3.0 * f2 + 1.0) * u0 + (f3 - 2.0 * f2 + f) * h * w0 + (3.0 * f2 - 2.0 * f3) * u1 + (f3 - f2) * h * w1;}// 線分 P0→P1 と球(中心 c・半径 R)の最初の交点を、線分の割合で返す(交わらなければ −1)float segSphere(vec3 P0, vec3 P1, vec3 c, float R) {  vec3 dv = P1 - P0; float lg = length(dv);  if (lg < 1e-6) return -1.0;  dv /= lg;  vec3 oc = P0 - c;  float bq = dot(oc, dv), cq = dot(oc, oc) - R * R, disc = bq * bq - cq;  if (disc < 0.0) return -1.0;  float sq = -bq - sqrt(disc);  if (sq < 0.0) sq = cq < 0.0 ? 0.0 : -1.0;  if (sq < 0.0 || sq > lg) return -1.0;  return sq / lg;}// 星空と円盤の関数(下の「星空と円盤」で定義する)float sky(vec3 d, float pw);float disk(vec3 Q, float r, float lam, float D, out float alpha);void main() {  // 1. この画素を通る視線の向き d  vec2 p = (gl_FragCoord.xy - 0.5 * uRes) / (0.5 * uRes.x);  vec3 d = normalize(uF + uTan * (p.x * uR + p.y * uU));  // 2. 光の通る平面: カメラの向き e1 と、それに直角で視線を含む向き e2。光の位置は r(e1 cosφ + e2 sinφ)  float D = length(uCam);  vec3 e1 = uCam / D;  float dr = dot(d, e1);  vec3 tv = d - dr * e1;  float dt = length(tv);  vec3 e2 = dt > 1e-6 ? tv / dt : normalize(cross(e1, vec3(0.0, 0.0, 1.0)));  dt = max(dt, 1e-6);  // 3. 始めの値: u = 1/D と、u' = −u√(1 − 2u)·cotψ(cotψ = dr/dt)。b は衝突パラメータ(光の角運動量 ÷ エネルギー)  float u = 1.0 / D;  float w = -u * sqrt(1.0 - 2.0 * u) * dr / dt;  float b = inversesqrt(w * w + u * u * (1.0 - 2.0 * u));  // λ: 光の角運動量の、円盤の軸の向きの成分(ドップラー効果に使う)。実際の光は視線と逆向きに進むので符号を返す  float lam = -b * cross(e1, e2).y;  // 赤道面からの高さは r(a1 cosφ + a2 sinφ)。この符号が変わった所で、光は赤道面を横切る  float a1 = e1.y, a2 = e2.y;  float phi = 0.0, s0 = a1, acc = 0.0, T = 1.0;  int fate = 0; // 0 = 決まらない(光子球のそばを回り続けた)・1 = 遠くへ届いた・2 = 地平線に落ちた・3 = 不透明な物に当たった  vec3 dirOut = vec3(0.0);  // 4. 角 φ を刻み h ずつ進める。遠い所(u が小さい)では刻みを 2.6 倍に広げて回数を減らす  for (int i = 0; i < 640; i++) {    if (float(i) >= uMax) break;    float h = uH * mix(2.6, 1.0, smoothstep(0.03, 0.16, u));    float u1 = u, w1 = w;    rk4(u1, w1, h);    // u が 0 を下回った = 無限遠に届いた。u = 0 になる角を直線で求め、その向きの星空を見る    if (u1 <= 0.0) {      float pf = phi + h * u / (u - u1);      dirOut = e1 * cos(pf) + e2 * sin(pf);      fate = 1;      break;    }    // この刻みの始めと終わりの位置 P0・P1(3 次元)。天体はこの線分と球が交わるかで調べる    float phi1 = phi + h, c0 = cos(phi), n0 = sin(phi), c1 = cos(phi1), n1 = sin(phi1);    float s1 = a1 * c1 + a2 * n1, r0 = 1.0 / u, r1 = 1.0 / u1;    vec3 P0 = r0 * (e1 * c0 + e2 * n0), P1 = r1 * (e1 * c1 + e2 * n1);    float fB = 2.0; int kB = -1;    if (uNB > 0.5 && min(r0, r1) < uBRmax) {      float z0 = r0 * s0, z1 = r1 * s1;      if (abs(z0) < uBR || abs(z1) < uBR || z0 * z1 < 0.0) {        for (int k = 0; k < 12; k++) {          if (float(k) >= uNB) break;          vec4 B = uB[k];          float f = segSphere(P0, P1, vec3(B.x, 0.0, -B.y), B.z);          if (f >= 0.0 && f < fB) { fB = f; kB = k; }        }      }    }    // 赤道面を横切ったか。横切った位置の半径 rc は、刻みの両端の u と u' からエルミート補間で求める    float fD = s0 * s1 < 0.0 ? s0 / (s0 - s1) : 2.0;    if (fD < fB) {      float rc = 1.0 / herm(u, w, u1, w1, h, fD), pc = phi + h * fD;      vec3 Q = rc * (e1 * cos(pc) + e2 * sin(pc));      if (uDisk > 0.5 && rc > RIN && rc < ROUT) { float al; float v = disk(Q, rc, lam, D, al); acc += T * al * v; T *= 1.0 - al; }      // 目印: 赤道面の 6M(ISCO)の円と、3M(光子球)の破線の円      if (uGrid > 0.5) {        float wm = max(length(Q - uCam) * uPix * 1.1, 0.02);        float ang = atan(-Q.z, Q.x);        float dash = step(0.4, fract(ang * 36.0 / (2.0 * PI)));        float ring = max(1.0 - smoothstep(0.5 * wm, wm, abs(rc - 6.0)), (1.0 - smoothstep(0.5 * wm, wm, abs(rc - 3.0))) * dash) * 0.95;        acc += T * ring; T *= 1.0 - ring;      }      if (T < 0.03) { fate = 3; break; }    }    // 天体に当たった: 中心のほうから照らされた球として濃淡を付け、縁取りをする(選んでいる天体は縞模様)    if (kB >= 0) {      vec4 B = uB[kB];      vec3 c = vec3(B.x, 0.0, -B.y), Hp = mix(P0, P1, fB), N = normalize(Hp - c), rd = normalize(P1 - P0);      float lit = max(dot(N, normalize(-c)), 0.0);      float rim = 1.0 - abs(dot(N, rd));      float vb = 0.08 + 0.92 * pow(lit, 0.7);      if (B.w > 0.5) vb = max(vb * 0.7, step(0.5, fract((N.y * 0.5 + 0.5) * 5.0)) * 0.95);      vb = max(vb, smoothstep(0.8, 0.96, rim));      acc += T * vb; T = 0.0; fate = 3;      break;    }    if (u1 > 0.5) { fate = 2; break; }    u = u1; w = w1; phi = phi1; s0 = s1;  }  // 5. 遠くへ届いた光は、まだ透けている分(T)だけ星空を足す。pw は隣の画素との向きの差(方眼の線の太さに使う)  float pw = length(fwidth(dirOut));  float v = acc;  if (fate == 1 && T > 0.02) v += T * sky(dirOut, pw);  // 6. 0〜1 の濃さを、白と黒の網点にする  outColor = vec4(mono(clamp(v, 0.0, 1.0), gl_FragCoord.xy), 1.0);}

1歩進むごとに、3つのことを調べる。が0を下回れば無限遠に届いたので星空を見る。が0.5を超えれば地平線に落ちたので黒にする。赤道面からの高さの符号が変われば、光は円盤の面を横切ったので、横切った半径をエルミート補間で求め、円盤の明るさを足す。天体は、1歩の線分と球が交わるかで調べている。遠い所では刻みを2.6倍に広げ、光子球のそばを回り続ける光は回数の上限で打ち切る。

星空と円盤の明るさ

星は、向きを細かな3次元の格子に分け、ます目ごとに乱数で置く。方眼の線の太さは、隣の画素との向きの差から決めているので、レンズで引き伸ばされた所でも線がつぶれにくい。

星空と円盤(src/sims/black-hole.ts の 397〜459 行)
float h12(vec2 p) { vec3 q = fract(vec3(p.xyx) * 0.1031); q += dot(q, q.yzx + 33.33); return fract((q.x + q.y) * q.z); }float h13(vec3 p) { p = fract(p * 0.1031); p += dot(p, p.zyx + 31.32); return fract((p.x + p.y) * p.z); }vec3 h33(vec3 p) { p = fract(p * vec3(0.1031, 0.1030, 0.0973)); p += dot(p, p.yxz + 33.33); return fract((p.xxy + p.yxx) * p.zyx); }// なめらかな雲のような模様(格子点ごとに向きをばらばらにした勾配のノイズ)。0〜1 のあたりの値vec2 g2(vec2 p) { float a = h12(p) * 6.2831853; return vec2(cos(a), sin(a)); }float gn(vec2 p) {  vec2 i = floor(p); vec2 f = fract(p); vec2 w = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);  float a = dot(g2(i), f), b = dot(g2(i + vec2(1.0, 0.0)), f - vec2(1.0, 0.0));  float c = dot(g2(i + vec2(0.0, 1.0)), f - vec2(0.0, 1.0)), d = dot(g2(i + vec2(1.0, 1.0)), f - vec2(1.0, 1.0));  return 0.5 + 0.75 * mix(mix(a, b, w.x), mix(c, d, w.x), w.y);}// 星: 向き d を N 倍した 3 次元の格子のます目ごとに、確率 prob で星を 1 つ置く(近くの 8 ますを調べる)float stars(vec3 d, float N, float prob, float big) {  vec3 q = d * N, b0 = floor(q - 0.5);  float v = 0.0;  for (int k = 0; k < 8; k++) {    vec3 c = b0 + vec3(float(k & 1), float((k >> 1) & 1), float((k >> 2) & 1));    float h = h13(c);    if (h > prob) continue;    vec3 sp = normalize(c + 0.2 + 0.6 * h33(c));    float ang = length(d - sp), m = h / prob, br = pow(1.0 - m, 6.0);    float rad = max(uPixCss * (0.7 + big * br), uPix * 0.7);    float core = 1.0 - smoothstep(0.4 * rad, rad, ang);    float halo = (1.0 - smoothstep(rad, 4.5 * rad, ang)) * 0.2 * big * br;    v = max(v, max(core * (0.5 + 0.5 * (1.0 - m)), halo));  }  return v;}// 天球: 3 段の大きさの星と、15° ごとの方眼(赤道は太く)float sky(vec3 d, float pw) {  float v = max(max(stars(d, 7.0, 0.32, 2.4), stars(d, 19.0, 0.5, 0.5)), stars(d, 34.0, 0.35, 0.0) * 0.6);  if (uGrid > 0.5) {    float lat = asin(clamp(d.y, -1.0, 1.0)), lon = atan(d.z, d.x), st = PI / 12.0;    // 経線と緯線を 15° ごとに。線の太さは 1 画素ぶんの角の大きさ pw に合わせる(レンズで引き伸ばされた所は太くなりすぎないように上限)    float w = clamp(pw, uPix * 0.8, uPix * 2.5);    float dl = abs(fract(lat / st + 0.5) - 0.5) * st;    float dn = (abs(fract(lon / st + 0.5) - 0.5) * st * cos(lat)) / max(0.001, smoothstep(1.35, 1.1, abs(lat)));    float line = 1.0 - smoothstep(0.35 * w, w, min(dl, dn));    float eq = 1.0 - smoothstep(0.6 * w, 1.3 * w, abs(lat));    v = max(v, max(0.5 * line, 0.92 * eq));  }  return v;}// 円盤の明るさ(0〜1 の濃さ)と不透明さ alpha。放射 F に g⁴ を掛ける(g: 重力とドップラー効果による光の振動数の変化)float disk(vec3 Q, float r, float lam, float D, out float alpha) {  float x = RIN / r;  float F = (1.0 - sqrt(x)) * x * x * x / 0.05665; // r = 8.2M で最大 1 になるように割る  float Om = pow(r, -1.5);  float g = sqrt(1.0 - 3.0 / r) / (sqrt(1.0 - 2.0 / D) * (1.0 - Om * lam));  float g2 = g * g, I = F * g2 * g2;  // 模様: 半径 8% ごとの輪に分け、輪ごとにその半径の角速度 Ω で回す(内側ほど速い)  float kb = floor(log(r / RIN) / log(1.08));  float r0 = RIN * pow(1.08, kb), r1 = r0 * 1.08, rb = sqrt(r0 * r1), fb = (r - r0) / (r1 - r0);  float ph = atan(-Q.z, Q.x) - pow(rb, -1.5) * uTime;  vec2 q = vec2(cos(ph), sin(ph)) * rb * 1.2 + vec2(kb * 7.31, kb * 3.17);  float cl = clamp(0.5 * gn(q) + 0.32 * gn(q * 2.3 + 5.2) + 0.18 * gn(q * 5.3 + 1.7), 0.0, 1.0);  float groove = 0.5 + 0.5 * cos(fb * 4.0 * PI);  float edge = smoothstep(0.0, 0.07, fb) * smoothstep(1.0, 0.93, fb);  float outer = smoothstep(ROUT, ROUT - 4.0, r) * smoothstep(RIN, RIN + 0.2, r);  alpha = clamp((0.45 + 0.55 * smoothstep(0.25, 0.7, cl)) * mix(0.5, 1.0, edge) * outer, 0.0, 1.0);  float tex = (0.45 + 0.8 * cl) * (0.82 + 0.18 * groove);  return 1.0 - exp(-1.25 * I * tex); // 明るさを 0〜1 の濃さに(明るい所が飽和しすぎないように)}

円盤の明るさは、薄い円盤の放射にを掛けたものだ。(カメラの位置の補正を除く)の分子が重力と円運動による時間の遅れ、分母がドップラー効果を表す。は光の角運動量で、光が赤道面を横切っても変わらないので、画素ごとに最初に1回だけ計算すればよい。

真上から見た図とグラフ

右下の図は、カメラのいる側がいつも下に来るように回している。大きな画面の左右と、図の左右がそろう。左上のグラフは、選んだ天体ので本文のと、3項目を除いたニュートンの場合を並べて描く。

図と文字(src/sims/black-hole.ts の 490〜692 行)
/** 画面の配置: 真ん中はブラックホール。右下に真上から見た図、左上に有効ポテンシャルのグラフ(どの縦横比でも角に置く) */function layout(api: Api) {  const m = clamp(api.u(12), 8, 18);  const size = Math.round(clamp(Math.min(api.w, api.h) * 0.36, 110, 320));  const s2 = Math.min(size, Math.round(api.w * 0.46));  const map: Box = { x: api.w - s2 - m, y: api.h - s2 - m, w: s2, h: s2 };  const gw = Math.round(clamp(Math.min(api.w * 0.3, api.w - s2 - 3 * m), 150, 380));  const graph: Box = { x: m, y: m, w: gw, h: Math.round(clamp(gw * 0.56, 96, 210)) };  return { map, graph };}function project(api: Api, c: ReturnType<typeof cam>, P: Vec): [number, number] | null {  const d: Vec = [P[0] - c.C[0], P[1] - c.C[1], P[2] - c.C[2]], z = dot(d, c.F);  if (z <= 0) return null;  const t = tanOf(api);  return [api.w / 2 + ((dot(d, c.R) / z / t) * api.w) / 2, api.h / 2 - ((dot(d, c.U) / z / t) * api.w) / 2];} /** 文字の札が、右下の図や左上のグラフに重ならないときだけ描く(幅は全角 1 字 = 文字の大きさ、半角はその 0.55 倍で見積もる) */function labelIfFree(api: Api, s: S, t: string, x: number, y: number, size: number, align: "left" | "center" | "right") {  let w = 0;  for (const ch of t) w += ch.charCodeAt(0) < 0x2000 ? size * 0.55 : size;  const x0 = align === "center" ? x - w / 2 : align === "right" ? x - w : x, y0 = y - size, pad = size * 0.4;  for (const B of [s.map, s.graph]) if (B && x0 - pad < B.x + B.w && x0 + w + pad > B.x && y0 - pad < B.y + B.h && y + pad > B.y) return;  if (x0 < 0 || x0 + w > api.w) return;  api.label(t, x, y, { size, align });}function drawMain(api: Api, s: S) {  const c = cam(api, s), f1 = fs(api, 10.5), cx = api.w / 2, cy = api.h / 2;  if (api.bool("grid")) {    // 影の縁(b = √27 M の光が見える向き)と、光がまっすぐ進むとしたときの地平線の見かけの大きさ    const D = c.D, t = tanOf(api), as = Math.asin(Math.min(1, (Math.sqrt(27) * Math.sqrt(1 - 2 / D)) / D)), ah = Math.asin(2 / D);    const rs = ((Math.tan(as) / t) * api.w) / 2, rh = ((Math.tan(ah) / t) * api.w) / 2;    api.circle(cx, cy, rs, { w: api.u(1), dash: [api.u(5), api.u(4)] });    api.circle(cx, cy, rh, { w: api.u(1), dash: [api.u(1.5), api.u(3)] });    api.line(cx, cy - rs, cx, cy - rs - api.u(14), { w: api.u(0.8) });    labelIfFree(api, s, "影の縁  b = √27 M", cx, cy - rs - api.u(14) - f1 * 0.4, f1, "center");    labelIfFree(api, s, "光が曲がらない場合の地平線", cx, cy + rh + f1 * 1.5, f1, "center");  }  if (api.bool("disk") && api.num("incl") >= 50) {    // 円盤の左右の端(画面の右 = R の向き)。左が近づく側(ドップラー効果で明るい)    const rr = ROUT + 1.2, Rp: Vec = [c.R[0], 0, c.R[2]], l = Math.hypot(Rp[0], Rp[2]) || 1;    const L = project(api, c, [(-Rp[0] / l) * rr, 0, (-Rp[2] / l) * rr]), Rr = project(api, c, [(Rp[0] / l) * rr, 0, (Rp[2] / l) * rr]);    if (L) labelIfFree(api, s, "近づく側", L[0], L[1] + f1 * 0.35, f1, "right");    if (Rr) labelIfFree(api, s, "遠ざかる側", Rr[0], Rr[1] + f1 * 0.35, f1, "left");  }} function drawMap(api: Api, s: S, B: Box) {  const ctx = api.ctx, f1 = fs(api, 9.5), pad = api.u(10);  const k = (B.w / 2 - pad) / s.mapR, cx = B.x + B.w / 2, cy = B.y + B.h / 2 + f1 * 0.5;  const cp = Math.cos(s.camPhi), sp = Math.sin(s.camPhi);  s.mt = { cx, cy, k, cp, sp };  const P = (x: number, y: number): [number, number] => [cx + k * (-x * sp + y * cp), cy + k * (x * cp + y * sp)];  api.rect(B.x, B.y, B.w, B.h, { fill: "paper", w: api.u(1) });  ctx.save(); ctx.beginPath(); ctx.rect(B.x, B.y, B.w, B.h); ctx.clip();  if (api.bool("disk")) api.circle(cx, cy, ROUT * k, { w: api.u(0.6), dash: [api.u(1), api.u(3)] });  api.circle(cx, cy, 6 * k, { w: api.u(1), dash: [api.u(4), api.u(3)] });  api.circle(cx, cy, 3 * k, { w: api.u(0.9), dash: [api.u(1.2), api.u(2.2)] });  const hz: [number, number][] = [];  for (let i = 0; i < 28; i++) hz.push([cx + 2 * k * Math.cos((i / 28) * TAU), cy + 2 * k * Math.sin((i / 28) * TAU)]);  api.hatch(hz, { gap: api.u(2.4), w: api.u(0.7) }); api.circle(cx, cy, 2 * k, { w: api.u(1.2) });  const sel = s.sel;  // 選んだ天体のこれから(点線)と、同じ近点・遠点のニュートンの楕円  if (sel && sel.gn > 1) {    const pts: [number, number][] = [];    for (let i = 0; i < sel.gn; i++) pts.push(P(sel.guide[2 * i], sel.guide[2 * i + 1]));    api.poly(pts, { w: api.u(0.9), dash: [api.u(1), api.u(3)] });  }  const inf = sel?.info;  if (sel && inf && inf.kind === "bound" && Number.isFinite(sel.lastPeri)) {    const a = (inf.rp + inf.ra) / 2, e = (inf.ra - inf.rp) / (inf.ra + inf.rp), w0 = sel.lastPeri, pts: [number, number][] = [];    if (e > 0.08) {      for (let i = 0; i <= 120; i++) { const th = (i / 120) * TAU, rr = (a * (1 - e * e)) / (1 + e * Math.cos(th)); pts.push(P(rr * Math.cos(th + w0), rr * Math.sin(th + w0))); }      api.poly(pts, { w: api.u(0.7), dash: [api.u(5), api.u(2), api.u(1), api.u(2)] });    }  }  // 軌跡(古いほど細く)  for (const b of s.bodies) {    const n = b.tn;    if (n < 2) continue;    const start = (b.ti - n + TRAIL) % TRAIL, seg = 5, wm = api.u(b === sel ? 1.7 : 1.1);    ctx.strokeStyle = api.ink; ctx.setLineDash([]); ctx.lineJoin = "round";    for (let g = 0; g < seg; g++) {      const k0 = Math.floor((g * (n - 1)) / seg), k1 = Math.floor(((g + 1) * (n - 1)) / seg);      ctx.beginPath();      for (let kk = k0; kk <= k1; kk++) { const i = (start + kk) % TRAIL, [X, Y] = P(b.tx[i], b.ty[i]); if (kk === k0) ctx.moveTo(X, Y); else ctx.lineTo(X, Y); }      if (g === seg - 1) { const [X, Y] = P(b.r * Math.cos(b.phi), b.r * Math.sin(b.phi)); ctx.lineTo(X, Y); }      ctx.lineWidth = wm * (0.2 + (0.8 * (g + 1)) / seg); ctx.stroke();    }  }  // 近点の印  if (sel) for (let i = 0; i < sel.peri.length; i++) {    const ph = sel.peri[i], rp = sel.info?.rp ?? sel.rmin, r0 = Number.isFinite(rp) ? rp : sel.r;    const [X0, Y0] = P((r0 - 1.4) * Math.cos(ph), (r0 - 1.4) * Math.sin(ph)), [X1, Y1] = P((r0 + 1.4) * Math.cos(ph), (r0 + 1.4) * Math.sin(ph));    api.line(X0, Y0, X1, Y1, { w: api.u(i === sel.peri.length - 1 ? 1.6 : 0.8) });  }  // 天体  for (const b of s.bodies) {    const [X, Y] = P(b.r * Math.cos(b.phi), b.r * Math.sin(b.phi));    api.circle(X, Y, api.u(3.2), { fill: "ink", stroke: false });    if (b === sel) { api.circle(X, Y, api.u(7), { w: api.u(1), dash: [api.u(2), api.u(2.5)] }); api.label(`天体 ${b.id}`, X + api.u(9), Y - api.u(6), { size: f1 }); }    else api.text(String(b.id), X + api.u(5), Y - api.u(4), { size: f1 * 0.9, font: "mono" });  }  // 投げる前の矢印と予測  const d = s.drag;  if (d && d.mode === "throw") {    const [X0, Y0] = P(d.px, d.py);    if (d.moved && d.pn > 1) {      const pts: [number, number][] = [];      for (let i = 0; i < d.pn; i++) pts.push(P(d.path[2 * i], d.path[2 * i + 1]));      api.poly(pts, { w: api.u(1.2), dash: [api.u(2), api.u(3)] });    }    api.circle(X0, Y0, api.u(4), { fill: "ink", stroke: false });    if (d.moved) {      api.arrow(X0, Y0, api.pointer.x, api.pointer.y, { w: api.u(1.4), head: api.u(7) });      const kind = !d.info ? "" : d.snap ? "円軌道" : d.info.kind === "plunge" ? "落ちる" : d.info.kind === "escape" ? "飛び去る" : d.info.kind === "circle" ? "円に近い" : "回り続ける";      const tx = clamp(api.pointer.x + api.u(8), B.x + api.u(4), B.x + B.w - api.u(70));      const ty = clamp(api.pointer.y - api.u(8), B.y + f1 * 1.4, B.y + B.h - api.u(6));      api.label(`${fmt(d.v, 2)} c  ${kind}`, tx, ty, { size: f1, font: "mono" });    }  } else if (api.pointer.inside && !api.pointer.down && api.pointer.x >= B.x && api.pointer.x <= B.x + B.w && api.pointer.y >= B.y && api.pointer.y <= B.y + B.h) {    api.circle(api.pointer.x, api.pointer.y, api.u(9), { w: api.u(1), dash: [api.u(2), api.u(2.5)] });  }  ctx.restore();  // 視点の印(下が見ている側)・凡例・題  const ex = cx, ey = B.y + B.h - api.u(5);  api.poly([[ex, ey - api.u(8)], [ex - api.u(5), ey], [ex + api.u(5), ey]], { close: true, fill: "ink", stroke: false });  api.text("視点", ex + api.u(8), ey - api.u(1), { size: f1 * 0.9 });  const lx = B.x + api.u(7);  let ly = B.y + f1 * 1.3;  api.label(`真上から  半径 ${fmt(s.mapR, 0)} M`, lx, ly, { size: f1, pad: 1 });  const leg: [string, number[]][] = [["2M 地平線", []], ["3M 光子球", [api.u(1.2), api.u(2.2)]], ["6M ISCO", [api.u(4), api.u(3)]]];  for (const [t, dash] of leg) { ly += f1 * 1.25; api.line(lx, ly - f1 * 0.32, lx + api.u(14), ly - f1 * 0.32, { w: api.u(1), dash }); api.label(t, lx + api.u(18), ly, { size: f1 * 0.92, pad: 1 }); }  api.label("この図をドラッグすると天体を投げられます", B.x + B.w, B.y - api.u(7), { size: f1, align: "right" });} function drawGraph(api: Api, s: S, G: Box) {  const ctx = api.ctx, f1 = fs(api, 9.5), b = s.sel;  api.rect(G.x, G.y, G.w, G.h, { fill: "paper", w: api.u(1) });  api.text(b ? `有効ポテンシャル V(r)  天体 ${b.id}  L = ${fmt(Math.abs(b.L), 2)} M` : "有効ポテンシャル V(r)", G.x + api.u(7), G.y + f1 * 1.35, { size: f1 });  if (!b) { api.text("天体を選ぶと出ます", G.x + G.w / 2, G.y + G.h / 2 + f1 * 0.4, { size: f1, align: "center" }); return; }  const L2 = b.L * b.L, En = (b.E * b.E - 1) / 2, inf = b.info;  const V = (r: number) => -1 / r + L2 / (2 * r * r) - L2 / (r * r * r), VN = (r: number) => -1 / r + L2 / (2 * r * r);  const r1 = clamp(Math.max(24, (inf && Number.isFinite(inf.ra) ? inf.ra : b.r) * 1.3), 24, 90);  let lo = En;  for (let k = 0; k <= 60; k++) { const r = 4 + ((r1 - 4) * k) / 60; lo = Math.min(lo, V(r), VN(r)); }  const span = Math.max(0.02, Math.max(En, 0) - lo), hi = Math.max(En, 0) + 0.55 * span;  lo -= 0.3 * span;  const x0 = G.x + api.u(10), x1 = G.x + G.w - api.u(10), y0 = G.y + f1 * 2, y1 = G.y + G.h - f1 * 1.9;  const X = (r: number) => x0 + ((r - 2) / (r1 - 2)) * (x1 - x0), Y = (v: number) => y0 + ((hi - v) / (hi - lo)) * (y1 - y0);  ctx.save(); ctx.beginPath(); ctx.rect(x0, y0, x1 - x0, y1 - y0); ctx.clip();  const curve = (f: (r: number) => number, o: { w: number; dash?: number[] }) => {    const pts: [number, number][] = [];    for (let k = 0; k <= 160; k++) { const r = 2 + ((r1 - 2) * k) / 160, v = f(r); pts.push([X(r), clamp(Y(v), y0 - 400, y1 + 400)]); }    api.poly(pts, o);  };  api.line(x0, Y(0), x1, Y(0), { w: api.u(0.5), dash: [api.u(1), api.u(3)] });  api.line(X(6), y0, X(6), y1, { w: api.u(0.5), dash: [api.u(1), api.u(3)] });  curve(VN, { w: api.u(1), dash: [api.u(4), api.u(3)] });  curve(V, { w: api.u(1.8) });  api.line(x0, Y(En), x1, Y(En), { w: api.u(0.8) });  if (inf && inf.kind === "bound") api.line(X(inf.rp), Y(En), X(Math.min(inf.ra, r1)), Y(En), { w: api.u(2.6) });  api.circle(X(Math.min(b.r, r1)), Y(En), api.u(3.6), { fill: "ink", stroke: false });  if (L2 > 12) {    const um = (1 + Math.sqrt(1 - 12 / L2)) / 6, ul = (1 - Math.sqrt(1 - 12 / L2)) / 6;    const yp = Y(V(1 / um)), yv = Y(V(1 / ul));    if (yp > y0 + f1) api.text("山", X(1 / um), yp - api.u(5), { size: f1, align: "center" });    if (1 / ul < r1 && yv < y1 - f1 * 0.2) api.text("谷", X(1 / ul), yv + f1 * 1.1, { size: f1, align: "center" });  }  ctx.restore();  for (const r of [2, 6, 12, 24, 48]) if (r <= r1) {    api.line(X(r), y1, X(r), y1 + api.u(4), { w: api.u(0.8) });    api.text(`${r}M`, X(r), y1 + f1 * 1.15, { size: f1 * 0.9, font: "mono", align: "center" });  }  api.line(x0, y1, x1, y1, { w: api.u(0.8) });  const lgx = x1 - api.u(126), lgy = y0 + f1 * 0.9;  api.line(lgx, lgy - f1 * 0.3, lgx + api.u(14), lgy - f1 * 0.3, { w: api.u(1.8) }); api.text("相対論", lgx + api.u(18), lgy, { size: f1 * 0.9 });  api.line(lgx + api.u(62), lgy - f1 * 0.3, lgx + api.u(76), lgy - f1 * 0.3, { w: api.u(1), dash: [api.u(4), api.u(3)] }); api.text("ニュートン", lgx + api.u(80), lgy, { size: f1 * 0.9 });  api.label("天体のエネルギー", x0 + api.u(18), clamp(Y(En) - api.u(5), y0 + f1, y1 - api.u(2)), { size: f1 * 0.9 });} function readout(api: Api, s: S) {  const M = MASS[api.str("mass")] ?? MASS.sgr, b = s.sel, lines: string[] = [];  lines.push(`遠くの時計  t = ${num(s.time, 0)} M(${M.label}なら ${realTime(s.time * M.m * SEC)})`);  if (b) {    const lapse = 1 - 2 / b.r, vphi = (b.L * Math.sqrt(lapse)) / (b.E * b.r), vr = b.pr / b.E, v = Math.min(0.9999, Math.hypot(vr, vphi));    lines.push(`天体 ${b.id}  r = ${fmt(b.r, 1)} M(${realLen(b.r * M.m * KM)})  速さ ${fmt(v, 2)} c  時計の進み ${fmt(lapse / b.E, 2)} 倍`);    const o = b.info;    if (!o) lines.push("軌道  -");    else if (o.kind === "circle") lines.push(`軌道  円(1 周 ${num(TAU * Math.pow(b.r, 1.5), 0)} M = ${realTime(TAU * Math.pow(b.r, 1.5) * M.m * SEC)})`);    else if (o.kind === "bound") lines.push(`軌道  近点 ${fmt(o.rp, 1)} M・遠点 ${fmt(o.ra, 1)} M  近点の移動 1 周で ${fmt(o.prec, 0)}°${Number.isFinite(b.meas) ? `(計算では ${fmt(b.meas, 0)}°)` : ""}`);    else if (o.kind === "escape") lines.push("軌道  重力を振り切って遠くへ飛び去る");    else lines.push(Math.abs(b.L) < Math.sqrt(12) ? `軌道  地平線へ落ちる(L が √12 M より小さく、ポテンシャルに谷が無い)` : "軌道  地平線へ落ちる(ポテンシャルの山を越えるか、山の内側にいる)");  } else lines.push("天体  なし(右下の図をドラッグすると投げられます)");  const D = api.num("dist");  lines.push(`見ている所  傾き ${fmt(api.num("incl"), 0)}°  距離 ${fmt(D, 0)} M(${realLen(D * M.m * KM)})`);  if (api.bool("grid")) {    const as = Math.asin(Math.min(1, (Math.sqrt(27) * Math.sqrt(1 - 2 / D)) / D)), ah = Math.asin(2 / D);    lines.push(`影の見かけの半径  ${fmt((as * 180) / Math.PI, 1)}°(光がまっすぐ進むときの地平線の ${fmt(as / ah, 2)} 倍)`);  }  api.readout(lines);}

天体を投げる

ドラッグの長さを、その場所に止まった観測者が測る速さ(光速に対する割合)に直す。そこから、を求めると、あとの動きはすべて決まる。である。投げた速さが円軌道の速さに近いときは、ちょうど円軌道になるように合わせている。

操作(src/sims/black-hole.ts の 694〜765 行)
/** 地図の上の点(画面の px)→ 赤道面の座標(M) */function fromMap(s: S, X: number, Y: number): [number, number] {  const t = s.mt!; const a = (X - t.cx) / t.k, b = (Y - t.cy) / t.k;  return [-a * t.sp + b * t.cp, a * t.cp + b * t.sp];}const inBox = (B: Box | null, x: number, y: number) => !!B && x >= B.x && x <= B.x + B.w && y >= B.y && y <= B.y + B.h; function throwVel(api: Api, s: S, d: Drag) {  const B = s.map!, [qx, qy] = fromMap(s, api.pointer.x, api.pointer.y);  let dx = qx - d.px, dy = qy - d.py;  const lenPx = Math.hypot(api.pointer.x - api.pointer.x0, api.pointer.y - api.pointer.y0);  const v = Math.min(0.97, lenPx / (0.42 * B.w)), l = Math.hypot(dx, dy) || 1;  dx /= l; dy /= l;  const r = Math.hypot(d.px, d.py), ux = d.px / r, uy = d.py / r;  let vr = v * (dx * ux + dy * uy), vt = v * (-dx * uy + dy * ux);  d.snap = false;  if (r > 3.3) {    const vc = 1 / Math.sqrt(r - 2);    if (Math.abs(vr) < 0.08 * vc && Math.abs(Math.abs(vt) - vc) < 0.06 * vc) { vr = 0; vt = Math.sign(vt) * vc; d.snap = true; }  }  d.vr = vr; d.vt = vt; d.v = Math.hypot(vr, vt);} /** 指の操作: 右下の図の上なら天体を投げる(たたくと選ぶ)、ほかの所なら見る向きを変える */function onPointer(type: "down" | "move" | "up" | "cancel" | "wheel", api: Api, s: S) {  const P = api.pointer;  if (type === "down") {    if (inBox(s.map, P.x, P.y) && s.mt) {      const [px, py] = fromMap(s, P.x, P.y);      s.drag = { mode: "throw", lx: P.x, ly: P.y, moved: false, px, py, vr: 0, vt: 0, v: 0, snap: false, path: new Float32Array(2400), pn: 0, info: null };    } else s.drag = { mode: "cam", lx: P.x, ly: P.y, moved: false, px: 0, py: 0, vr: 0, vt: 0, v: 0, snap: false, path: new Float32Array(2), pn: 0, info: null };    return;  }  const d = s.drag;  if (!d) return;  if (type === "move") {    if (Math.hypot(P.x - P.x0, P.y - P.y0) > 5) d.moved = true;    if (d.mode === "cam") {      const dx = P.x - d.lx, dy = P.y - d.ly;      d.lx = P.x; d.ly = P.y;      s.camPhi -= dx * 0.008;      api.set("incl", Math.round(clamp(api.num("incl") - dy * 0.35, 0, 90)));    } else if (d.moved) {      throwVel(api, s, d);      const r = Math.hypot(d.px, d.py), phi = Math.atan2(d.py, d.px), tmp = local(s, Math.max(r, 2.3), phi, d.vr, d.vt);      s.nextId--;      d.info = classify(tmp.E, tmp.L, tmp.r, tmp.pr);      d.pn = predict(tmp.E, tmp.L, tmp.r, tmp.pr, phi, d.path, 3 * s.mapR, TAU * 3);    }    return;  }  s.drag = null;  if (type === "cancel" || d.mode === "cam") return;  const r = Math.hypot(d.px, d.py), phi = Math.atan2(d.py, d.px);  if (!d.moved) {    // たたいた: 近くの天体を選ぶ    let best: Body | null = null, bd = api.u(16);    const t = s.mt;    if (t) for (const b of s.bodies) {      const x = b.r * Math.cos(b.phi), y = b.r * Math.sin(b.phi);      const X = t.cx + t.k * (-x * t.sp + y * t.cp), Y = t.cy + t.k * (x * t.cp + y * t.sp), dd = Math.hypot(X - P.x, Y - P.y);      if (dd < bd) { bd = dd; best = b; }    }    if (best) { s.sel = best; best.guideAt = -1e9; }    return;  }  if (r < 2.3) { api.note("地平線に近すぎて投げられません。2.3 M より外から投げると動きます"); return; }  throwVel(api, s, d);  const b = local(s, r, phi, d.vr, d.vt);  addBody(s, b); s.sel = b; b.guideAt = -1e9;  api.emit("throw", { r: Math.round(r * 10) / 10, v: Math.round(d.v * 100) / 100 });}

光の道筋の図

本文の小さな図は、同じ光子の式を、左の遠く()から平行に来る光について解いている。始めの傾きはから厳密に決める。がに近い光は、わずかな誤差で行き先が変わるので、光子球のそばでは刻みを細かくしている。

光の道筋(src/sims/black-hole-light.ts の 8〜33 行)
const BC = Math.sqrt(27); // 影の縁になる衝突パラメータinterface Ray { pts: Float32Array; n: number; fate: "out" | "in" | "loop"; turn: number; rmin: number }/** 衝突パラメータ b の光をたどる。点は (x, y)(ブラックホールが原点、光は左から右へ) */function trace(b: number, out: Float32Array, rView: number): Ray {  const sb = Math.sign(b) || 1, ab = Math.max(Math.abs(b), 1e-6);  // 遠く(r = 1000 M)から始める。そこでの傾き du/dψ は、エネルギーの式 (du/dψ)² = 1/b² − u²(1 − 2u) から厳密に決める  let u = Math.min(0.001, 0.999 / ab), psi = Math.asin(clamp(u * ab, 0, 1)), w = Math.sqrt(Math.max(0, 1 / (ab * ab) - u * u * (1 - 2 * u)));  const f = (x: number) => 3 * x * x - x;  let n = 0, rmin = 1 / u;  const push = () => { if (2 * n + 1 < out.length) { const r = 1 / u; out[2 * n] = -r * Math.cos(psi); out[2 * n + 1] = sb * r * Math.sin(psi); n++; } };  push();  let fate: Ray["fate"] = "loop";  for (let k = 0; k < 30000; k++) {    // 中心に近いほど刻みを細かく(光子球のそばを回る光は、少しの差で行き先が変わる)    const h = u > 0.25 ? 0.002 : u > 0.08 ? 0.008 : 0.03;    const k1u = w, k1w = f(u), k2u = w + 0.5 * h * k1w, k2w = f(u + 0.5 * h * k1u);    const k3u = w + 0.5 * h * k2w, k3w = f(u + 0.5 * h * k2u), k4u = w + h * k3w, k4w = f(u + h * k3u);    u += (h / 6) * (k1u + 2 * k2u + 2 * k3u + k4u); w += (h / 6) * (k1w + 2 * k2w + 2 * k3w + k4w); psi += h;    if (u >= 0.5) { u = 0.5; push(); fate = "in"; break; }    if (u <= 1 / (3 * rView) && w < 0) { push(); fate = "out"; psi += Math.asin(clamp(u * ab, 0, 1)); break; } // 残りのまっすぐな部分の角を足す    rmin = Math.min(rmin, 1 / u);    if (k % 3 === 0 && 1 / u < 3 * rView) push();    if (psi > 7 * Math.PI) break;  }  return { pts: out, n, fate, turn: psi, rmin };}

画面の部品(描画・場面の切り替え・画質の自動調整・GPU の網点)は全記事で共通です。シミュレーションの共通の仕組みで解説しています。

プログラム全体を見る(src/sims/black-hole.ts・842 行)

読み込んでいます

プログラム全体を見る(src/sims/black-hole-light.ts・128 行)

読み込んでいます

姉妹サイト

株式会社Game が運営するメディアです。