UnityのCommandBufferでGBufferを触ってビルボードで球のように見えるパーティクルを作る

はじめに

前回はCommandBufferでGBufferに書き込むことで板ポリに見せかけの球を描画してみました。 今回はその前回作った板ポリに球を描画するもののパーティクル版を作ってみます。

今回の実行結果

Unityのバージョン:2018.3.0b1

前回の見せかけの球をパーティクルに拡張する

今回は、毎フレームパーティクルの姿勢や位置を取得して、 その位置に前回と同じ方法でポリゴンを表示する形で実装します。

パーティクルの準備

Hierachyを右クリックして「Effect > Particle System」で パーティクルシステムをシーンに追加します。

パーティクルシステムを追加

パーティクルシステムのインスペクタから設定を行っていきます。

シミュレーションスペースの変更

シミュレーションスペースをデフォルトの「Local」から「World」に変更しました。 今回はパーティクルシステムを動かしたりしないのでどちらでもよいです。 「Emission」と「Shape」はデフォルトのままです。

「Force over lifetime」と「Collision」の設定を行います。

物理の設定

これでパーティクルに重力が働きぶつかると反射するようになりました。

レンダリングは自前で行うのでパーティクルシステム側では 何も描画しない設定にしておきます。

レンダリングはオフにする

各パーティクルのTRS行列を取得する

毎フレームパーティクルの位置を取得しTRS行列を生成するコードを作ります。

まずはパーティクルの位置を毎フレーム取得するコードを書いてみます。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class GBufferSphereParticle : MonoBehaviour {

  private ParticleSystem.Particle[] particles;

  public new ParticleSystem particleSystem;

  private void Start () {
    particles = new ParticleSystem.Particle[particleSystem.main.maxParticles];
  }

  private void Update () {
    var numParticleAlive = particleSystem.GetParticles (particles);
    for (var i = 0; i < numParticleAlive; i++) {
      var position = particles[i].position;
    }
  }
}

パーティクルシステムをインスペクタ上で設定してフィールドに渡します。

particleSystem.GetParticles (particles)でパーティクルを取得します。 毎フレーム配列を作るのは避けたいのでStartでパーティクルの最大数分の配列を用意しています。 particleSystem.GetParticles (particles)の戻り値で 現在Aliveなパーティクルの数が返ってくるので、for文でその数だけ回せば パーティクルの情報を取得できます。

このコードにちょっと書き加えて パーティクルをビルボードとして各パーティクルごとのTRS行列を取得してみます。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class GBufferSphereParticle : MonoBehaviour {

  private ParticleSystem.Particle[] particles;
  private Matrix4x4[] matrices;

  public new ParticleSystem particleSystem;

  private void Start () {
    particles = new ParticleSystem.Particle[particleSystem.main.maxParticles];
    matrices = new Matrix4x4[particleSystem.main.maxParticles];
    for (var i = 0; i < particleSystem.main.maxParticles; i++) {
      matrices[i] = new Matrix4x4 ();
    }
  }

  private void Update () {
    var numParticleAlive = particleSystem.GetParticles (particles);
    for (var i = 0; i < numParticleAlive; i++) {
      matrices[i].SetTRS (
        particles[i].position,
        camera.transform.rotation,
        Vector3.one
      );
    }
  }
}

行列も毎フレームインスタンスを作るのは避けたいので あらかじめ配列を作っておきます。 matrices[i].SetTRSで現在の行列を書き換えることで 毎フレームMatrix4x4のインスタンスを作らないようにしています。

CommandBuffer.DrawMeshInstanced

次は取得した行列の位置にポリゴンを描画するようにします。 前回作成したGBufferに書き込むコードを再利用します。

コード全文は次のとおりです。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;

public class GBufferSphereParticle : MonoBehaviour {

