panda's tech note

WebGLの基礎(GLSLによるプログラミング)

WebGL はブラウザ上で3Dグラフィックスを表示するための標準仕様で,多くのブラウザでサポートされています。WebGL は HTML5 の Canvas に描画します。また,WebGL は組み込み機器向けの OpenGL 規格である OpenGL ES から派生しているため,OpenGL ES Shading Language (GLSL) と呼ばれるプログラミング言語によりプログラミングします。

WebGL では,三次元空間の座標を扱うため,座標処理や図形の拡大や回転などに行列演算を多用します。Webブラウザで使う JavaScript には,標準ではこのような行列演算や三次元空間処理のライブラリがないため,three.js などの外部ライブラリを使うことが多いです。実際に WebGL で検索すると three.js を使ったデモなどがたくさん見つかりますが,ここではブラウザの WebGL API を直接呼び出す方法を試します。

立方体の描画

WebGLの基礎(座標変換の復習) で復習したオブジェクトの移動・拡大縮小・回転と投影の知識を使い,ここでは WebGL の基礎を立方体を描画するプログラムを書きながら説明します。ここで説明した内容を実装したのが,こちらの単一HTMLファイル ですので,説明が不要な方はこちらのソースコードを読んでみてください。

Canvas と WebGL

前述の通り,WebGL では HTML5 の Canvas 内に描画します。今回は <body> 要素の中に,640x480のサイズで glCanvas という ID の Canvas 要素を以下の通り作成します。

<body>
  <canvas id="glCanvas" width="640" height="480"></canvas>
</body>

シェーダー

OpenGL や Unity などのプログラミング経験がある方には説明不要かと思いますが,WebGL でもシェーダー(Shader)によりプログラミングを行います。WebGL では OpenGL ES Shading Language (GLSL) と呼ばれるプログラミング言語を使います。GLSLはC言語をベースとしているため,C言語と似た文法です。以下の2種類のシェーダーにより描画するプログラムを記述します。

  • Vertex shader: 頂点を描画するプログラム
  • Fragment shader: テクスチャからピクセルの色を決定し,描画するためのプログラム

シェーダーのプログラムは文字列で良いのですが,JavaScript 内に文字列で GLSL を書くとメンテナンス性が悪くなってしまうため, <script> タグ内に書いたものを JavaScript からロードします。

立方体を描画するには,Vertex shader で立方体の各面(6面)の頂点座標を定義し,その立方体を回転,移動したものを透視投影変換により描画します。Fragment shader ではVertex shader で描画した立方体に対して色を定義します。今回は色は固定色にしています。

Vertex shader

Vertex shader プログラムの説明をする前に,GLSL固有の修飾子について説明します。GLSLでは以下の4つの型修飾子があります。

  • const: 定数
  • attribute: 頂点に紐付き,頂点単位で変更されるグローバル変数(vertex shaderのみで利用可能)
  • uniform: 汎用的な値を扱うグローバル変数
  • varying: vertex shader と fragment shader 間のデータのやりとりをするための変数であり,vertex shader から書き込み,fragment shader からは読み込み専用となる変数

また,型としては,今回は4要素の縦ベクトル vec4 型と4x4の行列である mat4 型を使用します。

上述の通り,Vertex shader では立方体の各面の頂点座標の描画を行います。立方体の頂点ベクトル群(オブジェクトモデル)の定義とその回転・移動変換行列,透視投影変換行列は JavaScript で別途定義します。頂点のベクトルを vertexPosition,回転・移動変換行列を modelTransformationMatrix,透視投影変換行列を modelProjectionMatrix とすると,modelProjectionMatrix * modelTransformationMatrix * vertexPosition により変換後の頂点座標を得ることができます。変換後の頂点座標は gl_Position という予約済みのグローバル変数にセットします。つまり,Vertex shader のプログラムは以下のように書くことができます。

<script id="vertex-shader-3d" type="x-shader/x-vertex">
attribute vec4 vertexPosition;
uniform mat4 modelTransformationMatrix;
uniform mat4 modelProjectionMatrix;

void main() {
    gl_Position = modelProjectionMatrix * modelTransformationMatrix * vertexPosition;
}
</script>

ここで,vertexPosition は,頂点の場所をアプリケーション(JavaScript)から書き込み,Vertex shader で読み込み処理するため,attribute 修飾子を付けています。modelTransformationMatrix および modelProjectionMatrix は,頂点に対して座標変換をするための行列を表す変数なので, uniform 修飾子を付けています。

Fragment shader

