[JavaScript] Three.jsでボールバウンドアニメーションを実装する|Canvas2D実装の移植ログ

はじめに

JavaScript canvas 2D で作成した、ボールバウンドアニメーションをThree.jsで作ったゲームボックス内のクラスへ移植して見たのでその実装メモです。

canvas 2Dバージョンは、以下のブロック崩しゲームの最初の画面で確認できます。

無料で公開してます。

① 目的・背景

以前、Canvas 2D でブロック崩しゲームを作った際に、 ボールが画面内を跳ね返りながら動く ボールバウンドアニメーション を実装していた。

今回はその処理を流用し、 Three.js 上の GameBox 内ミニゲームとして移植することを目的にしている。

Three.js では描画対象が Vector3 ベースになり、 更新ループや座標管理の考え方も Canvas 2D とは異なるが、 基本的なロジック(移動・反射・衝突)はそのまま使える。

本記事では、 完成度の高い演出や最適化は一旦後回しにし、

  • まず動くこと
  • フレーム依存を避けること
  • UIや他のミニゲームと共存できる構造にすること

を優先して実装した内容をまとめる。

細かい調整や改善は、 今後 UI 実装やゲーム選択画面と組み合わせる段階で行う前提としている。

② 全体構造

今回のボールバウンドは、 既存のミニゲーム群と同じ構造で扱えるように GameModule を継承したクラスとして実装している。

基本的なライフサイクルは以下の通り。

  • init() ゲームボックスや壁情報を受け取り、オブジェクトを生成
  • start() アニメーション開始フラグを立てる
  • update(delta) 毎フレーム呼ばれ、位置更新や衝突判定を行う
  • dispose() ゲーム終了時に生成したオブジェクトを破棄
init → start → update → dispose

描画対象の Mesh はすべて box.group に追加し、 シーン直下にばらまかない構成にしている。

これにより、

  • UI切り替え
  • ゲーム選択画面への復帰
  • 他ミニゲームとの共存

がやりやすくなり、 Three.js 側の管理もシンプルになる。


③ 移動と反射(コア)

ボールの移動処理の中心は、 Canvas 2D 時代とほぼ同じ考え方を使っている。

ball.position.addScaledVector(v, delta);
  • 速度ベクトル v
  • フレーム間の差分時間 delta

を使って位置を更新することで、 フレームレートに依存しない動きを実現している。

壁との反射判定もシンプルで、

  • ボールが壁の内側限界を超えたら
  • 対応する速度成分を反転
  • clamp で座標を補正

という流れになっている。

ball.position.x = THREE.MathUtils.clamp(
  ball.position.x,
  -halfW + r,
  halfW - r
);

Canvas 2D で 「x / y 座標を直接操作していた部分」を、 そのまま Vector3 ベースの処理に置き換えただけ、と考えると分かりやすい。

Three.js 固有の複雑な仕組みは使っておらず、 ロジック自体は移植可能な形で保っているのがポイント。

④ ボール同士の衝突

複数のボールを同時に動かす場合、 一番破綻しやすいのが ボール同士の衝突処理 になる。

今回の実装では、以下の手順で処理している。

距離判定

まず、2つのボールの中心間距離を計算し、 半径の合計以内に入ったら衝突とみなす。

const delta = b1.position.clone().sub(b2.position);
const dist = delta.length();

if (dist === 0 || dist > r * 2) return;

距離が 0 の場合は法線が取れないため、 例外としてスキップしている。


法線ベクトルの算出

衝突時の反射方向を求めるため、 ボール間の差分ベクトルを正規化して 衝突法線を作る。

const normal = delta.normalize();

この法線を基準に、 速度ベクトルを反射させる。


dot積による速度反射

速度ベクトルを法線方向に射影し、 その成分を打ち消す形で反射させている。

const v1Dot = v1.dot(normal);
const v2Dot = v2.dot(normal);

v1.addScaledVector(normal, -2 * v1Dot);
v2.addScaledVector(normal, -2 * v2Dot);

物理的に正確な弾性衝突ではないが、 見た目と挙動としては十分自然に見える。


めり込み補正(超重要)

この処理を入れないと、 ボール同士が重なったまま反転を繰り返し、 震えたり動かなくなったりする。

const overlap = r * 2 - dist;
b1.position.addScaledVector(normal, overlap / 2);
b2.position.addScaledVector(normal, -overlap / 2);

重なった分だけ位置を押し戻すことで、 次のフレームで再衝突するのを防いでいる。

正直に言うと、 ここを省くと確実に破綻する

Three.js だから特別、という話ではなく、 Canvas 2D でも同じ問題が起きる。


この衝突処理は、 簡易的ではあるが、

  • 見た目が破綻しない
  • 実装が分かりやすい
  • 他のゲームロジックと干渉しにくい

という点を優先した構成になっている。

