【Unity2022】スプラインをCompute Shaderから扱う方法

こじゃらこじゃら

スプラインの計算をGPUで高速化する方法はないの?

このはこのは

Compute Shaderを使えば良いわ。Splinesパッケージには基本的な計算機能が提供されているわ。

Unity 2022.1から使用可能なSplinesパッケージでは、CPUによるスプライン計算の機能が提供されています。

通常はスプライン上の位置や接線などをメインスレッド上で計算しますが、一部の基本的な計算をCompute Shaderで実施するためのユーティリティが提供されています。

参考:Struct SplineComputeBufferScope<T> | Splines | 2.3.0

主に次の操作であれば、既存のユーティリティで実現できます。

Compute Shaderのユーティリティ群
  • スプライン上の位置計算
  • スプライン上の接線計算(Tangent)

ただし、次のような計算はサポートされておらず、独自実装する必要があります。

ユーティリティで出来ない事
  • 移動速度をほぼ一定に保つ
  • 上向きベクトルを計算する
  • 加速度を計算する

本記事では、Splinesパッケージが提供しているユーティリティを用いてCompute Shader上で基本的な計算を行う方法を解説していきます。

動作環境
  • Unity 2023.1.8f1
  • Splines 2.3.0

スポンサーリンク

前提条件

Unity 2022.1以降でプロジェクトが開かれ、Splinesパッケージがインストールされているものとします。

Splinesパッケージの導入方法および基本的な使い方は以下記事で解説しています。

Compute Shaderで計算する仕組み

スプラインの経路情報は内部的には3次ベジェ曲線として表現されます。

ベジェ曲線

ベジェ曲線上の位置は、割合t(範囲0~1)を用いて次式のように計算されます。

3次ベジェ曲線の式
P = (1-t)^3 P_0 + 3t (1-t)^2 P_1 + 3t^2 (1-t) P_2 + t^3 P_3

ただし、0 \leq t \leq 1

上記の位置計算を膨大な回数で行う場合、GPU上(Compute Shader等)で計算を並列化すればパフォーマンスの恩恵を受けられます。

Compute Shaderでスプラインにおける位置を計算するには、スプラインの制御点情報や入力情報GPU側のメモリ(VRAM)に転送しておく必要があります。

データ転送イメージ

メモリ間の転送が発生するため、この部分の処理コストには注意する必要があります。

Splinesパッケージでは、Compute Shader上で割合tにおけるスプライン上の位置と速度を求めるユーティリティ群が提供されています。

注意

システムメモリ(RAM)とGPU上のメモリ(VRAM)間のデータ転送にはコストがかかります。転送回数を抑える、なるべくVRAM上でデータをやり取りするなどの工夫が必要です。

スプライン上の位置を計算する

まず、割合tを入力、スプライン上の計算位置を出力する場合を例にとって解説していきます。

Compute Shaderを用いてスプライン位置などをGPU側で計算させるための手順は次の通りです。

