AR枯山水を作った技術
2020.02.20~02.24に東京ミッドタウンで開催された『未来の学校祭』。
そこでMESONが展示した『Spatial Message』というコンテンツで、自分が担当したAR枯山水で利用した技術について書いていきたいと思います。
体験中の動画はこんな感じです↓
AR枯山水の見た目はこんな感じ↓(開発中のキャプチャ)
AR枯山水って?
AR枯山水とは、ARで表現された枯山水に石を置くことで本物の枯山水のように見立てを楽しむコンテンツです。また体験者は石を置く位置をプレビューすることができ、その際にも模様が変わるようになっているので自分が置いた石によってどう模様が変化するのかも楽しむことができます。
どう実装したか?
今回の記事で解説するのはAR枯山水の模様をどう作ったか、それをどうインタラクティブにしたかです。
実装のポイントは以下です。
- パーティクルの速度を決める『Force Field』をテクスチャによって作る
- Force Fieldテクスチャをグリッド単位で変更できるようにする
- GPUパーティクルでレンダリングする
の3点です。
Force Fieldテクスチャの作成
Force Field用のテクスチャはデザイナーに作成してもらいました。
テクスチャはひとつの単位サイズ(詳細は後述)で作成し、それを並べることで全体のForce Fieldを実現しています。
作成したテクスチャの一部↓
Force Fieldテクスチャの更新
Force Fieldのテクスチャの更新方法についてはまず、体験者が石を置ける単位を1グリッドとして全体を区切り、そのグリッド単位で更新できるようにしました。
開発中のキャプチャ動画があるのでそれを見てもらうと分かりやすいと思います。
ちょっと毒々しい色になっていますがForce Fieldを可視化したものです。(最終的には白黒のシンプルなものに変更になりましたが)
テクスチャの単位はグリッドとパネル
上の動画ではグリッド単位で状態が変化しているのが分かるかと思います。
内部では1グリッドをさらに4パネルに分割していて、その4パネル単位で更新処理をしています。図にすると以下。
黒いラインがグリッドの範囲、青いラインがパネルの範囲です。1グリッドあたり4パネルが配置されています。
AR枯山水では体験者はグリッド単位で石を置くことができるようになっています。そして、体験者がコントローラでポイントしているグリッドの4パネルを動的に差し替えることで今回のインタラクションを実現しています。
Graphics.DrawTextureで更新
テクスチャの更新はGraphics.DrawTextureで行いました。コードは以下のようになります。
private void UpdateTexture()
{
if (_forceField == null)
{
return;
}
RenderTexture temp = RenderTexture.active;
RenderTexture.active = _forceField;
GL.PushMatrix();
GL.LoadPixelMatrix(0, TextureSize, TextureSize, 0);
int panelSize = _gridSize.U * 2;
float w = TextureSize / panelSize;
float h = TextureSize / panelSize;
Debug.Log($"One grid size is {w} and Texture size is {TextureSize}");
for (int y = 0; y < panelSize; y++)
{
for (int x = 0; x < panelSize; x++)
{
int idx = y * panelSize + x;
Panel panel = _repository.Find(new PanelID { ID = idx });
Graphics.DrawTexture(new Rect(x * w, y * h, w, h), panel.Texture.Texture, _graphicsDrawMat);
}
}
GL.PopMatrix();
RenderTexture.active = temp;
}
Graphics.DrawTextureのドキュメントは以下。
第一引数にどの位置にDrawするのかを指定し、第二引数に実際にDrawするテクスチャを、そして第三引数にマテリアルを指定します。(本来はただテクスチャをレンダリングするだけならこのマテリアルは不要なのですが、後述する理由により指定しています)
ここは特にむずかしいところはありません。
そして大事な点は次の3つ。
- GL.PushMatrixでマトリクスを保存
- GL.LoadPixelMatrixでマトリクスを定義
- GL.PopMatrixで保存したマトリクスを復元
Graphics.DrawTextureはこのマトリクスを適切に設定しないとおかしな座標変換が行われて意図した通りにレンダリングされません。そのため、まず現在設定されているマトリクスを退避させ、レンダリングしたいテクスチャサイズのマトリクスに設定します。そしてその設定でレンダリングを終えたあと、退避していたマトリクスを復元する、という手順を踏む必要があります。
具体的には以下の部分ですね。
GL.PushMatrix();
GL.LoadPixelMatrix(0, TextureSize, TextureSize, 0);
// ... 中略 ...
GL.PopMatrix();
LoadPixelMatrixのシグネチャは以下です。
public static void LoadPixelMatrix (float left, float right, float bottom, float top);
つまり、上記コードで行っているのはテクスチャのサイズを指定しているというわけですね。
パネル位置を求め該当箇所のテクスチャを書き換える
道具が揃いました。道具は、
- ポインタ位置を特定するグリッド・パネル単位
- 該当位置のパネルを書き換えるGraphics.DrawTexture
の2つです。
これらを用いて、体験者がポイントしている位置のテクスチャを書き換えます。書き換えている様子は上で紹介した動画の通りです。
書き換え処理は以下のようにしています。
void ISpatialArt.Replace(PackedTexture packedTexture)
{
if (packedTexture.Textures.Length < 4)
{
Debug.LogError("Not enough textures list.");
return;
}
// グリッドIDから対象の4パネルを取得する
PanelID[] panelIds = GetPanelIdsByGridID(packedTexture.GridID);
// 内部的にパネルの情報を更新する
for (int i = 0; i < panelIds.Length; i++)
{
Replace(panelIds[i], packedTexture.Textures[i]);
}
// 実際にテクスチャに変更を反映する
UpdateTexture();
}
上記処理では内部データを更新し、その情報を元にテクスチャをすべて書き換えることで対応しています。
なのでもし、対象のテクスチャサイズが大きかったり、テクスチャに含まれるパネル数が多い場合は対象位置だけを書き換えるなどの最適化が必要でしょう。
しかし今回の例ではそこまで多くないので実装しやすさを優先しています。
実際のテクスチャの更新処理は以下です。
private void UpdateTexture()
{
if (_forceField == null)
{
return;
}
RenderTexture temp = RenderTexture.active;
RenderTexture.active = _forceField;
GL.PushMatrix();
GL.LoadPixelMatrix(0, TextureSize, TextureSize, 0);
int panelSize = _gridSize.U * 2;
float w = TextureSize / panelSize;
float h = TextureSize / panelSize;
for (int y = 0; y < panelSize; y++)
{
for (int x = 0; x < panelSize; x++)
{
int idx = y * panelSize + x;
Panel panel = _repository.Find(new PanelID { ID = idx });
Graphics.DrawTexture(new Rect(x * w, y * h, w, h), panel.Texture.Texture, _graphicsDrawMat);
}
}
GL.PopMatrix();
RenderTexture.active = temp;
}
前述したGraphics.DrawTextureとGL系のメソッドが使われているのが分かるかと思います。
Androidに関するバグ
どうやらこれはUnityのバグらしいのですが、Graphics.DrawTextureの第三引数を省略するとAndroidで正常に描画されないという問題があります。
その問題については以下の記事を見て解決しました。
結論を言うと、入力をただ出力するだけのシェーダを書いてそのマテリアルを第三引数に指定するだけです。シェーダは以下。ほぼデフォルトのままなのが分かるかと思います。
Shader "Hidden/GraphicsDraw"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
}
SubShader
{
Tags { "Queue"="Transparent" "IgnoreProjector"="True" "RenderType"="Transparent" }
Lighting Off
Cull Off
ZTest Always
ZWrite Off
Fog { Mode Off }
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#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;
}
sampler2D _MainTex;
fixed4 frag (v2f i) : SV_Target
{
fixed4 col = tex2D(_MainTex, i.uv);
return col;
}
ENDCG
}
}
}
このシェーダを適用したマテリアルを渡すことでAndroidでも無事に描画することができます。(知らないとまず気づけないバグなのでだいぶトラップ…)
GPUパーティクルを使って枯山水を表現
前段でForce Fieldについての説明を終えました。
次はForce Fieldを利用してパーティクルの位置を更新する処理です。
なお、GPUパーティクル自体のセットアップ・レンダリングについては以前書いた以下の記事を参照ください。ここではForce Fieldを利用した更新処理についてのみ書きます。
コード量は多くないので先にコンピュートシェーダ全文を掲載します。
#pragma kernel UpdateParticle
struct Particle
{
float3 basePosition;
float3 position;
float3 velocity;
float4 color;
float scale;
float reactionRate;
float baseLifeTime;
float lifeTime;
};
RWStructuredBuffer<Particle> _Particles;
Texture2D<float4> _ForceField;
SamplerState _LinearRepeat;
int _Reverse;
float _DeltaTime;
float _Width;
float _Height;
float _Power;
float _BaseSize;
float _MinimumScale;
float _GlobalAlpha;
float4x4 _OriginMatrix;
[numthreads(8,1,1)]
void UpdateParticle (uint id : SV_DispatchThreadID)
{
Particle p = _Particles[id];
p.lifeTime -= _DeltaTime;
float3 pos = mul(_OriginMatrix, float4(p.position, 1.0)).xyz;
if (p.lifeTime < 0)
{
p.position = p.basePosition;
p.velocity = 0;
p.lifeTime = p.baseLifeTime;
}
else if (abs(pos.x) >= _Width * 0.5 || abs(pos.z) >= _Height * 0.5)
{
p.position = p.basePosition;
p.velocity = 0;
p.lifeTime = p.baseLifeTime;
}
else
{
float3 px = float3(1.0 / _Width, 1.0 / _Height, 0.0);
float2 uv = pos.xz * px.xy + 0.5;
float v = _ForceField.SampleLevel(_LinearRepeat, uv, 0).a;
float3 right = normalize(mul(float4(1.0, 0.0, 0.0, 0.0), _OriginMatrix).xyz);
float velocity = pow(v + 0.5, 3.0);
p.velocity = right * velocity * _Power * p.reactionRate;
p.position += p.velocity * _DeltaTime;
float scale = lerp(1.0 - v + _MinimumScale, v + _MinimumScale, _Reverse);
p.scale = lerp(p.scale, saturate(scale) * _BaseSize * p.reactionRate, _DeltaTime);
p.color.a = (p.scale / _BaseSize) * _GlobalAlpha;
float x = (pos.x + _Width * 0.5) / (_Width * 0.5) - 1.0;
float f = 1.0 - pow(x, 4.0);
p.color.rgb = f * lerp(sin(pos.z * 100.0), 1.0, f);
}
_Particles[id] = p;
}
if文による分岐がありますが、重要な位置更新部分は以下の部分のみです。
float3 px = float3(1.0 / _Width, 1.0 / _Height, 0.0);
float2 uv = pos.xz * px.xy + 0.5;
float v = _ForceField.SampleLevel(_LinearRepeat, uv, 0).a;
float3 right = normalize(mul(float4(1.0, 0.0, 0.0, 0.0), _OriginMatrix).xyz);
float velocity = pow(v + 0.5, 3.0);
p.velocity = right * velocity * _Power * p.reactionRate;
p.position += p.velocity * _DeltaTime;
float scale = lerp(1.0 - v + _MinimumScale, v + _MinimumScale, _Reverse);
p.scale = lerp(p.scale, saturate(scale) * _BaseSize * p.reactionRate, _DeltaTime);
p.color.a = (p.scale / _BaseSize) * _GlobalAlpha;
float x = (pos.x + _Width * 0.5) / (_Width * 0.5) - 1.0;
float f = 1.0 - pow(x, 4.0);
p.color.rgb = f * lerp(sin(pos.z * 100.0), 1.0, f);
順を追って見ていきましょう。
Force Fieldテクスチャから値をサンプルする
まずは値を取得する部分から。コードは以下です。
float3 px = float3(1.0 / _Width, 1.0 / _Height, 0.0);
float2 uv = pos.xz * px.xy + 0.5;
float v = _ForceField.SampleLevel(_LinearRepeat, uv, 0).a;
ここで行っていることは、テクセルの基本単位の計算と、それを利用してのサンプリングです。
テクセルの基本単位については以下の記事を参照ください。
Force Filedは前段で説明したものをコンピュートシェーダに送って利用しています。要は、Force Fieldのテクセルに格納されている値をそのまま速度の係数として利用しているというわけです。
サンプルした速度を利用して位置を更新する
次に、サンプルした値を利用してパーティクル位置の更新を行います。
コードは以下の部分です。
float3 right = normalize(mul(float4(1.0, 0.0, 0.0, 0.0), _OriginMatrix).xyz);
float velocity = pow(v + 0.5, 3.0);
p.velocity = right * velocity * _Power * p.reactionRate;
p.position += p.velocity * _DeltaTime;
最初の行でright変数はAR枯山水の右側を求めています。
なぜこれが必要かというと、AR枯山水の配置位置の回転が設定により変化するため、必ずしも(1, 0, 0)の方向が右方向とは限らないからです。
そのため、AR枯山水の位置・回転を表す行列をコンピュートシェーダに送り座標変換して右側を求めているわけです。
ちなみに右側を求めている理由は、AR枯山水のパーティクルは常に右側に流れるようにしているためです。
続く行で実際の速度を計算しています。
そして最終的に求まった速度に外部設定パラメータを乗算したあと、その速度にフレーム間の時間をかけてm/sからmに単位変換した値を位置に足し込んでいます。
速度に応じてパーティクルのスケールを変更する
前段で位置の更新が終わりました。続くコードではやや複雑な計算を行っていますが、実現したいことはシンプルです。
つまり速度が遅いほど小さくなるようにする、です。
ただ、最低サイズや最大サイズ、変化のアニメーションなどをするためにコードがやや複雑になっていますが、実現したいことは前述の通りです。
それを踏まえてもう一度冒頭の動画を見るとパーティクルの動きがそのようになっているのが確認できると思います。
最後に
以上がAR枯山水の実装についてです。
今回はインタラクションをもたせるためにグリッド・パネル単位に分けて実装しましたが、必要なければもっとシンプルに実装することができるでしょう。
それこそ、最後のコンピュートシェーダと事前生成したテクスチャを利用するだけで上記動画のような動きを実現することができます。
CES2020で展示したコンテンツにもコンピュートシェーダを利用したパーティクルシステムを採用していますが、GPUパーティクルの移動処理は様々に変更できるので覚えておくと表現力を身につけることができると思います。
(CES2020に展示したPORTALの動画↓)
この記事が気に入ったらサポートをしてみませんか?