💫

Web ページの背景を動く星空にした話 (with react-three-fiber)

2022/08/14に公開約12,600字

TL;DR

https://celestian.io/

モチベーション

ウェブページの背景に星空を見せたかった。それがちゃんと実際の星空の、リアルタイムの状況を反映していたら面白いよねと思った。

スタート時の状況

かかった時間

1.5年くらい

必要だった知識

  • 天文の基礎的な知識
  • TypeScript
  • React (NextJS)
  • react-three-fiber
    • three.js
    • WebGL
    • GLSL
  • Chakra UI

おおむねの構成

星のデータ自体は Hipparcos 星表を Python スクリプトで JSON に変換して、それを fetch() で読み込んで取得する。
読み込んだ星のデータをもとに星を天球上に配置し、配置した星を天球の中心に置いたカメラで映して星空を再現する。地球上の位置や自転はカメラを回転させることで再現している。

やったこと

先行事例の調査

要するに Stellarium みたいなことがやりたいものの、Stellarium ほどリッチじゃなくていいし、そもそも Stellarium Web が C を emscripten していて、とてもマネする気にはならなかったので 代替手段を探したところ、以下の参考資料に行き当たった。

上記で分かったことは以下の通り:

  • WebGL を使うことで普通の JavaScript でも星を描くことは可能
  • 星のデータは星表と呼ばれるデータで取得でき、ヒッパルコス星表がよく使われている[1]

技術調査

上記の事例はいずれもベタ書き JavaScript を使ったりしていたものの、Web ページの背景に使いたいんだよ!!!ってことでなんとかならんかと思って調べた結果、react-three-fiber を使うと Three.js をいい感じに扱えそうなことがわかり、React と react-three-fiber で再現を目指してみることに。

星空を描くために必要な物理的知識

概ね以下を計算する知識が必要だった。

  • 星の位置
  • 星の色
  • 星の大きさ

星の位置の計算

天球に描くために Right AscensionDeclination という座標系と、それぞれの星の位置の算出方法を知る必要があった(とはいえそんなに難しいことはなくヒッパルコス星表に書いてある通りで、数値と固有運動を計算するだけだった)。
あと、 RA/Dec が実際の地球のどこからどう見えるかの知識も必要であり、端的に言えばグリニッジ恒星時を計算する必要がある[2]

星の色の計算

ベテルギウスが赤かったり、スピカが青かったりするやつ。ヒッパルコス星表にはB-V 色指数が入っているので、ここから表面温度を算出し、xy色度図からXYZ表色系に変換、さらに RGB に読み替える ことで解決。

星の大きさの計算

星は Vmag (等級)が小さいほど明るく、大きいほど暗い。だいたい 1等級あたり 2.5 倍ずつ暗くなる。先行事例を踏襲し、星の大きさとして反映することにした。

react-three-fiber の基礎の基礎

three.js の基礎の基礎

WebGL を直接扱うのは大変なので、これをうまくラッピングしてくれるライブラリとして three.js がある。

three.js はたとえば、以下のような書き方をする

const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.1, 1000 );

const renderer = new THREE.WebGLRenderer();
renderer.setSize( window.innerWidth, window.innerHeight );
document.body.appendChild( renderer.domElement );

const geometry = new THREE.BoxGeometry( 1, 1, 1 );
const material = new THREE.MeshBasicMaterial( { color: 0x00ff00 } );
const cube = new THREE.Mesh( geometry, material );
scene.add( cube );

camera.position.z = 5;

function animate() {
	requestAnimationFrame( animate );

	cube.rotation.x += 0.01;
	cube.rotation.y += 0.01;

	renderer.render( scene, camera );
};

animate();

概ね THREE.BoxGeometry とかを作って scene.add していくことでレンダリングされていく。

react-three-fiber の基礎の基礎