前述の通り,Fragment shader では,Vertex shader で決定されたテクスチャからピクセルの色を決定し,描画するためのプログラムを定義します。今回は固定色として,白色(RGBAの値が全て1.0)を指定して,描画色を制御するグローバル変数である gl_fragColor 変数にセットします。つまり,Fragment shader のプログラムは以下のように書くことができます。

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

WebGLの初期化とコンテキストの取得

ここまでに,Vertex shader および Fragment shader について説明しました。ここからは,このシェーダーを JavaScript で初期化し,これらのシェーダーを呼び出し描画するプログラムを書いていきます。

まず,画面が読み込み(Canvas要素などの配置を含む)が完了したときに呼ばれる window.onload イベントから JavaScript の処理(以下では main() 関数)を呼び出すために,以下のように書きます。 main() 関数にシェーダーの呼び出しなどを実装していきます。

window.onload = main;

main() 関数の先頭で,以下のようにID glCanvas の Canvas 要素を取得します。

  // Get the canvas element
  const canvas = document.querySelector("#glCanvas");

次に,以下のようにこのキャンバス要素に対して WebGL コンテキストを取得します。canvas.getContext("webgl") の返り値が null である場合は,WebGL非対応のブラウザまたは環境なので,alertを出して終了します。

  // Initialize the GL context
  const gl = canvas.getContext("webgl");

  // Check if WebGL is available
  if ( null == gl ) {
    alert("Unable to initialize WebGL.");
    return;
  }

WebGLコンテキストの初期化が完了したら,以下のように Canvas の背景を半透明の黒でリセットします。

  // Specify clear color to black (half opaque)
  gl.clearColor(0.0, 0.0, 0.0, 0.5);
  // Clear the color buffer of the canvas with clear color specified above
  gl.clear(gl.COLOR_BUFFER_BIT);

これで WebGL で描画する Canvas の準備が整いました。

シェーダーの初期化

次にシェーダーの初期化を行っていきます。Vertex shader と Fragment shader で同様の処理を行うため,シェーダーの読み込み関数 loadShader() を作成します。この関数の第1引数には上記の WebGL コンテキスト,第2引数にはシェーダーの種別(Vertex gl.VERTEX_SHADER か Fragment gl.FRAGMENT_SHADER か),第3引数にはシェーダープログラムの文字列を指定します。

// Load shader to the WebGL context
function loadShader(gl, type, source) {
  let shader = gl.createShader(type);
  gl.shaderSource(shader, source);
  gl.compileShader(shader);
  if ( !gl.getShaderParameter(shader, gl.COMPILE_STATUS) ) {
    console.log("Failed to compile the shader: " + gl.getShaderInfoLog(shader));
    return null;
  }
  return shader;
}

上記の関数では,まず,gl.createShader(type) により,指定した種別のシェーダーを作成します。次に gl.shaderSource() でシェーダープログラムの文字列(コード)をシェーダーに読み込み,gl.compileShader() でコンパイルします。コンパイル後,gl.getShaderParameter() でコンパイルステータスを取得し,エラー判定を行います。

Vertex shader および Fragment shader はそれぞれ,ID vertex-shader-3d および ID fragment-shader-3d の script 要素に記述されているため,その要素から文字列として取り出し,下記のように loadShader() 関数を用いて初期化します。

  // Load the vertex and fragment shaders
  let ve = document.getElementById("vertex-shader-3d");
  let fe = document.getElementById("fragment-shader-3d");
  let vertexShader = loadShader(gl, gl.VERTEX_SHADER, ve.text);
  let fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fe.text);
  if ( !vertexShader ) {
    alert("Unable to compile the vertex shader.");
    return;
  }
  if ( !fragmentShader ) {
    alert("Unable to compile the fragment shader.");
    return;
  }

次に,以下のように gl.createProgram でプログラムを作成し,上記で初期化したシェーダーをリンクして実行可能なプログラムを構成します。また,gl.getAttribLocationgl.getUniformLocation により,プログラムで定義した attribute 変数や uniform 変数の場所(GLint で表される変数番号)を取得します。また,gl.useProgram でこのプログラムの使用を開始します。

  // Create a program and attach the shaders to the program
  let program = gl.createProgram();
  gl.attachShader(program, vertexShader);
  gl.attachShader(program, fragmentShader);
  gl.linkProgram(program);
  if ( !gl.getProgramParameter(program, gl.LINK_STATUS) ) {
    console.log("Failed to link the program: " + gl.getProgramInfoLog(program));
    return;
  }

  // Get the variables defined in the shader program
  let positionLocation = gl.getAttribLocation(program, "vertexPosition");
  let transformationLocation = gl.getUniformLocation(program, "modelTransformationMatrix");
  let projectionLocation = gl.getUniformLocation(program, "modelProjectionMatrix");

  // Use the program
  gl.useProgram(program);

