【Unity】BaseMeshEffectを継承したクラスを使った角丸表示

このエントリは【カヤック】面白法人グループ Advent Calendar 2024の19日目の記事です。

はじめに

こんにちは、カヤックアキバスタジオでエンジニアをやっている臼井です。
今年も残りわずかですね、みなさんいかがお過ごしでしょうか?
さて、今回はuGUIのImage、RawImageの角丸表示をやってみた内容を紹介していこうと思います。

環境

  • MacBook Pro 14インチ(macOS Sonoma 14.1)
  • Unity2022.3.52f1

準備

  • Unity HubからNew Projectを選択し空プロジェクトを作成します
  • 使いたい画像をAssetsフォルダ内に配置して、Inspector上でTexture TypeをSprite(2D and UI)に変更しApplyボタンを押します。
    • ※RawImageで使用する場合は、Defaultのままで大丈夫です
  • Hierarchy上で右クリック > UI > Imageを選択します
  • 追加したImageオブジェクトを選択して、Inspector上からSource Imageに追加した画像をセットします
  • Assetsフォルダを右クリック > Create > C# Scriptを選択し、ファイル名をRoundedMeshEffectとして設定します
  • Hierarchy上のImageオブジェクトを選択してInspector上から追加したRoundedMeshEffectコンポーネントをアタッチします

実装

BaseMeshEffectを継承

RoundedMeshEffect.csを開いて下記内容のように書きます。

using UnityEngine.UI;

public class RoundedMeshEffect : BaseMeshEffect
{
    public override void ModifyMesh(VertexHelper vh)
    {
    }
}

Imageコンポーネント側でメッシュ情報が設定されているので関数内が空でも今まで通り表示されます。
関数内でvh.Clearを呼び出すとメッシュ情報がなくなるため、何も表示されなくなります。
現在の情報を取得するにはvh.GetUIVertexStreamを使用するとこで取得ができるので
新しく追加したい情報だけを設定していけばいいのですが、
今回は0ベースで設定しようと思うのでvh.Clearを呼び出して処理を書いていこうと思います。

表示を9分割に分けて書いて行きます。

真ん中を表示

まずは真ん中を表示するための処理を作成します。
Imageの場合は、設定したspriteがAtlas化されている場合を考慮して、
uvRectの値を算出しています。

using UnityEngine;
using UnityEngine.UI;

public class RoundedMeshEffect : BaseMeshEffect
{
    public override void ModifyMesh(VertexHelper vh)
    {
        // 情報をクリアする
        vh.Clear();
        
        var radius = 100f; // 角丸の半径
        var division = 8; // 角丸の分割数

        var rectTransform = (RectTransform)transform;
        var size = new Vector2(rectTransform.rect.width, rectTransform.rect.height);
        var halfSize = size / 2f;
        var color32 = new Color32(255, 0, 0, 255);
        
        // 設定する座標からuv値を取得するために
        // 表示する頂点の最小、最大値を取得しておく
        var min = new Vector2(-halfSize.x, -halfSize.y);
        var max = new Vector2(halfSize.x, halfSize.y);
        
        // 角丸分だけサイズを縮める
        var r2 = radius * 2f;
        size.x -= r2;
        size.y -= r2;
        halfSize = size / 2f;
        
        // Pivot考慮するためにオフセット値を取得
        var offsetPos = rectTransform.rect.size * (new Vector2(0.5f, 0.5f) - rectTransform.pivot);
        
        // UV情報を取得
        var sprite = ((Image)graphic).sprite;
        var uvRect = new Rect(0, 0, 1, 1);
        if (sprite != null && sprite.texture != null)
        {
            var textureRect = sprite.textureRect;
            uvRect = new Rect(
                textureRect.x / sprite.texture.width,
                textureRect.y / sprite.texture.height,
                (textureRect.x + textureRect.width) / sprite.texture.width,
                (textureRect.y + textureRect.height) / sprite.texture.height);
        }
        
        // 左上
        var pos = new Vector2(-halfSize.x, halfSize.y);
        vh.AddVert(pos + offsetPos, color32,
            new Vector2(
                Mathf.Lerp(uvRect.xMin, uvRect.xMax, Mathf.InverseLerp(min.x, max.x, pos.x)),
                Mathf.Lerp(uvRect.yMin, uvRect.yMax, Mathf.InverseLerp(min.y, max.y, pos.y))));
        
        // 右上
        pos = new Vector2(halfSize.x, halfSize.y);
        vh.AddVert(pos + offsetPos, color32,
            new Vector2(
                Mathf.Lerp(uvRect.xMin, uvRect.xMax, Mathf.InverseLerp(min.x, max.x, pos.x)),
                Mathf.Lerp(uvRect.yMin, uvRect.yMax, Mathf.InverseLerp(min.y, max.y, pos.y))));

        // 右下
        pos = new Vector2(halfSize.x, -halfSize.y);
        vh.AddVert(pos + offsetPos, color32,
            new Vector2(
                Mathf.Lerp(uvRect.xMin, uvRect.xMax, Mathf.InverseLerp(min.x, max.x, pos.x)),
                Mathf.Lerp(uvRect.yMin, uvRect.yMax, Mathf.InverseLerp(min.y, max.y, pos.y))));

        // 左下
        pos = new Vector3(-halfSize.x, -halfSize.y);
        vh.AddVert(pos + offsetPos, color32,
            new Vector2(
                Mathf.Lerp(uvRect.xMin, uvRect.xMax, Mathf.InverseLerp(min.x, max.x, pos.x)),
                Mathf.Lerp(uvRect.yMin, uvRect.yMax, Mathf.InverseLerp(min.y, max.y, pos.y))));

        // 追加した頂点の繋ぎ方を設定
        vh.AddTriangle(0, 1, 2);
        vh.AddTriangle(2, 3, 0);
    }
}

