[Astro] #213 スマホVR深海 ABYSSAL: 水深で変わる海・GPUで動くクラゲ・魚の群れ・マリアナ海溝の底まで

[Astro] #213 スマホVR深海 ABYSSAL: 水深で変わる海・GPUで動くクラゲ・魚の群れ・マリアナ海溝の底まで

はじめに

スマホと Cardboard 型ゴーグルで、深海をゆっくり沈んでいくアプリ ABYSSAL の実装記録です。PC のブラウザでも動きます。

  • 沈むと、水の色が暗くなり、水深(m)と層の名前が出る。いちばん深い所(10,900 m)で止まる
  • 発光するクラゲ、魚の群れ、舞うプランクトン、チョウチンアンコウ、海底
  • 音は、コードで作る(外部の素材なし)
  • 足元の MENU を見つめると、設定のボタンが並ぶ(注視メニュー)
  • モデルも、テクスチャも、音源も使わない。すべてコードで生成している

スクリーンショット

浅い所のクラゲ

浅い所の、クラゲと魚の群れ

漆黒層のチョウチンアンコウ

暗い水の中の、チョウチンアンコウとクラゲ

海底

マリアナ海溝の底の、暗い砂地とクラゲ

動画(深海)

マリアナ海溝の底の、暗い砂地とクラゲ

1. 構成

public/tool/mobile/abyssal/ に置いています。

ファイル役割
index.html入り口。cardboard.css と abyssal.css を読み、abyssal.js を module で読む
abyssal.js本体。調整値(CONFIG)、シーン、クラゲ、魚、粒、アンコウ、海底、音、メニュー
abyssal.css起動画面の見出しの色だけ

VR の部品(STEREO 表示、傾きセンサー、注視ボタン、起動画面、上のバー)は、他のスマホ VR アプリと共通の ../common/cardboard.js を使っています。three.js は ../lib/three-vrm.js のバンドルから読みます(r183)。

2. 共通部品の使い方

const cb = initCardboard({
  scene, camera, title: 'ABYSSAL', intro: '…',
  onStart: () => { cb.gaze.dwell = CONFIG.dwell; startAudio(); },   // START のタップの中で、音を始める
  onFrame: update,                                                   // 毎フレーム呼ばれる
  debugLines: () => `…`,                                             // デバッグ表示の中身
});
cb.run();

見ている人は、原点に固定です。カメラは動かさず、世界のほうが動きます。沈むときは、粒とクラゲが上へ流れます。START を押した時に向いていた方が、−z です。

注視ボタン(cb.createGazeButton)、世界に置く文字(cb.createWorldHud)、片目の縦横比(cb.aspectEye)も、この部品の機能です。

3. 水深と水の色

水の色は、水深の表で決めます。間は線形に補間します。

depthStops: [[0, 0x0f6fa0], [150, 0x08466f], [400, 0x041f3d], [800, 0x010c1c], [1500, 0x000409]],

色は、scene.background と scene.fog.color に毎フレーム入れます。霧は FogExp2(密度 0.05)です。

ShaderMaterial の色空間

scene.background や Color の値は、内部では線形です。一方 ShaderMaterial の出力は、色空間の変換がされず、書いた値がそのまま画面に出ます。魚のシェーダーに霧の色(水の色)を渡すとき、そのまま渡すと、背景と色がずれます。

waterS.copy(water).convertLinearToSRGB();   // シェーダーに渡す色は、sRGB にしてから
fishMat.uniforms.uFogCol.value = waterS;

自前の霧

ShaderMaterial には、three.js の霧が効きません。シェーダーの中で、同じ式の霧を計算します。

float fogK(float dist, float d) { return 1.0 - exp(-d * d * dist * dist); }

不透明な魚は、mix(col, uFogCol, fog) で水の色に溶かします。加算合成のクラゲと粒は、水の色に混ぜると暗い板になるので、col * (1.0 - fog) で、遠いほど消します。

4. クラゲ

傘、芯、触手の 3 つのメッシュを、1 つのグループにしています。動きは、すべて頂点シェーダー(GPU)で計算します。

傘

SphereGeometry(2.2, 32, 28, 0, 2π, 0, π × 0.58) の半球を、縦に 1.15 倍にします。脈動は、法線方向の膨らみと、縁ほど大きい上下の動きです。

