のりまき日記

Unityなどの活用リファレンスブログ。「こうしたい時どうする」をまとめたい

unity)映画「ピクセル」みたいにボクセルを大量描画してハデに破壊したい❶ ~cubeの大量描画編~

めざすもの

はじめに

meshをvoxel化して大量描画して当たり判定もつけてハデに破壊してみました。いくつかの記事に分けてまとめてみます。(最適化はできていないので、コンセプト的な実装として読んでください。)

できたもの

10,000キューブ描画する

Graphics.DrawMeshInstancedIndirectを使って描画してみます。DrawMeshInstancedProceduralがCPUでインスタンス数などを指定するのに対して、特別なComputeBufferを用いてインスタンス数などを指定することができるようです。なにが嬉しいのかはイマイチわからなかったのですが、複雑なことをしたい時にGPUだけで完結できるのかな?と思っています。

public class RenderMassiveCube : MonoBehaviour
{
    [SerializeField]
    int m_CubeCount = 10000;

    [SerializeField]
    Mesh m_CubeMesh;

    [SerializeField]
    Material m_CubeMaterial;

    ComputeBuffer m_ArgumentsBuffer;

    MaterialPropertyBlock m_MaterialPropertyBlock;

    private void Awake()
    {
        m_MaterialPropertyBlock = new MaterialPropertyBlock();
    }

    private void OnEnable()
    {
        // 描画のための情報を準備
        var args = new int[4] { m_CubeMesh.GetIndices(0).Length, m_CubeCount, 0, 0 };
        m_ArgumentsBuffer = new ComputeBuffer(1, 16, ComputeBufferType.IndirectArguments);
        m_ArgumentsBuffer.SetData(args);
    }

    private void OnDisable()
    {
        // おわったら解放してあげる
        m_ArgumentsBuffer.Release();
    }

    private void OnRenderObject()
    {
        // 直接描画
        Graphics.DrawMeshInstancedIndirect(m_CubeMesh, 0, m_CubeMaterial, new Bounds(Vector3.zero, Vector3.one * 1000), m_ArgumentsBuffer, 0, m_MaterialPropertyBlock);
    }
}

10,000キューブ描画できた!

memo

Graphics.Draw***に関しては以下の記事が参考になりました。

logmi.jp

キューブを動かす

今回はComputeShaderComputeBufferを使います。ComputeShaderでComputeBufferに位置などを格納して、各インスタンスの描画時にComputeBufferから情報を取り出して位置などに変換します。いつもはCPUでやっている計算をゴソッとGPU側に移植する感じです。

マウスの位置からキューブが溢れ出ている様子

ComputeShaderはとても難解なので説明を省きますが、以下のページが参考になりました。

blog.jp.uwa4d.com

スクリプト

長いですがやっていることは以下になります。

  • Particle用のComputeBufferを作成して、計算用shaderと描画用shaderの間を引回す
  • よいタイミングで計算用shaderに計算させる
  • よいタイミングで描画用shaderに描画させる
public class RenderMassiveCube : MonoBehaviour
{
    struct Particle
    {
        // gameObject相当
        public bool active;

        // tranform相当
        public Vector3 position;
        public Vector3 rotation;
        public Vector3 scale;

        // rigidbody相当
        public Vector3 velocity;
        public Vector3 angularVelocity;

        // particle相当
        float lifeTime;
        float time;
    }

    [SerializeField]
    int m_CubeCount = 10000;

    [SerializeField]
    Mesh m_CubeMesh;

    [SerializeField]
    Material m_CubeMaterial;

    [SerializeField]
    ComputeShader m_CubeComputeShader;

    ComputeBuffer m_ParticlesBuffer;

    ComputeBuffer m_ArgumentsBuffer;

    MaterialPropertyBlock m_MaterialPropertyBlock;

    int m_EmitKernel;

    int m_UpdateKernel;
    [SerializeField]
    int m_CurrentParticleIndex;

    private void Awake()
    {
        // ComputeShaderの1回のDispatchで制御する個数に合わせる
        m_CubeCount = Mathf.CeilToInt(m_CubeCount / 8) * 8;

        // いろいろ準備
        m_EmitKernel = m_CubeComputeShader.FindKernel("Emit");
        m_UpdateKernel = m_CubeComputeShader.FindKernel("Update");
        m_MaterialPropertyBlock = new MaterialPropertyBlock();
    }

    private void Start()
    {
        DispatchInit();
    }

    private void OnEnable()
    {
        m_ParticlesBuffer = new ComputeBuffer(m_CubeCount, Marshal.SizeOf<Particle>(), ComputeBufferType.Default);

        var args = new int[4] { m_CubeMesh.GetIndices(0).Length, m_CubeCount, 0, 0 };
        m_ArgumentsBuffer = new ComputeBuffer(1, 16, ComputeBufferType.IndirectArguments);
        m_ArgumentsBuffer.SetData(args);
    }

