軽量な2次元物理演算エンジン「Matter.js」を使って、マルチデバイスなブラウザゲームを作ってみた。その2。

前回は、軽量、軽負荷な2次元物理演算エンジン「Matter.js」を使って、マルチデバイスなブラウザゲームを作ってみましたが、今回は、ゲームロジック部分は手を加えずに、「three.js」を使って、3D表現を組み込んでみました。


マルチデバイス3Dブラウザゲーム

20151019_img

ゲーム内容は前回のゲームと一緒で、イカがどこにも接触しないようにいいタイミングで、画面をタップする(PCの場合は何かしらのキーを押す)だけです。


ソースコード解説

「three.js」はWebGLをサポートしたJavaScriptの3D描画ライブラリです。Mr.Doob氏を中心にオープンソースで開発が進められていて、WebGLの3Dライブラリとしての実績も多く、現状ではデファクトスタンダードとなっています。今回は「three.js」と「Matter.js」を組み合わせたポイントだけピックアップして解説します。

engineパッケージ(物理演算エンジン)とviewパッケージ(3D表示)にクラスを分ける

物理演算エンジン用と3D表示用でクラスを分けています。全部一緒に書いてしまった方が簡単ですが、後の管理や更新、運用を考えると、物理演算エンジン用クラスと3D表示用クラスは切り離した方が使いやすいと思います。例えば、デバイスによって3D表示と2D表示を切り替える事や、「Matter.js」や「three.js」を後々別のライブラリに変更することが容易になります。

しかし、それだけクラスが増えてしまいますので、物理演算エンジン用のengineパッケージと3D表示用のviewパッケージに分けて、クラスを格納しました。CoffeeScriptなのでネームスペースというべきなのでしょう。

やっていることは、単純にオブジェクトを生成してクラスを格納しているだけです。ついでにMainクラスを作って、はじめに実行するアクションや、各種イベントを格納しています。

作成したクラスは以下です。

  • Main : メインクラス
  • engine.Engine : 物理演算エンジン(エンジン、プレーヤー、障害物等の管理)
  • engine.Player : 物理演算エンジンで扱うプレーヤー用Matter.Bodyの管理
  • engine.Obstacle : 物理演算エンジンで扱う障害物用Matter.Bodyの管理
  • view.View : 3D表示(レンダラー、プレーヤー、障害物等の管理)
  • view.Player : 3D表示で扱うプレーヤー用THREE.Meshの管理
  • view.Obstacle : 3D表示で扱う障害物用THREE.Meshの管理
  • Panel : 「Start」や「Replay」や「ポイント」などの前面に表示するパネルの管理
  • Sound : サウンド管理

engine.Engineクラス位置情報の更新イベントを発火

engine.Engineクラスから位置情報の更新イベントを発火して、view.Viewクラスに位置情報を送ります。engine.Engineクラスは、受け取った位置情報をもとに、X座標、Y座標、回転をTHREE.Meshに反映させます。このときview.Viewクラス基準点とengine.Engineの基準点の違いによって生じる差異を埋める必要があります。(※「Matter.js」では左上をX=0、Y=0に、「three.js」では画面の中心をX=0、Y=0にして計算しています。カメラの位置で調整する方法も考えられますが。)また、2次元物理演算エンジンを3D表現に反映しているので、奥行きに関しては変更せず、Z=0のままになっています。

ちなみに、THREE.Meshの追加と削除も、別のイベントを発火させるのではなく、位置情報更新イベントで行っています。THREE.Meshが存在しない場合は、THREE.Meshを追加してから位置情報を反映させ、位置情報として受け取らなかったTHREE.Meshは、Sceneから削除しています。

以下のコードは、位置情報をイベントオブジェクトにまとめて、位置更新イベントを発火しています。

./js/index.coffee Line154〜

