前回は前置きだけで終わってしまったので、今回はいよいよDOTSで制作した玉転がしゲームの中身について書いていきます。8000文字を超える大作です!
本ブログのチャンネルを設立し、テストプレイ動画をアップしました。音もない状態ですがひとまず。
DOTS及び、ECSの考え方などを自分の勉強した範囲で確認していきつつ、実際にどのようなコードを書いたかについてまとめていきます。
Entitiesパッケージ自体は現在0.8まで出ていて、正式版までそう遠くないのかなーという感じです。(※正式版はおそらくかなり遠いです)しかし、今回使用したDOTS向けの物理演算パッケージであるUnity.Physicsはまだ0.3であり、対応するのがEntities 0.6なのでEntities 0.8は使いません。試用版における現状の安定版がEntities 0.6とUnity.Physics 0.3ということになるでしょうか。
そして、前回の繰り返しになりますが、DOTSはまだまだ開発中であり、各パッケージの多くは試験段階のものです。今後の開発状況により、その中身は大きく変わり得ますし、本記事のコードも将来的に通らなくなる可能性があります。その点だけご了承していただくようお願いします。
今回の玉転がしゲーム制作にあたり、DOTSに関しては前回も紹介したUnity公式動画のPong及び、海外のUnity開発系YoutuberであるCode Monkeyさんの動画などを参考にしてます。CodeMonkeyさんはUnity Japanの方でもオススメされてた方のようなのでぜひ!
当然ですが、これらの動画の内容も今後古くなってしまう可能性があります。
ECSコンポーネント
まず従来のUnityにおけるコンポーネントとはGame Objectにアタッチする部品、モジュールであり、これにより様々な機能をゲームオブジェクトに持たせることが出来ました。これをGame Object コンポーネント(以下、GOコンポーネント)とします。
これに対し、ECSにおけるコンポーネントというと、基本的にはデータのみ、つまり特定のタイプの変数だけをもつ単純な構造体(struct)となり、これをここではECSコンポーネントとします。
MonoBehaviourは外してしまい、IComponentDataというインターフェイスを継承させたシンプルなstruct(構造体)です。[GenerateAuthoringComponent]というattributeをC#スクリプトに付け加えることで、Unityエディター上でGOコンポーネントと同じようなコンポーネントとして扱えるようになり、初期値設定などを行えるようになります。
後はConvertToEntityというコンポーネントをAdd Componentからゲームオブジェクトにアタッチすれば、ゲーム起動時にECSが自動でEntityに変換してくれます。ね?簡単でしょう?(Sub Sceneを使ったEntity変換もあるのですが今回は省きます)
やってることは3つだけ!
で、この単純なゲームを作るためにやったことは基本的に3つだけで、ECSコンポーネントを書き、ゲーム内の処理をするJobComponentSystemを書き、そのECSコンポーネントをゲームオブジェクトにくっ付けて値を設定する、になります。
もちろんステージ設計やボールのマテリアル、キューブを取った時のパーティクルエフェクト作成等の細かい作業はありましたが、ECS関連の作業はこれだけです。このゲームで作ったスクリプトは全部で10枚で、内訳はコンポーネントデータが4枚、システム系は5枚、後はいわゆるゲームマネージャー・スクリプトの1枚となります。
[Data]
BallinputData //コントロールのためのキーを指定する
PlayerBallData //ボールを動かすためのデータをまとめる
StarCubeTag //そのEntityがスターキューブであることを示す
ToPlayerDistanceData //プレイヤーとの距離を格納する
[System]
BallControllSystem //プレイヤーの入力をPlayerBallDataに入力する
BallMovingSystem //ボールを動かす
StarCubeRotateSystem //スターキューブを回転させる
StarCubeCollectSystem //スターキューブをプレイヤーがゲットする処理
FollowPlayerPosSystem //プレイヤーの位置をゲームマネージャに伝える
ToPlayerDistSystem //各スターキューブとプレイヤーの位置の計算
下二枚の画像はゲーム全体を管理するGameManagerスクリプト。参考のために載せますが、今回の本質ではないので読まなくていいヤツです。
(※一枚目、現在非表示)
今回はカメラの処理や、スコアの表示などの雑多な機能はゲームマネージャースクリプト内で行っています。シングルトン・クラスとしていろんな所で活躍。
例えばSetPlayerPosはEntity化されたプレイヤーボールの位置をこのクラスに教えるメソッドであり、そこを元にLateUpDate内でカメラを動かす処理が行われます。ですが細かい説明は省き、DOTS, ECSのコードについて見ていきます。
必要なデータと機能
このゲームに必要なデータは、まずプレイヤーキャラを動かすためのコントロールの設定、スピードや集めるキューブの回転スピード、プレイヤーとの距離です。必要な処理は、プレイヤー入力の受付、そしてそれに応じて玉を動かすのと、キューブを回転させる、プレイヤーとキューブの距離を計算、そして十分近づいたらキューブを消して、パーティクルを出す、となります。
まずはそうした必要なデータ、およびそのまとまりをECSコンポーネントとして定義して、必要なゲームオブジェクトに取り付けていく、ということです。
データ指向プログラミングといっても、必要なデータを考え、それをどういう風にまとめるか、それらをどういう処理するか、コンポーネントやシステムの名前はどうするか、というプログラミングの基本は変わらないはずです。
例えば上画像のJobComponentSystemスクリプトはプレイヤーのEntityとToPlayerDistanceDataコンポーネントを持つEntityの距離を計算するシステムなので、ToPlayerDist(ance)Systemとしています。システムが一つの関数であるかのように捉えるのがいいのかもしれません。
Entities.ForEachというメソッド
いろいろ細かいことは多いのですが、現時点で最も簡単にDOTSの恩恵を受けられる処理の定義の方法が、JobComponentSystemのOnUpdate関数内部でEntities.ForEachというメソッドを使う方法です。
ForEachは名前の通り、各Entityをそれぞれ処理をする、というものですが、実際やっていることは、コンポーネントから処理したいEntityを絞り込んでの一括処理です。
そもそもEntityのコンポーネント構成のことをArchetypeと呼び、それに基づいたEntityの絞り込みをEntityQueryと呼びますが、そこらへんは細かい内容なので省きます。とにかく今回のような単純なプロジェクトであれば、とりあえずJobComponentSystemを書いて、そのEntities.ForEach内で処理すれば十分だということです。
Entities.ForEach( ( //パラメータ )=>{ //処理 } ).Run();
プレイヤーの動く方向やスピードを格納するECSコンポーネント。このように単純な変数のまとまりとしてECSコンポーネントを書き、オーサライズする!
ちなみに環境設定で構造体のフォントカラーを青緑にしてclassと見分けがつきやすいようにしています。
このBallControllSystemではプレイヤーからの入力に基づいて、移動方向を計算し、PlayerBallDataのmoveDirectionに格納します。mathは従来のmathfではなくUnity.Mathematicsパッケージによる数学ライブラリで、normalizesafeを使ってfloat3を正規化しています。
このように自分で作成したECSコンポーネントデータもパラメータとして、ForeEach内で使用することが出来、refキーワードを付ければデータを操作することもできます。Entities.ForEach内部にはいろいろ制限も多いですが、単純なことは圧倒的な処理速度で実行でき感動します。
ECSでのRigidBody
Unityでの物理演算と言えば、RigidBodyコンポーネントですが、DOTSではPhysics Body とPhysics Shapeコンポーネントです。Physics BodyはいわゆるRigidBodyに代わるもので、Physics ShapeはColliderなどに該当します。
Add Component>DOTS>Physicsから選択可能です。
もし物理演算によって動かす必要が無い場合は、Physics Shapeだけでいいようです。なので、ステージの壁や床にはPhysics Shapeを付けます。(一応、変換時に自動で従来のColliderをPhysics Colliderに変換してくれます)
Unity.Physicsは決定論的な物理演算ということで、観察してると正直けっこう人工的な動きではありますが、従来のUnityの物理演算からするとかなり滑らかな動作で驚きます。(より高度な物理演算用にHavokのUnity版も開発中です)
物理でボールを動かす
前述のPhysics Body とPhysics ShapeはECSワールド内ではいくつかのコンポーネントに分解されます。その中で、物理演算による動く方向はPhysics Velocityというコンポーネントで指定できます。
Physics VelocityのLinearという変数にfloat3を加算すれば、物理ボールを物理的に動かすことが出来ます。入力するfloat3の値は動く方向×スピード、です!
PhysicsVelocity.Linear = moveDirection * moveSpeed;
プレイヤーコントロール
キーボードをコントローラとします。PlayerInputDataでは、入力に使うキーボードキーを指定します。これは参考にしたUnity公式によるPongの動画の影響で今回のゲームでは特に必要が無いのですが、2人対戦の場合も考えて残します。
いわゆるFPSなどでも使用するWASDキーを使います。
ここで指定したキーをBallControlSystemでチェックして、押されていたら1、-1を変数に足すようにします。こうすることで反対方向のキーを同時押ししたら止まるようにします。そして、PlayerMoveDataにプレイヤーの進む方向を入力します。
PlayerMovementSystemではPlayerMoveDataに入力された進行方向及びスピードデータを取得して、実際に動かす処理をします。前述のとおり、Physics VelocityのLinearパラメータに入力するだけです。
で、遊びでジャンプする機能も付けてます。スペースキーをチェックして、かつLinear.y(上下の動き)が絶対値で0.2以下か?つまり空中にいないかを簡単に確認して、Linear.yに値を加える、ということでジャンプさせています。(坂道だとたぶんジャンプできなくなる実装)
Entityを回す
ECSでは従来のTransformに代わり、position, ratation, scaleを含む4x4行列であるLocalToWorldというECSコンポーネントが加えられ、TranslationとRotationというコンポーネントも付随でEntityに付与されます。
Rotationはそのまま『回転』ですが、Translationは要するにTransformのPositionなので動かしたいときはこれを操作すればいいです。というわけでキューブを回転させたいのでRotationを使います。
RotationはUnity.Transforms.quaternionなのですが、従来のQuaternionとは別です。今回quaternionを使うと上手くいかなかったので、従来のQuaternionを掛けています。
quaternionはQuaternionと区別するためにUnity.Transforms.quaternionになるので注意が必要、というか名前付けどうにかならんかったのかと。(using文で宣言する方法あり)
今回はスターキューブだけ回転すればいいので、上のような空のECSコンポーネントを作成し、タグとして使います。StarCubeRotateSystemではWithAll<>メソッドでこのタグを目印に処理をするEntityを絞り込んでいます。
オブジェクトを回すのは決して安い処理ではないはずですが、この簡単なシステムにより高FPSでグルグル回ってくれます。
距離を測り、当たりを確認する。
ColliderもUnity.Physicsの領域で、Colliderを使った接触判定も考えたのですが、難しそうだったので今回はもっと単純な方法を採用しました。毎フレーム、各キューブとプレイヤーの距離を計算して、一定の距離以下になったら触ったと判定して、キューブを取得したことにする、という方法です。
ToPlayerDistDataというECSコンポーネントを作り(上画像)、キューブにアタッチします。ここにプレイヤーとの距離を格納するわけです。
ToPlayerDistanceSystemでは二つのEntities.ForEachを使い、プレイヤーの位置を求め、そして、各キューブとの位置を計算し、ToPlayerDistDataに入力する、という処理をシステムです。
キューブをゲット!Entities.Foreachの制約とは?
ということで距離を毎フレーム計算しているので、あとは一定の距離以下に近づいたら、キューブEntityを消去し、スコアを加算し、視覚エフェクトを出す、というシステムが必要になります。
いくつかあるForeach内部での大きな制約の一つとしてclassのstaticメソッドが使えないことです。WithoutBurstメソッドを使うことでこの問題を回避できます。
まだ詳しくは理解していないですが、Burstコンパイラーによるコンパイルをしないことでそうしたstaticメソッド実行可能に出来る、ということのようです。当然ですが、BurstCompileできないということはパフォーマンスが落ちるということであり、大規模な処理では、できる限り使わないのがいいのだろうと思います。
FollowPlayerPosSystemはゲームマネージャ・スクリプトのインスタンスを通じて、カメラにプレイヤーの位置を伝えるシステムです。このように従来のゲームオブジェクトとEntitiyとの連携をどう処理するかが課題になると思います。
ECSにおいて、Entityを作ったり、そのEntityにECSコンポーネントを加えたりするのはEntity Managerの仕事なのですが、なんとEntities.ForEach内部ではEntitiesに対して、その構造を変化させたり、ECSコンポーネントを着脱するような処理を行うことが出来ません。
そこでEntityCommandBufferの出番になります。
EntityCommandBuffer ecb =
new EntityCommandBuffer(Unity.Collections.Allocator.TempJob);
EntityCommandBufferとは?
公式ドキュメントの説明文を引用すると、
A thread-safe command buffer that can buffer commands that affect entities and components for later playback.
『Entitiesやコンポーネントに影響を与えるような命令を後で再生できるようにバッファすることのできる、スレッドセーフなコマンド・バッファ』
バッファとは溜め池、ダムのようなもので、ここでの理解は『コンピュータへの命令を一時的に記録する機械』というものでいいと思います。
つまり、Entities.ForeEach内部では、Entityを消去したりコンポーネントを付けたり外したりするような命令は直接実行できないので、EntityCommandBufferに記録しておいて、Entities.ForEachが終わった後、Entity Managerに対して記録された命令を再生することで事後的に処理を実行させる、というのがEntityCommandBufferの役割となります。
つまりStarCubeCollectSystemにおいて、Entityを消去するというのは、Entitiesの構造を変えるような処理なので、EntityCommandBufferにDestroyメソッドを記録させ、ForEachの外でそれをEntityManagerに伝えて処理を実行してもらう、という流れになります。
Entitiy Debuggerとは
EntityToConvertコンポーネントによりEntityに変換されるゲームオブジェクトは、上画像のようにプレイモード中はヒエラルキーウィンドウ内に表示されません。ECS worldの中にどんなEntity達がいるのか、何が起こっているかを確認するのにEntity Debuggerは大切な存在です。
EntityDebuggerはWindow>Analysisから選択でき、上画像ではエディター下部にドッキングしています。
見方としては、左がシステムでここでそのシーンでどのシステムがあるか、どのくらいの負荷で処理されているか、あるいは停止しているかを確認できます。真ん中はEntities欄で全てのEntityであったり、システムに関わるEntityを検索することも可能です。一番左はコンポーネントで、どういうECSコンポーネント構成になっているかを確認できます。
このようにシステムを選択し、コンポーネントをクリックすると該当するEntityのみが表示されます。
そして、ここでEntityを選択するとInspectorウィンドウにそのEntityの情報が表示されます。もちろんリアルタイムで値も動くのでどのように処理されているかもモニターでき、下画像上部のStarCubeDistanceDataのdistance(float)項目でリアルタイムで距離が計算されている様を確認することが出来ます。
自分もまだまだ使いこなせていないですが、かなり簡単で便利なデバッグツールなんじゃないかなと思います。
まとめ!
ということで、DOTS学習のために玉転がしゲームを制作した中身について書いてみました。DOTS関連については、このゲームを作ったり、この記事を書いている間にもどんどん開発が進んでおり、実際Verごとにドキュメントの内容も大きく変わったりもします。
なので、いろいろ調べつつ学ぶには少し時期尚早な感じもしますが、DOTSの可能性を肌で感じてしまうと、どうしてもその考え方や使い方に早く慣れたい!という気持ちが強いので、様子を見ながら学習を進めていきたいと思います。