  private Mesh quad;
  private Material material;
  private int gBuffer0ColorPropertyID;
  private int gBuffer1ColorPropertyID;
  private int gBuffer3ColorPropertyID;
  private Dictionary<Camera, CommandBuffer> dict = new Dictionary<Camera, CommandBuffer> ();
  private ParticleSystem.Particle[] particles;
  private Matrix4x4[] matrices;

  [ColorUsage (showAlpha: false)]
  public Color albedo;
  [Range (0, 1)]
  public float occlusion;
  [ColorUsage (showAlpha: false)]
  public Color specular;
  [Range (0, 1)]
  public float smoothness;
  [ColorUsage (showAlpha: false, hdr: true)]
  public Color emission;
  public new ParticleSystem particleSystem;

  private void Start () {
    quad = new Mesh ();
    var vertices = new List<Vector3> {
      new Vector3 (0.5f, 0.5f, 0),
      new Vector3 (-0.5f, 0.5f, 0),
      new Vector3 (0.5f, -0.5f, 0),
      new Vector3 (-0.5f, -0.5f, 0),
    };
    var triangles = new List<int> {
      1,
      0,
      2,
      1,
      2,
      3
    };
    var uvs = new List<Vector2> {
      new Vector2 (1, 1),
      new Vector2 (0, 1),
      new Vector2 (1, 0),
      new Vector2 (0, 0),
    };
    quad.SetVertices (vertices);
    quad.SetTriangles (triangles, 0);
    quad.SetUVs (0, uvs);

    material = new Material (Shader.Find ("GBuffer/GBufferSphere"));
    material.enableInstancing = true;

    gBuffer0ColorPropertyID = Shader.PropertyToID ("_GBuffer0Color");
    gBuffer1ColorPropertyID = Shader.PropertyToID ("_GBuffer1Color");
    gBuffer3ColorPropertyID = Shader.PropertyToID ("_GBuffer3Color");

    particles = new ParticleSystem.Particle[particleSystem.main.maxParticles];
    matrices = new Matrix4x4[particleSystem.main.maxParticles];
    for (var i = 0; i < particleSystem.main.maxParticles; i++) {
      matrices[i] = new Matrix4x4 ();
    }
  }

  private void OnWillRenderObject () {
    var camera = Camera.current;
    if (camera == null) return;

    CommandBuffer buf;
    if (dict.ContainsKey (camera)) {
      buf = dict[camera];
    } else {
      buf = new CommandBuffer ();
      buf.name = "GBuffer Sphere";
      camera.AddCommandBuffer (CameraEvent.AfterGBuffer, buf);
      dict[camera] = buf;
    }

    buf.Clear ();

    var gBufferTarget = new [] {
      new RenderTargetIdentifier (BuiltinRenderTextureType.GBuffer0),
        new RenderTargetIdentifier (BuiltinRenderTextureType.GBuffer1),
        new RenderTargetIdentifier (BuiltinRenderTextureType.GBuffer2),
        new RenderTargetIdentifier (BuiltinRenderTextureType.CameraTarget),
    };

    buf.SetRenderTarget (gBufferTarget, BuiltinRenderTextureType.CameraTarget);

    material.SetVector (
      gBuffer0ColorPropertyID,
      new Vector4 (albedo.r, albedo.g, albedo.b, occlusion)
    );
    material.SetVector (
      gBuffer1ColorPropertyID,
      new Vector4 (specular.r, specular.g, specular.b, smoothness)
    );
    material.SetVector (
      gBuffer3ColorPropertyID,
      new Vector4 (emission.r, emission.g, emission.b, 0)
    );

    var numParticleAlive = particleSystem.GetParticles (particles);
    for (var i = 0; i < numParticleAlive; i++) {
      matrices[i].SetTRS (
        particles[i].position,
        camera.transform.rotation,
        Vector3.one
      );
    }

    buf.DrawMeshInstanced (quad, 0, material, 0, matrices, numParticleAlive, null);
  }
}

長いですがほとんどは前回作ったものですね。 各パーティクルのTRS行列を取得するところについても説明したとおりです。