上下左右に四角表示を追加

先ほどの最後の処理に下記内容を追加します。

        // 上
        color32 = new Color32(255, 0, 0, 255);
        pos = new Vector2(-halfSize.x, halfSize.y + radius);
        vh.AddVert(pos + offsetPos, color32,
            new Vector2(
                Mathf.Lerp(uvRect.xMin, uvRect.xMax, Mathf.InverseLerp(min.x, max.x, pos.x)),
                Mathf.Lerp(uvRect.yMin, uvRect.yMax, Mathf.InverseLerp(min.y, max.y, pos.y))));

        pos = new Vector2(halfSize.x, halfSize.y + radius);
        vh.AddVert(pos + offsetPos, color32,
            new Vector2(
                Mathf.Lerp(uvRect.xMin, uvRect.xMax, Mathf.InverseLerp(min.x, max.x, pos.x)),
                Mathf.Lerp(uvRect.yMin, uvRect.yMax, Mathf.InverseLerp(min.y, max.y, pos.y))));

        vh.AddTriangle(4, 5, 1);
        vh.AddTriangle(1, 0, 4);

赤くなっている箇所が新しく情報を追加して表示したものとなります。

同じ要領で下と左右を追加したものがこちらです。

上下左右追加処理

        // 上
        color32 = new Color32(255, 0, 0, 255);
        pos = new Vector2(-halfSize.x, halfSize.y + radius);
        vh.AddVert(pos + offsetPos, color32,
            new Vector2(
                Mathf.Lerp(uvRect.xMin, uvRect.xMax, Mathf.InverseLerp(min.x, max.x, pos.x)),
                Mathf.Lerp(uvRect.yMin, uvRect.yMax, Mathf.InverseLerp(min.y, max.y, pos.y))));

        pos = new Vector2(halfSize.x, halfSize.y + radius);
        vh.AddVert(pos + offsetPos, color32,
            new Vector2(
                Mathf.Lerp(uvRect.xMin, uvRect.xMax, Mathf.InverseLerp(min.x, max.x, pos.x)),
                Mathf.Lerp(uvRect.yMin, uvRect.yMax, Mathf.InverseLerp(min.y, max.y, pos.y))));

        vh.AddTriangle(4, 5, 1);
        vh.AddTriangle(1, 0, 4);
        
        // 下
        pos = new Vector2(-halfSize.x, -halfSize.y - radius);
        vh.AddVert(pos + offsetPos, color32,
            new Vector2(
                Mathf.Lerp(uvRect.xMin, uvRect.xMax, Mathf.InverseLerp(min.x, max.x, pos.x)),
                Mathf.Lerp(uvRect.yMin, uvRect.yMax, Mathf.InverseLerp(min.y, max.y, pos.y))));

        pos = new Vector2(halfSize.x, -halfSize.y - radius);
        vh.AddVert(pos + offsetPos, color32,
            new Vector2(
                Mathf.Lerp(uvRect.xMin, uvRect.xMax, Mathf.InverseLerp(min.x, max.x, pos.x)),
                Mathf.Lerp(uvRect.yMin, uvRect.yMax, Mathf.InverseLerp(min.y, max.y, pos.y))));

        vh.AddTriangle(3, 2, 7);
        vh.AddTriangle(7, 6, 3);
        
        // 左
        pos = new Vector2(-halfSize.x - radius, halfSize.y);
        vh.AddVert(pos + offsetPos, color32,
            new Vector2(
                Mathf.Lerp(uvRect.xMin, uvRect.xMax, Mathf.InverseLerp(min.x, max.x, pos.x)),
                Mathf.Lerp(uvRect.yMin, uvRect.yMax, Mathf.InverseLerp(min.y, max.y, pos.y))));

        pos = new Vector2(-halfSize.x - radius, -halfSize.y);
        vh.AddVert(pos + offsetPos, color32,
            new Vector2(
                Mathf.Lerp(uvRect.xMin, uvRect.xMax, Mathf.InverseLerp(min.x, max.x, pos.x)),
                Mathf.Lerp(uvRect.yMin, uvRect.yMax, Mathf.InverseLerp(min.y, max.y, pos.y))));
        
        vh.AddTriangle(8, 0, 3);
        vh.AddTriangle(3, 9, 8);

        // 右
        pos = new Vector2(halfSize.x + radius, halfSize.y);
        vh.AddVert(pos + offsetPos, color32,
            new Vector2(
                Mathf.Lerp(uvRect.xMin, uvRect.xMax, Mathf.InverseLerp(min.x, max.x, pos.x)),
                Mathf.Lerp(uvRect.yMin, uvRect.yMax, Mathf.InverseLerp(min.y, max.y, pos.y))));

        pos = new Vector2(halfSize.x + radius, -halfSize.y);
        vh.AddVert(pos + offsetPos, color32,
            new Vector2(
                Mathf.Lerp(uvRect.xMin, uvRect.xMax, Mathf.InverseLerp(min.x, max.x, pos.x)),
                Mathf.Lerp(uvRect.yMin, uvRect.yMax, Mathf.InverseLerp(min.y, max.y, pos.y))));

        vh.AddTriangle(1, 10, 11);
        vh.AddTriangle(11, 2, 1);

