[Astro] #206 夜の空に列車を走らせる(石炭袋・FREE カメラ・GLB 機関車・効果音)

[Astro] #206 夜の空に列車を走らせる(石炭袋・FREE カメラ・GLB 機関車・効果音)

はじめに

TOP ページの空(Sky Today)に、見つけた人だけが気づく隠し要素を作りました。夜の星空にマウスを当てると反応し、クリックすると、宇宙の方から列車が走ってきて、目の前で止まります。

  • 渦は、星と天の川のシェーダーの中で、方向だけから計算して、空を削って作ります(画像もメッシュも足しません)
  • 渦の位置は、天文として正確ではありません。東京から見える位置の天の川に置いています
  • クリックの判定は、渦の方向に置いた見えない球です。マウスでも VR のレーザーでも、同じイベントで受ける作りです(夜しか見えない為、実機まだ未確認)
  • 空は、CAMERA: FREE を選ぶと、左ドラッグで見回せます
空に開いた渦と、停車した列車と駅名標

1. 渦のシェーダー

渦は、天球の方向 d(長さ 1 のベクトル)から、0〜1 の「穴の濃さ」を返す関数 coalShape(d) です。星(点)のシェーダーと、天の川のシェーダーの両方に同じ関数を入れて、穴の中を暗くします。

uniform vec3 uCDir; uniform vec3 uCE1; uniform vec3 uCE2; uniform vec4 uCP; uniform float uCH;
// uCDir = 渦の中心の方向、uCE1 / uCE2 = 中心に直交する 2 本の軸
// uCP = (半径 rad, 時間 秒, 強さ 0〜1, 外側の cos)

float coalShape(vec3 d){
  float c = dot(d, uCDir);
  if (c < uCP.w) return 0.0;                              // 範囲の外は、すぐ戻る
  float r  = acos(min(c, 1.0)) / uCP.x;                   // 中心からの角度 / 半径(0〜1)
  float ph = atan(dot(d, uCE2), dot(d, uCE1));            // 中心のまわりの角度
  float sp = ph - 1.7 * log(r + 0.05) - uCP.y * 0.08;     // 対数らせん + 回転
  float arm  = smoothstep(0.30, 0.72, 0.5 + 0.5 * cos(2.0 * sp));   // 2 本の腕
  float core = 1.0 - smoothstep(0.10, 0.26, r);                      // 中心の芯
  float body = 1.0 - smoothstep(0.22, 1.0, r);                       // 外へ薄くなる
  return clamp(core + arm * body * 1.05, 0.0, 1.0) * (1.0 - smoothstep(0.86, 1.0, r));
}
float coalHole(vec3 d){ return uCP.z <= 0.0 ? 0.0 : coalShape(d) * uCP.z; }
float coalEdge(vec3 d){ if (uCP.z <= 0.0) return 0.0; float h = coalShape(d); return (1.0 - abs(2.0 * h - 1.0)) * uCP.z * (1.0 + 2.5 * uCH); }
  • らせんは、ph - 1.7 * log(r + 0.05) が一定になる線です。cos(2.0 * sp) で、腕が 2 本になります
  • 回転は uCP.y * 0.08(rad/秒)なので、約 78 秒で 1 周です。uCP.y は、毎フレーム dt を足します
  • 星は、頂点シェーダーで vHole = coalHole(normalize(position)) を計算し、フラグメントで明るさに (1.0 - vHole) を掛けて消します
  • 天の川は、フラグメントシェーダーで、テクスチャの色に (1.0 - coalHole(d)) を掛けます。同じ式の coalEdge で、穴の縁にだけ、ごく薄い青(vec3(0.30, 0.38, 0.55) * 0.12)を足します
  • 渦の中にある星雲・星団(dso)は、メッシュなので、シェーダーでは消えません。渦の範囲に入るものは、visible = false にします

中心の方向は、銀河座標 (l, b) から、赤道座標の単位ベクトルに直して渡します。

function setCoalsack(l, b, radDeg, amt) {
  const cb = Math.cos(b * D);
  coal.dir.set(0, 0, 0)
    .addScaledVector(GAL_X, cb * Math.cos(l * D))
    .addScaledVector(GAL_Y, cb * Math.sin(l * D))
    .addScaledVector(GAL_Z, Math.sin(b * D)).normalize();
  const ref = Math.abs(coal.dir.z) > 0.99 ? new THREE.Vector3(1, 0, 0) : new THREE.Vector3(0, 0, 1);
  const e1 = new THREE.Vector3().crossVectors(ref, coal.dir).normalize();
  const e2 = new THREE.Vector3().crossVectors(coal.dir, e1);   // 中心に直交する軸
  // uCDir / uCE1 / uCE2 / uCP を更新
}

