こんばんは。ハツェです。
今回はョョョねこ Advent Calendar 2020の記事として参加しています。昨日の記事は、寝起きさんの「卍関数」を書いてみるでした!
当記事では、弾幕ミニゲームUnitBarrageのアウトプットとして、ECSについて書こうと思います。加えて、今回はパフォーマンスの観点からURPを使用していこうと思います。
久々のUnity記事ですが、張り切っていきましょう!
動作環境はUnity(URP) : 2020.1.15f1、Havok Physics for Unity : 0.4.1-preview.2、Hybrid Renderer : 0.10.0-preview.21です。
目次
前書き
注意
ECS(Entity Component System)自体がまだプレビュー版である際に触ったことについて記述しています。そのため、本記事は時間とともに情報が劣化している可能性があります。予めご了承ください。
前提
今回の記事はECSについて最低限の知識があるという方を対象としています。
ECS自体に関して良く知らないという方は、以下の入門記事やチュートリアル動画などを見てみることを強くお勧めします。
- ECSの概念を知りたい方向け note.com
- ECSをとりあえず触ってみたい方向け(日本語記事版) qiita.com
- ECSをとりあえず触ってみたい方向け(公式チュートリアル動画) www.youtube.com
また、Entityを描画するためにHybrid Rendererというパッケージを使用します。Havok Physics for UnityはPackageManagerからPreviewパッケージの表示をONにしておくとインストール出来ます。
ですがUnity2020からは、デフォルトでPreviewPackageのほとんどがPackageManagerの一覧に表示されなくなりました。そのため、デフォルトで一覧に無いパッケージについては、GitのURLを入力して追加でインストールする必要があります。
実際にHybrid Rendererをインポートする方法としては、PackageManagerタブの+ボタンからAdd package from git URL...を選択し、com.unity.rendering.hybridというURLを入力することでインポート出来ます。
これ以外にもPreviewPackageのGitURLが書かれた一覧がUnityの公式ページにあります。困っている方がいたら参照してみてください。
docs.unity3d.com
概要
この記事ではECSを使って単純な弾を生成する方法、および色々な弾幕パターンの作成方法について紹介出来たらと思います。
ECSについて軽く概要を説明しておくと、GameObjectとは別の概念であるEntityを用いてゲーム等を作成します。
作成のフローとしては、EntityにComponentを用いてデータを付与し、それを基にSystemでEntity達を制御するといった感じです。
シンプルに弾を出す
発射する弾を作成する
まずは、発射する弾のPrefabを作成していきます。弾としてはプリミティブなCubeもしくはSphereを使おうと思います。
また大事なことではありますが、オブジェクトのZ方向を前として作成しています。
Cubeを用いた弾を作る
- まず、Cubeを作成してPositionとRotationは(0, 0, 0)に、Scaleを(0.075, 0.075, 0.45)程度にします。
- BoxColliderは削除し、代わりとしてPhysics Body, Physics Shape, Convert To Entityをアタッチします。
- Physics BodyのMotion TypeをKinematicにします。
- Physics ShapeのCollision ResponseをRaise Trigger Eventsにします。
- CubeをPrefab化します。名前はCubeBulletにでもしておきましょう。好きな名前にしてもらって大丈夫です。
Sphereを用いた弾を作る
- まず、Sphereを作成してPositionとRotationは(0, 0, 0)に、Scaleを(0.1, 0.1, 0.1)にします。
- SphereColliderは削除し、代わりとしてPhysics Body, Physics Shape, Convert To Entityをアタッチします。
- Physics BodyのMotion TypeをKinematicにします。
- Physics ShapeのShapeTypeをSphereに、Collision ResponseをRaise Trigger Eventsにします。
- CubeをPrefab化します。名前はSphereBulletにでもしておきましょう。好きな名前にしてもらって大丈夫です。
PhysicsBody・PhysicsShapeとは
DOTS(Data-Oriented Technology Stack)版の物理演算するためにアタッチするコンポーネントと言う感じに思ってもらえればと思います。
RigidBodyのDOTS版がPhysicsBodyであり、BoxColliderやSphereCollider等といったコライダーのDOTS版がPhysicsShapeと言う感じです。
簡単な概要については以下に記事を一つ紹介しておきますが、DOTS版の物理演算についてもっと気になる方はUnityPhysicsで検索してみてください。
note.com
Convert To Entityとは
Convert To EntityはEntityにしたいGameObjectに必ずアタッチしなければならないコンポーネントです。
このコンポーネントをアタッチすることによって、実行時にEntityに変換されます。また、Conversion Modeを変更すると、Entityに変換しつつもGameObjectを残すということも可能です。
Prefabを弾と認識させる
ここまでは弾のPrefabをGameObjectで作りました。ですが、これを実行してEntityに変換しただけではSystemで制御する際になんのEntityかを判別することが出来ません(System側はデフォルトだとEntity全体を処理しようとするため)。
そこでTagを利用して弾であることを判別します。Tagという名前ですが、実態は要素が空のComponentです(ECSちょっとワカル方向け)。
Tagについては以下のページをご覧ください。
note.com
弾であることを示すBulletTagの作成
実際にTagを作っていきましょう。今回は要素のあるTagを作ります。
Projectタブで右クリックしてCreate > ECS > Runtime Component Type
を選択してスクリプトを作成します。名前はBulletTagにしましょう。
そして以下のような記述にします。
using Unity.Entities; [GenerateAuthoringComponent] public struct BulletTag : IComponentData { public float BulletSpeed; }
[GenerateAuthoringComponent]
属性をつけておくと、本来必要となるオーサリングを自動でうまくやってくれます。
オーサリングとは、GameObjectからEntityに変換することです。何か特殊なオーサリングを行わない場合を除き、基本的にはこの属性をComponentにつけておくと良いです。
オーサリングについてはここら辺を見ておくと良いかなと思います。
note.com
PrefabにBulletTagを登録
[GenerateAuthoringComponent]
属性をつけておいたおかげで簡単に登録できます。
先程作成したBulletTagをCubeBulletとSphereBulletにD&Dすることで追加できます。
弾を一定間隔で出す仕組みを作る
弾の発射源を作る
空のGameObjectを作成し、Convert To Entityをアタッチします。
そしたら次に、発射源であることを示すFirePointTagを作成しましょう。
using Unity.Entities; [GenerateAuthoringComponent] public struct FirePointTag : IComponentData { public Entity PrefabEntity; }
今回はComponentの要素として、複製元になるGameObjectを用意します。Entityで宣言しているのは、実行時に発射源ごとEntityに変換するためです。
これを先程作った空のGameObjectにアタッチします。PrefabEntityにはSphereBulletを入れておきましょう。
弾を発射するSystemを作る
Projectタブで右クリックしてCreate > ECS > Runtime Component Type
を選択してスクリプトを作成します。名前はBulletFireSystemにしてみました。
記入するコードは以下の通りです。
using Unity.Entities; using Unity.Transforms; [AlwaysUpdateSystem] public class BulletFireSystem : SystemBase { private BeginSimulationEntityCommandBufferSystem _entityCommandBufferSystem; private float _interval; protected override void OnCreate() { // EntityCommandBufferの取得 _entityCommandBufferSystem = World.GetOrCreateSystem<BeginSimulationEntityCommandBufferSystem>(); } protected override void OnUpdate() { // コマンドバッファを取得 var commandBuffer = _entityCommandBufferSystem.CreateCommandBuffer(); // 0.15秒数間隔で弾を発射する if (_interval > 0.15f) { Entities .WithAll<FirePointTag>() .WithoutBurst() .ForEach((in FirePointTag pointTag, in LocalToWorld localToWorld) => { // PrefabとなるEntityから弾を複製する var instantiateEntity = commandBuffer.Instantiate(pointTag.PrefabEntity); // 位置の初期化 commandBuffer.SetComponent(instantiateEntity, new Translation { Value = localToWorld.Position }); // 回転の初期化 commandBuffer.SetComponent(instantiateEntity, new Rotation { Value = localToWorld.Rotation }); }).Run(); // メインスレッド処理 // JobをCommandBufferで流し込む _entityCommandBufferSystem.AddJobHandleForProducer(Dependency); _interval = 0; } // 経過時間を反映 _interval += Time.DeltaTime; } }
コードの見方とかは入門記事などを参照してください。(ここでは解説しません)
コードの内容としては、0.15秒ずつ弾を発射させるSystemになっています。また、[AlwaysUpdateSystem]
属性をクラスにつけているため、このSystemは常に動くことになります。
また、0.15秒ごとという処理はEntityには直接関係は無い処理なのでForEachの外で行っており、Entityの生成等のEntityに直接かかわる処理はForEachの中で行っています。
ここからは、少しコードの中でも解説しておいた方が良いかなと思う項目について少し章立ててお話ししていきます。
EntityCommandBuffer
EntityCommandBufferはEntityを生成するために使用します。これは、Entityの生成・破棄・変更などのEntityの配列順が変化する際に使用します。
note.com
ワールド座標とローカル座標
生成するEntityの初期座標と角度に発射源のLocalToWorldを用いている理由としては、ローカル座標を扱いたいためです。
現状はまだ関係ないですが、発射源が回転するといったことをさせながら弾を発射させる場合、発射源のTranslationを使用するとワールド座標を代入することになるため、発射源の回転が考慮されずに同じ位置と角度に弾を出し続けるといったことになってしまいます。
それを防ぐために、初期座標および初期角度には発射源のTranslationではなくLocalToWorldを用いてローカル座標を代入しています。
弾を移動させるSystemを作る
先程のSystemによって、弾を生成する処理は完成しました。
ですが、このままでは生成がされても弾が生成された場所で停止した状態になってしまいます。
そうならないためにも、今度は弾を移動させるSystemを作りましょう。
先程と同様、Projectタブで右クリックしてCreate > ECS > Runtime Component Type
を選択してスクリプトを作成します。名前はBulletMovementSystemにしてみました。
記入するコードは以下の通りです。
using Unity.Entities; using Unity.Transforms; public class BulletMovementSystem : SystemBase { protected override void OnUpdate() { var deltaTime = Time.DeltaTime; Entities .WithAll<BulletTag>() .ForEach((ref Translation translation, in LocalToWorld localToWorld, in BulletTag bulletTag) => { translation.Value += localToWorld.Forward * bulletTag.BulletSpeed * deltaTime; }).ScheduleParallel(); // 分散並列スレッド処理 } }
先程に比べてこちらの方がシンプルですね。
Entityの中でもBulletTagを持つEntityのみに進行方向に進む処理を適用させています。
画面外に出た弾を消すSystemを作る
最後に、画面外に出た弾を消すSystemを作成します。現状のままだと、生成しただけで無限に増え続ける処理になってしまい、いくらECSと言えども流石に重くなってしまいます。
それを防ぐために、きちんと消す処理を作りましょう。
Projectタブで右クリックしてCreate > ECS > Runtime Component Type
を選択してスクリプトを作成します。名前はBulletDestroySystemにしてみました。
記入するコードは以下の通りです。
using UnityEngine; using Unity.Entities; using Unity.Transforms; [UpdateAfter(typeof(BulletMovementSystem))] public class BulletDestroySystem : SystemBase { private EndSimulationEntityCommandBufferSystem _entityCommandBufferSystem; private Camera _mainCamera; private Vector2 _lowerLeft; private Vector2 _upperRight; private readonly Vector2 _minPoint = new Vector2(-0.1f, -0.1f); private readonly Vector2 _maxPoint = new Vector2(1.1f, 1.1f); protected override void OnCreate() { // EntityCommandBufferの取得 _entityCommandBufferSystem = World.GetOrCreateSystem<EndSimulationEntityCommandBufferSystem>(); } protected override void OnStartRunning() { // スクリーン座標 → ワールド座標 _mainCamera = Camera.main; _lowerLeft = _mainCamera.ViewportToWorldPoint(_minPoint); _upperRight = _mainCamera.ViewportToWorldPoint(_maxPoint); } protected override void OnUpdate() { // コマンドバッファを取得 var commandBuffer = _entityCommandBufferSystem.CreateCommandBuffer(); Entities .WithAll<BulletTag>() .WithoutBurst() .ForEach((Entity entity, in Translation translation) => { // 画面外に出たら消す if (translation.Value.x > _upperRight.x || translation.Value.x < _lowerLeft.x || translation.Value.y > _upperRight.y || translation.Value.y < _lowerLeft.y) { commandBuffer.DestroyEntity(entity); } }).Run(); // メインスレッド処理 // JobをCommandBufferで流し込む _entityCommandBufferSystem.AddJobHandleForProducer(Dependency); } }
こちらは必要な時だけ実行されるSystemになっています([AlwaysUpdateSystem]
属性がないため)。そのため、メインカメラが切り替わる可能性を考慮してOnCreateではなくOnStartRunningにカメラを取得する処理を記述しています。システムの実行順序についてはこちらをご覧ください。
docs.unity3d.com
_lowerLeft
は左下のスクリーン座標を示すワールド座標であり、_upperRight
は右上のスクリーン座標を示すワールド座標です。少し微妙な数値に設定しているのは、画面端で消してしまうと不格好に見えてしまうためです。そのため、画面端よりも少し外に設定しておくことにより弾が完全に外に出たときに消すことが出来ます。
あとはBulletTagを持つEntityがその座標より外に出たらEntityCommandBufferを用いて対象のEntityを消去するという流れです。
実行
現状の状態で実行するとだいたい以下のようになると思います。
SphereBulletのBulletSpeedを調整してみるといい感じになるかと思います。
回転弾を作る
さて、ここまでで弾を出す一通りの仕組みは出来ました。次は発射源を回転させてみましょう。
RotateTagを作る
次に発射源を回転させていきます。
FirePointTagを持つEntityに対して回転させるSystemを書いても良いんですが、より幅を広げるために回転させるTagを作ろうと思います。
発射源に以下のTagをアタッチしましょう。
using Unity.Entities; [GenerateAuthoringComponent] public struct RotateTag : IComponentData { public float RotationSpeed; }
回転させるSystemを作る
先程作ったTagに適応するSystemを作ります。
軸を設定して回転させる処理です。
using Unity.Entities; using Unity.Mathematics; using Unity.Transforms; public class RotationSystem : SystemBase { protected override void OnUpdate() { var deltaTime = Time.DeltaTime; Entities .WithAll<RotateTag>() .ForEach((ref Rotation rotation, in RotateTag rotateTag) => { quaternion normalizedRotation = math.normalizesafe(rotation.Value); quaternion angleToRotate = quaternion.AxisAngle(math.up(), rotateTag.RotationSpeed * deltaTime); rotation.Value = math.mul(normalizedRotation, angleToRotate); }).ScheduleParallel(); // 分散並列スレッド処理 } }
実行
現状の状態で実行するとだいたい以下のようになると思います。
ECSで色々な弾を作って見た
ここまでは割と易しめに紹介してきました。
ですが、このままだと記事が膨大になってしまうので、ここからは実行画面とだいたいの設計方法を紹介できたらと思います。
ここからは、自機としてョョョねこ1号君に登場してもらいましょう。
N-way弾
弾幕の基本要素のうちの一つN-way弾ですね。
実装結果
実装手段
特に新規でSystemを加える必要は無く、発射源を3つに複製するだけで実現出来ます。
N-way弾の発射源にRotateTagをつけるとそれっぽくなります。
自機狙い弾
自機のいる方向に飛んでくる弾幕です。これも弾幕の中ではよくあるやつですね。
実装結果
実装手段
自機弾となるPrefab
まずは、自機弾となるPrefabを作りました。
普通の弾のScaleとかを反映した際に自機弾の方にも反映させたいため、Prefab Variantで作りました。
そして新規にAimBulletTagを作成し、アタッチしています。
using Unity.Entities; using Unity.Mathematics; using UnityEngine; [GenerateAuthoringComponent] public struct AimBulletTag : IComponentData { [HideInInspector] public float3 MoveDirection; }
自機弾を生成するSystem
生成の仕組み自体はBulletFireSystem.csのForEach内に追記しています。
サンプルでは、発射台についているFirePointTagのメンバにenumを追加してそこで分岐させていますね。
// ........省略........ Entities .WithAll<FirePointTag>() .WithoutBurst() .ForEach((in FirePointTag pointTag, in LocalToWorld localToWorld) => { // プレイヤーのローカル座標を取得 var playerEntityQuery = EntityManager.CreateEntityQuery(typeof(PlayerTag)); var playerEntity = playerEntityQuery.GetSingletonEntity(); var playerLocalToWorld = GetComponent<LocalToWorld>(playerEntity); // 発射台から見たプレイヤーの向き var direction = math.normalizesafe(playerLocalToWorld.Position - localToWorld.Position); // PrefabとなるEntityから弾を複製する var instantiateEntity = commandBuffer.Instantiate(pointTag.PrefabEntity); // 位置の初期化 commandBuffer.SetComponent(instantiateEntity, new Translation { Value = localToWorld.Position }); // 位置の初期化 commandBuffer.SetComponent(instantiateEntity, new Rotation { Value = quaternion.LookRotationSafe(direction, math.forward()) }); // 回転の初期化 commandBuffer.SetComponent(instantiateEntity, new AimBulletTag { MoveDirection = direction }); }).Run(); // メインスレッド処理 // JobをCommandBufferで流し込む _entityCommandBufferSystem.AddJobHandleForProducer(Dependency); // ........省略........ }
自機弾を動かすSystem
自機弾を動かす仕組みはBulletMovementSystem.csに追記しています。
ただ、最初に記載した通常段の影響を受けてしまうため、通常段の動きを書いている箇所には.WithNone<>()
を記載します。
Entities .WithAll<BulletTag>() .WithNone<AimBulletTag>() .ForEach((ref Translation translation, in LocalToWorld localToWorld, in BulletTag bulletTag) => { // ......省略...... }).ScheduleParallel(); Entities .WithAll<BulletTag, AimBulletTag>() .ForEach((ref Translation translation, in AimBulletTag aimBulletTag, in BulletTag bulletTag) => { translation.Value += aimBulletTag.MoveDirection * bulletTag.BulletSpeed * deltaTime; }).ScheduleParallel();
ランダム弾
ばらまき弾とも呼ばれるやつですね。
今回は、半径と最大角度を指定した扇形の面積内にランダムで出現する形のランダム弾にしています(多分使いやすい)。
実装結果
実装手段
発射の仕方だけを設定しています。
動きの方は通常段の動きをそのまま使用しているため、Prefabは通常段を使っています。
生成の仕組み自体はBulletFireSystem.csのForEach内に追記しています。だいたいは以下のような感じです。
// ........省略........ Entities .WithAll<FirePointTag>() .WithoutBurst() .ForEach((in FirePointTag pointTag, in LocalToWorld localToWorld) => { // PrefabとなるEntityから弾を複製する var instantiateEntity = commandBuffer.Instantiate(pointTag.PrefabEntity); var pi = math.PI; var lp = localToWorld.Position; var maxAngle = pi / 3.5f; // 最大角度 var radius = UnityEngine.Random.Range(0, 1.5f); // 半径 var angle = UnityEngine.Random.Range(-maxAngle - pi / 2.0f, maxAngle - pi / 2.0f); // 角度 var position = new float3(lp.x + radius * math.cos(angle), lp.y + radius * math.sin(angle), lp.z); // ランダム座標 var diff = math.normalizesafe(position - localToWorld.Position); // 発射源から見たランダム座標の向き // 位置の初期化 commandBuffer.SetComponent(instantiateEntity, new Translation { Value = position }); // 回転の初期化 commandBuffer.SetComponent(instantiateEntity, new Rotation { Value = quaternion.LookRotationSafe(diff, math.forward()) }); }).Run(); // JobをCommandBufferで流し込む _entityCommandBufferSystem.AddJobHandleForProducer(Dependency); // ........省略........ }
反射弾
最後に紹介する弾幕です。
画面端に弾が行くと、一度跳ね返る弾です。
実装結果
実装手段
跳ね返る弾を作る
まずは、跳ね返る弾を作成します。
反射弾はこれまでと同様、Prefab Variantで作成します。
そして、残り反射回数を持ったReflectionBulletTagを作成してアタッチします。
using Unity.Entities; [GenerateAuthoringComponent] public struct ReflectionBulletTag : IComponentData { public uint ReflectCount; }
反射弾を動かすSystemを作る
あとは、動かす仕組みを追加するだけです。
通常段とは跳ね返りがあるぐらいの違いしかないです。だいたい以下の通りですね。
Entities .WithAll<BulletTag>() .WithNone<AimBulletTag, ReflectionBulletTag>() .ForEach((ref Translation translation, in LocalToWorld localToWorld, in BulletTag bulletTag) => { // .........省略........ }).ScheduleParallel(); // .........省略........ Entities .WithAll<BulletTag, ReflectionBulletTag>() .ForEach((ref Translation translation, ref Rotation rotation, ref ReflectionBulletTag reflectionBulletTag, in LocalToWorld localToWorld, in BulletTag bulletTag) => { translation.Value += localToWorld.Forward * bulletTag.BulletSpeed * deltaTime; // 画面外で残り反射回数が0より大きかったら跳ね返す if (translation.Value.y < lowerLeft.y || translation.Value.y > upperRight.y || translation.Value.x < lowerLeft.x || translation.Value.x > upperRight.x) { if (reflectionBulletTag.ReflectCount > 0) { // 回転(精度が完璧ではない) quaternion normalizedRotation = math.normalizesafe(rotation.Value); quaternion angleToRotate = quaternion.AxisAngle(math.up(), 180 * deltaTime); rotation.Value = math.mul(normalizedRotation, angleToRotate); // 残り反射回数を減らす reflectionBulletTag.ReflectCount--; } } }).ScheduleParallel(); // 分散並列スレッド処理
ョョョねこ弾幕
おまけです。実装手段はサンプルのxyoxyoxyoNEKO.unity
を見てください。
実装結果
あとがき
今回はECSで弾幕を作る軽い紹介とその実例をいくつか紹介しました。かなりデカい記事になっちゃいました。すみませn...!
本記事ではECSで弾幕作れるよ!という紹介でしたが、まだまだPreview機能であることから出来ないこともあります。
例として一つ挙げるとすると、パーティクルをEntityに変換する方法がないため、敵を倒した際に消滅エフェクトを出すみたいなことは現状まだできません。むしろこの方法で出来るよ!みたいなのがあったらコメントで教えてもらえると非常に助かります。
出来ることも大いにありますが、Entityたけではまだまだ不足している機能も多いです。ECSが正式リリースされるのは、おそらく2022年以降になりそうなので、PureEntityでゲームを作るのはまだまだ複雑そうです。
ですが、これからECSがどういう経緯をたどっていくかは気になるので、今後もECSについて記事を書けたらなと思っています。
本記事では一部を掻い摘んで説明しているため、全実装が気になる方はこちらのGithubからcloneしてみてください(ライセンスを確認してくださいね)。
github.com
以上になります!
ョョョねこ Advent Calendar 2020の明日の記事は、本アドカレ主催のseptem47さんの記事になります!お楽しみに!!
追記
本当はこのまま気持ちよく終わろうと思ったのですが、2020/12/16に、ECSがロードマップから外された疑惑が出始めました。
確かにロードマップからECS無くなってるんですよね。開発終了じゃなくてIn Developmentに戻ってくれてたらいいなぁと思います。
unity3d.com
なので、もしかしたらこの記事はもう需要がない可能性もありますし、仮に今後ECSが再度Previewになったとしても本記事の内容とはかなり異なったアプローチになっているかもですね。ぴえん。