WebGL 変換

Gregg Tavares
Gregg Tavares

WebGL 2D 変換

3D に進む前に、もうしばらく 2D で説明しましょう。しばらくお待ちください。この記事の内容は難しそうに思われるかもしれませんが、いくつかの記事で要点をまとめます。

この記事は、WebGL の基礎以降のシリーズの続きです。まだ読んでいない場合は、少なくとも第 1 章を読んでから、ここに戻ることをおすすめします。 翻訳とは、基本的に何かを「動かす」という意味の数学的表現です。英語の文を日本語に動かすのも合っていると思いますが、ここではジオメトリの移動についてです。最初の投稿で紹介したサンプルコードを使用すると、setRectangle に渡される値を変更するだけで長方形を簡単に変換できました。こちらは、以前のサンプルに基づくサンプルです。

  // First lets make some variables 
  // to hold the translation of the rectangle
  var translation = [0, 0];
  // then let's make a function to
  // re-draw everything. We can call this
  // function after we update the translation.
  // Draw the scene.
  function drawScene() {
     // Clear the canvas.
    gl.clear(gl.COLOR_BUFFER_BIT);
    // Setup a rectangle
    setRectangle(gl, translation[0], translation[1], width, height);

    // Draw the rectangle.
    gl.drawArrays(gl.TRIANGLES, 0, 6);
  }

ここまでは順調です。ここで、同じことをより複雑なシェイプでも行いたいとします。たとえば、次のような 6 つの三角形で構成される「F」を描画するとします。

F の文字

現在のコードは次のようになります。setRectangle を次のようなものに変更する必要があります。

// Fill the buffer with the values that define a letter 'F'.
function setGeometry(gl, x, y) {
  var width = 100;
  var height = 150;
  var thickness = 30;
  gl.bufferData(
      gl.ARRAY_BUFFER,
      new Float32Array([
          // left column
          x, y,
          x + thickness, y,
          x, y + height,
          x, y + height,
          x + thickness, y,
          x + thickness, y + height,

          // top rung
          x + thickness, y,
          x + width, y,
          x + thickness, y + thickness,
          x + thickness, y + thickness,
          x + width, y,
          x + width, y + thickness,

          // middle rung
          x + thickness, y + thickness * 2,
          x + width * 2 / 3, y + thickness * 2,
          x + thickness, y + thickness * 3,
          x + thickness, y + thickness * 3,
          x + width * 2 / 3, y + thickness * 2,
          x + width * 2 / 3, y + thickness * 3]),
      gl.STATIC_DRAW);
}

これでは、うまくスケーリングできないことがわかります。何百、何千もの線からなる非常に複雑なジオメトリを描く場合、かなり複雑なコードを書く必要があります。さらに、JavaScript を描画するたびにすべてのポイントを更新する必要があります。簡単な方法があります。ジオメトリをアップロードして、シェーダーで変換を行うだけです。 これが新しいシェーダー

<script id="2d-vertex-shader" type="x-shader/x-vertex">
attribute vec2 a_position;

uniform vec2 u_resolution;
uniform vec2 u_translation;