前回と違うのは最後の部分で buf.DrawMeshではなくbuf.DrawMeshInstancedを使っている点です。 同じ形状のポリゴンを大量に描画するのでGPUインスタンシングを使っています。 GPUインスタンシングは1回の描画命令で大量に描画できるので 1回ずつ描画命令を発行するよりも効率が良くなります。 マテリアルに対してmaterial.enableInstancing = true; で インスタンシングを有効化しています。 各パーティクルのモデル行列Matrix4x4の配列を渡すことで 各インスタンスの位置や向き、大きさなどをパーティクルの位置にしています。

GPUインスタンシングに対応したシェーダの作成

前回作成したマテリアルはGPUインスタンシングに対応していないため、 このまま実行するとエラーが出てしまいます。 GPUインスタンシングするには対応したシェーダを書かなければなりません。

"GBuffer/GBufferSphereInstanced"というシェーダを新しく作り、 それを使う形にします。

// material = new Material (Shader.Find ("GBuffer/GBufferSphere"));
material = new Material (Shader.Find ("GBuffer/GBufferSphereInstanced"));

GPUインスタンシングに対応させたシェーダのコードがこちらです。

Shader "GBuffer/GBufferSphereInstanced"
{
  Properties
  {
    _GBuffer0Color ("GBuffer0 Color", Color) = (0, 0, 0, 0)
    _GBuffer1Color ("GBuffer1 Color", Color) = (0, 0, 0, 0)
    _GBuffer3Color ("GBuffer3 Color", Color) = (0, 0, 0, 0)
    _Radius ("Radius", float) = 1.0
  }
  SubShader
  {
    Pass
    {
      Stencil
      {
        Comp Always
        Pass Replace
        Ref 128
      }

      CGPROGRAM
      #pragma vertex vert
      #pragma fragment frag
      #pragma multi_compile_instancing

      #include "UnityCG.cginc"

      struct appdata
      {
        float4 vertex : POSITION;
        float3 normal : NORMAL;
        float2 uv : TEXCOORD0;
        UNITY_VERTEX_INPUT_INSTANCE_ID
      };

      struct v2f
      {
        float4 position : SV_POSITION;
        float3 normal : NORMAL;
        float2 uv : TEXCOORD0;
        float3 eyeSpacePos : TEXCOORD1;
      };

      struct flagout
      {
        float4 gBuffer0 : SV_TARGET0;
        float4 gBuffer1 : SV_TARGET1;
        float4 gBuffer2 : SV_TARGET2;
        float4 gBuffer3 : SV_TARGET3;
        float depth: SV_DEPTH;
      };

      uniform float4 _GBuffer0Color;
      uniform float4 _GBuffer1Color;
      uniform float4 _GBuffer3Color;
      uniform float _Radius;

      void vert (in appdata v, out v2f o)
      {
        UNITY_SETUP_INSTANCE_ID (v);

        o.position = UnityObjectToClipPos(v.vertex);
        o.normal = UnityObjectToWorldNormal(v.normal);
        o.uv = v.uv;
        o.eyeSpacePos = UnityObjectToViewPos(v.vertex);
      }

      void frag (in v2f i, out flagout o)
      {
        float3 eyeSpaceNormal;
        eyeSpaceNormal.xy = i.uv *2 - 1;
        float r2 = dot(eyeSpaceNormal.xy, eyeSpaceNormal.xy);
        if (r2 > 1.0) discard;
        eyeSpaceNormal.z = sqrt(1.0 - r2);

        float4 pixelPos = float4(i.eyeSpacePos + eyeSpaceNormal * _Radius, 1);
        float4 clipSpacePos = mul(UNITY_MATRIX_P, pixelPos);
        o.depth = clipSpacePos.z / clipSpacePos.w;

        float4 worldSpaceNormal = mul(
          transpose(UNITY_MATRIX_V),
          float4(eyeSpaceNormal.xyz, 0)
        );

        o.gBuffer0 = _GBuffer0Color;
        o.gBuffer1 = _GBuffer1Color;
        o.gBuffer2 = worldSpaceNormal * 0.5 + float4(0.5, 0.5, 0.5, 0);
        o.gBuffer3 = _GBuffer3Color;
      }
      ENDCG
    }
  }
}