角丸表示を追加

角丸は90度(Mathf.PI / 2f)を分割数で割った数分、頂点を追加し行って繋ぐことで表示をしています。
分割数が多いほど頂点数が多くなるため綺麗な角丸を表現することができます。
下記処理で左上に角丸を表示します。

        // 角丸で使用する情報を用意
        const float HALF_PI = Mathf.PI / 2f;
        var triangleNum = division + 1;
        var addRad = HALF_PI / triangleNum;
        var offsetRad = Mathf.PI + HALF_PI;
        var rad = -offsetRad + HALF_PI;
        var vertCount = vh.currentVertCount;
        color32 = new Color32(255, 0, 0, 255);
        
        // 左上に角丸表示
        for (var i = 0; i < (division + 2); ++i)
        {
            pos = new Vector2(-halfSize.x + Mathf.Cos(rad) * radius,
                halfSize.y + Mathf.Sin(rad) * radius);
            vh.AddVert(pos + offsetPos, color32,
                new Vector2(
                    Mathf.Lerp(uvRect.xMin, uvRect.xMax, Mathf.InverseLerp(min.x, max.x, pos.x)),
                    Mathf.Lerp(uvRect.yMin, uvRect.yMax, Mathf.InverseLerp(min.y, max.y, pos.y))));

            rad -= addRad;
        }

        var centerVertIndex = 0;
        for (var i = 0; i < triangleNum; ++i)
        {
            vh.AddTriangle(centerVertIndex, vertCount, ++vertCount);
        }

上下左右それぞれに角丸を表示したものがこちら

上下左右追加処理

        // 角丸で使用する情報を用意
        const float HALF_PI = Mathf.PI / 2f;
        var triangleNum = division + 1;
        var addRad = HALF_PI / triangleNum;
        var offsetRad = Mathf.PI + HALF_PI;
        var rad = -offsetRad + HALF_PI;
        var vertCount = vh.currentVertCount;
        color32 = new Color32(255, 0, 0, 255);
        
        // 左上に角丸表示
        for (var i = 0; i < (division + 2); ++i)
        {
            pos = new Vector2(-halfSize.x + Mathf.Cos(rad) * radius,
                halfSize.y + Mathf.Sin(rad) * radius);
            vh.AddVert(pos + offsetPos, color32,
                new Vector2(
                    Mathf.Lerp(uvRect.xMin, uvRect.xMax, Mathf.InverseLerp(min.x, max.x, pos.x)),
                    Mathf.Lerp(uvRect.yMin, uvRect.yMax, Mathf.InverseLerp(min.y, max.y, pos.y))));

            rad -= addRad;
        }

        var centerVertIndex = 0;
        for (var i = 0; i < triangleNum; ++i)
        {
            vh.AddTriangle(centerVertIndex, vertCount, ++vertCount);
        }
        
        // 右上に角丸表示
        vertCount = vh.currentVertCount;
        offsetRad = 0;
        rad = -offsetRad + HALF_PI;
        for (var i = 0; i < (division + 2); ++i)
        {
            pos = new Vector2(halfSize.x + Mathf.Cos(rad) * radius,
                halfSize.y + Mathf.Sin(rad) * radius);
            vh.AddVert(pos + offsetPos, color32,
                new Vector2(
                    Mathf.Lerp(uvRect.xMin, uvRect.xMax, Mathf.InverseLerp(min.x, max.x, pos.x)),
                    Mathf.Lerp(uvRect.yMin, uvRect.yMax, Mathf.InverseLerp(min.y, max.y, pos.y))));

            rad -= addRad;
        }

        centerVertIndex = 1;
        for (var i = 0; i < triangleNum; ++i)
        {
            vh.AddTriangle(centerVertIndex, vertCount, ++vertCount);
        }
        
        // 右下に角丸表示
        vertCount = vh.currentVertCount;
        offsetRad = HALF_PI;
        rad = -offsetRad + HALF_PI;
        for (var i = 0; i < (division + 2); ++i)
        {
            pos = new Vector2(halfSize.x + Mathf.Cos(rad) * radius,
                -halfSize.y + Mathf.Sin(rad) * radius);
            vh.AddVert(pos + offsetPos, color32,
                new Vector2(
                    Mathf.Lerp(uvRect.xMin, uvRect.xMax, Mathf.InverseLerp(min.x, max.x, pos.x)),
                    Mathf.Lerp(uvRect.yMin, uvRect.yMax, Mathf.InverseLerp(min.y, max.y, pos.y))));

            rad -= addRad;
        }

        centerVertIndex = 2;
        for (var i = 0; i < triangleNum; ++i)
        {
            vh.AddTriangle(centerVertIndex, vertCount, ++vertCount);
        }
        
        // 左下に角丸表示
        vertCount = vh.currentVertCount;
        offsetRad = Mathf.PI;
        rad = -offsetRad + HALF_PI;
        for (var i = 0; i < (division + 2); ++i)
        {
            pos = new Vector2(-halfSize.x + Mathf.Cos(rad) * radius,
                -halfSize.y + Mathf.Sin(rad) * radius);
            vh.AddVert(pos + offsetPos, color32,
                new Vector2(
                    Mathf.Lerp(uvRect.xMin, uvRect.xMax, Mathf.InverseLerp(min.x, max.x, pos.x)),
                    Mathf.Lerp(uvRect.yMin, uvRect.yMax, Mathf.InverseLerp(min.y, max.y, pos.y))));

            rad -= addRad;
        }

        centerVertIndex = 3;
        for (var i = 0; i < triangleNum; ++i)
        {
            vh.AddTriangle(centerVertIndex, vertCount, ++vertCount);
        }