float phase = uTime * uPulseSpeed + uPhase;
float hf = smoothstep(-1.2, 1.2, position.y);
float ex = sin(phase - position.y * 1.2) * uPulseIntensity * hf;   // 下ほど遅れて膨らむ
p.x += normal.x * ex; p.z += normal.z * ex; p.y += sin(phase) * 0.25 * hf;

フラグメントシェーダーは、フレネル(縁ほど濃い)、上へ向かう色のグラデーション、放射状の縞で、色を決めます。

触手と口腕

当初は、TubeGeometry の頂点を毎フレーム CPU で動かしていました。頂点の並びが想定とずれて、動きが崩れたので、カメラの横方向に幅を足す細い帯(リボン) に作り直しました。

  • 頂点ごとに、aSide(左右 ±1)、aT(根元 0 → 先端 1)、aPh(位相)、aW(幅)、aK(触手 0 / 口腕 1)の属性を持つ
  • 揺れは、sin(uTime * 2.6 - aT * 5.0 + aPh) * aT を、x と z に足す(先ほど大きく揺れる)
  • 幅は、ビュー空間で mv.x += aSide * aW * (1.0 - 0.65 * aT)。どちらから見ても、細い線に見える
  • 傘の脈動について行くよう、y にも同じ位相の動きを足す

1 匹あたり、触手 14 本と口腕 4 本を、1 つのジオメトリにまとめています。frustumCulled = false にしています(頂点シェーダーで動くので、見切れ判定が合わない)。

配置と、再配置

  • 見ている人から 9 m 以上離して置く。最初は、向いた方(−z 側)に 6 割を寄せる
  • 毎フレーム、脈に合わせて上へ泳ぐ速さと、沈んでいるときの上への流れを足す
  • 世界の半径(32 m)を出たら、世界のふちの下側から、また出す。沈むと、下から来る

見つめると光る

画面の中央からレイを飛ばし、傘に当たったら uGaze を 0 → 1 へなめらかに上げて、発光を強めます。

Raycaster は、visible = false のオブジェクトも判定します。水深で数を減らしたクラゲ(10 章)を除くため、判定の対象は、b.parent.visible で絞った配列を毎フレーム作っています。

5. 魚の群れ

形

makeFishGeometry() が、頂点を並べて作ります。外部のモデルはありません。

  • 体: 輪を 18 個並べた紡錘形。断面は、横 0.6 : 縦 1 の楕円。鼻先 +z、尾の付け根 −z
  • 二股の尾びれ、背びれ、胸びれ、腹の光る点(光器、2 列 × 8)、目
  • 頂点属性は、部位 aPart(0 体 / 1 尾 / 2 びれ / 3 光器 / 4 目)と、背腹 aBack
  • 法線は computeVertexNormals()

泳ぎ

体のくねりとひれの揺れは、頂点シェーダーです。尾に向かうほど大きい波を、位置 z に沿って送ります。

float w = clamp((0.35 - p.z) / 0.8, 0.0, 1.0);          // 尾ほど 1
float ph = uTime * 7.0 + iPh - p.z * 9.0;               // iPh: 個体ごとの位相
p.x += sin(ph) * 0.06 * w * w;
if (aPart > 0.5 && aPart < 2.5) p.x += sin(ph + 0.8) * 0.02;   // びれ

色は、腹が銀、背がテーマ色の暗い色。縁に薄いリム光。光器は、色を強く(vCol * 2.2)して、霧の影響を半分にします。

描画

InstancedMesh(80 匹)で、1 回の描画にまとめます。個体ごとの位相 iPh は、InstancedBufferAttribute としてジオメトリに持たせます。個体の色は setColorAt(シェーダー側は USE_INSTANCING_COLOR の instanceColor)、大きさは行列の scale(0.8〜1.6 倍)です。

群れの動き(boids)

位置と速度を、型付き配列(Float32Array)に持ちます。近くの個体(半径 5.5 m 以内)に対して、次の 3 つを足します。

  • まとまる: 近くの個体の重心へ
  • そろう: 近くの個体の平均の速度へ
  • 離れる: 1.8 m 以内の個体から(距離の 2 乗に反比例)

距離の比較は、2 乗のまま行います(sqrt を使わない)。世界の半径の 0.85 倍を出ると、中心へ戻します。見ている人から 6 m 以内には入らせません。速度は、下限 1.0、上限 2.2〜3.2 m/s です。向きは Matrix4.lookAt に速度ベクトルを渡します(+z が進む向き)。

6. 舞う粒