前回からの変更点について見ていきます。

#pragma multicompileinstancing

CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma multi_compile_instancing

#include "UnityCG.cginc"

GPUインスタンシングに対応させるためには #pragma multi_compile_instancingを追加する必要があります。

appdata

struct appdata
{
  float4 vertex : POSITION;
  float3 normal : NORMAL;
  float2 uv : TEXCOORD0;
  UNITY_VERTEX_INPUT_INSTANCE_ID
};

頂点シェーダの入力にUNITY_VERTEX_INPUT_INSTANCE_IDというのを追加しています。

頂点シェーダ

void vert (in appdata v, out v2f o)
{
  UNITY_SETUP_INSTANCE_ID (v);

  o.position = UnityObjectToClipPos(v.vertex);
  o.normal = UnityObjectToWorldNormal(v.normal);
  o.uv = v.uv;
  o.eyeSpacePos = UnityObjectToViewPos(v.vertex);
}

頂点シェーダの先頭にUNITY_SETUP_INSTANCE_ID (v);を追加します。

追加したマクロについてはドキュメントに載っています。

実行結果

実行結果

パーティクルの位置に前回作った見せかけの球が表示されるようになりました。


パーティクルのサイズを反映させる

パーティクルのサイズを反映するようにしてみます。

パーティクルの設定

パーティクルの初期サイズを小さくします。

パーティクルの初期サイズを小さくする

パーティクルを時間経過でサイズが小さくなるようにしてみます。

パーティクルのサイズが小さくなるようにする

行列にパーティクルのサイズを反映させる

渡している行列にパーティクルのサイズを反映させます。

var numParticleAlive = particleSystem.GetParticles (particles);
for (var i = 0; i < numParticleAlive; i++) {
  var particleSize = particles[i].GetCurrentSize (particleSystem);
  matrices[i].SetTRS (
    particles[i].position,
    camera.transform.rotation,
    Vector3.one * particleSize
  );
}

_Radius

これだけではシェーダ内で使っている_Radiusが変わらないため正しく描画されません。 パーティクルのインスタンスごとに_Radiusを渡すようにします。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;

public class GBufferSphereParticle : MonoBehaviour {

  ...
  private int radiusID;
  ...
  private ParticleSystem.Particle[] particles;
  private Matrix4x4[] matrices;
  private float[] radiuses;
  ...

  private void Start () {

    ...

    radiusID = Shader.PropertyToID ("_Radius");

    ...

    particles = new ParticleSystem.Particle[particleSystem.main.maxParticles];
    matrices = new Matrix4x4[particleSystem.main.maxParticles];
    for (var i = 0; i < particleSystem.main.maxParticles; i++) {
      matrices[i] = new Matrix4x4 ();
    }
    radiuses = new float[particleSystem.main.maxParticles];
  }

  private void OnWillRenderObject () {

    ...

    var numParticleAlive = particleSystem.GetParticles (particles);
    for (var i = 0; i < numParticleAlive; i++) {
      var particleSize = particles[i].GetCurrentSize (particleSystem);
      matrices[i].SetTRS (
        particles[i].position,
        camera.transform.rotation,
        Vector3.one * particleSize
      );
      radiuses[i] = particleSize / 2;
    }

    var properties = new MaterialPropertyBlock ();
    properties.SetFloatArray (radiusID, radiuses);

    buf.DrawMeshInstanced (quad, 0, material, 0, matrices, numParticleAlive, properties);
  }
}

