three.jsでブラウザ上にプラネタリウムを2館実装した — 20万点の星、回転客席、WebSocketなし同期でハマったこと

three.jsでブラウザ上にプラネタリウムを2館実装した — 20万点の星、回転客席、WebSocketなし同期でハマったこと
目次


ブラウザで動く3Dゲームホール「StudyQuest アーケード」に、遊技機を置かない鑑賞区画としてプラネタリウムを2館追加しました。

椅子に座るか寝台に寝そべると、天井のドームいっぱいに星空が広がります。

第2館では、星空だけでなく客席の床そのものが12分かけて1周します。

今回の実装では、three.js r168とDjangoという既存構成をそのまま使い、新しい依存ライブラリは追加していません。

この記事では、20万点規模の星をブラウザで描画する方法、浅いドームへの投影、オーロラ表現、回転床へのカメラ追従、WebSocketを使わない番組同期、そして本番へ出してしまった当たり判定の不具合まで紹介します。


1. 星を「絵」ではなく「点」にした

最初はドーム面のシェーダー内で星を描いていました。

ノイズからしきい値を超えた場所だけを光らせる、比較的よく使われる方法です。

軽量ではありますが、今回の実装では問題がありました。

星のまたたきが全体で似た動きになり、近くから見ると「星空」より「動く模様」に見えてしまったからです。

そこで、星1個をTHREE.Pointsの1頂点として扱う方式へ変更しました。

// 1館あたり最大10万点。低負荷モードでは2.5万点
export const STAR_QUALITY = Object.freeze({
  high: 100000,
  low: 25000,
});

星ごとに次の値を持たせています。

  • 明るさ
  • またたきの位相
  • またたきの速度

暗い星ほど多くする

すべての星を同じ明るさで配置すると、空が均一なドット模様に見えてしまいます。

そこで、恒星数と等級の関係を単純化したモデルとして、

N(<m) ∝ 10^(0.6m)

という分布を使い、暗い星ほど多く生成しています。

// 簡易的な等級分布。
// -1.5等から8.0等までの範囲で、暗い星ほど多くする。
const magSpan =
  Math.pow(10, 0.6 * (MAG_MAX - MAG_MIN)) - 1;

mag[i] =
  MAG_MIN +
  Math.log10(random() * magSpan + 1) / 0.6;

ここで使っている分布は、実際の全天恒星カタログを再現するものではありません。

見た目として「明るい星は少なく、暗い星は多い」状態を作るための簡易モデルです。

星ごとに別のリズムでまたたかせる

またたきは頂点シェーダーで計算しています。

星ごとの位相と速度を使い、周波数の異なる2つのサイン波を重ねました。

float amp =
    uTwinkle * mix(0.55, 0.12, altitude);

float flicker =
    sin(uTime * aRate + aPhase) * 0.6
  + sin(uTime * aRate * 2.37 + aPhase * 1.7) * 0.4;

2つ目の周期を単純な2倍や3倍にしないのがポイントです。

整数比から少し外すことで、星空全体が同じ周期で明滅しているように見えにくくなります。

さらに、地平線に近い星ほど大気の影響を強く感じる見た目にするため、低高度ほどまたたきの振れ幅を大きくしています。

色と明るさも星ごとに変える

星の色には、B−V色指数を参考にした青白色から橙色までの色変化を使っています。

ただし、今回の星には実際の恒星カタログを使っていません。

そのため、厳密には「実恒星のB−V値」ではなく、B−V色指数を参考にした演出用の色分布です。

明るい星へ加算合成を使うと、RGB値が白へ張り付きやすくなります。

そこでフラグメントシェーダー側で、

color / (1 + k × color)

のような簡易的な圧縮を入れ、明部が白一色につぶれにくいようにしました。

10万個あっても星部分は1ドローコール

10万個の星を別々のMeshとして作ってはいません。

1館分を1つのTHREE.Pointsへまとめています。

そのため、通常の1パス描画では星10万点を1回の描画呼び出しで送れます。

高品質設定では2館合計20万点です。

客席から見上げた状態で計測したところ、

場所Draw CallsTriangles
プラネタリウム側32約3.9万
同じホールの遊技場側439約13.1万

でした。

ただし、ここで注意が必要です。

THREE.Pointsは三角形ではありません。

そのため、trianglesだけを比較しても星の描画量は分かりません。

