「正弦波」を Unity で作ってみよう
まずは、こんな感じの正弦波を作りましょう!
プロジェクトは [Github] note-nota/SineWave を参照して下さい。
基本方針
Shader で再現してみよう!
点群がウネウネ動いても良いけども、計算点の間を適度に繋げて表現しないと「波」と伝わらない気がするので、Shader で再現してみよう。その際、Unity の Primitive な Plane は一辺が11点のメッシュで作られているので、「波」を再現するには役不足。。。ってことで、計算点をぐっと増やした Plane を Blender で作って、それに合わせて Shader で波を表現したいと思います。
波を表現する Plane の作成に関しては Blender 編 を確認してください。今回は続きで Shader 編になります。
Shader
まずは今回作った Shader スクリプトの全景を。
Shader "Custom/SineWave" {
Properties{
_MainTex("Albedo (RGB)", 2D) = "black" {}
_Amp("Amplitude", float) = 1.0
_Frq("Frequency", float) = 0.3
_Spd("Speed", float) = 0.05
_Ox("origin_x", Range(-0.5,0.5)) = 0
_Oy("origin_y", Range(-0.5,0.5)) = 0
}
SubShader{
Tags { "RenderType" = "Opaque" }
LOD 200
CGPROGRAM
#pragma surface surf Lambert vertex:vert
#pragma target 3.0
sampler2D _MainTex;
float _Amp;
float _Frq;
float _Spd;
float _Ox;
float _Oy;
struct Input {
float2 uv_MainTex;
float3 customVert;
};
struct WaveData {
float height;
float2 normal;
};
static const float PI = 3.14159265f;
WaveData sin_wave(float2 c) {
WaveData wave;
float c_dist = sqrt(c.x * c.x + c.y * c.y);
wave.height = _Amp * sin(2.0f * PI * _Frq * (_Time.y - (c_dist / _Spd)));
if (c_dist == 0.0f) {
wave.normal = float2(0.0f, 0.0f);
}
else {
float temp_cosin = -_Amp * 2.0f * PI * _Frq * cos(2.0f * PI * _Frq * (_Time.y - c_dist / _Spd)) / c_dist / _Spd;
wave.normal = float2(-temp_cosin * c.x, -temp_cosin * c.y);
}
return wave;
}
void vert(inout appdata_full v, out Input o)
{
UNITY_INITIALIZE_OUTPUT(Input, o);
float2 circle = float2(v.vertex.x - _Ox, v.vertex.z - _Oy);
WaveData wave_circle = sin_wave(circle);
float amp = wave_circle.height;
float2 del_amp = wave_circle.normal;
v.vertex.xyz = float3(v.vertex.x, v.vertex.y + amp, v.vertex.z);
v.normal = float3(del_amp.x, 1.0f, del_amp.y);
o.customVert = v.vertex.xyz;
}
void surf(Input IN, inout SurfaceOutput o) {
fixed4 c = tex2D(_MainTex, IN.uv_MainTex);
o.Albedo = c.rgb + float3(IN.customVert.y + 0.1f, 0.1f, -IN.customVert.y + 0.1f);
o.Alpha = c.a;
}
ENDCG
}
FallBack "Diffuse"
}
入力は以下の通り。
Properties{
_MainTex("Albedo (RGB)", 2D) = "black" {} // バックグランドテクスチャ
_Amp("Amplitude", float) = 1.0 // 大きさ
_Frq("Frequency", float) = 0.3 // 周波数
_Spd("Speed", float) = 0.05 // 広がる速さ
_Ox("origin_x", Range(-0.5,0.5)) = 0 // 中心 X 座標
_Oy("origin_y", Range(-0.5,0.5)) = 0 // 中心 Y 座標
}
今回は Plane を 1.0x1.0[m] で作成したので、Unity での Object Scale が単位長さに相当します。なので、例えば「速さ1」に設定すると、1秒で Plane の端から対辺の端まで到達する波になります。
以下、「Plane の変位を計算する頂点シェーダー」と「波の高さに応じた色分けを行うサーフェスシェーダー」が続く。ここからは数式を交えて内容の説明をしていきます。
波による変位
波の数式に関して、詳しい導出は “正弦波” 等を参照してもらうとして、ココではシェーダー内部の記述にスポットを当てて説明したいと思います。正弦波を表す数式は以下の通りです。
f(x,t) = A~sin{\frac{2\pi}{T}\left(t - \frac{x}{v} \right)} ~~,~~~~~
\left\{
\array{
A:振幅\\
T:周期\\
v:速さ
}
\right.
この部分がそのまま vert
に記述されます。
WaveData sin_wave(float2 c) {
WaveData wave;
float c_dist = sqrt(c.x * c.x + c.y * c.y);
wave.height = _Amp * sin(2.0f * PI * _Frq * (_Time.y - (c_dist / _Spd)));
// ...中略...
return wave;
}
void vert(inout appdata_full v, out Input o)
{
// ...中略...
float2 circle = float2(v.vertex.x - _Ox, v.vertex.z - _Oy);
WaveData wave_circle = sin_wave(circle);
float amp = wave_circle.height;
// ...中略...
v.vertex.xyz = float3(v.vertex.x, v.vertex.y + amp, v.vertex.z);
今回は円形に広がる波を表現するために、中心からの距離を c_dist
として $xz$ 平面の変数 v.vertex
から計算してます。
さらに、波として平面が盛り上がれば、当然ながら法線ベクトルも変化します。そのため、法線ベクトルの更新も同時に行う必要があります。そこで、“その8 波:プレートの頂点を揺らして波にする” を参考にさせてもらい、法線ベクトル $N$ を求めます。
N =
\left(
-\frac{\partial H}{\partial x}, 1, -\frac{\partial H}{\partial z}
\right) ~~~~~~~
\array{
,~~ \left(H:高さ変位\right)
}
先ほどの正弦波の数式から、以下のように計算されます。
H = A~sin{\frac{2\pi}{T}\left(t - \frac{\sqrt{x^2+z^2}}{v} \right)} ~~~ ,\\
\left(\array{
\frac{\partial H}{\partial x}\\
\frac{\partial H}{\partial z}
}\right) = A~\frac{2\pi}{T}~\frac{1}{v\sqrt{x^2+z^2}}~cos{\frac{2\pi}{T}\left(t - \frac{\sqrt{x^2+z^2}}{v} \right)}~
\left(\array{ x\\ z}\right)~.
頂点シェーダーの部分を参照すると以下のようにしてます。
WaveData sin_wave(float2 c) {
WaveData wave;
float c_dist = sqrt(c.x * c.x + c.y * c.y);
// ...中略...
if (c_dist == 0.0f) {
wave.normal = float2(0.0f, 0.0f);
}
else {
float temp_cosin = -_Amp * 2.0f * PI * _Frq * cos(2.0f * PI * _Frq * (_Time.y - c_dist / _Spd)) / c_dist / _Spd;
wave.normal = float2(-temp_cosin * c.x, -temp_cosin * c.y);
}
return wave;
}
void vert(inout appdata_full v, out Input o)
{
// ...中略...
float2 circle = float2(v.vertex.x - _Ox, v.vertex.z - _Oy);
WaveData wave_circle = sin_wave(circle);
// ...中略...
float2 del_amp = wave_circle.normal;
// ...中略...
v.normal = float3(del_amp.x, 1.0f, del_amp.y);
同心円状に波を発生させる際に、中心からの距離を求めているので、中心部分で偏微分が求まりません。「中心なんで上向きでいいや」ということで、if 文で (0.0f, 0.0f)
を出力するようにしました。
色分け
正弦波の頂点シェーダーの実装をして適当なテクスチャをあるだけでも良かったんですが、波の合成であったり波長を小さくするととたんに見にくくなってしまいました。そこで、冒頭の GIF のように「赤>黒>青」と高さに応じて変化するようにサーフェスシェーダーを作ることにしました。
void vert(inout appdata_full v, out Input o)
{
UNITY_INITIALIZE_OUTPUT(Input, o);
// ...中略...
o.customVert = v.vertex.xyz;
}
void surf(Input IN, inout SurfaceOutput o) {
fixed4 c = tex2D(_MainTex, IN.uv_MainTex);
o.Albedo = c.rgb + float3(IN.customVert.y + 0.1f, 0.1f, -IN.customVert.y + 0.1f);
o.Alpha = c.a;
}
頂点シェーダー vert
でサーフェスシェーダー surf
の Input
データを作ります。_MainTex
を SurfaceOutput
にあてるところで、Input.customVert
から高さの情報を取り出し、色加算を行っています。
MainTexture に黒背景を設定すれば "Height = 0" のときに黒になることを期待して、±1のときの色加算を上のグラフのようにイメージして作成。ほんとは緑とか黄色とかも交えて、寒色→暖色も考えたんですが、色がゴチャゴチャしすぎて分かりにくかったんでボツにしました。(シンプルな方が見やすい…)