[Astro] #166 サイトマップ #02 — 星に光を足して、霧と粒子と検索を入れ、VR へつなぐところまで作り込み

[Astro] #166 サイトマップ #02 — 星に光を足して、霧と粒子と検索を入れ、VR へつなぐところまで作り込み

はじめに

前回、サイトマップを作りました。カテゴリを大きな星、アプリを周りの星、記事を星屑にして、クリックでワープする 3D 版と、JavaScript なしで読める 2D 版です。

動きはしたのですが、見た目は「丸と線だけ」でした。そこで、同じデータのまま、作り込みました。番号は前回と同じ #165 で、続きとして書きます。

今回の要点は次の 4 つです。

  1. 後光・霧・粒子で、「丸と線」を星空らしくする
  2. 検索を入れて、ヒットした星だけをライトアップする
  3. スマホと画面サイズ周りの細かい不具合を直す
  4. VR 化の方針を決めて、最初の段階が Quest で動いた

完成画面

作り込む前

作り込む前の 3D サイトマップ — 丸と線だけ

作り込んだあと

作り込んだあとの 3D サイトマップ — 後光、紫の霧、光の粒

検索でライトアップ

MSX で検索 — ヒットした星だけが光る

見た目を作り込む

きっかけは、「この 3D 表示も、単なる丸と線だけだけど、少しアレンジしたら豪華になる?」という、ふとした疑問でした。やってみて、ダメなら戻せばいい、という軽い気持ちで始めました。戻せるように、変更の前のファイルは毎回残しています。

後光

星の後ろに、光のにじみを置きました。

  • 光の点は、中心が明るくて縁が透明な丸の画像(放射状のグラデーション)
  • 描画は、加算合成の Points
  • カテゴリ用(大)、項目用(小)、カテゴリの周りの薄いもや用(特大)の 3 枚

星そのものは、前と同じ球です。後ろに光を足しただけで、見え方がかなり変わりました。

線の色も変えました。カテゴリ側を明るく、項目側を暗くして、グラデーションにしています。

const f = s < NSI ? 0.28 : 1; // 根元(カテゴリ側)は明るく、先端(項目側)は暗く
colArr.set([tmp.r, tmp.g, tmp.b, tmp.r * f, tmp.g * f, tmp.b * f], s * 6);

線は 1 本の LineSegments のままで、頂点の色を 2 つ変えているだけです。

選んだ星に、白い大玉をやめてリングを付ける

前は、選んだ星を白くして、1.4 倍に膨らませていました。これが、隣の項目名の文字を隠していました。

今は、星の色はそのままにして、周りに細いリングを出します。リングは Sprite で、距離に合わせて大きさを変えて、画面上ではいつも同じ大きさに見えるようにしています。

背景の星は、やめた

最初は、背景に小さな星を 1400 個ばらまきました。奥行きは出ましたが、DUST(記事の星屑)と見分けがつかなくなりました。

そこで、星の代わりに、以前作った Procedural Solar System の背景の、紫の霧を持ってくることにしました。ノイズのシェーダーなので、重くなるかもしれないと思っていました。

霧は、1 回だけ焼く

ノイズを毎フレーム計算すると重くなります。そこで、起動したときに 1 回だけ、GPU でノイズを計算して、テクスチャに焼きました。そのテクスチャを、巨大な球(天球)の内側に貼ります。毎フレームの仕事は、球を 1 つ描くだけです。

renderer.setRenderTarget(rt);
renderer.render(bakeScene, new THREE.Camera()); // ノイズを rt(2048×1024)に焼く
renderer.setRenderTarget(null);

霧のシェーダーは、次の形です。

  • 3D の fbm ノイズ(5 段)を、さらに別の fbm でゆがめる(ドメインワープ)。これで霧の筋ができる
  • 球面の方向から 3D ノイズを引くので、つなぎ目が出ない
  • カテゴリのある方向には、そのカテゴリの色を少し混ぜる

カテゴリの色の混ぜ方は、原点から見た方向で決めています。そのため、星座の中心に立つと、星の配置と霧の色が一致します。

もう 1 つ、細かい話があります。最初は、ノイズをリニア空間の値のまま 8bit のテクスチャに焼こうとしました。暗い部分は、リニアのままだと 1 段の幅が粗くなって、段差(バンディング)が出ます。そこで、表示用の色のまま焼いて、天球は色変換なしで出力しています。

天球の半径は 5000 です。カメラの描画距離を 6000 から 12000 に伸ばさないと、球の反対側が欠けます。

濃さの調整

最初は、霧が濃すぎて、ラベルや線が読みにくくなりました。強さを 0.6 に落として、ちょうど良くなりました。強さは GLOW.nebula で変えられて、0 にすれば霧は消えます。

