凹みTips

C++、JavaScript、Unity、ガジェット等の Tips について雑多に書いています。

Unity で URP 向けのファーシェーダを書いてみた(動き・アニメーション連携)

はじめに

毛の連載も 4 回目になりました。これまでの記事は以下になります。

tips.hecomi.com

tips.hecomi.com

tips.hecomi.com

これまでは風による動きを Sin 関数でそれっぽくしてましたが、今回は実際に頂点毎の動きを見て動かしてみることをやってみましょう!

デモ

f:id:hecomi:20210814023542g:plain

f:id:hecomi:20210814023052g:plain

© UTJ/UCL

ダウンロード

github.com

戦略

毛を動かす方法は色々あるとは思います。例えば一番簡単な方法では、transform.position を基準とするバネを接続したモデルを考え、この変位分をこれまでの方法で見てきた _BaseMove という毛を曲げるパラメタに突っ込めば移動に対して毛が動くようになります。しかしながらこの方法では物体の回転やアニメーションといった動きに追従できません。これらを見るには頂点単位で毛の動きのシミュレーションを行う必要があります。

そこで ComputeBuffer を用意し、すべての頂点の位置に対してシミュレーションを行うことにします。コンピュートシェーダを使う必要があるように思われますが、頂点シェーダ内でも RWStructuredBuffer を使ってあげると処理することが出来ます。毎フレーム頂点シェーダですべての頂点ごとに減衰バネを計算し、この変位を毛の曲げ成分に入力してあげることにします。

なお、今回は 3 つ目の毛の手法である毛ポリゴン方式を使うことにします。前回見たようにテッセレーションとジオメトリシェーダを使うのですが、計算量削減とコード簡略化のため、テッセレーション後の頂点ではなく、前の頂点シェーダの時点で計算を行うことにします。

コード

バッファ

バッファは次のように 4 つのパラメタを用意します。

struct FurMoverData
{
    float3 posWS; // バネの位置
    float3 dPosWS; // バネの変位(曲げに使う)
    float3 velocityWS; // バネの速度
    float time; // 最後に処理した時間
};

減衰バネの運動方程式は以下になります。

\overrightarrow{f} = -k \cdot \overrightarrow{dx} - d \overrightarrow{v} + m \overrightarrow{g}

とりあえず変位計算用の一個前の位置 posWS と速度 velocityWS があれば良いのですが、テッセレーション後は頂点も増えてるので計算量削減のため dPosWS を予め計算しておきます。またここに _MoveScale を掛けてあげることで小さい動きや大きい動きのときの揺れを誇張したり制限したりできるようにしておきます。time が必要な理由は後述します。

ComputeBuffer の生成

まずは次のような ComputeBuffer の生成と破棄、そしてマテリアルにセットするコードを書きます。必要なバッファの数は MeshFilter または SkinnedMeshRenderer から描画対象のメッシュを取得して頂点数を調べてそれを使います。

using UnityEngine;

namespace Fur
{

[RequireComponent(typeof(Renderer))]
public class FurMover : MonoBehaviour
{
    ComputeBuffer _buffer = null;

    void OnEnable()
    {
        int numVertices = 0;

        var meshFilter = GetComponent<MeshFilter>();
        if (meshFilter)
        {
            numVertices = meshFilter.sharedMesh.vertexCount;
        }

        var skinnedMeshRenderer = GetComponent<SkinnedMeshRenderer>();
        if (skinnedMeshRenderer)
        {
            numVertices = skinnedMeshRenderer.sharedMesh.vertexCount;
        }

        if (numVertices == 0) return;

        var renderer = GetComponent<Renderer>();
        if (!renderer) return;

        // float3 (4*3) が 3 つと float (4) が 1 つ
        _buffer = new ComputeBuffer(numVertices, 12 * 3 + 4);
        Graphics.SetRandomWriteTarget(1, _buffer, true);

        foreach (var mat in renderer.materials)
        {
            mat.SetBuffer("_Buffer", _buffer);
        }
    }

    void OnDisable()
    {
        if (_buffer == null) return;

        _buffer.Dispose();
        _buffer = null;
    }
}

}

Graphics.SetRandomWriteTarget()RWStructuredBuffer としてシェーダの中で読み書きできるようになります。詳しくは以下の記事をご参照ください。

tips.hecomi.com

このスクリプトをファーシェーダを適用しているゲームオブジェクトに追加しておきます。

頂点シェーダ

まず ShaderLab 側に減衰バネ関連のパラメタを追加しておきます。

