便利そうなシェーダーを作成してp5.jsで描画する
はじめに
前回でp5.jsからシェーダーを使用することができるようになったので、 よく使用しそうなシェーダーをいくつか作成しました。 どのようなシェーダーを作成するかを考えるにあたって、 出来るだけあまり同じようなテクニックを使用することがないように選択しました。
今回作成するシェーダーは以下になります。
- ボロノイ図
- カーネルフィルター
- ブルーミング
- 遠近法(透視投影)
シェーダーファイルは頂点シェーダーファイルとフラグメントシェーダーファイルが必要ですが、 頂点シェーダーの方はすべて同一のものを使用できますので省略します。 前回で使用したものを使います。
ボロノイ図
ボロノイ図は以前もPythonで作成しましたがその時と同じく、 複数の母点を配置して異なる二つの位置が同一領域ならば、同じ母点が最近接点となる性質を利用します。 これは全ての画素に対して並列に処理可能であるという性質をうまく利用したものになっています。
ボロノイ図の母点を配置するにあたって、GLSLの性質上母点の位置に関連する情報はハードコートしなければなりませんので、 多くの点を配置するならば、規則性を持たせるか別にコード生成プログラムを作成するといいと思います。今回の母点の数は少なめの10個になります。
コードは以下になりますが、母点のハードコート部分は一部省略します。 point配列で母点の位置を保持し、fやradius配列でそれぞれの母点の性質を保持しています。
重要部分は最近接母点の探索部分です。最近接母点の位置をtarget変数に保持し、その位置を使用して色を設定しています。
#ifdef GL_ES
precision mediump float;
#endif
#define PI 3.14159265358979323846
uniform float u_frame;
varying vec2 vTexCoord;
const int N = 10;
void main() {
vec2 st = vTexCoord.xy;
vec3 color = vec3(0.0);
float f[N];
f[0] = 0.01;
// 省略
f[9] = 0.007;
float radius[N];
radius[0] = 0.5;
// 省略
radius[9] = 0.01;
vec2 point[N];
point[0] = vec2(0.83,0.75);
// 省略
point[9] = vec2(0.10,0.10);
for(int i = 0;i < N; ++i) {
float angle = u_frame*f[i] * 2.0 * PI;
point[i] = fract(point[i] + vec2(abs(sin(angle * 0.1)), abs(sin(angle * 0.1)) * radius[i] * 0.3));
}
float mx = 1.;
vec2 target = vec2(0.0);
for (int i = 0; i < N; ++i) {
float dist = distance(st, point[i]);
if (dist < mx) {
mx = dist;
target = point[i];
}
}
color.r = target.y;
color.g = abs(sin(10.0 * (mx + fract(u_frame * 0.002) * PI))) * 0.7;
color.b = target.x;
gl_FragColor = vec4(color, 1.0);
}
カーネルフィルター
フィルター処理は画像処理において頻繁に使用されますが、シェーダーでも処理を行うことができます。 使用するには近傍画素を取得する必要がありますが、 これはuniform sampler2Dとtexture2Dを使用すれば、近傍画素情報を取得することができます。 sampler2D tex0はp5.js側から渡されたグラフィックの画素情報になります。
ここでは例としてガウシアンフィルターを作成します。
#ifdef GL_ES
precision mediump float;
#endif
#define PI 3.14159265358979323846
varying vec2 vTexCoord;
uniform sampler2D tex0;
uniform vec2 stepSize;
uniform float scale;
uniform int u_frame;
vec2 offset[3*3];
float kernel[3*3];
float weight = 0.0;
vec4 col = vec4(0.0);
float gaussian(float r, float sigma) {
return 1.0 / (sqrt(2.0 * PI) * sigma) * exp(-r / sigma * 0.5);
}
void main(){
vec2 uv = vTexCoord;
float sigma = 2.1 + sin(float(u_frame) * 0.01 * PI);
for(int i = 0; i < 3*3; ++i){
float x = mod(float(i), 3.0) - 1.0;
float y = (float(i) / 3.0 - 1.0);
kernel[i] = gaussian(x * x + y * y, sigma * sigma);
offset[i] = vec2(x * stepSize.x, y * stepSize.y);
vec4 color = texture2D(tex0, uv + offset[i]*scale);
col += color * kernel[i];
weight += kernel[i];
}
col.rgb /= weight;
gl_FragColor = vec4(col.rgb, 1.0);
}
offsetを利用して着目画素の近傍画素情報を取得しています。
上記コードはカーネルサイズ3のガウシアンフィルターを適用していますが、 カーネルサイズを変更することも可能ですし、ソーベルやラプラシアン、エンボスフィルターなども適用可能です。 ソーベルやラプラシアンが使用可能ということでエッジ検出も可能ということがわかります。
texture2Dを使用すればマルコフ連鎖系の操作が可能ということですね。
ブルーミング
ブルーミングが画像の明るい領域をblurで膨らませてその領域を強調するような効果です。 見た目としては明るい領域の境界があいまいになり、光が領域を超えて広がるような感じです。
処理の流れとしては、大きめのガウシアンフィルタをかけた後、RGBの輝度を平均化して元の画像との線形補完をとります。 ここではガウシアンフィルタ部分の処理は前項で行ったので、省略しますが大きめののフィルターサイズ(19x19)に設定しています。
#ifdef GL_ES
precision mediump float;
#endif
#define PI 3.14159265358979323846
varying vec2 vTexCoord;
uniform sampler2D tex0;
uniform sampler2D tex1;
uniform int u_frame;
void main() {
vec2 uv = vTexCoord;
vec4 img = texture2D(tex0, uv);
vec4 blur = texture2D(tex1, uv);
float avg = dot(blur.rgb, vec3(0.33333));
float angle = float(u_frame) * 0.01 * PI;
vec4 bloom = mix(img, blur, clamp(avg*(1.0 + abs(sin(angle))), 0.0, 1.0));
gl_FragColor = bloom;
}
上記画像だと明確に明るい部分が少ないのでわかりにくいかもしれませんが、広がる感じが出ているのがわかると思います。
遠近法(透視投影)
レイトレーシングを利用します。 正確にはカメラの位置から発する光をトレースし、 何かしらの物体に初めて衝突したときにその光の方向角度がキャンバスの位置を表しているので、 その物体の色をその画素の色として設定します。
本来はレイトレーシングでは逆にカメラに入ってきた光を考慮することで決定しますので、 この場合は光のトレース方向は逆になります。
物体の位置や形状によって衝突判定が大変になるため、今回は衝突判定が容易な球の列の描画を行います。 容易なのは球の中心と半径のみで衝突判定が可能になるからです。
#define PI 3.14159265358979323846
uniform int u_frame;
varying vec2 vTexCoord;
const float fov = PI * 0.5 / 3.0;
vec3 cPos = vec3(0.0, 0.0, 2.0);
vec3 sPos = vec3(0.0, 0.0, 0.0);
const float sphereSize = 1.0;
const vec3 lightDir = vec3(-0.577, 0.577, 0.577);
vec3 trans(vec3 p){
return mod(p, 6.0) - 3.0;
}
float distanceFunc(vec3 p){
return length(trans(p - sPos)) - sphereSize;
}
vec3 getNormal(vec3 p){
float d = 0.0001;
return normalize(vec3(
distanceFunc(p + vec3( d, 0.0, 0.0)) - distanceFunc(p + vec3( -d, 0.0, 0.0)),
distanceFunc(p + vec3(0.0, d, 0.0)) - distanceFunc(p + vec3(0.0, -d, 0.0)),
distanceFunc(p + vec3(0.0, 0.0, d)) - distanceFunc(p + vec3(0.0, 0.0, -d))
));
}
void main() {
vec2 st = (vTexCoord.xy * 2.0 - 1.0);
vec3 ray = normalize(vec3(sin(fov) * st.x, sin(fov) * st.y, -cos(fov)));
sPos = vec3(cos(float(u_frame) * 0.1), sin(float(u_frame) * 0.1), 0.0);
float distance = 0.0;
float rLen = 0.0;
vec3 rPos = cPos;
for(int i = 0; i < 64; i++){
distance = distanceFunc(rPos);
rLen += distance;
rPos = cPos + ray * rLen;
}
if(abs(distance) < 0.001){
vec3 normal = getNormal(rPos);
float diff = clamp(dot(lightDir, normal), 0.1, 1.0);
gl_FragColor = vec4(vec3(0.5 * diff + 0.5 * normal), 1.0);
}
else{
gl_FragColor = vec4(vec3(0.0), 1.0);
}
}
上記コードで重要なのは光線が球と衝突するまで64回探索している部分だと思います。 distanceFunc関数で球の中心と光線の先端との距離が球の半径になれば衝突したという判定になっています(正確にはabs(eps) < 0.001と余裕を持たせています)。 つまりこれは球の表面と光線との衝突判定を行っていることと等しく、一行で書けてしまうので球の衝突判定が単純であることがわかると思います。 またgetNormalで法線をとり照明方法との内積を取ることで色の明るさを変化させて影の表現を行っています。 なおこのコードは以下のURLを改変したものであり、URL先では詳細に説明がなされています。 https://wgld.org/d/glsl/g011.html
まとめ
座標の色を決定するにあたって、その座標の特徴や座標変換などのテクニックをうまく使って多様な描画を行うことが可能になります。 他にも便利なGLSL関数やその他の表現テクニックも存在し、今回紹介したものはほんの一部で奥の深さが垣間見られます。