翻って react-three-fiber以下のような感じ

  <Canvas>
    <ambientLight />
    <pointLight position={[10, 10, 10]} />
    <Box position={[-1.2, 0, 0]} />
    <Box position={[1.2, 0, 0]} />
  </Canvas>

three.js で imperative に描かれていたものを declarative に書き換えていけばそれっぽく書ける感じ。

とりあえずの星空を描く

データの整形

ヒッパルコス星表をそのまま使うと読み込みも面倒だし 15 MB とかあってデカいので、星の等級 (Vmag) を元に何ファイルかに分割して適宜読み込んで使うことを考え[3]、Python で事前に変換しておくことにした。

また、ここは絶好の前処理の機会であり、以下の処理はこのステップで行っている。

  • 星の色の変換 (B-V → T → XYZ → RGB)
  • RA/Dec の読み替え (hms/dms → deg)

なお、どうせ天球上に描くのであれば RA/Dec を天球上の位置 (X, Y, Z) に読み替えておいても・・・と思われるかもしれないが、観測者の時刻によって固有運動を適用する必要があるため[4]、RA/Dec のままにしている。

https://github.com/karno/celestialengine/blob/master/scripts/genstardb/src/stars.py

データ構造の準備、読み込み

ひとまず、星を描くために必要そうなデータ構造 を作り、続いてデータを取得してくるためのスクリプト(今の fetcher.ts) の原型を作成。

ひとまず星空を描いてみる

ひとまず真っ暗闇に Sphere を描き、それをどこに移動させたときにどうなるのか、どのくらいの大きさになるのかを試しつつ、読み込んだ星データをもとに作った球を天球上に置いて、正しい位置に描けることを確認した。

実際には以下のように位置を算出している。

https://github.com/karno/celestialengine/blob/master/src/core/stars.ts#L102-L129

カスタムシェーダで改めて星を描く

Sphere を置くやり方ではとてもじゃないが現実的な描画スピードにならなかったのと、ある大きさの点を描きたいだけなのに Sphere は過剰だったので、RawShaderMaterial を使って点を自分で描くことにした。GLSL はいろいろなドキュメントがあるが、主にここを見たりして試行錯誤した。

何度か試行錯誤した後、ヒッパルコス星表の全データを描いてもサクサク動く状態になったので、よしとした。

https://github.com/karno/celestialengine/blob/master/src/renderers/Stars.tsx

星座線を描く

