本記事は https://github.com/tomotaco/ParticleSystemBeamShaderDemo にて公開済みの
「射線軸から見ても破綻しないビーム」をゼロから作っていく解説記事です。
「射線軸から見て破綻するビーム」とは
ParticleSystem のデフォルトの描画モードは Billboard です。これは常にカメラの方を向いた 1枚の四角ポリゴン(2枚の三角形)で 1つのパーティクルを表現します。一方、進行方向に伸びた形状にして疑似的にモーションブラーを表現する Stretched BillBoard というモードもあります。進行方向に対して横から見た時はとてもいい感じなのですが、進行方向の前後から見ると細くなってしまい、薄い板ポリゴンがバレてしまってとても残念なことになります。(下の図の右側、青いパーティクルが Stretched Billboard です)
この問題の解決策として、当時は3枚の板ポリゴンを互いに垂直になるように重ねて表現する方法もありましたが、余計に板ポリゴンが協調されてしまい悲しみが癒えることはありませんでした。
「射線軸から見ても破綻しないビーム」とは
パンツァードラグーン(セガサターン、1995年)やオメガブースト(プレイステーション、1999年)などのゲームで使用されている、速度ベクトルの方向に延びた楕円体をスプライトの変形により表現する手法です。(上の図の左側、黄色いパーティクルです)
ビームをエネルギーの球体と仮定するなら、進行方向から見てほぼ円形に見えるのはとても理にかなった表現です。ガイナックスのアニメ「王立宇宙軍オネアミスの翼(1987年)」「ふしぎの海のナディア(1990年)」でも弾道の表現に使われていましたので、ゲームの中で自由に撃ちまくれる!と当時心躍った方々は少なくないのではないでしょうか。
この手法をシェーダーにより実現する方法が、当時の開発者で現ユニティテクノロジーズジャパンの安原祐二氏により、Unite2016Tokyoの講演「ハードウェア性能を引き出して60fpsを実現するプログラミング・テクニック」にて「楕円体シェーダー」の名称で紹介されました。
具体的な実装については、github 内の下記ソースに書かれています。
(このプレゼンが発表された当時は、当時憧れたあの手法がついに公開された!と一人興奮していたのですが、自分の周囲ではあまり話題になっていなかったような気がします…orz)
Another Thread の楕円体シェーダーの描画方法
楕円体シェーダーでは、1つのビームの描画のため頂点シェーダーには4つの頂点が流れてきます。
そのうち2つには進行方向の先の座標が、残りの2つには進行方向の後ろの座標が2つ入っています。ここではそれぞれ P0, P1 と呼びます。つまり「進行方向にどれだけ伸ばすか」という情報は CPU で計算して与えていることになります。
また、座標の他に進行方向ベクトル(P0P1)、テクスチャのUV座標、パーティクルのサイズも入っています。
UV座標はテクスチャのどの位置に対応するかを0~1の値で表しますが、これは「ポリゴンのどちら側にあるか」を表すことになるので、パーティクルの頂点座標の計算にも使うことができます。
進行方向のベクトル P0P1 と、カメラからP0へのベクトルVeyeとの外積を求め、長さ1に正規化すると、上方向の単位ベクトル Vup が求まります。
さらに、VupとVeyeの外積を求め、長さ1に正規化すると、横方向の単位ベクトルVsideが求まります。VupとVeyeはそれぞれVeyeに対して垂直なので、VupとVeyeを組み合わせて得られる座標(P0を通り、VupとVeyeを含む平面)は画面に対してきつい角度になることがありません。
P0と Vup, Vside、さらにパーティクルのサイズからパーティクルの頂点座標Q0, Q1が求まります。P0の代わりにP1とVup, Vside から残りの Q2, Q3 が求まります。
Q0~Q3までの頂点が求まったら、後は普通にポリゴンを描画するだけです。つまり頂点シェーダが今回の主役で、フラグメントシェーダは特に手を入れる必要はありません。
ParticleSystem で実現するには
ParticleSystemでは頂点座標を速度に応じて伸ばして渡してくれる…なんてことは期待できませんが、代わりに色々なパーティクルの情報を頂点シェーダに渡すよう設定することができます。それが Custom Vertex Stream という仕組です。
デフォルト設定ではPosition・Color・UV・Normalが渡されていますが、
今回の目的にはNormalは不要、その代わりに速度ベクトル・サイズ(進行方向と垂直方向)が必要です。
また、Positionについては、同じ座標に集約した4頂点2ポリゴンのメッシュを渡せばシェーダ上で加工しやすいです。上記の図で言えば 4点とも P0 の座標が渡されることになりますが、P1は速度ベクトルと進行方向のサイズから計算可能なので問題ありません。
実際に作ってみる
今回ParticleSystemで実装する楕円体シェーダーは、ParticleSystem内蔵の Stretched Billboard でパーティクルを表示する設定から、改造する形で作っていくととても楽にできます。なのでまずは StretchedBillboard でパーティクルを飛ばしてみましょう。
まずはStretchedBillboard
Unity を立ち上げ、3D のプロジェクトを新規に作成します。
その後、Hierarchy の何もない所で右クリックし、Effects/Particle System を追加します。(これは、Particle System という空の GameObject を作成し、ParticleSystem コンポーネントをアタッチすることになります)
ParticleSystem が選択された状態で Inspector の Position, Rotation を以下の通りに設定します。
次に、Hierarchy から Main Camera を選択します。
Main Camera のインスペクタからPosition, Rotation を以下の通りに設定します。ついでに背景をまっ黒にしておくと結果の確認が容易です。ここで、カメラにパーティクルが映っていることを確認してください。
さらに、Hierarchy から ParticleSystemを選択し、ParticleSystemのインスペクタの下の方にある Renderer モジュールを開き、Render Mode を Stretched Billboard にして下さい。
上に戻ってメインモジュールを以下のように設定します。色を設定し、速度を速めにしていますがお好みで変えてもらって問題ありません。また、パーティクルの形状も進行方向に伸ばしつつ細めています。
Shapeモジュールもクリックして開き、以下のように設定します。(撃ち出し範囲を狭くしているだけなので、お好みで設定してもらって問題ありません)
Game View の絵を確認すると、パーティクルが画面奥に向かって飛んでいくのが確認できると思います。奥に行くに従い、パーティクルが細く残念な形状になっていることがわかります。
俺たちの戦いはこれからだ!(第一部完)
射線軸から見て破綻しないようにする
ここから本番です。パーティクルのシェーダーをゼロから書き起こすのは大変なので、Unity のサイトから、お使いの Unity と同じバージョンのビルトインシェーダーのソースを入手します。
ビルトインシェーダーのソースを入手
おもむろにブラウザを開き、"Unity Download Archive" の文字列で検索するとすぐ見つかると思います。見つかったら「ダウンロード(win)」のドロップダウンボックスをクリックし、「ビルトインシェーダー」を選ぶとダウンロードが始まります。
builtin_shaders-2018.2.14f1.zip を好きなフォルダで展開し、
DefaultResourcesExtra/Particle Add.shader をコピーし、Assets/Shaders/ ディレクトリでペースト、ファイル名を TacoParticleAddPass4Vertices.shader に変更します。
(Tacoなんとかは私の好みですので、好きな名前に変更して下さい)
ビルトインシェーダーのソースを改造
シェーダーの内容を以下の通りに書き換えます。(修正前のコードは "// (修正前)" を付けてコメントアウトし、修正後のコードは行末に "// (修正後)" をつけています)
// Unity built-in shader source. Copyright (c) 2016 Unity Technologies. MIT license (see license.txt)
// (修正前) Shader "Particles/Additive" {
Shader "TacoParticles/AdditivePass4Vertices" { // (修正後)
Properties {
_TintColor ("Tint Color", Color) = (0.5,0.5,0.5,0.5)
_MainTex ("Particle Texture", 2D) = "white" {}
_InvFade ("Soft Particles Factor", Range(0.01,3.0)) = 1.0
}
Category {
Tags { "Queue"="Transparent" "IgnoreProjector"="True" "RenderType"="Transparent" "PreviewType"="Plane" }
Blend SrcAlpha One
ColorMask RGB
Cull Off Lighting Off ZWrite Off
SubShader {
Pass {
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma target 2.0
#pragma multi_compile_particles
#pragma multi_compile_fog
#include "UnityCG.cginc"
sampler2D _MainTex;
fixed4 _TintColor;
struct appdata_t {
float4 vertex : POSITION;
fixed4 color : COLOR;
// (修正前) float2 texcoord : TEXCOORD0;
float4 texcood0 : TEXCOORD0; // uv = texcood0.xy, size = texcood0.zw // (修正後)
float4 texcood1 : TEXCOORD1; // velocity = texcood1.xyz// (修正後)
UNITY_VERTEX_INPUT_INSTANCE_ID
};
struct v2f {
float4 vertex : SV_POSITION;
fixed4 color : COLOR;
float2 texcoord : TEXCOORD0;
UNITY_FOG_COORDS(1)
#ifdef SOFTPARTICLES_ON
float4 projPos : TEXCOORD2;
#endif
UNITY_VERTEX_OUTPUT_STEREO
};
float4 _MainTex_ST;
v2f vert (appdata_t v)
{
v2f o;
UNITY_SETUP_INSTANCE_ID(v);
UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);
// (修正前) o.vertex = UnityObjectToClipPos(v.vertex);
float3 velocity = normalize(v.texcood1.xyz); // (修正後)
float3 viewDir = WorldSpaceViewDir(v.vertex); // (修正後)
float3 offsetU = normalize(cross(viewDir, velocity)); // (修正後)
float3 offsetV = normalize(cross(viewDir, offsetU)); // (修正後)
float2 uv = v.texcood0.xy; // (修正後)
float2 size = v.texcood0.zw; // (修正後)
float3 vert = v.vertex + // (修正後)
offsetU * (uv.x - 0.5) * size.x + // (修正後)
velocity * size.y * (1.0 - uv.y) + // (修正後)
offsetV * (uv.y - 0.5) * size.x; // (修正後)
o.vertex = UnityObjectToClipPos(vert);
#ifdef SOFTPARTICLES_ON
o.projPos = ComputeScreenPos (o.vertex);
COMPUTE_EYEDEPTH(o.projPos.z);
#endif
o.color = v.color;
// (修正前) o.texcoord = TRANSFORM_TEX(v.texcoord,_MainTex);
o.texcoord = TRANSFORM_TEX(v.texcood0.xy,_MainTex); // (修正後)
UNITY_TRANSFER_FOG(o,o.vertex);
return o;
}
UNITY_DECLARE_DEPTH_TEXTURE(_CameraDepthTexture);
float _InvFade;
fixed4 frag (v2f i) : SV_Target
{
#ifdef SOFTPARTICLES_ON
float sceneZ = LinearEyeDepth (SAMPLE_DEPTH_TEXTURE_PROJ(_CameraDepthTexture, UNITY_PROJ_COORD(i.projPos)));
float partZ = i.projPos.z;
float fade = saturate (_InvFade * (sceneZ-partZ));
i.color.a *= fade;
#endif
fixed4 col = 2.0f * i.color * _TintColor * tex2D(_MainTex, i.texcoord);
// (修正前) col.a = saturate(col.a); // alpha should not have double-brightness applied to it, but we can't fix that legacy behaior without breaking everyone's effects, so instead clamp the output to get sensible HDR behavior (case 967476)
UNITY_APPLY_FOG_COLOR(i.fogCoord, col, fixed4(0,0,0,0)); // fog towards black due to our blend mode
return col;
}
ENDCG
}
}
}
}
4頂点2ポリゴンのメッシュを作成する
次に、ParticleSystemに渡すメッシュを作成します。
また、Positionについては、同じ座標に集約した4頂点2ポリゴンのメッシュを渡せばシェーダ上で加工しやすいです。上記の図で言えば 4点とも P0 の座標が渡されることになりますが、P1は速度ベクトルと進行方向のサイズから計算可能なので問題ありません。
↑でこのように書いたメッシュを作成します。モデリングソフトで作るまでもないので、プログラムで生成してしまいましょう。
エディタ拡張を使って、プロジェクトを読み込んだ時にアセットとして作成しプロジェクト内に保存するようにします。
ParticleSystemの初期化時にメッシュを生成しても良いのですが、それだとエディタで作業中にパーティクルが正常に描画されず悲しいので、アセットとして保存するようにしています。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;
[InitializeOnLoad]
public class MeshCreator
{
static readonly string nameAsset4 = "Assets/Meshes/mesh4Vertices.asset";
static MeshCreator()
{
Debug.Log("MeshCreator::MeshCreator()");
if (!System.IO.File.Exists(nameAsset4)) {
Debug.Log("Creating: " + nameAsset4);
var mesh4Vertices = createMesh4Vertices();
AssetDatabase.CreateAsset(mesh4Vertices, nameAsset4);
} else {
Debug.Log("Already exists: " + nameAsset4);
}
}
static Mesh createMesh4Vertices()
{
var mesh = new Mesh();
Vector3[] vertices = {
new Vector3(0.0f, 0.0f, 0.0f),
new Vector3(0.0f, 0.0f, 0.0f),
new Vector3(0.0f, 0.0f, 0.0f),
new Vector3(0.0f, 0.0f, 0.0f)
};
Vector2[] uvs = {
new Vector2(0.0f, 0.0f),
new Vector2(0.0f, 1.0f),
new Vector2(1.0f, 1.0f),
new Vector2(1.0f, 0.0f)
};
int[] triangles = { 0, 1, 2, 0, 2, 3 };
mesh.vertices = vertices;
mesh.uv = uvs;
mesh.triangles = triangles;
return mesh;
}
}
上記のようなスクリプトを作成し、プロジェクトを開き直すと「Assets/Meshes/mesh4Vertices.asset」というファイル名でアセットが作成されているはずです。
生成された後は上記スクリプトを削除しても良いのですが、すでにある時は何もしないので残しておいて特に問題ありません。
Material の作成
Assets/Materials/ ディレクトリでMaterialを作成し、ファイル名を Material4Vetices に変更します。
作成した material を選択し、インスペクタから Shader を "TacoParticles/AdditivePasse4Vertices" に変更します。上記のシェーダーのソースで名称を変更している方はその名前を選択してください。
テクスチャを参照するので、Particle Texture 欄をクリックし、Default-Particle を選択しておきます。(もちろん好きなテクスチャ画像をに差し替えてもらって問題ありません)
ParticleSystem の設定
最後に、ParticleSystem を選択し、Renderer モジュールを下記のように設定します。
これで設定は完了です。ゲームビューにてパーティクルの形状が期待通り進行方向から見てちゃんと円になっていることが確認できると思います。
このビームを使用した開発中のゲームのデモ版を unityroom にて公開中です。よろしければ遊んでみて下さい。