HTML5Experts.jp

WebGLとWebSocketによる3Dオンラインレースゲーム「JS-Racing」の全て!(前編)

今回はHTML5JapanCup2014にてWebGL賞優秀賞を受賞したオンラインレースゲーム、JS-Racingの技術解説を書かせていただきます。

このコンテンツはWebGLの3D表現を活かした3Dレースゲームです。
また、WebSocketを使用しサーバを介して、複数のクライアントでの同時走行が可能なオンラインゲームになっています。同時に、ソケット通信時に発行されるソケットIDをPCとスマートフォンで共有することで、スマートフォンからPC上の車を操作することも可能です。

クライアントサイドの使用技術

クライアントサイドの構築において、目的・用途に応じて使用した言語やライブラリに関して解説します。

TypeScriptによるクラス設計

クライアントサイドのメインとなるロジックをTypeScriptを使用して設計しました。TypeScriptはマイクロソフトによって開発されたフリーでオープンソースのプログラミング言語です。大規模JavaScriptアプリケーションに向けて開発されたTypeScriptは、これまでのJavaScriptにはない以下の機能を有しています。

TypeScriptはコンパイルして最終的にJavaScriptに出力することができます。今回のように多くの機能の実装が必要になるWebアプリケーションでは、クラスベースのオブジェクト指向言語的な特徴を持つTypeScriptは、設計しやすく非常に有効なものになります。

ActionScript3と同じように、1つのTSファイル(TypeScriptファイル)にクラスを1つだけ記述して、ファイル名とクラス名を一致させました。また、モジュール毎にディレクトリを作成して、クラスを格納しています。これにより機能毎にモジュールとクラスを整理する事ができ、再利用しやすく見やすいコードになりました。最終的には、クラス毎に作成したTSファイルを、クラス毎のJavaScriptファイルに出力(コンパイル)し、Gruntで結合、圧縮して、実際に読み込むJavaScriptファイルにしています。

今回の場合、モジュールの分け方は、まず表示要素とロジックにモジュールを分けました。表示要素はゲームの各シーンや、3D描画、画面下部のランキング表示というように、表示要素単位でさらにモジュールを細分化していきます。ロジックは、イベントや値管理、定数、ユーティリティ、コースデータ管理、物理演算、リアルタイム通信といった、機能単位でモジュールを細分化していきます。このようにモジュールを分けていくことで、大規模なコンテンツ開発の際には、各クラスの責任範囲が明確化でき、バグが発生した際にも、原因を特定しやすく、さらには仕様変更にも強くなります。

TypeScriptは大規模開発においては、非常に設計しやすく、クラスベースのオブジェクト指向言語のメリットを最大限に享受できるものだと思います。ただその分、設計における手間と、なによりコード記述量が多くなります。中規模以下の案件では、最低限の設計で手軽に構築できるCoffeeScriptも、よい選択肢ではないかと思います。

Box2DJSを使った2D物理演算

車を走らせたり、障害物と衝突したりといった、ゲームの中核となるロジックはBox2DJSを使用しています。Box2DJSは、質量・速度・摩擦をシミュレーションするJavaScriptの2D物理演算エンジンです。

表現方法自体は3Dですが、物理演算エンジンは2Dを使用しました。今回のようなシンプルなゲームを作るには、3Dの物理演算エンジンより、2Dの物理演算エンジンの方が、ゲームバランスや操作感において向いていると感じます。コンシューマーゲームなどの本格的なゲームとなると、3D物理演算エンジンによる処理は、必要不可欠なものだとは思いますが、その分、マシンへの負荷も高くなります。

物理演算に関しては、別途サンプルを作成して、チューニングしていきました。ゲームの中核となる部分ですので、まずこのサンプルで楽しいと思えるかどうかで、最終的なゲームのゴールが見えてくると思います。可能な限りゲーム性向上のためのチューニングしていくことが大切です。