完成

半径、分割数頭をInspectorから設定できるようにしつつ、処理を整理したものがこちらになります。

RoundedMeshEffect.cs

public class RoundedMeshEffect : BaseMeshEffect
{
    const float HALF_PI = Mathf.PI / 2f;
    static readonly Rect _defaultUVRect = new Rect(0, 0, 1, 1);

    [SerializeField] float _radius = 20f;
    [SerializeField, Range(1, 12)] int _division = 8;
    [SerializeField] bool _isFitSize;
    [SerializeField] Vector2 _size = new(100, 100);
    [NonSerialized] RectTransform? _cacheRectTransform;

    Vector3 _min;
    Vector3 _max;
    Vector3 _offsetPos;
    Rect _uvRect;
    Color _color32;

    public RectTransform rectTransform => _cacheRectTransform ??= (RectTransform)transform;

    public override void ModifyMesh(VertexHelper vh)
    {
        if (_radius < 0f)
        {
            _radius = 0f;
        }

        vh.Clear();

        var size = new Vector2(rectTransform.rect.width, rectTransform.rect.height);
        var halfSize = size / 2f;

        _min.x = -halfSize.x;
        _min.y = -halfSize.y;
        _max.x = halfSize.x;
        _max.y = halfSize.y;

        if (!_isFitSize)
        {
            size = _size;
        }

        var r2 = _radius * 2f;
        size.x -= r2;
        size.y -= r2;
        halfSize = size / 2f;

        _color32 = graphic.color;
        _offsetPos = rectTransform.rect.size * (new Vector2(0.5f, 0.5f) - rectTransform.pivot);
        _uvRect = GetUVRect();

        // 中心
        var topLeft = new Vector3(-halfSize.x, halfSize.y);
        AddVert(vh, topLeft); // 0
        var topRight = new Vector3(halfSize.x, halfSize.y);
        AddVert(vh, topRight); // 1
        var bottomRight = new Vector3(halfSize.x, -halfSize.y);
        AddVert(vh, bottomRight); // 2
        var bottomLeft = new Vector3(-halfSize.x, -halfSize.y);
        AddVert(vh, bottomLeft); // 3

        vh.AddTriangle(0, 1, 2);
        vh.AddTriangle(2, 3, 0);

        // 上
        AddVert(vh, new Vector3(-halfSize.x, halfSize.y + _radius)); // 4
        AddVert(vh, new Vector3(halfSize.x, halfSize.y + _radius)); // 5

        vh.AddTriangle(4, 5, 1);
        vh.AddTriangle(1, 0, 4);

        // 下
        AddVert(vh, new Vector3(-halfSize.x, -halfSize.y - _radius)); // 6
        AddVert(vh, new Vector3(halfSize.x, -halfSize.y - _radius)); // 7

        vh.AddTriangle(3, 2, 7);
        vh.AddTriangle(7, 6, 3);

        // 左
        AddVert(vh, new Vector3(-halfSize.x - _radius, halfSize.y)); // 8
        AddVert(vh, new Vector3(-halfSize.x - _radius, -halfSize.y)); // 9

        vh.AddTriangle(8, 0, 3);
        vh.AddTriangle(3, 9, 8);

        // 右
        AddVert(vh, new Vector3(halfSize.x + _radius, halfSize.y)); // 10
        AddVert(vh, new Vector3(halfSize.x + _radius, -halfSize.y)); // 11

        vh.AddTriangle(1, 10, 11);
        vh.AddTriangle(11, 2, 1);

        // 角
        AddHorns(vh, 0, topLeft, Mathf.PI + HALF_PI, _radius);
        AddHorns(vh, 1, topRight, 0f, _radius);
        AddHorns(vh, 2, bottomRight, HALF_PI, _radius);
        AddHorns(vh, 3, bottomLeft, Mathf.PI, _radius);
    }

