ぷるぷるの雑記

低レイヤーがんばるぞいなブログ. 記事のご利用は自己責任で.

WebGL2の基礎を理解する

三角形を描画するスクリプトを通してWebGL2の基礎を理解しましょう。

はじめに

WebGL2とはざっくりいうとJavaScriptからOpenGLの機能を利用することができるJavaScriptAPIです. ブラウザの機能なのでプラグインなどが必要なく、既存のOpenGLのコードをかなり流用できます*1

WebGL2があるからにはWebGL(無印)もあるわけですが、せっかくなら新しい方を使おうということでこの記事ではWebGL(無印)に関しては一切触れません。

また、WebGL2を「完全に理解」するためには広範な知識が必要になります。したがって、この記事でカバーする部分とカバーしない部分を明確にしておきましょう。

この記事で扱うこと

  • シェーダープログラムをコンパイルする方法
  • 頂点バッファーオブジェクト(VBO)の利用方法
  • 頂点配列オブジェクト(VAO)の利用方法

この記事で扱わないこと

  • GLSLの書き方
  • インデックスの頂点バッファオブジェクトの利用方法
  • Canvasを画像化してダウンロードする方法
  • vertexAttribPointer()の引数の詳しい説明

コードの全貌

いきなりですが、以下が完成したコードになります。

<!-- index.html -->

<head>
    <style>
        canvas{
            width: 512px;
            height:512px;
        }
    </style>
</head>
<body>
    <canvas id="canvas"></canvas>
    <script src="script.js"></script>
</body>
// script.js

"use strict";

let vertexShaderSource = `#version 300 es

in vec4 position;
in vec4 color;
out vec4 vcolor;

void main() {

   vcolor = color;

  // gl_Positionは必須
  // out変数としての定義不要
  gl_Position = position;
}
`;

let fragmentShaderSource = `#version 300 es

// データ型指定必須
precision highp float;

// フラグメントシェーダーは最終的なout変数を定義する必要あり
in vec4 vcolor;
out vec4 outColor;

void main() {
  outColor = vcolor;
}
`;

function createShader(gl, type, source) {
  let shader = gl.createShader(type);
  gl.shaderSource(shader, source);
  gl.compileShader(shader);
  let success = gl.getShaderParameter(shader, gl.COMPILE_STATUS);
  if (success) {
    return shader;
  }

  console.log(gl.getShaderInfoLog(shader));  // eslint-disable-line
  gl.deleteShader(shader);
  return undefined;
}

function createProgram(gl, vertexShader, fragmentShader) {
  let program = gl.createProgram();
  gl.attachShader(program, vertexShader);
  gl.attachShader(program, fragmentShader);
  gl.linkProgram(program);
  let success = gl.getProgramParameter(program, gl.LINK_STATUS);
  if (success) {
    return program;
  }

  console.log(gl.getProgramInfoLog(program));  // eslint-disable-line
  gl.deleteProgram(program);
  return undefined;
}

function main() {
  // コンテクスト取得
  let canvas = document.querySelector("canvas");
  let gl = canvas.getContext("webgl2");
  if (!gl) {
    return;
  }
  
  // バーテックスシェーダーとフラグメントシェーダーをコンパイル
  let vertexShader = createShader(gl, gl.VERTEX_SHADER, vertexShaderSource);
  let fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fragmentShaderSource);
  
  // バーテックスシェーダーとフラグメントシェーダーをプログラムとリンクする
  let program = createProgram(gl, vertexShader, fragmentShader);
  
  /******************************************************************************** */
  gl.bindAttribLocation(program, 0, "position");
  gl.bindAttribLocation(program, 1, "color");
  
 
  const tri1 = [
    // x, y, z 
    1, 1, 0, 
    -1, -1, 0,
    1, -1, 0,
    0, 0.75, 0, 1, 
    0, 0.75, 0, 1,
    // Right Down
    0, 1, 0, 1
  ];
  
  const tri2 = [
    // x, y, z, r, g, b, a
    -1, 1, 0, 
    -1, -1, 0,
    1, 1, 0,
    // r, g, b, a
    // Left Up
    0, 0.5, 0, 1, 
    0, 0.75, 0, 1,
    0, 0.75, 0, 1
  ];

  let vao1 = gl.createVertexArray();
  prepare(gl, program, "position", 3, tri1, 0, 0, vao1);
  prepare(gl, program, "color", 4, tri1, 0, 4*9, vao1);
  
  let vao2= gl.createVertexArray();
  prepare(gl, program, "position", 3, tri2, 0, 0, vao2);
  prepare(gl, program, "color", 4, tri2, 0, 4*9, vao2);
    


  // キャンバスいっぱいをクリップ空間とする
  gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);

  // キャンバスクリア
  gl.clearColor(0, 0, 0, 0);
  gl.clear(gl.COLOR_BUFFER_BIT);


  draw(gl, program, vao1);
  draw(gl, program, vao2);
}

