のりまき日記

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

unity)大量描画したい ~ massive pixels ~(CustomRenderTexture編)

Unityで大量描画する方法はいろいろありますが、CustomRenderTextureを使った方法がお手軽なので実装してみます。

かっこいい!

はじめに

100万個の粒子を軽量に描画するのを目指します。パーティクルシステムやVFX Graphを使っても実現出来そうですが、自分で書けるといろいろ応用が効きそうなので勉強のため実装してみます。

やりたいことはCPUでやっている計算をGPUに任せる、ということになります。100万個のGameObjectを作ってUpdateでforを100万回回す(CPU)のは重いので、GPUに並行処理をしてもらう感じです。

100万ピクセル描画する

GPUに直接命令を出して描画します。Graphicsクラスにはいろいろな描画用のメソッドがありますが今回はDrawProceduralを使用しています。DrawProceduralは指定した頂点数を、指定したインスタンス分描画してくれます。

public class MassiveRenderer : MonoBehaviour
{
    [SerializeField]
    Material m_Material;

    void Update()
    {
        Graphics.DrawProcedural(m_Material, new Bounds(Vector3.zero, Vector3.one * 100), MeshTopology.Points, 1024, 1024);
    }
}

note

new Bounds(Vector3.zero, Vector3.one * 100)

第2引数のバウンズはおそらくカリングに使うものだと思います。今回は必要ないのでカメラに写る範囲で大きく設定しています。

MeshTopology.Points

第3引数はMeshTopologyの指定です。Unityでは通常頂点を3つ使って三角形を描画します。MeshTopologyをLinesにすると面は描画せず頂点を繋いだ線で描画してくれます。今回は粒子を描画したいのでPointsを指定して点で描画しています。

1024, 1024

第4引数と第5引数は頂点数とインスタンス数です。1000×1000で100万頂点になります。どちらかを1にしてもう片方を100万にしてもいいのですが、あとで出てくる計算でこっちの方がラクなのでこの指定にしています。

再生すると

100万粒子描画できた!

memo

DrawProceduralは即座に描画されるのでUnityの描画パイプラインに乗れないなどの問題(ライティングとか影とか)があるようです。今回はとにかく大量描画してみたいだけなので考えないようにします。

粒子の位置を移動する

今のままだと全ての粒子がVecror3.zeroの位置に描画されてしまうので各頂点の位置を渡してあげる必要があります。

ここでCustomRenderTextureを使用します。テクスチャの1pxあたりのrgbにxyzの位置を格納し、頂点シェーダーで取り出して位置に変換します。1024×1024のテクスチャで1,048,576個の位置を制御できることになります。

またデフォルトのRenderTextureのフォーマットだと1pxの各色あたり8bitしか格納できなく、ポジションを格納するには精度が低いのでARGBHalfを使用します。

memo

最近のRenderTextureを見たらRenderTextureFormatではなくGraphicsFormatというのを指定するようになっていました。以下のページに対応表があったのでメモしておきます。

docs.unity3d.com

UNormとかSFloatとか見慣れない単語が出てきますがおそらく以下のような認識でいいのかな

  • UNormはunsigned(符号なし)でノーマライズされた値
  • SFloatsはsigned(符号あり)なfloat

ARGBHalfはR16G16B16A16_SFloatとあるので精度ってどんなものだろう?といろいろ調べてみると以下のページをみつけました。

docs.unity3d.com

この辺りは浮動小数点数を学び直さねばと思いました。

CustomRenderTextureの準備

変更したところ

  • 「ColorFormat」をARGBHalf
  • 「Depth」は使わないのでNone
  • 補間する必要がないので「Filter Mode」はPoint
  • 前回の描画結果を参照するので「Double Buffer」をOn

更新用シェーダー

CustomRenderTextureにアタッチするマテリアルに使用します。

Shader "CustomRenderTexture/MyUpdate"
{
    Properties
    {
    }

    SubShader
    {
        Lighting Off
        Blend One Zero

        CGINCLUDE
        #include "UnityCustomRenderTexture.cginc"

        float random(float2 seed)
        {
            return frac(sin(dot(seed, float2(12.9898, 78.233))) * 43758.5453);
        }

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

        Pass
        {
            Name "Update"

            CGPROGRAM
            #pragma vertex CustomRenderTextureVertexShader
            #pragma fragment frag
            #pragma target 3.0

            float4 frag(v2f_customrendertexture IN) : COLOR
            {
                float4 pos = tex2D(_SelfTexture2D, IN.globalTexcoord.xy);
                // 徐々に透明にする
                pos.a = saturate(pos.a - unity_DeltaTime.x);
                return pos;
            }

            ENDCG
        }

        Pass
        {
            Name "Input"

            CGPROGRAM
            #pragma vertex CustomRenderTextureVertexShader
            #pragma fragment frag
            #pragma target 3.0

            float4 _MousePosition;

            float4 frag(v2f_customrendertexture IN) : COLOR
            {
                // マウス位置の周りに球状に配置する
                float3 rand = normalize(random3(IN.localTexcoord.xy));
                return float4(_MousePosition + rand * .1, 1);
            }

            ENDCG
        }
    }
}
  • RGBに位置を格納
  • Aがあまるのでアルファ値を格納