星についてはrenderer.info.render.pointsもあわせて確認します。

星座は実在の配置ではない

今回、外部の恒星カタログは取り込んでいません。

等級、色、天の川への集まり方などは星空らしく見えるよう調整していますが、星の位置は演出用です。

実際の星座配置ではありません。

画面でも「演出の星空」と表記しています。

将来、HipparcosやGaiaなどの実データへ置き換える場合でも、星の位置を生成する部分を差し替えれば、THREE.Pointsによる描画部分はそのまま利用できます。


2. 浅いドームへ「客席から見た空」を投影する

今回の天井は半球ではありません。

ホールの天井高が約4.2mしかないため、

  • 水平方向の半径:約11.6m × 7.8m
  • ドームの立ち上がり:約1.5m

という、かなり浅い楕円体キャップにしています。

単純に楕円面へ星を均等配置すると、客席から見たときに天頂付近の星が不自然に引き伸ばされます。

そこで、

  1. 客席の基準点を決める
  2. 方位角と仰角から空を見る方向を決める
  3. その方向へレイを飛ばす
  4. レイと楕円体の交点を星の位置とする

という順序で配置しています。

楕円体中心を原点として正規化すると、交点計算は二次方程式になります。

const dn = new THREE.Vector3(
  dir.x / axes.x,
  dir.y / axes.y,
  dir.z / axes.z,
);

const pn = new THREE.Vector3(
  p.x / axes.x,
  p.y / axes.y,
  p.z / axes.z,
);

const a = dn.dot(dn);
const b = 2 * pn.dot(dn);
const c = pn.dot(pn) - 1;

const discriminant = b * b - 4 * a * c;

if (discriminant >= 0) {
  const t =
    (-b + Math.sqrt(discriminant)) / (2 * a);
}

重要なのは、方向を先に決めてからドーム面との交点を求めることです。

見た目を決める基準をドーム側ではなく、客席側へ置いています。

踏んだ罠1:座標系を混ぜない

館はホール全体の原点から約60m離れた場所にあります。

ところが最初は、レイ計算の一部に館のローカル座標、一部に世界座標を使っていました。

その結果、レイと楕円体が交差せず、星空が一面真っ黒になりました。

原因は数学ではなく座標系の不一致です。

今回の計算では世界座標で統一しました。

Groupで建物全体を移動する設計では、

Meshの座標
↓
親Groupの変換
↓
世界座標

という変換があります。

CPU側やシェーダーへ独自に渡している値だけ、この変換を通っていないケースには注意が必要です。

踏んだ罠2:シェーダーへ巨大なシード値をそのまま渡さない

天の川のノイズに、

20260920

のような日付由来の大きな値を、そのままシードとして加えていた時期がありました。

すると、滑らかだった模様が大きなブロック状に崩れました。

大きな浮動小数点数では、小数部分を表現できる細かさが落ちます。

そこでシードを、

(Math.abs(Math.trunc(seed)) % 997) * 0.317 + 0.5

のように十分小さい範囲へ畳んでからシェーダーへ渡すようにしました。

ノイズへ使うシードは、値が大きいほど良いわけではありません。


3. オーロラは「扇」ではなくカーテンにする

最初のオーロラは、ドーム面へ明るい帯を直接描いていました。

しかし、結果はオーロラというより、天井へ扇形の模様を投影したような見た目でした。

そこで設計を変更しました。

オーロラをドームの模様ではなく、空間内に立っている半透明の幕として扱います。

今回の構成は次のとおりです。

  • カーテンは5枚
  • 天頂側からドームの縁へ垂れ下がる縦長の面
  • 縦筋はノイズで生成
  • カーテンごとにノイズの移動速度を変更
  • 筋と筋の間から星空が見える
  • 下部は明るい緑
  • 上へ行くにつれて青緑
  • 先端は紫寄り
  • 高い位置ほど透明度を上げる
  • 加算合成
  • depthTest: true
  • depthWrite: false

踏んだ罠3:独自頂点シェーダーでも親Groupの変換は必要

オーロラの幕は館のローカル座標で作り、館全体の親Groupへ載せています。

ところが、最初の自作頂点シェーダーでは、

gl_Position =
    projectionMatrix
  * viewMatrix
  * vec4(position, 1.0);

としていました。

これではmodelMatrixがありません。

