Unity の ECS を使って Apple Vision Pro でパーティクルを操作してみた
はじめに
Unity の Entity-Component-System (ECS) を、PolySpatial で利用してパーティクル表現を実現する方法について解説します。
動作したサンプルはこんな感じです↓
動作サンプルのプロジェクトは GitHub に公開しています。
環境セットアップ
PolySpatial と同時に利用するためには普段の ECS とは少し異なるセットアップが必要です。
ECS パッケージをインポートする
まずは ECS のパッケージをインストールします。インポートするにはパッケージマネージャの Unity Registory で Entities
と Entities Graphics
を検索してインポートします。
Extensions Package のインポート
ECS を PolySpatial で利用するためには追加で Extensions が必要なためこれをインポートします。インポートするにはパッケージマネージャの「Add package by name」から com.unity.polyspatial.extensions
を入力して追加します。
Color Gamut に DisplayP3 を追加する
Player Setting にある Color Gamut
に DisplayP3
を追加します。初期値は sRGB
のみのため、右下の +
ボタンから追加してください。
App Mode を Metal Rendering with Compositor Service に変更する
Project Settings にある XR Plug-in Management > Apple visionOS
にある App Mode
を Metal Rendering with Compositor Service
に変更します。
カメラのカラー設定を調整する
前述の App Mode を Metal Rendering に変えたことによりカメラの設定も見直す必要があります。
具体的には、カメラの出力設定を Solid Color に変更した上で、色のアルファを 0
にする必要があります。これは、(おそらく)最終のフレームバッファの透明部分にカメラ映像が合成されるためと思われます。言い換えると、アルファを 0
にしないと透明な部分がなくなり、結果的にカメラ映像が合成されません。
Project Validation を確認
その他、必要な修正については Project Settings 内の Project Validation にワーニングやエラーとして表示されているため適切に調整してください。
以上でセットアップ完了です。次からは実際に ECS を使ってパーティクルを実装していきます。
ECS を使ってパーティクルを実装する
ここからは実際にコードを確認しながらパーティクルを実装する過程を解説していきます。ECS の実装についてはある程度把握している前提で進めていきます。
今回の実装のポイント
今回実装したパーティクルシステムで重要になってくるのが死活管理とエミッター位置管理です。
ECS ではメモリ効率を最大化しつつ Entity
と呼ばれる単位でデータを扱います。そのため、個別のデータを持たせることは可能ですが、シェーダで言うところの Uniform
変数に相当するものが存在しません。そのため、パーティクルエミッターのように、所属する Entity
すべてに共通のデータが変化するものについては少し工夫をする必要があります。
この 2 点について解説していきます。
死活管理
死活管理はパーティクルプールの考え方で必要になってきます。生成したパーティクルが常に描画されているのであれば死活管理は必要ありませんが、一定数のパーティクルをプールしておき、生成リクエストがあったら活性化する、ということを実現したい場合は考慮する必要があります。
ECS での処理単位
ECS では扱うデータのグループを Archetype
という単位で管理します。そして Archetype
は Entity
に追加されている Component
の種類によって決まります。
ざっくり言うと「こういうコンポーネント群を持つ Entity
のリストをちょうだい」とリクエストすると、ECS のシステムはそれに応じた Entity
のリストを返してくれるようなイメージです。つまり、このリストの対象から外れることがそのまま非活性化につながります。
また、ECS ではレンダリング周りは既存のシステムが行ってくれるため、それに則って今回は実装しました。具体的に言うと DisableRendering
コンポーネントの有無で死活管理を行います。Unity が用意してくれている描画システムはこのコンポーネントが付与されていると描画対象となりません。そのため今回の自作のシステムでも同様に、このコンポーネントが付与されているものは処理を省くようにしました。
処理単位のリクエスト(クエリ)
これを実現するには Entity
をリクエストするためのクエリを工夫します。以下は実際に実装したコードの断片です。
[UpdateBefore(typeof(TransformSystemGroup))]
public partial struct DotParticleSystem : ISystem
{
private EntityQuery _query;
private BufferLookup<EmitterPositionElement> _emitterPositionBufferLookup;
public void OnCreate(ref SystemState state)
{
_emitterPositionBufferLookup = state.GetBufferLookup<EmitterPositionElement>(true);
_query = state.GetEntityQuery(
ComponentType.ReadOnly<MeshInstanceData>(),
ComponentType.ReadOnly<LocalToWorld>(),
ComponentType.Exclude<DisableRendering>());
state.RequireForUpdate<EmitterPositionElement>();
}
// 中略
}
処理対象として持っていてほしいコンポーネントを ComponentType.ReadOnly<T>
で、持っていてほしくないコンポーネントを ComponentType.Exclude<T>
で state.GetEntityQuery
メソッドに指定します。
ここでは Exclude
に DisableRendering
を指定しています。これにより、DisableRendering
が付与されていない Entity
のみが処理対象となります。
エミット処理(有効化)
死活管理に DisableRendering
コンポーネントを使うことを説明しました。以下は、実際にそれを達成しているコードを示します。
まずは自作システムに対してエミットをリクエストしている部分です。エミッターの位置となる IParticleAnchor
と、パーティクルの設定オブジェクト EmitParametter
を引数にしてエミットしています。
public EmittedParticleInfo Emit(IParticleAnchor anchor, EmitParameter emitParameter)
{
// 中略
// 自作したエミッター管理システムに追加し、空いているバッファのインデックスを受け取る
OriginIndexInfo originIndexInfo = _emitterOriginManager.AddOrigin(anchor);
List<Entity> particles = new List<Entity>();
for (int i = 0; i < emitParameter.Count; i++)
{
// 利用可能リストからパーティクル(Entity)を取り出し、
Entity entity = _availableParticles.Dequeue();
// 活性化処理をして設定を反映させる
EnableEntity(entity);
UpdateEntity(entity, emitParameter, originIndexInfo);
// 活性化リストに追加する
_activeParticles.Add(entity);
particles.Add(entity);
}
return new EmittedParticleInfo()
{
Index = originIndexInfo.Index,
Particles = particles,
};
}
// 更新処理は、引数に渡されたパラメータからデータを取り出し、パーティクルが持つコンポーネントにコピーする
private void UpdateEntity(Entity entity, EmitParameter emitParameter, OriginIndexInfo originIndexInfo)
{
MeshInstanceData meshInstanceData = _entityManager.GetComponentData<MeshInstanceData>(entity);
Vector3 p = Random.insideUnitSphere.normalized * emitParameter.Radius;
float3 position = new float3(p.x, p.y, p.z);
float3 scale = new float3(emitParameter.Scale);
meshInstanceData.OriginIndex = originIndexInfo.Index;
meshInstanceData.Position = position;
meshInstanceData.Scale = scale;
meshInstanceData.RotateSpeed = Random.Range(emitParameter.RotateSpeedRange.x, emitParameter.RotateSpeedRange.y);
meshInstanceData.Amplitude = Random.Range(emitParameter.AmplitudeRange.x, emitParameter.AmplitudeRange.y);
meshInstanceData.Frequency = Random.Range(emitParameter.FrequencyRange.x, emitParameter.FrequencyRange.y);
_entityManager.SetComponentData(entity, meshInstanceData);
ColorData colorData = _entityManager.GetComponentData<ColorData>(entity);
Color c = emitParameter.ColorTheme.GetRandomNext();
float4 value = new float4(c.r, c.g, c.b, 1.0f);
colorData.Value = value;
_entityManager.SetComponentData(entity, colorData);
}
// IParticleAnchor インターフェースは Transform を参照できるようにする
public interface IParticleAnchor
{
Transform Transform { get; }
}
// EmitParameter は、パーティクル生成に必要なデータをまとめたもの
public struct EmitParameter
{
public int Count;
public float Radius;
public float Scale;
public Vector2 RotateSpeedRange;
public Vector2 AmplitudeRange;
public Vector2 FrequencyRange;
public ColorTheme ColorTheme;
}
今回のこの手法はコンピュートシェーダなどでも利用されている、利用可能リストと活性化中リストを使って管理する方法です。
処理内容は、利用可能リストから Entity
を取り出し、その Entity
に対して活性化とパラメータの反映を行っています。そして活性化リストに追加して有効状態を切り替えています。
リターン処理(非活性化)
上の処理はパーティクルシステムに対してエミットリクエストを送って活性化するものでした。次は使用済みパーティクルをリターンする処理を見ていきます。
やることはエミットのときとほぼ逆な処理をするだけですが、エミット時よりはシンプルになっています。
public void ReturnParticles(EmittedParticleInfo emittedParticleInfo)
{
// 中略
// エミット時に設定されたパーティクルリストから、対象パーティクルを非活性化し、適切にリストに反映する
foreach (Entity entity in emittedParticleInfo.Particles)
{
DisableEntity(entity);
_availableParticles.Enqueue(entity);
_activeParticles.Remove(entity);
}
// エミッター管理システムからエミッター位置情報を削除する
_emitterOriginManager.RemoveOrigin(new OriginIndexInfo()
{
Index = emittedParticleInfo.Index,
});
}
リターン処理は各パーティクルを非活性化して適切にリストに反映する処理と、エミッター位置情報を削除する処理の 2 つです。エミットよりはやや処理が少ない感じですね。
以下は活性化・非活性化の処理です。といっても、対象 Entity
に対して DisableRendering
コンポーネントを付与 or 削除するだけです。
private void EnableEntity(Entity entity)
{
if (_entityManager.HasComponent<DisableRendering>(entity))
{
_entityManager.RemoveComponent<DisableRendering>(entity);
}
}
private void DisableEntity(Entity entity)
{
if (!_entityManager.HasComponent<DisableRendering>(entity))
{
_entityManager.AddComponent<DisableRendering>(entity);
}
}
エミッター位置管理
次に解説するのはエミッターの位置管理です。パーティクルそれぞれに持たせているデータには共通のデータは存在しません。もちろん、共通となるデータを持つコンポーネントを用いてそれを更新するという方法でも実装できますが、共通の少数データをパーティクルすべてにコピーするのは効率が悪いです。
Entity
間で共通のデータを持たせる ISharedComponentData
というものがありますが、こちらは今回の意図としては使用することができません。というのも、このコンポーネントの役割は Archetype
の細分化が目的なためです。どういうことかと言うと、特定の ISharedComponentData
を持つ Entity
のうち、さらに保持しているデータごとに細分化してチャンクに格納するという挙動をするためです。
例えば、MoveTypeSharedComponent
のような共有データがあり、そのコンポーネントの保持する移動のタイプごとに Entity
を分けて効率よく処理を行うような形で使用するものです。
しかし今回は毎フレームデータ内容が更新されるためこれを利用するのは適しません。(共有データを更新するとデータ構造が変化するため基本、ある程度固定的になるデータを持たせることが前提)
▼ ドキュメント
そこで、各パーティクルには共通データへのインデックスのみを持たせ、共通データのリストから自身のインデックスを元に対象データを割り出して利用するという方法を取りました。
共通データには DynamicBuffer<T>
を利用しています。これは ECS の機能で Entity
に対してバッファをバインドすることができる機能を利用したものです。
この DynamicBuffer<T>
は前述の Archetype
と呼ばれるグループが保存されている Chunk
内に保存されるデータです。そのため ECS の目的であるメモリ効率の点でも問題なく利用することができるようになっています。
エミッター位置データのイメージ
コード例を示す前に、これをどう使っているかについてのイメージを先に解説しておきます。
DynamicBuffer<T>
は Entity
にバインドされると書きました。コンポーネントは Entity
の属性のように振る舞い、 DynamicBuffer<T>
は付属するデータです。そのためデータの取得方法もコンポーネントと異なります。
今回はエミッターの位置管理データ用に DynamicBuffer<T>
を用いて領域を作成し、それをひとつの(シングルトン的な) Entity
に紐づけます。
そしてこの Entity
をパーティクル処理の際に取得し、そこから対象のバッファを取り出します。
前述のように、DynamicBuffer<T>
自体は Entity
にバインドされるだけなのでひとつだけしか存在してはいけないという制約はありません。しかし、複数の Entity
にバインドしてしまうと Uniform
変数的に利用したい場合にどの Entity
を参照したらいいかが分からなくなってしまいます。そのためひとつだけになるように制限をかけるというわけです。
ECS でもそういう使用を想定しているのか SystemAPI.GetSingletonEntity<T>()
というメソッドが用意されています。今回はこれを利用します。
さて、では実際に実装したコードを見ながら解説していきましょう。
エミッター位置データの定義
まずはエミッター位置データの定義を行います。これは IBufferElementData
を実装した構造体を用意し、エミッターの位置を示す Position
というメンバを持たせます。
public struct EmitterPositionElement : IBufferElementData
{
public float3 Position;
}
IBufferElementData
は DynamicBuffer<T>
で扱うデータの型を定義するためのインターフェースです。
エミッター位置データのバッファを作成
データを定義できたのでこれを Entity
にバインドします。
private void CreateSingletonEntity()
{
if (_emitterManagerEntity != Entity.Null) return;
_emitterManagerEntity = _entityManager.CreateEntity();
_entityManager.AddBuffer<EmitterPositionElement>(_emitterManagerEntity);
DynamicBuffer<EmitterPositionElement> emitterPositions = _entityManager.GetBuffer<EmitterPositionElement>(_emitterManagerEntity);
emitterPositions.Capacity = s_capacity;
for (int i = 0; i < s_capacity; i++)
{
emitterPositions.Add(new EmitterPositionElement());
}
}
Entity
の生成などは通常のものと変わりありません。 EntityManager.CreateEntity
メソッドで Entity
を生成します。そして生成した Entity
に対して EntityManager.AddBuffer<T>
メソッドでバッファをバインドします。
バッファの更新
作成したバッファの更新はシンプルです。特に今回はエミッターの位置という、パーティクル数に対して圧倒的に数が少ないため、通常の Update
メソッド内で実行しています。
public void UpdateOrigins()
{
// パーティクル発生源位置の更新
DynamicBuffer<EmitterPositionElement> emitterPositions = _entityManager.GetBuffer<EmitterPositionElement>(_emitterManagerEntity);
foreach (EmitterOriginInfo originInfo in _emitterOriginInfoList)
{
emitterPositions[originInfo.Index] = new EmitterPositionElement { Position = originInfo.Anchor.Transform.position };
}
}
private struct EmitterOriginInfo
{
public int Index;
public IParticleAnchor Anchor;
}
EmitterOriginInfo
は、エミッターの位置を示すアンカー(Transform
)のデータと、バッファのどの位置がこのデータに該当するのかのインデックス情報をペアにした構造体です。このインデックスを元に位置情報を更新しています。
バッファの利用
バッファの生成、更新はメインスレッドで行っていますがこのバッファを利用するのは ECS の Job System 内です。実際に利用している部分も見てみましょう。
private BufferLookup<EmitterPositionElement> _emitterPositionBufferLookup;
// ---------------------------------------------
public void OnUpdate(ref SystemState state)
{
_emitterPositionBufferLookup.Update(ref state);
Entity emitterManagerEntity = SystemAPI.GetSingletonEntity<EmitterPositionElement>();
DynamicBuffer<EmitterPositionElement> emitterPositions = _emitterPositionBufferLookup[emitterManagerEntity];
var job = new ParticleUpdateJob()
{
Time = SystemAPI.Time.ElapsedTime,
EmitterPositions = emitterPositions,
};
JobHandle handle = job.ScheduleParallel(_query, state.Dependency);
state.Dependency = handle;
}
_emitterPositionBufferLookup
は前述の OnCreate
メソッド内で以下のように取得したものです。
_emitterPositionBufferLookup = state.GetBufferLookup<EmitterPositionElement>(true);
そして事前に取得したこの構造体を用いて対象のバッファを取り出します。取り出している処理は以下の部分です。
Entity emitterManagerEntity = SystemAPI.GetSingletonEntity<EmitterPositionElement>();
DynamicBuffer<EmitterPositionElement> emitterPositions = _emitterPositionBufferLookup[emitterManagerEntity];
ここで前述のシングルトンアクセスのメソッドが利用されています。今回の自作システムでは EmitterPositionElement
を持つ Entity
はひとつだけに限定しているため、この方法で目的の Entity
を取得することができます。そしてこの Entity
をキーにしてバッファを取得します。
パーティクルの更新
前段まででバッファ情報を取得することができました。これはバッファ全体を示しているため、さらにパーティクル自体の処理の際に特定のデータを参照する必要があります。パーティクルの更新処理は以下のようになっています。
[BurstCompile]
partial struct ParticleUpdateJob : IJobEntity
{
public double Time;
[ReadOnly] public DynamicBuffer<EmitterPositionElement> EmitterPositions;
private void Execute([EntityIndexInQuery] int index, ref MeshInstanceData meshData, ref LocalToWorld localToWorld)
{
float3 center = EmitterPositions[meshData.OriginIndex].Position;
float3 basePosition = meshData.Position;
float3 projectedPosition = basePosition;
projectedPosition.y = 0;
float rad = math.atan2(projectedPosition.z, projectedPosition.x);
float radius = math.length(projectedPosition);
float speed = meshData.RotateSpeed;
float x = (float)math.cos(rad + math.PI * Time * speed) * radius;
float z = (float)math.sin(rad + math.PI * Time * speed) * radius;
float y = (float)math.sin(Time * meshData.Frequency) * meshData.Amplitude + basePosition.y;
float3 newPosition = new float3(x, y, z);
float3 position = center + newPosition;
localToWorld.Value = float4x4.TRS(position, quaternion.identity, meshData.Scale);
}
}
EmitterPositions
にはバッファの参照が保持されています。そしてパーティクル自身に設定されたインデックスを用いて該当データを参照し、それを center
として利用することで、エミッターの位置を基準としたパーティクルの位置を計算しています。
今回のアニメーションではエミッターの位置を中心に、球体状に散らばったパーティクルが、その球体表面を沿って流れるような実装をしています。今回のこの実装は、実際のプロジェクトで実装したものです。ただ、処理自体は自由に行えるためこれを応用することで様々な表現が可能です。また、今回はエミッター位置のみを参照していますが、これも応用すれば Uniform
変数のように様々な共通データをパーティクルで利用することができるでしょう。
おわりに
visionOS 2.x から Metal による MR のレンダリングもサポートされ、Unity の既存機能もある程度は利用できるようになってきました。今回の ECS を用いたパーティクル表現も、その一例です。もともと MESON のコンテンツではパーティクルを多用していたこともあり、visionOS 向けアプリでは今までの表現を移植するのが困難な側面もありましたが、これでようやく今までのものも移植可能性が出てきました。
パーティクルを自由に動かせるようになると表現の幅が大きく広がるので今後の visionOS と PolySpatial のアップデートにはとても期待ですね。
ぜひみなさんも、これを気にパーティクル表現にチャレンジしてみてください。
エンジニア絶賛募集中!
MESONではUnityエンジニアを絶賛募集中です! XR、空間コンピューティングのプロジェクトに関わってみたい! 開発したい! という方はぜひご応募ください!
MESONのメンバーページからご応募いただくか、TwitterのDMなどでご連絡ください。
書いた人
比留間 和也(あだな:えど)
カヤック時代にWEBエンジニアとしてリーダーを務め、その後VRに出会いコロプラに転職。 コロプラでは仮想現実チームにてXRコンテンツ開発に携わる。 DAYDREAM向けゲーム「NYORO THE SNAKE & SEVEN ISLANDS」をリリース。その後、ARに惹かれてMESONに入社。 MESONではARエンジニアとして活躍中。
またプライベートでもAR/VRの開発をしており、インディー部門でTGSに出展など公私関わらずAR/VRコンテンツ制作に精を出す。プライベートな時間でも開発しているように、新しいことを学ぶことが趣味で、最近は英語を学んでいる。
MESON Works
MESONの制作実績一覧もあります。ご興味ある方はぜひ見てみてください。
Discussion