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を書き出す例もおいてあるのでよければ見てみてください。