Three.jsのファイル分割はDIパターンがおすすめ【クラス設計】

Three.jsのコードは放っておくとすぐ肥大化します。私も学習中に作ったデモのHTMLは、シーン初期化・ライト・カメラ・入力・物理・アニメーションが1ファイルに詰まって、気づけば1本400行超え。どこに何があるか分からなくなって、ファイルを分ける方法を真剣に考えることになりました。

いろいろ試して落ち着いたのが、今回紹介するシングルトンのAppクラスを軸にしたDI(依存性注入)パターンです。Three.js界隈のコード(Bruno Simonの講座やthree.js journeyスタイルの構成)でもよく見かける形で、機能を足すときの見通しが劇的に良くなります。

デモは前回までと同じ「箱をWASDで動かす」だけですが、中身がクラス分割されています。開発者ツールのコンソールを開くと App Init!Init Init!World Init!Player Init!Loop Init! と初期化順が流れるのが見えます。

デモを別タブで開く

目指すファイル構成

index.js               → new App() するだけ
app/
  app.js               → Appクラス(シングルトン)。全体の親
  init.js              → シーン・カメラ・レンダラーなど共有リソースの初期化
  loop.js              → アニメーションループ
  world/
    world.js           → ステージの組み立て
    player.js          → プレイヤー
  input/
    keyStates.js       → キーボード入力
    joystick.js        → ジョイスティック入力

エントリーポイントの index.js は本当にこれだけです。

import App from './app/app';

new App();

核になるのはシングルトンのApp

このパターンの心臓部が app.js です。

import Init from './init';
import Loop from './loop';
import World from './world/world';

let instance = null;

export default class App {
  constructor() {
    if (instance) return instance; // 2回目以降は既存のインスタンスを返す
    instance = this;

    this.init = new Init();   // 共有リソースを最初に全部作る
    this.world = new World(); // ステージを組み立てる
    this.loop = new Loop();   // 最後にループを回し始める
  }
}

ポイントは最初の2行。JavaScriptのコンストラクタは return でオブジェクトを返すと new の結果を差し替えられるので、どこから何度 new App() しても同じインスタンスが返ってきます。これがシングルトンです。

これの何が嬉しいかというと、どのクラスからでも new App() と書くだけで共有リソースに手が届くことです。

// world/player.js
import App from '../app';
import * as THREE from 'three';
import { keyStates } from '../input/keyStates';

export default class Player {
  constructor() {
    this.app = new App(); // ← すでに作られたAppが返ってくる
    this.scene = this.app.init.scene;
    this.camera = this.app.init.camera;

    this.mesh = new THREE.Mesh(
      new THREE.CapsuleGeometry(0.5, 1),
      new THREE.MeshStandardMaterial({ color: 0xffaa44 })
    );
    this.scene.add(this.mesh);
  }

  loop(deltaTime) {
    // キー入力に応じて this.mesh を動かす
  }
}

シーンやカメラを引数でバケツリレーする必要がありません。プレイヤーに必要なもの(scene、camera、入力状態)をコンストラクタで自分から取りに行く。この「必要な依存を注入して組み立てる」構造がDIパターンと呼ばれる所以です(厳密にはサービスロケーター寄りですが、個人開発ではこの緩さがちょうどいいです)。

共有リソースはInitに集める

シーン・カメラ・レンダラー・ライト・リサイズ処理など、「全員が使うもの」は init.js に集約します。

// app/init.js
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

export default class Init {
  constructor() {
    this.clock = new THREE.Clock();

    this.scene = new THREE.Scene();

    this.camera = new THREE.PerspectiveCamera(60, 1, 0.1, 1000);
    this.camera.position.set(0, 8, 12);

    this.renderer = new THREE.WebGLRenderer({ antialias: true });
    this.renderer.setSize(window.innerWidth, window.innerHeight);
    document.body.appendChild(this.renderer.domElement);

    this.controls = new OrbitControls(this.camera, this.renderer.domElement);

    window.addEventListener('resize', () => this.resize());
    this.resize();
  }

  resize() { /* カメラとレンダラーの更新 */ }
}

こうしておくと、後から「CSS3Dレンダラーも足したい」「物理エンジンのワールドも共有したい」となったときに、Initにプロパティを1個足すだけで全クラスから使えるようになります。

ループは各クラスのloop()を呼んで回る

アニメーションループも1クラスに独立させて、各クラスに処理を委譲します。

// app/loop.js
import App from './app';

export default class Loop {
  constructor() {
    this.app = new App();
    this.init = this.app.init;
    this.loop();
  }

  loop() {
    const deltaTime = this.init.clock.getDelta();

    this.app.world.loop(deltaTime); // World → Player と伝播していく
    this.init.renderer.render(this.init.scene, this.init.camera);

    requestAnimationFrame(() => this.loop());
  }
}

WorldのloopがPlayerのloopを呼び、Playerが自分の移動を処理する。**「毎フレームやりたいことがあるクラスはloop(deltaTime)メソッドを持つ」**という規約だけ守れば、新しい登場人物(敵、アイテム、パーティクル…)が増えてもWorldのloopに1行足すだけで済みます。

初期化の順序には意味がある

Appのコンストラクタの並び順は適当ではありません。

  1. Init — シーンやレンダラーがないと何も始まらないので最初
  2. World(→ その中でPlayer) — Initのリソースを使って組み立てる
  3. Loop — 全部揃ってから回し始める

デモのコンソールに出る App Init! → Init Init! → World Init! → Player Init! → Loop Init! がまさにこの順序です。もしWorldをInitより先に作ると、this.app.init がまだ存在せずundefinedで落ちます。「Appが全体の組み立て図になっていて、依存の順番がコンストラクタに現れる」のがこのパターンの読みやすさです。

1点だけ罠があって、Worldなどの子クラスのコンストラクタ内で new App() すると、Appのコンストラクタがまだ完了していないインスタンスが返ってきます(instanceへの代入はコンストラクタの先頭で済んでいるため)。this.app.init は使えますが this.app.loop はまだ存在しない、という状態です。「コンストラクタでは自分より前に初期化されたものだけに触る」と覚えておけば大丈夫です。

ビルドツールについて

ファイルを実際に分けるとimport文でモジュールを読み込むことになるので、開発サーバーとしてViteを使うのが手軽です。

npm create vite@latest my-three-app
npm install three nipplejs
npm run dev

デモのHTMLは記事の都合で1ファイルに全クラスをまとめてありますが、コメントでファイル境界を示してあるので、そのままコピーして分割すれば上の構成になります。

まとめ

  • Three.jsのコードは「シングルトンのApp+役割ごとのクラス」で分割すると破綻しない
  • 共有リソース(シーン、カメラ、レンダラー)はInitに集約
  • どのクラスも new App() で共有リソースに手が届く=バケツリレー不要
  • 毎フレームの処理は各クラスの loop(deltaTime) に委譲
  • 初期化順序はAppのコンストラクタが一覧になっている

この構成にしておくと、次回やるマルチプレイヤー同期のような「後から足す大きめの機能」も、クラスを1個増やすだけで組み込めます。


Three.js解説シリーズ(全7回)

  1. 【Three.js入門】ビルドツール不要、CDNだけで始める基本の使い方
  2. レンダラー・ライト・カメラの使い方と設定まとめ
  3. キーボードとNipplejsでプレイヤーを動かす
  4. GLTFLoaderで3Dモデルをインポートする
  5. ファイル分割はDIパターンがおすすめ(この記事)
  6. CloudflareへのデプロイとWebSocketでの位置同期
  7. WebGLレンダラーとCSS3Dレンダラーを共存させる