国産ゲームライブラリ「phina.js」で3Dゲーム作ろうぜ！


　@emadurandalさんです、こんばんは。

　この記事はphina.js Advent Calendar 2015の記事の１つです。

　今年末、リリースされたばかりの新ゲームライブラリ「[phina.js](http://phinajs.com)」で3Dゲームを作っちゃおうという内容です。

# phina.jsの概要

　phina.jsは2D Canvas描画ベースのゲームライブラリです。
　前身であるゲームライブラリ「[tmlib.js](http://phi-jp.github.io/tmlib.js/)」のAPI・設計を整理し、より洗練させたものになっています。
（ライブラリの概要については@phiさんの[１日目の記事](http://phiary.me/phinajs-release/)を、その設計の詳細については[私が書いたこの記事](http://qiita.com/emadurandal/items/ac6d7a74a9b5521018d5)を参照してください）

　phina.jsの基本設計自体は決して2Dに制限されているわけではありませんが、現在のバージョンでは、描画関係の機能のほとんどは2D Canvasへの二次元的なグラフィックス描画を前提にしたものになります。
　
　では、phina.jsでは3Dゲームは作れないのでしょうか？ いや、それが作れるのです！（反語）

# Three.jsと連携できる！

　「作れる！」と言っても、phina.js自身には3D機能は「まだ」無いので、他のライブラリの力を借りることになります。はい、みなさんご存知の「[Three.js](http://threejs.org)」です。
　
　phina.jsには、ThreeLayerという、Three.jsとの連携用の描画クラスが用意されています。このレイヤーが、Three.jsの３D描画結果をphina.jsが管理する2D Canvasにコピーしてくれます。

## サンプルコード

　もっとも[簡単なサンプルを例示](http://goo.gl/lUVJWd)します（作ってくださった @phi さん、ありがとうございます。すみません、そのまま使わせていただきますw）。

```javascript
phina.globalize();

phina.define('MainScene', {
  superClass: 'CanvasScene',
  
  init: function(options) {
    this.superInit();
    
    var SCREEN_WIDTH = 640;
    var SCREEN_HEIGHT = 960;
    var layer = phina.display.ThreeLayer({
      width: options.width,
      height: options.height,
    }).addChildTo(this);

    var geometry = new THREE.BoxGeometry( 20, 20, 20 );

    for ( var i = 0; i < 2000; i ++ ) {
      var object = new THREE.Mesh( geometry, new THREE.MeshLambertMaterial( { color: Math.random() * 0xffffff } ) );

      object.position.x = Math.random() * 800 - 400;
      object.position.y = Math.random() * 800 - 400;
      object.position.z = Math.random() * 800 - 400;

      object.rotation.x = Math.random() * 2 * Math.PI;
      object.rotation.y = Math.random() * 2 * Math.PI;
      object.rotation.z = Math.random() * 2 * Math.PI;

      object.scale.x = Math.random() + 0.5;
      object.scale.y = Math.random() + 0.5;
      object.scale.z = Math.random() + 0.5;

      layer.scene.add( object );
    }

    var theta = 0;
    var radius = 100;
    layer.update = function() {
      theta += 0.5;

      this.camera.position.x = radius * Math.sin( THREE.Math.degToRad( theta ) );
      this.camera.position.y = radius * Math.sin( THREE.Math.degToRad( theta ) );
      this.camera.position.z = radius * Math.cos( THREE.Math.degToRad( theta ) );
      this.camera.lookAt( this.scene.position );

      this.camera.updateMatrixWorld();
    }
    
    var label = Label('phina.js のラベルも\n一緒に表示').addChildTo(this);
    label.fill = 'white';
    label.stroke = 'black'
    label.fontSize = 64;
    label.strokeWidth = 4;
    label.x = this.gridX.center();
    label.y = this.gridY.center();
  },
});

phina.main(function() {
  var app = GameApp({
    startLabel: 'main',
  });
  
  app.run();
});
```

　
　Three.jsで立方体を2000個ほど作り、ランダムな位置に配置しています。
　さらにカメラを原点向かせつつ回転させることで、どことなくサイバーな世界を表現できていますね。
　phina.jsの2D機能であるラベルも、同時に表示できています。3D描画はThree.js、UI部分はphina.jsに担当させる、という役割分担は鉄板のアプローチですね。

　さらに、もう少し複雑なサンプルを作ってみました。
　
* [物理遊び - ザ・運動量保存則](http://goo.gl/IVXUuS)

　左上のゲーム画面をマウスでクリックすると、ボールが生成されて、物理法則に従って箱の中で飛び跳ね回ります。ボール同士もきちんと衝突し、反発しあうようになっています。
　１分以内に、20個以上のボールを真ん中の穴から落とせばクリアです。ただし、その間にボール同士の衝突数が100回を超えるとゲームオーバーです。一度にたくさんボールを投入すると、衝突数があっという間に増えてしまいますし、かといってボールの投入の間隔がゆっくりすぎると、今度は制限時間に間に合わなくなってしまいます。絶妙なボールの投入頻度を見つけてみてください。

　さて、これのサンプルでは、コードを見るとわかりますが、既存の物理ライブラリなどは使っておらず、自前で物理処理を実装しています。といってもそんなに難しいことはしておらず、運動量保存則と反発係数の式を使っているだけです（高校物理の範囲だと思いますので、ネットで検索すると割とすぐ説明が見つかるかもしれませんね）。球の回転は考慮していません。
　もちろん、ammo.jsなどの、Three.jsと連携できる物理ライブラリを使うこともできると思います。皆さんが物理をしたい場合は、そこはお好みで選んでみてはいかがでしょうか。

　ちなみに、上のサンプルの、まるでJSBinやjsdoのようなJavaScript実行サービスは何？　と思われたかもしれません。
　これは、phina.jsのメインアーキテクトである @phi さんが開発された、JavaScript実験サービス「[Runstant](http://runstant.com/about)」です。「インスタント」と語感が似ていることからもわかるように、試したいコードを本当に楽に試せますので、こちらのサービスもぜひみなさん、使ってみてください。
　
　話がそれましたが、phina.jsとThree.jsを連携させると、このくらいの3Dゲームはいとも簡単に作れてしまいます。おそらく、やる気さえあればもっとすごいゲームも作れるでしょう。
　皆さんも是非挑戦してみてください。

　Three.jsの使い方については、本記事ではあまり深く追うことはしませんが、日本語で良い入門書が出ています。
　
* [three.jsによるHTML5 3Dグラフィックス〈上〉―ブラウザで実現するOpenGL(WebGL)の世界](http://www.amazon.co.jp/dp/4877833242)
* [three.jsによるHTML5 3Dグラフィックス〈下〉―ブラウザで実現するOpenGL(WebGL)の世界](http://www.amazon.co.jp/dp/4877833315)

　まずはこちらでThree.jsを勉強していただき、さらにゲームロジックやユーザー入力の部分について、phina.jsアドベントカレンダーの以下の記事をご覧になれば、それらの知識を組み合わせて、たいていの3Dゲームが作れるようになると思います。

* [phina.js のキーボード操作（倉庫番を作ってみた話）](http://qiita.com/YFukuyama/items/346678a6038e1db8e9be)
* [[phina.js] Tweenerを使いこなそう! [Tweener 基本編]](http://qiita.com/simiraaaa/items/b80fec588fa2db272f67)
* [ManagerSceneでゲームの流れを管理しよう](http://github.dev7.jp/b/2015/12/01/phinaadvcal20151202/)

## ThreeLayerの中身を覗いてみよう

　さて、ここで、ThreeLayerの中はどうなっているのか、ちょっと覗いてみましょう。
　phina.js Ver0.1.1では、以下のような実装になっています。

```
phina.namespace(function() {

  /**
   * @class
   */
  phina.define('phina.display.ThreeLayer', {
    superClass: 'phina.display.CanvasElement',

    scene: null,
    camera: null,
    light: null,
    renderer: null,

    /** 子供を 自分のCanvasRenderer で描画するか */
    renderChildBySelf: false,

    init: function(params) {
      this.superInit();

      this.scene = new THREE.Scene();

      this.camera = new THREE.PerspectiveCamera( 75, params.width / params.height, 1, 10000 );
      this.camera.position.z = 1000;

      this.light = new THREE.DirectionalLight( 0xffffff, 1 );
      this.light.position.set( 1, 1, 1 ).normalize();
      this.scene.add( this.light );

      this.renderer = new THREE.WebGLRenderer();
      this.renderer.setClearColor( 0xf0f0f0 );
      this.renderer.setSize( params.width, params.height );

      this.on('enterframe', function() {
        this.renderer.render( this.scene, this.camera );
      });
    },

    draw: function(canvas) {
      var domElement = this.renderer.domElement;
      canvas.context.drawImage(domElement, 0, 0, domElement.width, domElement.height);
    },
  });
});
```

　Three.jsの方でも、自身で描画ターゲットとなるCanvas要素を保持しています。ThreeLayerでは、drawメソッドでそれを取り出して、3D Canvasに描画されたイメージをphina.jsの2D Canvasに転送しています。

　また、パースペクティブカメラ、ディレクショナルライトといった、モノを表示する上で最低限必要なオブジェクトはあらかじめ内部で作られており、この外側でメッシュやプリミティブをシーンに追加すれば、すぐに描画が行われることが見て取れますね。

　人によっては、「このデフォルトのカメラやディレクショナルライトが邪魔だ」という方もいらっしゃるかもしれません。phina.jsの今後のバージョンでは、引数でこれらの有無を指定できるようにしてもいいかもしれませんね。

## phina.jsを通してThree.jsを使う意義

　「結局、3D処理はほぼ生のThree.jsを使って作ることになるなら、phina.jsを通さずに直接Three.jsだけを使ってもよくね？」

　そういう疑問も出てくることでしょう。しかし、phina.jsと連携することで以下のようなメリットが享受できます。

* ゲームUIを、phina.jsの豊富な2D描画機能・UI Widgetクラスに任せられる。
* ユーザーのキー入力・ゲームパッド入力など、phina.jsによって色々なイベントに対応させることが容易になる。
* サウンド（BGM、効果音など）の処理をphina.jsに任せられる。
* ゲームプログラムの設計を、phina.jsのフレームワーク作法に従わせることで、整理された状態で楽にゲームを開発できる。

　ゲームの構成要素はグラフィックスだけではありません。サウンド、UI、イベント処理など、多数の要素で成り立っていますから、こうした部分もphina.jsによる手厚いサポートがあると、開発効率がぐんと変わってきます。

　特にUIに関しては、phina.jsの豊富な2D Canvasへの描画機能・UI部品を使ってUIイメージを作り、そのイメージを（2D CanvasはWebGLでテクスチャとして扱えるので）、Three.jsでテクスチャとしてポリゴンに貼ることで、最近の３Dゲームでよくある三次元UI（奥の方に斜めったUIとか）を実現するという応用もできると思います。
　（ちょうど今、私が「そうした処理を高級APIで一発でできる機能をphina.jsに入れるのはどうか」と、開発チームに提案しているところです）

　phina.jsとThree.jsを合わせることで、可能性は大きく広がるのです。

# 番外：第２の選択肢「GLBoost」との連携

　「[GLBoost](https://github.com/emadurandal/GLBoost)」とはなんぞ！？
　ほとんどの人は聞いたこともないですよね。それもそのはず、私 @emadurandal が独自に開発を始めたWebGLレンダリングライブラリです。
　なんでそんなものを引き合いに……？　ごもっともな疑問です。実は、私のこの「[GLBoost](https://github.com/emadurandal/GLBoost)」ライブラリは、将来的にphina.jsの3D機能としての採用が予定されているのです！

　とはいうものの、それがどのような形での統合となるのか、現在のThreeLayerのような疎結合な形なのか、それともphina.jsに内蔵される形でのガッツリな統合となるのか、そこらへんはまだ協議中ではっきりとは決まっていません。

　しかし、すでに私の[GLBoost](https://github.com/emadurandal/GLBoost)のリポジトリでは、phina.js用にGLBoostLayerクラスを追加しています。これを使えば、ThreeLayerと同じ感じで、phina.jsでGLBoostによる3D機能が利用できるようになります。

### 注意

　GLBoostはまだまだ開発が始まったばかりのベリーアーリーデベロップメント段階です。
　私個人としては、まだバージョン0.0.x程度の出来であると認識しています。
　つまり、今後API仕様がまだまだコロコロ変わりうるということです。ご体験いただく際は、冷やかし程度の気持ちで、間違ってもAPI仕様をバッチリ覚えようとか思わないでください（そのお気持ちはありがたいですが、多分将来のバージョンで使えない知識になりそうです）。

## サンプルコード

ThreeLayerとわかりやすく対比できるように、最初にご紹介したサンプルと同内容のデモのGLBoostLayer使用版のコードを例示します。

[デモ実行はこちら](http://goo.gl/nAvgn3)から。

```javascript

GLBoost.TARGET_WEBGL_VERSION = 1;
var SCREEN_WIDTH = 640;
var SCREEN_HEIGHT = 640;

phina.globalize();

phina.define('MainScene', {
  superClass: 'CanvasScene',

  init: function(options) {
    this.superInit();

    var layer = phina.display.GLBoostLayer({
      width: SCREEN_WIDTH,
      height: SCREEN_HEIGHT
    }).addChildTo(this);

    var shader = new GLBoost.PhongShader(layer.canvas);
    var geometry = new GLBoost.Cube(new GLBoost.Vector3(20,20,20), new GLBoost.Vector4(1, 1, 1,1), layer.canvas);

    for ( var i = 0; i < 2000; i ++ ) {
      var material = new GLBoost.ClassicMaterial(layer.canvas);
      var object = new GLBoost.Mesh(geometry, material);
      material.shader = shader;
      material.baseColor = new GLBoost.Vector4(Math.random(), Math.random(), Math.random(), Math.random()*0.5+0.5);

      object.translate.x = Math.random() * 800 - 400;
      object.translate.y = Math.random() * 800 - 400;
      object.translate.z = Math.random() * 800 - 400;

      object.rotate.x = Math.random() * 2 * Math.PI;
      object.rotate.y = Math.random() * 2 * Math.PI;
      object.rotate.z = Math.random() * 2 * Math.PI;

      object.scale.x = Math.random() + 0.5;
      object.scale.y = Math.random() + 0.5;
      object.scale.z = Math.random() + 0.5;

      object.dirty = true;

      layer.scene.add( object );
    }

    var camera = new GLBoost.Camera(
        {
          eye: new GLBoost.Vector4(0.0, 5, 15.0, 1),
          center: new GLBoost.Vector3(0.0, 0.0, 0.0),
          up: new GLBoost.Vector3(0.0, 1.0, 0.0)
        },
        {
          fovy: 45.0,
          aspect: 1.0,
          zNear: 0.1,
          zFar: 1000.0
        }
    );
    layer.scene.add( camera );

    layer.scene.prepareForRender();

    var theta = 0;
    var radius = 100;
    layer.update = function() {
      //var rotateMatrix = GLBoost.Matrix44.rotateY(-0.02);
      //var rotatedVector = rotateMatrix.multiplyVector(camera.eye);
      theta += 0.5;
      camera.eye = new GLBoost.Vector3(
          radius * Math.sin( theta * Math.PI / 180 ),
          radius * Math.sin( theta * Math.PI / 180 ),
          radius * Math.cos( theta * Math.PI / 180 ));
    };


    var label = Label('phina.jsとGLBoostの\n夢の共演！').addChildTo(this);
    label.fill = 'white';
    label.stroke = 'black';
    label.fontSize = 32;
    label.strokeWidth = 4;
    label.x = this.gridX.center();
    label.y = this.gridY.center();
  }
});

phina.main(function() {
  var app = GameApp({
    startLabel: 'main',
    width: SCREEN_WIDTH,
    height: SCREEN_HEIGHT
  });

  app.run();
});
```

　ThreeLayerを使うバージョンと、ほとんど変わらないくらいの手軽さで、３Dシーンを作っていけることがわかりますね。

　他にもGLBoostでは、ユーザーが簡単にカスタムシェーダーを作ることができ、しかもそれを既存のGLBoostのシェーダーと効果を合体させることができます。

* [ティーポット変形デモ](http://jsdo.it/emadurandal/qbfM)
* [海面デモ](http://goo.gl/IIEyMX)

　シェーダーのカスタマイズ性という面では、GLBoostはすでにThree.jsを上回っているかもしれません（！？）

## GLBoostLayerの中身を覗いてみよう

ThreeLayerとほとんど内容は同一です。	

```javascript
phina.namespace(function() {

  /**
   * @class
   */
  phina.define('phina.display.GLBoostLayer', {
    superClass: 'phina.display.CanvasElement',

    scene: null,
    camera: null,
    light: null,
    renderer: null,
    canvas: null,

    /** 子供を 自分のCanvasRenderer で描画するか */
    renderChildBySelf: false,

    init: function(params) {
      this.superInit();

      this.canvas = document.createElement("canvas");
      this.canvas.id = 'glboost_world';
      this.canvas.width = params.width;
      this.canvas.height = params.height;


      // レンダラーを生成
      this.renderer = new GLBoost.Renderer({ canvas: this.canvas, clearColor: {red:0, green:1, blue:0, alpha:1}});
      this.scene = new GLBoost.Scene();
      this.on('enterframe', function() {
        this.renderer.clearCanvas();
        this.renderer.draw(this.scene);
      });
    },

    draw: function(canvas) {
      var domElement = this.canvas;
      canvas.context.drawImage(domElement, 0, 0, domElement.width, domElement.height);
    },
  });
});

```

　ThreeLayerと違い、GLBoostLayerではライトを生成するコードが見当たりません。
　さらに、GLBoostLayerを利用しているデモコード側でもライトを一切生成していないにも関わらず、ちゃんとキューブたちに光が当たっています。
　これはGLBoostの仕様として、ユーザー側でライトを生成・シーンに追加していない場合は、ライブラリ内部で原点位置にポイントライトを一つだけ自動で設置してくれるようになっているためです。いわゆるCG制作ソフトなどで良くある「デフォルトライト」ですね。ちなみにユーザー側で１つ以上のライトを設置した場合は、このデフォルトライトは無効になります。


　さてさて、ざっとこんな感じなんですが、

「ThreeLayerと使い方がほとんど変わらないなら、別にGLBoostなんて使わなくてもThree.jsでいいじゃん」

　　とお思いになる方もいらっしゃるかと思います。確かに、現時点ではごもっともです。
　ただし、GLBoostはThree.jsとはかなり違った方向性で開発を進めていく予定ですので、今後はThree.jsとは違った使い方・特長も出していくことができるかと思っています。今後にご期待ください^^;
　（現時点でのGLBoostの設計の一端を、[この記事で垣間見ることができます](http://qiita.com/emadurandal/items/3e8e336759f079bd2886)。もしご興味のある方はお付き合いください）

　このように、ThreeLayerとGLBoostLayerは非常に疎結合な仕組みでphina.jsと連携が取られています。まだphina.jsは開発が始まって間もないこともあり、WebGLライブラリとのこうした連携は、まぁ言ってしまえば正直「間に合わせ感」もあります。ですが良い言い方をするなら、おせっかいに邪魔される部分がなく、3Dライブラリの本来の機能をフルに引き出して好き放題にできる、という面もあります。

　とはいえ、やはり今後は柔軟性やプログラマビリティを損なわない形で、より高度な連携アプローチも模索されていくことになるのではないかと思います。

# まとめ

　いかがでしょうか。繰り返しになりますが、ゲームに必要な要素はグラフィックスだけではありません。サウンド、イベント処理、UIなど、非常に多くの要素から構成されます。
　そうした複雑なゲームを作るにあたって、phina.jsと[Three.js](http://threejs.org)（将来的には[GLBoost](https://github.com/emadurandal/GLBoost)も？）の組み合わせは、これからの開発ソリューションの一つと言えるのではないかと思います。
　まだ足りない部分もあるかもしれませんが、自信を持ってそう言えるようになるよう、今後も貪欲に開発が進められることでしょう。
　
　[Three.js](http://threejs.org)/[GLBoost](https://github.com/emadurandal/GLBoost)との連携は、現在はかなり疎結合な形で実現されていますが、先ほども言及した通り、今後は別の統合のアプローチもありえるかもしれません。
　（私個人としては、3Dライブラリに投げっぱなしではなく、phina.jsの作法にきちんと包んだ形で、より高級なAPIを提供すべきではないかと思っています。Three.jsほどの大きなプロジェクトが相手だと、phinaの事情に合わせて何かしてくれることを期待するのは難しいですが、私のGLBoostであれば、phina.jsと非常に緊密な連携をとって一緒に統合のあり方を模索することが可能です。phina.jsと一緒に使うという面において、GLBoostの方に有利な点があるとすれば、そこでしょうか）

　皆様のご意見も募集しております。もし、「こういう形で実現してほしい」等のご要望・フィードバックあれば、 私 @emadurandal や [phina.js](http://phinajs.com) 開発者の@phiさんのTwitterアカウント、もしくは[phina.jsのGitter](https://gitter.im/phi-jp/phina.js)にてコメントいただければと思います。
　
　本記事をお読みいただき、誠にありがとうございました。