[Astro #60] R3FによるSTGエンディングプロトコルの構築 〜パーティクル収束・クレジットスクロール・接続切断演出の全実装〜
はじめに
前回(#59)でボムの実装が完了し、「あとはEDを作ったら終了」と書いてから、ついにそのEDを実装しました。
最初は既存の簡易ダイアログ(HTML側のENDING画面)をそのまま流用しようとしていましたが、どうせなら3D空間で完結するものを作ろうと思いR3Fで1から設計することにしました。 最終的にパーティクル収束 → クレジットスクロール → 接続切断(発散)→ 感謝テキストという4フェーズ構成になり、想定より作り込んだ内容になりました。
これをもって、LAYER_STG_PROTOCOL の実装フェーズは一段落です。
前回の記事:
[Astro #59] WebXRにおける入力同期プロトコルのクレンジング 〜量子爆発ボムの実装と多重トランスフォーム競合の完全遮断〜 // PROTOCOL.LAIN
React Three FiberとWebXRを用いた3Dシューティングゲームにおいて、上流のScene、Manager、下流のController間で発生したフラグ競合を解消し、アバター追従型のミニマルHUDと3回制限ボムを完全同期させたリファクタリングの記録。
lain-lab.comスクリーンショット:
1. フェーズ管理アーキテクチャの設計
エンディング全体を単一のstateで管理するフェーズマシンとして設計しました。
WAITING → CONVERGE → CREDIT → BURST → THANKYOU → DONE
各フェーズの役割は以下の通りです。
| フェーズ | 内容 | 時間 |
|---|---|---|
| WAITING | 起動直後の待機 | 0.5秒 |
| CONVERGE | パーティクル収束開始・リング出現 | 3秒 |
| CREDIT | クレジットスクロール再生 | スクロール完了まで |
| BURST | パーティクル消滅・リング発散 | 1.8秒 |
| THANKYOU | 感謝テキスト表示 | 6秒 |
| DONE | 暗転→タイトルへ遷移 | 1.5秒 |
export const STGEndingScene = ({ score = 0, onComplete }) => {
const [phase, setPhase] = useState('WAITING');
const elapsed = useRef(0);
useFrame((_, delta) => {
elapsed.current += delta;
if (elapsed.current > 0.5 && phase === 'WAITING') setPhase('CONVERGE');
if (elapsed.current > 3.5 && phase === 'CONVERGE') setPhase('CREDIT');
});
// ...
};
フェーズ遷移は useFrame 内の elapsed タイマーと、クレジット完了コールバック(onFinished)の2系統で制御しています。setTimeout は BURST → THANKYOU および DONE → onComplete の遅延にのみ使用し、アニメーション中の判定はすべて useFrame で完結させています。
2. パーティクル収束ループの実装
エンディングの中核となる演出です。800個のパーティクルが円周上からランダムにスポーンし、中心の1点(原点)へ向かって移動し、到着したらフェードアウトして即座に円周上へリスポーンするループを継続します。
頂点カラーによる個別アルファ制御
Three.js の PointsMaterial は opacity でマテリアル全体の透明度しか制御できず、粒子ごとに個別の透明度を持たせることができません。これを解決するため、頂点カラー(vertexColors)を使い、各粒子のアルファ値をRGBとして頂点バッファに書き込むことで個別制御を実現しています。
// 頂点カラーのR/G/B全チャンネルにアルファ値を書き込む
const o = Math.max(0, op[i]);
colAttr.array[i*3] = o; // R
colAttr.array[i*3+1] = o; // G
colAttr.array[i*3+2] = o; // B
pointsMaterial 側で color を #00f2fe(シアン)に設定しておくと、頂点カラーと乗算されるため、o=1.0 のときは #00f2fe、o=0 のときは完全透明になります。
<pointsMaterial
color={PARTICLE_CONFIG.COLOR}
size={PARTICLE_CONFIG.SIZE}
transparent
opacity={1.0}
vertexColors
blending={THREE.AdditiveBlending}
depthWrite={false}
/>
毎フレームの更新ロジック
Reactのstateを一切使わず、全粒子の位置・不透明度・速度をすべて useRef で保持し、useFrame 内で直接バッファを書き換えています。これにより再レンダリングが発生せず、パフォーマンスを維持できます。
for (let i = 0; i < COUNT; i++) {
const dist = Math.sqrt(dx*dx + dy*dy + dz*dz);
if (dist < PARTICLE_CONFIG.ARRIVE_DIST) {
// 中心到着 → フェードアウト → リスポーン
op[i] -= delta * PARTICLE_CONFIG.FADE_OUT_SPD;
if (op[i] <= 0) resetParticle(i, p, op, sp);
} else {
// 中心方向への単位ベクトルで移動
const inv = 1.0 / dist;
p[i*3] -= dx * inv * sp[i] * delta;
p[i*3+1] -= dy * inv * sp[i] * delta;
p[i*3+2] -= dz * inv * sp[i] * delta;
op[i] = Math.min(1.0, op[i] + delta * 0.5);
}
}
posAttr.needsUpdate = true;
colAttr.needsUpdate = true;
調整パラメーター
演出の調整がしやすいよう、全パラメーターをファイル冒頭のオブジェクトに集約しています。
const PARTICLE_CONFIG = {
COUNT: 800, // パーティクル総数
SPAWN_RADIUS_MIN: 0, // スポーン円の最小半径
SPAWN_RADIUS_MAX: 18, // スポーン円の最大半径
SPEED_MIN: 0.5, // 収束速度の最小(units/sec)
SPEED_MAX: 1.0, // 収束速度の最大(units/sec)
ARRIVE_DIST: 0.09, // 到着判定距離
FADE_OUT_SPD: 3.0, // 到着後のフェードアウト速度
BURST_SPD: 1.8, // BURSTフェーズ時の消え速度
COLOR: '#00f2fe',
SIZE: 0.05,
};
3. クレジットスクロールの実装
クレジットデータをオブジェクト配列で定義し、各行の fontSize と gap から累積Y座標を事前計算してR3F上のTextとして配置します。
const makeCredits = (score) => [
{ text: 'ALL_STAGES_CLEAR', color: '#00ff88', fontSize: 0.46, bold: true, gap: 0.0 },
{ text: `FINAL SCORE ${String(score).padStart(6,'0')}`,
color: '#ffcc00', fontSize: 0.26, bold: false, gap: 0.15 },
{ text: '— SOUND —', color: '#00f2fe', fontSize: 0.16, bold: true, gap: 0.3 },
// ...以下クレジット行が続く
];
スクロールは group の position.y を毎フレームずらすだけのシンプルな実装です。スクロール完了の検知は、グループのY座標から最終行のワールド座標を逆算して画面上端との比較で行います。
const groupY = -5 + scrollY.current;
groupRef.current.position.y = groupY;
// 最終行のワールドY = groupY - totalHeight
// これが画面上端(SCREEN_TOP)を超えたら完了
if (!finished.current && groupY - totalHeight > SCREEN_TOP) {
finished.current = true;
onFinished?.();
}
最初に topY + totalHeight > SCREEN_TOP という符号ミスで、スクロール開始直後に完了が即発火するバグが発生しました。クレジット行は anchorY="top" で Y方向マイナスへ積まれるため、最終行は groupY - totalHeight になります。+ を - に変えるだけの修正でしたが、座標系を正確に把握していないと踏む落とし穴です。
4. 接続切断演出(BURST フェーズ)
クレジットが完了すると、パーティクルとリングが「爆散」するのではなく、その場でゆっくりフェードアウトして消えていきます。「接続が切れた」という演出の意図で、動きを止めて静かに消えるほうが世界観に合うと判断しました。
if (phase === 'BURST') {
burstTime.current += delta;
// マテリアル全体のopacityをBURST_SPDで減衰させる
mat.opacity = Math.max(0, 1.0 - burstTime.current * PARTICLE_CONFIG.BURST_SPD);
return; // 位置の更新はしない(止まって消える)
}
回転リングも同様に、BURSTフェーズ以降は opacity を毎フレーム減衰させます。
5. STGManagerへの統合
エンディングへの遷移と BGM 切替は STGManager.tsx のボス撃破処理(通常弾・チャージ弾の2箇所)で行います。
if (currentStage >= MAX_STAGE) {
boss.current.active = false;
(window as any).wiredSTGState = 'ENDING';
// エンディングBGMに切替
if (bgmAudio.current) bgmAudio.current.pause();
const endingBgmUrl = cachedUrls.current["stg_bgm_ending"];
if (endingBgmUrl) {
bgmAudio.current = new Audio(endingBgmUrl);
bgmAudio.current.loop = true;
bgmAudio.current.volume = 0.35;
bgmAudio.current.play().catch(e => console.warn(e));
}
setIsEnding(true);
isEnding フラグで STGTitleVR(既存の簡易ENDINGダイアログを含む)を非表示にし、STGEndingScene だけを表示する切り替えも重要です。当初 STGTitleVR の ENDING 状態ダイアログが R3F キャンバスの上に被さり、エンディング演出が完全に見えないという問題が発生しました。
{/* ENDINGフェーズ中は STGTitleVR を非表示 */}
{!isEnding && <STGTitleVR />}
{isEnding && (
<STGEndingScene
score={(window as any).wiredPlayerScore || 0}
onComplete={() => {
setIsEnding(false);
(window as any).wiredSTGState = 'TITLE';
// タイトルBGMに戻す
if (bgmAudio.current) bgmAudio.current.pause();
const openingBgmUrl = cachedUrls.current["stg_bgm_opening"];
if (openingBgmUrl) {
bgmAudio.current = new Audio(openingBgmUrl);
bgmAudio.current.loop = true;
bgmAudio.current.volume = 0.35;
bgmAudio.current.play().catch(e => console.warn(e));
}
window.dispatchEvent(new CustomEvent('wired-stg-to-title'));
}}
/>
)}
BGMは stgAssets.json に定義した stg_bgm_ending を使用します。cachedUrls.current は起動時にアセットをプリロードしたURLが格納されているため、直接参照するだけで再生できます。
{ "id": "stg_bgm_ending", "path": "/assets/audio/爽やかな香り.mp3", "version": 1 }
おわりに
これをもって LAYER_STG_PROTOCOL の主要実装は完了です。
実装してみて改めて感じたのは、R3F上のアニメーションはReactのstateとuseFrameの役割分担を明確にしないとすぐにパフォーマンスの問題が発生するという点です。今回は全粒子の状態を useRef で管理し useFrame 内でバッファを直接書き換えるアプローチをとりましたが、これが最も素直な解決策でした。
STGManager.tsx は現在1800行超と肥大化していますが、リファクタリングは実装が完了した今が適切なタイミングかもしれません。その話はまた次の機会に。