setInterval ()=>
  params = {}
  if @_player
    params.player = {
      x: @_player.getPositionX()
      y: @_player.getPositionY()
      angle: @_player.getAngle()
    }
  params.obstacleArr = []
  for obstacle in @_obstacleArr
    params.obstacleArr.push({
        id: obstacle.getId().top
        x: obstacle.getPositionX()
        y: obstacle.getPositionY().top
      }
    )
    params.obstacleArr.push({
        id: obstacle.getId().bottom
        x: obstacle.getPositionX()
        y: obstacle.getPositionY().bottom
      }
    )
  params.scroll = @_scrollTotal
  # 位置更新イベント
  $body.trigger "updatePositionEvent", [params]
, FPS

以下のコードは、位置情報更新イベントを受けて、THREE.Meshに反映させています。

./js/index.coffee Line446〜

# 位置更新
updatePosition: (params)->
  # プレーヤーの追加と位置更新
  if params.player
    if not @_player
      # プレーヤー追加
      @_player = new view.Player @_scene
    playerX = params.player.x - (STAGE_WIDTH / 2) || 0
    playerY = -(params.player.y - (STAGE_HEIGHT / 2)) || 0
    playerAngle = -params.player.angle || 0
    # プレーヤー位置更新
    @_player.setPosition playerX, playerY, playerAngle
    # カメラ位置変更
    @_camera.position.y = playerY * 0.4
  else if not params.player
    if @_player
      # プレーヤー削除
      @_player.remove()
      @_player = null
  # 障害物の追加と位置更新
  for obstacleInfo in params.obstacleArr
    obstacleX = obstacleInfo.x - (STAGE_WIDTH / 2) || 0
    obstacleY = -(obstacleInfo.y - (STAGE_HEIGHT / 2)) || 0
    isExist = false
    for obstacle in @_obstacleArr
      if obstacleInfo.id is obstacle.getId()
        isExist = true
        # 障害物位置更新
        obstacle.setPosition obstacleX, obstacleY
    if not isExist
      # 障害物追加
      obstacle = new view.Obstacle @_scene, obstacleInfo.id
      # 障害物位置更新
      obstacle.setPosition obstacleX, obstacleY
      @_obstacleArr.push obstacle
  # 障害物の削除
  arr = []
  isExist = false
  for obstacle in @_obstacleArr
    for obstacleInfo in params.obstacleArr
      if obstacleInfo.id is obstacle.getId()
        isExist = true
    if isExist
      arr.push obstacle
    else
      # 障害物の削除
      obstacle.remove()
  @_obstacleArr = arr
  # 床と天井のスクロール
  @_floor.position.x = -(params.scroll % (STAGE_WIDTH / 4))
  @_ceiling.position.x = -(params.scroll % (STAGE_WIDTH / 4))

スマートフォンを意識した負荷軽減

スマートフォンでの実行を考えると、できるだけ負荷は減らしておきたいので、そのための負荷軽減のTispをまとめました。

物理演算エンジンのレンダリング機能を停止

まず考えられるのは、物理演算エンジンのレンダリング機能を停止することです。「Matter.js」で利用しているHTMLのcanvas要素を非表示にするだけでも、負荷軽減の効果は大きいと思いますが、「Matter.js」でレンダリング用に処理を実行してしまうのも無駄なので、Matter.EngineMatter.Bodyを生成する際に、レンダリングオプションとしてvisible=falseを渡してレンダリングを非表示にします。

./js/index.coffee Line135〜

# 物理エンジンを作成
@_engine = Matter.Engine.create document.getElementById(element), {
  render: { # レンダリングの設定
    visible: false
  }
}
# 物理シュミレーションを実行
Matter.Engine.run @_engine
# 天井生成
ceiling = Matter.Bodies.rectangle(STAGE_WIDTH / 2, -10, STAGE_WIDTH * 2, 10, {
  isStatic: true # 固定するか否か
})
# 床生成
floor = Matter.Bodies.rectangle(STAGE_WIDTH / 2, STAGE_HEIGHT + 10, STAGE_WIDTH * 2, 10, {
  isStatic: true # 固定するか否か
})
# 天井・床追加
Matter.World.add @_engine.world, [ceiling, floor]