つまり、親Groupによる館の移動が反映されません。

結果として、オーロラだけがホール原点付近へ描画されました。

カメラからは見えないため、症状だけを見ると「オーロラが描画されない」ように見えます。

ローカル座標の頂点を使うなら、

gl_Position =
    projectionMatrix
  * modelViewMatrix
  * vec4(shifted, 1.0);

とします。

modelViewMatrixにはモデル変換とビュー変換の両方が含まれます。


4. 回る客席を作る

第2館には、直径約10.8mの回転式客席を作りました。

1周にかかる時間は720秒、つまり12分です。

回転角を「ページを開いてから何秒経過したか」で決めると、ユーザーごとに床の角度が変わってしまいます。

そこで、サーバー時刻を基準にした共通時間から角度を求めています。

hall.spin =
  ((nowMs / 1000) % hall.periodSec)
  * (Math.PI * 2)
  / hall.periodSec;

ここで使うnowMsは端末のDate.now()そのものではありません。

ページ読み込み時にDjangoから受け取ったサーバー時刻を基準とし、その後の経過時間だけをブラウザの単調増加クロックから足しています。

着席中は、席の位置だけでなくカメラも同じ角度だけ回転させます。

実測では30秒間で、

設計値 : 15.00°
実測値 : 15.01°

となりました。

同じ時間で席は円周上を約1.08m移動しましたが、カメラの高さは変化しませんでした。


5. 回転床で、本番ユーザーが歩けなくなる不具合を出した

今回もっとも大きかった不具合です。

席から立った直後、プレイヤーが一歩も動けなくなる状態を本番へ出してしまいました。

原因は回転ではありません。

当たり判定でした。

原因は「円形の床」と「四角い当たり判定」の不一致

見た目の回転床は半径5.4mの円です。

しかし、当たり判定には円ではなく、円を囲む10.8m四方のAABBを使っていました。

イメージすると次の状態です。

+-----------------------+
|       当たり判定       |
|                       |
|       *********       |
|    ***         ***    |
|   **   回転床     **   |
|    ***         ***    |
|       *********       |
|                       |
+-----------------------+

席から立つ処理では、プレイヤーを「席の前方1.15m」へ配置していました。

しかし、回転床上の席から1.15m移動した程度では、まだ四角い当たり判定の内側です。

つまり、

着席
 ↓
席の前へ1.15m移動
 ↓
まだ当たり判定の中
 ↓
次の移動先も当たり判定の中
 ↓
移動判定ですべて拒否

となっていました。

ページを再読み込みしないと脱出できません。

修正1:外接する箱の外へ確実に出す

回転床から降りる位置の計算を専用関数へまとめました。

export function platformExit(
  center,
  radius,
  seat,
  {
    radius: playerRadius = 0.3,
    margin = 0.5,
  } = {},
) {
  const dx = seat.x - center.x;
  const dz = seat.z - center.z;

  const reach =
    radius + playerRadius + margin;

  if (Math.abs(dz) >= Math.abs(dx)) {
    return {
      x: seat.x,
      z: center.z + (dz >= 0 ? reach : -reach),
    };
  }

  return {
    x: center.x + (dx >= 0 ? reach : -reach),
    z: seat.z,
  };
}

中心から席を見たとき、XとZのうち絶対値が大きい軸を選びます。

その方向へ、回転床の半径、プレイヤー半径、安全マージンを足した位置まで移動させます。

円の外へ出すだけではなく、実際に使っている四角い当たり判定の外まで出すのがポイントです。

修正2:降り先を再確認する

計算した場所が別の障害物へ入っている可能性もあります。

そこで、

新しい降車位置
 ↓
blockedAt()で検査
 ↓
通行可能
 ├─ Yes → その位置へ移動
 └─ No  → 着席前の位置へ戻す

というフォールバックを入れました。

修正3:立った後の向きも変える

位置だけ直しても不十分でした。

床の方向を向いたまま立たせると、プレイヤーが最初に押す「前進」が再び床方向になります。

ユーザーから見ると、

「まだ歩けない」

ように感じます。

そこで、降りたあとは回転床と反対側を向くようにしました。


6. 「旧実装なら失敗する」テストを書く

この不具合では、修正版が動くことだけをテストしていません。

まず、旧実装なら確実に詰む条件そのものをテストへ残しました。