GPUインスタンシングを行っている各インスタンスに異なる値を渡すには MaterialPropertyBlockをつかうようです。 インスタンスごとにFloatを渡したい場合はSetFloatArrayに詰め込みます。

シェーダ側のコードも変更します。

Shader "GBuffer/GBufferSphereInstanced"
{
  Properties
  {
    _GBuffer0Color ("GBuffer0 Color", Color) = (0, 0, 0, 0)
    _GBuffer1Color ("GBuffer1 Color", Color) = (0, 0, 0, 0)
    _GBuffer3Color ("GBuffer3 Color", Color) = (0, 0, 0, 0)
  }
  SubShader
  {
    Pass
    {
      Stencil
      {
        Comp Always
        Pass Replace
        Ref 128
      }

      CGPROGRAM
      #pragma vertex vert
      #pragma fragment frag
      #pragma multi_compile_instancing

      #include "UnityCG.cginc"

      struct appdata
      {
        float4 vertex : POSITION;
        float3 normal : NORMAL;
        float2 uv : TEXCOORD0;
        UNITY_VERTEX_INPUT_INSTANCE_ID
      };

      struct v2f
      {
        float4 position : SV_POSITION;
        float3 normal : NORMAL;
        float2 uv : TEXCOORD0;
        float3 eyeSpacePos : TEXCOORD1;
        float radius : TEXCOORD2;
      };

      struct flagout
      {
        float4 gBuffer0 : SV_TARGET0;
        float4 gBuffer1 : SV_TARGET1;
        float4 gBuffer2 : SV_TARGET2;
        float4 gBuffer3 : SV_TARGET3;
        float depth: SV_DEPTH;
      };

      uniform float4 _GBuffer0Color;
      uniform float4 _GBuffer1Color;
      uniform float4 _GBuffer3Color;

      UNITY_INSTANCING_BUFFER_START(Props)
        UNITY_DEFINE_INSTANCED_PROP(float, _Radius)
      UNITY_INSTANCING_BUFFER_END(Props)


      void vert (in appdata v, out v2f o)
      {
        UNITY_SETUP_INSTANCE_ID (v);

        o.position = UnityObjectToClipPos(v.vertex);
        o.normal = UnityObjectToWorldNormal(v.normal);
        o.uv = v.uv;
        o.eyeSpacePos = UnityObjectToViewPos(v.vertex);
        o.radius = UNITY_ACCESS_INSTANCED_PROP(Props, _Radius);
      }

      void frag (in v2f i, out flagout o)
      {
        float3 eyeSpaceNormal;
        eyeSpaceNormal.xy = i.uv *2 - 1;
        float r2 = dot(eyeSpaceNormal.xy, eyeSpaceNormal.xy);
        if (r2 > 1.0) discard;
        eyeSpaceNormal.z = sqrt(1.0 - r2);

        float4 pixelPos = float4(i.eyeSpacePos + eyeSpaceNormal * i.radius, 1);
        float4 clipSpacePos = mul(UNITY_MATRIX_P, pixelPos);
        o.depth = clipSpacePos.z / clipSpacePos.w;

        float4 worldSpaceNormal = mul(
          transpose(UNITY_MATRIX_V),
          float4(eyeSpaceNormal.xyz, 0)
        );

        o.gBuffer0 = _GBuffer0Color;
        o.gBuffer1 = _GBuffer1Color;
        o.gBuffer2 = worldSpaceNormal * 0.5 + float4(0.5, 0.5, 0.5, 0);
        o.gBuffer3 = _GBuffer3Color;
      }
      ENDCG
    }
  }
}

プロパティの定義にUnityで用意されているマクロを利用します。

UNITY_INSTANCING_BUFFER_START(Props)
  UNITY_DEFINE_INSTANCED_PROP(float, _Radius)
UNITY_INSTANCING_BUFFER_END(Props)

