[Astro] #211 WebXR銀河鉄道: 昼への切り替え・トウモロコシ畑・昼の駅(巨大な振り子時計とホーム)をコードで作る

[Astro] #211 WebXR銀河鉄道: 昼への切り替え・トウモロコシ畑・昼の駅(巨大な振り子時計とホーム)をコードで作る

はじめに

Sky Today の石炭袋から乗る銀河鉄道に、出発後の場面を足した実装記録です。外部の素材は使わず、見た目はすべてコードで生成しています。

  • 砂丘と、旋回しながら落ちる鳥(着いた場所に、波紋と粒)
  • アルビレオの観測所: 遺跡の石と、左右の窓に 1 本ずつ立つ巨大な円柱の塔
  • 昼への切り替え: 白いフラッシュのあいだに、夜空が昼の青空と雲に変わる
  • トウモロコシ畑: 1 本ずつ作った株を、大量に並べる
  • 昼の駅: 畑のなかを減速して止まり、骨組みだけの屋根の頭上に、巨大な六角形の時計が浮かぶ。振り子は目の前で揺れ、振り切るたびにカチッと鳴る
  • ホームに降りて歩き、光の円で座席に戻る

スクリーンショット

砂丘と、落ちる鳥

砂丘と、落ちる鳥

アルビレオの観測所

アルビレオの観測所

昼の青空とトウモロコシ畑

窓の外に、昼の青空とトウモロコシ畑が広がっている

ホームに降りて見た列車

板張りのホームから、止まった列車を見ている

1. 場面の時間割

場面は、出発ボタンを押した時刻 departAt からの秒で決めています。場面どうしは独立していて、xxxAt の値を変えるだけで、間に別の場面を入れられます。

場面値秒(出発から)
砂丘と鳥duneAt20 〜 約 140
アルビレオの観測所ruinsAt / ruinsEnd165 〜 約 285
昼への切り替え(フラッシュ)dayAt310
トウモロコシ畑 → 昼の駅に停車cornBrakeAt / cornStopAt昼から 40 秒で減速、60 秒で停止

場面の時刻は departAt からの秒だけで決まります。場面の途中から始めるときは、departAt を、その場面の頭に来る値に置き換えるだけで、通しで走ったときと同じ状態になります。

2. 砂丘と、落ちる鳥

出発から duneAt(20 秒)たつと、窓の外に、光る砂の丘がゆっくり現れます。そこへ、白い鳥が、旋回しながら降り、砂に着くと光って消えます。原作の、鳥を捕る人の場面を、窓から見る形にしたものです。

  • 丘: 320 m 四方、160 分割の面。起伏の式 duneH(x, p) を、丘と鳥の両方で使うので、鳥が着く高さは、砂の表面と合う
  • 流れ: p(客車の前後の位置 + 流れた距離)で、丘は後ろへ流れる。鳥の着く場所も、同じ速さで流れる(鳥が丘から離れない)
  • 砂の陰: 法線を、起伏の式の差分から出して、光の向きで陰をつける。きらめきは、小さな点で描く(四角いタイルに見えない)
  • 鳥: InstancedMesh。着く場所の p を、周回ごとに乱数(hash)で決め、真上から、らせんで降りる。丘の裏に入ると、丘が書いた深度で隠れる
  • 数: 出始めは少なく、duneBirdRamp(40 秒)かけて duneBirdN(60 羽)まで増える

鳥の向きは、経路の微分

最初は、鳥の向きを「旋回の接線(水平)+ 決めた角度の傾き」で別々に作ったので、経路と向きが一致せず、振り回されて見えました。鼻先を、鳥が実際に進む向き(経路の微分)にしました。

  • 経路は、円すいのらせん(半径も高さも、直線的に減る)。着く直前に、速さが 0 になって向きが決まらない問題を避けるため
  • 水平の向きは、旋回の進む向き。下向きの角度は、経路の傾き(降りる速さ ÷ 水平の速さ)に、duneBirdPitch(20°)を足したもの
  • 旋回の内側へ、duneBirdBank(20°)傾ける
  • 着いたあとは、角度を保ったまま、フェードアウト

