Zenn

Unity の ECS を使って Apple Vision Pro でパーティクルを操作してみた

2025/02/14に公開

キービジュアル

はじめに

Unity の Entity-Component-System (ECS) を、PolySpatial で利用してパーティクル表現を実現する方法について解説します。

動作したサンプルはこんな感じです↓

サンプル動画

動作サンプルのプロジェクトは GitHub に公開しています。

https://github.com/MESON-inc/Unity-ECS-Sample-with-PolySpatial

環境セットアップ

PolySpatial と同時に利用するためには普段の ECS とは少し異なるセットアップが必要です。

ECS パッケージをインポートする

まずは ECS のパッケージをインストールします。インポートするにはパッケージマネージャの Unity Registory で EntitiesEntities Graphics を検索してインポートします。

ECSパッケージをインポート

Extensions Package のインポート

ECS を PolySpatial で利用するためには追加で Extensions が必要なためこれをインポートします。インポートするにはパッケージマネージャの「Add package by name」から com.unity.polyspatial.extensions を入力して追加します。

PolySpatial Extensionのインポート

https://docs.unity3d.com/Packages/com.unity.polyspatial.visionos@2.0/manual/ExtensionsImport.html

Color Gamut に DisplayP3 を追加する

Player Setting にある Color GamutDisplayP3 を追加します。初期値は sRGB のみのため、右下の + ボタンから追加してください。

Color GamutをDisplayP3を追加

App Mode を Metal Rendering with Compositor Service に変更する

Project Settings にある XR Plug-in Management > Apple visionOS にある App ModeMetal Rendering with Compositor Service に変更します。

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 という単位で管理します。そして ArchetypeEntity に追加されている 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 メソッドに指定します。

ここでは ExcludeDisableRendering を指定しています。これにより、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,
    };
}
Entity の更新処理
// 更新処理は、引数に渡されたパラメータからデータを取り出し、パーティクルが持つコンポーネントにコピーする
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 を分けて効率よく処理を行うような形で使用するものです。

しかし今回は毎フレームデータ内容が更新されるためこれを利用するのは適しません。(共有データを更新するとデータ構造が変化するため基本、ある程度固定的になるデータを持たせることが前提)

▼ ドキュメント
https://docs.unity3d.com/Packages/com.unity.entities@0.1/manual/shared_component_data.html


そこで、各パーティクルには共通データへのインデックスのみを持たせ、共通データのリストから自身のインデックスを元に対象データを割り出して利用するという方法を取りました。

共通データには 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;
}

IBufferElementDataDynamicBuffer<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 };
    }
}
EmitterOriginInfo 構造体
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コンテンツ制作に精を出す。プライベートな時間でも開発しているように、新しいことを学ぶことが趣味で、最近は英語を学んでいる。

GitHub / Twitter

MESON Works

MESONの制作実績一覧もあります。ご興味ある方はぜひ見てみてください。

MESON Works

MESONテックブログ

Discussion

ログインするとコメントできます