🐫

気楽にWebGLへ入門しよう!〜 自作WebGLライブラリGlakuの紹介

2024/10/09に公開

はじめに

この記事の目的

  • コードを実際に動かしながら次の WebGL の基本概念を理解する
    • 2種類のシェーダ: 頂点シェーダ / フラグメントシェーダ
    • 3種類の変数: attribute / uniform / varying
  • 上記解説に付随して自作の WebGL ライブラリ「Glaku」を紹介

https://glaku.vercel.app

WebGL って何だ?

WebGL の要点を一言で表現すると、「頂点シェーダで頂点の位置を指定し、フラグメントシェーダでピクセルごとの色を指定する」ことです。
例えば、 WebGL を使って「青い三角形を画面に表示する」プロセスは次のようになります。

  1. 画面上の3点の頂点位置を指定する
    • 方法: 頂点の数だけ頂点シェーダを実行して位置を指定
  2. 3点で囲まれる内側のピクセルを全て割り出す(=ラスタライズ)
    • 方法: 頂点シェーダの実行結果を元に WebGL が自動的に実行
  3. 割り出したピクセルの色を全て青色に指定する
    • 方法: ピクセルの数だけフラグメントシェーダを実行して青色を指定

三角形の表示程度に大仰では?と思うかもしれませんが、GPU の力を使って「頂点ごと」「ピクセルごと」に並列実行できるというシェーダの特性は、パフォーマンスと柔軟性が求められる高度なグラフィック表現において非常に有効です。

WebGL は難しい?

WebGL のコンセプトはシンプルですが、次の理由で習得難易度は比較的高いと思います。

  1. API が複雑で重厚
  2. 主なユースケースである 3DCG 自体が難しい
    • MVP行列、陰影処理、シーングラフ、ポストエフェクト、シャドウマッピング、等々

「複雑なAPI」と「難しい3DCG」を真っ向から同時に学ぶのは中々大変です。
この記事では「ライブラリによってAPIを簡易にして」「3DCGから(一旦)目を背ける」ことで WebGL 入門の敷居を下げたいと考えます。

ピュアなWebGLでは三角形の表示にこれだけのコードが必要です

実践的なコードになると、さらに記述量と複雑さが増します。

Three.js を使えば良いのでは?

大抵の場合、その通りだと思います。Three.js は本当に素晴らしいライブラリです。WebGL を高度に抽象化し、3DCG 向けに最適化されているため、「ウェブで3D表現をしたい」というニーズに対して、シェーダを一行も書かずに対応できます。

ただし、次のようなケースでは、Three.js をより「深く」扱うため、WebGL の知識が必要になります。しかし、Three.js の高度な抽象化ゆえに、通常の利用では WebGL そのものの理解には繋がりにくく、その点にジレンマを感じるかもしれません。

  • 独自シェーダを使った高度な表現
  • 既存シェーダのカスタマイズや修正
  • 2D描画や GPGPU など、3D以外の用途でシェーダを活用する場合

また、シェーダを自分で書いて GPU を制御し、思い通りの画を作り上げる体験は非常に楽しく、是非おすすめしたいです。ただし、ピュアな WebGL の API はやや難解であり、Three.js はシェーダをゼロから書くような用途に最適化されていないため、気楽に始める方法を見つけるのは難しいかもしれません。

Glaku について

Glaku はピュアな WebGL を気楽に扱えるようにするためのライブラリです。「頂点シェーダで頂点の位置を指定し、フラグメントシェーダでピクセルごとの色を指定する」という WebGL の本質的な部分にプログラマが集中できるように設計されています。Glaku の主な特徴は以下の通りです。

  • シンプルなAPI:主要なクラスはわずか4つで、短いコードでシェーダの力を最大限引き出せます
  • 軽量:外部ライブラリに依存せず、ビルド後のサイズは約10KB (gzipped)

Glaku は次のような用途におすすめです。

  • WebGL学習:WebGL の概念やテクニックをスムーズに習得するサポートになります
  • 高度なWebGL表現:インスタンシングや浮動小数点テクスチャ、Multiple Render Targets などの発展的な機能を簡単に利用でき、WebGL の柔軟性とパフォーマンスを活かした表現が可能です

本題

概要

次のステップに沿って Glaku での実装を進めていき、WebGL の勘所を掴んでいきましょう。

  • Step.1: 三角形を表示する
  • Step.2: 三角形を動かす
  • Step.3: 三角形の色を位置に応じて変える

