Admittedly something small.
2016年11月12日

モナドのまほう 第4話『WebGLとCannonでどう見てもマインクラフトです』

JavaScript WebGL CANNON.js purescript 関数型プログラミング


ゲームを作る日記的なヤツのつづきです。今回あんまりコードが出てこないので面白くないと思います。

WebGL ゴリゴリ

途中まではCanvas APIでグラフィックを描いていたのですが、暗闇の表現で画像をちょっと暗くして描画、みたいなことがやりにくくて、結局WebGLを直接叩いてグラフィックをゴリゴリ描いていくことにしました。PureScriptにもWebGLバインディングがすでにあるのですが、これはIDLから自動生成したもので、少々扱いづらいところがあります。これを使ったからと言ってあまり楽になるとも思えなかったので、WebGLを自力である程度高レベルにラップして叩いています。

ウェブ向けのライブラリとしてはthree.jsもよく知られています。筆者もthree.jsをちょっと試してみたことがあったのですが、今回はパスしました。グラフィックスAPIには保持モードと直接モード.aspx)というのがあって、three.jsみたいなのは保持モードに相当します。保持モードのライブラリは『シーングラフ』などと呼ばれる独自のデータ構造を持っていて、そこに適宜オブジェクトを追加したり取り除いたりします。Cocos2DやHTMLのDOMやSVGなんかも保持モードの一種です。それに対して、CanvasAPIやWebGL、OpenGL、DirectXなんかはシーングラフを提供しない直接モードのAPIです。

一見、保持モードのAPIのほうが扱いやすそうなんですが、保持モードではシーングラフの状態を常に気にしながらコーディングする必要があります。3Dモデルを閲覧するだけのようなアプリケーションなら保持モードでもいいんですが、ゲームのようなアプリケーションではオブジェクトが頻繁に追加されたり削除されたりするわけで、あっさり状態を管理しそこねて画面にゴミなオブジェクトが残ったり、特定の状態の時だけ起こる再現性の低いバグに遭遇したりと、あまり良いことがありません。

人類は仮想DOMなんてものを発明してまでDOMというシーングラフから解放されようとあがいているのに、保持モードのAPIを備えるライブラリは、WebGLのシーングラフがないAPIにわざわざシーングラフを設けて管理すべき状態を作り出してしまっています。そういうAPIはあまり使いたくはありませんでした。

グラフィックAPIをWebGLに移行した副産物で、一人称視点でのプレイも出来るようになりました。現状のゲーム画面はこんな感じです。

オーケイ、スティーブ。君の言いたいことはよくわかる。これがマインクラフトそっくりであるという事実を認めるのは、私としてもやぶさかでない。でも、この画面を見た瞬間、君は言葉では言い表せない『ときめき』のようなものを感じてくれたと思う。実際、マインクラフトは良く出来ている。単純であるが、うまくデフォルメされ統一感のあるグラフィックデザイン。自由度が高く、クリエイティブで飽きのこないゲームシステム。一人称視点で没入感のある操作感。サンドボックス形式のゲームはマインクラフト以前にも存在したが、ゲーム実況が動画サイトで定着しつつあったという時流にもぴったりマッチしていた。実況者のオリジナリティを出しやすいモノづくりゲーほどプレイ実況に向いたゲームはない。マインクラフト以降、TerrariaやStarboundのような2Dスタイルのもの、RobocraftやFrom the Depthes、Besiegeのような乗り物デザインもの、Cube Worldのようなアクション、Space EngineersのようなSFチックなもの、まさしく雨後の筍のようにサンドボックスゲームが濫造されたが、その末席に筆者が加わろうと大した問題ではないだろう。

フォントでちょっと雰囲気出したい!

デザインではフォントは重要ですよね。今回はゲームということもあって、フォントでも楽しい雰囲気を演出したいと思い、ラノベPOPフォントを借りることにしました。こういう感じのフォントです。

これを@fontfaceで読み込むわけですが……このフォントファイル、1.5メガバイト弱もの大きさがあります。貧弱なネットワーク環境だと体感的にもちょっと待たされる感じです。

一見したところ、PureScriptが書き出すJavaScriptはかなり冗長に見えますが、現状のアプリケーションでせいぜい260キロバイトほど。firebase.jsが300キロバイト以上あるので、現状では依存しているライブラリよりアプリケーション本体のほうが小さいです。そして、ここに1.5メガバイトのフォントファイル、さらに5メガバイトもあるBGMも読み込むつもりですから、もうスクリプト自体のファイルサイズなんてどうでもよくなるレベルです。

