ライフゲーム3Dの作り方

three.jsを使った3Dのライフゲームを作ったので、次に進むためにその中身について書き残しておきます。

http://ando-yasushi.appspot.com/conways-game-of-life/gameoflife.html

HTML

とりあえずHTML部分はこんな感じ。Three.jsは言わずと知れたWebGLをいい感じに使えるようにしてくれるライブラリ。RequestAnimationFrame.jsはそのThree.jsにおまけとして付いてくるrequestAnimationFrameをクロスブラウザで使えるようにしてくれるライブラリ。setIntervalなんてオワコンですよ・・・って思ってたけどイマドキはむしろrequestAnimationFrameがオワコンなん?よく分からん。

<!doctype html>
<html lang="en">
  <head>
    <title>Conway's Game of Life 3D</title>
    <meta charset="utf-8">
    <style>
    body { background-color:black; padding:0; margin:0; }   
    </style>
    <script src="js/Three.js"></script>
    <script src="js/RequestAnimationFrame.js"></script>
    <script src="js/jquery-1.7.1.min.js"></script>
    <script>
      // 略
    </script>
  </head>
  <body>
  </body>
</html>

立方体を点滅

まず立方体を一つ表示して点滅させてみます。

var CUBE_SIZE = 5;

$(function() {
  var container;
  var camera, scene, renderer;
  var mesh;

  initScene();
  animate();

  function initScene() {
    container = document.createElement('div');
    document.body.appendChild(container);

    scene = new THREE.Scene();

    camera = new THREE.PerspectiveCamera(70, window.innerWidth / window.innerHeight, 1, 10000);
    camera.position.set(0, 0, 100);
    scene.add(camera);

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

    var geometry = new THREE.CubeGeometry(CUBE_SIZE, CUBE_SIZE, CUBE_SIZE);
    var material = new THREE.MeshLambertMaterial({color:0xffff00})
    mesh = new THREE.Mesh(geometry, material);
    scene.add(mesh);

    renderer = new THREE.WebGLRenderer();
    renderer.setSize(window.innerWidth, window.innerHeight);

    container.appendChild(renderer.domElement);
  }

  function animate() {
    requestAnimationFrame(animate);
    render();
  }

  var stepCount = 0;
  function render() {
    stepCount++;
    if (8 < stepCount) {
      stepCount = 0;
      mesh.material.opacity = Math.abs(mesh.material.opacity - 1);
    }
    renderer.render(scene, camera);
  }
});

Three.jsの基本的な流れは以下のような感じになります。

  1. 3D空間を表すSceneを構築する
    1. 3D空間内の一オブジェクトを表すMeshを作成する
      1. 3Dの形状を定義するGeometryを作成する
      2. 色やテクスチャなど、どのように表示するかを定義するMaterialを作成する
      3. GeometryとMaterialを指定してMeshを作成する
    2. MeshをSceneに追加する
    3. 必要ならもっとMeshを作成してSceneに追加する
    4. オブジェクトをどう照らすかを示すLightを作成する(ライトがないとオブジェクトに色が付いていても画面は真っ暗でなにも表示されません)
    5. LightをSceneに追加する
  2. Sceneをどこからどのように見るかを表すCameraを初期化する
  3. 画面への表示を担当するRendererを作成する
  4. Renderer#renderにSceneとCameraを渡して画面に3D空間を描画する

オブジェクトをアニメーションする場合は、定期的にSceneを再構築するなり、Meshのプロパティを変更するなりして、Renderer#renderを呼び出すことになります。

今回はオブジェクトの透明度(mesh.material.opacity)を0 -> 1 -> 0 -> 1 -> ... と切り替えて点滅させることにしました。

真ん中で黄色い四角が点滅してます。正面から見てるから全然立方体っぽくありませんが、立体です。

いっぱい表示する

three.js的には立方体が一つだろうが複数だろうがあまり関係ないですが、寂しいのでたくさん表示するようにします。

次のような関数を定義して、meshを作っていた部分から呼び出すように変更します。

  function initCells() {
    var axisSize = Math.floor(CUBE_SIZE * 1.2);
    var offset = Math.floor(WORLD_SIZE * axisSize / 2);
    cells = new Array(WORLD_SIZE);
    for (var i = 0; i < WORLD_SIZE; i++) {
      cells[i] = new Array(WORLD_SIZE);
      for (var j = 0; j < WORLD_SIZE; j++) {
        cells[i][j] = new Array(WORLD_SIZE);
        for (var k = 0; k < WORLD_SIZE; k++) {
          var geometry = new THREE.CubeGeometry(CUBE_SIZE, CUBE_SIZE, CUBE_SIZE);
          var material = new THREE.MeshLambertMaterial({
            color:i == WORLD_SIZE - 1 ? 0xffff00 : 0xff0000
          })
          var mesh = new THREE.Mesh(geometry, material);
          mesh.position.set(
            axisSize * k - offset,
            axisSize * j - offset, 
            axisSize * i - offset 
          );
          cells[i][j][k] = mesh;
          scene.add(mesh);
        }
      }
    }
  }

