#はじめに
こんにちは、UTVirtualアドベントカレンダー9日目の避雷です。
ECS完全に理解した、に行ってきたのでUTVirtualのアドベントカレンダーにかこつけて公開してやろうという気持ちです。オブジェクトがたくさん動くと無条件でエモいのでズルい
完成品はこちら
https://github.com/Hirai0827/ECSAudioVisualizer
#ECSとは何か?
一言でいうと「似たような挙動で動く沢山のオブジェクトの処理を爆速にする仕組み」です。
詳しく言うとメモリの最適化で、取っ散らかったメモリをしっかり整理することで素早い処理を可能にする技術です。
あんまりよくわかってないのでここら辺読んでください。
http://tsubakit1.hateblo.jp/entry/2018/03/25/180203
http://tsubakit1.hateblo.jp/entry/2018/04/02/233106
https://qiita.com/mao_/items/4951c4bf0c82de98b5d6
https://qiita.com/orange_lily1127/items/77310e31e64485280377
個人的な解釈としてはこんな感じです。
まず個々のオブジェクトはECSではEntityと呼ばれ、それはArchetypeという型を使って生成されます。
生成されたEntity達はWorldというベルトコンベアに乗ってManager/ComponentSystemという処理機械に通されます。適切な突起(ComponentSystem)を持ったEntityだけがManagerによって処理されます。
生物学をちょっと齧ったことのある人は酵素と基質の関係を想像するとわかりやすいかもしれません(ECSは1対1対応ではないのでその点は違いますが)。
#ECSを動かしてみよう
こんなコードを書いてMainCameraに貼り付けてみましょう。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Unity.Entities;
using Unity.Rendering;
using Unity.Transforms;
using Unity.Mathematics;
using Unity.Collections;
[RequireComponent(typeof(Camera))]
public sealed class MyCubeSystem : ComponentSystem
{
protected override void OnUpdate()
{
var chunks = EntityManager.CreateArchetypeChunkArray(query, Allocator.TempJob);
var positionType = GetArchetypeChunkComponentType<Position>(false);
var materialType = GetArchetypeChunkSharedComponentType<MeshInstanceRenderer>();
var time = Time.realtimeSinceStartup;
for (int chunkIndex = 0, length = chunks.Length; chunkIndex < length; chunkIndex++)
{
var chunk = chunks[chunkIndex];
var positions = chunk.GetNativeArray(positionType);
for (int i = 0, chunkCount = chunk.Count; i < chunkCount; i++)
{
var position = positions[i];
position.Value.y = math.sin(time + 0.2f * (position.Value.x)) * math.sin(time + 0.2f * (position.Value.z));
positions[i] = position;
}
}
chunks.Dispose();
}
private readonly EntityArchetypeQuery query = new EntityArchetypeQuery
{
Any = System.Array.Empty<ComponentType>(),
None = System.Array.Empty<ComponentType>(),
All = new ComponentType[] { ComponentType.Create<Position>(), ComponentType.Create<MeshInstanceRenderer>() }
};
}
public class HelloECS : MonoBehaviour
{
public Material[] materials;
// Start is called before the first frame update
void Start()
{
InitializeWorld();
CreateCubeForECS();
}
// Update is called once per frame
private void OnDisable()
{
ScriptBehaviourUpdateOrder.UpdatePlayerLoop(null);
_world?.Dispose();
}
private void InitializeWorld ()
{
_world = World.Active = new World("MyWorld");
_world.CreateManager(typeof(EntityManager));
_world.CreateManager(typeof(EndFrameTransformSystem));
_world.CreateManager(typeof(EndFrameBarrier));
_world.CreateManager<MeshInstanceRendererSystem>().ActiveCamera = GetComponent<Camera>();
_world.CreateManager(typeof(RenderingSystemBootstrap));
_world.CreateManager(typeof(MyCubeSystem));
ScriptBehaviourUpdateOrder.UpdatePlayerLoop(_world);
}
private World _world;
void CreateCube()
{
var cube = GameObject.CreatePrimitive(PrimitiveType.Cube);
cube.transform.position = Vector3.zero;
cube.transform.rotation = Quaternion.identity;
cube.transform.localScale = Vector3.one;
}
void CreateCubeForECS ()
{
var cube = GameObject.CreatePrimitive(PrimitiveType.Cube);
cube.transform.position = Vector3.zero;
cube.transform.rotation = Quaternion.identity;
cube.transform.localScale = Vector3.one;
var manager = _world?.GetExistingManager<EntityManager>();
if(manager != null)
{
var archetype = manager.CreateArchetype(ComponentType.Create<Position>(),
ComponentType.Create<MeshInstanceRenderer>());
for(int x = 0; x < 100; x++)
{
for(int z = 0; z < 100; z++)
{
var entity = manager.CreateEntity(archetype);
manager.SetComponentData(entity, new Position() { Value = new float3(x,0,z) });
manager.SetSharedComponentData(entity, new MeshInstanceRenderer()
{
mesh = cube.GetComponent<MeshFilter>().sharedMesh,
material = materials[(x + z) % materials.Length],
subMesh = 0,
castShadows = UnityEngine.Rendering.ShadowCastingMode.Off,
receiveShadows = false
});
}
}
}
Destroy(cube);
}
}
実行してみると次みたいな画面が出てくると思います。
†ECS†で雑にエモを生産しろ pic.twitter.com/w6USj4dKpG
— 避雷 (@lucknknock) 2018年11月14日
とりあえずECS完全に理解した。次はこれを使って何かを作ってみます。
#ECS&オーディオビジュアライザー(すごくいっぱい動くのでエモい)を作ろう
ECSで出したオブジェクトをグルーピングして音程ごとに割り当てれば簡単にオーディオビジュアルと化してエモが発生しそう(安直)
##とりあえずフーリエ変換して各周波数の成分を割り出してみます。
フーリエ変換をしてくれる関数はUnityにあります。おっぱげた…
https://docs.unity3d.com/ScriptReference/AudioSource.GetSpectrumData.html
AudioSource targetAudio = GetComponent<AudioSource>();
float[] spectrum = new spectrum[1024];
targetAudio.GetSpectrumData(spectrum, 0, FFTWindow.BlackmanHarris);
//これでspectrumに各成分が代入される。必要な細かさで分割して平均でも取るといいと思う。Log10して正定数を加えて二乗するとちょうどいいぐらいの変化度合いになる
っていうことはコレで値を求めてECSのscaleにぶち込めば完了です。
##早速実装しよう
実装コードはこんな感じです。(最初のをちょっと弄っただけ)
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Unity.Entities;
using Unity.Rendering;
using Unity.Transforms;
using Unity.Mathematics;
using Unity.Collections;
[RequireComponent(typeof(Camera))]
public sealed class MyAudioCubeSystem : ComponentSystem
{
protected override void OnUpdate()
{
var chunks = EntityManager.CreateArchetypeChunkArray(query, Allocator.TempJob);
var positionType = GetArchetypeChunkComponentType<Position>(false);
var scaleType = GetArchetypeChunkComponentType<Scale>(false);
var materialType = GetArchetypeChunkSharedComponentType<MeshInstanceRenderer>();
var time = Time.realtimeSinceStartup;
for (int chunkIndex = 0, length = chunks.Length; chunkIndex < length; chunkIndex++)
{
var chunk = chunks[chunkIndex];
var positions = chunk.GetNativeArray(positionType);
var scales = chunk.GetNativeArray(scaleType);
for (int i = 0, chunkCount = chunk.Count; i < chunkCount; i++)
{
var position = positions[i];
var scale = scales[i];
// position.Value.y = math.sin(time + 0.2f * (position.Value.x)) * math.sin(time + 0.2f * (position.Value.z));
//position.Value.y = Mathf.Pow(ECSAudioVisualizer.audioData[((int)(position.Value.x / 4) + (int)(position.Value.z / 4)) % 4],2);
scale.Value.y += Mathf.Pow(ECSAudioVisualizer.audioData[((int)(position.Value.x / 1) + (int)(position.Value.z / 1)) % 32], 2);
scale.Value.y /= 2;
scales[i] = scale;
//positions[i] = position;
}
}
chunks.Dispose();
}
private readonly EntityArchetypeQuery query = new EntityArchetypeQuery
{
Any = System.Array.Empty<ComponentType>(),
None = System.Array.Empty<ComponentType>(),
All = new ComponentType[] { ComponentType.Create<Position>(), ComponentType.Create<Scale>(), ComponentType.Create<MeshInstanceRenderer>() }
};
}
public class ECSAudioVisualizer : MonoBehaviour
{
public Material[] materials;
public AudioSource targetAudio;
public static float[] audioData;
public int divideLength;
// Start is called before the first frame update
void Start()
{
InitializeWorld();
CreateCubeForECS();
audioData = new float[1024 / divideLength];
}
private void Update()
{
float[] spectrum = new float[1024];
targetAudio.GetSpectrumData(spectrum, 0, FFTWindow.BlackmanHarris);
for(int i = 0; i < 1024 / divideLength; i++)
{
audioData[i] = 0;
for(int j = i * divideLength; j < (i + 1) * divideLength; j++)
{
audioData[i] += spectrum[j];
}
audioData[i] /= divideLength;
audioData[i] = Mathf.Log10(audioData[i]) + 7;
Debug.Log(i + ":" + audioData[i]);
}
}
// Update is called once per frame
private void OnDisable()
{
ScriptBehaviourUpdateOrder.UpdatePlayerLoop(null);
_world?.Dispose();
}
private void InitializeWorld()
{
_world = World.Active = new World("MyWorld");
_world.CreateManager(typeof(EntityManager));
_world.CreateManager(typeof(EndFrameTransformSystem));
_world.CreateManager(typeof(EndFrameBarrier));
_world.CreateManager<MeshInstanceRendererSystem>().ActiveCamera = GetComponent<Camera>();
_world.CreateManager(typeof(RenderingSystemBootstrap));
_world.CreateManager(typeof(MyAudioCubeSystem));
ScriptBehaviourUpdateOrder.UpdatePlayerLoop(_world);
}
private World _world;
void CreateCube()
{
var cube = GameObject.CreatePrimitive(PrimitiveType.Cube);
cube.transform.position = Vector3.zero;
cube.transform.rotation = Quaternion.identity;
cube.transform.localScale = Vector3.one;
}
void CreateCubeForECS()
{
var cube = GameObject.CreatePrimitive(PrimitiveType.Cylinder);
cube.transform.position = Vector3.zero;
cube.transform.rotation = Quaternion.identity;
cube.transform.localScale = Vector3.one;
var manager = _world?.GetExistingManager<EntityManager>();
if (manager != null)
{
var archetype = manager.CreateArchetype(ComponentType.Create<Position>(),ComponentType.Create<Scale>(),
ComponentType.Create<MeshInstanceRenderer>());
for (int x = 0; x < 50; x++)
{
for (int z = 0; z < 50; z++)
{
var entity = manager.CreateEntity(archetype);
manager.SetComponentData(entity, new Position() { Value = new float3(x, 0, z) });
manager.SetComponentData(entity, new Scale() { Value = new float3(0.9f, 1, 0.9f) });
manager.SetSharedComponentData(entity, new MeshInstanceRenderer()
{
mesh = cube.GetComponent<MeshFilter>().sharedMesh,
material = materials[(x + z) % materials.Length],
subMesh = 0,
castShadows = UnityEngine.Rendering.ShadowCastingMode.Off,
receiveShadows = false
});
}
}
}
Destroy(cube);
}
}
インスペクタはこんな感じ
ハードコーディング部分とインスペクタ部分が雑に絡まっているのでクソコードですが何をしているかはわかっていただけると思います。
##ちょっとした解説 各部分について素人なりに解説をします ###Worldの定義 ```C# private void InitializeWorld() { _world = World.Active = new World("MyWorld"); _world.CreateManager(typeof(EntityManager)); _world.CreateManager(typeof(EndFrameTransformSystem)); _world.CreateManager(typeof(EndFrameBarrier)); _world.CreateManager().ActiveCamera = GetComponent(); _world.CreateManager(typeof(RenderingSystemBootstrap)); _world.CreateManager(typeof(MyAudioCubeSystem)); ScriptBehaviourUpdateOrder.UpdatePlayerLoop(_world); } ``` 何もしなくてもECSは実行時に自動的にworldが生成されますが自分で臨むManagerだけをつけたworld1を生成することもできます。このInitializeWorld()はStartメソッド内で呼ばれています。 ###一般的なオブジェクト生成 ```C# void CreateCube() { var cube = GameObject.CreatePrimitive(PrimitiveType.Cube); cube.transform.position = Vector3.zero; cube.transform.rotation = Quaternion.identity; cube.transform.localScale = Vector3.one; } ``` 一般的(ECSを介さない)オブジェクト生成はこんな感じですね、今回はプリミティブキューブを生成しています。 ###ECS式オブジェクト生成 ```C# void CreateCubeForECS() { var cube = GameObject.CreatePrimitive(PrimitiveType.Cylinder); cube.transform.position = Vector3.zero; cube.transform.rotation = Quaternion.identity; cube.transform.localScale = Vector3.one; var manager = _world?.GetExistingManager(); if (manager != null) { var archetype = manager.CreateArchetype(ComponentType.Create(),ComponentType.Create(), ComponentType.Create()); for (int x = 0; x < 50; x++) { for (int z = 0; z < 50; z++) { var entity = manager.CreateEntity(archetype); manager.SetComponentData(entity, new Position() { Value = new float3(x, 0, z) }); manager.SetComponentData(entity, new Scale() { Value = new float3(0.9f, 1, 0.9f) }); manager.SetSharedComponentData(entity, new MeshInstanceRenderer() { mesh = cube.GetComponent().sharedMesh, material = materials[(x + z) % materials.Length], subMesh = 0, castShadows = UnityEngine.Rendering.ShadowCastingMode.Off, receiveShadows = false }); } }ECSを使った謎のオーディオビジュアライザが出来てしまった。
— 避雷 (@lucknknock) 2018年11月18日
2500個のオブジェクトを同時に動かしても60fps以上出るのでなかなかエモいといった感じ。 pic.twitter.com/WXF9hoGVpM
}
Destroy(cube);
}
こちらがECS版のオブジェクト生成です。手順としては
* レンダリングに使う用のプリミティブキューブを生成(ここはECSじゃない)
* position, scale, renderの要素を持ったArchetypeを生成
* for文を回して座標やマテリアルをずらしつつArchetypeに基づいたEntityを生成
* 最初に作った非ECSのプリミティブキューブを削除
って感じになっています。参考にした記事によるとfor文の部分をUnsafeなポインタ使用をした実装と取り換えるとさらに爆速になるそうですが僕は「安全な男」なのでUnsafeに屈したりはしません(女騎士)
~~可愛い後輩の女の子に「先輩はヘタレで安全なので」とか言われて隙だらけの姿を見せられたりしたくないですか?~~
###音声解析部分
```C#
float[] spectrum = new float[1024];
targetAudio.GetSpectrumData(spectrum, 0, FFTWindow.BlackmanHarris);
for(int i = 0; i < 1024 / divideLength; i++)
{
audioData[i] = 0;
for(int j = i * divideLength; j < (i + 1) * divideLength; j++)
{
audioData[i] += spectrum[j];
}
audioData[i] /= divideLength;
audioData[i] = Mathf.Log10(audioData[i]) + 7;
Debug.Log(i + ":" + audioData[i]);
}
- スペクトラムデータを取得し
- 区間に分けて加算し
- 区間ごとに平均をとって
- 対数をとって微調整する(この部分はビジュアライザを映えさせる為の処理なのでお好みで調節してください)
- その値をaudioDataに格納(audioDataはpublic変数になっていて、ECS側で取得する)
という作業をしています。
###ビジュアルを反映
public sealed class MyAudioCubeSystem : ComponentSystem
{
protected override void OnUpdate()
{
var chunks = EntityManager.CreateArchetypeChunkArray(query, Allocator.TempJob);
var positionType = GetArchetypeChunkComponentType<Position>(false);
var scaleType = GetArchetypeChunkComponentType<Scale>(false);
var materialType = GetArchetypeChunkSharedComponentType<MeshInstanceRenderer>();
var time = Time.realtimeSinceStartup;
for (int chunkIndex = 0, length = chunks.Length; chunkIndex < length; chunkIndex++)
{
var chunk = chunks[chunkIndex];
var positions = chunk.GetNativeArray(positionType);
var scales = chunk.GetNativeArray(scaleType);
for (int i = 0, chunkCount = chunk.Count; i < chunkCount; i++)
{
var position = positions[i];
var scale = scales[i];
// position.Value.y = math.sin(time + 0.2f * (position.Value.x)) * math.sin(time + 0.2f * (position.Value.z));
//position.Value.y = Mathf.Pow(ECSAudioVisualizer.audioData[((int)(position.Value.x / 4) + (int)(position.Value.z / 4)) % 4],2);
scale.Value.y += Mathf.Pow(ECSAudioVisualizer.audioData[((int)(position.Value.x / 1) + (int)(position.Value.z / 1)) % 32], 2);
scale.Value.y /= 2;
scales[i] = scale;
//positions[i] = position;
}
}
chunks.Dispose();
}
private readonly EntityArchetypeQuery query = new EntityArchetypeQuery
{
Any = System.Array.Empty<ComponentType>(),
None = System.Array.Empty<ComponentType>(),
All = new ComponentType[] { ComponentType.Create<Position>(), ComponentType.Create<Scale>(), ComponentType.Create<MeshInstanceRenderer>() }
};
}
ECSの流儀に乗っ取ってscaleを変更しています。
動作を滑らかに見せるために前フレームとaudiodataの値を平均しています。
- ECSentityの配列を取得
- 座標に応じてaudiodataの値と連動させてscaleを変更
みたいなことをしています、正直よくわかってないのでわかりやすい解説やビジュアルスクリプティングが待たれる
#最後に
Q:シェーダーでよくない?
A:うるせ~~~~~~
しらね~~~~~~~
Entity Component Syst
em