はじめに
この記事はUnity #3 Advent Calendar 2020の9日目の記事です。
この記事では高速フーリエ変換(FFT)を使った海洋シミュレーション、FFT Oceanについて書いていこうと思います。Unityに限らずいろんなゲームエンジンで再現できるよう理論サイドも俯瞰しつつ、私が実装を通して理解したことをまとめさせていただければと思います。
次に発展として、法線ベクトルの算出や、より波を尖らせてそれっぽくすることをやっていこうと思います。
最終的にはこんな絵ができあがります。
FFT Oceanについて
音声信号などの波形はフーリエ変換することで周波数を得ることができます。逆に周波数情報から波形を求めることができます(逆フーリエ変換)。
FFT Oceanは海面の高さを周波数から逆フーリエ変換で求めてしまおうというものです。すなわち周波数スペクトルが大事になってきます。
この周波数スペクトルをTessendorfの書いた手法で生成します。
https://evasion.imag.fr/Membres/Fabrice.Neyret/images/fluids-nuages/waves/Jonathan/articlesCG/simulating-ocean-water-01.pdf
一部のゲームや映画(タイタニック)などいろんなところに使われている有名(?)な手法のようです。
CUDA Sampleの中にもあったりします。
https://docs.nvidia.com/cuda/cuda-samples/index.html#cuda-fft-ocean-simulation
実装
0.概要をざっくりと
1、まずは事前計算で左の画像をCPUで計算しGPU上のメモリに載せておく
2、それをベースに毎フレーム真ん中の画像をGPU上で生成(tはtime)。これが周波数に相当
3、GPU上で逆フーリエ変換をして右の画像(=海面の高さ)を生成
4、右の画像から実際の位置にレンダリング
※数式上、最初から最後まで複素数がでてきますが、画像でいうと左の画像と中央の画像ではr,gが実数,虚数に対応、右の画像は実数を白で表現してい(ると思われ)ます。CUDA Sampleでも海面の高さには実数のみを使っていました。
1.事前計算でh0(k)をCPUで計算しGPU上のメモリに載せておく
\tilde{h}_0(\vec{k}) =\frac{1}{\sqrt{2}}(\xi_r+i\xi_i)\sqrt{P_h(\vec{k})}
です。
正直最初ほとんどの記号が意味ワカンナイ状態でしたが、他人のコードを見ながらちょっとずつ理解していった感じです。ひとつずつ見ていきましょう。
Kの定義
\vec{k}=(k_x,k_z)=(\frac{2\pi n}{L_x},\frac{2\pi m}{L_z})
-\frac{N}{2}\leq n<\frac{N}{2}\\
-\frac{M}{2}\leq m<\frac{M}{2}\\
n,mは整数
$N$,$M$はx,z方向の離散点の数です。
今後FFTで実装することを考慮し2のべき乗とし、今は$N=256$,$M=256$くらいで考えておけばよいです。
また$L_x,L_z$は海面サイズです。
こちらに関しては、スケールの問題なので何でもいいと最初思ってましたが論文にはこうありました。
・$dx=L_x/N > 2$
・$dz=L_z/M > 2$
・$dx,dz=2.5$ が最も良い (←自信ないのですが間違ってたらコメントしてください)
正規分布に基づく乱数
$\xi_r$と$\xi_i$はランダムな数です。具体的には正規分布する乱数が必要です。なんでこう難しそうな記号を使うのか・・・
コードではボックス=ミュラー法を使っています。これは標準正規分布する乱数を生成する手法です。
参考
JavaScriptでBox-Muller法による正規分布からのサンプリング
// Generates Gaussian random number with mean 0 and standard deviation 1.Vector2Gauss(){varu1=Random.value;varu2=Random.value;varlogU1=-2f*Mathf.Log(u1);varsqrt=(logU1<=0f)?0f:Mathf.Sqrt(logU1);vartheta=Mathf.PI*2.0f*u2;varz0=sqrt*Mathf.Cos(theta);varz1=sqrt*Mathf.Sin(theta);returnnewVector2(z0,z1);}
Phillips Spectrum
最後に残った$P_h(\vec{k})$の中身はこれです。
P_h(\vec{k})=A\frac{e^\frac{-1}{(kL)^2}}{k^4}|\vec{k}\cdot\vec{\omega}|^2
えー、これも一つずつ見ていきましょう。
$A$:これは定数です。最終的な波の高さのスケールだと思ってください。
$e$:これはさすがに自然対数の底でいいでしょう。
$k$:$\vec{k}$ の長さ。sqrt(k.x*k.x+k.y*k.y)とかで求められますね。
$L$:$L=V^2/g$ ここで$V$は風速 つまり定数、$g$は重力定数で$g=9.81$です。
$\vec{\omega}$:風向きの単位ベクトル(風がないと波が起きません)
これですべてそろいました。
なお、この式の意味するところですが理論的に導き出されたというよりは、テッセンドルフさんが必死に海を観察し導き出された"経験的"な式らしいです。なのであまり細かいことを気にせずに、こういうものだと思ったほうがいいです、精神衛生上。
で、ここまではまぁいいのですが、論文にはさらにこうあります。
多分ですが|k|が大きいとなにかがダメなようで、ちょっと修正しろと言ってるんですね。
他のコードも参考に下記のような修正を加えるようにしました。
P_{h_{modify}}(\vec{k})=P_h(\vec{k})×e^{-k^2(0.001L)^2}
usingUnityEngine;publicclassPhillips:MonoBehaviour{publicconstuintmeshSize=256;publicconstuintseasizeLx=meshSize*5/2;publicconstuintseasizeLz=meshSize*5/2;constfloatG=9.81f;// gravitational constantconstfloatA=0.000001f;// wave scale factor A - constantconstfloatwindSpeed=30.0f;constfloatwindDir=Mathf.PI*1.234f;//wind angle in radians// Generates Gaussian random number with mean 0 and standard deviation 1.Vector2Gauss(){varu1=Random.value;varu2=Random.value;varlogU1=-2f*Mathf.Log(u1);varsqrt=(logU1<=0f)?0f:Mathf.Sqrt(logU1);vartheta=Mathf.PI*2.0f*u2;varz0=sqrt*Mathf.Cos(theta);varz1=sqrt*Mathf.Sin(theta);returnnewVector2(z0,z1);}// Phillips spectrum// (Kx, Ky) - normalized wave vectorfloatGenerate_Phillips(floatKx,floatKy){floatk2mag=Kx*Kx+Ky*Ky;if(k2mag==0.0f){return0.0f;}floatk4mag=k2mag*k2mag;// largest possible wave from constant wind of velocity vfloatL=windSpeed*windSpeed/G;floatk_x=Kx/Mathf.Sqrt(k2mag);floatk_y=Ky/Mathf.Sqrt(k2mag);floatw_dot_k=k_x*Mathf.Cos(windDir)+k_y*Mathf.Sin(windDir);floatphillips=A*Mathf.Exp(-1.0f/(k2mag*L*L))/k4mag*w_dot_k*w_dot_k;// damp out waves with very small length w << lfloatl2=(L/1000)*(L/1000);phillips*=Mathf.Exp(-k2mag*l2);returnphillips;}// Generate base heightfield in frequency spacepublicVector2[]Generate_h0(){Vector2[]h0=newVector2[meshSize*meshSize];for(uinty=0;y<meshSize;y++){for(uintx=0;x<meshSize;x++){floatkx=(-(int)meshSize/2.0f+x)*(2.0f*Mathf.PI/seasizeLx);floatky=(-(int)meshSize/2.0f+y)*(2.0f*Mathf.PI/seasizeLz);floatP=Generate_Phillips(kx,ky);if(kx==0.0f&&ky==0.0f){P=0.0f;}uinti=y*meshSize+x;h0[i]=Gauss()*Mathf.Sqrt(P*0.5f);}}returnh0;}}
これでCPU上で$h_0(\vec{k})$を計算することができました。
後のコードに含まれていますが、GPUに転送するコードの一部を載せておきます。
Vector2[]h_h0;ComputeBufferd_h0;voidStart(){h_h0=phillips.Generate_h0();//CPU側メモリ確保。サイズはsizeof(Vector2)*256*256d_h0=newComputeBuffer(h_h0.Length,sizeof(float)*2);//GPU側メモリ確保 サイズはsizeof(Vector2)*256*256d_h0.SetData(h_h0);}
余談 w_dot_kの6乗のとき
余談ですが、Phillips Spectrumの計算でw_dot_kを2乗ではなく6乗でやる流派もあるようです。6乗にすることで、風の方向に応じた波が今まで以上に際立つそうです。
余談 Donelan-Bannerというワード
これも余談ですがPhillips Spectrumを求めた後、Donelan-Banner方向拡張?(中国語の翻訳なのでわかりませんが)という謎の計算をしてかけているコードも見かけたので一応書いておきます。
https://zhuanlan.zhihu.com/p/96811613
(もう一つ別の人のコードでDonelan-Bannerを計算しているのを見つけたがどっかいった)
2.それをベースに毎フレームh(k,t)をGPU上で生成(tはtime)
\tilde{h}(\vec{k},t)=\tilde{h}_0(\vec{k})e^{i\omega(k)t}+\tilde{h}_0^*(-\vec{k})e^{-i\omega(k)t}
ここで$ \tilde{h}_0^*$は$ \tilde{h}_0$の共役複素数です。プログラム上では虚数の要素に-1をかければいいだけです。
$\omega(k)=\sqrt{gk}$
$g$は重力定数 $=9.81$
$k$は$\vec{k}$の長さです。
$e$の肩に虚数$i$が乗っています。
ここで有名なオイラーの公式も一応載せておこうと思います。
$$ e^{i\theta} = \cos\theta + i\sin\theta $$
したがってこんなコードになります。
GPU上にあるデータに対しての計算なのでCompute Shaderのコードになります。
#pragma kernel GenerateSpectrumKernel
#define PI 3.14159265358979323846264338328
RWStructuredBuffer<float2>h0;RWStructuredBuffer<float2>ht;intN;intseasizeLx;intseasizeLz;floatt;float2conjugate(float2arg){float2f2;f2.x=arg.x;f2.y=-arg.y;return(float2)f2;}float2complex_exp(floatarg){return(float2)(cos(arg),sin(arg));}float2complex_add(float2a,float2b){return(float2)(a.x+b.x,a.y+b.y);}float2complex_mult(float2ab,float2cd){return(float2)(ab.x*cd.x-ab.y*cd.y,ab.x*cd.y+ab.y*cd.x);}[numthreads(256,1,1)]voidGenerateSpectrumKernel(uint2id:SV_DispatchThreadID){uintx=id.x;uinty=id.y;uintin_index=y*N+x;uintin_mindex=(N-y)%N*N+(N-x)%N;// mirroreduintout_index=y*N+x;// calculate wave vectorfloat2k;k.x=(-(int)N/2.0f+x)*(2.0f*PI/seasizeLx);k.y=(-(int)N/2.0f+y)*(2.0f*PI/seasizeLz);// calculate dispersion w(k)floatk_len=sqrt(k.x*k.x+k.y*k.y);floatw=sqrt(9.81f*k_len);if((x<(uint)N)&&(y<(uint)N)){float2h0_k=h0[in_index];float2h0_mk=h0[in_mindex];// output frequency-space complex valuesht[out_index]=complex_add(complex_mult(h0_k,complex_exp(w*t)),complex_mult(conjugate(h0_mk),complex_exp(-w*t)));}}
難しい点としては$ \tilde{h}_0(-\vec{k}) $のメモリアクセスかなと思います。
コードでいうとin_mindexの計算についてです。
in_mindexの計算
ちょっと考えてみましょう(自分のメモ代わりにもなるので)
ここで h0[y*N+x] というように配列変数にアクセスし$\tilde{h}_0(\vec{k})$の値を取り出せたとします。
そのときx,yを使って$\tilde{h}_0(-\vec{k})$を取り出すにはどうしたらいいでしょう、という問題になります。
\vec{k}=(k_x,k_z)=(\frac{2\pi n}{L_x},\frac{2\pi m}{L_z})\\
n=-\frac{N}{2}+x\\
m=-\frac{M}{2}+y\\
0\leqq x<N\\
0\leqq y<M\\
x,yは整数
なのでマイナスは
-\vec{k}=(-k_x,-k_z)=(-\frac{2\pi n}{L_x},-\frac{2\pi m}{L_z})\\
ここから
-\vec{k}=(\frac{2\pi n'}{L_x},\frac{2\pi m'}{L_z})\\
とし\\
n'=-n=\frac{N}{2}-x=-\frac{N}{2}+(N-x)\\
m'=-m=\frac{M}{2}-y=-\frac{M}{2}+(M-y)\\
つまり
x'=N-x\\
y'=M-y\\
としてx' y'を使ってh0[y'*N+x'] というようアクセスすることで$ \tilde{h}_0(-\vec{k}) $が得られることがわかりました。
ただしx=0 や y=0のときx'=N y'=Mとなり範囲外のアクセスになるのでx'=Nのときはx'=0(y'のときも同様)の値を使って計算してしまっています。ここは目を瞑ります。
Dispatchグループ数について
このCompute Shaderコードでは
id.x id.yが0~255になることを想定しているので、C#のDispatch側でも
shader.Dispatch(kernel_GenerateSpectrumKernel,1,256,1);
このようにグループ数を指定する必要があります。
3.GPU上で逆フーリエ変換をして右の画像(=海面の高さ)を生成
さきほど求めた画像$\tilde{h}(\vec{k},t)$をもとに逆2DFFTをかけ海面の高さを算出します。
式で書くと
h(\vec{x},t)=\sum_{k}\tilde{h}(\vec{k},t)e^{i\vec{k}\cdot\vec{x}}\\
を求めたいです。
その計算の仕方ですが、ちょっと長めの説明をしたいので先に結論だけ言います。
まずこの画像を普通に2次元逆FFTで変換します。そのあと
・計算結果全体を(N/2,M/2)ずらす
・要素のindex x,zが(x+z)%2==1となる要素に-1をかける
をすればよいです。
2次元FFT
まずは普通の方法からおさらいしましょう。(普通ってなんだよ)
2DFFTのやりかたはまず横方向にそれぞれFFTを実行し
その結果を今度は縦方向にFFTします。
iFFT(逆フーリエ変換)でも同様です。また縦→横の順にやっても答えは変わりません。
FFTの具体的な計算方法についてはわかりやすいサイトがたくさんあるのでここでは割愛させてください。またDFTからFFTで計算量が削減される話もこの記事では割愛させてください。
投げっぱなしなのもあれなので参考になりそうな記事をいくつかピックアップしてみました。
Compute Shaderで2DFFTしているコード
下町のナポレオン Compute ShaderでFFTと畳み込み演算でブラー
Compute Shaderではないが、ループ型のFFT計算のコードが乗ってる記事
任意要素数の高速フーリエ変換
高速フーリエ変換FFTを理解する
FFT(Fast Fourier transform)をC++で実装する
DFTから理解する
離散フーリエ変換(DFT)の仕組みを完全に理解する
マイナス周波数成分から始まるスペクトルのiFFT
先ほどの式を再掲します。
h(\vec{x},t)=\sum_{k}\tilde{h}(\vec{k},t)e^{i\vec{k}\cdot\vec{x}}\\
ここで$\vec{k}$と$\vec{x}$の定義は
\vec{k}=(k_x,k_z)=(\frac{2\pi n}{L_x},\frac{2\pi m}{L_z})\\
-\frac{N}{2}\leq n<\frac{N}{2}\\
-\frac{M}{2}\leq m<\frac{M}{2}\\
n,mは整数
\vec{x}=(x,z)=(\frac{qL_x}{N},\frac{rL_z}{M})\\
-\frac{N}{2}\leq q<\frac{N}{2}\\
-\frac{M}{2}\leq r<\frac{M}{2}\\
q,rは整数
こうなっています。
ベクトルを成分別に分けて書くと上記の式はこうなります。
h(\vec{x},t)=h(x,z,t)=\sum_{m=-\frac{M}{2}}^{\frac{M}{2}-1}\sum_{n=-\frac{N}{2}}^{\frac{N}{2}-1}\tilde{h}(\frac{2\pi n}{Lx},\frac{2\pi m}{Lz},t)e^{i(\frac{2\pi n}{Lx}x+\frac{2\pi m}{Lz}z)}
n,m,x,zがマイナスから始まっていることに注意です。なので普通にFFT(iFFT)の計算を適応しただけだと間違った答えになります。
画像をみればわかるとおり、中心に周波数0成分がきていますね。
一応証明
さっき太文字で書いたやり方で本当に正しいのか、一応自分用としてもメモさせてください・・・
まずは0オリジンで考えたいのでこうします。
n'\in{(0,1,2,3,...,N-1)}\\
m'\in{(0,1,2,3,...,M-1)}\\
x'\in{(0,1,2,3,...,N-1)}\\
z'\in{(0,1,2,3,...,M-1)}\\
x''\equiv x'+N/2 \quad(mod \quad N)\\
z''\equiv z'+M/2 \quad(mod \quad M)\\
するとx,z,n,mはこう書けます。
n=n'-\frac{N}{2}\\
m=m'-\frac{M}{2}\\
x=\frac{L_x(-\frac{N}{2}+x')}{N}=-\frac{L_x}{2}+\frac{x'L_x}{N}\\
z=\frac{L_z(-\frac{M}{2}+z')}{M}=-\frac{L_z}{2}+\frac{z'L_z}{M}\\
続いて式簡略化のため${h'}$と$\tilde{h'}$を定義
{h'}({x'},{z'},t)=h(x,z,t)\\
\tilde{h'}(n',m',t)=\tilde{h}(\frac{2\pi n}{Lx},\frac{2\pi m}{Lz},t)\\
あとは求めたい$h()$についてゴリゴリ式変形すると
{h'}({x'},{z'},t)=h(x,z,t)=\sum_{m=-\frac{M}{2}}^{\frac{M}{2}-1}\sum_{n=-\frac{N}{2}}^{\frac{N}{2}-1}\tilde{h}(\frac{2\pi n}{Lx},\frac{2\pi m}{Lz},t)e^{i(\frac{2\pi n}{Lx}x+\frac{2\pi m}{Lz}z)}\\
=\sum_{m'=0}^{M-1}\sum_{n'=0}^{N-1}\tilde{h'}(n',m',t)e^{i(\frac{2\pi (n'-\frac{N}{2})}{Lx}x+\frac{2\pi (m'-\frac{M}{2})}{Lz}z)}\\
=\sum_{m'=0}^{M-1}\sum_{n'=0}^{N-1}\tilde{h'}(n',m',t)e^{
i(\frac{\pi N}{2}-\pi x'+\frac{2\pi n'(x'-N/2)}{N})+
i(\frac{\pi M}{2}-\pi z'+\frac{2\pi m'(z'-M/2)}{M})
}\\
ここでN,Mは256としていたので
e^{i(\frac{\pi N}{2})}=1\\
e^{i(\frac{\pi M}{2})}=1\\
なので消すことができ
e^{i (\frac{2\pi (x'+N/2)}{N})}=\\
e^{i (\frac{2\pi (x'-N/2) +2\pi N}{N})}=\\
e^{i (\frac{2\pi (x'-N/2)}{N})}=\\
e^{i (\frac{2\pi x''}{N})}\\
と置き換えられ
e^{i \pi}=-1
なのでまとめると
{h'}({x'},{z'},t)=
\sum_{m'=0}^{M-1}
\sum_{n'=0}^{N-1}
\tilde{h'}(n',m',t)e^{
i(\frac{\pi N}{2}-\pi x'+\frac{2\pi n'(x'-N/2)}{N})+
i(\frac{\pi M}{2}-\pi z'+\frac{2\pi m'(z'-M/2)}{M}) }\\
=(-1)^{x'}(-1)^{z'}\sum_{m'=0}^{M-1}\sum_{n'=0}^{N-1}\tilde{h'}(n',m',t)e^{
i(\frac{2\pi n'x''}{N})+
i(\frac{2\pi m'z''}{M})
}
あとは$x'$と$x''$を入れ替え、$z$も同様にして
{h'}({x''},{z''},t)\\
={h'}({x'}+\frac{N}{2} (mod N),{z'}+\frac{M}{2} (mod M),t)\\
=(-1)^{x''}(-1)^{z''}\sum_{m'=0}^{M-1}\sum_{n'=0}^{N-1}\tilde{h'}(n',m',t)e^{
i(\frac{2\pi n'x'}{N})+
i(\frac{2\pi m'z'}{M})
}
これでやっとよくあるFFTの形になりました(ほんとか)。。。
普通と違うところは
・$h'$の添え字 : →計算結果の書き込み先を(N/2,M/2)ずらせばよい
・$(-1)^{x''}$と$(-1)^{z''}$ : →計算結果の書き込み先index x,zが(x+z)%2==1となる要素に-1をかければよい
ということでやはりよさそうです。
これらの要素を盛り込むことでこのようなコードになります。
#pragma kernel FFT2Dfunc256inv
#pragma kernel DFT2Dfunc256inv
#define PI 3.14159265358979323846264338328
RWStructuredBuffer<float2>buffer;//xが実数、yが虚数を格納RWStructuredBuffer<float2>buffer_dmy;//xが実数、yが虚数を格納//256*256要素の2D IFFT専用かつ負の周波数もあることも考慮しているコード(普通のFFT,DFTは正の周波数からはじまる)//グループ数Nで実行されること前提//2回実行されること前提#define M (8)
#define N (1<<M)
groupsharedfloat2block[N];[numthreads(N/2,1,1)]voidFFT2Dfunc256inv(uintid:SV_DispatchThreadID,uintgrid:SV_GroupID,uintgi:SV_GroupIndex){block[gi*2]=buffer[grid*N+gi*2];block[gi*2+1]=buffer[grid*N+gi*2+1];for(intloopidx=0;loopidx<M;loopidx++){intdleng=1<<(M-loopidx-1);uintt=gi%dleng;uintt0=(gi/dleng)*dleng*2+t;uintt1=t0+dleng;GroupMemoryBarrierWithGroupSync();floatr1=block[t1].x;floati1=block[t1].y;floatr0=block[t0].x-r1;floati0=block[t0].y-i1;floatrad=PI*t/dleng;//invなので-がかかっているfloatfsin=sin(rad);floatfcos=cos(rad);block[t0].x+=r1;block[t0].y+=i1;block[t1].x=r0*fcos-i0*fsin;block[t1].y=r0*fsin+i0*fcos;}GroupMemoryBarrierWithGroupSync();float2reim0=block[reversebits(gi*2)>>(32-M)];//32はuint=32bitの32float2reim1=block[reversebits(gi*2+1)>>(32-M)];reim1=-reim1;//出力結果に(-1)^indexが乗算されるので//書き込みはx,yを転置している。これによって2D FFTの計算の1回目と2回目を同じコードにできて、かつ、最終的なメモリ配置は最初と同じに戻る//最終的な書き込み先をN/2,N/2ずらすのも忘れずにbuffer_dmy[(gi*2+N/2)%N*N+grid]=reim0;buffer_dmy[(gi*2+1+N/2)%N*N+grid]=reim1;}//DFTで愚直に書いたバージョン//デバッグ用、理解を深める用//グループ数Nで実行されること前提[numthreads(N,1,1)]voidDFT2Dfunc256inv(uintid:SV_DispatchThreadID,uintgrid:SV_GroupID,uintgi:SV_GroupIndex){intx=gi-N/2;intz=grid-N/2;float2dftsum=0;for(intj=0;j<N;j++){intkz=-N/2+j;//* (2.0f * PI / N);for(inti=0;i<N;i++){intkx=-N/2+i;// *(2.0f * PI / N);floatrad=((kx*x+kz*z)%N)*(2.0f*PI/N);float2h=buffer[j*N+i];dftsum.x+=h.x*cos(rad)-h.y*sin(rad);dftsum.y+=h.y*cos(rad)+h.x*sin(rad);}}buffer_dmy[id]=dftsum;}
Dispatch側のコード
usingUnityEngine;publicclassFFT:MonoBehaviour{constboolDEBUG=false;[SerializeField]ComputeShadershader;ComputeBufferbuffer_dbg;intkernel_FFT2Dfunc256inv,kernel_DFT2Dfunc256inv;voidAwake(){kernel_FFT2Dfunc256inv=shader.FindKernel("FFT2Dfunc256inv");kernel_DFT2Dfunc256inv=shader.FindKernel("DFT2Dfunc256inv");}voidStart(){if(DEBUG)buffer_dbg=newComputeBuffer(1,sizeof(float)*2);}//bufferが入力、FFTの結果がbufferに出力publicvoidFFT2D_256_Dispatch(ComputeBufferbuffer,ComputeBufferbuffer_dmy){if(DEBUG)DEBUG_func1(buffer);//①引数をセットshader.SetBuffer(kernel_FFT2Dfunc256inv,"buffer",buffer);shader.SetBuffer(kernel_FFT2Dfunc256inv,"buffer_dmy",buffer_dmy);// GPUで計算shader.Dispatch(kernel_FFT2Dfunc256inv,256,1,1);//②引数をセットshader.SetBuffer(kernel_FFT2Dfunc256inv,"buffer",buffer_dmy);shader.SetBuffer(kernel_FFT2Dfunc256inv,"buffer_dmy",buffer);// GPUで計算shader.Dispatch(kernel_FFT2Dfunc256inv,256,1,1);if(DEBUG)DEBUG_func2(buffer);}//bufferが入力、FFTの結果がbuffer_dmyに出力publicvoidDFT2D_256_Dispatch(ComputeBufferbuffer,ComputeBufferbuffer_dmy){//引数をセットshader.SetBuffer(kernel_DFT2Dfunc256inv,"buffer",buffer);shader.SetBuffer(kernel_DFT2Dfunc256inv,"buffer_dmy",buffer_dmy);// GPUで計算shader.Dispatch(kernel_DFT2Dfunc256inv,256,1,1);}//FFTとDFTを比較voidDEBUG_func1(ComputeBufferbuffer){if(buffer_dbg.count!=buffer.count)buffer_dbg=newComputeBuffer(buffer.count,sizeof(float)*2);DFT2D_256_Dispatch(buffer,buffer_dbg);}//FFTとDFTを比較voidDEBUG_func2(ComputeBufferbuffer){Vector2[]bfr=newVector2[buffer.count];Vector2[]bfr_dbg=newVector2[buffer.count];buffer.GetData(bfr);buffer_dbg.GetData(bfr_dbg);floatrss=0.0f;for(inti=0;i<buffer.count;i++){Vector2v2=bfr[i]-bfr_dbg[i];rss+=v2.sqrMagnitude;}Debug.Log(rss);}}
カーネルプログラムFFT2Dfunc256invはコメントにもあるよう
・iFFTを普通に計算し(Cooley-Tukeyなので最後にbit逆順がある)
・書き込み先indexが奇数なら-1をかけ
・N/2ずらして
・行列転置(縦横入れ替え)してメモリに書き込み
しています。
横方向を計算した後 縦方向を計算するので合計2回実行される前提のコードです。
文字だけだとわかりにくいのでプログラムの動作を画像化してみました。カラーが最初のメモリの位置に相当します。色味が落ちてるところは-1がかかっているという意味です。
これによって
・計算結果全体を(N/2,N/2)ずらす
・要素のindex x,zが(x+z)%2==1となる要素に-1をかける
が実現していることがわかります。
(と、偉そうにのたまいましたが皆さんの好きなように2D逆フリーエ変換を実装すればいいと思います。)
4.右の画像から実際の位置にレンダリング
いろいろ大変な実装でしたが画面に描画してうまくいったとき疲れが全部吹き飛びます!あと一歩です。
すでに高さ情報はComputeBufferに格納されているので
Graphics.DrawProceduralNow
などで手軽に描画することができます。一番簡単なPointsの描画をしてみます。
CPU側コードとShaderはこんなかんじ
usingUnityEngine;publicclassFFTOceanMain:MonoBehaviour{[SerializeField]ComputeShadershader;[SerializeField]ShaderrenderingShader;MaterialrenderingShader_Material;[SerializeField]FFTfFT;[SerializeField]Phillipsphillips;intkernel_GenerateSpectrumKernel;Vector2[]h_h0;publicComputeBufferd_h0,d_ht,d_ht_dmy;intcnt;privatevoidAwake(){kernel_GenerateSpectrumKernel=shader.FindKernel("GenerateSpectrumKernel");renderingShader_Material=newMaterial(renderingShader);cnt=0;}voidStart(){h_h0=phillips.Generate_h0();//CPU側メモリ確保。サイズはsizeof(Vector2)*256*256d_h0=newComputeBuffer(h_h0.Length,sizeof(float)*2);//GPU側メモリ確保 サイズはsizeof(Vector2)*256*256d_ht=newComputeBuffer((int)(Phillips.meshSize*Phillips.meshSize),sizeof(float)*2);//GPU側メモリ確保 サイズはsizeof(Vector2)*256*256d_ht_dmy=newComputeBuffer(d_ht.count,sizeof(float)*2);d_h0.SetData(h_h0);SetArgs();}voidSetArgs(){//ComputeShader引数をセットshader.SetBuffer(kernel_GenerateSpectrumKernel,"h0",d_h0);shader.SetBuffer(kernel_GenerateSpectrumKernel,"ht",d_ht);shader.SetInt("N",(int)Phillips.meshSize);shader.SetInt("seasizeLx",(int)Phillips.seasizeLx);shader.SetInt("seasizeLz",(int)Phillips.seasizeLz);// GPUバッファをマテリアルに設定renderingShader_Material.SetBuffer("d_ht",d_ht);// その他Shader 定数関連renderingShader_Material.SetInt("N",(int)Phillips.meshSize);renderingShader_Material.SetFloat("halfN",0.5f*Phillips.meshSize);renderingShader_Material.SetFloat("dx",1.0f*Phillips.seasizeLx/Phillips.meshSize);renderingShader_Material.SetFloat("dz",1.0f*Phillips.seasizeLz/Phillips.meshSize);}voidUpdate(){//引数をセットshader.SetFloat("t",0.03f*cnt);shader.Dispatch(kernel_GenerateSpectrumKernel,1,256,1);//d_h0からd_htを計算fFT.FFT2D_256_Dispatch(d_ht,d_ht_dmy);//d_htから高さデータを計算cnt++;}voidOnRenderObject(){// レンダリングを開始renderingShader_Material.SetPass(0);// n個のオブジェクトをレンダリングGraphics.DrawProceduralNow(MeshTopology.Points,256*256);}privatevoidOnDestroy(){//解放d_h0.Release();d_ht.Release();d_ht_dmy.Release();}}
Shader"Custom/SurfaceShader"{SubShader{Pass{CGPROGRAM// シェーダーモデルは5.0を指定#pragma target 5.0
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
StructuredBuffer<float2>d_ht;uintN;floathalfN;floatdx;floatdz;float4ui_calcPos(uintui_idx){uintx=ui_idx%N;uintz=ui_idx/N;float2h=d_ht[ui_idx];returnfloat4((1.0*x-halfN)*dx,h.x,(1.0*z-halfN)*dz,1);}structVSOut{float4pos:SV_POSITION;};// 頂点シェーダVSOutvert(uintid:SV_VertexID){VSOutoutput;output.pos=ui_calcPos(id);output.pos=mul(UNITY_MATRIX_VP,output.pos);returnoutput;}// ピクセルシェーダーfixed4frag(VSOuti):COLOR{returnfloat4(0,1,1,1);}ENDCG}}}
実行結果
これだけでも十分綺麗ですね~!
ここまでのコードは
github
https://github.com/toropippi/OceanFFT/tree/main/OceanFFT1_points
にUPしてます。
法線を計算
さてせっかく綺麗な絵が出そうな雰囲気なので法線も計算してみましょう。
中心差分で算出
お手軽な方法として、すでに計算してある海面の高さを利用して中心差分で勾配を計算します。だんだん数値計算 Advent Calendarみが増してきました
x,z方向の勾配が求まれば、海面と垂直なベクトルがでます。
\vec{N}=normalize(-xの傾き,1,-zの傾き)
この計算のところだけピックアップします。雰囲気だけでもわかってもらえれば幸いです。
uintx0=(id.x-1+N)%N;uintx1=(id.x+1)%N;uinty0=(id.y-1+N)%N;uinty1=(id.y+1)%N;floatsubx=0.5f*(ht[x1+id.y*N].x-ht[x0+id.y*N].x)*rdx;floatsubz=0.5f*(ht[id.x+y1*N].x-ht[id.x+y0*N].x)*rdz;float3vec=normalize(float3(-subx,1.0,-subz));float4out4=1;out4.xyz=vec;tex[id]=out4;
法線ベクトル情報x,y,zをr,g,bで出力
できました。あとは煮るなり焼くなり法線情報でうまくライティングすればきれいな絵ができるでしょう。
github
https://github.com/toropippi/OceanFFT/tree/main/OceanFFT2_normal
念のためですが、法線が本当に正しいか、Aを100倍にして出力してみました。
x方向に向いている面は赤く、z方向に向いている面は青くなっています。面の法線ベクトルのx,z成分がr,bに対応しているので正しそうです。
偏微分で解析的に算出
ところでせっかく難しい式を計算をしてスペクトルを出してきたので、法線も式から求められないでしょうか。
これも結論から言うとできます。
これで偏微分が計算できます。
\nabla h(\vec{x},t)=\sum_{\vec{k}}i\vec{k}\tilde{h}(\vec{k},t)e^{i\vec{k}\cdot \vec{x}}
$\tilde{h}(\vec{k},t)$は「2.それをベースに毎フレームh(k,t)をGPU上で生成」で求めたやつです。これに$i\vec{k}$がかかっていますが、各要素の計算時に求めていた$kx,kz$を使うだけです。
x方向の偏微分ならkxをz方向の偏微分ならkzのみを使います。
...............................[numthreads(256,1,1)]voidGenerateSpectrumKernel(uint2id:SV_DispatchThreadID){uintx=id.x;uinty=id.y;uintin_index=y*N+x;uintin_mindex=(N-y)%N*N+(N-x)%N;// mirroreduintout_index=y*N+x;// calculate wave vectorfloat2k;k.x=(-(int)N/2.0f+x)*(2.0f*PI/seasizeLx);k.y=(-(int)N/2.0f+y)*(2.0f*PI/seasizeLz);// calculate dispersion w(k)floatk_len=sqrt(k.x*k.x+k.y*k.y);floatw=sqrt(9.81f*k_len);float2h0_k=h0[in_index];float2h0_mk=h0[in_mindex];// output frequency-space complex valuesfloat2htval=complex_add(complex_mult(h0_k,complex_exp(w*t)),complex_mult(conjugate(h0_mk),complex_exp(-w*t)));ht[out_index]=htval;//普通の高さの逆フーリエ前float2htival;//i*htvalhtival.x=-htval.y;htival.y=htval.x;ht_dx[out_index]=htival*k.x;//x方向偏微分の逆フーリエ前ht_dz[out_index]=htival*k.y;//z方向偏微分の逆フーリエ前}
そしてそれを逆フーリエ変換することで海面の高さの勾配(偏微分)が求まります。ここで、手元には複素数の状態で結果があると思いますが、使うのは実数成分のみです。海面の高さには実数のみを使用しているので勾配情報も実数のみを使う必要があります。
あとはさっきと同じように法線を計算し
できました。
さっきと見た目かわんないですが、中心差分よりは精度よくできてると思います。
これもgithubにupしてるので参考にしてください。
github
https://github.com/toropippi/OceanFFT/tree/main/OceanFFT3_analyticalNormal
もうちょっとライティングを頑張る
正直Compute Shader以外のシェーダーやレンダリングは詳しくないのでたいしたことは書けません。
一応、法線から反射ベクトルを計算してフレネル反射とか実装したりしなかったり・・・
コードを見ればわかりますが視線ベクトル決め打ちのレイトレーシングみたいなことをしています、、、だからカメラ動かしても絵は変わらないというひどいコード。誰かレンダリング教えて・・・
github
https://github.com/toropippi/OceanFFT/tree/main/OceanFFT4lighting
まぁかつてないほど海っぽくなったのでいいでしょう。
Choppy wave
NVIDIAのスライドを見るに、さっきの偏微分にすごい似た、でもちょっと違う式で計算されたDx,Dyというのを求めています。これは法線を計算するためのものではなく、波をより尖らせたChoppy waveを作るために必要なようです。
今までの波はあくまで有限個のsin,cosの足し合わせでできた波で、ちょっと滑らかになりすぎてるという問題があったようです。それをアーティスティックな謎テクで解決するのが今から説明する方法です。
いや滑らかすぎるならもっと離散点を増やして細かいsin,cosを足し合わせばいいじゃないかと個人的には思いましたが、そう書いてあるので仕方ありません。
まぁおそらく離散点を増やすと計算負荷が上がるからダメなんでしょうね。
で、このDx,Dyをどう使うかというと
\vec{D}(\vec{x},t)=\sum_{\vec{k}}-i\frac{\vec{k}}{k}\tilde{h}(\vec{k},t)e^{i\vec{k}\cdot \vec{x}}\\
\vec{x}'=\vec{x}+\lambda \vec{D}(\vec{x},t)
この$\vec{x}'$を新しい座標として使います。
今まで$\vec{x}=(x,z)$としてx,z座標は固定でやってきてましたよね。でy座標だけが動いていたと。
今度は$\lambda$に2.0とか-3.4とか適当な値をいれることでx,zもリアルタイムに動いて、見た目が激しくなるというイメージです。
\vec{D}(\vec{x},t)=\sum_{\vec{k}}-i\frac{\vec{k}}{k}\tilde{h}(\vec{k},t)e^{i\vec{k}\cdot \vec{x}}\\
の求め方ですが、偏微分を求めるときに書いたコードにちょっと付け足すだけです。
..................[numthreads(256,1,1)]voidGenerateSpectrumKernel(uint2id:SV_DispatchThreadID){uintx=id.x;uinty=id.y;uintin_index=y*N+x;uintin_mindex=(N-y)%N*N+(N-x)%N;// mirroreduintout_index=y*N+x;// calculate wave vectorfloat2k;k.x=(-(int)N/2.0f+x)*(2.0f*PI/seasizeLx);k.y=(-(int)N/2.0f+y)*(2.0f*PI/seasizeLz);// calculate dispersion w(k)floatk_len=sqrt(k.x*k.x+k.y*k.y);floatw=sqrt(9.81f*k_len);float2h0_k=h0[in_index];float2h0_mk=h0[in_mindex];// output frequency-space complex valuesfloat2htval=complex_add(complex_mult(h0_k,complex_exp(w*t)),complex_mult(conjugate(h0_mk),complex_exp(-w*t)));ht[out_index]=htval;//普通の高さの逆フーリエ前float2htival;//i*htvalhtival.x=-htval.y;htival.y=htval.x;ht_dx[out_index]=htival*k.x;//x方向偏微分の逆フーリエ前ht_dz[out_index]=htival*k.y;//z方向偏微分の逆フーリエ前if(k_len!=0.0){k.x/=k_len;k.y/=k_len;}displaceX[out_index]=-htival*k.x;//Dxの逆フーリエ前displaceZ[out_index]=-htival*k.y;//Dyの逆フーリエ前}
これで
-i\frac{\vec{k}}{k}\tilde{h}(\vec{k},t)
の部分まで求まったので、あとは3の手順でiFFTして完成です。
数式のお気持ちを考える
これで新しい座標$\vec{x}'$を求めることができましたが、これがどんな意味があるのか少し考えてみましょう。
上でも書いたよう、$\vec{D}(\vec{x},t)$の式は偏微分の式に酷似しています。
\nabla h(\vec{x},t)=\sum_{\vec{k}}i\vec{k}\tilde{h}(\vec{k},t)e^{i\vec{k}\cdot \vec{x}}\\
\vec{D}(\vec{x},t)=\sum_{\vec{k}}-i\frac{\vec{k}}{k}\tilde{h}(\vec{k},t)e^{i\vec{k}\cdot \vec{x}}\\
違うのはマイナスがかかっていることと、ベクトルの長さkで割っているところです。
kで割っても正負は反転しないのでここはざっくり"(偏微分×-1)に近い値"として思考を進めてみます。
簡単に1次元として、x座標を移動させるということは、こんな形の波があったときに青丸が矢印の方向に移動することが想像できます。
ということは移動後は
となります。あれ、波が尖がるどころかなだらかになってるじゃないか・・・
次sinカーブでも同じように考えてみます。
これが$\lambda=1$のとき
やっぱりなだらかになってる。
これが正解では???
λをいじって最適なChoppy waveを作る
実は論文にもほかの参考になりそうなサイトにも、λの決め方についてほとんどなにも書かれていません。まぁ適当にいじって決めてねってことなんだと思いますが、数式のお気持ちを察するに、これはマイナスの値を入れないといけないのではないでしょうか。
実際にやってみました。
λ=0.0のとき
λ=2.0のとき
λ=-2.0のとき
λ=9.0のとき
λ=-9.0のとき
やっぱり
λはマイナスの数字を入れることで波を尖がらせることができ、プラスの値をいれると滑らかにすることができます。と私は理解しています。
法線を修正
さて、x,zの位置がずれたことで海面の傾きが変わったので法線も今までの値を使うことはできません。
今のところchoppy waveを実装して海面の法線をまじめに計算しているコードは中国語の https://zhuanlan.zhihu.com/p/96811613ここで紹介されてる https://github.com/Straw1997/FFTOceanにupされてるコードしか確認できませんでした (探せばほかにもあるかもしれませんが)。
この方のコードをみると、x,z座標を動かあした後 中心差分で求めているようでした。しかしそもそも海面の高さをhtの実数じゃなくて複素数の絶対値で求めていたり、ちょっと私のやり方とは違うので参考にはできませんでした。
そこで私オリジナルのやり方になりますが、下記のような方法で実装してみました。
#pragma kernel SetNormal
RWTexture2D<float4>tex;RWStructuredBuffer<float2>ht_dx;RWStructuredBuffer<float2>ht_dz;RWStructuredBuffer<float2>displaceX;RWStructuredBuffer<float2>displaceZ;uintN;floatdx;floatdz;floatlambda;[numthreads(256,1,1)]voidSetNormal(uint2id:SV_DispatchThreadID){uintx0=(id.x-1+N)%N;uintx1=(id.x+1)%N;uinty0=(id.y-1+N)%N;uinty1=(id.y+1)%N;floatsubx=ht_dx[id.x+id.y*N].x;floatsubz=ht_dz[id.x+id.y*N].x;//displaceXZだけ定義点が移動することを考えここで傾きをさらにいじるsubx*=2.0*dx/((displaceX[x1+id.y*N].x-displaceX[x0+id.y*N].x)*lambda+2.0*dx);subz*=2.0*dz/((displaceZ[id.x+y1*N].x-displaceZ[id.x+y0*N].x)*lambda+2.0*dz);float3vec=normalize(float3(-subx,1.0,-subz));float4out4=1;out4.xyz=vec;tex[id]=out4;}
考え方としてはこんな感じ。解説図では$\lambda=1$として省略してます。
displaceXの分だけ点が動いて線の傾きかたが圧縮、拡張されるというイメージです。
これらを適応して$\lambda=-2$としたコードがこちら。
github
https://github.com/toropippi/OceanFFT/tree/main/OceanFFT5ChoppyWave
ま、十分綺麗なのでいいでしょう。
ここで、波のポリゴンが交差してるのが見えるでしょうか。画面左と、左奥の2か所です。これがchoppy waveの恩恵で、今回の実装で相当リアルになってきたのがわかると思います。
実際自分も実装していて、この絵が作れた時はかなりテンションあがりました。それまでは確かにのぺっとしていて、ちょっとおかしいなって感じはありました。
ここまでくるとFFT Oceanの実装で一番重要なのってここなんじゃ・・・?とすら思います。
泡表現(bubbles)
さっき波ポリゴンが重なるといいましたが、今度はこの重なりを検出して泡を表現しようという話です。
この検出にも$\vec{D}(\vec{x},t)$を使います。(プログラムでいうとdisplaceX,displaceZ)
J=J_{xx}J_{zz}-J_{xz}J_{zx}\\
J_{xx}=1+\lambda\frac{\partial D_x(\vec{x},t)}{\partial x}\\
J_{zz}=1+\lambda\frac{\partial D_z(\vec{x},t)}{\partial z}\\
J_{xz}=\lambda\frac{\partial D_x(\vec{x},t)}{\partial z}\\
J_{zx}=\lambda\frac{\partial D_z(\vec{x},t)}{\partial x}\\
このJの正負がマイナスのとき、その場所で重なり(反転)が起きていることになります。
したがってこんなコードになります。
#pragma kernel SetNormalBubble
RWTexture2D<float4>normalTex;RWTexture2D<float>bubbleTex;RWStructuredBuffer<float2>ht_dx;RWStructuredBuffer<float2>ht_dz;RWStructuredBuffer<float2>displaceX;RWStructuredBuffer<float2>displaceZ;uintN;floatdx;floatdz;floatlambda;[numthreads(256,1,1)]voidSetNormalBubble(uint2id:SV_DispatchThreadID){uintx0=(id.x-1+N)%N;uintx1=(id.x+1)%N;uinty0=(id.y-1+N)%N;uinty1=(id.y+1)%N;floatdDxdx=0.5*(displaceX[x1+id.y*N].x-displaceX[x0+id.y*N].x);//中心差分floatdDzdz=0.5*(displaceZ[id.x+y1*N].x-displaceZ[id.x+y0*N].x);//中心差分floatdDxdz=0.5*(displaceX[id.x+y1*N].x-displaceX[id.x+y0*N].x);//中心差分floatdDzdx=0.5*(displaceZ[x1+id.y*N].x-displaceZ[x0+id.y*N].x);//中心差分floatgradx=ht_dx[id.x+id.y*N].x;floatgradz=ht_dz[id.x+id.y*N].x;//displaceXZだけ定義点が移動することを考えここで傾きをさらにいじるgradx*=dx/(dDxdx*lambda+dx);gradz*=dz/(dDzdz*lambda+dz);float3vec=normalize(float3(-gradx,1.0,-gradz));float4out4=1;out4.xyz=vec;normalTex[id]=out4;//Jの計算floatJxx=1.0+dDxdx*lambda;floatJzz=1.0+dDzdz*lambda;floatJxz=dDxdz*lambda;floatJzx=dDzdx*lambda;floatJ=Jxx*Jzz-Jxz*Jzx;//J<0なら面が裏返しになってるbubbleTex[id]=J;}
レンダリング側
Shader"Custom/SurfaceShader"{Properties{_MainTexN("-",2D)="black"{}_MainTexB("-",2D)="black"{}}SubShader{Pass{CGPROGRAM// シェーダーモデルは5.0を指定#pragma target 5.0
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
sampler2D_MainTexN;sampler2D_MainTexB;StructuredBuffer<float2>d_ht;StructuredBuffer<float2>d_displaceX;StructuredBuffer<float2>d_displaceZ;uintN;floathalfN;floatdx;floatdz;floatlambda;floatGet_d_ht_real(uintx,uintz){float2h=d_ht[x%256+z%256*256];returnh.x;}float2Get_d_displaceXZ(uintx,uintz){float2ret;ret.x=d_displaceX[x%256+z%256*256].x;ret.y=d_displaceZ[x%256+z%256*256].x;returnlambda*ret;}structVSOut{float4pos:SV_POSITION;float3pos2:TEXCOORD2;float2uv:TEXCOORD0;};// 頂点シェーダ、1つの4角形ポリゴンに頂点4つVSOutvert(uintid:SV_VertexID,float3normal:NORMAL){VSOutoutput;uintsqx=((id+1)%4/2);//連続するid4つで四角形を作るuintsqz=(1-id%4/2);sqx+=(id/4)%256;sqz+=(id/4)/256;output.pos.y=Get_d_ht_real(sqx,sqz);output.pos.xz=float2((sqx-halfN)*dx,(sqz-halfN)*dz);//4角形output.pos.xz+=Get_d_displaceXZ(sqx,sqz);output.pos.w=1;output.pos2=output.pos.xyz;output.pos=mul(UNITY_MATRIX_VP,output.pos);floatrN1=1.0/N;output.uv=float2(sqx,sqz)*rN1+float2(0.5,0.5)*rN1;returnoutput;}// ピクセルシェーダー// ワールド座標系がよくわからないのでレイトレーシング的に色を決定している(要修正)float4frag(VSOuti):COLOR{float4col;col.w=1;float3normal=normalize(tex2D(_MainTexN,i.uv).xyz);//線形補完されて単位ベクトルじゃなくなっているのでfloat3viewDir=normalize(i.pos2-float3(0,41,-330));//ここはMain Cameraの座標を埋め込みfloat3lightDir=normalize(float3(0.15,0.45,0.6));//ライトベクトルも埋め込みfloat3reflectDir=-2.0*dot(normal,viewDir)*normal+viewDir;floatv=dot(reflectDir,lightDir);float3sky=(v+1.0)*float3(105.0/256,133.0/256,184.0/256);//空の色も埋め込み、しかも単色floatfresnel=(0.05+(1-0.05)*pow(1-max(dot(normal,-viewDir),0),5));col.xyz=sky*fresnel+(1.0-fresnel)*float3(0.01,0.13,0.15);//海の色も埋め込みcol.xyz+=pow(max(v,0),649)*float3(1.2,1.0,0.86);//太陽の色も埋め込み//泡floatbubble=tex2D(_MainTexB,i.uv);if(bubble<-0.3){bubble=min(-bubble*0.4,1);//裏返り度合いに応じて徐々に白くなるcol.xyz=bubble*float3(1.0,1.0,1.0)+(1.0-bubble)*col.xyz;}returncol;}ENDCG}}}
結果
github
https://github.com/toropippi/OceanFFT/tree/main/OceanFFT6Bubbles
波のてっぺん付近が白く着色されているのがわかります。波の先端、特にとがっているところは海面が90度以上傾いていることがあります。それを先ほどの計算で検出し、泡としてレンダリングしたということになります。
論文のFigureがわかりやすいかなと思ったのですが、Choppy Surfaceと書いてある波はよく目を凝らしてみるとPosition=15付近でぐるっと回っているのがわかります。これに対しMinimum E-value(最小固有値)がそこで0を下回っているというのが読み取れます。
論文では、最大固有値は常に正で最小固有値は図のように負になるから、最大固有値×最小固有値がマイナスなのを確認できれば検出できるよねと言ってます。プログラムでは$Jxx,Jxz,Jzx,Jzz$の2*2の行列の行列式を求めることで、この最大固有値×最小固有値がマイナスかどうかを判定しています。(というか参考にしたコードがそういうコードだった)
おしまい
以上です。お疲れ様でした。
最初はCuda SampleにあるFFT OceanのUnity移植版を公開して終わりにしようと思っていたのですが、調べるほど新しい情報に出会って、あれもこれも盛り込まなきゃってなって、いつしか終わらなくなってしまいました。
Advent Calendarってクリスマスまでの日をカウントするためにあるんですよね?私は投稿日までの日をずっとカウントしてましたよ・・・間に合ってよかったー
※Shaderの要修正の部分はそのうち修正してgithubにupします。
参考文献
一番参考にした中国語の解説
https://zhuanlan.zhihu.com/p/96811613
https://zhuanlan.zhihu.com/p/64414956
https://zhuanlan.zhihu.com/p/95482541
https://github.com/Straw1997/FFTOcean
NV_OceanCS_Slides
http://www-evasion.imag.fr/~Fabrice.Neyret/images/fluids-nuages/waves/Jonathan/articlesCG/NV_OceanCS_Slides.pdf
UE4で海面シミュレーションと描画を行う at Qiita
https://qiita.com/monguri/items/3ad184c3316343c635f5
その他英語スライドなど
http://developer.download.nvidia.com/assets/gameworks/downloads/regular/events/cgdc15/CGDC2015_ocean_simulation_en.pdf
http://evasion.imag.fr/~Fabrice.Neyret/images/fluids-nuages/waves/Jonathan/articlesCG/waterslides2001.pdf
https://www.slideshare.net/Codemotion/an-introduction-to-realistic-ocean-rendering-through-fft-fabio-suriano-codemotion-rome-2017
参考にしたコード
https://github.com/nobnak/FftUnity
https://github.com/nobnak/FftUnity