[Astro] #168 サイトマップ #04 — 惑星に陰影とホログラムを足し、霧を 2層にして、VR の記事ボードを縦長にして完成

[Astro] #168 サイトマップ #04 — 惑星に陰影とホログラムを足し、霧を 2層にして、VR の記事ボードを縦長にして完成

はじめに

サイトマップの、4 回目です。3D の星座を作って、作り込んで、VR にして、記事まで読めるようにしました。

#167 を公開した翌朝、Quest で見ながら、気になるところを直しました。この記事は、その朝の分の記録です。

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

  1. 背景の霧を 2 層にして、奥の 1 層だけを流す
  2. DUST(記事の星屑)を、最初から常にオンにする
  3. 惑星に、陰影と縁の光を足す(べた塗りの廃止)
  4. ホログラム風の見た目を足して、ボタンで切り替える
  5. VR の記事ボードとカードを、縦に伸ばす

完成画面

陰影と縁の光がついた惑星

惑星(PLANET)

陰影と縁の光がついた惑星

ホログラム風(HOLO)

縞が流れるホログラム風の惑星

VR の記事ボード

背景の霧を、2 層にする

#166 で、背景の紫の霧を、起動時に 1 回だけ焼いて、天球に貼りました。見た目は気に入っていたのですが、星を選んで画面が止まると、霧も止まっていて、動いていないことが分かってしまいました。

霧は、毎フレームのシェーダーにすると重くなります。そこで、焼く処理はそのままにして、焼いた霧を、もう 1 枚重ねることにしました。

  • 1 枚目は、今までどおり、動かさない
  • 2 枚目は、小さめ(1024×512)に別のノイズで焼いて、少し小さい天球に貼る。加算合成で、色は付けない
  • 2 枚目の球を、毎フレーム、少しずつ回す
// 2 枚目の霧だけ、ゆっくり回す(GLOW.drift は度/秒)
skyMesh2.rotateOnAxis(driftAxis, (GLOW.drift * Math.PI / 180) * dt);

毎フレームの仕事は、球を 1 つ回すだけです。ノイズの計算は、増えていません。

2 層が別々の速さで見えると、奥行きが出ます。2D ゲームの、背景の多重スクロールと同じ考え方です。ただ重ねただけなのに、同じ霧が、ずっとリアルになりました。

動いていないように見えた

最初は、回す速さを 0.6 度/秒にしていて、止まっているようにしか見えませんでした。実際には、回転の値は変わっていて、変わっていることは確かめられていました。ですが、見て分かる速さではありませんでした。2.0 度/秒に上げて、霧の濃さ(GLOW.wisp)を 0.5 にして、やっと分かるようになりました。

しかも、動いていないように見えた理由は、もう 1 つありました。私は、止まって見える方向ばかりを見ていました。画面全体を引いて見ると、霧が流れていることに気付きました。

動かすのは 2 枚目だけなので、動かない 1 枚目の濃い所では、変化が目に入りにくくなります。これは、そういう作りです。prefers-reduced-motion のときは、霧を動かさないようにしています。

DUST を、常にオンにする

DUST(記事の星屑)は、ボタンで出し入れする作りでした。ですが、記事の星があるほうが、断然、見栄えがします。

#166 で、最初からオンにするところまでは、やりました。今回は、ボタンを外して、常にオンにしました。記事の数は、タイトルに出します。

$('sm-title').textContent = dust ? `SITEMAP // 3D (${dust.n})` : 'SITEMAP // 3D';

今は、SITEMAP // 3D (834) と出ます。ボタンが 1 つ減って、画面もすっきりしました。

古いスマホで重くなる可能性は、確かめていません。実機で確かめたのは iPhone 13 だけです。重いと分かったら、最初の数秒の FPS を測って、低ければ自動で DUST を隠す、という対策を考えています。今は、入れていません。

惑星に、陰影と縁の光を足す

惑星(アプリとカテゴリの星)は、ただのべた塗りの球でした。見た目を変えたくなりましたが、重くしたくはありません。

星は、1 つの InstancedMesh

星は、全部で 100 個ほどで、1 つの InstancedMesh として描いています。マテリアルは MeshBasicMaterial(光の計算をしない)です。

ここに、陰影と縁の光を足すには、次のような方法がありました。

  • MeshStandardMaterial に替える:光源が要る。VR で左右の目に光が当たると、重くなる
  • 別のシェーダーを自作する:インスタンスの色や、行列の扱いを、全部やり直す必要がある
  • MeshBasicMaterial のシェーダーに、数行だけ足す:インスタンスの仕組みは、そのまま使える