    void AddVert(VertexHelper vh, in Vector3 pos)
    {
        vh.AddVert(pos + _offsetPos, _color32,
            new Vector2(
                Mathf.Lerp(_uvRect.xMin, _uvRect.xMax, Mathf.InverseLerp(_min.x, _max.x, pos.x)),
                Mathf.Lerp(_uvRect.yMin, _uvRect.yMax, Mathf.InverseLerp(_min.y, _max.y, pos.y))));
    }

    void AddHorns(VertexHelper vh, int centerVertIndex, in Vector3 centerPos, float offsetRad, float radius)
    {
        var triangleNum = _division + 1;
        var addRad = HALF_PI / triangleNum;
        var rad = -offsetRad + HALF_PI;
        var vertCount = vh.currentVertCount;
        for (var i = 0; i < (_division + 2); ++i)
        {
            AddVert(vh, new Vector3(centerPos.x + Mathf.Cos(rad) * radius,
                centerPos.y + Mathf.Sin(rad) * radius));

            rad -= addRad;
        }

        for (var i = 0; i < triangleNum; ++i)
        {
            vh.AddTriangle(centerVertIndex, vertCount, ++vertCount);
        }
    }

    Rect GetUVRect()
    {
        return graphic switch
        {
            Image image => GetUVRectForImage(image),
            RawImage rawImage => GetUVRectForRawImage(rawImage),
            _ => _defaultUVRect
        };
    }

    static Rect GetUVRectForImage(Image g)
    {
        if (g.sprite == null || g.sprite.texture == null)
        {
            return _defaultUVRect;
        }

        var sprite = g.sprite;
        var textureRect = sprite.textureRect;
        return new Rect(
            textureRect.x / sprite.texture.width,
            textureRect.y / sprite.texture.height,
            (textureRect.x + textureRect.width) / sprite.texture.width,
            (textureRect.y + textureRect.height) / sprite.texture.height);
    }

    static Rect GetUVRectForRawImage(RawImage g)
    {
        var scaleX = g.mainTexture.width * g.mainTexture.texelSize.x;
        var scaleY = g.mainTexture.height * g.mainTexture.texelSize.y;
        var r = g.uvRect;
        r.xMin *= scaleX;
        r.xMax *= scaleX;
        r.yMin *= scaleY;
        r.yMax *= scaleY;
        return r;
    }
}

まとめ

今回は頂点を追加する方法でやってみましたがいかがでしたでしょうか?
他にもImage、RawImageを継承してOnPopulateMesh関数内で処理するやり方や
shaderで対応する方法もあるので機会があれば作ってみたいなと思います。
また負荷検証は特にしなかったので機会があればやってみたいですね。

【WebGPU】Compute Shader で Curl Noise を計算してパーティクルを動かす

🎄この記事は【カヤック】面白法人グループ Advent Calendar 2024の18日目の記事です 🎄

こんにちは!ハイパーカジュアルゲームチーム・エンジニアの深澤です。

WebGPU の Compute Shader で Curl Noise を計算し、パーティクルの位置を更新してみました。
スクショは、MacBookPro M1で100万個のパーティクルを動かしたものです。

画像をクリックするとデモに飛びます。
WebGPU で実装しているため、Chromeのみの動作となります。

デモURL: https://takumifukasawa.github.io/webgpu-particle-compute-shader-curl-noise-demo/

リポジトリ github.com


目次


動機

たとえばこの動画のように、大量のパーティクルが一つ一つまるで風に揺れるように・流体のように動く様子はかっこいいですよね!

www.youtube.com

このような動きを、流体の方程式に沿ってちゃんとシミュレーションをするとどうしても大変な計算負荷になります。
リアルタイムレンダリングとなると、初期位置や速度の決定から、動いている途中の位置・速度を毎フレーム更新することが必要になります。
数十、数百のパーティクルぐらいなら耐えられるかもしれませんが、数千、数万と数が多くなるにしたがってより実現が難しくなっていくはずです。

そこで、Curl Noise という手法を使うことにより流体のシミュレーションをせずとも流体に近い動きが実現できます。

自分で Curl Noise の実装をしたことがなかったので、アドベントカレンダーを機に試してみることにしました。

せっかくなので、Chrome で標準で使用できるようになった WebGPU と Compute Shader を使っていきます。
WebGL でも TransformFeedback を使うことで同等のことは可能です。

Curl Noise

※理解が間違っている部分などありましたらすいません!

概要