const GLOW = { halo: 0.55, haze: 0.14, nebula: 0.6, ring: true, pulse: true, flow: 1.6 };

見た目の飾りは、全部ここで調整できます。気に入らなければ、個別に切れます。

線の上を、光の粒が流れる

最後に、線の上を光の粒が流れるようにしました。線 1 本につき、光の粒を 1 つ。根元から先端へ、ゆっくり動きます。

  • 約 100 個の Points を、毎フレーム CPU で位置と色を更新するだけ
  • 端では消えるように、sin で明るさを変える
  • ホバー中のカテゴリの線だけが明るくなる
  • prefers-reduced-motion のときは、無効にする

正直、「線の上を粒子が動くって、どうなんだろう」と思っていました。やってみると、予想以上に良くて、生命が宿ったように見えました。動きがあるだけで、静止した図が、動いている世界になりました。

検索を入れる

記事が約 830 本とアプリが約 95 本あるので、星を眺めるだけでは、探したいものが見つかりません。検索を足しました。

検索は、重くない

検索の対象は、タイトル・タグ・アプリの説明だけです。記事の本文は検索しません。合計で約 1000 件なので、入力のたびに全部を見ても、ほとんど負荷はかかりません。

  • 全角・半角、大文字・小文字は同じ扱い(NFKC に直して小文字にする)
  • スペースで区切ると AND
  • 日本語入力の、変換を確定する Enter は無視する

結果の見せ方

入力すると、カテゴリ一覧が、結果の一覧に入れ替わります。左端の色がカテゴリの色で、2 行目に「APP / 記事」とカテゴリ、日付が出ます。クリックするか、Enter で先頭を選ぶか、上下キーで選ぶと、その星へワープして詳細カードが開きます。

結果の一覧は最大 40 件ですが、ライトアップは全部のヒットが対象です。

ヒットした星だけが光る

検索の結果を、3D の星にも反映しました。例えば「MSX」で検索すると、MSX に関係のあるアプリと記事の星だけが明るくなって、他は沈みます。

ヒットしたかどうかを、Uint8Array で持っています。

let hitItem = null, hitDust = null, hitCat = null; // null なら検索していない
const kk = hitItem ? (hitItem[k] ? 1 : 0.22) : /* ふだんの明るさ */ base;

星・線・後光・光の粒・記事の星屑の明るさを決める所が、全部この配列を見ます。入力のたびに、約 1000 要素を回して塗り直すだけなので、軽いです。

ヒットした記事の星は、そのままだと小さくて見えないので、光の点を重ねています。

沈んだ星が、黒い穴に見えた

検索中、ヒットしなかった星は暗く沈めていました。ところが、星の色が黒に近いと、霧の上に黒い円盤が浮かんで、穴が空いたように見えました。暗さの下限を上げて、背景に溶ける程度の暗さにしました。

霧のような背景があると、「暗くする」ことは、「黒くする」こととは違う、と分かりました。

記事の星屑を、最初からオンにする

DUST(記事の星屑)は、最初はボタンを押すまで出さない仕様でした。ですが、記事の星があった方が、断然映えます。

重さは、コードから見積もりました。星屑は 1 つの Points(約 830 点)と、線と、光る点だけです。毎回の処理は、色を塗り直す 830 回のループと、マウスが動いたときの 830 点の画面座標への投影だけです。データは最初からページに埋め込んであるので、オンにしても、転送量は増えません。

そこで、起動時に toggleDust() を 1 回呼ぶだけで、最初からオンにしました。ボタンで消すことは、今までどおりできます。

スマホと画面サイズの修正

スマホで、左上の一覧が邪魔

スマホにすると、左上のカテゴリ一覧が、星空の大部分を隠していました。

  • 700px 以下では、一覧を隠す
  • 左上に「☰ MENU」ボタンを出して、押すと開く
  • カテゴリを選んだとき、または星空を触ったときに、自動で閉じる

もともと、スマホ用の CSS は書いてありました。ところが、一覧が #sm-list という入れ子の中にあるせいで、横スクロールの指定が効いていませんでした。しかも、700px 以下では、ALL / ROTATE / DUST のボタンが隠れていて、スマホでは DUST が押せませんでした。今回の MENU で、どちらも解消しました。

画面の下に、黒い帯が出る

3D の描画領域とフッターの間に、細い黒い帯が出ていました。

原因は、ステージの高さを 100vh - 100px と、決め打ちにしていたことです。実際のヘッダーは約 40px、フッターは約 37px で、合っていませんでした。

そこで、ステージを画面に固定して、ヘッダーとフッターの実際の高さを JavaScript で測って、上下に合わせるようにしました。

