KLabGames Tech Blog

KLabは、多くのスマートフォン向けゲームを開発・提供しています。 スマートフォン向けゲームの開発と運用は、Webソーシャルゲームと比べて格段に複雑で、またコンシューマゲーム開発とも異なったノウハウが求められる領域もあります。 このブログでは、KLabのゲーム開発・運用の中で培われた様々な技術や挑戦とそのノウハウについて、広く紹介していきます。

連載目次

はじめに

@tenntennです。

前回の記事では,Go Mobileのインストールを行い,MacとAndroid上でサンプルを動かしてみました。

前回動かしたexample/basicというサンプルのソースコードを見ると,glというプリフィックスがついたOpenGLの関数を使っていたり, シェーダーを書いたりとOpenGLを直接ガリガリと使っていることが分かります。 しかし,筆者はOpenGLについてあまり詳しくありません。 そこでこの記事では,OpenGLの関数群を直接使うのは最低限に抑え, spriteパッケージを使って,画像の描画や回転,拡大/縮小などを行う方法について説明します。
また,spriteパッケージを扱う上で,Go Mobileのイベントモデルを理解する必要があります。
そのため,この記事ではGo Mobileのイベントモデルとspriteパッケージに深く関係しているペイントイベントについても説明したいと思います。

なお,この記事は2015年7月26日時点の情報を基に執筆しております。 Go 1.5がリリースされる2015年8月には,パッケージ名や使用方法などが変更されている可能性がありますので, あらかじめご了承ください。
また,spriteパッケージは執筆当時,expディレクトリ以下にあり実験的な実装であることが分かります。
そのため,正式版になるころには破壊的な変更が入るおそれがあります。

サンプルを動かしてみよう

まずはサンプルを動かしてみて何ができるのか実際に見てみましょう。 spriteパッケージのサンプルは,Go Mobileのリポジトリのexample/sprite以下で提供されています。
それでは,gomobile buildコマンドを使ってapkファイルを作成しましょう。

$ cd ~/Desktop # どこでもよい
$ gomobile build golang.org/x/mobile/example/sprite

できたapkファイルをAndroid端末にインストールして動かしてみます。

$ adb install sprite.apk

いかがでしょうか?以下のように,Gopherが何かの本を献身的に燃やしているアニメーションが描画されたと思います。

example/spriteをAndroid上で動かしてみた

Gopherは何かの本の山から何冊か本を積んで焼却炉に向かい,本を燃やしてまた本の山に戻っていきます。 Gopherの画像は,手前の焼却炉に近づくたびに大きくなり,奥の何かの本の山に近づくたびに小さくなっています。 このことから,このサンプルでは,画像の描画,移動,拡大/縮小が行われていることが分かります。
この記事では,このサンプルを例に使って画像の描画および移動,拡大/縮小を行う方法について説明を行います。

spriteパッケージ

spriteパッケージは,2Dグラッフィク向けのシーングラフを提供するパッケージです。 シーングラフを構築し,各ノードにテクスチャを貼り付けたり,座標の指定や回転,拡大/縮小を行うことができます。

詳しい説明は後述しますが,spriteパッケージを使って画像を描画するには,以下のような流れでシーングラフを構築し,描画します。

シーングラフの構築と描画は,描画が必要になった際に発生するペイントイベントをハンドルして行います。
画像ファイルの読み込みとシーングラフの構築は,初めてペイントイベントを受信した際にだけ行います。
なお,画像ファイルはテクスチャという形でロードされます。
シーングラフの構築では,ノードの作成と登録を行い,そのノードにテクスチャの一部分をサブテクスチャとして設定し,そして,ノードのサイズや座標などを決めるアフィン変換行列を設定します。
そして,描画を行う前にそのフレームでのノードの配置を決定し,それから描画を行います。

spriteパッケージの概念図

spriteパッケージのgodocを見ると,どのような機能が提供されているかが分かります。 spriteパッケージ自体は,最低限のインタフェースやシンプルな構造体を提供するだけにとどまっています。 具体的な実装を提供しないことで,OpenGL以外でも描画できるように設計されています。

たとえば,sprite.Engineインタフェースを見てみましょう。

type Engine interface {
    Register(n *Node)
    Unregister(n *Node)

    LoadTexture(a image.Image) (Texture, error)

    SetSubTex(n *Node, x SubTex)
    SetTransform(n *Node, m f32.Affine) // sets transform relative to parent.

    // Render renders the scene arranged at the given time, for the given
    // window configuration (dimensions and resolution).
    Render(scene *Node, t clock.Time, c config.Event)
}

