WebGLで波状のカラーグラデーションを描画する

## 概要

この記事では、WebGLを使って時間経過で変化する波状のカラーグラデーションを描画する方法をWebGL初心者の方を対象に、ステップバイステップで手順を説明していきます。

## 完成イメージ
![gradation_1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/34853/5a43448d-1301-157c-62d1-36d66ed5289b.gif)



## 手順
### 手順1: HTMLファイルの作成

まずは、WebGLを描画するためのキャンバス要素をHTMLに配置します。以下のHTMLコードをコピーして、`index.html`などの名前で保存してください。`<style>`タグでキャンバスを画面全体に表示するように設定しています。

```html
<!DOCTYPE html>
<html>
<head>
  <title>Wavy Color Gradient</title>
  <style>
    body { margin: 0; }
    canvas { display: block; }
  </style>
</head>
<body>
<canvas id="canvas"></canvas>
<script>
  // ここにJavaScriptコードが入ります (後述)
</script>
</body>
</html>
```

### 手順2: JavaScriptでWebGLコンテキストを取得

次に、JavaScriptでWebGLコンテキストを取得します。`<script>`タグ内に以下のコードを追加します。

```javascript
const canvas = document.getElementById('canvas');
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
const gl = canvas.getContext('webgl');
```

これで、`gl`変数を通じてWebGLの機能を利用できるようになります。

### 手順3: シェーダーの作成

WebGLでは、頂点シェーダーとフラグメントシェーダーという2種類のシェーダーを使って描画を行います。

* **頂点シェーダー:** 頂点の位置を決定します。
* **フラグメントシェーダー:** 各ピクセルの色を決定します。

まずは、これらのシェーダーのコードを文字列として定義します。

```javascript
// 頂点シェーダー - 単純に頂点をそのまま出力
const vertexShaderSource = `
  attribute vec2 position;
  void main() {
    gl_Position = vec4(position, 0.0, 1.0);
  }
`;

// フラグメントシェーダー - 時間経過で変化する波状のカラーグラデーションを生成
const fragmentShaderSource = `
  precision mediump float;
  uniform float time;
  uniform vec2 resolution;

  // 7色のグラデーションカラーを定義
  vec3 colorA = vec3(1.0, 0.2, 0.2); // Red
  vec3 colorB = vec3(0.93, 0.47, 0.0); // Orange
  vec3 colorC = vec3(1.0, 0.87, 0.0); // Yellow
  vec3 colorD = vec3(0.0, 0.66, 0.37); // Green
  vec3 colorE = vec3(0.2, 0.6, 1.0); // Blue
  vec3 colorF = vec3(0.61, 0.45, 0.7); // Purple
  vec3 colorG = vec3(0.87, 0.52, 0.64); // Pink

  // 時間に基づいてグラデーションカラーを返す関数
  vec3 getColor(float t) {
    t = fract(t);
    if (t < 1.0/7.0) return mix(colorA, colorB, t * 7.0);
    if (t < 2.0/7.0) return mix(colorB, colorC, (t - 1.0/7.0) * 7.0);
    if (t < 3.0/7.0) return mix(colorC, colorD, (t - 2.0/7.0) * 7.0);
    if (t < 4.0/7.0) return mix(colorD, colorE, (t - 3.0/7.0) * 7.0);
    if (t < 5.0/7.0) return mix(colorE, colorF, (t - 4.0/7.0) * 7.0);
    if (t < 6.0/7.0) return mix(colorF, colorG, (t - 5.0/7.0) * 7.0);
    return mix(colorG, colorA, (t - 6.0/7.0) * 7.0);
  }

  void main() {
    vec2 uv = gl_FragCoord.xy / resolution.xy;
    float distortion = sin(uv.x * 10.0 + time) * 0.1 * sin(uv.y * 10.0 + time * 2.0);
    gl_FragColor = vec4(getColor(uv.x + distortion), 1.0);
  }
`;
```

頂点シェーダーは、入力として頂点座標(`position`)を受け取り、出力として`gl_Position`を設定します。フラグメントシェーダーには、時間(`time`)とキャンバスの解像度(`resolution`)を渡し、`getColor`関数で時間に基づいてグラデーションカラーを計算します。


### 手順4: シェーダーのコンパイルとリンク

WebGLはシェーダーのコードを直接理解できないため、コンパイルとリンクという手順が必要です。以下の関数を追加します。