着いたときの波紋と粒

水面に水滴が落ちたような、輪の波紋(2 枚、少しずらす)と、外へ飛んで落ちる粒(8 個)を出します。位置も時刻も、GPU の中で、鳥と同じ式から出します(毎フレームの CPU の計算は、値を渡すだけ)。鳥が次の周回に移っても、前の場所の波紋は、最後まで続きます(「いちばん新しく着いた場所」を、式で出すため)。

鳥 60 羽で、輪 120 枚 + 粒 480 個です。丘の面より軽く済みます。

終わり

出発から duneEnd(100 秒)後に、フェードアウトを始め、duneOutSec(20 秒)で消えます。砂丘が現れてから、合計 2 分です。

値内容既定
duneAmp / duneDrop丘の高さ m / 客車の床より下へ m4 / 3
duneBirdX / duneBirdXFar線路から、鳥が落ちる場所までの距離 m14 / 75
duneBirdSize鳥の大きさ m2.2
duneBirdPitch / duneBirdBank経路に足す下向きの角度 / 内側への傾き(度)20 / 20
duneRippleR / duneRippleSec波紋の半径 m / 秒4 / 1.6

これらは毎フレーム読むので、コンソールで変えると、その場で変わります。

3. アルビレオの観測所

遺跡の石(箱と円柱)を、1 つの InstancedMesh ずつで大量に並べ、巨大な円柱の塔を左右の窓に 1 本ずつ置きました。石は、列車の進行と逆向きに流します。

  • 石の置き方: 倒れた円柱は、回転後の高さの半分だけ地面から浮かせる。立った高さで置くと、宙に浮く
  • 流し方: z = mod(基準位置 - 流れた距離 + L/2, L) - L/2 で、一定の長さで循環させる
  • 塔: LatheGeometry で、裾の広がり・段・丸い肩を作り、石の目地はシェーダーで描く

見える範囲の上限

塔の上半分が円形に切れて見えませんでした。原因は、宇宙の場面で使っている黒い球(半径 95、BackSide)でした。球の外は、何も描かれません。

球を大きくすると、半径 98 付近にある Sky Today の星座線が球の外に出て、見えてしまいます。そのため、球は 95 のまま、切れ目を暗闇で隠すことにしました。

// 遠くへ行くほど黒へ。cutNear〜cutFar(既定 50〜90)で、球の縁の手前に消える
col = mix(col, vec3(0.0), smoothstep(uCutN, uCutF, dist));

地平線の下のふた(半径 90)と地面の円(半径 98)も、昼や観測所のあいだは非表示にします。

4. 昼への切り替え

白いフラッシュで、夜空から昼の青空へ切り替えます。

  • フラッシュ: カメラに付いた球(veil)を、暗転と同じ仕組みで白にする。dayFlashIn 1.2 秒で白くなり、0.3 秒そのまま、2.5 秒で戻る
  • 切り替えのタイミング: 真っ白のあいだに行う。見えないので、切れ目が分からない
  • 空: 黒い球を ShaderMaterial にして、天頂 #3f9fd0、地平線 #a4d6e8 のグラデーションと雲を描く

雲の継ぎ目

最初は、雲を sin のハッシュによる値ノイズで作りました。すると、実機(GPU)で格子状の継ぎ目が見えました。ノイズは、実装によって結果が違うので、使わないことにしました。

代わりに、canvas に雲を描いて、テクスチャにしました。

  • 2048×512 に、楕円の雲を 28 個、放射グラデーションで描く
  • 端をまたぐ雲は、左右にずらした複製も描く(3 枚分)
  • RepeatWrapping、ミップマップなし、NoColorSpace
  • 方位 az / 2π + 0.5 + 時間 × 0.002 と、高さ asin(h) / 1.05 で参照する