実装の流れ
  • スプライン上の位置を計算する処理の実装(Compute Shader側
  • スプラインの制御点情報をVRAM上に転送する処理の実装(C#スクリプト側)
  • Compute Shader側の計算を実行させる処理の実装(C#スクリプト側)

順番に解説していきます。

Compute Shaderの実装

Splinesパッケージでは、Compute Shaderでスプラインの計算を行うためのユーティリティが提供されています。

これを使用するためには、次のファイルをインクルードします。

#include "Packages/com.unity.splines/Shader/Spline.cginc"

Compute Shader側で保持しておく必要のあるスプライン情報は次の通りです。

  • ノット(Knot)の数
  • スプラインがループしているかどうか
  • スプライン全体の長さ
  • ベジェ曲線(4つの制御点から成る)の配列
  • 各ベジェ曲線の長さ

これらの情報は、次のような変数として定義します。

// スプライン情報
SplineInfo info = float4(0, 0, 0, 0);
StructuredBuffer<BezierCurve> curves;
StructuredBuffer<float> curveLengths;

SplineInfo型には、制御点の数スプラインがループしているかどうかスプライン全体の長さの情報が含まれています。

BezierCurve型は4つの制御点P_0P_1P_2P_3を格納するベジェ曲線情報です。

StructuredBuffer<T>型を用いることで配列を定義できます。

スプライン上の位置を計算するための入力情報、計算結果の出力情報は、次のような配列で定義します。

// 入出力情報
StructuredBuffer<float> inputs;
RWStructuredBuffer<float3> outputs;

RWStructuredBuffer<T>型読み書き可能な構造化バッファです。

Compute Shaderでスプライン上の位置を計算するためには、まず入力の構造化バッファから現在インデックスの要素を取得します。

// 割合tからスプライン上の位置を計算する
[numthreads(64,1,1)]
void GetPositions(uint id : SV_DispatchThreadID)
{
    // 割合tを入力値の配列から取得
    const float inputT = saturate(inputs[id]);

saturate関数0~1の範囲に収めています。

そして、Compute Shaderで計算するためのスプライン用の割合tを計算します。

// カーブ用の割合tに変換
// スプライン中のベジェ曲線のインデックス + ベジェ曲線内の割合tが返る
// 範囲は0~ノット数
const float curveT = SplineToCurveT(info, curveLengths, inputT);

SplineToCurveT関数はSplinesパッケージが提供しているユーティリティ関数です。

スプライン情報各ベジェ曲線の長さの配列入力の割合tより、カーブ上のどの位置かを計算して返します。

戻り値ベジェ曲線配列のインデックスにベジェ曲線内の割合tを加算した値です。例えばインデックス4のベジェ曲線の中心なら4.5という結果が返ります。

ベジェ曲線上の位置を計算するために、まずベジェ曲線のインデックスを計算します。前述のSplineToCurveT関数の戻り値の小数点を切り捨てた値がインデックスです。

// ベジェ曲線のインデックスを計算
const uint curveIndex = floor(curveT) % GetKnotCount(info);

最大値を超えないようにノット数(GetKnotCount関数の戻り値)で剰余演算を行っています。

そして、得られたインデックスからベジェ曲線配列の要素を取得します。

// インデックスからベジェ曲線を取得
const BezierCurve curve = curves[curveIndex];

最終的な位置計算は、EvaluatePosition関数で行います。

// 割合tからベジェ曲線上の位置を計算し、結果に格納
outputs[id] = EvaluatePosition(curve, frac(curveT));

第1引数にはベジェ曲線第2引数にはベジェ曲線における割合t(範囲は0~1)を指定します。割合tは、前述のSplineToCurveT関数の戻り値の小数部分になるため、frac関数を用いています。

サンプルスクリプト

最終的なCompute Shaderの完成形は以下のようになります。

GetPositions.compute
#include "Packages/com.unity.splines/Shader/Spline.cginc"

#pragma kernel GetPositions

// スプライン情報
SplineInfo info = float4(0, 0, 0, 0);
StructuredBuffer<BezierCurve> curves;
StructuredBuffer<float> curveLengths;

// 入出力情報
StructuredBuffer<float> inputs;
RWStructuredBuffer<float3> outputs;

// 割合tからスプライン上の位置を計算する
[numthreads(64,1,1)]
void GetPositions(uint id : SV_DispatchThreadID)
{
    // 割合tを入力値の配列から取得
    const float inputT = saturate(inputs[id]);

    // カーブ用の割合tに変換
    // スプライン中のベジェ曲線のインデックス + ベジェ曲線内の割合tが返る
    // 範囲は0~ノット数
    const float curveT = SplineToCurveT(info, curveLengths, inputT);

    // ベジェ曲線のインデックスを計算
    const uint curveIndex = floor(curveT) % GetKnotCount(info);

    // インデックスからベジェ曲線を取得
    const BezierCurve curve = curves[curveIndex];

    // 割合tからベジェ曲線上の位置を計算し、結果に格納
    outputs[id] = EvaluatePosition(curve, frac(curveT));
}

上記をGetPositions.computeという名前でUnityプロジェクトに保存しておきます。

C#スクリプトの実装

Compute Shaderに渡すスプライン情報はSplineComputeBufferScope<T>構造体を用いると比較的楽に管理できます。

public struct SplineComputeBufferScope<T> : IDisposable where T : ISpline

この構造体は、内部的にはスプライン情報やベジェ曲線の制御点情報のバッファを管理しています。前述のCompute Shaderのユーティリティ関数に対応しています。

参考:Struct SplineComputeBufferScope<T> | Splines | 2.3.0

初期化までの流れは以下のようなコードになります。

SplineContainer splineContainer;
SplineComputeBufferScope<Spline> splineBuffers;


・・・(中略)・・・

Spline spline = splineContainer.Spline;

// スプラインの情報をCompute Shader側に渡すためのバッファを生成する
splineBuffers = new SplineComputeBufferScope<Spline>(spline);

Compute Shader側に渡すためのバッファとスクリプト側のデータとの紐づけは、SplineComputeBufferScope.Bindメソッドで行います。

// Compute ShaderのGetPositions関数のカーネルのIDを取得する
int getPositionsKernel = _computeShader.FindKernel("GetPositions");

// Compute Shader側で定義したバッファをバインドする
// 名前に注意
splineBuffers.Bind(
    computeShader,
    getPositionsKernel,
    "info",
    "curves",
    "curveLengths"
);

第1引数Compute Shader第2引数カーネルIDを指定します。

第3~5引数Compute Shaderで管理されるバッファの変数名を指定します。順にスプライン情報ベジェ曲線情報各ベジェ曲線の長さです。

バッファ作成およびデータの転送SplineComputeBufferScope.Uploadメソッドで行います。

// スプラインの情報をCompute Shader側に渡す
splineBuffers.Upload();

Uploadメソッドの呼び出しは、スプライン情報が変更された場合などCompute Shader側のスプライン情報を更新したい場合に使います。

Compute Shaderとやり取りする入出力情報は上記SplineComputeBufferScope構造体では管理されないため、自分で用意する必要があります。

// 入出力バッファ
ComputeBuffer inputsBuffer = new ComputeBuffer(newInputsCount, sizeof(float));
ComputeBuffer outputsBuffer = new ComputeBuffer(newInputsCount, sizeof(float) * 3);

// 入力値をセット
inputsBuffer.SetData(_inputs);

// 入出力バッファをセット
computeShader.SetBuffer(_getPositionsKernel, "inputs", inputsBuffer);
computeShader.SetBuffer(_getPositionsKernel, "outputs", outputsBuffer);

ここまで全て揃ったら、Compute Shaderで実際の計算を行います。計算から出力値の受取り処理は以下の通りです。

// スレッドグループのサイズを取得
computeShader.GetKernelThreadGroupSizes(
    getPositionsKernel,
    out var x,
    out var y,
    out var z
);

// Compute Shaderを実行
computeShader.Dispatch(getPositionsKernel, (int) x, (int) y, (int) z);

// 出力値を取得
Vector3[] outputs;
outputsBuffer.GetData(outputs);

サンプルスクリプト

以上を踏まえ、スプライン上の位置をCompute Shaderによって計算し、その結果に基づいてオブジェクトを配置するC#側スクリプトの例です。

GetPositionsExample.cs
using System.Linq;
using UnityEngine;
using UnityEngine.Serialization;
using UnityEngine.Splines;

public class GetPositionsExample : MonoBehaviour
{
    [SerializeField] private SplineContainer _splineContainer;
    [SerializeField] private ComputeShader _computeShader;

    // 入力値を自動生成するかどうか
    [SerializeField] private bool _autoGenerate = true;

    // 入力値の割合tの配列
    [SerializeField] private float[] _inputs;

    // 出力値の位置に配置するオブジェクトのPrefab
    [SerializeField] private GameObject _outputPrefab;

    // 出力値の配列
    private Vector3[] _outputs;
    private GameObject[] _outputObjects;

    // Compute Shader側と受け渡しするためのバッファ
    private SplineComputeBufferScope<Spline> _splineBuffers;
    private ComputeBuffer _inputsBuffer;
    private ComputeBuffer _outputsBuffer;

    // Compute ShaderのGetPositions関数のカーネルのID
    private int _getPositionsKernel;

    // その他内部状態変数
    private bool _isInitialized;
    private int _inputsCount;

    private void Awake()
    {
        if (_autoGenerate)
        {
            // 自動配置
            // 0.05刻みで入力値を生成する
            _inputs = Enumerable.Range(0, 21)
                .Select(i => i * 0.05f)
                .ToArray();
        }

        var spline = _splineContainer.Spline;

        // スプラインの情報をCompute Shader側に渡すためのバッファを生成する
        _splineBuffers = new SplineComputeBufferScope<Spline>(spline);

        // Compute ShaderのGetPositions関数のカーネルのIDを取得する
        _getPositionsKernel = _computeShader.FindKernel("GetPositions");

        // Compute Shader側で定義したバッファをバインドする
        // 名前に注意
        _splineBuffers.Bind(
            _computeShader,
            _getPositionsKernel,
            "info",
            "curves",
            "curveLengths"
        );

        // スプラインの情報をCompute Shader側に渡す
        _splineBuffers.Upload();

        _isInitialized = true;

        // スプラインの情報が変更されたときの処理を呼ぶ
        OnSplineChanged(spline, 0, SplineModification.Default);
    }

    private void OnDestroy()
    {
        // バッファを破棄する
        _splineBuffers.Dispose();
        _inputsBuffer?.Dispose();
        _outputsBuffer?.Dispose();
    }

    private void OnEnable()
    {
        // スプラインの情報が変更されたときの処理を登録する
        Spline.Changed += OnSplineChanged;
    }

    private void OnDisable()
    {
        // スプラインの情報が変更されたときの処理を解除する
        Spline.Changed -= OnSplineChanged;
    }

    // スプラインの情報が変更されたときの処理
    private void OnSplineChanged(Spline spline, int knotIndex, SplineModification modificationEvent)
    {
        if (!_isInitialized)
            return;

        // スプラインの情報をCompute Shader側に渡す
        _splineBuffers.Upload();

        // 入力値の配列の長さが変わったらバッファを再生成する
        var newInputsCount = _inputs.Length;
        if (newInputsCount != _inputsCount)
        {
            _inputsBuffer?.Dispose();
            _outputsBuffer?.Dispose();

            _outputs = new Vector3[newInputsCount];

            _inputsBuffer = new ComputeBuffer(newInputsCount, sizeof(float));
            _outputsBuffer = new ComputeBuffer(newInputsCount, sizeof(float) * 3);

            _inputsCount = newInputsCount;

            // 出力値のオブジェクトを生成する
            if (_outputPrefab)
            {
                if (_outputObjects != null)
                {
                    for (var i = 0; i < _outputObjects.Length; i++)
                    {
                        Destroy(_outputObjects[i]);
                    }
                }

                _outputObjects = new GameObject[_inputsCount];
                for (var i = 0; i < _inputsCount; i++)
                {
                    _outputObjects[i] = Instantiate(_outputPrefab, _splineContainer.transform);
                }
            }
        }

        // 入力値をセット
        _inputsBuffer.SetData(_inputs);

        // 入出力バッファをセット
        _computeShader.SetBuffer(_getPositionsKernel, "inputs", _inputsBuffer);
        _computeShader.SetBuffer(_getPositionsKernel, "outputs", _outputsBuffer);

        // スレッドグループのサイズを取得
        _computeShader.GetKernelThreadGroupSizes(
            _getPositionsKernel,
            out var x,
            out var y,
            out var z
        );

        // Compute Shaderを実行
        _computeShader.Dispatch(_getPositionsKernel, (int) x, (int) y, (int) z);

        // 出力値を取得
        _outputsBuffer.GetData(_outputs);

        // 出力値のオブジェクトを配置する
        if (_outputObjects != null)
        {
            for (var i = 0; i < _outputObjects.Length; i++)
            {
                _outputObjects[i].transform.localPosition = _outputs[i];
            }
        }
    }

#if UNITY_EDITOR
    // エディタ上でスプラインの情報が変更されたときの処理
    private void OnValidate()
    {
        if (!UnityEditor.EditorApplication.isPlaying)
            return;

        OnSplineChanged(_splineContainer.Spline, 0, SplineModification.Default);
    }
#endif
}

上記をGetPositionsExample.csという名前でUnityプロジェクトに保存してください。

スプライン情報や入力値が変更されたらCompute Shader側に自動的に結果が反映されるようになっています。

シーンのセットアップ

まず、計算対象のスプラインをシーン上に配置します。

ヒエラルキーウィンドウ左上の+アイコン > Spline > Draw Splines Tool…から選択すると編集できます。

基本的な操作方法は以下記事をご覧ください。

次のように配置されていれば良いです。

そして、Compute Shader側で計算するためのスクリプトを適当なゲームオブジェクトにアタッチし、インスペクターより各種設定を行います。

インスペクターでの設定内容は以下の通りです。

  • Spline Container – スプライン
  • Compute Shader – 位置計算用のCompute Shader
  • Auto Generate – 入力値を自動生成するかどうか(しない場合はInputs項目で設定)
  • Inputs – 入力となる割合tの配列(範囲は0~1)
  • Output Prefab – 結果の位置に配置するオブジェクトのPrefab

例では、Prefabは次のような赤いキューブとします。

実行結果

Compute Shaderで計算されたスプライン上の位置にオブジェクトが配置されるようになりました。

スプラインを変更すると、リアルタイムに結果が更新されることが確認できます。

スクリプトの説明

スプライン情報が変更されたら結果を更新する挙動は、以下処理で実現しています。

private void OnEnable()
{
    // スプラインの情報が変更されたときの処理を登録する
    Spline.Changed += OnSplineChanged;
}

private void OnDisable()
{
    // スプラインの情報が変更されたときの処理を解除する
    Spline.Changed -= OnSplineChanged;
}

Spline.Changedイベントを通じてスプラインの変更検知が出来ます。このプロパティはstaticであることにご注意ください。

public static event Action<Spline, int, SplineModification> Changed

参考:Class Spline | Splines | 2.3.0

スプラインが更新された時の処理では、バッファに新しいデータをセットしますが、バッファ長が変化したときだけバッファを作成しなおすようにして頻繁なバッファ再作成をしないように対策しています。

// スプラインの情報をCompute Shader側に渡す
_splineBuffers.Upload();

// 入力値の配列の長さが変わったらバッファを再生成する
var newInputsCount = _inputs.Length;
if (newInputsCount != _inputsCount)
{
    _inputsBuffer?.Dispose();
    _outputsBuffer?.Dispose();

    _outputs = new Vector3[newInputsCount];

    _inputsBuffer = new ComputeBuffer(newInputsCount, sizeof(float));
    _outputsBuffer = new ComputeBuffer(newInputsCount, sizeof(float) * 3);

    _inputsCount = newInputsCount;

    // 出力値のオブジェクトを生成する
    if (_outputPrefab)
    {
        if (_outputObjects != null)
        {
            for (var i = 0; i < _outputObjects.Length; i++)
            {
                Destroy(_outputObjects[i]);
            }
        }

        _outputObjects = new GameObject[_inputsCount];
        for (var i = 0; i < _inputsCount; i++)
        {
            _outputObjects[i] = Instantiate(_outputPrefab, _splineContainer.transform);
        }
    }
}

スプライン上の接線を計算する

Compute Shaderで接線(ベクトル)を計算した場合、EvaluateTangent関数を使えば良いです。

// 割合tからベジェ曲線上の速度を計算し、結果に格納
outputs[id] = EvaluateTangent(curve, frac(curveT));

接線を返す関数となっていますが、内部的にはスプライン位置を割合tで微分した結果を返すようになっており、実質的には割合tにおける移動速度が返ります。

そのため、得られる結果(ベクトル)の長さは場所によって異なります。場合によっては長さ1に正規化する必要があるでしょう。

制約事項

Compute Shader側で提供されるユーティリティ関数は、C#側のSplineのように速さを一定に保つような補正処理は行われません。

そのため、曲線の場所によって速さが変化してしまう欠点があります。

このような速さの補正をCompute Shader側で実現するためには、割合tをルックアップテーブルに基づいて補正する計算処理を独自実装する必要があるでしょう。

さいごに

Splinesパッケージでは、Compute Shaderでスプライン上の位置や接線を計算するためのユーティリティ関数が提供されています。

ただし、通常のスプラインで行われるような速さ補正までは行われず、簡易的な計算であることに注意が必要です。

また、結果の受け渡しはシステムメモリとVRAM間の転送処理が走るため、この部分がネックにならないように注意が必要です。

Compute Shaderでの計算を上手く活用すれば、メッシュ変形などへの応用が期待できます。本記事では基本部分の解説に留めましたが、この辺の応用は別記事で紹介予定です。

関連記事

参考サイト

スポンサーリンク