次に、3D表示において負荷軽減を行います。

▼ここから再編集

マテリアルの選択とテクスチャでの工夫

「three.js」に限らず3D表現において、光はとても重要な要素です。光は物体にあたると反射します。この繰り返しが色と陰影をもたらします。いかに光を現実世界に近い状態で表現するかが、CGの世界では重要になります。しかし、この光の処理が一番負荷が高いものとなっています。

今回は負荷軽減のため思い切って光の処理をなくしてしまいました。方法は簡単で、マテリアルに陰影のつかない一番負荷の低いnew THREE.MeshBasicMaterialを選択します。これによって、表現力は低くなりますが、大幅に負荷を減らすことができます。ただし、これだけだと陰影ので非常にのっぺりとした、立体であることも認識できない状態になってしまいます。そこでテクスチャに擬似的に陰影をあらかじめつけておくことで、ある程度の立体表現を担保することが出来ます。

./js/index.coffee Line393〜

# 背景
geometry = new THREE.PlaneGeometry STAGE_WIDTH, STAGE_HEIGHT
texture = new THREE.ImageUtils.loadTexture "./images/bg.png"
material = new THREE.MeshBasicMaterial {
  map: texture
}
plane = new THREE.Mesh geometry, material
plane.castShadow = false;
plane.position.set 0, 0, -STAGE_DEPTH / 2
@_scene.add plane

20151019_img2

「three.js」では、以下のようなマテリアルの種類を用意しています。

  • MeshBasicMaterial : マテリアルの基本で陰影がつかない。
  • MeshLambertMaterial : 陰影付きのマテリアル。
  • MeshPhongMaterial : ランバートマテリアルの上位互換。

下に行くほど、表現力が上がりますが、その分負荷が高くなります。必要に応じて適切に選択する必要があります。

テクスチャのスプライト化

キャラクターにアニメーションさせたいときには、モーフィングさせたアニメーション付きのモデルデータを読み込む方法があります。モーフィングアニメーションに関しては、以前記事にまとめましたので、興味がありましたら参照ください。

しかし、この方法はデータ容量も多くなり、負荷も高くなってしまいます。もちろん必要な場合もありますが、代替手段を選ぶことで、データ容量と負荷を押さえることが出来ます。テクスチャをスプライト化して、必要に応じて切り替えることで、キャラクターをアニメーションさせることが出来ます。あくまで代替手段ですが。

テクスチャを生成する際に、テクスチャのリピートを設定して、今回のように2枚のスプライトで2列、1行の場合は@_texture.repeat.set(1 / 2, 1)と設定します。

./js/index.coffee Line〜511

@_texture = new THREE.ImageUtils.loadTexture "./images/gesso.png"
@_texture.wrapS = @_texture.wrapT = THREE.RepeatWrapping
@_texture.repeat.set 1 / 2, 1
@_texture.offset.x = 1

スプライト表示箇所を変更する場合、2コマ目を表示するタイミングで@_texture.offset.x = 1 / 2を設定して、100ms後に@_texture.offset.x = 1を設定して元の1コマ目に戻しています。

./js/index.coffee Line〜528

# ジャンプ
jump: ->
  @_texture.offset.x = 1 / 2
  if @_intervalId
    clearTimeout @_intervalId
  @_intervalId = setTimeout =>
    @_texture.offset.x = 1
  , 100

アンチエイリアスの解除

レンダラーを生成する際に、アンチエイリアスを有効にすると、物体の輪郭が滑らかになりますが、負荷が高くなります。オプションとしてantialias=falseを設定し、アンチエイリアスを無効にすることで、物体の輪郭がギザギザにはなりますが、負荷を下げる事が出来ます。

./js/index.coffee Line432〜

