GLSLでスモークシェーダーを作成する

画像

[KDPVの煙は、チュートリアルで得られた煙よりも少し複雑です。]



煙は常に謎の光輪に囲まれています。 それを見るのはいいが、モデル化するのは難しい。 他の多くの物理現象と同様に、煙は予測が非常に難しいカオスシステムです。 シミュレーションの状態は、個々の粒子間の相互作用に大きく依存します。



そのため、ビデオプロセッサで処理するのは非常に困難です。煙は、さまざまな場所で何百万回も繰り返される単一の粒子の動作に分解できます。



このチュートリアルでは、スモークシェーダーをゼロから作成することについて詳しく説明し、武器庫を拡張して独自の効果を作成できるように、いくつかの便利なシェーダー開発テクニックを説明します。



私たちが学ぶこと



これが私たちが努力する最終結果です。





リアルタイムゲームでの液体のダイナミクスに関するJos Stam研究で説明されているアルゴリズムを実装します。 さらに、テクスチャレンダリングする方法を学びます 。この手法はフレームバッファとも呼ばれます 。 多くのエフェクトを作成できるため、シェーダーのプログラミングに非常に役立ちます。



準備する



このチュートリアルの例とコードではJavaScriptとThreeJSを使用していますが、シェーダーをサポートする任意のプラットフォームで使用できます。 (プログラミングの基礎に慣れていない場合は、 このチュートリアルを学習する必要があります。)



すべてのコードサンプルはCodePenに保存されますが、記事に関連付けられているGitHubリポジトリにもあります(コードを読む方が便利な場合があります)。



理論と基礎



Jos Stamの研究からのアルゴリズムは、速度と視覚的品質を優先して、ゲームで必要な物理的精度を損ないます。



特に微分方程式に精通していない場合、この作業は実際よりもはるかに複雑に見えるかもしれません。 ただし、この手法の意味は次の図に要約されています。





分散のおかげで、各セルはその密度を隣接セルと交換します。



現実的な外観の煙効果を作成するために必要なのはこれだけです。各反復での各セルの値は、隣接するすべてのセルに「分散」されます。 この原則はすぐに明確になるとは限りません。例を試してみたい場合は、インタラクティブデモをご覧ください。





CodePenのインタラクティブなデモをご覧ください



セルをクリックすると、値100



が割り当てられます。 各セルがその値を隣接するセルに徐々に転送する方法がわかります。 これを確認する最も簡単な方法は、[ へ]をクリックして個々のフレームを表示することです。 表示モードを切り替えて、色の番号がそれらの番号と一致したときにどのように見えるかを確認します。



上記のデモは中央処理装置で動作し、サイクルは各セルで繰り返されます。 このループは次のようになります。



 //W =     //H =   //f =  / //     newGrid,    ,       for(var r=1; r<W-1; r++){ for(var c=1; c<H-1; c++){ newGrid[r][c] += f * ( gridData[r-1][c] + gridData[r+1][c] + gridData[r][c-1] + gridData[r][c+1] - 4 * gridData[r][c] ); } }
      
      





このコードは、アルゴリズムの基礎です。 各セルは、隣接する4つのセルの値の一部から自身の値を引いたものを受け取りますf



は1未満の係数です。セルの現在の値に4を掛けて、高い値から低い値に分散します。



これを明確にするために、次の状況を考慮してください。







中央のセル (グリッドの[1,1]



位置)を取得し、上記の散乱方程式を適用します。 f



0.1



あると仮定します:



 0.1 * (100+100+100+100-4*100) = 0.1 * (400-400) = 0
      
      





すべてのセルの値が同じであるため、散乱は発生しません!



次に、 左上隅のセルを検討します(表示されているグリッド外のすべてのセルの値は0



と考えられます)。



 0.1 * (100+100+0+0-4*0) = 0.1 * (200) = 20
      
      





これで、純利益は 20になりました! 最後のケースを見てみましょう。 1つのタイムステップの後(この数式をすべてのセルに適用した後)、グリッドは次のようになります。







中央のセルの分散をもう一度見てみましょう。



 0.1 * (70+70+70+70-4*100) = 0.1 * (280 - 400) = -12
      
      





純減は12になりました! したがって、値は常に大から小に変化します。



さて、もっとリアルにしたい場合は、セルのサイズを小さくする必要があります(デモで行うことができます)が、特定の段階では、各セルを順番に処理する必要があるため、すべてが非常に遅くなり始めます。 私たちの目標は、ビデオプロセッサの能力を使用して、すべてのセル(ピクセルなど)を同時に並行して処理できるシェーダーでこのアルゴリズムを記述することです。