const fit = () => {
  const hd = document.querySelector('.navi-os-header');
  const ft = document.querySelector('.system-footer');
  stage.style.setProperty('--sm-top', (hd ? hd.offsetHeight : 40) + 'px');
  stage.style.setProperty('--sm-bot', (ft ? ft.offsetHeight : 37) + 'px');
};

リサイズのときも、測り直します。要素が見つからないときは、40px と 37px です。

VR 化へ

ここまで作り込んだ世界を、VR で見たくなりました。

前回の試作は、失敗した

前回の記事の最後に、「島を歩く VR」を構想として書きました。試作も作りました。が、完成したものは、駄作でした。島に作品が並んでいても、情報が伝わりません。VR では、HTML も iframe も埋め込めない(と理解しています)。島を歩くよりも、今の星座のほうが、見たいものが分かります。

今の星座を、そのまま VR へ

方針を、次のように決めました。

  • 開始位置は、星座の中心。周りにカテゴリの星が見渡せる
  • コントローラーのレーザーでカテゴリの星を指して、最初は瞬間移動する
  • 移動のあとは、ワープ移動で星を渡り歩く
  • 星への当たり判定は、距離ではなく、角度で取る(画面上で最も近い星を探す、2D の方法と同じ考え方)
  • 到着したら、そのカテゴリのアプリを、湾曲した壁のようにパネルで並べる(マルチモニタ風)
  • 記事は、本文を JSON に抽出して、Canvas に描画する、簡易的なブラウザにする

VR の中では Web が開けません。そのため、記事を読むには、ビルド時に記事から本文を抜き出した JSON を、自前で描画するしかありません。コードブロックや画像は、簡略化することになります。

一気に作らない

段階を 3 つに分けました。

  1. VR で世界が見える。中心から、星を指して移動できる
  2. 到着先で、パネルを並べる
  3. 記事リーダー

1 つずつ、Quest で確かめながら進めます。段階 1 が、先に動きました。

画像の CORS は、確認が要る

パネルに画像を出すには、画像を WebGL のテクスチャにします。これには、CORS ヘッダー(Access-Control-Allow-Origin)が必要です。

2D のページで画像が見えているのは、<img> が CORS を必要としないからで、CORS が通っている証拠にはなりません。そこで、次のコードで確かめました。

fetch('https://assets.lain-lab.com/images/uploads/astro-165-sitemap.webp', { mode: 'cors' })
  .then(r => console.log('OK', r.status))
  .catch(e => console.log('NG', e));

結果は OK 200 でした。画像は、そのままテクスチャにできます。動画は、パネルに出す段階になったら確かめます。

ハマりどころ

1. 背景の星が、記事の星と混ざる

背景に星を撒くと、奥行きは出ますが、DUST の星屑と見分けがつかなくなります。星の代わりに、霧にしたら解決しました。奥行きの手がかりを出すときは、情報を持つ要素と、見分けがつく形にします。

2. 暗くしたつもりが、黒い穴になる

霧の上で「暗くする」と、黒い円盤になります。背景の色に近い暗さにします。

3. 暗い部分のノイズが、段差になる

リニア空間の値を 8bit のテクスチャに焼くと、暗い所で段差が出ます。表示用の色のまま焼きます。

4. 描画距離が足りず、天球が欠ける

天球の半径が 5000 のとき、カメラが原点から離れると、球の反対側が描画距離(far)を越えて欠けます。far を 12000 に伸ばしました。

5. 固定値の高さは、ズレる

100vh - 100px のような決め打ちは、ヘッダーとフッターの高さが変わると、すぐズレます。実際の高さを測ります。

6. 見えていても、CORS が通っているとは限らない

<img> で見えていても、WebGL のテクスチャにはできないことがあります。必要になる前に、fetch で確かめます。

7. 沈んだ星の黒、DUST の初期状態、スマホの CSS

どれも、実際の画面を見て、初めて気付きました。ヘッドレスのブラウザで確かめても、「見え方」までは分かりません。手元のブラウザで見て、直します。

まだ入れていないこと

  • 実機(Quest・スマホ)での、フレームレートの計測
  • VR の段階 2・3(パネルと記事リーダー)
  • 時間の回廊:記事を日付順に並べて、手前へ迫ってくるビュー
  • 作品(GALLERY の 14 本)を、3D の星にすること

おわりに

最初の版で、妥協しなくて良かったと思いました。

最初の版は、データを読んで、並べて、動かしただけのものでした。そこに、後光、霧、粒子、検索を足していくと、同じデータなのに、全然違う世界になりました。やはり、ライティングは大事です。

作り込みは、一度に全部をやらずに、1 つ足しては見て、1 つ足しては見て、を繰り返しました。気に入らなければ戻せるように、前のファイルを残しておく。それだけで、安心して試せました。

VR は、まだ最初の一歩です。次は、星の足元にパネルを並べて、記事を読めるところまで、進めたいと思います。