マリンスノー(420)と、光るプランクトン(130)です。1 回の描画で、板を大量に出します。点の大きさ(gl_PointSize)は、STEREO の左右の画面でずれるので、使いません。世界の大きさを持つ板(InstancedBufferGeometry の板)を、カメラの向きに回して出します。

位置は、GPU で計算します。高さ 56 m の範囲を、mod で循環させます。沈むと、uUp(上への流れの累積)が増えて、粒が上へ流れます。

7. 光の筋

水面から降る光は、7 枚の縦の板です。加算合成で、水深 500 m で見えなくなるよう、強さを下げます(0.55 × clamp(1 − 水深 / 500, 0, 1))。強さが十分小さくなったら、visible = false にします。

8. チョウチンアンコウ

水深 800〜6,500 m で、3 匹出ます。外部のモデルはなく、基本のジオメトリを組み合わせています。

  • 体: 球を (0.9, 1.0, 1.25) 倍に。尾: 円錐を横に潰して後ろへ
  • 歯: 円錐を上下に 11 本ずつ。位置は、楕円体の表面の z を計算して合わせる(1.25 × √(1 − (x/0.9)² − y²))
  • 目: 小さい球。釣り竿: CatmullRomCurve3 を TubeGeometry に
  • 餌: 小さい球と、加算合成の Sprite(放射状グラデーションの CanvasTexture)

材質は、光を受けない MeshBasicMaterial(暗い色)で、scene.fog が効きます。餌の Sprite は霧が効かないので、距離から自分で明るさを下げます(exp(−(0.05 × 距離)²))。

動きは、見ている人の周りの円軌道です。

u.th += u.om * dt;
a.position.set(u.rho * Math.cos(u.th), u.y0 + Math.sin(t * 0.3 + u.ph) * 1.5, u.rho * Math.sin(u.th));
a.rotation.y = Math.atan2(-Math.sin(u.th) * dir, Math.cos(u.th) * dir);   // 進む向き(接線)が +z

軌道の半径 rho は、11〜16 m です。近づくと、体が平らなシルエットで、歯が薄い板に見えて粗いので、遠くを回らせます。登場と退場は、水深に応じたスケール(0 → 1 → 0)で、なめらかに出し入れします。

9. 海底

いちばん深い所(CONFIG.bottom = 10,900 m)に近づくと、下から海底が現れます。

  • 100 m × 100 m、80 分割の平面を、起伏のある sin の合成で上下させる
  • 色は、頂点カラー(暗いグレー寄りで、起伏と乱数で少しだけ変える)。MeshBasicMaterial で、霧が効く
  • 位置は、floor.position.y = −(底までの距離)。見ている人は動かず、床が上がってくる
  • 底までの距離が 70 m 未満のときだけ表示する

生き物が床を突き抜けないよう、床の高さ state.floorY で、クラゲは floorY + 2、魚は floorY + 0.6 より下へ行かないようにします(魚は、下向きの速度を反転)。

10. 水深・層・降下

層

zones: [[0, '太陽光層'], [200, '薄明層'], [1000, '漆黒層'], [4000, '深層'], [6000, '超深海層']],

層が変わると、HUD(世界に置く文字)に 水深 1,000 m ・ 漆黒層 のように 5 秒間出します。層の間は、500 m ごとに水深だけ出します。

水深による増減

fishByDepth:  [[0, 1], [1000, 1], [4000, 0.3], [7500, 0.15]],   // 魚の数の割合
jellyByDepth: [[0, 1], [4000, 1], [7000, 0.5], [10900, 0.4]],   // クラゲの数の割合

表の間は、線形に補間します。魚は InstancedMesh.count を、クラゲは visible を切り替えます。6,000 m 以降は、魚の色を白く透けた色へ寄せます(シェーダーの uGhost を、水深から 0 → 1)。

降下と着底

水深の増え方は、一定の速さ(sinkRate = 12 m/s)に、メニューの倍率を掛けます。底の手前 250 m から、残りの距離に比例して遅くします。

const left = CONFIG.bottom - CONFIG.hover - state.depth;
state.depth += CONFIG.sinkRate * mult * clamp(left / 250, 0.05, 1) * dt;   // 下限は 5%(0.6 m/s)
if (CONFIG.bottom - CONFIG.hover - state.depth <= 0.3) { /* 着底: 止める。メッセージを出す */ }

底から 4 m(hover)の所で止まります。倍率 ×1 のとき、底まで約 16 分です。

11. 音

