はじめに
この記事はブロック崩しをUnity DOTSで作る方法を解説したものです。
また、この記事よりも初歩的な内容に関しては
Unity DOTSとUnity Physicsでただ単純に球を動かしてみる
に書かせて頂きました。
少しでも参考になれば幸いです。
注意点
- 本記事ではpreview packageを多く使用しています。今の実装と本記事の実装が大幅に異なる可能性があるのでご注意下さい。
- 本記事ではGameObject/Component を使用したHybrid ECSを前提としています。
- Unity DOTS(Unity ECS, C# Job System, Burst Compiler) やUnity Physics に関する詳しい説明は行わないのでご注意下さい。
環境
- macOS Catalina 10.15.1
- Unity 2019.3.0f1
- Entities preview 0.3.0
- Burst preview 1.2.0
- Jobs preview 0.2.1
- Mathematics 1.1.0
- Hybrid Renderer preview 0.3.0
- Unity Physics preview 0.2.5
実装
Wallを作る
Hierarchy > Create Empty で空のオブジェクトを作り、Wallと名前を付けます。
Positionは (0, 0, 0)としておきます。
Wallの子オブジェクトとしてCubeの3D Objectを4つ作成し、
Positionをそれぞれを(-3, 0, 0), (3, 0, 0), (0, 0, 4.75), (0, 0, -4.75)に、
Scaleをそれぞれ(0.5, 0.5 , 10), (0.5, 0.5, 10), (5.5, 0.5, 0.5), (5.5, 0.5, 0.5)に設定します。
ついでにMainCameraも Positionを(0, 10, 0), Rotationを(90, 0, 0)に設定しておきます。
全てのCubeに付いているBox Colliderをそれぞれ削除し、Physics ShapeをそれぞれのCubeオブジェクトにAdd Componentします。
そして最後にWallオブジェクトにConvertToEntityをAdd Componentします。
Paddleを作る
Cubeの3D Objectを作成し、Positionを(0, 0, -4)、Scaleを(1.5, 0.5, 0.5)に設定し、名前を「Paddle」に変更します。
そしてBox Colliderを削除し、Physics ShapeとPhysics BodyとConvertToEntityをAdd Componentします。
そして、Physics BodyのGravity Factorを0に設定します。
Paddle コンポーネント
Paddleのタグコンポーネントを作ります。
using Unity.Entities;
using System;
[Serializable]
public struct Paddle : IComponentData
{
}
Force コンポーネント
Paddleに対して左右に力をかけることによってPaddleを移動させようと思います。
そのPaddleにかける力の「方向」と「強さ」をそれぞれ管理するために次のスクリプトを作成します。
using System;
using Unity.Entities;
[Serializable]
public struct Force : IComponentData
{
public float magnitude;
public float direction;
}
PaddleAuthoring
次のスクリプトを作成し、Paddleオブジェクトにアタッチします。
using UnityEngine;
using Unity.Entities;
[RequiresEntityConversion]
public class PaddleAuthoring : MonoBehaviour, IConvertGameObjectToEntity
{
public void Convert(Entity entity, EntityManager dstManager, GameObjectConversionSystem conversionSystem)
{
dstManager.AddComponentData(entity, new Paddle());
dstManager.AddComponentData(entity, new Force{magnitude = 1});
}
}
Paddleを操作する
MovePaddleSystem
ここでPaddleに力を与えて動かす処理を担当するSystemを作成します。
$ m $ : Paddleの質量
$ \textbf{v} $ : Paddleの速度
$ \textbf{F} $ : Paddleが受ける外力
$ t $ : 時刻
とするとニュートンの運動方程式より
\textbf{v}(t + dt) = \textbf{v}(t) + \frac{1}{m}\textbf{F}dt
が成立します。
これを基にMovePaddleSystemを実装します。
(関連 : Unity DOTSとUnity Physicsでただ単純に球を動かしてみる - Qiita)
using Unity.Entities;
using Unity.Jobs;
using Unity.Mathematics;
using Unity.Physics;
using Unity.Transforms;
public class MovePaddleSystem : JobComponentSystem
{
protected override JobHandle OnUpdate(JobHandle inputDeps)
{
var dt = Time.DeltaTime;
var jobHandle = Entities
.WithBurst()
.WithAll<Paddle>()
.ForEach((ref Force force, ref PhysicsVelocity physicsVelocity, ref Translation position, ref Rotation rotation, in PhysicsMass physicsMass) =>
{
physicsVelocity.Linear += physicsMass.InverseMass * force.direction * force.magnitude * dt;
})
.Schedule(inputDeps);
return jobHandle;
}
}
解説
\textbf{v}(t + dt) = \textbf{v}(t) + \frac{1}{m}\textbf{F}dt
の式に相当するのが
physicsVelocity.Linear += physicsMass.InverseMass * force.direction * force.magnitude * dt;
です。
$\frac{1}{m}$ はphysicsMass.InverseMass
で取得できます。
dt はあらかじめ
var dt = Time.DeltaTime;
のようにTime.DeltaTime
で取得してキャッシュしています。
これをもしキャッシュせずに、ForEach関数内でいきなり使い、
// エラーになる
physicsVelocity.Linear += physicsMass.InverseMass * force.direction * force.magnitude * dt;
のように書いてしまうと、
error DC0002: Entities.ForEach Lambda expression invokes 'get_Time' on a ComponentSystemBase which is a reference type. This is only allowed with .WithoutBurst() and .Run().
というエラーが出てしまうので注意です。
さて、ここで一度実行して挙動を確認してみます。
あれ?なんか飛んでいってしまいましたね...
これは恐らくPaddleとWallが衝突した時に、Paddleに余計な力が加わって位置や姿勢が微妙にズレていってしまうせいです。
なのでMovePaddleSystemを次のように修正します。
MovePaddleSystem(修正版)
using Unity.Entities;
using Unity.Jobs;
using Unity.Mathematics;
using Unity.Physics;
using Unity.Transforms;
public class MovePaddleSystem : JobComponentSystem
{
protected override JobHandle OnUpdate(JobHandle inputDeps)
{
var dt = Time.DeltaTime;
var jobHandle = Entities
.WithBurst()
.WithAll<Paddle>()
.ForEach((ref Force force, ref PhysicsVelocity physicsVelocity, ref Translation position, ref Rotation rotation, in PhysicsMass physicsMass) =>
{
physicsVelocity.Linear += physicsMass.InverseMass * force.direction * force.magnitude * dt;
position.Value.y = 0;
position.Value.z = -4;
rotation.Value = quaternion.identity;
})
.Schedule(inputDeps);
return jobHandle;
}
}
新たに
position.Value.y = 0;
position.Value.z = -4;
rotation.Value = quaternion.identity;
を追加しました。これによりPaddleの位置と姿勢が改善されました。
(しかし、EntityDebuggerでよく調べてみると、RotationもTranslationも何故か若干ズレてしまっています。申し訳ありませんが理由は分かりません。原因究明中です。ご存知の方がいらっしゃったら教えていただけると嬉しいです。)
Ballを作る
Sphereの3D Objectを作成し、名前を「Ball」に変更します。
Positionを(0, 0, 0)、Scaleを(0.5, 0.5, 0.5)に設定します。
Sphere Colliderを削除し、Physics ShapeとPhysics BodyとConvertToEntityをAdd Componentします。
Physics ShapeのShape TypeをSphereに変更します。
Frictionを0, Minimumに変更します。
Restitutionを1, Maximumに変更します。
そしてPhysics BodyのPhysics Massを0.001に、Linear Dumpingを0に、Angular Dumpingを0に、Gravity Factorを0に、**Initial Linear Velocityを(0, 0, -3)**に設定します。
(ここで本当はPhysicsMassを0にしたいのですが、最小値が0.001なので0.001に設定しています。)
一度実行してみます。
なんだか上手くいっているような感じがします。
しかし、このBall、Paddleとの衝突の度に少しずつ減速しており、最終的に止まってしまいます。
また、Paddleの端をBallにぶつけると一気に加速してしまいます。
なので、Ballの速さを一定に保つ処理を実装することにします。
Ballの速さを一定にする
Ball コンポーネント
Ballのタグコンポーネントを作ります。
using System;
using Unity.Entities;
[Serializable]
public struct Ball : IComponentData
{
}
BallSpeed コンポーネント
Ballの速さを管理します。
using System;
using Unity.Entities;
[Serializable]
public struct BallSpeed : IComponentData
{
public float value;
}
BallAuthoring
次のようなスクリプトを作成し、Ballオブジェクトにアタッチします。
using System;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Physics.Authoring;
using UnityEngine;
[RequiresEntityConversion]
public class BallAuthoring : MonoBehaviour, IConvertGameObjectToEntity
{
private float initialSpeed;
private void Awake()
{
initialSpeed = math.length(this.GetComponent<PhysicsBodyAuthoring>().InitialLinearVelocity);
}
public void Convert(Entity entity, EntityManager dstManager, GameObjectConversionSystem conversionSystem)
{
dstManager.AddComponentData(entity, new Ball());
dstManager.AddComponentData(entity, new BallSpeed{value = initialSpeed});
}
}
解説
initialSpeed = math.length(GetComponent<PhysicsBodyAuthoring>().InitialLinearVelocity);
ここではまずGetComponent<PhysicsBodyAuthoring>().InitialLinearVelocity
でPhysicsBodyのInitialLinearVelocityに設定したベクトル(今の場合は(0, 0, -3))を取得し、そのベクトルの絶対値をmath.length()
で取得してinitialSpeed
に代入しています。
SustainBallSpeedSystem
Ballの速さを維持する処理を担当します。
using Unity.Entities;
using Unity.Jobs;
using Unity.Mathematics;
using Unity.Physics;
public class SustainBallSpeedSystem : JobComponentSystem
{
protected override JobHandle OnUpdate(JobHandle inputDeps)
{
var jobHandle = Entities
.WithBurst()
.WithAll<Ball>()
.ForEach((ref PhysicsVelocity physicsVelocity, in BallSpeed ballSpeed) =>
{
physicsVelocity.Linear = ballSpeed.value * math.normalize(physicsVelocity.Linear);
})
.Schedule(inputDeps);
return jobHandle;
}
}
Blockを作る
Cubeの3D Objectを作成し、名前を「Block」に変更します。
Positionを(0, 0, 3.5), Scaleを(1, 0.5, 0.5)くらいに設定します。
Box Colliderを削除し、Physics Shape, Physics Body, ConvertToEntityをAdd Componentします。
そしてPhysics BodyのMotion TypeをStaticに変更し、
Physics Shapeの Advanced > Raises Collision Events にチェックを入れます。
BallとBlockの接触を判定し、Blockを削除する
Block コンポーネント
Blockを識別するためのタグコンポーネントです。
using System;
using Unity.Entities;
[Serializable]
public struct Block : IComponentData
{
}
BlockAuthoring
次のスクリプトを作成し、Blockオブジェクトにアタッチします。
using Unity.Entities;
using UnityEngine;
[RequiresEntityConversion]
public class BlockAuthoring : MonoBehaviour, IConvertGameObjectToEntity
{
public void Convert(Entity entity, EntityManager dstManager, GameObjectConversionSystem conversionSystem)
{
dstManager.AddComponentData(entity, new Block());
}
}
DestroyBlockSystem
BallとBlockが衝突した時、そのBlockを削除する、という処理を実装します。
using Unity.Burst;
using Unity.Entities;
using Unity.Jobs;
using Unity.Physics;
using Unity.Physics.Systems;
using Unity.Collections;
[UpdateAfter(typeof(EndFramePhysicsSystem))]
public class DestroyBlockSystem : JobComponentSystem
{
private BuildPhysicsWorld _buildPhysicsWorldSystem;
private StepPhysicsWorld _stepPhysicsWorldSystem;
private EntityCommandBufferSystem _bufferSystem;
protected override void OnCreate()
{
_buildPhysicsWorldSystem = World.GetOrCreateSystem<BuildPhysicsWorld>();
_stepPhysicsWorldSystem = World.GetOrCreateSystem<StepPhysicsWorld>();
_bufferSystem = World.GetOrCreateSystem<EndSimulationEntityCommandBufferSystem>();
}
[BurstCompile]
private struct DestroyBlockJob : ICollisionEventsJob
{
[ReadOnly] public ComponentDataFromEntity<Block> Block;
[ReadOnly] public ComponentDataFromEntity<Ball> Ball;
public EntityCommandBuffer CommandBuffer;
public void Execute(CollisionEvent collisionEvent)
{
var entityA = collisionEvent.Entities.EntityA;
var entityB = collisionEvent.Entities.EntityB;
var isEntityABlock = Block.Exists(entityA);
var isEntityBBlock = Block.Exists(entityB);
var isEntityABall = Ball.Exists(entityA);
var isEntityBBall = Ball.Exists(entityB);
if (!isEntityABlock && !isEntityBBlock)
return;
if (!isEntityABall && !isEntityBBall)
return;
var blockEntity = isEntityABlock ? entityA : entityB;
CommandBuffer.DestroyEntity(blockEntity);
}
}
protected override JobHandle OnUpdate(JobHandle inputDeps)
{
var jobHandle = new DestroyBlockJob
{
Block = GetComponentDataFromEntity<Block>(true),
Ball = GetComponentDataFromEntity<Ball>(true),
CommandBuffer = _bufferSystem.CreateCommandBuffer(),
}.Schedule(_stepPhysicsWorldSystem.Simulation, ref _buildPhysicsWorldSystem.PhysicsWorld, inputDeps);
_bufferSystem.AddJobHandleForProducer(jobHandle);
return jobHandle;
}
}
解説
衝突時に何らかの処理を行う時は、ICollisionEventsJob
を実装した構造体を定義します。すると、Execute()
関数の引数に「どの2つのEntityが衝突しているか」という情報が格納されるので、その情報を使って関数内に具体的な処理を書きます。
衝突した2つのEntityは、
var entityA = collisionEvent.Entities.EntityA;
var entityB = collisionEvent.Entities.EntityB;
のようにそれぞれ取得します。
しかし今、entityA
とentityB
は必ずしもBlockやBallとは限りません。
なので、
var isEntityABlock = Block.Exists(entityA);
var isEntityBBlock = Block.Exists(entityB);
var isEntityABall = Ball.Exists(entityA);
var isEntityBBall = Ball.Exists(entityB);
のように、それぞれのEntityがBlockやBallなのか、という真偽値を取得します。
そして
if (!isEntityABlock && !isEntityBBlock)
return;
if (!isEntityABall && !isEntityBBall)
return;
により、衝突しているEntityがBlockでもBallでもない場合はreturnします。
そして最後に
var blockEntity = isEntityABlock ? entityA : entityB;
CommandBuffer.DestroyEntity(blockEntity);
で「Blockを削除しなさい」という命令をMain Threadへ送っています。
Entityを削除する処理はMain Thread上でしか行えないため、「Blockを削除する」というタスクの発行のみをWorker Threadで行い、後で実際にMain Threadで削除する、ということをEntityCommandBufferSystemを使って行っています。
詳しくは次のサイトをご参照下さい。
【Unity】C# Job SystemからECSのEntityやComponentDataを追加・削除・変更する指示を出す - テラシュールブログ
また、EntityCommandBufferSystemを使う場合は、OnUpdate()内に
_bufferSystem.AddJobHandleForProducer(jobHandle);
のように書く必要があります。
ここではJobが完了する前にCommandBuffer中の命令が実行されてしまうのを防ぐために依存関係を設定する役割を担っています。
(参考 : Class EntityCommandBufferSystem | Package Manager UI website)
完成
Blockをしれっと複製して適当に並べて完成です。
参考文献
- 【Unity】 ECS まとめ(前編バー
- 【Unity】 ECS まとめ(後編) - エフアンダーバー
- 【Unity】 ECSへ 思考の移行ガイド - エフアンダーバー
- 【Unity】Unity 2018のEntity Component System(通称ECS)について(1) - テラシュールブログ
- たのしいDOTS〜初級から上級まで〜 - Unite Tokyo 2019
- 大量のオブジェクトを含む広いステージでも大丈夫、そうDOTSならね - Unite Tokyo 2019
- 【Unity】Scene上に構築したステージを、Entity群に変換してECSで利用可能にする「SubScene」 - テラシュールブログ
- 【Unity 入門】【チュートリアル】ブロック崩しを作る 1. 壁、パドル、ボールの作成 - コガネブログ
- 【Unity】C# Job SystemからECSのEntityやComponentDataを追加・削除・変更する指示を出す - テラシュールブログ
# 関連