WebGLを使うと画像処理が実現でき、HTMLコンテンツに多彩なグラフィカル表現をもたらすことができます。例えば、表示をモノクロームやモザイクにするといった画像エフェクトは簡単に実現できます

WebGLはGPUの恩恵を受けれるため高速に実行でき、他の代替手法(canvas要素Context2Dオブジェクトによる画像処理等)よりも負荷が軽いのが利点です。

今回はWebGLの定番JSライブラリ「Three.js」とGLSLというシェーダー言語を使った、9種類の画像処理の実装方法を紹介します。ソースコードは「GitHub」からダウンロードして読み進めてください。

サンプルを試してみよう

次のサンプルでは複数のシェーダーを適用することができます。画面左上のチェックボックスで画像加工を選択でき、ラジオボタンから画像とビデオの2種類を切り替えることができます。

※ビデオは「Big Buck Bunny | Blender Foundation」を利用しています。
※このサンプルは平成30年6月現在最新のThree.js r93とVue.js 2.5で実装しています。

前提として覚えておく必要があるのは、フラグメントシェーダーの使い方

Three.jsでフラグメントシェーダー(断片シェーダー)を適用するコードは公式のデモ「postprocessing」が参考になります。Three.jsでシェーダーを作るための必要なJavaScriptファイル「CopyShader.js」「MaskPass.js」「EffectComposer.js」「RenderPass.js」「ShaderPass.js」を手に入れるためにも、公式のソースコード「Three.js」からダウンロードしておきましょう。「example」フォルダにこれらのファイルが格納されています。

Three.jsでシェーダーを使用する際のフラグメントシェーダーの必要最低限の計算は以下となります。このコードをカスタマイズして、いろんな画像加工エフェクトを作っていきます。

▼フラグメントシェーダー

varying vec2 vUv;
uniform sampler2D tDiffuse;
void main() {
  vec4 color = texture2D(tDiffuse, vUv);
  gl_FragColor = color;
}

vUvはフラグメントシェーダーから送られてきた値で、texture2D関数でtDiffusevUvからシェーダーが現在画像処理しようとしている部分のピクセルカラーを取得出来ます。gl_FragColorにピクセルカラーをセットすると実際に画面に表示されます。

モノクローム

表示をモノクロ(白黒)に変更するシェーダー。赤・緑・青の三原色を計算し彩度を消すことで、モノクロになります。

まずはじめにRGB各色から輝度を計算します。本来なら(r + g + b) / 3.0を輝度の値にしたいところですが、同じ合計値でも色によって感じる明るさが異なるため、輝度計算する計算式を使用しています。輝度を計算後、白黒になるように掛け算を行います。

▼フラグメントシェーダー

#define R_LUMINANCE 0.298912
#define G_LUMINANCE 0.586611
#define B_LUMINANCE 0.114478

varying vec2 vUv;
uniform sampler2D tDiffuse;
const vec3 monochromeScale = vec3(R_LUMINANCE, G_LUMINANCE, B_LUMINANCE);

void main() {
  vec4 color = texture2D(tDiffuse, vUv);
  float grayColor = dot(color.rgb, monochromeScale);
  color = vec4(vec3(grayColor), 1.0);
  gl_FragColor = vec4(color);
}

ネガポジ反転

ネガポジとはその名の通り、色の数値を反転させることで実現できます。具体的にはピクセルカラーを100%から反転させますcolorに入る値は「0.0〜1.0」になるので、1.0から色の値を引くだけで簡単に色が反転されます。ちなみに色の値は「x」が赤、「y」が青、「z」が緑になります。

▼フラグメントシェーダー

varying vec2 vUv;
uniform sampler2D tDiffuse;

void main() {
  vec4 color = texture2D(tDiffuse, vUv);
  gl_FragColor = vec4(1.0 - color.x, 1.0 - color.y, 1.0 - color.z, 1.0);
}

セピア調

表示をセピア調に変更するシェーダーです。モノクロと表現は似ていますが、映画の回想シーンでよく見かけるエフェクトですよね。まずはじめにRGB各色から輝度を計算します。本来なら(r + g + b) / 3.0を輝度の値にしたいところですが、同じ合計値でも色によって感じる明るさが異なるため、輝度計算する計算式を使用しています。輝度を計算後、セピア色になるように掛け算を行います。

▼フラグメントシェーダー

#define R_LUMINANCE 0.298912
#define G_LUMINANCE 0.586611
#define B_LUMINANCE 0.114478

varying vec2 vUv;
uniform sampler2D tDiffuse;

void main() {
  vec4 color = texture2D(tDiffuse, vUv);
  float v = color.x * R_LUMINANCE + color.y * G_LUMINANCE + color.z * B_LUMINANCE;
  color.x = v * 0.9;
  color.y = v * 0.7;
  color.z = v * 0.4;
  gl_FragColor = vec4(color);
}

