WebGLのシェーダーGLSLでの画像処理の作り方(モノクロ、セピア、モザイク、渦巻き)

43
143

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

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

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

サンプルを試してみよう

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

※ビデオは「Big Buck Bunny | Blender Foundation」を利用しています。
※このサンプルは2023年2月現在のThree.js r149とVue.js 3で実装しています。

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

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() {
  // 現在の色RGBAを取得
  vec4 color = texture2D(tDiffuse, vUv);
  // モノクロの輝度を計算
  float grayColor = dot(color.rgb, monochromeScale);
  // モノクロのRGBAに変換
  color = vec4(vec3(grayColor), 1.0);
  gl_FragColor = 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);
  // 各色は0.0〜1.0の値をもつので、1.0から引くことで反転させる。
  // 順番に赤、緑、青、アルファ
  gl_FragColor = vec4(1.0 - color.r, 1.0 - color.g, 1.0 - color.b, 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.g * R_LUMINANCE + color.g * G_LUMINANCE + color.b * B_LUMINANCE;
  // セピア調に変換(セピアなので赤や緑を多め)
  color.r = v * 0.9; // 赤
  color.g = v * 0.7; // 緑
  color.b = 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;
}

うずまき

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

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

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

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.5以上なら白として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);
  
  // 明るさを0.0〜1.0の範囲で計算
  float v = color.r * R_LUMINANCE + color.g * G_LUMINANCE + color.b * B_LUMINANCE;
  // 明るさが半分以上なら
  if (v >= 0.5) {
    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の実装でも役立ちます。ぜひご活用ください。

※この記事が公開されたのは9年前ですが、1年前の2023年2月に内容をメンテナンスしています。

野原 のぞみ

インタラクティブデベロッパー。好きな生き物はハムスター、好きな食べ物は豚汁です。ツールは使うより作りたい派。

この担当の記事一覧