WebGLのアルファブレンディングやってみる

前々回はドットで線を描画、前回はドットで矩形の描画とアルファブレンディングをやってみました。

どちらもループ処理で1ドットずつ打っていきました。例えばフルHDだとピクセル数は1920×1080=2073600になりますが、これだけの数を順番に1ピクセルずつ色をつけているのでしょうか?たぶん実際はGPUを使って並列にピクセルに色をつけるのではないかなと思います。今回は、WebGLでシェーダーを使ってGPUでドットを打ちます。

JavaScriptでCPUを使った処理だと下図の左のように1ピクセルずつ順番にループ処理で色付け、WebGLGPUを使った処理だと下図の右のようにvertexシェーダーで緑点を指定して色付けする範囲を指定し、範囲内の青く囲ったピクセルを並列でfragmentシェーダーに従って色付けする、というようなイメージでしょうか。

gyazo.com

わたくし、シェーダーは完全に初心者です。The Book of Shaders が良いとどこかで知って、いつか読んでみようと思っていたので読みながらWebGLで動かしてみました、という日記です。

まずはシェーダーをWebGLで動かす

Hello World!

The Book of Shaders の Hello World! のページを動かす。

完全に初心者だけどAndroidOpenGL ESの入門者向けの本は少し読んだことがある。そこで学んだことを思い出しつつやってみる。

Getting started with WebGL - Web APIs | MDN を見てみると、HTMLにcanvasタグをおいて const gl = canvas.getContext("webgl"); とやれば gl オブジェクトが手に入るみたい。

シェーダーを書くための文法はGLSL。WebGLではHTMLのscriptタグ内にvertexシェーダーとfragmentシェーダー、それぞれのソースコードを記述するっぽい。

The Book of Shaders にはfragmentシェーダーしか記述されてないけど、vertexシェーダーはcanvas全体を使うような矩形にすれば良いでしょう。fragmentシェーダーは写経する。

<canvas style="width:800px;height:400px;"></canvas>

<script id="vertex-shader" type="x-shader/x-vertex">
attribute vec2 pos;
void main() {
  gl_Position = vec4(pos, 0, 1);
}
</script>

<script id="fragment-shader" type="x-shader/x-fragment">
void main() {
    gl_FragColor = vec4(1.0,0.0,1.0,1.0);
}
</script>

前回までのようにループ処理で1ドット打つようなソースコードではない。vertexシェーダーで色をつける場所を指定する、fragmentシェーダーで1ピクセルごとの色付けを指定する、GPUはシェーダーに従ってたくさんのピクセルを並列で同時に色付けする、というようなイメージかな。GPUなので超並列で実行できるし、いくつかの関数はハードウェアを使って高速に実行されるらしい。

WebGLを使うJavaScriptのコードを書く。 initialize() という関数を用意して初期化処理をする、その後 gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); で描画する。

const canvas = document.querySelector('canvas');
const gl = canvas.getContext('webgl');

function initialize() {
  gl.clearColor(0, 0, 0, 1);
  gl.clear(gl.COLOR_BUFFER_BIT);
  
  // vertexシェーダーのソースコードをHTMLのscriptタグから読み込んでコンパイルする
  const vertexShaderSource = document.querySelector('#vertex-shader').text;
  const vertShader = gl.createShader(gl.VERTEX_SHADER);
  gl.shaderSource(vertShader, vertexShaderSource);
  gl.compileShader(vertShader);
  var isCompiled = gl.getShaderParameter( vertShader, gl.COMPILE_STATUS );
  if ( !isCompiled ) {
    throw new Error( 'Shader compile error: ' + gl.getShaderInfoLog( vertShader ) );
  }
  
  // fragmentシェーダーのソースコードをHTMLのscriptタグから読み込んでコンパイルする
  const fragmentShaderSource = document.querySelector('#fragment-shader').text;
  const fragShader = gl.createShader(gl.FRAGMENT_SHADER);
  gl.shaderSource(fragShader, fragmentShaderSource);
  gl.compileShader(fragShader);
  var isCompiled = gl.getShaderParameter( fragShader, gl.COMPILE_STATUS );
  if ( !isCompiled ) {
    throw new Error( 'Shader compile error: ' + gl.getShaderInfoLog( fragShader ) );
  }
  
  // リンクして実行できるプログラムにする
  var program = gl.createProgram();
  gl.attachShader(program, vertShader);
  gl.attachShader(program, fragShader);
  gl.linkProgram(program);
  gl.useProgram(program);
    
  // vertexシェーダーの `pos` 変数を使って頂点を4箇所セットする
  const vertex_buffer = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, vertex_buffer);
  const position = new Float32Array([
    -1, -1,
    1, -1,
    -1, 1,
    1, 1,
  ]);
  gl.bufferData(gl.ARRAY_BUFFER, position, gl.STATIC_DRAW);
  const positionLocation = gl.getAttribLocation(program, 'pos');
  gl.enableVertexAttribArray(positionLocation);
  gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
}