初期段階のモックアップのイメージですが、まずはこのようにBox2Dを使って、2Dレースゲームを構築しました。この2Dレースゲームの情報を3D表示部分や情報部分(スピードメーター等)やコースデータ管理ロジック、リアルタイム通信ロジックに渡します。

物理演算クラスimjcart.logic.physics.Physicsにて、マップ情報を元にnew Carで車インスタンスを生成し、車の状態が変更したイベントCHANGE_CAR_CONDITION_EVENTをキャッチして、イベントを発行します。

public createCar() {
    // マップ情報を元に車を生成
    var x = 0;
    var y = 0;
    var i, j, max, max2;
    for (i = 0, max = imjcart.logic.map.value.MapConst.MAP.length; i < max; i = i + 1) {
        for (j = 0, max2 = imjcart.logic.map.value.MapConst.MAP[i].length; j < max2; j = j + 1) {
            if (imjcart.logic.map.value.MapConst.MAP[i][j] == imjcart.logic.map.value.MapConst.MAP_KEY_CAR_START_POSITION) {
                y = imjcart.logic.map.value.MapConst.MAP_BLOCK_SIZE * i;
                x = imjcart.logic.map.value.MapConst.MAP_BLOCK_SIZE * j;
                break;
            }
        }
    }
    this._car = new Car(this._context, this._world, x, y);
    // 車状態変更イベント
    this._car.addEventListener(event.PhysicsEvent.CHANGE_CAR_CONDITION_EVENT, (evt) => {
        this.dispatchEvent(imjcart.logic.physics.event.PhysicsEvent.CHANGE_CAR_CONDITION_EVENT, {
            x: evt.x,
            y: evt.y,
            bodyAngle: evt.bodyAngle,
            wheelAngle: evt.wheelAngle,
            speed: evt.speed,
            power: evt.power,
            gear: evt.gear,
            direction: evt.direction
        });
    });
} 

imjcart.logic.physics.Carクラスでは、update関数が実行されると、入力されている値をホイールに反映し、車の状態に変化を与えます。変化した車の情報を付加して、車の状態が変更したイベントCHANGE_CAR_CONDITION_EVENTを発行します。

public update() {
    // ホイールに力を適応
    var i = 0, max;
    for (i = 0, max = this._frontWheels.length; i < max; i = i + 1) {
        var wheel = this._frontWheels[i];
        var direction = wheel.GetTransform().R.col2.Copy();
        direction.Multiply(this._enginePower);
        wheel.ApplyForce(direction, wheel.GetPosition());
    }
    // ホイールと車をつなぐジョイント部分に角度を適応
    for (i = 0, max = this._frontWheelJoints.length; i < max; i = i + 1) {
        var wheelJoint = this._frontWheelJoints[i];
        var angleDiff = this._steeringAngle - wheelJoint.GetJointAngle();
        wheelJoint.SetMotorSpeed(angleDiff * this._steerSpeed);
    }
    // 車状態変更イベントの発行
    var position = this._body.GetPosition();
    var velocity = this._body.GetLinearVelocity();
    var speedX = Math.abs(velocity.x);
    var speedY = Math.abs(velocity.y);
    var speed = 0;
    if (speedX < speedY) {
        speed = speedY;
    } else {
        speed = speedX;
    }
    this.dispatchEvent(imjcart.logic.physics.event.PhysicsEvent.CHANGE_CAR_CONDITION_EVENT, {
        x: position.x,
        y: position.y,
        bodyAngle: this._body.GetAngle(),
        wheelAngle: this._frontWheelJoints[0].GetJointAngle(),
        speed: speed,
        power: this._enginePower,
        gear: this._gear,
        direction: this._engineDirection
    });
}

dispatchEventメソッドは、イベントを発行するlib.event.EventDispacherクラス(オリジナルで作成)に定義されたメソッドです。表示要素のクラスはこのlib.event.EventDispacherクラスを継承しています。

three.jsの3D表現