3 つ目にしました。onBeforeCompile で、three.js が作るシェーダーに、文字列で追記します。

starMat.onBeforeCompile = (sh) => {
  sh.uniforms.uStyle = uStyle; sh.uniforms.uLight = uLight;
  sh.vertexShader = sh.vertexShader
    .replace('void main() {', 'varying vec3 vWN; varying vec3 vWP;\nvoid main() {')
    .replace('#include <begin_vertex>', `#include <begin_vertex>
      mat4 sm_m = modelMatrix;
      #ifdef USE_INSTANCING
        sm_m = modelMatrix * instanceMatrix;
      #endif
      vWN = normalize(mat3(sm_m) * normal); vWP = (sm_m * vec4(position, 1.0)).xyz;`);
  sh.fragmentShader = sh.fragmentShader
    .replace('#include <opaque_fragment>', `/* ここに陰影の計算 */ #include <opaque_fragment>`);
};

頂点では、インスタンスごとの行列まで掛けて、世界座標の法線と位置を、フラグメントに渡します。フラグメントでは、色が決まったあと、出力の直前(opaque_fragment)に、陰影を足します。

陰影と縁の光

足したのは、次の 2 つです。

float sl = dot(sN, uLight) * 0.5 + 0.5;            // 明るい側 1 ... 影側 0
float sd = 0.28 + 0.95 * sl * sl;                  // 影側も、真っ黒にはしない
float sr = pow(1.0 - max(dot(sN, sV), 0.0), 2.6);  // 縁の光
outgoingLight = outgoingLight * sd + outgoingLight * 1.7 * sr * (0.35 + 0.65 * sl);
  • 光の向き(uLight)は、世界に固定している。カメラに付けると、回すたびに、陰影が動いてしまう
  • 影側は、真っ黒にならないようにしている。霧の上で、黒い穴に見えるため(#166 の失敗と同じ)
  • 縁は、明るい側ほど強く光る

色は、元の出力(outgoingLight)に掛けています。そのため、検索で暗く沈めた星は、陰影を足したあとも、暗いままです。今までの明るさの仕組みを、触る必要はありませんでした。

球の分割は、10×8 から 20×14 に増やしました。陰影が、角張って見えなくなります。星は 100 個ほどなので、全部で 3 万頂点ほどで、気にならない量です。

立体感が出た

結果は、予想より、ずっと良くなりました。同じ球なのに、立体に見えます。この見た目を見たあと、元のべた塗りには、戻れなくなりました。

負荷は、ほとんど増えていません。増えたのは、ピクセルごとの、内積 2 回と pow 1 回です。描画は、今までどおり 1 回で、テクスチャも増えていません。Mac と Quest 3 で、体感は軽いままです。フレームレートの計測は、していません。

べた塗りは、廃止した

最初は、べた塗りと切り替えられるようにしました(ボタンで、STYLE を切り替える)。ですが、試した直後に、「もうべた塗には戻れない」となって、べた塗りは廃止しました。切り替えの仕組みだけを残しています。

const STYLES = ['PLANET', 'HOLO'];

STYLES が 2 つ以上のときだけ、STYLE ボタンが出ます。1 つのときは、ボタンは隠れます。選んだ見た目は、localStorage に保存します(保存できなくても、動きます)。VR は、PC で選んだ見た目を、そのまま使います。

ホログラム風(HOLO)

見た目の、もう 1 つの案として、ホログラム風を作りました。ネットワーク端末の画面のような見た目です。

  • カテゴリの色を残したまま、少しシアン寄りにする
  • 緯度の方向に、縞を流す
  • 縁を、強く光らせる
vec3 ht = outgoingLight * mix(vec3(1.0), vec3(0.55, 1.05, 1.3), 0.45);
float hb = 0.5 + 0.7 * sin(hN.y * 42.0 - uTime * 10.4); // 緯度方向の縞
float hr = pow(1.0 - max(dot(hN, hV), 0.0), 2.2);
outgoingLight = ht * (0.2 + 0.6 * hb) + ht * 1.3 * hr;

縞は、法線の高さ(hN.y)で決めています。世界の座標ではありません。そのため、VR で世界を縮めても、縞の細かさは変わりません。

uTime は、描画ループで更新します。prefers-reduced-motion のときは、0 にして、縞を止めます。

色が、白く飛んだ

最初の試作は、色を明るさだけにしてから、シアンを付けていました。すると、どの星も白っぽい灰色になって、カテゴリの色が消えました。

元の色に、色ごとの係数を掛ける形に変えて、直りました。「色を足す」のではなく、「色を傾ける」ほうが、元の色が残ります。

速さは、決めたら終わり

縞の速さは、uTime * 1.4 から始めて、速くしてみたら、面白くなりました。ですが、数字をいじり始めると、終わりません。最後は、10.4(約 0.6 秒で 1 周)にして、「ここで確定」としました。

速いので、VR で酔う可能性を考えました。Quest で見たところ、惑星自体が小さいので、酔うほどではありませんでした。

VR の記事ボードを、縦に伸ばす

#167 で作った記事ボード(簡易ブラウザ)は、1100×900 の Canvas でした。VR で使ってみると、縦が足りず、1 画面で読める行が少なすぎました。

VR は、縦にも横にも、空間が無限にあります。広げない理由が、ありませんでした。

ボードの高さは、Canvas の高さ(BHp)で決まります。世界の幅(VR.boardW)はそのままで、高さだけを伸ばしました。

const BWp = 1100, BHp = 1500; // 900 → 1400 → 1500

まず 1400 にして、読みやすくなって、さらに 1500 にしました。スクロールの範囲は、自動で計算し直されます。文字の大きさも、下のバーの位置も、変わりません。

カードも、少し伸ばす

左のカード(アプリのパネル)は、説明文が長いと、下の方が切れて読めませんでした。

そこで、カードの Canvas を、768×640 から 768×768 に伸ばしました。説明文は、2 行から 5 行まで入ります。ボタンの位置は、下端を基準にして、縦の長さに合わせて動きます。

const PW = 768, PH = 768, IMGH = 432, DESC_LINES = 5;
r.app = [20, PH - 88, 354, 68]; // ボタンは、下端基準

世界側の高さ(VR.panelH)も、Canvas の縦の比(768/640)に合わせて、62.5 から 75 に伸ばしました。文字の大きさは変わらず、縦だけが伸びます。

ボードと同じ高さにすると、違和感があるので、カードは、少し伸ばすだけにしました。

なお、このパネルは、カテゴリに着いたときの壁にも、同じ大きさで使われます。壁の行の間隔が、少しだけ広がりました。

ハマりどころ

1. 動かしているのに、動いていないように見える

霧の 2 枚目は、確かに回っていました。ですが、遅すぎて見えませんでした。さらに、動かない 1 枚目を見ていて、動いていないと思い込みました。回転の値を数字で確かめるだけでなく、画面を引いて、全体を見ることが必要でした。

2. 陰影は、世界に固定する

光の向きを、カメラの向きに合わせると、視点を回すたびに、明るい側が変わります。VR では、頭を動かすたびに、陰影がちらつきます。光は、世界に固定します。

3. 色は、足すより、傾ける

ホログラムで、色を足したら、白くなりました。元の色に係数を掛けると、元の色が残ります。

4. 画面を縮めても、縞の細かさが変わらない形にする

VR では、世界全体に縮尺をかけます。縞を、世界の座標で決めると、縮尺で細かさが変わります。法線の高さで決めれば、縮尺に依りません。

5. 見た目の数字は、キリがない

縞の速さも、縁の強さも、数字を変えるたびに、また見たくなります。「ここで確定」と決めないと、終わりません。

6. 見た目は、ヘッドレスでは判断できない

ヘッドレスのブラウザでは、エラーが出ないことと、描画されることしか、確かめられません。陰影が好みか、縞の速さが丁度いいかは、手元のブラウザと、Quest で見て決めました。

まだ入れていないこと

  • 実機(Quest・古いスマホ)での、フレームレートの計測
  • 動画の再生の確認(CORS を含む)
  • ホログラム以外の見た目(STYLES に足せば、ボタンが増える)
  • 時間の回廊:記事を日付順に並べて、手前へ迫ってくるビュー
  • 作品(GALLERY の 14 本)を、3D の星にすること

おわりに

これで、サイトマップは、完成にします。

最初は、メニューと記事のデータを、3D の星に並べただけのものでした。そこに、後光と霧を足して、検索を足して、VR にして、記事を読めるようにして、最後に、惑星を立体にしました。同じデータなのに、見え方は、全然変わりました。

今朝の作業は、どれも、小さなものでした。霧をもう 1 枚、シェーダーに数行、Canvas の高さを数百ピクセル。それでも、見た目の印象は、大きく変わりました。重い処理は、1 つも足していません。

自作した記事ボードは、使い勝手が良くて、他の実装にも使い回したくなっています。MDX を読んで、ブロックにして、Canvas に描く、という部分は、独立したモジュールに切り出せます。それは、また、別の機会に。