sprite.Engineインタフェースは,ノードの登録やテクスチャのロード,サブテクスチャの設定,シーングラフの描画などの機能を提供します。
このインタフェースのOpenGLを使った実装は,sprite/glspriteパッケージで提供されます。
glsprite.Engine関数を呼び出すことで,sprite.Engineインタフェースを実装した値を取得することができます(サンプルでの該当コードはこちら)。

eng := glsprite.Engine() // engはsprite.Engine型

sprite.Engineインタフェースの実装は,sprite/glspriteパッケージ以外にも,sprite/portableパッケージがあります。
sprite/portableパッケージは,描画にimageパッケージを使います。
そのため,シーングラフを画像として書き出すことができます。
たとえば,著者が作ったサンプルでは,
画面(sprite/glsprite)と画像(sprite/portable)に描画し,
画像にはHTTPサーバ経由でアクセスできるようにしています。

portableを使ったサンプル

シーングラフの構築

シーングラフを構築し,Engine.Renderメソッドを呼び出すことでそのシーングラフを描画することができます。
シーングラフのノードはsprite.Node型として提供されています。

type Node struct {
    Parent, FirstChild, LastChild, PrevSibling, NextSibling *Node

    Arranger Arranger

    // EngineFields contains fields that should only be accessed by Engine
    // implementations. It is exported because such implementations can be
    // in other packages.
    EngineFields struct {
        // TODO: separate TexDirty and TransformDirty bits?
        Dirty  bool
        Index  int32
        SubTex SubTex
    }
}

sprite.Node型の各フィールドは,外部に公開はされていますが, それはsprite/glspriteパッケージやsprite/portableパッケージなどのエンジンの実装パッケージからアクセスするためで基本的には直接代入は行いません。
sprite.Engineインタフェースのメソッドや*sprite.Node型のメソッドを介して設定されます。
なお,例外としてArrangerというフィールド(詳細は後述)は直接設定するようになっています。

それでははじめに,ルートノードを作成して,エンジンに登録してみましょう(サンプルの該当コードはこちら)。

scene := &sprite.Node{}
eng.Register(scene)     // engはsprite.Engine型

登録を解除する際はUnregisterを呼び出せばよさそうですが,執筆当時はglsprite.Engine関数が返すエンジンは,Unregisterメソッドを呼ぶとpanicが発生しました。どうやらUnregisterメソッドはまだ未実装のようです。

つぎに,子ノードを追加してみましょう(サンプルの該当コードはこちら)。

n := &sprite.Node{}
eng.Regsiter(n)
scene.AppendChild(n) // sceneが親ノードになる

これでシーングラフを構築することができるようになりました。

画像の読み込み

シーングラフを構築することはできるようになりましたが, このままEngine.Renderメソッドを呼び出してシーングラフを描画しても何も画像を設定していないため,何も表示されません。
そこでつぎに,テクスチャをロードして,ノードに画像を設定する方法を説明します。

example/spriteを見ると,assetsというディレクトリの中にwaza-gophers.jpegという画像ファイルが1つ入っています。
画像ファイルは1つしかありませんが,example/spriteでは,この画像をテクスチャとしてロードして, Gopherや本の山などを切り出して4つのサブテクスチャとしてシーングラフのノードに設定しています。
まずは,テクスチャをロードしてサブテクスチャに切り出すところまでを説明します。
example/spriteでは,以下のloadTexturesという関数でそれを行っています。 なお,番号は説明のために筆者が追加したものです。

func loadTextures() []sprite.SubTex {
    // (1) アセットファイルを開く
    a, err := asset.Open("waza-gophers.jpeg")
    if err != nil {
        log.Fatal(err)
    }
    defer a.Close()

    // (2) 画像をデコードし,image.Image型として読み込む
    img, _, err := image.Decode(a)
    if err != nil {
        log.Fatal(err)
    }

    // (3) エンジンにテクスチャをロードする
    t, err := eng.LoadTexture(img)
    if err != nil {
        log.Fatal(err)
    }

    // (4) テクスチャをサブテクスチャに切り出す
    return []sprite.SubTex{
        texBooks:   sprite.SubTex{t, image.Rect(4, 71, 132, 182)},
        texFire:    sprite.SubTex{t, image.Rect(330, 56, 440, 155)},
        texGopherR: sprite.SubTex{t, image.Rect(152, 10, 152+140, 10+90)},
        texGopherL: sprite.SubTex{t, image.Rect(162, 120, 162+140, 120+90)},
    }
}