function draw(gl, program, vao)
{

  // レンダリングに使用するシェーダープログラムを指定
  gl.useProgram(program);

  // レンダリングするVAOを指定
  gl.bindVertexArray(vao);

  // レンダリング
  let primitiveType = gl.TRIANGLES;
  let first = 0;
  let count = 3;
  gl.drawArrays(primitiveType, first, count);
}

function prepare(gl, program, varName, _size, data, _stride, _offset, vao)
{
  // VBOの設定
  let indexOfVbo = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, indexOfVbo);
  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(data), gl.STATIC_DRAW);
  

  // VAOの設定
  let attributeLocation = gl.getAttribLocation(program, varName);
  gl.bindVertexArray(vao);

  // VBO & VAOの設定
  let size = _size;         
  let type = gl.FLOAT;   
  let normalize = false; 
  let stride = _stride;        
  let offset = _offset;        
  gl.vertexAttribPointer(
      attributeLocation, 
      size, 
      type, 
      normalize, 
      stride, 
      offset
  );
  gl.enableVertexAttribArray(attributeLocation);

}

main();

上のコードの描画結果
割といい感じなグラデーション画像になっていますね。

コード解説 - ユーザー定義関数 -

今回のコードで利用しているユーザー定義関数は次のようになっています。

戻り値の型 関数名
WebGLShader createShader()
WebGLProgram createProgram()
なし prepare()
なし draw()

createShader()

function createShader(gl, type, source) {
  let shader = gl.createShader(type);
  gl.shaderSource(shader, source);
  gl.compileShader(shader);
  let success = gl.getShaderParameter(shader, gl.COMPILE_STATUS);
  if (success) {
    return shader;
  }

  console.log(gl.getShaderInfoLog(shader));  // eslint-disable-line
  gl.deleteShader(shader);
  return undefined;
}

この関数は文字列として定義されたシェーダーをコンパイルして利用可能な型(WebGLShader)としてリターンします。

createProgram()

function createProgram(gl, vertexShader, fragmentShader) {
  let program = gl.createProgram();
  gl.attachShader(program, vertexShader);
  gl.attachShader(program, fragmentShader);
  gl.linkProgram(program);
  let success = gl.getProgramParameter(program, gl.LINK_STATUS);
  if (success) {
    return program;
  }

  console.log(gl.getProgramInfoLog(program));  // eslint-disable-line
  gl.deleteProgram(program);
  return undefined;
}

この関数はコンパイル済みのシェーダー(WebGLShader)を受け取り最終的に利用可能な型(WebGLProgram)としてリターンします。



createShader()とcreateProgram()はWebGL2 Fundamentalsのサンプルコードを丸写ししました。

webgl2fundamentals.org

parepare()

function prepare(gl, program, varName, _size, data, _stride, _offset, vao)
{
  // VBOの設定
  let indexOfVbo = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, indexOfVbo);
  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(data), gl.STATIC_DRAW);
  

  // VAOの設定
  let attributeLocation = gl.getAttribLocation(program, varName);
  gl.bindVertexArray(vao);

  // VBO & VAOの設定
  let size = _size;         
  let type = gl.FLOAT;   
  let normalize = false; 
  let stride = _stride;        
  let offset = _offset;        
  gl.vertexAttribPointer(
      attributeLocation, 
      size, 
      type, 
      normalize, 
      stride, 
      offset
  );
  gl.enableVertexAttribArray(attributeLocation);

}

最も煩雑な処理を含んだ関数です。

// VBOの設定
let indexOfVbo = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, indexOfVbo);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(data), gl.STATIC_DRAW);

この三行でGPU上のメモリにデータが転送されます。注意点ですが、この時点ではまだ GPUは送られてきたデータが何を表しているのを知りません 。なので、このデータが具体的にどのようなデータなのかをGPUに教えてやる必要があります。このとき、生のデータ自体を管理するものを頂点バッファオブジェクト(VBO)といい、このVBOを管理するものを頂点配列オブジェクト(VAO)といいます。

// VAOの設定
let attributeLocation = gl.getAttribLocation(program, varName);
gl.bindVertexArray(vao);

この二行はVAOを利用するための準備をしています。なお、今回のコードはVAOの生成はmain()内で行うようにしています。varNameにはシェーダー中の変数名です。例えば、gl.getAttribLocation(program, "position")とするとこのシェーダープログラム中のpositionという変数の管理番号(実際には0以上のインデックス)が得られます。

// VBO & VAOの設定
  let size = _size;         
  let type = gl.FLOAT;   
  let normalize = false; 
  let stride = _stride;        
  let offset = _offset;        
  gl.vertexAttribPointer(
      attributeLocation, 
      size, 
      type, 
      normalize, 
      stride, 
      offset
  );
  gl.enableVertexAttribArray(attributeLocation);

このコードで メモリ上のデータがどのようなデータなのかをGPUに覚えさせ、VAOを介すことでシェーダープログラムからその情報に参照できるようにします。もう少し具体的に言うと、gl.vertexAttribPointer()のおかげでシェーダープログラム中の変数とGPUのメモリ上の情報が結びつきます。