SIGGRAPH 2007 で発表された、疑似流体アルゴリズムの発表の中に登場します。論文/資料はこちらになります。

https://www.cs.ubc.ca/~rbridson/docs/bridson-siggraph2007-curlnoise.pdf

この手法が開発された理由として、既存の流体運動の方程式によるシュミレーションはリアルな挙動を表現するが、計算速度の問題やパラメーターによる制御のコントロールが難しいことが発端にあったようです。
ベクトルのポテンシャル場が2次元ないし3次元空間に構築されており、速度をベクトルのポテンシャル場から求める、という方法です。

まず、ベクトルのポテンシャル場とは空間にベクトルが散らばっているイメージです。以下、ベクトル場と呼びます。
風を例にすると、屋外では風が横に吹いて樹木が揺れたり、木の葉が上に舞い上がったりしますよね。つまり、とある空間の中に様々な方向の力があちこちに存在している、というイメージです。

2次元の場合のイメージ。平面上にベクトルが散らばっている

- ベクトル場の中から、とある地点のベクトルの勾配(後述)を取り出す
- そのベクトルを回転させた値(ベクトル解析における回転)を速度として扱う
- 現在の位置を更新していくことで渦のような動きを得ることができる
というのが Curl Noise の核のようです。

特徴

Curl Noise の特徴として「完全に非圧縮性」という性質があります。非圧縮性というのは、密度が一定で、発散が0になるということのようです。速度場においては、入力と出力の速度が一定、という理解です。

計算

∇(= ナブラ)はベクトル微分演算子のことで、この文脈の中ではとても大雑把にいうと「勾配」として扱います。
計算には、現在の位置からx,y,zを少しずらした位置のベクトルを取得し、勾配をとったものです。

そして、ナブラの外積をとってベクトルを回転させたものが Curl Noise の計算結果となります。
各参考記事によるとこういう計算をすることになるようです。
(すいませんこのあたりは知識が特になく、理解が甘いです)

また、回転したベクトルと元のベクトルの内積をとったものが「発散」です。
非圧縮性の性質から発散が0になる = 内積が0 = 90度なので「ベクトル解析において90度回転させたものが Curl Noise の結果」と言い換えることもできそうです。

...つまり細かい計算は分かっていない部分が多々あるのですが、
ベクトル場を生成して、勾配をとり、ベクトルを回転させ、それを速度として捉える、ということなのかなと思っています。

本記事で触れること / 触れないこと

論文では境界制限(衝突,範囲制限)や粘性についても触れていますが、今回は単純に速度を求めることのみに着目します。

全体の流れ

速度の計算を Compute Shader で行うので座標の更新も Compute Shader で行っていきます。

大量にパーティクルを出すために、板ポリを GPU Instancing させます。
板ポリにする理由は、Point の描画ではなく、大きさを持ったビルボードとして常にカメラを向かせるような描画をさせたかったためです。

パーティクルのシェーダー内でのインスタンシング情報の読み取りですが、Compute Shader で GPU に紐づいたBufferを更新する方針なので、そのBuffer を共有・参照すれば計算をGPUで完結することができるので、CPU への読み書きや Buffer のバインドし直しなどが発生せず効率が良くなりそうです。

パーティクルを描画するためのRenderPipelineは以下のような構造になります。

// positionとvelocityをvec4<f32>な値として詰め込む
const instanceDataArray = new Float32Array([
    0, 0, 0, 0, // インスタンス=0のposition
    0, 0, 0, 0, // インスタンス=0のvelocity
    ...
]);

const particleInstancesBuffer = gDevice.createBuffer({
    size: instanceDataArray.byteLength,
    usage:
        GPUBufferUsage.STORAGE |
        GPUBufferUsage.VERTEX |
        GPUBufferUsage.COPY_DST,
    mappedAtCreation: true
});
new Float32Array(particleInstancesBuffer.getMappedRange()).set(instanceDataArray);
particleInstancesBuffer.unmap();

...

const particleRenderPipeline = gDevice.createRenderPipeline({
    layout: 'auto',
    vertex: {
        module: gDevice.createShaderModule({
            code: vertWGSL, // 頂点シェーダー
        }),
        entryPoint: 'main',
        buffers: [
            // buffer = 0
            {
                arrayStride: quadVertexSize, // UVなど板ポリの情報で使う頂点データの大きさ
                stepMode: 'vertex',
                attributes: [
                    // 頂点が持つデータ。uvなど任意に設定
                    {
                        shaderLocation: 0, // @location(0)
                        offset: quadPositionOffset,
                        format: 'float32x4'
                    },
                    {
                        shaderLocation: 1, // @location(1)
                        offset: quadColorOffset,
                        format: 'float32x4'
                    },
                    {
                        shaderLocation: 2, // @location(2)
                        offset: quadUVOffset,
                        format: 'float32x2'
                    }
                ]
            },
            // buffer = 1
            {
                arrayStride: instancingVertexSize, // インスタンシングごとの頂点データの大きさ
                stepMode: 'instance', // Bufferをインスタンシングとして扱う
                attributes: [
                    // インスタンシングごとのデータ。今回はpositionとvelocityを格納したBufferを扱う
                    {
                        shaderLocation: 3, // @location(3)
                        offset: 0,
                        format: 'float32x4'
                    },
                    {
                        shaderLocation: 4, // @location(4)
                        offset: 4 * 4,
                        format: 'float32x4'
                    }
                ]
            }
        ]
    },
    ...
});

