この記事がEntity Component System入門者の助けとなることを願って初透光です。
ECSについて初歩的な知識を有していることが前提です。
わからないことがあれば入門記事を参照してください。
C#7.2でソースコードを書いていますので、文法的な不明点がある場合はufcppを参照してください。
執筆経緯
Unity1週間ゲームジャムに初参加してきました。
これは月曜0時にお題が発表され、それに沿ったゲームを日曜20時までに投稿するという催しです。
今回のお題は「あつい」でした。既に締切を過ぎて様々なゲームが発表されていますが、なかなか参加者の発想の幅が広くて楽しいゲームジャムでした。
きゅうりをできる限り薄切りしていくゲームや、村をうっかり(確信犯)焼き尽くすゲームなど色々ありますので是非遊んでみてください。
あまり記事にするのが遅れるのもアレなので省ける解説は大分省いての投稿となりました。
さて、私自身は 相変わらずヴァーレントゥーガ互換リアルタイムストラテジーを作る練習として 大量の敵に追いかけられながら敵を焼き殺すというゲームを作りました。
1週間でどのぐらい自分が作れるのか自分でも全く把握していなかったので当初の予定より大幅に機能を削ぎ落としましたが、なんとか遊べる程度に完成できて良かったです。
Entity Component System(以下ECS)がWebGLに対応していないのでパッケージに手を加えて動かせるように自力でなんとかしました。
いやぁ…… 一週間ゲームShamu開始前に動かないことに気付けてよかったです。丸1日がこの対応作業で潰れましたからね。
リポジトリ
今回のゲームのリポジトリ
今回のゲーム「晩夏の昼の挽歌」へのリンク
リポジトリをcloneしたらそのまま遊べます。
前提環境
- WebGL版
- Unity 2018.2.8f1
- ECS 0.0.12-preview.11 on WebGL
- Job System不使用
設計
ECSはUIやシーン管理、Audio周りに対して現在のところ残念ながら向いていません。その部分は伝統的なMonoBehaviour/Componentで実装しています。
インゲームのコアなロジックに対してECSを使用しています。
シーン概説
シーン数は総計7つ
- Title
- 難易度や音量を設定するUIシーンを呼び出すボタンとゲーム開始ボタンがあるだけのシンプルなシーンです。当記事では非解説。
- MusicSetting
- 音量調節の方法を調べる時間がなかったのでBGM,SEのオンオフ切り替えのみできます。当記事では非解説。
- Unity1週間ゲームジャムが終了してからUnity2018.3.0b1が来てNested Prefab使用できるようになったので、もうマルチシーンエディティング君は終わりですね……
- DifficultySetting
- メインのゲームで登場する敵キャラの数を調整したり、戦場の広さを調節することができます。当記事では非解説。
- SCWR
- ~なぜSCWRという命名をしたのか正直覚えていません~
- ECSが動作するガッツリインゲームな部分です。 #当記事で解説します#。
- uGUIのキャンバス及びUI要素とカメラ2つ以外のGameObjectが存在しません。
- Ranking
- GameClear, GameOverからのみ呼び出されて追加されるシーン。当記事では非解説。
- 詳しくは【Unity、WebGL】なるべく簡単にオンラインランキング機能をつけるサンプルを参照してください。
- クリアする楽しみの他にハイスコアを狙うやりこみ要素もあるゲームですからランキング機能を付けました。
- GameOver
- SCWRに追加されます。ここでキルスコアと難易度設定から総合的なランクとスコアを計算し、表示しています。当記事では非解説
- 今から作り始めるならNested Prefabにし(ry - GameClear
- 上にほぼ同じです。当記事では非解説。
- 今ならNested(ry
矢印はシーン遷移を表します。太い矢印はSingleシーン遷移、細い矢印はAdditiveなシーン追加を意味します。
シーン間での情報の共有はScriptableObjectを使用しています。
具体的な方法は安定のテラシュールブログ記事を参照してください。
エディターでプレイするとScriptableObjectが勝手に書き換わる問題は気合でなんとかしました。
中核シーン概説
- Main Camera
- AudioListnerとAudioSource6つがついています。その中5つがSE用であり、1つがBGM用です。
- Event Systemコンポーネントも付属しています。
- このゲームのGO is GODクラスであるMangerクラスがAdd Componentされています。
- Root Canvas
- 体力ゲージ、機体温度表示UI、キルスコア表示UIを全て含有しています。
- UI関連は他のゲームと変わらないであろうので非解説。
- UI Camera
- Root Canvasに表示しているUIが大きすぎてプレイ上微妙に邪魔になったりするので、バックスペースキーで非表示にできるように、UIだけを写すカメラを用意しました。
- このGameObjectをinactivateすれば非表示できます。
神Managerクラス概説(あるいはポエム)
Managerクラスはその名の短さが示すように最も中核で全ての依存を管理し、全てのパラメータを受付け、全てのゲーム進行を掌握していた。
人は余りの神威故partialクラスとしてManagerを9つに裂き、その力を減じさせんとした。
- Manager.cs
- [SerializeField]が29個もあるので紛うことなき神
- Startマジックメソッドから各種設定をセットアップするのがお仕事。
- InitializeWorld.cs
- Manager.csに定義されたSerializeFieldを適切にComponentSystemのコンストラクタに渡すだけの簡単なお仕事。
- 神は言った。「UNITY_DISABLE_AUTOMATIC_SYSTEM_BOOTSTRAPしない市民は歴史上に存在することを許されていません。」
- Update.cs
- UI全体をUniRxを使用して管理する。
- AudioManger.cs
- SE管理(管理できていると言ってない)を行う。
- InitializePlayer.cs
- プレイヤーを表すEntityはシーン内に常に1つのみ存在し、CreateEntityされた後絶対にDestroyEntityされることはない。(これを保証するものはプログラマーの良心のみである。)
- クリティカルな部分であるので神はECSに任せず手ずからプレイヤーの生成を行われたのである。
- Stage.cs
- このゲームは敵を殺した数によって難易度が上昇する。
- UniRxを使用してキルスコアに応じて難易度管理をするクラス。
- 具体的にはECSのコンポーネントシステムのEnabledをtrueにしたりfalseにしたりと切り替えることで難易度管理をしている。
- GameStage.cs
- ネーミングがStage.csと被っていて分かり辛い(確信)
- プレイヤーの体力とキルスコアをUniRxを使用して監視し、プレイヤーが死んだりクリアしたりしたら全てのコンポーネントシステムの動作を停止させる。
- その後GameOverあるいはGameClearシーンをAdditiveにLoadSceneAsyncする。
- UniRx.cs
- UniRxを活用して入力に対するFacadeクラスとせんとしたが、力及ばずごく一部の武器切り替え機能しか持たない無力なpartialクラス。
- LastBoss.cs
- 死産
Manager.csの基礎構造
[RequireComponent(typeof(Camera))]
sealed partial class Manager : MonoBehaviour
{
[SerializeField] ScriptableObjects.Map mapTable;
[SerializeField] Material unlit;
[SerializeField] ScriptableObjects.EnemyDisplay enemyDisplay;
[SerializeField] ScriptableObjects.Speed playerSpeeds;
[SerializeField] ScriptableObjects.Speed enemySpeeds;
[SerializeField] AudioSource BgmSource;
[SerializeField] AudioClip[] BgmClips;
[SerializeField] float heatDamageRatio;
[SerializeField] float coolRatio;
[SerializeField] float rainCoolPower;
[SerializeField] float rainCoolTimeSpan;
[SerializeField] float rainCoolFrequency;
[SerializeField] ScriptableObjects.SkillSetting snowSkillSetting;
[SerializeField] AudioClip takenokoBulletShoot;
[SerializeField] AudioClip takenokoBulletBurst;
[SerializeField] AudioClip snowBurst;
[SerializeField] AudioSource[] sources;
[SerializeField] Sprite playerSprite;
[SerializeField] Material playerMaterial;
[SerializeField] Sprite kinokoHammer;
[SerializeField] Material kinokoMaterial;
[SerializeField] GameObject 武器欄;
[SerializeField] ScriptableObjects.Speed stage4EnemySpeed;
[SerializeField] GameObject respawnDisplay;
[SerializeField] AudioSource BGMSource;
[SerializeField] ScriptableObjects.TitleSettings titleSettings;
[SerializeField] ScriptableObjects.Result resultSettings;
[SerializeField] ScriptableObjects.SkillSetting[] playerSkills;
[SerializeField] ScriptableObjects.SkillSetting bombSkillEffect;
private EntityManager manager;
private Camera UICamera;
private EnemyPlayerCollisionSystem EnemyPlayerCollisionSystem;
private RainSystem RainSystem;
private PlayerShootSystem PlayerShootSystem;
void Start()
{
mainCamera = GetComponent<Camera>();
UICamera = GameObject.Find("UI Camera").GetComponent<Camera>();
sourceInfos = new (float, float, AudioClip)[sources.Length];
var position = this.transform.position;
position.x = titleSettings.Width * 0.5f;
position.z = titleSettings.Height * 0.5f;
this.transform.position = position;
#if UNITY_EDITOR
Validate();
#endif
InitializeAudio();
InitializeWorld();
InitializeUGUI();
InitializeBGM();
InitializeUniRx();
InitializeStageWatch();
InitializeGameOverUI();
}
}
ScriptableObjects名前空間は肥大化するSerializeFieldに抗うためにScriptableObjectを定義している空間です。
ScriptableObjects.TitleSettings titleSettingsとScriptableObjects.Result resultSettingsの2つがシーン間で共有するScriptableObjectsです。
他のScriptableObjectsはreadonlyとして扱うようプログラマーの良心に期待しています。
titleSettingsに関してはManagerクラスの所属するSCWRシーン内でreadonlyとして扱うようプログラマーの(ry
resultSettingsはSCWRシーン内からのみ書き込み可能であるという設定で良心あるプログラマーに扱(ry
ここら辺を厳密に良心や規約によらずコンパイラーに制限してもらう方法に心当たりはありますが、一週間ゲームジャムでやるには個人開発なので必要性が薄い&時間が足りないのでやっていません。
シーン毎にアクセシビリティを変更する方法(読み飛ばし可)
- Scriptsフォルダの下にシーンごとにフォルダを用意します。
- 各シーンフォルダにasmdefファイルを用意します。
- readonlyやwriteonlyにしたいフィールドに対するpublicアクセサを書きましょう。
- この時一般のクラスから見えないようにする部分にinternal修飾子を付与しましょう。
- シーンフォルダにAssemblyInfo.csを書きましょう。
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Unity1Week.Title")]
こんな感じで書けばCSWRシーンのinternalメンバーにTitleシーンからのみアクセスできるようになります。
InitializeWorld.cs
private void InitializeWorld()
{
Mesh enemyMesh = RotateSprite(enemyDisplay.enemySprite);
var world = World.Active = new World("default");
world.SetDefaultCapacity(1 << 18);
manager = world.CreateManager<EntityManager>();
var range = new Unity.Mathematics.uint2(titleSettings.Width, titleSettings.Height);
var decidePositionHashCodeSystem = world.CreateManager<DecidePositionHashCodeSystem>(range);
var enemyHashCodes = decidePositionHashCodeSystem.EnemyHashCodes;
var snowHashCodes = decidePositionHashCodeSystem.SnowBulletCodes;
var playerBulletHashCodes = decidePositionHashCodeSystem.PlayerBulletCodes;
var playerBulletPositionHashSet = decidePositionHashCodeSystem.PlayerBulletPositionHashCodeSet;
var snowBulletPositionHashSet = decidePositionHashCodeSystem.SnowBulletPositionHashCodeSet;
var chips = InitializePlane(range.x, range.y);
InitializePlayer(range, 100, InitialTemperature, ThermalDeathPoint);
world.CreateManager(typeof(PlayerEnemyRenderSystem), mainCamera, playerSprite, playerMaterial, enemyMesh, new Material[] { enemyDisplay.bossMaterial, enemyDisplay.leaderMaterial, enemyDisplay.subordinateMaterial });
world.CreateManager(typeof(MoveSystem));
world.CreateManager(typeof(EnemyBulletRenderSystem), mainCamera, snowSkillSetting.Sprites[0], snowSkillSetting.Material);
world.CreateManager(typeof(MoveEnemySystem), player);
world.CreateManager(typeof(EnemySnowShootSystem), player, 4);
world.CreateManager(typeof(ConfinePlayerPositionSystem), player, range, mainCamera.transform);
world.CreateManager(typeof(KinokoRenderSystem), mainCamera, kinokoHammer, kinokoMaterial, 120 * Math.PI / 180, 1f);
PlayerShootSystem = world.CreateManager<PlayerShootSystem>(player, mainCamera, new Action(TryToPlayTakenokoShoot));
var SpawnEnemySystem = InitializeSpawnEnemy(player, enemyMesh, world, range, titleSettings.LeaderCount);
deathCounter = SpawnEnemySystem.DeathCount;
nearToRespawn = SpawnEnemySystem.NearToRespawn;
world.CreateManager(typeof(TakenokoEnemyHitCheckSystem), 0.16f, enemyHashCodes, playerBulletHashCodes, playerBulletPositionHashSet, new Action(TryToPlayTakenokoBurst));
world.CreateManager(typeof(PlayerMoveSystem), player, mainCamera.transform);
InitializeAnimationRenderSystem(world, takenokoBulletBurst.length);
world.CreateManager(typeof(DestroyEnemyOutOfBoundsSystem), range);
world.CreateManager(typeof(DecideMoveSpeedSystem), range, chips, playerSpeeds.Speeds, enemySpeeds.Speeds);
world.CreateManager(typeof(UpdateCoolTimeSystem));
world.CreateManager(typeof(TakenokoRenderSystem), mainCamera, playerSkills[0].Sprites[0], playerSkills[0].Material);
world.CreateManager(typeof(BombHitCheckSystem), player, 4, enemyHashCodes);
world.CreateManager(typeof(ChipRenderSystem), mainCamera, range, chips, mapTable.chipTemperatures, mapTable.map, unlit);
world.CreateManager(typeof(SnowPlayerHitCheckSystem), player, snowSkillSetting.UtilityNumber, deathCounter, 0.5f, snowHashCodes, playerBulletHashCodes, snowBulletPositionHashSet, new Action(TryToPlaySnowBurst));
(this.RainSystem = world.CreateManager<RainSystem>(range, rainCoolTimeSpan, rainCoolPower, rainCoolFrequency)).Enabled = false;
(this.EnemyPlayerCollisionSystem = world.CreateManager<EnemyPlayerCollisionSystem>(player, enemyHashCodes, 0.16f, deathCounter)).Enabled = false;
world.CreateManager(typeof(PlayerTemperatureSystem), player, range, chips, heatDamageRatio, coolRatio);
world.CreateManager(typeof(UtusemiRenderSystem), mainCamera, playerSkills[1].Sprites[0], playerSkills[1].Material, 15);
ScriptBehaviourUpdateOrder.UpdatePlayerLoop(world);
}
World.CreateManager(Type t, paramas object[] constructorArguments)は内部的にActivator.CreateInstanceを使用しています。
よって、ComponentSystemに対する依存性注入は純粋にコンストラクタ経由で行えます。
ECS標準のMeshInstanceRendererSystemはActiveCameraというフィールドを外部に露出して書き込んでもらうことを前提とした設計ですが、それなんて前時代
ComponentSystemから外部にフィールドを公開する場合もreadonlyフィールドなので勝手に書き換えられる心配をしなくてすみます。 readonlyが使えないMonoBehaviourとは違うのだよ!
ちょっと残念なのは参照の依存関係上CreateManagerする順番が割と重要なことですね。大半のComponentSystemは他のComponentSystemに依存していないので初期化順番を気にしなくていいのですが……
24個もComponentSystemをCreateManagerしていますが、この規模のゲームとしてはどうなのでしょうかね?
ComponentSystem個々の働きを確認してみましょう。
- 描画系6つ
- AnimationSkillRender
- 爆発のスプライトコマ落ちアニメーションを扱います。爆発以外のアニメーションスプライトも統一的に扱えるよう設計しましたが、他にアニメするエフェクトがなかったです
- ChipRender
- 地面のマップチップを描画するシステムです。このゲームではプレイヤーの攻撃により地面の種類が変化しますし、マップも自動生成であるので事前にテクスチャをベイクするという手法を取れませんでした。そのためマップ描画専用のシステムを用意しました。
- PlayerEnemyRender
- 敵とプレイヤーを描画します。MeshInstanceRendererSystemは内部動作が複雑で不必要に高機能なので使用しませんでした。
- リポジトリを見れば十分わかると思うので当記事では解説しません。
- EnemyBulletRender
- アメイジングトレンチハンマーRender
- ドウリルヴェルファーRender
- 空蝉Render
- この4つのComponentSystemは攻撃を描画するためのものです。個々の攻撃の仕様が微妙に違うので専用のComponentSystemをそれぞれに用意しました。もうちょっと工夫すれば統一的に扱えたでしょう。
- リポジトリを見れば十分わかると思うので当記事では解説しません。
- 敵とプレイヤーの移動系6つ
- DecideMoveSpeed
- このゲームでは地形マス毎に移動力が設定されていますので、現在位置に応じて毎フレーム移動力を再計算しています。
- リポジトリを見れば十分わかると思うので当記事では解説しません。
- ConfinePlayerPosition
- DestroyEnemyOutOfBounds
- 戦場の外にプレイヤーが出ることが出来ないように制限しています。
- 敵や弾丸は制限せず、外に出た場合その場でDestroyEntityしています。
- 生成と削除のコストが安いECSだからこそできることですね。
- リポジトリを見れば十分わかると思うので当記事では解説しません。
- HeadingEnemy
- 一定時間毎に敵は移動方向を変更します。
- リポジトリを見れば十分わかると思うので当記事では解説しません。
- Move
- 移動方向(Heading)と移動速度(MoveSpeed)を元にあらゆるEnityのPositionを更新します。
- リポジトリを見れば十分わかると思うので当記事では解説しません。
- PlayerMove
- Playerの移動方向はInputから決定します。また、カメラが常にプレイヤーを中央に捉え続けるように設定しました。
- リポジトリを見れば十分わかると思うので当記事では解説しません。
- 当たり判定系4つ
- BombHit
- ドウリルヴェルファーが敵に当たった後発生する爆炎が敵やプレイヤーにダメージを与える処理をします。
- 爆炎とプレイヤー・敵の衝突判定を実行
- EnemyPlayerCollision
- 敵とプレイヤーの衝突判定。プレイヤーの体力を削る処理を含む。
- SnowPlayerHit
- 敵が放つ攻撃とプレイヤー・プレイヤーの放ったドウリルヴェルファーとの衝突判定。
- TakenokoEnemyHit
- ドウリルヴェルファーと敵の衝突判定
@mao_ さんがいずれECSにおける衝突判定に関するまとめ記事を書かれるそうなので詳細な解説はしません。- 解説記事が発表されました。
- NativeMultiHashMap<ulong,ValueTuple<Entity,float2>>をforeachで回して格子内での衝突判定をしています。参考資料
- 弾幕射出など攻撃系4つ
- UpdateCoolTime
- スキルクールタイムを採用しています。プレイヤーとスキルを使用する全ての敵のスキルのクールタイムを更新します。ただ、別にこのシステムよく考えたら不要なのですよね……
- 【Unite Tokyo 2018】誘導ミサイル完全マスターの116枚目あたりのテクニック使えばよかったです……
- PlayerShoot
- プレイヤーのスキルがクールタイムよりも長く使用されず、かつマウスの左ボタンが押されている場合にスキルを使用します。
- EnemyShoot
- 上に同じ
- LaserShoot
- 死産
- リポジトリ読めば十分理解できます。
- その他4つ
- Rain
- 時間経過による地形変更システム。リポジトリを読めば十分理解できるので当記事では非解説。
- SpawnEnemy
- 敵が一定以下に減らされたりなど特定条件を満たした場合に大量の敵を発生させる。
- PlayerTemperature
- プレイヤーの機体温度を管理する。一定以上の機体温度ではプレイヤーにダメージを与える。
描画系解説
このゲームはプレイヤー・敵、エフェクト等すべてをXW平面上に描画しています。
それをY軸上に存在するカメラが上から眺めている形になります。
ECSにおける一般的な描画方法については次の記事を参照するとよいでしょう。
AnimationSkillRender.cs
一番工夫がわかりやすいシステムから解説しましょう。 折角スケールするように設計しても対象が1つしかないので無駄とは言ってはいけない。
Graphics.DrawMeshInstancedIndirectが何故かWebGLで動かなかったので次善の策としてGraphics.DrawMeshInstancedを使用しています。
Graphics.DrawMeshInstancedIndirect関連は凡ミスが発生しやすい面倒なメソッドなので、何か私が気付かずにミスっているだけという可能性も高いですがね。
Graphics.DrawMeshInstancedは一度のBatchで描画できる対象数が1023個に制限されているため、数万個を描画しようとするとBatchCountがゴリゴリ増える欠点があります。
このシステムではスプライトをgifのようにアニメーションさせつつ大量に描画しています。
今回ゲームで使用した素材(ぴぽや様より拝借しました)
ご覧のように一枚のテクスチャに8つの爆発画像が含まれています。InspectorでSplite ModeをMultipleに設定して8つにスプライトを分割、各SpriteからMeshを取り出して配列に入れています。
このMesh配列の要素をGraphics.DrawMeshInstancedの引数に適切に渡してあげればアニメーションができるのです。
public sealed class AnimationSkillRenderSystem : ComponentSystem
{
private readonly Camera mainCamera;
private readonly Matrix4x4[][] matrixArray;
private readonly int[] countArray;
private readonly float[] timeArray;
private readonly Mesh[][] meshArray;
private readonly Material[] materialArray;
private readonly EntityArchetypeQuery[] queryArray;
private readonly NativeList<EntityArchetype>[] foundArchetypeListArray;
private readonly HashSet<Entity> toDestroy = new HashSet<Entity>();
private EntityManager manager;
public AnimationSkillRenderSystem(Camera mainCamera, (EntityArchetypeQuery query, Sprite[] sprites, Material material, float time)[] array)
{
if (array == null) throw new ArgumentOutOfRangeException();
this.mainCamera = mainCamera;
int length = array.Length;
this.timeArray = new float[length];
this.meshArray = new Mesh[length][];
this.materialArray = new Material[length];
this.queryArray = new EntityArchetypeQuery[length];
this.foundArchetypeListArray = new NativeList<EntityArchetype>[length];
int maxSpriteVariationCount = 0;
for (int i = 0; i < array.Length; i++)
{
ref var element = ref array[i];
if (element.time == 0) throw new ArgumentException();
this.timeArray[i] = element.time;
ref var meshes = ref meshArray[i];
meshes = new Mesh[element.sprites.Length];
maxSpriteVariationCount = Math.Max(element.sprites.Length, maxSpriteVariationCount);
for (int j = 0; j < meshes.Length; j++)
meshes[j] = element.sprites[j].FromSprite(0.02f);
this.materialArray[i] = element.material;
this.queryArray[i] = element.query;
this.foundArchetypeListArray[i] = new NativeList<EntityArchetype>(Allocator.Persistent);
}
var identity = Matrix4x4.identity;
this.matrixArray = new Matrix4x4[maxSpriteVariationCount][];
for (int i = 0; i < this.matrixArray.Length; i++)
{
this.matrixArray[i] = new Matrix4x4[1023];
unsafe
{
var srcPtr = &identity;
fixed (Matrix4x4* destPtr = this.matrixArray[i])
{
UnsafeUtility.MemCpyReplicate(destPtr, srcPtr, 64, 1023);
}
}
}
this.countArray = new int[maxSpriteVariationCount];
}
protected override void OnCreateManager(int capacity) => manager = EntityManager;
protected override void OnDestroyManager()
{
for (int i = 0; i < foundArchetypeListArray.Length; i++)
foundArchetypeListArray[i].Dispose();
}
protected override void OnUpdate()
{
toDestroy.Clear();
var PositionTypeRO = manager.GetArchetypeChunkComponentType<Position>(true);
var Position2DTypeRO = manager.GetArchetypeChunkComponentType<Position2D>(true);
var LifeTimeTypeRO = manager.GetArchetypeChunkComponentType<LifeTime>(true);
var EntityType = manager.GetArchetypeChunkEntityType();
var currentTime = Time.timeSinceLevelLoad;
for (int i = 0; i < queryArray.Length; i++)
{
manager.AddMatchingArchetypes(queryArray[i], foundArchetypeListArray[i]);
unsafe
{
fixed (int* ptr = countArray)
{
UnsafeUtility.MemClear(ptr, countArray.LongLength << 2);
}
}
using (var chunks = manager.CreateArchetypeChunkArray(foundArchetypeListArray[i], Allocator.Temp))
{
ref var material = ref materialArray[i];
ref var meshes = ref meshArray[i];
for (int j = 0; j < chunks.Length; j++)
{
var lifeTimes = chunks[j].GetNativeArray(LifeTimeTypeRO);
if (lifeTimes.Length == 0) continue;
var entities = chunks[j].GetNativeArray(EntityType);
var positions2D = chunks[j].GetNativeArray(Position2DTypeRO);
if (positions2D.Length == 0)
{
var positions3D = chunks[j].GetNativeArray(PositionTypeRO);
if (positions3D.Length == 0) continue;
Execute3D(i, currentTime, entities, lifeTimes, positions3D);
continue;
}
Execute2D(i, currentTime, entities, lifeTimes, positions2D);
}
CleanUp(i);
}
}
foreach (var entity in toDestroy)
if (manager.Exists(entity))
manager.DestroyEntity(entity);
}
private void CleanUp(int index)
{
var meshes = meshArray[index];
var material = materialArray[index];
for (int i = 0; i < meshes.Length; i++)
if (countArray[i] != 0)
Graphics.DrawMeshInstanced(meshes[i], 0, material, matrixArray[i], countArray[i], null, ShadowCastingMode.Off, false, 0, mainCamera, LightProbeUsage.Off, null);
}
private void Execute2D(int index, float currentTime, NativeArray<Entity> entities, NativeArray<LifeTime> lifeTimes, NativeArray<Position2D> positions)
{
var deathTime = timeArray[index];
var meshes = meshArray[index];
var material = materialArray[index];
for (int i = 0; i < lifeTimes.Length; i++)
{
var time = (currentTime - lifeTimes[i].Value) / deathTime;
if (time >= 1) // currentTime >= lifeTimes[i].Value + deathTime
{
toDestroy.Add(entities[i]);
continue;
}
var stage = (int)(meshes.Length * time);
var mesh = meshes[stage];
ref var matrixes = ref matrixArray[stage];
ref var count = ref countArray[stage];
ref var matrix = ref matrixes[count++];
matrix.m03 = positions[i].Value.x;
matrix.m23 = positions[i].Value.y;
if (count < 1023) continue;
Graphics.DrawMeshInstanced(mesh, 0, material, matrixes, 1023, null, ShadowCastingMode.Off, false, 0, mainCamera, LightProbeUsage.Off, null);
count = 0;
}
}
private void Execute3D(int index, float currentTime, NativeArray<Entity> entities, NativeArray<LifeTime> lifeTimes, NativeArray<Position> positions)
{
var deathTime = timeArray[index];
var meshes = meshArray[index];
var material = materialArray[index];
for (int i = 0; i < lifeTimes.Length; i++)
{
var time = (currentTime - lifeTimes[i].Value) / deathTime;
if (time >= 1) // currentTime >= lifeTimes[i].Value + deathTime
{
toDestroy.Add(entities[i]);
continue;
}
var stage = (int)(meshes.Length * time);
var mesh = meshes[stage];
ref var matrixes = ref matrixArray[stage];
ref var count = ref countArray[stage];
ref var matrix = ref matrixes[count++];
matrix.m03 = positions[i].Value.x;
matrix.m23 = positions[i].Value.z;
if (count < 1023) continue;
Graphics.DrawMeshInstanced(mesh, 0, material, matrixes, 1023, null, ShadowCastingMode.Off, false, 0, mainCamera, LightProbeUsage.Off, null);
count = 0;
}
}
}
ECS 0.0.12-preview.11からの新機能としてChunk Iterationがあります。
使用法はComponentGroupに比べたらやや複雑ですが十分簡単です。
EntityArchetypeQuery型とNativeList<EntityArchetype>型のフィールドを用意します。
毎フレームOnUpdate内部でEntityManager.AddMatchingArchetype(EntityArchetype, NativeList<EntityArchetype>)を呼び出してNativeList<EntityArchetype>を最新の情報にアップデートしてあげます。
その後EntityManager.CreateArchetypeChunkArray(NativeList<EntityArchetype>, Allocator)を呼び出してNativeArray<ArchetypeChunk>を得ましょう。
今回のプロジェクトは対象プラットフォームがWebGLで完全にシングルスレッドで実行されるのでAllocatorにAllocator.Tempを使用していますが、本来ECSはマルチスレッドで実行されますのでAllocator.Tempだとエラー吐かれますので、Allocator.TempJob以上にしましょう。
実際にEntityを処理する際はNativeArray<ArchetypeChunk>をfor文ぐるぐる回して処理します。
各ArchetypeChunkからEntityやComponentDataを得るにはArchetypeChunk.GetNativeArray(ArchetypeChunkEntityType), ArchetypeChunk.GetNativeArray<T>(ArchetypeChunkComponentType<T>を使用します。
引数に与えるArchetypeChunkComponentType<T>は毎フレームEntityManager.GetArchetypeChunkComponentType<Position>(bool isReadOnly)で生成します。
気を付けて欲しいのですが、GetNativeArrayの戻り値であるNativeArrayをDisposeしてはいけません。これは実質Span<T>みたいなものですので、所有権をプログラマーは有していません。ECSが生成し、破棄まで管理しています。
さて、Chunk Iterationによりチャンク毎に効率的に処することができるようになりましたね。
今回このシステムは「EntityArchetypeQuery毎にアニメーションするスプライトが全く異なる」という前提のもとに動いています。
つまり、EntityArchetypeQuery, Mesh[], Materialがタプルとなっているわけです。そのためコンストラクタにはValueTuple<EntityArchetypeQuery, Sprite[], Material, float>を与えています。
工夫した点は1023要素のMatrix4x4[]をMax(アニメーションのコマ数)個最初に用意し、以後配列をnewしないようにしてアロケーションを最低限にしたことですね。また、TransformSystemを介在させず直接Matrix4x4[]に書き込むことで無駄なコピーコストをなくしています。
衝突判定
BombHit.cs
実行順制御についてはテラシュールブログの記事が詳しいのでそちらを参照してください。
[UpdateAfter(typeof(DecidePositionHashCodeSystem))]
[UpdateBefore(typeof(BombRenderSystem))]
public sealed class BombHitCheckSystem : ComponentSystem
{
private readonly float rangeSquared;
private readonly NativeMultiHashMap<int, DecidePositionHashCodeSystem.Tuple> enemyHashCodes;
private readonly Entity player;
private ComponentGroup g;
private EntityArchetype deadMan;
private readonly (int, int)[] diff;
private readonly HashSet<Entity> toDestroy = new HashSet<Entity>();
public BombHitCheckSystem(Entity player, float radius, NativeMultiHashMap<int, DecidePositionHashCodeSystem.Tuple> enemyHashCodes)
{
this.player = player;
this.rangeSquared = radius * radius;
this.enemyHashCodes = enemyHashCodes;
var ls = new List<(int, int)>((int)rangeSquared);
for (int i = 0, r = (int)radius, r2 = r * r; i <= r; i++)
{
for (int j = 0, end = (int)Math.Sqrt(r2 - i * i); j <= end; j++)
{
ls.Add((i, j));
if (i != 0)
ls.Add((-i, j));
if (j != 0)
ls.Add((i, -j));
if (i != 0 && j != 0)
ls.Add((-i, -j));
}
}
this.diff = ls.ToArray();
}
protected override void OnCreateManager(int capacity)
{
g = GetComponentGroup(ComponentType.ReadOnly<Position2D>(), ComponentType.ReadOnly<BombEffect>());
deadMan = EntityManager.CreateArchetype(ComponentType.Create<DeadMan>());
}
protected override void OnUpdate()
{
var positions = g.GetComponentDataArray<Position2D>();
var manager = EntityManager;
var buf = PostUpdateCommands;
var playerPos = manager.GetComponentData<Position>(player).Value;
var deltaTime = Time.deltaTime;
toDestroy.Clear();
for (int consumed = 0, length = positions.Length; consumed < length;)
{
var posChunk = positions.GetChunkArray(consumed, length - consumed);
for (int i = 0; i < posChunk.Length; i++)
{
var x = (int)posChunk[i].Value.x;
var y = (int)posChunk[i].Value.y;
float plDistanceSquared;
{
var diffX = playerPos.x - posChunk[i].Value.x;
var diffY = playerPos.z - posChunk[i].Value.y;
plDistanceSquared = diffX * diffX + diffY * diffY;
}
if (plDistanceSquared <= rangeSquared)
{
var setting = manager.GetComponentData<PlayerSettings>(player);
setting.Temperature += 300f / plDistanceSquared * deltaTime;
manager.SetComponentData(player, setting);
}
for (int j = 0; j < diff.Length; j++)
{
if (!enemyHashCodes.TryGetFirstValue(((diff[j].Item1 + x) << 16) | (diff[j].Item2 + y), out var item, out var it))
continue;
toDestroy.Add(item.Entity);
while (enemyHashCodes.TryGetNextValue(out item, ref it))
toDestroy.Add(item.Entity);
manager.CreateEntity(deadMan);
break;
}
}
foreach (var item in toDestroy)
if (manager.Exists(item))
manager.DestroyEntity(item);
consumed += posChunk.Length;
}
}
}
BombEffectは爆発を意味するComponentTypeです。
爆発の所属するマス目とその上下左右についてハッシュ値を計算し、NativeMultiHashMapから所属するEntityとその座標を得て衝突判定を行っています。
衝突する場合は破棄予定EntityとしてHashSet<Entity>にAddし、OnUpdateの最後の方にまとめてEntityManager.Existsで生存を確認しつつEntityManager.DestroyEntityしています。
Unity.Mathematics.mathという数学クラスがあるのにどうしてベタベタと汚くコードを書いているのか疑問に思うでしょう。
それはUnity.Mathematics.mathのメソッドの大半がSystem.Mathのラッパーメソッドであるからです。しかもfloat<=>doubleの変換を一々伴うので効率が悪いです。
SIMD演算化されて高速に動作するという触れ込みですが、それはBurstでコンパイルされた時に限定されるため、Job Systemを使えないWebGLでは一切使わないのが一番です。
参考文献
- 【Unity】ECSで弾幕STGを作ってみたので技術周りについて解説
- 更に古いバージョンを対象としていますが、ECSで作られたまともに実際遊べるゲームの解説記事です。
- 【Unity】Entity Component System入門(その1)【2018.2】
- ゲームジャムに参加している間にバージョンが上がってしまったのでそのうち書き直します。
- この記事の読み飛ばしてもよい解説を全て理解できたら「ECSなんもわからん」と胸を張って言えます。
- 【Unity】PureECSの描画周りについて解説してみる
小ネタとは一体?- 描画周りはこれ読めば基本を全部抑えられます。本当です。付け足すことないです。
- 【Unite Tokyo 2018】誘導ミサイル完全マスター
- WebGLではComputeShader一切使えませんが、かなり参考になります。
- 【Unity】ECSで複数のWorldを運用する方法
- デフォルトワールドを殺した場合についてわかりやすい解説があります。
感想
弾幕とECSの相性は最高です。自然と大物量を扱うことになりますからね。
WebGLがシングルスレッド強制してくるのとComputeShader使用不可の縛りがもの凄くキツかったですが、unityroomにおける最大物量ゲームの記録を更新できたのではないかと思います。