WebGLを使った3D表示部分にはthree.jsを使用しています。three.jsは、WebGLを扱うライブラリとしては、最もよく使われているJavaScriptライブラリです。

Blenderというオープンソースの3次元コンピュータグラフィックスソフトウェアを使って、3Dモデルの作成を作成し、three.jsで読み込んで表示しています。Blenderで作成する3Dモデルデータ(今回のモデルデータはOBJ形式)は、ジオメトリ(形状)とUVテクスチャのみで、three.jsを使って、質感などの調整をしています。

また、今回のように実行環境が制限しづらいWebアプリケーションでは、なるべく多くのユーザーが利用できるように、マシンへの負荷は極力減らさないといけません。特にレースゲームは、現在走っているコースの先が見える必要があり、視野を広くしなければならないために、処理するデータ量が増え、より負荷がかかってしまいます。この負荷軽減のために、以下のことを考慮しています。

  1. ポリゴンが少なくても成り立つデザイン
  2. 影をつけない
  3. ジオメトリ(形状)の結合
  4. 負荷の少ないマテリアル(質感)を選択

1つめのポリゴンが少なくても成り立つデザインに関しては、下のタイヤのサンプルで、左のタイヤはポリゴン数を極端に減らしていますので、光や深度の計算が少なくなり、マシンへの負荷も小さくなります。右のタイヤはリアルなタイヤを再現したために、ポリゴン数も多く、マシンへの負荷も大きくなります。配置する量が少数ならそれほど問題になりませんが、コースデータに沿って多数配置する障害物ですので、マシン負荷は大きくなります。ただ、ポリゴン数が少ない分、リアルな表現からは遠く離れてしまっています。コース全体のデザインも含めてですが、リアルな表現を求めないポップなデザインを採用することで、低ポリゴンでも違和感のないようにしています。

2つめの影をつけないですが、下のキャプチャを見ていただければ、影がない左側のキャプチャと影がある右側のキャプチャでは、3D空間としての説得力にだいぶ違いが出てしまっているかと思います。ここは苦渋の決断ですが、影をつけることはthree.jsではとても負荷のかかる処理なので、より多くのユーザにストレスなく遊んでもらうためにも影をつけない方を採用しました。

ちなみに影を出すには、レンダラーのshadowMapEnabledtrueにして、光源のcastShadowtrueにします。そして影を作る側の物体のcastShadowtrueにして、影を受ける側の物体のreceiveShadowtrueにします。デフォルトではfalseのため影が出ません。

// レンダラー
renderer.shadowMapEnabled = true;
// 光源
light.castShadow = true;
// 影を作る側
mesh.castShadow = true;
// 影を受ける側
mesh.receiveShadow = true;

3つめのジオメトリの結合に関してですが、WebGLコンテンツの様にGPUを使用するコンテンツは、ジオメトリの数だけGPUに対してドローコール(描画命令)をします。このドローコールの回数はパフォーマンスに大きく影響します。ジオメトリをTHREE.GeometryUtils.merge(geometry, mesh)メソッドで結合することで、不要なドローコールの発生を少なくすることができます。注意点としては、結合したものは個別に動かすことや、マテリアルを個別に設定することはできませんので、今回は壁やコースなど、個別に動くことのない、大量に描画する必要のある要素に対してジオメトリの結合をしています。