# レンダラー
@_renderer = new THREE.WebGLRenderer {
  antialias: false
}

ジオメトリの結合

2以上の形の集合体の場合、それぞれのジオメトリを結合する事で、その分だけドローコールを減らす事が出来ます。詳しくはこちらの記事がわかりやすいかと思います。

今回の場合は、障害物においてジオメトリの結合を行っています。

./js/index.coffee Line〜542

# 障害物(表示用)
class view.Obstacle
  constructor: (scene, id)->
    @_scene = scene
    @_id = id
    @_mesh = null
    #
    geometry = new THREE.CylinderGeometry OBSTACLE_WIDTH / 2.5, OBSTACLE_WIDTH / 2.5, OBSTACLE_HEIGHT - 20, 16, 1, true
    mesh = new THREE.Mesh geometry
    #
    geometry = new THREE.TorusGeometry (OBSTACLE_WIDTH / 2) - 10, 20, 16, 16
    mesh2 = new THREE.Mesh geometry
    mesh2.rotation.x = Math.PI / 2
    mesh2.rotation.z = Math.PI / 2
    mesh2.position.set 0, (OBSTACLE_HEIGHT / 2) - 20, 0
    #
    mesh3 = new THREE.Mesh geometry
    mesh3.rotation.x = Math.PI / 2
    mesh3.rotation.z = Math.PI / 2
    mesh3.position.set 0, -(OBSTACLE_HEIGHT / 2) + 20, 0
    # ジオメトリ結合
    geometry = new THREE.Geometry()
    THREE.GeometryUtils.merge geometry, mesh
    THREE.GeometryUtils.merge geometry, mesh2
    THREE.GeometryUtils.merge geometry, mesh3
    texture = new THREE.ImageUtils.loadTexture "./images/clay-pipe.png"
    material = new THREE.MeshBasicMaterial {
      map: texture
    }
    @_mesh = new THREE.Mesh geometry, material
    @_mesh = new THREE.Mesh geometry, material
    @_mesh.castShadow = false
    #
    @_scene.add @_mesh

カメラ領域の設定

カメラを生成する際にも注意が必要です。THREE.PerspectiveCameraクラスからカメラ用インスタンスを生成する際に渡す第4引数に、カメラで表示する範囲を設定します。この値を必要最低限にしぼっておく事で、無駄な処理をさける事で、負荷を軽減することが出来ます。

./js/index.coffee Line429〜

# カメラ
@_camera = new THREE.PerspectiveCamera 60, STAGE_WIDTH / STAGE_HEIGHT, 1, STAGE_DEPTH * 2
@_camera.position.set 0, 0, STAGE_DEPTH * 1.5

不要メッシュの削除

カメラに表示しないTHREE.Meshを削除しておく事を忘れてはいけません。このゲームの場合、イカが障害物を避けながら右に進んでいるように見えますが、実際はスクロールの距離という変数を作り、時間経過と共にカウントアップしていき、そのスクロール距離の値に合わせて、障害物と床と天井を右から左に移動させています。障害物に関しては、カメラに入り込む直前にTHREE.Meshを生成し、カメラから消えた直後にTHREE.Meshを削除しています。天井と床に関しても、一定以上移動したらまた元の位置に戻して、を繰り返して、ずっと移動しているように見せています。

以上が今回実施している負荷軽減の施策でした。


まとめ

ほとんどデバッグしていないので、実機ではiPhone6でしか確認していませんが、ストレスなく動いていたと思います。3Dにすることで表現力は抜群に上がったと思います。three.min.jsのファイル容量が400KB以上あるのが気になるところですが、負荷に関しては工夫次第でスマートフォンでも問題なく表示できるレベルかと思います。

ネイティブアプリじゃなくても、ブラウザでリッチなスマートフォンゲームが簡単につくれるのなら、試してみてはいかがでしょうか?低コスト短納期審査不要が魅力のマルチデバイス3Dブラウザゲームの作り方でした。