2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

CesiumJSで月食をシミュレーションするWebアプリを作る

2
Last updated at Posted at 2026-03-03

本記事は解説画像とコードの一部にAI(GeminiとClaude)を使用しています。筆者に絵心がないからです。

はじめに

本記事を公開する2026/3/3(火)の夜に、日本で皆既月食が見られます。

ただ、筆者の住む関東では雨予報(雪も降るかも?)のため、直接見ることは叶わなそうです……。

そこで、CesiumJSを使って擬似的に月食をシミュレーションする簡単なWebアプリを作成しました。

image.png

CesiumJS は3D地球儀・地図の可視化ライブラリとして知られていますが、実はその内部には太陽・月の位置計算ライブラリも内蔵しています。これを用いて外部の天文計算 API を一切使わずに、月食のリアルタイムシミュレーターが作れます。

この記事では以下の 4 つの技術的課題について解説します。

  1. CesiumJS で月・太陽の正確な位置を取得する
  2. 地球の影 (本影・半影) を 3D の円錐として描画する
  3. 月が影のどこにあるかを幾何学的に判定する
  4. 月食フェーズに応じて月の色を変化させる

月食の仕組み

月食は、地球が太陽と月の間に入り、地球の影が月に落ちる現象です。地球の影には 2 つの領域があります。

image.png

領域 説明 月食の種類
本影 (Umbra) 太陽が地球に完全に隠れる 皆既・部分月食
半影 (Penumbra) 太陽が地球に部分的に隠れる 半影月食

各領域の半頂角は太陽・地球の半径と距離から幾何学的に決まります。

// 本影の半頂角:  tan(θ_u) = (R_sun - R_earth) / D
const UMBRA_HALF_ANGLE    = Math.atan((SUN_RADIUS - EARTH_RADIUS) / EARTH_TO_SUN)
// ≈ 0.2639°

// 半影の半頂角:  tan(θ_p) = (R_sun + R_earth) / D
const PENUMBRA_HALF_ANGLE = Math.atan((SUN_RADIUS + EARTH_RADIUS) / EARTH_TO_SUN)
// ≈ 0.2688°

// 本影の頂点までの距離 (地球中心から太陽の反対方向)
const UMBRA_APEX_DISTANCE = EARTH_RADIUS / Math.tan(UMBRA_HALF_ANGLE)
// ≈ 1,382,500 km

月の軌道半径 (384,400 km) は本影の頂点距離 (1,382,500 km) より小さいため、月は本影に完全に入れます。これが皆既月食が起きる理由です。


月・太陽の位置を取得する

CesiumJS の惑星位置計算クラス

CesiumJS には Simon1994PlanetaryPositions という惑星位置計算クラスが内蔵されています。これは Simon (1994) 1 の惑星位置理論に基づいており、外部 API や天体暦ファイルなしに月・太陽の位置を高精度で計算できます。

// 月の位置を ICRF 座標系で取得
const moonIcrf = new Cesium.Cartesian3()
Cesium.Simon1994PlanetaryPositions.computeMoonPositionInEarthInertialFrame(
  julianDate,
  moonIcrf,
)

// 太陽の位置を ICRF 座標系で取得
const sunIcrf = new Cesium.Cartesian3()
Cesium.Simon1994PlanetaryPositions.computeSunPositionInEarthInertialFrame(
  julianDate,
  sunIcrf,
)

JPL DE430 などの高精度天体暦との誤差は数 km 以内であり、視覚化用途では十分な精度です。

ICRF → ECEF 変換

Simon1994PlanetaryPositions が返す座標は ICRF (International Celestial Reference Frame) です。これは地球の自転を無視した「宇宙固定」座標系で、地球の自転とともに動く ECEF (Earth-Centered, Earth-Fixed) とは異なります。

CesiumJS のエンティティ配置には ECEF が必要なため、変換が必要です。

function icrfToEcef(icrfPosition, julianDate) {
  const rotation = new Cesium.Matrix3()

  if (!Cesium.defined(Cesium.Transforms.computeIcrfToFixedMatrix(julianDate, rotation))) {
    // 地球姿勢パラメーター (EOP) の非同期読み込みが未完了
    // → undefined を返して今フレームをスキップ
    return undefined
  }

  return Cesium.Matrix3.multiplyByVector(rotation, icrfPosition, new Cesium.Cartesian3())
}

computeIcrfToFixedMatrix は EOP (Earth Orientation Parameters) を使って ICRF と ECEF の対応を計算します。EOP は非同期で読み込まれるため、起動直後に undefined が返る場合があります。フォールバック処理を忘れずに実装します。


地球の影を 3D で描画する

CesiumJS の cylinder エンティティ

本影・半影を円錐で表現したいと思います。
3D の円錐を描画するには、CesiumJS の cylinder エンティティを使います。cylinder は上下で半径が異なる切頭円錐を描画できるため、円錐の近似に使えます。

距離 d での影の半径は次の式で求まります。