また、meshを点滅させていた部分も次のように変更します。

      for (var i = 0; i < WORLD_SIZE; i++) {
        for (var j = 0; j < WORLD_SIZE; j++) {
          for (var k = 0; k < WORLD_SIZE; k++) {
            cells[i][j][k].material.opacity = Math.abs(cells[i][j][k].material.opacity - 1);
          }
        }
      }

たくさんの四角がまとめて点滅しています。一番手前の四角だけが黄色で、奥のものは赤色にしました。

ライトを追加

分かりにくいんですが、先程の例では右上手前だけから照らす並行光源しかないので、左下側は真っ黒になっていました。これだと立体感がいまいちなので左下奥から照らす並行光源を追加します。

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

ちょっと立体感が出ました。こんな感じにライトは一つのシーンにいくらでも追加できます。(ちなみになぜ環境光を与えてないかというと、元にしたサンプルのコードがそうだったからです)

ユーザーの入力に従ってカメラを動かす

平行光源をもう一つ追加してちょっと立体感が出ましたが、せっかくの3Dなので視点を自由に移動してもっと立体を堪能したいと思うのが人の常。three.jsにはControlsというオブジェクトが用意されていて簡単に望みを叶えてくれます。

    controls = new THREE.FirstPersonControls(camera);
    controls.movementSpeed = 10;
    controls.lookSpeed = 0.05
    controls.lon = -85;

    clock = new THREE.Clock();

FirstPersonControlsは一人称視点のコントロールで、利用するとキーボードやマウスで前後左右上下に動いたり、見上げたり、見回したりできるようになります。上記のように初期化しておいて

controls.update(clock.getDelta());

Renderer#render呼出し前にupdateメソッドを呼出します。引数に含まれているclockオブジェクトはTHREE.Clockのインスタンスで、(たぶん)アプリケーション内での時間の経過を測ってくれています。アプリケーション起動時にインスタンス化しておいてください。

マウスやW/A/S/D/R/Fキーなんかで視点を動かして近くに寄ってみました。

パーティクルを散らす

コントロールを追加して視点移動できるようにしたんですが、セルが画面に入らない真っ黒な空間を眺めると、操作していてもどう動いているのか分からず難儀します。いわゆる空間識失調です(嘘)。画面にランダムに何かを散らしてセルが見えていない部分でも視点の移動を認識できるようにしましょう。

three.jsにはParticleSystemというクラスがあるのでこれを使います。

    var geometry = new THREE.Geometry();
    for (var i = 0; i < 20000; i++) {
      var vector = new THREE.Vector3(
        Math.random() * 2000 - 1000, 
        Math.random() * 2000 - 1000, 
        Math.random() * 2000 - 1000
      );
      geometry.vertices.push(new THREE.Vertex(vector));
    }
    var material = new THREE.ParticleBasicMaterial({size:1});
    var particles = new THREE.ParticleSystem(geometry, material);
    scene.add(particles);

立方体を作るときに使ったMeshクラスの代わりにParticleSystemを使う感じですね。

真っ黒だった部分に白い粒子が散って動きがわかるようになりました。

ちなみにパーティクルシステムはホントはこんなふうに炎のようなモヤッとしたものを表現するのに使うことが多いみたいです。

マウスでクリックした部分にあるオブジェクトを取得する

ただ見てるだけというのもいまいち退屈なので、マウスクリックでセルを作ったり消したりできるようにします。

  projector = new THREE.Projector();
  // ...snip...
 
  $(document).click(function(evt) {
    var x = (evt.clientX / window.innerWidth) * 2 - 1;                                                
    var y = -(evt.clientY / window.innerHeight) * 2 + 1;                                              
    var vector = new THREE.Vector3(x, y, 1);
    projector.unprojectVector(vector, camera);                                                        
    var ray = new THREE.Ray(camera.position, vector.subSelf(camera.position).normalize());            
    var intersects = ray.intersectScene(scene);                                                       
    if (intersects.length != 0) {                                                                     
      intersects[0].object.material.opacity = Math.abs(intersects[0].object.material.opacity - 1);
    }
  });

カメラ座標でのマウスをクリックした位置を取得するにはProjector#unprojectVectorを使います。その位置からカメラの向きに線(Ray)を伸ばして、その線と交わるオブジェクト(ray.intersectScene(scene))を取得、最初に交わったオブジェクトの表示非表示を切り替えています。

クリックしたら一番手前の黄色いセルが消えて奥の赤いセルが見えるようになりました。

ライフゲームにする

最後にセルの表示非表示をルールにそって切り替えるようにしてライフゲームにします。three.js関係なさすぎるので詳細は省略。

そういえば常識なのかも知れないけど、ライフゲームは海外だと「Conway's Game of Life」かせめて「Game of Life」って言わないと通じないです。「Life Game」って言ったらどうにも通じなくて困ったことが以前・・・。

ということで

完成した3Dライフゲームソースコードgithubに上げました。何かのお役に立てば幸いです。

https://github.com/technohippy/threejs-toys/tree/master/conways-game-of-life