```javascript
function createShaderProgram(vertexSource, fragmentSource) {
  const vertexShader = gl.createShader(gl.VERTEX_SHADER);
  const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
  gl.shaderSource(vertexShader, vertexSource);
  gl.shaderSource(fragmentShader, fragmentSource);
  gl.compileShader(vertexShader);
  gl.compileShader(fragmentShader);
  const shaderProgram = gl.createProgram();
  gl.attachShader(shaderProgram, vertexShader);
  gl.attachShader(shaderProgram, fragmentShader);
  gl.linkProgram(shaderProgram);
  return shaderProgram;
}

const shaderProgram = createShaderProgram(vertexShaderSource, fragmentShaderSource);
```

この`createShaderProgram`関数は、与えられた頂点シェーダーとフラグメントシェーダーのソースコードをコンパイルし、リンクして、シェーダープログラムを返します。


### 手順5: シェーダープログラムの属性とuniform変数の取得

シェーダープログラム内の属性とuniform変数の場所を取得します。

```javascript
const positionAttributeLocation = gl.getAttribLocation(shaderProgram, 'position');
const timeUniformLocation = gl.getUniformLocation(shaderProgram, 'time');
const resolutionUniformLocation = gl.getUniformLocation(shaderProgram, 'resolution');
```


### 手順6: 頂点バッファの作成

頂点バッファを作成し、頂点データをWebGLに渡します。

```javascript
const positionBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
  -1.0, -1.0, // 左下
  1.0, -1.0, // 右下
  -1.0,  1.0, // 左上
  -1.0,  1.0, // 左上
  1.0, -1.0, // 右下
  1.0,  1.0, // 右上
]), gl.STATIC_DRAW);
```

このコードでは、2つの三角形を描画するための頂点座標を指定しています。


### 手順7: 描画ループ

`requestAnimationFrame`を使って描画ループを実装します。

```javascript
function render(timestamp) {
  const time = timestamp * 0.001;

  gl.clearColor(0.0, 0.0, 0.0, 1.0);
  gl.clear(gl.COLOR_BUFFER_BIT);

  gl.useProgram(shaderProgram);

  gl.uniform1f(timeUniformLocation, time);
  gl.uniform2f(resolutionUniformLocation, gl.canvas.width, gl.canvas.height);

  gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
  gl.enableVertexAttribArray(positionAttributeLocation);
  gl.vertexAttribPointer(positionAttributeLocation, 2, gl.FLOAT, false, 0, 0);

  gl.drawArrays(gl.TRIANGLES, 0, 6);

  requestAnimationFrame(render);
}

requestAnimationFrame(render);
```

この`render`関数は、毎フレーム画面をクリアし、時間と解像度をシェーダーに渡して、三角形を描画します。

## 完成版のコード

上記のコードを全て`<script>`タグ内にまとめた完成版は以下の通りです。