Properties
{
    ...
    [Header(Move)][Space]
    _MoveScale("MoveScale", Range(0.0, 5.0)) = 1.0
    _Spring("Spring", Range(0.0, 100.0)) = 5.0
    _Damper("Damper", Range(0.0, 10.0)) = 1.0
    _Gravity("Gravity", Range(-10.0, 10.0)) = -2.0
}

これを次のようなコードで計算します。

struct _Attributes
{
    float4 positionOS : POSITION;
    float3 normalOS : NORMAL;
    float2 texcoord : TEXCOORD0;
    float2 lightmapUV : TEXCOORD1;
    uint id : SV_VertexID;
};

struct Attributes
{
    float4 positionOS : POSITION;
    float3 normalOS : NORMAL;
    float2 texcoord : TEXCOORD0;
    float2 lightmapUV : TEXCOORD1;
    uint id : TEXCOORD2;
};

...

float _Spring;
float _Damper;
float _Gravity;

Attributes vert(_Attributes input)
{
    FurMoverData data = _Buffer[input.id];

    float time = _Time.y;
    if (abs(time - data.time) > 1e-3)
    {
        float3 targetPosWS = TransformObjectToWorld(input.positionOS.xyz);
        float3 dPosWS = targetPosWS - data.posWS;
        float3 forceWS = _Spring * dPosWS - data.velocityWS * _Damper + float3(0.0, _Gravity, 0.0);
        float dt = 1.0 / 60;
        data.velocityWS += forceWS * dt;
        data.posWS += data.velocityWS * dt;
        data.dPosWS = (data.posWS - targetPosWS) * _MoveScale;
        float move = length(data.dPosWS);
        data.dPosWS = min(move, 1.0) / max(move, 0.01) * data.dPosWS;
        data.time = time;
        _Buffer[input.id] = data;
    }

    return (Attributes)input;
}

まず SV_VertexID で頂点 ID を受け取り、それをインデックスとして先程作成したバッファの領域を取得します。しかしながらこのセマンティクスはテッセレータやジオメトリシェーダには渡せないので、頂点シェーダの出力としては TEXCOORD2 として返すようにしています。

次は if 文です。この頂点シェーダはすべてのカメラのビューで駆動されてしまうため、同一フレームに複数回この処理が走ってしまうことになります。これは Scene ビューや Game ビューだけではなく、Inspector 上のプレビューなども含まれます。これを避けるために time として現在の時間(_Time.y)を保存しておいて差分があるときのみ、つまり最初のビューでのみ処理を行うようにしています。カメラが 1 つで実際のビルド後のゲームでは問題ならないようなケースの場合は最適化のために消しても良いかもしれません。

if 分の中では運動方程式を解いています。力を計算し、速度をアップデート、位置に反映させる一連のプロセスで減衰バネのような動きをします。また、伸び制限もかけています。本当は各ジョイントごとに計算してあげたほうが良い しなり になると思うのですが、コードが少し複雑になるので今回はシンプルに各頂点の位置でのバネとして計算します。

なお、バッファの初期化はしていません。なのでゲームを実行したときに大きく毛が動いてしまいます。初期化用のパスを書いたりする必要があるとは思いますがまぁそこまで気にならないのでシンプルですし今回は割愛することにします。

テッセレーション

ドメインシェーダでテッセレーションによって分割された各頂点の ID を返すようにします。最近接を見たほうが良いかもしれませんがここでは雑に 0 番目の頂点の ID を返すことにします。

Attributes domain(...)
{
    Attributes o = (Attributes)0;
    ...
    o.id = i[0].id;
    return o;
}

ジオメトリシェーダ

void geom(...)
{
    uint id0 = input[0].id;
    uint id1 = input[1].id;
    uint id2 = input[2].id;
    ...
    float3 faceNormalWS = ...;
    float3 windMoveWS = ...;
    float3 baseMoveWS = ...;
    float3 vertMoveWS = (_Buffer[id0].dPosWS + _Buffer[id1].dPosWS + _Buffer[id2].dPosWS) / 3;
    float3 movedFaceNormalWS = faceNormalWS + baseMoveWS + windMoveWS + vertMoveWS;
    ...
}

先程計算した変位の平均を法線変化に与えます。本当はもう少し正しく計算したほうが良いとは思いますが一応これでもそれっぽく見えるのとこれまでのコードの変更が最小限になるので手抜きしてます。

これであとはパラメタ調整すれば冒頭のデモのような動きになります。

おわりに

毛のフサフサした動く犬を作ろうと思ったら色々楽しくなって長くなってしまいました。毛の記事はひとまずここまでとします。