ありていに言うと、

VBO→GPU上のメモリにおくるデータを含んだ段ボール


VAO→各段ボールにどんな情報がどんな順番で入っているかを記録しておく伝票



といった感じでしょうか。

実は、1つの段ボールにすべての情報を含んでもよいですし、意味のある情報ごとに分けてもよいです。つまり、位置や色を一つのVBOに含めてもよいですし、逆に2つのVBOに分けても良いのです。

今回のコードは実は無駄なことをしていて、一つのVAOに対して位置と色の情報を含んだVBOを二回ずつ生成しています*2。本来であれば位置と色の情報を含んだVBOを一度だけ生成するか、位置の情報を含んだVBOと色の情報を含んだVBOを1度ずつ生成すれば十分なはずです。

無駄のないコードに直してみるのもいいですね。

draw()

function draw(gl, program, vao)
{

  // レンダリングに使用するシェーダープログラムを指定
  gl.useProgram(program);

  // レンダリングするVAOを指定
  gl.bindVertexArray(vao);

  // レンダリング
  let primitiveType = gl.TRIANGLES;
  let first = 0;
  let count = 3;
  gl.drawArrays(primitiveType, first, count);
}

実際に描画を行う関数です。今回のコードではインデックスを用いていないので与えられた3点を用いて三角形を描画します。

コード解説 - main関数 -

webgl2コンテクストを取得

function main() {
  // コンテクスト取得
  let canvas = document.getElementById("canvas");
  let gl = canvas.getContext("webgl2");
  if (!gl) {
    return;
  }
.
.
.

webgl2コンテクストを取得します。以後はOpenGLとほぼ同一の手続きを行います。

シェーダーのコンパイル、リンク

  // バーテックスシェーダーとフラグメントシェーダーをコンパイル
  let vertexShader = createShader(gl, gl.VERTEX_SHADER, vertexShaderSource);
  let fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fragmentShaderSource);
  
  // バーテックスシェーダーとフラグメントシェーダーをプログラムとリンクする
  let program = createProgram(gl, vertexShader, fragmentShader);

ユーザー定義関数を利用してシェーダーのコンパイルし、実際の描画に利用できるプログラムを生成します。

シェーダー中のin変数とプロクラム上のインデックスを結びつける

 gl.bindAttribLocation(program, 0, "position");
 gl.bindAttribLocation(program, 1, "color");

以降、programにとってattribute[0]と言えばposition、attribute[1]といえばcolorを表すようになります。

VBOに渡す情報を定義

const tri1 = [
    // x, y, z 
    1, 1, 0, 
    -1, -1, 0,
    1, -1, 0,
    // r, g, b, a
    0, 0.75, 0, 1, 
    0, 0.75, 0, 1,
    0, 1, 0, 1
  ];
  
  const tri2 = [
    // x, y, z, r
    -1, 1, 0,         
    -1, -1, 0,
    1, 1, 0,
    // r, g, b, a
    0, 0.5, 0, 1,  
    0, 0.75, 0, 1,
    0, 0.75, 0, 1
  ];

そのまんまです。今回は[位置,位置,位置,色,色,色]という順番で情報が並んでいますが、prepareの引数を変えれば[位置,色,位置,色,位置,色]にも対応することが出来ます.

VAO、VBOの設定

  let vao1 = gl.createVertexArray();
  prepare(gl, program, "position", 3, tri1, 0, 0, vao1);
  prepare(gl, program, "color", 4, tri1, 0, 4*9, vao1);
  
  let vao2= gl.createVertexArray();
  prepare(gl, program, "position", 3, tri2, 0, 0, vao2);
  prepare(gl, program, "color", 4, tri2, 0, 4*9, vao2);

詳しい説明はユーザー定義関数の解説のところを見てください。

描画

  // キャンバスいっぱいをクリップ空間とする
  gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);

  // キャンバスクリア
  gl.clearColor(0, 0, 0, 0);
  gl.clear(gl.COLOR_BUFFER_BIT);


  draw(gl, program, vao1);
  draw(gl, program, vao2);

VAOを指定した後描画を行います。

コード解説、以上!

おわりに

後半は力尽きて走り書き気味でしたが、基本的なところだけでもおさえられてよかったです。個人的にはWebGLの理解にはOpenGLの理解が重要だと思いました。OpenGLの教科書としては次の本に一番お世話になりました。



また、OpenGLを管理しているkhronosグループのクイックリファレンスも参考になりました。 https://www.khronos.org/files/webgl20-reference-guide.pdf

あとMDN Web Docsもお世話になりました。 developer.mozilla.org

今後の目標

  • vertexAttribPointer()の引数のメモを書く
  • インデックス情報を用いたオブジェクトの表現
  • Canvasの画像化

*1:関数名やマクロがほぼ一対一対応している

*2:strideとoffsetの使い方を明確にしたかったため