three.js + cannon-esで、Unityライクな軽量ゲームエンジンを作った話
2025-12-16 ・ 読了目安 6分
このサイトで使用している技術について
- three.js
- 目的
- WebGLを使って3D表現をする
- 目的
- cannon-es
- 目的
- 物理シミュレーションをする
- three.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やThreeDMeshコンポーネントは自身がアタッチされたGameObjectのTransformコンポーネントから、座標や回転情報を得て、それに同期するように振る舞う(Transformがあることが前提)
- 例1
所感
- AIに「こういうゲームのプロトタイプ作って」と依頼するとき、自前のエンジンで作った他のゲームのコードをcontextとして渡すと、秒速でプロトタイプを作ってくれるので良さそう