星と星を繋ぐ星座線を描くための情報が欲しかったものの、ポチポチやるには辛かったので Stellarium のデータを引用した (https://github.com/Stellarium/stellarium/discussions/790)。気前のいい原作者に感謝。

https://github.com/karno/celestialengine/blob/master/src/core/constellations.ts

この情報があれば、各 HIP ID に紐付く星の位置に線分を引くことで星座線が描画可能なのだが、あいにく ThreeJS の LineSegment では太さを変更できないのでLineSegments2を使って描くようにした。

https://github.com/karno/celestialengine/blob/master/src/renderers/primitives/Constellations.tsx

ブルームエフェクトを付ける

星っぽさを出すためには Bloom Effect が良いという話だったののと、react-three/postprocessing を使えば簡単にできそうだったので付けてみる。

https://github.com/karno/celestialengine/blob/master/src/renderers/Effector.tsx

ちゃんと星空を描く

プロパティを分離する

ここまではコンポーネント内で useState() を使ったりベタ書きして星空をどう描くか (何等星まで描くか、星座線をどう引くか、太さはどうするか) を制御していたが、これを外から注入できるようプロパティとして定義し、すべて外部から入力するようにした。

https://github.com/karno/celestialengine/blob/master/src/properties.ts

外部から入力するコードはだいたい似通うので、これもコンポーネントにまとめた。

https://github.com/karno/celestialengine/blob/master/src/contexts/Context.tsx

以下のような形で使えるイメージ。

<CelestialEngineProvider
  metadataSource="./dat_hp_meta.json"
  initialProps={{ vMag: 5.0 }}
>
  <CelestialCanvas useEngine={true} />
</CelestialEngineProvider>

プロパティの値の変更は useCelestialEngine でContext にアクセスできるようにする。

https://github.com/karno/celestialengine/blob/master/src/contexts/Context.tsx#L38-L46

自転と観測位置をカメラ回転に反映する

ここまでは OrbitControls を使って星空を眺め回していたが、地球の自転(時刻)と観測位置を反映し、そこから仰角と方位角を指定して、その方向の空にカメラを向ける。

めっちゃ細かい話をすると、ICRS (International Celestial Reference System) みたいな座標系があって、そこに固有運動を適用すると BCRS になって、視差を付けて屈折とか光行差を付けて・・・ みたいになっていくものの、今回は細かいことを無視して、地球の自転と地球上の位置だけ気にする。

要するに以下の通り:

左手をずいぶんぐるぐると回すこととなったが、最終的にはいい感じになったのでよしとする。

https://github.com/karno/celestialengine/blob/master/src/observations/Observer.tsx#L158-L179

ユーザ入力を受け付ける

OrbitControls に頼らなくなったので、ユーザの入力をもとにカメラを動かすことができなくなったため、これを自前で補う。ユーザの操作によって仰角と方位角を動かすことになり、だいたい以下の構図。

Observation State はコンポーネントの外側から注入されることを考え、User Interaction をエンコードしたデータをイベントハンドラに投げられるようにした。

https://github.com/karno/celestialengine/blob/master/src/observations/Interactor.tsx

https://github.com/karno/celestialengine/blob/master/src/properties.ts#L301-L328

座標グリッドを描く

いま自分がどこ向いているかをわかりやすくするため、方位座標角 (azimuthal grid) と赤道座標角 (equatorial grid) を描くことにした。

点線で描かれた球体を描くコンポーネントを作って、

https://github.com/karno/celestialengine/blob/master/src/renderers/primitives/GridSpheres.tsx

それを方位を示すほうと赤道座標を示すほうでそれぞれインスタンス化してやればOK。

https://github.com/karno/celestialengine/blob/master/src/renderers/Navigator.tsx

ラベルを描く

方位や星の名前、星座の名前(は今のところ描いていないが) を描くためには WebGL でテキストを描く必要がある。react-three-fiber の作者が出している drei を使ってもよいが、その中で使われている troika-three-text を使ってテキストを描いた。

https://github.com/karno/celestialengine/blob/master/src/renderers/primitives/Labels.tsx

Web ページの背景にする

概ね星空が描けるようになったので、Web サイトに仕上げる。

Web サイト側の準備をする

いい感じの Web ページを作れそうだったので、NextJS と Chakra UI を使う。また、ホスティングは下り帯域無制限の Cloudflare Pages を選択した (星のデータが大きめなので)。

全ページの背景に星空を載せて、また、ページが切り替わっても星空の状況を維持したかったので _app.tsxObservation State を持たせることにした。すなわち、ページコンポーネントの背景として星空を描いたキャンバスが居座る形となり、だいたい以下の感じ。

function CelestianApp({ Component, pageProps }: AppProps) {
  const [dpr, setDpr] = useState<number | undefined>();
  useEffect(() => {
    // capping dpr due to device performance
    const dprCap = isMobileOnly ? 1.5 : isTablet ? 2.0 : 9.0;
    setDpr(Math.min(window.devicePixelRatio, dprCap));
  }, [setDpr]);
  return (
    <ChakraProvider resetCSS theme={theme}>
      <CelestialEngineProvider
        metadataSource="./dat_hp_meta.json"
        initialProps={{ vMag: 5.0 }}
      >
        <GeolocationProvider>
          <div>
            <CelestialCanvas
              style={{
                height: "100vh",
                position: "fixed",
                zIndex: -10,
                background: "#000010",
              }}
              dpr={dpr}
              useContext={true}
            />
            <Component {...pageProps} />
          </div>
        </GeolocationProvider>
      </CelestialEngineProvider>
    </ChakraProvider>
  );
}

DPR が低いとジャギジャギした星空になってしまうものの、DPR を高くし過ぎると iPhone とかでめちゃくちゃ重くなってしまうので、モバイルでは 1.5 上限、タブレットでは 2.0 上限とした。

なお、GeolocationProvider は後述。

ページ内から方角と時刻を制御する

ページごとに仰角を変えたり、何等星まで見えるかを変えたいので、useCelestialEngine() を使って設定する。以下のような感じ。

  const { setProps } = useCelestialEngine();
  useEffect(
    () =>
      setProps((p) => ({
        ...p,
        controllable: false,
        selectable: false,
        azimuthalGrid: { show: false },
        equatorialGrid: { show: false },
        directionLabel: { show: false },
        constellations: {
          show: false,
          opacity: 0.5,
          lineWidth: 1.0,
        },
        zoom: 1.0,
        altitude: deg(altitude),
        vMag: vMag,
        targetStarNumber: null,
      })),
    []
  );

位置情報を取得し反映する

ユーザの現在地に応じた星空を出すには、例えば Geolocation APIGeoIP のような手段でユーザの現在地を取得する必要があるものの、Geolocation API はユーザの許諾を取るまではデフォルトの地点となるしなかなか許諾してくれないのと、GeoIP は MaxMind のライセンスが必要になる。

もっとお手軽な手段として、Cloudflare Workers を使うという手があり、例えばこういうサンプルが公開されている

要するに、ユーザの情報からおおよその位置情報 (lat,lon) を取得し、それを反映してやればよく、
このあたりは適宜実装してやればよい。今回はそういった処理を GeolocationProvider コンポーネントに押し込めており、このコンポーネントの child で useGeoInfo() を呼ぶと位置情報が得られるようにしている。

あとは普通に Web サイトを作ってデプロイする

だけです。

今後の課題

星座の名前を描きたい

星座の名前をレンダリングしたいんだが、面倒でやってない。やればできると思う。

多言語化したい

星の名前や星座の名前はいちおう多言語化できるようには作りかけているものの、どうやってユーザの言語情報を反映したものかわかっておらず、今のところ全部英語にしてる。

シリウスがデカい

かなり手加減してるものの、さすがにデカくて嘘くさくないか?という気持ち。

そもそも星の描画サイズを直接変えに行っているが、本来十分遠方から到来する星の光は点光源であり、なんか違う気がする。

天の川が描かれてない

空いっぱいに広がる天の川 が描けるんじゃねえかと思って始めた節があったものの、中々そうならない。stellarium など他のプラネタリウムソフトではテクスチャ貼ってるっぽいが、それ以外に方法はないのか考えている。

そもそも天の川が見えるメカニズムが複雑ということが 天の川が見える怪(加藤, 2006) および 続・天の川が見える怪(臼井, 2007) にて言及されているものの、そうすると(星景写真を再現するような形で)人の目で見えるよりも遙かに暗い星まで見えるようにすればよいはずだが、中々そうはならなかった。

ヒッパルコス星表 (収録数11万+) よりも遙かに大きなサイズの星表として、ティコ第二星表 (収録数250万+) があり、これを用いることでいけるのでは・・・という思惑があるものの、流石にレンダリングが重いのではという懸念があり、現状まだ試せていない。

脚注
  1. もっとデータ量が多い星表もあり、たとえばティコ第二星表とかあるものの、一旦はそこまでしなくてもいいか・・・という気持ちになった ↩︎

  2. 私にはちょっと厳しかったので、Astronomy Bundle ってパッケージでグリニッジ恒星時を計算してもらってる ↩︎

  3. よくある星空を描くだけなら三等星くらいまで描ければ十分だからね ↩︎

  4. 実際のところ固有運動はめっちゃ小さいのでただのこだわりでしかない ↩︎

Discussion

ログインするとコメントできます