モザイク

モザイク処理はフラグメントシェーダーを使うと、簡単な計算式で実現できます

  • 現在のスクリーンを任意のピクセルごとに縦横に分割
  • 分割した中での中央のピクセルを見てピクセルカラーを設定する
  • 本来は分割したピクセル内の平均値を設定するとベスト

以下のコードのfMosaicScaleはシェーダー外から設定をしているモザイクのピクセル数です。

▼フラグメントシェーダー

varying vec2 vUv;
uniform sampler2D tDiffuse;
uniform vec2 vScreenSize;
uniform float fMosaicScale;
void main() {
  vec2 vUv2 = vUv;
  vUv2.x = floor(vUv2.x * vScreenSize.x / fMosaicScale) / (vScreenSize.x / fMosaicScale) + (fMosaicScale / 2.0) / vScreenSize.x;
  vUv2.y = floor(vUv2.y * vScreenSize.y / fMosaicScale) / (vScreenSize.y / fMosaicScale) + (fMosaicScale / 2.0) / vScreenSize.y;
  
  vec4 color = texture2D(tDiffuse, vUv2);
  gl_FragColor = color;
}

すりガラス

すりガラス越しに見たような効果もフラグメントシェーダーで実現できます。もしくはクレヨンでスケッチしたような効果と見立てることもできます。

実装方法としては周辺のピクセルからランダムでピクセルを取得します。

▼フラグメントシェーダー

varying vec2 vUv;
uniform sampler2D tDiffuse;
uniform vec2 vScreenSize;

float rand(vec2 co) {
  float a = fract(dot(co, vec2(2.067390879775102, 12.451168662908249))) - 0.5;
  float s = a * (6.182785114200511 + a * a * (-38.026512460676566 + a * a * 53.392573080032137));
  float t = fract(s * 43758.5453);
  return t;
}

void main() {
  float radius = 5.0;
  float x = (vUv.x * vScreenSize.x) + rand(vUv) * radius * 2.0 - radius;
  float y = (vUv.y * vScreenSize.y) + rand(vec2(vUv.y, vUv.x)) * radius * 2.0 - radius;
  vec4 textureColor = texture2D(tDiffuse, vec2(x, y) / vScreenSize);
  gl_FragColor = textureColor;
}

うずまき

渦巻を表現することも、シェーダーの定番処理の一つです。うずまきは三角関数を使うと簡単に実現できます。

具体的には、渦の中心位置を基準に少しずつ回転をさせた位置のピクセルカラーを設定します。中心位置から離れるほど回転角度が強くなるようにすると渦が完成します。

▼フラグメントシェーダー

uniform sampler2D tDiffuse;
varying vec2 vUv;
uniform vec2 vScreenSize;
uniform vec2 vCenter;
uniform float fRadius;
uniform float fUzuStrength;

void main() {
  vec2 pos = (vUv * vScreenSize) - vCenter;
  float len = length(pos);
  if(len >= fRadius) {
    gl_FragColor = texture2D(tDiffuse, vUv);
    return;
  }
  
  float uzu = min(max(1.0 - (len / fRadius), 0.0), 1.0) * fUzuStrength; 
  float x = pos.x * cos(uzu) - pos.y * sin(uzu); 
  float y = pos.x * sin(uzu) + pos.y * cos(uzu);
  vec2 retPos = (vec2(x, y) + vCenter) / vScreenSize;
  vec4 color = texture2D(tDiffuse, retPos);
  gl_FragColor = color;
}

2値化(threshold)

画像の色を黒と白のみで表現するシェーダーです。セピア調と同じように輝度の計算後、0.533333 以上なら白として1.0を、未満なら黒として0.0を設定します。そうすると明るめの場所は白に、暗めの場所は黒として表示されます。

▼フラグメントシェーダー

#define R_LUMINANCE 0.298912
#define G_LUMINANCE 0.586611
#define B_LUMINANCE 0.114478

varying vec2 vUv;
uniform sampler2D tDiffuse;

void main() {
  vec4 color = texture2D(tDiffuse, vUv);
  float v = color.x * R_LUMINANCE + color.y * G_LUMINANCE + color.z * B_LUMINANCE;
  if (v >= 0.53333) {
    v = 1.0;
  } else {
    v = 0.0;
  }
  gl_FragColor = vec4(vec3(v, v, v), 1.0);
}

2値化(ランダムディザ)

ランダムディザは、thresholdを改良したものです。同じく白と黒で表現するのですが、thresholdのように一律でこの値より上だったら白・黒というようなものでなく、ある程度はランダムで決まります。ちなみにGLSLにはランダム関数は無いので自分で実装する必要があります。今回は「ランダムな値を返す関数 on GLSL」を参考にしてrand()関数を作成しました。