頂点シェーダーはこのような構造になっています。

struct Particle {
    position: vec4<f32>,
    velocity: vec4<f32>
};

@group(1) @binding(0) 
var<storage, read> instanceData : array<Particle>;
  
...  
@vertex 
fn main(
    // buffer=0の頂点データを展開
    @location(0) position : vec4<f32>, // ビルボードのオフセット方向
    @location(1) color : vec4<f32>,
    // buffer=1のインスタンスデータを展開
    @location(2) uv: vec2<f32>,
    @location(3) instancePosition: vec4<f32>,
    @location(4) instanceVelocity: vec4<f32>
) -> VertexOutput {
     ...

仮に、板ポリの描画用の情報が入ったBufferを particleVerticesBuffer、インスタンシングのデータが入った Buffer を particleInstancesBuffer とすると、以下のようにBufferを登録することができます。

...

const renderPassEncoder = renderCommandEncoder.beginRenderPass(renderPassDescriptor);
renderPassEncoder.setPipeline(particleRenderPipeline);

... uniforms の設定など

renderPassEncoder.setVertexBuffer(0, particleVerticesBuffer); // UVなど板ポリの情報が入った頂点のBufferをindex=0に登録
renderPassEncoder.setVertexBuffer(1, particleInstancesBuffer); // 全てのインスタンシングのデータが詰まったBufferをindex=1に登録

// インデックスを使ったインスタンシング描画
renderPassEncoder.setIndexBuffer(quadIndicesBuffer, 'uint16');
renderPassEncoder.drawIndexed(quadIndexArray.length, instanceNum);

そして、この particleInstancesBuffer を Compute Shader で直接更新していきます。

Compute Shader で実装

まずは Compute Shader の実行関数から見ていきましょう。

curlNoiseという関数の結果から速度を算出しています。

struct Instance {
    position: vec4<f32>,
    velocity: vec4<f32>
};

@group(0) @binding(0)
var<uniform> uniforms : Uniforms;
            
@group(0) @binding(1)
var<storage, read_write> input : array<Instance>; // インスタンスのバッファを読み込み、書き込み先にも使う

...

// jsからシェーダーを template literal として書き、workGroupSizeを埋め込む
// 引数が一つの場合、一次元になる
@compute @workgroup_size(${workgroupSize}) 

fn main(
    @builtin(global_invocation_id) global_invocation_id: vec3<u32>
) {
    let id = global_invocation_id.x; // 一次元なので、global_invocation_id をそのままインスタンスIDとして扱うことができる
    if(id < arrayLength(&input)) {
        let instance = input[id];
        let currentPosition = instance.position.xyz;
        let currentVelocity = instance.velocity.xyz;
        let force = curlNoise(currentPosition.xyz * uniforms.noiseScale) - currentVelocity;
        let newVelocity = force * uniforms.speed * uniforms.deltaTime;
        let newPosition = currentPosition + newVelocity;
        input[id].position = vec4<f32>(newPosition.xyz, 1.);
        input[id].velocity = vec4<f32>(newVelocity.xyz, 1.);
    }
}

javascript 側はこのようになっています。

...

const computeBindGroup = gDevice.createBindGroup({
    layout: computePipeline.getBindGroupLayout(0), // @group(0)
    entries: [
        {
            binding: 0, // @binding(0)
            resource: {
                buffer: computeUniformBuffer
            },
        },
        {
            binding: 1, // @binding(1)
            resource: {
                buffer: particleInstancesBuffer
            },
        },
    ]
});

...

const computeCommandEncoder = gDevice.createCommandEncoder();
const computePassEncoder = computeCommandEncoder.beginComputePass();
computePassEncoder.setPipeline(computePipeline);
computePassEncoder.setBindGroup(0, computeBindGroup);
computePassEncoder.dispatchWorkgroups(dispatchGroupSize); // 引数が一個なら一次元の実行
computePassEncoder.end();
gDevice.queue.submit([computeCommandEncoder.finish()]);

今回の実装では、workGroupとdispatchがどちらも一次元なので、
実行されるスレッド数は workgroup_size * dispatchWorkgroupsの数 となります。
この数が描画したいパーティクルの数と等しくなるように扱うことで、表示されているパーティクルをすべて更新することが可能となります。

それぞれ上限が決まっており、論理デバイスから値を取得することができます。
workGroupSizeは3次元を、dispatchWorkgroup は1つの数値を取得することができます(なので、xyzすべて同じ値だと予想します)。

以下、参考コードです。

const gAdapter = await navigator.gpu.requestAdapter(); // 物理デバイス
const gDevice = await gAdapter.requestDevice(); // 論理デバイス

// デバイスの制限値を取得
const limits = gDevice.limits;

// 手元のMacbookPro M1では256,64,64
console.log("Max Compute Workgroup Size:", {
    x: limits.maxComputeWorkgroupSizeX,
    y: limits.maxComputeWorkgroupSizeY,
    z: limits.maxComputeWorkgroupSizeZ,
});

// 手元のMacbookPro M1では65536
console.log("Max Compute Workgroups Per Dimension:",
    limits.maxComputeWorkgroupsPerDimension,
);

手元のMacbookProではそれぞれ x:256,y:64,z:64、65536でした。
つまり、workGroupSizeとdispatchWorkgroupを一次元で扱おうとする場合は 256 * 65536 = 16777216 のスレッドを同時に取り扱うことができる、ということになります。


Curl Noise の計算

論文ではベクトル場の生成に Perlin Noise が使われています。

今回の実装では Simplex Noise を使うこととします。
Simple Noise の実装はこちらを元にさせていただきます。

fn curlNoise(position: vec3<f32>) -> vec3<f32> {
    let eps = 0.001;
    let eps2 = 2.0 * eps;
    let invEps2 = 1.0 / eps2;
    let dx = vec3<f32>(eps, 0.0, 0.0);
    let dy = vec3<f32>(0.0, eps, 0.0);
    let dz = vec3<f32>(0.0, 0.0, eps);
     
    // 勾配算出のため、epsだけずらした地点のベクトルを算出  
    let px0 = snoise3(position - dx);
    let px1 = snoise3(position + dx);
    let py0 = snoise3(position - dy);
    let py1 = snoise3(position + dy);
    let pz0 = snoise3(position - dz);
    let pz1 = snoise3(position + dz);

    // 回転
    let x = (py1.z - py0.z) - (pz1.y - pz0.y);
    let y = (pz1.x - pz0.x) - (px1.z - px0.z);
    let z = (px1.y - px0.y) - (py1.x - py0.x);

    return vec3<f32>(x, y, z) * invEps2;
}

入力に使う座標からxyzを少しずらした位置のベクトルをベクトル場から取り出し、勾配を求め、ベクトルの回転をかける計算です。

simplex noise の調整

simplex noise の元の実装が vec3 の引数を元に float を返す実装になっているので、x,y,zそれぞれにsnoiseの結果を割り当てることにします。

このときポイントとなるのが、非連続になりづらいように「大きくオフセットした」ノイズの値にすることのようです。
おそらく、元のノイズのパターンの影響を受けづらいようにするためなのかなと思います。

fn snoise3(v: vec3<f32>) -> vec3<f32> {
    return vec3<f32>(
        snoise(v),
        snoise(v + vec3<f32>(100., 200., 300.)), // 任意のオフセット量を適用
        snoise(v + vec3<f32>(400., 500., 600.))
    );
}

発展

Volume Texture を使うことによって、ベクトル場をあらかじめ生成したものとして使用することができます。

なにかしらのツールであらかじめ生成した Volume Texture を使うことができれば、アーティストによるコントロールのしやすさは増す可能性があります。

また、計算負荷削減にもつながるかもしれません。今は、位置更新の計算のために snoise の計算をスレッドごとに 18 回行っています。これがパーティクルの数に比例した計算量となります。

Volume Texture を使う欠点としては、xyzのグリッド数によって比例的に容量が増えること、シェーダー内でノイズを計算するよりは動きのパターンが見えやすくなってしまうことでしょうか。

シェーダー内ですべて計算してしまうか、それとも Volume Texture を使うかは実際の使用する状況に応じての判断になるかなと思います。

最後に

WebGPU x Compute Shader 楽しいですね!

主要ブラウザにおけるWebGPU対応はまだまだ後になりそうですが、ちょっとした実験だったり、Compute Shader を活かしたツール作成では今からでも導入することができると思うので、夢が広がります。

今回、たくさんの記事を参考にさせていただきました。ありがとうございました。

カヤックでは WebGL / WebGPU を触りたいエンジニアを募集しています!

参考

WebGPU 関連

WebGPU入門
WebGPU Fundamentals
WebGPUでComputeShader #JavaScript - Qiita
WebGPU - コアの全てを canvas 抜きで (翻訳) - inzkyk.xyz
WebGPU Report
GLSL2WGSL

curl noise 関連

https://www.cs.ubc.ca/~rbridson/docs/bridson-siggraph2007-curlnoise.pdf
GitHub - IndieVisualLab/UnityGraphicsProgramming2: 書籍「UnityGraphicsProgramming vol.2」のサンプルコードリポジトリ
UnityのCompute ShaderでCurl Noiseを実装(流体編) - e.blog
非圧縮性流れ - Wikipedia

simplex noise 関連

webgl-noise/src/noise3D.glsl at master · stegu/webgl-noise · GitHub

数学関連

rot [物理のかぎしっぽ]
https://manabitimes.jp/physics/196