UIゴリゴリ

今までひたすらCanvasにゴリゴリ描いていたんですが、そろそろUIも作らないと!

筆者が以前作ったコレではpurescript-halogenというUIライブラリを使っていたんですが、今回はゲームということでrequestAnimationFrameのタイミングでDOMを更新しまくるつもりなので、halogenがそういう用途に耐えられるか確信がありませんでした。一番の問題は、Halogenのようなライブラリでは状態を更新すると自動的に再描画も走ってしまうことです。通常のUIのインタラクションではこれは自然ですが、ゲームでは一定時間ごとに状態更新があり再描画しまくるので、外部からの入力で状態が変更されたとしてもすぐさま再描画を行う必要はないわけです。それどころか、どうせすぐあとで再描画するのに入力のたびに描画を走らせるのは明らかに過剰です。そこでIncremental-domを軽くラップして簡単なUIライブラリを自分で作ることにしました。

purescript-incrementalというincremental-domのラッパもすでにあるのですが、あんまりメンテナンスされている気配がないですし、APIもちょっと気に入らなかったのでそれも今回はスルー。

物理演算ゴリゴリ

PureScriptは純粋な言語であり、データは基本的にすべて不変になっています。このことは状態の管理をとても楽にしてくれるのですが、現実のアプリケーションではなかなかそううまくは行きません。ゲームではゲームループが回るたびに事実上アプリケーションの状態が変化していくわけで、このためにRefを使いました。そして、状態を扱うハメになるもうひとつの原因が、外部のライブラリが持つ状態です。

ウェブのDOMについては、ご存知のとおり、仮想DOMを使うと一切の状態を覆い隠して純粋な計算に見せかけることができます。今回はincremental-domでこれをクリアしました。そしてグラフィックについても、three.jsのような内部に専用のデータ構造を抱えるライブラリを避けて直接WebGLを叩くことでクリアしています。それで、物理演算についてはどうするかなんですが、今回はCannon.jsを使うことにしました。

物理エンジンライブラリのほとんどがそうであるように、Cannon.jsも内部に独自のデータ構造を持っています。そのため、せっかく状態管理を避け続けてきたのに、ここでまた新たに管理すべき状態を持ち込むことに……。本当はあまり気が進まないのですが、物理演算を自分で実装するのは相当に大変ですし、ゲームの致命的なボトルネックになりうるので、やむなくCannonに頼ることを選びました。仮想DOMみたいに、毎回新しくデータを与えれば差分だけを計算してくれるような物理エンジンがあるといいんですが……。それにFirebaseのデータベースの状態も状態の一種ですから、現実のアプリケーションでは状態のない理想的な世界にはまだまだ遠いです。

今回ぜんぜんコードを出してないですし、念のためにCannonを叩く部分のコード出しておきましょうか。純粋関数型プログラミング言語PureScriptといえど、実際書いてみたら見た目のうえではJavaScriptと大して変わりはありませんから、この部分はあんまり面白くないですよ? JavaScriptならこんな感じ。

var sphere = new CANNON.Sphere(0.5);    // 半径0.5の球の形状を作成

var body = new CANNNON.Body({
    shape: sphere,                      // 形状をさっきつくったsphereにする
    mass: 1.0                           // 質量を1.0にする
});                                     

world.addBody(body);                    // 作成したBodyを空間に追加

step (1 / 60) world

PureScriptならこんな感じです。

sphere <- newSphere 0.5     -- 半径0.5の球の形状を作成

body <- newBody {
    shape: sphere,          -- 形状をさっきつくったsphereにする
    mass: 1.0               -- 質量を1.0にする
}                        

world # addBody body        -- 作成したBodyを空間に追加

world # step (1.0 / 60.0)   -- 1 / 60時間を進める

Cannonは内部に状態を持っており、worldオブジェクトやbodyオブジェクトの状態は実際に書き換わっています。でもコードのうえではどちらの言語も大した差はないでしょう。それでも、『純粋関数型プログラミング言語だと副作用の扱いが大変』とかいうデマが飛び交っていますから困ったものです。え? 『world # addBody bodyという式は、明らかにworldオブジェクトの状態を書き換えている、どうみてもこの式には副作用があり、純粋でない』って? いえいえ、ですからこれらすべての式はあくまで純粋ですってば。『純粋関数型』ってそういうことじゃないんです。

今回絵文字使いませんでした。絵文字書くの面倒くさい……。

次のお話


このエントリーをはてなブックマークに追加