▼フラグメントシェーダー

#define R_LUMINANCE 0.298912
#define G_LUMINANCE 0.586611
#define B_LUMINANCE 0.114478

varying vec2 vUv;
uniform sampler2D tDiffuse;

float rand(vec2 co) {
  float a = fract(dot(co, vec2(2.067390879775102, 12.451168662908249))) - 0.5;
  float s = a * (6.182785114200511 + a * a * (-38.026512460676566 + a * a * 53.392573080032137));
  float t = fract(s * 43758.5453);
  return t;
}

void main() {
  vec4 color = texture2D(tDiffuse, vUv);
  float v = color.x * R_LUMINANCE + color.y * G_LUMINANCE + color.z * B_LUMINANCE;
  if (v > rand(vUv)) {
    color.x = 1.0;
    color.y = 1.0;
    color.z = 1.0;
  } else {
    color.x = 0.0;
    color.y = 0.0;
    color.z = 0.0;
  }
  gl_FragColor = color;
}

2値化(ベイヤーディザ)

ベイヤーディザは上記2つの2値化より濃淡を綺麗に表示できるシェーダーです。4ピクセル×4ピクセルの閾値の表を用いて各ピクセルの色を決めていきます。現在のピクセルの輝度を閾値の表と比較し、輝度が大きければ白を小さければ黒となります。また、UV位置でなく4ピクセルごとにといったピクセル位置を取得する必要があるため、vScreenSizeとしてシェーダー外からスクリーンのサイズを設定しています。 ちなみにこのコードははじめ、配列に直接アクセスしようとスッキリしたコードを書いていたのですが、[]へのアクセスは定数でないといけないというエラー文に気が付き、全てif文に書きなおしたコードです。でもよく考えたらJavaScript上に配置している文字列なので自動生成できるよなぁと今更ながらに思っています。

▼フラグメントシェーダー

#define R_LUMINANCE 0.298912
#define G_LUMINANCE 0.586611
#define B_LUMINANCE 0.114478

varying vec2 vUv;
uniform sampler2D tDiffuse;
uniform vec2 vScreenSize;

void main() {
  vec4 color = texture2D(tDiffuse, vUv);
  float x = floor(vUv.x * vScreenSize.x);
  float y = floor(vUv.y * vScreenSize.y);
  mat4 m = mat4(
    vec4( 0.0,  8.0,  2.0,  10.0),
    vec4( 12.0, 4.0,  14.0, 6.0),
    vec4( 3.0,  11.0, 1.0,  9.0),
    vec4( 15.0, 7.0,  13.0, 5.0)
  );
  float xi = mod(x, 4.0);
  float yi = mod(y, 4.0);
  float threshold = 0.0;
  
  if(xi == 0.0) {  
    if(yi == 0.0) {threshold = m[0][0]; }
    if(yi == 1.0) {threshold = m[0][1]; }
    if(yi == 2.0) {threshold = m[0][2]; }
    if(yi == 3.0) {threshold = m[0][3]; }
  }
  
  if(xi == 1.0) {
    if(yi == 0.0) {threshold = m[1][0]; }
    if(yi == 1.0) {threshold = m[1][1]; }
    if(yi == 2.0) {threshold = m[1][2]; }
    if(yi == 3.0) {threshold = m[1][3]; }
  }
  
  if(xi == 2.0) {
    if(yi == 0.0) {threshold = m[2][0]; }
    if(yi == 1.0) {threshold = m[2][1]; }
    if(yi == 2.0) {threshold = m[2][2]; }
    if(yi == 3.0) {threshold = m[2][3]; }
  }
  
  if(xi == 3.0) {
    if(yi == 0.0) {threshold = m[3][0]; }
    if(yi == 1.0) {threshold = m[3][1]; }
    if(yi == 2.0) {threshold = m[3][2]; }
    if(yi == 3.0) {threshold = m[3][3]; }
  }
  color = color * 16.0;
  
  float v = color.x * R_LUMINANCE + color.y * G_LUMINANCE + color.z * B_LUMINANCE;
  if (v < threshold) {
    color.x = 0.0;
    color.y = 0.0;
    color.z = 0.0;
  } else {
    color.x = 1.0;
    color.y = 1.0;
    color.z = 1.0;
  }
  
  gl_FragColor = color;
}

まとめ

画像加工はフラグメントシェーダーの数式だけで簡単に実現できることがおわかりになったのではないでしょうか? Three.jsとあわせて使っていますが、GLSLのコードは他のJavaScriptライブラリや、OpenGLの実装でも役立ちます。ぜひご活用ください。