// 初期化処理を実行する
initialize();

// 4つの頂点を三角形を使って描画する。シェーダーでは点・線・三角形で描画する
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);

結果は紫色の矩形が表示されるだけ。fragmentシェーダーで gl_FragColor = vec4(1.0,0.0,1.0,1.0); と記述があって、RGBAで色を指定している。redとblueが1.0になってるので紫色になる。

Uniforms

The Book of Shaders の Uniformsのページを読んでいく。

uniform変数というのがある。CPU側の処理、つまり今回だとJavaScript側からパラメータを、GPU側の処理(つまり、fragmentシェーダー)に渡せる。全てのピクセルに対する処理でこのパラメータを読めるということ。

まず u_time というuniform変数が導入されてる。これは実行を開始してからの経過時間をJavaScript側からfragmentシェーダーへ伝えてるのに使う。経過時間に応じて色を変えるということをする。

<script id="fragment-shader" type="x-shader/x-fragment">
uniform float u_time;

void main() {
  gl_FragColor = vec4(abs(sin(u_time)),0.0,0.0,1.0);
}
</script>

次に、処理開始時間と u_time に値を渡すための値を app というオブジェクトに持たせることにする。

const canvas = document.querySelector('canvas');
const gl = canvas.getContext('webgl');

const app = {
  startTime: new Date().getTime(),
  uTime: null
};

function initialize() {
  ...
  
  gl.useProgram(program);
    
  // gl.useProgram()以降で可能
  app.uTime = gl.getUniformLocation(program, "u_time");

  ...

描画する処理を rendering() という関数にする。1度描画したらそれで終わりというわけではないので、 requestAnimationFrame(rendering); で繰り返し実行されるようにする。

function rendering() {
  // uniforms変数を更新
  const now = new Date().getTime();
  const currentTime = (now - app.startTime) / 1000;
  gl.uniform1f(app.uTime, currentTime); // gl.uniform1f() という関数で値をセット
  
  // 描画
  gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);

  requestAnimationFrame(rendering);
}

initialize();
rendering();

結果、こうなる。いいね、ちゃんと色に変化がある!各ピクセルを色付けするfragmentシェーダーは、CPU側のJavaScriptから渡された utime によって色が変わるため。

Image from Gyazo

次は u_resolution というuniform変数を導入する。これは描画領域全体のサイズをJavaScript側で計算してfragmentシェーダーへ伝える。

さらに gl_FragCoord というシェーダーで最初から用意されている変数を使う。ピクセルの座標を表す。これはfragmentシェーダーが実行される毎に異なる値になっていて、varying変数と呼ぶ。

<script id="fragment-shader" type="x-shader/x-fragment">
uniform float u_resolution;
uniform float u_time;

void main() {
  // 経過時間で色を変える
  //gl_FragColor = vec4(abs(sin(u_time)), 0.0, 0.0, 1.0); コメントアウト
  
  // ピクセルの位置で色を変える
  vec2 st = gl_FragCoord.xy/u_resolution;
  gl_FragColor = vec4(st.x,st.y,0.0,1.0);
}
</script>

app に追加。

const app = {
  startTime: new Date().getTime(),
  uTime: null,
  uResolution: null
};