本影半径: r_u(d) = R_e × (1 - d / UMBRA_APEX_DISTANCE)  ← 地球から離れるほど縮小
半影半径: r_p(d) = R_e + d × tan(PENUMBRA_HALF_ANGLE)   ← 地球から離れるほど拡大

影錐を太陽の反対方向に向ける

cylinder エンティティはローカル Z 軸方向に伸びます。影錐を太陽の反対方向に向けるには、Z 軸をその方向に回転させる四元数を orientation プロパティに設定します。

function quaternionAlongAntiSolar(antiSolarDir) {
  const defaultZ = Cesium.Cartesian3.UNIT_Z
  const dot = Cesium.Cartesian3.dot(defaultZ, antiSolarDir)

  if (Math.abs(dot + 1.0) < 1e-6) {
    // 真逆の場合: X 軸周りに 180° 回転
    return Cesium.Quaternion.fromAxisAngle(Cesium.Cartesian3.UNIT_X, Math.PI)
  }

  // 回転軸 = Z × antiSolarDir、回転角 = acos(dot)
  const axis  = Cesium.Cartesian3.normalize(
    Cesium.Cartesian3.cross(defaultZ, antiSolarDir, new Cesium.Cartesian3()),
    new Cesium.Cartesian3(),
  )
  const angle = Math.acos(Cesium.Math.clamp(dot, -1.0, 1.0))
  return Cesium.Quaternion.fromAxisAngle(axis, angle, new Cesium.Quaternion())
}

CallbackProperty でフレームごとに更新する

太陽は地球の周りを (見かけ上) 動くため、影錐の向きはフレームごとに更新する必要があります。CallbackProperty を使うと、エンティティの各プロパティをレンダリング時刻に応じて動的に計算できます。

const SHADOW_LENGTH = 6e8  // 600,000 km (月軌道の約 1.5 倍)

let lastAntiSolar = new Cesium.Cartesian3(1, 0, 0)  // EOP 未読み込み時のフォールバック

// 影錐の中心位置: 太陽の反対方向に SHADOW_LENGTH/2 進んだ点
const positionCallback = new Cesium.CallbackProperty((time) => {
  const sunEcef = getSunPositionEcef(time)
  if (sunEcef) {
    lastAntiSolar = Cesium.Cartesian3.normalize(
      Cesium.Cartesian3.negate(sunEcef, new Cesium.Cartesian3()),
      new Cesium.Cartesian3(),
    )
  }
  return Cesium.Cartesian3.multiplyByScalar(lastAntiSolar, SHADOW_LENGTH / 2, new Cesium.Cartesian3())
}, false)

// 影錐の向き: 太陽の反対方向に Z 軸を合わせた四元数
const orientationCallback = new Cesium.CallbackProperty(() => {
  return quaternionAlongAntiSolar(lastAntiSolar)
}, false)

// 本影 (Umbra): 地球側が太く、頂点側が細い
viewer.entities.add({
  position:    positionCallback,
  orientation: orientationCallback,
  cylinder: {
    length:       SHADOW_LENGTH,
    bottomRadius: EARTH_RADIUS,                                              // 地球側
    topRadius:    EARTH_RADIUS * (1 - SHADOW_LENGTH / UMBRA_APEX_DISTANCE), // 頂点側
    material:     Cesium.Color.fromCssColorString('#1a1a2e').withAlpha(0.55),
  },
})

// 半影 (Penumbra): 地球側から外側に向かって広がる
viewer.entities.add({
  position:    positionCallback,
  orientation: orientationCallback,
  cylinder: {
    length:       SHADOW_LENGTH,
    bottomRadius: EARTH_RADIUS,
    topRadius:    EARTH_RADIUS + SHADOW_LENGTH * Math.tan(PENUMBRA_HALF_ANGLE),
    material:     Cesium.Color.fromCssColorString('#2d2d4e').withAlpha(0.25),
  },
})

月食のフェーズを判定する

影軸への射影で判定する

月がどの影領域にあるかを判定するには、月の位置を影軸 (反太陽方向) に射影し、軸からの垂直距離を求めます。

Gemini_Generated_Image_r4xv3qr4xv3qr4xv_.png

この垂直距離 perpDist と、月の位置 d での本影・半影の半径を比較してフェーズを決定します。

