はじめに
JavaScript canvas 2D で作成した、ボールバウンドアニメーションをThree.jsで作ったゲームボックス内のクラスへ移植して見たのでその実装メモです。
canvas 2Dバージョンは、以下のブロック崩しゲームの最初の画面で確認できます。
無料で公開してます。
[ブロック崩しゲーム] ブロック☆パレット 魔法で描く不思議な世界
このゲームは、シンプルなブロック崩しに魔法的な演出とアニメーションが加わり、ブロックを崩すたびに新しい発見が待っています。遊びながら進化するゲームシステムと、アニメーション効果を楽しんでください。
https://humanxai.info/featured/project4/
① 目的・背景
以前、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を実装する予定です。
💬 コメント