WebGLで円形を描画し、カラーパレットを実装する

はじめに

Canvas2Dでは円の描画はarcメソッドを呼び出すだけで簡単に実現できます。同様にSVGでは<circle>タグを使用するだけで円を描画できます。しかし、WebGLではどうでしょうか?WebGLは点、線、三角形の3つの形状しか描画できず、円を直接描画する機能は提供されていません。もちろんSVGのようにタグを使用することもできないため、円の曲線を直接描画することはできません。このような場合、関連する数学知識を活用して円の描画を実装する必要があります。

パラメトリック方程式

数学の基礎がしっかりしている方であればすぐに思いつくかもしれませんが、パラメトリック方程式を使用して円周上の点の座標を取得し、十分な数の点を収集して線分でこれらの点を接続すれば、円に近い形状を得ることができます。視覚的には円に見えるでしょう。実は円は曲線の一種の特殊なケースであり、パラメトリック方程式を使用して他の一般的な曲線、例えば楕円、放物線、正弦・余弦曲線なども描画できるのです。

以下は円のパラメトリック方程式です:

円のパラメトリック方程式では、中心座標、半径、および角度の正弦・余弦値を使用して横座標と縦座標の値を表現できます。

具体的な実装

この方針に基づいて、コードを記述して円の曲線を描画してみましょう。

本格的な実装に先立ち、HTMLでCanvasを準備します:

<canvas id="webglCanvas" width="256" height="256"></canvas>

以降のコードでは、以前に簡単にラッピングしたWebGLクラスを使用します。これは、面倒なシェーダープログラムの作成手順をいくつかラッピングしたものです。ラッピングは粗いですが、必要な機能は備えています。それでは具体的な実装を始めましょう。

  • まず、円の曲線の頂点集合を取得する関数を定義します。
const CIRCLE_SEGMENTS = 60;
const FULL_CIRCLE = Math.PI * 2;
// 円の曲線の頂点集合を取得
function generateCirclePoints(centerX, centerY, radius, startAngle = 0, endAngle = Math.PI * 2) {
  const angleRange = Math.min(FULL_CIRCLE, endAngle - startAngle);
  const points = angleRange === FULL_CIRCLE ? [] : [[centerX, centerY]];
  const segments = Math.round(CIRCLE_SEGMENTS * angleRange / FULL_CIRCLE);
  
  for (let i = 0; i <= segments; i++) {
    const angle = startAngle + angleRange * i / segments;
    const x = centerX + radius * Math.cos(angle);
    const y = centerY + radius * Math.sin(angle);
    points.push([x, y]);
  }
  return points;
}

centerXとcenterYは円の中心座標、radiusは半径、startAngleとendAngleは円弧の開始角度と終了角度です。完全な円の場合、0から2πになります。これらのパラメータは比較的理解しやすいでしょう。

次に、generateCirclePoints関数の内部変数を見てみましょう。angleRangeは終了角度と開始角度の差です。segmentsは円弧上に取る点の総数で、完全な円の場合は60個の点を取ります。

次に、繰り返し処理を行い、segments数の点の座標を取得し、points配列に格納します。

  • これで、generateCirclePoints関数を呼び出して頂点集合を取得できます。
const circleVertices = generateCirclePoints(0, 0, 0.8);

WebGLでは座標系の視口座標範囲はデフォルトで-1から1であるため、視口全体に円が表示されるように、この円の半径は1を超えてはなりません。ここでは半径を0.8、中心を(0, 0)として頂点集合を取得します。

  • WebGLプログラムを作成して描画します。

WebGL部分のコードは比較的簡単です。まず2つのGLSLコードがあり、一般的な三角形の実装と大きな違いはありません:

const vertexShaderCode = `
  attribute vec2 position;
  varying vec2 vPosition;
  
  void main() {
    gl_PointSize = 1.0;
    gl_Position = vec4(position, 0.0, 1.0);
    vPosition = position;
  }
`;
const fragmentShaderCode = `
  precision mediump float;
  
  void main() {
    gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0);
  }
`;

パラメトリック方程式で取得した点は連続しているため、gl.LINE_LOOPの描画モードを使用してすべての点を連結すれば、視覚的には円の曲線が得られます。

const canvas = document.getElementById('webglCanvas');
const gl = canvas.getContext('webgl');
const webglRenderer = new WebGLRenderer(gl, vertexShaderCode, fragmentShaderCode);
webglRenderer.drawContinuousPoints(circleVertices.flat(), 2, gl.LINE_LOOP);

具体的なラッピングされたdrawContinuousPointsメソッドではgl.drawArraysを呼び出して図形を描画しています。

gl.drawArrays(gl.LINE_LOOP, 0, points.length / size);

実際に操作してみると、円の曲線を描画するのは比較的簡単であることがわかります。そのため、カラーパレットの実装も試みることができます。