要約すると、私たちの一般的な手法は、各ピクセルの各フレームが色の値の一部を失い、それを隣接するピクセルに渡すことです。 とても簡単ですね。 このシステムを実装して、何が起こるか見てみましょう!



実装



画面全体をレンダリングする基本的なシェーダーから始めます。 動作することを確認するには、画面を黒一色(またはその他)で塗りつぶしてください。 これは、私がJavascriptで使用する回路がどのようなものかを示しています。





上部のボタンをクリックして、HTML、CSS、JSコードを表示します。



シェーダーはシンプルです:



 uniform vec2 res; void main() { vec2 pixel = gl_FragCoord.xy / res.xy; gl_FragColor = vec4(0.0,0.0,0.0,1.0); }
      
      





res



およびpixel



は、現在のピクセルの座標を示します。 画面サイズを統一変数としてres



渡します。 (今のところ、それらは使用していませんが、すぐに役立ちます。)



ステップ1:ピクセル間で値を移動する



実装したいことをもう一度繰り返します。



私たちの一般的なテクニックは、すべてのフレームのすべてのフレームが色の値の一部を失い、それを隣接するピクセルに渡すことです。


この定式化では、シェーダーの実装は不可能です。 理由はわかりますか? シェーダーでできることは、処理中の現在のピクセルのカラー値を返すことだけです。 つまり、解決策が現在のピクセルのみに影響するように、問題を再定式化する必要があります。 私たちは言うことができます:



各ピクセルは、その隣のピクセルの色を少し取得し、独自のピクセルを少し失う必要があります。


これで、このソリューションを実装できます。 ただし、これを実行しようとすると、根本的な問題が発生します...