test(
  "the old behaviour (stand in front of the seat) traps you on the turntable",
  () => {
    const inFrontOfSeat = {
      x: seat.x + Math.sin(yaw) * 1.15,
      z: seat.z + Math.cos(yaw) * 1.15,
    };

    assert.ok(
      blockedAt(
        [PLATFORM_BOX],
        inFrontOfSeat.x,
        inFrontOfSeat.z,
      ),
    );

    assert.equal(
      canWalk(
        [PLATFORM_BOX],
        inFrontOfSeat,
      ),
      false,
    );
  },
);

このテストがあることで、

「修正後にたまたま歩けるようになった」

のではなく、

以前の失敗条件を再現したうえで、その原因を解消した

ことを確認できます。

当たり判定の計算も1つのモジュールへ集約しました。

座席ごとに別々の降車処理を書かないことも再発防止策の1つです。


7. DBもWebSocketも使わず、ほぼ同じ番組進行を見せる

プラネタリウムの番組状態はDBへ保存していません。

WebSocketによる番組同期も行っていません。

代わりにDjangoがページ生成時に、

  • server_now_ms
  • 番組の並び
  • 1番組600秒
  • クロスフェード7000ms

をブラウザへ渡します。

ブラウザ側では、ページを読み込んだ時点のサーバー時刻を基準にし、それ以降の経過時間を単調増加クロックから求めます。

イメージは次のとおりです。

const perfBase = performance.now();
const serverBase = server_now_ms;

function synchronizedNow() {
  return (
    serverBase
    + (performance.now() - perfBase)
  );
}

これなら、端末の時計が数分ずれていても番組進行には使われません。

途中から参加したユーザーも、サーバー時刻から計算した同じ番組位置へ入ります。

館ごとに番組番号へオフセットを付けることで、第1館と第2館では別の番組を流しています。

厳密なフレーム同期ではない

この方式にも制約があります。

server_now_msをサーバーがHTMLへ埋め込んでからブラウザへ届くまでには通信時間があります。

したがって、ユーザー間で完全に同じミリ秒を共有しているわけではありません。

今回のような10分単位のプラネタリウム演出では許容できるため、WebSocketを使わない構成を選びました。

ゲーム判定や音楽演奏のように数十ミリ秒単位の同期が必要なら、往復時間を計測して時刻差を補正する仕組みが必要です。

番組変更は7秒かけて混ぜる

番組は、

  • 静かな星空
  • 天の川
  • 月夜
  • オーロラ

を10分ごとに切り替えます。

ただし、瞬間的には切り替えません。

前番組と次番組の各パラメータを7秒間かけて補間します。

星そのもののアニメーション時刻はリセットしないため、番組境界でも天球の動きが飛びません。

テストでは、

  • 補間中の値が前番組と次番組の範囲内にあること
  • 番組境界でアニメーション時刻が巻き戻らないこと

を確認しています。


8. プラネタリウムのために光源を増やさない

プラネタリウムなので、館内はかなり暗くする必要があります。

しかし、そのために入退室のたび光源を追加・削除する設計にはしませんでした。

three.jsでは、シーン内で使用する光源の数や種類が変化すると、光源に依存するマテリアルが使用するシェーダープログラムの条件も変化します。

状況によっては、新しいプログラムのコンパイルや切り替えによる停止が発生します。

大きなホールへ入った瞬間のカクつきを避けるため、今回は、

  • HemisphereLight
  • DirectionalLight

の2本を基本照明として固定しました。

プラネタリウム内を暗くするときも、光源自体は増減させません。

代わりに、

  • 床を暗い材質にする
  • 壁を暗い材質にする
  • 椅子と寝台を暗くする
  • 足元灯にはMeshBasicMaterialを使う
  • 床の縁もMeshBasicMaterialで光っているように見せる
  • 案内文字も自発光しているように見せる

という方法を採りました。

MeshBasicMaterialで明るく見える面を置いても、その面が周囲のMeshを照らすわけではありません。

その性質を「間接照明らしく見せる演出」に使っています。


9. 「作ったのに気づかれない」を直す

実装後に別の問題も分かりました。

そもそもプラネタリウムの存在に気づきにくい。

館はカラオケエリアのさらに奥にあります。

案内を追加する前は、場所を知っているユーザーでなければ、入口まで辿り着くのが難しい状態でした。