GAL_X / GAL_Y / GAL_Z は、銀河座標の 3 軸(赤道座標での向き)で、天の川のシェーダーと同じ値です。既定は l=80°、b=0°(白鳥座のデネブの付近)、半径 3°、強さ 0.3 です。半径 6.5°・強さ 1 では、大きすぎて目立ちました。

2. 当たり判定とホバー

渦の方向に、見えない球(visible={false})を置き、毎フレーム追いかけます。

const d = c.sk.coalsack.worldDir(tmp);                      // 渦の方向(空の回転を反映)
hitOn.current = c.sk.coalsack.visible() && d.y > 0.02;       // 夜で、地平線の上のときだけ反応
m.position.copy(d).multiplyScalar(90);                       // 距離 90 に置く
m.scale.setScalar(90 * Math.sin(radDeg * Math.PI / 180 * 1.4));   // 見かけの半径の 1.4 倍
<mesh ref={hit} visible={false} frustumCulled={false} onPointerOver={onCoalOver} onPointerOut={onCoalOut} onClick={onCoalClick}>
  <sphereGeometry args={[1, 12, 8]} /><meshBasicMaterial />
</mesh>
  • visible() は、coal.on && S.sunAlt < -10(太陽の高度が -10° より下)です。昼は渦が見えないので、反応もしません
  • onPointerOver で coalsack.hover(true) を呼び、カーソルを pointer にします。シェーダー側は、coal.h を指数でなめらかにして、uCH = h * 0.6 + pulse を縁のにじみに掛けます。回転も速くなります(uCP.y += dt * (1 + 3h + 6pulse))
  • onClick は、e.delta(押してから離すまでの動き)が 4px を超えたら、クリックにしません。FREE の見回しのドラッグが、クリックとして扱われないようにするためです
  • クリックでは、coalsack.pulse()(1 → 0 に 1.4 秒で戻る余韻)を呼んで、wired-coalsack-click のイベントを送ります。列車は、これを受けます

3. FREE カメラ

WiredCameraRig.tsx に、視点 free を、並びの最後に足しました。位置は初期と同じ [0, 0, 4]、画角 35°です。

  • FREE に着いたあと(飛んでいる途中は不可)、空・背景の見えない球(半径 60)を左ドラッグすると見回せます。ホイールで画角を 15〜75° の間で変えます
  • 向きだけ変えて、位置は動かしません。1px あたりの角度は fov / 画面の高さ で、つかんだ空が指についてきます
  • 近くの物(アバターなど)が先に押しを受け取るので、アバターを押したときは見回しになりません
  • ほかの視点では、ドラッグしても動きません。weather.json の camera.look: false で、見回しだけを止められます
const k = (camera.fov * Math.PI / 180) / Math.max(1, el.clientHeight);
L.yaw += (e.clientX - L.x) * k; L.pitch += (e.clientY - L.y) * k;
L.pitch = Math.max(-1.5, Math.min(1.5, L.pitch));
camera.quaternion.setFromEuler(eu.set(L.pitch, L.yaw, 0, 'YXZ'));

4. 列車の道と、大きさの育て方

列車(createTrain(THREE, params))は、React に依存しません。道は、3 次ベジェ曲線と、まっすぐな線路をつないだ CurvePath です。

const hd = new THREE.Vector3(P.heading[0], 0, P.heading[1]).normalize();   // 止まるときの向き
const runway = Math.max(P.approach, total * P.size + 20);                   // 止まる手前のまっすぐな線路
const entry = stopP.clone().addScaledVector(hd, -runway);
const p0 = camPos.clone().addScaledVector(dir.clone().normalize(), P.startDist);   // 渦の方向、60 m 先
const c1 = p0.clone().addScaledVector(hd, -P.swing1), c2 = entry.clone().addScaledVector(hd, -P.swing2);
curve = new THREE.CurvePath();
curve.add(new THREE.CubicBezierCurve3(p0, c1, c2, entry));
curve.add(new THREE.LineCurve3(entry, stopP));
  • 出発点から、止まる向きと逆へ走り出し、大きく旋回して、線路に入ります。線路の長さは、車列より長くします
  • 後ろの車両は、同じ道を、先頭から車両の長さぶん遅れてたどります。出発点より手前(u < 0)は、出発の向きに延ばした直線です
  • 車両の向きは、Object3D.lookAt(進む点) です(車両は +Z が前)