行列と転置関数と行列積

ここからは,描画する立方体モデルの定義や回転・移動,透視投影変換行列を JavaScript で定義し,GLSL で書かれたシェーダーに渡して描画するために,JavaScript での行列の表現と演算を定義します。

まず,JavaScript では標準の型として行列型はないため,浮動小数点配列型である Float32Array 型のデータにより表現します。今回は,GLSL で採用されている column-major order と呼ばれる行列を列方向から並べて配列とする行列表現を使用します。C言語の2次元配列などで実装する場合はrow-major orderとなるため,column-major orderであることには注意が必要です。ただし,コードを書く上では,row-major orderの方が可読性が高いため,row-major orderからcolumn-major orderに変換するための4x4正方行列の転置関数 transpose4() を以下の通り定義しています。

// Transpose a 4x4 matrix
function transpose4(m) {
  let n = new Float32Array(16);
  if ( m.length != 16 ) {
    return null;
  }
  for ( let i = 0; i < 4; i++ ) {
    for ( let j = 0; j < 4; j++ ) {
      n[i + 4 * j] = m[4 * i + j];
    }
  }
  return n;
}

次に column-major order で表現された Float32Array 型の2つの4x4正方行列の積を求める関数 multiplyMatrix4() を以下の通り定義します。このあたりは行列演算の定義通りなので,特に説明の必要はないかと思います。

// Multiply two 4x4 matrices
function multiplyMatrix4(m1, m2) {
  let m = new Float32Array(16);
  if ( m1.length != 16 || m2.length != 16 ) {
    return null;
  }
  for ( let i = 0; i < 4; i++ ) {
    for ( let j = 0; j < 4; j++ ) {
      e = 0.0;
      for ( let k = 0; k < 4; k++ ) {
        e += m1[i + 4 * k] * m2[k + 4 * j];
      }
      m[i + 4 * j] = e;
    }
  }
  return m;
}

立方体モデルの定義

立方体モデルを定義します。立方体のモデルを定義するために,gl.createBuffer() で立方体の頂点の座標リストを格納するバッファを初期化します。次に,以下のコードのように,原点を重心とした各 1.0 の立方体の面(6面)を定義します。各座標\(x, y, z\)の3要素が4つで1つの面を表現できるため,\(3 \times 4 \times 6 = 72 \) 個の要素を持つ配列を定義します。この配列を gl.bindBuffer()gl.bufferData() により,gl.createBuffer() で作成したバッファにバインド(連携)します。また,gl.enableVertexAttribArray で Vertex shader 内の vertexPosition 変数を有効化し,gl.vertexAttribPointer で上記で準備したバッファと vertexPosition 変数をバインドします。この際に第2引数で1頂点あたりの要素数(今回は3要素)を指定します。第4引数と第5引数はそれぞれストライドとオフセットを指定していますが,今回は positions 変数から変換したバッファは,隙間無く詰めているため,0 を指定しています。

  // Initialize the buffers
  const positionBuffer = gl.createBuffer();
  // Cubic's vertices
  const positions = [
    -0.5, -0.5, -0.5,
    -0.5, -0.5, 0.5,
    -0.5, 0.5, 0.5,
    -0.5, 0.5, -0.5,

    0.5, -0.5, -0.5,
    0.5, -0.5, 0.5,
    0.5, 0.5, 0.5,
    0.5, 0.5, -0.5,

    -0.5, -0.5, -0.5,
    -0.5, -0.5, 0.5,
    0.5, -0.5, 0.5,
    0.5, -0.5, -0.5,

    -0.5, 0.5, -0.5,
    -0.5, 0.5, 0.5,
    0.5, 0.5, 0.5,
    0.5, 0.5, -0.5,

    -0.5, -0.5, -0.5,
    -0.5, 0.5, -0.5,
    0.5, 0.5, -0.5,
    0.5, -0.5, -0.5,

    -0.5, -0.5, 0.5,
    -0.5, 0.5, 0.5,
    0.5, 0.5, 0.5,
    0.5, -0.5, 0.5,
  ];
  // Bind the buffer to this context
  gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
  // Pass the positions to the context
  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW);
  // Bind the ARRAY_BUFFER buffer data to the attribute pointer
  gl.enableVertexAttribArray(positionLocation);
  gl.vertexAttribPointer(positionLocation, 3, gl.FLOAT, false, 0, 0);

立方体モデルの変形

