始めに
前回はVAT(Vertex Animation Texture)についてまとめました。
今回はVATとGraphic.DrawMeshInstancedIndirectを組み合わせるサンプルを作成してみました。
使用したプロジェクト・アセット
VATの記事でも使用させて頂いたAnimation Texture Bakerを今回も使っています。
モデルとアニメーションはこちらのアセットから使用しました。
VAT・マテリアルの作成
始めにアニメーションをさせるマテリアルの作成を行います。
こちらの内容は前回の内容を元に作成しているので、興味があれば前回の内容を拝見してください。
実装
C#プログラム
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;
using System.Runtime.InteropServices;
public class GPUInstancingZombies : MonoBehaviour
{
[SerializeField]
private ComputeShader m_computeShader;
[Header("DrawMeshInstancedDirectのパラメータ")]
[Space(20)]
[SerializeField]
private Mesh m_mesh;
[SerializeField]
private Material m_material;
[SerializeField]
private Bounds m_bounds;
[SerializeField]
private ShadowCastingMode m_shadowCastingMode;
[SerializeField]
private bool m_receiveShadows;
[Header("生成する数")]
[Space(20)]
[SerializeField]
private int m_instanceCount;
[SerializeField]
private float m_offsetPositionY;
[Header("モデルを歩かせる速さ")]
[Space(20)]
[SerializeField]
private float m_speed;
private ComputeBuffer m_argsBuffer;
private ComputeBuffer m_zombieDataBuffer;
private int m_moveKernel;
private Vector3Int m_groupSize;
private int m_deltaTimeId = Shader.PropertyToID("_deltaTime");
private float m_minBoundX;
private float m_maxBoundX;
private float m_minBoundZ;
private float m_maxBoundZ;
struct ZombieData {
public Vector3 position;
public float animationOffset;
}
// Start is called before the first frame update
void Start()
{
CalculateBounds();
InitializeArgsBuffer();
InitializeComputeShader();
}
void Update() {
m_computeShader.SetFloat(m_deltaTimeId, Time.deltaTime);
m_computeShader.Dispatch(m_moveKernel, m_groupSize.x, m_groupSize.y, m_groupSize.z);
}
// Update is called once per frame
void LateUpdate()
{
Graphics.DrawMeshInstancedIndirect(
m_mesh,
0,
m_material,
m_bounds,
m_argsBuffer,
0,
null,
m_shadowCastingMode,
m_receiveShadows
);
}
private void InitializeArgsBuffer() {
uint[] args = new uint[5] { 0, 0, 0, 0, 0};
args[0] = m_mesh.GetIndexCount(0);
args[1] = (uint)m_instanceCount;
m_argsBuffer = new ComputeBuffer(1, 4 * args.Length, ComputeBufferType.IndirectArguments);
m_argsBuffer.SetData(args);
}
private void InitializeComputeShader() {
m_instanceCount = Mathf.ClosestPowerOfTwo(m_instanceCount);
InitializeZombieDataBuffer();
m_moveKernel = m_computeShader.FindKernel("Move");
m_computeShader.GetKernelThreadGroupSizes(m_moveKernel, out uint x, out uint y, out uint z);
m_groupSize = new Vector3Int(m_instanceCount / (int)x, (int)y, (int)z);
m_computeShader.SetFloat("_Speed", m_speed);
m_computeShader.SetFloat("_MinBoundZ", m_minBoundZ);
m_computeShader.SetFloat("_MaxBoundZ", m_maxBoundZ);
m_computeShader.SetBuffer(m_moveKernel, "_ZombieDataBuffer", m_zombieDataBuffer);
}
private void InitializeZombieDataBuffer() {
ZombieData[] zombieData = new ZombieData[m_instanceCount];
for(int i = 0; i < m_instanceCount; ++i) {
zombieData[i].position = new Vector3(
Random.Range(m_minBoundX, m_maxBoundX),
m_offsetPositionY,
Random.Range(m_minBoundZ, m_maxBoundZ)
);
zombieData[i].animationOffset = Random.Range(0, 10.0f);
}
m_zombieDataBuffer = new ComputeBuffer(m_instanceCount, Marshal.SizeOf(typeof(ZombieData)));
m_zombieDataBuffer.SetData(zombieData);
m_material.SetBuffer("_ZombieDataBuffer", m_zombieDataBuffer);
}
private void CalculateBounds() {
m_minBoundX = m_bounds.center.x - m_bounds.size.x / 2.0f;
m_maxBoundX = m_bounds.center.x + m_bounds.size.x / 2.0f;
m_minBoundZ = m_bounds.center.z - m_bounds.size.z / 2.0f;
m_maxBoundZ = m_bounds.center.z + m_bounds.size.z / 2.0f;
}
private void OnDestroy() {
m_argsBuffer?.Release();
m_zombieDataBuffer?.Release();
}
}
ComputeShader
#pragma kernel Move
struct ZombieData {
float3 position;
float animationOffset;
};
RWStructuredBuffer<ZombieData> _ZombieDataBuffer;
float _Speed;
float _deltaTime;
float _MinBoundZ;
float _MaxBoundZ;
[numthreads(8,1,1)]
void Move(uint3 id : SV_DispatchThreadID) {
float z = _ZombieDataBuffer[id.x].position.z;
z += _deltaTime * _Speed;
if(z > _MaxBoundZ)
z = _MinBoundZ + (z - _MaxBoundZ);
_ZombieDataBuffer[id.x].position.z = z;
}
Shader
Shader "Unlit/TextureAnimPlayer"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
_PosTex("position texture", 2D) = "black"{}
_NmlTex("normal texture", 2D) = "white"{}
_DT ("delta time", float) = 0
_Length ("animation length", Float) = 1
[Toggle(ANIM_LOOP)] _Loop("loop", Float) = 0
}
SubShader
{
Tags { "RenderType"="Opaque" }
LOD 100 Cull Off
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma multi_compile ___ ANIM_LOOP
#include "UnityCG.cginc"
#define ts _PosTex_TexelSize
struct appdata
{
float2 uv : TEXCOORD0;
};
struct v2f
{
float2 uv : TEXCOORD0;
float3 normal : TEXCOORD1;
float4 vertex : SV_POSITION;
};
struct ZombieData {
float3 position;
float animationOffset;
};
sampler2D _MainTex, _PosTex, _NmlTex;
float4 _PosTex_TexelSize;
float _Length, _DT;
StructuredBuffer<ZombieData> _ZombieDataBuffer;
v2f vert (appdata v, uint vid : SV_VertexID, uint instanceID : SV_INSTANCEID)
{
float dt = _ZombieDataBuffer[instanceID].animationOffset;
float3 worldPos = _ZombieDataBuffer[instanceID].position;
// 時間 - オフセット
float t = (_Time.y - dt) / _Length;
#if ANIM_LOOP
t = fmod(t, 1.0);
#else
t = saturate(t);
#endif
float x = (vid + 0.5) * ts.x;
float y = t;
float4 pos = tex2Dlod(_PosTex, float4(x, y, 0, 0));
float3 normal = tex2Dlod(_NmlTex, float4(x, y, 0, 0));
v2f o;
// アニメーションしている頂点の座標位置 + ワールド座標
o.vertex = UnityObjectToClipPos(pos + worldPos);
o.normal = UnityObjectToWorldNormal(normal);
o.uv = v.uv;
return o;
}
half4 frag (v2f i) : SV_Target
{
half diff = dot(i.normal, float3(0,1,0))*0.5 + 0.5;
half4 col = tex2D(_MainTex, i.uv);
return diff * col;
}
ENDCG
}
}
}
解説
ComputeShaderやDrawMeshInstancedIndirectの内容については過去記事のまま使用しています。
モデルを歩かせる範囲の指定
DrawMeshInstancedIndirectで使用するBoudsをもとにしてモデルを歩かせる範囲を計算します。
private void CalculateBounds() { m_minBoundX = m_bounds.center.x - m_bounds.size.x / 2.0f; m_maxBoundX = m_bounds.center.x + m_bounds.size.x / 2.0f; m_minBoundZ = m_bounds.center.z - m_bounds.size.z / 2.0f; m_maxBoundZ = m_bounds.center.z + m_bounds.size.z / 2.0f; }
個体の初期座標、アニメーションのオフセットの初期化
C#プログラム内で個体の初期座標やアニメーションのオフセットを初期化します。
初期座標は先ほど計算で求めた範囲をもとに乱数を求めます。
private void InitializeZombieDataBuffer() { ZombieData[] zombieData = new ZombieData[m_instanceCount]; for(int i = 0; i < m_instanceCount; ++i) { zombieData[i].position = new Vector3( Random.Range(m_minBoundX, m_maxBoundX), m_offsetPositionY, Random.Range(m_minBoundZ, m_maxBoundZ) ); zombieData[i].animationOffset = Random.Range(0, 10.0f); } m_zombieDataBuffer = new ComputeBuffer(m_instanceCount, Marshal.SizeOf(typeof(ZombieData))); m_zombieDataBuffer.SetData(zombieData); m_material.SetBuffer("_ZombieDataBuffer", m_zombieDataBuffer); }
モデルの座標計算
ComputeShaderを使用して個体の移動を実装しています。
個体を前方向に移動させて、境界を越えたら一番後ろの境界に戻しています。
[numthreads(8,1,1)] void Move(uint3 id : SV_DispatchThreadID) { float z = _ZombieDataBuffer[id.x].position.z; z += _deltaTime * _Speed; if(z > _MaxBoundZ) z = _MinBoundZ + (z - _MaxBoundZ); _ZombieDataBuffer[id.x].position.z = z; }
Shaderでモデルを移動させる
今回は今までとは異なり、FragmentShaderでの実装となります。
FragmentShaderではSetup関数は無いので、vert関数でセマンティクスでインスタンスIDを取得して個体情報を受け取ります。
アニメーションのオフセットは時間計算を行っている個所に、座標は頂点座標計算を行っている個所に追加しています。
v2f vert (appdata v, uint vid : SV_VertexID, uint instanceID : SV_INSTANCEID) { float dt = _ZombieDataBuffer[instanceID].animationOffset; float3 worldPos = _ZombieDataBuffer[instanceID].position; // 時間 - オフセット float t = (_Time.y - dt) / _Length; #if ANIM_LOOP t = fmod(t, 1.0); #else t = saturate(t); #endif float x = (vid + 0.5) * ts.x; float y = t; float4 pos = tex2Dlod(_PosTex, float4(x, y, 0, 0)); float3 normal = tex2Dlod(_NmlTex, float4(x, y, 0, 0)); v2f o; // アニメーションしている頂点の座標位置 + ワールド座標 o.vertex = UnityObjectToClipPos(pos + worldPos); o.normal = UnityObjectToWorldNormal(normal); o.uv = v.uv; return o; }
結果
ゾンビの大行進を作ることが出来ました。
約6万体を表示してだいたい160fpsでした。
まとめ
VAT、ComputeShader、DrawMeshInstancedIndirectを組み合わせることが可能とわかりました。
今回は簡単なサンプルとして作成しましたが、次はもうちょっとちゃんとしたものを作りたいなと思います。
参考
今回もUnity Graphics Programming vol.3を参考にさせて頂きました。