【Unity】モバイルで10万個のオブジェクトを描画してみた!

はじめに

大量のオブジェクトを描画するって魅力的じゃないですか!?
GPUの力を使ってやってみました!
初めてのGPGPUなので、今後のテンプレートとして使えるような出来る限りシンプルな構造のものを作ってみました。
今回はモバイルを含め、より多くの環境で実行できるようにコンピュートシェーダーは使わずに実装しました。
(絵心が足りなくて見た目が地味ですが、10万個のキューブが波打っています!)
f:id:setchi_q:20171219231509j:plain

この記事のプロジェクト一式はこちらです。
github.com

実装方針

主な登場人物です。

  • レンダーテクスチャ(全オブジェクト位置を格納する箱として使用)
  • フラグメントシェーダ(オブジェクト位置計算器)
  • 制御用スクリプト(C#)
  • キューブ表示用シェーダ(RenderTextureから自身の位置を取り出して、その位置に描画します)

10万ピクセルのレンダーテクスチャを用意して、各ピクセル値を各オブジェクトの位置情報として使います。位置情報はフラグメントシェーダを用いて一気に更新します!

実装

カーネル部分(フラグメントシェーダ)

uv値をもとに、パーリンノイズを使ってオブジェクト位置を出力しています。

Shader "UnityGpuSandbox/CubeWave/Kernels"
{
    CGINCLUDE

    #include "UnityCG.cginc"

    sampler2D _PositionBuffer;

    float2 random2(float2 st)
    {
        st = float2(dot(st, float2(127.1, 311.7)),
                    dot(st, float2(269.5, 183.3)));
        return -1.0 + 2.0 * frac(sin(st) * 43758.5453123);
    }

    float perlin_noise(float2 st) 
    {
        float2 p = floor(st);
        float2 f = frac(st);
        float2 u = f * f * (3.0 - 2.0 * f);

        float v00 = random2(p + float2(0, 0));
        float v10 = random2(p + float2(1, 0));
        float v01 = random2(p + float2(0, 1));
        float v11 = random2(p + float2(1, 1));

        return lerp(lerp(dot(v00, f - float2(0, 0)), dot(v10, f - float2(1, 0) ), u.x),
                    lerp(dot(v01, f - float2(0, 1)), dot(v11, f - float2(1, 1) ), u.x), 
                    u.y) + 0.5f;
    }
    
    float4 frag_init_position(v2f_img i) : SV_Target
    {
        i.uv -= 0.5;
        i.uv *= 150;
        return float4(i.uv.x, 0, i.uv.y, 1);
    }
    
    float4 frag_update_position(v2f_img i) : SV_Target
    {
        float4 p = tex2D(_PositionBuffer, i.uv);
        p.y = perlin_noise(float2(i.uv.x * 10, i.uv.y * 10 + _Time.x * 10)) * 10;
        return p;
    }
    ENDCG

    SubShader
    {
        Pass
        {
            CGPROGRAM
            #pragma target 3.0
            #pragma vertex vert_img
            #pragma fragment frag_init_position
            ENDCG
        }

        Pass
        {
            CGPROGRAM
            #pragma target 3.0
            #pragma vertex vert_img
            #pragma fragment frag_update_position
            ENDCG
        }
    }
}
制御用スクリプト(C#)
public partial class CubeWave : MonoBehaviour
{
    [SerializeField]
    Shader kernelShader;
    [SerializeField]
    Shader debugShader;
    [SerializeField]
    Mesh[] shapes = new Mesh[1];
    [SerializeField]
    Material material;
    [SerializeField]
    ShadowCastingMode castShadows;
    [SerializeField]
    bool receiveShadows = false;
    [SerializeField]
    bool debug = false;

    RenderTexture positionBuffer1;
    RenderTexture positionBuffer2;
    Material kernelMaterial;
    Material debugMaterial;
    MaterialPropertyBlock props;
    BulkMesh bulkMesh;

    bool needsReset = true;

    RenderTexture CreateBuffer()
    {
        var width = bulkMesh.CopyCount;
        var height = 320;
        var buffer = new RenderTexture(width, height, 0, RenderTextureFormat.ARGBFloat);
        buffer.hideFlags = HideFlags.DontSave;
        buffer.filterMode = FilterMode.Point;
        buffer.wrapMode = TextureWrapMode.Repeat;
        return buffer;
    }

    Material CreateMaterial(Shader shader)
    {
        var material = new Material(shader);
        material.hideFlags = HideFlags.DontSave;
        return material;
    }

    void ResetResources()
    {
        if (bulkMesh == null)
        {
            bulkMesh = new BulkMesh(shapes, 320);
        }
        else
        {
            bulkMesh.Rebuild(shapes);
        }

        if (positionBuffer1) DestroyImmediate(positionBuffer1);
        if (positionBuffer2) DestroyImmediate(positionBuffer2);

        positionBuffer1 = CreateBuffer();
        positionBuffer2 = CreateBuffer();

        if (!kernelMaterial) kernelMaterial = CreateMaterial(kernelShader);
        if (!debugMaterial) debugMaterial = CreateMaterial(debugShader);

        InitializeBuffers();

        needsReset = false;
    }

    void InitializeBuffers()
    {
        Graphics.Blit(null, positionBuffer2, kernelMaterial, 0);
    }

    void SwapBuffersAndInvokeKernels()
    {
        var tempPosition = positionBuffer1;
        positionBuffer1 = positionBuffer2;
        positionBuffer2 = tempPosition;

        kernelMaterial.SetTexture("_PositionBuffer", positionBuffer1);
        Graphics.Blit(null, positionBuffer2, kernelMaterial, 1);
    }

    void OnDestroy()
    {
        if (bulkMesh != null) bulkMesh.Release();
        if (positionBuffer1) DestroyImmediate(positionBuffer1);
        if (positionBuffer2) DestroyImmediate(positionBuffer2);
        if (kernelMaterial) DestroyImmediate(kernelMaterial);
        if (debugMaterial) DestroyImmediate(debugMaterial);
    }

    void Update()
    {
        if (needsReset)
        {
            ResetResources();
        }

        SwapBuffersAndInvokeKernels();

        if (props == null)
        {
            props = new MaterialPropertyBlock();
        }
        props.SetTexture("_PositionBuffer", positionBuffer2);

        var mesh = bulkMesh.Mesh;
        var pos = transform.position;
        var rot = transform.rotation;
        var mat = material;
        var uv = new Vector2(0.5f / positionBuffer2.width, 0);

        for (var i = 0; i < positionBuffer2.height; i++)
        {
            uv.y = (0.5f + i) / positionBuffer2.height;
            props.SetVector("_BufferOffset", uv);
            Graphics.DrawMesh(
                mesh, pos, rot,
                mat, 0, null, 0, props,
                castShadows, receiveShadows
            );
        }
    }

    void OnGUI()
    {
        if (debug && Event.current.type.Equals(EventType.Repaint))
        {
            if (debugMaterial && positionBuffer2)
            {
                var w = positionBuffer2.width;
                var h = positionBuffer2.height;

                var rect = new Rect(0, 0, w, h);
                Graphics.DrawTexture(rect, positionBuffer2, debugMaterial);
            }
        }
    }
}

解説

制御用スクリプト(C#)の SwapBuffersAndInvokeKernels メソッド内でオブジェクト位置を計算&更新しています。

kernelMaterial.SetTexture("_PositionBuffer", positionBuffer1);

でマテリアルに現在のオブジェクト位置が入ったレンダーテクスチャをセットしています。

Graphics.Blit(null, positionBuffer2, kernelMaterial, 1);

でオブジェクト位置計算用マテリアルを使って positionBuffer2(レンダーテクスチャ) に新しいオブジェクト位置を描画(?)しています。

第四引数の「1」は、パスのインデックスの指定になっています。
シェーダのパス部分は下記のようになっており、上から順に0からインデックスが割り当てられています。「1」は二番目のパスになり、#pragma fragment で指定されている「frag_update_position」関数が呼び出されます。(「0」のパスはレンダーテクスチャ初期化用で、初期化時にC#スクリプトから呼んでいます)

SubShader
{
    // pass 0
    Pass
    {
        CGPROGRAM
        #pragma target 3.0
        #pragma vertex vert_img
        #pragma fragment frag_init_position
        ENDCG
    }
    // pass 1
    Pass
    {
        CGPROGRAM
        #pragma target 3.0
        #pragma vertex vert_img
        #pragma fragment frag_update_position
        ENDCG
    }
}

キューブの描画は、制御用スクリプト(C#)の Update 関数内が起点となっています。
MaterialPropertyBlock に位置情報を格納したレンダーテクスチャをセットして、Graphics.DrawMesh で描画しています。

if (props == null)
{
    props = new MaterialPropertyBlock();
}
props.SetTexture("_PositionBuffer", positionBuffer2);

var mesh = bulkMesh.Mesh;
var pos = transform.position;
var rot = transform.rotation;
var mat = material;
var uv = new Vector2(0.5f / positionBuffer2.width, 0);

for (var i = 0; i < positionBuffer2.height; i++)
{
    uv.y = (0.5f + i) / positionBuffer2.height;
    props.SetVector("_BufferOffset", uv);
    Graphics.DrawMesh(
        mesh, pos, rot,
        mat, 0, null, 0, props,
        castShadows, receiveShadows
    );
}

実行結果

f:id:setchi_q:20171219231630g:plain

最後に

今回作ったものをベースに引き続きGPGPUやっていきます!
絵作りに関する知識も勉強したい!綺麗でインパクトのある絵を描きたい!

f:id:setchi_q:20171219221239j:plain

この記事のプロジェクト一式はこちらです。
github.com