function initialize() {
  ...
  
  gl.useProgram(program);
  
  // uniforms
  app.uTime = gl.getUniformLocation(program, "u_time");
  app.uResolution = gl.getUniformLocation(program, "u_resolution");
  
  ...

canvasのサイズが動的になっても対応できるようにしておく。

function resize() {
  app.width = canvas.width;
  app.height = canvas.height;
  gl.uniform2f(app.uResolution, app.width, app.height);
  gl.viewport(0, 0, app.width, app.height);
}
window.addEventListener('resize', resize);

initialize();
resize();
rendering();

結果、こうなる。良いですな〜。

最後に u_mouse というuniform変数を導入する。これはマウスの位置をJavaScript側からfragmentシェーダーに渡す。マウスの位置で色付けを変える。ここまでとやってることはあまり変わらないので結果だけ。

See the Pen WebGL HelloWorld by kariyayo (@kariyayo) on CodePen.


できた!!

好きな大きさの長方形を描く

ここまでの例はcanvas全体に色付けしていた。canvas内の好きな場所に好きな大きさの長方形を描くにはどうすればいいか?

The Book of Shaders の「 Shapes」で、正規化された座標(canvas全体の座標を0.0 ~ 1.0で表した座標)で、(0.1, 0.1) から (0.9, 0.9) の範囲を白、それ以外を黒にする例がある。これを応用すればできるはず。

(0.1, 0.1) から (0.9, 0.9) の範囲を白にするのは以下のコードでできた。

<script id="fragment-shader" type="x-shader/x-fragment">
uniform float u_resolution;

void main() {
  vec2 st = gl_FragCoord.xy/u_resolution.xy;
  vec3 color = vec3(0.0);

  // x, y 両方とも0.1以上なら白(vec2(1.0))
  vec2 bl = step(vec2(0.1), st);
  // x. y 両方とも0.9以上なら白(vec2(1.0))
  vec2 tr = step(vec2(0.1), 1.0-st);
  
  // 全て条件を満たせば vec3(1.0) つまり白、どれか1つでも満たしていなければ vec3(0.0) つまり黒
  color = vec3(bl.x * bl.y * tr.x * tr.y);

  gl_FragColor = vec4(color,1.0);
}
</script>

これを始点と大きさで長方形を表して、その範囲内であれば色付けするように書き換える。あと好きな色も指定できるようにする。diffは以下。

@@ -5,11 +5,18 @@
   vec2 st = gl_FragCoord.xy/u_resolution.xy;
   vec3 color = vec3(0.0);
 
-  // x, y 両方とも0.1以上なら白(vec2(1.0))
-  vec2 bl = step(vec2(0.1), st);
-  // x. y 両方とも0.9以上なら白(vec2(1.0))
-  vec2 tr = step(vec2(0.1), 1.0-st);
-  
+  // 始点
+  vec2 start = vec2(0.1);
+  // 大きさ(0.1~0.9の範囲なので0.8)
+  vec2 wh = vec2(0.8);
+
+  vec2 end = start + wh;
+
+  // x, y 両方ともstart以上なら白
+  vec2 bl = step(start, st);
+  // x. y 両方ともend以下なら白
+  vec2 tr = step(vec2(1.0-end.x, 1.0-end.y), 1.0-st);
+
   // 全て条件を満たせば vec3(1.0) つまり白、どれか1つでも満たしていなければ vec3(0.0) つまり黒
   color = vec3(bl.x * bl.y * tr.x * tr.y);

ここまでOK。これを関数化する。ついでに白以外の好きな色を指定できるようにしておく。

vec3 rect(vec2 st, vec2 start, vec2 wh, vec3 color) {
  vec2 end = start + wh;
  // x, y 両方ともstart以上なら色付け
  vec2 bottom_left = step(start, st);
  // x. y 両方ともend以下なら色付け
  vec2 top_right = step(vec2(1.0-end.x, 1.0-end.y), 1.0-st);
  // 全て条件を満たせばcolorで色づけ、どれか1つでも満たしていなければ黒
  return color * vec3(bottom_left.x * bottom_left.y * top_right.x * top_right.y);
}

void main() {
  ...
  
  vec2 start = vec2(0.5, 0.0);
  vec2 wh = vec2(0.5, 0.5);
  color = rect(st, start, wh, vec3(1.0, 0.0, 0.0));

ここまでくれば、 rect() を2回利用することで赤い長方形と青い長方形を描くことができる。

  // 赤い長方形
  vec2 start = vec2(0.05, 0.5);
  vec2 wh = vec2(0.2, 0.4);
  vec3 c = rect(st, start, wh, vec3(1.0, 0.0, 0.0));
  if (c != vec3(0.0)) {
    color = c;
  }
  
  // 青い長方形
  start = vec2(0.1, 0.25);
  wh = vec2(0.2, 0.4);
  c = rect(st, start, wh, vec3(0.0, 0.0, 1.0));
  if (c != vec3(0.0)) {
    color = c;
  }

結果はこうなる。

gyazo.com

アルファブレンディング

自分で計算する

長方形が重なった部分の色をアルファブレンディングしたい。前回同様に不透明度を使って計算すればできる。けどWebGLにはブレンディングの機能があってそっちを使うべきな気がする。

一旦ここまでのソースコードを活かすと、アルファブレンディングする関数を用意して色を決める時に呼べば良いだけ。

vec4 alphaBlend(vec4 fg, vec4 bg) {
  // 背景の色成分 * (1.0 - 不透明度) + 前景の色成分 * 不透明度
  vec3 cc = bg.rgb * (vec3(1.0) - vec3(fg.a)) + fg.rgb * vec3(fg.a);
  return vec4(cc, fg.a);
}

...

  // 青い長方形
  start = vec2(0.1, 0.25);
  wh = vec2(0.2, 0.4);
  c = rect(st, start, wh, vec4(0.0, 0.0, 1.0, 0.8));
  if (c != vec4(0.0)) {
    color = alphaBlend(c, color);
  }

結果はこうなる。

See the Pen WebGL Shapes by kariyayo (@kariyayo) on CodePen.


WebGLの機能を使う

これまではvertexシェーダーで範囲を指定して、fragmentシェーダーで各ピクセルの色づけしていた。この「範囲を指定してその範囲内のピクセルの色づけをする」というステップを長方形の数だけJavaScript側から実行する。この別のやり方でアルファブレンディングしてみる。

vertexシェーダーで指定した範囲のピクセルを全て同じ色にすればいいのでfragmentシェーダーはすごいシンプルになる。

uniform vec4 u_color;

void main() {
  gl_FragColor = u_color;
}

JavaScriptrendering からvertexシェーダーの position にデータを流すのと、fragmentシェーダーの u_color にデータを流すのを、赤い長方形、青い長方形と2回実行する。

function rendering() {

  ...
  
  // 赤い長方形
  const redRect = new Float32Array([
    // 左下
    -0.8, 0,
    // 右下
    0, 0,
    // 左上
    -0.8, 1,
    // 右上
    0, 1,
  ]);
  gl.bufferData(gl.ARRAY_BUFFER, redRect, gl.STATIC_DRAW);
  gl.uniform4f(app.uColor, 1.0, 0.0, 0.0, 1.0);
  // render
  gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
  
  // 青い長方形
  const blueRect = new Float32Array([
    // 左下
    -0.5, -0.5,
    // 右下
    0.2, -0.5,
    // 左上
    -0.5, 0.4,
    // 右上
    0.2, 0.4,
  ]);
  gl.bufferData(gl.ARRAY_BUFFER, blueRect, gl.STATIC_DRAW);
  gl.uniform4f(app.uColor, 0.0, 0.0, 1.0, 0.8);
  // render
  gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);

  requestAnimationFrame(rendering);
}

そして、WebGLのブレンディング機能を使う。 gl.blendFunc() の第一引数はこれから色付けする色に掛ける係数。不透明度を掛けるので gl.SRC_ALPHA を指定する。第二引数は既についている色(背景色)に掛ける係数で、 1.0-不透明度 を掛けるので gl.ONE_MINUS_SRC_ALPHA を指定する。

function rendering() {
  ...

  gl.enable(gl.BLEND);
  gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);

  ...

結果こうなる。

See the Pen WebGL Shapes with blend functor by kariyayo (@kariyayo) on CodePen.


できた!

おしまい

シェーダー楽しい!!!