また、これらのステップを通して以下の概念の習得を目指します。

  • 2種類のシェーダ
    • 頂点シェーダ
      • 各頂点の位置を決定
    • フラグメントシェーダ
      • 各ピクセルの色を決定
  • 3種類の変数
    • attribute
      • 各頂点ごとに参照できる
      • 頂点シェーダでのみ使用可能
    • uniform
      • 全ての頂点で参照できる
      • 頂点シェーダ / フラグメントシェーダ両方で使用可能
    • varying
      • 頂点シェーダからフラグメントシェーダへデータを渡すために使う

留意点

WebGL では、シェーダは GLSL というC言語風の言語で記述する必要があります。また、Glaku では GLSL の中でも ES3.0 形式をサポートしています。この記事では GLSL の細かい文法については詳しく触れませんが、GLSL の文法自体は比較的シンプルですので、あまり心配せずに進めてみてください。もし不明な点があれば、その都度調べながら学ぶことができると思います。

事前準備

Glaku をインストールしましょう。各ステップごとに CodeSandbox を用意しているので、そちらをFork するのも良さそうです。

npm i glaku

キャンバス要素をHTML内の適切な場所、例えば <body> タグの中に配置しておきましょう。次のコードのように書きます。

<body>
  <canvas id="c"></canvas>
</body>

Step.1: 三角形を表示する

以下のコードを試してみましょう。青い三角形が表示されたら成功です! コードの構造はシンプルで、 VAO と Program を用意して Renderer に渡すだけです。 Glaku では発展的な実装を行う場合もこの流れから逸脱することはありません。

import { Core, Vao, Program, Renderer } from "glaku";

const main = (canvas: HTMLCanvasElement) => {
  const core = new Core({ canvas });

  const vao = new Vao(core, { // 三角形なので頂点位置(x,y)は3つ
    attributes: { a_position: [0, 1, 1, -1, -1, -1] }, 
  });

  const program = new Program(core, {
    attributeTypes: { a_position: "vec2" }, // a_positionを2次元ベクトルとして宣言 
    vert: /* glsl */ `
            void main() {  
             gl_Position = vec4(a_position, 1.0, 1.0); 
            }`,
    frag: /* glsl */ `
            out vec4 o_color;
            void main() {
             o_color = vec4(0.4, 0.4, 1.0, 1.0);
            }`,
  });

  const renderer = new Renderer(core);
  renderer.render(vao, program);
};

const canvas = document.getElementById("c");
main(canvas)

それではコードの各所を見ていきましょう。

まずは Core を準備します。Core のコンストラクタに CanvasElement を渡すことで最小限の初期化が完了します。 Core は WebGL でレンダリングするための様々な状態を管理していますが、この記事においてはざっくり「Glaku を使うための基盤」という認識で大丈夫です。

const canvas = document.getElementById("c");
const core = new Core({ canvas });

VAO は VertexArrayObject の略称で、各頂点で扱う属性(attribute)を格納するものです。 ここでは attribute として、3つの2次元位置座標を定義しています。
attribute がどのように使われるかはシェーダ次第です。(3次元座標やRGBをセットするのもプログラマの自由です)
a_position の値を[0, 0.5, 0.5, -0.5, -0.5, -0.5]などに変更したりしてみると、今回のシェーダでは a_position の値が頂点位置として使われていることを実感しやすいかもしれません。

const vao = new Vao(core, {
  attributes: { a_position: [0, 1, 1, -1, -1, -1] },
});

Program は特に重要なので丁寧に見ていきます。冒頭で示した「青い三角形を画面に表示する」プロセスを改めて再掲しますので、迷ったらここに立ち戻りましょう。

  1. 画面上の3点の頂点位置を指定する
    • 方法: 頂点の数だけ頂点シェーダを実行して位置を指定
  2. 3点で囲まれる内側のピクセルを全て割り出す(=ラスタライズ)
    • 方法: 頂点シェーダの実行結果を元に WebGL が自動的に実行
  3. 割り出したピクセルの色を全て青色に指定する
    • 方法: ピクセルの数だけフラグメントシェーダを実行して青色を指定

Program には2つの GLSL シェーダー(頂点シェーダ / フラグメントシェーダ)と、シェーダに外から渡す変数を定義します。
ここでは a_position: "vec2" とすることで、a_position が 2次元ベクトルであることをシェーダに伝えています。

const program = new Program(core, {
  attributeTypes: { a_position: "vec2" },
  vert: /* glsl */ `
      void main() {
        gl_Position = vec4(a_position, 1.0, 1.0);
      }`,
  frag: /* glsl */ `
      out vec4 o_color;
      void main() {
        o_color = vec4(0.4, 0.4, 1.0, 1.0);
      }`,
});