tips!

シェーダーでrandomなどを使いたい時はUnity公式のShader Graphのマニュアルをみるとサンプルコードが乗っているので参考になります。

docs.unity3d.com

描画用シェーダー

Graphics.DrawProceduralに渡すマテリアルに使用します。

Shader "Unlit/MyRendering"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        _PosMap ("PosMap", 2D) = "white" {}
    }
    SubShader
    {
        Tags { "Queue"="Transparent" "RenderType"="Transparent" }
        // 加算
        Blend SrcAlpha One
        // 合成負荷対策
        ZWrite On
        ZTest NotEqual

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "UnityCG.cginc"
            #pragma target 3.5

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
                // Graphics.DrawProceduralから情報を取得
                uint vid : SV_VertexID;
                uint iid : SV_InstanceID;
            };

            struct v2f
            {
                float4 vertex : SV_POSITION;
                float4 col : Color;
                // MeshTopology.Pointsで必要
                float size: PSIZE;
            };

            sampler2D _MainTex;
            float4 _MainTex_ST;

            sampler2D _PosMap;
            float4 _PosMap_TexelSize;

            v2f vert (appdata v)
            {
                v2f o;

                // 頂点インデックスとインスタンスインデックスからuvに変換
                float2 uv = float2(v.vid, v.iid) * _PosMap_TexelSize.xy;

                // CustomRenderTextureから位置情報を取得
                fixed4 posMap = tex2Dlod(_PosMap,  float4(uv, 0, 0));

                o.vertex = mul(UNITY_MATRIX_VP, float4(posMap.xyz, 1));
                o.col = float4(1,1,1,posMap.a);
                o.size = 1;
                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                return i.col;
            }
            ENDCG
        }
    }
}

note

// 合成負荷対策
ZWrite On
ZTest NotEqual

最初はZWriteをOffにして加算していたのですが、初期状態の時にVector3.zeroの位置に透明なピクセルが100万粒子が描画されるので合成処理が重くなってしまいました。フラグメントシェーダー内でclipしてみましたがclipも重いようです。なのでZを描いて同じ位置のピクセルは描画しないようにしました。

// Graphics.DrawProceduralから情報を取得
uint vid : SV_VertexID;
uint iid : SV_InstanceID;

セマンティクスを追加すると頂点インデックスやインスタンスインデックスを取得できます。

// 頂点インデックスとインスタンスインデックスからuvに変換
float2 uv = float2(v.vid, v.iid) * _PosMap_TexelSize.xy;

Graphics.DrawProceduralで1024頂点を1024インスタンスと指定しているので、VertexIDをx・InstanceIDをyとしてuvを計算できます。

// CustomRenderTextureから位置情報を取得
fixed4 posMap = tex2Dlod(_PosMap,  float4(uv, 0, 0));
o.vertex = mul(UNITY_MATRIX_VP, float4(posMap.xyz, 1));

ピクセルにワールド座標が入ってるのでモデル行列を省略しています。また頂点シェーダー内ではtex2Dのlod値が不明なためtex2Dlodでサンプリングしています。

更新処理を追加

public class MassiveRenderer : MonoBehaviour
{
    [SerializeField]
    Material m_Material;

    [SerializeField]
    CustomRenderTexture m_PosMap;