インスタンスごとのプロパティはUNITY_INSTANCING_BUFFER_START(name)UNITY_INSTANCING_BUFFER_END(name)で挟んで UNITY_DEFINE_INSTANCED_PROP(float, _Radius)で定義します。

このプロパティを取得するにはUNITY_ACCESS_INSTANCED_PROP(arrayName, color)という マクロを利用します。

o.radius = UNITY_ACCESS_INSTANCED_PROP(Props, _Radius);

_Radiusを頂点シェーダで受け取ってフラグメントシェーダに渡しています。

// float4 pixelPos = float4(i.eyeSpacePos + eyeSpaceNormal * _Radius, 1);
float4 pixelPos = float4(i.eyeSpacePos + eyeSpaceNormal * i.radius, 1);
float4 clipSpacePos = mul(UNITY_MATRIX_P, pixelPos);
o.depth = clipSpacePos.z / clipSpacePos.w;

フラグメントシェーダでは_Radiusの代わりにi.raduisを使うように変更しました。

今回はインスタンスごとのプロパティへのアクセスを頂点シェーダで行いました。 フラグメントシェーダ内でインスタンスごとのプロパティにアクセスする場合には UNITY_TRANSFER_INSTANCE_ID(v, o)が必要になるようです。 詳しくはドキュメントを参照してください。

実行結果

実行してみると次のようになります。

実行結果

パーティクルの大きさがきちんと反映されています。

影の描画

パーティクルの影を描画するようにしてみます。

パーティクルのRender Modeを「None」から「Billboard」にします。 これでシャドウマップへ書き込むときにパーティクルがカメラの方を向いてくれます。 Cast Shadowsを「Shadows Only」に変更します。

パーティクルの設定

影用のマテリアルを作成します。

Shader "Unlit/SphereShadow"
{
  SubShader
  {

    Pass
    {
      Tags { "LightMode" = "ShadowCaster" }

      CGPROGRAM
      #pragma vertex vert
      #pragma fragment frag
      #pragma multi_compile_instancing

      #include "UnityCG.cginc"

      struct appdata
      {
        float4 vertex : POSITION;
        float2 uv : TEXCOORD0;
      };

      struct v2f
      {
        float2 uv : TEXCOORD0;
        float4 vertex : SV_POSITION;
      };

      v2f vert (appdata v)
      {
        v2f o;
        o.vertex = UnityObjectToClipPos(v.vertex);
        o.uv = v.uv;
        return o;
      }

      fixed4 frag (v2f i) : SV_Target
      {
        float2 r = i.uv * 2 - 1;
        float r2 = dot(r, r);
        if (r2 > 1.0) discard;

        return fixed4(0, 0, 0, 0);
      }
      ENDCG
    }
  }
}

フラグメントシェーダーで円を描画するマテリアルになっています。

fixed4 frag (v2f i) : SV_Target
{
  float2 r = i.uv * 2 - 1;
  float r2 = dot(r, r);
  if (r2 > 1.0) discard;

  return fixed4(0, 0, 0, 0);
}

ただの円でもそれっぽくなります。

このシェーダから作成したマテリアルを影描画用のマテリアルとして設定します。

マテリアルの設定

これで影が描画されるようになりました。

影が描画された

おわりに

今回は、前回作ったものをパーティクルに拡張してみました。

実は前回や今回のようにGBufferに書き込むだけならば、CommandBufferを使う必要はありません。 Tags { "LightMode" = "Deferred" }をパスに与えてやると そのパスの書き出し先はGBufferになります。 CommandBufferでレンダーターゲットをGBufferにしなくてもGBufferへの書き込みは実現できます。 今回わざわざCommandBufferを使ったのは次回作るものの布石です。 次回はCommandBufferを有効活用して流体のレンダリングを行ってみます。

今回の記事のソースコードはこちらのリポジトリにおいてあります。 CommandBufferを使わずにTags { "LightMode" = "Deferred" }で GBufferを書き出す例もおいてあるのでよければ見てみてください。

  • Unity
  • Shader