[Astro #78] Three.jsで創る電脳魔女STG:動的クレジット案内板・プロシージャル床・星ハート爆散物理の実装
はじめに
本日の開発では、前回のインフラ整備によってカプセル化された自機コントローラーをベースに、演出面の大幅なクオリティアップと、ゲームの命盤となる衝突物理・エフェクトロジックの実装を行いました。
以下に、実装したシステムごとの詳細な仕様とコードアプローチを記録します。
1. データ駆動型クレジット案内板(WITCreditBoard.tsx)の新設
1. 動的クレジット案内板のシステム概要
外部アセット(3Dモデル・音源データ)の権利表記を統合・自動化するため、独立した3Dコンポーネントとして実装されました。本コンポーネントは、ゲーム全体の進行を管理するマネージャー層(WITManager.tsx)から描画 Props(座標、回転角、スケール)を受け取り、右舞台袖(マスコット「黒木智子」の前方付近)の3D空間座標上にマウントされます。
2. データ抽出および重複排除のメカニズム
静的なテキスト定義を行わず、プロジェクト内の各種アセット定義JSON(Accessories.json, Enemies.json, Sounds.json, Avatars.json)からリアルタイムに制作者名を一本釣りする設計となっています。
- Setオブジェクトによる計算量の最適化
各JSONデータから
creatorフィールド(有効な文字列が存在するもののみ)をスキャンする際、JavaScriptのSet構造体(一意な値の集合)を使用しています。 複数アセットで共通のクリエイター名(例:Kenney、DimitriyArts)が存在する場合でも、ループ内の.add()処理によって重複が自動的に排除され、アセット数 に対して の計算量でクリーンなクリエイターリストが生成されます。 - useMemoによるメモライゼーション
このスキャンおよびリスト生成処理は
useMemo内でカプセル化されており、参照しているJSONデータ自体が変更されない限り、毎フレームのレンダリング時に再計算が走らないよう最適化されています。
3. 高精細CanvasTextureと異方性フィルタリング(Anisotropy)
3D空間内でミニチュアサイズ(scale={0.52})に縮小投影された2D Canvasテキストは、テクセルピクセルの密度不足やWebGLのサンプリング仕様により、視認性が著しく低下する課題(エイリアシングおよびミップマップによる潰れ)がありました。
- テクセル密度の最大化
内部で動的生成するCanvasの解像度を
1024x1280ピクセルへ引き上げ、タイトルフォントを62px/64px、制作者名を76pxの太字(bold sans-serif)に設定。1文字あたりに割り当てられる絶対的なテクセル数を拡張し、線の細りや潰れを物理的に防いでいます。 - 異方性フィルタリング(Anisotropic Filtering)の強制適用
看板オブジェクトはカメラの視線(パースペクティブ)に対して斜めに傾斜(ローカル rotation X: 約10度、Y: 約25度)して配置されます。通常のトリリニアフィルタリングのみでは、パースによる急激な縮小補間によって文字の輪郭が強制的に平滑化され、ボヤけが発生します。
これを解決するため、テクスチャマテリアルに
texture.anisotropy = 4を明示的にバインドしました。グラフィックスカードのハードウェア機能を用いて斜めサンプリングの精度を4倍に高め、鳥瞰アングルからでも文字がシャープに直立して読める視認性を担保しています。
4. VRAM転送を抑制するGPUスクロールエンジン
アセット数が将来的に増加した場合、黒板(板メッシュ)の物理的な縦幅(boardH = 0.7)を超えて文字があふれ出すスケーラビリティの限界を、映画のスタッフロール風の永久ループギミックによって解決しています。
- wrapT(垂直ラッピング)の設定
Canvas上にテキストを等間隔(
yOffsetに基づくピクセル加算)でシリアルに描画した後、生成したTHREE.CanvasTextureの垂直方向の折り返し仕様をtexture.wrapT = THREE.RepeatWrappingに指定しています。これにより、V座標(テクスチャの縦軸)が1.0を超えたサンプリングが自動的に0.0へループするインフラが整います。 - UV座標シフトによる低負荷アニメーション
毎フレームCanvasを
clearRectして文字を描き直すアプローチは、CPUに多大な負荷を与え、VRAMへのテクスチャ再転送(Bus帯域の圧迫)とガベージコレクションを頻発させます。 本実装では、最初の1フレームのみCanvasへ文字を焼き付け、以降は@react-three/fiberのuseFrameループ内でデルタ時間(時間経過の差分)を同期し、texture.offset.y += delta * 0.02としてGPU側のテクスチャ座標(UVオフセット)のみを低速加算しています。データ転送およびCPU演算負荷を完全に「ゼロ」に抑えたまま、無限に文字が流れ続ける高パフォーマンスなエンドロールシステムを実現しました。
2. プロシージャル生成による高精細フローリング床の更生
1. プロシージャル・フローリングのシステム概要
ゲーム画面下部にマウントされる舞台袖を含めた床面メッシュ(boxGeometry)の質感向上のため、動的にテクスチャを1から計算生成するプロシージャル手法が導入されました。
外部画像(.jpg や .png)の非同期I/Oロード待ちによるスパイク(瞬間的なフレーム落ち)を回避し、静的ファイル容量を増加させることなく、実行時に数ミリ秒で高精細なVRAM上のテクスチャを確立するアプローチをとっています。
2. Canvas 2Dアルゴリズムによる木目・千鳥貼りの動的エミュレーション
床面テクスチャの生成は、HTML5のCanvas APIを用いて、メモリ上の隠蔽されたバッファ領域(document.createElement('canvas'))で実行されます。
- 天然木のバラつきを再現する調和計算
テクスチャの基本アスペクトを
512x512ピクセルに設定し、黄金比に基づいたベースカラー(#402c1e)で全塗りします。その後、1本の床板の幅をplankWidth = 128(計4本)と定義してループ演算を回します。 規則的なベタ塗りを排除するため、板の配列インデックス(idx = x / plankWidth)に対して(idx % 3) * 0.05というアルファ(透明度)のブレンド比率を算出し、白色(rgba(255, 255, 255, ...))を重ねることで、1本ずつの板が独立した個別の木目・濃淡を持つような視覚的バラつきを数学的にシミュレートしています。 - 千鳥貼りと三次元パースを維持する目地溝(縦横の影線)
大工仕事の「千鳥貼り(ジョイントを交互にずらす工法)」を表現するため、板のインデックスに応じた条件分岐を実装。
idxの値によって横の継ぎ目となるY座標(hGapY)を160,380,240,100ピクセルへと意図的に分散させ、暗色(rgba(0, 0, 0, 0.4))の影線(太さ4px)を走らせています。 さらにその直下にrgba(255, 255, 255, 0.05)の光のハイライト線を平行に2px追加することで、板の断面の面取りによる立体的な凹凸感を2D描画内で騙し絵的に表現しています。 カメラから遠ざかるパースペクティブ補間(縮小サンプリング)に負けないよう、板同士を分かつ縦の深い溝(目地)には一段とディープな暗黒茶色(#110a06)を使用し、太さを5pxに強化してクッキリとしたエッジを確保しています。
3. WebGL転送ライフサイクルと物理ベースマテリアル(PBR)の適用
Canvas上に焼き付けられたピクセル配列データは、Three.jsおよびWebGLのグラフィックスパイプラインを仲介して、GPU(ビデオメモリ)へ正しく同期・転送される必要があります。
- needsUpdate命令による仕様トラップの回避
Three.jsにおける
THREE.CanvasTextureは、初期化された段階ではGPU側にテクスチャデータが未アップロード(または空の状態)として扱われる特性があります。 Canvas要素に対するピクセル描画が完了した直後に、明示的にtexture.needsUpdate = trueフラグを点火(トゥルーラッチ)することで、WebGLコンテキストに対して「このCanvasのピクセルデータを、今すぐビデオメモリへ強制転送・更新せよ」というレンダリングコマンドを最速でキックし、描画がスキップされてベタ塗りになるバグを100%回避しています。 - PBR(物理ベースレンダリング)マテリアルへの昇格
ライトの光を無視するベタ塗り(
meshBasicMaterial)から、光の反射と影を数学的に正しくシミュレートする物理ベースのmeshStandardMaterialへと床面マテリアルを移行しました。 テクスチャの色空間をTHREE.SRGBColorSpaceで正しくアライメントした上で、床面に上品な劇場用ワックスが薄く塗られているような質感を表現するため、表面の微細な凹凸(マイクロファセット)による光の拡散率を制御するroughness: 0.6、および固有の金属光沢度を抑えるmetalness: 0.1を設定しました。 これにより、空間内に常設されている平行光源(directionalLight)の斜光を受けた際、床面の手前側と奥側で物理的に正しい減衰グラデーションが発生。自機(イレイナ)やマスコット(もこっち)、およびクレジット看板の下部に投影される影が床の木目テクスチャと完璧に噛み合い、ミニチュア劇場としての空間の説得力が跳ね上がる結果となりました。
3. 自機 ➔ 木製設置物(地形)のリアルタイム侵入不可クランプ
1. カプセル化に伴うデータ隔離の解決概要
前回のインフラ再設計により、自機の移動クランプおよび入出力物理は WITController.tsx へと完全にカプセル化・隔離されました。これによってコードの風通しは向上したものの、ステージのスクロールおよび地形オブジェクトのスポーンを司るマネージャー層(WITManager.tsx)との間でデータが寸断されるトレードオフが発生しました。
コントローラー層が現在画面内にスクロールしてきている地形の三次元座標を関知できなくなった結果、自機がオブジェクトを素通りする状態になっていました。この課題を解決するため、両コンポーネント間に単方向のリアルタイム・データパイプラインを再配線し、コントローラー内部に「侵入不可クランプ(押し戻し物理)」を実装しました。
2. 地形オブジェクト配列(visualTerrain)のバインドとProps伝達
WITManager.tsx 内の useFrame ループでは、経過時間とスクロールスピード(SCROLL_SPEED = 0.3)に基づいて計算されたアクティブな地形情報が、配列Ref(terrainObjsRef.current)および同期用ステート(visualTerrain)として毎フレーム更新管理されています。
- 単方向データバインド
このリアルタイムに変動する画面内のアクティブ地形配列を、JSXのコンポーネント境界を越えて
<WITController terrainObjs={visualTerrain} />の形式でPropsバインドし、コントローラー内部へと直接流し込むインフラを構築しました。これにより、独立した入出力筋肉層(Controller)に対して、脳(Manager)が見ている現在の世界(地形の現在位置)を毎フレーム寸分の狂いもなく教え込むことが可能になりました。
3. AABB(Axis-Aligned Bounding Box)に基づく衝突限界閾値の動的算出
コントローラーの useFrame 内に、 Props経由で受け取った terrainObjs 配列に対する総当たり(、 は画面内のアクティブ地形数)の衝突スキャンループを配線しました。
- オブジェクト形状による早期リターン
配列内の全オブジェクトに対して、まず
modelIdを判定します。当たり判定を持たない背景装飾アセット(WIT_TERRAIN_GRASSやWIT_TERRAIN_MUSHROOM)については判定処理をスキップ(早期リターン)させ、衝突演算のCPU負荷を最小限に抑えています。 - 絶対衝突境界値の導出
本作のメイン障害物である「木(
WIT_TERRAIN_TREE_01)」の固有コライダーサイズである幅boxW = 0.15、高さboxH = 0.35を基準値としてローカル定義します。 これに対し、自機のコライダーサイズ(playerSize = 0.1)から導出される半径(playerSize / 2)をそれぞれ加算し、自機中心点とオブジェクト中心点との間に発生し得る絶対的な衝突限界閾値(hitX = playerSize / 2 + boxW / 2,hitY = playerSize / 2 + boxH / 2)を動的に算出します。
4. 侵入方向の比率判定(縦横比)と押し戻しクランプアルゴリズム
毎フレーム、自機の現在座標Ref(playerPosRef.current)と、スクロール移動を続ける障害物の中心座標(obj.x, obj.y)との相対距離(dx = playerPosRef.current.x - obj.x, dy = playerPosRef.current.y - obj.y)の絶対値を計測します。
- 交差判定(Intersection Test)
Math.abs(dx) < hitXかつMath.abs(dy) < hitYの条件が同時に満たされた瞬間を「自機が木の幹の内部(AABB空間内)にめり込んだ」激突状態として検知します。 - 激突面の動的割り出しと座標の強制書き換え(クランプ)
激突を検知した際、ただ動きを止めるだけでは自機が障害物に吸着・埋没してしまいます。これを防ぐため、侵入してきたベクトルの深さを縦横の比率(
Math.abs(dx) / hitXとMath.abs(dy) / hitY)で比較判定するアルゴリズムを実装しました。 - 横から衝突した場合(比率が大きい場合):
木の左右の側面に激突したと判断し、相対位置の正負(
dx > 0)を判定。自機のX座標をobj.x + (dx > 0 ? hitX : -hitX)へと強制的に上書きクランプします。 - 上下から衝突した場合(比率が小さい場合):
木の上端または下端に激突したと判断し、同様にY座標を
obj.y + (dy > 0 ? hitY : -hitY)へと強制上書きクランプします。
この一連の軸方向クランプ物理により、レバー(キーボード)入力による移動ベクトルを木のコライダー表面で滑らかに相殺させつつ、スクロールによって減少していく木の obj.x の移動量がそのまま自機のX座標へと強制フィードバックされます。結果として、「木に突っ込むとガチッと物理的に引っかかり、そのままスクロールによって左方向へグググと押し戻される」という、2DサイドビューSTGとしての正しい堅牢な衝突挙動が100%実現されました。
4. AABB交差判定と星型・ハート型の電脳爆散パーティクル
1. コアゲームループにおける衝突判定とデータライフサイクル
STGとしてのゲームコアを駆動させるため、直進する弾丸(bulletsRef)と編隊飛行する敵機(enemiesRef)の間で、毎フレーム高速な衝突スキャンを実行する二重ループを配線しました。
- 平方根を排除した高速サークル交差スキャン
各弾丸と各敵機の中心点における相対距離(
dx,dy)を計測する際、CPUに負荷のかかるMath.sqrt(平方根計算)を完全に排除しています。 弾丸の半径(0.05)と敵機の半径(0.07)の和をhitLimitと定義し、dx * dx + dy * dy < hitLimit * hitLimitという「距離の2乗比較」のみで交差判定を完結させています。これにより、計算量を極小化し、多数のオブジェクトが密集した場合でも処理落ちのないミリ秒未満の衝突解決を実現しました。 - Set構造体による間引きデリートと配列インデックスズレの防止
衝突が検知されたオブジェクトのIDは、即座に
hitBulletIdsおよびhitEnemyIdsというSet構造体へ登録されます。 二重ループ内での動的な要素削除(splice等)を避け、ループ完了後に一括して.filter()を走らせて配列Refから安全に間引きデリート(クリーンアップ)を行うことで、配列の要素数が変化することによるインデックスの参照ズレバグやメモリリークを100%防止しています。 - ホログラムHUDスコアへのリアルタイム同期加算
敵の消滅が確定した瞬間に、スコアステートを
setScore(prev => prev + 100)で更新し、左上に常設されているホログラムUIコンポーネント(WITHud)へと即時同期・描画反映させています。
2. Canvas 2D幾何学パスによる星型・ハート型アルファマスクのオンボード生成
爆発時の破片(パーティクル)に魔女っ子STGとしての個性を与えるため、外部画像ファイルを読み込むことなく、起動時にメモリ上で一瞬で生成される動的な形状マスクを実装しました。
- 星型(STAR)の三角関数プロット描画
64x64ピクセルのCanvas上で、中心座標(32, 32)から外半径28、内半径12の5角星をプロットします。forループ内で1ステップごとに角度を ずつ進め、Math.cosおよびMath.sinによる三角関数を用いて外頂点と内頂点の座標を交互に算出し、.lineTo()で一筆書きのパスを結んで#ffffff(白)で塗り潰します。背景を#000000(黒)で全塗りしておくことで、WebGL側で完全な抜き型マスクとして認識されるインフラを構築しました。 - ハート型(HEART)の三次ベジェ曲線アライメント
ハート型は、頂点(32, 20)を始点とし、左右対称のなめらかな曲線を表現するため、制御点を厳密に指定した
.bezierCurveTo()(三次ベジェ曲線) を4回連続で駆動させる幾何学パスアルゴリズムで描画されます。 生成されたCanvasテクスチャは、それぞれstarTexture,heartTextureとしてuseMemoで保持され、needsUpdate = trueによって生成と同時にVRAMへ自動ラッチ(転送)されます。
3. 高輝度HDRブルーム連動パーティクルバースト物理
敵機が爆散した座標から四方八方へと弾け飛ぶ、3D電脳パーティクルエンジンを構築しました。
- 射出ベクトル計算とハーフ&ハーフ確率分岐
敵機の死亡座標を原点とし、一様乱数(
Math.random() * Math.PI * 2)による360度放射状の射出角度(angle)と、初期初速(0.25 + Math.random() * 0.75)から、毎フレームの移動増分となる速度ベクトル(vx = Math.cos(angle) * speed,vy = Math.sin(angle) * speed)を算出します。 このとき、射出される32発の粒子に対して、1発ごとにMath.random() > 0.5による確率50%の条件分岐を行い、粒子のアイデンティティ(shapeType)へ ‘STAR’ または ‘HEART’ の個性をランダムに割り振ります。 - 慣性減速シミュレーションと1マナスケール・不透明度減衰
useFrameループ内で、各パーティクルの座標に速度(vx,vy)を加算しつつ、毎フレーム速度成分に0.93を乗算することで、空気抵抗による自然な慣性減速(ブレーキング効果)を物理シミュレートしています。 また、粒子の寿命比率(ratio = p.age / p.maxAge)を算出し、描画層の<mesh>のscale属性に対して直接p.size * (1 - ratio)、マテリアルのopacity属性に対して1 - ratioをバインドして連動させました。これにより、以前発生していた空の入れ子オブジェクトによるスケールバグが完全に解消され、粒子がクルクルと回転・減速しながら、後半にかけてシュッと小さく透明にフェードアウトしていく美しい物理ライフサイクルが成立しました。 - HDR(高輝度)ブルーム限界突破による発光演出
パーティクルの基本色には、リプルレーザーと共通のシアンネオンカラー(
#00f2fe)を採用していますが、マテリアルに渡す色情報に対して.multiplyScalar(2.5)を実行し、RGBの輝度値を内部的にカラーフォーマットの最大値(1.0)を超えて意図的に「飽和(HDR状態)」させています。 これをtoneMapped={false}(トーンマップ対象外)に指定してWebGLに叩き込むことで、画面全体のトーンマッピングによる輝度抑制を強制的にバイパスさせ、システムのポストプロセッシング(ブルームフィルター)の輝度閾値を限界突破させています。 結果として、1体倒すごとに劇場の暗転空間にパシャァァン!と目も眩むような鮮烈な光の星とハートが飛び散る、極めて爽快感の高い電脳爆散エフェクトが完成しました。
5. ソースコードの可読性向上
度重なる機能追加で二重記述や残骸ゴーストコードが発生していた WITManager.tsx を全面リファクタリング。
すべての主要処理ブロック(非同期ロード、床テクスチャ、星ハートマスク、交差判定、各種アセットマウントJSXなど)の境界を整理し、一目で役割が判別できる和製絵文字タグ付きコメントを配置して、今後のステージ設計や敵パターン拡張に耐えうるクリーンなプロダクトコードに仕上げました。