これで、継ぎ目は出ませんでした。

5. トウモロコシ畑

草原と同じ作り(板を大量に立てる方式)では、葉と黄色い花に見えました。そこで、1 本ぶんのトウモロコシのメッシュを作り、InstancedBufferGeometry で並べました。

1 株の構成は、次のとおりです。

  • 茎: 細い十字の板
  • 葉: 7 枚。各 5 区間の帯で、根元は上向き、先は垂れる曲線にする。幅は途中が最大で、先で細くなる
  • 雄穂: 先端の細い房を 6 本
  • 実: 皮に包まれた黄緑の房を斜めに 1 つ

頂点に「部位」の属性(aKind)と、根元から先までの位置(aT)を持たせ、シェーダーで色を決めます。葉は uBase → uTip、雄穂は uTas、実は uEar です。遠くは、空の地平線の色(uHaze)に溶かします。

c = mix(c, uHaze, smoothstep(uFog.x, uFog.y, length(mv.xyz)));

畑が続くしくみ

畑は、客車の前後を一定の長さ cornLen(120 m)で循環させます。端で株が急に現れるのを、次の 2 つで隠します。

  • 端から cornSink(14 m)の範囲では、株を地面の下へ沈める(smoothstep で高さを引く)
  • 長さを 60 m → 80 m → 120 m に伸ばす(密度が同じなら、株数も増える)

VR(Quest)でも、重くはありませんでした。

走って、止まる

昼の始まりから cornBrakeAt 秒までは一定の速さ v、そのあと cornStopAt 秒の停止まで、一定の減速度で止まります。

const Tb = cornStopAt - cornBrakeAt;               // 減速の時間
const s  = Math.min(Math.max(0, f - cornBrakeAt), Tb);
const dist  = v * Math.min(f, cornBrakeAt) + v * (s - s * s / (2 * Tb));  // 進んだ距離
const total = v * cornBrakeAt + v * Tb / 2;                                // 止まるまでの距離
// 駅までの残り = total - dist

駅は、この「残りの距離」だけ前方に置きます。0 になったとき、ホームの中心が客車の中心に来ます。音と揺れの大きさも、速さの比(1 → 0)に合わせます。

6. 昼の駅

部品は galaxyStation2.ts にまとめました。ホームと骨組みは、頂点に色を持つ、1 つの合成メッシュです。

ホームと屋根

  • 床: 板張り。板の継ぎ目は、シェーダーで描く(fract(x / 0.4) と、板ごとにずらした fract(z / 2.4))
  • 足場: 床の下から地面まで届く柱と、横木
  • 屋根: 板は張らず、たる木・柱の上の梁・筋かいだけの骨組み(空が見える)

左右反転のバグ

左側のホームだけ、屋根が反対に傾いていました。左右を反転する補助関数が、[x0, x1] の範囲を [-x1, -x0] に返すので、傾きの「始まり」と「終わり」が入れ替わっていたのが原因です。範囲には使えますが、始点と終点が意味を持つ点には使えません。点には s * x(s は ±1)を使うように直しました。

時計と振り子

  • 時計: 屋根の遥か頭上(高さ 16 m)に浮かぶ、六角形。半径 2.5 m、ローマ数字、金色の縁。canvas に描いたテクスチャを、CircleGeometry(R, 6) に貼る。針は現在時刻(st2Time で固定もできる)
  • 振り子: 時計の下から、棒を下ろし、おもり(円盤)を、窓の目の前の高さ(床から約 2 m)に置く。列車の進行方向に、6 秒周期で揺らす
  • 位置: 屋根の骨組みに当たらないよう、振り子の通る範囲のたる木を抜く。時計と振り子は、列車から外へ離す(st2ClockX)
  • 位置は毎フレーム読み直すので、コンソールで値を変えると、その場で反映される

振り切ったときの効果音

振り子の向きが変わる瞬間(cos の符号が変わるとき)に、カチッと鳴らします。音量は、カメラからおもりまでの距離で、連続して変えます。