    void Update()
    {
        // マウスの位置を書き込む用のゾーン
        var updateZoneForInput = new CustomRenderTextureUpdateZone();
        updateZoneForInput.needSwap = true;
        updateZoneForInput.passIndex = 1;
        updateZoneForInput.rotation = 0f;
        updateZoneForInput.updateZoneCenter = new Vector2(m_PosMap.width / 2f, Time.frameCount % m_PosMap.height);
        updateZoneForInput.updateZoneSize = new Vector2(m_PosMap.width, 1f);

        // 全体更新用の更新用のゾーン
        var updateZoneForUpdate = new CustomRenderTextureUpdateZone();
        updateZoneForUpdate.needSwap = true;
        updateZoneForUpdate.passIndex = 0;
        updateZoneForUpdate.rotation = 0f;
        updateZoneForUpdate.updateZoneCenter = new Vector2(m_PosMap.width / 2f, m_PosMap.height / 2f);
        updateZoneForUpdate.updateZoneSize = new Vector2(m_PosMap.width, m_PosMap.height);

        // シェーダーにマウスの位置を渡す
        var wpos = Camera.main.ScreenToWorldPoint(new Vector3(Input.mousePosition.x, Input.mousePosition.y, 10f));
        m_PosMap.material.SetVector("_MousePosition", new Vector4(wpos.x, wpos.y, wpos.z, 0f));

        // CustomRenderTextureを更新
        m_PosMap.SetUpdateZones(new CustomRenderTextureUpdateZone[] { updateZoneForInput, updateZoneForUpdate });
        m_PosMap.Update();

        // 描画
        Graphics.DrawProcedural(m_Material, new Bounds(Vector3.zero, Vector3.one * 100), MeshTopology.Points, 1024, 1024);
    }
}

フレーム毎にyをズラしながらマウス位置を1024ピクセルに書き込んでいます。

書き込まれる様子

再生すると

ここまでの結果

うねうねさせる

ピクセルに格納されたポジションを流体エミュレートして変化させてみます。カールノイズというノイズを使います。カールノイズに関しては以下の記事でとても詳しく説明されていました。

edom18.hateblo.jp

シェーダー内でノイズを使う必要があったので以下のシェーダーを使用させていただきました。

github.com

Updateパス内で位置を更新します。

Shader "CustomRenderTexture/Update"
{
    Properties
    {
    }

    SubShader
    {
        Lighting Off
        Blend One Zero

        CGINCLUDE
        #include "UnityCustomRenderTexture.cginc"
        #include "Shader/SimplexNoise3D.hlsl"

        inline float noise(float3 s) { return snoise(s) * .5f + .5f; }

        inline float noise0(float3 s) { return noise(s); }

        inline float noise1(float3 s) { return noise(float3(s.y + 31.416f, s.z - 47.853f, s.x + 12.793f)); }

        inline float noise2(float3 s) { return noise(float3(s.z - 233.145f, s.x - 113.408f, s.y - 185.31f)); }

        inline float3 noise3d(float3 s) { return float3(noise0(s), noise1(s), noise2(s)); };

        float3 SamplePotential(float3 p)
        {
            return noise3d(p);
        }

        float3 ComputeCurl(float3 pos)
        {
            const float e = .0001f;
            float3 dx = float3(e, 0, 0);
            float3 dy = float3(0, e, 0);
            float3 dz = float3(0, 0, e);

            float3 p_x0 = SamplePotential(pos - dx);
            float3 p_x1 = SamplePotential(pos + dx);
            float3 p_y0 = SamplePotential(pos - dy);
            float3 p_y1 = SamplePotential(pos + dy);
            float3 p_z0 = SamplePotential(pos - dz);
            float3 p_z1 = SamplePotential(pos + dz);

            float x = (p_y1.z - p_y0.z) - (p_z1.y - p_z0.y);
            float y = (p_z1.x - p_z0.x) - (p_x1.z - p_x0.z);
            float z = (p_x1.y - p_x0.y) - (p_y1.x - p_y0.x);

            return float3(x, y, z) / (2*e);
        }

        float random(float2 seed)
        {
            return frac(sin(dot(seed, float2(12.9898, 78.233))) * 43758.5453);
        }

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

        Pass
        {
            Name "Update"

            CGPROGRAM
            #pragma vertex CustomRenderTextureVertexShader
            #pragma fragment frag
            #pragma target 3.0
            
            float4 frag(v2f_customrendertexture IN) : COLOR
            {
                float4 pos = tex2D(_SelfTexture2D, IN.globalTexcoord.xy);
                float3 vel = ComputeCurl(pos);
                pos.xyz += vel * unity_DeltaTime.x;
                pos.a = saturate(pos.a - unity_DeltaTime.x);
                return pos;
            }
            ENDCG
        }

        Pass
        {
            Name "Input"

            CGPROGRAM
            #include "UnityCustomRenderTexture.cginc"
            #pragma vertex CustomRenderTextureVertexShader
            #pragma fragment frag
            #pragma target 3.0

            float4 _MousePosition;

            float4 frag(v2f_customrendertexture IN) : COLOR
            {
                float3 rand = normalize(random3(IN.localTexcoord.xy));
                return float4(_MousePosition + rand * .1, 1);
            }
            ENDCG
        }
    }
}

うねうねしている様子