はじめに
前回は、GLSLで法線マップを作成する方法について解説しました。
今回は、GLSLで陰影付け処理を応用した特殊効果の画像処理について解説します。
コードは下記のGitHubのリポジトリのsrc/canvasで公開しています。
陰影付けの考え方
陰影付けの処理は、物体に光が当たったときの色の明るさを計算によって求めて表示することで、波状面や波紋の表現を可能にします。陰影付けを行ったときに表示すべき色を具体的に求めるときは、光に関する物理法則を利用しますが、このデモでは環境光(ambient)・拡散反射(diffuse)・鏡面反射(specular)の3種類の反射を合成するフォン反射モデルを使用します。この記事では詳しくは解説しないので、適宜調べてみてください。
それでは、デモで使用するライティング関数lighting()のGLSLは次のようになります。
// ライティング処理
vec3 lighting(vec3 color, vec3 normal) {
// 法線ベクトル
vec3 N = normalize(normal);
// ライト方向(zは固定で正面から)
vec3 L = normalize(vec3(lightDirectionX, lightDirectionY, 1.0));
// 拡散反射
float Id = max(dot(N, L), 0.0);
// 環境光
float Ia = ambient;
// 視線方向(カメラ正面)
vec3 V = normalize(vec3(0.0, 0.0, 1.0));
// ハーフベクトル
vec3 H = normalize(L + V);
// 鏡面反射
float Is = pow(max(dot(N, H), 0.0), 32.0);
return color * (Id + Ia + Is);
}lighting()の第1引数には、texture()で取得した画像のRGB値を渡します。第2引数には、デモで使用する形状の法線ベクトルを渡します。
法線ベクトルはlighting関数内でnormalizeで正規化します。lightDirectionXとlightDirectionYは、x,y方向の視線になりuniform変数にしてあるのでパラメータで動かせるようにしてます。z軸は常にカメラ正面からの光となるので固定で1.0にしてます。
これらのNとLの内積を取ることでランバートの拡散反射が得られます。ここの内積を取る意味としては、面が光を向いているほど、角度が0に近くなるので明るくなります。
環境光はambientでuniform変数をとり操作できるようにし、鏡面反射はカメラ正面の視線方向とライト方向を足して正規化することで、ハーフベクトルが得られるので、このハーフベクトルと法線ベクトルとの内積を取ったうえで、powで累乗することで得られます。
最後に、拡散反射と環境光、鏡面反射の3種類の反射を合成することで、最終的な色が得られます。今回の3種類のデモでは、このlighting関数を使用していきます。それでは、波状面・波紋・エンボスの法線ベクトルを求める方法についてみていきます。
波状面
xとyのどちらか一方向の場合を考えます。この記事では、x方向での波状面で解説します。波はsin関数で表現できるので、高さ関数を次式で表します。
この高さ関数から、法線ベクトルを求めるにはx、yで偏微分を取ることで得られます。
x方向の偏微分は
y方向の偏微分は
になるので、法線ベクトルは次のようになります。
ここまでをGLSLで書くと次のようになります。
void main() {
vec2 uv = vUv;
// 角周波数
float omega = 2.0 * PI / wavelength;
float dx = 0.0;
dx = amplitude * omega * cos(omega * uv.x);
// 波による擬似法線
vec3 normal = vec3(-dx, 0.0, 1.0);
vec3 texColor = texture(uTexture, uv).rgb;
vec3 color = lighting(texColor, normal);
fragColor = vec4(color, 1.0);
}周波数のwavelengthと振幅のamplitudeは、uniform変数にして変更できるようにしています。
結果は次のようになります。

屈折を考慮する
屈折を考慮し、RGBの値をずらすとガラスのような表現が可能になります。ここでは、簡易的に屈折を再現してみます。まずは下図のように法線から角度の視点からみて、角度屈折したgapを求めます。

図より、となるので、
となります。ここで加法定理を用いて整理すると
となります。GLSLで屈折量を計算するgetRef関数を実装していきます。dxはx方向かy方向の法線ベクトルで、dzは1.0を渡し、hは屈折面からサンプリング面までの距離で、refは屈折率になります。
// 屈折量計算
float getRef(float dx, float dz, float h, float ref) {
// 入射角計算
float rA = sqrt(dx * dx + dz * dz);
float sinA = -dx / rA;
float tanA = -dx / dz;
// 屈折角計算
float sinB = ref * sinA;
float tanB = sqrt(1.0 / (1.0 - sinB * sinB) - 1.0);
// 符号調整
if (dx > 0.0) tanB = -tanB;
// 屈折によるオフセット量
float res = (tanA - tanB) / (1.0 + tanA * tanB);
return h * res;
}急にsinが出てきましたが解説します。ひとまずオフセット量resを求めるためにtanAとtanBを導出します。
rAは三平方の定理よりdxとdzを用いて計算した入射ベクトルの長さになり、このrAからsinAを計算します。マイナスは向きの調整になります。tanAはdxとdzの比になります。
sinBはスネルの法則より、sinAと屈折率refを掛け算すれば求まります。tanBの箇所は複雑です。
float tanB = sqrt(1.0 / (1.0 - sinB * sinB) - 1.0);ここはtanの2乗が次式なのと、という恒等式を使っています。
整理した結果、次式のようになったのをコードで書いています。
ここで求まったtanAとtanBを用いてオフセット量resを求め、屈折面からサンプリング面までの距離hを掛けることで、屈折量が求まります。
RGBずらしも考慮した屈折量を求めるgetRefの使い方としては次のようになります。
void main() {
// ...
// 屈折率
float ref = 0.75;
// RGBずらし
float refR = ref - 0.05;
float refG = ref;
float refB = ref + 0.05;
// 屈折面からサンプリング面までの距離
float h = 0.02;
// RGBそれぞれのUVオフセット計算
float offXR = getRef(dx, 1.0, h, refR);
float offXG = getRef(dx, 1.0, h, refG);
float offXB = getRef(dx, 1.0, h, refB);
float r = texture(uTexture, uv + vec2(offXR, 0.0)).r;
float g = texture(uTexture, uv + vec2(offXG, 0.0)).g;
float b = texture(uTexture, uv + vec2(offXB, 0.0)).b;
// 屈折後の色
vec3 refrColor = vec3(r, g, b);
vec3 color = lighting(refrColor, normal)
fragColor = vec4(color, 1.0);
}ここでは、簡易的に屈折率をRとBで固定値でずらしてRGBずらしを実現させています。
結果は次のようになります。

