目次

webgpufundamentals.org

Fix, Fork, Contribute

inter-stage(シェーダ間)変数

前回の「基本」と題した記事では、 WebGPUの、とてもとても基本的な事柄について説明しました。 今回は、inter-stage変数について、普通に基本的な事柄について説明します。

inter-stage変数が登場するのは、頂点シェーダとフラグメントシェーダの間です。

頂点シェーダが3点の座標値を出力すると、三角形がラスタライズ(ピクセルとして描画)されます。 頂点シェーダはこの「位置を表す座標値」のほかに、何がしかの情報を出力することができます。 この情報は各頂点と結びついており、デフォルトでは、 3点の間でグラデーションのように補間(interpolate)されます。

前回の記事で使った「三角形を描くシェーダ」を改造して、inter-stage変数を使ってみましょう。 今回改造するのは、このシェーダ部分だけです。

  const module = device.createShaderModule({
-    label: 'our hardcoded red triangle shaders',
+    label: 'our hardcoded rgb triangle shaders',
    code: `
+      struct OurVertexShaderOutput {
+        @builtin(position) position: vec4f,
+        @location(0) color: vec4f,
+      };

      @vertex fn vs(
        @builtin(vertex_index) vertexIndex : u32
-      ) -> @builtin(position) vec4f {
+      ) -> OurVertexShaderOutput {
        let pos = array(
          vec2f( 0.0,  0.5),  // top center
          vec2f(-0.5, -0.5),  // bottom left
          vec2f( 0.5, -0.5)   // bottom right
        );
+        var color = array<vec4f, 3>(
+          vec4f(1, 0, 0, 1), // red
+          vec4f(0, 1, 0, 1), // green
+          vec4f(0, 0, 1, 1), // blue
+        );

-        return vec4f(pos[vertexIndex], 0.0, 1.0);
+        var vsOutput: OurVertexShaderOutput;
+        vsOutput.position = vec4f(pos[vertexIndex], 0.0, 1.0);
+        vsOutput.color = color[vertexIndex];
+        return vsOutput;
      }

-      @fragment fn fs() -> @location(0) vec4f {
-        return vec4f(1, 0, 0, 1);
+      @fragment fn fs(fsInput: OurVertexShaderOutput) -> @location(0) vec4f {
+        return fsInput.color;
      }
    `,
  });

最初にstruct(構造体)を宣言しています。 これはinter-stage変数を使うための簡単な方法です。 このstructを介して、頂点シェーダとフラグメントシェーダの間で データの受け渡しをすることができます。

      struct OurVertexShaderOutput {
        @builtin(position) position: vec4f,
        @location(0) color: vec4f,
      };