WebGLの基礎(座標変換の復習) で説明した通り,透視投影変換では,視点を原点に固定し,視線を\(z\)軸の負の方向に向けているため,上記で定義した立方体モデルをそのまま描画すると立体的に見えません。そのため,\(z\)軸を中心に\(-30^\circ\) (theta1),\(x\)軸を中心に\(-60^circ\) (theta2)回転させた後,\(z\)軸の負の方向に\(5.0\) (deltaz) 平行移動します。これをコードとして書くと以下のようになります。

  // Build the transformation matrix
  let theta1 = -Math.PI / 6;
  let theta2 = -2 * Math.PI / 6;
  let deltaz = -5.0;
  let matrixRotate1 = transpose4(new Float32Array(
    [Math.cos(theta1), -Math.sin(theta1), 0.0, 0.0,
    Math.sin(theta1), Math.cos(theta1), 0.0, 0.0,
    0.0, 0.0, 1.0, 0.0,
    0.0, 0.0, 0.0, 1.0, ]));
  let matrixRotate2 = transpose4(new Float32Array(
    [1.0, 0.0, 0.0, 0.0,
    0.0, Math.cos(theta2), -Math.sin(theta2), 0.0,
    0.0, Math.sin(theta2), Math.cos(theta2), 0.0,
    0.0, 0.0, 0.0, 1.0, ]));
  let matrixMove = transpose4(new Float32Array(
    [1.0, 0.0, 0.0, 0.0,
    0.0, 1.0, 0.0, 0.0,
    0.0, 0.0, 1.0, deltaz,
    0.0, 0.0, 0.0, 1.0, ]));
  let matrixTransformation = multiplyMatrix4(matrixMove, multiplyMatrix4(matrixRotate2, matrixRotate1));

普通に配列として書くと row-major order の行列になるため,transpose4 により転置をすることで column-major order に変換しています。ここで得られた matrixTransformation を後ほど Vertex shader に渡します。

投影モデルの定義

WebGLの基礎(座標変換の復習) の透視投影で説明した通り,投影変換をするための行列を構築します。視点から近い面までの距離を 1.0,遠い面までの距離を 10.0 として,Canvas のアスペクト比に合わせて近い面の幅を 0.640,高さを 0.640 とします。

  // Build the projection matrix
  let dn = 1.0;
  let df = 10.0;
  let width = 0.640;
  let height = 0.480;
  let matrixProjection = transpose4(new Float32Array(
    [2 * dn / width, 0.0, 0.0, 0.0,
    0.0, 2 * dn / height, 0.0, 0.0,
    0.0, 0.0, - (df + dn) / (df - dn) , - 2 * (df * dn) / (df - dn),
    0.0, 0.0, -1.0, 0.0, ]));

ここで得られた matrixProjection も先ほどの立方体の変形行列と一緒に Vertex shader に渡して描画に利用します。

描画

gl.uniformMatrix4fv により,transformationLocation および projectionLocation として保存している Vertex shader の modelTransformationMatrix 変数および modelProjectionMatrix 変数に,立方体の変形行列および投影モデルの変換行列を渡します。第2引数は与えられた行列の転置をするかを示す boolean 値ですが,現時点では false のみがサポートされているので,上述した通り column-major order の行列として扱うのが良いかと思います。

  // Set the shader uniform variables
  gl.uniformMatrix4fv(transformationLocation, false, matrixTransformation);
  gl.uniformMatrix4fv(projectionLocation, false, matrixProjection);

ここまでで描画の準備ができたので,gl.drawArrays により描画をしていきます。 positions 変数で定義したように,4つの頂点で1つの面を表すため,各面ごとに gl.drawArrays により描画します。第1引数は,頂点から面の描画・塗りつぶし方の違いを指定します。第2引数はオフセット,第3引数は描画に使用する頂点の数を指定します。今回は,立方体の辺のみを描画するため,第1引数は gl.LINE_LOOP としました。

  // Draw
  gl.drawArrays(gl.LINE_LOOP, 0, 4);
  gl.drawArrays(gl.LINE_LOOP, 4, 4);
  gl.drawArrays(gl.LINE_LOOP, 8, 4);
  gl.drawArrays(gl.LINE_LOOP, 12, 4);
  gl.drawArrays(gl.LINE_LOOP, 16, 4);
  gl.drawArrays(gl.LINE_LOOP, 20, 4);

これにより,下図のように立方体を描画することができます。

WebGL Cube

上記のサンプルは,こちら に単一のHTMLファイルとして置いてありますので,参考にしてください。

まとめ

このページでは WebGL の基礎を学ぶために外部ライブラリを使わず,立方体を描画しました。

行列演算ライブラリとして glMatrix.js を使ったチュートリアルは MDM Web Docs — Getting started with WebGL あたりが参考になるかと思います。また,three.js を使った WebGL プログラミングについては,オライリーから出ている以下の書籍などを参考にするとよいと思います。