const k = clamp(1 - (d - near) / (far - near), 0, 1);   // near=3m で最大、far=40m で無音
click(vol * k * k);

近づくと大きく、離れると小さくなります。再生は、列車の音の部品とは別の AudioContext で行います。

7. ホームに降りて歩く

白鳥の駅で作った仕組みを、そのまま使いました。ボタンは「ホームに降りる」と「降りる(TOP へ)」です。昼の駅は最後の駅なので、「出発」は出しません。

  • 歩く: 客車(とそれに付く駅)を、世界ごと、歩く向きの反対へずらす。カメラは動かさない
  • 戻る光の円: 駅の座標の枠 st2Frame(客車の位置・向き・ホームの床の高さ。大きさは 1)に置く。昼の駅が出ているあいだは、stationGroup() がこれを返す
  • 歩ける範囲: 横は白鳥の駅と同じ。前後は、ホームの端(st2Len / 2 - 1)まで

客車は、大きさの倍率を持つので、倍率のある客車の子に、そのまま円を置くと、位置がずれます。倍率のない枠を別に作ったのは、そのためです。

8. 調整の値

__train.P.field.xxx で、コンソールから変えられます。

値内容既定
cornSpeed畑を走る速さ(m/秒)6
cornBrakeAt / cornStopAt減速を始める秒 / 停止する秒(昼から)40 / 60
cornLen / cornCount畑の長さ(m) / 株数120 / 6700
cornBase / cornTip / cornEar / cornTassel葉の根元 / 先 / 実 / 雄穂の色—
st2ClockR / st2ClockY時計の半径 / 高さ2.5 / 16
st2ClockX / st2PendDx時計の外への距離 / 振り子だけのずらし6.6 / 時計半径×0.36
st2Swing / st2Period振り子の振れ幅(ラジアン)/ 周期(秒)0.3 / 6
st2SeNear / st2SeFar / st2SeVol効果音が最大になる距離 / 無音になる距離 / 音量3 / 40 / 0.5
st2Wood / st2Dark / st2Floor木の色—
st2EndSec停止から自動で TOP に戻るまでの秒(-1 で戻らない)-1

時計と振り子の位置(st2ClockX st2ClockZ st2PendDx)は、毎フレーム読み直します。畑や駅の形に関わる値(長さ・株数・時計の大きさなど)は、生成時に読むので、変えたら再読み込みします。

9. つまずいた点

  • 見える範囲の上限: 塔の上が円形に切れたのは、宇宙の場面の黒い球(半径 95)が原因でした。球を大きくすると、星座線が見えてしまいます(3 章)。球はそのままにして、暗闇で隠しました
  • 雲の継ぎ目: 値ノイズの雲は、GPU によって格子状の継ぎ目が見えました。canvas に描いたテクスチャに替えました
  • 畑の端で株が現れる: 端で地面へ沈め、長さを伸ばしました。長さを伸ばすと、同じ密度なら株数も増えます
  • 左右反転で屋根が逆に傾く: 範囲用の反転関数を、点に使っていました(6 章)
  • 値を変えても反映されない: 生成時にだけ読む値を、あとからコンソールで変えても、変わりません。位置のように、動かしたい値は毎フレーム読み直します
  • 客車の子に置くものは、客車の倍率を受ける: 駅の座標の枠は、倍率を持たない別の枠にしました(7 章)

10. 制約

  • 昼の駅は、1 か所です。出発のボタンは出さず、ホームに降りるか、TOP に戻ります
  • 時計の針は、現在時刻です(st2Time で固定できます)
  • 効果音は、振り切ったときだけで、時計の針の音は鳴りません
  • 見た目はすべて、コードで生成しています。木の色は、明るめです

クレジット

  • 効果音: 効果音ラボ(振り子の音)

アプリの CREDIT ボタンにも、同じ内容を出します。

関連

  • Sky Today(今日の空)