```html
<!DOCTYPE html>
<html>
<head>
  <title>Wavy Color Gradient</title>
  <style>
    body { margin: 0; }
    canvas { display: block; }
  </style>
</head>
<body>
<canvas id="canvas"></canvas>
<script>
  // キャンバス要素を取得
  const canvas = document.getElementById('canvas');
  // キャンバスのサイズを設定
  canvas.width = window.innerWidth;
  canvas.height = window.innerHeight;
  // WebGLコンテキストを取得
  const gl = canvas.getContext('webgl');

  // 頂点シェーダー - 単純に頂点をそのまま出力
  const vertexShaderSource = `
    attribute vec2 position; // 頂点座標
    void main() {
      gl_Position = vec4(position, 0.0, 1.0); // 出力座標
    }
  `;

  // フラグメントシェーダー - 時間経過で変化する波状のカラーグラデーションを生成
  const fragmentShaderSource = `
    precision mediump float; // 中程度の精度の浮動小数点数を指定
    uniform float time; // 時間経過を表す変数
    uniform vec2 resolution; // キャンバスの解像度

    // 7色のグラデーションカラーを定義
    vec3 colorA = vec3(255.0/255.0, 51.0/255.0, 51.0/255.0); // Red
    vec3 colorB = vec3(238.0/255.0, 120.0/255.0, 0.0/255.0); // Orange
    vec3 colorC = vec3(255.0/255.0, 220.0/255.0, 0.0/255.0); // Yellow
    vec3 colorD = vec3(0.0/255.0, 169.0/255.0, 96.0/255.0); // Green
    vec3 colorE = vec3(51.0/255.0, 153.0/255.0, 255.0/255.0); // Blue
    vec3 colorF = vec3(155.0/255.0, 114.0/255.0, 178.0/255.0); // Purple
    vec3 colorG = vec3(222.0/255.0, 130.0/255.0, 167.0/255.0); // Pink

    // 時間に基づいてグラデーションカラーを返す関数
    vec3 getColor(float t) {
      // 時間を 0.0 - 1.0 の範囲にクランプ
      t = fract(t);
      // 時間に応じて適切なカラーを線形補間で計算して返す
      if (t < 1.0/7.0) return mix(colorA, colorB, t * 7.0);
      if (t < 2.0/7.0) return mix(colorB, colorC, (t - 1.0/7.0) * 7.0);
      if (t < 3.0/7.0) return mix(colorC, colorD, (t - 2.0/7.0) * 7.0);
      if (t < 4.0/7.0) return mix(colorD, colorE, (t - 3.0/7.0) * 7.0);
      if (t < 5.0/7.0) return mix(colorE, colorF, (t - 4.0/7.0) * 7.0);
      if (t < 6.0/7.0) return mix(colorF, colorG, (t - 5.0/7.0) * 7.0);
      return mix(colorG, colorA, (t - 6.0/7.0) * 7.0);
    }

    void main() {
      // 画面上の座標を正規化
      vec2 uv = gl_FragCoord.xy / resolution.xy;
      // 時間と座標に基づいて歪みを計算
      float distortion = sin(uv.x * 10.0 + time) * 0.1 * sin(uv.y * 10.0 + time * 2.0);
      // 歪みを適用した座標でグラデーションを取得
      gl_FragColor = vec4(getColor(uv.x + distortion), 1.0); // 出力カラー
    }
  `;

  // シェーダーをコンパイルしてプログラムを作成する関数
  function createShaderProgram(vertexSource, fragmentSource) {
    // 頂点シェーダーを作成
    const vertexShader = gl.createShader(gl.VERTEX_SHADER);
    // フラグメントシェーダーを作成
    const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
    // シェーダーにソースコードを設定
    gl.shaderSource(vertexShader, vertexSource);
    gl.shaderSource(fragmentShader, fragmentSource);
    // シェーダーをコンパイル
    gl.compileShader(vertexShader);
    gl.compileShader(fragmentShader);
    // シェーダープログラムを作成
    const shaderProgram = gl.createProgram();
    // シェーダープログラムにシェーダーをアタッチ
    gl.attachShader(shaderProgram, vertexShader);
    gl.attachShader(shaderProgram, fragmentShader);
    // シェーダープログラムをリンク
    gl.linkProgram(shaderProgram);
    // シェーダープログラムを返す
    return shaderProgram;
  }

  // シェーダープログラムを作成
  const shaderProgram = createShaderProgram(vertexShaderSource, fragmentShaderSource);

  // シェーダープログラムの属性を取得
  const positionAttributeLocation = gl.getAttribLocation(shaderProgram, 'position'); // 頂点座標属性の場所を取得
  const timeUniformLocation = gl.getUniformLocation(shaderProgram, 'time'); // 時間経過を表すuniform変数の場所を取得
  const resolutionUniformLocation = gl.getUniformLocation(shaderProgram, 'resolution'); // キャンバスの解像度を表すuniform変数の場所を取得

  // 頂点バッファを作成
  const positionBuffer = gl.createBuffer();
  // 頂点バッファをバインド
  gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
  // 頂点バッファにデータをセット
  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
    -1.0, -1.0, // 左下
    1.0, -1.0, // 右下
    -1.0,  1.0, // 左上
    -1.0,  1.0, // 左上
    1.0, -1.0, // 右下
    1.0,  1.0, // 右上
  ]), gl.STATIC_DRAW);

  // アニメーションループ
  function render(timestamp) {
    // 時間経過を計算
    const time = timestamp * 0.001;

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

    // シェーダープログラムを使用
    gl.useProgram(shaderProgram);

    // 時間と解像度をシェーダーに渡す
    gl.uniform1f(timeUniformLocation, time);
    gl.uniform2f(resolutionUniformLocation, gl.canvas.width, gl.canvas.height);

    // 頂点バッファをバインドして有効化
    gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
    gl.enableVertexAttribArray(positionAttributeLocation);
    gl.vertexAttribPointer(positionAttributeLocation, 2, gl.FLOAT, false, 0, 0);

    // 三角形を描画
    gl.drawArrays(gl.TRIANGLES, 0, 6);

    // 次のフレームを要求
    requestAnimationFrame(render);
  }

  // アニメーションを開始
  requestAnimationFrame(render);
</script>
</body>
</html>
```

このコードをブラウザで開くと、7色の波状のカラーグラデーションが表示され、時間経過とともに変化していく様子が確認できます。

WebGLやシェーダーはとっつきにくいかもしれませんが、何度も書いていると理解が進むと思いますので、まずはぜひいろいろと触って作ってみてみてください。