[Astro] #208 星空の列車で旅をする(離陸・草原・光の波・白い鳥・トンネル・駅)
はじめに
#207 で、列車に乗り込んで空が流れるところまで作りました。今回は、そのあとの旅の実装記録です。場面は次の順に進みます。
- 野原を走り、汽笛とともに飛び立つ(地面が沈んで、街の灯りだけが残る)
- 宇宙に入り、遠くの星がゆっくり流れる
- 半透明の草原が現れ、リンドウが咲く
- 光の波の海に変わり、白い鳥が 1 羽ずつ増えて群れになる
- 光の回廊(トンネル)を抜ける
- 白鳥の駅に着いて、止まる。「降りる」ボタンが出る
スクリーンショット
草原とリンドウの花
光の波と白い鳥
白鳥のステーション
1. 場面は、経過時間の関数にする
場面の切り替えに、状態機械やイベントは使っていません。乗車からの経過秒 clock.current だけを入れて、毎フレーム、各場面に「どれだけ見せるか」を渡します。
const S0 = lift + liftSec + spaceAfter; // 宇宙に入った時刻(既定 56 秒)
const tm = clock.current - S0; // 宇宙に入ってからの秒
train.meadow(...); // 草原
train.waves(cp, wIn * wOut, tw * waveSpeed, tw, tw - birdAt);
train.tunnel(cp, tIn * tOut, ...);
train.station(cp, smooth01(stationAt, stationAt + fadeSec, tm), stD);
各場面は At(始まり)・FadeSec(現れる秒)・End(終わり。0 なら終わらない)の 3 つで決まります。smooth01(a, b, t) で 0〜1 に直し、アルファや位置に掛けます。
この形にした理由は、途中の場面へ飛べるからです。調整のたびに最初から見ると時間が足りないので、?b で場面を指定できます。
| URL | 始まる場面 |
|---|---|
?b | 乗車から |
?b=field lift space meadow flower | その場面から |
?b=wave bird tunnel station | 光の波 / 鳥 / 回廊 / 駅から |
?b=stop | 駅に着いて止まったところから |
?b=75 | 乗ってから 75 秒の場面から |
場面の開始秒は chapters の表にあり、weather.json の chapters で上書きできます。
既定のタイムライン(宇宙に入ってからの秒)
| 場面 | 開始 | 終わり |
|---|---|---|
| 草原 | 8 | 120(12 秒で消える) |
| リンドウ | 28 | 咲く 30 秒 |
| 光の波 | 140 | 290 |
| 白い鳥 | 波の 15 秒後 | 波と一緒 |
| 光の回廊 | 315 | 350 |
| 駅 | 340 | 止まったまま |
2. 草原とリンドウ(InstancedBufferGeometry)
草は 10,000 本を 1 回の描画で出します。InstancedBufferGeometry に、1 本分の細い三角形と、インスタンスごとの属性 aI(位置と乱数)を持たせます。
const g = new THREE.InstancedBufferGeometry();
g.setAttribute('position', base.getAttribute('position'));
g.setAttribute('aI', new THREE.InstancedBufferAttribute(at, 4));
g.instanceCount = n;
- 車と一緒に流れるように、頂点シェーダーで
uShiftを足し、modで範囲を巻き戻します。流れる向きはF.dir >= 0 ? 1 : -1で、車の座標系の z 方向です - 草は全部、同じ向きに倒します(
meadowLeanDeg)。原作の向きに合わせて、進行方向と逆に倒れるよう 270 度にしました。風の揺れはありません - 色は、根元(
meadowBase)から先端(meadowTip)へ。遠くは、背景色に溶けるようにアルファを落とします(meadowFogNear/meadowFogFar) - リンドウは、草のうち
flowerRatio(2%)だけが、flowerSec秒かけて下から伸びて咲きます
草の近くを歩くことはないので、花は細かく作っていません。近寄ると粗く見えますが、背景に合わせて暗い色にしているので、目立ちません。
3. 光の波
車の子として、400×400 の板(200×200 分割)を置いています。
模様(フラグメント)
板のローカル座標 x から、縞の位置 u = |x| / uScale を作り、揺らします。
float w = u + uAmp * (sin(...) + sin(...)); // うねり
float s = 0.5 + 0.5 * sin(w * 6.2832 / uLen - uTime);
float crest = pow(s, 10.0); // 細い光の筋
float body = pow(s, 2.6); // 筋の周りの広い光
(式は、考え方を簡略にしたものです。)筋と広い光を、waveCol(水色)と waveDeep(濃い青)で塗ります。
高さ(頂点)
板の頂点を持ち上げて、波の起伏を作ります。長さは waveHillLen、高さは waveHeight(決定値 3〜4)です。平面のうねりだけだと、窓から見て「模様が動いているだけ」に見えましたが、頂点を持ち上げると、海を航海している見え方になりました。
地平線の光
地平線のあたりに、BackSide の球(半径 94、加算ブレンド)をカメラに付けて、青い光を置いています。宇宙の黒い球(半径 95)の内側に収まります。窓から見えるのは、地平線の −10〜+2 度くらいなので、光はその範囲に集めています。
4. 白い鳥
鳥は、三角形 3 枚(胴と左右の翼)の InstancedBufferGeometry です。羽ばたきは、頂点シェーダーで翼の頂点だけを上下に動かします。
float flap = sin(uTime * uFlap * 6.2832 + aI.w * 6.2832);
p.y += flap * wing * 0.35; // wing は翼の頂点だけ 1
(式は、考え方を簡略にしたものです。)
増え方
1 羽 → 2 羽 → 4 羽 …と増やすために、インスタンスごとに「世代」L を決めます。L は、birdStart * birdGrow^L > i となる最小の整数です。世代 L の鳥は、birdStep * L 秒後に現れます。
float born = smoothstep(0.0, uFade, tb - L * uStep);
birdMax で上限を決めます。決定値は 300 羽で、最後は大群になります。
見え方
- 板ポリなので、近いと薄さが見えます。距離 70〜92 でフェードさせ、鳥を遠くに飛ばしています(
birdX45〜birdXFar75) - 左右の窓に交互に出します。ランダムにすると、片側の窓にしか出ない時間がありました
birdGroup: 1で、群れにせず、まばらに飛ばします。birdFlockを増やすと、群れになります- 向きは
uFwdで反転します(進行方向に鼻を向ける)
5. 光の回廊(トンネル)
半径 tunnelR(8m)の円筒に、光る輪と灯りを tunnelGap(9m)おきに並べて、後ろへ流します(tunnelSpeed 14m/秒)。鳥と同じインスタンス描画で、輪は uShift を mod で巻き戻します。線路がないので、特定の景色にしていません。輪は青(tunnelRing)、灯りは暖かい色(tunnelLamp)です。
6. 駅
駅は、列車の窓の外にだけ出る「箱」です。プログラムで作っています。
構成
| 部品 | 作り |
|---|---|
| 柱 | 縞の BoxGeometry を、N * 2 本、インスタンス描画 |
| 窓 | 柱のあいだに、格子の枠だけを描いた板 |
| 壁 | 左右に 1 枚ずつ。窓の位置に、穴(discard)を開ける |
| 床・天井 | 床は山形の模様、天井は格子の骨組みだけ |
| 駅名標 | createTrainSign を 2 つ。列車に向けて、左右に置く |
駅名標は、#206 の看板の作り(createTrainSign)を、そのまま呼んでいます。root.clone() はマテリアルを共有するので、2 枚のフェードを、1 か所の不透明度で動かせます。
時間関数(到着・停車・発車)
駅は、近づいてゆっくり止まります。距離 stD と速さの係数 stSf(0〜1)を、時間から決めます。
const u = Math.max(0, tm - stationAt);
if (u < T1) { const r = u / T1; stD = D1 * (1 - (1 - r) * (1 - r)); stSf = 1 - r; } // 近づく(減速)
else if (u < T1 + T2 || hold) { stD = D1; stSf = 0; } // 停車
else if (u < T1 + T2 + T3) { const r = (u - T1 - T2) / T3; stD = D1 + D3 * r * r; stSf = r; } // 発車(加速)
else { stD = D1 + D3 + (2 * D3 / T3) * (u - T1 - T2 - T3); stSf = 1; } // 走り去る
stSf を、走行音の速さと、車体の揺れ(0.3 + 0.7 * stSf)に掛けます。止まると、音が止まり、揺れもほぼなくなります。
駅の位置は、車の x/z に付けた上で、内側のグループを off = sgn * (dist - ap) だけずらしています。ap(150m)は、駅が見えない遠くからの出発点です。
止まったままにする(stationHold)
通りすぎる駅は、車内から見ても、よく見えません。そこで、stationHold: 1 にすると、着いたあとは発車せずに止まったままにします。止まっている間だけ、「降りる」ボタンを出しっぱなしにしています(普段は Esc か、VR で真下を見たときだけ)。
stHold.current = inSpace && stationAt > 0 && hold > 0 && tm - stationAt > stationArriveSec;
exitMesh.visible = mode !== 'out' && fade < 0.6 && (exitShow > 0 || stHold.current || (xr && lookDown));
壁・床・柱の見た目
最初は、壁を一色のシェーダーで塗っていて、平らに見えました。シェーダーの柄(縦のひだ・継ぎ目・足もとの光)を足してから、画像のテクスチャを重ねる形にしました。
画像は public/assets/textures/ に置きます。無い、または読み込みに失敗したときは、シェーダーの柄のままです。
const tex = new THREE.TextureLoader().load(url, () => { mesh.material.uniforms.uTexOn.value = 1; }, undefined, () => {});
tex.wrapS = tex.wrapT = THREE.MirroredRepeatWrapping; // つなぎ目を目立たせない
tex.colorSpace = THREE.NoColorSpace; // ShaderMaterial は、そのまま出力する
壁は、uv = vec2(vP.z / (S * 1.78), (vP.y + 0.5) / S) で貼り、床は vP.xz / S、柱は vec2(vP.x + vP.z, vP.y) / S で貼ります。S は、画像 1 枚の大きさ(m)で、stationWallTexScale などです。
明るさは Bright で下げます。暗くすると色が青くなったのは、足している光(vec3(0.03, 0.12, 0.15) * gl)が一定のままだったからで、stationWallTexGlow で掛け合わせるようにしました(既定 0)。
壁に窓の穴を開ける
窓の板の向こうは、壁で塞がっているので、宇宙が見えません。壁のフラグメントシェーダーで、窓の位置を discard して、穴にしています。
float dz = abs(mod(vP.z - zc0 + gap * 0.5, gap) - gap * 0.5);
if (dz < openW && abs(vP.y - openY) < openH) discard;
窓の板は、格子の線だけを描いて、残りを discard します。天井も、青い四角を discard して、骨組みだけにしました。穴からは、宇宙の黒い球と星が見えます。
7. 設定キー(weather.json の skyToday.coalsack.train.field)
| キー | 意味 |
|---|---|
lift / liftSec | 離陸の開始秒 / 地面が沈む秒数 |
lights / lightHold | 街の灯りの数 / 残る割合 |
meadowAt / meadowEnd | 草原の開始と終わり(0 で終わらない) |
flowerRatio / flowerSec | リンドウの割合 / 咲く秒数 |
waveAt / waveEnd | 光の波の開始と終わり |
waveHeight / waveHillLen | 波の高さ / 起伏の長さ |
birdAt / birdStep / birdStart / birdGrow / birdMax | 鳥の開始 / 世代の間隔 / 最初の数 / 増える倍率 / 上限 |
birdX / birdXFar / birdSize | 鳥の距離 / 遠い側の距離 / 大きさ |
tunnelAt / tunnelEnd | 回廊の開始と終わり(tunnelAt: 0 で出さない) |
stationAt / stationArriveSec / stationStopSec / stationLeaveSec | 駅の開始 / 到着 / 停車 / 発車の秒数 |
stationHold | 1 で、着いたら止まったまま |
stationSignX / Y / Z / Scale | 駅名標の位置と大きさ |
stationOpen / stationWindow / stationCeiling | 壁の穴 / 窓の格子 / 天井の骨組み(0 で消す) |
stationWallTex / FloorTex / PillarTex と …Scale …Bright …Tint | テクスチャのパスと、大きさ・明るさ・色 |
8. つまずいた点
- 頂点で計算した距離が、すべて同じになる: 巨大な板の頂点ごとに距離を計算して、フラグメントへ補間すると、フェードがかからなくなりました。頂点では視点座標(
vV)だけを渡して、length(vV)をフラグメントで計算します - 色が明るすぎる / 暗すぎる:
ShaderMaterialは、色を変換せずに出力します。THREE.Colorは線形なので、シェーダーの中でpow(c, vec3(0.4545))を掛けて、sRGB に直します。画像のテクスチャはNoColorSpaceで読み、そのまま使います - 加算ブレンドで暗くなる: 加算ブレンドは、アルファを掛けた色を足します。暗い色を足したいときは、色かアルファを大きめにします
- GLSL の整数と小数:
1 > 0.5や0.0 - 4のように、整数を混ぜると、シェーダーがコンパイルできません。JS から数値を埋め込むときは、toFixed(3)で小数にします - 試験用ページで、波が見えない: 試験用の空のシーンの地面が、視界を覆っていました。列車だけを残す関数を用意して確認しました
9. 制約
- Quest(VR)では、波・鳥・駅の通しを見て確認しました。スマホは確認していません
- 全体で 7 分ほどあります(駅に着いて止まるまで)。長いので、各場面の秒数は、あとで調整します
- 駅は止まったままで、降りて歩くことはできません。「降りる」で、元の画面に戻ります
- 画像のテクスチャは、AI で生成した画像を使っています
関連
- #206 星空に回転する暗い渦を開ける
- #207 星空の列車に乗り込む
- Sky Today(今日の空)