void main() {
   // Add in the translation.
   vec2 position = a_position + u_translation;

   // convert the rectangle from pixels to 0.0 to 1.0
   vec2 zeroToOne = position / u_resolution;
   ...

コードを少し再構築します1 つ目の例では、ジオメトリを一度設定するだけで済みます。

// Fill the buffer with the values that define a letter 'F'.
function setGeometry(gl) {
  gl.bufferData(
      gl.ARRAY_BUFFER,
      new Float32Array([
          // left column
          0, 0,
          30, 0,
          0, 150,
          0, 150,
          30, 0,
          30, 150,

          // top rung
          30, 0,
          100, 0,
          30, 30,
          30, 30,
          100, 0,
          100, 30,

          // middle rung
          30, 60,
          67, 60,
          30, 90,
          30, 90,
          67, 60,
          67, 90]),
      gl.STATIC_DRAW);
}

次に、u_translation を更新して、目的の翻訳で描画します。

  ...
  var translationLocation = gl.getUniformLocation(
             program, "u_translation");
  ...
  // Set Geometry.
  setGeometry(gl);
  ..
  // Draw scene.
  function drawScene() {
    // Clear the canvas.
    gl.clear(gl.COLOR_BUFFER_BIT);

    // Set the translation.
    gl.uniform2fv(translationLocation, translation);

    // Draw the rectangle.
    gl.drawArrays(gl.TRIANGLES, 0, 18);
  }

setGeometry は 1 回だけ呼び出されます。drawScene 内にはなくなりました。

今では、WebGL を描画する際には、実質的にすべての処理を行っています。私たちは、翻訳を設定して描画を依頼するだけです。ジオメトリに数万個のポイントがあっても、メインのコードは変わりません。

WebGL 2D 回転

どうやって説明すれば合理的かわかりませんが、いったい何を試してみてもよいでしょう。

まず、「単位円」と呼ばれるものを紹介します。中学生の数学を覚えているなら、円には半径があります。円の半径は、円の中心から端までの距離です。単位円とは、半径 1.0 の円です。

3 年生の基礎計算では、1 を掛けても同じ結果になります。したがって、123 * 1 = 123 となります。ごく基本的なことでした。単位円、半径が 1.0 の円も 1 の形式です。1 回転です。この単位円で何かを乗算すると、1 を掛けるようなものです。ただし、魔法のように物事が回転して回転します。単位円上の任意の点から X と Y の値を取得し、ジオメトリに前のサンプルで使用した値を乗算します。シェーダーのアップデートは次のとおりです。

<script id="2d-vertex-shader" type="x-shader/x-vertex">
attribute vec2 a_position;

uniform vec2 u_resolution;
uniform vec2 u_translation;
uniform vec2 u_rotation;

void main() {
  // Rotate the position
  vec2 rotatedPosition = vec2(
     a_position.x * u_rotation.y + a_position.y * u_rotation.x,
     a_position.y * u_rotation.y - a_position.x * u_rotation.x);

  // Add in the translation.
  vec2 position = rotatedPosition + u_translation;

そして、この 2 つの値を渡すように JavaScript を更新します。

  ...
  var rotationLocation = gl.getUniformLocation(program, "u_rotation");
  ...
  var rotation = [0, 1];
  ..
  // Draw the scene.
  function drawScene() {
    // Clear the canvas.
    gl.clear(gl.COLOR_BUFFER_BIT);

    // Set the translation.
    gl.uniform2fv(translationLocation, translation);

    // Set the rotation.
    gl.uniform2fv(rotationLocation, rotation);

    // Draw the rectangle.
    gl.drawArrays(gl.TRIANGLES, 0, 18);
  }

解説そう、計算を見てみて。

rotatedX = a_position.x * u_rotation.y + a_position.y * u_rotation.x;
rotatedY = a_position.y * u_rotation.y - a_position.x * u_rotation.x;

長方形があり、それを回転させます。回転を開始する前の右上は 3.0、9.0 です。単位円上の 12 時から時計回り 30 度の位置にある点を選びます。

30 度回転

円上の位置は 0.50 と 0.87 である

3.0 * 0.87 + 9.0 * 0.50 = 7.1
9.0 * 0.87 - 3.0 * 0.50 = 6.3

まさにこの瞬間に私たちが求めているのは

回転の描画

時計回りに 60 度同じ

60 度回転

円上の位置は 0.87 と 0.50 である

3.0 * 0.50 + 9.0 * 0.87 = 9.3
9.0 * 0.50 - 3.0 * 0.87 = 1.9

この点を時計回りに回転させると、X の値が大きくなり、Y の値が小さくなります。90 度を超えると、X は再び小さくなり、Y は大きくなり始めます。このパターンで回転を行います。単位円上の点には別の名前があります。これらは正弦と余弦と呼ばれます。したがって、任意の角度について、サインとコサインをこのように検索できます。

function printSineAndCosineForAnAngle(angleInDegrees) {
  var angleInRadians = angleInDegrees * Math.PI / 180;
  var s = Math.sin(angleInRadians);
  var c = Math.cos(angleInRadians);
  console.log("s = " + s + " c = " + c);
}

コードをコピーして JavaScript コンソールに貼り付け、「printSineAndCosignForAngle(30)」と入力すると、s = 0.49 c= 0.87 と表示されます(注: 数値は四捨五入しています)。これらを組み合わせることで、ジオメトリを任意の角度に回転させることができます。回転させたい角度のサインとコサインに設定するだけです。

  ...
  var angleInRadians = angleInDegrees * Math.PI / 180;
  rotation[0] = Math.sin(angleInRadians);
  rotation[1] = Math.cos(angleInRadians);

この情報がお役に立てば幸いです。次はより簡単な問題です。スケーリング

ラジアンとは

ラジアンは、円、回転、角度で使用される測定単位です。距離をインチ、ヤード、メートルなどで測定できるのと同様に、角度は度数やラジアンで測定できます。

ご存知のとおり、メートル法による計算は、ヤードポンド法による計算よりも簡単です。インチをフィートに換算するには、12 で割ります。インチをヤードに換算するには、36 で割ります。どうしたらいいかわからないけど、頭の中で 36 で割ることはできないよ。指標を使うと簡単に対処できますミリメートルからセンチメートルを単位にするには、10 で割ります。ミリメートルをメートルに換算するには、1,000 で割ります。頭の中で 1,000 で割ることはできる

ラジアンと度数は似ています。度は計算が難しくなります。ラジアンは計算が楽になります。円の中には 360 度ありますが、その直線は 2pi ラジアンのみです。1 回転は 2pi ラジアンです。半回転は pi ラジアンです。1/4 回転、すなわち 90 度は ± 2 ラジアンです。90 度回転させたい場合は、Math.PI * 0.5 を使用してください。45 度回転する場合は、Math.PI * 0.25 などを使用します。

角度、円、回転に関するほとんどの計算は、ラジアンで考え始めると非常に簡単に機能します。ぜひお試しください。UI 表示の場合を除き、角度ではなくラジアンを使用します。

WebGL 2D スケール

スケーリングは平行移動と同じくらい簡単です。

掲載順位に目盛りを掛けます。前のサンプルからの変更点は次のとおりです。

<script id="2d-vertex-shader" type="x-shader/x-vertex">
attribute vec2 a_position;

uniform vec2 u_resolution;
uniform vec2 u_translation;
uniform vec2 u_rotation;
uniform vec2 u_scale;

void main() {
  // Scale the positon
  vec2 scaledPosition = a_position * u_scale;

  // Rotate the position
  vec2 rotatedPosition = vec2(
     scaledPosition.x * u_rotation.y +
        scaledPosition.y * u_rotation.x,
     scaledPosition.y * u_rotation.y -
        scaledPosition.x * u_rotation.x);

  // Add in the translation.
  vec2 position = rotatedPosition + u_translation;

描画時のスケール設定に必要な JavaScript を追加します。

  ...
  var scaleLocation = gl.getUniformLocation(program, "u_scale");
  ...
  var scale = [1, 1];
  ...
  // Draw the scene.
  function drawScene() {
    // Clear the canvas.
    gl.clear(gl.COLOR_BUFFER_BIT);

    // Set the translation.
    gl.uniform2fv(translationLocation, translation);

    // Set the rotation.
    gl.uniform2fv(rotationLocation, rotation);

    // Set the scale.
    gl.uniform2fv(scaleLocation, scale);

    // Draw the rectangle.
    gl.drawArrays(gl.TRIANGLES, 0, 18);
  }

負の値でスケーリングするとジオメトリが反転することに注意してください。この最後の 3 つの章が、平行移動、回転、拡大縮小の理解のお役に立てば幸いです。次に、これら 3 つすべてを、よりシンプルで、多くの場合、より有用な形式に組み合わせる行列について説明します。

「F」が必要な理由

テクスチャ上で「F」が使用されているのを初めて見たのは、「F」自体は重要ではありません。重要なのは、どの方向からでもデバイスの向きがわかるということです。たとえば、ハート ❤ や三角形 △ を使用した場合、左右に反転しているかどうかを判断できません。円 ○ の場合、さらに悪化します。色付きの四角形は四隅ごとに色を変えても間違いなく機能しますが、どの角がどの角だったかを覚えておく必要があります。F の向きはすぐに認識できます。

F 方向

どの図形の向きでも構いませんが、私は「F」を使い始めたばかりです。

WebGL 2D 行列

これまでの 3 つの章では、ジオメトリの移動、回転、スケーリングを行う方法を説明しました。平行移動、回転、拡大縮小は、それぞれ「変換」の一種と見なされます。それぞれの変換にはシェーダーの変更が必要で、3 つの変換はそれぞれ順序に依存していました。

たとえば、縮尺が 2, 1、回転が 30%、移動が 100, 0 の場合などです。

F 回転と平行移動

これが、平行移動が 100,0、回転が 30%、縮尺が 2, 1 の場合です。

F の回転とスケーリング

結果はまったく異なります。さらに悪いことに、2 番目の例が必要な場合は、平行移動、回転、スケーリングを新しい順序に適用する別のシェーダーを作成する必要があります。私より頭がいい人もいるけど、行列計算でも同じことができるとわかったの。2D には 3x3 行列を使用します。3x3 行列は 9 つのボックスからなるグリッドのようなものです。

1.0 2.0 3.0
4.0 5.0 6.0
7.0 8.0 9.0

計算のために、行列の各列の位置を乗算して、その結果を合計します。位置には x と y の 2 つの値しかありませんが、この計算を行うには 3 つの値が必要なので、3 つ目の値には 1 を使用します。この場合、結果は次のようになります。

newX = x * 1.0 + y * 4.0 + 1 * 7.0

newY = x * 2.0 + y * 5.0 + 1 * 8.0

extra = x * 3.0 + y * 6.0 + 1 * 9.0

あなたはこれを見て「要点」と考えているのではないでしょうか。翻訳があるとします。翻訳する数値を tx と ty を呼び出します。次のような行列を

1.00.00.0
0.01.00.0
txty1.0

さっそく

newX = x * 1.0 + y * 0.0 + 1 * tx

newY = x * 0.0 + y * 1.0 + 1 * ty

extra = x * 0.0 + y * 0.0 + 1 * 1

代数を覚えているなら、ゼロを乗算する場所はどれでも削除できます。1 をかけても実質的には何も起こらないので、単純化して何が起こっているかを確認しましょう。

newX = x + tx;
newY = y + ty;

それ以外は重要ではありませんこれは、翻訳例の翻訳コードとよく似ています。同様に回転を行います回転に関する投稿で説明したように、回転させる角度のサインとコサインが必要です。

s = Math.sin(angleToRotateInRadians);
c = Math.cos(angleToRotateInRadians);

そして、このようなマトリックスを

c-s0.0
sc0.0
0.00.01.0

行列を適用すると次のようになります。

newX = x * c + y * s + 1 * 0

newY = x * -s + y * c + 1 * 0

extra = x * 0.0 + y * 0.0 + 1 * 1

0 と 1 の乗算をすべて黒く塗りつぶすと、

newX = x *  c + y * s;
newY = x * -s + y * c;

これが、先ほどのローテーションのサンプルとまったく同じです。 最後はスケーリングですスケール係数を sx と sy の 2 つとします

Sx0.00.0
0.0sy0.0
0.00.01.0

行列を適用すると次のようになります。

newX = x * sx + y * 0 + 1 * 0

newY = x * 0 + y * sy + 1 * 0

extra = x * 0.0 + y * 0.0 + 1 * 1

これは本当に

newX = x * sx;
newY = y * sy;

これはスケーリング サンプルと同じです。 まだ考えていらっしゃるのかと思います。では、どうすればよいでしょうか。どういうこと?以前と同じことをやるためだけに、たくさんの労力がかかっているように思えるかもしれません。そこで重要になるのが魔法です。行列を乗算して、すべての変換を一度に適用できることがわかりました。たとえば、2 つの行列を乗算して結果を返す関数 matrixMultiply があるとします。わかりやすくするために、平行移動、回転、拡大縮小の行列を作成する関数を作成してみましょう。

function makeTranslation(tx, ty) {
  return [
    1, 0, 0,
    0, 1, 0,
    tx, ty, 1
  ];
}

function makeRotation(angleInRadians) {
  var c = Math.cos(angleInRadians);
  var s = Math.sin(angleInRadians);
  return [
    c,-s, 0,
    s, c, 0,
    0, 0, 1
  ];
}

function makeScale(sx, sy) {
  return [
    sx, 0, 0,
    0, sy, 0,
    0, 0, 1
  ];
}

それでは、シェーダーを変更しましょう。以前のシェーダー

<script id="2d-vertex-shader" type="x-shader/x-vertex">
attribute vec2 a_position;

uniform vec2 u_resolution;
uniform vec2 u_translation;
uniform vec2 u_rotation;
uniform vec2 u_scale;

void main() {
  // Scale the positon
  vec2 scaledPosition = a_position * u_scale;

  // Rotate the position
  vec2 rotatedPosition = vec2(
     scaledPosition.x * u_rotation.y + scaledPosition.y * u_rotation.x,
     scaledPosition.y * u_rotation.y - scaledPosition.x * u_rotation.x);

  // Add in the translation.
  vec2 position = rotatedPosition + u_translation;
  ...

新しいシェーダーははるかにシンプルになります。

<script id="2d-vertex-shader" type="x-shader/x-vertex">
attribute vec2 a_position;

uniform vec2 u_resolution;
uniform mat3 u_matrix;

void main() {
  // Multiply the position by the matrix.
  vec2 position = (u_matrix * vec3(a_position, 1)).xy;
  ...

これを

  // Draw the scene.
  function drawScene() {
    // Clear the canvas.
    gl.clear(gl.COLOR_BUFFER_BIT);

    // Compute the matrices
    var translationMatrix =
       makeTranslation(translation[0], translation[1]);
    var rotationMatrix = makeRotation(angleInRadians);
    var scaleMatrix = makeScale(scale[0], scale[1]);

    // Multiply the matrices.
    var matrix = matrixMultiply(scaleMatrix, rotationMatrix);
    matrix = matrixMultiply(matrix, translationMatrix);

    // Set the matrix.
    gl.uniformMatrix3fv(matrixLocation, false, matrix);

    // Draw the rectangle.
    gl.drawArrays(gl.TRIANGLES, 0, 18);
  }

では、どうすればよいのでしょうか。あまりメリットがないように思えますが、ただし、順序を変更する場合でも、新しいシェーダーを作成する必要はありません。計算を変えるだけで済みます。

    ...
    // Multiply the matrices.
    var matrix = matrixMultiply(translationMatrix, rotationMatrix);
    matrix = matrixMultiply(matrix, scaleMatrix);
    ...

このような行列を適用できることは、体の腕、太陽の周りの惑星の月、木の枝のような階層アニメーションでは特に重要です。階層アニメーションの簡単な例として、「F」を 5 回描画しますが、毎回前の「F」の行列から始めることができます。

  // Draw the scene.
  function drawScene() {
    // Clear the canvas.
    gl.clear(gl.COLOR_BUFFER_BIT);

    // Compute the matrices
    var translationMatrix = makeTranslation(translation[0], translation[1]);
    var rotationMatrix = makeRotation(angleInRadians);
    var scaleMatrix = makeScale(scale[0], scale[1]);

    // Starting Matrix.
    var matrix = makeIdentity();

    for (var i = 0; i < 5; ++i) {
      // Multiply the matrices.
      matrix = matrixMultiply(matrix, scaleMatrix);
      matrix = matrixMultiply(matrix, rotationMatrix);
      matrix = matrixMultiply(matrix, translationMatrix);

      // Set the matrix.
      gl.uniformMatrix3fv(matrixLocation, false, matrix);

      // Draw the geometry.
      gl.drawArrays(gl.TRIANGLES, 0, 18);
    }
  }

そのために、単位行列を作成する関数 makeIdentity を導入しました。単位行列は 1.0 を効果的に表す行列であるため、単位を掛けても何も起こりません。まるで

X * 1 = X

同様

matrixX * identity = matrixX

単位行列を作成するコードは次のとおりです。

function makeIdentity() {
  return [
    1, 0, 0,
    0, 1, 0,
    0, 0, 1
  ];
}

もう一つの例は、これまでのすべてのサンプルで、「F」は左上隅を中心として回転しています。これは、使用している計算が常に原点を中心に回転し、「F」の左上隅が原点(0, 0)にあるためです。ただし、行列計算を実行し、変換を適用する順序を選択できるため、残りの変換が適用される前に原点を移動できます。

    // make a matrix that will move the origin of the 'F' to
    // its center.
    var moveOriginMatrix = makeTranslation(-50, -75);
    ...

    // Multiply the matrices.
    var matrix = matrixMultiply(moveOriginMatrix, scaleMatrix);
    matrix = matrixMultiply(matrix, rotationMatrix);
    matrix = matrixMultiply(matrix, translationMatrix);

この手法を使用すると、任意のポイントから回転やスケーリングを行うことができます。これで、Photoshop や Flash で回転ポイントを移動する方法がわかりました。 もっと大げさにやってみましょう。WebGL の基礎に関する最初の記事では、シェーダーに次のようなコードでピクセルからクリップ空間に変換していることを覚えているかもしれません。

  ...
  // convert the rectangle from pixels to 0.0 to 1.0
  vec2 zeroToOne = position / u_resolution;

  // convert from 0->1 to 0->2
  vec2 zeroToTwo = zeroToOne * 2.0;

  // convert from 0->2 to -1->+1 (clipspace)
  vec2 clipSpace = zeroToTwo - 1.0;

  gl_Position = vec4(clipSpace * vec2(1, -1), 0, 1);

各ステップを順番に見ていくと、最初のステップ「ピクセルを 0.0 から 1.0 に変換する」というステップは、実際にはスケール操作です。2 つ目はスケーリング オペレーションです。次は平行移動で、最後は Y を -1 でスケーリングします。これは、シェーダーに渡す行列ですべて行うことができます。2 つのスケール行列を作成し、1 つは解像度 1.0 でスケール、もう 1 つは 2.0 でスケール、3 つ目は -1.0、-1.0 で変換し、4 つ目は Y を -1 で変換してから、すべてを乗算します。ただし、数学が単純であるため、特定の解像度に対して「射影」行列を直接作成する関数を作成します。

function make2DProjection(width, height) {
  // Note: This matrix flips the Y axis so that 0 is at the top.
  return [
    2 / width, 0, 0,
    0, -2 / height, 0,
    -1, 1, 1
  ];
}

これで、シェーダーをさらに簡素化できるようになりました。新しい頂点シェーダーの全体は次のとおりです。

<script id="2d-vertex-shader" type="x-shader/x-vertex">
attribute vec2 a_position;

uniform mat3 u_matrix;

void main() {
  // Multiply the position by the matrix.
  gl_Position = vec4((u_matrix * vec3(a_position, 1)).xy, 0, 1);
}
</script>

JavaScript では、射影行列を乗算する必要がある

  // Draw the scene.
  function drawScene() {
    ...
    // Compute the matrices
    var projectionMatrix =
       make2DProjection(canvas.width, canvas.height);
    ...

    // Multiply the matrices.
    var matrix = matrixMultiply(scaleMatrix, rotationMatrix);
    matrix = matrixMultiply(matrix, translationMatrix);
    matrix = matrixMultiply(matrix, projectionMatrix);
    ...
  }

また、解像度を設定するコードも削除しました。この最後のステップで、6 ~ 7 ステップのかなり複雑なシェーダーから、1 ステップのみの非常にシンプルなシェーダーに、行列計算の魔法を活かせるものになりました。

この記事が行列計算の謎を解明する一助になれば幸いです。次は 3D に進みましょう。3D 行列の計算も同じ原則と使用方法に従います。わかりやすくするために 2D から始めました。