[Next.js #21] Canvas 2Dで挑む「タイムパイロット」式 全方位カメラと無限の雲海

はじめに

Three.js、Babylon.js、R3Fなど、3Dばかりやってきましたが、「Canvas 2Dで画面全体を回転させるアプリは作れるのか?」と思い立ち、挑戦してみました。

3Dアプリのようにマウスの左右操作で画面全体を回転させ、さらにスクロールで拡大・縮小が可能です。 加えて、背景の雲をノイズで生成し、多重スクロール&多重レイヤーによって「雲の中を飛んでいる」ような演出にも取り組んでいます。

アプリを制作するにあたりイメージしたのは、全方位移動とショットが可能で、敵が四方八方から迫ってくるレトロゲームの名作、コナミの『タイムパイロット』です。そういった処理を想定して構築しましたが、シューティング要素はあくまでオマケ。 今回本当にやりたかったのは、**「画面のダイナミックな回転」「拡大縮小」「ノイズによる背景描写」**の3点です。

Next.jsのプロジェクト内で制作しましたが、これまでのシリーズの中では番外編的な位置づけになります。

前回の記事:

動画(Youtube):

動画(PC):

1. 「世界を回す」座標変換の黄金順序

3Dゲームでは当たり前の「カメラ」という概念を、Canvas 2Dで実現するための核心部分です。通常、2Dでオブジェクトを回転させる際は「その場(自機)」を回しますが、タイムパイロット風の実装では「自機以外の宇宙すべて」を回す発想が必要になります。

なぜ個別に回さないのか?

敵や雲、弾丸など、数百個あるオブジェクトの一つ一つに対して「プレイヤーとの相対角度を計算して座標を変換する」のは、数学的にも負荷的にも悪手です。

そこで、描画コンテキスト(Canvasの座標軸そのもの)を、プレイヤーの背後で強引に動かす手法をとります。

魔法の4ステップ(描画ロジック)

Canvas 2Dにおいて、自機を画面中央に固定し、世界を自由自在にズーム・旋回させるためのコードの並びは、数学的な「黄金順序」が存在します。

// 全オブジェクト(敵・雲・弾)を描画する直前の処理
ctx.save();

// 1. 画面の中心(ビューポートの中央)を原点にする
ctx.translate(canvas.width / 2, canvas.height / 2);

// 2. ズーム(拡大・縮小)を適用
ctx.scale(config.camera.zoom, config.camera.zoom);

// 3. カメラの角度に合わせて世界を逆回転させる
// ※「自分が右を向く = 世界が左に回る」ため、マイナスをかけるのがコツ
ctx.rotate(-config.player.angle - Math.PI / 2);

// 4. プレイヤーのワールド座標分だけ、世界を引き戻す
ctx.translate(-config.player.x, -config.player.y);

// --- ここで全てのオブジェクトを「ワールド座標(x, y)」のまま描画するだけ ---
// 例: enemies.forEach(e => ctx.fillRect(e.x, e.y, ...));

ctx.restore();

この順序がもたらす「3つの恩恵」

  1. ロジックの純粋性(Zero Confusion) ゲーム内の当たり判定や移動処理は、回転を一切無視した平易な2D座標 ($x, y$) のままで完結します。 「$100, 100$ の地点に敵がいる」という事実さえ管理すれば、回転後の描画位置を計算する必要は一切ありません。Canvasの行列演算にすべて丸投げできるからです。
  2. カメラワークの自由化 ctx.scale を挟み込む場所をこの順序に固定することで、「自機を中心にズームしつつ、自機の向きに合わせて世界が回る」という、3Dアプリのカメラコンポーネントのような挙動が2Dで手に入ります。
  3. デバッグの容易さ 複雑な三次元行列を使わないため、「なぜか自機が画面外に飛んでいった」というトラブル時も、config.player の $x, y$ 座標を見るだけで原因が特定できます。

2. ノイズによる「決定論的な無限背景」の生成

全方位スクロールゲームにおける最大の課題は、「広大な世界のデータをどう保持するか」です。配列に全ての雲の位置を記憶させては、メモリがいくらあっても足りません。 そこで、「データを持たず、その場で計算する」というアプローチを採用しました。

pseudoNoise(x, y):宇宙のハッシュ関数

今回の実装の核となるのは、特定の座標を入力すると $0$ から $1$ の値を返す決定論的(Deterministic)な関数です。

$$n = \sin(x \cdot 12.9898 + y \cdot 78.233) \cdot 43758.5453$$

$$Noise(x, y) = n - \lfloor n \rfloor$$

この数式は、同じ $(x, y)$ を与えれば常に同じ値を返します。つまり、「プレイヤーが一度離れて、また戻ってきた」としても、そこには以前と同じ形の雲が「再生成」されるわけです。これにより、世界を保存するための巨大な配列は不要になりました。

グリッドベースの描画カリング

無限の宇宙を計算し続けるわけにはいかないため、描画処理にはグリッドベースの動的カリングを導入しています。

  1. 宇宙の断片化: 世界を GRID_SIZE(例:800px)ごとの格子状に分割して考えます。
  2. 可視範囲の限定: 全ての格子をチェックするのではなく、プレイヤーの現在地を中心とした描画半径内にある格子だけをループで回します。
  3. オンデマンド生成: ループ内の各格子点座標を pseudoNoise に放り込み、値がある閾値(threshold)を超えた場合のみ、そこに雲を描画します。
// プレイヤーの周囲数グリッド分だけを走査する
const startX = Math.floor((player.x - drawRadius) / GRID_SIZE) * GRID_SIZE;
const endX = Math.floor((player.x + drawRadius) / GRID_SIZE) * GRID_SIZE;

for (let ix = startX; ix <= endX; ix += GRID_SIZE) {
    for (let iy = startY; iy <= endY; iy += GRID_SIZE) {
        // 座標から一意なノイズ値を算出
        const n = pseudoNoise(ix + seedOffset, iy + seedOffset);
        if (n > threshold) {
            // ここで雲を描画(位置、スケール、回転もノイズから決定)
        }
    }
}

メモリ消費ゼロの「実存的」な空

この手法の利点は、プレイヤーがどれだけ高速に、あるいはどれだけ遠くに移動しても、CPU負荷とメモリ使用量が常に一定であることです。 雲は描画される瞬間にのみ「実存」し、画面外に消えればデータすら残りません。

また、seedOffset(シード値のズームやズレ)を変えることで、同じアルゴリズムを使い回しながら、全く異なる雲の分布を持つ「多重レイヤー」を重ねることも容易にしています。

3. 多重スクロール(パララックス)と空気遠近法

2Dのキャンバスに圧倒的な「奥行き」と「高度感」をもたらすのは、複数のレイヤーを異なる速度で動かすパララックス(視差効果)です。今回の実装では、独自の視差係数(factor)を用いて、単なる背景ではない「立体的な空」を構築しました。

視差係数(factor)による3層構造

共通の描画関数 drawCloudLayer に渡される factor は、プレイヤーの移動量に対する背景の移動比率を決定します。これを利用して、以下の3層で世界を構成しました。

  • 遠景(factor = 0.4): プレイヤーの動きに対してゆっくりと動き、遥か下方の雲海を表現します。
  • 中景(factor = 1.0): 自機と同じ高度にある雲です。プレイヤーの座標と同期して動くため、飛行速度の基準となります。
  • 近景(factor = 1.6): 自機よりも「カメラ寄り」に存在する巨大な雲です。

「自機を隠す」という没入感

この実験で最も効果的だったのは、factor > 1.0 のレイヤーを自機の描画後に重ねる手法です。 視差を1.0より大きく設定した雲は、自機よりも速く画面内を横切ります。この雲が自機の手前を通過し、赤い機体を一瞬だけ隠す(遮蔽する)ことで、「自分が雲の中を突き進んでいる」という圧倒的な没入感が生まれます。

2Dにおける「空気遠近法」の再現

大気の層を感じさせるため、Canvas 2Dの ctx.filter を活用した視覚的演出を加えています。

  • 疑似的な被写界深度(Blur): 遠景には薄いブラーを、最前面の近景には強いブラーをかけることで、ピントのズレを表現しました。
  • 透過度と階層: レイヤーごとに globalAlpha を調整し、下層には影の色(shadowCloud)を、上層には明るい白(brightCloud)を配することで、空気の厚みを演出しています。
function drawCloudLayer(factor, alpha, seedOffset, texture, blurAmount) {
  ctx.save();
  // ★空気遠近法の要:手前と奥をぼかして距離感を出す
  if (blurAmount > 0) ctx.filter = `blur(${blurAmount}px)`;

  ctx.translate(cx, cy);
  ctx.scale(config.camera.zoom, config.camera.zoom);
  ctx.rotate(-config.player.angle - Math.PI / 2);
  // factorを乗じることで、層ごとに移動速度を分ける
  ctx.translate(-config.player.x * factor, -config.player.y * factor);

  // ...描画処理...
  ctx.restore();
}

4. アセットレスなレトロ・フィードバック

最後の仕上げとして、MSXやCommodore 64といったレトロコンピューティングへの敬意を込め、外部アセットに頼らない「コードによる演出」を実装しました。画像や音声ファイルを読み込むのではなく、数学と信号処理で「手応え」を作り出すプロセスは、計算機リソースが限られていた時代の開発哲学にも通じます。

数学的関数による星形描画アルゴリズム

爆発エフェクトや火花には、円や矩形ではなく、数学的に定義された星形(Star Shape)を採用しました。

  • 外側の半径(outerRadius)と内側の半径(innerRadius)を交互に結ぶループを回すことで、任意の頂点数を持つ星を描画します。
  • 頂点数(spikes)を5に設定し、内径を調整することで、鋭利な火花からふっくらした星まで自在に制御可能です。
  • パーティクルの寿命(life)に合わせて ctx.rotate で回転を加えることで、単なる図形がダイナミックな「火花」へと昇華します。
function drawStar(ctx, x, y, spikes, outerRadius, innerRadius) {
  let rot = (Math.PI / 2) * 3;
  let step = Math.PI / spikes;
  ctx.beginPath();
  ctx.moveTo(x, y - outerRadius);
  for (let i = 0; i < spikes; i++) {
    ctx.lineTo(x + Math.cos(rot) * outerRadius, y + Math.sin(rot) * outerRadius);
    rot += step;
    ctx.lineTo(x + Math.cos(rot) * innerRadius, y + Math.sin(rot) * innerRadius);
    rot += step;
  }
  ctx.closePath();
}

Web Audio API によるシンセサイズ

音声面でも、MP3やWAVファイルは一切使用していません。Web Audio API のオシレーターを直接叩き、その場で波形を生成する「ピコピコ」としたレトロサウンドを構築しました。

  • ショット音: triangle(三角波)の周波数を一瞬で高域から低域へスイープさせ、短いエンベロープで切ることで鋭い発射音を作ります。
  • 爆発音: AudioBuffer にランダムな数値を詰め込んだホワイトノイズを生成し、指数関数的な減衰(exponentialRampToValueAtTime)をかけることで、爆発の衝撃を表現しました。
  • ゲームオーバー: square(短形波)を用い、悲しげな下降アルペジオをシーケンスとして記述しています。

外部ファイルをロードする待機時間も、著作権の心配もありません。コードが実行された瞬間に、ブラウザ内に仮想の音源チップが構築され、世界が鳴り始める心地よさは格別です。

まとめ

今回の試みは、単に「レトロなシューティングゲームを作ること」が目的ではありませんでした。「Canvas 2Dという平易な描画システムにおいて、いかにして3D的な没入感と無限の広がりをシミュレートできるか」という、一種の座標系に対する挑戦でした。

実験の成果

  • 座標の解脱: 全てのオブジェクトを個別に計算するのではなく、宇宙の理(座標軸)そのものをトランスフォームすることで、ロジックを極めてシンプルに保ったままダイナミックな視点移動を実現しました。
  • 実存するノイズ: 決定論的ノイズを用いることで、メモリ上に存在しないはずの「無限の空」に実存的な手触りを与えました。雲は描画されるその瞬間にのみ現れ、去れば消える。しかし、再び戻ればそこには同じ雲が待っているという「法則の永続性」を記述できました。
  • アセットレスの美学: 画像や音声ファイルという外部の「実体」に頼らず、数学的な関数(星型アルゴリズム)や信号処理(Web Audio API)によって、ブラウザ内部でゼロから現象を立ち上げる心地よさを再確認しました。