【Unity】ライトプローブの影響を受ける雪エフェクトを作ってみた
はじめに
降雪エフェクトをLight Probeで照らしてみました。
サンプルプロジェクト
環境
Unity 2022.1.0b2
Universal RP 13.1.3
実装について
通常、Particle Systemにライトプローブを適用すると、パーティクル全体に同じライトが適用され、全体が同じ色になってしまいます。(画像左)
今回の実装だと、パーティクルを部分的にライトで照らせるようになります。 (画像右)
ライトプローブから3Dテクスチャを作成し、パーティクル座標で3Dテクスチャをサンプリングするような実装になっています。
ライトプローブ(Light Probe)とは?
ライトプローブを配置すると、そこを通り抜ける光の情報を保存することができます。
オブジェクトへのライティングには、光を補間したものが利用されます。
上図では光を矢印形式で表していますが、実際は球面調和関数形式で表現されます。
ライトプローブのライティング
オブジェクトをシーンに配置すると、オブジェクトに最も近いライトプローブを利用して、ライティングが行われます。
ParticleSystemにライティングを適用した場合は以下のようになります。
ある一点を基準にして最も近いライトプローブが選択されてライティングに使用されるため、雪全体が同じ色(不自然な見た目)になってしまいます。
今回のように広範囲に降らせるエフェクトでは、エフェクトの粒子ごとにライトプローブが選択される方が望ましいです。
パーティクル位置を利用したライティング
今回は、以下のような実装でパーティクル位置でライトプローブの色を反映させるようにしてみました。
- 空間を格子状に区切り、それぞれの格子点でライトプローブをサンプリングする
- サンプリング結果から3Dテクスチャを作成する
- パーティクルの中心座標を利用して3Dテクスチャをサンプリングし、パーティクルの色に反映する
この方法でライティングを行うと、以下のような結果になります。
実装1 : ライトプローブのサンプリング
LightProbes.GetInterpolatedProbe
を利用することで、あるポイントを通過する光を取得できます。
LightProbes.GetInterpolatedProbe(position, null, out SphericalHarmonicsL2 sh);
光の情報は SphericalHarmonicsL2
形式で取得でき、この中には球面調和関数の係数が格納されています。
拡散光の取得
ここで、雪を観察してみましょう。
雪に当たった白い光は乱反射を起こすので、白く見えます。
雪の粒子に光が当たると、乱反射を起こして四方八方に光が散らばります。(拡散光)
実装コード
SphericalHarmonicsL2(球面調和関数)のL0成分を取ることで、拡散光の強さを取得できます。
var r = sh[rgb: 0, coefficient: 0]; // 赤い光 (red)
var g = sh[rgb: 1, coefficient: 0]; // 緑の光 (blue)
var b = sh[rgb: 2, coefficient: 0]; // 青の光 (green)
実装2 : 3Dテクスチャの作成
光が届く領域をまるごと覆う形で箱を配置し、箱の中の領域をテクスチャ座標(0,0,0) ~ (1,1,1)として割り当てることを行います。
(Texture3Dなので、テクスチャ座標は3次元になります)
箱を格子状に区切り、それぞれの格子点で球面調和関数のL0をサンプリングします。
実装コード
空間を格子状に区切り、ライトプローブをサンプリングする実装例を以下に示します。
Vector3 minPosition = transform.position - size / 2; // 箱の最小座標
Vector3 maxPosition = transform.position + size / 2; // 箱の最大座標
// 格子上に区切り、各点でライトプローブをサンプリングする
int index = 0;
for (int zi = 0; zi < grid.z; zi++)
{
float tz = (float) zi / (grid.z - 1);
float z = Mathf.Lerp(minPosition.z, maxPosition.z, tz);
for (int yi = 0; yi < grid.y; yi++)
{
float ty = (float) yi / (grid.y - 1);
float y = Mathf.Lerp(minPosition.y, maxPosition.y, ty);
for (int xi = 0; xi < grid.x; xi++)
{
float tx = (float) xi / (grid.x - 1);
float x = Mathf.Lerp(minPosition.x, maxPosition.x, tx);
// ライトプローブをサンプリングする
var p = new Vector3(x, y, z);
LightProbes.GetInterpolatedProbe(p, null, out SphericalHarmonicsL2 sh);
// ライトプローブの保存
shTable[index] = sh;
index++;
}
}
}
サンプリングしたライトプローブのL0成分を取り出し、Texture3Dに格納します。
Vector3Int grid = _shLattice.Grid;
int count = grid.x * grid.y * grid.z;
var shL0 = new Color32[count];
for (int i = 0; i < count; i++)
{
var sh = _shLattice.ShTable[i];
var r = sh[rgb: 0, coefficient: 0];
var g = sh[rgb: 1, coefficient: 0];
var b = sh[rgb: 2, coefficient: 0];
shL0[i] = new Color(r, g, b);
}
_shTexture3D.SetPixels32(shL0);
_shTexture3D.Apply();
作成した3Dテクスチャは、マテリアルに渡しておきます。
MaterialPropertyBlock
を使うことで、マテリアルインスタンスを複製せずにパラメータを渡すことができるので便利です。
_materialPropertyBlock.SetTexture(ShaderProperty._ShTex, _shTexture3D);
実装3 : パーティクルの色に反映
以下の手順で、パーティクル座標を元にTexture3Dから色を取得します。
- パーティクルの中心座標をシェーダーへ流す (Custom Vertex Streams)
- パーティクルの中心座標から、3Dテクスチャ座標を計算 (ShaderLab)
- テクスチャ座標を利用して、3Dテクスチャから色を取り出す (ShaderLab)
パーティクルの中心座標を渡す(Particle System)
Particle System側のCustom Vertex Streams へ Center を登録します。
これにより、シェーダー側からパーティクルの中心座標が取れるようになります。
TEXCOORD0.zw|x と表示されていますが、
これは頂点アトリビュートの TEXCOORD0.zw と TEXCOORD1.x を参照することで、
パーティクルの中心座標が取れることを表しています。
パーティクルの中心座標の取得(ShaderLab)
頂点属性の構造体にTEXCOORD0とTEXCOORD1を定義しておきます。
struct appdata
{
float4 vertex : POSITION; // 頂点座標
half4 color : COLOR; // 頂点カラー
float4 texCoord0 : TEXCOORD0;
float4 texCoord1 : TEXCOORD1;
};
頂点シェーダー内では以下のように記述することで、パーティクルの中心座標を取得できます。
v2f vert (appdata v)
{
// パーティクル中心座標 (Custom Vertex Streamsで指定した座標を取り出す)
float3 worldPosition = float3(v.texCoord0.zw, v.texCoord1.x);
実装例
パーティクル中心座標を利用して、3次元テクスチャをサンプリングする実装例を以下に示します。
struct appdata
{
float4 vertex : POSITION; // 頂点座標
half4 color : COLOR; // 頂点カラー
float4 texCoord0 : TEXCOORD0;
float4 texCoord1 : TEXCOORD1;
};
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.texCoord = TRANSFORM_TEX(v.texCoord0.xy, _MainTex);
// パーティクル中心座標 (Custom Vertex Streamsで指定した座標を取り出す)
float3 worldPosition = float3(v.texCoord0.zw, v.texCoord1.x);
// テクスチャ座標を復元
float3 shCoord = (worldPosition - _MinPosition) / (_MaxPosition - _MinPosition);
// 0 ~ 1の範囲に収める
shCoord = saturate(shCoord);
// ライト情報を取り出す
float4 light = tex3D(_ShTex, shCoord);
o.color = v.color * light;
return o;
}
最終的な表示
以下のような、ライトプローブの影響を受ける雪エフェクトが作れます。
詳しい作り方が気になる方は、以下のサンプルプロジェクトをご覧ください。
おまけ: SphericalHarmonicsL2の中身
SphericalHarmonicsL2 の中には、27個のfloat値が入っています。
これらは、球面調和関数の係数です。
private float shr0;
private float shr1;
private float shr2;
private float shr3;
private float shr4;
private float shr5;
private float shr6;
private float shr7;
private float shr8;
private float shg0;
private float shg1;
private float shg2;
private float shg3;
private float shg4;
private float shg5;
private float shg6;
private float shg7;
private float shg8;
private float shb0;
private float shb1;
private float shb2;
private float shb3;
private float shb4;
private float shb5;
private float shb6;
private float shb7;
private float shb8;
球面調和関数を利用した光の近似
ライトプローブを通過する光を表現する方法について考えてみます。
ライトプローブを中心とする球面を通過する光が分かれば、ライトプローブを通過する光が分かります。
球面を通過する光をすべて扱おうとすると、大量のデータが必要になります。
例えば、100個の光線が球面を通過する場合、100個のベクトルが必要になります。
これらの光線すべてをコンピューター上で扱うのはデータが多すぎて現実的ではありません。
そこで、光線の集まりを球面上の分布関数
この関数をそのまま扱う場合も、やはり多くのデータが必要になります。
そこで、
表現するということを行います。
関数
球面調和関数は、以下のような形をしています。
Wikipedia「球面調和関数」より引用
球面調和関数を利用した光の復元
ライトプローブに入ってくる光の分布
以下のように表せます。
重み
ライトプローブに入ってくる光の分布
Unityでの球面調和関数
Unity上では、球面調和関数の係数を9個を打ち切っています。
例えば、shr0
~ shr8
は赤い光の係数
// L = 0
private float shr0;
// L = 1
private float shr1;
private float shr2;
private float shr3;
// L = 2
private float shr4;
private float shr5;
private float shr6;
private float shr7;
private float shr8;
数式的に書くと、以下のような式になります。
無限の計算を途中で打ち切っているので、近似値になります。
上式の SphericalHarmonicsL2.cs
の shr0
~ shr8
に対応しています。
拡散光
ライトプローブの拡散光を取得するとき、以下のような実装を行いました。
var r = sh[rgb: 0, coefficient: 0]; // 赤い光 (red)
var g = sh[rgb: 1, coefficient: 0]; // 緑の光 (blue)
var b = sh[rgb: 2, coefficient: 0]; // 青の光 (green)
これは、球面調和関数のL0成分を取得しているコードで、以下の式の
参考書籍
Discussion