[Astro] #211 WebXR銀河鉄道: 昼への切り替え・トウモロコシ畑・昼の駅(巨大な振り子時計とホーム)をコードで作る
はじめに
Sky Today の石炭袋から乗る銀河鉄道に、出発後の場面を足した実装記録です。外部の素材は使わず、見た目はすべてコードで生成しています。
- 砂丘と、旋回しながら落ちる鳥(着いた場所に、波紋と粒)
- アルビレオの観測所: 遺跡の石と、左右の窓に 1 本ずつ立つ巨大な円柱の塔
- 昼への切り替え: 白いフラッシュのあいだに、夜空が昼の青空と雲に変わる
- トウモロコシ畑: 1 本ずつ作った株を、大量に並べる
- 昼の駅: 畑のなかを減速して止まり、骨組みだけの屋根の頭上に、巨大な六角形の時計が浮かぶ。振り子は目の前で揺れ、振り切るたびにカチッと鳴る
- ホームに降りて歩き、光の円で座席に戻る
スクリーンショット
砂丘と、落ちる鳥
アルビレオの観測所
昼の青空とトウモロコシ畑
ホームに降りて見た列車
1. 場面の時間割
場面は、出発ボタンを押した時刻 departAt からの秒で決めています。場面どうしは独立していて、xxxAt の値を変えるだけで、間に別の場面を入れられます。
| 場面 | 値 | 秒(出発から) |
|---|---|---|
| 砂丘と鳥 | duneAt | 20 〜 約 140 |
| アルビレオの観測所 | ruinsAt / ruinsEnd | 165 〜 約 285 |
| 昼への切り替え(フラッシュ) | dayAt | 310 |
| トウモロコシ畑 → 昼の駅に停車 | 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 / 客車の床より下へ m | 4 / 3 |
duneBirdX / duneBirdXFar | 線路から、鳥が落ちる場所までの距離 m | 14 / 75 |
duneBirdSize | 鳥の大きさ m | 2.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)を、暗転と同じ仕組みで白にする。dayFlashIn1.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(今日の空)