function computeEclipsePhase(moonEcef, sunEcef) {
  // 反太陽方向の単位ベクトル
  const antiSolar = Cesium.Cartesian3.normalize(
    Cesium.Cartesian3.negate(sunEcef, new Cesium.Cartesian3()),
    new Cesium.Cartesian3(),
  )

  // 月を反太陽軸に射影した距離 d
  const d = Cesium.Cartesian3.dot(moonEcef, antiSolar)
  if (d <= 0) return { phase: 'none', ratio: 0 }  // 月が太陽側 → 食なし

  // 軸上の射影点と月との差 → 垂直距離 perpDist
  const axisPoint = Cesium.Cartesian3.multiplyByScalar(antiSolar, d, new Cesium.Cartesian3())
  const perpDist  = Cesium.Cartesian3.magnitude(
    Cesium.Cartesian3.subtract(moonEcef, axisPoint, new Cesium.Cartesian3()),
  )

  // 距離 d での本影・半影の半径
  const umbraRadius    = EARTH_RADIUS * (1 - d / UMBRA_APEX_DISTANCE)
  const penumbraRadius = EARTH_RADIUS + d * Math.tan(PENUMBRA_HALF_ANGLE)

  // 月全体が本影に入っている → 皆既月食
  if (umbraRadius > 0 && perpDist < umbraRadius - MOON_RADIUS)
    return { phase: 'total', ratio: 1.0 }

  // 月の一部が本影に入っている → 部分月食
  if (umbraRadius > 0 && perpDist < umbraRadius + MOON_RADIUS) {
    const ratio = (umbraRadius + MOON_RADIUS - perpDist) / (2 * MOON_RADIUS)
    return { phase: 'partial', ratio: Cesium.Math.clamp(ratio, 0, 1) }
  }

  // 月の一部が半影に入っている → 半影月食
  if (perpDist < penumbraRadius + MOON_RADIUS) {
    const ratio = (penumbraRadius + MOON_RADIUS - perpDist) / (2 * MOON_RADIUS)
    return { phase: 'penumbra', ratio: Cesium.Math.clamp(ratio, 0, 1) }
  }

  return { phase: 'none', ratio: 0 }
}

ratio は食の深さ (0〜1) を表し、後続の色補間に使います。


月の色を変化させる

組み込みの Moon を置き換える

CesiumJS には viewer.scene.moon という組み込みの月オブジェクトがありますが、色変化を制御できません。そのため、独自の楕円体エンティティで置き換えます。

viewer.scene.moon.show = false  // 組み込みの Moon を非表示

let lastKnownPosition = new Cesium.Cartesian3(EARTH_TO_MOON, 0, 0)

const moonEntity = viewer.entities.add({
  position: new Cesium.CallbackProperty((time) => {
    const pos = getMoonPositionEcef(time)
    if (pos) lastKnownPosition = pos
    return lastKnownPosition
  }, false),
  ellipsoid: {
    radii: new Cesium.Cartesian3(MOON_RADIUS, MOON_RADIUS, MOON_RADIUS),
    material: Cesium.Color.WHITE,  // 後から setupMoonAppearance() で上書きする
  },
})

赤銅色を色補間で近似する

皆既月食時には月が赤く(赤銅色)染まります。これは、地球の大気を通過した太陽光が月に当たることで生じます。大気はレイリー散乱によって青い光を散乱し、赤い光だけが月に届きます。夕焼けが赤いのと同じしくみですね。

本実装では、この現象を Cesium.Color.lerp() による色補間で近似します。

function eclipsePhaseToColor(phase, ratio) {
  switch (phase) {
    case 'total':
      // 皆既: 赤銅色 (地球大気のレイリー散乱を模倣)
      return new Cesium.Color(0.75, 0.12, 0.05)

    case 'partial':
      // 部分食: 白 → 赤へ線形補間
      return Cesium.Color.lerp(
        Cesium.Color.WHITE,
        new Cesium.Color(0.85, 0.18, 0.05),
        ratio,
        new Cesium.Color(),
      )

    case 'penumbra':
      // 半影食: 白 → 薄灰色へ (肉眼ではほぼ判別不能)
      return Cesium.Color.lerp(
        Cesium.Color.WHITE,
        new Cesium.Color(0.75, 0.75, 0.75),
        ratio * 0.5,
        new Cesium.Color(),
      )

    default:
      return Cesium.Color.WHITE
  }
}

ColorMaterialProperty で毎フレーム更新する

ColorMaterialPropertyCallbackProperty を渡すと、レンダリングのたびに月の色を再計算できます。

moonEntity.ellipsoid.material = new Cesium.ColorMaterialProperty(
  new Cesium.CallbackProperty((time) => {
    const moonPos = getMoonPositionEcef(time)
    const sunPos  = getSunPositionEcef(time)
    if (!moonPos || !sunPos) return Cesium.Color.WHITE

    const { phase, ratio } = computeEclipsePhase(moonPos, sunPos)
    return eclipsePhaseToColor(phase, ratio)
  }, false),
)

まとめ

CesiumJS を用いて月食を再現した際のポイントは以下のとおりです。

課題 CesiumJS での解決方
月・太陽の位置取得 Simon1994PlanetaryPositions
座標系の変換 Transforms.computeIcrfToFixedMatrix (ICRF → ECEF)
影錐の描画 cylinder entities + 四元数で向きを制御
フレームごとの更新 CallbackProperty
月の色変化 ColorMaterialProperty + Color.lerp()
広い深度範囲の描画 scene.logarithmicDepthBuffer = true

CesiumJSは、天文計算・座標変換・3D 描画がすべて揃っています。 月食のような宇宙スケールの現象も、外部ライブラリなしに再現できてしまいます。
CesiumJS単体だと地球と月と太陽しかシミュレーションできないのですが、他のライブラリと組み合わせるともっと大きな天文現象も再現できそうです。

  1. Simon, J.L. et al. (1994) — Numerical expressions for precession formulae and mean elements for the Moon and the planets

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?