したかみ ぶろぐ

Unity成分多め

アニメーションするオブジェクトを大量に表示する(VAT + GPUInstancing)

f:id:vxd-naoshi-19961205-maro:20200921143351p:plain

始めに

前回はVAT(Vertex Animation Texture)についてまとめました。

今回はVATとGraphic.DrawMeshInstancedIndirectを組み合わせるサンプルを作成してみました。


過去記事



使用したプロジェクト・アセット

VATの記事でも使用させて頂いたAnimation Texture Bakerを今回も使っています。

github.com


モデルとアニメーションはこちらのアセットから使用しました。

assetstore.unity.com



VAT・マテリアルの作成

始めにアニメーションをさせるマテリアルの作成を行います。

こちらの内容は前回の内容を元に作成しているので、興味があれば前回の内容を拝見してください。

f:id:vxd-naoshi-19961205-maro:20200921135036g:plain



実装

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の内容はAnimation Texture Bakerの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;
            }



結果

ゾンビの大行進を作ることが出来ました。

f:id:vxd-naoshi-19961205-maro:20200921143740g:plain


約6万体を表示してだいたい160fpsでした。

f:id:vxd-naoshi-19961205-maro:20200921143041p:plain



まとめ

VAT、ComputeShader、DrawMeshInstancedIndirectを組み合わせることが可能とわかりました。

今回は簡単なサンプルとして作成しましたが、次はもうちょっとちゃんとしたものを作りたいなと思います。



参考

今回もUnity Graphics Programming vol.3を参考にさせて頂きました。

github.com