(1)では,assetsパッケージのOpen関数を使うことでassetsディレクトリ以下にあるwaza-gophers.jepgという画像ファイルを開いています。
なお,os.Openも使えますが,OSごとにファイルパスが異なるため,assets.Openを使うほうがよいでしょう。
また,assetsパッケージにはファイルを開く関数しか無いため,ファイルを作りたい場合はos.Createなどを使うしかないようです。
AndroidではAndroidManifest.xmlファイルにWRITE_EXTERNAL_STORAGE権限を追加すれば,os.Createを使ってdataディレクトリ以下にファイルを作成することができるのを確認できました。

(2)では,image/pngパッケージやimage/jpegパッケージを使って画像をimage.Image型にデコードしています。
透過PNG画像を描画したい場合は,以下のOpenGLの関数をloadTexturesの中で呼んでおくと良いでしょう。

gl.Enable(gl.BLEND)
gl.BlendEquation(gl.FUNC_ADD)
gl.BlendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)

(3)で,エンジンにテクスチャをロードしています。 執筆時点ではロードしかできないので,削除はできませんでした。
ゲームなどでシーンが変わって,テクスチャをメモリ上から破棄したい場合は,エンジンごと破棄するしかないようです。
また,エンジンとしてglspriteを使用する場合は,内部で画像サイズが2^n変換されるようです。
なお,テクスチャのロードは最初のペイントイベント(paint.Event)が発生する前に呼び出すとpanic発生します。 ペイントイベントについての詳細は後述します。

(4)では,sprite.SubTexという構造体の配列を返しています。 sprite.SubTex型は,サブテクスチャを表す型で,あるテクスチャの一部分を表しています。 ここでは,テクスチャを指定した座標でサブテクスチャとして4分割にしています。 執筆時点では,テクスチャ画像の作成およびサブテクスチャの分割は手作業でやる必要がありました。 将来的には,go generateを使って渡した画像からテクスチャを作成するツールなどをが出てくることでしょう。なお,一度設定した画像を消したい場合は,空のサブテクスチャ(sprite.SubTex{})を設定すると画像が描画されません。もちろん、設定するサブテクスチャを変える事もできます。

つぎに,切り出したサブテクスチャをノードに設定する部分を見てみます(サンプルの該当コードはこちら)。

eng.SetSubTex(n, texs[texBooks])

Engine.SetSubTex関数を使うと,引数で渡されたノードに対して,サブテクスチャを設定できます。
なお,サブテクスチャを設定するには,ノードがエンジンに登録されている必要があり, 登録していないノードに対して呼び出すとpanicが発生します。 これでノードにサブテクスチャを設定することが出来ました。
しかしこれだけでは,まだノードの大きさが設定されていないため,画面に表示されません。
ノードにアフィン変換行列を設定して,描画する画像サイズを指定しましょう。

画像の移動・回転・拡大/縮小

Engine.SetTransformメソッドを使うことで,ノードにアフィン変換行列を設定することができます。
あるノードの座標や大きさは,親ノードまでの変換結果に,そのノードの変換行列を掛け合わせることで求められます。
ルート(根)となるノードは,サイズは1pt x 1ptで,座標は(0, 0),回転角度は0度を基準として,変換行列を掛けます。
そして,ノードにサブテクスチャが設定されている場合は,ノードの大きさや座標,回転角度を基に描画が行われます。
なお,example/spriteでは,sceneというノードがルートノードにあたります。

また,ptという単位は,フォントで使われるptと同様で,1pt = 1/72 inchとなり,geom.Pt型で定義されています。 1ptが何ピクセルを表すかは端末ごとに違い,geom.Pt.Pxメソッドで取得できます。

それでは,Engine.SetTransformメソッドを使った例を見てみましょう(example/spriteには該当箇所はありません)。

parent := &sprite.Node{}
eng.Register(parent)
eng.SetTransform(parent, f32.Affine{
    {2, 0, 5},
    {0, 2, 5},
})

child := &sprite.Node{}
eng.Register(child)
parent.AddChild(child)
eng.SetSubTex(child, tex)
eng.SetTransform(child, f32.Affine{
    {100, 0, 10},
    {0, 100, 10},
})