そこで導線を3段階で追加しました。

ホールから南へ誘導する

ホールと南側増築部分の境界に、

さらに奥 … カラオケ・プラネタリウム

という案内を置きました。

文字は目の高さにしています。

カラオケからプラネタリウムへ誘導する

カラオケ入口の看板も、

カラオケの個室・プラネタリウム へ

へ変更しました。

最後は床と扉で誘導する

カラオケ廊下からプラネタリウム入口まで、

  • 足元の光る帯
  • 頭上の吊り看板
  • 扉を囲む発光フレーム

を追加しました。

特に効果が大きかったのは足元の帯です。

文字を読まなくても、光を追えば入口へ着けます。

案内板は画面上端へ置かないようにしました。

近距離ではHUDのボタンと重なり、読めなくなるためです。


10. 動作確認で見るようにしたこと

今回の不具合を受けて、単に「館が表示される」だけでは完了としないようにしました。

最低でも次を確認します。

入館
 ↓
席へ近づく
 ↓
座る
 ↓
見回す
 ↓
床が回る
 ↓
立つ
 ↓
前進する
 ↓
館外へ出る

さらに、

  • 椅子から立てるか
  • 寝台から立てるか
  • 回転床上のすべての座席から降りられるか
  • 床の回転角度が変わっても降りられるか
  • 番組切り替え直前・直後に描画が飛ばないか
  • 低負荷モードでも星が表示されるか
  • ゲストユーザーでも利用できるか
  • 案内だけを見て入口まで到達できるか

も確認対象にしました。

今回のような3D空間では、ユニットテストだけでは「人間が実際に操作すると詰む」問題を拾いきれません。

計算ロジックのテストと、実際に歩くE2E確認の両方が必要でした。


11. 完成したプラネタリウム

現在の構成は次のとおりです。

第1プラネタリウム

  • 床は固定
  • リクライニングチェア10脚
  • 寝台4台

第2プラネタリウム

  • 客席床が回転
  • 720秒で1周
  • 椅子6脚
  • 寝台2台

星空

  • 1館最大10万点
  • 2館合計最大20万点
  • 星ごとに異なるまたたき
  • 青白色から橙色まで色を変化
  • 天の川
  • 月夜
  • オーロラ

番組

  • 静かな星空
  • 天の川
  • 月夜
  • オーロラ
  • 10分ごとに変更
  • 7秒クロスフェード

椅子へ座ると、床から約1.02mの高さで35°上を向きます。

寝台では約0.62mの高さから76°上を向きます。

回転床の上では、それぞれ床の高さぶん約0.22m上がります。

着席中は歩行操作だけを止め、マウスやタッチによる見回しは残しています。

座席予約もメダル消費もありません。

空いている席へ座るだけで、ゲストユーザーでも利用できます。


まとめ

今回のプラネタリウムで特に重要だったのは、星を増やすことよりも、座標系、時間、当たり判定という別々の基準を混ぜないことでした。

星の投影ではローカル座標と世界座標を混ぜると何も表示されません。

回転床では見た目が円でも、当たり判定が四角なら四角を基準に出口を計算する必要があります。

番組同期では端末時計ではなく共通の時間基準が必要です。

3D空間では、見た目として正常でも、内部で使っている座標系や判定形状が一致しているとは限りません。

今回もっとも大きかった失敗も、回転処理そのものではなく、見た目の円形床と、内部の四角い当たり判定を同じものとして扱ってしまったことでした。

そして、ユニットテストが通っただけでは十分ではありませんでした。

最後に実際の本番環境へ入り、

座る → 立つ → 歩く

まで操作したことで、不具合を発見できました。

ブラウザ3Dでは、数学やシェーダーだけでなく、最後に「プレイヤーとして普通に触る」確認も重要だと改めて分かりました。

StudyQuest アーケードから入る場合は、

アーケードホール
  ↓
南側の通り口
  ↓
カラオケの廊下を東へ
  ↓
一番東側の光っている扉
  ↓
プラネタリウム

の順です。

足元の光る帯が入口まで続いています。

読んだ内容を10問練習と実技で確認

記事で理解した用語を、StudyQuestの演習とクラウド実技ラボで定着させます。

10問練習 実技ラボ

コメント(0件)

まだコメントはありません。最初のコメントを投稿してください!

コメントを投稿