デモでは、屈折の有り無しを切り替えられたりしているので試してみてください!
波紋
先ほどは1次元波(波状面)でしたが、ここでは2次元波(波紋)を実現しましょう。ここでも高さ関数を定義します。波紋の高さ関数は次のようになります。
ここでは、中心からの距離になります。
波紋の法線ベクトルは勾配をとることで、求まるので
となります。これをGLSLで実装すると次のようになります。
void main() {
vec2 uv = vUv;
// 画面中心
vec2 center = vec2(0.5);
// 中心からの相対位置
vec2 p = uv - center;
// アスペクト補正(円形の波を保つ)
p.x *= uResolution.x / uResolution.y;
// 中心からの距離
float r = length(p);
// 角周波数
float omega = 2.0 * PI / wavelength;
// 高さ変化の勾配量
float dz = amplitude * omega * cos(omega * r);
// 勾配ベクトル(∂z/∂x, ∂z/∂y)
vec2 grad = dz * (p / r);
// 波による擬似法線
vec3 normal = vec3(-grad.x, -grad.y, 1.0);
vec3 texColor = texture(uTexture, uv).rgb;
vec3 color = lighting(texColor, normal);
fragColor = vec4(color, 1.0);
}波紋は円形の波になるので、uResolution.xとuResolution.yの比をp.xに掛けることでアスペクト補正を行うようにしてます。
屈折を考慮する
波紋でも屈折を考慮した実装をしてみましょう。波状面では1次元のみだったのですが、波紋はx方向とy方向の2次元になるので別途関数を用意せずに屈折のずれの量を実装します。
gradはx方向とy方向の法線ベクトルの2つの成分になるので、normalizeで正規化して(Ngrad)、RGBのオフセットを計算します。
実装は次のようになります。
void main() {
// ...
// 屈折率
float ref = 0.75;
// RGBずらし
float refR = ref - 0.25;
float refG = ref;
float refB = ref + 0.25;
// 屈折の強さ係数
float h = 0.02;
// 勾配方向を正規化(UVずらし用)
vec2 Ngrad = normalize(grad);
// RGBそれぞれのUVオフセット計算
vec2 offsetR = Ngrad * h * refR;
vec2 offsetG = Ngrad * h * refG;
vec2 offsetB = Ngrad * h * refB;
float rCol = texture(uTexture, uv + offsetR).r;
float gCol = texture(uTexture, uv + offsetG).g;
float bCol = texture(uTexture, uv + offsetB).b;
// 屈折後の色
vec3 refrColor = vec3(rCol, gCol, bCol);
vec3 color = lighting(refrColor, normal);
fragColor = vec4(color, 1.0);
}結果は次のようになります。

光源を考慮したエンボス処理
最後に光源を考慮したエンボス処理のデモを紹介します。簡易的なエンボス処理は以前の記事で解説しました。エンボス処理で使用する高さ関数()は、前回の法線マップを使用します。
光源を考慮したエンボス処理のGLSLでの実装は次のようになります。
// グレイスケール
float gray(vec3 color) {
return dot(color, vec3(0.299, 0.587, 0.114));
}
// 高さマップ(輝度取得)
float height(vec2 uv) {
vec3 c = texture(uTexture, uv).rgb;
return gray(c);
}
void main() {
vec2 uv = vUv;
vec2 pos = 1.0 / uResolution;
// 偏微分の近似を行うため差分を求める
float hL = height(uv - vec2(pos.x, 0.0));
float hR = height(uv + vec2(pos.x, 0.0));
float hD = height(uv - vec2(0.0, pos.y));
float hU = height(uv + vec2(0.0, pos.y));
// 偏微分
float dx = (hR - hL) * strength;
float dy = (hU - hD) * strength;
// 法線計算
vec3 normal = vec3(-dx, -dy, 1.0 / strength);
vec3 texColor = texture(uTexture, uv).rgb;
// 色味調整
vec3 grayColor = vec3(gray(texColor) * 0.8 + 0.2);
vec3 color = lighting(grayColor, normal);
fragColor = vec4(color, 1.0);
}そのままだと、画像の色がそのまま出力されるので、次のコードで色味を調整してからlighting関数に渡すようにしてます。
// 色味調整
vec3 grayColor = vec3(gray(texColor) * 0.8 + 0.2);結果は次のようになります。

デモでは、エンボスの凸凹の強さをstrengthで調整できるようにしているのと、光源の位置を調整できるようにしているのでぜひ試してみてください。
まとめ
この記事では、視覚的に面白い陰影付け処理を応用した特殊効果の画像処理について解説していきました。陰影付けの方法については、他の方法などがあるのでぜひ他の記事なども参照してみてください。
次回は、モザイク処理やイラスト調などのユニークな特殊効果の画像処理をやっていきたいと思います!
画像処理のおすすめ本
下記は画像処理全般の基礎の勉強におすすめの書籍になります。