ハツェの真時代傾向璋

興味を持ったことを書いていく鱗片的な場所から先の未来の場

ECSを使って弾幕を作る

 こんばんは。ハツェです。
 今回はョョョねこ 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自体に関して良く知らないという方は、以下の入門記事やチュートリアル動画などを見てみることを強くお勧めします。


 また、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を入力することでインポート出来ます。 f:id:hatuxes:20201203114419p:plain
 これ以外にもPreviewPackageのGitURLが書かれた一覧がUnityの公式ページにあります。困っている方がいたら参照してみてください。
docs.unity3d.com

概要

 この記事ではECSを使って単純な弾を生成する方法、および色々な弾幕パターンの作成方法について紹介出来たらと思います。
 ECSについて軽く概要を説明しておくと、GameObjectとは別の概念であるEntityを用いてゲーム等を作成します。
 作成のフローとしては、EntityにComponentを用いてデータを付与し、それを基にSystemでEntity達を制御するといった感じです。

シンプルに弾を出す

発射する弾を作成する

まずは、発射する弾のPrefabを作成していきます。弾としてはプリミティブなCubeもしくはSphereを使おうと思います。
また大事なことではありますが、オブジェクトのZ方向を前として作成しています。

Cubeを用いた弾を作る

  1. まず、Cubeを作成してPositionとRotationは(0, 0, 0)に、Scaleを(0.075, 0.075, 0.45)程度にします。
  2. BoxColliderは削除し、代わりとしてPhysics Body, Physics Shape, Convert To Entityをアタッチします。
  3. Physics BodyのMotion TypeをKinematicにします。
  4. Physics ShapeのCollision ResponseをRaise Trigger Eventsにします。
  5. CubeをPrefab化します。名前はCubeBulletにでもしておきましょう。好きな名前にしてもらって大丈夫です。

f:id:hatuxes:20201210031726p:plain

Sphereを用いた弾を作る

  1. まず、Sphereを作成してPositionとRotationは(0, 0, 0)に、Scaleを(0.1, 0.1, 0.1)にします。
  2. SphereColliderは削除し、代わりとしてPhysics Body, Physics Shape, Convert To Entityをアタッチします。
  3. Physics BodyのMotion TypeをKinematicにします。
  4. Physics ShapeのShapeTypeをSphereに、Collision ResponseをRaise Trigger Eventsにします。
  5. CubeをPrefab化します。名前はSphereBulletにでもしておきましょう。好きな名前にしてもらって大丈夫です。

f:id:hatuxes:20201204004824p:plain

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することで追加できます。
f:id:hatuxes:20201204024926p:plain

弾を一定間隔で出す仕組みを作る

弾の発射源を作る

  空のGameObjectを作成し、Convert To Entityをアタッチします。
  そしたら次に、発射源であることを示すFirePointTagを作成しましょう。

using Unity.Entities;

[GenerateAuthoringComponent]
public struct FirePointTag : IComponentData
{
    public Entity PrefabEntity;
}

  今回はComponentの要素として、複製元になるGameObjectを用意します。Entityで宣言しているのは、実行時に発射源ごとEntityに変換するためです。
  これを先程作った空のGameObjectにアタッチします。PrefabEntityにはSphereBulletを入れておきましょう。
f:id:hatuxes:20201210032915p:plain

弾を発射する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を調整してみるといい感じになるかと思います。 f:id:hatuxes:20201212223640g:plain

回転弾を作る

  さて、ここまでで弾を出す一通りの仕組みは出来ました。次は発射源を回転させてみましょう。

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(); // 分散並列スレッド処理
    }
}


実行

  現状の状態で実行するとだいたい以下のようになると思います。
f:id:hatuxes:20201207050312g:plain

ECSで色々な弾を作って見た

  ここまでは割と易しめに紹介してきました。
  ですが、このままだと記事が膨大になってしまうので、ここからは実行画面とだいたいの設計方法を紹介できたらと思います。
  ここからは、自機としてョョョねこ1号君に登場してもらいましょう。
f:id:hatuxes:20201211030434g:plain

N-way弾

  弾幕の基本要素のうちの一つN-way弾ですね。

実装結果

f:id:hatuxes:20201211030214g:plain