厳密な物理計算よりも、 「まずちゃんと動く」ことを重視した実装だ。

⑤ 見た目の工夫(残像)

動きに少しだけ情報量を足すため、 ボールには 簡易的な残像表現 を入れている。

やっていることは非常に単純で、

  • 現在のボールを clone()
  • 半透明にして少しだけ縮小
  • 短時間だけ表示して削除

という処理を毎フレーム行っている。


残像の生成

const trail = ball.clone();
trail.material = ball.material.clone();
trail.material.opacity = 0.15;
trail.material.transparent = true;
trail.scale.setScalar(0.95);

this.box.group.add(trail);
this.trails.push({ mesh: trail, life: 0.3 });

元のボールとマテリアルを共有すると副作用が出るため、 マテリアルは必ず clone している


life 管理と削除

残像は life を持たせ、 フレーム更新ごとに減衰させていく。

t.life -= delta;
t.mesh.material.opacity = Math.max(0, t.life);

寿命が尽きたら、 シーンから取り除いて配列からも削除する。

if (t.life <= 0) {
  this.box.group.remove(t.mesh);
  this.trails.splice(i, 1);
}

割り切りについて

この方式は、

  • 毎フレーム Mesh を生成
  • 毎フレーム Mesh を破棄

という構成になるため、 GPU 的にもメモリ的にも決して軽くはない。

ただし今回は、

  • ボール数が少ない
  • 表示時間が短い
  • 演出確認が主目的

という前提で、 実装の分かりやすさを優先して割り切っている。

最適化が必要になった場合は、

  • InstancedMesh を使う
  • 残像用のバッファを使い回す

といった改善を行う余地がある。

今回はあくまで 「まず動かして見せる」ための実装としている。

⑥ 今後の改善

今回の実装は、 「まず動く状態を作る」ことを優先しているため、 改善の余地は多く残している。

残像処理の最適化

現在は、

  • 毎フレーム Mesh を生成
  • 寿命が来たら即破棄

という単純な構成になっている。

今後は、

  • InstancedMesh を使った描画
  • あらかじめ用意した残像オブジェクトの再利用

などに置き換えることで、 負荷を下げる余地がある。


速度ベクトルの正規化

初期速度はランダム生成しているが、 衝突を繰り返すうちに速度の大きさがばらつく。

new THREE.Vector3(Math.random() * 3 - 1.5, Math.random() * 3 - 1.5, 0);

必要であれば、

  • 速度の最大値・最小値を制限
  • 一定速度に正規化

することで、 見た目や難易度の調整がしやすくなる。


衝突の多段反射対策

ボール同士や壁との衝突が重なった場合、 同一フレーム内で何度も反射が起きる可能性がある。

現状では、

  • hitTimer による簡易的な制御

で誤魔化しているが、 より安定させるには、

  • 衝突回数の制限
  • 衝突後のクールダウン時間の調整

といった対応が考えられる。


UI との連動

現時点では自動でアニメーションが開始されるが、 今後は UI と連動させる予定。

  • ボタンで開始/停止
  • タイトル画面でのデモ表示
  • ゲーム選択中の背景演出

などに使えるよう、 start() / dispose() を前提とした構造にしている。

このボールバウンドは、 UI 実装の前段階として使うミニゲームという位置づけで、 今後の拡張を前提にした実装になっている。

次の一手

この記事で実装した ballBound は、 単体のミニゲームとして完結させるよりも、 UI と組み合わせて使うことで価値が出る構成になっている。

特に相性がいいのは、 タイトル画面やゲーム選択画面での利用だ。

タイトル画面のミニモニターに流す

UI 用に用意したモニターやパネルの中で、

  • 小さな画面
  • 操作を受け付けない表示専用エリア

として ballBound を再生する。

これにより、

  • 静止画だけのタイトル画面よりも情報量が増える
  • VR 空間内で「動いている」感じが出る
  • ゲーム全体の雰囲気作りに使える

といった効果がある。


UI 選択中の「動いてる背景」として使う

ゲーム選択中は、

  • ユーザーの操作が UI に集中する
  • 背景はあまり目立ちすぎてはいけない

という状態になる。

そのため、

  • 入力は受け付けない
  • ただ動き続けるだけ

という ballBound の使い方がちょうどいい。

UI 操作中の背後で ボールが静かに跳ね返り続けることで、

  • 画面が止まった印象を与えない
  • 待ち時間や選択時間を自然に演出できる

この実装は、 UI 実装に入る前の ウォームアップ兼素材作りとして位置づけている。

次は、

  • ゲーム選択 UI
  • モニター配置
  • レーザーポインタ操作

と組み合わせながら、 UI 側の実装を進めていく予定だ。

おわりに

上記でAIがまとめてくれましたが、次回は、ここ数日で作ったミニゲームとアニメーションを選択して切り替えられるUIを実装する予定です。