パーティクルフォグ
今回のサンプルの実行結果
パーティクルとフォグ
前回は、頂点情報を使ってフォグを適用する距離フォグについて解説しました。
距離フォグを用いると、レンダリング結果にはうっすらと霧が掛かったような効果が得られ、幻想的なシーンを演出するのに役立ちます。
今回のテキストでは同じフォグに関連する技術を紹介しますが、その手法はまったく異なります。今回紹介するフォグはパーティクルフォグです。パーティクルとは、直訳すると[ 粒子 ]となる英単語ですが、プログラミングでは主に小さな点やスプライトを表すことが多いです。
今回のフォグにパーティクルフォグと名をつけたのは、前回の距離フォグが頂点の位置(カメラとの距離)に応じてフォグを掛ける方法だったのに対し、パーティクルを三次元空間内にばらまき、まるでフォグが掛かったように見せる方法だからです。
距離フォグはその性質上、奥行きがあり重なり合っているような霧の演出を行なうことができません。これは、レンダリングされる頂点の位置を使ってフォグを適用するため、どうしても手前から奥に向かって霧が単調に、均一に濃くなっていくような雰囲気になってしまうためです。
パーティクルフォグの場合はその名の通り、パーティクルを画面内に無数に配置し、それらを一重の霧の膜としてレンダリングします。これにより、霧が重なり合うような独特の雰囲気を演出できます。しかし、普通にパーティクルを配置しただけでは困った問題が出てきます。これを解消することが今回の肝となります。
パーティクルフォグの問題点
パーティクルフォグでは、板状の四角形ポリゴンを三次元空間にたくさん配置します。これらの板状のポリゴンに霧のようなテクスチャを適用して、ブレンドを有効にして半透明描画すればなんとなく霧っぽく見えそうな気がします。
まず、この霧のようなテクスチャを準備する都合上、当サイトオリジナルのライブラリ noiseX.js を今回は使います。このライブラリの詳細については以下を参照するといいでしょう。
参考:プロシージャルノイズ
霧のようなテクスチャが準備できたら、単純にこれをパーティクルに貼り付けてレンダリングしてみます。するとこうなります。
パーティクルにテクスチャを貼っただけの状態
中央にはトーラスがレンダリングされているのですが、はっきり言ってそれすらほとんど認識できないひどい状態ですね。それでは次に、ブレンドを有効にして、半透明でパーティクルがレンダリングされるようにしてみましょう。
すると、こうなります。
ブレンドを有効にする
こんな感じです。
正直なところ、まったくもって霧とは程遠い見た目です。これでは霧として利用することはできませんね。
では続いて、真四角で無機質なポリゴンの形状をわかりにくくするために、特殊な計算を用いてパーティクルの上角二箇所の透明度を上げて、半月形に見えるようにしてみましょう。
そうするとこうなります。
透明度を操作して半月形にする
だいぶそれっぽく見えるようになってきましたね。
しかし、これでもまだパーティクル、つまり板状のポリゴンがそこにあることがまるわかりの状態なので、霧というにはちょっと無理がありますね。
細部までよく見てみると、後ろのボックスモデルや、中央のトーラスモデルなど、別のモデルとパーティクルが交差する部分では、そのエッジが強烈に出ています。これでは霧というより板がそこに浮いているような感じにしか見えませんよね。
エッジ部分
このエッジが強烈に出てしまう現象こそが、パーティクルを用いたフォグの実装における最大の難点です。
このようなエッジが見えてしまっていては、そこにパーティクルがあることは明らかで非常に不自然です。なんとかこのエッジを消すことができれば、もっと自然なフォグを実現できるはずです。この問題を解消するためのポイントは[ 深度 ]と、そして[ 射影 ]です。
深度のレンダリングと射影テクスチャ
パーティクルフォグの難点であるこのエッジを目立たなくするためには、パーティクル以外のモデルがどの座標位置にあるかを見定め、それに応じてパーティクルとの深度の差を検出してやります。
なぜこんなことをするかというと、パーティクルと他のモデルとが交差するポイント、つまり深度がほとんど同じ場合にエッジが発生してしまうからです。逆に言えば、この交差するポイントだけパーティクルの透明度を上げてやることができれば、エッジは自然と目立たなくなるはずです。
これを実現するためには、パーティクル以外のモデルの深度値を保存しておき、パーティクルをレンダリングする際に深度値を読み出して利用しなければなりません。以前、シャドウマッピングや被写界深度で使った深度値のレンダリングを今回も使うことになります。
参考:シャドウマッピング
フレームバッファを用意し、そこに深度値のレンダリングを行うことができたとしても、これとパーティクルとの深度を比較するにはもうひと工夫必要です。
実際にパーティクルをレンダリングする際には、これからレンダリングしようとしているスクリーン座標位置の、他のモデルの深度値をリアルタイムに、しかもピクセル単位で持ってくる必要があります。深度値を格納したテクスチャが仮にあったとしても、今からどのピクセルの深度値を拾ってくればいいのかを計算してやらなくてはならないわけですね。
これを行なうために、こちらも以前のテキストで解説したテクニックを流用します。そのテクニックとは射影テクスチャマッピングです。
参考:射影テクスチャマッピング
射影テクスチャマッピングを使うと、モデルにテクスチャを映写機で投影したような効果を得ることができます。深度値をレンダリングしたテクスチャをパーティクルに投影してやることで、今まさにレンダリングされようとしているピクセル位置の、正確な深度値を取得することができるのですね。
この射影テクスチャマッピングによって得られた深度値と、パーティクル自身の深度値を比較して、一定範囲よりも深度が近しい場合には透明度を上げてやります。そうすることでエッジが浮き出てしまう部分ほど透明度が高くなり、結果的にエッジはほとんど見えなくなるというわけです。
深度値を射影テクスチャマッピングしたパーティクル
このような、深度値を利用したパーティクルのエッジ消去テクニックは、一般にソフトパーティクルなどと呼ばれています。ソフトパーティクルはフォグのほか、たとえば粉塵が舞っているようなシーン、あるいは炎や光のエフェクトなど、とにかく様々な場面で流用のきくテクニックです。ちょっと概念は難しいかもしれませんが、習得しておけばプログラミングの幅が広がるはずです。
シェーダの記述
今回のサンプルは三つのシェーダを使い分けて最終的なレンダリングシーンを作り出します。
第一のシェーダはモデルをグーローシェーディングでレンダリングする、通常のライティングシェーダ。第二のシェーダはパーティクル以外のモデルの深度値を、フレームバッファにオフスクリーンレンダリングするためのシェーダです。
そして第三のシェーダがパーティクル、つまりフォグをレンダリングするためのシェーダです。この第三のシェーダが今回のサンプルの肝ですね。
それではまず第一のシェーダ、通常のライティングを行なうシェーダのソースです。
ライティングを行なうシェーダ
// 頂点シェーダ
attribute vec3 position;
attribute vec3 normal;
attribute vec4 color;
uniform mat4 mMatrix;
uniform mat4 mvpMatrix;
uniform mat4 invMatrix;
uniform vec3 lightDirection;
uniform vec3 eyePosition;
uniform vec4 ambientColor;
varying vec4 vColor;
void main(void){
vec3 invLight = normalize(invMatrix * vec4(lightDirection, 0.0)).xyz;
vec3 invEye = normalize(invMatrix * vec4(eyePosition, 0.0)).xyz;
vec3 halfLE = normalize(invLight + invEye);
float diffuse = clamp(dot(normal, invLight), 0.1, 1.0);
float specular = pow(clamp(dot(normal, halfLE), 0.0, 1.0), 50.0);
vColor = color * vec4(vec3(diffuse), 1.0) + vec4(vec3(specular), 1.0) + ambientColor;
gl_Position = mvpMatrix * vec4(position, 1.0);
}
// フラグメントシェーダ
precision mediump float;
varying vec4 vColor;
void main(void){
gl_FragColor = vColor;
}
このシェーダに関しては、今まで散々使いまわしてきた基本的な処理を行なっているだけですので、特別なことはなにもありません。頂点シェーダ側で普通にライティングの計算を行い、フラグメントシェーダ側で色づけを行なっているだけですね。
さて、続いては深度値をフレームバッファにレンダリングするためのシェーダですが、こちらも以前に解説したものとあまり変わりません。
深度値をレンダリングするシェーダ
// 頂点シェーダ
attribute vec3 position;
uniform mat4 mvpMatrix;
varying vec4 vPosition;
void main(void){
vPosition = mvpMatrix * vec4(position, 1.0);
gl_Position = vPosition;
}
// フラグメントシェーダ
precision mediump float;
varying vec4 vPosition;
const float near = 0.1;
const float far = 10.0;
const float linerDepth = 1.0 / (far - near);
vec4 convRGBA(float depth){
float r = depth;
float g = fract(r * 255.0);
float b = fract(g * 255.0);
float a = fract(b * 255.0);
float coef = 1.0 / 255.0;
r -= g * coef;
g -= b * coef;
b -= a * coef;
return vec4(r, g, b, a);
}
void main(void){
float linerPos = linerDepth * length(vPosition);
vec4 convColor = convRGBA(linerPos);
gl_FragColor = convColor;
}
頂点シェーダ側では attribute 変数としてモデルの位置情報のみを受け取ります。それを座標変換した結果が、フラグメントシェーダ側に渡され深度値として描き込まれます。
フラグメントシェーダでは深度値を 32 ビット精度で描き込むための関数である convRGBA
関数が使われていますが、この関数がなにをやっているのかはシャドウマッピングのテキストに詳しく記載してありますのでそちらを参照してください。
さて、続いては第三のシェーダ、パーティクルをレンダリングするためのシェーダです。
こちらは結構文量が多いですが、臆せず見ていきましょう。
パーティクルフォグ用頂点シェーダ
attribute vec3 position;
attribute vec4 color;
attribute vec2 texCoord;
uniform mat4 mMatrix;
uniform mat4 mvpMatrix;
uniform mat4 tMatrix;
varying vec4 vPosition;
varying vec4 vColor;
varying vec2 vTexCoord;
varying vec4 vTexProjCoord;
void main(void){
vec3 pos = (mMatrix * vec4(position, 1.0)).xyz;
vPosition = mvpMatrix * vec4(position, 1.0);
vColor = color;
vTexCoord = texCoord;
vTexProjCoord = tMatrix * vec4(pos, 1.0);
gl_Position = vPosition;
}
まずは頂点シェーダからです。
attribute 変数としては頂点座標、頂点色、テクスチャ座標の三つが入ってきます。テクスチャ座標は、このあとフラグメントシェーダ側で半月形に見えるようにアルファ値を加工するために必要になります。
uniform 変数は通常の座標変換行列 mvpMatrix
以外に、モデル座標変換行列のみで入ってくる mMatrix
のほか、テクスチャを射影マッピングするための tMatrix
が入ってきます。この tMatrix
の中身については、以前の射影テクスチャマッピングのテキストと基本的にやっていることは同じですので、そちらを先に読んでおくと理解が早いかもしれません。
さて、続いてはこれと対になるフラグメントシェーダです。
パーティクルフォグ用フラグメントシェーダ
precision mediump float;
uniform vec2 offset;
uniform float distLength;
uniform sampler2D depthTexture;
uniform sampler2D noiseTexture;
uniform bool softParticle;
varying vec4 vPosition;
varying vec4 vColor;
varying vec2 vTexCoord;
varying vec4 vTexProjCoord;
float restDepth(vec4 RGBA){
const float rMask = 1.0;
const float gMask = 1.0 / 255.0;
const float bMask = 1.0 / (255.0 * 255.0);
const float aMask = 1.0 / (255.0 * 255.0 * 255.0);
float depth = dot(RGBA, vec4(rMask, gMask, bMask, aMask));
return depth;
}
const float near = 0.1;
const float far = 10.0;
const float linerDepth = 1.0 / (far - near);
void main(void){
float depth = restDepth(texture2DProj(depthTexture, vTexProjCoord));
float linerPos = linerDepth * length(vPosition);
vec4 noiseColor = texture2D(noiseTexture, vTexCoord + offset);
float alpha = 1.0 - clamp(length(vec2(0.5, 1.0) - vTexCoord) * 2.0, 0.0, 1.0);
if(softParticle){
float distance = abs(depth - linerPos);
if(distLength >= distance){
float d = distance / distLength;
alpha *= d;
}
}
gl_FragColor = vec4(vColor.rgb, noiseColor.r * alpha);
}
さて、フラグメントシェーダ側で受け取る uniform 変数は全部で五つあります。まず最初に出てくる offset
ですが、これはテクスチャ座標をずらすために使います。
先ほども書いたように、今回のサンプルでは当サイトオリジナルのノイズ生成ライブラリ noiseX.js を使っていますが、このライブラリではシームレスなノイズテクスチャが生成できます。シームレスとは継ぎ目の無い状態のことで、左の端と右の端、同じように上下の端が継ぎ目無くつながります。
シームレスなノイズテクスチャを使っているので、テクスチャ座標をずらしても違和感が出ることはありません。全てのパーティクルが同じノイズを表示している(同じテクスチャ座標を使用している)のはあまりかっこよくないので、フラグメントシェーダ側でテクスチャ座標をずらしてやるわけです。変数 offset
はこのテクスチャ座標のずらしに使われるのですね。
次に出てくる distLength
は、他のモデルの深度とパーティクルの深度の差が、どのくらい近い場合にアルファ値を操作するのか決めるための係数です。HTML 内の input 要素から 0.0 ~ 0.1 の値がフラグメントシェーダに入ってきます。
パーティクル以外のモデルの深度、つまりオフスクリーンレンダリングされたフレームバッファから読み出した深度が仮に 0.5 だったとします。このとき distLength
が仮に 0.1 だったとすると、パーティクルの深度が 0.4 ~ 0.6 の範囲に収まるとき、アルファ値に影響が出るようになるわけですね。
また、今回のサンプルではそもそもソフトパーティクルを適用するのかどうか、真偽値で受け取るようにフラグメントシェーダのソースを記述しています。uniform 変数 softParticle
に HTML 内の input 要素から受け取ったフラグが入ってきますので、それで処理を分岐するようにしています。
それではソースを引用しつつもう少し詳しく見てみます。
フラグメントシェーダの main
関数だけを抜き出してみます。
main 関数
void main(void){
float depth = restDepth(texture2DProj(depthTexture, vTexProjCoord));
float linerPos = linerDepth * length(vPosition);
vec4 noiseColor = texture2D(noiseTexture, vTexCoord + offset);
float alpha = 1.0 - clamp(length(vec2(0.5, 1.0) - vTexCoord) * 2.0, 0.0, 1.0);
if(softParticle){
float distance = abs(depth - linerPos);
if(distLength >= distance){
float d = distance / distLength;
alpha *= d;
}
}
gl_FragColor = vec4(vColor.rgb, noiseColor.r * alpha);
}
まず冒頭では、射影テクスチャマッピングを使ってオフスクリーンレンダリングした深度値を読み出しています。ここで float
型の変数 depth
に、今から処理しようとしているスクリーン座標上の、パーティクル以外のモデルの深度値が取得できます。
関数内の二行目、変数 linerPos
には、パーティクル自身の深度値が入ります。先ほどの depth
と、この linerPos
とを比較すれば、パーティクルと他のモデルの深度値の差が取得できることになりますね。
三行目では noiseColor
にオフセットを適用したノイズテクスチャの色情報を取得しています。これは簡単ですね。ちょっとわかりにくいのは次の四行目でしょう。ここでは、パーティクルを半月形になるように透明度を調整しています。 vec2(0.5, 1.0)
というテクスチャ座標位置と、パーティクルのテクスチャ座標との距離を length
という GLSL のビルトイン関数で求めておきます。それをクランプした上で 1.0 から減算します。こうすると、先ほどの vec2(0.5, 1.0)
という位置を中心として、円形に透明度が徐々に高くなっていくようなアルファ値を適用できます。ここで指定しているテクスチャ座標位置をずらしてやれば、半月形だけでなく円形に透明度を適用してやることもできるでしょう。
さて、ちょっと長くなりますが最後は肝心なソフトパーティクル処理を行なう部分。
main 関数内のソフトパーティクル処理
if(softParticle){
float distance = abs(depth - linerPos);
if(distLength >= distance){
float d = distance / distLength;
alpha *= d;
}
}
先ほど検出した二つの深度値を使って、両者の差を検出した結果が変数 distance
に入ります。GLSL のビルトイン関数 abs
を使うことで、取得した差は必ず正の値として変数に入ります。
ここで取得した深度値の差と、uniform 変数として入ってくる distLength
とを比較し、条件に合う場合だけアルファ値を調整しているのがわかると思います。最終的にはここで計算したアルファ値がパーティクルに適用され、見事ソフトパーティクルが実現できます。
javascript 側の処理
だいぶ長々と書いてきましたが、もう少し続きます。
最後はメインプログラム側の処理についてですね。
今回のサンプルでは、メモリ空間上に HTML のエレメントである canvas を生成し、そこにノイズテクスチャ用の値を入れます。noiseX.js があればこれは簡単にできますので、大丈夫でしょう。
ノイズを canvas に適用しているコード
// ノイズを生成し専用のcanvasに割り当てる
var n = new noiseX(5, 2, 0.6);
n.setSeed(new Date().getTime());
var noiseCanvas;
var noiseColor = new Array(128 * 128);
for(var i = 0; i < 128; i++){
for(var j = 0; j < 128; j++){
noiseColor[i * 128 + j] = n.snoise(i, j, 128);
noiseColor[i * 128 + j] *= noiseColor[i * 128 + j];
}
}
noiseCanvas = n.canvasExport(noiseColor, 128);
// ノイズテクスチャの準備
var noiseTexture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, noiseTexture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, noiseCanvas);
gl.generateMipmap(gl.TEXTURE_2D);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);
gl.bindTexture(gl.TEXTURE_2D, null);
今回のサンプルでは 128 x 128 の canvas を用意してそこにノイズを流し込んでいます。
同時に、生成した canvas からテクスチャを準備していますが、ポイントになるのは最後のほうで行なっているテクスチャパラメータの設定です。ここで texParameteri
メソッドで gl.REPEAT
を指定している部分がありますが、ここを間違ってしまうとせっかくのシームレスノイズが正しく表示されませんので気をつけましょう。
また、今回はパーティクルを半透明でレンダリングする都合上、ブレンドを有効にしてレンダリングを行ないます。ただし、ここで注意点があります。
思い出してみてほしいのですが、深度値をオフスクリーンで描き込む際には、取得した深度値を RGBA の各要素をフル活用して分割していましたよね。この結果をブレンド有効でレンダリングしてしまうと、もともとあった色情報と変換した深度値がブレンドされてしまうので、最終的に深度を読み出す段階で正しく復元できなくなってしまいます。
オフスクリーンで深度値をレンダリングするときにはブレンドを無効に。そして最終的にパーティクルをレンダリングする段階ではブレンドを有効化する、これを忘れないようにしましょう。
また、3D プログラミングでは言うまでもなく基本中の基本ですが、透明度のあるモデルをレンダリングする際には、奥にあるものから順にレンダリングする必要があります。
今回はパーティクルを 30 個飛ばしていますが、この辺の処理を一応簡単に解説しておきます。
パーティクル周りの設定
// パーティクル用のデータを初期化
var particleCount = 30;
var offsetPositionX = new Array(particleCount);
var offsetPositionZ = new Array(particleCount);
var offsetPositionS = new Array(particleCount);
var offsetTexCoordS = new Array(particleCount);
var offsetTexCoordT = new Array(particleCount);
for(i = 0; i < particleCount; i++){
offsetPositionX[i] = Math.random() * 6.0 - 3.0;
offsetPositionZ[i] = -Math.random() * 1.5 + 0.5;
offsetPositionS[i] = Math.random() * 0.02;
offsetTexCoordS[i] = Math.random();
offsetTexCoordT[i] = Math.random();
}
offsetPositionZ.sort(function(a, b){return a - b;});
パーティクルの初期 X 座標が offsetPositionX
に入ります。同様に、Z 座標も初期化されます。さらに、パーティクルごとに移動するスピードを変えるため offsetPositionS
にも乱数を取得しています。
その下にある二つの配列はテクスチャのオフセット座標です。これら全ての値が取得し終わったら、Z 座標をソートして奥から順番にパーティクルがレンダリングされるようにしています。
まとめ
だいぶてんこ盛りな内容になりましたね。かなり駆け足だったので、一度で理解できない部分があるかもしれません。
ただ、実は今回のサンプルは、今までのテキストで解説してきたテクニックを流用して寄せ集めたものでしかありません。深度値のオフスクリーンレンダリングや射影テクスチャマッピングを、うまく利用してソフトパーティクルによるフォグを実現しているだけなのですね。
ベースとなる技術をきちんと身につけていれば、あとはそれをどう利用し、どう結果に結びつけるかだけです。落ち着いて、必要に応じて過去のテキストを振り返りながら、実装していただければと思います。
今回のサンプルも実際に動作するものを見たい場合には以下にリンクがあります。