実装手段

  特に新規でSystemを加える必要は無く、発射源を3つに複製するだけで実現出来ます。
f:id:hatuxes:20201211030628p:plain
  N-way弾の発射源にRotateTagをつけるとそれっぽくなります。
f:id:hatuxes:20201211031113g:plain

自機狙い弾

  自機のいる方向に飛んでくる弾幕です。これも弾幕の中ではよくあるやつですね。

実装結果

f:id:hatuxes:20201211032025g:plain

実装手段

自機弾となるPrefab

  まずは、自機弾となるPrefabを作りました。
  普通の弾のScaleとかを反映した際に自機弾の方にも反映させたいため、Prefab Variantで作りました。
  そして新規にAimBulletTagを作成し、アタッチしています。

using Unity.Entities;
using Unity.Mathematics;
using UnityEngine;

[GenerateAuthoringComponent]
public struct AimBulletTag : IComponentData
{
    [HideInInspector] public float3 MoveDirection;
}

f:id:hatuxes:20201211032503p:plain

自機弾を生成する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();


ランダム弾

  ばらまき弾とも呼ばれるやつですね。
  今回は、半径と最大角度を指定した扇形の面積内にランダムで出現する形のランダム弾にしています(多分使いやすい)。

実装結果

f:id:hatuxes:20201211040150g:plain

実装手段

  発射の仕方だけを設定しています。
  動きの方は通常段の動きをそのまま使用しているため、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);

    // ........省略........
}


反射弾

  最後に紹介する弾幕です。
  画面端に弾が行くと、一度跳ね返る弾です。

実装結果

f:id:hatuxes:20201211042911g:plain

実装手段

跳ね返る弾を作る

  まずは、跳ね返る弾を作成します。
  反射弾はこれまでと同様、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を見てください。

実装結果

f:id:hatuxes:20201212000140g:plain

あとがき

  今回は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になったとしても本記事の内容とはかなり異なったアプローチになっているかもですね。ぴえん。

VideoPlayerを作ろう - プレイリスト編

こんばんは。ハツェです。
前回に引き続きVideoPlayerに関して記事を書きたいと思います。
今回は、任意のURLを入力するタイプでなく、プレイリストをUnity上で作ろうというお話です。
動作環境は、Unity : 2018.4.20f1、VRCSDK : 2021.05.17.12.52、U# : v0.19.11です。

目次


今回の概要

シンプルに作れてLocalで動作するVideoPlayerについて紹介します。
今回は、事前にURLを組み込んでおいて動作させるプレイリストみたいなものを作る予定です。
Localで動作するため、これをそのままワールドに使用した際、みんなで見るものが違うVideoPlayerが出来上がりますのでご注意ください。

前回

前回は、任意のURLから動画を再生させる方法について紹介しました。
hatuxes.hatenablog.jp

プレイリスト

プレイリストと言っても事前にUnityにURLを書き込んだ配列を用意するだけです。
普通のStringではダメなんですが、VRCUrl型の配列だと下記画像のようにうまくプレイリストに落とし込むことが出来ます。

f:id:hatuxes:20200919225556p:plain

実際に作成してみる

完成図

だいたいは以下の通りに作ろうと思います。
画面と音声、加えて各種操作パネルがあるという構成です。
今回はAVProを使って作成しますが、Unity版のコンポーネントを使用しても正しく動作すると思います。

f:id:hatuxes:20200919223845p:plain

加えて今回、オブジェクトの作成とコンポーネントのアタッチについては前回とほぼ同様なため省略します。

スクリプトを作る

VRCAV Pro Video Playerをアタッチしたオブジェクトと同じオブジェクトにUdonBehaviorをアタッチしてスクリプトを生成します。
加えて、今回使用したコードは以下の通りです。

f:id:hatuxes:20200919164227p:plain
using UdonSharp;
using UnityEngine;
using UnityEngine.UI;
using VRC.SDKBase;
using VRC.Udon;

public class LocalVideoController_PlayList : UdonSharpBehaviour
{
    [SerializeField]
    private VRC.SDK3.Video.Components.Base.BaseVRCVideoPlayer _videoPlayer;

    [SerializeField]
    private VRCUrl[] _playlistURL;

