🌈

そろそろShaderをやるパート91 -URP編- GPU Instancingで大量に草を生やす

2023/11/15に公開

そろそろShaderをやります

そろそろShaderをやります。そろそろShaderをやりたいからです。
パート100までダラダラ頑張ります。10年かかってもいいのでやります。
100記事分くらい学べば私レベルの初心者でもまあまあ理解できるかなと思っています。

という感じでやってます。

※初心者がメモレベルで記録するので
 技術記事としてはお力になれないかもしれません。

下準備

下記参考
そろそろShaderをやるパート1 Unite 2017の動画を見る(基礎知識~フラグメントシェーダーで色を変える)

URP対応のためにUnityHubからプロジェクトテンプレート選択画面でURPを選択しました。

バージョン

Unity 2021.2.12f1
Universal RP 12.1.4

デモ

草を生やすデモです。500ポリゴン程度のオブジェクトをおおよそ1000本以上生やしていますが、モバイルでもFPSをそれなりに高い水準でキープして動作します。

Shaderサンプル

Shader "Custom/GPUInstancing"
{
    // プロパティーブロック。ビルトインと同様に外部から操作可能な設定値を定義できる。
    Properties
    {
        // ここに書いたものがInspectorに表示される。
        // 色、陰影。
        _Color("MainColor",Color) = (0,0,0,0)
        _AmbientLight ("Ambient Light", Color) = (0.5,0.5,0.5,1)
        _AmbientPower("Ambient Power ", Range(0, 3)) = 1

        // 出現する表現で利用。
        _Alpha ("Alpha", Float) = 1
        _Size ("Size", Float) = 1

        // 揺れ表現で利用。
        _Frequency("Frequency ", Range(0, 3)) = 1
        _Amplitude("Amplitude", Range(0, 1)) = 0.5
        _WaveSpeed("WaveSpeed",Range(0, 20)) = 10
    }

    // サブシェーダーブロック。ここに処理を書いていく。
    SubShader
    {
        // タグ。サブシェーダーブロック、もしくはパスが実行されるタイミングや条件を記述する。
        Tags
        {
            //レンダリングのタイミング(順番)
            "RenderType" = "Transparent"
            //レンダーパイプラインを指定する。なくても動く。動作環境を制限する役割。
            "RenderPipeline" = "UniversalRenderPipeline"
        }

        Blend SrcAlpha OneMinusSrcAlpha
        Cull off

        Pass
        {
            // HLSL言語を使うという宣言(おまじない)。ビルトインではCg言語だった。
            HLSLPROGRAM
            // vertという名前の関数がvertexシェーダーです と宣言してGPUに教える。
            #pragma vertex vert
            // fragという名前の関数がfragmentシェーダーです と宣言してGPUに教える。
            #pragma fragment frag
            // GPUインスタンシングを有効にする。
            #pragma multi_compile_instancing

            // Core機能をまとめたhlslを参照可能にする。いろんな便利関数や事前定義された値が利用可能となる。
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"

            // ランダムな値を返す
            float rand(float2 co) // 引数はシード値と呼ばれる 同じ値を渡せば同じものを返す
            {
                return frac(sin(dot(co.xy, float2(12.9898, 78.233))) * 43758.5453);
            }

            // パーリンノイズ
            float perlinNoise(float2 st)
            {
                float2 p = floor(st);
                float2 f = frac(st);
                float2 u = f * f * (3.0 - 2.0 * f);

                float v00 = rand(p + float2(0, 0));
                float v10 = rand(p + float2(1, 0));
                float v01 = rand(p + float2(0, 1));
                float v11 = rand(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;
            }

            // 頂点シェーダーに渡す構造体。名前は自分で定義可能。
            struct appdata
            {
                // オブジェクト空間における頂点座標を受け取るための変数
                float4 position : POSITION;
                // 法線を受け取るための変数
                float3 normal : NORMAL;
                // GPUインスタンシングに必要な変数
                UNITY_VERTEX_INPUT_INSTANCE_ID
            };

            // フラグメントシェーダーに渡す構造体。名前は自分で定義可能。
            struct v2f
            {
                // 頂点座標を受け取るための変数。
                float4 vertex : SV_POSITION;
                // 法線を受け取るための変数。
                float3 normal : NORMAL;
            };

            // 変数の宣言。Propertiesで定義した名前と一致させる。
            float4 _Color;
            float3 _AmbientLight;
            float _AmbientPower;
	    float _Alpha;
            float _Size;
            float _Frequency;
            float _Amplitude;
            float _WaveSpeed;

            // 頂点シェーダー。引数には事前定義した構造体が渡ってくる。
            v2f vert(appdata v, uint instanceID : SV_InstanceID)
            {
                // 先ほど宣言した構造体のオブジェクトを作る。
                v2f o;

                // GPUインスタンシングに必要な変数を設定する。
                UNITY_SETUP_INSTANCE_ID(v);

                // スケールのための行列を計算。
                float4x4 scaleMatrix = float4x4(1, 0, 0, 0,
                                                0, _Size * clamp(rand(instanceID), 0.7, 1.0) * 1.2, 0, 0,
                                                0, 0, 1, 0,
                                                0, 0, 0, 1);
                v.position = mul(scaleMatrix, v.position);

                // 揺らめく表現。
                float2 factors = _Time.w * _WaveSpeed + v.position.xy * _Frequency;
                float2 offsetFactor = sin(factors) * _Amplitude * (v.position.y) * perlinNoise(_Time * rand(instanceID));
                v.position.xz += offsetFactor.x + offsetFactor.y;

                // "3Dの世界での座標は2D(スクリーン)においてはこの位置になりますよ" という変換を関数を使って行っている。
                o.vertex = TransformObjectToHClip(v.position);
                // 法線を変換。
                o.normal = TransformObjectToWorldNormal(v.normal);

                // 変換結果を返す。フラグメントシェーダーへ渡る。
                return o;
            }

            // フラグメントシェーダー。引数には頂点シェーダーで処理された構造体が渡ってくる。
            float4 frag(v2f i) : SV_Target
            {
                float4 col = _Color;

                // ライト情報を取得。
                Light light = GetMainLight();
                // ピクセルの法線とライトの方向の内積を計算する。
                float t = dot(i.normal, light.direction);
                // 内積の値を0以上の値にする。
                t = max(0, t);
                // 拡散反射光を計算する。
                float3 diffuseLight = light.color * t;
                // 拡散反射光を反映。
                col.rgb *= diffuseLight + _AmbientLight * _AmbientPower;
                // アルファ値を設定。
                col.a = _Alpha;
                return col;
            }
            ENDHLSL
        }
    }
}

かなりタイトルとは関係のない項目が多いですが、pragmaの宣言とUNITY_VERTEX_INPUT_INSTANCE_ID、UNITY_SETUP_INSTANCE_IDなど利用すれば以下画像のようにチェックボックスが出てきます。

これでGPU Instancingは設定完了ですが、正直これだけではいまいち負荷軽減の実感を得られませんでした。

他に手段がないか調べてみると、DrawMeshInstancedという同一のメッシュを大量に描画するためのメソッドが存在することがわかったので実装してみました。

結論、十分に負荷軽減の実感が得られました。

C#スクリプト

using System.Collections.Generic;
using UnityEngine;

public class DrawMeshInstancing : MonoBehaviour
{
    [SerializeField] private Mesh _mesh;
    [SerializeField] private Material _material;
    [SerializeField] private float _areaWidth = 5.0f;
    [SerializeField] private float _areaHeight = 15.0f;
    [SerializeField] private Vector3 _adjustPosition;
    [SerializeField] private Vector3 _adjustScale;
    [SerializeField] private int _meshCount = 512;
    [SerializeField] private float _rotationAngleDegrees;

    private Matrix4x4[] _matrices;
    private List<Vector3> _positions;

    private void Start()
    {
        _positions = GenerateCoordinates(
            _areaWidth,
            _areaHeight,
            _meshCount,
            transform.position,
            _rotationAngleDegrees);

        _matrices = new Matrix4x4[_positions.Count];

        for (var i = 0; i < _positions.Count; i++)
        {
            var pos = _positions[i] * Random.Range(0.99f, 1.01f);
            var meshPosition = new Vector3(
                pos.x + _adjustPosition.x,
                _positions[i].y + _adjustPosition.y,
                pos.z + _adjustPosition.z);

            _matrices[i % _meshCount] =
                Matrix4x4.TRS(meshPosition, Quaternion.identity, _adjustScale);
        }
    }


    List<Vector3> GenerateCoordinates(
        float width,
        float height,
        int totalCoordinates,
        Vector3 center,
        float rotationAngleDegrees)
    {
        var coordinateList = new List<Vector3>();
        var rowCount = Mathf.FloorToInt(Mathf.Sqrt(totalCoordinates * (width / height)));
        var columnCount = totalCoordinates / rowCount;

        var spacingX = width / rowCount;
        var spacingZ = height / columnCount;

        var rotationAngleRadians = rotationAngleDegrees * Mathf.Deg2Rad;

        for (var col = 0; col < columnCount; col++)
        {
            for (var row = 0; row < rowCount; row++)
            {
                var x = row * spacingX - width / 2 + center.x;
                var z = col * spacingZ - height / 2 + center.z;

                var pos = RotatePoint(new Vector3(x, center.y, z), center, rotationAngleRadians);
                coordinateList.Add(pos);

                if (coordinateList.Count >= totalCoordinates)
                    return coordinateList;
            }
        }

        return coordinateList;
    }

    Vector3 RotatePoint(Vector3 point, Vector3 center, float angleRadians)
    {
        var cosTheta = Mathf.Cos(angleRadians);
        var sinTheta = Mathf.Sin(angleRadians);

        var rotatedX = cosTheta * (point.x - center.x) - sinTheta * (point.z - center.z) + center.x;
        var rotatedZ = sinTheta * (point.x - center.x) + cosTheta * (point.z - center.z) + center.z;

        return new Vector3(rotatedX, point.y, rotatedZ);
    }

    private void Update()
    {
        Graphics.DrawMeshInstanced(_mesh, 0, _material, _matrices, _positions.Count);
    }
}

今回は田んぼのように任意の区画に敷き詰めて配置するようなコードをChatGPTに書いてもらいました。

普段はHierarchyからオブジェクトを操作することで任意の座標や回転について調整できます。
しかし、Graphics.DrawMeshInstancedを利用する場合は、Matrix4x4の配列に自前で位置と回転を定義することになるのでその点が使いこなしポイントだと思います。

おまけ

成長する表現のコードも貼っておきます。ShaderのパラメーターをC#から操作して大きさや透明度を変化させています。

using UnityEngine;

public class GrowthSimulation : MonoBehaviour
{
    [SerializeField] private Material _material;
    [SerializeField] private float _lerpDuration = 3.0f;
    [SerializeField] private float _waitSeconds = 3.0f;

    private float _time;
    private float _currentValue;
    private bool _isIncreasing = true;
    private static readonly int Size = Shader.PropertyToID("_Size");
    private static readonly int Alpha = Shader.PropertyToID("_Alpha");

    void Update()
    {
        // increasingがtrueの場合、0から1に向けて線形補間
        if (_isIncreasing)
        {
            _time += Time.deltaTime / _lerpDuration;
            _currentValue = Mathf.Lerp(0.0f, 1.0f, _time);

            if (_time >= 1.0f)
            {
                _time = 0.0f;
                _isIncreasing = false;
            }
        }
        else
        {
            _time += Time.deltaTime / _waitSeconds;

            if (_time >= 1.0f)
            {
                _time = 0.0f;
                _isIncreasing = true;
                _currentValue = 0.0f;
            }
        }

        _material.SetFloat(Size, _currentValue);
        _material.SetFloat(Alpha, _currentValue);
    }
}

参考リンク

【Unityにおける大量描画のテクニック】ライフゲームタワーを使ったパフォーマンス比較
Unityで行うGPU instancingについて
【Unity】大量のメッシュを軽く描画!GPUインスタンシングの基礎知識とシェーダの書き方まとめ
【Unity】Graphics.DrawMeshInstancedを使う

Discussion