Program のうち、頂点シェーダ(Vertex Shader)に注目しましょう。
頂点シェーダでは組み込み変数の gl_Positionvec4 形式の値を代入することで頂点位置を指定します。ここでは単純に、VAO で用意した a_position をそのまま頂点位置として指定しているのが分かるかと思います。
今回の例において、レンダリングの際 a_position にどんな値が入るでしょうか?先ほど VAO には3つの頂点位置を定義しました。([0, 1, 1, -1, -1, -1])そして、頂点シェーダは「頂点の数」だけ実行されて位置を指定するものです。
ということでこの頂点シェーダは3回実行され、実行ごとの a_position の値は、[0, 1] [1, -1] [-1, -1]となります。そしてこの実行結果を元にラスタライズ(頂点で囲まれるピクセルの割り出し)が行われ、フラグメントシェーダの実行へ移行します。

  void main() {
    gl_Position = vec4(a_position, 1.0, 1.0);
  }

続いてフラグメントシェーダ(Fragment Shader)を見てみましょう。フラグメントシェーダでは、出力用の変数(ここでは out vec4 o_color 名前は任意)を宣言し、ここにRGBAを0~1の範囲で指定することでピクセルの色を指定します。

  out vec4 o_color;
  void main() {
    o_color = vec4(0.4, 0.4, 1.0, 1.0);
  }

ここまででデータ(vao)と処理(program)が揃いました!
あとはRendererを初期化して、render メソッドを実行すれば三角形が表示されます🎉

const renderer = new Renderer(core);
renderer.render(vao, program);

Step.2: 三角形を動かす

今度は三角形をグルグル回してみましょう。
まず、三角形をx方向に +1 だけ動かすにはどうすればいいか考えます。単純に考えると頂点の位置である a_position のデータを変更すれば良さそうです。次のようなイメージですね。

  • 移動前: [0, 1], [1, -1], [-1, -1]
  • 移動後: [1, 1], [2, -1], [0, -1]

しかし、このやり方は非常に効率が悪いです。三角形の位置を動かす度にVAOを作り直して GPU に転送する必要があるからです。ではどうするか、全ての頂点から共通して参照できる uniform という変数を使用します。頂点の位置は元のまま持っておいて、その移動量だけを渡すイメージです。

  • 移動前: [0, 1], [1, -1], [-1, -1]
  • 移動後: ([0, 1], [1, -1], [-1, -1]) + [1, 0]

頂点数が数千単位の3Dモデルなどで想像すると、uniform によるデータ転送の効率の高さが実感しやすいですね。

+ import { Core, Vao, Program, Renderer, Loop } from "glaku";

export const main = (canvas: HTMLCanvasElement) => {
  const core = new Core({ canvas });

  const vao = new Vao(core, {
    attributes: { a_position: [0, 1, 1, -1, -1, -1] },
  });

  const program = new Program(core, {
    attributeTypes: { a_position: "vec2" },
+   uniformTypes: { u_offset: "vec2" },
    vert: /* glsl */ `
          void main() {
+           gl_Position = vec4(0.5 * a_position + u_offset, 1.0, 1.0);
          }`,
    frag: /* glsl */ `
          out vec4 o_color;
          void main() {
            o_color = vec4(0.4, 0.4, 1.0, 1.0);
          }`,
  });

  const renderer = new Renderer(core);

+ const loop = new Loop({
+   callback: ({ elapsed }) => {
+     const rad = elapsed / 250;
+     const offset = [0.5 * Math.cos(rad), 0.5 * Math.sin(rad)];
+     program.setUniform({ u_offset: offset });
+     renderer.render(vao, program);
+   },
+ });
+ loop.start();
};

まず、シェーダに uniform を渡すために名前と型を指定しておきましょう。(u_offset: "vec2")そして、ここで渡す u_offset は「全ての頂点をx, y方向にどれだけ移動させるか」を表すものなので、頂点シェーダ内で a_position に加算すれば良さそうですね。ただし、a_position の値をそのまま使うと三角形が大きく画面外へ飛び出してしまうので、ここでは三角形の大きさを半分にした上で
u_offset を加算しています。(0.5 * a_position + u_offset)

+   uniformTypes: { u_offset: "vec2" },
    vert: /* glsl */ 
          void main() {
+           gl_Position = vec4(0.5 * a_position + u_offset, 1.0, 1.0);
          },

さて実際に三角形を回すため、以下の処理を毎フレーム実行してアニメーションさせましょう。

  • 三角形の移動量(offset)を計算
    • 経過時間(elapsed)を元にした角度(rad)で円運動を表現
      • [0.5 * Math.cos(rad), 0.5 * Math.sin(rad)]
  • offset を uniform(u_offset)としてシェーダに転送
    • program.setUniform({ u_offset: offset })
  • 移動後の三角形を描画
    • renderer.render(vao, program)