頂点シェーダはは返り値としてvec4f型変数を返していましたが、 これを、構造体OurVertexShaderOutputを返すように変更します。

      @vertex fn vs(
        @builtin(vertex_index) vertexIndex : u32
-      ) -> @builtin(position) vec4f {
+      ) -> OurVertexShaderOutput {

三つの色を表す配列を用意します。

        var color = array<vec4f, 3>(
          vec4f(1, 0, 0, 1), // 赤
          vec4f(0, 1, 0, 1), // 緑
          vec4f(0, 0, 1, 1), // 青
        );

返り値として、位置を表すvec4fではなく、位置と色の情報を持つ構造体を返すようにします。 そのために、まず構造体のインスタンスを宣言します。 構造体の各項目に値が設定できたら、それをreturnします。

-        return vec4f(pos[vertexIndex], 0.0, 1.0);
+        var vsOutput: OurVertexShaderOutput;
+        vsOutput.position = vec4f(pos[vertexIndex], 0.0, 1.0);
+        vsOutput.color = color[vertexIndex];
+        return vsOutput;

フラグメントシェーダはこの構造体を、関数の引数として受け取るように変更します。

      @fragment fn fs(fsInput: OurVertexShaderOutput) -> @location(0) vec4f {
        return fsInput.color;
      }

フラグメントシェーダの返り値は、色です。受け取った構造体の中に入っています。

これを実行します。頂点シェーダは呼ばれるたびに赤、緑、青を、それぞれの頂点の情報として返します。 3つの点を結ぶ三角形を構成する各ピクセルを描くひとつひとつのフラグメントシェーダは、 3つの点の間で補間(interpolate)された色を受け取ります。

inter-stage変数は、主に三角形中のテクスチャ座標の補間のために利用されます。 これについては「テクスチャについて」で説明します。 ほかの用途としては、三角形の法線情報の補間があります。 これについては、光源処理に関する記事の1本目、 「平行光源について」で扱います。

inter-stage変数はlocationで結び付けられる

頂点シェーダとフラグメントシェーダの間での情報の受け渡しは、インデックスを介して行われる、という点に注意してください。 インデックスを介したデータの受け渡し、という考え方は、inter-stage変数に限らずWebGPUの多くの場面で登場します。 inter-stage変数の場合、locationインデックスが使用されます。

「インデックスを介した」というのがどういうことか説明するために、試しにフラグメントシェーダ「だけ」変更してみます。 構造体OurVertexShaderOutputではなく、location(0)vec4fを受け取るようにします。

      @fragment fn fs(@location(0) color: vec4f) -> @location(0) vec4f {
        return color;
      }

上のようなコードに変更しても、挙動は変わらずに動作します。 構造体や、変数の名前や、引数の記述順、ではなく、 location(0)として明示的に指定したインデックスによって関係づけられている、ということです。

@builtin(position)の仕組み

これを踏まえて、@builtin(position)について考えてみます。 今使用しているサンプルプログラムでは、頂点シェーダとフラグメントシェーダで、共通の構造体を使っています。 この構造体にはpositionというフィールドがあります。 positionにはlocationのインデックス情報がなく、替わりに@builtin(position)と宣言されています。

      struct OurVertexShaderOutput {
*        @builtin(position) position: vec4f,
        @location(0) color: vec4f,
      };

この構造体のpositionフィールドはinter-stage変数ではありませんbuiltinです。

@builtin(position)と記述されたものは、頂点シェーダとフラグメントシェーダで、違った解釈がされます。

頂点シェーダにおいては、@builtin(position)とは出力で、GPUが三角形/線/点を描くために必要とする座標情報です。

フラグメントシェーダにおいては、@builtin(position)は入力で、フラグメントシェーダが色を決めるべきピクセル、の座標情報です。 これは「ピクセル座標」です。

ピクセル座標は、ピクセルが構成する四角形の一端の頂点を(0,0)、同ピクセルの対角の頂点を(1,1)としています。 そして、フラグメントシェーダに渡されるのは、各ピクセルの中央の座標値です。 描画対象とするテクスチャが3x2ピクセルのサイズである場合、座標は次の図のようになります。

このピクセル座標によって色が決まるようなシェーダを書くこともできます。 例として、市松模様(checkerboard)を描いてみます。

  const module = device.createShaderModule({
    label: 'our hardcoded checkerboard triangle shaders',
    code: `
      struct OurVertexShaderOutput {
        @builtin(position) position: vec4f,
-        @location(0) color: vec4f,
      };

      @vertex fn vs(
        @builtin(vertex_index) vertexIndex : u32
      ) -> OurVertexShaderOutput {
        let pos = array(
          vec2f( 0.0,  0.5),  // top center
          vec2f(-0.5, -0.5),  // bottom left
          vec2f( 0.5, -0.5)   // bottom right
        );
-        var color = array<vec4f, 3>(
-          vec4f(1, 0, 0, 1), // red
-          vec4f(0, 1, 0, 1), // green
-          vec4f(0, 0, 1, 1), // blue
-        );

        var vsOutput: OurVertexShaderOutput;
        vsOutput.position = vec4f(pos[vertexIndex], 0.0, 1.0);
-        vsOutput.color = color[vertexIndex];
        return vsOutput;
      }

      @fragment fn fs(fsInput: OurVertexShaderOutput) -> @location(0) vec4f {
-        return fsInput.color;
+        let red = vec4f(1, 0, 0, 1);
+        let cyan = vec4f(0, 1, 1, 1);
+
+        let grid = vec2u(fsInput.position.xy) / 8;
+        let checker = (grid.x + grid.y) % 2 == 1;
+
+        return select(red, cyan, checker);
      }
    `,
  });

上のコードではfsInput.positionを使っています。 この構造体フィールドは@builtin(position)として宣言されています。 このvec4f型の値から、xy、つまりvec2f型の座標値を取り出して、それをvec2u型の値に変換しています。 vec2uは符号なし整数値を2つ持つベクトルです。 これを8で割って、「8ピクセルごとに変化する」カウンタにしています。 このgridの座標xyを足して% 2(2で割った余り)を求めて、0か1の値を得ています。 その値が1ならtrue、0ならfalse、となる真偽値(boolean)としています。 この真偽値を元に、WGSLの組み込み関数selectを使って、redcyanのどちらかの値を選択しています。 WGSLのselect関数の仕組みは、JavaScriptで表現するならこんな感じです (下のJavaScriptコードが難解という人は「アロー関数」、「三項演算子」を調べてください)。

// 条件(condition)がfalseなら`a`の値を、そうでなければ`b`の値を返す。
select = (a, b, condition) => condition ? b : a;

@builtin(position)をフラグメントシェーダ側のコード中で使わない場合でも、 頂点シェーダとフラグメントシェーダで「共通の構造体」が使える、というのは便利です。 一方で、共通の構造体を使っていても、頂点シェーダから見たpositionフィールドと フラグメントシェーダから見たpositionフィールドは「関係がない、別の変数である」 ということは重要です。

inter-stage変数の本質は、ロケーション、@location(?)の部分です。 重要なのは「ロケーションを合わせること」なので、 「頂点シェーダの出力」と「フラグメントシェーダの入力」で同じ構造体を使う必要はなく、 それぞれで別の書き方をするのはそれほど珍しいことではありません。

このことは、頂点シェーダ、フラグメントシェーダのソースコードが別々になっている場合を考えるとわかりやすいでしょう。

-  const module = device.createShaderModule({
-    label: 'hardcoded checkerboard triangle shaders',
+  const vsModule = device.createShaderModule({
+    label: 'hardcoded triangle',
    code: `
      struct OurVertexShaderOutput {
        @builtin(position) position: vec4f,
      };

      @vertex fn vs(
        @builtin(vertex_index) vertexIndex : u32
      ) -> OurVertexShaderOutput {
        let pos = array(
          vec2f( 0.0,  0.5),  // top center
          vec2f(-0.5, -0.5),  // bottom left
          vec2f( 0.5, -0.5)   // bottom right
        );

        var vsOutput: OurVertexShaderOutput;
        vsOutput.position = vec4f(pos[vertexIndex], 0.0, 1.0);
        return vsOutput;
      }
+    `,
+  });
+
+  const fsModule = device.createShaderModule({
+    label: 'checkerboard',
+    code: `
-      @fragment fn fs(@builtin(position) pixelPosition: vec4f) -> @location(0) vec4f {
+      @fragment fn fs(@builtin(position) pixelPosition: vec4f) -> @location(0) vec4f {
        let red = vec4f(1, 0, 0, 1);
        let cyan = vec4f(0, 1, 1, 1);

-        let grid = vec2u(fsInput.position.xy) / 8;
+        let grid = vec2u(pixelPosition.xy) / 8;
        let checker = (grid.x + grid.y) % 2 == 1;

        return select(red, cyan, checker);
      }
    `,
  });

シェーダモジュールが2つになるので、パイプラインの生成コードをそれに合わせて修正します。

  const pipeline = device.createRenderPipeline({
    label: 'hardcoded checkerboard triangle pipeline',
    layout: 'auto',
    vertex: {
-      module,
+      module: vsModule,
    },
    fragment: {
-      module,
+      module: fsModule,
      targets: [{ format: presentationFormat }],
    },
  });

以上の変更後も、同じように動作します。

WebGPUのサンプルプログラムではよく、2つのシェーダで1つの構造体を利用する書き方をします。 が、これは「便利だから」という以上の意味はない、というのが、この話題のポイントです。 実際のところ、この点においてはWebGPUは「WGSLが文法上正しいか」のチェックしかしません。 WebGPUは次に、指定したentryPointを確認しますが、これもentryPointがどこか、以上のチェックはしません。 頂点シェーダとフラグメントシェーダで@builtin(position)が別々の解釈をされても問題ありません。

プログラマの視点からすると、構造体やバインドグループの記述を各シェーダについて別々に書かずに済むのは、便利です。 一方で、WebGPUの視点からすると、構造体やバインドグループの記述は各シェーダそれぞれに書かれているように解釈されます。 ソースコード的には同じことが書かれていても、それぞれのシェーダで違った解釈がされる。それでも問題ない。ということです。

注:今回、市松模様を描くために@builtin(position)を使っていますが、 これはあまり一般的なやり方ではありません。 市松模様に限らず、何がしかのパターンを描きたいとき、通常はテクスチャの仕組みを利用します。 これについては別の記事、「テクスチャについて」で説明します。 今回のやり方に問題があることは、ウィンドウサイズを変えてみると観察することができます。 市松模様の「パターンの大きさ」はピクセル座標の値を元にしているので、 描画される三角形の大きさとは関係なく、canvasの解像度に依存して決定されます。

「補間(interpolation)方法」の設定

ここまで、inter-stage変数について見てきました。 inter-stage変数は、頂点シェーダで出力されて、「補間」されて、フラグメントシェーダに渡されます。 WebGPUでは、この「補間」をどうやるかについて、設定項目が2つあります。 「補間」については、デフォルトの設定以外で使いたいケースはほとんどありません。 そういった特殊ケースについては、別の記事で触れます。

補間タイプの設定:

  • perspective: perspective correctで(3次元のパースに合うように)補間する(default)
  • linear: linearに(線形補間的に)、perspective correctでない形で補間する
  • flat: 補間しない。補間サンプリングを行なわない

補間サンプリングの設定:

  • center: 補間に際して「ピクセルの中心」をサンプリングする(default)
  • centroid: 補間に際して「プリミティブ(三角形などの基本図形)」単位でサンプリングする。プリミティブ内の全ピクセルで同じ値となる
  • sample: 補間を「サンプル」単位で行なう。フラグメントシェーダは各サンプルについて実行される
  • first: Used only with type = flat. (default) The value comes from the first vertex of the primitive being drawn
  • either: Used only with type = flat. The value comes from either the first or the last vertex of the primitive being drawn.

補間の設定は、inter-stage変数の属性として記述します。たとえばこんな風に書きます。

  @location(2) @interpolate(linear, center) myVariableFoo: vec4f;
  @location(3) @interpolate(flat) myVariableBar: vec4f;

なお、inter-stage変数が整数型の場合は、flatの設定にする必要があります。

補間タイプflatに設定した場合、フラグメントシェーダに渡される値は、 「三角形の1つめの頂点」のinter-stage変数の値です。

次の記事「uniform変数」では、 シェーダにデータを渡す、別な方法を紹介します。

問題点/バグ? githubでissueを作成.
comments powered by Disqus