進み方は、時間の割合 t(0〜1)から、道のりの割合 u を出す関数です。最初の accel(0.25)でゆっくり速度に乗り、最後の brake(0.4)で長くブレーキをかけます。残りは一定の速さで、全体は 45 秒です。

const vp = 1 / (a / 2 + c + b / 2);   // いちばん速いときの速さ
if (t < a) s = vp * t * t / (2 * a);
else if (t < a + c) s = vp * (a / 2 + (t - a));
else { const x = t - a - c; s = vp * (a / 2 + c + x - x * x / (2 * b)); }

大きさは、道のり全体で育てる

出てくる所は、カメラから 60 m ほどで、遠くの空の渦から来るようには見えません。そこで、豆粒の大きさ(s0 = 0.03)で出して、走りながら本来の大きさ(size)に育てます。

const g = Math.min(1, u / Math.max(0.05, P.growEnd));      // growEnd = 0.9
const sc = Math.pow(P.s0, 1 - g) * Math.pow(P.size, g);    // 指数で補間(体感の大きさの変化が、一定に近い)
// 車両の間隔も、同じ倍率で縮める
const ui = u - offs[i] * sc / len;
  • 線形ではなく、指数(対数の補間)で育てます。大きさの倍率が、一定の割合で増えます
  • 育てる範囲は、道のりの全体(0.9 まで)です。旋回の所までに、ほぼ育て終わる作りにすると、折り返しで急に巨大化して、不自然になりました
  • 高さは、ground を「停車したときの車輪の下の高さ」とし、車両の中心を yc = ground + carH / 2 * size に置きます。size を変えても、足元の高さは変わりません

遠いときは、空に溶ける

車体の色を、先頭までの距離で変えます。遠い(55 m 以上)ときは空に近い暗色 0x04060b、近い(14 m 以下)ときは本来の色 0x27303f です。窓の灯りだけが、遠くでは、小さな光の点(加算合成の Points)として見えます。

5. 客車

客車は、BoxGeometry の車体と屋根に、窓の板(PlaneGeometry、両側に 7 つずつ)と、窓の位置の光の点を足したものです。材質は、光に左右されない MeshBasicMaterial です。煙突は付けていません。

光の点の絵は、キャンバスの放射グラデーションで作ります。点の大きさは 3 + 13 * 倍率(画面上の px、sizeAttenuation: false)で、豆粒のときは小さく、近づくと大きくなります。

6. 機関車(GLB)

先頭の車両は、GLB の機関車に差し替えます。

  • 読み込みは、GLTFLoader に DRACOLoader(setDecoderConfig({ type: 'js' })、setDecoderPath('/tool/draco/'))をつなぎます。読めなければ、箱の車両のままで動きます
  • 大きさは、Box3 で測って、全長が locoLen(14 m)になるように拡大します。前は +X なので、rotation.y = -π/2 の入れ物に入れて、車両の前(+Z)に合わせます。底は、箱の車体の底にそろえます
  • 材質は、MeshBasicMaterial(元のテクスチャを引き継ぐ)に作り直します。色は、遠いと空の色、近いと白(× locoLight = 0.6)です。名前に emission を含む材質(前照灯)は、そのまま明るくします
  • 先頭の光の点は、前照灯のメッシュの位置に置きます

車輪の回転

付属のアニメーションは、車輪が 2 秒で 1 回転します(timeScale = 1 で、角速度 π rad/s)。進む速さに合わせて、timeScale を決めます。

const v = (st.u - u0) * len / dt / (locoK * Math.max(1e-3, st.scale));   // モデルの大きさに換算した速さ
mixer.timeScale = Math.min(12, v / wheelR / Math.PI);                    // 角速度 v / 半径 を、π で割る
mixer.update(dt);

wheelR は、車輪(DWheel01_16)の軸の高さです。止まると、速さが 0 になって、車輪も止まります。

7. 効果音(Web Audio)

音は、galaxyTrainSound.ts に分けました。ファイルを先に fetch しておき、最初のクリックの中で AudioContext を作ってデコードします(ブラウザの自動再生の制限のため)。

