[Astro] #208 星空の列車で旅をする(離陸・草原・光の波・白い鳥・トンネル・駅)

[Astro] #208 星空の列車で旅をする(離陸・草原・光の波・白い鳥・トンネル・駅)

はじめに

#207 で、列車に乗り込んで空が流れるところまで作りました。今回は、そのあとの旅の実装記録です。場面は次の順に進みます。

  1. 野原を走り、汽笛とともに飛び立つ(地面が沈んで、街の灯りだけが残る)
  2. 宇宙に入り、遠くの星がゆっくり流れる
  3. 半透明の草原が現れ、リンドウが咲く
  4. 光の波の海に変わり、白い鳥が 1 羽ずつ増えて群れになる
  5. 光の回廊(トンネル)を抜ける
  6. 白鳥の駅に着いて、止まる。「降りる」ボタンが出る

スクリーンショット

草原とリンドウの花

草原とリンドウの花

光の波と白い鳥

光の波と白い鳥

白鳥のステーション

白鳥のステーション

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 で上書きできます。

既定のタイムライン(宇宙に入ってからの秒)

場面開始終わり
草原8120(12 秒で消える)
リンドウ28咲く 30 秒
光の波140290
白い鳥波の 15 秒後波と一緒
光の回廊315350
駅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 でフェードさせ、鳥を遠くに飛ばしています(birdX 45〜birdXFar 75)
  • 左右の窓に交互に出します。ランダムにすると、片側の窓にしか出ない時間がありました
  • 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駅の開始 / 到着 / 停車 / 発車の秒数
stationHold1 で、着いたら止まったまま
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(今日の空)