外部の音源を使わず、Web Audio でコードから作ります。音源のファイルを使いたいときは、CONFIG.se にパスを書くと、それをループ再生します。

  • 水の響き: ブラウンノイズ(8 秒)を、ローパス(260 Hz)に通す。ループの継ぎ目は、末尾と先頭を 0.3 秒だけ混ぜて消す。音量は、ゆっくりした LFO(0.09 Hz)で波打つ
  • 低い和音: 41 / 62 / 82 Hz のサイン波を、ごく小さい音量で。それぞれ、遅い LFO で音量を揺らす
  • 鯨の声: サイン波が、110〜220 Hz から 6〜8 割の高さまで、3.5〜6 秒かけて下がる。ビブラートと、ローパス、音量の山。18〜40 秒おきに、左右ランダムの位置(StereoPanner)で
  • 泡: ホワイトノイズを、バンドパス(Q 9)に通し、中心の周波数を 0.12 秒で 2〜3 倍に上げる。1.2〜5 秒おきに、左右ランダム
  • 残響: 4.5 秒の、減衰するノイズのインパルスを ConvolverNode に。ドライとウェットに分ける

iPhone の Web Audio は、ユーザー操作(START のタップ)の中で AudioContext を作って resume() しないと鳴りません。そのため、起動画面の START の中で startAudio() を呼びます。ページが隠れたら suspend()、戻ったら resume() します。

12. 注視メニュー

足元の MENU を、視界の真ん中の点で 1.2 秒見つめると、正面にボタンが横に並びます。何も押さずに 15 秒たつと、閉じます。

ボタン内容
JELLYクラゲの数(6 / 10 / 16 / 24)
GLOW発光の強さ(×0.8 / ×1.2 / ×1.8 / ×2.6)
COLOR色(CYAN / MAGENTA / EMERALD / AMBER)
FISH魚の ON / OFF
DIVE沈む速さ(×1 / ×3 / ×10 / OFF)
SURFACE水面(水深 30 m)に戻る
SOUND音の ON / OFF
RETURNアプリ一覧に戻る
CLOSEメニューを閉じる

ボタンの並びは、画面の形(片目の縦横比)から視野の横幅を計算して、収まる大きさに縮めます(layoutMenu)。ボタンの文字は、前後に空行を入れて小さくし、円の中に収めます。

COLOR を切り替えると、クラゲ、魚、粒、アンコウの餌の色を、すべて塗り直します。

13. 調整の値

abyssal.js の先頭の CONFIG で、数字を変えて再読み込みするだけです。

項目既定値内容
jellyCounts / jellyCount[6, 10, 16, 24] / 1クラゲの数の候補 / 最初の番号
fishCount80魚の数(浅い所)
anglers3チョウチンアンコウの数
sinkRate12沈む速さ(m/秒)
diveSpeeds[1, 3, 10, 0]DIVE の倍率
bottom / hover10900 / 4底の水深 / 底から浮く高さ(m)
radius32世界の半径(m)
fogDensity0.05霧の濃さ
dwell1.2注視ボタンを押すまでの秒数
se''音源ファイルのパス。空なら、コードで作る音
volume0.5音量

14. つまずいた点

  • 触手の頂点が、想定とずれる: TubeGeometry の頂点の並びを前提にした CPU 計算をやめ、GPU で動く帯に作り直した(4 章)
  • 魚だけ、背景と色がずれる: ShaderMaterial は色空間の変換がない。水の色を sRGB にして渡した(3 章)
  • 加算合成のものが、霧で暗い板になる: 水の色に混ぜず、遠いほど消す式にした(3 章)
  • 近づいたアンコウが粗い: 軌道の半径を広げた(8 章)
  • 床の下に、生き物が潜る: 床の高さで、位置を押し戻した(9 章)

15. 制約

  • 見ている人は、動きません(原点に固定)。魚とクラゲは、見ている人の周りを動きます
  • アンコウは、近づくと粗いので、遠くを回らせています。魚は 1 種類です
  • 海底に、生き物と地形の起伏の他は、置いていません
  • 音の聞こえ方は、端末とイヤホンで変わります。鯨の声と泡の音量は、buildAmbience の数字で変えられます
  • 深さの数字(層の境と、底の 10,900 m)は、おおよその値です

クレジット

外部のモデル、テクスチャ、音源は、使っていません。すべて、コードで生成しています。three.js(MIT)と、共通の VR 部品(cardboard.js)を使っています。

関連

  • BONFIRE(スマホ VR の焚き火)
  • PLANETARIUM(スマホ VR の星空)