    [SerializeField]
    private Text _playTimeDisplayText;

    [SerializeField]
    private Text _indexDisplayText;

    [SerializeField]
    private Toggle _loopToggle;

    private float _videoDuration;
    private int _urlIndex;
    private bool _isPausing;

    private void Update()
    {
        _indexDisplayText.text = string.Format("Index : {0}", _urlIndex);

        if (_videoPlayer.IsPlaying)
        {
            _playTimeDisplayText.text = string.Format("{0:f} / {1:f}", _videoPlayer.GetTime(), _videoDuration);
        }
    }

    public override void OnVideoStart()
    {
        _videoDuration = _videoPlayer.GetDuration();
    }

    public override void OnVideoEnd()
    {
        if (_loopToggle.isOn)
        {
            _urlIndex = (_urlIndex + 1) >= _playlistURL.Length ? 0 : _urlIndex + 1;
        }
        else
        {
            if ((_urlIndex + 1) >= _playlistURL.Length)
            {
                return;
            }

            _urlIndex++;
        }

        PlayVideo();
    }

    public void PlayVideo()
    {
        _videoPlayer.Stop();
        _videoPlayer.PlayURL(_playlistURL[_urlIndex]);
    }

    public void PauseVideo()
    {
        _isPausing = !_isPausing;

        if (_isPausing)
        {
            _videoPlayer.Pause();
        }
        else
        {
            _videoPlayer.Play();
        }
    }

    public void StopVideo()
    {
        _videoPlayer.Stop();
    }

    public void SkipVideo()
    {
        _videoPlayer.SetTime(_videoPlayer.GetTime() + 10.0f);
    }

    public void BackVideo()
    {
        _videoPlayer.SetTime(_videoPlayer.GetTime() - 5.0f);
    }

    public void NextVideo()
    {
        _urlIndex = (_urlIndex + 1) >= _playlistURL.Length ? 0 : _urlIndex + 1;

        PlayVideo();
    }

    public void PreviousVideo()
    {
        _urlIndex = (_urlIndex - 1) < 0 ? _playlistURL.Length - 1 : _urlIndex - 1;

        PlayVideo();
    }
}


部分解説

Update内

再生時間を表示しています。
実際には以下のように表示されます。
左側に現在の再生時間を、右側には動画時間を表示している感じです。

f:id:hatuxes:20200919210302p:plain

動画時間の取得

OnVideoStartイベントでBaseVRCVideoPlayer.GetDuration()を使用して変数に格納しています。
OnVideoStartイベントは、停止状態から再生した際に呼ばれるものなので、(後述にある)PlayVideo関数内では一度停止させてから再生させています。

動画が終わったときの処理

OnVideoEndイベントを用いて、動画終了時に次のプレイリストの動画に移るという処理をしています。
UIのToggleにチェックマークが入ってた場合は、一周したらまた頭から再生し、チェックマークが入ってなかったらプレイリストの終わりで動画が止まるようになってます。

ボタンから呼んでいる関数

UIからUdonの関数を動作させる方法については入門記事をご覧ください。
hatuxes.hatenablog.jp
OnVideoStart以下に記述している関数は全てボタンから呼んでいます。
PlayVideo()からBackVideo()まではURLInput方式のVideoPlayerと同様の処理なので、詳しくはそちらをご覧ください。

進むボタンと戻るボタン

NextVideo()では次のプレイリストの動画に、PreviousVideo()では前のプレイリストの動画に移るようになっています。
ここの処理はLoopのUIチェックボックスに関係なく処理されるべきなので、そのように記述しています。

実行

実際にVRChatで試してみると以下のように動作していました。

f:id:hatuxes:20200919231736g:plain

あとがき

今回は事前にURLを指定しておいてプレイリストのように動作させるVideoPlayerを紹介しました。
前回と今回の内容、それとUnityVideo版も含むサンプルはまとめてGithubに置いてあります。
ライセンスを確認したうえで、お使いください。
github.com

ちなみに、他人にもしっかりと同期して動作するVideoPlayerはU#の制作者でもあるMerlinさんが制作されています。
プレイリストにも対応しているので、素直にこれを使うのがいいかもしれないですね。
github.com