+ const loop = new Loop({
+   callback: ({ elapsed }) => {
+     const rad = elapsed / 250;
+     const offset = [0.5 * Math.cos(rad), 0.5 * Math.sin(rad)];
+     program.setUniform({ u_offset: offset });
+     renderer.render(vao, program);
+   },
+ });
+ loop.start();

Step.3: 三角形の色を位置に応じて変える

いよいよ最後のステップです。三角形を表示するピクセルの色を、描画先の座標位置に応じて変更したいと思います。色を変えるにはフラグメントシェーダを使えば良さそうですが、「位置に応じて」というのが少し難しそうですね。
フラグメントシェーダの実行時に、描画先の座標位置が取得できればどうにかなりそうですが、   attribute は頂点シェーダでしか参照できないですし、uniform の値はシェーダの実行毎に共通でした。
なのでここでは varying という種類の変数を使うことで、頂点シェーダからフラグメントシェーダへデータを伝えます。

import { Core, Vao, Program, Renderer, Loop } from "glaku";

export const main = (canvas: HTMLCanvasElement) => {
  const core = new Core({ canvas });

  const vao = new Vao(core, {
    attributes: { a_position: [0, 1, 1, -1, -1, -1] },
  });

  const program = new Program(core, {
    attributeTypes: { a_position: "vec2" },
    uniformTypes: { u_offset: "vec2" },
    vert: /* glsl */ `
+         out vec2 v_position;
          void main() {
+           vec2 position = 0.5 * a_position + u_offset;
+           v_position = position;
+           gl_Position = vec4(position, 1.0, 1.0);
          }`,
    frag: /* glsl */ `
+         in vec2 v_position;
          out vec4 o_color;
          void main() {
+           float r =  v_position.x;
+           float g =  -v_position.x;
+           float b =  v_position.y + 0.5;
+           o_color = vec4(r, g, b, 1.0);
          }`,
  });

  const renderer = new Renderer(core);

  const loop = new Loop({
    callback: ({ elapsed }) => {
      const rad = elapsed / 250;
      const offset = [0.5 * Math.cos(rad), 0.5 * Math.sin(rad)];
      program.setUniform({ u_offset: offset });
      renderer.render(vao, program);
    },
  });
  loop.start();
};

varying を使うために、頂点シェーダでは出力用の変数(ここでは out vec2 v_position)を宣言し、これに値を代入する必要があります。元々 gl_Position に指定していた頂点位置を position という一時変数に代入し、これを gl_Positionv_position に代入することで頂点位置を指定しつつ、フラグメントシェーダにも伝えましょう。

+ out vec2 v_position;
void main() {
+   vec2 position = 0.5 * a_position + u_offset;
+   v_position = position;
+   gl_Position = vec4(position, 1.0, 1.0);
}

フラグメントシェーダでは入力用の変数(ここでは in vec2 v_position)を宣言することで、頂点シェーダから送られた varying を使うことができます。
今回の実装では三角形の色が次のように変化するように、RGBの値を v_position に基づいて決定しています。

  • 右に行くほど赤く: float r = v_position.x
  • 左に行くほど緑に: float g = -v_position.x
  • 上に行くほど青く: floatr b = v_position.y + 0.5
+ in vec2 v_position;
out vec4 o_color;
void main() {
+   float r =  v_position.x;
+   float g =  -v_position.x;
+   float b =  v_position.y + 0.5;
+   o_color = vec4(r, g, b, 1.0);
}`,

おわり

お疲れ様です、これで全てのステップを完了しました!🎉🎉🎉
三角形を動かしたり色を変えたりしてきましたが、高度な表現もすべてこの延長線上にあります。
例えば、頂点シェーダを工夫して MVP行列 を実装すれば、立体的な表現が可能ですし、フラグメントシェーダを工夫して フォンシェーディング を実装すれば、光による陰影を描写することができます。
自分はWebGLが持つ表現の可能性にワクワクし、その魅力をより多くの方に知ってもらいたいという思いから記事を書きました。
この記事がWebGL学習者の方にとって、ほんの一助となれば幸いです。

付録

Glaku

  • ドキュメントでは複数のサンプルを用意していますが、特に Cyberpunk (Defferd Shading + Instancing のデモ)は触ると楽しいと思うので是非
  • 要望/不具合等あれば気楽にissueを立てて貰えると嬉しいです
  • なんで作ったのか
    • WebGLを学んだ結果を形にしたかった
    • OSSとして何かライブラリを公開してみたかった
    • WebGLを使ったオモチャを作ってみたいので準備として
  • いつかWebGPU用のライブラリも作ってみたい

参考(抜粋)

数えきれないくらい多くのサイトを参考にしましたが、特にお世話になったものを抜粋します。

Discussion