var geometry = new THREE.Geometry(); // 空のジオメトリを作成
var meshArr = [];
// モデルデータとマテリアルを読み込む
var loader = new THREE.OBJMTLLoader();
loader.load("models/block03/block03.obj", "models/block03/block03.mtl", (object) => {
    object.traverse(function (child) {
        if (child instanceof THREE.Mesh) {
            child.material = new THREE.MeshLambertMaterial(child.material);
            child.material.ambient = new THREE.Color(imjcart.display.main.view3d.value.View3dConst.AMBIENT_COLOR);
            meshArr.push({
                mesh: child,
                material: child.material
            });
        }
    });
    // 読み込んだモデルデータをマップデータに沿って、クローンして配置。
    var i, j, max, max2;
    for (i = 0, max = imjcart.logic.map.value.MapConst.MAP.length; i < max; i = i + 1) {
        for (j = 0, max2 = imjcart.logic.map.value.MapConst.MAP[i].length; j < max2; j = j + 1) {
            if (imjcart.logic.map.value.MapConst.MAP[i][j] == imjcart.logic.map.value.MapConst.MAP_KEY_BLOCK) {
                var tagX = imjcart.logic.map.value.MapConst.MAP_BLOCK_SIZE * j + (imjcart.logic.map.value.MapConst.MAP_BLOCK_SIZE / 2);
                var tagY = 1;
                var tagZ = imjcart.logic.map.value.MapConst.MAP_BLOCK_SIZE * i + (imjcart.logic.map.value.MapConst.MAP_BLOCK_SIZE / 2);
                var mesh = meshArr[1].mesh.clone();
                mesh.position.set(tagX, tagY, tagZ);
                THREE.GeometryUtils.merge(geometry, mesh); // 空のジオメトリにマージして結合していく
            }
        }
    }
    this._block = new THREE.Mesh(geometry, meshArr[1].material);
    this._scene.add(this._block);
});

最後に4つめの負荷の少ないマテリアル(質感)の選択は、表面の質感を上げるマテリアルを選択すると、その分マシンへの負荷が上がるということです。マテリアルの種類は代表的なもので、以下のメリットがあります。

  1. MeshBasicMaterial : 光の影響を受けない
  2. MeshLambertMaterial : 影や光の反射などを描画できる(ランバート反射モデル)
  3. MeshPhongMaterial : 影や光の反射などをより綺麗に描画できる(フォンシェーディング)

マシンへの負荷に関しては1、2、3の順に次第に高くなります。選択したマテリアルの違いもマシン負荷への影響は大きいですが、マテリアルにバンプマップを使うかどうかもマシン負荷への影響は大きくなります。バンプマップは表面に細かな凹凸を表現します。下のキャプチャはアスファルトにバンプマップを適応したもの(右)と、適応していないもの(左)の違いです。表現力を上げるためには、バンプマップを採用したいところですが、ここでもマシン負荷を考慮してバンプマップを使っていません。

リアルな表現に近づければ近づけるほど、マシンへの負荷が増えてしまいます。ある程度の表現力は確保しながら、どれだけ負荷を減らせるかの調整は、これくらいのスペックなら問題なく表示できるようにしたいというターゲットとなるマシンを決めて、そのマシンで最低限確保したいフレームレートを決めます。あとは都度、フレームレートを確認しながら調整するということの繰り返しになります。マシンスペックだけでなく、ブラウザによっても表示速度に差異があることを意識していなければなりません。

HTML5のAPIによる新しい表現力

WebGL以外のHTML5の要素も、それぞれの特徴にあわせて適宜使用しています。

WebGLによる3D表示をしているCanvasの上に、各要素を配置していますが、上に乗っかっている要素を動かしたり、形状を変更させる場合には、背景に不透明な物を敷くなどの工夫が必要になります。そうしないと上に乗っかっている要素に変更があるたびに、下のCanvas全体まで、不要な再描画処理がかかってしまい、描画のパフォーマンスを著しく低下させてしまいます。

まとめ

以上が今回作成したJS-Racingで利用した、主なクライアントサイド技術になります。

3D表現として利用したWebGLは、IE11の対応によって最新のモダンブラウザでは、ほぼ実行可能な環境が揃ってきていると思います。

ユーザーのマシンスペックや、WebGL対応ブラウザの普及率を考えると、まだある程度ユーザーをしぼる必要がありますが、最近では話題を呼んでいる3Dコンテンツも次第に増えてきたように感じます。

次回はサーバサイドの使用技術と、その他の技術トピックに関して解説する予定です。