three.js + cannon-esで、Unityライクな軽量ゲームエンジンを作った話
2025-12-16 ・ 読了目安 6分
このサイトで使用している技術について
- three.js
- 目的
- WebGLを使用し3D表現を行う
- 目的
- cannon-es
- 目的
- 物理シミュレーションを行う
- there.jsと相性がよく、座標系も合わせやすい
- 目的
ゲームを作るに当たって、何かしらアーキテクチャがある方が良いと感じた
- アークテクチャが欲しいと思った理由
- 手軽に遊べるゲームをたくさん作りたい
- 毎回three.jsとcannon-esの同期処理敵意なものを書いたりするのが面倒だし、ゲームごとに書きっぷりが変わるとメンテナンスが大変そう
アーキテクチャがあると、そのルール沿った書き方をすることで、ゲームを量産できるのでは?と思い、軽量なゲームエンジンを作ることにした
目指した軽量なゲームエンジン
- Unityのようなエディタは目指さない
- 最初からそこを目指すと、完成までにどれやら時間がかかるか分からない
- コードベースで、規約・制約を作るでOK
軽量ゲームエンジンに登場する主なクラス
エンジンの主要なクラス構成を以下に示します。
GameScene
├─ GameObject[]
│ ├─ Component[]
│ │ ├─ Transform (位置・回転・スケール)
│ │ ├─ RigidBody (物理演算)
│ │ ├─ ThreeDMesh (3Dメッシュ表示)
│ │ ├─ GLBModel (GLBモデル読み込み)
│ │ ├─ Camera (カメラ)
│ │ └─ Light (ライト)
│ └─ start() / update() / onRemove()
├─ THREE.Scene
├─ CANNON.World
└─ SceneLoadTracker (ロード管理)
GameScene
- 責務
- 一つのゲームのまとまり
- 後述のGameObjectを管理し、canvasへの描画をサポートするクラス
- GameObjectのupdateなどをGameSceneが呼び出している
- GameSceneに追加することで、そのゲームにGameObjectが追加され、three.jsで表示する必要がある場合は、表示されるイメージ
- ゲームのロジックは持たない
- 何かロジックを持たせる場合は、それ専用のGameObjectクラスを作るイメージ
- 主要なメソッド
addGameObject(gameObject): GameObjectを追加removeGameObject(gameObject): GameObjectを削除start(): シーンの初期化(全GameObjectのstartを呼び出し)update(): 毎フレームの更新(物理演算→GameObject更新→描画)trackLoad(promise): ロード中のPromiseを登録waitForLoadingIdle(): ロード完了まで待機
- 実装例
const scene = new GameScene(); const ball = new Ball(); ball.addComponent(new Transform()); ball.addComponent(new RigidBody(0.3, new CANNON.Sphere(0.15))); scene.addGameObject(ball); scene.start(); scene.startAnimationLoop();
GameObject
- 責務
- GameScene上で、後述のComponentをまとめて実行する概念
- 例えば、
Ball extends GameObjectのようなクラスを作った場合、このBallという概念には、ボールの見た目と物理演算の振る舞いが必要なので、見た目用のComponentと物理演算用のComponentをアタッチする
- 例えば、
- GameScene上で、後述のComponentをまとめて実行する概念
- 主要なメソッド
addComponent(component): Componentを追加getComponent(ComponentClass): 指定した型のComponentを取得start(): 初期化処理(オーバーライド可能)update(deltaTime): 毎フレームの更新(オーバーライド可能)onRemove(): 削除時のクリーンアップ(オーバーライド可能)
- 実装例
export class Ball extends GameObject { constructor() { super('Ball'); this.addComponent(new Transform()); this.addComponent(new RigidBody(0.3, new CANNON.Sphere(0.15))); this.addComponent(new ThreeDMesh( new THREE.SphereGeometry(0.15), new THREE.MeshStandardMaterial({ color: 0xff6600 }) )); } start(): void { const transform = this.getComponent(Transform); transform?.setPosition(0, 5, 0); } }
Component(abstract)
- 責務
- GameScene上で、GameObjectの振る舞いの一つの定義
- 例1:RigidBody
- cannon-esによる物理演算と剛体の振る舞い
- Transformコンポーネントと連携して、物理演算の結果を座標に反映
- 例2:ThreeDMesh
- three.jsで表示するキューブや球体など、プリミティブな図形の表現
- Transformコンポーネントから座標・回転・スケールを取得して同期
- 例3:Camera
- three.jsの描画の起点となるカメラの管理
- Transformコンポーネントから位置・回転を取得して同期
- 例4:Transform
- 3D表現上の座標・スケール・回転の管理
- 他のComponentから参照される「真実の源(Source of Truth)」
setPosition(),setRotation(),setScale()で値を設定
- 例1:RigidBody
- GameScene上で、GameObjectの振る舞いの一つの定義
- 主要なメソッド
getGameObject(): アタッチされているGameObjectを取得getComponent(ComponentClass): 同じGameObjectの他のComponentを取得start(): 初期化処理(オーバーライド可能)update(deltaTime): 毎フレームの更新(オーバーライド可能)onRemove(): 削除時のクリーンアップ(オーバーライド可能)
- Transformコンポーネントの実装例
export class Transform extends Component { private position: THREE.Vector3; private rotation: THREE.Euler; private scale: THREE.Vector3; setPosition(x: number, y: number, z: number): void { this.position.set(x, y, z); this.syncToThree(); // Three.jsのObject3Dと同期 } getPosition(): THREE.Vector3 { return this.position.clone(); } }
考慮が必要なこと
- ゲームエンジンで作られたインスタンス同士の情報をどこまで共有すべきか
- 一旦は、「GameObjectはGameSceneの情報を、Componentは自身がアタッチされたGameObjectと、アタッチされた他のコンポーネントの情報、そこから間接的にGameSceneの情報を取得できる」にした
- 特に、Component同士は情報のやり取りが発生する
- 例1
- Rigidbodyコンポーネントは自身がアタッチされたGameObjectのTransformコンポーネントにアクセスし、座標や回転をTransformコンポーネントに与える(Transformがあることが前提)
- 例2
- CameraやTreeDMeshコンポーネントは自身がアタッチされたGameObjectのTransformコンポーネントから、座標や回転情報を得て、それに同期するように振る舞う(Transformがあることが前提)
- 例1
所感
- AIに「こういうゲームのプロトタイプ作って」と依頼する際に、自前のエンジンで作った、他のゲームのコードをcontextとして渡すと、秒速でプロトタイプを作ってくれるので良さそう