カラーパレットは実心の円であるため、線の方式で描画することはできません。以前の「ベクトルを使用して多边形の境界を判断する」記事で述べたように、多边形は複数の三角形の組み合わせと見なすことができます。そのため、多边形を三角分割し、つまり複数の三角形の組み合わせを使用して多边形を表現し、これらの三角形をすべてキャンバスに描画すれば多边形が構成され、円は特殊な多边形と見なすことができます。

三角分割アルゴリズムは比較的複雑であるため、既存のライブラリを直接呼び出してこの操作を完了させることができます。以前はearcutというライブラリを使用していましたが、今回はTESS2という別のライブラリに切り替えます。より詳細な紹介はそのGitHubリポジトリを参照してください。以下では、TESS2のAPIを呼び出して三角分割操作を実行します。

webglRenderer.drawPolygonWithTess2(circleVertices);
// ↓↓ 
drawPolygonWithTess2(points, {
    fillColor,
    fillRule = WINDING_ODD/*WINDING_NONZERO*/
} = {}) {
    const triangles = performTriangulation(points, fillRule);
    triangles.forEach(t => this.renderTriangle(t, {fillColor}));
}
// ↓↓
function performTriangulation(points, rule = WINDING_ODD) {
    const result = tesselate({
        contours: [points.flat()],
        windingRule: rule,
        elementType: POLYGONS,
        polySize: 3,
        vertexSize: 2,
        strict: false
    });
    const triangles = [];
    for (let i = 0; i < result.elements.length; i += 3) {
        const a = result.elements[i];
        const b = result.elements[i + 1];
        const c = result.elements[i + 2];
        triangles.push([
            [result.vertices[a * 2], result.vertices[a * 2 + 1]],
            [result.vertices[b * 2], result.vertices[b * 2 + 1]],
            [result.vertices[c * 2], result.vertices[c * 2 + 1]],
        ])
    }
    return triangles;
}

これで、黒い実心円が描画されました。

カラーパレットを実装するには、HSVまたはHSLの色表現形式を使用する必要があります。色相(Hue)の値の範囲は0から360度であるため、これらの色表現形式を使用すると、色値を角度と直接関連付けることができます。そのため、varying変数を使用して座標情報をフラグメントシェーダーに渡し、フラグメントシェーダー内で座標情報を使用してhsv形式のピクセル色値を計算します。

// 頂点シェーダー
attribute vec2 position;
varying vec2 vPosition;

void main() {
  gl_PointSize = 1.0;
  gl_Position = vec4(position, 0.0, 1.0);
  vPosition = position;
}

しかし、WebGLではHSVの色表現形式を直接処理することはできないため、hsv2rgb関数を使用して色ベクトルの変換を行う必要があります。この変換アルゴリズムの詳細についてはあまり理解していませんが、興味のある方は各自で研究してみてください。

// フラグメントシェーダー
#define PI 3.1415926535897932384626433832795
precision mediump float;

varying vec2 vPosition;

// hsv -> rgb
// パラメータの範囲はすべて (0, 1)
vec3 hsvToRgb(vec3 hsv) {
  vec3 rgb = clamp(abs(mod(hsv.x * 6.0 + vec3(0.0, 4.0, 2.0), 6.0) - 3.0) - 1.0, 0.0, 1.0);
  rgb = rgb * rgb * (3.0 - 2.0 * rgb);
  return hsv.z * mix(vec3(1.0), rgb, hsv.y);
}

void main() {
  float centerX = 0.0;
  float centerY = 0.0;
  float hue = atan(vPosition.y - centerY, vPosition.x - centerX);
  hue = hue / (PI * 2.0); // 正規化処理
  vec3 hsvColor = vec3(hue, 1.0, 1.0);
  vec3 rgbColor = hsvToRgb(hsvColor);
  gl_FragColor = vec4(rgbColor, 1.0);
}

上記のコードでは、atan関数を呼び出して(0,0)を中心とする弧度値を計算し、それを2πで割って正規化された値を取得します。次に、この正規化された値をhsvToRgb関数でRGB色ベクトルに変換します。

これで、WebGLを使用してカラーパレットを実装しました。色の遷移をより自然にするために、彩度が半径の増加に伴って増加するように設定することもできます。

void main() {
  // ...
  float radius = sqrt((vPosition.x - centerX) * (vPosition.x - centerX) + 
                     (vPosition.y - centerY) * (vPosition.y - centerY)); // 半径を計算

  vec3 hsvColor = vec3(hue, radius * 1.2, 1.0);
  // ...
}

これで、円を描画し、カラーパレットを実装する方法が理解できたはずです。実際に手を動かして試してみてください。

タグ: WebGL カラーパレット 円形描画 シェーダー WebGL描画

6月15日 17:50 投稿