Aizu-Progressive xr Lab blog

会津大学のVR部であるA-PxLの部員が持ち回りで投稿していくブログです。部員がそれぞれVRに関する出来事やVRにちなんだことについて学んだことを書いていきます。

「面白法人カヤックVR分室」としても活動しています。詳細はこちら

三次元極座標系でパーティクル

学部4年のユムルです。

最近Shaderをかいたり、VRのMV (Music Video) を作ったりして遊んでます。
作った物をVRSNSのVRChatにて人に見せたりして楽しんでいるのですが、最近ブタジエン(https://twitter.com/butadiene121)さんのシェーダーパーティクルに魅せられて、真似して次のようなシェーダーパーティクルを作りました。

これの解説をします。

パーティクルとして使うメッシュと表現の決定

今回はCubeのメッシュをもとに、次の表現を使うことにします。

f:id:aizu-vr:20200904223913p:plain
一つのパーティクル表現

書いたShaderは次のものです。

Shader "PolarParticle/Cube1" {
  Properties { }
  SubShader {
    Tags { "RenderType"="Transparent" "Queue"="Transparent" } // 透明
    Cull off
    Blend SrcAlpha One
    ZWrite Off
    LOD 100

    Pass {
      CGPROGRAM
      #pragma target 5.0
      #pragma vertex VS
      #pragma fragment FS
      
      #include "UnityCG.cginc"

      struct VS_OUT {
        float4 vertex : SV_Position;
        float4 texcoord : TEXCOORD0;
        float4 color : COLOR;
      };

      VS_OUT VS(appdata_full v) { 
        VS_OUT o;
        o.vertex = UnityObjectToClipPos(v.vertex);
        o.texcoord = v.texcoord;
        o.color = float4(0, 1, 1, 1);
        return o;
      }

      float4 FS(VS_OUT i) : SV_Target {
        float2 uv = i.texcoord.xy;
        float2 p = uv * 2.0 - 1.0;
        float t = 0.01 / min(abs(abs(p.x) - 1.0), abs(abs(p.y) - 1.0));
        t = saturate(t);
        return float4(i.color.xyz, t);
      }
      ENDCG
    }
  }
}

メッシュの大量複製

メッシュをパーティクルとして扱うために、メッシュを大量にコピーしたいです。

私はこれをするために2種類の方法を知っています。 普通は次の方法1を使えば良いと思いますが、VRChatではそれをすることはできないので方法2を使いました。

それぞれの方法で試しに100万個のCubeを表示してみますが、後につくる極座標のパーティクルは1024個で作成します。

方法1

次の記事を参考にします。 shitakami.hatenablog.com feelingames.hatenablog.com

次のコードを書きます。

using UnityEngine;

public class GPUInstance : MonoBehaviour {
  public Mesh mesh;
  public Material material;
  public Bounds bounds;
  public int instanceNum;
  public ComputeBuffer buffer;

  void Start() {
    var args = new uint[5] { 0, 0, 0, 0, 0 };
    args[0] = mesh != null ? (uint) mesh.GetIndexCount(0) : 0;
    args[1] = (uint) instanceNum;
    buffer = new ComputeBuffer(1, args.Length * sizeof(uint), ComputeBufferType.IndirectArguments);
    buffer.SetData(args);
  }

  void Update() {
    Graphics.DrawMeshInstancedIndirect(
      mesh,
      0,
      material,
      bounds,
      buffer);
  }

  void OnDestroy() {
    if (buffer != null) buffer.Release();
  }
}

取り敢えず大量のCubeを規則正しく並べるシェーダーを書きます、

Shader "PolarParticle/ManyCube" {
  Properties { }
  SubShader {
    Tags { "RenderType"="Transparent" "Queue"="Transparent" }
    Cull off
    Blend SrcAlpha One
    ZWrite Off
    LOD 100

    Pass {
      CGPROGRAM
      #pragma target 5.0
      #pragma vertex VS
      #pragma fragment FS
      
      #include "UnityCG.cginc"

      struct VS_OUT {
        float4 vertex : SV_Position;
        float4 texcoord : TEXCOORD0;
        float4 color : COLOR;
      };

      static uint particleNum = 1000000; // パーティクルの数
      VS_OUT VS(appdata_base v, uint instanceId : SV_InstanceID) {
        VS_OUT o;
        uint id = instanceId;  // パーティクルのID
        uint n = pow(particleNum, 1.0 / 3.0);
        float particleSize = 0.1; // パーティクルのサイズ
        float interval = 1; // パーティクル間の距離
        float zoneLength = (n-1) * interval; // 全体の範囲の長さ
        uint3 axisId = uint3( // 極ごとのID
          id % n,
          id / n % n,
          id / (n * n));
        float3 pos = (float3)(-zoneLength / 2) + axisId * interval;
        v.vertex.xyz = v.vertex.xyz * particleSize + pos;
        o.vertex = UnityObjectToClipPos(v.vertex);
        o.texcoord = v.texcoord;
        o.color = float4(0, 1, 1, 1);
        return o;
      }

      float4 FS(VS_OUT i) : SV_Target {
        float2 uv = i.texcoord.xy;
        float2 p = uv * 2.0 - 1.0;
        float a = 0.01 / min(abs(abs(p.x) - 1.0), abs(abs(p.y) - 1.0));
        a = saturate(a);
        return float4(i.color.rgb, a);
      }
      ENDCG
    }
  }
}

書いたシェーダからマテリアルを作成し、GameObjectに先ほどのC#のコードをアタッチして、次のように100万個のCubeが出るように設定します。

f:id:aizu-vr:20200905185451p:plain

シーンを再生すると、100万個のCubeの位置が頂点シェーダーによって決められているのを確認できます。 f:id:aizu-vr:20200905185554p:plain

後のパーティクルの為、今回はInstance Numを1024に設定してください。

方法2

VRChatでメッシュのパーティクルを使う場合は、C#によるスクリプトを組み込んだ作品をアップロードできません。したがって、方法1も使えません。 代わりに間接的にC#を用いて、Meshを複製してアセットファイル化するスクリプトを書き、更にジオメトリシェーダーの[instance(31)]にて31倍にメッシュを増やします。

メッシュを複製したアセットファイルを作成するため、C#のエディタ拡張を書きます

using System.Linq;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
#if UNITY_EDITOR
using UnityEditor;

public class MeshCopy : EditorWindow {
  [MenuItem("MyEditor/MeshCopy")]
  static void Open() {
    GetWindow<MeshCopy>("MeshCopy");
  }

  Mesh useMesh;  // コピー元のメッシュ
  int copyN; // 複製する数

  void OnGUI() {
    useMesh = (Mesh) EditorGUILayout.ObjectField("Use Mesh", useMesh, typeof(Mesh), false);
    copyN = EditorGUILayout.IntField("Copy Num", copyN);
    if (GUILayout.Button("Mesh N Copy")) {
      MeshNCopy();
    }
  }

  void MeshNCopy() {
    var vertices = new List<Vector3>();
    var texcoord = new List<Vector3>();
    var triangles = new List<int>();
    var normals = new List<Vector3>();
    var tangents = new List<Vector4>();
    var colors = new List<Color>();
    // ここのループでメッシュをコピー、texcoordのz値にidを入れておく
    for (int i = 0; i < copyN; i++) {
      vertices.AddRange(useMesh.vertices);
      texcoord.AddRange(useMesh.uv.Select(v => new Vector3(v.x, v.y, i)));
      triangles.AddRange(useMesh.triangles
        .Select(index => index + i * useMesh.vertexCount));
      normals.AddRange(useMesh.normals);
      tangents.AddRange(useMesh.tangents);
      colors.AddRange(useMesh.colors);
    }
    var mesh = new Mesh();
    mesh.indexFormat = UnityEngine.Rendering.IndexFormat.UInt32;
        // indexFormatを変えておかないとtrianglesの値の上限を余裕で超えてしまう
    mesh.SetUVs(0, texcoord);
    mesh.triangles = triangles.ToArray();
    mesh.normals = normals.ToArray();
    mesh.tangents = tangents.ToArray();
    mesh.colors = colors.ToArray();
    string path = string.Format("Assets/{0}x{1}.asset", useMesh.name, copyN);
    AssetDatabase.CreateAsset(mesh, path);
    AssetDatabase.SaveAssets();
  }
}
#endif

Unity上部のメニューにある、MyEditor -> MeshCopyを開き、Cubeのメッシュを設定し、後でメッシュを更に31倍するので、Copy Numを100万 / 31 = 約33000個 に設定しボタンを押します。
すると、Assets直下に33000個のCubeメッシュのアセットファイルが生成されます。
とはいえこの時点で、メッシュファイルのサイズは81MBになってしまうので、実用的に考えるならCube1024個で2MBくらいのものになるでしょう。

f:id:aizu-vr:20200904234839p:plain

Shaderの方は次のようです。こちらもcubeを規則正しく並べておきます。 メッシュのサイズを抑え、ジオメトリシェーダのinstanceにて更に31倍までポリゴンを増やすことができます。 頂点シェーダーVSは値をそのまま返すのみにし、ジオメトリシェーダGSにて頂点位置を決定します。

Shader "PolarParticle/ManyCube2" {
  Properties { }
  SubShader {
    Tags { "RenderType"="Transparent" "Queue"="Transparent" }
    Cull off
    Blend SrcAlpha One
    ZWrite Off
    LOD 100

    Pass {
      CGPROGRAM
      #pragma target 5.0
      #pragma vertex VS
      #pragma geometry GS
      #pragma fragment FS
      
      #include "UnityCG.cginc"

      struct GS_OUT {
        float4 vertex : SV_Position;
        float4 texcoord : TEXCOORD0;
        float4 color : COLOR;
      };

      appdata_base VS(appdata_base v) { return v; }

      #define instanceN 31
      [instance(instanceN)]
      [maxvertexcount(3)]
      void GS(triangle appdata_base i[3], inout TriangleStream<GS_OUT> stream, uint gsid : SV_GSInstanceID) {
        for (int j = 0; j < 3; j++) {
          appdata_base v = i[j];
          GS_OUT o;
          uint id = gsid + instanceN * v.texcoord.z;  // パーティクルのIDを計算
          uint n = pow(33000 * instanceN, 1.0/3.0);
          float particleSize = 0.1;
          float interval = 1;
          float zoneLength = (n-1) * interval;
          uint3 axisId = uint3(
            id % n,
            id / n % n,
            id / (n * n));
          float3 pos = (float3)(-zoneLength / 2) + axisId * interval;
          v.vertex.xyz = v.vertex.xyz * particleSize + pos;
          o.vertex = UnityObjectToClipPos(v.vertex);
          o.texcoord = v.texcoord;
          o.color = float4(0, 1, 1, 1);
          stream.Append(o);
        }
        stream.RestartStrip();
      }

      float4 FS(GS_OUT i) : SV_Target {
        float2 uv = i.texcoord.xy;
        float2 p = uv * 2.0 - 1.0;
        float a = 0.01 / min(abs(abs(p.x) - 1.0), abs(abs(p.y) - 1.0));
        a = saturate(a);
        return float4(i.color.rgb, a);
      }
      ENDCG
    }
  }
}

SkinnedMeshRendererにてBoundsを設定しつつ使います。

方法1との違いは、頂点シェーダーでなくジオメトリシェーダを使うこと、idの設定に一手間加えることです。
以降のシェーダーのコードは方法1を使う場合のコードとします。もし、VRChatのために方法2を使う人は読み変えてください。

後のパーティクルの為、今回は1024個のパーティクルを表示する為に1024/16 = 64個のCubeメッシュを生成し、後でジオメトリのinstanceにて16倍するように設定してください。

極座標系によるパーティクルの位置決定

x座標、y座標をもとに、座標を決定する座標系を直交座標と言いますが、
極座標系という座標系も存在します。

極座標系は原点からの距離rと、x軸から反時計回りへの角度θによる(r, θ)で座標を決定します。

mathtrain.jp

原点からのrをsinやcosなどの三角関数と共に角度によって適当に決めるようにすると、綺麗な模様が描けます

www.desmos.com

二次元の極座標系(r, θ)を直交座標系(x, y)に変換する式は次の通りです

x = r cosθ
y = r sinθ

今回のパーティクルではこれを三次元で考えます。

mathtrain.jp

三次元極座標は、原点からの距離r、角度はz軸からの角度θとx軸からの角度φによる(r, θ, Φ)によって決まるようです。 今回はUnityのシェーダーで実装しますから、数学でz軸であるところはy軸、y軸のところがz軸であると考えます。 したがって、原点からの距離r、y軸からの角度θ, x軸からの角度Φで考え、 三次元直交座標系(x, y, z)への変換は次のようです。

x = r sinθ cosφ
y = r cosθ
z = r sinθ sinφ

ではシェーダを書いていきます。
パーティクルのidをもとに、パーティクルの位置を極座標系を用いて算出する関数を用意します。
最初に極座標系(r, θ, Φ)の変数と直交座標系への変換のコードを書いておきます。

#define PI UNITY_PI
void particleTransform(uint id, out float3 pos) {
  float r = 1;
  float theta = 0;
  float phi = 0;
  pos = float3(
    r * sin(theta) * cos(phi),
    r * cos(theta),
    r * sin(theta) * sin(phi));
}

さて、二次元極座標において適当なグラフを示したときはr をθをもとに決定していましたが、
今回はidの値が元になります。
したがって、idの値を適当に変数 tに入れて扱うことにします。 `` float t = id * 0.01; θをπ / 2にしておくと、phiの値によりxz座標上の値になります

float t = id * 0.01;
float r = 1;
float theta = PI / 2;
float phi = t;

また、作成した関数を頂点シェーダに適応します。

VS_OUT VS(appdata_base v, uint instanceId : SV_InstanceID) {
  VS_OUT o;
  uint id = instanceId;
  float particleSize = 0.05;
  float3 pos;
  particleTransform(id, pos);
  v.vertex.xyz = v.vertex.xyz * particleSize + pos;
  o.vertex = UnityObjectToClipPos(v.vertex);
  o.texcoord = v.texcoord;
  o.color = float4(0, 1, 1, 1);
  return o;
}

f:id:aizu-vr:20200905191523p:plain

θの値を適当なサイン波にしてみます。
float theta = PI / 2 + sin(5.5 * t) * 0.2;

f:id:aizu-vr:20200905191558p:plain

Φの値も適当なサイン波にしてみます。
float phi = sin(t) * 3 * PI;

f:id:aizu-vr:20200905191621p:plain

θのサイン波の大きさを敢えて小さくしていましたが、試しに大きくしてみます。
float theta = PI / 2 + sin(5.5 * t) * 0.7;

f:id:aizu-vr:20200905191832p:plain

止まっていてもつまらないので、時間によって変化するようにします。
Φの値を時間によって変化させてみます。
float phi = sin(t) * 3 * PI + sin(t + _Time.y * 0.3) * 3;

f:id:aizu-vr:20200905193044g:plain

なかなかかっこよくなってきました。

tの値、rの値も時間によって変化させてみます。
float t = id * 0.01 + _Time.y * 0.1;
float r = 0.2 + sin(3 * t);

f:id:aizu-vr:20200905193744g:plain

パーティクルが全て同じ方向を向いているので、回転の設定をします。

wgld.org

この記事を参考に、回転の関数を書きます。行列のままの方が扱いやすいので行列のまま値の受け渡しを行います。

float3x3 rotate(float angle, float3 axis) {
  float3 a = normalize(axis);
  float s = sin(angle);
  float c = cos(angle);
  float r = 1.0 - c;
  return float3x3(
    a.x * a.x * r + c, a.y * a.x * r + a.z * s, a.z * a.x * r - a.y * s,
    a.x * a.y * r - a.z * s, a.y * a.y * r + c, a.z * a.y * r + a.x * s,
    a.x * a.z * r + a.y * s, a.y * a.z * r - a.x * s, a.z * a.z * r + c);
}

また、サイズと色の設定も適当に設定します。

#define PI UNITY_PI
void particleTransform(uint id, out float3 pos, out float3x3 rot, out float size, out float3 color) {
    // out 変数追加
  float t = id * 0.01 + _Time.y * 0.1;;
  float r = 0.2 + sin(3 * t);
  float theta = PI / 2 + sin(5.5 * t) * 0.7;
  float phi = sin(t) * 3 * PI + sin(t + _Time.y * 0.3) * 3;
  pos = float3(
    r * sin(theta) * cos(phi),
    r * cos(theta),
    r * sin(theta) * sin(phi));
  // rot, size, color設定
  rot = mul(rotate(theta, float3(1, 0, 0)), rotate(phi - _Time.y * 3, float3(0, 1, 0)));
  size = cos(5.3 * t + _Time.y * 3.0) * 0.05;
  color = float3(sin(2.4 + t + _Time.y * 0.6), sin(0.7 + 2 * t - _Time.y * 0.6), sin(3.0 * t + _Time.y * 0.7)) / 2 + 0.5;
}

頂点シェーダも書き変えます。

VS_OUT VS(appdata_base v, uint instanceId : SV_InstanceID) {
  VS_OUT o;
  uint id = instanceId;
  float particleSize = 0.05;
  float3 pos;
  float3x3 rot;
  float size;
  float3 color;
  particleTransform(id, pos, rot, size, color);
  v.vertex.xyz = mul(rot, v.vertex.xyz * size) + pos;
  o.vertex = UnityObjectToClipPos(v.vertex);
  o.texcoord = v.texcoord;
  o.color = float4(color, 1);
  return o;
}

f:id:aizu-vr:20200905194539g:plain

かっこいいのができあがりました。

コードの全体は次のようです。

Shader "PolarParticle/Test" {
  Properties { }
  SubShader {
    Tags { "RenderType"="Transparent" "Queue"="Transparent" }
    Cull off
    Blend SrcAlpha One
    ZWrite Off
    LOD 100

    Pass {
      CGPROGRAM
      #pragma target 5.0
      #pragma vertex VS
      #pragma fragment FS
      
      #include "UnityCG.cginc"

      struct VS_OUT {
        float4 vertex : SV_Position;
        float4 texcoord : TEXCOORD0;
        float4 color : COLOR;
      };

      float3x3 rotate(float angle, float3 axis) {
        float3 a = normalize(axis);
        float s = sin(angle);
        float c = cos(angle);
        float r = 1.0 - c;
        return float3x3(
          a.x * a.x * r + c, a.y * a.x * r + a.z * s, a.z * a.x * r - a.y * s,
          a.x * a.y * r - a.z * s, a.y * a.y * r + c, a.z * a.y * r + a.x * s,
          a.x * a.z * r + a.y * s, a.y * a.z * r - a.x * s, a.z * a.z * r + c);
      }

      #define PI UNITY_PI
      void particleTransform(uint id, out float3 pos, out float3x3 rot, out float size, out float3 color) {
        float t = id * 0.01 + _Time.y * 0.1;;
        float r = 0.2 + sin(3 * t);
        float theta = PI / 2 + sin(5.5 * t) * 0.7;
        float phi = sin(t) * 3 * PI + sin(t + _Time.y * 0.3) * 3;
        pos = float3(
          r * sin(theta) * cos(phi),
          r * cos(theta),
          r * sin(theta) * sin(phi));
        rot = mul(rotate(theta, float3(1, 0, 0)), rotate(phi - _Time.y * 3, float3(0, 1, 0)));
        size = cos(5.3 * t + _Time.y * 3.0) * 0.05;
        color = float3(sin(2.4 + t + _Time.y * 0.6), sin(0.7 + 2 * t - _Time.y * 0.6), sin(3.0 * t + _Time.y * 0.7)) / 2 + 0.5;
      }

      VS_OUT VS(appdata_base v, uint instanceId : SV_InstanceID) {
        VS_OUT o;
        uint id = instanceId;
        float particleSize = 0.05;
        float3 pos;
        float3x3 rot;
        float size;
        float3 color;
        particleTransform(id, pos, rot, size, color);
        v.vertex.xyz = mul(rot, v.vertex.xyz * size) + pos;
        o.vertex = UnityObjectToClipPos(v.vertex);
        o.texcoord = v.texcoord;
        o.color = float4(color, 1);
        return o;
      }

      float4 FS(VS_OUT i) : SV_Target {
        float2 uv = i.texcoord.xy;
        float2 p = uv * 2.0 - 1.0;
        float a = 0.01 / min(abs(abs(p.x) - 1.0), abs(abs(p.y) - 1.0));
        a = saturate(a);
        return float4(i.color.rgb, a);
      }
      ENDCG
    }
  }
}

会津大学VR部の部員が持ち回りで投稿していくブログです。特にテーマに縛りを設けずに書いていきます!