場面音鳴らし方
クリック汽笛 1playbackRate = 0.7(再生速度を下げて、低くする)
走行中シュッポシュッポloop = true の AudioBufferSourceNode。音量とテンポを毎フレーム変える
停車汽笛 21 回だけ。同時に、走行音を 0.8 秒で消す
const near = smooth(0.05, 0.9, st.u);                      // 道のりの 5% → 90% で、音量が上がる
const vol = P.volChug * near * (0.25 + 0.75 * st.speed);   // ブレーキでは、速さに合わせて小さく
chugGain.gain.setTargetAtTime(vol, ctx.currentTime, 0.15);
chugSrc.playbackRate.setTargetAtTime(0.7 + 0.3 * st.speed, ctx.currentTime, 0.2);   // ゆっくりに

st.speed は、progress の傾きを、いちばん速いときの値で割った 0〜1 です。HTMLAudio の loop は、つなぎ目に隙間が出るので、AudioBufferSourceNode にしています。

クリックから列車が出るまでは、delay(既定 3 秒)の間をおきます。汽笛を先に鳴らして、間を作るためです。この間の二重クリックは、無視します。

8. 半透明のフェードイン

出始めは、半透明で背景に溶け込み、fadeIn(既定 7 秒)でだんだんはっきりします。

st.fade = P.fadeIn > 0 ? smooth(0, P.fadeIn / P.seconds, st.t) : 1;
for (const m of fadeList) {
  const tr = st.fade < 1 || m.userData.t;                          // 元の transparent を覚えておく
  if (m.transparent !== tr) { m.transparent = tr; m.needsUpdate = true; }
  m.opacity = m.userData.o * st.fade;
}
glowMat.opacity = (0.3 + 0.65 * smooth(10, 45, st.dist)) * st.fade;

車体・屋根・窓・機関車の材質を fadeList に入れ、元の不透明度と transparent を覚えておきます。transparent は、フェード中だけ有効にして、終わったら元に戻します。透明な材質は描く順番の乱れが出やすいので、その時間を短くするためです。

9. 駅名標

停車すると、列車の手前に駅名標が、3 秒かけて現れます。板の絵は、キャンバス(1024 × 640)に、ふりがな・駅名・ローマ字・左右の隣の駅・いちばん下にモデルのクレジットを描きます。文字は、幅に収まるまで、フォントを小さくしながら測ります(measureText)。

板は、BoxGeometry(前の面だけテクスチャ)と、2 本の脚の円柱です。向きは、列車の進む向き (hx, hz) と平行で、カメラ側の面を向けます。

let r = Math.atan2(-hz, hx);                 // 進む向きの横を向く
const nx = Math.sin(r), nz = Math.cos(r);    // 板の正面の向き
if (nx * (cam.x - pos.x) + nz * (cam.z - pos.z) < 0) r += Math.PI;   // 反対側なら、裏返す
root.rotation.y = r;

文面は、weather.json の sign(駅名・隣の駅・クレジットの行)で変えられます。夜に真っ白だと浮くので、材質の色に light(0.75)を掛けています。

10. 間違いの記録

  • リロードすると列車の中になる: 走っていないのに、列車が見えていました。アプリの別の処理が、シーンの全物体の visible を true に戻していたためです。root.visible を Object.defineProperty の getter にして、idle の間は、常に false を返すようにしました
  • size を上げると、車輪が地面に沈む: 車両の中心の高さを size と無関係にしていたためです。yc = ground + carH / 2 * size にしました
  • 出だしの豆粒が、size に比例して大きくなる: 倍率を size の割合で決めていたためです。s0^(1-g) * size^g にして、出だしは s0 のままにしました
  • 旋回の所で巨大化して見える: 育てる範囲を、旋回の手前までにしたためです。道のり全体(growEnd = 0.9)に戻しました
  • 値の意味を変えたら、調整した見た目がずれる: ground の意味を変えたときに、前の見た目になる値(-4.2)を、新しい既定にしました。意味を変えるときは、見た目が変わらない既定値も、一緒に決めます

11. 制約

  • 渦の位置は、天文として正確ではありません。昼や、渦が地平線の下にあるときは、見えず、反応しません
  • 渦の中を、星座線・星座名・星の名前は通ります(マスクなし)
  • VR(Quest)での再生と見え方は、確認していません。スマホ(タッチ)での見回しも、対応していません
  • 客車は、箱のままです
  • 音量は、アプリ全体の SE 音量の設定とは、つながっていません

関連

  • #205 寝息を合成して WAV に書き出す
  • Sky Today(今日の空)