より単純なケースを考えてみましょう。 赤でイメージを徐々に再描画するシェーダーが必要だとしましょう。 次のシェーダーを作成できます。



 uniform vec2 res; uniform sampler2D texture; void main() { vec2 pixel = gl_FragCoord.xy / res.xy; gl_FragColor = texture2D( tex, pixel );//     gl_FragColor.r += 0.01;//    }
      
      





各フレームの各ピクセルの赤成分は0.01



ずつ増加することが予想されます。 代わりに、すべてのピクセルが最初よりも少しだけ赤くなっている静的な画像を取得します。 すべてのフレームがシェーディングされているという事実にもかかわらず、各ピクセルの赤成分は一度だけ増加します。



なぜこれが起こっているのか理解できますか?



問題



問題は、シェーダーで実行した操作が画面に転送され、その後永久に消えてしまうことです。 これで、プロセスは次のようになります。







均一な変数とテクスチャーをシェーダーに渡し、ピクセルを少し赤くし、画面上に描画して、最初からやり直します。 シェーダーで描画するものはすべて、次の描画ステップでクリアされます。



次のようなものが必要です。







画面に直接レンダリングする代わりに、ピクセルをテクスチャに描画し、そのテクスチャを画面に描画できます。 同じ画像が画面に表示されますが、出力を入力として転送できるようになりました。 したがって、すべてのフレームをリセットするだけでなく、値を蓄積または分配するシェーダーを取得できます。 これは「フレームバッファーを使用したフォーカス」と呼ばれます。



フレームバッファーでフォーカス



一般的な手法は、すべてのプラットフォームで同じです。 Googleは、使用する言語やツールの「テクスチャにレンダリング」し、実装の詳細を学習します。 また、 フレームバッファーオブジェクトの使用方法も確認できます 。これは、画面ではなく、バッファー内のレンダリング関数の単なる別名です。



ThreeJSでは、この関数の類似物はWebGLRenderTargetです。 レンダリングの中間テクスチャとして使用します。 ただし、小さな障害が残りました同時に読み取り、1つのテクスチャにレンダリングすることはできません 。 この制限を回避する最も簡単な方法は、2つのテクスチャを使用することです。



AとBを作成した2つのテクスチャとします。 その場合、メソッドは次のようになります。



  1. Aをシェーダーに渡し、Bにレンダリングします。
  2. Bを画面にレンダリングします。
  3. Bをシェーダーに渡し、Aにレンダリングします。
  4. Aを画面にレンダリングします。
  5. 繰り返し1。


短いコードは次のとおりです。



  1. Aをシェーダーに渡し、Bにレンダリングします。
  2. Bを画面にレンダリングします。
  3. AとBを変更します(つまり、変数AにはBにあるテクスチャが含まれるようになり、逆も同様です)。
  4. 繰り返し1。


それだけです ThreeJSでのこのアルゴリズムの実装は次のとおりです。





新しいシェーダーコードは[ HTML ]タブにあります。



開始時の黒い画面がまだ表示されています。 シェーダーもそれほど変わりません:



 uniform vec2 res; //     uniform sampler2D bufferTexture; //   void main() { vec2 pixel = gl_FragCoord.xy / res.xy; gl_FragColor = texture2D( bufferTexture, pixel ); }
      
      





この行を追加したという事実に加えて( test! ):



 gl_FragColor.r += 0.01;
      
      





画面が徐々に赤くなることがわかります。 これは非常に重要なステップなので、簡単に説明し、最初のアルゴリズムの動作と比較することができます。



タスク: gl_FragColor.r += pixel.x;



gl_FragColor.r += pixel.x;



とどうなりますか? 元の例とは異なり、フレームバッファを使用した例では? なぜ結果が異なるのか、なぜそうなのかを少し考えてください。



ステップ2:煙源を入手する



すべてを動かす前に、煙を作り出す方法を見つける必要があります。 最も簡単な方法は、シェーダーの任意の領域を手動で白でペイントすることです。



 //         float dist = distance(gl_FragCoord.xy, res.xy/2.0); if(dist < 15.0){ //    15  gl_FragColor.rgb = vec3(1.0); }
      
      





フレームバッファーの正確性を確認する場合は、色の値を追加するだけでなく、色の値を追加することもできます。 円が徐々に白くなることがわかります。



 //         float dist = distance(gl_FragCoord.xy, res.xy/2.0); if(dist < 15.0){ //    15  gl_FragColor.rgb += 0.01; }
      
      





別の方法は、この固定点をマウスの位置に置き換えることです。 マウスボタンが押されているかどうかを示す3番目の値を渡すことができます。 この方法では、左キーを押すことで煙を作成できます。 この機能の実装は次のとおりです。





クリックして煙を作成します。



シェーダーは次のようになります。



 //     uniform vec2 res; //   uniform sampler2D bufferTexture; // x,y -  . z -  / uniform vec3 smokeSource; void main() { vec2 pixel = gl_FragCoord.xy / res.xy; gl_FragColor = texture2D( bufferTexture, pixel ); //         float dist = distance(smokeSource.xy,gl_FragCoord.xy); //  ,     if(smokeSource.z > 0.0 && dist < 15.0){ gl_FragColor.rgb += smokeSource.z; } }
      
      





タスク:シェーダーでは通常、分岐(条件付き遷移)が高価であることを忘れないでください。 ifコンストラクトを使用せずにシェーダーを書き換えることはできますか? (ソリューションはCodePenにあります。)



理解できない場合は、 前のチュートリアルで、シェーダーでのマウスの使用に関する詳細な説明があります(照明に関する部分)。



ステップ3:煙を分散させる



これで最も簡単ですが、最も興味深い部分です! すべてをまとめて、最後にシェーダーに伝える必要があります。 各ピクセルは 隣接 ピクセル から色の一部を受け取り、 独自の一部を失います。



次のようになります。



 //   float xPixel = 1.0/res.x; //    float yPixel = 1.0/res.y; vec4 rightColor = texture2D(bufferTexture,vec2(pixel.x+xPixel,pixel.y)); vec4 leftColor = texture2D(bufferTexture,vec2(pixel.x-xPixel,pixel.y)); vec4 upColor = texture2D(bufferTexture,vec2(pixel.x,pixel.y+yPixel)); vec4 downColor = texture2D(bufferTexture,vec2(pixel.x,pixel.y-yPixel)); //   gl_FragColor.rgb += 14.0 * 0.016 * ( leftColor.rgb + rightColor.rgb + downColor.rgb + upColor.rgb - 4.0 * gl_FragColor.rgb );
      
      





係数f



は同じままです。 この場合、タイムステップ( 0.016



、つまりプログラムは60 fpsで実行されるため1/60)があり、値14



に落ち着くまで異なる数値をピックアップしました。 結果は次のとおりです。







ああ、それはすべてハングアップした!



これは、CPUのデモで使用したのと同じ散布方程式ですが、シミュレーションは停止します! その理由は何ですか?



テクスチャ(コンピューター上のすべての数値など)の精度には限界があることがわかりました。 ある時点で、減算する係数が小さくなりすぎて0に丸められるため、シミュレーションが停止します。 これを修正するには、最小値を下回っていないことを確認する必要があります。



 float factor = 14.0 * 0.016 * (leftColor.r + rightColor.r + downColor.r + upColor.r - 4.0 * gl_FragColor.r); //       float minimum = 0.003; if (factor >= -minimum && factor < 0.0) factor = -minimum; gl_FragColor.rgb += factor;
      
      





係数を取得するためにrgb



代わりにr



コンポーネントを使用します。これは、個々の数値を使用する方が簡単であり、すべてのコンポーネントが同じ値を持っているためです(煙が白いため)。



試行錯誤を通して、適切なしきい値は0.003



であり、プログラムは停止しません。 私は、常に一定の減少を保証するために、負の値の係数のみを心配しています。 この修正を追加すると、次のものが得られます。





ステップ4:スモークアップ



しかし、まだ煙のようには見えません。 すべての方向ではなく、上に移動したい場合は、重みを追加する必要があります。 下のピクセルが常に他の方向よりも影響を与える場合、ピクセルが上昇しているように見えます。



係数を試してみると、この式でかなり良いものを選択できます。



 //   float factor = 8.0 * 0.016 * ( leftColor.r + rightColor.r + downColor.r * 3.0 + upColor.r - 6.0 * gl_FragColor.r );
      
      





そして、これはシェーダーの外観です:





分散方程式に関する注意



私はオッズで遊んだので、立ち上がった煙がきれいに見えました。 彼を他の方向に動かすことができます。



シミュレーションの「拡大」は非常に簡単であることを追加することが重要です。 (値6.0



5.0



変更して、何が起こるかを確認してください)。 明らかに、これは細胞が失うよりも多くを得るという事実によるものです。



この方程式は、私が「貧弱な分散」のモデルとして引用した論文で実際に言及されています。 仕事にはもっと安定した別の方程式がありますが、それは私たちにとって非常に便利ではありません。主に、読み取り元のグリッドに書き込む必要があるからです。 つまり、一度に1つのテクスチャを読み書きする必要があります。



私たちが持っているものは私たちの目的には十分ですが、興味があれば、作品の説明を勉強することができます。 さらに、中央処理装置の対話型デモに別の方程式が実装されていますdiffuse_advanced()



関数を参照してください。



軽微な修正



画面の下部で煙を作成すると、そこに煙が詰まっていることに気付くかもしれません。 これは、一番下の行のピクセルがその下に存在しないピクセルから値を取得しようとしているためです。



これを修正するために、下のピクセルで下に0



見つけます。



 //    //        if(pixel.y <= yPixel){ downColor.rgb = vec3(0.0); }
      
      





CPUのデモでは、下のセルが分散しないことを確認するだけでこれに対処しました。 境界の外側のすべてのセルを手動で0



設定することもでき0



。 (CPUのデモのグリッドは、1行1列ですべての方向の境界を超えています。つまり、境界は表示されません)



スピードグリッド



おめでとうございます! これで、既製のスモークシェーダーができました! 最後に、作業で言及した速度場について簡単に説明したいと思います。





移動(移流)ステージは、静的速度場に沿って密度を移動します。



煙は上方向や他の方向に均等に分散する必要はありません;図に示すような一般的なパターンに従うことができます。 これは、色の値が現在のポイントで煙が移動する方向を表す別のテクスチャを送信することで実装できます。 これは、ライティングチュートリアルで法線マップを使用して各ピクセルの方向を示すのに似ています。



実際、速度テクスチャも静的である必要はありません! フレームバッファーでフォーカスを使用して、リアルタイムで速度を変更できます。 これについてはチュートリアルで説明しませんが、この機能には研究の大きな可能性があります。



おわりに



このチュートリアルから学ぶべき最も重要なことは、画面の代わりにテクスチャにレンダリングする機能が非常に有用なテクニックであることです。



どのフレームバッファーが便利ですか?



多くの場合、これらはゲームの後処理に使用されます。 各オブジェクトで使用するのではなく、カラーフィルターを適用する場合は、すべてのオブジェクトを画面に合わせてテクスチャにレンダリングし、このテクスチャにシェーダーを適用して画面に描画します。



別のユースケースは、 複数のパスを必要とするシェーダーの実装です 例:blur 。 通常、画像はシェーダーを通過し、x軸に沿ってブラーされ、次に再び通過してy軸に沿ってブラーします。



最後の例は、 前のチュートリアルで説明した遅延レンダリングです。 これは、シーンに複数の光源を効果的に追加する簡単な方法です。 ここで素晴らしいのは、照明の計算が光源の数に依存しなくなったことです。



技術記事を恐れないでください



より多くの詳細は、私が引用した研究で見つけることができます。 線形代数に精通している必要がありますが、システムを分析して実装しようとすることを妨げないでください。 その本質は、実装するのが非常に簡単です(係数の調整後)。



シェーダーについてもう少し学んでいただければ幸いです。



All Articles