この例では,parentノードに設定された変換行列で,2倍の大きさになり,さらに座標は(5, 5)に移動しています。 子のchildノードでは,さらに100倍,座標も(10, 10)だけ移動します。
親の変換行列にさらに子の変換行列を掛けるため,最終的に画像は200pt x 200ptの大きさで,(25, 25)の位置に描画されます。
つまり,親ノードで2倍になっているため,子のノードで移動距離を(10, 10)に設定すると,実際は(20, 20)だけ移動します。

アフィン変換行列を表すf32.Affine型のポインタ型には,いくつか便利なメソッドが用意されています。
変換行列を生で書くのが辛い場合は,*f32.Affine.Scaleメソッドや*f32.Affine.Rotateメソッド,*f32.Affine.Translateメソッドなどを使うと良いでしょう。
メソッドのレシーバがf32.Affine型のポインタとなっているため少々使いづらいですが,直接変換行列を書くよりかは分かりやすいはずです。

イベント

ここまで,いくつかイベントという言葉を使ってきました。 ここでは,Go Mobileのイベントモデルと各イベントについて簡単に説明します。 Go Mobileでは以下のようなイベントを扱うことができます。 なお,かっこ内は対応するイベントの型です。

  • 描画イベント(paint.Event
  • 設定イベント(config.Event
  • ライフサイクルイベント(lifecycle.Event
  • タッチイベント(touch.Event
  • マウスイベント(mouse.Event
  • キーイベント(key.Event

執筆時は,キーイベントやマウスイベントはまだ実装中のようでした。 おそらくモバイル用というよりはPC用のイベントのようです。 キーイベントはAndroidのバックボタンを特定のキーでシミュレートしたりと,デバッグ用途に使えそうです。

Go Mobileでは,app.Main関数を用いて,イベントループを構築します。
通常,app.Main関数はmain関数内で呼び出すことが多いでしょう。 app.Main関数の引数には,func(app.App)型の関数を渡します。

func main() {
    app.Main(func(a app.App) {
        // ...
    })
}

この関数で渡されるapp.Appインタフェースは,以下のように定義されています。

// App is how a GUI mobile application interacts with the OS.
type App interface {
    // Events returns the events channel. It carries events from the system to
    // the app. The type of such events include:
    //  - config.Event
    //  - lifecycle.Event
    //  - mouse.Event
    //  - paint.Event
    //  - touch.Event
    // from the golang.org/x/mobile/event/etc packages. Other packages may
    // define other event types that are carried on this channel.
    Events() <-chan interface{}

    // Send sends an event on the events channel. It does not block.
    Send(event interface{})

    // EndPaint flushes any pending OpenGL commands or buffers to the screen.
    // If EndPaint is called with an old generation number, it is ignored.
    EndPaint(paint.Event)
}

各イベントは,app.App.Eventsメソッドで取得できるチャネルを通じて送られてきます。
一般的には,以下のようにswitch-case文で各イベントをハンドリングします(サンプルでの該当コードはこちら)。
app.Filter関数を通すことにより,各イベントの前処理などをやっているようです。
なお,for文のrangeの後にチャネルを書くとチャネルから送られてくるデータを変数に入れループを回してくれます()。

func main() {
    app.Main(func(a app.App) {
        for e := range a.Events() {
            var c config.Event
            switch e := app.Filter(e).(type) {
                case config.Event:
                    c = e
                case paint.Event:
                    onPaint(c)
                    a.EndPaint(e)
            }
        }
    })
}

それでは,各イベントについて説明していきたいと思います。 この記事では,config.Eventpaint.Eventについて説明し,他のイベントについては次回以降の記事で説明したいと思います。

config.Eventは,画面サイズが変わった場合などに送られてきます。PCだとウィンドウサイズが変わるごとにイベントが発生しますが,端末だと画面が回転した場合などに呼ばれるようです。
config.Event構造体からは,画面のサイズが取得できます。

// Event holds the dimensions and physical resolution of the app's window.
type Event struct {
    // WidthPx and HeightPx are the window's dimensions in pixels.
    WidthPx, HeightPx int

    // WidthPt and HeightPt are the window's dimensions in points (1/72 of an
    // inch).
    WidthPt, HeightPt geom.Pt

    // PixelsPerPt is the window's physical resolution. It is the number of
    // pixels in a single geom.Pt, from the golang.org/x/mobile/geom package.
    //
    // There are a wide variety of pixel densities in existing phones and
    // tablets, so apps should be written to expect various non-integer
    // PixelsPerPt values. In general, work in geom.Pt.
    PixelsPerPt float32
}

paint.Eventは,描画が必要になった場合に送られてきます。
上述のとおり,テクスチャをロードはpaint.Eventが送られてきた後に行う必要があります。
パフォーマンスのことを考えると,テクスチャのロード以外は事前に済ませておくと良いでしょうが, はじめはひとまず,テクスチャの読み込みおよびシーングラフの構築は初めてpaint.Eventが送られてきたときに行うと良いでしょう(サンプルでの該当コードはこちら)。

シーングラフの描画は,sprite.Engine.Renderメソッドを呼び出すことで行なわれます。
sprite.Engine.Renderメソッドの引数は,シーングラフのルートノード(*sprite.Node),時刻(clock.Time),config.Eventです。
引数に時刻を渡していることから分かるように,sprite.Engine.Renderメソッドはある特定の時刻のシーングラフの状態を描画します。
時刻よって座標などが変化するノードのある時刻の状態を決定するには,ノードにsprite.Arrangerインタフェースを設定している必要があります。

type Arranger interface {
    Arrange(e Engine, n *Node, t clock.Time)
}

sprite.Arrangerインタフェースの定義を見ると,Arrangeというメソッドを持っており,その引数に時刻(clock.Time)を取ることがわかります。sprite.Nodeに設定されたsprite.Arrangerインタフェースは,sprite.Engine.Renderメソッドが呼び出された際に,sprite.Engine.Renderの引数として渡された事項をそのまま渡し,実行されます。

example/spriteにおいても,Gopherの座標を決めるためにsprite.Arrangerインタフェースを設定してます(該当箇所はこちら)。

n.Arranger = arrangerFunc(func(eng sprite.Engine, n *sprite.Node, t clock.Time) {
    // TODO: use a tweening library instead of manually arranging.
    t0 := uint32(t) % 120
    if t0 < 60 {
        eng.SetSubTex(n, texs[texGopherR])
    } else {
        eng.SetSubTex(n, texs[texGopherL])
    }

    u := float32(t0) / 120
    u = (1 - f32.Cos(u*2*math.Pi)) / 2

    tx := 18 + u*48
    ty := 36 + u*108
    sx := 36 + u*36
    sy := 36 + u*36
    eng.SetTransform(n, f32.Affine{
        {sx, 0, tx},
        {0, sy, ty},
    })
})

なお,arrangerFuncは以下のように定義されている,sprite.Arrangerインタフェースを実装する型です(サンプルでの該当コードはこちら)。
なお、関数によるインタフェースの実装については,筆者のQiitaの投稿で解説しています。

type arrangerFunc func(e sprite.Engine, n *sprite.Node, t clock.Time)

func (a arrangerFunc) Arrange(e sprite.Engine, n *sprite.Node, t clock.Time) { a(e, n, t) }

// TODO: use a tweening library instead of manually arranging.とコメントあるように,sprite.clockパッケージの中にtween.goというファイルがあり,補間を行う関数が提供されています。

paint.Eventが発生し,その後sprite.Engine.Renderメソッドを呼び出しても実際にはまだ描画はされません。
そのフレームでのすべての描画に関する処理が終わったらapp.App.EndPaintメソッドを呼ぶ必要があります。
EndPaintメソッドが呼び出すと画面など(sprite.portableは画像)に描画結果が出力されます。

今回は,spriteパッケージの簡単な説明とGo Mobileにおけるイベントモデルの説明を行いました。
次回は今回扱わなかったtouch.Eventlifecycle.Eventについて説明する予定です。

やまだです。 SIMD.jsについてゆるく話をします。

SIMDとは何か?

まず、SIMD(Single Instruction Multiple Data)とは何かから簡単にお話しします。 SIMDは命令1つで複数のデータの演算を一括して行う計算方式です。 複数データに対する演算を一括して行うため、 同じような演算を大量に実行しなければならない場合に威力を発揮します。 しかし、SIMDを使うためにはCPUによって異なる命令を実行する必要があります。 たとえばIntel系のCPUであればSIMD拡張の命令セットであるSSEを使い、 ARM系CPUならNEONというように使い分けないといけません。

ブラウザとSIMD

今まではこれらはJavaScriptからは使うことができませんでした。 しかし、ブラウザのパフォーマンスを求め続けた結果ある実装が生まれました。

そう、Dartです。 DartはJavaScriptとは異なる高速なVM上で動作しながらも、ECMAScript 5に変換もできるという優れた処理系でした。 この野心的なプロジェクトは1年以上前にSIMDに対応していました。 記事によると3D分野でしばしば用いられる4x4行列の乗算で300%のパフォーマンスを発揮したようです。

そして、この実装はJavaScriptにも取り入れられようとしています。 JavaScriptの標準仕様であるECMAScript 2015が承認されたことは記憶に新しいと思います。 ECMAScript 2015ではアロー演算子やデストラクチャリングなど様々な拡張が行われました。 今、ECMAScriptは次なる仕様ECMAScript 7に向けて仕様の策定が進められています。 その中の一つに今回紹介するSIMD.jsがあります。 ECMAScriptの策定プロセスのうち、Stage 2、つまりDraft段階にあり主要な機能の定義が進められています。 まだまだ仕様として安定はしていないのですが、様々なプラットフォームでSIMD.jsを試すことができます。

SIMD.jsを実際に動かしてみる

今回はChromiumでSIMD.jsのパワーを体感してみましょう。 SIMD.jsの試験的な実装がなされたChromiumは こちらから手に入ります。 Windows、Mac、Linuxそれぞれのバイナリが用意されていますが、 実行時に--js-flags=--simd-objectをコマンドライン引数として与える必要があります。

このChromiumを使用してSIMDのパフォーマンスを体感できるコードを書いてみましょう。 合計値を計算するsum関数を実装してみます。 sum1は配列を1つずつ加算して行くのに対し、sum2は4つ同時にまとめて加算しています。

//NO SIMD
var sum1 = function(list) {
  var total = 0;
  var length = list.length;
  for(var i = 0; i < length; ++i) {
    total += list[i];
  }

  return total;
}

//SIMD
var sum2 = function(list) {
  var i32x4list = new Int32x4Array(list.buffer);
  var total = SIMD.int32x4.splat(0);
  var length = list.length / 4;
  for(var i = 0; i < length; ++i) {
    total = SIMD.int32x4.add(total, i32x4list.getAt(i));
  }

  return total.x + total.y + total.z + total.w;
}
SIMD ops/s
Yes 1509ops/s
No 620ops/s

結果はご覧のとおり、2倍以上のスピードが出ています。 ただの加算ですがここまで違いが出てしまいます。 こういった大量のデータ処理はSIMDの得意分野です。

とくにWebGLではベクトル演算の需要が大いにあります。 衝突演算、物理演算に威力を発揮することでしょう。

SIMD.jsと未来

SIMDを使ったチューニングはC言語などを使い、低レイヤーで行われることが多いですが、 JavaScriptによるSIMDチューニングができる時代がすぐそこまできています。 もちろん、OSやデバイスを意識せずに手軽に使うことができます。 Mac、Windows、Linux、そしてスマートフォンでも!

工夫次第で大きくパフォーマンスを向上できるSIMD.jsの今後にワクワクしてきませんか?

@やまだ

バーチャルリアリティーは漢のロマン

昔、私が高校生くらいの時にもVRブームがあり、バーチャルリアリティエキスポなるイベントで、 筋斗雲的な何かに乗ったり、高速に上下するLEDアレイディスプレイで三次元を表示するゲームなどに、 ワクワクしたことを思い出します。

そして、ここ数年、再びバーチャルリアリティー(VR)が盛り上がっています。 Oculus Riftや SCEのProject Morpheusといった ヘッドマウントディスプレイも格段に進歩し、 コンピュータグラフィックスやGPUなどの技術の進歩のおかげで、 非常にリアルな仮想現実没入体験が、簡単に手の届く領域にきています。

そんななか、私がもっともワクワクしているのが、 Googleの「Cardboard」です。 なぜなら、非常に安く手軽に手に入れ使うことができ、特にユーザーに身近なものになりうるからです。

本稿では、Cardboardを使ってVRでロボットに搭乗して操縦するデモを作成したことを紹介します。

Cardboardを使ってみる

CardboardはGoogleが公開した段ボールでできたスマフォ用VRアタッチメントです。 設計図も公開されているので、100円ショップで買ったルーペのレンズと、 段ボールと、テープと工具があれば自分で作ることができます。

fig1

簡単!Cardboardを自作する

実際にGoogleが公開しているデータで、 Cardboardを二つほど作ってみました。 レンズは、100円ショップで手に入るルーペを分解すると手に入ります。 私は、ダイソーで写真のルーペを買いました。なんとレンズが二つ入っていて、 一つ買うだけで両目分のレンズが手に入ります。

fig2

レーザーカッターがあれば簡単なのでしょうが、残念ながらないので、 プリントアウトした型紙を段ボールに貼り、 地道に普通のカッターで切り抜いて組み立てます。

純正のCardboardには、頭に固定するバンドがないので、 手芸用のゴム紐とマジックテープで簡単な固定用バンドを追加しました。

以下が実際に自作してみたものです。

fig3

UnityでCardboard(Durovis Dive編)

最初に試したのが、このDurovis Diveです。 リンク先の開発者ページにあるSDKをダウンロードし、 unitypackageをインポートして使います。

インポートしたDive/Prefabs/にあるDive_CameraというPrefabを、 シーンに追加すると、ヘッドトラッキング付両眼カメラになります。 このカメラをメインカメラにすることで、非常に簡単にCardboardなどの スマフォ用VRアタッチメントに対応させることができます。

UnityでCardboard(CardboardSDK編)

次に、Google Cardboard公式のUnitySDKを試してみましょう。 Cardboard Developper Page(Unity)を見ると、Download and Samplesからunitypackageをダウンロードできます。 プロジェクトにインポートすると、Cardboardフォルダができます。

Durovis Diveと同様に、 Cardboard/Prefabs/にある、CardboardMainというPrefabをシーンに追加することで、カメラになります。 これだけで、Cardboardに対応させることができます。

どちらにしたのか

今回は、両方とも実装してみての確認は行いましたが、 詳細な評価と比較はできていません。 実際のアプリではDurovis Diveを使いましたが、 これは作り始めた初期の時点でCardboardのUnity対応SDKがリリースされてなかったという理由によります。

どうやって操作するの?

スマフォVRアタッチメントを使用する場合、大きな問題となるのがユーザーインターフェースです。 Cardboardでは、これを磁石とスマフォの磁気センサーでスイッチを作って解決しています。 磁石二つの位置関係による磁力の変化を、スマフォの磁気センサーで検出することで、一ボタンに対応する入力を実現しています。 非常に面白い仕組みです。 Ver.2のCardboardでは、導電性の布とレバー機構を使うことで、ボタンを押すと画面にタッチされる機構が作られています。これも面白い仕組みですね。 一方で、どちらの入力方式でもアクション性の高いゲームに利用するには入力の少なさと、反応速度と操作しやすさから難しいです。

そうだ、スマフォ二台使おう

ここで、私が考えたのが、スマフォの加速度センサーやタッチパネルをコントローラーとして使えないかということでした。 スマフォの普及率が爆発的に増加した昨今、二つくらい前に使っていたスマフォが机の引き出しの中に眠っていたりしないでしょうか。 そうです、それらをコントローラとして使えばいいのです!!

そうなると、表示用と左手用右手用の都合三台のスマフォが必要です。 我が家には、幸い私と妻が以前使っていたiPhone4とiPhone4sが余っていました。 これをコントローラーとして使えるようにしてみましょう。

無線で本体とコントローラーをつなぐ

では、どのように表示+ゲーム実行用の本体スマフォと、コントローラー用スマフォをつなげばいいのでしょうか。 ここで今回は、WebSocketを使ってみました。 サーバーを介して、本体とコントローラをWebSocketでつないでみました。

fig4

クライアント側のUnityでは、WebSocketSharpのUnity対応のためのKLab改変版を使って、以下のような受信用プログラムが動いています。

using UnityEngine;
using System.Collections;
using WebSocketSharp;

public class WebSocketClient : MonoBehaviour {
    public const int RIGHT = 0;
    public const int LEFT = 1;
    public const int B1 = 0;
    public const int B2 = 1;
    public Vector3[] accel = new Vector3[2];
    public bool[,] button = new bool[2,2]{{false,false},{false,false}};

    private WebSocket ws;

    // Use this for initialization
    void Start () {
        ws = new WebSocket ("ws://URL/chat/test");
        ws.OnMessage += (object sender, MessageEventArgs e) => {
            string [] message = e.Data.Split(new char[]{':'});
            int controllerNo=RIGHT;// Default Right it is not good
            switch(message[1]){
            case "R":
                controllerNo = RIGHT;
                break;
            case "L":
                controllerNo = LEFT;
                break;
            default:
                break;
            }
            switch(message[2]){
            case "AC":
                accel[controllerNo] = new Vector3(float.Parse(message[3]),
                                                  float.Parse(message[4]),
                                                  float.Parse(message[5]));
                break;
            case "B":
                button[controllerNo,int.Parse(message[3])-1] =
                    (message[4].Equals("DOWN")) ? true : false;
                break;
            default:
                break;
            }
        };
        ws.Connect ();
    }

    // Update is called once per frame
    void Update () {
    }
}

Playerのコントロールスクリプトなどで、このクラスから情報を取得して操作します。

一方で、コントローラーは、HTMLファイルで作りました。ブラウザで表示するだけでコントローラーになります。

<!DOCTYPE html>
<html><head>
<meta name="viewport" content="width=device-width; initial-scale=1.0; maximum-scale=1.0; user-scalable=no;">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
</head>

<body style='user-select: none; -webkit-user-select: none;'>
<script type="text/javascript">
document.addEventListener('touchmove', function(e) {
  e.preventDefault();
},false);

var ws;
ws = new WebSocket("ws://URL/chat/test");

function send(message){
  ws.send(message);
}

window.addEventListener("devicemotion",function(evt){
  var x = evt.accelerationIncludingGravity.x; //横方向の傾斜
  var y = evt.accelerationIncludingGravity.y; //縦方法の傾斜
  var z = evt.accelerationIncludingGravity.z; //上下方向の傾斜
  send("1:L:AC:"+x+":"+y+":"+z);
},false);
</script>
<div style="width: 100px; height: 100px; background-color: red; margin: 20px; float: right;" ontouchstart="send('1:L:B:1:DOWN')" ontouchend="send('1:L:B:1:UP')" ></div>
<div style="clear: right;"></div>
<br>
<br>
<div style="width: 100px; height: 100px; background-color: blue; margin: 20px; float: right;" ontouchstart="send('1:L:B:2:DOWN')" ontouchend="send('1:L:B:2:UP')" ></div>
</body></html>

このHTMLをコントローラーの識別番号を変えて左手用と右手用の二つ作ります。

中継用サーバーは、単純なWebSocketで届いたメッセージを接続しているクライアントにブロードキャストするだけのサーバーです。 clojure-aleph-chatを参考にしました。

バーチャロンへのオマージュ

二本のスティックでロボットを操作すると言えばあれですね。 加速度センサにより、端末の傾きをとることにより、 左手右手の両方のスティックを前に傾けると前進、 後ろに傾けると後退、前後に互い違いに倒すと回転、 横方向に同じ方向に倒すと横移動、 横方向に開くと上昇、閉じると下降という操作体系です。 皆さん、よくご存じですよね。

コントローラー画面には、攻撃ボタンとブーストボタンを表示してみました。 タップするとそれぞれの動作をします。

fig7

写真にあるように、ブラウザでコントローラー画面を表示させます。 赤が攻撃ボタン、青がブーストボタンです。 AndroidとiOSのどちらでも動きますが、AndroidとiOSで加速度センサーの軸が違っているので気をつけましょう。

プレイ風景・・・

遊んでいるところ(筆者近影)

fig5

実際の画面のスクリーンショット

fig6

まとめ

今回の実験は、三台のスマフォが必要というネックがありますが、 最近のスマフォの普及率を考えると意外と何とかなるのではないでしょうか。 実際我が家には、iOSだけで、3GS,4,4s,5,5s,6,初代iPadがありますし、 AndroidはHTC AriaとKindle Fire HDとKobo Arcがあります。 3台くらいきっとなんとかなりますよね。

また、WebSocketで一回サーバーを介するレイテンシも、プレイしてる分にはあまり気にならず、 作り方次第で十分隠蔽可能なのではないかという感想です。

そして最高ですね。この没入感、飛翔感。楽しいです。文章ではこの楽しさが伝わらないのが残念です。 今回は、搭乗して操縦する体験を目標にしたので、ゲーム性は皆無に等しいのですが、 今後ゲームとして成り立たせる方向も模索したいと思います。


oho-s

↑このページのトップヘ