    private void Update()
    {
        DispatchEmit(Input.mousePosition, 10);
        DispatchUpdate();
    }

    private void OnRenderObject()
    {
        Rendering();
    }

    private void OnDisable()
    {
        m_ParticlesBuffer.Release();
        m_ArgumentsBuffer.Release();
    }

    void DispatchInit()
    {
        var initKernel = m_CubeComputeShader.FindKernel("Init");
        m_CubeComputeShader.SetBuffer(initKernel, "_Particles", m_ParticlesBuffer);
        m_CubeComputeShader.Dispatch(initKernel, m_CubeCount / 8, 1, 1);

        // グローバルな情報もセットしておく
        m_CubeComputeShader.SetVector("_Gravity", Physics.gravity);
    }

    void DispatchEmit(Vector3 pos, int amount)
    {
        pos.z = 10;
        var wpos = Camera.main.ScreenToWorldPoint(pos);

        m_CubeComputeShader.SetBuffer(m_EmitKernel, "_Particles", m_ParticlesBuffer);
        // インデックスを指定して古いものから使うようにする
        m_CubeComputeShader.SetInt("_CurrentParticleIndex", m_CurrentParticleIndex);
        m_CubeComputeShader.SetVector("_MousePosition", wpos);
        // 8 * amount個分呼ばれる
        m_CubeComputeShader.Dispatch(m_EmitKernel, amount, 1, 1);
        m_CurrentParticleIndex = (m_CurrentParticleIndex + 8 * amount) % m_CubeCount;
    }

    void DispatchUpdate()
    {
        m_CubeComputeShader.SetBuffer(m_UpdateKernel, "_Particles", m_ParticlesBuffer);
        m_CubeComputeShader.SetFloat("_DeltaTime", Time.deltaTime);
        m_CubeComputeShader.Dispatch(m_UpdateKernel, m_CubeCount / 8, 1, 1);
    }

    void Rendering()
    {
        m_MaterialPropertyBlock.SetBuffer("_Particles", m_ParticlesBuffer);
        Graphics.DrawMeshInstancedIndirect(m_CubeMesh, 0, m_CubeMaterial, new Bounds(Vector3.zero, Vector3.one * 1000), m_ArgumentsBuffer, 0, m_MaterialPropertyBlock);
    }
}

note

struct Particle
{
    // gameObject相当
    public bool active;

    // tranform相当
    public Vector3 position;
    public Vector3 rotation;
    public Vector3 scale;

    // rigidbody相当
    public Vector3 velocity;
    public Vector3 angularVelocity;

    // particle相当
    float lifeTime;
    float time;
}

Particle用の構造体です。各種コンポーネント相当のデータを持つようにします。

// ComputeShaderの1回のDispatchで制御する個数に合わせる
m_CubeCount = Mathf.CeilToInt(m_CubeCount / 8) * 8;

オーバーフローを防ぐために入れています。

// インデックスを指定して古いものから使うようにする
m_CubeComputeShader.SetInt("_CurrentParticleIndex", m_CurrentParticleIndex);

...

 m_CurrentParticleIndex = (m_CurrentParticleIndex + 8 * amount) % m_CubeCount;

パーティクルを再利用するために、以下の記事などでは死活管理をして「在庫があれば使う」という処理をしていました。 qiita.com

今回は「なければ古いものを使う」にしたかったので、インデックスを進めていって常に一番古いものを使うようにしました。CPUを介さずにできる方法を探しましたが、見つからなかったのでこんな感じになっています。

m_MaterialPropertyBlock.SetBuffer("_Particles", m_ParticlesBuffer);
Graphics.DrawMeshInstancedIndirect(m_CubeMesh, 0, m_CubeMaterial, new Bounds(Vector3.zero, Vector3.one * 1000), m_ArgumentsBuffer, 0, m_MaterialPropertyBlock);

描画用shaderにComputeBufferを渡しています。同じmaterialなので直接materialに渡せばいい気もしますが、MaterialPropertyBlockを使えるみたいだったので使ってみました。場合によっては利点があるんだと思います。

計算用shader(ComputeShader)

#pragma kernel Init
#pragma kernel Emit
#pragma kernel Update

struct Particle
{
    bool active;

    float3 position;
    float3 rotation;
    float3 scale;

    float3 velocity;
    float3 angularVelocity;

    float lifeTime;
    float time;
};

RWStructuredBuffer<Particle> _Particles;

// 環境
float3 _Gravity;

// emit用
uint _CurrentParticleIndex;
float3 _MousePosition;

// update用
float _DeltaTime;

// ユーティリティー
inline float random(float2 seed)
{
    return frac(sin(dot(seed, float2(12.9898, 78.233))) * 43758.5453);
}

inline float3 random3(float2 seed)
{
    return float3(random(seed * 1), random(seed * 2), random(seed * 3)) * 2 - 1;
}

[numthreads(8,1,1)]
void Init (uint id : SV_DispatchThreadID)
{
    Particle p = _Particles[id];
    p.active = false;
    p.scale = 0;
    _Particles[id] = p;
}

[numthreads(8,1,1)]
void Emit (uint id : SV_DispatchThreadID)
{
    uint pid = _CurrentParticleIndex + id;
    Particle p = _Particles[pid];

    float2 seed = float2(pid, 0);
    float3 rand = random3(seed);

    p.active = true;
    
    p.position = _MousePosition;
    p.scale = .1;

    p.velocity = normalize(rand);
    p.angularVelocity = normalize(rand);

    p.time = 0;
    p.lifeTime = 3;
    
    _Particles[pid] = p;
}

[numthreads(8,1,1)]
void Update (uint id : SV_DispatchThreadID)
{
    Particle p = _Particles[id];

    if (p.active)
    {
        // 速度を更新
        p.velocity += _Gravity * _DeltaTime;
        // 位置を更新
        p.position += p.velocity * _DeltaTime;
        // 回転を更新
        p.rotation += p.angularVelocity * _DeltaTime;

        // ライフタイムを更新
        p.time += _DeltaTime;
        p.active = (p.time <= p.lifeTime);
    }
    else
    {
        p.scale = 0;
    }

    _Particles[id] = p;
}

note

struct Particle
{
    bool active;

    float3 position;
    float3 rotation;
    float3 scale;

    float3 velocity;
    float3 angularVelocity;

    float lifeTime;
    float time;
};

Particle構造体はC#でも計算用shaderでも描画用shaderでも引き回すので、全く同じ内容の構造体をそれぞれに定義します。

// 速度を更新
p.velocity += _Gravity * _DeltaTime;
// 位置を更新
p.position += p.velocity * _DeltaTime;
// 回転を更新
p.rotation += p.angularVelocity * _DeltaTime;

いつもはrigidbodyに任せている計算をGPUに移植します。

描画用shader

Shader "Custom/Cube"
{
    Properties
    {
        _Color ("Color", Color) = (1,1,1,1)
        _MainTex ("Albedo (RGB)", 2D) = "white" {}
        _Glossiness ("Smoothness", Range(0,1)) = 0.5
        _Metallic ("Metallic", Range(0,1)) = 0.0
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 200

        CGPROGRAM
        #pragma surface surf Standard fullforwardshadows vertex:vert

        #pragma target 3.0

        sampler2D _MainTex;

        struct appdata {
            float4 vertex : POSITION;
            float4 tangent : TANGENT;
            float3 normal : NORMAL;
            float2 texcoord : TEXCOORD0;
            float2 texcoord1 : TEXCOORD1;
            float2 texcoord2 : TEXCOORD2;
            float2 texcoord3 : TEXCOORD3;
            fixed4 color : COLOR;
            uint instanceID : SV_InstanceID;
        };

        struct Input
        {
            float2 uv_MainTex;
        };

        struct Particle
        {
            bool active;

            float3 position;
            float3 rotation;
            float3 scale;

            float3 velocity;
            float3 angularVelocity;

            float lifeTime;
            float time;
        };

        half _Glossiness;
        half _Metallic;
        fixed4 _Color;

        #if defined(SHADER_API_D3D11) || defined(SHADER_API_METAL)
            StructuredBuffer<Particle> _Particles;
        #endif

        float3 rotate(float3 p, float3 axis, float angle)
        {
            angle = radians(angle);
            float s = sin(angle);
            float c = cos(angle);
            float one_minus_c = 1.0 - c;

            axis = normalize(axis);
            float3x3 rot_mat = 
            {   one_minus_c * axis.x * axis.x + c, one_minus_c * axis.x * axis.y - axis.z * s, one_minus_c * axis.z * axis.x + axis.y * s,
                one_minus_c * axis.x * axis.y + axis.z * s, one_minus_c * axis.y * axis.y + c, one_minus_c * axis.y * axis.z - axis.x * s,
                one_minus_c * axis.z * axis.x - axis.y * s, one_minus_c * axis.y * axis.z + axis.x * s, one_minus_c * axis.z * axis.z + c
            };
            return mul(rot_mat, p);
        }

        void vert (inout appdata v) 
        {
            #if defined(SHADER_API_D3D11) || defined(SHADER_API_METAL)
                Particle p = _Particles[v.instanceID];
                v.vertex.xyz = rotate(v.vertex.xyz, normalize(p.rotation), length(p.rotation));
                v.vertex.xyz *= p.scale * pow(1 - p.time / p.lifeTime, 2);
                v.vertex.xyz += p.position;
            #endif
        }

        void surf (Input IN, inout SurfaceOutputStandard o)
        {
            fixed4 c = tex2D (_MainTex, IN.uv_MainTex) * _Color;
            o.Albedo = c.rgb;
            o.Metallic = _Metallic;
            o.Smoothness = _Glossiness;
            o.Alpha = c.a;
        }
        ENDCG
    }
    FallBack "Diffuse"
}

note

float3 rotate(float3 p, float3 axis, float angle)
{
    angle = radians(angle);
    float s = sin(angle);
    float c = cos(angle);
    float one_minus_c = 1.0 - c;

    axis = normalize(axis);
    float3x3 rot_mat = 
    {   one_minus_c * axis.x * axis.x + c, one_minus_c * axis.x * axis.y - axis.z * s, one_minus_c * axis.z * axis.x + axis.y * s,
        one_minus_c * axis.x * axis.y + axis.z * s, one_minus_c * axis.y * axis.y + c, one_minus_c * axis.y * axis.z - axis.x * s,
        one_minus_c * axis.z * axis.x - axis.y * s, one_minus_c * axis.y * axis.z + axis.x * s, one_minus_c * axis.z * axis.z + c
    };
    return mul(rot_mat, p);
}

頂点を回転させる処理です。以下から持ってきました。 docs.unity3d.com

Particle p = _Particles[v.instanceID];
v.vertex.xyz = rotate(v.vertex.xyz, normalize(p.rotation), length(p.rotation));
v.vertex.xyz *= p.scale * pow(1 - p.time / p.lifeTime, 2));
v.vertex.xyz += p.position;

Particle情報から頂点位置を計算しています。原点で計算するので回転とスケールを計算してから位置を移動します。 スケールは線形に変化するとつまらないので、powを使ってカーブを描くようにしました。

ちょっと最適化する

いまの実装だと描画する必要のないキューブもスケール0として描画されています。描画の必要があるキューブのみ描画するようにしてみます。

スクリプト

ComputeBuffer m_ActiveIndexesBuffer;

...

private void OnEnable()
{
    m_ParticlesBuffer = new ComputeBuffer(m_CubeCount, Marshal.SizeOf<Particle>(), ComputeBufferType.Default);
    m_ActiveIndexesBuffer = new ComputeBuffer(m_CubeCount, sizeof(int), ComputeBufferType.Append);
    ...
}

...

void DispatchUpdate()
{
    m_ActiveIndexesBuffer.SetCounterValue(0);
    ...
    m_CubeComputeShader.Dispatch(m_UpdateKernel, m_CubeCount / 8, 1, 1);
}

...

void Rendering()
{
    ComputeBuffer.CopyCount(m_ActiveIndexesBuffer, m_ArgumentsBuffer, 4);
    m_MaterialPropertyBlock.SetBuffer("_Particles", m_ParticlesBuffer);
    m_MaterialPropertyBlock.SetBuffer("_ActiveIndexes", m_ActiveIndexesBuffer);
    Graphics.DrawMeshInstancedIndirect(m_CubeMesh, 0, m_CubeMaterial, new Bounds(Vector3.zero, Vector3.one * 1000), m_ArgumentsBuffer, 0, m_MaterialPropertyBlock);
}

計算用shader

AppendStructuredBuffer<uint> _ActiveIndexes;

...

[numthreads(8,1,1)]
void Update (uint id : SV_DispatchThreadID)
{
    Particle p = _Particles[id];

    if (p.active)
    {
        ...
        _ActiveIndexes.Append(id);
    }
    ...
}

描画用shader

#if defined(SHADER_API_D3D11) || defined(SHADER_API_METAL)
    StructuredBuffer<Particle> _Particles;
    StructuredBuffer<uint> _ActiveIndexes;
#endif

... 

void vert (inout appdata v) 
{
    #if defined(SHADER_API_D3D11) || defined(SHADER_API_METAL)
        Particle p = _Particles[_ActiveIndexes[v.instanceID]];
        ...
    #endif
}

note

やっていることは以下になります。

  • アクティブなParticleのインデックスを保持するbufferを作る
  • 計算用shaderのupdateカーネル内でbufferを毎回更新する
  • bufferのカウント分だけ描画する
  • 描画用shaderでbufferからアクティブなParticleを見つける
ComputeBuffer.CopyCount(m_ActiveIndexesBuffer, m_ArgumentsBuffer, 4);

CopyCountの第3引数でカウントをコピーするオフセットを指定できるようです。DrawMeshInstancedIndirectに渡す引数用バッファの2つ目がインスタンス数なので上記でコピーできました。

memo

「必要な分だけ描画する方法」を調べても見つからなかったので、こんな感じで実装してみました。正しい使い方なのかはわかりませんが、動いてるのでオッケーです。

おわり

つぎはスクリーンスペース当たり判定を実装してみます。

ここまでの結果